// chikrii_algorithm_strategic_labprovided as internal research — read-only
root@chikrii-lab:~/chikrii-lab/softlab-archive/concurrency-control.md

동시성 제어 방법과 락·트랜잭션 차이: 실무 선택 기준

재고가 1개 남았을 때 두 요청이 동시에 수량을 조회하면 둘 다 구매 가능하다고 판단할 수 있습니다. 이후 각 요청이 재고를 차감하면 품절 상품이 중복 판매되거나 한쪽 변경이 사라집니다. 트랜잭션을 사용했는데도 이런 문제가 생겼다면 무엇이 부족했던 것일까요? 답을 찾으려면 트랜잭션, 락, 격리 수준, MVCC가 맡는 역할부터 구분해야 합니다.

두 요청이 동일한 재고를 읽고 수정하는 동시성 제어 흐름

핵심만 먼저 정리하면, 트랜잭션은 여러 작업을 하나의 논리적 단위로 묶는 실행 경계이고 락은 같은 데이터에 접근하는 작업 사이의 충돌을 조정하는 장치입니다. 격리 수준은 트랜잭션이 다른 트랜잭션의 변경을 어느 정도까지 볼 수 있는지 정하며, MVCC는 여러 버전의 데이터를 이용해 읽기와 쓰기의 충돌을 줄이는 구현 방식입니다. 네 개념은 서로를 대체하지 않으며 DBMS와 쿼리에 따라 함께 작동합니다.

동시성 문제가 발생하는 이유

서버 요청은 코드에 적힌 순서대로 하나씩 실행되지 않습니다. 여러 스레드와 프로세스, 여러 서버 인스턴스가 같은 데이터를 동시에 읽고 수정할 수 있습니다. 각 요청만 보면 올바른 로직이어도 실행 순서가 겹치면 결과가 달라지는 현상을 race condition이라고 합니다.

대표적인 예가 lost update입니다. 요청 A와 B가 잔액 10만 원을 읽고 각각 1만 원을 차감한 뒤 9만 원을 저장하면, 두 차감 중 하나가 사라질 수 있습니다. 쿠폰 잔여 수량 확인과 발급을 분리하면 제한 수량보다 많이 발급될 수 있고, 주문 생성 전에 중복 여부만 조회하면 같은 결제 요청으로 주문이 두 건 만들어질 수도 있습니다.

  • Dirty read: 다른 트랜잭션이 아직 커밋하지 않은 값을 읽는 현상
  • Non-repeatable read: 한 트랜잭션에서 같은 행을 다시 읽었을 때 값이 달라지는 현상
  • Phantom read: 같은 조건으로 다시 조회했을 때 행 집합이 추가되거나 사라지는 현상
  • Deadlock: 두 작업이 서로 상대방이 보유한 자원을 기다려 진행하지 못하는 상태

장애를 분석할 때는 결과 데이터만 보지 말고 요청별 시작 시각, 트랜잭션 범위, 실행 SQL, 대기 이벤트와 데드락 로그를 함께 봐야 합니다. 재현과 관찰 방법은 동시성 분석 실무 가이드처럼 실행 순서를 타임라인으로 복원하는 방식이 유용합니다.

트랜잭션은 작업의 성공과 실패를 묶는다

트랜잭션은 여러 데이터 작업을 하나의 논리적 작업 단위로 처리하는 경계입니다. 계좌 이체라면 출금과 입금이 모두 성공해야 커밋하고, 하나라도 실패하면 전체를 롤백해야 합니다. 이 성질은 ACID의 원자성, 일관성, 격리성, 지속성으로 설명됩니다.

여기서 일관성은 DBMS가 모든 비즈니스 규칙을 자동으로 이해한다는 뜻이 아닙니다. 잔액이 음수가 되면 안 된다는 규칙은 조건부 갱신, 제약 조건, 적절한 락 또는 충돌 검증으로 표현해야 합니다. 트랜잭션은 그 규칙을 적용하는 작업들을 묶지만, 잘못 작성된 로직까지 올바르게 바꾸지는 않습니다.

