Skip to content

같은 주식 주문이 동시에 여러 번 체결되는 문제

sieun edited this page Dec 5, 2024 · 3 revisions

💣 문제 상황

  • 삼성 전자의 주식을 10주 매수 요청하면 체결이 30주가 되는 이상한 현상이 일어남.
    • 로컬 환경에서는 해당 현상이 일어나지 않고, 서버에 배포를 했을 경우에만 일어나는 현상이라는 사실을 인지함.
    • 서버 로그를 확인해 본 결과, 3개의 컨테이너에서 같은 주문이 중복으로 처리된다는 사실을 알게 됨.
    • 원인을 파악해 본 결과, 주문 및 체결 시 아래의 흐름도와 같은 양상을 보임
sequenceDiagram

participant C as Client
participant S1 as Server 1 (구독중)
participant S2 as Server 2 (구독중)
participant S3 as Server 3 (구독중)
participant DB as Database
participant KI as Korea Investment OpenAPI

note over C: 10주 주문

C->>S1: 10주 주문 등록
par 
    KI-->>S1: 실시간 체결가 반환
    S1->>DB: 10주 매수 주문 체결
    KI-->>S2: 실시간 체결가 반환
    S2->>DB: 10주 매수 주문 체결
    KI-->>S3: 실시간 체결가 반환
    S3->>DB: 10주 주문 체결
end

note over DB: 30주 체결
Loading

✨ 해결 과정

  • 컨테이너 3개에서 동시에 체결가가 들어와 일어나는 상황이라는 것을 인지하고, 처음에는 기존 트랜잭션 격리 수준을 높여 관리하려고 함.
  • 기존에는 mysql의 기본 트랜잭션 격리 수준인 REPEATABLE_READ 을 사용하고 있었으나, 하나의 체결이 진행 중일 때에는 다른 체결이 진행되어서는 안된다고 생각해 아래와 같이 SERIALIZABLE 격리 수준으로 변경함.
async checkExecutableBuyOrder(stockCode, value) {
    const queryRunner = this.dataSource.createQueryRunner();
    await queryRunner.startTransaction('SERIALIZABLE');

    try {
      const buyOrders = await queryRunner.manager.find(Order, {
        where: {
          stock_code: stockCode,
          trade_type: TradeType.BUY,
          status: StatusType.PENDING,
          price: MoreThanOrEqual(value),
        },
      });

      await Promise.all(
        buyOrders.map((buyOrder) => this.executeBuy(queryRunner, buyOrder)),
      );

      await queryRunner.commitTransaction();
      return buyOrders.length;
    } catch (err) {
      await queryRunner.rollbackTransaction();
      throw new InternalServerErrorException(err);
    } finally {
      await queryRunner.release();
    }
  }
  • 격리 수준을 변경하니 체결이 중복되어 일어나는 현상은 사라졌으나, 체결 속도가 눈에 띄게 느려진 사실을 확인할 수 있었음.
    • 주식 거래는 실시간성이 매우 중요한 요소이기 때문에, 성능 측면에서 보아도 SERIALIZABLE 격리 수준은 적합하지 않다고 생각함.
  • 성능을 조금 더 향상시키기 위해서, SERIALIZABLE로 변경했던 격리 수준을 다시 REPEATABLE_READ로 변경함. 대신 동시성을 처리하기 위해 DB에서 체결 가능한 주문을 찾는 find문에 아래와 같이 lock을 걸기로 결정함.
    • lock에도 종류가 많아 고민해본 결과, 어떤 체결 가능한 주문이 체결 중일 때에는 다른 트랜잭션에서 읽기, 쓰기 모두 불가능해야 동시에 여러 체결이 진행되어도 독립적일 수 있을 것이라 생각해 pessimistic_write lock을 걸기로 결정함.
async checkExecutableBuyOrder(stockCode, value) {
    const queryRunner = this.dataSource.createQueryRunner();
    await queryRunner.startTransaction();

    try {
      const buyOrders = await queryRunner.manager.find(Order, {
        where: {
          stock_code: stockCode,
          trade_type: TradeType.BUY,
          status: StatusType.PENDING,
          price: MoreThanOrEqual(value),
        },
        lock: {
          mode: 'pessimistic_write',
        },
      });

      await Promise.all(
        buyOrders.map((buyOrder) => this.executeBuy(queryRunner, buyOrder)),
      );

      await queryRunner.commitTransaction();
      return buyOrders.length;
    } catch (err) {
      await queryRunner.rollbackTransaction();
      throw new InternalServerErrorException(err);
    } finally {
      await queryRunner.release();
    }
  }
  • 해당 방식으로 변경하니 컨테이너 3개를 동시에 띄워도 각 주문이 독립적으로 체결되며, 성능에 있어서도 큰 차이가 없었음.

👓 참고 자료

[DB] 낙관적 락(Optimistic Lock), 비관적 락(Pessimistic Lock)

[MySQL] 트랜잭션의 격리 수준(Isolation Level)에 대해 쉽고 완벽하게 이해하기

📜 개발 일지

⚠️ 트러블 슈팅

❗ 규칙

🗒️ 기록

기획
회의록
데일리스크럼
그룹 멘토링
그룹 회고

😲 개별 멘토링

고동우
김진
서산
이시은
박진명
Clone this wiki locally