반응형
Ant_U
DBA 개미
Ant_U
전체 방문자
오늘
어제
  • 분류 전체보기 (302) N
    • AWS (3)
    • C# (1)
    • SQL (276) N
      • MYSQL (225) N
      • MSSQL (51) N
    • 자격증 (20)
      • SQLD (12)
      • SQLP (8)

인기 글

최근 글

250x250
hELLO · Designed By 정상우.
Ant_U

DBA 개미

[MySQL] SELECT FOR UPDATE와 FOR SHARE 비교: 예약·재고 처리 잠금 설계
SQL/MYSQL

[MySQL] SELECT FOR UPDATE와 FOR SHARE 비교: 예약·재고 처리 잠금 설계

2026. 9. 29. 09:00
728x90
반응형

[MySQL] SELECT FOR UPDATE와 FOR SHARE 비교: 예약·재고 처리 잠금 설계

데이터베이스를 다루다 보면 재고 차감이나 좌석 예약처럼 '먼저 값을 읽고, 조건을 확인한 뒤, 다시 값을 갱신하는' 로직을 자주 작성하게 됩니다. 문제는 이 읽기와 쓰기 사이에 다른 트랜잭션이 끼어들면 초과 판매나 중복 예약 같은 정합성 문제가 발생한다는 점입니다. 이때 가장 흔하게 사용되는 것이 바로 비관적 잠금(Pessimistic Locking)이며, MySQL의 InnoDB 스토리지 엔진은 이를 위해 SELECT ... FOR UPDATE와 SELECT ... FOR SHARE라는 두 가지 잠금 읽기(Locking Read) 구문을 제공합니다. 이 글에서는 두 구문의 기본 개념과 잠금 종류부터 격리 수준별 동작 차이, 재고 차감 로직에 어떤 것을 선택해야 하는지, 그리고 데드락을 피하기 위한 NOWAIT와 SKIP LOCKED까지 단계적으로 살펴보겠습니다.

1. FOR UPDATE와 FOR SHARE의 기본 개념과 잠금 종류

두 구문 모두 트랜잭션 내부에서 조회한 행에 잠금을 걸어, 트랜잭션이 커밋되거나 롤백될 때까지 다른 세션이 해당 행을 특정 방식으로 건드리지 못하게 막는 역할을 합니다.

-- 배타 잠금(Exclusive Lock, X Lock)
SELECT stock FROM inventory WHERE product_id = 1001 FOR UPDATE;

-- 공유 잠금(Shared Lock, S Lock)
SELECT stock FROM inventory WHERE product_id = 1001 FOR SHARE;
  • FOR UPDATE는 배타 잠금을 겁니다. 이 잠금이 걸린 행은 다른 트랜잭션이 읽기(FOR SHARE, FOR UPDATE 모두)도, 쓰기(UPDATE, DELETE)도 할 수 없습니다. 잠금 없는 일반 SELECT(MVCC 기반의 일관된 읽기)는 여전히 가능합니다.
  • FOR SHARE(구버전 명칭 LOCK IN SHARE MODE)는 공유 잠금을 겁니다. 같은 행에 대해 여러 트랜잭션이 동시에 FOR SHARE를 걸 수 있지만, 어느 트랜잭션도 그 행을 수정하거나 FOR UPDATE로 승격할 수는 없습니다.

MySQL 8.0부터 FOR SHARE가 공식 문법으로 추가되었고, LOCK IN SHARE MODE는 이전 버전과의 호환을 위해 남아있는 동의어입니다. 동작 자체는 동일합니다.

구분 SELECT FOR UPDATE SELECT FOR SHARE
잠금 종류 배타 잠금(X Lock) 공유 잠금(S Lock)
동시에 여러 세션이 잠글 수 있는가 불가능 가능(다른 세션도 FOR SHARE는 획득 가능)
같은 행의 UPDATE/DELETE 허용 여부 본인 트랜잭션만 가능 어느 트랜잭션도 커밋 전까지 불가능
일반적인 용도 값을 확인한 뒤 곧바로 수정할 때(재고 차감 등) 값을 참조만 하고 그 사이 변경되지만 않으면 될 때
데드락 위험 단독 사용 시 낮음 여러 세션이 FOR SHARE 후 UPDATE로 승격 시도하면 높음

2. 트랜잭션 격리 수준에 따른 잠금 범위 차이

