Skip to content

Latest commit

 

History

History
423 lines (299 loc) · 21.2 KB

태희.md

File metadata and controls

423 lines (299 loc) · 21.2 KB

1-6 ~ 1-10

6. Prefer standard errors to custom ones

사용자 정의 오류보다는 표준 오류를 선호하라

require, check, assert 함수들은 가장 흔한 코틀린 오류들을 다루지만, 그 외에도 예상치 못한 상황을 표시해야 할 경우가 있다.

예를 들어, JSON 포맷을 파싱하는 라이브러리를 구현할 때, 제공된 JSON 파일의 형식이 올바르지 않은 경우에는 JsonParsingException을 던지는 것이 합리적이다.

inline fun <reified T> String.readObject(): T {
    // ...
    if (incorrectSign) {
        throw JsonParsingException()
    }
    // ...
    return result
}

표준 라이브러리에 이 상황을 나타낼 적절한 오류가 없기 때문에 사용자 정의 오류를 사용하였다.

가능한 표준 라이브러리에서 제공하는 예외를 사용하고, 사용자 정의 예외는 정의하지 않는 것이 좋다.

표준 라이브러리는

  • 개발자에게 잘 알려져 있으며,
  • 재사용될 수 있다.
  • API를 배우고 이해하기 쉽게 만들 수 있다.

흔히 사용하는 예외들

  • IllegalArgumentExceptionIllegalStateException: 이는 requirecheck를 사용하여 던질 수 있다.
  • IndexOutOfBoundsException: 인덱스 매개변수 값이 범위를 벗어났음을 나타낸다. 주로 컬렉션과 배열에서 사용되며, 예를 들어 ArrayList.get(Int)에서 던져진다.
  • ConcurrentModificationException: 동시 수정이 금지되어 있음에도 불구하고 탐지된 경우를 나타낸다.
  • UnsupportedOperationException: 해당 객체가 선언된 메서드를 지원하지 않음을 나타낸다. 이러한 상황은 피해야 하며, 메서드가 지원되지 않는 경우 해당 메서드는 클래스에 존재해서는 안 된다.
  • NoSuchElementException: 요청된 요소가 존재하지 않음을 나타낸다. 예를 들어, Iterable을 구현할 때 더 이상 요소가 없을 때 클라이언트가 next를 요청하는 경우 사용된다.

7. Prefer null or Failure result when the lack of result is possible

결과가 없을 가능성이 있을 때는 null 또는 Failure를 선호하라

때로는 함수가 원하는 결과를 생성할 수 없는 경우가 있다.

  • 서버에서 데이터를 가져오려고 하지만, 인터넷 연결에 문제가 있는 경우
  • 특정 기준에 맞는 첫 번째 요소를 가져오려고 하지만, 리스트에 그런 요소가 없는 경우
  • 텍스트에서 객체를 파싱하려고 하지만, 해당 텍스트가 잘못된 형식인 경우

이러한 상황을 처리하는 주요 메커니즘은 두 가지이다.

  • 실패를 나타내는 null 또는 sealed 클래스(Failure라고 자주 명명됨)를 반환하기
  • 예외를 던지기

모든 예외는 잘못된, 특별한 상황을 나타내며 그렇게 취급되어야 한다.

예외는 오직 예외적인 상황에서만 사용해야 한다.

  • 예외가 전파되는 방식은 대부분의 프로그래머에게 덜 읽기 쉽고, 코드에서 쉽게 놓칠 수 있다.
  • 코틀린에서 모든 예외는 체크되지 않은 예외이다. 사용자들은 이를 처리하도록 강제되거나 권장되지 않으며, 종종 잘 문서화되지 않는다. API를 사용할 때도 정말로 눈에 띄지 않다.
  • 예외는 예외적인 상황을 위해 설계되었기 때문에, JVM 구현자들이 이를 명시적인 테스트만큼 빠르게 만들 동기가 적다.
  • try-catch 블록 내부에 코드를 배치하면 컴파일러가 수행할 수 있는 특정 최적화가 방해될 수 있다.

반면에, null이나 Failure는 예상된 오류를 나타내기에 완벽하다.

이들은 명시적이고 효율적이며, 코틀린의 관용적인 방식으로 처리할 수 있다. 그렇기 때문에 오류가 예상될 때는 null이나 Failure를 반환하고, 오류가 예상되지 않을 때는 예외를 던져야 한다는 규칙이 있다.

inline fun <reified T> String.readObjectOrNull(): T? {
    //...
    if(incorrectSign) {
        return null
    }
    //...
    return result
}

