포스트

[Spring] Springboot & Hibernate는 언제 커넥션을 획득하고 반납할까?

선 요약

  • Hibernate의 커넥션 핸들링 기본 전략은 DELAYED_ACQUISITION_AND_RELEASE_AFTER_TRANSACTION, 커넥션이 필요할 때 획득되고 트랜잭션이 끝나면 반납한다.
  • Springboot가 부트스트래핑 하는 과정에서 전략을 DELAYED_ACQUISITION_AND_HOLD 로 변경한다.
  • 따라서 기본 설정값으로 Springboot + Hibernate를 사용한다면 커넥션이 필요할 때 획득되고, 세션이 종료되면 반납한다.

이론

Hibernate의 커넥션 핸들링 전략엔 5가지가 있다. 친절하게도 코드 내에 주석으로 설명해주고 있다. PhysicalConnectionHandlingMode 라는 Enum에 정리해두었다.

  • IMMEDIATE_ACQUISITION_AND_HOLD : 세션이 열리면 얻고, 세션이 종료될 때 까지 쥐고있는다.
  • DELAYED_ACQUISITION_AND_HOLD : 커넥션이 필요할 때 획득되고, 세션이 종료될 때 까지 쥐고있는다.
  • DELAYED_ACQUISITION_AND_RELEASE_AFTER_STATEMENT : 커넥션이 필요할 때 획득되고, 각 statement가 종료될 때 놓아준다.
  • DELAYED_ACQUISITION_AND_RELEASE_BEFORE_TRANSACTION_COMPLETION : 커넥션이 필요할 때 획득되고, 커밋이나 롤백 후 놓아준다.
  • DELAYED_ACQUISITION_AND_RELEASE_AFTER_TRANSACTION : 커넥션이 필요할 때 획득되고, 각 트랜잭션이 종료되면 놓아준다.

이 값들은 애플리케이션이 실행될때 주입되며, LogicalConnectionManagedImpl 에 디버깅을 찍어보면 실제로 어떤 값이 들어가는지 볼 수 있다.

실제로 들어가는 값은 DELAYED_ACQUISITION_AND_HOLD 이다.

하지만 Hibernate의 기본 설정은 DELAYED_ACQUISITION_AND_RELEASE_AFTER_TRANSACTION 로 알고있고, 실제 공식문서에도 그렇게 나와있다.

공식문서 링크

하지만 Spring이 개입되면 이야기가 달라진다. Spring은 Hibernate의 기본 설정을 빈 주입단계에서 수정한다. HibernateJpaVendorAdapter에 가보면 왜 스프링이 값을 DELAYED_ACQUISITION_AND_HOLD 로 변경하는지 주석으로 나와있다.

Set whether to prepare the underlying JDBC Connection of a transactional Hibernate Session, that is, whether to apply a transaction-specific isolation level and/or the transaction’s read-only flag to the underlying JDBC Connection.

Spring은 개발자가 지정해준 격리 수준, 읽기 전용 여부 상태를 설정하기 위해 해당 메서드(prepareConnection()) 사용할지 말지 결정할 수 있다.

…(전략) On Hibernate 5.1+, this flag remains true by default like against previous Hibernate versions. The vendor adapter manually enforces Hibernate’s new connection handling mode DELAYED_ACQUISITION_AND_HOLD in that case unless a user-specified connection handling mode property indicates otherwise; switch this flag to false to avoid that interference.
…(후략)

이는 하이버네이트 5.1+ 버전에도 해당 옵션(prepareConnection() 을 사용할지 말지)은 true이다.

application.yml에 기본 설정을 적어주지 않는 한, Spring은 Hibernate의 커넥션 관리 모드를 DELAYED_ACQUISITION_AND_HOLD 로 수동으로 강제한다. 또한 코드의 일부 부분에서 HOLD 을 강제하는 것 처럼 보인다.

1
2
3
4
5
6
7
// HibernateJpaDialect.java
else if (isolationLevelNeeded) {
	throw new InvalidIsolationLevelException(
			"HibernateJpaDialect is not allowed to support custom isolation levels: " +
			"make sure that its 'prepareConnection' flag is on (the default) and that the " +
			"Hibernate connection release mode is set to ON_CLOSE.");
}

