관리 메뉴

나구리의 개발공부기록

제네릭, 프로젝트 환경 구성과 제네릭이 필요한 이유, 다형성을 통한 중복 해결 시도, 제네릭 적용, 제네릭 용어와 관례, 제네릭 활용 예제 본문

인프런 - 실전 자바 로드맵/실전 자바 - 중급 2편

제네릭, 프로젝트 환경 구성과 제네릭이 필요한 이유, 다형성을 통한 중복 해결 시도, 제네릭 적용, 제네릭 용어와 관례, 제네릭 활용 예제

소소한나구리 2025. 2. 2. 00:45
728x90

출처 : 인프런 - 김영한의 실전 자바 - 중급2편 (유료) / 김영한님  
유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용


1. 프로젝트 환경 구성과 제네릭이 필요한 이유

1) 프로젝트 환경 구성

(1) 프로젝트 생성

  • 인텔리제이에서 NEW PROJECT 선택
  • Name: 4.java-mid2
  • Location: 원하는 위치
  • Build system: IntelliJ
  • JDK: 21

2) 제네릭이 필요한 이유

(1) IntegerBox, StringBox

  • 숫자와 문자열을 각각 보관하고 꺼낼 수 있는 기능을 가진 클래스들
package generic.ex1;

public class IntegerBox {
    private Integer value;

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

package generic.ex1;

public class StringBox {
    private String value;

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

    public String get() {
        return value;
    }
}

 

(2) BoxMain1

  • 숫자와 문자열을 보관하는 IntegerBox, StringBox를 생성하고 각 값을 입력한 뒤 출력하는 코드
package generic.ex1;

public class BoxMain1 {
    public static void main(String[] args) {

        IntegerBox integerBox = new IntegerBox();
        integerBox.set(10);
        Integer integer = integerBox.get();
        System.out.println("integer = " + integer);

        StringBox stringBox = new StringBox();
        stringBox.set("hello");
        String str = stringBox.get();
        System.out.println("str = " + str);
    }
}
/* 실행 결과
integer = 10
str = hello
*/

 

(3) 문제

  • 만약, 이후에 Double, Boolean을 포함하는 다양한 타입을 담는 박스가 필요하다면 각각의 타입별로 DoubleBox, BooleanBox와 같이 클래스를 새로 만들어야 할 텐데 담아야 할 타입이 수십개라면 이러한 형태로 수십 개의 Box 클래스를 생성해야함
  • 이 문제를 해결할 수 있는 방법은 무엇일까? 지금 Box 클래스들보면 여러개를 만들어도 구조가 비슷함

2. 다형성을 통한 중복 해결 시도

1) Object로 문제 해결 시도

(1) ObjectBox

  • Object는 모든 타입의 부모이기 때문에 다형성(다형적 참조)를 사용하여 이 문제를 간단히 해결할 수 있음
  • 내부에 Object value를 가지고 있으며, Object는 모든 타입의 부모이기 때문에 모든 타입을 ObjectBox에 보관할 수 있음
package generic.ex1;

public class ObjectBox {

    private Object value;

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

    public Object get() {
        return value;
    }
}

 

(2) BoxMain2

  • ObjectBox를 활용하면 캐스팅을 활용하여 정상적으로 동작하도록 하는 것 같지만, 몇 가지 문제가 있음
package generic.ex1;

public class BoxMain2 {
    public static void main(String[] args) {
        ObjectBox integerBox = new ObjectBox();
        integerBox.set(10);
        Integer integer = (Integer) integerBox.get();
        System.out.println("integer = " + integer);

        ObjectBox stringBox = new ObjectBox();
        stringBox.set("hello");
        String string = (String) stringBox.get();
        System.out.println("string = " + string);

        // 잘못된 타입의 인수 전달 시
        integerBox.set("문자100");
        Integer result = (Integer) integerBox.get();    // String -> Integer 캐스팅 예외 발생
        System.out.println("result = " + result);
    }
}
/* 실행 결과
integer = 10
string = hello
Exception in thread "main" java.lang.ClassCastException: class java.lang.String cannot be cast to class java.lang.Integer (java.lang.String and java.lang.Integer are in module java.base of loader 'bootstrap')
	at generic.ex1.BoxMain2.main(BoxMain2.java:17)
*/

 

