Git 설명서 - (7) 브랜치의 개념

|

브랜치

Git을 사용하는 가장 큰 이유 중 하나는 ‘브랜치(Branch)’ 때문이라고 할 수 있습니다. 사실 저도 브랜치 사용하는 법은 익숙하지 않아서 잘 사용하지 못하는데, Git에서는 브랜치 활용을 적극적으로 추천하고 있습니다.

심지어 혼자서 개발을 하더라도, 하루에 수십 번씩 브랜치를 생성하더라도 브랜치 활용을 적극적으로 하기를 추천하고 있습니다.


Git의 브랜치

Git은 소스 관리를 이전 버전과 현재 버전과의 차이점(Diff)으로 저장하는 것이 아닌, 해당 시점의 스냅샷(Snapshot)으로 저장하고 있습니다.

그리고 소스를 commit 하게 되면 이전 commit과의 연결 포인터를 저장하고 있습니다. 따라서 다음 그림처럼 각 commit 간의 이동이 자유롭습니다.

image

Git에서의 브랜치는 각 commit 간 이동을 쉽게 해줄 수 있는 포인터라고 생각하면 됩니다. Git은 기본적으로 master 브랜치를 만들어줍니다.

image

master 브랜치는 가장 마지막 commit을 가리키고 있습니다.


브랜치 생성

새로운 브랜치를 생성해봅니다.

$ git branch testing

image

위 그림처럼 새로 만든 브랜치도 기본적으로 가장 마지막의 commit을 가리키게 됩니다.


HEAD 브랜치

Git에서 HEAD 브랜치는 현재 작업중인(checkout 상태인) 브랜치를 가리키는 특수한 포인터입니다.

위에서 ‘testing’ 이라는 브랜치를 새로 생성하긴 했지만, ‘checkout’을 하지 않았기 때문에 HEAD 브랜치는 여전히 master 브랜치를 가리키고 있습니다.

image

여기서 ‘testing’ 브랜치로 checkout을 하게 되면 다음과 같이 HEAD 브랜치의 위치가 옮겨가게 됩니다.

image

여기서 ‘testing’ 브랜치에 새로운 commit을 하면 소스 트리는 다음과 같은 형태가 됩니다.

image

이 상태에서 다시 master 브랜치로 checkout을 하면 HEAD 브랜치의 위치가 옮겨집니다.

image

즉, 이렇게 checkout을 이용해서 각 브랜치간 이동을 자유롭고 간편하게 할 수 있습니다.

만약, 이 상태에서 master 브랜치에서 새로운 작업을 commit 하게 되면 소스 트리는 다음과 같은 형태가 될 것입니다.

image


Git 브랜치의 장점

보통 다른 버전 관리 시스템에서는 브랜치를 생성하게 되면 해당 버전의 파일들을 통째로 복사하기 때문에 속도도 느리고 용량도 많이 차지하게 됩니다.

하지만, Git에서 브랜치는 각 commit을 가리키는 40 글자의 SHA-1 체크섬 파일에 불과합니다. 즉, 아주 가볍습니다. 그래서 브랜치 생성이나 브랜치간 이동도 아주 자유롭고 빠릅니다. 또한 Git의 브랜치는 이전 버전의 commit 포인터를 갖고 있기 때문에 나중에 소스 정합(Merge)를 할 때도 수월하고 편하게 작업할 수 있는 장점이 있습니다.

최근 실행 이력(Multitask)에서 내 앱 숨기기

|

안드로이드에서 멀티태스킹 버튼을 누르면 최근 실행한 앱들 이력이 좌르르 나옵니다.

여기에 표시되는 내 앱이 표시되지 않도록 하는 방법은 다음과 같습니다.

AndroidManifest.xml 파일의 액티비티 속성에 다음과 같은 항목을 추가해주면 됩니다.

<activity
      android:label="@string/app_name"
      android:excludeFromRecents="true">
</activity>

모듈간 AIDL 통신 예제

|

안드로이드 스튜디오(Android Studio)에서 모듈간 AIDL 통신을 하는 예제 코드입니다. 기존에 Eclipse에서 프로젝트간 AIDL 통신을 할 때에 비해 약간의 차이점이 있긴 했습니다. (예를 들어 Android Studio에서는 aidl 전용 폴더를 만들어서 따로 관리한다던지 등)

여기서는 화면이 존재하지 않는 서비스(Service) 모듈과 별도의 어플리케이션간 AIDL 통신을 하는 코드를 다뤄 보겠습니다.


