개발 공부 기록하기/01. JAVA & Kotlin

이펙티브 코틀린 간단 정리 (Effective Kotlin)

lannstark 2022. 5. 21. 15:34

잘 몰랐거나, 인상깊은 부분 위주로만 정리

 

1장 안정성

안정성 : 크래시가 적으면 사용자와 개발자 모두에게 좋고, 상당한 비즈니스 가치를 제공한다.

Item 1. 가변성을 제한하라

시간의 변화에 따라서 변하는 요소를 표현할 수 있다는 것은 유용하지만, 상태를 적절하게 관리하는 것이 생각보다 꽤 어렵다.

  1. 프로그램을 이해하고 디버그하기 힘들어진다.
  2. 시점에 따라 값이 달라질 수 있기 때문에 코드의 실행을 추론하기 어려워진다.
  3. 멀티스레드 프로그램일 때는 적절한 동기화가 필요하다.
  4. 테스트하기 어렵다.
  5. 상태 변경이 일어날 때 이러한 변경을 다른 부분에 알려야 하는 경우가 있다.

가변성은 시스템의 상태를 나타내기 위한 주요한 방법이다. 하지만, 변경이 일어나야 하는 부분을 신중하고 확실하게 결정하고 사용해야 한다.

  • val은 읽기 전용 프로퍼티이지만, 변경할 수 없음을 의미하는 것은 아니다. (custom getter or Delegate)
  • 코틀린이 내부적으로 immutable 하지 않은 컬렉션을 외부적으로 immutable 하게 만들어서 얻어지는 안정성이다. 그런데 개발자가 ‘시스템 해킹'을 시도해서 다운캐스팅을 할 때 문제가 된다.
  • mutable 컬렉션을 사용하는 것이 처음에는 더 간단하게 느껴지겠지만, mutable 프로퍼티를 사용하면 객체 변경을 제어하기가 더 쉽다.

mutable 객체를 외부로 노출하지 않는 방법

  • 방어적 복사
  • 읽기 전용 슈퍼타입으로 업캐스트

Item 2. 변수의 스코프를 최소화하라

코드를 분석할 때는 어떤 시점에 어떤 요소가 있는지를 알아야 한다. 이때 요소가 많아져서 프로그램에 변경될 수 있는 부분이 많아지면, 프로그램을 이해하기가 어려워진다.

변수는 읽기 전용 또는 읽고 쓰기 전용 여부와 상관 없이, 변수를 정의할 때 초기화되는 것이 좋다.

Item 3. 최대한 플랫폼 타입을 사용하지 말라

  • 플랫폼 타입은 안전하지 않으므로, 최대한 빨리 제거하는 것이 좋다
  • 플랫폼 타입이 전파되는 일은 굉장히 위험하다.

Item 4. Inferred 타입으로 리턴하지 말라

할당 때 inferred 타입은 정확하게 오른쪽에 있는 피연산자에 맞게 설정된다는 것을 기억해야 한다. 절대로 슈퍼클래스 또는 인터페이스로는 설정되지 않는다.

안전을 위해서 외부 API를 만들 때는 반드시 타입을 지정하고, 이렇게 지정한 타입을 특별한 이유와 확실한 확인 없이는 제거하지 말아야 한다.

Item 5. 예외를 활용해 코드에 제한을 걸어라

  • require
  • check

(require 뒤에 check가 나오는 것이 일반적이다)

  • assert : 테스트시에만 확인된다
    • 파이썬에서 많이 사용되고, Java에서는 딱히 사용되지 않는다. 코틀린에서는 코드를 안정적으로 만들고 싶을 때 양념처럼 사용할 수 있다.
  • return과 throw를 활용한 Elvis 연산자는 nullable을 확인할 때 굉장히 많이 사용되는 관용적인 방법이다.

Item 6. 사용자 정의 오류보다는 표준 오류를 사용하라