REPEATABLE READ(MySQL 기본 격리 수준)에서 인덱스를 통한 검색은 레코드 잠금뿐 아니라 갭 잠금(Gap Lock)과 넥스트 키 잠금(Next-Key Lock)까지 함께 겁니다. 이는 팬텀 리드를 막기 위한 것으로, 예를 들어 WHERE show_date BETWEEN '2026-09-10' AND '2026-09-12' FOR UPDATE처럼 범위 조건으로 잠금을 걸면 실제 존재하는 행뿐 아니라 그 사이의 간격(gap)까지 잠기기 때문에, 해당 범위에 새로운 행을 INSERT하려는 다른 트랜잭션도 대기하게 됩니다. 날짜·범위 조건의 동작 특성은 [MySQL] BETWEEN 연산자 가이드: 범위 검색의 효율성과 주의사항 글에서 다룬 포함 관계와 함께 이해하면 좋습니다.

반면 READ COMMITTED로 격리 수준을 낮추면 갭 잠금이 비활성화되고 레코드 잠금만 남습니다. 따라서 동시성은 높아지지만, 그만큼 팬텀 리드에 대한 보호는 사라진다는 점에 주의가 필요합니다. 재고 차감처럼 단일 PK(product_id)로 조회하는 경우에는 갭 잠금의 영향이 크지 않지만, 예약 시스템처럼 날짜·좌석 등급 같은 범위·다중 조건으로 조회하는 경우에는 의도치 않게 인접한 좌석의 INSERT가 막히는 현상이 나타날 수 있습니다. 실제로 몇 개의 행이 잠기는지는 [MySQL] EXPLAIN ANALYZE 해석법: 추정 행 수와 actual rows 오차 찾기 글에서 설명한 actual rows 지표를 함께 확인하면 도움이 됩니다.

3. 재고 차감 로직에는 왜 FOR UPDATE를 써야 할까

재고가 1개 남은 상품을 두 사용자가 동시에 주문하는 상황을 생각해 보겠습니다. 잠금 없는 일반 SELECT로 재고를 확인하고 애플리케이션에서 '재고 >= 주문 수량'을 검증한 뒤 UPDATE 하면, 두 트랜잭션 모두 재고=1을 읽고 각각 차감을 시도해 재고가 음수가 되는 초과 판매(overselling)가 발생할 수 있습니다.

START TRANSACTION;
SELECT stock FROM inventory WHERE product_id = 1001 FOR UPDATE;
-- 애플리케이션에서 stock >= 주문 수량 검증
UPDATE inventory SET stock = stock - 1 WHERE product_id = 1001;
COMMIT;

FOR UPDATE로 조회하면 첫 번째 트랜잭션이 커밋(또는 롤백)될 때까지 두 번째 트랜잭션의 SELECT ... FOR UPDATE는 대기 상태에 들어갑니다. 첫 트랜잭션이 커밋되고 나면 두 번째 트랜잭션은 갱신된 재고 값(예: 0)을 다시 읽게 되므로, 애플리케이션 로직에서 재고 부족을 정확히 판단할 수 있습니다.

만약 여기서 FOR SHARE를 사용하면 어떻게 될까요. 두 트랜잭션이 동시에 SELECT ... FOR SHARE로 재고를 조회하면 둘 다 공유 잠금을 획득하는 데 성공합니다. 이후 각각 UPDATE를 실행하려는 순간, 서로 상대방이 가진 공유 잠금이 풀리기를 기다리게 되어 전형적인 데드락(Deadlock) 패턴이 만들어집니다. 흔히 하는 실수 중 하나가 '일단 잠그기만 하면 되겠지'라는 생각으로 FOR SHARE를 재고 차감에 사용하는 것인데, 이는 오히려 데드락 발생 확률을 크게 높이는 결과를 가져오기 때문에 주의가 필요합니다.

4. FOR SHARE가 적합한 경우: 수정 없이 존재만 보장할 때

예를 들어 주문을 생성하기 전에 참조하는 쿠폰이나 배송 정책 행이 트랜잭션 도중 삭제되지 않도록 보장하고 싶지만, 이 트랜잭션이 그 행 자체를 수정하지는 않는 경우를 생각해 보겠습니다. 이런 경우에는 FOR SHARE로 충분합니다. 여러 주문 트랜잭션이 동시에 같은 쿠폰 정책을 참조하며 읽어도 서로를 막지 않고, 다만 그 사이에 관리자가 쿠폰을 삭제하거나 수정하려는 트랜잭션만 대기하게 만들 수 있습니다.

