본문 바로가기

Java/Kotlin

[이펙티브 코틀린] 4장 추상화 설계

728x90

추상화(abstraction)는 프로그래밍 세계에서 가장 중요한 개념 중 하나이며, OOP에서 캡슐화와 상속과 함께 세 가지 주요 개념 중 하나이다.

컴퓨터 과학에서 추상화(abstraction)는 복잡한 자료, 모듈, 시스템 등으로부터 핵심적인 개념 또는 기능을 간추려 내는 것을 말한다.

 

조금 간단하게 표현하면, 추상화는 복잡성을 숨기기 위해 사용되는 단순한 형식을 의미한다.

e.g. 복잡한 클래스에서 메서드와 프로퍼티만 추출해서 간단하게 만든 인터페이스

 

프로그래밍에서의 추상화

추상화를 설계한다는 것은 단순하게 모듈 또는 라이브러리로 분리한다는 의미가 아니다. 함수를 정의할 때는 그 구현을 함수 시그니처 뒤에 숨기게 되는데, 이것이 바로 추상화다.

 

fun maxOf(a: Int, b: Int) = if (a > b) a else b

두 숫자 중 큰 것을 리턴하는 메서드 maxOf를 정의하지 않아도 필요할 때마다 다음과 같은 코드를 사용하면 두 숫자 중에 큰 것을 리턴하는 데 아무런 문제가 없다.

val biggest = if (x > y) x else y

val height = if (minHeight > calculatedHeight) minHeight else calculatedHeight

하지만 이 코드는 너무 기본적인 수준의 연산이 표면적으로 드러나 있다.

두 숫자 중에서 어떤 숫자가 더 큰지 계산할 수는 있지만, 이를 표현하는 능력이 굉장히 떨어진다고 할 수 있다.

즉 추상적인 것을 표현하는 추상화 능력이 떨어지는 것이다.

 

자바 8까지는 리스트 매핑을 쉽게 표현할 수 있는 기능이 없었다.

List<String> names = new ArrayList<>();
for (User user : users) {
    names.add(user.getName());
}

코틀린은 초기부터 간단한 함수를 사용해서 이를 표현할 수 있었다.

val names = users.map { it.name }

 

lazy 프로퍼티 초기화 패턴은 아직 자바에서 지원하지 않지만, 코틀린에서는 프로퍼티 델리게이트를 통해 이를 표현할 수 있다.

val connection by lazy { makeConnection() }

 

강력한 프로그래밍 언어들이 당연히 갖고 있는 기능 중 하나는 공통 패턴에 이름을 붙여서 추상화를 만드는 것이다.

e.g. 함수, 델리게이트, 클래스 등

이를 활용하면 추상화를 할 수 있다.

추상화는 언어가 표현할 수 있는 표현에 따라서도 조금씩 달라질 수 있다.

 

추상화와 자동차

자동차는 굉장히 잘 만들어진 인터페이스다. 내부적으로 굉장히 복잡한 요소들이 많지만, 굉장히 쉽게 사용할 수 있다.

핸들은 왼쪽과 오른쪽 방향 전환에 대한 추상화, 변속기는 전후 방향 전환에 대한 추상화, 가속 페달은 가속에 대한 추상화, 브레이크는 감속에 대한 추상화는 나타낸다.

이것이 우리가 자동차를 운전하기 위해서 알아야 하는 전부다. 추상화는 이처럼 내부적으로 일어나는 모든 것을 마법처럼 숨겨 준다. 따라서 운전자는 자동차가 어떻게 구성되는지 전혀 몰라도 괜찮다. 운전하는 방법만 알면 된다.

 

마찬가지로 프로그래밍에서는 다음과 같은 목적으로 추상화를 사용한다.

  • 복잡성을 숨기기 위해
  • 코드를 체계화하기 위해
  • 만드는 사람에게 변화의 자유를 주기 위해

 

함수 내부의 추상화 레벨을 통일하라

컴퓨터는 굉장히 복잡한 장치이지만 이러한 복잡함이 여러 계층에 다양한 요소로서 분할되어 있으므로 쉽게 사용할 수 있는 것이다.

 

계층이 잘 분리되면 무엇이 좋은가?
어떤 계층에서 작업할 때 그 아래의 계층은 이미 완성되어 있으므로, 해당 계층만 생각하면 된다.
즉, 전체를 이해할 필요가 없어진다는 것이다.

 

개발자는 일반적으로 특정한 계층에서 작업하며, 가끔 그 위에 추가로 계층을 올려서 사용한다. 계층이 잘 나뉘어 있어서, 개발자는 여기까지만 알아도 되는 것이다.

e.g. 어셈블리어나 JVM 바이트 코드를 몰라도 프로그래밍할 수 있다.

