Kotlin 변수와 타입: val, var, 기본 타입과 null 안전성

이 글의 핵심

Kotlin은 null이 될 수 있는지를 타입에 적게 해서 NullPointerException을 컴파일 단계에서 줄입니다. 그런데 !!를 습관적으로 붙이면 그 보호가 사라지고, Int가 Long으로 자동 변환되지 않아 당황하는 경우도 많습니다. 불변을 기본으로 두고 null을 안전하게 다루는 습관을 예제로 익히게 합니다.

들어가며

val·var와 널 안전성(?, !!, ?.)으로 불변을 기본에 두며, 널 가능성을 타입에 적습니다. 컴파일러가 흐름 분석으로 불필요한 null 검사를 줄여 줍니다.


val과 var

val (불변)

val name = "홍길동"
val age = 25
// name = "김철수"  // 컴파일 에러!

var (가변)

var score = 90
score = 95  // OK
var count = 0
count++  // OK

선택 기준

// 기본적으로 val 사용 (권장)
val pi = 3.14
val maxSize = 100
// 변경이 필요한 경우만 var
var currentPage = 1
var isLoggedIn = false

val을 기본으로 두는 이유는 “이 값이 어디서 바뀌는가”를 추적할 필요가 없어지기 때문입니다. 함수 안의 변수가 모두 val이면 값이 정해진 줄만 보면 되고, 여러 스레드가 같은 변수를 읽어도 대입 경쟁이 없습니다. IntelliJ는 한 번도 재대입되지 않는 var에 Variable is never modified and can be declared immutable using 'val' 경고를 띄워 주므로, 일단 val로 쓰고 컴파일러가 재대입을 막을 때만 var로 바꾸는 습관이 편합니다.

주의할 점은 val이 참조를 고정할 뿐 객체 내부까지 불변으로 만들지는 않는다는 것입니다. val list = mutableListOf(1, 2)는 list = ...로 다른 리스트를 대입할 수는 없지만 list.add(3)은 됩니다. Java의 final과 같은 의미이고, 내용까지 바꾸지 못하게 하려면 List 같은 읽기 전용 타입을 함께 써야 합니다. 또 클래스의 val 프로퍼티라도 커스텀 getter(val now get() = System.currentTimeMillis())가 있으면 읽을 때마다 값이 달라질 수 있습니다.


숫자, 문자, 불리언 타입

숫자 타입

// 정수
val byte: Byte = 127
val short: Short = 32767
val int: Int = 2147483647
val long: Long = 9223372036854775807L
// 실수
val float: Float = 3.14f
val double: Double = 3.14159
// 타입 추론
val number = 10        // Int
val bigNumber = 10L    // Long
val decimal = 3.14     // Double
val precise = 3.14f    // Float

정수 리터럴은 기본적으로 Int로 추론되고, Int 범위를 넘는 값을 쓰면 자동으로 Long이 됩니다. 실수 리터럴은 Double이 기본이라 val f: Float = 3.14는 The floating-point literal does not conform to the expected type Float 에러가 나고, 반드시 f 접미사를 붙여야 합니다.

Java 개발자가 가장 먼저 당황하는 부분은 숫자 타입 사이에 암시적 확장 변환이 없다는 점입니다. Java에서는 long l = intValue;가 자연스럽게 되지만, Kotlin에서 val l: Long = number는 Type mismatch: inferred type is Int but Long was expected 컴파일 에러입니다. 대신 1L + number처럼 연산자는 섞어서 쓸 수 있도록 오버로드되어 있습니다. 불편해 보이지만, 이 규칙 덕분에 Int 값이 Long 매개변수 자리에서 몰래 바뀌면서 박싱된 객체의 equals가 false를 돌려주는 Java식 버그가 원천적으로 막힙니다.

오버플로도 조용히 일어난다는 점을 기억해 두세요. Int.MAX_VALUE + 1은 예외 없이 -2147483648이 됩니다. 밀리초 타임스탬프나 파일 크기처럼 20억을 넘을 수 있는 값은 처음부터 Long으로 선언하고, 0.1 + 0.2 == 0.3이 false인 부동소수점 오차 때문에 금액 계산에는 Double 대신 BigDecimal이나 정수(원 단위)를 씁니다.

