9주차 미리보기
W09 미리보기 · 웹소설 본편

STARRY PASS 5 — 사라진 두 번째 1,000원

두 사람이 같은 잔액을 동시에 만졌을 때, 둘 다 끝냈는데 왜 한 번만 반영되는지 추적하는 밤

공연을 마친 STARRY 계산대에는 닫힌 정산함 하나와 작은 시험 봉투 여섯 장이 놓여 있었다. 니지카는 봉투들을 날짜 대신 번호순으로 늘어놓고, 맨 앞에 굵은 글씨로 규칙을 적었다.

‘봉투를 바꿀 때마다 시험 무대를 새로 만든다. 앞 봉투의 결과를 다음 봉투의 시작값으로 쓰지 않는다.’

“그러면 오늘 이야기는 한 손님 돈이 여섯 번 이어서 움직이는 게 아니네요?” 히토리가 물었다.

“맞아. 같은 계산대를 쓰는 것처럼 보여도 매번 보관함을 비우고 그 시험이 요구한 카드와 기록을 다시 준비해.” 니지카가 답했다. “오늘은 새 기능을 마구 붙이는 날도 아니야. 이미 있던 약속을 가리고 설명한 뒤, 지금도 그대로 지켜지는지 다시 보는 날이지.”

키타가 첫 봉투를 열었다. 안에는 ‘돈을 옮기는 일은 반드시 공식 출입문으로 들어온다’는 짧은 문장이 있었다. 계산대 앞에는 멀쩡한 문 하나와 직원만 아는 지름길 하나가 그려져 있었다.

“공식 문으로 들어오면 문지기가 작업 전체에 큰 봉투를 씌워 줘요.” 키타가 말했다. “중간에 문제가 나면 봉투 안에서 바꾼 것을 한꺼번에 되돌릴 수 있게요.”

히토리는 시험표를 천천히 읽었다. 이 시험이 직접 확인하는 것은 딱 두 가지였다. 실제로 받은 이동 담당자가 문지기를 거치는 물건인지, 그리고 누구나 호출하는 이동 함수 자체에 ‘이 함수가 봉투의 경계’라는 표지가 붙어 있는지였다.

“그럼 실제로 돈을 옮겨 보고 성공과 취소까지 확인한 거예요?”

료가 고개를 저었다. “그건 다른 시험이 해. 이 첫 표는 문이 설치됐고 공개된 입구에 경계 표지가 있는지만 본다. 문이 있다는 사실과, 그 문을 지나 실제 모든 상황이 안전하다는 사실은 같은 말이 아니야.”

AOP 프록시와 공개 트랜잭션 표지의 직접 확인 범위
그림 한눈에: 첫 시험은 이동 담당자가 공식 문지기를 거치는지와 공개 입구에 봉투 경계 표지가 있는지만 확인한다.
같은 화면의 시험 범위를 확인하는 키타와 히토리의 공식 장면
그림 한눈에: 키타와 히토리가 ‘문이 있다’와 ‘모든 이동이 성공한다’를 같은 주장으로 섞지 않고 시험표를 함께 읽는다.

첫 봉투를 닫은 니지카는 두 번째 봉투에서 LOST-UPDATE라고 적힌 카드 한 장을 꺼냈다. 시작 잔액은 10,000원이었다. 카드 앞에는 두 개의 창구가 나란히 놓였다.

“두 창구에서 각각 1,000원씩 빼 달라는 요청이 동시에 왔다고 해 보자.”

히토리는 바로 8,000원을 적었다. 10,000원에서 1,000원을 두 번 빼면 8,000원이었다. 그런데 료는 두 직원의 읽기와 쓰기를 따로 적어 보라고 했다.

첫 번째 직원이 10,000원을 읽었다. 아직 첫 직원이 새 값을 저장하기 전에 두 번째 직원도 10,000원을 읽었다. 둘은 출발 신호가 떨어질 때까지 서로가 읽기를 끝냈다는 사실을 확인하고 기다렸다. 신호가 떨어지자 첫 번째 직원은 자기가 읽은 10,000원에서 1,000원을 뺀 9,000원을 저장했다. 두 번째 직원도 자기가 읽은 10,000원에서 1,000원을 뺀 9,000원을 저장했다.

완료표는 두 장이었다. 두 작업 모두 끝났다고 기록됐다. 하지만 마지막 카드에는 9,000원만 남았다.

두 작업이 모두 만원을 읽고 구천원을 써서 변화 하나가 사라지는 시간선
그림 한눈에: 두 직원 모두 10,000원을 읽고 각각 9,000원을 써서 완료는 2번인데 최종 잔액은 9,000원이다.

“두 번째 빼기가 실패한 것도 아닌데 사라졌어요.” 히토리가 중얼거렸다.

“정확히는 두 번째 작업의 저장값이 첫 번째와 똑같아서, 변화 하나가 다른 변화 위를 덮은 거야.” 니지카가 말했다. “그래서 완료 횟수만 세면 사고를 못 봐. 마지막 값도 함께 봐야 해.”

이 실험에는 일부러 평소 보호 장치를 우회하는 시험 전용 메모 방식이 쓰였다. 직원은 저장하기 직전에 ‘내가 읽은 값이 아직 최신인지’를 확인하지 않고, 읽어 둔 숫자를 바탕으로 새 잔액을 그대로 덮어썼다. 마지막에는 카드의 변경 번호도 0인지 확인했다.

“그럼 우리가 실제 계산대에서 쓰는 모든 저장 방식이 고장 났다는 증거인가요?”

“아니.” 니지카가 빨간 선을 그었다. “이건 사고 모양을 확실히 재현하려고 만든 시험 전용 길이야. 실제 운영 경로의 변경 번호 보호가 깨졌다는 시험이 아니고, 세상의 모든 동시 작업이 늘 이렇게 된다는 뜻도 아니야.”

키타는 같은 시작 잔액 10,000원으로 시험 무대를 다시 만들었다. 이번에는 카드 한 장을 한 직원이 펼쳐 보는 동안 다른 직원이 기다리는 줄을 세웠다. 첫 직원이 10,000원을 읽고 9,000원을 저장한 뒤 카드를 놓았다. 그제야 둘째 직원이 9,000원을 읽고 8,000원을 저장했다.

