8주차 미리보기

8주차 미리보기 · 이야기

STARRY PASS 4 — 여섯 장의 시험표와 되돌아온 1,000원

새 기능을 더하지 않고, 서로 다른 지난 약속을 자료 없이 다시 꺼내는 밤

공연 시작까지 두 시간 남은 STARRY 계산대 위에는 새 카드도 새 안내판도 없었다. 대신 니지카가 색이 다른 봉투 여섯 장을 펼쳐 놓았다. 봉투마다 지난주까지 한 번 통과했던 시험표가 들어 있었다. 히토리는 ‘이미 통과함’이라는 도장을 보고 조금 안심했다가, 봉투 아래 적힌 문장을 읽고 다시 굳었다.

자료를 가리고 설명한 뒤 다시 실행할 것. 오래된 봉투라는 이유만으로 믿지 말고, 오늘 다시 본 숫자만 오늘의 초록 도장으로 인정할 것.

“정답을 다시 보면 안 되는 건가요?”

“먼저는 안 돼.” 니지카가 첫 봉투를 히토리에게 건넸다. “무엇을 넣고, 무엇이 돌아오고, 실패하면 무엇이 남지 않아야 하는지 우리 말로 설명해. 그다음 지금 파일과 예전 기준을 대조하고 같은 시험을 다시 실행하는 거야.”

키타가 여섯 봉투 사이에 굵은 칸막이를 세웠다.

“그리고 아주 중요한 규칙! 이건 한 손님이 여섯 일을 차례로 겪는 이야기가 아니에요. 봉투를 바꿀 때마다 보관함을 비우고, 그 시험이 정한 카드와 기록을 새로 준비해요.”

료가 봉투 수를 셌다.

“새 발명 0개. 다시 확인할 약속은 여섯 묶음.”

월요일부터 토요일까지 서로 독립적으로 다시 실행하는 여섯 시험 봉투
그림 한눈에: 여섯 봉투는 서로 다른 시험 무대를 매번 새로 꾸며 실행하며, 앞 결과를 뒤 입력으로 잇는 하나의 긴 시험이 아니다.
태블릿을 보며 이야기하는 니지카의 공식 장면
그림 한눈에: 니지카가 새 화면을 만드는 대신 이미 있던 여섯 시험표를 한 장씩 다시 펼친다.

첫 봉투에는 카드 창구라고 적혀 있었다. 키타는 빈 보관함 앞에 서서 A-100이라는 카드를 주문했다. 시작 금액은 10,000원이었다. 화면에 접수 완료 번호 201이 나타나자 료가 실제 보관함을 열어 카드번호 칸이 A-100인 줄을 셌다. 정확히 1개였다.

“201만 보면 안 되나요?” 히토리가 물었다.

“성공처럼 보이면서 저장은 안 됐을 수도 있어.” 니지카가 답했다. “이 시험은 화면 201과 실제 A-100 한 장을 둘 다 본다.”

키타는 보관함을 다시 비우고 두 번째 요청표를 내밀었다. 카드 이름은 BAD, 시작 금액은 -1원, 부탁표 번호는 w6-invalid였다. 이번에는 화면이 400으로 거절했고, 본문에 INVALID_REQUEST와 w6-invalid가 함께 나타났다. 료가 보관함을 세니 카드 줄은 0개였다.

“잘못된 카드가 화면에서만 거절되고 뒤에서는 생기면 안 되니까요.” 키타가 말했다.

세 번째에는 customer-1이 자기 LOOKUP 카드를 7,000원으로 먼저 만들었다. 그 카드 번호를 찾아보자 200과 LOOKUP이 돌아왔다. 네 번째에는 존재하지 않는 999999번을 찾았다. 결과는 404, ACCOUNT_NOT_FOUND, w6-missing이었다.

히토리는 네 칸을 표로 정리했다. A-100/10,000원은 201과 실제 1행. BAD/-1원은 400, 두 오류 문구, 실제 0행. LOOKUP/7,000원은 200과 이름 LOOKUP. 없는 999999번은 404와 두 오류 문구.

A-100 생성, BAD 거절, LOOKUP 조회, 없는 계좌 조회의 직접 assertion 표
그림 한눈에: A-100·BAD·LOOKUP·999999의 입력, 응답, 실제 저장 assertion을 네 장의 영수증으로 나눈다.

“LOOKUP 시험은 응답에 7,000원도 직접 적혀 있는지 확인했나요?” 료가 물었다.

히토리가 시험표를 다시 보았다. “아니요. 카드를 7,000원으로 만들긴 했지만, 응답에서는 200과 LOOKUP 문자열을 직접 확인했어요.”

“그게 오늘 첫 번째 규칙이야.” 니지카가 말했다. “준비값과 직접 확인한 값을 섞지 않기. 시험 이름이 넓게 들려도 실제 확인문에 없는 보장을 덧붙이지 않기.”

첫 봉투를 닫자 키타가 보관함을 완전히 비웠다. A-100, BAD, LOOKUP은 다음 봉투로 넘어가지 않았다.

두 번째 봉투에는 영수증 세 장만 있었다. 새로 연 LQ-100 카드의 시작 금액은 10,000원이었다. 그 시작 기록 뒤에 LQ-OUT 영수증이 붙었다. 500원이 나가 방향 붙은 금액은 -500, 그때 잔액은 9,500원이었다. 그다음 LQ-IN 영수증에는 500원이 들어와 방향 붙은 금액 +500, 잔액은 다시 10,000원이라고 적혀 있었다.

“이번에는 세 장을 최신순으로 읽을 거야.” 니지카가 말했다. “한 번에 두 장만.”

첫 묶음에는 가장 최근의 LQ-IN과 그다음 LQ-OUT이 들어왔다. 크기는 2였다. 그중 두 번째 영수증의 시각과 번호를 책갈피로 삼아 다음 묶음을 열자, 남은 시작 기록 한 장이 나왔다. 크기는 1이었다.

같은 화면을 확인하는 키타와 히토리의 공식 장면
그림 한눈에: 히토리와 키타가 첫 묶음의 마지막 영수증에서 시각과 번호 두 칸을 함께 확인한다.

히토리는 다음 한 장의 번호를 첫 묶음 두 번호와 비교했다.

“겹치는 번호가 없어요. 2장 다음 1장, 반복 0장.”

