이펙티브 코틀린 Item 31: 문서로 규약을 정의하라

woga·2023년 8월 26일
0

코틀린 공부

목록 보기
34/54
post-thumbnail
fun Context.showMessage(
    message: String,
    duration: MessageLength = MessageLength.LONG
) {
    val toastDuration = when (duration) {
        MessageLength.SHORT -> Toast.LENGTH_SHORT
        MessageLength.LONG -> Toast.LENGTH_LONG
    }
    Toast.makeText(this, message, toastDuration).show()
}

enum class MessageLength { SHORT, LONG }

메시지 출력 방법을 자유롭게 바꿀 수 있게 함수로 추출했다. 하지만 문서화가 잘 돼 있지 않다. 다른 개발자는 이 코드를 읽고 당연히 토스트를 출력할 거라 생각할 수 있다. 하지만 showMessage라는 이름은 토스트가 아닌 다른 타입으로도 메시지를 출력할 수 있게 하고자 붙인 이름이다. 따라서 이 함수가 뭘 하는지 명확하게 설명하고 싶다면 KDoc 주석을 붙이는 게 좋다.

/**
 * 프로젝트가 사용자에게 짧은 메시지를 표시하는 보편적인 방법
 * @param message 유저에게 표시돼야 하는 텍스트
 * @param length 메시지 길이가 어느 정도 되는지 나타내는 enum 값
 */
fun Context.showMessage(
    message: String,
    duration: MessageLength = MessageLength.LONG
) {
    val toastDuration = when (duration) {
        MessageLength.SHORT -> Toast.LENGTH_SHORT
        MessageLength.LONG -> Toast.LENGTH_LONG
    }
    Toast.makeText(this, message, toastDuration).show()
}

enum class MessageLength { SHORT, LONG }

일반적으로 대부분의 함수, 클래스는 이름만으로 예측할 수 없는 세부사항들을 갖고 있다. 예를 들어 아래 코드에서 powerset()의 powerset은 멱집합이라는 명확한 수학적 개념이지만 멱집합이 뭔지 모르는 사람이 있을 수 있으므로 추가적인 설명이 필요하다.

/**
 * Powerset은 자신과 빈 집합을 포함해서 리시버의 모든 하위 집합을 리턴한다
 */
fun <T> Collection<T>.powerset(): Set<Set<T>> =
    if (isEmpty()) setOf(emptySet())
    else take(size - 1)
            .powerset()
            .let { it + it.map { it.last() } }

이런 설명만으론 멱집합이 어떤 순서인지 알 수 없다. 따라서 이런 함수를 쓰는 사용자는 powerset()이 리턴하는 컬렉션의 요소 순서를 의존하는 코드를 작성해선 안 된다. 순서를 명확하게 지정하지 않았으므로 이후에 함수 구현을 최적화할 때 순서가 바뀔 수 있기 때문이다.

/**
 * Powerset은 자신과 빈 집합을 포함해서 리시버의 모든 하위 집합을 리턴한다
 */
fun <T> Collection<T>.powerset(): Set<Set<T>> =
    powerset(this, setOf(setOf()))

private tailrec fun <T> powerset(
    left: Collection<T>,
    acc: Set<Set<T>>
): Set<Set<T>> = when {
    left.isEmpty() -> acc
    else -> {
        val head = left.first()
        val tail = left.drop(1)
        powerset(tail, acc + acc.map { it + head })
    }
}

일반적인 문제는 행위가 문서화되지 않고 요소의 이름이 명확하지 않다면 이를 쓰는 사용자는 우리가 만들려고 했던 추상화 목표가 아닌 현재 구현에만 의존하게 된다는 것이다. 이런 문제는 예상되는 행위를 문서로 설명해 해결한다.

규약

어떤 행위를 설명하면 사용자는 이를 일종의 약속으로 취급하며 이를 기반으로 스스로 자유롭게 생각하던 예측을 조정한다. 이처럼 예측되는 행위를 요소의 규약(contract of an element)이라고 부른다. 현실의 규약과 마찬가지로 규약의 당사자들은 서로 상대방이 규약을 안정적으로 계속 지킬 거라 믿는다.

