요즘 알고리즘 문제를 풀어보기 시작했다. 그러면서 새로 들인 습관이 있다. 내가 생각한 코드로 정답을 맞춘 뒤 GPT에게 코드를 더 짧게 리팩토링 해달라고 하는 것이다. 코드가 짧아지는 대신 실행 시간이 늘어나는 경우도 있고 아닌 경우도 있고, 미처 생각지 못한 접근법을 제시해주기도 한다. 이것저것 차이를 관찰하는 재미가 나름 쏠쏠...
아무튼 이번에도 백준 문제를 풀고 나서 GPT 에게 내가 짠 코드를 더 짧게 바꿔달라고 했고, 얘가 map 함수를 쓰는 과정을 보고 예상치 못한 궁금증이 생겼다. 바로 map 함수 안에서 외부 변수인 amount를 바꾸는 게 괜찮은가? 하는 점이었다. 이걸 이해하고 나니 map에 대한 시야가 좀 더 넓어진 것 같아 이 경험을 정리해두려 한다.
왜냐하면 나는 여태 map 에 들어가는 고차함수 내부에는 map 의 요소와 관련된 연산만 해야한다고 생각했기 때문이다.......... 나 같은 사람이 어딘가에 한 명쯤은 있지 않을까.
fun main() {
val coin = listOf(25, 10, 5, 1)
repeat(readln().toInt()) {
var amount = readln().toInt()
val result = coin.map {
val count = amount / it
amount %= it
count
}
println(result.joinToString(" "))
}
}
위 코드를 처음 봤을 때 내가 제일 먼저 걸렸던 건 이 부분이었다. map 안에서 amount를 바꿔도 되는 걸까?
처음엔 굉장히 어색하게 느껴졌다. 왜냐면 map은 각 요소에 대해 어떤 연산을 적용해서 새로운 리스트를 만들어내는 함수다. 나는 그동안 map 안에서는 오로지 it에만 집중해서 연산을 하고, 바깥 상태는 건드리지 않는 게 맞다고 알고 있었다.
하지만 코틀린의 람다는 외부 변수를 참조할 수 있고, 그 값을 바꾸는 것도 가능하다. 이를 클로저(closure)라고 부른다. 쉽게 말해서 람다 함수 안에서 바깥쪽 변수의 값을 읽거나 수정할 수 있다는 것이다. 코틀린 공식문서 에 보면 다음과 같이 설명되어 있었다.

다음과 같은 문장이 눈에 띈다.
Lambdas can access variables declared in the outer scope. Such variables are said to be captured by the closure.
이 문장을 이해하려면 스코프(scope)의 개념을 먼저 짚고 넘어가야 한다. viewModel 스코프를 그렇게 써놓고 이제서야 개념을 이제 확실히 알게 된
scope
스코프는 변수나 함수가 유효한 범위를 의미한다. 예를 들어, 함수 안에서 만든 변수는 그 함수 밖에선 쓸 수 없다. 그 변수의 스코프는 “함수 내부”로 제한되기 때문이다.
fun example() {
val x = 10 // x는 example 함수 안에서만 유효
}
closer
반면 클로저는 조금 다르다. 위에서도 설명했듯이, 클로저란 람다 함수가 바깥에 있는 변수들을 기억하고 사용할 수 있는 함수다. 이때 람다가 바깥 변수를 “캡처”했다고 표현한다. 참 재밌는 표현이다. 클로저의 개념이 이해될 간단한 예시를 살펴보자.
fun main() {
var count = 0
val inc = {
count += 1
}
inc()
inc()
println(count) // 2
}
람다 inc는 바깥에 선언된 count에 접근해서 값을 바꾸고 있다. 바로 이게 클로저다. Kotlin은 count라는 변수를 람다 내부에서 쓸 수 있 캡처(capture)해서 기억해두고 있는 것이다. 덕분에 map 안에서도 바깥의 amount를 변경할 수 있었던 것!
따라서 amount %= it처럼 바깥 변수를 바꾸는 코드가 map 안에 있어도 전혀 문제 없이 동작한다. 오히려 이렇게 했기 때문에 루프를 따로 안 돌리고도 각 동전 개수를 구할 수 있었다.
추가로 알아야 할 것이 있다. Kotlin에서 map은 반드시 각 요소마다 반환할 값을 명시해야 한다. 코드를 다시 살펴보자.
*올바른 예시
이렇게 count가 마지막 줄에 있어야 그 값이 result 리스트에 들어간다.
val count = amount / it
amount %= it
count // ❗ 마지막 이 줄이 없으면 map 결과는 Unit 리스트가 된다
*잘못된 예시
마지막 줄에 아무것도 없거나 println() 같은 걸 쓰고 끝내면 map 함수는 Unit 리스트를 반환하게 된다.
왜냐면 println() 자체는 값을 반환하지 않고, Kotlin에서는 아무것도 반환하지 않으면 Unit이라는 타입을 반환한다고 간주한다. 그래서List<Unit>이 되는 것이다.
val result = listOf(1, 2, 3).map {
println(it)
}
println(result)
*잘못된 예시의 출력값
kotlin.Unit, kotlin.Unit, kotlin.Unit
따라서 map 안에서는 반드시 반환할 값을 마지막 줄에 써줘야 한다.
처음엔 map 안에서는 요소(it)에만 집중해야 한다고 생각했는데, 외부 변수도 충분히 사용할 수 있다는 걸 알게 되었다. Kotlin이 이런 걸 허용해주는 언어라는 점도 새롭게 이해했다.
물론 외부 변수를 바꾸는 방식은 함수형 스타일에서는 부작용(side effect)이라고 해서 지양되기도 한다. 하지만 이번 문제처럼 간단한 연산에서는 오히려 코드가 더 직관적이고 깔끔해지기 때문에 사용해도 좋을 것 같다. 문제 풀이용이니까..
개인적으로 이번 경험은 map이라는 익숙한 함수의 내부 동작을 다시 생각해보는 계기가 됐다. 고차함수를 쓸 때 꼭 ‘원칙’만 지켜야 한다고 생각하기보다, 언어가 허용하는 유연함 안에서 적절한 선택을 하는 것이 더 중요하다는 걸 새삼 느꼈다.