Search
Duplicate

4. 추상화 설계

목차

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

추상화

추상화 레벨을 통일하라고 하는데 추상화가 무엇인지에 대해 간단하게 짚고 넘어가자.
우리는 커피를 마시고 싶으면 카페로가서 종업원에게 커피를 주문하고 돈을 지불하고, 일정 시간을 기다려서 커피를 받는다. 그게 끝이다.
그럼 여기서 커피를 주문하고 받기까지 우리는 다른 어떤걸 신경 쓸 필요가 없다. 하지만, 내부적으로 커피를 만들기 위해 원두를 꺼내서 적절한 굵기로 갈고 탬퍼링을 하고 머신에 꼽아넣고 샷을 내리고, 컵을 옮겨담아서 물 혹은 우유등을 추가하고 얼음을 넣는 과정까지 상당히 많은 과정들이 있다.
하지만, 사용자 입장에서 이런 과정들을 알아야 할까? 사용자는 그저 커피를 주문했으면 받아서 마시는데만 집중하면 된다.
그리고 이처럼 부가적인 상세 내용들을 신경쓰지 않고 how 보다는 what에 집중하는것이 추상화다.

추상화 레벨

옆의 표를 보면 아래로 내려갈수록 물리 계층과 가까워진다.
프로그래밍에선 물리 계층 즉 실체와 가까워질수록 추상화 레벨이 낮아진다고 표현한다. 이런 실체와 가까워지는 과정(추상화 레벨 다운)이 진행 될 수록 우리는 많은 것을 직접 제어해야하기에 더 세밀한 제어가 가능하지만, 반대로 말하면 너무 많은 걸 매번 모두 제어해줘야 하기 때문에 비용도 크고 문제가 발생할 여지도 커진다.
반대로 실체로부터 멀어질수록(추상화 레벨 업) 우리는 그런 세밀한 제어를 할 수는 없지만 대신 단순하게 목표에 집중할 수 있게 된다.
애플리케이션
프로그래밍 언어
어셈블러
하드웨어
물리장치
대표적으로 자바는 메모리 관리를 가비지 컬렉터(GC)가 대신 해주기 때문에 개발자가 메모리 관리까지 신경을 쓰지 않고 도메인에만 집중하면 되지만, 메모리를 직접 최적화 하기가 까다롭다.
반면, C 언어와 같은 로우 언어를 사용할 경우 메모리 관리를 직접 할 수 있기에 최적화를 하기 용이하지만, 반대로 직접 가비지 컬렉터가 하는 관리를 해줘야해서 까다롭다.

추상화 레벨 통일

