코드 품질 개선 기법 13편: 클론 가족

1 day ago 1
이 글은 2024년 2월 15일에 일본어로 먼저 발행된 기사를 번역한 글입니다.

안녕하세요. 커뮤니케이션 앱 LINE의 모바일 클라이언트를 개발하고 있는 Ishikawa입니다.

저희 회사는 높은 개발 생산성을 유지하기 위해 코드 품질 및 개발 문화 개선에 힘쓰고 있습니다. 이를 위해 다양한 노력을 하고 있는데요. 그중 하나가 Review Committee 활동입니다.

Review Committee에서는 머지된 코드를 다시 리뷰해 리뷰어와 작성자에게 피드백을 주고, 리뷰하면서 얻은 지식과 인사이트를 Weekly Report라는 이름으로 매주 공유하고 있습니다. 이 Weekly Report 중 일반적으로 널리 적용할 수 있는 주제를 골라 블로그에 코드 품질 개선 기법 시리즈를 연재하고 있습니다.

이번에 블로그로 공유할 Weekly Report의 제목은 '클론 가족'입니다.

클론 가족

두 개의 데이터 모델 FooDataModel과 BarDataModel, 그리고 이에 대응하는 데이터 공급자 FooModelProvider와 BarModelProvider가 있다고 가정해 봅시다. 각 데이터 모델은 OriginalData라는 공통 객체에서 생성됩니다.

다음 구현에서는 OriginalData를 가져오기 위한 로직을 공통화하기 위해 ParentProvider를 정의했습니다. 또한 상속으로 변경되는 동작을 최소화하기 위해 createModel은 오버라이딩 불가능한 메서드로 정의하고, OriginalData에서 각 데이터 모델로 변환하는 메서드 convert를 추상 메서드로 정의했습니다.

interface CommonDataModel class FooDataModel(...): CommonDataModel class BarDataModel(...): CommonDataModel open class ParentProvider(...) { fun createModel(...): CommonDataModel { val originalData = getOriginalData(...) return convert(originalData) } protected abstract fun convert(originalData: OriginalData): CommonDataModel private fun getOriginalData(): OriginalData { ... } } class FooProvider(...): ParentProvider(...) { override fun convert(originalData: OriginalData): FooDataModel = ... } class BarProvider(...): ParentProvider(...) { override fun convert(originalData: OriginalData): BarDataModel = ... }

이 구조에 문제가 있을까요?

어느 가족인가?

FooDataModel의 인스턴스를 가져오는 코드는 다음과 같습니다.

val fooProvider = FooProvider(...) ... val fooDataModel = fooProvider.createModel() as FooDataModel

위 코드에는 타입 안전성 문제가 있습니다.

먼저 createModel의 반환 타입이 CommonDataModel이므로 FooDataModel을 가져오려면 다운캐스팅이 필요한데요. 이 다운캐스팅을 수행하려면 'FooProvider는 FooDataModel을 반환한다'는 제약 조건을 알아야 합니다. 하지만 이 제약 조건은 타입 검사로 확인할 수 없는 조건이기 때문에 코드 변경 시 오류가 발생할 수 있습니다.

또한 '하나의 공급자가 하나의 데이터 모델에 대응한다'는 것도 암묵적인 제약 조건일 뿐 보장된 동작이 아닙니다. 만약 FooDataModel과 BarDataModel을 모두 반환할 수 있는 공급자가 생성된다면 이를 안전하게 처리할 수 있는지 확인하는 것은 쉽지 않을 것입니다.

조금 더 일반적으로 말하자면 두 개의 상속 트리가 있고 개별 클래스는 트리 전체에 걸쳐 대응 관계에 놓이지만 이 대응 관계가 암묵적이라는 것이 문제입니다. 이 경우 다음과 같은 해결 방안을 생각해 볼 수 있습니다.

  • 상속을 애그리게이션(aggregation)이나 컴포지션으로 대체하기
  • 매개변수적 다형성(parametric polymorphism) 활용하기

