C언어 포인터 완전 정복 — 기본부터 이중 포인터, malloc까지
C 포인터의 핵심 개념부터 배열, malloc, 이중 포인터까지 헷갈리는 상황별로 정리합니다.
포인터가 왜 이렇게 어려운가?
포인터는 값을 직접 다루는 게 아니라 값이 있는 위치(주소)를 다루는 개념이다. 처음엔 왜 이걸 써야 하는지부터 감이 안 온다.
비유로 시작하자.
친구에게 책을 빌려주는 방법이 두 가지 있다.
- 책 전체를 복사해서 준다 → 값 복사
- 책이 꽂힌 책장 위치를 알려준다 → 포인터
2번 방식이면 친구가 책에 메모를 남길 때 원본에도 반영된다.
C에서 포인터를 쓰는 이유는 크게 세 가지다.
| 이유 | 설명 |
|---|---|
| 효율 | 큰 데이터를 복사 없이 전달 |
| 수정 | 함수 안에서 외부 변수를 바꿀 수 있음 |
| 동적 메모리 | 실행 중에 크기가 결정되는 메모리 할당 |
메모리 구조 먼저 이해하기
C 프로그램이 사용하는 메모리는 크게 4 영역으로 나뉜다.
┌─────────────────┐ 높은 주소
│ Stack │ ← 함수 호출 시 지역변수 저장 (자동 관리)
├─────────────────┤
│ ↓ │
│ │
│ ↑ │
├─────────────────┤
│ Heap │ ← malloc으로 할당하는 영역 (수동 관리)
├─────────────────┤
│ Data / BSS │ ← 전역변수, static 변수
├─────────────────┤
│ Code │ ← 프로그램 코드
└─────────────────┘ 낮은 주소
포인터를 이해하려면 모든 변수는 메모리의 어딘가에 저장되어 있고, 그 위치를 숫자(주소)로 나타낼 수 있다는 사실만 기억하면 된다.
포인터 기초
& — 주소를 가져온다
int x = 10;
printf("%d\n", x); // 10 (값)
printf("%p\n", &x); // 0x7fff... (x가 저장된 메모리 주소)
* — 주소에 있는 값을 가져온다 (역참조)
int x = 10;
int *p = &x; // p에 x의 주소를 저장
printf("%p\n", p); // x의 주소
printf("%d\n", *p); // 10 (p가 가리키는 곳의 값)
*p = 99; // p가 가리키는 곳(= x)의 값을 바꿈
printf("%d\n", x); // 99
변수 x: [ 10 ] ← 주소 0x100
포인터 p: [0x100] ← *p를 하면 0x100 위치의 값인 10을 꺼냄
포인터 선언 읽는 법
int x; // int 값
int *p; // int를 가리키는 포인터
int **pp; // int 포인터를 가리키는 포인터 (이중 포인터)
팁:
*는 변수 이름에 붙인다고 생각하자.int* p, q;는int *p; int q;와 같다. q는 포인터가 아니다.
함수와 포인터 — 왜 필요한가?
C는 함수 인자를 항상 복사해서 전달한다. (Call by Value)
void add_ten(int n) {
n += 10; // 복사본을 바꿈. 원본에 영향 없음
}
int main() {
int x = 5;
add_ten(x);
printf("%d\n", x); // 여전히 5
}
포인터를 쓰면 주소를 넘겨 원본을 수정할 수 있다.
void add_ten(int *n) {
*n += 10; // 주소가 가리키는 실제 값을 바꿈
}
int main() {
int x = 5;
add_ten(&x); // x의 주소를 넘김
printf("%d\n", x); // 15
}
배열과 포인터 — 이 둘은 거의 같다
배열 이름은 첫 번째 원소의 주소
int arr[5] = {10, 20, 30, 40, 50};
printf("%p\n", arr); // arr의 시작 주소
printf("%p\n", &arr[0]); // 동일한 주소
// 아래 두 줄은 완전히 동일하다
printf("%d\n", arr[2]); // 30
printf("%d\n", *(arr+2)); // 30
arr[i] 는 컴파일러가 *(arr + i) 로 변환한다.
배열을 함수에 넘길 때
// 아래 세 선언은 모두 동일하다
void print_arr(int *arr, int n)
void print_arr(int arr[], int n)
void print_arr(int arr[5], int n) // 크기 5는 그냥 무시됨
// 배열을 넘기면 포인터가 전달된다 (복사 없음)
void print_arr(int *arr, int n) {
for (int i = 0; i < n; i++) {
printf("%d ", arr[i]); // arr[i] == *(arr + i)
}
}
int main() {
int arr[5] = {1, 2, 3, 4, 5};
print_arr(arr, 5); // arr는 자동으로 포인터로 변환
}
배열과 포인터가 다른 점
int arr[5] = {1, 2, 3, 4, 5};
int *p = arr;
// 포인터는 이동 가능
p++; // p가 다음 원소를 가리킴
printf("%d\n", *p); // 2
// 배열 이름은 이동 불가 (상수 포인터)
// arr++; // 컴파일 에러!
// sizeof도 다르다
printf("%zu\n", sizeof(arr)); // 20 (5 * 4bytes)
printf("%zu\n", sizeof(p)); // 8 (포인터 크기)
malloc — 동적 메모리 할당
왜 malloc이 필요한가?
배열 크기를 실행 중에 결정해야 할 때 쓴다.
// 컴파일 시 크기가 고정 — 크기를 미리 알아야 한다
int arr[100];
// 실행 중 크기 결정 — 유연하다
int n;
scanf("%d", &n);
int *arr = malloc(n * sizeof(int));
malloc 기본 사용법
#include <stdlib.h>
// 기본 형태
타입 *포인터 = malloc(크기 * sizeof(타입));
// 예시: int 5개짜리 배열
int *arr = malloc(5 * sizeof(int));
if (arr == NULL) {
// 메모리 부족 시 NULL 반환 — 반드시 체크
fprintf(stderr, "메모리 할당 실패\n");
return 1;
}
arr[0] = 10; // 일반 배열처럼 사용
arr[1] = 20;
arr[2] = 30;
free(arr); // 다 쓰면 반드시 해제
arr = NULL; // dangling pointer 방지
Heap 메모리:
malloc(5 * sizeof(int))
→ [ 10 | 20 | 30 | ?? | ?? ]
0x200 0x204 0x208 ...
arr → 0x200
malloc vs calloc vs realloc
| 함수 | 초기화 | 용도 |
|---|---|---|
malloc(size) |
안 함 (쓰레기값) | 일반 할당 |
calloc(n, size) |
0으로 초기화 | 초기화 필요할 때 |
realloc(ptr, size) |
유지 | 크기 변경 |
// calloc: 0으로 초기화된 int 5개
int *arr = calloc(5, sizeof(int)); // {0, 0, 0, 0, 0}
// realloc: arr를 10개짜리로 늘림
int *tmp = realloc(arr, 10 * sizeof(int));
if (tmp == NULL) {
free(arr); // realloc 실패 시 기존 포인터는 유효
return 1;
}
arr = tmp; // 성공하면 교체
이중 포인터 — 언제 쓰는가?
이중 포인터(**)가 필요한 상황은 딱 두 가지다.
상황 1. 함수 안에서 포인터 자체를 바꿔야 할 때
// 이렇게 하면 안 된다
void alloc(int *p, int n) {
p = malloc(n * sizeof(int)); // 복사본 p만 바뀜. 원본 포인터 그대로
}
int main() {
int *arr = NULL;
alloc(arr, 5);
// arr는 여전히 NULL!
}
포인터도 변수다. 함수에 넘기면 복사된다. 포인터 자체(주소값)를 바꾸려면 포인터의 주소를 넘겨야 한다.
// 이중 포인터를 써야 한다
void alloc(int **p, int n) {
*p = malloc(n * sizeof(int)); // *p = 원본 포인터를 역참조해서 바꿈
}
int main() {
int *arr = NULL;
alloc(&arr, 5); // arr의 주소를 넘김
arr[0] = 10; // 이제 정상 동작
free(arr);
}
main의 arr: [ NULL ] ← 주소 0x300
alloc에서: p = 0x300 (arr의 주소)
*p = malloc(...) → arr가 가리키는 곳을 바꿈
main의 arr: [ 0x500 ] ← Heap의 어딘가
상황 2. 2차원 배열을 동적으로 만들 때
행(row)과 열(col)이 런타임에 결정되는 경우다.
int rows = 3, cols = 4;
// int **matrix: 포인터의 배열 → 각 포인터가 한 행을 가리킴
int **matrix = malloc(rows * sizeof(int *)); // 포인터 배열
for (int i = 0; i < rows; i++) {
matrix[i] = malloc(cols * sizeof(int)); // 각 행 할당
}
// 사용: matrix[행][열]
matrix[1][2] = 42;
printf("%d\n", matrix[1][2]); // 42
// 해제는 역순으로
for (int i = 0; i < rows; i++) {
free(matrix[i]); // 각 행 먼저
}
free(matrix); // 포인터 배열 마지막
matrix → [ 0x400 | 0x420 | 0x440 ] ← 포인터 3개
↓ ↓ ↓
[행0] [행1] [행2] ← 각각 cols개의 int
포인터 사용 시 흔한 실수
1. 초기화 안 한 포인터 사용 (Dangling / Wild Pointer)
int *p;
*p = 10; // 위험! p가 어디를 가리키는지 모름 → 크래시 or 메모리 오염
// 반드시 초기화
int *p = NULL;
// 사용 전 NULL 체크
if (p != NULL) *p = 10;
2. free 후 포인터 사용 (Use-After-Free)
int *p = malloc(sizeof(int));
*p = 5;
free(p);
printf("%d\n", *p); // 위험! 이미 해제된 메모리 접근
free(p);
p = NULL; // 해제 후 즉시 NULL로 초기화
3. 메모리 누수 (Memory Leak)
void func() {
int *p = malloc(100 * sizeof(int));
// ... 사용 ...
return; // free 없이 반환 → 메모리 누수
}
void func() {
int *p = malloc(100 * sizeof(int));
// ... 사용 ...
free(p); // 반드시 해제
}
4. 배열 범위 초과 (Buffer Overflow)
int arr[5];
arr[5] = 10; // 범위 초과! (0~4가 유효)
5. 스택 변수 주소 반환
int* bad_func() {
int x = 10;
return &x; // 함수 종료 후 x는 사라짐 → 쓰레기 주소 반환
}
int* good_func() {
int *x = malloc(sizeof(int));
*x = 10;
return x; // Heap에 있으므로 함수 종료 후에도 유효
}
한눈에 보는 포인터 판단 기준
포인터를 써야 하는가?
│
├── 함수에서 외부 변수를 바꿔야 하는가?
│ └── YES → 포인터로 주소를 넘긴다 (int *p)
│
├── 크기가 런타임에 결정되는 배열이 필요한가?
│ └── YES → malloc + 포인터 (int *arr)
│
├── 함수 안에서 포인터 자체(주소)를 바꿔야 하는가?
│ └── YES → 이중 포인터 (int **p)
│
└── 2차원 배열을 동적으로 만들어야 하는가?
└── YES → 이중 포인터 + malloc 2단계 (int **matrix)
코드로 보는 전체 패턴 요약
#include <stdio.h>
#include <stdlib.h>
// 패턴 1: 기본 포인터 — 외부 변수 수정
void increment(int *n) { (*n)++; }
// 패턴 2: 배열 포인터 — 배열 전달
int sum(int *arr, int n) {
int s = 0;
for (int i = 0; i < n; i++) s += arr[i];
return s;
}
// 패턴 3: 이중 포인터 — 함수에서 포인터 할당
void make_array(int **arr, int n) {
*arr = malloc(n * sizeof(int));
for (int i = 0; i < n; i++) (*arr)[i] = i * 10;
}
// 패턴 4: 이중 포인터 — 2D 배열 동적 할당
int** make_2d(int rows, int cols) {
int **m = malloc(rows * sizeof(int *));
for (int i = 0; i < rows; i++)
m[i] = calloc(cols, sizeof(int));
return m;
}
void free_2d(int **m, int rows) {
for (int i = 0; i < rows; i++) free(m[i]);
free(m);
}
int main() {
// 패턴 1
int x = 5;
increment(&x);
printf("x = %d\n", x); // 6
// 패턴 2
int arr[] = {1, 2, 3, 4, 5};
printf("sum = %d\n", sum(arr, 5)); // 15
// 패턴 3
int *dynamic = NULL;
make_array(&dynamic, 5);
printf("dynamic[3] = %d\n", dynamic[3]); // 30
free(dynamic);
// 패턴 4
int **matrix = make_2d(3, 4);
matrix[2][3] = 99;
printf("matrix[2][3] = %d\n", matrix[2][3]); // 99
free_2d(matrix, 3);
return 0;
}
정리
| 개념 | 핵심 한 줄 |
|---|---|
&x |
x가 저장된 메모리 주소를 반환 |
*p |
p가 가리키는 주소의 값을 읽거나 씀 |
int *p |
int 값의 주소를 저장하는 포인터 변수 |
| 배열과 포인터 | 배열 이름은 첫 원소의 주소. arr[i] == *(arr+i) |
malloc(n) |
Heap에서 n바이트 할당. 반드시 free로 해제 |
calloc(n, size) |
0으로 초기화된 메모리 할당 |
realloc(p, size) |
이미 할당된 메모리 크기 변경 |
int **pp |
포인터의 포인터. 함수에서 포인터를 바꾸거나 2D 배열에 사용 |
| free 후 NULL | free(p); p = NULL; — Use-After-Free 방지 |