- 해당 챕터는 명명 패턴이 애너테이션보다 좋은 점을 설명하긴 한다.
- 하지만 애너테이션을 직접 구현해보면서 그 동작이 어떻게 구현되는지에 대한 내용이 더 많다
- 그 과정에서 애너테이션의 장점을 자연스럽게 알게 되니 삼천포로 빠진다고 너무 의심하지 말자
- 전통적으로 도구나 프레임워크가 특별히 다뤄야 할 프로그램 요소에는 딱 구분되는 명명 패턴을 적용해왔다.
- 예로 테스트 프레임워크인 JUnit은 버전 3까지 테스트 메서드 이름을 test로 시작하게끔 했다.
import junit.framework.TestCase;
public class CalculatorTest extends TestCase {
// test로 시작하는 메서드 이름!
public void testAdd() {
Calculator calculator = new Calculator();
int result = calculator.add(1, 2);
assertEquals(result, 3);
}
// test로 시작하지 않는 이름의 메서드는 Junit3가 인식하지 못함
public void tsetAdd() {
Calculator calculator = new Calculator();
int result = calculator.add(1, 2);
assertEquals(result, 3);
}
}
- Junit이 왜 이런 선택을 했었는지는 다음을 확인!
- 위와 같이 명명으로 부가 로직을 구분하는 패턴이
명명 패턴
- 명명 패턴은 전통적으로 많이 사용되어 왔지만 단점이 많다.
- 실수로 이름을 잘못 지으면 명명 패턴으로 동작하는 로직이 동작하지 않음
- Junit3는
test
로 시작하지 않는 메서드를 무시한다 👀
- 어떤 개발자가 클래스 이름을 TestSafety라고 지어놓고 다음을 기대한다.
클래스 이름이 Test로 시작하니까 클래스의 모든 테스트 메서드를 수행해주겠지 👀
- Junit3는 하지만 클래스 이름에는 관심없다.
- 위와 같은 행위는 컴파일 에러도 안내줌 (그냥 클래스 이름일 수도 있습니다.)
- 위와 같은 예시는 다음을 암시한다.
- 명명 패턴에서 개발자의 의도는 적용되지 않은 채 희석될 수 있다.
- 명명 패턴은 사용하기 위한 자세한 명세가 부족하고 오해할 수 있는 여지가 있다는 것이 핵심!
- 특정 예외를 던져야만 성공하는 테스트가 있다고 해보자
- 기대하는 예외 타입을 테스트에 매개변수로 전달해야 하는 상황이다.
- 우리는 명명 패턴을 통해 test로 시작하는 메서드만 파싱할 수 있을 뿐 매개변수로 전달할 마땅한 방법이 없다.
- 명명 패턴을 사용하는 경우 예외의 이름을 테스트 메서드 이름에 덧붙이는 방법도 있지만, 보기도 나쁘고 깨지기도 쉽다.
testThrowIllegalArgumentException()
메서드 명을 이렇게 지을 수 있다.- 하지만 보기 나쁘고 깨지기 쉽다
- 애너테이션은 명명 패턴의 위와 같은 단점들을 해결해주는 멋진 개념
- Junit4에서도 전면 도입한 모습을 볼 수 있다.
- 애너테이션이 명명 패턴보다 어떠한 점이 더 좋을까?
- 이번 아이템에서는 직접 작은 테스트 프레임워크 애너테이션을 구현할 것이다.
- 애너테이션을 만들고 동작을 확인하면서 애너테이션이 명명 패턴보다 어떠한 점들이 좋은지 살펴보자
@Test
애너테이션을 만들어보자@Test
애너테이션은 자동으로 수행되는 간단한 테스트용 애너테이션- 예외가 발생하면 해당 테스트를 실패로 처리한다.
import java.lang.annotation.*;
/**
* 테스트 메서드임을 선언하는 애너테이션이다.
* 매개 변수 없는 정적 메서드 전용이다.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Test {
}
- 보다시피
@Test
애너테이션 타입 선언 자체에도 두 가지의 다른 애너테이션이 달려 있다. - 바로
@Retention
,@Target
- 이 처럼 애너테이션 선언에 다는 애너테이션을 메타애너테이션이라 한다.
@Retention(RetentionPolicy.RUNTIME)
- 해당 메타애너테이션은 테스트가 런타임에도 유지되어야 한다는 표시
- 만약 해당 메타애너테이션이 없으면 테스트 도구는
@Test
를 인식할 수 없음
@Target(ElementType.METHOD)
- 해당 메타애너테이션은
@Test
가 메서드 선언에서만 사용되어야 함을 알림 - 따라서 클래스 선언, 필드 선언 등 다른 프로그램 요소에는 달 수 없다.
- 해당 메타애너테이션은
public class Sample {
@Test public static void m1() {}
}
- 위의 코드는
@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는 그렇지 않다.
- 요약하면 총 4개의 테스트 메서드 중 1개는 성공 2개는 실패, 1개는 잘못 사용
@Test
를 붙이지 않은 나머지 4개의 메서드는 테스트 도구가 무시할 것이다.
@Test
애너테이션이 Sample 클래스의 의미에 직접적인 영향을 주지는 않는다.- 그저 이 애너테이션에 관심 있는 프로그램에게 추가 정보를 제공할 뿐이다.
- 즉
@Test
애너테이션을 붙이는 것만으로 우리는 원하는 동작을 얻을 수 없다. (부가 정보만 줄 뿐) - 이 애너테이션에 관심있는 도구에서 특별한 처리를 해야 원하는 동작을 할 수 있다.
@Test
애너테이션에 관심있는 도구는 어떤 도구일까- 바로 다음의 RunTests이다.
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 exc) {
System.out.println("잘못 사용한 @Test: " + m);
}
}
}
System.out.printf("성공: %d, 실패\\\\: %d%n", passed, tests -passed);
}
}
- 해당 테스트 러너는 명령줄로부터 완전 정규화된 클래스 이름을 받아 그 클래스에서
@Test
애너테이션이 달린 메서드를 차례로 호출 - 테스트 메서드가 예외를 던지면 리플렉션 메커니즘이
InvocationTargetException
으로 감싸서 다시 던진다. - 다음은 이 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
- 지금까지는 인자를 받지 않는 애너테이션을 지정하고 그 동작을 구현해보았다.
- 명명패턴보다 컴파일 타임에 여러 정보를 알려주고 그 동작을 분리할 수 있다는 점에서 장점을 가진다는 것을 확인할 수 있다.
- 조금 더 욕심을 부려보자
- 명명패턴에서는 하지 못하는 것인데 애너테이션에 인자를 하나 전달할 수 있을까?
- 전달할 수 있다.
- 다음의 상황을 따라가보자.
- 이제 특정 예외를 던져야만 성공하는 테스트를 지원하도록 해볼 것.
- 그러려면 애너테이션이 인자를 받을 수 있으면 좋을 것 같다.
@ExceptionTest(IllegalArgumentException.class)
처럼- 앞서 명명 패턴에서는 위 처럼 인자를 받는 것이 불가능함을 설명했었다.
- 애너테이션에서는 가능할까? 가능하다!
- 새로운 애너테이션 타입을 구현하면서 어떻게 가능한지 살펴보자
import java.lang.annotation.*;
/**
* 명시한 예외를 던져야만 성공하는 테스트 메서드용 애너테이션
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
Class<? extends Throwable> value();
}
- 매개 변수 하나짜리 애너테이션임을 확인할 수 있다.
- 이제 이 애너테이션을 실제 활용하는 모습을 살펴보자.
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() {} // 실패 - 예외가 발생하지 않음
}
- 이제 이 애너테이션을 다룰 수 있도록 테스트 도구를 수정해보자.
- 코드 39-3의 코드를 다음과 같이 수정하면 됨
...
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);
...
@Test
애너테이션용 코드와 비슷해 보임- 한 가지 차이라면, 이 코드는 애너테이션 매개변수의 값을 추출하여 테스트 메서드가 올바른 예외를 던지는지 확인하는 데 사용
- 형변환 코드가 없으니 ClassCastException 걱정은 음따
- 명명패턴은 못하는 인자처리를 애너테이션에서는 가능한 것을 확인할 수 있다
- 애너테이션 굳 👍
- 앞서 애너테이션은 인자 한개를 처리할 수 있음을 확인했다.
- 그런데 인자 여러개도 처리할 수 있을까?
@ExceptionTest({NullPointerException.class, IllegalArgumentException.class})
- 위처럼 테스트 메서드가 던지는 예외가 여러개의 예외 중 하나인지를 검증하고 싶다고 가정해보자
- 이러한 처리도 애너테이션은 가능하다
- 또한 반복적으로 애너테이션을 사용하여 해결할 수도 있다. (물론 애너테이션 클래스의 설정을 바꿔주어야 함)
@ExceptionTest(IndexOutOfBoundsException.class)
@ExceptionTest(NullPointerException.class)
public static void doublyBad() {...}
- 위와 같은 형태는 처리할 때 주의를 여러 주의를 요하지만 (반복 가능 애너테이션임을 선언하지 않으면 무시) 애너테이션이 유연하게 사용될 수 있음을 말하기에는 손색이 없는 것 같다.
- 이번 아이템의 테스트 프레임워크는 아주 간단하지만 애너테이션이 명명 패턴보다 낫다는 점은 확실히 보여준다.
- 테스트는 애너테이션으로 할 수 있는 일 중 극히 일부!
- 여러분이 다른 프로그래머가 소스코드에 추가 정보를 제공할 수 있는 도구를 만드는 일을 한다면 적당한 애너테이션 타입도 함께 정의해 제공하자.
- 도구 제작자를 제외하고는 일반 프로그래머가 애너테이션 타입을 직접 정의할 일은 거의 없다 (조슈아 블로크 왈)
- 하지만 자바 프로그래머라면 예외 없이 자바가 제공하는 애너테이션 타입들은 사용할 수 있어야 한다.