트러블슈팅과 고민

멀티 스레드 환경에서 공유 세션으로 발생한 동시성 경합과 커넥션 누수 분석 및 해결

정재익 2025. 10. 19. 22:00

동기에서 비동기 로직으로의 변경

제 서비스는 상대방에게 편지를 작성하면 DB에 저장하고 편지주인에게 FCM 푸시 알림을 전송합니다.
기존에는 편지를 작성하면 DB에 저장하고 상대방에게 FCM 푸시 알림까지 동기적으로 하였습니다.
응답속도 개선을 위해 FCM 푸시 알림 로직을 별도의 스레드 풀을 사용하는 비동기 이벤트 구조로 변경했습니다.
 

문제 발생

그 이후 동일한 테스트를 시행했을 때 ConcurrentModificationException이라는 다량의 예외와 동시에 DB 커넥션 누수가 탐지되었습니다. 커넥션 생성주기가 짧아서 그런 것일 까봐 커넥션 생명주기와 RDS의 타임아웃 설정을 조정해도 같은 문제가 발생했습니다.
 

문제의 코드

// 1. 메인 스레드 편지 보내기 로직
public void 편지 보내기(String userName, MessageDTO messageDTO) {
    유저엔티티조회 // 편지를 전달받을 사람을 조회, 조회 직후 준영속으로 변경
    편지 저장 // 그 사람의 편지함에 편지 저장
    // 비동기 이벤트 발생,  준영속 엔티티 전달
    eventPublisher.publishEvent(new MessageEvent(유저엔티티))}


// 2. 이후 리스너가 이벤트를 수신하여 새로운 스레드가 아래의 푸시 알림 로직을 실행합니다.
public void 편지 전달 받을 사람에게 FCM 푸시 알림 발송(유저엔티티) {
    try { // 준영속 엔티티를 대상으로 지연로딩
        if (푸시 알림이 허용되어 있다면) { // 지연로딩으로 유저의 설정 조회
            FCM메시지 보내기
        }
    } catch (예외) {
        throw new CustomException(ErrorCode.FCM_SEND_ERROR);
    }
}

준영속 엔티티를 대상으로 지연로딩을 했기에 LazyInitializationException 예상했지만 ConcurrentModificationException이 발생했습니다.
 

실제로 누수가 되었는가?

spring.datasource.hikari.leak-detection-threshold=60000

이 설정은 해당 밀리초 동안 돌아오지 않는 커넥션을 누수로 간주하고 누수가 일어난 커넥션의 최초 획득 지점을 로그로 보여줍니다. 따라서 실제로 누수가 일어난것이 아니라 시간이 지나 다시 돌아올 수도 있습니다.
 
한번 오래 기다려 보겠습니다.

2025-10-19 19:00:25.917 [HikariPool-1 connection adder] DEBUG com.zaxxer.hikari.pool.HikariPool - HikariPool-1 - After adding stats (total=40, active=0, idle=40, waiting=0)
2025-10-19 19:39:24.809 [HikariPool-1 housekeeper] DEBUG com.zaxxer.hikari.pool.HikariPool - HikariPool-1 - Pool stats (total=40, active=10, idle=30, waiting=0)

히카리풀을 디버그 해본 결과 최초의 40개의 커넥션이 있었고 테스트를 시행한 이후 약 40분을 기다렸지만 커넥션이 돌아오지 않아 실제로 누수가 된 것을 확인하였습니다.
 

어디서 누수가 되었는가?

[http-nio-8080-exec-7] 커넥션 획득: ConnectionImpl@4242175f | 스레드: http-nio-8080-exec-7 | 호출위치: 편지보내기의 유저엔티티 조회
[HikariPool-1 housekeeper] WARN  c.zaxxer.hikari.pool.ProxyLeakTask -
Connection leak detection triggered for com.mysql.cj.jdbc.ConnectionImpl@4242175f on thread http-nio-8080-exec-7, stack trace follow

로그를 분석한 결과 메인스레드에서 커넥션 누수가 발생하였고 해당 커넥션이 누수된 지점은 메인스레드의 유저엔티티조회입니다.
상대방에게 푸시알림을 보내기 위해 엔티티를 조회한 커넥션에서 누수가 발생되었습니다.
 

