Spring Boot 커스텀 어노테이션으로 레이어 아키텍처 명확하게 표현하기
Spring Boot의 메타 어노테이션 원리를 활용해 @UseCase, @DomainService, @Adaptor 커스텀 어노테이션을 만들고, 레이어 아키텍처의 역할을 코드에 명시적으로 표현하는 방법을 도입 전/후 비교와 함께 정리합니다.
들어가며
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 { ... }
이 원리를 그대로 활용하면, 우리 프로젝트에 맞는 커스텀 어노테이션을 만들 수 있습니다.
graph TB
subgraph "Spring 메타 어노테이션 계층"
Component["@Component<br/><i>Spring 기본</i>"]
subgraph "Spring 제공"
Service["@Service"]
Repository["@Repository"]
Controller["@Controller"]
end
subgraph "우리가 만든 것"
UseCase["@UseCase"]
DomainService["@DomainService"]
Adaptor["@Adaptor"]
end
Service -->|내부에 @Component 포함| Component
Repository -->|내부에 @Component 포함| Component
Controller -->|내부에 @Component 포함| Component
UseCase -->|내부에 @Component 포함| Component
DomainService -->|내부에 @Component 포함| Component
Adaptor -->|내부에 @Component 포함| Component
end
style Component fill:#4CAF50,color:#fff
style Service fill:#FF9800,color:#fff
style Repository fill:#FF9800,color:#fff
style Controller fill:#FF9800,color:#fff
style UseCase fill:#2196F3,color:#fff
style DomainService fill:#9C27B0,color:#fff
style Adaptor fill:#009688,color:#fff
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,
) { ... }
graph TB
subgraph "도입 전: 모든 중간 레이어가 @Service"
C1["🌐 AuthController<br/>@RestController"]
S1["LoginService<br/>@Service"]
S2["UserService<br/>@Service"]
S3["UserAdaptor<br/>@Service"]
R1["UserJpaRepository"]
C1 --> S1
S1 --> S2
S2 --> S3
S3 --> R1
end
style S1 fill:#FF9800,color:#fff
style S2 fill:#FF9800,color:#fff
style S3 fill:#FF9800,color:#fff
문제점:
-
역할 구분 불가 —
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,
) { ... }
graph TB
subgraph "도입 후: 어노테이션이 레이어를 표현"
C2["🌐 AuthController<br/>@RestController"]
U2["LoginUseCase<br/>@UseCase<br/><i>오케스트레이션</i>"]
D2["UserDomainService<br/>@DomainService<br/><i>비즈니스 로직</i>"]
A2["UserAdaptor<br/>@Adaptor<br/><i>DB 접근 래퍼</i>"]
R2["UserJpaRepository"]
C2 --> U2
U2 --> D2
D2 --> A2
A2 --> R2
end
style U2 fill:#2196F3,color:#fff
style D2 fill:#9C27B0,color:#fff
style A2 fill:#009688,color:#fff
개선점:
| 관점 | 도입 전 (@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 프로젝트 실제 적용 구조
graph TB
subgraph "Api 모듈 (SClass-Api-Supporters)"
LoginUseCase["@UseCase<br/>LoginUseCase"]
RegisterUseCase["@UseCase<br/>RegisterUseCase"]
LogoutUseCase["@UseCase<br/>LogoutUseCase"]
RefreshUseCase["@UseCase<br/>RefreshUseCase"]
end
subgraph "Domain 모듈 (SClass-Domain)"
subgraph "비즈니스 로직"
UserDomainService["@DomainService<br/>UserDomainService"]
TokenDomainService["@DomainService<br/>TokenDomainService"]
PasswordService["@DomainService<br/>PasswordService"]
end
subgraph "데이터 접근"
UserAdaptor["@Adaptor<br/>UserAdaptor"]
UserRoleAdaptor["@Adaptor<br/>UserRoleAdaptor"]
RefreshTokenAdaptor["@Adaptor<br/>RefreshTokenAdaptor"]
end
end
LoginUseCase --> UserDomainService
LoginUseCase --> TokenDomainService
RegisterUseCase --> UserDomainService
RegisterUseCase --> TokenDomainService
LogoutUseCase --> TokenDomainService
RefreshUseCase --> TokenDomainService
UserDomainService --> UserAdaptor
UserDomainService --> UserRoleAdaptor
UserDomainService --> PasswordService
TokenDomainService --> RefreshTokenAdaptor
style LoginUseCase fill:#2196F3,color:#fff
style RegisterUseCase fill:#2196F3,color:#fff
style LogoutUseCase fill:#2196F3,color:#fff
style RefreshUseCase fill:#2196F3,color:#fff
style UserDomainService fill:#9C27B0,color:#fff
style TokenDomainService fill:#9C27B0,color:#fff
style PasswordService fill:#9C27B0,color:#fff
style UserAdaptor fill:#009688,color:#fff
style UserRoleAdaptor fill:#009688,color:#fff
style RefreshTokenAdaptor fill:#009688,color:#fff
어노테이션 색상만으로 의존성 방향이 한눈에 보입니다. 파랑(@UseCase) → 보라(@DomainService) → 청록(@Adaptor). 역방향 의존은 존재하지 않습니다.
정리
커스텀 어노테이션은 Spring의 메타 어노테이션 메커니즘을 활용해 기능 손실 없이 아키텍처 의도를 코드에 명시적으로 표현하는 방법입니다.
@Component를 메타 어노테이션으로 포함시키면 Spring이 자동으로 빈 등록@Service와 기능적 차이 없이, 레이어 역할이라는 추가 의미를 부여- ArchUnit과 결합하면 아키텍처 규칙을 CI에서 자동으로 검증 가능
- 코드 자체가 아키텍처 문서 역할을 하여 온보딩 비용 절감
“어노테이션 하나 바꿨을 뿐인데?”라고 생각할 수 있지만, 프로젝트가 커질수록 “이 클래스가 어느 레이어인가”를 어노테이션 한 줄로 전달할 수 있는 것의 가치는 점점 커집니다.