스프링 커스텀 스테레오타입 애노테이션으로 반복 설정을 묶는다

애플리케이션 서비스 클래스를 하나 열어 보면 이런 상태가 되어 있다

@Service
@Transactional
@Validated
@RequiredArgsConstructor
public class MemberModifyService implements MemberRegister {

네 줄 모두 이 클래스가 무슨 일을 하는지에 대한 설명이 아니다. 스프링 빈으로 등록하고, 트랜잭션을 걸고, 파라미터 검증을 켜고, 생성자를 만들어 달라는 지시다. 애플리케이션 서비스를 새로 만들 때마다 이 네 줄을 복사해서 붙인다. 하나를 빼먹으면 런타임에서야 문제가 드러난다

결론부터 말하면 이 중 세 개는 애노테이션 하나로 줄일 수 있다. 그리고 줄인 결과물은 단순히 짧아진 게 아니라, “이 클래스는 애플리케이션 서비스 계층의 구현이다”라는 정보를 코드에 담는다. 스프링이 이미 @Service@Repository로 하고 있는 일을 우리 팀 어휘로 한 번 더 하는 것이다

이 글은 토비님의 스프링 백엔드 강의에서 다루는 온라인 학습 서비스 예제 프로젝트 Splearn을 따라 구현하면서, 강의 내용과 직접 작성한 코드를 함께 정리한 것이다. 개념과 설계 판단의 근거는 강의를 따르되, 코드와 해석은 직접 구현한 저장소 기준이다

이 글에서 자주 나오는 용어

