배열과 동적 배열 — ArrayList가 크기를 늘리는 진짜 방법
배열의 크기가 고정되어 있다면, ArrayList는 어떻게 요소를 계속 추가할 수 있을까요?
배열이란
배열(Array)은 같은 타입의 데이터를 연속된 메모리 공간에 저장하는 자료구조입니다.
- 인덱스를 통해 O(1) 에 접근할 수 있습니다.
- 크기가 생성 시점에 고정됩니다.
- 메모리가 연속적이기 때문에 CPU 캐시 친화적입니다.
// 자바에서 정적 배열 선언
int[] arr = new int[10]; // 크기 10으로 고정
arr[0] = 42; // O(1) 접근
정적 배열은 단순하고 빠르지만, 크기를 미리 알아야 한다는 제약이 있습니다. 데이터의 양을 예측하기 어려운 상황에서는 불편합니다.
왜 동적 배열이 필요한가
실무에서는 데이터의 개수를 미리 알 수 없는 경우가 대부분입니다.
- HTTP 요청으로 들어오는 파라미터 목록
- DB에서 조회한 결과 리스트
- 사용자가 입력하는 데이터 목록
이런 상황에서 매번 "최대 크기"를 정해놓고 배열을 만드는 것은 비효율적입니다. 그래서 동적 배열 이 필요합니다.
ArrayList의 내부 동작
Java의 ArrayList는 내부적으로 정적 배열 을 감싸고 있습니다.
핵심 필드
// ArrayList 내부 (간략화)
public class ArrayList<E> {
Object[] elementData; // 실제 데이터를 저장하는 배열
int size; // 현재 저장된 요소 수
}
elementData의 길이가 capacity (내부 배열의 크기)size는 실제로 저장된 요소의 수- 항상
size <= capacity
capacity와 size의 관계
ArrayList<String> list = new ArrayList<>();
// 기본 capacity: 10 (Java 8 기준, 최초 add 시 할당)
// size: 0
list.add("A"); // size: 1, capacity: 10
list.add("B"); // size: 2, capacity: 10
// ... 10개까지는 배열 확장 없이 추가 가능
배열 확장 과정
capacity를 초과하면 다음 과정이 일어납니다.
- 새로운 배열을 할당합니다 (기존 capacity의 약 1.5배).
- 기존 배열의 모든 요소를 새 배열로 복사합니다.
- 기존 배열은 GC 대상이 됩니다.
// ArrayList.grow() 메서드 (간략화)
private void grow(int minCapacity) {
int oldCapacity = elementData.length;
// 기존의 1.5배로 새 크기 계산
int newCapacity = oldCapacity + (oldCapacity >> 1);
elementData = Arrays.copyOf(elementData, newCapacity);
}
oldCapacity >> 1은 비트 시프트로, oldCapacity / 2와 같습니다. 결과적으로 1.5배 씩 늘어납니다.
Amortized O(1)의 의미
"배열을 복사하면 O(n)인데, add가 O(1)이라고?"라는 의문이 생길 수 있습니다.
핵심은 분할 상환 분석(Amortized Analysis) 입니다.
예시로 이해하기
capacity가 4인 ArrayList에 8개의 요소를 추가한다고 가정합니다.
| 연산 | 비용 | 설명 |
|---|---|---|
| add 1 | 1 | 빈 공간에 추가 |
| add 2 | 1 | 빈 공간에 추가 |
| add 3 | 1 | 빈 공간에 추가 |
| add 4 | 1 | 빈 공간에 추가 |
| add 5 | 4 + 1 = 5 | 4개 복사 + 1개 추가 |
| add 6 | 1 | 빈 공간에 추가 |
| add 7 | 6 + 1 = 7 | 6개 복사 + 1개 추가 |
| add 8 | 1 | 빈 공간에 추가 |
총 비용: 1+1+1+1+5+1+7+1 = 18
8번의 연산에 18의 비용이므로, 평균 비용은 약 2.25 입니다. 이것이 amortized O(1)입니다.
수학적으로
n번의 add를 수행할 때, 배열 복사에 드는 총 비용은 대략 다음과 같습니다.
1 + 2 + 4 + 8 + ... + n ≈ 2n
n번의 연산에 총 O(n)의 복사 비용이 들므로, 연산 하나당 O(n/n) = O(1) 이 됩니다.
삽입과 삭제의 시간 복잡도
| 연산 | 시간 복잡도 | 이유 |
|---|---|---|
get(i) | O(1) | 인덱스 직접 접근 |
add(e) (끝에 추가) | amortized O(1) | 가끔 배열 복사 |
add(i, e) (중간 삽입) | O(n) | 뒤의 요소들을 밀어야 함 |
remove(i) | O(n) | 뒤의 요소들을 당겨야 함 |
contains(e) | O(n) | 순차 탐색 |
중간 삽입/삭제가 빈번하다면 ArrayList보다 LinkedList 가 나을 수 있습니다. 하지만 실제로는 캐시 효율 때문에 ArrayList가 대부분의 경우에 더 빠릅니다.
코드 예제
기본 사용
import java.util.ArrayList;
public class ArrayListExample {
public static void main(String[] args) {
// 기본 생성 (초기 capacity: 10)
ArrayList<String> names = new ArrayList<>();
// 끝에 추가: amortized O(1)
names.add("Alice");
names.add("Bob");
names.add("Charlie");
// 인덱스 접근: O(1)
String first = names.get(0); // "Alice"
// 중간 삽입: O(n)
names.add(1, "David"); // Bob 이전에 삽입
// 삭제: O(n)
names.remove(2); // Bob 삭제
System.out.println(names); // [Alice, David, Charlie]
}
}
capacity를 미리 지정하기
데이터의 대략적인 크기를 알고 있다면 capacity를 미리 지정하는 것이 좋습니다.
// DB에서 대략 1000건 정도 조회될 것으로 예상
ArrayList<Order> orders = new ArrayList<>(1000);
// 불필요한 배열 복사를 줄일 수 있습니다
for (Order order : queryResult) {
orders.add(order);
}
trimToSize로 메모리 절약
ArrayList<String> list = new ArrayList<>(1000);
// 실제로 100개만 추가했다면
// capacity: 1000, size: 100 → 900칸 낭비
list.trimToSize(); // capacity를 size에 맞춰 줄임
// capacity: 100, size: 100
백엔드/실무에서의 연결
JPA에서의 List
@Entity
public class Order {
@OneToMany(mappedBy = "order")
private List<OrderItem> items = new ArrayList<>();
// JPA는 내부적으로 PersistentBag 등으로 래핑하지만
// 기본 동작은 ArrayList 기반입니다
}
Stream API와 ArrayList
// collect(Collectors.toList())는 내부적으로 ArrayList를 생성합니다
List<String> filtered = names.stream()
.filter(name -> name.startsWith("A"))
.collect(Collectors.toList());
Arrays.asList() 주의점
// 이 리스트는 고정 크기입니다 (java.util.Arrays$ArrayList)
List<String> fixed = Arrays.asList("A", "B", "C");
fixed.add("D"); // UnsupportedOperationException 발생!
// 수정 가능한 ArrayList로 변환
List<String> mutable = new ArrayList<>(Arrays.asList("A", "B", "C"));
mutable.add("D"); // 정상 동작
대량 데이터 처리 시 고려사항
- 수백만 건의 데이터를 ArrayList에 담으면 메모리 부족 이 발생할 수 있습니다.
- 이 경우 페이징 처리나 스트림 기반 처리를 고려해야 합니다.
- 배열 복사 비용도 무시할 수 없으므로, 초기 capacity 설정이 중요합니다.
배열 vs ArrayList vs LinkedList 비교
| 특성 | 배열 | ArrayList | LinkedList |
|---|---|---|---|
| 크기 | 고정 | 가변 | 가변 |
| 인덱스 접근 | O(1) | O(1) | O(n) |
| 끝에 추가 | - | amortized O(1) | O(1) |
| 중간 삽입 | - | O(n) | O(1)* |
| 메모리 | 최소 | 여분 존재 | 노드마다 포인터 |
| 캐시 효율 | 최고 | 높음 | 낮음 |
*LinkedList의 중간 삽입은 노드를 찾는 비용 O(n)이 별도로 필요합니다.
정리
- ArrayList는 내부적으로 **정적 배열을 감싸고 **, capacity 초과 시 1.5배 크기의 새 배열을 할당 합니다.
- add 연산은 amortized O(1) 로, 배열 복사 비용을 여러 연산에 분산시킨 결과입니다.
size는 실제 요소 수,capacity는 내부 배열의 크기입니다.- 대량 데이터를 다룰 때는 초기 capacity 설정 으로 불필요한 복사를 줄이는 것이 좋습니다.
- 실무에서 List가 필요하면 대부분 ArrayList를 선택하면 됩니다. 캐시 효율이 좋기 때문입니다.