Java 제네릭에 대하여 (+ TypeReference)

잼구·2023년 10월 5일
7
post-thumbnail

해당 글 내용으로 발표한 발표 영상이 업로드 되었습니다 ㅎㅎ

제네릭 넌 뭐니?

개발을 하다보면 제네릭을 참 많이 만납니다.
특히! 프레임워크에서 제공하는 함수, class 들을 보면 제네릭을 손 쉽게 볼 수 있습니다. (Data JPA 쓰는 분들이라면 숨 쉬듯이 보시겠죠?)
그때부터 제네릭은 제 마음에 살포시 들어와 뭔가 굉장히 간지나고 유용한 것으로 자리 잡았습니다.
왜냐면 제눈에 Java 같이 암걸리는 강타입 언어에서 제네릭은 엄청나게 유연하고 확장성 쩌는 코드를 짜게 해주는 것 처럼 느껴졌거든요...

제네릭? 뭐든 들어갈 수 있네? -> 그럼 돌려쓰는 코드에서 바뀌어야 되는 부분만 제네릭으로 박고 재사용 하면 될듯 엌ㅋㅋㅋㅋ 나 천재?ㅋㅋ 저는 제네릭을 대충 이렇게 알고 있었던 거죠.
제네릭에 대한 공부 따위는 한적이 없습니다... 아 돌아가면 된거라고!

평소 쓰던 방식

import { Model } from 'mongoose';

export abstract class CommonRepository<
  Entity,
  CreateCommonDto,
  UpdateCommonDto,
  CreateCommon,
> {
  private model: Model<any>;
  constructor(model: Model<any>) {
    this.model = model;
  }

  async create(createEntityDto): Promise<CreateCommon> {
    const createdCommon = new this.model(createEntityDto);
    return await createdCommon.save();
  }

  async findAll(): Promise<CreateCommon[]> {
    return await this.model.find().populate('author').exec();
  }
  
  ...
  
}

갑자기 분위기 TS.
해당 코드는 노드로 서버를 짤때, 몽고디비 레포를 만든 코드 입니다. 저렇게 CommonRepository 를 추상 클래스로 만들어 놓고 외부에서는 extends 받아서 제네릭에 있는 부분만 해당 entity에 맞는 방식으로 바꾸면 되는 것이죠. data Jpa 를 구리게 만들어본거라고 생각하시면 좋을 것 같아요~

이때 엄청 유용하게 잘 썼던 기억이 있어서 자프링으로 넘어 온 이후에도 이런식으로 코드를 많이 썼던 것 같습니다...

문제 상황

그러던 어느날... 코드를 작성 중에 알 수 없는 에러가 터졌습니다.

public class JsonFileRepository<T> implements FileRepository<T> {
    protected final ObjectMapper objectMapper;
    protected final File repositoryFile;

    protected static final String currentDir = System.getProperty("user.dir");
    protected final String specifiedPath;

    public JsonFileRepository(ObjectMapper objectMapper, String filePathProperty) {
        this.objectMapper = objectMapper;
        this.specifiedPath = AppProperties.getProperty("file.specifiedPath");
        this.repositoryFile = new File(currentDir + File.separator + specifiedPath + File.separator + AppProperties.getProperty(filePathProperty));
    }

    public ConcurrentHashMap<Long, T> loadFromFile() {
        if (repositoryFile.exists()) {
            try {
                if (repositoryFile.length() > 0) {
                    ConcurrentHashMap<Long, T> loadedItems = objectMapper.readValue(
                            repositoryFile, new TypeReference<>() {
                            }
                    );
                    return loadedItems;
                }
            } catch (IOException e) {
                throw new SystemErrorException();
            }
        } else {
            try {
                repositoryFile.createNewFile();
            } catch (IOException e) {
                throw new SystemErrorException();
            }
        }
        return new ConcurrentHashMap<Long, T> ();
    }
    ...
    
}

해당 class 는 json 파일을 데이터베이스 처럼 쓰기 위해 만든 파일입니다. T 에 내가 원하는 Entity 를 넣고 객체를 생성하면 되는 구조 입니다. loadFromFile() 메서드는 json 파일에서 데이터를 가지고 와서 ConcurrentHashMap<id 값, Entity> 로 만들어서 반환해주는 함수 입니다.

