안동민 개발노트

본문 시작

tryLock과 획득 실패

획득 실패 뒤 무조건 unlock하는 예외를 재현하고 즉시·시간 제한 tryLock의 바쁜 대기, 제한 시간, interrupt 결과를 설계합니다.

tryLock은 락을 무한히 기다리지 않고 용량 부족을 정상적인 업무 결과로 다룰 수 있게 합니다.

즉시 형태는 지금 비어 있는지 한 번 보고, 시간 제한 형태는 정한 예산 안에서 기다립니다.

반환값을 무시하면 임계 영역을 락 없이 실행하거나 소유하지 않은 락을 해제하게 됩니다.


잠금 획득 결과와 해제 조건

다음 예제는 획득 결과와 관계없이 unlock하는 잘못된 구조입니다.

lab/UnlockWithoutAcquisitionFailure.java
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();
        }
    }
}
획득 실패 뒤 unlock하면 소유권 예외가 난다

UnlockWithoutAcquisitionFailure에서 실제 관측한 false와 IllegalMonitorStateException, 원문상 가능한 true 정상 종료를 구분합니다.

획득 실패 뒤 unlock하면 소유권 예외가 난다
main의 획득 결과그 시도의 소유권무조건 unlock하는 원문의 결과
acquired=false시도 때 소유자가 아직 보유 · main은 미소유IllegalMonitorStateException · 이번 실행에서 관측
acquired=true소유자가 먼저 해제 · main이 획득정상 해제 가능 · 이번 실행에서는 미관측
acquired=false
그 시도의 소유권: 시도 때 소유자가 아직 보유 · main은 미소유
무조건 unlock하는 원문의 결과: IllegalMonitorStateException · 이번 실행에서 관측
acquired=true
그 시도의 소유권: 소유자가 먼저 해제 · main이 획득
무조건 unlock하는 원문의 결과: 정상 해제 가능 · 이번 실행에서는 미관측

래치는 소유자가 한 번 획득했음을 알릴 뿐, main의 시도까지 보유를 유지하지 않습니다. main이 지연되어 소유자가 먼저 해제하면 두 번째 경로도 가능합니다.

획득에 성공한 분기 안에 해제 책임을 묶어야 합니다.


tryLock의 호출자 역압력

대기할 수 없는 UI 요청, 중복 실행 방지, best-effort maintenance는 바쁨 결과를 바로 반환할 수 있습니다.

실패가 예외일 필요는 없지만 drop, 재시도, 큐 중 어떤 정책인지 알려야 합니다.

src/ImmediateTryLockService.java
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), 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();
        }
    }
}
래치 해제 전과 소유자 종료 뒤에 한 번씩 시도한다

ImmediateTryLockService의 두 호출은 sleep 추정이 아니라 release 래치와 owner join으로 구분됩니다.

래치 해제 전과 소유자 종료 뒤에 한 번씩 시도한다
호출 시점락을 가진 스레드반환 출력
release 래치 해제 전owner가 락을 유지함 · main은 획득하지 못함while-held=busy
래치 해제와 owner join 뒤main이 획득하고 finally에서 해제함after-release=refreshed
release 래치 해제 전
락을 가진 스레드: owner가 락을 유지함 · main은 획득하지 못함
반환 출력: while-held=busy
래치 해제와 owner join 뒤
락을 가진 스레드: main이 획득하고 finally에서 해제함
반환 출력: after-release=refreshed

busy는 즉시 거부 결과입니다. 이를 재시도하는 반복문은 이 예제에 없습니다.

호출자가 바쁨 결과를 받고 무제한으로 재시도하면 CPU와 경합을 늘리므로 상한이 있는 큐, 백오프, 사용자 재시도 안내 가운데 하나를 고릅니다.


시간 제한 tryLock 결과

tryLock(time, unit)은 예산 안에 획득하면 true, 시간이 지나면 false, 대기 중 interrupt면 InterruptedException입니다.

false와 예외를 같은 실패 코드로 합치면 재시도 정책과 shutdown 관찰이 흐려집니다.

src/TimedTryLockOutcome.java
import java.time.Duration;
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) {
        TimedTryLockOutcome service = new TimedTryLockOutcome();
        System.out.println(
                "result=" + service.update(Duration.ofMillis(20)) + ", version=" + service.version);
    }
}

제시한 main은 경합과 중단 요청 없이 실행해 result=UPDATED, version=1을 출력합니다. TIMED_OUT과 INTERRUPTED 분기는 실행하지 않습니다.

획득됨 boolean은 finally에서 소유 여부를 정확히 반영합니다.

Duration 입력 검증은 이 예제에 없으므로 공개 API로 확장할 때 별도로 정의해야 합니다.


전체 요청 기한과 락 예산

락에 500ms, DB에 500ms, 네트워크에 500ms를 각각 독립으로 주면 총 요청 예산 700ms를 넘습니다.

