본문 바로가기
개발/Database

[Spring/Postgres] Exclusive Lock 제대로 사용하기 (동시성 문제)

by Mingvel 2025. 2. 22.

 

데이터베이스에 데이터를 적재하는 애플리케이션을 운영하다 보면

 

데이터베이스에 Lock을 거는 행위는 매우 빈번하게 일어난다

 

이번 글에서는 

 

필자가 Springboot Web Application + Postgres 환경에서 

 

비관적 락의 잘못된 사용으로 발생했던 이슈와, 해결하는 과정을 담아보았다

 

 

먼저 필자가 비관적 락을 사용했던 이유는

 

동시성 이슈 해소를 위함이었다

 

예를 들어 A 테이블의 1번 Low에 동시에 접근하는 두 트랜잭션이 있을 경우

 

1번 트랜잭션 실행 이후 2번 트랜잭션이 실행되는 것이 보장되어야 했고, 

 

Postgres의 기본 Isolation Level인 READ COMMITED 와 

 

Hibernate에서 제공하는

@Lock(LockModeType.PESSIMISTIC_WRITE)

 

 

어노테이션을 사용하면 당연히 기대한 바(SELECT FOR UPDATE)와 같이 동작한다고 생각했다.

 

심지어 동시 요청 테스트 코드에서도 정상적인 동작을 했었다. (이때 좀 더 정확한 동작을 위해 stress 테스트라도 해봤어야 했다..)

 

따라서 위 코드는 테스트코드를 철석같이 믿은 채 운영 환경에 올라갔고

 

순조롭게 운영되다가 몇 달 후 기어이 문제가 터지고 말았다 (그동안은 운이 좋아서 문제 상황이 발생하지 않았던 것)

 

그것은 또다시 등장하는 동시성이슈

 

 

파악된 상황은 이러했다

 

Expected

  1. T1 트랜잭션 시작 후 Data 1 조회 성공 (exclusive lock)
  2. T2 트랜잭션 시작 후 Data 1 조회 실패 (대기)
  3. T1의 Data 1 데이터 업데이트 성공
  4. T1 Commit
  5. T2 Data 1 조회 (성공)
  6. T2 Data 1 데이터 업데이트 성공
  7. T2 Commit

 

But Actual

  1. T1 트랜잭션 시작 후 Data 1 조회 성공 (exclusive lock인데 조금 다른..)
  2. T2 트랜잭션 시작 후 Data 1 조회 성공(..) (대기)
  3. T1의 Data 1 데이터 업데이트 성공
  4. T1 Commit
  5. T2 Data 1 데이터 업데이트 성공 (2번에서 조회한 데이터를 기준으로 업데이트)
  6. T2 Commit

 

이로써 데이터 정합성이 야무지게 깨졌다 ㅎ.. (정합성을 못 맞추는 개발자가 있다? 어림없지)

 

위 이슈가 발생한 서비스는 실제 돈의 흐름을 실시간으로 관리하는 서비스였으며 

 

동시성 이슈는 꽤나 치명적인 이슈였기에 최대한 빠른 원인파악이 필요했다

 

먼저 실제 실행 된 쿼리를 보자

2025-02-07 08:02:03.422 UTC [2885] LOG:  execute <unnamed>: select (컬럼) from (테이블) where (조건절) for no key update

 

여기서 눈여겨봐야 할 것은 FOR UPDATE 가 아닌 FOR NO KEY UPDATE로 쿼리가 나갔다는 것이다

 

postgres 공식 문서를 보면 FOR NO KEY UPDATE는 FOR UPDATE와 비슷하게 동작하지만, lock 획득이 조금 약하다고 말하고 있다

 

공문에 2줄 적혀있는 내용으로는 정확히 어떤 동작을 한다를 알 수 없지만

 

Actual에서 동작한 내용을 토대로 보면 

 

T1(Transaction 1)에서 FOR NO KEY UPDATE로 조회한 행을 커밋 이전T2에서 FOR NO KEY UPDATE로 조회할 때,

 

