애너테이션 지도 — 계층별로 무엇이 무엇을 하나
스프링을 처음 만지면 애너테이션이 수십 개 쏟아진다. 하나씩 외우면 끝이 없고, 외워도
@Service와@Component가 왜 둘 다 있는지는 여전히 모른다.이 편은 개별 뜻보다 어느 계층에 속하고 무엇과 짝을 이루나를 지도로 잡는다. 그리고 그 전에, 애너테이션이 애초에 무엇을 하는 물건인지부터 본다.
1. 애너테이션은 아무것도 하지 않는다
가장 먼저 바로잡아야 할 오해가 이것이다. @Transactional 을 붙였다고 그 애너테이션이 트랜잭션을 여는 게 아니다. 애너테이션은 표식(marker)일 뿐이고, 그것을 읽고 행동하는 쪽이 따로 있다.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Audited {
String value() default "";
}
이렇게 만들어 메서드에 붙여도 아무 일도 일어나지 않는다. 컴파일도 되고 실행도 되지만 그냥 붙어 있을 뿐이다. 누군가 읽어야 한다.
Method m = target.getClass().getMethod("save", Order.class);
Audited a = m.getAnnotation(Audited.class);
if (a != null) log.info("감사 대상: {}", a.value());
읽는 방법이 리플렉션이다. 애너테이션은 리플렉션과 한 쌍이고, 스프링이 우리 코드를 다룰 수 있는 것도 이 조합 덕분이다. 우리가 표식을 남기고, 프레임워크가 그것을 찾아 행동한다.
@Retention — 언제까지 살아남나
| 값 | 어디까지 남나 | 쓰임 |
|---|---|---|
SOURCE | 컴파일하면 사라진다 | @Override — 컴파일러만 보면 된다 |
CLASS | .class 파일에는 있지만 실행 중엔 안 읽힌다 | 바이트코드 도구용 (기본값) |
RUNTIME | 실행 중에도 리플렉션으로 읽힌다 | 스프링 애너테이션 전부 |
스프링이 읽어야 하는 애너테이션은 반드시 RUNTIME 이다. 직접 만든 애너테이션이 동작하지 않는 흔한 원인이 이것을 안 붙인 것이다. 기본값이 CLASS 라 조용히 안 읽힌다.
@Target — 어디에 붙일 수 있나
METHOD·FIELD·TYPE(클래스·인터페이스)·PARAMETER·ANNOTATION_TYPE 등을 지정한다. 안 맞는 자리에 붙이면 컴파일 에러로 막힌다. 잘못 쓸 여지를 컴파일 시점에 줄이는 장치다.
메타 애너테이션 — 애너테이션에 붙는 애너테이션
@Retention 과 @Target 자체가 애너테이션에 붙는 애너테이션이다. 이 성질을 활용해 여러 애너테이션을 하나로 묶을 수 있다.
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Service
@Transactional
public @interface DomainService { } // 두 개를 한 번에
스프링 자신이 이 수법을 쓴다. @RestController 는 @Controller + @ResponseBody 를 묶은 것이고, @SpringBootApplication 은 @SpringBootConfiguration + @EnableAutoConfiguration + @ComponentScan 을 묶은 것이다. 애너테이션 하나가 실은 여러 개인 경우가 많다 — 소스를 열어 보면 무엇이 묶여 있는지 바로 보인다.
2. 계층별 지도
| 계층 | 애너테이션 | 무엇을 하나 |
|---|---|---|
| 빈 등록 | @Component @Service @Repository @Controller @RestController | 컴포넌트 스캔이 찾아 빈으로 만든다 |
| 빈 등록(수동) | @Configuration @Bean | 내가 직접 만들어 등록한다 |
| 주입 | @Autowired @Qualifier @Primary @Value | 무엇을 꽂을지 고른다 |
| 생명주기 | @PostConstruct @PreDestroy | 초기화·정리 시점을 잡는다 |
| 웹 | @GetMapping @PathVariable @RequestParam @RequestBody | HTTP 요청을 메서드에 매핑한다 |
| 검증·예외 | @Valid @RestControllerAdvice @ExceptionHandler | 입력을 검사하고 예외를 응답으로 바꾼다 |
| JPA | @Entity @Id @GeneratedValue @Column @ManyToOne @Version | 객체와 테이블을 잇는다 |
| 부가 기능 | @Transactional @Cacheable @Async @Scheduled | 프록시가 앞뒤에 코드를 끼운다 |
| 조건부 | @ConditionalOnClass @ConditionalOnMissingBean @Profile | 상황에 따라 빈을 넣거나 뺀다 |
| 테스트 | @SpringBootTest @WebMvcTest @DataJpaTest @MockitoBean | 무엇을 띄우고 무엇을 가짜로 둘지 정한다 |
아래에서 계층별로 하나씩 본다.
3. 스테레오타입 — @Component 계열은 거의 같다
@Component public class MailSender { }
@Service public class OrderService { }
@Repository public class OrderJdbcDao { }
@Controller public class OrderPageController { }
먼저 알아야 할 것 — 네 개의 기능은 거의 같다. 전부 @Component 를 메타 애너테이션으로 갖고 있고, 컴포넌트 스캔이 찾아 빈으로 등록한다. @Service 를 @Component 로 바꿔도 대체로 동작한다.
그럼 왜 나눠 뒀나. 읽는 사람에게 역할을 알려 주기 위해서다. 클래스 목록만 봐도 무엇이 도메인 로직이고 무엇이 데이터 접근인지 구분된다. 여기에 도구가 얹힌다 — 계층별로 AOP 를 걸거나, 아키텍처 검증 도구가 "컨트롤러가 리포지토리를 직접 부르면 안 된다" 같은 규칙을 검사할 수 있다.
기능 차이가 있는 것은 둘이다.
@Repository— 예외 변환이 붙는다. JDBC 드라이버마다 다른SQLException을 스프링의DataAccessException계층으로 번역해 준다. DB 를 바꿔도 예외 처리 코드가 안 바뀌는 이유다@Controller— 웹 요청 매핑의 대상이 된다. 이게 없으면@GetMapping을 찾지 않는다
함정 — 컴포넌트 스캔은 시작 클래스가 있는 패키지와 그 하위만 훑는다. 그 밖에 둔 클래스는
@Service를 붙여도 빈이 되지 않고, 나중에 주입받는 자리에서 "빈이 없다" 로 터진다. 애너테이션을 의심하기 전에 패키지 위치를 먼저 본다.
4. @Bean 과 @Component — 언제 무엇을
둘 다 빈을 만들지만 갈리는 기준이 명확하다.
@Configuration
public class AppConfig {
@Bean
public RestClient restClient() { // 내가 못 고치는 클래스
return RestClient.builder().baseUrl("https://api.example.com").build();
}
}
@Component— 내가 작성한 클래스. 클래스에 직접 붙인다@Bean— 남이 작성한 클래스, 또는 만드는 과정에 로직이 필요한 경우. 메서드에 붙여 반환값을 등록한다
라이브러리 클래스에는 애너테이션을 붙일 수 없으니 @Bean 밖에 방법이 없다. 반대로 내 클래스를 일일이 @Bean 으로 쓰는 것은 손이 많이 간다.
@Configuration 도 프록시다
@Configuration
public class AppConfig {
@Bean ObjectMapper mapper() { return new ObjectMapper(); }
@Bean A a() { return new A(mapper()); } // mapper() 를 두 번 부르는데
@Bean B b() { return new B(mapper()); } // 같은 객체가 들어간다
}
mapper() 를 두 번 불렀는데 같은 인스턴스가 주입된다. 스프링이 @Configuration 클래스를 상속해 프록시로 감싸고, @Bean 메서드 호출을 가로채 "이미 만든 게 있으면 그것을 준다" 로 바꿔치기하기 때문이다.
@Configuration(proxyBeanMethods = false) 로 이 프록시를 끌 수 있다. 시작이 조금 빨라지지만, 그때부터 mapper() 는 평범한 메서드 호출이라 매번 새 객체를 만든다. 메서드끼리 부르지 않는 설정 클래스에만 쓴다.
5. 주입 — @Autowired 는 대개 안 쓴다
@Service
public class OrderService {
private final OrderRepository repo;
private final PaymentClient payment;
public OrderService(OrderRepository repo, PaymentClient payment) { // @Autowired 없다
this.repo = repo;
this.payment = payment;
}
}
생성자가 하나면 @Autowired 를 생략할 수 있다. 스프링이 그 생성자를 쓸 수밖에 없으므로 표식이 필요 없다.
생성자 주입을 쓰는 이유는 셋이다.
final로 둘 수 있다 — 만든 뒤 바뀌지 않는다- 의존성 없이는 객체가 만들어지지 않는다 — 빠뜨렸으면 시작할 때 터진다. 필드 주입은 객체가 만들어진 다음에 꽂으므로 실행 중에
NullPointerException으로 나타난다 - 테스트에서
new로 만들 수 있다 — 스프링 없이 가짜를 넣어 줄 수 있다
숨은 이득이 하나 더 있다. 생성자 파라미터가 여섯 개, 일곱 개로 늘어나면 불편해서 눈에 띈다. 필드 주입은 줄만 추가하면 되니 열 개가 넘어도 아무렇지 않다. 생성자 주입은 "이 클래스가 너무 많은 일을 한다" 는 신호를 대신 내 준다.
같은 타입이 여럿이면
@Bean @Primary PayClient kakao() { ... } // 기본으로 쓸 것
@Bean("naver") PayClient naver() { ... }
public Order(@Qualifier("naver") PayClient client) { ... } // 이름으로 지목
@Primary 는 "특별한 지시가 없으면 나" 이고, @Qualifier 는 "그중에 이것" 이다. 둘 다 없으면 스프링이 후보를 좁히지 못해 시작 시점에 실패한다 — 실행 중이 아니라 시작할 때 알려 준다는 점이 중요하다.
6. 웹 계층 — 요청의 어느 부분을 받나
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@GetMapping("/{id}")
public OrderResponse find(@PathVariable Long id) { ... }
@GetMapping
public Page<OrderResponse> list(@RequestParam(defaultValue = "0") int page) { ... }
@PostMapping
public ResponseEntity<Void> create(@Valid @RequestBody OrderRequest req) { ... }
}
세 개를 정확히 구분하는 것이 요점이다.
| 어디서 값을 꺼내나 | 예 | |
|---|---|---|
@PathVariable | URL 경로의 일부 | /orders/42 → 42 |
@RequestParam | 쿼리 문자열 또는 폼 | ?page=2 → 2 |
@RequestBody | 요청 본문(JSON) 전체 | {"item":"book"} → 객체 |
@RequestBody 는 본문을 객체로 바꾸는데, 이 변환도 **리플렉션**으로 필드를 채운다. 그래서 JSON 키와 필드 이름이 안 맞으면 조용히 null 이 된다.
@RestController 는 @Controller + @ResponseBody 다. @ResponseBody 가 "반환값을 뷰 이름이 아니라 응답 본문으로 써라" 라는 뜻이므로, REST API 를 만들 때 매 메서드에 붙일 필요가 없어진다.
검증과 예외는 짝이다
@Valid 는 검증만 하고 실패하면 예외를 던진다. 그 예외를 응답으로 바꾸는 것은 @RestControllerAdvice 의 일이다.
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
ResponseEntity<ErrorResponse> onInvalid(MethodArgumentNotValidException e) { ... }
}
둘 중 하나만 있으면 반쪽이다. @Valid 만 있으면 사용자가 500 을 받고, 핸들러만 있으면 검사할 것이 없다.
7. JPA — 객체와 테이블을 잇는 표식
@Entity
@Table(name = "orders")
public class Order {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 50)
private String orderNo;
@ManyToOne(fetch = FetchType.LAZY) // 기본이 즉시 로딩이라 반드시 지정한다
@JoinColumn(name = "member_id")
private Member member;
@Version
private Long version; // 낙관적 락
protected Order() { } // 기본 생성자가 필요하다
}
protected Order() { } 가 왜 필요한지가 핵심이다. JPA 는 DB 에서 읽은 행으로 객체를 만들 때 어떤 값을 넣어야 할지 모르는 상태에서 먼저 빈 객체를 만들고, 그다음 리플렉션으로 필드를 채운다. 그러려면 인자 없는 생성자가 있어야 한다. private 이 아니라 protected 인 것은 프록시가 상속해야 하기 때문이다.
@ManyToOne 의 기본값이 EAGER 라는 점은 반드시 기억한다. 지정하지 않으면 주문 하나를 읽을 때마다 회원까지 조회하고, 목록을 읽으면 그만큼 쿼리가 늘어난다.
8. 부가 기능 — 하나를 알면 전부 같다
@Transactional // 앞에서 트랜잭션 열고, 뒤에서 커밋·롤백
@Cacheable("product") // 앞에서 캐시 보고, 있으면 메서드를 아예 건너뛴다
@Async // 다른 스레드에 던진다
@Scheduled(cron = "0 0 3 * * *") // 시간이 되면 대신 불러 준다
이 계열은 전부 같은 방식으로 동작한다. 스프링이 그 빈을 프록시로 감싸고, 메서드 호출을 가로채 앞뒤에 코드를 끼운다. 애너테이션마다 끼우는 내용만 다르다.
그래서 함정도 전부 같다.
@Service
public class OrderService {
public void placeAll(List<Order> orders) {
for (Order o : orders) save(o); // this.save() — 프록시를 안 거친다
}
@Transactional
public void save(Order o) { ... } // 트랜잭션이 걸리지 않는다
}
호출자가 프록시를 거쳐 placeAll() 에 들어온 순간 this 는 이미 진짜 객체다. 그 안에서 자기 메서드를 부르면 프록시를 다시 탈 이유가 없다. 이것은 버그가 아니라 프록시 방식의 원리적 한계이고, @Transactional 에서 겪은 그대로가 @Cacheable·@Async 에서도 일어난다.
같은 이유로 private 메서드에는 걸리지 않는다. 프록시가 상속해서 재정의할 수 없기 때문이다.
순서 함정 —
@Transactional과@Async를 같은 메서드에 붙이면 순서가 문제가 된다. 프록시가 겹칠 때@Async가 먼저 걸리면 트랜잭션이 새 스레드에서 시작된다. 트랜잭션은 스레드에 묶여 있으므로 원래 호출자의 트랜잭션과는 무관한 별개가 된다. 두 기능이 필요하면 메서드를 나눈다.
9. 조건부 — 자동 설정의 뼈대
@Configuration
@ConditionalOnClass(DataSource.class) // 클래스패스에 있으면
@ConditionalOnMissingBean(DataSource.class) // 내가 직접 만든 게 없으면
public class DataSourceAutoConfiguration { ... }
스프링 부트가 "알아서 설정해 주는데 내가 만들면 내 것을 쓴다" 로 동작하는 근거가 이 두 줄이다. 마법이 아니라 조건 애너테이션의 조합이다.
| 애너테이션 | 조건 |
|---|---|
@ConditionalOnClass | 그 클래스가 클래스패스에 있으면 |
@ConditionalOnMissingBean | 그 타입의 빈이 아직 없으면 |
@ConditionalOnProperty | 설정값이 특정 값이면 |
@Profile | 활성 프로파일이 맞으면 |
@ConditionalOnMissingBean 은 평가 순서에 의존한다. 사용자 설정이 자동 설정보다 먼저 평가되므로 내 빈이 이긴다. 반대로 자동 설정끼리 서로 이 조건을 쓰면 순서에 따라 결과가 달라져 진단이 까다로워진다.
10. 테스트 — 무엇을 띄우나가 곧 속도다
| 애너테이션 | 띄우는 범위 | 언제 |
|---|---|---|
@SpringBootTest | 애플리케이션 전체 | 통합 검증. 느리다 |
@WebMvcTest | 웹 계층만 | 컨트롤러 매핑·검증·예외 처리 |
@DataJpaTest | JPA 관련만 (+ 테스트 후 롤백) | 쿼리·매핑 |
| 없음 | 아무것도 안 띄운다 | 순수 단위 테스트 — 가장 빠르다 |
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired MockMvc mvc;
@MockitoBean OrderService service; // 서비스는 가짜로
}
@MockitoBean 은 그 타입의 빈을 가짜로 바꿔 컨텍스트에 넣는다. 컨트롤러만 검증하고 싶을 때 서비스 구현을 통째로 대신한다. (예전 이름은 @MockBean 이었다.)
속도 함정 —
@SpringBootTest를 아무 데나 붙이면 테스트가 느려진다. 다만 스프링은 설정이 같은 컨텍스트를 재사용하므로, 클래스마다@MockitoBean구성을 조금씩 다르게 하면 그때마다 컨텍스트를 새로 띄운다. 느린 테스트의 원인이 대개 테스트 개수가 아니라 컨텍스트 종류의 개수다.
11. 자바 표준 애너테이션 — 대부분 컴파일러용
| 애너테이션 | 무엇을 하나 |
|---|---|
@Override | 상위 것을 재정의한다고 선언. 이름을 틀리면 컴파일 에러 |
@FunctionalInterface | 추상 메서드가 하나뿐임을 강제 |
@Deprecated | 쓰지 말라는 표시. 쓰면 경고 |
@SuppressWarnings | 특정 경고를 끈다 |
@SafeVarargs | 제네릭 가변 인자 경고를 "안전하다" 고 보증 |
@Override 의 값어치를 과소평가하기 쉽다. 붙이지 않고 이름을 틀리면 재정의가 아니라 새 메서드가 하나 생기고, 컴파일도 실행도 되지만 원래 것이 그대로 불린다. 조용히 안 먹는 버그의 전형이다. 붙여 두면 컴파일러가 잡아 준다.
이들은 대부분 SOURCE 나 CLASS 라 실행 중에는 없다. 컴파일러에게 하는 말이지 프레임워크에게 하는 말이 아니다.
12. Lombok — 컴파일 시점에 코드를 만든다
@Getter @RequiredArgsConstructor
public class OrderService {
private final OrderRepository repo; // 생성자를 만들어 준다
}
Lombok 은 리플렉션이 아니라 컴파일 시점에 소스를 생성한다. 그래서 실행 성능에는 영향이 없고, 대신 IDE 플러그인이 없으면 코드가 없는 것처럼 보인다.
@Data를 엔티티에 붙이지 않는다.@Data는 모든 필드로equals·hashCode·toString을 만든다. 엔티티에 붙으면 셋 다 문제가 된다 —toString이 연관 엔티티를 건드려 지연 로딩을 일으키거나 순환 참조로 스택이 넘치고,hashCode가id를 쓰는데 저장 전에는null이라 컬렉션에 넣은 뒤 값이 바뀐다. 엔티티에는@Getter정도만 쓰고 나머지는 직접 쓴다.
@Builder 도 주의할 곳이 있다. 필드를 추가했을 때 빌더에서 안 넣어도 컴파일이 되므로 누락이 조용히 통과한다. 필수 값은 생성자로 강제하는 편이 안전하다.
13. 자주 겪는 것
| 증상 | 원인 |
|---|---|
| 직접 만든 애너테이션이 안 읽힌다 | @Retention(RUNTIME) 이 없다 |
@Service 를 붙였는데 빈이 없다 | 컴포넌트 스캔 범위 밖 패키지다 |
@Transactional 이 안 걸린다 | 자기 호출이거나 private 메서드다 |
@Cacheable 이 안 걸린다 | 위와 같은 이유. 원리가 같다 |
@Value 가 null 이다 | 필드 주입은 생성자 실행 후에 채워진다 → 생성자 안에서는 아직 없다 |
| 같은 타입 빈이 둘이라 시작 실패 | @Primary 나 @Qualifier 로 좁힌다 |
@Async 가 동기로 돈다 | @EnableAsync 가 없거나 자기 호출이다 |
| 테스트가 갑자기 느려졌다 | 컨텍스트 설정이 갈라져 여러 벌 뜬다 |
엔티티에서 StackOverflowError | @Data 의 toString 이 순환 참조를 돈다 |
한눈에 정리
- 애너테이션은 표식일 뿐이다 — 읽는 쪽(리플렉션)이 있어야 동작한다.
@Retention(RUNTIME)이 그 전제 - 메타 애너테이션 —
@RestController·@SpringBootApplication처럼 여러 개를 묶은 것이 많다. 소스를 열어 보는 습관 - 스테레오타입 4개는 기능이 거의 같다 — 역할 표시가 목적. 예외 변환(
@Repository)과 요청 매핑(@Controller)만 실기능 @Component는 내 클래스,@Bean은 남의 클래스- 생성자 주입 —
final·시작 시점 실패·테스트 용이. 의존성이 늘면 눈에 띈다는 이득까지 - 웹 3종 — 경로(
@PathVariable)·쿼리(@RequestParam)·본문(@RequestBody) @Valid는@RestControllerAdvice와 짝 — 하나만 있으면 반쪽- 엔티티에 기본 생성자가 필요한 이유 — 리플렉션으로 채우기 때문
- 부가 기능 계열은 전부 프록시 —
@Transactional의 자기 호출 함정이@Cacheable·@Async에도 똑같이 있다 - 조건부 애너테이션이 자동 설정의 정체 — 마법이 아니다
- 테스트는 띄우는 범위가 곧 속도 — 컨텍스트 종류를 줄이는 것이 개수를 줄이는 것보다 효과가 크다
- 자바 표준 애너테이션은 컴파일러용 —
@Override는 조용한 버그를 막아 준다 - Lombok
@Data를 엔티티에 쓰지 않는다
출처 — Spring Framework Reference: "Annotation-based Container Configuration" · "Classpath Scanning and Managed Components" · "Using @Bean and @Configuration" · "Declarative Transaction Management"(프록시·자기 호출) · Spring Boot Reference: "Condition Annotations" · "Testing" · Jakarta Persistence 3.2 §2.1(엔티티 기본 생성자) · Java Language Specification §9.6(애너테이션 타입) · Project Lombok 문서