Overview
DIP를 준수하는 class 설계를 위해 interface가 필요하다. 이 interface는 일반 class만 사용해도 쉽게 구현할 수 있다. 아래는 clean architecture에서 use case에서 repository 의존성을 분리하여 의존성 흐름이 adapter에서 business logic 계층으로 향하도록 역전시키는 구현 예시이다. Adaptor 계층에서 repository pattern을 사용하여 use case가 repository 의존성을 주입받는다.
class Repository {
String fetchData() {
throw Exception("Not implemented");
};
}
class RepositoryImpl extends Repository {
@override
String fetchData() {
return storage.getData();
}
}
class UseCaseImpl extends UseCase {
UseCaseImpl({required this.repository});
final Repository repository;
String getData() {
return repository.fetchData();
}
}
final repository = RepositoryImpl();
final useCase = UseCaseImpl(repository: repository);
이렇게 class만 사용해도 구현할 수 있지만 code가 의도와 다르게 사용될 여지가 있다. 가령, Repository
는 의존성 역전을 위한 interface 역할만 해야 하므로 instance를 만들거나 스스로 구현 코드를 가지면 안된다.
하지만, 위 코드에서 Repository
는 일반 class이므로 interface를 생성할 수 있고 method는 반드시 구현부를 가져야 한다. Subclass에 구현을 위임하도록 강제하기 위해 Repository
에 선언한 method에서 Exception
을 발생시킬 수도 있겠지만, subclass가 fetchData
를 override하지 않을 때 runtime에만 문제를 알 수 있으므로 위험한 코드다. 또, 모든 method에 같은 exception 코드를 추가해야 하므로 좋은 방법은 아니다.
abstract
modifier를 사용하는 방법
위에서 살펴본 문제들이 발생하지 않도록 compile-time에 제한하여 안정성을 높이는 것이 좋다. 그러려면 Repository
는 아래 두가지 조건을 만족해야 한다.
Repository
는 instance를 생성할 수 없어야 한다.- 구현체인
RepositoryImpl
class는fetchData
method를 override하여 반드시 구현부를 추가해야 한다.
1번 조건을 만족시키기 위해 Repository
를 abstract class
로 만들 수 있다. 이렇게 하면 생성자로 instance를 생성할 수 없을 뿐만 아니라, Repository
가 abstract method를 정의할 수 있으므로 exception을 발생시키지 않고도 compile time에 RepositoryImpl
에 구현을 강제할 수 있다.
abstract class Repository {
String fetchData();
}
class RepositoryImpl extends Repository {
// Override 하지 않으면 compile error가 발생한다.
@override
String fetchData() {
return storage.getData();
}
}
하지만 이 방법에도 한계가 있는데, abstract
modifier는 단순히 class에 body가 없는 abstract method를 선언하여 구현을 subclass에 위임할 수 있는 특성을 부여하는 것이므로, 여전히 Repository
에 구현부를 가진 method를 선언할 수 있다는 것이다. abstract class
에서 구현부를 가진 일반 method는 subclass에서 override하지 않아도 compile error가 발생하지 않는다.
abstract class Repository {
String fetchData() {
return "Some data";
}
}
class RepositoryImpl extends Repository {
// `fetchData`를 override 하지 않아도 compile error가 발생하지 않는다.
}
이 정도는 subclass에 구현을 위임하려는 method를 반드시 abstract method로 선언하도록 개발자가 좀 더 신경쓰면 될 수도 있다. 하지만, 개발자가 개입하지 않고 문제가 발생할 가능성을 제거하는 방법은 없을까?
interface
modifier를 사용하는 방법
Subclass를 만들 때 abstract class
를 implements
로 구현하면 모든 method를 override하는 것이 compile-time에 강제되므로 문제를 해결할 수 있다. 하지만, abstract class
는 extends
로 확장시킬 수도 있기 때문에 실수로 문제가 발생할 가능성은 여전히 존재한다.
extends
를 사용한 확장을 제한하기 위해 abstract class
대신 interface class
로 만들면 어떨까? interface class
는 implements
로 구현하는 것만 허용되므로 fetchData
method를 override하는 것이 강제되긴 하지만, abstract method를 가질 수 없으므로 method에 불필요한 구현부를 추가해야 한다는 문제가 다시 발생한다. 또, interface class
는 instance를 생성할 수 있으므로 조건에 맞지 않는다.
interface class Repository {
String fetchData() {
throw Exception("Not implemented");
}
}
class RepositoryImpl implements Repository {
// `fetchData`를 override 하지 않으면 compile error가 발생한다.
@override
String fetchData() {
return storage.getData();
}
}
// Instance를 생성할 수 있으므로 조건에 맞지 않는다.
final repository = Repository();
abstract interface
를 사용하는 방법
두 class modifier의 동작을 보면 “instance를 생성할 수 없고 method override를 compile-time에 강제해야 한다"는 조건은 각각 abstract
와 interface
modifier를 사용해서 강제할 수 있다. 따라서, 두 modifier를 합친 abstract interface
를 사용하면 두 조건을 완벽하게 만족한다.
abstract interface class Repository {
String fetchData();
}
final repository = Repository(); // ❌ : abstract가 instance 생성을 제한한다.
class RepositoryImpl implements Repository {
// interface가 확장(extends)을 제한하여 method를 override하도록 강제한다.
@override
String fetchData() {
return storage.getData();
}
}
abstract interface
가 위에서 언급한 두 가지 조건을 만족하지만, 사실 완벽하진 않다. Dart의 class modifier들은 확장과 구현 관계에 대해 같은 file 안에서는 제한을 두지 않기 때문이다. File 개수가 많아지는 것을 피하려고 interface와 구현체를 하나의 file에 작성한다면 2번 조건을 강제할 방법은 없다.
Conclusion
abstract interface
가 interface의 조건을 모두 만족하는 것 같지만, 같은 file 안에서는 기능을 제한하지 않으므로 한계가 있다. 따라서, 항상 interface와 구현체를 다른 file로 분리한다는 규칙이 없다면 abstract interface
도 완벽하지 않다.
abstract interface class
라는 keyword 묶음은 코드를 꽤 장황하게 만든다. 어차피 사람이 규칙을 정해서 신경써야 한다면 abstract class
로 instance 생성만 제한하고 interface로 사용하는 abstract class
에는 항상 abstract method만 선언한다던가, extends
가 아닌 implements
만 사용한다던가 하는 규칙을 정하는게 코드를 간결하게 유지하는 방법일 수 있겠다.
한편으로, Dart의 class와 modifier들을 보고 있으면 Swift의 protocol
이 굉장히 편리했다는 것을 깨닫는다. Swift의 protocol
은 그 자체가 instance를 생성할 수 있는 class가 아니고, protocol
에 정의한 method들은 optional
로 선언하지 않는 한 반드시 채택한 class에서 구현을 추가하도록 강제되기 때문이다. Swift를 사용하던 경험과 비교했을 때, Dart 언어는 class modifier가 과하게 파편화 되어 있다고 느껴진다. 또, 확장 및 구현에 대한 제한이 다른 file에 대해서만 적용된다는 점은 아쉽다.