가장 먼저 바로잡아야 할 오해 — @Transactional 을 붙였다고 그 애너테이션이 트랜잭션을 여는 게 아니다. 애너테이션은 표식(marker) 일 뿐이고, 그것을 읽고 행동하는 쪽이 따로 있다.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Audited {
String value() default "";
}
이렇게 만들어 붙여도 아무 일도 일어나지 않는다. 컴파일도 되고 실행도 되지만 그냥 붙어 있을 뿐이다. 누군가 읽어야 한다.
Audited a = method.getAnnotation(Audited.class);
if (a != null) log.info("감사 대상: {}", a.value());
읽는 방법이 리플렉션이다. 애너테이션은 리플렉션과 한 쌍이고, 프레임워크가 우리 코드를 다룰 수 있는 것도 이 조합 덕분이다 — 우리가 표식을 남기고, 프레임워크가 찾아 행동한다.
@Retention — 언제까지 살아남나
| 값 | 어디까지 남나 | 쓰임 |
|---|---|---|
SOURCE | 컴파일하면 사라진다 | @Override — 컴파일러만 보면 된다 |
CLASS | .class 에는 있으나 실행 중엔 안 읽힌다 | 바이트코드 도구용 (기본값) |
RUNTIME | 실행 중에도 리플렉션으로 읽힌다 | 스프링 애너테이션 전부 |
프레임워크가 읽어야 하면 반드시 RUNTIME 이다. 직접 만든 애너테이션이 동작하지 않는 흔한 원인이 이것을 빠뜨린 것이다. 기본값이 CLASS 라서 에러도 경고도 없이 조용히 안 읽힌다.
왜 세 단계로 나눠 뒀나. 실행 중에 읽을 필요가 없는 표식까지 .class 에 담아 두면 파일이 커지고 메모리에 남는다. 읽을 시점이 정해져 있으면 그 이후로는 버리는 것이 이득이라는 판단이다.
@Target — 어디에 붙일 수 있나
METHOD·FIELD·TYPE(클래스·인터페이스)·PARAMETER·ANNOTATION_TYPE 등을 지정한다. 안 맞는 자리에 붙이면 컴파일 에러로 막힌다. 잘못 쓸 여지를 실행 전에 줄이는 장치다.
메타 애너테이션 — 애너테이션에 붙는 애너테이션
@Retention·@Target 자체가 애너테이션에 붙는 애너테이션이다. 이 성질로 여러 개를 하나로 묶을 수 있다.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Service
@Transactional
public @interface DomainService { } // 두 개를 한 번에
스프링 자신이 이 수법을 쓴다.
@RestController=@Controller+@ResponseBody@SpringBootApplication=@SpringBootConfiguration+@EnableAutoConfiguration+@ComponentScan@Service·@Repository·@Controller= 전부@Component를 안에 갖고 있다
애너테이션 하나가 실은 여러 개인 경우가 많다. 동작이 이해되지 않을 때 그 애너테이션 소스를 열어 보면 무엇이 묶여 있는지 바로 보인다.
그래서 함정도 정해져 있다
@Retention을 안 붙였다 → 조용히 안 읽힌다- 애너테이션만 붙이고 읽는 쪽을 안 켰다 →
@Async는@EnableAsync가,@Scheduled는@EnableScheduling이 있어야 읽힌다 - 읽는 쪽이 프록시라면 프록시의 한계를 그대로 받는다 → 자기 호출·
private메서드에는 걸리지 않는다
면접 함정
- ❌ "애너테이션이 기능을 수행한다" → 표식일 뿐이고 리플렉션으로 읽는 쪽이 수행한다.
- ❌ "애너테이션을 많이 붙이면 느려진다" → 대개 시작 시점에 한 번 읽는다. 느려지는 것은 그것이 만들어 낸 프록시·부가 로직 쪽이다.
- ❌ "
@Override는 안 붙여도 같다" → 이름을 틀리면 재정의가 아니라 새 메서드가 되고 조용히 안 먹는다.