[Java ] 개념노트 53 제네릭 클래스와 제네릭 메서드 이해하기

[Java 개념노트 53] 제네릭 클래스와 제네릭 메서드 이해하기

안녕하세요. Java 개념노트 시리즈 쉰세 번째 글입니다.

지난 글에서는 타입을 미리 지정해서 안전하게 값을 다룰 수 있게 해주는 제네릭의 기본 개념을 정리했습니다. 이번 글에서는 제네릭을 직접 활용해서 제네릭 클래스제네릭 메서드를 만드는 방법을 알아보겠습니다.

지금까지는 ArrayList<String>, HashMap<String, Integer>처럼 이미 만들어진 컬렉션에서 제네릭을 사용했습니다. 이번에는 우리가 직접 만든 클래스와 메서드에 <T>, <K, V> 같은 타입 파라미터를 붙여보겠습니다.


1. 제네릭 클래스란?

제네릭 클래스는 클래스에서 사용할 타입을 나중에 정할 수 있도록 만든 클래스입니다.

일반 클래스는 필드 타입이나 메서드 매개변수 타입이 이미 정해져 있습니다. 반면 제네릭 클래스는 T 같은 타입 파라미터를 사용해서, 객체를 만들 때 실제 타입을 정할 수 있습니다.

쉽게 말하면
제네릭 클래스는 “어떤 타입을 담을지는 나중에 정하고, 구조는 미리 만들어두는 클래스”입니다.

2. 제네릭 클래스 기본 형태

제네릭 클래스는 클래스 이름 뒤에 꺾쇠괄호 < >를 붙이고, 그 안에 타입 파라미터를 작성합니다.

class 클래스이름<T> {
    private T value;

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

    public T getValue() {
        return value;
    }
}

여기서 T는 아직 정해지지 않은 타입입니다. 실제로 객체를 만들 때 String, Integer, Product 같은 타입으로 결정됩니다.

3. Box<T> 클래스 만들기

가장 기본적인 제네릭 클래스 예제로 Box<T>를 만들어보겠습니다. 이 클래스는 어떤 타입의 값이든 하나 담을 수 있는 상자 역할을 합니다.

class Box<T> {
    private T value;

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

    public T getValue() {
        return value;
    }
}

T는 필드 타입, 메서드 매개변수 타입, 반환 타입으로 모두 사용되었습니다. 즉, T가 실제 타입으로 결정되면 클래스 안의 T 부분이 그 타입처럼 동작합니다.

코드 의미
class Box<T> T라는 타입 파라미터를 가진 제네릭 클래스
private T value T 타입의 값을 저장하는 필드
setValue(T value) T 타입 값을 저장하는 메서드
T getValue() T 타입 값을 반환하는 메서드

4. Box<String> 사용하기

이제 Box<T>를 문자열 전용 상자로 사용해보겠습니다.

public class Main {
    public static void main(String[] args) {
        Box<String> box = new Box<>();

        box.setValue("Java");

        String value = box.getValue();

        System.out.println(value);
    }
}

class Box<T> {
    private T value;

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

    public T getValue() {
        return value;
    }
}

실행 결과는 다음과 같습니다.

Java

Box<String>으로 만들었기 때문에 이 상자의 TString처럼 동작합니다. 따라서 getValue()의 반환값도 String으로 받을 수 있습니다.

5. Box<Integer> 사용하기

같은 Box<T> 클래스를 이번에는 정수 전용 상자로 사용할 수 있습니다.

public class Main {
    public static void main(String[] args) {
        Box<Integer> box = new Box<>();

        box.setValue(100);

        Integer value = box.getValue();

        System.out.println(value);
    }
}

class Box<T> {
    private T value;

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

    public T getValue() {
        return value;
    }
}

실행 결과는 다음과 같습니다.

100

이번에는 Box<Integer>로 만들었기 때문에 TInteger처럼 동작합니다. 하나의 Box<T> 클래스를 여러 타입에 맞게 재사용할 수 있습니다.

핵심 포인트
제네릭 클래스는 클래스 구조는 하나만 만들고, 사용할 때 타입을 다르게 지정해서 재사용할 수 있습니다.