격리 레벨이 필요할 때, ON_CLOSE (예전 버전의 HOLD)가 아니면 예외를 발생시켜버린다. 결국 Spring의 아키텍쳐 설계는 반드시 세션과 커넥션의 종료를 같은 시점에 요구하고 있다.

하지만 좀 모호한 부분이 있다. DELAYED_ACQUISITION에서 말하는 “필요한” 이란 정확하게 어느 시점일까? 아래 실험을 통해 알아봤다.

실험에서는 두 가지 변인을 살펴보았다. 이전 게시글에서 봤듯이 OSIV는 세션과 관련있고, HOLD역시 세션과 관련있다. 또한 해결책을 찾는 과정에서 LazyConnectionDataSourceProxy을 사용하면 획득 시점을 늦출 수 있다는 방법도 보아서 실험해봤다.

시나리오 1: OSIV = true, db 지연로딩 없음

Transactional & 쿼리 있음

획득: @Transaction이 적용된 메서드 진입 시

반납: 컨트롤러 바깥 (세션이 종료되는 filter)

Transactional(readOnly = true) & 쿼리 있음

획득: @Transaction이 적용된 메서드 진입 시

반납: 컨트롤러 바깥

Transactional(readOnly = true) & 쿼리 없음

획득: @Transaction이 적용된 메서드 진입 시

반납: 컨트롤러 바깥

DELAYED_ACQUISITION 에서 말하는 “필요한” 이란 @Transactional이 붙은 메서드에 진입하는 시점이었다. 이는 readOnly 플래그와 관련 없음도 확인했다. 주석에서 말하는 커넥션의 각종 설정을 미리 한다는 뜻은 쿼리를 날리는 시점이 아니었다.

또한 트랜잭션 스코프를 벗어나도 커넥션을 반납하지 않는다. 이는 커넥션 관리에 어려움을 줄 수 있다.

시나리오 2: OSIV = false, db 지연로딩 없음

Transactional & 쿼리 있음

획득: @Transaction이 적용된 메서드 진입 시

반납: @Transaction이 적용된 메서드 종료 시

Transactional(readOnly = true) & 쿼리 있음

획득: @Transaction이 적용된 메서드 진입 시

반납: @Transaction이 적용된 메서드 종료 시

Transactional(readOnly = true) & 쿼리 없음

획득: @Transaction이 적용된 메서드 진입 시

반납: @Transaction이 적용된 메서드 종료 시

OSIV를 종료한 만큼, 세션이 View 레벨까지 이어지지 않아 커넥션을 미리 반납하는 모습을 볼 수 있다.

하지만 쿼리가 없어도 커넥션은 획득하기 때문이 비효율적인 부분이 존재한다.

시나리오 3: OSIV = false, db 지연로딩 있음

Transactional & 쿼리 있음

획득: 쿼리 실행 시

반납: @Transaction이 적용된 메서드 종료 시

Transactional(readOnly = true) & 쿼리 있음

획득: 쿼리 실행 시

반납: @Transaction이 적용된 메서드 종료 시

Transactional(readOnly = true) & 쿼리 없음

획득: X

반납: X

LazyConnectionDataSourceProxy가 어떻게 커넥션 획득을 늦출 수 있는가?

먼저, 앞서 말한 “필요한” 시점에 획득한다는 것을 알아보자.

왜 쿼리를 날리기도 전에 커넥션이 필요해 지는 걸까? 그것은 개발자가 미리 설정한 값들 (read-only, 격리수준 등)을 지정해주기 위함이다. 따라서 커넥션을 미리 요구한다.

