Postgres Lock Timeout 적용하기

hyezuu·2025년 3월 20일

문제 인식

성능테스트중 부하가 많을때에도 타임아웃예외가 터지지 않는 것에서 의문이 생겼다. 아무리 데이터셋이 적더라도 이게 가능한가..?
확인한 결과 0초는 정상적용 되었으나, 1ms까지 타임아웃 시간을 줄여도 타임아웃이 동작하지 않는 것을 발견했다.

테스트

테스트 환경 :

  • 타임아웃 제한 1000ms
  • 트랜잭션 내에 의도적으로 3000ms 를 걸어 예외가 터지는지 확인
  • @ActiveProfiles("test") 을 통해 h2 환경 / postgreSQL 각각 테스트
	@Lock(LockModeType.PESSIMISTIC_WRITE)
	@QueryHints({
		@QueryHint(name = "jakarta.persistence.lock.timeout", value = "1000"),
		@QueryHint(name = "org.hibernate.dialect.lock.timeout", value = "1000")
	})
	@Query("SELECT s FROM Stock s WHERE s.id = :id AND s.deletedAt is null")
	Optional<Stock> findByIdWithLock(@Param("id") StockId id);
	@Override
	@Transactional
	public void prepareStock(PrepareStockRequestDto requestDto) {
		try {
			getSortedStocks(requestDto.stocks())
				.forEach(stockItem ->
					getStockWithLock(stockItem.stockId()).decreaseStock(stockItem.quantity()));
			sleep(3000);
		} catch (InterruptedException e) {
			throw new RuntimeException(e);
		}
	}