애플리케이션
프로그래밍 언어
어셈블러
하드웨어
물리 장치

 

추상화 레벨

높은 레벨로 갈수록 물리 장치로부터 점점 멀어진다. 프로그래밍에서는 일반적으로 높은 레벨일수록 프로세서로부터 멀어진다고 표현한다. 높은 레벨일수록 걱정해야 하는 세부적인 내용들이 적어 단순하지만, 제어력(control)을 잃는다.

e.g. C언어는 메모리 관리를 직접할 수 있고, Java는 GC가 자동으로 메모리를 관리해 준다.

 

추상화 레벨 통일

컴퓨터 과학과 마찬가지로 코드도 추상화를 계층처럼 만들어서 사용할 수 있다. 이를 위한 기본적인 도구가 바로 함수다. 컴퓨터 과학이 높은 레벨과 낮은 레벨을 확실하게 구분하고 있는 것처럼, 함수도 높은 레벨과 낮은 레벨을 구분해서 사용해야 한다는 원칙이 있다. 이를 추상화 레벨 통일(Single Level of Abstraction, SLA) 원칙이라고 한다.

 

버튼 하나만 누르면 커피를 만들 수 있는 커피 머신을 나타내는 클래스를 만든다고 해 보자.

class CoffeeMachine {

    fun makeCoffee() {
        // 수백 개의 변수를 선언한다.
        // 복잡한 로직을 처리한다.
        // 낮은 수준의 최적화도 여기에서 잔뜩한다.
    }
}

이렇게 코드를 작성하면, makeCoffee라는 함수가 수백 줄이 될 수도 있다. 이런 함수는 함수를 읽으면서 세부적인 내용을 하나하나 신경 써야 하므로, 읽고 이해하는 것이 거의 불가능에 가깝다.

class CoffeeMachine {

    fun makeCoffee() {
        boilWater()
        brewCoffee()
        pourCoffee()
        pourMilk()
    }

    private fun boilWater() {
        // ...
    }

    private fun brewCoffee() {
        // ...
    }

    private fun pourCoffee() {
        // ...
    }

    private fun pourMilk() {
        // ...
    }
}

이제 이 함수가 대체 어떤 식으로 동작하는지 확실하게 확인할 수 있다. makeCoffee 함수는 읽고 이해하기 쉬우며, 누군가가 낮은 레벨(boilWater, brewCoffee 등)을 이해해야 한다면, 해당 부분의 코드만 살펴보면 된다. 매우 간단한 추상화를 추출해서 가독성을 크게 향상했다.

 

이처럼 함수는 간단해야 한다.

함수는 작아야 하며, 최소한의 책임만을 가져야 한다.

 

재사용과 테스트가 쉬워진다.

예를 들어 앞의 makeCoffee 함수는 라떼를 만드는 과정인데, 만약 에스프레소 커피를 만드는 기능을 추가한다면, 다음과 같이 우유만 안 넣으면 된다.

fun makeEspressoCoffee() {
    boilWater()
    brewCoffee()
    pourCoffee()
}

 

함수가 작아져 단위 테스트도 쉽다.

 

프로그램 아키텍처의 추상 레벨

추상화 계층이라는 개념은 함수보다 높은 레벨에서도 적용할 수 있다. 추상화를 구분하는 이유는 서브시스템의 세부 사항을 숨김으로써 상호 운영성(interoperability)과 플랫폼 독립성을 얻기 위함이다. 이는 문제 중심으로 프로그래밍한다는 의미다.

4
높은 레벨 문제 중심
3
낮은 레벨 문제 중심
2
낮은 레벨 구현 구조
1
프로그래밍 언어 구조와 도구
0
운영 체제 연산과 머신 명령

이러한 개념은 모듈 시스템(modular system)을 설계할 때도 중요하다. 모듈을 분리하면 계층 고유의 요소를 숨길 수 있다.

애플리케이션을 만들 때는 입력과 출력을 나타내는 모듈(프런트엔드의 뷰, 백엔드의 HTTP 요청 처리 등)은 낮은 레벨의 모듈이고, 비즈니스 로직을 나타내는 부분이 높은 레벨의 모듈이다.

 

계층화가 잘 된 프로젝트는 어떤 계층 위치에서 코드를 보아도, 일관적인 관점을 얻을 수 있다.

 

정리

별도의 추상화 계층을 만드는 것은 프로그래밍에서 일반적으로 사용되는 개념이다. 이는 knowledge를 체계화하고, 서브시스템의 세부 사항을 숨김으로써 상호 운영성(interoperability)과 플랫폼 독립성을 얻게 한다.

 

변화로부터 코드를 보호하려면 추상화를 사용하라

