HomeAboutMeBlogGuest
© 2025 Sejin Cha. All rights reserved.
Built with Next.js, deployed on Vercel
🤩
개발
/Kotlin/
타입 안전성

타입 안전성

Any와 Nothing 클래스Null 가능 참조null은 에러를 유발함null 가능 타입 사용하기safe-call 연산자elvis operator사용해서는 안될 안전하지 않은 확정 연산자 !!when의 사용Platform types타입 체크와 캐스팅타입 체크is 사용하기스마트 캐스트명시적 타입 캐스팅제네릭타입 불변성공변성(covariance) 사용하기반공변성(contravariance) 사용하기where를 사용한 파라미터 타입 제한스타 프로젝션구체화된 타입 파라미터reified
💡
코틀린은 디자인 바이 컨트랙트(Design By Contract) 접근방식으로 개발자는 함수나 메소드가 null 을 받거나 리턴할 수 있는지 명확하게 표현할 수 있으며, 그 시점도 알 수 있다. 만약에 참조가 null 이 될 수 있다면 참조하고 있는 객체의 속성이나 메서드를 사용할 땐 언제나 null 체크를 하도록 강제한다. 코틀린은 이런식으로 코드를 안전하게 만든다. 그리고 안전한 코드는 디버깅과 서비스 중에 일어나는 오류를 막아준다. 그리고 코틀린은 null에 사용 가능한 연산자를 몇 가지 제공한다. 그런 연산자를 사용하면 null일 가능성이 있는 참조를 다룰 때 코드에 혼란이 적어진다. 이 기능의 더 특별한 기능은 이런 체크가 모두 컴파일 타임 시간에 이루어지고 바이트코드에는 아무것도 추가되지 않는다는 점이다.

Any와 Nothing 클래스

  • 코틀린의 Any 클래스는 Java의 Object에 대응되는 클래스라고 볼 수 있음
    • 비록 Any가 Java 바이트코드에서 Object에 매칭되지만, Any와 Object가 동일한 것은 아님. Any는 확장함수를 통해 특별한 메소드들을 제공
  • Java와 같은 언어에서는 리턴이 없는 메서드에 void를 사용. 코틀린에서는 표현식이 리턴을 하지 않을때 void 대신 Unit을 사용.
    • Nothing을 메서드의 리턴타입으로 사용한다면, 그건 해당 함수가 절대로 리턴을 하지 않는다는 이야기이다. 함수호출은 예외만 발생시킨다.
    • 예외는 Nothing 타입을 대표한다.

Null 가능 참조

null은 에러를 유발함

