logo
Published on

동시성 제어는 어떻게 해야할까?

Authors
  • Name
    Twitter

왜 동시성 제어가 중요할까?

백엔드 개발자로 일을 하다보면 언젠가 맞닥드리게 되는게 있다. 바로 동시성 문제이다. 동시에 많은 요청이 몰리는 시스템에서는 경쟁 조건(Race condition)으로 인해 데이터의 정합성이 깨지거나 중복 처리가 발생할 수 있다. 흔히 발생하는 동시성 문제의 대표적인 사례는 다음과 같다.

  • 선착순 예약 서비스에서 정원을 초과해 예약이 들어가는 상황
  • 재고 관리 시스템에서 남은 재고보다 더 많은 상품이 판매되는 상황
  • 실시간 티켓 판매 서비스에서 같은 좌석이 중복 예매되는 상황

이러한 상황에서 발생하는 문제들은 서비스의 신뢰성을 크게 떨어뜨린다. 실제 상황이라면 생각만 해도 식은땀이 날것 같다.. 그렇다면 이런 동시성 문제를 어떤 방법으로 제어할 수 있을까?

실제 상황 재현하기: 선착순 예약 서비스

먼저 간단한 선착순 예약 서비스를 예로 들어 동시성 문제를 재현해보자.

  • event: 예약 가능한 이벤트 (예: 콘서트, 공연)
  • reservation: 사용자가 예약한 이벤트 내역

사용자는 이벤트에 대해 티켓을 예약할 수 있고, 정원이 다 찼으면 예약이 불가능해야 한다. 그런데 여러 사용자가 동시에 예약 요청을 보내면 어떻게 될까? 동시성 문제가 발생할 수 있다.

테스트 환경 구성

먼저 동시성 문제를 재현하기 위해 간단한 API와 트랜잭션 코드를 작성했다.
그 중 일부를 가져왔다.

/**
 * 예약 요청 - 기본적인 트랜잭션 적용
 */
async reserveEventWithBasicTransaction(eventId: number, userId: number) {
  const event = await this.eventService.getEventInfo(eventId);
  if (!event) {
    throw new NotFoundException('존재하지 않는 이벤트입니다.');
  }

  return await this.prisma.$transaction(async (tx) => {
    const currentReservationCount = await tx.reservation.count({
      where: { eventId },
    });

    if (currentReservationCount >= event.maxTickets) {
      throw new BadRequestException('매진되었습니다.');
    }

    await tx.reservation.create({
      data: {
        eventId,
        userId,
        reservedAt: new Date(),
        status: 'RESERVED',
      },
    });

    return {
      success: true,
      message: '예약이 완료되었습니다.',
    };
  });
}

코드만 보면 문제 없어 보이지만 실제로는 문제가 발생한다.

k6로 동시성 문제 재현하기

실제로 동시성 문제를 재현하기 위해 부하 테스트 툴 k6를 사용해 테스트를 진행했다.
작성한 테스트 스크립트는 다음과 같다.

import http from 'k6/http'
import { check } from 'k6'
import { Counter, Rate } from 'k6/metrics'

const successfulBookings = new Counter('successful_bookings')
const failedBookings = new Counter('failed_bookings')
const errorRate = new Rate('error_rate')

// 테스트 설정
export const options = {
  scenarios: {
    concurrency_test: {
      executor: 'shared-iterations',
      vus: 150, // 150명이 동시에
      iterations: 150, // 총 150개의 요청
      maxDuration: '1s',
    },
  },
}

const EVENT_ID = 1 // 이벤트 ID (maxTickets: 100)
const BASE_URL = 'http://localhost:3001'

export default function () {
  const userId = Math.floor(Math.random() * 10000) + 1
  const url = `${BASE_URL}/reservation/${EVENT_ID}/${userId}`

  const response = http.post(url)

  // 응답 확인
  const isSuccess = response.status === 200 || response.status === 201

  check(response, {
    'reservation successful': () => isSuccess,
  })

  if (isSuccess) {
    successfulBookings.add(1)
  } else if (response.status === 400) {
    failedBookings.add(1)
  } else {
    errorRate.add(1)
  }
}

