변수나 함수의 이름을 일관된 방식으로 작성하는 패턴
전통적으로 도구나 프레임워크가 특별히 다뤄야 할 프로그램 요소의 구분을 위해 사용
단점
예를 들어, jUnit은 버전3까지 테스트 메서드 이름을 test
로 시작(명명패턴)하게 했다.
오타가 나면 안 된다.
만약 test
로 시작되어야 할 메서드 이름이 오타로 인해 tset
로 작성되었다면, 명명 패턴에는 벗어나지만 프로그램 상에서는 문제가 없기 때문에 테스트 메서드로 인식하지 못하고 테스트를 수행하지 않는다. 하지만, 개발자는 이 테스트가 실패하지 않았으니 통과했다고 오해할 수 있다.
명명패턴을 의도한 곳에서만 사용할 것이라는 보장이 없다.
개발자는 jUnit3의 명명 패턴인 test
를 메서드가 아닌 클래스의 이름으로 지음으로써 해당 클래스에 정의된 테스트 메서드들이 수행되길 바랄 수 있다. 하지만 jUnit은 클래스 이름에는 관심이 없다. 따라서 개발자가 의도한 테스트는 전혀 수행되지 않는다.
명명패턴을 적용한 요소를 매개변수로 전달할 마땅한 방법이 없다.
기대하는 예외 타입을 테스트에 매개변수로 전달해야 하는 상황일 때, 예외의 이름을 테스트 메서드 이름에 덧붙이는 방법도 있지만, 가독성도 좋지 않고 깨지기도 쉽다. 또한 컴파일러는 메서드 이름에 덧붙인 문자열이 예외를 가리키는지도 알 수 없다.
위의 명명패턴의 문제점을 해결할 수 있다.
jUnit 4부터 도입하였다.
Test
라는 이름의 애너테이션을 정의해보자. 자동으로 수행되는 간단한 테스트용 애너테이션으로, 예외가 발생하면 해당 테스트를 실패로 처리한다.
import java.lang.annotation.*;
/**
* 테스트 메서드임을 선언하는 애너테이션이다.
* 매개변수 없는 정적 메서드 전용이다.
*/
@Retention(RetentionPolicy.RUNTIME) // 보존 정책
@Target(ElementType.METHOD) // 적용 대상
public @interface Test {
}
@Test
애너테이션 타입 선언 자체에도 두 가지의 다른 애너테이션이 붙어 있다.
이처럼 애너테이션 선언에 다는 애너테이션은 매타 에너테이션(meta-annotation) 이라 한다.
@Retention(RetentionPolicy.RUNTIME)
: @Test
가 런타임에도 유지되어야 한다는 표시이다. 만약 생략한다면 테스트 도구는 @Test
를 인식할 수 없다.@Target(Element.METHOD)
: @Test
가 반드시 메서드 선언에서만 사용되어야 한다고 알려준다. 따라서 클래스 선언, 필드 선언 등 다른 프로그램 요소에는 달 수 없다.아래의 코드는 @Test
애너테이션을 실제로 (애너테이션 처리기 없이)적용한 예시이다.
이와 같은 애너테이션을 아무 매개변수 없이 단순히 대상에 마킹(marking) 한다라는 뜻에서 마커 애너테이션(marker annotation) 이라고 한다. 이 애너테이션을 사용하면 오타나 메서드 선언 외의 프로그램 요소에 달았을 때 컴파일 오류를 내준다.
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() { }
}
이러한 마커 애너테이션은 적절한 애너테이션 처리기가 필요하다. 마커 애너테이션은 단순히 Test
라는 이름에 오타가 있거나 메서드 선언 외의 프로그램 요소에 달면 컴파일 오류를 내어주는 역할을 할 뿐이다.
실제 클래스가 동작하는데 직접적인 영향을 미치는 게 아니고 유용한 정보를 제공할 뿐이다.
import java.lang.reflect.*;
/**
* 완전 정규화된 클래스 이름을 받아, 그 클래스에서 @Test annotaiton이 달린 메서드를 차례로 호출
*/
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);
}
}
리플렉션을 이용하여 마커 애너테이션을 찾고, 예외 발생 시 InvocationTargetException
으로 감싸진다. 그리고 해당 예외에 담긴 실패 정보를 추출(getCause
)해서 출력한다.
해당 애너테이션의 의도와 다르게 사용되어졌을 경우**(매개변수 없는 정적 메서드가 아닌 경우)**에는 InvocationTargetException
외의 다른 예외가 발생되는데, 두 번째 catch 블록에서 붙잡아 적절한 오류 메시지를 출력한다.
만약 특정 예외를 던져야만 성공하는 테스트를 지원하기 위해서는, 새로운 애너테이션 타입이 필요하다.
import java.lang.annotation.*;
/**
* 명시한 예외를 던져야만 성공하는 테스트 메서드용 애너테이션
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
Class<? extends Throwable> value();
}
이 애너테이션의 매개변수 타입은 Class<? extends Throwable>
이다.
Throwable을 확장한 클래스의 Class 객체를 뜻한다. 즉, 모든 예외 타입을 수용한다는 뜻이다.
사용 방법은 다음과 같다.
import java.util.*;
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.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(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);
}
}
}
System.out.printf("성공: %d, 실패: %d%n",
passed, tests - passed);
}
}
위의 코드는 애너테이션 매개변수의 값을 추출하여 테스트 메서드가 올바른 예외를 던지는지 확인하는 데 사용한다.
더 나아가 예외를 여러 개 명시하고 그중 하나가 발생하면 성공하게 만들 수도 있다.
기존 애너테이션에 Class 객체를 배열로 수정해보자.
import java.lang.annotation.*;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
Class<? extends Throwable>[] value();
}
원소가 여럿인 배열을 지정할 때는 원소들을 중괄호로 감싸고 쉼표로 구분하면 된다.
import java.util.*;
public class Sample3 {
@ExceptionTest({ IndexOutOfBoundsException.class,
NullPointerException.class })
public static void doublyBad() {
List<String> list = new ArrayList<>();
list.addAll(5, null);
}
}
애너테이션 처리기는 아래와 같이 반복문을 추가하여 Throwable 배열을 검증하는 로직을 가지도록 수정하면 된다.
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 (Throwable 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 실패: %s %n", m, exc);
}
}
}
}
}
Java8 부터는 단일 요소에 애너테이션을 반복적으로 달 수 있는 @Repeatable
메타 애너테이션을 제공한다. 따라서 배열 매개변수 대신 @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.class)
@ExceptionTest(NullPointerException.class)
public static void doublyBad() { }
@ExceptionTest
를 여러 개 달면 하나만 달렸을 때와 구분하기 위해 컨테이너 애너테이션 타입이 적용된다.
애너테이션 처리기에서 이 둘을 구분하기 위해서는 isAnnotationPresent
로 검사를 수행해야 한다. getAnnotationType
은 둘을 구분하지 않아 모두 가져오지만, isAnnotationPresent
는 이 둘을 명확히 구분할 수 있기 때문이다.
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 (Throwable wrappedExc) {
Throwable exc = wrappedExc.getCause();
int oldPassed = passed;
ExceptionTest[] excTests =
m.getAnnotationsByType(ExceptionTest.class);
for (ExceptionTest excTest : excTests) {
if (excTest.value().isInstance(exc)) {
passed++;
break;
}
}
if (passed == oldPassed)
System.out.printf("테스트 %s 실패: %s %n", m, exc);
}
}
}
}
}
이러한 방법은 코드 가독성을 높일 수 있지만 애너테이션을 처리하는 부분의 코드가 늘어나고 복잡해서 오류가 발생할 확률이 커질 수 있다.
하지만 이번 예제는 이를 감수하더라도 명명 패턴보다 애너테이션이 좋다는 것을 확실히 보여준다.
애니터이션으로 처리할 수 있다면 명명패턴을 사용할 이유가 없다.
도구 제작자를 제외하고는, 일반 프로그래머가 애너테이션 타입을 직접 정의할 일은 거의 없다. 하지만 자바 프로그래머라면 자바가 제공하는 애너테이션 타입들은 사용해야 한다!