21장. 실수를 방지하기 위한 제네릭이라는 것도 있어요

공부하는 감자·2023년 12월 13일
0

자바의 신 3판

목록 보기
21/30

들어가기 전

『자바의 신 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이란
메소드나 클래스 같은 작은 단위를 쉽게 테스트할 수 있도록 도와주는 프레임워크이다.
작성한 메소드를 테스트하는 코드를 만들어야 하고, 테스트 조건에 맞지 않으면 실패로 간주하도록 되어 있다.
테스트와 관련된 참조 자료는 이 책의 마지막 장을 참고하기 바란다.

제네릭이란

타입 형 변환에서 발생할 수 있는 문제점을 컴파일 시 점검할 수 있도록 하기 위하여 만들어졌다. (명시적으로 타입을 지정)

  • 컴파일 단계에서 오류를 검출하여 타입 안전성을 제공한다.

    • 타입 안전성이란, 위의 경우와 같이 잘못된 타입으로 형 변환하는 것을 말한다.
  • 중복의 제거와 타입 안전성을 동시에 추구할 수 있게 되었다.

    • 중복 제거의 예를 들자면, 타입 별로 메소드를 선언해야 할 경우 제네릭을 사용하면 메소드를 하나만 선언해도 된다.
    • 아래는 ChatGPT의 예제 코드
    // 제네릭을 사용하지 않은 경우
    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이다.

만약 잘못된 타입으로 치환했다면 컴파일 자체가 안된다.

따라서, “실행 시”에 다른 타입으로 잘못 형 변환하여 예외가 발생하는 일은 없다.

제네릭 타입의 이름 정하기

앞서 클래스 선언 시 <> 안에 어떤 단어가 들어가더라도 상관없다고 했다. 그렇지만, 자바에서 정의한 기본 규칙은 있다.

  • E: 요소 (Element, 자바 컬렉션에서 주로 사용됨)
  • K: 키
  • N: 숫자
  • T: 타입
  • V: 값
  • S, U, V: 두 번째, 세 번째, 네 번째에 선언된 타입

꼭 이 규칙을 지켜야만 컴파일이 되는 것은 아니다.

하지만, 다른 어떤 사람이 보더라도 쉽게 이해할 수 있도록 하려면 이 규칙을 따르는 것이 좋다.

Wildcard 타입으로 여러 타입 받기

예제로 보는 문제점

아래처럼 제네릭한 클래스와, 이 클래스를 매개 변수로 받는 메소드가 있다.

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 객체를 받을 수 없다.

wildcard를 이용한 해결

따라서 만약 다른 타입으로 선언된 WildcardGeneric 객체를 받으려면, 다음과 같이 선언하면 된다.

public class WildcardSample {
    public void wildcardMethod(WildcardGeneric<?> c) {
        Object value=c.getWildcard();
        if(value instanceof String) {
            System.out.println(value);
        }
    }
}

이렇게 String 대신에 ? 를 적어주면 어떤 타입이 제네릭 타입이 되더라도 상관 없다.

  • ? 로 명시한 타입을 영어로는 wildcard 타입이라고 부른다.
  • 메소드 내부에서는 해당 타입을 정확히 모르기 때문에, Object로 처리해야만 한다.

wildcard로 객체를 선언하면 안 된다.

다음과 같이 와일드 카드로 객체를 선언 후 setWildcard() 을 사용하면 컴파일 오류가 발생한다.

WildcardGeneric<?> wildcard=new WildcardGeneric<String>();
wildcard.setWildcard("A");

오류를 보면 알 수 없는 타입에 String을 지정할 수 없다고 하고 있다.

다시 말해, 어떤 객체를 wildcard로 선언하고, 그 객체의 값은 가져올 수 있지만, 와일드 카드로 선언했을 때에는 이 예제와 같이 특정 타입으로 값을 지정하는 것은 “불가능”하다.

따라서 wildcard는 메소드의 매개 변수로만 사용하는 것이 좋다.

Bounded Wildcards

와일드 카드를 사용할 때, 매개 변수로 넘어오는 제네릭 타입을 제한하는 것을 말한다.

< ? extends 타입>
  • 제네릭 타입으로 타입 을 상속받은 모든 클래스를 사용할 수 있다는 의미다.
  • 반드시 타입 클래스와 관련되어 있는 상속한 클래스가 넘어와야만 한다.
    • 그렇지 않을 경우 컴파일 시에 에러가 발생
  • wildcard와 마찬가지로, Bounded Wildcards로 선언한 타입에는 값을 할당할 수는 없다.
    • 조회용 매개 변수로 사용해야만 한다.

메소드를 제네릭하게 선언하기

wildcard 의 단점

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처럼 사용하기

메소드 선언 시, 아래와 같이 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)

정리하며

제니릭한 클래스를 선언해서 사용할 일은 그리 많지 않겠지만, 제네릭한 클래스를 사용할 일은 매우 많을 것이다.

따라서, 이 장에 설명한 대로 제네릭 클래스를 선언할 수는 없어도, 사용하는 방법은 꼭 이해하고 넘어가야만 한다.

정리해 봅시다.

Q. 제네릭이 자바에 추가된 이유는 무엇인가요?

Me: 실행 시 발생할 수 있는 형 변환 오류를 방지하기 위해서, 즉 타입 안정성을 위해서 추가되었다.

Q. 제네릭 타입의 이름은 T나 E 처럼 하나의 캐릭터로만 선언되어야 하나요?

Me: X

Q. 메소드에서 제네릭 타입을 명시적으로 지정하기 애매할 경우에는 < > 안에 어떤 기호를 넣어 주어야 하나요?

