CodeGym /행동 /JAVA 25 SELF /제네릭 소개

제네릭 소개

JAVA 25 SELF
레벨 16 , 레슨 4
사용 가능

1. 소개

무엇이든 넣을 수 있는 만능 상자를 하나 가지고 있다고 상상해 보세요. 사과, 책, 심지어 장난감도 넣을 수 있습니다. Java에서 이런 “만능 상자”는 가장 일반적인 타입인 Object를 담는 클래스입니다. 이 타입은 Java의 모든 클래스의 부모이므로 Object 타입 변수에는 어떤 객체든 담을 수 있습니다.

이제 그런 상자들로 가득한 창고를 떠올려 보세요. 상자에 라벨이 없다면 무엇이든 넣을 수 있지만, 꺼낼 때가 되면 상자를 열어 안에 무엇이 들어 있는지 일일이 확인해야 합니다. 제네릭은 “사과만”, “책만”, “도구만” 같은 깔끔한 라벨이 붙은 창고와 비슷합니다. 각 상자에 무엇이 들어 있는지 항상 알고, 실수로 사과 상자에 책을 넣는 일을 막을 수 있습니다.

겉보기에는 Object에 저장하는 것이 편리해 보입니다. 서로 다른 데이터 타입마다 별도의 클래스를 만들 필요가 없기 때문이죠. 하지만 실제로는 이런 “범용성”이 문제를 일으킵니다:

  1. 실수하기 쉽습니다. 의도와 다른 객체를 상자에 잘못 넣을 수 있습니다.
  2. 컴파일러가 문제를 발견하지 못합니다. Object 타입이라 무엇이든 넣도록 허용하기 때문입니다.
  3. 직접 “꺼내서 캐스팅”해야 합니다. 상자에서 꺼낸 객체는 다시 Object 타입이므로 원하는 타입으로 직접 캐스팅해야 합니다(이를 타입 캐스팅 또는 cast라고 합니다). 만약 타입을 잘못 캐스팅하면 프로그램이 오류와 함께 바로 종료됩니다!

간단한 예제로 확인해 봅시다.

class Box {
    private Object value;

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

    public Object get() {
        return value;
    }
}

이제 이 상자를 사용해 봅시다:

Box box = new Box();
box.set("안녕"); // 문자열을 넣었다
String s = (String) box.get(); // 문자열을 꺼냈다, 문제 없음

box.set(123); // 숫자를 넣었다
// 컴파일러는 문제를 보지 못한다...
String t = (String) box.get(); // 프로그램 실행 중 오류!

보시다시피, 컴파일러는 결국 오류로 이어질 코드를 조용히 통과시켰습니다. 우리는 프로그램을 실행해 프로그램이 “터질” 때가 되어서야 문제를 알게 됩니다.

2. 해결책 — 제네릭

Generics(제네릭)은 이 문제를 해결하기 위한 방법입니다. 컴파일 단계에서 클래스나 메서드를 특정 데이터 타입에 결합할 수 있게 해 주는 특별한 문법이죠. 쉽게 말해, 상자에 붙은 라벨처럼 “이 상자에는 문자열만 들어갈 수 있다” 또는 “이 상자에는 숫자만 들어갈 수 있다”고 선언하는 것입니다.

이렇게 하면 타입 안전성을 얻습니다. 컴파일러가 “상자”에 잘못된 객체를 넣지 못하도록 막아 줍니다. 코드를 검사해 프로그램을 실행하기 전에 오류를 알려 줍니다.

같은 Box 클래스이지만, 이제 제네릭을 사용해 봅시다:

class Box<Type> {
    private Type value;

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

    public Type get() {
        return value;
    }
}

여기서 Type타입 매개변수입니다. 우리가 임의로 정하는 이름이며(보통 T, E, K, V 등을 사용), “Box를 만들 때 어떤 타입으로 동작할지 지정할 것이고, 코드에서 Type이라고 쓰인 곳은 그 타입으로 대체된다”는 의미입니다.

3. Generics 사용법

제네릭이 있는 클래스의 객체를 만들 때는 꺾쇠 괄호 <...> 안에 구체적인 타입을 지정합니다.

// 문자열만 다루는 상자를 만들었다
Box<String> stringBox = new Box<>();
stringBox.set("안녕, 세계!"); // OK, 문자열을 넣었다
String s = stringBox.get(); // 캐스팅 없이 문자열을 꺼냈다

stringBox.set(123); // 컴파일 오류! 컴파일러가 허용하지 않는다.

Box<Integer>를 만들면, 컴파일러는 숫자만 들어가도록 보장합니다:

// 숫자만 다루는 상자를 만들었다
Box<Integer> intBox = new Box<>();
intBox.set(42); // OK, 숫자를 넣었다
Integer number = intBox.get(); // 캐스팅 없이 숫자를 꺼냈다

intBox.set("안녕"); // 컴파일 오류!

이제 컴파일러는 각 상자에 어떤 타입의 데이터가 들어가야 하는지 정확히 알고, 우리를 오류로부터 든든히 보호해 줍니다.

4. 작동 방식 예제

Generics는 클래스뿐만 아니라 메서드에도 사용할 수 있습니다. 이를 통해 매우 유연하고 범용적인 코드를 작성할 수 있습니다.

예제 1 — 제네릭을 사용하는 클래스

예를 들어, 같은 타입의 두 객체를 보관하는 Pair 클래스가 필요하다고 해 봅시다. 제네릭을 사용하면 다음과 같습니다:

class Pair<T> {
    private T first;
    private T second;

    public Pair(T first, T second) {
        this.first = first;
        this.second = second;
    }

    public T getFirst() {
        return first;
    }

