Developing Myself Everyday

1. 1장을 시작하며


사람들이 코틀린을 사용하는 가장 큰 이유는 바로 코틀린의 안정성(Safey)입니다.

 

코틀린은 다양한 설계지원을 통해서 애플리케이션의 잠재적인 오류를 줄여줍니다. 다만, 코틀린을 안전하게 사용하려면 개발자가 뒷받침을 해야 합니다.

 

그렇기 때문에 이번장에서는 코틀린이 안전을 위해 사용하는 기능들을 알아보고, 이를 올바르게 사용하는 방법을 알아볼 것입니다. 

 

이번장의 목표는 `오류가 덜 발생하는 코드를 만드는 것`입니다.

 

 

 

 

2. 아이템 1 - "가변성을 제한하라"


2.1. 상태(state)

var을 사용하거나 mutable 객체를 사용하면 값이 변동될 여지가 생기게 됩니다. 이를 상태(state)라고 합니다.

 

상태를 가지게 되는 경우, 이제는 객체의 사용 방법뿐만이 아니라 객체의 상태에 대한 이력(history)에 의존하게 됩니다.

 

그렇기에 코틀린에서는 주로 3가지 방법을 사용해서 가변성을 제한합니다.

  • val (읽기 전용 프로퍼티)
  • 가변 컬렉션과 읽기 전용 컬렉션 구분하기
  • 데이터 클래스의 copy

 

 

2.2. val

val을 사용해 읽기 전용 프로퍼티를 만들 수 있습니다. 다만 이러한 val의 값이 항상 변하지 않는다는 말은 아닙니다.

 

val이 mutable 하거나 getter를 정의해서 다른 변경 가능한 프로퍼티를 참조한다면, val 값도 변할 수 있습니다. 아래의 예시에서 person은 변할 수 있습니다.

<kotlin />
val name: String = "준형" var age: Int = 25 val person get() = name to age

 

val의 값은 변경될 수 있지만, 프로퍼티 참조 자체를 변경할 수는 없으므로 동기화 문제를 줄일 수 있습니다.

 

 

2.3. 가변 컬렉션과 읽기 전용 컬렉션 구분하기

코틀린의 컬렉션은 변경할 수 있느냐에 따라 구분됩니다. 읽고 쓸 수 있는 컬렉션에는 `Mutable`이 붙습니다.

 

 `Mutable`이 붙는 인터페이스들은 각각의 컬렉션을 상속받아서 읽고 쓰기 위한 메서드를 추가한 것입니다.

 

그러니 Immutable 컬렉션이 Mutable 컬렉션이 되는 다운캐스팅(DownCasting)은 코틀린에서 절대로 일어나서는 안 되는 일입니다. 

 

메서드에서 Immutable 컬렉션을 반환했다면, 이를 읽기 전용으로만 사용해야 합니다. 만약 Mutable 하게 변경해야 한다면, Copy를 통해 새로운 Mutable 컬렉션을 만들어야 합니다.

 

`toMutableList()`는 안전하게 새로운 Mutable 컬렉션을 반환합니다.

<code />
public fun <T> Collection<T>.toMutableList(): MutableList<T> { return ArrayList(this) }

 

또한 가변 컬렉션을 var으로 선언하는 것은 가장 최악의 방식입니다.

 

 

2.4. 변경 가능 지점 노출 금지

객체를 변경할 수 있는 메서드나 지점은 외부에 노출해서는 안됩니다.

 

 

 

3. 아이템 2 - "변수의 스코프를 최소화하라"


상태를 정의할 때에는 최대한 좁은 스코프를 갖게 정의하는 것이 좋습니다.


스코프라는 것은 요소를 볼 수 있는 컴퓨터 프로그램 영역입니다. 코틀린에서 스코프는 기본적으로 중괄호로 만들어지며, 내부 스코프에서 외부 스코프에 있는 요소에만 접근할 수 있습니다.

 

그 이유는 프로그램을 추적하고 관리하기 쉽기 때문입니다. 요소가 많아져서 변경될 수 있는 부분이 많아지면, 프로그램을 이해하기가 어려워집니다.

 

 

 

 

4. 아이템 3 - "최대한 플랫폼 타입을 사용하지 말라"


