함수 호출은 어디에 돌아갈 위치와 지역 변수를 남길까? 호출 스택과 스택 프레임

반응형

함수 안에서 다른 함수를 호출하면 실행은 잠시 새 함수로 이동했다가 정확히 다음 문장으로 돌아온다. 호출이 여러 겹이어도 각 함수의 지역 변수는 서로 섞이지 않는다. 이 질서를 만드는 핵심이 호출 스택(call stack: 아직 끝나지 않은 함수 호출을 최근 순서대로 쌓아 두는 실행 영역)이다.

호출 스택을 이해하면 재귀 호출이 왜 메모리를 계속 쓰는지, 오류 메시지에 함수 이름이 왜 역순으로 찍히는지, 지역 변수가 함수가 끝난 뒤 왜 사라지는지를 한 흐름으로 설명할 수 있다.

함수 호출에서 가장 먼저 기억해야 할 규칙

함수를 호출할 때는 코드만 이동하지 않는다. 지금 함수로 돌아올 위치와 새 함수가 사용할 지역 상태를 함께 보관한다. 새 함수가 끝나면 가장 최근에 보관한 상태부터 꺼내 실행을 되돌린다.

main 실행
  → parse 호출: main의 다음 위치를 보관
      → readToken 호출: parse의 다음 위치를 보관
      ← readToken 종료: parse로 복귀
  ← parse 종료: main으로 복귀

마지막에 들어간 호출이 가장 먼저 끝나는 구조이므로 스택(stack: 마지막에 넣은 항목을 먼저 꺼내는 자료구조)과 잘 맞는다. 다만 언어와 실행 환경에 따라 실제 최적화 방식은 달라질 수 있다. 여기서는 함수 호출을 이해하는 가장 기본적인 모델을 다룬다.

스택 프레임은 한 번의 호출에 필요한 상태를 묶는다

함수를 한 번 호출할 때 쌓이는 묶음을 스택 프레임(stack frame: 한 번의 함수 호출이 사용하는 실행 상태 묶음)이라고 한다. 보통 다음 정보가 들어간다.

  • 반환 주소(return address: 호출한 함수로 돌아가서 다음에 실행할 명령의 위치)
  • 매개변수와 지역 변수
  • 계산 중 잠시 보관할 값
  • 이전 프레임을 찾는 데 필요한 정보

같은 함수를 두 번 호출해도 호출마다 프레임이 따로 생긴다. sum(1, 2)와 sum(10, 20)이 같은 코드로 실행되면서도 서로 다른 값을 다룰 수 있는 이유다.

위쪽: 가장 최근 호출

┌ readToken 프레임 ┐  index = 4, 반환 주소 = parse의 다음 명령
├ parse 프레임 ───┤  node = ..., 반환 주소 = main의 다음 명령
└ main 프레임 ────┘

아래쪽: 먼저 시작한 호출

호출과 반환은 명령 포인터도 바꾼다

명령 포인터(instruction pointer: CPU가 다음에 실행할 명령의 위치를 가리키는 값)는 함수 호출 순간 새 함수의 시작 위치로 바뀐다. 그 전에 기존 명령 포인터의 다음 위치를 반환 주소로 남긴다.

함수가 끝나면 반환값을 호출한 쪽이 정한 위치에 두고 스택 프레임을 제거한 뒤 반환 주소를 명령 포인터에 되돌린다. 그래서 호출과 반환은 다음 네 단계로 볼 수 있다.

1. 호출할 함수에 전달할 값을 준비한다.
2. 돌아올 위치를 포함한 새 스택 프레임을 만든다.
3. 명령 포인터를 호출된 함수의 시작 위치로 옮긴다.
4. 함수가 끝나면 프레임을 제거하고 반환 주소부터 계속 실행한다.

컴파일러와 VM이 함수 호출 명령을 설계할 때도 이 상태 전환이 필요하다. 바이트코드 VM이라면 실제 CPU 스택 대신 자신이 관리하는 값 스택과 프레임 목록으로 같은 규칙을 구현할 수 있다.

지역 변수는 이름보다 호출에 속한다

지역 변수(local variable: 특정 함수 호출이 실행되는 동안만 사용하는 이름과 값)는 보통 그 호출의 스택 프레임에 속한다. 함수가 다시 호출되면 같은 변수 이름을 쓰더라도 새 저장 공간이 생긴다.

factorial(3): n = 3
  factorial(2): n = 2
    factorial(1): n = 1

세 호출은 모두 n이라는 이름을 쓰지만 서로 다른 프레임의 값이다. 가장 안쪽 호출이 끝나면 n = 1인 프레임부터 사라지고 바깥 호출은 자신이 가진 n = 2를 그대로 이어서 쓴다.

