들어가며
많은 이들이 자료구조를 Java나 Python으로 입문한다. ArrayList나 list.append() 한 줄이면 복잡한 메모리 관리 없이도 데이터를 손쉽게 다룰 수 있기 때문이다.
하지만 임베디드/펌웨어 개발 환경에서는 이야기가 다르다. 가용 RAM이 바이트 단위로 제한적인 환경에서 ‘알아서 관리해 주는 메모리’란 존재하지 않는다. 포인터로 주소에 직접 접근하고, 스택과 데이터 영역의 크기를 설계 단계부터 고민해야 한다. 특히 안정성이 최우선인 환경에서는 메모리 파편화(Fragmentation) 위험 때문에 힙(Heap) 할당조차 지양하며, 모든 데이터를 예측 가능한 범위 안에서 관리한다.
이 시리즈는 단순한 구현을 넘어, C언어를 통해 데이터가 메모리에 어떻게 배치되는지, 그리고 왜 펌웨어 개발에서 메모리를 ‘예측 가능하게’ 설계해야 하는지 그 이유를 함께 파헤친다. 첫 번째 주제는 모든 자료구조의 출발점, **배열(Array)**이다.
정적 배열 (Static Array)
| |
이 한 줄이 선언되는 순간 스택(Stack) 에 5 × 4 = 20바이트가 연속으로 잡힌다.
arr은 첫 번째 요소의 주소(0x1000)를 가리키는 포인터다. arr[2]에 접근한다는 건 *(arr + 2), 즉 0x1000 + 2×4 = 0x1008 주소를 직접 읽는 것이다.
메모리 설계
| 항목 | 값 |
|---|---|
| 요소 타입 | int (4바이트) |
| 요소 개수 | 5 |
| 총 메모리 사용량 | 20바이트 |
| 저장 위치 | Stack |
| 접근 시간복잡도 | O(1) |
정적 배열의 한계
크기가 컴파일 타임에 고정된다. 사용자 입력에 따라 크기가 달라져야 하는 상황이라면 쓸 수 없다.
VLA는 스택 오버플로우 위험이 있고, C11부터는 선택사항(optional)이다. 실무나 코딩테스트에서는 쓰지 않는 게 낫다.
동적 배열 (Dynamic Array)
런타임에 크기를 결정해야 할 때 malloc()으로 힙(Heap) 에 메모리를 직접 할당한다.
C++의 std::vector, Java의 ArrayList가 바로 이 구조의 구현체다. 내부적으로 동일하게 힙 할당 + Doubling Strategy를 사용한다. 우리가 직접 만들어보는 것이다.
스택에는 DynamicArray 구조체(포인터 + int 2개 = 16바이트)만 올라가고, 실제 데이터 블록은 힙에 연속으로 저장된다. da->data가 그 힙 주소를 가리키는 포인터다.
메모리 설계
| 항목 | 값 |
|---|---|
| 포인터 크기 (스택) | 8바이트 (64bit) |
| 데이터 크기 (힙) | sizeof(int) × n 바이트 |
| n = 5일 때 총 사용량 | 8 + 20 = 28바이트 |
| 저장 위치 | 포인터 → Stack, 데이터 → Heap |
| 접근 시간복잡도 | O(1) |
구현
Definition
size와 capacity를 분리한 이유가 있다. 요소를 추가할 때마다 realloc()을 호출하면 성능이 나쁘다. capacity를 2배씩 늘리는 방식(Doubling Strategy)으로 realloc() 호출 횟수를 O(log n)으로 줄인다.
Functions
| |
realloc() 동작 방식: 현재 블록 뒤에 공간이 있으면 확장, 없으면 새 위치에 복사 후 이전 블록 해제.
Main (테스트 및 성능 측정)
| |
실행하면 아래와 같은 결과를 볼 수 있다.
1,000만 개를 넣는데 realloc()은 단 23번. capacity가 4 → 8 → 16 → … → 16,777,216으로 두 배씩 늘어나기 때문이다. 매번 realloc()을 호출했다면 1,000만 번 호출했을 것이다.
정적 배열 vs 동적 배열 비교
| 항목 | 정적 배열 | 동적 배열 |
|---|---|---|
| 크기 결정 시점 | 컴파일 타임 | 런타임 |
| 저장 위치 | Stack | Heap |
| 크기 변경 | 불가 | realloc()으로 가능 |
| 접근 속도 | O(1) | O(1) |
| 메모리 해제 | 자동 (스코프 종료 시) | free() 필수 |
| 사용 상황 | 크기가 고정된 경우 | 크기가 가변적인 경우 |
정리
- 배열은 연속된 메모리 블록이다. 인덱스 접근이 O(1)인 이유는 시작 주소 + offset으로 바로 계산되기 때문.
- 정적 배열은 스택, 동적 배열은 힙에 올라간다.
malloc()으로 할당한 메모리는 반드시free()로 해제해야 한다. 해제하지 않으면 메모리 누수(Memory Leak).realloc()은 반드시 임시 포인터로 받아서 NULL 체크 후 대입한다. 직접 대입하면 실패 시 기존 주소를 잃는다.free()순서: 내부 데이터 → 구조체 자체. 반대로 하면 Undefined Behavior.- Doubling Strategy로
realloc()호출 횟수를 O(log n)으로 줄일 수 있다. 이것이std::vector,ArrayList의 내부 동작 원리이기도 하다.
다음 편에서는 포인터를 본격적으로 활용하는 연결 리스트(Linked List) 를 구현한다. 배열과 달리 메모리가 연속적이지 않아도 되는 구조가 어떤 장단점을 갖는지 살펴본다.