본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
14장 : 선형 자료구조 구현

제네릭 목록과 타입 안전성

Object 기반 목록의 늦은 ClassCastException을 재현하고 제네릭 MyArrayList의 타입 검사를 컴파일 시점으로 당깁니다.

Object 목록은 숫자, 문자열, 게시글을 모두 담을 수 있지만 그 자유는 꺼내는 쪽의 캐스팅 책임으로 돌아옵니다.

제네릭 규칙은 잘못된 값이 저장되는 최초 위치를 컴파일러가 가리키게 합니다.

배열 생성 제약 때문에 내부는 Object[]를 쓰더라도 공개 메서드는 E만 주고받도록 구분을 좁힙니다.


Object 목록의 오류가 get까지 늦어지는 이유

입력 단계는 모두 성공하지만 합계를 계산하는 두 번째 캐스팅에서 실행이 멈춥니다.

이 관찰은 오류 위치와 원인 위치가 멀어지는 Object 컨테이너의 약점을 보여 줍니다.

lab/ObjectListCastFailure.java
import java.util.ArrayList;

public final class ObjectListCastFailure {
    public static void main(String[] args) {
        ArrayList<Object> values = new ArrayList<>();
        values.add(40);
        values.add("fifty");
        int total = (Integer) values.get(0) + (Integer) values.get(1);
        System.out.println(total);
    }
}
관찰 결과
java.lang.ClassCastException

목록이 보관할 원소 타입을 규칙으로 표현하지 않았습니다.

호출자가 제각각 캐스팅하면 동일한 저장소를 서로 다른 타입으로 해석할 수 있습니다.

타입 매개변수 E를 add, get, set의 서명 전체에 연결해야 합니다.


타입 매개변수 E에 따른 쓰기와 읽기 연결

  • MyArrayList<E>의 E는 한 인스턴스가 받아들일 원소 타입을 선언하며 메서드마다 바뀌지 않습니다.
  • 제네릭 배열 생성이 제한되므로 Object[]를 사용하되 캐스팅은 get 내부 한 지점에 격리합니다.
  • add(E)는 잘못된 타입을 호출 시점의 컴파일 오류로 바꾸어 원인과 진단 위치를 붙입니다.
  • List<Number>List<Integer>는 대입 관계가 아니므로 쓰기 안전성을 임의로 가정하지 않습니다.
  • 조회와 set은 0 이상 size 미만을 요구하고 add(index)는 size 위치를 추가로 허용합니다.
  • 원시 타입은 이전 코드 접점에서만 받아 즉시 매개변수화 타입으로 검증해 넘깁니다.

비검사 캐스팅을 get 내부에 격리한 구현

GenericArrayListBoundary는 외부에 Object를 한 번도 노출하지 않습니다.

비검사 경고는 실제 저장을 add(E)로 제한한 get 내부에서만 억제합니다.

set은 이전 값을 E로 돌려주므로 교체 결과도 타입 안전합니다.

src/GenericArrayListBoundary.java
import java.util.Arrays;

public final class GenericArrayListBoundary {
    public static void main(String[] args) {
        MyArrayList<Integer> viewCount = new MyArrayList<>();
        viewCount.add(40);
        viewCount.add(55);
        viewCount.set(1, 60);
        System.out.println("total=" + (viewCount.get(0) + viewCount.get(1)));
        System.out.println(viewCount);
    }

    private static final class MyArrayList<E> {
        private Object[] values = new Object[2];
        private int size;

        void add(E value) {
            if (size == values.length) values = Arrays.copyOf(values, values.length * 2);
            values[size++] = value;
        }

        @SuppressWarnings("unchecked")
        E get(int index) {
            check(index);
            return (E) values[index];
        }

        E set(int index, E value) {
            check(index);
            E old = get(index);
            values[index] = value;
            return old;
        }

        private void check(int index) {
            if (index < 0 || index >= size)
                throw new IndexOutOfBoundsException("index=" + index + ", size=" + size);
        }

        public String toString() {
            return Arrays.toString(Arrays.copyOf(values, size));
        }
    }
}

제네릭의 오류 조기 발견

MyArrayList<Integer>add는 Integer만 받습니다.

문자열을 전달하면 저장 코드가 실행되기 전에 javac가 호출 위치를 거부합니다.

Object 목록처럼 나중의 get에서 실패하지 않으므로 잘못된 입력과 진단 사이의 거리가 짧아집니다.

E는 목록 인스턴스마다 한 번 정해지는 타입 매개변수입니다.

메서드의 반환과 인수에 같은 E를 사용해야 “넣은 종류와 꺼내는 종류가 같다”는 관계가 보존됩니다.

get만 제네릭이고 add가 Object라면 쓰기 구분이 다시 열립니다.


내부 Object 배열의 제한된 역할

Java에서는 런타임에 E 정보가 소거되므로 new E[10]을 만들 수 없습니다.

직접 구현은 Object[]를 확보하고 get에서 E로 캐스팅합니다.

이 캐스팅이 안전한 근거는 모든 쓰기가 add(E)와 set(E)를 통과한다는 불변식입니다.

@SuppressWarnings("unchecked")를 클래스 전체에 붙이면 다른 위험한 캐스팅도 가려집니다.