언제 누수가 되었는가?

히카리풀의 누수 탐지 설정은 누수가 일어난 커넥션을 획득한 시점만 보여주지 언제 누수가 일어난지는 보여주지 않습니다.
다만 커넥션을 추적하면 어느것이 트리거가 된지 확인할 수 있습니다.
이전에 4242175f라는 커넥션이 7번 스레드에 바인딩 된것을 확인하였습니다. 그리고 아래 로그를 보겠습니다.

[HTTP 7번 스레드] 편지 작성 - 스레드: HTTP 7번스레드 , 영속
[FCM 2번 스레드] 지연로딩 직전 - 준영속
[HTTP 7번 스레드] ERROR o.s.o.jpa.EntityManagerFactoryUtils - Failed to release JPA EntityManager (리소스 해제 실패)
ResourceRegistryStandardImpl에서 java.util.ConcurrentModificationException 발생
.
.
.
at org.springframework.orm.jpa.support.OpenEntityManagerInViewInterceptor.afterCompletion(OpenEntityManagerInViewInterceptor.java:112)
at org.hibernate.internal.SessionImpl.immediateLoad(SessionImpl.java:1048)
at org.hibernate.proxy.AbstractLazyInitializer.initialize(AbstractLazyInitializer.java:186)
at org.hibernate.proxy.AbstractLazyInitializer.getImplementation(AbstractLazyInitializer.java:328)
at org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor.intercept(ByteBuddyInterceptor.java:44)
at org.hibernate.proxy.ProxyConfiguration$InterceptorDispatcher.intercept(ProxyConfiguration.java:102)
at 유저의 설정$HibernateProxy$1QGiBoN0.isMessageNotification(Unknown Source)
at FCM 전송(FcmService.java:51)

[HikariPool-1 housekeeper] WARN  c.zaxxer.hikari.pool.ProxyLeakTask - Connection leak detection triggered for com.mysql.cj.jdbc.ConnectionImpl@4242175f on thread http-nio-8080-exec-7, stack trace follows

위 로그를 해석해 보겠습니다.
 
1. 편지 작성 후 비동기 호출 직전 메인 스레드는 영속상태입니다
2. 지연로딩 직전 비동기스레드는 준영속상태입니다
2. 메인 스레드에서 세션 해제가 실패했습니다.
3. ResourceRegistryStandardImpl는 Jdbc의 리소스를 HashMap으로 보관하는 구현체입니다. 여기서 동시에 하나의 자료구조를 수정할때 발생하는 ConcurrentModificationException가 발생했습니다.
4OpenEntityManagerInViewInterceptor.afterCompletion로 OSIV가 존재함을 확인할 수 있습니다. 또한 OSIV의 종료가 호출 되었던것도 확인 가능합니다.
5. 아래쪽 에러코드는 지연로딩에 관한 에러입니다. 지연로딩시에 오류가 발생했다는 것을 알 수 있습니다. 다만 여기서 SesionImpl.immediateLoad()까지 접근하는데 준영속 상태에서 접근할 수 없는 곳 입니다. 나중에 상세히 다루겠습니다.
6. 그리고 4242175f은 누수가 되었고 로그에 첨부하지는 못했지만 커넥션의 숫자가 줄고 끝까지 커넥션 풀로 돌아오지 못했습니다.
한마디로 지연로딩으로 인한 ConcurrentModificationException로 커넥션이 누수 되었다는 것을 알 수 있습니다.

 

먼저 세션과 커넥션에 대해 알아봅시다

 
1. 세션(엔티티 매니저의 하이버네이트 구현체)의 참조는 스레드로컬에 저장됩니다. 세션 객체가 아니라 세션의 참조를 스레드로컬에 저장합니다. 실제로 하이버네이트 세션 클래스를 살펴보면 세션은 스레드safe하지 않다고 주석이 적혀 있습니다. 세션의 내부에는 세션이 스레드마다 유일할 수 있도록 동기화하는 매커니즘은 없습니다 따라서 여러 스레드가 하나의 세션을 바라보는 상황은 일어날 수 있습니다.
2. 세션 내부에는 커넥션의 참조가 있습니다. 세션은 꼭 커넥션이 활성화 되어야 하는 것은 아닙니다. JPA에서 세션은 참조를 통해 커넥션을 간접적으로 관리합니다.
 

