머리 아픈 동시성 해결하기

Jinkyuhan·2021년 12월 29일
1

문제 상황

처음의 구현은 멍청단순하게

때는, 첫 직장이던 지방의 한 SI에서 어떤 오픈마켓 앱 도메인은 비밀 개발의 대행을 맡아 수행할 때다.

구매자의 주문이 완료될 때, 판매자의 포인트를 증가 시킨 뒤 사전에 정의한 일정 기준을 넘으면 판매자의 등급을 높여주는 판매자 등급 시스템을 구현했다.

처음에는 정말 단순히 SELECT로 판매자의 현재 포인트를 읽어와서, 포인트에 획득 점수를 더한 값이 등급의 조건을 넘으면 UPDATE 쿼리를 추가로 날리는 식으로 만들었다.

테스트 중에 발견해서 다행;

해당 API가 여러 조건의 호출을 통해 결과 값을 비교하는 단순한 E2E테스트 진행 시에는 정상적으로 작동하다가, 스트레스 테스트를 하는 과정에서 등급이 주문 대비 너무 큰 수로 증가하는 버그를 발견했다.

등급 별로 혜택이 주어지기 때문에 이대로 배포되면 정말 큰 일 날 뻔 했다.
근데 사실 실 유저가 없었음


문제 원인

이거 왜 이래?

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가지의 고려 할 만한 해결방안이 생겼다. 감사해요 구글센세

1. DB Locking

A. 비관적 Locking

DB에 트랜잭션을 날릴 때, 트랜잭션의 격리수준(Transaction Isolation Level) 을 설정 할 수 있다. (각 DB의 SQL인터페이스로도 존재)

사용하는 라이브러리에 따라 격리수준을 설정하는 방법은 다를 수 있지만, 보통 제공되는 격리수준은 다음과 같다.

  • READ UNCOMMITTED
    • 실행되고 있는 다른 트랜잭션이 리소스를 변경시키고 커밋하기 이전이라도 값을 읽어갈 수 있음
    • Resource에 대해 shared lock이 발생하지 않아서, Rollback 될 데이터도 읽어 올 수 있으므로 데이터의 일관성이 어긋날 수 있음
    • 어떠한 lock도 걸지 않음
  • READ COMMITTED
    • 이때까지 커밋된 데이터만을 읽는 방식
    • 동일 트랜잭션 내에서 일관성을 보장하지 않는다. 한 트랜잭션에 두번의 읽기를 할 경우 값이 다르게 나올 수 있음
    • 트랜잭션동안 접근하는 행에 대해 shared lock이 걸린다.
  • REPEATABLE READ
    • 동일 트랜잭션 내에서 일관성을 보장
    • 트랜잭션 동안 다른 트랜잭션은 해당 행에 대해 UPDATE가 불가능
    • 읽기 시 snapshot을 만들어 놓고 한 트랜잭션 내에서는 커밋하기전까지 그 snapshot 만을 읽는다.
    • 트랜잭션 동안 접근하는 행에 대해 shared lock 이 발생한다.
  • SERIALIZABLE
    • 트랜잭션 동안 다른 트랜잭션은 해당 테이블에 INSERT or UPDATE 가 불가능
    • 리소스가 속한 테이블 전체에 shared lock을 건다.

내가 부딪힌 문제를 해결하기 위해서는,

프로젝트에 사용중인 Postgres의 기본값으로 설정된READ COMMITTEDREPEATABLE READ 이상으로 변경하여 처음의 트랜잭션을 커밋하기 전이면 두번째 트랜잭션의 UPDATE를 실패 하도록 하는 방법이 있을 수 있다.
(MySQL은 기본값이REPEATABLE READ 라고 한다.)

B. 낙관적 Lock

SELECT FOR UPDATE 를 통해 명시적인 exclusive
lock을 걸어서
두번째 트랜잭션이 아예 값을 읽어가지 못하고 Fail하도록 하는 방법을 고려할 수 있습니다.

하지만 READ UNCOMMITTED를 사용하는 것은 포인트라는 중요한 데이터의 정합성을 깨뜨릴 수 잇어서 절대 사용할 수 없었고, SELECT FOR UPDATE 트랜잭션 중 block이 걸리기 때문에
동시 요청이 많아 질 경우 Long Transaction으로 응답이 느려질 가능성이 있어 꺼려졌습니다.

( 수정중 )

2. 현재 Cache로 사용중인 Redis를 이용하여 큐잉

 그 다음으로 고려된 것은 요청의 앞에 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의 싱글스레드가 어떻게 여러 요청의 동시성을 지원하는지를 공부하는 계기가 되었습니다.

profile
신뢰를 주는 실력과 철학을 갖고 싶은 개발자입니다.

4개의 댓글

comment-user-thumbnail
2021년 12월 29일

안녕하세요, 지나가다 글 보고 댓글 남깁니당
비동기 큐잉을 한다면.. 쌓여있던 큐 중 일부가 어떠한 문제로 exception이 발생해 주문 트랜잭션이 실패 했을때는 어떻게 전달할 수 있을까요?

1개의 답글
comment-user-thumbnail
2023년 7월 13일

낙관적 locking 하위에 소개된 SELECT FOR ... UPDATE의 경우 실제로는 비관적 locking에 해당되는걸로 알고 있습니다 ~

답글 달기
comment-user-thumbnail
2023년 7월 13일

낙관적 locking 하위에 소개된 SELECT FOR ... UPDATE의 경우 실제로는 비관적 locking에 해당되는걸로 알고 있습니다 ~

답글 달기