상세 컨텐츠

본문 제목

디비 락은 언제 걸리고 언제 기다리는가

Development/Database

by thisisnew 2026. 9. 1. 18:56

본문

반응형


분산락을 보다 보면 자연스럽게 다시 DB 락으로 돌아오게 된다. 결국 많은 동시성 문제는 데이터베이스 안의 한 줄, 혹은 특정 조건을 만족하는 데이터 묶음에서 터진다. Redis나 etcd 같은 외부 락을 쓰기 전에 DB가 이미 어떤 락을 걸고 있는지부터 알아야 한다.

처음에는 DB 락을 “성능을 느리게 만드는 것” 정도로만 생각하기 쉽다. 그런데 실제로는 조금 다르다. DB 락은 정합성을 지키기 위해 일부 요청을 기다리게 만드는 장치다. 문제는 락이 있다는 사실이 아니라, 내가 어떤 범위를 얼마나 오래 잠그고 있는지 모른다는 데서 자주 생긴다.

읽기는 항상 막히는 게 아니다

먼저 헷갈리는 부분부터 정리해야 한다. 트랜잭션이 있다고 해서 모든 읽기와 쓰기가 서로 막히는 건 아니다. PostgreSQL과 InnoDB는 모두 MVCC 기반으로 동작한다. 그래서 일반적인 SELECT는 보통 이미 커밋된 스냅샷을 읽고, 다른 트랜잭션의 row lock 때문에 무조건 막히지 않는다.

하지만 SELECT FOR UPDATE, UPDATE, DELETE처럼 실제로 같은 row를 바꾸거나 바꿀 준비를 하는 쿼리는 이야기가 달라진다. 같은 row를 두 트랜잭션이 동시에 수정하려 하면 하나는 기다려야 한다.

BEGIN;

SELECT *
FROM orders
WHERE id = 10
FOR UPDATE;

-- 여기서 주문 상태를 검사하고 변경한다.

COMMIT;

이 쿼리는 orders.id = 10 row를 잠근다. 다른 트랜잭션이 같은 주문을 수정하려고 하면 먼저 잡은 트랜잭션이 끝날 때까지 대기한다. 이게 DB 락의 가장 기본적인 감각이다.

PostgreSQL에서 row lock은 writer끼리 부딪힌다

PostgreSQL 문서에 따르면 row-level lock은 일반적인 데이터 조회를 막지 않고, 같은 row를 수정하거나 잠그려는 writer와 locker를 막는다. 그래서 “조회가 많아서 락이 걸렸다”보다는 “수정하려는 row가 겹쳤다” 쪽을 먼저 의심하는 게 맞다.

PostgreSQL에서 자주 보는 형태는 이런 것들이다.

  • FOR UPDATE: 조회한 row를 업데이트할 것처럼 잠근다.
  • FOR NO KEY UPDATE: row는 수정하지만 key 변경까지는 아닌 경우에 쓰인다.
  • FOR SHARE, FOR KEY SHARE: 더 약한 공유 성격의 row lock이다.
  • pg_locks: 현재 어떤 락이 잡혀 있는지 확인할 때 보는 시스템 뷰다.

여기서 중요한 건 트랜잭션의 길이다. FOR UPDATE 자체가 문제라기보다, 그 락을 잡은 상태로 외부 API를 호출하거나, 오래 걸리는 계산을 하거나, 사용자 응답을 기다리는 구조가 문제다. 락은 트랜잭션이 끝날 때 풀리므로 트랜잭션 안에서 하는 일이 길어질수록 다른 요청의 대기 시간도 길어진다.

MySQL InnoDB는 인덱스와 범위를 같이 봐야 한다

MySQL InnoDB를 볼 때는 row lock이라고만 생각하면 놓치는 게 생긴다. InnoDB 문서는 record lock, gap lock, next-key lock을 따로 설명한다. 특히 기본 격리 수준인 REPEATABLE READ에서는 phantom row를 막기 위해 next-key locking을 사용한다.

쉽게 말하면, InnoDB는 특정 row만 잠그는 게 아니라 “인덱스에서 검색한 범위”를 잠글 수 있다. 이때 인덱스를 잘 타지 못하면 생각보다 넓은 범위가 잠길 수 있다.

BEGIN;

SELECT *
FROM coupons
WHERE expires_at > NOW()
FOR UPDATE;

COMMIT;

이런 쿼리가 적절한 인덱스 없이 실행되면, 내가 의도한 것보다 훨씬 많은 레코드나 범위를 훑게 된다. InnoDB 문서에서도 UPDATE, DELETE, locking read는 처리 과정에서 스캔한 index record에 락을 잡는다고 설명한다. 즉, WHERE 조건만 볼 게 아니라 실제 실행 계획과 인덱스 사용 여부를 같이 봐야 한다.

Gap lock은 “없는 값”을 잠그는 느낌이다

