트랜잭션과 동시성은 다르다

2025-11-29

@khkim6040

database
transaction
concurrency

카풀 서비스 Paxi는 카풀 방을 만들고 유저들이 원하는 방에 들어오게 해 준다. 유저가 방에 들어오면 DB의 여러 부분에 업데이트 및 삽입이 일어나서 이들을 어플리케이션 단에서 트랜잭션 하나로 묶어놓았다.

  async joinRoom(
    uuid: string,
    userUuid: string,
  ): Promise<{ sendMessage: boolean; room: RoomWithUsersDto }> {
    const room = await this.findOne(uuid);
 
    // 문제 상황: 정원 4명 중 3명이 찬 상태에서
    // 두 명이 동시에 입장 요청하면?
    if (!roomUser && room.currentParticipant == room.maxParticipant) {
      throw new BadRequestException('정원이 가득 찼습니다.');
    }
 
      try {
        // 로직 생략
        await queryRunner.commitTransaction();
      } catch (err) {
        await queryRunner.rollbackTransaction();
        throw err;
      } finally {
        await queryRunner.release();
      }
    }
 
    return {
      sendMessage,
      room: await this.findOneWithRoomUsers(uuid),
    };
  }

트랜잭션에 묶인 명령 중 하나라도 실패하면 이미 삽입 성공된 명령도 롤백되어 모두 성공하거나 실패하는 원자성(atomicity)이 보장된다.

위와 같은 트랜잭션 코드가 동시성도 보장해준다고 잘못 생각했었다.

예를 들면, 정원이 4명인 방에 이미 3명이 있는 상황에서 유저 두 명이 동시에 입장을 한 상황을 고려해준다고 생각했던 것이다.
프론트엔드에서 정원이 찼을 때 요청을 막아두었지만 정말 동시에 요청이 불가능한 것은 아니다.
만약 요청이 두 개 동시에 들어왔다면 백엔드에서 현재 코드로 적절하게 처리가 가능할까?

가능한 시나리오를 생각해보자.

  1. 둘 중 하나만 성공시키고 나머지는 실패 처리 (이상적)
  2. 요청 둘 다 실패 처리 (안전하지만 비효율적)
  3. 둘 다 성공하여 정원 초과 (최악)
  4. 둘 다 DB에 삽입되지만, 나중에 커밋된 트랜잭션의 수정사항이 앞선 수정사항을 덮어씀 (최악2)

트랜잭션을 걸어놨으면 저런 상황도 처리 가능하다고 생각했었는데 전혀 아니다.
트랜잭션은 쿼리의 원자성을 보장하지, 동시성을 보장하지 않는다. 더 정확히 말하면, 트랜잭션은 여러 쿼리를 하나의 논리적 단위로 묶어 트랜잭션 단위의 원자성을 보장하지만, 동시에 실행되는 여러 트랜잭션 간의 동시성 제어는 하지 않는다.

동시성을 보장하려면 DB 수준에서 테이블이나 필드에 락을 걸어야 한다.
그렇게 되면 한 쿼리가 테이블이나 필드에 작업을 할 때 락을 점유하게 되어 같은 데이터에 작업하는 쿼리는 동시에 실행되지 못하고 실행되고 있는 쿼리가 끝날 때까지 기다려야 한다.

락도 동시에 취득한다면? 그렇게 되면 또 동시성 문제가 생길 수 있다.
걱정과 다르게 락은 마음 편하게 사용할 수 있다. 하드웨어 수준에서 동시성이 발생하지 않도록 구현되어 있기 때문이다. 물론 락을 걸어도 두 트랜잭션이 서로가 가진 락을 기다리게 되는 교착 상태(deadlock) 가 발생할 수 있다. 이때는 DB가 자동으로 한쪽 트랜잭션을 강제 롤백시킨다.

예약, 입장 등 공간, 수량이 한정된 부분의 로직을 짤 때는 데이터 삽입의 일관성뿐만 아니라 여러명이 동시에 요청했을 때 어떻게 핸들링해야 하는지 동시성 문제도 염두해 두어야 한다.

앞선 최악의 4번 상황은 DB 동시성 문제 중 Lost Update(갱신 분실) 로 불린다. 두 트랜잭션이 같은 데이터를 읽고 각각 수정한 뒤 커밋하면, 나중에 커밋된 트랜잭션이 먼저 커밋된 결과를 덮어쓴다.

DB 쪽도 동시성 문제를 당연히 알고 있고, 읽기 수준과 쓰기 수준에서 제공하는 서비스들이 있다. DBMS는 동시성 문제를 제어하기 위해 트랜잭션 격리 수준(isolation level) 을 제공한다. 이는 읽기 및 쓰기 시점에 얼마나 엄격하게 다른 트랜잭션의 변경을 차단할지를 결정한다. MySQL, Postgres 등 DBMS마다 기본 정책이 조금씩 다르다.

격리 수준방지하는 문제설명
READ UNCOMMITTED없음Dirty Read 허용, 가장 느슨함
READ COMMITTEDDirty Read커밋된 데이터만 읽기, 대부분의 DB 기본값
REPEATABLE READDirty Read, Non-repeatable Read트랜잭션 내 일관된 읽기, MySQL 기본값
SERIALIZABLEDirty Read, Non-repeatable Read, Phantom Read완전한 격리, 느리지만 가장 안전

실제로 이 문제를 해결하려면 findOne 시점에 FOR UPDATE 락을 걸거나, currentParticipant 업데이트 시 낙관적 락(Optimistic Lock)을 사용하는 등의 동시성 제어 방법이 필요하다.

다음에는 이 내용을 바탕으로 코드를 수정하고 실제로 어떻게 문제를 해결했는지, 동시성 관련 테스트 코드는 어떻게 작성하는지 공유해 보겠다.

함께 읽으면 좋을 포스트

https://ruempler.eu/2024/11/25/solutions-to-the-lost-update-problem/

문제가 되었던 lost update를 방지하기 위한 세 가지 방법을 알려준다.
주목할 부분은 isolation level은 SQL에서 Dirty Read, Non-repeatable Read, Phantom Read를 막기 위한 단계들을 정한 약속일 뿐이라는 것이다.
lost update는 이 약속에 속해있지 않으므로 DBMS마다 lost update를 막는 레벨은 상이할 수 있다. Postgres는 repeateble read에서 막아지고, mysql은 serializable까지 가야 막아진다.