Skip to Content
독학사독학사 2단계C프로그래밍12. 포인터와 함수: 인자 전달·포인터 인자·배열 인자

이번 문서의 목표: 이 파일을 다 읽으면 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

한 줄씩 실행 추적:

단계실행 위치메모리 상태
1int 원본 = 10;main 프레임에 원본 생성, 값 10
2값증가(원본); 호출값증가 프레임 새로 생성. 원본의 값 10이 복사되어 새 변수 x에 저장
3x = x + 1;값증가 프레임의 x만 11로 변경. 원본은 영향 없음
4printf (함수 안)x는 11 출력
5함수 반환값증가 프레임 소멸(x 사라짐)
6printf (함수 밖)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

한 줄씩 실행 추적:

단계실행 위치메모리 상태
1int 원본 = 10;main 프레임에 원본 생성(주소를 0x...9ac라 하자), 값 10
2값증가(&원본);&원본이 계산되어 주소값 0x...9ac가 복사됨. 값증가 프레임의 px에 그 주소값 저장
3*px = *px + 1;px가 가리키는 곳(0x...9ac, 즉 원본이 있는 그 칸)의 값을 읽어(10) 1을 더해(11) 그 칸에 다시 씀
4함수 반환px는 사라지지만, px가 가리켰던 원본의 메모리 칸에는 11이 남음
5printf (함수 밖)원본을 직접 읽으니 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 = 1

temp는 지역 변수로, *a(즉 x의 값) 1을 잠깐 보관한다. *a = *b;x의 자리에 y의 값 2를 쓰고, *b = temp;y의 자리에 보관해 둔 1을 쓴다. ab가 각각 x, y의 주소를 가리키고 있으므로, *a*b를 통한 쓰기는 곧바로 x, y 자신의 메모리 칸을 고치는 것이 된다.

만약 swap(int a, int b)처럼 값으로만 받았다면, abx, 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

한 줄씩 실행 추적:

단계실행 위치메모리 상태
1int 숫자들[5] = {1,2,3,4,5};main 프레임에 배열 5칸 생성, 시작 주소를 0x...100이라 하자
2모두더하기1(숫자들, 5);숫자들이 첫 원소 주소 0x...100으로 변환되어 복사됨. 매개변수 arr에 그 주소값 저장. n에는 5가 복사됨
3for 루프 각 반복arr[i]*(arr + i)와 같은 뜻이므로, 0x...100에서 i칸 떨어진 곳(즉 숫자들[i]가 있는 바로 그 칸)에 직접 접근해 값을 1 증가
4함수 반환arr, n은 사라지지만, 숫자들 배열의 각 칸에는 이미 값이 바뀌어 남음
5main의 출력 루프숫자들을 직접 읽으니 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 xfunc(원본)불가능값만 복사, 별개의 메모리 칸
int *pxfunc(&원본)가능원본의 주소를 받아 역참조로 직접 접근
const int *pxfunc(&원본)불가능(컴파일 오류)주소는 받지만 그 값을 통한 쓰기가 금지됨
int arr[]func(배열)가능(항상)배열 이름이 첫 원소 주소로 변환되어 전달
const int arr[]func(배열)불가능(컴파일 오류)배열 원소를 읽기 전용으로 제한

자주 틀리는 점

  • “매개변수 이름이 원본 변수와 같으면 서로 연결된다”고 착각하는 경우가 많다. C에서 변수는 이름이 아니라 메모리 주소로 구별되므로, 이름이 같아도 스코프(범위)가 다르면 완전히 별개의 변수다.
  • 포인터를 넘겼는데도 원본이 안 바뀌는 문제는 대부분 px = &새값;처럼 포인터 자체를 바꾼 것과 *px = 새값;처럼 가리키는 대상을 바꾼 것을 혼동한 경우다.
  • 배열을 함수에 넘길 때 크기 정보가 함께 넘어간다고 착각한다. 실제로는 주소 하나만 넘어가므로, 크기는 항상 별도 매개변수로 받아야 한다.
  • 2차원 배열을 함수에 넘길 때 int arr[][열개수]처럼 열의 크기는 반드시 명시해야 한다. 행 크기는 생략 가능하지만 열 크기가 없으면 컴파일러가 각 행의 시작 위치를 계산할 수 없어 컴파일 오류가 난다.

핵심 정리

  • C의 함수 호출은 예외 없이 값에 의한 전달이다. 참조 전달 문법은 C에 존재하지 않는다.
  • 변수 자체를 넘기면 함수 안에서 원본을 바꿀 수 없고, 변수의 주소(포인터)를 넘기면 역참조를 통해 원본을 직접 바꿀 수 있다.
  • 배열은 함수 인자 자리에서 이름이 자동으로 첫 원소의 주소(포인터)로 변환되므로, 배열 인자는 크기와 무관하게 항상 포인터 하나만 복사되고, 원소 변경은 언제나 원본에 반영된다.
  • 함수 매개변수로 받은 배열에 sizeof를 쓰면 포인터 크기만 나오므로, 원소 개수는 반드시 별도 인자로 받아야 한다.
  • const가 붙은 포인터·배열 매개변수는 함수가 원본을 읽기만 하고 바꾸지 않는다는 약속이며, 위반 시 컴파일 오류가 발생한다.

마무리 복습

문제 14지선다
C 언어의 함수 호출 방식에 대한 설명으로 옳은 것은?
void f(int n) { n = n * 2; } int main(void) { int a = 5; f(a); printf("%d", a); }
문제 24지선다
위 코드를 실행했을 때 출력되는 값은 무엇인가?
문제 34지선다
원본 변수의 값을 함수 안에서 바꾸고 싶을 때 매개변수를 선언하는 올바른 방법은?
문제 44지선다
배열을 함수 인자로 넘길 때 실제로 함수에 복사되어 전달되는 것은?
void f(int arr[]) { printf("%zu", sizeof(arr)); } int main(void) { int a[10]; f(a); }
문제 54지선다
위 함수 f 안에서 sizeof(arr)를 계산했을 때 나오는 값은 무엇인가?
void swap(int *a, int *b) { int t = *a; *a = *b; *b = t; } int main(void) { int x = 3, y = 7; swap(&x, &y); }
문제 64지선다
위 swap 함수 실행 후 main의 x, y 값으로 옳은 것은?
문제 74지선다
const int arr[]로 선언된 배열 매개변수에 대한 설명으로 옳은 것은?

참고 자료

Last updated on