'김영한의 실전 자바 - 중급 2편' 강의를 들으면서 복습할만한 내용을 정리하였다.

1. 제네릭 - Generic 1

1.1 제네릭이 필요한 이유

제네릭을 사용하면 코드 재사용과 타입 안전성이라는 두 마리 토끼를 한번에 잡을 수 있다.

public class GenericBox<T> {

    private T value;

    public void set(T value) {
        this.value = value;
    }

    public T get() {
        return value;
    }
}
  • <> 를 사용한 클래스를 제네릭 클래스라 한다. 이 기호 <> 를 보통 다이아몬드라 한다.
  • 제네릭 클래스를 사용할 때는 Integer, String 같은 타입을 미리 결정하지 않는다.
  • 대신에 클래스명 오른쪽에 <T> 와 같이 선언하면 제네릭 클래스가 된다. 여기서 T타입 매개변수라 한다. 이 타입 매개변수는 이후에 Integer, String 으로 변할 수 있다.
  • 그리고 클래스 내부에 T 타입이 필요한 곳에 T value 와 같이 타입 매개변수를 적어두면 된다.

사용 예제

public class BoxMain3 {

    public static void main(String[] args) {
        GenericBox<Integer> integerBox = new GenericBox<Integer>(); // 생성 시점에 T의 타입 결정
        integerBox.set(10);
        //integerBox.set("문자열");
        Integer integer = integerBox.get(); // Integer 타입 변환(캐스팅 X)

        GenericBox<String> stringBox = new GenericBox<String>(); // 생성 시점에 T의 타입 결정
//        stringBox.set(10);
        stringBox.set("문자열");
        String str = stringBox.get(); // Integer 타입 변환(캐스팅 X)
    }
}

생성 시점에 원하는 타입 지정

제네릭 클래스는 다음과 같이 정의한다.

class GenericBox<T>

<> 안에 타입 매개변수를 정의하면 된다.

제네릭 클래스는 생성하는 시점에 <> 사이에 원하는 타입을 지정한다.

new GenericBox<Integer>()

원하는 모든 타입 사용 가능

new GenericBox<Double>()
new GenericBox<Boolean>()
new GenericBox<MyClass>()

제네릭 클래스를 사용하면 위와 같이 GenericBox 객체를 생성하는 시점에 원하는 타입을 마음껏 지정할 수 있다.

참고

제네릭을 도입한다고 해서 앞서 설명한 Generic<Integer>, Generic<String> 와 같은 코드가 실제 만들어지는 것은 아니다. 대신에 자바 컴파일러가 우리가 입력한 타입 정보를 기반으로 이런 코드가 있다고 가정하고 컴파일 과정에 타입 정보를 반영한다. 이 과정에서 타입이 맞지 않으면 컴파일 오류가 발생한다.

1.2 제네릭 용어와 관례

제네릭의 핵심은 사용할 타입을 미리 결정하지 않는다는 점이다. 클래스 내부에서 사용하는 타입을 클래스를 정의하는 시점에 결정하는 것이 아니라 실제 사용하는 생성 시점에 타입을 결정하는 것이다.

용어 정리

  • 제네릭(Generic) 단어

    • 제네릭이라는 단어는 일반적인, 범용적인이라는 영어 단어 뜻이다.

    • 풀어보면 특정 타입에 속한 것이 아니라 일반적으로, 범용적으로 사용할 수 있다는 뜻이다.

  • 제네릭 타입(Generic Type)

    • 클래스나 인터페이스를 정의할 때 타입 매개변수를 사용하는 것을 말한다.

    • 제네릭 클래스, 제네릭 인터페이스를 모두 합쳐서 제네릭 타입이라 한다.

      • 타입은 클래스, 인터페이스, 기본형(int 등)을 모두 합쳐서 부르는 말이다.
    • 예: class GenericBox<T> { private T t; }

    • 여기에서 GenericBox<T> 를 제네리 타입이라 한다.

  • 타입 매개변수(Type Parameter)

    • 제네릭 타입이나 메서드에서 사용되는 변수로, 실제 타입으로 대체된다.
    • 예: GenericBox<T>
    • 여기에서 T 를 타입 매개변수라 한다.
  • 타입 인자(Type Argument)

    • 제네릭 타입을 사용할 때 제공되는 실제 타입이다.

    • 예: GenericBox<Integer>

    • 여기에서 Integer 를 타입 인자라 한다.

