참고

https://techblog.woowahan.com/2664/

 

HikariCP Dead lock에서 벗어나기 (이론편) | 우아한형제들 기술블로그

{{item.name}} 안녕하세요! 공통시스템개발팀에서 메세지 플랫폼 개발을 하고 있는 이재훈입니다. 메세지 플랫폼 운영 장애를 바탕으로 HikariCP에서 Dead lock이 발생할 수 있는 case와 Dead lock을 회피할

techblog.woowahan.com

https://techblog.woowahan.com/2664/

 

HikariCP Dead lock에서 벗어나기 (이론편) | 우아한형제들 기술블로그

{{item.name}} 안녕하세요! 공통시스템개발팀에서 메세지 플랫폼 개발을 하고 있는 이재훈입니다. 메세지 플랫폼 운영 장애를 바탕으로 HikariCP에서 Dead lock이 발생할 수 있는 case와 Dead lock을 회피할

techblog.woowahan.com

https://techblog.woowahan.com/2664/

 

HikariCP Dead lock에서 벗어나기 (이론편) | 우아한형제들 기술블로그

{{item.name}} 안녕하세요! 공통시스템개발팀에서 메세지 플랫폼 개발을 하고 있는 이재훈입니다. 메세지 플랫폼 운영 장애를 바탕으로 HikariCP에서 Dead lock이 발생할 수 있는 case와 Dead lock을 회피할

techblog.woowahan.com

 

테스트 코드

https://github.com/donghyeon0725/jpaDeadLockTest

 

GitHub - donghyeon0725/jpaDeadLockTest

Contribute to donghyeon0725/jpaDeadLockTest development by creating an account on GitHub.

github.com

 

 

📌 발생한 예외

 

Caused by: java.sql.SQLTransactionRollbackException: (conn:...) Deadlock found when trying to get lock; try restarting transaction

데드락이 걸린 상황입니다.

 

 

 

📌 문제 상황

 

java, spring, jpa 사용 중입니다. Food 엔티티를 insert 하고 이 엔티티를 다시 조회해서 update 하는 간단한 코드였습니다.

 

 

Food 엔티티

@Getter
@Setter
@Entity
public class Food {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private Long foodId;

    private String name;

    private Integer price;
}

 

FoodRepository.java

public interface FoodRepository extends JpaRepository<Food, Long> {
}

 

FoodService.java

...

@Transactional
public Long saveFood(FoodCommand foodCommand) {
    Food food = new Food();

    food.setName(foodCommand.getName());
    food.setPrice(foodCommand.getPrice());

    foodRepository.save(food);

    return food.getFoodId();
}

 

FoodController.java

...

@PostMapping("/v3.0/food")
public void createFoodV3(@RequestBody FoodCommand foodCommand) {
    Long foodId = foodService.saveFood(foodCommand).getFoodId();

    Food food = foodRepository.findById(foodId).orElseThrow(() -> new RuntimeException("못 찾음"));
    food.setStatus(FoodStatus.REGISTERED);
    foodRepository.save(food); // 예외 터진 부분!
}

foodRepository.save(food) 메소드에서 예외가 터졌습니다. 이때, 예외가 계속 터지지는 않았고 사용자가 몰릴 때만 예외가 터졌습니다.

 

 

📌 처음에 생각(예상) 했던 문제 원인

 

서로 다른 트랜잭션을 사용하고 있었고 Open-Session-In-View 옵션이 true 이였기 때문에 DB 락의 Isolation 레벨에 의한 예외라고 생각했습니다.

즉, 아직 Write 트랜잭션이 닫히지 않아서 Lock 이 걸려있는 상황에서 다른 트랜잭션이 이 row에 대한 접근을 시도할 것이라고 예상했었고, row 가 없기 때문에 무한정 대기를 타다가 예외가 터져버릴 것으로 예상했습니다.

 

그런데 이 예상은 틀렸습니다. 그 이유는 트랜잭션이 열려있는 상황에도 명시적인 트랜잭션이 끝날 때는 insert 쿼리가 실제로 나가기 때문입니다. 이러면 사실상 DB 입장에서는 트랜잭션 처리가 끝난 것이라고 보는 것이 맞을 것이고 이렇게 되면 Isolation 레벨과는 상관없이 읽을 수 있을 것이기 때문입니다. 또한 제가 예상한 문제가 맞다면 사용자 수와 관계없이 늘 예외가 터져야 하는데 그렇지 않았습니다.

 