6. 제네릭 클래스의 타입은 객체를 만들 때 결정된다

제네릭 클래스의 타입 파라미터는 객체를 만들 때 실제 타입으로 결정됩니다.

Box<String> stringBox = new Box<>();
Box<Integer> integerBox = new Box<>();

위 코드에서 같은 Box 클래스를 사용했지만, 두 객체는 서로 다른 타입을 담는 상자처럼 사용됩니다.

객체 T의 의미
Box<String> TString처럼 동작
Box<Integer> TInteger처럼 동작
Box<Product> TProduct처럼 동작

7. 잘못된 타입은 넣을 수 없다

제네릭 클래스는 타입이 정해지면 다른 타입의 값을 넣을 수 없습니다.

Box<String> box = new Box<>();

box.setValue("Java");

// box.setValue(100); // 오류 발생

Box<String>은 문자열을 담는 상자입니다. 따라서 정수 100을 넣으려고 하면 컴파일 단계에서 오류가 발생합니다.

주의
제네릭은 잘못된 타입의 값이 들어가는 것을 컴파일 단계에서 막아줍니다. 이것이 제네릭의 큰 장점입니다.

8. 직접 만든 객체를 제네릭에 사용하기

제네릭 타입에는 직접 만든 클래스도 사용할 수 있습니다. 이번에는 Product 객체를 담는 Box를 만들어보겠습니다.

public class Main {
    public static void main(String[] args) {
        Box<Product> box = new Box<>();

        box.setValue(new Product("키보드", 50000));

        Product product = box.getValue();

        product.printInfo();
    }
}

class Box<T> {
    private T value;

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

    public T getValue() {
        return value;
    }
}

class Product {
    String name;
    int price;

    Product(String name, int price) {
        this.name = name;
        this.price = price;
    }

    void printInfo() {
        System.out.println(name + ": " + price + "원");
    }
}

실행 결과는 다음과 같습니다.

키보드: 50000원

Box<Product>로 만들었기 때문에 getValue()의 결과를 바로 Product 타입으로 받을 수 있습니다.

9. 타입 파라미터 이름

제네릭에서 사용하는 T, E, K, V 같은 문자를 타입 파라미터라고 합니다.

꼭 이 이름만 써야 하는 것은 아니지만, 자바에서는 관례적으로 많이 사용하는 이름이 있습니다.

타입 파라미터 자주 쓰는 의미 예시
T Type Box<T>
E Element List<E>
K Key Map<K, V>
V Value Map<K, V>
N Number 숫자 계열 타입 표현

10. 타입 파라미터가 두 개인 클래스

제네릭 클래스는 타입 파라미터를 하나만 가질 필요가 없습니다. 두 개 이상도 사용할 수 있습니다.

예를 들어 key와 value를 함께 저장하는 Pair<K, V> 클래스를 만들 수 있습니다.

class Pair<K, V> {
    private K key;
    private V value;

    public Pair(K key, V value) {
        this.key = key;
        this.value = value;
    }

    public K getKey() {
        return key;
    }

    public V getValue() {
        return value;
    }
}

여기서 K는 key 타입, V는 value 타입을 의미합니다.

11. Pair<K, V> 사용하기

위에서 만든 Pair<K, V> 클래스를 사용해보겠습니다.

public class Main {
    public static void main(String[] args) {
        Pair<String, Integer> score = new Pair<>("Java", 90);

        System.out.println(score.getKey());
        System.out.println(score.getValue());
    }
}

class Pair<K, V> {
    private K key;
    private V value;

    public Pair(K key, V value) {
        this.key = key;
        this.value = value;
    }

    public K getKey() {
        return key;
    }

    public V getValue() {
        return value;
    }
}

실행 결과는 다음과 같습니다.

Java
90

Pair<String, Integer>로 만들었기 때문에 KString, VInteger처럼 동작합니다.

12. 제네릭 클래스와 생성자

제네릭 클래스의 생성자에서도 타입 파라미터를 사용할 수 있습니다.

class Box<T> {
    private T value;

    public Box(T value) {
        this.value = value;
    }