  • 메타 애노테이션(Meta Annotation): 애노테이션 선언 위에 붙는 애노테이션. 애노테이션을 설명하거나 기능을 부여한다
  • 합성 애노테이션(Composed Annotation): 여러 개의 메타 애노테이션을 묶어 놓은 애노테이션. 하나만 붙이면 묶인 전체가 적용된다
  • 스테레오타입 애노테이션(Stereotype Annotation): 그 애노테이션이 붙은 대상의 역할을 한눈에 알아볼 수 있게 정의한 애노테이션. @Service, @Controller가 여기 해당한다
  • 리텐션(Retention): 애노테이션 정보가 어느 단계까지 살아남는지를 지정하는 값. 소스, 클래스, 런타임 세 단계가 있다
  • AOP 프록시: 트랜잭션이나 검증 같은 부가 기능을 끼워 넣기 위해 스프링이 원본 빈 앞에 만들어 두는 대리 객체

메타 애노테이션은 애노테이션 위에 붙는 애노테이션이다

자바 애노테이션은 코드로 선언된다. 인터페이스 선언 앞에 @를 붙인 형태다. 코드로 선언되기 때문에 그 선언 위에 또 다른 애노테이션을 붙일 수 있다. 애노테이션 위에 붙는 애노테이션이라서 메타 애노테이션이라고 부른다

자바 표준은 여기에 특별한 의미를 부여하지 않았다. 원래 애노테이션은 “무언가를 설명하기 위해 붙여 놓은 주석”에 가까운 개념이었고, 상속 같은 규칙도 정의되어 있지 않다. 의미를 부여한 쪽은 프레임워크다. 스프링은 클래스에 붙은 애노테이션만 보지 않고, 그 애노테이션에 붙은 메타 애노테이션, 다시 그 위에 붙은 메타 애노테이션까지 계층을 전부 탐색한다

가장 익숙한 예가 @Service

// 스프링의 @Service 선언 (일부 생략)
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component
public @interface Service {

@Service 위에 @Component가 메타 애노테이션으로 붙어 있다. 그래서 클래스에 @Service만 붙여도 스프링의 빈 스캐너가 @Component를 인식해 빈으로 등록한다. @Service@Component를 둘 다 붙일 필요가 없는 이유가 여기에 있다. 동시에 @Service라는 이름은 “이 클래스는 서비스 계층의 빈이다”라는 설명 역할을 한다. 기능과 설명을 한 애노테이션이 같이 담고 있는 셈이다

합성 애노테이션은 메타 애노테이션을 여러 개 쌓은 것이다

메타 애노테이션은 하나만 붙일 수 있는 게 아니다. 여러 개를 쌓아 하나로 묶은 것을 합성 애노테이션이라고 부른다. 스프링 부트에서 리포지토리 테스트에 쓰는 @DataJpaTest가 극단적인 예다

// 스프링 부트의 @DataJpaTest 선언
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@BootstrapWith(DataJpaTestContextBootstrapper.class)
@ExtendWith(SpringExtension.class)
@OverrideAutoConfiguration(enabled = false)
@TypeExcludeFilters(DataJpaTypeExcludeFilter.class)
@Transactional
@AutoConfigureCache
@AutoConfigureDataJpa
@AutoConfigureTestDatabase
@AutoConfigureTestEntityManager
@ImportAutoConfiguration
public @interface DataJpaTest {

위쪽 네 개는 자바 애노테이션을 선언할 때 쓰는 표준이고, 그 아래가 @DataJpaTest의 실체다. @Transactional은 테스트 메서드가 실행될 때 트랜잭션을 시작하고 끝나면 롤백한다. @AutoConfigureTestDatabase는 테스트용 인메모리 데이터베이스를 준비한다. @AutoConfigureTestEntityManagerTestEntityManager를 주입받을 수 있게 빈으로 등록한다. 이 열 개를 한 줄로 줄인 것이 @DataJpaTest다. 그리고 이 중 몇 개는 타고 들어가면 또 다른 메타 애노테이션이 붙어 있다

정리하면 이 기법은 스프링 전용 기술이 아니다. 자바 애노테이션 표준 위에 스프링이 “메타 애노테이션 계층을 전부 탐색한다”는 규칙을 부여했기 때문에 성립한다. 그래서 우리도 같은 방식으로 만들 수 있다

스테레오타입 애노테이션은 역할을 이름으로 드러낸다

스테레오타입은 원래 어떤 그룹에 대한 정형화된 인식을 뜻하는 단어다. IT 쪽에서는 UML이 먼저 가져다 썼다. UML에서는 기본 다이어그램에 좀 더 구체적인 역할이나 의미를 부여할 때 라벨로 스테레오타입을 붙인다

┌─────────────────┐
│  <<component>>  │   ← 스테레오타입 라벨
│  MemberService  │
└─────────────────┘

클래스 다이어그램처럼 생긴 박스에 <<entity>>, <<interface>>, <<component>>처럼 꺾은 괄호 두 개로 감싼 라벨이 붙는다. 이 라벨을 보면 “이건 컴포넌트라는 특정한 역할을 가진 것”임을 바로 인식할 수 있다

스프링도 같은 목적으로 애노테이션을 쓴다. @Component, @Service, @Controller, @Repository가 스프링의 스테레오타입 애노테이션이다. @RestController가 붙어 있으면 코드를 읽지 않고도 이건 REST API를 담당하는 클래스라고 판단할 수 있다

중요한 건 스테레오타입 애노테이션이 프레임워크 전용이 아니라는 점이다. 개발팀 안에서 합의된 정의가 있다면 직접 정의해서 쓸 수 있다. 도메인 모델이 팀 내부에서 공유되는 도메인 지식을 담는 것처럼, 커스텀 스테레오타입 애노테이션은 우리 코드 구조에 대한 지식을 담는다. 거기에 합성 애노테이션 기법을 같이 쓰면 덕지덕지 붙은 애노테이션을 정리하는 효과까지 얻는다

표준 메타 애노테이션 세 개가 먼저 필요하다

애노테이션을 직접 만들려면 그 위에 붙일 자바 표준 메타 애노테이션을 먼저 이해해야 한다. 세 개다

@Target은 이 애노테이션을 어디에 붙일 수 있는지 지정한다. ElementType으로 필드, 메서드, 파라미터, 생성자 등을 상세하게 나눌 수 있고 하나 이상 지정할 수 있다. 지정하지 않은 위치에 붙이면 컴파일 에러가 난다. 우리가 만들 애노테이션은 클래스에 붙을 것이므로 TYPE을 쓴다. TYPE은 클래스, 인터페이스, 이넘, 레코드 네 가지를 포함한다

@Retention은 애노테이션 정보가 어느 단계까지 살아남을지를 지정한다. 세 단계가 있다

정보가 남는 범위대표 예시
SOURCE소스 코드까지. 컴파일되면 사라진다@Override, 롬복의 @Getter
CLASS.class 파일까지. 메모리에 로딩되면 사라진다 (기본값)별도 지정 없이 만든 애노테이션
RUNTIME실행 중 리플렉션으로 읽을 수 있다@Service, @Transactional

@OverrideSOURCE인 이유는 컴파일러가 오버라이드 여부를 검사할 때만 필요하기 때문이다. 컴파일이 끝나면 쓸모가 없고, 남겨 두면 클래스 파일 크기와 런타임 메모리를 낭비한다. 롬복 애노테이션도 전부 SOURCE다. 롬복은 컴파일 전에 애노테이션을 보고 소스 코드를 생성해 넣은 뒤 컴파일시키는 방식으로 동작하므로, 그 이후에는 정보가 필요 없다. 그래서 런타임에 어떤 롬복 애노테이션이 붙어 있었는지 확인할 방법이 없다

주의할 점은 기본값이 CLASS라는 것이다. 애노테이션이 설계될 당시에는 주석이나 부가 설정 정보 용도가 기본이고 런타임에 읽는 건 특별한 경우로 봤다. 지금은 반대다. 프레임워크가 애노테이션 정보를 보고 초기화하거나 동작 방식을 바꾸기 때문에, 스프링이 인식하게 하려면 RUNTIME을 명시해야 한다

@Documented는 이 애노테이션 정보를 자바독에 포함시킬지를 지정한다. 스테레오타입 애노테이션은 주석으로서도 중요한 의미를 갖기 때문에 붙여 주는 편이 좋다

세 개를 붙인 골격은 이렇게 생겼다

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ApplicationService {
}

인터페이스 선언과 거의 같고 interface 앞에 @만 붙는다. 여기까지가 시작점이고, 이제 이 안에 합성할 애노테이션을 넣는다

어느 계층에 붙일지 먼저 결정한다

헥사고날 아키텍처와 도메인 모델 패턴을 적용한 프로젝트라면 계층이 세 개다. 계층마다 스테레오타입 애노테이션이 필요한지를 따로 판단해야 한다

Adapter              → 필요하다. @Adapter, @WebApiAdapter
   ↓
Application Service  → 필요하다. @ApplicationService
   ↓
Domain               → 지금은 필요하지 않다

도메인 계층은 만들지 않기로 했다. 엔티티나 값 객체에는 @Entity, @Embeddable 같은 JPA 애노테이션이 이미 어느 정도 의미를 담고 있다. 애그리거트를 표현하는 애노테이션은 JPA에 없으므로 @Aggregate를 만들 여지는 있지만, 도메인 이벤트를 다룰 때 함께 만드는 편이 자연스럽다. 지금 당장 얻는 게 없다면 만들지 않는 쪽이 낫다

애플리케이션 서비스 계층은 붙이기에 가장 적절하다. 클래스마다 기계적으로 따라붙는 애노테이션이 실제로 존재하기 때문이다. 어댑터 계층도 마찬가지다. 컨트롤러에 붙는 애노테이션이 있고, 설령 하나뿐이라도 계층의 의미를 담은 이름을 하나 둘 가치가 있다

정리하면 판단 기준은 두 가지다. 기계적으로 반복되는 애노테이션이 있는가, 그리고 계층의 의미를 이름으로 드러낼 가치가 있는가. 둘 중 하나만 해당해도 만들 이유가 된다

패키지는 support 아래에 둔다

애노테이션을 정의할 위치를 정해야 한다. 메인 모듈의 패키지는 domain, application, adapter 세 개인데, 각각 하나의 계층을 담기 위한 패키지이므로 계층을 가로지르는 애노테이션이 그 안에 들어가면 어색하다

애플리케이션 전체가 공통으로 사용하는 코드를 담을 패키지가 따로 필요하다. 이름은 support로 했다

kimspring.splearn
├── domain
├── application
├── adapter
└── support
    └── stereotype        // 스테레오타입 애노테이션

support 바로 아래에 두지 않고 stereotype 하위 패키지를 하나 더 뒀다. support에는 앞으로 다른 성격의 공통 코드도 들어올 것이므로 성격별로 구분해 두는 편이 낫다

@ApplicationService에는 공통 설정만 담는다

MemberModifyService에 붙어 있던 네 개 중 후보는 세 개다. @RequiredArgsConstructor는 합성할 수 없다. 롬복 애노테이션의 리텐션이 SOURCE이기 때문에, RUNTIME 리텐션으로 만드는 애노테이션 안에 넣어도 컴파일 시점에 사라진다

남은 세 개를 그대로 다 넣는 게 첫 시도였는데, MemberQueryService를 보면 문제가 드러난다

// 조회만 하는 서비스인데 @Validated까지 붙어 있다
@Service
@Transactional
@Validated
@RequiredArgsConstructor
public class MemberQueryService implements MemberFinder {
    private final MemberRepository memberRepository;