Item 7. 결과 부족이 발생할 경우 null과 Failure를 사용하라

  • 충분히 예측할 수 있는 범위의 오류는 null과 Failure를 사용하고, 예측하기 어려운 예외적인 범위의 오류는 예외를 throw해서 처리하는 것이 좋다.

Item 8. 적절하게 null을 처리하라

  • 변수를 일단 선언하고, 이후에 사용하기 전에 값을 할당해서 사용하기로 한다면
    • 이후 프로퍼티를 계속 unpack해야 하므로 사용하기 귀찮고
    • 해당 프로퍼티가 실제로 이후에 의미 있는 null 값을 가질 가능성 자체를 차단해버린다

→ 때문에 이런 경우네는 lateinit 또는 Delegates.notNull을 사용해야 한다.

  • !! 연산자가 의미 있는 경우는 굉장히 드물다. 일반적으로 nullability가 제대로 표현되지 않는 라이브러리를 사용할 때 정도에만 사용해야 한다. 코틀린을 대상으로 설계된 API를 활용한다면, !! 연산자를 사용하는 것을 이상하게 생각해야 한다.
    • !! 연산자를 보면 반드시 조심하고, 무언가가 잘못되어 있을 가능성을 생각하자.

lateinit 한정자는 프로퍼티가 이후에 설정될 것임을 명시하는 한정자이다. lateinit을 사용할 경우에도 비용이 발생한다. 만약, 초기화 전에 값을 사용하려고 하면 예외가 발생한다. 처음 사용하기 전에는 반드시 초기화가 되어 있을 경우에만 lateinit을 붙이는 것이다. 만약 그런 값이 사용되어 예외가 발생한다면, 그 사실을 알아야 하므로 예외가 발생하는 것은 오히려 좋은 일이다.

lateinit을 사용할 수 없는 경우도 있다. JVM에서 기본타입과 연결된 타입으로 프로퍼티를 초기화해야 하는 경우이다.

Item 9. use를 사용하여 리소스를 닫아라

  • InputStream / OutputStream
  • java.sql.Connection
  • java.io.Reader
  • java.new.Socket / java.uti.Scanner

모든 리소스는 최종적으로 리소스에 대한 레퍼런스가 없어질 때, 가비지 컬렉터가 처리한다. 하지만 굉장히 느리며 (쉽게 처리되지 않는다) 그동안 리소스를 유지하는 비용이 많이 들어간다. 따라서 더 이상 필요하지 않다면, 명시적으로 close 메소드를 호출해 주는 것이 좋다.

Item 10. 단위 테스트를 작성하라

2장 가독성

Item 11. 가독성을 목표로 설계하라

항상 가독성을 생각하며 코드를 작성해야 한다. 가독성은 사람에 따라 다르게 느낄 수 있다.

  • 숙련된 개발자만을 위한 코드는 좋은 코드가 아니다. 구현 A와 구현 B는 사실 비교조차 할 수 없을 정도로 A가 훨씬 가독성이 좋은 코드이다.
  • 사용 빈도가 적은 관용구는 코드를 복잡하게 만든다. 그리고 그런 관용구들을 한 문장 내부에 조합해서 사용하면 복잡성은 훨씬 빠르게 증가한다.
  • 구현 A는 수정하기 쉽고, 디버깅도 더 간단하다.
  • 익숙하지 않은 구조를 사용하면, 이처럼 잘못된 동작을 코드를 보면서 확인하기 어렵다.

이런 이야기를 let은 절대로 쓰면 안된다로 이해하는 사람들이 꽤 많다. 극단적이면 안된다.

어떤 것이 비용을 지불할 만한 코드인지 아니지는 항상 논란이 있을 수 있다. 균형을 맞추는 것이 중요하다. 일단 어떤 구조들이 어떤 복잡성을 가져오는지 등을 파악하는 것이 좋다. 또한 두 구조를 조합해서 사용하면, 단순하게 개별적인 복잡성의 합보다 훨씬 커진다.

Item 12. 연산자 오버로드를 할 때는 의미에 맞게 사용하라

DSL을 설계할 때는 제외

