경쟁 상태 재현
95분 안팎
학습 목표
프로세스·스레드와 공유 상태의 경쟁을 구분합니다.
개념
왜 혼자 요청하면 보이지 않을까요
한 권의 실물 도서에 두 회원이 대여 요청을 보냅니다. 둘 다 현재 대여가 없다고 읽고 각자 이력을 추가하면 서비스가 두 번 성공할 수 있습니다. 각 요청의 입력 검증은 올바르지만 두 실행을 합친 결과는 도서 한 권에 현재 대여 한 건이라는 규칙을 깨뜨립니다. 이번 레슨은 보호 장치를 붙이기 전에 실패가 가능한 실행 순서를 재현합니다. 느린 컴퓨터가 원인이라는 추측 대신 어떤 관찰과 변경 사이가 열려 있는지를 설명하는 것이 목표입니다.
이번 모듈의 실행 환경
모든 Java 실습은 JDK 17과 Spring Boot 3.1.5의 Maven 프로젝트입니다. 압축을 푼 starter 폴더에서 ./mvnw test를 실행합니다. 저장소의 config를 복사하지 않으며 누적 API 실습은 H2 메모리 DB를 사용합니다. 처음에는 실패하는 검사가 있습니다. 테스트 이름, 기대값, 실제값을 읽고 TODO를 수정합니다. solution은 비교용입니다. 준비된 Maven 캐시에서는 ./mvnw -o -q test로도 검증할 수 있습니다. 테스트 완료 뒤 종료되는 프로그램만 실행하며 웹 서버를 따로 띄울 필요가 없습니다.
프로세스와 스레드의 공유 범위
프로세스는 실행 중인 프로그램의 자원 경계이며 같은 JVM 안의 스레드는 객체를 함께 사용할 수 있습니다. 두 요청 처리 스레드가 같은 서비스 빈에 들어오면 메서드의 지역 변수는 각 호출에 속하지만 그 빈의 필드는 함께 보게 됩니다. 서로 다른 JVM은 Java 객체와 모니터를 직접 공유하지 않습니다. 그러나 같은 DB를 이용하면 같은 도서 행을 변경할 수 있습니다. 따라서 메모리 공유가 없다는 사실만으로 데이터 경쟁도 없다고 판단하지 않습니다.
실험에서는 판단을 따로 고정합니다
RaceModel은 실물 도서가 비어 있는지 occupied.get()으로 확인합니다. free 지역 변수는 각 작업이 관찰한 과거 결과입니다. 두 작업이 이 값을 얻은 뒤 CountDownLatch의 수를 줄입니다. latch가 0이 될 때까지 변경을 멈추면 둘 다 빈 상태를 읽었다는 조건이 만들어집니다. 그 뒤 각각 occupied를 1로 바꾸고 승인 수를 증가시킵니다. 최종 점유 값은 1이어도 승인 수는 2입니다. 점유 값만 확인하면 업무 오류를 놓칩니다.
원자적 변수도 전체 규칙을 대신하지 않습니다
occupied와 done은 AtomicInteger입니다. 개별 get, set, incrementAndGet은 해당 API의 원자적 연산을 사용하지만 빈 상태 확인과 승인 결정 전체가 하나의 연산이 되는 것은 아닙니다. 이 예제는 카운터 자체의 갱신 유실을 피하여 같은 빈 상태를 보고 두 번 승인하는 문제만 분리합니다. AtomicInteger를 일반 int로 바꾸어 다른 오류까지 섞지 않습니다. volatile을 추가하는 것 역시 두 호출의 판단과 변경을 한 덩어리로 묶지 않습니다.
시작을 맞추는 것과 읽기를 맞추는 것
시작 신호만 동시에 열어도 한 작업이 다른 작업보다 먼저 끝날 수 있습니다. 이번 latch는 작업 시작이 아니라 빈 상태를 읽은 직후에 둡니다. 두 관찰을 기다리므로 오류를 만드는 순서가 확정됩니다. API의 실제 트랜잭션 테스트에서는 이력 INSERT 뒤를 같은 방식으로 기다립니다. 어디서 멈추는지가 실험의 의미입니다. Thread.sleep을 길게 주는 방법은 다른 스레드가 그 지점에 도착했다는 사실을 알려 주지 않습니다.
작업 완료와 실패를 호출자에게 전달합니다
ExecutorService에 Callable을 제출하면 Future로 결과를 받습니다. get에 시간 제한을 넣어 작업이 완료되지 않는 상황을 테스트 실패로 표시합니다. 작업에서 던진 예외는 ExecutionException의 원인으로 전달됩니다. Future를 무시하면 작업 내부에서 터진 오류가 테스트의 성공으로 보일 수 있습니다. await에도 제한 시간을 넣어 한 작업만 도착했을 때 영원히 기다리지 않도록 합니다. InterruptedException을 받으면 인터럽트 상태를 복원하고 실패를 전파합니다.
순차 실행을 동시 실행으로 착각하지 않습니다
한 요청을 호출하고 그 반환값을 받은 뒤 두 번째 요청을 호출하면 순차 실험입니다. 두 작업의 Future를 모두 만든 다음 결과를 받는 구조를 사용합니다. 풀 크기가 1이면 첫 작업이 latch에서 멈춘 동안 두 번째 작업이 실행 기회를 얻지 못합니다. 최소 두 작업을 동시에 진행할 수 있는 풀을 준비합니다. latch 초기값은 관찰자 수인 2입니다. 초기값을 3으로 쓰면 누가 세 번째 신호를 보낼지 없으므로 시간 제한에 걸립니다.
정리 코드도 실험의 일부입니다
RaceModel의 finally는 풀에 shutdown을 호출하고 awaitTermination으로 작업 종료를 확인합니다. shutdown은 새 작업 제출을 막는 동작이며 이미 제출된 작업의 즉시 종료를 뜻하지 않습니다. 모든 대기와 결과 수집에 제한 시간이 있으므로 작업이 스스로 실패하거나 끝나도록 설계했습니다. 프로세스를 강제로 종료하여 통과로 보이게 하지 않습니다. 반복 실행에서 이전 occupied 값이 남지 않도록 실험 자원은 reproduce 호출마다 새로 만듭니다.
실패 메시지를 규칙으로 번역합니다
starter의 expected 2 but was 1은 재현 코드가 아직 순차 결과를 돌려준다는 뜻입니다. 여기서는 승인 수 2를 확인하는 것이 올바른 검사입니다. 운영 코드가 2명을 승인해도 된다는 뜻이 아닙니다. observation timeout은 관찰자 수와 풀 크기, latch를 기다리는 위치를 점검하라는 신호입니다. worker timeout은 제출한 작업이 종료되는 경로를 찾으라는 신호입니다. 서로 다른 실패를 모두 경쟁 상태라는 한 문장으로 합치지 않습니다.
재현 결과로부터 말할 수 있는 범위
이 작은 모델은 특정 실행 순서가 이중 승인을 만들 수 있음을 보여 줍니다. 실제 HTTP 요청, DB 잠금, 네트워크의 모든 동작을 대신하지 않습니다. 재현을 만들 때는 축소한 요소와 유지한 규칙을 함께 적습니다. 유지한 것은 도서 한 권, 두 관찰자, 확인 후 변경이라는 순서입니다. 축소한 것은 사용자 인증, 테이블 저장, 상태 코드입니다. 다음 레슨에서는 같은 문제를 실제 DB의 제약과 트랜잭션 결과로 확인합니다.
제출할 설명을 준비합니다
수정한 reproduce 메서드와 통과한 검사 두 개를 제출합니다. A가 빈 상태를 읽음, B가 빈 상태를 읽음, A 승인, B 승인이라는 순서를 자신의 말로 기록합니다. 같은 JVM 안에서 공유한 AtomicInteger와 서로 독립적인 free 변수를 구분합니다. 이어서 여러 서버가 같은 DB를 쓰면 Java 모니터 하나로 보호할 수 없는 이유를 설명합니다. 스레드의 메모리 모델과 동기화 도구의 일반 원리는 더 읽기 장에서 확장하고 여기서는 재현 가능한 도서 승인 실험에 집중합니다.
따라하기
실패한 검사를 찾습니다
starter에서 실행합니다. twoObservationsCauseDoubleAcceptance의 기대 2와 실제 1을 비교합니다.
./mvnw testTODO의 경계를 복원합니다
RaceModel.java의 reproduce에 두 작업 제출, 읽기 이후 latch, 승인 수 수집을 구현합니다. solution 코드와 비교하여 free를 읽는 지점을 latch보다 앞에 둡니다.
수정 후 전체 검사를 실행합니다
재현 순서가 구현된 뒤 프로젝트 검사를 실행합니다. 두 승인 재현과 독립 실행의 상태 초기화가 모두 통과하는지 검사 요약에서 확인합니다.
./mvnw test두 관찰 뒤 승인 수를 직접 봅니다
컴파일된 solution의 재현 모델을 실행한 결과입니다. 기대 승인 1건과 실제 모델 승인 2건의 차이를 설명합니다.
java -cp target/classes lab.RaceModel실행 결과
accepted=2
확인 문제
실습
RaceModel.java의 reproduce에 두 작업 제출, 읽기 이후 latch, 승인 수 수집을 구현합니다. solution 코드와 비교하여 free를 읽는 지점을 latch보다 앞에 둡니다. 테스트는 삭제하거나 완화하지 않습니다. 정상·경계·실패 결과와 작업 종료를 확인하여 수정 코드와 실행 보고서를 제출합니다.
실행 명령
./mvnw test
기대 결과
실패·오류·건너뜀 0. starter는 이 레슨 TODO 관련 검사 일부가 실패합니다.
모범 답안
모범 답안 내려받기더 읽기
면접 질문
- 두 회원의 동시 대여가 경쟁 상태가 되는 실행 순서를 설명합니다.