완료표는 이번에도 두 장이었다. 최종 잔액은 8,000원이었다.

줄이 없을 때 최종 구천원과 행 잠금 뒤 최종 팔천원을 비교한 표
그림 한눈에: 줄이 없으면 10,000원을 두 번 읽어 최종 9,000원, 줄을 세우면 10,000→9,000→8,000원이 된다.

“줄을 세우면 두 변화가 모두 남네요.”

“오늘 준비한 두 작업에서는 그래.” 료가 말했다. “하지만 이 표가 모든 손님에게 공평한 순서를 약속하거나, 교착이 절대 없거나, 사람이 아주 많아도 빠르다고 증명한 건 아니야. 두 작업이 모두 끝났고 마지막이 8,000원이라는 데까지만 직접 봤어.”

두 실험의 기다림에도 울타리가 있었다. 각 독립 작업은 최대 10초짜리 봉투 안에서 수행됐고, 바깥에서 완료표를 기다릴 때는 작업마다 최대 20초만 기다렸다. 그것은 시험이 영원히 멈춰 있지 않게 하는 안전장치였다. 일부러 느린 보관함이나 실제 시간 초과를 만들어 동작을 검증한 것은 아니었다.

세 번째 봉투에는 FAIL-FROM 10,000원과 FAIL-TO 5,000원 카드가 들어 있었다. W7-FAIL이라는 이동표에는 1,000원이 적혀 있었다. 키타가 이동을 시작하자 보내는 카드와 받는 카드, 거래표와 장부표가 봉투 안에서 바뀌기 시작했다.

그리고 업무 값을 바꾼 바로 다음, 약속된 종이 한 번 울렸다. 종이 울리면 갑작스러운 중단이 일어나도록 미리 연결해 둔 시험이었다. 중단 문구는 ‘업무 변경 뒤 일부러 일으킨 중단’이라고 정확히 적혀 있었다.

히토리는 오래된 설명 카드 하나를 보다가 고개를 갸웃했다. 카드에는 고장 위치가 여러 곳인 것처럼 보이는 문장이 있었다. 니지카는 그 카드를 덮고 지금 실행되는 시험표를 다시 펼쳤다.

“오늘 근거는 이 시험표야. 여기에는 업무 변경 뒤 울리는 종 하나만 있어. 시작 직후의 별도 종이나 세 번째 고장 위치를 실제로 실행했다고 늘려 말하면 안 돼.”

중단이 난 뒤, 료는 작업 중 보던 화면을 그대로 믿지 않고 보관함을 새로 열었다. 보내는 카드는 10,000원, 받는 카드는 5,000원이었다. 이동이라고 적힌 거래표는 0장, 이동이라고 적힌 장부표도 0장이었다. 고장 장치가 실제로 불렸다는 표시도 켜져 있었다.

업무 변경 뒤 한 실패 지점의 만원 오천원 영행 영행 롤백 영수증
그림 한눈에: 업무 변경 뒤 한 지점에서 갑작스러운 중단을 넣자 새 조회의 끝값은 10,000원·5,000원·0건·0건이다.

“그럼 보관함 전원이 나가거나, 너무 오래 기다리거나, 다른 종류의 오류가 나도 다 똑같다고 써도 될까요?”

“오늘 시험은 그 말을 허락하지 않아.” 니지카가 답했다. “직접 넣은 한 종류의 갑작스러운 중단과 한 위치만 확인했어. 실제 전원 중단, 느린 조회, 기다림 종료, 점검이 필요한 다른 오류는 별도 시험이 있어야 해.”

그 옆에는 독립된 장부 연습장이 한 권 놓여 있었다. 이번에는 거래표의 실패 이유 칸이 비어 있으면 화면에 NONE이라고 보여 주는 문제였다. 비어 있는 칸 자체를 저장된 글자 NONE으로 바꾸는 일이 아니라, 읽어 보여 줄 때만 빈칸 대신 NONE을 고르는 일이었다.

키타가 성공 거래 하나를 가리켰다. 그 행의 실패 이유는 비어 있었다. 히토리가 빈칸이면 NONE, 값이 있으면 원래 값을 고르는 표시식을 붙이자 화면에는 NONE이 나타났다. 실패 거래의 INSUFFICIENT_BALANCE 같은 실제 이유는 그대로 남았다.

failure_reason NULL은 NONE으로 값이 있으면 그대로 표시하는 COALESCE 표
그림 한눈에: 거래표 한 행씩 읽어 실패 이유가 비면 NONE, 값이 있으면 원래 이유를 표시하며 결과 표시값의 NULL은 0건이어야 한다.

“이 연습장은 방금 중단 시험이 자동으로 채점하나요?” 히토리가 물었다.

“아니. 같은 묶음에 들어 있을 뿐 별도 문제야.” 료가 말했다. “어느 표의 한 행을 읽는지, 결과 한 행이 무엇인지, 몇 행을 예상하는지 먼저 적고 직접 실행 결과를 남겨야 해. 제공된 정답 파일도 없으니 예시 문장과 자기 실행 증거를 구분해야 하고.”

네 번째 봉투에는 큰 투명 봉투와 작은 봉투가 포개져 있었다. 료가 빈 시험용 표를 만들고 먼저 큰 봉투 안에 OUTER_ROLLED_BACK이라는 표지를 넣었다. 그런 다음 큰 봉투를 잠시 멈추고, 별도 계산 담당자를 통해 새 작은 봉투를 열어 INNER_COMMITTED라는 표지를 넣었다. 작은 봉투는 그 자리에서 따로 닫아 확정했다.

큰 봉투로 돌아온 료는 ‘바깥 취소’라는 갑작스러운 중단을 일으켰다. 큰 봉투는 닫히지 못하고 안의 변경이 취소됐다. 마지막에 시험용 표를 새로 읽자 한 줄만 남아 있었다.

INNER_COMMITTED.

OUTER_ROLLED_BACK은 없었다.

