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

코드 품질 개선 기법 29편: 고르디우스 변수

이 글은 2024년 6월 13일에 일본어로 먼저 발행된 기사를 번역한 글입니다.

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

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

이번에 블로그로 공유할 Weekly Report의 제목은 '고르디우스 변수'입니다(참고: 고르디우스의 매듭).

고르디우스 변수

로컬과 원격, 두 저장소에 FooData라는 데이터가 저장돼 있고 이를 동기화하기 위해 FooSynchronizer를 구현한다고 가정해 보겠습니다. 여기서 가정 상황은 다음과 같습니다.

  • 원격 FooData가 원본이고, 이를 사용해 로컬 FooData를 업데이트한다.
  • 원격 및 로컬 데이터는 각각 FooLocalDaoFooRemoteClient에서 가져올 수 있다.
  • 문제를 단순화하기 위해 트랜잭션 등은 고려하지 않아도 된다.

FooSynchronizersynchronizeWithRemoteEntries 함수에서는 로컬과 원격 간 FooData ID 목록을 비교하고, 그 결과에 따라 추가, 업데이트, 삭제를 수행합니다.

  • 추가: ID가 원격에만 존재할 때 실행(id !in localEntryIds && id in remoteEntryIdstrue일 때 실행)
  • 업데이트: ID가 로컬과 원격에 모두 존재할 때 실행(id in localEntryIds && id in remoteEntryIdstrue일 때 실행)
  • 삭제: ID가 로컬에만 존재할 때 실행(id in localEntryIds && id !in remoteEntryIdstrue일 때 실행)

이 사양을 충족하도록 다음과 같이 FooSynchronizer를 구현했습니다.

class FooSynchronizer(
    private val fooRemoteClient: FooRemoteClient,
    private val fooLocalDao: FooLocalDao
) {
    fun synchronizeWithRemoteEntries() {
        val remoteEntries: List<FooModel> = fooRemoteClient.fetch()
        val remoteEntryIds = remoteEntries.map(FooModel::id).toSet()

        val localEntries: List<FooModel> = fooLocalDao.getAllEntries()
        val localEntryMap = localEntries.associateBy(FooModel::id)
        val localEntryIds = localEntryMap.keys

        val createdEntryIds = remoteEntryIds.subtract(localEntryIds)
        val deletedEntryIds = localEntryIds.subtract(remoteEntryIds)

        remoteEntries.forEach { remoteEntry ->
            // 항목 추가
            if (remoteEntry.id in createdEntryIds) {
                ... // `remoteEntry`를 사용해 '추가'하는 로직
                return@forEach
            }

            // 항목 업데이트
            val localEntry = localEntryMap[remoteEntry.id]
                ?: error("This must not happen.")
            ... // `remoteEntry`와 `localEntry`를 사용해 '업데이트'하는 로직
        }

        localEntries.asSequence()
            .filter { it.id in deletedEntryIds }
            .forEach { deletedEntry ->
                ... // `deletedEntry`를 사용해 '삭제'하는 로직
            }
    }
}

여기서 List.associateByList에서 Map을 생성하는 함수입니다. 즉 localEntries.associateBy(FooModel::id)는 ID를 키로, FooData를 값으로 하는 Map을 반환합니다. 또한 subtract는 집합의 차집합(수신자에 포함되고 인수에는 포함되지 않는 요소)을 반환하는 함수입니다.

이 코드에서 개선할 수 있는 점이 있을까요?

매듭을 풀기 위한 중간 목표

이 코드의 흐름을 따라가려면 상당한 노력이 필요합니다. 그 이유 중 하나는 데이터의 의존성이 복잡하게 얽혀 있기 때문입니다. 예를 들어 추가할 요소 목록인 createdEntryIds를 구하는 흐름은 다음과 같습니다.

  • remoteEntries에서 remoteEntryIds를 구한다.
  • localEntries에서 localEntryMap를 구하고 거기서 다시 localEntryIds를 구한다.
  • remoteEntryIdslocalEntryIds를 비교해서 createdEntryIds를 구한다.

또한 createdEntryIds는 ID만 가지고 있으므로 실제로 추가를 수행하려면 FooData를 가져와야 하며, 이에 따라 위 코드에서는 remoteEntries를 다시 사용하고 있습니다. 이처럼 동일한 데이터를 여러 번 사용하면 처리 흐름이 복잡해집니다.