커밋은 트랜잭션의 변경을 확정하고 롤백은 실패한 변경을 취소합니다. 그러나 두 트랜잭션이 같은 값을 읽은 뒤 각각 계산하여 저장하는 lost update는 단순히 트랜잭션 범위를 선언하는 것만으로 막히지 않을 수 있습니다. 격리 수준, SQL의 원자적 갱신 여부, 명시적 락 또는 버전 검사가 함께 검토돼야 합니다.

트랜잭션의 시작부터 커밋과 롤백까지의 흐름

락은 충돌하는 접근 순서를 조정한다

락은 데이터나 리소스를 사용하는 권한을 조정하는 메커니즘입니다. 공유 락은 일반적으로 여러 읽기 작업이 함께 보유할 수 있지만, 배타 락은 충돌하는 읽기 또는 쓰기를 기다리게 할 수 있습니다. 다만 어떤 락끼리 충돌하는지, 일반 조회가 락을 사용하는지, 락이 언제 해제되는지는 DBMS와 격리 수준에 따라 달라집니다.

락 범위도 행, 키, 인덱스 구간, 페이지, 테이블처럼 다양합니다. 행 단위 락은 서로 다른 행을 수정하는 작업의 동시성을 높일 가능성이 있지만 많은 락을 추적하는 비용이 생깁니다. 넓은 범위의 락은 관리 비용이 낮을 수 있는 대신 관련 없는 요청까지 기다리게 할 수 있습니다. 모든 DBMS가 동일한 단위를 노출하거나 개발자가 원하는 단위를 그대로 강제하도록 지원하는 것은 아닙니다.

락을 보유한 트랜잭션이 길어지면 대기 요청이 누적되어 응답 시간이 증가합니다. 특히 락을 잡은 채 외부 결제 API나 메시지 서비스의 응답을 기다리는 설계는 피하는 편이 좋습니다. 필요한 외부 작업을 먼저 준비하고, 데이터 정합성에 필요한 짧은 구간만 트랜잭션으로 묶는 방식이 안전합니다.

락과 트랜잭션의 차이는 목적과 생명주기에 있다

구분 트랜잭션
주요 목적 작업 묶음의 원자적 성공·실패와 격리 동시 접근 사이의 충돌 조정
적용 대상 여러 SQL과 논리적 업무 단위 행, 키, 범위, 페이지, 테이블 등의 리소스
종료 기준 커밋 또는 롤백 문장 종료나 트랜잭션 종료 등 DBMS 규칙
실패 대응 변경 취소와 재시도 대기, 타임아웃, 데드락 감지 후 희생 트랜잭션 중단

둘은 선택지 A와 B가 아닙니다. 예를 들어 재고 행을 잠근 뒤 수량 확인과 차감을 한 트랜잭션에서 실행하면, 트랜잭션은 두 작업을 묶고 락은 경쟁 요청의 순서를 조정합니다. 반대로 MVCC 스냅샷으로 읽는 트랜잭션도 쓰기 충돌이나 스키마 변경과 관련된 락을 완전히 피하는 것은 아닙니다.

비관적 락과 낙관적 락은 충돌 빈도로 선택한다

충돌이 잦다면 비관적 락을 검토한다

비관적 락은 다른 작업이 같은 데이터를 변경할 가능성이 높다고 보고 먼저 잠근 뒤 처리합니다. 인기 공연의 마지막 좌석, 한정 쿠폰, 잔액처럼 충돌 가능성이 높고 순차 처리가 필요한 데이터에 적합할 수 있습니다. 대신 요청이 몰리면 락 대기가 길어지고, 여러 자원을 다른 순서로 획득하면 데드락 위험이 커집니다.