안쪽 독립 봉투는 확정되고 바깥 봉투만 취소되는 흐름
그림 한눈에: 작은 봉투는 별도로 확정되고 큰 봉투만 취소되어 마지막 표에는 INNER_COMMITTED 한 줄만 남는다.
태블릿을 보며 독립 트랜잭션 결과를 설명하는 니지카의 공식 장면
그림 한눈에: 니지카가 ‘안쪽이 따로 남을 수 있다’는 결과와 ‘그러니 언제나 좋은 설계다’라는 판단을 분리해 설명한다.

“안쪽 기록을 따로 확정하면 더 안전한 거 아닌가요?” 키타가 물었다.

“필요한 경우는 있지만 자동으로 더 안전해지는 건 아니야.” 니지카가 말했다. “바깥 일이 실패했는데 안쪽 흔적만 남는 것이 요구사항일 때만 맞아. 둘이 반드시 함께 성공해야 하는 업무에 함부로 쓰면 오히려 부분 완료를 만들 수 있어.”

히토리는 두 담당자가 왜 서로 다른 물건으로 준비됐는지도 물었다. 료는 공식 문지기가 바깥 호출을 가로채 새 작은 봉투를 열어야 하기 때문이라고 설명했다. 한 물건이 자기 안의 다른 함수를 바로 부르면 그 문지기를 건너뛸 수 있어, 오늘 시험은 안쪽 담당자를 별도 물건으로 만들어 주입했다.

“그래도 이 한 줄로 감사 기록 설계가 옳다거나, 외부 결제까지 함께 안전하다고 말할 수는 없겠네요.”

“정확해. 확인한 것은 시험용 표에서 안쪽 한 줄만 남는다는 결과뿐이야.”

다섯 번째 봉투를 열자 새로운 고장 장치는 없었다. 세 번째 봉투에서 확인했던 똑같은 중단 시험표가 다시 들어 있었다. 정답과 이전 결과를 가리고 시간이 지난 뒤, 원인과 네 끝값을 다시 설명하고 같은 시험을 재실행하는 봉투였다.

히토리는 다시 적었다. FAIL-FROM 10,000원, FAIL-TO 5,000원, 이동 거래표 0장, 이동 장부표 0장. 고장 위치는 업무 변경 뒤 한 곳. 실제 시간 초과나 느린 조회는 확인하지 않음.

“같은 시험을 두 번 실행했으니 고유한 시험도 두 개로 세나요?”

“실행 횟수에는 두 번 들어가지만 서로 다른 시험 이름은 하나야.” 료가 답했다. “세 번째 봉투에서 한 번, 다섯 번째 봉투에서 한 번. 반복 회수라는 사실을 숫자에서 숨기면 안 돼.”

같은 봉투 옆에는 두 번째 독립 장부 연습장이 있었다. 2026년 7월 1일 하루의 거래만 고르는 문제였다. 씨앗 자료에는 정확히 세 시각이 들어 있었다. 7월 1일 00:00:00, 12:00:00, 23:59:59.999. 바로 다음 날인 7월 2일 00:00:00에도 다른 거래가 하나 있었다.

히토리는 하루의 마지막을 23:59:59라고 적으려다 멈췄다. 그렇게 쓰면 23:59:59.999를 놓칠 수 있었다. 대신 시작은 7월 1일 00:00:00 이상, 끝은 7월 2일 00:00:00 미만으로 정했다. 결과는 정확히 3행이었고, 다음 날 0시 행은 들어오지 않았다.

2026년 7월 1일 반개구간에 포함되는 세 시각과 제외되는 다음 날 자정
그림 한눈에: 7월 1일 0시는 포함하고 7월 2일 0시는 제외하면 00:00·12:00·23:59:59.999의 세 행이 정확히 남는다.

“하루 끝의 소수점 자릿수가 더 길어져도 다음 날 0시 미만이라는 규칙이면 놓치지 않겠어요.” 키타가 말했다.

“그래서 마지막 순간을 추측해 박아 두지 않는 거야.” 니지카가 답했다. “이 문제도 앱의 이동 시험과는 별도인 연습용 거래표에서 실행하고, 입력 한 행과 출력 한 행의 뜻, 예상 3행, 경계 반례를 함께 남겨야 해.”

마지막 봉투에는 코드보다 먼저 네 칸짜리 보관함 지도가 들어 있었다. 계좌 카드함, 업무 거래함, 장부함, 같은 요청을 중복 처리하지 않기 위한 요청함. 빈 보관소에 첫 설계도를 적용한 뒤, 이 네 종류의 표가 정확히 생기는지 보는 시험이었다.

히토리는 네 이름을 하나씩 읽었다. account, business_tx, ledger_entry, idempotency_request.

“열이나 규칙까지 전부 확인하나요?”

“이 시험문이 직접 비교하는 것은 표 이름 네 개야.” 료가 답했다. “순서는 상관없지만, 작업 도구가 따로 관리하는 이력표를 빼고 나면 이 네 표가 정확히 있어야 해. 각 열, 모든 연결 규칙, 빠른 찾기용 표식, 실행 중 봉투 동작까지 이 한 시험이 전부 확인하는 건 아니야.”

account business_tx ledger_entry idempotency_request 네 핵심 표
그림 한눈에: 빈 보관소에 첫 설계도를 적용한 뒤 관리용 이력표를 제외하고 네 핵심 표 이름이 정확히 있는지만 직접 비교한다.

여섯 봉투를 모두 닫은 뒤 니지카는 마지막 계산을 시작했다. 첫 봉투의 시험은 1개였다. 두 번째 봉투에는 줄 없는 경우와 줄을 세운 경우, 2개가 있었다. 세 번째는 중단 시험 1개. 네 번째는 안쪽 별도 확정 시험 1개. 다섯 번째는 앞서 본 중단 시험 1개를 다시 실행했다. 마지막은 네 표 이름 시험 1개였다.

1 + 2 + 1 + 1 + 1 + 1 = 실행 노출 7회.

