추상클래스와 인터페이스의 활용 (Feat. Android)

7 분 소요

추상클래스와 인터페이스는 자바나 코틀린을 공부할 때 항상 튀어나오는 주제이다. 오늘도 코틀린을 공부하는 와중에 추상클래스와 인터페이스가 등장했다. 그래서 이참에 한 번 정리를 해보면 좋을 것 같아서 정리해본다.

들어가기에 앞서 ‘추상’과 ‘추상화’, ‘구체화’의 의미를 알고 가자.

추상 - 낱낱의 구체적 표상이나 개념에서 공통된 성질을 뽑아 이를 일반적인 개념으로 파악하는 정신 작용

추상화 - 클래스간의 공통점을 찾아내서 공통의 조상을 만드는 작업

구체화 - 상속을 통해 클래스를 구현, 확장하는 작업

추상클래스와 인터페이스의 차이

추상클래스

  • 미완성 설계도, 인스턴스화 X
  • 상속을 통해서 자손 클래스에 의해서만 완성될 수 있다.
  • 자체로는 클래스로서의 역할을 다 못하지만, 새로운 클래스를 작성하는데 있어 바탕이 된다.
  • 조상클래스로서 중요한 의미를 갖는다.

인터페이스

  • 밑그림만 그려져 있는 ‘기본 설계도’, 인스턴스화 X
  • 구현 클래스가 필요하다.
  • 추상화 정도가 높아서 일반 메서드, 멤버 변수를 가질 수 없다. 추상 메서드와 상수만 가능 ← 사실 틀린말이다. Java 1.8 부터는 인터페이스안에 구현 메서드인 default 메서드도 가질 수 있게 되었다.
  • 인터페이스는 인터페이스로부터만 상속 가능. 다중 상속 가능.

공통점

모두 미완성이기 때문에 구현해줄 구현클래스가 반드시 필요하다. 자식클래스가 무언가를 반드시 구현해야할 때 사용한다. 원래는 상속에서부터 다루어야하지만, 상속이라는 특징 때문에 두 가지 모두 다형성을 표현할 수 있기 때문에 느슨한 결합이 가능해진다. 느슨한 결합(Loose Coupling)이란 객체끼리 상호작용은 있지만 서로를 잘 모르는 것이다. 느슨한 결합은 유지보수하기 편하고 테스트 가능한 코드를 만들어낸다. 물론 객체 간의 상속을 줄이고 외부에서 객체를 전달하며, 의존성 주입을 구현해야 느슨한 결합이 보다 완성될 수 있지만 다형성이 기초적인 역할을 한다. 느슨한 결합에는 팩토리 패턴까지 들어가기도 하던데 이 부분은 나중에 디자인패턴을 더 자세히 공부해서 채워넣어야겠다.

차이점

사실 차이점이 중요하다.

문법적인 차이점보다 사용하는 목적이 중요하다고 본다. 키워드는 확장성과 동일 기능이다.

추상클래스는 좀 더 확장의 개념을 가지고 있고, 인터페이스는 동일 기능을 하도록 보장하는 개념을 가지고 있다.게임 스타크래프트의 저그 종족을 예로 들면 모든 유닛들은 움직이거나 멈출 수 있으며 일정 시간마다 HP가 채워지는 공통의 작업을 수행할 수 있고, 일부 유닛만이 오버로드라는 유닛에 탈 수 있는 기능을 할 수가 있다.

그렇다면 최상위 객체인 ZergUnit에는 move(), stop(), recover() 메서드를 만들어 줄 수 있다. 여기서 move()와 recover()는 유닛마다 다를 수 있으므로 abstract를 시켜줘야한다.

abstract class ZergUnit {
    int x, y;
    abstract void move(int x, int y);
    void stop() { /* 현재 위치에 정지 */ }
    abstract void recover(int timeFlag);
}

이렇게 만들고 ZergUnit을 상속받은 Mutalisk은 move(), recover()를 구현해야 하고 공통 기능인 stop()을 이용할 수 있게 된다. 이외에 Mutalisk 고유의 공격기능이나 기타 필요한 기능들을 추가하여 ZergUnit으로부터 확장될 수 있게 된다. 동시에 다형성도 구현된다.

class Mutalisk extends ZergUnit {
    void move(int x, int y) {
        // 고유의 속도를 가지고 좌표로 이동
    }