inline fun <reified T> String.readObject(): Result<T> {
    //...
    if(incorrectSign) {
        return Failure(JsonParsingException())
    }
    //...
    return Success(result)
}

sealed class Result<out T>
class Success<out T>(val result: T): Result<T>()
class Failure(val throwable: Throwable): Result<Nothing>()

class JsonParsingException: Exception()
val age = userText.readObjectOrNull<Person>()?.age ?: -1

위와 같은 방식으로 오류를 처리하면 쉬우며 놓치기 어렵다. 세이프 콜이나 엘비스 연산자도 사용가능하다.

// 오타 아닌가? 
val age = when(val person = userText.readObjectOrNull<Person>()) {
    is Success -> person.age
    is Failure -> -1
}

When 표현식 사용 가능

이러한 오류 처리는 try-catch 블록보다 더 효율적일 뿐만 아니라, 종종 사용하기도 쉽고 더 명시적이다.

예외는 놓칠 수 있으며, 애플리케이션 전체를 멈출 수 있다.

반면에 null 값이나 sealed 결과 클래스는 명시적으로 처리해야 하지만, 애플리케이션의 흐름을 방해하지 않는다.

nullable 결과와 sealed 결과 클래스를 비교할 때, 실패 시 추가 정보를 전달해야 할 경우에는 후자를 선호하고, 그렇지 않으면 null을 사용해야 한다.

Failure에는 필요한 모든 데이터를 담을 수 있다는 점을 기억하자

실패가 발생할 수 있음을 예상하는 함수와 실패를 예기치 않은 상황으로 처리하는 함수의 두 가지 변형을 가지는 것이 일반적이다. 좋은 예로는 List가 다음 두 가지를 모두 제공하는 경우가 있다:

  • get: 해당 위치에 요소가 있을 것으로 기대할 때 사용하며, 요소가 없을 경우 IndexOutOfBoundsException을 던진다.
  • getOrNull: 범위를 벗어난 요소를 요청할 수 있을 것으로 예상될 때 사용하며, 그럴 경우 null을 반환한다.

개발자가 안전하게 요소를 가져온다고 알고 있을 때는 nullable 값을 처리할 필요가 없어야 하며, 동시에 약간의 의심이 있다면 getOrNull을 사용하고 값이 없는 경우를 적절히 처리해야 한다.

8. Handle nulls properly

null을 적절하게 처리하라

null은 값이 없음을 의미한다. 프로퍼티의 경우, 이는 값이 설정되지 않았거나 제거되었음을 나타낼 수 있다. 함수가 null을 반환하는 경우, 그 의미는 함수에 따라 다를 수 있다

  • String.toIntOrNull()은 문자열을 Int로 올바르게 파싱할 수 없을 때 null을 반환한다.
  • Iterable<T>.firstOrNull(() -> Boolean)은 인자로 제공된 조건에 맞는 요소가 없을 때 null을 반환한다.

이와 같은 모든 경우에 null의 의미는 가능한 한 명확해야 한다. 이는 nullable 값이 반드시 처리되어야 하며, 이를 어떻게 처리할지는 API 사용자(즉, API 요소를 사용하는 프로그래머)가 결정해야 하기 때문이다.

val printer: Printer? = getPrinter()
printer.print() // 컴파일 오류: printer는 nullable이므로 직접 사용할 수 없습니다.

printer?.print() // 안전 호출, printer가 null이 아닌 경우에만 사용됨
if (printer != null) printer.print() // 스마트 캐스팅
printer!!.print() // Not-null 단언, printer가 null일 때 NPE를 던짐

일반적으로 nullable 타입을 처리하는 세 가지 방법이 있다:

  • 안전 호출 ?., 스마트 캐스팅, 엘비스 연산자 ?: 등을 사용해 null 가능성을 안전하게 처리하기
  • 오류를 던지기
  • 이 함수나 프로퍼티를 리팩토링하여 nullable하지 않도록 만들기

null을 안전하게 처리하기

세이프 콜과 스마트 캐스팅을 사용하는 것

printer?.print() // 안전 호출
if (printer != null) printer.print() // 스마트 캐스팅

위 두 경우 모두 print 함수는 printer가 null이 아닐 때만 호출된다. 따라서 가장 안전하다.

엘비스 연산자로 nullable 타입에 기본값을 제공하기

val printerName1 = printer?.name ?: "Unnamed"
val printerName2 = printer?.name ?: return
val printerName3 = printer?.name ?: throw Error("Printer must be named")
  • return 과 throw 둘 다 사용 가능

