본문 바로가기
Dev Books/Effective Java

[item 39] 명명 패턴보다 애너테이션을 사용하라

by Thumper 2025. 6. 26.

명명 패턴

명명 패턴(Naming Pattern)이란, 코드나 시스템 내에서 이름을 정할 때 따르는 일관된 규칙을 말한다.

대상 예시 설명
변수명 userName, isDeleted camelCase 사용
클래스명 UserService, ItemController PascalCase 사용
상수명 MAX_COUNT, DEFAULT_TIMEOUT 전부 대문자 + 스네이크_CASE
함수명 getUserById(), createOrder() 동사 + 목적어 구조
DB 테이블 user_base, order_history 소문자 + snake_case 사용

1. 명명 패턴 단점

1. 오타에 매우 취약함

이름 기반으로 동작하는 경우가 많기 때문에, 오타가 나면 시스템이 제대로 작동하지 않는다.

예: "user_id"와 "userId"를 혼용하면 오류 발생 가능.

특히, 문자열 기반 리플렉션(예: JSON 직렬화, 템플릿 바인딩, Spring Bean 이름 등)에서는 컴파일 타임이 아닌 런타임에 오류가 발생해 디버깅이 어렵다.

2. 올바른 프로그램 요소에서만 사용되라라 보증할 방법이 없다.

명명 패턴은 단지 이름 짓는 규칙일 뿐이고, 문법적으로 강제되거나 보장되지 않는다.
이름이 맞다고 해서, 그게 "진짜 올바른 대상"을 참조한다는 보장은 없다.

Spring에서 Bean 이름 의존

@Autowired
private UserService userService; // 변수명은 규칙에 맞췄지만...

UserService라는 이름의 Bean이 실제 존재하지 않으면 런타임 오류 발생한다.
이름은 맞지만 타입이 다르거나 존재하지 않을 수 있다.

3. 프로그램 요소를 매개변수로 전달할 마땅한 방법이 없다.

이름(문자열)에 의존한 처리 방식의 한계가 있다.
명명 규칙은 문자열 기반으로 동작하기 때문에, 실제 코드 구조나 타입 정보를 활용하는 데 한계가 있다.

문제점 있는 방식: 이름에 예외 포함

예외 타입이 문자열로 표현했을 때, 컴파일과정에서 검증 불가하다.
문자열은 코드 요소(예: 클래스, 타입)가 아니다.
그래서 컴파일러는 그것이 올바른 예외 타입인지, 실제 존재하는지 확인할 수 없다.
즉, 런타임에야 오류가 드러난다.

// 예외 이름을 문자열처럼 메서드 이름에 표현
void shouldThrowIllegalArgumentExceptionWhenInvalidInput() {
    // 코드상 실제로는 다른 예외 던짐
    throw new NullPointerException(); 
}

메서드 이름은 IllegalArgumentException을 암시하지만, 실제로는 NullPointerException을 던지고 있다.
컴파일러는 전혀 모른다. (신뢰할 수 없다)

2. 마커(marker) 애너테이션

애너테이션은 예외 테스트의 불안정성과 가독성 문제를 해결해주는 "구문적 해법"이다.
Unit은 버전 4부터 애너테이션 기반으로 전환하며 테스트 선언을 보다 명시적이고 견고하게 만들었다.

마커 애너테이션

마커 애너테이션(Marker Annotation)**이란, 속성(요소)이 하나도 없는 애너테이션을 말한다.
즉, 존재 자체가 의미를 가지며, "표시(mark)"만을 위한 용도로 사용된다.

마커 애너테이션 타입 선언

import java.lang.annotation.*;

// 테스트 메서드임을 선언하는 애너테이션,
// 매개변수 없는 정적 메서드 전용.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Test {
}

@Retention(RetentionPolicy.RUNTIME)

@Test은 언제까지 유지될지를 정의하는데, RUNTIME을 지정하면 실행 중(리플렉션)에도 접근 가능하다.
테스트 프레임워크가 @Test를 찾아 실행할 수 있게 한다. (없으면 인식을 못해 실행못함)

@Target(ElementType.METHOD)

이 애너테이션을 어디에 적용할 수 있는지 정의한다.
METHOD를 지정했으므로, 메서드에만 사용 가능하다.

1. 마커 애너테이션을 사용한 프로그램 예

package effectivejava.chapter6.item39.markerannotation;

public class Sample {
    @Test
    public static void m1() { }        // ✅ 테스트 통과 (정상)

    public static void m2() { }        // ⛔ 테스트 아님, (→ @Test가 없음)