함수와 클래스 등의 추상화로 실질적인 코드를 숨기면, 사용자가 세부 사항을 알지 못해도 괜찮다는 장점이 있다. 그리고 이후에 실질적인 코드를 원하는 대로 수정할 수도 있다.

 

상수

리터럴은 아무것도 설명하지 않는다. 따라서 코드에서 반복적으로 등장할 때 문제가 된다.

이러한 리터럴을 상수 프로퍼티로 변경하면 해당 값에 의미 있는 이름을 붙일 수 있으며, 상수의 값을 변경해야 할 때 훨씬 쉽게 변경할 수 있다.

fun isPasswordValid(text: String): Boolean {
    if (text.length < 7) return false
    //...
}

여기서 숫자 7은 아마도 '비밀번호의 최소 길이'를 나타내겠지만, 이해하는 데 시간이 걸린다.

const val MIN_PASSWORD_LENGTH = 7

fun isPasswordValid(text: String): Boolean {
    if (text.length < MIN_PASSWORD_LENGTH) return false
    //...
}

이렇게 하면 '비밀번호의 최소 길이'를 변경하기도 쉽다. 함수의 내부 로직을 전혀 이해하지 못해도, 상수의 값만 변경하면 된다.

 

상수로 추출하면

  • 이름을 붙일 수 있고,
  • 나중에 해당 값을 쉽게 변경할 수 있다.

 

함수

다음과 같은 코드를 사용해서 토스트 메시지를 자주 출력해야 한다면?

Toast.makeText(this, message, Toast.LENGTH_LONG).show()

다음과 같이 간단한 확장 함수로 만들어서 사용할 수 있다.

fun Context.toast(
    message: String,
    duration: Int = Toast.LENGTH_LONG
) {
    Toast.makeText(this, message, duration).show()
}

// 사용
context.toast(message)

// 액티비티 또는 컨텍스트의 서브클래스에서 사용할 경우
toast(message)

이렇게 일반적인 알고리즘을 추출하면, 토스트를 출력하는 코드를 항상 기억해 두지 않아도 괜찮다. 또한 이후에 토스트를 출력하는 방법이 변경되어도, 확장 함수 부분만 수정하면 되므로 유지보수성이 향상된다.

만약 토스트가 아니라 스낵바라는 다른 형태의 방식으로 출력해야 한다면?

fun Context.snackbar(
    message: String,
    duration: Int = Snackbar.LENGTH_LONG
) {
    //...
}

메시지의 출력 방법이 바뀔 수 있다는 것을 알고 있다면, 이때부터 중요한 것은 메시지의 출력 방법이 아니라, 사용자에게 메시지를 출력하고 싶다는 의도 자체다. 따라서 메시지를 출력하는 더 추상적인 방법이 필요하다.

fun Context.showMessage(
    message: String,
    duration: MessageLength = MessageLength.Long
) {
    val toastDuration = when(duration) {
        SHORT -> Length.LENGTH_SHORT
        LONG -> Length.LENGTH_LONG
    }
    Toast.makeText(this, message, toastDuration).show()
}

enum class MessageLength { SHORT, LONG }

가장 큰 변화는 이름이다. 컴파일러 관점에서는 큰 차이가 없다고 생각할 수 있지만, 사람의 관점에서는 이름이 바뀌면 큰 변화가 일어난 것이다.

함수는 추상화를 표현하는 수단이며, 함수 시그니처는 이 함수가 어떤 추상화를 표현하고 있는지 알려 준다. 따라서 이름은 굉장히 중요하다.

 

클래스

class MessageDisplay(val context: Context) {

    fun show(
        message: String,
        duration: MessageLength = MessageLength.Long
    ) {
        val toastDuration = when(duration) {
            SHORT -> Length.SHORT
            LONG -> Length.LONG
        }
        Toast.makeText(context, message, toastDuration).show()
    }
}

enum class MessageLength { SHORT, LONG }

// 사용
val messageDisplay = MessageDisplay(context)
messageDisplay.show("Message")

클래스가 함수보다 더 강력한 이유는 상태를 가질 수 있으며, 많은 함수를 가질 수 있다는 점 때문이다.

 

의존성 주입 프레임워크를 사용하면, 클래스 생성을 위임할 수도 있다.

@Inject lateinit var messageDisplay: MessageDisplay

 

mock 객체를 활용해서 해당 클래스에 의존하는 다른 클래스의 기능을 테스트할 수 있다.

val messageDisplay: MessageDisplay = mockk()

 

메시지를 출력하는 더 다양한 종류의 메서드를 만들 수도 있다.

messageDisplay.setChristmasMode(true)

 

이처럼 클래스는 훨씬 더 많은 자유를 보장해 준다. 하지만 여전히 한계가 있다. 더 많은 자유를 얻으려면, 더 추상적이게 만들면 된다.

 

인터페이스

