자료형이 데이터를 다루는 방법이라면, 함수function는 동작을 다루는 방법입니다. C 프로그램은 함수의 집합이며, 가장 작은 프로그램조차 main이라는 함수 하나로 이루어져 있습니다. 이번 글에서는 함수를 정의하고 호출하는 기본 문법부터, 호출 시 내부에서 무슨 일이 일어나는지까지를 다룹니다.
함수란
프로그램을 작성하다 보면 같은 작업을 여러 곳에서 반복해야 하는 상황이 생긴다. 예를 들어 두 정수 중 큰 값을 고르는 작업이 프로그램 곳곳에서 필요하다고 하자. 매번 비교 코드를 복사할 수도 있지만, 이 작업을 하나의 이름 아래 묶어두면 한 번 작성해서 여러 번 재사용할 수 있다. 그 이름 아래 묶인 코드 단위가 함수다.
#include <stdio.h>
int max(int a, int b) {
if (a >= b)
return a;
else
return b;
}
int main(void) {
int result = max(3, 7);
printf("%d\n", result); // 7
return 0;
}
이 코드에서 max는 두 정수를 받아 큰 쪽을 돌려주는 함수다. main에서 max(3, 7)을 호출하면 실행 흐름이 max로 이동하고, return을 만나면 다시 main으로 돌아온다. 이 예제를 통해 함수의 기본 용어를 정리할 수 있다.
함수의 구성 요소
C 함수는 네 가지 구성 요소로 이루어진다.
int max(int a, int b) {
if (a >= b)
return a;
else
return b;
}
- 반환 타입return type: 함수 이름 앞에 오는 자료형이다.
max의 반환 타입은int로, 이 함수가 정수 값을 돌려준다는 뜻이다. 돌려줄 값이 없는 함수는void를 사용한다. - 함수 이름function name. 함수를 호출할 때 사용하는 식별자다.
max가 이 함수의 이름이다. - 매개변수 목록parameter list. 괄호 안에 선언된 변수들이다.
int a, int b가 매개변수 목록으로, 이 함수가 외부로부터 두 개의 정수를 받는다는 것을 나타낸다. 매개변수가 없는 함수는void를 명시하거나 괄호를 비워둔다1. - 함수 본문function body. 중괄호
{}로 감싸진 코드 블록이다. 함수가 호출되면 이 블록 안의 문장이 위에서 아래로 실행된다.return문은 값을 반환하면서 함수의 실행을 종료한다.
매개변수와 인자
매개변수parameter와 인자argument는 자주 혼용되지만, 엄밀히 구분된다.
- 매개변수는 함수를 정의할 때 선언하는 변수다. 함수 시그니처에 이름과 타입이 명시되며, 함수 본문 안에서 지역 변수처럼 사용된다.
max의a와b가 매개변수다. - 인자는 함수를 호출할 때 전달하는 실제 값이다.
max(3, 7)에서3과7이 인자다.
int max(int a, int b) { // a, b는 매개변수(parameter)
// ...
}
int result = max(3, 7); // 3, 7은 인자(argument)
매개변수는 함수의 선언에 속하고, 인자는 함수의 호출에 속한다.
함수 선언과 함수 정의
C에서는 함수의 선언declaration과 정의definition가 분리될 수 있다. 이것은 다른 많은 언어에는 없는, C의 분할 컴파일 모델에서 비롯된 특성이다.
- 함수 정의는 반환 타입, 이름, 매개변수 목록, 그리고 본문을 모두 포함한다. 앞서 본
max함수가 정의다. - 함수 선언 (또는 함수 원형prototype)은 반환 타입, 이름, 매개변수 목록만 적고 본문 대신 세미콜론으로 끝낸다. 컴파일러에게 "이런 시그니처의 함수가 어딘가에 존재한다"고 알리는 역할이다.
int max(int a, int b); // 선언 (prototype)
선언이 필요한 이유
C 컴파일러는 소스 코드를 위에서 아래로 한 번만 읽는다2. 함수를 호출하는 시점에 컴파일러가 그 함수의 존재를 아직 모르면, 인자의 개수나 타입을 검사할 수 없다. 다음 코드는 이 문제를 보여준다.
#include <stdio.h>
int main(void) {
int result = max(3, 7); // 컴파일러: max가 뭔지 모름
printf("%d\n", result);
return 0;
}
int max(int a, int b) {
if (a >= b) return a;
else return b;
}
main이 max보다 먼저 나오기 때문에, 컴파일러가 max(3, 7)을 만나는 시점에는 max의 시그니처를 알 수 없다. C89에서는 이 경우 컴파일러가 암묵적으로 int max()를 가정했지만, 이는 타입 안전성을 해치는 위험한 동작이었다. C99부터는 선언 없이 함수를 호출하면 컴파일 오류가 발생한다3.
이 문제를 해결하는 방법은 main보다 앞에 함수 원형을 선언하는 것이다.
#include <stdio.h>
int max(int a, int b); // 함수 원형 (선언)
int main(void) {
int result = max(3, 7); // OK: 컴파일러가 시그니처를 알고 있음
printf("%d\n", result);
return 0;
}
int max(int a, int b) { // 함수 정의
if (a >= b) return a;
else return b;
}
선언이 정의보다 앞에 오면, 컴파일러는 호출 시점에 인자의 개수와 타입을 검증할 수 있다. 대규모 프로젝트에서는 함수 선언을 헤더 파일(.h)에 모아두고, 정의는 소스 파일(.c)에 작성하는 것이 일반적이다.
함수가 있는 프로그램의 흐름
함수가 호출되면 프로그램의 실행 흐름은 순차 진행에서 벗어나 함수 본문으로 이동한다. return을 만나면 호출한 지점으로 되돌아온다.
#include <stdio.h>
int square(int n) {
return n * n;
}
int sum_of_squares(int a, int b) {
int sa = square(a);
int sb = square(b);
return sa + sb;
}
int main(void) {
int result = sum_of_squares(3, 4);
printf("%d\n", result); // 25
return 0;
}
이 프로그램의 실행 흐름을 추적하면 이렇다.
main이 sum_of_squares를 호출하고, sum_of_squares가 다시 square를 호출한다. 함수는 이렇게 다른 함수를 호출할 수 있으며, 각 호출은 끝나면 자신을 호출한 곳으로 정확히 돌아간다. 이 "돌아갈 위치"를 기억하는 메커니즘이 바로 호출 스택이다.
호출 스택
함수가 호출될 때마다 컴퓨터는 스택 프레임(stack frame)4이라는 메모리 블록을 스택에 쌓는다. 스택 프레임에는 다음 정보가 저장된다.
- 반환 주소return address: 함수가 끝난 뒤 실행을 재개할 명령어의 위치다.
sum_of_squares가square를 호출하면,square의 스택 프레임에는 "sum_of_squares의 다음 줄로 돌아가라"는 주소가 기록된다. - 매개변수와 지역 변수: 함수의 매개변수와 본문에서 선언한 변수가 이 프레임 안에 할당된다. 함수가 끝나면 프레임이 제거되므로, 지역 변수는 자동으로 사라진다.
앞서 본 sum_of_squares(3, 4) 호출 시점의 스택을 그려보면 이렇다.
square(4)가 return 16을 실행하면, square의 스택 프레임이 제거되고 실행은 sum_of_squares로 돌아간다. sum_of_squares가 return 25를 실행하면 이 프레임도 제거되고 main으로 돌아간다. 스택은 후입선출(Last In, First Out)5 구조이므로, 가장 최근에 호출된 함수가 가장 먼저 반환된다.
이 구조 덕분에 함수는 아무리 깊이 중첩되어 호출되더라도, 반환 시 정확한 위치로 돌아갈 수 있다. 다만 스택의 크기는 유한하므로, 재귀 호출이 너무 깊어지면 스택 오버플로(stack overflow)가 발생한다.
값에 의한 호출
C의 함수는 값에 의한 호출call-by-value만 지원한다. 인자로 전달하는 것은 원본이 아니라 원본의 복사본이다.
#include <stdio.h>
void increment(int n) {
n = n + 1;
printf("함수 안: %d\n", n); // 6
}
int main(void) {
int x = 5;
increment(x);
printf("함수 밖: %d\n", x); // 5 — 원본은 변하지 않음
return 0;
}
increment(x)를 호출하면 x의 값 5가 매개변수 n에 복사된다. 함수 안에서 n을 아무리 수정해도, 그것은 복사본이므로 원본 x에는 영향이 없다.
원본을 수정하고 싶다면 값 자체가 아니라 값이 저장된 주소를 전달해야 한다. 이때 포인터를 사용한다.
#include <stdio.h>
void increment(int *p) {
*p = *p + 1;
}
int main(void) {
int x = 5;
increment(&x);
printf("%d\n", x); // 6 — 원본이 수정됨
return 0;
}
&x는 x의 주소다. increment는 이 주소를 매개변수 p에 복사받는다. 주소 자체는 여전히 복사되지만, 그 주소를 통해 원본 x에 접근하여 값을 수정할 수 있다. 이것은 참조에 의한 호출call-by-reference이 아니라, "포인터 값의 복사를 통한 간접 접근"이다. C에서 모든 인자 전달은 값의 복사라는 원칙은 예외 없이 성립한다.
void 함수
값을 반환하지 않는 함수는 반환 타입으로 void를 사용한다.
void greet(const char *name) {
printf("안녕하세요, %s님!\n", name);
}
void 함수에서는 return;으로 조기 종료하거나, 함수 본문의 끝에 도달하면 자동으로 반환된다. return 뒤에 값을 적으면 컴파일 오류가 발생한다.
반대로, 매개변수를 받지 않는 함수는 매개변수 목록에 void를 명시한다.
int get_answer(void) {
return 42;
}
int get_answer()와 int get_answer(void)는 다르다. 전자는 "매개변수에 대한 정보가 없다"는 뜻이고6, 후자는 "매개변수가 없다"는 명시적 선언이다. C23에서는 빈 괄호도 void와 동일하게 "매개변수 없음"으로 해석되도록 변경되었다7.
main 함수
모든 C 프로그램은 main 함수에서 시작된다. 운영체제가 프로그램을 실행하면 main이 호출되고, main이 반환하면 프로그램이 종료된다.
int main(void) {
// 프로그램의 실행이 여기서 시작된다
return 0;
}
main의 반환값은 프로그램의 종료 상태exit status로, 운영체제에 전달된다. 0은 성공을 의미하고, 0이 아닌 값은 오류를 나타낸다. <stdlib.h>에 정의된 EXIT_SUCCESS와 EXIT_FAILURE 매크로를 사용하면 의미가 더 명확해진다.
main의 시그니처
C 표준이 허용하는 main의 시그니처는 두 가지다.
int main(void);
int main(int argc, char *argv[]);
argc는 명령줄 인자의 개수이고, argv는 각 인자를 가리키는 문자열 배열이다. 프로그램을 ./program hello world로 실행하면 argc는 3이고, argv[0]은 "./program", argv[1]은 "hello", argv[2]는 "world"가 된다.
main은 진짜 시작점인가
main이 프로그램의 진입점이라고 했지만, 정확히 말하면 main은 C 수준의 진입점이다. 운영체제가 프로그램을 메모리에 올리고 가장 먼저 실행하는 코드는 main이 아니라 C 런타임 라이브러리의 시작 루틴이다.
리눅스 환경에서 실제 실행 순서는 이렇다.
_start는 링커가 ELF8 바이너리에 기록하는 진짜 진입점이다. _start는 C 런타임 라이브러리의 __libc_start_main을 호출하고, 이 함수가 표준 라이브러리 초기화(표준 입출력 버퍼 설정, 환경 변수 구성 등)를 마친 뒤에 main을 호출한다. main이 반환하면 exit가 호출되어 atexit으로 등록된 정리 함수를 실행하고 프로세스를 종료한다9.
일반적인 C 프로그래밍에서 이 과정을 의식할 필요는 없다. 하지만 "main이 시작이다"라는 말은 "C 언어 수준에서 프로그래머가 작성하는 코드의 시작이다"라는 의미로 이해하는 것이 정확하다.
재귀 함수
함수가 자기 자신을 호출하는 것을 재귀recursion라 한다. 재귀는 문제를 같은 구조의 더 작은 하위 문제로 분해할 수 있을 때 유용하다.
unsigned long factorial(unsigned int n) {
if (n <= 1)
return 1; // 기저 조건 (base case)
return n * factorial(n - 1); // 재귀 호출
}
factorial(4)를 호출하면 다음과 같은 호출 체인이 만들어진다.
각 호출마다 스택 프레임이 쌓이므로, n이 매우 크면 스택 오버플로가 발생할 수 있다. 재귀 함수에는 반드시 기저 조건base case이 있어야 한다. 기저 조건이 없으면 함수는 무한히 자기 자신을 호출하다가 스택이 고갈된다.
반복문과 재귀 중 어느 쪽이 나은지는 상황에 따라 다르다. 팩토리얼처럼 단순한 선형 재귀는 반복문으로 바꾸는 편이 스택 공간을 절약할 수 있다. 반면 트리 순회나 분할 정복처럼 구조 자체가 재귀적인 문제에서는 재귀가 더 자연스럽고 읽기 쉬운 코드를 만든다.
출처
- ISO/IEC 9899:2011 (C11 표준), §6.7.6.3 Function declarators, §6.9.1 Function definitions.
- ISO/IEC 9899:2024 (C23 표준), §6.7.7.3 — 빈 괄호의 의미 변경.
- Kernighan, B. W. and Ritchie, D. M. (1988) The C Programming Language. 2nd edn. Englewood Cliffs: Prentice Hall.
- King, K. N. (2008) C Programming: A Modern Approach. 2nd edn. New York: W. W. Norton.
- Linux man-pages (2025)
_start(3),exit(3).
Footnotes
-
C에서
int f()와int f(void)는 의미가 다르다.int f()는 매개변수에 대한 정보를 제공하지 않는 것이고,int f(void)는 매개변수가 0개임을 명시적으로 선언하는 것이다. 후자를 권장한다. ↩ -
C 컴파일러가 실제로 소스를 단일 패스로 처리하는 것은 아니다. 하지만 식별자의 가시성visibility 규칙은 "선언 지점 이후"를 원칙으로 하므로, 프로그래머 관점에서는 위에서 아래로 읽는 것과 동일한 효과다. ↩
-
C89에서는 선언 없이 함수를 호출하면
int 함수이름()을 암묵적으로 가정했다. 이를 암묵적 함수 선언implicit function declaration이라 하며, 반환 타입이나 인자 타입이 실제와 다르면 정의되지 않은 동작undefined behavior, UB을 유발한다. C99에서 이 규칙이 제거되었고,-Wimplicit-function-declaration경고로 감지할 수 있다. ↩ -
스택 프레임은 활성화 레코드activation record라고도 부른다. 함수 호출 시 생성되어 반환 주소, 매개변수, 지역 변수, 저장된 레지스터 등을 포함한다. x86-64에서는
rbp(베이스 포인터)와rsp(스택 포인터) 레지스터가 현재 프레임의 경계를 표시한다. ↩ -
LIFO(Last In, First Out)는 "가장 나중에 들어간 것이 가장 먼저 나온다"는 원칙이다. 스택 자료구조의 기본 성질이며, 함수 호출과 반환이 이 순서를 따르기 때문에 호출 스택에 스택 구조가 사용된다. ↩
-
C에서
int f()와int f(void)의 차이는 역사적 유산이다. C의 전신인 K&R C에서는 함수 선언에 매개변수 타입을 적지 않았기 때문에, 빈 괄호가 "매개변수 정보 없음"을 의미하게 되었다. ANSI C(C89)에서 프로토타입이 도입된 뒤에도 호환성을 위해 이 의미가 유지되었다. ↩ -
C23(ISO/IEC 9899:2024)에서는 함수 선언자의 빈 괄호
()가(void)와 동일하게 "매개변수가 없음"을 의미하도록 변경되었다. WG14 문서 N2841 참조. 이 변경으로 K&R 스타일과의 역사적 비대칭이 해소되었다. ↩ -
ELF(Executable and Linkable Format)는 리눅스를 비롯한 Unix 계열 운영체제의 표준 실행 파일 형식이다. ELF 헤더에는 프로그램 실행 시 가장 먼저 실행될 주소(
e_entry)가 기록되어 있으며, 이 주소가 보통_start를 가리킨다. ↩ -
glibc의
__libc_start_main은main을 호출하기 전에 스레드 로컬 저장소(TLS) 초기화,__cxa_atexit등록, 시그널 핸들러 설정 등을 수행한다. 이 과정은 CRT(C Runtime) 시작 코드라 불리며,crt1.o,crti.o,crtn.o등의 오브젝트 파일에 구현되어 있다. 자세한 내용은 glibc 소스의csu/libc-start.c에서 확인할 수 있다. ↩