image-test

i2234i2234
8 min read

INTRO

동시성? 락?
동시에 수정하면 문제생기는거 정도는 알죠.
근데 들어만봤지, 써본적은 없었어요.

이번 사이드프로젝트에서 재고 관련 기능을 개발하면서
직접 테스트해본 동시성 제어 전략들을 소개할게요.

Java 21, Spring Boot 3 , PostgreSQL 17 환경에서
JPA를 사용해 테스트했고, 전체 코드는 여기서 확인할 수 있어요.

간단 예시 코드

이번 글의 목표는 코드보다 원리 중심으로 설명하는 거예요.
최대한 직관적으로 전달하기 위해 노력할게요.


0.아무것도 안하면?

들어가기 전에, 아무것도 안하면 무슨 일이 벌어지는지부터 살펴보죠.

public void justDecrease(Long stockId, Long amount) {
    var stock = stockRepository.findById(stockId).orElseThrow();
    stock.desc(amount);
    log.info("stock = {}", stock);
}

@DisplayName("아무 전략도 없을때")
@Test
void t0() throws Exception {
    Long stockId = repository.save(Stock.of(10000L)).getId();
    runConcurrentDesc(
            () -> service.justDecrease(stockId, 10L),
            20, -- threadCnt
            50  -- repeatPerThread
    );
    var result = repository.findById(stockId).orElseThrow();
    log.info("result = {}", result);  //Stock(id=1, quantity=8720)
}

Copy

20개의 스레드가 각각 50번씩 10개를 감소시키는 시나리오에요. 예상보다 약 1/10만 감소했어요.

스레드 20개가 50번 반복하며 재고를 10개씩 감소하는 시나리오에서,
10 × 20 × 50 = 10,000 이 감소해야 하지만, 실제 감소된 값은 1,280이에요.
여러 스레드가 동시에 값을 변경하다 보니, 서로의 수정 내용을 덮어쓴 거죠.

이게 바로 동시성 문제죠.

1.Pessimistic Lock (비관적 락)

"이 row를 업데이트하려면, 한번에 한명씩만 접근!!"

SELECT ... FROM stock WHERE id =1 FOR NO KEY UPDATE

Copy

POSTGRES는 다음과 같은 쿼리가 나가요

이름에서 알 수 있듯, 가장 확실하고 강력한 방식이에요.
SELECT ... FOR UPDATE 문을 사용하는데,
데이터를 읽을 때 해당 행에 락을 걸어버리고 다른 트랜잭션을 대기시켜요.

주의할 점은 다음과 같아요:

  • 충돌이 실제로 발생하지 않아도, 무조건 락이 걸려요.

  • 여러 락을 순서대로 획득하지 못하면 데드락 위험이 있어요.

  • 일반 SELECT 는 락이 필요없기 때문에,
    모든 업데이트가 필요한 경우에 SELECT FOR UPDATE를 사용해야 해요.

SELECT 사용시 가능 시나리오


JPA

2.Optimistic Lock(낙관적 락)

"일단 마음껏 업데이트하고, 겹치면 알아서 재시도!"

낙관적 락은, 이름처럼 충돌이 없을 거라 낙관해서, 실제 락을 사용하지 않고
데이터의 버전 정보를 비교해서 충돌을 감지하는 방식이에요.

SQL에서 UPDATE는 격리 수준과 상관없이, 원자적으로 그 시점에 조건이 일치하는 레코드만 수정하고, 변경된 레코드의 개수를 반환해요.
만약 UPDATE가 아무런 레코드도 수정하지 못했다면, DB에서 0을 반환하고, 이를 JPA가 충돌로 간주하는거죠.

UPDATE stock SET quantity = ?,version = ? --버전을 비교하면서
WHERE id = ? AND version = ?; -- 버전을 업데이트

Copy

모든 업데이트는 이렇게 진행되요

이 방식은 실제로 락을 사용하지 않기 때문에, 충돌이 거의 없는 상황에서 가장 좋아요(단순 update거든요).

하지만, 충돌이 발생한다면, 락을 기다리는게 아니라 바로 실패로 처리되고, 트랜잭션을 재시도해야 해요. (따라서 꼭 재시도 로직을 구현해줘야 해요)
당연히 락 사용보다 훨씬 비효율적이겠죠?

그리고 당연하게도, 낙관적 락이 아닌 다른 방식으로 재고가 감소됐다면 감지하지 못해요.

JPA

3.REPEATABLE READ❌

가능하지만, 억지로 사용하는 방식입니다. 쓰지마세요.

BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;

Copy

REPEATABLE READ 격리수준은, 동일 트랜잭션 내 동일 쿼리 결과가 멱등하다는게 가장 큰 특징이지만,
하나의 레코드를 동시에 여러 트랜잭션이 변경 하는 작업도 허용하지 않아요.

이는 낙관적 락과 비슷한데,
트랜잭션 시작 시점의 스냅샷을 기준으로 다른 트랜잭션이 먼저 커밋해버리면
내 트랜잭션의 커밋 시점에서 충돌로 간주되어 직렬화 예외가 발생해요.

억지로 이렇게도 할 수 있다는 정도로 읽어주세요.

JPA

4.DB에게 계산 시키기

"동시성 생각하기 머리아파..그냥 니가 계산 해줘 "

위에서 설명한 전략들은, 전부 다음과 같은 순서에요.
DB에서 조회 => 애플리케이션에서 연산 =>결과를 DB에 업데이트.

하지만 이 방법은 연산 자체를 DB에게 맡기는 거에요.

UPDATE stock SET quantity = quantity - ?  
WHERE id = ? AND quantity >= ?;

Copy

DB의 update연산은 무조건 row-level lock을 걸고 , setwhere 을 원자적으로 처리해요.
즉, 동시에 실행하더라도 재고가 0보다 작아지거나 덮어씌워질 염려가 없어요.

간단한 +,- 연산엔 가장 적합하지만, 복잡한 로직에는 적용이 어렵고,
JPA에선 native query를 사용해야 해요.

JPA

지금까지 테스트해본 결과, 각 전략마다 장단점이 확실히 드러나요.

  • 비관적 락은 확실하게 충돌을 방지하지만, 락이 많아질수록 DB에 부담이 가요.

  • 낙관적 락은 충돌이 적으면 효율적이지만, 충돌이 많아질수록 효율이 떨어져요.

  • 간단한 연산엔 DB에 맡기는 방식이 가장 효율적이지만, 비즈니스 로직이 복잡해지만 한계가 드러나요.

무엇보다 이 전략들은 모두 DB의 레코드를 기준으로 작동해요.

하지만 실제 서비스에서는,
레코드 하나를 잠그는 것만으로는 부족한 상황이 자주 발생해요.
예를 들어, “한 사용자의 주문은 동시에 하나만 처리되어야 한다” 같은 요구는
단순히 특정 row에 락을 건다고 해결되지 않아요.

또 DB가 샤딩되었다면? DB가 여러 개라면? DB와 전혀 상관없는 리소스를 다룬다면?...

그래서, 복잡한 분산 환경에서는 별도의 분산 락 시스템을 사용하는데, 대표적인 예로 Redis를 사용하죠.
레코드 기반 락을 넘어선 분산 락 전략은, 나중에 더 자세히 다뤄볼게요.


3-POINT

  1. 레코드 기반 동시성 제어를 직접 테스트해 봤어요.

  2. 크게 3가지 종류가 있고, 각각의 장단점은 확실해요.

  3. 하지만 분산 환경에서는 DB 레코드 기반으로는 한계가 있어요.

0
Subscribe to my newsletter

Read articles from i2234 directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

i2234
i2234