스마트 캐스팅은 코틀린의 계약 기능에 의해 지원되며, 이 기능은 함수 내에서 스마트 캐스팅을 가능하게 한다:

println("What is your name?")
val name = readLine()
if (!name.isNullOrBlank()) {
    println("Hello ${name.toUpperCase()}")
}

val news: List<News>? = getNews()
if (!news.isNullOrEmpty()) {
    news.forEach { notifyUser(it) }
}

방어적 프로그래밍과 공격적 프로그래밍

방어적 프로그래밍

  • null일 때 사용하지 않는 것
  • 코드가 프로덕션 환경에서 더 안정적이게끔 함
  • 현재로서는 불가능한 상황에 대해 방어하는 관행들을 포함
  • 가능한 모든 상황을 올바르게 처리할 방법이 있을 때 최고다.

공격적 프로그래밍

  • 예상치 못한 상황이 발생하면 이를 크게 알리고,
  • 이러한 상황을 초래한 개발자에게 이를 수정하도록 강제하는 것
  • require, check, assert

Throw an error

특정 상황에 대해 강한 기대가 있으며, 그 기대가 충족되지 않으면 예외를 던져 예상치 못한 상황에 대해 프로그래머에게 알리는 것이 좋다.

fun process(user: User) {
    requireNotNull(user.name)
    val context = checkNotNull(context)
    val networkService = getNetworkService(context) ?: throw NoInternetConnection()
    networkService.getData { data, userData ->
        show(data!!, userData!!)
    }
}

오류를 던지는 함수들을 사용해야 할 수도 있다.

not-null assertion의 문제점

  • 자바에서 발생하는 일과 유사하다
  • null이 아니라고 생각했지만 잘못된 경우 NPE가 발생
  • 아무것도 설명하지 않는 일반적인 예외를 던진다.
fun largestOf(a: Int, b: Int, c: Int, d: Int): Int = listOf(a, b, c, d).max()!!

리스트가 비어있지 않을거라고 생각한 사람은 !!을 쓸 수 있음

하지만 리스트가 비어있다면? max가 null을 반환함

class UserControllerTest {

    private var dao: UserDao? = null
    private var controller: UserController? = null

    @BeforeEach
    fun init() {
        dao = mockk()
        controller = UserController(dao!!)
    }

    @Test
    fun test() {
        controller!!.doSomething()
    }

}

이 경우, 매번 이러한 프로퍼티를 언팩킹해야 하는 것은 번거로울 뿐만 아니라, 향후 해당 프로퍼티에 의미 있는 null을 실제로 할당할 수 있는 가능성을 차단하게 된다. 이 아이템의 뒷부분에서 볼 수 있듯이, 이러한 상황을 처리하는 올바른 방법은 lateinit이나 Delegates.notNull을 사용하는 것이다.

코드가 어떻게 발전할지는 아무도 예측할 수 없다. 만약 not-null !!이나 명시적인 오류 던지기를 사용한다면, 언젠가 오류가 발생할 것을 가정해야 한다. 예외는 예상치 못한 잘못된 상황을 나타내기 위해 던져진다( Item 7: 결과가 없을 가능성이 있을 때는 null 또는 Failure를 선호하라 참조). 하지만 명시적인 오류는 일반적인 NPE보다 훨씬 많은 정보를 제공하며, 거의 항상 선호되어야 한다.

not-null !!이 합리적인 드문 경우는 null 가능성이 제대로 표현되지 않은 라이브러리와의 상호 운용성 문제로 발생한다. 코틀린에 맞게 제대로 설계된 API와 상호 작용할 때, 이 방법은 표준이 되어서는 안 된다.

일반적으로 not-null !! 사용을 피해야 합니다. 이 제안은 우리 커뮤니티에서도 상당히 널리 받아들여지고 있다. 많은 팀이 이를 차단하는 정책을 가지고 있으며, 일부는 Detekt 정적 분석기를 설정해 사용될 때마다 오류를 발생시키기도 한다. 이러한 접근이 너무 극단적이라고 생각할 수 있지만, 일반적으로 이는 코드 냄새로 간주된다. 이 연산자가 생긴 모양 자체도 우연이 아닌 것 같습니다. !!은 "조심하라" 또는 "여기 뭔가 문제가 있다"라고 외치는 것처럼 보인다.

의미 없는 null 가능성을 피하라