해결 방안 1: 애그리게이션이나 컴포지션으로 대체하기

ParentProvider에서는 상속을 코드의 공통화를 위해서만 사용하고 있는데요. 코드의 공통화만이 목적이라면 상속보다는 컴포지션이나 애그리게이션이 더 적합한 경우가 많습니다.

다음 코드에서는 OriginalData를 가져오기 위한 로직을 OriginalDataProvider로 추출하고, 각 공급자는 OriginalDataProvider의 인스턴스를 속성으로 가집니다.

class FooDataModel(...) // No parent type class BarDataModel(...) // No parent type class FooProvider(...) { // No parent type private val originalDataProvider: OriginalDataProvider = ... fun createModel(...): FooDataModel { val originalData = originalDataProvider.create(...) ... } } class BarProvider(...) { // No parent type private val originalDataProvider: OriginalDataProvider = ... fun createModel(...): BarDataModel { val originalData = originalDataProvider.create(...) ... } } class OriginalDataProvider(...) { fun create(...): OriginalData { ... } }

이렇게 하면 다운캐스팅 없이 각 데이터 모델을 가져올 수 있습니다.

val fooProvider = FooProvider(...) ... val fooDataModel: FooDataModel = fooProvider.createModel()

해결 방안 2: 매개변수적 다형성 활용하기

다형성이 필요한 상황에서 부모 클래스가 필요한 경우도 있습니다. 예를 들어 여러 공급자의 생명 주기(life cycle)를 관리하기 위해 단일 컬렉션으로 묶어야 하는 경우가 이에 해당합니다. 이런 경우 부모 클래스가 OriginalData를 가져오는 로직을 갖도록 하는 것은 자연스러운 구현이라고 할 수 있습니다.

하지만 공급자에게 상속 관계가 필요하더라도 데이터 모델까지 상속 관계를 가져갈 필요는 없습니다. 각 공급자가 반환값의 타입을 결정하도록 하면 CommonDataModel을 만들 필요가 없으며, 이를 구현하는 방법 중 하나로 제네릭(generic)과 같은 '매개변수적 다형성'이 있습니다. 다음 코드에서는 각 공급자가 개별 데이터 모델을 지정할 수 있도록 타입 파라미터 T를 사용해 createModel의 반환값을 정의합니다.

class FooDataModel(...) // No parent type class BarDataModel(...) // No parent type open class ParentProvider<T>(...) { fun createModel(...): T { val originalData = getOriginalData(...) return convert(originalData) } protected abstract fun convert(originalData: OriginalData): T private fun getOriginalData(): OriginalData { ... } } class FooProvider(...): ParentProvider<FooDataModel>(...) { override fun convert(originalData: OriginalData): FooDataModel = ... } class BarProvider(...): ParentProvider<BarDataModel>(...) { override fun convert(originalData: OriginalData): BarDataModel = ... }

이렇게 하면 부모 공급자(ParentProvider)를 정의하면서 각 공급자는 개별 데이터 모델을 지정할 수 있습니다.

val fooProvider = FooProvider(...) ... val fooDataModel: FooDataModel = fooProvider.createModel() // Also, up-casting is valid val barProvider: ParentProvider<BarDataModel> = BarProvider(...) // We may create a list of ParentProvider<*>. val providers = listOf(fooProvider, barProvider)

만약 데이터 모델의 부모 클래스인 CommonDataModel을 정의해야 한다면 타입 파라미터의 상한을 정의하는 것이 좋습니다. 예를 들어 open class ParentProvider<T : CommonDataModel>과 같이 정의할 수 있습니다.


한 줄 요약: 두 상속 트리 간의 암묵적인 대응 관계를 피하려면 상속을 컴포지션이나 애그리게이션으로 대체하거나 매개변수 다형성을 활용한다.

키워드: type checking, inheritance tree, implicit correspondence

Read Entire Article