(3-1) 반환 타입이 맞지 않는 문제

  • new ObjectBox()로 integerBox를 만들어서 숫자를 입력하는 부분에는 문제가 없지만 integerBox.get()으로 호출할 때 반환타입이 Object로 반환됨
  • 그래서 직접 (Integer)로 타입 캐스팅 코드를 작성하여 직접 다운 캐스팅을 해야하며 stringBox의 경우에도 마찬가지로 직접 다운 캐스팅을 해야함

(3-2) 잘못된 타입의 인수 전달 문제

  • 개발자의 의도는 integerBox에는 변수 이름과 같이 숫자 타입이 입력되기를 기대하였으나, set()메서드는 모든 타입의 부모인 Object를 매개변수로 받기 때문에 모든 타입을 입력 받을 수 있음
  • 즉, integerBox.set()메서드에 숫자 타입이 아닌 문자열을 입력해도 자바 언어 입장에서는 아무런 문제가 나타나지 않지만 잘못된 타입의 값을 꺼낼 때 캐스팅을 할 수 없어 문제가 발생함
  • 숫자가 들어있을 것으로 예상한 박스에 문자열이 들어가고 다운 캐스팅 시 String을 Integer로 캐스팅할 수 없다는 예외가 발생하면서 프로그램이 종료하게 됨

(4) 정리

  • 다형성을 활용한 덕분에 코드의 중복을 제거하고 기존 코드를 재사용할 수 있게 되었으나 입력할 때 실수로 원하지 않는 타입이 들어갈 수 있는 타입 안정성 문제가 발생하게 되었음
  • 그리고 반환 시점에 Object를 반환하기 때문에 원하는 타입을 정확하게 받을 수 없고 위험한 다운 캐스팅을 시도해야함
  • 지금까지 개발한 코드들은 코드 재사용과 타입 안정성이라는 2마리 토끼를 한번에 잡을 수 없음
  • BoxMain1은 각각의 타입별로 IntegerBox, StringBox와 같이 클래스를 모두 정의하여 타입을 잘못입력하면 컴파일 시점에 모든 오류를 잡아주기 때문에 타입안정성이 매우 뛰어나지만 코드 재사용성이 없음
  • BoxMain2는 ObjectBox를 사용하여 다형성으로 하나의 클래스만 정의하여 사용할 수 있어 코드 재사용성이 매우 높지만 모든 타입을 입력받을 수 있어 타입 안정성이 좋지 않은 치명적인 문제가 있음

3. 제네릭 적용

1) 제네릭 적용 예제

(1) GenericBox<T>

  • 제네릭을 사용하면 코드 재사용과 타입 안정성이라는 두 마리 토끼를 한번에 잡을 수 있음
  • <>를 사용한 클래스를 제네릭 클래스라하며 <>기호를 보통 다이아몬드라고 함
  • 제네릭 클래스를 사용할 때는 Integer, String 같은 타입을 미리 결정하지 않는 대신 클래스명 오른쪽에 <T>와 같이 선언하면 됨
  • 여기서 T를 타입 매개변수라고 하며, 이 타입 매개변수가 이후에 Integer, String과 같이 실제 타입으로 변함
  • 클래스 내부에 T 타입이 필요한 곳에 T value와 같이 타입 매개변수를 적으면 됨
package generic.ex1;

public class GenericBox<T> {
    
    private T value;
    
    public void set(T value) {
        this.value = value;
    }
    
    public T get() {
        return value;
    }
}

 

