C언어 포인터 완전 정복 — 기본부터 이중 포인터, malloc까지

C 포인터의 핵심 개념부터 배열, malloc, 이중 포인터까지 헷갈리는 상황별로 정리합니다.

포인터가 왜 이렇게 어려운가?

포인터는 값을 직접 다루는 게 아니라 값이 있는 위치(주소)를 다루는 개념이다. 처음엔 왜 이걸 써야 하는지부터 감이 안 온다.

비유로 시작하자.

친구에게 책을 빌려주는 방법이 두 가지 있다.

  1. 책 전체를 복사해서 준다 → 값 복사
  2. 책이 꽂힌 책장 위치를 알려준다 → 포인터

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 방지