직접 실행

  • @ActiveProfiles("test")
    H2 환경으로는 타임아웃 적용됨!
    (동시성 이슈 확인용 테스트만 수행했다..ㅎ 재사용^^)
    Caused by: org.hibernate.PessimisticLockException: JDBC exception executing SQL [select s1_0.hub_id,s1_0.product_id,s1_0.created_at,s1_0.created_by,s1_0.deleted_at,s1_0.deleted_by,s1_0.quantity,s1_0.updated_at,s1_0.updated_by from p_stock s1_0 where (s1_0.hub_id,s1_0.product_id)=(?,?) and s1_0.deleted_at is null for update] [Timeout trying to lock table "P_STOCK"; SQL statement:
    select s1_0.hub_id,s1_0.product_id,s1_0.created_at,s1_0.created_by,s1_0.deleted_at,s1_0.deleted_by,s1_0.quantity,s1_0.updated_at,s1_0.updated_by from p_stock s1_0 where (s1_0.hub_id,s1_0.product_id)=(?,?) and s1_0.deleted_at is null for update [50200-232]] [n/a]
    	at org.hibernate.dialect.H2Dialect.lambda$buildSQLExceptionConversionDelegate$3(H2Dialect.java:771) ~[hibernate-core-6.6.8.Final.jar:6.6.8.Final]
    	at org.hibernate.exception.internal.StandardSQLExceptionConverter.convert(StandardSQLExceptionConverter.java:58) ~[hibernate-core-6.6.8.Final.jar:6.6.8.Final]
    	at org.hibernate.engine.jdbc.spi.SqlExceptionHelper.convert(SqlExceptionHelper.java:108) ~[hibernate-core-6.6.8.Final.jar:6.6.8.Final]
    	at org.hibernate.engine.jdbc.spi.SqlExceptionHelper.convert(SqlExceptionHelper.java:94) ~[hibernate-core-6.6.8.Final.jar:6.6.8.Final]
    	at org.hibernate.sql.results.jdbc.internal.DeferredResultSetAccess.executeQuery(DeferredResultSetAccess.java:268) ~[hibernate-core-6.6.8.Final.jar:6.6.8.Final]
    	at org.hibernate.sql.results.jdbc.internal.DeferredResultSetAccess.getResultSet(DeferredResultSetAccess.java:171) ~[hibernate-core-6.6.8.Final.jar:6.6.8.Final]
    	at org.hibernate.sql.results.jdbc.internal.JdbcValuesResultSetImpl.<init>(JdbcValuesResultSetImpl.java:74) ~[hibernate-core-6.6.8.Final.jar:6.6.8.Final]
    	at org.hibernate.sql.exec.internal.JdbcSelectExecutorStandardImpl.resolveJdbcValuesSource(JdbcSelectExecutorStandardImpl.java:355) ~[hibernate-core-6.6.8.Final.jar:6.6.8.Final]
    	at org.hibernate.sql.exec.internal.JdbcSelectExecutorStandardImpl.doExecuteQuery(JdbcSelectExecutorStandardImpl.java:137) ~[hibernate-core-6.6.8.Final.jar:6.6.8.Final]
    	at org.hibernate.sql.exec.internal.JdbcSelectExecutorStandardImpl.executeQuery(JdbcSelectExecutorStandardImpl.java:102) ~[hibernate-core-6.6.8.Final.jar:6.6.8.Final]
    	at org.hibernate.sql.exec.spi.JdbcSelectExecutor.executeQuery(JdbcSelectExecutor.java:91) ~[hibernate-core-6.6.8.Final.jar:6.6.8.Final]
    	at org.hibernate.sql.exec.spi.JdbcSelectExecutor.list(JdbcSelectExecutor.java:165) ~[hibernate-core-6.6.8.Final.jar:6.6.8.Final]
    	at org.hibernate.query.sqm.internal.ConcreteSqmSelectQueryPlan.lambda$new$1(ConcreteSqmSelectQueryPlan.java:152) ~[hibernate-core-6.6.8.Final.jar:6.6.8.Final]
    	at org.hibernate.query.sqm.internal.ConcreteSqmSelectQueryPlan.withCacheableSqmInterpretation(ConcreteSqmSelectQueryPlan.java:442) ~[hibernate-core-6.6.8.Final.jar:6.6.8.Final]
    	at org.hibernate.query.sqm.internal.ConcreteSqmSelectQueryPlan.performList(ConcreteSqmSelectQueryPlan.java:362) ~[hibernate-core-6.6.8.Final.jar:6.6.8.Final]
    	at org.hibernate.query.sqm.internal.QuerySqmImpl.doList(QuerySqmImpl.java:380) ~[hibernate-core-6.6.8.Final.jar:6.6.8.Final]
    	at org.hibernate.query.spi.AbstractSelectionQuery.list(AbstractSelectionQuery.java:143) ~[hibernate-core-6.6.8.Final.jar:6.6.8.Final]
    	at org.hibernate.query.spi.AbstractSelectionQuery.getSingleResult(AbstractSelectionQuery.java:275) ~[hibernate-core-6.6.8.Final.jar:6.6.8.Final]
  • @ActiveProfiles("local") - default (postgreSQL)
    타임아웃 예외가 발생하지 않아 30초가 걸리는걸 볼 수 있다.......^^
    그냥..터져라 제발

    확인

    https://docs.jboss.org/hibernate/orm/current/userguide/html_single/Hibernate_User_Guide.html#locking-jpa-query-hints

    JDBC 드라이버의 락 타임아웃 지원 제한:
    "Not all JDBC database drivers support setting a timeout value for a locking request. If not supported, the Hibernate dialect ignores this query hint."
    -> 모든 JDBC 데이터베이스 드라이버가 락 요청에 대한 타임아웃 값 설정을 지원하지는 않습니다. 지원하지 않는 경우, Hibernate 방언(dialect)은 이 쿼리 힌트를 무시합니다.

javax.persistence.lock.timeout이나 jakarta.persistence.lock.timeout 설정을 했더라도, 사용 중인 데이터베이스의 JDBC 드라이버가 이 기능을 지원하지 않으면 설정이 적용되지 않을 수 있다.
예를 들어, MySQL, Oracle 등의 드라이버는 이 기능을 지원하지만, 일부 다른 드라이버는 지원하지 않을 수 있다.

의심이 확신이 되는 순간이었다.

https://docs.jboss.org/hibernate/orm/current/userguide/html_single/Hibernate_User_Guide.html#settings-jakarta.persistence.lock.timeout

물론 NOWAIT 설정은 제대로 동작한다.javax.persistence.lock.timeout = 0 으로 두면 NOWAIT으로 동작했다 (이것 때문에 더 헤맸던 것도 있다.)

추가 참고 문서 https://docs.jboss.org/hibernate/orm/current/userguide/html_single/Hibernate_User_Guide.html#locking-jpa-query-hints

해결

  • 네이티브 쿼리aop로 적용하여 해결했다

1. 어노테이션 정의