라이브러리를 만드는 사람은 내부 클래스의 가시성을 제한하고, 인터페이스를 통해 이를 노출하는 코드를 많이 사용한다. 이렇게 하면 사용자가 클래스를 직접 사용하지 못하므로, 라이브러리를 만드는 사람은 인터페이스만 유지한다면, 별도의 걱정 없이 자신이 원하는 형태로 그 구현을 변경할 수 있다.

즉, 인터페이스 뒤에 객체를 숨김으로써 실질적인 구현을 추상화하고, 사용자가 추상화된 것에만 의존하게 만들 수 있다.

즉, 결합(coupling)을 줄일 수 있는 것이다.

 

interface MessageDisplay(
    fun show(
        message: String,
        duration: MessageLength = LONG
    )
)

class ToastDisplay(val context: Context): MessageDisplay {

    override fun show(
        message: String,
        duration: MessageLength
    ) {
        val toastDuration = when(duration) {
            SHORT -> Length.SHORT
            LONG -> Length.LONG
        }
        Toast.makeText(context, message, toastDuration).show()
    }
}

enum class MessageLength { SHORT, LONG }

이렇게 구성하면 더 많은 자유를 얻을 수 있다.

 

테스트할 때 인터페이스 페이킹(faking)이 클래스 모킹(mocking) 보다 간단하므로, 별도의 모킹 라이브러리(mocking library)를 사용하지 않아도 된다.

val messageDisplay: MessageDisplay = TestMessageDisplay()

 

선언과 사용이 분리되어 있으므로, ToastDisplay 등의 실제 클래스를 자유롭게 변경할 수 있다. 다만 사용 방법을 변경하려면, MessageDisplay 인터페이스를 변경하고, 이를 구현하는 모든 클래스를 변경해야 한다.

 

ID 만들기(nextId)

프로젝트에서 고유 ID(unique ID)를 사용해야 하는 상황을 가정하자.

가장 간단한 방법은 어떤 정수 값을 계속 증가시키면서, 이를 ID로 활용하는 것이다.

var nextId: Int = 0

// 사용
val newId = nextId++

이 방법은 문제가 있다.

  • 이 코드의 ID는 무조건 0부터 시작한다.
  • 이 코드는 스레드-안전(thread-safe) 하지 않다.
private var nextId: Int = 0
fun getNextId(): Int = nextId++

// 사용
val newId = getNextId()

이제 ID 생성 방식의 변경으로부터는 보호되지만, ID 타입 변경 등은 대응하지 못한다.

data class Id(private val id: Int)

private var nextId: Int = 0
fun getNextId(): Id = Id(nextId++)

 

더 많은 추상화는 더 많은 자유를 주지만, 이를 정의하고, 사용하고, 이해하는 것이 조금 어려워졌다.

 

추상화가 주는 자유

추상화를 하는 방법

  • 상수로 추출한다.
  • 동작을 함수로 래핑 한다.
  • 함수를 클래스로 래핑 한다.
  • 인터페이스 뒤에 클래스를 숨긴다.
  • 보편적인 객체(universal object)를 특수한 객체(specialistic object)로 래핑 한다.

이를 구현할 때 활용할 수 있는 여러 도구

  • 제네릭 타입 파라미터를 사용한다.
  • 내부 클래스를 추출한다.
  • 생성을 제한한다.

 

추상화의 문제

추상화는 자유를 주지만, 코드를 이해하고 수정하기 어렵게 만든다.

어떤 방식으로 추상화를 하려면 코드를 읽는 사람이 해당 개념을 배우고, 잘 이해해야 한다. 또 다른 방식으로 추상화를 하려면 또 해당 개념을 배우고, 잘 이해해야 한다. 물론 추상화의 가시성을 제한하거나, 구체적인 작업에서만 추상화를 도입하는 것은 큰 문제가 없다. 그래서 큰 프로젝트에서는 잘 모듈화해야 한다. 어쨌거나 추상화도 비용이 발생한다. 따라서 극단적으로 모든 것을 추상화해서는 안 된다.

추상화는 많은 것을 숨길 수 있는 테크닉이다. 생각할 것을 어느 정도 숨겨야 개발이 쉬워지는 것도 사실이지만 너무 많은 것을 숨기면 결과를 이해하는 것 자체가 어려워진다.

 

어떻게 균형을 맞춰야 할까?

  • 많은 개발자가 참여하는 프로젝트는 이후에 객체 생성과 사용 방법을 변경하기 어렵다. 따라서 추상화 방법을 사용하는 것이 좋다. 최대한 모듈과 부분(part)을 분리하는 것이 좋다.
  • 의존성 주입 프레임워크를 사용하면, 생성이 얼마나 복잡한지는 신경 쓰지 않아도 된다.
  • 테스트를 하거나, 다른 애플리케이션을 기반으로 새로운 애플리케이션을 만든다면 추상화를 사용하는 것이 좋다.
  • 프로젝트가 작고 실험적이라면, 추상화를 하지 않고도 직접 변경해도 괜찮다.

 

