Skip to content

Latest commit

 

History

History
298 lines (234 loc) · 13.3 KB

명명_패턴보다_애너테이션을_사용하라.md

File metadata and controls

298 lines (234 loc) · 13.3 KB

아이템 39: 명명 패턴보다 애너테이션을 사용하라

주의!

  • 해당 챕터는 명명 패턴이 애너테이션보다 좋은 점을 설명하긴 한다.
  • 하지만 애너테이션을 직접 구현해보면서 그 동작이 어떻게 구현되는지에 대한 내용이 더 많다
  • 그 과정에서 애너테이션의 장점을 자연스럽게 알게 되니 삼천포로 빠진다고 너무 의심하지 말자

Junit3로 이해하는 명명 패턴

  • 전통적으로 도구나 프레임워크가 특별히 다뤄야 할 프로그램 요소에는 딱 구분되는 명명 패턴을 적용해왔다.
  • 예로 테스트 프레임워크인 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 애너테이션을 만들어보자
  • @Test 애너테이션은 자동으로 수행되는 간단한 테스트용 애너테이션
  • 예외가 발생하면 해당 테스트를 실패로 처리한다.

마커(marker) 애너테이션 타입 선언

import java.lang.annotation.*;
/**
* 테스트 메서드임을 선언하는 애너테이션이다.
* 매개 변수 없는 정적 메서드 전용이다.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Test {
}
  • 보다시피 @Test 애너테이션 타입 선언 자체에도 두 가지의 다른 애너테이션이 달려 있다.
  • 바로 @Retention, @Target
  • 이 처럼 애너테이션 선언에 다는 애너테이션을 메타애너테이션이라 한다.
  • @Retention(RetentionPolicy.RUNTIME)
    • 해당 메타애너테이션은 테스트가 런타임에도 유지되어야 한다는 표시
    • 만약 해당 메타애너테이션이 없으면 테스트 도구는 @Test를 인식할 수 없음
  • @Target(ElementType.METHOD)
    • 해당 메타애너테이션은 @Test가 메서드 선언에서만 사용되어야 함을 알림
    • 따라서 클래스 선언, 필드 선언 등 다른 프로그램 요소에는 달 수 없다.

@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이다.

마커 애너테이션을 처리하는 프로그램 - RunTests

코드 39-3

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() {...}
  • 위와 같은 형태는 처리할 때 주의를 여러 주의를 요하지만 (반복 가능 애너테이션임을 선언하지 않으면 무시) 애너테이션이 유연하게 사용될 수 있음을 말하기에는 손색이 없는 것 같다.

애너테이션으로 할 수 있는 일을 명명패턴으로 처리할 이유는 없다.

  • 이번 아이템의 테스트 프레임워크는 아주 간단하지만 애너테이션이 명명 패턴보다 낫다는 점은 확실히 보여준다.
  • 테스트는 애너테이션으로 할 수 있는 일 중 극히 일부!
  • 여러분이 다른 프로그래머가 소스코드에 추가 정보를 제공할 수 있는 도구를 만드는 일을 한다면 적당한 애너테이션 타입도 함께 정의해 제공하자.
  • 도구 제작자를 제외하고는 일반 프로그래머가 애너테이션 타입을 직접 정의할 일은 거의 없다 (조슈아 블로크 왈)
  • 하지만 자바 프로그래머라면 예외 없이 자바가 제공하는 애너테이션 타입들은 사용할 수 있어야 한다.