사전 GC 스파이크
문제

GC 스파이크

gabury1고친 사람 github-actions[bot]

GC 스파이크는 가비지 컬렉션이 프로그램을 잠깐 세우는 바람에 평소 고르던 처리 시간이 한순간 크게 튀는 현상입니다. 시간을 재는 그래프에 뾰족한 봉우리로 솟아서 이런 이름이 붙었습니다. 게임에서는 화면이 툭 끊깁니다. 서버에서는 몇몇 요청만 유난히 늦어집니다. 대개 프로그램이 새 객체를 쉬지 않고 만들 때 생깁니다.

쉽고 빠른 이해

메모리 청소 때문에 프로그램이 잠깐 서서, 그 순간만 처리 시간이 크게 튀는 일입니다. 1초에 화면을 60장 그리던 게임이 어느 한 장에서 멈칫하는 것이 그 모습입니다.

이 문제가 곤란한 까닭은 평균에 안 드러나기 때문입니다. 나머지 수많은 순간이 멀쩡해서 평균은 괜찮아 보입니다. 그런데 사용자는 끊긴 그 한 번을 또렷이 느낍니다.

  1. 프로그램이 잠깐 쓰고 버릴 객체를 계속 만듭니다
  2. 메모리가 차면 청소가 시작됩니다. 그동안 프로그램이 섭니다
  3. 그 순간 처리하던 화면이나 요청이 늦어집니다
  4. 메모리가 다시 차면 같은 일이 되풀이됩니다

막으려면 값을 치릅니다. 객체를 버리지 않고 다시 쓰는 코드는 길어집니다. 그 객체가 쓰는 메모리도 프로그램이 도는 내내 붙들고 있어야 합니다.

그래서 게임이나 응답 목표가 있는 서버는 이 문제를 챙깁니다. 끝나는 시각만 보는 배치 작업은 덜 챙깁니다.

상세

GC(Garbage Collection)는 가비지 컬렉션의 줄임말입니다. 스파이크(spike)는 그래프에서 뾰족하게 솟은 봉우리를 말합니다. 둘을 합치면 「가비지 컬렉션 때문에 솟은 봉우리」라는 뜻입니다.

이 절은 가비지 컬렉션이 왜 프로그램을 세우는지부터 봅니다.

가비지 컬렉션이 프로그램을 세우는 까닭

자바·C#·자바스크립트·Go 같은 언어에서 개발자는 객체를 만들기만 합니다. 다 쓴 객체의 메모리를 돌려주는 코드는 쓰지 않습니다. 그 일은 런타임이 대신 합니다.

다 쓴 메모리를 이렇게 청소하는 일을 가비지 컬렉션이라고 합니다. 줄여서 그냥 수집이라고도 부릅니다. 이 일을 맡은 부품은 컬렉터라고 부릅니다.

객체가 놓이는 메모리 구역을 힙이라고 합니다. 프로그램이 객체를 만들수록 힙의 빈 곳이 줄어듭니다. 컬렉터는 힙이 어느 만큼 차면 깨어납니다.

깨어난 컬렉터는 먼저 프로그램이 아직 닿을 수 있는 객체를 찾아 표시합니다. 닿을 수 있다는 것은 지금 변수에서 출발해 객체 사이의 연결을 따라가면 찾을 수 있다는 뜻입니다. 그다음 표시가 없는 객체의 메모리를 돌려받습니다. 아무도 못 찾는 객체는 다시 쓰일 일이 없기 때문입니다.

표시하는 동안 프로그램이 계속 돌면 일이 꼬입니다. 컬렉터가 객체 A 는 이미 훑었다고 칩니다. B 는 아직 안 훑었습니다. 이때 프로그램이 B 에서 C 로 가던 연결을 A 에서 C 로 옮깁니다.

컬렉터는 이미 훑은 A 를 다시 보지 않습니다. 나중에 B 를 훑을 때는 C 로 가는 연결이 없습니다. 그래서 C 는 끝내 표시를 못 받습니다. 프로그램이 아직 쓰는 객체인데도 치워집니다.

가장 단순한 컬렉터는 이런 일을 아예 막으려고 일하는 동안 프로그램의 스레드를 모두 세웁니다. 이 멈춤을 가비지 컬렉션 정지라고 합니다. 세상을 멈춘다는 뜻에서 스톱 더 월드(stop-the-world)라고도 부릅니다.

