시배's Android

Effective Kotlin | 4장. 추상화 본문

Book/Effective Kotlin

Effective Kotlin | 4장. 추상화

si8ae 2024. 2. 28. 22:38

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

  • 계층이 잘 분리되면 무엇이 좋을까요?
  • 어떤 계층에서 작업할 때 그 아래의 계층은 이미 완성되어 있으므로, 해당 계층만 생각하면 된다는 것입니다.
  • 즉, 전체를 이해할 필요가 없어지는 것입니다.

추상화 레벨

  • 높은 레벨로 갈수록 물리 장치로부터 점점 멀어집니다.
  • 프로그래밍에서는 일반적으로 높은 레벨일수록 프로세서로부터 멀어진다고 표현합니다.
  • 높은 레벨일수록 걱정해야 하는 세부적인 내용들이 적습니다.
  • 높은 레벨일수록 단순함을 얻지만, 제어력을 잃습니다.
  • 예를 들어 C언어는 메모리 관리를 직접 할 수 있습니다. 반면, 자바는 가비지 컬렉터가 자동으로 메모리를 관리해 줍니다.

추상화 레벨 통일

  • 함수도 높은 레벨과 낮은 레벨을 구분해서 사용해야 한다는 원칙이 있습니다.
  • 이를 추상화 레벨 통일 SLA 원칙이라고 부릅니다.
class CoffeeMachine {
  fun makeCoffee() {
      // 수백 개의 변수를 선언
      // 복잡한 로직을 처리
      // 낮은 수준의 최적화도 여기에서 잔뜩
  }
}

->

class CoffeeMachine {  
    fun makeCoffee() {  
        boilWater()  
        brewCoffee()  
        pourCoffee()  
        pourMilk()  
    }  
}
  • 아래의 makeCoffee 함수는 읽고 이해하기 쉬우며, 누군가가 낮은 레벨을 이해해야 한다면, 해당 부분의 코드만 살펴보면 됩니다.
  • 간단한 추상화를 추출해서 가독성을 크게 향상시킨 것입니다.
  • 함수는 작아야 하며, 최소한의 책임만을 가져야 한다라는 일반적인 규칙입니다.
  • 재사용과 테스트가 쉬워집니다.

프로그램 아키텍처의 추상 레벨

  • 추상화를 구분하는 이유는 서브시스템의 세부 사항을 숨김으로써 상호 운영성과 플랫폼 독립성을 얻기 위함입니다.
  • 모듈을 분리하면 계층 고유의 요소를 숨길 수 있습니다.
  • 계층화가 잘 된 프로젝트는 어떤 계층 위치에서 코드를 보아도 일관적인 관점을 얻을 수 있습니다.

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

함수와 클래스 등의 추상화로 실질적인 코드를 숨기면, 사용자가 세부 사항을 알지 못해도 괜찮다는 장점이 있습니다.

상수

  • 리터럴은 아무것도 설명하지 않습니다.
  • 코드에서 반복적으로 등장할 때 문제가 됩니다.
  • 상수 프로퍼티로 변경하면 해당 값에 의미 있는 이름을 붙일 수 있으며, 상수의 값을 변경해야 할 때 훨씬 쉽게 변경할 수 있습니다.

ex) MIN_PASSWORD_LENGTH = 7

함수

  • 많이 사용되는 알고리즘은 확장 함수로 만들어서 사용할 수 있습니다.
fun Context.toast(
    message : String,
    duration : Int = Toast.LENGTH_LONG
) {
    Toast.makeText(this, message, duration).show()
}
  • 토스트를 출력하는 방법이 변경되어도, 확장 함수 부분만 수정하면 되므로 유지보수성이 향상됩니다.
  • 만약 다른 형태의 방식으로 출력해야 한다면, 어떻게 해야 할까요?
  • 함수의 이름을 직접 바꾸는 것은 위험할 수 있습니다.
  • 다른 모듈이 이 함수에 의존하고 있다면, 다른 모듈에 큰 문제가 발생할 것입니다.
  • 파라미터는 한꺼번에 바꾸기가 쉽지 않으므로, 메시지의 지속시간을 나타내기 위한 Toast.LENGTH_LONG이 계속 사용되고 있다는 문제도 있습니다.
  • 함수는 추상화를 표현하는 수단이며, 함수 시그니처는 이 함수가 어떤 추상화를 표현하고 있는지 알려 줍니다.
  • 의미 있는 이름은 굉장히 중요합니다.
  • 함수는 매우 단순한 추상화지만, 제한이 많습니다.
  • 상태를 유지하지 않습니다.

