때는, 첫 직장이던 지방의 한 SI에서 어떤 오픈마켓 앱 도메인은 비밀 개발의 대행을 맡아 수행할 때다.
구매자의 주문이 완료될 때, 판매자의 포인트를 증가 시킨 뒤 사전에 정의한 일정 기준을 넘으면 판매자의 등급을 높여주는 판매자 등급 시스템을 구현했다.
처음에는 정말 단순히 SELECT
로 판매자의 현재 포인트를 읽어와서, 포인트에 획득 점수를 더한 값이 등급의 조건을 넘으면 UPDATE
쿼리를 추가로 날리는 식으로 만들었다.
해당 API가 여러 조건의 호출을 통해 결과 값을 비교하는 단순한 E2E테스트 진행 시에는 정상적으로 작동하다가, 스트레스 테스트를 하는 과정에서 등급이 주문 대비 너무 큰 수로 증가하는 버그를 발견했다.
등급 별로 혜택이 주어지기 때문에 이대로 배포되면 정말 큰 일 날 뻔 했다.
근데 사실 실 유저가 없었음
E2E에서는 잘 됐는데, 스트레스 테스트에서는 왜 비정상적으로 작동한 걸까?
문제는 각 트랜잭션이 포인트를 증가 여부를 판단하기 위해 리소스를 읽어갈 때 앞선 트랜잭션이 아직 커밋하지 않아서 다음 트랜잭션이 변경 이전의 값을 읽어가는 것이 원인이었다. 나중에 알아보니 무척이나 널리알려진 전형적인 문제였다.
의도한 바로는 주문을 완료한 순서대로 포인트가 누산 되어야 하지만, 각 요청이 비동기적으로 처리되며 동시에 읽어간 값을 기준으로 DB를 업데이트 시켰던 것이다.
1. 요청 A가 현재 포인트 n 읽어감.
2. 요청 A가 끝나기전에 요청 B가 들어옴, 현재 포인트 n 읽어감.
3. 요청 A가 DB를 n + a 로 업데이트, 등급 업 조건 충족하여 등급 업.
4. 요청 B가 DB를 n + a 로 업데이트, 등급 업 조건 충족하여 등급 업.
- 기대결과 : 포인트 n + 2a 달성, 등급 1 up
- 실제 : n + a, 등급 2 up,
결과 값이 매 케이스 마다 다른 형태로 나타나는 것으로 보아 동시성과 관련된 문제임을 의심했고 실행되는 SQL을 확인해서 정확한 원인을 밝혀냈다.
이 문제는 이러한 한 리소스에 여러요청이 동시성을 갖고 접근할 때, 순서를 어떻게 보장해야 하는가?
라는 고민으로 이어졌다.
고민과 공부 끝에 2가지의 고려 할 만한 해결방안이 생겼다. 감사해요 구글센세
A. 비관적 Locking
DB에 트랜잭션을 날릴 때, 트랜잭션의 격리수준(Transaction Isolation Level)
을 설정 할 수 있다. (각 DB의 SQL인터페이스로도 존재)
사용하는 라이브러리에 따라 격리수준을 설정하는 방법은 다를 수 있지만, 보통 제공되는 격리수준은 다음과 같다.
READ UNCOMMITTED
READ COMMITTED
REPEATABLE READ
UPDATE
가 불가능SERIALIZABLE
INSERT
or UPDATE
가 불가능프로젝트에 사용중인 Postgres의 기본값으로 설정된READ COMMITTED
를 REPEATABLE READ
이상으로 변경하여 처음의 트랜잭션을 커밋하기 전이면 두번째 트랜잭션의 UPDATE
를 실패 하도록 하는 방법이 있을 수 있다.
(MySQL은 기본값이REPEATABLE READ
라고 한다.)
B. 낙관적 Lock
SELECT FOR UPDATE 를 통해 명시적인 exclusive
lock을 걸어서 두번째 트랜잭션이 아예 값을 읽어가지 못하고 Fail하도록 하는 방법을 고려할 수 있습니다.
하지만 READ UNCOMMITTED를 사용하는 것은 포인트라는 중요한 데이터의 정합성을 깨뜨릴 수 잇어서 절대 사용할 수 없었고, SELECT FOR UPDATE 트랜잭션 중 block이 걸리기 때문에
동시 요청이 많아 질 경우 Long Transaction으로 응답이 느려질 가능성이 있어 꺼려졌습니다.
( 수정중 )
그 다음으로 고려된 것은 요청의 앞에 Queue를 두는 것입니다. 요청이 들어오면 '주문완료' 메시지를 하나씩 Queue에 집어넣고 순서대로 하나씩 꺼내서 처리를 하는 것입니다.
요청이 들어올 때는 Queue에 넣기만 하고 응답을 주기 때문에 응답이 지연되지 않습니다. 또한 Queue의 특성상 하나씩 선입선출을 보장하기 때문에 한개의 메시지 큐를 두면 동시에 들어오는 많은 요청을 순서를 보장하며 처리할 수 있습니다.
Redis의 List 자료구조를 사용하여 이벤트 메시지 큐를 구현하고, blocking pop 인터페이스를 이용한 무한 루프로 consumer를 구현하여 직렬로 처리하니 들어온 많은 요청에 대한 순서를 보장할 수 있었습니다.
// 당시 작성했던 NodeJS Consumer 코드.
function getNewProducer(queueName) {
// 새로운 이벤트 메시지를 큐에 넣는 함수를 생성
const redisClient = require('./redis-wrapper')(`PUB_${queueName}`);
return async (eventMessage) =>
await redisClient.lpushAsync(queueName, JSON.stringify(eventMessage));
}
async function startEventListening(client, queueName, cb) {
// blocking pop 으로 새로운 이벤트가 있을 때까지 block 상태로 기다림
client.brpop(queueName, 10000, async (err, poppedMessage) => {
if (err) {
console.error(err);
}
if (poppedMessage != null) {
const [_, eventMessageInString] = poppedMessage;
const eventMessage = JSON.parse(eventMessageInString);
if (eventMessage != null) {
if (cb.constructor.name === 'AsyncFunction') {
await cb(eventMessage);
} else {
cb(eventMessage);
}
}
}
// 재귀함수로 무한 루프
startEventListening(client, queueName, cb);
});
}
function addNewConsumer(queueName, cb) {
// 해당 이벤트가 있을 때마다 콜백 함수 실행하도록 consumer 등록
const redisClient = require('./redis-wrapper')(`SUB_${queueName}`, () => {
startEventListening(redisClient, queueName, cb);
});
}
서버는 늘 N개의 요청을 상대해야 합니다. 그리고 각 요청이 비동기적으로 돈다는 것은 굉장히 중요하며, 이러한 사실을 완벽이 이해하고 염두에 두어야만 최적화된 프로그래밍을 할 수 있습니다.
서버를 구성하는 환경마다 이 동시성을 구현한 방법은 많이 다르겠지만, 이를 통해 이후에 같은 실수를 하지 않을 수 있게 되었습니다.
또한 개념적으로만 알고있었던, Java는 1요청당 1스레드를 만든다는 말을 비로소 체감 할 수 있었고, Node의 싱글스레드가 어떻게 여러 요청의 동시성을 지원하는지를 공부하는 계기가 되었습니다.
안녕하세요, 지나가다 글 보고 댓글 남깁니당
비동기 큐잉을 한다면.. 쌓여있던 큐 중 일부가 어떠한 문제로 exception이 발생해 주문 트랜잭션이 실패 했을때는 어떻게 전달할 수 있을까요?