스택과 힙: 내 코드는 메모리 어디에 사는가

풍요 속의 빈곤, 잊혀진 메모리

학교에서 운영체제나 시스템 프로그래밍 수업 때 ‘메모리 구조’를 배우긴 했다. 스택이니 힙이니 하는 용어들이 시험 문제로 나왔던 기억도 난다.

하지만 솔직히 말해서, 졸업할 때까지 메모리를 진지하게 신경 써본 적은 거의 없었다. 요즘 개인 노트북 램(RAM)이 기본 16GB, 32GB 하는 시대다. 학부 수준의 과제를 하면서 메모리가 부족할 일은 사실상 없었다. 게다가 자바(Java) 같은 언어는 가비지 컬렉터(GC)가 알아서 청소까지 해주니, 굳이 내가 메모리 주소를 따질 필요가 없었다.

그렇게 나는 하드웨어의 풍요로움과 언어의 편리함에 취해, 개발자의 기본 소양인 ‘메모리 감각’을 잊어버린 채 실무에 뛰어들었다.

실무가 알려준 뼈아픈 교훈

학교에서 코딩 테스트 연습을 할 때는 기껏해야 배열에 숫자 몇 개 넣고 돌리는 게 전부였다. 입력 데이터가 많아봤자 10만 개를 넘지 않았다. 하지만 실무는 달랐다. 아무리 작은 스타트업이라도, 데이터베이스(DB)에는 수십, 수백만 건의 실제 고객 데이터가 쌓여 있었다.

실무에서는 이 방대한 데이터를 편하게 다루기 위해 ‘ORM(Object Relational Mapping)’이라는 기술을 쓴다. SQL 쿼리를 직접 짜지 않고도 자바 객체처럼 데이터를 다룰 수 있게 해주는 아주 고마운 도구다. (자바 진영에서는 Hibernate가 대표적이다.)

문제는 이 도구가 ‘너무 편하다’는 데 있었다. 버튼 하나만 누르면 DB에 있는 데이터를 리스트로 쫙 가져와 주니까, 나는 그 뒤에 숨겨진 데이터의 무게를 가늠하지 못했다. “고객 정보를 가져와라”라고 코드 한 줄을 썼을 뿐인데, 수만 명의 정보와 그에 딸린 주문 내역까지 전부 메모리로 퍼 올린 것이다.

결과는 처참했다. 32GB 램이 순식간에 가득 찼다. 서버는 메모리를 확보하려는 GC(청소부) 때문에 ‘극심한 렉(Lag)’에 시달리더니, 결국 멈춰버렸다.

익숙한 스택, 낯선 힙

서버가 토해낸 에러 로그를 뜯어보던 나는 한 단어에 시선이 꽂혔다.

java.lang.OutOfMemoryError: Java heap space

사실 ‘스택(Stack)’이라는 단어는 꽤 친숙했다. 자료구조 시간에 지겹도록 배웠고, 개발자라면 하루에 한 번은 들어가는 사이트 이름(Stack Overflow)이기도 하니까. 재귀 함수를 잘못 짜면 스택이 터진다는 것도 상식으로 알고 있었다.

하지만 실무에서 서버가 죽을 때마다 마주친 범인은 스택이 아니었다. 에러 로그는 항상 ‘힙(Heap)’을 가리키고 있었다.

“스택이 꽉 찬 게 아니라, 힙 공간이 부족하다고?”

순간 머릿속에 의문이 스쳤다. 스택은 알겠는데 힙은 대체 뭐길래 실무에서 이렇게 자주 터지는 걸까? 자료구조의 그 힙(Heap)과 같은 건가? 왜 내 코드는 스택이 아니라 힙을 괴롭히고 있을까?

그 의문이 나를 다시 먼지 쌓인 전공 서적과 구글링의 세계로 이끌었다. 그리고 알게 되었다. 내 코드가 살아가는 집인 ‘메모리(RAM)’는 사실 하나의 통짜 원룸이 아니라, 용도에 따라 철저히 나뉘어 운영되는 ‘분리형 공간’이라는 사실을.

메모리는 하나의 통짜 공간이 아니다. 빠르고 좁은 ‘스택’과 느리고 넓은 ‘힙’으로 나뉜다.

디지털 물류 센터의 작업대와 창고

우리의 ‘디지털 물류 센터’ 세계관으로 돌아와 보자. 여기서 ‘RAM(메모리)’은 작업자(CPU)가 일을 하기 위해 물건을 펼쳐놓는 공간이다. 하지만 이 공간은 효율성을 위해 두 가지로 철저하게 분리되어 운영된다.

1. 스택(Stack): 작업자의 개인 작업대

  • 특징: 작업자(CPU) 바로 앞에 있는 좁고 높은 책상이다.
  • 용도: 지금 당장 수행 중인 업무(함수)에 필요한 변수들(지역변수, 매개변수)만 잠깐 올려둔다.
  • 수명: 업무(함수)가 끝나면 책상을 싹 치워버린다(Pop). 관리가 매우 쉽고 빠르다.
  • 비유: 요리할 때 도마 위에 올려둔 식재료들. 요리 끝나면 바로 치운다.