하지만 중단 시험 하나가 두 번 등장했으므로 서로 다른 고유 시험은 6개였다. 그중 이번 주에 처음 들어온 것은 줄 없는 경우, 줄을 세운 경우, 안쪽 별도 확정의 3개였다. 나머지 3개는 이전에 있던 공식 문, 중단 취소, 네 표 이름 시험을 다시 꺼낸 것이었다.

day 1부터 6까지 실행 7회 고유 6개 신규 3개를 구분한 표
그림 한눈에: 여섯 봉투에서 실행은 7회지만 중단 시험이 두 번이어서 고유 시험은 6개, 이번 주에 새로 들어온 시험은 3개다.
계산대에서 네 사람이 시험 결과를 맞춰 보는 공식 장면
그림 한눈에: 네 사람이 성공 숫자 하나가 아니라 각 시험이 직접 본 값과 보지 않은 경계를 마지막으로 맞춰 본다.

모든 봉투에는 ‘제공된 시험 결과를 다시 확인함’이라는 표지가 붙었다. 새 시험 세 개가 이번 주에 들어오기는 했지만, 학습자가 처음부터 쓴 정답으로 기록되는 항목은 0개였다. 여섯 봉투의 일곱 실행 결과가 모두 제공 자료 재확인 범주였다. 실행된 시험이 0개가 아니며, 실패·오류·건너뜀이 모두 0이고, 대상 파일의 지문까지 맞아야 초록 도장을 찍을 수 있었다.

히토리는 범위표의 왼쪽에 직접 본 것을 적었다. 공식 문지기와 공개 입구 표지. 10,000원에서 두 번 빼고도 9,000원만 남는 재현. 줄을 세워 8,000원이 되는 비교. 한 고장 지점 뒤 10,000원·5,000원·0·0. 바깥 중단 뒤 INNER_COMMITTED 한 줄. 네 핵심 표 이름. 그리고 별도 연습장의 빈 실패 이유를 NONE으로 보이기, 7월 1일 세 행 고르기.

오른쪽에는 아직 말할 수 없는 것을 적었다. 모든 동시 상황과 모든 격리 규칙, 공평함과 성능, 교착 없음, 실제 보관함 중단과 느린 조회, 모든 오류 종류, 자기 안에서 바로 부른 함수, 외부 시스템까지 묶인 원자성, 네 표의 모든 열과 제약.

“이렇게 적고 보니 초록 도장은 ‘어떤 느낌으로 안전하다’가 아니네요.” 히토리가 말했다. “어떤 시작값에서 어떤 일을 시켰고, 마지막에 무엇을 직접 읽었는지가 정해진 작은 약속이에요.”

니지카가 웃었다. “그리고 그 작은 약속 여섯 개를 섞지 않는 게 오늘 가장 큰 약속이지.”

료는 LOST-UPDATE 카드에 10,000→9,000을, 줄 세운 카드에 10,000→9,000→8,000을 다시 적었다. 키타는 두 장의 완료표 옆에 서로 다른 색의 동그라미를 그렸다.

공연장 불이 꺼질 무렵, 히토리는 마지막 문장을 남겼다.

‘완료가 두 번이라고 변화가 두 번 남은 것은 아니다. 마지막 상태를 함께 보아야 사라진 변화를 찾을 수 있다. 반대로 한 작업만 따로 확정하면 바깥 취소 뒤에도 그 흔적이 남을 수 있다. 어느 쪽도 무조건 좋은 주문은 아니다. 같은 봉투에 묶어야 할 것과 따로 닫아도 될 것을 먼저 정하고, 시험이 실제로 확인한 숫자까지만 말한다.’

W09 미리보기 · 히토리 질문 노트

히토리의 질문 노트 — W09 동시 변경과 따로 닫히는 봉투

본문에서 쉬운 말로 본 장면을 실제 코드·테스트·SQL 이름에 연결합니다. 각 답은 “직접 보장”과 “아직 보장하지 않음”을 함께 적습니다.

1. 첫 장면의 ‘공식 출입문’은 실제로 무엇인가요?

Spring은 서비스 객체 바깥에 대리 문지기를 둘 수 있습니다. 다른 객체가 TransferService의 공개 메서드를 호출하면 그 문지기가 호출을 가로채 트랜잭션을 시작하고, 정상 종료면 확정하고, 롤백 대상 예외면 취소합니다. 본문의 ‘공식 출입문’은 이 프록시 경로를 쉽게 표현한 말입니다.

하지만 프록시 객체가 있다는 사실 하나만으로 모든 호출이 반드시 올바른 경계를 지난다고 결론 내리면 안 됩니다. 같은 객체 안에서 자기 메서드를 곧바로 부르는 호출은 프록시를 우회할 수 있고, 이번 W9 day 1 시험은 그런 self-invocation을 실행하지 않습니다.

실제 기술 이름Spring AOP proxy, @Transactional, public transaction boundary, self-invocation boundary

AOP 프록시와 공개 트랜잭션 표지의 직접 확인 범위
그림 한눈에: 테스트가 직접 확인한 프록시 객체와 공개 메서드 표지, 직접 실행하지 않은 내부 지름길을 구분한다.

2. TransactionProxyIT는 정확히 무엇을 확인하나요?

고유 테스트 메서드는 transferBeanIsProxiedAndPublicMethodOwnsTheBoundary 하나입니다. 첫 assertion은 주입받은 TransferServiceAopUtils.isAopProxy(transfers)에서 true인지 봅니다. 둘째 assertion은 리플렉션으로 공개 transfer(TransferService.Command) 메서드를 찾아 @Transactional이 붙었는지 봅니다.

이 시험은 실제 이체를 호출하지 않습니다. 따라서 커밋·롤백 결과, 잔액, 원장 행, 예외 종류, 같은 클래스 내부 호출까지 직접 보장하지 않습니다. 이름이 넓게 들려도 assertion 두 줄의 범위를 넘겨 말하지 않는 것이 핵심입니다.

실제 기술 이름TransactionProxyIT.transferBeanIsProxiedAndPublicMethodOwnsTheBoundary, AopUtils.isAopProxy, reflection

3. ‘사라진 변경’은 무슨 뜻인가요?