public class PersistentBookRepository {
    private final JsonFileRepository<Book> jsonFileRepository;
    final ConcurrentHashMap<Long, Book> books = new ConcurrentHashMap<>();

    public PersistentBookRepository(JsonFileRepository<Book> jsonFileRepository) {
        this.jsonFileRepository = jsonFileRepository;
        // 안되는 부분
        books.putAll(jsonFileRepository.loadFromFile());
    }
    
    ...
    
}

이후 JsonFileRepository<Book> 으로 선언해서 만든 객체를 인젝션 받아서 생성자 부분에서 loadFromFile() 메서드를 실행 시켜 주었고, 만들어진 repo를 서비스 함수에서 쓰려고 했습니다.

하지만 돌아오는건 뜬금 없는 에러뿐...!

class java.util.LinkedHashMap cannot be cast to class com.나의 패키지.Book (java.util.LinkedHashMap is in module java.base of loader 'bootstrap'; com.나의 패키지.Book is in unnamed module of loader 'app')

ConcurrentHashMap 에서 데이터를 읽어오는 메서드에서 계속 해당 에러가 뜹니다. Book entity 로 변환하는데 실패 했다는 것! 분명 ConcurrentHashMap 에는 Book이 value 로 들어가야 하는데 LinkedHashMap 가 들어가 있습니다... (어이 없을 무)

생성자에서 loadFromFile() 할때 에러는 나지 않았습니다. 분명 가져올때 Book 으로 변환이 안되면 에러가 났을텐데 왜 이런 일이 벌어진 것일까요?

참고로 json 파일 속 데이터는 모두 정상 값이었고 ConcurrentHashMap 를 디버깅 모드에서 까봤을때 value 에 LinkedHashMap 로 들어 있는 것을 보았습니다...

문제는? Generic Type erasure

Type erasure 는? 컴파일 타임에만 타입 제약 조건을 적용하고, 런타임 시 타입 정보를 삭제하는 프로세스.

Java 에서 제네릭은 런타임에 소거 됩니다.
라고 말하면 "아니 당연하지;;; 제네릭에 들어간 타입에 맞게 바이트 코드 생성하고 사라지는거 아님?;;" 라고 알고 있는 분도 있을 겁니다!!!!(그 사람이 바로 저에요)
TS 에서는 어짜피 컴파일 타임에 모든 타입 정보가 사라지기 때문에 인지하고 있었음.
하지만 C++ 컴파일러는 컴파일 타임에 class template 를 이용해 실제 클래스(클래스 정의 코드)를 생성한다. 나는 자바에서도 제네릭이 이렇게 동작하는줄 알았다ㅠㅠ

하지만 자바에서 제네릭은 컴파일 타임에 타입 검사를 하고 런타임에 Object 로 변환 됩니다.
제네릭을 사용함으로써 런타임에 추가적인 클래스가 생성되지 않는다는 것

자바 제네릭 타입 소거 원칙

  • 바인딩되지 않은 타입 <T>, <?> 는 Object 로 변환
  • 바인딩 된 타입 <T extends Comparable> 은 Comparable 로 변환

엣지 케이스

  • 타입 안전성이 필요한 경우 타입 캐스트를 넣을 수 있다.
  • 하위 호환성을 유지하기 위해 컴파일러가 bridge method 를 생성 할 수 있다.

2번은 주로 제네릭을 사용하는 상위 클래스의 메서드를 하위 클래스에서 오버라이드할 때 발생한다. 제네릭의 타입 소거 때문!

public class Parent {
    public void method(Object obj) {
        System.out.println("Parent's method");
    }
}

public class Child extends Parent {
    public void method(String str) {
        System.out.println("Child's method");
    }
}

위 경우에서 Child 와 Parent 은 다른 파라미터를 가진 메서드를 가진다.

public class Parent<T> {
    public void method(T t) {
        System.out.println("Parent's method");
    }
}

public class Child extends Parent<String> {
    @Override
    public void method(String s) {
        System.out.println("Child's method");
    }
}