null 가능성은 비용이 따르므로 적절하게 처리해야 하며, 필요하지 않은 경우 null 가능성을 피하는 것이 좋다. null은 중요한 메시지를 전달할 수 있지만, 다른 개발자에게 의미 없는 상황에서는 null 사용을 피해야 한다. 그렇지 않으면 개발자는 안전하지 않은 not-null !!을 사용하거나 코드만 어수선해지는 일반적인 안전 처리를 반복하게 될 수 있다. 클라이언트에게 의미가 없는 null 가능성을 피하는 것이 좋다. 이를 위한 가장 중요한 방법은 다음과 같다:

  • 클래스는 결과가 예상되는 함수와 값이 없음을 고려하여 nullable 결과나 sealed 결과 클래스를 반환하는 함수를 제공할 수 있다. 간단한 예는 List<T>getgetOrNull이다. 이러한 내용은 Item 7: 결과가 없을 가능성이 있을 때는 null 또는 Failure를 선호하라에서 자세히 설명되어 있다.
  • 값이 확실히 사용 전에 설정되지만 클래스 생성 시점 이후에 설정되어야 할 경우, lateinit 프로퍼티나 notNull 대리자를 사용하자.
  • 빈 컬렉션 대신 null을 반환하지 말자. 예를 들어, List<Int>? 또는 Set<String>? 같은 컬렉션을 다룰 때, null은 빈 컬렉션과는 다른 의미를 가진다. 이는 컬렉션이 존재하지 않음을 의미합니다. 요소가 없음을 나타내려면 빈 컬렉션을 사용하자
  • Nullable 열거형과 None 열거형 값은 두 가지 다른 메시지이다. null은 별도로 처리해야 하는 특별한 메시지이지만, 열거형 정의에 포함되지 않으므로 필요한 경우 열거형에 추가할 수 있다.

lateinit property and notNull delegate

class UserControllerTest {
    private lateinit var dao: UserDao
    private lateinit var controller: UserController

    @BeforeEach
    fun init() {
        dao = mockk()
        controller = UserController(dao)
    }

    @Test
    fun test() {
        controller.doSomething()
    }
}

lateinit의 비용은 초기화되지 않은 상태에서 값을 얻으려고 하면 예외가 발생한다는 점다. 이는 무섭게 들리지만 실제로는 바람직한 것다. 첫 번째 사용 전에 프로퍼티가 초기화될 것이라는 확신이 있을 때만 lateinit을 사용해야 한다.

lateinit과 nullable 사이의 주요 차이점은 다음과 같다:

  • nullable로 매번 프로퍼티를 "언팩"할 필요가 없다.
  • null을 사용하여 의미 있는 것을 나타내야 할 경우, 이를 쉽게 nullable로 만들 수 있다.
  • 일단 프로퍼티가 초기화되면 다시 초기화되지 않은 상태로 돌아갈 수 없다.

lateinit은 프로퍼티가 첫 번째 사용 전에 초기화될 것이라고 확신할 때 좋은 관행입니다. 우리는 주로 클래스에 라이프사이클이 있고, 속성을 나중에 사용할 첫 번째 메서드 중 하나에서 설정할 때 이러한 상황을 다룹니다. 예를 들어, Android ActivityonCreate, iOS UIViewControllerviewDidAppear, 또는 React ComponentcomponentDidMount에서 객체를 설정할 때가 있다.

lateinit 사용하지 못할 때 Delegates.notNull

lateinit을 사용할 수 없는 경우는, JVM에서 Int, Long, Double, Boolean과 같은 프리미티브와 연결된 타입으로 프로퍼티를 초기화해야 할 때이다. 이러한 경우, 조금 더 느리지만 해당 타입을 지원하는 Delegates.notNull을 사용해야 한다

class DoctorActivity: Activity() {
    private var doctorId: Int by Delegates.notNull()
    private var fromNotification: Boolean by Delegates.notNull()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        doctorId = intent.extras.getInt(DOCTOR_ID_ARG)
        fromNotification = intent.extras.getBoolean(FROM_NOTIFICATION_ARG)
    }
}

9. Close resources with use

리소스를 use로 닫아라

자동으로 닫을 수 없는 리소스가 있으며, 더 이상 필요하지 않을 때 close 메서드를 호출해야 힌다. 코틀린/JVM에서 사용하는 자바 표준 라이브러리에는 이러한 리소스가 많이 포함되어 있다.

예를 들어:

  • InputStreamOutputStream
  • java.sql.Connection
  • java.io.Reader (예: FileReader, BufferedReader, CSSParser)
  • java.net.Socketjava.util.Scanner

이 모든 리소스는 Closeable 인터페이스를 구현하며, 이는 AutoCloseable을 확장한다.

문제는 이러한 모든 경우에 리소스를 더 이상 필요로 하지 않을 때 close 메서드를 호출해야 한다는 점이다.

