한 줄 정의

잠금 이름·소유자·만료 시간을 담은 테이블 한 장과 for update 쿼리만으로, 여러 노드 중 하나의 스레드만 작업을 실행하게 만드는 분산 잠금 구현입니다.

쉽게 말하면

회의실 문 앞에 붙은 사용 중 팻말 을 떠올리면 됩니다. 여러 층(노드)에서 사람들이 같은 회의실(작업)을 쓰러 오는데 팻말은 하나뿐입니다.

팻말을 읽고 고치는 동안은 한 사람씩만 접근합니다(for update). 팻말이 비어 있으면 내 이름과 끝나는 시각을 적고 들어갑니다. 다른 사람 이름이 있고 끝나는 시각이 아직이면 돌아갑니다. 다른 사람 이름인데 시각이 지났으면 그 사람은 나오는 걸 잊었거나 쓰러진 것이니 내 이름으로 덮어씁니다. 내 이름이면 시각만 늘립니다. 다 쓰고 나오면 팻말을 지웁니다(unlock).

핵심은 팻말을 만지는 순간만 줄을 세우고, 회의실 사용 자체는 팻말에 적힌 이름과 시각으로 표현한다 는 점입니다. DB 행 잠금은 몇 밀리초만 잡고, 분산 잠금은 그 위에 데이터로 얹혀 몇 분을 갑니다. 끝나는 시각 덕분에 누가 쓰러져도 회의실이 영원히 잠기지 않습니다.

왜 중요한가?

다음 요구사항을 만족하는 기능이 필요한 상황입니다.

  • 애플리케이션이 1분 간격으로 작업을 실행합니다
  • 애플리케이션 프로세스는 여러 노드에서 실행됩니다
  • 동시에 여러 스레드가 작업을 실행하면 데이터에 문제가 생깁니다

즉 두 개 이상의 프로세스가 동시에 떠 있어도 그중 하나의 프로세스, 하나의 스레드만 작업을 실행해야 합니다. 프로세스 수준 잠금은 JVM 하나 안에서만 유효하므로 노드가 둘이 되는 순간 무력해지고, 프로세스 간 잠금인 분산 잠금 이 필요합니다.

레디스나 주키퍼(Zookeeper) 같은 기술을 쓸 수도 있지만, 이미 쓰고 있는 DB로 구현하면 별도 인프라 없이 구조를 단순하게 유지할 수 있습니다. 여기서 구현하는 것은 일정 시간 동안 잠금을 소유하는 방식 의 분산 잠금입니다.

핵심 내용

잠금 정보 저장 테이블

칼럼타입역할
namevarchar(100), PK개별 잠금을 구분하는 값
ownervarchar(100)잠금 소유자. 여러 스레드가 같은 이름의 잠금을 시도할 때 충돌을 판별
expirydatetime잠금 소유 만료 시간. 한 소유자가 잠금을 오래 붙들지 못하게 함
CREATE TABLE dist_lock
(
    name varchar(100) NOT NULL COMMENT '락 이름',
    owner varchar(100) COMMENT '락 소유자',
    expiry datetime COMMENT '락 만료 시간',
    primary key (name)
)

name이 PK이므로 같은 이름의 잠금 행은 하나만 존재할 수 있습니다. 이 제약이 동시 insert에 대한 마지막 안전망 역할을 합니다.

잠금 획득 절차

잠금이 필요한 스레드는 트랜잭션 안에서 잠금 행을 점유한 뒤, 소유자와 만료 시간에 따라 네 갈래로 나뉩니다.

flowchart TD
  S["트랜잭션 시작"] --> Q["select … for update 로 잠금 행 점유"]
  Q --> E{"행(소유자)이 있나?"}
  E -->|없음| I["insert: 내 owner, now + duration"] --> OK["획득 성공"]
  E -->|있음| O{"owner가 나인가?"}
  O -->|같음| U1["update: expiry만 연장"] --> OK
  O -->|다름| X{"expiry가 지났나?"}
  X -->|지남| U2["update: owner·expiry 교체"] --> OK
  X -->|안 지남| F["획득 실패"]
  OK --> C["commit"]
  C -.->|커밋 실패·예외| F

