한입 CS

메모리는 언제 해제될까: 스택, 힙, 가비지 컬렉션

도달 가능성, 마크 앤 스윕, 참조 카운팅, 누수 패턴

2장 · 맨 아래 층부터 · 읽는 시간 약 18분

목차 · 진행 0%

    관리 화면을 한 페이지 앱(single-page application, SPA)으로 만들었다. 보고서 페이지와 목록 페이지를 몇십 번 오가면 탭이 눈에 띄게 굼떠진다. 브라우저 작업 관리자를 열어 보니 탭 메모리가 페이지를 옮길 때마다 늘기만 하고 줄지 않는다. 힙 스냅숏(heap snapshot)을 찍어 보면 이미 떠난 보고서 페이지의 표 데이터가 그대로 남아 있다.

    JavaScript와 Python에는 free()가 없다. 다 쓴 객체는 가비지 컬렉터(garbage collector, GC)가 알아서 치운다. 그런데 "다 쓴 객체"를 GC는 무엇으로 알아볼까? 떠난 페이지의 데이터는 왜 다 쓴 객체로 보이지 않았을까? 이 장은 GC가 객체를 살리고 치우는 규칙을 코드로 직접 돌려 본다.

    스택과 힙: 함수가 끝나도 남는 것

    함수를 부르면 그 호출의 지역 변수를 담는 칸이 생기고, 함수가 끝나면 그 칸이 통째로 사라진다. 호출이 쌓였다가 거꾸로 빠지니 이 칸들의 모음을 콜 스택(call stack)이라 하고, 칸 하나를 스택 프레임(stack frame)이라 한다. 객체와 배열은 함수 호출과 상관없이 오래 살 수 있어야 하므로 별도 공간인 힙(heap)에 만든다. 변수에는 그 객체를 가리키는 참조(reference)가 들어 있다.

    function loadRows() {
      const limit = 1000
      const rows = new Array(limit).fill(0) // 배열 자체는 힙에
      return rows.length
    }

    loadRows()가 끝나면 프레임과 함께 limit, rows 변수가 사라진다. 하지만 힙의 배열은 그 순간 저절로 사라지지 않는다. 다른 곳에서 이 배열을 가리키고 있을지도 모르기 때문이다. 힙의 객체를 언제 치워도 되는지 판단하는 일이 GC의 몫이다. 이 장은 "변수는 프레임에, 객체는 힙에"라는 단순한 모델로 본다. 실제 엔진은 최적화로 이 모델과 다르게 저장하기도 한다.

    닿을 수 없으면 다 쓴 것이다

    GC는 "이 객체를 앞으로 쓸까?"를 알 수 없다. 대신 더 쉬운 질문으로 바꾼다. 지금 실행 중인 코드가 참조를 따라가서 이 객체에 닿을 수 있나? 출발점은 루트(root)다. 전역 객체, 호출 중인 함수들의 지역 변수가 루트다. 루트에서 참조를 따라 닿는 객체는 도달 가능(reachable)하고, 닿지 않는 객체는 다시 쓰일 방법이 없으니 치워도 된다(수거, garbage collection)(MDN: 메모리 관리).

    이 규칙을 그대로 구현한 것이 마크 앤 스윕(mark-and-sweep)이다. 루트에서 출발해 닿는 객체마다 표시(mark)하고, 다 돌면 표시 없는 객체를 쓸어 낸다(sweep). MDN은 현재 모든 주요 JavaScript 엔진이 마크 앤 스윕 계열 GC를 쓴다고 적고 있다(MDN: 메모리 관리).

    예측먼저 답해 보세요

    객체 D와 E가 서로를 가리킨다(D → E, E → D). 루트에서는 D와 E로 가는 참조가 없다. 마크 앤 스윕이 끝나면 D와 E는?

    선택지와 해설 보기
    실험 마크 앤 스윕을 한 줄씩
    실험마크 앤 스윕을 한 줄씩

    refs는 객체마다 그 객체가 가리키는 객체를 적은 표입니다. 루트에서 출발해 닿는 객체를 초록으로 표시하고, 끝에 표시 없는 객체를 빨강으로 수거합니다.

    언어
    입력
    ▶ 다음에 실행할 줄(디버거가 멈춘 줄)
    방금 실행한 줄
    변수·출력·아래 설명은 방금 실행한 줄까지의 상태입니다.
    카드 안에서 ← → 키로도 넘길 수 있습니다.
    const refs: Record<string, string[]> = { A: ['B'], B: ['C'], C: [], D: ['E'], E: ['D'] }const roots = ['A']const marked: Record<string, boolean> = {}const stack = [...roots]while (stack.length > 0) {  const node = stack.pop()!  if (node in marked) continue  marked[node] = true  for (const child of refs[node]) {    stack.push(child)  }}const freed: string[] = []for (const name of Object.keys(refs)) {  if (!(name in marked)) freed.push(name)}console.log(`살림: ${Object.keys(marked).join(', ')}`)console.log(`수거: ${freed.join(', ')}`)
    변수
    refs
    { A: ['B'], B: ['C'], C: [], D: ['E'], E: ['D'] }
    출력
     
    객체
    1. A
    2. B
    3. C
    4. D
    5. E
    stack
    1. A
    1/34단계.
    • 해볼 것
    • 순환이 있는 그래프 입력을 끝까지 실행해 D와 E가 서로 가리켜도 수거되는 것을 본다
    • 떠난 페이지 입력을 끝까지 실행해 oldPage가 왜 살아남는지 본다

    에서 A, B, C만 표시된다. 이미 표시한 객체를 건너뛰는 줄이 node in marked다. 떠난 페이지 입력은 이 장 첫 장면을 옮긴 것이다.

    루트에서 닿지 않는 D와 E는 서로를 가리켜도 수거된다. 이미 표시한 객체는 다시 따라가지 않으니 (reference cycle)이 있어도 마크 앤 스윕은 끝난다.

    이 장 첫 장면도 같은 규칙으로 읽힌다. 사용자는 newPage를 보고 있지만, 전역 객체(win)에 등록해 둔 이벤트 핸들러(handler)가 클로저(closure)로 oldPage를 붙잡고 있다. 루트에서 win → handler → oldPage → rows로 닿으니 떠난 페이지와 그 표 데이터가 모두 살아남는다. GC는 규칙대로 일했다. 아무도 쓰지 않는 페이지를 남긴 것은 리스너를 지우지 않은 코드다. 반면 draft는 rows를 가리키지만 아무도 draft를 가리키지 않아 수거된다. 루트에서 이어지는 참조를 받는 쪽이어야 살아남는다. 내가 남을 가리키는 것으로는 살아남지 못한다.

    언제 치울까: V8의 세대, CPython의 카운트

    닿는 객체를 매번 힙 전체에서 찾으면 비싸다. V8은 "대부분의 객체는 만들어지고 곧 쓸모가 없어진다"는 세대 가설(generational hypothesis)을 이용한다. 힙을 젊은 세대(young generation)와 늙은 세대(old generation)로 나누고, 새 객체가 모이는 젊은 세대는 작은 GC(Scavenger)로 자주 치운다. 힙 전체는 큰 GC(Mark-Compact)가 맡는다. V8은 GC 작업 일부를 도우미 스레드(helper thread, 스레드는 3장)로 넘긴다. JavaScript를 잠시 멈추고 메인 스레드와 도우미 스레드가 GC를 나눠 하거나(parallel), JavaScript가 도는 동안 도우미 스레드가 뒤에서 한다(concurrent)(V8 블로그: Trash talk, 2019). GC가 언제 돌지는 엔진이 정하고, 코드에서 정할 수 없다. 이름은 몰라도 된다. "작은 GC 자주, 큰 GC 가끔"만 기억하면 된다.

    CPython은 방식이 다르다. 객체마다 자기를 가리키는 참조 수를 세 두고, 그 수가 0이 되면 바로 치우는 참조 카운팅(reference counting)이 기본이다. 다만 서로를 가리키는 은 카운트가 0이 되지 않아서, 순환을 찾는 수거기를 따로 둔다(Python 문서: 데이터 모델). 이것이 gc 모듈이다. 문서는 이 수거기가 참조 카운팅을 보충하는 것이라, 순환을 만들지 않는다고 확신하면 꺼도 된다고 설명한다(Python 문서: gc). sys.getrefcount(x)로 카운트를 볼 수 있지만, 인자로 넘기며 생긴 참조 하나가 더 붙고 일부 객체는 아주 큰 값을 돌려주니 정확한 수로 믿지는 말라고 한다(Python 문서: sys.getrefcount).

    데이터 모델 문서는 "닿지 않게 된 객체가 바로 치워진다"는 데 기대지 말고 파일은 직접 닫으라고 한다. 바로 치우는 것은 지금 CPython 구현의 성질이지 언어의 약속이 아니다.

    예측먼저 답해 보세요

    oldPage로 이어지는 참조 그래프에서 resize 리스너 연결(win → handler)만 끊으면 oldPage는 수거 대상이 될까? 다음 연결은 그대로다.
    - handler → oldPage(리스너 클로저가 잡은 변수)
    - oldPage → rows
    - rows → oldPage(행이 자기 페이지를 가리키는 부모 참조)
    - win → cache(전역 캐시)
    - cache → rows

    선택지와 해설 보기
    실험 참조를 끊으면 누가 수거될까
    실험참조를 끊으면 누가 수거될까

    한 페이지 앱(single-page application, SPA)에서 이전 페이지(oldPage)로 이어지는 참조를 모델로 그렸습니다. 연결을 누르면 끊기고, 다시 누르면 이어집니다. 초록은 루트(win)에서 닿는 객체, 빨강은 수거 대상입니다. 칸 위 숫자는 참조 카운트(그 객체를 가리키는, 아직 치우지 않은 객체에서 온 연결 수)입니다. 빨강인데 참조가 0이 아니면 참조 카운팅만으로는 치우지 못하는 객체입니다.

    객체
    1. 루트win
    2. 참조 1router
    3. 참조 1newPage
    4. 참조 1handler
    5. 참조 2oldPage
    6. 참조 2rows
    7. 참조 1cache

    모든 객체가 루트에서 닿습니다. 아무것도 수거되지 않습니다.

    • 해볼 것
    • resize 리스너 연결을 끊어(리스너 해제) handler를 수거 대상으로 만든다
    • rows까지 수거 대상으로 만든다
    • 참조 카운팅만으로는 치울 수 없는 수거 대상(순환)을 만든다

    리스너를 끊으면 handler만 수거 대상(빨강)이 된다. 캐시 연결까지 끊으면 oldPage와 rows가 빨강이 되는데, 칸 위 참조 수는 둘 다 1이다. 부모 참조까지 끊으면 순환이 풀려 카운트도 0이 된다.

    리스너만 끊어서는 oldPage가 수거되지 않는다. win → cache → rows → oldPage로 아직 닿기 때문이다. 캐시 연결까지 끊은 oldPage와 rows는 서로를 가리키는 순환이라 참조 카운팅만으로는 치울 수 없고, 루트에서 닿는지를 보는 수거기라야 치운다.

    GC가 있어도 새는 메모리

    GC는 닿지 않는 객체만 치운다. 그러니 누수(leak)는 "다 쓴 객체로 가는 길을 코드가 남겨 둔 것"이다. 실무에서 자주 보는 길은 세 가지다.

    • 지우지 않은 이벤트 리스너·타이머: 전역 객체(window)에 등록한 핸들러와 setInterval 콜백은 루트에서 닿는다. 그 함수가 클로저로 잡은 변수도 함께 살아남는다. removeEventListener로 지우거나, addEventListener에 signal 옵션을 넘기고 AbortController.abort()로 한꺼번에 지운다(MDN: addEventListener). 타이머는 clearInterval로 멈춘다.
    • 끝없이 자라는 캐시: 모듈 전역의 Map에 넣기만 하고 빼지 않으면 넣은 값은 영원히 닿는다. 개수 상한을 두거나(LRU 캐시, 5장에서 만든다), 키가 객체라면 WeakMap을 쓴다. LRU(least recently used)는 가장 오래 안 쓴 항목부터 버리는 규칙이다. WeakMap의 키는 GC를 막지 않는다. 대신 키를 나열할 수 없고 키는 객체여야 한다(MDN: WeakMap). WeakRef도 있지만 언제 수거될지 보장이 거의 없어 MDN은 되도록 피하라고 한다(MDN: 메모리 관리).
    • 큰 객체를 붙잡은 클로저: 오래 사는 함수가 큰 배열 전체를 클로저로 잡으면, 필요한 값이 길이 하나뿐이어도 배열 전체가 남는다. 필요한 값만 꺼내 변수에 담아 두고 그 변수를 쓴다.

    Python에도 같은 도구가 있다. weakref.WeakValueDictionary는 값을 약한 참조(weak reference)로 들고 있어서, 다른 곳에서 그 값을 더는 가리키지 않으면 항목이 저절로 빠진다. 문서가 드는 주된 쓰임새가 큰 객체 캐시다(Python 문서: weakref). Node.js 서버에서 힙이 자라는 것을 스냅숏으로 좇는 방법은 운영 Node 프로세스의 메모리 누수 찾기 노트에 있다.

    함정 찾기 AI가 짠 보고서 훅, 페이지를 오갈수록 무엇이 쌓일까?
    함정 찾기AI가 짠 보고서 훅, 페이지를 오갈수록 무엇이 쌓일까?
    내가 AI에게 한 요청
    “보고서 id로 데이터를 받아 오고, 창 너비도 추적하고, 5초마다 새로 고치는 React 훅을 짜 줘. 한 번 받은 보고서는 캐시해 줘.”

    의심스러운 줄을 모두 눌러 표시한 뒤 “검토 끝”을 누르세요. 함정은 3개입니다.

    확인 문제 장을 덮기 전에 떠올려 보기
    확인 문제장을 덮기 전에 떠올려 보기

    1. 마크 앤 스윕이 수거하는 객체는?

    2. CPython에서 서로를 가리키는 두 객체를 프로그램 어디에서도 더는 가리키지 않게 되면?

    3. DOM 요소마다 계산 결과를 붙여 두되, 요소가 사라지면 결과도 함께 수거되게 하려면?

    더 풀어 보기(선택) · 참조 카운팅이 놓치는 객체 찾기
    보너스 문제참조 카운팅이 놓치는 객체 찾기

    참조 카운팅만 쓰는 수거기를 코드로 흉내 내 보자. 객체 그래프 refs가 있고, page라는 변수가 객체 page를 가리키다가 지워진다. 카운트가 0이 된 객체를 치우고, 치운 객체가 가리키던 객체의 카운트를 내리는 일을 연쇄로 끝까지 한 뒤 치운 객체와 남은 객체를 출력하자. 같은 그래프를 마크 앤 스윕으로 치우면 무엇이 남는지도 견줘 보자.

    refs = { page: ['header', 'list'], header: [], list: ['item'], item: ['list'] }
    수거: page, header
    남음: list, item

    이 장을 언급한 노트

    다음3장. 프로세스와 스레드: 메인 스레드가 막히면메모리 다음은 실행입니다. 메인 스레드가 막히면 화면이 왜 멈추는지 봅니다.

    『한입 CS』 2장. 메모리는 언제 해제될까: 스택, 힙, 가비지 컬렉션 · younggeun0. Claude와 함께 썼고, 저자가 아직 전체 내용을 검수하지 않은 초안입니다. 사실 주장은 공식 문서로, 예제 코드는 실제로 실행해 확인했습니다.

    보기 옵션