[Spring] 동시성 이슈를 고려한 자바 프로젝트 설계

개요

 트래픽이 많아질수록 동시성 이슈를 철저하게 고려해야 한다.

자바 스프링에서 발생할 수 있는 동시성 이슈를 체크하고, 다양한 해결방법에 대해서 공부해보자.

 

 

동시성 문제는 지역변수와 같이 쓰레드별로 할당되는 공간에서는 발생하지 않으며, 싱글톤과 같이 동일한 인스턴스의 필드에 접근하거나, static과 같은 공용 필드를 변경할 때 발생한다. 위와 같이 간단한 엔티티와 서비스 클래스를 생성해준 후, 재고를 감소시키는 decrease라는 로직이 동시성 이슈 위에서 제대로 동작하는지를 체크해보도록 하자.

 

 

 가장 먼저 들었던 생각은 그냥 "@Transactional 어노테이션 걸어주면 되는거 아니야?"라는 단순한 생각이었다. 트랜잭션을 통해서 데이터의 무결성을 지킬 수 있지 않을까? 라는 생각으로 간단한 테스트코드를 만들었다. 

 

@BeforeEach
public void init() {
    stockRepository.saveAndFlush(new Stock(1L, 100L));
}
@Test
@DisplayName("멀티쓰레드 환경에서 Race Condition 발생!!")
public void 동시요청() throws InterruptedException {
    int threadCount = 100;
    // 쓰레드 32개를 관리하는 쓰레드 풀 객체 생성
    ExecutorService executorService = Executors.newFixedThreadPool(32);
    CountDownLatch latch = new CountDownLatch(threadCount);

    for(int i=0; i<threadCount; i++) {
        executorService.submit(() -> {
            try {
                stockService.decrease(1L, 1L);
            } finally {
                latch.countDown(); // 각 쓰레드의 작업 종료를 명시한다.
            }
        });
    }

    latch.await(); // 메인쓰레드는 latch의 count가 0이 되기를 기다린다.

    Stock stock = stockRepository.findById(1L).orElseThrow();
    Assertions.assertThat(stock.getQuantity()).isEqualTo(0); // race condition으로 인하여 원하는 결과가 나오지 않음
}
*ExecutorService는 간단하게 쓰레드 풀을 관리하는 역할을 하고, CountDownLatch를 통해서 일정 개수의 쓰레드의 실행을 기다렸다가 메인쓰레드가 다음 로직을 진행할 수 있도록 하였다. (자세한 설명은 https://imasoftwareengineer.tistory.com/100 참고)

 

처음 데이터베이스에 재고 100개짜리 상품이 존재하고, 더티체킹을 통해서 100개의 쓰레드에서 각각 재고를 1씩 감소시키는 로직을 실행하였으므로 남은 재고가 0일것이라는 예상을 해볼 수 있었다. 하지만..

 

11개밖에 감소하지 않았다.

예상과는 다르게 재고가 남아있는 모습을 확인할 수 있었다.

 

 

Race Condition

 3학년때 OS, 데이터베이스 과목에서 들어본 후로 오랜만에 들어보는 것 같은데, Race Condition이란 "다중 스레드 또는 다중 프로세스 환경에서 공유된 자원에 대한 동시적인 접근으로 인해 예기치 못한 동작이 발생하는 상황"을 말한다.

 

현재 재고를 감소시키기 위한 decrease() 함수의 동작을 위해서는

(1) 데이터베이스에서 product의 재고 수량을 조회 (Read)
(2) read한 수량을 1 감소 (Modify)
(3) 감소시킨 수량을 데이터베이스에 반영 (Update)

 

라는 진행과정이 필요하다.

 

하지만 쓰레드 A가 데이터를 read하고 update하기까지 걸리는 시간 사이에 다른 쓰레드 B가 update되지 않은 재고 수량을 읽어버린다면, 결국 쓰레드 A, B는 동일한 update 작업을 반복하게 되는 것이 되므로 테스트코드에서 재고가 모두 소진되지 않는 문제가 발생했던 것이다.

 

 

1.  트랜잭션 격리 수준 변경

 

 

 첫번째로 생각했던 해결책이다. 트랜잭션의 고립성(Isolation)은 여러 트랜잭션이 동시에 실행될 때 각 트랜잭션은 다른 트랜잭션의 영향을 받지 않음을 위해서 존재하고, 트랜잭션이 시작하고 커밋될 때 까지 다른 트랜잭션이 미치는 영향도를 결정한다. 따라서 현재 트랜잭션의 격리 수준만 올려주면 해결할 수 있지 않을까? 라는 생각이 들었다.

 

격리 수준: serializable

 

하지만 테스트는 실패했는데, 가장 높은 트랜잭션 격리 수준인 SERIALIZABLE조차 트랜잭션 도중 select 쿼리에 대해서 공유락(LS)만을 걸기 때문에, 다른 쓰레드에서 읽기작업이 진행되는 것을 막을 수는 없기 때문으로 보인다.

 

 

2. synchronized 키워드 추가

 

 예전에 정리한적 있듯이, 스레드간 blocking을 이용하여 동기화 블락 전체에 락을 걸어주어 해결할 수 있다. 다만 여기서 유의해야 할 점은, @Transactional을 사용할 때 아래 코드와 같이 스프링 트랜잭션은 프록시 방식으로 동작하기 때문에 기존의 서비스 클래스를 필드로 주입받는 가짜 객체를 사용하여 트랜잭션을 관리하게 된다.

 

왼쪽이 서비스 클래스, 오른쪽이 트랜잭션 프록시 예시

 

자세히 설명하면, 스프링 트랜잭션은 오른쪽과 같이 프록시 객체가 실제 서비스 객체를 감싸고 있는 형태로 되어 동작하는데, 이 과정에서 decrease() 메소드 자체에 대해서는 동시성이 보장되지만, db에 데이터가 업데이트되는 시점은 트랜잭션 종료 시점(endTransaction() 호출 시점)이므로 decrease()와 endTransaction() 사이에 다른 스레드가 db를 조회하게 되면 동시성을 보장할 수 없다.

 

 

 

@Transactional 어노테이션을 제거하여 메소드 단독으로 실행되도록 하거나, 메소드를 호출하는 쪽에 synchronized 키워드를 설정해준다면 정상적으로 동작함을 확인할 수 있다.

 

 하지만 전자의 경우 트랜잭션을 적용할 수 없다는 문제가 있고, 두번째 방법도 결국 모든 요청 각각에 lock을 걸어 한번에 하나의 스레드만 실행되게 되므로 성능이 좋지 않을 것임을 예상할 수 있다.

뿐만 아니라 synchronized 키워드는 '동일 프로세스의 스레드'에 대해서만 동시성을 보장한다. 단일 서버라면 문제가 되지 않겠지만, 여러대의 서버에서 요청이 들어오는 경우는 동시성을 보장할 수 없다.

 

 

 

3. RDB 이용하기

3-1. Pessimistic Lock (비관적 락)

 

 데이터베이스에서 데이터에 접근할 때 배타락(EX)를 걸어 다른 프로세스에서 데이터에 접근할 수 없도록 하는 방법이다. 데이터의 정합성이 보장되기는 하지만 성능 감소가 일어날 수 밖에 없다는 단점이 있다. (데이터 충돌이 빈번하게 일어나면 낙관적 락보다는 성능이 좋다.)

 

 

일반적으로 비관적 락을 걸 때는 PESSIMISTIC_WRITE를 주로 사용하는데, 이렇게 비관적 락을 걸어주면 트랜잭션은 락을 획득할 때까지 대기하게 된다.

 

 

3-2. Optimistic Lock (낙관적 락)

 

 락을 직접 이용하지는 않고, 별도의 컬럼을 이용하여 정합성을 맞출 수 있다. update를 수행할 때, 읽어온 version값에 해당하는 데이터만 업데이트함으로서 동일한 update쿼리가 반복 실행되지 않도록 방지한다. 이를 위해서 동일한 update문이 수행되는 것을 방지하는 별도의 애플리케이션 로직이 필요하다.

 

 

 엔티티에 @Version 어노테이션을 통해서 낙관적 락의 버전을 명시하고, Facade 클래스를 만들어서 version정보가 틀려서 update쿼리가 실패한다면 50ms 이후 재시도하는 별도의 로직으로 감싸주었다.

 

 

충돌이 빈번하게 발생할 것이라고 예상된다면 비관적 락을, 아니라면 낙관적 락을 사용하자.

 

 

3-3. Named Lock (네임드 락)

 

 

 네임드 락은 이름을 가지는 락으로, 이름과 timeout정보를 얻어서 락을 획득하고, 해제하는 방법이다. pessimistic lock은 db상의 데이터에 락을 걸었다면, named lock은 별도의 접근공간에 락을 걸게 된다.

 

 

 

4. NoSQL(Redis) 이용하기

트래픽이 증가한다면 rdb보다는 nosql을 사용하는 것이 성능상의 이득을 볼 수 있다.

 

4-1. Lettuce (Spring Data Redis)

 Redis의 setnx(set if not exists) 명령어를 활용하여 키가 기존에 존재하지 않을 경우에만 동작하게 할 수 있는데, Spring Data Redis의 redis connection pool을 제공하는 클라이언트 라이브러리 중 하나인 Lettuce를 사용하여 다음과 같이 활용할 수 있다.

 

(위에 Lettuce(Spring Data Redis) 라고 써놓은 이유는 기본 redis-cli 라이브러리가 Lettuce이기 때문이다.)

 

 

while문을 이용하여 spin lock의 형태로 구현하였다. 락을 얻을때까지 sleep(100)을 반복하며 락 획득을 시도하다가, 락을 획득하면 decrease()를 수행한 후 unlock한다. spin lock 방식이므로 많은 스레드가 lock획득을 위해서 대기할수록 redis에 부하가 증가하게 된다.

 

Named Lock과 거의 동일한 원리를 가지지만, Redis의 setnx를 사용한다는 점에서 차이가 존재한다.

 

 

4-2. Redisson

 redis pub-sub 기반으로 동작하기 때문에 redis에 가는 부하를 줄여줄 수 있으며, 라이브러리 차원에서 lock을 제공하기 때문에 기존 라이브러리를 그대로 사용해주면 된다.

 

 

 

 

 

 

 

 

 

github: https://github.com/eckrin/sync-example

참고: 자바 ORM 표준 JPA 프로그래밍, 재고시스템으로 알아보는 동시성이슈 해결

https://zzang9ha.tistory.com/443