API 안정성을 확인하라

세상에 있는 모든 자동차의 운전 방법이 다르다면, 자동차를 운전하기 전에 운전 방법을 배워야 할 것이다. 이처럼 일시적으로만 사용되는 인터페이스를 배우는 것은 굉장히 귀찮고 의미 없는 일이다. 따라서 운전 방법은 안정적이면서 표준적인 것이 좋다.

마찬가지로 프로그래밍에서도 안정적이고 최대한 표준적인 API(Application Programming Interface)를 선호한다.

  1. API가 변경되고, 개발자가 이를 업데이트했다면, 여러 코드를 수동으로 업데이트해야 한다.
  2. 사용자가 새로운 API를 배워야 한다.

하지만 좋은 API를 한 번에 설계할 수는 없다. API 제작자는 이를 계속해서 개선하기 위해서 변경을 원한다. 따라서 우리(프로그래밍 커뮤니티)는 계속해서 API를 안정적으로 유지하기 위한 의견을 제시해서 균형을 맞춰야 한다.

일반적으로 버전을 활용해서 라이브러리와 모듈의 안정성을 나타낸다. 많은 버저닝 시스템(versioning system)이 있지만, 일반적으로는 시멘틱 버저닝(Semantic Versioning, SemVer)을 사용한다.

  • MAJOR 버전: 호환되지 않는 수준의 API 변경
  • MINOR 버전: 이전 변경과 호환되는 기능을 추가
  • PATCH 버전: 간단한 버그 수정

각각의 부분은 0 이상의 정수로 구성되며, 0부터 시작해서 API에 해당 변경 사항이 있을 때 1씩 증가시킨다.


안정적인 API에 새로운 요소를 추가할 때, 아직 해당 요소가 안정적이지 않다면, 먼저 다른 브랜치에 해당 요소를 두는 것이 좋다. 일부 사용자가 이를 사용하도록 허용하려면, 일단 Experimental 메타 어노테이션을 사용해서 사용자들에게 아직 해당 요소가 안정적이지 않다는 것을 알려 주는 것이 좋다.

@Experimental(level = Experimental.Level.WARNING)
annotaion class ExperimentalNewApi

@ExperimentalNewApi
suspend fun getUsers(): List<User) {
	//...
}

이러한 요소는 언제든지 변경될 수 있다. 요소를 오랜 시간 동안 실험적 기능으로 유지하는 것을 두려워하지 마라. 채택 속도는 느려지지만, 더 오래 동안 좋은 API를 설계하는 데 도움이 된다.

 

안정적인 API의 일부를 변경해야 한다면, 전환하는 데 시간을 두고 Deprecated 어노테이션을 활용해서 사용자에게 미리 알려 줘야 한다.

@Deprecated("Use suspending getUsers instead")
fun getUsers(callback: (List<User>)->Unit) {
	//...
}

또한 직접적인 대안(direct alternative)이 있다면, IDE가 자동 전환을 할 수 있게 ReplaceWith를 붙여 주는 것도 좋다.

@Deprecated("Use suspending getUsers instead", ReplaceWith("getUsers()"))
fun getUsers(callback: (List<User>)->Unit) {
	//...
}

이렇게 Deprecated와 ReplaceWith를 사용했다면, 사용자가 이러한 변경에 적응할 시간을 제공해야 한다. 사용자는 라이브러리를 최신 버전으로 적용하는 일 이외에도 수많은 일을 갖고 있으므로, 적용하는 데에도 시간이 꽤 걸린다. 이러한 시간이 지난 뒤에는 주요 배포판(major release)에서 이 요소를 제거하면 된다.

 

외부 API를 랩(wrap)해서 사용하라

불안정한(API 설계자가 안전하지 않다고 하거나 우리가 그것을 제대로 신뢰할 수 없다면) API를 과도하게 사용하는 것은 굉장히 위험하며, 어쩔 수 없이 이런 API를 활용해야 한다면, 최대한 이러한 API를 로직과 직접 결합시키지 않는 것이 좋다.

그래서 많은 프로젝트가 잠재적으로 불안정하다고 판단되는 외부 라이브러리 API를 랩(wrap)해서 사용한다.

 

장점

  • 문제가 있다면 래퍼(wrapper)만 변경하면 되므로, API 변경에 쉽게 대응할 수 있다.
  • 프로젝트의 스타일에 맞춰서 API의 형태를 조절할 수 있다.
  • 특정 라이브러리에서 문제가 발생하면, 래퍼를 수정해서 다른 라이브러리를 사용하도록 코드를 쉽게 변경할 수 있다.
  • 필요한 경우 쉽게 동작을 추가하거나 수정할 수 있다.