두 작업이 같은 시작값 10,000원을 읽고 각각 1,000원을 뺀 9,000원을 저장하면, 둘 다 끝났어도 최종값은 8,000원이 아니라 9,000원입니다. 두 번째 저장이 첫 번째 저장과 같은 숫자로 덮이면서 변화 한 번이 최종 상태에서 사라진 것입니다.

중요한 점은 “작업 하나가 예외로 실패했다”가 아니라는 것입니다. 이번 재현에서는 두 작업 모두 완료표를 돌려줍니다. 그래서 완료 횟수 2와 최종 잔액 9,000원을 함께 봐야 문제를 발견합니다.

실제 기술 이름lost update, read-modify-write race, final-state assertion

두 작업이 모두 만원을 읽고 구천원을 써서 변화 하나가 사라지는 시간선
그림 한눈에: T1과 T2가 모두 10,000원을 읽고 9,000원을 쓰는 순서를 따라가면 사라진 1,000원이 보인다.

4. 줄 없는 실험의 테스트 메서드와 직접 확인값은 무엇인가요?

메서드 이름은 test_only_versionless_unconditional_updates_commit_twice_and_lose_one_update입니다. LOST-UPDATE 계좌를 10,000원으로 연 뒤, 독립 트랜잭션 두 개가 각각 잔액을 읽습니다. 둘 다 읽기를 마칠 때까지 장벽에서 맞춘 다음, 각자 observed - 1_000을 조건 없이 덮어씁니다.

직접 assertion은 세 가지입니다. 완료된 작업 수는 2, 최종 잔액은 9,000, version은 0입니다. 이 세 값이 함께 있어야 “두 작업은 끝났지만 한 변화가 소실된 시험 전용 기준선”이라는 뜻이 완성됩니다.

실제 기술 이름LostUpdateBaselineIT.test_only_versionless_unconditional_updates_commit_twice_and_lose_one_update, unconditional SQL UPDATE, version 0

5. 왜 CountDownLatch를 쓰나요? 데이터베이스 잠금인가요?

첫 실험의 readBarrier = new CountDownLatch(2)는 두 스레드가 모두 10,000원을 읽은 뒤에 쓰기를 시작하도록 순서를 맞춥니다. 사고가 우연히 일어나기를 기다리지 않고, 같은 오래된 값을 읽는 장면을 재현 가능하게 만드는 시험 조정 장치입니다.

이 장벽은 애플리케이션 메모리에 있는 동시성 도구이지 행을 잠그는 데이터베이스 잠금이 아닙니다. countDown()await()가 읽기 시점을 맞춰 줄 뿐, 다른 트랜잭션이 계좌 행을 읽거나 쓰지 못하게 보호하지는 않습니다.

실제 기술 이름CountDownLatch, deterministic concurrency test, test barrier versus database lock

6. 왜 이 실험을 실제 운영의 @Version 실패라고 부르면 안 되나요?

문제 재현 경로는 JPA 엔티티 저장 대신 원시 SQL로 UPDATE account SET balance=:balance WHERE id=:id를 실행합니다. WHERE version=:oldVersion 같은 비교가 없고, 버전도 올리지 않습니다. 이름에 test_only_versionless_unconditional_updates가 들어간 이유입니다.

따라서 이 시험은 “보호 장치가 없는 읽기-계산-덮어쓰기라면 lost update가 이렇게 보인다”를 보여 줍니다. 운영의 낙관적 잠금 경로가 망가졌다는 증거도 아니고, 모든 격리 수준에서 항상 같은 결과가 난다는 증거도 아닙니다.

실제 기술 이름versionless test-only raw SQL, optimistic locking boundary, @Version non-coverage

7. 줄을 세운 실험은 정확히 어떻게 8,000원을 보존하나요?

메서드 이름은 production_pessimistic_lock_path_commits_twice_and_preserves_both_updates입니다. 두 작업이 출발한 뒤 각자 SELECT balance FROM account WHERE id=:id FOR UPDATE로 같은 행을 읽습니다. 첫 작업이 잠금을 쥐고 10,000→9,000을 저장하는 동안 둘째는 기다립니다. 첫 작업이 놓은 뒤 둘째는 최신 9,000원을 읽어 8,000원을 저장합니다.

직접 assertion은 완료 수 2와 최종 잔액 8,000입니다. SQL은 저장 때 version=version+1도 수행하지만 이 테스트는 마지막 버전 숫자를 assertion하지 않습니다. 실행된 코드와 직접 보장된 관찰값을 구분해야 합니다.

실제 기술 이름LostUpdateBaselineIT.production_pessimistic_lock_path_commits_twice_and_preserves_both_updates, SELECT ... FOR UPDATE, pessimistic row lock

줄이 없을 때 최종 구천원과 행 잠금 뒤 최종 팔천원을 비교한 표
그림 한눈에: 조건 없는 덮어쓰기의 최종 9,000원과 행 잠금 경로의 최종 8,000원을 같은 시작값에서 비교한다.

8. 행 잠금 시험이 보장하지 않는 것은 무엇인가요?

이 시험은 준비된 계좌 하나와 작업 두 개에서 둘 다 완료되고 최종값이 8,000원임을 확인합니다. 공평한 대기 순서, 교착 상태가 절대 없음, 높은 부하에서의 처리량, 여러 계좌를 잡는 전체 잠금 순서, 모든 격리 수준의 동작은 측정하지 않습니다.

또한 FOR UPDATE가 언제나 최선이라는 결론도 내리지 않습니다. 충돌이 잦은지, 기다림 비용을 감당할 수 있는지, 낙관적 잠금과 재시도 중 무엇이 맞는지는 업무와 부하를 보고 선택해야 합니다.

실제 기술 이름pessimistic locking trade-off, deadlock non-coverage, throughput non-coverage

9. 두 동시 작업의 REQUIRES_NEW와 뒤의 안쪽 봉투 REQUIRES_NEW는 같은 이야기인가요?

