Files
llm-wiki/wiki/explainer/transaction-boundary-abstraction.md
T

221 lines
18 KiB
Markdown

---
title: (강사 설명) 트랜잭션 경계를 어디에 둘 것인가 — 5가지 답이 갈리는 진짜 이유
source_type: explainer
status: draft
confidence: medium
tags: [transaction, clean-architecture, spring]
related_projects: [ca-skeleton]
last_reviewed: 2026-06-04
---
# (강사 설명) 트랜잭션 경계를 어디에 둘 것인가 — 5가지 답이 갈리는 진짜 이유
> Layer: `wiki/explainer/` — **derived(파생) 교육 문서.** "나의 진짜 이해" 를 위한 1타강사 칠판이다.
> 정확한 사실·근거·검증 등급은 여기서 만들지 않는다. 전부 아래 canonical 에서 가져온다:
> - 개념·대안·근거: [[wiki/concepts/transaction-boundary-abstraction]]
> - 내 프로젝트 실제 구현·검증 범위: [[wiki/projects/ca-tmpl/transaction-boundary-abstraction]]
>
> 이 문서의 **비유는 의도적으로 부정확**하다 (이해를 위한 단순화). 비유를 사실로 인용하지 마라. 면접에서 말할 땐 위 canonical 의 표현을 써라.
---
## 0. 한 장면 — 5초 만에 고통 느끼기
`PostService.createPost()` 안에서 DB 에 두 번 쓴다.
```text
1) posts 테이블에 글 한 줄 INSERT ← 성공
2) tags 테이블에 태그 세 줄 INSERT ← 여기서 예외 펑!
```
자, 1번은 이미 커밋됐고 2번은 터졌다. 결과는? **태그 없는 반쪽짜리 글**이 DB 에 영원히 남는다. 누구도 지워주지 않는다.
이걸 막는 게 트랜잭션이다. "1번과 2번은 **한 묶음**. 둘 다 되든가, 둘 다 없던 일이 되든가." 은행 송금이랑 똑같다 — 내 계좌 -1만 원, 상대 계좌 +1만 원, 중간에 멈추면 돈이 증발한다. 그래서 "다 되거나 다 취소(rollback)" 로 묶는다.
**여기까진 아무도 이견이 없다.** 진짜 싸움은 다음 한 줄에서 시작된다:
> "그래서 이 '한 묶음' 의 시작과 끝을, **코드 어디에, 누가, 어떻게** 선언하지?"
5가지 답이 있다. 그리고 답이 갈리는 이유는 — 곧 보겠지만 — 사람마다 **무엇이 문제인지 자체가 다르기 때문**이다.
---
## 1. 진짜 문제는 무엇인가 — 모든 대안이 싸우는 단 하나의 축
트랜잭션은 비즈니스 로직이 아니다. "글을 쓴다" 는 비즈니스고, "이걸 한 묶음으로 처리해라" 는 **인프라 관심사(infrastructure concern)** 다. DB 라는 기계를 다루는 기술적 약속이지, 도메인 규칙이 아니다.
그래서 모든 대안이 답하려는 질문은 결국 **하나의 축** 위에 있다:
> **인프라 관심사인 '트랜잭션 경계' 를, 비즈니스 핵심 코드에서 얼마나 떼어낼 것인가?**
```text
분리 0% ─────────────────────────────────────────────► 분리 100%
"핵심 코드에 그냥 붙여" "핵심 코드는 트랜잭션을 몰라야 해"
대안1 대안2 대안4 대안3 대안5
@Transactional Template Interceptor Functional TransactionPort
직접 부착 명령형 커스텀 AOP monad (port 추상화)
```
축의 **왼쪽 끝** 신념: "분리? 그거 다 오버엔지니어링이야. 트랜잭션 경계가 코드에 **눈으로 보이는 게** 제일 중요해."
축의 **오른쪽 끝** 신념: "비즈니스 핵심은 Spring 이든 뭐든 **프레임워크를 몰라야 해**. 그래야 갈아끼우고 테스트하기 좋아."
5개 대안은 이 축 위 서로 다른 지점에 점을 찍은 것뿐이다. **누가 맞고 틀린 게 아니라, 무엇을 더 두려워하는지가 다른 것이다.** 이제 한 명씩 그 사람 입장이 되어보자.
---
## 2. 대안들 = 문제를 "다르게 정의한" 답 ★이 문서의 심장★
각 대안을 똑같은 5단으로 본다:
**(a) 이 사람이 본 문제 → (b) 핵심 직관 → (c) 왜 이게 그 문제를 푸는가(끝까지) → (d) 언제 맞고 어디서 깨지나 → (e) 근거.**
---
### 대안 1. `@Transactional` 직접 부착 — "경계는 *눈에 보이는 곳*에 둬" (다수파)
**(a) 이 사람이 본 문제:**
"트랜잭션이 어디서 시작하고 끝나는지, 코드를 열었을 때 **그 자리에서 바로** 보여야 한다. 한 겹 추상화를 끼우면 그 가시성이 사라진다. 추상화는 비용이고, 나는 그 비용을 낼 이유가 없다."
**(b) 핵심 직관:**
메서드 위에 붙인 `@Transactional` 은 **형광펜**이다. "여기부터 여기까지 한 묶음" 이라고 코드에 직접 칠해두는 표시.
**(c) 왜 이게 문제를 푸는가 (끝까지):**
Spring 이 이 형광펜을 발견하면, 네 객체를 그대로 안 쓰고 **대역(proxy) 객체**를 하나 만든다. 대역은 네 메서드를 부르기 *직전*에 몰래 `BEGIN`(트랜잭션 시작) 을 끼우고, 무사히 끝나면 `COMMIT`, 예외가 터지면 `ROLLBACK` 을 대신 해준다. 그래서 너는 트랜잭션 코드를 **한 줄도 안 쓴다.** 형광펜만 칠하면 끝.
**그런데** 이 마법은 "대역을 거쳐야만" 작동한다. 만약 같은 클래스 안에서 `this.otherMethod()` 처럼 내 메서드를 *직접* 부르면? 대역을 안 거치고 진짜 객체를 바로 부른다 → 형광펜이 **그냥 무시된다(self-invocation 함정).** 분명 `@Transactional` 을 붙였는데 트랜잭션이 안 걸리는 미스터리가 여기서 나온다.
> **강사의 한마디:** 이 함정은 "치명적 결함" 이 아니라 "알면 피하는 함정" 이다. self-injection, public 메서드 분리, 별도 bean 으로 빼기 — 표준 우회가 여러 개 있고 수많은 프로덕션이 이걸로 잘 돌아간다. "AOP 는 self-invocation 때문에 깨진다" 고 *단정하면 과장*이다.
**(d) 언제 맞나 / 어디서 깨지나:**
- ✅ 맞다: 단순 CRUD 위주, 프레임워크 바꿀 계획 없음, 팀이 Spring 에 익숙. → 형광펜의 가시성이 추상화 비용보다 명백히 이득.
- ❌ 깨진다: "비즈니스 핵심 코드는 프레임워크를 import 하면 안 된다" 는 규칙(Clean Architecture)을 세운 순간. 형광펜을 칠하려면 `org.springframework...Transactional` 을 import 해야 하는데, **그 import 자체가 규칙 위반**이 된다. (대안 5 의 출발점이 바로 여기다.)
**(e) 근거:** [[wiki/concepts/transaction-boundary-abstraction]] 대안 1 · self-invocation = claim `AT-TX-C5`. 다수파라는 증거(Buckpal·Reflectoring) 도 같은 문서 Claim-backed 표 참조.
---
### 대안 2. `TransactionTemplate` 명령형 — "마법 말고 *내 손으로* 묶을게"
**(a) 이 사람이 본 문제:**
"형광펜(annotation)은 *선언*일 뿐, 실제 실행은 보이지 않는 대역(proxy)이 한다. 그 보이지 않는 마법과 self-invocation 함정이 싫다. 트랜잭션 시작·끝을 **내가 쓴 코드로 명시적으로** 보고 싶다."
**(b) 핵심 직관:**
형광펜 대신 **직접 괄호를 친다.** `template.execute(status -> { ...여기 안이 한 묶음... })`. 묶음의 시작과 끝이 중괄호로 눈에 보인다.
**(c) 왜 이게 문제를 푸는가 (끝까지):**
`execute(...)` 를 부르는 순간 그 자리에서 진짜로 `BEGIN` 이 실행되고, 람다가 끝나면 `COMMIT`, 예외면 `ROLLBACK`. **프록시 대역이 없다.** 내가 직접 부른 메서드 안에서 시작하므로 self-invocation 함정도 원천적으로 없다. 경계가 "선언" 이 아니라 "실행되는 코드 한 줄" 이 됐다.
**그런데** 대가가 있다. 묶고 싶은 use case 마다 `template.execute(...)` 보일러플레이트를 반복해서 써야 한다. 그리고 결정적으로 — 이 `TransactionTemplate` 클래스 역시 `org.springframework...` 소속이다. 즉 **비즈니스 코드가 여전히 Spring 을 직접 안고 있다.** 가시성·함정 문제는 풀었지만 "프레임워크 분리" 축에서는 대안 1 과 같은 자리다.
**(d) 언제 맞나 / 어디서 깨지나:**
- ✅ 맞다: self-invocation 같은 AOP 함정을 확실히 피하고 싶고, 트랜잭션 경계를 코드로 또렷이 보고 싶을 때.
- ❌ 깨진다: 보일러플레이트가 늘어나는 게 싫을 때 / "프레임워크 import 금지" 규칙이 있을 때 (여전히 Spring 클래스 import).
**(e) 근거:** [[wiki/concepts/transaction-boundary-abstraction]] 대안 2 · Spring 공식 programmatic API.
---
### 대안 3. 함수형 Resource/monad (예: Arrow Kt) — "트랜잭션을 *값* 으로 만들어"
**(a) 이 사람이 본 문제:**
"트랜잭션은 '효과(effect)' 다. 효과를 숨겨진 마법(proxy)이나 명령형 괄호로 다루지 말고, **타입으로 드러내서 합성** 하고 싶다. 그래야 컴파일러가 검증해주고, 순수 함수처럼 테스트할 수 있다."
**(b) 핵심 직관:**
트랜잭션을 *행동* 이 아니라 **레시피(값)** 로 본다. "이 작업은 트랜잭션이 필요함" 이라는 사실이 **타입에 적혀** 따라다닌다. 레시피들을 레고처럼 합쳐서 마지막에 한 번 실행한다.
**(c) 왜 이게 문제를 푸는가 (끝까지):**
효과가 타입에 드러나면, "이 함수는 트랜잭션 안에서 돌아야 한다" 를 **컴파일 타임에** 강제할 수 있다 → 실행은 순수 함수 합성이라, Spring context 같은 무거운 환경 없이 검증 가능 → testability 가 최고로 올라간다.
**그런데** 이건 사고방식 자체가 다르다. Java 위주 Spring 팀에게 monad/패턴 매칭/함수 합성은 **학습 절벽**이다. 게다가 Spring 이 공짜로 주던 propagation·isolation 의미를 monad 위에 직접 다시 구현해야 할 때도 있다. 강력하지만 비싸다.
**(d) 언제 맞나 / 어디서 깨지나:**
- ✅ 맞다: 팀이 이미 함수형(Kotlin/Arrow 등)에 능하고, 효과를 타입으로 다루는 가치를 아는 경우.
- ❌ 깨진다: 평범한 Java/Spring 팀. 도입 비용이 이득을 압도한다. "함수형이 테스트에 항상 우월" 은 *과장* — 팀 역량/언어/기존 코드가 비용을 결정한다.
**(e) 근거:** [[wiki/concepts/transaction-boundary-abstraction]] 대안 3 (Arrow Kt Resource).
---
### 대안 4. 커스텀 `TransactionInterceptor` (AOP) — "마법은 좋아, 근데 *내 마법*으로"
**(a) 이 사람이 본 문제:**
"annotation 기반 마법(대안 1)의 편리함은 좋다. 하지만 표준 `@Transactional` 은 트랜잭션만 한다. 나는 트랜잭션 *경계에서* 추가 정책 — 예를 들어 권한(capability) 검증 — 을 같이 끼우고 싶다."
**(b) 핵심 직관:**
대안 1 의 형광펜을 **내가 직접 만든 형광펜**으로 바꾼다. 내 annotation, 내 interceptor → 묶음의 시작·끝에 내가 원하는 로직을 추가로 끼워넣는다.
**(c) 왜 이게 문제를 푸는가 (끝까지):**
내 interceptor 가 메서드 호출을 가로채니, `BEGIN`/`COMMIT` 사이에 커스텀 정책을 자유롭게 주입할 수 있다 → 트랜잭션 + 정책을 한 곳에서 다룬다.
**그런데** 이건 결국 대안 1 과 같은 AOP 기반이다. **self-invocation 함정 그대로 상속**한다. interceptor 구현 자체가 Spring AOP 에 의존하고, `@TransactionalEventListener` 같은 표준 도구와의 호환을 내가 직접 챙겨야 한다. "마법을 커스터마이즈" 한 대가로 표준이 주던 보장을 일부 떠안는다.
**(d) 언제 맞나 / 어디서 깨지나:**
- ✅ 맞다: 트랜잭션 경계에 *정말로* 횡단 정책을 묶어야 하는 특수 요구가 있을 때.
- ❌ 깨진다: 그냥 트랜잭션만 필요한데 이걸 쓰면 — AOP 함정 + 호환성 부담만 떠안는 오버엔지니어링.
**(e) 근거:** [[wiki/concepts/transaction-boundary-abstraction]] 대안 4 (custom interceptor 사례).
---
### 대안 5. `TransactionPort` 추상화 — "핵심 코드는 *트랜잭션이 뭔지도 몰라야 해*" (소수파, ca-tmpl 채택)
**(a) 이 사람이 본 문제:**
"내 비즈니스 핵심(application layer)은 **Spring 의 존재 자체를 몰라야 한다.** 그래야 (1) 프레임워크를 갈아끼워도 핵심이 안 흔들리고, (2) use case 를 Spring context 없이 가볍게 단위 테스트할 수 있다. 트랜잭션이라는 인프라 관심사도 예외 없이 이 규칙을 따라야 한다."
**(b) 핵심 직관:**
핵심 코드에는 **콘센트 구멍(interface)** 만 뚫어둔다 — `tx.inWrite(() -> { ... })`. 이 구멍은 "트랜잭션으로 묶어줘" 라고 *요청* 만 할 뿐, **어떻게** 묶는지는 모른다. 진짜 Spring 플러그(`SpringTransactionPort`)는 바깥 어댑터 계층에서 꽂는다. 핵심은 콘센트 규격만 알고, 전기 회사가 한전인지 아닌지는 모른다.
**(c) 왜 이게 문제를 푸는가 (끝까지):**
application 은 자기가 만든 `TransactionPort` 인터페이스만 import 한다 → `org.springframework...` 가 비즈니스 코드에서 **완전히 사라진다** → Clean Architecture 의 "의존성은 안쪽(핵심)으로만" 규칙을 트랜잭션 경계까지 지킨다 → 테스트에선 진짜 Spring 대신 **가짜(fake) port** 를 꽂아 "경계가 제대로 선언됐나" 를 Spring context 없이 검증한다.
**그런데** 공짜가 아니다. port 인터페이스 추가 + 어댑터 구현체 추가 + "propagation/isolation 을 port 시그니처에 어떻게 드러낼까" 라는 설계 결정 비용이 든다. 그리고 이건 **소수파**다 — 유명한 hexagonal 예제(Buckpal)나 Spring 공식 incubator(Modulith)조차 오히려 `@Transactional` 을 직접/메타로 부착한다. 즉 "추상화만이 정답" 이라고 말하면 *과장*이다.
**(d) 언제 맞나 / 어디서 깨지나:**
- ✅ 맞다: "핵심은 프레임워크를 모른다" 를 **진짜 규칙으로 강제** 하려는 프로젝트 (skeleton/템플릿처럼 규율이 자산인 경우). 도메인 복잡도가 크고 testability 가 중요할 때.
- ❌ 깨진다: 단순 CRUD 가 대부분이고 프레임워크 교체 계획도 없는데 이걸 쓰면 — 그냥 **오버엔지니어링.** 콘센트 한 겹이 가시성만 깎아먹는다.
**(e) 근거:** [[wiki/concepts/transaction-boundary-abstraction]] 대안 5 · 소수파 증거(Buckpal/Modulith는 반대 방향) Claim-backed 표 참조.
---
## 3. 그래서 나는 어떤 문제로 "정의" 했나
**ca-tmpl 이 대안 5(`TransactionPort`)를 고른 이유.**
여기가 핵심이다. ca-tmpl 이 `TransactionPort` 를 고른 건 "그게 제일 우월해서" 가 **아니다.** **내가 문제를 그렇게 정의했기 때문**이다.
ca-tmpl 은 **Clean Architecture skeleton 템플릿**이다. 이 프로젝트의 존재 이유 자체가 "규율(discipline)을 코드로 강제해서 남에게 물려주는 것" 이다. 그래서 나는 가장 먼저 이 규칙을 세웠다:
> **"application layer 는 Spring 을 import 하지 않는다."**
이 규칙을 세운 *순간*, 답은 거의 정해졌다. 대안 1·2·4 는 전부 `org.springframework...` import 를 요구하니 **규칙 위반**이다. 대안 3 은 팀 언어(Java)에 안 맞는다. 남는 건 대안 5. → **문제 정의가 답을 결정했다.**
내가 실제로 한 것 (검증된 사실만 — 자세히는 [[wiki/projects/ca-tmpl/transaction-boundary-abstraction]]):
- `TransactionPort` 인터페이스: `inWrite` / `inRead` / `inNew` 3개. 인자를 `Supplier`/`Runnable` 로만 받는다 → checked exception 을 시그니처에 노출 안 함(설계 결정 D11).
- `inNew` = `REQUIRES_NEW` = **새 물리 connection** 을 잡는다 → pool 을 소모하므로 loop 안에서 부르면 안 됨(D12). 비싼 도구라 명시적 케이스(outbox/audit)에만.
- `Isolation``READ_COMMITTED` **한 값만** 노출 (나머지는 다른 브랜치로 위임 — 범위를 좁혀 결정 비용을 미룸).
- 진짜 강제 장치: **ArchUnit fitness function** 이 application 패키지에서 `@Transactional` import 를 발견하면 **테스트를 깨뜨린다.** 규칙이 문서가 아니라 빌드 게이트가 됐다.
**검증은 어디까지?** JVM 단위 테스트 + 정적 분석(ArchUnit)까지. **실 DB 통합 테스트도, 운영 배포도 없다.** 그러니 면접에서 "운영에서 검증했다", "실 DB 로 전파를 측정했다" 고 말하면 **거짓말**이다. (과장 금지 전체 목록: project 문서 §과장 금지)
> **강사의 결론:** 누가 "왜 그냥 `@Transactional` 안 썼어요? 그게 표준인데" 라고 물으면, 정답은 "추상화가 우월해서" 가 **아니라** 이렇게 답해야 한다 —
> *"제 프로젝트의 문제 정의가 '핵심은 프레임워크를 모른다' 였습니다. 그 규칙을 세운 순간 `@Transactional` import 는 위반이 됩니다. 만약 단순 CRUD 서비스였다면 저도 `@Transactional` 을 직접 붙였을 겁니다. 문제 정의가 다르면 답도 다릅니다."*
> 이게 "대안을 안다" 의 진짜 의미다.
---
## 4. 다시 처음 장면으로 — 원리를 곱씹는 자가 점검
0번의 그 장면(반쪽짜리 글)으로 돌아가자. 이제 너는 단순히 "트랜잭션 걸면 됨" 이 아니라, **어디에 점을 찍을지** 를 물을 수 있어야 한다. 답을 보지 말고 스스로 재구성해봐라:
1. 만약 네가 지금 만드는 게 **사내 단순 게시판 CRUD** 라면, 위 축에서 어느 점을 찍겠는가? 그 이유를 "두려워하는 것" 의 언어로 한 문장으로 말해봐라.
2. 누가 "AOP `@Transactional` 은 self-invocation 때문에 깨지니까 쓰지 마세요" 라고 단정한다. 어디가 과장인가? 표준 우회를 하나라도 말할 수 있는가?
3. 대안 2(`TransactionTemplate`)와 대안 5(`TransactionPort`)는 **둘 다 명시적 호출**이다. 그런데 분리 축에서 자리가 다르다. **무엇 하나** 때문에 갈리는가? (힌트: import 하는 클래스가 누구 소속인가)
4. ca-tmpl 이 `Isolation``READ_COMMITTED` 한 값만 노출한 건 "결정을 미룬 것" 이다. 이게 왜 *나쁜 게으름이 아니라* 좋은 설계 판단일 수 있는가? (힌트: skeleton 의 목적 + 결정 비용)
5. 한 단계 더: `inNew`(REQUIRES_NEW)를 for-loop 안에서 100번 부르면 무슨 일이 일어나는가? 왜 그게 D12 에서 금지됐는가? (힌트: 비유에서 콘센트가 아니라 "새 전선을 매번 새로 까는" 비용)
이 5개를 막힘없이 말로 설명할 수 있으면, 너는 이 주제를 "외운" 게 아니라 "이해한" 거다.
---
## Sources (이 설명의 출처 — 모두 canonical)
- [[wiki/concepts/transaction-boundary-abstraction]] — 5개 대안 정의 / Claim-backed 근거 / 과장 금지 (사실의 금고)
- [[wiki/projects/ca-tmpl/transaction-boundary-abstraction]] — ca-tmpl 실제 구현 · 검증 범위 · 면접 가능 범위 (내 프로젝트 사실)