다른 프로그래밍 언어에서 와서 nullable 여부를 알 수 없는 타입을 플랫폼 타입이라고 합니다.

 

예를 자바로 들어보면, 자바는 기본적으로 모든 것이 nullable합니다. 그렇기 때문에 자바의 객체를 가져올 때, 해당 객체가 nullable 하지 않다는 것을 보장할 수가 없게 됩니다.

 

그렇기 때문에 플랫폼 타입을 사용한다면 많은 위험을 야기할 수 있습니다.

 

 

 

 

5. 아이템 4 - "inferred 타입으로 리턴하지 말라"


타입 추론을 사용할 때는 몇 가지 위험한 부분들이 있습니다. 이러한 위험을 피하기 위해서는 정확하게 오른쪽에 있는 피 연산자에 맞게 타입이 설정되어야 합니다.

 

타입을 확실하게 지정해야 하는 경우에는 명시적으로 타입을 지정해야 합니다.

 

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

 

 

 

 

6. 아이템 5 - "예외를 활용해 코드에 제한을 걸어라"


코틀린에서는 동작에 제한을 걸 때 다음과 같은 방법을 사용할 수 있습니다.

  • require: 아규먼트를 제한할 수 있습니다.
  • check: 상태와 관련된 동작을 제한할 수 있습니다.
  • assert: 어떤 것이 true인지 확인할 수 있습니다. assert 블록은 테스트에 사용할 수 있습니다.
  • throw e: 직접 예외를 던질 수 있습니다.

 

이런 식으로 예외를 통한 제한을 걸게 되면 다음과 같은 장점이 발생합니다.

  • 문서를 읽지 않아도 문제를 확인할 수 있습니다.
  • 문제가 있을 경우에 예상되지 않은 동작을 하지 않고 예외를 던집니다.
  • 코드를 자체적으로 검사할 수 있어, 단위 테스트의 양을 줄일 수 있습니다.
  • 스마트 캐스트 기능을 활용할 수 있습니다.
스마트 캐스트
스마트 캐스트는 해당 변수의 타입 검사가 이미 이루어 졌을 때, 변수의 타입을 자동으로 변경해 주는 기능을 말합니다.
아래의 같은 경우가 예외를 통해 스마트 캐스팅을 사용한 경우입니다.

fun antToInt(a: Any): Int {
    require(a is String)
    require(a.toIntOrNull() != null)
    return a.toInt()
}

 

 

6.1. 아규먼트(argument)

함수를 정의할 때 아규먼트에 제한을 거는 코드를 많이 사용합니다. 이러한 제한을 걸 때에는 `require` 함수를 사용합니다.

<kotlin />
@kotlin.internal.InlineOnly public inline fun require(value: Boolean, lazyMessage: () -> Any): Unit { contract { returns() implies value } if (!value) { val message = lazyMessage() throw IllegalArgumentException(message.toString()) } }

 

require 함수는 Boolean 값으로 해당 제한을 판별하고 false일 경우에는 `IllegalArgumentException`에 메시지를 담아서 예외를 던집니다.

 

메시지를 매개변수로 전달하지 않는 경우에는 아래와 같이, 기본 메시지로 `require`를 실행합니다.

<code />
@kotlin.internal.InlineOnly public inline fun require(value: Boolean): Unit { contract { returns() implies value } require(value) { "Failed requirement." } }

 

 

6.2. 상태

어떠한 조건을 만족할 때만 함수를 사용할 수 있게 해야 할 경우에는 `check` 함수를 사용합니다.

<kotlin />
@kotlin.internal.InlineOnly public inline fun check(value: Boolean, lazyMessage: () -> Any): Unit { contract { returns() implies value } if (!value) { val message = lazyMessage() throw IllegalStateException(message.toString()) } }

 

위에서 확인할 수 있듯이 check 함수와 require의 구조는 거의 동일합니다. 다만 조건에 만족하지 않았을 때 check 함수는 `IllegalStateException`을 던진다는 것에서 차이가 있습니다.

 

check 함수를 사용해야 하는 경우는 상태가 올바른지 확인해야 할 때입니다. 이러한 확인은 사용자가 규약을 어기고, 사용하면 안 되는 곳에서 함수를 호출하고 있다고 의심될 때 합니다. 사용자가 코드를 제대로 사용할 거라고 믿고 있는 것보다는 항상 문제 상황을 예측하고, 문제 상황에 예외를 throw 하는 것이 좋습니다.

 

 