    void recover(int timeFlag) {
        // 고유의 회복속도를 가지고 HP를 회복
    }

    void attack() {
        // 공격기능
    }
}

인터페이스는 동일 동작을 보장하는 개념이라고 했었다. 모든 ZergUnit이 오버로드라는 유닛에 탈 수 있는 것은 아니다. 일부 유닛만이 타는 기능을 할 수 있다. 이러한 상황에서 일부 유닛이 오버로드에 타는 기능을 보장하는 인터페이스 Shippable을 만들어서 객체에 구현할 수 있다.

interface Shippable {
}

해당 인터페이스를 구현한 객체들은 이제 모두 오버로드라는 유닛에 탈 수 있는 동일 기능을 보장하게 되는 것이다.

class Overlord extends ZergUnit {
    void move(int x, int y) {
        // 고유의 속도를 가지고 좌표로 이동
    }

    void recover(int timeFlag) {
        // 고유의 회복속도를 가지고 HP를 회복
    }

    void attack() {
        // 공격기능
    }
    
    void ship(Shippable s){
        // Shippable한 유닛을 태운다.
    }
}

너무 주저리주저리 한 면이 없지 않아서 정리하면

추상클래스는 관련성이 높은 클래스 간에 코드를 공유하면서 동시에 하위클래스는 확장성을 보장하고 싶은 목적으로 사용하고, 인터페이스는 어떤 기능을 명세하고 싶을 때, 근데 구현한 모든 객체에서 같은 기능을 보장하면서. 라고 정리해볼 수 있을 것 같다.

자 이제 뜬 구름 잡는 코드는 좀 옆에 레퍼런스로 두고, 실제 안드로이드에서는 어떻게 추상클래스와 인터페이스가 활용되고 있는지 알아보자.

추상클래스 활용

아래의 클래스는 안드로이드 앱을 만들면서 사용한 모든 Activity의 부모 클래스가 되는 BaseActivity이다. 모든 Activity는 기본적으로 AppCompatActivity를 상속받아야 Activity의 기능을 할 수 있기 때문에 해당 BaseActivity 클래스를 상속받지 못하면 제 기능을 할 수가 없게 된다. 그러면 그냥 AppCompatActivity를 상속받으면 되지 않느냐라고 할 수 있는데, AppCompatActivity의 기능을 하면서 동시에 더 많은 공통 기능을 제공하면서 Activity마다 다른 동작을 하는 메서드가 필요했기 때문에 추상클래스를 만들어 활용했다. 안드로이드에서 MVVM을 활용할 때 사용했던 클래스인데 MVVM은 나중에 자세히 소개하기로 해야겠다.

public abstract class BaseActivity<T extends ViewDataBinding, V extends BaseViewModel> extends AppCompatActivity
    implements BaseFragment.Callback {
  private static final String TAG = "BaseActivity";

  private T mViewDataBinding;
  private V mViewModel;

  /**
   * Override for set binding variable
   *
   * @return variable id
   */
  public abstract int getBindingVariable();

  /**
   * @return layout resource id
   */
  public abstract
  @LayoutRes
  int getLayoutId();

  /**
   * Override for set view model
   *
   * @return view model instance
   */
  public abstract V getViewModel();

  @Override
  protected void onCreate(@Nullable Bundle savedInstanceState) {
    performDependencyInjection();
    super.onCreate(savedInstanceState);
    performDataBinding();
  }

  private void performDependencyInjection() {
    AndroidInjection.inject(this);
  }

  public T getViewDataBinding() {
    return mViewDataBinding;
  }

  public boolean isNetworkConnected() {
    return NetworkUtils.isNetworkConnected(getApplicationContext());
  }

  public boolean isGpsConnected() {
    return GpsUtils.isGpsConnected(getApplicationContext());
  }

  public void hideKeyboard() {
    View view = this.getCurrentFocus();
    if (view != null) {
      InputMethodManager inputMethodManager = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE);
      if (inputMethodManager != null) {
        inputMethodManager.hideSoftInputFromWindow(view.getWindowToken(), 0);
      }
    }
  }

  private void performDataBinding() {
    mViewDataBinding = DataBindingUtil.setContentView(this, getLayoutId());
    this.mViewModel = mViewModel == null ? getViewModel() : mViewModel;
    mViewDataBinding.setVariable(getBindingVariable(), mViewModel);
    mViewDataBinding.executePendingBindings();
  }
}

