개발자의 길

[항해플러스] 미니 e-commerce 프로젝트에서 발생가능한 동시성 문제와 그 해결법

토아드 2025. 1. 19. 14:36
반응형

서론 및 개요

인터넷에서 상품을 결제하고 배송을 해주는 e-commerce 서비스는 여러 기능들을 제공한다. 내 계정에 포인트를 충전하거나, 상품을 구매하기 위해 어떤 상품을 얼마나 넣는지 등을 결정하여 결제를 진행하기도 하고, 이벤트성으로 쿠폰을 발급하거나 특별 할인 등을 진행하여 매우 값싼 상품을 일시적으로 판매하기도 한다.


이런 기능들은 대개 사람들이 몰려서 API 요청들이 많이 몰리기 마련인데, 이때 동시에 같은 작업을 진행하다 보면 데이터의 정합성이 맞지 않거나 오류가 나는 등의 '동시성 문제' 가 생길 수 있다.


항해플러스를 진행하면서 미니 e-commerce 프로젝트를 진행하고 있는데, 이번에 내가 구현한 서비스에서 제공하는 유스케이스에서 어떤 동시성 문제가 있을지 알아보고 이것들을 어떻게 해결할지 알아보기 위해 글을 쓴다.

동시성 문제가 발생 가능한 시나리오들

  1. 한 유저의 여러번의 잔액 충전 요청
    이번 프로젝트에서는 회원의 잔액(포인트) 를 충전하는 기능이 존재하는데, 발생 가능성은 낮으나 이 잔액 충전 요청시에 한 유저에 대한 충전 요청이 여러번 들어올 경우 DB 에 저장된 값을 불러오면서 값을 늘리고 다시 저장하는 과정에서 데이터가 정상적으로 처리가 안될 가능성이 있다. 예를 들어 동시에 10번의 1000원 충전 요청을 할 경우, 결과적으로는 10,000 원이 충전되어야 하지만 동시성 문제가 발생하면서 4,000 원이나 7,000 원 등 타이밍에 따라 의도치 않은 값이 충전될 수 있다.

  2. 주문서 생성 요청 시
    현재 구현한 주문 기능은 [주문서 생성 -> 주문서 결제처리] 순으로 이루어 지는데, 주문서는 여러개 생성한다고 해서 크게 문제될것은 없지만 주문서 결제처리 시에는 문제가 될 수 있다.

  3. 주문서 결제 처리 시
    상품을 결제 처리하면 1)상품의 재고가 줄어들고, 2)유저의 잔액이 차감되면서, 3)결제가 완료 처리 되는 시퀀스가 진행된다. 이 과정에서 여러가지 동시성 문제가 발생 할 수있는데, 아래와 같은 시나리오에서 발생 가능하다.
    1. 하나의 주문서에 대한 여러번의 주문서 결제 처리 요청시
      사실 비즈니스 로직 상으로 발생할 수 있는 문제는 아니다. 하지만 클라이언트에서 같은 결제 처리 API 요청을 여러번 할수 있는 취약점이 존재하고 그 상황이 발생한다면 이미 결제 완료 처리한 주문서가 한번 더 결제 처리되면서 데이터 정합성 문제가 발생할 수 있다.
      하나의 주문서에 상품은 2개 결제 되었다고 뜨지만, 2번 요청되면서 재고가 4개가 줄어들 수도 있고, 잔액도 2번 줄어들면서 상품은 2개 샀는데 잔액은 그 2배로 쓸 수도 있는 것이다.
    2. 하나의 상품에 대한 여러명의 주문서 결제 처리시
      주문서를 결제 처리하면서 상품의 재고수를 차감시키는데, 이때 동시성 문제를 고려하지 않는다면 두명의 유저가 1개씩의 상품 A의 결제를 성공했는데 상품 A의 재고수는 하나만 주는 경우가 발생할 수가 있다.
  4. 선착순 쿠폰 발행 요청 시
    가장 동시성 문제가 빈번하게 발생 가능한 비즈니스이다. 하나의 쿠폰에 대한 발급 요청을 수많은 유저가 동시에 요청하는 것은 굉장히 발생할 가능성이 높은 기능이며, 가장 트래픽이 많이 몰리는 기능이기도 하다. 이 기능에서 동시성을 제대로 제어하지 못한다면 원하는 예상보다 많은 양의 쿠폰이 발행되는 문제가 생길 수 있다.

동시성 문제를 해결하기 위한 여러 방법들

Synchronized, ReentrantLock

Java 언어 자체에서 제공되는 기능이므로 구현이 간단하며, 추가적인 인프라 설정이 필요하지 않다. 그러나 ReentrantLock은 명시적으로 Lock을 해제해야 하므로 코드 실수로 인해 Deadlock이 발생할 가능성이 있다.

단일 프로세스 환경에서 높은 성능을 보장한다. 그러나 멀티 프로세스나 분산 환경에서는 락이 한 프로세스 내에서만 동작하므로 분산 환경을 가정하는 현재 프로젝트에서는 사용하기 적합하지 않기 때문에 현재 프로젝트에서는 제외한다

Database 수준에서의 동시성 제어