그리고 테스트 코드를 여럿 만들었고 예외가 나기를 기대했는데, 같은 예외를 만들 수 없었습니다

 

 

📌 진짜 문제 원인

 

하나의 쓰레드가 2개의 커넥션을 요청해서 사용했기 때문에 문제가 된 상황입니다. (MYSQL DB를 사용할 때, JPA의 특정한 아이디 생성 전략을 사용하면 키 생성을 위해서 별도의 connection 이 하나 더 필요합니다.)

 

순간적으로 connection pool에 저장된 connection수를 넘는 연결 시도가 있었고, connection pool의 connection이 동난 상황에서 이미 connection을 얻어서 작업 중이던 쓰레드가 JPA 엔티티의 키 생성을 위해서 또 다른 connection을 요구하니까 pool 입장에선 더이상 내어줄 connection이 없었던 것입니다.

 

쓰레드 입장에서는 키 자원에 대한 요청을 했는데 응답이 오지않으니, “아! 키 자원이 데드락 상태이구나" 판단해버린 것입니다. 그래서, 사용자가 많아질 때에만 순간적으로 DeadLock이 걸렸던 것입니다.

 

 

 

📌 문제 환경 정리

 

먼저 문제의 원인을 풀어 나가기 전에, 문제에 직접적으로 원인이 되었던 요소를 정리하겠습니다.

 

  • Spring JPA 사용
  • DB : MYSQL (테스트할 때 버전은 8.0.28)
  • 엔티티의 키 생성 전략 : TABLE (SEQUENCE, AUTO 포함)
  • 커넥션 수 : 커넥션 풀의 개수가 최대 생성될 수 있는 쓰레드의 수와 같거나 작을 때

 

📌 기존 쓰레드 풀 동작

 

아래와 같은 상황을 가정

  • 쓰레드 개수 : 4개
  • 커넥션 수 : 3개
  • 요청 하나를 처리하는데 필요한 커넥션 수 : 2개

 

정상적인 상황

  1. 요청을 받는다.
  2. DB 커넥션 풀에서 커넥션을 요청해 받아온다.
  3. 받은 커넥션을 이용해서 DB 작업이 진행된다.
  4. 처리가 끝나면 응답을 내어준다. 그리고 커넥션은 풀에 돌려준다.

 

동시에 4개의 요청을 받았을 때

이 때는 작업을 위한 커넥션의 수가 모자란 상황입니다.

작업을 시작하기 위해 3개의 쓰레드에서 커넥션 풀을 요청해서 받아갔고, 3번째 쓰레드는 커넥션을 얻기 위해서 대기합니다. (또는 예외를 만납니다)

 

 

모든 쓰레드가 1개의 connection 을 이미 가지고 간 상황

다음과 같은 상황으로 가정하겠습니다.

  • 쓰레드 수 : 2개
  • 커넥션 수 : 2개
  • 요청 하나를 처리하는데 필요한 커넥션 수 : 2개

두 개의 모든 쓰레드는 키 삽입을 위해서 커넥션이 하나 더 필요한데 없기 때문에 대기합니다.

기본 설정 시간인 30초가 지나면 그제야 TimeoutException 을 발생시키면서 가지고 있던 커넥션을 모두 풀에 돌려줍니다.

 

 

📌 문제 상황을 피하려면

 

이미 이 이슈는 HikariCP github에서도 issue로 등록되었고, HikariCP wiki에서 Dead lock을 해결하는 방법을 제시하고 있습니다.

 

방법 1

pool size = Tn x (Cm - 1) + 1
  • Tn : 전체 Thread 개수
  • Cm : 하나의 Task에서 동시에 필요한 최대 Connection 수

 

HikariCP wiki에서는 이 공식대로 Maximum pool size를 설정하면 Dead lock을 피할 수 있다고 합니다.

