이펙티브 자바 완벽 공략 2부
아이템 26. 로 타입은 사용하지 말라.
아이템 26. 로 타입은 사용하지 말라
- 아이템 26. 로 타입은 사용하지 말라
- 아이템 26. 핵심 정리 - 제네릭 용어 정리
- 아이템 26. 로 타입은 사용하지 말라
- 제네릭이 없던 코드부터 살펴보자
- Raw Type에서는 사실상 타입 안전성을 잃는다
- 버그가 발생한 위치와 발견한 위치가 달라진다
- 제네릭을 적용해보자
- 런타임보다 컴파일 타임에 실패하는 것이 좋다
- 형변환도 필요 없어진다
- 제네릭 용어를 이해해보자
- Generic Type
- 타입 매개변수 Type Parameter
- 반드시 E라는 이름을 써야 하는 것은 아니다
- 실제 타입 인수 Type Argument
Box<Integer>는 매개변수화 타입이다- List를 기준으로 다시 보면
- 타입 매개변수가 실제 타입으로 결정되면
- 그래서 잘못된 타입을 넣을 수 없다
- 한정적 타입 매개변수
- Integer는 사용할 수 있다
- String은 사용할 수 없다
- extends는 클래스 상속에만 사용하는 것이 아니다
- 와일드카드 타입
- 비한정적 와일드카드
- 왜
Box<?>가 필요한가? - Raw Type과
Box<?>는 다르다 - 와일드카드에서는 아무 값이나 넣을 수 없다
- 와일드카드는 읽는 용도에서 유용하다
- 특정 타입의 Box만 받고 싶다면
Box<Integer>는Box<Object>의 하위 타입이 아니다- 왜
Box<Integer>를Box<Object>로 받을 수 없을까? - 이 특성을 불공변이라고 이해할 수 있다
- 아무 Box나 받고 싶다면 Object가 아니라 ?를 사용한다
Box<Object>와Box<?>비교- 한정적 와일드카드
- extends를 사용한 와일드카드
- 타입 매개변수와 와일드카드를 구분하자
- 제네릭 용어를 한 번에 정리해보자
- 표로 정리하면
- Raw Type과 Object 타입을 혼동하면 안 된다
- Raw Type은 타입 시스템을 우회한다
- 제네릭은 코드의 의도를 타입으로 표현한다
- 제네릭을 사용하는 이유를 한 흐름으로 정리하면
- 제네릭에서 가장 먼저 기억해야 할 세 가지
- 핵심 정리
- 한 줄 정리
- 아이템 26. 핵심 정리 - 로(raw) 타입은 사용하지 말라.
- 아이템 26. 로 타입은 사용하지 말라
- 제네릭의 첫 번째 장점은 타입 안전성이다
- 오류는 가능한 한 빨리 발견하는 것이 좋다
- 제네릭의 두 번째 장점은 표현력이다
- 타입이 주석 역할까지 수행한다
- 그렇다면 Java는 왜 Raw Type을 없애지 않았을까?
- 제네릭과 타입 소거
- 컴파일러가 형변환을 대신 넣어준다
- 타입 소거를 너무 단순하게 이해하면 안 된다
List와List<Object>는 다르다- Raw List는 타입 검사를 우회할 수 있다
- 문제는 넣을 때가 아니라 꺼낼 때 터진다
List<Object>로 바꾸면 어떻게 될까?- 왜
List<String>이List<Object>가 될 수 없을까? - Raw Type과
List<Object>의 가장 큰 차이는 안전성이다 - 가장 범용적인 제네릭 타입이 필요하면 와일드카드를 사용한다
Set<?>를 사용하자Set<?>의 의미Set과Set<?>는 둘 다 여러 종류의 Set을 받을 수 있다- Raw Set에는 아무 값이나 넣을 수 있다
Set<?>에는 구체적인 값을 넣을 수 없다- null은 예외다
- Raw Type은 자유롭지만 위험하다
- 모른다는 사실을 타입으로 표현한다
- Raw Type과 비한정적 와일드카드 비교
List<Object>와List<?>차이도 같은 원리다- List
List<Object>List<?>- 세 타입을 비교하면
- Raw Type을 사용해야 하는 예외가 있다
- 매개변수화 타입의 클래스 리터럴은 사용할 수 없다
- 왜
List<String>.class가 없을까? - instanceof에서도 타입 소거를 고려해야 한다
- 비한정적 와일드카드는 instanceof에 사용할 수 있다
- instanceof 이후에도 Raw Type을 계속 사용할 필요는 없다
- Raw Type 사용 여부를 판단하는 기준
- 타입 안전성과 표현력이라는 기준으로 다시 돌아가자
- 타입 안전성 관점
- 표현력 관점
- 제네릭은 제약을 만드는 것이 아니라 안전한 자유를 제공한다
- 제네릭 코드를 볼 때 생각할 세 가지
- 핵심 정리
- 한 줄 정리
- 아이템 26. 완벽 공략 - GenericRepository
- 아이템 26 완벽 공략: 제네릭 Repository로 중복 코드 제거하기
- 중복되는 Repository 코드
- AccountRepository를 만든다
- MessageRepository도 만든다
- 무엇이 다른지 찾아보자
- 타입 이름만 바꾸는 복사 붙여넣기
- 복사 붙여넣기 코드의 더 큰 문제
- 중복 코드는 유지보수 비용으로 돌아온다
- GenericRepository를 만들어보자
- E에는 getId()가 있다는 보장이 없다
- 타입 매개변수의 범위를 제한한다
- 컴파일러도 E가 Entity라는 것을 알게 된다
- 완성된 GenericRepository
- AccountRepository 코드가 크게 줄어든다
- MessageRepository도 동일하다
- 타입 안전성도 유지된다
- 제네릭이 없었다면 Object를 사용할 수도 있다
- 제네릭은 재사용성과 타입 안전성을 함께 가져간다
- 한정적 타입 매개변수가 중요한 이유
- 제네릭 타입의 경계는 공통 기능을 결정한다
- 새로운 Entity를 추가해보자
- 엔티티가 늘어날수록 효과가 커진다
- 생산성의 차이는 새로운 타입을 추가할 때 드러난다
- 공통 버그 수정도 한 곳에서 가능하다
- 제네릭 Repository를 만들기 전 테스트가 중요하다
- 리팩터링 전후에 동일한 테스트를 통과해야 한다
- 공통화를 너무 성급하게 하면 안 된다
- 공통 로직과 개별 로직을 분리할 수 있다
- 제네릭을 활용할 수 있는 다른 사례
- 백업 시스템
- Excel 다운로드
- Reflection과 함께 활용할 수도 있다
- 제네릭을 적용할 수 있는 신호
- 하지만 모든 중복을 제네릭으로 없애는 것은 아니다
- 제네릭은 중복 코드를 제거하기 위한 타입 수준의 추상화다
- 상속과 제네릭의 역할을 구분하자
- 제네릭이 주는 생산성의 본질
- 기존 방식과 제네릭 방식 비교
- 핵심 정리
- 한 줄 정리
- 아이템 26. 핵심 정리 - 제네릭 용어 정리
아이템 26. 핵심 정리 - 제네릭 용어 정리
아이템 26. 로 타입은 사용하지 말라
Java 5부터 제네릭(Generic)이 도입되었다.
제네릭은 처음 접하면 문법이 복잡하게 느껴질 수 있다.
List<String>
Map<String, Integer>
Box<? extends Number>
List<? super Integer>
<T>, <?>, extends, super 같은 문법이 한꺼번에 등장하기 때문이다.
하지만 제네릭이 Java에 도입된 가장 중요한 목적은 오히려 개발자가 더 편리하고 더 안전하게 코드를 작성하도록 돕는 것이다.
특히 제네릭을 사용하면 다음 두 가지 장점을 얻을 수 있다.
잘못된 타입의 데이터가 들어오는 것을
컴파일 시점에 차단할 수 있다.
+
객체를 꺼낼 때
불필요한 형변환을 줄일 수 있다.
아이템 26의 핵심은 이 장점을 포기하게 만드는 로 타입(Raw Type)을 사용하지 말라는 것이다.
제네릭을 제대로 이해하기 위해서는 먼저 제네릭에서 사용하는 용어들을 정리할 필요가 있다.
제네릭이 없던 코드부터 살펴보자
다음 리스트가 있다고 하자.
List numbers = new ArrayList();
타입 매개변수를 지정하지 않았다.
이것이 바로 로 타입(Raw Type) 을 사용한 형태다.
List 자체는 제네릭 타입이다.
원래 선언을 보면 개념적으로 다음과 같다.
public interface List<E> {
...
}
E라는 타입 매개변수를 받을 수 있도록 만들어져 있다.
그런데 사용하는 쪽에서
List numbers
처럼 타입을 지정하지 않았다.
이것이 Raw Type이다.
Raw Type에서는 사실상 타입 안전성을 잃는다
다음 코드를 보자.
List numbers = new ArrayList();
numbers.add(10);
numbers.add(20);
numbers.add("30");
컴파일러는 이를 막지 못한다.
Integer도 객체이고 String도 객체이기 때문에 서로 다른 타입의 값이 같은 컬렉션 안에 들어갈 수 있다.
결과적으로 리스트의 상태가 다음과 같이 될 수 있다.
[
Integer,
Integer,
String
]
문제는 값을 꺼낼 때 발생한다.
개발자는 이 리스트에 Integer만 들어 있다고 생각할 수 있다.
Integer number =
(Integer) numbers.get(2);
하지만 실제 세 번째 값은 "30"이라는 String이다.
따라서 실행 시점에 문제가 발생한다.
Runtime
↓
ClassCastException
즉 잘못된 코드는 이미 다음 순간에 만들어졌다.
numbers.add("30");
하지만 실제 문제를 발견하는 시점은 훨씬 뒤다.
(Integer) numbers.get(2);
버그가 발생한 위치와 발견한 위치가 달라진다
이것이 Raw Type의 큰 문제 중 하나다.
버그 발생
numbers.add("30");
↓
여러 코드 실행
↓
문제 발견
(Integer) numbers.get(...)
실제 프로젝트에서는 두 코드가 서로 멀리 떨어져 있을 수도 있다.
Service A
→ 잘못된 값 삽입
Repository
→ 전달
Service B
→ 조회
Controller
→ 형변환
Runtime Error
문제가 늦게 발견될수록 원인을 추적하기 어려워진다.
제네릭을 적용해보자
이 리스트가 Integer만 다룬다는 사실을 선언한다.
List<Integer> numbers =
new ArrayList<>();
이제 다음 코드는 정상이다.
numbers.add(10);
numbers.add(20);
하지만 다음 코드는 컴파일되지 않는다.
numbers.add("30");
컴파일러는 이미 numbers가
List<Integer>
라는 것을 알고 있기 때문이다.
따라서 add() 역시 개념적으로 다음 타입을 요구한다.
add(Integer value)
String을 전달하면 즉시 오류가 발생한다.
Compile Time Error
런타임보다 컴파일 타임에 실패하는 것이 좋다
두 상황을 비교해보자.
Raw Type
잘못된 데이터 입력
↓
컴파일 성공
↓
프로그램 실행
↓
특정 코드 실행
↓
ClassCastException
Generic
잘못된 데이터 입력
↓
컴파일 실패
↓
즉시 수정
가능하다면 문제는 일찍 발견되는 것이 좋다.
제네릭은 타입과 관련된 많은 실수를 실행 시점에서 컴파일 시점으로 앞당겨준다.
형변환도 필요 없어진다
제네릭이 없으면 값을 꺼낼 때 타입 정보를 알 수 없다.
List numbers =
new ArrayList();
numbers.add(10);
get() 결과는 타입 안전하게 Integer라는 보장이 없으므로 형변환이 필요해진다.
Integer number =
(Integer) numbers.get(0);
반면 제네릭을 사용하면
List<Integer> numbers =
new ArrayList<>();
numbers.add(10);
컴파일러가 리스트의 요소 타입을 알고 있다.
따라서 바로 사용할 수 있다.
Integer number =
numbers.get(0);
정리하면 제네릭의 대표적인 장점은 다음과 같다.
타입 안전성 향상
+
불필요한 형변환 제거
제네릭 용어를 이해해보자
제네릭을 공부하다 보면 비슷해 보이는 용어가 많이 등장한다.
대표적으로 다음과 같다.
Generic Type
Type Parameter
Type Argument
Parameterized Type
Bounded Type Parameter
Wildcard Type
Bounded Wildcard Type
Raw Type
처음에는 모두 비슷하게 들리지만 타입을 정의하는 쪽과 사용하는 쪽을 나누어 생각하면 이해하기 쉽다.
Generic Type
다음 클래스를 만들어보자.
public class Box<E> {
private E value;
public void set(E value) {
this.value = value;
}
public E get() {
return value;
}
}
이 클래스는 특정 타입 하나에 고정되어 있지 않다.
Integer Box
String Box
Member Box
Order Box
등 다양한 타입으로 사용할 수 있다.
이처럼 타입 매개변수를 선언한 클래스나 인터페이스를 제네릭 타입(Generic Type) 이라고 한다.
클래스라면 좀 더 구체적으로 제네릭 클래스(Generic Class) 라고 부를 수 있다.
class Box<E>
인터페이스라면 다음과 같은 형태다.
interface Repository<E>
타입 매개변수 Type Parameter
다음 코드에서
public class Box<E> {
}
E가 타입 매개변수(Type Parameter) 다.
E라는 실제 클래스가 존재하는 것은 아니다.
이것은 일종의 자리 표시자다.
"아직 정확한 타입은 모르겠지만
그 타입을 여기에서는 E라고 부르겠다."
라는 의미다.
따라서 다음과 같이 사용할 수 있다.
private E value;
public void set(E value) {
this.value = value;
}
public E get() {
return value;
}
E가 어떤 타입인지 클래스 정의 시점에는 결정되어 있지 않다.
반드시 E라는 이름을 써야 하는 것은 아니다
다음 코드도 문법적으로 가능하다.
public class Box<D> {
private D value;
}
심지어
public class Box<ABC> {
}
도 가능하다.
하지만 제네릭에는 일반적으로 사용하는 관례가 있다.
대표적으로 다음 이름을 자주 사용한다.
| 이름 | 일반적인 의미 |
|---|---|
T | Type |
E | Element |
K | Key |
V | Value |
N | Number |
R | Return/Result |
예를 들어 List는 요소를 다루기 때문에 흔히 E를 사용한다.
List<E>
Map은 Key와 Value를 사용한다.
Map<K, V>
이런 관례를 따르면 코드를 이해하기가 훨씬 쉽다.
실제 타입 인수 Type Argument
Box<E>를 실제로 사용해보자.
Box<Integer> box =
new Box<>();
여기에서
Integer
가 실제로 전달된 타입이다.
이를 타입 인수(Type Argument) 라고 한다.
즉 다음 관계다.
정의할 때
Box<E>
↑
Type Parameter
사용할 때
Box<Integer>
↑
Type Argument
E라는 자리에 실제 Integer 타입이 들어갔다고 이해하면 쉽다.
Box<Integer>는 매개변수화 타입이다
다음 전체 타입은
Box<Integer>
매개변수화 타입(Parameterized Type) 이라고 한다.
즉 Box라는 제네릭 타입이 Integer라는 실제 타입 인수로 매개변수화된 것이다.
정리하면 다음과 같다.
Box<E>
→ Generic Type 선언
E
→ Type Parameter
Integer
→ Type Argument
Box<Integer>
→ Parameterized Type
List를 기준으로 다시 보면
우리가 매일 사용하는 List에도 동일하게 적용된다.
List<String>
이를 분해하면 다음과 같다.
List<E>
→ Generic Type
E
→ Type Parameter
String
→ Type Argument
List<String>
→ Parameterized Type
타입 매개변수가 실제 타입으로 결정되면
다음 제네릭 클래스를 생각해보자.
public class Box<E> {
private E value;
public void set(E value) {
this.value = value;
}
public E get() {
return value;
}
}
그리고 다음과 같이 사용한다.
Box<Integer> box =
new Box<>();
개념적으로는 E가 Integer로 결정된 것처럼 생각할 수 있다.
private Integer value;
public void set(Integer value) {
this.value = value;
}
public Integer get() {
return value;
}
실제 Java의 제네릭 구현 방식은 타입 소거 등의 내용과 연결되므로 내부적으로 정말 별도의 Box<Integer> 클래스가 만들어진다는 뜻은 아니다.
하지만 소스 코드를 이해하는 단계에서는
E 자리에 Integer가 들어간다.
라고 생각하면 편하다.
그래서 잘못된 타입을 넣을 수 없다
Box<Integer> box =
new Box<>();
이제 다음은 가능하다.
box.set(100);
하지만 다음은 불가능하다.
box.set("hello");
Box<Integer>에서 set()이 요구하는 타입은 Integer이기 때문이다.
한정적 타입 매개변수
지금까지의 E에는 사실 거의 모든 참조 타입을 사용할 수 있다.
Box<String>
Box<Integer>
Box<Member>
Box<Object>
그런데 때로는 사용할 수 있는 타입을 제한하고 싶을 수 있다.
예를 들어 숫자 타입만 받고 싶다고 생각해보자.
이럴 때 한정적 타입 매개변수(Bounded Type Parameter) 를 사용할 수 있다.
public class NumberBox<E extends Number> {
private E value;
public void set(E value) {
this.value = value;
}
public E get() {
return value;
}
}
핵심은 다음 부분이다.
E extends Number
의미는 다음과 같다.
E는 Number 또는
Number의 하위 타입이어야 한다.
Integer는 사용할 수 있다
Integer는 Number의 하위 클래스다.
따라서 다음은 가능하다.
NumberBox<Integer> box =
new NumberBox<>();
Double도 가능하다.
NumberBox<Double> box =
new NumberBox<>();
대략적인 관계는 다음과 같다.
Number
/ \
Integer Double
String은 사용할 수 없다
String은 Number 계층과 관계가 없다.
따라서 다음 코드는 컴파일되지 않는다.
NumberBox<String> box =
new NumberBox<>();
즉 타입 매개변수의 범위를 제한한 것이다.
<E>
↓
거의 모든 참조 타입 허용
<E extends Number>
↓
Number 계층으로 제한
이것이 한정적 타입 매개변수다.
extends는 클래스 상속에만 사용하는 것이 아니다
제네릭 문법에서
<T extends SomeType>
의 extends는 조금 넓은 의미를 가진다.
SomeType이 클래스일 수도 있고 인터페이스일 수도 있다.
예를 들어 다음처럼 사용할 수 있다.
<T extends Comparable<T>>
따라서 제네릭 문맥에서는
extends
=
상위 경계(upper bound)를 지정한다.
정도로 이해하는 것이 좋다.
와일드카드 타입
이번에는 사용하는 쪽에서 다음과 같은 타입을 볼 수 있다.
Box<?>
?를 와일드카드(Wildcard) 라고 한다.
다음 의미로 생각할 수 있다.
정확히 어떤 타입인지는 모르겠지만
어떤 타입으로 매개변수화된 Box다.
즉
Box<Integer>
도 받을 수 있고
Box<String>
도 받을 수 있고
Box<Member>
도 받을 수 있다.
비한정적 와일드카드
다음처럼 extends나 super 없이 ?만 사용하는 것을 비한정적 와일드카드 타입(Unbounded Wildcard Type) 이라고 한다.
Box<?>
예를 들어 다음 메서드를 작성할 수 있다.
public static void printBox(
Box<?> box
) {
System.out.println(box.get());
}
이 메서드는 다양한 Box를 받을 수 있다.
Box<Integer> integerBox =
new Box<>();
Box<String> stringBox =
new Box<>();
printBox(integerBox);
printBox(stringBox);
왜 Box<?>가 필요한가?
메서드 입장에서 다음과 같은 요구가 있다고 하자.
"Box이기만 하면 된다.
안에 Integer가 들었든
String이 들었든 상관없다."
이런 요구를 표현하는 타입이
Box<?>
다.
Raw Type과 Box<?>는 다르다
다음 두 코드는 비슷해 보인다.
Box box
Box<?> box
하지만 의미가 다르다.
첫 번째는 Raw Type이다.
Box box
제네릭 타입 정보를 포기한 형태다.
반면 두 번째는
Box<?> box
제네릭 타입 시스템 안에서
"정확한 타입은 모르겠다."
라고 표현한 것이다.
따라서 아무 종류의 Box를 받고 싶다면 Raw Type 대신 다음을 사용하는 것이 더 안전하다.
Box<?>
와일드카드에서는 아무 값이나 넣을 수 없다
다음 코드를 생각해보자.
Box<?> box =
new Box<Integer>();
box가 실제로 Box<Integer>라는 사실은 오른쪽에서 확인할 수 있다.
하지만 변수의 정적 타입은
Box<?>
다.
컴파일러 입장에서는 ?가 정확히 어떤 타입인지 알 수 없다.
다음일 수도 있다.
Integer
String
Member
Order
따라서 다음처럼 값을 넣게 허용하면 위험하다.
box.set("hello");
만약 실제 객체가 Box<Integer>라면 타입 안전성이 깨진다.
그래서 Box<?>에는 일반적인 구체 타입의 값을 마음대로 넣을 수 없다.
다만 정확히 말하면 null은 참조 타입의 공통 값이므로 별개의 예외적인 경우가 될 수 있다. 핵심은 구체적인 타입의 객체를 안전하게 넣을 수 없다는 것이다.
와일드카드는 읽는 용도에서 유용하다
Box<?>에서 값을 꺼낸다고 해보자.
Object value =
box.get();
정확한 타입은 모르지만 적어도 모든 참조 타입은 Object이므로 Object로 읽어낼 수 있다.
Box<?>
↓
정확한 타입은 모름
↓
Object 수준에서는 읽을 수 있음
그래서 단순히 내용을 확인하거나 출력하는 메서드라면 유용하다.
public static void printBox(
Box<?> box
) {
System.out.println(box.get());
}
특정 타입의 Box만 받고 싶다면
이번에는 String만 담는 Box를 받는다고 해보자.
public static void printStringBox(
Box<String> box
) {
System.out.println(box.get());
}
다음 호출은 가능하다.
Box<String> stringBox =
new Box<>();
printStringBox(stringBox);
하지만 다음은 불가능하다.
Box<Integer> integerBox =
new Box<>();
printStringBox(integerBox);
메서드가 요구하는 것은
Box<String>
이지만 전달한 타입은
Box<Integer>
이기 때문이다.
Box<Integer>는 Box<Object>의 하위 타입이 아니다
제네릭을 처음 공부할 때 가장 헷갈리는 부분 중 하나다.
Integer는 Object의 하위 타입이다.
Object
↑
Integer
그렇다면 다음 관계도 성립할 것처럼 보인다.
Box<Object>
↑
Box<Integer>
하지만 그렇지 않다.
Box<Integer>
≠ subtype of
Box<Object>
두 타입은 서로 다른 매개변수화 타입이다.
왜 Box<Integer>를 Box<Object>로 받을 수 없을까?
만약 이것이 가능하다고 가정해보자.
Box<Integer> integerBox =
new Box<>();
Box<Object> objectBox =
integerBox;
objectBox는 Box<Object>이므로 다음 코드가 허용되어야 한다.
objectBox.set("hello");
하지만 실제 객체는
Box<Integer>
다.
결과적으로 Integer만 담아야 하는 Box에 String이 들어간다.
Box<Integer>
↓
"hello" 삽입
제네릭 타입 안전성이 깨진다.
그래서 Java는 처음부터 다음 대입을 허용하지 않는다.
Box<Object> objectBox =
integerBox;
이 특성을 불공변이라고 이해할 수 있다
Java 제네릭의 일반적인 매개변수화 타입은 이러한 관계에서 불공변(invariant) 하다.
즉
Integer <: Object
라고 해서
Box<Integer> <: Box<Object>
가 되는 것은 아니다.
이 특성을 이해하지 못하면 와일드카드를 공부할 때 혼란스러워진다.
아무 Box나 받고 싶다면 Object가 아니라 ?를 사용한다
다음 메서드를 보자.
public static void printBox(
Box<Object> box
) {
}
이 메서드는 이름만 보면 아무 객체를 담은 Box를 받을 수 있을 것처럼 보인다.
하지만 실제 의미는
정확히 Object 타입으로 매개변수화된 Box
다.
따라서
Box<Integer>
를 받을 수 없다.
Box<String>
도 받을 수 없다.
아무 타입의 Box를 받고 싶다면 다음과 같이 표현해야 한다.
public static void printBox(
Box<?> box
) {
}
Box<Object>와 Box<?> 비교
| 타입 | 의미 |
|---|---|
Box<Object> | Object를 요소 타입으로 사용하는 Box |
Box<?> | 요소 타입이 무엇인지 알 수 없는 임의의 Box |
Box | 타입 정보를 버린 Raw Type |
예를 들어 다음 Box들이 있다고 하자.
Box<Integer>
Box<String>
Box<Member>
Box<?>는 모두 받을 수 있다.
Box<Integer> ─┐
Box<String> ─┼→ Box<?>로 참조 가능
Box<Member> ─┘
하지만 Box<Object>는 다르다.
Box<Integer> → Box<Object> X
Box<String> → Box<Object> X
한정적 와일드카드
와일드카드에도 범위를 지정할 수 있다.
예를 들어 숫자 계열의 Box만 받고 싶다고 하자.
Box<? extends Number>
의미는 다음과 같다.
Number 또는
Number의 어떤 하위 타입을 사용하는 Box
따라서 다음은 가능하다.
Box<Integer>
Box<Double>
Box<Long>
하지만 다음은 허용되지 않는다.
Box<String>
extends를 사용한 와일드카드
Box<? extends Number>
를 한정적 와일드카드 타입(Bounded Wildcard Type) 이라고 할 수 있다.
다음과 같이 사용할 수 있다.
public static void printNumberBox(
Box<? extends Number> box
) {
Number number = box.get();
System.out.println(number);
}
Integer, Double 등 무엇이 들어 있는지는 정확히 모르지만 적어도 Number 계열이라는 사실은 알고 있다.
그래서 값을 Number로 읽을 수 있다.
타입 매개변수와 와일드카드를 구분하자
다음 두 문법을 혼동하기 쉽다.
<T>
<?>
둘 다 아직 정확한 타입을 모르고 있는 것처럼 보인다.
하지만 역할이 다르다.
타입 매개변수
<T>
타입에 이름을 붙인다.
예를 들어
public static <T> T getFirst(
List<T> list
) {
return list.get(0);
}
여기서는 T라는 타입을 여러 위치에서 다시 사용할 수 있다.
입력 List<T>
↓
출력 T
타입 사이의 관계를 표현할 수 있다.
와일드카드
<?>
타입 이름 자체가 중요하지 않을 때 사용할 수 있다.
public static int size(
List<?> list
) {
return list.size();
}
여기서는 리스트 안에 무엇이 들어 있는지 알 필요가 없다.
"어떤 타입이든 List면 된다."
라는 의미다.
제네릭 용어를 한 번에 정리해보자
다음 클래스를 기준으로 보자.
public class Box<E> {
private E value;
}
제네릭 타입
Box<E>
타입 매개변수를 선언하고 있는 클래스다.
타입 매개변수
E
정의할 때 사용하는 타입 변수다.
타입 인수
Integer
다음 코드에서 실제로 전달한 타입이다.
Box<Integer>
매개변수화 타입
Box<Integer>
실제 타입 인수가 적용된 타입이다.
한정적 타입 매개변수
<E extends Number>
타입 매개변수로 사용할 수 있는 범위를 제한한다.
비한정적 와일드카드 타입
Box<?>
정확한 타입을 특정하지 않은 임의의 Box다.
한정적 와일드카드 타입
Box<? extends Number>
특정 범위 안에서 임의의 타입을 나타낸다.
Raw Type
Box
제네릭 타입인데 타입 인수를 사용하지 않은 형태다.
표로 정리하면
| 용어 | 예제 | 의미 |
|---|---|---|
| Generic Type | Box<E> | 타입 매개변수를 선언한 타입 |
| Type Parameter | E | 타입 정의에서 사용하는 타입 변수 |
| Type Argument | Integer | 실제 사용 시 전달하는 타입 |
| Parameterized Type | Box<Integer> | 실제 타입이 지정된 제네릭 타입 |
| Bounded Type Parameter | <E extends Number> | 타입 매개변수 범위 제한 |
| Unbounded Wildcard | Box<?> | 임의의 타입을 가진 Box |
| Bounded Wildcard | Box<? extends Number> | 범위가 제한된 임의 타입 |
| Raw Type | Box | 타입 인수를 생략한 제네릭 타입 |
Raw Type과 Object 타입을 혼동하면 안 된다
다음 두 코드는 완전히 같은 의미가 아니다.
List list
List<Object> list
첫 번째는 Raw Type이다.
List list
타입 검사 기능을 상당 부분 포기한다.
반면
List<Object> list
는 Object를 요소 타입으로 사용하는 정상적인 매개변수화 타입이다.
예를 들어 List<Object>에는 여러 객체를 넣을 수 있다.
List<Object> list =
new ArrayList<>();
list.add("hello");
list.add(10);
list.add(new Member());
이것은 제네릭 타입 시스템 안에서 의도적으로
"나는 Object를 담겠다."
라고 선언한 것이다.
Raw Type과는 의미가 다르다.
Raw Type은 타입 시스템을 우회한다
다음 코드를 보자.
List<String> strings =
new ArrayList<>();
List rawList =
strings;
Raw Type을 중간에 끼우면 다음과 같은 위험한 코드가 가능해질 수 있다.
rawList.add(100);
원래 strings는 String만 담아야 한다.
그런데 Raw Type을 거치면서 타입 검사가 약해졌다.
List<String>
↓
Raw List
↓
Integer 삽입
그리고 나중에
String value =
strings.get(0);
같은 코드에서 문제가 터질 수 있다.
이것이 아이템 26에서 Raw Type을 피하라고 강조하는 근본적인 이유다.
제네릭은 코드의 의도를 타입으로 표현한다
다음 코드를 비교해보자.
List values =
new ArrayList();
이 코드만 보면 무엇을 저장할지 알 수 없다.
반면
List<Order> orders =
new ArrayList<>();
는 의도가 명확하다.
"이 리스트는 Order만 다룬다."
컴파일러뿐만 아니라 코드를 읽는 개발자에게도 정보를 제공한다.
즉 제네릭은 단순한 문법 기능이 아니라 API와 코드의 의도를 타입에 기록하는 도구이기도 하다.
제네릭을 사용하는 이유를 한 흐름으로 정리하면
Raw Type에서는 다음 문제가 존재한다.
어떤 타입이 들어갈지 알 수 없음
↓
아무 객체나 삽입 가능
↓
꺼낼 때 형변환 필요
↓
잘못된 타입이라면 Runtime Error
↓
문제 발견이 늦음
제네릭을 적용하면 다음처럼 바뀐다.
요소 타입 명시
↓
잘못된 타입 삽입 차단
↓
Compile Time Error
↓
꺼낼 때 명시적 형변환 불필요
↓
타입 안전성 향상
제네릭에서 가장 먼저 기억해야 할 세 가지
처음 제네릭을 학습한다면 복잡한 와일드카드 규칙보다 먼저 다음 세 가지를 확실하게 이해하는 것이 좋다.
타입을 명시한다
List<String>
Raw Type을 사용하지 않는다.
List
제네릭 타입끼리는 일반적인 상속 관계가 그대로 전파되지 않는다
Integer extends Object
하지만
List<Integer>
does not extend
List<Object>
정확한 타입이 중요하지 않다면 Raw Type 대신 와일드카드를 사용한다
List<?>
즉
List
가 아니라
List<?>
를 고려한다.
이 세 가지를 이해하면 이후 제네릭 아이템들을 훨씬 쉽게 이해할 수 있다.
핵심 정리
- Java의 제네릭은 Java 5에서 도입되었으며 타입 안전성과 코드 편의성을 높이기 위한 기능이다.
- 제네릭을 사용하면 잘못된 타입의 값을 컬렉션 등에 넣는 실수를 컴파일 시점에 발견할 수 있다.
- Raw Type은 제네릭 타입이 제공하는 타입 매개변수를 지정하지 않고 사용하는 형태다.
List는 Raw Type이고List<Integer>는 매개변수화 타입이다.- Raw Type에서는 서로 다른 타입의 객체가 컬렉션에 들어갈 수 있고 문제를 실행 시점까지 발견하지 못할 수 있다.
- 제네릭을 사용하면 잘못된 타입이 들어오는 순간 컴파일 오류가 발생하므로 문제를 더 빨리 발견할 수 있다.
- 제네릭을 사용하면 컬렉션에서 값을 꺼낼 때 불필요한 명시적 형변환도 줄일 수 있다.
Box<E>처럼 타입 매개변수를 선언한 타입을 제네릭 타입이라고 한다.E는 타입을 정의하는 곳에서 사용하는 타입 매개변수다.Box<Integer>에서Integer는 실제로 전달한 타입 인수다.Box<Integer>전체는 매개변수화 타입이다.<E extends Number>처럼 사용할 수 있는 타입 범위를 제한한 것을 한정적 타입 매개변수라고 한다.Box<?>는 정확한 타입은 모르지만 어떤 타입으로 매개변수화된 Box라는 의미의 비한정적 와일드카드 타입이다.Box<? extends Number>처럼 와일드카드에도 범위를 지정할 수 있다.Box<?>에는 구체적인 타입의 값을 안전하게 추가할 수 없으며 주로 정확한 요소 타입을 알 필요 없는 API에서 유용하다.Box<Object>와Box<?>는 전혀 다른 의미다.Integer가Object의 하위 타입이라고 해서Box<Integer>가Box<Object>의 하위 타입이 되는 것은 아니다.- 아무 타입의 Box를 받고 싶은 경우
Box<Object>가 아니라Box<?>가 적절하다. - Raw Type인
Box와 와일드카드 타입인Box<?>도 다르다. - Raw Type은 제네릭 타입 정보를 포기하지만
Box<?>는 제네릭 타입 시스템 안에서 알 수 없는 타입을 안전하게 표현한다. - 제네릭은 단순히 형변환을 줄이는 문법이 아니라 코드가 어떤 타입을 다루는지 명확하게 표현하고 잘못된 타입 사용을 컴파일 단계에서 차단하는 타입 안전성 도구다.
한 줄 정리
제네릭은
List<Integer>처럼 코드가 다루는 타입을 컴파일러에게 명확하게 알려 잘못된 타입 사용을 실행 전에 차단하고 불필요한 형변환을 없애는 기능이며, 타입 정보를 포기하는List같은 Raw Type 대신 매개변수화 타입이나List<?>같은 와일드카드 타입을 사용하는 것이 제네릭의 타입 안전성을 제대로 활용하는 방법이다.
아이템 26. 핵심 정리 - 로(raw) 타입은 사용하지 말라.
아이템 26. 로 타입은 사용하지 말라
제네릭을 사용하는 가장 중요한 이유는 크게 두 가지로 정리할 수 있다.
타입 안전성
+
표현력
제네릭이 없었던 시절에는 컬렉션 안에 어떤 객체가 들어가는지를 타입 시스템을 통해 제한하기 어려웠다.
List numbers = new ArrayList();
numbers.add(10);
numbers.add("20");
Integer도 Object이고 String도 Object이므로 둘 다 들어갈 수 있다.
문제는 값을 넣는 순간이 아니라 꺼내서 사용하는 시점에 발생할 가능성이 높다는 것이다.
Integer number =
(Integer) numbers.get(1);
실제로 들어 있는 객체가 String이라면 런타임에서 ClassCastException이 발생한다.
제네릭을 사용하면 이러한 문제를 실행하기도 전에 발견할 수 있다.
List<Integer> numbers =
new ArrayList<>();
numbers.add(10);
numbers.add("20"); // 컴파일 오류
따라서 제네릭의 첫 번째 핵심 가치는 타입과 관련된 오류를 런타임에서 컴파일 타임으로 끌어올리는 것이다.
제네릭의 첫 번째 장점은 타입 안전성이다
Raw Type을 사용한 코드를 다시 살펴보자.
List numbers =
new ArrayList();
numbers.add(10);
numbers.add("hello");
컴파일러 입장에서는 List에 어떤 타입이 들어가야 하는지 정확한 정보가 없다.
따라서 타입 안전성을 충분히 검사할 수 없다.
List
↓
요소 타입 정보 없음
↓
여러 종류 객체 삽입 가능
↓
꺼낼 때 형변환 필요
↓
런타임 오류 가능
반면 다음 코드는 다르다.
List<Integer> numbers =
new ArrayList<>();
컴파일러에게 매우 중요한 정보를 제공하고 있다.
이 List는 Integer만 다룬다.
따라서 다음 코드는 정상이다.
numbers.add(100);
하지만 다음 코드는 즉시 차단된다.
numbers.add("100");
잘못된 값이 컬렉션 내부로 들어가기 전에 문제를 발견한다.
오류는 가능한 한 빨리 발견하는 것이 좋다
Raw Type을 사용하면 문제의 발생 지점과 발견 지점이 멀어질 수 있다.
잘못된 데이터 삽입
↓
여러 메서드 호출
↓
다른 객체로 전달
↓
몇 단계 뒤에서 조회
↓
형변환
↓
ClassCastException
실제 버그는 다음 코드였는데
list.add("wrong value");
문제는 다음 코드에서 발견될 수도 있다.
Integer value =
(Integer) list.get(index);
대규모 애플리케이션에서는 두 코드가 전혀 다른 계층에 위치할 수도 있다.
반면 제네릭을 사용하면
List<Integer> list =
new ArrayList<>();
list.add("wrong value");
이 순간 바로 컴파일 오류가 발생한다.
버그 발생 지점
=
버그 발견 지점
에 가까워지는 것이다.
제네릭의 두 번째 장점은 표현력이다
제네릭에는 타입 안전성뿐만 아니라 코드 자체가 더 많은 의미를 전달한다는 장점이 있다.
다음 코드만 있다고 생각해보자.
List numbers =
new ArrayList();
변수 이름이 numbers라고 되어 있지만 실제로 무엇이 들어가는지 타입만 보고 알 수 없다.
Integer인가?
Long인가?
String으로 표현된 숫자인가?
Number의 여러 구현체가 섞이는가?
주석이나 주변 코드를 확인해야 할 수도 있다.
반면 다음 코드는 매우 명확하다.
List<Integer> numbers =
new ArrayList<>();
타입 선언만 봐도 알 수 있다.
이 리스트에는 Integer가 들어간다.
따라서 제네릭은 타입 안전성을 높이는 기능인 동시에 코드의 의도를 타입 시스템에 기록하는 기능이기도 하다.
타입이 주석 역할까지 수행한다
제네릭이 없다면 다음과 같은 설명이 필요할 수 있다.
// Integer만 저장하는 List
List numbers =
new ArrayList();
하지만 주석에는 중요한 한계가 있다.
주석은 컴파일러가 검사하지 않는다.
// Integer만 저장하는 List
List numbers =
new ArrayList();
numbers.add("hello");
주석과 실제 코드가 모순되어도 컴파일러는 이를 막지 못한다.
반면 타입으로 표현하면
List<Integer> numbers =
new ArrayList<>();
컴파일러가 규칙을 강제한다.
주석
→ 개발자에게만 의미가 있음
타입
→ 개발자에게 의미 전달
+
컴파일러가 규칙 검증
그래서 가능한 제약을 주석보다 타입으로 표현하는 것이 훨씬 강력하다.
그렇다면 Java는 왜 Raw Type을 없애지 않았을까?
여기서 자연스럽게 의문이 생긴다.
제네릭이 이렇게 좋은 기능이라면
List
같은 Raw Type을 아예 금지하고 무조건
List<String>
List<Integer>
처럼 타입 인수를 적도록 강제하면 되지 않았을까?
Java가 Raw Type을 허용하는 가장 중요한 이유 중 하나는 기존 Java 코드와의 호환성이다.
제네릭은 Java 5에서 추가되었다.
하지만 Java에는 이미 그 이전부터 엄청난 양의 코드가 존재했다.
예를 들어 과거에는 자연스럽게 다음처럼 작성했다.
List list =
new ArrayList();
제네릭 도입과 동시에 이런 기존 코드를 모두 사용할 수 없게 만들었다면 호환성에 큰 문제가 생겼을 것이다.
따라서 Java는 기존 코드와 새로운 제네릭 코드가 함께 동작할 수 있도록 설계해야 했다.
이와 밀접하게 연결되는 개념이 타입 소거(Type Erasure) 다.
제네릭과 타입 소거
다음과 같은 코드가 있다고 하자.
List<Integer> numbers =
new ArrayList<>();
numbers.add(100);
Integer number =
numbers.get(0);
소스 코드에서는 명확하게
List<Integer>
라고 작성되어 있다.
하지만 Java 제네릭은 타입 소거 방식으로 구현되어 있다.
컴파일 과정에서는 제네릭 타입 정보의 상당 부분이 제거되고 필요한 위치에 컴파일러가 형변환 등을 추가한다.
개념적으로 보면 다음 코드와 비슷한 형태로 생각할 수 있다.
List numbers =
new ArrayList();
numbers.add(100);
Integer number =
(Integer) numbers.get(0);
물론 실제 바이트코드 수준의 세부 동작은 이 단순한 소스 코드 변환보다 복잡하지만, 제네릭의 기본 원리를 이해하는 데에는 이 관점이 도움이 된다.
컴파일러가 형변환을 대신 넣어준다
제네릭이 없었다면 개발자가 직접 작성해야 했던 코드가 있다.
Integer value =
(Integer) list.get(0);
제네릭을 사용하면
List<Integer> list =
new ArrayList<>();
Integer value =
list.get(0);
처럼 작성할 수 있다.
컴파일러가 List<Integer>라는 타입 정보를 이용해 타입 안전성을 검사하고 필요한 형변환을 처리한다.
즉 개발자는
컴파일 타임
→ 강한 타입 검사
를 얻으면서도 코드에서는
명시적 캐스팅 감소
라는 편리함을 얻는다.
타입 소거를 너무 단순하게 이해하면 안 된다
여기서 한 가지 구분할 부분이 있다.
List<Integer>라는 정보가 소스 코드에만 의미가 있고 아무런 흔적도 남지 않는다고 단순화해서 이해하기보다는 다음 정도가 정확하다.
런타임 객체 자체가
List<Integer>와 List<String>이라는
서로 다른 클래스로 만들어지는 것은 아니다.
즉 다음 두 코드를 사용한다고 해서
List<String>
List<Integer>
런타임에 ListOfString, ListOfInteger 같은 별개의 클래스가 생성되는 것은 아니다.
Java의 제네릭은 기본적으로 타입 소거를 통해 기존 JVM 및 기존 코드와 호환될 수 있도록 설계되어 있다.
List 와 List<Object>는 다르다
제네릭을 처음 사용할 때 상당히 중요하게 이해해야 할 차이가 있다.
다음 두 타입은 같은 것이 아니다.
List
List<Object>
첫 번째는 Raw Type이다.
두 번째는 정상적인 매개변수화 타입이다.
Raw List는 타입 검사를 우회할 수 있다
다음 메서드를 생각해보자.
private static void unsafeAdd(
List list,
Object value
) {
list.add(value);
}
매개변수가 Raw Type이다.
따라서 다음 코드가 가능하다.
List<String> strings =
new ArrayList<>();
strings.add("A");
strings.add("B");
unsafeAdd(strings, 42);
strings는 분명히 다음 타입이다.
List<String>
그런데 unsafeAdd()에서는 이를 단순히
List
로 받았다.
제네릭 타입 정보가 무시된다.
따라서
list.add(42);
가 가능해질 수 있다.
결국 리스트 내부 상태는 개념적으로 다음과 같이 깨진다.
List<String>
[
"A",
"B",
42
]
문제는 넣을 때가 아니라 꺼낼 때 터진다
다음 코드가 실행된다.
for (String value : strings) {
System.out.println(value);
}
개발자는 당연히 String만 들어 있다고 생각한다.
하지만 실제로 Integer가 들어 있다.
결국 Integer를 String으로 사용하려는 순간 런타임 문제가 발생한다.
잘못된 값 삽입
↓
당장은 실행됨
↓
나중에 값 조회
↓
ClassCastException
Raw Type 때문에 타입 안전성을 잃은 것이다.
List<Object>로 바꾸면 어떻게 될까?
이번에는 메서드를 다음과 같이 변경해보자.
private static void add(
List<Object> list,
Object value
) {
list.add(value);
}
그리고 다음을 전달한다.
List<String> strings =
new ArrayList<>();
add(strings, 42);
이번에는 컴파일되지 않는다.
왜냐하면
List<String>
은
List<Object>
의 하위 타입이 아니기 때문이다.
String은 Object의 하위 타입이다.
Object
↑
String
하지만 그렇다고 해서
List<Object>
↑
List<String>
관계가 만들어지는 것은 아니다.
Java의 일반적인 제네릭 타입은 불공변(invariant)이기 때문이다.
왜 List<String>이 List<Object>가 될 수 없을까?
만약 다음 대입이 가능하다고 가정해보자.
List<String> strings =
new ArrayList<>();
List<Object> objects =
strings;
objects는 List<Object>이므로 다음 코드가 가능해야 한다.
objects.add(100);
하지만 실제 객체는 List<String>이다.
결국 Integer가 String 리스트 안으로 들어간다.
List<String>
↓
List<Object>로 참조
↓
Integer 삽입
↓
타입 안전성 붕괴
그래서 Java는 애초에 다음 관계를 허용하지 않는다.
List<String>
→
List<Object>
X
Raw Type과 List<Object>의 가장 큰 차이는 안전성이다
정리하면 다음과 같다.
Raw Type
List
타입 정보를 버린다.
따라서 여러 매개변수화 타입을 받을 수 있지만 타입 안전성을 잃는다.
매개변수화 타입
List<Object>
정확히 Object를 요소 타입으로 사용하는 리스트다.
제네릭 타입 시스템의 보호를 그대로 받는다.
가장 범용적인 제네릭 타입이 필요하면 와일드카드를 사용한다
이번에는 다음 요구가 있다고 생각해보자.
Set 안의 구체적인 타입에는 관심 없다.
어떤 타입을 담은 Set이든 받을 수 있으면 된다.
Raw Type으로 작성한다면 다음과 같다.
static int numElementsInCommon(
Set s1,
Set s2
) {
...
}
Set<Integer>도 받을 수 있다.
Set<String>도 받을 수 있다.
범용적으로 보인다.
하지만 Raw Type이라는 문제가 있다.
메서드 안에서 다음과 같은 코드도 가능해진다.
s1.add("unexpected");
어떤 Set이 들어왔는지 모르면서 값을 추가할 수 있다.
타입 안전성이 깨진다.
Set<?>를 사용하자
이럴 때 적절한 타입이 비한정적 와일드카드 타입이다.
Set<?>
예를 들어 다음과 같이 작성할 수 있다.
static int numElementsInCommon(
Set<?> s1,
Set<?> s2
) {
int result = 0;
for (Object element : s1) {
if (s2.contains(element)) {
result++;
}
}
return result;
}
이 메서드는 다양한 Set을 받을 수 있다.
Set<Integer> numbers =
Set.of(1, 2, 3);
Set<Integer> values =
Set.of(2, 3, 4);
numElementsInCommon(
numbers,
values
);
String Set도 가능하다.
Set<String> a =
Set.of("A", "B");
Set<String> b =
Set.of("B", "C");
numElementsInCommon(a, b);
Set<?>의 의미
Set<?>는 다음 의미를 가진다.
어떤 타입인지 정확히 알 수는 없지만
특정 타입 하나를 원소 타입으로 사용하는 Set
예를 들어 다음 타입들을 모두 참조할 수 있다.
Set<Integer> ─┐
Set<String> ─┼→ Set<?>
Set<Long> ─┤
Set<Member> ─┘
따라서 정확한 원소 타입에 관심 없이 Set이라는 사실만 필요할 때 매우 유용하다.
Set과 Set<?>는 둘 다 여러 종류의 Set을 받을 수 있다
겉으로 보면 비슷하다.
Set
도 Set<Integer>를 받을 수 있다.
Set<?>
도 Set<Integer>를 받을 수 있다.
그래서 이런 의문이 생길 수 있다.
둘 다 아무 Set이나 받을 수 있는데
무슨 차이가 있을까?
가장 중요한 차이는 타입 안전성이다.
Raw Set에는 아무 값이나 넣을 수 있다
static void modify(Set set) {
set.add("hello");
set.add(100);
set.add(new Object());
}
Raw Type이므로 타입 정보가 없다.
어떤 타입을 원소로 사용하는 Set이 들어왔는지와 관계없이 잘못된 값을 넣을 수 있다.
예를 들어 실제 객체가
Set<Integer>
였는데
set.add("hello");
를 수행하면 컬렉션의 타입 불변식이 깨질 수 있다.
Set<?>에는 구체적인 값을 넣을 수 없다
이번에는 다음과 같다.
static void modify(
Set<?> set
) {
}
다음 코드는 허용되지 않는다.
set.add("hello");
Integer도 넣을 수 없다.
set.add(100);
왜 그럴까?
컴파일러는 ?가 정확히 무엇인지 모른다.
다음일 수 있다.
Set<Integer>
Set<String>
Set<Member>
Set<Order>
만약 다음을 허용한다면
set.add("hello");
실제 객체가 Set<Integer>일 때 타입 안전성이 깨진다.
그래서 컴파일러가 막는다.
null은 예외다
정확하게 말하면 Set<?>에 절대로 어떤 값도 추가할 수 없는 것은 아니다.
null은 추가할 수 있다.
set.add(null);
물론 해당 컬렉션 구현이 null 자체를 허용해야 실제 호출이 성공한다는 조건도 별도로 존재한다.
핵심은
Set<?>에는
구체적인 타입의 값을
안전하게 추가할 수 없다.
는 것이다.
이 제한 덕분에 오히려 타입 안전성이 보장된다.
Raw Type은 자유롭지만 위험하다
다음과 같다.
Set
↓
무엇이 들어 있는지 모름
↓
그런데 아무 값이나 넣을 수 있음
↓
위험
반면 와일드카드는
Set<?>
↓
무엇이 들어 있는지 모름
↓
그러므로 아무 구체 값이나 넣지 못함
↓
안전
이다.
둘의 철학이 완전히 다르다.
모른다는 사실을 타입으로 표현한다
Set<?>가 중요한 이유는 컴파일러에게 다음 사실을 정확하게 알려준다는 것이다.
"나는 이 Set의 정확한 원소 타입을 모른다."
모르기 때문에 컴파일러가 위험한 연산을 제한할 수 있다.
반면 Raw Type은 사실상 다음과 같다.
"원래 타입 정보가 있었지만
나는 그 정보를 사용하지 않겠다."
그래서 컴파일러가 보호해줄 수 있는 범위가 크게 줄어든다.
Raw Type과 비한정적 와일드카드 비교
| 구분 | Set | Set<?> |
|---|---|---|
| 종류 | Raw Type | 비한정적 와일드카드 타입 |
| 여러 Set 타입을 받을 수 있음 | O | O |
| 제네릭 타입 시스템 사용 | 사실상 우회 | O |
| 임의의 객체 추가 | 가능할 수 있음 | 불가능 |
| 타입 안전성 | 낮음 | 높음 |
| 권장 여부 | 피하는 것이 좋음 | 정확한 타입을 모를 때 권장 |
따라서
Set
보다는
Set<?>
를 사용하는 것이 좋다.
List<Object> 와 List<?> 차이도 같은 원리다
다음 세 타입을 함께 비교하면 제네릭을 이해하기 훨씬 쉽다.
List
List<Object>
List<?>
각각 의미가 완전히 다르다.
List
List
Raw Type이다.
원소 타입 정보 포기
타입 안전성이 깨질 수 있다.
List<Object>
List<Object>
정확히 Object를 원소 타입으로 사용하는 List다.
List<Object> objects =
new ArrayList<>();
objects.add("hello");
objects.add(100);
여러 객체를 넣을 수 있는 이유는 타입 정보를 포기했기 때문이 아니다.
애초에 타입을 Object라고 명시했기 때문이다.
List<?>
List<?>
정확한 원소 타입을 알 수 없는 List다.
List<String>일 수도 있고
List<Integer>일 수도 있고
List<Member>일 수도 있다.
따라서 값을 읽으면 최소한 Object로는 받을 수 있다.
Object value =
list.get(0);
하지만 특정 타입 값을 자유롭게 추가할 수는 없다.
세 타입을 비교하면
| 타입 | 의미 | 안전성 |
|---|---|---|
List | 원소 타입 정보 포기 | 낮음 |
List<Object> | Object를 원소로 갖는 리스트 | 높음 |
List<?> | 정확한 원소 타입을 모르는 리스트 | 높음 |
제네릭을 사용할 때 이 세 가지를 명확히 구분하는 것이 중요하다.
Raw Type을 사용해야 하는 예외가 있다
원칙은 분명하다.
Raw Type은 사용하지 않는다.
하지만 Java 언어의 제네릭과 타입 소거 특성 때문에 예외적으로 Raw Type 형태를 사용하게 되는 경우가 있다.
대표적으로 클래스 리터럴(Class Literal) 이 있다.
매개변수화 타입의 클래스 리터럴은 사용할 수 없다
다음은 가능하다.
String.class
Integer.class
List.class
하지만 다음과 같은 형태는 사용할 수 없다.
List<String>.class
List<Integer>.class
런타임에는 List<String>과 List<Integer>가 서로 별도의 클래스 객체로 존재하는 구조가 아니기 때문이다.
따라서 클래스 리터럴은 다음과 같이 사용한다.
List.class
Map.class
이것은 Raw Type을 볼 수 있는 대표적인 예외적인 문맥이다.
왜 List<String>.class가 없을까?
만약 가능하다고 생각해보자.
List<String>.class
그렇다면 다음도 별도로 존재해야 할 것처럼 보인다.
List<Integer>.class
하지만 타입 소거 이후 런타임에서 둘은 서로 다른 List 클래스로 존재하지 않는다.
개념적으로는 하나의 클래스가 존재한다.
List.class
따라서 다음은 가능하지만
List.class
다음은 허용되지 않는다.
List<String>.class
instanceof에서도 타입 소거를 고려해야 한다
다음 코드는 사용할 수 없다.
if (object instanceof Set<String>) {
}
런타임에는 Set<String>의 String 타입 인수를 검사할 수 없기 때문이다.
즉 런타임에서 알 수 있는 것은
이 객체가 Set인가?
정도다.
이 객체가 정확히 Set<String>인가?
까지는 일반적인 타입 소거 기반 instanceof 검사로 확인할 수 없다.
비한정적 와일드카드는 instanceof에 사용할 수 있다
이 부분에서는 Raw Type과 와일드카드를 조금 더 정확하게 구분해서 알아두는 것이 좋다.
다음과 같은 검사는 가능하다.
if (object instanceof Set<?>) {
// ...
}
Set<?>는 런타임에서 검사 가능한 형태의 타입이다.
반면 다음은 불가능하다.
if (object instanceof Set<String>) {
// 컴파일 오류
}
따라서 instanceof에서도 구체적인 제네릭 타입 인수를 검사하려고 해서는 안 된다.
instanceof 이후에도 Raw Type을 계속 사용할 필요는 없다
예를 들어 다음과 같이 사용할 수 있다.
if (object instanceof Set<?>) {
Set<?> set =
(Set<?>) object;
System.out.println(
set.size()
);
}
현대 Java에서는 패턴 매칭을 사용할 수 있는 환경이라면 형태를 더 간단하게 표현할 수도 있다.
if (object instanceof Set<?> set) {
System.out.println(set.size());
}
핵심은 instanceof 때문에 프로그램 전체에서 Raw Type을 사용할 필요는 없다는 것이다.
Raw Type 사용 여부를 판단하는 기준
실무에서는 다음처럼 생각하면 간단하다.
일반적인 변수 선언
Raw Type을 사용하지 않는다.
List<String> names;
또는 정확한 타입을 모르면
List<?> values;
를 사용한다.
메서드 매개변수
정확한 원소 타입이 필요한 경우
void process(
List<String> values
)
처럼 명시한다.
타입 자체는 중요하지 않다면
void print(
List<?> values
)
처럼 와일드카드를 사용한다.
클래스 리터럴
List.class
처럼 사용한다.
List<String>.class
는 사용할 수 없다.
instanceof
구체적인 타입 인수를 검사하지 않는다.
object instanceof Set<?>
같은 형태를 사용할 수 있다.
타입 안전성과 표현력이라는 기준으로 다시 돌아가자
아이템 26 전체를 관통하는 핵심은 결국 처음에 이야기했던 두 가지다.
안전성
+
표현력
Raw Type은 둘 모두를 약화시킨다.
타입 안전성 관점
List
는 잘못된 타입의 값이 들어가는 것을 충분히 차단하지 못한다.
List<Integer>
는 컴파일러가 타입을 검사한다.
따라서 후자가 안전하다.
표현력 관점
List values;
는 안에 무엇이 들어 있는지 알기 어렵다.
List<Integer> values;
는 코드 자체가 의미를 전달한다.
정확한 타입을 몰라도
List<?> values;
라고 선언하면
"정확한 원소 타입은 중요하지 않은 List"
라는 의도를 표현할 수 있다.
제네릭은 제약을 만드는 것이 아니라 안전한 자유를 제공한다
처음 제네릭을 보면
왜 이렇게 타입을 계속 적어야 하지?
왜 add가 막히지?
왜 List<String>을 List<Object>로 못 넘기지?
처럼 불편하게 느껴질 수 있다.
하지만 이런 제한은 개발자를 괴롭히기 위한 것이 아니다.
제네릭이 보장하려는 핵심은 다음과 같다.
한 번 타입을 약속했다면
그 약속이 깨지지 않도록
컴파일러가 계속 검사한다.
그래서 개발자는 런타임까지 가서 타입 오류를 발견하는 대신 코드 작성과 컴파일 단계에서 문제를 발견할 수 있다.
제네릭 코드를 볼 때 생각할 세 가지
앞으로 제네릭 코드를 보면 다음 세 가지 질문을 해보면 좋다.
이 타입은 무엇을 담는가?
List<Order>
Map<String, Member>
가능하면 타입으로 명확하게 표현한다.
정확한 타입을 알아야 하는가?
알아야 한다면 구체적인 타입을 쓴다.
List<String>
몰라도 된다면 와일드카드를 고려한다.
List<?>
Raw Type을 사용하고 있지는 않은가?
다음 코드가 보이면
List list
왜 Raw Type을 사용했는지 확인해야 한다.
대부분의 경우 다음 중 하나로 바꿀 수 있다.
List<String>
List<Integer>
List<?>
즉 Raw Type은 기본 선택지가 아니라 아주 제한적인 예외가 아니면 피해야 하는 형태다.
핵심 정리
- 제네릭을 사용하는 가장 중요한 이유는 타입 안전성과 표현력이다.
- Raw Type을 사용하면 컬렉션에 잘못된 타입의 객체가 들어갈 수 있고 문제가 런타임까지 늦게 발견될 수 있다.
- 매개변수화 타입을 사용하면 잘못된 객체를 추가하는 순간 컴파일러가 오류를 알려준다.
- 제네릭 타입은 컬렉션이나 객체가 어떤 타입을 다루는지도 코드 자체에 표현해준다.
- 타입으로 표현한 제약은 주석과 달리 컴파일러가 직접 검증할 수 있다.
- Java에서 Raw Type이 완전히 제거되지 않은 중요한 이유는 Java 5 이전 코드와의 호환성이다.
- Java 제네릭은 타입 소거 방식을 사용하며 컴파일러는 타입 정보를 이용해 타입 검사를 수행하고 필요한 형변환을 처리한다.
List와List<Object>는 같은 타입이 아니다.List는 타입 정보를 포기한 Raw Type이고List<Object>는 정확히Object를 요소 타입으로 사용하는 매개변수화 타입이다.List<String>은List<Object>의 하위 타입이 아니다.String이Object의 하위 타입이라는 관계가List<String>과List<Object>까지 그대로 전파되지 않는다.- Raw Type을 매개변수로 사용하면 원래
List<String>같은 컬렉션에도 잘못된 타입의 값을 삽입할 수 있어 타입 안전성을 잃을 수 있다. - 정확한 타입은 중요하지 않고 어떤 타입의 컬렉션이든 받아야 한다면 Raw Type보다 비한정적 와일드카드 타입을 사용해야 한다.
Set<?>는Set<Integer>,Set<String>등 다양한 Set을 참조할 수 있다.- Raw Type인
Set에는 임의의 객체를 추가할 수 있지만Set<?>에는 구체적인 타입의 값을 안전하게 추가할 수 없다. Set<?>에서 값 추가가 제한되는 것은 컴파일러가 실제 원소 타입을 모르기 때문이며 이 제한이 타입 안전성을 보장한다.List,List<Object>,List<?>는 각각 의미가 다르므로 구분해야 한다.- 클래스 리터럴에서는
List.class처럼 사용할 수 있지만List<String>.class같은 매개변수화 클래스 리터럴은 사용할 수 없다. instanceof Set<String>처럼 구체적인 타입 인수를 검사할 수는 없지만instanceof Set<?>같은 비한정적 와일드카드 타입 검사는 가능하다.- 일반적인 애플리케이션 코드에서는 Raw Type을 기본적으로 피하고 구체적인 매개변수화 타입이나 비한정적 와일드카드 타입을 사용해야 한다.
- 제네릭의 제한은 코딩을 어렵게 하기 위한 것이 아니라 타입에 대한 약속이 프로그램 전체에서 깨지지 않도록 컴파일러가 대신 검증해주는 장치다.
한 줄 정리
Raw Type은 타입 안전성과 코드의 표현력을 모두 포기하게 만들지만
List<Integer>같은 매개변수화 타입과List<?>같은 와일드카드 타입은 컴파일러가 타입 계약을 끝까지 지켜주도록 하므로, 클래스 리터럴 등 극히 제한적인 예외를 제외하면 Raw Type 대신 제네릭 타입을 사용하는 것이 핵심이다.
아이템 26. 완벽 공략 - GenericRepository
아이템 26 완벽 공략: 제네릭 Repository로 중복 코드 제거하기
제네릭을 사용하는 가장 대표적인 이유 중 하나는 타입만 다르고 구조가 동일한 코드를 하나의 공통 구현으로 합치는 것이다.
제네릭을 단순히
List<String>
List<Integer>
처럼 Java 컬렉션을 사용할 때만 접하면 제네릭이 제공하는 장점을 절반 정도만 경험하게 된다.
제네릭의 진짜 강력함은 우리가 직접 클래스나 API를 설계할 때 드러난다.
예를 들어 다음과 같은 상황을 생각해보자.
AccountRepository
MessageRepository
ScheduleRepository
OrderRepository
각각 다루는 엔티티는 다르지만 내부 동작이 거의 동일하다면 어떨까?
저장한다.
ID로 조회한다.
전체를 조회한다.
삭제한다.
엔티티 타입만 다르고 구현 코드가 모두 동일하다면 Repository를 추가할 때마다 같은 코드를 복사하고 타입 이름만 변경하게 된다.
바로 이런 상황에서 제네릭을 활용하면 상당한 중복 코드를 제거할 수 있다.
중복되는 Repository 코드
먼저 두 개의 엔티티가 있다고 생각해보자.
public interface Entity {
Long getId();
}
Entity는 모든 엔티티가 가져야 하는 공통 계약을 정의한다.
핵심은 다음 메서드다.
Long getId();
그리고 Account가 이를 구현한다.
public class Account implements Entity {
private final Long id;
private final String name;
public Account(
Long id,
String name
) {
this.id = id;
this.name = name;
}
@Override
public Long getId() {
return id;
}
public String getName() {
return name;
}
}
Message도 같은 인터페이스를 구현한다.
public class Message implements Entity {
private final Long id;
private final String message;
public Message(
Long id,
String message
) {
this.id = id;
this.message = message;
}
@Override
public Long getId() {
return id;
}
public String getMessage() {
return message;
}
}
두 타입의 관계는 다음과 같다.
Entity
↑
┌─────┴─────┐
│ │
Account Message
AccountRepository를 만든다
먼저 Account를 저장하는 Repository를 만든다고 생각해보자.
public class AccountRepository {
private final Map<Long, Account> repository =
new HashMap<>();
public void save(Account account) {
repository.put(
account.getId(),
account
);
}
public Account findById(Long id) {
return repository.get(id);
}
public List<Account> findAll() {
return new ArrayList<>(
repository.values()
);
}
}
특별히 어려운 코드는 없다.
MessageRepository도 만든다
이번에는 Message를 저장해야 한다.
public class MessageRepository {
private final Map<Long, Message> repository =
new HashMap<>();
public void save(Message message) {
repository.put(
message.getId(),
message
);
}
public Message findById(Long id) {
return repository.get(id);
}
public List<Message> findAll() {
return new ArrayList<>(
repository.values()
);
}
}
코드를 잘 비교해보면 거의 동일하다.
무엇이 다른지 찾아보자
AccountRepository에서는
Account
를 사용한다.
MessageRepository에서는
Message
를 사용한다.
그것을 제외하면 구조는 거의 같다.
Map<Long, ???>
save(???)
findById()
findAll()
즉 변하는 것은 사실상 하나다.
Entity Type
이런 코드가 바로 제네릭을 적용하기 좋은 신호다.
타입 이름만 바꾸는 복사 붙여넣기
제네릭을 사용하지 않는다면 새로운 엔티티가 추가될 때마다 비슷한 작업을 반복하게 된다.
예를 들어 Schedule이 추가된다고 생각해보자.
먼저 기존 Repository를 복사한다.
MessageRepository 복사
↓
ScheduleRepository 생성
그리고 타입 이름을 바꾼다.
Message
↓
Schedule
그러다 보면 다음과 같은 실수가 생길 수도 있다.
public class ScheduleRepository {
private final Map<Long, Schedule> repository =
new HashMap<>();
public void save(
Schedule message
) {
...
}
}
타입은 Schedule로 변경했지만 변수 이름에 message가 남을 수 있다.
더 복잡한 Repository라면 일부 타입 변경을 놓칠 수도 있다.
복사 붙여넣기 코드의 더 큰 문제
문제는 단순히 변수 이름을 잘못 짓는 것에 그치지 않는다.
Repository에 버그가 발견되었다고 생각해보자.
AccountRepository
MessageRepository
ScheduleRepository
OrderRepository
MemberRepository
모두 동일한 로직을 복사해서 만들어두었다.
그런데 공통 조회 로직에서 버그가 발견되었다.
그러면 어떻게 해야 할까?
AccountRepository 수정
MessageRepository 수정
ScheduleRepository 수정
OrderRepository 수정
MemberRepository 수정
같은 수정 작업을 여러 클래스에 반복해야 한다.
그리고 하나라도 빠뜨리면 구현별 동작이 달라진다.
중복 코드는 유지보수 비용으로 돌아온다
중복 코드가 많다는 것은 단순히 코드 줄 수가 많다는 의미가 아니다.
중복 코드 증가
↓
수정 지점 증가
↓
테스트 범위 증가
↓
누락 가능성 증가
↓
유지보수 비용 증가
따라서
코드는 완전히 동일한데
타입만 다르다.
는 상황을 발견했다면 제네릭을 적용할 수 있는지 검토해볼 가치가 크다.
GenericRepository를 만들어보자
두 Repository에서 공통적인 구조를 추출한다.
먼저 클래스를 만든다.
public class GenericRepository<E> {
}
E는 아직 어떤 타입인지 정해지지 않은 타입 매개변수다.
E
→ Repository가 다룰 Entity Type
그러면 기존의 Account 자리를 모두 E로 바꿀 수 있다.
public class GenericRepository<E> {
private final Map<Long, E> repository =
new HashMap<>();
public void save(E entity) {
...
}
public E findById(Long id) {
...
}
public List<E> findAll() {
...
}
}
그런데 문제가 하나 생긴다.
E에는 getId()가 있다는 보장이 없다
기존 코드는 다음을 사용했다.
account.getId()
또는
message.getId()
그런데 지금은 타입이 다음과 같다.
E
컴파일러 입장에서 E는 어떤 타입이든 될 수 있다.
String
Integer
Account
Message
Object
따라서 다음 코드를 사용할 수 없다.
entity.getId();
컴파일러는 E에 getId()가 존재한다는 사실을 알지 못하기 때문이다.
타입 매개변수의 범위를 제한한다
우리가 Repository에 저장하려는 객체에는 공통점이 있다.
모두 Entity다.
public interface Entity {
Long getId();
}
따라서 E를 아무 타입이나 받을 수 있도록 둘 필요가 없다.
다음과 같이 제한할 수 있다.
public class GenericRepository<
E extends Entity
> {
}
이것을 한정적 타입 매개변수(Bounded Type Parameter) 라고 한다.
의미는 다음과 같다.
E는 아무 타입이 아니다.
E는 반드시
Entity 또는
Entity의 하위 타입이어야 한다.
Entity가 인터페이스이므로 실제로는 Entity를 구현한 타입들이 들어갈 수 있다.
Entity
↑ implements
Account
Message
Schedule
컴파일러도 E가 Entity라는 것을 알게 된다
다음 선언을 하는 순간
<E extends Entity>
컴파일러는 중요한 정보를 얻는다.
E가 정확히 무슨 타입인지는 모르겠다.
하지만 최소한 Entity라는 것은 안다.
Entity에는 다음 메서드가 정의되어 있다.
Long getId();
따라서 이제 다음 코드를 사용할 수 있다.
entity.getId();
바로 이 부분이 한정적 타입 매개변수의 중요한 장점이다.
완성된 GenericRepository
public class GenericRepository<
E extends Entity
> {
private final Map<Long, E> repository =
new HashMap<>();
public void save(E entity) {
repository.put(
entity.getId(),
entity
);
}
public E findById(Long id) {
return repository.get(id);
}
public List<E> findAll() {
return new ArrayList<>(
repository.values()
);
}
}
이제 이 Repository는 특정 Account에 종속되어 있지 않다.
Account
Message
Schedule
기타 Entity
를 모두 처리할 수 있다.
AccountRepository 코드가 크게 줄어든다
기존 코드는 다음처럼 여러 메서드를 직접 구현했다.
public class AccountRepository {
private final Map<Long, Account> repository =
new HashMap<>();
public void save(Account account) {
...
}
public Account findById(Long id) {
...
}
public List<Account> findAll() {
...
}
}
제네릭 Repository를 만들었다면 간단하게 만들 수 있다.
public class AccountRepository
extends GenericRepository<Account> {
}
끝이다.
GenericRepository의 E가 Account로 결정된다.
개념적으로 다음처럼 생각할 수 있다.
GenericRepository<E>
E = Account
↓
save(Account)
findById() → Account
findAll() → List<Account>
MessageRepository도 동일하다
public class MessageRepository
extends GenericRepository<Message> {
}
역시 별도의 CRUD 구현을 작성할 필요가 없다.
GenericRepository<E>
E = Message
이 되기 때문이다.
타입 안전성도 유지된다
AccountRepository에는 Account만 넣을 수 있다.
AccountRepository repository =
new AccountRepository();
repository.save(
new Account(1L, "Yoon")
);
다음 코드는 허용되지 않는다.
repository.save(
new Message(
1L,
"hello"
)
);
즉 코드 중복을 제거했다고 해서 타입 안전성을 포기한 것이 아니다.
오히려 제네릭이 이를 컴파일 단계에서 보장해준다.
제네릭이 없었다면 Object를 사용할 수도 있다
중복을 없애겠다는 이유로 다음처럼 만들 수도 있다.
public class Repository {
private final Map<Long, Object> repository =
new HashMap<>();
public void save(
Long id,
Object entity
) {
repository.put(id, entity);
}
public Object findById(Long id) {
return repository.get(id);
}
}
중복은 제거했다.
하지만 타입 안전성을 잃었다.
repository.save(
1L,
new Account(...)
);
repository.save(
2L,
"hello"
);
repository.save(
3L,
100
);
무엇이든 들어간다.
조회할 때도 형변환이 필요하다.
Account account =
(Account) repository.findById(
1L
);
잘못된 타입으로 형변환하면 런타임 오류가 발생한다.
제네릭은 재사용성과 타입 안전성을 함께 가져간다
이 차이가 중요하다.
Object를 이용한 일반화는 다음과 같다.
중복 제거
O
타입 안전성
X
제네릭을 이용한 일반화는
중복 제거
O
타입 안전성
O
다.
즉 제네릭은 단순히
코드를 짧게 만드는 기술
이 아니다.
다양한 타입에서 코드를 재사용하면서도
타입 정보를 잃지 않는 기술
이라고 보는 것이 더 정확하다.
한정적 타입 매개변수가 중요한 이유
이번 예제에서 단순히 다음처럼 작성하지 않았다.
GenericRepository<E>
다음처럼 작성했다.
GenericRepository<E extends Entity>
두 설계 사이에는 큰 차이가 있다.
아무 타입이나 허용
GenericRepository<E>
다음도 가능하다.
GenericRepository<String>
GenericRepository<Integer>
하지만 Repository가 getId()를 필요로 한다면 적합하지 않다.
Entity 계층만 허용
GenericRepository<
E extends Entity
>
이제 다음만 허용된다.
Account
Message
Schedule
...
즉 Repository가 요구하는 최소 계약을 타입 시스템으로 표현했다.
제네릭 타입의 경계는 공통 기능을 결정한다
다음 코드가 있다고 하자.
<T>
이 상태에서 T를 이용할 때 컴파일러가 확실하게 알고 있는 가장 일반적인 타입은 Object 수준이다.
그래서 사실상 다음 정도만 확실히 사용할 수 있다.
value.toString();
물론 이것도 Object가 제공하는 메서드이기 때문이다.
하지만
<T extends Entity>
로 바꾸면 Entity 계약도 사용할 수 있다.
value.getId();
즉 타입 경계를 지정하면
이 타입이 최소한 무엇을 할 수 있는가?
를 컴파일러에게 알려주는 효과가 있다.
새로운 Entity를 추가해보자
이제 새로운 요구사항이 생겼다.
Schedule이라는 엔티티를 추가한다.
public class Schedule
implements Entity {
private final Long id;
private final String title;
public Schedule(
Long id,
String title
) {
this.id = id;
this.title = title;
}
@Override
public Long getId() {
return id;
}
public String getTitle() {
return title;
}
}
기존 구조라면 ScheduleRepository 전체를 작성해야 했다.
public class ScheduleRepository {
private final Map<Long, Schedule> repository =
new HashMap<>();
public void save(
Schedule schedule
) {
...
}
public Schedule findById(
Long id
) {
...
}
public List<Schedule> findAll() {
...
}
}
하지만 GenericRepository가 있다면 다음이 끝이다.
public class ScheduleRepository
extends GenericRepository<Schedule> {
}
엔티티가 늘어날수록 효과가 커진다
처음에 엔티티가 두 개뿐이라면 코드 차이가 크게 느껴지지 않을 수도 있다.
Account
Message
그런데 시스템이 성장하면서
Account
Message
Schedule
Order
Member
Payment
Coupon
Notification
Shipment
처럼 늘어난다면 이야기가 달라진다.
각 Repository마다 동일한 로직을 50줄씩 작성한다고 가정하면 중복 코드가 계속 늘어난다.
제네릭 Repository가 있다면 공통 구현은 한 곳에만 존재한다.
GenericRepository
↑
┌────┼────┬──────┐
│ │ │ │
Account Message Schedule Order ...
공통 코드를 수정할 때도 한 곳만 수정하면 된다.
생산성의 차이는 새로운 타입을 추가할 때 드러난다
제네릭 적용 전에는 새로운 엔티티가 하나 추가될 때 다음 작업이 필요할 수 있다.
기존 Repository 찾기
↓
복사
↓
클래스 이름 변경
↓
타입 변경
↓
변수 이름 변경
↓
놓친 코드 확인
↓
테스트
제네릭 적용 후에는 상당 부분 단순화된다.
public class ScheduleRepository
extends GenericRepository<Schedule> {
}
공통 코드가 이미 검증되어 있기 때문이다.
공통 버그 수정도 한 곳에서 가능하다
예를 들어 Repository의 save() 정책에 검증 로직이 추가되었다고 생각해보자.
public void save(E entity) {
Objects.requireNonNull(entity);
repository.put(
entity.getId(),
entity
);
}
제네릭을 적용하지 않았다면 이 검증을 모든 Repository에 넣어야 할 수도 있다.
AccountRepository
MessageRepository
ScheduleRepository
OrderRepository
제네릭 공통 구현에서는 한 번 수정하면 된다.
GenericRepository 수정
↓
모든 하위 Repository에 적용
이것이 중복 제거가 유지보수성으로 이어지는 이유다.
제네릭 Repository를 만들기 전 테스트가 중요하다
이런 변경은 코드 구조를 크게 바꾸는 리팩터링에 해당한다.
리팩터링의 중요한 원칙은 다음과 같다.
구조는 바꾸지만
외부 동작은 바꾸지 않는다.
따라서 기존 코드를 변경하기 전에 테스트가 있어야 한다.
예를 들어 AccountRepository에 다음과 같은 테스트를 만들 수 있다.
@Test
void saveAndFindAccount() {
AccountRepository repository =
new AccountRepository();
Account account =
new Account(
1L,
"Yoon"
);
repository.save(account);
Account result =
repository.findById(1L);
assertEquals(
account,
result
);
}
리팩터링 전후에 동일한 테스트를 통과해야 한다
흐름은 다음과 같다.
현재 코드
↓
테스트 작성
↓
테스트 성공
↓
GenericRepository로 리팩터링
↓
기존 테스트 다시 실행
↓
테스트 성공
이렇게 하면
코드 구조는 크게 변경했지만
기존 기능은 유지되었다.
는 사실을 검증할 수 있다.
공통화를 너무 성급하게 하면 안 된다
제네릭이 중복 제거에 강력하다고 해서 비슷해 보이는 코드라면 무조건 공통화해야 하는 것은 아니다.
예를 들어 처음에는 두 Repository가 동일해 보인다.
AccountRepository
MessageRepository
하지만 시간이 지나
AccountRepository
→ 계정 상태 검증 필요
MessageRepository
→ 메시지 보관 정책 필요
처럼 규칙이 크게 달라질 수도 있다.
따라서 공통화를 할 때는 단순히 코드 모양만 보는 것이 아니라
두 코드의 책임과 변경 이유가 실제로 같은가?
도 생각해야 한다.
공통 로직과 개별 로직을 분리할 수 있다
공통 Repository를 사용하더라도 특정 엔티티만의 기능은 하위 Repository에 추가하면 된다.
public class AccountRepository
extends GenericRepository<Account> {
public Account findByName(
String name
) {
// Account 전용 조회
return null;
}
}
공통 기능은 상위 클래스에 있다.
save()
findById()
findAll()
특화 기능은 구체 Repository에 둔다.
AccountRepository
→ findByName()
이렇게 공통성과 특수성을 나눌 수 있다.
제네릭을 활용할 수 있는 다른 사례
이 패턴은 Repository에만 국한되지 않는다.
핵심 조건은 다음이다.
전체 알고리즘은 같다.
+
처리하는 타입만 달라진다.
이런 상황에서는 제네릭을 고려할 수 있다.
백업 시스템
예를 들어 여러 엔티티를 백업한다고 생각해보자.
Account Backup
Message Backup
Order Backup
Payment Backup
전체 흐름은 거의 비슷할 수 있다.
데이터 조회
↓
변환
↓
파일 생성
↓
저장
다른 것은 처리 대상 타입이다.
그렇다면 다음처럼 일반화할 수 있다.
public class BackupService<
E extends Entity
> {
public void backup(
List<E> entities
) {
// 공통 백업 처리
}
}
Excel 다운로드
여러 엔티티를 Excel 파일로 출력하는 코드도 비슷할 수 있다.
AccountExcelExporter
OrderExcelExporter
PaymentExcelExporter
파일 생성, Workbook 구성, 스트림 출력 같은 공통 과정은 동일하고 실제 셀에 넣는 데이터만 다를 수 있다.
이런 구조에서도 제네릭을 이용한 공통화를 검토할 수 있다.
다만 엔티티마다 어떤 필드를 출력해야 하는지까지 완전히 동일한 것은 아니므로 해당 차이는 별도의 전략이나 메타데이터 등으로 분리할 필요가 있다.
Reflection과 함께 활용할 수도 있다
여러 객체의 필드 구조를 읽어서 공통 처리해야 하는 경우에는 Reflection과 제네릭을 함께 활용하는 방법도 생각할 수 있다.
예를 들어 개념적으로 다음과 같은 구조다.
public class CsvExporter<T> {
public void export(
List<T> values
) {
Class<?> type =
values.get(0)
.getClass();
Field[] fields =
type.getDeclaredFields();
// ...
}
}
제네릭은
어떤 타입의 객체를 처리하는가?
를 표현하고, Reflection은
그 객체가 실제로 어떤 필드를 가지고 있는가?
를 런타임에 조사하는 역할을 할 수 있다.
다만 Reflection은 캡슐화, 성능, 타입 안정성, 접근 제어 등 별도의 고려사항이 있으므로 단순히 중복을 줄이기 위해 무조건 적용해야 하는 것은 아니다.
핵심은 제네릭을 다른 기술과 조합하면 반복되는 타입별 처리 코드를 더 일반화할 수 있다는 점이다.
제네릭을 적용할 수 있는 신호
실제 코드를 보면서 다음 상황이 반복된다면 제네릭을 고려해볼 수 있다.
클래스 구조가 동일하다.
↓
메서드 구조도 동일하다.
↓
필드 구조도 거의 동일하다.
↓
타입 이름만 계속 바뀐다.
예를 들면 다음과 같다.
Account save(Account account)
Message save(Message message)
Schedule save(Schedule schedule)
이런 코드가 반복된다면
E save(E entity)
로 일반화할 수 있는지 살펴본다.
하지만 모든 중복을 제네릭으로 없애는 것은 아니다
다음처럼 코드가 비슷해 보이더라도 의미가 완전히 다르면 억지로 제네릭화하지 않는 것이 좋다.
PaymentProcessor
NotificationProcessor
둘 다 process()라는 이름을 가지고 있다는 이유만으로 하나의 제네릭 클래스에 넣을 필요는 없다.
중요한 질문은 다음과 같다.
단지 코드 모양이 비슷한가?
아니면 실제 알고리즘과 책임이 같은가?
후자일 때 제네릭을 통한 추상화가 더 의미 있다.
제네릭은 중복 코드를 제거하기 위한 타입 수준의 추상화다
객체지향에서는 공통 동작을 다음과 같이 추상화할 수 있다.
상속
인터페이스
합성
제네릭은 또 다른 차원의 추상화를 제공한다.
"이 로직에서 사용되는 타입만 다르다."
는 차이를 타입 매개변수로 추상화한다.
예를 들어
AccountRepository
와
MessageRepository
의 차이를
<E>
하나로 추상화하는 것이다.
상속과 제네릭의 역할을 구분하자
이번 예제에서는 다음 두 개념이 함께 사용된다.
public class GenericRepository<
E extends Entity
> {
}
extends Entity는 E가 사용할 수 있는 타입의 범위를 제한한다.
그리고
public class AccountRepository
extends GenericRepository<Account> {
}
의 extends는 실제 클래스 상속이다.
겉으로는 둘 다 extends를 사용하지만 역할이 다르다.
첫 번째는 제네릭의 상위 경계(Upper Bound) 다.
<E extends Entity>
의미는
E는 Entity 계층이어야 한다.
다.
두 번째는 실제 상속 관계다.
AccountRepository
extends GenericRepository<Account>
의미는
AccountRepository는
GenericRepository<Account>의
하위 클래스다.
이다.
제네릭이 주는 생산성의 본질
GenericRepository를 도입했을 때 줄어드는 것은 단순한 코드 줄 수만이 아니다.
중복 구현 감소
↓
버그 발생 위치 감소
↓
공통 변경 지점 감소
↓
새로운 타입 추가 비용 감소
↓
테스트 범위 단순화
↓
유지보수성 향상
특히 타입 수가 계속 증가하는 시스템일수록 효과가 커진다.
기존 방식과 제네릭 방식 비교
제네릭 적용 전
AccountRepository
├─ save()
├─ findById()
└─ findAll()
MessageRepository
├─ save()
├─ findById()
└─ findAll()
ScheduleRepository
├─ save()
├─ findById()
└─ findAll()
모든 곳에 동일한 코드가 존재한다.
제네릭 적용 후
GenericRepository<E extends Entity>
├─ save(E)
├─ findById()
└─ findAll()
↑
┌────┼───────┐
│ │ │
Account Message Schedule
Repository Repository Repository
공통 코드는 한 곳에만 있다.
각 Repository는 자신이 사용하는 타입만 결정한다.
핵심 정리
- 제네릭은 Java 컬렉션을 사용할 때뿐 아니라 직접 공통 API와 클래스를 설계할 때 더 큰 장점을 얻을 수 있다.
- 타입만 다르고 구현 구조가 동일한 코드가 반복된다면 제네릭을 적용할 수 있는지 검토할 수 있다.
AccountRepository와MessageRepository처럼 Repository 구현이 거의 동일하고 엔티티 타입만 다르다면 하나의GenericRepository<E>로 일반화할 수 있다.- 공통 엔티티 인터페이스에
getId()를 정의하면 여러 엔티티가 동일한 최소 계약을 가질 수 있다. - 단순히
<E>로 정의하면 컴파일러는E가 어떤 타입인지 모르기 때문에getId()같은 엔티티 전용 메서드를 사용할 수 없다. <E extends Entity>처럼 한정적 타입 매개변수를 사용하면E가 반드시Entity계층이라는 사실을 컴파일러에게 알려줄 수 있다.- 그 결과 GenericRepository 안에서도
entity.getId()를 타입 안전하게 호출할 수 있다. GenericRepository<Account>에서는E가Account로 결정되므로save()는Account만 받을 수 있고findById()역시Account를 반환한다.Object를 사용해서도 공통 코드를 만들 수 있지만 타입 안전성을 잃고 형변환이 필요해진다.- 제네릭은 코드 재사용과 타입 안전성을 동시에 얻을 수 있다는 점이 중요하다.
- 새로운
Schedule같은 엔티티가 추가되어도 공통 Repository 로직을 다시 복사할 필요 없이 타입만 지정하면 된다. - 엔티티 수가 증가할수록 코드 중복 제거와 유지보수 비용 감소 효과가 커진다.
- 공통 Repository에서 버그를 수정하거나 기능을 추가하면 모든 구체 Repository가 동일한 공통 구현을 사용할 수 있다.
- 기존 코드를 제네릭 구조로 변경하는 것은 리팩터링이므로 변경 전에 테스트를 만들어 기존 동작을 검증하는 것이 중요하다.
- 공통 기능은 GenericRepository에 두고 특정 엔티티에만 필요한 기능은 구체 Repository에 남길 수 있다.
- Repository뿐 아니라 백업, 데이터 내보내기, 변환 등 전체 알고리즘은 같고 처리 타입만 다른 코드에서도 제네릭을 활용할 수 있다.
- Reflection 같은 기술과 조합하면 타입별 반복 코드를 더 일반화할 수도 있지만 필요성과 복잡성을 함께 고려해야 한다.
- 단순히 코드 모양이 비슷하다는 이유만으로 무조건 제네릭화해서는 안 되고 실제 책임과 변경 이유가 같은지를 확인해야 한다.
- 제네릭의 중요한 역할 중 하나는 타입의 차이를 매개변수로 추상화하여 동일한 알고리즘을 여러 타입에 안전하게 재사용하도록 만드는 것이다.
한 줄 정리
AccountRepository,MessageRepository처럼 로직은 같고 다루는 타입만 다른 코드가 반복된다면<E extends Entity>같은 제네릭과 한정적 타입 매개변수를 이용해 공통 구현을 하나로 모을 수 있으며, 이를 통해 타입 안전성을 유지하면서 중복 코드와 새로운 타입 추가 비용, 유지보수 비용을 크게 줄일 수 있다.