익숙함 뒤에 숨겨진 '마법'
자바 개발자라면 하루에도 수십 번씩 작성하는 코드가 있습니다. 바로 List<>입니다.
우리는 습관적으로 배열(Array) 대신 ArrayList를 선택합니다. 이유는 단순합니다. 압도적으로 편하기 때문입니다. new int[10]처럼 크기를 미리 정할 필요도 없고, 데이터가 늘어나면 알아서 저장 공간이 무한정 늘어나는 것처럼 보입니다.
하지만 엔지니어링의 세계에 공짜 점심은 없습니다. 고정된 물리 메모리를 사용하는 컴퓨터 위에서, 도대체 어떻게 ArrayList는 무한히 늘어나는 '마법'을 부리는 걸까요? 그리고 우리가 그 편리함의 대가로 지불하고 있는 비용(Cost)은 과연 무엇일까요?
오늘은 리플렉션(Reflection)을 통해 그 내부를 직접 해부해 보겠습니다.
배열의 한계와 리스트의 거짓말
ArrayList를 이해하려면 먼저 그 근간인 배열(Array)의 속성을 직시해야 합니다. 컴퓨터 구조 관점에서 배열은 가장 정직한 자료구조입니다.
- 연속성: 데이터가 메모리에 빈틈없이 붙어 있습니다.
- 고정성: 한 번 10칸짜리 방을 잡으면, 건물을 부수기 전까지는 11번째 데이터를 받을 수 없습니다. (크기 불변)
그런데 ArrayList는 데이터를 계속 받습니다. 이것은 "같은 방을 늘려서 쓰는 것"이 아닙니다. 실제로는 "몰래 더 큰 집으로 이사를 가는 것"에 가깝습니다.
실험: 리플렉션으로 주소 추적하기
데이터를 추가할 때마다 내부 배열(elementData)의 메모리 주소가 실제로 바뀌는지 추적해 보겠습니다.
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;
public class Main {
public static void main(String[] args) throws Exception {
List<Integer> list = new ArrayList<>();
for (int i = 0; i < 100; i++) {
list.add(i);
Object[] internalArray = getInternalArray(list);
int identityHash = System.identityHashCode(internalArray);
System.out.printf("size = %2d, array identityHash = %d%n", list.size(), identityHash);
}
}
private static Object[] getInternalArray(List<?> list) throws Exception {
Field elementDataField = ArrayList.class.getDeclaredField("elementData");
elementDataField.setAccessible(true);
return (Object[]) elementDataField.get(list);
}
}[실험 결과]
size = 1, array identityHash = 1927950199
...
size = 10, array identityHash = 1927950199
size = 11, array identityHash = 1828972342 // 주소 변경 발생
...
size = 22, array identityHash = 1452126962
size = 23, array identityHash = 931919113 // 주소 변경 발생
...
size = 49, array identityHash = 1607521710
size = 50, array identityHash = 764977973 // 주소 변경 발생
...
size = 74, array identityHash = 381259350
...
size =100, array identityHash = 381259350데이터가 꽉 차는 순간(10개, 15개...), 내부 배열의 주소값이 완전히 달라집니다. 기존 배열을 늘린 게 아니라, 아예 다른 객체로 교체된 것입니다.
편리함의 청구서, '복사 비용'
이 '이사' 과정이 바로 ArrayList의 핵심 메커니즘인 grow() 메서드입니다. 우리가 편하게 add()를 호출하는 동안, 뒤에서는 다음과 같은 작업이 수행됩니다.
private Object[] grow(int minCapacity) {
int oldCapacity = this.elementData.length;
if (oldCapacity <= 0 && this.elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
return this.elementData = new Object[Math.max(10, minCapacity)];
} else {
int newCapacity = ArraysSupport.newLength(oldCapacity, minCapacity - oldCapacity, oldCapacity >> 1);
return this.elementData = Arrays.copyOf(this.elementData, newCapacity);
}
}- 배열이 비어 있을 경우
10부터 시작 - 배열이 존재할 경우
ArraysSupport.newLength()를 통해 다음 크기 계산
newLength 함수는 다음과 같습니다.
public static int newLength(int oldLength, int minGrowth, int prefGrowth) {
int prefLength = oldLength + Math.max(minGrowth, prefGrowth);
return 0 < prefLength && prefLength <= 2147483639 ? prefLength : hugeLength(oldLength, minGrowth);
}prefGrowth는 oldCapacity >> 1로, 기존 크기의 절반이라고 할 수 있습니다. 즉, 기존 크기 + 절반 정도로 늘어나는 방식 → 대략 1.5배 확장 정책이라고 말할 수 있습니다.
예:
- 10 → 15 (10 + 5)
- 15 → 22 (15 + 7)
- 22 → 33 (22 + 11)
이 방식이 위에서 확인한 주소 변화 결과와 정확히 일치한다.
마무리
ArrayList는 분명 훌륭한 도구입니다. 복잡한 메모리 관리를 추상화(Abstraction)하여 개발자가 비즈니스 로직에만 집중하게 해주니까요.
하지만 그 추상화의 장막 뒤에 메모리 낭비와 복사 비용이 숨어있다는 사실을 아는 개발자와 모르는 개발자의 코드는 다릅니다.
엔지니어의 선택 가이드
ArrayList가 정답인 경우:- 데이터의 크기가 얼마나 들어올지 예측할 수 없을 때.
- 중간에 데이터 삽입/삭제가 빈번하여 편의성이 생산성과 직결될 때.
Array를 고려해야 하는 경우:- 데이터 크기가 고정적일 때 (예: 1년은 12달, 요일은 7개).
- 수백만 건의 데이터를 다루며, 0.1초의 성능 최적화가 중요할 때.
- 현명한 타협 (
Initial Capacity):ArrayList를 쓰더라도 대략적인 크기를 안다면?new ArrayList<>(100)처럼 초기 크기를 지정해 주세요. 이것만으로도 불필요한 이사(Resizing) 비용을 획기적으로 줄일 수 있습니다.