예를 들어서, 아래와 같은 상황일 땐 다음과 같이 계산하면 됩니다.

  • 전체 쓰레드 개수 : 2
  • 하나의 쓰레드에서 동시에 필요한 최대 커넥션 수 : 2
pool size = 2 * (2 - 1) + 1 = 3

 

방법 2

ID 대역을 언제든지 변경할 수 있어야 하는 상황이 아닌 경우에는 시퀀스 생성 전략을 바꾸는 것이 방법이 될 수 있습니다.

사실 문제가 되는 키 전략이었던 TABLE 전략의 경우엔 성능 자체도 그렇게 좋은 편이 아닙니다.

 

 

📌 해결방안 공식 검증 (방법 1번 검증)

 

2개의 Thread가 동시에 HikariCP에 Connection을 요청하고 2개의 Connection을 골고루 나눠 가졌습니다. 그럼에도 불구하고 1개의 Connection이 남아있습니다.

 

이 1개의 Connection이 Dead lock을 피할 수 있게 해주는 Key Connection이 됩니다. 남은 1개의 커넥션을 2개의 쓰레드가 번갈아 사용하면서 작업을 마칠 수 있게 되는 것이죠.

 

공식에 따라서 여분의 커넥션을 하나 늘렸고, 이제 커넥션 개수가 2개가 아니라 3개가 되었습니다.

  • 여분의 커넥션이 색은 주황색으로 표기했습니다.

  • 두 개의 요청이 동시에 들어와서 두 쓰레드 모두 각각 하나의 커넥션을 가져갔고, 이 상황에서 두 쓰레드에서 추가적으로 커넥션 하나를 더 얻으려는 시도가 발생합니다.

  • 우측에 쓰레드가 먼저 커넥션을 받아가 작업을 처리합니다.
  • 그렇게 되면 왼쪽 쓰레드는 여분의 커넥션이 생길 때까지 대기합니다.

  • 이 때 2번째 커넥션을 먼저 받은 쓰레드가 처리하고 키 생성이 끝나면 여분의 connection 만 풀에 반납 후 나머지 작업을 진행합니다.

  • 이후 왼쪽 쓰레드가 여분의 Connection 을 받아 요청을 처리합니다.

이렇게 하면 여분의 커넥션 덕에 DeadLock 이 걸리는 상황은 막을 수 있습니다. 물론 쓰레드 성능과 개수에 따라서 Timeout 이 날 가능성은 여전히 존재하지만요.

 

 

📌 적절한 커넥션의 개수는?

 

실제로 운이 없게 모든 커넥션이 모두 찰 수 있고 1개의 여분 커넥션으로 이 상황을 돌파하려고 하면, 여분의 커넥션을 기다리면서 30초 이상의 타임아웃이 발생할 가능성 또한 있기 때문에 문제가 발생한다면 아래와 같이 DBCP (DataBase Connection Pool) 수를 설정하는 것이 어떨까 싶습니다.

 pool size = 최대쓰레드수 * (최대 사용하는 커넥션 수 - 1) + 최대쓰레드수 * 0.1 +  alpha

 

다만, 다른 관점도 있습니다.

https://www.holaxprogramming.com/2013/01/10/devops-how-to-manage-dbcp/

 

DB Connection Pool에 대한 이야기 · 안녕 프로그래밍

웹 애플리케이션을 운영하다 보면 에러 로그로는 식별 할 수 없는 잠재적인 이슈가 발생 할 때가 있다. 애플리케이션내의 오류가 명확히 확인이 되지 않은 상태에서 Out of memory가 발생 하거나, DB

www.holaxprogramming.com

DBPC가 WAS Thread 수를 넘어설 경우 비효율적이라는 말입니다. 따라서, 예외가 발생할 경우에만 위 설정을 따르거나,

ID 대역을 언제든지 변경할 수 있어야 하는 상황이 아닌 경우에는 시퀀스 생성 전략을 바꾸는 것이 방법이 될 수 있을 것 같습니다.

가장 좋은 것은 현재 운영되는 서비스의 동접자 수를 확인하고 상황에 맞춰서 대처하는 것일 거 같습니다.

 

 

📌 왜 하나의 쓰레드에서 2개의 커넥션을 사용할까?

 

