tryLock과 시간 제한 락 획득
락이 이미 잡혀 있을 때 기다리지 않고 다른 길로 가거나 정한 시간만 기다리는 tryLock의 두 형태와, 획득에 성공했을 때만 unlock하는 올바른 사용법을 배웁니다.
lock()은 락을 얻을 때까지 기다립니다. 다른 스레드가 락을 1초 동안 쥐고 있으면 1초를 기다리고, 끝내 놓지 않으면 끝없이 기다립니다. 앞 글의 lockInterruptibly()를 쓰면 다른 스레드가 interrupt()로 이 대기를 끊어 줄 수는 있습니다. 하지만 기다리는 쪽이 스스로 '100ms까지만 기다리겠다'거나 '누가 쓰고 있으면 아예 기다리지 않겠다'고 정할 방법은 여전히 없습니다.
그런데 실제 프로그램에는 기다리지 않는 편이 나은 상황이 많습니다. 사용자가 새로 고침 버튼을 연달아 두 번 누르면, 두 번째 요청은 첫 번째 새로 고침이 끝나기를 기다릴 필요 없이 '이미 새로 고치는 중'이라고 알려 주면 됩니다. 응답 기한이 700ms인 요청이라면 락 하나를 기다리느라 그 시간을 다 써 버려서는 안 됩니다. ReentrantLock의 tryLock은 이런 경우를 위한 메서드입니다. 락을 얻지 못하면 기다리는 대신 false를 돌려주므로, '락을 얻지 못했다'는 사실을 무한 대기나 예외가 아닌 평범한 반환값으로 다룰 수 있습니다.
tryLock의 두 가지 형태
tryLock에는 인자가 없는 형태와 기다릴 시간을 받는 형태가 있습니다. 인자 없는 tryLock()은 호출한 순간 락이 비어 있는지 한 번만 확인합니다. 비어 있으면 락을 얻고 true를 돌려주며, 다른 스레드가 쥐고 있으면 기다리지 않고 바로 false를 돌려줍니다.
tryLock(long time, TimeUnit unit)은 time만큼 락이 풀리기를 기다립니다. 그 안에 얻으면 true, 시간이 다 지나면 false를 돌려주고, 기다리는 도중에 interrupt()를 받으면 InterruptedException을 던집니다. 시간 단위는 TimeUnit.MILLISECONDS처럼 TimeUnit 열거형으로 지정합니다. 이 글에서는 앞의 것을 즉시 tryLock, 뒤의 것을 시간 제한 tryLock이라고 부르겠습니다. 앞에서 배운 두 메서드와 나란히 놓으면 차이가 분명해집니다.
| 메서드 | 락이 비어 있으면 | 다른 스레드가 쥐고 있으면 | 기다리는 중 interrupt를 받으면 |
|---|---|---|---|
lock() | 얻는다 | 얻을 때까지 기다린다 | 무시하고 계속 기다린다 |
lockInterruptibly() | 얻는다 | 얻을 때까지 기다린다 | InterruptedException |
tryLock() | 얻고 true | 바로 false | 기다리지 않으므로 해당 없음 |
tryLock(time, unit) | 얻고 true | 최대 time까지 기다린 뒤 false | InterruptedException |
호출하기 전에 이미 interrupt 플래그가 켜져 있다면 어떨까요? lockInterruptibly()와 시간 제한 tryLock은 락이 비어 있어도 곧바로 InterruptedException을 던집니다. 반면 lock()과 즉시 tryLock()은 플래그를 확인하지 않으므로 평소처럼 락을 얻고, 플래그는 켜진 채로 남습니다. 또 플래그가 꺼져 있고 같은 스레드가 이미 쥐고 있는 락이라면, 두 tryLock 모두 재진입으로 보고 바로 true를 돌려주며 획득 횟수를 1 늘립니다. true를 받았다면 그 한 번에 대해 unlock()도 한 번 불러야 한다는 뜻입니다.
얻지 못했는데 unlock하면 생기는 일
tryLock은 결과를 반환값으로만 알려 줍니다. 그래서 그 값을 확인하지 않으면 락을 얻었는지 모르는 채로 다음 코드가 실행됩니다. 다음 예제는 이 실수를 일부러 재현합니다. short-holder 스레드가 락을 얻어 50ms 동안 쥐고 있는 사이에 main이 tryLock()을 호출하고, 결과와 상관없이 finally에서 unlock()을 부릅니다.
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.locks.ReentrantLock;
public final class UnlockWithoutAcquisitionFailure {
public static void main(String[] args) throws InterruptedException {
ReentrantLock lock = new ReentrantLock();
CountDownLatch acquired = new CountDownLatch(1);
Thread holder = new Thread(() -> {
lock.lock();
try {
acquired.countDown();
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
lock.unlock();
}
}, "short-holder");
holder.start();
acquired.await();
boolean mine = lock.tryLock();
try {
System.out.println("acquired=" + mine);
} finally {
lock.unlock(); // 잘못된 코드: 얻지 못했어도 해제한다
}
}
}acquired 래치는 holder가 락을 얻은 다음에 열리므로, main이 acquired.await()에서 돌아왔다면 holder는 이미 락을 쥐고 있습니다. holder는 그 뒤 50ms를 잔 다음 락을 놓습니다. 따라서 main이 곧바로 tryLock()을 부르면 대개 holder가 아직 락을 쥐고 있어서 false가 나옵니다. 실행하면 다음과 같이 출력됩니다.
acquired=false
Exception in thread "main" java.lang.IllegalMonitorStateException첫 줄은 tryLock()이 false를 돌려줬다는 뜻인데, 그런데도 try 블록은 실행됐습니다. 여기서는 출력 한 줄이지만, 이 자리가 계좌 잔액을 바꾸는 코드였다면 holder가 락 안에서 같은 잔액을 다루는 동안 락 없이 끼어든 셈입니다. 이어서 finally의 unlock()이 예외를 던집니다. ReentrantLock은 락을 쥔 스레드만 해제할 수 있고, 쥐지 않은 스레드가 unlock()을 부르면 IllegalMonitorStateException을 던지기 때문입니다. 이 예외는 비검사 예외라서 아무도 잡지 않으면 main 밖으로 빠져나가고, 둘째 줄 아래로 ReentrantLock.unlock을 거친 스택 트레이스가 이어진 뒤 프로그램이 종료 코드 1로 끝납니다.
다만 이 결과가 매번 나온다고 장담할 수는 없습니다. 50ms 잠은 main이 tryLock()을 부를 때까지 holder가 락을 쥐고 있게 만드는 장치가 아닙니다. 운영체제가 main 스레드를 50ms 넘게 실행하지 않고 미뤄 두면 그사이 holder가 락을 놓고, tryLock()은 true를 돌려줍니다. 그러면 acquired=true가 출력되고 unlock()도 자기 락을 정상적으로 해제하므로 프로그램은 아무 문제 없이 끝납니다.
이 글을 준비하면서 JDK 21로 796번 실행했을 때는 매번 앞의 예외가 났습니다. 그중 96번은 16개씩 동시에 띄워 CPU를 바쁘게 만든 상태였습니다. 하지만 tryLock() 바로 앞에 Thread.sleep(200)을 넣어 main을 일부러 늦추자 acquired=true를 출력하고 정상 종료했습니다. 같은 코드가 타이밍에 따라 예외로 끝나기도 하고 멀쩡히 끝나기도 한다는 점이 이 버그를 더 위험하게 만듭니다. 테스트를 몇 번 통과했다고 해서 안전한 코드라고 볼 수 없습니다.
획득 실패 뒤 unlock하면 소유권 예외가 난다
tryLock의 false/true에 따라 무조건 unlock의 예외/정상 결과가 갈립니다. sleep이 순서를 보장하지 않는 조건 아래 획득→해제 책임을 추적합니다.
UnlockWithoutAcquisitionFailure에서 실제 관측한 false와 IllegalMonitorStateException, 원문상 가능한 true 정상 종료를 구분합니다.
해제 조건 · 소유권
획득 실패 뒤 unlock하면 소유권 예외가 난다
false를 받은 시도는 락을 소유하지 않습니다. 무조건 unlock하면 예외가 나며, 지연되어 true를 받은 경로도 가능합니다.
acquired=false
- 그 시도의 소유권
- 시도 때 소유자가 아직 보유 · main은 미소유
- 무조건 unlock하는 원문의 결과
IllegalMonitorStateException· 이번 실행에서 관측
acquired=true
- 그 시도의 소유권
- 소유자가 먼저 해제 · main이 획득
- 무조건 unlock하는 원문의 결과
- 정상 해제 가능 · 이번 실행에서는 미관측
래치는 소유자가 한 번 획득했음을 알릴 뿐, main의 시도까지 보유를 유지하지 않습니다. main이 지연되어 소유자가 먼저 해제하면 두 번째 경로도 가능합니다.
이 예의 조건
- sleep 50은 main 시도까지 holder 보유를 보장하지 않습니다. false·true는 대안입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | sleep 50은 시도까지 보유를 고정하지 않아 false의 예외와 지연된 true의 정상 해제가 둘 다 가능합니다. 실제 소유한 획득분만 반납해야 합니다. |
| 4 | sleep 50은 보유 보장 아님 |
| 15 | 실선: 시도 결과에 따른 분기 · 거절한 획득에는 unlock 책임이 없음 |
| 16 | 같은 원래 코드라도 main이 늦으면 true가 가능 · 한 번의 예외를 고정 출력으로 해석하지 않음 |
| 구분 | 내용 |
|---|---|
| 설명 | sleep 50은 시도까지 보유를 고정하지 않아 false의 예외와 지연된 true의 정상 해제가 둘 다 가능합니다. 실제 소유한 획득분만 반납해야 합니다. |
| 전체 읽기 정보 | 획득 실패 뒤 unlock하면 소유권 예외가 난다 획득 실패 뒤 unlock하면 소유권 예외가 난다 UnlockWithoutAcquisitionFailure에서 실제 관측한 false와 IllegalMonitorStateException, 원문상 가능한 true 정상 종료를 구분합니다. 해제 조건 · 소유권 획득 실패 뒤 unlock하면 소유권 예외가 난다 false를 받은 시도는 락을 소유하지 않습니다. 무조건 unlock하면 예외가 나며, 지연되어 true를 받은 경로도 가능합니다. acquired=false 그 시도의 소유권 시도 때 소유자가 아직 보유 · main은 미소유 무조건 unlock하는 원문의 결과 IllegalMonitorStateException · 이번 실행에서 관측 acquired=true 그 시도의 소유권 소유자가 먼저 해제 · main이 획득 무조건 unlock하는 원문의 결과 정상 해제 가능 · 이번 실행에서는 미관측 래치는 소유자가 한 번 획득했음을 알릴 뿐, main의 시도까지 보유를 유지하지 않습니다. main이 지연되어 소유자가 먼저 해제하면 두 번째 경로도 가능합니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 공통 즉시 시도 main의 tryLock 시점과 holder 반납 시점이 갈립니다. |
|
|
| 무조건 반납 호출 원 코드가 두 결과 모두에서 unlock을 호출합니다. |
|
|
| 각 관찰 결과 미소유·소유 상태의 해제 결과를 비교합니다. |
|
|
획득 실패 뒤 unlock하면 소유권 예외가 난다
tryLock의 false/true에 따라 무조건 unlock의 예외/정상 결과가 갈립니다. sleep이 순서를 보장하지 않는 조건 아래 획득→해제 책임을 추적합니다.
UnlockWithoutAcquisitionFailure에서 실제 관측한 false와 IllegalMonitorStateException, 원문상 가능한 true 정상 종료를 구분합니다.
해제 조건 · 소유권
획득 실패 뒤 unlock하면 소유권 예외가 난다
false를 받은 시도는 락을 소유하지 않습니다. 무조건 unlock하면 예외가 나며, 지연되어 true를 받은 경로도 가능합니다.
acquired=false
- 그 시도의 소유권
- 시도 때 소유자가 아직 보유 · main은 미소유
- 무조건 unlock하는 원문의 결과
IllegalMonitorStateException· 이번 실행에서 관측
acquired=true
- 그 시도의 소유권
- 소유자가 먼저 해제 · main이 획득
- 무조건 unlock하는 원문의 결과
- 정상 해제 가능 · 이번 실행에서는 미관측
래치는 소유자가 한 번 획득했음을 알릴 뿐, main의 시도까지 보유를 유지하지 않습니다. main이 지연되어 소유자가 먼저 해제하면 두 번째 경로도 가능합니다.
이 예의 조건
- sleep 50은 main 시도까지 holder 보유를 보장하지 않습니다. false·true는 대안입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | sleep 50은 시도까지 보유를 고정하지 않아 false의 예외와 지연된 true의 정상 해제가 둘 다 가능합니다. 실제 소유한 획득분만 반납해야 합니다. |
| 4 | sleep 50은 보유 보장 아님 |
| 15 | 실선: 시도 결과에 따른 분기 · 거절한 획득에는 unlock 책임이 없음 |
| 16 | 같은 원래 코드라도 main이 늦으면 true가 가능 · 한 번의 예외를 고정 출력으로 해석하지 않음 |
| 구분 | 내용 |
|---|---|
| 설명 | sleep 50은 시도까지 보유를 고정하지 않아 false의 예외와 지연된 true의 정상 해제가 둘 다 가능합니다. 실제 소유한 획득분만 반납해야 합니다. |
| 전체 읽기 정보 | 획득 실패 뒤 unlock하면 소유권 예외가 난다 획득 실패 뒤 unlock하면 소유권 예외가 난다 UnlockWithoutAcquisitionFailure에서 실제 관측한 false와 IllegalMonitorStateException, 원문상 가능한 true 정상 종료를 구분합니다. 해제 조건 · 소유권 획득 실패 뒤 unlock하면 소유권 예외가 난다 false를 받은 시도는 락을 소유하지 않습니다. 무조건 unlock하면 예외가 나며, 지연되어 true를 받은 경로도 가능합니다. acquired=false 그 시도의 소유권 시도 때 소유자가 아직 보유 · main은 미소유 무조건 unlock하는 원문의 결과 IllegalMonitorStateException · 이번 실행에서 관측 acquired=true 그 시도의 소유권 소유자가 먼저 해제 · main이 획득 무조건 unlock하는 원문의 결과 정상 해제 가능 · 이번 실행에서는 미관측 래치는 소유자가 한 번 획득했음을 알릴 뿐, main의 시도까지 보유를 유지하지 않습니다. main이 지연되어 소유자가 먼저 해제하면 두 번째 경로도 가능합니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 공통 즉시 시도 main의 tryLock 시점과 holder 반납 시점이 갈립니다. |
|
|
| 무조건 반납 호출 원 코드가 두 결과 모두에서 unlock을 호출합니다. |
|
|
| 각 관찰 결과 미소유·소유 상태의 해제 결과를 비교합니다. |
|
|
성공했을 때만 try로 들어가기
고치는 원칙은 앞에서 본 lock()-try-finally 구조와 같습니다. 락을 얻는 데 성공한 다음에야 try를 열고 finally에서 해제합니다. 그러면 finally는 락을 쥐고 있을 때만 실행되고, 얻지 못한 경우는 try 밖의 다른 분기로 빠집니다. Lock 인터페이스의 API 문서도 tryLock()의 대표적인 사용법으로 다음 구조를 보여 줍니다.
if (lock.tryLock()) {
try {
// 락으로 보호하는 상태를 다룬다
} finally {
lock.unlock();
}
} else {
// 락을 얻지 못했을 때 할 일
}앞 예제의 main을 이 원칙대로 고치면 다음과 같습니다. 이번에는 false를 받으면 메시지만 출력하고 돌아가므로, 임계 영역에 들어가지도 않고 해제할 것도 없습니다.
boolean mine = lock.tryLock();
if (!mine) {
System.out.println("acquired=false");
return;
}
try {
System.out.println("acquired=true");
} finally {
lock.unlock();
}이렇게 고친 프로그램을 평소처럼 실행하면 acquired=false, main을 200ms 늦추면 acquired=true가 나오고 두 경우 모두 정상 종료합니다. 결과는 여전히 타이밍에 따라 갈리지만, 어느 쪽이든 락 없이 임계 영역에 들어가거나 남의 락을 해제하는 일은 생기지 않습니다.
바쁘면 바로 돌아오는 tryLock()
즉시 tryLock()이 어울리는 곳은 '지금 누가 하고 있으면 나는 하지 않아도 되는' 작업입니다. 새로 고침 요청, 앞의 실행과 겹치면 안 되는 주기 작업, 여유가 있을 때만 하는 캐시 정리가 그런 예입니다. 다음 예제의 refresh()는 락을 얻지 못하면 기다리지 않고 "busy"를 돌려줍니다.
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.locks.ReentrantLock;
public final class ImmediateTryLockService {
private final ReentrantLock lock = new ReentrantLock();
String refresh() {
if (!lock.tryLock()) return "busy";
try {
return "refreshed"; // 실제로는 여기서 데이터를 다시 읽어 온다
} finally {
lock.unlock();
}
}
public static void main(String[] args) throws InterruptedException {
ImmediateTryLockService service = new ImmediateTryLockService();
CountDownLatch held = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
Thread owner = new Thread(() -> {
service.lock.lock();
try {
held.countDown();
await(release);
} finally {
service.lock.unlock();
}
}, "refresh-owner");
owner.start();
held.await();
System.out.println("while-held=" + service.refresh());
release.countDown();
owner.join();
System.out.println("after-release=" + service.refresh());
}
private static void await(CountDownLatch latch) {
try {
latch.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}main은 두 래치로 실행 순서를 고정합니다. refresh-owner 스레드는 락을 얻은 뒤 held 래치를 열고, main이 release 래치를 열어 줄 때까지 락을 쥔 채 기다립니다. main은 held.await()로 owner가 락을 얻은 것을 확인한 뒤 첫 번째 refresh()를 부르고, 그다음에야 release를 열고 owner.join()으로 owner가 끝나기를 기다렸다가 두 번째 refresh()를 부릅니다. 람다로 만든 Runnable의 run()은 검사 예외를 던질 수 없으므로, owner 안에서는 await 도우미 메서드가 InterruptedException을 받아 interrupt 플래그를 복원합니다. 실행하면 다음 두 줄이 출력됩니다.
while-held=busy
after-release=refreshed첫 번째 refresh()가 실행될 때 owner는 release를 기다리며 락을 쥐고 있으므로 tryLock()은 false이고 결과는 busy입니다. 이 분기는 락을 얻지 않았으니 unlock()도 부르지 않습니다. 두 번째 호출 때는 owner가 이미 락을 놓고 종료했으므로 tryLock()이 성공하고, finally가 이번 호출이 얻은 락을 해제합니다. 첫 예제와 달리 이 출력은 실행할 때마다 같습니다. owner가 락을 놓는 시점을 sleep 시간이 아니라 main이 여는 release 래치가 정하기 때문입니다.
래치 해제 전과 소유자 종료 뒤에 한 번씩 시도한다
held 래치로 고정한 busy 시도 뒤 release·owner join을 거쳐 다음 tryLock 성공합니다. 실제 획득 조건 변화가 단계형에 적합합니다.
ImmediateTryLockService의 두 호출은 sleep 추정이 아니라 release 래치와 owner join으로 구분됩니다.
즉시 시도 · 전후 경계
래치 해제 전과 소유자 종료 뒤에 한 번씩 시도한다
release 래치 전에는 owner가 락을 유지합니다. 래치를 풀고 owner를 회수한 뒤 main이 다시 시도합니다.
실선: 래치·종료·시도의 필요 순서sleep50의 추정과 다른 고정 조건즉시거부가 무한재시도를 허용하진 않음
실선: 래치·종료·시도의 필요순서sleep50의 추정과 다른 고정조건즉시거부가 무한재시도를허용하진 않음
release 래치 해제 전
- 락을 가진 스레드
- owner가 락을 유지함 · main은 획득하지 못함
- 반환 출력
while-held=busy
래치 해제와 owner join 뒤
- 락을 가진 스레드
- main이 획득하고 finally에서 해제함
- 반환 출력
after-release=refreshed
busy는 즉시 거부 결과입니다. 이를 재시도하는 반복문은 이 예제에 없습니다.
이 예의 조건
- held와 release 래치가 두 시도의 보유 조건을 고정합니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | held/release 래치와 owner join은 보유 중의 busy와 반납 뒤의 refreshed를 구분합니다. 소유하지 않은 분기는 해제하지 않고 재시도 정책은 이 예제 밖입니다. |
| 4 | held 신호 후 release 대기 |
| 9 | main이 release 해제 |
| 10 | owner가 unlock 후 종료 |
| 13 | tryLock=true |
| 14 | refreshed |
| 17 | sleep 50의 추정과 다른 고정 조건 |
| 18 | 즉시 거부가 무한 재시도를 허용하진 않음 |
| 구분 | 내용 |
|---|---|
| 설명 | held/release 래치와 owner join은 보유 중의 busy와 반납 뒤의 refreshed를 구분합니다. 소유하지 않은 분기는 해제하지 않고 재시도 정책은 이 예제 밖입니다. |
| 전체 읽기 정보 | 래치 해제 전과 소유자 종료 뒤에 한 번씩 시도한다 래치 해제 전과 소유자 종료 뒤에 한 번씩 시도한다 ImmediateTryLockService의 두 호출은 sleep 추정이 아니라 release 래치와 owner join으로 구분됩니다. 즉시 시도 · 전후 경계 래치 해제 전과 소유자 종료 뒤에 한 번씩 시도한다 release 래치 전에는 owner가 락을 유지합니다. 래치를 풀고 owner를 회수한 뒤 main이 다시 시도합니다. release 래치 해제 전 락을 가진 스레드 owner가 락을 유지함 · main은 획득하지 못함 반환 출력 while-held=busy 래치 해제와 owner join 뒤 락을 가진 스레드 main이 획득하고 finally에서 해제함 반환 출력 after-release=refreshed busy는 즉시 거부 결과입니다. 이를 재시도하는 반복문은 이 예제에 없습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 보유 중 즉시 시도 owner가 획득 후 held를 알리고 release를 기다립니다. |
|
|
| 반납 회수 main이 release를 해제하고 owner.join을 끝냅니다. |
|
|
| 다음 즉시 시도 main이 tryLock에 성공하고 refresh를 수행합니다. |
|
|
래치 해제 전과 소유자 종료 뒤에 한 번씩 시도한다
held 래치로 고정한 busy 시도 뒤 release·owner join을 거쳐 다음 tryLock 성공합니다. 실제 획득 조건 변화가 단계형에 적합합니다.
ImmediateTryLockService의 두 호출은 sleep 추정이 아니라 release 래치와 owner join으로 구분됩니다.
즉시 시도 · 전후 경계
래치 해제 전과 소유자 종료 뒤에 한 번씩 시도한다
release 래치 전에는 owner가 락을 유지합니다. 래치를 풀고 owner를 회수한 뒤 main이 다시 시도합니다.
실선: 래치·종료·시도의 필요 순서sleep50의 추정과 다른 고정 조건즉시거부가 무한재시도를 허용하진 않음
실선: 래치·종료·시도의 필요순서sleep50의 추정과 다른 고정조건즉시거부가 무한재시도를허용하진 않음
release 래치 해제 전
- 락을 가진 스레드
- owner가 락을 유지함 · main은 획득하지 못함
- 반환 출력
while-held=busy
래치 해제와 owner join 뒤
- 락을 가진 스레드
- main이 획득하고 finally에서 해제함
- 반환 출력
after-release=refreshed
busy는 즉시 거부 결과입니다. 이를 재시도하는 반복문은 이 예제에 없습니다.
이 예의 조건
- held와 release 래치가 두 시도의 보유 조건을 고정합니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | held/release 래치와 owner join은 보유 중의 busy와 반납 뒤의 refreshed를 구분합니다. 소유하지 않은 분기는 해제하지 않고 재시도 정책은 이 예제 밖입니다. |
| 4 | held 신호 후 release 대기 |
| 9 | main이 release 해제 |
| 10 | owner가 unlock 후 종료 |
| 13 | tryLock=true |
| 14 | refreshed |
| 17 | sleep 50의 추정과 다른 고정 조건 |
| 18 | 즉시 거부가 무한 재시도를 허용하진 않음 |
| 구분 | 내용 |
|---|---|
| 설명 | held/release 래치와 owner join은 보유 중의 busy와 반납 뒤의 refreshed를 구분합니다. 소유하지 않은 분기는 해제하지 않고 재시도 정책은 이 예제 밖입니다. |
| 전체 읽기 정보 | 래치 해제 전과 소유자 종료 뒤에 한 번씩 시도한다 래치 해제 전과 소유자 종료 뒤에 한 번씩 시도한다 ImmediateTryLockService의 두 호출은 sleep 추정이 아니라 release 래치와 owner join으로 구분됩니다. 즉시 시도 · 전후 경계 래치 해제 전과 소유자 종료 뒤에 한 번씩 시도한다 release 래치 전에는 owner가 락을 유지합니다. 래치를 풀고 owner를 회수한 뒤 main이 다시 시도합니다. release 래치 해제 전 락을 가진 스레드 owner가 락을 유지함 · main은 획득하지 못함 반환 출력 while-held=busy 래치 해제와 owner join 뒤 락을 가진 스레드 main이 획득하고 finally에서 해제함 반환 출력 after-release=refreshed busy는 즉시 거부 결과입니다. 이를 재시도하는 반복문은 이 예제에 없습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 보유 중 즉시 시도 owner가 획득 후 held를 알리고 release를 기다립니다. |
|
|
| 반납 회수 main이 release를 해제하고 owner.join을 끝냅니다. |
|
|
| 다음 즉시 시도 main이 tryLock에 성공하고 refresh를 수행합니다. |
|
|
busy를 받은 호출자가 할 일
busy는 오류가 아니라 '지금은 다른 요청이 같은 일을 하고 있다'는 정상적인 결과입니다. 그래서 예외를 던질 필요는 없지만, 호출자가 이 결과를 받아 무엇을 할지는 정해 두어야 합니다. 새로 고침이라면 진행 중인 작업이 곧 최신 데이터를 가져올 테니 이번 요청은 버려도 됩니다. 사용자 요청이라면 잠시 후 다시 시도하라고 알릴 수 있고, 꼭 처리해야 하는 작업이라면 크기가 정해진 큐에 넣어 두었다가 나중에 처리할 수도 있습니다. 피해야 할 것은 busy를 받자마자 다시 시도하는 다음과 같은 반복문입니다.
while (!lock.tryLock()) {
// 아무것도 하지 않고 곧바로 다시 시도한다
}이 코드는 락이 풀릴 때까지 쉬지 않고 같은 확인을 되풀이합니다. 기다리는 동안에도 CPU를 계속 쓰기 때문에 이런 방식을 바쁜 대기(busy waiting)라고 부릅니다. 락을 쥔 스레드가 써야 할 CPU 시간을 빼앗을 수 있고, 여러 스레드가 함께 이렇게 돌면 경합도 커집니다. 기다릴 생각이라면 락이 풀릴 때까지 스레드를 대기 상태로 두는 lock()이나 시간 제한 tryLock을 처음부터 쓰는 편이 낫습니다. 꼭 다시 시도해야 한다면 횟수에 상한을 두고, 시도 사이의 간격을 점점 늘리는 백오프(backoff)를 넣습니다.
정한 시간만 기다리는 tryLock(time, unit)
시간 제한 tryLock의 결과는 세 가지입니다. 시간 안에 락을 얻으면 true, 시간이 다 지나면 false, 기다리는 중에 interrupt()를 받으면 InterruptedException입니다. 다음 예제는 이 세 결과를 각각 UPDATED, TIMED_OUT, INTERRUPTED로 바꿔 돌려주고, main에서 세 경우를 차례로 만들어 봅니다.
import java.time.Duration;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
public final class TimedTryLockOutcome {
private enum Result {
UPDATED,
TIMED_OUT,
INTERRUPTED
}
private final ReentrantLock lock = new ReentrantLock();
private int version;
Result update(Duration wait) {
boolean acquired = false;
try {
acquired = lock.tryLock(wait.toNanos(), TimeUnit.NANOSECONDS);
if (!acquired) return Result.TIMED_OUT;
version++;
return Result.UPDATED;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return Result.INTERRUPTED;
} finally {
if (acquired) lock.unlock();
}
}
public static void main(String[] args) throws InterruptedException {
TimedTryLockOutcome service = new TimedTryLockOutcome();
Duration wait = Duration.ofMillis(20);
// 1. 아무도 락을 쥐고 있지 않을 때
System.out.println("free: " + service.update(wait) + ", version=" + service.version);
// 2. 다른 스레드가 기다리는 시간 내내 락을 쥐고 있을 때
CountDownLatch held = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
Thread owner = new Thread(() -> {
service.lock.lock();
try {
held.countDown();
release.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
service.lock.unlock();
}
}, "owner");
owner.start();
held.await();
System.out.println("held: " + service.update(wait) + ", version=" + service.version);
release.countDown();
owner.join();
// 3. 호출하기 전에 이미 중단 요청을 받았을 때(락은 비어 있다)
Thread.currentThread().interrupt();
System.out.println("interrupted: " + service.update(wait) + ", version=" + service.version);
System.out.println("flag=" + Thread.currentThread().isInterrupted());
}
}update()는 앞 절의 refresh()와 구조가 다릅니다. tryLock(...)이 던지는 InterruptedException을 같은 메서드 안에서 잡으려고 tryLock 호출을 try 안에 넣었기 때문입니다. 이렇게 하면 락을 얻지 못한 경로에서도 finally가 실행되므로, 거기서 무조건 unlock()을 부르면 첫 예제와 같은 실수가 됩니다.
그래서 지역 변수 acquired에 이번 호출이 락을 얻었는지 기록해 두고, finally에서는 acquired가 true일 때만 해제합니다. acquired는 tryLock이 true를 돌려준 경우에만 true가 됩니다. 시간이 초과되면 false가 대입되고, 예외가 나면 대입 자체가 일어나지 않아 처음 값 false가 그대로 남습니다. 실행하면 다음과 같이 출력됩니다.
free: UPDATED, version=1
held: TIMED_OUT, version=1
interrupted: INTERRUPTED, version=1
flag=true첫 줄은 아무도 락을 쥐지 않은 경우입니다. tryLock이 기다릴 필요 없이 바로 락을 얻고, version이 0에서 1이 됩니다. 둘째 줄에서는 owner 스레드가 release 래치를 기다리며 락을 계속 쥐고 있으므로, update()는 20ms를 다 기다린 뒤 false를 받아 TIMED_OUT을 돌려줍니다. 락을 얻지 못했으니 version은 그대로 1이고, finally의 해제도 건너뜁니다.
셋째 줄은 main이 자기 자신에게 미리 interrupt()를 걸어 둔 경우입니다. owner는 이미 종료해서 락은 비어 있었지만, 시간 제한 tryLock은 들어오자마자 interrupt 플래그를 확인하고 InterruptedException을 던집니다. ReentrantLock의 API 문서에 따르면 이 메서드는 락을 얻거나 시간 초과를 알리는 것보다 interrupt에 응답하는 것을 우선합니다. 예외가 던져질 때 플래그는 지워지지만, catch에서 Thread.currentThread().interrupt()로 다시 켜 두었으므로 마지막 줄은 flag=true입니다.
시간 초과와 중단을 따로 돌려주는 이유
TIMED_OUT과 INTERRUPTED는 둘 다 락을 얻지 못했다는 점에서는 같아 보이지만, 호출자가 해야 할 일은 정반대입니다. TIMED_OUT은 지금 락이 바빴다는 뜻이므로 잠시 뒤 다시 시도하거나 다른 길로 갈 수 있습니다. INTERRUPTED는 누군가 이 스레드에게 하던 일을 멈추라고 요청했다는 뜻이므로, 다시 시도하지 말고 정리한 뒤 빠져나와야 합니다. 둘을 false 하나로 합쳐 버리면 호출자는 종료 요청을 받은 스레드까지 재시도하게 되고, 프로그램을 끄려 해도 작업이 계속 돌 수 있습니다.
catch에서 플래그를 복원하는 것도 같은 이유입니다. interrupt 플래그를 다룬 글에서 본 것처럼 InterruptedException이 던져지면 플래그가 지워지므로, 복원하지 않으면 더 바깥의 코드는 중단 요청이 있었다는 사실을 알 수 없습니다. 이 예제는 결과를 INTERRUPTED로 알리면서 플래그도 되살려, 결과를 보는 호출자와 플래그를 보는 상위 코드가 모두 요청을 알 수 있게 합니다.
기다릴 시간으로 Duration.ZERO나 음수를 넘기면 tryLock은 전혀 기다리지 않고 한 번만 시도합니다. 이 예제는 wait가 null인지 검사하지 않으므로 null을 넘기면 wait.toNanos()에서 NullPointerException이 납니다. 다른 코드가 호출하는 API로 공개한다면 이런 입력을 어떻게 처리할지 정하고 검사해야 하는데, 뒤에 나올 SafeBoundedPublisher가 그 예입니다.
더 깊이: isHeldByCurrentThread()로 해제 여부를 정하면 안 되는 이유
acquired 변수 대신 finally에서 lock.isHeldByCurrentThread()로 '지금 이 스레드가 락을 쥐고 있는가'를 물어보면 되지 않을까 싶을 수 있습니다. 다음 코드는 그 생각을 그대로 옮긴 것으로, acquired 변수를 없애고 마지막 finally에서 이 메서드의 결과로 해제 여부를 정합니다.
try {
if (!lock.tryLock(wait.toNanos(), TimeUnit.NANOSECONDS)) return Result.TIMED_OUT;
version++;
return Result.UPDATED;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return Result.INTERRUPTED;
} finally {
if (lock.isHeldByCurrentThread()) lock.unlock(); // 이번 호출이 얻은 락인지는 알 수 없다
}하지만 이 메서드는 이번 호출이 락을 얻었는지가 아니라, 이 스레드가 어떤 경로로든 락을 쥐고 있는지를 알려 줍니다. 차이는 재진입에서 드러납니다. 바깥 메서드가 이미 같은 락을 쥔 상태에서 안쪽 메서드의 시간 제한 tryLock이 interrupt 때문에 예외를 던지면, 안쪽은 락을 새로 얻지 못했는데도 isHeldByCurrentThread()는 true입니다. 그래서 안쪽의 finally가 바깥 메서드의 몫을 풀어 버립니다.
JDK 21에서 시험해 보면 획득 횟수가 1에서 0으로 떨어지고, 바깥 메서드는 락 없이 임계 영역을 계속 실행하다가 자기 finally의 unlock()에서 IllegalMonitorStateException을 만납니다. API 문서도 이 메서드를 주로 디버깅과 테스트에 쓰는 것으로 설명합니다. 해제 여부는 이번 호출이 얻었는지를 기록한 지역 변수나, 획득에 성공한 다음에만 여는 try 블록으로 정해야 합니다.
요청 전체의 기한 안에서 락 기다리기
시간 제한을 정할 때는 락 하나만 보지 말고 요청 전체의 기한을 봐야 합니다. 응답 기한이 700ms인 요청이 락에 500ms, DB 조회에 500ms, 외부 API 호출에 500ms를 각각 따로 준다면, 셋이 모두 한도까지 걸리는 최악의 경우 1500ms가 걸려 기한을 한참 넘깁니다. 그래서 단계마다 고정된 시간을 주는 대신, 요청을 받을 때 마감 시각을 한 번 정해 두고 각 단계에는 남은 시간만 넘깁니다.
Result handle(long deadline) throws InterruptedException {
long left = deadline - System.nanoTime(); // 남은 시간(나노초)
if (!lock.tryLock(left, TimeUnit.NANOSECONDS)) { // 0 이하이면 기다리지 않는다
return Result.TIMED_OUT;
}
try {
left = deadline - System.nanoTime(); // 락을 기다린 만큼 줄었다
if (left <= 0) return Result.TIMED_OUT;
return queryDatabase(Duration.ofNanos(left)); // DB 조회에도 남은 시간만 준다
} finally {
lock.unlock();
}
}deadline은 요청을 받을 때 System.nanoTime() + Duration.ofMillis(700).toNanos()처럼 한 번 계산해 둔 값입니다. System.nanoTime()은 현재 시각이 아니라 경과 시간을 재는 용도의 시계라서, 컴퓨터의 시계를 맞추느라 시각이 바뀌어도 영향을 받지 않습니다. 이 메서드는 InterruptedException을 호출자에게 그대로 던지므로 앞 절과 달리 tryLock을 try 밖에 둘 수 있고, 덕분에 acquired 변수 없이도 성공한 경우에만 해제합니다. 락을 얻은 뒤 남은 시간을 다시 계산하는 것은 락을 기다리느라 이미 시간을 썼기 때문입니다.
시간 제한 tryLock이 false를 돌려줬다는 것은 이 호출자가 기다리기를 그만뒀다는 뜻일 뿐입니다. 락을 쥔 스레드의 작업이 취소되는 것은 아니어서, 그 스레드는 하던 일을 끝까지 계속합니다. 사용자가 응답이 늦은 요청을 다시 보내는 일도 흔한데, 이때 락을 쥔 스레드가 바로 그 요청의 이전 시도를 처리하고 있을 수 있습니다. 그래서 다시 시도할 때는 같은 작업이 이미 끝났는지 확인하고 실행해야 같은 일이 두 번 일어나지 않습니다. 이렇게 여러 번 실행해도 한 번 실행한 것과 결과가 같도록 만드는 성질을 멱등성(idempotency)이라고 합니다.
획득 실패를 결과로 알리기
락 획득 결과를 호출자에게 돌려줄 때는 원인이 드러나게 나누는 것이 좋습니다. 예를 들어 BUSY는 다른 요청이 처리 중, TIMED_OUT은 기다릴 시간이 다 됨, INTERRUPTED는 취소 요청, REJECTED는 입력이 잘못됨으로 나누면 호출자가 결과마다 다른 정책을 고를 수 있습니다. false 하나만 돌려주면 호출자는 원인을 알 수 없고, 반대로 자주 일어나는 '바쁨'까지 예외로 던지면 정상적인 흐름에서도 예외와 로그가 쏟아집니다. 결과의 종류를 enum처럼 정해진 집합으로 두면 로그와 모니터링 지표에서도 다루기 쉽습니다. 지표의 레이블로 쓸 때 값의 종류가 끝없이 늘어나지 않기 때문입니다.
지표를 모은다면 락을 얻기까지 기다린 시간과 락을 쥐고 일한 시간을 따로 재 두세요. 대기 시간이 길면 경합이 문제이고, 쥐고 있는 시간이 길면 임계 영역 안의 작업이 문제라는 것을 구분할 수 있습니다.
다음 예제는 결과 상태와 함께 지금까지 게시한 횟수를 레코드로 돌려줍니다. 실행해 보면 문제가 없어 보이지만, 실패 분기에 숨은 결함이 있습니다.
import java.time.Duration;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
public final class BoundedPublisher {
private record Outcome(String status, int published) {}
private final ReentrantLock lock = new ReentrantLock();
private int published;
Outcome publish(Duration wait) {
boolean acquired = false;
try {
acquired = lock.tryLock(wait.toNanos(), TimeUnit.NANOSECONDS);
if (!acquired) return new Outcome("timed-out", published);
published++;
return new Outcome("published", published);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return new Outcome("interrupted", published);
} finally {
if (acquired) lock.unlock();
}
}
public static void main(String[] args) {
BoundedPublisher publisher = new BoundedPublisher();
System.out.println(publisher.publish(Duration.ofMillis(10)));
System.out.println(publisher.publish(Duration.ZERO));
}
}publish()의 구조는 앞의 update()와 같고, 상태 문자열과 published 값을 담은 Outcome 레코드를 돌려줍니다. main은 아무도 락을 쥐지 않은 상태에서 publish()를 두 번 부르므로, 다음과 같이 둘 다 성공합니다.
Outcome[status=published, published=1]
Outcome[status=published, published=2]두 번째 호출의 Duration.ZERO는 기다리지 않겠다는 뜻이지만, 락이 비어 있으니 바로 얻습니다. Outcome[...] 형식은 레코드가 자동으로 만들어 주는 toString()의 결과입니다. 이 main은 timed-out과 interrupted 분기를 한 번도 실행하지 않습니다.
미획득 결과의 카운터 읽기는 락으로 보호되지 않는다
성공 경로는 보호된 증가·반환, 실패 분기는 획득 없이 plain 카운터를 읽습니다. 분기별 획득 여부·갱신·읽기·반납을 단계로 설명하되 원 main은 성공만 실행함을 남깁니다.
BoundedPublisher의 실제 두 성공 출력과 timed-out/interrupted 소스 분기를 나눕니다. 실패 분기의 plain published 무잠금 조회 결함은 그대로 존재합니다.
결과와 결함 · 미획득 읽기
미획득 결과의 카운터 읽기는 락으로 보호되지 않는다
실패 상태를 반환해도 published를 읽는 경계가 안전해지는 것은 아닙니다. 미획득 두 분기의 카운터는 무잠금 조회입니다.
published
- 카운터 변경과 해제
- 획득 뒤 증가 · finally에서 unlock
- 원문과 실행의 범위
- main의 두 성공 결과:
published=1,published=2
timed-out
- 카운터 변경과 해제
- 증가와 unlock 없음
- 원문과 실행의 범위
- 미실행 분기 · published를 락 없이 읽어 반환
interrupted
- 카운터 변경과 해제
- 플래그 복원 · 증가와 unlock 없음
- 원문과 실행의 범위
- 미실행 분기 · published를 락 없이 읽어 반환
실패 결과의 published는 다른 스레드의 갱신을 동기화해 읽은 값이 아닙니다. 정확한 실패 스냅샷으로 해석할 수 없습니다. 성공 경로도 숫자 증가만 하며 실제 게시 I/O는 없습니다.
이 예의 조건
- 원 main은 성공 반환 1·2만 실행합니다. 시간 만료·중단은 미실행 조건입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 원래 성공 경로 1/2와 미실행인 두 실패 분기를 구분합니다. 실패의 plain published 읽기는 정확한 스냅샷이 아니며 본문의 별도 버전은 실패 카운터를 관측하지 않습니다. |
| 7 | 원래 main에서 1 뒤 2 |
| 13 | 획득 없이 published 반환 |
| 21 | 실선: 원래 코드의 분기와 반환 |
| 22 | 원래 main은 실패 두 분기 미실행 |
| 23 | 본문의 별도 버전은 실패 숫자를 안 읽음 |
| 구분 | 내용 |
|---|---|
| 설명 | 원래 성공 경로 1/2와 미실행인 두 실패 분기를 구분합니다. 실패의 plain published 읽기는 정확한 스냅샷이 아니며 본문의 별도 버전은 실패 카운터를 관측하지 않습니다. |
| 전체 읽기 정보 | 미획득 결과의 카운터 읽기는 락으로 보호되지 않는다 미획득 결과의 카운터 읽기는 락으로 보호되지 않는다 BoundedPublisher의 실제 두 성공 출력과 timed-out/interrupted 소스 분기를 나눕니다. 실패 분기의 plain published 무잠금 조회 결함은 그대로 존재합니다. 결과와 결함 · 미획득 읽기 미획득 결과의 카운터 읽기는 락으로 보호되지 않는다 실패 상태를 반환해도 published를 읽는 경계가 안전해지는 것은 아닙니다. 미획득 두 분기의 카운터는 무잠금 조회입니다. published 카운터 변경과 해제 획득 뒤 증가 · finally에서 unlock 원문과 실행의 범위 main의 두 성공 결과: published=1 , published=2 timed-out 카운터 변경과 해제 증가와 unlock 없음 원문과 실행의 범위 미실행 분기 · published를 락 없이 읽어 반환 interrupted 카운터 변경과 해제 플래그 복원 · 증가와 unlock 없음 원문과 실행의 범위 미실행 분기 · published를 락 없이 읽어 반환 실패 결과의 published는 다른 스레드의 갱신을 동기화해 읽은 값이 아닙니다. 정확한 실패 스냅샷으로 해석할 수 없습니다. 성공 경로도 숫자 증가만 하며 실제 게시 I/O는 없습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 공통 획득 시도 timed tryLock의 성공·false·중단 예외를 비교합니다. |
|
|
| 각 상태 접근 성공만 락 안에서 published를 증가시킵니다. |
|
|
| 반환과 해제 각 분기의 결과와 해제 책임을 함께 둡니다. |
|
|
미획득 결과의 카운터 읽기는 락으로 보호되지 않는다
성공 경로는 보호된 증가·반환, 실패 분기는 획득 없이 plain 카운터를 읽습니다. 분기별 획득 여부·갱신·읽기·반납을 단계로 설명하되 원 main은 성공만 실행함을 남깁니다.
BoundedPublisher의 실제 두 성공 출력과 timed-out/interrupted 소스 분기를 나눕니다. 실패 분기의 plain published 무잠금 조회 결함은 그대로 존재합니다.
결과와 결함 · 미획득 읽기
미획득 결과의 카운터 읽기는 락으로 보호되지 않는다
실패 상태를 반환해도 published를 읽는 경계가 안전해지는 것은 아닙니다. 미획득 두 분기의 카운터는 무잠금 조회입니다.
published
- 카운터 변경과 해제
- 획득 뒤 증가 · finally에서 unlock
- 원문과 실행의 범위
- main의 두 성공 결과:
published=1,published=2
timed-out
- 카운터 변경과 해제
- 증가와 unlock 없음
- 원문과 실행의 범위
- 미실행 분기 · published를 락 없이 읽어 반환
interrupted
- 카운터 변경과 해제
- 플래그 복원 · 증가와 unlock 없음
- 원문과 실행의 범위
- 미실행 분기 · published를 락 없이 읽어 반환
실패 결과의 published는 다른 스레드의 갱신을 동기화해 읽은 값이 아닙니다. 정확한 실패 스냅샷으로 해석할 수 없습니다. 성공 경로도 숫자 증가만 하며 실제 게시 I/O는 없습니다.
이 예의 조건
- 원 main은 성공 반환 1·2만 실행합니다. 시간 만료·중단은 미실행 조건입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 원래 성공 경로 1/2와 미실행인 두 실패 분기를 구분합니다. 실패의 plain published 읽기는 정확한 스냅샷이 아니며 본문의 별도 버전은 실패 카운터를 관측하지 않습니다. |
| 7 | 원래 main에서 1 뒤 2 |
| 13 | 획득 없이 published 반환 |
| 21 | 실선: 원래 코드의 분기와 반환 |
| 22 | 원래 main은 실패 두 분기 미실행 |
| 23 | 본문의 별도 버전은 실패 숫자를 안 읽음 |
| 구분 | 내용 |
|---|---|
| 설명 | 원래 성공 경로 1/2와 미실행인 두 실패 분기를 구분합니다. 실패의 plain published 읽기는 정확한 스냅샷이 아니며 본문의 별도 버전은 실패 카운터를 관측하지 않습니다. |
| 전체 읽기 정보 | 미획득 결과의 카운터 읽기는 락으로 보호되지 않는다 미획득 결과의 카운터 읽기는 락으로 보호되지 않는다 BoundedPublisher의 실제 두 성공 출력과 timed-out/interrupted 소스 분기를 나눕니다. 실패 분기의 plain published 무잠금 조회 결함은 그대로 존재합니다. 결과와 결함 · 미획득 읽기 미획득 결과의 카운터 읽기는 락으로 보호되지 않는다 실패 상태를 반환해도 published를 읽는 경계가 안전해지는 것은 아닙니다. 미획득 두 분기의 카운터는 무잠금 조회입니다. published 카운터 변경과 해제 획득 뒤 증가 · finally에서 unlock 원문과 실행의 범위 main의 두 성공 결과: published=1 , published=2 timed-out 카운터 변경과 해제 증가와 unlock 없음 원문과 실행의 범위 미실행 분기 · published를 락 없이 읽어 반환 interrupted 카운터 변경과 해제 플래그 복원 · 증가와 unlock 없음 원문과 실행의 범위 미실행 분기 · published를 락 없이 읽어 반환 실패 결과의 published는 다른 스레드의 갱신을 동기화해 읽은 값이 아닙니다. 정확한 실패 스냅샷으로 해석할 수 없습니다. 성공 경로도 숫자 증가만 하며 실제 게시 I/O는 없습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 공통 획득 시도 timed tryLock의 성공·false·중단 예외를 비교합니다. |
|
|
| 각 상태 접근 성공만 락 안에서 published를 증가시킵니다. |
|
|
| 반환과 해제 각 분기의 결과와 해제 책임을 함께 둡니다. |
|
|
실패 분기에서 카운터를 읽으면 안 되는 이유
결함은 실행되지 않은 두 실패 분기에 있습니다. timed-out과 interrupted 분기는 락을 얻지 못한 채 published 필드를 읽어 결과에 담습니다. 그런데 이 분기들은 하필 다른 스레드가 그 락을 쥐고 있을 때, 즉 published를 바꾸고 있을지도 모르는 바로 그때 실행됩니다.
Java 메모리 모델에서 락이 다른 스레드의 쓰기를 보이게 해 주는 것은 '한 스레드의 unlock은 그 뒤에 같은 락을 얻는 lock보다 먼저 일어난다(happens-before)'는 규칙 덕분입니다. Lock 인터페이스의 API 문서는 성공한 lock과 unlock이 synchronized의 모니터 진입·해제와 같은 메모리 효과를 내야 한다고 정하면서, 실패한 획득 시도에는 그런 효과가 필요하지 않다고 밝힙니다. 따라서 실패 분기의 읽기와 다른 스레드가 락 안에서 한 쓰기 사이에는 아무 순서 관계가 없습니다. 같은 변수에 접근하는 두 동작 가운데 하나 이상이 쓰기인데 둘 사이에 happens-before 순서가 없으면, 이를 데이터 경합(data race)이라고 합니다(JLS 17.4.5).
데이터 경합이 있는 읽기는 가장 최근 값을 읽는다는 보장이 없습니다. int 하나를 읽는 동작은 쪼개지지 않으므로 아무도 쓴 적 없는 값이 나오지는 않지만, 이미 지나간 옛 값이 나올 수 있습니다. 운 좋게 최신 값을 읽었더라도 락을 쥔 스레드가 바로 다음 순간 값을 바꿀 수 있으니, 실패한 시점의 정확한 개수라고 볼 수도 없습니다.
다음 SafeBoundedPublisher는 실패 분기에서 카운터를 아예 읽지 않습니다. 결과의 observedPublished를 Integer로 바꾸고, 락을 얻지 못한 경우에는 null을 담습니다. 여기서 null은 '0개'가 아니라 '이번 시도에서는 읽지 않았다'는 뜻입니다. 기다릴 시간이 null이거나 음수이면 IllegalArgumentException으로 거절하는 입력 검사도 넣었습니다.
import java.time.Duration;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
public final class SafeBoundedPublisher {
private record Outcome(String status, Integer observedPublished) {}
private final ReentrantLock lock = new ReentrantLock();
private int published;
Outcome publish(Duration wait) {
if (wait == null || wait.isNegative()) throw new IllegalArgumentException("wait");
boolean acquired = false;
try {
acquired = lock.tryLock(wait.toNanos(), TimeUnit.NANOSECONDS);
if (!acquired) return new Outcome("timed-out", null);
published++;
return new Outcome("published", published);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return new Outcome("interrupted", null);
} finally {
if (acquired) lock.unlock();
}
}
public static void main(String[] args) {
SafeBoundedPublisher publisher = new SafeBoundedPublisher();
System.out.println(publisher.publish(Duration.ofMillis(10)));
Thread.currentThread().interrupt();
System.out.println(publisher.publish(Duration.ZERO));
System.out.println("flag-restored=" + Thread.currentThread().isInterrupted());
}
}main은 첫 호출 뒤에 자기 자신에게 interrupt를 걸고 두 번째 호출을 합니다. 실행 결과는 다음과 같습니다.
Outcome[status=published, observedPublished=1]
Outcome[status=interrupted, observedPublished=null]
flag-restored=true첫 호출은 락을 얻어 카운터를 1로 올리고, 그 값을 락 안에서 읽어 돌려줍니다. 두 번째 호출은 Duration.ZERO로 기다리지 않는 호출이고 락도 비어 있었지만, 시간 제한 tryLock이 interrupt를 먼저 확인해 예외를 던집니다. 그래서 상태는 interrupted이고, 카운터는 읽지 않았으므로 null입니다. catch가 플래그를 복원했으므로 마지막 줄은 true이고, 이 main도 timed-out 분기는 실행하지 않습니다.
실패했을 때도 정확한 개수가 꼭 필요하다면, 락을 얻은 뒤에 값을 읽는 별도의 조회 메서드를 두고 그 조회가 얼마나 기다릴지도 따로 정해야 합니다. 또 이 예제의 published는 횟수를 세는 카운터일 뿐이라서, 값이 늘었다고 실제 게시가 끝났다는 뜻은 아닙니다.
더 깊이: tryLock()과 tryLock(0, TimeUnit.SECONDS)의 차이
기다릴 시간을 0으로 준 시간 제한 tryLock은 기다리지 않는다는 점에서 즉시 tryLock()과 비슷하지만, 두 가지가 다릅니다. 첫째는 공정성입니다. 공정 락을 다룬 글에서 본 것처럼 new ReentrantLock(true)로 만든 공정 락은 오래 기다린 스레드에게 먼저 락을 줍니다.
그런데 즉시 tryLock()은 공정 락에서도 이 순서를 따르지 않고, 락이 비어 있기만 하면 다른 스레드가 기다리고 있어도 바로 가져갑니다. ReentrantLock의 API 문서는 이를 끼어들기(barging)라고 부르며, 공정성은 깨지지만 쓸모 있는 경우도 있다고 설명합니다. 공정성을 지키고 싶다면 거의 같은 동작인 tryLock(0, TimeUnit.SECONDS)를 쓰라고 안내하는데, 시간 제한 형태는 공정 락에서 다른 스레드가 기다리고 있으면 락이 비어 있어도 가져가지 않기 때문입니다.
둘째는 interrupt입니다. 앞에서 본 것처럼 시간 제한 형태는 들어오자마자 interrupt 플래그를 확인하지만, 즉시 tryLock()은 확인하지 않습니다. 다음 프로그램이 이 차이를 보여 줍니다.
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
public final class ZeroWaitComparison {
public static void main(String[] args) {
ReentrantLock lock = new ReentrantLock();
Thread.currentThread().interrupt(); // 중단 요청을 미리 받아 둔다
boolean immediate = lock.tryLock();
System.out.println("tryLock()=" + immediate
+ ", flag=" + Thread.currentThread().isInterrupted());
if (immediate) lock.unlock();
try {
boolean timed = lock.tryLock(0, TimeUnit.SECONDS);
System.out.println("tryLock(0)=" + timed);
if (timed) lock.unlock();
} catch (InterruptedException e) {
System.out.println("tryLock(0)=InterruptedException, flag="
+ Thread.currentThread().isInterrupted());
Thread.currentThread().interrupt();
}
}
}main은 interrupt 플래그를 먼저 켜 둔 채로 두 메서드를 차례로 부르고, 락은 내내 비어 있습니다. 출력은 다음과 같습니다.
tryLock()=true, flag=true
tryLock(0)=InterruptedException, flag=false플래그가 켜져 있어도 즉시 tryLock()은 비어 있는 락을 얻고, 플래그도 건드리지 않습니다. 반면 tryLock(0, TimeUnit.SECONDS)는 같은 상황에서 InterruptedException을 던지고 플래그를 지웁니다. 어느 형태를 쓰든 락의 공정성은 락을 요청한 순서에 관한 것일 뿐이며, 요청이 서버에 도착한 순서나 사용자가 버튼을 누른 순서까지 지켜 주지는 않습니다. 그런 순서가 필요하면 큐 같은 별도의 구조로 설계해야 합니다.
어떤 획득 방법을 고를까
락 API를 고를 때는 '기다리지 못하면 무엇을 할 것인가'에서 출발하면 됩니다. 지금까지 본 방법을 상황별로 정리하면 다음과 같습니다.
| 상황 | 알맞은 방법 |
|---|---|
| 임계 영역이 짧고 항상 기다려도 된다 | synchronized 또는 lock() |
| 기다리다가 취소 요청을 받으면 그만둬야 한다 | lockInterruptibly() |
| 누가 쓰고 있으면 기다리지 않고 다른 길로 간다 | tryLock() |
| 정해진 시간까지만 기다린다 | tryLock(time, unit) |
특별한 요구가 없다면 synchronized가 가장 단순합니다. 블록을 벗어나면 자동으로 해제되므로 finally를 빠뜨릴 걱정이 없습니다. ReentrantLock은 표의 아래 세 줄처럼 기다리는 방식을 제어해야 할 때 고릅니다. 공정 모드를 켤지는 오래 기다리기만 하는 스레드가 생기는 기아(starvation) 문제가 실제로 있는지, 처리량이 얼마나 줄어드는지 측정해 보고 따로 정합니다. 여러 락을 차례로 얻어야 하는 코드에서는 시간 제한 tryLock이 실패하면 이미 얻은 락을 모두 놓고 물러났다가 다시 시도하는 방식으로 교착 상태(deadlock)를 피하기도 합니다.
락이 아예 필요 없는 경우도 있습니다. 한 스레드가 바꾼 값을 다른 스레드가 보기만 하면 되는 종료 플래그 하나라면 19장 첫 글에서 본 volatile로 충분합니다. 다른 값과 함께 바뀔 일이 없는 카운터 하나라면 AtomicInteger 같은 원자 클래스가 더 간결합니다. 결국 출발점은 어떤 상태들을 함께 지켜야 하는지, 그리고 기다리지 못할 때 어떤 결과를 돌려줄지입니다. 그것이 정해지면 알맞은 도구는 자연히 따라옵니다.
정리
tryLock()은 락이 비어 있으면 얻고true를, 다른 스레드가 쥐고 있으면 기다리지 않고false를 돌려줍니다. 공정 락에서도 줄을 서지 않고, interrupt 플래그도 확인하지 않습니다.tryLock(time, unit)은 최대time까지 기다립니다. 결과는true,false(시간 초과),InterruptedException(중단 요청) 세 가지이며, interrupt 플래그가 켜져 있으면 락이 비어 있어도 예외를 던집니다.unlock()은 획득에 성공한 경우에만 부릅니다. 성공한 다음에try를 열거나, 이번 호출이 얻었는지를 지역 변수에 기록해finally에서 확인합니다. 쥐지 않은 락을 해제하면IllegalMonitorStateException이 납니다.- 시간 초과와 중단은 서로 다른 결과로 돌려주고,
InterruptedException을 잡았다면 플래그를 복원합니다. - 기다릴 시간은 요청 전체의 기한에서 남은 시간으로 정합니다.
busy를 받았다고 곧바로 다시 시도하는 반복문은 쓰지 않습니다. - 획득에 실패한 분기에서는 락이 지키는 상태를 읽지 않습니다. 실패한 시도는 다른 스레드의 쓰기를 보이게 해 주지 않습니다.
연습 문제
-
계산 결과를 보호하는 락을 최대 30ms 기다리는
load()메서드를 작성하세요. 락을 얻으면"fresh", 시간 안에 얻지 못하면"cached", 기다리는 중 interrupt를 받으면"cancelled"를 돌려주고, 획득에 성공한 경우에만 락을 해제해야 합니다. -
다음
save()에는 버그가 있습니다. 이 프로그램이 무엇을 출력할지 예상해 보세요. 또 다른 스레드가 락을 200ms 동안 쥐고 있을 때save()를 부르면 호출자가 무엇을 받을지 답하고, 코드를 고쳐 보세요.exercise/TimedSaveExercise.java import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.ReentrantLock; public final class TimedSaveExercise { private final ReentrantLock lock = new ReentrantLock(); private int count; String save() throws InterruptedException { try { if (!lock.tryLock(100, TimeUnit.MILLISECONDS)) return "timeout"; count++; return "saved"; } finally { lock.unlock(); } } public static void main(String[] args) { TimedSaveExercise service = new TimedSaveExercise(); Thread.currentThread().interrupt(); try { System.out.println(service.save()); } catch (InterruptedException e) { System.out.println("caller saw InterruptedException"); } catch (IllegalMonitorStateException e) { System.out.println("caller saw IllegalMonitorStateException, flag=" + Thread.currentThread().isInterrupted()); } } } -
다음 세 상황에 알맞은 획득 방법을 고르고 이유를 설명하세요.
- 매분 실행되는 정산 작업이 앞의 실행과 겹치면 이번 실행은 건너뛴다.
- 결제 요청 전체의 기한은 2초이고, 락은 남은 시간 안에서만 기다린다.
- 종료 요청이 오면 락을 기다리던 작업자가 바로 빠져나와야 하지만, 기다리는 시간에 상한은 없다.
해설 보기
1번은 tryLock(30, TimeUnit.MILLISECONDS)를 try 안에서 부르고, 이번 호출이 락을 얻었는지를 acquired 변수에 기록해 finally에서 확인합니다. InterruptedException을 잡는 catch에서는 플래그를 복원합니다.
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
public final class TimedCacheFallbackSolution {
private final ReentrantLock lock = new ReentrantLock();
String load() {
boolean acquired = false;
try {
acquired = lock.tryLock(30, TimeUnit.MILLISECONDS);
if (!acquired) return "cached";
return "fresh";
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return "cancelled";
} finally {
if (acquired) lock.unlock();
}
}
public static void main(String[] args) {
TimedCacheFallbackSolution cache = new TimedCacheFallbackSolution();
System.out.println(cache.load());
}
}이 main은 경합도 중단 요청도 없이 한 번 부르므로 fresh를 출력합니다. 호출자가 기다리는 30ms 내내 다른 스레드가 락을 쥐고 있으면 cached가, 미리 interrupt를 걸어 둔 스레드에서 부르면 cancelled가 나오고 플래그는 다시 켜집니다. 30ms는 락을 기다리는 시간의 상한일 뿐, load() 전체가 30ms 안에 끝난다는 뜻은 아닙니다. 락을 얻은 뒤의 계산이 오래 걸리면 그만큼 더 걸립니다.
2번의 출력은 다음과 같습니다.
caller saw IllegalMonitorStateException, flag=falsemain이 미리 interrupt를 걸어 두었으므로 tryLock은 들어오자마자 InterruptedException을 던지고 플래그를 지웁니다. 그런데 tryLock이 try 안에 있으니 finally가 실행되고, 락을 쥐지 않은 채 부른 unlock()이 IllegalMonitorStateException을 던집니다. finally를 다룬 글에서 본 것처럼 finally에서 새 예외가 나면 전파되던 원래 예외를 덮어씁니다. 그래서 호출자는 InterruptedException 대신 IllegalMonitorStateException을 받고, 플래그를 복원한 코드도 없으니 중단 요청은 흔적도 없이 사라집니다.
다른 스레드가 락을 200ms 동안 쥐고 있을 때도 마찬가지입니다. tryLock이 100ms 뒤 false를 돌려줘 return "timeout"이 실행되지만, 메서드가 끝나기 전에 finally의 unlock()이 예외를 던져 반환값을 덮어씁니다. 호출자는 "timeout" 대신 IllegalMonitorStateException을 받습니다. 고치려면 tryLock을 try 밖으로 옮겨, 락을 얻은 다음에만 try가 시작되게 합니다.
String save() throws InterruptedException {
if (!lock.tryLock(100, TimeUnit.MILLISECONDS)) return "timeout";
try {
count++;
return "saved";
} finally {
lock.unlock();
}
}이렇게 고치면 같은 main은 caller saw InterruptedException을 출력하고, 락이 바쁠 때는 "timeout"이 그대로 호출자에게 돌아갑니다. InterruptedException은 save()의 throws 절을 따라 호출자에게 전달되므로, 그 뒤의 처리는 호출자가 정합니다.
3번의 첫 번째 상황에는 즉시 tryLock()이 맞습니다. 앞의 실행이 아직 락을 쥐고 있으면 false를 받아 이번 실행을 건너뛰면 되므로 기다릴 이유가 없습니다. 두 번째 상황에는 시간 제한 tryLock을 쓰되, 고정된 2초가 아니라 요청을 받을 때 정한 마감 시각에서 지금까지 쓴 시간을 뺀 나머지를 넘깁니다. 세 번째 상황은 시간 제한 없이 기다리되 interrupt에는 응답해야 하므로 lockInterruptibly()가 맞습니다. lock()은 interrupt를 받아도 계속 기다리기 때문에 종료 요청에 응답하지 못합니다.
다음 글
이것으로 19장을 마칩니다. 다음 장에서는 생산자·소비자 문제와 조건 대기를 다룹니다. 락을 얻은 다음에도 '큐에 빈자리가 생길 때까지'처럼 어떤 조건이 맞을 때까지 기다려야 하는 경우가 있습니다. 이때 락을 쥔 채 잠들면 어떤 일이 생기는지, 그리고 조건을 올바르게 기다리는 방법이 무엇인지 살펴봅니다.