Item 13. Unit?을 리턴하지 말라

Item 14. 변수 타입이 명확하지 않은 경우 확실하게 지정하라

val data = getSomeData()

위의 코드는 타입을 숨기고 있다. 가독성을 위해 코드를 설계할 때 읽는 사람에게 중요한 정보를 숨겨서는 안 된다. ”코드를 읽으면서 함수 정의를 보며 타입을 확인하면 되지 않나?” 라고 생각할 수도 있지만 이는 곧 가독성이 떨어진다는 의미이다.

Item 15. 리시버를 명시적으로 참조하라

무언가를 더 자세하게 설명하기 위해서, 명시적으로 긴 코드를 사용할 때가 있다. (this 혹은 라벨)

리시버가 명확하지 않다면, 명시적으로 리시버를 적어서 이를 명확하게 해 주어야 한다. 레이블 없이 리시버를 사용하면, 가장 가까운 리시버를 의미한다.

Item 16. 프로퍼티는 동작이 아니라 상태를 나타내야 한다.

  • 파생 프로퍼티 : var을 사용해서 만든 읽고 쓸 수 있는 프로퍼티

프로퍼티를 함수 대신 사용할 수도 있지만, 그렇다고 완전히 대체해서 사용하는 것은 좋지 않다. 원칙적으로 프로퍼티는 상태를 나타내거나 결정하기 위한 목적으로만 사용하는 것이 좋고, 다른 로직 등을 포함하지 않아야 한다.

  • 연산 비용이 높거나, 복잡도가 O(1)보다 큰 경우 : 관습적으로 프로퍼티를 사용할 때 연산 비용이 많이 필요하다고 생각하지 않는다.
  • 비즈니스 로직(애플리케이션의 동작)을 포함하는 경우 : 프로퍼티가 로깅, 리스너 통지, 바인드된 요소 변경과 같은 단순한 동작 이상을 할 거라고 기대하지 않는다.
  • 결정적이지 않은 함수 : 실행마다 다른 값이 나올 때
  • 변환의 경우 : Int.toDouble() 처럼 타입 변환이 이루어지는 경우
  • getter에서 프로퍼티의 상태 변경이 일어나야 하는 경우

Item 17. Named Argument를 사용하라

Item 18. 코딩 컨벤션을 지켜라

3장 재사용성

Item 19. knowledge를 반복하여 사용하지 말라

두 코드가 함께 knowledge를 나타내는지, 다른 knowledge를 나타내는지는 ‘함께 변경될 가능성이 높은가? 따로 변경될 가능성이 높은가?’라는 질문으로 어느 정도 결정할 수 있다. (단일 책임 원칙)

많은 개발자는 Don't Repeat Yourself 라는 문장을 엄격하게 지키려고 비슷해 보이는 코드는 모두 추출하려는 경향이 있습니다. 극단적인 것은 언제나 좋지 않습니다. 항상 균형이 중요합니다. 어떤 것을 추출해야 할지 결정하기 어려울 수 있습니다.

Item 20. 일반적인 알고리즘을 반복해서 구현하지 말라

Item 21. 일반적인 프로퍼티 패턴은 프로퍼티 위임으로 만들어라

val value by lazy { createValue() }

변화가 있을 때 이를 감지하는 observable 패턴을 쉽게 만들 수 있다.

일반적으로 프로퍼티 위임 매커니즘을 활용하면, 다양한 패턴들을 만들 수 있다. (리소스 바인딩 / 의존성 주입 / 데이터 바인딩 등이 있다)

Item 22. 일반적인 알고리즘을 구현할 때 제네릭을 사용하라

구체적인 타입의 서브타입만 사용하게 타입을 제한하는 기능이 타입 파라미터에서는 중요하다

Item 23. 타입 파라미터의 섀도잉을 피하라

프로퍼티와 파라미터가 같은 이름을 가질 수 있다. 이때 지역 파라미터는 외부 scope에 있는 프로퍼티를 가린다. 이를 shadowing이라고 한다.

