Spring Boot 커스텀 어노테이션으로 레이어 아키텍처 명확하게 표현하기
Spring Boot 커스텀 어노테이션으로 레이어 아키텍처 명확하게 표현하기
들어가며
Spring Boot 프로젝트에서 @Service를 붙이는 클래스가 수십 개가 되면, 이런 고민이 생깁니다.
- "이
@Service는 비즈니스 로직인가, 유즈케이스 오케스트레이션인가?" - "
@Repository를 감싸는 래퍼 클래스에는 어떤 어노테이션을 써야 하지?" - "새로 합류한 팀원이 코드를 보고 레이어 구조를 바로 이해할 수 있을까?"
s-class 프로젝트에서는 이 문제를 커스텀 어노테이션으로 해결했습니다. @UseCase, @DomainService, @Adaptor 세 가지 어노테이션을 만들어 각 클래스의 아키텍처적 역할을 코드 자체에 인코딩했습니다.
이 글에서는 Spring Boot의 메타 어노테이션 원리부터, 도입 전/후 비교, 그리고 실제 적용 사례까지 정리합니다.
Spring Boot 메타 어노테이션 원리
@Component의 발견 메커니즘
Spring의 컴포넌트 스캔은 @Component가 직접 붙은 클래스뿐만 아니라, @Component를 메타 어노테이션으로 가진 어노테이션이 붙은 클래스도 빈으로 등록합니다. 이것을 합성 어노테이션(Composed Annotation) 이라고 합니다.
사실 우리가 매일 쓰는 @Service, @Repository, @Controller도 같은 원리입니다.
// Spring Framework 내부 코드 @Component public @interface Service { ... } @Component public @interface Repository { ... } @Component public @interface Controller { ... }
이 원리를 그대로 활용하면, 우리 프로젝트에 맞는 커스텀 어노테이션을 만들 수 있습니다.
다이어그램 로딩 중...
Spring이 제공하는 @Service와 우리가 만든 @UseCase는 동일한 메커니즘으로 동작합니다. 둘 다 내부에 @Component를 포함하고 있어 컴포넌트 스캔에 의해 자동으로 빈 등록됩니다.
실제 구현 코드
세 어노테이션 모두 구조가 동일합니다.
// @UseCase - Api 모듈의 유즈케이스 오케스트레이션 @Target(AnnotationTarget.CLASS) @Retention(AnnotationRetention.RUNTIME) @Component annotation class UseCase(val value: String = "") // @DomainService - Domain 모듈의 비즈니스 로직 @Target(AnnotationTarget.CLASS) @Retention(AnnotationRetention.RUNTIME) @Component annotation class DomainService(val value: String = "") // @Adaptor - Domain 모듈의 Repository 래퍼 @Target(AnnotationTarget.CLASS) @Retention(AnnotationRetention.RUNTIME) @Component annotation class Adaptor(val value: String = "")
핵심은 @Component를 메타 어노테이션으로 포함시킨 것입니다. 이 한 줄로 Spring이 해당 클래스를 빈으로 인식합니다.
도입 전 vs 도입 후 비교
도입 전: Spring 기본 어노테이션만 사용
@RestController class AuthController( private val loginService: LoginService, ) { ... } @Service // 유즈케이스 오케스트레이션 class LoginService( private val userService: UserService, private val tokenService: TokenService, ) { ... } @Service // 비즈니스 로직 class UserService( private val userAdaptor: UserAdaptor, ) { ... } @Service // Repository 래퍼... @Repository? @Service? @Component? class UserAdaptor( private val userRepository: UserJpaRepository, ) { ... }
다이어그램 로딩 중...
문제점:
-
역할 구분 불가 —
LoginService와UserService가 둘 다@Service. 코드만 보고 어떤 것이 오케스트레이션이고 어떤 것이 비즈니스 로직인지 알 수 없습니다. -
의미적 부정확 —
UserAdaptor에@Repository를 쓰면 Spring의PersistenceExceptionTranslation이 적용되는데, Adaptor는 JPA Repository가 아닙니다.@Service를 쓰자니 Service도 아닙니다.@Component는 너무 범용적입니다. -
아키텍처 위반 감지 불가 —
@Service끼리는 서로 아무 제약 없이 주입 가능합니다. UseCase가 다른 UseCase를 주입하거나, Adaptor가 UseCase를 주입하는 등의 레이어 위반이 발생해도 감지할 방법이 없습니다. -
온보딩 비용 — 새 팀원이 "이
@Service는 어느 레이어야?"를 매번 패키지 경로와 코드 내용을 읽어서 추론해야 합니다.
도입 후: 커스텀 어노테이션 사용
@RestController class AuthController( private val loginUseCase: LoginUseCase, ) { ... } @UseCase // 오케스트레이션이라는 것이 즉시 파악됨 class LoginUseCase( private val userDomainService: UserDomainService, private val tokenDomainService: TokenDomainService, ) { ... } @DomainService // 비즈니스 로직 담당이라는 것이 명확 class UserDomainService( private val userAdaptor: UserAdaptor, ) { ... } @Adaptor // DB 접근 래퍼라는 역할이 어노테이션에 표현됨 class UserAdaptor( private val userRepository: UserJpaRepository, ) { ... }
다이어그램 로딩 중...
개선점:
| 관점 | 도입 전 (@Service) | 도입 후 (커스텀) |
|---|---|---|
| 역할 파악 | 패키지 경로 + 코드 내용 확인 필요 | 어노테이션 한 줄로 즉시 파악 |
| 의미 정확성 | @Service가 3가지 역할을 모두 담당 | 각 역할에 정확히 대응하는 어노테이션 |
| 아키텍처 규칙 | 강제할 방법 없음 | ArchUnit으로 레이어 의존성 테스트 가능 |
| IDE 검색 | @Service 검색 시 수십 개 혼재 | @UseCase 검색 시 유즈케이스만 정확히 조회 |
| 온보딩 | 레이어 구조를 별도 문서로 설명 | 코드 자체가 아키텍처 문서 역할 |
@Service / @Repository를 안 쓰면 잃는 것은?
"Spring이 제공하는 어노테이션을 버리면 뭔가 손해 아닌가?" 라는 의문이 생길 수 있습니다.
| Spring 기본 어노테이션 | 부가 기능 | 커스텀 전환 시 영향 |
|---|---|---|
@Service | 없음. @Component와 기능 동일 | 손실 없음 |
@Repository | JPA 예외 → DataAccessException 자동 변환 | Adaptor는 JPA Repository가 아닌 래퍼이므로 불필요. 실제 JpaRepository 인터페이스에는 Spring Data가 알아서 처리 |
@Controller | MVC 핸들러 매핑 | @RestController 그대로 유지하므로 무관 |
결론적으로, 실질적으로 잃는 기능은 없습니다.
ArchUnit으로 아키텍처 규칙 강제하기
커스텀 어노테이션의 진짜 힘은 아키텍처 규칙을 테스트로 강제할 수 있다는 점입니다. ArchUnit을 사용하면 이런 테스트가 가능합니다.
@ArchTest val `UseCase는 다른 UseCase를 주입받을 수 없다` = noClasses() .that().areAnnotatedWith(UseCase::class.java) .should().dependOnClassesThat() .areAnnotatedWith(UseCase::class.java) @ArchTest val `Adaptor는 UseCase에 의존할 수 없다` = noClasses() .that().areAnnotatedWith(Adaptor::class.java) .should().dependOnClassesThat() .areAnnotatedWith(UseCase::class.java) @ArchTest val `DomainService는 UseCase에 의존할 수 없다` = noClasses() .that().areAnnotatedWith(DomainService::class.java) .should().dependOnClassesThat() .areAnnotatedWith(UseCase::class.java)
만약 @Service만 사용했다면 이런 테스트는 작성할 수 없습니다. 모든 것이 @Service이니 구분할 기준이 없기 때문입니다.
s-class 프로젝트 실제 적용 구조
다이어그램 로딩 중...
어노테이션 색상만으로 의존성 방향이 한눈에 보입니다. 파랑(@UseCase) → 보라(@DomainService) → 청록(@Adaptor). 역방향 의존은 존재하지 않습니다.
정리
커스텀 어노테이션은 Spring의 메타 어노테이션 메커니즘을 활용해 기능 손실 없이 아키텍처 의도를 코드에 명시적으로 표현하는 방법입니다.
@Component를 메타 어노테이션으로 포함시키면 Spring이 자동으로 빈 등록@Service와 기능적 차이 없이, 레이어 역할이라는 추가 의미를 부여- ArchUnit과 결합하면 아키텍처 규칙을 CI에서 자동으로 검증 가능
- 코드 자체가 아키텍처 문서 역할을 하여 온보딩 비용 절감
"어노테이션 하나 바꿨을 뿐인데?"라고 생각할 수 있지만, 프로젝트가 커질수록 "이 클래스가 어느 레이어인가"를 어노테이션 한 줄로 전달할 수 있는 것의 가치는 점점 커집니다.