이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • Row lock, table lock, gap lock은 어떤 상황에서 문제가 되는가?
  • 인덱스가 lock 범위에 영향을 줄 수 있는 이유는 무엇인가?
  • MySQL InnoDB의 gap lock은 왜 백엔드 개발자가 알아야 하는가?

개요

Lock은 단일 종류가 아니다. DB는 행, 테이블, 인덱스 범위, 메타데이터 등 여러 수준에서 잠금을 사용한다. 백엔드 개발자는 모든 내부 모드를 외울 필요는 없지만, “내 쿼리가 생각보다 넓게 막을 수 있다”는 점은 이해해야 한다.

특히 MySQL InnoDB의 gap lock과 next-key lock은 범위 검색과 동시 insert에 영향을 줄 수 있다.

원리

Lock을 이해하는 원리는 “DB가 알아서 잠근다”가 아니라 “내 SQL이 어떤 조건과 인덱스를 통해 어떤 row, range, table, metadata를 얼마나 오래 막는가”를 추적하는 것이다. 같은 UPDATE라도 PK 단건 조건이면 좁게 끝날 수 있고, 인덱스 없는 범위 조건이면 많은 row와 gap을 오래 막을 수 있다.

백엔드 코드에서는 트랜잭션 경계, 쿼리 순서, 인덱스 유무, batch 크기, 외부 API 호출 위치가 lock 범위를 결정한다. 인프라/DBA가 lock wait를 보더라도, 어떤 기능 흐름이 그 lock을 만들었는지는 백엔드가 설명해야 한다.

Row Lock

Row lock은 특정 행 변경 충돌을 막는다.

SELECT id, stock
FROM products
WHERE id = 10
FOR UPDATE;

이 쿼리는 products.id = 10 행을 잠그고 이후 재고 변경을 안전하게 수행할 수 있게 한다.

UPDATE products
SET stock = stock - 1
WHERE id = 10
  AND stock > 0;

재고 차감처럼 단일 행 변경은 조건부 update 하나로 충분한 경우도 많다. FOR UPDATE로 읽고 update하는 방식보다 원자적 update가 더 짧고 단순할 수 있다.

FOR UPDATE를 쓰는 경우에도 반드시 같은 트랜잭션 안에서 빨리 변경하고 커밋해야 한다.

@Transactional
public void decreaseStock(Long productId) {
    Product product = productRepository.findByIdForUpdate(productId);
    product.decrease();
}

이 메서드 안에 외부 API 호출이나 긴 계산이 들어가면 해당 row를 잡은 채 다른 주문을 기다리게 만든다.

Table Lock

Table lock은 테이블 전체 수준의 작업에서 문제가 된다. DDL, 일부 대량 작업, 명시적 lock, 메타데이터 변경이 테이블 단위 대기를 만들 수 있다.

운영에서 대형 테이블에 DDL을 실행할 때 조심해야 하는 이유가 여기에 있다.

ALTER TABLE orders ADD COLUMN memo varchar(255);

DB 버전과 엔진에 따라 이 작업이 매우 빠를 수도 있고, 테이블 rewrite나 metadata lock으로 장애를 만들 수도 있다.

MySQL에서는 metadata lock 때문에 짧아 보이는 DDL이 뒤따르는 읽기/쓰기 요청을 막을 수 있다. PostgreSQL에서도 DDL 종류에 따라 lock 강도가 다르다. “컬럼 하나 추가”라는 기능 크기와 “운영 lock 영향”은 별개의 문제다.

Gap Lock과 Next-key Lock

MySQL InnoDB에서는 Repeatable Read에서 범위 조건이 gap lock이나 next-key lock을 만들 수 있다. 이것은 존재하는 행뿐 아니라 인덱스 값 사이의 간격에 대한 insert를 막을 수 있다.

SELECT *
FROM coupons
WHERE code BETWEEN 'A100' AND 'A200'
FOR UPDATE;

