Hanbit the Developer

Effective Kotlin | 1장. 안정성 본문

Mobile/Kotlin

Effective Kotlin | 1장. 안정성

hanbikan 2023. 6. 13. 16:17

아이템 1: 가변성을 제한하라

프로퍼티에 가변성이 있을 경우 일관성 문제, 복잡성 증가 문제, 그리고 멀티 쓰레드 문제 등 단점이 많기 때문에 제한해야 한다.

*가변성을 완전히 제거하고자 하는 시도: 함수형 언어

*val은 getter만 제공하므로, val 프로퍼티를 var로 오버라이드 할 수 있다.

val MutableList<T> vs var List<T> - 변이 지점을 어디에 두어야 하는가?

변경가능한 컬렉션을 관찰가능하게 하려면, 읽기 전용 컬렉션에 세터를 다는 것이 쉽다. 게다가 access modifier를 private로 달 수도 있으며 동기화를 구현하기 좋다.

var list: List<Int> = listOf()
	private set(value) { // ... }

변경이 필요한 객체를 만들 때, data class로 만들고 copy()를 활용하는 것이 좋다.

아이템 2: 변수의 스코프를 제한하라

장점은 다음과 같다:

  • 코드를 추적하고 관리하기 쉽다.
  • 이해하기 쉽다.
  • 잘못 사용할 수 있는 여지를 제거한다.

여러 프로퍼티를 한꺼번에 설정해야 하는 경우, 구조분해 선선을 활용한다:

val (desc, color) = when {
	degrees < 5 -> "cold" to Color.BLUE
	else -> "hot" to Color.RED
}

아이템 3: 최대한 플랫폼 타입을 사용하지 말라

플랫폼 타입은 자바 등 다른 언어에서 온 코드여서 nullable인지 확인할 수 없는 타입을 말한다.(!)

val user1 = repo.user // User!
val user2: User = repo.user // User
val user3: User? = repo.user // User?

이는 위험하기 때문에 최대한 줄여야 한다. 자바에서는 @Nullable, @NotNull 등 어노테이션을 붙이면 된다.

아이템 4: inferred 타입으로 리턴하지 말라

interface CarFactory {
	fun produce(): Car
}

위 코드에서 디폴트 자동차를 아래처럼 구현하면 한 유형의 차만을 생산하게 되는 문제가 발생한다.

val DEFAULT_CAR = Fiat126P()

interface CarFactory {
	fun produce() = DEFAULT_CAR
}

즉, 웬만해선 타입을 명시하는 것이 좋다.

아이템 5: 예외를 활용해 코드에 제한을 걸어라

예외에는 다음과 같은 장점이 있다:

  • 문제 확인이 쉽다.
  • 코드가 더 안정적으로 작동한다.(예외 없이 예상치 못한 동작으로 코드가 돌아가고 있다면, 예외가 발생하는 것보다 훨씬 위험한 상태이다.)
  • 자체적으로 테스트하기 때문에 유닛 테스트를 줄일 수 있다.
  • 스마트 캐스트를 활용할 수 있기 때문에, 캐스팅을 더 적게 할 수 있다.

아규먼트

함수 앞 부분에 require() 등 함수를 통해 유효검 검사를 한다.

상태

check() 등 함수의 전제 조건인 상태를 검사한다. 예를 들면:

  • 어떤 객체가 초기화되어 있어야 처리할 수 있는 함수
  • 로그인 했을 때만 처리할 수 있는 함수

Assert 계열 함수 사용

assertEquals()를 함수 내에서 사용한다.

단위 테스트는 여전히 따로 작성해야 하며, 앱 실행 시 assert가 예외를 throw하지 않는다는 점을 명심하라.

사실 이런 코드는 python에서 많이 사용되며, 코틀린에서는 코드를 안정적으로 만들고 싶을 때 양념처럼 사용한다.

nullability와 스마트 캐스팅

require(email ≠ null), requireNotNull(email), checkNotNull(email) 등을 사용하면 스마트캐스팅 되어 캐스팅 코드를 줄일 수 있다.

또는 person.email = email ?: run { log("~"); return } 식으로 Elvis 연산자를 이용할 수도 있다.

아이템 6: 사용자 정의 오류보다는 표준 오류를 사용하라

적절한 오류가 없을 때는 만들어 쓰면 되지만, 최대한 널리 알려져 있는 표준 오류를 사용한다.

일반적으로 쓰이는 예외들은 다음과 같다:

  • IlligalArgumentException, IllegalStateException
  • IndexOutOfBoundsException
  • ConcurrentModificationException: 동시 수정을 금지했으나 발생했다는 것을 나타낸다.
  • UnsupportedOperationException: 사용한 메서드가 현제 객체에서 사용할 수 없다는 것을 나타낸다.
  • NoSuchElementException: 사용하려고 했던 요소가 존재하지 않음을 나타낸다. 예를 들어 빈 Iterable에서 next()를 호출할 때 발생한다.

아이템 7: 결과 부족이 발생할 경우 null과 Failure를 사용하라

서버에서 데이터를 읽으려고 했으나 오프라인이어서 읽을 수 없는 경우, 함수가 결과를 만들어 낼 수 없다. 이러한 경우를 처리하기 위한 메커니즘은 다음과 같다:

  • null 또는 실패를 나타내는 sealed 클래스를 반환한다.
  • 예외를 throw한다.

이때 예외는 잘못된 특별한 상황을 나타낼 때 쓰여야 한다. 그 이유는 다음과 같다:

  • 많은 개발자가 예외가 전파되는 과정을 제대로 추적하지 못한다.
  • 코틀린의 모든 예외는 unchecked여서 사용자가 처리를 하지 않을 수 있다.
  • 빠르지 않다.
  • try-catch 내부에 배치하면 컴파일러의 최적화가 제한된다.

따라서 충분히 예측할 수 있는 범위의 오류는 null, Failure를 사용함으로써, 애플리케이션을 중지하지 않으면서도 명시적이고 효율적이며 간단하게 처리하라. 반면 예측하기 어려운 예외적인 범위의 오류는 예외를 throw하라.

추가로, 오류 발생 시 추가적인 정보를 전달해야 한다면 sealed 클래스를 사용하는 것이 일반적이다.

*개발자에게 null이 반환될 수 있다는 경고를 주기 위해, 함수 이름 뒤에 OrNull을 붙이는 것이 좋다.

아이템 8: 적절하게 null을 처리하라

null을 안전하게 처리하기

  • 안전 호출: printer?.print()
  • 스마트 캐스팅: null이 아닐 때만 처리
  • Elvis 연산자: null이 아닌 경우 디폴트 값을 주거나 함수에서 나가거나 예외를 던질 수 있음
💡 방어적 프로그래밍: 모든 가능성을 처리하는 것 공격적 프로그래밍: 모든 상황을 안전하게 처리하는 것이 불가능하여 그렇게 하지 않는 경우

오류 throw하기

 printer?.print()

위 코드에서 printer가 null인 경우, 자세한 상황을 모르는 개발자는 왜 출력이 되지 않는지 헷갈려할 수 있다. 이러한 경우 오류를 강제로 발생하는 것이 좋다. throw, !!, requireNotNull, cehckNotNull 등을 활용한다.

not-null assertion(!!)과 관련된 문제

!!는 완전히 null이 아닌 경우에만 사용해야 한다. 이를 남용하게 되면 NPE로 이어지게 된다. 일반적으로 !! 연산자 사용을 피해야 한다.

미래에 코드가 어떻게 변할지 모른다는 점을 명심하라.

의미 없는 nullability 피하기

이를 피할 때 사용할 수 있는 몇 가지 방법은 다음과 같다:

  • get(), getOrNull() 같이 함수를 나눈다.
  • 어떤 값이 확실하게 초기화 될 예정이라면 lateinit, notNull 델리게이트를 사용한다.
  • null 대신 빈 컬렉션을 리턴하라.
  • nullable enum, None enum 값은 완전히 다른 의미이다.

lateinit 프로퍼티와 notNull 델리게이트

lateinit은 처음 사용하기 전에 초기화가 되어 있을 경우에만 사용한다. 그렇지 않은 경우 예외가 발생하는데, 이 경우 오류를 알려주게 되는 것이므로 오히려 좋은 일이다.

nullable과의 차이점은 다음과 같다:

  • !! 연산자로 언팩하지 않아도 된다.
  • 이후 어떤 의미를 나타내기 위해 null 값을 넣을 수 있다.
  • 초기화된 이후에는 초기화되지 않은 상태로 돌아갈 수 없다.(참고: isInitialized)

primitive의 경우 lateinit이 불가능하며, 이 경우 lateinit보다는 약간 느리지만, Delegates.notNull을 사용한다: private var doctorId: Int by Delegates.notNull()

아이템 9: use를 사용하여 리소스를 닫아라

BufferReader 등에서 close()로 닫을 경우, 코드가 길어지고 예외 처리가 더러워진다. 대신, Closeable 객체의 use() 함수를 사용한다.

val reader = BufferedReader(FileReader(path))
reader.use {
	return reader.lineSequence().sumBy { it. length }
}

추가로 파일을 한 줄씩 읽어 들이는 경우와 같은 상황이 많이 때문에, useLines() 함수도 제공된다.

아이템 10: 단위 테스트를 만들어라

단위테스트는 일반적으로 다음과 같은 내용을 확인한다:

  • 유즈 케이스
  • 일반적인 오류 케이스와 잠재적인 문제
  • 엣지 케이스와 잘못된 인자

장점은 다음과 같다:

  • 코드 신뢰성
  • 리팩토링이 두렵지 않아진다. 테스트를 통해 버그 테스트를 쉽게 할 수 있기 때문이다.
  • 수동 테스트보다 빠르다.

단점은 다음과 같다:

  • 시간이 소요된다.(다만 장기적으로 보면 결국 시간이 절약된다.)
  • 테스터블한 코드를 작성해야 한다.
  • 좋은 테스트를 만드는 것이 어렵다.

다음과 같은 핵심 부분에 대해새 단위 테스트를 만들 수 있어야 한다:

  • 복잡한 부분
  • 수정이 빈번하거나 리팩토링이 일어날 수 있는 부분
  • 비즈니스 로직
  • 공용 API
  • 문제가 자주 발생하는 부분
  • 수정해야 할, 프로덕션 환경에서 발생하는 버그