web/SpringBoot

[SpringBoot] JPA Hibernate Assertion Failure 트러블슈팅

뽀리님 2024. 7. 12. 14:59

지금 있는 회사에서 기존 레거시 서비스를 운영중이다.
근데 보다보니 자꾸 특정시간만 되면 main 페이지가 느려지는게 아닌가?

확인해보니 매시간 정각에 back단에서 배치서비스가 실행이 되는데, 문제는 그 서비스가 실행되는 동안 다른 호출이 DB에 접근을 못한다는거다.

그 이유를 확인해보다 정리하는 글

일단 에러는 이렇다.

2024-07-10 00:01:00.995  WARN 7618 --- [scheduling-1] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 0, SQLState: S1009
2024-07-10 00:01:00.996 ERROR 7618 --- [scheduling-1] o.h.engine.jdbc.spi.SqlExceptionHelper   : '∞' is not a valid numeric or approximate numeric value

2024-07-10 00:01:01.003 ERROR 7618 --- [scheduling-1] org.hibernate.AssertionFailure           : HHH000099: an assertion failure occurred (this may indicate a bug in Hibernate, but is more likely due to unsafe use of the session): org.hibernate.AssertionFailure: null id in com.XXX.XXX.data.jpa.entity.stat.StatPlayHour entry (don't flush the Session after an exception occurs)

org.hibernate.AssertionFailure: null id in com.XXX.XXX.data.jpa.entity.abc.efg.entry (don't flush the Session after an exception occurs)

2024-07-10 15:01:25.462 ERROR 7618 --- [scheduling-1] o.s.s.s.TaskUtils$LoggingErrorHandler    : Unexpected error occurred in scheduled task.
org.springframework.transaction.UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only



에러가 뭔가 엄청 많다...
일단 차근차근 살펴보자.

'AssertionFailure' 요놈 부터 보자

✅ AssertionFailure 예외가 발생하는 이유
AssertionFailure 예외를 발생시킨 지점은 실제 범인이 아닐 가능성이 높다.(흥 이거땜에 삽질엄청함) 
같은 트랜잭션(세션) 범위 내에서 앞서 실행된 어떤 쿼리(작업)가 영향을 미쳤을 확률이 높다.
AssertionFailure 예외는 @Transactional이 명시된 세션 내의 복수개의 쿼리가 연속적으로 실행되는 상황에서 중간에 실행된 특정 쿼리로 인해 발생한다. 


Hibernate는 성능 향상을 위해 트랜잭션의 최종 커밋 시점까지 INSERT, UPDATE, DELETE를 최대한 미루다가 모아서 실행하는데, 
요놈이 개발자의 의도와 다른 결과를 유발하는 경우가 있고, AssertionFailure 예외도 이러한 대표적인 사례에 속한다.

내가 반복적으로 경험했던 원인은 통계배치에서 숫자를 계산하는 과정에서 어디서 무한대(?) 인 '∞' 값이 들어가게 되면서 예외가 발생했던 경우이다.(추측하건데 통계데이터라 나누기 0을 해서..지 않을까?)


여튼, 예외 처리를 적절히 수행했더라도 최종적으로 해당 엔티티와 관계가 연결된 다른 엔티티에 대해 JPA 로직(find등)를 실행할 때 뒤늦게 org.hibernate.AssertionFailure 예외가 발생했던 것이다. 
실제 실행이 실패되어 저장된 id가 획득되지 않은 채로 지나갔고, 같은 세션 내의 해당 엔티티를 관계로 참조 및 조회하는 시점에 예외가 발생한 것이다.


여기서 중요한점.


☑️ hibernate exception이 발생하면 알아서 clear 해주지 않는다.

[실제로 문제가 됬던 소스 부분]

private Double calculateAvgDays(long id) {		 
	Query obj = em.createNativeQuery("select a~ 대충평균구하는소스"); 
	obj.setParameter("id", id); 
	BigDecimal result = new BigDecimal(0.0d); 
	try { 
		result = (BigDecimal)obj.getSingleResult(); 
		if(result == null) 
			return 0.0d; 
		else { 
			return result.doubleValue(); 
		} 
	} catch(Exception e) { 
		log.error("error occurred", e); 
	} 
	return 0.0d; 
}



정확히 여기서 result = (BigDecimal)obj.getSingleResult();  이부분에서 에러가났다.
결과를 가져오려고 하지만 hibernate exception으로 DB에 Lock이 걸린 상태

어떻게 해결했는지도 기록해본다.

일단 그전에 

잠깐 짚고 넘어가는 EntityManager !!


✔️ EntityManager?

 

스프링에서 JPA를 다루다보면 EntityManager 객체를 만나게 된다.
EntityManager는 JPA 인터페이스의 일부로, @Entity 어노테이션을 달고 있는 Entity 객체들을 관리하며 실제 DB 테이블과 매핑하여 데이터를 조회/수정/저장 하는 중요한 기능을 수행한다. 
EntityManager는 PersistenceContext라는 논리적 영역을 두어, 내부적으로 Entity의 생애주기를 관리한다.


✔️ EntityManager의 특징


EntityManager는 데이터베이스와 어플리케이션 사이에 위치하여, 편리한 기능들을 제공한다. 대표적인 특징으로는 First Level Cache, Identity, Write-Behind, Dirty Check, Lazy Loading 등이 있다.

  • First Level Cache

EntityManager는 Persistence Context에 등록된 Entity 객체의 @Id 어노테이션이 붙은 필드 값을 사용하여 Map<Id, Entity>형태로 저장한다. 어플리케이션에서 데이터 조회를 요청하면, EntityManager는 먼저 내부에 캐싱된 Entity가 있는지 확인한다. 데이터가 존재하면 DB에 쿼리를 전송하지 않고, 캐싱된 Entity를 반환하기 때문에 쿼리 성능이 최적화된다.

  • Identity

앞서 설명한 바와 같이, EntityManager는 내부적으로 Entity를 저장한다. 따라서, 동일한 Entity를 요청할 때에는 동일한 Entity를 반환함을 보장한다.

  • Write-Behind

EntityManager는 실제 트랜젝션이 커밋되기 전까지 발생한 쿼리를 모아서, commit()이 호출될 때 한번에 전달한다. 이러한 방식은 데이터를 처리하는 과정에서 의도치 않은 에러가 발생할 경우 roll-back이 용이하며, 네트워크 비용을 최소화할 수 있다는 장점을 지닌다.

 

  • Dirty Check

EntityManager에 등록된 Entity에 수정이 발생하면, 트랜잭션이 커밋되는 시점에 First Level Cache와의 비교를 통해 변경사항을 감지하여 UPDATE 쿼리를 자동으로 실행한다.

 

  • Lazy Loading

@ManyToOne과 같이 다대일 관계를 가진 Entity를 조회할 때, 실제로 해당 Entity의 데이터에 직접 접근하는 시점에 쿼리를 실행하는 방식을 의미한다. Lazy Loading을 통해 EntityManager는 불필요한 쿼리를 최소화한다.

 



✔️ EntityManagerFactory


EntityManagerFactory는 EntityManager를 생성하는 클래스이다. EntityManager의 생성방식은 크게 Container-Manager 방식과 Application-Managed 구분된다.

1. Container-Managed
스프링 컨테이너에 등록된 EntityManagerFactory에서 EntityManager를 생성하여 주입하는 방식을 의미한다.

✓ @PersistenceContext
또한, Container-Managed 방식은 Thread-Safe하다는 특징이 있다. 스프링 컨테이너는 EntityManager를 주입할 때, 실제로는 이를 감싼 프록시 객체를 주입한다. 프록시 객체는 내부적으로 Thread-Lock과 관련된 기능을 구현하고 있어 멀티 스레딩 환경에서 안전하게 사용할 수 있다.

 


2. Application-Managed
스프링 컨테이너에 등록된 EntityManagerFactory를 주입받아, 어플리케이션에서 직접 EntityManager를 생성하여 사용하는 방식을 의미한다. Container-Managed 방식 보다 EntityManager를 유연하게 관리할 수 있다는 것이 특징이다.

 

 

 

다시 돌아와서 문제점이 있는 소스를 보자

@Transactional
public class StatHourService {
    private final EntityManager entityManager;
    
    public StatHourService(EntityManager entityManager) {
        this.entityManager = entityManager;
    }

 

 

난 처음에 해당 소스에  @Transactional 가 걸려있어 Spring Boot가 알아서 트랜잭션을 관리해주겠거니 생각했다.
하지만 문제는 EntityManager 였지!

 

위에서 말했다시피 EntityManager의 생성방식은 크게 컨테이너와, 어플리케이션 방식이있는데

여기서는 생성자로 주입받은걸 보면 어플리케이션 방식에 좀 더 가깝다고 볼 수 있다.

그러나 이렇게 선언했을 경우, 우리가 알고 있다시피 스프링의 특성상 스프링 빈은 싱글톤으로 관리가 된다.

 

ㅋㅋ 이말은 모다?

동일한 빈 인스턴스가 스프링 애플리케이션 컨텍스트 내에서 한 번만 생성되고, 여러 스레드에 의해 공유된다는 것을 의미한다.

 

즉, EntityManager 가 여러 스레드에 의해 공유가 될 수도 있단 소리다... 무결성 개나줭...

 

그래서 @PersistenceContext 어노테이션을 사용해야 한다. 이는 각각의 쓰레드에 대해 별도의 엔티티 매니저가 생성됨을 보장한다.
아니면 EntityManagerFactory를 사용하여 필요한 시점에 EntityManager 인스턴스를 생성하고 사용 후 닫는 것도 하나의 방법이다.

나는 @PersistenceContext 을 이용하여 해결했다.

@PersistenceContext
private final EntityManager entityManager;



이거 때문에 진짜 한 이 틀 삽질 한것 같다.

내가 짠 소스코드가 아니였기 때문에 문제를 찾을 때 꽤 시간이 걸렸다.
다시한번 느끼지만 JPA는 정말 양날의 검인거 같다.
JPA의 가장 큰 특징이 영속성이지만 이 특징(메모리) 때문에, 관리도 훨씬 더 신경을 써야 할 것 같다.

 

-끝-

 

참조:

https://juneyr.dev/hibernate-exception-does-not-flush
https://jsonobject.tistory.com/614 
https://velog.io/@koo8624/Spring-EntityManager
https://do-study.tistory.com/97https://velog.io/@koo8624/Spring-EntityManager