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

14 KiB

title, source_type, status, related_branches, related_projects, tags, created, status_label, target_audience, inspiration_url, archive_url
title source_type status related_branches related_projects tags created status_label target_audience inspiration_url archive_url
blog-topic / transaction-port-abstraction-over-spring-transactional-2026-05-28 blog-topic raw
feature-application-port-usecase-contract
ca-skeleton
blog-topic
ca-skeleton
transaction
hexagonal
spring
transactional
transaction-port
archunit
2026-05-28 ready-for-canonical backend-engineer

blog-topic: transaction-port-abstraction-over-spring-transactional-2026-05-28

Layer: raw/blog-topics/ — 채용공고가 아닌 작업·학습·트러블슈팅에서 나온 블로그 글감 원석. canonical 정제 전 raw 후보이며, wiki/blog/ 직접 생성 근거가 아니다.

Parent / 부모

트리거 / 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.
    • TransactionPortinWrite / inRead / inNew 3개 메서드만 노출하고 NESTED / NEVER propagation 은 의도적 미노출 — 근거: 동일 branch-note Decisions 2026-05-28 ("TransactionPort API 는 inWrite / inRead / inNew 3 메서드. NESTEDNEVER 는 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 / SERIALIZABLEfeature-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 TransactionTemplatesetReadOnly / 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-corespring-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 / 근거 후보

미해결 / Unknown

  • 아직 확인해야 할 사실: TransactionPortnoRollbackFor / 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 통합 검증 부재와 소수파 선택이라는 경계를 유지한다.