홈시리즈멘토링

© 2026 정기창. All rights reserved.

본 블로그의 콘텐츠는 CC BY-NC-SA 4.0 라이선스를 따릅니다.

☕후원하기소개JSON Formatter러닝 대기질개인정보처리방침이용약관

© 2026 정기창. All rights reserved.

콘텐츠: CC BY-NC-SA 4.0

☕후원하기
소개|JSON Formatter|러닝 대기질|개인정보처리방침|이용약관

FOR UPDATE 를 걸어도 정원은 초과될 수 있습니다 — 락이 아니라 읽기 시점의 문제였습니다

정기창·2026년 9월 9일

이력서를 고치다가 한 줄에서 멈췄습니다. 「트랜잭션과 행 잠금으로 선착순 정원 경합을 처리했다」는 문장이었습니다. 틀린 말은 아니었습니다. 다만 그 문장을 제가 지금도 증명할 수 있는지 확인해 본 적이 없다는 생각이 들어, 9개월 만에 그 코드를 다시 열었습니다.

정원과 대기 순번이 있는 신청을 받는 그누보드5 사이트였습니다. 그누보드에는 ORM 도 트랜잭션 래퍼도 없어서, 정원을 다루려면 BEGIN 과 SELECT ... FOR UPDATE 를 손으로 적어 넣는 수밖에 없었습니다. 그 작업의 앞부분은 그누보드 예약 시스템을 만든 이야기에 적어 두었는데, 거기서 저는 "대용량 트래픽이 유입이 되지 않을 서비스라고 판단하여 (…) 복잡하게 다루지는 않았습니다" 라고 썼습니다. 그런데 같은 글에 "3일동안 131명이 회원가입을 했고 대기가 발생한 곳도 있" 다는 문장도 함께 적혀 있었습니다. 한 편의 글 안에서 두 문장이 서로를 반박하고 있었고, 락은 그 뒤에 들어갔습니다.

다시 열어 본 결과부터 적겠습니다. 락은 제대로 걸려 있었습니다. 그런데 두 군데가 제가 골라서 안전한 것이 아니라 우연히 안전했습니다. 하나는 트랜잭션이 만지는 테이블이 어쩌다 전부 InnoDB 였다는 것이고, 다른 하나는 구문의 순서가 어쩌다 맞았다는 것입니다. 미리 밝혀 두면 둘 다 아직 장애로 이어진 적이 없습니다. 무언가 터진 이야기가 아니라, 안전의 근거가 제 판단이 아닌 곳에 있었다는 이야기입니다.

아래에 싣는 출력은 전부 일회용 MariaDB 10.6 컨테이너에서 직접 재현한 것이고, 운영 데이터베이스에는 접속하지 않았습니다. 실제 코드는 외주 산출물이라 파일 이름과 테이블 이름은 일반적인 것으로 바꿔 적었습니다. 구조와 순서는 그대로입니다.

FOR UPDATE 를 얹은 자리는 세 곳이었습니다

신청 처리는 파일 하나에서 끝납니다. 트랜잭션 구간의 뼈대만 순서대로 옮기면 이렇습니다.

-- 트랜잭션 밖
SELECT * FROM course WHERE ...                    ← 화면 표시용

BEGIN
    SELECT id, max_capacity FROM course
      WHERE ... FOR UPDATE                        ← 잠금 읽기 (이 트랜잭션의 첫 읽기)

    SELECT COUNT(*) FROM application
      WHERE course_id = ? AND status = 'approved' ← FOR UPDATE 없음

    SELECT COUNT(*) FROM application
      WHERE course_id = ? AND status = 'waiting'  ← FOR UPDATE 없음

    SELECT COUNT(*) FROM application
      WHERE user_id = ? AND status = 'approved'
      FOR UPDATE                                  ← 한 사람의 승인 건수

    SELECT IFNULL(MAX(waiting_number), 0) + 1
      FROM application WHERE ... FOR UPDATE       ← 대기 순번

    UPDATE application ... / INSERT INTO application ...
    UPDATE course ...
