『자바의 신 3판』 을 읽고 내용 정리 및 공부한 내용을 정리한 글입니다.
서적: 자바의 신 3판 구입처
컴파일 단계에서 잡지 못하고 실행 시에 개발자가 미처 생각하지 못한 부분에서 예외를 발생시키는 경우가 있다.
특히 자바는 여러 타입들이 존재하기 때문에, 형 변환을 하면서 많은 예외가 발생할 수 있다.
예를 들어, 아래와 같은 DTO가 있다고 할 때, Object에 String을 넣을 수도 있고 StringBuilder를 넣을 수도 있다.
이렇게 private 변수, getter, setter, Serializable 구현을 해야만 제대로 된 DTO 클래스라고 할 수 있다.
public class CastingDTO implements Serializable {
private Object object;
public void setObject(Object obj) {
object = obj;
}
public Object getObject() {
return object
}
}
이럴 때는, Object를 꺼내서 받아올 때 넣은 타입으로 받아줘야 하므로 명시적 형 변환을 시켜줘야 한다.
하지만, 만약 String을 넣고 StringBuilder 타입으로 형 변환을 시킨다면 실행 시 오류가 발생하게 된다.
public static void main(String[] args) {
CastingDTO dto = new CastingDTO();
dto.setObject(new String());
String s1 = (String) dto.getObject();
String s3 = (StringBuilder) dto.getObject(); // error
}
instanceof
예약어로 타입을 점검 후 형 변환을 시켜줄 수도 있겠지만, 이렇게 타입을 하나씩 점검해주는 것은 번거로운 일이다.
이러한 단점을 보완하기 위해 Java 5부터 새롭게 추가된 것이 제네릭이다.
일반적으로는 실행 오류를 대비하기 위해 메소드 개발과 함께 JUnit과 같은 테스트 코드를 작성한다.
물론 이렇게 테스트를 열심히 해도 개발자가 미처 생각지 못한 부분에 대해서는 테스트 케이스를 만들지 못할 수도 있다.
💡 JUnit이란
메소드나 클래스 같은 작은 단위를 쉽게 테스트할 수 있도록 도와주는 프레임워크이다.
작성한 메소드를 테스트하는 코드를 만들어야 하고, 테스트 조건에 맞지 않으면 실패로 간주하도록 되어 있다.
테스트와 관련된 참조 자료는 이 책의 마지막 장을 참고하기 바란다.
타입 형 변환에서 발생할 수 있는 문제점을 컴파일 시 점검할 수 있도록 하기 위하여 만들어졌다. (명시적으로 타입을 지정)
컴파일 단계에서 오류를 검출하여 타입 안전성을 제공한다.
중복의 제거와 타입 안전성을 동시에 추구할 수 있게 되었다.
// 제네릭을 사용하지 않은 경우
public class NonGenericUtils {
public static void reverseIntList(List<Integer> list) {
Collections.reverse(list);
}
public static void reverseStringList(List<String> list) {
Collections.reverse(list);
}
// ...
}
// 제네릭을 사용한 경우
public class GenericUtils {
public static <T> void reverseList(List<T> list) {
Collections.reverse(list);
}
}
클래스 선언문에 꺽쇠(<>)를 추가한다. 꺽쇠 안에는 가상의 타입 이름을 지정한다.
public class 클래스명<T> {}
위 예제에서는 가상의 타입 이름으로 T
를 사용했다.
꺽쇠 안에 들어가는 가상 타입 이름은 아무런 이름이나 지정해도 컴파일하는 데 전혀 상관 없다. 현재 존재하는 클래스를 사용해도 되고, 존재하지 않는 것을 사용해도 된다.
앞서 만들었던 DTO를 제네릭으로 선언하면 아래처럼 쓸 수 있다.
public class CastingDTO<T> implements Serializable {
private T object;
public void setObject(T obj) {
object = obj;
}
public T getObject() {
return object
}
}
클래스 선언문에 <T>
를 추가하고, 코드의 타입 부분이 모두 T로 바뀐 것을 알 수 있다.
책의 예제를 사용했다.
public class GenericSample {
public static void main(String args[]) {
GenericSample sample = new GenericSample();
sample.checkGenericDTO();
}
public void checkGenericDTO() {
CastingGenericDTO<String> dto1=new CastingGenericDTO<String>();
dto1.setObject(new String());
CastingGenericDTO<StringBuffer> dto2=new CastingGenericDTO<StringBuffer>();
dto2.setObject(new StringBuffer());
CastingGenericDTO<StringBuilder> dto3=new CastingGenericDTO<StringBuilder>();
dto3.setObject(new StringBuilder());
}
}
객체를 생성할 때 꺽쇠 안에 타입을 명시한다.
이렇게 작업하는 것은 귀찮아 보이지만, getObjcet() 메소드를 사용하여 각 객체를 가져올 때는 형 변환을 할 필요가 없어진다.
String temp1 = dto1.getObject();
StringBuffer temp2 = dto2.getObject();
StringBuilder temp3 = dto3.getObject();
해당 객체에 선언되어 있는 dto1 ~ 3의 제네릭 타입은 각각 String, StringBuffer, StringBuilder이다.
만약 잘못된 타입으로 치환했다면 컴파일 자체가 안된다.
따라서, “실행 시”에 다른 타입으로 잘못 형 변환하여 예외가 발생하는 일은 없다.
앞서 클래스 선언 시 <>
안에 어떤 단어가 들어가더라도 상관없다고 했다. 그렇지만, 자바에서 정의한 기본 규칙은 있다.
꼭 이 규칙을 지켜야만 컴파일이 되는 것은 아니다.
하지만, 다른 어떤 사람이 보더라도 쉽게 이해할 수 있도록 하려면 이 규칙을 따르는 것이 좋다.
아래처럼 제네릭한 클래스와, 이 클래스를 매개 변수로 받는 메소드가 있다.
public class WildcardGeneric<W> {
W wildcard;
public void setWildcard(W wildcard) {
this.wildcard=wildcard;
}
public W getWildcard() {
return wildcard;
}
}
public class WildcardSample {
public void wildcardStringMethod(WildcardGeneric<String> c) {
String value=c.getWildcard();
System.out.println(value);
}
}
이렇게 메소드의 매개 변수로 제네릭 타입을 받으면, 반드시 지정한 타입(여기서는 String)을 사용하는WildcardGeneric
객체만 받을 수 있다.
그런데, 위처럼 사용하면 다른 타입으로 선언된 WildcardGeneric
객체를 받을 수 없다.
따라서 만약 다른 타입으로 선언된 WildcardGeneric
객체를 받으려면, 다음과 같이 선언하면 된다.
public class WildcardSample {
public void wildcardMethod(WildcardGeneric<?> c) {
Object value=c.getWildcard();
if(value instanceof String) {
System.out.println(value);
}
}
}
이렇게 String
대신에 ?
를 적어주면 어떤 타입이 제네릭 타입이 되더라도 상관 없다.
?
로 명시한 타입을 영어로는 wildcard 타입이라고 부른다.다음과 같이 와일드 카드로 객체를 선언 후 setWildcard()
을 사용하면 컴파일 오류가 발생한다.
WildcardGeneric<?> wildcard=new WildcardGeneric<String>();
wildcard.setWildcard("A");
오류를 보면 알 수 없는 타입에 String을 지정할 수 없다고 하고 있다.
다시 말해, 어떤 객체를 wildcard로 선언하고, 그 객체의 값은 가져올 수 있지만, 와일드 카드로 선언했을 때에는 이 예제와 같이 특정 타입으로 값을 지정하는 것은 “불가능”하다.
따라서 wildcard는 메소드의 매개 변수로만 사용하는 것이 좋다.
와일드 카드를 사용할 때, 매개 변수로 넘어오는 제네릭 타입을 제한하는 것을 말한다.
< ? extends 타입>
타입
을 상속받은 모든 클래스를 사용할 수 있다는 의미다.타입
클래스와 관련되어 있는 상속한 클래스가 넘어와야만 한다.wildcard로 메소드를 선언하면 매개 변수로 사용된 객체에 값을 추가할 수가 없다는 큰 단점이 있다.
매개 변수로 제네릭 타입을 받지 않고 메소드 안에서 값을 부여해주려고 하면 오류가 난다.
public void genericMethod(WildcardGeneric<T> c) {
c.setWildcard("string");
T value=c.getWildcard();
System.out.println(value);
}
이 때, 아래와 같이 사용하면 객체에 값을 추가할 수 있다.
<>
로 제네릭 타입을 선언T addValue
set
해준다.public <T> void genericMethod(WildcardGeneric<T> c, T addValue) {
c.setWildcard(addValue);
T value=c.getWildcard();
System.out.println(value);
}
이처럼 메소드 선언 시 리턴 타입 앞에 제네릭한 타입을 선언해 주고, 그 타입을 매개 변수에서 사용하면 컴파일할 때 전혀 문제가 없다.
즉, 외부에서 넘겨주는 값을 T라는 제네릭 타입으로 받아서, 마찬가지로 T라는 제네릭 타입으로 선언된 객체에 넣어주는 것은 가능하다.
그렇지만 메소드 내부에서는 이 T가 어떤 타입인지를 모르기 때문에, String 값을 직접 넣어줄 수는 없는 것이다.
WildcardGeneric<String> wildcard=new WildcardGeneric<String>();
genericMethod(wildcard,"Data");
Wildcard 처럼 타입을 두리뭉실하게 하는 것보다, 명시적으로 메소드 선언 시 타입을 지정해주면 보다 더 견고한 코드를 작성할 수 있다.
메소드 선언 시, 아래와 같이 Bounded Wildcard처럼 사용할 수 있다.
public <T extends Car> void genericMethod(WildcardGeneric<T> c, T addValue)
한 개 이상의 제네릭 타입 선언은 콤마로 구분하여 나열하면 된다.
// S와 T라는 제네릭 타입을 메소드에서 사용가능
public <S,T extends Car> void genericMethod(WildcardGeneric<T> c, T addValue, S another)
제니릭한 클래스를 선언해서 사용할 일은 그리 많지 않겠지만, 제네릭한 클래스를 사용할 일은 매우 많을 것이다.
따라서, 이 장에 설명한 대로 제네릭 클래스를 선언할 수는 없어도, 사용하는 방법은 꼭 이해하고 넘어가야만 한다.
Me: 실행 시 발생할 수 있는 형 변환 오류를 방지하기 위해서, 즉 타입 안정성을 위해서 추가되었다.
Me: X
Me: <?>
처럼 사용한다.
<>
안에 어떤 기호를 넣어 주어야 하나요?Me: extends
Me: 메소드 내부에서는 해당 타입을 정확히 모르기 때문에, Object로 처리해야만 한다.
Me: <>
로 제네릭 타입을 선언한다.
💡 책에 있는 내용이 아닙니다.
책을 읽으며 설명이 더 필요하거나, 추가로 궁금한 점에 대해 질문 형식으로 작성 후, 답을 구해보고 있습니다.
참고한 사이트나 영상은 [출처]로 달아두었으며, 오류 지적은 언제나 환영합니다.
자바의 이전 버전과의 호환성을 유지하기 위해서 컴파일 시 타입 소거(type erasure) 과정을 거쳐 제거한다.
컴파일 단계에서 타입 소거를 한다고 해서, .class
파일을 열어보면 될 거라고 생각했다. 그런데, vscode
에서 실제로 열어보니 타입은 그대로 T
로 남아있었다.
public class WildcardGeneric<W> {
W wildcard;
public void setWildcard(W wildcard) {
this.wildcard=wildcard;
}
public W getWildcard() {
return wildcard;
}
}
// Source code is decompiled from a .class file using FernFlower decompiler.
class WildcardGeneric<W> {
W wildcard;
WildcardGeneric() {
}
public void setWildcard(W var1) {
this.wildcard = var1;
}
public W getWildcard() {
return this.wildcard;
}
}
찾다가 안되겠어서 ChatGPT의 도움을 받았다.
제네릭 타입이 그대로 남아있다면 이는 디컴파일된 코드나 특정 도구에서 제공하는 정보일 수 있습니다.
디컴파일한 파일의 상단에 보면 주석으로 FernFlower 디컴파일러를 사용한다고 적혀 있다. 그리고 이 디컴파일러는 디컴파일할 때 제네릭 타입 정보를 유지하려고 한다.
즉, 디컴파일러의 문제였다.
나는 타입 소거된 결과를 보고 싶었던 거라서, 표준 자바 디컴파일러인 javap
를 사용하기로 했다.
이 화면은 메소드 부분인데, field
에 #n
으로 상수풀을 가리키고 있다. 그리고 주석에 Field Wildcard:Ljava/lang/Object
라고 적혀 있는 걸로 봐서는, 타입 소거가 이루어진 것 같다.
#n
으로 가리키고 있는 상수풀을 따라가보면, wildcard가 Object 타입이라고 되어 있음을 볼 수 있다.
public Node(T data, Node<T> next)
→ public Node(Object data, Node next)
public class Node<T extends Comparable<T>>
로 선언한다면public Node(T data, Node<T> next)
→ Node(Comparable data, Node next)
// 아래와 같은 상속 구조일 때
class Shape {}
class Circle extends Shape {}
class Rectangle extends Shape {}
// 아래 메소드를 선언했다면
public static <T extends Shape> void draw(T shape) {}
// 이렇게 치환된다.
public static void draw(Shape shape) {}
💡 아래 예제와 설명은 모두 JDK 8 버전으로 작성된 oracle javase 문서를 참고했습니다.
컴파일러는 클래스나 인터페이스를 상속받거나 구현하는 클래스 혹은 인터페이스가 있을 경우 경우, Bridge Method라고 부르는 합성 메소드를 생성한다.
아래와 같은 코드가 있다고 할 때,
public class Node<T> {
public T data;
public Node(T data) {
this.data = data;
}
public void setData(T data) {
System.out.println("Node.setData");
this.data = data;
}
}
public class MyNode extends Node<Integer> {
public MyNode(Integer data) {
super(data);
}
public void setData(Integer data) {
System.out.println("MyNode.setData");
super.setData(data);
}
}
타입 소거를 시키면 아래처럼 코드가 치환된다.
public class Node {
public Object data;
public Node(Object data) {
this.data = data;
}
public void setData(Object data) {
System.out.println("Node.setData");
this.data = data;
}
}
public class MyNode extends Node {
public MyNode(Integer data) {
super(data);
}
public void setData(Integer data) {
System.out.println("MyNode.setData");
super.setData(data);
}
}
이 코드대로라면 부모의 setData
메소드와 자식의 setData
메소드는 오버라이딩이 아닌, 오버로딩의 관계를 갖게 된다.
MyNode mn = new MyNode(5);
Node n = mn;
// Integer타입에 String을 넣었으므로
// ClassCastException 가 발생해야 하지만 정상 동작함
n.setData("Hello");
따라서, 컴파일러는 타입 소거를 해도 다형성을 보존할 수 있게 별도의 합성 메소드를 만드는데 이것이 바로 Bridge Method이다.
class MyNode extends Node {
// 컴파일러가 생성한 Bridge method
public void setData(Object data) {
setData((Integer) data);
}
public void setData(Integer data) {
System.out.println("MyNode.setData");
super.setData(data);
}
// ...
}
이제 문제의 n.setData("Hello");
코드를 실행하면, String을 Integer로 형 변환할 수 없으므로 ClassCastException
오류가 날 것이다.
DTO는 Data Transfer Object의 약어이고, VO는 Value Object의 약어이다.
두 객체 모두 데이터를 전달하거나 보관하는 데 사용된다는 공통점이 있다. 그래서인지 둘을 언제 어디서 쓰는가 헷갈렸다.
DTO는 데이터 전송을 위한 가벼운 객체이며, VO는 비즈니스 로직에서 사용하는 값을 불변으로 표현하는 객체이다.
Serializable는 뒷 장에서 배우는데, 객체를 파일로 저장하거나 다른 서버로 전송할 경우, 이 인터페이스를 구현해야 한다.
제네릭 타입은 Object로 치환되는 만큼 안 되지 않을까 생각했는데, 실제로는 가능하다.
public class GenericOverloadingExample {
// 메서드 오버로딩: 제네릭 타입이 다른 경우
public <T> void printValue(T value) {
System.out.println("Generic method: " + value);
}
public <T, U> void printValues(T value1, U value2) {
System.out.println("Generic method with two parameters: " + value1 + ", " + value2);
}
// 다른 타입의 매개변수를 받는 오버로딩
public void printValue(String value) {
System.out.println("Non-generic method: " + value);
}
public static void main(String[] args) {
GenericOverloadingExample example = new GenericOverloadingExample();
// 제네릭 메서드 호출
example.printValue(42);
example.printValues("Hello", 3.14);
// 오버로딩된 메서드 호출
example.printValue("Overloaded method");
}
}
제네릭 컬렉션에서 사용되며, 어떤 타입의 데이터를 다룰 것인지를 나타낼 때 암묵적으로 사용하는 기호이다.
사용할 수 있다.
Is there a way to see what my code looks like after type erasure?
Type Erasure (The Java™ Tutorials > Learning the Java Language > Generics (Updated))