JPA 트랜잭션과 동시성 문제

목차

  1. 동시성 문제
  2. 비관적락
  3. 낙관적락
  4. 네임드락
  5. 분산락 (with Redis)

모든 코드는 github 에서 확인할 수 있습니다.

동시성 문제

소프트웨어 개발(컴퓨터 과학)에서 동시성 문제란 여러 프로세스(또는 스레드)가 동시에 실행되는 환경에서 발생하는 문제를 말합니다. 본글에서는 여러가지 동시성 문제중 웹 애플리케이션 개발시 흔히 발생하는 race condition을 설명하고 Lock을 사용한 해결방법을 알아봅니다.

Race Condition

숫자를 1씩 증가시키는 카운터를 개발한다고 생각해 봅시다. 다음과 같이 테이블을 설계할 수 있습니다.

CREATE TABLE `counter` (
  `id` BIGINT NOT NULL,
  `count` BIGINT NOT NULL DEFAULT 0,
  PRIMARY KEY (`id`)
);

위 테이블을 JPA의 엔티티로 표현하면 다음과 같습니다. 또한 엔티티에 카운트값을 증가시킬 increaseCount 메서드를 정의했습니다.

@Entity
class Counter(
    @Id val id: Long,
    var count: Long = 0,
) {
    fun increaseCount() {
        this.count += 1
    }
}

서비스에서 다음과 같이 사용하면 정상적으로 동작할까요?

@Service
class CounterService(
    private val counterRepository: CounterRepository,
) {
    @Transactional
    fun counting(id: Long) {
        // 1. 카운터 조회
        val counter = counterRepository.findById(id).orElseThrow()
        // 2. 카운터 증가
        counter.increaseCount()
    }
}

로직은 이상이 없어보이지만 정상적인 동작을 기대할 수 없는 코드입니다. 이유는 요청이 동시에 발생하면 couting메서드에 여러 쓰레드가 동시에 진입가능하기 때문입니다.

sequenceDiagram; autonumber; application(thread 01)->>database: SELECT * FROM counter WHERE id = 1; database->>application(thread 01): count = 0; application(thread 02)->>database: SELECT * FROM counter WHERE id = 1; database->>application(thread 02): count = 0; application(thread 01)->>database: UPDATE counter SET count = 1 WHERE id = 1; database->>application(thread 01): 1(success); application(thread 02)->>database: UPDATE counter SET count = 1 WHERE id = 1; database->>application(thread 02): 1(success);

두개의 요청을 각각 thread01, thread02로 표현했을때 thread01에서 읽은(2) 값은 0입니다. 이때 thread02에서 읽은(4) 값도 0이기 때문에 두 요청 모두 count값을 1로 업데이트 하도록 요청합니다.

이처럼 한 자원(위예시에서는 카운트값)에 여러 스레드(또는 프로세스)가 동시에 접근할경우 실행순서에 따라 결과가 달라질수 있는것을 race condition이라고 합니다. 여러 트래픽을 동시에 처리해아하는 웹 어플리케이션에서는 흔히 발생할수 있는 문제로 다음과 같은 다양한 상황이 존재합니다.

  • e-commerce에서 정해진 수량만큼의 사용자 요청 처리
    • 상품의 재고 관리
    • 쿠폰 발급 개수 관리
  • sns의 게시글 좋아요 처리
  • banking의 사용자 계좌 처리

본 글에서는 race condition을 해결하기 위한 Lock을 사용한 다양한 방법을 소개합니다.

비관적락

첫 번째 방법은 비관적락(pessimistic lock)을 사용해서 race condition을 제어하는 방법입니다. 비관적락은 데이터베이스에서 잠금을 수행하는 방법으로 row 접근시 잠금을 걸어 다른 요청(트랜잭션)에서 같은 row에 접근하면 대기하도록 하는 방법입니다. 코드로 표현하면 다음과 같습니다.

interface CounterRepository : JpaRepository<Counter, Long> {
    @Lock(LockModeType.PESSIMISTIC_READ)
    @Query("SELECT c FROM Counter c WHERE c.id = :id")
    fun findOneAsPessimistic(id: Long): Counter?
}

조회시 @Lock(LockModeType.PESSIMISTIC_READ) 어노테이션을 사용함으로써 비관적락에 해당하는 조회쿼리를 수행할 수 있습니다. 수행되는 쿼리는 다음과 같습니다.

SELECT * FROM counter WHERE id = 1 FOR UPDATE;

FOR UPDATE로 구문이 추가된것을 볼 수 있습니다. 이렇게 조회된 row를 다른 요청에서 조회하면 원래 요청이 완료되지 전까지 대기하게 됩니다.

