동시성 문제는 평소에는 숨어 있다
개발 환경에서는 요청이 보통 한 명씩 들어온다. 테스트 데이터도 작고, 버튼을 동시에 누르는 사용자도 없다. 그래서 재고가 음수가 되거나, 쿠폰이 중복 발급되거나, 같은 좌석이 두 번 예약되는 문제는 잘 보이지 않는다.
하지만 실제 서비스에서는 다르다. 이벤트 시작 시각, 인기 상품 오픈, 월급날 정산, 광고 유입처럼 특정 순간에 같은 데이터를 향한 요청이 몰린다. 이때 데이터베이스는 단순히 빠르게 읽고 쓰는 저장소가 아니라, 동시에 들어온 작업의 순서를 조정하는 장치가 된다.
동시성 제어의 목표는 모든 요청을 무조건 한 줄로 세우는 것이 아니다. 그렇게 하면 안전하지만 너무 느리다. 핵심은 충돌 가능성이 있는 작업만 적절히 조정하고, 충돌이 없는 작업은 최대한 동시에 처리하게 하는 것이다.
AI 생성 이미지
동시에 읽고 쓸 때 생기는 이상 현상
격리 수준을 이해하려면 먼저 어떤 문제가 생기는지 알아야 한다. 여기서 말하는 이상 현상은 데이터베이스가 고장 났다는 뜻이 아니다. 선택한 격리 수준에서 허용되는 동작일 수 있다.
더티 리드
더티 리드는 다른 트랜잭션이 아직 커밋하지 않은 값을 읽는 현상이다.
예를 들어 트랜잭션 A가 사용자 잔액을 10만 원에서 20만 원으로 바꿨지만 아직 커밋하지 않았다고 하자. 트랜잭션 B가 이 20만 원을 읽고 출금을 허용했다. 그런데 A가 오류로 롤백되면, B는 실제로 존재하지 않았던 값을 근거로 판단한 셈이 된다.
많은 현대 DBMS는 기본 설정에서 더티 리드를 막는다. PostgreSQL은 SQL 표준의 Read Uncommitted를 설정해도 실제로는 Read Committed처럼 동작해 커밋되지 않은 값을 읽지 않는다1. 그렇다고 더티 리드만 막으면 충분하다는 뜻은 아니다.
반복 불가능 읽기
반복 불가능 읽기는 같은 트랜잭션 안에서 같은 행을 두 번 읽었는데 값이 달라지는 현상이다.
-- 트랜잭션 A
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- 10000
-- 트랜잭션 B
UPDATE accounts SET balance = 5000 WHERE id = 1;
COMMIT;
-- 트랜잭션 A
SELECT balance FROM accounts WHERE id = 1; -- 5000일 수 있음
COMMIT;Read Committed에서는 각 SQL 문장이 시작되는 시점의 커밋된 데이터를 본다. 따라서 같은 트랜잭션 안이라도 첫 번째 SELECT와 두 번째 SELECT가 다른 스냅샷을 볼 수 있다. 화면에 보여준 금액과 최종 검증에 사용한 금액이 달라질 수 있다는 뜻이다.
팬텀 리드
팬텀 리드는 같은 조건으로 조회했는데 결과 행의 집합이 달라지는 현상이다.
-- 트랜잭션 A
BEGIN;
SELECT COUNT(*) FROM reservations
WHERE concert_id = 10 AND status = 'CONFIRMED'; -- 99
-- 트랜잭션 B
INSERT INTO reservations(concert_id, user_id, status)
VALUES (10, 7, 'CONFIRMED');
COMMIT;
-- 트랜잭션 A
SELECT COUNT(*) FROM reservations
WHERE concert_id = 10 AND status = 'CONFIRMED'; -- 100일 수 있음
COMMIT;반복 불가능 읽기가 같은 행의 값 변화라면, 팬텀 리드는 조건에 맞는 행의 추가나 삭제 때문에 결과 집합이 바뀌는 문제다. 정원 제한, 중복 예약 제한, 기간별 합계 검증처럼 집합 단위의 규칙을 다룰 때 중요하다.
손실 업데이트
손실 업데이트는 두 트랜잭션이 같은 값을 읽고 각자 계산한 뒤 저장하면서, 한쪽 변경이 사라지는 현상이다.
-- 현재 stock = 1
-- 요청 A와 요청 B가 거의 동시에 실행
SELECT stock FROM products WHERE id = 1; -- 둘 다 1을 읽음
-- 애플리케이션에서 stock - 1 계산
UPDATE products SET stock = 0 WHERE id = 1; -- A 성공
UPDATE products SET stock = 0 WHERE id = 1; -- B도 성공처럼 보임최종 stock은 0이지만 실제로는 주문이 두 건 생성되었을 수 있다. 재고 1개로 두 명에게 판매한 것이다. 더 위험한 형태는 포인트 적립처럼 마지막 UPDATE가 이전 UPDATE를 덮어써 증가분 하나가 사라지는 경우다.
손실 업데이트는 애플리케이션에서 값을 읽고 계산한 뒤 절대값으로 저장할 때 자주 발생한다. 이 문제는 격리 수준만 올리는 것보다, SQL을 원자적인 조건부 변경으로 작성하는 편이 더 단순하고 강력할 때가 많다.
격리 수준은 안전성과 처리량의 균형이다
격리 수준은 동시에 실행되는 트랜잭션이 서로를 얼마나 강하게 분리해서 볼지 정하는 설정이다. 강한 격리 수준은 더 많은 이상 현상을 막지만, 대기 시간 증가, 잠금 경합, 재시도 증가 같은 비용을 만든다.
| 격리 수준 | 주된 특징 | 남는 위험 | 실무 판단 |
|---|---|---|---|
| Read Committed | 커밋된 데이터만 읽는다. 보통 문장마다 새 스냅샷을 본다. | 반복 불가능 읽기, 팬텀 리드, 일부 경쟁 조건 | 일반적인 OLTP 기본값으로 많이 사용한다. 대신 쓰기 쿼리를 안전하게 작성해야 한다. |
| Repeatable Read | 트랜잭션 동안 같은 스냅샷을 본다. | DBMS 구현에 따라 쓰기 충돌, 쓰기 스큐 같은 문제가 남을 수 있다. | 여러 번 읽는 동안 일관된 화면이나 계산 기준이 필요할 때 유용하다. |
| Serializable | 동시 실행 결과가 어떤 순서로든 하나씩 실행한 것과 같도록 보장한다. | 실패 시 재시도 필요, 처리량 저하 가능 | 돈, 정산, 제한 수량처럼 불변 조건이 매우 중요한 영역에 선택적으로 사용한다. |
PostgreSQL을 예로 들면 Read Committed가 기본값이고, Repeatable Read는 트랜잭션 시작 시점의 안정적인 스냅샷을 제공하며, Serializable은 직렬화 이상을 감지해 일부 트랜잭션을 실패시킬 수 있다1. 여기서 중요한 점은 Serializable이 모든 문제를 조용히 해결해 주는 마법이 아니라는 것이다. 애플리케이션은 직렬화 실패를 정상적인 동시성 충돌로 보고 재시도할 수 있어야 한다.
격리 수준은 DBMS마다 구현이 다르다. MySQL InnoDB의 Repeatable Read와 PostgreSQL의 Repeatable Read는 같은 이름이어도 잠금 방식과 팬텀 처리 방식이 다를 수 있다. 따라서 문서의 표준 이름만 믿지 말고, 실제 사용하는 DBMS의 동작을 기준으로 설계해야 한다.
MVCC는 읽기와 쓰기의 충돌을 줄인다
MVCC는 Multi-Version Concurrency Control의 약자다. 한글로는 다중 버전 동시성 제어라고 부른다. 핵심 아이디어는 하나의 행을 덮어쓰기만 하지 않고, 변경 시점에 따라 여러 버전을 관리하는 것이다.
읽기 트랜잭션은 자신이 볼 수 있는 시점의 버전을 읽는다. 쓰기 트랜잭션은 새 버전을 만든다. 그래서 많은 MVCC 기반 DBMS에서는 읽기가 쓰기를 막지 않고, 쓰기도 읽기를 막지 않는 경우가 많다. 덕분에 조회가 많은 서비스에서 처리량이 좋아진다2.
하지만 MVCC에도 비용이 있다.
오래된 행 버전을 일정 기간 보관해야 하므로 저장 공간이 늘어난다.
더 이상 필요 없는 버전을 정리하는 vacuum, purge 같은 작업이 필요하다.
긴 트랜잭션이 오래된 스냅샷을 붙잡고 있으면 정리가 지연된다.
쓰기 충돌은 여전히 발생하며, 일부 트랜잭션은 실패 후 재시도해야 한다.
실무에서 자주 보이는 문제는 긴 조회 트랜잭션이다. 관리자가 큰 리포트를 트랜잭션 안에서 오래 실행하거나, 배치 작업이 커서를 열어둔 채 천천히 처리하면 오래된 버전이 정리되지 못한다. 그러면 테이블이 불어나고 쓰기 성능도 떨어질 수 있다. MVCC는 동시성을 공짜로 주지 않는다. 읽기와 쓰기의 충돌을 줄이는 대신, 버전 관리 비용을 지불하게 한다.
경쟁 조건을 막는 실무 패턴
동시성 버그는 좋은 의도만으로 막을 수 없다. 코드의 확인과 저장 사이에 다른 요청이 끼어들 수 있기 때문이다. 안전한 패턴은 확인과 변경을 데이터베이스 안에서 하나의 원자적 작업으로 묶는 것이다.
조건부 업데이트
재고 차감은 먼저 조건부 UPDATE를 검토한다.
UPDATE products
SET stock = stock - 1
WHERE id = 1
AND stock > 0;영향받은 행 수가 1이면 차감 성공, 0이면 재고 부족이다. 이 방식은 SELECT로 읽고 애플리케이션에서 판단한 뒤 UPDATE하는 방식보다 안전하다. 조건 검사와 변경이 하나의 SQL 안에서 처리되기 때문이다.
낙관적 락도 같은 원리로 구현할 수 있다.
UPDATE posts
SET title = 'new title', version = version + 1
WHERE id = 10
AND version = 3;수정된 행이 0개라면 누군가 먼저 수정한 것이다. 이때는 사용자에게 다시 시도하도록 안내하거나, 최신 데이터를 다시 읽어 병합해야 한다.
SELECT FOR UPDATE
복잡한 판단이 필요해 먼저 읽어야 한다면, 행을 잠그고 읽는다.
BEGIN;
SELECT stock
FROM products
WHERE id = 1
FOR UPDATE;
-- 애플리케이션에서 검증
UPDATE products
SET stock = stock - 1
WHERE id = 1;
COMMIT;SELECT FOR UPDATE는 선택한 행을 다른 트랜잭션이 동시에 수정하지 못하도록 잠근다. 단, 잠금은 짧게 잡아야 한다. 트랜잭션 안에서 외부 결제 API를 호출하거나 이메일을 보내면, 그 시간 동안 다른 주문이 모두 기다릴 수 있다. 필요한 데이터만 잠그고, 같은 종류의 자원을 항상 같은 순서로 잠가 데드락 가능성을 줄인다.
유니크 제약
중복 쿠폰 발급이나 중복 예약은 애플리케이션의 사전 조회만으로 막기 어렵다.
SELECT * FROM coupons
WHERE event_id = 1 AND user_id = 10;
-- 없으면 INSERT두 요청이 동시에 SELECT를 실행하면 둘 다 없다고 판단할 수 있다. 최종 방어선은 유니크 제약이다.
ALTER TABLE coupons
ADD CONSTRAINT uq_coupon_event_user
UNIQUE (event_id, user_id);이후 INSERT가 충돌하면 하나만 성공하고 다른 하나는 실패한다. 애플리케이션은 이 실패를 서버 오류로만 보지 말고, 이미 발급된 상태로 해석할지 사용자에게 안내할지 결정해야 한다.
좌석 예약도 비슷하다. 활성 예약 기준으로 같은 공연의 같은 좌석이 하나만 존재해야 한다면, 그 규칙을 유니크 제약이나 별도 점유 테이블로 표현하는 편이 안전하다. 중요한 업무 규칙은 가능하면 데이터베이스 제약으로도 남겨야 한다.
동시 요청이 몰리는 기능의 체크포인트
예약, 쿠폰 발급, 재고 차감은 모두 같은 질문으로 설계할 수 있다.
첫째, 지켜야 할 불변 조건이 무엇인가. 재고는 음수가 되면 안 된다. 한 사용자는 이벤트 쿠폰을 한 번만 받을 수 있다. 한 좌석은 동시에 한 명에게만 확정되어야 한다. 불변 조건을 문장으로 쓰지 못하면 잠금과 제약도 정확히 설계할 수 없다.
둘째, 충돌 단위가 무엇인가. 상품 전체인지, 상품 옵션인지, 좌석 한 칸인지, 사용자와 이벤트의 조합인지 정해야 한다. 충돌 단위가 작을수록 동시성은 좋아진다. 모든 쿠폰 발급을 이벤트 한 행에 몰아 잠그면 단순하지만 병목이 된다.
셋째, 실패를 재시도할 수 있는가. Serializable, 낙관적 락, 유니크 충돌은 실패를 정상 흐름으로 만들 수 있다. 그러려면 요청에 멱등성 키를 두고, 같은 요청이 두 번 들어와도 결과가 중복되지 않게 해야 한다.
넷째, 트랜잭션 안에서 오래 걸리는 일을 하지 않는가. 외부 API 호출, 파일 업로드, 긴 계산, 사용자 응답 대기는 잠금을 오래 붙잡는다. 데이터베이스 안에서 확정해야 하는 최소 작업만 트랜잭션으로 묶고, 나머지는 커밋 이후 처리하는 구조가 안전하다.
다섯째, 운영 지표를 보고 있는가. 락 대기 시간, 데드락 수, 직렬화 실패 수, 재시도 횟수, 재고 차감 실패율은 동시성 설계의 건강 상태를 보여준다. 동시성 문제는 로그 한 줄 없이 사용자 불만으로 먼저 드러날 때가 많다.
동시성 제어의 핵심은 모든 기능에 가장 강한 격리 수준을 적용하는 것이 아니다. 데이터의 불변 조건을 명확히 하고, 충돌이 생기는 지점에 조건부 업데이트, 잠금, 유니크 제약, 재시도를 적절히 배치하는 것이다. 테스트 환경에서 조용한 코드는 충분하지 않다. 동시에 실행되어도 안전한 코드가 운영 환경에서 살아남는다.
참고
1PostgreSQL Documentation: Transaction Isolation
2Concurrency Control in DBMS: How Locking, MVCC and Optimistic Strategies Keep Data Consistent
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.