함수 호출·매개변수·재귀와 스택 프레임
함수는 인자를 받아 처리하고 값을 반환하는 단위이다. 함수 선언과 정의, 값 전달과 공유 대상 변경, 호출 스택의 LIFO 원리, 재귀의 종료 조건과 반환 순서를 구분한다.
함수는 인자를 받아 처리하고 값을 반환하는 단위이다. 함수 선언과 정의, 값 전달과 공유 대상 변경, 호출 스택의 LIFO 원리, 재귀의 종료 조건과 반환 순서를 구분한다.
그림으로 확인하기

함수 선언·정의와 프로토타입
C에서 함수 프로토타입은 함수의 이름, 반환형, 매개변수의 형을 알려 주는 선언이다. 호출이 올바른 형식인지 확인하는 데 사용한다.
int add(int a, int b); /* 프로토타입: 본문이 없다 */
int add(int a, int b) { /* 정의: 실행할 본문이 있다 */
return a + b;
}
선언은 사용 방법을 알려 주고, 정의는 실제 구현을 제공한다. add(2, 3)은 함수 호출이며, 2, 3은 인자이다. 프로토타입의 매개변수 이름은 생략하고 int add(int, int);처럼 형만 적을 수도 있다.
표준 헤더는 라이브러리 함수의 선언 등을 제공한다. 예를 들어 printf를 사용하려면 <stdio.h>를 포함한다. 함수 프로토타입을 소프트웨어 개발 초기에 만드는 시제품이라는 뜻의 프로토타입과 혼동하지 않는다. 여기서는 호출 인터페이스의 선언을 뜻한다.
함수 정의와 호출 과정
인자와 매개변수
다음 표현에서 x + 1과 5는 호출하는 쪽의 인자이고, a와 b는 함수가 값을 받는 매개변수다.
result = add(x + 1, 5)
└─ 인자 ─┘
add(a, b)
└ 매개변수 ┘
- 인자(argument, 실인수): 함수 호출식에 제공하는 식 또는 그 식을 평가한 값
- 매개변수(parameter, 형식 매개변수): 함수 정의에 선언되며 호출될 때 값을 받는 새 지역 변수·이름
- 반환값(return value): 호출이 정상 완료될 때 호출식에 돌려주는 결과
국문 교재에서는 인수·인자라는 말을 혼용하기도 한다. 용어가 모호하면 호출 위치인지, 정의 위치인지를 기준으로 판별한다.
일반적인 호출 흐름
함수·메서드 호출은 다음 흐름으로 추적하면 된다.
- 호출할 함수 또는 메서드를 결정한다.
- 인자식을 언어가 정한 순서로 평가해 인자값을 만든다.
- 개수·형·호출 규칙을 확인하고 필요한 변환을 적용한다.
- 새 호출 문맥을 만들고 인자값을 대응하는 매개변수에 연결한다.
- 함수 본문을 실행한다.
return또는 본문 종료로 현재 호출을 끝낸다.- 반환값이 있으면 호출식의 결과로 전달하고, 제어를 호출 지점 다음으로 돌린다.
인자식 평가 순서는 언어마다 다르다.
| 언어 | 일반적인 인자식 평가 순서 |
|---|---|
| C | 여러 인자식 사이의 상대 순서를 고정하지 않는다. 모든 인자 평가는 실제 함수 본문 실행 전에 끝난다. |
| Java | 왼쪽에서 오른쪽으로 평가한다. |
| Python | 왼쪽에서 오른쪽으로 평가한다. |
판별 기준: 괄호 안에 왼쪽부터 적혔다는 이유만으로 C의 인자식도 반드시 왼쪽부터 평가된다고 가정하면 안 된다.
반환값과 부작용
반환은 값을 호출식으로 전달하는 동작이고, 부작용(side effect) 은 함수 바깥에서 관찰할 수 있는 상태 변화다.
| 동작 | 반환값인가? | 부작용인가? |
|---|---|---|
return a + b | 예 | 그 자체로는 아님 |
| 화면에 출력 | 아니오 | 예 |
| 파일에 기록 | 아니오 | 예 |
| 전달받은 가변 객체의 내용 변경 | 아니오 | 예 |
| 전역 변수 변경 | 아니오 | 예 |
반환값과 부작용은 동시에 존재할 수도 있다. 화면에 10을 출력한 함수가 반드시 정수 10을 반환한 것은 아니며, 값을 반환하는 함수도 실행 중 로그를 출력할 수 있다.
- C와 Java에서
void함수·메서드는 호출식에 사용할 결과값을 반환하지 않는다. - Python 함수가 값 없는
return을 실행하거나 본문 끝에 도달하면None을 반환한다. return은 현재 호출 한 번을 끝낸다. 재귀 중 가장 안쪽 호출의return이 실행됐다고 바깥 호출들까지 한꺼번에 종료되는 것은 아니다.
매개변수 전달 방식
값 전달과 참조 전달의 정확한 뜻
- 값 전달(call by value): 인자값을 새 매개변수에 복사한다. 매개변수에 다른 값을 대입해도 호출자 변수 자체는 바뀌지 않는다.
- 참조 전달(call by reference): 매개변수가 호출자 변수 자체의 별칭이 된다. 매개변수에 대입하면 호출자 변수의 값도 직접 바뀐다.
C·Java·Python의 일반 호출을 설명할 때 가장 중요한 질문은 “객체냐 아니냐”보다 무엇이 새 매개변수에 들어가며, 무엇을 변경했는가다.
| 언어 | 새 매개변수에 전달되는 것 | 매개변수 재대입 | 공유 대상의 직접 변경 |
|---|---|---|---|
| C | 값. 포인터 인자를 사용하면 포인터값이 복사됨 | 호출자 변수에 영향 없음 | 복사된 포인터로 유효한 대상을 역참조해 변경하면 호출자에게 보임 |
| Java | 기본형 값 또는 객체 참조값 | 호출자 변수에 영향 없음 | 복사된 참조값으로 같은 객체의 필드·원소를 변경하면 호출자에게 보임 |
| Python | 평가된 객체를 새 지역 매개변수 이름에 할당 | 호출자의 이름 결합에 영향 없음 | 같은 가변 객체를 제자리 변경하면 호출자에게 보임 |
C의 포인터 전달, Java의 객체 참조 전달, Python의 객체 공유는 호출자와 피호출자가 같은 대상을 볼 수 있다는 공통점이 있다. 그러나 매개변수 자체가 호출자 변수의 별칭이 되는 참조 전달과는 다르다.
C: 포인터를 넘겨도 포인터값의 값 전달이다
#include <stddef.h>
#include <stdio.h>
static void change(int number, int *target) {
number = 99;
*target = 30;
target = NULL;
printf("inside: %d %d\n", number, target == NULL);
}
int main(void) {
int number = 10;
int target = 20;
change(number, &target);
printf("caller: %d %d\n", number, target);
return 0;
}
실행 결과:
inside: 99 1
caller: 10 30
number에는10이라는 값이 복사되므로 함수 안에서99를 대입해도 호출자의number는10이다.target에는 호출자 변수의 주소값이 복사된다.*target = 30은 그 주소가 가리키는 호출자 객체를 변경한다.target = NULL은 지역 포인터 매개변수에 새 포인터값을 대입한 것이므로 호출자의 변수나 주소를 바꾸지 않는다.
C에서 두 값을 실제로 맞바꾸려면 두 변수의 주소를 전달하고 역참조한 객체를 변경해야 한다. 이를 흔히 “주소에 의한 호출”이라고 설명하지만, 언어 의미상 전달되는 것은 여전히 주소값의 복사본이다.
Java: 객체가 아니라 참조값이 복사된다
public class ParameterDemo {
private static final class Box {
int value;
Box(int value) {
this.value = value;
}
}
private static void change(int number, Box box) {
number = 99;
box.value = 30;
box = new Box(40);
System.out.println("inside: " + number + " " + box.value);
}
public static void main(String[] args) {
int number = 10;
Box box = new Box(20);
change(number, box);
System.out.println("caller: " + number + " " + box.value);
}
}
실행 결과:
inside: 99 40
caller: 10 30
- 기본형
int값10이 복사되므로 지역 매개변수number의 재대입은 호출자에게 영향을 주지 않는다. - 객체 참조값이 복사되어 처음에는 호출자 변수와 매개변수
box가 같은Box객체를 가리킨다. 그래서box.value = 30은 호출자에게 보인다. box = new Box(40)은 매개변수에 새 참조값을 대입한 것이다. 호출자의box변수는 여전히 기존 객체를 가리킨다.
“Java 객체는 참조로 전달된다”라고 외우면 매개변수 재대입까지 호출자에게 반영된다고 오해할 수 있다. 정확한 표현은 객체를 가리키는 참조값이 값으로 전달된다이다.
Python: 매개변수 이름에 객체를 할당한다
def change(number: int, values: list[int]) -> None:
number = 99
values.append(30)
values = [40]
print("inside:", number, values)
number = 10
values = [20]
change(number, values)
print("caller:", number, values)
실행 결과:
inside: 99 [40]
caller: 10 [20, 30]
- 함수 호출 시
number와values라는 새 지역 매개변수 이름이 각각 인자 객체를 가리킨다. number = 99와values = [40]은 지역 이름을 다른 객체에 재결합하므로 호출자의 이름 결합을 바꾸지 않는다.values.append(30)은 호출자와 공유하던 리스트 객체 자체를 제자리에서 변경하므로 호출자도[20, 30]을 본다.
정수는 값 전달, 리스트는 참조 전달처럼 서로 다른 규칙을 쓰는 것이 아니다. 둘 다 같은 호출 규칙을 따르며, 객체를 제자리 변경할 수 있는지가 관찰 결과를 가른다.
전달 문제를 푸는 세 질문
- 새 매개변수에 들어간 값 또는 객체는 무엇인가?
- 문장이 매개변수 자체를 재대입·재결합하는가?
- 아니면 포인터·참조·이름이 가리키는 공유 대상을 직접 변경하는가?
이 세 질문을 분리하면 “원본이 복사된다”, “객체가 넘어간다” 같은 모호한 표현에 의존하지 않고 결과를 판단할 수 있다.
호출 스택과 지역 상태
함수 호출마다 매개변수·지역 변수·복귀 위치 등 호출을 이어 가기 위한 실행 문맥이 필요하다. 이를 설명하는 단위가 활성 레코드 또는 스택 프레임이다. 실제 저장 배치가 언제나 같은 모양이라는 뜻은 아니며, 코드 문제에서는 각 호출의 지역 상태를 구분하는 모델로 사용한다.
A가 B를 호출하고 B가 C를 호출하면, C가 끝난 뒤 B의 호출 다음 위치로 돌아오고 B가 끝나면 A로 돌아온다. 나중에 호출된 함수가 먼저 반환하는 LIFO 관계이다.
일반 지역 변수는 호출마다 별개의 상태를 갖는다. 같은 함수의 재귀 호출에서도 각 단계의 매개변수 값을 분리하여 적어야 한다. 단, static 지역 변수처럼 호출 사이에 값이 유지되는 변수나 공유 객체의 상태는 별도로 추적한다.
C에서 함수 종료 후 사라지는 자동 지역 변수의 주소를 반환해 계속 사용하는 것은 올바르지 않다. Java·Python에서 함수의 지역 이름이 사라진 것과 그 이름이 가리키던 객체의 수명이 끝난 것은 동일한 사건이 아니다.
재귀 호출
직접 재귀와 간접 재귀
- 직접 재귀(direct recursion): 함수가 자신의 본문에서 자신을 다시 호출한다.
- 간접 재귀(indirect recursion):
A → B → A처럼 다른 함수를 거쳐 다시 원래 함수로 돌아온다.
재귀 함수는 보통 두 부분으로 구성한다.
- 기저 조건(base case): 더 이상 재귀 호출하지 않고 바로 결과를 내는 조건
- 재귀 단계(recursive case): 문제를 더 작은 형태로 바꾸어 다시 호출하는 부분
기저 조건이 존재하는 것만으로는 부족하다. 모든 허용 입력에서 재귀 인자가 기저 조건에 가까워져야 한다. 예를 들어 n == 0에서 멈추는데 양수 n을 매번 n + 1로 호출하면 기저 조건에 도달하지 않는다.
팩토리얼 호출과 반환 추적
다음 C 함수는 n >= 0이고 결과가 표현 범위에 들어간다는 전제에서 팩토리얼을 계산한다.
static unsigned long long factorial(unsigned int n) {
if (n <= 1U) {
return 1ULL;
}
return (unsigned long long)n * factorial(n - 1U);
}
factorial(4)의 호출 단계는 다음과 같다.
| 호출 깊이 | 현재 호출 | 아직 끝나지 않은 계산 |
|---|---|---|
| 1 | factorial(4) | 4 × factorial(3) |
| 2 | factorial(3) | 3 × factorial(2) |
| 3 | factorial(2) | 2 × factorial(1) |
| 4 | factorial(1) | 기저 조건에서 1 반환 |
가장 깊은 시점에는 팩토리얼 함수의 프레임이 4개 활성 상태다. 이는 호출자 프레임을 제외한 수다.
이후 반환 단계는 호출의 역순으로 진행된다.
| 반환 순서 | 완료되는 호출 | 반환값 |
|---|---|---|
| 1 | factorial(1) | 1 |
| 2 | factorial(2) | 2 × 1 = 2 |
| 3 | factorial(3) | 3 × 2 = 6 |
| 4 | factorial(4) | 4 × 6 = 24 |
호출 순서는 4 → 3 → 2 → 1, 반환값이 확정되는 순서는 1 → 2 → 6 → 24다. 가장 안쪽 호출이 끝나야 바깥 호출의 보류된 곱셈을 계속할 수 있다.
재귀 호출 전후의 출력 순서
def trace(n: int) -> None:
if n == 0:
return
print(n, end=" ")
trace(n - 1)
print(n, end=" ")
trace(3)
출력:
3 2 1 1 2 3
- 재귀 호출 앞의
print는 프레임이 쌓이는 호출 단계에서 실행되어3 2 1을 출력한다. - 재귀 호출 뒤의
print는 안쪽 호출이 끝난 뒤 프레임이 풀리는 반환 단계에서 실행되어1 2 3을 출력한다.
코드 추적 문제에서는 재귀 호출문 아래의 문장을 빠뜨리기 쉽다. 재귀 호출이 돌아온 뒤 현재 프레임의 남은 문장이 계속 실행된다는 점을 표시해야 한다.
재귀와 반복 비교
| 비교 기준 | 재귀 | 반복 |
|---|---|---|
| 상태 표현 | 호출별 매개변수·지역 변수와 보류된 연산에 상태가 나뉨 | 반복 변수와 명시적 자료구조에 상태를 직접 보관 |
| 종료 조건 | 기저 조건과 기저로 향하는 진행 규칙 | 반복 조건과 갱신 규칙 |
| 자원 사용 | 일반적으로 미완료 호출 수만큼 호출 문맥이 필요 | 단순 반복은 고정된 지역 상태만으로 처리할 수 있음 |
| 장점 | 트리 탐색, 분할 정복, 재귀적 정의를 코드 구조에 직접 반영하기 쉬움 | 깊이가 큰 선형 작업에서 호출 깊이 위험을 줄이기 쉬움 |
| 주의점 | 깊은 재귀, 잘못된 진행 규칙, 반환 단계 누락 | 갱신식 오류, 반복 조건 오류, 명시적 스택 관리 필요 가능 |
재귀가 항상 느리거나 반복이 항상 읽기 쉬운 것은 아니다. 문제의 구조, 입력 깊이, 실행 환경의 호출 한계, 상태를 표현하는 방식으로 선택한다.