추상화 레벨은 통일하는게 좋다. CS(Computer Science)에서도 함수는 추상화 레벨이 높은 것과 낮은 것을 구분해서 사용해야 하는 원칙이 있다고 하는데 이를 추상화 레벨 통일(Single Level of Abstraction, SLA) 원칙 이라 한다.
위에서 커피 얘기를 했으니 예제도 커피 만들기(makeCoffee)로 작성해보자.
커피를 작성하기 위해서는 대략적으로 과정을 나누면
물을 끓이고
원두를 갈고
컵 위에 드리퍼를 올리고
드리퍼에
종이 필터를 올리고
분쇄된 원두를 올리고
끓인 물을 붓고
컵과 드리퍼를 분리한다.
이를 코드로 작성하면 다음과 같다.
fun makeCoffee() { //물, 원두, 드리퍼, 종이 필터, 컵 등의 변수를 선언한다. //물을 끓이는 로직부터 컵 분리 로직까지 각각 작성한다. //최적화 작업이나 리팩토링도 이 안에서 해결한다. }
Kotlin
복사
단순하게 로직에 집중해서 코드를 만들면 makeCoffee()함수의 내부 로직은 위 주석과 같이 작성될 것이다. 하지만, 이런 식의 구성은 makeLatte()같이 라떼 생성 함수나 리팩토링, 특정 기능 추가(물의 온도 제어 변경)와 같은 요청들을 처리하기 위해서 함수 내의 많은 로직중 적절한 위치를 찾아서 수정해야 하는 작업이 필요한데, 이런 함수들이 많아질수록 찾기는 더 힘들어 질 수 밖에 없다.
그렇기에 여기서 의미있는 기능들을 적절한 함수 시그니처로 분리해보면 다음과 같이 만들 수 있다.
fun makeCoffee() { boilWater() grindCoffeeBeans() placeOnCup() placeOnDripper() pourWater() seperateCup() }
Kotlin
복사
우리는 makeCoffee() 메서드에서 일어나는 과정들에 대해 내부에서 호출되는 함수 시그니처만 봐도 알 수 있다. 물을 끓이고, 원두를 갈고, 컵위에 드리퍼를 세팅하고, 드리퍼위에 필터와 원두를 세팅하고, 물을 넣고 컵을 빼는 과정 전부 말이다.
그리고 여기서 물의 온도를 조절하고 싶으면 boilWater() 메서드만 참고해도 된다. 다른 로직들을 살펴 볼 필요가 없는 것이다. 또한 라떼를 만들고 싶다면 저기서 pourMilk() 함수만 추가해주면 된다.
이런 분리는 기능들을 쪼개어 각각의 함수들이 더 적은 책임을 맡게하고 이는 단위테스트가 용이해진다는 장점도 가질 수 있게 된다.

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

상황별 추상화

추상화의 대상은 다양하다.
클래스를 인터페이스로 추상화 할 수 있고, 그러면서 인터페이스에 정의되는 함수 역시 추상화 된다.
그 외에 매직넘버나 매직리터럴 역시 추상화가 가능하다. 즉, 변화가 가능한데, 변경이 힘들게 작성된 것들은 대부분 추상화가 가능하다.

매직넘버, 매직리터럴은 상수로

fun validName(name: String) : Boolean { require(name.length > 5) { "이름은 5글자가 넘을 수 없습니다" } return name != "캣비" }
Kotlin
복사
위 이름 유효성 검증 함수는, 이름이 5글자가 넘을 경우 예외를 던지고, 이름이 캣비가 아닐 경우 참(true)를 반환한다.
그런데 여기서 5가 뭘 의미하는지 캣비가 뭘 의미하는지 알기 쉬운가? 이러한 값들만 가지고는 이 값들이 가지고 있는 의미(시그니처)를 제대로 보여줄 수 없다.
그렇기에 의미있는 시그니처를 제공할 수 있게 상수로 분리해보면 어떨까?
const val MAX_NAME_LENGTH = 5 const val INVALID_NAME = "캣비" fun validName(name: String) : Boolean { require(name.length > MAX_NAME_LENGTH ) { "이름은 5글자가 넘을 수 없습니다" } return name != INVALID_NAME }
Kotlin
복사
이제 이름이 최대길이를 넘겼는지 검사하고 유효하지 않은 이름과 일치하는지를 비교한다는 것을 바로 상수명만 보고 알 수 있게 된다. 또한 이러한 값들이 재사용될 경우 해당 상수의 값만 바꿔줘도 된다.

함수의 추상화

우리는 객체지향프로그래밍을 배우면서 SOLID원칙에 대해 학습을 했다.
(만약, 잘 모르는 원칙들이라면 해당 포스팅의 SOLID원칙을 보고 오도록 하자. )
이 중 개방-폐쇄 원칙(OCP)와 의존성-역전 원칙(DIP)을 고민 하게 되면 자연스럽게 함수들을 추상화 해야겠다는 생각을 할 수 밖에 없다.
함수들이 구체적일수록 기능 변경이 필요한 상황에서 변경이 힘들어지게 된다.
만약 내가 자판기 프로그램을 만들 때 돈을 입력 받는 함수를 다른 나라를 고려하지 않고 한국만 생각해서 insertKrwMoney 라는 함수를 만들었다고 하자. 그럼 이 자판기가 생각보다 잘 팔려서 미국에서도 팔아야 하는데 미국에서는 달러를 사용 해야 하기 때문에 해당 함수를 쓸 수가 없다.
그렇다고 insertKrwMoney의 내부 로직을 달러를 기준으로 바꾸고 insertDollarMoney 로 메서드명을 바꾸는 것은 API의 안전성을 깨트릴 수 있고 해당 함수를 사용하는 다른 모든 부분을 고쳐줘야 한다. (단순히 이름만 바꾸는걸 말하는게 아니다.)
또한, OCP원칙을 생각하면 확장에는 열려있되 변경에는 닫혀있어야 한다는 원칙인데, 메서드명과 내부 로직을 바꿔주는건 이러한 원칙을 어기게 되는 것이다.
우리는 이런 어려움을 느끼기전에 먼저 insertKrwMoney라는 함수 시그니처부터 고민해볼 필요가 있다. 함수명부터가 한국 돈을 입력한다는 행위를 보여주며 한국 돈에 대한 의존성을 보여주고 있다.
처음부터 이러한 의존성을 제거하고 다음과 같이 만들어보면 어떨까?
fun insertMoney(moneyType: MoneyType = MoneyType.KRW) { ... } insertMoney(MoneyType.DOLLAR) insertMoney(MoneyType.KRW)
Kotlin
복사

클래스의 추상화

함수는 기본적으로 무상태(stateless)이기에 제한이 있고, 그렇기에 상태를 가질 수 있는 클래스보다 강력한 추상화를 제공할 수 없다. 또한 클래스는 많은 메서드를 가질 수 있다.
위임, 모킹과 같은 다양한 추상화 방식들로 더 자유로운 개발을 가능하도록 해준다.
하지만 클래스 레벨부터 불변(final)로 선언된다면 구현을 노출하게 되는 문제는 여전히 존재한다.
open 클래스를 활용해 상속가능하게 하며 확장성을 통한 추상화를 제공해줄 수 있는데, 인터페이스를 활용해 실제 구현체를 인터페이스 뒤로 숨기게 되면 더 추상적인 설계가 가능해진다.

인터페이스

코틀린의 대부분은 인터페이스로 추상화가 되어 있다.
컬렉션도 List, Set등의 추상화된 인터페이스를 반환해 사용하도록 한다. 그리고 이러한 인터페이스 역시 Iterator, Interable프로토콜을 구현하고 있기에 컬렉션을 순회하며 처리할 수 있다.
코틀린이 인터페이스를 반환하는 이유는 하나 더 있는데, 바로 코틀린이 멀티 플랫폼 언어라는 것이다. 다시 말해 코틀린으로 코틀린/JVM, 코틀린/JS, 코틀린/네이티브냐에 따라 구현이 다른 리스트를 반환할 것이다. 하지만 모두 동일한 인터페이스에 맞춰져 있어서 사용에는 문제가 없을 것이다.
SOLID 원칙 중 DIP원칙(Dependency Inversion Principle, DIP) 을 지키는 방법도 바로 인터페이스를 이용하는 것이다. 세부원칙이 아닌 추상화에 의존하라. 라는 말이 있는데, 이게 곧 how보단 what에 집중하라는 말과 동일한 말이며 반대로 세부원칙이 정책에 의존하게 만들라는 말이기도 하다.
인터페이스를 활용하면 여러 디자인패턴들을 사용할 수 있고 예를 들어 전략 패턴(Strategy Pattern)같은 경우, 이런 추상화를 적극적으로 활용하는 케이스라 할 수 있다.
다음과 같이 자동차의 이동 메서드가 있을 때 내부적으로 자동차가 전진하는 상세 조건은 우리가 알지 못한다. 그저 해당 전략에게 움직일 수 있는지 요청하고 응답 결과에 따라 움직이는 것이다.
만약 전진 조건이 달라진다면, 우리는 해당 코드에서 수정할 부분이 없다. 그저 전진 전략 자체를 다른 새로운 전략을 파라미터로 전달해주면 되는 것이다.
자연스럽게 OCP, DIP 원칙을 지킬 수 있게 되었다.
class Car(val name: String, private var distance: Int) { fun move(movableStrategy: MovableStrategy) { if(movableStrategy.movable()) { distance += MOVING_DISTANCE } } }
Kotlin
복사

일급 객체

바로 위에서 예제를 들었던 자동차 객체를 다시 보자.
이름과 거리 속성을 가지고 있는데, 고객사 요청에 의해 이름이 모두 Long형의 타입으로 식별자코드로 관리되게 바뀐다면 어떻게 될까? 이를 방지하기 위해 Name혹은 Id라는 객체로 래핑해보면 어떨까?
class Car(val name: Name, private var distance: Int) { fun move(movableStrategy: MovableStrategy) { if(movableStrategy.movable()) { distance += MOVING_DISTANCE } } } @JvmInline value class Name(val value: String) { //... }
Kotlin
복사
여기서 만약 이름의 타입을 유연하게 가져가고 싶다면 NameProvider와 같은 인터페이스를 만들고, 이를 구현하는 구현체들을 만들어 제공할 경우 Car 객체는 이른 타입 변경과 같은 변경에서 같이 변경되야하는 위험을 감수할 필요가 없어지게 된다.

추상화는 무적이 아니다

그렇다고 추상화가 치트키는 아니다.
추상화는 하려면 거의 무한하게 할 수가 있다. 예를 들어 10개정도의 기능을 가진 클래스가 있다고 할 때 이 10개의 기능을 모두 하나 하나의 인터페이스로 추상화 할 수 있고 이 하나하나의 기능을 뜯어서 또 여러 인터페이스로 추상화 할 수 있다. 다음은 이러한 상황을 풍자한 FizzBuzz Enterprise Edition이라는 프로젝트인데 FizzBuzz라는 간단한 문제를 수 많은 추상화를 적용해 어디까지 복잡해질 수 있는지 보여주는 예제 프로젝트이다.
FizzBuzzEnterpriseEdition
EnterpriseQualityCoding
추상화는 세부 항목을 숨겨 본질적인 내용에 집중하게 해주는 장점을 가지지만, 반대로 너무 많은 것을 숨기게 되면 동작을 유추하는 것 자체가 힘들어지게 된다.
위에서 봤던 자동차의 전진조건이 랜덤 숫자의 값에 의해 결정되는지 그 값의 숫자가 어떻게되는가 범위는 어디까지인가, 아니면 다른 입력값에 의해 제어되는가등등 여러 내용들을 우리는 알 지 못하기에 오히려 문제가 될 수도 있다.
이처럼 추상화가 너무 많아지면 코드를 이해하기 어려워질 수 있다. 프로덕션 코드에서 이슈가 생겼을 때 분석을 해야하는데, 분석하는데만 한나절을 잡아야하는 문제가 생길수도 있는 것이다.
그렇기에 우리는 추상화와 구체화의 사이에서 균형을 잡아야 할 필요가 있다.

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

추상화의 정도를 고려하기 위해 우리가 고려해야하는 요소들은 다음과 같다.
팀의 크기
팀의 경험
프로젝트의 크기
특징 세트(feature set)
도메인 지식
그리고 이런 요소들을 고려해서 우리가 추상화 정도를 정하기 위한 몇 가지 규칙들을 정해보면 다음과 같다.
협업자가 많을수록 API가 배포된 이후로는 변경이 힘들어진다. 최대한 모듈과 부분(part)를 분리하자.
DI 프레임워크를 사용할 경우 생성 비용을 크게 고려할 필요는 없어진다.
단 한번만 정의되면 이후 고민할 필요가 없기 때문이다
테스트를 작성하거나 외부에 의존하는 경우, 추상화를 사용하면 좋다.
외부 의존하는 영역을 mocking및 stubbing을 이용해 제어할 수 있게 바꿀 수 있기 때문에 기능단을 추상화해 FakeObject를 만들 수 있기 때문이다.
프로젝트가 작고 실험적이라면 추상화보단 생산성에 집중해도 된다. 추상화는 프로젝트가 성공적이고 커질 경우 고려해도 늦지 않다.

28. API 안정성을 확인하라

TDD나 ATDD관련 코드 리뷰어로 활동하면서 자주 듣는 질문이 있다.
어디까지 테스트를 작성해야 하나요? 그리고 어디까지 문서화를 해야하나요?
난 이런 질문에 다음과 같이 답변한다.
public API는 모두 테스트하고 문서화 하는 것을 권장 드립니다.
왜냐하면, public API는 나 혼자만 쓰는게 아니고 모든 클라이언트에게 공개되는 기능이다.
일단 public API는 한 번 배포가 되면 다시 Deprecated시키기는 힘들고, 스펙변경하기도 힘들고 항상 안정적으로 기능을 제공해야 한다. 즉 API는 안정적이어야 한다.
만약, 내가 만들어 배포한 API가 외부에서 천 명 정도가 사용하고 있다고 하자.
내가 여기서 기능을 변경해서 필요한 파라미터가 변경된다면, 천 명의 사용자는 모두 변경된 스펙에 맞춰 자신의 프로그램을 수정해야 한다.
API에 문제가 생겨도 나만 문제가 아니라 내 API를 사용하는 천 명의 사용자의 프로그램도 모두 문제가 생길 것이다.
그렇기에 우리는 API 혹은 API의 일부가 불안정할 경우 버전을 활용해서 라이브러리와 모듈의 안정성을 안내해줘야 한다. 많은 버저닝 시스템(versioning system)이 있는데, 보통 시멘틱 버저닝(Semantic Versioning, SemVer)를 사용한다고 한다.

시멘틱 버저닝(Semantic Versioning, SemVer)

이 시스템은 버전 번호를 MAJOR, MINOR, PATCH로 구분해 구성한다.
MAJOR: 호환되지 않는 수준의 API 변경
MINOR: 이전 변경과 호환되는 기능을 추가
PATCH: 간단한 버그 수정
{MAJOR}.{MINOR}.{PATCH} EX: CatsbiAPI Ver 1.0.0
이렇게 API 최초 출시를 하며 1.0.0으로 배포를 했다고 할 때 API에서 버그가 있어 수정을 했을 경우
1.0.1 ~ 1.0.N이 될 것이고, 내부적으로 API의 알고리즘을 리팩토링해서 성능향상이 되었거나 한 경우 1.1.0 ~ 1.N.0이 될 것이고, 더 크게 새로운 기능이 추가되고, API의 스펙이 바뀌어서 기존의 정책대로 사용할 수 없는 경우에는 1.0.0 ~ N.0.0이 될 것이다.
또한, 버전을 증가시킬 경우 우측의 버전 정보는 모두 0으로 초기화시킨다.

@Experimental meta annotation

코틀린에서는 api에 아직 안전하지 않은 시험적인 요소가 추가될 경우 이러한 점을 알려주기 위해 @Experimental 이라는 메타 애노테이션을 제공해주는데 이 애노테이션을 붙이면, 사용할 수는 있지만, 사용할 때 내가 설정한 레벨에 맞춰 경고나 오류가 출력 된다.
@Experimental(level Experimental.Level.WARNING) annotation class ExperimentalNewApi @ExperimentalNewApi suspend fun getUsers(): List<User> { ... }
Kotlin
복사

@Deprecated meta annotation

이제 API가 불필요해져 제거를 해야 하거나 스펙 자체가 바뀌어서 기존 사용자들도 해당 api를 사용하지 않아야 하거나 필요 프로토콜을 변경을 해야하는 경우 @Deprecated 메타 애노테이션을 붙혀서 변경된다는 점을 알려주고 전환할 준비를 할 수 있는 시간을 알려줄 수도 있다.
@Deprecated("getUsers() 메서드를 사용해주세요") fun getUsers(callback: (List<User>)->Unit) { ... }
Kotlin
복사
혹은 대체할 함수등이 이미 제공되고 있을 경우 IDE에 의해 자동 변경될 수 있도록 ReplaceWith를 사용할 수 있다.
@Deprecated("getUsers() 메서드를 사용해주세요", ReplaceWith("getUsers()")) fun getUsers(callback: (List<User>)->Unit) { ... }
Kotlin
복사

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

외부 API는 우리가 제어할 수 없는 영역이다.
내가 테스트 코드를 작성하든, 기도를 하든, 예외처리를 하든간에 API의 응답 결과가 제대로 되지 않거나 변경이 잦아질수록 내 코드는 같이 영향을 받을 수 밖에 없다. 그렇기에 불안정하다.
하지만, 우리는 이런 API를 사용할 수 밖에 없는 상황에 놓일 때가 많다.
그럼 그냥 냅두고 신경꺼야할까? 잘되기만을 바래야 하거나 문제가 생겨도 “이거 저희 탓 아니에요” 하고 넘겨야 할까?
우리는 이러한 외부 라이브러리의 API를 래핑해서 제어할 수 없는 영역을 제어할 수 있는 영역으로 만들 수 있다. 만약 우리가 S3(Simple Storage Service, S3)를 이용해 간단한 이미지나 파일 저장소로 사용을 한다고 할 때 이를 래핑해서 StorageService 라는 서비스 계층을 만들 수 있고, 기능을 정의해두면 차후 S3에서 다른 스토리지 서비스로 넘어갈때도 쉽게 넘어갈 수 있고, 문제가 발생하더라도 래퍼만 수정하면 되기에 API 변경에 대한 대응도 쉬워진다.
또한, 우리 프로젝트의 코드 스타일과 다르더라도 래퍼를 이용해 컨벤션을 맞춰줄 수도 있다.
하지만, 장점만 있는 것은 아닌데 무엇보다도 래퍼 클래스를 따로 만들어야 한다는 비용 문제가 있다.
외부 API를 쓰는 족족 모두 래퍼를 구현한다면 그 비용만해도 만만치 않을 것이다.
뿐만 아니라 협업관계에서는 이런 래퍼 클래스에 대한 내용이 공유되지 않을 경우 분석에 시간이 걸리거나, 래퍼를 발견 못해서 그대로 사용하면서 코드가 혼잡해질 가능성이 높다.
이러한 장단점을 고려해서 API의 래핑 여부를 결정해야 한다.
책에서는 보통 버전 번호 및 사용자 수를 토대로 얼마나 많은 유지보수가 되었고 생태계가 구성되었는지 확인을 하고, 안정적일수록 래핑을 우선순위에서 내려도 무관해질 것이다.
반대로 사용자가 적은 라이브러리일 경우 위험할 수 있기 때문에 사용에도 주의 해야 하며 사용하기로 했다면, 클래스와 함수로 랩하는 것을 고려해야 한다.

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

객체지향 프로그래밍에 대해 공부하면 처음 배우는 부분이 바로 캡슐화를 이용한 가시성 제한이다.
우리는 객체지향 언어를 사용하며 많은 함수와 상태에 대한 가시성을 접근제어자를 통해 제어할 수 있다.
그리고 이러한 가시성은 최소화 할수록 사용성과 유지보수성이 높아진다.
어째서일까?
자동차를 예로 들어보자. 우리가 자동차를 운전하기 위해서는 필요한 지식 중 외부 교통 정보와 지침을 제외하고 자동차의 제어만 보자면 악셀을 밟아 전진하고, 브레이크를 밟아 정지하고, 핸들을 돌려서 방향을 지정할 수 있다.
여기서 시간을 좀 더 돌려보면 자동과 수동이 있고, 수동까지 시간을 돌린다면 추가적으로 우리는 속도를 조절하기 위해서 기어봉을 적절히 조절할 수 있어야 한다. 하지만, 시대가 흐르고 오토가 나오면서 자동차 조작법의 가시성은 더 낮아졌고, 그 결과 기어봉을 제어하기보단 그냥 D에 놓고 악셀을 적절히 밟아 가속하면 알아서 기어가 바뀌며 속도가 증가하게 된다.
여기서 우리가 얻은 이점은 무엇일까?
우리가 신경써야 할게 줄어들었다는 점이 가장 큰 이점이다. 그럼 사용자만 이득일까?
아니다. 개발자 역시 이득을 얻게 되었다. 사용자가 볼 수 있는 가시성은 기능이 동작하기 위한 최소한의 간결한 동작이고, 그 뒤에 숨어있는 다양하고 복잡한 방법HOW 이 좀 더 자유롭게 개발자가 유지보수 뿐아니라 확장할 수 있게 된다.
다시 개발로 돌아와서 객체의 여러 상태들을 외부에서 직접 변경할 수 있게 한다고 해보자.
class Car(var name: String, var price: Money){ .... } class Seller(var carList: List<Car>, var money: Money){ fun sell(buyer: Customer, pickedCar: Car){ check(carList.contains(pickedCar)) { "not exists pickedCar: $pickedCar" } money.plusAssign(pickedCar.price) buyer.ownCar = pickedCar carList.remove(pickedCar) } } class Customer(var ownCar: Car?) fun logic() { val pickedCar = seller.carList(0).apply { this.price = Money.zero } seller.sell(buyer, pickedCar) }
Kotlin
복사
위 로직을 실행하면 자동차 구매자는 무료로 자동차를 살 수 있게 될 것이다.
사실 그 뿐아니라 모든 프로퍼티가 변경가능하고 외부에서 조작가능하기 때문에 언제 어디서 어떻게 개판이 될 지 알 수 없다. 즉 불변하지 않고 가변적인 클래스들이 된 것이다.
그렇기에 우선 모든 가변 프로퍼티는 가시성을 제한해 캡슐화를 해주는 것이 좋다.
class Car(private var name: String, private var price: Money){ .... } class Seller(private var carList: List<Car>, private var money: Money){ fun sell(buyer: Customer, pickedCar: Car){ check(carList.contains(pickedCar)) { "not exists pickedCar: $pickedCar" } money.plusAssign(pickedCar.price) buyer.ownCar = pickedCar carList.remove(pickedCar) } } class Customer(private var ownCar: Car?) fun logic() { //compile error val pickedCar = seller.carList(0).apply { this.price = Money.zero } seller.sell(buyer, pickedCar) }
Kotlin
복사
혹은 프로퍼티의 setter만 private로 해줘서 조회는 외부에서도 할 수 있지만 값 변경은 캡슐화 하는 방식도 자주 사용된다.
class Car(private var name: String, private var price: Money){ var mileage: Int = 0 private set }
Kotlin
복사
자동차의 주행거리는 외부에서 조작이 안되도록 하지만 조회는 가능하도록 하는게 맞다.

코틀린의 가시성 한정자(접근 제어자)

가시성을 가장 간단하게 변경하는 방법은 접근제어자를 이용해서 할 수가 있다.
자바와 매우 흡사한데 internal이라는 요소가 있다는게 좀 다르다.
public(default): 어디에서나 볼 수 있다.
private: 클래스 내부에서만 볼 수 있다.
protected: 클래스와 서브클래스 내부에서만 볼 수 있다.
internal: 모듈 내부에서만 볼 수 있다.
톱레벨 요소는 세 가지 접근제어자를 사용할 수 있다.
public(default): 어디에서나 볼 수 있다.
private: 같은 파일 내부에서만 볼 수 있다.
internal: 모듈 내부에서만 볼 수 있다.

참고: 모듈의 영역은 패키지와 다르다.

코틀린에서 모듈은 컴파일되는 코틀린 소스를 의미한다. - Gradle SourceSet - Maven project - IDEA Module - Ant Task Compile Set

참고: 이펙티브자바의 아이템15번과 유사한 내용을 가진다.

31. 문서로 규약을 정의하라

우리는 매번 모든 API의 코드를 직접 뜯어보면서 분석하고 히스토리와 로직파악을 해야할까?
내가 숙련도가 있고 도메인 이해도가 있다면, 큰 문제는 없을 수도 있다. 하지만 모든 개발자가 그럴까?
현실은 그렇지 않고, 내가 빠르게 분석을 한다 하더라도, 문서화가 잘 되어있으면 1분이면 될 일을 코드보면서 3분동안 분석하는건 대략 3배의 손해일 수 있다. 심지어 내가 작성한 코드도 1년뒤에 보면 생경하기 마련이다. 그렇기 때문에 KDoc 주석을 작성 해 주는게 좋다.
KDoc의 문서 작성 요령은 javadoc과 동일하다.
/** * ${Main Description} * * @param <name> ${parameter description} * @return {return description} */ fun sumAllByEven(numbers: List<Int>): Int {...}
Kotlin
복사
Main Description: 함수에 대한 대략적인 기능 설명
Ex: 인수에서 짝수의 총 합을 계산해 반환한다.
parameter description: 파라미터에 대한 설명
Ex: 숫자 목록
return description: 반환값에 대한 설명
Ex: 짝수의 총 합 결과

이외의 주석이 필요할까?

자바 초기에는 문학적 프로그래밍이라 해서 주석으로 모든 것을 설명하는 식으로 개발을 진행했다고 하지만, 현재는 주석없이도 코드를 이해 할 수 있도록 코드를 작성하는게 대세다.
클린 코드책이 나오고 이에 가장 큰 영향을 받았는데, 해당 책에서도 이렇게 말하고 있다.
주석으로 주절거리지 말아라.

기타 유용한 주석 태그(@)

param과 return 태그에 대해서만 언급한 이유는 가장 자주사용되는 태그이기 때문인데, 이 외에도 다양한 태그들이 있기에 알아두면 언젠가 사용할 일이 있는 태그부터 알아두면 유용한 태그도 있다.
@constructor: 클래스의 기본 생성자를 문서화한다.
@receiver:확장 함수의 리시버를 문서화한다.
@property <name>: 명확한 이름을 갖고 있는 클래스의 프로퍼티
@throws <class>, @exception <class>: 메서드 내부에서 발생할 수 있는 예외를 문서화한다.
꽤나 자주사용되는 주석 태그이다.
@sample<identifier>: 정규화된 형식 이름을 사용해 함수의 사용 예를 문서화한다.
@see <identifier>: 특정한 클래스 또는 메서드에 대한 링크를 추가한다
@author: 요소의 작성자를 지정한다.
형상관리툴이 발전하며 중요도가 낮아졌다.
@since: 요소의 대한 버전을 지정한다.
형상관리툴이 발전하며 중요도가 낮아졌다.
@supress: 만들어지는 문서에서 해당 요소가 제외된다. 외부에선 사용할 수 있긴 하지만 공식 API에 포함할 필요가 없는 요소에 지정한다.

32.추상화 규약을 지켜라

할 수 있다고 모두 해도 되는 것은 아니다.
접근제어자를 통해 가시성을 제한하고 캡슐화를 함으로써 객체의 내부값이나 내부 메서드에 대한 조작을 할 수 없을 것 같지만, 자바 혹은 코틀린에서 제공하는 리플렉션을 이용하면 가시성을 무시하고 접근해 제어할 수가 있다.
class Foo { private val id: Int = 2 private fun printId() { println("Private id: $id") } } fun hackingFoo(foo: Foo) { foo::class.java.declaredMethods .first { it.name == "printId" } .apply { isAccessible = true } .invoke(foo) } fun changeFooId(foo: Foo, newId: Int) { foo::class.java.getDeclaredField("id") .apply { isAccessible = true } .set(foo, newId) } fun main() { val foo = Foo() hackingFoo(foo) // Private id: 2 changeFooId(foo, 5) hackingFoo(foo) // Private id: 5 }
Kotlin
복사
foo::class.java.declaredMethods ….
foo의 클래스 정보 중 정의된 메서드 목록을 꺼낸 뒤 메서드 명이 printId인 함수를 찾아 접근 권한을 true로 설정한 뒤 실행시킨다.
foo::class.java.getDeclaredField("id")
클래스 정보 중 정의된 프로퍼티 중 id라는 이름의 프로퍼티를 찾아서 접근 권한을 true로 변경하고 newId로 setting 해주도록 한다.
분명 접근제어자를 통해 가시성을 제한했지만 리플렉션을 통해 호출 및 변경이 가능하다.
하지만 할 수 있다고 해도 되는건 아니다. 우리가 접근제어자를 통해 가시성을 제한한 것은 규약이라고 할 수 잇는데, 이런 규약을 지키지 않을 경우 당장은 편해질 수 있을지 모르지만 금새 선택의 대가가 찾아올 수 있다.
우리가 만드는 혹은 사용하는 함수들은 개발자의 의도가 규약들로 정해져서 작성된다.
그리고 그 규약내에서 생기는 문제에 대해서 업데이트하고 개선해줘야 한다. 하지만 반대로 그 규약을 지키지 않고 접근하거나 호출하거나 수정을 시도함으로써 생기는 이슈에 대해서는 우리가 대응을 해주거나 할 필요가 없다.
아니 있다면 그러한 시도를 막는 시도가 있을 것이다.
특히 클래스를 상속하거나 다른 라이브러리의 인터페이스를 구현할때도 상속할 클래스의 규약과 인터페이스에 정의된 규약들을 지켜야 한다.