
C# 배열 원리를 왜 깊게 알아야 할까요?
오래된 .NET 프로젝트를 유지보수하다 보면 원인을 알 수 없는 성능 저하나 갑작스러운 메모리 부족 문제에 직면할 때가 있어요. 코드 한 줄은 간단한 배열 선언이지만, 그 이면에서는 복잡한 메모리 관리와 가비지 컬렉션(GC)의 움직임이 치열하게 일어나고 있거든요. 단순히 값을 저장하는 바구니로만 생각했다가, 대규모 데이터를 다룰 때 시스템 전체가 느려지는 경험을 한 적이 있나요?
배열은 가장 기본적이면서도 강력한 자료구조예요. 하지만 그 내부 구조를 모른 채 사용하면 예상치 못한 비용을 치르게 돼요. 특히 레거시 환경에서는 최신 문법보다 메모리 레이아웃을 어떻게 설계했느냐가 시스템 안정성을 결정짓는 기준이 되기도 해요. C# 배열 원리를 정확히 파악하면, 성능 병목 지점을 찾아내고 더 효율적인 코드를 작성할 수 있는 눈을 갖게 돼요.
오늘 이 글을 통해 단순히 문법을 넘어, 엔진 내부에서 배열이 어떻게 숨 쉬고 있는지 살펴보려고 해요. 이 내용을 모두 익히고 나면 다음과 같은 능력을 갖추게 될 거예요.
- 배열이 메모리(Stack vs Heap)에 할당되는 정확한 메커니즘 파악
- 다차원 배열과 가변 배열이 성능에 미치는 차이 이해
- 대용량 배열 사용 시 가비지 컬렉션 부하를 줄이는 전략 수립
- 현대적인 C# 환경에서 배열을 더 안전하게 사용하는 방법
배열을 다루기 전 꼭 알아야 할 기초 지식
배열을 본격적으로 파고들기 전에, .NET의 메모리 관리 체계를 먼저 머릿속에 그려두어야 해요. C#에서 배열은 단순히 데이터의 묶음이 아니라, 참조 타입(Reference Type)이라는 점이 핵심이에요. 변수는 배열 객체가 있는 메모리 주소를 가리킬 뿐, 실제 데이터는 힙(Heap) 영역에 자리 잡고 있어요.
메모리 구조의 이해
우리가 변수를 선언할 때, 그 변수가 어디에 저장되는지에 따라 동작 방식이 완전히 달라져요. 기본 타입인 int나 bool 같은 값 타입(Value Type)은 스택(Stack)에 직접 저장되지만, 배열은 그 안에 무엇을 담든 상관없이 무조건 힙에 생성돼요. 이는 배열의 크기가 실행 중에 결정될 수 있고, 메모리 공간을 연속적으로 확보해야 하기 때문이에요.
배열은 힙 영역에 연속된 메모리 블록으로 존재해요. 이 ‘연속성’ 덕분에 인덱스를 통한 빠른 접근이 가능하지만, 동시에 크기를 변경하려면 새로운 메모리 공간을 통째로 할당해야 하는 제약이 생겨요.
자료구조 선택의 기준
무조건 배열이 좋다고 말할 수는 없어요. 상황에 맞는 적절한 선택이 필요해요. 아래 표를 통해 어떤 상황에서 배열을 우선적으로 고려해야 하는지 비교해 보세요.
| 구분 기준 | 배열(Array) | 리스트(List<T>) | 권장 사용 상황 |
|---|---|---|---|
| 크기 고정 여부 | 고정됨 | 동적 변경 가능 | 데이터 양이 확정된 경우 배열 권장 |
| 접근 속도 | 매우 빠름 | 빠름 (약간의 오버헤드) | 성능이 극도로 중요한 연산은 배열 |
| 메모리 효율 | 매우 높음 | 중간 (여유 공간 필요) | 메모리 사용량이 민감할 때 배열 |
결국 데이터의 크기가 변하지 않고, 인덱스를 통한 무작위 접근이 잦을 때 배열을 선택하는 것이 가장 현명한 방법이에요. 만약 데이터를 계속 추가하거나 삭제해야 하는 동적인 상황이라면 리스트가 훨씬 유리하겠지만, 성능 최적화가 필요한 핵심 루프 안에서는 배열을 사용하는 것이 유리할 때가 많아요.
C# 배열의 내부 동작과 심화 활용법
이제 겉모습을 넘어 엔진 내부의 심장부로 들어가 볼게요. C# 배열이 메모리 상에서 어떻게 배치되고, 우리가 인덱스를 호출할 때 CPU는 어떤 계산을 하는지 알게 되면 코드를 대하는 태도가 달라질 거예요.
STEP 1. CLR 객체 레이아웃과 메모리 할당
C#에서 배열을 생성하면 .NET 런타임(CLR)은 힙 영역에 일정한 구조를 가진 블록을 할당해요. 단순히 데이터만 들어가는 게 아니에요. 배열 객체는 다음과 같은 구조를 가져요.
- SyncBlock Index: 스레드 동기화 등을 위해 사용하는 관리 영역이에요.
- Method Table Pointer: 이 객체가 어떤 타입인지 알려주는 메타데이터 주소예요.
- Array Length: 배열의 길이를 저장하는 필드예요. 이 정보 덕분에 우리가 IndexOutOfRangeException을 방지할 수 있는 거죠.
- Actual Data: 실제 우리가 저장한 값들이 연속적으로 배치되는 영역이에요.
이 구조 때문에 배열은 일반 객체보다 아주 약간 더 많은 메모리를 사용해요. 하지만 이 구조 덕분에 런타임은 배열의 길이를 즉시 파악하고 안전한 접근을 보장할 수 있어요.
STEP 2. 인덱싱의 수학적 원리: 주소 계산법
우리가 array[5]라고 코드를 쓰면, 컴퓨터는 마법처럼 값을 가져오는 게 아니에요. 아주 단순하고 빠른 수학 계산을 수행해요. 배열의 시작 주소를 Base Address라고 한다면, 특정 인덱스의 주소는 다음과 같이 계산돼요.
Target Address = Base Address + (Index × Element Size)
예를 들어, 4바이트 크기의 int형 배열에서 5번 인덱스에 접근한다면, 시작 주소에서 정확히 20바이트(5 × 4) 뒤를 찾아가는 식이에요. 이 계산이 매우 단순하기 때문에 배열의 접근 속도가 O(1), 즉 상수 시간 내에 이루어지는 것이에요. 이것이 바로 배열이 가진 강력한 성능의 원천이에요.
STEP 3. 다차원 배열 vs 가변 배열의 결정적 차이
C#에는 두 가지 형태의 다차원 배열이 있어요. 이 차이를 모르면 메모리 효율과 성능에서 큰 손해를 볼 수 있어요.
직사각형 배열 (Rectangular Array)
예를 들어 int[,] matrix = new int[3, 4];와 같이 선언하는 형태예요. 이 배열은 메모리 상에 완벽하게 하나의 직사각형 블록으로 할당돼요. 데이터가 빈틈없이 연속적으로 붙어 있기 때문에 CPU 캐시 적중률(Cache Locality)이 매우 높고 성능이 아주 좋아요.
가변 배열 (Jagged Array)
int[][] jagged = new int[3][];와 같이 선언하는 형태예요. 이건 ‘배열의 배열’이에요. 각 행이 서로 다른 길이를 가질 수 있죠. 메모리 관점에서 보면, 각 행이 서로 다른 힙 영역에 흩어져 있을 수 있어요. 따라서 행을 넘나들 때마다 새로운 주소를 찾아야 하므로 직사각형 배열보다 약간 느릴 수 있지만, 행마다 데이터 크기가 제각각일 때는 메모리를 훨씬 절약할 수 있어요.
STEP 4. 대용량 배열과 가비지 컬렉션(GC)의 위협
실무에서 가장 주의해야 할 지점이 바로 여기예요. .NET의 가비지 컬렉터는 메모리를 관리할 때 크기에 따라 구역을 나눠요. 보통 85,000바이트를 기준으로 그보다 큰 객체는 LOH(Large Object Heap)라는 특별한 구역에 할당해요.
LOH에 할당된 대형 배열은 일반적인 GC 과정에서 쉽게 압축(Compaction)되지 않아요. 즉, 큰 배열을 자주 만들고 버리면 메모리 사이에 빈 공간이 생기는 ‘파편화(Fragmentation)’ 현상이 발생하고, 결국 시스템에 OutOfMemoryException을 일으킬 수 있어요.
이를 방지하려면 대형 배열은 가급적 한 번 만들어 재사용하거나, ArrayPool<T>를 활용해 메모리를 빌려 쓰고 반납하는 전략을 써야 해요.
STEP 5. 성능 최적화 실무 시나리오
배열의 성능을 극대화하고 싶다면 Span<T>나 ReadOnlySpan<T>를 공부해야 해요. 이는 배열의 일부분을 복사하지 않고도 마치 새로운 배열인 것처럼 다룰 수 있게 해주는 현대적인 도구예요. 예를 들어, 거대한 문자열이나 바이트 배열에서 특정 구간만 잘라내어 처리할 때, 기존에는 Substring() 등을 써서 메모리 복사를 일으켰지만, Span을 쓰면 복사 없이 주소값만 넘겨서 처리할 수 있어요.
아래는 성능 최적화를 고려한 코드 예시 시나리오예요.
1. 대규모 로그 데이터를 처리할 때
byte[]를 직접 조작하기보다 Span<byte>를 사용하여 메모리 할당을 최소화하세요.2. 반복문 안에서 배열의 길이를 매번 호출하기보다, 변수에 저장해 두고 사용하세요.
3. 2차원 데이터를 다룰 때는 데이터의 규칙성이 있다면 직사각형 배열을 사용해 캐시 효율을 높이세요.
자주 하는 실수와 해결법
실무에서 개발자들이 흔히 범하는 배열 관련 실수들을 정리했어요. 비슷한 문제로 골머리를 앓고 있다면 이 체크리스트를 확인해 보세요.
- ❌ IndexOutOfRangeException 발생 → 왜 발생하는가: 배열의 크기를 고려하지 않고 인덱스에 접근했기 때문이에요. ✅ 해결법: 반복문의 경계 조건(
i < array.Length)을 명확히 확인하고, 데이터가 비어있을 가능성을 항상 염두에 두세요. - ❌ 대용량 배열의 잦은 생성 → 왜 발생하는가: LOH 파편화를 일으켜 메모리 부족을 유발해요. ✅ 해결법:
ArrayPool<T>.Shared를 사용하여 배열을 재사용하는 방식으로 코드를 개선하세요. - ❌ 가변 배열의 무분별한 사용 → 왜 발생하는가: 행마다 메모리가 흩어져 있어 캐시 효율이 떨어져요. ✅ 해결법; 데이터 구조가 규칙적이라면 직사각형 배열(
int[,])을 우선 고려하세요. - ❌ 배열 복사 시 얕은 복사 사용 → 왜 발생하는가: 배열 내부의 객체 주소만 복사되어 원본 데이터가 의도치 않게 수정될 수 있어요. ✅ 해결법: 객체 배열이라면
Array.Clone()이나 깊은 복사를 수행하는 별도의 로직을 구현하세요. - ❌ LINQ 남용으로 인한 성능 저하 → 왜 발생하는가: 단순 배열 순회에 복잡한 LINQ 연산자를 쓰면 추가적인 객체 할당과 오버헤드가 발생해요. ✅ 해결법: 성능이 핵심인 루프 내부에서는 전통적인
for문을 사용하세요.
자주 묻는 질문
Q. 배열의 크기를 동적으로 조절할 수 있나요?
C#에서 배열은 한 번 생성되면 크기를 바꿀 수 없어요. 크기를 변경하려면 새로운 배열을 만들고 기존 데이터를 복사해야 해요. 만약 빈번하게 크기를 조절해야 한다면 List를 사용하는 것이 훨씬 효율적이에요.
Q. List와 배열 중 무엇이 더 빠른가요?
순수하게 인덱스로 접근하는 속도만 따지면 배열이 약간 더 빨라요. 리스트는 내부적으로 배열을 가지고 있지만, 인덱서(Indexer)를 거쳐야 하는 추가적인 단계가 있기 때문이에요. 하지만 일반적인 비즈니스 로직에서는 그 차이가 미미하므로 편의성을 고려해 선택하세요.
Q. 다차원 배열은 왜 느릴 수 있나요?
가변 배열(Jagged Array)의 경우, 각 행이 서로 다른 메모리 주소에 존재할 수 있기 때문이에요. CPU가 다음 데이터를 읽으려고 할 때, 현재 위치와 멀리 떨어진 곳을 찾아가야 하므로 캐시 적중률이 낮아져 성능이 떨어질 수 있어요.
Q. Span을 쓰면 왜 성능이 좋아지나요?
Span은 메모리의 특정 부분을 가리키는 '창문' 같은 역할을 해요. 새로운 메모리를 할당하거나 데이터를 복사하지 않고도 기존 메모리의 특정 구간을 안전하게 참조할 수 있기 때문에 할당 오버헤드가 거의 없어요.
Q. 2차원 배열을 효율적으로 쓰는 법은요?
데이터를 순회할 때 가장 바깥쪽 루프를 행(Row)으로, 안쪽 루프를 열(Column)로 설정하세요. 메모리에 저장된 순서대로 읽어야 CPU 캐시를 최대한 활용할 수 있어요.
C# 배열 마스터를 위한 최종 점검
오늘 우리는 C# 배열이 단순한 데이터 저장소를 넘어, .NET 런타임과 메모리 구조 사이에서 얼마나 정교하게 동작하는지 살펴보았어요. 배열의 원리를 이해하는 것은 단순한 문법 공부를 넘어, 안정적이고 빠른 소프트웨어를 만드는 엔지니어로 성장하는 과정이에요.
- 배열은 힙(Heap)에 할당되는 참조 타입이며, 연속된 메모리 블록을 가져요.
- 인덱스 접근은 단순한 수학 계산(주소 + 인덱스 × 크기)을 통해 매우 빠르게 수행돼요.
- 다차원 배열은 직사각형 배열(성능 우수)과 가변 배열(유연함)로 나뉘어요.
- 85,000바이트 이상의 큰 배열은 LOH에 할당되어 메모리 파편화를 일으킬 수 있어요.
- 성능 최적화가 필요할 땐 ArrayPool이나 Span을 적극 활용하세요.
이제 이론은 충분해요. 직접 코드를 작성하며 메모리 변화를 관찰해 볼 차례예요. 오늘 배운 내용을 바탕으로 현재 유지보수 중인 프로젝트의 대규모 배열 처리 로직을 한 번 점검해 보는 건 어떨까요?
다음 단계로 나아가기:
- 오늘 할 일: 기존 코드 중 큰 배열이 생성되는 곳을 찾아 LOH 할당 여부 확인하기
- 이번 주 할 일: ArrayPool을 적용하여 메모리 재사용 로직 구현해 보기
- 실행 직전 할 일: Span<T>를 사용하여 문자열 파싱 로직 최적화해 보기
배열의 원리를 완벽히 익혔다면, 이제 데이터를 더 효율적으로 순회하는 방법이 궁금해질 거예요. C# 반복문 관련 글로 연결하여 다음 단계의 학습을 완성해 보세요. 여러분의 프로그래밍 여정을 응원해요!