6.3. Contract

require와 check 함수의 내부를 모면 `contract`라는 블록이 존재합니다. contract는 인라인 함수로 컴파일러와 소통할 수 있는 방법을 정의합니다.

<kotlin />
@ContractsDsl @ExperimentalContracts @InlineOnly @SinceKotlin("1.3") @Suppress("UNUSED_PARAMETER") public inline fun contract(builder: ContractBuilder.() -> Unit) { }

 

개발자와 컴파일러와의 계약을 맺음으로서 컴파일러는 처리과정에서 모든 것을 담보할 수 없고, 개발자는 담보할 수 있는 예외를 지정해 줍니다.

 

그렇기 때문에 우리는 따로 캐스팅을 하지 않아도 자동으로 스마트 캐스팅된 타입을 사용할 수 있습니다.

 

 

6.4. Assert 계열 함수 사용

스스로 구현한 내용을 확인할 때에는 일반적으로 `assert` 함수를 사용합니다. 해당 함수는 구현을 잘못했거나, 누군가 변경해서 제대로 작동하지 않을 경우에 생기는 추가적인 문제를 예방하기 위해 사용합니다.

 

그렇기 때문에 Assertion은 예외와는 조금 다른 개념입니다. Assertion은 개발자가 참이라고 하는 상태를 명시할 수 있으며, 올바르게 수행될 수 있는 조건을 명시하므로 조건을 만족하는 경우에만 코드가 실행되도록 합니다.

<kotlin />
public inline fun assert(value: Boolean, lazyMessage: () -> Any) { if (_Assertions.ENABLED) { if (!value) { val message = lazyMessage() throw AssertionError(message) } } }

 

 

해당 함수는 `_Asserions.ENABLED` 가 true일 때에만 assert 함수의 본분이 실행되게 됩니다. 

<code />
@PublishedApi internal object _Assertions { @JvmField @PublishedApi internal val ENABLED: Boolean = javaClass.desiredAssertionStatus() }

 

 

` _Asserions.ENABLED`는 클래스 로더에 있는 Assertion Status를 가져옵니다. Assertion Status는 새 클래스가 해당 클래스 로더에 의해 활성화되었는지 여부를 결정하는 값입니다. 클래스 로더는 기본  Assertion Status를 False로 가지고 있습니다.

 

`-ea JVM` 옵션이 바로 Assertion의 Status를 True로 바꿔 런타임 Assertion을 활성화하는 옵션입니다. 그렇기 때문에 실제 소프트웨어가 운영되고 사용자에게 제공되는 프로덕션 환경에서는 오류가 발생하지 않습니다. 테스트를 할 때만 활성화되므로, 오류가 발생해도 사용자는 이를 알지 못합니다.

 

 

 

 

7. 아이템 6 - "사용자 정의 오류보다는 표준 오류를 사용하라"


오류를 처리할 때, 표준 라이브러리에 해당하지 않은 오류가 발생할 때가 있습니다. 이런 경우에는 사용자 정의 오류를 사용할 수가 있습니다.

 

다만, 사용자 정의 오류를 사용한다면 많은 사람들이 이해하기가 어려울 수 있기에 최대한 잘 알려진 표준 라이브러리의 오류를 사용하는 것이 좋습니다.

 

 

 

 

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


함수가 원하는 결과를 만들어 낼 수 없을 때는 1) null 또는 실패를 나타내는 sealed 클래스를 리턴하거나 2) 예외를 던져서 이러한 상황을 처리할 수 있습니다.

 

다만, 예외를 던지는 경우는 정보를 전달하는 상황에서 사용되면 안됩니다. 그렇기 때문에 충분히 예측할 수 있는 범위의 오류는 첫번째 방법을 사용하고, 예측하기 어려운 범위의 오류는 2번쨰 방법을 사용하는 것이 좋습니다.

 

 

8.1. null을 반환하는 경우

null을 반환하는 경우에는 코틀린의 다양한 null-safety 기능을 활용하면 됩니다.

<kotlin />
fun main() { val result = test(10) ?: 0 } fun test(target: Int): Int? { ... return null }

