이번 문서의 목표: 이 파일을 다 읽으면 C에서 함수 호출이 왜 항상 “값에 의한 전달”인지 설명할 수 있고, 포인터 인자로 원본 변수를 바꾸는 코드와 배열을 함수에 넘길 때 실제로 무엇이 복사되는지를 스택 프레임 그림으로 추적할 수 있다.
왜 이 주제가 중요한가
10편에서 함수를 배울 때 “매개변수는 값이 복사되어 전달된다”고 했다. 그런데 실제 시험 문제에서는 함수 안에서 매개변수 값을 바꿔도 호출한 쪽의 변수가 왜 안 바뀌는지, 반대로 포인터를 넘기면 왜 바뀌는지, 배열을 넘기면 왜 항상 바뀌는지를 헷갈려서 틀리는 경우가 압도적으로 많다. C프로그래밍 시험에서 포인터 인자·배열 인자 문제는 “출력 예측형”으로 거의 매 회 출제되는 핵심 유형이므로, 이 편에서 스택 프레임과 메모리 주소를 직접 그려가며 원리를 끝까지 파고든다.
C의 매개변수 전달 방식: 값에 의한 전달
쉽게 말하면: C는 함수를 호출할 때 인자의 “값”을 매개변수라는 새 상자에 복사해 넣을 뿐, 원본 변수 자체를 건네주지 않는다.
C 언어의 함수 호출은 예외 없이 값에 의한 전달(pass by value, 값 전달)만 지원한다. 함수를 호출하면 호출한 쪽(caller, 호출자)의 인자 값이 그대로 복사되어, 호출된 함수(callee, 피호출자)의 매개변수라는 새로운 지역 변수에 저장된다. 이때 매개변수는 원본 변수와 이름만 같을 수 있을 뿐, 메모리 주소가 다른 완전히 별개의 변수다.
10편에서 배웠듯 함수가 호출되면 스택 프레임(stack frame, 함수 호출마다 스택에 새로 쌓이는 메모리 구역)이 하나 생기고, 그 안에 매개변수와 지역 변수가 자리를 잡는다. 값 전달의 핵심은 바로 이 지점이다 — 호출자의 스택 프레임에 있는 변수와 피호출자의 스택 프레임에 있는 매개변수는 물리적으로 다른 메모리 칸이라는 점이다.
#include <stdio.h>
void 값증가(int x) {
x = x + 1;
printf("함수 안: x = %d\n", x);
}
int main(void) {
int 원본 = 10;
값증가(원본);
printf("함수 밖: 원본 = %d\n", 원본);
return 0;
}함수 안: x = 11
함수 밖: 원본 = 10한 줄씩 실행 추적:
| 단계 | 실행 위치 | 메모리 상태 |
|---|---|---|
| 1 | int 원본 = 10; | main 프레임에 원본 생성, 값 10 |
| 2 | 값증가(원본); 호출 | 값증가 프레임 새로 생성. 원본의 값 10이 복사되어 새 변수 x에 저장 |
| 3 | x = x + 1; | 값증가 프레임의 x만 11로 변경. 원본은 영향 없음 |
| 4 | printf (함수 안) | x는 11 출력 |
| 5 | 함수 반환 | 값증가 프레임 소멸(x 사라짐) |
| 6 | printf (함수 밖) | main 프레임의 원본은 그대로 10 |
x와 원본은 이름만 다를 뿐 아니라 애초에 주소가 다른 별개의 변수다. 함수 안에서 x를 아무리 바꿔도 원본이 저장된 메모리 칸은 건드리지 않으므로, 함수가 끝나고 돌아오면 원본은 처음 값 그대로다.
시험 함정: “C는 값 전달과 참조 전달을 모두 지원한다”는 설명은 옳지 않은 것 고르기 유형에서 정답(틀린 문장)으로 자주 나온다. C++와 달리 C에는 참조 전달 문법(& 매개변수)이 없다. C에서 원본을 바꾸는 것처럼 “보이는” 코드는 사실 뒤에서 설명할 포인터를 값으로 전달하는 것이지, 참조 전달이 아니다.
포인터 인자로 원본 바꾸기
쉽게 말하면: 변수 자체 대신 그 변수의 “주소”를 값으로 넘기면, 함수 안에서 그 주소를 역참조해 원본이 있는 메모리 칸을 직접 고칠 수 있다.
12편에서 &(주소 연산자, 변수의 메모리 주소를 얻는다)와 *(역참조 연산자, 포인터가 가리키는 곳의 값에 접근한다)를 배웠다. 함수에서 원본 변수를 바꾸고 싶다면, 변수의 값 대신 변수의 주소를 인자로 넘기면 된다. 주소도 결국 하나의 값(정수 형태의 메모리 위치)이므로, 이 역시 값 전달의 일종이다 — 다만 복사되는 값이 “주소”라는 점이 다르다.
#include <stdio.h>
void 값증가(int *px) {
*px = *px + 1;
printf("함수 안: *px = %d, px가 가리키는 주소 = %p\n", *px, (void *)px);
}
int main(void) {
int 원본 = 10;
printf("main: &원본 = %p\n", (void *)&원본);
값증가(&원본);
printf("함수 밖: 원본 = %d\n", 원본);
return 0;
}main: &원본 = 0x7ffee2a1c9ac
함수 안: *px = 11, px가 가리키는 주소 = 0x7ffee2a1c9ac
함수 밖: 원본 = 11한 줄씩 실행 추적:
| 단계 | 실행 위치 | 메모리 상태 |
|---|---|---|
| 1 | int 원본 = 10; | main 프레임에 원본 생성(주소를 0x...9ac라 하자), 값 10 |
| 2 | 값증가(&원본); | &원본이 계산되어 주소값 0x...9ac가 복사됨. 값증가 프레임의 px에 그 주소값 저장 |
| 3 | *px = *px + 1; | px가 가리키는 곳(0x...9ac, 즉 원본이 있는 그 칸)의 값을 읽어(10) 1을 더해(11) 그 칸에 다시 씀 |
| 4 | 함수 반환 | px는 사라지지만, px가 가리켰던 원본의 메모리 칸에는 11이 남음 |
| 5 | printf (함수 밖) | 원본을 직접 읽으니 11 |
여기서 복사된 것은 여전히 “값”(주소값 0x...9ac)이라는 점에 주의한다. 다만 그 값이 가리키는 곳이 원본과 정확히 같은 메모리 칸이기 때문에, *px를 통한 쓰기가 원본에도 그대로 반영되는 것이다.
시험 함정: px에 저장된 값을 바꾸는 것(px = &다른변수;)과 px가 가리키는 곳의 값을 바꾸는 것(*px = 새값;)은 완전히 다르다. 전자는 px라는 포인터 변수 자체의 내용(주소)을 바꾸는 것이고, 후자는 그 포인터가 가리키는 대상의 값을 바꾸는 것이다. 원본을 바꾸려면 반드시 *px처럼 역참조해야 한다.
두 변수를 맞바꾸는 swap 함수
포인터 인자의 대표적인 활용 예가 두 변수의 값을 서로 바꾸는 swap 함수다. 값 전달만으로는 절대 구현할 수 없고, 반드시 포인터가 필요하다는 점을 실행 추적으로 확인한다.
#include <stdio.h>
void swap(int *a, int *b) {
int temp = *a;
*a = *b;
*b = temp;
}
int main(void) {
int x = 1, y = 2;
swap(&x, &y);
printf("x = %d, y = %d\n", x, y);
return 0;
}x = 2, y = 1temp는 지역 변수로, *a(즉 x의 값) 1을 잠깐 보관한다. *a = *b;로 x의 자리에 y의 값 2를 쓰고, *b = temp;로 y의 자리에 보관해 둔 1을 쓴다. a와 b가 각각 x, y의 주소를 가리키고 있으므로, *a와 *b를 통한 쓰기는 곧바로 x, y 자신의 메모리 칸을 고치는 것이 된다.
만약 swap(int a, int b)처럼 값으로만 받았다면, a와 b는 x, y와 전혀 다른 메모리 칸이므로 함수 안에서 아무리 값을 바꿔도 x, y는 그대로였을 것이다. 이 비교가 값 전달과 포인터 전달의 차이를 가장 분명하게 보여주는 예시다.
배열을 함수 인자로 넘길 때 일어나는 일
쉽게 말하면: 배열은 함수에 넘기는 순간 이름이 “배열 전체”에서 “첫 원소를 가리키는 포인터”로 변신하기 때문에, 배열은 항상 원본이 직접 바뀐다.
11편과 12편에서 배열 이름이 대부분의 문맥에서 첫 번째 원소의 주소로 자동 변환된다고 배웠다. 함수의 인자 자리도 이 규칙이 적용되는 대표적인 문맥이다. 배열을 함수에 넘기면, 실제로 복사되는 것은 배열 전체(모든 원소)가 아니라 배열의 첫 원소를 가리키는 포인터 하나뿐이다.
#include <stdio.h>
void 모두더하기1(int arr[], int n) {
for (int i = 0; i < n; i++) {
arr[i] = arr[i] + 1;
}
}
int main(void) {
int 숫자들[5] = {1, 2, 3, 4, 5};
모두더하기1(숫자들, 5);
for (int i = 0; i < 5; i++) {
printf("%d ", 숫자들[i]);
}
printf("\n");
return 0;
}2 3 4 5 6한 줄씩 실행 추적:
| 단계 | 실행 위치 | 메모리 상태 |
|---|---|---|
| 1 | int 숫자들[5] = {1,2,3,4,5}; | main 프레임에 배열 5칸 생성, 시작 주소를 0x...100이라 하자 |
| 2 | 모두더하기1(숫자들, 5); | 숫자들이 첫 원소 주소 0x...100으로 변환되어 복사됨. 매개변수 arr에 그 주소값 저장. n에는 5가 복사됨 |
| 3 | for 루프 각 반복 | arr[i]는 *(arr + i)와 같은 뜻이므로, 0x...100에서 i칸 떨어진 곳(즉 숫자들[i]가 있는 바로 그 칸)에 직접 접근해 값을 1 증가 |
| 4 | 함수 반환 | arr, n은 사라지지만, 숫자들 배열의 각 칸에는 이미 값이 바뀌어 남음 |
| 5 | main의 출력 루프 | 숫자들을 직접 읽으니 2 3 4 5 6 |
여기서 함수 매개변수 선언 int arr[]은 컴파일러 내부적으로 int *arr과 완전히 동일하게 처리된다. 함수 매개변수 자리에서만 성립하는 특별 규칙이며, 배열의 “크기” 정보는 이 과정에서 사라지므로 배열을 함수에 넘길 때는 반드시 원소 개수(n)를 별도 인자로 함께 넘겨야 한다.
시험 함정: 함수 안에서 sizeof(arr)를 계산하면, 배열 전체 크기가 아니라 포인터 하나의 크기(대개 8바이트, 64비트 환경 기준)가 나온다. “함수 매개변수로 받은 배열에 sizeof를 쓰면 원본 배열 크기를 구할 수 있다”는 설명은 옳지 않은 것 고르기 문제의 단골 오답이다. sizeof로 배열 크기를 구하는 것은 배열이 선언된 그 스코프(범위) 안에서만 가능하다.
배열 인자와 const 포인터
값이 바뀌지 않아야 하는 배열을 함수에 넘길 때는, 매개변수를 const int arr[] 또는 const int *arr로 선언해 함수 안에서 원소를 실수로 바꾸는 것을 컴파일 단계에서 막을 수 있다. const가 붙은 포인터를 통해 값을 바꾸려 하면 컴파일 오류가 발생한다.
void 배열출력(const int arr[], int n) {
for (int i = 0; i < n; i++) {
printf("%d ", arr[i]);
}
/* arr[0] = 100; 이 줄을 넣으면 컴파일 오류 발생 (const 위반) */
}const는 “이 함수는 배열 원본을 읽기만 하고 바꾸지 않는다”는 약속을 컴파일러에게 알리는 역할을 한다. 함수 시그니처(선언부)만 보고도 그 함수가 배열을 변경하는지 여부를 알 수 있게 해주므로, 실전 코드에서 자주 쓰이는 관용구다.
함수 시그니처로 동작 예측하기
시험에서는 함수 원형(prototype, 함수의 이름·반환형·매개변수 목록만 적은 선언)만 보고 그 함수가 원본을 바꿀 수 있는지 판단하는 문제가 자주 나온다. 다음 표로 정리한다.
| 매개변수 선언 | 넘기는 인자 | 원본 변경 가능? | 이유 |
|---|---|---|---|
int x | func(원본) | 불가능 | 값만 복사, 별개의 메모리 칸 |
int *px | func(&원본) | 가능 | 원본의 주소를 받아 역참조로 직접 접근 |
const int *px | func(&원본) | 불가능(컴파일 오류) | 주소는 받지만 그 값을 통한 쓰기가 금지됨 |
int arr[] | func(배열) | 가능(항상) | 배열 이름이 첫 원소 주소로 변환되어 전달 |
const int arr[] | func(배열) | 불가능(컴파일 오류) | 배열 원소를 읽기 전용으로 제한 |
자주 틀리는 점
- “매개변수 이름이 원본 변수와 같으면 서로 연결된다”고 착각하는 경우가 많다. C에서 변수는 이름이 아니라 메모리 주소로 구별되므로, 이름이 같아도 스코프(범위)가 다르면 완전히 별개의 변수다.
- 포인터를 넘겼는데도 원본이 안 바뀌는 문제는 대부분
px = &새값;처럼 포인터 자체를 바꾼 것과*px = 새값;처럼 가리키는 대상을 바꾼 것을 혼동한 경우다. - 배열을 함수에 넘길 때 크기 정보가 함께 넘어간다고 착각한다. 실제로는 주소 하나만 넘어가므로, 크기는 항상 별도 매개변수로 받아야 한다.
- 2차원 배열을 함수에 넘길 때
int arr[][열개수]처럼 열의 크기는 반드시 명시해야 한다. 행 크기는 생략 가능하지만 열 크기가 없으면 컴파일러가 각 행의 시작 위치를 계산할 수 없어 컴파일 오류가 난다.
핵심 정리
- C의 함수 호출은 예외 없이 값에 의한 전달이다. 참조 전달 문법은 C에 존재하지 않는다.
- 변수 자체를 넘기면 함수 안에서 원본을 바꿀 수 없고, 변수의 주소(포인터)를 넘기면 역참조를 통해 원본을 직접 바꿀 수 있다.
- 배열은 함수 인자 자리에서 이름이 자동으로 첫 원소의 주소(포인터)로 변환되므로, 배열 인자는 크기와 무관하게 항상 포인터 하나만 복사되고, 원소 변경은 언제나 원본에 반영된다.
- 함수 매개변수로 받은 배열에
sizeof를 쓰면 포인터 크기만 나오므로, 원소 개수는 반드시 별도 인자로 받아야 한다. const가 붙은 포인터·배열 매개변수는 함수가 원본을 읽기만 하고 바꾸지 않는다는 약속이며, 위반 시 컴파일 오류가 발생한다.
마무리 복습
void f(int n) { n = n * 2; }
int main(void) {
int a = 5;
f(a);
printf("%d", a);
}void f(int arr[]) { printf("%zu", sizeof(arr)); }
int main(void) {
int a[10];
f(a);
}void swap(int *a, int *b) {
int t = *a;
*a = *b;
*b = t;
}
int main(void) {
int x = 3, y = 7;
swap(&x, &y);
}참고 자료
- C 언어 포인터 집중 강의 노트 성격의 대학 강의 자료 — 포인터를 함수 인자로 전달하는 패턴과 배열-포인터 관계 설명에 참고
- 국가평생교육진흥원 독학학위제: https://bdes.nile.or.kr