문자와 문자열

// 문자
val char: Char = 'A'
val koreanChar = '가'
// 문자열
val text: String = "Hello"
val multiLine = """
    여러 줄
    문자열
""".trimIndent()
// 문자열 템플릿
val name = "홍길동"
val greeting = "안녕하세요, $name님!"
val info = "나이: ${age + 1}세"

Char는 Java와 달리 숫자 타입이 아니어서 val c: Char = 65는 컴파일되지 않습니다. 문자 코드가 필요하면 'A'.code(Kotlin 1.5 이상)로 Int를 얻고, 반대로 65.toChar()로 문자를 만듭니다. 문자열 템플릿에서 $name은 변수 하나에만 쓰이고, ${age + 1}이나 ${user.name}처럼 식이나 프로퍼티 접근은 중괄호가 필요합니다. "$user.name"이라고 쓰면 user의 toString() 결과 뒤에 .name이라는 글자가 붙어 나오는 실수가 흔합니다. 세 따옴표 문자열은 이스케이프 없이 여러 줄을 쓸 수 있는데, 코드 들여쓰기가 그대로 문자열에 들어가므로 trimIndent()로 공통 들여쓰기를 제거합니다.

불리언

val isActive: Boolean = true
val isCompleted = false
// 논리 연산
val result = isActive && !isCompleted

Nullable 타입과 ?., ?:, !!, as?

Non-null vs Nullable

// Non-null (기본)
var name: String = "홍길동"
// name = null  // 컴파일 에러!
// Nullable
var nullableName: String? = "홍길동"
nullableName = null  // OK

Kotlin에서 String과 String?는 서로 다른 타입입니다. String?은 String보다 넓은 타입이라 String 값을 String? 변수에 넣는 것은 자유롭지만, 반대 방향은 null 검사를 거쳐야 합니다. 그래서 nullableName.length라고 쓰면 Only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type String? 컴파일 에러가 납니다. NullPointerException을 런타임이 아니라 컴파일 단계로 끌어오는 것이 Kotlin 널 안전성의 핵심입니다.

이 보호에는 빈틈이 하나 있습니다. Java 코드에서 온 값은 널 가능성을 알 수 없어서 String!로 표시되는 플랫폼 타입이 되고, 컴파일러가 검사를 강제하지 않습니다. Java 라이브러리가 null을 반환했는데 Kotlin 쪽에서 String으로 받아 쓰면 그 자리에서 NullPointerException이 납니다. Java API의 반환값은 문서나 @Nullable 어노테이션을 확인하고, 확실하지 않으면 String?으로 받는 것이 안전합니다.

Safe Call (?.)

Nullable 타입을 안전하게 다루는 핵심 연산자입니다:

val name: String? = null
// Safe Call: null이면 null 반환, 아니면 프로퍼티/메서드 호출
val length = name?.length  
// name이 null이면 length도 null
// name이 "홍길동"이면 length는 3
// Java의 if (name != null) name.length() 를 간결하게 표현
// 체이닝 가능 - 중간에 null이 있으면 전체가 null
val city = user?.address?.city
// user가 null이면 → null
// user.address가 null이면 → null
// 둘 다 있으면 → user.address.city
// Java라면 여러 줄의 null 체크가 필요

안전 호출은 결과 타입도 nullable로 만든다는 점을 놓치기 쉽습니다. name이 String?이면 name?.length의 타입은 Int가 아니라 Int?라서 val len: Int = name?.length는 컴파일되지 않습니다. 보통은 바로 뒤의 엘비스 연산자로 기본값을 주거나, name?.let { println(it.length) }처럼 null이 아닐 때만 블록을 실행하는 방식과 함께 씁니다.

Elvis 연산자 (?:)

null일 때 기본값을 제공하는 연산자입니다:

val name: String? = null
// Elvis 연산자: null이면 오른쪽 값 사용
val length = name?.length ?: 0  
// name?.length가 null이면 0 반환
// name?.length가 3이면 3 반환
// 기본값 제공
val displayName = name ?: "Guest"
// name이 null이면 "Guest"
// name이 "홍길동"이면 "홍길동"
// 실전 예시: 사용자 이름 표시
fun greet(name: String?) {
    val greeting = "안녕하세요, ${name ?: "방문자"}님!"
    println(greeting)
}
greet(null)      // 안녕하세요, 방문자님!
greet("홍길동")  // 안녕하세요, 홍길동님!

