Files
llm-wiki/raw/blog-topics/transaction-port-abstraction-over-spring-transactional-2026-05-28.md
T

128 lines
14 KiB
Markdown

---
title: blog-topic / transaction-port-abstraction-over-spring-transactional-2026-05-28
source_type: blog-topic
status: raw
related_branches: [feature-application-port-usecase-contract]
related_projects: [ca-skeleton]
tags: [blog-topic, ca-skeleton, transaction, hexagonal, spring, transactional, transaction-port, archunit]
created: 2026-05-28
status_label: ready-for-canonical
target_audience: backend-engineer
inspiration_url:
archive_url:
---
# blog-topic: transaction-port-abstraction-over-spring-transactional-2026-05-28
> Layer: `raw/blog-topics/` — 채용공고가 아닌 작업·학습·트러블슈팅에서 나온 블로그 글감 원석. canonical 정제 전 raw 후보이며, `wiki/blog/` 직접 생성 근거가 아니다.
## Parent / 부모
- [[raw/branch-notes/feature-application-port-usecase-contract]] — `TransactionPort` 도입 + ArchUnit fitness function + `sample-ticket` 마이그레이션의 실 구현 (D3, Decisions 2026-05-28).
- [[raw/project-notes/ca-skeleton-operational-contract]] — ca-tmpl skeleton 의 운영 계약 맥락.
## 트리거 / Trigger
- 트리거 유형: `branch-work`
- 트리거 날짜: 2026-05-28
- 트리거 연결 노트: [[raw/branch-notes/feature-application-port-usecase-contract]] — branch-note 의 D3 ("transaction boundary 는 application use case 책임이지만 Spring `@Transactional` 직접 import 는 금지하고 `TransactionPort` 또는 `TransactionalUseCaseRunner` abstraction 을 기본값으로") 를 실제 코드로 옮긴 작업이 글감의 핵심.
## 글감 / Topic seed
- 한 문장 요지: application 계층이 `@Transactional`_직접 import_ 하지 않도록 `TransactionPort` 같은 추상화를 두는 선택은 Hexagonal 의 소수파 패턴이지만, **ArchUnit fitness function 과 결합하면** framework leakage drift 를 _실제로_ 막을 수 있다 — 추상은 그 자체가 아니라 _enforce 되는_ 추상이 의미를 갖는다.
- 떠오른 계기: `feature-application-port-usecase-contract` 작업에서 `TransactionPort` 를 정의하고 ArchUnit 의 `application_does_not_use_spring_transactional_annotation` 으로 `@Transactional` 직접 import 를 차단, 동시에 `sample-ticket` 의 기존 `@Transactional` 사용을 모두 `tx.inWrite` / `tx.inRead` 로 마이그레이션한 경험.
- 예상 제목 후보:
- application 계층에서 `@Transactional` 을 떼어내기 — TransactionPort 와 ArchUnit fitness function
- Hexagonal 다수파 vs 소수파 — 트랜잭션 경계 추상화의 비용과 이득
- `@Transactional` 직접 부착 vs TransactionPort — ca-skeleton 사례
## 핵심 주장 후보 / Claim candidates
> 각 항목은 branch-note Decision ID 또는 외부 source claim ID 로 근거를 인용한다.
- 사실 후보:
- ca-tmpl 의 `application-core` 모듈은 Gradle 의존성에서 `spring-tx`_제거_ 해서 `@Transactional` 어노테이션이 컴파일 classpath 에서 _reach 불가능_ — 근거: `feature-application-port-usecase-contract.md` Decisions 2026-05-28 ("`application-core` Gradle 의 `spring-tx` 의존성 제거. `@Transactional` 어노테이션이 모듈 컴파일 classpath 자체에 없도록 belt+suspenders") + 구현 결과 §application-core build.gradle.
- `TransactionPort``inWrite` / `inRead` / `inNew` 3개 메서드만 노출하고 `NESTED` / `NEVER` propagation 은 _의도적_ 미노출 — 근거: 동일 branch-note Decisions 2026-05-28 ("`TransactionPort` API 는 `inWrite` / `inRead` / `inNew` 3 메서드. `NESTED``NEVER` 는 API 에서 노출 안 함") + §TransactionPort Contract 표.
- `SpringTransactionPort` 는 mode 별로 미리 빌드된 `TransactionTemplate` 인스턴스 3개를 보관해서 호출 시점의 mutation 없이 _동시성 안전_ — 근거: 동일 Decisions ("모드별 `TransactionTemplate` 인스턴스 3개를 미리 빌드해서 보관. `setReadOnly` / `setPropagationBehavior` 의 호출당 mutation 으로 인한 동시성 위험 차단").
- ArchUnit `noClasses().that().resideInAPackage("..application..").should().dependOnClassesThat().haveFullyQualifiedName("org.springframework.transaction.annotation.Transactional")` 으로 application 의 `@Transactional` import 를 자동 차단 — 근거: 동일 branch-note Claims to Verify 표의 "application package의 ArchUnit rule이 `org.springframework.transaction.annotation.Transactional` import를 실제로 catch" 행 → `actually-implemented` (2026-05-28).
- `Isolation` enum 은 `READ_COMMITTED` 만 노출하고 `REPEATABLE_READ` / `SERIALIZABLE``feature-transaction-concurrency-contract` 로 위임 — 근거: 동일 Decisions ("`Isolation` enum 은 `READ_COMMITTED` 만 노출").
- Spring `@Transactional` AOP proxy 의 _self-invocation_ 함정: 같은 클래스 내 `this.otherMethod()` 호출 시 proxy 우회 — 근거: `raw/official-docs/at-transactional-spring-official.md#AT-TX-C5`.
- `readOnly = true` 는 REQUIRED / REQUIRES_NEW propagation 에 한정 적용 — 근거: `raw/official-docs/spring-tx-management-reference.md#SPRING-TX-MGR-C6`. ca-tmpl 의 `TransactionPort.inRead` 도 이 제약을 따라 REQUIRED + readOnly 로 구현.
- 경험 후보:
- 기존 `sample-ticket` 의 aggregate Service (`UserService` / `PostService`) 의 `@Transactional(readOnly=true)` class-level + `@Transactional` method-level 패턴을 `tx.inRead(() -> ...)` / `tx.inWrite(() -> ...)`_일괄 마이그레이션_. 동작 동등하지만 application 의 Spring 의존성 surface 가 감소 — 근거: `feature-application-port-usecase-contract.md` 구현 결과 §sample-ticket.
- ArchUnit rule 을 추가했지만 `app-bootstrap` 의 test classpath 가 `sample-ticket` 을 포함하지 않아서 _vacuously_ 통과한 사례. `testImplementation project(':sample-ticket')` 으로 test-only 의존을 추가해 해결 — 근거: 파생 에러 [[raw/errors/archunit-test-scope-sample-ticket-inclusion-2026-05-28]].
- `Idempotency` enum 값을 `IDEMPOTENT` / `KEYED` / `NOT_IDEMPOTENT` 세 종으로 결정. `KEYED` 는 idempotency key 기반 dedup 필요 표시이고 후속 `feature-rate-limit-idempotency-contract` 가 이어받음 — 근거: `feature-application-port-usecase-contract.md` Decisions 2026-05-28.
- 의견 / 해석 후보:
- `TransactionPort` 추상의 _진짜 이득_ 은 testability 가 아니다 (`@Transactional` 메서드도 `@SpringBootTest` 로 잘 테스트됨). **Spring 의존을 단일 진입점 (`SpringTransactionPort`) 으로 좁힌다** — Spring 업그레이드 / multi-tenant / multi-DB 시 transaction 정책 변경 지점이 한 클래스로 집중됨.
- **boilerplate 증가는 사실** (메서드마다 `tx.inWrite(() -> { ... })` 한 단). 단일 DB / 단일 transactionManager 환경의 작은 팀은 다수파 (`@Transactional` 직접 부착, hexagonal-reflectoring) 가 reasonable.
- **추상은 _enforce 되는_ 추상이 의미를 갖는다**. ArchUnit fitness function 없이 `TransactionPort` 만 두면 _컨벤션_ 에 그치지만, fitness function 이 `@Transactional` import 를 _실패_ 시키면 추상이 _drift 방지 메커니즘_ 으로 작동.
- **`testImplementation project(':sample-ticket')` 으로 sample 을 test classpath 에만 두는 비대칭 의존**은 production drift 차단 (`production_code_does_not_depend_on_sample_ticket`) 과 ArchUnit scope 확장을 _양립_ 시키는 흥미로운 패턴 — 일반화 가능.
## Outline seed
> 각 섹션 옆에 `→ 핵심 메시지` 를 함께 명시한다.
1. 동기 — hexagonal / Clean Architecture 를 "했다" 면서 `@Transactional` 은 application 에 그대로 두는 일관성 누락 → **추상의 _enforceable_ 형태가 없으면 컨벤션이 무너진다.**
2. 다수파 입장의 합리성 — `@Transactional` 직접 부착 ([[raw/company-tech-blogs/hexagonal-reflectoring-transactional-placement]]) → **소수파 결정의 _대가_ 를 인정하고 시작.**
3. ca-tmpl 의 선택 — `TransactionPort` (UNIL / Vassilis Soum 류) + ArchUnit fitness function _결합_**두 요소가 함께 있을 때만 의미.**
4. 구현 스케치 — `TransactionPort` 의 3 메서드 한정 API → **`NESTED` / `NEVER` 미노출이 컨벤션이 아니라 API 표현.**
5. infrastructure 구현 — `SpringTransactionPort` 의 mode 별 pre-built `TransactionTemplate`**`setReadOnly` / `setPropagationBehavior`_호출당 mutation_ 회피.**
6. belt+suspenders — `application-core` Gradle 에서 `spring-tx` 제거 → **컴파일 classpath 에서 reach 불가능 + ArchUnit 양쪽으로 막음.**
7. ArchUnit scope 의 함정 — `app-bootstrap` test classpath 가 `sample-ticket` 을 안 보던 문제 → **`testImplementation project(':sample-ticket')` 의 비대칭 의존.**
8. 마이그레이션 결과 — `sample-ticket``@Transactional` 0개. `tx.inWrite` / `tx.inRead` 로 일괄 전환 → **동작 동등 + Spring 의존 surface 감소.**
9. 한계 / 미해결 — `REQUIRES_NEW` 실 outbox 통합 검증 미수행, `noRollbackFor` / `timeout` 미지원, `externalOutboundAllowed` dependency-aware rule 미작성 → **추상이 _모든_ Spring 표현력을 capture 하는 게 아니라는 정직함.**
10. 정리 — 추상은 그 자체로 가치 있는 게 아니라, _enforce 되는_ 추상이 의미를 갖는다 → **fitness function 결합이 본 글의 진짜 thesis.**
## Canonical 전환 후보 / Canonical extraction candidates
- `wiki/projects/ca-tmpl/transaction-port-contract.md` 후보:
- `TransactionPort` 의 3 메서드 + `Isolation.READ_COMMITTED` 만 노출 + `NESTED` / `NEVER` 차단.
- `SpringTransactionPort` 의 mode 별 pre-built template 패턴.
- `application-core``spring-tx` 제거 + ArchUnit fitness function 의 belt+suspenders.
- sample-ticket 마이그레이션 결과 (Before / After 코드).
- `wiki/concepts/transaction-boundary-abstraction.md` 후보:
- 다수파 (`@Transactional` 직접 부착) vs 소수파 (`TransactionPort`) 의 trade-off 표.
- `TransactionTemplate` vs `@Transactional` AOP proxy 의 self-invocation 차이.
- "추상은 enforce 되는 추상이 의미를 갖는다" 의 일반 원칙.
- 필요한 추가 검증:
- `REQUIRES_NEW` 의 실 outbox 동작 통합 테스트 (Testcontainers, `feature-domain-event-outbox-contract` 로 위임).
- `readOnly = true` 의 Hibernate flush-mode 측정 PoC (`feature-application-port-usecase-contract.md` Claims to Verify 의 `planned`).
- `*Port` outbound naming rule + `externalOutboundAllowed` dependency-aware rule (outbound port marker 정의 필요).
## Sources / 근거 후보
- [[raw/branch-notes/feature-application-port-usecase-contract]] — 결정 D1~D10 + 2026-05-28 구현 결과 + Claims to Verify status.
- [[raw/branch-notes/feature-architecture-enforcement-rules]] — 자매 결정 (D8: application `@Transactional` 직접 import 금지) 의 _자동 검증_ 자매.
- [[raw/errors/archunit-test-scope-sample-ticket-inclusion-2026-05-28]] — ArchUnit scope 의 test-classpath 함정.
- [[raw/interviews/transaction-port-vs-spring-transactional]] — 같은 주제의 면접 질문 노트.
- [[raw/official-docs/at-transactional-spring-official]] — `@Transactional` 공식 + self-invocation 함정 (`AT-TX-C1`, `AT-TX-C5`).
- [[raw/official-docs/spring-tx-management-reference]] — Spring transaction abstraction (`SPRING-TX-MGR-C3`, `SPRING-TX-MGR-C6`).
- [[raw/official-docs/transaction-template-spring-official]] — `TransactionTemplate` programmatic 공식 (`TX-TMPL-C1` ~ `TX-TMPL-C4`).
- [[raw/company-tech-blogs/transaction-port-clean-ddd-spring-medium]] — UNIL TransactionPort 진화 사례 (`UNIL-TX-C1`, `UNIL-TX-C2`).
- [[raw/company-tech-blogs/transaction-port-vassilis-soum-github-readme]] — Vassilis Soum 의 TransactionPort 참고 구현 (`VSOUM-TX-C1` ~ `VSOUM-TX-C4`).
- [[raw/company-tech-blogs/hexagonal-reflectoring-transactional-placement]] — `@Transactional` 직접 부착 다수파 (`HEX-REFL-C1`, `HEX-REFL-C5`).
- [[raw/blog-topics/clean-architecture-boundary-enforcement-2026-05-28]] — ArchUnit fitness function 의 자매 글감.
## 미해결 / Unknown
- 아직 확인해야 할 사실: `TransactionPort``noRollbackFor` / `timeout` / `transactionManager` (multi-DB) 등 Spring 표현력 전체를 capture 가능한지 — 현재는 _하지 않음_ (의도적 한정).
- 아직 확인해야 할 사실: `REQUIRES_NEW` 가 실제 outbox / audit row 시나리오에서 _독립 commit_ 되는지 통합 테스트 미수행.
- 아직 확인해야 할 사실: `TransactionTemplate` 기반 구현이 self-invocation 함정에서 _완전히_ 자유로운지 (port 메서드가 다른 port 메서드를 호출하는 경우 PoC 필요).
- 과장하면 안 되는 부분: 본 글의 모든 검증은 **`locally-verified`** 이고 prod 운영 없음.
- 과장하면 안 되는 부분: `TransactionPort` 가 다수파 (`@Transactional` 직접) 보다 _우월_ 하다는 식 금지. _이 맥락 (template repository, 격리 우선)_ 의 선택까지만.
- 블로그로 쓰기 전에 필요한 canonical 정제: `wiki/projects/ca-tmpl/transaction-port-contract.md` 정제 + outbox 통합 검증 1 사례 추가 후 `expanded`.
## Decision / 처리 결정
- 액션: `promote-to-canonical`
- 이유: `wiki/projects/ca-tmpl/transaction-boundary-abstraction.md` 에 TransactionPort abstraction 글감으로 반영했다.
- 다음 단계: source canonical이 `verified` 상태이므로 이후 `blogify` 대상으로 삼을 수 있다. 단 REQUIRES_NEW/outbox 실 DB 통합 검증 부재와 소수파 선택이라는 경계를 유지한다.
## Related / 관련
- 관련 branch: [[raw/branch-notes/feature-application-port-usecase-contract]] (본 결정의 SSOT), [[raw/branch-notes/feature-architecture-enforcement-rules]] (자매 — `@Transactional` 금지 rule), [[raw/branch-notes/feature-domain-event-outbox-contract]] (후속 — `REQUIRES_NEW` 의 실 사용처).
- 관련 errors: [[raw/errors/archunit-test-scope-sample-ticket-inclusion-2026-05-28]].
- 관련 interview prep: [[raw/interviews/transaction-port-vs-spring-transactional]] (같은 주제의 자매), [[raw/interviews/clean-architecture-boundary-enforcement]] (ArchUnit 자매).
- 관련 blog topics: [[raw/blog-topics/clean-architecture-boundary-enforcement-2026-05-28]] (자매 글감 — boundary 강제), [[raw/blog-topics/clean-architecture-module-blueprint-2026-05-28]] (자매 글감 — module 분리), [[raw/blog-topics/post-implementation-knowledge-capture-workflow-2026-05-28]] (캡처 워크플로우 자매).
- derived blog: 생성 전. 생성 시 `wiki/blog/transaction-port-abstraction-over-spring-transactional-YYYY-MM-DD.md` 후보.