코틀린 val과 var, 제대로 알고 써야 하는 이유

자바에서 코틀린(Kotlin)으로 넘어온 많은 개발자가 처음 마주하는 생소한 키워드가 바로 valvar입니다. 단순히 변수를 선언하는 키워드라고 생각하고 무심코 사용하기 쉽지만, 이 두 키워드에는 코틀린의 철학이 담겨 있으며, 어떻게 사용하느냐에 따라 코드의 안정성과 가독성, 나아가 전체 애플리케이션의 품질이 크게 달라질 수 있습니다. 저는 풀스택 개발자로서 다양한 프로젝트에서 코틀린을 사용하며 valvar의 올바른 사용이 얼마나 중요한지 수없이 체감했습니다. 이 글에서는 두 키워드의 단순한 차이점을 넘어, 불변성(Immutability)이라는 핵심 개념을 중심으로 왜 코틀린이 val 사용을 권장하는지, 그리고 실무에서 마주치는 다양한 상황에서 어떤 것을 선택해야 하는지에 대해 깊이 있게 파헤쳐 보겠습니다.

val과 var의 핵심 차이점: 불변성(Immutability) vs 가변성(Mutability)

가장 기본적인 차이점부터 시작하겠습니다. valvar를 구분하는 핵심은 '재할당 가능 여부'입니다.

val (Value): 재할당이 불가능한 읽기 전용 변수

val로 선언된 변수는 초기화된 이후에 다른 값이나 객체로 재할당할 수 없습니다. 이는 자바의 final 키워드와 유사한 개념입니다. 한 번 정해진 값은 그 생명주기 동안 절대 변하지 않는다는 것을 보장합니다.


fun main() {
    val name: String = "Kotlin" // 선언과 동시에 초기화
    println(name) // 출력: Kotlin

    // name = "Java" // 컴파일 에러 발생! Val cannot be reassigned.
}
컴파일 에러: 위 코드에서 주석 처리된 라인의 주석을 해제하면, IDE는 즉시 "Val cannot be reassigned"라는 에러를 표시하며 컴파일 자체를 막습니다. 이는 런타임에 발생할 수 있는 버그를 코딩 단계에서부터 원천적으로 차단하는 코틀린의 중요한 안전장치입니다.

var (Variable): 재할당이 가능한 일반 변수

반면 var로 선언된 변수는 언제든지 다른 값으로 재할당이 가능합니다. 우리가 전통적인 프로그래밍 언어에서 사용해 온 일반적인 변수와 동일합니다.


fun main() {
    var score: Int = 80 // score 변수를 80으로 초기화
    println(score) // 출력: 80

    score = 95 // score 변수에 새로운 값 95를 재할당
    println(score) // 출력: 95
}

이처럼 var는 변수의 상태가 계속해서 변경되어야 하는 경우에 유용하게 사용될 수 있습니다. 예를 들어, 사용자의 입력을 받거나, 반복문 내에서 카운터를 증가시키는 등의 작업에 적합합니다.

한눈에 보는 val vs var 비교

두 키워드의 특징을 표로 정리하면 다음과 같습니다.

구분 val (Value) var (Variable)
의미 값(Value), 불변(Immutable) 변수(Variable), 가변(Mutable)
재할당 불가능 (한 번만 할당 가능) 가능 (여러 번 재할당 가능)
Java 키워드 비유 final 일반 변수 (final 없음)
핵심 특징 읽기 전용 (Read-only) 읽기/쓰기 가능 (Read-write)
주요 사용처 상수, 변경되지 않는 데이터, 함수형 프로그래밍 스타일 카운터, 상태 값, 변경이 필요한 모델 속성
컴파일러 관점 컴파일 시점에 값이 변경되지 않음을 보장 값이 변경될 수 있음을 인지하고 추적

왜 코틀린은 `val` 사용을 적극 권장할까?

코틀린 공식 문서와 이펙티브 코틀린(Effective Kotlin)과 같은 권위 있는 자료들은 한결같이 "가능한 모든 곳에 val을 사용하라(Prefer val over var)"고 강조합니다. 단순히 코드를 짧게 만드는 것을 넘어, 불변성을 지향하는 것이 더 안전하고 예측 가능한 소프트웨어를 만드는 핵심 원칙이기 때문입니다.

1. 부수 효과(Side Effect) 최소화

부수 효과란, 함수가 결과값을 반환하는 것 외에 외부 상태를 변경하는 것을 의미합니다. 소프트웨어 공학