이러한 리소스는 상당히 비용이 많이 들며, 스스로 쉽게 닫히지 않기 때문이다(가비지 컬렉터가 이 리소스에 대한 참조를 더 이상 유지하지 않으면 결국 이를 처리하지만, 시간이 걸릴 수 있다).

따라서 리소스를 닫지 못하는 상황을 피하기 위해, 전통적으로 이러한 리소스를 try-finally 블록에 감싸고 close를 호출했다:

fun countCharactersInFile(path: String): Int {
    val reader = BufferedReader(FileReader(path))
    try {
        return reader.lineSequence().sumBy { it.length }
    } finally {
        reader.close()
    }
}

이러한 구조는 복잡하고 잘못된 방식입니다. 잘못된 이유는 close가 오류를 던질 수 있으며, 이러한 오류가 잡히지 않기 때문이다. 또한, try 블록의 본문과 finally 블록에서 모두 오류가 발생할 경우, 하나만 제대로 전파된다. 우리가 기대해야 할 동작은 새로운 오류에 대한 정보가 이전 오류에 추가되는 것이다.

fun countCharactersInFile(path: String): Int {
    val reader = BufferedReader(FileReader(path))
    reader.use {
        return reader.lineSequence().sumBy { it.length }
    }
}
  • 가독성을 향상시키며 리소스를 올바르게 닫고 예외를 처리할 수 있다.
fun countCharactersInFile(path: String): Int {
    File(path).useLines { lines ->
        return lines.sumBy { it.length }
    }
}
  • useLines로 파일을 줄 단위로 읽기 가능

10. Write unit tests

단위 테스트를 작성하라

@Test
fun `fib works correctly for the first 5 positions`() {
    assertEquals(1, fib(0))
    assertEquals(1, fib(1))
    assertEquals(2, fib(2))
    assertEquals(3, fib(3))
    assertEquals(5, fib(4))
}

위 테스트에서는 일반적으로 다음을 확인한다:

  • 일반적인 사용 사례 (행복 경로): 요소가 사용될 것으로 예상되는 일반적인 방법. 위 예제처럼, 작은 숫자 몇 개에 대해 함수가 제대로 작동하는지 테스트한다.
  • 일반적인 오류 사례 또는 잠재적 문제: 제대로 작동하지 않을 가능성이 있거나, 과거에 문제가 되었던 사례.
  • 경계 사례와 잘못된 인자: Int의 경우 Int.MAX_VALUE와 같은 매우 큰 숫자를 테스트할 수 있다. nullable 객체의 경우, null이거나 null 값으로 채워진 객체일 수 있다. 음수 위치에는 피보나치 수가 없기 때문에 이 함수가 이러한 상황에서 어떻게 동작하는지 확인할 수 있다.

테스트는 계속해서 축적되므로, 회귀를 쉽게 확인할 수 있다.

Unit Test로 얻을 수 있는 장점

  • 잘 테스트된 요소는 더 신뢰할 수 있으며, 심리적인 안정감을 제공한다. 요소가 잘 테스트된 경우, 더 자신 있게 작업할 수 있다.
  • 요소가 제대로 테스트되면 리팩토링에 대한 두려움이 줄어든다. 결과적으로 잘 테스트된 프로그램은 점점 더 나아지는 경향이 있다. 반면, 테스트되지 않은 프로그램에서는 개발자가 레거시 코드를 건드리는 것을 두려워할 수 있다. 이는 무의식적으로 오류를 도입할 수 있기 때문이다.
  • 단위 테스트를 사용하는 것이 수동으로 테스트하는 것보다 올바르게 작동하는지 확인하는 데 훨씬 더 빠른 경우가 많다. 버그를 빨리 찾을수록, 수정 비용이 저렴해집니다.

Unit Test의 단점

  • 단위 테스트를 작성하는 데 시간이 걸리지만 장기적으로는 좋은 단위 테스트가 시간을 절약해준다. 단위 테스트를 실행하는 것이 수동 테스트나 다른 종류의 자동화 테스트보다 훨씬 빠ㄹ르다.
  • 코드를 테스트 가능하게 만들기 위해 코드를 조정해야 한다. 이러한 변경은 어렵지만, 일반적으로 개발자가 좋은 아키텍처를 사용하도록 강제한다.
  • 좋은 단위 테스트를 작성하는 것은 어렵다. 이는 개발의 다른 부분과는 다른 기술과 이해를 필요로 한다. 잘못 작성된 단위 테스트는 오히려 해로울 수 있다.