COMMIT

막으려던 경합은 세 가지였습니다. 정원을 넘겨 승인되는 것, 같은 대기 순번이 두 사람에게 나가는 것, 한 사람이 정해진 개수보다 많이 승인되는 것. 락의 종류와 데드락을 정리하며 익힌 것을 그대로 얹은 셈인데, 지금 다시 봐도 이 세 자리는 고칠 데가 없어 보였습니다. 문제는 락이 아니라 락이 서 있는 바닥이었습니다.

우연 ① — 한 데이터베이스 안에 InnoDB 와 MyISAM 이 섞여 있었습니다

이 기능을 위해 제가 만든 테이블은 넷입니다. DDL 에 ENGINE=InnoDB 를 적었으니 트랜잭션이 됩니다. 되돌릴 수 있는 것은 InnoDB 스토리지 엔진의 내부 구조를 정리하며 봤던 언두 로그가 변경 이전 값을 들고 있기 때문입니다. 그런데 같은 데이터베이스에 이미 들어 있던 그누보드5 코어 테이블 31개는 ENGINE=MyISAM 입니다. MyISAM 에는 그 언두 로그가 없고, 회원과 게시판과 포인트가 전부 그쪽에 있습니다.

이 31 이라는 숫자는 제가 맡은 사이트의 사정이 아니라 그누보드5 설치본이면 어디나 같은 값입니다. 즉 그누보드5 위에 트랜잭션을 얹은 사람은 누구나 같은 바닥 위에 서 있습니다.

정직하게 덧붙이면 운영 데이터베이스의 엔진을 직접 조회해 본 것은 아닙니다. 근거는 그누보드5 설치 스키마와 제가 작성한 DDL 두 가지이고, 그사이 누군가 코어를 InnoDB 로 전환했을 가능성까지 배제하지는 못합니다. 다만 그 전환을 제가 한 적도, 요청한 적도 없다는 것은 분명합니다.

엔진이 섞여 있다는 것 자체는 오래된 PHP CMS 를 쓰면 흔한 상태입니다. 문제는 제가 그 위에 BEGIN 을 적었다는 쪽입니다.

ROLLBACK 은 절반만 되돌리고 «경고» 하나를 남깁니다

확인은 일회용 컨테이너에서 했습니다. 두 엔진으로 테이블을 하나씩 만들고, 같은 트랜잭션에서 양쪽에 쓴 다음 되돌려 봤습니다.

CREATE TABLE core_myisam (id INT PRIMARY KEY, v VARCHAR(20)) ENGINE=MyISAM;
CREATE TABLE app_innodb  (id INT PRIMARY KEY, v VARCHAR(20)) ENGINE=InnoDB;

BEGIN;
INSERT INTO app_innodb  VALUES (1,'innodb-write');
INSERT INTO core_myisam VALUES (1,'myisam-write');
ROLLBACK;
SHOW WARNINGS;

결과는 이랬습니다.

+---------+------+---------------------------------------------------------------+
| Level   | Code | Message                                                       |
+---------+------+---------------------------------------------------------------+
| Warning | 1196 | Some non-transactional changed tables couldn't be rolled back |
+---------+------+---------------------------------------------------------------+

+---------------------+-----------+
| tbl                 | rows_left |
+---------------------+-----------+
| app_innodb(InnoDB)  |         0 |   ← 되돌아감
| core_myisam(MyISAM) |         1 |   ← 살아남음
+---------------------+-----------+

눈여겨볼 칸은 Level 입니다. Error 가 아니라 Warning 입니다. 서버는 요청받은 대로 롤백했고, 되돌릴 수 없는 테이블이 있었다는 사실만 Some non-transactional changed tables couldn't be rolled back 이라는 한 줄로 남겼습니다. 규격대로 동작한 것입니다. 문제는 그누보드의 쿼리 실행 래퍼가 이 경고를 조회하지 않는다는 쪽입니다. 애플리케이션 입장에서 이 롤백은 온전히 성공한 롤백과 구별되지 않고, 절반만 되돌아간 상태에서 코드는 성공 경로를 그대로 이어 갑니다.

