추상화는 복잡성을 숨기기 위해 사용되는 단순한 형식을 의미합니다. 대표적인 예로 인터페이스가 있습니다. 인터페이스는 클래스라는 복잡한 것에서 메서드와 프로퍼티만 추출해서 간단하게 만들었으므로, 클래스의 추상화라고 할 수 있습니다. 어떤 대상(객체)에 대한 추상화는 여러 가지가 나올 수 있습니다. 객체는 여러 행태로 추상화해서 표현할 수 있습니다.
프로그래밍에서의 추상화
추상화를 설계한다는 것은 단순하게 모듈 또는 라이브러리로 분리한다는 의미가 아닙니다. 함수를 정의할 때는 그 구현을 함수 시그니처 뒤에 숨기게 되는데, 이것이 바로 추상화입니다.
아이템 26. 함수 내부의 추상화 레벨을 통일하라
컴퓨터 과학이 높은 레벨과 낮은 레벨을 확실하게 구분하고 있는 것처럼, 함수도 높은 레벨과 낮은 레벨을 구분해서 사용해야 한다는 원칙이 있습니다. 이를 추상화 레벨 통일 (Single Level of Abstraction, SLA) 원칙이라고 부릅니다.
class CoffeeMachine {
fun makeCoffee() {
// 수백 개의 변수의 선언합니다.
// 복잡한 로직을 처리합니다.
// 낮은 수준의 최적화도 여기에서 잔뜩합니다.
}
}
class CoffeeMachine {
fun makeCoffee() {
boilWater()
brewCoffee()
pourCoffee()
pourMilk()
}
}
위의 코드처럼 작성하면 makeCoffee라는 함수가 수백 줄이 될 수도 있습니다. 하지만 이런 함수는 로직을 읽을 때 세부적인 내용을 하나하나 신경써야 하므로, 읽고 이해하는 것이 거의 불가능에 가깝습니다. 따라서 최근에는 다음과 같이 함수를 계층처럼 나누어서 사용하는 것입니다.
아래 코드처럼 작성하면 함수가 대체 어떤 식으로 동작하는지 확실하게 확인할 수 있습니다. makeCoffee 함수는 읽고 이해하기 쉬우며, 누군가가 낮은 레벨 (boilWater, brewCoffee 등)을 이해해야 한다면, 해당 부분의 코드만 살펴보면 됩니다. 매우 간단한 추상화를 추출해서 가독성을 크게 향상시킨 것입니다.
함수는 간단해야 합니다. 이는 '함수는 작아야 하며, 최소한의 책임만 가져야 한다' 라는 일반적인 규칙 (SRP)입니다. 또한 어떤 함수가 다른 함수보다 좀 복잡하다면, 일부 부분을 추출해서 추상화하는 것이 좋습니다.
추가적으로 이런 형태로 함수를 추출하면, 재사용과 테스트가 쉬워집니다.
프로그램 아키텍처의 추상 레벨
추상화 계층이라는 개념은 함수보다 높은 레벨에서도 적용할 수 있습니다. 추상화를 구분하는 이유는 서브시스템의 세부 사항을 숨김으로써 상호 운영성 (interoperability)과 플랫폼 독립성을 얻기 위함입니다. 이는 문제 중심으로 프로그래밍한다는 의미입니다.
4 높은 레벨 문제 중심 |
3 낮은 레벨 문제 중심 |
2 낮은 레벨 구현 구조 |
1 프로그래밍 언어 구조와 도구 |
0 운영 체제 연산과 머신 명령 |
이러한 개념은 모듈 시스템 (modular system)을 설계할 때도 중요합니다. 모듈을 분리하면 계층 고유의 요소를 숨길 수 있습니다. 애플리케이션을 만들 때는 입력과 출력을 나타내는 모듈 (FE의 뷰, BE의 HTTP 요청 처리 등)은 낮은 레벨의 모듈입니다. 그리고 비즈니스 로직을 나타내는 부분이 높은 레벨의 모듈입니다.
아이템 27. 변화로부터 코드를 보호하려면 추상화를 사용하라
함수와 클래스 등의 추상화로 실질적인 코드를 숨기면, 사용자가 세부 사항을 알지 못해도 괜찮다는 장점이 있습니다. 그리고 이후에 실질적인 코드를 원하는대로 수정할 수도 있습니다.
상수
리터럴은 아무것도 설명하지 않습니다. 따라서 코드에서 반복적으로 등장할 때 문제가 됩니다. 이러한 리터럴을 상수 프로퍼티로 변경하면 해당 값에 의미있는 이름을 붙일 수 있으며, 상수의 값을 변경해야 할 때 훨씬 쉽게 변경할 수 있습니다.
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
// ...
}
이렇게 하면 '비밀번호의 최소 길이'를 변경하기도 쉽습니다. 함수의 내부 로직을 전혀 이해하지 못해도 상수의 값만 변경하면 됩니다. 그래서 두 번 이상 사용 되는 값은 이렇게 상수로 추출하는 것이 좋습니다. 예를 들어 데이터베이스에 동시에 연결할 수 있는 최대 스레드 수를 다음과 같이 정의했다고 합시다.
val MAX_THREADS = 10
일단 이렇게 추출하면 변경이 필요할 때 쉽게 변경할 수 있습니다. 이러한 숫자가 프로젝트 전체에 펴져 있다면 변경하기 정말 힘들 것입니다.
상수로 추출하면 다음과 같은 장점이 있습니다.
- 이름을 붙일 수 있고,
- 나중에 해당 값을 쉽게 변경할 수 있습니다.
함수
애플리케이션을 개발하고 있느데, 사용자에게 토스트 메세지를 자주 출력해야 하는 상황이 발생했다고 합시다. 기본적으로 다음과 같은 코드를 사용해서 토스트 메세지를 출력합니다.
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)
이렇게 일반적인 알고리즘을 추출하면, 토스트를 출력하면 코드를 항상 기억해 두지 않아도 괜찮습니다. 또한 (거의 일어나지 않겠지만) 이후로 토스트를 출력하는 방법이 변경되어도, 확장 함수 부분만 수정하면 되므로 유지보수성이 향상됩니다.
하지만 이런 해결 방법은 좋지 않습니다. 내부적으로만 사용하더라도, 함수의 이름을 직접 바꾸는 것은 위험할 수 있습니다 (아이템 28: API 안전성을 확인하라). 다른 모듈이 이 함수에 의존하고 있다면, 다른 모듈에 큰 문제가 발생할 것입니다. 또한 함수의 이름은 한꺼번에 바꾸기 쉽지만, 파라미터는 한꺼번에 바꾸기가 쉽지 않으므로, 메시지의 지속시간을 나타내기 위한 Toast.LENGTH_LONG이 계속 사용되고 있다는 문제도 있습니다.
메시지의 출력 방법이 바뀔 수 있다는 것을 알고 있디만, 이 때부터 중요한 것은 메시지의 출력 방법이 아니라, 사용자에게 메시지를 출력하고 싶다는 의도 자체입니다. 따라서 메시지를 출력하는 더 추상적인 방법이 필요합니다. 토스트 출력을 토스트라는 개념과 무관한 showMessage라는 높은 레벨의 함수로 옮겨봅시다.
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")
클래스가 함수보다 더 강력한 이유는 상태를 가질 수 있으며, 많은 함수를 가질 수 있다는 점 때문입니다. 현재 위의 코드에서 클래스의 상태인 context는 기본 생성자를 통해 주입(inject)됩니다. 의존성 주입 프레임워크를 사용하면, 클래스 생성을 위임할 수도 있습니다.
@Inject lateinit var messageDisplay: MessageDisplay
또한 mock 객체를 활용해서 해당 클래스에 의존하는 다른 클래스의 기능을 테스트할 수 있습니다.
val messageDisplay: MessageDisplay = mockk()
게다가 메시지를 출력하는 더 다양한 종류의 메서드를 만들 수도 있습니다.
messageDisplay.setChristmasMode(true)
이처럼 클래스는 훨씬 더 많은 자유를 보장해 줍니다. 하지만 여전히 한계가 있습니다. 예를 들어 클래스가 final이라면, 해당 클래스 타입 아래에 어떤 구현이 있는지 알 수 있습니다. open 클래스를 활용하면 조금은 더 자유를 얻을 수 있습니다. open 클래스는 서브클래스를 대신 제공할 수 있기 때문입니다. 더 많은 자유를 얻으려면, 더 추상적이게 만들면 됩니다. 바로 인터페이스 뒤에 클래스를 숨기는 방법입니다.
인터페이스
라이브러리를 만드는 사람은 내부 클래스의 가시성을 제한하고, 인터페이스를 통해 이를 노출하는 코드를 많이 사용합니다. 이렇게 하면 사용자가 클래스를 직접 사용하지 못하므로, 라이브러리를 만드는 사람은 인터페이스만 유지한다면, 별도의 걱정 없이 자신이 원하는형태로 그 구현을 변경할 수 있습니다. 사용자가 추상화된 것에만 의존하게 만들 수 있는 것입니다. 즉, 결합(coupling)을 줄일 수 있는 것입니다.
코틀린은 멀티 플랫폼 언어이기 때문에 클래스가 아니라 인터페이스를 리턴합니다. 왜냐하면 최적화 때문에 각 플랫폼 별로 listOf가 다른 리스트를 리턴하기 때문입니다.
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 }
이렇게 구성하면 더 많은 자유를 얻을 수 있습니다. 이러한 클래스는 태블릿에서 토스트를 출력하게 만들 수도 있고, 스마트폰에서 스낵바를 출력하게 할 수도 있습니다. 또한 안드로이드, iOS, 웹에서 공유해서 사용하는 공통 모듈에서도 MessageDisplay를 사용할 수 있습니다. 각각의 플랫폼에서 구현만 조금 다르게 하면 됩니다.
또 다른 장점은 테스트할 때 인터페이스 페이킹(faking)이 클래스 모킹(mocking)보다 간단하므로, 별도의 모킹 라이브러리(mocking library)를 사용하지 않아도 된다는 것입니다.
val messageDisplay: MessageDisplay = TestMessageDisplay()
마지막으로 선언과 사용이 분리되어 있으므로, ToastDisplay 등의 실제 클래스를 자유롭게 변경할 수 있습니다. 다만 사용 방법을 변경하려면, MessageDisplay 인터페이스를 변경하고, 이를 구현하는 모든 클래스를 변경해야 합니다.
ID 만들기 (nextId)
그럼 추가적인 예를 하나 더 살펴봅시다. 프로젝트에서 고유 ID(unique ID)를 사용해야 하는 상황을 가정하겠습니다. 가장 간단한 방법은 어떤 정수 값을 계속 증가시키면서, 이를 ID로 활용하는 것입니다.
val nextId: Int = 0
// 사용
val newId = newxId++
그런데 이러한 코드가 많이 사용되면, 약간 위험합니다. ID가 생성되는 방식을 변경할 때 문제가 발생하기 때문입니다.
- 이 코드의 ID는 무조건 0부터 시작합니다.
- 이 코드는 thread-safe하지 않습니다.
만약 그래도 이 방법을 사용해야 한다면, 일단 이후에 발생할 수 있는 변경으로부터 코드를 보호할 수 있게 함수를 사용하는 것이 좋습니다.
prvate var nextId: Int = 0
fun getNextId(): Int = nextId++
// 사용
val newId = getNextId()
이제 ID 생성 방식의 변경으로부터는보호되지만, ID 타입 변경 등은 대응하지 못합니다. 미래의 어느 시점에 ID를 문자열로 변경해야 한다면 어떨까요? 만약 그 시점 이전에 ID가 계속 Int로 유지될 거라고 생각해서, 여러 연산들이 타입에 종속적이게 작성되었다면 어떻게 해야 할까요? 이를 최대한 방지하려면, 이후에 ID 타입을 쉽게 변경할 수 있게 클래스를 사용하는 것이 좋습니다.
data class Id(private val id: Int)
private var nextId: Int = 0
fun getNextId(): Id = Id(nextId++)
더 많은 추상화는 더 많은 자유를 주지만, 이를 정의하고, 사용하고, 이해하는 것이 조금 어려워졌습니다.
'Kotlin' 카테고리의 다른 글
[이펙티브 코틀린] 3장 재사용성 (1) | 2023.06.18 |
---|---|
[Kotlin] != null과 ?.let 퍼포먼스 차이가 있을까? (2) | 2023.02.06 |
[Kotlin] filter, map 호출 순서에 따른 성능 차이 (0) | 2023.02.03 |
[Kotlin] Collection 관련 함수들 (filter, map, flatmap) (0) | 2023.02.03 |
[Kotlin] 문자열 Count 하기 (0) | 2022.08.01 |
댓글