시스템아 미안해
Chapter 1: Safety - Item 4: Minimize the scope of variables 본문
Chapter 1: Safety - Item 4: Minimize the scope of variables
if else 2026. 1. 22. 16:08상태를 정의할 때에는, 다음과 같은 방식으로 변수와 프로퍼티의 범위(scope)를 최대한 좁히는 것이 바람직합니다.
- 프로퍼티 대신 지역 변수(local variable) 를 사용합니다.
- 변수를 가능한 한 가장 좁은 범위에서 사용합니다.
예를 들어, 어떤 변수가 특정 반복문 안에서만 사용된다면, 그 반복문 내부에서 변수를 정의합니다.
범위(scope) 란, 프로그램에서 해당 요소에 접근할 수 있는 영역을 의미합니다.
Kotlin에서는 대부분의 경우, 중괄호 {}가 범위를 생성합니다.
기본 규칙은 다음과 같습니다.
같은 범위(scope)와 그 바깥 범위(outer scope)에 있는 요소에는 접근할 수 있습니다.
다음 예제를 한 번 살펴보시기 바랍니다.
val a = 1
fun fizz() {
val b = 2
print(a + b)
}
val buzz = {
val c = 3
print(a + c)
}
// Here we can see a, but not b nor c
위 예제에서, fizz와 buzz 함수의 scope 내에서는 외부 scope의 변수에 접근할 수 있습니다. 하지만 외부 scope에서는 이러한 함수들 내에서 정의된 변수에 접근할 수 없습니다. 변수의 scope를 제한하는 방법의 예시는 다음과 같습니다:
// Bad
var user: User
for (i in users.indices) {
user = users[i]
print("User at $i is $user")
}
// Better
for (i in users.indices) {
val user = users[i]
print("User at $i is $user")
}
// Same variables scope, nicer syntax
for ((i, user) in users.withIndex()) {
print("User at $i is $user")
}
첫 번째 예제에서는 user 변수가 for-loop의 범위 안뿐만 아니라, 그 밖에서도 접근 가능합니다.
반면 두 번째와 세 번째 예제에서는 user 변수의 범위를 명확하게 for-loop 내부로 제한하고 있습니다.
이와 비슷하게, 범위 안에 또 다른 범위가 중첩되는 경우도 자주 있습니다
(대부분은 람다 표현식 안에 또 다른 람다 표현식이 있는 형태로 만들어집니다).
그럼에도 불구하고, 변수는 가능한 한 가장 좁은 범위에서 정의하는 것이 바람직합니다.
이렇게 하는 데에는 여러 이유가 있지만, 가장 중요한 이유는 다음과 같습니다.
변수의 범위를 좁히면, 프로그램을 추적하고 관리하기가 훨씬 쉬워집니다.
코드를 분석할 때 우리는 “이 시점에 어떤 요소들이 존재하는가”를 계속 생각해야 하는데,
다뤄야 할 요소가 많아질수록 프로그래밍은 그만큼 어려워집니다.
애플리케이션이 단순할수록, 깨질 가능성도 줄어듭니다.
이는 우리가 가변 객체보다 불변 객체를 선호하는 이유와도 같습니다.
가변 프로퍼티가 아주 제한된 범위에서만 변경될 수 있다면,
그 변화 과정을 추적하기가 훨씬 쉽고,
동작을 이해하거나 수정하기도 수월해집니다.
또 하나의 문제는, 범위가 넓은 변수는 다른 개발자에 의해 남용될 가능성이 크다는 점입니다.
예를 들어, 어떤 변수가 반복문에서 다음 요소를 할당하는 데 사용되고 있다면,
반복문이 끝난 뒤에도 그 변수에는 마지막 요소가 남아 있을 것이라고 추론할 수 있습니다.
이런 추론은 반복문 이후에 그 변수를 사용해 마지막 요소로 무언가를 처리하는 식의
아주 나쁜 사용 방식으로 이어질 수 있습니다.
이렇게 되면, 나중에 그 값을 이해하려는 다른 개발자는
전체 맥락과 추론 과정을 모두 파악해야만 하게 되고,
이는 전혀 필요 없는 복잡성을 초래합니다.
변수가 읽기 전용이든(read-only), 읽기/쓰기 가능하든(read-write) 상관없이,
우리는 항상 정의되는 시점에 초기화되기를 선호합니다.
다른 개발자가 “이 변수가 어디에서 초기화되었지?”를 찾아다니게 만들지 마십시오.
이러한 방식은 if, when, try-catch 같은 제어 구조나,
표현식으로 사용되는 엘비스 연산자(?:) 를 통해 충분히 지원할 수 있습니다.
// Bad
val user: User
if (hasValue) {
user = getValue()
} else {
user = User()
}
// Better
val user: User = if (hasValue) {
getValue()
} else {
User()
}
여러 속성을 설정해야 하는 경우, destructuring declaration이 도움이 될 수 있습니다:
// Bad
fun updateWeather(degrees: Int) {
val description: String
val color: Int
if (degrees < 5) {
description = "cold"
color = Color.BLUE
} else if (degrees < 23) {
description = "mild"
color = Color.YELLOW
} else {
description = "hot"
color = Color.RED
}
// ...
}
// Better
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
}
// ...
}
마지막으로, 너무 넓은 변수 scope는 위험할 수 있습니다. 한 가지 위험을 설명하겠습니다.
Capturing
제가 Kotlin 코루틴을 가르칠 때 사용하는 연습 문제 중 하나는,
시퀀스 빌더(sequence builder) 를 이용해 에라토스테네스의 체(Sieve of Eratosthenes) 알고리즘을 구현하여 소수를 찾는 것입니다.
이 알고리즘은 개념적으로 매우 단순합니다.
- 2부터 시작하는 숫자들의 목록을 준비합니다.
- 그중 첫 번째 숫자를 선택합니다. 이 숫자는 소수입니다.
- 나머지 숫자들 중에서, 방금 선택한 숫자 자신을 제외하고
해당 소수로 나누어떨어지는 모든 숫자를 걸러냅니다.
이 알고리즘을 매우 단순하게 구현하면 다음과 같은 형태가 됩니다.
var numbers = (2..100).toList()
val primes = mutableListOf<Int>()
while (numbers.isNotEmpty()) {
val prime = numbers.first()
primes.add(prime)
numbers = numbers.filter { it % prime != 0 }
}
print(primes) // [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31,
// 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97]
이 과제의 핵심은 소수를 잠재적으로 무한히 생성하는 시퀀스를 만들도록 구현하는 데 있습니다.
도전해 보고 싶으시다면, 여기서 잠시 멈추고 직접 구현해 보셔도 좋습니다.
다음은 이 문제에 대한 가능한 해결 방법의 한 예시입니다.
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을 가변 변수(mutable variable) 로 바깥으로 꺼내는 방식을 시도합니다.
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 }
}
}
문제는 이 구현이 더 이상 올바르게 작동하지 않는다는 것입니다. 다음은 yield된 첫 10개의 숫자입니다:
print(primes.take(10).toList())
// [2, 3, 5, 6, 7, 8, 9, 10, 11, 12]
이 결과가 왜 나오는지, 잠시 멈춰서 설명해 보시기 바랍니다.
이러한 결과가 발생하는 이유는 primes생성 시점에 prime 변수를 캡처(capture)했기 때문입니다.
시퀀스를 사용하고 있기 때문에 primes 동작이나 필터링은 지연(lazy) 방식으로 수행됩니다.
즉, 매 단계마다 필터가 즉시 실행되는 것이 아니라, primes.take에서 필터가 하나씩 계속 추가됩니다.
문제가 되는 “최적화된” 버전에서는,
추가되는 모든 필터가 가변 프로퍼티인 prime을 참조하고 있습니다.
그 결과, 필터는 항상 현재 시점의 prime 값, 즉 마지막으로 설정된 값만을 기준으로 동작하게 됩니다.
이 때문에 필터링이 제대로 작동하지 않습니다.
실제로 정상적으로 동작하는 것은 drop 단계뿐이며,
그 결과 연속된 숫자들만 남게 됩니다
(단, prime이 아직 2였던 시점에 걸러진 4만은 예외로 제거됩니다).
이 사례는 의도하지 않은 변수 캡처가 얼마나 위험한지를 잘 보여줍니다.
이러한 문제를 예방하려면,
- 가변성을 피하고
- 변수의 범위를 최대한 좁게 유지하는 것
을 항상 우선적으로 고려해야 합니다.
'책 > Effective Kotlin' 카테고리의 다른 글
| Chapter 1: Safety - Item 6: Prefer standard errors to custom ones (0) | 2026.01.22 |
|---|---|
| Chapter 1: Safety - Item 5: Specify your expectations for arguments and state (0) | 2026.01.22 |
| Chapter 1: Safety - Item 3: Eliminate platform types as soon as possible (0) | 2026.01.22 |
| Chapter 1: Safety - Item 2: Eliminate critical sections (0) | 2026.01.22 |
| Chapter 1: Safety - Item 1: Limit mutability (0) | 2026.01.21 |