실제 캐스팅 한 줄을 가진 메서드에만 붙이고, 주석이나 구조로 안전 근거를 남깁니다.

경고 제거는 문제 은폐가 아니라 검증 범위의 명시여야 합니다.


불공변성과 원시 타입의 경계

Integer가 Number의 하위 타입이어도 MyArrayList<Integer>MyArrayList<Number>의 하위 타입이 아닙니다.

만약 대입을 허용하면 Number 목록으로 본 코드가 Double을 넣어 Integer 목록 규칙을 깨뜨릴 수 있습니다.

다양한 숫자를 읽기만 하는 API는 이후 와일드카드로 표현합니다.

타입 인자를 생략한 원시 타입은 add 검사를 약화시킵니다.

이전 라이브러리와 연결하는 좁은 어댑터 외에는 사용하지 않고, 발견한 컴파일 경고를 빌드 로그의 정상 상태로 받아들이지 않습니다.


Entry 타입만 받는 저장 지점

누적 CLI의 Entry 레코드가 저장 타입을 고정합니다.

문자열과 조회수를 따로 배열에 두지 않으므로 인덱스 불일치도 막습니다.

이후 저장 구조를 바꾸더라도 add의 인수는 완성된 Entry라는 방향을 유지합니다.

app/PostRepositoryCliCH143.java
public final class PostRepositoryCliCH143 {
    public static void main(String[] args) {
        PostRepository repository = new PostRepository();
        repository.add("generic-entry", 42);
        repository.add("checked-cast", 53);
        repository.print();
        System.out.println("total=" + repository.totalViewCount());
    }

    private static final class PostRepository {
        private Entry[] entries = new Entry[2];
        private int size;

        void add(String title, int viewCount) {
            if (title == null || title.isBlank()) throw new IllegalArgumentException("title");
            if (viewCount <= 0) throw new IllegalArgumentException("viewCount");
            ensureCapacity(size + 1);
            entries[size++] = new Entry(title, viewCount);
        }

        private void ensureCapacity(int required) {
            if (required <= entries.length) return;
            Entry[] grown = new Entry[Math.max(required, entries.length * 2)];
            System.arraycopy(entries, 0, grown, 0, size);
            entries = grown;
        }

        int totalViewCount() {
            int total = 0;
            for (int i = 0; i < size; i++) {
                total += entries[i].viewCount();
            }
            return total;
        }

        void print() {
            for (int i = 0; i < size; i++) {
                Entry entry = entries[i];
                System.out.println(i + ":" + entry.title() + "=" + entry.viewCount());
            }
        }
    }

    private record Entry(String title, int viewCount) {}
}

CLI의 Entry 레코드는 제목과 조회수가 따로 움직이지 않게 합니다.

배열의 한 슬롯에는 완성된 Entry만 들어가며 get 결과도 Entry이므로 다운캐스팅이 없습니다.

두 입력이 정확한 타입으로 보존되고 totalViewCount에서 정수 조회수가 바로 사용되는지 확인합니다.


Object·제네릭·공통 상위 타입의 선택

질문관찰할 값선택 또는 조치
여러 타입을 섞어야 하는가공통 상위 규칙명시적 합 타입 설계
다운캐스팅이 보이는가캐스팅 위치 수제네릭 경계 도입
배열을 내부에 쓰는가비검사 경고한 메서드로 격리
null을 허용하는가null 의미정책을 문서화

Object를 쓰면 확장성이 생기는 것이 아니라 타입 정보가 사라질 수 있습니다.

서로 다른 항목을 담아야 한다면 공통 인터페이스나 sealed 계층으로 가능한 종류를 표현하고, 단순 편의를 위한 원시 타입은 선택하지 않습니다.


연습 문제

Integer만 받는 두 칸 저장소를 만들고 30과 70을 넣은 뒤 큰 값을 반환하세요.

Object 반환이나 호출자 캐스팅을 쓰지 마세요.

정답과 해설

저장소의 타입 인자와 get 반환 타입을 연결하면 비교 위치에서 Integer를 바로 받을 수 있습니다.

배열 캐스팅은 저장소 내부 한 곳에만 존재합니다.

exercise/TypedMaximumStoreSolution.java
public final class TypedMaximumStoreSolution {
    public static void main(String[] args) {
        Store<Integer> store = new Store<>();
        store.add(30);
        store.add(70);
        int maximum = Math.max(store.get(0), store.get(1));
        System.out.println("maximum=" + maximum);
    }

    private static final class Store<E> {
        private final Object[] values = new Object[2];
        private int size;

        void add(E value) {
            values[size++] = value;
        }

        @SuppressWarnings("unchecked")
        E get(int index) {
            if (index < 0 || index >= size) throw new IndexOutOfBoundsException(index);
            return (E) values[index];
        }
    }
}

호출부에는 캐스팅이 없습니다.

Store의 쓰기 메서드가 E만 허용하기 때문에 get 내부의 제한된 캐스팅을 신뢰할 수 있습니다.


타입 안전 목록 판단

공개 API에서 Object와 비검사 캐스팅이 보이지 않아야 합니다.

경고 억제 범위는 실제 변환 메서드 하나로 제한합니다.

원시 타입으로 잘못된 값을 넣는 경로가 없다면 런타임 ClassCastException을 컴파일 오류로 이동시킨 것입니다.