Home 동서싱 이슈 해결 방법
Post
Cancel

동서싱 이슈 해결 방법


해당 글은 재고시스템으로 알아보는 동시성 이슈 해결 방법 강의를 정리한 글입니다.

정리 코드: 깃허브 링크

개발 환경

  • Java 17
  • Spring boot 3.2.3
  • redis
  • docker
  • mysql
  • jpa
  • lombok



✅ 동시성 이슈란?

  • 동시에 여러 개의 스레드, 프로세스 또는 작업이 공유된 자원에 접근하고 수정하는 과정에서 발생할 수 있는 문제들
  • 여러 작업이 동시에 실행되고 동시에 동일한 자원에 접근하거나 수정하려고 할 때 발생되는 이슈


📌 동서싱 이슈로 발생되는 문제

  • 경쟁상태(Race Condition): 여러 개의 스레드나 프로세스가 동시에 공유된 자원에 접근하고 수정할 때, 실행 순서나 타이밍 등에 따라 예기치 않은 결과가 발생
  • 교착상태(DeadLock): 두 개 이상의 작업이 서로 상대방이 가진 자원을 기다리면서 무한히 대기하는 상태
  • 일관성 문제(Consistency Issue): 동시에 여러개의 작업이 데이터를 수정하거나 읽고 쓰는 경우, 데이터 일관성이 깨질 수 있음
  • 병목 현상(Bottleneck): 특정 자원에 대한 동시 접근이 많아져 병목현상 발생



✅ 재고시스템 기본 코드

📌 Stock

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Getter
@NoArgsConstructor
@Entity
public class Stock {
  @Id
  @GeneratedValue(strategy = GenerationType.AUTO)
  private Long id;

  private Long productId;

  private Long quantity;

  @Version
  private Long version;

  public Stock (long productId, long quantity) {
    this.productId = productId;
    this.quantity = quantity;
  }

  public void decrease(Long quantity) {
    if (this.quantity - quantity < 0) {
      throw new RuntimeException("재고는 0개미만이 될 수 없습니다.");
    }

    this.quantity -= quantity;
  }
}


📌 StockRepository

1
2
public interface StockRepository extends JpaRepository<Stock, Long> {
}


📌 StockService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@RequiredArgsConstructor
@Service
public class StockService {

    private final StockRepository stockRepository;

    @Transactional
    public void decrease(Long id, Long quantity) {
        Stock stock = stockRepository.findById(id).orElseThrow();
        stock.decrease(quantity);

        stockRepository.saveAndFlush(stock);
    }
}


📌 StockServiceTest

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
@SpringBootTest
class StockServiceTest {

    @Autowired
    private StockService stockService;

    @Autowired
    private StockRepository stockRepository;

    @BeforeEach
    public void dataInsert() {
        stockRepository.saveAndFlush(new Stock(1L, 100L));
    }

    @AfterEach
    public void dataDelete() {
        stockRepository.deleteAll();
    }


    @Test
    void 재고감소() throws Exception {
        stockService.decrease(1L, 1L);

        Stock stock = stockRepository.findById(1L).orElseThrow();

        assertThat(99L).isEqualTo(stock.getQuantity());
    }


    @Test
    void 동시에_100개요청_재고감소() throws Exception {
        //given
        int threadCount = 100;
        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();

        Stock stock = stockRepository.findById(1L).orElseThrow();
        assertThat(stock.getQuantity()).isZero();
    }

}


위 테스트 코드 중 동시에_100개요청_재고감소 테스트 실행 결과입니다.

stockestfail.png

테스트 실패 ..!

아래 여러 예제를 통해 동시성 이슈를 해결해 보겠습니다.



✅ 동서싱 이슈 해결

📌 Java - synchronized

1
2
3
4
5
6
public synchronized void decrease(Long id, Long quantity) {
  Stock stock = stockRepository.findById(id).orElseThrow();
  stock.decrease(quantity);

  stockRepository.saveAndFlush(stock);
}
  • 메서드에 고유락을 걸어 하나의 스레드만 접근 가능하게 해준다.
  • 멀티스레드 환경에서 스레드간 데이터 동기화를 시켜주기 위해 자바에서 제공하는 키워드

문제점

  • synchronized는 하나의 프로세스 안에서만 보장이 된다
  • 서버가 1대일 때는 정상 동작하지만 서버가 2대 이상일 경우 동기화가 불가능하다.
  • @Transactional 과 동시에 사용할 수 없습니다. -> 실제 commit 나가는 시점보다 빠르게 다음 스레드가 메서드 실행하기 때문에 데이티 불일치 문제 발생


📌 Database(Mysql) - Pessimistic Lock(비관적 락)

1
2
3
4
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select s from Stock s where s.id = :id")
Stock findByIdWithPessimisticLock(Long id);
}
  • 실제 쿼리에 Lock을 걸어 정합성을 맞추는 방법
  • exclusive lock(베타적 잠금)을 걸게되면 다른 트랜잭션에서는 lock이 해제되기 전에 데이터를 가져갈 수 없음
  • lock의 범위를 최소한 해야한다.

문제점

  • 데드락이 걸릴 수 있음
  • 동시 처리 성능 저하 발생


📌 Database(Mysql) - Optimistic Lock(낙관적 락)

