반응형

[PART6.배열과 문자열 기본(2/14)] 다차원 배열과 들쭉날쭉 배열 — int[,]int[][]는 다른 동물이다

같은 "2차원"이지만 메모리 레이아웃·IL 명령어·GC 부담이 모두 다르다 / 인덱스 접근이 메서드 호출인지 IL 한 줄인지 / 연속 블록의 캐시 친화성 vs 1차원 배열의 BCE 최적화 / Unity 타일맵·체스판에서 무엇을 골라야 하는가


1. 왜 둘을 헷갈리면 안 되는가

C에서 넘어왔거나 자바를 먼저 익힌 신입은 거의 항상 int[][]를 2차원 배열의 기본 형태라고 생각합니다. C#에는 그것 말고도 int[,]라는 별개의 문법이 있고, 두 형태가 같아 보이지만 컴파일 결과·메모리 사용량·접근 속도가 모두 다릅니다.

C#
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 부담 거의 0
  • int[][]로 잡으면 객체 201개, GC 마킹 단계가 200배 비싸짐

반대로 행마다 길이가 다른 데이터(파스칼의 삼각형, 가변 길이 그룹) — 이건 int[,]로는 표현 자체가 안 됩니다. 어느 쪽을 골라야 하는지가 단순한 취향이 아니라 자료의 모양과 성능 목표에 따라 갈리는 결정이라는 뜻입니다.

이 글은 두 형태의 진짜 차이를 메모리·IL·GC 세 층위에서 풀어 보고, Unity 신입이 "어느 쪽을 써야 할지" 곧바로 판단할 수 있게 하는 것이 목표입니다.


2. 두 형태가 무엇인가

비유 — 한 동짜리 직사각형 빌딩 vs 단독주택 N채로 이뤄진 단지

int[,]은 한 동짜리 빌딩입니다. 행과 열 수가 입주 시점에 정해지고, 그 안의 모든 칸이 한 건물 안에 같이 들어 있습니다. 칸 위치만 알면 같은 건물 안에서 곧장 갈 수 있습니다.

int[][]은 같은 단지 안에 단독주택 여러 채가 모여 있는 모양입니다. 단지 입구(외부 배열)에서 "3번 집(행 3)"으로 들어간 뒤, 그 집 안에서 다시 방을 찾아갑니다. 방마다 면적(행마다 길이)이 달라도 됩니다 — 어차피 집끼리는 독립이니까요.

메모리 레이아웃의 결정적 차이

int[,] rect = new int[3,4]

int[,]는 단 하나의 힙 객체이고, 12칸이 한 줄로 누워 있습니다. int[][]는 외부 배열 한 개와 행마다 별도의 객체 — 행이 N개면 총 N+1개입니다. 두 형태의 거의 모든 차이가 이 그림에서 나옵니다.

두 형태의 IL 비교

C#
// 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;
}
IL
// 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이 갈라진다

이 섹션 하나가 두 형태의 성능 차이를 만드는 핵심입니다.

C#
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];
}
IL
// 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에서 정확히 두 단계로 풀립니다.

  1. ldelem.ref — 외부 배열에서 r번째 슬롯의 참조를 꺼냅니다(이게 행 배열).
  2. ldelem.i4 — 그 행 배열의 c번째 슬롯에서 int 값을 꺼냅니다.

두 단계 모두 1차원 배열의 빠른 길이라, 안쪽 루프에서 for (int c = 0; c < row.Length; c++) 같은 정형 패턴을 만들면 BCE가 그대로 적용됩니다. 이론적으로는 두 번 인덱싱이 무거워 보이지만, 실측에서는 1차원 배열 최적화가 강력해 들쭉날쭉이 더 빠른 경우가 많습니다.

순회 IL — Get 호출이 루프 안에 박힌다

C#
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;
}
IL
// 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: 직사각형 데이터를 들쭉날쭉으로 잡지 말기

C#
// ❌ 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: 행마다 길이가 다른 데이터를 무리하게 정사각형으로 잡지 말기

C#
// ❌ 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)

C#
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인 채로 접근

C#
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의 동작이 다르다

C#
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를 기대하지 말 것

C#
// ❌ 다차원 배열의 안쪽 루프는 매 접근이 메서드 호출
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[,]에 직접 쓸 수 없습니다.

C#
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(전체 칸 수) vs GetLength(d)(차원별 길이) vs Rank(차원 수)를 헷갈리지 말 것.
  • [ ] new int[3][]은 외부만 만든다 — 행을 일일이 new int[…]로 채워야 한다.
  • [ ] C# 12 컬렉션 식 [[1,2],[3,4]]은 들쭉날쭉(int[][])에만 적용된다.
반응형

+ Recent posts