    @Test
    public static void m3() {          // ❌ 테스트 실패 (예외 발생)
        throw new RuntimeException("Boom");
    }

    public static void m4() { }        // ⛔ 테스트 아님, (→ @Test가 없음)

    @Test
    public void m5() { }               // ❌ 잘못된 사용 (정적 메서드 static 아님)

    public static void m6() { }        // ⛔ 테스트 아님, (→ @Test가 없음)

    @Test
    public static void m7() {          // ❌ 테스트 실패 (예외 발생)
        throw new RuntimeException("Crash");
    }

    public static void m8() { }        // ⛔ 테스트 아님, (→ @Test가 없음)
}

어떤 메서드가 테스트인지 구분하는 기준은?

  • @Test 애너테이션이 붙어 있어야 한다.
  • public static void 시그니처여야 한다.
  • public void m5()처럼 static이 빠지면 잘못된 사용이다.

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

런타임에 @Test 마커 애너테이션이 붙은 메서드를 찾아 실행하는 간단한 테스트 러너(Test Runner) 구현이다.

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()) {
            // 메서드에 @Test 애너테이션이 붙었는지 확인
            if (m.isAnnotationPresent(Test.class)) {
                tests++;
                try {
                    // static 메서드이므로 null을 인스턴스로 전달해서 호출
                    m.invoke(null);
                    passed++;
                } catch (InvocationTargetException wrappedExc) {
                    // 메서드 내부에서 예외 발생 시 예외 원본을 꺼냄
                    Throwable exc = wrappedExc.getCause();
                    System.out.println(m + " failed: " + exc);
                } catch (Exception exc) {
                    // invoke 호출 자체가 실패한 경우 (ex. 인스턴스 문제, 접근제어 등)
                    System.out.println("Invalid @Test: " + m);
                }
            }
        }

        System.out.printf("Passed: %d, Failed: %d%n", passed, tests - passed);
    }
}
public static void Sample.m3() failed: RuntimeException: Boom
Invalid @Test: public void Sample.m5()
public static void sample.m7() failed: RuntimeException: Crash
성공: 1, 실패: 3

주요 포인트는 다음과 같다.

  • args[0]으로 테스트 대상 클래스 이름을 받아 Class.forName()으로 로드
  • 리플렉션으로 모든 메서드 탐색
  • @Test 애너테이션이 붙은 메서드만 실행
  • m.invoke(null)로 static 메서드 호출 (static이 아니면 예외 발생 가능)
  • 메서드 내부 예외는 InvocationTargetException으로 감싸져 전달, 실제 예외는 getCause()로 추출

3. 매개변수를 가진 애너테이션

특정 예외를 던저야만 성공하는 테스트를 지원하기 위한 목적으로, 예외 테스트를 깔끔하게 표현하는 방법 중 하나이다.

import java.lang.annotation.*;
/**
 * 명시한 예외를 던저야만 성공하는 테스트 메서드용 애너테이션
 */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
    Class<? extends Throwable> value();
}

이 애너테이션의 매개변수 타입은 Class<? extends Throwable> value()이다.
"Throwable을 확장한 클래스의 Class 객체"라는 뜻이며, 따라서 모든 예외 타입을 다 수용한다.

매개변수 애너테이션을 사용한 경우

import effectivejava.chapter6.item39.annotationwithparameter.ExceptionTest;
import java.util.*;

public class Sample2 {
    @ExceptionTest(ArithmeticException.class)
    public static void m1() {  // Test should pass
        int i = 0;
        i = i / i;
    }
    @ExceptionTest(ArithmeticException.class)
    public static void m2() {  // Should fail (wrong exception)
        int[] a = new int[0];
        int i = a[1];
    }
    @ExceptionTest(ArithmeticException.class)
    public static void m3() { }  // Should fail (no exception)
}
package effectivejava.chapter6.item39.annotationwithparameter;

import effectivejava.chapter6.item39.markerannotation.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 + " failed: " + exc);
                } catch (Exception exc) {
                    System.out.println("Invalid @Test: " + m);
                }
            }

            if (m.isAnnotationPresent(ExceptionTest.class)) {
                tests++;
                try {
                    m.invoke(null);
                    System.out.printf("Test %s failed: no exception%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(
                                "Test %s failed: expected %s, got %s%n",
                                m, excType.getName(), exc);
                    }
                } catch (Exception exc) {
                    System.out.println("Invalid @ExceptionTest: " + m);
                }
            }
        }

        System.out.printf("Passed: %d, Failed: %d%n",
                passed, tests - passed);
    }
}