같은 전파 옵션을 쓰지만 관찰 목적이 다릅니다. LostUpdateBaselineIT에서는 스레드마다 TransactionTemplate을 새로 만들어 두 작업을 서로 독립된 트랜잭션으로 실행합니다. 그래야 둘이 같은 계좌를 실제로 동시에 읽고 경쟁할 수 있습니다.

TransactionPropagationIT에서는 이미 바깥 트랜잭션이 있는 상태에서 안쪽 호출이 그것을 잠시 멈추고 새 트랜잭션을 열어 먼저 확정하는 결과를 봅니다. 앞 실험은 동시 경쟁을 만들기 위한 독립성, 뒤 실험은 바깥 롤백과 안쪽 커밋을 갈라 보는 전파 규칙입니다.

실제 기술 이름PROPAGATION_REQUIRES_NEW, TransactionTemplate, propagation semantics

10. 10초와 20초는 무엇이며, 무엇을 증명하지 않나요?

TransactionTemplate의 timeout은 10초입니다. 첫 실험의 읽기 장벽도 최대 10초 기다립니다. 바깥 테스트는 각 Futurefuture.get(20, TimeUnit.SECONDS)로 최대 20초 기다립니다. 시험이 꼼짝없이 멈추지 않도록 둔 시간 울타리입니다.

이 값이 있다는 사실은 느린 SQL을 일부러 만들었다거나 데이터베이스 timeout 뒤 롤백을 검증했다는 뜻이 아닙니다. 정상 fixture가 제한 안에서 끝났다는 것과 timeout 고장 경로를 실행했다는 것은 별개입니다.

실제 기술 이름transaction timeout 10 seconds, latch timeout 10 seconds, Future.get timeout 20 seconds

11. 실패 주입 시험의 실제 메서드와 고장 지점은 무엇인가요?

메서드 이름은 runtimeExceptionAfterBusinessMutationRollsBackEveryTransferEffect입니다. 실제 소스의 TransferFailureHook은 업무 변경 뒤 호출되는 한 지점에서 RuntimeException("injected after business mutation")을 던집니다. AtomicBoolean invokedtrue가 되어 고장 장치가 실제 호출됐는지도 확인합니다.

현재 실행 소스에는 afterClaim을 따로 시험하는 두 번째 훅이나 세 개의 고장 위치가 없습니다. PDF의 오래된 재검증 카드 일부가 두 훅을 말하지만, selector가 실제 실행하는 최신 Java 파일과 충돌하므로 미리보기는 실행 소스의 한 지점을 기준으로 삼습니다.

실제 기술 이름TransferFailurePointIT.runtimeExceptionAfterBusinessMutationRollsBackEveryTransferEffect, TransferFailureHook, AtomicBoolean

업무 변경 뒤 한 실패 지점의 만원 오천원 영행 영행 롤백 영수증
그림 한눈에: 실제 selector의 고장 지점은 업무 변경 뒤 한 곳이며, assertion 영수증은 10,000·5,000·0·0이다.

12. 롤백 시험의 시작값과 마지막 assertion 네 개는 무엇인가요?

시작 계좌는 FAIL-FROM 10,000원과 FAIL-TO 5,000원입니다. 요청은 W7-FAIL, 금액은 1,000원입니다. RuntimeException 뒤 새 JDBC 조회로 보내는 잔액 10,000, 받는 잔액 5,000, tx_type='TRANSFER'business_tx 0행, entry_type LIKE 'TRANSFER_%'ledger_entry 0행을 정확히 확인합니다.

idempotency_request 0행은 이 메서드의 assertion 목록에 없습니다. 전원 중단, timeout, checked exception, 재전송도 실행하지 않습니다. 따라서 “모든 실패 지점과 모든 저장 효과가 증명됐다”가 아니라 “업무 변경 뒤 RuntimeException 한 지점의 네 관찰값이 원상복구됐다”가 정확합니다.

실제 기술 이름rollback invariant, fresh JDBC observation, transfer transaction count, transfer ledger count

13. 같은 실패 시험이 두 번 나오면 무엇이 달라지나요?

W9 day 3과 day 5의 selector는 둘 다 com.example.financialcore.transfer.TransferFailurePointIT입니다. day 5는 새 테스트가 아니라 24–72시간 뒤 정답과 이전 로그를 가리고 같은 계약을 다시 설명·실행하는 지연 회수입니다.

따라서 실행 노출 수에는 두 번을 모두 셉니다. 하지만 고유 테스트 메서드 수에는 runtimeExceptionAfterBusinessMutationRollsBackEveryTransferEffect 하나만 셉니다. day 5 설명은 timeout·slow query를 주장하지 말라고 명시합니다.

실제 기술 이름delayed retrieval, repeated selector exposure, unique test de-duplication

14. 큰 봉투가 취소돼도 작은 봉투만 남는 테스트는 무엇인가요?

메서드 이름은 requiresNewCommitsWhileTheOuterTransactionRollsBack입니다. OuterProbe.insertOuterThenFail()은 바깥 트랜잭션에서 OUTER_ROLLED_BACK을 넣고, 주입받은 InnerProbe를 부른 뒤 RuntimeException("rollback outer")을 던집니다. InnerProbe.insertCommittedInnerRow()REQUIRES_NEW로 새 트랜잭션을 열어 INNER_COMMITTED를 넣고 먼저 확정합니다.

테스트는 예외 종류와 메시지를 확인한 뒤, 시험 전용 w9_propagation_probe 표를 ID 순으로 읽어 결과가 정확히 ["INNER_COMMITTED"]인지 확인합니다. 바깥 행은 0, 안쪽 행은 1이라는 결과가 한 목록 assertion에 담깁니다.

실제 기술 이름TransactionPropagationIT.requiresNewCommitsWhileTheOuterTransactionRollsBack, Propagation.REQUIRES_NEW, suspend outer transaction

안쪽 독립 봉투는 확정되고 바깥 봉투만 취소되는 흐름
그림 한눈에: 안쪽 트랜잭션이 먼저 확정된 뒤 바깥 RuntimeException이 나면 INNER_COMMITTED만 남는다.

15. 왜 InnerProbeOuterProbe를 별도 bean으로 만드나요?

