사전 메모리 누수
문제

메모리 누수

gabury1

메모리 누수는 다 쓴 메모리를 프로그램이 돌려주지 않아 쓸 수 있는 양이 조금씩 줄어드는 고장입니다. 프로그램은 그 메모리를 다시 읽지도 쓰지도 않습니다. 그런데도 임자가 있는 것으로 남아 다른 곳에 내주지 못합니다. 한 번에 새는 양이 몇 바이트여도 같은 일을 수없이 되풀이하는 서버에서는 며칠에 걸쳐 쌓입니다.

쉽고 빠른 이해

다 쓴 메모리를 안 돌려줘서 쓸 수 있는 양이 한 방향으로만 줄어드는 고장입니다. 요청이 올 때마다 결과를 그릇 하나에 담아 두고 지우지 않는 서버가 그런 경우입니다. 그릇은 결과를 키마다 담아 두는 통입니다. 실무에서는 맵이라고 부릅니다.

이 고장에 따로 이름이 붙은 까닭은 터지기 전까지 신호가 없기 때문입니다. 새는 동안 프로그램은 오류 하나 없이 잘 돌고 응답도 늦지 않습니다. 쓰는 양이 한계에 닿는 순간에야 한꺼번에 드러납니다.

이렇게 터집니다:

  1. 일 하나가 메모리를 얻습니다
  2. 일이 끝납니다. 얻은 메모리를 돌려주지 않습니다
  3. 다음 일이 또 얻습니다. 쓰는 양이 한 단씩 올라간 채 내려오지 않습니다

무엇이 나빠지나 — 며칠 멀쩡하던 서버가 어느 날 갑자기 죽습니다. 죽기 직전에는 남은 메모리를 짜내느라 느려지기까지 해서 원인이 메모리라는 것이 늦게 드러납니다.

상세

메모리 누수는 프로그램이 다 쓴 메모리를 돌려주지 않아 쓸 수 있는 양이 한 방향으로만 줄어드는 고장입니다. 이 편에서 「돌려준다」는 이 메모리를 이제 안 쓴다고 알려 다른 곳이 가져다 쓸 수 있게 놓아주는 일입니다.

프로그램이 도는 중에 그때그때 얻어 쓰는 메모리 구역을 힙이라고 합니다. 누수는 이 구역에서 일어납니다. 힙에서 메모리는 한 번에 한 조각씩 얻습니다. 이 한 조각을 이 편에서는 덩이라고 부릅니다.

얻는 줄은 코드 곳곳에 흩어져 있습니다. 돌려주는 줄은 그보다 적습니다. 한 군데만 빠져도 새기 시작합니다.

새는 양은 한 번에 얼마 안 됩니다. 문제는 되풀이입니다. 요청 하나에 백 바이트가 남으면 요청 천만 건 뒤에는 1기가바이트가 남습니다.

빌린 책을 반납하지 않는 것

도서관에서 책을 빌리고 반납하지 않으면 그 책은 서가로 돌아오지 않습니다. 읽지 않고 책상 구석에 쌓아 두기만 해도 대출 기록에는 빌린 것으로 남습니다. 다음 사람은 그 책을 못 빌립니다.

이런 사람이 한둘이면 티가 안 납니다. 매일 수백 권이 나가기만 하면 어느 날 서가가 빕니다.

주소를 잃는 누수와 안 놓는 누수

메모리를 돌려주는 방법은 크게 둘입니다. 프로그램이 손으로 돌려주거나, 가비지 컬렉션이 대신 거둬 가거나입니다. 가비지 컬렉션은 아무도 안 가리키는 메모리를 찾아 자동으로 거둬 가는 장치입니다.

누수는 두 방법 모두에서 생깁니다. 다만 새는 모양이 다릅니다. 손으로 돌려주는 쪽은 주소를 잃어서 못 돌려줍니다. 거둬 가는 쪽은 안 놓아서 못 거둬 갑니다.

아래 그림은 거둬 가는 쪽이 무엇을 보는지 그린 것입니다. 뿌리는 프로그램이 언제든 닿을 수 있는 출발선입니다. 전역 변수가 그런 출발선입니다.