트랜잭션 커밋에 실패하면 잠금 획득도 실패입니다. 잠금 소유에 성공했을 때만 원하는 기능을 실행하고, 실패하면 실행하지 않도록 구현합니다.

구현 코드

LockOwner

잠금 소유자를 표현하는 타입입니다. 현재 소유자가 누구인지 비교하는 isOwnedBy() 와 만료 여부를 확인하는 isExpired() 만 있습니다.

public record LockOwner(String owner, LocalDateTime expiry) {
    public boolean isOwnedBy(String owner) {
        return this.owner.equals(owner);
    }
    public boolean isExpired() {
        return expiry.isBefore(LocalDateTime.now());
    }
}
DistLock 사용 방법

잠금 이름, 소유자, 소유 시간을 넘겨 시도하고, 성공했을 때만 작업을 실행하며 finally 에서 반드시 해제합니다.

DistLock lock = new DistLock(ds);
String owner = "owner1";
if (lock.tryLock("lockName", owner, Duration.ofMinutes(1))) {
    try {
        // … 코드 실행
    } finally {
        lock.unlock("lockName", owner);
    }
} else {
    // 잠금에 실패
}
tryLock()

앞의 절차를 그대로 코드로 옮긴 것입니다. 네 분기 중 세 개가 성공이고, “소유자가 다른데 아직 안 만료됨” 하나만 실패입니다.

public boolean tryLock(String name, String owner, Duration duration) {
    Connection conn = null;
    boolean owned;
    try {
        conn = dataSource.getConnection();
        conn.setAutoCommit(false);
        LockOwner lockOwner = getLockOwner(conn, name);     // select … for update
        if (lockOwner == null || lockOwner.owner() == null) {
            insertLockOwner(conn, name, owner, duration);    // 소유자 없음 → 소유 시도
            owned = true;
        } else if (lockOwner.isOwnedBy(owner)) {
            updateLockOwner(conn, name, owner, duration);    // 소유자 같음 → 만료 시간 연장
            owned = true;
        } else if (lockOwner.isExpired()) {
            updateLockOwner(conn, name, owner, duration);    // 소유자 다름 && 만료 → 소유자 교체
            owned = true;
        } else {
            owned = false;                                    // 소유자 다름 && 미만료 → 실패
        }
        conn.commit();
    } catch (Exception e) {
        owned = false;                                        // DB 연동 실패도 잠금 실패
        rollback(conn);
    } finally {
        close(conn);
    }
    return owned;
}

DB 연동 실패를 잠금 실패로 처리하는 catch가 중요합니다. 잠금 행이 없는 상태에서 두 트랜잭션이 동시에 insert하면 한쪽은 PK 중복 예외가 나는데, 이 예외가 곧 “잠금을 못 얻었다”는 뜻이 됩니다.

내부에서 쓰는 메서드는 전부 단순한 JDBC 쿼리입니다. 만료 시간은 LocalDateTime.now().plusSeconds(duration.getSeconds()) 로 애플리케이션 시각 기준으로 계산합니다.

메서드쿼리
getLockOwnerselect * from dist_lock where name = ? for update
insertLockOwnerinsert into dist_lock values (?, ?, ?)
updateLockOwnerupdate dist_lock set owner = ?, expiry = ? where name = ?
clearOwnerupdate dist_lock set owner = null, expiry = null where name = ?
핵심은 for update 한 줄

getLockOwner() 의 for update 가 이 구현 전체를 떠받칩니다. 한 번에 한 트랜잭션만 잠금 행을 조회할 수 있게 제한하므로, 분산 환경의 여러 노드가 잠금 데이터를 동시에 읽고 쓰는 것을 막습니다. 잠금이 두 층위로 나뉜다는 점을 이해하면 이 구현이 왜 성립하는지 보입니다.

DB 행 잠금 (for update)분산 잠금 (owner·expiry)
범위트랜잭션 하나모든 노드
수명tryLock·unlock 트랜잭션 동안 (밀리초)expiry 또는 unlock까지 (초~분)
역할잠금 데이터를 읽고 갱신하는 순간을 직렬화실제 작업을 실행할 권한
unlock()

