Devin.KR

다차원 배열과 포인터 배열 - 행렬과 문자열 목록을 메모리로 그리기

개발자KR 조회 8

이 장에서 배우는 것

앞 장에서 복잡한 포인터 선언을 읽고 함수 포인터로 콜백 테이블을 만드는 방법을 살펴보았다. 프로그램을 작성하다 보면 단일 데이터가 아니라 여러 데이터를 묶어서 다뤄야 할 때가 많다. 특히 도서관 대출 기록과 같이 행과 열이 있는 표 형태의 데이터나, 여러 책 제목을 모아둔 문자열 목록을 처리하려면 다차원 배열과 포인터 배열이 필요하다.

이 장에서는 다차원 데이터가 실제 메모리에 어떻게 배치되는지 그림으로 확인하고, 컴파일러가 이를 어떻게 해석하는지 알아본다. 2차원 배열과 포인터 배열의 본질적인 차이를 이해하면, 상황에 맞는 자료구조를 선택하고 메모리를 올바르게 관리할 수 있다.

  • 2차원 배열이 1차원 메모리 공간에 연속으로 배치되는 원리 이해하기
  • 배열을 가리키는 포인터 타입의 의미와 포인터 연산 규칙 파악하기
  • 문자열 목록을 다룰 때 2차원 배열과 포인터 배열의 차이점 비교하기
  • 가변 길이 행을 가지는 다차원 데이터를 동적으로 할당하고 해제하는 방법 익히기

문제 상황

작은 도서관 관리 프로그램을 개발 중이다. 회원별로 최근 4개월간의 월별 도서 대출 횟수를 기록해야 한다. 회원이 3명이라면 3행 4열의 표 형태 데이터가 필요하다. 이를 1차원 배열로 길게 나열해서 인덱스를 계산할 수도 있지만, 코드가 복잡해지고 의미를 한눈에 파악하기 어렵다.

또한 도서관이 보유한 도서 제목 목록을 관리해야 한다. 책 제목은 "C Programming"처럼 짧은 것도 있고, "The Art of Computer Programming"처럼 긴 것도 있다. 이를 고정된 크기의 2차원 문자열 배열에 저장하면 짧은 제목을 저장할 때 메모리 낭비가 발생한다.

표 형태의 숫자 데이터와 길이가 제각각인 문자열 목록을 메모리에 가장 효율적으로 담고, 이를 함수 인자로 안전하게 넘기는 방법을 결정해야 한다.

2차원 배열의 메모리 배치

C 언어에서 2차원 배열은 행(row)과 열(column)을 가진 격자 형태처럼 보이지만, 실제 메모리는 1차원의 선형 구조다. 컴파일러는 프로그래머가 작성한 다차원 인덱스를 1차원 메모리 오프셋으로 변환하여 처리한다.

대출 기록을 담을 배열 int records[3][4];를 선언했다고 가정하자. 이 배열은 정수 4개로 이루어진 1차원 배열 3개를 연달아 메모리에 놓은 것과 같다. 메모리에는 records[0][0]부터 records[2][3]까지 12개의 정수가 빈틈없이 연속으로 할당된다.

여기서 배열 이름 records는 수식에서 사용될 때 배열의 첫 번째 원소를 가리키는 포인터로 변환된다. 1차원 배열 int a[4]의 이름 a가 int *로 변환되는 것과 같다. 그렇다면 records는 어떤 타입으로 변환될까? records의 첫 번째 원소는 records[0]이며, 이는 정수 4개짜리 배열(int [4])이다. 따라서 배열의 이름 records는 길이가 4인 정수 배열을 가리키는 포인터로 변환된다. 이를 코드로 쓰면 int (*)[4]가 된다.

2차원 배열이 연속된 메모리에 배치되고, 포인터 연산 시 행 크기만큼 이동하는 모습

이 타입 정보는 포인터 산술 연산에서 핵심적인 역할을 한다. records + 1을 계산하면, 단순히 메모리 주소가 4바이트(정수 1개 크기) 증가하는 것이 아니다. 포인터가 가리키는 대상의 크기, 즉 int [4]의 크기인 16바이트만큼 주소가 증가한다. 결과적으로 records + 1은 두 번째 행인 records[1]의 시작 주소를 가리키게 된다.

함수에서 2차원 배열을 매개변수로 받을 때는 이 원리를 반영하여 void print_records(int (*arr)[4], int rows)와 같이 작성해야 한다. 대괄호를 사용하여 void print_records(int arr[][4], int rows)로 쓸 수도 있으며, 컴파일러는 두 표현을 완전히 동일한 int (*)[4] 타입으로 취급한다. 반드시 열의 크기를 지정해야 컴파일러가 다음 행으로 넘어가는 메모리 간격을 계산할 수 있다.

