자바 아키텍처·Spring 학습 노트 목차

애너테이션 지도 — 계층별로 무엇이 무엇을 하나

스프링을 처음 만지면 애너테이션이 수십 개 쏟아진다. 하나씩 외우면 끝이 없고, 외워도 @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 @RequestBodyHTTP 요청을 메서드에 매핑한다
검증·예외@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) { ... }
}

세 개를 정확히 구분하는 것이 요점이다.

어디서 값을 꺼내나
@PathVariableURL 경로의 일부/orders/4242
@RequestParam쿼리 문자열 또는 폼?page=22
@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웹 계층만컨트롤러 매핑·검증·예외 처리
@DataJpaTestJPA 관련만 (+ 테스트 후 롤백)쿼리·매핑
없음아무것도 안 띄운다순수 단위 테스트 — 가장 빠르다
@WebMvcTest(OrderController.class)
class OrderControllerTest {
    @Autowired MockMvc mvc;
    @MockitoBean OrderService service;      // 서비스는 가짜로
}

@MockitoBean 은 그 타입의 빈을 가짜로 바꿔 컨텍스트에 넣는다. 컨트롤러만 검증하고 싶을 때 서비스 구현을 통째로 대신한다. (예전 이름은 @MockBean 이었다.)

속도 함정@SpringBootTest 를 아무 데나 붙이면 테스트가 느려진다. 다만 스프링은 설정이 같은 컨텍스트를 재사용하므로, 클래스마다 @MockitoBean 구성을 조금씩 다르게 하면 그때마다 컨텍스트를 새로 띄운다. 느린 테스트의 원인이 대개 테스트 개수가 아니라 컨텍스트 종류의 개수다.

11. 자바 표준 애너테이션 — 대부분 컴파일러용

애너테이션무엇을 하나
@Override상위 것을 재정의한다고 선언. 이름을 틀리면 컴파일 에러
@FunctionalInterface추상 메서드가 하나뿐임을 강제
@Deprecated쓰지 말라는 표시. 쓰면 경고
@SuppressWarnings특정 경고를 끈다
@SafeVarargs제네릭 가변 인자 경고를 "안전하다" 고 보증

@Override 의 값어치를 과소평가하기 쉽다. 붙이지 않고 이름을 틀리면 재정의가 아니라 새 메서드가 하나 생기고, 컴파일도 실행도 되지만 원래 것이 그대로 불린다. 조용히 안 먹는 버그의 전형이다. 붙여 두면 컴파일러가 잡아 준다.

이들은 대부분 SOURCECLASS 라 실행 중에는 없다. 컴파일러에게 하는 말이지 프레임워크에게 하는 말이 아니다.

12. Lombok — 컴파일 시점에 코드를 만든다

@Getter @RequiredArgsConstructor
public class OrderService {
    private final OrderRepository repo;      // 생성자를 만들어 준다
}

Lombok 은 리플렉션이 아니라 컴파일 시점에 소스를 생성한다. 그래서 실행 성능에는 영향이 없고, 대신 IDE 플러그인이 없으면 코드가 없는 것처럼 보인다.

@Data 를 엔티티에 붙이지 않는다. @Data 는 모든 필드로 equals·hashCode·toString 을 만든다. 엔티티에 붙으면 셋 다 문제가 된다 — toString 이 연관 엔티티를 건드려 지연 로딩을 일으키거나 순환 참조로 스택이 넘치고, hashCodeid 를 쓰는데 저장 전에는 null 이라 컬렉션에 넣은 뒤 값이 바뀐다. 엔티티에는 @Getter 정도만 쓰고 나머지는 직접 쓴다.

@Builder 도 주의할 곳이 있다. 필드를 추가했을 때 빌더에서 안 넣어도 컴파일이 되므로 누락이 조용히 통과한다. 필수 값은 생성자로 강제하는 편이 안전하다.

13. 자주 겪는 것

증상원인
직접 만든 애너테이션이 안 읽힌다@Retention(RUNTIME) 이 없다
@Service 를 붙였는데 빈이 없다컴포넌트 스캔 범위 밖 패키지다
@Transactional 이 안 걸린다자기 호출이거나 private 메서드다
@Cacheable 이 안 걸린다위와 같은 이유. 원리가 같다
@Valuenull 이다필드 주입은 생성자 실행 후에 채워진다 → 생성자 안에서는 아직 없다
같은 타입 빈이 둘이라 시작 실패@Primary@Qualifier 로 좁힌다
@Async 가 동기로 돈다@EnableAsync 가 없거나 자기 호출이다
테스트가 갑자기 느려졌다컨텍스트 설정이 갈라져 여러 벌 뜬다
엔티티에서 StackOverflowError@DatatoString 이 순환 참조를 돈다

한눈에 정리

  • 애너테이션은 표식일 뿐이다 — 읽는 쪽(리플렉션)이 있어야 동작한다. @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 문서

Spring Boot — 자동설정·스타터·프로파일웹 — MVC·REST·검증·예외·WebSocket/STOMP