cheoly's language study blog

이중 포인터로 2차원 배열 다루기 – 개념부터 실전 사용까지

프로그래밍/C언어
반응형
SMALL

C로 코딩하다 보면 2차원 배열을 함수로 넘기거나 동적으로 만들고 싶은데,
int arr[3][4]; 만으로는 뭔가 막히는 느낌이 올 때가 있다.
이때 자주 등장하는 게 바로 이중 포인터(int **) 다.

이 글에서는

  1. 정적 2차원 배열과 이중 포인터의 차이
  2. 왜 int arr[][4]는 되는데 int **는 안 되는지
  3. int **로 2차원 배열처럼 동적 할당하는 방법
  4. free 할 때 주의점, 자주 하는 실수

까지 한 번에 정리해본다.

이중 포인터를 시각적으로 표현한 다이어그램. 깊은 파란 배경 위에 **p , *p₀ , p₀₀ , p₀₁ 같은 요소들이 박스와 화살표로 연결된 구조로 배치되어 있으며, 포인터에서 포인터로 이어지는 참조 관계를 직관적으로 보여주는 프로그래밍 개념 이미지


1. 2차원 배열과 포인터의 기본 관계

먼저 익숙한 2차원 배열부터 보자.

int arr[3][4];
  • 타입: “4개의 int로 이루어진 배열이 3개
  • arr 의 타입은: int [3][4]
  • 포인터로 decay(함수 인자로 전달 등)될 때 타입: int (*)[4]
    → “int 4개짜리 배열을 가리키는 포인터”

즉, arr는 **“배열의 배열”**이고,
arr가 포인터처럼 쓰일 때는 **“한 행을 가리키는 포인터”**가 된다.

int (*p)[4] = arr; // OK
p[0][1] = 10;      // arr[0][1]과 동일

여기서 중요한 포인트:

정적 2차원 배열 int arr[3][4] 는 int **가 아니라
int (*)[4] 타입으로 취급된다.

그래서 함수 인자로 받을 때도 이렇게 해야 한다.

void print_matrix(int arr[][4], int rows);
// 또는
void print_matrix(int (*arr)[4], int rows);

void print_matrix(int **arr, int rows, int cols); 로 받으면
정적 2차원 배열을 그대로 넘겨서는 안 맞는다.


2. 이중 포인터 int ** 는 뭔가?

int ** 는 말 그대로

“int를 가리키는 포인터를 가리키는 포인터”

구조를 그림으로 그리면 이런 느낌이다.

  • int **pp : “행 포인터들을 모아둔 배열(또는 영역)을 가리킨다”
  • pp[i] : i번째 행을 가리키는 int *
  • pp[i][j] : i번째 행, j번째 열의 int

즉, **“포인터들의 배열 + 각 행을 별도로 할당한 구조”**라고 보면 된다.

그래서 int ** 를 제대로 쓰려면
할당도 2단계로 해줘야 한다.


3. int ** 로 2차원 배열처럼 동적 할당하기

예를 들어 rows x cols 크기의 int “2차원 배열처럼” 쓰고 싶다고 하자.

3-1. 할당

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    int rows = 3;
    int cols = 4;

    // 1단계: 행 포인터 배열 할당
    int **arr = malloc(rows * sizeof(int *));
    if (arr == NULL) {
        perror("malloc");
        return 1;
    }

    // 2단계: 각 행마다 int 배열 할당
    for (int i = 0; i < rows; i++) {
        arr[i] = malloc(cols * sizeof(int));
        if (arr[i] == NULL) {
            perror("malloc");
            // 이미 할당한 부분 정리 후 종료
            for (int k = 0; k < i; k++) {
                free(arr[k]);
            }
            free(arr);
            return 1;
        }
    }

    // 이제 arr[i][j] 로 접근 가능
    for (int i = 0; i < rows; i++) {
        for (int j = 0; j < cols; j++) {
            arr[i][j] = i * cols + j;
        }
    }

    // 출력 테스트
    for (int i = 0; i < rows; i++) {
        for (int j = 0; j < cols; j++) {
            printf("%2d ", arr[i][j]);
        }
        printf("\n");
    }

    // 3단계: 해제
    for (int i = 0; i < rows; i++) {
        free(arr[i]);  // 각 행 해제
    }
    free(arr);         // 행 포인터 배열 해제

    return 0;
}

