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

안동민 개발노트

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

ReentrantLock과 계좌 불변식

종료 스레드가 락을 남기는 실패를 재현하고 검증·변경·조회에 같은 `private` `final` 락을 적용합니다.

ReentrantLock은 synchronized와 달리 블록을 벗어났다고 자동 해제되지 않습니다. return이나 예외가 발생해도 락은 소유자가 unlock()할 때까지 남습니다. 따라서 획득 직후 try-finally를 열고 보호하는 모든 경로가 같은 락을 사용해야 합니다.

소유 스레드 종료 시 자동 해제 오해

아래 소유자는 락을 얻은 뒤 unlock 없이 종료합니다. Thread가 TERMINATED되어도 ReentrantLock은 자동 복구되지 않아 main의 시간 제한 tryLock이 false입니다. 실제 출력은 owner=TERMINATED, wrong-auto-release=false입니다.

lab/LeakedReentrantLockBug.java
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;

public final class LeakedReentrantLockBug {
    public static void main(String[] args) throws InterruptedException {
        ReentrantLock lock = new ReentrantLock();
        Thread owner =
                new Thread(
                        () -> {
                            lock.lock();
                            System.out.println("owner-acquired");
                        },
                        "leaking-owner");
        owner.start();
        owner.join();
        boolean acquired = lock.tryLock(20, TimeUnit.MILLISECONDS);
        System.out.println("owner=" + owner.getState() + ", wrong-auto-release=" + acquired);
        if (acquired) lock.unlock();
    }
}

내장 기능 모니터는 synchronized 블록을 예외로 나가도 JVM이 반납하지만 명시적 Lock은 개발자 책임입니다. 소유자 사망을 감지해 다른 스레드가 unlock할 수도 없습니다. 소유하지 않은 호출은 IllegalMonitorStateException을 냅니다.

락·try·finally 구조

try를 락 호출보다 먼저 열면 락 획득 중 예외가 난 경로에서도 finallyunlock을 호출할 수 있습니다. 기본 형태는 lock.lock(); try { critical(); } finally { lock.unlock(); }입니다. 성공 여부를 반환하는 tryLock은 boolean true일 때만 이 블록에 들어갑니다.

returntry 안에 있어도 finally는 실행됩니다. 입력 검사 실패가 임계 영역 중간에 있더라도 unlock을 놓치지 않습니다. Error를 포함한 비정상 흐름에서도 finally는 일반적으로 실행되지만 프로세스 강제 종료까지 회복하는 영속성 보장은 아닙니다.

src/LockedBankAccount.java
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public final class LockedBankAccount {
    private int balance;
    private final Lock lock = new ReentrantLock();

    public LockedBankAccount(int initial) {
        if (initial < 0) throw new IllegalArgumentException("initial");
        balance = initial;
    }

    public boolean withdraw(int amount) {
        lock.lock();
        try {
            if (amount <= 0) throw new IllegalArgumentException("amount");
            if (balance < amount) return false;
            balance -= amount;
            return true;
        } finally {
            lock.unlock();
        }
    }

    public int balance() {
        lock.lock();
        try {
            return balance;
        } finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        LockedBankAccount account = new LockedBankAccount(1000);
        boolean[] result = new boolean[2];
        Thread first = new Thread(() -> result[0] = account.withdraw(800), "lock-a"),
                second = new Thread(() -> result[1] = account.withdraw(800), "lock-b");
        first.start();
        second.start();
        first.join();
        second.join();
        System.out.println(
                "success="
                        + ((result[0] ? 1 : 0) + (result[1] ? 1 : 0))
                        + ", balance="
                        + account.balance());
    }
}

성공 스레드는 달라도 성공 수 1과 균형 200은 고정입니다. ReentrantLock 획득-릴리스가 가시성 간선을 제공하므로 균형에 volatile을 추가하지 않습니다.

getter의 동일한 락 규칙

write만 락하고 read는 plain 필드로 두면 판독기가 stale 값나 복합 상태의 불일치를 볼 수 있습니다. 보호 필드에 대한 모든 가변 접근을 같은 락으로 감쌉니다. 스냅샷이 여러 필드를 묶으면 한 획득에서 모두 복사합니다.

단순 int 게터 한 줄도 락 오버헤드보다 정확성이 우선입니다. read 비율이 매우 높고 경합 증거가 있을 때 ReadWriteLock, 불변 스냅샷, optimistic scheme을 측정해 검토합니다. 미리 복잡도를 늘리지 않습니다.

락 필드를 인터페이스 타입으로 선언하면 구현 교체가 쉽지만 공정성·큐 진단을 써야 하면 ReentrantLock 타입이 필요합니다. 외부 호출자에게 락 객체를 반환하지 않습니다.

임계 영역 안에는 검증과 상태 커밋을 함께 배치

withdraw는 균형 확인과 차감을 분리하면 안 됩니다. 입력 형식 검증처럼 공유 상태와 무관한 계산은 락 밖에서 할 수 있지만 현재 잔액을 전제로 한 판단은 안에 둡니다. DB나 네트워크 호출을 락 안에서 기다리면 다른 계정 연산이 모두 정지하므로 reservation이나 트랜잭션 구분점을 다시 설계합니다.