조회 자체는 그대로 성공했고, 해당 행을 UPDATE 하는 부분에서 예상했던 Lock 이 동작하는 것으로 보인다

 

따라서 T2에서 UPDATE를 수행하는 행은 T1의 UPDATE 결과를 반영하기 이전 시점의 데이터인 것이고

 

결과적으로 T2의 UPDATE 결과가 T1의 UPDATE 결과를 덮어쓰기 해버린 셈인 것이다 

 

이후에 동료분이 공유해 주셨지만, T1과 T2가 최초 조회 데이터를 베이스로 현재 상태를 결정하는 이런 현상을 Write Skew 라고 표현하는데, 이는 ANSI SQL-92 스펙(isolation level)에서 다루지 않는 이상 현상 중 하나라고 한다

 

 

각설하고... 그래서 왜 FOR UPDATE 가 아닌 FOR NO KEY UPDATE로 동작한 건데?? 

 

2018년에 hibernate 이슈로 올라온 내용을 보면 이에 대한 힌트를 얻을 수 있다.

 

위 내용을 요약하자면

 

이슈가 작성되는 시점엔 postgres 도 PESSIMISTIC_WRITE를 사용하면 FOR UPDATE로 동작하지만

 

글쓴이는 PostgreSQL에서는 FOR UPDATE 쿼리를 실행하는데, 이 lock이 너무 강력하여 외래 키 제약이 있는 다른 엔티티의 새 행 삽입까지 막아버리는 문제가 발생하지만, FOR NO KEY UPDATE 로 위 문제를 해결할 수 있고, 

결과적으로 PostgreSQL에서는 FOR NO KEY UPDATE를 기본적으로 사용하도록 변경하는 것이 적절하지 않냐는 내용이고

 

답변은

1. hibernate 설정으로 NO KEY를 사용할 수 있게 하거나

2. LockOptions.NO_KEYS와 같은 형태로 새로운 LockOption을 제공한다

 

이지만 결과적으로 PostgreSQL에서는 FOR NO KEY UPDATE를 기본적으로 사용하는 방향으로 진행된 것으로 보인다

 

그렇다면 트랜잭션 격리 수준을 serializable로 하면 어떨까? 

 

결론부터 말하면 Write Skew 가 동일하게 발생한다.

 

따라서 위 현상을 해결하려면 최초 조회하는 시점에서 lock이 동작해야 한다

 


이를 구현하기 위해 

 

비관적 락과 함께 hibernate에서 제공하는 쿼리 힌트를 함께 사용했다

@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "0"))

 

위와 같이 postgres에 lock timeout 값 0을 주면 select 문에 NO WAIT 가 붙어서 나오고

 

조회 시점 데이터에 lock이 잡혀있다면 그 즉시 PessimisticLockException 이 발생한다

 

즉, 조회 시점에 PessimisticLockException을 터뜨리고, Spring에서 제공하는 retry 기능인 @Retryable 어노테이션을 사용하면 위 동시성 이슈를 해결할 수 있다

    @Transactional
    @Retryable(
        retryFor = [PessimisticLockingFailureException::class],
        maxAttempts = 10,
        backoff = Backoff(300),
    )
    fun targetFunction()

 

Retryable 어노테이션엔 대상 예외 메서드와, 최대 시도 횟수, Backoff를 설정할 수 있다

Backoff 는 기본적으로 ms 단위로 설정이 가능하다

 

위 코드는 PessimisticLockException 이 발생하면, 0.3초마다 총 10번까지 재시도하는 것으로 동작한다

 

이로써 동시에 발생한 요청들을 누락 없이 반영할 수 있는 환경이 만들어졌다


 

동시성 이슈는 해결했지만, 위 해결 방법은 다중 스레드 처리 순서를 보장하지는 못한다

 

동시에 들어오는 요청의 처리 순서를 보장하고자 한다면 Queue, batch 처리 등 상황에 맞는 방법을 고려해 볼 수 있을 것이다

 

 

반응형

댓글