클래스

  • 클래스가 함수보다 더 강력한 이유는 상태를 가질 수 있으며, 많은 함수를 가질 수 있다는 점 때문입니다.
  • 의존성 주입 프레임워크를 사용하면, 클래스 생성을 위임할 수도 있습니다.
  • mock 객체를 활용해서 해당 클래스에 의존하는 다른 클래스의 기능을 테스트할 수 있습니다.
  • 메시지를 출력하는 더 다양한 종류의 메서드를 만들 수도 있습니다.
  • 여전히 한계는 존재합니다. final이라면, 해당 클래스 타입 아래에 어떤 구현이 있는지 알 수 있습니다.
  • open 클래스를 활용하면 조금은 더 자유를 얻을 수 있습니다.

인터페이스

  • 라이브러리를 만드는 사람은 내부 클래스의 가시성을 제한하고, 인터페이스를 통해 이를 노출하는 코드를 많이 사용합니다.
  • 인터페이스 뒤에 객체를 숨김으로써 실질적인 구현을 추상화하고, 사용자가 추상화된 것에만 의존하게 만들 수 있는 것입니다.
  • coupling을 줄일 수 있는 것입니다.
  • faking이 mocking보다 간단하므로 테스트할 때 좋습니다.
  • 선언과 사용이 분리되어 있으므로, 변경에 자유롭습니다.

추상화가 주는 자유

추상화를 하는 방법

  • 상수로 추출
  • 동작을 함수로 래핑
  • 함수를 클래스로 래핑
  • 인터페이스 뒤에 클래스를 숨긴다
  • 특수한 객체로 래핑한다

추상화의 문제

  • 코드를 읽는 사람이 해당 개념을 배우고, 잘 이해해야 합니다.
  • 추상화도 비용이 발생합니다.
  • 추상화는 많은 것을 숨길 수 있는 테크닉입니다. 생각할 것을 어느 정도 숨겨야 개발이 쉬워지는 것도 사실이지만 너무 많은 것을 숨기면 결과를 이해하는 것 자체가 어려워집니다.

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

  • 팀의 크기
  • 팀의 경험
  • 프로젝트의 크기
  • feature 세트
  • 도메인 지식

위와 같은 요소들을 고려해야 합니다.

  • 많은 개발자가 참여하는 프로젝트는 이후에 객체 생성과 사용 방법을 변경하기 어렵습니다. 따라서 추상화 방법을 사용하는 것이 좋습니다. 최대한 모듈과 부분을 분리하는 것이 좋습니다.
  • DI 프레임워크를 사용하면, 생성이 얼마나 복잡한지는 신경 쓰지 않아도 됩니다.
  • 테스트를 하거나, 다른 애플리케이션을 기반으로 새로운 애플리케이션을 만든다면 추상화를 사용하는 것이 좋습니다.
  • 프로젝트가 작고 실험적이라면, 추상화를 하지 않고도 직접 변경해도 괜찮습니다.

아이템28. API 안정성을 확인하라

  • API가 변경되고, 개발자가 이를 업데이트했다면, 여러 코드를 수동으로 업데이트 해야합니다.
  • 사용자가 새로운 API를 배워야 합니다.
  • 일반적으로 버전을 활용해서 라이브러리와 모듈의 안정성을 나타냅니다.
  • 일반적으로는 시멘틱 버저닝을 사용합니다.
  • MAJOR : 호환되지 않는 수준의 API 변경
  • MINOR : 이전 변경과 호환되는 기능을 추가
  • PATCH : 간단한 버그 수정
  • 안정적인 APi에 새로운 요소를 추가할 때, 아직 해당 요소가 안정적이지 않다면, 다른 브랜치에 해당 요소를 두는 것이 좋습니다.
  • 일부 사용자가 이를 사용하도록 허용하려면, 일단 Experimental 메타 어노테이션을 사용해서 사용자들에게 알려 주는 것이 좋습니다.
  • 안정적인 API의 일부를 변경해야 한다면, 전환하는 데 시간을 두고 Deprecated 어노테이션을 활용해서 사용자에게 미리 알려 줘야 합니다.
  • ReplaceWith를 붙여 대안이 있다면 자동 전환을 할 수 있도록 하는 것도 좋습니다.

아이템29. 외부 API를 랩해서 사용하라

불안정하다고 판단되는 외부 라이브러리 API를 랩해서 사용하면 다음과 같은 자유와 안정성을 얻을 수 있습니다.

  • 문제가 있다면 래퍼만 변경하면 되므로, API 변경에 쉽게 대응할 수 있습니다.
  • 프로젝트의 스타일에 맞춰서 API의 형태를 조정할 수 있습니다.
  • 특정 라이브러리에서 문제가 발생하면, 래퍼를 수정해서 다른 라이브러리를 사용하도록 코드를 쉽게 변경할 수 있습니다.
  • 필요한 경우 쉽게 동작을 추가하거나 수정할 수 있습니다.

