Skip to Content
독학사독학사 2단계C프로그래밍09. 함수 정의·호출과 매개변수 전달

이번 문서의 목표: 함수 선언과 정의, 프로토타입의 관계를 정확히 구분하고, C가 인자를 값으로 전달한다는 사실이 실제로 어떤 결과를 만드는지, 함수를 호출할 때마다 스택 프레임이 어떻게 쌓이고 정리되는지 그림과 표로 설명할 수 있게 된다.

왜 함수가 필요한가

같은 계산(예: 두 수 중 더 큰 값 찾기, 합계 구하기)을 프로그램 여러 곳에서 반복해야 한다고 가정해 봅시다. 매번 같은 코드를 복사해 붙이면 코드가 길어지고, 나중에 로직을 수정할 때 모든 복사본을 하나하나 고쳐야 합니다. 함수(function, 함수)는 이런 반복되는 작업을 이름 붙인 하나의 단위로 묶어, 필요할 때마다 그 이름을 불러(호출, call) 재사용하는 문법입니다.

쉽게 말하면: 함수는 “레시피”입니다. 레시피를 한 번 적어 두면, 그 요리가 필요할 때마다 처음부터 다시 설명하지 않고 레시피 이름만 부르면 됩니다.

06편에서 이미 main 함수를 다뤘습니다. 사실 C 프로그램은 main이라는 특별한 함수 하나로 시작해서, 그 안에서 다른 함수들을 호출하며 진행되는 구조입니다. 이번 편에서는 사용자가 직접 함수를 만들고 호출하는 방법과, 그 과정에서 메모리가 어떻게 움직이는지를 다룹니다.

1. 함수 선언·정의·프로토타입 — 세 용어의 차이

독학사 시험에서 세 용어를 헷갈리게 묻는 문제가 자주 나옵니다. 아래 표로 먼저 구분합니다.

용어의미예시
함수 정의(definition)함수의 이름·매개변수·반환형과 함께 실제 실행할 코드(본문) 까지 작성한 것int add(int a, int b) { return a + b; }
함수 선언(declaration)함수의 이름·매개변수·반환형만 컴파일러에게 알려주는 것(본문 없음)int add(int a, int b);
함수 프로토타입(prototype)함수 정의보다 먼저 컴파일러에게 함수의 존재를 알리기 위한 선언. 실무에서는 선언과 사실상 같은 뜻으로 쓰인다int add(int, int);(매개변수 이름 생략 가능)

프로토타입이 왜 필요한가

C 컴파일러는 소스 코드를 위에서 아래로 한 번 읽으면서 해석합니다. 만약 main 함수 안에서 add라는 함수를 호출하는데, add의 정의가 main보다 아래에 있다면, 컴파일러는 add를 호출하는 시점에 아직 add가 무엇인지(매개변수 개수·형, 반환형) 모르는 상태입니다.

#include <stdio.h> int add(int a, int b); /* 프로토타입: add의 존재를 미리 알림 */ int main(void) { int result = add(3, 4); printf("%d\n", result); return 0; } int add(int a, int b) { /* 실제 정의는 main보다 뒤에 위치 */ return a + b; }
7

프로토타입 한 줄이 main보다 위에 있으므로, 컴파일러는 add(3, 4)를 만나는 순간 “정수 두 개를 받아 정수를 반환하는 함수가 있다”는 사실을 이미 알고 있어 문제없이 컴파일됩니다. 자주 틀리는 점: 프로토타입 없이 함수를 자신의 정의보다 먼저 호출하면, 컴파일러에 따라 경고 또는 오류가 발생하거나(특히 매개변수 형이 다르면), 잘못된 방식으로 인자를 해석해 예상과 다른 결과가 나올 수 있습니다.

2. 값 전달(pass by value) — C 함수 호출의 기본 원칙

C에서 함수에 인자를 넘기면, 인자 값의 복사본이 함수 안의 매개변수에 전달됩니다. 이를 값에 의한 전달(pass by value, 값 전달)이라고 부릅니다.

#include <stdio.h> void addOne(int n) { n = n + 1; printf("함수 안 n: %d\n", n); } int main(void) { int x = 5; addOne(x); printf("함수 밖 x: %d\n", x); return 0; }
함수 안 n: 6 함수 밖 x: 5
시점main의 xaddOne의 n설명
addOne(x) 호출 직전5(아직 없음)x의 값 5가 복사되어 전달 준비
addOne 진입 직후55n은 x와 값만 같을 뿐 서로 다른 메모리 공간
n = n + 1 실행 후56n만 바뀌고 x는 그대로
addOne 종료 후5(메모리 해제됨)x는 애초에 영향받은 적이 없음

