Skip to Content
독학사독학사 4단계통합프로그래밍06. C 언어 핵심 ②: 포인터와 함수, 동적 메모리 할당

이번 문서의 목표: 이 파일을 다 읽으면 함수에 값을 넘길 때와 포인터를 넘길 때 원본 변수가 왜 다르게 동작하는지 설명할 수 있고, malloc·free로 힙 메모리를 안전하게 할당·해제하는 코드를 한 줄씩 추적할 수 있으며, 동적 배열과 구조체를 malloc으로 만드는 패턴을 읽고 쓸 수 있다.

왜 함수와 포인터를 함께 봐야 하는가

06편에서 배운 포인터의 기본 성질(주소를 저장하고, 역참조로 값을 읽고 쓴다)은 함수 호출과 만났을 때 진가를 발휘한다. C 함수는 기본적으로 인자를 복사해서 넘기기 때문에, 함수 안에서 원본 변수를 직접 바꾸려면 포인터가 필요하다. 이 원리를 정확히 알아야 07편 뒤에 나오는 동적 메모리 할당(malloc이 반환하는 주소를 포인터 변수에 어떻게 저장하고 함수 사이에서 넘기는지)도 이해할 수 있다.

쉽게 말하면: 함수에 값을 그냥 넘기는 것은 “원본 문서를 복사해서 건네주는 것”이고, 포인터를 넘기는 것은 “원본 문서가 있는 서랍 위치(주소)를 알려주는 것”이다. 복사본에 낙서해도 원본은 그대로지만, 서랍 위치를 받은 사람은 원본에 직접 낙서할 수 있다.

call by value: 함수는 기본적으로 복사본을 받는다

C에서 함수를 호출하면, 인자로 넘긴 값은 매개변수에 복사되어 전달된다. 이를 값에 의한 전달(call by value)이라 한다.

#include <stdio.h> void increase(int n) { n = n + 1; printf("함수 안: n=%d\n", n); } int main(void) { int a = 5; increase(a); printf("함수 밖: a=%d\n", a); return 0; }
함수 안: n=6 함수 밖: a=5
단계코드a의 값n의 값비고
1int a = 5;5(아직 생성 전)main의 스택에 a 생성
2increase(a); 호출55a의 값 5가 n에 복사됨(서로 다른 메모리)
3n = n + 1;56n만 바뀜, a는 무관
4increase 종료 후5(스택 프레임 소멸)a는 처음 그대로

na는 메모리 상에서 완전히 별개의 변수다. n을 아무리 바꿔도 a는 영향을 받지 않는다. 이것이 값에 의한 전달의 한계이자, 다음 절에서 포인터가 필요한 이유다.

포인터를 넘기면 함수 밖의 변수를 바꿀 수 있다

함수에 변수의 주소를 넘기면, 함수 안에서 그 주소를 역참조해 원본 변수의 값을 직접 바꿀 수 있다.

#include <stdio.h> void increase(int *p) { *p = *p + 1; printf("함수 안: *p=%d\n", *p); } int main(void) { int a = 5; increase(&a); printf("함수 밖: a=%d\n", a); return 0; }
함수 안: *p=6 함수 밖: a=6
단계코드a의 값p가 가리키는 곳
1int a = 5;5-
2increase(&a); 호출5a의 주소가 p에 전달됨
3*p = *p + 1;6p가 가리키는 a의 값이 직접 바뀜
4increase 종료 후6a는 바뀐 채로 유지

대표 문제 유형: swap 함수

값에 의한 전달과 포인터 전달의 차이를 가장 잘 보여주는 시험 단골 문제가 두 변수의 값을 맞바꾸는 swap 함수다.

#include <stdio.h> void swap_wrong(int x, int y) { /* call by value - 실패 버전 */ int temp = x; x = y; y = temp; } void swap_right(int *x, int *y) { /* 포인터 전달 - 성공 버전 */ int temp = *x; *x = *y; *y = temp; } int main(void) { int a = 1, b = 2; swap_wrong(a, b); printf("swap_wrong 후: a=%d, b=%d\n", a, b); swap_right(&a, &b); printf("swap_right 후: a=%d, b=%d\n", a, b); return 0; }
swap_wrong 후: a=1, b=2 swap_right 후: a=2, b=1

