본문 바로가기
Java

자바 컬렉션 (1) – 기본 개념

by gentle-tiger 2025. 7. 2.
프로젝트 중 느낀 자바 기초의 부족함을 보완하고자 핵심 주제를 순차적으로 복습할 예정입니다.
자바 코어 → 자바 스레드 → 자바 OOP → 자바의 예외 → 자바의 컬렉션 → 최대 절전 모드 순으로 정리할 예정입니다. 

 

자바 컬렉션 - 기본 개념

  1. 객체 배열의 제한 사항은 무엇입니까?
  2. 배열과 컬렉션의 차이점은 무엇입니까?
  3. 배열과 ArrayList의 차이점은 무엇입니까?
  4. 배열과 벡터의 차이점은 무엇입니까?
  5. 컬렉션 API란?
  6. 컬렉션 프레임워크란?
  7. 컬렉션과 컬렉션의 차이점은 무엇입니까?

추가 정리

  • 자바에서 new T[] 같은 제네릭 배열 생성을 왜 금지했을까?
  • 고정 길이 배열 복사 성능 비교: System.arraycopy

 

객체 배열의 제한 사항은 무엇입니까?

자바에서 배열의 제한 사항은 선언 시 길이가 고정되기 때문에 크기를 바꿀 수 없고, 요소를 삽입·삭제 하려면 새 배열을 만든 뒤 기존 요소를 복사해야 한다는 점입니다. 또한 컬렉션처럼 중복 제거나 정렬 유지 같은 기능을 자동으로 지원해주지 않습니다. 그 대신 연속적인 메모리 배치 덕분에 접근 속도는 빠르지만, 유연성이 떨어집니다. 

 

배열과 컬렉션의 차이점은 무엇입니까?

배열은 선언 순간 길이가 고정되고 단일 타입만 연속 메모리에 담기 때문에 인덱스 접근이 매우 빠르고 오버헤드도 거의 없습니다. 다만 길이를 변경하기 어렵고, 중복 제거나 정렬 유지 같은 기능을 제공하지 않습니다. 반면 컬렉션은 내장 메서드로 길이를 자유롭게 조정할 수 있고, 제네릭으로 타입 안전성을 확보하며 중복 금지·정렬 유지 같은 구조적 제약도 자동으로 보장합니다. 대신 해시 버킷 등 추가 메타데이터를 사용하므로 메모리와 CPU 비용이 배열보다 크다는 단점이 있습니다. 또 배열은 int[]처럼 원시 타입을 직접 담을 수 있지만, 컬렉션은 객체만 허용하므로 Integer로 박싱해야 하며, 대규모 집계 연산이 필요할 때는 이 박싱·언박싱 비용이 성능 차이가 클 수 있습니다.

 

배열과 ArrayList의 차이점은 무엇입니까?

고정 길이 및 단일 타입의 특징을 가진 배열과 달리 ArrayList는 내부적으로 배열을 숨겨두는데, add 하면 자동으로 배열의 용량을 1.5배로 늘려 공간을 확보하고 제네릭으로 타입 안전성을 얻습니다. 배열의 크기를 미리 확보하고 싶다면 ensureCapacity(n)을 통해 확보할 수 있고 trimToSize()로 남는 배열을 잘라 메모리를 회수활 수도 있습니다. 다만 ArrayList는 size, modCount 같은 메타데이터와 자동 확장 기능이 있어 배열보다 무겁고, 원시 값을 담으려면 Integer 같은 래퍼로 박싱해야하기 때문에 대량 연산에서는 추가 비용이 발생합니다.

 

배열과 벡터의 차이점은 무엇입니까?

배열은 선언 순간에 길이가 고정되고 스레드 안전성을 제공하지 않습니다. 반면 Vector 는 내부에 가변 배열을 유지하면서 모든 public 메서드에 synchronized 키워드를 적용하기 때문에 여러 스레드에서도 안전하게 동작합니다. 다만, 메서드마다 동일한 객체 락을 사용하므로 단일 스레드 환경에서는 불필요한 락 오버헤드로 인해 throughput이 떨어질 수 있습니다. 읽기 비중이 압도적으로 높은 경우에는, 쓰기 시에만 락을 획득하고 전체 배열을 복사하는 CopyOnWriteArrayList 가 락 경합을 사실상 제거하므로 훨씬 효율적입니다. 그럼에도 Vector 가 JDK에 남아 있는 이유는 직렬화 포맷 및 일부 표준 API와의 호환성을 유지하기 위해서입니다. 따라서 신규 코드에서는 레거시로 간주되는 Vector 대신, 단일 스레드 환경에서는 ArrayList, 멀티스레드 환경에서는 CopyOnWriteArrayList 나 Collections.synchronizedList 사용을 권장하고 있습니다. 

 

컬렉션 API란?

컬렉션 API는 java.util 패키지에 있는 인터페이스·구현 클래스·알고리즘·유틸리티 집합을 통틀어 가리키며, 리스트·세트·맵 같은 자료구조를 추상화해 제공합니다. 개발자는 List, Set, Map 인터페이스로 코드를 작성하고, 필요에 따라 ArrayList, HashSet, LinkedHashMap 등으로 구현체만 교체하면 되므로 결합도가 크게 낮아진다는 장점이 있습니다. 