규약이 적절하게 정의돼 있다면 클래스를 만든 사람은 클래스가 내부적으로 어떻게 구현돼 있는지를 걱정하지 않아도 된다. 클래스 구현을 믿을 수 있으므로 이를 의존해서 다른 뭔가를 만들 수도 있다.

만약 규약을 설정하지 않는다면 클래스를 쓰는 사람들은 스스로 할 수 있는 것, 할 수 없는 것을 모르므로 구현의 세부적인 정보에 의존하게 된다. 클래스를 만든 사람은 사용자가 뭘 할지 알 수가 없으므로 사용자의 구현을 망칠 위험이 있다. 따라서 규약을 설정하는 것은 중요하다.

규약 정의하기

규약을 정의하는 방법은 다양하게 있다. 간단하게 대표적인 몇 가지를 정리한다.

  • 이름 : 일반적인 개념과 관련된 메서드는 이름만으로 동작을 예측할 수 있다. sum이란 메서드가 있다면 이 메서드가 뭘 하는 메서드인지 문서를 볼 필요도 없을 것이다

  • 주석과 문서 : 필요한 모든 규약을 적을 수 있는 강력한 방법이다

  • 타입 : 타입은 객체에 대한 많은 걸 알려준다. 어떤 함수의 선언에 있는 리턴 타입, 아규먼트 타입은 큰 의미가 있다. 자주 쓰이는 타입의 경우, 타입만 봐도 어떻게 쓰는지 알 수 있지만 일부 타입은 문서에 추가로 설명해야 할 의무가 있다

주석을 써야 할까?

자바 커뮤니티 초기에는 문학적 프로그래밍이란 개념이 굉장히 인기 있었다. 이는 주석으로 모든 걸 설명하는 프로그래밍 방식이다. 하지만 10년이 지난 후에는 주석 없이도 읽을 수 있는 코드를 작성해야 하는 프로그래밍 방식으로 바뀌었다. 기존의 문학적 프로그래밍에서 쓰이던 주석은 여러 비판을 받았다.

극단적인 건 언제나 좋지 않다. 필자는 물론 코드만 읽어도 어느 정도 알 수 있는 코드를 만들어야 한다는 데 절대적으로 동의한다. 하지만 주석을 함께 쓰면 함수 또는 클래스에 더 많은 내용의 규약을 설명할 수 있다. 추가적으로 현대의 주석은 문서를 자동 생성하는 데 많이 쓰인다. 문서는 프로젝트에서 가장 진실된 내용이 적힌 것으로 취급된다.

물론 대부분의 기능은 이름 등으로도 뭘 하는지 확실하게 알 수 있으므로 주석을 활용한 추가적인 설명이 필요없다. 예를 들어 아래 코드의 product는 이름이 곱셈이라는 명확한 수학적 개념을 나타내므로 추가적인 주석이 필요 없다

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

여기 주석을 다는 것은 코드를 산만하게 만드는 노이즈다. 함수명과 파라미터만으로 정확하게 표현되는 요소에는 따로 주석을 넣지 않는 게 좋다. 아래는 불필요한 주석의 예시다.

// 리스트의 모든 숫자를 곱한다
fun List<Int>.product() = fold(1) { acc, i -> acc * i }

추가적으로 주석을 다는 것보다 함수로 추출하는 게 훨씬 좋다는 것에 동의한다. 아래 예를 확인한다.

fun update() {
    // 사용자를 업데이트
    for (user in users) {
        user.update()
    }
    
    // 책 업데이트
    for (book in books) {
        updateBook(book)
    }
}

이 함수의 주석 아래 코드를 보면 명확하게 함수로 추출할 수 있는 구성이다. 해당 부분을 함수로 추출하면 주석이 없어도 이해하기 쉬운 코드를 만들 수 있다.

아래 코드는 private 메서드 등의 별도 요소를 써서 이 부분을 추출한 것이다.

fun update() {
    updateUsers()
    updateBooks()
}

private fun updateUsers() {
    for (user in users) {
        user.update()
    }
}

