제네릭(Generic) - 제네릭이 필요한 이유

Jackson·2024년 11월 5일
1

신입으로 취업을 준비하면서 108번의 면접을 보았는데, 살다라는 기업에서 처음으로 나에게 큰 임팩트를 주었다.

이 기술을 왜 사용했고, 다른 대안은 없었는가?
왜 이 언어를 사용했는가?
기능을 구현할 때 왜 이 문법을 사용했는가?

부트캠프에서 공부를 하면서 기술 익히기에 급급했던 과거의 나는 이 기업에서 면접을 보고나서 공부에 대한 태도를 바꿔야겠다는 생각이 들었다.

당시 "제네릭(Generic)을 왜 사용하셨나요?"에 대한 질문이 왔을 때 큰 충격이 있었는데,
지금 다시 자바 기본 문법부터 공부를 하다가 다시 만난 제네릭 !

"타입을 제한하기 위해서입니다"와 같은 단답에서 벗어나서 "why"에 초점을 맞춰서 내용을 정리해보려고 한다.


1. 만약 제네릭이 없다면?

  1. 숫자를 보관하고 꺼낼 수 있는 클래스
package generic;

public class IntegerBox {

    private Integer value;

    public void setValue(Integer value) {
        this.value = value;
    }

    public Integer getValue() {
        return value;
    }
}
  1. 문자열을 보관하고 꺼낼 수 있는 클래스
package generic;

public class StringBox {

    private String value;

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

    public String getValue() {
        return value;
    }
}
  1. 실행 로직
package generic;

public class BoxMain1 {

    public static void main(String[] args) {
        IntegerBox integerBox = new IntegerBox();
        integerBox.setValue(10); // Auto Boxing
        Integer integer = integerBox.getValue();
        System.out.println("integer = " + integer);


        StringBox stringBox = new StringBox();
        stringBox.setValue("Hello Java !");
        String string = stringBox.getValue();
        System.out.println("string = " + string);
    }
}
  1. 문제점

    이후에 Double, Boolean 등 다양한 타입의 박스가 필요하다면?

    각각의 타입별로 XxxBox 클래스를 새로 만들어야 한다.
    매우 비효율적이기 때문에 이 문제를 단계별로 해결해 나가보자.

다형성으로 중복 해결 시도

Object는 모든 타입의 부모이기 때문에, 다형성(다형적 참조)를 사용해서 해결해보자.

부모는 자식을 담을 수 있으므로 세상의 모든 타입을 ObjectBox에 담을 수 있다.

package generic;

public class ObjectBox {
    
    private Object value;

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

    public Object getValue() {
        return value;
    }
}
package generic;

public class BoxMain2 {
    public static void main(String[] args) {
        ObjectBox integerBox = new ObjectBox();
        integerBox.setValue(10);
//        Object integer = integerBox.getValue();
        Integer integer = (Integer) integerBox.getValue(); // Object -> Integer 캐스팅
        System.out.println("integer = " + integer);

        ObjectBox stringBox = new ObjectBox();
        stringBox.setValue("Hello Java !");
//        Object value = stringBox.getValue();
        String string = (String) stringBox.getValue(); // Object -> String 캐스팅
        System.out.println("value = " + string);

        // 잘못된 타입의 인수 전달
        integerBox.setValue("문자 10");
        Integer result = (Integer) integerBox.getValue();
        System.out.println("value = " + result);


    }
}

실행 결과

integer = 10
value = Hello Java !
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.BoxMain2.main(BoxMain2.java:19)

문제점

  1. 반환 타입이 맞지 않는 문제
// Object integer = integerBox.getValue();
Integer integer = (Integer) integerBox.getValue(); // Object -> Integer 캐스팅

Object 타입을 Integer 타입으로 직접 다운캐스팅 해야한다.

다운 캐스팅을하게 되면 몇가지 문제점을 신경써야 한다.
1. ClassCastException 발생 가능성
컴파일러는 다운 캐스팅이 가능하다고 단정할 수 없기 때문에, 런타임 시점에 검증을 해야한다.
// 잘못된 타입의 인수 전달의 경우 처럼, 다른 객체로 반환하려고 한다면 예외가 발생해 프로그램이 종료된다.
2. 타입 안정성
다운 캐스팅은 개발자가 직접 타입을 보장해주는 것과 마찬가지인데, 잘못된 타입이 저장되었을 때 문제를 사전에 감지할 수 있는 방법이 없다.
3. 불필요한 코드 복잡성
다운 캐스팅을 반복해서 사용하게 되면 코드가 불필요하게 복잡해지고, 각 다운 캐스팅에 대한 타입 확인 로직을 추가하는 등으로 가독성이 떨어질 수 있다.

  1. 잘못된 타입의 인수 전달
    개발자의 의도는 integerBox에는 숫자 타입이 입력되길 기대했는데, public void setValue(Object value) {...} 메서드는 Object를 매개변수로 받기 때문에 세상의 어떤 데이터도 입력받을 수 있다.
    문법적으로 아무런 문제가 되지 않기 때문에 컴파일 시점에 실수를 파악할 수 없고, 잘못된 입력값으로 잘못된 값이 출력되는 것도 문제다.

정리

다형성을 활용한 장점
1. 코드 중복 제거
2. 기존 코드를 재사용 (클래스를 계속해서 XxxBox로 만들지 않음)

문제점
1. 타입 안정성 문제 (실수로 원하지 않는 타입이 들어갈 수 있다.)
2. 데이터 반환 시점에 원하는 타입을 정확하게 받을 수 없다.
3. 위험한 다운 캐스팅을 시도해야 한다.

