LY Corporation Tech Blog

LY Corporation과 LY Corporation Group(LINE Plus, LINE Taiwan and LINE Vietnam)의 기술과 개발 문화를 알립니다.

This post is also available in the following languages. Japanese, English

코드 품질 개선 기법 3편: 전략 없는 전략

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

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

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

이번에 블로그로 공유할 Weekly Report의 제목은 '전략 없는 전략'입니다. 

전략 없는 전략

다음 Loggable은 로그 출력 정보를 담고 있는 인터페이스입니다.

interface Loggable {
    val logType: LogType
    val logLevel: LogLevel
    val logDescription: String
    val timestamp: Long
    val codeLocation: StackTraceElement
    ...
)

이때 어떤 속성을 로그로 출력할지 결정하기 위해 LogAttribute라는 열거형이 필요해졌다고 가정해 봅시다. createLogMessage 함수는 입력된 LogAttribute에 따라 어떤 속성을 사용해 로그 메시지를 구성할지 결정합니다.

enum class LogAttribute { LOG_TYPE, LOG_LEVEL, LOG_DESCRIPTION, ...}

fun createLogMessage(loggable: Loggable, attributesToLog: Set<LogAttribute>): String {
  ...
}

예를 들어 setOf(LOG_LEVEL, LOG_TYPE, LOG_DESCRIPTION)attributesToLog로 입력되면 로그 메시지에 포함할 속성은 로그 레벨과 종류, 설명이 될 것이고, 이에 따라 ‘FATAL, Crash, something wrong with some parameter’와 같은 로그가 출력될 것입니다.

여기서 해당 LogAttribute의 순서는 정적으로 정해져 있어서 변경할 필요가 없다고 가정해 봅시다. 예를 들어 '로그 레벨은 메시지의 시작 부분에 위치해야 한다'는 규칙이 있다고 생각해 보겠습니다. 이 규칙을 구현할 때 개발자는 아래와 같이 순서를 결정하는 목록(ORDERED_ATTRIBUTES_TO_LOG)을 정의하고 이를 사용해 로그 메시지를 구성할 수 있습니다.

// This order might be different from `ordinal` of `LogAttribute`.
val ORDERED_ATTRIBUTES_TO_LOG: List<LogAttribute> = listOf(
  LOG_LEVEL,
  LOG_TYPE,
  LOG_DESCRIPTION,
  ...
)

fun createLogMessage(loggable: Loggable, attributesToLog: Set<LogAttribute>): String =
    ORDERED_ATTRIBUTES_TO_LOG.asSequence()
        .filter(attributesToLog::contains)
        .map { attribute ->
            when (attribute) {
                LogAttribute.LOG_LEVEL -> getLogLevelText(loggable)
                LogAttribute.LOG_TYPE -> getLogTypeText(loggable)
                ...
            }
        }.joinToString()

위 코드에 문제가 있을까요?

반복되는 '고도의 유연성'

해당 코드는 반복문 내부에 분기가 있고 각 분기는 반복하면서 기껏해야 한 번만 사용되는 구조입니다. 이로 인해 다음 두 가지 문제가 발생합니다.

  • 분기를 반복문 내부에 직접 작성했기 때문에 함수의 흐름을 파악하기 위해서 각 분기를 파악해야 한다.
  • 타입의 순서와 분기를 대응시키기 어렵다.

이 문제를 해결하기 위한 네 가지 방법을 소개하겠습니다. 각 방법은 장단점이 있으므로 상황에 따라 적절히 사용해야 합니다.

첫 번째 방법: 반복문 제거하기

'타입을 나타내는 컬렉션'이 충분히 작은 경우 아래와 같이 각 요소를 if로 직접 분기해도 좋습니다.

val message = StringBuilder()
if (attributesToLog.contains(LogAttribute.LOG_LEVEL)) {
    message.append(getLogLevelText(loggable))
}
if (attributesToLog.contains(LogAttribute.LOG_TYPE)) {
    message.append(getLogTypeText(loggable))
}
...

getAttributeTextOrEmpty와 같은 보조 함수를 만들어 이용하면 더 명확하게 만들 수 있습니다.

val message =
    loggable.getAttributeTextOrEmpty(attributesToLog, LogAttribute.LOG_LEVEL, ::getLogLevelText) +
    loggable.getAttributeTextOrEmpty(attributesToLog, LogAttribute.LOG_TYPE, ::getLogTypeText) +
    ...

private fun Loggable.getAttributeTextOrEmpty(
    attributesToLog: Set<LogAttribute>,
    targetAttribute: LogAttribute,
    attributeTextCreator: (Loggable) -> String
): String = if (targetAttribute in attributesToLog) attributeTextCreator(this) else ""

이 방법에는 두 가지 단점이 있습니다. 첫 번째는 모든 타입이 포함됐는지 확인하는 단위 테스트를 작성하기 어렵다는 것입니다. 따라서 새로운 타입을 추가했을 때 구현 누락을 경고할 수 있는 수단이 부족할 수 있습니다. 두 번째는 joinToString과 같은 컬렉션 유틸리티 함수를 사용할 수 없다는 것입니다. 위 코드에서는 사실 ", "로 연결하는 코드를 생략했는데요. 이를 구현하면 코드가 조금 더 복잡해집니다.