그럼 위 경우는 어떨까. 타입 소거 때문에 Parent의 method(T t) 메서드는 method(Object o)로 변환되고, Child에서 이 메서드를 String 타입 파라미터로 오버라이드했기 때문에, 원래의 오버라이드 규칙에 위반된다. 따라서 컴파일러는 이 문제를 해결하기 위해 브릿지 메서드를 추가하게 된다.

public class Child extends Parent<String> {

    // 실제로 오버라이드된 메서드
    public void method(String s) {
        System.out.println("Child: " + s);
    }

    // 컴파일러에 의해 자동으로 추가된 브릿지 메서드
    public void method(Object o) {
        method((String) o);
    }
}

위의 문제 코드를 컴파일하면 이런 코드가 될 것이다. Child 클래스는 사실상 두 개의 method 메서드를 갖게 된 것이다.

위 엣지 케이스들로 알 수 있는건 제네릭이 무조건 Object 가 된다고 알고 있기 보다는 컴파일러가 보정을 해주는 경우나 라이브러리 보정이 되는 경우도 있으니 런타임에 어떻게 돌아갈지 잘 알아보고 써야한다는 것이다.

제네릭에 대한 더 자세한 제한 사항

내 코드에서 문제 였던 점

ConcurrentHashMap<Long, T> loadedItems = objectMapper.readValue(
  repositoryFile, new TypeReference<ConcurrentHashMap<Long, T>>() {}
  );

해당 코드에서 objectMapper 는 ConcurrentHashMap<Long, T> 를 참고해 파일을 가져온다. 하지만 T 는 결국 컴파일 타임에 타입검사만 하고 Object 가 되고, objectMapper 는 Object 형태 중 내 json 파일과 적당히 맞아 보이는 LinkedHashMap 로 value 를 가져온 것이다...!

결국 내가 new JsonFileRepository<Book>(...) 로 Book 에 대한 새로운 객체를 생성하는 것 처럼 보이지만, 이건 new JsonFileRepository<>(...) 랑 똑같은 코드라고 보면 된다.new JsonFileRepository<Book>(...) 같은 코드는 컴파일 타임에도 생성 되지 않는다. 타입 체크만 할뿐! 이말은? ConcurrentHashMap<Long, Book> 같은 코드는 어느 시점이든 생기지 않는 다는 뜻!

🐥 TypeReference에 대한 짤막한 의문!
아니 제네릭이 런타임에 Object 로 대체되면 결국 T 에 구체 타입을 넣어도 런타임에 new TypeReference<ConcurrentHashMap<Long, T>>() = new TypeReference<Object>() 가 되는건 똑같잖아요;; 그럼 저 제네릭을 알아야 런타임에 read 해 올텐데 아예 저런 식으로 못 쓴다는거임?? 이라는 의문이 들 것이다.

하지만 그런 일을 방지하기 위해 우리는 TypeReference 를 쓴다. TypeReference를 사용하면 익명 자식 객체를 생성할 때, 이 익명 자식 객체는 부모 타입의 제네릭 정보를 유지하고 이를 런타임에 가지고 올 수 있다.
(TIP. 익명 객체는 클래스 상속, 인터페이스 구현 시에만 생성 가능한데 전자는 "익명 자식 객체", 후자는 "익명 구현 객체" 라고 한다)

이게 무슨 말이냐면, 일반적으로 Java에서 제네릭 정보는 컴파일 타임에만 존재하고, 런타임에는 소거되기 때문에 제네릭 타입에 대한 런타임 정보를 가져올 수 없다. 하지만 Java에서 제네릭 클래스나 인터페이스를 상속, 구현할 때의 실제 타입 정보는 서브클래스 바이트코드에 남아있다. 이것이 가능한 이유는 JVM이 자식 클래스의 메타데이터에 부모 클래스에 대한 정보를 저장하기 때문이다. 이러한 특성을 이용하여, 익명 자식 객체를 생성하면 부모 클래스에 대한 정보가 런타임(바이트 코드)에도 유지된다.

사용자가 TypeReference 의 익명 자식 객체를 생성하면, 그 익명 자식 객체는 부모 타입에 대한 정보, 즉 제네릭 타입 정보를 런타임에도 가지게되는 것!