위의 클래스를 상속받아서 자식 클래스에서 다음과 같이 활용하고는 했다. Activity 마다 제공하는 Layout Resource가 다르고 ViewModel도 다르다 보니 추상 메서드로 만들어서 Activity마다 메서드를 다르게 구현하여 BaseActivity에서 Activity마다 다르게 해당 객체들을 처리했다.

public class HomeActivity extends BaseActivity<ActivityHomeBinding, HomeViewModel> implements PlanFragment.OnPlanFragmentInteractionListener {
  private static final String TAG = "HomeActivity";

  // ...

  public static Intent newIntent(Context context) {
    return new Intent(context, HomeActivity.class);
  }

  @Override
  public int getBindingVariable() {
    return BR.viewModel;
  }

  @Override
  public int getLayoutId() {
    return R.layout.activity_home;
  }

  @Override
  public HomeViewModel getViewModel() {
    return mHomeViewModel;
  }

    // ...
}

인터페이스 활용

~able

~able은 직접 만들기보다 많이 사용했었다. Serializable, Parcelable과 같이 Bundle로 데이터를 전달하고 싶은 객체에 직렬화가 된다는 기능을 보장할 때 사용했었고 Cloneable과 같이 객체를 복사할 수 있는 기능을 보장할 때 사용했었다.

public class Plan implements Serializable, Cloneable {
        // ...
}

리스너

일종의 옵저버 패턴처럼 사용하는 것인데, 옵저버 패턴은 어떤 객체의 상태 변화를 관찰하다가 상태에 변화가 생길때마다 메서드 등을 통해 객체가 각 옵저버에게 통지하도록하는 디자인 패턴이다. 이 패턴의 핵심은 옵저버 또는 리스너(listener)라 불리는 하나 이상의 객체를 관찰 대상이 되는 객체에 등록시킨다. 그리고 각각의 옵저버들은 관찰 대상인 객체가 발생시키는 이벤트를 받아 처리한다. 관찰 대상인 객체는 “이벤트를 발생시키는 주체”라는 의미에서 Subject로 표시되어 있다. 이벤트가 발생하면 각 옵저버는 콜백(callback)을 받는다. notify 함수는 관찰 대상이 발행한 메시지 이외에, 옵저버 자신이 생성한 인자값을 전달할 수도 있다.

Android에서 가장 흔하게 사용되는 패턴이 아닐까 싶다. 주로 액티비티와 프래그먼트 간 통신, 프래그먼트 간 통신을 하거나 어댑터와 액티비티 간 통신을 할 때 흔히 사용되고는 한다. 여담으로 프래그먼트 간 통신은 중요한 주제인데 옵저버 패턴을 포함하여 세 가지의 방법이 있다고 한다. 안드로이드 정보 찾다보면 자주 볼 수 있는 찰스님께서 정리해놓은 글이 있어서 나중에 참고하기 위해 링크를 저장한다. 다시 본론으로 들어가서 액티비티와 프래그먼트 간 통신을 하는 코드를 보면서 정리를 해보자.

리스너(옵저버) - HomeActivity, 서브젝트 - PlanFragment, 이벤트 - onCartButtonClicked, 콜백 - onFragmentCartButtonClicked

HomeActivity는 OnPlanFragmentInteractionListener를 구현하기 때문에 콜백이라고 할 수 있는 onFragmentCartButtonClicked 메서드를 구현하면서 리스너(옵저버)가 된다. 그리고 PlanFragment는 관찰 대상인 서브젝트이다. Fragment는 Activity에 Attach될 때 onAttach 메서드를 구현해야하는데 여기서 보통 리스너를 등록한다. 여기서 onCartButtonClicked가 호출되어 이벤트가 발생하면 등록한 리스너로 콜백을 보내게 된다. 최종적으로 HomeActivity는 구현한 콜백을 실행하게 되면서 프로세스는 끝이 난다. 코드를 보면 알 수 있듯이 서브젝트인 PlanFragment는 콜백을 통해 인자값을 전달할 수도 있다. 보통 프래그먼트 간 통신에서는 콜백을 통해 전달된 인자값을 액티비티가 받아서 다른 프래그먼트에 전달해주는 식으로 통신을 구현한다.