flowchart TD
    R["뿌리 · 전역 변수"]
    R --> C["요청 결과를 담아 둔 그릇"]
    R --> W["지금 처리 중인 요청"]
    C --> O1["끝난 요청의 결과 1"]
    C --> O2["끝난 요청의 결과 2"]
    C --> O3["끝난 요청의 결과 3"]
    L["주소를 잃어버린 덩이 · 이어지는 화살표 없음"]

그릇에 매달린 결과 셋은 뿌리에서 사슬로 이어져 있어 거둬 가는 쪽이 손을 못 댑니다. 프로그램이 그 결과를 다시 볼 일이 없는데도 그렇습니다.

지금 처리 중인 요청은 다릅니다. 일이 끝나면 사슬에서 떨어져 나가 곧 거둬집니다.

따로 떨어져 있는 덩이는 일부러 떼어 놓은 것입니다. 이것이 손으로 돌려주는 쪽의 누수입니다. 뿌리에서 이어지는 화살표가 하나도 없어서 아무도 못 닿고, 그래서 돌려줄 방법도 없습니다.

이름은 둘이지만 남는 것은 같습니다. 두 경우 모두 프로그램이 다시 쓸 일이 없는 메모리입니다. 누가 가리키느냐가 아니라 앞으로 쓰느냐가 누수를 가릅니다.

주소를 잃는 길

손으로 돌려주는 언어에서는 얻은 덩이의 주소를 포인터에 담아 둡니다. 포인터는 메모리의 번지를 담는 변수입니다. 그 번지를 잃으면 덩이는 남습니다. 가리킬 방법이 없어집니다.

아래는 C 코드 넉 줄입니다. malloc 은 힙에서 덩이를 얻어 그 번지를 돌려주는 함수입니다. free 는 그 번지의 덩이를 놓아주는 함수입니다. 번지 하나를 덮어써서 앞 덩이를 잃는 장면입니다.

C
char *p = malloc(1024);  // p = 0xA0
읽기(p);
p = malloc(2048);        // p = 0xC0
free(p);                 // 0xA0 는 샌다

셋째 줄에서 p 에 새 번지가 덮였습니다. 0xA0 을 적어 둔 변수가 하나도 안 남았습니다. 넷째 줄의 free 는 지금 p 가 가진 0xC0 만 돌려주므로 앞 덩이는 프로그램이 끝날 때까지 남습니다.

덮기 전과 덮은 뒤를 나란히 놓으면 이렇습니다.

flowchart TD
    subgraph S1["덮기 전"]
        P1["p"] --> A1["0xA0 · 1024바이트"]
    end
    subgraph S2["덮은 뒤"]
        P2["p"] --> A2["0xC0 · 2048바이트"]
        A3["0xA0 · 1024바이트 · 들어오는 화살표 없음"]
    end
    S1 --> S2

이 꼴은 되풀이 안에 들어갈 때 위험해집니다. 돌 때마다 번지 하나씩 잃으면 새는 양이 도는 횟수에 그대로 비례합니다.

안 놓는 길

거둬 가는 언어에서는 주소를 잃을 일이 없습니다. 대신 안 놓는 것이 누수가 됩니다. 오래 사는 그릇에 뭔가를 넣고 빼지 않으면 그 안의 내용물은 영영 거둬지지 않습니다.

아래 파이썬 코드의 결과 = {} 가 앞에서 말한 그릇입니다.

Python
결과 = {}                # len 0
while 참:
    r = 받기()
    결과[r.id] = 처리(r)  # len 1 · 2 · 3
    보내기(결과[r.id])

결과 는 되풀이 밖에서 만들어졌으니 프로그램이 도는 내내 삽니다. 넣는 줄은 있습니다. 빼는 줄은 없습니다. 오른쪽 주석이 보이듯 담긴 개수가 도는 횟수만큼 늘어납니다.

이렇게 쓰려던 것이 대개는 캐시입니다. 캐시는 비싼 계산의 결과를 담아 두고 다음번에 다시 계산하지 않는 그릇입니다. 캐시에 최대 크기나 만료를 안 정하면 그때부터는 캐시가 아니라 누수입니다.

같은 꼴이 몇 군데 더 있습니다. 대표는 이벤트 구독입니다. 어떤 일이 생기면 알려 달라고 미리 등록해 두는 것입니다.

다 쓰고 나서 해지하지 않으면 등록을 받아 둔 쪽이 등록한 쪽을 계속 붙듭니다. 붙들려 있는 동안은 거둬지지 않습니다. 순환 참조와 스레드 지역 저장소도 같은 꼴입니다.

