Giwon's Blog

@Transactional - 스냅샷은 첫 쿼리에 만드는데, 커넥션은 왜 미리 잡을까

2026. 4. 5.

@Transactional 메서드 안에 섞여 있던 외부 API 호출을 트랜잭션 밖으로 분리하던 중, 한 가지 의문이 생겼다.

쿼리를 하나도 실행하지 않았는데, 커넥션은 왜 이미 잡혀 있을까?

MySQL의 기본 격리 수준인 REPEATABLE READ에서 스냅샷은 첫 번째 쿼리 시점에 생성된다.
그런데 커넥션은 @Transactional 메서드에 진입하는 순간 이미 잡혀 있다.
같은 트랜잭션 안에서 하나는 미루고, 하나는 미리 잡는다.

처음엔 "AOP라서 메서드 진입 시점에 잡는 거 아닌가?" 하고 넘어가려 했다.
그런데 생각해보니 이상했다. 스냅샷도 AOP 시점에 미리 만들어둘 수 있을 텐데, 스냅샷은 첫 쿼리까지 기다린다.
여기엔 분명 이유가 있을 거라 생각하고 한번 파보기로 했다.


autoCommit이 커넥션을 잡는 이유

커넥션이 잡히는 지점에 브레이크포인트를 두고, 실제로 어느 시점에 잡히는지 디버깅해보았다.
혹시 쿼리가 몰래 나가는 건 아닌가 싶어서, 메서드 안의 쿼리를 전부 비우고 테스트했다.
그런데도 브레이크포인트가 걸렸다. 쿼리와 무관하게, 메서드에 진입하는 것만으로 커넥션을 잡고 있었다.

TransactionInterceptor.invoke()             ← AOP 진입점
  → ...
    → LogicalConnectionManagedImpl.begin()  ← 여기서 커넥션을 잡음
      → HikariDataSource.getConnection()    ← 커넥션 획득

AOP가 트랜잭션을 시작하면, Hibernate가 begin()을 처리하는 과정에서 LogicalConnectionManagedImpl.begin()이 커넥션을 꺼내고 있었다.

좀 더 들여다보니 범인은 autoCommit 확인 로직이었다.
AOP로 인해 메서드 실행 전에 다음과 같은 코드가 호출된다.

// LogicalConnectionManagedImpl.java
initiallyAutoCommit = !doConnectionsFromProviderHaveAutoCommitDisabled()
    && determineInitialAutoCommitMode(getConnectionForTransactionManagement());

getConnectionForTransactionManagement() — 이 한 줄이 쿼리 실행 전에 커넥션을 잡는 원인이었다.

그렇다면 왜 autoCommit을 확인해야 할까?

JDBC 스펙상 커넥션의 기본 autoCommit 값은 true다. 이 상태에서는 각 SQL 문이 실행 즉시 커밋된다.
INSERT 두 건을 실행하다가 두 번째에서 에러가 나면, 첫 번째는 이미 커밋되어 롤백할 수 없다.
트랜잭션으로 묶으려면 반드시 setAutoCommit(false)를 먼저 호출해야 한다.

Hibernate 입장에서는 커넥션 풀이 autoCommit을 어떻게 설정해뒀는지 모른다.
그래서 AOP 시점에 커넥션을 꺼내서 getAutoCommit()으로 원래 상태를 저장하고, setAutoCommit(false)로 바꾼다.
트랜잭션이 끝나면 다음 사용자를 위해 원래 상태로 복원한 뒤 풀에 반납한다.

정리하면 Hibernate의 트랜잭션 라이프사이클은 이렇다:

  1. 커넥션 획득
  2. getAutoCommit()으로 원래 상태 저장
  3. setAutoCommit(false)
  4. 쿼리 실행
  5. commit/rollback
  6. setAutoCommit(원래값) 복원
  7. 풀에 반납

원래 상태를 저장하고 복원하려면 쿼리 전에 커넥션을 잡아야 한다.
이것이 스냅샷은 첫 쿼리까지 기다리면서, 커넥션은 미리 잡는 이유다.

그러면 다음 질문이 자연스럽게 떠오른다. 굳이 이 시점에 해야 할까?
첫 쿼리 시점에 setAutoCommit(false) 하면 안 되나?


첫 쿼리 시점에 하면 안 될까?

별도 설정 없이도, 첫 쿼리 시점에 커넥션을 잡고 getAutoCommit() 저장 → setAutoCommit(false) 호출하면 되지 않을까?
이렇게 하면 쿼리를 호출하지 않는 메서드에서 커넥션을 잡지 않을 수 있다.

왜 이 시점에 하는지에 대한 명확한 이유는 찾지 못했다.
추측해보자면, Hibernate의 트랜잭션 모델이 "트랜잭션 시작 = 커넥션 획득"이라는 단순한 구조로 설계된 것이고,
이에 대해 autoCommit 체크만 생략하는 최소한의 옵션만 제공하는 것으로 보인다.

하지만 이 동작이 실제로 문제가 되는 경우가 있다.


커넥션을 미리 잡으면 생기는 문제

커넥션풀에서 커넥션을 꺼내는 건, 이미 만들어진 TCP 연결을 인메모리에서 가져오는 것이라 빠르다.
문제는 커넥션을 언제 잡느냐가 아니라 얼마나 오래 점유하느냐다.

@Transactional 메서드 안에 외부 API 호출이 섞여 있다고 해보자.
외부 API 응답이 3초 걸리면, 그 3초 동안 아무 쿼리도 날리지 않으면서 커넥션을 점유한다.