1
2
3
4
5
6
7
8
9
10
11
// Stock 클래스에 추가
@Version
private Long version; 

// Repository
@Lock(LockModeType.OPTIMISTIC)
@Query("select s from Stock s where s.id = :id")
Stock findByIdWithOptimisticLock(Long id);
}

  • Entity에 version 컬럼 추가 및 충돌 발생시 처리 로직을 직접 구현해주는 방식
  • 실제로 Lock을 걸지않고 version 값을 이용하여 정합성을 맞추는 방법
  • 충돌이 나지 않는다는 가정하에, 별도의 락을 잡지 않으므로 비관적 락보단 성능이 좋음

과정

  1. Server1이 version1 임을 조건절에 명시하면서 업데이트 쿼리를 날림
  2. version1 값이 업데이트 되어 version2가 됨
  3. server2가 version1로 업데이트를 시도하면 버전이 맞지 않아 실패함

문제점

  • 업데이트가 실패했을 때, 재시도 로직을 개발자가 구현해 줘야함
  • 충돌이 빈번하게 일어나면 오히려 성능이 안좋음


📌 Database(Mysql) - Named Lock

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Repository
@Query(value = "select get_lock(:key, 3000)", nativeQuery = true)
void getLock(String key);

@Query(value = "select release_lock(:key)", nativeQuery = true)
void releaseLock(String key);


// service
@Transactional
public void decrease(Long id, Long quantity) {
  try {
    lockRepository.getLock(id.toString());
    stockService.decrease(id, quantity);
  } finally {
    lockRepository.releaseLock(id.toString());
  }
}
  • 이름을 가진 metadata Lock이다.
  • 락을 획득 후, 해지될 때 까지 다른 세션은 이 락을 획득할 수 없음
  • 트랜잭션이 종료될 때 락이 자동으로 해지되기 않기 때문에, 별도로 해주해주거나 선점시간이 끝나야 해지됨
  • Mysql에서는 getLock() 락 획득, releaseLock() 락 해제 명령어 입니다.
  • Pessimistic Lock 은 time out을 구현하기 힘들지만, Named Lock은 손쉽게 명시할 수 있음

문제점

  • 실제 서비스에서는 커넥션풀이 부족해질 수 있기때문에 DataSource 분리 해야한다.


📌 Redis - Lettuce

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// 의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-data-redis'


// Repository 추가
@RequiredArgsConstructor
@Component
public class RedisRepository {

  private final RedisTemplate<String, String> redisTemplate;

  public Boolean lock(Long key) {
    return redisTemplate
      .opsForValue()
      .setIfAbsent(generateKey(key), "lock", Duration.ofMillis(3000));
  }

  public Boolean unLock(Long key) {
    return redisTemplate.delete(generateKey(key));
  }

  private String generateKey(Long key) {
    return key.toString();
  }
}


// Lock 획득 코드 추가
@RequiredArgsConstructor
@Component
public class LettuceLockStockFacade {

  private final RedisRepository redisRepository;
  private final StockService stockService;

  public void decrease(Long id, Long quantity) throws InterruptedException {
    while (!redisRepository.lock(id)) {
      Thread.sleep(100);
    }

    try {
      stockService.decrease(id, quantity);
    } finally {
      redisRepository.unLock(id);
    }
  }
}

  • Setnx 명령어를 활용하여 분산락을 구현 (Set if not Exist - key:value) -> 값이 없을때만 Set 명령어 수행
  • Setnx는 Spin Lock 방식이므로 retry 로직을 직접 작성해 줘야함
  • 구현이 간단하다.

Spin Lock 이란?

  • Lock을 획득하려는 스레드가 Lock을 획득할 수 있는지 확인하며 반복적으로 시도하는 방법
  • 동시에 많은 스레드가 lock 획득 대기 상태라면 redis에 부하가 갈 수 있음.



📌 Redis - Redisson

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 의존성 추가
implementation 'org.redisson:redisson-spring-boot-starter:3.23.2'

// 락 획득 코드 추가
@RequiredArgsConstructor
@Component
public class RedissonLockStockFacade {

  private final RedissonClient redissonClient;
  private final StockService stockService;

  public void decrease(Long id, Long quantity) {
    RLock lock = redissonClient.getLock(id.toString());

    try {
      // 락 획득 시도 시간, 락 점유 시간
      boolean available = lock.tryLock(10, 1, TimeUnit.SECONDS);

      if (!available) {
        System.out.println("lock 획득 실패");
        return;
      }
      stockService.decrease(id ,quantity);
    } catch (InterruptedException e) {
      throw new RuntimeException(e);
    } finally {
      lock.unlock();
    }
  }
}
  • Pub/sub 방식으로 lock을 획득
  • 락 획득 재시도를 기본으로 제공해준다.
  • lettuce와 비교했을 때 redis에 부하가 덜 간다.



마치며

되게 저렴한 강의였는데 생각보다 알찼던 것 같다.
결국 동시성 이슈 발생시 해결을 위해 어느 곳에서 락을 걸지 결정해야 하는데,
여러가지 시나리오 생각 후 결정하는 것이 best choice 인 것 같다.

This post is written by PRO.

Graceful Shutdown 동작 과정

MySQL 실수로 날린 데이터 복구하기