변수와 타입 시스템 — val, var, 그리고 코틀린이 타입을 다루는 방식
코틀린을 처음 접하면 val과 var부터 배우게 된다. 자바의 final과 비슷해 보이지만, 코틀린의 타입 시스템은 그보다 훨씬 깊다. 타입 추론, boxing, 스마트 캐스트까지 — 면접에서도 자주 나오는 주제들을 하나씩 짚어보자.
val과 var — 재할당 가능 여부의 차이
코틀린에서 변수를 선언하는 키워드는 딱 두 가지다.
val name = "코틀린" // 재할당 불가 (read-only)
var age = 5 // 재할당 가능 (mutable)
// name = "자바" // 컴파일 에러!
age = 6 // OK
val은 "불변"이 아니라 "재할당 불가"
면접에서 가장 많이 헷갈리는 포인트가 이 부분이다. val은 참조(reference)를 고정할 뿐, 객체 내부의 상태까지 보장하지 않는다.
val numbers = mutableListOf(1, 2, 3)
// numbers = mutableListOf(4, 5) // 컴파일 에러 — 참조 변경 불가
numbers.add(4) // OK — 내부 상태 변경은 가능
println(numbers) // [1, 2, 3, 4]
자바의 final과 같은 개념이라고 보면 된다. 진짜 불변을 원한다면 불변 컬렉션(listOf)이나 불변 데이터 클래스를 쓰자.
const val — 컴파일 타임 상수
val과 별도로 const val이 있다. 컴파일 시점에 값이 결정되어야 하므로 기본 타입이나 String만 가능하다.
// 최상위 레벨 또는 object 내부에서만 사용 가능
const val MAX_RETRY = 3
const val APP_NAME = "MyApp"
// const val date = Date() // 컴파일 에러 — 런타임 값은 불가
타입 추론 — 컴파일러가 알아서 판단한다
코틀린은 타입을 명시하지 않아도 컴파일러가 추론해준다.
val message = "안녕하세요" // String으로 추론
val count = 42 // Int로 추론
val pi = 3.14 // Double로 추론
val isKotlin = true // Boolean으로 추론
물론 명시적으로 적어도 된다. 함수 파라미터에는 반드시 타입을 적어야 한다.
val score: Int = 100 // 명시적 타입 선언
fun greet(name: String): String { // 파라미터는 타입 필수
return "안녕, $name"
}
타입 추론이 안 되는 경우
변수를 선언하면서 초기화하지 않으면 타입을 명시해야 한다.
val result: String // 타입 명시 필수
result = computeSomething()
// val unknown // 컴파일 에러 — 타입을 알 수 없음
기본 타입과 boxing — Int와 Int?의 차이
코틀린에는 자바처럼 primitive/reference 구분이 문법상 없다. 하지만 JVM에서는 성능을 위해 구분된다.
val a: Int = 42 // JVM에서 primitive int로 컴파일
val b: Int? = 42 // JVM에서 java.lang.Integer(boxing)로 컴파일
왜 이런 차이가 생길까?
primitive 타입은 null을 담을 수 없다. 그래서 Int?처럼 nullable로 선언하면 JVM에서 wrapper 객체(Integer)를 써야 한다.
val x: Int = 1000
val y: Int = 1000
println(x == y) // true — 값 비교(==)
val a: Int? = 1000
val b: Int? = 1000
println(a == b) // true — 값 비교(==)는 같음
println(a === b) // false — 참조 비교(===)는 다름 (서로 다른 Integer 객체)
면접에서 ==와 ===의 차이를 물어보는 이유가 바로 이것이다.
==: 구조적 동등성 (equals() 호출)===: 참조적 동등성 (같은 객체인지)
숫자 타입 변환은 명시적으로
자바에서는 int를 long에 대입하면 자동 변환되지만, 코틀린은 그렇지 않다.
val intVal: Int = 42
// val longVal: Long = intVal // 컴파일 에러!
val longVal: Long = intVal.toLong() // 명시적 변환 필요
이 설계는 의도치 않은 타입 변환으로 인한 버그를 방지하기 위함이다.
Any, Unit, Nothing — 코틀린의 특수 타입
Any — 모든 타입의 최상위
자바의 Object에 대응하지만, primitive 타입도 포함한다.
val anything: Any = 42 // Int도 Any의 하위 타입
val text: Any = "문자열"
// Any에 정의된 메서드
anything.toString()
anything.hashCode()
anything.equals(text)
Unit — "반환할 게 없음"
자바의 void에 해당하지만, 코틀린에서는 실제 타입이다. 제네릭에서 유용하다.
fun printMessage(msg: String): Unit { // Unit 생략 가능
println(msg)
}
// 제네릭에서의 활용
fun <T> execute(action: () -> T): T = action()
execute<Unit> { println("실행") } // void 대신 Unit 사용 가능
Nothing — "이 함수는 반환하지 않는다"
가장 헷갈리는 타입이다. Nothing은 "정상적으로 반환하지 않는다"는 의미다.
fun fail(message: String): Nothing {
throw IllegalArgumentException(message)
}
// Nothing은 모든 타입의 하위 타입이므로 이런 코드가 가능하다
val result: String = input ?: fail("값이 없습니다")
Nothing이 모든 타입의 하위 타입이라는 점이 핵심이다. 엘비스 연산자 오른쪽에 예외를 던지는 함수를 넣을 수 있는 이유가 여기에 있다.
// Nothing?은 null만 담을 수 있는 타입
val nothing: Nothing? = null
// val impossible: Nothing = ??? // 값을 생성할 수 없음
스마트 캐스트 — is 검사 후 자동 캐스팅
자바에서는 instanceof 검사 후에도 명시적 캐스팅이 필요하다. 코틀린은 이걸 자동으로 해준다.
fun describe(obj: Any): String {
// is 검사를 통과하면 자동으로 해당 타입으로 캐스팅
if (obj is String) {
return "문자열 길이: ${obj.length}" // obj가 String으로 자동 캐스팅
}
if (obj is Int) {
return "정수의 제곱: ${obj * obj}" // obj가 Int로 자동 캐스팅
}
return "알 수 없는 타입"
}
when과 함께 쓰면 더 강력하다
fun process(value: Any) {
when (value) {
is String -> println("길이: ${value.length}") // 자동 캐스팅
is List<*> -> println("리스트 크기: ${value.size}") // 자동 캐스팅
is Int -> println("값: ${value + 1}") // 자동 캐스팅
else -> println("기타")
}
}
스마트 캐스트가 동작하지 않는 경우
class Example {
var data: Any = "초기값"
fun check() {
if (data is String) {
// println(data.length) // 컴파일 에러!
// var 프로퍼티는 다른 스레드에서 변경될 수 있으므로
// 스마트 캐스트가 보장되지 않는다
}
// 해결 방법 1: 지역 변수에 복사
val localData = data
if (localData is String) {
println(localData.length) // OK
}
// 해결 방법 2: 명시적 캐스팅
if (data is String) {
println((data as String).length) // OK (하지만 안전하지 않음)
}
}
}
스마트 캐스트가 동작하는 조건을 정리하면 이렇다.
val지역 변수 — 항상 동작val프로퍼티(custom getter 없음) — 동작var지역 변수(검사와 사용 사이에 변경 없음) — 동작var프로퍼티 — 동작하지 않음 (다른 스레드에서 변경 가능)
명시적 캐스팅 — as와 as?
val obj: Any = "코틀린"
// 안전하지 않은 캐스팅 — 실패하면 ClassCastException
val str: String = obj as String
// 안전한 캐스팅 — 실패하면 null 반환
val num: Int? = obj as? Int // null (ClassCastException 대신)
면접에서 as와 as?의 차이를 물어보는 경우가 많다. 안전한 캐스팅(as?)을 습관적으로 쓰는 게 좋다.
타입 시스템 계층 구조 정리
Any
/ | \
Int String ...
\ | /
Nothing
- Any — 모든 non-null 타입의 최상위 (자바의 Object에 대응)
- Any? — 모든 타입의 최상위 (null 포함)
- Nothing — 모든 타입의 최하위 (값이 존재하지 않음)
- Nothing? — null만 가능한 타입
정리 — 면접에서 기억할 포인트
- val vs var — val은 참조 재할당 불가이지 불변이 아니다
- 타입 추론 — 컴파일러가 추론하되, 파라미터에는 명시 필수
- Int vs Int? — JVM에서 primitive vs boxing으로 구분. 성능 차이 있음
- ==와 === — 구조적 동등성 vs 참조적 동등성
- Any/Unit/Nothing — 각각 최상위 타입 / void 대응 / 반환 안 함
- 스마트 캐스트 — is 검사 후 자동 캐스팅. var 프로퍼티에서는 동작 안 함