료가 책갈피 행을 다음 묶음 맨 앞에 다시 놓았다.

“조건에 ‘같아도 됨’을 넣으면 이 행이 한 번 더 나온다.”

“그래서 책갈피와 같거나 오래된 것이 아니라, 책갈피보다 엄격하게 오래된 것만 고르는 거군요.”

니지카는 같은 시각의 영수증이 둘일 수 있으니 시각만 기억해서도 안 된다고 설명했다. 시각이 같을 때 번호가 순서를 가른다. 최신 시각부터, 같은 시각이면 큰 번호부터 정렬한 방향과 다음 묶음의 비교 방향이 서로 맞아야 했다.

LQ-100 장부의 첫 두 행과 다음 한 행을 시각과 ID 책갈피로 나눈 그림
그림 한눈에: LQ-100 영수증 세 장을 최신 2장과 다음 1장으로 나누고, 찍힌 시각과 영수증 번호 책갈피로 중복 0을 만든다.

“이 시험이면 영수증이 읽는 도중 새로 생기거나 사라져도 언제나 완벽하다는 뜻인가요?” 히토리가 물었다.

“아니.” 니지카가 울타리를 그었다. “지금 준비한 세 줄에서 첫 묶음 2, 다음 묶음 1, 책갈피 행 반복 0을 직접 확인했어. 읽는 도중 생기거나 사라지는 모든 경우, 아주 큰 장부의 속도, 한 번에 가져올 장수를 0장이나 101장으로 잘못 적은 경우까지 본 건 아니야.”

두 번째 봉투도 닫혔다. 세 영수증은 그대로 세 번째 봉투로 넘어가지 않았다.

세 번째 봉투에는 이동 창구의 상태표가 있었다. 다시 비운 보관함에 CTRL-FROM 10,000원과 CTRL-TO 5,000원을 준비했다. customer-1이 W7-FIRST라는 이름으로 1,000원을 옮겨 달라고 하자 화면은 201을 돌려주었고 본문에는 W7-FIRST와 COMPLETED가 들어 있었다.

히토리는 10,000원에서 1,000원을 빼고 5,000원에 1,000원을 더하려다가 펜을 멈췄다.

“정상이라면 9,000원과 6,000원이겠지만, 이 시험표가 그 잔액을 직접 다시 읽었나요?”

“안 읽었어.” 료가 답했다. “이 표가 직접 본 것은 201과 두 문구다.”

키타가 금액 0인 W7-ZERO 요청을 보냈다. 이번에는 400이 돌아왔고, 이동이라고 적힌 업무 거래는 0개였다. 화면 거절과 이동 기록 없음이 함께 확인됐다. 이어 손목띠 없이 금액 100인 W7-ANON 요청을 보내자 401이 돌아왔다.

“401 시험은 이동 기록 0개도 셌나요?”

“그 시험문에는 401 확인만 있어.” 니지카가 말했다. “정상 구조라면 보안 입구에서 앞서 막히지만, 직접 세지 않은 보관함 줄까지 그 한 줄이 증명했다고 말하면 안 돼.”

W7-FIRST 201, W7-ZERO 400, W7-ANON 401의 직접 확인 범위 표
그림 한눈에: W7-FIRST는 201·W7-FIRST·COMPLETED, W7-ZERO는 400·TRANSFER 거래 0개, W7-ANON은 401만 직접 확인한다.

상태표를 닫기 전 니지카는 별도의 장부 연습장을 펼쳤다. 방금 이동 시험의 일부가 아니라, 같은 묶음에 배정된 독립 문제였다.

“최근 거래 열 건을 가져오려면 어떤 순서로 적을래?”

히토리는 먼저 원장의 한 행이 무엇인지, 결과 한 행이 무엇인지, 최대 몇 행인지 주석으로 적었다. 그다음 최신 시각부터 정렬하고 10개로 제한했다. 키타가 같은 시각의 거래 두 건을 붙이자 히토리는 번호를 두 번째 정렬 기준으로 더했다.

“시각이 같아도 번호가 큰 거래부터 나오게 해야 매번 순서가 같아요.”

workbook business_tx를 occurred_at과 tx_id 내림차순으로 정렬해 최근 10건을 고르는 Q07 예시
그림 한눈에: 최근 거래 열 건 연습은 최신 시각부터, 같은 시각이면 큰 영수증 번호부터 놓고 열 장에서 멈춘다.

료가 연습장에 ‘최근 거래 열 건’이라고 적었다. 이 문제의 통과 조건은 실제로 목록을 가져오기, 같은 시각이면 영수증 번호로 순서를 고정하기, 시작 표와 한 줄의 뜻과 반례를 설명하기였다. 이동 창구의 세 시험이 이 별도 답안을 대신 채점하는 것은 아니었다.

네 번째 봉투를 열자 다시 LQ-100을 10,000원으로 만들고, -500원과 +500원 영수증을 붙인 처음 상태가 나타났다. 앞의 봉투에서 이어 온 것이 아니라 같은 준비법으로 새로 꾸민 별도 시험 무대였다.

카드 잔액은 10,000원. 영수증의 방향 붙은 금액 합도 시작 10,000원에 -500원과 +500원을 더해 10,000원. 차이가 있는 계좌 수는 0이었다.

료가 카드 잔액만 1원 올렸다. 카드에는 10,001원, 원장 합에는 10,000원이 남았다. 같은 검사를 다시 실행하자 차이가 있는 계좌 수가 정확히 1이 되었다.

계좌 잔액과 장부 합이 같을 때 0건, 잔액에 1원을 더하면 1건인 대사 비교
그림 한눈에: 새로 꾸민 정상 시험 무대의 10,000원 대 10,000원은 0건, 카드 잔액만 10,001원으로 바꾸면 1건이다.

“정상 0만 확인하면 안 되나요?” 히토리가 물었다.

“검사기가 언제나 0만 돌려줘도 통과할 수 있어.” 니지카가 답했다. “정상일 때 0, 일부러 틀리게 했을 때 1을 모두 봐야 차이를 실제로 찾는지 알 수 있지.”

키타는 자동 수정 단추를 찾았다. 니지카는 고개를 저었다. 이 검사는 차이를 찾을 뿐, 카드가 틀렸는지 영수증 한 줄이 빠졌는지 조사하지 않고 고쳐 주지 않았다. 마지막 영수증에 적힌 당시 잔액 한 칸만 보는 것과 모든 방향 붙은 금액을 합치는 것도 다른 일이었다.

