카테고리 없음

자바 공부#3_01. 제네릭(Generics)과 PECS 원칙

코딩연습중 2025. 4. 9. 16:28

제네릭(Generics) 이란?

자바에서 제네릭은 클래스 내부에서 사용할 데이터 타입을 나중에 확정하는 기법을 의미한다. 이는 클래스나 메서드를 선언할 때가 아니라 사용할 때, 즉 인스턴스를 생성할 때나 메서드를 호출할 때 정한다는 말이다.

List<String> list = new ArrayList<>();

 

위의 예시에서 <> 이 부분이 제네릭이다. 괄호 안에 타입을 명시하면 list의 데이터 타입을 String으로 지정할 수 있다.

 

만약 우리가 어떤 클래스 또는 메서드에 대해 다양한 타입을 지원하고 싶을 때, 제네릭을 사용하지 않는다면 두 가지 방법이 있다. Object 타입을 사용하는 방법과, 동일한 메커니즘을 가지는 클래스 및 메서드를 여러 개 정의하는 방법이다.

 Object를 사용한다면 코드 중복은 막을 수 있지만 객체의 타입을 컴파일 타임에 체크할 수 없다는 제약이 생긴다. 이로 인해 런타임에 타입으로 인한 오류가 생길 수 있다.

동일한 메커니즘을 갖는 클래스 및 메서드를 여러 개 정의하는 건 너무 비효율적인 방법이다.

이러한 문제를 해결하기 위해 제네릭을 사용한다.

 

제네릭의 장점

  • 잘못된 타입이 들어올 수 있는 것을 컴파일 단계에서 방지할 수 있다. → 타입 안정성 ↑, 형변환의 번거로움 ↓
  • 클래스 외부에서 타입을 지정해주기 때문에 따로 타입을 체크하고 변환해줄 필요가 없다. 즉, 관리하기가 편하다.
  • 비슷한 기능을 지원하는 경우 코드의 재사용성이 높아진다.
타입 안정성(type safety)
- 의도하지 않은 타입의 객체가 저장되는 것을 막는다.
- 저장된 객체를 꺼내올 때 다른 타입으로 잘못 형변환하여 발생할 수 있는 오류를 줄인다.

 

 


 

 

제네릭 타입 매개변수

제네릭의 <> 꺾쇠 괄호 안에 들어가는 타입 매개변수(타입 변수)는 제네릭을 이용한 클래스나 메서드를 설계할 때 사용된다. 주로 사용되는 타입들은 다음과 같다.

타입 매개변수 설명
<T> Type
<E> Element
<K> Key
<V> Value
<N> Number

 

위의 타입들은 암묵적인 규칙이라고 보면 된다.

 

 


 

 

제네릭 클래스

제네릭은 타입을 선언한 클래스를 제네릭 클래스라 한다.

// 제네릭 타입 한 개.
public class ClassName <T> {
    private T type;
    public T getType() {
    	return type;
    }    
    public void setType(T type) {
    	this.type = type;
    }
}

// 제네릭 타입 두 개
public class ClassName <T, K> {
    private T type;
    private K key;
    
    public T getType() {
    	return type;
    }    
    public void setType(T type) {
    	this.type = type;
    }
    public K getKey() {
    	return key;
    }    
    public void setKey(K key) {
    	this.key = key;
    }
}

// 제네릭 타입 두 개 예시: HashMap
public class HashMap <K, V> { ... }

 

이렇게 생성된 제네릭 클래스를 사용할 때에는, 즉 객체를 생성할 때에 구체적인 타입을 명시해주면 된다.

public class Main {
	public static void main(String[] args) {
    	// 1번.
    	ClassName<String, Integer> a = new ClassName<String, Integer>();
        // 2번.
    	ClassName<String, Integer> a = new ClassName<>();
        // 3번.
    	ClassName a = new ClassName();
    }
}

JDK 1.7부터 생성자의 <>에 타입을 생략할 수 있다. 따라서 2번과 같이 객체를 생성해도 문제가 없다. 

또한 3번과 같이 타입을 지정하지 않고도 객체를 생성할 수 있는데, 이 때에는 타입을 Object로 간주한다.

 

 


 

 

제네릭 메서드

