안녕하세요. 커뮤니케이션 앱 LINE의 모바일 클라이언트를 개발하고 있는 Ishikawa입니다.
저희 회사는 높은 개발 생산성을 유지하기 위해 코드 품질 및 개발 문화 개선에 힘쓰고 있습니다. 이를 위해 다양한 노력을 하고 있는데요. 그중 하나가 Review Committee 활동입니다.
Review Committee에서는 머지된 코드를 다시 리뷰해 리뷰어와 작성자에게 피드백을 주고, 리뷰하면서 얻은 지식과 인사이트를 Weekly Report라는 이름으로 매주 공유하고 있습니다. 이 Weekly Report 중 일반적으로 널리 적용할 수 있는 주제를 골라 블로그에 코드 품질 개선 기법 시리즈를 연재하고 있습니다.
이번에 블로그로 공유할 Weekly Report의 제목은 '왔던 길을 되돌아가 보자'입니다.
왔던 길을 되돌아가 보자
네트워크나 파일 시스템 같은 I/O를 사용하는 경우 I/O의 데이터 표현과 코드의 데이터 표현을 상호 변환해야 합니다. 대표적인 예 중 하나가 인터페이스 정의 언어(IDL)나 데이터베이스 스키마 등 외부에서 정의된 데이터와 코드에서 정의된 모델 클래스를 상호 변환하는 것입니다. 이때 '상태'나 '타입'을 의미하는 값을 사용한다면 코드에서 열거형(enumeration)을 사용하기도 합니다.
다음 코드에서는 데이터베이스에서 사용되는 값과 열거자(enumerator)를 상호 변환하기 위해 Map
을 사용합니다.
enum class AccountType { FREE, PREMIUM, ULTIMATE }
val DB_VALUE_TO_ACCOUNT_TYPE_MAP: Map<Int, AccountType> = mapOf( // or SparseArray
0 to AccountType.FREE,
1 to AccountType.PREMIUM,
2 to AccountType.ULTIMATE
)
val ACCOUNT_TYPE_TO_DB_VALUE_MAP: Map<AccountType, Int> = mapOf( // or EnumMap
AccountType.FREE to 0,
AccountType.PREMIUM to 1,
AccountType.ULTIMATE to 2
)
위 코드에 문제가 있을까요?
평행한 두 개의 도로
위 코드에서는 데이터베이스 값에서 열거형으로 변환하는 것과 그 반대로 변환하는 것이 각각 별도로 정의돼 있기 때문에 다음 두 가지 문제가 발생합니다.
- 사양 변경 시 두
Map
을 모두 업데이트해야 한다. - 두 변환 간에 서로 대응이 잘 이뤄지고 있는지 보장할 수 없다.
이 문제를 해결하려면 '역방향 변환'은 기존 변환으로 유도하는 것이 좋습니다. 특히 열거자 속성이나 when
/switch
식을 사용할 수 있는 경우 이를 Map
대신 사용하면 모든 열거자를 포괄하는 것을 보장하기 쉽습니다.
구현 예제 1: 열거자 속성 및 역변환 맵
열거자 속성을 사용하면 모든 열거자가 그에 대응하는 데이터베이스 값을 갖도록 할 수 있습니다. 예를 들어 Kotlin의 경우 associateBy
함수를 사용해 dbValue
에서 AccountType
으로 Map
을 만들 수 있습니다. 다른 많은 언어에서도 map
과 같은 함수나 열거자에 대한 반복문을 작성해 같은 작업을 수행할 수 있습니다.
enum class AccountType(val dbValue: Int) {
FREE(0),
PREMIUM(1),
ULTIMATE(2);
companion object {
val DB_VALUE_TO_TYPE_MAP: Map<Int, AccountType> =
AccountType.entries.associateBy(AccountType::dbValue)
}
}
다음 코드는 변환을 수행하는 예제입니다.
accountType.dbValue // AccountType to DB value
AccountType.DB_VALUE_TO_TYPE_MAP[dbValue] // DB value to AccountType
위 구현 방법은 단순하다는 장점이 있지만, AccountType
이 광범위하게 사용되는 모델의 경우 dbValue
도 광범위하게 보인다는 점이 문제가 될 수 있습니다. 또한 여러 데이터 표현 간에 상호 변환이 필요한 경우 dbValue
에 해당하는 값을 여러 개 정의해야 하므로 혼란을 초래하기 쉽습니다.
다음 AccountType
구현은 여러 변환을 위한 값을 가지고 있는데요. 이대로 변환 대상과 변환 원본이 늘어나면 클래스 정의가 금세 비대해질 것이라는 것을 쉽게 예상할 수 있습니다. 이런 경우에는 다음에 소개할 '구현 예제 2' 적용을 고려해 보는 게 좋습니다.
enum class AccountType(
val dbValue: Int,
val fooApiJsonValue: String,
val barApiJsonValue: String
) {
...
}
구현 예제 2: 개별 레이어 내에서 변환 구현 및 역변환 맵
변환을 사용하는 코드가 기능이나 레이어, 스코프 내에 국한되어 있다면 해당 범위 내에서 변환을 정의하는 방법이 있습니다. 다음 코드는 serverValue
와 AccountType
의 상호 변환을 AccountTypeNetworkClient
에 국한된 범위로 정의합니다. 이때 AccountType
에서 serverValue
로 변환하는 것에는 when
식을 사용해 모든 열거자를 포괄하는 것을 보장합니다.
class AccountTypeNetworkClient {
/* snip */
companion object {
private fun AccountType.toServerValue(): String = when (this) {
AccountType.FREE -> "free"
AccountType.PREMIUM -> "premium"
AccountType.ULTIMATE -> "ultimate"
}
private val SERVER_VALUE_TO_TYPE_MAP: Map<String, AccountType> =
AccountType.entries.associateBy { it.toServerValue() }
}
}
또 다른 방법은 Mapper
나 Converter
와 같은 클래스를 별도로 정의하는 것입니다. 플랫폼에 따라 이를 쉽게 구현할 수 있는 플랫폼이 있습니다. 단, Mapper
나Converter
를 구현하는 경우에도 순방향 변환과 역방향 변환은 같은 파일이나 클래스에서 정의해야 합니다.
단사(injection, 單射)를 보장하는 단위 테스트
위 구현 예제는 둘 다 모든 열거자에 대응하는 값이 있다는 것(열거자가 정의역일 것)을 보장합니다. 단, 값이 중복되지 않는다는 것(단사일 것)은 보장하지 않습니다. 값이 중복되지 않음을 보장하려면 다음과 같은 테스트를 작성하면 됩니다.
assertEquals(DB_VALUE_TO_TYPE_MAP.size, AccountType.entries.size)
한 줄 요약: 양방향 변환을 할 때는 한쪽 변환 로직에서 다른 쪽 로직을 연역적으로 구하는 것이 바람직하다.
키워드:type conversion
,bidirectional
,single source of truth