낙관적 락

 동시성 문제가 거의 일어나지 않을 거라고 가정하고, 각 row 에 버전 정보를 기입해서 데이터 처리 후 버전 정보가 내가 가지고 있는것과 다른 경우에 롤백을 진행하는 방식으로 구현하는 방식이다.

 실제로 DB 의 배타락을 거는것이 아니기 때문에 성능이 좋지만, 동시성 문제가 발생할 경우 작업하던 것을 롤백시키고 다시 시도하기 때문에 동시에 많은 요청이 들어오는 경우에는 오히려 성능이 저하될 수 있다. 또 deadlock 발생 가능성을 고려하지 않아도 되고 version 컬럼을 추가해서 version 이 변경되지 않은 경우에만 업데이트 하는 방식을 쓰면 되기 때문에 구현이 간단하다.

비관적 락

 RDBMS 의 배타 락을 row 레벨로 걸어서 다른 유저가 해당 row 를 수정하려고 시도할때 block 시켜서 작업을 하지 못하도록 하는 방법이다. 이미 lock 이 걸린 상황에서 다른 유저가 로직을 실행하기 전에 대기시키도록 하기 때문에 확실한 동시성을 보장한다. 하지만 실제 배타 락을 걸기 때문에 서비스 로직에 따라 deadlock 문제가 발생할 수도 있고 DB 의 자원 사용하기 때문에 다른 트랜잭션의 지연이나 성능 문제가 발생 할 수 있다. 여러 테이블의 row 가 연관된 서비스 로직을 구현하는 경우 deadlock 을 일으키지 않도록 구현하는게 중요하기 때문에 구현 난이도가 높다.

 그리고 Database 수준에서의 동시성 제어 방법들은 어디까지나 DB 인스턴스 하나에 lock 을 거는 것이기 때문에 DB 자체가 분산되거나 하는 경우에는 해당 방법들은 쓰기가 어렵다.

Redis

 Redis 는 key-value 를 기반으로 데이터를 저장하는 인메모리 DB 로써 매우 빠른 쓰기/읽기 성능을 가진다. Redis 는 싱글 쓰레드로 동작하기 때문에 여러 요청을 동시에 처리할수 없다는 점을 이용해서, 분산되어있는 여러 서비스/인프라에서 하나의 lock 을 이용하고 관리할수 있는 분산락을 구현할 수 있다.  key 가 존재하지 않는 경우에만 key-value 를 세탕하는 SETNX 커맨드를 이용하면 간단하게 Redis 의 분산 락을 구현할 수 있다. 구현 방법은 Simple lock, Spin lock, Pub/Sub Lcok 등이 있다. 

 다만 Redis 서버 하나의 장애로 인해 단일 장애점으로 관련 서비스 로직이 아예 동작하지 않을 수 있는 점에 주의가 필요하다. 이를 해결하기 위해서는 클러스터를 구성해야 하는데, 이 경우에는 Redlock 과 같은 알고리즘이 필요하게 된다.

Kafka(Message Queue)

 메시지 큐는 같은 key 값을 가진 job 에 대해서는 모든 요청을 '순차적으로' 처리하게 되는데, 이 방식을 이용해서 동시성을 제어할 수 있다. 쿠폰 발급을 예로 들자면 쿠폰 발급 요청이 한번에 100개가 온다고 해도, 메시지 큐에 쌓은 다음 워커에서 하나씩 꺼내서 처리하게 되므로 동시성을 보장할 수 있다.

 하지만 메시지 큐를 이용하면 비동기 처리를 하게 되기 때문에 처리 결과를 유저에서 바로 확인할수 없다. 이러한 특징 때문에 실시간성이 필요한 경우에는 적합하지 않다. 또 메시지 프로듀서, 컨슈머를 설정하고 메시지를 처리하는 워커쪽으로 로직 분리가 필요하기에 구현 난이도는 높은 편이다.

 

 

그래서, 동시성 시나리오를 해결하기 위한 방법들은?

  1. 한 유저의 여러번의 잔액 충전 요청
    한 유저가 여러번의 잔액 충전을 요청할 일은 발생할 가능성이 적기 때문에, 낙관적 락을 이용하여 동시성을 보장한다.

  2. 주문서 생성 요청 시
    주문서 생성 요청은 값을 바꾸거나 하는 로직은 없고, 데이터를 읽어서 새로운 주문서를 생성만 하기 때문에 동시성 처리는 굳이 필요없다.

  3. 주문서 결제 처리 시
    아래 경우는 여러 table 의 row 들이 유동적으로 관리되고, 하나의 상품의 재고를 줄이는 상황은 자주 일어 날 수 있어 충돌이 잦지만, 한번에 트래픽이 매우 많이 몰리진 않을 것으로 예상된다. 따라서 각각의 row 에 락을 거는 배타락을 이용하여 구현한다. 
    • 하나의 주문서에 대한 여러번의 주문서 결제 처리 요청시
    • 하나의 상품에 대한 여러명의 주문서 결제 처리시
  4. 선착순 쿠폰 발행 요청 시
     한번에 트래픽이 매우 많이 몰리는 경우이기 때문에 성능이 중요하고, DB 에 너무 많은 부하가 몰리면 다른 서비스에까지 영향이 갈 수 있기 때문에 redis 의 분산 락을 이용한다. 유저의 사용성을 높이기 위해서는 MQ 를 사용하는게 더 적절해 보이지만 (쿠폰 발급 요청을 하고 응답을 기다려야 하기 때문) 추후에는 MQ 를 이용하기도하기도 하고, 차주에 redis 를 이용하여 구현하는 과제가 있다고 하니 redis 를 사용해 보기로 한다.
반응형