제네릭 명명 관례

타입 매개변수는 일반적인 변수명처럼 소문자로 사용해도 문제는 없다. 하지만 일반적으로 대문자를 사용하고 용도에 맞는 단어의 첫글자를 사용하는 관례를 따른다.

  • E - Element
  • K - Key
  • N - Number
  • T - Type
  • V - Value
  • S, U, V etc - 2nd, 3rd, 4th types

여러 타입 매개변수

class Data<K, V> {}

2. 제네릭 - Generic 2

2.1 타입 매개변수 제한

타입 매개 변수를 특정 타입으로 제한할 수 있다.

public class AnimalHospitalV3<T extends Animal> {

    private T animal;

    public void set(T animal) {
        this.animal = animal;
    }

    public void checkup() {
        System.out.println("동물 이름: " + animal.getName());
        System.out.println("동물 크기: " + animal.getSize());
        animal.sound();
    }

    public T bigger(T target) {
        return animal.getSize() > target.getSize() ? animal : target;
    }
}

<T extends Animal> 를 통해 타입 매겨변수 TAnimal 과 그 자식만 받을 수 있도록 제한을 두는 것이다. 즉, T 의 상한이 Animal 이 되는 것이다.

이제 자바 컴파일러는 T 에 입력될 수 있는 값의 범위를 예측할 수 있다.

타입 매개변수 T 에는 타입 인자로 Animal, Dog, Cat 만 들어올 수 있다. 따라서 이를 모두 수용할 수 있는 AnimalT 의 타입으로 가정해도 문제가 없다.

따라서 Animal 이 제공하는 getName(), getSize() 같은 기능을 사용할 수 있다.

제네릭에 타입 매개변수 상한을 사용해서 타입 안전성을 지키면서 상위 타입의 원하는 기능까지 사용할 수 있었다. 덕분에 코드 재사용성과 타입 안정성이라는 두 마리 토끼를 동시에 잡을 수 있었다.

2.2 제네릭 메서드

GenericMethod

public class GenericMethod {

    public static Object objMethod(Object obj) {
        System.out.println("Object print = " + obj);
        return obj;
    }

    public static <T> T genericMethod(T t) {
        System.out.println("Object print = " + t);
        return t;
    }

    public static <T extends Number> T numberMethod(T t) {
        System.out.println("Bound print = " + t);
        return t;
    }
}

MethodMain1

public class MethodMain1 {
    public static void main(String[] args) {
        Integer i = 10;
        Object object = GenericMethod.objMethod(i);

        // 타입 인자(Type Argument) 명시적 전달
        Integer integer = GenericMethod.<Integer>genericMethod(i);
        System.out.println("integer = " + integer);

        Integer integerValue = GenericMethod.<Integer>numberMethod(10);
        Double doubleValue = GenericMethod.<Double>numberMethod(10.0);
    }
}

제네릭 메서드

  • 정의: <T> T genericMethod(T t)
  • 타입 인자 전달: 메서드를 호출하는 시점
    • 예) GenericMethod.<Integer>genericMethod(i)
  • 제네릭 메서드는 클래스 전체가 아니라 특정 메서드 단위로 제네릭을 도입할 대 사용한다.
  • 제네릭 메서드를 정의할 때는 메서드의 반환 타입 왼쪽에 다이아몬드를 사용해서 <T> 와 같이 타입 매개변수를 적어준다.
  • 제네릭 메서드는 메서드를 실제 호출하는 시점에 다이아몬드를 사용해서 <Integer> 와 같이 타입을 정하고 호출한다.

