이 글은 2024년 5월 30일에 일본어로 먼저 발행된 기사를 번역한 글입니다.
LY Corporation은 높은 개발 생산성을 유지하기 위해 코드 품질 및 개발 문화 개선에 힘쓰고 있습니다. 이를 위해 다양한 노력을 하고 있는데요. 그중 하나가 Review Committee 활동입니다.
Review Committee에서는 머지된 코드를 다시 리뷰해 리뷰어와 작성자에게 피드백을 주고, 리뷰하면서 얻은 지식과 인사이트를 Weekly Report라는 이름으로 매주 공유하고 있습니다. 이 Weekly Report 중 일반적으로 널리 적용할 수 있는 주제를 골라 블로그에 코드 품질 개선 기법 시리즈를 연재하고 있습니다.
이번에 블로그로 공유할 Weekly Report의 제목은 '티끌이 모여 태산이 되듯 의존성도 쌓이면'입니다.
티끌이 모여 태산이 되듯 의존성도 쌓이면
다음 LatestNewsSnippetUseCase는 뉴스 기사 스니펫을 NewsSnippet이라는 데이터 모델로 제공하는 클래스입니다. NewsSnippet 데이터 모델은 리포지터리의 데이터를 조합하고 문자열을 포매팅해 생성합니다.
class LatestNewsSnippetUseCase(
private val locale: Locale,
private val articleRepository: NewsArticleRepository,
private val sourceRepository: NewsSourceRepository,
private val stringTruncator: StringTruncator,
private val timeFormatter: TimeTextFormatter = TimeTextFormatterImpl(locale),
private val modelFactory: (title: String, content: String, dateText: String, source: String) -> NewsSnippet =
::NewsSnippet
) {
fun getLatestSnippet(): NewsSnippet {
val article = articleRepository.getLatestArticle()
val articleText = stringTruncator.truncate(article.contextText, ARTICLE_TEXT_LENGTH, locale)
val dateText = timeFormatter.toShortMonthDayText(article.timestampInMillis)
val sourceName = sourceRepository.getSource(article.sourceId).shortName
return modelFactory(article.title, articleText, dateText, sourceName)
}
companion object {
private const val ARTICLE_TEXT_LENGTH = 280
}
}
리포지터리는 두 개(NewsArticleRepository, NewsSourceRepository)가 있고, 이들은 복잡한 클래스(예: 네트워크에 의존)라고 가정해 봅시다. 문자열 포매터도 두 개(StringTruncator, TimeTextFormatter)가 있는데 아주 단순하게 구현돼 있다고 가정하겠습니다(원래 문자열 처리는 복잡해지는 경향이 있지만 설명을 위해 단순화합니다).
생성자 매개변수 modelFactory는 NewSnippet 인스턴스를 생성하는 함수를 나타냅니다. 기본 인수로는 NewSnippet의 생성자 참조인 ::NewSnippet를 정의합니다. 즉 기본 인수의 동작은 modelFactory(...) 호출과 생성자 NewSnippet(...)의 호출이 동일합니다.
여기서 StringTruncator와 TimeTextFormatter, NewsSnippet 이 세 가지를 다음과 같이 정의하며, StringTruncator의 구현체 StringTruncatorImpl의 생성자는 인수를 받지 않는다고 가정하겠습니다.
interface StringTruncator {
fun truncate(string: String, length: Int, locale: Locale, suffix: String = "…" /* U+2026 */): String
}
interface TimeTextFormatter {
fun toShortMonthDayText(millis: Long): String
}
class NewsSnippet(val title: String, val content: String, val dateText: String, val source: String)
이 코드에서 개선할 점이 있을까요?
주입의 존재 이유
간단히 말해 modelFactory를 외부에서 전달할 필요가 없습니다. 또한 StringTruncator와 TimeTextFormatter도 싱글톤 등에 의존하지 않는다면 인터페이스를 분리하고 외부에서 구현체를 전달(의존성 주입)할 필요가 없습니다. 이 코드에서 외부에서 전달할 수 있도록 만들어야 하는 것은 환경에 따라 달라지는 값(Locale)과 복잡한 클래스(리포지터리)뿐입니다.
따라서 코드를 더 단순화하려면 다음과 같이 재작성하는 것이 좋습니다.
modelFactory:NewsSnippet의 생성자를 직접 호출한다.stringTruncator/timeFormatter: 인스턴스를 일반적인private속성으로 유지한다.- 인터페이스와 구현을 통합하는 것도 고려한다.
StringTruncator의 경우 상태를 갖지 않는다면object로 변경해도 괜찮다.
class LatestNewsSnippetUseCase(
private val locale: Locale,
private val articleRepository: NewsArticleRepository,
private val sourceRepository: NewsSourceRepository
) {
private val stringTruncator: StringTruncator = StringTruncatorImpl()
private val timeFormatter: TimeTextFormatter = TimeTextFormatterImpl(locale)
suspend fun getLatestSnippet(): NewsSnippet {
val article = articleRepository.getLatestArticle()
val articleText = stringTruncator.truncate(article.contextText, ARTICLE_TEXT_LENGTH, locale)
val dateText = timeFormatter.toShortMonthDayText(article.timestampInMillis)
val sourceName = sourceRepository.getSource(article.sourceId).shortName
return NewsSnippet(article.title, articleText, dateText, sourceName)
}
companion object {
private const val ARTICLE_TEXT_LENGTH = 280
}
}
의존성 주입을 활용할 때는 목적이 명확해야 합니다. 일반적으로 다음과 같은 목적으로 의존성을 주입합니다.
- 의존 대상의 범위 및 라이프사이클 관리: 객체를 공유(예: 상태 공유, 횡단 관심사 분리)하거나 자신보다 라이프사이클이 긴 객체를 사용하기 위해
- 의존성 역전: 모듈의 순환 의존성을 해결하거나 아키텍처에서 정한 의존 방향을 따르기 위해
- 구현 전환: 설정 등에 따른 기능 전환이나 테스트, 디버깅, 검증용 구현으로 교체하기 위해
- 구현 분리: 빌드 속도를 높이거나 독점(proprietary) 라이브러리를 제공하기 위해
위와 같은 목적이 아닌 경우, 예를 들어 참조 투명(referential transparency)한 유틸리티 함수나 단순한 모델 클래스 등은 의존성을 주입할 필요가 거의 없으며, 불필요한 주입은 다음과 같은 문제를 일으킬 수 있습니다.
- 불필요하고 암묵적인 의존성: 생성자를 통해 주입을 수행하는 경우 의존 대상의 동작을 추적하려면 생성자의 호출자를 확인해야 합니다. 생성자가 다양한 곳에서 호출되고 있는 경우 모든 호출자를 확인하는 데 수고가 필요할 것입니다. 다른 방식으로 주입하더라도 인터페이스가 분리된 상황에서는 구현을 추적하는 수고가 늘어납니다.
- 호출자의 책임 증가: 생성자나 세터를 통해 주입을 수행하면 호출자가 의존성을 해결해야 합니다. 특히 의존성 해결이 연쇄적으로 전달될 경우 메인 클래스에 온갖 의존성이 모여듭니다(이 문제는 세컨더리 생성자, 팩토리 함수, 기본 인수, DI(dependency injection) 컨테이너 등 으로 완화할 수 있습니다).
- 값의 연관성 파괴: '여러 객체가 공통 값을 사용하기를 원한다'는 제약 조건이 있더라도 값 주입으로 인해 그 제약 조건이 깨질 수 있습니다. 위
UseCase예시의 경우StringTruncator.truncate와TimeTextFormatter에서 사용하는Locale이 동일할 것이라고 암묵적으로 기대하게 됩니다. 그러나 실제로는LatestNewsSnippetUseCase(locale1, ..., TimeTextFormatterImpl(locale2), ...)처럼 다른 로케일을 전달할 수 있습니다. 특히 의존성 해결이 다른 곳에서 이뤄지는 경우 다른 값이 전달돼도 놓치기 쉽습니다. 또한 공통 값이 사용되고 있는지 검증하는 테스트를 작성하기도 어려워질 수 있습니다.
한 줄 요약: 의존성을 주입할 때는 그 목적을 명확히 한다.
키워드:
dependency injection,dependency explicitness,constructor parameter