Me: <?> 처럼 사용한다.

Q. 메소드에서 제네릭 타입을 명시적으로 지정하기에는 애매하지만, 어떤 클래스의 상속을 받은 특정 타입만 가능하다는 것은 나타내려면 <> 안에 어떤 기호를 넣어 주어야 하나요?

Me: extends

Q. 제네릭 선언시 wildcard라는 것을 선언했을 때 어떤 제약사항이 있나요?

Me: 메소드 내부에서는 해당 타입을 정확히 모르기 때문에, Object로 처리해야만 한다.

Q. 메소드를 제네릭하게 선언하려면 리턴타입 앞에 어떤 것을 추가해 주면 되나요?

Me: <> 로 제네릭 타입을 선언한다.

질문

💡 책에 있는 내용이 아닙니다.

책을 읽으며 설명이 더 필요하거나, 추가로 궁금한 점에 대해 질문 형식으로 작성 후, 답을 구해보고 있습니다.
참고한 사이트나 영상은 [출처]로 달아두었으며, 오류 지적은 언제나 환영합니다.

Q. 컴파일 시 제네릭 타입은 제거된다.

자바의 이전 버전과의 호환성을 유지하기 위해서 컴파일 시 타입 소거(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 타입이라고 되어 있음을 볼 수 있다.

제네릭 클래스의 타입 소거

  • unbounded 제네릭 타입은 Object로 치환한다.
    • public Node(T data, Node<T> next)public Node(Object data, Node next)
  • Bounded 제네릭 타입은 첫 번째 경계 클래스(상속 클래스)로 치환한다.
    • public class Node<T extends Comparable<T>> 로 선언한다면
    • public Node(T data, Node<T> next)Node(Comparable data, Node next)
  • 제네릭 타입을 제거한 후, 타입이 맞지 않는 곳이 있다면 명시적으로 형 변환 코드를 추가해준다.

제네릭 메소드의 타입 소거

  • unbounded 제네릭 타입은 Object로 치환한다.
  • Bounded 제네릭 타입은 첫 번째 경계 클래스(상속 클래스)로 치환한다.
    // 아래와 같은 상속 구조일 때
    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) {}

타입 소거와 Bridge Methods

💡 아래 예제와 설명은 모두 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 오류가 날 것이다.

Q. DTO 와 VO

DTO는 Data Transfer Object의 약어이고, VO는 Value Object의 약어이다.

두 객체 모두 데이터를 전달하거나 보관하는 데 사용된다는 공통점이 있다. 그래서인지 둘을 언제 어디서 쓰는가 헷갈렸다.

DTO

  • 주로 외부 시스템 혹은 레이어(계층) 간의 데이터 교환을 목적으로 한다.
  • 필요한 데이터만을 포함하며, 비즈니스 로직은 갖고 있지 않다.
  • 데이터 전송을 위해 사용되기 때문에 불변성은 갖지 않는다.

VO

  • 주로 비즈니스 로직에서 사용하는 값을 캡슐화하고 싶을 때 사용한다.
  • 객체의 상태나 속성을 나타내며, 데이터에 대한 행위(메소드)를 가질 수 있다.
  • 비즈니스 도메인에서 중요한 값을 표현하므로, 불변성을 갖는다.

요약

DTO는 데이터 전송을 위한 가벼운 객체이며, VO는 비즈니스 로직에서 사용하는 값을 불변으로 표현하는 객체이다.

Q. DTO에 Serializable은 왜 사용 했는가?

Serializable는 뒷 장에서 배우는데, 객체를 파일로 저장하거나 다른 서버로 전송할 경우, 이 인터페이스를 구현해야 한다.

Q. 왜 제네릭 타입은 오버로딩이 불가능한가?

제네릭 타입은 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");
    }
}

Q. 제네릭의 엘리먼트 요소(E)는 무엇인가?

제네릭 컬렉션에서 사용되며, 어떤 타입의 데이터를 다룰 것인지를 나타낼 때 암묵적으로 사용하는 기호이다.

Q. 제네릭에서 static과 final사용

static

  • 제네릭은 Static 변수로 선언할 수 없다.
    • 제네릭은 주로 인스턴스 수준에서 동작하도록 설계되었기 때문이다.
    • Static 변수는 여러 인스턴스에서 공유되는 변수이다.
    • 제네릭을 사용할 경우, 컴파일 시에 타입이 소거되면 여러 인스턴스는 서로 다른 제네릭 타입을 가질 수 없게 된다.
    • 제네릭이 런타임 시 형 변환 문제를 컴파일 시 잡아주기 위해 나왔다는 걸 생각해보면, Static 변수에서 사용하는 것은 말이 안 되기는 하다.
  • 제네릭은 Static 메소드의 매개 변수로 사용할 수 있다.

final

사용할 수 있다.

  • 제네릭 클래스에 final 사용 : 상속 불가능
  • 제네릭 메소드에 final 사용 : 오버라이딩 불가능
  • 제네릭 변수에 final 사용 : 초기화 이후 변경 불가능

참고 사이트

제네릭 - 생활코딩

코딩교육 티씨피스쿨

타입 소거 과정을 보려고 찾았던 사이트

Is there a way to see what my code looks like after type erasure?

자바의 String과 Constant Pool

☕ 자바 제네릭 타입 소거 컴파일 과정 알아보기

Type Erasure (The Java™ Tutorials > Learning the Java Language > Generics (Updated))

profile
책을 읽거나 강의를 들으며 공부한 내용을 정리합니다. 가끔 개발하는데 있었던 이슈도 올립니다.

0개의 댓글