InnoDB의 gap lock은 처음 보면 좀 낯설다. row가 아니라 index record 사이의 빈 공간을 잠그기 때문이다. 예를 들어 10과 20 사이에 값이 없더라도, 그 사이에 새 row가 들어오는 것을 막을 수 있다.

왜 이런 게 필요할까. 트랜잭션 안에서 “이 조건에 맞는 row가 없다”는 사실까지 안정적으로 유지하려면, 누군가 그 사이에 새 row를 끼워 넣는 것을 막아야 한다. 그래서 next-key lock은 record lock과 gap lock을 합친 형태로 phantom row를 줄이는 데 쓰인다.

실무에서 이 부분은 종종 예상 밖의 대기로 이어진다. 분명히 서로 다른 row를 건드리는 것처럼 보이는데 insert가 기다린다. 그럴 때는 “row가 같은가?”만 보지 말고 “같은 인덱스 범위나 gap을 건드리고 있는가?”를 봐야 한다.

Advisory lock은 DB 안에 둔 애플리케이션 락이다

PostgreSQL에는 advisory lock도 있다. 이건 특정 row나 table을 DB가 알아서 보호하는 락이라기보다, 애플리케이션이 의미를 정해서 쓰는 락이다. 예를 들어 user_id나 job_id를 숫자 키로 바꿔 “이 작업은 하나만 실행” 같은 규칙을 만들 수 있다.

SELECT pg_try_advisory_xact_lock(12345);

트랜잭션 레벨 advisory lock은 트랜잭션이 끝나면 같이 풀린다. 그래서 짧은 임계 구역에는 꽤 깔끔하다. 반대로 세션 레벨 advisory lock은 명시적으로 풀거나 세션이 끝날 때까지 남으므로, 커넥션 풀과 섞을 때 더 조심해야 한다.

다만 advisory lock은 이름 그대로 애플리케이션이 잘 지켜야 하는 락이다. 같은 규칙을 모든 코드 경로가 동일하게 따라야 의미가 있다. 어떤 코드는 advisory lock을 잡고 수정하고, 다른 코드는 그냥 수정한다면 보호가 깨진다.

Deadlock은 버그라기보다 설계 신호에 가깝다

DB 락을 다루다 보면 deadlock도 만난다. A 트랜잭션은 1번 row를 잡고 2번 row를 기다리고, B 트랜잭션은 2번 row를 잡고 1번 row를 기다리는 상황이다. 둘 다 서로를 기다리니 DB는 보통 하나를 실패시키고 나머지를 진행시킨다.

deadlock이 한 번도 없어야 한다고 생각하면 운영이 피곤해진다. 중요한 건 deadlock이 발생했을 때 재시도할 수 있는가, 그리고 락을 잡는 순서가 일관적인가다.

  • 여러 row를 잠글 때는 항상 같은 순서로 잠근다.
  • 트랜잭션 안에서는 외부 호출을 피한다.
  • 사용자 입력 대기나 긴 계산을 트랜잭션 밖으로 뺀다.
  • deadlock이나 serialization failure는 재시도 가능한 오류로 본다.

DB 락을 볼 때 내 기준

요즘은 DB 락 문제를 보면 아래 순서로 본다.

  • 어떤 쿼리가 락을 잡는가?
  • 그 쿼리가 어떤 인덱스를 타는가?
  • 락을 잡은 트랜잭션이 얼마나 오래 열려 있는가?
  • 같은 row를 기다리는 문제인가, 범위/gap을 기다리는 문제인가?
  • 정합성은 unique constraint나 상태 전이 조건으로 더 단순하게 보장할 수 없는가?
  • 실패했을 때 재시도해도 안전한 구조인가?

이렇게 보면 락 문제는 단순히 “DB가 느리다”가 아니다. 대부분은 쿼리, 인덱스, 트랜잭션 범위, 재시도 설계가 같이 얽혀 있다.

마무리

DB 락은 피해야만 하는 기능이 아니다. 데이터가 틀어지는 것보다 잠깐 기다리는 게 훨씬 나은 경우가 많다. 다만 락을 너무 넓게, 너무 오래 잡으면 그때부터는 정합성을 지키는 장치가 병목이 된다.

그래서 DB 락을 볼 때는 “락이 걸렸다”에서 멈추면 안 된다. 어떤 row인지, 어떤 index range인지, 어떤 트랜잭션이 오래 들고 있는지까지 봐야 한다. 특히 InnoDB에서는 gap lock과 next-key lock 때문에 “나는 한 줄만 건드렸다”고 생각한 작업이 실제로는 범위를 막고 있을 수 있다.

결국 DB 락은 데이터베이스가 나 대신 싸워주는 마지막 방어선이다. 고마운 기능이지만, 오래 붙잡고 있으면 주변 요청이 다 기다린다. 그래서 짧게 잡고, 좁게 잡고, 실패하면 다시 시도할 수 있게 만드는 게 핵심이다.

참고한 자료

반응형

댓글 영역