두 번째 방법: 분기 추출

다음으로 단순히 조건 분기를 보조 함수로 추출하는 방법도 있습니다.

fun createLogMessage(loggable: Loggable, attributesToLog: Set<LogAttribute>): String =
    ORDERED_ATTRIBUTES_TO_LOG.asSequence()
        .filter(attributesToLog::contains)
        .map { attribute -> attribute.getLogText(loggable) }
        .joinToString()

private fun LogAttribute.getLogText(loggable: Loggable): String = when(this) {
    LogAttribute.LOG_TYPE -> ...
    ...
}    

이 방법은 가독성을 어느 정도 개선하기는 하지만, ORDERED_ATTRIBUTES_TO_LOGgetLogText 간의 대응이 어렵다는 점은 개선하지 못합니다. 또한 분기에 else(default)를 사용하면 포괄성을 보장할 수 없게 되므로 else를 사용하지 않도록 구현하는 것이 좋습니다.

세 번째 방법: 로직을 타입에 포함하기

전략(strategy) 패턴이나 이와 유사한 구조를 적용해 각 타입에 고유 로직을 포함할 수 있습니다.

enum class LogAttribute {
  LOG_TYPE {
    override fun getLogText(loggable: Loggable) = ...
  },
  LOG_LEVEL {
    override fun getLogText(loggable: Loggable) = ...
  },
  ...;

  abstract fun getLogText(loggable: Loggable)
}

이 방법을 사용하면 getLogText는 다음과 같이 됩니다.

fun createLogMessage(loggable: Loggable, attributesToLog: Set<LogAttribute>): String =
    ORDERED_ATTRIBUTES_TO_LOG.asSequence()
        .filter(attributesToLog::contains)
        .map { attribute -> attribute.getLogText(loggable) }
        .joinToString()

createLogMessage에서는 분기가 보이지 않으므로 함수의 흐름을 쉽게 파악할 수 있습니다. 또한 포괄성을 보장하기도 쉽습니다. 로직 구현이 누락되면 컴파일 오류가 발생하며, ORDERED_ATTRIBUTES_TO_LOG가 모든 요소를 포함하고 있는지 확인하는 단위 테스트도 쉽게 작성할 수 있습니다.

아래와 같이 ORDERED_ATTRIBUTES_TO_LOG를 생성하기 위해 val logOrder: Int라는 속성을 각 타입에 포함시켜도 괜찮습니다.

val ORDERED_ATTRIBUTES_TO_LOG: List<LogAttribute> =
    LogAttribute.values().sortedBy(LogAttribute::logOrder)

단, 이 방법은 해당 타입을 다른 기능에서 사용하지 않는 경우에만 사용할 수 있습니다. 해당 타입이 광범위하게 사용되는 경우 특정 기능 고유의 로직이나 값을 포함시키면 의존 관계가 복잡해집니다.

네 번째 방법: 관계를 명시하는 튜플 만들기

타입과 타입의 순서, 로직의 연관성을 명시하기 위해 다음 AttributeTextModel과 같은 튜플을 만들 수도 있습니다.

class AttributeTextModel(val attributeType: LogAttribute, val textCreator: (Loggable) -> String)

이때 createLogMessage는 다음과 같습니다.

val ORDERED_ATTRIBUTES_TO_LOG: List<AttributeTextModel> = listOf(
    AttributeTextModel(LogAttribute.LOG_LEVEL, ::getLogLevelText),
    AttributeTextModel(LogAttribute.LOG_TYPE, ::getLogTypeText),
    ...
)

fun createLogMessage(loggable: Loggable, attributesToLog: Set<LogAttribute>): String =
    ORDERED_ATTRIBUTES_TO_LOG.asSequence()
        .filter { attributesToLog.contains(it.attributeType) }
        .map { it.textCreator(loggable) }
        .joinToString()

이와 같은 방법으로 '타입의 순서가 정의돼 있다면 대응하는 로직 attributesToLog도 구현돼 있다'는 것을 보장할 수 있습니다. 또한 세 번째 방법과 달리 널리 사용되는 타입에도 기능 고유의 로직을 연결할 수 있습니다.

위 코드에서는 '모든 타입에 대응하는 로직이 있다'는 포괄성은 보장하지 않는데요. 단위 테스트를 작성해 이를 보완할 수 있습니다. 테스트할 때 '각 LogAttribute에 대응하는 AttributeTextModelORDERED_ATTRIBUTES_TO_LOG 내에 있다'는 것을 확인하면 됩니다.


한 줄 요약: 반복문 내에 큰 조건 분기가 있는 경우 분기와 로직 간의 대응 관계를 알기 쉽게 재구조화하는 것을 고려한다.

키워드: loop, conditional branch, strategy pattern