F-LAB JAVA · 7주차 · Phase 5 · 수동 트랜잭션의 한계
🏆 Phase 5 완주 — 수동 트랜잭션의 결정적 함정
이 Unit을 끝내면 다음을 답할 수 있어야 한다.
수동 트랜잭션의 3가지 결정적 함정 — (1) rollback/close 누락 같은 실수 누락 (운영 사고로 직결), (2) 트랜잭션 경계가 모호해 commit 후 외부 API 호출 같은 잘못된 위치의 코드, (3) 메서드 A 가 메서드 B 를 호출할 때 트랜잭션 전파 (새 트랜잭션? 참여? 중첩?) 가 수동으로는 거의 불가능 — 이 세 함정이 @Transactional 의 propagation 옵션과 AOP 자동화의 직접적 동기다.
수동 트랜잭션의 결정적 함정 3가지는 운영 사고와 직결되는 실무 문제다.
함정 1 — 실수 누락:rollback()또는close()누락 시 — Connection 이 풀로 반환 안 되어 Connection 누수 (HikariCP 풀 고갈), 트랜잭션 leak (이상한 데이터 영속), 운영 사고 (서비스 다운).
함정 2 — 트랜잭션 경계 모호: 어디부터 어디까지가 트랜잭션? — commit 후 외부 API 호출 (이메일/Slack/결제) 시, commit 은 됐는데 외부 호출 실패면 일관성 깨짐 (트랜잭션 후 처리 = After Commit 패턴 필요).
함정 3 — 트랜잭션 전파 어려움: 메서드 A 가 메서드 B 호출 시 — B 가 새 트랜잭션? A 의 트랜잭션 참여? 중첩? — 수동으로는 거의 불가능 (전파 정책 자체 표현 X).
이 세 함정이 — @Transactional 의 7가지 propagation 옵션 (REQUIRED / REQUIRES_NEW / NESTED / ...) + AOP 자동화 의 직접적 동기다 (Phase 6-7 에서 해결).
3가지 함정 = 셰프의 3가지 실수:
[1] 식기 안 정리 (rollback/close 누락):
- 사고 시 식기 안 치움
- 다음 손님 못 받음 (풀 고갈)
- 부엌 마비
[2] 식기 정리 후 손님 만남 (commit 후 외부):
- 식기 정리 (commit) 끝
- "이메일 보내자" (외부 API)
- 이메일 실패!
- "어, 식기는 정리됐는데?"
- 일관성 깨짐
[3] 다른 셰프 도와줘 (트랜잭션 전파):
- 메서드 A 가 B 부름
- B 도 트랜잭션?
- 둘이 합쳐? 따로?
- 수동으로 표현 어려움
@Transactional 의 답:
- 함정 1: 어노테이션이 알아서 (실수 X)
- 함정 2: @TransactionalEventListener (After Commit)
- 함정 3: propagation 옵션 (REQUIRED/REQUIRES_NEW/NESTED)
ILIC:
- 1020 메서드 × 함정 위험 = 운영 사고 risk
- @Transactional 도입 = 안정성
면접 단골:
- propagation
- 5가지 함정 (Phase 7)
- 프록시 동작
→ 3가지 함정 = 실수 + 경계 + 전파, 운영 사고 risk, @Transactional 답.
1. 3가지 함정 개요
2. 함정 1 — rollback 누락
3. 함정 1 — close 누락 (Connection 누수)
4. 함정 2 — 트랜잭션 경계 모호
5. 함정 2 — commit 후 외부 작업
6. 함정 3 — 트랜잭션 전파 어려움
7. 함정 3 — 중첩 트랜잭션
8. 실무 영향 + 해결 방향
9. Phase 5 완주 + Phase 6 예고
3가지 함정:
함정 1 — 실수 누락:
- rollback 누락
- close 누락
함정 2 — 트랜잭션 경계 모호:
- 어디까지가 트랜잭션?
- commit 후 작업
함정 3 — 트랜잭션 전파 어려움:
- 메서드 A → B 호출
- 새/참여/중첩
함정의 결과:
함정 1 → 운영 사고:
- Connection 누수
- 풀 고갈
- 서비스 다운
함정 2 → 일관성 깨짐:
- 부분 성공
- 데이터 불일치
- 비즈니스 사고
함정 3 → 코드 복잡 / 버그:
- 의도 불명확
- 재사용 어려움
- 디버깅 ↓
면접 빈도:
함정 1:
- 기본 (Connection 누수 등)
- 자주 질문
함정 2:
- 중급 (After Commit)
- 가끔
함정 3:
- 고급 (propagation)
- 자주 (시니어급)
Phase 7 에서 7.3 ★ 깊이 = 5가지 함정 (확장)
ILIC 의 위험 시나리오 (수동 가정)
ILIC = 102 테이블 × 1020 메서드:
함정 1 (실수):
- 1020 메서드 중 일부 rollback/close 누락
- 운영에서 발견 (사고)
- HikariCP 풀 고갈 (1-2 시간 후)
- 서비스 다운
함정 2 (경계):
- 결제 → DB 업데이트 → 이메일 발송
- 이메일 실패 → DB 만 업데이트
- 사용자에게 알림 X
- 불일치
함정 3 (전파):
- 배송 처리 → 운임 계산 → 이메일
- 각자 트랜잭션?
- 합쳐?
- 코드 복잡
→ 모든 함정 = ILIC 의 운영 위험
→ @Transactional 의 필요성
3가지 함정 개요는?
답:
1. 함정 1:
함정 2:
함정 3:
결과:
rollback 누락 결과:
예외 발생했는데 rollback X:
- 트랜잭션 메모리에 (uncommitted)
- DB 락 유지
- Connection 점유 (풀 X)
- 운영 사고
→ 가장 흔한 실수
// 흔한 실수 (rollback 누락)
public void process() {
try {
tx.begin();
// 비즈니스 로직
throw new RuntimeException("에러");
tx.commit(); // 도달 X
} catch (Exception e) {
// tx.rollback(); ← 빠뜨림!
log.error("Error", e);
throw e;
} finally {
em.close();
}
}
// 결과:
// - 트랜잭션 미완료 상태
// - Connection 풀에서 빌린 상태
// - DB 락 유지
EntityTransaction tx;
class EntityTransaction { void begin() {} void commit() {} void rollback() {} }
EntityManager em;
class EntityManager { void close() {} }
org.slf4j.Logger log;
발생 빈도:
- 신입 개발자 / 급한 코드 / 코드 복사
- 모든 메서드에 같은 패턴 → 한두 개 누락
- 1020 메서드 → 1-5개 누락 가능
운영에서 발견:
- 부하 시 풀 고갈
- 1-2 시간 후 다운
// 안전한 패턴 (active 체크)
try {
tx.begin();
// 비즈니스
tx.commit();
} catch (Exception e) {
if (tx.isActive()) { // active 체크
tx.rollback();
}
throw new RuntimeException(e);
}
// 또는 finally 에서 (이중 안전)
} finally {
if (tx.isActive()) {
tx.rollback(); // 만약 commit 안 됐으면
}
em.close();
}
EntityTransaction tx;
class EntityTransaction {
void begin() {}
void commit() {}
void rollback() {}
boolean isActive() { return false; }
}
EntityManager em;
class EntityManager { void close() {} }
// 또 다른 실수 (특정 예외만 rollback)
try {
tx.begin();
// 비즈니스
tx.commit();
} catch (BusinessException e) {
tx.rollback(); // 비즈니스 예외만
throw e;
}
// catch (RuntimeException e) 없음!
// → 런타임 예외 발생 시 rollback X
// → 트랜잭션 leak
EntityTransaction tx;
class EntityTransaction { void begin() {} void commit() {} void rollback() {} }
class BusinessException extends Exception {}
// ILIC 에서 실수 위험 (수동, 가정)
@Service
public class ShipmentService {
public void processBatch(List<Long> ids) {
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
try {
tx.begin();
for (Long id : ids) {
Shipment s = em.find(Shipment.class, id);
s.process();
em.merge(s);
}
tx.commit();
} catch (BusinessException e) {
tx.rollback();
log.error("Business error", e);
throw e;
}
// ❌ RuntimeException 처리 X
// ❌ finally 없음 (em.close X)
// → 운영에서 풀 누수
}
}
class Shipment { void process() {} }
class BusinessException extends Exception {}
EntityManagerFactory emf;
class EntityManagerFactory { EntityManager createEntityManager() { return null; } }
class EntityManager {
EntityTransaction getTransaction() { return null; }
<T> T find(Class<T> c, Object id) { return null; }
<T> T merge(T t) { return null; }
void close() {}
}
class EntityTransaction { void begin() {} void commit() {} void rollback() {} }
org.slf4j.Logger log;
함정 1 — rollback 누락의 위험은?
답:
1. 결과:
빈도:
시나리오:
운영:
Connection 누수:
em.close() 또는 conn.close() 누락:
- Connection 풀로 반환 X
- 영원히 점유
결과:
- HikariCP 풀 고갈
- 후속 요청 timeout
- 서비스 다운
// 누수 시나리오 1: finally 누락
public void process() {
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
try {
tx.begin();
// 비즈니스
tx.commit();
} catch (Exception e) {
tx.rollback();
}
// finally 없음!
// em.close() 누락
// → Connection 누수
}
// 누수 시나리오 2: 예외 발생 후
public void process() {
EntityManager em = emf.createEntityManager();
Object data = riskyOperation(); // 예외 발생!
// em.close() 도달 X
em.close();
}
EntityManagerFactory emf;
class EntityManagerFactory { EntityManager createEntityManager() { return null; } }
class EntityManager {
EntityTransaction getTransaction() { return null; }
void close() {}
}
class EntityTransaction { void begin() {} void commit() {} void rollback() {} }
Object riskyOperation() { return null; }
HikariCP 의 동작:
풀 크기 = 20:
- Connection 1-20 풀에서 빌림 / 반환
- 사용 후 close() = 반환
누수 시:
- 빌리고 반환 X
- 풀 점차 고갈 (20 → 0)
- 새 요청 = waiting
- 30초 timeout → 에러
운영 모니터링:
- HikariCP 의 metric 추적
- 비정상 시 알람
# 누수 탐지 (HikariCP 설정)
spring:
datasource:
hikari:
leak-detection-threshold: 30000 # 30초
# 동작:
# - Connection 30초 이상 사용
# - 경고 로그
# - 호출 스택 출력
# - 누수 원인 추적
누적 효과:
메서드별 누수 가능성:
- 1020 메서드 × 0.1% 누수 = 1 메서드
- 부하 시 자주 호출 → 누수 ↑
시간 따라:
- 1시간: 풀 50% 사용
- 2시간: 풀 80% 사용
- 3시간: 풀 고갈 → 서비스 다운
→ 운영 사고
# ILIC 의 HikariCP 설정
spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
leak-detection-threshold: 30000 # 누수 탐지
# 운영 모니터링:
# - HikariCP metric (active / idle / pending)
# - 누수 시 즉시 경고
# - 비정상 패턴 추적
// ILIC 의 자원 관리 (수동 시)
// 보일러플레이트 + 실수 위험 ↑
// @Transactional (실제 사용)
// → Spring 이 자동
// → 누수 X
함정 1 — close 누락 (Connection 누수) 은?
답:
1. 누수:
결과:
HikariCP:
운영 사고:
경계의 중요성:
트랜잭션 = ACID 보장 범위:
- begin 부터 commit 까지
- 이 안의 작업은 모두 / 모두 X
경계 모호 시:
- "어디까지 트랜잭션?"
- 헷갈림
- 잘못된 코드 위치
// 명확한 예
try {
tx.begin();
// === 트랜잭션 안 ===
em.persist(entity);
em.merge(other);
// === 트랜잭션 안 끝 ===
tx.commit();
} catch (Exception e) {
tx.rollback();
} finally {
em.close();
}
// 트랜잭션 밖:
// - tx.begin 전
// - tx.commit 후
// - tx.rollback 후
EntityTransaction tx;
class EntityTransaction { void begin() {} void commit() {} void rollback() {} }
EntityManager em;
class EntityManager {
void persist(Object o) {}
<T> T merge(T t) { return null; }
void close() {}
}
Object entity;
Object other;
// 모호 1: commit 후 작업
try {
tx.begin();
em.persist(order);
tx.commit();
// commit 후
sendEmailToCustomer(); // ← 여기 트랜잭션?
// 만약 실패 → 어떻게?
} catch (Exception e) {
tx.rollback();
// 이메일 실패 시 rollback?
// 이미 commit 됐는데?
}
// 모호 2: begin 전 작업
EntityManager em = emf.createEntityManager();
List<Order> orders = repository.findOrders(); // 다른 트랜잭션?
EntityTransaction tx = em.getTransaction();
tx.begin();
// ...
class EntityManager { void persist(Object o) {} void close() {} EntityTransaction getTransaction() { return null; } }
class EntityTransaction { void begin() {} void commit() {} void rollback() {} }
EntityManagerFactory emf;
class EntityManagerFactory { EntityManager createEntityManager() { return null; } }
EntityTransaction tx;
EntityManager em;
Object order;
void sendEmailToCustomer() {}
Repository repository;
class Repository { java.util.List<Order> findOrders() { return null; } }
class Order {}
시각화의 어려움:
수동 코드에서:
- try/catch 범위
- tx.begin / tx.commit
- 시각적 표시 X
vs 어노테이션:
- @Transactional 명시적
- 메서드 = 트랜잭션
- 명확
→ 자동화의 가치
// 함정: 비동기에 트랜잭션 전달?
try {
tx.begin();
Order order = em.persist(newOrder);
// 비동기 작업
CompletableFuture.runAsync(() -> {
em.merge(order); // ← 다른 쓰레드!
// 다른 EntityManager / 다른 트랜잭션?
});
tx.commit();
} catch (Exception e) {
tx.rollback();
}
// 비동기 작업과 메인 트랜잭션 분리
// 매우 복잡
EntityTransaction tx;
EntityManager em;
class Order {}
Object newOrder;
class CompletableFuture {
static void runAsync(Runnable r) {}
}
class EntityTransaction { void begin() {} void commit() {} void rollback() {} }
class EntityManager {
<T> T persist(T t) { return null; }
<T> T merge(T t) { return null; }
}
// ILIC 의 트랜잭션 경계 시나리오
@Service
public class ShipmentService {
public void processShipment(Long id) {
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
try {
tx.begin();
// ① DB 작업 (트랜잭션 안)
Shipment s = em.find(Shipment.class, id);
s.markAsShipped();
em.merge(s);
tx.commit();
// ② commit 후 외부 호출 (트랜잭션 밖)
slackNotifier.notify("Shipped " + id); // 외부 1
emailService.send(s.getCustomerEmail()); // 외부 2
trackingService.register(s.getBlNo()); // 외부 3
// 만약 외부 3 실패 → DB 는 이미 commit
// 일관성 깨짐!
} catch (Exception e) {
if (tx.isActive()) tx.rollback();
throw new RuntimeException(e);
} finally {
em.close();
}
}
}
// → 경계 모호 + 외부 작업 위험
class Shipment {
void markAsShipped() {}
String getCustomerEmail() { return null; }
String getBlNo() { return null; }
}
EntityManagerFactory emf;
class EntityManagerFactory { EntityManager createEntityManager() { return null; } }
class EntityManager {
EntityTransaction getTransaction() { return null; }
<T> T find(Class<T> c, Object id) { return null; }
<T> T merge(T t) { return null; }
void close() {}
}
class EntityTransaction {
void begin() {}
void commit() {}
void rollback() {}
boolean isActive() { return false; }
}
SlackNotifier slackNotifier;
class SlackNotifier { void notify(String s) {} }
EmailService emailService;
class EmailService { void send(String s) {} }
TrackingService trackingService;
class TrackingService { void register(String s) {} }
함정 2 — 트랜잭션 경계 모호는?
답:
1. 경계:
모호:
시각화 X:
비동기:
commit 후 외부 호출:
시나리오:
1. DB 업데이트 (트랜잭션)
2. commit
3. 이메일 발송 (외부 API)
외부 API 실패 시:
- DB 는 이미 commit
- rollback X (이미 commit)
- 사용자는 알림 못 받음
- 일관성 깨짐
// 문제 코드
try {
tx.begin();
// 1. DB 업데이트
Order order = new Order();
em.persist(order);
tx.commit(); // ← 커밋!
// 2. 이메일 발송 (외부)
emailService.send(order.getEmail());
// 만약 이메일 서비스 다운 →
// 예외 발생
// DB 는 이미 commit
// 사용자에게 알림 X
} catch (Exception e) {
if (tx.isActive()) tx.rollback();
// 이미 commit → rollback 의미 X
throw e;
}
EntityTransaction tx;
class EntityTransaction { void begin() {} void commit() {} void rollback() {} boolean isActive() { return false; } }
EntityManager em;
class EntityManager { void persist(Object o) {} }
class Order { String getEmail() { return null; } }
EmailService emailService;
class EmailService { void send(String s) {} }
// 반대 패턴 (이메일 먼저)
try {
tx.begin();
// 1. 이메일 먼저
emailService.send(email); // ← 외부 API 안에서!
// 2. DB 업데이트
em.persist(order);
tx.commit();
} catch (Exception e) {
tx.rollback();
// 이메일은 이미 보냄 (외부 효과)
// rollback 못 함 (메일은 외부)
// 일관성 깨짐
}
// 이메일 보내고 → 트랜잭션 실패 → DB 안 만들어짐
// 사용자: "메일 받았는데 주문 없네?"
EntityTransaction tx;
class EntityTransaction { void begin() {} void commit() {} void rollback() {} }
EntityManager em;
class EntityManager { void persist(Object o) {} }
EmailService emailService;
class EmailService { void send(String s) {} }
String email;
Object order;
해결 방향:
After Commit 패턴:
- 트랜잭션 commit 후에만 외부 호출
- rollback 시 외부 호출 X
- 일관성 보장
수동으로 구현:
- boolean flag
- 메모리에 작업 큐
- 복잡 / 실수 위험
Spring 의 답:
- @TransactionalEventListener(AFTER_COMMIT)
- 자동 처리
// Spring 의 답 (@TransactionalEventListener)
@Service
public class OrderService {
@Autowired ApplicationEventPublisher publisher;
@Transactional
public void createOrder() {
// DB 작업
Order order = saveOrder();
// 이벤트 발행 (commit 후 처리)
publisher.publishEvent(new OrderCreatedEvent(order));
}
}
// 이벤트 리스너 (commit 후만 실행)
@Component
public class OrderEventListener {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleOrderCreated(OrderCreatedEvent event) {
emailService.send(event.getOrder().getEmail());
// commit 성공 시에만 실행
// rollback 시 실행 X
}
}
// → After Commit 패턴 자동
class Order { String getEmail() { return null; } }
class OrderCreatedEvent {
OrderCreatedEvent(Order o) {}
Order getOrder() { return null; }
}
class ApplicationEventPublisher { void publishEvent(Object e) {} }
ApplicationEventPublisher publisher;
Order saveOrder() { return null; }
class TransactionPhase { static int AFTER_COMMIT = 0; }
@interface TransactionalEventListener { int phase(); }
@interface Transactional {}
EmailService emailService;
class EmailService { void send(String s) {} }
// ILIC 의 패턴 (실제 @Transactional 사용)
@Service
public class ShipmentService {
@Autowired ApplicationEventPublisher publisher;
@Transactional
public void processShipment(Long id) {
// DB 작업 (트랜잭션 안)
Shipment s = shipmentRepository.findById(id).orElseThrow();
s.markAsShipped();
// 이벤트 발행 (commit 후 처리)
publisher.publishEvent(new ShipmentShippedEvent(s));
}
}
@Component
public class ShipmentEventListener {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onShipped(ShipmentShippedEvent event) {
// commit 성공 후만 실행
slackNotifier.notify("Shipped");
emailService.send(event.getShipment().getCustomerEmail());
trackingService.register(event.getShipment().getBlNo());
}
}
// 효과:
// - DB commit 실패 → 외부 호출 X
// - DB commit 성공 → 외부 호출
// - 일관성 보장
class Shipment {
void markAsShipped() {}
String getCustomerEmail() { return null; }
String getBlNo() { return null; }
}
ShipmentRepository shipmentRepository;
interface ShipmentRepository { java.util.Optional<Shipment> findById(Long id); }
class ShipmentShippedEvent {
ShipmentShippedEvent(Shipment s) {}
Shipment getShipment() { return null; }
}
ApplicationEventPublisher publisher;
class ApplicationEventPublisher { void publishEvent(Object e) {} }
SlackNotifier slackNotifier;
class SlackNotifier { void notify(String s) {} }
EmailService emailService;
class EmailService { void send(String s) {} }
TrackingService trackingService;
class TrackingService { void register(String s) {} }
@interface Transactional {}
@interface TransactionalEventListener { int phase(); }
class TransactionPhase { static int AFTER_COMMIT = 0; }
함정 2 — commit 후 외부 작업의 함정은?
답:
1. 외부 호출:
실패 시:
해결:
Spring:
전파 (Propagation):
메서드 A 가 메서드 B 호출 시:
- B 가 트랜잭션 가져야?
- A 의 트랜잭션 참여?
- 새 트랜잭션?
- 중첩?
= 트랜잭션 전파 정책
// 시나리오:
// A 는 트랜잭션, A 가 B 호출
public void serviceA() {
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
try {
tx.begin();
// A 의 작업
serviceB(); // ← B 호출
// A 의 작업
tx.commit();
} catch (Exception e) {
tx.rollback();
} finally {
em.close();
}
}
public void serviceB() {
// B 도 트랜잭션 필요?
// - 새로? (B 만의)
// - A 의 트랜잭션 참여? (같은 거)
}
EntityManagerFactory emf;
class EntityManagerFactory { EntityManager createEntityManager() { return null; } }
class EntityManager { EntityTransaction getTransaction() { return null; } void close() {} }
class EntityTransaction { void begin() {} void commit() {} void rollback() {} }
옵션 1 — 참여 (REQUIRED, 가장 일반):
B 는 A 의 트랜잭션 참여:
- A 의 commit 시 B 도 commit
- A 의 rollback 시 B 도 rollback
- 하나의 트랜잭션
수동 구현:
- B 가 A 의 EntityManager 받음
- 또는 ThreadLocal 사용
- 복잡!
옵션 2 — 새 트랜잭션 (REQUIRES_NEW):
B 는 별도 트랜잭션:
- A 는 잠시 정지
- B 가 commit / rollback 후
- A 계속
예시:
- 로그 기록 (반드시 저장)
- A 가 rollback 해도 로그는 남음
수동 구현:
- 별도 EntityManager
- 트랜잭션 시작
- 복잡!
옵션 3 — 중첩 (NESTED):
B 는 A 의 부분 트랜잭션:
- SAVEPOINT 사용
- B 실패 시 SAVEPOINT 까지만 rollback
- A 는 계속 진행 가능
수동 구현:
- SAVEPOINT 명시
- JDBC 직접
- 매우 복잡
// 수동 REQUIRED 구현 시도
public void serviceA() {
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
try {
tx.begin();
// A 작업
// B 호출 (같은 트랜잭션 참여)
serviceB(em); // ← em 전달
// A 작업
tx.commit();
} catch (Exception e) {
tx.rollback();
} finally {
em.close();
}
}
public void serviceB(EntityManager em) {
// em 받음 (A 의 트랜잭션)
// B 작업
// commit / rollback X (A 가 결정)
}
// 문제:
// - 메서드 시그너처 변경 (em 전달)
// - 모든 호출 체인 변경
// - 매우 침투적
EntityManagerFactory emf;
class EntityManagerFactory { EntityManager createEntityManager() { return null; } }
class EntityManager { EntityTransaction getTransaction() { return null; } void close() {} }
class EntityTransaction { void begin() {} void commit() {} void rollback() {} }
// ILIC 의 전파 시나리오 (수동, 가정)
// 배송 처리 + 운임 계산 + 알림
public void processShipment(Long id) {
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
try {
tx.begin();
// 1. 배송 업데이트
Shipment s = em.find(Shipment.class, id);
s.markAsShipped();
// 2. 운임 계산 (서비스 호출)
freightService.calculate(em, s); // em 전달!
// 3. 로그 기록 (별도 트랜잭션 원함)
// → 수동으로는?
// → 별도 EntityManager 만들고 별도 begin/commit/close
EntityManager logEm = emf.createEntityManager();
EntityTransaction logTx = logEm.getTransaction();
try {
logTx.begin();
logEm.persist(new ShipmentLog(id, "SHIPPED"));
logTx.commit();
} catch (Exception e) {
if (logTx.isActive()) logTx.rollback();
// 로그 실패 → 메인 트랜잭션은?
} finally {
logEm.close();
}
tx.commit();
} catch (Exception e) {
if (tx.isActive()) tx.rollback();
} finally {
em.close();
}
}
// → 매우 복잡
// → 실수 위험
// @Transactional + propagation
// → 간단
// → 다음 Phase
class Shipment { void markAsShipped() {} }
class ShipmentLog { ShipmentLog(Long id, String s) {} }
FreightService freightService;
class FreightService { void calculate(EntityManager em, Shipment s) {} }
EntityManagerFactory emf;
class EntityManagerFactory { EntityManager createEntityManager() { return null; } }
class EntityManager {
EntityTransaction getTransaction() { return null; }
<T> T find(Class<T> c, Object id) { return null; }
void persist(Object o) {}
void close() {}
}
class EntityTransaction {
void begin() {}
void commit() {}
void rollback() {}
boolean isActive() { return false; }
}
함정 3 — 트랜잭션 전파 어려움은?
답:
1. 전파:
3 옵션:
수동:
@Transactional:
중첩 트랜잭션 (NESTED):
부분 rollback 가능:
- 외부 트랜잭션 안에
- 내부 트랜잭션 (SAVEPOINT)
- 내부 실패 시 SAVEPOINT 까지만 rollback
- 외부는 계속
유용한 케이스:
- "한 단계 실패해도 전체 X"
시나리오:
주문 처리 (외부):
- 1. 재고 차감 (내부 - 가능하면)
- 2. 결제 (필수)
- 3. 배송 (필수)
목표:
- 재고 차감 실패해도 결제 / 배송 진행
- 결제 / 배송 실패는 전체 rollback
중첩 트랜잭션:
- 재고 차감 = 내부 (SAVEPOINT)
- 결제 / 배송 = 외부
// JDBC 의 SAVEPOINT
Connection conn = dataSource.getConnection();
conn.setAutoCommit(false);
try {
// 외부 트랜잭션
// 내부 (SAVEPOINT)
Savepoint savepoint = conn.setSavepoint();
try {
// 재고 차감
updateStock(conn, ...);
} catch (Exception e) {
// 부분 rollback (SAVEPOINT 까지)
conn.rollback(savepoint);
log.warn("재고 차감 실패", e);
}
// 외부 계속
processPayment(conn);
processShipping(conn);
conn.commit();
} catch (Exception e) {
conn.rollback(); // 전체 rollback
} finally {
conn.close();
}
DataSource dataSource;
interface DataSource { Connection getConnection() throws SQLException; }
void updateStock(Connection conn, Object... a) {}
void processPayment(Connection conn) {}
void processShipping(Connection conn) {}
org.slf4j.Logger log;
수동의 복잡성:
- SAVEPOINT 명시
- 부분 rollback 처리
- 예외 분기
- 모든 경우 처리
실수 시:
- 부분 rollback 누락
- 전체 commit / rollback 잘못
- 디버깅 어려움
// @Transactional NESTED (간단)
@Service
public class OrderService {
@Transactional // 외부
public void createOrder() {
// 1. 외부 트랜잭션
try {
stockService.deduct(); // NESTED
} catch (StockException e) {
// 부분 rollback (자동)
log.warn("재고 차감 실패");
}
// 2. 외부 계속
paymentService.process();
shippingService.create();
// 자동 commit
}
}
@Service
public class StockService {
@Transactional(propagation = Propagation.NESTED)
public void deduct() {
// 부분 트랜잭션
}
}
// → 간단, 어노테이션만
class OrderService {}
class StockService {}
@interface Transactional { Propagation propagation() default Propagation.REQUIRED; }
enum Propagation { REQUIRED, NESTED, REQUIRES_NEW }
class StockException extends RuntimeException {}
StockService stockService;
PaymentService paymentService;
ShippingService shippingService;
class PaymentService { void process() {} }
class ShippingService { void create() {} }
org.slf4j.Logger log;
// ILIC 의 중첩 시나리오 (가상)
@Service
public class ShipmentService {
@Transactional // 외부
public void processShipment(Long id) {
// 1. 배송 업데이트
Shipment s = shipmentRepo.findById(id).orElseThrow();
s.markAsShipped();
// 2. 운임 계산 (NESTED - 실패해도 OK)
try {
freightService.calculate(s);
} catch (FreightCalculationException e) {
// 부분 rollback (운임만)
log.warn("운임 자동 계산 실패, 수동 처리 필요");
// 메인 트랜잭션은 계속
}
// 3. 알림 로그
notificationService.logShippedEvent(id);
// 자동 commit
}
}
@Service
public class FreightService {
@Transactional(propagation = Propagation.NESTED)
public void calculate(Shipment s) {
// 부분 트랜잭션
// 실패 시 SAVEPOINT 까지만 rollback
}
}
// → 어노테이션만으로
// → 수동은 매우 복잡
class Shipment { void markAsShipped() {} }
ShipmentRepository shipmentRepo;
interface ShipmentRepository { java.util.Optional<Shipment> findById(Long id); }
FreightService freightService;
class FreightCalculationException extends RuntimeException {}
NotificationService notificationService;
class NotificationService { void logShippedEvent(Long id) {} }
@interface Transactional { Propagation propagation() default Propagation.REQUIRED; }
enum Propagation { REQUIRED, NESTED }
org.slf4j.Logger log;
함정 3 — 중첩 트랜잭션의 복잡성은?
답:
1. 중첩:
JDBC:
수동:
@Transactional NESTED:
실무 영향:
함정 1 (실수):
- 운영 사고 (Connection 누수)
- 서비스 다운
- 1-2 시간 내 발견
함정 2 (경계):
- 데이터 불일치
- 사용자 경험 ↓
- 비즈니스 사고
함정 3 (전파):
- 코드 복잡
- 재사용 X
- 디버깅 ↓
→ 모두 운영 / 비즈니스 위험
해결 방향:
1. 자동화 (보일러플레이트 X):
- @Transactional 어노테이션
- AOP / 프록시
→ Phase 7
2. 추상화 (인터페이스):
- PlatformTransactionManager
- DataSource 정신
→ Phase 6
3. 전파 정책 명시:
- propagation 옵션
- 7가지
→ Phase 7
4. After Commit:
- @TransactionalEventListener
→ Phase 7 (5가지 함정)
5주차 + 6주차 + 7주차:
5주차 (패턴):
- DI / OCP / 템플릿+전략
- 프록시
6주차 (DB 접근):
- DataSource
- ACID
- JdbcTemplate (자원 자동)
7주차 Part B (트랜잭션 추상화):
- PlatformTransactionManager (DataSource 정신)
- @Transactional (프록시 + AOP)
- propagation
- = 모든 학습의 응축
실무 선택:
거의 모든 자바 백엔드:
- @Transactional 사용
- 수동 트랜잭션 안 씀
- Spring 의 표준
예외:
- 매우 특수 (수동 제어 필요)
- 미세 튜닝
→ @Transactional 압도적
학습 가치:
수동 트랜잭션 이해 →
@Transactional 의 자동화 가치 절감 →
깊은 이해 →
함정 회피
Phase 7 의 7.3 (5가지 함정):
- 모두 수동의 함정에서 파생
- 어노테이션 시 발생 가능
- 면접 단골
ILIC 의 실무
ILIC = @Transactional 표준:
- 102 테이블
- 1020 메서드
- 모두 어노테이션
수동의 함정 학습:
- 함정 의식
- @Transactional 사용 시도 회피
- 깊은 이해
Phase 6-7 학습으로:
- propagation 정책 활용
- After Commit 패턴
- 함정 회피
→ 안정 운영
실무 영향과 해결 방향은?
답:
1. 영향:
해결:
5+6+7주차:
실무:
🔧 Phase 5 — 수동 트랜잭션의 한계
Unit 5.1 — 결합 문제:
- 비즈니스 + 인프라 혼재
- SoC 위반
- 보일러플레이트
Unit 5.2 — 3가지 함정 ← 여기:
- 함정 1: 실수 누락
- 함정 2: 경계 모호
- 함정 3: 전파 어려움
→ 수동 트랜잭션의 결정적 한계
→ 자동화의 동기
Phase 5 핵심 메시지:
"수동 트랜잭션은 비즈니스 로직과 인프라의 결합 +
실수 누락 + 경계 모호 + 전파 어려움 의 4 결정적 문제.
이 4 문제 모두를 풀어주는 게 Spring 의 트랜잭션 추상화."
🎯 Phase 6 — PlatformTransactionManager (3 Unit)
Unit 6.1 — 인터페이스 추상화:
- PlatformTransactionManager
- 5주차 DI / DIP 정신
- 6주차 DataSource 와 같은 사상
Unit 6.2 — 3가지 구현체:
- DataSourceTransactionManager (JDBC)
- HibernateTransactionManager (Hibernate)
- JpaTransactionManager (JPA)
Unit 6.3 — 사용 전후 비교:
- 수동 vs PlatformTransactionManager
- 코드 개선
Phase 6 의 가치:
- 트랜잭션의 인터페이스 추상화
- 5주차 DI / DIP / OCP 정신
- 6주차 DataSource 와 같은 사상
- @Transactional 의 기반
→ 이해 깊이 ↑
Phase 6 → Phase 7:
Phase 6:
- 인터페이스 추상화 (TM)
- 수동에서 한 단계 자동
Phase 7 (★★★ 핵심):
- @Transactional
- 프록시 + AOP
- 5가지 함정
- 면접 단골
→ 점진적 추상화
→ Phase 7 가 정점
🗂️ Part A — 데이터 모델링과 ORM (완주)
✅ Phase 1-4 (16)
🔄 Part B — 트랜잭션 추상화의 진화
✅ Phase 5 (2) ← 여기, 완주!
⏭ Phase 6 (3)
⏭ Phase 7 (3) — 모두 ★깊이
총: 18/24 Unit (75%, Phase 5 완주!)
| Q | 핵심 답변 |
|---|---|
| 3가지 함정? | 실수 / 경계 / 전파 |
| rollback 누락? | 트랜잭션 leak |
| close 누락? | Connection 누수 |
| 풀 고갈? | 서비스 다운 |
| commit 후 외부? | After Commit |
| @TransactionalEventListener? | AFTER_COMMIT |
| 트랜잭션 전파? | REQUIRED 등 |
| NESTED? | SAVEPOINT |
| @Transactional 답? | 모두 해결 |
| Phase 6-7? | 추상화 + 자동화 |
답:
답:
답:
답:
답:
1. 3가지 함정
2. 실무 영향
3. 해결 방향
🔧 Phase 5 — 수동 트랜잭션의 한계
✅ Unit 5.1 트랜잭션이 비즈니스 로직과 결합
✅ Unit 5.2 수동 트랜잭션 3가지 함정 ← 여기, Phase 5 완주
→ 수동의 결정적 한계 이해
→ 자동화의 동기 정확히 인식
→ Phase 6 (인터페이스 추상화) 의 기반
🎯 Phase 6 — PlatformTransactionManager (3 Unit)
Unit 6.1 — 인터페이스 추상화
Unit 6.2 — 3가지 구현체 (DataSource/Hibernate/JPA TM)
Unit 6.3 — 사용 전후 비교
Phase 6 주제:
🗂️ Part A — 데이터 모델링과 ORM (완주)
✅ Phase 1-4 (16)
🔄 Part B — 트랜잭션 추상화의 진화
✅ Phase 5 (2) ← 완주
⏭ Phase 6 (3)
⏭ Phase 7 (3) — 모두 ★깊이
총: 18/24 Unit (75%, Phase 5 완주!)
🏆 Phase 5 완주 — 수동 트랜잭션의 한계