객체는 어디에 놓이고 언제 사라질까? 메모리 할당과 가비지 컬렉션

반응형

프로그램이 값을 다룰 때 모든 값이 같은 방식으로 메모리에 놓이지는 않는다. 함수가 잠깐 쓰는 지역값은 호출이 끝나면 함께 사라질 수 있지만, 함수 밖으로 전달되거나 크기가 달라지는 객체는 더 긴 수명을 가져야 한다. 이 객체들이 머무는 공간을 보통 힙이라고 부른다.

힙에서 중요한 질문은 “언제 만들까”보다 “언제 다시 써도 될까”다. 아직 사용하는 객체를 너무 일찍 없애면 프로그램의 의미가 깨지고, 필요 없는 객체를 계속 남기면 메모리가 바닥난다. 가비지 컬렉션은 이 둘 사이에서 프로그램이 여전히 도달할 수 있는 객체를 찾아 보존하는 규칙이다.

스택과 힙은 객체의 수명을 다르게 다룬다

함수가 호출되면 지역 변수와 반환 위치 같은 짧은 수명의 정보가 스택에 쌓인다. 함수가 끝나면 그 호출의 스택 공간은 한꺼번에 되돌릴 수 있다. 어느 함수의 호출인지가 수명의 경계가 되기 때문이다.

반면 힙 객체는 함수 하나가 끝난 뒤에도 남을 수 있다. 함수가 만든 결과를 호출자에게 넘기거나, 여러 객체가 같은 데이터를 함께 참조할 수 있기 때문이다.

함수 호출
  → 지역 변수는 현재 스택 프레임에 둠
  → 오래 살아야 하는 객체는 힙에서 공간을 얻음
  → 참조가 그 객체를 가리킴
  → 더 이상 도달할 수 없을 때 회수 후보가 됨

힙이 단지 “큰 값을 두는 곳”이라고만 생각하면 수명 문제를 놓친다. 핵심은 어느 호출이 끝났는지가 아니라, 프로그램 안에 그 객체로 가는 참조 경로가 남아 있는가다.

객체가 살아 있다는 말은 어디에서 출발할까

가비지 컬렉터는 아무 객체에서나 출발하지 않는다. 현재 실행 중인 함수의 지역 변수, 전역값, 실행 스택처럼 프로그램이 직접 닿을 수 있는 시작점을 먼저 찾는다. 이 시작점을 루트라고 한다.

루트가 session을 가리키고, session이 profile과 cart를 가리키는 경우를 보자.

root → session → profile
               └→ cart

임시 결과 C

컬렉터는 루트에서 출발해 session, profile, cart를 따라가며 살아 있다고 표시한다. C는 어디에서도 닿지 않으므로 표시되지 않는다. 여기서 살아 있다는 말은 최근에 사용했다는 뜻도, 오래 만들었다는 뜻도 아니다. 지금 프로그램이 다시 찾아갈 수 있다는 뜻이다.

표시하고 정리하는 과정은 어떻게 나뉠까

가장 이해하기 쉬운 방식은 표시-정리 방식이다. 먼저 루트에서 도달 가능한 객체에 표시를 남기고, 그 다음 표시되지 않은 객체의 공간을 회수한다.

1. 루트 집합을 찾음
2. 루트가 가리키는 객체를 표시
3. 표시한 객체가 가리키는 다음 객체도 표시
4. 표시되지 않은 객체만 회수

이 방식은 서로 참조하는 객체도 판단할 수 있다. A가 B를, B가 A를 가리키더라도 루트에서 A나 B로 가는 길이 있으면 둘은 살아 있다. 반대로 A와 B가 서로만 가리키고 루트와 끊기면, 표시 단계에서는 둘 다 찾지 못하므로 회수 후보가 된다.

참조 수만 세는 방식은 이 마지막 경우를 놓칠 수 있다. A와 B가 서로를 가리키면 각자의 참조 수가 0이 되지 않기 때문이다. 어떤 방식을 쓰든, “객체의 수명”을 참조 그래프의 문제로 보는 관점은 같다.

가비지 컬렉션이 있어도 메모리 누수는 생긴다

가비지 컬렉션 언어에서는 필요 없는 객체가 자동으로 사라지니 메모리 누수가 없을 것처럼 보인다. 하지만 오래된 객체를 전역 캐시, 이벤트 구독 목록, 작업 큐가 계속 가리키면 컬렉터는 그 객체를 살아 있다고 판단한다.

전역 캐시 → 오래된 큰 목록
             ↑
             더 이상 화면에는 쓰지 않음

이 경우 문제는 컬렉터가 느린 것이 아니라, 애플리케이션이 필요 없어진 참조를 계속 가지고 있는 것이다. 메모리 사용량이 늘어날 때는 “어떤 객체가 큰가”만 보지 말고, 그 객체를 루트까지 연결하는 경로가 왜 남아 있는지 찾아야 한다.

회수 작업도 실행 시간에 영향을 준다

객체가 많아지면 컬렉터는 루트에서 더 많은 참조를 따라가고, 회수할 공간도 정리해야 한다. 어떤 런타임은 이 동안 프로그램을 잠시 멈추고, 어떤 런타임은 애플리케이션 실행과 번갈아 가며 일을 나눈다.

짧은 수명의 임시 객체가 매우 많이 생기면 컬렉션이 자주 일어날 수 있다. 반대로 큰 객체 하나가 오래 남으면 메모리가 쉽게 줄지 않는다. 둘 다 “메모리가 많이 든다”로 보이지만, 전자는 생성 경로를 줄여야 하고 후자는 참조 경로를 끊어야 한다. 원인이 다르면 해결도 다르다.

이해 확인 질문과 답변

가비지 컬렉터는 오래된 객체를 지우는 걸까?

아니다. 컬렉터는 루트에서 다시 도달할 수 없는 객체를 회수한다. 오래됐어도 참조가 남아 있으면 살아 있고, 방금 만들었어도 어디에서도 닿지 않으면 회수 후보가 될 수 있다.

null을 대입하면 메모리가 바로 줄어들까?

그 참조 하나를 끊는 데는 도움이 되지만, 다른 참조가 남아 있으면 객체는 회수되지 않는다. 또한 실제 회수 시점은 런타임이 정하므로 즉시 메모리가 돌아온다고 보장할 수 없다.

참조 횟수 방식은 왜 순환 참조를 놓칠 수 있을까?

A와 B가 서로만 가리키면 둘의 참조 수는 0이 되지 않는다. 하지만 루트에서 둘로 가는 길이 없다면 프로그램은 다시 사용할 수 없다. 루트부터 따라가는 방식은 이 차이를 찾아낼 수 있다.

가비지 컬렉션이 있으면 객체 수명을 설계하지 않아도 될까?

아니다. 어떤 객체를 누가 소유하고, 언제 참조를 놓을지에 따라 회수 여부와 지연 시간이 달라진다. 컬렉터는 수명 설계를 대신하는 도구가 아니라, 그 설계 안에서 안전하게 공간을 되찾는 도구다.

AI에게 이렇게 요청할 수 있다

루트, 객체 그래프, 순환 참조가 있는 작은 예제로 표시-정리 가비지 컬렉션을 설명해 줘. 살아 있는 객체와 오래 남은 불필요한 객체를 구분하고, 참조를 놓지 않아 생기는 누수도 보여 줘.

객체를 이해할 때는 “이 객체를 누가 만들었나”에서 멈추지 않고, “루트에서 이 객체까지 어떤 참조가 남아 있으며, 그 참조는 언제 사라져야 하나”까지 따라가야 한다.

 

반응형
KEEP READING
카테고리 전체 보기 →

댓글