[C언어 50강] 20강. 배열 2차원: 메모리 관점, 행/열 순회
C에서 2차원 배열을 처음 배우면 많은 분이 “행과 열”만 기억하고 끝냅니다. 그런데 실무에서 중요한 건 모양이 아니라 메모리에 실제로 어떻게 놓이는지입니다. 오늘은 int a[3][4] 같은 선언이 메모리에 어떤 의미를 가지는지, 왜 순회 순서가 성능과 직결되는지, 그리고 함수에 전달할 때 왜 두 번째 차원 크기를 알아야 하는지를 개념 중심으로 정리합니다.
핵심 개념
- 2차원 배열은 “배열의 배열”이며, 메모리에는 연속된 1차원 블록(row-major) 으로 저장된다.
a[r][c]는 문법적으로 편하지만, 본질은 포인터 연산이며 기준은 “한 행의 크기”다.- 순회는 가능하면 행 우선(row-first) 으로 해야 캐시 친화적이며 성능이 안정적이다.
개념 먼저 이해하기
int a[3][4];를 보면 3행 4열 표를 떠올리기 쉽습니다. 이 비유 자체는 나쁘지 않지만, C 컴파일러는 표를 그리지 않습니다. 컴파일러가 만드는 것은 길이 12짜리 int 연속 메모리입니다. 단지 “한 행에 4개씩 끊어서 해석하라”는 규칙을 같이 갖고 있을 뿐입니다. 이게 핵심입니다. 즉 2차원 배열은 물리적으로는 1차원 연속 저장, 논리적으로는 2차원 인덱싱입니다.
이 관점을 이해하면 여러 의문이 한 번에 정리됩니다. 왜 a의 타입이 int*가 아니라 int (*)[4]인지, 왜 함수 파라미터에서 열 크기(두 번째 차원)를 알아야 하는지, 왜 a[i][j]가 단순 문법 설탕(syntax sugar)인지가 연결됩니다. a + 1은 “다음 원소”가 아니라 “다음 행”으로 이동합니다. 이동 바이트 수는 sizeof(int) * 4입니다. 즉 첫 번째 인덱스(i)는 행 단위 점프, 두 번째 인덱스(j)는 행 내부 위치입니다.
성능 관점도 여기서 나옵니다. CPU 캐시는 연속된 메모리 접근을 좋아합니다. 행 우선 저장(row-major)인 C에서 for (i) for (j) 순회는 인접한 주소를 차례로 읽습니다. 반대로 for (j) for (i) 식의 열 우선 순회는 큰 간격(stride)으로 점프하며 캐시 미스를 유발합니다. 데이터가 작을 땐 체감이 적지만, 이미지 버퍼·행렬·로그 테이블처럼 커지면 차이가 크게 벌어집니다.
초보자가 자주 하는 오해는 “2차원 배열 == 이중 포인터(int**)”, “함수 인자로 그냥 int** 받으면 된다”입니다. 이건 대부분 틀립니다. int a[3][4]는 각 행이 연속인 고정 폭 레이아웃이고, int**는 포인터들의 배열(또는 포인터 체인)일 뿐이라 메모리 모양이 다를 수 있습니다. 따라서 int a[3][4]를 int**로 받으면 타입 시스템 관점에서도 잘못이고, 운 좋게 돌아가도 UB(정의되지 않은 동작) 가능성이 큽니다.
정리하면 2차원 배열 학습의 포인트는 세 가지입니다. 첫째, “표”가 아니라 “연속 메모리 + 행 크기 규칙”으로 이해하기. 둘째, 인덱싱을 주소 계산으로 해석할 수 있기. 셋째, 순회 순서와 함수 시그니처에서 이 구조를 일관되게 반영하기. 이 세 가지를 잡으면 이후 포인터, 동적 2차원 구조, 고성능 루프 최적화까지 자연스럽게 넘어갈 수 있습니다.
기본 사용
예제 1) 최소 동작 예제
#include <stdio.h>
int main(void) {
int a[3][4] = {
{1, 2, 3, 4},
{5, 6, 7, 8},
{9, 10, 11, 12}
};
for (int i = 0; i < 3; i++) {
for (int j = 0; j < 4; j++) {
printf("a[%d][%d]=%d ", i, j, a[i][j]);
}
puts("");
}
return 0;
}
설명:
- 선언 시
a는 “3개의 원소를 가진 배열”이고 각 원소 타입은int[4]입니다. a[i][j]는( * (a + i) )[j]로 해석할 수 있습니다. 즉 먼저 i번째 행으로 이동한 뒤, 그 행에서 j번째 원소를 읽습니다.- 메모리 관점에서 값은 1~12가 연속 배치되고, 인덱싱이 그 연속 공간을 3×4처럼 보이게 만듭니다.
예제 2) 실무에서 자주 맞닥뜨리는 패턴
#include <stdio.h>
#define ROWS 3
#define COLS 4
void print_sum_by_row(const int m[ROWS][COLS]) {
for (int i = 0; i < ROWS; i++) {
int sum = 0;
for (int j = 0; j < COLS; j++) {
sum += m[i][j];
}
printf("row %d sum = %d\n", i, sum);
}
}
int main(void) {
int score[ROWS][COLS] = {
{10, 20, 30, 40},
{11, 21, 31, 41},
{12, 22, 32, 42}
};
print_sum_by_row(score);
return 0;
}
설명:
- 함수 인자에서
const int m[ROWS][COLS]는 읽기 전용 의도를 명확히 보여줍니다. - 핵심은
COLS정보가 필요하다는 점입니다. 컴파일러가m[i][j]주소를 계산하려면 한 행의 폭(열 수)을 알아야 하기 때문입니다. - 실무에서는 매직 넘버 대신
ROWS,COLS상수를 두어 리팩터링 시 실수를 줄입니다.
예제 3) 디버깅 포인트 포함 예제
#include <stdio.h>
int main(void) {
int a[2][3] = {
{1, 2, 3},
{4, 5, 6}
};
printf("a = %p\n", (void*)a);
printf("a + 1 = %p (다음 행)\n", (void*)(a + 1));
printf("&a[0][0] = %p\n", (void*)&a[0][0]);
printf("&a[0][1] = %p\n", (void*)&a[0][1]);
printf("&a[1][0] = %p\n", (void*)&a[1][0]);
int *flat = &a[0][0];
for (int k = 0; k < 6; k++) {
printf("flat[%d]=%d\n", k, flat[k]);
}
return 0;
}
설명:
- 주소를 직접 출력해 보면
a + 1이int1칸이 아니라 한 행(3칸) 만큼 이동함을 확인할 수 있습니다. &a[0][0]부터 평탄화(flatten)해서 0~5를 읽으면 2차원 배열이 실제로 연속 저장된다는 사실이 눈에 보입니다.- 디버깅 시 인덱스 오염이 의심되면 값만 보지 말고 주소 흐름도 함께 확인하세요.
자주 하는 실수
실수 1) 2차원 배열을 int**로 받기
- 원인:
- “차원이 2개니까 포인터도 두 번”이라는 직관만 믿고 타입 레이아웃 차이를 무시함.
- 해결:
- 고정 열 크기 배열은
int (*p)[COLS]또는int arr[][COLS]형태로 받기. - 동적 2차원 구조를 만들 때만
int**를 사용하고, 그 경우에도 할당/해제 정책을 명확히 분리하기.
- 고정 열 크기 배열은
실수 2) 열 우선 순회로 성능 저하 유발
- 원인:
- 수학적 행렬 표기 습관대로 열 먼저 돌거나, 루프 순서를 무심코 바꿈.
- 해결:
- C의 row-major 저장을 기준으로 내부 루프를 열(
j)에 두기:for (i) for (j). - 대용량 데이터 처리 시 루프 순서 변경 전후를 간단한 벤치마크로 확인하기.
- C의 row-major 저장을 기준으로 내부 루프를 열(
실수 3) 경계 검사 없이 인덱스 접근
- 원인:
i <= ROWS,j <= COLS처럼 등호를 잘못 써서 한 칸 초과 접근.
- 해결:
- 반복 조건은 기본적으로
<사용. - 개발 단계에서는 assert/로그를 통해 인덱스 범위를 적극 검증.
- 반복 조건은 기본적으로
실무 패턴
- 명시적 차원 관리:
#define또는enum으로ROWS,COLS를 선언해 타입/루프/검증에 동일하게 사용. - 순회 정책 통일: 팀 코딩 규칙으로 2차원 배열 기본 순회를
row -> col로 통일. - 입출력 분리: 데이터 채우기, 계산, 출력 함수를 분리해 테스트 가능성 확보.
- 경계값 우선 테스트: 0행/마지막 행, 0열/마지막 열 케이스를 먼저 점검.
- 성능 이슈 사전 차단: 데이터가 커지면 캐시 효율이 곧 성능. 알고리즘만큼 접근 패턴도 코드리뷰 항목에 포함.
오늘의 결론
한 줄 요약: 2차원 배열은 “표”가 아니라 “연속 메모리 + 행 크기 규칙”이며, 이 관점이 타입·함수 설계·성능을 동시에 결정한다.
연습문제
int b[4][5]에서b + 1,&b[0][0] + 1,&b[1][0]의 차이를 주소 관점으로 설명하세요.- 3x3 정수 배열의 각 열 합을 구하는 함수를 작성하되, 내부적으로는 row-major 특성을 고려해 캐시 친화적으로 순회해 보세요.
int a[2][3]를 함수에 전달하여 최댓값을 구하는 코드를 작성하고, 왜 열 크기 정보가 필요한지 주석으로 설명하세요.
이전 강의 정답
- 19강(배열 1차원) 연습문제 핵심 정리
- 배열 평균 계산은 누적합 변수 초기화(
sum = 0)와 길이 상수화가 핵심입니다. - 최댓값/최솟값 탐색은 첫 원소로 초기화 후 1번 인덱스부터 비교하면 분기 실수를 줄일 수 있습니다.
- 경계 오류 방지는
for (i = 0; i < N; i++)패턴 고정이 가장 효과적입니다.
- 배열 평균 계산은 누적합 변수 초기화(
실습 환경/재현 정보
- 컴파일러: clang 17+ 또는 gcc 13+
- 컴파일 옵션:
-std=c11 -Wall -Wextra -O0 -g - 실행 환경: macOS/Linux 터미널
- 재현 체크:
- 예제 1 출력이 행 단위로 3줄인지 확인
- 예제 3에서
a+1과&a[1][0]가 같은 위치 의미인지 주소로 확인 - 루프 조건을
<=로 바꿨을 때 경고/오동작 가능성을 직접 관찰