← all posts
DEV 2026.05.02 · 11 min read Intermediate

Java 배열과 Arrays 클래스, 무엇을 알아야 하는가

배열의 메모리 구조부터 정렬·검색·복사·변환·다차원 배열까지, Arrays 클래스의 설계 철학과 실전 함정을 추적한다.


Java 배열은 언어에서 가장 단순한 자료구조지만, Arrays 클래스를 제대로 쓰지 않으면 예상치 못한 함정에 빠진다. arr == arr2true인데 값은 다르거나, Arrays.sort()Collections.reverseOrder()를 거부하거나, 2차원 배열 복사본이 원본과 여전히 연결되어 있거나. 이 함정들은 전부 배열의 설계 원칙을 모를 때 생긴다.

배열의 본질: 고정 크기 연속 메모리

배열은 같은 타입 원소를 연속된 메모리 공간에 배치한다. int[5]를 생성하면 4바이트 × 5 = 20바이트가 연속 할당되고, 인덱스 접근은 baseAddress + index * elementSize로 O(1)이다.

이 구조의 귀결이 두 가지다.

첫째, 크기는 생성 시 고정된다. arr.lengthfinal 필드다. 재할당이 필요하면 새 배열을 만들어 복사해야 한다.

둘째, 참조 타입 배열은 포인터 배열이다. String[]String 객체를 담는 게 아니라 참조(주소)를 담는다. 배열을 복사해도 원소 객체까지 복사되지 않는 이유가 여기에 있다.

String[] original = {"Alice", "Bob"};
String[] copy = Arrays.copyOf(original, 2);

copy[0] = "ALICE";          // copy의 포인터만 교체
System.out.println(original[0]);  // Alice (영향 없음)

하지만 원소가 가변 객체(StringBuilder 등)라면 포인터가 공유되므로 원소 내부 변경이 양쪽에 반영된다.

정렬: 기본 타입과 객체의 분기

Arrays.sort()는 입력 타입에 따라 알고리즘이 달라진다.

  • 기본 타입 배열 (int[], double[] 등): Dual-Pivot Quicksort. 평균 O(n log n), 불안정 정렬.
  • 객체 배열 (String[], Integer[] 등): TimSort (병합 정렬 + 삽입 정렬). 항상 O(n log n), 안정 정렬.

안정 정렬의 차이는 다중 조건 정렬에서 드러난다. 점수로 1차 정렬한 뒤 동점자의 원래 순서가 보존되어야 한다면, 객체 배열 + Comparator를 써야 한다.

내림차순 정렬에서 흔한 함정이 있다.

// ❌ 컴파일 에러
int[] arr = {5, 2, 8};
Arrays.sort(arr, Collections.reverseOrder());

// ✅ Wrapper 배열 필요
Integer[] arr = {5, 2, 8};
Arrays.sort(arr, Collections.reverseOrder());

Collections.reverseOrder()Comparator<T>를 반환하는데, int[]는 제네릭 인자를 받을 수 없다. int[]를 내림차순 정렬하려면 정렬 후 수동으로 반전하거나 Stream을 사용해야 한다.

다중 조건 정렬은 람다나 Comparator 체이닝으로 간결하게 표현할 수 있다.

// 점수 내림차순, 동점이면 나이 오름차순
Arrays.sort(students,
    Comparator.comparingInt((Student s) -> s.score).reversed()
              .thenComparingInt(s -> s.age));

검색: 반환값의 의미를 읽어라

Arrays.binarySearch()정렬된 배열에서만 올바르게 동작한다. 정렬 없이 호출하면 결과가 예측 불가능하다.

반환값 규칙은 처음엔 낯설지만 일관성이 있다.

  • 찾았을 때: 해당 인덱스 (≥ 0)
  • 못 찾았을 때: -(삽입 위치) - 1 (< 0)

삽입 위치를 복원하려면 -(result) - 1로 계산한다. 이 값은 “이 원소가 있었다면 있어야 할 인덱스”다. LIS(최장 증가 부분 수열) 알고리즘이 이 반환값을 활용하는 대표 사례다.

중복 원소가 있을 때는 주의가 필요하다. binarySearch()는 중복 중 어느 인덱스를 반환할지 보장하지 않는다. 첫 번째나 마지막 인덱스가 필요하다면 반환값 기준으로 좌우를 직접 스캔해야 한다.

정렬-검색 Comparator 일치

커스텀 Comparator로 정렬한 배열을 검색할 때는 반드시 동일한 ComparatorbinarySearch()에도 전달해야 한다. 정렬 기준과 검색 기준이 다르면 결과가 틀린다.

복사: 얕은 복사와 깊은 복사의 경계

Arrays.copyOf()System.arraycopy()는 모두 **얕은 복사(shallow copy)**다. 기본 타입 배열에서는 값이 복사되므로 문제없지만, 참조 타입 배열에서는 포인터만 복사된다.

2차원 배열이 대표적인 함정이다.

int[][] original = {{1, 2}, {3, 4}};

// 얕은 복사 — 행 배열만 복사, 원소 배열은 공유
int[][] shallow = Arrays.copyOf(original, original.length);
shallow[0][0] = 99;
System.out.println(original[0][0]);  // 99 (원본도 변경!)

// 깊은 복사 — 각 행을 개별 복사
int[][] deep = new int[original.length][];
for (int i = 0; i < original.length; i++) {
    deep[i] = Arrays.copyOf(original[i], original[i].length);
}
deep[0][0] = 99;
System.out.println(original[0][0]);  // 1 (원본 보존)

Arrays.copyOf()는 새 배열을 반환하는 편의 메서드고, System.arraycopy()는 기존 배열에 복사하는 저수준 메서드다. 내부적으로 Arrays.copyOf()System.arraycopy()를 호출하므로 성능 차이는 없다. 복잡한 부분 복사가 필요할 때는 System.arraycopy()를 쓴다.

변환: asList()의 고정 크기 함정

Arrays.asList()는 배열을 List로 감싸는 뷰를 반환한다. 원본 배열과 메모리를 공유하며, 크기 변경(add/remove)이 불가능하다.

List<String> fixed = Arrays.asList("a", "b", "c");
fixed.set(0, "A");    // ✅ 원소 교체 가능
fixed.add("d");       // ❌ UnsupportedOperationException

추가·삭제가 필요하면 new ArrayList<>(Arrays.asList(arr))로 감싸야 한다.

기본 타입 배열(int[])은 Arrays.asList()에 직접 전달하면 배열 자체가 원소 1개인 리스트가 된다. Stream을 통해 박싱해야 올바른 변환이 이루어진다.

int[] arr = {1, 2, 3};
List<Integer> list = Arrays.stream(arr).boxed()
                           .collect(Collectors.toList());

정리

  • 배열은 고정 크기 연속 메모리다. length는 변경 불가이고, 참조 타입 배열은 포인터 배열이다.
  • Arrays.sort()는 기본 타입에 Quicksort, 객체에 TimSort를 쓴다. 내림차순은 Integer[] + Collections.reverseOrder()가 필요하다.
  • binarySearch()의 음수 반환값은 삽입 위치를 인코딩한다. -(result) - 1로 복원한다.
  • copyOf()는 얕은 복사다. 2D 배열의 깊은 복사는 각 행을 개별 복사해야 한다.
  • asList()는 고정 크기 뷰다. 가변 리스트가 필요하면 new ArrayList<>()로 감싸라.

다음 글에서는 Collections 클래스가 List, Set, Map에 대해 동일한 철학을 어떻게 적용하는지, 그리고 Arrays와 어떻게 다른 선택을 했는지 추적한다.