Files
llm-wiki/wiki/blog/ca-tmpl-transaction-boundary-abstraction-2026-07-02.md

202 lines
13 KiB
Markdown

---
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<T>` 또는 `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> T inWrite(Supplier<T> action);
<T> T inRead(Supplier<T> action);
<T> T inNew(Supplier<T> 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<CreateWorkLogCommand, WorkLog> {
@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]]