var로 선언된 변수는 코드의 어느 곳에서든 값이 변경될 수 있습니다. 특히 이 변수가 클래스의 속성이거나 전역 변수일 경우, 여러 함수나 스레드에서 이 변수에 접근하여 값을 바꾸게 되면 예기치 않은 버그의 원인이 됩니다.


var totalScore = 0 // 여러 곳에서 접근 가능한 가변 상태

fun addScore(score: Int) {
    // 어떤 로직...
    totalScore += score // 외부 상태를 직접 변경 (부수 효과 발생!)
}

fun resetScoreBasedOnEvent() {
    // 특정 이벤트 발생 시 점수 초기화
    totalScore = 0 // 외부 상태를 직접 변경 (부수 효과 발생!)
}

위 코드에서 totalScore는 언제, 어디서, 왜 바뀌었는지 추적하기가 매우 어렵습니다. 반면 val을 사용하면 애초에 이런 변경 가능성을 차단하여 코드를 훨씬 단순하고 명확하게 만듭니다.


// 부수 효과가 없는 함수 설계
fun calculateNewTotalScore(currentTotal: Int, scoreToAdd: Int): Int {
    return currentTotal + scoreToAdd // 외부 상태를 바꾸지 않고, 새로운 결과를 반환
}

fun main() {
    val initialScore = 0
    val scoreAfterFirstGame = calculateNewTotalScore(initialScore, 100)
    val scoreAfterSecondGame = calculateNewTotalScore(scoreAfterFirstGame, 150)

    println("최종 점수: $scoreAfterSecondGame") // 최종 점수: 250
}

이처럼 불변성을 유지하면 데이터의 흐름이 단방향으로 명확해지고, 각 함수는 독립적으로 테스트하기 쉬워집니다. 이것이 바로 함수형 프로그래밍 패러다임의 핵심 사상이며, 코틀린이 강력하게 지지하는 방식입니다.

2. 코드의 예측 가능성 및 가독성 향상

코드를 읽을 때, 변수가 val로 선언되어 있다면 "아, 이 변수는 여기서 초기화된 후 절대 바뀌지 않겠구나"라고 안심하고 코드를 분석할 수 있습니다. 반면 var를 보면 "이 변수는 어디선가 바뀔 수 있으니 끝까지 추적해야 한다"는 부담감을 갖게 됩니다. 불필요한 var의 남용은 코드의 복잡도를 높이고, 디버깅을 어렵게 만드는 주범입니다.

3. 스레드 안전성(Thread Safety) 확보

멀티스레드 환경(예: 안드로이드 앱, 서버 백엔드)에서는 여러 스레드가 동시에 공유 데이터에 접근할 때 심각한 문제가 발생할 수 있습니다. 이를 '경쟁 상태(Race Condition)'라고 합니다.

만약 공유 데이터가 var로 선언된 가변 상태라면, 여러 스레드가 동시에 값을 읽고 쓰는 과정에서 데이터가 깨지거나 잘못된 결과가 나올 수 있습니다. 이를 막기 위해 synchronized 블록이나 락(Lock) 같은 복잡한 동기화 메커니즘을 사용해야 합니다.

하지만 공유 데이터가 val로 선언된 불변 객체라면 어떨까요? 여러 스레드가 동시에 읽기만 할 뿐, 아무도 데이터를 변경할 수 없으므로 동기화 문제가 원천적으로 발생하지 않습니다. 즉, 불변 객체는 태생적으로 스레드에 안전(Inherently Thread-safe)합니다. 이것이 코틀린을 사용한 서버 개발이나 동시성 프로그래밍에서 val이 특히 중요한 이유입니다.

val과 var, 언제 어떻게 사용해야 할까? (실전 가이드)

이론적인 장점들을 알았으니, 이제 실제 코딩에서 적용할 수 있는 구체적인 가이드를 살펴보겠습니다.

규칙 1: 무조건 `val`로 시작하라 (Default to `val`)

가장 중요한 원칙입니다. 변수를 선언할 때는 습관적으로 val을 먼저 사용하세요. 그리고 코드를 작성하다가 정말로 재할당이 필요한 시점이 오면, 그때 var로 변경해도 늦지 않습니다. IDE가 "Val cannot be reassigned" 에러를 보여줄 때가 바로 var로 변경할지, 아니면 코드 구조를 바꿀지 고민해야 할 시점입니다.

프로 개발자의 습관: 숙련된 코틀린 개발자는 변수를 선언할 때 거의 무의식적으로 val을 타이핑합니다. 이는 단순히 타이핑을 줄이는 것을 넘어, 불변성을 기본으로 사고하는 프로그래밍 습관이 몸에 밴 결과입니다.

규칙 2: `var`가 필요한 명확한 경우들

