목차
이번 게시글은 Effective Kotlin — Chapter4. Abstraction에 대한 내용입니다.
1. 추상화
컴퓨터 과학에서 추상화는 복잡한 자료, 모듈, 시스템 등으로부터 핵심적인 개념 또는 기능을 간추려 내는 것을 말합니다.
조금 더 간단하게 표현하면, 추상화는 핵심만 도출해 내어 단순하게 만드는 것을 의미합니다.
대표적인 예로 인터페이스가 있습니다. 인터페이스는 클래스라는 복잡한 것에서 메서드와 프로퍼티라는 중요한 것만 추출해서 간단하게 만들어었으므로, 클래스의 추상화라고 할 수 있습니다.
추상화는 프로그래밍 세계에서 가장 중요한 개념 중 하나입니다. OOP에서 추상화는 세 가지 주요 개념 중에 하나입니다. (캡슐화, 상속, 추상화)
프로그래밍에서의 추상화
많은 개발자는 프로그래밍에서 하는 모든 일이 추상화라는 것을 종종 잊어버립니다. 예를 들어 숫자를 입력하면, 이는 내부적으로 0과 1이라는 복잡한 형식으로 표현됩니다. 문자열을 입력하면 모든 문자가 UTF-8과 같은 복잡한 형식의 문자 집합으로 만들어집니다. 이러한 것들은 모두 추상화가 되어 있기 때문에 우리가 쉽게 사용할 수 있는 것 입니다.
강력한 프로그래밍 언어들이 당연히 갖고 있는 기능 중 하나는 패턴에 이름을 붙여서 추상화를 만드는 기능입니다. 예를 들어 함수, 델리게이트, 클래스 등이 대표적인 예입니다. 이를 활용하면 추상화를 할 수 있습니다.
추상화와 자동차
자동차는 굉장히 잘 만들어진 인터페이스입니다. 내부적으로 굉장히 복잡한 요소들이 많지만, 굉장히 쉽게 사용할 수 있습니다. 가령 감속을 위해 브레이크를 밟는다면 브레이크를 밟고 어떤 원리로 자동차가 감속되는지 알 필요 없이 브레이크를 밟으면 감속이 됩니다.
이처럼 추상화는 내부에 일어나는 모든 것들을 마법처럼 숨겨줍니다. 따라서 운전자는 자동차가 어떻게 구성되는지 전혀 몰라도 괜찮습니다. 운전하는 방법만 알면 됩니다.
마찬가지로 프로그래밍에서는 다음과 같은 목적으로 추상화를 사용합니다.
- 복잡성을 숨기기 위해
- 코드를 체계화 하기 위해
- 만드는 사람에게 변화의 자유를 주기 위해
2. 함수 내부의 추상화 레벨을 통일하라
컴퓨터는 굉장히 복잡한 장치입니다. 하지만 이러한 복잡함이 여러 계층에 다양한 요소로서 분할되어 있으므로 쉽게 사용할 수 있는 것입니다.
개발자의 관점에서 컴퓨터에서 가장 낮은 추상화 계층은 하드웨어입니다. 개발자는 일반적으로 프로세서를 위한 코드를 작성하므로, 하드웨어 위의 관심 있는 계층은 프로세서 제어 명령입니다.
이러한 프로세서 제어 명령은 원래 0, 1로 이루어지지만, 이를 쉽게 읽을 수 있게 일대일로 대응된 어셈블리 라는 언어로 표현합니다. 하지만 어셈블리는 매우 어렵기 때문에, 오늘날 우리가 사용하는 것과 같은 프로그램을 만드는 것은 상상도 할 수 없습니다.
그래서 프로그래밍을 간단하게 할 수 있게, 엔지니어는 한 언어를 다른 언어(java, kotlin이라면 바이트 코드로 변환)로 변환 할 수 있게 컴파일러를 만들었습니다.
계층이 잘 분리되면 무엇이 좋을까요? 어떤 계층에서 작업할 때 그 아래의 계층은 이미 완성되어 있으므로, 해당 계층만 생각하면 된다는 것입니다. 즉, 전체를 이해할 필요가 없어지는 것입니다. 예를 들어 어셈블리어나 JVM 바이트 코드가 무엇인지 몰라도 프로그래밍을 할 수 있습니다.
추상화 레벨
일반적으로 컴퓨터 과학자들은 어떤 계층이 높은 레벨인지 낮은 레벨인지를 구분합니다. 높은 레벨로 갈수록 물리 장치로부터 점점 멀어집니다. 높은 레벨일수록 걱정해야 하는 세부적인 내용들이 적습니다. 높은 레벨일수록 단순함을 얻지만, Control하기 어렵습니다.
예를 들어 C언어는 메모리 관리를 직접 할 수 있습니다. 하지만 자바는 GC가 자동으로 메모리를 관리해 줍니다. 따라서 메모리 사용을 최적화하는 것이 매우 힘듭니다.
추상화 레벨 통일
컴퓨터 과학과 마찬가지로 코드도 추상화를 계층처럼 만들어서 사용할 수 있습니다. 이를 위한 기본적인 도구가 바로 함수입니다. 컴퓨터 과학이 높은 레벨과 낮은 레벨을 확실하게 구분하고 있는 것처럼, 함수도 높은 레벨과 낮은 레벨을 구분해서 사용해야 한다는 원칙이 있습니다.
이를 추상화 레벨 통일 (Single Level of Abstraction, SLA) 원칙이라고 부릅니다.
함수 추상화 예시
버튼 하나만 누르면 커피를 만들 수 있는 커피 머신을 나타내는 클래스를 만든다고 해 봅시다. 커피를 만드는 것은 커피 머신의 여러 부분들이 필요한 복잡한 작업입니다. 다음과 같이 makeCoffee()
라는 함수 하나를 갖는 CoffeeMachine
클래스를 만들어 봅시다.
하지만 이렇게 코드를 작성하면 makeCoffee()
가 수백 줄이 될 수도 있습니다. 이런 함수는 읽으면서 세부적인 내용을 하나하나 신경 써야하므로, 읽고 이해하기가 매우 어렵습니다. 만약에 이런 코드에서 “물의 온도를 수정해 달라”라고 요청을 받았다면, 어디 부분을 수정해야 하는지 감조차 잡히지 않을 것 입니다.
이제 이 함수가 대체 어떤 식으로 동작하는지 확실하게 확인 할 수 있습니다.makeCoffee()
는 읽고 이해하기 쉬우며, 누군가가 낮은 레벨 (boilWater()
, brewCoffee()
등)을 이해해야 한다면, 당 부분의 코드만 살펴보면 됩니다.
이처럼 함수는 간단해야 합니다. 이는 ‘함수는 작아야 하며, 최소한의 책임만을 가져야 한다’라는 일반적인 규칙입니다. 또한 어떤 함수가 다른 함수보다 좀 복잡하다면, 일부분을 추출해서 추상화 하는 것이 좋습니다.
모든 추상화 레벨에서 추상 요소(메소드, 클래스)를 조작합니다. 각각의 추상 요소가 어떤 내용을 담고 있는지 확인하고 싶다면, 정의로 이동해서 확인하면 됩니다.
추가적으로 이런 형태로 함수를 추출하면, 재사용과 테스트가 쉬워집니다.
예를 들어 makeCoffee()
는 물을 끓이고, 커피를 내리고, 커피를 붓고, 우유를 넣습니다. 따라서 라떼를 만드는 과정입니다. 만약 에스프레소를 만드는 기능을 추가한다면, 다음과 같이 우유만 안 넣으면 됩니다. 함수를 재 사용하는 일이 훨씬 쉬워진 것입니다.
또한 함수가 작아져 단위 테스트도 쉽습니다. makeCoffee()
, makeEspressoCoffee()
처럼 복잡한 함수가 아니라, boilWater()
, brewWater()
처럼 작은 함수로 테스트 할 수 있기 때문입니다.
프로그램 아키텍처의 추상 레벨
추상화 계층이라는 개념은 함수보다 높은 레벨에서도 적용할 수 있습니다. 추상화를 구분하는 이유는 서브시스템의 세부 사항을 숨김으로써 상호 운영성과 플랫폼 독립성을 얻기 위함입니다. 이는 문제 중심으로 프로그래밍을 한다는 의미입니다.
이러한 개념은 모듈 시스템을 설계할 때도 중요합니다. 모듈을 분리하면 계층 고유의 요소를 숨길 수 있습니다. 애플리케이션을 만들 때는 입력과 출력을 나타내는 모듈 (뷰, 백엔드 HTTP 요청 등)은 낮은 레벨의 모듈입니다. 그리고 비즈니스 로직을 나타내는 부분이 높은 레벨의 모듈입니다.
정리
별도의 추상화 계층을 만드는 것은 프로그래밍에서 일반적으로 사용되는 개념입니다. 이는 knowledge를 체계화하고, 서브시스템의 세부 사항을 숨김으로써 상호 운영성과 플랫폼 독립성을 얻게 합니다. 함수, 클래스, 모듈 등의 다양한 방식을 통해서 추상화를 분리합니다. 이때 각각의 레이어가 너무 커지는 것은 좋지 않습니다. 작고 최소한의 책임만 갖는 함수가 이해하기 쉽습니다. 추상화 레벨은 구체적인 동작, 프로세서, 입출력과 가까울수록 낮은 레벨이라고 표현합니다. 낮은 추상화 계층에서는 높은 계층에서 사용하는 API를 만듭니다.
3. 변화로부터 코드를 보호하려면 추상화를 사용하라
함수와 클래스 등의 추상화로 실질적인 코드를 숨기면, 사용자가 세부 사항을 알지 못해도 괜찮다는 장점이 있습니다. 그리고 이후에 실질적인 코드를 원하는대로 수정할 수도 있습니다. 예를 들어 정렬 알고리즘을 할수로 추출하면, 이를 사용하는 코드에 어떠한 영향도 주지 않고, 함수의 성능을 최적화 할 수 있습니다.
상수
리터럴(고정 값)은 아무것도 설명하지 않습니다. 따라서 코드에서 반복적으로 등장 할 때 읽는데 방해가 됩니다. 이러한 리터럴은 상수 프로퍼티로 변경하면 해당 값의 의미 있는 이름을 붙일 수 있으며, 리터럴을 변경하기도 용이합니다.
함수
함수의 추상화는 함수의 구현 내용보다는 구현 의도를 표현해야 합니다.
가령 애플리케이션에서 토스트 메시지를 자주 출력해야 하고 있어 토스트 메시지를 보여줄 함수를 만들었습니다.
이렇게 구현해두면 토스트를 띄우기 위한 세부사항을 기억하고 있지 않아도 간편하게 토스트 메시지를 띄울 수 있습니다.
근데 만약 요구사항이 변경되어 토스트가 아닌 스낵바로 출력해야 한다면 어떻게 해야할까요?
이렇게 만들어서 기존 toast()
를 모두 snackbar()
로 바꾸실 것인가요?
IDE 기능을 통해 이름 바꾸는 것은 어렵지 않습니다. 하지만 변경된 것은 이름 뿐만이 아닙니다. length : Int
파라미터 값도 변경되었습니다. 이를 변경하기란 쉽지 않습니다. 일일이 찾아서 바꿔줘야 합니다.
그래서 변경에 쉽게 대응하기 위해서는 함수는 구체적인 행동에 대한 이름보다는 행동에 대한 의도를 표현하는 것이 좋습니다.
우리의 의도는 “메시지를 보여 준다”이고, 그 중에 이 메시지를 보여주기 위해서 Toast
를 사용하거나 Snackbar
를 사용하는 것 입니다. 그렇기에 우리의 의도인 showMessage()
와 같은 이름으로 사용하는 것이 맞습니다.
클래스
어떤 특정 기능을 클래스로 두었을 때, 클래스는 상태와 행동을 가지고 있기 때문에 함수보다 더 강력합니다.
예시로 이전 메시지 출력 함수를 클래스로 변경해보겠습니다
또한 이뿐만 아니라 메시지를 출력하는 더 다양한 종류의 메시지를 만들 수도 있습니다.
messageDisplay.setChristmasMode(true)
또한 mock 객체를 이용해서 해당 클래스에 의존하는 다른 클래스의 기능을 테스트 할 수 있습니다.
val messageDisplay : MessageDisplay = mockk()
이처럼 클래스는 함수보다 훨씬 더 많은 자유를 보장해 줍니다. 여기서 더 많은 자유를 주기 위해서, 더 추상적이게 만들 수 있습니다. 바로 인터페이스 뒤에 클래스를 숨기는 방법입니다.
인터페이스
코틀린 표준 라이브러리를 읽어보면, 의 모든 것이 인터페이스로 표현된다는 것을 확인할 수 있을 것입니다.
listOf()
는List
인터페이스를 리턴합니다.- 컬렉션 처리 함수는
Iterable
또는Collection
의 확장 함수로서List
,Map
인터페이스를 리턴합니다. - 프로퍼티 위임은
ReadOnlyProperty
혹은ReadWriteProperty
뒤에 숨겨집니다. 이것들도 모두 인터페이스입니다. 실질적인 클래스는 일반적으로private
입니다.lazy()
도Lazy
인터페이스를 리턴합니다.
라이브러리를 만드는 사람은 내부 클래스의 가시성을 제한하고, 인터페이스를 통해 이를 노출하는 코드를 많이 사용합니다. 이렇게 하면 사용자가 클래스를 직접 사용하지 못하므로, 라이브러리를 만드는 사람은 인터페이스만 유지한다면, 별도의 걱정 없이 자신이 원하는 형태로 그 구현을 변경 할 수 있습니다.
즉, 인터페이스 뒤에 객체를 숨김으로써 실질적인 구현을 추상화하고, 사용자가 추상화된 것에만 의존하게 만들 수 있는 것입니다. 즉 결합도를 낮출 수 있습니다.
이제 인터페이스를 이용해서 앞서 만든 MessageDisplay
를 변경해보도록 하겠습니다.
이렇게 구성하면 더 많은 자유를 얻을 수 있습니다. AOS, IOS, Web에서 공유해서 사용하는 공통 모듈에서 MessageDisplay를 사용할 수 있습니다. 각각 플랫폼에서 구현만 조금 다르게 하면 됩니다.
또 다른 장점은 테스트 할 때 인터페이스 페이킹이 클래스 모킹보다 간단하므로, 별도의 모킹 라이브러리를 사용하지 않아도 된다는 것입니다.
- 테스트
val messageDisplay : MessageDisplay = TestMessageDisplay()
- 실제
val messageDisplay : MessageDisplay = ToastMessageDisplay()
추상화가 주는 자유
지금까지 제시한 추상화를 하는 몇 가지 방법을 정리해보면 다음과 같습니다.
- 상수로 추출한다.
- 동작을 함수로 래핑한다
- 함수를 클래스로 래핑한다
- 인터페이스 뒤에 클래스를 숨긴다.
- 보편적인 객체를 특수한 객체로 래핑한다.
지금까지 이러한 것들이 우리에게 어떤 장점을 주는지 살펴보았습니다. 이를 구현할 때는 여러 도구를 활용할 수 있습니다.
- 제네릭 타입 파라미터를 사용한다
- 내부 클래스를 추출한다.
- 생성을 제한한다 (생성자를 막고 factory 함수로만 객체 생성)
추상화의 문제
어떤 방식으로 추상화를 하려면 코드를 읽는 사람이 해당 개념을 배우고, 잘 이해해야 합니다. 그렇기에 극단적으로 모든 것을 추상화 해서는 안 됩니다.
오히려 극단적인 추상화로 간단하게 해결될 문제가 더 복잡하게 될 수 있습니다.
어떻게 균형을 맞춰야 할까?
경험에 의하면, 모든 추상화는 자유를 주지만, 코드가 어떻게 돌아가는 것인지 이해하기 어렵게 만듭니다. 극단적인 것은 언제나 좋지 않으며, 상황에 따라 맞게 조절해야 합니다.
- 많은 개발자가 참여하는 프로젝트는 이후에 객체 생성과 사용 방법을 변경하기 어렵습니다. 따라서 추상화 방법을 사용하는 것이 좋습니다.
- DI 프레임워크를 활용하여 객체 생성의 단순화를 만들면 좋습니다.
- 테스트를 하거나, 공통 모듈을 활용한다면 추상화를 사용하는게 좋습니다.
- 프로젝트가 작고 실험적이면, 추상화에 대한 이득은 크지 않습니다.
4. API 안정성을 확인하라
일반적으로 버전을 활용해서 라이브러라와 모듈의 안정성을 나타냅니다. 많은 버저닝 시스템이 있지만 일반적으로 시멘틱 버저닝을 사용합니다.
시멘틱 버저닝은 3.2.1와 같은 버전 번호를 보셨을 것입니다. 이 버전 번호는 .을 기점으로 MAJOR.MINOR.PATCH로 나누어 구성합니다.
각 부분은 0 이상의 정수로 구성되며, 0부터 시작하여 API의 변경이 있을 때 +1씩 해줍니다.
- MAJOR 버전 : 기존 버전과 호환되지 않는 수준의 API 변경
- MINOR 버전 : 이전 변경과 호환되는 기능 추가
- PATCH 버전 : 간단한 버그 수정
MAJOR를 증가시킬 때는 MINOR와 PATCH를 0으로 돌려 둡니다. MINOR를 증가시킬 때는 PATCH를 0으로 돌려둡니다. 사전 배포와 빌드 메타데이터 등은 추가적인 레이블을 활용합니다.
5. 외부 API를 랩(wrap)해서 사용하라
API 설계자가 안전하지 않다고 하거나, API 설계가 안전하다고 해도 우리 기획에 의해 바뀔 여지가 분명히 존재한다면 해당 API를 래핑 해서 사용하는 것이 좋습니다.
랩해서 사용하면 다음과 같은 자유와 안정성을 얻을 수 있습니다.
- 문제가 있다면 래퍼만 변경하면 되므로, API 변경에 쉽게 대응할 수 있습니다.
- 프로젝트 스타일에 맞춰서 API의 형태를 조정할 수 있습니다.
- 특정 라이브러리에서 문제가 발생하면, 래퍼를 수정해서 다른 라이브러리를 사용하도록 코드를 쉽게 변경할 수 있습니다.
- 필요한 경우 쉽게 동작을 추가하거나 수정할 수 있습니다.
단점은 다음과 같습니다.
- 래퍼를 따로 정의해야 합니다.
- 다른 개발자가 프로젝트를 다룰 때, 어떤 래퍼들이 있는지 따로 확인해야 합니다.
이 장단점을 비교하고 외부 API를 래핑할 지 말지 결정해야 합니다.
6. 요소의 가시성을 최소화하라
API를 설계할 때 가능한 간결한 API를 선호하는 데는 여러가지 이유가 있습니다.
- 작은 인터페이스는 배우기 쉽고 유지하기 쉽습니다.
- 변경을 가할 때는 기존의 것을 숨기는 것보다 새로운 것을 노출하는 것이 쉽습니다.
- 클래스의 상태를 나타내는 프로퍼티를 외부에서 변경할 수 있다면, 클래스는 자신의 상태를 보장할 수 없습니다.
가시성 한정자 사용하기
클래스 멤버인 경우
public
(디폴트) : 어디에서나 볼 수 있습니다.private
: 클래스 내부에서만 볼 수 있습니다.protected
: 클래스와 서브 클래스 내부에서만 볼 수 있습니다.internal
: 모듈 내부에서만 볼 수 있습니다.
톱레벨 요소인 경우
public
(디폴트) : 어디에서나 볼 수 있습니다.private
: 같은 파일 내에서만 볼 수 있습니다.internal
: 모듈 내부에서만 볼 수 있습니다.
만약 모듈이 다른 모듈에 의해서 사용될 가능성이 있다면, internal
을 사용해서 공개하고 싶지 않은 요소를 숨깁니다.
요소가 상속을 위해 설계되어 있고, 클래스와 서브 클래스에서만 사용되게 만들고 싶다면 protected
를 사용합니다.
동일한 파일 또는 클래스에서만 요소를 사용하게 만들고 싶다면 private
을 사용합니다.
한가지 큰 제한은, API를 상속할 때 오버라이드에서 가시성을 제한할 수는 없습니다. 이는 서브 클래스가 슈퍼클래스로도 사용될 수 있기 때문입니다.
이것이 상속보다 컴포지션을 선호하는 대표적인 이유입니다.
7. KDoc 형식
주석으로 함수를 문서화할 때 사용되는 공식적인 형식을 KDoc이라고 부릅니다.
Effective Kotlin Chapter 4. Abstraction에 대한 요약은 여기까지 입니다.
다음 게시글에서 Chapter5. 객체 생성에 대한 요약으로 작성하도록 하겠습니다.