자세히는

  1. JpaTransactionManager.doBegin() 실행
  2. HibernateJpaDialect.beginTrasaction()

    1
    2
    3
    4
    5
    6
    7
    8
    
     Connection preparedCon = null;
        
     if (isolationLevelNeeded || definition.isReadOnly()) {
     	if (this.prepareConnection && ConnectionReleaseMode.ON_CLOSE.equals(
     			session.getJdbcCoordinator().getLogicalConnection().getConnectionHandlingMode().getReleaseMode())) {
     		preparedCon = session.getJdbcCoordinator().getLogicalConnection().getPhysicalConnection();
     		previousIsolationLevel = DataSourceUtils.prepareConnectionForTransaction(preparedCon, definition);
     	}
    

    이때 preparedCon에 DataSource 객체가 들어오게 된다.

  3. DataSourceUtils.prepareConnectionForTransaction(); 해당 메서드 내부에서 readOnly 설정 등이 일어난다.

하지만 Proxy를 사용한다면 이야기가 달라진다.

Proxy란 실제 객체를 호출하는 대신, 실제 객체를 감싼 가짜 객체를 호출하는 방식이다.

우리가 설정을 통해 LazyConnectionDataSourceProxy를 DataSource 타입으로 Bean 등록해두면, 위 과정에서 실제 DataSource 대신 Proxy 객체가 들어온다.

따라서 실제로 커넥션을 꺼내와 값을 설정하지 않고, 설정값을 Proxy에 저장만 해 둔다. 이후 실제로 쿼리가 날아갈 때, 해당 값들을 커넥션에 주입해 사용한다.

해당 과정은

LazyConnectionDataSourceProxy.getTargetConnection() 에서 이루어진다.

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
32
33
34
35
36
37
38
39
40
41
42
43
private Connection getTargetConnection(Method operation) throws SQLException {
	if (this.target == null) {
		// No target Connection held -> fetch one.
		if (logger.isTraceEnabled()) {
			logger.trace("Connecting to database for operation '" + operation.getName() + "'");
		}

		// Fetch physical Connection from DataSource.
		DataSource dataSource = getDataSourceToUse();
		this.target = (this.username != null ? dataSource.getConnection(this.username, this.password) :
				dataSource.getConnection());
		if (this.target == null) {
			throw new IllegalStateException("DataSource returned null from getConnection(): " + dataSource);
		}

		// Apply kept transaction settings, if any.
		if (this.readOnly && readOnlyDataSource == null) {
			try {
				this.target.setReadOnly(true);
			}
			catch (Exception ex) {
				// "read-only not supported" -> ignore, it's just a hint anyway
				logger.debug("Could not set JDBC Connection read-only", ex);
			}
		}
		if (this.transactionIsolation != null &&
				!this.transactionIsolation.equals(defaultTransactionIsolation())) {
			this.target.setTransactionIsolation(this.transactionIsolation);
		}
		if (this.autoCommit != null && this.autoCommit != defaultAutoCommit()) {
			this.target.setAutoCommit(this.autoCommit);
		}
	}

	else {
		// Target Connection already held -> return it.
		if (logger.isTraceEnabled()) {
			logger.trace("Using existing database connection for operation '" + operation.getName() + "'");
		}
	}

	return this.target;
}

여기서 target 이 커넥션이다. 커넥션이 없다면 dataSource.getConnection()를 통해 이 곳에서 실제 커넥션을 불러온다.

이후 read-only 설정, 격리레벨 설정, 오토커밋 설정 등을 해 준다. 그렇기 때문에 쿼리가 나갈 때 실제 커넥션을 가져오는 것이었다.

그래서 어떤 값을 사용해야 할까?

원하는 것은 결국 하나다. 반드시 필요한 구간에서만 실제 커넥션을 사용해서 점유 시간을 줄여 처리량을 늘리는 것이다.

LazyConnectionDataSourceProxy 을 사용해서 실제 쿼리가 나갈 때 커넥션을 요구하도록 변경하면, 커넥션이 필요없는 순수 로직, 외부 API 호출에서 점유 시간을 아낄 수 있다.

커넥션을 View Level까지 이어지지 않게 하기 위해선 두 가지 선택지가 있다.

  • OSIV = false 설정
  • hibernate.connection.handling_mode: DELAYED_ACQUISITION_AND_RELEASE_AFTER_TRANSACTION 설정

위에서 언급했지만, Spring의 설계는 커넥션과 세션이 다르게 종료되길 원하지 않는다. 아마 추측으로, 지연로딩의 효율성 때문일 것이라 생각된다. 애써 값을 모두 설정해 둔 커넥션이 반납됐는데, 세션이 다시 커넥션을 요구하면 설정값을 다시 설정할 길이 없어진다. (설정값은 @Transactional 에 진입해야 설정 가능하다.)

따라서 OSIV를 꺼주어서 세션이 조기에 종료되게 하는 것이 옳은 방법이라고 생각한다.



이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.