이 쿼리는 조건 범위의 기존 행뿐 아니라 해당 범위에 새 code가 insert되는 것을 막을 수 있다. 동시성 문제를 막는 데 도움이 되지만, 예상보다 넓은 대기를 만들 수 있다.

인덱스와 Lock 범위

인덱스가 없거나 부적절하면 DB가 더 많은 행과 범위를 검사해야 한다.

UPDATE orders
SET status = 'EXPIRED'
WHERE status = 'PENDING'
  AND expired_at < now();

이 쿼리가 인덱스 없이 대량 스캔하면 많은 행을 잠그고 오래 실행될 수 있다. 다음 인덱스가 후보가 될 수 있다.

CREATE INDEX idx_orders_status_expired
ON orders (status, expired_at);

인덱스는 성능뿐 아니라 잠금 시간과 범위를 줄이는 데 도움을 줄 수 있다.

하지만 인덱스가 항상 lock 문제를 해결하는 것은 아니다. 조건에 맞는 row가 매우 많거나, batch가 같은 hot row를 반복 갱신하거나, 트랜잭션이 외부 I/O 때문에 길면 인덱스가 있어도 wait가 생긴다. 인덱스는 잠금 후보를 좁히는 도구이지, 트랜잭션 설계를 대신하지 않는다.

실전 팁

  • FOR UPDATE는 정말 변경할 행에만 사용한다.
  • 범위 update/delete에는 조건을 지원하는 인덱스를 둔다.
  • 대량 update는 batch로 나눠 lock 시간을 줄인다.
  • MySQL에서는 gap lock과 metadata lock을 장애 원인 후보로 둔다.
  • DDL은 DB 버전별 lock 동작을 확인하고 실행한다.
  • lock wait 분석에는 blocking SQL뿐 아니라 그 SQL을 실행한 endpoint, batch, transaction 경계를 함께 본다.
  • FOR UPDATE는 “읽고 바로 바꿀 row”에만 쓰고, 단순 상세 조회나 화면 표시에는 쓰지 않는다.
  • 대형 DDL은 online DDL 가능 여부, lock timeout, replica lag, rollback 계획을 인프라/DBA와 사전에 맞춘다.

위험 신호!

  • 인덱스 없는 조건으로 UPDATE, DELETE를 실행한다.
  • FOR UPDATE를 단순 조회에 붙인다.
  • 운영 중 대형 테이블 DDL을 영향 분석 없이 실행한다.
  • MySQL에서 범위 조건 lock이 insert를 막는 상황을 모른다.
  • batch가 사용자 API와 같은 row 범위를 오래 잠근다.
  • FOR UPDATE 후 외부 API를 호출해 같은 row를 변경하려는 사용자 요청이 줄줄이 대기한다.
  • DDL이 짧을 것이라 생각하고 metadata/table lock 관측 없이 운영 시간에 실행한다.

확인 질문

  • Row lock은 어떤 문제를 막기 위한 것인가?
    • 같은 행을 동시에 변경해 데이터 정합성이 깨지는 것을 막기 위한 것이다.
  • 인덱스가 lock 범위에 영향을 줄 수 있는 이유는 무엇인가?
    • 조건에 맞는 행을 더 정확하고 빠르게 찾게 해 불필요하게 많은 행이나 범위를 읽고 잠그는 일을 줄일 수 있기 때문이다.
  • Gap lock을 알아야 하는 이유는 무엇인가?
    • MySQL InnoDB에서 범위 조건이 기존 행뿐 아니라 값 사이 간격의 insert까지 막아 예상치 못한 대기를 만들 수 있기 때문이다.
  • 인덱스가 있어도 lock wait가 생길 수 있는 이유는 무엇인가?
    • 인덱스는 잠금 후보를 줄일 수 있지만, 대상 row가 많거나 hot row 경합이 있거나 트랜잭션이 길면 lock 보유 시간이 길어져 대기가 생길 수 있기 때문이다.

참고 문서