app/LockedInventory.java
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.locks.ReentrantLock;

public final class LockedInventory {
    private final Map<String, Integer> stock = new LinkedHashMap<>();
    private final ReentrantLock lock = new ReentrantLock();

    public void put(String item, int quantity) {
        if (quantity < 0) throw new IllegalArgumentException();
        lock.lock();
        try {
            stock.put(item, quantity);
        } finally {
            lock.unlock();
        }
    }

    public boolean reserve(String item, int quantity) {
        if (quantity <= 0) throw new IllegalArgumentException();
        lock.lock();
        try {
            int current = stock.getOrDefault(item, 0);
            if (current < quantity) return false;
            stock.put(item, current - quantity);
            return true;
        } finally {
            lock.unlock();
        }
    }

    public Map<String, Integer> snapshot() {
        lock.lock();
        try {
            return Map.copyOf(stock);
        } finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        LockedInventory inventory = new LockedInventory();
        inventory.put("book", 3);
        Thread a = new Thread(() -> System.out.println("a=" + inventory.reserve("book", 2))),
                b = new Thread(() -> System.out.println("b=" + inventory.reserve("book", 2)));
        a.start();
        b.start();
        a.join();
        b.join();
        System.out.println(inventory.snapshot());
    }
}

두 reservation 중 하나만 true이고 최종 book 수량은 1입니다. 출력 순서와 성공 주체는 달라도 불변식은 유지됩니다. 스냅샷은 가변 내부 Map을 노출하지 않습니다.

unlock 실패를 다른 예외로 가리지 않는 이유

finally에서 unlock할 때 현재 스레드가 소유자가 아니면 IllegalMonitorStateException이 발생해 원래 업무 예외를 가릴 수 있습니다. 구조를 단순하게 유지해 획득과 해제를 같은 어휘적 메서드에 둡니다. 락을 호출자에게 넘겨 다른 스레드가 해제하도록 설계하지 않습니다.

여러 락을 중첩하면 각 락은 잘 해제돼도 획득 순서가 반대여서 교착 상태가 생길 수 있습니다. 전역 순서를 정하거나 tryLock 시간 제한으로 롤백하는 전략이 필요합니다. 한 문서의 bank 계정은 락 하나로 불변식을 보호해 이 문제를 피합니다.

두 락 구현의 비교 기준

기능 결과, 임계 영역 범위, 락 식별 정보가 같아야 성능을 비교할 수 있습니다. ReentrantLock으로 바꿨다는 이유만으로 빨라진다고 가정하지 않습니다. JVM synchronized 최적화도 발전하므로 작업 부하 벤치마크가 기준입니다.

시간 제한이나 중단 가능 wait가 필요하지 않으면 synchronized가 자동 해제와 간단한 문법에서 유리합니다. 명시적 Lock을 선택했다면 static analysis와 코드 검토에서 finally 패턴을 필수로 확인합니다.

연습 문제

초기 좌석 5개에서 두 스레드가 각각 4개를 예약하도록 구현하세요. 정확히 한 요청만 성공하고 1개가 남아야 하며 잘못된 수량도 락 누수 없이 예외를 던져야 합니다.

정답과 return 경로

부족한 경우 try 안에서 false를 반환하지만 finally가 실행됩니다. 균형 게터도 동일 락을 사용합니다.

exercise/LockedSeatReservationSolution.java
import java.util.concurrent.locks.ReentrantLock;

public final class LockedSeatReservationSolution {
    private static final class Seats {
        private int remaining;
        private final ReentrantLock lock = new ReentrantLock();

        Seats(int count) {
            remaining = count;
        }

        boolean reserve(int count) {
            lock.lock();
            try {
                if (count <= 0) throw new IllegalArgumentException("count");
                if (remaining < count) return false;
                remaining -= count;
                return true;
            } finally {
                lock.unlock();
            }
        }

        int remaining() {
            lock.lock();
            try {
                return remaining;
            } finally {
                lock.unlock();
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Seats seats = new Seats(5);
        boolean[] ok = new boolean[2];
        Thread a = new Thread(() -> ok[0] = seats.reserve(4)),
                b = new Thread(() -> ok[1] = seats.reserve(4));
        a.start();
        b.start();
        a.join();
        b.join();
        System.out.println(
                "success="
                        + ((ok[0] ? 1 : 0) + (ok[1] ? 1 : 0))
                        + ", remaining="
                        + seats.remaining());
    }
}

결과는 success=1, 남은 값=1입니다. 어느 indextrue인지는 공정성 규칙이 없으므로 검사하지 않습니다.

ReentrantLock 활용의 핵심은 기능이 아니라 구조입니다. private final 식별 정보, 획득 직후 try-finally, 보호 필드의 모든 접근, 최소이면서 완전한 불변식 범위를 지켜야 명시적 락의 제어력이 안전하게 작동합니다.