serversim
학습시뮬레이터템플릿가격
…
…
메뉴 열기
학습↗시뮬레이터↗템플릿↗가격↗
내 Documents문서
목차/3단원3 / 16

스택과 힙 — 무엇이 어디에 쌓이나

함수가 끝나면 사라지는 값과 그 뒤에도 남는 값은 무엇이 다를까요? 책상에 펼쳐 둔 메모지는 일이 끝나면 치우지만, 창고에 넣어 둔 상자는 누군가 찾을 때까지 그대로 남습니다.

프로그램이 쓰는 메모리는 크게 두 구역으로 나뉩니다. 스택과 힙입니다. 둘의 차이는 "언제 사라지는가"와 "크기를 미리 아는가"에서 옵니다. 이 구분을 정확히 이해하면, 왜 어떤 값은 함수가 끝나면 사라지고 어떤 값은 남는지, 왜 큰 객체를 함수에 넘겨도 느리지 않은지, 그리고 왜 메모리 문제는 대부분 힙에서 생기는지가 한꺼번에 풀립니다.

① 스택에는 무엇이 들어가나

함수가 호출될 때마다 스택에 프레임이 하나 쌓입니다. 그 안에는 세 가지가 들어갑니다. 첫째 매개변수, 둘째 그 함수의 지역변수, 셋째 함수가 끝나면 돌아갈 복귀 주소입니다. 크기는 함수가 컴파일될 때 이미 정해집니다 — 지역변수가 몇 개인지 코드를 보면 알 수 있으니까요. 그래서 스택은 관리가 아주 쌉니다. 포인터를 위로 올리면 할당, 아래로 내리면 해제입니다.

② 힙에는 무엇이 쌓이나

반대로 배열·객체·문자열처럼 크기가 실행 중에 정해지거나 계속 변하는 데이터, 그리고 함수가 끝난 뒤에도 살아 있어야 하는 데이터는 힙에 놓입니다. 힙은 커다란 창고이고, 필요할 때마다 빈자리를 찾아 내어 줍니다. 자리를 찾고 관리하는 비용이 있으므로 스택보다는 비쌉니다.

③ 스택은 함수와 운명을 같이한다

이것이 둘의 결정적 차이입니다. 함수가 끝나면 그 프레임은 통째로 사라지고, 안에 있던 지역변수도 함께 사라집니다. 하지만 힙에 있는 객체는 함수가 끝나도 남습니다 — 누군가 그 주소를 들고 있는 한 계속 살아 있습니다. 아래 애니메이션에서 login 함수가 끝나 프레임이 사라져도 User 객체는 힙에 남는 장면이 정확히 그것입니다.

④ 그래서 큰 객체를 넘겨도 싸다

함수에 배열을 넘길 때 배열 전체가 복사되는 게 아니라 주소표만 복사됩니다. 그래서 백만 개짜리 배열을 넘겨도 비용은 숫자 하나 복사하는 것과 같습니다. 대신 부작용이 하나 따라옵니다 — 함수 안에서 그 배열을 고치면 바깥에서도 바뀝니다. 같은 상자를 함께 가리키고 있으니까요.

⑤ 스택이 넘치면, 힙이 새면

스택은 호출이 깊어질수록 쌓입니다. 재귀 함수가 끝없이 자기를 부르면 스택이 정해진 한계를 넘어 스택 오버플로가 납니다. 반대로 힙은 아무도 안 쓰는 상자가 쌓여 문제가 됩니다. 이 두 번째 이야기가 바로 다음 단원의 주제입니다. 아래에서 스택이 세 칸까지 깊어졌다가 걷히는 동안, 힙 상자들은 계속 남아 있는 것을 확인해 보세요.

단계 1/9속도
program
1main() {
2 user = login("danbi")PC ◀
3 cart = ["book"]
4 total = checkout(user, cart)
5 print(total)
6}
7login(name) {
8 return User(name)
9}
10checkout(u, items) {
11 sum = 0
12 for it in items: sum += price(it)
13 return sum
14}
15price(it) { return 12000 }

main이 시작되며 스택에 프레임 하나가 놓입니다. 이 프레임 안에는 main의 지역변수(user, cart, total) 자리가 마련됩니다. 아직 값은 비어 있습니다.

CPU0
상태실행 중PC · 실행 위치→ 2번 줄
스택↑ 위가 최근 프레임
main()
user = ?cart = ?total = ?
힙
∅

시뮬레이터에서 해보기

  1. 1.'GC 압박 시연' 프리셋을 열면 Spring 노드의 JVM heap (MB)이 64로 줄어 있습니다 — 응답 버퍼(512KB)가 힙에 쌓이는 속도가 CPU보다 빠른 상황입니다.
  2. 2.10초 재생 후 KPI 패널의 p99를 보세요 — 91.9ms까지 튑니다. 인스펙터에서 JVM heap (MB)를 2,048로 되돌리면 같은 부하에서 p99가 51.2ms로 내려갑니다.
  3. 3.이 단원에서 본 스택(호출 중인 함수)과 힙(객체)의 구분이 여기서 갈립니다 — 스택은 그대로인데, 힙에 쌓인 응답 버퍼 객체가 회수 속도를 못 따라가는 겁니다.
시뮬레이터에서 실험해보기 →

이 단원의 용어

스택 프레임
함수 하나가 쓰는 공간. 매개변수 + 지역변수 + 복귀 주소.
힙
크기가 변하거나 오래 살아야 하는 데이터를 놓는 영역.
참조(주소표)
값 자체가 아니라 값이 있는 위치를 가리키는 것. 이 페이지의 @1 표기.
값 전달 / 참조 전달
숫자처럼 작은 값은 그대로 복사되고, 객체는 주소표만 복사된다.
스택 오버플로
호출이 너무 깊어져 스택 한계를 넘는 것. 주로 무한 재귀.

왜 이렇게 발전했나

왜 굳이 둘로 나눴을까요? 스택만 있으면 함수가 끝난 뒤에도 살아야 할 데이터를 둘 곳이 없고, 힙만 있으면 지역변수 하나 만들 때마다 창고에서 빈자리를 찾아야 해서 너무 느립니다. 그래서 수명이 짧고 크기가 정해진 것은 스택에서 초고속으로, 크고 오래 사는 것은 힙에서 유연하게 — 이 역할 분담이 거의 모든 언어의 공통 설계가 되었습니다.

스택은 함수와 함께 사라지고, 힙은 누가 가리키는 동안 살아남는다.

남은 문제

그럼 힙에 쌓인 상자는 누가, 언제 치울까요? →

← 이전 단원

2단원. 코드 한 줄은 어떻게 실행되나

다음 단원 →

4단원. 메모리는 왜 차는가 — 그리고 GC