명명 패턴
명명 패턴이란 메소드나 필드의 이름을 일관된 방식으로 작성하는 패턴이다.
테스트 프레임워크인 JUnit이 버전 3까지 테스트 메소드 이름을 test로 시작하게 했다.
명명패턴에는 세가지 단점이 존재한다. 위 JUnit 3을 기준으로 말하면 아래와 같다.
1. 오타가 나면 안된다.
test가 아닌 tset으로 작성한다면 이 메소드를 무시하고 지나쳐서 테스트가 통과했다고 오해할 수 있다.
2. 올바른 프로그램 요소에서만 사용되리라 보증할 방법이 없다.
test를 메소드 말고 의도하지 않은 다른 곳에 붙히는 등 경고조차도 없다.
3. 프로그램 요소를 매개변수로 전달할 마땅한 방법이 없다.
예외를 던져야 성공하는 테스트가 있을 때, 기대하는 예외 타입을 매개변수로 전달해야하는 경우다. 예외 이름을 테스트 메소드 이름에 붙힐 수 있지만, 보기 나쁘고 깨지기 쉬우며 컴파일러는 그 문자열이 예외를 가리키는지 모르며 그 클래스가 존재여부, 혹은 예외여부도 모른다.
마커 애노테이션(marker annotation)
아무 매개변수 없이 단순히 대상에 마킹하는 애노테이션이다. 이름에 오타가 나거나 잘못된 위치에 사용하면 컴파일 오류가 발생한다.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Test {
}
위 코드는 매개변수 없는 정적 메소드 전용이지만, 이 제약을 컴파일러가 강제할 순 없다. 그렇게 하려면
javax.annotation.processing API 문서를 참조해서 사용하면된다. 다음에 사용할 일 있으면 참고해야겠다.
참조:
만약 저 API를 사용하지 않은 상태, 즉 적절한 애노테이션 처리기 없이 인스턴스 메소드나 매개변수가 잇는 메소드에 달면 어떻게 될까?
컴파일은 잘 되지만 테스트 도구 실행시 문제가 된다.
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를 단 m3, m7은 예외를 던지고 m1과 m5는 그렇지 않다. 그리고 m5는 정적 메소드가 아니기에 잘못 사용한 경우다.
@Test 애노테이션이 Sample 클래스의 의미에 직접적 영향을 주진않고, 대상 코드의 의미는 그대로 둔 채 그 애노테이션에 관심있는
도구에서 특별한 처리를 할 기회를 준다. 다음 RunTests 클래스가 그 도구의 예다.
public class RunTests {
public static void main(String[] args) throws Exception {
int tests = 0;
int passed = 0;
Class<?> testClass = Class.forName("effective.chapter6.item39.markerannotation.Sample");
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으로 감싸서 다시 던진다.
그래서 이 프로그램은 그 예외를 잡아 원래 예외에 담긴 실패 정보를 추출해(getCause) 출력한다.
InvocationTargetException 외의 예외는 @Test를 잘못 사용한 것으로, 인스턴스 메소드거나 매개변수가 있거나, 호출할 수 없는 메소드 등에 달았다는 것이다. 위에서 설명한대로 매개변수없는 정적 메소드만 가능한 애노테이션이기 때문에 그러한 예외가 발생하는 것이다.
다음으로 위의 예제에 나왔던 @Test 애노테이션 내의 애노테이션에 대해서 좀 더 자세히 살펴보자.
메타 애노테이션(meta-annotation): 애노테이션 선언에 다는 애노테이션
@Test 애노테이션 탑입 선언 자체에 내부적으로 두 가지 다른 애노테이션이 달려있다
@Retention(RetentionPolicy.RUNTIME) @Test가 런타임에도 유지되어야 한다는 표시다. 생략하면 @Test를 인식할 수 없다.
@Target(ElementType.METHOD) @Test가 반드시 메소드 선언에서만 사용한다고 알려준다.(즉, 클래스, 필드에서 사용 불가)
Retention 애노테이션 내부로 들어가보면 다음과 같다.
@Documented javadoc으로 문서를 만들 때 어노테이션에 대한 설명도 포함하도록 지정해주는 것이다.
내부에는 3가지 정책이 있다.
SOURCE: 주석처럼 사용되며, 컴파일러가 컴파일할 때 해당애노테이션의 메모리는 버려지게 된다.
CLASS: 컴파일러에 의해 클래스파일에는 기록되지만, 런타임에 가상머신에는 유지할 필요가 없을 때 사용되며 기본값이다.
RUNTIME: 컴파일러에 의해 클래스 파일에 기록되고, 런타임에 가상머신에서 유지된다.
자세한 내용은 아래 사이트를 참조하자.
Target 애노테이션을 까보면 다음과 같다.
ElementType을 통해, 애노테이션이 부착될 타입을 지정할 수 있다.
ElementType.Type | 클래스, 인터페이스, 애노테이션 타입, 열거타입 |
ElementType.FIELD | 필드 |
ElementType.METHOD | 메소드 |
ElementType.PARAMETER | 파라미터 |
ElementType.CONSTRUCTOR | 생성자 |
ElementType.LOCAL_VARIABLE | 지역변수 |
ElementType.ANNOTATION | 애노테이션 |
ElementType.PACKAGE | 패키지 |
ElementType.TYPE_PARAMETER | 파라미터 타입 |
ElementType.Type_USE | 어떤 타입에도 사용 가능 |
ElementType.MODULE | 모듈 |
매개변수 하나를 받는 애노테이션 타입
// 명시한 예외를 던져야만 성공하는 테스트 메서드용 애너테이션
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
Class<? extends Throwable> value();
}
애노테이션에 필드를 두면 옵션값으로 사용할 수 있다. 내부에 정의할 수 있는 필드는 아래 공식문서를 참조하자.
위 코드의 애노테이션 매개변수 타입은 Class<? extends Throwable>이다.
이 와일드카드 타입은 Throwable을 확장한 클래스의 Class 객체로 모든 예외와 외류 타입을 다 수용할 수 있다.
한정적 타입 토큰의 또 하나의 활용 사례인 것이다.
다음은 이 애노테이션을 활용하는 코드다. class 리터럴은 애노테이션 매개변수의 값으로 사용됐다.
public class Sample2 {
@ExceptionTest(ArithmeticException.class)
// 성공해야 한다.
public static void m1() {
int i = 0;
i = i / i;
}
@ExceptionTest(ArithmeticException.class)
// ArrayIndexOutOfBoundsException을 던져야 하기때문에 실패해야 한다.
public static void m2() {
int[] a = new int[0];
int i = a[1];
}
@ExceptionTest(ArithmeticException.class)
// 예외가 발생하지 않기에 실패해야 한다.
public static void m3() { }
}
이제 이 애노테이션을 다룰 수 있도록 위 메인문의 일부를 수정하면된다.
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);
}
}
위 마커 애노테이션에서 사용한 코드와는 차이가 있다.
애노테이션 매개변수의 값을 추출해 테스트 메소드가 올바른 예외를 던지는지 확인하는데 사용한다.
테스트 프로그램이 문제없이 컴파일되면 예외가 올바른 타입이라는 뜻이다.
단, 해당 예외의 클래스 파일이 컴파일 타임에는 존재했으나 런타임에는 존재하지 않을 수 있는데, TypeNotPresentException을 던진다.
컴파일 시 있고 런타임에 존재하지 않게 하기위해 RuntimeException을 확장한 새로운 예외를 정의했다.
public class KTaeException extends RuntimeException { }
이 예외를 Sample2 클래스의 m1 메소드의 애노테이션으로 달았다.
컴파일에는 있고, 런타임시 제거하기 위해 RunTest의 메인문에 Thread.sleep(15000)을 걸었고,
컴파일 후 15초동안 메인문의 실행이 지연되니 그 사이 build된 내용물에서 KTaeException.class 파일을 지웠다.
그러면 컴파일 시 런타임에 존재하지 않는 조건을 충족한 것이다. 그 결과 예상했던대로, TypeNotPresentException을 던졌다.
책의 조건대로 예외를 유도하긴 했으나, 실무에서 이러한 조건이 맞는 상황이 발생할지는 모르겠다.
배열 매개변수를 받는 애노테이션 타입
예외를 여러 개 명시하고 그중 하나가 발생하면 성공하게할 수도 있다. 애노테이션 매커니즘은 이런 쓰임에 유용한 기능이 기본으로 있다.
@ExceptionTest의 매개변수 타입을 class 객체의 배열로 수정하면 아래와 같다.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
Class<? extends Exception>[] value();
}
배열 매개변수를 받는 애노테이션용 문법은 유연하다. 단일 원소 배열에 최적화 했지만, 앞의 @ExceptionTest도 수정없이 모두 수용한다.
원소가 여럿인 배열을 지정할 땐, 원소들을 중괄호로 감싸서 쉼표 구분하면 된다.
@ExceptionTest({ IndexOutOfBoundsException.class, NullPointerException.class })
public static void doublyBad() {
List<String> list = new ArrayList<>();
//NPE
list.addAll(5, null);
}
다음은 이 새로운 @ExceptionTest를 지원하도록 RunTests를 수정한 내용이다.
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);
}
}
반복 가능 애노테이션
자바8부터는 여러 개의 값을 받는 애노테이션을 다른 방식으로도 만들 수 있다.
배열 매개변수를 사용하는 대신 애노테이션에 @Repeatable 메타 애노테이션을 사용하는 방법이 있다.
이 메타 애노테이션을 사용하면, 하나의 프로그램 요소에 여러번 달 수 있다. 하지만 주의사항이 있다.
1. @Repeatable을 단 애노테이션을 반환하는 컨테이너 애노테이션이 필요하다.
2. 컨테이너 애노테이션은 내부 에노테이션 타입의 배열을 반환하는 value 메소드를 정의해야한다.
3. 컨테이너 애노테이션 타입에는 적절한 보존 정책(@Retention)과 적용 대상(@Target)을 명시해야한다.
Repetable 애노테이션을 까보면 아래와 같다.
Annotation 타입이 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();
}
샘플에 적용해보면 아래와 같다.
@ExceptionTest(IndexOutOfBoundsException.class)
@ExceptionTest(NullPointerException.class)
public static void doublyBad() {
List<String> list = new ArrayList<>();
//NPE
list.addAll(5, null);
}
반복 가능 애노테이션은 처리할 때도 주의해야한다. 반복 가능 애노테이션을 여러개 달면 하나만 달았을 떄와 구분하기 위해 해당 컨테이너 애노테이션 타입이 적용된다. getAnnotationsByType 메소드는 이 둘을 구분하지 않아서, 반복 가능 애노테이션과 그 컨테이너 애노테이션을 모두 가져오지만 isAnnotationPresent 메소드는 둘을 명확히 구분한다.
따라서 반복 가능 애노테이션을 여러번 달고 isAnnotationPresent로 ExceptionTest.class가 있는지 확인하면 없다고 나온다.
컨테이너가 달렸기 때문이고, 둘 다 검사하기 위해서는 ExceptionTestContainer도 검사해줘야한다.
단 한개의 애노테이션만 달았다면 ExceptionTest.clas에대해 true가 나올거고,
여러개가 있다면 ExceptionTestContainer로 인식해 true가 나온다.
코드는 아래와 같다.
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);
}
}
과정들이 결국 두개의 애노테이션을 사용하는 것은 @ExceptionTestContainer에 value를 통해 @ExceptionTest를 넣는것과 같다.
@ExceptionTest(IndexOutOfBoundsException.class)
@ExceptionTest(NullPointerException.class)
// -->
@ExceptionTestContainer(value = {@ExceptionTest(IndexOutOfBoundsException.class), @ExceptionTest(NullPointerException.class)})
정리
반복 가능 애노테이션으로 코드 가독성을 높였지만 애노테이션을 선언하고 이를 처리하는 부분에서 코드양이 늘어나며 처리가 복잡해져 오류가 날 가능성이 크다는 것도 알아야한다. 여기서 다룬 예제는 간단하지만 명명패턴보단 낫다는 것을 알 수 있다.
다른 프로그래머가 소스코드에 추가 정보를 제공할 수 있는 도구를 만드는 일을 한다면 적당한 애너테이션 타입을 제공하자.
애노테이션으로 할 수 있는 일을 명명 패턴으로 처라할 이유는 없다.
자바 프로그래머라면 예외 없이 자바가 제공하는 애노테이션 타입들은 사용해야한다.
* 위 글은 EffectiveJava 3/E 책 내용을 정리한 글로, 저작권 관련 문제가 있다면 댓글로 남겨주시면 즉각 삭제조치 하겠습니다.
* 위 글은 EffectiveJava 3/E 책 내용을 정리한 글로, 저작권 관련 문제가 있다면 댓글로 남겨주시면 즉각 삭제조치 하겠습니다.
'Reading > Effective Java' 카테고리의 다른 글
[Effective-Java] Item 41. 정의하려는 것이 타입이라면 마커 인터페이스를 사용하라. (0) | 2021.10.31 |
---|---|
[Effective-Java] Item 40. @Override 애노테이션을 일관되게 사용하라 (0) | 2021.10.31 |
[Effective-Java] Item 38. 확장할 수 있는 열거 타입이 필요하면 인터페이스를 사용하라 (0) | 2021.10.31 |
[Effective-Java] Item 37. ordinal 인덱싱 대신 EnumMap을 사용하라 (0) | 2021.10.31 |
[Effective-Java] Item 36. 비트 필드 대신 EnumSet을 사용하라 (0) | 2021.10.31 |