--- title: Transaction을 Annotation이 아니라 Application Port로 다루기 source_type: blog status: verified confidence: high tags: [blog, ca-tmpl, transaction, clean-architecture] related_projects: [ca-tmpl] last_reviewed: 2026-07-02 canonical_sources: - wiki/projects/ca-tmpl/transaction-boundary-abstraction audience: backend-engineer target_publish: status_label: ready --- # Transaction을 Annotation이 아니라 Application Port로 다루기 ## Parent / 부모 (필수) - 핵심 canonical: [[wiki/projects/ca-tmpl/transaction-boundary-abstraction]] — ca-tmpl `TransactionPort`, Spring adapter, ArchUnit rule, local verification 범위. - 관련 개념 문서: [[wiki/concepts/transaction-boundary-abstraction]] — Spring transaction boundary 대안 비교. 현재 concept 문서는 `draft`이므로 이 글의 구현 사실 근거는 verified project canonical에 둔다. ## 타깃 독자 / Target reader - 독자 profile: Clean Architecture에서 transaction boundary를 application layer에 어떻게 둘지 고민하는 백엔드 엔지니어. - 이미 안다고 가정하는 것: `@Transactional`, propagation, read/write transaction. - 처음 듣는다고 가정하는 것: `TransactionPort`로 framework 의존을 adapter에 밀어내는 방식. ## 도입 / Hook - 문제 / 궁금증: `@Transactional`은 편하지만 application core가 Spring에 묶일 수 있다. - 이 글이 답하는 것: ca-tmpl이 transaction boundary를 port로 추상화하고 어떤 범위를 검증했는지. - 이 글이 답하지 않는 것: 모든 DB vendor isolation tuning. ## 본문 outline / Body outline 1. transaction boundary는 use case 책임이다. 2. Spring annotation을 core에 두지 않는 이유. 3. `TransactionPort.inRead`/`inWrite` 류의 모델. 4. propagation/isolation의 owner 분리. 5. local verification과 운영 DB 검증 경계. ## 본문 / Body Spring Boot에서 transaction을 다루는 가장 익숙한 방법은 `@Transactional`입니다. service method에 annotation을 붙이면 Spring AOP proxy가 method 호출을 감싸고, commit과 rollback을 처리합니다. 실무에서 널리 쓰이고, 단순 CRUD에서는 이 방식이 가장 읽기 쉽습니다. 그런데 Clean Architecture 관점에서는 질문이 하나 생깁니다. application layer가 Spring transaction annotation을 직접 import해도 괜찮은가? ca-tmpl은 이 질문에 대해 보수적인 답을 택했습니다. application core가 Spring transaction API를 직접 알지 않도록 `TransactionPort`를 두고, 실제 Spring transaction 실행은 persistence adapter의 `SpringTransactionPort`가 맡게 했습니다. 여기서 핵심은 `@Transactional`이 나쁘다는 주장이 아닙니다. Spring의 declarative transaction은 표준적이고 좋은 도구입니다. 다만 ca-tmpl은 skeleton template입니다. skeleton은 새 프로젝트가 어떤 adapter와 운영 계약을 붙이더라도 application core의 dependency direction이 유지되어야 합니다. 그래서 transaction도 repository나 HTTP client처럼 port 뒤로 밀어내는 쪽을 선택했습니다. `TransactionPort`의 표면은 작습니다. write use case는 `inWrite`, read use case는 `inRead`, 독립 commit이 필요한 outbox/audit/compensation 흐름은 `inNew`를 사용합니다. callback은 `Supplier` 또는 `Runnable`입니다. checked exception을 port signature에 노출하지 않고, runtime exception은 Spring transaction template을 통해 rollback되고 다시 전파됩니다. 이 API만 보면 application은 Spring의 propagation enum이나 `TransactionTemplate`을 알 필요가 없습니다. Spring 구현체는 adapter-persistence 쪽에 있습니다. 현재 코드에서는 `SpringTransactionPort`가 `PlatformTransactionManager`를 주입받고, write/read/requires-new용 `TransactionTemplate`을 미리 만들어 둡니다. write는 `PROPAGATION_REQUIRED` + readOnly false, read는 `PROPAGATION_REQUIRED` + readOnly true, requires-new는 `PROPAGATION_REQUIRES_NEW` + readOnly false입니다. 모두 `ISOLATION_READ_COMMITTED`를 명시합니다. 미리 만들어 둔 template을 쓰는 이유도 중요합니다. `TransactionTemplate`은 설정을 가진 객체입니다. 호출할 때마다 같은 template의 propagation/readOnly/isolation을 바꾸는 방식은 동시성 상황에서 읽기 어려운 race를 만들 수 있습니다. ca-tmpl은 mode별 template을 분리해서 “이 method는 어떤 transaction mode로 실행되는가”를 코드 구조로 고정합니다. use case 쪽에서는 `@UseCaseCapability`가 같이 등장합니다. 이 annotation은 use case의 transaction mode, idempotency, repository access, 외부 outbound 허용 여부를 드러냅니다. 그러면 class 이름이나 body를 끝까지 읽지 않아도 이 use case가 read인지 write인지, repository를 쓰는지, 외부 호출을 하는지 볼 수 있습니다. 그리고 ArchUnit은 이 선언과 실제 `TransactionPort` 호출이 맞는지 검사합니다. 예를 들어 `CreateWorkLogUseCase`는 `transactionMode = WRITE`, `repositoryAccess = WRITE_REPOSITORY`를 선언하고 `tx.inWrite(...)` 안에서 aggregate 저장과 outbox append를 함께 수행합니다. 반대로 query use case는 `tx.inRead(...)`를 사용합니다. ca-tmpl은 application package에서 `org.springframework.transaction.annotation.Transactional`에 의존하는 것도 ArchUnit으로 막습니다. 즉 annotation을 몰래 붙여서 port를 우회하는 경로를 build-time에 차단합니다. 하지만 `TransactionPort`가 항상 더 좋은 선택이라는 뜻은 아닙니다. framework 교체 가능성이 낮고, 팀이 Spring transaction에 익숙하며, 대부분이 단순 CRUD라면 `@Transactional`을 직접 쓰는 편이 더 단순합니다. `TransactionPort`는 interface, adapter 구현, rule, test를 추가합니다. 이 비용은 skeleton처럼 경계를 학습하고 재사용해야 하는 프로젝트에서는 설명 가능하지만, 모든 팀의 기본값이 될 필요는 없습니다. 검증 범위도 분명히 나눠야 합니다. ca-tmpl에는 `TransactionPort`, `SpringTransactionPort`, capability annotation, ArchUnit rule, unit test가 존재하고 로컬/dev 수준으로 검증됐습니다. 하지만 운영 배포는 없고, 실 DB connection에서 `readOnly`가 flush mode를 어떻게 바꾸는지 측정한 자료도 없습니다. `REQUIRES_NEW`가 outbox/audit에서 실제 connection pool을 얼마나 쓰는지도 별도 통합 검증 대상입니다. 정리하면 ca-tmpl의 transaction boundary 결정은 “Spring을 쓰지 않겠다”가 아닙니다. Spring transaction은 adapter에서 사용합니다. 대신 application core는 “나는 read transaction이 필요하다”, “나는 write transaction이 필요하다”라는 의도만 port로 말합니다. 이 작은 우회 덕분에 application layer의 dependency rule, use case capability, ArchUnit fitness function이 한 줄로 이어집니다. ## 코드 예제 / Code samples (있다면) ```java // 출처: [[wiki/projects/ca-tmpl/transaction-boundary-abstraction]] // 실제 파일: application-core/.../TransactionPort.java, ca-tmpl @f6fbd4e196b4 public interface TransactionPort { T inWrite(Supplier action); T inRead(Supplier action); T inNew(Supplier action); default void inWrite(Runnable action) { ... } default void inRead(Runnable action) { ... } default void inNew(Runnable action) { ... } } ``` ```java // 출처: [[wiki/projects/ca-tmpl/transaction-boundary-abstraction]] // 실제 파일: adapter-persistence-rdbms/.../SpringTransactionPort.java, ca-tmpl @f6fbd4e196b4 @Component public class SpringTransactionPort implements TransactionPort { private final TransactionTemplate writeTemplate; private final TransactionTemplate readTemplate; private final TransactionTemplate requiresNewTemplate; public SpringTransactionPort(PlatformTransactionManager transactionManager) { this.writeTemplate = template(transactionManager, TransactionMode.WRITE, TransactionDefinition.PROPAGATION_REQUIRED, false); this.readTemplate = template(transactionManager, TransactionMode.READ_ONLY, TransactionDefinition.PROPAGATION_REQUIRED, true); this.requiresNewTemplate = template(transactionManager, TransactionMode.REQUIRES_NEW, TransactionDefinition.PROPAGATION_REQUIRES_NEW, false); } } ``` ```java // 출처: [[wiki/projects/ca-tmpl/transaction-boundary-abstraction]] // 실제 파일: application-core/.../UseCaseCapability.java, ca-tmpl @f6fbd4e196b4 @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) public @interface UseCaseCapability { TransactionMode transactionMode(); Idempotency idempotency(); RepositoryAccess repositoryAccess(); boolean externalOutboundAllowed() default false; } ``` ```java // 출처: [[wiki/projects/ca-tmpl/transaction-boundary-abstraction]] // 실제 파일: sample-portfolio/.../CreateWorkLogUseCase.java, ca-tmpl @f6fbd4e196b4 @UseCaseCapability( transactionMode = TransactionMode.WRITE, idempotency = Idempotency.NOT_IDEMPOTENT, repositoryAccess = RepositoryAccess.WRITE_REPOSITORY) public class CreateWorkLogUseCase implements CommandUseCase { @Override public WorkLog handle(CreateWorkLogCommand cmd) { return tx.inWrite( () -> { WorkLog saved = repository.save(...); appendReservedEvent(saved); return saved; }); } } ``` ```java // 출처: [[wiki/projects/ca-tmpl/transaction-boundary-abstraction]] // 실제 파일: app-bootstrap/.../CleanArchitectureTest.java, ca-tmpl @f6fbd4e196b4 @ArchTest static final ArchRule APPLICATION_DOES_NOT_USE_SPRING_TRANSACTIONAL_ANNOTATION = noClasses() .that() .resideInAPackage("..application..") .should() .dependOnClassesThat() .haveFullyQualifiedName("org.springframework.transaction.annotation.Transactional") .as("application package must use TransactionPort instead of @Transactional") .allowEmptyShould(true); ``` ```java // 출처: [[wiki/projects/ca-tmpl/transaction-boundary-abstraction]] // 실제 파일: app-bootstrap/.../CleanArchitectureTest.java, ca-tmpl @f6fbd4e196b4 @ArchTest static final ArchRule USE_CASE_CAPABILITY_MATCHES_TRANSACTION_PORT_BOUNDARY = classes() .that() .areAnnotatedWith(UseCaseCapability.class) .should(callTransactionPortMethodRequiredByCapability()) .as("READ_REPOSITORY+READ_ONLY -> inRead, WRITE_REPOSITORY+WRITE -> inWrite"); ``` ## Sources / 근거 (canonical 인용 필수, derived layer 의무) - [[wiki/projects/ca-tmpl/transaction-boundary-abstraction]] — 이 글의 1차 canonical. `TransactionPort`, Spring 구현체, ArchUnit rule, unit/local verification, planned 항목과 과장 금지 경계를 따른다. - [[wiki/concepts/transaction-boundary-abstraction]] — 관련 개념 문서. 현재 `draft`이므로 구현 사실의 출처로 쓰지 않는다. ## 사실 vs 의견 / Fact vs opinion 구분 - 사실: ca-tmpl에는 `TransactionPort`, `TransactionMode`, `Isolation`, `UseCaseCapability`, `SpringTransactionPort`, transaction 관련 ArchUnit rule과 unit test가 존재한다. 근거: [[wiki/projects/ca-tmpl/transaction-boundary-abstraction]] - 사실: application package에서 Spring `@Transactional` 의존을 금지하는 ArchUnit rule이 존재한다. 근거: [[wiki/projects/ca-tmpl/transaction-boundary-abstraction]] - 사실: 운영 배포, 실 DB 통합 검증, `readOnly` flush-mode 측정, `inNew` connection pool 실측은 없다. 근거: [[wiki/projects/ca-tmpl/transaction-boundary-abstraction]] - 의견: skeleton template에서는 `@Transactional` 직접 부착보다 port 기반 경계가 학습과 검증에 유리할 수 있다. - 알지 못하는 것: production lock/contention behavior, 실제 DB vendor별 성능 차이. ## 답할 수 있는 범위 / Answer boundary - 자신 있게 답할 수 있는 후속 질문: - 왜 application core에 `@Transactional`을 직접 두지 않았는가? - `TransactionPort.inWrite`/`inRead`/`inNew`는 각각 어떤 의도를 표현하는가? - `SpringTransactionPort`가 mode별 `TransactionTemplate`을 미리 만드는 이유는 무엇인가? - ArchUnit은 transaction boundary를 어디까지 강제하는가? - 다음 글로 넘길 부분: - vendor-specific isolation tuning. - 실 DB/Testcontainers 기반 `readOnly`/`REQUIRES_NEW` 동작 검증. - outbox와 transaction boundary의 통합 검증. ## 게시 체크리스트 / Publish checklist - [x] 모든 사실 주장에 canonical 링크 있음 - [x] 사실 vs 의견 분리 명시됨 - [x] 금지 마케팅 표현 없음 - [x] 코드 예제 출처 명시 - [x] 타깃 독자 가정과 톤 일치 - [x] `/lint` 통과 - [ ] 게시 URL 기록 (게시 후): ## Related / 관련 - 후속 글 후보: [[wiki/blog/ca-tmpl-transactional-outbox-pattern-2026-07-02]] - 관련 개념 문서: [[wiki/concepts/transaction-boundary-abstraction]]