그럼, 왜 1개의 쓰레드에서 2개의 커넥션 요청이 생길 수밖에 없었는지 그 상황에 대해서 이해해보려고 합니다.

 

먼저, mybatis 와 jpa 를 같이 사용하고 있는 경우 서로 다른 커넥션을 사용하기 때문에, 데드락은 아니지만(예외의 모양새가 데드락인지, 아닌지 정확하게 확인 못함)  위와 같은 timeout 문제가 나타날 수 있습니다.

 

그리고, 두번 째는 JPA의 키 생성 전략 때문에 나타나는 문제입니다.

이이해하려면 MYSQL DB 특성과 JPA 의 키 생성 전략에 대해서 이해하고 있어야 합니다.

 

 

키 생성 방식 종류 (@GeneratedValue 어노테이션)

  • strategy = GenerationType.AUTO
    • JPA 구현체가 자동으로 생성 전략을 결정
  • strategy = GenerationType.IDENTITY
    • 기본키 생성을 데이터베이스에게 위임
  • strategy = GenerationType.SEQUENCE
    • 데이터베이스의 오브젝트인 시퀀스를 사용하여 기본키를 생성
  • strategy = GenerationType.TABLE
    • 데이터베이스에 키 생성 전용 테이블을 만들고 이를 사용하여 기본키 생성

 

Mysql 일 경우 SEQUENCE 전략을 사용해도 의미가 없는데 이유는, Mysql 자체가 시퀀스 객체를 지원하지 않기 때문입니다. 따라서 SEQUENCE 를 사용하면 TABLE 전략과 동일하게 동작합니다.

 

TABLE 전략은 테이블을 이용해서 시퀀스 객체를 흉내 내는 기법으로, 테이블에 저장된 값을 시퀀스 값으로 사용합니다. 그리고 jpa 는 hibernate_sequence 이라는 테이블을 하나 만들어서 이 값을 관리합니다.

 

TABLE 전략의 구현 방식은 키 생성 쿼리 로그를 보면 확인할 수 있습니다. jpa repository의 메소드인 save 가 호출되는 시점에 다음과 같은 로그를 확인할 수 있습니다.

select next_val as id_val from hibernate_sequence for update
update hibernate_sequence set next_val= ? where next_val=?

즉, 값을 조회해서 키 값으로 사용하고 이 값을 + n 해놓습니다.

 

여기서 for update 가 붙었는데, Mysql 에서 select 쿼리에 for update 가 붙으면 row 에 트랜잭션이 끝나기 전까지 row 에 lock 이 걸립니다.

ID 값을 담당하는 값이기에 중복이 되면 안 되고, 이 값을 select 하는 시점에, 동시성 문제가 발생하면 키 값이 중복될 수 있기 때문에 락 처리를 위해 이런 쿼리를 구성한 것 같습니다.

 

한편, 이런 lock 처리가 걸려 있으면 이 트랜잭션이 끝날 때까지 다른 트랜잭션에서 접근할 수 없습니다.

실제로 jpa 는 이렇게 hibernate_sequence 테이블 조회하고 업데이트하는 과정에서 별도의 sub 트랜잭션을 생성해서 실행하는데 이유는 다음과 같이 추측됩니다.

 

jpa는 영속성 관리를 위해서 key 값이 필요한데 이를 위해서 전략이 IDENTITY 인 경우에 persist 메소드 호출 시점에 바로 쿼리를 날려서 키를 받아옵니다. (데이터를 insert 할 때 key 값을 받는 방식 때문)

마찬가지로 비슷한 이유에서 TABLE 전략을 사용하면 persist 할 때 key 를 먼저 받아오기 위해서 위와 같은 쿼리를 날리는 것 같습니다.

 

그런데, lock 이 걸리는 위 쿼리 특성상 트랜잭션이 끝나기 전까지 다른 스레드에서 이 key 값을 읽을 수 없기 때문에 별도의 sub 트랜잭션을 열어서 처리하는 것으로 추측하고 있습니다. (의도는 다를 수 있음)

여기서 순간적으로 connection 을 2개 사용하게 됩니다.

 

 

