202 lines
13 KiB
Markdown
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]]
|