단점

  • 래퍼를 따로 정의해야 한다.
  • 다른 개발자가 프로젝트를 다룰 때, 어떤 래퍼들이 있는지 따로 확인해야 한다.
  • 래퍼들은 프로젝트 내부에서만 유효하므로, 문제가 생겨도 질문할 수 없다.

사용자가 많은 라이브러리가 일반적으로 안정적이며, 제작자가 프로젝트에 작은 변화를 가할 때도 굉장히 신중하게 할 것이다.

반대로 인기가 없고 새로 만들어진 라이브러리는 위험할 수 있으니 신중하게 사용을 결정하고, 사용하기로 했다면 클래스와 함수로 랩 하는 것을 고려하자.

 

요소의 가시성을 최소화하라

API를 설계할 때 가능한 한 간결한 API를 선호하는 데는 여러 가지 이유가 있는데, 가장 중요한 몇 가지 이유를 살펴보자.

작은 인터페이스는 배우기 쉽고 유지하기 쉽다. 보이는 요소 자체가 적다면, 유지보수하고 테스트할 것이 적다.

변경을 가할 때는 기존의 것을 숨기는 것보다 새로운 것을 노출하는 것이 쉽다. 일반적으로 공개적으로 노출되어 있는 요소들은 공개 API의 일부이며, 외부에서 사용할 수 있다. 따라서 이러한 요소들은 이미 외부에서 사용되고 있을 것이다. 그래서 이런 요소들은 변경하면, 이 코드를 사용하는 모든 부분이 영향을 받는다. 널리 사용되는 공개 라이브러리라면, 일부 요소의 가시성을 제한한 경우 여러 사용자가 분노할 수 있다. 따라서 처음에는 작은 API로서 개발을 하도록 강제하는 것이 더 좋을 수 있다.

클래스의 상태를 나타내는 프로퍼티를 외부에서 변경할 수 있다면, 클래스는 자신의 상태를 보장할 수 없다. 클래스가 만족해야 하는 클래스의 상태에 대한 규약 등이 있을 수 있는데, 이를 모르는 사람은 클래스의 상태를 마음대로 변경할 수 있으므로, 클래스의 불변성(invariant)이 무너질 가능성이 있다. 세터(setter)만 private으로 만드는 코드는 굉장히 많이 사용된다. 이 부분이 없다면 외부에서 이 코드를 강제로 바꿀 수 있고, 이 프로퍼티에 대한 신뢰성에 문제가 생길 수 있다.

class CounterSet<T>(
    private val innerSet: MutableSet<T> = setOf()
) : MutableSet<T> by innerSet {

    var elementsAdded: Int = 0
        private set

    override fun add(element: T): Boolean {
        elementsAdded++
        return innerSet.add(element)
    }

    override fun addAll(elements: Collection<T>): Boolean {
    	elementsAdded += elements.size
        return innerSet.addAll(elements)
    }
}

일반적으로 코틀린에서는 이처럼 구체 접근자의 가시성을 제한해서 모든 프로퍼티를 캡슐화하는 것이 좋다.

 

서로서로 의존하는 프로퍼티가 있을 때는 객체 상태를 보호하는 것이 더 중요해진다.

예를 들어 mutableLazy 델리게이트를 구현할 때를 생각해 보자. 이는 initialized가 true라면 값 초기화가 이루어지고, 이때의 값은 T 타입이라는 것을 예상할 수 있다. 이때 initialized의 세터가 노출되면 예상하지 못한 변경에 의해서 예외가 발생하고, 코드의 신뢰성이 떨어질 수 있다.

class MutableLazyHolder<T>(val initializer: () -> T) {

    private var value: Any = Any()
    private var initialized = false

    override fun get(): T {
        if (!initialized) {
            value = initializer()
            initialized = true
        }
        return value as T
    }

    override fun setValue() {
        this.value = value
        initialized = true
    }
}

가시성이 제한될수록 클래스의 변경을 쉽게 추적할 수 있으며, 프로퍼티의 상태를 더 쉽게 이해할 수 있다.

이는 동시성(concurrency)을 처리할 때 중요하다. 상태 변경은 병렬 프로그래밍에서 문제가 된다. 따라서 많은 것을 제한할수록 병렬 프로그래밍할 때 안전해진다.

 

가시성 한정자 사용하기

내부적인 변경 없이 작은 인터페이스를 유지하고 싶다면, 가시성(visibility)을 제한하면 된다.

