티스토리 뷰
전통적으로 도구나 프레임워크가 특별히 다뤄야 할 프로그램 요소에는 딱 구분되는 명명 패턴을 적용해왔습니다.
예컨대 테스트 프레임워크인 JUnit은 버전 3까지 테스트 메서드 이름을 test로 시작하게끔 했습니다.
효과적인 방법이지만 단점도 큽니다.
[1]. 오타가 나면 안 된다.
실수로 이름을 tsetSafetyOverride로 지으면 JUnit 3은 이 메서드를 무시하고 지나치기 때문에 개발자는 이 테스트가 통과했다고 오해할 수 있습니다.
[2]. 올바른 프로그램 요소에서만 사용되리라 보증할 방법이 없다는 것이다.
예컨대 (메서드가 아닌) 클래스 이름을 TestSafetyMechanisms로 지어 JUnit에 던져줬다고 해봅시다.
개발자는 이 클래스에 정의된 테스트 메서드들을 수행해주길 기대하겠지만 JUnit은 클래스 이름에는 관심이 없습니다.
이번에도 JUnit은 경고 메시지조차 출력하지 않지만 개발자가 의도한 테스트는 전혀 수행되지 않습니다.
[3]. 프로그램 요소를 매개변수로 전달할 마땅한 방법이 없다는 것이다.
특정 예외를 던져야만 성공하는 테스트가 있다고 해봅시다.
기대하는 예외 타입을 테스트에 매개변수로 전달해야 하는 상황입니다.
예외의 이름을 테스트 메서드 이름에 덧붙이는 방법도 있지만, 보기도 나쁘고 깨지기도 쉽습니다. (아이템 62)
컴파일러는 메서드 이름에 덧붙인 문자열이 예외를 가리키는지 알 도리가 없습니다.
테스트를 실행하기 전에는 그런 이름의 클래스가 존재하는지 혹은 예외가 맞는지조차 알 수 없습니다.
애너테이션은 이 모든 문제를 해결해주는 멋진 개념으로, JUnit도 버전 4부터 전면 도입했습니다.
이번 아이템에서는 애너테이션의 동작 방식을 보여주고자 직접 제작한 작은 테스트 프레임워크를 사용할 것입니다.
Test라는 이름의 애너테이션을 정의한다고 해봅시다.
자동으로 수행되는 간단한 테스트용 애너테이션으로, 예외가 발생하면 해당 테스트를 실패로 처리합니다.
import java.lang.annotation.*;
/**
* 테스트 메서드임을 선언하는 애너테이션이다.
* 매개변수 없는 정적 메서드 전용이다.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Test{}
보다시피 @Test 애너테이션 타입 선언 자체에도 두 가지의 다른 애너테이션이 달려 있습니다.
바로 @Retention과 @Target입니다.
이처럼 애너테이션 선언에 다는 애너테이션을 메타애너테이션이라고 합니다.
@Retention(RetentionPolicy.RUNTIME) 메타에너테이션은 @Test가 런타임에도 유지되어야 한다는 표시입니다.
만약 이 메타애너테이션을 생략하면 테스트 도구는 @Test를 인식할 수 없습니다.
한편, @Target(ElementType.METHOD) 메타애너테이션은 @Test가 반드시 메서드 선언에서만 사용돼야 한다고 알려줍니다.
따라서 클래스 선언, 필드 선언 등 다른 프로그램 요소에는 달 수 없습니다.
앞 코드의 메서드 주석에는 "매개변수 없는 정적 메서드 전용이다"라고 쓰여 있습니다.
이 제약을 컴파일러가 강제할 수 있으면 좋겠지만, 그렇게 하려면 적절한 애너테이션 처리기를 직접 구현해야 합니다.
관련 방법은 jvavx.annotation.processing API 문서를 참고합시다.
🤔
적절한 애너테이션 처리기 없이 인스턴스 메서드나 매개변수가 있는 메서드에 달면 어떻게 될까요?
컴파일은 잘 되겠지만, 테스트 도구를 실행할 때 문제가 됩니다.
다음 코드는 @Test 애너테이션을 실제 적용한 코드입니다.
이와 같은 애너테이션을 "아무 매개변수 없이 단순히 대상에 마킹한다"는 뜻에서 마커 애너테이션이라 합니다.
이 애너테이션을 사용하면 프로그래머가 Test 이름에 오타를 내거나 메서드 선언 외의 프로그램 요소에 달면 컴파일 오류를 내줍니다.
public class Sample {
@Test public static void m1() { } // 성공해야 한다.
public static void m2() { }
@Test public static void m3() { // 실패해야 한다.
throw new RuntimeException("실패");
}
public static void m4() { }
@Test public void m5() { } // 잘못 사용한 예 : 정적 메서드가 아니다.
public static void m6() { }
@Test public static void m7() { // 실패해야 한다.
throw new RuntimeException("실패");
}
public static void m8() { }
Sample 클래스에는 정적 메서드가 7개고, 그 중 4개에 @Test를 달았습니다.
m3와 m7 메서드는 예외를 던지고 m1과 m5는 그렇지 않습니다.
그리고 m5는 인스턴스 메서드이므로 @Test를 잘못 사용한 경우입니다.
요약하면 총 4개의 테스트 메서드 중 1개는 성공, 2개는 실패, 1개는 잘못 사용했습니다.
그리고 @Test를 붙이지 않은 나머지 4개의 메서드는 테스트 도구가 무시할 것입니다.
@Test 애너테이션이 Sample 클래스의 의미에 직접적인 영향을 주지는 않습니다.
그저 이 애너테이션에 관심 있는 있는 프로그램에게 추가 정보를 제공할 뿐입니다.
더 넓게 이야기하면, 대상 코드의 의미는 그대로 둔 채 그 애너테이션에 관심 있는 도구에서 특별한 처리를 할 기회를 줍니다.
다음의 RunTests가 바로 그런 도구의 예입니다.
import java.reflect.*;
public class RunTests {
public static void main(String[] args) throws Exception {
int tests = 0;
int passed = 0;
Class<?> testClass = Class.forName(args[0]);
for (Method m : testClass.getDeclaredMethods()) {
if (m.isAnnotationPresent(Test.class)) {
tests++;
try {
m.invoke(null);
passed++;
} catch (InvocationTargetException wrappedExc) {
Throwable exc = wrappedExc.getCause();
System.out.println(m + " 실패: " + exc);
} catch (Exception exc) {
System.out.println("잘못 사용한 @Test: " + m);
}
}
}
System.out.printf("성공: %d, 실패: %d%n", passed, tests - passed);
}
}
이 테스트 러너는 명령줄로부터 완전 정규화된 클래스 이름을 받아, 그 클래스에서 @Test 애너테이션이 달린 메서드를 차례로 호출합니다.
isAnnotationPresent가 실행할 메서드를 찾아주는 메서드입니다.
테스트 메서드가 예외를 던지면 리플렉션 메커니즘이 InvocationTargetException으로 감싸서 다시 던집니다.
그래서 이 프로그램은 InvocationTargetException을 잡아 원래 예외에 담긴 실패 정보를 추출해(getCause) 출력합니다.
InvocationTargetException 외의 예외가 발생한다면 @Test 애너테이션을 잘못 사용했다는 걸 의미합니다.
아마도 인스턴스 메서드, 매개변수가 있는 메서드, 호출할 수 없는 메서드 등에 달았을 것입니다.
앞 코드에서 두 번째 catch 블록은 이처럼 잘못 사용해서 발생한 예외를 붙잡아 적절한 오류 메시지를 출력합니다.
다음은 이 RunTests로 Sample을 실행했을 때의 출력 메시지입니다.
pub static void Sample.m3() : failed: RuntimeException: Boom
Invalid @Test: public void Sample.m5()
public static void Sample.m7() failed: RuntimeException: Crash
성공: 1, 실패: 3
이제 특정 예외를 던져야만 성공하는 테스트를 지원하도록 해봅시다.
그러러면 새로운 애너테이션 타입이 필요합니다.
import java.lang.annotation.*;
/**
* 명시한 예외를 던져야만 성공하는 테스트 메서드용 애너테이션
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest{
Class<? extends Throwable> value();
}
이 애너테이션의 매개변수 타입은 Class<? extends Throwable>입니다.
여기서의 와일드카드 타입은 많은 의미를 담고 있습니다.
"Throwable을 확장한 클래스의 Class 객체"라는 뜻이며, 따라서 모든 예외 타입을 다 수용합니다.
이는 한정적 타입 토큰(아이템 33)의 또 하나의 활용 사례입니다.
그리고 다음은 이 애너테이션을 실제 활용하는 코드입니다.
class 리터럴은 애너테이션 매개변수의 값으로 사용됐습니다.
public class Sample2 {
ExceptionTest(ArithmeticException.class)
public static void m1() { // 성공해야 한다.
int i = 0;
i = i / i;
}
ExceptionTest(ArithmeticException.class)
public static void m2() { // 실패해야 한다. (다른 예외 발생)
int[] a = new int[0];
int i = a[1];
}
ExceptionTest(ArithmeticException.class)
public static void m3() { } // 실패해야 한다. (예외가 발생하지 않음)
}
이제 이 애너테이션을 다룰 수 있도록 테스트 도구를 수정해봅시다.
import java.reflect.*;
public class RunTests {
public static void main(String[] args) throws Exception {
int tests = 0;
int passed = 0;
Class<?> testClass = Class.forName(args[0]);
for (Method m : testClass.getDeclaredMethods()) {
if (m.isAnnotationPresent(ExceptionTest.class)) {
tests++;
try {
m.invoke(null);
System.out.printf("테스트 %s 실패: 예외를 던지지 않음%n", m);
} catch (InvocationTargetException wrappedExc) {
Throwable exc = wrappedExc.getCause();
Class<? extends Throwable> excType =
m.getAnnotation(ExceptionTest.class).value();
if (excType.isInstance(exc)) {
passed++;
} else {
System.out.printf(
"테스트 %s 실패: 기대한 예외 %s, 발생한 예외 %s%n",
m, excType.getName(), exc);
}
} catch (Exception exc) {
System.out.println("잘못 사용한 @ExceptionTest: " + m);
}
}
}
System.out.printf("성공: %d, 실패: %d%n", passed, tests - passed);
}
}
@Test 애너테이션용 코드와 비슷해 보입니다.
한 가지 차이라면, 이 코드는 애너테이션 매개변수의 값을 추출하여 테스트 메서드가 올바른 예외를 던지는지 확인하는 데 사용합니다.
형변환 코드가 없으니 ClassCastException 걱정은 없습니다.
따라서 테스트 프로그램이 문제없이 컴파일되면 애너테이션 매개변수가 가리키는 예외가 올바른 타입이라는 뜻입니다.
단, 해당 예외의 클래스 파일이 컴파일타임에는 존재했으나 런타임에는 존재하지 않을 수는 있습니다.
이런 경우라면 테스트 러너가 TypeNotPresentException을 던질 것입니다.
이 예외 테스트 예에서 한걸음 더 들어가, 예외를 여러 개 명시하고 그중 하나가 발생하면 성공하게 만들 수도 있습니다.
애너테이션 메커니즘에는 이런 쓰임에 아주 유용한 기능이 기본으로 들어 있습니다.
@ExceptionTest 애너테이션의 매개변수 타입을 Class 객체의 배열로 수정해봅시다.
import java.lang.annotation.*;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest{
Class<? extends Throwable>[] value();
}
배열 매개변수를 받는 애너테이션용 문법은 아주 유연합니다.
단일 원소 배열에 최적화했지만, 앞서의 @ExceptionTest들도 모두 수정 없이 수용합니다.
원소가 여럿인 배열을 지정할 때는 다음과 같이 원소들을 중괄호로 감싸고 쉼표로 구분해주기만 하면 됩니다.
@ExceptionTest({ IndexOutOfBoundsException.class,
NullPointerException.class })
public static void doublyBad() { // 성공해야 한다.
List<String> list = new ArrayList<>();
// 자바 API 명세에 따르면 다음 메서드는 IndexOutOfBoundsException이나
// NullPointerException을 던질 수 있다.
list.addAll(5, null);
}
다음은 이 새로운 @ExceptionTest를 지원하도록 테스트 러너를 수정한 모습입니다.
import java.reflect.*;
public class RunTests {
public static void main(String[] args) throws Exception {
int tests = 0;
int passed = 0;
Class<?> testClass = Class.forName(args[0]);
for (Method m : testClass.getDeclaredMethods()) {
if (m.isAnnotationPresent(ExceptionTest.class)) {
tests++;
try {
m.invoke(null);
System.out.printf("테스트 %s 실패: 예외를 던지지 않음%n", m);
} catch (InvocationTargetException wrappedExc) {
Throwable exc = wrappedExc.getCause();
int oldPassed = passed;
Class<? extends Throwable>[] excTypes =
m.getAnnotation(ExceptionTest.class).value();
for (Class<? extends Throwable> excType: excTypes) {
if (excType.isInstance(exc)) {
passed++;
break;
}
}
if (passed == oldPassed)
System.out.printf("테스트 %s 실패: %ㄴ %n", m, exc);
}
}
}
System.out.printf("성공: %d, 실패: %d%n", passed, tests - passed);
}
}
자바 8에서는 여러 개의 값을 받는 애너테이션을 다른 방식으로도 만들 수 있습니다.
배열 매개변수를 사용하는 대신 애너테이션에 @Repeatable 메타애너테이션을 다는 방식입니다.
@Repeatable을 단 애너테이션은 하나의 프로그램 요소에 여러 번 달 수 있습니다.
단, 주의할 점이 있습니다.
[1]. @Repeatable을 단 애너테이션을 반환하는 '컨테이너 애너테이션'을 하나 더 정의하고, @Repeatable에 이 컨테이너 애너테이션의 class 객체를 매개변수로 전달해야 한다.
[2]. 컨테이너 애너테이션은 내부 애너테이션 타입의 배열을 반환하는 value 메서드를 정의해야 한다.
[3]. 컨테이너 애너테이션 타입에는 적절한 보존 정책(@Retention)과 적용 대상(@Target)을 명시해야 한다. 그렇지 않으면 컴파일되지 않을 것이다.
// 반복 가능한 애너테이션
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Repeatable(ExceptionTestContainer.class)
public @interface ExceptionTest{
Class<? extends Throwable> value();
}
// 컨테이너 애너테이션
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTestContainer{
ExceptionTest[] value();
}
이제 앞서의 배열 방식 대신 반복 가능 애너테이션을 적용해봅시다.
@ExceptionTest(IndexOutOfBoundsException.calss)
@ExceptionTest(NullPointerException.calss)
public static void doublyBad() { ... }
반복 가능 애너테이션은 처리할 때도 주의를 요합니다.
반복 가능 애너테이션을 여러 개 달면 하나만 달았을 때와 구분하기 위해 해당 '컨테이너' 애너테이션 타입이 적용됩니다.
getAnnotationsByType 메서드는 이 둘을 구분하지 않아서 반복 가능 애너테이션과 그 컨테이너 애너테이션을 모두 가져오지만, isAnnotationPresent 메서드는 둘을 명확히 구분합니다.
따라서 반복 가능 애너테이션을 여러 번 단 다음 isAnnotationPresent로 반복 가능 애너테이션이 달렸는지 검사한다면 "그렇지 않다"라고 알려줍니다. (컨테이너가 달렸기 때문입니다.)
그 결과 애너테이션을 여러 번 단 메서드들을 모두 무시하고 지나칩니다.
같은 이유로, isAnnotationPresent로 컨테이너 애너테이션이 달렸는지 검사한다면 반복 가능 애너테이션을 한 번만 단 메서드를 무시하고 지나칩니다.
그래서 달려 있는 수와 상관없이 모두 검사하려면 둘을 따로따로 확인해야 합니다.
다음은 RunTests 프로그램이 ExceptionTest의 반복 가능 버전을 사용하도록 수정한 모습입니다.
import java.reflect.*;
public class RunTests {
public static void main(String[] args) throws Exception {
int tests = 0;
int passed = 0;
Class<?> testClass = Class.forName(args[0]);
for (Method m : testClass.getDeclaredMethods()) {
if (m.isAnnotationPresent(ExceptionTest.class
|| m.isAnnotationPresent(ExceptionTestContainer.class)) {
tests++;
try {
m.invoke(null);
System.out.printf("테스트 %s 실패: 예외를 던지지 않음%n", m);
} catch (InvocationTargetException wrappedExc) {
Throwable exc = wrappedExc.getCause();
int oldPassed = passed;
Class<? extends Throwable>[] excTypes =
m.getAnnotationsByType(ExceptionTest.class);
for (ExceptionTest excTest : excTests) {
if (excTest.value().isInstance(exc)) {
passed++;
break;
}
}
if (passed == oldPassed)
System.out.printf("테스트 %s 실패: %ㄴ %n", m, exc);
}
}
}
System.out.printf("성공: %d, 실패: %d%n", passed, tests - passed);
}
}
반복 가능 애너테이션을 사용해 하나의 프로그램 요소에 같은 애너테이션을 여러 번 달 떄의 코드 가독성을 높여보았습니다.
이 방식으로 여러분 코드의 가독성을 개선할 수 있다면 이 방식을 사용하도록 합시다.
하지만 애너테이션을 선언하고 이를 처리하고 부분에서는 코드 양이 늘어나며, 특히 처리 코드가 복잡해져 오류가 날 가능성이 커짐을 명심합시다.
이번 아이템의 테스트 프레임워크는 아주 간단하지만 애너테이션이 명명 패턴보다는 낫다는 점은 확실히 보여줍니다.
테스트는 애너테이션으로 할 수있는 일 중 극히 일부일 뿐입니다.
여러분이 다른 프로그래머가 소스코드에 추가 정보를 제공할 수 있는 도구를 만드는 일을 한다면 적당한 애너테이션 타입도 함께 정의해 제공합시다.
애너테이션으로 할 수 있는 일을 명명 패턴으로 처리할 이유는 없으니 말입니다.
도구 제작자를 제외하고는, 일반 프로그래머가 애너테이션 타입을 직접 정의할 일은 거의 없습니다.
하지만 자바 프로그래머라면 예외 없이 자바가 제공하는 애너테이션 타입들은 사용해야 합니다.(아이템 40, 아이템 27)
IDE나 정적 분석 도구가 제공하는 애너테이션을 사용하면 해당 도구가 제공하는 진단 정보의 품질을 높여줄 것입니다.
단, 그 애너테이션들은 표준이 아니니 도구를 바꾸거나 표준이 만들어지면 수정 작업을 조금 거쳐야 할 수도 있습니다.
'JAVA > 이펙티브 자바' 카테고리의 다른 글
아이템[41]. 정의하려는 것이 타입이라면 마커 인터페이스를 사용하라 (0) | 2022.04.21 |
---|---|
아이템[40]. @Override 애너테이션을 일관되게 사용하라 (0) | 2022.04.21 |
아이템[38]. 확장할 수 있는 열거 타입이 필요하면 인터페이스를 사용하라 (0) | 2022.04.21 |
아이템[37]. ordinal 인덱싱 대신 EnumMap을 사용하라 (0) | 2022.04.18 |
아이템[36]. 비트 필드 대신 EnumSet을 사용하라 (0) | 2022.04.18 |
- Total
- Today
- Yesterday
- Algorithm
- 테라폼
- Spring
- kkoon9
- Java
- 클린 코드
- 이팩티브 자바
- C++
- 코테
- kotest
- 정규표현식
- Kotlin
- 디자인 패턴
- BOJ
- node.js
- 알고리즘
- 이펙티브 자바
- 디자인패턴
- 객체지향
- programmers
- BAEKJOON
- 클린 아키텍처
- JPA
- AWS
- 프로그래머스
- MSA
- Effective Java
- 백준
- Spring Boot
- Olympiad
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | ||||||
2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | 27 | 28 |