Java 배열과 Arrays 클래스, 무엇을 알아야 하는가
배열의 메모리 구조부터 정렬·검색·복사·변환·다차원 배열까지, Arrays 클래스의 설계 철학과 실전 함정을 추적한다.
- 01 Java String은 왜 불변인가
- 02 Java 수 타입의 설계 철학 — 왜 세 가지가 필요한가
- 03 Java 배열과 Arrays 클래스, 무엇을 알아야 하는가
- 04 Java Generics는 왜 타입을 지운가
- 05 Java Enum은 단순한 상수 묶음이 아니다
- 06 Java 예외는 어떻게 설계해야 하는가
- 07 Java Collections Framework, 하나의 철학으로 읽는다
- 08 Java 함수형 인터페이스는 왜 이렇게 설계됐을까
- 09 Modern Java의 불변 설계 — Record, Switch, Sealed Class
- 10 Java Util API의 공통 철학: 비교, 흐름, 부재, 패턴
- 11 Java 8 Time API는 왜 기존 Date를 버렸는가
- 12 Java IO는 왜 이렇게 복잡한가
- 13 Java Reflection은 어떻게 프레임워크를 만드는가
- 14 Java 동시성은 왜 이렇게 설계됐을까
Java 배열은 언어에서 가장 단순한 자료구조지만, Arrays 클래스를 제대로 쓰지 않으면 예상치 못한 함정에 빠진다. arr == arr2가 true인데 값은 다르거나, Arrays.sort()가 Collections.reverseOrder()를 거부하거나, 2차원 배열 복사본이 원본과 여전히 연결되어 있거나. 이 함정들은 전부 배열의 설계 원칙을 모를 때 생긴다.
배열의 본질: 고정 크기 연속 메모리
배열은 같은 타입 원소를 연속된 메모리 공간에 배치한다. int[5]를 생성하면 4바이트 × 5 = 20바이트가 연속 할당되고, 인덱스 접근은 baseAddress + index * elementSize로 O(1)이다.
이 구조의 귀결이 두 가지다.
첫째, 크기는 생성 시 고정된다. arr.length는 final 필드다. 재할당이 필요하면 새 배열을 만들어 복사해야 한다.
둘째, 참조 타입 배열은 포인터 배열이다. 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를 binarySearch()에도 전달해야 한다. 정렬 기준과 검색 기준이 다르면 결과가 틀린다.
복사: 얕은 복사와 깊은 복사의 경계
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와 어떻게 다른 선택을 했는지 추적한다.