[QRworld] 락 대신 쿼리를 이용한 원자적 업데이트

suhwani·2025년 6월 3일
0
post-thumbnail

문제 원인 파악


문제가 발생한 부분은 선착순으로 정해진 인원만큼 등록을 받는 서비스에서 가장 중요한 등록 기능이다.
정해진 인원만큼 등록을 받아야했지만, 멀티쓰레드 언어인 자바의 특성상 동시에 여러 요청이 들어오면서 정해진 인원보다 많은 인원이 등록 가능한 상황이 발생하였다.

코드를 도식화하면 그림처럼 진행된다. 이 중 빨간 박스에서 등록 가능한 사람의 수가 -1이 되는 문제가 발생한다. 따라서 빨간 박스의 로직을 실행하면서 다른 작업, 트랜잭션이 중간에 들어올 수 없도록 막아줘야 한다.

어떻게 해결할까


동시성 처리에 대해 많은 해결방법이 존재한다. 각각의 개념을 알고 현재 상황과 가장 적합한 해결 방법을 선택하여 적용할 예정이다. 다만 해당 글에서는 각 개념을 다루기보다는 어떻게 적용했는지 과정을 설명할 것이다.

읽기 제어는 다음과 같은 문제를 해결할 수 있다.

  • Dirty Read: 다른 트랜잭션에서 커밋되지 않은 값을 읽는 현상
  • Non-repeatable Read: 같은 트랜잭션 내 같은 행을 여러 번 읽을 때 결과가 다른 현상
  • Phantom Read: 같은 트랜잭션 내 같은 조건으로 여러 번 조회 시 새로운 행이 생기거나 사라지는 현상
!! Non-repeatable Read vs Phantom Read
- Non-repeatable Read는 Update 쿼리에 의해 같은 행을 읽어도 다른 결과를 받는 경우
- Phantom Read는 Insert, Delete 쿼리에 의해 여러 번 읽을 때 새로운 데이터가 생기거나 사라지는 경우

이와 다르게 쓰기 제어는 다음 문제를 해결할 수 있다.

  • Lost Update: 두 트랜잭션이 같은 행을 읽고 각각 수정한 뒤 커밋을 하는 경우 마지막 커밋이 앞선 수정을 덮어 한쪽 수정이 사라지는 현상
  • Over booking: 수량, 카운터를 중복 감소하거나 증가하여 음수 또는 초과 되는 현상

현재 선착순 인원을 초과해 등록이 되는 문제를 해결하기 위해 쓰기 제어 기법 중 원자적 업데이트를 적용할 예정이다.

'원자적 업데이트'를 선택한 이유


메서드락, 동시성 패키지
synchronized, ReentrantLock을 말한다. 간단하게 구현 가능하지만, 로직 수행 과정 전체에 Lock을 얻고 해제해야 하며, 단일 서버 내에서만 동작하기 때문에 현재는 단일 서버로 운영을 하더라도 추후 다중 서버 또는 로드밸런싱 등을 위해 맞지 않다고 생각한다.

분산 락
추가 인프라를 구축하여 락을 DB가 아닌 다른 서비스에서 관리하는 기법이다. 다중 서버(MSA)에서도 동작 가능하고 락의 책임을 DB와 분리할 수 있다는 장점이 있지만, 인프라를 추가한다는 것은 병목 지점이 늘어나고 관리 요소가 늘어나는 것과 같다고 생각한다. 추후 분산 락을 도입할 수 있겠지만, 현재는 추가 인프라를 넣게 되면 비용이 추가되기도 하고, 자원을 더 먹게 되기에 맞지 않다고 생각한다.

낙관적 락
실제 락을 걸지 않고, 충돌이 일어난 경우 재시도 또는 롤백을 실행한다. Entity에 @Version을 추가하여 버전 충돌을 감지하여 실행 또는 예외를 실행한다. 락을 걸지 않아 성능상 이점이 있으나, 충돌이 빈번한 경우 재시도를 해야한다. 재시도를 하지 않고 에러를 내보낸다면, 사용자에게 재시도를 하라는 문구를 내보내야 한다. 현재 프로젝트는 선착순이 중요한 목표로 빠른 시간 내 많은 트래픽을 기대하고 있어서 충돌이 많을 것으로 예상하기에 맞지 않다고 생각한다.

비관적 락
실제 락을 걸고 충돌을 제어한다. @Lock을 사용하고 LockModeType에는 PESSIMISTIC_WRITE, PESSIMISTIC_READ, PESSIMISTIC_FORCE_INCREMENT(WRITE와 동일하지만, @Version 지정된 Entity와 협력 및 버전 업데이트 기능)가 있다. 다만 FOR UPDATE 락이 걸리기 때문에 다른 트랜잭션은 동일 행에 대해 UPDATE, DELETE, FOR UDPATE를 진행할 수 없다. 이는 성능이 안 좋아지는 단점이 있다.

원자적 업데이트
UPDATE 쿼리 시 WHERE절에 조건 검사를 추가하여 READUPDATE를 원자적으로 묶는 방법이다.
다만 기존 READ와 UPDATE 사이 로직이 SQL로 처리될만큼 간단한 경우에 사용할 수 있다.

select stock from product where id = 1
update product set stock = stock -1 where id = 1

이처럼 개별적으로 실행하게 되면 읽은 이후 누군가 값을 업데이트 하였을 때 정합성이 깨질 수 있다.

update product set stock = stock - :stock
where 
    id = 1 
    and (stock - :stock >= 0)

이런 식으로 수정하게 되면, WHERE절에서 stock을 읽고 UPDATE를 하기 때문에 READ와 UPDATE를 원자적으로 수행할 수 있다.

LINK: 원자적 업데이트로 동시성 해결, 인프런 답변

비관적 락과 원자적 업데이트 차이점

비관적 락

비관적 락은 @Lock을 통해 구현할 수 있고, FOR UPDATE 락이 걸린다. 비관적 락을 사용한다면 READ -> UPDATE 과정 전체가 락이 걸린 상태로 진행하게 된다.

원자적 업데이트

원자적 업데이트는 업데이트와 조건 검사를 한 번에 수행한다. 물론 UPDATE 쿼리는 락을 진행하고 수행이 되는데, 락을 거는 시점과 유지되는 시간에서 비관적 락보다 효율적이다. 또한 락 없이 진행되는 낙관적 락과 비교하여 재시도, 충돌이 일어나지 않아 락을 동반하지만 이 방법이 더 나은 방향이라고 생각한다.

적용

비관적 락을 사용한다면 Read -> Update 과정이 진행되며, 두 과정 모두 Lock을 걸게 되지만 이 과정을 쿼리를 이용해 원자적으로 수행하여 Lock 범위를 최소화했다.

Service 파일 변경

변경 전 ❌

변경 후✅

Repository 쿼리 작성

변경 후✅

검증

테스트 코드를 작성하여 위 문제 해결 방법이 제대로 적용됐고, 실제로 해결이 됐는지를 검증했다.

  • 10개 쓰레드를 생성하여 준비시켜놓고, 동시에 모든 쓰레드에서 요청을 보내 등록을 진행
  • 등록 가능 인원은 5명
  • 10개 쓰레드 중 중 5개는 성공, 5개는 실패를 해야 테스트 성공

테스트 코드 검증

기존 테스트 코드 실패

변경 후 테스트 코드 성공

profile
Backend-Developer

0개의 댓글