추상화를 통해 변화로부터 코드를 보호할 수 있다.
추상화를 위해서 아래 네가지 방법을 사용할 수 있다.
- 상수
- 함수
- 클래스
- 인터페이스
상수(constant value)
리터럴은 아무것도 설명하지 않으므로 코드에서 반복적으로 등장할 때 문제가 된다.
이 리터럴을 상수 프로퍼티로 변경하면 값에 의미있는 이름을 붙일 수 있으며 변경도 쉬워진다.
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)
이렇게 추출하면 토스트를 출력하는 방법이 변경되어도 확장 함수 부분만 수정하면 되어 유지보수성이 향상된다.
위에서 토스트가 아닌 스낵바라는 형태의 방식으로 출력의 변경이 필요하면 아래와 같이 기존 Context.toast()의 이름을 Context.snakbar()로 수정할 수 있다.
fun Context.snackbar(
message: String,
duration: Int = Toast.LENGTH_LONG
) {
// ...
}
하지만 이런 해결 방법은 아래 관점에서 좋지 않다.
- 내부적으로 사용하더라도 함수의 이름을 직접 바꾸는 것은 위험할 수 있다. 다른 모듈이 이 함수에 의존하고 있다면 다른 모듈에 큰 문제가 발생할 수 있다.
- 함수의 이름은 바꾸기 쉽지만 파라미터는 바꾸기 쉽지 않다. 메시지 지속시간을 나타내는 Toast.LENGTH_LONG이 계속 사용된다는 문제도 있다.
- 스낵바를 출력하는 행위가 토스트의 필드에 영향을 받는 것은 좋지 않다. 그렇다고 Snackbar.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) {
MessageLength.SHORT -> Length.LENGTH_SHORT
MessageLength.LONG -> Length.LENGTH_LONG
}
Toast.makeText(this, message, toastDuration).show()
}
}
enum class MessageLength { SHORT, LONG }
// 사용
val messageDisplay = MessageDisplay(context)
messageDisplay.show("Message")
클래스가 함수보다 더 강력한 이유는 상태를 가질 수 있으며, 많은 함수를 가질 수 있다는 점 때문이다.
- 의존성 주입 프레임워크를 사용해 클래스 생성을 위임할 수도 있다.
- mock 객체를 이용해 클래스에 의존하는 다른 클래스의 기능을 테스트할 수 있다.
- 위의 예시에서 메시지를 출력하는 더 다양한 종류의 메소드를 만들 수 있다.
인터페이스
클래스는 많은 자유를 보장해주지만 여전히 한계가 존재하며 많은 자유를 얻으려면 인터페이스를 이용할 수 있다.
코틀린 표준 라이브러리를 읽어보면 거의 모든것이 인터페이스로 표현된다는 것을 확인할 수 있다.
- listOf 함수는 List를 리턴하며 여기서 List는 인터페이스이다.
- 컬렉션 처리 함수는 Iterable, Collection의 확장 함수로 List, Map 등을 리턴하며 이들은 모두 인터페이스이다.
- 프로퍼티 위임은 ReadOnlyProperty 또는 ReadWriteProperty 뒤에 숨겨지며 이들 또한 모두 인터페이스이다.
라이브러리를 만드는 사람은 내부 클래스의 가시성을 제한하고 인터페이스를 통해 이를 노출하는 코드를 많이 사용한다. 이렇게 하면 사용자가 클래스를 직접 사용하지 못하므로, 라이브러리를 만드는 사람은 인터페이스만 유지하면 별도의 걱정 없이 원하는 형태로 그 구현을 변경할 수 있다.
인터페이스 뒤에 객체를 숨기면서 실질적인 구현을 추상화하고 사용자가 추상화된 것에만 의존하게 만들 수 있다. 즉 결합(coupling)을 줄일 수 있다.
interface MessageDisplay {
fun show(
message: String,
duration: MessageLength = MessageLength.LONG
)
}
class ToastDisplay(val context: Context) : MessageDisplay {
override fun show(
message: String,
duration: MessageLength
) {
val toastDuration = when (duration) {
MessageLength.SHORT -> Length.LENGTH_SHORT
MessageLength.LONG -> Length.LENGTH_LONG
}
Toast.makeText(this, message, toastDuration).show()
}
}
enum class MessageLength { SHORT, LONG }
이렇게 구성하면 더 많은 자유를 얻을 수 있다.
- 각각의 플랫폼(태블릿, IOS, 안드로이드, 웹)에서 구현만 조금 다르게 하면 유연하게 사용할 수 있다.
- 인터페이스 페이킹이 클래스 모킹보다 간단해 별도의 모킹 라이브러리를 사용하지 않아도 된다.
- 선언과 사용이 분리되어 toastDisplay 등의 실제 클래스를 자유롭게 변경할 수 있다.
추상화가 주는 자유
추상화를 하는 몇가지 방법을 정리하면 아래와 같다.
- 상수로 추출
- 동작을 함수로 래핑
- 함수를 클래스로 래핑
- 인터페이스 뒤에 클래스를 숨긴다.
- 보편적인 객체(universal object)를 특수한 객체(specialistic object)로 래핑한다.
또한 이를 구현할 때는 여러 도구를 활용할 수 있다.
- 제네릭 타입 파라미터 사용
- 내부 클래스를 추출한다.
- 생성을 제한한다. (팩토리 함수로만 객체 생성할 수 있게 하는 등)
추상화의 문제
추상화도 비용이 발생하기 떄문에 극단적으로 모든 것을 추상화해서는 안된다.
추상화는 거의 무한하게 할 수 있지만, 어느 순간부터 득보다 실이 많아질 수 있다.
이를 풍자한 FizzBuzz Enterprise Edition이라는 프로젝트가 있음
추상화가 너무 많으면 코드를 이해하기 어렵다. 추상화가 많은 코드를 보면 코드를 제대로 읽기도 전에 두려움에 사로잡힐 수 있다.
추상화를 이해하려면 예제를 살펴보는게 좋다. 단위 테스트와 문서의 예제는 추상화가 어떻게 사용되는지 확실하게 보여준다.
어떻게 균형을 맞춰야 할까?
추상화는 많은 자유를 주지만 이해하기 어렵게 만든다.
극단적인 것은 좋지 않으며 최상의 답은 언제나 그 사이 어딘가에 있다. 이는 아래 요소들에 따라 달라질 수 있다.
- 팀의 크기
- 팀의 경험
- 프로젝트의 크기
- 특징 세트(feature set)
- 도메인 지식
적절한 균형을 찾는 것은 어렵지만, 균형을 찾기 위해 사용할 수 있는 몇가지 규칙이 있다.
- 많은 개발자가 참여하는 프로젝트는 이후에 객체 생성이나 사용 방법을 변경하기 어렵기 때문에 추상화를 사용하는게 좋다. 최대한 모듈과 부분(part)를 분리하는것이 좋다.
- 의존성 주입 프레임워크를 사용하면 생성의 복잡함은 신경쓰지 않아도 된다.
- 테스트를 하거나 다른 애플리케이션을 기반으로 새로운 애플리케이션을 만든다면 추상화를 사용하는게 좋다.
- 프로젝트가 작고 실험적이라면 추상화를 하지 않고도 직접 변경해도 괜찮다. 문제가 발생하면 최대한 빨리 직접 변경하면 된다.
정리
- 추상화는 단순히 중복성을 제거해 코드를 구성하기 위한 것이 아니다.
- 추상화를 사용할때의 장점과 단점을 모두 이해하고 프로젝트 내에서 균형을 찾아야 한다.
Reference
- 이펙티브 코틀린 - 프로그래밍 인사이트, 마르친 모스칼라 지음, 윤인성 옮김
개인적인 기록을 위해 작성된 글이라 잘못된 내용이 있을 수 있습니다.
오류가 있다면 댓글을 남겨주세요.