@Inherited
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface LockTimeout {
	int timeout() default 3000;
}
  • @Inherited: 이 어노테이션이 부모 클래스에서 자식 클래스로 상속됨을 나타낸다. 즉, 부모 클래스의 메서드에 이 어노테이션이 있으면 자식 클래스에서 오버라이드된 메서드에도 적용된다.
  • @Retention(RetentionPolicy.RUNTIME): 이 어노테이션 정보가 런타임에도 유지되어야 함을 명시한다. 이렇게 해야 실행 중인 애플리케이션에서 리플렉션을 통해 어노테이션 정보를 읽을 수 있다.
  • @Target(ElementType.METHOD): 이 어노테이션이 메서드에만 적용될 수 있음을 나타낸다.
  • public @interface LockTimeout: LockTimeout이라는 이름의 어노테이션을 정의한다.
  • int timeout() default 3000: 이 어노테이션의 속성으로 timeout을 정의하며, 기본값은 3000이다. 어노테이션 사용 시 이 값을 지정하지 않으면 기본값이 사용된다.

2. aop 로 적용할 advice 정의

테스트 환경은 h2, 실제 로컬 db 는 postgres 로 사용했는데, 둘의 문법이 달라 인터페이스로 정의해줬다.

public interface LockManager {

	void setLockTimeout(int timeout);
}
  • h2 구현체
@Slf4j
@Profile("Test")
@Component
@RequiredArgsConstructor
public class H2LockManager implements LockManager {

	@Override
	public void setLockTimeout(int timeout) {
		log.info("테스트 환경에서는 락 타임아웃 설정이 무시됩니다: {}ms", timeout);
	}
}
  • postgres 구현체
@Profile("!Test")
@Component
@RequiredArgsConstructor
public class PostgresLockManager implements LockManager {

	private final EntityManager em;

	public void setLockTimeout(int timeout) {
		Query query = em.createNativeQuery("SET LOCAL lock_timeout = '" + timeout + "ms'");
		query.executeUpdate();
	}

}

3. aspect 정의

@Aspect 
@Component
@RequiredArgsConstructor
public class LockTimeoutAspect {

	private final LockManager lockManager;

	@Before("@annotation(takeoff.logistics_service.msa.product.stock.infrastructure.persistence"
		+ ".aspect.LockTimeout)")
	public void beforeLockTimeout(JoinPoint joinPoint) {
		MethodSignature signature = (MethodSignature) joinPoint.getSignature();
		LockTimeout annotation = signature.getMethod().getAnnotation(LockTimeout.class);
		lockManager.setLockTimeout(annotation.timeout());
	}
}

4. 사용 방법

이제 락 타임아웃 설정이 필요한 메서드에 다음과 같이 어노테이션을 추가하면 된다:

	@LockTimeout(timeout = 1000)
	@Lock(LockModeType.PESSIMISTIC_WRITE)
	@Query("SELECT s FROM Stock s WHERE s.id = :id AND s.deletedAt is null")
	Optional<Stock> findByIdWithLock(@Param("id") StockId id);

5. 동작

  • findByIdWithLock 메서드가 호출되면, @LockTimeout 어노테이션 때문에 AOP가 동작함
  • AOP는 LockTimeoutAspect의 beforeLockTimeout 메서드를 실행함
    이 메서드는 LockManager.setLockTimeout()을 호출함
  • PostgreSQL 환경에서는 PostgresLockManager의 구현이 사용되고, 이 구현은 em.createNativeQuery("SET LOCAL lock_timeout = '1000ms'")를 실행함
  • 그 후에 실제 쿼리(@Query("SELECT s FROM Stock s WHERE s.id = :id AND s.deletedAt is null"))가 실행되는데, 이 쿼리는 @Lock(LockModeType.PESSIMISTIC_WRITE) 때문에 for update 또는 for no key update 구문이 추가됨

마무리

정말 상상도 못한 부분에서 오류가 나서 당황했다..
그래도 빨리 확인 한 것이 다행이지 않나 싶기도하고.
지금에서는 타임아웃이 의미가 있나 싶긴하지만~ (무한대기라던지!)
해당 예외로 CircuitBreaker 같은 설정도 할 수 있지 않을까!
혹시라도 제가 간과한 부분이 있다면 언제든지 알려주세요!

ref : https://mungyu.tistory.com/22

profile
기록

0개의 댓글