문자열 목록 다루기: 2차원 배열과 포인터 배열

여러 개의 문자열을 다룰 때는 두 가지 접근 방식이 있다. 하나는 2차원 문자열 배열을 사용하는 것이고, 다른 하나는 문자(char) 포인터의 1차원 배열을 사용하는 것이다. 두 방식은 메모리 구조와 사용처가 완전히 다르다.

2차원 문자열 배열

char titles_2d[3][16];과 같이 선언하면, 각 행마다 16바이트씩 총 48바이트의 연속된 메모리 공간이 할당된다. 여기에 "C", "Unix", "Linux"를 저장하면, 남는 공간은 널(Null) 문자로 채워지거나 쓰레기값으로 남는다.

이 방식의 장점은 모든 데이터가 하나의 연속된 덩어리로 존재하므로 할당과 복사가 한 번에 이루어진다는 것이다. 또한 저장된 문자열의 내용을 언제든지 수정할 수 있다. 단점은 가장 긴 문자열 길이에 맞춰 열의 크기를 정해야 하므로, 짧은 문자열이 많을 경우 메모리 낭비가 심하다는 점이다.

포인터 배열

const char *titles_ptr[3];과 같이 선언하면, 포인터 3개를 저장할 수 있는 배열만 할당된다(64비트 시스템에서 총 24바이트). 이 배열에는 실제 문자열 데이터가 저장되지 않는다. 각 포인터는 메모리 어딘가에 존재하는 문자열의 첫 글자를 가리킨다.

문자열 리터럴을 사용하여 const char *titles_ptr[] = {"C", "Unix", "Linux"};로 초기화하면, 컴파일러는 세 개의 문자열 리터럴을 읽기 전용 메모리 영역에 따로 저장하고, 그 시작 주소들을 포인터 배열에 담는다.

이 방식은 문자열 길이에 상관없이 필요한 만큼만 메모리를 차지하므로 공간 효율이 좋다. 하지만 데이터가 흩어져 존재하며, 문자열 리터럴을 가리킬 경우 내용을 수정할 수 없다는 제약이 있다. 동적으로 할당한 문자열을 가리킬 수도 있는데, 이때는 각 문자열의 메모리를 개별적으로 관리해야 한다.

2차원 문자열 배열의 빈 공간과 포인터 배열이 가리키는 흩어진 문자열의 비교

가변 길이 행 할당과 해제

도서관 회원마다 대출 횟수가 다를 수 있다. 어떤 회원은 한 달 정보만 있고, 어떤 회원은 12개월치 정보가 있을 수 있다. 정적 2차원 배열은 직사각형 형태만 가능하므로 낭비가 발생한다. 이때는 각 행의 길이가 다른 다차원 구조(Jagged Array)를 동적 메모리 할당으로 만들어야 한다.

이를 위해서는 이중 포인터(int **)를 사용한다. 먼저 행의 시작 주소들을 담을 포인터 배열을 동적으로 할당하고, 다시 반복문을 돌면서 각 행에 필요한 만큼의 메모리를 개별적으로 할당하여 포인터 배열에 연결한다.

메모리를 해제할 때는 반드시 할당의 역순으로 진행해야 한다. 개별 행을 먼저 모두 해제한 뒤, 마지막에 포인터 배열 자체를 해제해야 메모리 누수를 막을 수 있다. 도중에 할당이 실패할 경우, 이미 할당된 메모리들을 정리하고 종료하는 오류 처리 규약도 지켜야 한다.

완성 코드

library_data.c

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

/* 2차원 배열을 매개변수로 받음. 열 크기가 4로 고정됨 */
void print_monthly_records(int (*records)[4], int users) {
    printf("--- 월별 대출 횟수 ---\n");
    for (int i = 0; i < users; i++) {
        printf("회원 %d: ", i + 1);
        for (int j = 0; j < 4; j++) {
            printf("%d ", records[i][j]);
        }
        printf("\n");
    }
    printf("\n");
}

/* 문자열 포인터 배열을 매개변수로 받음 */
void print_book_titles(const char *titles[], int count) {
    printf("--- 도서 목록 ---\n");
    for (int i = 0; i < count; i++) {
        printf("%d. %s\n", i + 1, titles[i]);
    }
    printf("\n");
}