public class PlanFragment extends BaseFragment<FragmentPlanBinding, PlanFragmentViewModel> implements
    PlanFragmentNavigator {
    private OnPlanFragmentInteractionListener mListener;

    // ...

    @Override
      public void onAttach(Context context) {
        super.onAttach(context);
        if (context instanceof OnPlanFragmentInteractionListener) {
          mListener = (OnPlanFragmentInteractionListener) context;
        } else {
          throw new RuntimeException(context.toString()
              + " must implement OnPlanFragmentInteractionListener");
          }
    }

    @Override
    public void onCartButtonClicked() {
    mListener.onFragmentCartButtonClicked();
  }

    public interface OnPlanFragmentInteractionListener {
      void onFragmentCartButtonClicked();
    }
}
public class HomeActivity extends BaseActivity<ActivityHomeBinding, HomeViewModel> implements PlanFragment.OnPlanFragmentInteractionListener {
  private static final String TAG = "HomeActivity";

  // ...

    @Override
  public void onFragmentCartButtonClicked() {
        openBottomSheet();
  }

  public void openBottomSheet() {
        mBottomSheetBehavior.setState(BottomSheetBehavior.STATE_EXPANDED);
    }
}

기능 명세 및 Loose Coupling

가장 기본에 충실하게 인터페이스를 이용하여 기능 명세를 하는 방법도 자주 쓰인다. 먼저 구현하고자 하는 클래스의 기능을 인터페이스를 통해 명세를 해두고 코딩하는 방법인데 미리 명세가 되어있는 것은 협업을 할 때 다른 프로그래머들에게 혼동을 주지 않기도 하므로 유용하다. 동시에 객체간 상호작용은 있지만 서로를 알지는 못하게 하는 Loose Coupling을 가능하게 만들기도 한다. 프로젝트를 진행하면서 Dependency Injection 프레임워크인 Dagger를 사용했었는데 DI는 외부에서 객체를 생성하여 전달함으로써 Aggregation을 하는 방식이다. SharedPreference를 관리하는 PreferencesHelper, 로컬데이터베이스를 관리하는 DbHelper를 최종적으로 데이터를 관리하는 AppDataManager 클래스에 전달할 때 사용했는데, 여기에서 PreferencesHelper, DbHelper는 모두 인터페이스이다. 실제 구현 클래스는 앞에 App을 붙인 AppPreferencesHelper, AppDbHelper이다. 코드에서 생성자를 보면 모두 인터페이스로 참조변수가 설정되어 있다. 그래서 AppDataManager 클래스는 구현 객체로 어떤 객체가 오든지 간에 작동에만 신경쓰게 된다. 이렇게 Loose Coupling이 된다. 동시에 AppDataManager는 변경에는 닫혀있고 확장에는 열려있는 OCP 원칙을 따르게 되는데 어떤 PreferencesHelper가 오든지 클래스를 수정할 필요없이 주입할 PreferencesHelper만 변경해주므로 변경에는 닫혀있고, 다양한 PreferencesHelper의 구현체로부터 확장될 수 있기에 확장에는 열려있게 된다. 코드를 보다보면 xxx, xxxImpl 과 같은 패턴을 많이 보게 되는데 이 또한 Loose Coupling과 OCP 원칙을 따르기 위한 것이 아닌가 추측해본다.

public class AppDataManager implements DataManager {
    public AppDataManager(Context context, DbHelper dbHelper, PreferencesHelper preferencesHelper) {
    this.mContext = context;
    this.mDbHelper = dbHelper;
    this.mPreferencesHelper = preferencesHelper;
  }
}
public interface PreferencesHelper {

  String getCurrentUserEmail();

  void setCurrentUserEmail(String email);

  void clear();
}
public class AppPreferencesHelper implements PreferencesHelper {

  private static final String PREF_KEY_CURRENT_USER_EMAIL = "PREF_KEY_CURRENT_USER_EMAIL";

  private final SharedPreferences mPrefs;

    // ...

  @Override
  public String getCurrentUserEmail() {
    return mPrefs.getString(PREF_KEY_CURRENT_USER_EMAIL, null);
  }

  @Override
  public void setCurrentUserEmail(String email) {
    mPrefs.edit().putString(PREF_KEY_CURRENT_USER_EMAIL, email).apply();
  }

  @Override
  public void clear() {
    mPrefs.edit().clear().apply();
  }
}

댓글남기기