REQUIRES_NEW는 프록시가 호출을 가로챌 때 적용됩니다. 같은 객체의 메서드가 자기 안의 다른 메서드를 직접 부르면 프록시 바깥으로 나갔다가 다시 들어오지 않으므로 새 트랜잭션이 열리지 않을 수 있습니다.

시험은 @TestConfiguration에서 두 객체를 각각 bean으로 만들고, OuterProbeInnerProbe를 주입합니다. 그래서 바깥 객체에서 안쪽 객체로 넘어가는 호출이 프록시 경계를 통과한다는 구조가 분명합니다. 다만 이 테스트는 실제 self-invocation 실패 사례를 따로 실행해 비교하지는 않습니다.

실제 기술 이름separate Spring beans, proxy interception, self-invocation caveat, @TestConfiguration

16. REQUIRES_NEW는 왜 ‘무조건 더 안전한 옵션’이 아닌가요?

바깥 업무가 실패해도 안쪽 기록은 남습니다. 감사 흔적처럼 정말 독립 보존해야 하는 정보라면 의도한 결과일 수 있습니다. 반대로 주문과 결제가 반드시 함께 성공해야 하는데 한쪽만 별도 확정하면 부분 완료가 생깁니다.

이번 시험은 시험용 표에서 안쪽 한 줄이 남는 기계적 결과만 보여 줍니다. 감사로그 설계가 올바르다는 가치 판단, 외부 시스템과의 원자성, 보상 작업, 같은 클래스 내부 호출은 보장하지 않습니다.

실제 기술 이름independent commit risk, partial commit, transactional consistency boundary

17. 네 핵심 표를 확인하는 테스트는 정확히 어디까지 보나요?

메서드 이름은 v001CreatesExactlyTheRequiredCoreTables입니다. information_schema.tables에서 public 스키마의 기본 표를 읽되 flyway_schema_history는 제외하고, 결과가 account, business_tx, ledger_entry, idempotency_request 네 이름과 순서 무관하게 정확히 일치하는지 확인합니다.

이 assertion은 네 표의 존재와 불필요한 추가 기본 표가 없다는 범위를 봅니다. 모든 열, PK/FK/UNIQUE/CHECK, 인덱스, 마이그레이션 이력의 성공 행, 런타임 롤백 동작을 각각 직접 assertion하지 않습니다. PDF의 넓은 회수 명세와 실제 한 테스트의 직접 보장을 구분해야 합니다.

실제 기술 이름CoreSchemaIT.v001CreatesExactlyTheRequiredCoreTables, information_schema.tables, schema regression

account business_tx ledger_entry idempotency_request 네 핵심 표
그림 한눈에: 관리용 이력표를 제외한 public 기본 표 집합이 네 핵심 이름과 정확히 같은지를 확인한다.

18. 첫 마이그레이션 파일을 ‘불변’으로 본다는 것은 무슨 뜻인가요?

이미 어떤 환경에 적용된 버전 마이그레이션은 나중에 내용을 몰래 고치지 않고, 새 변경은 다음 버전 파일로 추가한다는 운영 원칙입니다. 기존 파일을 바꾸면 과거에 만든 DB와 새 DB의 구조가 달라져 재현이 어려워질 수 있습니다.

W9 day 6 목표 설명에는 immutable V001 회귀 검증이 포함됩니다. 그러나 현재 CoreSchemaIT의 직접 assertion은 네 표 이름 집합입니다. “원칙을 배운다”와 “이 메서드가 모든 불변성 위반을 자동 탐지한다”를 같은 뜻으로 쓰면 안 됩니다.

실제 기술 이름Flyway versioned migration, immutable migration principle, regression-test boundary

19. SQL Q09는 어느 표의 어느 칸을 다루나요?

Q09는 애플리케이션 V001 표가 아니라 SQL workbook의 business_tx를 읽는 별도 연습입니다. 이 표의 한 행은 업무 거래 한 건이고, failure_reason은 실패 이유가 없으면 NULL일 수 있습니다. 기본 fixture에는 거래 21행이 있고, 그중 NULL 이유는 17행, 실제 이유가 있는 행은 4행입니다. 출력도 거래 한 건당 한 행, 즉 기본 fixture에서는 21행이며 NULL 표시값이 0건이어야 합니다.

가장 단순한 예시 형태는 SELECT tx_id, COALESCE(failure_reason, 'NONE') AS failure_reason_display FROM business_tx ORDER BY tx_id;입니다. 빈 값은 화면에 NONE, 실제 값 INSUFFICIENT_BALANCENETWORK는 그대로 보입니다. 저장 데이터를 UPDATE로 바꾸는 문제는 아닙니다.

실제 기술 이름W9-SQL-Q09, workbook business_tx.failure_reason, SQL COALESCE

failure_reason NULL은 NONE으로 값이 있으면 그대로 표시하는 COALESCE 표
그림 한눈에: COALESCE는 NULL만 NONE으로 대체하고 이미 있는 실패 이유는 보존한다.

20. Q09의 정답 파일과 통과 증거는 어떻게 구분하나요?

reference project는 workbook 스키마와 seed를 제공하지만 Q09의 canonical learner answer 파일은 제공하지 않습니다. 위 SQL은 계약을 만족하는 예시 답안입니다. 학습자는 실행 전에 입력 표의 한 행 뜻, 출력 한 행 뜻, 예상 행 수를 주석으로 적고 관련 NULL 반례를 확인해야 합니다.

증거 경로는 evidence/w9/sql-q09.sql입니다. 통과 조건은 SQL 실행 성공, 결과의 NULL 표시 0건, 시작 표·cardinality·반례 설명입니다. Java의 TransferFailurePointIT가 이 SQL 파일의 정답을 직접 채점하지 않습니다.

실제 기술 이름prompt-only SQL answer, evidence/w9/sql-q09.sql, cardinality contract

21. SQL Q10에서 왜 23:59:59를 쓰면 안 되나요?