재고라면 조회 후 애플리케이션에서 계산하기보다 재고 > 0 조건을 포함한 단일 갱신문으로 차감하고 영향받은 행 수를 확인하는 방식도 먼저 고려할 수 있습니다. 복잡한 검증이 필요해 잠금 읽기를 사용한다면 대상 인덱스와 검색 범위를 확인해야 합니다. 넓은 범위 스캔은 예상보다 많은 충돌을 만들 수 있습니다.

충돌이 드물다면 낙관적 락이 유리할 수 있다

낙관적 락은 읽을 때 잠금을 오래 보유하지 않고 수정 시점에 버전 번호나 타임스탬프가 그대로인지 검사합니다. 상품 설명이나 사용자 설정처럼 읽기가 많고 같은 레코드의 동시 수정은 드문 경우에 잘 맞습니다. 버전이 달라졌다면 갱신을 거부하고 최신 값을 다시 읽어 재시도하거나 사용자에게 충돌을 알려야 합니다.

충돌률이 높으면 재시도가 폭증하므로 낙관적 락이 항상 빠르다고 볼 수 없습니다. 재시도 횟수 제한, 지수형 대기, 전체 업무의 멱등성, 사용자에게 보여줄 실패 정책까지 설계해야 합니다.

비관적 락과 낙관적 락의 처리 흐름 비교

MVCC와 격리 수준을 같은 개념으로 보면 안 된다

MVCC는 데이터의 여러 버전을 유지하여 읽기 작업이 적절한 스냅샷을 보게 하는 방식입니다. 읽기와 쓰기의 직접적인 블로킹을 줄일 수 있지만 쓰기끼리의 충돌, 오래된 버전 정리 비용, 버전 저장 공간과 메타데이터 락까지 없애지는 않습니다. 격리 수준은 어떤 스냅샷을 언제 만들고 어떤 이상 현상을 허용할지 결정하는 정책에 가깝습니다.

READ COMMITTED에서는 일반적으로 커밋되지 않은 변경을 읽지 않지만, 같은 트랜잭션의 다음 문장이 새로 커밋된 값을 볼 수 있습니다. REPEATABLE READ는 반복 조회의 일관성을 강화하지만 팬텀과 쓰기 충돌 처리 방식은 제품마다 다릅니다. SERIALIZABLE은 성공적으로 커밋된 결과가 직렬 실행과 동등하도록 가장 강한 보장을 목표로 하지만, 대기나 직렬화 실패 및 재시도 비용을 고려해야 합니다.

PostgreSQL의 공식 격리 수준 문서에 따르면 PostgreSQL은 MVCC 구조상 READ UNCOMMITTED 요청을 READ COMMITTED처럼 처리하며, 자체 REPEATABLE READ에서는 팬텀 읽기도 허용하지 않습니다. 이는 SQL 표준이 정한 최소 보장보다 강한 제품별 동작의 사례입니다.

MySQL InnoDB의 기본 격리 수준은 REPEATABLE READ이며, 잠금 읽기와 갱신에서는 검색 조건과 인덱스에 따라 레코드 락, 갭 락, 넥스트 키 락이 사용될 수 있습니다. 정확한 범위는 MySQL 8.4 InnoDB 격리 수준 문서처럼 실제 운영 버전의 문서를 기준으로 확인해야 합니다.

SQL Server는 전통적인 락 기반 격리와 함께 row versioning 기반의 RCSI 및 SNAPSHOT 선택지를 제공합니다. 같은 이름의 격리 수준이라도 데이터베이스 옵션에 따라 읽기 동작이 달라질 수 있습니다. 따라서 “READ COMMITTED면 항상 공유 락으로 읽는다”처럼 특정 제품의 설정을 일반 법칙으로 단정해서는 안 됩니다.

격리 수준별 동시 읽기 현상 비교

업무 유형에 맞는 동시성 제어 방법

