JAVA

자바 제네릭 — 타입을 나중에 정하는 틀 만들기(GENERIC)

PANCO_IT
#자바#JAVA#제네릭#generic#타입

타입마다 클래스를 따로 만드는 경우와 제네릭 틀 하나로 해결하는 경우를 비교한 그림

제네릭은 속을 비워 둔 틀입니다. 무엇을 담을지는 쓸 때 정합니다.

호빵 가게를 코드로 옮긴다고 해봅시다. 팥호빵, 야채호빵, 피자호빵. 클래스를 세 개 만들어야 할까요. 안을 들여다보면 세 클래스가 거의 똑같습니다. 재료를 담고, 꺼내고, 이름을 출력하는 코드가 판박이죠. 하는 일은 같은데 다루는 타입만 다른 이 상황을 해결하는 것이 오늘의 주제, 제네릭입니다.

제네릭이 뭔가요?

제네릭(Generic)은 클래스나 메서드를 만들 때 타입을 비워 두었다가, 실제로 쓸 때 정하는 문법입니다. 비워 둔 자리는 꺾쇠 안에 알파벳 한 글자로 표시합니다.

쉽게 — 호빵 틀과 같습니다. 틀은 하나뿐이고, 그 틀에 팥을 넣으면 팥호빵, 야채를 넣으면 야채호빵이 나옵니다. 틀을 재료 수만큼 만들지 않죠. 제네릭이 바로 이 속을 비워 둔 틀입니다. 무엇을 채울지는 빵을 찍는 순간에 정합니다.

틀 만들고 재료 정하기

문법은 클래스 이름 뒤에 꺾쇠와 알파벳을 붙이는 것이 전부입니다.

class 호빵<T> {              // T = 아직 정하지 않은 타입
    private T 재료;

    호빵(T 재료) {
        this.재료 = 재료;
    }
    public T 꺼내기() {
        return 재료;
    }
}

여기서 T는 정해진 키워드가 아니라 자리를 표시하는 이름입니다. 아무 글자나 써도 되지만, 자바에서는 관례가 있습니다. 타입은 T(Type), 원소는 E(Element), 키와 값은 KV를 씁니다.

이제 쓸 때 재료를 정합니다.

호빵<> ob1 = new 호빵<>(new 팥());     // 팥호빵
호빵<야채> ob2 = new 호빵<>(new 야채());  // 야채호빵

뒤쪽 꺾쇠 안이 비어 있는 것을 눈여겨보세요. 앞에서 이미 타입을 밝혔으니 컴파일러가 알아서 채웁니다. 이 빈 꺾쇠를 다이아몬드 연산자라고 부릅니다.

제네릭 3단계: 틀 만들기, 재료 정하기, 컴파일러가 검사하기

그림 1. 틀을 만들고, 쓸 때 재료를 정하면, 잘못된 재료는 컴파일러가 실행 전에 막아 줍니다.

Object로 하면 안 되나요?

여기서 좋은 질문이 나옵니다. 모든 클래스의 부모인 Object로 받으면 아무 타입이나 담을 수 있는데, 왜 굳이 제네릭을 쓸까요.

class 호빵 {
    private Object 재료;
    public Object 꺼내기() { return 재료; }
}

호빵 ob = new 호빵(new 팥());
팥 p = (팥) ob.꺼내기();       // 꺼낼 때마다 형변환이 필요
야채 v = (야채) ob.꺼내기();   // 컴파일은 통과, 실행하면 터짐

두 가지가 불편합니다. 첫째, 꺼낼 때마다 형변환을 직접 써야 합니다. 둘째가 더 중요한데, 잘못된 형변환을 컴파일러가 못 잡습니다. 팥이 든 호빵을 야채로 꺼내겠다고 해도 컴파일은 조용히 통과하고, 실행하는 순간 ClassCastException이 터집니다.

제네릭을 쓰면 이 문제가 사라집니다. 타입을 미리 알려 줬으니 형변환이 필요 없고, 잘못 쓰면 실행 전에 컴파일러가 잡아냅니다. 1편에서 예외를 배웠지만, 애초에 예외가 날 일을 만들지 않는 것이 더 좋은 설계였죠. 제네릭이 바로 그 예방책입니다.

extends로 재료 범위 정하기

틀이 아무거나 다 받으면 곤란할 때가 있습니다. 호빵 틀에 민트초코를 넣겠다면 막아야죠. 타입 자리에 extends를 붙이면 범위를 제한할 수 있습니다.

class 호빵재료 { }
class extends 호빵재료 { }
class 야채 extends 호빵재료 { }
class 민트초코 { }                       // 호빵재료가 아님

