| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 | 31 |
- JDBC
- 사면초가
- AOP
- 냄새라도
- 공통규약
- 클린아키텍처
- SpringLegacy
- 문제선택근거
- Transaction
- 전전긍긍
- 디스크i/o
- 커뮤니티서버
- 공통기술
- JPA
- springsecurity
- index
- 단위테스트
- TDD
- OOP
- 로그백
- 이벤트핸들러this
- DDD
- SEQUENCE
- string
- MVC
- MVC요청플로우
- 설정세팅
- Ajax
- model
- 이벤트핸들러등록
- Today
- Total
개발이군고구마
JDBC 트랜잭션의 원리 본문
Spring + JDBC 트랜잭션의 원리
이중 실행 방지까지
ThreadLocal, Connection Pool, @Transactional의 동작 원리를 한 번에 이해하는 가이드 — 주니어 개발자를 위한 실전 해설
목차
1왜 이 문제를 만났는가 — 이중 실행의 위험
백엔드 개발을 하다 보면 반드시 마주치는 상황이 있습니다. 하나의 API 요청 안에서 여러 업무(비즈니스 로직)가 순서대로 실행되어야 할 때, 그리고 그 중 하나가 동시에 중복 실행되면 안 되는 경우입니다.
클라이언트(앱/브라우저)는 타임아웃이 발생해 요청을 취소했지만, 서버는 그 사실을 모른 채 계속 작동합니다. 이 틈에 같은 요청이 다시 들어오면 데이터가 중복으로 처리될 수 있습니다.
class OrderService {
@Transactional
void processOrder(Long orderId) {
task1_validateOrder(orderId); // 업무1: DB 조회
task2_deductInventory(orderId); // 업무2: 재고 차감 (느림, 중복 불가!)
// ↑ 클라이언트가 여기서 timeout → 재요청 발생
// ↑ 서버는 모르고 계속 실행 중...
task3_createShipment(orderId); // 업무3: 배송 생성
}
}
업무2는 두 가지 조건이 충돌합니다. 처리 시간이 길어서 클라이언트가 타임아웃을 일으키기 쉽고, 동시에 같은 데이터에 대해 두 번 실행되면 재고가 두 배로 차감되는 치명적 버그가 발생합니다.
2JDBC가 Connection을 관리하는 방법 — ThreadLocal
트랜잭션을 이해하려면 먼저 JDBC가 DB 커넥션을 어떻게 저장하고 관리하는지를 알아야 합니다. 핵심 개념은 바로 ThreadLocal입니다 — 스레드마다 별도의 변수 공간을 제공하는 것입니다.
Spring의 TransactionSynchronizationManager
Spring이 실제로 커넥션을 관리할 때 사용하는 클래스가 바로 TransactionSynchronizationManager입니다. 내부적으로 ThreadLocal로 구현되어 있습니다.
public abstract class TransactionSynchronizationManager {
// 핵심: ThreadLocal로 Connection을 스레드에 묶는다
private static final ThreadLocal<Map<Object, Object>> resources =
new NamedThreadLocal("Transactional resources");
// 트랜잭션 시작 시 Connection을 현재 스레드에 바인딩
public static void bindResource(Object key, Object value) {
Map<Object, Object> map = resources.get();
if (map == null) { map = new HashMap<>(); resources.set(map); }
map.put(key, value); // DataSource → Connection 매핑
}
// 같은 스레드에서 Connection 조회 → 항상 같은 Connection 반환
public static Object getResource(Object key) {
Map<Object, Object> map = resources.get();
return (map != null ? map.get(key) : null);
}
}
같은 스레드 안에서 실행되는 모든 메서드가 동일한 Connection을 공유할 수 있는 이유가 바로 ThreadLocal입니다. 업무1(), 업무2(), 업무3()이 모두 같은 트랜잭션에 묶이는 것이 이 원리 덕분입니다.
REQUIRES_NEW 사용 시 Connection이 중첩되는 구조
3스레드 풀과 Connection 반납 원리
스레드를 매 요청마다 새로 만들면 CPU와 메모리 비용이 큽니다. 그래서 서버는 스레드 풀(Thread Pool)을 미리 만들어 두고 재사용합니다. Tomcat은 기본적으로 200개의 스레드를 유지합니다.
왜 Thread.join()을 쓸 수 없는가
처음에는 "업무2()를 별도 스레드로 분리하고, thread.join()으로 기다리면 되지 않나?"라고 생각할 수 있습니다. 하지만 이 방법에는 치명적인 문제가 있습니다.
Thread.join()은 현재 스레드를 블로킹(blocking)합니다. 스레드 풀에서 빌려온 Thread-1이 join()으로 멈추면, 그 스레드는 다른 HTTP 요청을 처리하지 못합니다.
Thread-1이 잡고 있는 Connection-1도 함께 블로킹됩니다. 풀의 스레드와 커넥션이 모두 대기 상태가 되어 서버 전체가 마비될 수 있습니다.
스레드 풀 스레드를 블로킹하지 않는 방법이 필요합니다. JDK 21의 Virtual Thread가 이 문제를 우아하게 해결합니다.
Virtual Thread는 블로킹되더라도 OS 스레드를 점유하지 않아서 수천 개가 동시에 실행되어도 풀 고갈이 없습니다.
4@Transactional 어노테이션의 실제 동작
@Transactional은 많은 개발자가 "그냥 트랜잭션 묶어주는 거"로만 알고 있습니다. 하지만 실제로는 AOP Proxy를 통해 동작하며, 이 원리를 모르면 예상치 못한 버그를 만납니다.
// Spring이 생성하는 Proxy 클래스 (개념적 표현)
class OrderService$$SpringCGLIB extends OrderService {
void processOrder(Long orderId) {
Connection conn = dataSource.getConnection(); // Pool에서 획득
conn.setAutoCommit(false);
TransactionSynchronizationManager.bindResource(dataSource, conn);
try {
super.processOrder(orderId); // 실제 비즈니스 로직
conn.commit();
} catch (RuntimeException e) {
conn.rollback(); throw e;
} finally {
TransactionSynchronizationManager.unbindResource(dataSource);
conn.close(); // 실제로는 Pool에 반납
}
}
}
같은 클래스 내부 호출은 Proxy를 거치지 않습니다! this.업무2()처럼 자기 자신을 직접 호출하면 Proxy가 끼어들지 못해 트랜잭션이 적용되지 않습니다. 반드시 다른 Bean을 통해 호출하거나, AopContext.currentProxy()를 활용해야 합니다.
트랜잭션 전파(Propagation) 옵션
| 옵션 | 동작 | 언제 쓰나 |
|---|---|---|
REQUIRED (기본) |
트랜잭션이 있으면 합류, 없으면 새로 생성 | 일반적인 상황 대부분 |
REQUIRES_NEW |
항상 새 트랜잭션 생성, 기존 일시 중지 | 외부 TX 실패와 무관하게 커밋이 필요할 때 (감사 로그 등) |
NESTED |
중첩 트랜잭션 (Savepoint 활용) | 부분 롤백이 필요할 때 |
NOT_SUPPORTED |
트랜잭션 없이 실행 | 성능을 위해 TX 없이 조회할 때 |
NEVER |
트랜잭션 있으면 예외 발생 | 절대 TX 안에서 실행되면 안 되는 작업 |
5DB Lock과 트랜잭션 전파 — 첫 번째 시도와 그 한계
이중 실행을 막기 위해 가장 먼저 떠오르는 방법은 DB Lock입니다. 업무2()를 실행하기 전에 Lock을 걸고, 같은 Lock을 획득하려는 두 번째 요청은 실패시키는 것입니다.
@Service
class OrderService {
@Transactional // 외부 트랜잭션 (Connection-1)
public void processOrder(Long orderId) {
task1_validateOrder(orderId);
inventoryService.deductWithLock(orderId); // 별도 Bean 호출 → Proxy 통과 ✅
task3_createShipment(orderId);
}
}
@Service
class InventoryService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
// 새로운 트랜잭션 (Connection-2) 생성 — Thread-1이 이제 2개 커넥션 보유!
public void deductWithLock(Long orderId) {
Inventory inv = inventoryRepo.findByOrderIdForUpdate(orderId); // SELECT FOR UPDATE
if (inv == null) throw new DuplicateExecutionException("이미 처리 중");
inventoryRepo.save(inv);
} // REQUIRES_NEW 커밋 → Lock 해제
}
REQUIRES_NEW를 사용한다면 최악의 경우 스레드당 2개의 Connection이 필요합니다. Pool 사이즈를 넉넉히 잡지 않으면 Connection 대기 타임아웃이 발생합니다.
분산 Lock(Redis SETNX)을 쓰면 TTL을 설정할 수 있지만, "얼마나 긴 TTL을?"이라는 새로운 문제가 생깁니다. TTL이 짧으면 정상 처리 중에 Lock이 해제되고, TTL이 길면 서버 장애 시 Lock이 오래 남습니다. Heartbeat를 구현하면 복잡도가 폭발합니다. Redisson 같은 라이브러리가 이를 내부적으로 처리해 줍니다.
6JDK 21 Virtual Thread로 문제 해결하기
앞서 살펴본 모든 문제의 근원은 하나입니다: Thread-1이 업무2를 처리하는 동안 Connection-1을 잡고 있어야 한다는 것입니다. JDK 21의 Virtual Thread를 사용하면 이 문제를 우아하게 풀 수 있습니다.
Virtual Thread로 문제 해결하는 원리
핵심 아이디어는: 업무2를 Virtual Thread에서 실행시키는 것입니다. Virtual Thread는 자신만의 ThreadLocal을 가지므로, 새로운 Connection이 자동으로 할당됩니다.
@Service
class OrderService {
@Transactional
public void processOrder(Long orderId) {
task1_validateOrder(orderId); // Thread-1, Connection-1 사용
// ✅ Virtual Thread를 생성하여 업무2 실행
// 새 ThreadLocal → 새 Connection → 독립 트랜잭션 (REQUIRES_NEW 불필요)
Thread vt = Thread.ofVirtual()
.name("vt-deduct-" + orderId)
.start(() -> inventoryService.deductInventory(orderId));
try {
vt.join(); // VT 종료 대기
// ✅ VT가 블로킹될 때 캐리어 OS Thread를 반납 → Tomcat 풀 고갈 없음
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("업무2 처리 중단", e);
}
task3_createShipment(orderId); // Thread-1, Connection-1 계속 사용
}
}
@Service
class InventoryService {
@Transactional // REQUIRES_NEW 불필요 — VT는 새 ThreadLocal = 새 Connection
public void deductInventory(Long orderId) {
// DB 유니크 제약으로 중복 방지 (멱등성 보장)
boolean inserted = inventoryRepo.insertDeductionIfNotExists(orderId);
if (!inserted) throw new DuplicateDeductionException("이미 처리된 주문");
Inventory inv = inventoryRepo.findByOrderId(orderId);
inv.deduct();
inventoryRepo.save(inv);
}
}
Virtual Thread는 새로운 ThreadLocal 공간을 가지므로, 부모 스레드의 인증 정보(SecurityContext), 요청 컨텍스트 등이 전달되지 않습니다. 필요한 데이터는 명시적으로 전달하거나, ScopedValue(JDK 21+)를 활용하세요.
7실전 설계 — CountDownLatch + UPSERT + SELECT FOR UPDATE
앞서 살펴본 Virtual Thread 기반 해결책에서 한 단계 더 나아간 설계입니다. 핵심 아이디어는 Thread-2를 Lock 전담 스레드로만 쓰는 것입니다. 업무2() 실행은 Thread-1이 직접 하고, Thread-2는 Lock 행만 붙잡고 있다가 신호를 받으면 트랜잭션을 커밋해서 Lock을 자동 해제합니다.
Thread-2가 업무2()까지 처리하면 업무2가 오래 걸릴수록 Thread-1이 계속 대기해야 합니다. Thread-1 입장에서는 Lock을 잡는 시간 (짧음) 만 기다리면 충분합니다. 업무2 실행은 Thread-1이 직접 해야 지연이 없습니다.
Lock의 생명주기가 task2 블록과 정확히 일치하는 원리
LockGuardOptions — 이중 실행 감지 시 동작 옵션
Lock 획득 실패(이중 실행 감지) 시 Thread-1이 어떻게 반응할지를 호출 전에 옵션으로 주입합니다.
public class LockGuardOptions {
public enum OnDuplicate {
SKIP, // 조용히 넘어감 (멱등 처리)
FAIL // n초 대기 후 예외 발생
}
private final OnDuplicate onDuplicate;
private final long failWaitMillis; // FAIL 시 대기 시간 (ms)
public static LockGuardOptions skip() {
return new LockGuardOptions(OnDuplicate.SKIP, 0);
}
public static LockGuardOptions failAfter(long millis) {
return new LockGuardOptions(OnDuplicate.FAIL, millis);
}
}
@Transactional
public void processOrder(Long orderId, LockGuardOptions opts) {
task1_validateOrder(orderId); // Connection-1 사용
// ── Latch 준비 ──────────────────────────────────────────
CountDownLatch thread1Latch = new CountDownLatch(1); // T2 → T1 신호
CountDownLatch thread2Latch = new CountDownLatch(1); // T1 → T2 신호
AtomicReference<Exception> lockError = new AtomicReference<>();
// ── Thread-2: Lock 전담 Virtual Thread ──────────────────
Thread.ofVirtual().name("lock-guard-" + orderId).start(() -> {
try {
// A. UPSERT → 행 생성 or 갱신
lockRepo.upsertLock(orderId);
// B. SELECT FOR UPDATE WAIT {n}
// 다른 세션이 같은 행 잡고 있으면 ORA-30006 발생 → 이중 실행 감지
lockRepo.selectForUpdateWait(orderId);
// C. Lock 확보 완료 → Thread-1 깨움
thread1Latch.countDown();
// D. Thread-1이 task2() 완료할 때까지 대기 (timeout 없음 — 업무 최우선)
// 이 동안 Connection-2의 트랜잭션이 열려있어 Lock이 유지됨
thread2Latch.await();
} catch (Exception e) {
lockError.set(e);
thread1Latch.countDown(); // 에러여도 Thread-1 블로킹 해제
}
// E. 메서드 return → @Transactional Proxy가 commit()
// → SELECT FOR UPDATE Lock 자동 해제 (DELETE 불필요)
});
// ── Thread-1: Lock 확보 대기 ────────────────────────────
thread1Latch.await();
// ── 이중 실행 감지 처리 ──────────────────────────────────
Exception err = lockError.get();
if (err != null) {
thread2Latch.countDown(); // Thread-2 정리
if (opts.getOnDuplicate() == OnDuplicate.SKIP) {
return; // 조용히 넘어감
}
Thread.sleep(opts.getFailWaitMillis()); // n ms 대기 후 예외
throw new DuplicateExecutionException("이미 처리 중인 요청입니다", err);
}
// ── task2 보호 구간 ──────────────────────────────────────
// try/finally가 핵심: 성공/예외/롤백/인터럽트 무관하게 Thread-2를 반드시 깨움
try {
task2_deductInventory(orderId); // Thread-1이 직접 실행 (Connection-1)
} finally {
thread2Latch.countDown(); // ✅ Thread-2 깨움 → 커밋 → Lock 자동 해제
}
task3_createShipment(orderId); // Lock과 무관, Connection-1 계속 사용
}
task2 정상 완료 → countDown() → Thread-2 커밋 → Lock 해제
task2 예외 발생 → countDown() → Thread-2 커밋 → Lock 해제 → @Transactional 롤백
HTTP timeout 인터럽트 → countDown() → Thread-2 커밋 → Lock 해제 → Thread-1 롤백
JVM 크래시 → Java finally도 실행 불가, 그러나 Oracle 세션 자체가 끊겨 Lock 자동 해제
SELECT FOR UPDATE WAIT {n} 의 역할
-- UPSERT: 행이 없으면 INSERT, 있으면 touched_at 갱신
MERGE INTO execution_lock el
USING (SELECT :orderId AS order_id FROM dual) src
ON (el.order_id = src.order_id)
WHEN NOT MATCHED THEN
INSERT (order_id, created_at) VALUES (:orderId, SYSDATE)
WHEN MATCHED THEN
UPDATE SET el.touched_at = SYSDATE;
-- SELECT FOR UPDATE WAIT {n}
-- 다른 세션이 같은 행을 잡고 있으면 n초 대기 후 ORA-30006 발생
-- n = 이중 실행을 얼마나 빨리 감지할지 결정하는 값 (보통 0~3초)
SELECT order_id FROM execution_lock
WHERE order_id = :orderId
FOR UPDATE WAIT 3;
너무 짧으면 (WAIT 0 = NOWAIT) — 정상적인 첫 번째 요청이 아직 Lock을 잡기 전에 두 번째 요청이 도달해서 false positive 발생 가능
너무 길면 — 이중 실행 감지까지 오래 걸려 Connection-2를 오래 점유
권장 — 업무2()의 시작까지 걸리는 시간보다 약간 길게 설정 (보통 1~3초)
Oracle의 세션 종료 시 동작 — 주의사항
Thread-1이 비정상 종료될 때 Oracle이 자동 롤백을 해주는지 궁금할 수 있습니다. 결론은 "상황에 따라 다르며, 의존하면 위험합니다."
| 종료 방식 | Oracle 동작 | 안전 여부 |
|---|---|---|
| HTTP timeout → Thread 인터럽트 | Java finally가 실행되어 명시적 롤백 + countDown() | ✅ 안전 (finally 보장) |
| DBA가 세션 KILL | Oracle이 해당 세션 트랜잭션 자동 롤백 | ✅ 안전 |
| Connection Pool removeAbandoned | Oracle에서 close()가 커밋으로 처리될 수 있음 | ❌ 위험 — 설정 주의 |
| JVM 크래시 | Oracle 세션 끊김으로 자동 롤백 | ✅ 안전 (Oracle 보장) |
Thread-2가 thread2Latch.await()로 오래 블로킹되면 HikariCP가 Connection-2를 "유기된 커넥션"으로 판단할 수 있습니다. 이때 강제 close()가 호출되면 Oracle에서는 롤백 대신 커밋이 될 수 있습니다. keepaliveTime과 maxLifetime을 업무2의 예상 최대 실행 시간보다 충분히 길게 설정하세요.
8공통 백엔드 개발자가 꼭 알아야 할 원리 정리
① 스레드와 상태 격리 원리
| 개념 | 핵심 이해 | 실제 적용 |
|---|---|---|
ThreadLocal |
스레드 로컬 변수 — 스레드마다 별도 공간 | Spring 트랜잭션, MDC 로깅, SecurityContext 저장 |
| 스레드 풀 재사용 | 스레드가 재사용될 때 ThreadLocal이 남아있을 수 있음 | finally 블록에서 반드시 ThreadLocal 초기화 |
| 스레드 경계 | 트랜잭션은 스레드 경계를 넘지 않음 | 비동기(@Async) 사용 시 트랜잭션 전파 안 됨을 이해 |
② 트랜잭션 설계 원칙
| 원칙 | 내용 |
|---|---|
| 트랜잭션은 짧게 | 트랜잭션이 긴 시간 열려있으면 Lock이 길게 유지되고 Connection을 점유합니다. HTTP 호출, 외부 API 등은 트랜잭션 밖으로 빼세요. |
| 경계를 명확히 | 어디서 트랜잭션이 시작되고 끝나는지 항상 추적하세요. @Transactional이 붙었다고 항상 새 트랜잭션인 건 아닙니다 (REQUIRED 전파). |
| 읽기와 쓰기 분리 | 조회 전용은 @Transactional(readOnly = true)로 최적화하세요. 불필요한 쓰기 Lock을 피하고 성능이 향상됩니다. |
| 멱등성 설계 | 재시도가 발생해도 같은 결과가 나오도록 설계하세요. UPSERT, 유니크 제약 조건, 처리 상태 플래그를 활용하세요. |
③ Connection Pool 설계
# application.yml
spring:
datasource:
hikari:
maximum-pool-size: 10 # 권장: (CPU코어×2) + 디스크수
connection-timeout: 30000 # 30초 초과 시 예외
minimum-idle: 5
max-lifetime: 1800000 # 30분 (DB wait_timeout보다 짧게)
connection-test-query: SELECT 1
connections = (CPU 코어 수 × 2) + 디스크 수 — 예: 4코어 서버 + SSD 1개 → 최적 Pool 사이즈 = 9. 너무 많은 Connection이 오히려 Lock 경합과 컨텍스트 스위칭 비용으로 성능을 저하시킵니다.
④ 동시성 제어 전략 비교
| 방법 | 장점 | 단점 | 적합한 상황 |
|---|---|---|---|
낙관적 락 @Version |
Lock 없이 충돌 감지, 읽기 성능 우수 | 충돌 시 재시도 로직 필요 | 충돌 빈도가 낮을 때 |
| 비관적 락 SELECT FOR UPDATE | 강력한 일관성 보장 | Lock 대기로 처리량 저하, 데드락 위험 | 충돌이 자주 발생할 때 |
| 분산 Lock Redis/Zookeeper | 다중 서버 환경에서 동작 | 인프라 추가, TTL/Heartbeat 관리 | 여러 서버가 공유 자원에 접근 |
| DB 유니크 제약 | 단순하고 강력, DB가 보장 | 삽입-삭제 패턴에만 적용 가능 | 중복 처리 방지 (멱등성) |
⑤ 다음으로 공부할 토픽
1. ThreadLocal이 같은 스레드 내 Connection을 공유하게 한다.
2. @Transactional은 AOP Proxy가 Connection 획득-커밋/롤백-반납을 대신한다.
3. 스레드 풀 스레드는 블로킹되면 안 된다 — Thread.join()은 OS 스레드를 잡는다.
4. Virtual Thread는 새로운 ThreadLocal = 새 Connection = 독립 트랜잭션이다.
5. 트랜잭션 설계는 "어느 Connection이, 언제, 어떤 Lock을 잡는가"를 항상 추적해야 한다.
'SERVER > Spring' 카테고리의 다른 글
| @NoArgsConstructor & @AllArgsConstructor: 무작위 생성자 어노테이션의 진짜 의미 (0) | 2026.01.06 |
|---|---|
| JVM 클래스 로딩 vs Spring 컴포넌트 스캔: 같은 ‘로딩’이 아니다 (0) | 2025.12.31 |
| [HandlerMethodArgumentResolver] 레거시코드, 고쳐도 될까? (0) | 2025.12.31 |
| [Spring Batch] 수기 업무의 늪에서 탈출하기: 조건부 흐름 제어와 대용량 처리 (1) | 2025.12.02 |
| [Spring Security] redis/DB로 자동로그인 구현 (0) | 2025.11.28 |