[PART6.배열과 문자열 기본(2/14)] 다차원 배열과 들쭉날쭉 배열 — int[,]와 int[][]는 다른 동물이다
같은 "2차원"이지만 메모리 레이아웃·IL 명령어·GC 부담이 모두 다르다 / 인덱스 접근이 메서드 호출인지 IL 한 줄인지 / 연속 블록의 캐시 친화성 vs 1차원 배열의 BCE 최적화 / Unity 타일맵·체스판에서 무엇을 골라야 하는가
1. 왜 둘을 헷갈리면 안 되는가
C에서 넘어왔거나 자바를 먼저 익힌 신입은 거의 항상 int[][]를 2차원 배열의 기본 형태라고 생각합니다. C#에는 그것 말고도 int[,]라는 별개의 문법이 있고, 두 형태가 같아 보이지만 컴파일 결과·메모리 사용량·접근 속도가 모두 다릅니다.
int[,] rect = new int[3, 4]; // 단일 객체. 12칸이 한 줄로 연속.
int[][] jagged = new int[3][]; // 외부 배열만 — 내부는 아직 null
jagged[0] = new int[4];
jagged[1] = new int[4];
jagged[2] = new int[4]; // 행을 하나씩 따로 만들어 넣어야 한다
위 두 줄짜리 차이를 모르면 Unity에서 200×200 타일맵을 만들 때:
int[,]로 잡으면 객체 1개, GC 부담 거의 0int[][]로 잡으면 객체 201개, GC 마킹 단계가 200배 비싸짐
반대로 행마다 길이가 다른 데이터(파스칼의 삼각형, 가변 길이 그룹) — 이건 int[,]로는 표현 자체가 안 됩니다. 어느 쪽을 골라야 하는지가 단순한 취향이 아니라 자료의 모양과 성능 목표에 따라 갈리는 결정이라는 뜻입니다.
이 글은 두 형태의 진짜 차이를 메모리·IL·GC 세 층위에서 풀어 보고, Unity 신입이 "어느 쪽을 써야 할지" 곧바로 판단할 수 있게 하는 것이 목표입니다.
2. 두 형태가 무엇인가
비유 — 한 동짜리 직사각형 빌딩 vs 단독주택 N채로 이뤄진 단지
int[,]은 한 동짜리 빌딩입니다. 행과 열 수가 입주 시점에 정해지고, 그 안의 모든 칸이 한 건물 안에 같이 들어 있습니다. 칸 위치만 알면 같은 건물 안에서 곧장 갈 수 있습니다.
int[][]은 같은 단지 안에 단독주택 여러 채가 모여 있는 모양입니다. 단지 입구(외부 배열)에서 "3번 집(행 3)"으로 들어간 뒤, 그 집 안에서 다시 방을 찾아갑니다. 방마다 면적(행마다 길이)이 달라도 됩니다 — 어차피 집끼리는 독립이니까요.
메모리 레이아웃의 결정적 차이
![int[,] rect = new int[3,4]](https://blog.kakaocdn.net/dna/t0Anb/dJMcacXfiSJ/AAAAAAAAAAAAAAAAAAAAAG9ZFPV7vojSNCuqjnIMcMWFa1sFSe-Uy43zMsWCYA9O/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&expires=1780239599&allow_ip=&allow_referer=&signature=y0g0iED7HViamKJP2UUFJNc79ho%3D)
int[,]는 단 하나의 힙 객체이고, 12칸이 한 줄로 누워 있습니다. int[][]는 외부 배열 한 개와 행마다 별도의 객체 — 행이 N개면 총 N+1개입니다. 두 형태의 거의 모든 차이가 이 그림에서 나옵니다.
두 형태의 IL 비교
// Decl.cs
public static int[,] MakeRect()
{
return new int[3, 4]; // 단일 객체
}
public static int[][] MakeJagged()
{
int[][] j = new int[3][]; // 외부 배열만 (내부는 null)
j[0] = new int[2];
j[1] = new int[3];
j[2] = new int[4];
return j;
}
// MakeRect — 단일 newobj
IL_0001: ldc.i4.3
IL_0002: ldc.i4.4
IL_0003: newobj instance void int32[0..., 0...]::.ctor(int32, int32) // ← 다차원 배열은 newarr가 아니라 newobj
IL_0008: stloc.0
// MakeJagged — newarr가 4번
IL_0001: ldc.i4.3
IL_0002: newarr int32[] // ← 외부 배열 (요소 타입이 int[]임에 주목)
IL_0007: stloc.0
IL_000a: ldc.i4.2
IL_000b: newarr [System.Runtime]System.Int32 // ← 행 0
IL_0010: stelem.ref // 외부[0]에 행 0 참조 저장
IL_0013: ldc.i4.3
IL_0014: newarr [System.Runtime]System.Int32 // ← 행 1
IL_0019: stelem.ref
IL_001c: ldc.i4.4
IL_001d: newarr [System.Runtime]System.Int32 // ← 행 2
IL_0022: stelem.ref
int[,]는 IL에서 int32[0..., 0...] 라는 별도 타입이고, 생성도 newobj로 인스턴스 생성자를 부릅니다. 1차원 배열의 빠른 길 — newarr·ldelem·stelem 같은 전용 명령어 — 와는 다른 트랙을 탑니다. 반면 int[][]는 본질적으로 1차원 배열을 1차원 배열들의 컨테이너로 쓰는 것이라, 외부도 내부도 모두 같은 1차원 배열의 빠른 길을 그대로 씁니다.
int32[0..., 0...]— IL이 다차원 배열을 표시하는 방식 두 차원 모두0(하한)부터 시작하고 상한은 런타임에 정해진다는 의미. C#에서는 항상 0 하한이지만, .NET 런타임 자체는 임의 하한 다차원 배열을 지원하기 때문에 IL 표기법에 이런 형태가 박혀 있다.
3. 인덱스 접근의 IL이 갈라진다
이 섹션 하나가 두 형태의 성능 차이를 만드는 핵심입니다.
public static int RectGet(int[,] g, int r, int c)
{
return g[r, c];
}
public static int JaggedGet(int[][] j, int r, int c)
{
return j[r][c];
}
// RectGet — 인스턴스 메서드 Get 호출
IL_0001: ldarg.0
IL_0002: ldarg.1
IL_0003: ldarg.2
IL_0004: call instance int32 int32[0..., 0...]::Get(int32, int32) // ← 메서드 호출!
IL_0009: stloc.0
// JaggedGet — 두 번의 ldelem
IL_0001: ldarg.0
IL_0002: ldarg.1
IL_0003: ldelem.ref // ← 외부[r] : int[] 참조 로드
IL_0004: ldarg.2
IL_0005: ldelem.i4 // ← 그 행의 [c] : int 로드
IL_0006: stloc.0
g[r, c] 한 줄이 call instance ::Get(int32, int32) — 즉 일반 메서드 호출로 컴파일됩니다. JIT가 인라인하기에는 작지만, 1차원 배열의 ldelem.i4 한 명령에 비하면 분명히 무겁습니다. 그리고 이게 루프 안에서도 똑같은 메서드 호출 패턴이 됩니다 — JIT가 다차원 배열에 대해서는 1차원과 같은 BCE(Bounds Check Elimination — 범위 검사 제거)·SIMD(Single Instruction Multiple Data — 한 명령으로 여러 데이터를 처리하는 CPU 명령) 최적화를 적극적으로 적용하지 못한다고 알려져 있습니다.
반면 j[r][c]는 IL에서 정확히 두 단계로 풀립니다.
ldelem.ref— 외부 배열에서r번째 슬롯의 참조를 꺼냅니다(이게 행 배열).ldelem.i4— 그 행 배열의c번째 슬롯에서 int 값을 꺼냅니다.
두 단계 모두 1차원 배열의 빠른 길이라, 안쪽 루프에서 for (int c = 0; c < row.Length; c++) 같은 정형 패턴을 만들면 BCE가 그대로 적용됩니다. 이론적으로는 두 번 인덱싱이 무거워 보이지만, 실측에서는 1차원 배열 최적화가 강력해 들쭉날쭉이 더 빠른 경우가 많습니다.
순회 IL — Get 호출이 루프 안에 박힌다
public static int SumRect(int[,] g)
{
int sum = 0;
int rows = g.GetLength(0);
int cols = g.GetLength(1);
for (int r = 0; r < rows; r++)
for (int c = 0; c < cols; c++)
sum += g[r, c];
return sum;
}
public static int SumJagged(int[][] j)
{
int sum = 0;
for (int r = 0; r < j.Length; r++)
{
int[] row = j[r];
for (int c = 0; c < row.Length; c++)
sum += row[c];
}
return sum;
}
// SumRect 핵심 — GetLength × 2 + Get을 루프마다 호출
IL_0005: callvirt instance int32 [System.Runtime]System.Array::GetLength(int32) // 행 길이
IL_000d: callvirt instance int32 [System.Runtime]System.Array::GetLength(int32) // 열 길이
...
IL_0021: call instance int32 int32[0..., 0...]::Get(int32, int32) // ← 안쪽 루프마다 호출
IL_0026: add
// SumJagged 핵심 — 외부 ldlen, 행 캐싱(ldelem.ref) 후 안쪽은 ldlen + ldelem.i4
IL_000a: ldelem.ref // ← 행을 한 번만 꺼내 저장
IL_000b: stloc.2
...
IL_0013: ldelem.i4 // ← 안쪽 루프는 빠른 길
IL_001c: ldlen // ← BCE 친화 패턴
IL_001d: conv.i4
핵심 비교:
| 동작 | int[,] |
int[][] |
|---|---|---|
| 길이 가져오기 | callvirt GetLength(int) (메서드 호출) |
ldlen (단일 명령) |
| 요소 가져오기 | call Get(int, int) (메서드 호출) |
ldelem.ref + ldelem.i4 (두 명령) |
| 메모리 배치 | 연속 (CPU 캐시 친화) | 행마다 분산 (캐시 미스 위험) |
| BCE 적용 | 약함 | 강함 (정형 루프) |
두 형태가 같이 쓰는 안쪽 행 캐싱
들쭉날쭉 배열의 정석은 안쪽 루프 들어가기 전에 행을 지역 변수에 한 번 꺼내 두는 것입니다. j[r]을 int[] row로 받아 두면, 안쪽 루프 안에서 매번 외부 배열을 다시 인덱싱하지 않아도 되고 BCE도 더 잘 듣습니다. 안쪽 루프는 그저 1차원 배열을 도는 것과 동일한 IL이 됩니다.
4. 실전 적용 — 어느 쪽을 골라야 하는가
Before/After: 직사각형 데이터를 들쭉날쭉으로 잡지 말기
// ❌ Before — 200×200 타일맵을 들쭉날쭉으로
public class TileMapJagged
{
private int[][] _tiles;
public TileMapJagged()
{
_tiles = new int[200][];
for (int r = 0; r < 200; r++) _tiles[r] = new int[200];
}
public int Get(int r, int c) => _tiles[r][c];
}
// → 힙 객체 201개. GC 마킹 200배 비용. 행 사이 캐시 미스 위험.
// ✅ After — 직사각형이 보장되면 다차원 배열
public class TileMapRect
{
private int[,] _tiles = new int[200, 200];
public int Get(int r, int c) => _tiles[r, c];
}
// → 힙 객체 1개. 40,000칸이 한 줄로 누움. GC 부담 거의 0.
200×200 직사각형 타일맵을 들쭉날쭉으로 잡으면 객체 수만 200배가 됩니다. Boehm GC(Unity가 사용하는 보수적·정지형 가비지 컬렉터)는 마킹 단계에서 모든 살아 있는 객체를 훑기 때문에, 객체 수가 많아질수록 GC 한 번의 비용이 그대로 늘어납니다. 모양이 직사각형이 보장되면 int[,]가 명확히 유리합니다.
Before/After: 행마다 길이가 다른 데이터를 무리하게 정사각형으로 잡지 말기
// ❌ Before — 가변 길이를 직사각형으로 (낭비 + 마법값)
int[,] groups = new int[playerCount, 32]; // "한 그룹 최대 32명이라 가정"
// 멤버 수가 적은 그룹은 빈 칸이 -1로 가득
// 최대를 넘기면 사고
// ✅ After — 들쭉날쭉
int[][] groups = new int[playerCount][];
for (int p = 0; p < playerCount; p++)
groups[p] = new int[memberCounts[p]]; // 정확한 길이
행마다 길이가 다른 데이터(이벤트 로그 페이지, 스킬 트리 분기, 채팅 채널 멤버) — 이건 들쭉날쭉이 자연스럽습니다. 직사각형으로 잡으면 빈 칸을 채울 마법값(-1, 0 등)이 필요해지고, 인덱스 검사가 늘어나 코드가 지저분해집니다.
Unity에서의 판단 기준
| 자료 모양 | 추천 형태 | 이유 |
|---|---|---|
| 타일맵·체스판·격자 픽셀 (직사각형 + 큰 데이터) | int[,] |
GC 객체 수 최소화. 캐시 친화. |
| 행렬 연산 (전치·곱셈) | 1차원 배열로 row-major 직접 관리 (new int[N*M]) |
SIMD 적용 쉬움 |
| 행마다 길이가 다른 가변 데이터 | int[][] |
표현력 + 정확한 길이 |
| 작은 고정 격자에서 안쪽 루프 성능이 중요 | int[][] (행 캐싱) |
BCE·SIMD 최적화로 실제 더 빠름 |
성능이 진짜 중요하면 1차원 배열에 index = r * cols + c 식으로 직접 매핑하는 방식이 가장 빠릅니다 — Unity의 NativeArray·Burst Compiler 친화 패턴도 같은 방식이고, Span<T>·SIMD와도 자연스럽게 어울립니다. 다만 코드 가독성이 떨어지므로 Unity Profiler에서 병목으로 잡힐 때만 거기까지 내려갑니다.
5. 함정과 주의사항
함정 1 — Length vs GetLength(d)
int[,] g = new int[3, 4];
Console.WriteLine(g.Length); // 12 ← 전체 칸 수, 행 수 아님!
Console.WriteLine(g.GetLength(0)); // 3 행 수
Console.WriteLine(g.GetLength(1)); // 4 열 수
Console.WriteLine(g.Rank); // 2 차원 수
int[,]의 Length는 행 수가 아니라 모든 칸의 총 개수입니다. 1차원 배열의 Length와 의미가 똑같지만, 신입은 g.Length를 행 수로 착각해서 for (int r = 0; r < g.Length; r++)라고 쓴 뒤 곧장 예외를 만나곤 합니다. 행/열 길이는 반드시 GetLength(0)·GetLength(1)로 구합니다.
들쭉날쭉 배열은 이 함정이 없습니다 — j.Length는 외부 배열 길이(=행 수), j[r].Length는 그 행의 길이. 1차원 배열의 Length 의미가 그대로 일관되게 쓰입니다.
함정 2 — 들쭉날쭉의 행을 잊고 null인 채로 접근
int[][] j = new int[3][];
j[0] = new int[5];
// j[1], j[2]는 아직 null 상태
Console.WriteLine(j[1][0]); // NullReferenceException
new int[3][]은 외부 배열만 만들고 내부 행들을 만들지 않습니다 — 모두 null입니다. 모든 행을 직접 채워야 안전하게 접근할 수 있습니다. int[,]는 한 번에 모든 칸이 0(또는 기본값)으로 만들어지므로 이 함정이 없습니다.
함정 3 — foreach의 동작이 다르다
int[,] g = { {1,2}, {3,4} };
foreach (int x in g) Console.Write(x); // 1234 ← 단일 foreach로 전체 순회
// row-major로 평탄화
int[][] j = { new[]{1,2}, new[]{3,4} };
foreach (int x in j) Console.Write(x); // ❌ 컴파일 오류 — j의 요소는 int가 아니라 int[]
foreach (int[] row in j)
foreach (int x in row) Console.Write(x); // ✅ 중첩 foreach 필요
int[,]는 단일 foreach로 모든 요소를 한 번에 도는 편의가 있고, int[][]는 외부와 내부를 따로 돌아야 합니다. 의미상 차이가 있어서 한쪽 코드를 다른 쪽으로 옮길 때 무심코 컴파일 오류를 만납니다.
함정 4 — int[,] 루프에서 BCE를 기대하지 말 것
// ❌ 다차원 배열의 안쪽 루프는 매 접근이 메서드 호출
for (int r = 0; r < rows; r++)
for (int c = 0; c < cols; c++)
total += rect[r, c]; // call ::Get(int,int)
// ✅ 1차원으로 펴서 다루면 BCE + SIMD 친화
int[] flat = new int[rows * cols];
for (int i = 0; i < flat.Length; i++)
total += flat[i]; // ldelem.i4 — BCE 가능
성능이 정말 중요한 핫패스라면 — 다차원 배열을 쓰지 말고 1차원으로 펴서 직접 인덱스 매핑하는 게 가장 빠릅니다. C# 성능 최적화 가이드도 일반적으로 다차원 배열보다는 들쭉날쭉(또는 1차원 매핑)을 권장합니다.
6. C# 버전별 변화
배열 형태 자체는 C# 1.0부터 거의 그대로지만, 주변 도구가 발전했습니다.
| 버전 | 변화 | 비고 |
|---|---|---|
| 1.0 | int[,], int[][] 모두 도입 |
기본 |
| 7.2 | Span<T> 도입 — 1차원 매핑한 다차원 데이터를 타입 안전하게 잘라 보기 |
성능 패턴 정착 |
| 12 | 컬렉션 식 [1, 2, 3]이 다차원 배열에는 적용 안 됨 |
들쭉날쭉은 OK |
C# 12 컬렉션 식은 int[,]에 직접 쓸 수 없습니다.
int[,] g = [[1, 2], [3, 4]]; // 컴파일 오류
int[,] g = { {1, 2}, {3, 4} }; // 기존 표기는 그대로 동작
int[][] j = [[1, 2], [3, 4, 5]]; // 들쭉날쭉은 컬렉션 식 OK
다차원 배열은 컴파일러 입장에서 1차원 배열과 다른 트랙(newobj)이라, 새 컬렉션 문법의 혜택을 받지 못합니다. 들쭉날쭉은 본질이 1차원 배열이라 자연스럽게 컬렉션 식과 호환됩니다 — 이것 하나만으로도 신문법 친화도는 들쭉날쭉이 한 수 위입니다.
7. 정리
- [ ]
int[,]= 단일 객체, row-major 연속 저장, 모든 행 길이 동일. - [ ]
int[][]= 외부 배열(참조 N개) + 행마다 별도 객체, 총 N+1개, 행마다 길이 다를 수 있음. - [ ] IL:
int[,]는newobj+Get/Set메서드 호출.int[][]는newarr+ldelem.ref+ldelem.i4(1차원 빠른 길). - [ ] JIT 최적화(BCE, SIMD)는 1차원 배열에 강하다 → 안쪽 루프 성능은 들쭉날쭉이 보통 더 빠르다.
- [ ] GC 객체 수는
int[,]압승 (1 vs N+1). 큰 직사각형 데이터에서 결정적 차이. - [ ] 모양이 직사각형이 확실하고 GC 부담을 줄여야 한다 →
int[,]. 격자·체스판·타일맵·이미지. - [ ] 행마다 길이가 다르거나 안쪽 루프 성능이 중요하다 →
int[][]. 가변 그룹·페이지·핫패스 매트릭스. - [ ] 극한 성능 핫패스라면
new int[rows * cols]1차원 매핑 +Span<T>. SIMD·Burst Compiler·NativeArray와 모두 친함. - [ ]
int[,]에서는Length(전체 칸 수) vsGetLength(d)(차원별 길이) vsRank(차원 수)를 헷갈리지 말 것. - [ ]
new int[3][]은 외부만 만든다 — 행을 일일이new int[…]로 채워야 한다. - [ ] C# 12 컬렉션 식
[[1,2],[3,4]]은 들쭉날쭉(int[][])에만 적용된다.
'C# 기초' 카테고리의 다른 글
| [PART6.배열과 문자열 기본(4/14)] 범위·인덱스 연산자 — `^1`과 `1..3` 한 글자에 숨은 GC 비용 (0) | 2026.05.01 |
|---|---|
| [PART6.배열과 문자열 기본(3/14)] 배열 초기화·순회·Length — 가장 많이 쓰는 세 가지 작업 (0) | 2026.05.01 |
| [PART6.배열과 문자열 기본(1/14)] 1차원 배열 — 가장 단순하지만 가장 깊은 데이터 구조 (0) | 2026.05.01 |
| [PART5.메서드와 매개변수(18/18)] 메서드를 매개변수로 넘기기 — 콜백의 첫 소개 (0) | 2026.04.28 |
| [PART5.메서드와 매개변수(17/18)] async Main — 진입점도 비동기로 (C# 7.1) (0) | 2026.04.28 |