다섯 번째 봉투에는 새 시험이 없었다. 두 번째 봉투의 책갈피 시험과 네 번째 봉투의 차이 찾기 시험을 같은 원장 조회 시험 묶음으로 다시 실행하라는 표가 들어 있었다.

“아까 각각 통과했는데 왜 또 해요?” 키타가 물었다.

“낱장 시험만 골랐을 때와 시험 묶음 전체를 골랐을 때 둘 다 초록인지 확인하는 회수야.” 니지카가 답했다. “새로운 세 번째 시험이 생긴 건 아니고, 앞의 두 낱장 시험이 함께 실행돼.”

히토리는 숫자를 적었다. 책갈피 시험 한 번, 차이 찾기 시험 한 번, 그리고 클래스 전체에서 두 시험을 한 번씩 더. 실행된 시험 수에는 4가 더해지지만, 고유한 시험 이름은 여전히 두 개였다.

그 옆에는 두 번째 독립 장부 연습장이 있었다. 금액의 1퍼센트를 계산하고 원 단위로 반올림하는 문제였다. 히토리는 계산식을 쓰기 전에 입력 한 줄과 결과 한 줄의 뜻, 예상 줄 수를 적었다. 10,000원의 1퍼센트는 100원이었다. 1원 미만 소수가 생기는 금액에서는 어느 방향으로 반올림하는지 규칙을 분명히 적고 경계 반례로 실행해야 했다.

workbook business_tx의 amount에 1퍼센트를 곱해 원 단위로 반올림하는 Q08 예시
그림 한눈에: 두 번째 별도 장부 연습은 금액의 1퍼센트, 원 단위 반올림 규칙과 경계 반례를 함께 기록한다.

“이 답안도 장부 시험 두 개가 대신 보장해 주나요?”

“아니.” 료가 ‘1퍼센트 수수료’라고 적었다. “같은 봉투 옆의 별도 연습장이다. 실행 결과와 반올림 규칙을 따로 남긴다.”

마지막 봉투에는 FAIL-FROM 10,000원, FAIL-TO 5,000원 카드가 있었다. W7-FAIL이라는 이름으로 1,000원을 옮기기 시작한 뒤, 업무 기록을 바꾼 바로 다음 지점에서 일부러 갑작스러운 중단을 일으켰다. 화면 성공을 보는 시험이 아니라, 고장 뒤 보관함에 무엇이 남는지 보는 시험이었다.

히토리는 갑작스러운 중단이 실제로 일어났는지, 고장 장치가 정말 불렸는지 먼저 확인했다. 그다음 보관함을 새로 열어 네 숫자를 셌다.

보내는 카드 10,000원. 받는 카드 5,000원. 이동 업무 기록 0개. 이동 원장 줄 0개.

업무 변경 뒤 RuntimeException이 나면 두 잔액과 거래·장부가 원상복구되는 표
그림 한눈에: W7-FAIL 1,000원 처리 중 고장 뒤 최종 관찰값은 10,000원·5,000원·0개·0개다.

“그럼 실제 보관함 전원이 꺼지거나 기다림 시간이 끝나도 똑같이 안전하다고 말해도 되나요?” 히토리가 물었다.

“안 돼.” 니지카가 바로 답했다. “오늘 다시 확인한 건 일을 바꾼 뒤 갑자기 멈춘 한 종류야. 실제 보관함 중단, 너무 오래 기다려 끝난 경우, 다른 종류의 중단, 같은 요청 재전송은 이 시험이 직접 증명하지 않아.”

“시험이 말한 만큼만 말하기.” 키타가 첫 봉투의 규칙을 다시 읽었다.

료는 여섯 봉투의 도장을 세었다. 첫 봉투 4개, 두 번째 1개, 세 번째 3개, 네 번째 1개, 다섯 번째 2개, 마지막 1개. 봉투 순서대로 실제 실행된 시험 수는 12였다. 하지만 두 번째와 네 번째 시험이 다섯 번째에서 한 번씩 다시 실행됐으므로 서로 다른 시험 이름은 10개였다.

요일별 실행 12회와 중복을 뺀 고유 테스트 10개를 구분하는 표
그림 한눈에: 4+1+3+1+2+1=실행 12회이며, 다섯 번째 시험 묶음이 앞의 두 낱장 시험을 반복하므로 서로 다른 시험은 10개다.

각 봉투의 기록에는 발견된 시험이 0개가 아닌지, 실패·오류·건너뜀이 모두 0인지, 대상 파일의 지문이 남았는지도 확인했다. 실행 명령이 끝났다는 사실만으로는 부족했다. 실제 시험이 한 개 이상 발견되고 하나도 건너뛰지 않았으며, 자신이 설명한 파일의 지문까지 맞아야 봉투에 초록 도장을 찍었다.

자동으로 정답을 덮어쓴 흔적을 자신의 작성 결과라고 부르지도 않았다. 이번 주에 새로 준 정답 파일과 준비 파일은 0개였고, 여섯 봉투에는 모두 ‘제공 결과 재확인’ 표지가 붙었다. 이는 ‘내가 오늘 새로 썼다’가 아니라 ‘이미 제공된 결과를 가리고 설명한 뒤 지정 시험표로 다시 확인했다’는 뜻이었다.

니지카는 마지막 범위표를 두 칸으로 나눴다. 직접 확인한 칸에는 A-100의 201과 한 행, BAD의 400과 0행, LOOKUP 200, 없는 카드 404, 책갈피 2→1과 중복 0, 이동 창구의 201·400·401, 차이 0→1, 고장 뒤 10,000·5,000·0·0을 적었다. 독립 연습장에는 최근 10건 안정 정렬과 1퍼센트 원 단위 반올림을 적었다.

아직 확인하지 않은 칸은 길게 이야기하지 않았다. 실제 보관함 중단과 기다림 종료, 동시에 같은 잔액을 바꾸는 상황, 같은 요청을 겹쳐 보내는 상황은 이번 여섯 봉투 밖이었다.

계산대에 모여 결과를 확인하는 네 사람의 공식 장면
그림 한눈에: 네 사람이 새 기능이 아니라 여섯 회수 결과의 직접 보장과 미보장 경계를 마지막으로 맞춰 본다.