@Transactional  // ← 여기서 커넥션 획득
public void process() {
    externalApi.call();    // 3초 대기... 커넥션은 묶여 있음
    repository.save(data); // 실제 DB 사용은 여기서부터
}

사실 @Transactional을 적용할 거라면, 커넥션 점유 여부를 떠나서 메서드 내에 외부 의존성을 함께 두지 않는 것을 선호한다. 명확한 경계를 위해 분리하는 게 좋다고 생각한다.

더 심각한 케이스는 클래스 단위 @Transactional 이다. DB를 전혀 쓰지 않는 메서드에서도 커넥션을 점유한다.

이런 케이스가 동시에 쌓이면 HikariCP의 커넥션풀이 고갈된다.
connectionTimeout을 초과한 스레드는 블로킹되고, Tomcat 스레드풀까지 연쇄적으로 마비될 수 있다.

스냅샷은 첫 쿼리까지 기다리는데, 커넥션은 setAutoCommit 때문에 미리 잡힌다.
이 간극이 불필요한 커넥션 점유의 원인이다.

그렇다면 이 간극을 없앨 수 있는 방법은 없을까?


해결: 커넥션 획득을 첫 쿼리까지 미루기

Hibernate 5.2.10에서 HHH-11542로 추가된 설정이 있다.

spring:
  datasource:
    hikari:
      # HikariCP가 커넥션을 꺼낼 때 이미 autoCommit=false 상태로 제공
      auto-commit: false
  jpa:
    properties:
      # "풀이 이미 처리했으니 확인하지 마"라고 Hibernate에게 알려줌
      hibernate.connection.provider_disables_autocommit: true

Hibernate가 이를 신뢰하면 확인할 것도, 바꿀 것도, 복원할 것도 없으니 커넥션을 미리 잡을 이유가 사라진다.

Before: 메서드 진입 → 커넥션 획득 → getAutoCommit → setAutoCommit(false) → ... → 쿼리 → commit → 복원 → 반납
After:  메서드 진입 → ... → 첫 쿼리 시점에 커넥션 획득 → 쿼리 → commit → 반납

아까의 코드에 적용하면 3초 동안 커넥션을 낭비하던 것이 사라진다.

@Transactional  // ← 커넥션 아직 안 잡힘
public void process() {
    externalApi.call();    // 3초 대기... 커넥션 없음
    repository.save(data); // ← 이 시점에 커넥션 획득
}

좋아보이는데, 왜 이게 기본값이 아닐까?


왜 기본값이 아닐까

provider_disables_autocommit=true는 "풀이 이미 autoCommit=false로 줄 테니 확인하지 마"라는 약속이다.
Hibernate는 이 약속이 진짜인지 검증할 방법이 없다.
만약 HikariCP의 auto-committrue로 설정되어 있다면, 트랜잭션이 조용히 깨진다.

그렇기에 위에서 언급한 두 설정은 반드시 같이 적용되어야 한다.


그런데 이 설정, 꼭 적용해야 할까?

@Transactional 메서드에서 외부 API를 호출한다면 트랜잭션 바깥에서 호출하도록 분리하면 된다.
쿼리 호출 전에 CPU 바운딩 작업이 있더라도, 말도 안 되는 시간복잡도로 코드를 짜놓지 않는 이상 얼마 안 걸린다.

코드를 잘 작성한다면 이 설정 없이도 커넥션으로 인한 블로킹 시간을 최소화할 수 있다.
약간의 성능 향상을 위해 이 설정을 적용하는 것은 오버엔지니어링일 수 있다.

그럼에도 이 설정이 의미 있는 환경이 있다. 대용량 트래픽에서 쿼리 지연시간을 최소화하고자 할 때다.


setAutoCommit의 숨은 비용

setAutoCommit(false)는 단순한 자바 메서드 호출이 아니다.
MariaDB 드라이버 기준으로, 내부적으로 SET autocommit=0이라는 SQL을 DB에 전송한다.
이는 건당 1~3ms의 Network I/O를 유발한다.

트랜잭션 하나에 setAutoCommit(false) + setAutoCommit(true) = 라운드트립 2회...
트래픽이 작으면 체감하기 어렵지만, 대용량 환경에서는 이야기가 달라진다.

참고한 기술블로그 기준으로, 쿠폰 발급에 총 6ms가 걸리는데, 그 중 4ms가 setAutoCommit 호출이었다.
provider_disables_autocommit=true 적용 후, 쿠폰 전체 API 평균 43% 성능 향상을 달성했다.
쿼리가 가볍고 빠른 API일수록 setAutoCommit의 상대적 비중이 커지기 때문이다.


정리

  • Hibernate는 커넥션 풀의 autoCommit 상태를 모르기 때문에, @Transactional 진입 시점에 커넥션을 잡아서 확인하고 끈다.
  • 이 동작이 불필요한 커넥션 점유를 유발할 수 있고, setAutoCommit 자체가 네트워크 라운드트립을 발생시킨다.
  • hikari.auto-commit=false + provider_disables_autocommit=true로 커넥션 획득을 첫 쿼리 시점까지 미룰 수 있다.
  • 다만 코드에서 외부 의존성을 트랜잭션 밖으로 분리하는 것이 더 근본적인 해결이다. 이 설정은 대용량 트래픽 환경에서 setAutoCommit의 네트워크 비용이 체감될 때 적용을 고려하면 될듯?

Reference