이런 shadowing 현상은 클래스 타입 파라미터와 함수 타입 파라미터 사이에서도 발생한다

interface Tree
class Birch: Tree
class Spruce: Tree

class Forest<T: Tree> {
  fun <T: Tree> addTree(tree: T) { }
}

Item 24. 제네릭 타입과 variance 한정자를 활용하라

Box<T> 라는 클래스에 대하여,

  • 타입 파라미터 : 기본적으로 불공변성이다 (invariant)
    • Box<Number> 와 Box<Int> 는 호환되지 않는다.
  • out 사용 : Covariant
    • Box<Number> 는 Box<Int> 의 상위 타입으로 인식된다.
  • in 사용 : Contravariant
    • Box<Number> 는 Box<Int> 의 하위 타입으로 인식된다.

코틀린에서 모든 함수 타입은 기본적으로 아래의 variance 한정자를 자동 사용한다.

  • 파라미터 : contravariant (더 높은 타입은 넣을 수 있음)
  • 리턴타입 : covariant (더 낮은 타입이 돌아올 수 있음)

자바의 배열은 covariant 하지만, 코틀린에서는 invariant 하다.

  • 코틀린은 public인 set 위치에 convariant 타입 파라미터가 오는 것을 금지하고 있다.
  • 코틀린은 public인 get 위치에 contravariant 타입 파라미터가 오는 것을 금지하고 있다.

Item 25. 공통 모듈을 추출해서 여러 플랫폼에서 재사용하라

4장 추상화 설계

추상화 : 복잡성을 숨기기 위해 사용되는 단순한 형식

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

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

추상화는 자유를 주지만, 코드를 이해하고 수정하기 어렵게 만든다. 추상화를 이해하려면, 예제를 살펴보는 것이 좋다. 예제 없이 추상화를 설명하면 이해하기도 어렵고, 오해하기도 어렵다.

Item 28. API 안정성을 확인하라

개발자가 안정적인 라이브러리로 업데이트하는 것을 두려워한다는 것은 매우 좋지 않은 상황이다.

Item 29. 외부 API를 wrap해서 사용하라

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

API를 상속할 때 오버라이드 해서 가시성을 제한할 수는 없다. 상속보다 컴포지션을 선호하는 대표적인 이유이다.

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

일반적으로 대부분의 함수와 클래스는 이름만으로 예측할 수 없는 세부 사항들을 갖고 있다.

일반적인 문제는 행위가 문서화되지 않고, 요소의 이름이 명확하지 않다면 이를 사용하는 사용자가 우리가 만들려고 했던 추상화 목표가 아닌, 현재 구현에만 의존하게 된다는 것이다. 이러한 문제는 예상되는 행위를 문서로 설명함으로써 해결된다.

규약을 정의하는 것은 3가지의 대표적인 방법이 있다.

  • 이름
  • 주석과 문서
  • 타입

Item 32. 추상화 규약을 지켜라

5장 객체 생성

Item 33. 생성자 대신 팩토리 함수를 사용하라

  • 함수에 이름을 붙일 수 있다.
  • 함수가 원하는 형태의 타입을 리턴할 수 있다. (구체 클래스 대신 인터페이스 반환이 가능하다)
  • 호출 될 때마다 새 객체를 만들 필요가 없다. 원하는 때에 생성자를 호출할 수 있다.
  • 아직 존재하지 않는 객체를 리턴할 수도 있다. (프록시 등)
  • 가시성을 원하는 대로 제어할 수 있다.
  • inline, reified를 사용할 수도 있고, 생성자로 만들기 복잡한 객체도 만들어 낼 수 있다.

총 5가지 종류가 있다.

  • companion 객체 팩토리 함수
  • 확장 팩토리 함수
  • Top Level 팩토리 함수
  • 가짜 생성자
  • 팩토리 클래스의 메소드

→ 일반적인 방법은 companion 객체를 사용하는 것이다. (companion 객체 또는 Top Level 팩토리 함수를 주로 사용한다.)