히토리는 새 종이에 마지막 문장을 적었다.

‘우리는 한 손님의 긴 시험을 새로 만든 것이 아니다. 서로 다른 과거 시험 무대를 매번 새로 꾸미고, 여섯 지정 시험표를 자료 없이 설명한 뒤 다시 실행했다. 실행은 12번, 고유 시험은 10개, 새 파일은 0개다. 0개라는 말은 배운 것이 없다는 뜻이 아니라, 남의 제공 결과를 내 새 코드라고 부르지 않으면서도 그 약속을 다시 꺼낼 수 있다는 뜻이다.’

공연 시작 종이 울렸다. 니지카는 여섯 봉투를 번호순으로 묶었다. 서로 다른 색 끈은 여섯 준비물이 섞이지 않았다는 마지막 표시가 되었다. 히토리는 이제 초록색 도장이 왜 초록인지 설명할 수 있었다. 무엇이 이어졌는지가 아니라, 어떤 시험 무대에서 어떤 값을 직접 보았고 어디부터는 아직 보지 않았는지가 선명했기 때문이다.

8주차 미리보기 · 히토리의 질문 노트

히토리의 질문 노트 — 여섯 시험표는 무엇을 직접 확인했을까?

이야기에서 쉬운 말로 숨겨 둔 이름을 하나씩 꺼낸다. 먼저 실제 값으로 이해하고, 마지막 줄에서 Java·Spring·JUnit·SQL의 정확한 이름을 확인한다.

1여섯 시험표는 한 손님의 긴 통합 시험인가요?

히토리는 카드 창구, 장부 책갈피, 이동 창구, 대사, 장부 전체, 고장 복구 표를 한 줄로 놓았다. “이 순서대로 한 손님의 결과가 다음 시험의 입력이 되는 건가요?”

아니다. 8주차 월~토 selector는 서로 다른 과거 시험을 순서 있게 다시 꺼내는 회수 묶음이다. 각 테스트 클래스의 BeforeEach는 표를 비우고 자기 fixture를 새로 만든다. A-100의 결과가 LQ-100으로 넘어가거나, LQ-100의 잔액이 CTRL-FROM의 시작값이 되지 않는다. 여섯 표를 한 계산대에 펼친 것은 계좌 HTTP, 장부 조회, 이동 HTTP, 대사, 롤백의 관계를 이해하기 위한 서사 장치일 뿐이다.

W8 월~토에는 이 여섯 fixture를 한 요청 흐름으로 연결하는 새 @Test가 없다. 따라서 “한 손님이 생성→충전→이동→조회까지 실제로 통과했다”고 주장하면 제공 selector보다 넓은 말이 된다.

실제 기술 이름independent test fixture, BeforeEach reset, selector replay, no single end-to-end @Test

월요일부터 토요일까지 서로 독립적으로 다시 실행하는 여섯 시험 봉투
그림 한눈에: 여섯 selector는 매번 DB를 초기화한 독립 fixture이며 앞 봉투의 결과를 다음 봉투로 넘기지 않는다.

2새 파일이 0개인 주차에도 배울 것이 있나요?

“정답 파일을 새로 만들지 않았으니 이번 주 성과도 0 아닌가요?” 히토리가 물었다.

8주차는 6·7주차에서 이미 만든 결과를 자료 없이 설명하고 다시 실행하는 주차다. 시간이 지난 뒤에도 책임, 입력, 출력, 실패 조건을 기억하고 있는지 확인하면 우연히 초록색이었던 결과와 실제로 이해한 결과를 구분할 수 있다. 이때 기존 제공 파일이 통과했다는 사실을 오늘 자신이 새로 작성한 코드라고 주장하면 안 된다.

W8의 월~토 기록은 새 구현 소유권이 아니라 기존 제공 결과의 재검증 소유권이다. 그래서 새 solution과 scaffold 파일 수가 0이어도 확인 대상과 보장 경계는 분명히 남는다.

실제 기술 이름delayed retrieval, regression verification, PROVIDED_FIXTURE_VERIFIED, ownership boundary

3월요일부터 토요일까지 실제로 다시 실행하는 시험은 몇 개인가요?

같은 시험을 다른 날에 다시 부르기도 해서, 찍힌 도장 수와 서로 다른 시험 이름 수가 다르다. 월~토에 실제로 노출되어 실행되는 시험 수를 모두 더하면 12회다. 중복을 제거한 고유 시험 메서드는 총 10개다. 계좌 창구 4개, 장부 조회 2개, 이동 창구 3개, 이동 중 고장 1개다.

월에는 AccountControllerTest의 4개, 화에는 장부 책갈피 1개, 수에는 TransferControllerTest의 3개, 목에는 장부 차이 찾기 1개, 금에는 LedgerQueryIT의 2개 전체, 토에는 TransferFailurePointIT의 1개를 다시 실행한다. 화와 목의 두 메서드는 금요일 전체 실행에 다시 포함되므로 실행 횟수와 고유 메서드 수를 혼동하면 안 된다. 일요일 통합 묶음은 이번 미리보기의 월~토 코드 범위에서 제외한다.

실제 기술 이름JUnit exact selector, test class selector, method selector, unique test method count

요일별 실행 12회와 중복을 뺀 고유 테스트 10개를 구분하는 표
그림 한눈에: 월~토 선택자의 실행 노출 수 12와, 화·목 메서드가 금요일에 반복된 뒤의 고유 메서드 10을 구분한다.

4A-100 카드를 만들 때 201만 보면 충분한가요?

키타가 JSON 요청에 accountNo A-100과 openingBalance 10000을 넣는다. createReturns201AndPersistsOpening은 응답 상태가 201인지 보고, 이어 실제 account 표에서 account_no가 A-100인 행이 정확히 1개인지 센다.

즉 이 시험은 “접수 완료처럼 보였다”와 “실제로 한 장 저장됐다”를 함께 확인한다. 하지만 응답 본문의 모든 필드나 Location 헤더의 정확한 값까지 직접 단언하지는 않는다. 메서드 이름이 넓어 보이더라도 실제 assertion에 없는 보장은 덧붙이면 안 된다.

실제 기술 이름HTTP 201 Created, persistence assertion, AccountControllerTest.createReturns201AndPersistsOpening

5-1원 카드는 왜 400과 저장 0개를 둘 다 확인하나요?