정리하면, 조회한 행을 이번 트랜잭션에서 직접 수정할 계획이라면 FOR UPDATE를, 수정 계획은 없고 다만 다른 세션이 그 값을 못 바꾸게만 막고 싶다면 FOR SHARE를 사용하는 것이 더 안전합니다.

5. 잠금 대기, 데드락, 그리고 NOWAIT / SKIP LOCKED

기본적으로 innodb_lock_wait_timeout(기본값 50초) 동안 잠금을 기다리다가 시간이 초과하면 'Lock wait timeout exceeded' 에러가 발생합니다. 이때 주의할 점은 트랜잭션 전체가 자동으로 롤백되는 것이 아니라 해당 문장만 실패하고 트랜잭션은 계속 열려 있다는 점입니다. 따라서 애플리케이션에서 이 에러를 잡아 명시적으로 ROLLBACK을 호출하지 않으면 잠금을 계속 들고 있는 좀비 트랜잭션이 남을 수 있으므로 주의가 필요합니다.

MySQL 8.0부터는 FOR UPDATE와 FOR SHARE 모두에 NOWAIT와 SKIP LOCKED 옵션을 사용할 수 있습니다.

  • NOWAIT: 잠금이 이미 걸려 있으면 대기하지 않고 즉시 에러를 반환합니다.
  • SKIP LOCKED: 잠금이 걸린 행은 결과에서 제외하고, 잠기지 않은 행만 즉시 반환합니다.

예를 들어 좌석 예약 시스템에서 여러 사용자가 동시에 '예약 가능한 좌석 하나'를 찾을 때 SKIP LOCKED를 사용하면 이미 다른 트랜잭션이 선점한 좌석은 건너뛰고 즉시 다음 후보 좌석을 잡을 수 있어, 대기 없이 처리량을 높일 수 있습니다.

SELECT seat_id FROM seats
WHERE show_id = 500 AND status = 'AVAILABLE'
ORDER BY seat_id
LIMIT 1
FOR UPDATE SKIP LOCKED;

다만 SKIP LOCKED는 이미 잠긴 행을 건너뛰므로 결과가 트랜잭션 격리 수준이 보장하는 일관된 스냅샷이 아니라는 점을 의미합니다. 반복 실행 시 매번 다른 결과가 나올 수 있으며, 이는 큐(Queue)나 좌석 선점처럼 '먼저 잡는 사람이 임자'인 로직에는 적합하지만, 정확한 정합성 리포트가 필요한 곳에는 사용하면 안 됩니다.

6. 동시 요청 상황에서 잠금 방식별 처리량 직접 측정하기

FOR UPDATE와 FOR SHARE의 성공·대기·충돌 건수, 그리고 처리량 차이는 실제 동시 접속 환경에서만 관찰되는 값이므로 단일 커넥션에서 실행한 쿼리 하나로는 재현할 수 없습니다. 아래 절차대로 직접 부하 테스트를 구성해 측정하는 것이 가장 정확합니다.

  1. 테스트용 inventory 테이블을 만들고, 재귀 CTE 등으로 수천 행 규모의 재고 데이터를 적재합니다.
  2. sysbench 커스텀 Lua 스크립트나, 여러 개의 MySQL 커넥션을 동시에 여는 Python/Java 스크립트로 'START TRANSACTION → SELECT ... FOR UPDATE(또는 FOR SHARE) → 짧은 지연 → UPDATE → COMMIT' 흐름을 동시 세션 수(예: 10, 50, 100)를 바꿔가며 일정 시간 반복 실행합니다.
  3. 각 라운드마다 성공 커밋 수, Lock wait timeout 발생 수, 데드락 발생 수(SHOW ENGINE INNODB STATUS의 LATEST DETECTED DEADLOCK 섹션 참고), 초당 처리 트랜잭션(TPS)을 기록합니다.
  4. REPEATABLE READ와 READ COMMITTED 두 격리 수준에서 각각 반복해, 갭 잠금 유무가 처리량에 미치는 영향을 함께 비교합니다.

아래는 결과를 정리할 때 쓸 수 있는 표 양식입니다. 칸은 실제 부하 테스트 결과로 채워야 하며, 다른 환경의 수치를 그대로 옮겨서는 안 됩니다.

잠금 방식 동시 세션 수 성공 커밋 수 Lock wait timeout 발생 수 데드락 발생 수 초당 처리량(TPS)
FOR UPDATE - - - - -
FOR SHARE → UPDATE 승격 - - - - -
FOR UPDATE SKIP LOCKED - - - - -

7. 실무에서 흔히 하는 실수와 점검 체크리스트