코드 재사용성을 늘리기 위해 Object의 다형성을 사용하면 타입 안정성이 떨어지는 문제가 발생한다.

  • 각각의 타입별로 XxxBox 형태로 클래스를 모두 정의하면
    • 코드 재사용 ❌
    • 타입 안정성 ⭕️
  • Object를 사용해서 다형성으로 하나의 클래스만 정의하면
    • 코드 재사용 ⭕️
    • 타입 안정성 ❌

두 마리 토끼를 다 잡을 수는 없을까???

제네릭(Generic) 적용

package generic.ex1;

public class GenericBox<T> {
    
    private T value;

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

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

  1. 생성 시점에 원하는 타입 지정
new GenericBox<Integer>()

제네릭 클래스는 생성하는 시점에 <> 사이에 원하는 타입을 지정한다.
이렇게 지정하면, GenericBox의 T가 다음과 같이 지정한 타입으로 변환한 다음 생성된다.(실제로 만들어지는 것은 아님 !)

  • T가 모두 Integer로 변한다. 따라서 Integer 타입을 입력하고 조회할 수 있다.
  • public void setValue(Integer value) {...}로 변경되므로 이 메서드에는 Integer 타입만 담을 수 있다.
  • public T getValue() {...} 메서드의 경우에도 Integer를 반환하기 때문에 타입 캐스팅 없이 숫자 타입으로 조회할 수 있다.
String string = stringBox.getValue(); // 캐스팅 X
  1. 원하는 모든 타입 사용 가능
  • 제네릭 클래스를 사용하면 다음과 같이 GenericBox 객체를 생성하는 시점에 원하는 타입을 지정할 수 있다.
new GenericBox<Double>()
new GenericBox<Boolean>()
new GenericBox<MyClass>()
  • 앞서 말한 것처럼, 제네릭을 도입한다고 해서 Generic, Generic 같은 코드가 실제로 만들어지는 것은 아니다.
  • 대신에 자바 컴파일러가 개발자가 입력한 타입 정보를 기반으로 이런 코드가 있다고 가정하고 컴파일 과정에 타입 정보를 반영한다.
  • 이 과정에서 타입이 맞지 않으면 컴파일 오류가 발생한다.
  1. 타입 추론
GenericBox<Integer> integerBox = new GenericBox<Integer>() // 타입 직접 입력

GenericBox<Integer> integerBox2 = new GenericBox<>() // 타입 추론
  • 자바는 왼쪽에 있는 변수를 선언할 때 를 보고 오른쪽에 있는 객체를 생성할 때 필요한 타입 정보를 얻을 수 있다.
    • 따라서 두번째 줄의 오른쪽 코드 new GenericBox<>();와 같이 타입 정보를 생략할 수 있다.
  • 이렇게 자바가 스스로 정보를 추론해서 개발자가 타입 정보를 생략할 수 있는 것을 타입 추론이라 한다.

    제네릭을 사용한 덕분에 코드 재사용타입 안정성이라는 두마리 토끼를 모두 잡을 수 있었다. 🐰🐰

제네릭의 핵심

제네릭의 핵심은 사용할 타입을 미리 결정하지 않는다는 점이다.

클래스 내부에서 사용하는 타입을 클래스를 정의하는 시점에 결정하는 것이 아니라, 실제 사용하는 생성 시점에 타입을 결정하는 것이다.

메서드의 매개변수와 인자의 관계와 비슷하다.

void hello() {
  System.out.println("Hello Java!");
}
  • 메서드에 필요한 값을 이렇게 메서드 정의 시점에 미리 결정하게 되면, 이 메서드는 오직 "Hello Java!"라는 값만 출력할 수 있다.
  • 따라서 재사용성이 떨어진다.

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

void hello(String param) { 
  println(param); 
}

void main() { 
  method2("Hello");
  method2("Java!"); 
}
  • 메서드에 필요한 값을 메서드를 정의하는 시점에 미리 결정하는 것이 아니라, 메서드를 실제 사용하는 시점으로 미룰 수 있다.
  • 메서드에 매개변수(String param)를 지정하고, 메서드를 사용할 때 원하는 값을 인자로 전달하면 된다.
  • 이렇게 하면 실행 시점에 얼마든지 다른 값을 받아서 처리할 수 있다.
  • 따라서 재사용성이 크게 늘어난다.

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

  • 제네릭 클래스를 정의할 때 내부에서 사용할 타입을 미리 결정하는 것이 아니라, 해당 클래스를 실제 사용하는 생성 시점에 내부에서 사용할 타입을 결정하는 것이다.
  • 메서드의 매개변수는 사용할 값에 대한 결정을 나중으로 미루는 것.
  • 제네릭의 타입 매개변수는 사용할 타입에 대한 결정을 나중으로 미루는 것.
  • 메서드는 매개변수인자를 전달해서 사용할 값을 결정한다.
  • 제네릭 클래스는 타입 매개변수타입 인자를 전달해서 사용할 타입을 결정한다.

용어 정리

  • 제네릭(Generic)

    • 일반적인, 범용적인
    • 특정 타입에 속한 것이 아니라, 일반적으로, 범용적으로 사용할 수 있다는 뜻
  • 타입 매개변수

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

    • GenericBox<Integer>
      • 여기에서 Integer타입 인자라 한다.
    • 제네릭 타입을 사용할 때 제공되는 실제 타입이다.
  • 제네릭 타입(Generic Type)

    • 클래스나 인터페이스를 정의할 때 타입 매개변수를 사용하는 것.
    • 제네릭 클래스, 제네릭 인터페이스를 모두 합쳐서 제네릭 타입이라 한다.
 ex)
  class GenericBox<T> {
  	private T t;
  }
  여기에서 GenericBox<T>를 제네릭 타입이라 한다.
profile
훌륭한 화가는 자기 그림이 마음에 들 때까지 붓을 놓지 않는다.

0개의 댓글

관련 채용 정보