제네릭 메서드의 핵심은 메서드를 호출하는 시점에 타입 인자를 전달해서 타입을 지정하는 것이다. 따라서 타입을 지정하면서 메서드를 호출한다.

인스턴스 메서드, static 메서드

제네릭 메서드는 인스턴스 메서드와 static 메서드에 모두 적용할 수 있다.

class Box<T> { //제네릭 타입
	static <V> V staticMethod2(V v) {} //static 메서드에 제네릭 메서드 도입
    <Z> Z instanceMethod2(Z z) {} //인스턴스 메서드에 제네릭 메서드 도입 가능
}

참고

제네릭 타입은 static 메서드에 타입 매개변수를 사용할 수 없다. 제네릭 타입은 객체를 생성하는 시점에 타입이 정해진다. 그런데 static 메서드는 인스턴스 단위가 아니라 클래스 단위로 작동하기 때문에 제네릭 타입과는 무관하다.

따라서 static 메서드에 제네릭을 도입하려면 제네릭 메서드를 사용해야 한다.

class Box<T> {
  	T instanceMethod(T t) {} //가능
  	static T staticMethod1(T t) {} //제네릭 타입의 T 사용 불가능
}

타입 매개변수 제한

제네릭 메서드도 제네릭 타입과 마찬가지로 타입 매개변수를 제한할 수 있다.

public static <T extends Number> T numberMethod(T t) {}

제네릭 메서드 타입 추론

제네릭 메서드를 호출할 때 <Integer> 와 같이 타입 인자를 계속 전달하는 것은 매우 불편하다.

자바 컴파일러는 타입 인자를 추론할 수 있다.

System.out.println("타입 추론");
Integer result2 = GenericMethod.genericMethod(i);
Integer integerValue2 = GenericMethod.numberMethod(10);
Double doubleValue2 = GenericMethod.numberMethod(20.0);

2.3 와일드 카드

와일드 카드는 제네릭 타입을 조금 더 편리하게 사용할 수 있도록 도와준다. 참고로 와일드카드라는 뜻은 컴퓨터 프로그래밍에서 *, ? 와 같이 하나 이상의 문자들을 상징하는 특수 문자를 뜻한다. 여러 타입이 들어올 수 있다는 뜻이다.

제네릭 타입 Box<T>

public class Box<T> {

	private T value;

  	public void set(T value) {
 		this.value = value;
    }
 
  	public T get() {
		return value;
    }
}

WildcardEx

public class WildcardEx {

    static <T> void printGenericV1(Box<T> box) {
        System.out.println("T = " + box.getValue());
    }

    static void printWildcardV1(Box<?> box) {
        System.out.println("? = " + box.getValue());
    }

    static <T extends Animal> void printGenericV2(Box<T> box) {
        T t = box.getValue();
        System.out.println("이름 = " + t.getName());
    }

    static void printWildcardV2(Box<? extends Animal> box) {
        Animal animal = box.getValue();
        System.out.println("이름 = " + animal.getName());
    }

    static <T extends Animal> T printAndReturnGeneric(Box<T> box) {
        T t = box.getValue();
        System.out.println("이름 = " + t.getName());
        return t;
    }

    static Animal printAndReturnWildcard(Box<? extends Animal> box) {
        Animal animal = box.getValue();
        System.out.println("이름 = " + animal.getName());
        return animal;
    }
}
  • 제네릭 메서드와 와일드 카드를 비교할 수 있게 같은 기능을 하나씩 배치했다.
  • 와일드 카드는 ? 를 사용해서 정의한다.

참고

와일드카드는 제네릭 타입이나, 제네릭 메서드를 선언하는 것이 아니다. 와일드카드는 이미 만들어진 제네릭 타입을 활용할 때 사용한다.

제네릭 메서드 vs 와일드카드