3개의 모듈 생성

먼저 안드로이드 스튜디오에서 3개의 모듈을 생성합니다. 각각의 모듈의 역할은 다음과 같습니다.

  • app : 실제 Activity 등이 있는 어플리케이션
  • sdk : AIDL 통신을 쉽게 할 수 있도록 도와주는 라이브러리
  • service : GUI가 존재하지 않는 서비스

여기서 sdk 모듈은 따로 존재하지 않아도 됩니다. 하지만, AIDL 통신 부분은 처음 접하는 사람들에게는 다소 어려움이 존재하기 때문에 AIDL을 전혀 모르는 사람들도 쉽게 사용할 수 있도록 Wrapper 클래스화 시키는 것이 좋은 것 같습니다.


종속성 설정

각 모듈간 종속성은 다음과 같습니다.

  • app 모듈은 sdk 모듈을 참조합니다.
  • service 모듈은 sdk 모듈을 참조합니다.

즉, app 모듈과 service 모듈의 build.gradle 파일에 다음 코드를 추가해줍니다.

dependencies {
    ...
    compile project(':snowsdk');
}


AIDL 파일 생성

sdk 모듈의 New 메뉴에서 ‘Folder > AIDL’을 선택하면 AIDL 폴더가 만들어집니다.

Image

그리고 그 안에 패키지 이름까지 지정해서 하위 폴더를 만듭니다.

Image


다음은 예제로 만들어 본 aidl 파일입니다. 어플리케이션에서 서비스로 메세지를 전송하는 IServiceInterface.aidl과 그 반대 방향으로 콜백(Callback) 이벤트를 알려주는 용도의 IServiceCallback.aidl 파일 2개를 만들었습니다.

IServiceInterface.aidl

package snowdeer.sdk;
import snowdeer.sdk.IServiceCallback;

interface IServiceInterface {
    boolean registerCallback(in IServiceCallback cb);
    boolean unregisterCallback(in IServiceCallback cb);

    void sendMessage(int what, in Bundle bundle);
}


IServiceCallback.aidl

package snowdeer.sdk;

oneway interface IServiceCallback {
    void onMessageReceived(int what, in Bundle bundle);
}


Message 상수값 정의

굳이 이벤트 메세지의 상수값을 정의할 필요없이 필요한 API 개수만큼 aidl 코드안에 정의하는 편이 더 좋을 때도 있습니다.

다만, API를 앞으로 빈번하게 수정해야 할 경우가 있다면 메세지 상수값을 정의해놓고 향후 상수값만 추가하면서 업데이트해나가는 편이 더 수월하더군요.

SNOW_EVENT_MESSAGE.java

package snowdeer.sdk.constant;

public class SNOW_EVENT_MESSAGE {
  public static final int SAY_HELLO = 101;
  public static final int SAY_GOOD_BYE= 102;
}


SNOW_RESPONSE_MESSAGE.java

package snowdeer.sdk.constant;

public class SNOW_RESPONSE_MESSAGE {
  // TODO
}


Java 소스 구현

이제 위에서 만든 aidl 파일들을 이용해서 쉽게 통신할 수 있도록 인터페이스를 만들도록 합니다.

가급적 클래스(class)가 아닌 인터페이스(interface)를 사용하는 편이 더 좋기 때문에 인터페이스로 만들도록 하겠습니다.

SnowInterface.java

package snowdeer.sdk;

public interface SnowInterface {
  public interface OnEventListener {
    void onHelloRequested();
  }

  public void setOnEventListener(OnEventListener listener);

  void hello();
  void goodBye();
}


SnowInterfaceImpl.java

package snowdeer.sdk.impl;

import android.os.Bundle;
import android.os.RemoteException;
import android.util.Log;
import snowdeer.sdk.IServiceCallback;
import snowdeer.sdk.IServiceInterface;
import snowdeer.sdk.SnowInterface;
import snowdeer.sdk.constant.SNOW_EVENT_MESSAGE;

public class SnowInterfaceImpl implements SnowInterface {

  OnEventListener mOnEventListener;
  IServiceInterface mServiceInterface;

  public void setOnEventListener(OnEventListener listener) {
    mOnEventListener = listener;
  }

  public SnowInterfaceImpl(IServiceInterface iface) {
    mServiceInterface = iface;
  }

  public IServiceCallback getServiceCallback() {
    return mServiceCallback;
  }