(2) BoxMain3

  • 제네릭 클래스를 생성하는 시점에 <> 사이에 원하는 타입을 지정하면 앞서 정의한 GenericBox의 T(타입 매개변수)가 지정한 타입으로 변환된 다음 생성됨
  • 즉, T에 Integer를 적용하면 GenericBox<T>에 정의된 모든 T가 Integer로 변하여 set(Integer value)가 되어 Integer 타입만 담을 수 있고 get()메서드의 반환타입도 Integer가 되어 Integer 타입이 반환됨
  • 마찬가지로 GenericBox<String>으로 적용하면 T가 모두 String으로 적용되어 new GenericBox<원하는 타입>()으로 GenericBox객체를 생성하는 시점에 원하는 타입을 마음껏 지정할 수 있음
package generic.ex1;

public class BoxMain3 {
    public static void main(String[] args) {

        GenericBox<Integer> integerBox = new GenericBox<Integer>();  // 생성 시점에 T의 타입 결정
        integerBox.set(10);     // Integer 타입만 허용
//        integerBox.set("문자100");    // 컴파일 오류 발생, Integer 타입만 허용 됨

        Integer integer = integerBox.get(); // Integer 타입이 반환 되어 다운 캐스팅이 필요 없음
        System.out.println("integer = " + integer);

        GenericBox<String> stringBox = new GenericBox<String>();
        stringBox.set("Hello");             // String 타입만 허용
        String string = stringBox.get();    // String 타입 반환
        System.out.println("string = " + string);

        // 원하는 모든 타입 사용 가능
        GenericBox<Double> doubleBox = new GenericBox<Double>();
        doubleBox.set(3.14);
        Double doubleValue = doubleBox.get();
        System.out.println("doubleValue = " + doubleValue);
    }
}
/* 실행 결과
integer = 10
string = Hello
doubleValue = 3.14
*/

 

** 참고

  • 기본형은 안되고 래퍼형만 가능함
  • 제네릭을 도입한다고 해서 앞서 설명한 GenericBox<String>, GenericBox<Integer>와 같은 코드가 실제 만들어지는 것은 아니고 자바 컴파일러가 입력된 타입 정보를 기반으로 이런 코드가 있다고 가정하여 컴파일 과정에 타입 정보를 반영함
  • 이 과정에서 타입이 맞지 않으면 컴파일 오류가 발생하며 더 자세한 내용은 뒤에서 설명함

(3) 타입 추론

  • 첫번째 줄의 코드를 보면 변수를 선언할 때와 객체를 생성할 때 <Integer>가 두번 나옴
  • 자바는 왼쪽에 있는 변수를 선언할 때 <Integer>를 보고 오른쪽에 있는 객체를 생성할 때 필요한 타입 정보를 얻을 수 있으므로 오른쪽의 객체 생성 코드에는 new GenericBox<>(); 처럼 타입을 생략할 수 있음
  • 이렇게 자바가 스스로 타입 정보를 추론하여 생략할 수 있는 것을 타입 추론이라고 함
  • 타입 추론은 그냥 되는 것은 아니고 읽을 수 있는 타입 정보가 주변에 존재해 자바 컴파일러가 타입을 추론할 수 있는 상황에만 가능함
GenericBox<Integer> integerBox = new GenericBox<Integer>(); // 타입 직접 입력
GenericBox<Integer> integerBox2 = new GenericBox<>();       // 타입 추론

4. 제네릭 용어와 관례

1) 제네릭 용어

(1-1) 제네릭의 핵심

  • 사용할 타입을 미리 결정하지 않는 것이 핵심
  • 클래스 내부에서 사용하는 타입을 클래스를 정의하는 시점에 결정하는 것이 아니라 실제 사용하는 생성 시점에 타입을 결정하는 것임
  • 쉽게 비유하면 메서드의 매개변수와 인자의 관계와 비슷함

(1-2) 메서드에 필요한 값을 메서드 정의 시점에 미리 결정

  • 메서드에 필요한 값을 이렇게 메서드 정의 시점에 미리 결정하게 되면 이 메서드는 오직 "hello"라는 값만 출력할 수 있으므로 재사용성이 떨어짐
void method1() {
    println("hello");
}

 

