본문으로 건너뛰기

비관적 락에 대해, 그리고 네임드락과 차이점

네임드 락에 이어서 이번에는 비관적 락은 무엇이고, 어느 상황에서 써야되고, 전에 배운 네임드락과 어느점이 다른지 알아보려고합니다.

비관적 락
#

비관적 락은 select ... for update를 통해 실행됩니다.
직접 명시하여 DB락을 사용할 수 있는 용도입니다. 여기서 DB락 InnoDB 스토리지 엔진을 뜻합니다. 기본적으로 레코드락을 기반으로 하기에 동시성 처리를 제공할 수 있습니다. 그리고 이는 sql쿼리문과 실행계획에 따라 달라집니다. 종류로는 레코드 락(단일), 갭락, 넥스트 키 락, 자동증가 락이 있습니다.

저희가 알아볼 것은 어느 상황에 적합한 지 알아보는 것이기에 각각에 대한 소개는 생략하겠습니다.

범위 기반 락으로 했을 때 주의점
#

WHERE 절을 통해 비관적 락을 걸었을 때 격리 수준에 따라 락 걸리는 범위 차이가 있었습니다.
예를 들어 회의실 예약 상황이라 가정해보겠습니다.

복합인덱스 (room_id, start_at, end_at)
      
WHERE room_id = 1
  AND start_at < '2026-03-25 10:30:00'
  AND end_at > '2026-03-25 10:00:00'
FOR UPDATE

이렇게 락을 잡는다고 했을 때 roomid = 1, 그리고 10:00 ~ 10:30분만 정확히 잡히면 좋겠지만 그렇지 않았습니다. 실행계획을 확인한 결과는 다음과 같았습니다.

"used_key_parts" : ["room_id", "start_at" ]

즉 InnoDB는 (room_id, start_at, end_at) 복합 인덱스 중 room_id, start_at까지만 범위 탐색의 기준으로 사용했고, end_at > ‘2026-03-25 10:00:00’ 조건은 정확한 범위를 닫는 키라기보다 추가 필터처럼 적용됐습니다. 그 결과, 실제 겹침 예약만 잠기는 것이 아니라 room_id = 1이고 start_at < ‘2026-03-25 10:30:00’인 인덱스 스캔 범위 전체를 기준으로 예상보다 넓게 잠금이 걸릴 수 있었습니다. 실험에서도 실제 예약 충돌과 무관한 09:00대 데이터까지 잠금 영향 범위에 들어갔습니다.

이런 특성은 특히 REPEATABLE READ에서 next-key lock과 gap lock 때문에 더 두드러지며, 동시성 측면에서 불리할 수 있습니다.

이 문제는 시간 구간 겹침을 그대로 잠그려 하기 때문에 발생합니다.

++) 이 챕터는 글 하단에 추가 내용 부분과 연결됩니다.

해결책 - 충돌 구체화
#

위 문제 상황에서는 분 단위로 로우를 미리 생성해서 그것을 잡는 방식으로 해결할 수 있습니다. 이 방식을 충돌 구체화라고 합니다.

예를 들어 예약 단위를 15분으로 고정하면, 10:00 ~ 10:30 예약은 다음 두 슬롯으로 표현할 수 있습니다.

  • 10:00
  • 10:15

필요한 슬롯 row를 미리 생성해 두고, 예약 시에는 필요한 슬롯만 정확히 잠급니다.

SELECT *
FROM room_slot_inventory
WHERE room_id = 1
  AND slot_start IN ('2026-03-25 10:00:00', '2026-03-25 10:15:00')
FOR UPDATE;

이 방식에서는 더 이상 겹치는 시간 구간을 어떻게 잠글까를 고민하지 않고, (room_id, slot_start)라는 정확한 키 충돌만 다루면 됩니다.

언제 써야될까?
#

비관적 락은 SELECT … FOR UPDATE로 이미 존재하는 row를 읽고, 그 row를 기준으로 후속 변경을 직렬화하고 싶을 때 가장 적합합니다. 특히 등호 조건이나, 인덱스 순서상 하나의 연속 구간으로 잘 표현되는 범위 조건에서는 위에서 얘기했듯이 어떤 row들이 잠길지 예측하기 쉽습니다.