물론 모든 변수를 val로 만들 수는 없습니다. var가 반드시 필요한 상황도 존재합니다.

  • 반복문의 카운터 또는 상태 변수: while 루프처럼 외부 조건에 따라 반복을 제어해야 할 때 카운터 변수는 var로 선언해야 합니다.
    
            var i = 0
            while (i < 10) {
                println(i)
                i++ // i가 var가 아니면 이 코드는 불가능
            }
            
  • 나중에 초기화해야 하는 속성(Late-initialized properties): 클래스의 속성 중 생성자에서는 초기화할 수 없지만, 나중에 반드시 초기화될 것이 보장되는 경우 lateinit var를 사용합니다. (예: 의존성 주입)
    
            class MyService {
                lateinit var dependency: AnotherService // 나중에 주입될 의존성
    
                fun initialize(dependency: AnotherService) {
                    this.dependency = dependency
                }
            }
            
  • 변경이 필요한 데이터 모델의 속성: 사용자의 프로필 정보처럼, 객체가 생성된 이후에도 속성 값이 변경되어야 하는 경우 해당 속성은 var로 선언할 수 있습니다.
    
            data class UserProfile(
                val id: Long, // ID는 불변
                var nickname: String, // 닉네임은 변경 가능
                var email: String // 이메일도 변경 가능
            )
            

규칙 3: `var`를 사용하기 전, 다른 대안을 먼저 고민하라

상태 변경이 필요해 보일 때 무조건 var를 쓰기 전에, 불변성을 유지하면서 동일한 목적을 달성할 방법은 없는지 생각해보는 것이 좋습니다. 코틀린은 이를 위한 강력한 도구들을 제공합니다.

예를 들어, 위 UserProfile 객체의 닉네임을 변경해야 할 때, var 속성을 직접 수정하는 대신 코틀린의 data class가 제공하는 copy() 메서드를 활용할 수 있습니다.


// 모든 속성이 val인 불변 데이터 클래스
data class ImmutableUserProfile(
    val id: Long,
    val nickname: String,
    val email: String
)

fun main() {
    val user = ImmutableUserProfile(1L, "kotlin_lover", "hello@kt.com")

    // 닉네임 변경이 필요할 때, 기존 객체를 수정하는 대신
    // nickname만 바뀐 새로운 복사본 객체를 생성한다.
    val updatedUser = user.copy(nickname = "pro_kotlin_dev")

    println("기존 사용자: $user")
    println("업데이트된 사용자: $updatedUser")
}

이 방식은 원본 객체의 불변성을 그대로 유지하면서 상태 변경을 표현할 수 있어 훨씬 안전하고 예측 가능합니다. React나 Jetpack Compose 같은 선언형 UI 프레임워크에서는 이러한 패턴이 상태 관리의 핵심적인 역할을 합니다.

`val`은 정말 '불변'일까? 참조 불변과 상태 불변의 함정

여기서 많은 초보 개발자들이 혼란을 겪는 중요한 지점이 있습니다. val은 '재할당'이 불가능하다는 것이지, val이 가리키는 객체의 '내부 상태'가 변하지 않는다는 것을 보장하지는 않습니다.

val은 참조의 불변성(Read-only Reference)을 의미하며, 객체 자체의 불변성(Immutable Object)을 의미하는 것은 아닙니다.

이게 무슨 의미일까요? 예를 들어, val로 가변 컬렉션(Mutable Collection)인 mutableListOf를 선언해 보겠습니다.


fun main() {
    val numbers = mutableListOf("one", "two", "three")

    // numbers = mutableListOf("four", "five") // 컴파일 에러! val이므로 다른 리스트로 재할당 불가

    // 하지만 numbers가 가리키는 리스트 객체 '내부'의 상태는 변경 가능하다.
    numbers.add("four")
    numbers.remove("one")
    
    println(numbers) // 출력: [two, three, four]
}

위 코드에서 numbers 변수 자체는 다른 리스트를 가리키도록 바꿀 수 없습니다. 즉, 참조가 불변입니다. 하지만 그 참조가 가리키고 있는 MutableList 객체는 내부적으로 원소를 추가하거나 삭제할 수 있는 '가변 객체'이므로, 그 상태는 얼마든지 변경될 수 있습니다.

진정한 불변성을 원한다면, val과 함께 불변 컬렉션(Immutable Collection)인 listOf, setOf, mapOf 등을 사용해야 합니다.


fun main() {
    // val + 불변 컬렉션(listOf) = 진정한 불변성
    val immutableNumbers = listOf("one", "two", "three")

    // immutableNumbers.add("four") // 컴파일 에러! listOf는 add, remove 같은 수정 메서드를 제공하지 않음
}