📌 테스트 코드로 증명하려던 예외

 

테스트 코드는 위에서 언급한 환경이 갖춰졌을 때, 쓰레드 개수가 1개라고 가정을 하고 최대 커넥션을 2개 요구하는 상황을 만들었습니다. 그리고, DB 커넥션 풀의 개수를 1로 맞춰 테스트를 진행했습니다.

순간적으로 connection 을 2개 사용하려고 시도하면 SQLTransactionRollbackException 예외가 날 것을 예상하고 테스트를 진행했습니다.

 

📌시퀀스 생성 전략이 AUTO 인데도 환경에서 예외가 난 이유는?

 

시퀀스 생성 전략이 AUTO 일 때 기본값으로 설정된 전략을 따라가게 되는데 다음과 같습니다.

  • spring boot 1.5 이상 2.0 미만 : SEQUENCE
  • spring boot 2.0 이상 : TABLE

 

이 기본 설정은 바꿀 수 있습니다.

spring.jpa.hibernate.use-new-id-generator-mappings=true

위 설정 값을 true 로 하면 TABLE 전략을 따라가고 false 하면 SEQUENCE 전략을 따라갑니다.

  • spring boot 1.5 이상 2.0 미만 : SEQUENCE (use-new-id-generator-mappings = false)
  • spring boot 2.0 이상 : TABLE(use-new-id-generator-mappings = true)

 

시퀀스 생성 전략이 AUTO인 경우 설정 값에 따라서 시퀀스 생성 전략이 바뀔 수 있는데 이는 코드에서 살펴볼 수 있습니다. 위에 언급한 배민 기술 블로그에서 자료를 긁어왔습니다.


 

Id Generator에 대해 hibernate 공식 문서에서는 아래와 같이 설명하고 있습니다.

This is the default strategy since Hibernate 5.0. For older versions, this strategy is enabled through the hibernate.id.new_generator_mappingsconfiguration property . When using this strategy, AUTO always resolvesto SequenceStyleGenerator. If the underlying database supportssequences, then a SEQUENCE generator is used. Otherwise, a TABLEgenerator is going to be used instead.

 

위의 내용 + 실제 코드를 기반으로 작성한 Flow chart입니다.

 

Flow chart

먼저 hibernate.id.new_generator_mappings=false 인 경우입니다.

 

 

DefaultIdentifierGeneratorFactory.getIdentifierGeneratorClass

strategy가 native 인 경우 사용하는 Dialect에 의해 Generator가 결정됩니다.

 

 

Dialect.getNativeIdentifierGeneratorStrategy

Dialect에서 supportsIdentityColumns()가 true 인 경우 IdentityGenerator를 사용하게 됩니다. false 인 경우 SequenceStyleGenerator를 사용하게 됩니다.

이번엔 hibernate.id.new_generator_mappings=true 인 경우입니다.

 

 

SequenceStyleGenerator.buildDatabaseStructure

Dialect에서 Sequence기능 제공 여부에 따라 내부적으로 사용하는 DatabaseStructure가 결정됩니다.

 

 

SequenceStyleGenerator.isPhysicalSequence

  • Sequence기능을 지원하는 경우 SequenceStructure를 사용
  • Sequence기능을 지원하지 않는 경우 TableStructure를 사용합니다.

위에서 결정된 DatabaseStructure는 아래 코드에서 사용됩니다.

 

 

SequenceStyleGenerator.generate


 

 

📌 키 생성 시점에 2개의 connection 를 사용하는 게 맞는지 코드 검증

 

save 시점에 Key 생성과 관련한 로직이 동작할 것이기 때문에 save 메서드 코드를 열어보았습니다.

과정 확인 없이 맨 아래에 클래스 하나만 확인하셔도 충분합니다.

 

CrudRepository.java

@NoRepositoryBean
public interface CrudRepository<T, ID> extends Repository<T, ID> {

	/**
	 * Saves a given entity. Use the returned instance for further operations as the save operation might have changed the
	 * entity instance completely.
	 *
	 * @param entity must not be {@literal null}.
	 * @return the saved entity; will never be {@literal null}.
	 * @throws IllegalArgumentException in case the given {@literal entity} is {@literal null}.
	 */
	<S extends T> S save(S entity);