명시적으로 해제하는 메서드입니다. 해제 전에 정말 내 잠금인지 를 다시 확인합니다.

public void unlock(String name, String owner) {
    // 트랜잭션 시작 후
    LockOwner lockOwner = getLockOwner(conn, name);            // select … for update
    if (lockOwner == null || !lockOwner.isOwnedBy(owner)) {
        throw new IllegalStateException("no lock owner");      // 내 잠금이 아님
    }
    if (lockOwner.isExpired()) {
        throw new IllegalStateException("lock is expired");    // 만료됨 → 이미 남의 것일 수 있음
    }
    clearOwner(conn, name);                                    // owner·expiry를 null로
    conn.commit();
    // SQLException 시 rollback 후 RuntimeException, finally에서 close
}

소유자 검사가 없으면 내 잠금이 만료되어 다른 노드가 가져간 뒤에 내가 unlock을 호출해 남의 잠금을 지우는 사고가 납니다. 만료된 잠금의 해제를 막는 것도 같은 이유입니다.

책 코드를 그대로 옮기면 unlock 뒤에 재획득이 안 됩니다

clearOwner() 는 행을 지우지 않고 owner·expiry를 null로 바꿉니다. 그런데 getLockOwner() 는 rs.getTimestamp("expiry").toLocalDateTime() 을 바로 호출하므로 expiry가 null인 행에서 NPE가 나고, tryLock() 의 catch가 이를 잠금 실패로 처리합니다. NPE를 막더라도 owner() == null 분기가 insert로 가서 이미 있는 행과 PK가 충돌합니다. 실제로 쓸 때는 owner가 null인 행을 update로 처리하거나 unlock에서 행을 delete해야 합니다. close() 가 setAutoCommit(false) 를 호출하는 것도 풀에 반납하기 전 true로 되돌리려던 의도로 보입니다.

비교 / 트레이드오프

DB 선점 잠금레디스 · 주키퍼
추가 인프라없음. 이미 쓰는 DB별도 구성과 운영 필요
구조테이블 한 장 + 코드 수십 줄전용 클라이언트와 알고리즘
적합한 상황1분 간격 배치처럼 잠금 시도가 드물 때잠금 시도가 잦고 트래픽이 많을 때

내 생각

  • expiry는 “오래 못 쓰게”보다 “죽은 소유자 복구” 장치입니다. 노드가 작업 중 죽으면 finally 의 unlock도 실행되지 않는데, expiry가 없으면 그 잠금은 영원히 남습니다. 그래서 duration은 “작업 최대 시간보다 넉넉하게, 그러나 장애 시 참을 만한 시간만큼”으로 잡아야 합니다.
  • 작업이 duration보다 오래 걸리면 두 노드가 동시에 실행됩니다. expiry가 지나는 순간 다른 노드가 소유자를 교체할 수 있으므로, 긴 작업은 중간에 tryLock을 다시 호출해 만료를 연장하거나(소유자 같으면 expiry만 갱신하는 분기가 이 용도) 작업 시간을 모니터링해야 합니다.
  • 만료 판정이 애플리케이션 서버 시각 기준입니다. getExpiry() 와 isExpired() 모두 LocalDateTime.now() 를 쓰므로 노드 간 시계가 어긋나면 잠금이 이르게 뺏기거나 늦게 풀립니다. 실무에서는 DB의 now() 로 만료를 계산해 기준 시계를 하나로 두는 편이 안전합니다.
  • MySQL에서는 PK 중복 대신 데드락으로 실패할 수 있습니다. InnoDB의 REPEATABLE READ에서 없는 행에 for update 를 걸면 갭 락이 잡히고, 두 트랜잭션이 갭 락을 나눠 가진 채 insert하면 서로를 기다리다 한쪽이 데드락으로 롤백됩니다. 어느 쪽이든 catch에서 실패로 처리되어 결과는 같지만, 로그에 데드락이 찍혀도 버그가 아닙니다.

관련 개념