재현되는 조건

넷이 다 서면 메모리 누수가 됩니다.

  1. 되풀이되는 일 하나가 힙에 메모리를 얻습니다
  2. 그 일이 끝난 뒤에도 얻은 메모리를 돌려주지 않습니다
  3. 되풀이 횟수에 상한이 없습니다
  4. 프로세스가 되풀이보다 오래 삽니다
flowchart TD
    A["일 하나를 시작하며 메모리를 얻는다"] --> B["그 일을 끝낸다"]
    B --> C{"얻은 메모리를 돌려주나"}
    C -->|돌려준다| D["쓰는 양이 일 시작 전으로 돌아온다"]
    C -->|안 돌려준다| E["쓰는 양이 한 단 올라간 채 남는다"]
    E --> A

오른쪽 고리가 누수입니다. 갈림에 걸린 것은 돌려주느냐 하나뿐입니다. 고리를 한 바퀴 돌 때마다 시작점의 쓰는 양이 조금씩 높아집니다.

넷째가 빠지면 새어도 문제가 안 됩니다. 일 하나마다 프로세스를 새로 띄우고 끝내면 운영체제가 그 프로세스의 메모리를 통째로 거둬 갑니다. 한 프로세스를 몇 달씩 띄워 두는 서버라야 새는 양이 쌓일 시간이 생깁니다.

누수가 아닌 것

메모리가 모자란 모든 경우가 누수는 아닙니다. 고치는 방법이 서로 다릅니다. 처음에 잘못 짚으면 엉뚱한 곳을 팝니다.

이런 경우 왜 누수가 아닌가
처리할 데이터가 커서 많이 쓴다 일이 끝나면 돌아온다. 쓰는 양이 내려온다
메모리 단편화 돌려주기는 했는데 빈틈이 잘게 흩어져 큰 덩이를 못 준다
크기를 정해 둔 캐시가 꽉 찼다 정한 만큼에서 멈춘다. 한 방향으로 늘지 않는다
쓸 양을 애초에 적게 잡았다 프로그램은 제자리인데 준 몫이 작다

가르는 물음은 하나입니다. 일이 끝난 뒤 쓰는 양이 일 시작 전으로 돌아오나. 안 돌아오고 매번 조금씩 높아지면 누수입니다.

누수를 알아채는 신호

쓰는 양 하나만 보면 안 됩니다. 정상으로 도는 프로그램도 일이 몰리면 오르내립니다. 봐야 할 것은 높이가 아니라 바닥이 올라가는가입니다.

아래 표의 상주 메모리는 프로세스가 지금 붙들고 있는 메모리 양입니다. 운영체제가 알려 주는 값이라 코드를 안 고치고도 볼 수 있습니다.

보는 것 누수일 때
상주 메모리 오르내리지만 골짜기가 계속 높아진다
가비지 컬렉션 직후의 쓰는 양 돌 때마다 조금씩 높아진다
가비지 컬렉션이 도는 횟수 점점 잦아지고 한 번이 오래 걸린다
재시작 뒤 걸린 시간 며칠 뒤부터 나빠지고 재시작하면 나아진다

둘째 줄이 거둬 가는 언어에서 가장 또렷한 신호입니다. 거둘 것을 다 거둔 뒤에도 남은 양이 어제보다 많다면 못 거두는 무언가가 쌓이고 있다는 뜻입니다.

마지막 줄은 어느 언어에서나 통합니다. 재시작이 고친다면 원인은 바깥 환경이 아니라 프로세스 안에 쌓이는 무언가입니다.

메모리가 바닥날 때의 증상

증상이 메모리로 안 보일 때도 있습니다. 남은 메모리가 줄면 운영체제가 당장 안 쓰는 메모리를 디스크로 밀어냅니다. 이 스왑이 심해지면 프로그램 전체가 느려져서 성능 문제처럼 보입니다.

더 밀어낼 것이 없으면 거기서 끝납니다. 운영체제가 메모리 부족 킬러로 프로세스를 죽이거나, 프로그램이 메모리를 더 못 얻는다는 오류(OutOfMemoryError)를 내고 멈춥니다.

정상에서 끝까지 가는 길은 이렇습니다.