2. 힙(Heap): 공용 물류 창고

  • 특징: 작업대 뒤편에 있는 거대한 창고다. 공간이 넓지만 물건을 찾으러 가는 데 시간이 걸린다.
  • 용도: 크기가 크거나, 오랫동안 보관해야 하는 데이터(객체, 인스턴스)를 저장한다.
  • 수명: 사용자가 직접 버리거나, 청소부(Garbage Collector)가 치우기 전까지는 계속 남아있다.
  • 비유: 냉장고나 식자재 창고. 요리가 끝나도 재료는 그대로 남아있다.
함수가 끝나면 사라지는 ‘스택’, 청소부가 치워야 사라지는 ‘힙’.

[Code Verification] 에러로 증명하는 두 세계

정말로 메모리가 이렇게 나뉘어 있을까? 코드로 일부러 에러를 내보면 이 두 공간의 존재를 확실하게 증명할 수 있다.

1. 스택 터뜨리기 (StackOverflowError)

스택은 ‘작업대’라고 했다. 작업대는 좁다. 만약 함수가 끝나지 않고 계속 자기 자신을 호출(재귀)하면, 작업대 위에 서류가 천장까지 쌓이다가 결국 무너진다.

public class StackTest {
    public static void recursiveCall(int depth) {
        // 무한 재귀 호출: 함수가 끝나지 않고 계속 작업대(Stack)에 쌓임
        System.out.println("Stack Depth: " + depth);
        recursiveCall(depth + 1);
    }

    public static void main(String[] args) {
        recursiveCall(1);
    }
}

결과: 몇 천 번 정도 돌다가 StackOverflowError를 뱉으며 뻗는다. 힙 공간이 아무리 텅텅 비어 있어도, 스택(작업대)이 꽉 차면 프로그램은 죽는다.

2. 힙 터뜨리기 (OutOfMemoryError)

이번엔 내가 겪었던 악몽을 재현해보자. ORM을 잘못 써서 수만 개의 객체를 한 번에 로딩하는 상황과 유사하게, 거대한 리스트를 계속 만들어 창고(Heap)에 쑤셔 넣어보겠다.

import java.util.ArrayList;
import java.util.List;

public class HeapTest {
    public static void main(String[] args) {
        List<byte[]> warehouse = new ArrayList<>();
        
        while (true) {
            // 1MB짜리 데이터를 계속 생성해서 창고(Heap)에 쌓음
            // 실무 예시: DB에서 수만 건의 데이터를 페이징 없이 한 번에 조회할 때 발생
            warehouse.add(new byte[1024 * 1024]);
        }
    }
}

결과: java.lang.OutOfMemoryError: Java heap space. 이건 작업대(Stack) 문제가 아니다. 창고(Heap)에 더 이상 물건을 적재할 공간이 없어서 발생하는 에러다. 내가 짠 코드가 힙을 어떻게 괴롭히는지 눈으로 확인하는 순간이다.

청소부의 존재: Garbage Collector (GC)

여기서 중요한 차이가 있다. 스택은 함수가 끝나면 ‘자동으로’ 비워진다. 신경 쓸 필요가 없다. 하지만 힙은 누군가 치워주지 않으면 쓰레기가 계속 쌓인다.

C언어 같은 옛날 언어는 개발자가 직접 free()라는 명령어로 청소를 해야 했다. 까먹으면 창고가 쓰레기로 가득 차서 터진다(메모리 누수). 반면 자바(Java), 파이썬(Python), 자바스크립트(JS) 같은 현대 언어는 ‘GC(Garbage Collector)’라는 전문 청소부를 고용했다.

“이 객체, 더 이상 아무도 안 쓰네?” GC는 주기적으로 힙을 돌아다니며 주인 없는 객체를 찾아내 갖다 버린다. 덕분에 우리는 메모리 해제 코드를 짜지 않아도 된다.

하지만 공짜는 없다. GC가 대청소를 하는 순간, 잠시 물류 센터의 모든 작업이 멈춘다(Stop-the-world). 게임이 갑자기 렉이 걸리거나, 서버가 1초 정도 멈칫하는 현상이 바로 이 청소 시간 때문이다.

마치며: 보이지 않는 것을 보는 눈

스택과 힙을 이해하고 나니, 내 모니터 속 코드가 다르게 보이기 시작했다. 예전에는 new Student()라는 코드를 보며 단순히 “객체를 하나 만들었다”라고만 생각했다. 하지만 이제는 보인다.

“지금 힙(Heap)이라는 창고에 박스 하나가 들어갔구나. 이건 내가 지우지 않으면(또는 GC가 오지 않으면) 계속 메모리를 잡아먹겠구나.”

이 ‘보이지 않는 것을 보는 눈’이 생긴 이후로 나는 막연한 두려움에서 벗어날 수 있었다. 서버가 OutOfMemory를 토해내도, 예전처럼 당황해서 재부팅 버튼부터 누르지 않는다. 대신 침착하게 “어떤 객체가 힙을 점령했지?”라고 질문하며 분석 도구를 켠다. 원인을 알면 해결할 수 있기 때문이다.

우리는 이제 코드가 저장되는 공간(Memory)을 정복했다. 물류 창고는 완벽하게 준비되었다. 그렇다면 이 창고에서 실제로 물건을 나르고 조립하는 ‘작업자’들은 누구일까? 혼자 일할 때와 여럿이 동시에 일할 때(Multi-Tasking)는 무엇이 다를까?

다음 시간에는 운영체제의 꽃이자, 백엔드 개발자의 영원한 숙제인 ‘프로세스와 스레드(Process & Thread)’의 세계로 떠나보자.

댓글 남기기