`val`/`var`와 가변/불변 컬렉션 조합 비교

선언 설명 재할당 (=) 내부 상태 변경 (.add())
val list = listOf(...) 가장 권장되는 형태. 참조도 불변, 객체도 불변. X X
val list = mutableListOf(...) 참조는 불변이지만, 객체의 내용은 변경 가능. (위의 함정 예제) X O
var list = listOf(...) 리스트 내용은 못 바꾸지만, 다른 불변 리스트로 교체(재할당) 가능. O X
var list = mutableListOf(...) 가장 유연하지만 가장 위험한 형태. 참조도, 객체 내용도 모두 변경 가능. O O
주의: 특히 팀 단위 프로젝트에서 API의 반환 타입이나 클래스의 공개 속성을 정의할 때, List, Set, Map과 같은 불변 인터페이스 타입으로 노출하는 것이 매우 중요합니다. 이는 외부에서 컬렉션의 내부 상태를 임의로 변경하는 것을 막아주는 훌륭한 방어막이 됩니다.

코틀린 기본 문법 속 `val`과 `var` 활용 예제

이제 valvar의 개념을 코틀린의 다른 기본 문법과 결합하여 어떻게 활용할 수 있는지 살펴보겠습니다.

함수(fun)와 함께 사용하기

함수의 파라미터는 기본적으로 val입니다. 함수 내부에서 파라미터 값을 재할당할 수 없으며, 이는 함수가 외부 상태에 미치는 영향을 최소화하는 데 도움을 줍니다.


// a와 b는 암묵적으로 val
fun sum(a: Int, b: Int): Int {
    // a = 10 // 컴파일 에러! 파라미터는 재할당 불가
    return a + b
}

조건문(if, when)을 표현식(Expression)으로 활용하기

코틀린에서 ifwhen은 문장(Statement)이 아닌 표현식(Expression)으로 사용될 수 있습니다. 즉, 값을 반환할 수 있다는 뜻입니다. 이를 이용하면 복잡한 조건에 따른 값을 val 변수에 간결하게 할당할 수 있어 var의 사용을 줄일 수 있습니다.


fun getGrade(score: Int): String {
    // if-else 표현식을 사용해 결과를 val 변수에 바로 할당
    val grade = if (score >= 90) {
        "A"
    } else if (score >= 80) {
        "B"
    } else {
        "C"
    }
    return grade
}

fun describe(obj: Any): String {
    // when 표현식을 사용해 결과를 val 변수에 할당
    val description = when (obj) {
        1 -> "One"
        "Hello" -> "Greeting"
        is Long -> "Long"
        !is String -> "Not a string"
        else -> "Unknown"
    }
    return description
}

자바였다면, gradedescription 변수를 var로 선언하고 각 조건 분기마다 값을 할당해야 했을 겁니다. 코틀린의 표현식 지향 문법은 불변성을 유지하는 데 큰 도움을 줍니다.

반복문(for, while)과 함께 사용하기

for 루프에서 각 아이템을 받는 변수는 루프가 돌 때마다 새로운 값이 할당되는 것이므로 자연스럽게 val로 취급됩니다.


val items = listOf("apple", "banana", "kiwi")
for (item in items) { // item은 암묵적으로 val
    // item = "orange" // 컴파일 에러!
    println(item)
}

앞서 본 것처럼, while 루프의 카운터처럼 명시적으로 상태를 변경해야 할 때는 var가 사용됩니다.

결론: 불변성을 향한 첫걸음, `val`과 `var` 제대로 사용하기

코틀린에서 valvar는 단순히 변수를 선언하는 두 가지 방법이 아닙니다. 이것은 코드를 작성하는 개발자의 사고방식, 즉 '이 데이터는 변해야 하는가, 아니면 변하지 않아야 하는가?'라는 근본적인 질문에 답하는 과정입니다.

`val`을 기본으로 사용하고, 재할당이 꼭 필요한 경우에만 신중하게 `var`를 선택하는 습관은 여러분의 코틀린 코드를 더욱 안전하고, 예측 가능하며, 동시성 문제로부터 자유롭게 만들어 줄 것입니다. 또한, '참조 불변성'과 '상태 불변성'의 차이를 명확히 이해하고 불변 컬렉션을 적절히 활용한다면, 더욱 견고한 애플리케이션을 설계하는 훌륭한 밑거름이 될 것입니다. 이 글이 여러분의 코틀린 여정에 튼튼한 디딤돌이 되기를 바랍니다.

Post a Comment