동시성 리뷰 - 공유 상태와 경쟁 조건
이 장에서 배우는 것
앞 장에서 책임과 의존 관계를 살펴보았다. 이번에는 메서드 하나가 올바르게 보여도 여러 요청이 겹치면 결과가 달라지는 코드를 읽는다. 도서관 대출 서비스에서 마지막 한 권을 두 사람이 동시에 신청하는 상황이다. 코드의 실행 순서를 한 줄씩 따라가며, 두 요청이 함께 지켜야 하는 규칙을 찾는다.
동시성 리뷰의 출발점은 스레드 수가 아니다. 누가 어떤 상태를 함께 읽고 바꾸는지, 그리고 어떤 판단과 변경이 하나의 작업으로 묶여야 하는지를 확인하는 일이다.
- 공유 가변 상태를 찾아 읽기와 쓰기를 구분한다.
- 확인 후 실행 경쟁이 중복 대출 승인을 만드는 과정을 설명한다.
- 동기화 범위를 업무 규칙에 맞게 정한다.
- 불변 객체와 동시 컬렉션이 해결하는 문제의 범위를 구분한다.
- 실행 순서를 제어해 결함과 수정 결과를 반복해서 확인한다.
문제 상황
팀은 소장본 한 권의 대출 상태를 메모리에 저장하는 서비스를 만들었다. 여기서 책 식별자는 제목이나 도서 분류 번호가 아니라 실제 소장본 한 권의 식별자다. 같은 제목의 책이 세 권이면 식별자도 세 개다. 이 장의 규칙은 간단하다. 하나의 소장본에는 대출 기록이 최대 하나만 존재하며, 이미 대출 중이면 새 신청을 거절한다.
리뷰 대상 변경은 일반 맵을 동시 컬렉션으로 교체한 코드다. 작성자는 여러 요청이 동시에 접근하므로 저장소를 바꾸었다고 설명했다. 한 요청씩 실행하는 확인에서는 첫 신청이 성공하고 다음 신청이 거절된다. 그러나 겹쳐 들어온 요청에서는 두 사람 모두 성공 응답을 받는다. 저장소에는 나중 신청자의 기록 하나만 남는다.
“저장소에 기록이 하나만 남는 것과 성공 응답이 한 번만 나가는 것은 다른 조건이다. 대출 가능 여부를 읽은 시점부터 기록을 넣는 시점까지 두 요청의 순서를 펼쳐 보자.”
리뷰에서는 먼저 지켜야 할 규칙을 말로 고정한다. 이 서비스에서는 같은 소장본에 대한 두 신청 중 최대 하나만 성공해야 한다. 맵의 크기만 확인하면 기록 덮어쓰기로 사라진 성공 응답을 놓친다. 반환값과 최종 저장 상태를 함께 보아야 한다.
| 관찰 대상 | 확인할 내용 | 놓치기 쉬운 점 |
|---|---|---|
| 반환값 | 같은 소장본에 성공이 한 번인가 | 기록 수만으로 판단할 수 없다 |
| 저장 상태 | 성공한 신청자의 기록이 남는가 | 나중 쓰기가 앞선 기록을 덮을 수 있다 |
| 작업 경계 | 확인과 기록이 함께 보호되는가 | 각 메서드가 안전해도 조합은 다르다 |
공유 상태를 찾고 실행 순서를 펼친다
공유 가변 상태(shared mutable state)는 여러 스레드가 함께 접근하고, 실행 중 내용이 바뀔 수 있는 상태다. 예제에서는 대출 기록을 담는 맵이 해당한다. 메서드의 지역 변수인 신청자 식별자는 호출마다 따로 생기지만, 서비스 필드에 있는 맵은 같은 서비스 인스턴스를 사용하는 요청들이 함께 본다.
필드라는 이유만으로 모두 문제가 되는 것은 아니다. 생성 이후 바뀌지 않는 날짜 값은 여러 요청이 읽어도 상태가 변하지 않는다. 반대로 지역 변수라고 무조건 독립적인 것도 아니다. 지역 변수에 공용 맵의 참조를 대입하면 그 변수가 가리키는 대상은 여전히 공유된다. 변수의 선언 위치와 실제 객체의 소유 범위를 함께 읽어야 한다.
경쟁 조건(race condition)은 실행이 겹치는 순서에 따라 규칙을 지키지 못하거나 결과가 달라지는 상황이다. 여기서는 ‘없으면 넣는다’를 ‘없는지 확인한다’와 ‘넣는다’로 나누면서 확인 후 실행 경쟁이 생긴다. 두 요청이 모두 빈 상태를 읽으면 둘 다 자신에게 대출할 수 있다고 판단한다.
그림의 순서는 두 스레드가 같은 순간에 명령을 실행해야만 가능한 것이 아니다. 첫 스레드가 확인한 직후 멈추고 다른 스레드가 확인하면 충분하다. 프로세서 개수나 실행 속도에 기대어 이 순서를 배제할 수 없다.
동시 컬렉션(concurrent collection)은 자신의 연산에 필요한 동시 접근 처리를 제공한다. 그러나 호출자가 이어 붙인 모든 연산을 한 덩어리로 만들어 주지는 않는다. ConcurrentHashMap의 containsKey와 put을 각각 호출하면 그 사이에 다른 스레드가 들어올 수 있다. 이때 맵 내부의 동작이 안전하다는 사실과 서비스 규칙이 유지된다는 사실을 분리해서 판단해야 한다.
“containsKey와 put 사이에서 다른 요청이 같은 키를 넣을 수 있다. 값이 없을 때만 넣는 연산을 사용하고, 그 연산의 반환값으로 성공 여부를 결정하면 어떨까.”
보호 범위와 불변성의 역할을 나눈다
원자성(atomicity)은 다른 작업이 중간 상태를 끼어들어 이용하지 못하도록 작업을 하나의 단위로 취급하는 성질이다. 이번 규칙에서는 ‘기록이 없으면 등록하고, 등록한 요청만 성공한다’가 그 단위다. ConcurrentHashMap의 putIfAbsent는 같은 키에 대해 이 판단과 등록을 하나의 연산으로 제공한다. 반환값이 null이면 이번 호출이 등록했고, 기존 값이 반환되면 먼저 등록한 요청이 있다는 뜻이다. 이 맵은 null 값을 저장하지 않으므로 이 구분을 사용할 수 있다.
동기화(synchronization)로 해결할 수도 있다. 다만 확인할 때 잠금을 잡았다가 풀고, 기록할 때 다시 잡으면 두 동작 사이의 틈은 남는다. 확인과 기록이 모두 같은 잠금 안에 있어야 한다. 접근하는 모든 경로가 같은 잠금 규칙을 따라야 한다는 조건도 붙는다. 쓰는 쪽만 잠그고 읽는 쪽은 다른 규칙을 사용하면 보호 범위를 제대로 설명하기 어렵다.
잠금 객체가 같은지도 확인한다. 호출할 때마다 새 객체를 만들어 잠그면 요청들이 서로 다른 문을 잠그는 셈이다. 인스턴스 메서드에 synchronized를 붙여도 서비스 인스턴스가 둘이고 저장소가 하나라면 잠금은 서로 다르다. ‘잠금 문법이 있다’에서 멈추지 말고, 공유 상태와 잠금의 수명이 일치하는지 추적해야 한다.
불변 객체(immutable object)는 생성 후 관찰 가능한 상태가 바뀌지 않는 객체다. 완성 코드의 Loan은 record로 정의하며, 구성 요소는 문자열과 LocalDate다. 모두 이 예제에서 변경할 수 없는 값이므로 Loan도 불변 값으로 다룰 수 있다. 날짜는 생성자에서 고정값을 받아 사용한다.
record라는 선언만으로 내부의 모든 객체가 불변이 되는 것은 아니다. 구성 요소가 수정 가능한 목록이면 그 목록의 내용은 별도로 보호해야 한다. final 필드 역시 참조를 다시 대입하지 못하게 할 뿐, 참조 대상의 변경까지 막지는 않는다.
불변 대출 기록을 쓰면 기록 안의 신청자가 나중에 바뀌는 문제는 줄어든다. 그러나 맵에 어떤 기록을 넣을지 결정하는 경쟁은 남는다. 객체의 불변성과 등록 작업의 원자성은 각각 필요한 위치가 다르다.
| 수단 | 보호하는 대상 | 추가로 볼 것 |
|---|---|---|
| 불변 Loan | 이미 만든 대출 기록의 내용 | 등록 여부를 정하는 경쟁 |
| 동시 맵의 개별 연산 | 맵 연산의 동시 접근 | 여러 호출을 합친 업무 규칙 |
| putIfAbsent | 한 키의 부재 확인과 등록 | 반환값에 따른 응답 결정 |
| 같은 잠금의 임계 구역 | 잠금 안에 묶은 상태 변경 | 모든 접근 경로의 잠금 준수 |
재현은 속도가 아니라 순서를 제어한다
Thread.sleep으로 잠깐 기다리게 하면 결함이 더 자주 보일 수는 있다. 그러나 원하는 지점까지 상대 스레드가 도달했다는 보장은 없다. 컴퓨터 상태에 따라 결과가 달라지면 리뷰에서 같은 근거를 공유하기 어렵다.
완성 코드에서는 CountDownLatch로 진행 지점을 맞춘다. 이 도구는 정해진 횟수만큼 신호를 받기 전까지 기다리게 한다. 두 작업이 준비되었다는 신호를 보낸 뒤, 실행 담당 스레드가 A의 등록을 먼저 허용하고 A가 끝나면 B를 허용한다. 결과는 작업 스레드 안에서 출력하지 않고, 둘 다 끝난 다음 정해진 순서로 출력한다.
결함 버전의 대기 지점은 ‘없음’을 확인한 뒤다. 수정 버전의 대기 지점은 조건부 등록 직전이다. 수정 버전은 별도 사전 조회로 성공을 결정하지 않는다. 따라서 두 버전에서 대기 지점의 의미까지 같다고 주장해서는 안 된다. 같은 소장본과 신청자, A 다음 B라는 등록 시도 순서를 유지하고, 그 순서에서 각 구현의 판단이 어떻게 달라지는지 비교한다.
대기 지점을 전달하는 Runnable은 이 실습의 실행 제어 장치다. 실제 대출 기능의 요구 사항은 아니다. 잠금을 사용하는 구현에 이 장치를 그대로 옮겨 잠금 안에서 기다리게 하면, 다른 스레드가 같은 지점에 도달하지 못할 수 있다. 재현 장치가 바뀐 구현에서도 진행 가능한지 함께 검토해야 한다.
먼저 한 요청씩 실행해 두 구현이 같은 입력에 같은 결과를 내는지 검사한다. 이어서 의도적으로 요청을 겹쳐 수정이 필요한 차이를 드러낸다. 수정 전의 잘못된 동작까지 보존해야 하는 것은 아니다. 정상적인 순차 사용의 결과와, 동시 사용에서 회복해야 할 규칙을 나누어 검증한다.
완성 코드
다음 내용을 Main.java에 저장한다. 파일 입출력이나 현재 시각 조회는 사용하지 않는다. 실제 스레드는 두 개지만, 등록을 진행시키는 순서는 실행 담당 스레드가 고정한다.
Main.java
import java.time.LocalDate;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
public class Main {
private static final String BOOK = "COPY-1";
private static final LocalDate DATE = LocalDate.of(2030, 4, 5);
private static int failures;
enum Result {
SUCCESS, ALREADY_LOANED
}
record Loan(String bookId, String memberId, LocalDate date) {
}
interface LoanService {
Result borrow(String bookId, String memberId,
Runnable beforeCommit);
Loan find(String bookId);
}
static final class BrokenService implements LoanService {
private final ConcurrentHashMap<String, Loan> loans =
new ConcurrentHashMap<>();
private final LocalDate date;
BrokenService(LocalDate date) {
this.date = date;
}
@Override
public Result borrow(String bookId, String memberId,
Runnable beforeCommit) {
if (loans.containsKey(bookId)) {
return Result.ALREADY_LOANED;
}
beforeCommit.run();
loans.put(bookId, new Loan(bookId, memberId, date));
return Result.SUCCESS;
}
@Override
public Loan find(String bookId) {
return loans.get(bookId);
}
}
static final class FixedService implements LoanService {
private final ConcurrentHashMap<String, Loan> loans =
new ConcurrentHashMap<>();
private final LocalDate date;
FixedService(LocalDate date) {
this.date = date;
}
@Override
public Result borrow(String bookId, String memberId,
Runnable beforeCommit) {
Loan candidate = new Loan(bookId, memberId, date);
beforeCommit.run();
Loan previous = loans.putIfAbsent(bookId, candidate);
return previous == null
? Result.SUCCESS : Result.ALREADY_LOANED;
}
@Override
public Loan find(String bookId) {
return loans.get(bookId);
}
}
record Outcome(Result first, Result second, Loan stored) {
}
private static void await(CountDownLatch latch) {
try {
latch.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("interrupted", e);
}
}
private static void join(Thread thread) {
try {
thread.join();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("interrupted", e);
}
}
private static Outcome sequential(LoanService service) {
Runnable noPause = () -> { };
Result first = service.borrow(BOOK, "A", noPause);
Result second = service.borrow(BOOK, "B", noPause);
return new Outcome(first, second, service.find(BOOK));
}
private static Outcome overlapping(LoanService service) {
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch allowA = new CountDownLatch(1);
CountDownLatch allowB = new CountDownLatch(1);
CountDownLatch doneA = new CountDownLatch(1);
Result[] results = new Result[2];
Throwable[] errors = new Throwable[2];
Thread a = new Thread(() -> {
try {
results[0] = service.borrow(BOOK, "A", () -> {
ready.countDown();
await(allowA);
});
} catch (Throwable e) {
errors[0] = e;
} finally {
doneA.countDown();
}
}, "request-A");
Thread b = new Thread(() -> {
try {
results[1] = service.borrow(BOOK, "B", () -> {
ready.countDown();
await(allowB);
});
} catch (Throwable e) {
errors[1] = e;
}
}, "request-B");
a.start();
b.start();
await(ready);
allowA.countDown();
await(doneA);
allowB.countDown();
join(a);
join(b);
if (errors[0] != null || errors[1] != null) {
throw new IllegalStateException("worker failed",
errors[0] != null ? errors[0] : errors[1]);
}
return new Outcome(results[0], results[1], service.find(BOOK));
}
private static int successes(Outcome outcome) {
int count = 0;
if (outcome.first() == Result.SUCCESS) {
count++;
}
if (outcome.second() == Result.SUCCESS) {
count++;
}
return count;
}
private static void show(String name, Outcome outcome) {
System.out.println(name + ": A=" + outcome.first()
+ ", B=" + outcome.second()
+ ", stored=" + outcome.stored().memberId()
+ ", date=" + outcome.stored().date());
}
private static void check(boolean condition, String name) {
System.out.println((condition ? "PASS " : "FAIL ") + name);
if (!condition) {
failures++;
}
}
public static void main(String[] args) {
Outcome sequentialBroken = sequential(new BrokenService(DATE));
Outcome sequentialFixed = sequential(new FixedService(DATE));
show("sequential broken", sequentialBroken);
show("sequential fixed", sequentialFixed);
check(sequentialBroken.equals(sequentialFixed),
"sequential results match");
check(sequentialFixed.first() == Result.SUCCESS
&& sequentialFixed.second() == Result.ALREADY_LOANED
&& sequentialFixed.stored().memberId().equals("A"),
"sequential rule");
Outcome broken = overlapping(new BrokenService(DATE));
Outcome fixed = overlapping(new FixedService(DATE));
show("overlap broken", broken);
show("overlap fixed", fixed);
check(successes(broken) == 2, "broken race reproduced");
check(broken.stored().memberId().equals("B"),
"broken record overwritten");
check(successes(fixed) == 1, "fixed single success");
check(fixed.first() == Result.SUCCESS
&& fixed.second() == Result.ALREADY_LOANED
&& fixed.stored().memberId().equals("A"),
"fixed winner matches record");
check(fixed.stored().date().equals(DATE), "fixed injected date");
System.out.println("failures=" + failures);
if (failures != 0) {
throw new AssertionError("check failed");
}
}
}
줄별 해설
상수와 Loan 선언. BOOK은 소장본 한 권을 나타낸다. DATE는 실행할 때마다 달라지지 않는 날짜다. 두 서비스는 이 값을 생성자로 받는다. Loan은 소장본, 신청자, 날짜를 한 값으로 묶는다. record가 생성하는 equals는 구성 요소를 비교하므로 순차 실행의 Outcome 전체를 비교할 수 있다.
LoanService 선언. 두 구현의 입력과 반환값을 맞춘다. beforeCommit은 등록 직전 멈출 수 있게 하는 실습 장치다. find는 최종 상태 확인용이다. 이 예제에는 반납이나 기록 수정 경로가 없으므로 대출 등록만 추적하면 된다.
BrokenService의 containsKey. 이미 기록이 있으면 거절한다. 여기까지만 읽으면 조건이 맞아 보인다. 그러나 false를 읽은 사실은 이후 put까지 유효하다는 보장이 없다. 그 사이 다른 요청이 기록을 넣어도 이 메서드는 다시 판단하지 않는다.
BrokenService의 put과 반환문. put은 기존 값을 덮어쓸 수 있다. 반환값을 이용하지 않고 SUCCESS를 돌려주므로, 앞선 대출을 덮은 요청도 성공한다. 결함은 맵이 손상되는 현상보다 업무상 승인 수가 늘어나는 현상으로 드러난다.
FixedService의 candidate. 등록 후보를 먼저 만든다. 후보 생성 자체는 공유 맵을 바꾸지 않는다. 이번 예제에서 Loan 생성은 값 세 개를 묶는 일뿐이므로, 등록에 실패한 후보가 만들어져도 대출이 발생한 것은 아니다.
FixedService의 putIfAbsent. 등록 여부를 결정하는 지점이다. 별도 조회 결과를 믿지 않고 이 연산의 반환값을 사용한다. 기존 기록이 없어서 후보를 넣은 요청만 SUCCESS를 반환한다. 이미 기록이 있으면 맵을 덮어쓰지 않고 ALREADY_LOANED를 반환한다.
await와 join. 대기 중 인터럽트를 받으면 인터럽트 상태를 복원하고 실패를 전달한다. join이 정상적으로 끝난 뒤에는 해당 작업 스레드가 기록한 결과를 실행 담당 스레드에서 읽을 수 있다. results 배열은 각 스레드가 자기 칸만 쓰며, 출력은 두 join 이후에 이루어진다.
sequential. 멈추지 않는 Runnable을 전달하여 A와 B를 차례로 호출한다. 구현마다 새 서비스 인스턴스를 쓰므로 빈 저장소라는 시작 조건도 같다. 두 결과의 일치뿐 아니라 첫 성공과 두 번째 거절이라는 업무 규칙도 별도로 검사한다.
overlapping의 네 대기 장치. ready는 두 요청이 대기 지점에 도달했음을 뜻한다. allowA와 allowB는 각 요청의 등록 진행을 허용한다. doneA는 A의 메서드 호출이 끝났음을 알린다. 실행 담당 스레드는 ready를 기다린 다음 A만 풀고, doneA 이후 B를 푼다.
작업 스레드의 오류 보관. 이 작은 실행 장치에서는 작업 실패가 조용히 사라지지 않도록 오류를 배열에 보관한다. A는 finally에서 완료 신호를 보낸다. 두 스레드를 합류시킨 뒤 오류가 있으면 실행 전체를 실패시킨다. Throwable을 잡는 부분은 재현 장치의 실패 전달용이며 대출 거절을 처리하는 코드가 아니다.
main의 검사. 결함 재현 검사는 성공 두 번과 B의 덮어쓰기를 예상한다. 따라서 이 검사의 PASS는 결함을 원하는 순서로 드러냈다는 뜻이다. 수정 검사는 성공 한 번, A의 기록 유지, 주입한 날짜를 확인한다. 전체 검사 실패 수가 0이 아니면 프로그램도 실패한다.
실행 결과
Main.java가 있는 디렉터리에서 다음 명령을 실행한다. 첫 명령의 -Xlint:all은 경고 확인을 위한 선택 사항이며, 제시한 코드는 JDK 17에서 이 옵션으로도 경고 없이 컴파일할 수 있다.
javac -Xlint:all -d out *.java
java -cp out Main
기본 명령인 javac -d out *.java로 컴파일해도 실행 방법과 결과는 같다. 예상 출력은 다음과 같다.
sequential broken: A=SUCCESS, B=ALREADY_LOANED, stored=A, date=2030-04-05
sequential fixed: A=SUCCESS, B=ALREADY_LOANED, stored=A, date=2030-04-05
PASS sequential results match
PASS sequential rule
overlap broken: A=SUCCESS, B=SUCCESS, stored=B, date=2030-04-05
overlap fixed: A=SUCCESS, B=ALREADY_LOANED, stored=A, date=2030-04-05
PASS broken race reproduced
PASS broken record overwritten
PASS fixed single success
PASS fixed winner matches record
PASS fixed injected date
failures=0
순차 실행 두 줄은 같은 입력에서 기존의 정상 동작이 유지됨을 보여 준다. 겹친 실행에서는 차이가 생긴다. 결함 버전은 A가 먼저 등록한 뒤에도 B가 성공하고, 수정 버전은 B를 거절한다. 맵에 남은 기록은 두 경우 모두 하나이므로, 반환값을 보지 않으면 결함을 구별하기 어렵다.
이 결과는 지정한 실행 순서를 검증한다. 가능한 모든 순서를 시험했다는 뜻은 아니다. 수정의 근거는 조건부 등록 연산의 보장과 반환값 사용에 있고, 실행은 그 설명을 구체적인 사례로 확인한다. 실행 담당 스레드가 순서를 제어하므로 A가 먼저 성공할 뿐, 실제 서비스에서 A에게 우선권을 주는 기능을 구현한 것은 아니다.
실무에서 자주 틀리는 것
확인과 기록을 서로 다른 잠금 구간에 둔다
다음 코드는 HashMap 접근마다 같은 잠금을 사용한다. 그래도 빈 상태를 확인한 뒤 잠금을 놓기 때문에 두 요청이 함께 통과할 수 있다. 각 예제는 별도의 클래스로 컴파일 가능한 코드다.
class SplitLockLoan {
private final Object lock = new Object();
private final java.util.Map<String, String> loans =
new java.util.HashMap<>();
boolean borrow(String book, String member) {
synchronized (lock) {
if (loans.containsKey(book)) {
return false;
}
}
synchronized (lock) {
loans.put(book, member);
return true;
}
}
}
고친 코드는 판단과 기록을 같은 잠금 구간으로 옮긴다. 이 클래스에 조회나 반납을 추가한다면 그 접근도 같은 잠금 규칙을 따라야 한다. 모든 책이 하나의 잠금을 공유한다는 범위도 리뷰 대상이다.
class WholeLockLoan {
private final Object lock = new Object();
private final java.util.Map<String, String> loans =
new java.util.HashMap<>();
boolean borrow(String book, String member) {
synchronized (lock) {
if (loans.containsKey(book)) {
return false;
}
loans.put(book, member);
return true;
}
}
}
“잠금 구간이 둘로 나뉘어 있어 같은 책의 빈 상태를 두 요청이 읽을 수 있다. 확인과 등록을 같은 구간으로 묶어야 대출 규칙을 지킬 수 있다.”
final 참조를 불변 상태로 해석한다
다음 대출 기록은 필드가 final이어도 호출자가 전달한 목록과 내부 목록이 같은 객체다. 외부에서 목록을 바꾸거나 members가 돌려준 목록을 수정하면 기록도 달라진다.
class SharedMembers {
private final java.util.List<String> members;
SharedMembers(java.util.List<String> members) {
this.members = members;
}
java.util.List<String> members() {
return members;
}
}
고친 코드는 입력 목록의 수정 불가능한 복사본을 보관한다. 요소가 불변인 문자열이므로 이 예제에서는 요소 내부의 변경도 걱정하지 않아도 된다. 생성자에 전달하는 목록 자체가 복사 도중 다른 스레드에서 수정되지 않는다는 전제는 별도로 필요하다.
class FrozenMembers {
private final java.util.List<String> members;
FrozenMembers(java.util.List<String> members) {
this.members = java.util.List.copyOf(members);
}
java.util.List<String> members() {
return members;
}
}
이 수정은 기록 내용의 변경 경로를 줄인다. 소장본의 대출 가능 여부를 결정하는 작업까지 원자적으로 만들지는 않는다. 불변 값으로 교체한 뒤에도 맵의 등록 규칙은 따로 살펴야 한다.
조건부 등록을 호출하고도 성공을 무조건 반환한다
안전한 연산을 선택해도 그 결과를 버리면 서비스의 응답 규칙은 어긋난다. 다음 코드는 기존 기록을 덮지 않지만, 거절되어야 할 신청에도 true를 반환한다.
class IgnoredRegistration {
private final java.util.concurrent.ConcurrentHashMap<String, String>
loans = new java.util.concurrent.ConcurrentHashMap<>();
boolean borrow(String book, String member) {
loans.putIfAbsent(book, member);
return true;
}
}
고친 코드는 실제로 등록한 호출만 성공 처리한다. 기록이 보호되는 것과 호출자에게 올바르게 알리는 것을 함께 확인하는 수정이다.
class CheckedRegistration {
private final java.util.concurrent.ConcurrentHashMap<String, String>
loans = new java.util.concurrent.ConcurrentHashMap<>();
boolean borrow(String book, String member) {
return loans.putIfAbsent(book, member) == null;
}
}
이 방식의 범위는 한 키의 조건부 등록이다. 나중에 ‘회원별 대출 수를 늘리면서 소장본도 등록한다’는 규칙이 생기면 서로 다른 두 상태의 일관성을 다시 설계해야 한다. 두 맵이 각각 동시 컬렉션이라는 이유로 두 변경이 함께 처리되지는 않는다.
한눈에 보기
| 읽는 지점 | 리뷰 질문 | 이 장의 판단 |
|---|---|---|
| 서비스 필드 | 여러 요청이 같은 객체를 바꾸는가 | 대출 맵이 공유 가변 상태다 |
| 조건문 뒤의 쓰기 | 확인 결과가 쓰기까지 유지되는가 | containsKey와 put 사이에 틈이 있다 |
| 잠금 구간 | 업무 규칙 전체와 같은 잠금을 쓰는가 | 확인과 기록을 함께 보호한다 |
| 저장하는 값 | 등록 후 내용을 바꿀 경로가 있는가 | Loan은 불변 값으로 둔다 |
| 동시 컬렉션 호출 | 복합 작업을 지원하는 연산이 있는가 | putIfAbsent의 결과로 응답한다 |
| 재현 실행 | 어떤 순서를 실제로 확인했는가 | 두 요청 준비 후 A, B 순서로 진행한다 |
실제 서비스가 여러 프로세스로 실행된다면 각 프로세스의 메모리와 잠금은 서로 공유되지 않는다. 이 장의 수정은 하나의 서비스 인스턴스를 공유하는 스레드들에 적용된다. 저장소를 외부로 옮기거나 실행 단위를 늘릴 때는 대출 규칙을 어느 저장소 경계에서 보장할지 다시 확인해야 한다.
연산의 보장 범위를 확인할 때는 JDK 17의 putIfAbsent 설명과 CountDownLatch 설명을 참고할 수 있다. 리뷰 의견에는 도구 이름만 적기보다, 어떤 판단과 변경을 함께 보호해야 하는지까지 적는다.
연습 문제
- 결함 버전에서 최종 대출 기록 수가 하나인데도 잘못된 결과인 이유를 설명하라. 반환값과 저장 상태를 각각 언급하라.
- overlapping에서 B를 먼저 허용하고, B의 완료를 기다린 뒤 A를 허용하도록 바꾸려 한다. 필요한 대기 장치 변경과 두 구현의 예상 반환값 및 최종 신청자를 적어라.
- containsKey와 put을 각각 synchronized 메서드로 만들면 문제가 해결되는지 판단하라. 두 호출 사이에 가능한 실행 순서를 제시하라.
- 회원당 대출 수 제한을 추가하고, 회원별 수를 담는 동시 맵과 소장본 대출 맵을 각각 갱신했다. putIfAbsent만으로 새 규칙도 지켜지는지 설명하고 리뷰 질문을 한 문장으로 작성하라.
정답과 해설
성공 응답이 두 번 나갔기 때문이다. A의 성공 뒤 B도 성공하지만, B가 같은 키의 값을 덮어 최종 기록은 B 하나만 남는다. 저장된 원소 수는 승인 횟수를 나타내지 않는다. 같은 소장본에 성공이 최대 한 번이라는 조건과, 성공한 신청자의 기록이 유지된다는 조건을 함께 확인해야 한다.
B의 완료를 알리는 doneB를 만들고 B 작업의 finally에서 신호를 보내야 한다. 실행 담당 스레드는 ready를 기다린 뒤 allowB를 풀고, doneB를 기다린 다음 allowA를 푼다. 두 스레드를 합류시키는 과정은 유지한다. 결함 버전은 A와 B가 모두 SUCCESS이고 최종 신청자는 A다. 수정 버전은 B가 SUCCESS, A가 ALREADY_LOANED이며 최종 신청자는 B다. 출력 항목이 A부터라면 출력의 나열 순서와 실제 등록 순서를 혼동하지 않아야 한다.
각 메서드가 같은 객체를 잠그더라도 두 호출을 묶지 않으면 해결되지 않는다. A의 확인이 false를 반환하고 잠금을 놓은 뒤 B의 확인도 false를 반환할 수 있다. 이후 A와 B가 차례로 기록하면 둘 다 성공한다. 같은 잠금 안에서 확인과 기록을 수행하는 하나의 메서드로 묶거나 조건부 등록 연산을 사용해야 한다.
한 키의 등록만으로 회원별 수와 소장본 기록이라는 두 상태의 일관성까지 보장되지는 않는다. 같은 회원의 서로 다른 책 신청이 함께 제한을 통과할 수 있고, 한쪽 변경만 이루어지는 경로도 살펴야 한다. 리뷰 질문은 “같은 회원의 신청이 겹칠 때 제한 확인, 소장본 등록, 회원별 수 변경을 어느 공통 경계에서 함께 보장하는가”라고 쓸 수 있다.