함수가 끝난 뒤에도 필요한 객체는 별도 수명을 가져야 한다. 이런 값은 힙(heap: 함수 호출보다 오래 살 수 있는 객체를 두는 메모리 영역)에 놓고 참조로 연결하는 경우가 많다. 스택과 힙은 단순히 빠른 곳과 느린 곳이 아니라 수명과 소유 방식이 다른 영역으로 보는 편이 정확하다.

재귀 호출은 종료 조건만큼 스택 깊이도 중요하다

재귀 호출(recursion: 함수가 자기 자신을 다시 호출하는 실행 방식)은 문제를 작은 형태로 줄여 표현하기 좋다. 하지만 호출할 때마다 프레임이 쌓인다. 종료 조건에 도달하지 못하거나 입력이 너무 깊으면 스택 오버플로(stack overflow: 호출 스택이 허용된 크기를 넘는 오류)가 난다.

walk(node)
  → walk(child)
      → walk(grandchild)
          → ... 프레임이 계속 증가

알고리즘이 논리적으로 끝난다는 사실만으로 충분하지 않다. 최악의 입력에서 호출 깊이가 얼마나 되는지, 반복문과 직접 관리하는 스택으로 바꿔야 하는지도 함께 판단해야 한다.

일부 언어는 꼬리 호출 최적화(tail-call optimization: 함수의 마지막 동작이 다른 함수 호출일 때 기존 프레임을 재사용하는 최적화)를 제공한다. 하지만 모든 언어와 실행 환경이 보장하는 규칙은 아니다. 재귀가 깊어도 자동으로 안전하다고 가정하면 안 된다.

오류의 스택 트레이스는 호출 경로를 거꾸로 보여 준다

스택 트레이스(stack trace: 오류 지점까지 이어진 함수 호출 목록)는 현재 쌓여 있는 프레임을 가장 최근 호출부터 보여 준다. 맨 위에는 오류가 난 함수가, 아래에는 그 함수를 불러온 경로가 이어진다.

readConfig  ← 여기서 오류
loadApp     ← readConfig를 호출
main        ← loadApp을 호출

오류가 난 줄만 보는 것보다 각 호출에 어떤 입력이 들어왔고 어느 경계에서 잘못된 상태를 허용했는지 따라가는 편이 중요하다. 비동기 작업이나 여러 스레드에서는 하나의 호출 스택만으로 전체 원인을 설명하지 못할 수도 있다. 그때는 작업을 넘긴 지점과 별도의 실행 흐름을 로그나 트레이스로 연결해야 한다.

이해 확인 질문과 답변

함수가 끝난 뒤 정확히 원래 위치로 돌아갈 수 있는 이유는 무엇일까?

함수를 호출하기 전에 다음에 실행할 위치를 반환 주소로 보관하기 때문이다. 호출된 함수가 끝나면 가장 최근 스택 프레임에서 반환 주소를 꺼내 명령 포인터를 되돌린다.

같은 함수를 재귀적으로 호출해도 지역 변수가 섞이지 않는 이유는 무엇일까?

함수 코드가 같더라도 호출마다 스택 프레임이 새로 생긴다. 각 프레임이 자기 매개변수와 지역 변수를 가지므로 같은 이름의 n도 호출 깊이에 따라 다른 값이다.

모든 지역 값은 반드시 스택에만 놓일까?

그렇지 않다. 언어 구현과 최적화에 따라 레지스터나 힙에 놓일 수도 있다. 더 중요한 규칙은 값의 수명과 접근 범위가 한 호출에 묶이는지, 함수가 끝난 뒤에도 살아야 하는지다.

스택 오버플로는 메모리 전체가 부족하다는 뜻일까?

항상 그렇지는 않다. 힙에 여유가 있어도 호출 스택에 정해진 한계를 넘으면 발생한다. 깊은 재귀, 종료 조건 오류, 호출마다 너무 큰 지역 상태를 두는 경우를 따로 확인해야 한다.

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

main이 parse를 호출하고 parse가 readToken을 호출하는 예를 보여 줘. 각 호출에서 스택 프레임, 반환 주소, 지역 변수, 명령 포인터가 어떻게 바뀌고 함수가 끝날 때 어떤 순서로 복원되는지 설명해 줘. 재귀 호출이 깊어져 스택 오버플로가 나는 경우도 함께 보여 줘.

호출 스택을 이해하면 함수 호출을 단순한 코드 이동으로 보지 않게 된다. 돌아올 위치와 지역 상태를 프레임에 보관하고, 가장 최근 호출부터 복원하는 상태 전환으로 볼 수 있다.

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

댓글