printGenericV1() 제네릭 메서드를 보자. 제네릭 메서드에는 타입 매개변수가 존재한다. 그리고 특정 시점에 타입 매개변수에 타입 인자를 전달해서 타입을 결정해야 한다. 이런 과정은 매우 복잡하다.

반면에 printWildcardV1() 메서드를 보자. 와일드카드는 일반적인 메서드에 사용할 수 있고, 단순히 매개변수로 제네릭 타입을 받을 수 있는 것 뿐이다. 제네릭 메서드처럼 타입을 결정하거나 복잡하게 작동하지 않는다. 단순히 일반 메서드에 제네릭 타입을 받을 수 있는 매개변수가 하나 있는 것 뿐이다.

제네렉 타입이나 제네릭 메서드를 정의하는게 꼭 필요한 상황이 아니라면, 더 단순한 와일드카드 사용을 권장한다.

상한 와일드카드

static void printWildcardV2(Box<? extends Animal> box) {
	Animal animal = box.get();
 	System.out.println("이름 = " + animal.getName());
}
  • 제네릭 메서드와 마찬가지로 와일드카드에도 상한 제한을 둘 수 있다.
  • 결과적으로 Animal 타입의 기능을 호출할 수 있다.

타입 매개변수가 꼭 필요한 경우

와일드카드는 제네릭을 정의할 때 사용하는 것이 아니다. Box<Dog>, Box<Cat> 처럼 타입 인자가 전달된 제네릭 타입을 활용할 때 사용한다. 따라서 다음과 같은 경우에는 제네릭 타입이나 제네릭 메서드를 사용해야 문제를 해결할 수 있다.

static <T extends Animal> T printAndReturnGeneric(Box<T> box) {
	T t = box.getValue();
	System.out.println("이름 = " + t.getName());
	return t;
}

static Animal printAndReturnWildcard(Box<? extends Animal> box) {
	Animal animal = box.getValue();
	System.out.println("이름 = " + animal.getName());
	return animal;
}

printAndReturnGeneric() 은 전달한 타입을 명확하게 반환할 수 있다.

Dog dog = WildcardEx.printAndReturnGeneric(dogBox);

반면에 printAndReturnWildcard() 의 경우 전달한 타입을 명확하게 반환할 수 없다. 여기서는 Animal 타입으로 반환한다.

Animal animal = WildcardEx.printAndReturnWildcard(dogBox);

2.4 타입 이레이저

이레이저(eraser)는 지우개라는 뜻이다.

제네릭은 자바 컴파일 단계에서만 사용되고, 컴파일 이후에는 제네릭 정보가 삭제된다. 제네릭에 사용한 타입 매개변수가 모두 사라지는 것이다. 쉽게 이야기해서 컴파일 전인 .java 에는 제네릭의 타입 매겨변수가 존재하지만, 컴파일 이후인 자바 바이트 코드 .class 에는 타입 매개변수가 존재하지 않는 것이다.

제네릭 타입 선언

GenericBox.java

public class GenericBox<T> {

    private T value;

    public T get() {
        return value;
    }

    public void set(T value) {
        this.value = value;
    }
}

제네릭 타입에 Integer 타입 인자 전달

Main.java

void main() {
	GenericBox<Integer> box = new GenericBox<Integer>();
    box.set(10);
 	Integer result = box.get();
}

이렇게 하면 자바 컴파일러는 컴파일 시점에 타입 매개변수와 타입 인자를 포함한 제네릭 정보를 활용해서 new GenericBox<Integer>() 에 대해 다음과 같이 이해한다.

public class GenericBox<Integer> {

	private Integer value;

  	public Integer get() {
 		return value;
    }
 
	public void set(Integer value) {
 		this.value = value;
    }
}

컴파일이 모두 끝나면 자바는 제네릭과 관련된 정보를 삭제한다. 이때 .class 에 생성된 정보는 다음과 같다.

컴파일 후

GenericBox.class

public class GenericBox<Object> {

    private Object value;

    public Object get() {
        return value;
    }

    public void set(Object value) {
        this.value = value;
    }
}

Main.class