(1-3) 메서드에 필요한 값을 인자를 통해 매개변수로 전달해서 결정

  • 메서드에 필요한 값을 메서드를 정의하는 시점에 미리 결정하지 않고 매개변수를 지정하여 메서드를 사용할 때 원하는 값을 인자("hello", "hi")로 전달하여 메서드를 실제 사용하는 시점으로 미룰 수 있음
  • 이 메서드는 실행 시점에 얼마든지 다른 값을 받아서 처리할 수 있기 때문에 다양한 값을 처리할 수 있고, 재사용성이 크게 늘어남
void method2(String param) {
    println(param);
}

void main() {
    method2("hello");
    method2("hi");
}

 

(1-4) 메서드의 매개변수와 인자

  • 매개변수(Parameter): String param
  • 인자, 인수(Argument): arg
  • 메서드의 매개변수에 인자를 전달하여 메서드의 사용 값을 결정함
void method(String param) //매개변수

void main() {
    String arg = "hello";
    method(arg) //인수 전달
}

 

(2) 제네릭의 타입 매개변수와 타입 인자

  • 제네릭도 앞서 설명한 메서드의 매개변수와 인자의 관계와 비슷하게 작동하는데, 제네릭 클래스를 정의할 때 내부에서 사용할 타입을 미리 결정하지 않고 해당 클래스를 실제 사용하는 생성 시점에 내부에서 사용할 타입을 결정함
  • 차이가 있다면 메서드의 매개변수는 사용할 값에 대한 결정을 나중으로 미루는 것이지만, 제네릭의 타입 매개변수는 사용할 타입에 대한 결정을 나중으로 미루는 것
  • 정리하면 메서드매개변수인자를 전달하여 사용할 값을 결정하고, 제네릭 클래스타입 매개변수타입 인자를 전달해서 사용할 타입을 결정함
  • 제네릭에서 사용하는 용어도 매개변수, 인자의 용어를 그대로 가져다가 사용하지만 값이 아니라 타입을 결정하는 것이기 때문에 앞에 타입을 붙임
  • 타입 매개변수: GenericBox<T> 에서 T
  • 타입 인자: GenericBox<Integer>, GenericBox<String> 에서 Integer와 String
  • 제네릭 타입의 타입 매개변수 <T>에 타입 인자를 전달하여 제네릭의 사용타입을 결정하게 됨

(3) 용어 정리

  • 제네릭(Generic) 단어: 일반적인, 범용적인이라는 영어 단어로 특정 타입에 속한 것이 아니라 일반적으로, 범용적으로 사용할 수 있다는 뜻임
  • 제네릭 타입(Generic Type)
     - 클래스나 인터페이스를 정의할 때 타입 매개변수를 사용하는 것을 말하며 제네릭 클래스, 제네릭 인터페이스를 모두 합쳐서 제네릭 타입이라고 말함(타입은 클래스, 인터페이스, 기본형을 모두 합쳐서 부르는 말임)
    - 예를 들어 class GenericBox<T> { ... } 라고 되어있으면 GenericBox<T>가 제네릭 타입임
  • 타입 매개변수(Type Parameter): 제네릭 타입이나 메서드에서 사용되는 변수로 실제 타입으로 대체됨, GenericBox<T>에서 T가 타입 매개변수
  • 타입 인자(Type Argument): 제네릭 타입을 사용할 때 제공되는 실제 타입, GenericBox<Integer>에서 Integer가 타입 인자

2) 제네릭 관례

(1) 제네릭 명명 관례

  • 타입 매개변수는 일반적인 변수명처럼 소문자로 사용해도 문제는 없지만 일반적으로 대문자를 사용하고 용도에 맞는 단어의 첫글자를 사용하는 관례를 따름
  • 주로 아래의 키워드를 사용함
  • E - Element
  • K - Key
  • N - Number
  • T - Type
  • V - Value
  • S,U,V etc. - 2nd, 3rd, 4th types

