[#9] 같은 주문에 2명의 라이더가 동시에 배달하는 문제 해결 - Redis Transaction을 이용하여 데이터 atomic 보장
제가 진행하는 make-delivery 배달 앱 서버 프로젝트는 라이더들이 배차가 되지 않은 주문 목록 중에서 배차 요청을 하여 배달할 주문을 선택하는 로직이 있습니다. 하지만 만약 동시에 라이더들이 같은 주문에 배차 요청을 한다면 동시에 두 라이더가 배달을 하게 되는 것으로 나오며 주문 테이블에 라이더 정보는 더 늦게 배차 요청을 한 라이더가 입력되는 문제가 발생할 것이라 판단했습니다. 이는 데이터의 atomic이 보장되지 않는 문제이며 Race Condition과 비슷한 문제라고 판단했습니다.
기본적으로 이 문제가 발생한 원인을 설명하기 위해 간단한 예제를 설명하겠습니다.
public int getNext() {
return value++;
}
얼핏 보기에 value++; 의 연산은 단 한번으로 이루어지는 것으로 보입니다. 하지만 이 연산은
- 값을 읽고
- 읽은 값에 1을 더하고
- 그 결과를 다시 기록하는
이렇게 3가지 연산으로 구성되어 있습니다.
멀티 스레드 어플리케이션에서 getNext()함수를 두 스레드에서 아주 적은 시간차이로 실행을 한다면 A와 B 모두 value를 9로 읽어오고 그다음 각각 연산에서 1을 더하고 그다음 저장할 때도 10을 저장하게 됩니다. value++;를 두번 실행하면 9->11이 되어야 하는데 이 데이터값이 겹쳐지게 된 것입니다. 자바에서는 이러한 동시성 문제를 해결하기 위해 Synchronized등 여러 방법을 사용하여 한 스레드에서 작업이 끝난 후 다른 스레드의 작업이 가능하게끔 문제를 해결합니다. (물론 이러한 동시성 이슈를 아예 발생시키지 않게 하기 위해 Spring에선 싱글톤 방식으로 빈들이 공유되더라도 멤버 변수를 사용하지 않아서 동시성 문제를 애초에 차단합니다)
하지만 제 프로젝트에서 라이더, 배차 대기중인 주문 목록은 실시간으로 빠른 응답을 제공하기 위해 레디스에서 관리하고 있습니다. 레디스는 싱글스레드와 이벤트 루프 기반의 비동기방식으로 작동합니다. 따라서 한번에 하나의 요청을 처리하는 방식으로 구성되어있어 단일 연산이라면 동시성 문제가 발생하지 않습니다. 즉 자바에서는 멀티스레드 방식이기 때문에 여러 명의 손님에 대처하기 위해 여러 잔의 음료를 점원이 동시에 만들지만 레디스에서는 한명의 점원이 여러 명의 손님을 한명씩 처리하는 방식이라고 볼 수 있습니다.
이러한 레디스의 동작방식에도 불구하고 데이터의 atomic이 보장되지 않은 이유는 라이더들이 주문 요청을 하는 연산이 단일 연산이 아니기 때문입니다. 즉 라이더들이 배차요청을 할 때 주문에 신청만 하는 것이 아니라 다음과 같은 로직으로 수행됩니다.
1. 라이더들이 주문에 배차요청을 하고
2. 배차를 기다리는 주문 목록에서 해당 주문을 삭제해준다.
왜냐면 라이더 A가 먼저 1번을 수행하고 그다음 라이더 B가 2번을 같은 주문에 수행하고 그 다음 라이더A가 2번을 수행하고 그 다음 라이더 B가 2번을 수행한다면 위의 예제인 value++;에서 발생한 문제와 똑같은 문제가 발생하기 때문입니다.
이러한 문제를 해결하기 위해 Redis Transaction을 이용하여
1. 라이더들이 주문에 배차요청을하고
2. 배차를 기다리는 주문 목록에서 해당 주문을 삭제해준다.
이 두 연산을 1번에 수행되도록 만들어야 하는 것입니다. (Mysql의 Transaction과 개념적으로는 동일합니다) 따라서 이 두 연산을 1번에 수행되도록 만든다면 아까처럼 라이더 A가 1번을 수행하고 2번을 수행하기전 라이더 B가 중간에 끼어든다면 라이더 B의 요청은 discard(취소)됩니다. (Mysql등 RDBMS에서는 lock이 걸립니다. 레디스와 mysql이 이러한 차이를 보이는 이유는 낙관적 잠금을 사용하느냐 비관적 잠금을 사용하느냐의 차이입니다)
따라서 위에서 설명한대로 Redis Transaction을 이용하여 두 연산을 한개의 연산으로 만들어 다른 요청에서 끼어들 수 없게 atomic하게 만든다면 같은 주문에 2명이상의 라이더가 배당되는 문제는 발생하지 않을 것입니다.
watch는 레디스 트랜잭션에서 어떠한 레디스 key에 변경이 있는지를 감지하기 위한 명령어이고 watch하고 있는 key가 변경된다면 해당 트랜잭션이 아닌 다른 트랜잭션을 discard시키는 방식입니다. multi()로 트랜잭션을 시작시키고 명령어들을 수행한 뒤 exec()로 한번의 연산을 수행하는 것입니다.
이렇게 레디스 트랜잭션을 이용하여 레디스에 데이터를 추가할 때 여러 연산을 한번의 연산으로 바꾸어 데이터 atomic을 보장하는 방법을 알아보고 라이더들의 배차요청 문제를 해결하였습니다.
프로젝트
github.com/f-lab-edu/make-delivery
참고자료
redis.io
자바 병렬프로그래밍 책 (조슈아 블로쉬)