지금까지 얻은 근거로 원인 도출

다시 기존 코드를 보며 지금까지 얻은 근거로 상황정리를 해보겠습니다. 

// 1. 메인 스레드 편지 보내기 로직
public void 편지 보내기(String userName, MessageDTO messageDTO) {
    유저엔티티조회 // 편지를 전달받을 사람을 조회, 조회 직후 준영속으로 변경
    편지 저장 // 그 사람의 편지함에 편지 저장
    // 비동기 이벤트 발생,  준영속 엔티티 전달
    eventPublisher.publishEvent(new MessageEvent(유저엔티티))}


// 2. 이후 리스너가 이벤트를 수신하여 새로운 스레드가 아래의 푸시 알림 로직을 실행합니다.
public void 편지 전달 받을 사람에게 FCM 푸시 알림 발송(유저엔티티) {
    try { // 준영속 엔티티를 대상으로 지연로딩
        if (푸시 알림이 허용되어 있다면) { // 지연로딩으로 유저의 설정 조회
            FCM메시지 보내기
        }
    } catch (예외) {
        throw new CustomException(ErrorCode.FCM_SEND_ERROR);
    }
}

1. 메인스레드에서 커넥션은 @Transactional이 없어 쿼리 실행시 커넥션의 조회와 반납이 이루어집니다
2. OSIV가 켜져있어 세션의 닫힘은 HTTP 응답의 완료 이후에 일어납니다.
3. OSIV의 활성화 때문에 세션이 스레드에 존재한다고 표시된 로그를 이해 할 수 있습니다.
4. OSIV는 비동기 로직에 영향을 줄 수 없습니다. 세션의 생명주기가 연장되어도 세션의 참조는 스레드로컬에 바인딩되고 그것이 비동기 스레드에 영향을 줄 수 없습니다.
실제로 비동기 로직의 지연로딩 직전에 엔티티가 준영속상태로 나타났습니다.
5. 하지만 두 가지의 스레드가 하나의 세션을 이용해야 ResourceRegistryStandardImpl의 HashMap을 수정해서 ConcurrentModificationException이 발생할 수 있습니다. 그리고 비동기 스레드는 SesionImpl.immediateLoad()까지 접근하는데 세션이 없으면 LIE 에러가 반환되고 접근 불가능한 루트입니다 (나중에 상세히 말씀드리겠습니다.)
 
그럼 밝혀야 할 것은 네가지 입니다.
1. 지연로딩 직전에 비동기 스레드의 엔티티는 준영속이다. 어떻게 LIE를 회피하고 세션의 즉시로드를 호출하였는가
2. 어떻게 별도의 스레드가 동일한 세션을 공유하였는가
3. 왜 ConcurrentModificationException이 발생하였는가
4. 안전장치가 되어있을텐데 ConcurrentModificationException에서 커넥션이 누수된 이유는 무엇인가.
 

1. 세션이 없는 비동기 스레드가  LIE를 회피하고 세션의 즉시로드를 호출한 이유

첫번째 스택트레이스 InterceptorDispatcher.intercept()를 보겠습니다.

class InterceptorDispatcher {