void main() {
	GenericBox<Integer> box = new GenericBox<Integer>();
    box.set(10);
 	Integer result = (Integer) box.get(); // 컴파일러가 캐스팅 추가
}
  • 값을 반환 받는 부분을 Object 로 받으면 안된다. 자바 컴파일러는 제네릭에서 타입 인자로 지정한 Integer 로 캐스팅하는 코드를 추가해준다.
  • 이렇게 추가된 코드는 자바 컴파일러가 이미 검증하고 추가했기 때문에 문제가 발생하지 않는다.

타입 매겨변수 제한의 경우

T extends Animal 같이 제한을 하는 경우

컴파일 전 AnimalHospitalV3.java

public class AnimalHospitalV3<T extends Animal> {

    private T animal;

    public void set(T animal) {
        this.animal = animal;
    }

    public void checkup() {
        System.out.println("동물 이름: " + animal.getName());
        System.out.println("동물 크기: " + animal.getSize());
        animal.sound();
    }

    public T bigger(T target) {
        return animal.getSize() > target.getSize() ? animal : target;
    }
}

컴파일 후 AnimalHospitalV3.class

public class AnimalHospitalV3 {

    private Animal animal;

    public void set(Animal animal) {
        this.animal = animal;
    }

    public void checkup() {
        System.out.println("동물 이름: " + animal.getName());
        System.out.println("동물 크기: " + animal.getSize());
        animal.sound();
    }

    public Animal bigger(Animal target) {
        return animal.getSize() > target.getSize() ? animal : target;
    }
}
  • T 의 타입 정보가 제거되어도 상한으로 지정된 Animal 타입으로 대체되기 때문에 Animal 타입의 메서드를 사용하는데는 아무런 문제가 없다.

자바의 제네릭은 단순하게 생각하면 개발자가 직접 캐스팅 하는 코드를 컴파일러가 대신 처리해주는 것이다. 자바는 컴파일 시점에 제네릭을 사용한 코드에 문제가 없는지 완벽하게 검증하기 때문에 자바 컴파일러가 추가하는 다운 캐스팅에는 문제가 발생하지 않는다.

타입 이레이저 방식의 한계

컴파일 이후에는 제네릭의 타입 정보가 존재하지 않는다. .class 로 자바를 실행하는 런타임에는 우리가 지정한 Box<Integer>, Box<String 의 타입 정보가 모두 제거된다.

따라서 런타임에 타입을 활용하는 다음과 같은 코드는 작성할 수 없다.

소스 코드

class EraserBox<T> {
 
	public boolean instanceCheck(T param) {
 		return param instanceof T; // 오류
    }

  	public void create() {
 		return new T(); // 오류
    }
}

런타임

class EraserBox {
 
	public boolean instanceCheck(Object param) {
 		return param instanceof Object; // 오류
    }

  	public void create() {
 		return new Object(); // 오류
    }
}
  • 여기서 T 는 런타임에 모두 Object 가 되어버린다.
  • instanceof 는 항상 Object 와 비교하게 된다. 이렇게 되면 항상 참이 반환되는 문제가 발생한다. 자바는 이런 문제 때문에 타입 매개변수에 instanceof 를 허용하지 않는다.
  • new T 는 항상 new Object 가 되어버린다. 개발자가 의도한 것과는 다르다. 따라서 자바는 타입 매겨변수에 new 를 허용하지 않는다.

정리

실무에서 직접 제네릭을 사용해서 무언가를 설계하거나 만드는 일은 드물다. 그것보다는 대부분 이미 제네릭을 통해 만들어진 프레임워크나 라이브러리들을 가져다 사용하는 경우가 훨씬 많다. 그래서 이미 만들어진 코드의 제네릭을 읽고 이해하는 정도면 충분하다. 실무에서 직접 제네릭을 사용하더라도 어렵고 복잡하게 사용하기 보다는 보통 단순하게 사용한다.

profile
가오리의 개발 이야기

0개의 댓글