TL;DR
- 코틀린의 suspend 함수는 내부 최적화 때문에 디버깅하기 까다로웠다.
- 이 최적화는 코루틴의 메모리 누수를 방지하기 위한 장치이다. (버전 1.4.20 에서 추가됨)
- 코틀린 1.8.0 버전 부터는 디버깅 경험 개선을 위해 최적화를 끌 수 있는 옵션을 제공한다. (
-Xdebug
)
발단
여기 아주 간단한 코틀린 코드가 있다.
suspend fun main() {
testFun("a", "b")
}
suspend fun testFun(a: String, b: String) {
println("a: $a")
delay(1)
println("b: $b")
}
위 코드의 testFun()
에서 println()
메소드를 호출하는 라인(8번, 10번)마다 break 를 걸어서 아래처럼 디버깅을 시도해보자.
- 8번 라인
디버거 대시보드를 살펴 보자. testFun()
메소드 스택 프레임 내부에 존재하는 지역 변수 a, b 의 값을 확인할 수 있다. 다음 break 포인트로 넘어가자.
- 10번 라인
당혹스럽다. delay()
메소드로 함수를 잠시 멈췄다 돌아왔더니 a 변수가 없어졌다. 코드상에는 또렷하게 보이는데 디버거를 통해 들여다본 런타임 프레임에는 없다. 이상한 말만 남긴채 사라졌다.
“was optimised out”
이런 일이 왜 벌어지는지 알아보기 전에, 먼저 위 같은 현상에 대해 불만을 제기했던 YouTrack 이슈(KT-48678)를 살펴보자.
위 이슈를 등록한 개발자는 “was optimised out” 기능이 디버깅하는데 방해가 된다며 해당 기능을 끌 수 있었으면 좋겠다고 말한다. 해당 이슈에는 다른 개발자들도 공감을 표하는 코멘트를 많이 달았다. 이슈가 등록된 지 1년쯤 지났을 무렵 실제로 해당 최적화 기능을 개발한 Jetbrains 소속 개발자가 코멘트를 달았는데, 그 내용이 인상깊다.
위 코멘트의 내용을 정리해보면,
- suspend 함수는 내부적으로 continuation 클래스를 생성해서 로컬 변수를 저장하는 용도로 활용
- 모든 suspension 포인트 마다 변수들은 continuation 객체에 저장되고(spill) 복원됨(unspill)
- 위 과정은 각기 다른 스레드에서 재개될 수 있는 코루틴의 특성상 필연적임
- 이는 KT-16222 같은 버그를 유발하기도 했음
(코루틴의 내부적인 참조 때문에 gc 가 제대로 수행되지 않아서 메모리 누수가 발생한다는 내용)
- KT-16222 는 약 3년동안이나 고쳐지지 않았는데, 이는 고치기 어려워서가 아니라 이를 고침으로써 희생해야하는 디버깅 경험때문임
- 이 메모리 누수는 개발자가 완전히 통제할 수 있다고 생각했는데 이는 완전히 틀린 생각이었음
- 통제할 수 없는 메모리 누수는 디버깅 경험을 조금 해치더라도 반드시 해결해야 한다고 판단
- 사용되지 않는 로컬 변수는 그 즉시 회수되도록 수정
- 변수의 값을 보전하면서 메모리 누수 또한 방지할 방법은 없다. 필요 시 해당 최적화를 끌 수 있는 옵션을 제공하겠다.
정리해보면, suspend 함수를 사용하면 continuation 클래스의 로컬 변수에 대한 참조로 인해 gc 가 애플리케이션 개발자의 생각대로 동작하지 않았고 메모리 누수를 유발했다. 그래서 이를 방지하기 위한 최적화를 추가했고(1.4.20) 나중에 디버깅 경험 개선을 위해 이를 켜고 끌 수 있는 스위치를 추가했다는 것이다.(1.8.0) 여기서 말하는 ‘최적화’ 가 우리가 계속 궁금했던 “was optimised out” 인데, 기능을 이름으로 유추해보자면 ‘최적화하여 없앴다’ 정도로 보인다.
하지만 최적화가 정확히 어떤 행동을 하는지 알고싶다. 조금만 더 찾아보도록 하자.
KT-16222 이슈를 다시 보자.
이슈는 해결(Fixed)되었고 타겟 버전은 1.4.20 이다. 코틀린은 오픈 소스이고 깃허브에서 코드가 관리되고 있으니 개발자(Ilmir Usmanov)가 커밋했던 기록이 남아있을 것 같다.
코틀린 1.4.20 버전의 체인지 로그를 보자.
1.4.20 에서 위 이슈가 해결된건 확실하다. 커밋 로그를 찾아보자.
커밋 날짜로보나 committer 이름으로보나 위 커밋이 맞는 것 같다. 해당 커밋에서 변경된 코드를 분석해보자.
먼저 CFG(제어 흐름 그래프)를 생성하는데, 아마 각각의 suspensionPoint 들을 나타내기 위함인 것 같다. 현재 suspensionPoint 를 기준으로 바로 이전 suspensionPoint 들을 dfs 로 모두 찾는다. 이 로직을 각 suspensionPoint 마다 적용하여 해당 point 와 직전 point 들을 묶은 map 을 생성한다.(predSuspensionPoints
) 그리고 각 suspensionPoint 에서 spill 된 변수의 수와 직전 suspensionPoint 에서 spill 된 변수의 수를 pair 로 묶은 배열을 생성한다. (referencesToCleanBySuspensionPointIndex
)
바로 직전 코드에서 생성한 referencesToCleanBySuspensionPointIndex
를 활용하여 실제 clean 작업을 수행한다. 작업 자체도 간단하다. continuation 객체에서 해당 변수를 가리키는 reference 를 null 로 바꿔버린다. clean 작업을 수행하는 바이트코드를 suspension 작업마다 삽입하는걸로 보인다.
그리고 1.8.0 버전부터는 위 작업을 on, off 할 수 있는 스위치가 추가되었다. (shouldOptimiseUnusedVariables
)
코틀린 1.8.0 릴리즈 노트를 정리하던 중 -Xdebug
옵션이 추가된 걸 보고 이게 뭐하는 옵션인지 알아본다는게 생각보다 조금 더 깊이 들어가버렸다. 코루틴을 공부하면서 contiuation 객체가 상태를 보관한다는건 배웠지만 이게 메모리 누수로 이어질거라는 생각은 전혀 못했었다. 이 누수가 별거 아닐 수도 있지만 특정 상황에서는 심각할 수도 있고 이 버그를 고치는 데에 주저했던 Jetbrains 개발자의 생각도 이해가 된다.
개발자들이 이슈를 제기하고 코멘트로 서로의 생각을 주고받고 문제를 해결해 나가는 과정을 추적해봤는데 생각보다 너무 뜻깊고 재밌었다. 백엔드 개발자로서 코틀린을 주력 언어로 사용하는데도 매번 릴리즈 노트나 읽을줄 알았지 위 같은 이슈에 대해 논의나 기여를 해본적이 없다. 해 볼 생각도 없었다고 하는게 더 정확할지도 모르겠다.
YouTrack 에는 지금도 수많은 이슈가 올라와있다. 당장 내가 기여할 수 있는 이슈는 거의 없겠지만 계속 들여다보고 발도장이라도 찍어보려한다.
책이나 공식 문서는 줄 수 없는 보석같은 통찰이 곳곳에 숨어있다.