그런데 이 사이트에서는 그런 일이 벌어지지 않았습니다. 트랜잭션 구간이 실제로 만지는 테이블을 전수로 세어 봤더니 이랬습니다.

테이블 엔진 트랜잭션 안에서의 접근
신청 테이블 InnoDB 6회
과정 테이블 InnoDB 2회
그누보드 코어 31개 MyISAM 0회

코어 접근이 0회였습니다. 그래서 이 트랜잭션의 롤백은 언제나 온전했습니다. 그리고 그것은 제가 「트랜잭션 안에서는 코어를 건드리지 않는다」고 정해 둔 결과가 아니라, 그런 요구가 들어오지 않은 결과입니다. 승인될 때 회원에게 포인트를 적립한다든지 게시판에 안내 글을 한 줄 남긴다든지 하는 요구는 조금도 이상하지 않습니다. 그중 하나만 들어왔어도 그 INSERT 는 코어 테이블로 향했을 것이고, 그 뒤로 실패 경로를 탈 때마다 절반씩 남는 데이터가 생겼을 것입니다. 원자성은 제가 아니라 요구사항 목록이 지켜 주고 있었습니다.

우연 ② — 잠금 읽기와 일관 읽기는 서로 다른 시점을 봅니다

두 번째가 더 껄끄러웠습니다. 이쪽은 요구사항이 아니라 구문의 순서가 안전을 만들고 있었습니다. 기본 격리 수준인 REPEATABLE READ 에서 InnoDB 의 읽기는 두 갈래로 나뉩니다. 트랜잭션과 MVCC 편에서 원리로만 정리했던 read view 가, 이 코드에서는 정원 판단의 근거가 되어 있었습니다.

구분 예 무엇을 읽나
잠금 읽기 SELECT ... FOR UPDATE 언제나 최신 커밋본
일관 읽기 평범한 SELECT read view(스냅샷)를 통과한 값

핵심은 그 스냅샷이 언제 만들어지는가입니다. read view 는 BEGIN 시점이 아니라 그 트랜잭션의 첫 일관 읽기 시점에 생성되고, 이후의 모든 일관 읽기가 그 시점에 묶입니다. 평범한 SELECT 를 처음 실행한 순간이 그 트랜잭션이 보게 될 세계의 시각을 정하는 셈입니다.

제 코드는 FOR UPDATE 가 먼저 오고 평범한 COUNT(*) 가 그 뒤에 옵니다. 락을 먼저 잡고, 그 뒤에 read view 가 생깁니다. 그래서 정원 판단에 쓰는 숫자가 최신입니다. 맞게 동작합니다. 다만 제가 그 순서를 이 이유로 고른 것이 아닙니다. 잠글 대상을 먼저 잡는 편이 자연스러워 보여서 그렇게 적었을 뿐입니다.

평범한 SELECT 한 줄이 앞에 놓이면 락을 잡고도 옛 숫자를 읽습니다

같은 컨테이너에서 세션 두 개로 확인했습니다. 정원이 2이고 승인이 1건인 상태에서, 세션 A 가 SLEEP 으로 멈춰 있는 사이 다른 세션이 승인 1건을 커밋합니다. 실제 값은 2건이 됩니다.

CASE A — 실제 코드 순서 (FOR UPDATE 가 먼저)
  BEGIN;
  SELECT id FROM course WHERE id=1 FOR UPDATE;              -- 잠금 읽기
  SELECT SLEEP(3);                                          -- 이 사이 다른 세션이 INSERT + COMMIT
  SELECT COUNT(*) FROM appl WHERE cid=1 AND st='approved';
  → seen_by_A = 2      (실제 2)  ✅

