Theme:

배열의 크기가 고정되어 있다면, ArrayList는 어떻게 요소를 계속 추가할 수 있을까요?

배열이란

배열(Array)은 같은 타입의 데이터를 연속된 메모리 공간에 저장하는 자료구조입니다.

  • 인덱스를 통해 O(1) 에 접근할 수 있습니다.
  • 크기가 생성 시점에 고정됩니다.
  • 메모리가 연속적이기 때문에 CPU 캐시 친화적입니다.
JAVA
// 자바에서 정적 배열 선언
int[] arr = new int[10]; // 크기 10으로 고정
arr[0] = 42;             // O(1) 접근

정적 배열은 단순하고 빠르지만, 크기를 미리 알아야 한다는 제약이 있습니다. 데이터의 양을 예측하기 어려운 상황에서는 불편합니다.

왜 동적 배열이 필요한가

실무에서는 데이터의 개수를 미리 알 수 없는 경우가 대부분입니다.

  • HTTP 요청으로 들어오는 파라미터 목록
  • DB에서 조회한 결과 리스트
  • 사용자가 입력하는 데이터 목록

이런 상황에서 매번 "최대 크기"를 정해놓고 배열을 만드는 것은 비효율적입니다. 그래서 동적 배열 이 필요합니다.

ArrayList의 내부 동작

Java의 ArrayList는 내부적으로 정적 배열 을 감싸고 있습니다.

핵심 필드

JAVA
// ArrayList 내부 (간략화)
public class ArrayList<E> {
    Object[] elementData; // 실제 데이터를 저장하는 배열
    int size;             // 현재 저장된 요소 수
}
  • elementData의 길이가 capacity (내부 배열의 크기)
  • size는 실제로 저장된 요소의 수
  • 항상 size <= capacity

capacity와 size의 관계

JAVA
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를 초과하면 다음 과정이 일어납니다.

  1. 새로운 배열을 할당합니다 (기존 capacity의 약 1.5배).
  2. 기존 배열의 모든 요소를 새 배열로 복사합니다.
  3. 기존 배열은 GC 대상이 됩니다.
JAVA
// 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 11빈 공간에 추가
add 21빈 공간에 추가
add 31빈 공간에 추가
add 41빈 공간에 추가
add 54 + 1 = 54개 복사 + 1개 추가
add 61빈 공간에 추가
add 76 + 1 = 76개 복사 + 1개 추가
add 81빈 공간에 추가

총 비용: 1+1+1+1+5+1+7+1 = 18

8번의 연산에 18의 비용이므로, 평균 비용은 약 2.25 입니다. 이것이 amortized O(1)입니다.

수학적으로

n번의 add를 수행할 때, 배열 복사에 드는 총 비용은 대략 다음과 같습니다.

PLAINTEXT
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가 대부분의 경우에 더 빠릅니다.

코드 예제

기본 사용

JAVA
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를 미리 지정하는 것이 좋습니다.

JAVA
// DB에서 대략 1000건 정도 조회될 것으로 예상
ArrayList<Order> orders = new ArrayList<>(1000);

// 불필요한 배열 복사를 줄일 수 있습니다
for (Order order : queryResult) {
    orders.add(order);
}

trimToSize로 메모리 절약

JAVA
ArrayList<String> list = new ArrayList<>(1000);
// 실제로 100개만 추가했다면
// capacity: 1000, size: 100 → 900칸 낭비

list.trimToSize(); // capacity를 size에 맞춰 줄임
// capacity: 100, size: 100

백엔드/실무에서의 연결

JPA에서의 List

JAVA
@Entity
public class Order {
    @OneToMany(mappedBy = "order")
    private List<OrderItem> items = new ArrayList<>();
    // JPA는 내부적으로 PersistentBag 등으로 래핑하지만
    // 기본 동작은 ArrayList 기반입니다
}

Stream API와 ArrayList

JAVA
// collect(Collectors.toList())는 내부적으로 ArrayList를 생성합니다
List<String> filtered = names.stream()
    .filter(name -> name.startsWith("A"))
    .collect(Collectors.toList());

Arrays.asList() 주의점

JAVA
// 이 리스트는 고정 크기입니다 (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 비교

특성배열ArrayListLinkedList
크기고정가변가변
인덱스 접근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를 선택하면 됩니다. 캐시 효율이 좋기 때문입니다.
댓글 로딩 중...