Pingu
영차영차! Backend

Spring Boot 커스텀 어노테이션으로 레이어 아키텍처 명확하게 표현하기

2026년 3월 21일

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,
) { ... }
다이어그램 로딩 중...

문제점:

  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,
) { ... }
다이어그램 로딩 중...

개선점:

관점도입 전 (@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 프로젝트 실제 적용 구조

다이어그램 로딩 중...

어노테이션 색상만으로 의존성 방향이 한눈에 보입니다. 파랑(@UseCase) → 보라(@DomainService) → 청록(@Adaptor). 역방향 의존은 존재하지 않습니다.

정리

커스텀 어노테이션은 Spring의 메타 어노테이션 메커니즘을 활용해 기능 손실 없이 아키텍처 의도를 코드에 명시적으로 표현하는 방법입니다.

  • @Component를 메타 어노테이션으로 포함시키면 Spring이 자동으로 빈 등록
  • @Service와 기능적 차이 없이, 레이어 역할이라는 추가 의미를 부여
  • ArchUnit과 결합하면 아키텍처 규칙을 CI에서 자동으로 검증 가능
  • 코드 자체가 아키텍처 문서 역할을 하여 온보딩 비용 절감

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

댓글

?