자바 컴파일러의 타입 소거에 의해 발생한 오버로딩 문제를 원인이 무엇인지, 좋은 해결 방법은 무엇인지 함께 고민해봅시다.
단순한 meta data를 내보내는 DTO객체를 만들고 이를 생성자를 통해 생성을 시도했습니다. 만들어진 DTO 객체는 아래와 같습니다.
@Getter
@AllArgsConstructor
@NoArgsConstructor
public class MetaDataResponse {
private String category;
private List<MetaDTO> metaDataList;
public MetaDataResponse (String category, List<MetaData> entityList) {
//...
}
}
그런데 아래와 같은 에러가 표시되는 것을 확인했습니다. 분명, 제가 아는 한 자바의 문법을 크게 벗어나지 않았고 오류가 발생할 것이라고는 예상을 하지 못했습니다.
error message : 'MetaDataResponse(String, List)' clashes with 'MetaDataResponse(String, List)'; both methods have same erasure
발생한 에러는 아래의 이미지와 같이 인텔리제이에서 표시하고 있었습니다. 문제는 저건 warning 아닌 치명적인 error라는 것입니다.
문법 상으로는 아무 문제가 없어 보이는 DTO는 왜 error를 뱉고 있는 것일까요? 이제 오버로딩 same erasure 문제를 알아봅시다.
일단 오버로딩이 문제라고 했으니 문제를 일으킨 메소드를 살펴봅시다.
public MetaDataResponse (String category, List<MetaData> entityList) {
//...
}
단순한 생성자입니다. 다만, 인자가 없는 기본 생성자가 아닌 인자를 받아 값을 설정하는 생성자입니다. 오버로딩이 문제라면 위와 같은 구조를 가진 생성자가 있어야 합니다.
위에서 살펴본 MetaDataResponse
의 생성자 생성은 rombok을 통해 사용하고 있습니다. 그렇다면 사용한 @AllArgsConstructor
와 @NoArgsConstructor
rombok을 제거하고 다시 DTO 객체를 살펴 봅시다.
@Getter
public class MetaDataResponse {
private String category;
private List<MetaDTO> metaDataList;
//NoArgsConstructor로 만든 기본 생성자
public MetaDataResponse () {
//...
}
//AllArgsConstructor 로 만든 생성자
public MetaDataResponse (String category, List<MetaDTO> metaDataList) {
//...
}
// 문제를 일으킨 생성자
public MetaDataResponse (String category, List<MetaData> entityList) {
//...
}
}
사용한 rombok의 생성자를 코드로 풀어내면 위와 같은 형태를 만들어 낼 수 있습니다. 이제 에러 메세지가 말하고 있는 부분이 눈에 보이기 시작합니다. 바로 @AllArgsConstructor
와 문제를 일으킨 생성자의 구조입니다.
//AllArgsConstructor 로 만든 생성자
public MetaDataResponse (String category, List<MetaDTO> metaDataList) {
//...
}
// 문제를 일으킨 생성자
public MetaDataResponse (String category, List<MetaData> entityList) {
//...
}
그리고 다시 에러 메세지를 살펴 봅시다.
error message : 'MetaDataResponse(String, List)' clashes with 'MetaDataResponse(String, List)'; both methods have same erasure
MetaDataResponse(String, List<MetaData>)
생성자와 MetaDataResponse(String, List<MetaDTO>)
생성자가 같은 erasure를 가진다고 하는 에러로 볼 수 있습니다.
그렇다면 이 erasure는 무엇을 의미하는 것일까요?
자바의 제너릭은 컴파일과 런타임 시 다른 코드로 읽히도록 JVM이 관리를 합니다.
컴파일을 할 때 제너릭은 타입의 제약 조건을 사용하도록 정의하고, 런타임에 들어가면 소거를 하게 됩니다. 즉, 컴파일을 하는 시점에서 제너릭에 명시한 Object의 타입이 유효하지만, 런타임 시에는 제너릭에 명시한 Object의 타입이 소거되어 없어지는 것을 말합니다.
type erasure, 타입 소거는 아래와 같은 절차를 통해 진행합니다.
이제 이러한 type erasure가 어떻게 변환되는지 따라가 봅시다.
public class Card<T> {
T pattern;
T getPattern() {
return this.pattern;
}
void setCardKind(T updatePattern) {
this.pattern = updatePattern;
}
}
위와 같은 card라는 제너릭 클래스를 자바 컴파일러가 제너릭 타입을 삭제합니다. 그리고 제너릭 타입을 삭제하면서 삭제한 타입이 바운디드 타입이라면 해당 타입으로 변환하고 그렇지 않다면 Object 타입으로 변환합니다.
public class Card {
Object pattern;
Object getPattern() {
return this.pattern;
}
void setCardKind(T updatePattern) {
this.pattern = updatePattern;
}
}
만약, Card 클래스가 String 제너릭 타입이였다면 아래와 같이 변환됩니다.
public class Card {
private String pattern;
String getPattern() {
return this.pattern;
}
void setCardKind(T updatePattern) {
this.pattern = updatePattern;
}
}
자바 컴파일러가 제너릭을 모두 소거하면서 상속과 같이 타입에 종속성이 발생하는 경우 타입의 안정성이 떨어지게 됩니다. 이를 위해 타입의 다형성을 보장하기 위해 브릿지 메소드를 사용합니다.
예시를 함께 봅시다.
public class Card<T> {
T pattern;
T getPattern() {
return this.pattern;
}
void setPattern(T updatePattern) {
this.pattern = updatePattern;
}
}
public class TrumpCard extends Card<String> {
TrumpCard(String pattern) {
super(pattern);
}
@Override
void setPattern(String updatePattern) {
super.setPattern(updatePattern);
}
}
Card을 상속 받은 TrumpCard라는 클래스가 있습니다. 제너릭이 제거되면서 발생하는 문제는 setPattern 함수에서 발생합니다.
public class Card<T> {
//...
void setPattern(Object updatePattern) {
this.pattern = updatePattern;
}
}
public class TrumpCard extends Card {
//...
@Override
void setPattern(String updatePattern) {
super.setPattern(updatePattern);
}
}
Card의 setPattern은 Object 타입을 받도록 되어 있지만 TrumpCard의 setPattern은 String 타입을 받도록 되어 있습니다. 즉, 일치하지 않는 타입을 받기 때문에 setPattern 함수는 오버라이딩을 하지 못하게 되면서 다형성이 보장되지 않는 문제가 발생합니다.
이로 인해 무너진 다형성을 컴파일러가 오버라이딩을 보장하는 브릿지 메소드를 자식 클래스(TrumpCard)에 선언하게 됩니다.
public class Card<T> {
//...
void setPattern(Object updatePattern) {
this.pattern = updatePattern;
}
}
public class TrumpCard extends Card {
//...
//브릿지 메소드
@Override
void setPattern(Object updatePattern) {
setPattern((String) updatePattern);
}
//기존에 TrumpCard에 선언한 setPattern 함수
void setPattern(String updatePattern) {
super.setPattern(updatePattern);
}
}
자바의 컴파일러는 Object 타입을 받는 setPattern 함수를 선언하고 이를 Override하도록 합니다. 이렇게 되면 Card의 setPattern 함수를 문제 없이 Override 해서 사용할 수 있고, 이렇게 만들어진 브릿지 함수는 기존에 만들어진 String을 파라미터로 받는 setPattern 함수를 다시 호출해 의도한 대로 사용할 수 있도록 도와줍니다.
그렇다면 왜 제너릭 타입에 대한 소거를 자바 컴파일러는 진행하는 것일까요?
그 이유는 이전 자바 버전과의 호환성입니다. 자바의 제너릭 타입은 자바 5버전부터 추가 되었습니다. 이로 인해 이전 버전의 자바와의 호환성을 고려해 제너릭 타입을 소거하도록 자바 컴파일러를 사용하게 됩니다.
(다만, 한 가지 드는 의문점은 상당한 버전의 변화가 있었음에도 여전히 제너릭 타입을 소거하는 이유가 궁금하긴 합니다. 현재 노후되긴 했지만 여전히 자바 8, 11, 17 버전이 강세인 환경에서 이제 5 미만의 버전에 대한 지원이 필요한가에 대한 의문이 드는데 이에 대해 아시는 분들은 답글 남겨주시면 감사하겠습니다!)
그렇다면 문제가 되는 코드를 다시 살펴봅시다. 이번엔 자바 컴파일러가 제너릭을 소거한 코드로 살펴봅시다.
@Getter
public class MetaDataResponse {
private String category;
private List metaDataList;
//NoArgsConstructor로 만든 기본 생성자
public MetaDataResponse () {
//...
}
//AllArgsConstructor 로 만든 생성자
public MetaDataResponse (String category, List metaDataList) {
//...
}
// 문제를 일으킨 생성자
public MetaDataResponse (String category, List entityList) {
//...
}
}
List와 같은 객체를 실체화 불가 타입이라고 부르는데 이는 컴파일 시에 제너릭에 해당하는 부분에 대한 정보를 소거하는 타입들을 의미합니다. List라는 Object 타입은 명시되지만 List의 제너릭 타입은 소거가 됨을 의미합니다.
위의 코드를 보게 되면 결국 MetaDataResponse(String, List<MetaData>)
생성자와 MetaDataResponse(String, List<MetaDTO>)
생성자는 완전 동일한 함수로 인식이 되는 것입니다.
이제 이 생성자를 유의미하게 분리해서 사용해봅시다.
위의 코드가 가지는 문제는 2가지입니다.
우리는 생성자를 누구도 접근할 수 없게 만듦과 동시에 타입 소거에 따른 생성자 오버라이딩 문제를 해결해야 합니다.
여기에 가장 좋은 방법은 바로 팩토리 패턴입니다.
우리는 팩토리 패턴을 사용해 생성자에 대한 접근을 차단하고, 함수명을 정의해 사용처 혹은 의도를 명시해 사용할 것입니다.
@Getter
public class MetaDataResponse {
private String category;
private List metaDataList;
public static MetaDataResponse createBySubFront(String category, List<MetaDTO> metaDataList) {
//... 객체 생성 코드
}
public static MetaDataResponse createByFeignBackend(String category, List<MetaData> entityList) {
//... 객체 생성 코드
}
}
SubFront에 응답할 객체를 만들 때는 createBySubFront
팩토리 함수를, FeignBackend에 응답할 객체를 만들 때에는 createByFeignBackend
함수를 사용해 객체를 생성하도록 처리합니다.
이렇게 팩토리 패턴을 사용한다면 함수를 호출해야 하는 의도를 명확하게 표현할 수 있을 뿐만 아니라 우리가 겪었던 타입 소거에 따른 생성자 오버로딩 문제를 해결할 수 있습니다.
대부분 타입 소거에 따른 오버 로딩 문제를 함수의 이름을 다르게 써라라고 명시하지만, 발생 원인이 무엇인지 확인하고, 생성자가 문제라면 더 좋은 객체 생성 방법은 없는지 고민해보는 것이 중요하다고 볼 수 있겠습니다.
- https://jyami.tistory.com/99
- https://inpa.tistory.com/entry/JAVA-%E2%98%95-%EC%A0%9C%EB%84%A4%EB%A6%AD-%ED%83%80%EC%9E%85-%EC%86%8C%EA%B1%B0-%EC%BB%B4%ED%8C%8C%EC%9D%BC-%EA%B3%BC%EC%A0%95-%EC%95%8C%EC%95%84%EB%B3%B4%EA%B8%B0
- https://wisdom-and-record.tistory.com/134
- https://dev.gmarket.com/28
- https://docs.oracle.com/javase/tutorial/java/generics/erasure.html