클래스 멤버에서 사용할 수 있는 가시성 한정자

  • public(기본): 어디에서나 볼 수 있다.
  • private: 클래스 내부에서만 볼 수 있다.
  • protected: 클래스와 서브클래스 내부에서만 볼 수 있다.
  • internal: 모듈 내부에서만 볼 수 있다.

톱레벨 요소에서 사용할 수 있는 가시성 한정자

  • public(기본): 어디에서나 볼 수 있다.
  • private: 같은 파일 내부에서만 볼 수 있다.
  • internal: 모듈 내부에서만 볼 수 있다.

만약 모듈이 다른 모듈에 의해서 사용될 가능성이 있다면, internal을 사용해서 공개하고 싶지 않은 요소를 숨긴다.

요소가 상속을 위해 설계되어 있고, 클래스와 서브클래스에서만 사용되게 만들고 싶다면 protected를 사용한다.

동일한 파일 또는 클래스에서만 요소를 사용하게 만들고 싶다면 private를 사용한다.

 

이러한 규칙은 데이터를 저장하도록 설계된 클래스(데이터 모델 클래스, DTO)에는 적용하지 않는 것이 좋다. 프로퍼티를 사용할 수 있게 눈에 띄게 만드는 것이 좋고, 필요하지 않으면 그냥 프로퍼티를 제거하자.

class User(
    val name: String,
    val surname: String,
    val age: Int,
}

 

API를 상속할 때 오버라이드해서 가시성을 제한할 수는 없다. 이는 서브클래스가 슈퍼클래스로도 사용될 수 있기 때문이다. 이것이 상속보다 컴포지션을 선호하는 대표적인 이유.

 

정리

  • 인터페이스가 작을수록 이를 공부하고 유지하는 것이 쉽다.
  • 최대한 제한이 되어 있어야 변경하기 쉽다.
  • 클래스의 상태를 나타내는 프로퍼티가 노출되어 있다면, 클래스가 자신의 상태를 책임질 수 있다.
  • 가시성이 제한되면 API이 변경을 쉽게 추적할 수 있다.

 

문서로 규약을 정의하라

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

 

규약

어떤 행위를 설명하면 사용자는 이를 일종의 약속으로 취급하며, 이를 기반으로 스스로 자유롭게 생각하던 예측을 조정한다. 이처럼 예측되는 행위를 요소의 규약(contract of an element)이라고 부른다.

 

규약이 적절하게 정의되어 있다면, 클래스를 만든 사람은 클래스가 어떻게 사용될지 걱정하지 않아도 된다. = 규약만 지킨다면 원하는 부분을 마음대로 수정할 수 있다.

클래스를 사용하는 사람은 클래스가 내부적으로 어떻게 구현되어 있는지를 걱정하지 않아도 된다.

클래스의 구현을 믿을 수도 있으므로, 이를 의존해서 다른 무언가를 만들 수도 있다.

클래스를 만드는 사람과 사용하는 사람 모두 미리 정의된 규약에 따라 독립적으로 작업할 수 있다.

서로가 규약을 존중한다면, 독립적으로 작업해도 모든 것이 정상적으로 기능할 것이고, 이는 모두에게 편안함과 자유를 준다.

 

만약 규약을 설정하지 않는다면, 클래스를 사용하는 사람은 스스로 할 수 있는 것과 할 수 없는 것을 몰라서 구현의 세부적인 정보에 의존하게 된다.

클래스를 만든 사람은 사용자가 대체 무엇을 할지 알 수가 없어서 사용자의 구현을 망칠 위험이 있다.

 

규약 정의하기

  • 이름: 일반적인 개념과 관련된 메서드는 이름만으로 동작을 예측할 수 있다.
    • e.g. sum이라는 메서드가 있다면, 이 메서드가 무엇을 하는 메서드인지 문서를 볼 필요도 없다.
  • 주석과 문서: 필요한 모든 규약을 적을 수 있는 강력한 방법이다.
  • 타입: 타입은 객체에 대한 많은 것을 알려 준다. 어떤 함수의 선언에 있는, 리턴 타입과 아규먼트 타입은 굉장히 큰 의미가 있다. 자주 사용되는 타입의 경우에는 타입만 보아도 어떻게 사용하는지 알 수 있지만, 일부 타입은 문서에 추가로 설명해야 할 의무가 있다.

 

주석을 써야 할까?

주석 없이도 읽을 수 있는 코드를 작성해야 한다. 하지만 주석을 함께 사용하면 요소(함수 또는 클래스)에 더 많은 내용의 규약을 설명할 수 있다. 추가적으로 현대의 주석은 문서를 자동 생성하는 데 많이 사용된다.

 

대부분의 기능은 이름 등으로도 무엇을 하는지 확실하게 알 수 있으므로, 주석을 활용한 추가적인 설명이 필요 없다.

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

