18 KiB
title, source_type, status, confidence, tags, related_projects, last_reviewed
| title | source_type | status | confidence | tags | related_projects | last_reviewed | ||||
|---|---|---|---|---|---|---|---|---|---|---|
| (강사 설명) 트랜잭션 경계를 어디에 둘 것인가 — 5가지 답이 갈리는 진짜 이유 | explainer | draft | medium |
|
|
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 에 두 번 쓴다.
1) posts 테이블에 글 한 줄 INSERT ← 성공
2) tags 테이블에 태그 세 줄 INSERT ← 여기서 예외 펑!
자, 1번은 이미 커밋됐고 2번은 터졌다. 결과는? 태그 없는 반쪽짜리 글이 DB 에 영원히 남는다. 누구도 지워주지 않는다.
이걸 막는 게 트랜잭션이다. "1번과 2번은 한 묶음. 둘 다 되든가, 둘 다 없던 일이 되든가." 은행 송금이랑 똑같다 — 내 계좌 -1만 원, 상대 계좌 +1만 원, 중간에 멈추면 돈이 증발한다. 그래서 "다 되거나 다 취소(rollback)" 로 묶는다.
여기까진 아무도 이견이 없다. 진짜 싸움은 다음 한 줄에서 시작된다:
"그래서 이 '한 묶음' 의 시작과 끝을, 코드 어디에, 누가, 어떻게 선언하지?"
5가지 답이 있다. 그리고 답이 갈리는 이유는 — 곧 보겠지만 — 사람마다 무엇이 문제인지 자체가 다르기 때문이다.
1. 진짜 문제는 무엇인가 — 모든 대안이 싸우는 단 하나의 축
트랜잭션은 비즈니스 로직이 아니다. "글을 쓴다" 는 비즈니스고, "이걸 한 묶음으로 처리해라" 는 인프라 관심사(infrastructure concern) 다. DB 라는 기계를 다루는 기술적 약속이지, 도메인 규칙이 아니다.
그래서 모든 대안이 답하려는 질문은 결국 하나의 축 위에 있다:
인프라 관심사인 '트랜잭션 경계' 를, 비즈니스 핵심 코드에서 얼마나 떼어낼 것인가?
분리 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/inNew3개. 인자를Supplier/Runnable로만 받는다 → checked exception 을 시그니처에 노출 안 함(설계 결정 D11).inNew=REQUIRES_NEW= 새 물리 connection 을 잡는다 → pool 을 소모하므로 loop 안에서 부르면 안 됨(D12). 비싼 도구라 명시적 케이스(outbox/audit)에만.Isolation은READ_COMMITTED한 값만 노출 (나머지는 다른 브랜치로 위임 — 범위를 좁혀 결정 비용을 미룸).- 진짜 강제 장치: ArchUnit fitness function 이 application 패키지에서
@Transactionalimport 를 발견하면 테스트를 깨뜨린다. 규칙이 문서가 아니라 빌드 게이트가 됐다.
검증은 어디까지? JVM 단위 테스트 + 정적 분석(ArchUnit)까지. 실 DB 통합 테스트도, 운영 배포도 없다. 그러니 면접에서 "운영에서 검증했다", "실 DB 로 전파를 측정했다" 고 말하면 거짓말이다. (과장 금지 전체 목록: project 문서 §과장 금지)
강사의 결론: 누가 "왜 그냥
@Transactional안 썼어요? 그게 표준인데" 라고 물으면, 정답은 "추상화가 우월해서" 가 아니라 이렇게 답해야 한다 — "제 프로젝트의 문제 정의가 '핵심은 프레임워크를 모른다' 였습니다. 그 규칙을 세운 순간@Transactionalimport 는 위반이 됩니다. 만약 단순 CRUD 서비스였다면 저도@Transactional을 직접 붙였을 겁니다. 문제 정의가 다르면 답도 다릅니다." 이게 "대안을 안다" 의 진짜 의미다.
4. 다시 처음 장면으로 — 원리를 곱씹는 자가 점검
0번의 그 장면(반쪽짜리 글)으로 돌아가자. 이제 너는 단순히 "트랜잭션 걸면 됨" 이 아니라, 어디에 점을 찍을지 를 물을 수 있어야 한다. 답을 보지 말고 스스로 재구성해봐라:
- 만약 네가 지금 만드는 게 사내 단순 게시판 CRUD 라면, 위 축에서 어느 점을 찍겠는가? 그 이유를 "두려워하는 것" 의 언어로 한 문장으로 말해봐라.
- 누가 "AOP
@Transactional은 self-invocation 때문에 깨지니까 쓰지 마세요" 라고 단정한다. 어디가 과장인가? 표준 우회를 하나라도 말할 수 있는가? - 대안 2(
TransactionTemplate)와 대안 5(TransactionPort)는 둘 다 명시적 호출이다. 그런데 분리 축에서 자리가 다르다. 무엇 하나 때문에 갈리는가? (힌트: import 하는 클래스가 누구 소속인가) - ca-tmpl 이
Isolation을READ_COMMITTED한 값만 노출한 건 "결정을 미룬 것" 이다. 이게 왜 나쁜 게으름이 아니라 좋은 설계 판단일 수 있는가? (힌트: skeleton 의 목적 + 결정 비용) - 한 단계 더:
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 실제 구현 · 검증 범위 · 면접 가능 범위 (내 프로젝트 사실)