본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
19장 : 가시성과 동기화

tryLock과 획득 실패

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

tryLock은 락을 무한히 기다리지 않고 용량 부족을 정상적인 업무 결과로 다룰 수 있게 합니다. 즉시 형태는 지금 비어 있는지 한 번 보고, 시간 제한 형태는 정한 예산 안에서 기다립니다. 반환값을 무시하면 임계 영역을 락 없이 실행하거나 소유하지 않은 락을 해제하게 됩니다.

잠금 획득 결과와 해제 조건

보관 객체가 락을 가진 동안 main의 즉시 tryLock은 false입니다. 그런데 finally에서 무조건 unlock해 실제 IllegalMonitorStateException이 발생합니다. 이 예제는 의도한 실행 오류입니다.

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();
        }
    }
}

올바른 구조는 false면 즉시 대체 경로를 반환하고, true 분기에서만 try-finally를 여는 것입니다. 소유 스레드가 아니면 다른 스레드의 락을 대신 풀 수 없습니다.

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();
        }
    }
}

첫 결과는 바쁜 대기, 두 번째는 refreshed입니다. 호출자가 바쁜 대기를 무제한 tight 재시도하면 CPU와 경합을 늘리므로 상한이 있는 큐, exponential 백오프, 사용자 재시도 안내 가운데 하나를 고릅니다.

시간 제한 tryLock 결과

tryLock(time, unit)은 예산 안에 획득하면 true, 시간이 지나면 false, 대기 중 interruptInterruptedException입니다. 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);
    }
}

정상 실행은 UPDATED와 버전 1입니다. 획득됨 booleanfinally에서 소유 여부를 정확히 반영합니다. Duration 음수와 지나치게 큰 값을 public 구분점에서 확인합니다.

전체 요청 기한과 락 예산

락에 500ms, DB에 500ms, 네트워크에 500ms를 각각 독립으로 주면 총 요청 예산 700ms를 넘습니다. 상위 기한에서 남은 시간을 계산해 획득에 전달하고, 락을 얻은 뒤 하위 수집기에도 남은 예산만 줍니다.

시간 제한 tryLock이 false면 스레드는 RUNNABLE로 돌아옵니다. 락 소유자의 작업은 계속 진행합니다. 시간 제한 호출자가 같은 업무를 재시도할 때 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));
    }
}

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

연습 문제

주요 computation 락을 최대 30ms 기다리고 얻으면 fresh, 얻지 못하면 cached, interruptcancelled를 반환하는 메서드를 작성하세요. 성공한 획득만 해제해야 합니다.

정답과 세 결과

획득됨 변수를 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());
    }
}

경합이 없는 실행은 fresh입니다. 별도 보관 객체가 30ms보다 오래 락을 유지하면 cached, 기다리는 current 스레드를 interrupt하면 취소됨이 됩니다.

tryLock은 “락을 얻지 못함”을 정상 제어 흐름으로 올려 줍니다. 반환값, 시간 제한, interrupt를 구분하고 획득됨인 경우에만 unlock하며 상위 기한과 대체 경로를 연결해야 무한 대기를 줄이는 목적을 달성합니다.