Item 34. 기본 생성자에 이름 있는 옵션 argument를 사용하라

코틀린에서는 점층적 생성자 패턴을 사용하지 않는다. 대신 default argument를 활용하는 것이 좋다. default argument는 더 짧고, 더 명확하고, 더 사용하기 쉽다. 빌더 패턴도 마찬가지로 거의 사용하지 않는다. 기본 생성자를 사용하는 코드로 바꾸거나, DSL을 사용하는 것이 좋다.

Item 35. 복잡한 객체를 생성하기 위한 DSL을 정의하라

코틀린 DSL은 type safe 이므로 (그루비 등과 다르게) 여러 가지 유용한 힌트를 활용할 수 있다. 이미 존재하는 코틀린 DSL을 활용하는 것도 좋지만, 사용자 정의 DSL을 만드는 방법도 알아두면 좋다.

단순한 기능까지 DSL을 사용한다는 것은 닭 잡는 데 소 잡는 칼을 쓰는 꼴이다. DSL은 다음과 같은 것을 표현하는 경우에 유용하다.

  • 복잡한 자료구조
  • 계층적인 구조
  • 거대한 양의 데이터

6장 클래스 설계

Item 36. 상속보다는 컴포지션을 사용하라

  • 컴포지션은 더 안전하고, 유연하고 명시적이다.

상속은 is - a 관계일 때 상속을 사용하는 것이 좋다. 슈퍼 클래스를 상속하는 모든 서브클래스는 슈퍼클래스로도 동작할 수 있어야 한다. 슈퍼 클래스의 모든 단위 테스트는 서브 클래스로도 통과할 수 있어야 한다는 의미이다.

Item 37. 데이터 집합 표현에 data 한정자를 사용하라

Item 38. 연산 또는 액션을 전달할 때는 인터페이스 대신 함수 타입을 사용하라

인터페이스를 사용해야 하는 특별한 이유가 없다면, 함수 타입을 활용하는게 좋다. 딱 한 가지 경우에는 SAM을 사용하는 것이 좋다. 코틀린이 아닌 다른 언어에서 사용할 클래스를 설계할 때이다.

Item 39. 태그 클래스보다는 클래스 계층을 사용하라

  • 태그 클래스 : 상수 ‘모드'를 가진 클래스 (이러한 상수 모드를 태그라 부르며, 태그를 포함한 클래스를 태그 클래스라 한다)

Item 40. equals의 규약을 지켜라

Item 41. hashCode의 규약을 지켜라

Item 42. compareTo의 규약을 지켜라

Item 43. API의 필수적이지 않은 부분을 확장 함수로 추출하라

확장은 우리가 직접 멤버를 추가할 수 없는 경우, 데이터와 행위를 분리하도록 설계된 프로젝트에 사용된다.

API의 필수적인 부분은 멤버로 두는 것이 좋지만, 필수적이지 않은 부분은 확장 함수로 만드는 것이 여러모로 좋다.

Item 44. 멤버 확장 함수의 사용을 피하라

어떤 클래스에 대한 확장 함수를 정의할 때, 이를 멤버로 추가하는 것은 좋지 않다.

7장 비용 줄이기

장기적으로 보았을 때 효율성은 중요하다. 하지만 최적화는 쉬운 일이 아니다. 또한 최적화를 초기 단계에서부터 하는 것은 얻는 것보다 잃는 것이 많은 경우가 많다. 가독성과 성능 사이에 Trade-Off가 발생할 때, 개발하는 컴포넌트에서 무엇이 더 중요한지 스스로 답할 수 있어야 한다.

Item 45. 불필요한 객체 생성을 피하라

  • 싱글톤, 캐시를 활용하는 팩토리 함수
  • 무거운 객체를 외부 스코프로 보내기
  • 지연 초기화, 기본 자료형 사용하기

Item 46. 함수 타입 파라미터를 갖는 함수에 inline 한정자를 붙여라