    @RuntimeType
    public static Object intercept(
          @This final Object instance,
          @Origin final Method method,
          @AllArguments final Object[] arguments,
          @StubValue final Object stubValue,
          @FieldValue(INTERCEPTOR_FIELD_NAME) Interceptor interceptor
    ) throws Throwable {
       if ( interceptor == null ) {
          if ( method.getName().equals( "getHibernateLazyInitializer" ) ) {
             return instance;
          }
          else {
             return stubValue;
          }
       }
       else {
          return interceptor.intercept( instance, method, arguments );
       }
    }

 
@FieldValue(INTERCEPTOR_FIELD_NAME) Interceptor interceptor에서 볼 수 있듯 기존 프록시가 있어야만 이 메서드가 실행됩니다. 한마디로 비동기 스레드에는 이미 기존에 프록시가 있었습니다. 멤버에서 설정엔티티를 지연로딩하는 상황에서 멤버안의 설정 엔티티는 프록시 상태로 남습니다. 그 프록시를 호출한 것 입니다.
여기서 return interceptor.intercept( instance, method, arguments )를 호출하여 ByteBuddyInterceptor.intercept()로 갑니다.
 
ByteBuddyInterceptor.intercept()는프록시로 메서드를 가로채고 메서드의 실행을 위해서 AbstractLazyInitializer. getImplementation()로 가고 그 곳에서 AbstractLazyInitializer.initialize()을 호출합니다.
AbstractLazyInitializer.initialize() 의 내부 구현을 보겠습니다.

@Override
public final void initialize() throws HibernateException {
    if ( !initialized ) {
       try {
          if ( allowLoadOutsideTransaction ) {
             permissiveInitialization();
          }
          else if ( session == null ) {
             throw new LazyInitializationException( "Could not initialize proxy ["
                   + entityName + "#" + id + "] - no session" );
          }
          else if ( !session.isOpenOrWaitingForAutoClose() ) {
             throw new LazyInitializationException( "Could not initialize proxy ["
                   + entityName + "#" + id + "] - the owning session was closed" );
          }
          else if ( !session.isConnected() ) {
             throw new LazyInitializationException( "Could not initialize proxy ["
                   + entityName + "#" + id + "] - the owning session is disconnected" );
          }
          else {
             target = session.immediateLoad( entityName, id );
             initialized = true;
             checkTargetState( session );
          }
       }
       finally {
          if ( session != null && !session.isTransactionInProgress() ) {
             session.getJdbcCoordinator().afterTransaction();
          }
       }
    }
    else {
       checkTargetState( session );
    }
}

여기서 LIE가 되는 조건과 되지 않는 조건이 있습니다.
세션이 존재해야하며 세션이 열려있어야하고 세션이 커넥션과 연결되어있어야합니다. 이중에 하나라도 만족하지 않으면 LIE에러가 반환됩니다. 하지만 모든 조건을 만족하여 session.immediateLoad( entityName, id );를 호출했습니다.
 
로그에서 지연 로딩 직전 비동기 스레드에 세션이 존재하지 않았다고 적힌것과 반대의 상황입니다.
프록시의 구현체인 MapProxy의 일부를 보겠습니다.

public class MapProxy implements HibernateProxy, Map, Serializable {

    private final MapLazyInitializer li;

    private Object replacement;

    MapProxy(MapLazyInitializer li) {
       this.li = li;
    }

    @Override
    public LazyInitializer getHibernateLazyInitializer() {
       return li;
    }

프록시는 LazyInitializer의 참조를 가지고 있습니다.
 
LazyInitializer의 구현체인 AbstractLazyInitializer의 일부 구현을 보겠습니다.

public abstract class AbstractLazyInitializer implements LazyInitializer {
    private static final CoreMessageLogger LOG = CoreLogging.messageLogger( AbstractLazyInitializer.class );

    private final String entityName;
    private Object id;
    private Object target;
    private boolean initialized;
    private boolean readOnly;
    private boolean unwrap;
    private transient SharedSessionContractImplementor session;
    private Boolean readOnlyBeforeAttachedToSession;