  IServiceCallback.Stub mServiceCallback = new IServiceCallback.Stub() {

    @Override
    public void onMessageReceived(int what, Bundle bundle) throws RemoteException {
      switch (what) {
        // TODO
      }
    }
  };

  @Override
  public void hello() {
    Log.i("snowdeer", "[snowdeer] Hello!");
    try {
      Bundle bundle = new Bundle();
      mServiceInterface.sendMessage(SNOW_EVENT_MESSAGE.SAY_HELLO, bundle);
    } catch (Exception e) {
      e.printStackTrace();
    }
  }

  @Override
  public void goodBye() {
    Log.i("snowdeer", "[snowdeer] Good Bye!");
    try {
      Bundle bundle = new Bundle();
      mServiceInterface.sendMessage(SNOW_EVENT_MESSAGE.SAY_GOOD_BYE, bundle);
    } catch (Exception e) {
      e.printStackTrace();
    }
  }
}


ServiceProvider 구현

위에서 SnowInterface.javaSnowInterfaceImpl.java 코드를 만들었다면 이 인스턴스를 획득할 수 있는 ServiceProvider가 필요합니다. 아래와 같이 구현할 수 있습니다.

SnowServiceProvider.java

package snowdeer.sdk;

import android.content.Context;
import snowdeer.sdk.impl.SnowServiceProviderImpl;

public final class SnowServiceProvider {
  private SnowServiceProvider() {}

  public interface OnServiceEventListener {
    void onConnected(SnowInterface iface);
    void onDisconnected();
  }

  public void setOnServiceEventListener(OnServiceEventListener listener) {
    SnowServiceProviderImpl.setOnServiceEventListener(listener);
  }

  public static boolean connect(Context context) {
    return SnowServiceProviderImpl.connect(context);
  }

  public static void disconnect(Context context) {
    SnowServiceProviderImpl.disconnect(context);
  }
}


SnowServiceProviderImpl.java

package snowdeer.sdk.impl;

import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.os.IBinder;
import android.os.RemoteException;
import snowdeer.sdk.IServiceInterface;
import snowdeer.sdk.SnowServiceProvider.OnServiceEventListener;

public class SnowServiceProviderImpl {
  static final String SERVICE_NAME = "snowdeer.service";
  static final String PACKAGE_NAME = "snowdeer.service";

  static IServiceInterface mIServiceInterface;
  static OnServiceEventListener mOnServiceEventListener;
  static SnowInterfaceImpl mServiceInterfaceImpl;
  static boolean isConnected = false;

  public static void setOnServiceEventListener(OnServiceEventListener listener) {
    mOnServiceEventListener = listener;
  }

  public static boolean connect(Context context) {
    if(isConnected) return false;

    Intent intent = new Intent(SERVICE_NAME);
    intent.setPackage(PACKAGE_NAME);
    return context.bindService(intent, mServiceConnection, Context.BIND_AUTO_CREATE);
  }

  public static void disconnect(Context context) {
    if(!isConnected) return;

    if(mServiceConnection != null) {
      context.unbindService(mServiceConnection);
    }

    isConnected = false;
    if(mOnServiceEventListener != null) {
      mOnServiceEventListener.onDisconnected();
    }
  }

  static final ServiceConnection mServiceConnection = new ServiceConnection() {
    @Override
    public void onServiceConnected(ComponentName name, IBinder service) {
      mIServiceInterface = IServiceInterface.Stub.asInterface(service);
      if(mServiceInterfaceImpl == null) {
        mServiceInterfaceImpl = new SnowInterfaceImpl(mIServiceInterface);
      }

      try {
        mIServiceInterface.registerCallback(mServiceInterfaceImpl.getServiceCallback());
      }
      catch (RemoteException e) {
        e.printStackTrace();
        return;
      }

      if(mOnServiceEventListener != null) {
        mOnServiceEventListener.onConnected(mServiceInterfaceImpl);
      }
    }

    @Override
    public void onServiceDisconnected(ComponentName name) {
      try {
        mIServiceInterface.unregisterCallback(mServiceInterfaceImpl.getServiceCallback());
      }
      catch (RemoteException e) {
        e.printStackTrace();
      }

      if(mOnServiceEventListener != null) {
        mOnServiceEventListener.onDisconnected();
      }

      mServiceInterfaceImpl = null;
      mIServiceInterface = null;
      isConnected = false;
    }
  };
}


이제 SDK 쪽은 끝이 났습니다. AIDL 통신을 하는 서비스 쪽 코드를 살펴보도록 하겠습니다.

서비스 구현

AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
  package="snowdeer.service">

  <application
    android:allowBackup="true"
    android:icon="@mipmap/ic_launcher"
    android:label="@string/app_name"
    android:roundIcon="@mipmap/ic_launcher_round"
    android:supportsRtl="true"
    android:theme="@style/AppTheme">
    <service
      android:enabled="true"
      android:exported="true"
      android:name=".SnowService">
      <intent-filter>
        <action android:name="snowdeer.service" />
        <category android:name="android.intent.category.DEFAULT" />
      </intent-filter>
    </service>
  </application>

</manifest>


SnowService.java

package snowdeer.service;

import android.app.Service;
import android.content.Intent;
import android.os.Bundle;
import android.os.IBinder;
import android.os.RemoteException;
import android.support.annotation.IntDef;
import snowdeer.sdk.IServiceCallback;
import snowdeer.sdk.IServiceInterface;

public class SnowService extends Service {

  public SnowService() {
  }

  @Override
  public void onCreate() {
    super.onCreate();
  }

  @Override
  public IBinder onBind(Intent intent) {
    return mBinder;
  }

  @Override
  public boolean onUnbind(Intent intent) {
    return super.onUnbind(intent);
  }

  @Override
  public int onStartCommand(Intent intent, int flags, int startId) {
    return super.onStartCommand(intent, flags, startId);
  }

  @Override
  public void onDestroy() {
    super.onDestroy();
  }

  final IServiceInterface.Stub mBinder = new IServiceInterface.Stub() {

    @Override
    public boolean registerCallback(IServiceCallback cb) throws RemoteException {
      return EventCallbackHandler.getInstance().registerCallback(cb);
    }

    @Override
    public boolean unregisterCallback(IServiceCallback cb) throws RemoteException {
      return EventCallbackHandler.getInstance().unregisterCallback(cb);
    }

    @Override
    public void sendMessage(int what, Bundle bundle) throws RemoteException {
      switch (what) {
        // TODO
      }
    }
  };
}


EventCallbackHandler.java

package snowdeer.service;

import android.os.Bundle;
import android.os.RemoteCallbackList;
import snowdeer.sdk.IServiceCallback;

public class EventCallbackHandler {

  static EventCallbackHandler mInstance = new EventCallbackHandler();

  private EventCallbackHandler() {}

  public static EventCallbackHandler getInstance() {
    return mInstance;
  }

  final RemoteCallbackList<IServiceCallback> mIServiceCallbackList = new RemoteCallbackList<>();

  public boolean registerCallback(IServiceCallback cb) {
    return mIServiceCallbackList.register(cb);
  }

  public boolean unregisterCallback(IServiceCallback cb) {
    return mIServiceCallbackList.unregister(cb);
  }

  public void sendCallbackEvent(int what, Bundle bundle) {
    int receiverCount = mIServiceCallbackList.beginBroadcast();
    for (int i = 0; i < receiverCount; i++) {
      try {
        IServiceCallback cb = mIServiceCallbackList.getBroadcastItem(i);

        cb.onMessageReceived(what, bundle);
      } catch (Exception e) {
        e.printStackTrace();
      }
    }

    mIServiceCallbackList.finishBroadcast();
  }
}


Git 설명서 - (6) 원격 저장소 연결

|

원격 저장소

여러 명의 인원이 프로젝트를 개발하다보면 결국은 원격 저장소를 활용하게 될 가능성이 높습니다.

원격 저장소 확인

git remote 명령어를 이용해서 현재 프로젝트가 어떤 원격 저장소에 연결되었는지 확인할 수 있습니다.

만약 clone해서 받은 프로젝트라면 폴더에서 다음과 같이 git remote 명령어를 실행해보도록 합시다.

$ git remote
origin

만약 -v 옵션을 이용하면 서버의 저장소 이름과 URL 까지 같이 볼 수 있습니다.

$ git remote -v
origin  https://github.com/snowdeer/snowdeer.android.service.git (fetch)
origin  https://github.com/snowdeer/snowdeer.android.service.git (push)


원격 저장소의 데이터 가져오기

원격 저장소의 데이터를 가져오려면 git fetch [remote name] 명령어를 이용할 수 있습니다. (원격 저장소를 clone 했을 경우, 기본적으로 원격 저장소 이름은 ‘origin’이 됩니다.)

$ git fetch origin

fetch 명령어는 로컬에는 없지만, 원격 저장소에는 있는 데이터를 모두 가져옵니다. 그래서 원격 저장소의 모든 브랜치(Branch)를 로컬에서 접근할 수 있어서 소스 정합을 쉽게 할 수 있도록 해줍니다.