class 호빵<T extends 호빵재료> { ... }   // 호빵재료의 자식만 허용

호빵<> ob1 = new 호빵<>(new 팥());       // OK
호빵<민트초코> ob2 = ...;                  // 컴파일 오류

JAVA2에서 배운 상속이 여기서 쓰입니다. 호빵재료를 부모로 두고 그 자식만 통과시키는 것이죠. 여기서 헷갈리기 쉬운 점 하나. 제네릭의 extends는 클래스 상속의 그것과 글자만 같을 뿐, 여기서는 "이 타입의 하위만 허용한다"는 범위 표시로 읽어야 합니다.

타입 자리는 여러 개도 됩니다

비워 두는 자리가 꼭 하나일 필요는 없습니다. 쉼표로 나열하면 됩니다.

class Couple<M extends Person, W extends Person> {
    private M 남자;
    private W 여자;
}

Couple<Man, Woman> c1 = new Couple<>(new Man("철수", 20), new Woman("영희", 20));

앞으로 배울 Map<K, V>가 정확히 이 구조입니다. 키 타입과 값 타입을 따로 정하는 틀이죠.

직접 실행해 보기

아래 코드를 돌려 보세요. 그다음 마지막 줄의 주석을 풀어 민트초코 호빵을 만들어 보시기 바랍니다. 실행되기 전에 컴파일러가 막아 주는 것을 직접 확인할 수 있습니다.

제네릭 호빵 틀
class Filling { }                       // 호빵 재료(부모)

class RedBean extends Filling {
  public String toString() { return "단팥"; }
}
class Veggie extends Filling {
  public String toString() { return "야채"; }
}
class MintChoco { }                     // 재료가 아님

class Bun<T extends Filling> {          // Filling의 자식만 허용
  private T filling;
  Bun(T filling) { this.filling = filling; }
  public T take() { return filling; }
  public String toString() { return "호빵 [재료=" + filling + "]"; }
}

public class Main {
  public static void main(String[] args) {
      Bun<RedBean> b1 = new Bun<>(new RedBean());
      Bun<Veggie> b2 = new Bun<>(new Veggie());

      System.out.println(b1);          // 호빵 [재료=단팥]
      System.out.println(b2);          // 호빵 [재료=야채]

      RedBean r = b1.take();           // 형변환 없이 바로 받음
      System.out.println("꺼낸 재료: " + r);

      // Bun<MintChoco> b3 = new Bun<>(new MintChoco());  // 주석을 풀면 컴파일 오류
  }
}

📱 코드 실행(실습)은 태블릿·PC에서 이용할 수 있어요. 모바일에서는 위 코드를 눈으로 따라가며 확인하세요.

단골 함정 — 기본형은 넣을 수 없습니다

제네릭 자리에는 클래스 타입만 올 수 있습니다. int, double 같은 기본형은 넣지 못합니다.

상자<int> a = new 상자<>();        // 컴파일 오류
상자<Integer> b = new 상자<>();    // 정상

대신 기본형을 감싼 클래스를 씁니다. intInteger, doubleDouble, charCharacter입니다. 이런 클래스를 래퍼 클래스라고 부르는데, 요즘 자바는 상자<Integer> b = new 상자<>(); b.넣기(10);처럼 숫자를 그냥 써도 알아서 변환해 주니 크게 불편하지는 않습니다.

한 장 정리

항목내용
제네릭타입을 비워 두고 쓸 때 정하는 문법
관례 이름T(타입), E(원소), K·V(키·값)
쓰는 이유형변환 불필요 + 잘못된 타입을 컴파일 때 차단
범위 제한<T extends 상위타입>으로 허용 범위 지정
다이아몬드new 호빵<>()처럼 뒤쪽 타입은 생략 가능
제약기본형 불가, 래퍼 클래스 사용

이것만 기억하면 됩니다. 제네릭은 속을 비워 둔 틀입니다. 타입만 다른 클래스를 여러 개 만들 필요가 없어지고, 무엇보다 잘못된 타입을 실행 전에 컴파일러가 막아 줍니다. 범위를 좁히고 싶으면 꺾쇠 안에 extends를 붙이면 됩니다.

미니 퀴즈

개념을 제대로 잡았는지 직접 풀어 봅니다. 정답은 눌러야 나옵니다.

미니 퀴즈

OX 2문제 + 4지선다 3문제


포켓코딩(JAVA3) 3편 · 예제 출처: 강사 개인 GitHub 저장소. 다음 편: 컬렉션 — List·Set·Map으로 데이터 담기.

PANCO_IT

IT 지식을 정리하는 개발자

관련 글