엘비스 연산자의 오른쪽에는 값뿐 아니라 return이나 throw도 올 수 있습니다. val user = findUser(id) ?: return null이나 val token = header ?: throw IllegalArgumentException("토큰 없음")처럼 쓰면, null일 때 함수에서 바로 빠져나가고 그 아래 코드에서는 user를 non-null 타입으로 다룰 수 있습니다. Java의 “가드 절”을 한 줄로 쓰는 셈이라 실무 코드에서 매우 자주 보입니다.

Not-null 단언 (!!)

“이 값은 절대 null이 아니다”라고 컴파일러에게 단언합니다:

val name: String? = "홍길동"
// !! : null이 아님을 단언
val length = name!!.length  
// name이 null이면 NullPointerException 발생
// (Kotlin 1.4 이전에는 KotlinNullPointerException)
// name이 "홍길동"이면 3 반환
// 주의: 확실할 때만 사용!
// 가능하면 ?. 또는 ?: 사용 권장
// 나쁜 예
val user: User? = getUser()
val userName = user!!.name  // null이면 크래시!
// 좋은 예
val safeName = user?.name ?: "Unknown"  // null 안전

!!는 컴파일러의 검사를 끄는 스위치라서, 코드베이스에 !!가 늘어날수록 Kotlin을 쓰는 이점이 줄어듭니다. 제가 Java 코드를 Kotlin으로 옮길 때 가장 흔히 보는 패턴은 IDE의 자동 변환이 남긴 !!가 그대로 커밋되는 경우인데, 이런 코드는 Java 시절과 똑같은 지점에서 똑같이 NPE를 냅니다. 더 곤란한 것은 한 줄에 a!!.b!!.c!!처럼 여러 개가 있으면 스택 트레이스의 줄 번호만으로는 어느 것이 null이었는지 알 수 없다는 점입니다. 정말 null일 수 없는 값이라면 requireNotNull(user) { "user는 로그인 후에만 접근" }처럼 이유를 메시지로 남기는 편이 나중에 디버깅할 때 훨씬 도움이 됩니다. 연산자 비교:

연산자동작null일 때
?.Safe Callnull 반환
?:Elvis기본값 반환
!!Not-null 단언예외 발생

Safe Cast (as?)

val obj: Any = "Hello"
val str: String? = obj as? String  // 성공
val num: Int? = obj as? Int        // null 반환

일반 캐스트 as는 타입이 맞지 않으면 ClassCastException: class java.lang.String cannot be cast to class java.lang.Integer를 던지지만, as?는 실패하면 null을 돌려줍니다. 그래서 (obj as? Int) ?: 0처럼 엘비스와 함께 쓰는 경우가 많습니다. 다만 as?가 실패를 null로 바꿔 버리므로, “캐스트가 실패하면 안 되는” 상황에서 쓰면 버그가 조용히 기본값으로 가려질 수 있다는 점은 트레이드오프입니다.


명시적 타입 변환

명시적 변환

val int: Int = 10
val long: Long = int.toLong()
val double: Double = int.toDouble()
val string: String = int.toString()
// 모든 변환 메서드
val byte = int.toByte()
val short = int.toShort()
val float = int.toFloat()

큰 타입에서 작은 타입으로 줄이는 변환은 값을 잘라 냅니다. 300.toByte()는 에러 없이 44가 되고, 3.99.toInt()는 반올림이 아니라 버림이라 3입니다. 반올림이 필요하면 kotlin.math.roundToInt()를 씁니다. 또 Double을 toShort(), toByte()로 바로 줄이는 함수는 Kotlin 1.3부터 deprecated 되었으므로 toInt()를 거쳐 변환해야 합니다.

문자열 변환

// 문자열 → 숫자
val str = "123"
val num = str.toInt()
val decimal = str.toDouble()
// 안전한 변환
val safeNum = str.toIntOrNull() ?: 0