int main(void) {
    /* 1. 고정 크기 2차원 배열 */
    int records[3][4] = {
        {2, 0, 1, 3},
        {0, 0, 5, 1},
        {1, 2, 0, 0}
    };
    
    print_monthly_records(records, 3);

    /* 2. 문자열 포인터 배열 */
    const char *book_titles[] = {
        "The C Programming Language",
        "Operating Systems",
        "Networks"
    };
    
    /* 배열 원소 개수 계산: 전체 크기 / 원소 하나 크기 */
    int book_count = sizeof(book_titles) / sizeof(book_titles[0]);
    print_book_titles(book_titles, book_count);

    /* 3. 가변 길이 행 동적 할당 */
    int users = 3;
    int **history = malloc(sizeof(int *) * users);
    if (history == NULL) {
        return 1;
    }

    /* 각 회원마다 다른 개수의 대출 기록 생성 (행마다 길이가 다름) */
    int months_per_user[] = {2, 5, 3};
    
    for (int i = 0; i < users; i++) {
        history[i] = malloc(sizeof(int) * months_per_user[i]);
        if (history[i] == NULL) {
            /* 메모리 할당 실패 시, 앞서 할당한 메모리 모두 해제 */
            for (int k = 0; k < i; k++) {
                free(history[k]);
            }
            free(history);
            return 1;
        }
        /* 데이터 초기화 */
        for (int j = 0; j < months_per_user[i]; j++) {
            history[i][j] = (i + 1) * (j + 1);
        }
    }

    /* 동적 할당된 가변 길이 데이터 출력 */
    printf("--- 상세 대출 이력 (가변 길이) ---\n");
    for (int i = 0; i < users; i++) {
        printf("회원 %d (%d개월): ", i + 1, months_per_user[i]);
        for (int j = 0; j < months_per_user[i]; j++) {
            printf("%d ", history[i][j]);
        }
        printf("\n");
    }

    /* 4. 동적 메모리 해제 (역순) */
    for (int i = 0; i < users; i++) {
        free(history[i]);
    }
    free(history);

    return 0;
}

줄별 해설

  • 6~16줄: 매개변수 int (*records)[4]는 열 크기가 4인 2차원 정수 배열을 받는다. 이 함수 안에서 records[i][j]를 쓰면 컴파일러는 내부적으로 *(*(records + i) + j)로 해석하고, records + i는 i * 4 * sizeof(int)만큼 주소를 이동한다.
  • 19~25줄: 매개변수 const char *titles[]는 문자 포인터의 배열을 인자로 받는다. titles[i]는 i번째 문자열의 시작 주소를 의미한다. 배열 원소의 개수를 알 수 없으므로 count를 따로 전달받는다.
  • 45줄: 포인터 배열의 요소 개수를 구하는 일반적인 패턴이다. 전체 배열 크기를 첫 번째 포인터 크기로 나누어 길이를 계산한다.
  • 50~53줄: 이중 포인터 history를 선언하고, int * 타입을 담을 수 있는 공간을 users 수만큼 할당한다. 이것이 가변 길이 다차원 구조의 뼈대가 된다.
  • 59~72줄: 뼈대 배열의 각 포인터에 제각기 다른 크기의 메모리를 할당한다. history[i]가 가리키는 행의 길이가 모두 다르다. 메모리 할당에 실패하면 누수를 막기 위해 지금까지 할당된 이전 행들을 모두 free로 지운 후 종료한다.
  • 86~89줄: 사용이 끝난 메모리는 각 행을 순회하며 free(history[i])로 먼저 지우고, 마지막에 뼈대 역할을 한 free(history)를 지운다. 순서가 바뀌면 행 주소를 잃어버려 메모리 누수가 발생한다.

실행 결과

$ cc -std=c17 -Wall -Wextra -o library_data library_data.c
$ ./library_data
--- 월별 대출 횟수 ---
회원 1: 2 0 1 3 
회원 2: 0 0 5 1 
회원 3: 1 2 0 0 

--- 도서 목록 ---
1. The C Programming Language
2. Operating Systems
3. Networks

--- 상세 대출 이력 (가변 길이) ---
회원 1 (2개월): 1 2 
회원 2 (5개월): 2 4 6 8 10 
회원 3 (3개월): 3 6 9 

실무에서 자주 틀리는 것

2차원 배열과 이중 포인터의 혼동

가장 흔하게 발생하는 오류는 연속된 메모리 블록인 2차원 배열을 함수로 전달할 때 매개변수 타입을 이중 포인터로 적는 것이다.

int table[3][4];

/* 틀린 코드: 2차원 배열은 이중 포인터로 변환되지 않는다. */
void process_table(int **t) {
    /* 컴파일 경고 또는 런타임 오류 발생 */
}
process_table(table);

/* 고친 코드: 배열을 가리키는 포인터 타입을 정확히 명시한다. */
void process_table(int (*t)[4]) {
    /* 정상 동작 */
}
process_table(table);

컴파일러는 table을 int (*)[4]로 변환한다. 이를 int ** 타입 매개변수로 받으면, 컴파일러는 table[0]에 있는 정수 데이터 자체를 포인터 주소로 착각하여 잘못된 메모리를 참조하게 만든다.