	...
}

jpa 가 제공하는 CrudRepository 인터페이스

 

SimpleJpaRepository.java

@Repository
@Transactional(readOnly = true)
public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {
	/*
	 * (non-Javadoc)
	 * @see org.springframework.data.repository.CrudRepository#save(java.lang.Object)
	 */
	@Transactional
	@Override
	public <S extends T> S save(S entity) {

		Assert.notNull(entity, "Entity must not be null.");

		if (entityInformation.isNew(entity)) {
			em.persist(entity);
			return entity;
		} else {
			return em.merge(entity);
		}
	}

	...
}

CrudRepository 구현체인 SimpleJpaRepository 를 보면 엔티티를 EntityManager 를 통해 persist 하는 코드를 확인할 수 있다.

 

 

EntityManager.java

public interface EntityManager {

    /**
     * Make an instance managed and persistent.
     * @param entity  entity instance
     * @throws EntityExistsException if the entity already exists.
     * (If the entity already exists, the <code>EntityExistsException</code> may 
     * be thrown when the persist operation is invoked, or the
     * <code>EntityExistsException</code> or another <code>PersistenceException</code> may be 
     * thrown at flush or commit time.) 
     * @throws IllegalArgumentException if the instance is not an
     *         entity
     * @throws TransactionRequiredException if there is no transaction when
     *         invoked on a container-managed entity manager of that is of type 
     *         <code>PersistenceContextType.TRANSACTION</code>
     */
    public void persist(Object entity);

		...
}

EntityManager 인터페이스

 

SessionImpl.java

AbstractSessionImpl 클래스 - SessionImplementor 인터페이스 - Session 인터페이스를 보면 EntityManager 인터페이스를 상속받는 부분이 있다.

public class SessionImpl
		extends AbstractSessionImpl
		implements EventSource, SessionImplementor, HibernateEntityManagerImplementor {

	@Override
	public void persist(Object object) throws HibernateException {
		checkOpen();
		firePersist( new PersistEvent( null, object, this ) );
	}

	private void firePersist(final PersistEvent event) {
		try {
			checkTransactionSynchStatus();
			checkNoUnresolvedActionsBeforeOperation();

			fastSessionServices.eventListenerGroup_PERSIST.fireEventOnEachListener( event, PersistEventListener::onPersist );
		}
		catch (MappingException e) {
			throw getExceptionConverter().convert( new IllegalArgumentException( e.getMessage() ) );
		}
		catch (RuntimeException e) {
			throw getExceptionConverter().convert( e );
		}
		finally {
			try {
				checkNoUnresolvedActionsAfterOperation();
			}
			catch (RuntimeException e) {
				throw getExceptionConverter().convert( e );
			}
		}
	}

	...
}

EntityManager 구현체인 SessionImpl 를 보면 persist 메소드가 있다. 코드 흐름을 따라가보면

PersistEvent 이벤트를 PersistEventListener 에 발행하고 있는 것을 알 수 있다.

 

PersistEventListener.java

public interface PersistEventListener extends Serializable {

    /** 
     * Handle the given create event.
     *
     * @param event The create event to be handled.
     * @throws HibernateException
     */
	public void onPersist(PersistEvent event) throws HibernateException;
	
	...
}

PersistEventListener 인터페이스를 보면 onPersist 메소드를 통해서 이벤트를 핸들링함을 알 수 있다.

 

DefaultPersistEventListener.java