위의 예시의 경우에는 Elvis 연산자를 사용해서 null이 반환되었을 때를 처리할 수 있게 했습니다.

 

 

8.2. sealed 클래스를 반환하는 경우

아래와 같은 sealed interface를 반환할 수 있습니다.

<kotlin />
sealed interface Result { data object Success: Result data class Fail(val throwable: Throwable): Result }

 

 

sealed 클래스를 반환하게 되면 사용자는 해당 함수에 결과를 파악할 수 있으며, 이에 대처하기가 쉬워집니다. 아래의 예시는 when을 사용해서 결과에 따라 대처하는 코드입니다.

<kotlin />
fun main() { val result = when(test(10)) { is Result.Fail -> TODO() Result.Success -> TODO() } } fun test(target: Int): Result { ... return Result.Fail(IllegalStateException()) }

 

 

 

 

9. 아이템 8 - "적절하게 null을 처리하라"


null은 값이 부족하다는 것을 나타냅니다. 프로퍼티가 null일 경우에는 값이 설정되지 않았거나, 제거되었다는 것을 나타냅니다. 함수가 null을 반환할 경우에는 명확한 의미를 갖는 것이 좋습니다.

 

아래와 같은 함수 `toIntOrNull`가 Int로 변환할 수 없을 경우 null을 반환한다는 명확한 의미를 가지고 있다는 좋은 예시입니다.

<code />
@SinceKotlin("1.1") public fun String.toIntOrNull(): Int? = toIntOrNull(radix = 10)

 

 

9.1. null-safety하게 처리하기

null을 안전하게 처리하는 방법으로는 안전 호출(safe call)스마트 캐스팅(smart casting)이 있습니다.

<kotlin />
printer?.print() // safe call if (printer != null) printer.print() // smart casting

 

컬렉션의 경우에는 null을 표현하기 위해서 비어있는 리스트인 `emptyList()`를 사용합니다.

 

 

9.2. 오류 throw하기

null을 안전하게 처리하면 null 일 때, 이를 개발자에게 알리지 않고 코드가 그대로 진행됩니다. 하지만 printer가 null이 되리라 예상하지 못했다면 코드가 호출되지 않는 일은 너무 이상합니다. 

 

이러한 코드들이 많아지면 개발자가 오류를 찾기 어렵게 만듭니다. 따라서 다른 개발자가 해당 코드를 보고 선입견을 가질 수 있다면, 오류를 강제로 발생시켜 주는 것이 좋습니다.

 

 

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

nullable을 처리하는 가장 간단한 방법은 대상은 null이 아니라고 선언하는 것입니다.

 

다만 이러한 방식은 좋은 해결 방법은 아닙니다. 현재 확실하다고 미래에도 확실하다고 보장할 수는 없습니다. 그렇기 때문에 미래의 어느  시점에서 해당 코드가 오류를 발생시킬 수 있다는 것을 염두에 둬야 합니다.

 

예외는 예상하지 못한 잘못된 부분을 알려 주기 위해서 발생하는 것입니다.

 

nullablity와 관련된 정보는 숨겨져 있으므로, 굉장히 쉽게 놓칠 수 있습니다. 그렇기 때문에 일반적으로 !!의 사용은 피해야 합니다.

 

 

9.4. 의미없는 nullability 피하기

nullablity는 어찌되었든 처리해야 하는 비용이 발생합니다. 그렇기 때문에 피할 수 있다면 피하는 것이 좋습니다.

 

nullablity를 피할 수 있는 방법은 아래와 같습니다.

  • nullability에 따라 여러 함수를 만들어서 제공하세요.
  • 어떤 값이 클래스 생성 이후에 확실하게 설정된다면, null로 두지 말고 `lateinit`과 `notNull 델리게이트`를 사용하세요.
  • 컬렉션에서는 빈 컬렉션을 반환하세요.

 

 

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

여러 함수에서 공통적으로 사용되는 프로퍼티를 정의할 때 이를 null로 두고 재정의 할 때, null이 아닌 것으로 타입 변환하는 것은 바람직하지 않습니다. 이러한 값은 설정될 거라는 것이 명확하므로, 의미없습니다.

 

이러한 상황에서는 lateinit 한정자를 사용할 수 있습니다.