예를 들어 다음과 같은 경우입니다.

  • 특정 사용자 row 1건 조회 후 수정
  • 특정 재고 batch 집합 조회 후 차감
  • 특정 기간에 속한 기존 주문 row 집합을 잠그고 정산 처리

이런 경우에는 무엇을 잠가야 하는지가 이미 테이블 안의 row로 존재하므로, 비관적 락이 잘 맞습니다.

한계점
#

반대로 비관적 락이 애매해지는 경우는, 충돌 조건이 기존 row 자체가 아니라 어떤 row가 없어야 한다는 조건에 의존할 때입니다.

예를 들어 어떤 사용자에게 특정 쿠폰은 한 번만 발급 가능하다고 가정하겠습니다. 그러면 두 트랜잭션이 이미 발급된 쿠폰 row가 있는지를 조회했을 때 둘 다 아무 것도 반환받지 못하면, FOR UPDATE를 사용하더라도 잠글 기존 row가 없습니다. 그래서 둘 다 중복으로 insert하면 두 개의 쿠폰이 생길 수 있습니다.

실험
#

두 개의 세션에서 동시에 아래 조회를 수행했습니다.

SELECT id, user_id, coupon_template_id, issued_by
FROM user_coupon
WHERE user_id = 1
  AND coupon_template_id = 100
FOR UPDATE;

두 세션 모두 조회 결과가 비어 있는 것을 확인했습니다. 즉 FOR UPDATE를 사용했지만, 잠글 기존 row가 없는 상태였습니다.

이후 각 세션에서 아래와 같이 INSERT를 수행했습니다.

//session A 
INSERT INTO user_coupon (user_id, coupon_template_id, issued_by)
VALUES (1, 100, 'session_a');

COMMIT;

//session B
INSERT INTO user_coupon (user_id, coupon_template_id, issued_by)
VALUES (1, 100, 'session_b');

COMMIT;

그 결과 두 세션의 INSERT가 모두 성공했고, 최종적으로 동일한 사용자와 동일한 쿠폰 템플릿에 대해 두 개의 row가 생성되었습니다.

img1

이 케이스에서는 UNIQUE를 (user_id, coupon_template_id)로 한다면 중복 삽입을 막는 식으로 해결할 수 있습니다.

Error Code: 1062. Duplicate entry '1-100' for key 'user_coupon.uq_user_coupon_issue'

테스트 해본 결과 이런식으로 정상적으로 에러 반환을 하여 중복을 막았습니다.

네임드락과 차이가 무엇일까
#

이전 글에서 알아본 네임드 락도 DB를 활용한 락입니다. 다만 네임드락은 테이블 row나 인덱스 레코드에 거는 락이 아니라, 애플리케이션 수준의 상호배제 락입니다.

그래서 한계점에서 언급한 아직 row가 없어서 FOR UPDATE로 잠글 대상이 없을 때 경우에서 대안책으로 사용할 수 있습니다. 왜냐면 로직 흐름 자체를 락으로 관리하기 때문에 직렬적으로 실행되기 때문입니다.

img2
주황색 부분이 네임드락으로 관리하는 임계 영역이고 노란색 부분은 동시성 위험 구간 입니다.

다만 네임드 락은 여러 SQL에 걸친 로직 흐름 전체를 직렬화하는 방식이기 때문에, row 단위 잠금보다 임계 구역이 더 넓어질 수 있으며 처리량이 낮아질 수 있다는 점을 고려해야 합니다.


정리
#

비관적 락이 실행될 때 InnoDB는 실행계획상 스캔한 인덱스 범위를 기준으로 잠금을 걸 수 있기 때문에,
조건에 따라 어떤 실행계획이 선택되는지가 중요하다는 것을 알게 되었습니다.
또한 SELECT … FOR UPDATE처럼 기존 row를 잠그는 방식은 존재하지 않는 row의 부재 자체를 직접 보장하기 어렵기 때문에, 경우에 따라서는 애플리케이션 수준에서 로직 전체를 직렬화하는 네임드 락이 대안이 될 수 있다는 점도 살펴보았습니다.

