이 글은 2024년 5월 9일에 일본어로 먼저 발행된 기사를 번역한 글입니다.
LY Corporation은 높은 개발 생산성을 유지하기 위해 코드 품질 및 개발 문화 개선에 힘쓰고 있습니다. 이를 위해 다양한 노력을 하고 있는데요. 그중 하나가 Review Committee 활동입니다.
Review Committee에서는 머지된 코드를 다시 리뷰해 리뷰어와 작성자에게 피드백을 주고, 리뷰하면서 얻은 지식과 인사이트를 Weekly Report라는 이름으로 매주 공유하고 있습니다. 이 Weekly Report 중 일반적으로 널리 적용할 수 있는 주제를 골라 블로그에 코드 품질 개선 기법 시리즈를 연재하고 있습니다.
이번에 블로그로 공유할 Weekly Report의 제목은 '유산의 가치'입니다.
유산의 가치
버튼과 텍스트 같은 UI 요소가 있는 FooScreen이라는 클래스가 있고, 해당 UI 요소의 색상과 이미지는 '테마'에 따라 바뀐다고 가정해 봅시다. 이 사양을 구현하기 위해 다음 코드에서는 FooScreenThemeStrategy라는 인터페이스를 정의했습니다.
class FooScreen {
private val okButton: Button = ...
private val cancelButton: Button = ...
private val mainDescription: TextView = ...
...
fun bindTheme(strategy: FooScreenThemeStrategy) {
okButton.bindTheme(strategy)
cancelButton.bindTheme(strategy)
mainDescription.textColor = strategy.textColorInt
...
}
companion object {
private fun Button.bindTheme(strategy: FooScreenThemeStrategy) {
buttonColor = strategy.buttonColorInt
textColor = strategy.textColorInt
}
}
}
interface FooScreenThemeStrategy {
val backgroundColorInt: Color
val textColorInt: Color
val buttonColorInt: Color
val headerIcon: Image
val errorIcon: Image
val checkMarkIcon: Image
}
FooScreenThemeStrategy 인터페이스는 테마로 사용할 수 있는 요소를 정의할 뿐이고, 실제 값은 다음와 같이 상속을 통해 결정됩니다.
class FooScreenLightTheme: FooScreenThemeStrategy {
override val backgroundColorInt: Color = Color.WHITE
override val textColorInt: Color = Color.DARK_GRAY
override val buttonColorInt: Color = Color.LIGHT_GREEN
override val headerIcon: Image = GREEN_HEADER_ICON
override val errorIcon: Image = RED_ERROR_ICON
override val checkMarkIcon: Image = GREEN_CHECK_MARK_ICON
}
class FooScreenDarkTheme: FooScreenThemeStrategy {
override val backgroundColorInt: Color = Color.BLACK
override val textColorInt: Color = Color.OFF_WHITE
override val buttonColorInt: Color = Color.DARK_GRAY
override val headerIcon: Image = OFF_WHITE_HEADER_ICON
override val errorIcon: Image = OFF_WHITE_ERROR_ICON
override val checkMarkIcon: Image = OFF_WHITE_CHECK_MARK_ICON
}
위 코드에서 개선할 수 있는 부분은 무엇일까요?
상속하지 않는다는 선택
값만 다르고 로직이 같을 때에는 대부분의 경우 상속이 필요하지 않습니다. 상속이 필요한 대표적인 예는 다음과 같습니다.
- 로직을 동적으로 변경할 때(동적 디스패치 활용)
- 합 타입을 구현할 때(Java나 Kotlin의
sealed클래스 등) - 구현과 인터페이스를 분리할 때(프레임워크 제약 조건 대응, DI 컨테이너 활용, 빌드 가속화 등)
- 의존성 역전 법칙을 적용할 때(순환 의존 해결, 의존성 단방향화)
위와 같은 목적 외에는 상속을 사용하지 않는 것이 더 간결해지는 경우가 많습니다. 예를 들어 단순한 로직 공통화라면 애그리게이션(aggregation)이나 컴포지션을 사용할 수도 있습니다.
FooScreenThemeStrategy에서 필요한 것은 '로직을 동적으로 변경'하는 것에 가깝습니다. 그러나 실제로는 로직을 변경할 필요 없이 값만 변경해도 충분했습니다. 이번 경우에는 인터페이스를 사용하는 대신 값을 가지는 클래스를 정의해서 상속 없이 구현할 수 있습니다.
class FooScreenThemeModel(
val backgroundColorInt: Color,
val textColorInt: Color,
val buttonColorInt: Color,
val headerIcon: Image,
val errorIcon: Image,
val checkMarkIcon: Image
)
val FOO_SCREEN_LIGHT_THEME_MODEL = FooScreenThemeModel(
backgroundColorInt = Color.WHITE,
textColorInt = Color.DARK_GRAY,
buttonColorInt = Color.LIGHT_GREEN,
headerIcon = GREEN_HEADER_ICON,
errorIcon = RED_ERROR_ICON,
checkMarkIcon = GREEN_CHECK_MARK_ICON,
)
특히 Kotlin의 경우 open 등을 붙이지 않는 한 클래스 상속이 불가능합니다(Java의 final class에 해당합니다). FooScreenThemeModel을 인터페이스가 아닌 상속 불가능한 클래스로 정의하면 다음 두 가지를 보장할 수 있습니다.
- (속성 값이 변하지 않는다면) 각 속성은 동적으로 변경되지 않는다.
- 인스턴스 고유의(특유의) 로직은 없다.
한 줄 요약: 값만 다르고 로직이 같을 때에는 상속을 사용하지 않고 값이 다른 인스턴스를 생성한다.
키워드:
inheritance,instantiation,dynamic dispatch