상위 기한에서 남은 시간을 계산해 획득에 전달하고, 락을 얻은 뒤 하위 수집기에도 남은 예산만 줍니다.

시간 제한 tryLock이 false를 반환하면 호출자는 미획득 분기를 실행합니다.

락 소유자의 작업은 계속 진행합니다.

시간 제한 호출자가 같은 업무를 재시도할 때 previous attempt가 커밋되지 않았는지 멱등성을 확인합니다.

공정 락에서 즉시 tryLock은 대기 큐를 건너뛸 수 있습니다.

공정성이 요구라면 시간 제한 형태의 규칙을 확인하고 큐 기반 요청 순서를 별도로 설계합니다.

락 API만으로 end-to-end 사용자 순서를 주장하지 않습니다.


락 API 선택 기준

짧고 항상 기다려도 되는 임계 영역은 synchronized가 단순합니다.

중단 가능 무한 대기는 lockInterruptibly, 즉시 포기는 tryLock, 제한된 기다림은 시간 제한 tryLock입니다.

공정 모드는 기아 위험과 처리량을 측정한 뒤 별도로 정합니다.

가시성만 필요한 단일 stop 플래그는 volatile이 맞고, 단일 독립 카운터는 원자 타입이 간결할 수 있습니다.

도구 이름이 아니라 보호하는 불변식과 대기 결과에서 출발합니다.


관찰 가능한 실패 결과

BUSY는 동시 처리 중, TIMED_OUT은 예산 소진, INTERRUPTED는 취소 요청, REJECTED는 입력 정책 위반처럼 나눕니다.

지표 레이블이 너무 세밀해 원소 수가 폭증하지 않게 제한된 enum을 사용합니다.

락 wait 기간 히스토그램과 소유자 critical 기간을 별도로 측정하면 병목 위치를 찾기 쉽습니다.

예외를 정상 바쁜 대기 흐름에 남발하면 로그 잡음이 커질 수 있습니다.

반대로 false만 반환하면 호출자가 원인을 버릴 수 있으므로 도메인 결과 타입을 둡니다.

다음 예제는 상태 문자열과 카운터를 함께 반환합니다.

app/BoundedPublisher.java
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));
    }
}
미획득 결과의 카운터 읽기는 락으로 보호되지 않는다

BoundedPublisher의 실제 두 성공 출력과 timed-out/interrupted 소스 분기를 나눕니다. 실패 분기의 plain published 무잠금 조회 결함은 그대로 존재합니다.

미획득 결과의 카운터 읽기는 락으로 보호되지 않는다
반환 상태카운터 변경과 해제원문과 실행의 범위
published획득 뒤 증가 · finally에서 unlockmain의 두 성공 결과: published=1, published=2
timed-out증가와 unlock 없음미실행 분기 · published를 락 없이 읽어 반환
interrupted플래그 복원 · 증가와 unlock 없음미실행 분기 · published를 락 없이 읽어 반환
published
카운터 변경과 해제: 획득 뒤 증가 · finally에서 unlock
원문과 실행의 범위: main의 두 성공 결과: published=1, published=2
timed-out
카운터 변경과 해제: 증가와 unlock 없음
원문과 실행의 범위: 미실행 분기 · published를 락 없이 읽어 반환
interrupted
카운터 변경과 해제: 플래그 복원 · 증가와 unlock 없음
원문과 실행의 범위: 미실행 분기 · published를 락 없이 읽어 반환

실패 결과의 published는 다른 스레드의 갱신을 동기화해 읽은 값이 아닙니다. 정확한 실패 스냅샷으로 해석할 수 없습니다. 성공 경로도 숫자 증가만 하며 실제 게시 I/O는 없습니다.

Duration.ZERO 시간 제한 형태는 immediate 시도와 비슷하지만 중단 가능·공정성 세부 규칙이 다를 수 있어 API 문서를 기준으로 합니다.


연습 문제

주요 computation 락을 최대 30ms 기다리고 얻으면 fresh, 얻지 못하면 cached, interrupt면 cancelled를 반환하는 메서드를 작성하세요.

성공한 획득만 해제해야 합니다.

정답과 세 결과

획득됨 변수를 finally에서 확인하고 InterruptedException catch에서 플래그를 복원합니다.

exercise/TimedCacheFallbackSolution.java
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, 중단 가능 획득이 InterruptedException으로 끝나면 cancelled입니다. 제시한 main에서는 두 분기를 실행하지 않습니다.

tryLock은 “락을 얻지 못함”을 정상 제어 흐름으로 올려 줍니다.

반환값, 시간 제한, interrupt를 구분하고 획득됨인 경우에만 unlock하며 상위 기한과 대체 경로를 연결해야 무한 대기를 줄이는 목적을 달성합니다.