Better Kotlin — 범위 제한

Jaeho Choe
6 min readFeb 16, 2021

--

Summary
변수는 가급적 좁은 범위에서 사용해야 합니다.

능숙한 개발자거나 Effective Java를 읽어봤거나 주변에 좋은 동료가 있다면 변수의 범위는 항상 최소화해야 한다는걸 알고 있을 것입니다. 그 이유는 왜일까요? 사실 변수의 범위가 넓으면 사용하는데 편리합니다(거짓). 어디서든 변수에 접근 할 수 있으면 프로그램을 작성하는데 고민할 거리가 줄어들죠. 하지만 동시에 변수의 값이 변할 수 있는 지점이 많아 지면서 관리 비용이 늘고 그 값을 제대로 제어하지 못했을 경우 의도하지 않은 값으로 파멸을 맞이할 수 있습니다. 반대로 변수의 범위를 최소화한다면? 변수의 변경 지점이 적어 코드는 추적하기 쉬워지고 코드를 읽는것도 보다 쉬워집니다. 앞서 살펴봤던 Better Kotlin — 가변성 제약 에서 다뤘던 불변객체가 가변 객체보다 매력적인 것과 유사한 이유죠. 아무래도 우리 삶이나 코드나 심플한것이 좋은건 마찬가지 인것 같습니다. 그럼 넓은 범위를 갖는 변수가 왜 위험한지 하나의 예를 살펴보겠습니다.

변수의 범위는 요만큼 작게

변수의 캡쳐

이 예를 진행 하기 앞서서 Kotlin의 Sequences 에 대한 기본적인 이해가 필요합니다.

- Kotlin의 Collections 는 람다를 인자로 받는 확장함수들(filter(), map() 등등)이 새로운 Collections 을 반환합니다. 즉 일반적으로 Collections을 다룰때 다양한 확장함수를 chain call 한다는 점을 고려해보면 불필요한 객체 생성이라는 비효율성이 존재합니다.

- SequencesJava8의 stream 과 비슷한 개념으로 둘 다 lazy evaluation 으로 처리됩니다.

- Sequences 는 아래와 같은 방식으로 생성할 수 있습니다.

1.Collections.asSequence()
2. generateSequence()
3. yield()
4. sequenceOf()

Sequences 로 소수를 구하는 코드는 다음과 같이 작성할 수 있습니다.

val primes = sequence {
var numbers = generateSequence(2) { it + 1 }
while (true) {
val prime = numbers.first()
yield(prime)
numbers = numbers.drop(1).filter { number ->
number % prime != 0
}
}
}
primes.take(5) // [2, 3, 5, 7, 11]

위 코드는 아래와 같이 동작합니다.

  1. 첫 소수인 2부터 1씩 증가하는 시퀀스를 생성합니다.
  2. while 문에서 시퀀스의 첫 값을 prime 으로 선언하여 yield() 함수로 primes 시퀀스에 보내고 처음 생성했던 시퀀스에서 첫 값을 뺀 시퀀스를 생성하여 prime 으로 나눠 떨어지는 값들을 제외합니다.
  3. 2번 과정을 반복합니다.

이 함수에서 prime 변수를 매번 선언하는 것이 효율적이지 못하다고 판단하여 while 문 밖으로 옮기면 어떻게 될까요?

val primes = sequence {
var numbers = generateSequence(2) { it + 1 }
var prime = 0
while (true) {
prime = numbers.first()
yield(prime)
numbers = numbers.drop(1).filter { number ->
number % prime != 0
}
}
}
primes.take(5) // [2, 3, 5, 6, 7]

결과가 위의 경우와 다릅니다. 이유는 Sequences 의 filter() 가 지연된 연산을 수행하여 실제 filter 람다 안의 코드가 수행될때 while 문을 돌며 변한 prime 값을 참조하게 되기 때문입니다.

Java의 람다가 final 값만 캡쳐할 수 있던 것과 다르게 Kotlin의 람다는 var 변수도 캡쳐가 가능합니다. val 값은 람다의 코드에 값과 함께 저장되지만 var 변수는 값을 래퍼로 감싸고 그 래퍼를 람다의 코드가 참고합니다. 보다 자세한 내용은 여길 참고하세요.

이 경우는 Sequencesfilter() 함수 동작 방식과 var, val 의 캡쳐되는 방식에 따라서 발생한 불운의 결과이지만 변수의 범위를 반복문 안으로 최소화 했다면 이런 불운을 모두 피해갈 수 있었습니다. 그만큼 변수의 범위를 최소화 하는 것은 중요한데요, 자 그럼 변수의 범위를 좁히는데 필요한 올바른 습관에 대해 알아볼까요?

properties 대신 지역 변수를 사용합니다.

이건 사실 어렵지 않습니다.

// bad
var weight: Int = 0
fun eat() {
weight += 1
}
fun diet() {
weight -= 1
}
// better
fun eat(weight: Int) = weight += 1
fun diet(weight: Int) = weight -= 1

위에 정의된 두개의 eat()diet() 함수는 모두 weight 값을 1씩 증감 하지만 bad case 에 경우 eat(), diet() 함수가 아닌 다른 곳에서도 weight 값을 바꿀 수 있을 뿐 아니라 weight 값을 동기화 시키는 데에도 어려움이 있습니다. 만약 eat() 과 diet() 가 멀티 쓰레드 환경에서 반복적으로 호출될 경우 값의 변화를 추적하는 일은 기분 좋은 경험은 아닐것입니다.

가능한 좁은 범위에서 변수를 사용합니다.

지역 변수 내에서도 항상 가능한 좁은 범위에서 변수를 사용해야합니다. 가령 변수가 반복문 내에서만 사용하게 된다면? 변수는 반복문 안에 위치해야 합니다. 그렇지 않을 경우 마주하게될 불행한 예시는 위에서 이미 함께 살펴봤습니다.

변수를 정의하는 것과 동시에 초기화합니다.

누구도 변수의 기원을 찾아 떠나는 여정(⌘B) 을 원하지 않습니다. 변수는 가능한 정의하는 것과 동시에 초기화합니다.

// bad
val balance: Int
if(hasAccount) {
balance = getBalance()
} else {
balance = 0
}
// better
val balance = if(hasAccount) getBalance() else 0

bad case 와 비교해 볼때 아래의 코드는 가독성을 높일 뿐 아니라 코드의 양도 줄어듭니다. 무엇보다 커다란 코드 더미에서 어딘가 선언되어 있을 변수의 위치를 찾을 필요가 없다는 점에서 매력적입니다. 만약 여러 값을 선언해야할 경우엔 Kotlin의 destructuring 을 사용하는것도 고려해보세요.

data class Account(val name: String, val balance: Int)val (name, balance) = if(hasAccount) getAccount() else newAccount()

이상으로 좁은 범위에서 변수를 사용하는것이 왜 중요하며 어떤 습관이 그걸 도와 줄 수 있는지에 대해 살펴봤습니다. 위의 변수의 캡쳐 예에서 볼 수 있듯 이런 습관들은 예상치 못한 위기에 빠지지 않게 도와줄 수 있습니다.

특별한 이유가 없다면 변수는 항상 작고 귀엽게 사용해주세요!

참고 자료:
https://medium.com/@yangweigbh/how-kotlin-lambda-capture-variable-ef90e11e531d
https://leanpub.com/effectivekotlin

--

--