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 | ||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Transaction을 Annotation이 아니라 Application Port로 다루기 | blog | verified | high |
|
|
2026-07-02 |
|
backend-engineer | 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
- transaction boundary는 use case 책임이다.
- Spring annotation을 core에 두지 않는 이유.
TransactionPort.inRead/inWrite류의 모델.- propagation/isolation의 owner 분리.
- 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 (있다면)
// 출처: [[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) { ... }
}
// 출처: [[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);
}
}
// 출처: [[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;
}
// 출처: [[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;
});
}
}
// 출처: [[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);
// 출처: [[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 통합 검증,
readOnlyflush-mode 측정,inNewconnection 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를 어디까지 강제하는가?
- 왜 application core에
- 다음 글로 넘길 부분:
- vendor-specific isolation tuning.
- 실 DB/Testcontainers 기반
readOnly/REQUIRES_NEW동작 검증. - outbox와 transaction boundary의 통합 검증.
게시 체크리스트 / Publish checklist
- 모든 사실 주장에 canonical 링크 있음
- 사실 vs 의견 분리 명시됨
- 금지 마케팅 표현 없음
- 코드 예제 출처 명시
- 타깃 독자 가정과 톤 일치
/lint통과- 게시 URL 기록 (게시 후):