1. 명명 패턴의 단점
도구나 프레임워크가 특별히 다뤄야 할 프로그램 요소에는 딱 구분되는 명명 패턴을 적용해왔는데,, 예컨대 테스트 프레임워크인 Junit 은 버전 3까지 테스트 메서드 이름을 test 로 시작하게끔 했다. 직관적이지만, 단점이 많다.
오타가 나면 안된다.
실수로 이름을 tsetSafetyOverride 로 지으면, Junit 3 은 이 메서드를 무시하고 지나쳐 개발자는 통과되었다고 오해할 수 있다.
올바른 프로그램 요소에서만 사용된다는 보증이 없다.
무슨 말이냐면, 예를 들어 클래스 이름을 TestSafetyMechanisms 로 지어 Junit 에 던져줬다고 가정해보자. 개발자는 테스트대리라 기대했겠지만 Junit 은 클래스 이름은 관심이 없고, 메서드 이름에만 관심이 있다. 테스트가 전혀 수행되지 않는다.
프로그램 요소를 매개변수로 전달할 마땅한 방법이 없다.
예를 들어, 특정 예외를 던져야만 성공하는 테스트가 있다고 가정해보자. 기대하는 예외 타입을 테스트에 매개변수로 전달해야 하는데, 예외 이름을 테스트 메서드에 덧붙이기도 이상하고, 덧붙인다고 해도 컴파일러가 알 도리가 없다.
2. 애너테이션