차이점으로, 이 코드는 애너테이션 매개변수의 값을 추출하여 테스트 메서드가 올바른 예외를 던지는지 확인하는 데 사용한다.
형변환 코드가 없으니 ClassCastException 걱정은 없다.
테스트 프로그램이 문제없이 컴파일되면, 애너테이션 매개변수가 가리키는 예외가 올바른 타입이라는 뜻이다.

컴파일 타임에는 존재했지만, 런타임에 없는 예외 클래스

@ExceptionTest(SomeCustomException.class)

SomeCustomException.class가 컴파일 타임에는 존재했지만, 런타임 시 클래스 경로에 없으면 어떻게 될까요?
이럴 경우 JVM은 TypeNotPresentException을 던진다.

애너테이션의 value() 값은 클래스 리터럴 (Class<?>)이기 때문에
리플렉션으로 해당 애너테이션을 읽을 때 JVM은 그 클래스를 로드하려고 한다.
하지만 없으면? → TypeNotPresentException

4. 배열 매개변수를 받는 애너테이션 타입

애너테이션 내부에 배열 타입의 요소를 정의하여, 여러 개의 값을 한 번에 전달할 수 있도록 하는 형태이다.

import java.lang.annotation.*;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
    Class<? extends Exception>[] value();
}

배열 매개변수를 받는 애너테이션을 사용하는 코드

package effectivejava.chapter6.item39.annotationwitharrayparameter;
import java.util.*;

public class Sample3 {

    @ExceptionTest(ArithmeticException.class)
    public static void m1() {  // Test should pass
        int i = 0;
        i = i / i;            // 0 / 0 → ArithmeticException 발생 → ✅ 통과
    }
    @ExceptionTest(ArithmeticException.class)
    public static void m2() {  // Should fail (wrong exception)
        int[] a = new int[0];
        int i = a[1];       // IndexOutOfBoundsException 발생 → ❌ 실패 (기대한 예외 아님)
    }
    @ExceptionTest(ArithmeticException.class)
    public static void m3() { }  // 예외 안 던짐 → ❌ 실패 (기대한 예외 없음)


    @ExceptionTest({ IndexOutOfBoundsException.class,
                     NullPointerException.class })
    public static void doublyBad() {   // Should pass
        List<String> list = new ArrayList<>();

       // IndexOutOfBoundsException OR NullPointerException 중 하나 발생 → ✅ 통과
        list.addAll(5, null);
    }
}

애너테이션 배열 매개변수 사용 덕분에 생긴 장점

이전 방식 개선 방식
@ExceptionTest(SomeException.class)예외 1개만 허용 @ExceptionTest({A.class, B.class})예외 여러 개 허용
예외 2개 테스트하려면 2개 메서드 필요 하나의 메서드에 선언만으로 해결
유연성 부족 명확하고 선언적이며 유연함

이 예제는 배열 매개변수를 가진 애너테이션이 어떻게 현실적인 테스트 상황을 깔끔하게 표현하는지 보여준다.
애너테이션이 단순 마커용이 아니라, 풍부한 메타데이터 전달 수단이라는 것을 잘 보여주는 사례이다.

import effectivejava.chapter6.item39.markerannotation.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 + " failed: " + exc);
                } catch (Exception exc) {
                    System.out.println("Invalid @Test: " + m);
                }
            }


            // (수정된 버전) 배열 매개변수를 받는 애너테이션으로 적용한 테스트 러너 
            if (m.isAnnotationPresent(ExceptionTest.class)) {
                tests++;
                try {
                    m.invoke(null);
                    System.out.printf("Test %s failed: no exception%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("Test %s failed: %s %n", m, exc);
                }
            }
        }
        System.out.printf("Passed: %d, Failed: %d%n",
                passed, tests - passed);
    }
}

5. 반복 가능한 애터네이션

자바 8에서는 여러 개의 값을 받는 애너테이션을 다른 방식으로도 만들 수 있다.
배열 매개변수를 사용하는 대신, 애너테이션에 @Repeatable 메타애너테이션을 다는 방식이다.
@Repeatable을 단 애너테이션은 하나의 프로그램 요소에 여러 번 달 수 있다.

@ExceptionTest(ArithmeticException.class)
@ExceptionTest(NullPointerException.class)  // ✅ 중복 사용 가능
public static void myMethod() { ... }