sequenceDiagram; autonumber; application(thread 01)->>database: SELECT * FROM counter WHERE id = 1; database->>application(thread 01): count = 0; application(thread 02)->>database: SELECT * FROM counter WHERE id = 1; application(thread 01)->>database: UPDATE counter SET count = 1 WHERE id = 1; database->>application(thread 01): 1(success); database->>application(thread 02): count = 1; application(thread 02)->>database: UPDATE counter SET count = 2 WHERE id = 1; database->>application(thread 02): 1(success);

thread01에서 조회한 row(id = 1)를 thread02에서 조회했지만 thread01의 요청이 끝나기 전까지 대기하다가 정상적으로 업데이트된 count = 1을 반환받는것을 확인할 수 있습니다.

낙관적락

두 번째 방법은 낙관적락(optimistic lock)을 사용해서 race condition을 제어하는 방법입니다. 낙관적락은 애플리케이션 레벨에서 동시성을 제어하는 방법으로 CAS(Compare and Set)라고도 합니다. 업데이트시 이전 값을 조건절로 수행합니다.

#  번째 수행시
UPDATE counter c SET c.count = 1 WHERE c.count = 0;

#  번째 수행시
UPDATE counter c SET c.count = 2 WHERE c.count = 1; 

JPA에서는 버전값을 별로도 추가해서 낙관적락을 지원합니다.

@Entity
class Counter(
    @Id val id: Long,
    var count: Long = 0,
    @Version var version: Long = 1,
) {
    fun increaseCount() {
        this.count += 1
    }
}

interface CounterRepository : JpaRepository<Counter, Long> {
    @Lock(LockModeType.OPTIMISTIC)
    @Query("SELECT c FROM Counter c WHERE c.id = :id")
    fun findOneAsOptimistic(id: Long): Counter?
}
sequenceDiagram; autonumber; application(thread 01)->>database: SELECT * FROM counter WHERE id = 1; database->>application(thread 01): count = 0, version = 0; application(thread 02)->>database: SELECT * FROM counter WHERE id = 1; database->>application(thread 02): count = 0, version = 0; application(thread 01)->>database: UPDATE counter SET count = 1, version = 1 WHERE id = 1 AND version = 0; database->>application(thread 01): 1(success); application(thread 02)->>database: UPDATE counter SET count = 1, version = 1 WHERE id = 1 AND version = 0; database->>application(thread 02): 0(fail);

thread01과 thread02 모두 업데이트전 상태를 읽어갔지만 thread01은 업데이트 성공, thread02는 업데이트 실패(thread01에서 version = 1로 이미 업데이트했기때문에)를 확인할 수 있습니다. 낙관적락의 경우 실패시 애플리케이션 레벨에서 재시도를 수행해야합니다.

비관적락과 낙관적락은 잠금을 위한 방식일뿐 구현체에 종속된 개념이 아닙니다. 즉 쿼리를 직접 작성해서 수행하는 경우도 동일한 개념들을 적용할 수 있습니다.

네임드락

세 번째 방법은 MySQL의 네임드락입니다. 네임드락은 특정 row나 테이블이 아닌 사용자가 지정한 문자열에 대해 락을 획득하고 반납하는 락입니다.

SELECT GET_LOCK('COUNTER:1', 2);

...

SELECT RELEASE_LOCK('COUNTER:1');

분산락 (with Redis)

세 번째 방법은 Redis를 사용한 분산락을 사용한 방법입니다. Redis는 인메모리 기반 key-value 스토리지로 싱글스레드 기반으로 동작합니다. 때문에 동시성 문제가 발생하지 않고 임의의 키를 저장하고 제거하는 동작으로 락을 구현할 수 있습니다.

Redis를 사용할 수 있는 여러 클라이언트 라이브러리 들이 존재합니다. 여기서는 Spring Data Redis와 Redisson 두 가지 라이브러리를 사용해서 락을 사용하는 방법을 소개합니다.

Spring Data Redis

Spring Date Redis를 사용하면 임의의 key-value를 저장하고 제거하는것으로 락을 구현할 수 있습니다. 다만 락이 점유해지 확인을 애플리케이션에서 반복적인 요청으로 확인하는 스핀락 방식으로 구현해야합니다.

while (
    !redisTemplate.opsForValue()
        .setIfAbsent("lock:$id", "lock", Duration.ofMillis(3_000))!!
) {
}

counterService.count(id)
redisTemplate.delete("lock:$id")

Redisson

또 다른 Redis 클라이언트 라이브러리로 Redisson이 존재합니다. Redisson은 Pub-sub 기반의 락을 위한 인터페이스를 제공합니다.

val lock = redissonClient.getLock("lock:$id")
try {
    lock.lock(10, TimeUnit.SECONDS)
    counterService.count(id)
} finally {
    lock.unlock()
}

References