stateDiagram-v2
    [*] --> A
    A: 정상 · 오류 없이 잘 돈다
    B: 남은 메모리가 준다 · 스왑이 심해져 느려진다
    C: 더 밀어낼 것이 없다
    D: 메모리 부족 킬러가 프로세스를 죽인다
    E: 메모리를 더 못 얻는다는 오류를 내고 멈춘다
    A --> B: 새는 양이 쌓인다
    B --> C: 밀어낼 메모리가 떨어진다
    C --> D
    C --> E
    D --> [*]
    E --> [*]

가운데 구간이 성능 문제처럼 보이는 대목입니다. 여기서 원인을 메모리로 안 짚으면 마지막 두 갈래 중 하나로 갑니다.

새는 곳을 좁히는 방법

찾는 일은 두 걸음입니다. 먼저 정말 새는지를 가리고, 그 다음 무엇이 안 놓이는지를 봅니다.

첫 걸음은 앞에서 본 물음 하나로 끝납니다. 일이 끝난 뒤 쓰는 양이 시작 전으로 돌아오는지만 보면 됩니다. 두 번째 걸음은 메모리를 누가 얼마나 쥐고 있는지를 보는 도구가 맡습니다.

힙 덤프는 어느 순간의 힙 내용을 통째로 찍어 낸 파일입니다. 시간을 두고 두 번 찍어 견주면 그 사이에 불어난 종류가 드러납니다. 프로파일러는 한 걸음 더 들어가 그 메모리를 얻은 코드 줄까지 짚어 줍니다.

찾은 뒤에 하는 일

고치는 방법은 안 놓는 이유마다 다릅니다. 그릇에 최대 크기를 정하거나, 다 쓴 것을 지우는 줄을 넣거나, 등록해 둔 것을 해지하는 줄을 넣는 식입니다.

원인을 못 찾는 동안 롤링 재시작으로 버티기도 합니다. 인스턴스를 하나씩 돌아가며 다시 띄우는 것입니다. 새는 속도보다 자주 돌리면 터지는 때를 계속 미룰 수 있습니다. 고친 것이 아니라 미룬 것입니다.

같은 꼴로 새는 다른 자원

메모리만 이렇게 새는 것은 아닙니다. 파일 핸들, 소켓, 커넥션, 스레드도 같은 꼴로 샙니다. 이것들을 묶어 자원 누수라고 부릅니다.

얻은 것을 돌려주는 줄이 빠졌다는 점에서 원인이 같습니다. 다만 이쪽은 쓸 수 있는 개수가 훨씬 적어서 훨씬 빨리 터집니다.

관련 항목

메모리 누수가 쌓이는 메모리 구역

힙 · 스택 · 메모리 · 가상 메모리 · 주소 공간 · 상주 메모리

메모리를 돌려주는 일을 맡는 장치

가비지 컬렉션 · 참조 카운팅 · 메모리 할당자 · malloc · free · 소유권 · RAII

메모리 누수를 만드는 참조 관계

강한 참조 · 약한 참조 · 순환 참조 · 도달 가능성 · 루트 집합 · 캐시 · 스레드 지역 저장소

메모리 누수와 헷갈리는 이웃 고장

메모리 단편화 · 댕글링 포인터 · 이중 해제 · 해제 후 사용 · 버퍼 오버플로 · 스래싱

새는 메모리가 바닥났을 때 나타나는 증상

OutOfMemoryError · 메모리 부족 · 메모리 부족 킬러 · 스왑 · 가비지 컬렉션 정지 · 꼬리 지연

메모리 누수와 같은 꼴로 새는 다른 자원

자원 누수 · 파일 디스크립터 누수 · 커넥션 풀 고갈 · 스레드 누수 · 좀비 프로세스

메모리 누수를 찾아내는 데 쓰는 도구

프로파일러 · 힙 덤프 · 정적 분석 · Valgrind · AddressSanitizer · 모니터링

메모리 누수를 안고 버티는 운영 수단

롤링 재시작 · cgroup · 메모리 제한 · 헬스 체크 · 오토스케일링 · 프로세스

메모리 누수가 자주 걸리는 코드 안의 그릇

포인터 · 해시 테이블 · 큐 · 이벤트 리스너 · 커넥션 풀 · 버퍼

다른 이름: memory leak · 메모리 릭 · 메모리 누출