interface SummaryData {
  metrics?: {
    successful_bookings?: { values?: { count?: number } }
    failed_bookings?: { values?: { count?: number } }
    [key: string]: any
  }
  [key: string]: any
}

export function handleSummary(data: SummaryData) {
  const successful = Number(data.metrics?.successful_bookings?.values?.count) || 0
  const failed = Number(data.metrics?.failed_bookings?.values?.count) || 0
  const total = successful + failed

  console.log(`${'='.repeat(50)}`)
  console.log(`🔴 동시성 문제 발생 테스트 결과`)
  console.log(`${'='.repeat(50)}`)
  console.log(`총 요청 수: ${total}`)
  console.log(`✅ 성공한 예약: ${successful}`)
  console.log(`❌ 실패한 예약: ${failed}`)

  // 동시성 문제 감지
  if (successful > 100) {
    console.log(`예약 가능 수(100)를 초과하는 예약이 발생했습니다`)
    console.log(`초과 예약 수: ${successful - 100}`)
    console.log(`Race Condition이 발생한 것으로 보입니다.`)
  }

  console.log(`${'='.repeat(50)}`)
}

위에 작성된 테스트 스크립트의 시나리오는 다음과 같다.

  • 이벤트 설정: 최대 예약 가능 인원 100명
  • 테스트 환경: 150명의 가상 사용자가 동시에 예약 요청
  • 예상 결과: 100명만 예약 성공, 50명은 실패

하지만 실제로는 다음과 같은 결과가 나왔다.

예상과는 달리 104명 예약 성공, 46명은 실패하는 결과가 나왔다.
4명의 초과 예약이 발생한 것이다. 실제 서비스였다면 아찔했을 것이다.

왜 이런 문제가 발생했을까?

트랜잭션을 사용했음에도 문제가 발생했다. 왜일까?
트랜잭션 내부에서의 흐름은 다음과 같다.

// 트랜잭션 내부에서의 흐름
1. 현재 예약 수 조회: count = 99
2. 예약 가능 여부 확인: 99 < 100 (예약 가능)
3. 예약 생성
4. 트랜잭션 커밋

문제는 다음과 같이 여러 트랜잭션이 동시에 실행될 때 발생한다.

시간 →
User A: [count 조회: 99][확인: OK][예약 생성][커밋]
User B:      [count 조회: 99][확인: OK][예약 생성][커밋]
User C:           [count 조회: 99][확인: OK][예약 생성][커밋]
User D:               [count 조회: 99][확인: OK][예약 생성][커밋]
User E:                   [count 조회: 99][확인: OK][예약 생성][커밋]

5명의 사용자가 거의 동시에 요청을 보내면, 모두 같은 시점의 예약 수인 99를 읽게 된다. 각자 아직 자리가 있다고 판단하고 예약을 진행하게 되는데 결과적으로 여러 트랜잭션이 동시에 같은 값을 읽고 예약을 생성하여, 100명이 아닌 104명이 예약되는 상황이 발생된다.

현재 테스트에 사용한 DB는 MySQL을 사용했다. MySQL(InnoDB)의 기본 격리 수준은 REPEATABLE READ이다. 이 수준에서는 같은 row를 반복해서 읽으면 동일한 값을 보장하지만, 도중에 다른 트랜잭션이 새로운 row의 삽입하는 것(Phantom Read)을 막지 못한다. 따라서 COUNT 쿼리 실행 후 다른 트랜잭션이 새로운 예약을 INSERT 할 수 있어, 위와 같은 동시성 문제가 발생한다.

어떻게 해결할 수 있을까?

DB 레벨에서 동시성 문제를 해결하는 방법은 여러 가지가 있다.

  1. 조건부 업데이트 (Atomic Update): DB의 원자적 연산을 활용한 방법
  2. 격리 수준 상향 (SERIALIZABLE): 트랜잭션 격리 수준을 높여 동시성을 제어하는 방법
  3. 비관적 락 (Pessimistic Lock): 트랜잭션 시작 시 데이터에 락을 걸어 다른 트랜잭션의 접근을 막는 방법
  4. 낙관적 락 (Optimistic Lock): 버전 관리를 통해 충돌을 감지하는 방법

