한 줄 정의
잠금 이름·소유자·만료 시간을 담은 테이블 한 장과
for update쿼리만으로, 여러 노드 중 하나의 스레드만 작업을 실행하게 만드는 분산 잠금 구현입니다.
쉽게 말하면
회의실 문 앞에 붙은 사용 중 팻말 을 떠올리면 됩니다. 여러 층(노드)에서 사람들이 같은 회의실(작업)을 쓰러 오는데 팻말은 하나뿐입니다.
팻말을 읽고 고치는 동안은 한 사람씩만 접근합니다(for update). 팻말이 비어 있으면 내 이름과 끝나는 시각을 적고 들어갑니다. 다른 사람 이름이 있고 끝나는 시각이 아직이면 돌아갑니다. 다른 사람 이름인데 시각이 지났으면 그 사람은 나오는 걸 잊었거나 쓰러진 것이니 내 이름으로 덮어씁니다. 내 이름이면 시각만 늘립니다. 다 쓰고 나오면 팻말을 지웁니다(unlock).
핵심은 팻말을 만지는 순간만 줄을 세우고, 회의실 사용 자체는 팻말에 적힌 이름과 시각으로 표현한다 는 점입니다. DB 행 잠금은 몇 밀리초만 잡고, 분산 잠금은 그 위에 데이터로 얹혀 몇 분을 갑니다. 끝나는 시각 덕분에 누가 쓰러져도 회의실이 영원히 잠기지 않습니다.
왜 중요한가?
다음 요구사항을 만족하는 기능이 필요한 상황입니다.
- 애플리케이션이 1분 간격으로 작업을 실행합니다
- 애플리케이션 프로세스는 여러 노드에서 실행됩니다
- 동시에 여러 스레드가 작업을 실행하면 데이터에 문제가 생깁니다
즉 두 개 이상의 프로세스가 동시에 떠 있어도 그중 하나의 프로세스, 하나의 스레드만 작업을 실행해야 합니다. 프로세스 수준 잠금은 JVM 하나 안에서만 유효하므로 노드가 둘이 되는 순간 무력해지고, 프로세스 간 잠금인 분산 잠금 이 필요합니다.
레디스나 주키퍼(Zookeeper) 같은 기술을 쓸 수도 있지만, 이미 쓰고 있는 DB로 구현하면 별도 인프라 없이 구조를 단순하게 유지할 수 있습니다. 여기서 구현하는 것은 일정 시간 동안 잠금을 소유하는 방식 의 분산 잠금입니다.
핵심 내용
잠금 정보 저장 테이블
| 칼럼 | 타입 | 역할 |
|---|---|---|
| name | varchar(100), PK | 개별 잠금을 구분하는 값 |
| owner | varchar(100) | 잠금 소유자. 여러 스레드가 같은 이름의 잠금을 시도할 때 충돌을 판별 |
| expiry | datetime | 잠금 소유 만료 시간. 한 소유자가 잠금을 오래 붙들지 못하게 함 |
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()) 로 애플리케이션 시각 기준으로 계산합니다.
| 메서드 | 쿼리 |
|---|---|
| getLockOwner | select * from dist_lock where name = ? for update |
| insertLockOwner | insert into dist_lock values (?, ?, ?) |
| updateLockOwner | update dist_lock set owner = ?, expiry = ? where name = ? |
| clearOwner | update 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에서 실패로 처리되어 결과는 같지만, 로그에 데드락이 찍혀도 버그가 아닙니다.
관련 개념
- Ch06 동시성, 데이터가 꼬이기 전에 잡아야 한다 — 선점 잠금(
for update)과 분산 잠금 개념, 잠금 사용 시 주의 사항 - Ch05-3 InnoDB 스토리지 엔진 잠금 — 없는 행에
for update를 걸 때 잡히는 갭 락 (Real MySQL 8)