    @Override
    public Member find(Long memberId) { ... }
}

@Transactional이 붙으면 AOP가 적용되어 원본 빈 앞에 프록시가 하나 생긴다. 런타임에 생성되므로 시간이 걸리지만, 트랜잭션 처리와 예외 처리를 얻으니 값을 치른다. @Validated는 프록시를 하나 더 추가한다. 파라미터에 @Valid가 붙은 게 있을 때 검증하기 위해서다. 두 개를 다 붙이면 프록시가 최소 두 겹 쌓인다

MemberQueryServiceLong memberId 하나를 받아 조회할 뿐이다. 검증할 게 없는데 프록시를 하나 더 만든다. 필요하지 않은 AOP가 추가되는 건 바람직하지 않다

그래서 두 단계로 분리했다. 기본형에는 모든 애플리케이션 서비스에 공통으로 적용될 것만 넣는다

// support/stereotype/ApplicationService.java
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Service
@Transactional
public @interface ApplicationService {
}

앞으로 애플리케이션 서비스 전체에 공통으로 적용할 설정이 생기면 여기 한 곳만 고치면 된다. 예를 들어 @Transactional(readOnly = true)를 기본값으로 두고 쓰기가 필요한 메서드에만 @Transactional을 다시 붙이는 최적화 기법을 나중에 도입한다면, 그 변경도 이 파일 하나에서 끝난다

@ValidatedApplicationService로 계층을 쌓는다

검증이 필요한 서비스에는 애노테이션을 하나 더 만든다. 여기서 메타 애노테이션의 계층 탐색이 그대로 활용된다

// support/stereotype/ValidatedApplicationService.java
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@ApplicationService
@Validated
public @interface ValidatedApplicationService {
}

@ApplicationService를 메타 애노테이션으로 붙였으므로, 그 위에 있는 @Service@Transactional이 전부 적용된다. 거기에 @Validated가 더해진다. 계층이 이렇게 쌓인다

@ValidatedApplicationService
  └── @Validated
  └── @ApplicationService
        └── @Service
        └── @Transactional

적용 결과는 애플리케이션 서비스 다섯 개에 대해 이렇게 갈렸다

클래스적용한 애노테이션이유
MemberModifyService@ValidatedApplicationService등록·수정 요청 DTO 검증 필요
MemberAuthenticationService@ValidatedApplicationService로그인 요청 DTO 검증 필요
MemberQueryService@ApplicationServiceid로 조회만 한다
InstructorModifyService@ValidatedApplicationService강사 신청 요청 DTO 검증 필요
InstructorQueryService@ApplicationService조회만 한다

적용 후 MemberModifyService는 이렇게 남았다

@ValidatedApplicationService
@RequiredArgsConstructor
public class MemberModifyService implements MemberRegister {

네 줄이 두 줄로 줄었고, 첫 줄은 이제 설정 지시가 아니라 “이 클래스는 검증이 필요한 애플리케이션 서비스의 구현이다”라는 선언으로 읽힌다

적용이 실제로 되는지는 기존 테스트로 확인할 수 있다. MemberRegisterTest를 실행하면 MemberModifyService가 빈으로 주입되고 트랜잭션과 빈 검증이 걸린 상태로 동작한다. 확인 방법이 하나 더 있다. @ValidatedApplicationService에서 @Validated를 잠시 지우고 검증 실패를 기대하는 테스트 메서드를 실행하면 실패한다. 검증이 안 되어 예외가 발생하지 않기 때문이다. 다시 붙이면 통과한다. 합성한 애노테이션이 정말 전달되고 있다는 증거다. 테스트가 통과하는 것보다 의도적으로 깨뜨려서 확인하는 편이 검증에 가깝다

아무 기능이 없는 @Adapter를 만드는 이유

어댑터 계층에는 웹 API 컨트롤러가 하나 있고, 붙어 있는 애노테이션은 @RestController@RequiredArgsConstructor 두 개였다. 롬복은 합성할 수 없으므로 묶을 수 있는 건 사실상 하나뿐이다. 그래도 계층을 나타내는 이름을 만들었다

// support/stereotype/WebApiAdapter.java
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Adapter
@RestController
public @interface WebApiAdapter {
}

@Adapter는 이렇게 생겼다.

// support/stereotype/Adapter.java
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Adapter {
}

아무 기능이 없다. 표준 메타 애노테이션 세 개뿐이고 스프링이 인식할 애노테이션은 하나도 없다. 이걸 만드는 이유는 두 가지다

첫째, 계층을 대표하는 이름을 하나 정해 두면 그 아래로 상세한 애노테이션을 계층적으로 정의할 수 있다. 지금은 웹 API 어댑터만 있지만, 타임리프 같은 서버사이드 렌더링을 도입하면 @RestController 대신 @Controller를 쓰는 @WebAdapter가 필요해진다. 그때도 @Adapter는 그대로 위에 붙는다

둘째, 애노테이션을 기준으로 검증하는 기능을 나중에 만들 수 있다. ArchUnit은 애노테이션을 보고 조건을 부여하는 검증을 지원한다. “어댑터 계층의 클래스는 도메인 엔티티의 조회 메서드만 호출할 수 있다” 같은 규칙을 쓸 때, 어댑터 계층을 가리키는 애노테이션 하나가 있으면 규칙을 패키지 경로가 아니라 의미로 표현할 수 있다

기능이 없는 애노테이션도 주석으로서의 역할과 앞으로의 확장 지점 역할을 한다. @WebApiAdapter가 붙은 클래스는 “어댑터 계층이고, 그중 웹 API를 담당한다”로 읽힌다

테스트 애노테이션이 정리 효과가 가장 크다

메인 모듈보다 테스트 쪽이 애노테이션이 더 많이 붙어 있었다. 애플리케이션 서비스 테스트는 이런 상태였다

@SpringBootTest
@Transactional
@Import(SplearnTestConfiguration.class)
record MemberRegisterTest(MemberRegister memberRegister, EntityManager entityManager) {

애플리케이션 서비스 테스트라면 이 세 개는 항상 같이 쓴다. 테스트 쪽에도 support/stereotype 패키지를 만들고 합성 애노테이션으로 묶었다

// (test) support/stereotype/ApplicationServiceTest.java
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@SpringBootTest
@Transactional
@Import(SplearnTestConfiguration.class)
public @interface ApplicationServiceTest {
}

적용하면 테스트 클래스 선언이 한 줄로 줄어든다.

@ApplicationServiceTest
record MemberRegisterTest(MemberRegister memberRegister, EntityManager entityManager) {

웹 API 어댑터 테스트도 같은 방식으로 묶었다. 여기에는 @AutoConfigureMockMvc가 추가된다

// (test) support/stereotype/WebApiAdapterTest.java
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
@Import(SplearnTestConfiguration.class)
public @interface WebApiAdapterTest {
}

여기서 @Import(SplearnTestConfiguration.class)를 웹 어댑터 테스트에도 똑같이 넣은 것에는 실용적인 이유가 있다. 스프링 부트 테스트는 컨텍스트 설정이 동일한 테스트들끼리 컨테이너를 재사용한다. 설정이 하나라도 다르면 컨테이너를 새로 만들기 때문에 테스트 전체 실행 시간이 늘어난다. 테스트용 빈 정의를 담은 설정 클래스를 계층별로 다르게 가져가면 컨테이너가 계층마다 따로 뜬다. 공통으로 넣어 두는 편이 낫다

컨텍스트 설정 동일  →  컨테이너 1개 재사용   →  빠르다
컨텍스트 설정 상이  →  컨테이너 2개 생성    →  느리다

테스트 클래스에서 필드 주입을 정리하는 작업도 같이 했다. @Autowired를 필드마다 붙이는 대신 final 필드와 @RequiredArgsConstructor 조합으로 통일했다

@WebApiAdapterTest
@RequiredArgsConstructor
class MemberApiTest {
    final MockMvcTester mvcTester;
    final ObjectMapper objectMapper;
    final MemberRepository memberRepository;
    final MemberRegister memberRegister;

레코드로 테스트 클래스를 만드는 방법도 있고 실제로 MemberRegisterTest는 레코드다. 다만 레코드는 필드를 가질 수 없어서 픽스처 상태를 담아야 하는 순간 클래스로 바꿔야 한다. 그래서 클래스와 @RequiredArgsConstructor 조합을 표준으로 쓰고, 레코드는 가능한 케이스를 보여 주는 정도로만 남겨 뒀다

이 방식이 통하지 않는 조건

강의에서 다루지 않았거나, 실제로 적용하면서 갈린 지점이 몇 가지 있다

리텐션이 SOURCE인 애노테이션은 합성할 수 없다. 롬복이 대표적이다. @RequiredArgsConstructor는 스테레오타입 애노테이션에 넣을 방법이 없으므로 클래스마다 따로 붙어 있어야 한다. 즉 네 줄이 한 줄로 줄어들지는 않는다. 두 줄이 현실적인 최소치다

모든 테스트를 합성 애노테이션으로 묶을 수는 없다. MemberApiWebMvcTest@WebMvcTest로 컨트롤러 하나만 떼어 내 테스트하는 특수 케이스다. 목을 매번 만들어야 하고 코드도 번거롭기 때문에 API 테스트의 표준으로 쓰지 않기로 했고, 표준이 아닌 것에 스테레오타입 애노테이션을 만들면 이름이 규칙을 잘못 안내한다. 그래서 이 클래스는 @WebMvcTest를 그대로 뒀다. MemberRepositoryTest@DataJpaTest도 마찬가지로 손대지 않았다. JPA 쪽 설정을 더 깊이 다룰 때 함께 정리할 대상이다

합성 애노테이션은 설정을 숨긴다. @ValidatedApplicationService만 보고 프록시가 두 겹 생긴다는 사실을 알아차리기는 어렵다. 팀의 관례를 모르는 사람에게는 오히려 정보가 줄어든다. 정의를 타고 들어가면 확인할 수 있지만, 그 전에 팀 안에서 이 애노테이션이 무엇을 담고 있는지 합의하고 공유하는 절차가 필요하다. 개발 가이드 문서에 남겨 두는 편이 안전하다

정리하면 커스텀 스테레오타입 애노테이션은 반복되는 설정을 줄이는 도구이기 이전에, 계층의 의미를 코드에 새기는 도구다. 기계적으로 따라붙는 애노테이션이 두 개 이상 보이면 만들 때가 됐다고 보면 된다

출처 – 토비의 클린 스프링 – 도메인 모델 패턴과 헥사고날 아키텍처 Part 2