멈춤의 길이는 컬렉터가 훑을 양에 달려 있습니다. 살아 있는 객체가 많고 힙이 클수록 길어집니다. 그동안 개발자가 쓴 코드는 한 줄도 돌지 않습니다.

그래프에 솟는 봉우리

멈춤은 시간을 재는 그래프에 찍힙니다. 게임이라면 프레임 시간 그래프가 그렇습니다. 화면 한 장을 그리는 데 걸린 시간을 장마다 하나씩 찍은 그래프입니다.

아래 그림의 값은 모양을 보이려고 고른 것입니다. 다섯째 프레임에 수집이 끼어들었다고 칩니다.

xychart-beta
    title "프레임마다 걸린 시간"
    x-axis "프레임" [1, 2, 3, 4, 5, 6, 7, 8]
    y-axis "밀리초" 0 --> 50
    bar [15, 14, 15, 16, 48, 15, 14, 15]

다섯째 막대가 GC 스파이크입니다. 막대가 여덟 개뿐인 이 그림에서는 평균도 눈에 띄게 오릅니다. 그런데 1분 동안 그린 3,600장 가운데 한 장만 이렇다면 평균은 거의 안 움직입니다. 평균값만 보면 멀쩡해 보이는 까닭입니다.

게임이 1초에 화면을 60장 그리려면 한 장에 60분의 1초밖에 못 씁니다. 위 그림의 세로 눈금으로 치면 16을 조금 넘는 값입니다. 이 한도를 프레임 예산이라고 합니다. 수집 한 번이 이 예산을 넘기면 그 장이 제때 못 나옵니다.

그러면 화면이 한 번 툭 걸려 보입니다. 게임 개발에서는 이렇게 한 번 걸리는 끊김을 히칭(hitching)이라고 부릅니다. GC 스파이크는 히칭을 부르는 흔한 원인입니다.

서버에서는 같은 일이 응답 시간에 찍힙니다. 멈춘 동안 도착한 요청은 처리가 시작되지도 못하고 기다립니다. 그 순간에 걸린 몇몇 요청만 유난히 늦어집니다.

이렇게 늦은 소수는 평균에 안 드러납니다. 응답 시간을 줄 세워 오래 걸린 쪽 끝을 읽어야 보입니다. 「요청 100개를 짧은 것부터 세웠을 때 99번째 값」처럼 줄의 한 위치를 읽는 값을 백분위수라고 합니다.

오래 걸린 쪽 끝에 몰린 지연은 꼬리 지연이라고 부릅니다. 평균 응답 시간은 그대로인데 99번째 백분위수만 튄다면 GC 스파이크를 먼저 의심해 볼 만합니다.

터지는 네 조건

GC 스파이크는 아래 네 조건이 함께 갖춰질 때 생깁니다. 하나라도 빠지면 봉우리가 안 생기거나 생겨도 아무도 못 느낍니다.

조건 빠지면
런타임이 가비지 컬렉션으로 메모리를 돌려받는다 C·C++ 처럼 개발자가 직접 메모리를 돌려주는 언어에는 수집 멈춤이 없다
수집 도중 프로그램을 세우는 구간이 있다 세우는 구간이 아주 짧은 컬렉터(아래 「봉우리를 낮추는 길」의 증분·동시 가비지 컬렉션)에서는 봉우리가 낮다
프로그램이 쉬지 않고 새 객체를 만든다 새로 만드는 객체가 없으면 힙이 안 차서 컬렉터가 깨어나지 않는다
한 번의 멈춤이 시간 한도를 넘는다 봉우리는 있어도 프레임 예산이나 응답 목표 안이라 드러나지 않는다

앞의 둘은 언어와 런타임이 정합니다. 셋째는 개발자가 쓴 코드가 정합니다. 넷째는 셋째가 얼마나 심한지와 프로그램이 견딜 수 있는 한도가 함께 정합니다.

넷이 갖춰지면 아래 고리가 돕니다. 객체를 만드는 속도가 일정하면 고리도 일정한 간격으로 돕니다. 그래서 GC 스파이크는 몇 초마다 한 번씩 비슷한 간격으로 솟는 일이 많습니다.

flowchart TD
    A["프레임마다 새 객체를 만든다"] --> B["힙의 빈 곳이 줄어든다"]
    B --> C["힙이 차면 컬렉터가 깨어난다"]
    C --> D["프로그램을 세우고 힙을 훑는다"]
    D --> E["그 프레임이 예산을 넘긴다"]
    E -->|"만드는 속도가 같으면 같은 간격으로 되풀이"| A