public class DefaultPersistEventListener
		extends AbstractSaveEventListener
		implements PersistEventListener, CallbackRegistryConsumer {

	/**
	 * Handle the given create event.
	 *
	 * @param event The create event to be handled.
	 *
	 */
	public void onPersist(PersistEvent event, Map createCache) throws HibernateException {
			
		...

		final EntityEntry entityEntry = source.getPersistenceContextInternal().getEntry( entity );
		EntityState entityState = EntityState.getEntityState( entity, entityName, entityEntry, source, true );
		if ( entityState == EntityState.DETACHED ) {
			...
		}

		switch ( entityState ) {
			case DETACHED: {
				throw new PersistentObjectException(
						"detached entity passed to persist: " +
								EventUtil.getLoggableName( event.getEntityName(), entity )
				);
			}
			case PERSISTENT: {
				entityIsPersistent( event, createCache );
				break;
			}
			case TRANSIENT: {
				entityIsTransient( event, createCache );
				break;
			}
			case DELETED: {
				entityEntry.setStatus( Status.MANAGED );
				entityEntry.setDeletedState( null );
				event.getSession().getActionQueue().unScheduleDeletion( entityEntry, event.getObject() );
				entityIsDeleted( event, createCache );
				break;
			}
			default: {
				throw new ObjectDeletedException(
						"deleted entity passed to persist",
						null,
						EventUtil.getLoggableName( event.getEntityName(), entity )
				);
			}
		}

	}

	protected void entityIsTransient(PersistEvent event, Map createCache) {
		LOG.trace( "Saving transient instance" );

		final EventSource source = event.getSession();
		final Object entity = source.getPersistenceContextInternal().unproxy( event.getObject() );

		if ( createCache.put( entity, entity ) == null ) {
			saveWithGeneratedId( entity, event.getEntityName(), createCache, source, false );
		}
	}

	...
}

PersistEventListener 구현체인 onPersist 를 따라가면

엔티티 상태가 영속성 관리 전, 즉 TRANSIENT 일 때 entityIsTransient 메서드가 동작하고 있음을 알 수 있다.

saveWithGeneratedId 메서드가 동작한다.

 

AbstractSaveEventListener.java

public abstract class AbstractSaveEventListener
		extends AbstractReassociateEventListener
		implements CallbackRegistryConsumer {

	/**
	 * Prepares the save call using a newly generated id.
	 *
	 * @param entity The entity to be saved
	 * @param entityName The entity-name for the entity to be saved
	 * @param anything Generally cascade-specific information.
	 * @param source The session which is the source of this save event.
	 * @param requiresImmediateIdAccess does the event context require
	 * access to the identifier immediately after execution of this method (if
	 * not, post-insert style id generators may be postponed if we are outside
	 * a transaction).
	 *
	 * @return The id used to save the entity; may be null depending on the
	 *         type of id generator used and the requiresImmediateIdAccess value
	 */
	protected Serializable saveWithGeneratedId(
			Object entity,
			String entityName,
			Object anything,
			EventSource source,
			boolean requiresImmediateIdAccess) {
		callbackRegistry.preCreate( entity );

		if ( entity instanceof SelfDirtinessTracker ) {
			( (SelfDirtinessTracker) entity ).$$_hibernate_clearDirtyAttributes();
		}

		EntityPersister persister = source.getEntityPersister( entityName, entity );
		Serializable generatedId = persister.getIdentifierGenerator().generate( source, entity );
		
		...
	}

	...
}

Serializable generatedId = persister.getIdentifierGenerator().generate( source, entity ); 부분을 보면 generate 메소드가 동작한다.


IdentifierGenerator.java

public interface IdentifierGenerator extends Configurable, ExportableProducer {
	/**
	 * Generate a new identifier.
	 *
	 * @param session The session from which the request originates
	 * @param object the entity or collection (idbag) for which the id is being generated
	 *
	 * @return a new identifier
	 *
	 * @throws HibernateException Indicates trouble generating the identifier
	 */
	Serializable generate(SharedSessionContractImplementor session, Object object) throws HibernateException;

	...
}

IdentifierGenerator 인터페이스를 보면 식별자를 생성하는 메소드임을 알 수 있다.

 

 

SequenceStyleGenerator.java

public class SequenceStyleGenerator
		implements PersistentIdentifierGenerator, BulkInsertionCapableIdentifierGenerator {

	@Override
	public Serializable generate(SharedSessionContractImplementor session, Object object) throws HibernateException {
		return optimizer.generate( databaseStructure.buildCallback( session ) );
	}

	...
}

구현체인 SequenceStyleGenerator 를 보면 generate 메소드가 동작하면서 buildCallback 메소드가 동작한다.

 

DatabaseStructure.java

public interface DatabaseStructure extends ExportableProducer {
	