이 코드에서는 product라는 이름이 곱셈이라는 명확한 수학적 개념을 나타내므로, 추가적인 주석이 필요 없다.

오히려 여기에 주석을 다는 것이 코드를 산만하게 만드는 노이즈가 된다.

 

주석을 다는 것보다 함수로 추출하는 것이 훨씬 좋다.

fun update() {
    // 사용자를 업데이트한다.
    for (user in users) {
        user.update()
    }

    // 책을 업데이트한다.
    for (book in books) {
        updateBook(book)
    }
 }

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

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

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

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

 

주석은 굉장히 유용하고 중요하다.

코틀린 표준 라이브러리의 모든 public 함수들을 살펴보자.

/**
 * 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()

listOf 함수의 주석을 보면 간단하게 JVM에서 읽기 전용이고 직렬화할 수 있는 List를 리턴한다는 것을 알 수 있다.

최소한의 설명이지만, 대부분의 코틀린 개발자에게는 이것만으로도 충분하다.

 

KDoc 형식

주석으로 함수를 문서화할 때 사용되는 공식적인 형식을 KDoc이라고 한다.

모든 KDoc 주석은 /**로 시작해서 */로 끝난다. 또한 이 사이의 모든 줄은 일반적으로 *로 시작한다.

 

KDoc 주석의 구조

  • 첫 번째 부분: 요소에 대한 요약 설명(summaru description)
  • 두 번째 부분: 상세 설명
  • 이어지는 줄은 모두 태그로 시작한다. 이러한 태그는 추가적인 설명을 위해 사용된다.

사용할 수 있는 태그

  • @param <name>: '함수 파라미터' 또는 '클래스, 프로퍼티, 함수 타입 파라미터'
  • @return: 함수의 리턴 값
  • @constructor: 클래스의 기본 생성자
  • @receiver: 확장 함수의 리시버
  • @property <name>: 명확한 이름을 갖고 있는 클래스의 프로퍼티 (기본 생성자에 정의된 프로퍼티에 사용)
  • @throws <class>, @exception <class>: 메서드 내부에서 발생할 수 있는 예외
  • @sample <identifier>: 정규화된 형식 이름(specified qualified name)을 사용한 함수의 사용 예
  • @see <identifier>: 특정한 클래스 또는 메서드에 대한 링크
  • @author: 작성자
  • @since: 버전
  • @suppress: 문서에서 제외 (외부에서 사용할 수는 있지만, 공식 API에 포함할 필요는 없는 요소에 지정)

 

타입 시스템과 예측

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

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

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)
}

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

 

추상화 규약을 지켜라

규약은 개발자들의 단순한 합의다. 따라서 한쪽에서 규약을 위반할 수도 있다. 기술적으로 모든 부분에서 규약 위반이 발생할 수 있다.

무언가를 할 수 있다는 것이 그것을 해도 괜찮다는 의미는 아니다. 리플렉션을 활용하면, private 프로퍼티와 private 함수에 접근할 수 있다. 하지만 이러한 코드는 private 프로퍼티와 private 함수의 이름과 같은 세부적인 정보에 매우 크게 의존한다. 이러한 이름은 규약이라고 할 수 없기 때문에, 언제든지 변경될 수 있다. 따라서 이런 코드를 프로젝트에서 사용한다면, 프로젝트 내부에 폭탄을 설치한 것과 같다.

규약은 보증(warranty)과 같다. 스마트폰을 그냥 사용했다면 AS를 받을 수 있지만, 스마트폰을 뜯거나 해킹했다면 AS를 받을 수 없듯이 코드도 마찬가지로 규약을 위반하면, 코드가 작동을 멈췄을 때 문제가 된다.

 

상속된 규약

클래스를 상속하거나, 다른 라이브러리의 인터페이스를 구현할 때는 규약을 반드시 지켜야 한다.

예를 들어 모든 클래스는 equals와 hashCode 메서드를 가진 Any 클래스를 상속받는다. 이러한 메서드를 모두 우리가 반드시 존중하고 지켜야 하는 규약을 갖고 있다. 만약 규약을 지키지 않는다면, 객체가 제대로 동작하지 않을 수 있다.

예를 들어 hashCode가 제대로 구현되지 않으면, HashSet과 함께 사용할 때 제대로 동작하지 않는다.

다음 코드는 equals가 제대로 구현되지 않아 세트에서 중복을 허용해 버린다. (원래 세트는 중복을 허용하지 않는다.)

class Id(val id: Int) {
	override fun equals(other: Any?) = other is Id && other.id == id
}

val set = mutableSetOf(Id(1))
set.add(Id(1))
set.add(Id(1))
print(set.size) // 3

 

정리

프로그램을 안정적으로 유지하고 싶다면, 규약을 지켜라!

 

728x90