해석: nx의 값을 복사해 받은 완전히 별개의 변수입니다. addOne 함수 안에서 n을 아무리 바꿔도, 함수를 호출한 쪽의 x는 전혀 영향을 받지 않습니다. 이것이 “값 전달”의 핵심입니다.

쉽게 말하면: 함수를 부를 때 원본을 건네주는 것이 아니라 원본을 복사한 사본을 건네줍니다. 함수 안에서 사본을 아무리 고쳐도 원본은 그대로입니다.

자주 틀리는 점: “함수에 변수를 넘기면 그 변수가 함수 안에서 바뀐 대로 함수 밖에서도 바뀐다”고 착각하는 경우가 매우 흔합니다. C에서 함수 호출만으로는 호출한 쪽의 변수를 바꿀 수 없습니다. 호출한 쪽의 원본 변수를 바꾸려면 주소를 전달해 포인터로 접근해야 하며, 이는 12~13편에서 다룹니다.

return의 역할

return은 함수 실행을 즉시 종료하고, 지정한 값을 호출한 자리로 돌려줍니다. return을 만나면 그 아래 남은 코드는 실행되지 않습니다. 반환형이 void인 함수는 값을 돌려주지 않으며, return;(값 없이)으로 중간에 함수를 끝내거나, 함수 끝까지 도달하면 자동으로 종료됩니다.

3. 지역 변수와 전역 변수의 범위

지역 변수(local variable, 지역 변수)는 함수(또는 블록) 안에서 선언되어, 그 함수(블록)가 실행되는 동안에만 존재하고 함수가 끝나면 사라집니다. 전역 변수(global variable, 전역 변수)는 함수 밖에서 선언되어, 프로그램이 실행되는 내내 존재하며 모든 함수에서 접근할 수 있습니다.

#include <stdio.h> int counter = 0; /* 전역 변수 */ void increase(void) { int counter = 100; /* 지역 변수: 전역 변수와 이름만 같은 별개의 변수 */ counter++; printf("함수 안 counter: %d\n", counter); } int main(void) { increase(); printf("함수 밖 counter: %d\n", counter); return 0; }
함수 안 counter: 101 함수 밖 counter: 0

해석: increase 함수 안에서 int counter = 100;으로 같은 이름의 지역 변수를 새로 선언했습니다. 이렇게 되면 함수 안에서 counter를 쓸 때는 바깥의 전역 변수가 아니라 가장 가까운 범위(scope, 스코프)의 지역 변수가 우선됩니다. 이 현상을 가리기(shadowing, 섀도잉)라고 부릅니다. 함수 안의 counter++는 지역 변수 101을 만들 뿐, 전역 변수 counter는 여전히 0으로 남습니다.

자주 틀리는 점: 지역 변수가 전역 변수와 이름이 같으면, 그 지역 변수의 범위 안에서는 전역 변수에 접근할 수 없게 가려집니다(사라지는 것이 아니라 가려질 뿐입니다). 시험에서는 이런 이름 충돌 코드를 주고 “각 counter가 가리키는 것이 무엇인가”를 묻습니다.

4. 함수 호출과 스택 프레임

06편·05편에서 스택(stack) 영역의 큰 그림을 소개했습니다. 이제 함수가 호출될 때 스택에 정확히 무슨 일이 일어나는지 살펴봅니다.

함수가 호출될 때마다, 그 함수를 위한 스택 프레임(stack frame, 스택 프레임)이 스택 영역에 새로 쌓입니다(push). 스택 프레임에는 그 함수의 매개변수, 지역 변수, 그리고 함수가 끝난 뒤 되돌아갈 위치(복귀 주소)가 저장됩니다. 함수가 끝나면 그 스택 프레임은 통째로 제거됩니다(pop).

#include <stdio.h> int square(int x) { int result = x * x; return result; } int main(void) { int a = 4; int b = square(a); printf("%d\n", b); return 0; }
실행 시점스택에 쌓인 프레임각 프레임의 지역 변수
main 시작 직후maina(값 미정)
a = 4 실행 후maina = 4
square(a) 호출 순간main, squaremain: a = 4 / square: x = 4
result = x * x 실행 후main, squaremain: a = 4 / square: x = 4, result = 16
square 종료(return 직후)mainmain: a = 4, b = 16