(2) 제네릭 기타

  • class Data<K, V> { ... } 처럼 한번에 여러 타입 매개변수를 선언할 수 있음
  • 타입 인자로 기본형을 사용할 수 없음

3) 로 타입 - row type

(1) RawTypeMain

  • 제네릭 타입을 사용할 때는 항상 <>를 사용해서 사용시점에 원하는 타입을 지정해야 하지만, 아래의 예제처럼 지정하지 않아도 사용 가능한데, 이런것을 로 타입(raw type) 또는 원시 타입이라고 함
  • 이렇게 원시 타입을 사용하면 내부의 타입 매개변수가 Object로 사용된다고 이해하면 됨
  • 자바의 제네릭이 자바가 처음 등장할 때부터 있었던 것이 아니라 자바가 오랜기간 사용된 이후에 등장했기 때문에 제네릭이 없던 시적의 과거 코드와의 하위 호환이 필요했기 때문에 어쩔 수 없이 이런 로 타입을 지원하게 되었음
  • 즉, 하위 호환성을 위해서 존재하는 타입이기 때문에 로 타입(원시 타입)은 사용하지 말아야 하며 Object 타입을 사용해야 한다면 타입 인자로 GenericBox<Object>로 Object를 지정해서 사용하면 됨
package generic.ex1;

public class RawTypeMain {
    public static void main(String[] args) {
        GenericBox integerBox = new GenericBox();
//        GenericBox<Object> integerBox = new GenericBox<>();   // 권장

        integerBox.set(10);
        Integer result = (Integer) integerBox.get();
        System.out.println("result = " + result);
    }
}

5. 제네릭 활용 예제

1) 제네릭 활용 예제

(1) 구조

  • generic.animal이라는 별도의 패키지에 생성

(2) Animal

  • 이름, 크기 정보를 가지는 부모클래스로 toString()을 IDE 통해서 오버라이딩하였고 생성자와 게터가 있음
package generic.animal;

public class Animal {
    private String name;
    private int size;

    public Animal(String name, int size) {
        this.name = name;
        this.size = size;
    }

    public String getName() {
        return name;
    }

    public int getSize() {
        return size;
    }

    public void sound() {
        System.out.println("동물 울음 소리");
    }

    @Override
    public String toString() {
        return "Animal{" +
                "name='" + name + '\'' +
                ", size=" + size +
                '}';
    }
}

 

(3) Dog, Cat

  • Animal을 상속받고 sound()메서드를 각각 클래스에 맞게 오버라이딩
  • 부모 클래스에 정의된 생성자가 있기 때문에 맞춰서 super(name, size)를 호출함
package generic.animal;

public class Dog extends Animal {
    
    public Dog(String name, int size) {
        super(name, size);
    }

    @Override
    public void sound() {
        System.out.println("멍멍");
    }
}

public class Cat extends Animal {
    
    public Cat(String name, int size) {
        super(name, size);
    }

    @Override
    public void sound() {
        System.out.println("야옹");
    }
}

 

(4) Box<T>

  • 객체를 보관할 수 있는 제네릭 클래스
package generic.ex2;

public class Box<T> {
    private T value;

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

    public T get() {
        return value;
    }
}

 

(5) AnimalMain1

  • Box 제네릭 클래스의 타입 인자로 각각의 타입을 전달하여 생성하면 타입에 맞는 동물을 보관하고 꺼낼 수 있음
  • Box<Dog>, Box<Cat>, Box<Animal>로 각각 타입을 보관할 수 있음
package generic.ex2;

public class AnimalMain1 {
    public static void main(String[] args) {
        Animal animal = new Animal("동물", 0);
        Dog dog = new Dog("멍멍이", 100);
        Cat cat = new Cat("야옹이", 50);

        Box<Dog> dogBox = new Box<>();
        dogBox.set(dog);
        Dog findDog = dogBox.get();
        System.out.println("findDog = " + findDog);

        Box<Cat> catBox = new Box<>();
        catBox.set(cat);
        Cat findCat = catBox.get();
        System.out.println("findCat = " + findCat);

        Box<Animal> animalBox = new Box<>();
        animalBox.set(animal);
        Animal findAnimal = animalBox.get();
        System.out.println("findAnimal = " + findAnimal);
    }
}
/* 실행 결과
findDog = Animal{name='멍멍이', size=100}
findCat = Animal{name='야옹이', size=50}
findAnimal = Animal{name='동물', size=0}
*/

 

