목차
앞 글의 가상 머신은 바이트코드를 한 명령씩 읽어 실행했다. 그런데 그 읽는 일을 계속하고 있으면 손해가 쌓인다.
자주 읽는 대목만 나중에 제대로 번역한다
사무실에 통역사가 붙어 원고를 읽어 주고 있다고 하자. 원고는 300쪽인데 직원들이 실제로 들여다보는 건 늘 같은 대여섯 쪽이다.
이때 합리적인 선택은 이것이다. 300쪽을 다 번역하지는 말고, 사람들이 계속 펼치는 그 대여섯 쪽만 제대로 번역해 인쇄해 두는 것. 나머지는 어차피 몇 번 안 읽으니 통역사가 그때그때 읽어 주면 된다.
이 방식이 JIT(just-in-time) 컴파일이다. 실행하면서 관찰하다가, 값이 나올 만한 자리만 골라 기계어로 다시 옮긴다.
실행해 보기 전엔 알 수 없는 것이 있다
왜 미리 다 옮겨 두지 않고 굳이 실행 중에 하나. 미리 옮길 때는 모르는 것이 있기 때문이다.
- 이 함수가 하루에 두 번 불리는지 초당 만 번 불리는지
- 이 반복문이 세 바퀴 도는지 백만 바퀴 도는지
- 이 자리에 실제로 들어오는 값이 늘 같은 종류인지
셋 다 소스만 봐서는 알 수 없고, 실제 사용자와 실제 데이터가 들어와야 드러난다. 그래서 미리 옮기는 쪽은 “어느 경우에도 무난한” 결과물을 내놓을 수밖에 없다.
JIT는 반대편에 선다. 일단 돌려 놓고 실제로 벌어지는 일을 보고 나서 결정한다. 300쪽 중 어느 쪽이 닳는지 눈으로 확인한 뒤에 인쇄소에 보내는 셈이다.
뜨거운 곳을 세어서 고른다
무엇이 자주 도는지는 어떻게 아나. 간단하다. 센다.
가상 머신은 함수가 불릴 때마다, 반복문이 한 바퀴 돌 때마다 조용히 숫자를 하나씩 올린다. 그 숫자가 미리 정해 둔 선을 넘으면 “여기는 뜨겁다”고 판단하고 기계어로 옮기는 작업을 시작한다. 옮기는 일이 끝나면 그 다음부터는 옮겨 둔 쪽으로 간다.
세는 일 자체도 공짜는 아니지만, 백만 번 해석할 일을 한 번의 번역으로 바꾸는 값에 비하면 아주 싸다. 그래서 대부분의 가상 머신은 인터프리터로 시작해 필요한 곳만 승격시키는 식으로 만들어져 있다. 자바의 JVM도, 브라우저의 자바스크립트 엔진도 이 뼈대다.
그래서 처음이 느리다 - 워밍업
이 방식에는 피할 수 없는 대가가 하나 있다. 처음 얼마간은 느리다.
↑ 같은 일을 한 번 하는 데 걸린 시간
몇 번째로 도는 중인가 →
처음이 느린 건 고장이 아니다. 아직 다시 옮기기 전이라서다. 절벽이 어디에 생기는지는 언어와 코드마다 다르고, 절벽이 있다는 것이 요점이다.
절벽이 생기는 자리가 다시 옮긴 시점이다. 그 왼쪽은 아직 한 줄씩 읽어 가며 실행하는 구간이고, 오른쪽은 기계어로 바뀐 뒤다. 같은 코드, 같은 입력인데 시간이 다르다.
이 구간을 워밍업이라 부른다. 고장이 아니라 설계상 당연한 것이다. 이 시리즈가 되풀이해 말한 대로 옮기는 일은 사라지지 않고, JIT는 그 일을 실행이 시작된 뒤로 미뤄 둔 것뿐이다.
추측하고, 틀리면 되돌린다
JIT가 미리 옮기는 쪽보다 나아질 수 있는 이유는 단순히 “나중에 해서”가 아니다. 실행 중에만 알 수 있는 사실을 근거로 삼기 때문이다.
예를 들어 어떤 자리에 지금까지 들어온 값이 백만 번 내내 같은 종류였다면, JIT는 “앞으로도 그럴 것”이라고 추측하고 그 경우만 빠르게 처리하는 코드를 만든다. 매번 종류를 확인하던 검사를 걷어 내니 훨씬 짧아진다.
문제는 그 추측이 틀릴 수 있다는 것이다. 어느 날 다른 종류의 값이 들어오면 그 짧은 코드는 맞지 않는다. 그래서 JIT는 안전장치를 같이 심어 둔다. 추측이 깨지면 그 자리에서 최적화된 코드를 버리고 원래의 느린 길로 되돌아간다. 그러고는 다시 관찰을 시작한다.
그래서 잘 돌던 서비스가 어느 배포 뒤에 특정 구간만 느려지는 일이 생긴다. 코드 한 곳이 이전보다 다양한 값을 받게 되면서 추측이 계속 깨지는 것이다.
돌고 있는 것을 갈아끼운다
여기서 비유가 확실하게 깨진다. 번역 사무실이라면 인쇄본이 새로 나와도 지금 읽고 있는 사람은 다 읽고 나서 새 책으로 갈아탄다. 읽는 도중에 손에 든 종이가 바뀌지는 않는다.
JIT는 그걸 한다. 백만 바퀴를 도는 반복문이 있으면, 그 반복문은 한 번 들어가면 한참 안 나온다. 다음에 불릴 때까지 기다리면 옮겨 둔 보람이 없다. 그래서 지금 돌고 있는 그 반복문의 한가운데에서 실행 중인 것을 옮겨 둔 쪽으로 바꿔치기한다. 어디까지 돌았는지를 그대로 옮겨 앉히는 것이다.
사람의 번역에는 이런 일이 없다. 이 대목만큼은 비유를 놓고 그림으로 이해하는 게 낫다. 같은 일을 하는 두 벌의 코드가 있고, 실행이 그 사이를 건너뛴다.
미리 옮겨 두는 길도 있다
JIT의 대가는 워밍업만이 아니다. 관찰하고 옮기는 작업도 메모리와 CPU를 쓰고, 옮긴 결과물도 어딘가에 들고 있어야 한다. 오래 사는 프로그램이라야 본전을 뽑는 구조다.
그래서 반대 방향의 선택지도 쓰인다. 실행 전에 통째로 기계어까지 옮겨 두는 것이고, 이걸 AOT(ahead-of-time)라 부른다. 자바 진영에도 바이트코드를 미리 네이티브 실행 파일로 만들어 두는 도구가 있다.
거래는 명확하다.
| JIT | 미리 옮겨 두기 | |
|---|---|---|
| 시작할 때 | 느리다(워밍업) | 곧바로 제 속도 |
| 오래 돌 때 | 실제 사용 패턴에 맞춰 더 빨라질 수 있다 | 처음 만든 그대로 |
| 메모리 | 관찰·번역·결과물이 함께 든다 | 적게 든다 |
짧게 살다 사라지는 프로그램이라면 미리 옮겨 두는 쪽이 유리하다. 요청 하나를 처리하고 사라지는 함수형 서비스나, 명령 한 번 실행하고 끝나는 도구가 그렇다. 반대로 몇 주씩 떠 있는 서버는 워밍업 값을 한 번만 치르고 계속 이득을 본다.
실무에서: 워밍업을 다루는 법
JIT를 우리가 직접 조종할 일은 거의 없다. 대신 워밍업이라는 성질을 모르고 있으면 잘못 판단하게 되는 자리가 있다.
- 새로 뜬 서버에 곧바로 몰아주지 않는다 - 방금 뜬 인스턴스는 차갑다. 다른 서버와 똑같이 요청을 주면 그 서버만 느리고, 사용자 눈에는 간헐적 지연으로 보인다. 그래서 로드 밸런서는 준비됐다는 신호를 받을 때까지 요청을 안 보내거나 천천히 늘린다. 스케일 아웃이 “서버를 늘리면 곧바로 그만큼 처리된다”가 아닌 이유 중 하나가 이것이다.
- 배포 직후의 지표를 그대로 믿지 않는다 - 무중단 배포로 새 버전을 올린 직후에는 응답 시간이 잠깐 튄다. 이걸 회귀로 오해하고 롤백하면 애먼 시간을 쓴다. 배포 직후 얼마간은 판단을 미루는 게 낫다.
- 성능 측정은 워밍업을 버리고 잰다 - 첫 실행 몇 번을 그대로 평균에 넣으면 숫자가 통째로 틀어진다. 벤치마크 도구들이 “워밍업 회차”를 따로 두는 이유이고, 손으로 잴 때도 앞부분은 버려야 한다.
세 가지 다 “JIT를 튜닝하자”는 얘기가 아니다. 처음이 느린 것이 정상이라는 걸 알면, 엉뚱한 데를 파지 않게 된다.
정리
- 미리 옮길 때는 무엇이 자주 도는지, 어떤 값이 실제로 들어오는지를 알 수 없다.
- JIT는 일단 실행하며 세어 보고, 뜨거운 곳만 골라 기계어로 다시 옮긴다.
- 그 대가가 워밍업이다. 처음이 느린 건 고장이 아니라 옮기는 일을 뒤로 미룬 값이다.
- JIT는 실행 중에만 알 수 있는 사실로 추측해 더 짧은 코드를 만들고, 틀리면 되돌린다.
- 짧게 살다 사라지는 프로그램은 미리 옮겨 두는 쪽이 낫다. 오래 사는 서버라야 JIT가 본전을 뽑는다.
여기까지가 “무엇으로 옮기나”였다. 다음 글은 옮긴 조각들을 하나로 묶는 일로 간다. “그 라이브러리를 못 찾겠다”는 에러의 정체가 거기서 풀린다.