재고·잔액·좌석처럼 충돌이 많은 데이터

DB 제약 조건과 원자적 조건부 갱신을 우선 검토하고, 여러 조회와 검증이 필요한 경우 비관적 락이나 더 높은 격리 수준을 비교합니다. 잠금 대상이 되는 조건에 적절한 인덱스가 있는지 확인하고, 데드락과 타임아웃을 정상적인 실패 유형으로 취급해 안전한 재시도를 준비해야 합니다.

읽기가 많고 충돌이 드문 데이터

MVCC 기반 일반 읽기와 낙관적 락 조합이 효율적일 수 있습니다. 단, 버전 충돌을 무시한 마지막 저장 우선 방식은 lost update를 다시 만들 수 있습니다. 수정 화면에 버전 값을 포함하고 갱신 조건에서 검증한 뒤 실패 결과를 명시적으로 처리해야 합니다.

중복 주문·결제·쿠폰 요청

동시성 제어만으로 네트워크 재전송까지 해결할 수는 없습니다. 요청별 멱등성 키를 저장하고 유니크 제약 조건으로 중복 생성을 차단해야 합니다. “중복 여부 조회 후 삽입”은 두 요청이 동시에 통과할 수 있으므로, 최종 방어선은 데이터베이스의 원자적 제약이어야 합니다.

캐시도 락과 트랜잭션을 대신하지 않습니다. 분산 락을 사용하더라도 만료, 소유권 검증, 장애 복구 문제를 별도로 다뤄야 하며 원본 DB의 제약을 함께 유지해야 합니다. 캐시 스탬피드와 DB 정합성은 캐싱 구조를 이해하는 실무 가이드처럼 읽기 경로와 무효화 시점을 나눠 점검하는 것이 좋습니다.

성능 저하와 데드락을 줄이는 체크리스트

  • 트랜잭션 안에는 정합성에 필요한 작업만 포함하고 가능한 한 짧게 유지한다.
  • 락을 보유한 상태에서 외부 API 호출, 파일 처리, 사용자 입력 대기를 하지 않는다.
  • 여러 행이나 테이블을 수정할 때 모든 코드 경로에서 같은 순서로 접근한다.
  • 조회 조건과 실행 계획을 확인해 불필요하게 넓은 범위를 잠그지 않는다.
  • 락 대기 시간, 타임아웃, 데드락 횟수, 재시도 성공률을 함께 모니터링한다.
  • 데드락이나 직렬화 실패 재시도 시 트랜잭션 전체를 처음부터 다시 실행한다.
  • 재시도되는 요청이 결제나 메시지 발송 같은 부수 효과를 중복 생성하지 않도록 멱등성을 보장한다.
  • 운영 DBMS의 버전, 격리 수준, row versioning 설정과 드라이버 기본값을 문서화한다.

정답은 가장 강한 설정이 아니라 검증 가능한 설계다

동시성 제어 방법을 고를 때는 먼저 지켜야 할 불변 조건을 정의해야 합니다. 재고는 0보다 작아질 수 없는지, 잔액 변경은 원장과 함께 기록돼야 하는지, 중복 주문은 어떤 키로 판별할지를 명확히 합니다. 그다음 예상 충돌 빈도, 허용 가능한 대기 시간, 재시도 비용을 기준으로 원자적 SQL, 제약 조건, 비관적 락, 낙관적 락과 격리 수준을 조합합니다.

트랜잭션은 업무의 경계를 만들고, 락은 경쟁 작업을 조정하며, MVCC는 읽기 동시성을 높이고, 격리 수준은 관찰 가능한 데이터의 범위를 정합니다. 어느 하나만 선언한다고 정합성이 자동으로 완성되지는 않습니다. 실제 DBMS 동작을 확인하고 충돌·타임아웃·재시도 경로까지 테스트할 때 비로소 운영 환경에서 신뢰할 수 있는 동시성 제어가 됩니다.