(6) AnimalMain2

  • 여기서 Box<Animal>인 경우 타입 매개변수 T에 타입 인자 Animal을 대입하면 set(Animal value)가 되므로 Animal()의 하위 타입인 Dog, Cat도 전달할 수 있음
  • 물론 꺼낼 때는 반환타입이 Animal이기 때문에 Animal타입으로만 꺼낼 수 있음
package generic.ex2;

public class AnimalMain2 {
    public static void main(String[] args) {
        Animal animal = new Animal("동물", 0);
        Dog dog = new Dog("멍멍이", 100);
        Cat cat = new Cat("야옹이", 50);

        Box<Animal> animalBox = new Box<>();
        animalBox.set(animal);
        animalBox.set(dog);
        animalBox.set(cat);
        Animal findAnimal = animalBox.get();
        System.out.println("findAnimal = " + findAnimal);
    }
}
/* 실행 결과
findAnimal = Animal{name='야옹이', size=50}
*/

 


6. 문제와 풀이

1) 제네릭 기본1

(1) 문제 설명

  • 다음 코드와 실행 결과를 참고하여 Container 클래스를 생성
  • Container 클래스는 제네릭을 사용해야 함
더보기
package generic.test.ex1;

public class ContainerTest {
    public static void main(String[] args) {
        Container<String> stringContainer = new Container<>();
        System.out.println("빈값 확인1: " + stringContainer.isEmpty());
        
        stringContainer.setItem("data1");
        System.out.println("저장 데이터: " + stringContainer.getItem());
        System.out.println("빈값 확인2: " + stringContainer.isEmpty());
        
        Container<Integer> integerContainer = new Container<>();
        integerContainer.setItem(10);
        System.out.println("저장 데이터: " + integerContainer.getItem());
    }
}

 

실행 결과

빈값 확인1: true

저장 데이터: data1

빈값 확인2: false

저장 데이터: 10

 

(2) 정답

더보기
package generic.test.ex1;

public class Container<T> {
    private T item;

    public void setItem(T item) {
        this.item = item;
    }

    public T getItem() {
        return item;
    }

    public boolean isEmpty() {
        return item == null;
    }
}

2) 제네릭 기본2

(1) 문제 설명

  • 다음 코드와 실행 결과를 참고하여 Pair 클래스를 생성
  • Pair 클래스는 제네릭을 사용해야 함
더보기
package generic.test.ex2;

public class PairTest {
    public static void main(String[] args) {
        Pair<Integer, String> pair1 = new Pair<>();
        pair1.setFirst(1);
        pair1.setSecond("data");
        System.out.println(pair1.getFirst());
        System.out.println(pair1.getSecond());
        System.out.println("pair1 = " + pair1);
        
        Pair<String, String> pair2 = new Pair<>();
        pair2.setFirst("key");
        pair2.setSecond("value");
        System.out.println(pair2.getFirst());
        System.out.println(pair2.getSecond());
        System.out.println("pair2 = " + pair2);
    }
}

 

실행 결과

1

data

pair1 = Pair{first=1, second=data}

key

value

pair2 = Pair{first=key, second=value}

 

(2) 정답

더보기
package generic.test.ex2;

public class Pair<K, V> {

    private K first;
    private V second;

    public K getFirst() {
        return first;
    }

    public void setFirst(K first) {
        this.first = first;
    }

    public V getSecond() {
        return second;
    }

    public void setSecond(V second) {
        this.second = second;
    }

    @Override
    public String toString() {
        return "Pair{" +
                "first=" + first +
                ", second=" + second +
                '}';
    }
}
728x90