swap_wrong은 매개변수 x, y만 서로 바뀔 뿐 maina, b는 전혀 건드리지 못한다. swap_righta, b주소를 받아 그 주소가 가리키는 값을 직접 맞바꾸므로 실제로 교환된다.

자주 틀리는 점: 배열을 함수에 넘길 때는 예외적으로 항상 주소(포인터)가 전달된다는 점과 헷갈리기 쉽다. void f(int arr[])처럼 배열을 매개변수로 선언해도 실제로는 void f(int *arr)와 동일하게 처리되며, 06편에서 본 “배열 이름은 포인터로 변환된다”는 성질 때문에 배열은 함수 안에서 원본이 그대로 바뀐다. 반면 int 같은 단일 변수는 명시적으로 &를 붙여 주소를 넘기지 않으면 복사본만 전달된다.

동적 메모리 할당: malloc과 free

지역 변수는 함수가 끝나면 자동으로 사라지고, 배열은 선언할 때 크기를 미리 정해야 한다. 하지만 실행 중에야 필요한 크기를 알 수 있거나, 함수가 끝난 뒤에도 데이터가 살아 있어야 하는 경우에는 동적 메모리 할당(dynamic memory allocation)이 필요하다. C에서는 malloc 함수로 힙(heap) 영역에서 원하는 크기만큼 메모리를 직접 요청한다.

함수시그니처동작
mallocvoid *malloc(size_t size)size 바이트만큼 힙에서 확보하고 시작 주소를 반환한다. 실패하면 NULL을 반환하며, 내용은 초기화되지 않는다
callocvoid *calloc(size_t n, size_t size)n개 × size 바이트를 확보하고 모든 바이트를 0으로 초기화한 뒤 시작 주소를 반환한다
reallocvoid *realloc(void *ptr, size_t newsize)기존 ptr이 가리키던 블록의 크기를 newsize로 조정하고 새 시작 주소를 반환한다
freevoid free(void *ptr)ptr이 가리키던 힙 메모리를 운영체제에 반납한다. 반환값은 없다
#include <stdio.h> #include <stdlib.h> int main(void) { int *p = (int *)malloc(sizeof(int)); if (p == NULL) { printf("할당 실패\n"); return 1; } *p = 42; printf("*p = %d\n", *p); free(p); p = NULL; /* 댕글링 포인터 방지 */ return 0; }
*p = 42
단계코드힙 상태p의 값
1malloc(sizeof(int))int 하나 크기만큼 힙에 새 블록 생성새 블록의 주소
2*p = 42;그 블록에 42 저장변화 없음
3free(p);블록이 운영체제에 반납됨여전히 옛 주소를 담고 있음(위험)
4p = NULL;-NULL(안전하게 무효화)

자주 틀리는 점: free(p)를 호출한 뒤 p에 남아 있는 주소값을 계속 사용하는 실수가 잦다. free는 메모리를 반납할 뿐 p의 값을 자동으로 지우지 않으므로, free 직후 p는 이미 반납된 메모리를 가리키는 댕글링 포인터(dangling pointer)가 된다. 이후 그 주소로 값을 읽거나 쓰면 정의되지 않은 동작이 발생한다. 그래서 free(p) 다음 줄에 p = NULL;을 넣는 습관이 안전하다. 반대로 malloc으로 얻은 메모리를 free하지 않고 그 포인터 변수마저 사라지면, 그 메모리는 프로그램이 끝날 때까지 되찾을 방법이 없는 메모리 누수(memory leak) 상태가 된다.

동적 배열 할당: 실행 중에 정해지는 크기

배열의 크기를 컴파일 시점이 아니라 실행 중 사용자 입력 등으로 정해야 할 때, malloc으로 원하는 개수만큼 연속된 메모리를 확보해 배열처럼 사용한다.

#include <stdio.h> #include <stdlib.h> int main(void) { int n = 3; int *arr = (int *)malloc(sizeof(int) * n); int i; if (arr == NULL) return 1; for (i = 0; i < n; i++) { arr[i] = (i + 1) * 10; } for (i = 0; i < n; i++) { printf("arr[%d]=%d\n", i, arr[i]); } free(arr); return 0; }
arr[0]=10 arr[1]=20 arr[2]=30