여기에 업데이트와 삭제 코드 관련해서도 복잡한 의존성 때문에 발생하는 문제가 있습니다.

  • 업데이트 코드 문제: localEntryMap에 대한 의존성 때문에 원래라면 발생할 수 없는 런타임 에러를 정의해야 한다.
  • 삭제 코드 문제: 추가 및 업데이트 코드와 일관성이 맞지 않는다.

위와 같은 문제를 해결하려고 할 때, 이상적인 중간 데이터가 어떤 것인지 상상하고 거기서부터 함수의 구성을 역으로 설계하면 잘 풀릴 때가 있습니다. 이번 경우에는 데이터 추가, 업데이트, 삭제를 수행하므로 createdEntries, updatedEntries, deletedEntries라는 세 쌍이 있다면 코드를 단순화할 수 있을 것입니다.

이 세 쌍은 다음과 같이 만들 수 있습니다.

  1. 로컬과 원격의 모든 ID 집합 allEntryIds를 생성한다.
  2. 각 ID에 대해 로컬 및 원격 데이터 쌍 Pair<FooData?, FooData?>를 생성한다.
  3. Sequence<Pair<S?, T?>>에서 세 쌍을 만드는 partitionByNullity 함수를 만든다. 세 쌍의 각 요소는 다음과 같다.
    • 첫 번째 요소 List<S>: S가 non-null이고 T가 null인 경우
    • 두 번째 요소 List<Pair<S, T>>: ST가 모두 non-null인 경우
    • 세 번째 요소 List<T>: S가 null이고 T가 non-null인 경우

위에서 생성한 partitionByNullity를 사용하면 함수 흐름을 간결하게 만들 수 있습니다. 대략적인 흐름은 다음과 같습니다.

  1. ID -> 원격 FooDataMap, remoteEntryMap을 생성한다.
  2. ID -> 로컬 FooDataMap, localEntryMap을 생성한다.
  3. remoteEntryMaplocalEntryMap에서 추가, 업데이트, 삭제 항목 (createdEntries, updatedEntries, deletedEntries)을 생성한다.
  4. createdEntries, updatedEntries, deletedEntries 각각에 대해 추가, 업데이트, 삭제를 실행한다.

구현 예시는 다음과 같습니다.

    fun synchronizeWithRemoteEntries() {
        val remoteEntries: List<FooModel> = fooRemoteClient.fetch()
        val remoteEntryMap = remoteEntries.associateBy(FooModel::id)

        val localEntries: List<FooModel> = fooLocalDao.getAllEntries()
        val localEntryMap = localEntries.associateBy(FooModel::id)
        
        val allEntryIds = remoteEntryMap.keys + localEntryMap.keys
        val (createdEntries, updatedEntries, deletedEntries) = allEntryIds.asSequence()
            .map { id -> remoteEntryMap[id] to localEntryMap[id] }
            .partitionByNullity()
        
        createdEntries.forEach { createdEntry ->
            ... // `createdEntry`를 사용해 '추가'하는 로직
        }

        updatedEntries.forEach { (remoteEntry, localEntry) ->
            ... // `remoteEntry`와 `localEntry`를 사용해 '업데이트'하는 로직
        }
        
        deletedEntries.forEach { deletedEntry ->
            ... // `deletedEntry`를 사용해 '삭제'하는 로직
        }
    }

    companion object {
        /** ... */
        private fun <S : Any, T : Any> Sequence<Pair<S?, T?>>.partitionByNullity():
                Triple<List<S>, List<Pair<S, T>>, List<T>> {
            val leftEntries: MutableList<S> = mutableListOf()
            val bothEntries: MutableList<Pair<S, T>> = mutableListOf()
            val rightEntries: MutableList<T> = mutableListOf()
            forEach { (left, right) ->
                when {
                    left != null && right == null -> leftEntries += left
                    left != null && right != null -> bothEntries += left to right
                    left == null && right != null -> rightEntries += right
                    else /* left == null && right == null */ -> Unit
                }
            }
            return Triple(leftEntries, bothEntries, rightEntries)
        }
    }

이렇게 하면 synchronizeWithRemoteEntries 내에서 가장 중요한 코드인 추가, 업데이트, 삭제의 forEach를 부각할 수 있습니다.


한 줄 요약: 데이터의 의존성이 복잡할 때는 이상적인 중간 데이터를 생성해서 정리할 수 있다.

키워드: data dependency, function flow, intermediate data structure