33. 타입 안전 이종 컨테이너를 고려하라

1. 타입 안전 이종 컨테이너 패턴 소개:

1. 컨테이너가 무엇일까?

컨테이너(Container)는 데이터를 저장하고 관리하는 구조이다.

저장하는 데이터의 개수에 따라 두 가지로 구분된다.

  • 여러 개의 원소를 저장하는 컨테이너 → Set, Map, List

  • 단일 원소를 저장하는 컨테이너 → ThreadLocal, AtomicReference

컨테이너는 데이터를 정리하고 효율적으로 접근할 수 있도록 해주는 역할을 한다.

2. 이 패턴이 없을 때 개발자들은 어떻게 기능을 사용했을까?

과거에는 제네릭이 없었기 때문에, 모든 데이터를 Object 타입으로 저장하는 방법을 사용했다.

비-제네릭 컬렉션(raw type collections)을 사용해야 했다.

예제 1: ArrayList에 여러 타입의 데이터 저장

List container = new ArrayList(); // 비-제네릭 리스트 사용 
container.add("Hello"); // String 저장 
container.add(123); // Integer 저장 

예제 2: HashMap에 여러 타입의 데이터 저장

Map map = new HashMap(); // 키와 값의 타입을 지정하지 않음 
map.put("stringKey", "Hello"); 
map.put("integerKey", 123); 

각기 다른 타입의 데이터를 저장할 때, 타입별로 별도의 컬렉션을 만들어야 했다.

List<String> stringList = new ArrayList<>();

List<Integer> integerList = new ArrayList<>();

3. 해당 기능 사용 시, 어떤 불편한 점이 있었을까?

🚨 타입 안전성이 부족했다.

컴파일 타임에 타입 검사가 불가능했다.

Object 타입으로 저장되었기 때문에, 잘못된 타입이 들어가도 컴파일러가 감지하지 못했다.

데이터를 꺼낼 때 강제 형변환(Casting)이 필요했다.

String str = (String) container.get(0); // 강제 형변환 필요 
Integer num = (Integer) container.get(1); 

문제: 어떤 데이터가 들어있는지 보장되지 않으므로, 형변환이 안전하지 않다.

잘못된 형변환을 하면 ClassCastException 발생 가능성이 있었다.

String str = (String) container.get(1); // Integer를 String으로 변환하려다 오류 발생! 
// Exception in thread "main" java.lang.ClassCastException: java.lang.Integer cannot be cast to java.lang.String 

코드 가독성과 유지보수성이 떨어졌다.

컬렉션이 많아지면, 각 컬렉션이 어떤 타입을 저장하는지 관리하기 어려웠다.

데이터 저장/조회 시 형변환 코드가 반복적으로 필요했다.

🚀 기존 방식 (비-제네릭 컬렉션) - 타입 안정성 없음

import java.util.*; 

public class RawTypeCollection {     
  public static void main(String[] args) {         
    Map map = new HashMap(); // (1) 타입을 지정하지 않음 (비-제네릭 컬렉션)
    map.put("name", "Alice"); // (2) String 저장         
    map.put("age", 25); // (3) Integer 저장          
    String name = (String) map.get("name"); // (4) 형변환 필요 (문제없음)         
    Integer age = (Integer) map.get("age"); // (5) 형변환 필요 (문제없음)         
    String invalid = (String) map.get("age"); // (6) 잘못된 형변환 (런타임 오류 발생)          
    System.out.println(name);         
    System.out.println(age);     
  } 
} 

🚨 실행 결과 (잘못된 형변환 시 발생할 오류)

Exception in thread "main" java.lang.ClassCastException: java.lang.Integer cannot be cast to java.lang.String 

4. 불편한 점을 어떻게 개선해서 이 패턴이 만들어진 걸까?

🚀 비-제네릭 컬렉션의 문제를 해결하기 위해 "키를 매개변수화하는 방식"을 도입했다.

📌 개선 방법: 컨테이너가 아니라 "키"를 제네릭으로 매개변수화

컨테이너 자체를 타입 지정하는 대신, 저장할 때 "키"에 타입을 포함시키는 방식.

각 타입의 Class 객체를 "키"로 사용하여, 키마다 특정 타입만 저장되도록 강제했다.

예제 코드:

Class<String> key = String.class;  // key의 타입은 Class<String> 
Class<Integer> key2 = Integer.class;  // key2의 타입은 Class<Integer> 

String.class의 타입은 Class,

