목차
앞 글의 회수기는 힙을 걸어 다니며 표시하고 쓸었다. 그 일이 도는 동안 프로그램은 무엇을 하고 있을까.
치우는 동안은 아무도 못 들어간다
관리인이 건물을 돌며 빈 방을 확인하는 중이라고 하자. 그런데 확인하는 사이에도 사람들이 계속 방을 잡고 나가고 한다. 1층을 다 돌고 2층에 있는 사이 누가 1층 방을 새로 빌리면, 관리인의 장부는 이미 틀린 것이다.
메모리도 같다. 회수기가 참조를 따라 걷는 도중에 프로그램이 참조를 바꾸면 표시가 어긋난다. 그리고 어긋나는 방향이 나쁘다. 아직 쓰는 객체에 표시가 안 붙어 살아 있는 것을 회수해 버릴 수 있다. 그건 회수기가 낼 수 있는 최악의 버그다.
가장 확실한 해법은 단순하다. 일하는 동안 프로그램을 세운다. 모든 스레드를 안전한 지점에서 멈춰 세우고, 그 정지된 그림 위에서 표시하고 쓸고, 끝나면 다시 놓아준다. 이걸 stop-the-world라고 부른다.
관리인이 방 확인을 시작하면 건물 전체가 잠깐 얼어붙는 셈이다. 정확하긴 확실히 정확하다.
멈춤은 응답 시간에 그대로 나온다
문제는 그 멈춤이 사용자에게 그대로 간다는 것이다. 서버가 요청을 처리하는 도중에 회수가 시작되면 그 요청은 멈춤이 끝날 때까지 아무것도 못 한다.
기둥을 안 지나간 요청은 그대로다. 지나간 요청만 그 폭만큼 통째로 밀린다 - 평균이 아니라 꼬리에 나타나는 이유다.
여기서 이 문제의 성격이 드러난다. 평균은 거의 안 움직이고 꼬리만 늘어난다. 대부분의 요청은 멈춤 구간을 안 만나 평소대로 끝나고, 하필 그때 걸린 요청만 크게 느려진다. 그래서 평균 응답 시간만 보면 아무 일도 없어 보인다. 평균이 아니라 분포로 봐야 이 봉우리가 보인다.
멈춤이 얼마나 긴지는 한 마디로 말할 수 없다. 힙이 얼마나 큰지, 살아남은 객체가 얼마나 많은지, 어떤 회수기를 쓰는지, 심지어 같은 언어의 어느 버전인지에 따라 자릿수가 달라진다. 그러니 “GC는 몇 밀리초쯤 멈춘다”는 식의 수는 믿지 말고 자기 프로그램에서 재야 한다.
대부분은 금방 죽는다
멈춤을 줄이려면 회수기가 하는 일 자체를 줄여야 한다. 그래서 사람들은 힙에서 객체가 실제로 어떻게 사는지를 관찰했고, 거기서 한 가지 규칙성을 발견했다.
새로 만든 객체는 대부분 금방 죽는다.
생각해 보면 당연하다. 요청 하나를 처리하며 만든 임시 객체, 문자열을 이어 붙이며 생긴 중간 결과, 반복문 안에서 만든 값. 이런 것들은 그 함수를 벗어나기도 전에 아무도 안 가리키게 된다. 반대로 한동안 살아남은 객체는 앞으로도 꽤 오래 산다. 설정, 캐시, 연결 풀 같은 것들이다.
이 관찰을 세대 가설(generational hypothesis) 이라 부른다. 법칙이 아니라 경향이고, 프로그램에 따라 안 맞을 수도 있다. 그런데 대부분의 프로그램에서 잘 맞아서, 오늘날 회수기 설계의 뼈대가 됐다.
그래서 젊은 자리만 자주 훑는다
가설이 맞다면 전략이 하나 나온다. 새 객체를 한군데 몰아 두고 거기만 자주 치우는 것이다.
회의실로 치면 건물을 앞동과 뒷동으로 나눈 것과 같다. 새로 오는 팀은 무조건 앞동에 넣는다. 앞동은 회전이 빠르니 자주 돌아 보고, 앞동에서 오래 버틴 팀만 뒷동으로 옮긴다. 뒷동은 어차피 오래 있을 팀들이니 가끔만 본다.
앞동 청소가 싸게 끝나는 이유가 앞의 글에 이미 나와 있다. 표시에 드는 시간은 살아남는 것의 양에 달렸다. 앞동은 대부분 죽어 있으니 살아남아 표시할 것이 얼마 없다. 그래서 영역은 작지 않은데 일은 적다.
살아남은 것들은 뒷동으로 옮겨 붙이고 앞동은 통째로 비운다. 그러면 앞의 글에서 본 단편화도 함께 풀린다. 이 잦고 짧은 청소를 흔히 마이너 수집이라 부른다.
오래 산 자리는 가끔, 대신 크게
뒷동도 언젠가는 찬다. 오래 사는 객체가 계속 쌓이기 때문이다. 그때 뒷동을 치우는 것이 메이저 수집, 또는 힙 전체를 보는 풀 수집이다.
이쪽은 성격이 정반대다.
| 젊은 자리 치우기 | 오래된 자리 치우기 | |
|---|---|---|
| 얼마나 자주 | 자주 | 가끔 |
| 살아남는 비율 | 낮다 | 높다 |
| 한 번의 멈춤 | 짧다 | 길다 |
| 주로 하는 일 | 살아남은 것 옮기기 | 표시하고 쓸고 몰아 붙이기 |
살아남는 비율이 높다는 게 그대로 비용이다. 뒷동에 있는 것들은 대부분 아직 쓰는 것이라 표시할 것이 많고, 몰아 붙이려면 옮길 것도 많다. 그래서 아픈 멈춤은 대개 이쪽이다.
응답 시간 그래프에서 작은 봉우리가 규칙적으로 오르내리다가 가끔 훨씬 큰 봉우리가 하나 솟는 모양을 보게 되는데, 그 큰 봉우리가 이것이다.
멈추지 않고 치우려는 시도
그래서 회수기들은 멈춤을 줄이려고 애쓴다. 방향은 하나다. 프로그램을 세워 놓고 하던 일을 프로그램과 나란히 돌게 만드는 것.
관리인이 건물을 통째로 얼리는 대신, 사람들이 오가는 와중에 돌아다니며 확인하는 셈이다. 그러면 앞서 말한 문제가 되돌아온다. 확인하는 사이에 참조가 바뀌면 장부가 틀린다. 그래서 이렇게 도는 회수기는 프로그램이 참조를 바꿀 때마다 그 사실을 회수기에 알리게 만들어 둔다. 바뀐 자리만 나중에 다시 본다.
값은 셋이다.
- 평소가 조금 느려진다. 참조를 바꿀 때마다 알리는 일이 프로그램 쪽에 얇게 깔린다
- CPU를 나눠 쓴다. 회수기가 다른 코어에서 도는 동안 그만큼 프로그램 몫이 준다
- 멈춤이 사라지지는 않는다. 시작과 끝처럼 정확히 봐야 하는 대목은 여전히 짧게 세운다
그래도 긴 멈춤 하나를 짧은 멈춤 여럿과 약간의 상시 비용으로 바꾼 것이라, 응답 시간이 중요한 서비스에서는 남는 장사다.
거래는 셋 사이에서 일어난다
여기까지 오면 회수기 고르기가 왜 취향 문제가 아닌지 보인다. 세 가지를 동시에 다 가질 수 없다.
- 처리량 - 같은 시간에 프로그램이 얼마나 일하나. 회수에 쓴 CPU만큼 준다
- 멈춤 시간 - 한 번 설 때 얼마나 오래 서나
- 메모리 사용량 - 죽은 객체를 얼마나 오래 데리고 있나
셋은 서로를 밀어낸다. 멈춤을 줄이려면 회수기가 프로그램과 나란히 돌아야 하니 처리량이 준다. 회수를 덜 자주 하면 처리량은 오르지만 죽은 객체가 오래 남고, 한 번에 치울 게 많아져 멈춤이 길어진다. 메모리를 아끼려고 힙을 조이면 회수가 잦아진다.
그래서 판단의 출발점은 회수기 이름이 아니라 질문 하나다. 이 프로그램은 무엇을 못 참는가. 사용자 응답을 기다리는 서비스라면 멈춤이고, 밤새 도는 배치라면 처리량이다.
힙을 키우면 해결되나
가장 흔한 대응이 힙을 키우는 것이고, 흔한 만큼 자주 어긋난다.
힙을 키우면 회수가 덜 자주 일어난다. 여기까지는 맞다. 그런데 한 번 치울 때 훑을 영역이 커지고 살아남은 것도 많아지므로 한 번의 멈춤은 오히려 길어질 수 있다. 총 멈춤 시간은 줄었는데 최악의 응답 시간은 나빠지는 일이 실제로 생긴다.
정리하면 이렇다.
- 회수가 너무 잦아 처리량이 깎이는 상황이면 힙을 키우는 게 맞다
- 가끔 오는 긴 멈춤이 문제라면 힙을 키우는 건 반대 방향이다
- 누수가 원인이면 힙을 키워도 터지는 시점만 미룬다. 이건 다음 글의 이야기다
그래서 “메모리가 부족하다”는 말이 나오면 어느 증상을 보고 하는 말인지부터 갈라야 한다. 세 상황의 처방이 서로 반대다.
실무에서: 멈춤이 의심될 때 보는 것
응답이 가끔 튀는데 코드에는 이상한 데가 없을 때, 회수를 의심할 근거와 확인 순서가 있다.
- 꼬리부터 본다. 평균이 아니라 상위 백분위 응답 시간을 본다. 평균이 멀쩡한데 꼬리만 주기적으로 솟으면 회수는 유력한 용의자다
- 회수 로그를 켠다. 언어마다 회수가 언제 얼마나 돌았는지를 남기는 기능이 있다. 응답이 튄 시각과 회수가 돈 시각이 겹치는지 보면 추측이 사실로 바뀐다
- 잦은 짧은 멈춤인지, 가끔 오는 긴 멈춤인지 가른다. 처방이 다르다. 앞엣것은 임시 객체를 너무 많이 만드는 코드 쪽을 보고, 뒤엣것은 오래 사는 객체가 계속 늘고 있지 않은지를 본다
- 헬스 체크가 멈춤에 걸리지 않게 한다. 멈춤이 길면 살아있는지 묻는 신호에 제때 답을 못 해, 멀쩡한 인스턴스가 죽은 것으로 판정돼 재시작될 수 있다. 이런 재시작이 반복되면 원인을 엉뚱한 데서 찾게 된다
- 쓰레기를 덜 만드는 쪽이 근본이다. 요청마다 큰 임시 객체를 쌓는 코드를 줄이면 회수기가 할 일 자체가 준다. 회수기 설정을 만지는 것보다 이쪽이 대개 더 크게 듣는다
마지막 줄이 이 글의 실무 결론이다. 회수기를 어떻게 달래느냐보다, 회수기에게 일을 얼마나 주느냐가 먼저다.
정리
- 표시하는 도중에 참조가 바뀌면 살아 있는 것을 회수할 수 있다. 그래서 프로그램을 세우고 치운다.
- 그 멈춤은 평균에 잘 안 보이고 꼬리에 나타난다. 길이는 힙 크기·수집기·버전마다 달라 한 마디로 말할 수 없다.
- 새 객체는 대부분 금방 죽고, 오래 산 것은 계속 산다. 이 관찰이 회수기 설계의 뼈대다.
- 그래서 젊은 자리는 자주 짧게, 오래된 자리는 가끔 길게 치운다. 아픈 멈춤은 대개 뒤엣것이다.
- 멈춤을 줄이는 길은 프로그램과 나란히 도는 것이고, 값은 상시 비용과 CPU다.
- 처리량·멈춤 시간·메모리는 서로를 밀어낸다. 무엇을 못 참는지가 선택을 정한다.
- 힙을 키우면 회수는 덜 자주 오지만 한 번의 멈춤은 길어질 수 있다.
다음 글은 이 시리즈를 닫는다. 회수기가 이렇게까지 하는데도 왜 메모리는 여전히 새는지를 본다.