다만 fetch는 소스 정합을 자동으로 해주지는 않습니다. 사용자가 일일이 직접 정합을 해주어야 하는데, 오히려 직접 확인하면서 정합을 할 수 있기 때문에 자동으로 정합해주는 것보다 더 추천하는 방법입니다.

만약 자동으로 정합까지 하도록 하고 싶으면 git pull 명령어를 이용하면 됩니다.


원격 저장소에 업로드

로컬에서 작업한 내용을 원격 저장소에 업로드 할 때는 git push [원격 저장소 이름] [브랜치 이름] 명령어를 이용해서 수행할 수 있습니다.

‘master’ 브랜치를 원격 저장소 ‘origin’에 업로드하는 명령어는 다음과 같습니다.

$ git push origin master


원격 저장소 정보 조회

원격 저장소의 정보 조회는 git remote show [리모트 저장소 이름]으로 획득할 수 있습니다.

$ git remote show origin
* remote origin
  Fetch URL: https://github.com/snowdeer/snowdeer.android.service.git
  Push  URL: https://github.com/snowdeer/snowdeer.android.service.git
  HEAD branch: master
  Remote branch:
    master tracked
  Local branch configured for 'git pull':
    master merges with remote master
  Local ref configured for 'git push':
    master pushes to master (up to date)

원격 저장소의 URL 및 브랜치 정보 등을 조회할 수 있습니다.


원격 저장소의 이름 수정 및 삭제

원격 저장소의 이름은 git remote rename 명령어를 이용해서 변경할 수 있습니다.

다음과 같은 코드를 실행하면 원격 저장소의 이름을 ‘origin’에서 ‘snowdeer_origin’으로 변경합니다.

$ git remote rename origin snowdeer_origin

$ git remote
snowdeer_origin

원격 저장소 삭제는 git remote rm 명령어를 이용합니다.

$ git remote rm snowdeer_origin

Git 설명서 - (5) 되돌리기

|

되돌리기

실수를 했을 경우 했던 작업을 복원 및 되돌리기를 해야합니다. 다만, 한 번 되돌린 경우 복구를 할 수 없기 때문에 주의해야 합니다.

commit 되돌리기

종종 commit한 결과를 수정해야 할 경우가 있습니다. 어떤 파일을 누락하거나 commit 메세지를 수정할 경우도 이에 해당합니다. --amend 옵션을 이용하면 다시 commit을 수행할 수 있습니다.

$ git commit --amend

만약 파일 추가를 빼먹었다면 다음과 같은 순서로 명령을 실행할 수 있습니다.

$ git commit -m 'minor bug fixed'

$ git add snowdeer_added.cpp

$ git commit --amend

두 번째 commit은 첫 번째 commit를 덮어쓰기 때문에 여기서 실행한 명령어 3개는 모두 하나의 commit으로 기록됩니다.


Staged 파일을 Unstaged 상태로 되돌리기

실수로 git add * 명령어를 실행하면 의도치 않은 파일들까지 Staged 상태로 되는 경우가 발생할 수 있습니다.

git status로 확인하면 다음과 같습니다.

$ git status
On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

        modified:   snowdeer_1.cpp
        modified:   snowdeer_2.cpp

여기서 설명에 나온 git reset HEAD <file> 명령어를 이용해서 Staged 상태의 파일을 Unstaged 상태로 만들어줄 수 있습니다.

$ git reset HEAD snowdeer_2.cpp


수정한 파일을 원래 상태로 되돌리기

파일 수정을 했지만 서버에 있는 내용으로 복원하고 싶은 경우입니다. 자주 발생하는 경우이기도 합니다.

마찬가지로 git status를 실행해보면 복구하는 명령어를 조회할 수 있습니다.

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)

        modified:   snowdeer_1.cpp

위 설명에 있는 git checkout -- <file> 명령어를 이용해서 아래와 같이 실행해주면 됩니다.

$ git checkout -- snowdeer_1.cpp

이제 파일이 복원된 것을 확인하면 됩니다.

하지만 위와 같은 방법으로 복원한 것은 기존에 작업하던 내용을 전부 날려버리기 때문에 상당히 위험한 명령어입니다. 따라서 각별히 주의해서 사용해야 할 것입니다.

좀 더 안전하게 복원하고 싶으면 stash 명령어나 branch를 활용하는 것이 더 좋습니다.