toInt()는 숫자가 아닌 문자열을 받으면 NumberFormatException: For input string: "abc"를 던집니다. 사용자 입력이나 외부 파일처럼 형식을 보장할 수 없는 값은 toIntOrNull()로 바꾸고 엘비스로 기본값을 주는 것이 Kotlin다운 방식이며, try-catch로 감싸는 것보다 짧고 의도가 분명합니다. 공백이 섞인 " 123"은 toIntOrNull()에서도 null이 되므로 trim()을 먼저 호출하는 것이 좋습니다.


is와 스마트 캐스트

is 연산자

val obj: Any = "Hello"
if (obj is String) {
    // 자동으로 String으로 캐스트
    println(obj.length)
}
// not is
if (obj !is String) {
    println("문자열이 아님")
}

when과 스마트 캐스트

fun describe(obj: Any): String {
    return when (obj) {
        is String -> "문자열, 길이: ${obj.length}"
        is Int -> "정수: $obj"
        is List<*> -> "리스트, 크기: ${obj.size}"
        else -> "알 수 없음"
    }
}

스마트 캐스트는 is 검사 뒤에 컴파일러가 “여기서는 이 값이 String일 수밖에 없다”고 판단하면 캐스트를 생략하게 해 줍니다. null 검사도 같아서 if (name != null) 블록 안에서는 String?이 String으로 취급됩니다. when의 is List<*>에서 <*>를 쓰는 이유는 타입 소거 때문에 is List<String>처럼 원소 타입까지는 런타임에 검사할 수 없기 때문입니다.

스마트 캐스트가 동작하지 않는 경우를 알아 두면 에러 메시지에 덜 당황합니다. 클래스의 var 프로퍼티는 검사한 직후 다른 스레드나 코드가 값을 바꿀 수 있으므로 Smart cast to 'String' is impossible, because 'name' is a mutable property that could have been changed by this time 에러가 납니다. 다른 모듈에 선언된 val이나 커스텀 getter가 있는 프로퍼티도 마찬가지입니다. 이럴 때는 val current = name 처럼 지역 변수에 한 번 담은 뒤 검사하거나, name?.let { ... }을 쓰면 해결됩니다.


const val, lateinit, lazy

const val

// 컴파일 타임 상수
const val MAX_SIZE = 100
const val API_KEY = "your-api-key"
// 최상위, object, companion object 안에서만 선언 가능
class Config {
    companion object {
        const val TIMEOUT = 5000
    }
}

const val과 일반 val의 차이는 값이 정해지는 시점입니다. const는 컴파일 시점에 값이 확정되어야 하므로 기본 타입과 String만 쓸 수 있고, 함수 호출 결과(val now = System.currentTimeMillis())는 담을 수 없습니다. 대신 컴파일러가 사용하는 곳에 값을 직접 박아 넣기 때문에 어노테이션 인자(@RequestMapping(BASE_PATH))로 쓸 수 있고, Java에서 Config.TIMEOUT으로 바로 접근할 수 있습니다. 일반 val을 companion object에 두면 Java에서는 Config.Companion.getTIMEOUT()처럼 보입니다. 값이 박혀 들어간다는 특성 때문에, 라이브러리의 const 값을 바꿨는데 그 라이브러리를 쓰는 쪽을 다시 컴파일하지 않으면 옛 값이 남는 경우가 있다는 점도 알아 두면 좋습니다. 예제의 API_KEY처럼 비밀 값을 소스 코드 상수로 두는 것은 저장소에 그대로 노출되므로 실제로는 환경 변수나 설정 파일에서 읽어야 합니다.

lateinit

// 나중에 초기화
class MyClass {
    lateinit var name: String
    
    fun init() {
        name = "홍길동"
    }
    
    fun isInitialized() = ::name.isInitialized
}

lateinit은 var에만, 그리고 Int·Boolean 같은 기본 타입이 아닌 non-null 타입에만 쓸 수 있습니다. 내부적으로 null을 “아직 초기화 안 됨” 표시로 쓰기 때문에, null을 담을 수 없는 JVM 기본 타입과는 함께 쓸 수 없습니다. 초기화 전에 읽으면 UninitializedPropertyAccessException: lateinit property name has not been initialized가 나며, 이는 String?로 선언하고 !!를 쓰는 것보다 에러 메시지가 명확하다는 장점이 있습니다. 의존성 주입 프레임워크나 테스트의 @BeforeEach처럼 “생성 직후 반드시 외부에서 채워지는” 프로퍼티에 적합합니다.