    public T getValue() {
        return value;
    }
}

사용 예시는 다음과 같습니다.

public class Main {
    public static void main(String[] args) {
        Box<String> box = new Box<>("Java");

        System.out.println(box.getValue());
    }
}

class Box<T> {
    private T value;

    public Box(T value) {
        this.value = value;
    }

    public T getValue() {
        return value;
    }
}

실행 결과는 다음과 같습니다.

Java

13. 제네릭 메서드란?

제네릭 메서드는 메서드 안에서 사용할 타입을 메서드 호출 시점에 정할 수 있는 메서드입니다.

제네릭 클래스가 클래스 전체에서 타입 파라미터를 사용하는 구조라면, 제네릭 메서드는 특정 메서드에서만 타입 파라미터를 사용하는 구조입니다.

public static <T> void printValue(T value) {
    System.out.println(value);
}

반환타입 앞에 있는 <T>가 이 메서드에서 사용할 타입 파라미터를 선언하는 부분입니다.

14. 제네릭 메서드 기본 예제

제네릭 메서드를 사용하면 여러 타입의 값을 하나의 메서드로 처리할 수 있습니다.

public class Main {
    public static void main(String[] args) {
        printValue("Java");
        printValue(100);
        printValue(true);
    }

    public static <T> void printValue(T value) {
        System.out.println(value);
    }
}

실행 결과는 다음과 같습니다.

Java
100
true

문자열, 정수, boolean 값을 모두 같은 printValue() 메서드로 출력했습니다. 메서드를 호출할 때 전달된 값에 따라 T의 타입이 결정됩니다.

15. 제네릭 메서드의 반환 타입

제네릭 메서드는 타입 파라미터를 반환 타입으로 사용할 수도 있습니다.

public class Main {
    public static void main(String[] args) {
        String text = getValue("Java");
        Integer number = getValue(100);

        System.out.println(text);
        System.out.println(number);
    }

    public static <T> T getValue(T value) {
        return value;
    }
}

실행 결과는 다음과 같습니다.

Java
100

getValue("Java")를 호출하면 TString처럼 동작하고, getValue(100)을 호출하면 TInteger처럼 동작합니다.

16. 타입 파라미터가 두 개인 제네릭 메서드

제네릭 메서드도 타입 파라미터를 여러 개 사용할 수 있습니다.

public class Main {
    public static void main(String[] args) {
        printPair("Java", 90);
        printPair("Spring", true);
    }

    public static <K, V> void printPair(K key, V value) {
        System.out.println(key + ": " + value);
    }
}

실행 결과는 다음과 같습니다.

Java: 90
Spring: true

KV가 각각 전달된 값의 타입에 맞게 결정됩니다.

17. 제네릭 클래스와 제네릭 메서드의 차이

제네릭 클래스와 제네릭 메서드는 비슷해 보이지만 타입이 결정되는 범위가 다릅니다.

구분 제네릭 클래스 제네릭 메서드
타입 선언 위치 클래스 이름 뒤 반환타입 앞
타입 적용 범위 클래스 전체 해당 메서드 안
예시 class Box<T> public <T> void print(T value)
타입 결정 시점 객체 생성 시점 메서드 호출 시점

18. 일반 클래스 안에도 제네릭 메서드를 만들 수 있다

클래스 자체가 제네릭 클래스가 아니어도, 메서드만 제네릭으로 만들 수 있습니다.

public class Main {
    public static void main(String[] args) {
        Printer printer = new Printer();

        printer.print("Java");
        printer.print(100);
    }
}

class Printer {
    public <T> void print(T value) {
        System.out.println(value);
    }
}

실행 결과는 다음과 같습니다.

Java
100

Printer 클래스는 일반 클래스이지만, print() 메서드만 제네릭 메서드로 작성했습니다.

19. static 메서드와 제네릭

제네릭 클래스 안에서 static 메서드는 클래스의 타입 파라미터 T를 직접 사용할 수 없습니다.

잘못된 예

class Box<T> {
    // static void print(T value) { } // 오류 발생
}