private fun updateBooks() {
    for (book in books) {
        updateBook(book)
    }
}

하지만 주석은 굉장히 유용하고 중요하다. 코틀린 표준 라이브러리의 모든 public 함수들을 확인해 보라. 이 함수들은 규약을 잘 정리해 주므로 사용자에게 자유를 준다. 예를 들어 listOf()는 아래처럼 돼 있다.

/**
 * Returns a new read-only list of given elements.  The returned list is serializable (JVM).
 * @sample samples.collections.Collections.Lists.readOnlyList
 */
public fun <T> listOf(vararg elements: T): List<T> =
    if (elements.size > 0) elements.asList() else emptyList()

주석을 보면 간단하게 JVM에서 읽기 전용이고 직렬화할 수 있는 List를 리턴한다는 걸 알 수 있다. 이외의 규약은 없다.

최소한의 설명이지만 대부분의 코틀린 개발자에겐 이것만으로 충분하다. 또한 간단한 예(@sample)로 사용법도 보여 주고 있다.

KDoc 형식

주석으로 함수를 문서화할 때 쓰이는 공식적인 형식을 KDoc이라고 부른다. 모든 KDoc 주석은 /**로 시작해서 */로 끝난다. 설명은 KDoc 마크다운이란 형식으로 작성한다.

KDoc 주석의 구조는 아래와 같다.

  • 첫 번째 부분은 요소에 대한 요약 설명
  • 두 번째 부분은 상세설명
  • 이어지는 줄은 모두 태그로 시작함. 이런 태그는 추가적인 설명을 위해 쓰인다

설명과 태그를 설명하는 텍스트 모두 요소, 구체 클래스, 메서드, 프로퍼티, 파라미터를 연결할 수 있다. 관련된 요소 등에 링크를 걸 때는 대괄호를 쓴다. 만약 링크 대상에 추가 설명을 입력하고 싶을 땐 대괄호를 2번 연속해서 쓴다.

/**
 * [element], [com.package.SomeClass.element2],
 * [element3에 대한 설명] [element3] 처럼 쓰면 링크를 만들 수 있음
 */

이런 태그는 모든 코틀린 문서 생성 도구에서 쓰인다. 공식적인 코틀린 문서 생성 도구의 이름은 Dokka다.

Dokka는 온라인에 게시하고 외부 사용자에게 제공할 수 있는 문서 파일을 만들어 준다.

모든 걸 설명할 필요는 없다. 짧으면서 불명확한 부분은 자세하게 설명하는 문서가 좋은 문서다.

타입 시스템과 예측

타입 계층(type hierarchy)은 객체와 관련된 중요한 정보다. 인터페이스는 우리가 구현해야 한다고 약속한 메서드 목록 이상의 의미를 갖는다.

클래스, 인터페이스에도 여러 예측이 들어간다. 클래스가 어떤 동작을 할 거라 예측되면 그 서브클래스도 이를 보장해야 한다. 이를 리스코프 치환 원칙이라 부른다. 이 원칙은 객체 지향 프로그래밍에서 굉장히 중요하다. 기본적으로 이는 "S가 T의 서브타입이면 별도의 변경이 없어도 T타입 객체를 S타입 객체로 대체할 수 있어야 한다"라고 이야기한다. 그래서 클래스가 어떻게 동작할 거라는 예측 자체에 문제가 있으면 이 클래스와 관련된 다양한 상속 문제가 발생할 수 있다.

사용자가 클래스의 동작을 확실하게 예측할 수 있게 하려면 공개 함수에 대한 규약을 잘 지정해야 한다. 예를 들어 아래같은 인터페이스를 활용하면 자동차에 대한 규약을 더 잘 지정할 수 있다.

interface Car {
    fun setWheelPosition(angle: Float)
    fun setBreakPedal(pressure: Double)
    fun setGasPedal(pressure: Double)
}

class GasolineCar: Car {
    override fun setWheelPosition(angle: Float) {
        // ...
    }

    override fun setBreakPedal(pressure: Double) {
        // ...
    }

    override fun setGasPedal(pressure: Double) {
        // ...
    }

}

그런데 이 코드는 여러 의문이 든다. setWheelPosition의 angle은 뭘 의미하는 것인가? 그 단위는 무엇인가? setBreakPedal과 setGasPedal은 어떤 처리를 하는가? Car 타입의 인스턴스를 활용하는 사람에게 이를 어떻게 전달할 수 있을까? 이런 것들은 모두 문서를 통해 전달할 수 있다.

interface Car {
    /**
     * 자동차의 방향을 변경한다
     * 
     * @param angle 바퀴 각도를 지정함. 라디안 단위로 지정하며 0은 직진 의미
     * pi / 2는 오른쪽으로 최대한 돌렸을 경우, -pi / 2는 왼쪽으로 최대한 돌렸을 경우를 의미
     * 값은 (-pi / 2, pi / 2) 범위로 지정해야 함
     */
    fun setWheelPosition(angle: Float)

    /**
     * 자동차 속도가 0이 될 때가지 감속한다
     * 
     * @param pressure 브레이크 페달을 쓰는 비율. 0~1 사이의 숫자를 지정한다
     * 0은 브레이크를 안 쓰는 경우, 1은 브레이크를 최대한 사용하는 경우를 의미한다
     */
    fun setBreakPedal(pressure: Double)

    /**
     * 최대 속도까지 자동차를 가속한다
     * 
     * @param pressure 가스 페달(가속 페달)을 쓰는 비율. 0~1 사이의 숫자를 지정한다
     * 0은 가스 페달을 안 쓰는 경우, 1은 가스 페달을 최대한 쓰는 경우를 의미한다
     */
    fun setGasPedal(pressure: Double)
}

자동차가 어떻게 작동해야 하는지를 설명하는 표준을 만들었다. 표준 라이브러리, 인기 있는 라이브러리에 있는 대부분의 클래스는 그 서브클래스와 요소에 대한 자세한 설명, 규약을 갖고 있다. 이를 기반으로 사용자는 해당 클래스에 대한 예측을 쉽게 할 수 있다.

이런 설명, 규약은 인터페이스를 유용하게 만든다. 규약이 지켜지는 범위에선 이를 구현하는 클래스를 자유롭게 만들어도 된다.

조금씩 달라지는 세부 사항

구현의 세부적인 내용은 항상 조금씩 다르다. 예를 들어 자동차 안의 엔진은 조금씩 다르게 운전한다. 물론 사용자가 자동차를 운전하는 데는 문제 없지만, 운전하면서 뭔가 차이가 있다는 걸 조금 느낄 수 있다. 하지만 이런 내용은 규약에 명시돼 있지 않으므로 괜찮다.

프로그래밍 언어에서도 구현의 세부 사항은 조금씩 달라진다. 리플렉션을 써서 함수를 호출할 수도 있지만, 컴파일러에 의해 최적화가 안 된 경우 일반적인 함수 호출보다 느리다.

구현의 세부 사항은 항상 달라질 수 있지만 최대한 많이 보호하는 게 좋다. 일반적으로 캡슐화를 통해 보호한다. 캡슐화는 "허용하는 범위"를 지정하는 데 도움을 주는 도구다. 캡슐화가 많이 적용될수록 사용자가 구현에 신경을 많이 쓸 필요가 없어지므로 많은 자유를 갖게 된다.

정리

요소, 특히 외부 API(external API)를 구현할 때는 규약을 잘 정의하자

이러한 규약은 이름, 문서, 주석, 타입을 통해 구현할 수 있다. 규약은 사용자가 객체를 사용하는 방법을 쉽게 이해하는 등 요소를 쉽게 예측할 수 있게 해준다.

규약은 요소가 현재 어떻게 동작하고 앞으로 어떻게 동작할지를 사용자에게 전달한다. 이를 기반으로 사용자는 요소를 확실하게 사용할 수 있고, 규약에 없는 부분을 변경할 수 있는 자유를 얻는다. 규약은 단순한 합의지만, 양쪽 모두가 그 합의를 존중한다면 큰 문제는 없을 것이다.

profile
와니와니와니와니 당근당근

0개의 댓글