서론
필자는 그동안 앱을 개발하면서 팩토리 패턴을 굉장히 많이 사용해 왔습니다. 예컨대 다음같이 Hilt를 사용하지 않고 앱을 개발하던 시절 Retrofit -> DataSource -> Repository까지의 필요한 인스턴스들을 Repository Interface에서 팩토리 패턴을 사용함으로써 View에서 Data Layer 방향으로의 직접적인 의존성을 끊을 수 있었습니다.
interface PlaceRepository {
companion object{
fun create(): PlaceRepository{
return PlaceRepositoryImpl(
PlaceDataSource(
RetrofitInstance.serviceProvider(PlaceService::class.java)
)
)
}
}
}
class SearchMainViewModelFactory(): ViewModelProvider.Factory {
private val placeRepository = PlaceRepository.create()
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(SearchMainViewModel::class.java)) {
return SearchMainViewModel(placeRepository) as T
}
throw IllegalArgumentException("unknown ViewModel class")
}
}
이렇듯 팩토리 패턴은 다양한 편의성을 제공해 주었습니다. ViewModel이 특정 Repository의 구현체에 의존하지 않도록 함으로써 관심사를 분리해 ViewModel에 필요한 Repository들을 자유롭게 갈아 끼워줄 수 있었습니다.
또한 팩토리 패턴을 사용하지 않지 않았다면 Retrofit Service Interface부터 Repository까지 모든 인스턴스를 ViewModelFactory에서 생성하게 되어 인스턴스 초기화 코드만 엄청나게 길어졌을 것입니다.
이 과정에서 제게 가장 도움이 되었책이 바로 Effective Kotlin이었습니다. 오늘은 약 8개월 만에 Effective Kotlin을 다시 읽으면서 새롭게 우아한 테크코스를 통해 배운 지식들을 적용하고 해석해 보겠습니다.
생성자 대신 팩토리 함수를 사용하라
팩토리 함수란 생성자의 역할을 대신 해주는 함수를 의미합니다. 팩토리 함수는 다양한 장점을 갖습니다.
1. 네이밍이 자유롭다.
팩토리 함수는 이름을 붙일 수 있습니다. 이펙티브 코틀린에선 from, of, valueOf 등등 다양한 네이밍 컨벤션을 가이드하고 있으며 꼭 이 가이드에 따르지 않아도 각자의 팀에서 정한 규칙에 따라 네이밍 할 수 있습니다. 실제로 저희 팀에선 이펙티브 코틀린에서 가이드하는 네이밍 중 직관적으로 원하는 이름이 없었습니다. 그래서 팀 내 협의를 통해 가장 직관적이라고 생각한 create를 사용했습니다.
2. 원하는 형태의 타입을 리턴할 수 있다.
팩토리 함수는 원하는 형태의 타입을 리턴할 수 있습니다. 아래 예시처럼 구현체 타입이 아닌 인터페이스 타입을 반환하여 실제 구현체를 숨길 수 있습니다.
interface Foo{
companion object{
fun create(): Foo = FooImpl()
}
}
class FooImpl: Foo
3. 호출될 때마다 새 객체를 만들 필요가 없다.
팩토리 함수를 사용해서 객체를 생성하면 싱글톤 패턴처럼 객체를 하나만 생성해 캐싱 매커니즘을 사용할 수 있습니다. 이를 플라이 웨이트 패턴이라 합니다. 먼저 객체를 미리 생성 후 재사용하는 방법을 우아한 테크코스의 로또 미션을 통해 이해해보겠습니다.
로또 번호는 1부터 45까지 존재하며 팩토리 함수에 의해 로또 번호가 생성될 때 마다 Map에 저장된 LottoNumber 객체를 가져와 재사용합니다. 이때, 객체의 재사용을 강제하기 위해 LottoNumber Class의 기본 생성자는 private으로 막아두었습니다.
class LottoNumber private constructor(val value: Int) {
init {
check(value in LOTTO_MIN..LOTTO_MAX) {
throw LottoNumberException.InvalidLottoNumberRange()
}
}
companion object {
const val LOTTO_MIN = 1
const val LOTTO_MAX = 45
private val NUMBERS: Map<Int, LottoNumber> = (LOTTO_MIN..LOTTO_MAX)
.associateWith { LottoNumber(it) }
fun from(value: Int): LottoNumber {
return NUMBERS[value] ?: throw LottoNumberException.InvalidLottoNumberRange()
}
}
}
로또의 경우 실제로 1회 10만원을 초과하여 판매 및 구매할 수 없다고 합니다. 고로, 최악의 경우 600개의 LottoNumber가 생성됩니다.
- 로또 한 장 가격 = 1,000원
- 최대 구매 가능 금액 = 10만 원
- 최대 100장(600개 숫자)까지 구매 가능
팩토리 함수를 사용하면 이러한 중복된 객체 생성을 막을 수 있습니다. 하지만 현재 방식에도 단점이 존재합니다. 로또를 하나만 구매할 경우 6개의 숫자만 사용되며 이 경우 나머지 39개의 LottoNumber는 불필요한 메모리 낭비를 발생시킵니다.
class LottoNumber private constructor(val value: Int) {
init {
check(value in LOTTO_MIN..LOTTO_MAX) {
throw LottoNumberException.InvalidLottoNumberRange()
}
}
companion object {
const val LOTTO_MIN = 1
const val LOTTO_MAX = 45
private val NUMBERS: MutableMap<Int, LottoNumber> = mutableMapOf()
fun from(value: Int): LottoNumber {
return NUMBERS.getOrPut(value) { LottoNumber(value) }
}
}
}
이 경우 객체를 모두 미리 생성하는 것이 아닌 객체를 실제 사용 시점에 만들고 재사용하는 방법을 사용해 해결할 수 있습니다. 동등성과 동일성을 모두 테스트했을 때 테스트에 통과하는 것으로 동일한 객체를 재사용함을 검증할 수 있습니다.
@Test
fun equals(){
val number1 = LottoNumber.from(1)
val number2 = LottoNumber.from(1)
assertThat(number1).isEqualTo(number2) // 동등성 ==
assertThat(number1).isSameAs(number2) // 동일성 ===
}
여태껏 LottoNumber는 일반 class를 사용해 왔으며 LottoNumber는 인자를 단 하나만 가지는 Wrapper Class입니다.이런 Wrapper Class는 Value Class를 사용하면 컴파일 타임에 Wrapping을 해제하고 내부 프로퍼티값으로 대체(인라이닝)된다는 점을 활용하면 더욱 최적화할 수 있을 것 같습니다.
Value Class와 팩토리 함수
@JvmInline
value class LottoNumber private constructor(val value: Int) {
init {
check(value in LOTTO_MIN..LOTTO_MAX) {
throw LottoNumberException.InvalidLottoNumberRange()
}
}
companion object {
const val LOTTO_MIN = 1
const val LOTTO_MAX = 45
private val NUMBERS: MutableMap<Int, LottoNumber> = mutableMapOf()
fun from(value: Int): LottoNumber {
return NUMBERS.getOrPut(value) { LottoNumber(value) }
}
}
}
그러나 Value Class로 변경 후 테스트했을 때 동일성 검증 테스트에서 실패했습니다. 동일성 검증에 실패했단 것은 해당 객체가 재사용되고 있지 않음을 의미합니다. 단지 Value Class로 변경했을 뿐인데 왜 동일성 테스트에 실패할까요?
Value Class가 동일성 검증에 실패하는 Value Class 탄생 배경에 있습니다. 동일성은 각 객체를 메모리 주소를 통해 고유하게 식별하기 위해 사용됩니다. Value Class는 컴파일 타임에 객체를 실제 필드값으로 인라이닝함으로써 참조 값 대신 값 자체를 비교하는 방식으로 작동합니다.
즉, 동일성을 검증하는 것이 아닌 실제 값을 비교하는 방식으로 동작해야 하기 때문에 동등성 검증은 통과하는 반면, 동일성 테스트에선 실패합니다. Value Class에 대한 자세한 내용은 이 글에서 확인하실 수 있습니다.
생성자를 꼭 감춰야할까?
기본 생성자를 private을 감추는 것은 친절한 API가 아니라고 생각합니다. 실제로 제가 직접 LottoNumber class를 만들어놓고 기본 생성자로 초기화 되지 않아 오잉? 했던 경험이 있습니다. 그래서 한 가지 방법으로 연산자 오버로딩 함수를 사용해 "생성자처럼" 사용하는 방법이 있습니다.
companion object {
// ...
operator fun invoke(value: Int): LottoNumber = from(value)
private fun from(value: Int): LottoNumber {
return NUMBERS.getOrPut(value) { LottoNumber(value) }
}
}
다만 이 방법 또한 이펙티브 코틀린에선 권장하지 않는 방법입니다. invoke()는 본래 "호출한다"라는 의미로 객체를 생성하는 동작과는 본래 의도가 의미가 맞지 않습니다. 또한 참조연산자(::)를 사용할 때 다음과 같이 호출해야하기 때문에 호출시 복잡성이 늘어난다고 합니다. 그렇기 때문에 팩토리 함수는 무분별하게 남발하지 않고 꼭 필요한 곳에서 적절하게 사용해야 합니다.
LottoNumber.Companion::invoke
어떻게 사용해야 할까?
Value Class는 팩토리 함수의 객체를 재사용한다는 이점을 이용할 수 없습니다. 그렇기 때문에 다수의 객체를 재활용해야 한다면 일반 Class와 팩토리 함수를 사용하고, 객체를 재활용하지 않고 필드를 인라이닝해 성능을 최적화하고 싶다면 Value Class를 사용해야 합니다.
'KOTLIN' 카테고리의 다른 글
Abstract class vs interface in Kotlin (2) | 2025.04.01 |
---|---|
다시 읽는 Effective Kotlin - Item39. 태그 클래스보다는 클래스 계층을 사용하라 (2) | 2025.03.23 |
Channel 내부 동작 분석을 분석해보자 (0) | 2025.02.07 |
Kotlin Value Class With Project Valhalla (4) | 2024.11.22 |
[Kotlin]Sealed Class란 무엇일까 ? (0) | 2024.03.22 |