해석: square(a)를 호출하는 순간 square만을 위한 새 스택 프레임이 스택 맨 위에 쌓입니다. 이 프레임 안의 x, resultsquare가 실행되는 동안에만 존재하는 지역 변수입니다. return result;가 실행되면 그 값(16)이 호출한 자리로 전달되고, square의 스택 프레임은 완전히 제거됩니다. 이후 main에서 xresult라는 이름의 변수에 접근하는 것은 불가능합니다(애초에 main의 스택 프레임에는 그런 변수가 없었기 때문입니다).

재귀 호출 시 스택 프레임이 쌓이는 모습

함수가 자기 자신을 호출하는 것을 재귀(recursion, 재귀)라고 합니다. 재귀 호출은 호출할 때마다 매번 새로운 스택 프레임이 별도로 쌓인다는 점을 이해하면 헷갈리지 않습니다.

int factorial(int n) { if (n <= 1) { return 1; } return n * factorial(n - 1); }

factorial(3)을 호출하면 스택에는 다음과 같이 프레임이 쌓입니다.

호출 깊이쌓이는 프레임그 프레임의 n반환을 기다리는 식
1factorial(3)33 * factorial(2)의 결과 대기
2factorial(2)22 * factorial(1)의 결과 대기
3factorial(1)1n <= 1이므로 즉시 1 반환

가장 안쪽(factorial(1))부터 값이 확정되며 스택 프레임이 하나씩 제거(pop)되고, 그 값을 이용해 바깥쪽 호출이 순서대로 완료됩니다: factorial(1)이 1을 반환 → factorial(2)2 * 1 = 2를 반환 → factorial(3)3 * 2 = 6을 반환합니다.

자주 틀리는 점: 재귀 함수의 각 호출은 서로 다른 스택 프레임을 가지므로, n이라는 같은 이름의 변수라도 호출 깊이마다 값이 다른 별개의 변수입니다. “재귀 호출에서 n을 바꾸면 이전 호출의 n도 바뀐다”는 것은 잘못된 생각입니다. 또한 if (n <= 1) return 1;처럼 재귀를 멈추는 조건(기저 조건, base case)이 없으면 스택 프레임이 무한히 쌓이다가 메모리가 고갈되는 스택 오버플로(stack overflow, 스택 오버플로)가 발생합니다.

핵심 정리

  • 함수 정의는 본문 포함, 함수 선언(프로토타입)은 본문 없이 존재만 알림. 프로토타입은 호출보다 먼저 나와야 한다.
  • C는 인자를 값으로 전달한다. 함수 안에서 매개변수를 바꿔도 호출한 쪽의 원본 변수는 바뀌지 않는다.
  • 지역 변수는 함수(블록) 실행 동안만 존재하고, 전역 변수는 프로그램 실행 내내 존재한다.
  • 지역 변수와 전역 변수의 이름이 같으면 지역 변수가 전역 변수를 가린다(shadowing).
  • 함수를 호출할 때마다 그 함수만의 스택 프레임이 push되고, 함수가 끝나면 pop되어 사라진다.
  • 재귀 호출은 호출마다 별도의 스택 프레임을 쌓으며, 기저 조건이 없으면 스택 오버플로가 발생한다.

마무리 복습

문제 14지선다
함수 프로토타입에 대한 설명으로 옳은 것은?
문제 24지선다
다음 코드를 실행하면 함수 밖 x에는 어떤 값이 출력되는가? (함수 addOne은 매개변수 n을 n+1로 바꾸고, main에서 x=5로 addOne(x)를 호출한 뒤 x를 출력)
문제 34지선다
지역 변수와 전역 변수의 이름이 같을 때 함수 안에서 그 이름을 사용하면 어떤 변수가 우선하는가?
문제 44지선다
함수를 호출할 때마다 새로 생성되고, 함수가 끝나면 제거되는 메모리 단위로 매개변수·지역 변수·복귀 주소를 담는 것은?
문제 54지선다
factorial(3)을 재귀로 호출할 때, factorial(1)의 스택 프레임에 대한 설명으로 옳은 것은?
문제 64지선다
재귀 함수에 기저 조건(base case)이 없을 때 발생할 수 있는 문제로 가장 적절한 것은?

참고 자료

Last updated on