    private String sessionFactoryUuid;
    private String sessionFactoryName;
    private boolean allowLoadOutsideTransaction;

AbstractLazyInitializer는 세션의 참조를 가지고 있습니다. 즉 프록시는 세션의 참조를 가지고 있습니다.
한마디로 준영속엔티티안에 프록시가 살아있었고 해당 프록시는 세션의 참조를 가지고 있기에 지연로딩이 LIE를 회피하고 세션즉시로딩을 실행했습니다.
일반적으로 준영속이란 세션(엔티티매니저)의 생명주기를 따릅니다. 세션이 닫혀있으면 LIE가 반환되지만 세션은 열려있었습니다.
OSIV는 비동기로직에 영향을 주지못하고 새로운 세션을 할당합니다.
준영속 상태라 세션은 연결되지않지만 엔티티 내부 프록시에는 세션의 참조가 있고 그로인해 메인스레드의 세션에 접근할 수 있었습니다.
그때 메인스레드의 세션이 닫혀있으면 LIE가 나타나겠지만
HTTP 응답의 반환이 마무리 되지않아 OSIV의 종료가 시작되지 않아 세션이 살아있었고 그 시점에 지연로딩을 시작한것입니다. 이것은 OSIV의 영향입니다.

OSIV는 비동기로직에 영향을 주지는 못한다고 알려져있지만 비동기스레드에서 프록시를 통한 메인스레드의 세션에 접근하면 메인스레드의 세션오픈가능성이 있기때문에 간접적으로 영향을 줄 수 있습니다
 
2번의 어떻게 별도의 스레드가 동일한 세션을 공유하였는가도 같이 해결이 됩니다. 프록시의 세션참조로 메인스레드와 같은 세션을 공유할 수 있었습니다.
 
 

2. 왜 ConcurrentModificationException이 발생하였는가

계속 지연로딩절차를 따라가 보겠습니다.
1. SessionImple.immediateLoad에서 fireLoadNoChecks()를 호출합니다.
2. AbstractEntityPersister.doLoad()
3. Loader.executeQueryStatement()
4. JdbcCoordinatorImpl.executeQuery()
5. StatementPreparerImpl.prepareStatement()
6. ResourceRegistryStandardImpl.register()
 
이 과정에서 세션에 다시 새로운 커넥션이 생기게 됩니다.
그리고 문제의 ConcurrentModificationException이 일어난 ResourceRegistryStandardImpl에 접근합니다.
ResourceRegistryStandardImpl의 relaseResource()와 register()의 구현을 보겠습니다.

private final HashMap<Statement, HashMap<ResultSet,Object>> xref = new HashMap<>();

@Override
public void releaseResources() {
	log.trace( "Releasing JDBC resources" );

	if ( jdbcObserver != null ) {
		jdbcObserver.jdbcReleaseRegistryResourcesStart();
	}

	xref.forEach( ResourceRegistryStandardImpl::releaseXref ); // 오류가 발생한 부분
	xref.clear();
    
	private static void closeAll(final HashMap<ResultSet,Object> resultSets) {
		if ( resultSets == null ) {
			return;
		}
		resultSets.forEach( (resultSet, o) -> close( resultSet ) );
		resultSets.clear();
	}
        
@Override
public void register(ResultSet resultSet, Statement statement) {
    log.tracef( "Registering result set [%s]", resultSet );

    if ( statement == null ) {
       try {
          statement = resultSet.getStatement();
       }
       catch (SQLException e) {
          throw convert( e, "unable to access Statement from ResultSet" );
       }
    }
    if ( statement != null ) {
       HashMap<ResultSet,Object> resultSets = xref.get( statement );

       // Keep this at DEBUG level, rather than warn.  Numerous connection pool implementations can return a
       // proxy/wrapper around the JDBC Statement, causing excessive logging here.  See HHH-8210.
       if ( resultSets == null ) {
          log.debug( "ResultSet statement was not registered (on register)" );
       }

       if ( resultSets == null || resultSets == EMPTY ) {
          resultSets = new HashMap<>();
          xref.put( statement, resultSets );
       }
       resultSets.put( resultSet, PRESENT );
    }
    else {
       if ( unassociatedResultSets == null ) {
          this.unassociatedResultSets = new HashMap<ResultSet,Object>();
       }
       unassociatedResultSets.put( resultSet, PRESENT );
    }
}

지연로딩으로 가져온 결과 값으로 HashMap을 수정하는 모습이 보입니다.
추가로 이 메서드에서 예외가 터지면 HashMap이 힙메모리에 그대로 남아 고아객체가 되지만 GC에 의해 정리됩니다.
 
그럼 이제 메인 스레드의 상황을 보겠습니다. OSIV에서 응답이 끝나고 세션을 닫을때 입니다.
아래는 OpenEntityManagerInViewInterceptor.afterCompletion() 메서드의 구현입니다.

@Override
public void afterCompletion(WebRequest request, @Nullable Exception ex) throws DataAccessException {
    if (!decrementParticipateCount(request)) {
       EntityManagerHolder emHolder = (EntityManagerHolder)
             TransactionSynchronizationManager.unbindResource(obtainEntityManagerFactory());
       logger.debug("Closing JPA EntityManager in OpenEntityManagerInViewInterceptor");
       EntityManagerFactoryUtils.closeEntityManager(emHolder.getEntityManager());
    }
}

 
이 메서드를 따라가다보면 ResourceRegistryStandardImpl에서 해시맵에 담긴 JDBC 리소스를 삭제하려는 것을 볼 수 있습니다.
두개의 메서드가 하나의 세션에서 같은 해시맵을 동시에 조작하여 ConcurrentModificationException이 발생한 것 입니다.
그렇다면 그 세션의 커넥션은 어떻게 될까요?
 

3. 안전장치가 되어있을텐데 ConcurrentModificationException에서 커넥션이 누수된 이유는 무엇인가.

OpenEntityManagerInViewInterceptor.afterCompletion() 메서드로부터 세션의 해제를 시작하고
논리적 트랜잭션을 담당하는 LogicalConnectionManagedImpl 클래스를 만날 수 있습니다.
아래는 LogicalConnectionManagedImpl.close()의 구현입니다. 논리적인 커넥션을 해제하는 단계입니다.
논리적커넥션에는 JDBC리소스를 관리하는 ResourceRegistry가 필드로 있습니다.

@Override
public Connection close() {
    if ( closed ) {
       return null;
    }

    getResourceRegistry().releaseResources(); <- 오류가 발생한 부분

    log.trace( "Closing logical connection" );
    try {
       releaseConnection();
    }
    finally {
       // no matter what
       closed = true;
       log.trace( "Logical connection closed" );
    }
    return null;
}

논리적 트랜잭션이 닫혀으면 그대로 리턴하고 아니면 getResourceRegistry().releaseResources()를 호출합니다
그리고 여기서 비동기 스레드와 HashMap의 동시수정으로 예외가 발생했습니다.
try문의 물리적커넥션을 해제하는 releaseConnection()을 수행하지 못한채 finally로 논리적 커넥션상태를 닫힘으로 만듭니다.
한마디로 물리적 커넥션은 연결되었지만 논리적 커넥션만 닫힌 상태입니다. 논리적 커넥션이 닫혀있으면 물리적 커넥션도 해제하지 못해 영원히 커넥션이 커넥션 풀로 돌아가지 못합니다.
 
현재 세션의 상태는 메인 스레드가 비트랜잭션 접근으로 조회시 논리적, 물리적 커넥션을 이미 해제했지만 비동기 스레드로 인해 다시 해당 세션에 논리적, 물리적 커넥션을 생성한 상태입니다.
그리고 비동기 스레드는 논리적 커넥션이 이미 닫혀있어 이 로직에 진입하지 못하고 return됩니다.
논리적 커넥션이 닫혀있으면 물리적 커넥션도 해제하지 못해 영원히 커넥션이 커넥션 풀로 돌아가지 못합니다.
이 클래스의 구현이 납득가지않아 하이버네이트에 PR을 보냈습니다.
 

커넥션 누수의 원인

1. 원인은 두 스레드가 같은 세션을 참조하고 논리적 커넥션 해제단계에서 JDBC 리소스를 해제할 때 예외가 발생하여 물리적커넥션을 해제하지 못했습니다.
2. 같은 세션을 참조한 이유는 OSIV의 활성화로 세션의 생명주기가 HTTP 응답에 따라가 영속 상태인 엔티티내부의 프록시가 세션참조를 가지고 있어 비동기 스레드가 메인 스레드와 같은 세션을 가질 수 있었습니다.
 

개선 

1. 비동기 로직에 엔티티대신 DTO를 전달함으로서 모든 에러 가능성을 제거했습니다.
2. OSIV를 비활성화하고 비동기 메서드에 @EventListenerTransactional(AFTER_COMMIT) 을 설정하여 메인 메서드의 커밋이 완료되고 난 후에 비동기 메서드의 작업을 시작하게 하였습니다.

2025-10-19 18:58:40.566 [HikariPool-1 connection adder] DEBUG com.zaxxer.hikari.pool.HikariPool - HikariPool-1 - After adding stats (total=40, active=0, idle=40, waiting=0)
2025-10-19 19:00:09.221 [HikariPool-1 housekeeper] DEBUG com.zaxxer.hikari.pool.HikariPool - HikariPool-1 - Pool stats (total=40, active=0, idle=40, waiting=0)

로그를 보면 알 수 있듯 동일한 테스트에서 커넥션 누수가 발생하지 않았습니다.

결론

1. OSIV가 비활성화되면 내부의 프록시로 같은 세션을 참조할 수는 없지만 비동기 스레드 전달 이전에 세션이 닫혀 준영속화가 됩니다. 따라서 동시성 이슈는 발생할 수 없으며 커넥션 누수 또한 절대 일어나지 않습니다.
2. OSIV가 활성화되었다고 무조건 문제가 발생하지는 않습니다. @Transactional로 새로운 세션을 바인딩하여 동시성 이슈를 막을 수 있습니다. 이런 경우에는 LIE 오류가 호출됩니다. 다만 커넥션이 누수되지 않습니다.
3. 또한 OSIV가 활성화 되었다해도 비동기 로직에 엔티티 대신 필드나 DTO를 전달하면 문제가 일어나지 않습니다.
4. 근본적인 원인은 잘못된 설계입니다. 세션의 생명주기를 조절하지 않고 엔티티를 비동기 로직에 전달하며 OSIV에 세션의 생명주기를 맡기어 종합적으로 커넥션이 도출되었습니다.
5. 하지만 OSIV의 활성화는 이처럼 복잡한 문제를 초래할 수 있고 개발자가 꼼꼼히 세션관리를 해주어야합니다. 그렇기에 대다수의 개발환경에서 OSIV를 비활성화 해둡니다. 하지만 스프링에서 OSIV의 기본값은 활성화입니다. 초보 개발자가 문제를 겪고 OSIV의 위험성을 깨달으라는 의도라는 글을 보았습니다. OSIV가 무조건 좋지 않다고는 말할수없고 꼭 필요한 환경도 있습니다. 하지만 제어하기 쉽지 않으며 제어에 실패할경우 위험성이 큽니다.
6. 개선 이후 누수가 발생하지 않았습니다.

구분전체 커넥션누수 커넥션
개선 전40개10개
개선 후40개0개

 

하이버네이트 창시자 gavin king과의 대화

1. LogicalConnectionManagedImpl.close()에서 JDBC리소스 정리중 문제가 발생하면 물리적 커넥션이 실행되지 않은 문제가 있어 제가 직접 코드를 수정하고 PR을 보냈습니다.
 
2. 그리고 깃허브와 하이버네이트Jira에서 하이버네이트 창시자인 gavin king이 코멘트를 달았습니다. https://github.com/hibernate/hibernate-orm/pull/11468
 

"I'm not saying we can't do the connection closing in a finally. I don't see any particular problem with that. But the fact that you're concerned about a leaked connection in a scenario where you're using Hibernate in a completely broken and illegal way is worrying me a lot more."
"I would much rather that you run out of connections and are forced to fix your broken session handling than that we "fix" this and you take that as permission to leave your program in its current broken state."

 
gavin king은 저의 PR에 문제는 없고 finally에서 물리적 커넥션 해제를 보장하는 것이 가능하지만, 애초에 잘못만든 코드에서 가장 우선시되는것은 커넥션 누수가 아닌 잘못만든 코드를 고치는 것이라고 했습니다.
 
또한 우리가 이 문제를 고치게 된다면 프로그래머들은 자신의 실수를 인지하지 못한채 계속 코드를 사용할 것이고 커넥션이 누수되어 직접 코드를 바꾸면서 프로그래머 자신의 코드에 문제가 있다는 것을 인식시키는 것이 더 나은 방법이라고 하였습니다.
 
맞는 말입니다. 만약 누군가 동시성 상황에서도 물리적 커넥션 해제를 보장하도록 수정했다면, 저도 제 코드의 문제점을 인지하지 못하고 계속 잘못된 코드를 쓸 가능성이 있습니다. 실제로 누수가 되었기 때문에 문제점을 인식하고 코드를 고치고 하이버네이트에 대해 더 나은 이해를 할 수 있었습니다.
 
gavin king과의 대화에서 무조건 문제를 해결하는 것만이 가장 좋은 방법이 아니라는 것을 깨닫는 소중한 경험이었습니다.