T는 객체를 만들 때 결정되는 타입입니다. 하지만 static 메서드는 객체 없이 클래스 이름으로 호출될 수 있으므로, 클래스의 T를 바로 사용할 수 없습니다.

올바른 예

class Box<T> {
    public static <T> void print(T value) {
        System.out.println(value);
    }
}

static 메서드에서 제네릭을 사용하려면 메서드 자체에 <T>를 따로 선언해야 합니다.

주의
제네릭 클래스의 타입 파라미터는 객체 기준입니다. static 메서드에서 제네릭을 사용하려면 메서드 자체의 타입 파라미터를 따로 선언해야 합니다.

20. Object 방식과 제네릭 방식 비교

제네릭을 사용하지 않고 Object로도 여러 타입을 받을 수 있습니다. 하지만 값을 꺼낼 때 형변환이 필요하고, 잘못된 타입이 들어가도 컴파일 단계에서 막기 어렵습니다.

Object 방식

class ObjectBox {
    private Object value;

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

    public Object getValue() {
        return value;
    }
}
ObjectBox box = new ObjectBox();

box.setValue("Java");

String text = (String) box.getValue();

제네릭 방식

Box<String> box = new Box<>();

box.setValue("Java");

String text = box.getValue();

제네릭을 사용하면 타입이 이미 정해져 있기 때문에 값을 꺼낼 때 직접 형변환하지 않아도 됩니다.

21. 제네릭 전체 예제

이번에는 제네릭 클래스와 제네릭 메서드를 함께 사용하는 전체 예제를 보겠습니다.

public class Main {
    public static void main(String[] args) {
        Box<String> textBox = new Box<>("Java");
        Box<Integer> numberBox = new Box<> 100);

        printBox(textBox);
        printBox(numberBox);

        Pair<String, Integer> score = new Pair<>("Spring", 85);

        printPair(score.getKey(), score.getValue());
    }

    public static <T> void printBox(Box<T> box) {
        System.out.println("Box 값: " + box.getValue());
    }

    public static <K, V> void printPair(K key, V value) {
        System.out.println(key + ": " + value);
    }
}

class Box<T> {
    private T value;

    public Box(T value) {
        this.value = value;
    }

    public T getValue() {
        return value;
    }
}

class Pair<K, V> {
    private K key;
    private V value;

    public Pair(K key, V value) {
        this.key = key;
        this.value = value;
    }

    public K getKey() {
        return key;
    }

    public V getValue() {
        return value;
    }
}

위 코드에는 한 줄 오타가 있습니다. new Box<> 100) 부분은 괄호가 빠졌습니다. 올바른 코드는 다음과 같습니다.

public class Main {
    public static void main(String[] args) {
        Box<String> textBox = new Box<>("Java");
        Box<Integer> numberBox = new Box<>(100);

        printBox(textBox);
        printBox(numberBox);

        Pair<String, Integer> score = new Pair<>("Spring", 85);

        printPair(score.getKey(), score.getValue());
    }

    public static <T> void printBox(Box<T> box) {
        System.out.println("Box 값: " + box.getValue());
    }

    public static <K, V> void printPair(K key, V value) {
        System.out.println(key + ": " + value);
    }
}

class Box<T> {
    private T value;

    public Box(T value) {
        this.value = value;
    }

    public T getValue() {
        return value;
    }
}

class Pair<K, V> {
    private K key;
    private V value;

    public Pair(K key, V value) {
        this.key = key;
        this.value = value;
    }

    public K getKey() {
        return key;
    }

    public V getValue() {
        return value;
    }
}

실행 결과는 다음과 같습니다.

Box 값: Java
Box 값: 100
Spring: 85

Box<T>는 제네릭 클래스이고, printBox()printPair()는 제네릭 메서드입니다. 여러 타입의 값을 하나의 구조로 안전하게 처리할 수 있습니다.

22. 제네릭 클래스와 메서드 사용 시 자주 하는 실수

제네릭 클래스와 제네릭 메서드를 처음 배울 때는 아래와 같은 실수를 자주 합니다.

