Files
llm-wiki/wiki/blog/ca-tmpl-clean-architecture-package-layout-2026-07-02.md

13 KiB

title, source_type, status, confidence, tags, related_projects, last_reviewed, canonical_sources, audience, target_publish, status_label
title source_type status confidence tags related_projects last_reviewed canonical_sources audience target_publish status_label
Clean Architecture를 패키지 구조로 강제하기 blog verified high
blog
ca-tmpl
clean-architecture
package-layout
ca-tmpl
2026-07-02
wiki/projects/ca-tmpl/clean-architecture-package-layout
backend-engineer ready

Clean Architecture를 패키지 구조로 강제하기

Parent / 부모 (필수)

타깃 독자 / Target reader

  • 독자 profile: Clean Architecture를 Java/Spring 멀티모듈 skeleton에 적용하려는 백엔드 엔지니어.
  • 이미 안다고 가정하는 것: controller, application service, domain, adapter 계층.
  • 처음 듣는다고 가정하는 것: package layout 자체를 ArchUnit fitness function으로 고정하는 방식.

도입 / Hook

  • 문제 / 궁금증: Clean Architecture는 그림으로는 쉽지만, package가 흐트러지면 금방 관례가 된다.
  • 이 글이 답하는 것: ca-tmpl이 package/module layout과 dependency rule을 어떻게 구현·검증했는지.
  • 이 글이 답하지 않는 것: 모든 도메인에 맞는 universal package 구조.

본문 outline / Body outline

  1. 계층 그림만으로는 부족하다 — import 방향이 깨지면 architecture도 깨진다.
  2. ca-tmpl의 module/package layout — domain, application, adapters, shared contract의 책임.
  3. ArchUnit rule로 강제하기 — 금지 import와 boundary violation을 build에서 잡는다.
  4. sample-portfolio 격리 — 예제 코드는 template core와 분리한다.
  5. 말할 수 있는 범위 — local verification과 planned/open risk를 구분한다.

본문 / Body

Clean Architecture는 그림으로 보면 단순합니다. domain은 안쪽에 있고, application은 use case를 담고, adapter는 바깥쪽 기술을 맡습니다. 문제는 그림이 아니라 시간이 지난 뒤의 코드입니다. controller가 repository를 직접 부르거나, application이 web DTO를 parameter로 받거나, shared package가 business common dumping ground가 되기 시작하면 구조는 이름만 남습니다.

ca-tmpl은 이 문제를 package naming convention만으로 해결하지 않았습니다. Gradle multi-module을 1차 경계로 두고, ArchUnit을 2차 경계로 둡니다. build graph에서는 어떤 module이 어떤 module을 의존할 수 있는지 검사하고, source import graph에서는 domain/application/adapter/shared-contract가 금지된 타입을 가져오는지 검사합니다. 즉 “Clean Architecture로 짰다”가 아니라, 깨졌을 때 build가 알려주는 skeleton을 만들려는 결정입니다.

현재 ca-tmpl의 production root는 dev.caskeleton입니다. bootstrap은 dev.caskeleton.bootstrap에 있고, domain/application/adapter/shared package를 component scan 대상으로 명시합니다. module은 domain-core, application-core, adapter-web, adapter-persistence-rdbms, adapter-persistence-postgresql, adapter-outbound, adapter-identifier, shared-contract, app-bootstrap, sample-portfolio로 나뉘어 있습니다. project canonical의 최초 slice는 8개 module blueprint였고, 이후 다른 slice에서 identifier와 persistence 세분화가 추가됐습니다. 그래서 이 글에서는 “현재 HEAD의 module 수”와 “그 slice가 검증한 결정”을 섞어 말하지 않습니다.

Gradle 쪽 핵심은 verifyCleanArchitectureDependencies입니다. 이 task는 module별 허용 dependency를 whitelist로 들고 있다가, 허용되지 않은 project() dependency가 들어오면 실패합니다. 예를 들어 web adapter가 persistence adapter나 outbound adapter를 직접 의존하면 안 됩니다. app-bootstrap은 composition root라 여러 module을 조립할 수 있지만, production code가 sample-portfolio에 의존하는 것은 금지됩니다. sample은 학습과 fixture 역할을 하는 소비자 module이지 production core가 기대는 기반 module이 아니기 때문입니다.

