시스템아 미안해

Chapter 3: Reusability - Item 20: Do not repeat common algorithms 본문

책/Effective Kotlin

Chapter 3: Reusability - Item 20: Do not repeat common algorithms

if else 2026. 1. 30. 14:07

 

저는 개발자들이 같은 알고리즘을 반복해서 다시 구현하는 경우를 자주 봅니다.
여기서 말하는 알고리즘이란, 프로젝트에 종속되지 않은 패턴을 의미하며,
비즈니스 로직을 포함하지 않고 별도의 모듈이나 심지어 라이브러리로도 추출할 수 있는 것들을 말합니다.

 

이에는 수학적 연산, 컬렉션 처리, 그 밖의 다양한 공통 동작들이 포함됩니다.
때로는 최적화된 정렬 알고리즘처럼 길고 복잡한 경우도 있지만,
숫자를 특정 범위로 보정(coercion)하는 것과 같은 아주 단순한 예도 많이 존재합니다.

 
 
kotlin
val percent = when {
    numberFromUser > 100 -> 100
    numberFromUser < 0 -> 0
    else -> numberFromUser
}

 

이런 기능은 직접 구현할 필요가 없다는 점에 주목하시기 바랍니다.
이미 표준 라이브러리(stdlib)에 coerceIn 확장 함수로 제공되고 있기 때문입니다.

 
kotlin
val percent = numberFromUser.coerceIn(0, 100)

 

짧더라도 반복되는 알고리즘을 추출하는 것에는 다음과 같은 장점들이 있습니다.

  • 알고리즘을 직접 구현하는 것보다 단일 호출로 사용하는 편이 훨씬 쉽기 때문에 개발 속도가 빨라집니다.
  • 이름이 붙어 있기 때문에, 구현을 읽지 않고도 이름만 보고 어떤 알고리즘인지 파악할 수 있습니다.
    해당 알고리즘에 익숙한 개발자에게는 특히 이해하기 쉽습니다.
    물론 개념에 익숙하지 않은 개발자에게는 처음엔 다소 어려울 수 있지만, 반복적으로 사용되는 알고리즘의 이름을 익혀 두는 것은 장기적으로 충분한 가치가 있습니다. 한 번 익히고 나면 이후에는 계속해서 그 이점을 누릴 수 있습니다.
  • 불필요한 잡음을 제거할 수 있어, 비정상적이거나 특이한 로직을 더 쉽게 발견할 수 있습니다.
    긴 알고리즘 안에서는 의도와 다른 로직이 숨어 있어도 놓치기 쉽습니다.
    예를 들어 sortedBy와 sortedByDescending의 차이를 생각해 보십시오. 두 함수의 본문은 거의 동일하지만, 호출부만 보아도 정렬 방향이 명확합니다.
    만약 이 로직을 매번 직접 구현해야 한다면, 오름차순인지 내림차순인지 혼동하기 쉬웠을 것입니다.
    알고리즘 구현 앞에 주석을 다는 것도 근본적인 해결책은 아닙니다. 실제로는 코드가 변경되면서 주석이 함께 수정되지 않는 경우가 많아, 시간이 지날수록 주석을 신뢰하기 어려워지기 때문입니다.
  • 한 번 최적화하면, 해당 함수를 사용하는 모든 곳에서 그 이점을 누릴 수 있습니다.

표준 라이브러리 학습하기 (Learn the standard library)

공통 알고리즘은 거의 항상 이미 누군가에 의해 정의되어 있습니다.
대부분의 라이브러리는 사실상 공통 알고리즘들의 모음이라고 볼 수 있습니다.

 

그중에서도 가장 대표적인 것이 바로 **표준 라이브러리(stdlib)**입니다.
표준 라이브러리는 주로 확장 함수 형태로 정의된 방대한 유틸리티 집합입니다.
표준 라이브러리의 함수들을 익히는 일은 쉽지 않을 수 있지만, 그만한 가치가 있습니다.

 

이를 제대로 활용하지 못하면 개발자들은 같은 바퀴를 계속해서 다시 발명하게 됩니다.
예시를 하나 살펴보기 위해, 다음은 한 오픈소스 프로젝트에서 가져온 코드 조각입니다.

 
kotlin
override fun saveCallResult(item: SourceResponse) {
    var sourceList = ArrayList<SourceEntity>()
    item.sources.forEach {
        var sourceEntity = SourceEntity()
        sourceEntity.id = it.id
        sourceEntity.category = it.category
        sourceEntity.country = it.country
        sourceEntity.description = it.description
        sourceList.add(sourceEntity)
    }
    
    db.insertSources(sourceList)
}