이번 글에서는 조건부 업데이트격리 수준 상향 두 가지 방법을 이용해서 동시성 문제를 해결해보려고 한다. 비관적 락과 낙관적 락은 다음 편에서 자세히 다룰 예정이다.

1. 조건부 업데이트 (원자적 UPDATE)

조건부 업데이트는 DB의 원자적(Atomic) 연산을 활용하여 동시성 문제를 해결하는 방법이다.

Event 테이블에 reservedCount 컬럼을 추가하고, UPDATE 쿼리 하나로 다음 두 가지를 동시에 처리한다.

  1. 조건 확인: reservedCount < maxTickets (예약이 가능한가?)
  2. 값 증가: reservedCount = reservedCount + 1 (예약 수 증가)

이 두 작업이 하나의 원자적 연산으로 실행되기 때문에, 여러 요청이 동시에 들어와도 Row-level lock으로 같은 레코드에 대한 UPDATE가 순차적으로 실행되어 정합성이 보장된다.

구현 코드

/**
   * 예약 요청 - 조건부 업데이트
   * 원자적 UPDATE 연산으로 동시성 제어
   */
  async reserveEventWithConditionalUpdate(eventId: number, userId: number) {
    const event = await this.eventService.getEventInfo(eventId);
    if (!event) {
      throw new NotFoundException('존재하지 않는 이벤트입니다.');
    }

    return await this.prisma.$transaction(async (tx) => {
      // 조건부 업데이트: reservedCount < maxTickets일 때만 증가 (MySQL 문법)
      const updateResult = await tx.$executeRaw`
        UPDATE \`Event\`
        SET \`reservedCount\` = \`reservedCount\` + 1
        WHERE id = ${eventId}
          AND \`reservedCount\` < \`maxTickets\`
      `;

      // 업데이트 실패 = 매진
      if (updateResult === 0) {
        throw new BadRequestException('매진되었습니다.');
      }

      // 예약 레코드 생성
      await tx.reservation.create({
        data: {
          eventId,
          userId,
          reservedAt: new Date(),
          status: 'RESERVED',
        },
      });

      return {
        success: true,
        message: '예약이 완료되었습니다.',
      };
    });
  }

테스트 결과

150명이 동시 예약을 시도했는데 정확히 100명만 예약 성공하고 나머지 50명은 실패했다. 예상했던 대로 동시성 문제가 해결된 것을 볼 수 있다.

장점

  • 구현이 간단하다: 복잡한 락 로직 없이 쿼리 하나로 해결
  • 성능이 우수하다: Row-level lock만 사용하므로 다른 행에 대한 접근은 차단되지 않음
  • 재시도 불필요하다: 실패한 요청은 명확히 매진이므로 사용자에게 바로 응답 가능
  • 데드락 위험이 낮다: 단순한 UPDATE 연산이므로 데드락 발생 가능성이 적음

한계 및 주의사항

하지만 이 방법도 실무에서는 고려해야 할 점들이 몇 가지 있다.

  • 데이터 정합성 관리 필요

    • reservedCount 컬럼과 실제 예약 레코드 수가 달라질 수 있다.
    • ex) 예약 생성 중 서버 오류 발생 시 reservedCount는 증가했지만 예약 레코드는 생성되지 않을 수 있다.
  • 복잡한 비즈니스 로직에는 부적합

    • 단순 카운터 증감에는 효과적이지만, 복잡한 조건이 필요한 경우 한계가 있다.
    • ex) A와 B 이벤트를 동시에 예약해야 함, 특정 사용자만 예약 가능, 예약 취소 시 어떻게 관리할 것인지 등
  • 스키마 변경 필요

    • 기존 테이블에 reservedCount 컬럼 추가 필요한데, 레거시 시스템에서는 부담스러울 수 있다.

2. 격리 수준 상향 (SERIALIZABLE)

트랜잭션의 격리 수준을 높여서 동시성 문제를 해결하는 방법도 있다. 이론적으로는 가능하지만, 실제로는 어떤 결과가 나올까?

SERIALIZABLE의 동작 방식

SERIALIZABLE은 가장 높은 격리 수준으로, 트랜잭션들이 완전히 순차적으로 실행되는 것처럼 동작하게 만든다.