히토리가 BAD 카드에 openingBalance -1을 적었다. negativeOpeningReturns400AndWritesNothing은 응답이 400인지, 본문에 INVALID_REQUEST와 요청표 번호 w6-invalid가 있는지, account 행이 0개인지 모두 본다.

400만 보면 화면에서는 거절됐지만 카드가 몰래 저장되는 버그를 놓칠 수 있다. 반대로 행이 0개인 것만 보면 사용자가 어떤 오류를 받았는지 알 수 없다. 오류 계약과 부작용 없음은 서로 다른 확인이다.

이 시험은 정확히 -1원이라는 경계를 다룬다. 아주 큰 음수, 숫자가 아닌 JSON, 필드 누락까지 전부 같은 방식으로 처리된다고 자동으로 확장해서 말하면 안 된다.

실제 기술 이름Bean Validation boundary, HTTP 400 Bad Request, no-write assertion, request ID correlation

6LOOKUP 7,000원 조회와 999999번 조회는 무엇을 나누어 보나요?

주인이 있는 카드를 찾는 경우와 아예 없는 번호를 찾는 경우를 따로 본다. ownerReadsAccount는 customer-1 소유의 LOOKUP 카드를 7,000원으로 만든 뒤, 그 ID를 인증된 주인이 조회한다. 직접 확인하는 값은 HTTP 200과 응답 문자열의 LOOKUP이다. missingAccountReturns404는 존재하지 않는 999999번을 조회해 404, ACCOUNT_NOT_FOUND, w6-missing을 확인한다.

정상 조회와 없는 대상 조회를 나누면 성공 경로만 있는 가짜 구현과 모든 요청을 404로 보내는 가짜 구현을 함께 걸러낼 수 있다. 하지만 첫 시험은 응답에서 LOOKUP만 찾으며 7,000이라는 balance 문자열을 직접 단언하지 않는다. 시작 fixture가 7,000원이라는 사실과 응답 잔액을 assertion했다는 말은 다르다.

실제 기술 이름authenticated resource lookup, HTTP 200, HTTP 404, AccountControllerTest.ownerReadsAccount, missingAccountReturns404

7로그인한 사람이라는 사실과 카드 주인이라는 사실은 같은가요?

“customer-1로 로그인했으면 customer-1의 카드만 자동으로 보이는 거죠?” 히토리가 물었다.

로그인은 요청한 사람이 누구인지 확인하는 일이다. 소유권 확인은 그 사람이 특정 카드에 접근할 권리가 있는지 비교하는 일이다. Principal의 이름을 얻었다고 데이터의 ownerId 비교가 자동으로 끝나는 것은 아니다.

이번 월요일 시험에는 주인이 자기 LOOKUP 카드를 읽는 정상 예는 있지만, 다른 사람 카드 조회가 403으로 거절되는 반례는 없다. 따라서 “주인 조회 200”은 확인됐지만 “모든 비소유자 접근이 거절된다”까지 이 시험만으로 보장하면 안 된다.

실제 기술 이름authentication, authorization, Principal, ownership check, untested negative authorization path

A-100 생성, BAD 거절, LOOKUP 조회, 없는 계좌 조회의 직접 assertion 표
그림 한눈에: A-100·BAD·LOOKUP·999999의 준비값과 실제 응답·DB assertion을 서로 다른 칸으로 나눈다.

8W7-FIRST 이동 시험은 정확히 무엇을 확인하나요?

CTRL-FROM은 10,000원, CTRL-TO는 5,000원으로 시작한다. customer-1이 transactionId W7-FIRST와 amount 1000을 보내면 firstTransferIs201은 응답 상태가 201인지, 본문에 W7-FIRST와 COMPLETED가 있는지 확인한다.

정상 구현이라면 최종 잔액은 9,000원과 6,000원이겠지만 이 메서드는 그 두 잔액과 장부 두 줄을 직접 조회해 단언하지 않는다. 응답 계약 시험을 잔액 원자성 시험으로 부풀리면 안 된다. 잔액과 원복은 다른 통합 시험의 assertion을 함께 봐야 한다.

실제 기술 이름TransferControllerTest.firstTransferIs201, API response contract, correlation ID, COMPLETED status

90원 이동은 왜 400뿐 아니라 이동 기록 0개까지 보나요?

0원 부탁은 화면에서만 거절되어서는 안 된다. zeroAmountIs400WithoutTransferWrite는 W7-ZERO 요청의 amount를 0으로 보낸다. 응답 400을 확인한 뒤 business_tx에서 tx_type이 TRANSFER인 행이 0개인지 센다.

입구에서 거절된 요청은 업무 거래를 시작하지 않아야 한다. 화면 400만 확인하면 잘못된 이동 기록을 남겼다가 응답만 실패로 바꾸는 구현도 통과할 수 있다. 반대로 거래 행 0개만 확인하면 고객 계약이 400인지 500인지 알 수 없다.

이 메서드는 transfer business_tx 0개를 직접 보지만 ledger_entry 0개는 직접 세지 않는다. 이름의 “without transfer write”를 모든 표의 모든 행 0개로 넓혀 해석하지 않는다.

실제 기술 이름TransferControllerTest.zeroAmountIs400WithoutTransferWrite, validation failure, no business transaction side effect

10손목띠 없는 100원 요청의 401은 어디까지 보장하나요?

손목띠가 없는 부탁은 입구에서 401로 멈춘다. missingAuthenticationIs401은 인증 정보 없이 W7-ANON, amount 100 요청을 보낸다. 직접 확인하는 것은 HTTP 401 하나다. 이는 보안 입구가 익명 요청을 막는다는 증거다.

그러나 이 메서드는 이후 service 호출 횟수, business_tx 행 수, ledger_entry 행 수를 직접 확인하지 않는다. 정상 구조에서는 보안 필터가 컨트롤러 앞에서 막지만, “DB 부작용도 0임을 이 assertion이 직접 증명했다”고 말할 수는 없다. 필요하면 별도 spy나 DB count assertion을 추가해야 한다.

실제 기술 이름Spring Security filter chain, unauthenticated request, HTTP 401, TransferControllerTest.missingAuthenticationIs401

11영수증을 두 장씩 넘기는 방식의 정확한 이름은 무엇인가요?

히토리가 마지막으로 본 행을 가리켰다. “몇 장을 건너뛰었는지 세는 대신, 이 영수증 다음부터 달라고 말했죠?”