CASE B — 평범한 SELECT 한 줄만 앞에 놓음
  BEGIN;
  SELECT COUNT(*) FROM appl WHERE cid=1;                    -- ← read view 가 여기서 생성됨
  SELECT SLEEP(3);                                          -- 이 사이 다른 세션이 INSERT + COMMIT
  SELECT id FROM course WHERE id=1 FOR UPDATE;              -- 락은 정상 획득
  SELECT COUNT(*) FROM appl WHERE cid=1 AND st='approved';
  → seen_by_A = 1      (실제 2)  ❌

CASE B 에서 FOR UPDATE 는 아무 문제 없이 락을 얻었습니다. 잠긴 자원도 같고 다른 세션을 대기시키는 동작도 같습니다. 어긋난 것은 정원 판단에 쓰는 숫자뿐입니다. 실제 승인은 2건인데 1건으로 읽고, 정원이 2이니 「한 자리 남았다」고 판단해 승인합니다. 락은 멀쩡히 걸려 있는 채로 정원이 초과됩니다. 락은 값을 지켜 주지만, 제가 그 값을 보고 있지 않으면 지켜 줄 대상이 없습니다.

CASE A 를 CASE B 로 바꾸는 데는 상식적인 수정 하나면 충분합니다

이 부분이 가장 불편했습니다. 두 코드의 차이는 평범한 SELECT 한 줄의 위치뿐입니다. 신청이 몰릴 때 상황을 남기려고 BEGIN 직후에 현재 신청 건수를 세어 로그를 찍는다면, 그 한 줄이 read view 를 앞당겨 만듭니다. 「검증은 앞쪽에 모으자」는 정리도 마찬가지입니다. 어느 쪽도 나쁜 습관이 아니고, 오히려 대체로 권장되는 정리에 가깝습니다.

그리고 이 변경은 테스트에서 드러나지 않습니다. 두 세션이 그 3초 창 안에서 겹쳐야만 값이 갈라지기 때문에, 순차로 돌리면 두 경우가 언제나 같은 답을 냅니다. 코드 리뷰도 크게 다르지 않습니다. 진단 로그 한 줄이 늘어난 diff 를 두고 「이 줄이 아래쪽 COUNT(*) 의 시점을 3초 전으로 되돌린다」고 말하려면 리뷰어가 read view 의 생성 시점을 알아야 하는데, 저는 그 코드를 쓰던 때조차 그것을 알지 못했습니다.

순서에 기대지 않게 만드는 법 — 잠금 읽기로 통일하거나 카운터로 옮기기

고치는 방향은 세 가지로 정리됐습니다.

  1. 판단에 쓰는 값도 잠급니다. 정원 판단의 COUNT(*) 를 잠금 읽기로 통일하면 구문 순서와 무관하게 최신값을 봅니다. 다만 집계에 락을 걸면 잠기는 범위가 인덱스 선택에 좌우된다는 별개의 문제가 따라옵니다. 이 부작용은 아직 제가 풀지 못했습니다.
  2. 락 대상과 판단 대상을 일치시킵니다. 승인 인원을 과정 행에 카운터로 비정규화해 두면, 이미 FOR UPDATE 로 잠근 바로 그 행만 읽으면 됩니다. 두 테이블을 오가지 않는 것이 근본 해법에 가깝습니다.
  3. 엔진 혼용을 감지 가능하게 만듭니다. 「트랜잭션 안에서 코어를 만지지 않는다」를 주석이 아니라 검사로 바꾸는 일입니다. 최소한 ROLLBACK 직후 SHOW WARNINGS 를 읽어 1196 이 나오면 기록하는 것만으로도 달라집니다. 지금은 감지조차 하지 않는 것이 문제이기 때문입니다.

가장 근본적인 처방은 물론 코어 테이블을 InnoDB 로 전환하는 것입니다. 다만 남의 CMS 스키마를 통째로 바꾸는 일은 제가 받은 범위 밖이었고, 앞으로도 제 손으로 결정할 수 있는 종류가 아닙니다. 그래서 세 번째가 현실적인 타협으로 남습니다. 문제를 없애지는 못하지만 적어도 일어났을 때 알 수는 있게 만드는 쪽입니다.