ArchUnit 쪽 핵심은 import 방향입니다. domain_is_pure rule은 domain package가 Spring, JPA, Hibernate, Lombok, application, adapter, bootstrap에 의존하지 못하게 합니다. application package도 adapter, bootstrap, persistence, Spring Web, Hibernate에 의존하지 못합니다. application에서 Spring @Transactional을 직접 쓰지 못하게 막는 rule도 여기에 놓여 있습니다. transaction 자체를 다루지 않는다는 뜻이 아니라, 그 책임을 TransactionPort 같은 application port로 드러내겠다는 뜻입니다.

adapter 간 직접 의존도 막습니다. web adapter가 persistence/outbound adapter를 직접 알면, controller가 DB나 외부 client 세부 구현을 우회할 수 있습니다. persistence adapter가 web adapter를 알면, 저장소 계층이 transport 모양을 알게 됩니다. outbound adapter가 persistence adapter를 직접 알면, 외부 호출과 저장소 구현이 서로 엮입니다. ca-tmpl은 이런 adapter 간 연결을 application/domain/shared contract를 통해서만 흐르게 하려 합니다.

shared-contract도 별도 경계가 있습니다. 이름이 shared라고 해서 아무 공통 코드를 넣는 곳이 아닙니다. ca-tmpl에서는 response, request, error, headers, logging, tracing, metrics, registry, annotation 같은 operational contract package만 허용합니다. business concept가 shared로 들어오면 여러 domain이 같은 이름의 공통 모델에 묶이기 쉽습니다. 그래서 shared는 편의 package가 아니라 운영 계약의 제한된 통로로 둡니다.

또 하나 중요한 장치는 violations-as-data입니다. ArchUnit rule은 매칭 대상이 비어 있으면 의미 없이 green이 될 수 있습니다. ca-tmpl은 의도적으로 잘못된 fixture class를 test tree에 두고, rule이 그 위반을 실제로 잡는지 확인합니다. 이렇게 하면 rule 이름만 있고 아무 것도 검사하지 않는 상태를 줄일 수 있습니다. 이것은 architecture test 자체를 테스트하는 장치입니다.

다만 이 구조도 만능은 아닙니다. ArchUnit은 bytecode에 남는 import, call, annotation을 잘 잡지만, string-key ApplicationContext.getBean(String), Class.forName(String) 같은 reflection-style 우회는 정적으로 잡기 어렵습니다. 또한 운영 배포나 장기 유지보수 효과 측정은 없습니다. 이 글에서 말할 수 있는 범위는 ca-tmpl repository에서 구현됐고, 로컬/dev 검증으로 확인된 module/package boundary까지입니다.

정리하면 ca-tmpl의 Clean Architecture package layout은 “도메인, 애플리케이션, 어댑터로 나눴다”가 핵심이 아닙니다. 핵심은 그 나눔을 Gradle dependency matrix와 ArchUnit fitness function으로 계속 확인한다는 점입니다. skeleton은 한 번 예쁘게 만든 구조보다, 새 도메인을 추가하는 사람이 실수했을 때 어디서 잘못됐는지 알려주는 구조여야 합니다.

코드 예제 / Code samples (있다면)

// 출처: [[wiki/projects/ca-tmpl/clean-architecture-package-layout]]
// 실제 파일: settings.gradle, ca-tmpl @f6fbd4e196b4
include 'app-bootstrap'
include 'domain-core'
include 'application-core'
include 'adapter-web'
include 'adapter-persistence-rdbms'
include 'adapter-persistence-postgresql'
include 'adapter-outbound'
include 'adapter-identifier'
include 'shared-contract'
include 'sample-portfolio'
// 출처: [[wiki/projects/ca-tmpl/clean-architecture-package-layout]]
// 실제 파일: app-bootstrap/.../CaSkeletonApplication.java, ca-tmpl @f6fbd4e196b4
@SpringBootApplication(
    scanBasePackages = {
      "dev.caskeleton.bootstrap",
      "dev.caskeleton.adapter",
      "dev.caskeleton.application",
      "dev.caskeleton.domain",
      "dev.caskeleton.shared"
    })