타임스탬프는 초보다 더 작은 단위를 가질 수 있습니다. workbook seed에는 2026-07-01 23:59:59.999+09 행이 실제로 있습니다. 끝을 <= 23:59:59로 쓰면 그 행을 놓칩니다. 데이터베이스 정밀도가 바뀔 때마다 ‘하루의 마지막 순간’을 다시 추측하는 방식도 취약합니다.

그래서 시작은 포함하고 다음 날 시작은 제외합니다. occurred_at >= 2026-07-01 00:00:00+09이면서 occurred_at < 2026-07-02 00:00:00+09이면 7월 1일의 모든 정밀도를 자연스럽게 포함합니다.

실제 기술 이름W9-SQL-Q10, half-open interval [start, nextStart), timestamp precision boundary

2026년 7월 1일 반개구간에 포함되는 세 시각과 제외되는 다음 날 자정
그림 한눈에: 23:59:59.999는 포함하고 다음 날 00:00:00은 제외하는 반개구간을 보여 준다.

22. Q10의 예시 SQL과 기대 결과 3행은 무엇인가요?

예시 형태는 SELECT tx_id, status, occurred_at FROM business_tx WHERE occurred_at >= TIMESTAMPTZ '2026-07-01 00:00:00+09' AND occurred_at < TIMESTAMPTZ '2026-07-02 00:00:00+09' ORDER BY occurred_at, tx_id;입니다.

workbook seed의 포함 행은 거래 ID 끝자리 201, 202, 203이고 시각은 00:00:00, 12:00:00, 23:59:59.999로 정확히 3행입니다. 7월 2일 00:00:00의 204 행은 제외됩니다. canonical learner answer는 없으므로 이것도 예시이며, 학습자 증거는 evidence/w9/sql-q10.sql에 실행 결과와 입력/출력 grain, 예상 3행, 경계 반례를 함께 남깁니다.

실제 기술 이름workbook business_tx.occurred_at, TIMESTAMPTZ +09, evidence/w9/sql-q10.sql

23. day 1부터 day 6까지 selector와 학습 대상은 어떻게 연결되나요?

순서는 다음과 같습니다. day 1은 TransactionProxyITTransferService.java, day 2는 LostUpdateBaselineIT와 같은 테스트 파일, day 3은 TransferFailurePointITTransferService.java, day 4는 TransactionPropagationIT와 같은 테스트 파일, day 5는 다시 TransferFailurePointITTransferService.java, day 6은 CoreSchemaITV001__common.sql입니다.

day 2와 day 4에만 W9 scaffold가 새 테스트 파일을 넣습니다. day 1·3·5·6은 이전 주에 들어온 selector를 회수합니다. 그 이후의 누적 selector 묶음은 이번 미리보기 범위에 포함하지 않습니다.

실제 기술 이름W9 manifest day 1–6 selectors, learner targets, introducedAt W7D3/W9D2/W7D5/W9D4/W6D1

24. 실행 7회, 고유 6개, 신규 3개는 어떻게 계산하나요?

날짜별 테스트 메서드 노출은 1 + 2 + 1 + 1 + 1 + 1 = 7회입니다. TransactionProxyIT 1개, LostUpdateBaselineIT 2개, TransferFailurePointIT 1개, TransactionPropagationIT 1개, day 5의 같은 TransferFailurePointIT 1개, CoreSchemaIT 1개입니다.

day 3과 day 5가 같은 실패 테스트를 반복하므로 고유 메서드는 6개입니다. W9에 새로 도입된 직접 테스트는 LostUpdate의 2개와 Propagation의 1개, 합계 3개입니다. 나머지 고유 3개는 이전 주에서 가져온 회수 대상입니다.

실제 기술 이름selector exposure count 7, unique test count 6, W9 direct new tests 3

day 1부터 6까지 실행 7회 고유 6개 신규 3개를 구분한 표
그림 한눈에: day별 7칸에서 반복된 실패 시험 하나를 합치면 고유 6개, 그중 W9 신규는 3개다.

25. PROVIDED_FIXTURE_VERIFIED와 학습자 작성 0개는 무슨 뜻인가요?

day 1–6의 gateOwnership은 모두 PROVIDED_FIXTURE_VERIFIED입니다. 제공되었거나 누적된 파일을 정답과 이전 로그 없이 설명하고, 지정 selector로 현재도 Green인지 다시 검증했다는 뜻입니다. 제공 파일을 학습자가 이번에 처음 작성했다고 주장하는 표지가 아닙니다.

PDF의 W9 시작 요약은 제공 fixture Green 7, learner-authored Green 0으로 기록합니다. 여기서 7은 day 1–6 selector의 테스트 실행 노출 수와 맞고, 0은 학습자가 새로 소유했다고 인정된 Green 수입니다. 새 테스트가 W9에 3개 도입됐다는 말과 저작권/소유권 0은 서로 다른 축입니다.

실제 기술 이름PROVIDED_FIXTURE_VERIFIED, LEARNER_AUTHORED Green 0, evidence ownership

26. W9 day 1–6이 최종적으로 보장하는 것과 보장하지 않는 것을 한 번에 정리하면요?

직접 보장하는 핵심은 여섯 가지입니다. 프록시와 공개 트랜잭션 표지, 시험 전용 무보호 경로의 완료 2·최종 9,000·버전 0, 행 잠금 경로의 완료 2·최종 8,000, 업무 변경 뒤 RuntimeException의 10,000·5,000·0·0, 바깥 롤백 뒤 INNER_COMMITTED만 남음, 네 핵심 표 이름의 정확한 집합입니다. 별도 SQL 연습은 Q09의 NULL 표시 0건과 Q10의 반개구간 3행을 요구합니다.

직접 보장하지 않는 범위에는 모든 격리 수준, 실제 운영 JPA 버전 경로의 고장, 공평함·성능·교착 없음, 전원 중단·slow query·timeout, 여러 실패 훅, idempotency 행 0, checked exception, self-invocation 비교, 외부 시스템 원자성, 네 표의 모든 열과 제약이 있습니다. 이 경계를 지키면 테스트가 말한 것보다 큰 안전 신화를 만들지 않을 수 있습니다.

실제 기술 이름W9 day 1–6 guarantee matrix, assertion-driven scope, non-guarantee boundary