마지막 정렬 키를 책갈피로 삼아 그 이후 범위를 읽는 방식이 keyset pagination이다. 이번 코드는 최신순으로 createdAt과 id를 함께 정렬하고, 첫 페이지의 마지막 행을 다음 페이지 cursor로 전달한다. 앞에서 몇 행을 세어 건너뛰는 OFFSET 방식과 달리 마지막으로 본 값이 출발점이다.

이 방식도 정렬 기준과 비교식이 맞아야 안정적이다. LIMIT만 붙였다고 자동으로 keyset이 되지는 않는다.

실제 기술 이름keyset pagination, cursor pagination, ORDER BY created_at DESC, id DESC

LQ-100 장부의 첫 두 행과 다음 한 행을 시각과 ID 책갈피로 나눈 그림
그림 한눈에: 첫 페이지 마지막 행의 createdAt과 id를 다음 페이지의 책갈피로 넘긴다.

12책갈피에 시간과 번호를 함께 적는 이유는 무엇인가요?

두 영수증이 같은 createdAt을 가질 수 있다. 시간만 적으면 같은 시각의 여러 행 가운데 어느 행까지 봤는지 결정할 수 없다. id를 두 번째 정렬 키로 더하면 같은 시각 안에서도 순서가 하나로 정해진다.

내림차순 정렬에서는 더 최신 시간이 먼저 나오고, 시간이 같으면 더 큰 id가 먼저 나온다. cursor도 occurredAt과 id 두 값을 한 쌍으로 들고 있어야 같은 규칙을 이어 간다. id 하나만 쓰는 것도 업무 시각 순서를 그대로 표현하지 못할 수 있다.

실제 기술 이름composite sort key, tie-breaker, LedgerQueryService.Cursor(Instant occurredAt, long id)

13다음 페이지 조건에서 왜 같음을 허용하면 안 되나요?

첫 페이지의 마지막 행 자체가 책갈피다. 다음 쿼리에서 “책갈피보다 작거나 같은 행”을 고르면 그 행이 다시 포함된다. 그래서 내림차순 다음 페이지는 (created_at, id) < (:occurredAt, :id)처럼 엄격하게 작은 행만 골라야 한다.

히토리가 less-than-or-equal을 적었다가 지웠다. “같음 하나 때문에 첫 장이 반복되는군요.” 정확하다. 테스트는 다음 페이지 ID들이 첫 페이지의 두 ID를 포함하지 않는지 확인해 이 실수를 잡는다.

실제 기술 이름strict cursor predicate, tuple comparison, duplicate cursor row, less-than versus less-than-or-equal

142장 뒤 1장이라는 숫자는 어떻게 만들어졌나요?

세 장이 준비되어 있으니 두 장을 먼저 보고 남은 한 장을 보는 구조다. setUp은 LQ-100 계좌를 opening 10,000원으로 열어 원장 한 줄을 만든다. 이어 500원이 나간 TRANSFER_OUT 줄을 추가해 balanceAfter 9,500원으로 만들고, 500원이 들어온 TRANSFER_IN 줄을 추가해 balanceAfter 10,000원으로 만든다. 총 세 줄이다.

firstPage(accountId, 2)는 최신 두 줄을 받고, 그중 두 번째 행을 cursor로 nextPage(accountId, cursor, 2)를 부르면 남은 한 줄을 받는다. 직접 보장하는 것은 첫 페이지 크기 2, 다음 페이지 크기 1, 다음 ID가 앞의 두 ID와 겹치지 않는다는 사실이다. 중간 삽입·삭제와 대규모 성능은 이 fixture가 보장하지 않는다.

실제 기술 이름LedgerQueryIT.keysetPagesAreStableAndDoNotRepeatTheCursorRow, fixture cardinality, pagination assertion

15카드 잔액과 장부 합을 맞춰 보는 일의 이름은 무엇인가요?

카드에 적힌 현재 돈과 모든 영수증의 들어오고 나간 금액 합은 같아야 한다. 코드에서는 account.balance와 그 카드에 속한 모든 ledger_entry.signed_amount 합계를 비교해 다른 계좌 수를 세며, 이 일을 reconciliation이라고 한다. 더한 금액은 양수, 빠진 금액은 음수로 저장한다.

이 검사는 “마지막 영수증의 balanceAfter만 맞다”를 보는 것이 아니다. 계좌별 모든 signed_amount 합을 모아 account.balance와 비교한다. 행이 하나도 없는 경우를 0으로 취급하는 COALESCE도 필요하다.

실제 기술 이름reconciliation, signed ledger sum, GROUP BY, HAVING, COALESCE

계좌 잔액과 장부 합이 같을 때 0건, 잔액에 1원을 더하면 1건인 대사 비교
그림 한눈에: 카드 잔액 10,000원과 signed 합 10,000원은 0건, 잔액만 10,001원으로 바꾸면 1건이다.

16정상 0건과 오류 1건을 왜 한 시험에서 모두 보나요?

검사가 정상과 오류를 구분하는지 보려면 두 상태가 모두 필요하다. reconciliationReturnsZeroThenDetectsOneInjectedMismatch는 먼저 정상 fixture에서 mismatch count가 0인지 본다. 다음으로 account balance만 balance+1로 바꾸고 다시 세어 1인지 확인한다.

정상 0만 보면 쿼리가 언제나 빈 결과를 돌려주는 버그도 통과한다. 오류 1만 보면 정상 데이터까지 항상 오류로 표시하는 버그를 놓칠 수 있다. 음성 예와 양성 예를 같은 fixture 변화로 이어 보면 탐지기가 실제 차이에 반응하는지 알 수 있다.

실제 기술 이름negative control, positive control, fault injection, LedgerQueryIT.reconciliationReturnsZeroThenDetectsOneInjectedMismatch

17차이 1건을 찾으면 자동으로 1원을 고치나요?

“검사 결과가 1이면 balance를 바로 장부 합으로 덮어쓰면 빠르지 않나요?” 히토리가 물었다.

reconciliation은 탐지다. 카드 balance가 틀렸는지, 원장 행이 빠졌는지, 중복 행이 생겼는지 아직 모르는 상태에서 한쪽을 자동 수정하면 원인을 가리고 손실을 확정할 수 있다. 먼저 관련 요청, 업무 거래, 원장 행을 조사하고 승인된 복구 절차를 따라야 한다.

