왜 자료구조인가
프로그래밍의 본질은 데이터를 다루는 것입니다. 같은 데이터라도 어떻게 저장하느냐 에 따라 읽기, 쓰기, 검색 속도가 완전히 달라집니다.
100만 개의 데이터에서 특정 값을 찾는다고 생각해 보세요. 정렬된 배열에서 이진 탐색을 하면 약 20번의 비교로 찾을 수 있지만, 정렬되지 않은 링크드 리스트에서는 최악의 경우 100만 번을 순회해야 합니다. 자료구조 선택이 곧 성능입니다.
이 시리즈의 첫 번째 글에서는 가장 기본적인 두 자료구조, 배열 과 링크드 리스트를 비교하면서 Big-O 표기법을 함께 익힙니다.
배열 (Array)
배열은 연속된 메모리 공간 에 같은 타입의 데이터를 순서대로 저장하는 자료구조입니다.
메모리 구조
배열이 메모리 주소 0x100에서 시작하고, 각 요소가 4바이트라면:
주소 값
0x100 arr[0]
0x104 arr[1]
0x108 arr[2]
0x10C arr[3]인덱스로 접근할 때는 시작 주소 + (인덱스 x 요소 크기)로 바로 계산합니다. 이것이 배열의 인덱스 접근이 O(1)인 이유입니다.
의사코드
Array-Access(A, i):
return A[i] // O(1) - 주소 계산으로 바로 접근
Array-Insert(A, i, value):
for j = length(A) down to i+1 // O(n) - 뒤 요소를 한 칸씩 이동
A[j] = A[j-1]
A[i] = value
length(A) = length(A) + 1
Array-Delete(A, i):
for j = i to length(A)-2 // O(n) - 앞으로 한 칸씩 이동
A[j] = A[j+1]
length(A) = length(A) - 1JavaScript 구현
// 배열 기본 연산
const arr = [10, 20, 30, 40, 50];
// 인덱스 접근 - O(1)
console.log(arr[2]); // 30
// 중간 삽입 - O(n): splice는 뒤 요소를 모두 이동
arr.splice(2, 0, 25); // [10, 20, 25, 30, 40, 50]
// 중간 삭제 - O(n): 뒤 요소를 앞으로 당김
arr.splice(2, 1); // [10, 20, 30, 40, 50]
// 끝에 추가/삭제 - 평균 O(1)
arr.push(60); // [10, 20, 30, 40, 50, 60]
arr.pop(); // [10, 20, 30, 40, 50]push와 pop은 요소 이동이 필요 없으므로 평균 O(1)입니다. 하지만 shift (맨 앞 삭제)와 unshift (맨 앞 삽입)는 모든 요소를 이동해야 하므로 O(n)입니다.
링크드 리스트 (Linked List)
링크드 리스트는 각 노드가 값(data)과 다음 노드를 가리키는 포인터 (next)로 구성됩니다. 메모리 어디에나 흩어져 있어도 포인터로 연결됩니다.
메모리 구조
head -> [A | next] -> [B | next] -> [C | next] -> null
각 노드는 메모리의 임의 위치에 저장됨:
노드 A: 0x3A0 (next: 0x7F8)
노드 B: 0x7F8 (next: 0x120)
노드 C: 0x120 (next: null)배열과 달리 연속된 공간이 필요 없으므로, 메모리 단편화 상황에서도 유리합니다.
의사코드
Node:
data
next
LinkedList-Access(head, i):
current = head
for j = 0 to i-1 // O(n) - 포인터를 따라 순회
current = current.next
return current.data
LinkedList-Insert(prev, value): // prev 노드 뒤에 삽입
newNode = Node(value)
newNode.next = prev.next // O(1) - 포인터 2개만 변경
prev.next = newNode
LinkedList-Delete(prev): // prev 다음 노드를 삭제
target = prev.next
prev.next = target.next // O(1) - 포인터 1개만 변경JavaScript 구현
class Node {
constructor(data) {
this.data = data;
this.next = null;
}
}
class LinkedList {
constructor() {
this.head = null;
this.size = 0;
}
// 인덱스로 접근 - O(n)
get(index) {
let current = this.head;
for (let i = 0; i < index; i++) {
current = current.next;
}
return current.data;
}
// 특정 노드 뒤에 삽입 - O(1)
insertAfter(node, data) {
const newNode = new Node(data);
newNode.next = node.next;
node.next = newNode;
this.size++;
}
// 특정 노드 뒤의 노드 삭제 - O(1)
deleteAfter(node) {
if (node.next === null) return;
node.next = node.next.next;
this.size--;
}
// 맨 앞에 삽입 - O(1)
prepend(data) {
const newNode = new Node(data);
newNode.next = this.head;
this.head = newNode;
this.size++;
}
}링크드 리스트의 삽입/삭제가 O(1)이 되는 핵심 조건은 삽입/삭제할 위치의 노드를 이미 알고 있을 때 입니다. 위치를 모르면 먼저 O(n) 순회가 필요합니다.
Big-O 표기법
Big-O는 입력 크기(n)에 따른 최악의 경우 성능 증가율을 표현합니다. 상수는 무시하고 증가율의 차수만 봅니다.
// O(1) - 상수 시간: 입력 크기와 무관
function getFirst(arr) {
return arr[0];
}
// O(n) - 선형 시간: 입력에 비례
function findValue(arr, target) {
for (let i = 0; i < arr.length; i++) {
if (arr[i] === target) return i;
}
return -1;
}
// O(n^2) - 이차 시간: 중첩 루프
function hasDuplicate(arr) {
for (let i = 0; i < arr.length; i++) {
for (let j = i + 1; j < arr.length; j++) {
if (arr[i] === arr[j]) return true;
}
}
return false;
}n = 1,000일 때:
- O(1): 1번 연산
- O(n): 1,000번 연산
- O(n^2): 1,000,000번 연산
O(1)과 O(n)의 차이는 작을 수 있지만, O(n)과 O(n^2)의 차이는 데이터가 커질수록 극적으로 벌어집니다.
시각화
아래 시각화에서 배열과 링크드 리스트의 접근, 삽입, 삭제 과정을 단계별로 비교해 보세요.
비교 표
| 연산 | 배열 | 링크드 리스트 | 비고 |
|---|---|---|---|
| 인덱스 접근 | O(1) | O(n) | 배열은 주소 계산, 리스트는 순회 |
| 탐색 | O(n) | O(n) | 둘 다 최악의 경우 전체 순회 |
| 맨 앞 삽입 | O(n) | O(1) | 배열은 전체 이동 필요 |
| 맨 뒤 삽입 | O(1)* | O(n) | *배열은 공간 부족 시 재할당 |
| 중간 삽입 | O(n) | O(1)** | ** 위치를 알고 있을 때 |
| 삭제 | O(n) | O(1)** | ** 위치를 알고 있을 때 |
| 메모리 효율 | 높음 | 낮음 | 리스트는 포인터 추가 저장 |
| 캐시 친화성 | 높음 | 낮음 | 배열은 연속 메모리로 캐시 히트 |
실무 연결: JavaScript의 Array는 진짜 배열이 아니다
C/Java의 배열은 위에서 설명한 고정 크기의 연속 메모리 구조입니다. 하지만 JavaScript의 Array는 다릅니다.
V8 엔진은 배열을 두 가지 모드 로 관리합니다.
// 1. Packed (밀집) 모드 - C 배열처럼 연속 메모리 사용
const packed = [1, 2, 3, 4, 5];
// 2. Holey (구멍 있는) 모드 - 해시맵처럼 동작
const holey = [1, , , 4, 5]; // 빈 슬롯이 있음
packed[100] = 99; // 갑자기 큰 인덱스 사용 -> Holey로 전환V8의 내부 분류 (V8 블로그 "Elements kinds in V8" 기준):
- SMI (Small Integer) : 정수만 들어 있을 때 가장 빠름
- DOUBLE : 부동소수점 포함 시
- ELEMENTS : 다양한 타입 혼재 시
- HOLEY 변형 : 빈 슬롯이 있으면 각각의 Holey 버전으로 전환
// 성능 팁: V8이 Packed SMI를 유지하도록
const fast = [];
for (let i = 0; i < 1000; i++) {
fast.push(i); // SMI, Packed 유지 -> 최적화됨
}
// 피해야 할 패턴
const slow = new Array(1000); // Holey! 빈 슬롯 1000개
slow[999] = 1; // 여전히 Holey핵심은 JavaScript Array가 동적 크기 조절, 타입 혼합, 희소 배열 을 지원하기 위해 전통적인 배열보다 복잡한 내부 구조를 사용한다는 것입니다. 밀집 배열을 유지하면 C 배열에 가까운 성능을 얻을 수 있습니다.
다음 단계
배열과 링크드 리스트는 모든 자료구조의 기초입니다. 다음 글에서는 이 두 자료구조를 활용해 만드는 스택 (Stack)과 큐 (Queue) 를 살펴봅니다. "마지막에 넣은 것을 먼저 꺼내는" LIFO와 "먼저 넣은 것을 먼저 꺼내는" FIFO가 실제로 어떻게 구현되는지 시각화합니다.