fun nickName(name: String): String { if (name == "William") { return "Bill" } return null // Error }
  • 코틀린은 참조타입이 null 불가인 곳에 null을 리턴하려고 하면 컴파일 오류를 냄
  • 위의 경우 반환 타입이 String 이어야 하는데 null을 반환하여 error를 발생

null 가능 타입 사용하기

  • null 가능 타입은 타입 이름 뒤에 ? 가 붙음
  • String → null 불가 타입. String? → null 가능 타입
💡
null 가능 타입의 바이트코드 맵핑 JVM에는 직접 표시할 수 없으므로 null 가능 타입은 대응되는 null 불가 타입으로 대체됨. 코틀린 컴파일러는 이 메타 인스트럭션을 컴파일 시간에 체크하기 때문에 실행 시간에는 성능적 오버헤드가 없음

safe-call 연산자

fun nickName(name: String?): String? { if (name == "William") { return "Bill" } if (name != null) { return name.reserved() } return null } // safe-call 연산자 사용 fun nickName(name: String?): String? { if (name == "William") { return "Bill" } return name?.reserved() }
  • ? 연산자를 이용하면 메소드 호출 또는 객체 속성 접근과 null 체크를 하나로 합칠 수 있음
  • 참조가 null 일 경우 세이프 콜 연산자의 결과는 null임

elvis operator

  • 세이프 콜 연산자는 타깃이 null 일 경우 null 을 리턴하는데, null 이 아닌 다른 것을 리턴해주고 싶을때! 사용하는 것이 엘비스 연산자임
fun nickName(name: String?): String { if (name == "William") { return "Bill" } val result = name?.reserved()?.toUpperCase() return if (result == null) "Joker" else result } // 위의 두 줄으르 아래와 같이 한줄로 변경 가능. return name?.reversed()?.toUpperCase() ?: "Joker"
  • 눈(:) 위로 엘비스의 헤어스타일(?)이 보이는가. 이 연산자의 이름이 엘비스인 이유임

사용해서는 안될 안전하지 않은 확정 연산자 !!

  • 사용하지 마라.
  • null 이 아니라고 확신했는데 null 넘어가면? NullPointerException 나온다

when의 사용

  • null 가능 참조로 작업을 할 때 참조의 값에 따라서 다르게 동작하거나 다른 행동을 취해야 한다면 ?. 이나 ?: 보다는 when을 사용하는 것을 고려해보자.
fun nickName(name: String?): String { if (name == "William") { return "Bill" } return name?.reversed()?.toUpperCase() ?: "Joker" } fun nickName(name: String?) = when (name) { "William" -> "Bill" null -> "Joker" else -> name.reversed().toUpperCase() }
  • 위와 같이 함으로써 더 명확하게 코드 작성이 가능하다

Platform types

[ 공식문서 참고 ]
T! ↔ T or T?
Java에서 사용되는 변수는 Kotlin에서 표현할 방법이 없다. null 일수도 null 이 아닐수도 있으니
그래서 이를 표현하기 위해 Platform Types를 도입한 것

타입 체크와 캐스팅

💡
권장사항 - 가능한 스마트 캐스트를 사용하라 - 스마트 캐스트가 불가능한 경우에만 안전한 캐스트 연산자를 사용하라 - 애플리케이션이 불타거나 무너지는걸 보고 싶다면 안전하지 않은 캐스트 연산자 사용해라(절대 쓰지마)

타입 체크

  • 그게 기능인지 결함인가? 는 실행 시간 타입 체크에 대한 끝없는 논쟁임. 가끔 객체의 타입을 체크하는 것은 필수적이지만, 확장성의 측면에서 봤을 때 타입체크는 최소한으로만 해야 함
  • 임의의 타입을 체크하는 행위는 새로운 타입을 추가 했을 때 코드를 부서지기 쉽게 만들고 개방-폐쇄 원칙을 위배하기 때문에 (Java의 instanceof)

is 사용하기

class Animal { override operator fun equals(other: Any?) = other is Animal }
  • is 연산자는 객체가 참조로 특정 타입을 가리키는지 확인함. 위 코드에서는 other가 Animal 클래스인지 확인
  • is 연산자를 부정과 함께 사용 → other !is Animal
  • 코틀린에서 is, !is 를 사용하면 캐스팅을 공짜로 할 수 있다.

스마트 캐스트

@Override public boolean equals(Object other) { if (other instanceof Animal) { return age == ((Animal) other).age; } return false; }
  • Java에서는 age 속성에 접근하기 위해 other이 Animal인지 확인 후, 캐스팅을 직접 해주어야 함
class Animal(val age:Int) { override operator fun equals(other: Any?): Boolean { return if (other is Animal) age == other.age else false } } // otehr is Animal && age == other.age로 바꿀 수 있음
  • other.age라고 바로 사용이 가능함. if 문 전에 other.age로 접근하면 컴파일 오류
  • 하지만 is 연산자로 체크를 했기에 캐스트를 직접 할 필요가 없다

명시적 타입 캐스팅

  • 명시적 타입캐스팅은 컴파일러가 타입을 확실하게 결정할 수 없어 스마트 캐스팅을 하지 못할 경우에만 사용
    • 예를 들어 var 변수가 체크와 사용 사이에서 변경되는 경우
fun fetchMessage(id: Int): Any = if (id == 1) "Record found" else StringBuilder("data not found") // 이렇게 하면 StringBuilder가 반환될 경우 ClassCastException 발생 for (id in 1..2) { println("Message length: ${(fetchMessage(id) as String).length}" } // 반면 as? 는 null 가능 참조 타입을 결과로 가짐 val message: String = fetchMessage(1) as String val message: String? = fetchMessage(1) as? String // 최종 결과 println("Message length: ${(fetchMessage(id) as? String).length ?: "---"}")
  • as 연산자는 캐스팅이 실패하면 죽는데 반해 안전한 캐스트 연산자인 as? 는 캐스팅이 실패하면 null을 할당함

제네릭

  • 타입 T 이외에도 공변성(covariance)을 허용해주길 원할 때가 있다
    • ↔ 유저가 컴파일러에게 파라미터 타입 T의 자식 클래스도 사용하도록 허용해달라
    • Java에서 <? extends T> 문법을 사용해 공변성을 사용
  • 반공변성(contravariance)
    • ↔ 파라미터 타입 T의 부모 클래스를 타입 T가 필요한 자리에서 쓸 수 있도록 한다
    • Java → <? super T>

타입 불변성

  • 메소드가 클래스 T의 객체를 받을 때, T 클래스의 자식이라면 어떤 객체든 전달할 수 있다. 예를 들어 Animal 의 인스턴스를 전달할 수 있다면, Animal의 자식 클래스인 Dog의 인스턴스 역시 전달 가능
  • 하지만 메서드가 타입 T의 제네릭 오브젝트를 받는다면(예 : List<T>) T의 파생 클래스를 전달할 수 없다.
    • 예로, List<Animal>을 전달할 수 있지만, Dog extends Animal 이라도 List<Dog> 전달이 불가능 ↔ 타입 불변성
fun receiveFruits(fruits: Array<Fruit>) { println("Number of fruits : ${fruits.size}") } val bananas: Array<Banana> = arrayOf() receiveFruits(bananas) //ERROR: type mismatch
  • Array<Banana> 가 Array<Fruit>을 인자로 받는 메소드에 전달이 가능하다면 메서드 내부에서 Orange를 Array<Fruit>에 담게 될 때 문제가 발생. 이런 상황에서 Array<Banana>를 처리할 때 우리가 Orange를 Banana로 취급을 하게 되면서 캐스팅 예외가 발생함
  • 코틀린은 Banana가 Fruit을 상속받았더라도 Array<Banana>를 Array<Fruit>으로 취급해서 전달하는 것을 막아서 제네릭을 타입 안정적으로 만들었다.
 

공변성(covariance) 사용하기

fun copyFromTo(from: Array<Friut>, to: Array<Fruit>) { for (i in 0 until from.size) { to[i] = from[i] } }
  • from 파라미터는 파라미터의 값을 읽기만 하기 때문에 Array<T>의 T에 Fruit 클래스나 Fruit 클래스의 하위 클래스가 전달되더라도 아무 위험이 없다. 이런 것을 타입이나 파생 타입에 접근하기 위한 파라미터 타입의 공변성이라고 이야기함
 
  • Fruit의 자식 클래스들을 전달 가능하게 만들기 위해 from: Array<out Fruit> 문법을 사용
fun copyFromTo(from: Array<Friut>, to: Array<out Fruit>) { for (i in 0 until from.size) { to[i] = from[i] } }
  • 코틀린은 from 레퍼런스에 data가 새로 들어가게 하는 메소드 호출이 없다는 사실을 확인하고 메서드 시그니처가 호출되는 것을 확인하여 이를 검증함
    • from[i] = Fruit() // ERROR from.set(i, to[i]) // ERROR
  • from에서는 읽기만 하고 to에 값을 설정하는 경우에만 from 파라미터 위치에 Array<Banana>, Array<Orange>, Array<Fruit>을 전달할 수 있음
  • Array<T> 클래스는 T 타입의 객체를 읽고, 쓰는 메서드 모두를 가지고 있다. Array<T>를 사용하는 모든 함수는 읽고, 쓰는 메서드 모두를 사용 가능. 하지만 공변성을 사용하기 위해 우리가 코틀린 컴파일러에게 Array<T> 파라미터에 어떤 값도 추가하거나 변경하지 않겠다는 약속을 해야 함.
  • 이런 제네릭 클래스를 사용하는 관점에서 공변성을 이용하는 것을 사용처 가변성(use-site variance) 혹은 타입 프로젝션 이라 부름

반공변성(contravariance) 사용하기

<in T> : 이것으로 정의되면 전체적으로 파라미터 타입을 받을 수만 있고 리턴하거나 다른 곳으로 보낼 수는 없는 반공변성으로 특정된다.
val things = Array<Any>(3) { _ -> Fruit() } val bananaBasket = Array<Banana>(3) { _ -> Banana() } copyFromTo(bananaBasket, things) // ERROR : type mismatch fun copyFromTo(from: Array<out Fruit>, to: Array<in Fruit>) { for (i in 0 until from.size) { to[i] = from[i] } }

where를 사용한 파라미터 타입 제한

fun <T> useAndClose(input: T) { input.close() // ERROR : unresolved reference: close } fun <T: AutoCloseable> useAndClose(input: T) { input.close() } // 여러 개의 제약 조건을 넣고 싶을 때 where 절 사용 fun <T> useAndClose(input: T) where T: AutoCloseable, T: Appendable { input.append("there") input.close() }

스타 프로젝션

Java에서 함수가 모든 타입의 제네릭 객체를 받아서 읽기전용으로 사용할 수 있도록 만들기 위해 ? 를 사용
파라미터 타입을 정의하는 스타 프로젝션<*> 은 제네릭 읽기 전용 타입과 raw 타입을 위한 코틀린의 기능
스타 프로젝션은 타입에 대해 정확히는 알 수 없지만 타입 안정성을 유지하면서 파라미터를 전달할 때 사용. 읽는 것만 허용하고 쓰는 것은 허용되지 않음
fun printValues(values: Array<*>) { for (value in values) { println(value) } // values[0] = values[1] // ERROR }
  • 스타 프로젝션<*> 은 out T 와 동일하지만 더 간결하게 작성할 수 있다.
  • 스타 프로젝션이 선언처 가변성에서 <in T>로 정의된 반공분산으로 사용된다면 in Nothing을 사용한 것과 같아짐

구체화된 타입 파라미터

Java에서 제네릭을 사용할 때 Class<T>를 함수에 파라미터로 전달해야 하는 냄새나는 코드를 볼 수 있다. 제네릭 함수에서 특정 타입이 필요하지만 Java의 타입 이레이져 때문에 타입 정보를 잃어버릴 경우 필수적으로 따라옴
코틀린은 구체화된 타입 파라미터(reified)를 이용해서 악취를 제거
abstract class Book(val name: String) class Fiction(name: String) : Book(name) class NonFiction(name: String) : Book(name) val books: List<Book> = listOf( Fiction("Moby Dick"), NonFiction("Learn to Code"), Fiction("LOTR")) fun <T> findFirst(books: List<Book>, ofClass: Class<T>): T { val selected = books.filter { book -> ofClass.isInstance(book) } if (selected.size == 0) { throw RuntimeException("Not Found") } return ofClass.cast(selected[0]) } println(findFirst(books, NonFiction::class.java).name)
타입 이레이져로 인하여 Class<T> 파라미터를 이용해서 직접 타입을 넘겨주는식으로 작성(Java에서는)

reified

inline fun <reified T> findFirst(books: List<Book>): T { val selected = books.filter { book -> book is T } if (selected.size == 0) { throw RuntimeException("Not Found") } return selected[0] as T }
  • 함수를 inline 으로 선언하고 파라미터 타입 T를 reified 로 선언함으로써 Class<T> 파라미터를 제거, 함수 안에서 T를 타입 체크와 캐스팅용으로 사용 가능하게 됨