WHERE 절에 인덱스가 없는 컬럼으로 FOR UPDATE를 걸면 옵티마이저가 풀 테이블 스캔을 선택하게 되고, 이 경우 스캔한 모든 행(그리고 그 갭)에 잠금이 걸려 사실상 테이블 전체가 잠기는 것과 같은 효과가 납니다. 재고 차감처럼 트래픽이 몰리는 쿼리라면 잠금 대상 컬럼에 반드시 인덱스가 있는지 먼저 확인해야 합니다. 인덱스가 실제로 잘 타는지는 [MySQL] WHERE 절 완전 정복: 기본 문법부터 8.0 최적화 팁까지 글에서 다룬 기준으로 점검할 수 있습니다.

또한 자동 커밋(autocommit)을 끄고 트랜잭션을 시작한 뒤 COMMIT이나 ROLLBACK을 누락하면, 애플리케이션 커넥션 풀에 반환된 커넥션이 잠금을 계속 들고 있어 다른 요청까지 연쇄적으로 대기하게 됩니다. 트랜잭션은 최대한 짧게 유지하는 것이 가장 중요한 원칙입니다.

서로 다른 두 트랜잭션이 여러 상품을 다른 순서로 잠그면(A→B, B→A 순) 데드락이 발생합니다. 여러 상품의 재고를 한 번에 차감해야 한다면 product_id 오름차순처럼 일관된 순서로 잠그는 것이 더 안전합니다.

버전별 동작 차이도 짚어야 합니다. FOR SHARE 문법 자체는 MySQL 8.0에서 정식 추가되었고, NOWAIT/SKIP LOCKED도 8.0부터 지원됩니다. 5.7 이하 버전에서는 LOCK IN SHARE MODE만 사용 가능하며 NOWAIT/SKIP LOCKED는 지원되지 않으므로, 서비스 중인 MySQL 버전을 반드시 먼저 확인해야 합니다.

정리해 드립니다. 조회한 값을 이번 트랜잭션에서 직접 수정할 계획이면 FOR UPDATE, 수정 계획 없이 존재나 값이 바뀌지 않는 것만 보장하면 되면 FOR SHARE를 선택하는 것이 기본 원칙입니다. 재고 차감처럼 읽고 곧바로 쓰는 로직에는 FOR UPDATE가 정답에 가깝고, 여기에 SKIP LOCKED를 더하면 동시 요청 처리량까지 개선할 수 있습니다. 다만 어떤 방식을 선택하든 실제 동시성 환경에서의 성공률과 데드락 발생 빈도는 서비스마다 다르므로, 위에서 설명한 절차대로 직접 부하 테스트를 돌려 수치를 확인하는 과정이 반드시 필요합니다.
728x90
반응형

'SQL > MYSQL' 카테고리의 다른 글

[MySQL] EXPLAIN ANALYZE 카디널리티 오차 진단과 실행 계획 튜닝 순서  (0) 2026.10.03
[MySQL] 갭 락과 넥스트키 락 재현: 없는 값을 조회해도 INSERT가 막히는 이유  (0) 2026.10.02
[MySQL] 메타데이터 락 때문에 멈춘 DDL 진단과 안전한 해소 절차  (0) 2026.10.01
[MySQL] NOWAIT와 SKIP LOCKED 활용: 대기 없는 작업 큐 구현과 한계  (0) 2026.09.30
[MySQL] 긴 트랜잭션이 Undo와 Purge를 지연시키는 과정 측정하기  (0) 2026.09.28
[MySQL] performance_schema로 현재 잠금 대기와 차단 세션 찾기  (0) 2026.09.27
[MySQL] 데드락 로그 읽는 법: 피해 트랜잭션과 잠금 순서 복원하기  (0) 2026.09.26
[MySQL] 옵티마이저 힌트 사용 기준: JOIN_ORDER와 INDEX 힌트의 선택·제거 원칙  (0) 2026.09.25
    'SQL/MYSQL' 카테고리의 다른 글
    • [MySQL] 메타데이터 락 때문에 멈춘 DDL 진단과 안전한 해소 절차
    • [MySQL] NOWAIT와 SKIP LOCKED 활용: 대기 없는 작업 큐 구현과 한계
    • [MySQL] 긴 트랜잭션이 Undo와 Purge를 지연시키는 과정 측정하기
    • [MySQL] performance_schema로 현재 잠금 대기와 차단 세션 찾기
    Ant_U
    Ant_U

    티스토리툴바