현재 테스트도 count 0→1만 단언하며 자동 복구는 호출하지 않는다. 따라서 “차이를 찾아낸다”와 “올바르게 고친다”는 별도 기능과 별도 시험이다.

실제 기술 이름detection versus remediation, reconciliation report, controlled repair workflow

18마지막 balanceAfter 한 칸과 signed 합은 왜 다른가요?

마지막 영수증의 잔액 한 칸과 모든 영수증의 합계는 서로 다른 증거다. balanceAfter는 특정 원장 행이 기록될 당시의 스냅숏이다. 그 행이 마지막이라는 보장이 깨지거나 중간 행이 누락돼도 겉보기 숫자 하나는 우연히 현재 잔액과 같을 수 있다. 반면 signed_amount 전체 합은 시작부터 더해지고 빠진 변화의 총합을 다시 계산한다.

이번 reconciliation SQL은 account별로 모든 ledger_entry를 LEFT JOIN하고 SUM(signed_amount)를 사용한다. balanceAfter는 목록 표시와 추적에 유용하지만, 이 대사 식의 비교 대상은 전체 signed 합이다.

실제 기술 이름running balance snapshot, signed amount aggregation, ledger invariant

19이동 도중 고장을 일부러 내는 시험은 무엇을 확인하나요?

고장 시험은 1,000원을 옮기다 일부러 멈춘 뒤 모든 변화가 되돌아갔는지 본다. FAIL-FROM 10,000원과 FAIL-TO 5,000원을 준비하고 W7-FAIL로 이동을 시작한다. failure hook은 업무 거래를 만진 뒤 RuntimeException을 던진다. 시험은 예외 종류와 메시지가 맞고 hook이 실제 호출됐는지도 먼저 확인한다.

그 뒤 새 DB 조회로 두 잔액과 이동 기록 수를 센다. 단순히 예외가 발생했다는 사실만으로 롤백을 증명할 수 없으므로, 최종 데이터 효과를 직접 비교해야 한다.

실제 기술 이름failure injection, rollback integration test, TransferFailurePointIT.runtimeExceptionAfterBusinessMutationRollsBackEveryTransferEffect

20고장 뒤 정확히 어떤 네 숫자가 남아야 하나요?

시험이 기대하는 목록은 10,000, 5,000, 0, 0이다. 순서대로 보내는 계좌 balance, 받는 계좌 balance, tx_type이 TRANSFER인 business_tx 수, entry_type이 TRANSFER_로 시작하는 ledger_entry 수다.

이동 중 잠깐 9,000과 6,000이 되었더라도 메서드 밖으로 RuntimeException이 나가면 한 트랜잭션이 전부 취소되어야 한다. 네 값 가운데 하나라도 9,000, 6,000, 1, 2처럼 남으면 부분 성공이다.

실제 기술 이름atomic rollback invariant, business transaction row count, ledger entry row count

업무 변경 뒤 RuntimeException이 나면 두 잔액과 거래·장부가 원상복구되는 표
그림 한눈에: 고장 후 최종 관찰값 10,000·5,000·0·0을 정상 이동의 9,000·6,000·1·2와 나란히 비교한다.

21RuntimeException 시험 하나로 모든 예외의 롤백을 보장하나요?

아니다. 현재 failure hook이 던지는 것은 RuntimeException이고, Spring의 기본 트랜잭션 규칙도 보통 처리되지 않은 RuntimeException과 Error에서 롤백한다. checked exception, 예외를 내부에서 잡아 정상 반환하는 경우, 별도 rollbackFor 설정은 이 메서드가 직접 확인하지 않는다.

또 DB 프로세스 중단, 네트워크 단절, 커넥션 timeout도 같은 fixture가 아니다. “한 종류의 실행 중 예외 뒤 원복”이라는 정확한 울타리를 지켜야 한다.

실제 기술 이름Spring @Transactional, default rollback rule, checked exception boundary, rollbackFor

22여섯 번의 학습 대상은 어떤 파일로 연결되나요?

여섯 봉투마다 자료를 가리고 설명할 파일이 하나씩 정해져 있다. 8주차에는 새 solution이 없지만 learnerTarget은 있다. 첫 번째는 AccountController.java, 두 번째는 LedgerQueryService.java, 세 번째는 TransferController.java, 네 번째는 LedgerQueryIT.java, 다섯 번째는 다시 LedgerQueryService.java, 여섯 번째는 TransferService.java다.

따라서 일일 대상 발생은 6번이지만 서로 다른 경로는 5개다. LedgerQueryService.java가 keyset 메서드 회수와 LedgerQueryIT 전체 회수에서 두 번 등장하기 때문이다. learnerTarget은 “오늘 책임과 불변식을 설명할 파일”이고 selector는 “그 설명이 맞는지 실행할 시험”이다. 둘을 같은 경로라고 가정하면 안 된다.

실제 기술 이름learning-stage learnerTarget, exact selector, 6 daily targets, 5 unique target paths

여섯 일일 learner target과 다섯 고유 파일 경로를 selector에 연결한 지도
그림 한눈에: 여섯 일일 learnerTarget과 각 exact selector를 화살표로 잇고, LedgerQueryService의 두 번 등장을 표시한다.

23AccountController.java는 네 시험과 어떻게 연결되나요?

첫 봉투의 창구 파일은 요청을 직접 계산하지 않고 카드 일을 맡는 곳으로 넘긴다. 실제 학습 대상 AccountController.java는 POST /api/accounts에서 AccountService.create를 부르고 201 응답을 만들며, GET /api/accounts/{accountId}에서 인증된 이름을 서비스 조회에 넘긴다. 이 파일을 가리고 입력·출력·위임 책임을 설명한 뒤 AccountControllerTest 전체를 실행한다.

연결된 네 메서드는 createReturns201AndPersistsOpening, negativeOpeningReturns400AndWritesNothing, ownerReadsAccount, missingAccountReturns404다. 테스트 원본은 W6 day-7 scaffold에 있지만 W8 첫 selector로 실행된다. “day-7 파일 본문을 결과 코드로 복제하지 않는다”는 문서 경계와 “그 테스트를 selector로 실행한다”는 실행 사실은 동시에 참이다.