실수 문제점 해결 방법
타입 파라미터 선언 위치를 헷갈림 제네릭 클래스와 메서드 문법 혼동 클래스는 클래스명 뒤, 메서드는 반환타입 앞
기본 자료형을 제네릭 타입으로 사용 Box<int>는 사용할 수 없음 Box<Integer> 사용
static 메서드에서 클래스의 T를 직접 사용 객체 생성 전 타입이 정해지지 않음 static 제네릭 메서드는 자체 <T> 선언
제네릭을 Object와 완전히 같다고 생각 타입 안정성과 형변환 차이를 놓침 제네릭은 타입을 미리 정해 안전하게 사용
복잡한 제네릭을 바깥부터 읽음 타입 구조가 더 어렵게 느껴짐 안쪽 타입부터 차례대로 읽기

23. 직접 연습해보기

아래 코드를 직접 작성하고 실행해보세요.

public class Main {
    public static void main(String[] args) {
        Storage<String> textStorage = new Storage<>("Java 개념노트");
        Storage<Product> productStorage = new Storage<>(new Product("키보드", 50000));

        printStorage(textStorage);
        printStorage(productStorage);

        Result<String, Boolean> loginResult = new Result<>("로그인 성공", true);

        printResult(loginResult.getMessage(), loginResult.getSuccess());
    }

    public static <T> void printStorage(Storage<T> storage) {
        System.out.println("저장된 값: " + storage.getData());
    }

    public static <M, S> void printResult(M message, S success) {
        System.out.println(message + " / 결과: " + success);
    }
}

class Storage<T> {
    private T data;

    public Storage(T data) {
        this.data = data;
    }

    public T getData() {
        return data;
    }
}

class Result<M, S> {
    private M message;
    private S success;

    public Result(M message, S success) {
        this.message = message;
        this.success = success;
    }

    public M getMessage() {
        return message;
    }

    public S getSuccess() {
        return success;
    }
}

class Product {
    private String name;
    private int price;

    public Product(String name, int price) {
        this.name = name;
        this.price = price;
    }

    @Override
    public String toString() {
        return name + ": " + price + "원";
    }
}

실행 결과는 다음과 같습니다.

저장된 값: Java 개념노트
저장된 값: 키보드: 50000원
로그인 성공 / 결과: true

24. 이번 글 정리

이번 글에서는 직접 타입 파라미터를 사용하는 제네릭 클래스제네릭 메서드에 대해 정리했습니다. 핵심 내용은 다음과 같습니다.

  • 제네릭 클래스는 클래스에서 사용할 타입을 나중에 정할 수 있는 클래스이다.
  • 제네릭 클래스는 클래스 이름 뒤에 <T>를 붙여 만든다.
  • T는 필드 타입, 매개변수 타입, 반환 타입으로 사용할 수 있다.
  • 제네릭 클래스의 실제 타입은 객체를 만들 때 결정된다.
  • 하나의 제네릭 클래스를 여러 타입에 맞게 재사용할 수 있다.
  • 타입 파라미터는 T, E, K, V 같은 이름을 자주 사용한다.
  • 타입 파라미터는 두 개 이상 사용할 수도 있다.
  • 제네릭 메서드는 메서드에서 사용할 타입을 호출 시점에 정할 수 있는 메서드이다.
  • 제네릭 메서드는 반환타입 앞에 <T>를 작성한다.
  • 제네릭 클래스가 아니어도 제네릭 메서드를 만들 수 있다.
  • static 메서드에서 제네릭을 쓰려면 메서드 자체에 타입 파라미터를 선언해야 한다.
  • 제네릭을 사용하면 Object 방식보다 타입 안정성이 높고 형변환을 줄일 수 있다.

한 줄 요약
제네릭 클래스와 제네릭 메서드는 사용할 타입을 나중에 정할 수 있게 만들어 코드 재사용성과 타입 안정성을 함께 높여주는 문법입니다.

다음 글 예고
다음 글에서는 [Java 개념노트 54] 제한된 제네릭과 와일드카드 이해하기라는 주제로 <T extends Number>, <?>, <? extends 타입> 같은 문법을 정리해보겠습니다.

GWDEVELBlog Java 개념노트 시리즈