개발이군고구마

JDBC 트랜잭션의 원리 본문

SERVER/Spring

JDBC 트랜잭션의 원리

김구황 2026. 5. 17. 20:00
728x90

 

Backend Deep Dive

Spring + JDBC 트랜잭션의 원리
이중 실행 방지까지

ThreadLocal, Connection Pool, @Transactional의 동작 원리를 한 번에 이해하는 가이드 — 주니어 개발자를 위한 실전 해설

Spring Framework JDBC / Connection Pool ThreadLocal JDK 21 Virtual Thread

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입니다 — 스레드마다 별도의 변수 공간을 제공하는 것입니다.

다이어그램 1

Spring의 TransactionSynchronizationManager

Spring이 실제로 커넥션을 관리할 때 사용하는 클래스가 바로 TransactionSynchronizationManager입니다. 내부적으로 ThreadLocal로 구현되어 있습니다.

📄 Spring 내부 구현 (간략화)
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이 중첩되는 구조

다이어그램 2

3스레드 풀과 Connection 반납 원리

스레드를 매 요청마다 새로 만들면 CPU와 메모리 비용이 큽니다. 그래서 서버는 스레드 풀(Thread Pool)을 미리 만들어 두고 재사용합니다. Tomcat은 기본적으로 200개의 스레드를 유지합니다.

다이어그램 3

왜 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를 통해 동작하며, 이 원리를 모르면 예상치 못한 버그를 만납니다.

다이어그램 4
1
Spring이 Proxy 객체 생성
Spring 컨텍스트가 시작될 때, @Transactional이 붙은 빈을 감싸는 CGLIB Proxy를 생성합니다. 실제로 주입받는 것은 원본 객체가 아닌 이 Proxy입니다.
2
Proxy가 메서드 호출 가로채기
메서드가 호출되면 Proxy가 먼저 받아서 TransactionInterceptor를 실행합니다. 여기서 DataSource로부터 Connection을 가져와 ThreadLocal에 바인딩합니다.
3
실제 메서드 실행 — 같은 Connection 재사용
원본 메서드가 실행됩니다. 내부에서 Repository를 통해 DB에 접근할 때, JDBC는 ThreadLocal에서 아까 바인딩한 Connection을 꺼내어 사용합니다 — 모두 같은 트랜잭션에 속합니다.
4
예외 없으면 커밋, 예외 있으면 롤백 후 반납
메서드가 정상 종료되면 connection.commit()을 호출하고, RuntimeException이 발생하면 connection.rollback()을 호출합니다. 이후 Connection을 Pool에 반납합니다.
📄 @Transactional이 실제로 하는 일 (의사코드)
// 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에 반납
        }
    }
}
⚠️
@Transactional이 동작하지 않는 경우

같은 클래스 내부 호출은 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을 획득하려는 두 번째 요청은 실패시키는 것입니다.

📄 첫 번째 시도: REQUIRES_NEW + SELECT FOR UPDATE
@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 해제
}
다이어그램 5
🔴
HikariCP 데드락 방지 설정

REQUIRES_NEW를 사용한다면 최악의 경우 스레드당 2개의 Connection이 필요합니다. Pool 사이즈를 넉넉히 잡지 않으면 Connection 대기 타임아웃이 발생합니다.

⚙️
Heartbeat 방식의 문제

분산 Lock(Redis SETNX)을 쓰면 TTL을 설정할 수 있지만, "얼마나 긴 TTL을?"이라는 새로운 문제가 생깁니다. TTL이 짧으면 정상 처리 중에 Lock이 해제되고, TTL이 길면 서버 장애 시 Lock이 오래 남습니다. Heartbeat를 구현하면 복잡도가 폭발합니다. Redisson 같은 라이브러리가 이를 내부적으로 처리해 줍니다.

6JDK 21 Virtual Thread로 문제 해결하기

앞서 살펴본 모든 문제의 근원은 하나입니다: Thread-1이 업무2를 처리하는 동안 Connection-1을 잡고 있어야 한다는 것입니다. JDK 21의 Virtual Thread를 사용하면 이 문제를 우아하게 풀 수 있습니다.

다이어그램 6

Virtual Thread로 문제 해결하는 원리

핵심 아이디어는: 업무2를 Virtual Thread에서 실행시키는 것입니다. Virtual Thread는 자신만의 ThreadLocal을 가지므로, 새로운 Connection이 자동으로 할당됩니다.

다이어그램 7
📄 Virtual Thread를 활용한 개선된 구현
@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 데이터 공유 불가

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()를 실행하면 안 되나?

Thread-2가 업무2()까지 처리하면 업무2가 오래 걸릴수록 Thread-1이 계속 대기해야 합니다. Thread-1 입장에서는 Lock을 잡는 시간 (짧음) 만 기다리면 충분합니다. 업무2 실행은 Thread-1이 직접 해야 지연이 없습니다.

Lock의 생명주기가 task2 블록과 정확히 일치하는 원리

다이어그램 8

LockGuardOptions — 이중 실행 감지 시 동작 옵션