lazy

// 처음 사용 시 초기화
val heavyObject: HeavyObject by lazy {
    println("초기화 중...")
    HeavyObject()
}
// 처음 접근 시에만 초기화됨
println(heavyObject.data)

입력 처리, 안전한 계산, 변환 유틸리티 예제

예제 1: 사용자 입력 처리

fun main() {
    print("이름을 입력하세요: ")
    val name = readlnOrNull() ?: "Guest"

    print("나이를 입력하세요: ")
    val ageInput = readlnOrNull()
    val age = ageInput?.toIntOrNull() ?: 0
    
    println("안녕하세요, $name님! (${age}세)")
}

readlnOrNull()(Kotlin 1.6 이상, 예전 코드의 readLine()과 같음)은 입력 스트림이 끝나면 null을 반환하므로 String? 타입입니다. 파이프로 빈 입력을 넘기거나 IDE 콘솔에서 입력 없이 종료하면 null이 되고, 엘비스 연산자가 이 경우를 처리합니다. 한 가지 빈틈은 사용자가 그냥 Enter를 누르면 null이 아니라 빈 문자열 ""이 들어온다는 점입니다. 빈 이름을 “Guest”로 바꾸려면 readlnOrNull()?.takeIf { it.isNotBlank() } ?: "Guest"처럼 한 단계를 더 두어야 합니다.

예제 2: 안전한 계산

fun divide(a: Int, b: Int): Double? {
    return if (b != 0) {
        a.toDouble() / b
    } else {
        null
    }
}
fun main() {
    val result = divide(10, 0)
    println("결과: ${result ?: "0으로 나눌 수 없음"}")
}

예제 3: 타입 변환 유틸리티

fun parseNumber(input: String): Int {
    return input.toIntOrNull() ?: run {
        println("잘못된 입력: $input")
        0  // run 블록의 마지막 식이 반환값
    }
}
fun main() {
    println(parseNumber("123"))    // 123
    println(parseNumber("abc"))    // 0
}

run { } 블록은 마지막 식의 값을 반환합니다. 이 예제의 이전 버전은 블록이 println(...)으로 끝나서 Unit을 반환했고, 그 결과 Int를 반환해야 하는 함수에서 Type mismatch: inferred type is Unit but Int was expected 컴파일 에러가 났습니다. 이렇게 “로그를 남기고 기본값을 돌려주는” 패턴은 엘비스 연산자와 run의 조합으로 자주 쓰이는데, 블록 마지막 줄이 반환값이라는 규칙을 잊기 쉬우니 주의하세요. 예제 2의 divide처럼 실패를 null로 표현하면 호출한 쪽이 엘비스로 처리 방법을 고를 수 있고, 예외를 던지는 방식보다 흐름이 단순해집니다. 다만 실패 이유가 여러 가지라면 null 하나로는 구분할 수 없으므로 Result나 sealed class를 고려합니다.


변수와 타입 요약

  1. val: 불변 (권장)
  2. var: 가변
  3. Nullable: String?, ?., ?:, !!
  4. 타입 변환: toInt(), toString()
  5. 스마트 캐스트: is 연산자
  6. 지연 초기화: lateinit, lazy

다음 단계


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. lateinit과 by lazy는 언제 각각 쓰나요?

A. lateinit var는 생성 시점에는 값을 줄 수 없지만 의존성 주입이나 onCreate처럼 나중에 외부에서 대입되는 non-null 프로퍼티에 씁니다. 대입 전에 읽으면 UninitializedPropertyAccessException이 나므로 ::name.isInitialized로 확인할 수 있습니다. by lazy는 val에만 쓸 수 있고 처음 접근할 때 초기화 블록을 한 번 실행해 값을 캐시하므로, 만드는 비용이 큰 객체를 필요할 때까지 미루고 싶을 때 적합합니다.