public class CaSkeletonApplication {
  public static void main(String[] args) {
    SpringApplication.run(CaSkeletonApplication.class, args);
  }
}
// 출처: [[wiki/projects/ca-tmpl/clean-architecture-package-layout]]
// 실제 파일: build.gradle, ca-tmpl @f6fbd4e196b4
tasks.register('verifyCleanArchitectureDependencies') {
    doLast {
        Map<String, Set<String>> allowedProjectDependencies = [
                'domain-core'      : ['shared-contract'] as Set,
                'application-core' : ['domain-core', 'shared-contract'] as Set,
                'adapter-web'      : ['application-core', 'domain-core', 'shared-contract'] as Set,
                'adapter-outbound' : ['application-core', 'domain-core', 'shared-contract'] as Set,
                'shared-contract'  : [] as Set
        ]
        // 허용되지 않은 ProjectDependency가 있으면 GradleException을 던진다.
    }
}
// 출처: [[wiki/projects/ca-tmpl/clean-architecture-package-layout]]
// 실제 파일: app-bootstrap/.../CleanArchitectureTest.java, ca-tmpl @f6fbd4e196b4
@ArchTest
static final ArchRule DOMAIN_IS_PURE =
    noClasses()
        .that()
        .resideInAPackage("..domain..")
        .should()
        .dependOnClassesThat()
        .resideInAnyPackage(
            "org.springframework..",
            "jakarta.persistence..",
            "org.hibernate..",
            "lombok..",
            "..application..",
            "..adapter..",
            "..bootstrap..")
        .allowEmptyShould(true);
// 출처: [[wiki/projects/ca-tmpl/clean-architecture-package-layout]]
// 실제 파일: app-bootstrap/.../CleanArchitectureTest.java, ca-tmpl @f6fbd4e196b4
@ArchTest
static final ArchRule PRODUCTION_CODE_DOES_NOT_DEPEND_ON_SAMPLE_PORTFOLIO =
    noClasses()
        .that()
        .resideOutsideOfPackage("..sample.portfolio..")
        .should()
        .dependOnClassesThat()
        .resideInAPackage("..sample.portfolio..");
// 출처: [[wiki/projects/ca-tmpl/clean-architecture-package-layout]]
// 실제 파일: app-bootstrap/.../ArchitectureViolationFixtureTest.java, ca-tmpl @f6fbd4e196b4
class ArchitectureViolationFixtureTest {
  private static final JavaClasses VIOLATION_CLASSES =
      new ClassFileImporter().importPackages("dev.caskeleton.bootstrap.architecture.violations");

  // intentional fixture classes를 읽어 각 ArchUnit rule이 실제 위반을 잡는지 확인한다.
}

Sources / 근거 (canonical 인용 필수, derived layer 의무)

사실 vs 의견 / Fact vs opinion 구분

  • 사실: ca-tmpl에는 Gradle module dependency matrix, CleanArchitectureTest, ArchitectureViolationFixtureTest, production → sample dependency ban, shared-contract package scope rule이 존재한다. 근거: wiki/projects/ca-tmpl/clean-architecture-package-layout
  • 사실: 검증 범위는 local/dev이며, 운영 배포나 운영 metric 검증은 없다. 근거: wiki/projects/ca-tmpl/clean-architecture-package-layout
  • 의견: package layout은 문서보다 build-time guardrail과 함께 있을 때 skeleton 학습 효과가 커진다.
  • 알지 못하는 것: 운영 조직에서 이 구조가 장기 유지보수 비용을 얼마나 줄였는지는 측정하지 않았다.

답할 수 있는 범위 / Answer boundary

  • 자신 있게 답할 수 있는 후속 질문:
    • 왜 Gradle multi-module을 1차 boundary로 두었는가?
    • Gradle dependency matrix와 ArchUnit rule은 각각 무엇을 막는가?
    • sample-portfolio를 production code가 의존하지 못하게 한 이유는 무엇인가?
    • violations-as-data fixture가 왜 필요한가?
  • 다음 글로 넘길 부분:
    • Spring Modulith 도입 여부.
    • 대규모 도메인에서 feature module을 더 쪼개는 전략.
    • runtime lookup/reflection 우회를 자동으로 잡는 방법.

게시 체크리스트 / Publish checklist

  • 모든 사실 주장에 canonical 링크 있음
  • 사실 vs 의견 분리 명시됨
  • 금지 마케팅 표현 없음
  • 코드 예제 출처 명시
  • 타깃 독자 가정과 톤 일치
  • /lint 통과
  • 게시 URL 기록 (게시 후):