이 구조의 특징:

  • 메모리가 연속적이지 않을 수 있다.
    각 행을 따로 malloc 했기 때문.
  • 대신 행 크기를 서로 다르게 할당하는 것도 가능하다. (jagged array)

예:

arr[0] = malloc(3 * sizeof(int)); // 3열
arr[1] = malloc(10 * sizeof(int)); // 10열
// 이런 식으로 ‘삐뚤빼뚤한’ 2차원 구조도 가능

 

4. 정적 2차원 배열을 함수에 넘길 때 vs int **

정적 2차원 배열을 함수에 넘기고 싶을 땐:

void print_matrix(int rows, int cols, int arr[][4]) {
    for (int i = 0; i < rows; i++) {
        for (int j = 0; j < cols; j++) {
            printf("%d ", arr[i][j]);
        }
        printf("\n");
    }
}

int main(void) {
    int arr[3][4] = {
        { 1, 2, 3, 4 },
        { 5, 6, 7, 8 },
        { 9, 10, 11, 12 }
    };

    print_matrix(3, 4, arr);
}

또는 C99 이상이라면 가변 길이 배열(VLA) 를 써서

void print_matrix(int rows, int cols, int arr[rows][cols]);

이렇게도 가능하다.

반면, int ** 로 받은 함수는 int ** 로 동적 할당한 데이터만 안전하게 처리해야 한다.

void print_matrix_dyn(int rows, int cols, int **arr);

여기에 int arr[3][4]; 를 그대로 넘기면
타입이 맞지 않고, 심지어 우연히 돌아가도 UB(정의되지 않은 동작) 이 될 수 있다.


5. 이중 포인터로 2차원 배열을 다룰 때 자주 하는 실수

❌ 1) 한 번만 malloc 하고 int ** 로 캐스팅

int **arr = malloc(rows * cols * sizeof(int)); // 잘못된 패턴

이렇게 한 덩어리로 할당해 놓고 arr[i][j] 로 쓰는 건
정상적인 int ** 구조가 아니다.

이 경우 arr 는 사실상 int *로 취급되어야 한다.

int *arr = malloc(rows * cols * sizeof(int));
arr[i * cols + j] = 10;

처럼 1차원 배열 + 인덱스 계산으로 써야 한다.

❌ 2) free 순서를 잘못 처리

int ** 구조에서는

  1. 먼저 각 행(arr[i])을 free
  2. 마지막에 arr 자체를 free

순서가 중요하다.

for (int i = 0; i < rows; i++) {
    free(arr[i]);   // 행 해제
}
free(arr);          // 행 포인터 배열 해제

반대로 먼저 free(arr); 를 해버리면
그 뒤의 arr[i] 접근은 이미 해제된 메모리에 접근하는 셈이라 위험하다.


6. 언제 int ** 를 쓰는 게 좋을까?

  • 행마다 크기가 다른 2차원 구조가 필요할 때
  • 런타임에 행 수, 열 수가 결정되는 2차원-like 데이터를 다룰 때
  • 재사용 가능한 라이브러리 코드에서
    “2차원처럼 보이는 포인터 배열”을 받도록 설계할 때

반면,

  • 크기가 고정이고
  • 메모리가 한 덩어리로 연속되어 있는 게 좋다면

int arr[ROWS][COLS]; 또는

int *arr = malloc(rows * cols * sizeof(int));

처럼 1차원 + 수동 인덱싱 방식이 더 단순할 수 있다.


마무리 정리

  • 정적 2차원 배열 int arr[3][4] 는
    int (*)[4] 로 decay되지, int ** 가 아니다.
  • int ** 는 “포인터들의 배열” 구조이므로
    행 포인터 배열 + 각 행 메모리를 따로 할당해야 한다.
  • int **로 할당한 건 int ** 로만 다루고,
    정적 배열은 int (*)[COL] 또는 int arr[][COL] 로 받자.
  • free 할 때는 행 먼저, 마지막에 상위 포인터를 해제하는 순서를 지키기.
반응형
LIST