남는 것 — 「돌아간다」와 「돌아가게 만들었다」는 다릅니다

두 우연을 나란히 놓으면 한 문장으로 모입니다. 이 코드는 테스트를 통과했고 운영에서도 멀쩡했습니다. 그러나 그 안전은 제가 고른 것이 아니라 요구사항의 우연과 구문 순서의 우연이 준 것이라, 다음 사람이나 6개월 뒤의 제가 지극히 상식적인 수정 하나로 걷어낼 수 있습니다. 더 곤란한 것은 걷어내는 순간에도 아무 신호가 없다는 점입니다.

두 번째로 남는 것은 전제에 대한 책임입니다. ORM 도 트랜잭션 래퍼도 없는 곳에 BEGIN 을 직접 적는다는 것은, 「이 안에서 만지는 모든 테이블이 트랜잭션을 지원한다」는 전제를 제가 보증한다는 뜻이었습니다. 프레임워크가 있었다면 그 전제는 프레임워크의 몫이었을 것입니다. 추상화가 없는 자리에 추상화를 얹으면 기능만이 아니라 그 기능이 서 있는 전제까지 제가 떠맡게 된다는 생각이 들었습니다. 저는 그 전제를 한 번도 확인하지 않은 채 9개월을 보냈습니다.

세 번째는 1196 그 자체입니다. 그 경고는 되돌릴 수 없는 것을 되돌릴 수 없다고 정확히 말했습니다. 다만 아무도 듣지 않는 자리에서 말했을 뿐입니다. 이런 종류를 찾는 일은 잘못 쓴 줄을 찾는 일과 조금 다른 것 같습니다. 코드에서 틀린 곳을 고르는 게 아니라 아무도 읽지 않는 채널을 고르는 일에 가깝습니다. SHOW WARNINGS 는 그 9개월 내내 같은 자리에 있었습니다.

마지막으로 적어 둘 것은 발견의 계기입니다. 이 두 가지는 장애 대응 중에 나온 것도, 기능을 추가하다 걸린 것도 아닙니다. 이력서에 쓴 제 문장을 스스로 심문하다가 나왔습니다. 「트랜잭션으로 처리했다」는 한 줄을 남 앞에 내놓으려니 그 문장이 무엇을 보장하는지 제가 설명할 수 있어야 한다는 생각이 들었고, 설명해 보려 하니 두 군데가 비어 있었습니다.

제 코드에 대한 제 설명은 대체로 그 코드가 잘 돌아가던 시절에 만들어집니다. 그리고 잘 돌아간다는 사실이 그 설명을 검증해 주지는 않습니다. 저는 오랫동안 그 둘을 같은 것으로 여겼던 것 같습니다.

MySQLInnoDBMyISAM트랜잭션동시성데이터베이스그누보드

관련 글

락의 종류와 데드락 — 동시성 제어의 실체 (4편)

MVCC가 읽기-쓰기 충돌을 해결한다면, 락은 쓰기-쓰기 충돌을 해결한다. InnoDB의 레코드 락, 갭 락, 넥스트키 락이 각각 어떤 문제를 방지하는지, 데드락은 왜 발생하고 어떻게 감지되는지 추적합니다.

관련도 94%

트랜잭션과 MVCC — 동시에 읽고 쓸 수 있는 이유 (3편)

READ COMMITTED와 REPEATABLE READ의 차이를 코드 없이 설명할 수 있는가? InnoDB의 MVCC가 Undo Log 체인과 Read View로 어떻게 동작하는지, 격리 수준별로 어떤 이상 현상이 허용되는지 추적합니다.

관련도 93%

먼저 온 배포가 양보하게 만들었습니다 — 직렬화가 지켜주지 않는 15분

배포가 겹치지 않게 락을 걸었더니 겹침은 정말 사라졌습니다. 다만 사라진 것은 겹침이지 낭비가 아니었습니다. 뒤에 온 세션은 앞 체인이 끝나기를 기다렸다가 같은 코드를 다시 빌드하고 있었습니다.

관련도 92%