런타임에는 TypeReference의 익명 자식 객체의 메타데이터에 제네릭 타입 정보가 있기 때문에, getClass().getGenericSuperclass() 메서드를 사용해서 TypeReference 클래스의 실제 제네릭 타입인 ConcurrentHashMap<Long, Book> 정보를 반환 받는다. 반환된 Type 객체는ParameterizedType 인터페이스의 인스턴스로 이 인터페이스는 제네릭 타입에 대한 정보를 제공하는 메서드들을 가지고 있어 getActualTypeArguments() 메서드를 사용하면 ConcurrentHashMap<Long, Book>의 실제 타입 인수들인 LongBook을 얻을 수 있다. 하지만 나는 ConcurrentHashMap<Long, T> 로 선언해 놨으니, ConcurrentHashMap<Long, Object> 로 타입이 얻어졌을꺼다.

실제 TypeReference의 코드실제 TypeReference의 코드 이다.

제네릭은 언제 써야할까?

이쯤 되면 제네릭은 참 무서운 놈이란 생각이 듭니다... 모르고 쓸때가 머리 안아프고 좋았습니다...
이런 무서운 제네릭 우리는 언제 써야할까요?

오라클 자바 닥에는 이렇게 써져 있다.

제네릭은 컴파일 타임에 더 많은 버그를 감지할 수 있도록 하여 코드에 안정성을 추가 해 줍니다.

제네릭을 사용하면 클래스, 인터페이스 및 메서드를 정의할 때 타입(클래스와 인터페이스)을 매개변수로 사용할 수 있고, 타입 매개변수는 동일한 코드를 다양한 입력값으로 재사용할 수 있게 해줍니다.

자바 닥이 말하는 제네릭의 이점.

  1. 컴파일 타임에 유형 검사가 더욱 강력해졌습니다.
    Java 컴파일러는 일반 코드에 강력한 유형 검사를 적용하고 코드가 유형 안전성을 위반하는 경우 오류를 발행합니다. 컴파일 시간 오류를 수정하는 것은 찾기 어려울 수 있는 런타임 오류를 수정하는 것보다 쉽습니다.

  2. 캐스트 제거.
    제네릭이 없는 코드에는 캐스팅이 필요합니다.
    제네릭을 사용하도록 다시 작성하면 코드에 캐스팅이 필요하지 않습니다.

  3. 프로그래머가 일반 알고리즘을 구현할 수 있도록 합니다.
    제네릭을 사용함으로써 프로그래머는 다양한 유형의 컬렉션에 대해 작동하고 사용자 정의할 수 있으며 유형이 안전하고 읽기 쉬운 제네릭 알고리즘을 구현할 수 있습니다. (제네릭이 쓰인 곳에 어느 타입이든 들어 갈 수 있다는 사실을 알 수 있음)

결국 제네릭은 Object 가 들어갈 곳에 컴파일 시점 타입 안정성을 얻기 위한 목적으로 쓰기 위해 만들어 진 것을 알 수 있습니다. 저는 "유연한 코드" 에만 집중 해 제네릭을 팩토리 같은 개념으로 이해했었지만, 이제는 본 목적에 부합 하게 "컴파일 시점 타입 검사"에 더 집중해 사용해야 겠다는 교훈을 얻었습니다.
그리고 실제 런타임에 어떻게 동작할지 한번 더 뇌내 테스트 코드를 돌리고 사용할 필요가 있다는 사실을 알았습니다...

마지막으로!

제네릭 타입 매개변수 명명 규칙입니다! 생각보다 신경 안쓰며 짜고 있는데, 이런 명명 규칙 하나부터 코드의 가독성이 올라간다고 생각하기 때문에 앞으로 고심해서 신경쓰려고 합니다! 명명 규칙까지 잘 지켜서 우리 제네릭의 가독성과 안정성을 가져가봐요~~!


ref
자바 닥
밸덩

profile
잼구입니다

1개의 댓글

comment-user-thumbnail
2023년 10월 5일

제네릭 자주 썼었는데 이런 주의점이 있었군요~ 하나 잘 알아갑니다!

답글 달기