Collections는 List, Set 같은 컬렉션에 정렬·탐색·동기화 래퍼를 적용하는 유틸리티이며, Arrays는 순수 배열을 정렬하거나 배열을 리스트로 변환하는 기능을 담당합니다. 따라 Collections.sort()는 리스트 계열에, Arrays.sort()는 배열을 대상으로 오버로딩한다는 차이점이 있습니다.

 

컬렉션 프레임워크란?

컬렉션 프레임워크란 컬렉션 API를 계층 구조로 조직한 설계 철학 자체를 가리킵니다. 루트 인터페이스(Collection, Map)에서 파생된 하위 인터페이스(List, Set, Queue)와 구현체가 계약 기반으로 결합돼, 교체 가능성과 코드 재사용성을 극대화합니다. 이를 통해 기존 코드를 유지한 채 구현체만 바꿔 사용할 수 있기 때문에 재사용성이 극대화됩니다. 예를 들어, 메서드가 List<String>을 매개변수로 받으면 호출 측에서는 ArrayList든 LinkedList든 원하는 구현체를 주입할 수 있으므로 DI(의존성 주입), 테스트 더블 교체가 수월합니다. 별도 서브프레임워크인 java.util.concurrent에는 ConcurrentHashMap, CopyOnWriteArrayList 같은 고성능 동시성 컬렉션이 마련돼 있어, 멀티스레드 환경에서도 전통 컬렉션보다 더 높은 확장성과 안전성을 제공합니다.

 

컬렉션과 컬렉션의 차이점은 무엇입니까?

Collection은 List, Set, Queue 등이 파생되는 최상위 인터페이스로 “데이터를 어떻게 담을 것인가”를 규정합니다. 반면 Collections는 정적 메서드만 모아 둔 유틸리티 클래스로, 기존 컬렉션 위에서 정렬·탐색·래퍼 적용 같은 “행위”를 제공합니다.

List<String> names = new ArrayList<>(List.of("C", "A", "B"));
Collections.sort(names);          // [A, B, C]

 

자바에서 new T[] 같은 제네릭 배열 생성을 왜 금지했을까?

제네릭이 없던 자바 1.0 시절, Object[] 대신 String[], Integer[] 등을 그대로 쓰면서도 필요하면 상위 타입으로 한 번에 넘기는 다형적 API가 필요했습니다. 이러한 해결점으로 배열 업캐스트(Sub[] → Super[])를 허용했고, 잘못된 타입이 저장되면 런타임 검사 + ArrayStoreException으로 막았습니다. 읽기 전용 사용이 압도적으로 많이 구현되기 때문에 예외 위험보다 호환성과 간결성을 더 높은 우선순위로 지정하여 채택한 것입니다. 이후 제네릭이 도입되면서 컬렉션은 무공변(타입 소거)이어야 컴파일러가 타입 안전성을 증명할 수 있었지만, 배열은 이미 공변으로 굳어 있었고 레거시 코드도 방대해 규칙을 뒤집을 수 없었습니다. 그 결과 타입 소거와 공변성이 충돌해 힙 오염 위험이 생기는 제네릭 배열 생성(new T[], new List<String>[ ])만 문법적으로 금지되었습니다. 

String[] sa = new String[5];                      // O
Object[] oa = sa;                                 // O
List<String> list = new ArrayList<>();            // O

List<String>[] bad = new ArrayList<String>[10];   // X
T[] tArr = new T[10];                             // X

 

 

고정 길이 배열 복사 성능 비교: System.arraycopy

배열은 선언 시 길이가 고정되기 때문에, 배열에 삽입 삭제하기 위해선 배열을 복사하는 과정이 필요합니다. 구현 초기라면 가변 구조를 가진 컬렉션을 사용하면 되지만, 이미 배열을 통해 구현되어 있으며, 배열 복사가 필요하다면 for 문보다 System.arraycopy를 사용해 복사하면 성능적 이점을 가져갈 수 있습니다.

System.arraycopy는 호출되면 JVM이 내부적으로 memmove(C 표준 라이브러리의 “메모리 블록을 한 번에 복사-이동” 함수)로 바꿔 실행합니다. 덕분에 경계‧타입 검사를 단 한 번만 하고, 영역이 겹쳐도 안전하게 방향을 알아서 잡아 주기 때문에, 요소를 하나씩 돌리는 for 루프보다 대량 복사·시프트에서 훨씬 빠릅니다.

import java.util.Arrays;
import java.util.stream.IntStream;

class Main {
    public static void main(String[] args) {
        int[] src  = IntStream.range(0, 1_000_000).toArray(); // 100만 개
        int[] dst1 = new int[src.length];
        int[] dst2 = new int[src.length];

        // 1) System.arraycopy 복사
        long t0 = System.nanoTime();
        System.arraycopy(src, 0, dst1, 0, src.length);
        long t1 = System.nanoTime();

        // 2) 수동 for-loop 복사
        for (int i = 0; i < src.length; i++) {
            dst2[i] = src[i];
        }
        long t2 = System.nanoTime();

        System.out.printf("arraycopy : %,d ns%n", (t1 - t0));
        System.out.printf("for loop  : %,d ns%n", (t2 - t1));
    }
}
// 결과
arraycopy : 711,932 ns
for loop  : 20,360,075 ns