sizeof(int) * n으로 필요한 바이트 수를 정확히 계산하는 부분이 시험에서 빈칸으로 자주 나온다. n개의 int가 필요한데 sizeof(int)(단일 크기)만 넘기면 한 칸짜리 메모리만 확보되어, 이후 arr[1], arr[2]에 접근하는 순간 확보되지 않은 메모리를 침범하게 된다. 이렇게 malloc으로 얻은 메모리는 06편에서 본 정적 배열과 마찬가지로 arr[i], *(arr + i) 표기를 그대로 쓸 수 있다. 배열처럼 보이지만 실제로는 힙에 있는 포인터라는 점만 다르다.

동적 구조체 할당: 포인터 하나로 여러 필드를 관리

구조체(struct)의 정확한 문법은 08편에서 자세히 다루지만, 이번 편에서는 malloc과 함께 쓰는 패턴만 미리 본다. 구조체도 배열처럼 malloc으로 힙에 만들 수 있으며, 이때는 포인터를 통해 필드에 접근하는 -> 연산자를 함께 쓴다.

#include <stdio.h> #include <stdlib.h> struct Point { int x; int y; }; int main(void) { struct Point *p = (struct Point *)malloc(sizeof(struct Point)); if (p == NULL) return 1; p->x = 3; /* (*p).x = 3; 과 동일 */ p->y = 4; printf("p->x=%d, p->y=%d\n", p->x, p->y); free(p); return 0; }
p->x=3, p->y=4

p->x(*p).x를 줄여 쓴 것으로, “p가 가리키는 구조체의 x 필드”라는 뜻이다. sizeof(struct Point)는 구조체 안 모든 필드의 크기를 합친(정렬 규칙에 따라 약간 커질 수도 있는) 값을 반환하므로, malloc에 정확한 구조체 하나 분량을 요청할 수 있다. 구조체 배열·구조체 포인터의 세부 문법과 파일 입출력 연동은 08편에서 이어서 다룬다.

핵심 정리

  • 함수에 값을 그대로 넘기면(call by value) 매개변수는 원본과 별개의 복사본이며, 함수 안에서 바꿔도 원본은 변하지 않는다.
  • 함수에 변수의 주소(포인터)를 넘기면, 함수 안에서 역참조로 원본 변수의 값을 직접 바꿀 수 있다. swap 함수가 대표 예시다.
  • 배열을 함수에 넘기면 배열 이름이 포인터로 변환되어 항상 원본이 전달되므로, 단일 변수와 다르게 함수 안에서 원본이 그대로 바뀐다.
  • malloc은 힙에서 메모리를 확보하고, free는 반납한다. free 이후에는 포인터를 NULL로 만들어 댕글링 포인터를 막아야 한다.
  • 동적 배열은 malloc(sizeof(자료형) * 개수)로, 동적 구조체는 malloc(sizeof(구조체))로 만들고, 구조체 포인터의 필드에는 -> 연산자로 접근한다.

마무리 복습

문제 14지선다
다음 함수를 호출한 뒤 a의 값은?
void f(int n) {
  n = n + 1;
}

int a = 5;
f(a);
문제 24지선다
다음 함수를 호출한 뒤 a의 값은?
void f(int *p) {
  *p = *p + 1;
}

int a = 5;
f(&a);
문제 34지선다
swap_wrong(int x, int y)처럼 값만 넘겨받는 함수로 두 변수를 교환하려 할 때 발생하는 문제는?
문제 44지선다
int *p = (int *)malloc(sizeof(int) * 5); 에 대한 설명으로 옳은 것은?
문제 54지선다
다음처럼 free(p); 를 호출한 직후 p = NULL; 을 실행하는 이유로 가장 적절한 것은?
free(p);
p = NULL;
문제 64지선다
struct Point *p = (struct Point *)malloc(sizeof(struct Point)); 이후 p의 x 필드에 값을 대입하는 올바른 표현은?
문제 74지선다
malloc으로 확보한 메모리를 free하지 않고 그 메모리를 가리키던 포인터 변수마저 사라졌을 때 발생하는 문제는?

참고 자료

Last updated on