InnoDB에서의 특성

  • 읽기 작업도 공유 잠금(읽기 잠금)을 획득해야 함
  • 다른 트랜잭션은 잠긴 레코드를 변경할 수 없음
  • 가장 엄격한 격리 수준이지만 동시 처리 성능이 가장 떨어짐

충돌 처리

  • 동시에 실행되더라도 충돌 감지 시 한쪽 트랜잭션을 롤백
  • Serialization Failure 에러 발생 시 애플리케이션에서 재시도 필요
  • 완벽한 데이터 일관성을 보장하지만 그만큼 성능 저하가 발생한다.

구현 코드

/**
   * SERIALIZABLE 격리 수준 사용
   */
  async reserveEventWithSerializable(eventId: number, userId: number) {
    const event = await this.eventService.getEventInfo(eventId);
    if (!event) {
      throw new NotFoundException('존재하지 않는 이벤트입니다.');
    }

    try {
      const result = await this.prisma.$transaction(
        async (tx) => {
          const currentReservationCount = await tx.reservation.count({
            where: { eventId },
          });

          if (currentReservationCount >= event.maxTickets) {
            throw new BadRequestException('매진되었습니다.');
          }

          await tx.reservation.create({
            data: {
              eventId,
              userId,
              reservedAt: new Date(),
              status: 'RESERVED',
            },
          });

          return {
            success: true,
            message: '예약이 완료되었습니다.',
          };
        },
        {
          isolationLevel: 'Serializable', // 격리 수준 설정
          maxWait: 5000, // 락 대기 시간
          timeout: 10000, // 트랜잭션 타임아웃
        },
      );

      return result;
    } catch (error) {
      console.error(error);
      throw new InternalServerErrorException('내부 서버 오류');
    }
  }

테스트 결과

100명이 예약 성공하기를 기대했지만 실제로는 단 9명만 성공했다.

왜 이런 일이 발생했을까?

SERIALIZABLE 격리 수준에서는 동시에 실행되는 트랜잭션들 간에 충돌이 매우 빈번하게 발생한다.

  1. 150개의 트랜잭션이 거의 동시에 시작
  2. 각 트랜잭션이 reservation 테이블을 읽고 쓰려고 시도
  3. MySQL이 이를 **직렬화 충돌(Serialization Conflict)**로 감지
  4. 쓰기 충돌로 인해 대부분의 트랜잭션이 롤백
  5. 롤백된 트랜잭션은 재시도되지 않고 실패 처리

때문에 대부분의 요청에서 아래와 같은 에러가 발생했다.

[Nest] 28538  - ERROR [ExceptionsHandler]
PrismaClientKnownRequestError:
Transaction failed due to a write conflict or a deadlock.
Please retry your transaction
code: 'P2034'

실무에서 사용하기 어려운 이유

  1. 낮은 성공률

    • 동시 요청이 많을수록 대부분 실패함
  2. 성능 저하

    • 트랜잭션 롤백으로 인한 DB 부하 증가
    • 사용자 응답 시간 지연
  3. 예측 불가능한 동작

    • 트래픽 증가 시 시스템이 거의 마비될 수 있음

결론

DB 레벨에서 동시성 문제를 해결하는 두 가지 방법을 살펴봤다.

조건부 업데이트를 통한 동시성 문제 해결은 간단하고 성능 좋음을 보장하고 격리 수준을 SERIALIZABLE로 높이는 것은 이론적으로는 동시성 제어가 가능할 것 같았지만 실무에서는 비효율적임을 알 수 있었다. 하지만 이 두 방법만으로는 모든 동시성 문제를 해결할 수 없다. 복잡한 비즈니스 로직이나 여러 테이블에 걸친 작업에 대해서는 다른 접근이 필요하다.

다음 글에서는 비관적 락(Pessimistic Lock)과 낙관적 락(Optimistic Lock)을 다뤄볼 예정이다.

  • 비관적 락: SELECT ... FOR UPDATE로 데이터를 선점하는 방식
  • 낙관적 락: 버전 관리로 충돌을 감지하는 방식

참고

  • Real MySQL 8.0