프레임마다 새 객체를 만드는 코드

셋째 조건이 코드에서 어떻게 생기는지 봅니다. 아래는 게임이 프레임마다 부르는 함수를 자바 꼴로 적은 것입니다. 오른쪽 주석은 그 줄이 한 번 돌 때 새로 생기는 객체입니다.

Java
void update() {
  var hp = "HP " + life;   // 새 문자열 1개
  var near = find(me);     // 새 리스트 1개
  for (Foe e : near) aim(e);
}

한 프레임에 새 객체가 둘 생깁니다. 1초에 60프레임이면 1초에 120개입니다. 1분이면 7,200개입니다. 이 객체들은 그 프레임이 끝나면 아무도 안 쓰는 쓰레기가 됩니다.

객체를 만들 때마다 런타임은 힙에서 메모리를 떼어 줍니다. 이 일을 메모리 할당이라고 합니다.

하나하나는 작아도 힙은 꾸준히 찹니다. 힙이 차는 속도가 컬렉터가 깨어나는 주기를 정합니다. 쓰레기를 빨리 만들수록 봉우리도 자주 솟습니다.

새 객체는 new 가 안 보이는 줄에서도 생깁니다. 문자열을 + 로 잇는 줄과 리스트를 새로 만들어 돌려주는 메서드 호출이 그렇습니다. 숫자 같은 기본형 값을 객체가 들어갈 칸에 넣는 줄도 그렇습니다. 기본형 값을 객체로 감싸는 이 일을 박싱(boxing)이라고 부릅니다.

아래는 문자열 줄은 빼고 리스트 줄만 고친 꼴입니다. 리스트를 필드로 한 번만 만들어 두고 프레임마다 비워서 다시 씁니다. findInto 는 새 리스트를 돌려주지 않습니다. 넘겨받은 리스트에 찾은 결과를 채워 넣습니다.

Java
final List<Foe> near = new ArrayList<>();

void update() {
  near.clear();            // 새 객체 0개
  findInto(me, near);      // 새 객체 0개
}

이렇게 고친 함수는 프레임마다 쓰레기를 남기지 않습니다. 대신 코드가 길어집니다. 리스트가 쓰는 메모리도 프로그램이 도는 내내 붙들고 있습니다.

알아채는 길

GC 스파이크인지 확인하려면 봉우리가 솟은 순간에 수집이 돌았는지 맞춰 봅니다. 그 증거는 대개 세 곳에서 찾습니다. 그중 첫째인 프로파일러는 프로그램이 어느 함수에서 시간과 메모리를 썼는지 재는 도구입니다.

어디서 보나 무엇이 보이나
프로파일러 솟은 프레임을 열면 그 안에서 수집이 차지한 시간이 보인다. 함수마다 새로 할당한 양을 보여 주는 도구도 많다
런타임이 남기는 수집 기록 수집이 시작된 시각과 프로그램을 세운 시간이 한 줄씩 남는다. 늦은 요청의 시각과 맞춰 본다
힙 사용량 그래프 천천히 오르다가 뚝 떨어지는 톱니 모양이 된다. 뚝 떨어지는 순간이 수집이 돈 순간이다

프로파일러에서 눈여겨볼 값은 함수마다 새로 할당한 양입니다. 이 값이 0 이 아닌 함수가 셋째 조건의 출처입니다.

힙 사용량 그래프의 톱니와 프레임 시간 그래프의 봉우리를 한 시간축에 겹쳐 보면 판정이 쉽습니다. 톱니가 떨어지는 때와 봉우리가 솟는 때가 매번 겹치면 GC 스파이크입니다.

헷갈리는 이웃

증상이 비슷해서 함께 거론되는 이름들이 있습니다. 무엇이 원인이고 무엇이 결과인지로 가르면 덜 헷갈립니다.

이름 GC 스파이크와 다른 점
히칭 한 번 툭 걸리는 끊김 전부를 가리킨다. 파일을 처음 읽어 오는 일처럼 다른 원인도 많다. GC 스파이크는 그 원인 중 하나다
프레임 드롭 그림 한 장이 제때 못 나온 결과를 가리킨다. GC 스파이크는 그 결과를 부르는 원인 쪽이다
메모리 누수 다 쓴 객체를 어딘가 계속 붙들고 있어서 수집해도 힙이 안 줄어든다. 힙 그래프가 톱니가 아니라 오르막이 된다
GC 스래싱 힙이 너무 빠듯해서 수집이 쉬지 않고 돈다. 봉우리 하나가 아니라 전체가 고르게 처진다