Lock 획득 실패(이중 실행 감지) 시 Thread-1이 어떻게 반응할지를 호출 전에 옵션으로 주입합니다.

📄 LockGuardOptions
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);
    }
}
📄 전체 구현 — processOrder()
@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 계속 사용
}
🔑
try/finally가 보장하는 것

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} 의 역할

📄 Oracle SQL — Lock 획득 쿼리
-- 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 {n} 값 설정 기준

너무 짧으면 (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 보장)
🔴
HikariCP removeAbandoned 주의

Thread-2가 thread2Latch.await()로 오래 블로킹되면 HikariCP가 Connection-2를 "유기된 커넥션"으로 판단할 수 있습니다. 이때 강제 close()가 호출되면 Oracle에서는 롤백 대신 커밋이 될 수 있습니다. keepaliveTimemaxLifetime을 업무2의 예상 최대 실행 시간보다 충분히 길게 설정하세요.

 

8공통 백엔드 개발자가 꼭 알아야 할 원리 정리

① 스레드와 상태 격리 원리

개념 핵심 이해 실제 적용
ThreadLocal 스레드 로컬 변수 — 스레드마다 별도 공간 Spring 트랜잭션, MDC 로깅, SecurityContext 저장
스레드 풀 재사용 스레드가 재사용될 때 ThreadLocal이 남아있을 수 있음 finally 블록에서 반드시 ThreadLocal 초기화
스레드 경계 트랜잭션은 스레드 경계를 넘지 않음 비동기(@Async) 사용 시 트랜잭션 전파 안 됨을 이해

② 트랜잭션 설계 원칙

원칙 내용
트랜잭션은 짧게 트랜잭션이 긴 시간 열려있으면 Lock이 길게 유지되고 Connection을 점유합니다. HTTP 호출, 외부 API 등은 트랜잭션 밖으로 빼세요.
경계를 명확히 어디서 트랜잭션이 시작되고 끝나는지 항상 추적하세요. @Transactional이 붙었다고 항상 새 트랜잭션인 건 아닙니다 (REQUIRED 전파).
읽기와 쓰기 분리 조회 전용은 @Transactional(readOnly = true)로 최적화하세요. 불필요한 쓰기 Lock을 피하고 성능이 향상됩니다.
멱등성 설계 재시도가 발생해도 같은 결과가 나오도록 설계하세요. UPSERT, 유니크 제약 조건, 처리 상태 플래그를 활용하세요.

③ Connection Pool 설계

📄 HikariCP 핵심 설정
# 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
📌
HikariCP 공식 권장 공식

connections = (CPU 코어 수 × 2) + 디스크 수 — 예: 4코어 서버 + SSD 1개 → 최적 Pool 사이즈 = 9. 너무 많은 Connection이 오히려 Lock 경합과 컨텍스트 스위칭 비용으로 성능을 저하시킵니다.

④ 동시성 제어 전략 비교

방법 장점 단점 적합한 상황
낙관적 락 @Version Lock 없이 충돌 감지, 읽기 성능 우수 충돌 시 재시도 로직 필요 충돌 빈도가 낮을 때
비관적 락 SELECT FOR UPDATE 강력한 일관성 보장 Lock 대기로 처리량 저하, 데드락 위험 충돌이 자주 발생할 때
분산 Lock Redis/Zookeeper 다중 서버 환경에서 동작 인프라 추가, TTL/Heartbeat 관리 여러 서버가 공유 자원에 접근
DB 유니크 제약 단순하고 강력, DB가 보장 삽입-삭제 패턴에만 적용 가능 중복 처리 방지 (멱등성)

⑤ 다음으로 공부할 토픽

📚
트랜잭션 격리 수준
READ UNCOMMITTED / READ COMMITTED / REPEATABLE READ / SERIALIZABLE — Phantom Read, Dirty Read의 차이
🏗️
SAGA 패턴
마이크로서비스에서 분산 트랜잭션을 대체하는 패턴. Choreography vs Orchestration 방식
🔄
Outbox 패턴
DB 저장과 메시지 발행의 원자성 보장. 이중 쓰기 문제 해결
🧵
Project Loom 심화
Carrier Thread, Pinning, ScopedValue, StructuredConcurrency
📊
MVCC
PostgreSQL/MySQL의 다중 버전 동시성 제어. Snapshot Isolation 내부 동작
R2DBC / Reactive
논블로킹 DB 접근. Virtual Thread와의 차이점과 각각의 장단점 비교
 
🎯
핵심 5줄 요약

1. ThreadLocal이 같은 스레드 내 Connection을 공유하게 한다.
2. @Transactional은 AOP Proxy가 Connection 획득-커밋/롤백-반납을 대신한다.
3. 스레드 풀 스레드는 블로킹되면 안 된다 — Thread.join()은 OS 스레드를 잡는다.
4. Virtual Thread는 새로운 ThreadLocal = 새 Connection = 독립 트랜잭션이다.
5. 트랜잭션 설계는 "어느 Connection이, 언제, 어떤 Lock을 잡는가"를 항상 추적해야 한다.