Integer.class의 타입은 Class.

이 점을 활용하여 "타입을 보장하는 컨테이너"를 만들 수 있다.

5. 타입 안전 이종 컨테이너 패턴이란?

🚀 개념 정리

컨테이너 자체가 아닌 "키"를 제네릭으로 매개변수화하는 방식.

하나의 컨테이너에서 여러 타입의 데이터를 안전하게 저장하고 꺼낼 수 있다.

컴파일러가 키와 값의 타입을 체크할 수 있으므로, ClassCastException 같은 문제를 방지할 수 있다.


2. 타입 안전 이종 컨테이너 패턴의 간단한 예시 - Favorites 클래스:

1. Favorites 클래스 소개

Favorites 클래스는 타입별로 즐겨 찾는 인스턴스를 저장하고 검색할 수 있는 간단한 예시입니다

코드 예시 1 (Favorites 클래스 - API (인터페이스 정의))

public class Favorites {      
  public <T> void putFavorite(Class<T> type, T instance);     
  public <T> T getFavorite(Class<T> type); 
  } 

public class Favorites

  • Favorites 클래스 선언.

  • 이 클래스는 제네릭 타입을 안전하게 저장하고 조회하는 컨테이너 역할을 한다.

public void putFavorite(Class type, T instance);

  • 특정 타입의 데이터를 저장하는 메서드 선언.

  • Class type: 저장할 데이터의 타입 (ex. String.class, Integer.class)

  • T instance: 저장할 데이터 (ex. "Java", 123)

  • 매개변수화된 키(Class)를 이용하여 타입 정보를 유지할 수 있도록 함.

public T getFavorite(Class type);

  • 특정 타입의 데이터를 가져오는 메서드 선언.

  • Class type: 조회할 데이터의 타입 (ex. String.class)

  • 반환 타입이 T → 타입 안전하게 값을 반환함.

  • 저장 시 사용한 Class 키를 기반으로 값을 가져올 수 있음.

코드 예시 2 (Favorites 클래스 -클라이언트 (사용 코드))

public static void main(String[] args) {          
  Favorites f = new Favorites();                  // (1)
  f.putFavorite(String.class, "Java");            // (2)
  f.putFavorite(Integer.class, 0xcafebabe);       // (3)
  f.putFavorite(Class.class, Favorites.class);    // (4)
  String favoriteString = f.getFavorite(String.class);    // (5)
  int favoriteInteger = f.getFavorite(Integer.class);     // (6)
  Class favoriteClass = f.getFavorite(Class.class);       // (7)
  System.out.printf("%s %x %s%n", favoriteString, favoriteInteger, favoriteClass.getName());    // (8)
}

코드 (1)

  • Favorites 클래스의 인스턴스 f를 생성

  • f를 통해 여러 타입의 데이터를 저장하고 검색할 예정.

코드(2)

  • 키로 String.class를 사용하여 "Java"라는 문자열 저장

  • 즉, "Java" 값이 String.class 키에 연결되어 저장됨.

코드(3)

  • 키로 Integer.class를 사용하여 0xcafebabe(16진수) 저장

  • 0xcafebabe는 10진수로 변환하면 3405691582

  • 즉, Integer.class 키에 3405691582 값이 저장됨.

코드(4)

  • 키로 Class.class를 사용하여 Favorites.class 저장

  • 즉, Class.class 키에 Favorites.class 값이 저장됨.

  • 이 값은 Class 타입이다.

코드(5)

  • String.class 키로 저장된 데이터를 가져옴.

  • 반환 타입이 String이므로, 타입 캐스팅 없이 바로 String을 받을 수 있음.

코드(6)

  • Integer.class 키로 저장된 데이터를 가져옴.

  • 반환 타입이 int이므로, 타입 캐스팅 없이 바로 정수값을 받을 수 있음.

코드(7)

  • Class.class 키로 저장된 데이터를 가져옴.

  • favoriteClass의 타입이 Class이므로, getName()을 호출하면 "Favorites"가 출력됨.

코드(8)

  • %s → String 값 ("Java") 출력

  • %x → 16진수로 변환 (0xcafebabe) 출력

  • %s → 클래스 이름 ("Favorites") 출력

코드 예시 3 (Favorites 클래스 - 구현)

public class Favorites { 
  private Map<Class<?>, Object> favorites = new HashMap<>();    // (1)
  public <T> void putFavorite(Class<T> type, T instance) {      // (2)
    favorites.put(Objects.requireNonNull(type), instance);      // (3)
  }
  public <T> T getFavorite(Class<T> type) {                     // (4)
    return type.cast(favorites.get(type));                      // (5)
  } 
}

