일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | ||||
4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 |
- 객체
- 멤버변수
- 2022 플러터 안드로이드 스튜디오
- jvm 작동 원리
- 스택 큐 차이
- 2022 플러터 설치
- 안드로이드 라이선스
- 스택 자바 코드
- 자바 다형성
- ANR이란
- 안드로이드 레트로핏 crud
- 안드로이드 os 구조
- android retrofit login
- 서비스 vs 쓰레드
- 안드로이드 레트로핏 사용법
- Rxjava Observable
- rxjava disposable
- ar vr 차이
- jvm이란
- 플러터 설치 2022
- 클래스
- android ar 개발
- 큐 자바 코드
- 안드로이드 유닛테스트란
- 안드로이드 유닛 테스트 예시
- rxjava cold observable
- 안드로이드 라이선스 종류
- 안드로이드 유닛 테스트
- rxjava hot observable
- 서비스 쓰레드 차이
- Today
- Total
나만을 위한 블로그
[이펙티브 코틀린] 아이템 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
// ...
}
이렇게 하면 비밀번호의 최소 길이를 바꾸기도 쉽다. 함수 내부 로직을 전혀 이해하지 못해도 상수값만 바꾸면 된다. 그래서 2번 이상 쓰이는 값은 이렇게 상수로 추출하는 게 좋다. 예를 들어 DB에 동시 연결할 수 있는 최대 쓰레드 수를 아래처럼 정의했다고 가정한다.
val MAX_THREADS = 10
일단 이렇게 추출하면 변경이 필요할 때 쉽게 바꿀 수 있다. 이런 숫자가 프로젝트 전체에 퍼져 있다면 변경하기 힘들 것이다. 상수로 추출하면
- 이름을 붙일 수 있고
- 나중에 해당 값을 쉽게 바꿀 수 있다
이는 다른 추상화 방법에서도 적용되는 얘기다.
함수
앱을 개발하고 있는데 사용자에게 토스트 메시지를 자주 출력해야 하는 상황이 발생했다고 가정한다. 기본적으로 아래와 같은 코드로 토스트 메시지를 출력한다.
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
이렇게 많이 쓰이는 알고리즘은 아래처럼 간단한 확장 함수로 만들어서 사용할 수 있다.
fun Context.toast(
message: String,
duration: Int = Toast.LENGTH_SHORT
) {
Toast.makeText(this, message, duration).show()
}
이렇게 일반적인 알고리즘을 추출하면 토스트 출력 코드를 기억하지 않아도 괜찮다. 또한 거의 일어나지 않겠지만 이후에 토스트를 출력하는 방법이 변경돼도 확장 함수 부분만 수정하면 되므로 유지보수성이 향상된다. 스낵바로 출력해야 한다면 스낵바를 출력하는 확장 함수를 만들고 기존의 Context.toast()를 Context.snackbar()로 한꺼번에 바꾸면 된다.
하지만 이런 해결법은 좋지 않다. 내부적으로만 사용하더라도 함수명을 직접 바꾸는 것은 위험할 수 있다. 다른 모듈이 이 함수에 의존하고 있다면 다른 모듈에 큰 문제가 발생할 것이다. 또한 함수명은 한꺼번에 바꾸기 쉽지만 파라미터는 한꺼번에 바꾸기 쉽지 않으므로 메시지 지속시간을 나타내기 위한 Toast.LENGTH_SHORT가 계속 쓰이고 있다는 문제도 있다.
메시지 출력 방법이 바뀔 수 있다는 걸 알고 있다면 중요한 것은 메시지 출력 방법이 아니라 사용자에게 메시지를 출력하고 싶다는 의도 자체다. 따라서 메시지를 출력하는 더 추상적인 방법이 필요하다. 토스트 출력을 토스트란 개념과 무관한 showMessage()라는 높은 레벨의 함수로 옮긴다.
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 }
가장 큰 변화는 이름이다. 일부 개발자는 이름 변경은 그냥 레이블을 붙이는 방식의 변화이므로 큰 차이가 없다고 생각하기도 한다. 하지만 이런 관점은 컴파일러 관점에서만 유효하다. 사람 관점에선 이름이 바뀌면 큰 변화가 일어난 것이다. 함수는 추상화를 표현하는 수단이며 함수 시그니처는 이 함수가 어떤 추상화를 표현하고 있는지 알려준다. 따라서 의미있는 이름은 굉장히 중요하다.
함수는 매우 단순한 추상화지만 제한이 많다. 함수는 상태를 유지하지 않는다. 또한 함수 시그니처를 바꾸면 프로그램 전체에 큰 영향을 줄 수 있다. 구현을 추상화할 수 있는 더 강한 방법은 클래스가 있다.
클래스
이전의 메시지 출력을 클래스로 추상화한다.
enum class MessageLength { SHORT, LONG }
class MessageDisplay(val context: Context) {
fun show(
message: String,
duration: MessageLength = MessageLength.SHORT
) {
val toastDuration = when (duration) {
MessageLength.SHORT -> Toast.LENGTH_SHORT
MessageLength.LONG -> Toast.LENGTH_LONG
}
Toast.makeText(context, message, toastDuration).show()
}
}
클래스가 함수보다 더 강력한 이유는 상태를 가질 수 있으며 많은 함수를 가질 수 있다는 점 때문이다. 클래스 멤버 함수를 메서드라 부른다. 현재 위의 코드에서 클래스 상태인 context는 기본 생성자로 주입된다. 의존성 주입 프레임워크를 쓰면 클래스 생성을 위임할 수도 있다.
@Inject lateinit var messageDisplay: MessageDisplay
그리고 메시지를 출력하는 더 다양한 종류의 메서드를 만들 수도 있다.
messageDisplay.setChristmasMode(true)
이처럼 클래스는 훨씬 더 많은 자유를 보장해준다. 하지만 여전히 한계가 있다. 클래스가 final이라면 해당 클래스 타입 아래에 어떤 구현이 있는지 알 수 있다. open 클래스를 쓰면 조금은 더 자유를 얻을 수 있다. open 클래스는 서브클래스를 대신 제공할 수 있기 때문이다. 더 많은 자유를 얻으려면 더 추상적으로 만들면 된다. 인터페이스 뒤에 클래스를 숨기는 방법이다.
인터페이스
코틀린 표준 라이브러리를 읽어보면 거의 모든 것이 인터페이스로 표현된다는 걸 확인할 수 있다. 예를 들어
- listOf()는 List를 리턴한다. 여기서 List는 인터페이스다. listOf()는 팩토리 메서드라고 할 수 있다
- 컬렉션 처리 함수는 Iterable 또는 Collection의 확장 함수로서 List, Map 등을 리턴한다. 이것들은 모두 인터페이스다
- 프로퍼티 위임은 ReadOnlyProperty 또는 ReadWriteProperty 뒤에 숨겨진다. 이것들도 모두 인터페이스다. 실질적인 클래스는 일반적으로 private다. 함수 lazy는 Lazy 인터페이스를 사용한다
지금까지 봤던 메시지 표시 예제에 인터페이스를 도입한다. 클래스를 인터페이스 뒤에 숨긴다는 건 아래처럼 한다는 의미다.
enum class MessageLength { SHORT, LONG }
class ToastDisplay(val context: Context): MessageDisplay {
override fun show(message: String, duration: MessageLength) {
val toastDuration = when (duration) {
MessageLength.SHORT -> Toast.LENGTH_SHORT
MessageLength.LONG -> Toast.LENGTH_LONG
}
Toast.makeText(context, message, toastDuration).show()
}
}
interface MessageDisplay {
fun show(message: String, duration: MessageLength = MessageLength.SHORT)
}
이렇게 구성하면 더 많은 자유를 얻을 수 있다. 이런 클래스는 태블릿에서 토스트를 출력하게 만들 수도 있고 스낵바를 출력하게 할 수도 있다. 또한 안드로이드, iOS, 웹에서 공유해 사용하는 공통 모듈에서도 사용할 수 있다. 각 플랫폼에서 구현만 조금 다르게 하면 된다. 또 다른 장점은 테스트 시 인터페이스 페이킹이 클래스 모킹보다 간단하므로 별도의 모킹 라이브러리를 안 써도 된다는 것이다.
ID 만들기(nextId)
프로젝트에서 고유 ID를 써야 하는 상황을 가정한다. 가장 간단한 방법은 어떤 정수값을 계속 증가시키면서 이를 ID로 활용하는 것이다.
var nextId: Int = 0
// 사용
val newId = nextId++
그런데 이런 코드가 많이 쓰이면 약간 위험하다. ID가 생성되는 방식을 변경할 때 문제가 발생한다. 이 방법은 아래의 문제들이 있다.
- 이 코드의 ID는 무조건 0부터 시작한다
- 이 코드는 쓰레드 세이프하지 않다
그래도 이 방법을 써야 한다면 이후에 발생할 수 있는 변경으로부터 코드를 보호할 수 있게 함수를 쓰는 게 좋다.
private var nextId: Int = 0
fun getNextId(): Int = nextId++
// 사용
val newId = getNextId()
이제 ID 생성 방식의 변경으로부터는 보호되지만 ID 타입 변경 등은 대응하지 못한다. 미래의 어느 시점에 ID를 문자열로 바꿔야 한다면 어떤가? 그 시점 이전에 ID가 계속 Int로 유지될 거라 생각해서 여러 연산들이 타입에 종속적이게 작성됐다면? 이를 최대한 방지하려면 이후에 ID 타입을 쉽게 변경할 수 있게 클래스를 쓰는 게 좋다. 더 많은 추상화는 더 많은 자유를 주지만 이를 정의, 사용, 이해하는 게 조금 어려워졌다.
추상화가 주는 자유
지금까지 본 추상화 방법들을 구현할 때 여러 도구를 활용할 수 있다.
- 제네릭 타입 파라미터를 사용한다
- 내부 클래스를 추출한다
- 생성을 제한한다(팩토리 함수로만 객체를 만들 수 있게 만드는 등)
하지만 추상화에는 단점도 있다.
추상화의 문제
어떤 방식으로 추상화하려면 코드를 읽는 사람이 해당 개념을 배우고 잘 이해해야 한다. 또 다른 방식으로 추상화하려면 또 해당 개념을 배우고 잘 이해해야 한다. 물론 추상화 가시성을 제한하거나 구체적인 작업에서만 추상화를 도입하는 것은 큰 문제가 없다. 그래서 큰 프로젝트에선 잘 모듈화해야 한다. 어쨌거나 추상화도 비용이 발생한다. 따라서 극단적으로 모든 걸 추상화해선 안 된다.
추상화는 많은 걸 숨길 수 있는 테크닉이다. 너무 많은 것을 숨기면 결과 이해 자체가 어려워진다. 추상화가 너무 많으면 코드를 이해하기 어렵다.
어떻게 균형을 맞춰야 하는가?
모든 추상화는 자유를 주지만 코드가 어떻게 돌아가는지 이해하기 어렵게 만든다. 극단적인 것은 언제나 좋지 않다. 최상의 답은 언제나 그 사이 어딘가에 있다. 적절한 균형을 찾는 건 거의 감각에 의존해야 하는 예술에 가깝다. 수천 시간까진 아니더라도 수백 시간의 경험이 있어야 할 수 있는 일이다.
항상 뭔가 변화할 수 있다고 생각하는 것이 좋다. 이후에 더 일반적인 매커니즘이 필요할 가능성이 있는지, 플랫폼 독립적인 매커니즘이 필요할 수 있는지 등은 여러 경험을 해보면 어느 정도 알 수 있게 된다.
'책 > Effective Kotlin' 카테고리의 다른 글
[이펙티브 코틀린] 아이템 29. 외부 API를 랩(wrap)해서 사용하라 (0) | 2023.01.14 |
---|---|
[이펙티브 코틀린] 아이템 28. API 안정성을 확인하라 (0) | 2023.01.13 |
[이펙티브 코틀린] 아이템 26. 함수 내부의 추상화 레벨을 통일하라 (0) | 2023.01.09 |
[이펙티브 코틀린] 아이템 25. 공통 모듈을 추출해서 여러 플랫폼에서 재사용하라 (0) | 2022.11.09 |
[이펙티브 코틀린] 아이템 24. 제네릭 타입과 variance 한정자를 활용하라 (0) | 2022.10.30 |