한 줄 정의
상태는 프로퍼티보다 지역 변수로, 선언은 실제로 쓰이는 가장 좁은 스코프에 두고 선언과 동시에 초기화해야 추적 가능한 코드가 됩니다.
쉽게 말하면
변수를 선언하는 일은 값을 어디에 적어 둘지 고르는 일입니다. 회의실 한복판의 공용 화이트보드 에 적으면 누구나 언제든 볼 수 있어 편하지만, 중간에 들어온 사람은 그 숫자가 언제 적힌 것인지도, 누가 고쳐 쓴 것인지도 알 수 없습니다. 반면 그 자리에서 쓰고 버리는 포스트잇은 수명이 한눈에 보입니다.
더 고약한 경우는 화이트보드의 값을 “나중에 확인하겠다”고 약속해 둔 사람이 여럿일 때입니다. 각자 약속한 시점의 값이 아니라 마지막으로 덮어쓰인 값 하나를 모두가 함께 읽게 됩니다. 뒤에서 다루는 캡처링 문제가 정확히 이 모양입니다.
왜 중요한가?
스코프는 어떤 요소를 볼 수 있는 프로그램의 영역입니다. 코틀린은 중괄호로 스코프를 만들고, 안쪽에서는 자신의 스코프와 외부 스코프의 요소에 접근할 수 있습니다.
val a = 1
fun fizz() {
val b = 2
print(a + b)
}
val buzz = {
val c = 3
print(a + c)
}
// 여기서 a는 사용할 수 있으나, b와 c는 사용이 불가능합니다.스코프를 좁히는 이유는 성능이 아니라 추적 가능성 입니다. 코드를 읽을 때는 “지금 이 지점에 살아 있는 요소가 무엇인가”를 머릿속에 유지해야 하고, 그 목록이 길어질수록 판단이 어려워집니다. 가변 프로퍼티보다 불변을 선호하는 이유와 같습니다.
또 하나는 남용 가능성입니다. 넓은 스코프의 변수는 반복문이 끝난 뒤에도 마지막 값을 들고 있으므로, 다른 개발자가 그 값으로 뒤처리를 하려는 코드를 붙이게 됩니다. 그 값이 어떤 의미인지 알려면 스코프 전체를 읽어야 하고, 상태는 불필요하게 복잡해집니다.
핵심 내용
좁은 스코프로 옮기기
반복문에서만 쓰이는 값을 바깥에 선언하면 반복문 밖에서도 접근할 수 있게 됩니다.
// 나쁜 예
var user: User
for (i in users.indices) {
user = users[i]
print("User at $i is $user")
}
// 좋은 예
for (i in users.indices) {
val user = users[i]
print("User at $i is $user")
}
// 동일한 변수 스코프에 더 나은 구문
for ((i, user) in users.withIndex()) {
print("User at $i is $user")
}아래 두 방식은 user 를 정확히 반복문 스코프로 제한합니다. 람다 안의 람다처럼 중첩된 스코프가 생기는 경우도 있지만, 가능하면 좁은 단일 스코프 에서 정의하는 편이 낫습니다.
선언과 동시에 초기화
읽기 전용이든 아니든 변수는 선언할 때 초기화합니다. 값이 어디에서 정해지는지 찾아 헤매게 만들지 않는 것이 목적입니다. if · when · try-catch 같은 제어 구조와 엘비스 연산자는 모두 식이므로 초기화에 그대로 쓸 수 있습니다.
// 나쁜 예
val user: User
if (hasValue) {
user = getValue()
} else {
user = User()
}
// 좋은 예
val user: User = if (hasValue) {
getValue()
} else {
User()
}여러 값을 한꺼번에 정해야 하면 구조 분해 선언(destructuring declaration)으로 묶습니다. 분기마다 두 변수를 따로 대입하던 코드가 한 번의 선언으로 줄어듭니다.
// 좋은 예
fun updateWeather(degrees: Int) {
val (description, color) = when {
degrees < 5 -> "cold" to Color.BLUE
degrees < 23 -> "mild" to Color.YELLOW
else -> "hot" to Color.RED
}
// ...
}캡처링
에라토스테네스의 체는 2부터 시작하는 숫자에서 맨 앞을 소수로 취하고, 남은 숫자 중 그 소수의 배수를 제거하는 것을 반복하는 알고리즘입니다. 이를 무한 시퀀스로 구현하면 다음과 같습니다.
val primes: Sequence<Int> = sequence {
var numbers = generateSequence(2) { it + 1 }
while (true) {
val prime = numbers.first()
yield(prime)
numbers = numbers.drop(1)
.filter { it % prime != 0 }
}
}
print(primes.take(10).toList())
// [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]여기서 반복문마다 변수가 새로 생기는 것을 피하려고 prime 을 바깥의 가변 변수로 빼면, 결과가 소수가 아니게 됩니다.
val primes: Sequence<Int> = sequence {
var numbers = generateSequence(2) { it + 1 }
var prime: Int
while (true) {
prime = numbers.first()
yield(prime)
numbers = numbers.drop(1)
.filter { it % prime != 0 }
}
}
print(primes.take(10).toList())
// [2, 3, 5, 6, 7, 8, 9, 10, 11, 12]filter 람다가 prime 을 캡처 했기 때문입니다. 시퀀스는 지연 평가되므로 필터는 즉시 실행되지 않고 파이프라인에 쌓이기만 합니다. 반복이 진행될수록 필터가 계속 추가되는데, 가변 변수를 캡처한 쪽은 모든 필터가 같은 변수 하나를 가리키므로 결국 전부 prime 의 마지막 값으로 판정합니다. 필터가 제구실을 못 하고 drop 만 동작하니 연속된 숫자가 흘러나옵니다. 4가 빠진 이유는 prime 이 3일 때 이미 drop 으로 사라졌기 때문입니다.
캡처는 값이 아니라 변수를 붙잡습니다
루프 안에서
val로 선언하면 반복마다 새 변수가 만들어져 각 람다가 그 시점의 값을 붙잡습니다. 바깥의var하나를 공유하면 람다들은 “지금 값”이 아니라 “나중에 읽을 변수”를 붙잡습니다.
비교 / 트레이드오프
반복문 안의 지역 변수를 바깥으로 빼는 것은 최적화처럼 보이지만 실제로 얻는 것이 없습니다.
| 선언 위치 | 반복마다 생기는 것 | 실제 이득 | 위험 |
|---|---|---|---|
루프 안 val | 반복별 새 변수 (스택 슬롯 재사용, 캡처 시에만 객체 생성) | 각 람다가 그 시점 값을 고정 | 없음 |
루프 밖 var | 없음 | 사실상 없음 | 람다가 변수를 공유해 마지막 값으로 동작, 루프 뒤에도 값이 살아남 |
내 생각
- 지연 평가와 비동기 경계가 캡처 사고의 실전 무대입니다. 시퀀스뿐 아니라
@Async·CompletableFuture· 코루틴launch에 루프 변수를 넘길 때도 같은 함정이 생기므로, 루프 안에서val로 스냅샷을 떠서 넘기는 것이 기본입니다. - 스코프 최소화는 스레드 안전과 같은 얘기입니다. Spring 빈은 기본이 싱글톤이므로 요청별 상태를 필드로 올리는 순간 모든 요청이 그 값을 공유합니다. 상태를 메서드 지역 변수로 내리는 것만으로 경합이 사라집니다.
if·when표현식 초기화는lateinit과 널 가능var남용을 줄여 줍니다. “선언 먼저, 대입은 나중”이 필요해 보일 때는 보통 그 블록 자체를 식으로 바꿀 수 있는지 먼저 확인할 만합니다.
관련 개념
- 아이템 01 가변성을 제한하라 — 변경 지점을 줄이는 쪽에서 같은 문제를 다룹니다