주의할 점

  • @Repeatable을 단 애너테이션을 반환하는 컨테이너 애너테이션을 하나 더 정의한다.
  • @Repeatable에 이 컨테이너 애너테이션의 class 객체를 매개변수로 전달해야 한다.
  • 컨테이너 애너테이션은 내부 애너테이션 타입의 배열을 반환하는 value 메서드를 정의해야 한다.
  • 컨테이너 애너테이션 타입에는 적절한 보존 정책(@Retation)과 적용 대상(@Target)을 명시해야 한다. 그렇지 않으면 컴파일되지 않을 것이다.

반복 가능한 애너테이션 타입

import java.lang.annotation.*;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Repeatable(ExceptionTestContainer.class)
public @interface ExceptionTest {
    Class<? extends Throwable> value();
}

컨테이너 애너테이션

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTestContainer {
    ExceptionTest[] value();
}

적용: 반복 가능한 애너테이션을 두번 단 코드

import java.util.ArrayList;
import java.util.List;

public class Sample4 {
    @ExceptionTest(ArithmeticException.class)
    public static void m1() {  // Test should pass
        int i = 0;
        i = i / i;
    }

    @ExceptionTest(ArithmeticException.class)
    public static void m2() {  // Should fail (wrong exception)
        int[] a = new int[0];
        int i = a[1];
    }

    @ExceptionTest(ArithmeticException.class)
    public static void m3() { }  // Should fail (no exception)


    // doublyBad()는 반복 가능한 애너테이션 사용 예시
    @ExceptionTest(IndexOutOfBoundsException.class)
    @ExceptionTest(NullPointerException.class)
    public static void doublyBad() {
        List<String> list = new ArrayList<>();

        list.addAll(5, null);
    }
}

이 메서드는 IndexOutOfBoundsException이나 NullPointerException 중 하나라도 던지면 성공한다.
현재는 list.addAll(5, null) 때문에 보통 IndexOutOfBoundsException이 발생한다. (그러면 테스트 통과)

@Repeatable 처리 시 주의점

반복 가능 애너테이션은 처리할 때도 주의를 요한다.

1. 여러 개 달면 하나만 달았을 때와 구분하기 위해 해당 컨테이너 애너테이션 타입이 적용된다.

반복 가능 애너테이션을 여러 개 달면, 자바 컴파일러는 내부적으로 컨테이너 애너테이션도 함께 붙이는 것처럼 만다.

2. isAnnotationPresent로 반복 가능 애너테이션이 달렸는지 검사한다면 "그렇지 않다"라고 알려준다.

m.isAnnotationPresent(ExceptionTest.class)를 호출하면 여러 개가 붙어있어도 "없다"고 잘못 알려줄 수 있다.
왜냐하면, 반복 가능 애너테이션은 컨테이너 애너테이션이 실제로 달려 있기 때문이다.

3. 달려 있는 수와 상관없이 모두 검사하려면 둘을 따로따로 확인해야 한다.

ExceptionTest[] tests = m.getAnnotationsByType(ExceptionTest.class);

이 메서드는 @Repeatable이 붙은 애너테이션을 자동으로 모두 찾아서 배열로 반환한다.
따라서 단일 애너테이션이든, 여러 개든 상관없이 일관되게 처리할 수 있다.

반복 가능 애너테이션 다루기

import effectivejava.chapter6.item39.markerannotation.Test;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;


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 + " failed: " + exc);
                } catch (Exception exc) {
                    System.out.println("INVALID @Test: " + m);
                }
            }

            // Processing repeatable annotations 
            if (m.isAnnotationPresent(ExceptionTest.class)
                    || m.isAnnotationPresent(ExceptionTestContainer.class)) {
                tests++;
                try {
                    m.invoke(null);
                    System.out.printf("Test %s failed: no exception%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("Test %s failed: %s %n", m, exc);
                }
            }
        }
        System.out.printf("Passed: %d, Failed: %d%n",
                          passed, tests - passed);
    }
}

반복 가능 애너테이션을 사용해 하나의 프로그램 요소에 같은 애너테이션을 여러 번 달 때의 코드 가독성을 높여보았다.
하지만 애너테이션을 선언하고 이를 처리하는 부분에서는 코드 양이 늘어나며, 특히 처리 코드가 복잡해져 오류가 날 가능성이 커짐을 명심하자.

결론

애너테이션이 명명패턴보다 낫다는 점은 확실히 보여준다.

  • 다른 프로그래머가 소스코드에 추가 정보를 의해 제공하자.
  • 애너테이션으로 할 수 있는 일을 명명 패턴으로 처리할 이유는 없다.
  • 자바 프로그래머라면 예외 없이 자바가 제공하는 애너테이션 타입들은 사용해야 한다.

댓글