봉우리를 낮추는 길

고치는 길은 크게 셋입니다. 쓰레기를 덜 만들거나, 멈춰도 티 안 나는 때로 수집을 옮기거나, 수집이 한 번에 세우는 시간을 줄이는 것입니다.

길 무엇을 바꾸나 치르는 값
객체 다시 쓰기 · 객체 풀 만든 객체를 버리지 않고 모아 두었다가 다시 쓴다 코드가 길어지고 메모리를 계속 붙든다
미리 만들어 두기 필요한 객체를 로딩 때 한 번에 만든다 시작이 늦어지고 가장 많이 쓸 때만큼 메모리를 잡는다
수집 시점 옮기기 로딩 화면처럼 멈춰도 티 안 나는 때에 수집을 직접 부른다 그 뒤에 쌓이는 쓰레기는 막지 못한다
증분 가비지 컬렉션 수집을 잘게 나눠 프레임 사이사이에 조금씩 한다 표시 도중 연결이 바뀌어 쓰는 객체를 놓치는 일(위 「가비지 컬렉션이 프로그램을 세우는 까닭」)을 막으려고, 프로그램이 연결을 바꿀 때마다 검사 코드가 한 번 더 돈다
동시 가비지 컬렉션 다른 스레드가 프로그램과 같은 시각에 수집한다 컬렉터가 프로세서 코어 일부를 가져가 프로그램이 쓸 몫이 준다. 표시 도중 연결이 바뀌는 문제도 똑같이 막아야 한다
세대별 가비지 컬렉션 금방 버려지는 새 객체만 자주 훑어 한 번의 수집을 작게 한다 오래 산 객체까지 힙 전체를 훑는 수집은 여전히 길 수 있다

처음 둘은 셋째 조건을 건드립니다. 새 객체를 덜 만들어 힙이 차는 속도를 늦춥니다. 수집 시점 옮기기는 넷째 조건을 건드립니다. 멈춤은 그대로 두고 아무도 못 느끼는 때로 옮깁니다.

뒤의 셋은 둘째 조건을 건드립니다. 컬렉터가 프로그램을 세우는 구간을 짧게 만듭니다. 컬렉터를 바꿀 수 없는 환경에서는 앞의 셋이 개발자 손에 남은 길입니다.

챙겨야 하는 프로그램과 덜 챙기는 일

멈춤 한 번의 길이가 곧 품질인 프로그램이 GC 스파이크를 챙깁니다. 프레임마다 시간이 정해진 게임이 그렇습니다. 사용자 손길에 바로 반응해야 하는 화면 프로그램과, 응답 목표를 백분위수로 거는 서버도 그렇습니다.

반대로 밤새 도는 배치 작업처럼 끝나는 시각만 중요한 일은 봉우리를 덜 챙깁니다. 멈춤이 끼어도 기다리는 사람이 없습니다. 이런 일은 봉우리 높이보다 컬렉터가 쓴 시간의 합계, 곧 처리량을 봅니다.

관련 항목

GC 스파이크가 속하는 상위 분류

가비지 컬렉션 · 메모리 관리 · 런타임 · 성능 · 지연

GC 스파이크를 일으키는 원인

가비지 컬렉션 정지 · 메모리 할당 · 힙 · 박싱 · 문자열 연결 · 할당률 · 루트 집합

GC 스파이크가 드러나는 증상

히칭 · 프레임 드롭 · 꼬리 지연 · 응답 시간 · 타임아웃

GC 스파이크를 재는 지표와 도구

프레임 시간 · 프레임 예산 · 백분위수 · 프로파일러 · GC 로그 · 힙 덤프

GC 스파이크를 줄이는 수집 방식과 기법

증분 가비지 컬렉션 · 동시 가비지 컬렉션 · 세대별 가비지 컬렉션 · 객체 풀 · 참조 카운팅 · 탈출 분석

GC 스파이크와 헷갈리는 이웃 문제

메모리 누수 · GC 스래싱 · 메모리 부족 · 메모리 단편화

GC 스파이크를 챙기는 분야와 실행 환경

게임 개발 · 게임 루프 · 게임 엔진 · JVM · 자바스크립트 엔진 · 실시간 시스템

다른 이름: GC spike · garbage collection spike · 가비지 컬렉션 스파이크 · GC 히치 · GC hitch