이펙티브 자바 완벽 공략 2부
아이템 27. 비검사 경고를 제거 하라
아이템 27. 비검사 경고를 제거 하라
- 아이템 27. 비검사 경고를 제거 하라
- 아이템 27. 핵심 정리
- 아이템 27. 비검사 경고를 제거하라
- 컴파일 에러와 컴파일 경고는 다르다
- 비검사 경고란 무엇인가?
- Raw Type은 타입 안전성을 깨뜨릴 수 있다
- 컴파일러는 이런 위험을 경고한다
- 비검사 경고는 가능하면 모두 제거한다
- 제네릭 타입을 명시하면 경고와 위험이 함께 사라진다
- Java 7부터는 다이아몬드 연산자를 사용할 수 있다
- 다이아몬드 연산자를 사용하는 이유
- 비검사 경고를 항상 제거할 수 있는 것은 아니다
- @SuppressWarnings란?
- @SuppressWarnings는 unchecked 전용 애노테이션이 아니다
- 무조건 @SuppressWarnings부터 붙이면 안 된다
- 컴파일러가 증명하지 못한다고 코드가 반드시 위험한 것은 아니다
- ArrayList 같은 제네릭 구현에서도 비슷한 문제가 생길 수 있다
- 제거할 수 없는 비검사 경고는 억제하는 편이 좋다
- 경고는 신호 역할을 해야 한다
- @SuppressWarnings의 범위는 가능한 한 작게 만든다
- 경고 억제 범위가 넓을수록 새로운 문제를 숨길 수 있다
- 지역 변수로 분리해서 범위를 줄일 수 있다
- 메서드 전체보다는 작은 범위가 좋다
- @SuppressWarnings를 클래스 전체에 붙이는 것은 더 위험하다
- 왜 경고를 억제했는지 주석으로 남긴다
- 주석은 미래의 코드를 보호한다
- 경고를 제거하는 것과 경고를 숨기는 것은 다르다
- 좋은 처리 흐름
- 비검사 경고를 무시하는 습관이 위험한 이유
- 실무에서 경고가 많은 코드베이스가 위험한 이유
- @SuppressWarnings는 타입 안전성 보증서가 아니다
- 타입 안전성을 유지하는 제네릭 코드
- 아이템 26과 아이템 27 연결하기
- 핵심 정리
- 한 줄 정리
- 아이템 27. 완벽 공략 - 애너테이션
- 아이템 27 완벽 공략: Java 애너테이션과 @Target, @Retention, @Documented
- 애너테이션도 하나의 타입이다
- 메타 애너테이션이란?
- @Target은 애너테이션을 어디에 사용할 수 있는지 결정한다
- TYPE에만 허용하면 메서드에는 붙일 수 없다
- 여러 위치에 사용할 수도 있다
- 대표적인 ElementType
- 아이템 27의 @SuppressWarnings와 연결해보자
- return 문과 지역 변수 선언은 다르다
- @Target은 애너테이션 API의 일부다
- @Retention은 애너테이션 정보를 언제까지 유지할지 결정한다
- 중요한 점: 기본 Retention은 CLASS다
- RetentionPolicy.SOURCE
- SOURCE는 컴파일 과정에서만 필요한 정보에 적합하다
- 애너테이션 프로세싱과 SOURCE
- RetentionPolicy.CLASS
- CLASS 정책은 바이트코드 도구에서 활용할 수 있다
- RetentionPolicy.RUNTIME
- CLASS로 바꾸면 Reflection 결과가 달라진다
- 런타임 프레임워크에서는 RUNTIME이 중요하다
- Spring에서 애너테이션을 많이 사용하는 이유와도 연결된다
- Retention을 시간 축으로 이해해보자
- 어떤 Retention을 선택해야 할까?
- @Documented는 Javadoc에 애너테이션 정보를 포함시킨다
- API 계약의 일부라면 @Documented를 고려할 수 있다
- @Documented와 @Retention은 목적이 다르다
- 커스텀 애너테이션 전체 예제
- 애너테이션은 메타데이터다
- 애너테이션 자체는 아무 동작도 하지 않는다
- 컴파일 타임 애너테이션과 런타임 애너테이션
- 바이트코드 기반 처리도 있다
- @Target과 @Retention을 함께 생각하자
- 애너테이션 설계 예제: 실행 시간 측정
- 애너테이션 설계 예제: 소스 검사용 힌트
- @SuppressWarnings가 좋은 예제인 이유
- 왜 애너테이션 적용 범위를 이해해야 할까?
- 세 가지 메타 애너테이션 비교
- Retention 세 가지 다시 비교하기
- 커스텀 애너테이션을 만들 때 체크할 것
- 필요 이상으로 RUNTIME을 사용하지 않아도 된다
- 핵심 정리
- 한 줄 정리
- 아이템 27. 핵심 정리
아이템 27. 핵심 정리
아이템 27. 비검사 경고를 제거하라
제네릭 코드를 작성하다 보면 컴파일러가 다음과 같은 경고를 보여주는 경우가 있다.
unchecked
또는
unchecked warning
Java에서는 여러 종류의 컴파일 경고가 발생할 수 있는데, 이번 아이템에서 집중하는 것은 그중에서도 비검사 경고(Unchecked Warning) 다.
여기서 주의할 점은 unchecked warning과 unchecked exception은 전혀 다른 개념이라는 것이다.
unchecked warning
→ 컴파일러의 타입 안전성 관련 경고
unchecked exception
→ 예외 처리 체계에서 사용하는 개념
이름에 unchecked라는 단어가 공통으로 들어갈 뿐 서로 별개의 내용이다.
컴파일 에러와 컴파일 경고는 다르다
먼저 컴파일 에러와 컴파일 경고의 차이를 이해할 필요가 있다.
컴파일 에러
컴파일러가 코드를 정상적인 프로그램으로 만들 수 없다고 판단하는 경우다.
예를 들어 다음과 같은 코드는 컴파일할 수 없다.
int number = "hello";
타입 자체가 맞지 않는다.
Compile
↓
Error
↓
컴파일 실패
↓
실행 불가능
컴파일 경고
경고는 다르다.
컴파일러가
"이 코드는 컴파일할 수는 있지만
위험할 가능성이 있습니다."
라고 알려주는 것이다.
즉 일반적으로
Compile
↓
Warning
↓
컴파일 가능
↓
실행 가능
한 상태다.
프로그램을 만들 수 없는 정도의 오류는 아니지만 안전성이나 코드 품질 측면에서 확인해야 할 부분이 있다는 뜻이다.
비검사 경고란 무엇인가?
비검사 경고는 제네릭과 관련해서 컴파일러가 타입 안전성을 완전히 확인할 수 없는 상황에서 발생한다.
예를 들어 Raw Type을 사용한다고 생각해보자.
Set set = new HashSet();
Set은 원래 제네릭 타입이다.
개념적으로 다음과 같이 선언되어 있다.
Set<E>
그런데 타입 인수를 지정하지 않았다.
Set set
즉 Raw Type을 사용했다.
이 상태에서는 컬렉션이 어떤 타입의 값을 다루는지 알 수 없다.
Raw Type은 타입 안전성을 깨뜨릴 수 있다
다음 코드를 보자.
Set set = new HashSet();
set.add("100");
set.add(200);
String도 들어가고 Integer도 들어간다.
컬렉션 내부는 다음과 같은 상태가 될 수 있다.
Set
├─ String
└─ Integer
그런데 개발자가 이 컬렉션에는 Integer만 들어 있다고 생각한다고 해보자.
for (Object value : set) {
Integer number =
(Integer) value;
System.out.println(
number * 2
);
}
Integer인 값에서는 문제가 없지만 String인 "100"을 Integer로 형변환하려는 순간 문제가 발생한다.
ClassCastException
잘못된 데이터는 훨씬 이전에 들어갔지만 실제 문제는 나중에 발견된다.
컴파일러는 이런 위험을 경고한다
컴파일러는 Raw Type과 제네릭 코드가 섞였을 때
unchecked
관련 경고를 보여줄 수 있다.
IDE에서도 이런 코드를 작성하면 보통 노란색 경고 표시를 볼 수 있다.
중요한 점은 경고 메시지를 단순히 귀찮은 메시지로 생각하지 않는 것이다.
경고는 컴파일러가
현재 코드만으로는 타입 안전성을 보장하기 어렵다.
라고 알려주는 신호다.
비검사 경고는 가능하면 모두 제거한다
이번 아이템의 첫 번째 원칙은 단순하다.
제거할 수 있는 비검사 경고는 모두 제거한다.
Raw Type 때문에 경고가 발생하고 있다면 타입을 정확하게 선언하면 된다.
예를 들어 다음 코드가 있다.
Set numbers =
new HashSet();
Integer를 저장하려는 Set이라면 다음처럼 변경한다.
Set<Integer> numbers =
new HashSet<Integer>();
이제 컴파일러는 numbers가 Integer만 다룬다는 사실을 알 수 있다.
numbers.add(100);
은 가능하지만
numbers.add("100");
은 컴파일되지 않는다.
제네릭 타입을 명시하면 경고와 위험이 함께 사라진다
중요한 것은 단순히 노란색 경고 표시를 없앤 것이 아니다.
다음 두 가지가 동시에 개선된다.
비검사 경고 제거
+
타입 안전성 확보
컴파일러가 코드에 대한 더 많은 정보를 가지게 되었기 때문이다.
Raw Type
Set
↓
무엇을 담는지 알 수 없음
반면
Parameterized Type
Set<Integer>
↓
Integer만 다룬다는 사실을 알 수 있음
이다.
Java 7부터는 다이아몬드 연산자를 사용할 수 있다
제네릭이 처음 도입되었을 때는 다음처럼 타입을 반복해서 작성해야 했다.
Set<Integer> numbers =
new HashSet<Integer>();
왼쪽에서 이미 Integer라고 선언했는데 오른쪽에서도 다시 Integer를 적어야 한다.
Java 7부터는 이를 간단하게 작성할 수 있다.
Set<Integer> numbers =
new HashSet<>();
오른쪽의
<>
를 다이아몬드 연산자(Diamond Operator) 라고 부른다.
컴파일러가 왼쪽의 타입 정보를 이용해서 오른쪽의 타입을 추론한다.
Set<Integer>
↓
new HashSet<>()
↓
Integer라는 타입을 컴파일러가 추론
따라서 같은 타입을 반복해서 적을 필요가 없다.
다이아몬드 연산자를 사용하는 이유
다음 두 코드는 의미상 동일하게 사용할 수 있다.
Map<String, Integer> map =
new HashMap<String, Integer>();
Map<String, Integer> map =
new HashMap<>();
두 번째 코드가 훨씬 간결하다.
특히 타입이 복잡해질수록 차이가 커진다.
Map<String, List<Integer>> map =
new HashMap<String, List<Integer>>();
다이아몬드 연산자를 사용하면
Map<String, List<Integer>> map =
new HashMap<>();
로 줄일 수 있다.
제네릭 타입은 정확하게 유지하면서 불필요한 반복은 제거할 수 있다.
비검사 경고를 항상 제거할 수 있는 것은 아니다
이상적으로는 모든 비검사 경고를 타입 선언을 수정해서 제거하면 좋다.
하지만 실제 제네릭 코드를 구현하다 보면 컴파일러가 타입 안전성을 완벽하게 증명할 수 없는 경우가 존재할 수 있다.
개발자는 코드 구조상 안전하다는 사실을 알고 있지만 컴파일러에게 그것을 충분히 표현하기 어려운 경우다.
대표적으로 제네릭 배열이나 내부 구현을 다루는 코드 등에서 이런 상황이 생길 수 있다.
이럴 때 사용할 수 있는 것이
@SuppressWarnings("unchecked")
다.
@SuppressWarnings란?
@SuppressWarnings는 특정 컴파일 경고를 표시하지 않도록 만드는 애노테이션이다.
이름 자체를 풀어보면 의미가 명확하다.
Suppress
→ 억제하다
Warnings
→ 경고들
즉
"이 경고는 알고 있으니
이 위치에서는 표시하지 말아 달라."
는 의미다.
비검사 경고를 억제하려면 다음과 같이 작성한다.
@SuppressWarnings("unchecked")
여기에서 "unchecked"가 어떤 종류의 경고를 억제할 것인지 지정한다.
@SuppressWarnings는 unchecked 전용 애노테이션이 아니다
@SuppressWarnings 자체가 비검사 경고만을 위한 기능은 아니다.
@SuppressWarnings("unchecked")
에서 "unchecked"를 지정했기 때문에 현재는 비검사 경고를 억제하는 것이다.
즉 구조는 다음과 같다.
@SuppressWarnings
→ 경고를 억제하는 애노테이션
"unchecked"
→ 그중 비검사 경고를 억제
무조건 @SuppressWarnings부터 붙이면 안 된다
비검사 경고를 발견했다고 바로 다음처럼 작성하면 안 된다.
@SuppressWarnings("unchecked")
public void execute() {
...
}
먼저 해야 할 일은 경고의 원인을 제거하는 것이다.
Unchecked Warning 발견
↓
타입 선언을 수정해서
제거할 수 있는가?
├─ YES
│ ↓
│ 경고 원인 제거
│
└─ NO
↓
실제로 타입 안전한지 검증
↓
안전하다면
@SuppressWarnings("unchecked")
즉 @SuppressWarnings는 경고를 해결하는 첫 번째 수단이 아니라 마지막 수단에 가깝다.
컴파일러가 증명하지 못한다고 코드가 반드시 위험한 것은 아니다
예를 들어 어떤 내부 구현에서 배열을 사용하고 있다고 생각해보자.
개념적으로 다음과 같은 상황이 있을 수 있다.
E[] elements =
(E[]) new Object[capacity];
컴파일러는
Object[]
→
E[]
형변환이 실제로 안전한지 확인할 수 없다.
따라서 비검사 형변환 경고를 발생시킬 수 있다.
하지만 클래스 전체 구조를 살펴봤을 때 elements에 오직 E와 호환되는 값만 들어오도록 모든 진입 경로가 통제되어 있다면 개발자는 타입 안전성을 논리적으로 증명할 수도 있다.
이처럼
컴파일러는 증명하지 못함
하지만
개발자는 코드 불변식으로 안전성을 증명 가능
한 경우에만 경고 억제를 고려해야 한다.
ArrayList 같은 제네릭 구현에서도 비슷한 문제가 생길 수 있다
제네릭 컨테이너를 직접 구현하면 내부적으로 타입 소거 등의 제약 때문에 컴파일러가 완벽하게 타입을 확인하지 못하는 코드가 생길 수 있다.
중요한 것은
경고가 귀찮아서 없앤다.
가 아니라
이 코드는 왜 타입 안전한지
개발자가 설명할 수 있다.
는 전제가 있어야 한다는 것이다.
제거할 수 없는 비검사 경고는 억제하는 편이 좋다
처음 보면 이런 의문이 생길 수 있다.
"이미 안전한 걸 알고 있다면
그냥 경고를 남겨두면 되지 않을까?"
하지만 경고를 그대로 남겨두는 것도 좋은 방법은 아니다.
예를 들어 프로젝트가 항상 다음 경고를 발생시킨다고 생각해보자.
warning 1
warning 2
warning 3
warning 4
warning 5
개발자들이
"원래 발생하는 경고야."
라고 생각하기 시작한다.
그런데 나중에 새로운 위험한 코드가 추가되어
warning 6
이 발생한다.
기존 경고가 너무 많으면 새 경고가 묻힐 가능성이 있다.
경고는 신호 역할을 해야 한다
가장 이상적인 상태는 다음과 같다.
평상시
Warning = 0
이 상태에서 새로운 경고가 발생하면 바로 눈에 들어온다.
Warning = 1
↓
새로운 문제가 생겼다.
반면 기존 경고를 계속 방치하면
Warning = 43
↓
44개로 증가
해도 새로운 문제가 추가되었다는 사실을 쉽게 알아차리지 못할 수 있다.
따라서
실제로 안전함을 검증했다.
+
경고를 코드 구조상 제거할 방법이 없다.
면 정확하게 @SuppressWarnings를 적용해서 알고 있는 경고를 제거해두는 것이 오히려 새로운 경고를 발견하는 데 도움이 된다.
@SuppressWarnings의 범위는 가능한 한 작게 만든다
다음과 같은 메서드가 있다고 하자.
@SuppressWarnings("unchecked")
public E[] toArray() {
// 여러 줄의 로직
return (E[]) elements;
}
메서드 전체에 애노테이션을 붙이면 이 메서드 안에서 발생하는 모든 unchecked 경고가 억제될 수 있다.
현재 알고 있는 위험한 지점은 하나뿐이라고 해보자.
(E[]) elements
그런데 나중에 누군가 메서드 중간에 새로운 코드를 추가한다.
SomeType value =
(SomeType) something;
여기에서도 새로운 비검사 경고가 생길 수 있다.
하지만 메서드 전체에
@SuppressWarnings("unchecked")
가 붙어 있으면 새로운 경고까지 함께 숨겨질 가능성이 있다.
경고 억제 범위가 넓을수록 새로운 문제를 숨길 수 있다
구조적으로 다음과 같다.
Method 전체
┌─────────────────────────┐
│ @SuppressWarnings │
│ │
│ 안전한 코드 │
│ │
│ 의도적으로 억제한 코드 │
│ │
│ 새로 추가된 위험 코드 │
│ │
└─────────────────────────┘
↓
모두 경고 억제
이것은 좋지 않다.
우리가 원하는 것은 정확히 문제가 되는 한 지점만 억제하는 것이다.
지역 변수로 분리해서 범위를 줄일 수 있다
문제의 표현식에 직접 애노테이션을 붙일 수 없는 경우도 있다.
예를 들어 return 문 자체에는 원하는 방식으로 애노테이션을 붙이기 어려울 수 있다.
그렇다면 반환할 값을 지역 변수에 먼저 담아 범위를 좁힐 수 있다.
@SuppressWarnings("unchecked")
E[] result =
(E[]) elements;
return result;
이렇게 하면 @SuppressWarnings("unchecked")가 적용되는 범위가 매우 작아진다.
오직 result 변수 초기화 영역
정도로 제한할 수 있다.
메서드 전체보다는 작은 범위가 좋다
다음보다
@SuppressWarnings("unchecked")
public E[] toArray() {
// 수십 줄의 코드
return (E[]) elements;
}
가능하다면 다음처럼 작성하는 편이 좋다.
public E[] toArray() {
// 다른 로직
@SuppressWarnings("unchecked")
E[] result =
(E[]) elements;
return result;
}
이제 다른 곳에서 새로운 unchecked 경고가 발생하면 컴파일러가 정상적으로 알려줄 수 있다.
@SuppressWarnings를 클래스 전체에 붙이는 것은 더 위험하다
다음과 같은 코드는 특히 신중해야 한다.
@SuppressWarnings("unchecked")
public class GenericContainer<E> {
// 수백 줄의 코드
}
클래스 전체에 경고 억제가 적용되면 앞으로 이 클래스에 추가되는 모든 비검사 경고가 숨겨질 수 있다.
현재 알고 있는 경고
+
미래에 추가될 경고
↓
모두 억제
따라서 가능한 한
Class
↓
Method
↓
Local Variable
순으로 더 작은 범위를 선호해야 한다.
핵심은
경고를 억제해야 한다면 그 경고가 실제로 발생하는 가장 작은 범위에만 적용한다.
는 것이다.
왜 경고를 억제했는지 주석으로 남긴다
@SuppressWarnings("unchecked")가 붙어 있다는 것은 컴파일러가 안전하다고 증명하지 못한 코드를 개발자가 대신 안전하다고 판단했다는 의미다.
따라서 다음 개발자가 코드를 보면 자연스럽게 궁금해질 수 있다.
왜 이 경고를 무시해도 되는가?
이 질문에 답할 수 있도록 근거를 남겨두는 것이 좋다.
예를 들어 다음과 같이 작성할 수 있다.
// elements에는 오직 E 타입의 값만 저장되므로
// 이 형변환은 타입 안전하다.
@SuppressWarnings("unchecked")
E[] result =
(E[]) elements;
중요한 것은
"경고가 떠서 붙였다."
가 아니라
"왜 실제로 타입 안전한지"
를 설명하는 것이다.
주석은 미래의 코드를 보호한다
타입 안전성을 설명하는 주석은 단순한 친절 이상의 의미가 있다.
현재 클래스의 불변식이 다음과 같다고 하자.
elements에는 E 타입만 저장한다.
이 조건 때문에 형변환이 안전하다.
그런데 나중에 누군가 코드를 변경해서 elements에 다른 타입을 넣을 수 있게 만들어 버린다면 어떻게 될까?
기존 주석을 읽은 개발자는
"이 형변환의 안전성은
elements에 E만 저장된다는 조건에 의존하고 있구나."
라고 알 수 있다.
즉 주석이 단순 설명을 넘어 타입 안전성을 유지하기 위해 깨뜨리면 안 되는 조건을 문서화하는 역할도 한다.
경고를 제거하는 것과 경고를 숨기는 것은 다르다
두 상황을 반드시 구분해야 한다.
경고 제거
Set set =
new HashSet();
를
Set<String> set =
new HashSet<>();
로 변경한다.
컴파일러가 타입 안전성을 확인할 수 있게 되었기 때문에 경고 자체의 원인이 사라졌다.
경고 숨기기
@SuppressWarnings("unchecked")
를 사용한다.
컴파일러는 여전히 증명하지 못하지만 개발자가 안전하다고 판단하고 경고 표시만 제거하는 것이다.
따라서 항상 첫 번째 방법을 우선해야 한다.
원인 제거
>
경고 억제
좋은 처리 흐름
비검사 경고를 만났다면 다음 순서로 처리하는 것이 좋다.
Unchecked Warning 발생
↓
왜 발생했는지 확인
↓
Raw Type 때문인가?
├─ YES
│
│ 매개변수화 타입으로 변경
│
│ List<String>
│ Set<Integer>
│ Map<K, V>
│
└─ NO
↓
타입 선언 개선으로 제거 가능한가?
├─ YES
│
│ 코드 수정
│
└─ NO
↓
실제 타입 안전성을
논리적으로 증명 가능한가?
├─ NO
│
│ 코드 설계 재검토
│
└─ YES
↓
가장 좁은 범위에
@SuppressWarnings("unchecked")
↓
왜 안전한지 주석 작성
비검사 경고를 무시하는 습관이 위험한 이유
제네릭 경고는 프로그램이 당장 실행되지 않는 오류가 아니다.
그래서 다음처럼 생각하기 쉽다.
"컴파일 되는데 굳이 고쳐야 하나?"
하지만 비검사 경고가 알려주는 것은 대개 타입 안전성과 관련된 문제다.
잘못하면
컴파일 성공
↓
서비스 실행
↓
문제 있는 경로는 한동안 실행되지 않음
↓
특정 데이터 입력
↓
ClassCastException
처럼 실제 오류가 늦게 나타날 수 있다.
특히 제네릭은 바로 이런 타입 오류를 컴파일 시점에 잡기 위해 사용하는 기능이다.
비검사 경고를 계속 무시한다면 제네릭을 통해 얻을 수 있는 중요한 장점을 스스로 약화시키게 된다.
실무에서 경고가 많은 코드베이스가 위험한 이유
프로젝트에 다음과 같은 경고가 계속 존재한다고 생각해보자.
100 warnings
개발자들은 점점 경고를 보지 않게 된다.
"원래 저 프로젝트는 경고가 많아."
이 상태가 되면 컴파일러의 경고 시스템이 사실상 의미를 잃는다.
새로운 문제가 발생해도
100 warnings
↓
101 warnings
의 차이를 알아차리기 어렵다.
반대로 경고를 꾸준히 제거해서
0 warnings
을 유지한다면 새 경고 하나가 매우 강한 신호가 된다.
0
↓
1
↓
새로운 코드에 문제가 있을 가능성이 있다.
그래서 경고를 가능한 한 제거하고, 의도적으로 남길 수밖에 없는 경우에는 정확히 억제해두는 것이 중요하다.
@SuppressWarnings는 타입 안전성 보증서가 아니다
다음 코드를 작성했다고 해서
@SuppressWarnings("unchecked")
코드가 안전해지는 것은 아니다.
이 애노테이션은 단순히
컴파일러 경고를 보여주지 않는다.
라는 역할을 한다.
즉
@SuppressWarnings
≠
타입 안전성 확보
다.
안전성을 증명하는 책임은 개발자에게 있다.
따라서 다음과 같은 사고방식은 위험하다.
경고 발생
↓
@SuppressWarnings 붙임
↓
해결 완료
올바른 흐름은 다음과 같다.
경고 발생
↓
코드 분석
↓
타입 안전성 확인
↓
컴파일러만 증명할 수 없는 상황인지 확인
↓
그때만 억제
타입 안전성을 유지하는 제네릭 코드
이번 아이템을 제네릭 전체의 관점에서 보면 중요한 흐름이 보인다.
아이템 26에서는 Raw Type을 사용하지 말라고 했다.
Raw Type
↓
타입 정보 손실
↓
타입 안전성 약화
아이템 27에서는 한 단계 더 나아간다.
제네릭을 사용하고 있더라도
↓
Unchecked Warning이 발생한다면
↓
컴파일러가 타입 안전성을
완전히 보장하지 못하고 있다는 뜻
↓
가능하면 제거
즉 두 아이템 모두 결국 타입 안전성이라는 동일한 목표를 가지고 있다.
아이템 26과 아이템 27 연결하기
아이템 26의 핵심은 다음과 같다.
List
대신
List<String>
또는 필요에 따라
List<?>
를 사용한다.
그리고 아이템 27에서는 그렇게 작성한 제네릭 코드에서도 컴파일러가 보여주는 비검사 경고를 그냥 넘기지 않는다.
아이템 26
타입 정보를 버리지 않는다.
↓
아이템 27
컴파일러가 타입 안전성을
확신하지 못하는 지점도 제거한다.
결국 목표는 하나다.
컴파일러가 가능한 한 많은
타입 오류를 실행 전에 찾아주게 한다.
핵심 정리
- Java 컴파일 과정에서는 컴파일 에러뿐 아니라 다양한 컴파일 경고가 발생할 수 있다.
- 컴파일 에러는 프로그램을 만들 수 없는 오류이지만 경고는 컴파일과 실행 자체는 가능하다.
- 비검사 경고는 컴파일러가 타입 안전성을 확인하는 데 필요한 정보가 충분하지 않을 때 발생하는 제네릭 관련 경고다.
unchecked warning은checked exception,unchecked exception과는 별개의 개념이다.- Raw Type을 사용하면 제네릭 타입 정보를 잃기 때문에 비검사 경고와 타입 안전성 문제가 발생할 수 있다.
- 제거할 수 있는 비검사 경고는 모두 제거하는 것이 좋다.
- Raw Type으로 인한 경고라면
Set<Integer>,List<String>처럼 적절한 매개변수화 타입을 선언해 원인 자체를 제거해야 한다. - Java 7부터 다이아몬드 연산자
<>를 이용하면 생성자에서 타입을 반복하지 않고도 제네릭 타입을 안전하게 사용할 수 있다. - 모든 비검사 경고를 코드 구조만으로 제거할 수 있는 것은 아니다.
- 개발자가 타입 안전성을 논리적으로 증명할 수 있지만 컴파일러가 이를 확인할 수 없는 경우
@SuppressWarnings("unchecked")를 사용할 수 있다. @SuppressWarnings는 비검사 경고만을 위한 애노테이션이 아니라 특정 종류의 컴파일 경고를 억제하는 기능이다.- 경고를 발견하자마자
@SuppressWarnings를 붙이면 안 되고 먼저 경고 원인을 제거할 수 있는지 확인해야 한다. - 제거할 수 없는 비검사 경고를 그대로 방치하면 기존 경고 속에 새로운 경고가 묻힐 수 있으므로 안전성을 확인했다면 정확하게 억제하는 편이 좋다.
@SuppressWarnings("unchecked")는 가능한 한 가장 좁은 범위에 적용해야 한다.- 메서드나 클래스 전체에 적용하면 이후 추가된 새로운 비검사 경고까지 함께 숨길 수 있다.
- 표현식에 직접 애노테이션을 적용하기 어려운 경우 지역 변수를 만들어 그 변수 선언에만 경고 억제를 적용할 수 있다.
- 경고를 억제할 때는 왜 해당 코드가 타입 안전한지를 설명하는 주석을 함께 남기는 것이 좋다.
@SuppressWarnings는 코드의 안전성을 만들어주는 기능이 아니라 경고 표시만 억제하므로 실제 타입 안전성을 증명할 책임은 개발자에게 있다.- 아이템 26과 아이템 27의 공통 목표는 컴파일러가 가능한 한 많은 타입 오류를 실행 전에 발견할 수 있도록 제네릭의 타입 안전성을 끝까지 유지하는 것이다.
한 줄 정리
비검사 경고는 컴파일러가 제네릭 코드의 타입 안전성을 완전히 증명하지 못하고 있다는 신호이므로 가능한 한 타입 선언을 수정해 모두 제거하고, 정말 제거할 수 없으면서 코드가 안전함을 증명할 수 있는 경우에만 가장 좁은 범위에
@SuppressWarnings("unchecked")를 적용하고 그 근거를 명확히 남겨야 한다.
아이템 27. 완벽 공략 - 애너테이션
아이템 27 완벽 공략: Java 애너테이션과 @Target, @Retention, @Documented
Java 코드를 작성하다 보면 애너테이션을 매우 자주 사용하게 된다.
@Override
public String toString() {
return "...";
}
@SuppressWarnings("unchecked")
E[] result = (E[]) elements;
@Deprecated
public void oldMethod() {
}
Spring을 사용한다면 더 익숙하다.
@Service
public class OrderService {
}
@GetMapping("/orders")
public List<Order> getOrders() {
...
}
@Transactional
public void order() {
...
}
애너테이션을 사용하는 일에는 익숙하지만, 막상 다음 질문을 받으면 정확하게 설명하기 어려울 수 있다.
어떤 애너테이션은 왜 클래스에만 붙일 수 있을까?
왜 어떤 애너테이션은 메서드에는 붙는데
return 문에는 붙지 않을까?
어떤 애너테이션은 Reflection으로 읽히고
어떤 애너테이션은 읽히지 않는 이유가 뭘까?
애너테이션 정보는 컴파일 후에도 남아 있을까?
이러한 동작을 결정하는 대표적인 메타 애너테이션이 있다.
@Target
@Retention
@Documented
이 세 가지를 이해하면 Java 애너테이션이 단순히 @Something을 붙이는 문법이 아니라 어디에 적용되고, 언제까지 유지되고, 어떤 도구에서 활용되는 메타데이터인지 이해할 수 있다.
애너테이션도 하나의 타입이다
직접 애너테이션을 정의하려면 다음과 같이 작성한다.
public @interface MyAnnotation {
}
일반 인터페이스 선언은 다음과 같다.
public interface MyInterface {
}
애너테이션 타입은 다음처럼 @interface를 사용한다.
public @interface MyAnnotation {
}
이제 다음과 같이 사용할 수 있다.
@MyAnnotation
public class MyService {
}
하지만 아직 중요한 두 가지가 정해지지 않았다.
이 애너테이션을 어디에 붙일 수 있는가?
그리고 이 정보가 언제까지 유지되는가?
이것을 결정하는 것이 각각
@Target
@Retention
이다.
메타 애너테이션이란?
@Target, @Retention, @Documented 역시 애너테이션이다.
그런데 이들은 일반 클래스나 메서드의 의미를 설명하기보다 다른 애너테이션을 설명하기 위해 사용된다.
이런 애너테이션을 메타 애너테이션이라고 한다.
일반 애너테이션
@MyAnnotation
↓
클래스나 메서드 등에 사용
메타 애너테이션
@Target
@Retention
@Documented
↓
@MyAnnotation 자체를 설명
예를 들어 다음처럼 정의할 수 있다.
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface MyAnnotation {
}
이 선언만으로 두 가지 규칙이 생긴다.
@Target(ElementType.TYPE)
→ 타입 선언에 사용할 수 있다.
@Retention(RetentionPolicy.RUNTIME)
→ 런타임까지 애너테이션 정보를 유지한다.
@Target은 애너테이션을 어디에 사용할 수 있는지 결정한다
먼저 @Target부터 살펴보자.
다음 애너테이션이 있다고 하자.
@Target(ElementType.TYPE)
public @interface MyAnnotation {
}
ElementType.TYPE이 지정되어 있다.
의미는 다음과 같다.
이 애너테이션은
타입 선언에 붙일 수 있다.
예를 들어 클래스에 사용할 수 있다.
@MyAnnotation
public class OrderService {
}
인터페이스에도 적용할 수 있다.
@MyAnnotation
public interface OrderRepository {
}
enum 등의 타입 선언에도 적용할 수 있다.
TYPE에만 허용하면 메서드에는 붙일 수 없다
다음 애너테이션을 다시 보자.
@Target(ElementType.TYPE)
public @interface MyAnnotation {
}
이 경우 다음 사용은 허용되지 않는다.
public class OrderService {
@MyAnnotation
public void order() {
}
}
왜냐하면 현재 MyAnnotation은
TYPE
에만 적용할 수 있도록 정의되어 있기 때문이다.
컴파일러가 사용 위치를 검사한다.
여러 위치에 사용할 수도 있다
@Target은 하나의 위치만 지정해야 하는 것은 아니다.
배열 형태로 여러 ElementType을 지정할 수 있다.
@Target({
ElementType.TYPE,
ElementType.METHOD
})
public @interface MyAnnotation {
}
이제 클래스에도 붙일 수 있다.
@MyAnnotation
public class OrderService {
}
메서드에도 붙일 수 있다.
@MyAnnotation
public void order() {
}
즉 @Target은 애너테이션의 사용 가능한 문법적 위치를 제한하는 역할을 한다.
대표적인 ElementType
애너테이션은 다양한 위치에 적용할 수 있다.
대표적으로 다음이 있다.
| ElementType | 적용 위치 |
|---|---|
TYPE | 클래스, 인터페이스, enum 등의 타입 선언 |
FIELD | 필드 |
METHOD | 메서드 |
PARAMETER | 메서드나 생성자의 매개변수 |
CONSTRUCTOR | 생성자 |
LOCAL_VARIABLE | 지역 변수 선언 |
ANNOTATION_TYPE | 다른 애너테이션 타입 |
PACKAGE | 패키지 |
TYPE_PARAMETER | 제네릭 타입 매개변수 |
TYPE_USE | 타입이 사용되는 위치 |
MODULE | Java 모듈 |
Java 버전에 따라 ElementType의 종류가 확장되어 왔기 때문에 애너테이션을 직접 정의할 때는 실제 목적에 맞는 Target을 선택해야 한다.
아이템 27의 @SuppressWarnings와 연결해보자
앞에서 비검사 경고를 억제하기 위해 다음 코드를 사용했다.
@SuppressWarnings("unchecked")
E[] result =
(E[]) elements;
여기서 중요한 것은 @SuppressWarnings가 지역 변수 선언에 적용될 수 있다는 것이다.
지역 변수 선언이기 때문에 다음 Target이 관련된다.
ElementType.LOCAL_VARIABLE
실제 @SuppressWarnings는 여러 선언 위치에 적용할 수 있도록 정의되어 있다.
예를 들어 클래스에도 사용할 수 있고
@SuppressWarnings("unchecked")
public class MyClass {
}
메서드에도 사용할 수 있으며
@SuppressWarnings("unchecked")
public void execute() {
}
지역 변수 선언에도 사용할 수 있다.
@SuppressWarnings("unchecked")
E[] result =
(E[]) elements;
이 때문에 아이템 27에서 경고 억제 범위를 지역 변수 수준까지 줄일 수 있었다.
return 문과 지역 변수 선언은 다르다
다음 코드를 생각해보자.
return (E[]) elements;
return은 return statement다.
반면
E[] result =
(E[]) elements;
는 지역 변수 선언이다.
둘은 문법적으로 다른 요소다.
그래서 특정 선언 위치에 적용되도록 설계된 애너테이션을 return 문 자체에 그대로 붙일 수 있다고 생각하면 안 된다.
아이템 27에서 다음처럼 코드를 변경한 이유도 여기에 있다.
public E[] toArray() {
@SuppressWarnings("unchecked")
E[] result =
(E[]) elements;
return result;
}
원래는
return (E[]) elements;
한 줄이었지만, 경고 억제 범위를 최소화하기 위해 지역 변수 선언으로 분리했다.
@Target은 애너테이션 API의 일부다
애너테이션을 직접 설계할 때 @Target을 너무 넓게 잡는 것도 좋은 설계는 아니다.
예를 들어 실제로는 메서드에만 의미가 있는 애너테이션이라고 생각해보자.
@Target(ElementType.METHOD)
public @interface Retry {
}
이렇게 해두면 사용자의 의도가 명확해진다.
@Retry
public void request() {
}
반면 아무 곳에서나 사용하도록 너무 넓게 허용하면
@Retry
public class User {
}
같이 의미가 모호한 사용이 가능해질 수 있다.
따라서 @Target 역시 최소한의 범위로 설계하는 것이 좋다.
@Retention은 애너테이션 정보를 언제까지 유지할지 결정한다
다음으로 중요한 것이 @Retention이다.
@Target이
어디에 붙일 수 있는가?
를 결정한다면 @Retention은
그 애너테이션 정보를 언제까지 보존할 것인가?
를 결정한다.
Java에서는 세 가지 정책을 제공한다.
RetentionPolicy.SOURCE
RetentionPolicy.CLASS
RetentionPolicy.RUNTIME
범위는 개념적으로 다음과 같다.
SOURCE
↓
CLASS
↓
RUNTIME
뒤로 갈수록 애너테이션 정보가 더 오래 유지된다.
중요한 점: 기본 Retention은 CLASS다
@Retention을 생략하면 기본 정책은
RetentionPolicy.CLASS
다.
즉 다음과 같이 정의하면
public @interface MyAnnotation {
}
명시적인 @Retention이 없기 때문에 기본적으로 CLASS 정책을 따른다.
런타임 Reflection을 통해 애너테이션을 읽어야 한다면 반드시 다음처럼 명시해야 한다.
@Retention(RetentionPolicy.RUNTIME)
public @interface MyAnnotation {
}
이 차이는 커스텀 애너테이션을 직접 만들 때 매우 중요하다.
RetentionPolicy.SOURCE
가장 짧게 유지되는 정책이다.
@Retention(RetentionPolicy.SOURCE)
public @interface MyAnnotation {
}
애너테이션 정보는 소스 코드 수준에서만 의미가 있다.
개념적으로 다음과 같다.
.java
애너테이션 존재
↓
javac
↓
.class
애너테이션 정보 유지 필요 없음
즉 컴파일이 완료된 이후에는 해당 애너테이션 정보를 사용할 필요가 없는 경우 적합하다.
SOURCE는 컴파일 과정에서만 필요한 정보에 적합하다
대표적으로 컴파일러가 소스 코드를 검사하거나 처리하는 데 필요한 애너테이션을 생각할 수 있다.
@SuppressWarnings가 좋은 예다.
@SuppressWarnings("unchecked")
이 애너테이션의 목적은 컴파일러에게 다음을 전달하는 것이다.
"이 위치에서 발생하는
unchecked 경고를 표시하지 마라."
컴파일이 끝난 뒤 애플리케이션이 실행되는 동안 이 정보가 필요할 이유가 없다.
Source Code
↓
Compiler가 @SuppressWarnings 확인
↓
경고 처리
↓
컴파일 완료
↓
Runtime에서는 필요 없음
따라서 SOURCE 수준의 유지 정책과 잘 맞는다.
애너테이션 프로세싱과 SOURCE
컴파일 시점에서 애너테이션을 읽어서 새로운 코드를 생성하는 방식도 생각할 수 있다.
개념적인 흐름은 다음과 같다.
Java Source
↓
Annotation Processor
↓
애너테이션 분석
↓
새로운 Java Source 생성
↓
Compile
이처럼 애너테이션의 역할이 오직 컴파일 타임에 끝난다면 런타임까지 정보를 유지할 필요가 없다.
다만 실제 애너테이션 프로세서에서 사용하는 애너테이션의 retention 선택은 처리 방식과 도구에 따라 달라질 수 있으므로, 단순히 “컴파일 타임 애너테이션은 무조건 SOURCE”라고 일반화하기보다는 실제 사용 목적에 맞춰 결정해야 한다.
RetentionPolicy.CLASS
두 번째는 CLASS다.
@Retention(RetentionPolicy.CLASS)
public @interface MyAnnotation {
}
이 경우 애너테이션 정보가 클래스 파일에는 기록된다.
.java
↓
Compile
↓
.class
애너테이션 정보 존재
하지만 일반적인 Java Reflection을 통해 런타임에 애너테이션을 조회하도록 보장되지는 않는다.
즉
Source
↓
Class File
O
Runtime Reflection
X
정도로 이해할 수 있다.
CLASS 정책은 바이트코드 도구에서 활용할 수 있다
클래스 파일 안에 애너테이션 정보가 있으므로 바이트코드를 직접 분석하는 도구에서는 이 정보를 활용할 수 있다.
예를 들어 바이트코드 분석이나 조작 도구를 생각할 수 있다.
.class
↓
Bytecode Analyzer
↓
Annotation 정보 분석
이 경우 굳이 Reflection을 통해 JVM 실행 중에 애너테이션을 조회할 필요가 없다.
RetentionPolicy.RUNTIME
가장 넓게 유지되는 정책은 RUNTIME이다.
@Retention(RetentionPolicy.RUNTIME)
public @interface MyAnnotation {
}
이 경우 애너테이션 정보는 실행 시점까지 유지되어 Reflection API로 조회할 수 있다.
예를 들어 다음과 같이 정의해보자.
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface MyAnnotation {
}
그리고 클래스에 붙인다.
@MyAnnotation
public class MyService {
}
런타임에서 조회할 수 있다.
Annotation[] annotations =
MyService.class
.getAnnotations();
for (Annotation annotation
: annotations) {
System.out.println(annotation);
}
또는 직접 조회할 수도 있다.
MyAnnotation annotation =
MyService.class
.getAnnotation(
MyAnnotation.class
);
이것이 가능한 이유가
RetentionPolicy.RUNTIME
이기 때문이다.
CLASS로 바꾸면 Reflection 결과가 달라진다
다음처럼 변경해보자.
@Retention(RetentionPolicy.CLASS)
public @interface MyAnnotation {
}
소스에서는 여전히 애너테이션이 보인다.
@MyAnnotation
public class MyService {
}
클래스 파일에도 관련 메타데이터가 남을 수 있다.
하지만 Reflection으로 다음 코드를 실행했을 때
MyService.class
.getAnnotation(
MyAnnotation.class
);
RUNTIME처럼 애너테이션 객체를 얻을 수 없다.
즉 코드에 분명히 애너테이션을 붙였는데 Reflection으로 읽히지 않는다면 반드시 확인해봐야 할 것이 있다.
@Retention이 무엇으로 설정되어 있는가?
런타임 프레임워크에서는 RUNTIME이 중요하다
런타임에 객체를 분석해서 특정 동작을 수행하는 프레임워크를 생각해보자.
예를 들어 다음과 같은 애너테이션이 있다고 하자.
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface Component {
}
애플리케이션 실행 중 특정 패키지의 클래스들을 탐색해서
@Component가 붙어 있는가?
를 확인한다.
Class 탐색
↓
Reflection
↓
@Component 확인
↓
조건이 맞으면 객체 생성
↓
Container 등록
이런 동작을 하려면 런타임에도 애너테이션 정보가 필요하다.
따라서 RUNTIME 정책이 필요하다.
Spring에서 애너테이션을 많이 사용하는 이유와도 연결된다
Spring에서는
@Component
@Service
@Controller
@Transactional
등 많은 애너테이션을 사용한다.
이들 중 런타임 처리와 관련된 애너테이션은 프레임워크가 해당 메타데이터를 읽어서 동작을 결정한다.
개념적으로는
Annotation
↓
Framework가 메타데이터 분석
↓
Bean 등록
AOP 적용
요청 매핑
트랜잭션 처리
같은 방식으로 활용될 수 있다.
따라서 커스텀 애너테이션을 만들어 Reflection 기반 런타임 동작에 사용한다면 RUNTIME retention 여부가 핵심이다.
Retention을 시간 축으로 이해해보자
세 정책을 시간 순서대로 보면 이해하기 쉽다.
Java Source
│
│ SOURCE
▼
Compile
│
│ CLASS
▼
.class
│
│ RUNTIME
▼
JVM 실행
조금 더 정확하게 정리하면 다음과 같다.
| RetentionPolicy | 소스 | .class | Reflection 런타임 조회 |
|---|---|---|---|
SOURCE | O | X | X |
CLASS | O | O | X |
RUNTIME | O | O | O |
이 표를 기억하면 대부분의 상황을 이해할 수 있다.
어떤 Retention을 선택해야 할까?
애너테이션을 직접 만든다면 먼저 질문해야 한다.
소스나 컴파일 과정에서만 필요한가?
예를 들어
컴파일 경고 제어
정적 분석
소스 코드 생성 목적
등이라면 SOURCE를 검토할 수 있다.
클래스 파일을 분석하는 도구만 필요로 하는가?
Bytecode 분석
Class File 가공
같은 목적이라면 CLASS가 적합할 수 있다.
실행 중 Reflection으로 읽어야 하는가?
런타임 객체 등록
Reflection 기반 처리
런타임 프레임워크 기능
등이라면
@Retention(RetentionPolicy.RUNTIME)
을 사용해야 한다.
@Documented는 Javadoc에 애너테이션 정보를 포함시킨다
세 번째로 살펴볼 메타 애너테이션은 @Documented다.
사용법은 간단하다.
@Documented
public @interface MyAnnotation {
}
@Documented 자체에는 별도의 옵션이 없다.
이 애너테이션의 목적은
이 애너테이션의 사용 여부를
생성된 API 문서에도 보여주고 싶다.
는 것이다.
API 계약의 일부라면 @Documented를 고려할 수 있다
예를 들어 어떤 public API에서 특정 애너테이션이 중요한 의미를 가진다고 하자.
@ThreadSafe
public class Cache {
}
이 애너테이션 자체가 사용자가 API를 이해하는 데 중요하다면 생성된 Javadoc에서도 보이는 것이 유용할 수 있다.
이런 경우 애너테이션 타입에
@Documented
를 붙여 문서화 대상으로 만들 수 있다.
@Documented와 @Retention은 목적이 다르다
둘을 혼동하면 안 된다.
@Documented
→ Javadoc 같은 문서에
애너테이션 사용 정보를 포함할 것인가?
@Retention
→ 애너테이션 정보를
언제까지 유지할 것인가?
완전히 다른 관심사다.
다음처럼 함께 사용할 수도 있다.
@Documented
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface MyAnnotation {
}
의미를 풀면 다음과 같다.
타입에 사용할 수 있다.
런타임까지 유지한다.
API 문서에도 표시한다.
커스텀 애너테이션 전체 예제
직접 런타임 애너테이션을 만들어보자.
@Documented
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface ServiceInfo {
String value();
}
클래스에 사용한다.
@ServiceInfo("order")
public class OrderService {
}
런타임에서 읽는다.
ServiceInfo serviceInfo =
OrderService.class
.getAnnotation(
ServiceInfo.class
);
if (serviceInfo != null) {
System.out.println(
serviceInfo.value()
);
}
출력은 다음과 같다.
order
애너테이션을 단순히 표시용으로 붙인 것이 아니라 런타임 프로그램이 읽어서 사용할 수 있게 된 것이다.
애너테이션은 메타데이터다
애너테이션을 이해하는 좋은 방법은 코드에 붙이는 메타데이터라고 생각하는 것이다.
다음 코드가 있다고 하자.
@Transactional
public void order() {
}
@Transactional 자체가 비즈니스 로직을 직접 작성하고 있는 것은 아니다.
대신
"이 메서드는 트랜잭션 처리 대상이다."
라는 정보를 코드에 붙인다.
그 정보를
컴파일러
Annotation Processor
정적 분석 도구
바이트코드 분석 도구
런타임 프레임워크
Reflection 코드
등이 읽어서 의미 있는 작업으로 변환한다.
애너테이션 자체는 아무 동작도 하지 않는다
중요한 개념이다.
다음 애너테이션을 만들었다고 해보자.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Retry {
}
메서드에 붙인다.
@Retry
public void request() {
...
}
이것만으로 자동 재시도가 발생하는 것은 아니다.
@Retry를 읽어서
이 메서드 호출이 실패하면
다시 실행하라.
라는 동작을 수행하는 코드가 별도로 필요하다.
예를 들면
Annotation
+
Annotation을 해석하는 코드
=
실제 기능
이다.
따라서 애너테이션은 행동 그 자체가 아니라 행동을 결정할 수 있는 메타데이터라고 이해하는 것이 중요하다.
컴파일 타임 애너테이션과 런타임 애너테이션
애너테이션의 활용 방식을 크게 나누면 다음처럼 생각할 수도 있다.
컴파일 타임 중심
Source
↓
Compiler 또는
Annotation Processor
↓
검증 / 코드 생성 / 경고 제어
대표적인 예로 @SuppressWarnings를 생각할 수 있다.
런타임 중심
Class Loading
↓
Reflection 등으로
Annotation 확인
↓
Runtime 동작 결정
Spring과 같은 프레임워크에서 익숙하게 볼 수 있는 방식이다.
바이트코드 기반 처리도 있다
Reflection 외에도 클래스 파일의 바이트코드를 직접 읽어 애너테이션 정보를 분석할 수 있다.
.class
↓
Bytecode Library
↓
Annotation Metadata 분석
이 경우 애플리케이션 객체를 실제로 Reflection으로 생성하고 조회하는 방식과는 다른 접근이 가능하다.
따라서
애너테이션 정보를 언제 읽을 것인가?
는 @Retention을 결정하는 중요한 기준이 된다.
@Target과 @Retention을 함께 생각하자
애너테이션을 설계할 때는 두 질문을 반드시 함께 해야 한다.
1. 어디에 사용할 것인가?
2. 언제까지 정보가 필요한가?
첫 번째 질문의 답이
@Target(...)
이고
두 번째 질문의 답이
@Retention(...)
이다.
예를 들어 메서드에 붙이고 런타임 Reflection으로 읽어야 한다면
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Retry {
}
가 된다.
애너테이션 설계 예제: 실행 시간 측정
메서드 실행 시간을 측정하는 애너테이션을 만든다고 생각해보자.
먼저 어디에 붙일지를 정한다.
메서드에만 사용한다.
@Target(ElementType.METHOD)
그리고 실행 시점에 애너테이션을 읽어서 실제 시간을 측정해야 한다.
@Retention(RetentionPolicy.RUNTIME)
결과적으로 다음과 같다.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface MeasureTime {
}
사용한다.
@MeasureTime
public void execute() {
...
}
그리고 프레임워크나 프록시 코드가 런타임에 @MeasureTime을 확인해서 시간을 측정한다.
Method 호출
↓
@MeasureTime 존재 확인
↓
시작 시간 측정
↓
실제 Method 실행
↓
종료 시간 측정
RUNTIME이 필요한 이유가 명확하다.
애너테이션 설계 예제: 소스 검사용 힌트
반대로 컴파일이나 소스 분석 단계에서만 필요한 애너테이션이라면 실행 시점까지 남길 필요가 없다.
@Retention(RetentionPolicy.SOURCE)
public @interface SomeCheck {
}
도구가 컴파일 과정에서 해당 정보를 확인한 뒤 역할이 끝난다면 런타임 메타데이터로 남겨둘 필요가 없다.
@SuppressWarnings가 좋은 예제인 이유
아이템 27에서 사용한
@SuppressWarnings("unchecked")
는 이번 내용을 이해하기 좋은 실제 사례다.
이 애너테이션의 목적은
컴파일러가 특정 경고를
표시하지 않도록 하는 것
이다.
즉 실행 중에는 의미가 없다.
그리고 다음처럼 비교적 여러 선언 위치에서 사용해야 한다.
@SuppressWarnings("unchecked")
public class A {
}
@SuppressWarnings("unchecked")
public void execute() {
}
@SuppressWarnings("unchecked")
E[] result = ...
그래서 애너테이션의 Target과 Retention을 살펴보면 왜 그런 방식으로 사용할 수 있는지를 이해할 수 있다.
왜 애너테이션 적용 범위를 이해해야 할까?
애너테이션을 그냥
"@ 붙이는 특별한 문법"
정도로 생각하면 다음과 같은 문제를 만났을 때 원인을 찾기 어렵다.
왜 여기에 애너테이션을 붙일 수 없지?
왜 Reflection에서 나오지 않지?
왜 컴파일 후 애너테이션이 없어졌지?
왜 Javadoc에는 표시되지 않지?
하지만 세 가지 메타 애너테이션을 이해하면 질문을 나눠서 볼 수 있다.
붙일 수 없다.
↓
@Target 확인
Reflection으로 읽을 수 없다.
↓
@Retention 확인
문서에 나타나지 않는다.
↓
@Documented 확인
문제 해결 기준이 명확해진다.
세 가지 메타 애너테이션 비교
| 애너테이션 | 핵심 질문 | 역할 |
|---|---|---|
@Target | 어디에 붙일 것인가? | 사용 가능한 문법 위치 제한 |
@Retention | 언제까지 남길 것인가? | 애너테이션 생명주기 결정 |
@Documented | API 문서에 보여줄 것인가? | Javadoc 문서화 여부 지정 |
이 세 가지를 구분해두면 커스텀 애너테이션을 설계할 때 상당히 도움이 된다.
Retention 세 가지 다시 비교하기
| 정책 | 소스에서 존재 | 클래스 파일에 유지 | 일반 Reflection 조회 | 대표적인 목적 |
|---|---|---|---|---|
SOURCE | O | X | X | 컴파일 단계 처리 |
CLASS | O | O | X | 바이트코드 수준 처리 |
RUNTIME | O | O | O | 런타임 Reflection 처리 |
그리고 다시 한 번 중요한 기본값을 기억해야 한다.
@Retention을 생략하면
RetentionPolicy.CLASS
이다.
커스텀 애너테이션을 만들 때 체크할 것
직접 애너테이션을 정의해야 한다면 먼저 다음을 결정하는 것이 좋다.
어디에 사용할 것인가?
Class?
Method?
Field?
Parameter?
Local Variable?
이에 맞춰 @Target을 정한다.
누가 애너테이션을 읽을 것인가?
Compiler?
Annotation Processor?
Bytecode Tool?
Runtime Framework?
Reflection?
이에 따라 @Retention을 정한다.
사용자에게 공개되는 API 의미를 가지는가?
그렇다면
@Documented
를 고려할 수 있다.
필요 이상으로 RUNTIME을 사용하지 않아도 된다
직접 애너테이션을 만들 때 아무 생각 없이 다음처럼 작성하기 쉽다.
@Retention(RetentionPolicy.RUNTIME)
하지만 실제로 컴파일 시점에서만 필요한 정보라면 런타임까지 유지할 필요가 없다.
애너테이션의 생명주기 역시 필요한 범위까지만 설정하는 것이 좋다.
컴파일까지만 필요
→ SOURCE 검토
클래스 파일까지만 필요
→ CLASS 검토
런타임 조회 필요
→ RUNTIME
애너테이션 설계에서도 필요 이상의 범위를 열어두지 않는 것이 좋은 기준이 된다.
핵심 정리
- Java 애너테이션은 코드에 부가적인 의미를 표현하는 메타데이터다.
- 애너테이션 타입은
@interface를 사용해 직접 정의할 수 있다. @Target,@Retention,@Documented는 다른 애너테이션 자체의 특성을 정의하는 메타 애너테이션이다.@Target은 해당 애너테이션을 클래스, 메서드, 필드, 매개변수, 지역 변수 등 어디에 사용할 수 있는지 결정한다.ElementType.TYPE은 클래스나 인터페이스 등의 타입 선언을 대상으로 한다.ElementType.METHOD는 메서드,LOCAL_VARIABLE은 지역 변수 선언을 대상으로 한다.- 여러 위치에 적용해야 한다면
@Target에 여러ElementType을 지정할 수 있다. - 아이템 27의
@SuppressWarnings("unchecked")를 지역 변수 선언에 붙일 수 있는 것도 해당 애너테이션이 그 위치에서 사용 가능하도록 정의되어 있기 때문이다. - return 문과 지역 변수 선언은 문법적으로 서로 다른 요소이므로 경고 억제 범위를 좁히기 위해 지역 변수를 만드는 방법을 사용할 수 있다.
@Retention은 애너테이션 정보가 언제까지 유지되는지를 결정한다.RetentionPolicy.SOURCE는 소스 코드 단계까지만 애너테이션 정보를 유지한다.RetentionPolicy.CLASS는 클래스 파일까지 정보를 유지하지만 일반적인 런타임 Reflection 조회 대상으로 유지하지 않는다.RetentionPolicy.RUNTIME은 애너테이션 정보를 실행 시점까지 유지하여 Reflection으로 조회할 수 있게 한다.@Retention을 생략했을 때의 기본 정책은RetentionPolicy.CLASS다.- 컴파일 경고 제어나 소스 수준 도구에만 필요한 애너테이션은
SOURCE가 적합할 수 있다. - 바이트코드 수준에서 메타데이터를 분석하려면
CLASS정책을 활용할 수 있다. - Reflection 기반 프레임워크처럼 실행 시점에 애너테이션을 읽어야 한다면
RUNTIME이 필요하다. @Documented는 애너테이션 사용 정보를 Javadoc 같은 생성 문서에 포함할지를 결정한다.- 애너테이션 자체가 기능을 실행하는 것은 아니며 애너테이션을 해석하는 컴파일러, 프로세서, 도구 또는 런타임 코드가 있어야 실제 동작으로 연결된다.
- 애너테이션을 직접 설계할 때는 어디에 붙일 것인지, 언제까지 필요한지, 누가 읽을 것인지를 먼저 판단한 뒤 Target과 Retention을 결정하는 것이 좋다.
한 줄 정리
Java 애너테이션은 코드에 의미를 추가하는 메타데이터이며
@Target은 어디에 사용할 수 있는지를,@Retention은 그 정보를 언제까지 유지할지를,@Documented는 API 문서에 노출할지를 결정하므로 커스텀 애너테이션을 설계할 때는 먼저 누가 언제 어디에서 이 메타데이터를 사용할지를 명확히 해야 한다.