	/**
	 * A callback to be able to get the next value from the underlying
	 * structure as needed.
	 *
	 * @param session The session.
	 * @return The next value.
	 */
	AccessCallback buildCallback(SharedSessionContractImplementor session);

	...
}

buildCallback 메소드를 통해서 다음 식별자를 가져온다.

 

 

TableStructure.java

public class TableStructure implements DatabaseStructure {
	@Override
	public AccessCallback buildCallback(final SharedSessionContractImplementor session) {
		final SqlStatementLogger statementLogger = session.getFactory().getServiceRegistry()
				.getService( JdbcServices.class )
				.getSqlStatementLogger();
		if ( selectQuery == null || updateQuery == null ) {
			throw new AssertionFailure( "SequenceStyleGenerator's TableStructure was not properly initialized" );
		}

		final SessionEventListenerManager statsCollector = session.getEventListenerManager();

		return new AccessCallback() {
			@Override
			public IntegralDataTypeHolder getNextValue() {
				return session.getTransactionCoordinator().createIsolationDelegate().delegateWork(
						...
				);
			}

			...
		};
	}

	...
}

DatabaseStructure 구현체인 TableStructure 클래스의 buildCallback 메소드를 보면

session.getTransactionCoordinator().createIsolationDelegate().delegateWork() 메소드가 동작하는데 인터페이스를 열어보며

현재 트랜젝션의 고립 레벨 안으로 주어진 일을 수행한다.

 

IsolationDelegate.java

public interface IsolationDelegate {
	/**
	 * Perform the given work in isolation from current transaction.
	 *
	 * @param work The work to be performed.
	 * @param transacted Should the work itself be done in a (isolated) transaction?
	 *
	 * @return The work result
	 *
	 * @throws HibernateException Indicates a problem performing the work.
	 */
	public <T> T delegateWork(WorkExecutorVisitable<T> work, boolean transacted) throws HibernateException;

}

IsolationDelegate 인터페이스


JdbcIsolationDelegate.java

public class JdbcIsolationDelegate implements IsolationDelegate {

	@Override
	public <T> T delegateWork(WorkExecutorVisitable<T> work, boolean transacted) throws HibernateException {
		boolean wasAutoCommit = false;
		try {
			Connection connection = jdbcConnectionAccess().obtainConnection();
			
			...
		}
		catch (SQLException sqle) {
			throw sqlExceptionHelper().convert( sqle, "unable to obtain isolated JDBC connection" );
		}
	}

	...
}

jdbcConnectionAccess().obtainConnection(); 메소드가 동작하면서 새로운 connection 을 얻고 있다.

 

 

JdbcConnectionAccess.java

public interface JdbcConnectionAccess extends Serializable {
	/**
	 * Obtain a JDBC connection
	 *
	 * @return The obtained connection
	 *
	 * @throws SQLException Indicates a problem getting the connection
	 */
	Connection obtainConnection() throws SQLException;
	
	...
}

여기서 새로운 커넥션을 얻고 있다.

발생한 SQLTransactionRollbackException.java 를 열어보면 SQLTransientException.java 를 통해서 SQLException.java 를 상속 받고 있음을 알 수 있다.

 

 

📌 왜 문제 원인을 예상 못 했을까?

 

connection 을 모두 사용했을 때 이를 초과하는 요청이 오면 해당 사용자는 두가지중 하나의 상황에 직면합니다.

  • 연결할 수 없다는 예외를 만나기
  • 커넥션 풀에 커넥션이 찰 때까지 기다리기
    • 또는 요청 처리 자체를 제 시간 내에 시작하지 못하고, 일정 시간이 초과되어 CannotGetJdbcConnectionException 만나기

위 두 상황은 모두 데드락 상황이 아닙니다.

그리고 spring framework 내에서 1개의 Request에 1개의 Thead를 생성해서 처리하고 해당 쓰레드는 1개의 connection 을 얻어서 처리하는 것을 당연하게 생각하고 있었습니다.

따라서 위 생각(가정)이 맞다면 데드락이 아니라, 다른 예외가 나야 할 것이라고 생각했습니다.

그래서 전혀 connection pool 이 문제일 것이라고 예상하지 못했습니다.