<kotlin />
lateinit var string : String

 

다만 lateinit을 사용할 때, 초기화를 시키지 않았으면 예외가 발생합니다.

 

그리고 lateinit을 사용할 수 없는 자료형도 존재합니다. 그렇기 때문에 이러한 경우에는 조금 느리지만 notNull 델리게이트를 사용합니다. 

<kotlin />
var int : Int by Delegates.notNull()

 

 

 

 

10. 아이템 9 - "use를 사용해서 리소스를 닫아라"


자바 표준 라이브러리에는 사용하고 난 다음 더 이상 필요하지 않을 때 `close()`를 해줘야 하는 리소스들이 있습니다.

  • InputStream과 OutputStream
  • java.sql.Connection
  • java.io.Reader
  • java.new.Socket과 java.util.Scanner

 

이러한 리소스들은 AutoCloseable을 상속받는 Closeable 인터페이스를 구현하고 있습니다. 리소스에 대한 레퍼런스가 없어지면 가비지 컬렉터가 이러한 리소스를 처리하지만, 이는 굉장히 느리며 쉽게 처리되지 않습니다.

 

그렇기에 더 이상 사용되지 않는 경우에는 명시적으로 close를 해주는 것이 좋습니다.

 

코틀린에서는 이러한 Closeable 인터페이스를 구현한 객체를 안전하게 사용할 수 있게 하는 메서드를 제공합니다.

<code />
@InlineOnly public inline fun <T : Closeable?, R> T.use(block: (T) -> R): R { contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) } var exception: Throwable? = null try { return block(this) } catch (e: Throwable) { exception = e throw e } finally { when { apiVersionIsAtLeast(1, 1, 0) -> this.closeFinally(exception) this == null -> {} exception == null -> close() else -> try { close() } catch (closeException: Throwable) { // cause.addSuppressed(closeException) // ignored here } } } }

 

 

이 `use()` 함수는 직접 진행해야 했던 try-catch문을 수행해주고 일반적인 예외가 발생했을 경우리소스를 닫을 때 발생하는 예외를 따로 처리해 줍니다. 

<code />
val reader = BufferedReader(FileReader("")) reader.use { // 처리 }

 

 

 

 

11. 아이템 10- "단위 테스트를 만들어라"


사용자의 관점에서 애플리케이션 외부적으로 제대로 작동하는지 확인하는 것이 목표인 테스트는 개발자에게 유용하지만 충분하지는 않습니다.

 

이것만으로는 해당 요소가 올바르게 작동한다는 것을 완전하게 보증할 수는 없습니다. 그렇기 때문에 단위 테스트(Unit Test)가 필요합니다.

 

11.1. 단위 테스트의 장점

단위 테스트는 개발자가 만들고 있는 요소가 제대로 동작하는지를 빠르게 피드백해 주므로 개발하는 동안에 큰 도움이 됩니다. 그리고 아래와 같은 장점이 있습니다.

  1. 테스트가 잘 된 코드는 신뢰할 수 있습니다.
  2. 리팩터링이 두렵지 않게 됩니다.
  3. 수동으로 테스트하는 것보다 단위 테스트로 확인하는 것이 빠릅니다.

 

11.2. 단위 테스트의 단점

다만 아래와 같은 단점도 존재합니다.

  1. 단위 테스트를 만들어야 하므로 시간이 걸립니다.
  2. 테스트를 활용할 수 있게 코드를 만들어야 합니다.
  3. 좋은 단위 테스트를 만드는 것이 어렵습니다.

 

 

 

12. 1장을 마무리하며


지금까지 프로그램이 올바르게 작동해야 한다는 것을 최우선적인 목표로 두고 여러 가지 내용을 살펴보았습니다. 이번 장에서 설명한 내용들을 활용하면, 안정적인 프로그램을 만들 수 있습니다. 하지만 가장 중요한 것은 애플리케이션이 진짜로 올바르게 동작하는지 확인하는 것입니다. 

 

이것이 테스트입니다.

 

 

 

 

 

13. Reference

 

Smart Casts via Assertions + Kotlin Contracts

There are three things I do every-time I start a codebase that I know that I will be maintaining for the long haul.

proandroiddev.com

 

 

 

 

profile

Developing Myself Everyday

@배준형

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!