    public T getSecond() {
        return second;
    }
}

사용 예:

Pair<String> greetings = new Pair<String>("안녕", "세계");
System.out.println(greetings.getFirst() + " " + greetings.getSecond());

Pair<Integer> numbers = new Pair<Integer>(10, 20);
System.out.println(numbers.getFirst() + numbers.getSecond());

첫 번째 경우 greetings에는 문자열만, 두 번째 경우에는 숫자만 들어 있음을 컴파일러가 보장합니다.

예제 2 — 범용 메서드

어떤 타입의 데이터와도 동작하는 메서드를 작성할 수 있습니다.

class Utils {
    // void 앞의 <T>는 이 메서드가 타입 매개변수 T와 함께 동작함을 의미한다
    public static <T> void printTwice(T value) {
        System.out.println(value);
        System.out.println(value);
    }
}

이제 이 메서드는 어떤 타입과 함께 호출해도 동일하게 동작합니다:

Utils.printTwice("Java");
Utils.printTwice(123);
Utils.printTwice(3.14);

5. 제네릭의 장점

Generics는 단순한 문법 설탕이 아니라, 개발에서 실제 문제를 해결하는 강력한 도구입니다. 세 가지 핵심 장점을 살펴보겠습니다.

타입 안전성이 제네릭의 가장 큰 장점입니다. 제네릭이 없으면 컴파일러는 “범용” 클래스와 메서드에서 타입을 올바르게 사용하는지 확인할 수 없습니다. “숫자 전용 상자”에 문자열을 넣으려는 시도 같은 오류는 프로그램이 실행 중일 때, ClassCastException 같은 예외와 함께 갑자기 “터질” 때 발견됩니다. 제네릭을 사용하면 컴파일러가 타입을 엄격하게 검사합니다. Box<String>에는 문자열만, Box<Integer>에는 숫자만 넣도록 확인합니다. 잘못된 작업을 시도하면 즉시 오류를 알려 주므로, 프로그램을 실행하기 전에 수정할 수 있습니다. 이는 코드를 훨씬 더 신뢰할 수 있고 예측 가능하게 만듭니다.

코드의 간결함(불필요한 캐스팅 제거). 제네릭이 없는 “만능 상자” 코드를 떠올려 보세요: Box box = new Box(); box.set("안녕"); String s = (String) box.get(); 상자에서 객체를 꺼낼 때마다 (String), (Integer)처럼 캐스팅을 직접 써야 했습니다. 제네릭을 사용하면 이 필요가 사라집니다. 컴파일러가 이미 내부 타입을 알고 자동으로 맞춰 줍니다: Box<String> stringBox = new Box<>(); stringBox.set("안녕"); String s = stringBox.get(); 코드는 더 짧아질 뿐 아니라 가독성과 사용성이 크게 향상됩니다.

유연성과 코드 재사용. Generics를 사용하면 타입 안전성을 잃지 않으면서 서로 다른 데이터 타입과 동작하는 진정한 범용 클래스와 메서드를 만들 수 있습니다. 예를 들어 Box<T> 클래스는 문자열, 숫자, 여러분이 만든 클래스(Student, Car) 등 무엇이든 저장하는 데 사용할 수 있습니다! StringBox, IntegerBox, StudentBox 같은 별도의 클래스를 작성할 필요가 없습니다. 범용 클래스 Box<T> 하나만 작성하고 객체를 만들 때 필요한 타입만 지정하면 됩니다. 이렇게 하면 코드 중복을 줄이고, 프로그램을 더 모듈화하고 유연하게 만들 수 있습니다.

6. 제네릭의 제약

여러 장점에도 불구하고, 제네릭에는 알아두어야 할 몇 가지 중요한 제약이 있습니다.

원시 타입(primitives). int, double, boolean 등과 같은 원시 타입은 타입 매개변수로 사용할 수 없습니다. 예를 들어 Box<int> intBox = new Box<>(); 코드는 컴파일 오류가 납니다. 대신 “래퍼 클래스”(Integer, Double, Boolean)를 사용해야 합니다. Box<Integer> intBox = new Box<>();. Java 컴파일러는 원시 타입과 래퍼 클래스 간을 자동으로 변환할 줄 압니다(intInteger) — 이 메커니즘을 오토박싱/언박싱 (autoboxing/unboxing)이라고 합니다.

타입 소거(Type Erasure). 이는 Java에서 제네릭이 구현된 핵심 특징입니다. 아이디어는 Java 컴파일러가 제네릭 정보를 컴파일 시점에만 사용한다는 것입니다. 타입 안전성을 확인한 뒤에는 타입 매개변수에 대한 모든 정보를 소거합니다. 예를 들어, Box<String>Box<Integer>는 컴파일된 코드(바이트코드)에서 단순히 Box로 보입니다. 즉, Java 가상 머신(JVM) 입장에서는 동일한 타입이 됩니다.

이것이 의미하는 바는 다음과 같습니다. new Box<String>[10]처럼 제네릭 배열을 만들 수 없고, instanceof와 제네릭을 함께 사용해 객체가 instanceof Box<String>인지 검사할 수도 없습니다. 더 깊이 있는 주제이지만, Java를 제대로 이해하려면 중요합니다. 지금은 제네릭이 본질적으로 컴파일러를 위한 “라벨”이며, 프로그램이 실행 준비를 마칠 때 그 라벨이 제거된다는 점만 기억하면 충분합니다.

1
설문조사/퀴즈
중첩 클래스와 내부 클래스, 레벨 16, 레슨 4
사용 불가능
중첩 클래스와 내부 클래스
중첩 클래스와 내부 클래스
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION