다차원 배열과 포인터 배열 - 행렬과 문자열 목록을 메모리로 그리기
이 장에서 배우는 것
앞 장에서 복잡한 포인터 선언을 읽고 함수 포인터로 콜백 테이블을 만드는 방법을 살펴보았다. 프로그램을 작성하다 보면 단일 데이터가 아니라 여러 데이터를 묶어서 다뤄야 할 때가 많다. 특히 도서관 대출 기록과 같이 행과 열이 있는 표 형태의 데이터나, 여러 책 제목을 모아둔 문자열 목록을 처리하려면 다차원 배열과 포인터 배열이 필요하다.
이 장에서는 다차원 데이터가 실제 메모리에 어떻게 배치되는지 그림으로 확인하고, 컴파일러가 이를 어떻게 해석하는지 알아본다. 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]가 된다.
이 타입 정보는 포인터 산술 연산에서 핵심적인 역할을 한다. 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"};로 초기화하면, 컴파일러는 세 개의 문자열 리터럴을 읽기 전용 메모리 영역에 따로 저장하고, 그 시작 주소들을 포인터 배열에 담는다.
이 방식은 문자열 길이에 상관없이 필요한 만큼만 메모리를 차지하므로 공간 효율이 좋다. 하지만 데이터가 흩어져 존재하며, 문자열 리터럴을 가리킬 경우 내용을 수정할 수 없다는 제약이 있다. 동적으로 할당한 문자열을 가리킬 수도 있는데, 이때는 각 문자열의 메모리를 개별적으로 관리해야 한다.
가변 길이 행 할당과 해제
도서관 회원마다 대출 횟수가 다를 수 있다. 어떤 회원은 한 달 정보만 있고, 어떤 회원은 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차원 배열 (char [][16]) |
포인터 배열 (char *[]) |
|---|---|---|
| 메모리 배치 | 연속적인 직사각형 블록 | 메모리 곳곳에 흩어진 문자열을 가리킴 |
| 메모리 효율성 | 짧은 문자열이 많으면 낭비 심함 | 문자열 길이에 맞춰 최적화 가능 |
| 내용 수정 | 가능 (스택이나 데이터 영역에 복사됨) | 리터럴을 가리키는 경우 수정 불가 |
| 함수 매개변수 타입 | char (*)[16] |
char ** 또는 const char *[] |
| 선언 형태 | 메모리 구조 | 수식에서 변환되는 타입 | 포인터 +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 *) |
연습 문제
- 다음 선언
int matrix[5][8];이 주어졌을 때,matrix + 2가 가리키는 곳은 배열의 시작 주소로부터 몇 바이트 떨어진 곳인가? (단,int는 4바이트라고 가정한다.) - 문자열 목록을
char names[10][32];로 관리하던 프로그램을 동적 메모리 할당을 활용하여 필요 메모리를 최소화하도록 수정하려고 한다. 이를 위해 선언해야 할 뼈대 배열의 타입은 무엇이며, 할당 절차를 간략히 설명하라. - 아래 코드에서 메모리 누수가 발생하는 위치를 찾고 올바른 순서로 해제하는 코드로 고치라.
float **data = malloc(sizeof(float*) * 10); for (int i=0; i<10; i++) data[i] = malloc(sizeof(float) * 20); free(data);
정답과 해설
- 64바이트.
matrix는 수식에서int (*)[8]타입으로 변환된다. 따라서+ 1을 할 때마다 한 행의 크기인sizeof(int[8])(32바이트)만큼 주소가 증가한다.+ 2를 했으므로 64바이트 뒤를 가리키게 된다. - 이중 포인터(
char **)를 사용한다. 먼저char **names_ptr = malloc(sizeof(char *) * 10);로 포인터 10개를 담을 뼈대를 만든다. 그다음 반복문을 통해 10개의 문자열 각각의 길이를 측정하고(strlen), 널 문자를 포함한 크기만큼 개별적으로malloc(sizeof(char) * (길이 + 1))을 호출하여 뼈대 배열에 할당한다. 저장 후에는strcpy로 복사한다. - 개별 데이터 행을 먼저 해제하지 않고 포인터 배열 자체를 해제했기 때문에, 10개의
float배열에 접근할 방법이 사라져 누수가 발생한다./* 고친 코드 */ for (int i = 0; i < 10; i++) { free(data[i]); } free(data);