제네릭 타입을 선언한 메서드를 제너릭 메서드라고 한다. 

클래스의 제네릭 타입이 전역 변수처럼 사용된다면 메서드의 제네릭 타입은 해당 메서드 안에서만 사용할 수 있는 지역성을 갖는다.

public class ClassName {
    public <T> T method(T type) {
    	...
    }
}

 

제네릭 타입을 메서드에 선언할 때는 접근제한자와 반환타입 사이에 선언한다.

ClassName a = new ClassName();
// 1번.
a.<String>method("string");
// 2번.
a.method("string");

제네릭 메서드 사용 시에는 위와 같이 메서드 명 앞에 타입을 지정해줘야 하짐나 컴파일러가 타입을 추정할 수 있는 경우에는 생략할 수 있다. 대부분의 경우 컴파일러가 타입을 추정할 수 있다.

 

하지만 제네릭을 사용할 때 주의할 점이 있다. 바로 정적 메서드로 선언할 때이다.

static 메서드는 객체가 생성되기 전에 이미 메모리에 올라가있어야하는데 다음과 같이 메서드에 타입을 따로 지정해주지 않으면 오류가 생긴다.

class ClassName<E> {
    /*
     * 클래스와 같은 E 타입이더라도
     * static 메소드는 객체가 생성되기 이전 시점에
     * 메모리에 먼저 올라가기 때문에
     * E 유형을 클래스로부터 얻어올 방법이 없다.
     */
    static E genericMethod01(E o) {	// error!
        return o;
    }
    // -> 아래와 같이 타입을 따로 명시해줘야 함
    // 아래 메서드의 E 타입은 제네릭 클래스의 E 타입과 다른 독립적인 타입이다.
    static <E> E genericMethod02(E o) {	// error!
		return o;
	}
	
}

class Main {

    public static void main(String[] args) { 
        // ClassName 객체가 생성되기 전에 접근할 수 있으나 유형을 지정할 방법이 없어 에러남
        ClassName.getnerMethod01(3);

        // 클래스 객체는 String으로 생성
        ClassName<String> a = new ClassName<String>();
        ClassName.genericMethod02(3).getClass().getName();	// Integer
        ClassName.genericMethod02("string").getClass().getName();	//String

    }
}

 

 


 

 

제네릭 제한

객체를 생성할 때 타입을 지정하는데, 어떤 타입이 올 수 있는지를 제한하지 않으면 정말 모든 타입이 올 수 있다. 따라서 extends, super 키워드를 통해 이를 문법적으로 제한할 수 있는 기능을 제공한다.

 

extends - 제네릭 상한 제한

extends는 해당 타입 또는 그 하위 타입만 허용하도록 제한하는 기능이다. 

class BoxMaterial { }
class Paper extends BoxMaterial { }
class Plastic extends BoxMaterial { }

public class Box<M extends BoxMaterial> {

    private M material;

    public static void main(String[] args) {

        Box<Water> waterBox = new Box<>();   // 불가. BoxMaterial과 자식 타입만 가능
        Box<Paper> paperBox = new Box<>();
        Box<Plastic> plasticBox = new Box<>();
    }
}

 

 

super- 제네릭 하한제한

super 키워드는 해당 타입과 그 상위 타입만 사용 가능하도록 제한할 때 사용한다.

class BoxMaterial { }
class Paper extends BoxMaterial { }
class Plastic extends BoxMaterial { }
class Waste extends Plastic { }

public class Box<T> {
    private T material;

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

    public T getMaterial() {
        return material;
    }
}

 

 


 

와일드 카드

와일드카드는 제네릭 타입에서 정확한 타입을 지정하지 않고 유연하게 사용하고 싶을 때 사용하는 특수한 문법이다. 쉽게 말해 '알 수 없는 타입'이라는 의미이다. 타입을 제한하거나 확장하면서도 다양한 타입을 받을 수 있는 장점이 있다. 사용 방법은 다음과 같다. 

 

1. <?> : 모든 타입 허용 (Unbounded Wildcard)

public void printList(List<?> list) {
    for (Object obj : list) {
        System.out.println(obj);
    }
}
  • <?>는 어떤 타입이든 올 수 있다. → 한마디로 Object (최상위 타입) 라고 생각하면 됨.
  • 단, 요소를 읽는 것만 가능하고 추가(insert)는 불가능하다 (컴파일 에러 발생).

 