문자열 배열 크기 계산 실수

함수 안에서 전달받은 배열의 크기를 sizeof 연산자로 구하려고 할 때 논리적 오류가 발생한다.

void print_all(const char *list[]) {
    /* 틀린 코드: list는 배열이 아니라 포인터다. */
    /* 64비트 시스템에서 sizeof(list)는 8바이트가 된다. */
    int count = sizeof(list) / sizeof(list[0]); 
}

/* 고친 코드: 배열의 크기나 원소 개수는 반드시 호출하는 쪽에서 전달해야 한다. */
void print_all(const char *list[], int count) {
    for (int i = 0; i < count; i++) {
        /* ... */
    }
}

배열 이름이 함수의 매개변수로 전달될 때는 항상 포인터로 붕괴(Decay)된다. 함수 내부에서는 원본 배열의 크기를 절대 알 수 없으므로 길이를 함께 인자로 전달하는 습관을 들여야 한다.

동적 다차원 배열 해제 누락

이중 포인터를 통해 할당한 뼈대 배열만 지우고 개별 행을 지우지 않으면 대량의 메모리 누수가 발생한다.

int **grid = malloc(sizeof(int *) * 3);
for(int i=0; i<3; i++) grid[i] = malloc(sizeof(int) * 5);

/* 틀린 코드: 뼈대만 해제하고 데이터 행 3개는 메모리에 남겨둔다. */
free(grid);

/* 고친 코드: 반복문을 돌며 안쪽부터 해제한다. */
for(int i=0; i<3; i++) {
    free(grid[i]);
}
free(grid);

한눈에 보기

2차원 문자열 배열과 포인터 배열의 구조적 차이
구분 2차원 배열 (char [][16]) 포인터 배열 (char *[])
메모리 배치 연속적인 직사각형 블록 메모리 곳곳에 흩어진 문자열을 가리킴
메모리 효율성 짧은 문자열이 많으면 낭비 심함 문자열 길이에 맞춰 최적화 가능
내용 수정 가능 (스택이나 데이터 영역에 복사됨) 리터럴을 가리키는 경우 수정 불가
함수 매개변수 타입 char (*)[16] char ** 또는 const char *[]

다차원 배열의 선언과 붕괴(Decay) 타입
선언 형태 메모리 구조 수식에서 변환되는 타입 포인터 +1 연산 시 이동 크기
int a[4]; 1차원 연속 배열 int * sizeof(int)
int b[3][4]; 2차원 연속 배열 (1차원처럼 배치) int (*)[4] sizeof(int[4])
int *c[3]; 포인터들의 1차원 배열 int ** sizeof(int *)

연습 문제

  1. 다음 선언 int matrix[5][8];이 주어졌을 때, matrix + 2가 가리키는 곳은 배열의 시작 주소로부터 몇 바이트 떨어진 곳인가? (단, int는 4바이트라고 가정한다.)
  2. 문자열 목록을 char names[10][32];로 관리하던 프로그램을 동적 메모리 할당을 활용하여 필요 메모리를 최소화하도록 수정하려고 한다. 이를 위해 선언해야 할 뼈대 배열의 타입은 무엇이며, 할당 절차를 간략히 설명하라.
  3. 아래 코드에서 메모리 누수가 발생하는 위치를 찾고 올바른 순서로 해제하는 코드로 고치라.
    float **data = malloc(sizeof(float*) * 10);
    for (int i=0; i<10; i++) data[i] = malloc(sizeof(float) * 20);
    free(data);
    

정답과 해설

  1. 64바이트. matrix는 수식에서 int (*)[8] 타입으로 변환된다. 따라서 + 1을 할 때마다 한 행의 크기인 sizeof(int[8]) (32바이트)만큼 주소가 증가한다. + 2를 했으므로 64바이트 뒤를 가리키게 된다.
  2. 이중 포인터(char **)를 사용한다. 먼저 char **names_ptr = malloc(sizeof(char *) * 10);로 포인터 10개를 담을 뼈대를 만든다. 그다음 반복문을 통해 10개의 문자열 각각의 길이를 측정하고(strlen), 널 문자를 포함한 크기만큼 개별적으로 malloc(sizeof(char) * (길이 + 1))을 호출하여 뼈대 배열에 할당한다. 저장 후에는 strcpy로 복사한다.
  3. 개별 데이터 행을 먼저 해제하지 않고 포인터 배열 자체를 해제했기 때문에, 10개의 float 배열에 접근할 방법이 사라져 누수가 발생한다.
    /* 고친 코드 */
    for (int i = 0; i < 10; i++) {
        free(data[i]);
    }
    free(data);
    

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.