실제 기술 이름AccountController learnerTarget, AccountControllerTest class selector, carried day-7 test contract

24LedgerQueryService와 LedgerQueryIT가 번갈아 학습 대상이 되는 이유는 무엇인가요?

장부 일을 하는 파일과 그 일을 확인하는 파일은 역할이 다르다. 두 번째 학습 대상은 LedgerQueryService.java이고 selector는 keysetPagesAreStableAndDoNotRepeatTheCursorRow 한 메서드다. 네 번째 학습 대상은 테스트 파일 LedgerQueryIT.java 자체이고 selector는 reconciliationReturnsZeroThenDetectsOneInjectedMismatch 한 메서드다. 다섯 번째에는 LedgerQueryService.java를 다시 가린 뒤 LedgerQueryIT 클래스 전체, 즉 두 메서드를 함께 실행한다.

서비스 코드는 firstPage, nextPage, reconciliationMismatchCount의 동작을 가진다. 테스트 코드는 그 동작에 사용할 LQ-100, -500, +500 fixture와 assertion을 가진다. 생산 코드와 테스트 코드가 서로 다른 역할을 하므로 learnerTarget이 어느 쪽인지 매번 확인해야 한다.

실제 기술 이름LedgerQueryService production target, LedgerQueryIT test target, method selector versus class selector

25TransferController.java는 세 상태 코드 시험과 어떻게 연결되나요?

이동 창구 파일은 요청을 받아 실제 이동 일을 맡기는 다리다. 세 번째 학습 대상 TransferController.java는 JSON 요청과 인증된 Principal 이름을 TransferService.Command로 바꾸고, 성공 결과를 TransferResponse로 바꿔 201을 돌려준다. selector는 TransferControllerTest 전체다.

연결된 메서드는 firstTransferIs201, zeroAmountIs400WithoutTransferWrite, missingAuthenticationIs401이다. 각각 정상 응답, 0원 입력 거절, 미인증 경계를 나누어 본다. 첫 메서드가 최종 잔액을 직접 단언하지 않고, 마지막 메서드가 DB 행 수를 직접 세지 않는다는 보장 경계도 컨트롤러 책임 설명에 함께 남겨야 한다.

실제 기술 이름TransferController learnerTarget, TransferControllerTest class selector, status-code matrix, authentication boundary

26TransferService 회수에서 실제 고장점은 몇 개인가요?

마지막 봉투가 실제로 끊는 고장점은 하나뿐이다. 여섯 번째 학습 대상 TransferService.java에는 transfer 마지막 부분에서 failureHook.afterBusinessMutation()을 한 번 부르는 지점이 있다. 실제 reference source의 TransferFailurePointIT도 이 한 지점에서 RuntimeException을 던지는 @Test 하나만 가진다. 직접 assertion하는 것은 invoked가 true, FAIL-FROM 10,000원, FAIL-TO 5,000원, TRANSFER business_tx 0개, TRANSFER_ ledger_entry 0개다.

W8 PDF의 일부 토요일 카드 표현에는 AFTER_CLAIM과 AFTER_BUSINESS 두 hook 또는 idempotency 0 같은 문구가 보일 수 있다. 그러나 manifest가 가리키는 introducedAt W7D5의 실제 소스에는 한 hook과 한 @Test만 있다. 미리보기와 코드 뒤풀이는 실행 가능한 actual source를 기준으로 하며 두 고장점을 시험했다고 부풀리지 않는다.

실제 기술 이름TransferService learnerTarget, TransferFailureHook.afterBusinessMutation, source-of-truth precedence

27최근 거래 10건을 가져올 때 ORDER BY와 LIMIT만 쓰면 되나요?

최근 거래는 새것부터 10개만 가져오되 같은 시각끼리도 순서가 흔들리지 않아야 한다. SQL 연습 Q07은 애플리케이션 ledger_entry가 아니라 workbook의 business_tx를 읽는다. occurred_at DESC로 최신순을 만들고 LIMIT 10을 붙이되, 같은 occurred_at을 가진 행의 순서를 고정하도록 tx_id DESC를 두 번째 기준으로 둔다.

전체 예시 형태는 SELECT tx_id, tx_type, status, amount, occurred_at FROM business_tx ORDER BY occurred_at DESC, tx_id DESC LIMIT 10이다. 이 문제는 W8 SQL 연습 결과물 evidence/w8/sql-q07.sql로 남기는 prompt이며, reference project에 canonical learner answer 파일이 없고 위의 JUnit 10개도 이 SQL 정답을 직접 채점하지 않는다.

실제 기술 이름workbook business_tx, SQL ORDER BY occurred_at DESC, tx_id DESC, LIMIT, prompt-only SQL Q07

workbook business_tx를 occurred_at과 tx_id 내림차순으로 정렬해 최근 10건을 고르는 Q07 예시
그림 한눈에: Q07의 business_tx 한 행 grain, occurred_at DESC·tx_id DESC, LIMIT 10과 동률 반례를 한 장에 묶는다.

28금액의 1퍼센트 수수료는 왜 반올림 규칙까지 적어야 하나요?

1퍼센트를 계산한 값에 1원 미만이 생기면 어느 쪽 원으로 보낼지 먼저 정해야 한다. SQL 연습 Q08은 amount의 1퍼센트를 계산해 원 단위로 반올림한다. amount * 0.01이라는 식만 적으면 데이터형과 DB의 반올림 방식에 따라 소수 처리 의도가 흐릴 수 있다. ROUND를 어느 자리에서 적용하는지, 0.5를 어떤 방향으로 처리하는지 계약에 적어야 한다.

예를 들어 10,000원의 1퍼센트는 100원이라 경계가 드러나지 않는다. 10,050원처럼 100.5원이 나오는 입력을 함께 넣어 기대값을 명시해야 반올림 규칙을 시험할 수 있다. 이 역시 evidence/w8/sql-q08.sql에 남기는 별도 SQL 연습이며 현재 LedgerQueryIT가 직접 보장하지 않는다.

실제 기술 이름SQL arithmetic, ROUND, numeric scale, rounding policy, prompt-only SQL Q08

workbook business_tx의 amount에 1퍼센트를 곱해 원 단위로 반올림하는 Q08 예시
그림 한눈에: Q08의 amount×1퍼센트, 원 단위 ROUND, 명시할 반올림 규칙과 경계 입력을 한 장에 묶는다.