위 문제를 모두 해결해주는 멋진 개념으로, Junit 도 버전 4부터 도입되었다. 이제, Test 라는 이름의 애너테이션을 정의한다고 해보자. 예외가 발생하면 해당 테스트를 실패로 처리한다.
import java.lang.annotation.*;
// 테스트 메서드임을 선언하는 애너테이션이다.
// 매개변수 없는 정적 메서드 전용이다.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Test {
}
@Retention 과 @Target 처럼 애너테이션 선언에 다는 애너테이션을 메타애너테이션이라 한다. @Retention(RetentionPolicy.RUNTIME) 메타애너테이션은 @Test 가 런타임에도 유지되어야 한다는 표시이고, @Target(ElementType.METHOD) 메타애너테이션은 @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() { }
}
정적 메서드가 7개이고, @Test 는 4개가 달려있으며, 넷 중 m3과 m7만 예외를 던진다. m5는 정적 메서드가 아니라 잘못 사용했다. 즉, 총 4개의 테스트 메서드 중 1 성공, 2 실패, 1 잘못사용 이다. 그리고, @Test 를 붙이지 않은 메서드는 무시된다. 아래 예시를 살펴볼텐데, @Test 애너테이션은 그저 관심 있는 메서드에 추가 정보를 제공할 뿐이라는 관점에서 살펴보자.
import java.lang.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 exception) {
System.out.println("잘못 사용한 @Test: " + m);
}
}
}
System.out.printf("성공: %d, 실패: %d%n", passed, tests - passed);
}
}
RunTest 메서드는 명령줄로부터 클래스 이름을 받아, 그 클래스에서 @Test 애너티에션이 달린 메서드를 차례로 호출한다. isAnnotationPresent 가 실행할 메서드를 찾아주는 메서드이다. Runtests 로 Sample 을 실행해보자.
public static void Sample.m3() failed: RuntimeException: Boom
Invalid @Test: public void Sample.m5()
public static void Sample.m7() failed: RuntimeException: Crash
성공: 1, 실패:3
예상대로 총 4개 중 m1 하난만 성공이다. 이제, 특정 예외를 던져야만 성공하는 테스트를 지원하도록 @ExceptionTest 를 만들어보자.
import java.lang.annotation.*;
/**
* 명시한 예외를 던저야만 성공하는 테스트 메서드 애너테이션
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
Class<? extends Throwable> value();
}
바로, 이 애너테이션의 매개변수 타입은 Class<? extends Throwable> 이라는 말이다. 여기서 와일드카드 타입은 모든 예외 타입을 다 수용한다는 말이다. 실제 활용하는 모습을 보자. 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() { } // 실패해야 한다. (예외가 발생하지 않음)
}
예외가 발생해야지만 테스트가 성공하도록 구현되었다. 이제, 샘플말고 아까 main 메서드에서 이 에너테이션을 다룰 수 있도록 테스트 도구를 수정해보자.
if (m.isAnnotationPresent(ExceptionTest.class)) {
tests++;
try {
m.invoke(null);
System.out.printf("테스트 %s 실패: 예외를 던지지 않음%n", m);
} catch (InvocationTargetException wrappedEx) {
Throwable exc = wrappedEx.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);
}
}
비슷해보이지만, 차이가 있다. 애너테이션 배개변수의 값을 추출하여, 테스트 메서드가 올바른 예외를 던지는지 확인한다. 테스트 프로그램이 문제없이 컴파일되면 애너테이션 매개변수가 가리키는 예외가 올바른 타입이라는걸 의미한다. 더 들어가서, 예외를 여러 개 명시하고 그 중 하나가 발생하면 성공하게 만들 수도 있다. 아래와 같이 ExceptionTest 어노테이션을 수정해보자.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
Class<? extends Exception>[] value();
}
배열 매개변수를 받는 것이다. 원소가 여럿인 배열을 넘겨보자.
@ExceptionTest({IndexOutOfBoundsException.class, NullPointerException.class})
public static void doublyBad() {
List<String> list = new ArrayList<>();
list.addAll(1, null);
}
이후, 이 매개변수를 여러 개 받는 @ExceptionTest 어노테이션을 지원하도록 테스트 러너를 수정해보자.
if (method.isAnnotationPresent(MadExceptionTest.class)) {
testCount++;
try {
method.invoke(null);
System.out.printf("테스트 %s 실패: 예외를 던지지 않음%n", m);
} catch (Throwable wrappedExc) {
Throwable exc = itException.getCause();
int oldPassed = passed;
Class<? extends Throwable>[] excTypes = m.getAnnotation(Exception.class).value();
for (Class<? extends Throwable> excType : excTypes) {
if (excType.isInstance(exc)) {
passed++;
break;
}
}
if (passed == oldPassed) {
System.out.printf("테스트 %s 실패: %s %n", m, exc);
}
}
}
추가로, 자바 8에서는 여러 개의 값을 받는 애너테이션을 다른 방식으로 만들 수 있다. 배열 매개변수 대신, @Repeatable 메타애너테이션을 다는 방식이다. 단, 주의점이 있다. @Repeatable 을 단 애너테이션을 반환하는 '컨테이너 애너테이션' 을 하나 더 만들어야 하고, @Repeatable 에 이 컨테이너 애너테이션의 class 객체를 매개변수로 전달해야 한다. 그리고, 컨테이너 애너테이션은 내부 애너테이션 타입의 배열을 반환하는 value 메서드를 정의해야 한다. 무슨 말인지 잘 모르겠으니 예시를 살펴보자.
@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();
}
ExceptionTestContainer 라는 컨테이너 애너테이션을 만들고, @Repeatable 애너테이션을 이용해 매겨변수로 컨테이너 애너테이션을 만든다. 그리고, 컨테이너 애너테이션은 value 라는 내부 애너테이션 타입의 배열을 정의했다.
3. 정리
애너테이션이 명명 패턴보다는 낫다. 다른 프로그래머가 소스코드에 추가 정보를 제공할 수 있는 도구를 만드는 일을 하고 있다면, 적당한 애너테이션 타입도 함께 정의해서 제공하자. 애너테이션으로 할 수 있는 일을 명명 패턴으로 처리할 이유는 없다.
'1️⃣ 백앤드 > 이펙티브 자바' 카테고리의 다른 글
| [아이템41] 정의하려는 것이 타입이라면 마커 인터페이스를 사용하라 (2) | 2024.11.07 |
|---|---|
| [아이템40] @Override 애너테이션을 일관되게 사용하라 (1) | 2024.11.07 |
| [아이템38] 확장할 수 있는 열거 타입이 필요하면 인터페이스를 사용하라 (0) | 2024.11.05 |
| [아이템37] ordinal 인덱싱 대신 EnumMap 을 사용하라 (0) | 2024.11.04 |
| [아이템36] 비트 필드 대신 EnumSet 을 사용하라 (1) | 2024.11.04 |