2. <? extends T> : T와 T의 하위 타입만 허용 (Upper Bounded Wildcard)

public void printNumbers(List<? extends Number> list) {
    for (Number num : list) {
        System.out.println(num);
    }
}
  • 예: List<Integer>, List<Double> 모두 허용

 

3. <? super T> : T와 T의 상위 타입만 허용 (Lower Bounded Wildcard)

public void addIntegers(List<? super Integer> list) {
    list.add(1);
    list.add(2);
}
  • 예: List<Object>, List<Number> 허용
  • 값을 추가(insert) 할 수 있지만, 꺼낼 때는 Object 타입으로만 꺼낼 수 있음

 

와일드카드 vs 제네릭 타입 매개변수

와일드카드는 메서드 파라미터에만 사용하고,
제네릭 타입 매개변수는 클래스나 메서드 전체에서 타입을 직접 다룰 때 사용

 

 


 

PECS (Producer Extends, Consumer Super) 원칙

PECS는 제네릭을 사용할 때 변성(variance)를 적용하는 규칙이다.

1. Producer Extends (PE)

데이터를 읽을 때 extends를 사용해야 한다는 원칙이다. 즉, "생산자"는 extends를 사용한다는 것이다.

이렇게 하면 하위 타입의 객체를 읽을 때 유연성이 증가한다.

public <T> void processProducer(List<? extends T> producer) {
    for (T item : producer) {
        // Do something with item
    }
}

(받아 온 데이터를 이용해 뭔가를 produce 함)

 

예시)

List<? extends Fruit> basket = new ArrayList<Apple>();

Fruit f = basket.get(0);     // ✅ OK, 안전하게 읽을 수 있음
basket.add(new Apple());     // ❌ 안 됨 (어떤 구체적 타입인지 몰라서)

여기서 list는 List<Animal>이든 List<Dog>이든 List<Cat>이든 받아서
Animal 타입으로 안전하게 읽을 수 있다.

 

 

2. Consumer Super (CS)

데이터를 쓸 때 super을 사용해야 한다는 원칙이다. 즉, "소비자"는 super을 사용한다는 것이다.

이렇게 하면 타입의 객체를 안전하게 쓸 수 있다.

public <T> void processConsumer(List<? super T> consumer, T item) {
    consumer.add(item);
}

(받아 온 데이터에 나의 것을 consume 해서 무언가를 함)

 

예시)

List<? super Apple> basket = new ArrayList<Fruit>();

basket.add(new Apple());     // ✅ OK
basket.add(new FujiApple()); // ✅ OK (Apple의 하위 타입이니까)

Apple a = basket.get(0);     // ❌ 안 됨 (Object로만 나옴)

여기서 list는 List<Dog>, List<Animal>, List<Object> 전부 가능.
Dog를 안전하게 넣을 수 있는 list를 받고 싶은 경우 이렇게 선언한다.

 

 

PECS는 왜 필요한가?

타입 안정성을 보장하면서 유연하게 설계하기 위해 필요하다. 잘못된 타입이 들어가는 걸 컴파일 타임에 방지할 수 있다는 장점이 있다.

 

 

 

참고:

- https://inpa.tistory.com/entry/JAVA-%E2%98%95-%EC%A0%9C%EB%84%A4%EB%A6%ADGenerics-%EA%B0%9C%EB%85%90-%EB%AC%B8%EB%B2%95-%EC%A0%95%EB%B3%B5%ED%95%98%EA%B8%B0

- https://atoz-develop.tistory.com/entry/JAVA-%EC%A0%9C%EB%84%A4%EB%A6%ADGenerics-%ED%81%B4%EB%9E%98%EC%8A%A4%EC%99%80-%EB%A9%94%EC%86%8C%EB%93%9C

- https://st-lab.tistory.com/153

- https://velog.io/@user1/PECS

- https://sunba30.tistory.com/50

- https://dy-coding.tistory.com/entry/%EC%9E%90%EB%B0%94%EC%97%90%EC%84%9C-PECS%EB%9E%80