inline 한정자를 사용할 경우의 장점

  1. Type Argument에 reified 한정자를 붙여서 사용할 수 있다.
  2. 함수 타입 파라미터를 가진 함수가 훨씬 빠르게 동작한다.
  3. non-local 리턴을 사용할 수 있다.

reified 한정자를 지정하면, 타입 파라미터를 사용한 부분이 Type argument로 대체된다.

일반적으로 함수 타입의 파라미터가 어떤 식으로 동작하는지 이해하기 어려우므로, 함수 타입 파라미터를 활용해서 유틸리티 함수를 만들 때 (ex. 컬렉션 처리)는 그냥 인라인을 붙여 준다 생각하는 것도 좋습니다.

inline 한정자의 비용

  • inline 함수는 재귀적으로 동작할 수 없다.
  • 인라인 함수는 더 많은 가시성 제한을 가진 요소를 사용할 수 없다.

crossinline과 noinline

  • crossinline : argument로 인라인 함수를 받지만, non-local return을 하는 함수는 받을 수 없게 만든다.
  • noinline : argument로 inline 함수를 받을 수 없게 만든다.

Item 47. 인라인 클래스의 사용을 고려하라

inline class는 다른 자료형을 래핑해서 새로운 자료형을 만들 때 많이 사용된다. 이때 어떠한 오버헤드도 발생하지 않는다

  • 측정 단위를 표현할 때
  • 타입 오용으로 발생하는 문제를 막을 때

인터페이스를 구현하는 인라인 클래스는 아무런 의미가 없다.

인라인 클래스를 사용하면, typealias과 다르게 비용과 안전이라는 두 마리 토끼를 모두 잡을 수 있다.

Item 48. 더 이상 사용하지 않는 객체의 레퍼런스를 제거하라

“더 이상 사용하지 않는 객체의 레퍼런스를 유지하면 안 된다” 라는 규칙 정도는 지켜 주는 것이 좋다.

8장 효율적인 컬렉션 처리

Item 49. 하나 이상의 처리 단계를 가진 경우에는 시퀀스를 사용하라

Iterable과 Sequence는 완전히 다른 목적으로 설계되어서, 완전히 다른 형태로 동작한다. 무엇보다 Sequence는 지연(lazy) 처리된다. 따라서 시퀀스 처리 함수들을 사용하면, 데코레이터 패턴으로 꾸며진 새로운 시퀀스가 리턴된다.

필자의 경험으로는 하나 이상의 처리 단계를 포함하는 컬렉션 처리는 20 ~ 40% 정도의 성능이 향상된다.

컬렉션 전체를 기반으로 처리해야 하는 연산은 시퀀스를 사용해도 빨라지지 않는다. (sorted)

무한 시퀀스에 sorted를 사용할 수 없다는 결함은 따로 기억해야 한다.

  • 자바의 스트림과 코틀린의 시퀀스는 다음과 같은 세 가지 큰 차이점이 있다.
    • 코틀린의 시퀀스가 더 많은 처리 함수를 갖고 있다. 그리고 사용하기 더 쉽다.
    • 자바 스트림은 병렬 함수를 사용해서 병렬 모드로 실행할 수 있다.
    • 코틀린의 시퀀스는 코틀린/JVM, 코틀린/JS, 코틀린/네이티브 등의 일반적인 모듈에서 모두 사용할 수 있다.

(병렬 함수 내부에서 사용하는 common join-fork 스레드 풀과 관련된 이슈가 있다)

Item 50. 컬렉션 처리 단계 수를 제한하라

컬렉션 처리 단계 수를 적절하게 제한하는 것이 좋다. 어떤 메소드를 사용하는지에 따라서 컬렉션 처리의 단계 수가 달라진다.

Item 51. 성능이 중요한 부분에는 기본 자료형 배열을 사용하라

일반적으로 Array보다 List와 Set을 사용하는 것이 좋다. 하지만 기본 자료형의 컬렉션을 굉장히 많이 보유해야 하는 경우에는 성능을 높이고, 메모리 사용량을 줄일 수 있도록 Array를 사용하는 것이 좋다.