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

문제점:

  1. 역할 구분 불가LoginServiceUserService가 둘 다 @Service. 코드만 보고 어떤 것이 오케스트레이션이고 어떤 것이 비즈니스 로직인지 알 수 없습니다.

  2. 의미적 부정확UserAdaptor@Repository를 쓰면 Spring의 PersistenceExceptionTranslation이 적용되는데, Adaptor는 JPA Repository가 아닙니다. @Service를 쓰자니 Service도 아닙니다. @Component는 너무 범용적입니다.

  3. 아키텍처 위반 감지 불가@Service끼리는 서로 아무 제약 없이 주입 가능합니다. UseCase가 다른 UseCase를 주입하거나, Adaptor가 UseCase를 주입하는 등의 레이어 위반이 발생해도 감지할 방법이 없습니다.

  4. 온보딩 비용 — 새 팀원이 “이 @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와 기능 동일손실 없음
@RepositoryJPA 예외 → DataAccessException 자동 변환Adaptor는 JPA Repository가 아닌 래퍼이므로 불필요. 실제 JpaRepository 인터페이스에는 Spring Data가 알아서 처리
@ControllerMVC 핸들러 매핑@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에서 자동으로 검증 가능
  • 코드 자체가 아키텍처 문서 역할을 하여 온보딩 비용 절감

“어노테이션 하나 바꿨을 뿐인데?”라고 생각할 수 있지만, 프로젝트가 커질수록 “이 클래스가 어느 레이어인가”를 어노테이션 한 줄로 전달할 수 있는 것의 가치는 점점 커집니다.