단점으로는 다음과 같은 것들이 있습니다.

  • 래퍼를 따로 정의해야 합니다.
  • 다른 개발자가 프로젝트를 다룰 때, 어떤 래퍼들이 있는지 따로 확인해야 합니다.
  • 래퍼들은 프로젝트 내부에서만 유효하므로, 문제가 생겨도 질문할 수 없습니다.

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

  • 클래스의 상태를 나타내는 프로퍼티를 외부에서 변경할 수 있다면, 클래스는 자신의 상태를 보장할 수 없습니다.
class CounterSet<T>(
    private val innerSet : MutableSet<T> = setOf()
) : MutableSet<T> by innterSet {

    var elementsAdded : Int = 0
        private set

    override fun add(element : T) : Boolean {
        elementsAdded++
        return innerSet.add(element)
    }

    override fun addAll(elements: Collection<T>) : Boolean {
        elementsAdded  += elements.size
        return innerSet.add(element)
    }
}
  • 일반적으로 코틀린에서는 접근자의 가시성을 제한해서 모든 프로퍼티를 캡슐화하는 것이 좋습니다.
  • 객체 상태를 보호하는 것이 더 중요해집니다.
  • 가시성이 제한될수록 클래스의 변경을 쉽게 추적할 수 있으며, 프로퍼티의 상태를 더 쉽게 이해할 수 있습니다.

가시성 한정자 사용하기

  • public : 어디에서나 볼 수 있습니다.
  • private : 클래스 내부에서만
  • protected : 클래스와 서브클래스 내부에서만
  • internal : 모듈 내부에서만

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

규약

  • 어떤 행위를 설명하면 사용자는 이를 약속으로 취급하며, 이를 기반으로 스스로 자유롭게 생각하던 예측을 조정합니다.
  • 규약을 정의해두면, 클래스를 만드는 사람과 사용하는 사람 모두 미리 정의된 규약에 따라 독립적으로 작업할 수 있습니다.

규약 정의하기

규약을 정의하는 방법은 다양합니다.

  • 이름 : 이름만으로 동작을 예측할 수 있다.
  • 주석과 문서: 모든 규약을 적을 수 있는 방법이다.
  • 타입: 타입은 객체에 대한 많은 것을 알려준다. 리턴 타입과 아규먼트 타입은 큰 의미가 있으며, 자주 사용되는 타입의 경우에는 타입만 봐도 어떻게 사용하는지 알 수 있지만, 일부 타입은 문서에 추가로 설명해야 한다.

KDoc 형식

  • 주석으로 함수를 문서화할 때 사용되는 공식적인 형식을 KDoc이라고 부르며, /**로 시작해서 */로 끝납니다.
  • 또한 이 사이의 모든 줄은 일반적으로 *으로 시작합니다.
  • KDoc 주석의 구조는 다음과 같습니다.
  • 첫 번째 부분은 요소에 대한 요약 설명
  • 두 번째 부분은 상세 설명
  • 이어지는 줄은 모두 태그로 시작하며, 추가적인 설명을 위해 사용된다.
  • 사용할 수 있는 태그는 다음과 같습니다.
  • @param : 함수 파라미터 또는 클래스, 프로퍼티, 함수 타입 파라미터
  • @return: 함수의 리턴 타입 값
  • @constructor: 클래스의 기본 생성자
  • @receiver: 확장 함수의 리시버
  • @property : 이름을 갖고 있는 클래스의 프로퍼티, 기본 생성자에 정의된 프로퍼티에 사용
  • @throws , @exception : 메서드 내부에서 발생할 수 있는 예외
  • @sample : 정규화된 형식 이름을 사용한 함수의 사용 예
  • @see : 특정한 클래스 또는 메서드에 대한 링크
  • @author: 요소의 작성자
  • @since: 요소에 대한 버전
  • @supress: 이를 지정하면, 만들어지는 문서에서 해당 요소가 제외된다. 공식 API에 포함할 필요가 없는 요소에 지정

아이템32. 추상화 규약을 지켜라

class Employee {
    private val id = 2
    override fun toString() = "User(id=$id)"

    private fun privateFunction() {
        println("Private function called")
    }
}

fun callPrivateFunction(employee : Employee) {
    employee::class.declaredMemberFunctions
        .first { it.name == "privateFunction" }
        .apply { isAccessible = true } 
        .call(employee)
}
  • 무언가를 할 수 있다는 것이 그것을 해도 괜찮다는 의미는 아닙니다.
  • 현재 코드는 private 프로퍼티와 private 함수의 이름과 같은 세부적인 정보에 매우 크게 의존하고 있습니다.
  • 이러한 이름은 규약이라고 할 수 없기 때문에, 언제든지 변경될 수 있습니다.
  • 만약 규약을 지키지 않는다면, 객체가 제대로 동작하지 않을 수 있습니다.

ex) 원래 세트는 중복을 허용하지 않는데, equals가 제대로 구현되지 않았을 경우 중복을 허용해 버립니다.