대안으로는 네임드 락뿐 아니라 Redis 기반 락도 활용할 수 있을 것 같습니다.
각각 어떤 상황에서 더 적합한지, 그리고 어떤 트레이드오프가 있는지 비교해보는 것도 흥미로운 주제가 될 것 같습니다.

읽어주셔서 감사합니다.


+ 추가 내용
#

범위 기반 락을 걸 때 위에서 테스트 한 것은 몇 십건 정도였지만 몇 만건 정도가 되어도 똑같이 모두 락을 걸까 궁금했습니다.
이를 확인하기 위해 room_id = 1이고 start_at < '2026-03-25 10:30:00'인 데이터를 10만 건까지 늘려서 다시 실험해보았습니다.

실행계획을 확인한 결과 EXPLAIN, EXPLAIN ANALYZE 모두 여전히 used_key_parts를 room_id, start_at까지만 사용하고 있었고, 놀라웠던 점이 추정 반환은 49913건, 실제 반환은 44건 이었습니다. 옵티마이저를 이용한 추정치는 생각보다 정확하지 않다는 것을 확인하였습니다. 왜 대략 추정 반환이 5만 건일까 하고 데이터를 새로 2만 건을 하고 EXPLAIN 해봤습니다. 이번에는 1만 건 추정 반환이 나왔습니다. 다르게 해도 1/2로 계속 나왔습니다.
이 부분은 정확한 원인을 아직은 모르겠어서 더 공부해보겠습니다.

정확히 락 걸린 것을 확인하기 위해 performance_schema.data_locks를 직접 확인해보았습니다. 그 결과 ix_room_start_end 인덱스에서 room_id = 1이고 start_at < 10:30에 해당하는 범위 전반에 걸쳐 record lock이 관찰되었고, 여러 leaf page의 끝을 의미하는 supremum pseudo-record도 함께 확인할 수 있었습니다. 이를 통해 단순히 결과로 반환된 row들만 잠기는 것이 아니라, 실제로는 훨씬 넓은 인덱스 범위가 잠금 영향 범위에 들어간다는 점을 확인할 수 있었습니다.

또 하나 흥미로웠던 점은 room_id = 2로 insert를 시도했음에도 lock wait이 발생한 경우가 있었다는 점입니다. 처음에는 room_id = 1 범위만 잠겼으니 room_id = 2는 영향이 없을 것이라고 생각했지만,
기존 room_id = 2의 첫 row보다 더 앞쪽에 삽입되는 값은 복합 인덱스 (room_id, start_at, end_at) 상에서 room_id = 1 범위의 다음 경계 gap에 들어가게 됩니다.

...(1, 2026-03-25 10:29, ...)
...(1, 2026-03-25 10:28, ...)
...(1, 2026-03-25 10:27, ...)
---- room_id=1 범위 끝 ---- <- 여기에도 gap lock이 걸림
(2, 2026-03-25 10:10, ...)
(2, 2026-03-25 10:25, ...)

즉 범위 기반 잠금은 특정 room_id 내부에서만 깔끔하게 끝나는 것이 아니라, 인덱스 정렬 순서상 다음 prefix의 시작 지점까지도 영향을 줄 수 있다는 점을 알 수 있었습니다.

테스트 정리
#

  • 범위 기반 락은 실행계획상 스캔한 인덱스 범위에 따라, 결과 row 수보다 훨씬 넓은 범위까지 잠금 영향이 퍼질 수 있었습니다.
  • EXPLAIN, EXPLAIN ANALYZE의 rows 값은 실제 잠금 개수가 아니라 옵티마이저의 추정치라는 점을 확인했습니다.
  • gap lock은 같은 복합 인덱스 내에서 다음 prefix의 첫 row 앞 경계 gap까지 영향을 줄 수 있었습니다.