여기서 forEach를 사용하는 것은 별다른 의미가 없습니다.
전통적인 for 루프 대신 이를 사용했을 때 얻는 이점을 저는 찾기 어렵습니다.

 

다만 이 코드에서 분명히 보이는 것은, **한 타입을 다른 타입으로 변환하는 작업(mapping)**이 이루어지고 있다는 점입니다.
이런 경우에는 map 함수를 사용하는 것이 더 적절합니다.

 

또 하나 짚고 넘어갈 점은 SourceEntity의 구성 방식이 그다지 이상적이지 않다는 것입니다.
이는 Kotlin에서는 이미 구식이 된 JavaBean 패턴입니다.
대신 팩토리 메서드나 **주 생성자(primary constructor)**를 사용하는 편이 바람직합니다
(자세한 내용은 5장 *객체 생성(Object creation)*을 참고하시기 바랍니다).

 

만약 어떤 이유로든 이 구조를 유지해야 한다면,
적어도 apply를 사용해 하나의 객체에 대한 모든 프로퍼티 설정을 암묵적으로 묶는 것이 좋습니다.

 

다음은 이러한 점들을 반영해 간단히 정리한 이후의 함수입니다.

 
 
kotlin
override fun saveCallResult(item: SourceResponse) {
    val entries = item.sources.map(Source::toEntry)
    db.insertSources(entries)
}

private fun Source.toEntry() = SourceEntity().apply {
    id = this.id
    category = this.category
    country = this.country
    description = this.description
}

자체 유틸리티 구현하기 (Implementing your own utils)

모든 프로젝트에서는 언젠가 표준 라이브러리에 없는 알고리즘이 필요해지는 순간이 옵니다.
예를 들어, 컬렉션에 들어 있는 숫자들의 **곱(product)**을 계산해야 한다면 어떨까요?

 

이는 잘 알려진 추상화이므로, 범용적인 유틸리티 함수로 정의해 두는 것이 바람직합니다.

 
kotlin
fun Iterable<Int>.product() =
    fold(1) { acc, i -> acc * i }

여러 번 사용될 때까지 기다릴 필요는 없습니다.
product는 잘 알려진 수학적 개념이며, 그 이름만으로도 개발자에게 의미가 분명합니다.
나중에 다른 개발자가 이 기능을 필요로 하게 될 수도 있고, 이미 정의되어 있다는 사실을 알게 된다면 분명 반가워할 것입니다.물론 그 개발자가 이 함수를 잘 찾아낼 수 있기를 기대해야겠지만요.

 

동일한 결과를 내는 함수가 중복으로 존재하는 것은 좋지 않은 관행입니다.
각 함수는 테스트되어야 하고, 기억되어야 하며, 유지보수되어야 합니다. 이 모든 것은 비용입니다.
따라서 필요하지 않은 함수를 정의해서는 안 되며,

직접 구현하기 전에 이미 존재하는 함수가 있는지 먼저 찾아보는 것이 바람직합니다.

 

product가 Kotlin 표준 라이브러리의 대부분의 함수들처럼 **확장 함수(extension function)**로 정의되어 있다는 점에도 주목할 만합니다.
공통 알고리즘을 추출하는 방법은 최상위 함수나 프로퍼티 델리게이트부터 클래스에 이르기까지 다양하지만,
확장 함수는 특히 좋은 선택입니다. 그 이유는 다음과 같습니다.

  • 함수는 상태를 가지지 않으므로, 특히 **부수 효과가 없는 동작(behavior)**을 표현하는 데 매우 적합합니다.
  • 일반적인 최상위 함수에 비해, 확장 함수는 구체적인 타입을 가진 객체에서만 제안되기 때문에 더 적절합니다.
  • 인자를 수정하는 것보다 확장 리시버를 수정하는 방식이 더 직관적입니다.
  • companion object나 static 메서드에 비해, 확장 함수는 IDE 자동 완성에서 찾기가 훨씬 쉽습니다.
    예를 들어 "Text".isEmpty()는 TextUtils.isEmpty("Text")보다 찾기 쉽습니다.
    "Text" 뒤에 점(.)을 찍으면 해당 객체에 적용 가능한 모든 확장 함수가 바로 제안되기 때문입니다.
    반면 TextUtils.isEmpty를 찾으려면, 그것이 어디에 정의되어 있는지 추측해야 하고,
    여러 라이브러리의 유틸 객체들 사이를 헤매야 할 수도 있습니다.
  • 메서드를 호출할 때는 최상위 함수인지, 클래스나 슈퍼클래스의 메서드인지 혼동하기 쉽지만,
    확장 함수는 반드시 객체에 대해 호출되므로 이런 혼란이 발생하지 않습니다.