코드(1)

  • favorites라는 맵(Map) 필드 선언

  • Class → 키로 Class 객체 사용 (어떤 클래스 타입도 저장 가능하도록 사용)

  • Object → 값은 어떤 타입이든 저장 가능 (모든 객체가 Object를 상속받기 때문)

코드(2)

  • putFavorite 메서드: 특정 타입의 데이터를 저장하는 메서드

  • Objects.requireNonNull(type)

  • type이 null이면 예외 발생 (안전성 보장)

  • favorites.put(type, instance);

  • 키(type)에 대한 값(instance)을 Map에 저장

코드(3)

  • getFavorite 메서드: 특정 타입의 데이터를 검색하는 메서드

  • favorites.get(type)

  • type 키에 해당하는 데이터를 가져옴

  • type.cast(...)

  • 올바른 타입으로 변환 (Class의 cast 메서드 활용)

  • 컴파일러가 타입을 보장하므로, 형변환 없이 안전하게 값 반환

📌 코드 전체 흐름 정리

Favorites 클래스는 제네릭 기반의 타입 안전 이종 컨테이너

데이터를 저장할 때 타입 정보를 Class 키로 사용

데이터를 검색할 때 키로 타입을 전달하여 올바른 타입으로 반환

Class를 활용하여 런타임에도 타입 정보를 유지할 수 있음

컴파일러가 타입을 보장하므로 ClassCastException이 발생하지 않음


타입 토큰(Type Token)이란?

1️⃣ 타입 정보가 사라지는 문제 (Type Erasure)

📌 제네릭의 한계: 컴파일 이후에 타입 정보가 사라짐

자바의 제네릭은 타입 소거(Type Erasure) 과정을 거친다.

즉, 컴파일러가 제네릭 타입을 확인한 후, 런타임에서는 실제 타입 정보를 유지하지 않음.

🚀 예제 1: 타입 소거 확인

import java.util.ArrayList;
import java.util.List;

public class TypeErasureExample {
    public static void main(String[] args) {
        List<String> stringList = new ArrayList<>();
        List<Integer> intList = new ArrayList<>();

        System.out.println(stringList.getClass() == intList.getClass()); // true 출력
    }
}

✅ List과 List는 다른 타입처럼 보이지만,

✅ 런타임에서는 동일한 ArrayList로 취급됨.

📌 왜냐하면 컴파일 이후에는 제네릭 타입 정보가 지워지기 때문!

  • List → List

  • List → List

🚨 즉, 런타임에서는 T의 타입을 알 수 없음.

2️⃣ 타입 토큰(Type Token) - 이 문제를 해결하는 방법

🚀 Class를 이용해서 런타임에 타입 정보를 유지하는 방법

타입 정보가 사라지는 문제(Type Erasure)를 해결하려면, 런타임에 타입을 유지해야 함.

이를 위해 Class를 활용하면 제네릭 타입 정보를 런타임에도 유지할 수 있다.

📌 예제 2: 타입 토큰 활용

public class TypeTokenExample {
    public static <T> void printType(Class<T> type) {
        System.out.println("전달된 타입: " + type.getName());
    }

    public static void main(String[] args) {
        printType(String.class);  // 출력: 전달된 타입: java.lang.String
        printType(Integer.class); // 출력: 전달된 타입: java.lang.Integer
    }
}

✅ Class를 사용하면, 런타임에서도 타입 정보를 유지할 수 있다!

✅ 즉, 컴파일 후에도 T가 String인지 Integer인지 알 수 있다.

3️⃣ 결론

✅ 컴파일 후 제네릭 타입 정보가 사라지는 문제(Type Erasure)가 있음.

✅ 이 문제를 해결하려면 Class를 활용해야 함.

✅ Class를 이용해 런타임에도 타입 정보를 유지할 수 있음.

✅ 이 방식이 Favorites 클래스처럼 "타입 안전 이종 컨테이너"를 만들 때 필수적으로 사용됨.

🚀 ➡ "타입 토큰(Type Token)은 런타임에 제네릭 타입 정보를 유지하기 위한 Class 객체이다!" 🚀


발표자료 https://byumm315.atlassian.net/wiki/external/ZmJmYTI5N2JiN2U5NDViM2E5ODk4NDg1NTFiNDk5YTg

Last updated