7주차 미리보기

7주차 미리보기 · 이야기

STARRY PASS 3 — 두 카드 사이의 1,000원

한쪽에서 돈을 빼는 순간부터 다른 쪽에 도착했다는 사실을 다시 확인할 때까지

비가 그친 뒤의 STARRY 계단은 젖은 우산 냄새와 리허설 소리로 가득했다. 문을 열 때마다 바깥의 서늘한 공기가 들어왔고, 무대에서는 킥 드럼이 둥, 둥 울렸다. 공연 시작까지 두 시간. 니지카는 계산대 위에 새 안내판을 올려놓고 글자가 똑바로 보이는지 한 걸음 물러서서 확인했다.

“공연카드끼리 돈을 옮겨 드립니다.”

키타가 안내판을 소리 내어 읽고는 양손을 번쩍 들었다.

“드디어 친구에게 음료값을 바로 보내 줄 수 있겠네요! 카드 한 장에 돈이 몰렸을 때 매점 앞에서 동전 세는 일도 끝이에요.”

료는 앰프 옆에 앉아 기타 줄을 닦다가 고개만 들었다.

“나에게 보내면 더 빨리 끝난다. 돌려줄 과정이 사라지니까.”

“그건 돈 옮기기가 아니라 돈 잃어버리기잖아.”

니지카의 대답이 끝나기도 전에 첫 손님이 계산대로 다가왔다. 손님은 파란 공연카드와 초록 공연카드를 나란히 놓았다. 파란 카드에는 10,000원, 초록 카드에는 5,000원이 들어 있었다.

“파란 카드에서 초록 카드로 1,000원만 옮겨 주세요.”

키타와 히토리가 함께 화면을 보는 공식 장면
그림 한눈에: 두 사람이 같은 화면을 보듯, 이체는 두 카드의 시작값을 함께 보는 데서 출발한다.

히토리는 두 카드를 보자마자 노트를 펼쳤다. 숫자 하나를 잘못 적었다가 공연장 전체가 정전되는 장면이 머릿속에서 너무 자연스럽게 이어졌기 때문이다.

“그러면 파란 카드는 10,000→9,000, 초록 카드는 5,000→6,000…… 맞죠? 이 두 숫자만 화면에 예쁘게 띄우면 끝일까요?”

“화면은 마지막 장면일 뿐이야.”

이번에는 키타가 먼저 고개를 저었다. 며칠 전 자신이 음료 주문 화면만 믿고 컵을 받지 못했던 일을 아직 기억하고 있었다.

“주문 완료라는 글자가 떠도 컵이 없으면 곤란하잖아요. 두 카드의 진짜 기록도 바뀌었는지 봐야 해요.”

료가 종이컵 두 개를 가져와 파란 스티커와 초록 스티커를 붙였다.

“그리고 왜 바뀌었는지도 남겨야 한다. 파란 컵에서 천 원이 증발하고 초록 컵에 천 원이 우연히 생긴 것처럼 보이면, 나중에 누가 범인인지 알 수 없다.”

“범인은 네가 아니고?”

“아직은.”

니지카는 손님에게 작은 이동 신청표를 내밀었다. 표에는 네 칸이 있었다. 이 부탁을 다른 부탁과 구별할 이름, 돈이 나갈 카드 번호, 돈이 들어갈 카드 번호, 옮길 금액. 히토리는 각 칸 옆에 조그만 확인 표시를 그렸다.

부탁 이름은 비어 있으면 안 된다. 카드 번호는 제대로 된 양수여야 한다. 옮길 금액도 0보다 커야 한다. 하지만 네 칸이 모두 채워졌다고 해서 곧바로 돈을 옮길 수 있는 것은 아니었다.

거래번호와 ID와 금액을 검사하는 순서
그림 한눈에: 부탁 이름·두 카드 번호·금액은 돈을 움직이기 전에 먼저 확인한다.

키타가 장난삼아 두 카드 번호 칸에 똑같이 파란 카드 번호를 적었다.

“파란 카드에서 파란 카드로 1,000원을 보내면요? 나갔다가 돌아오니까 안전한 이동 아닐까요?”

히토리는 잠시 진지하게 계산했다.

“잔액만 보면 그대로일 수도 있지만, 이동표와 장부는 생길 테고…… 실제로 아무것도 옮기지 않았는데 옮겼다고 기록되는 건 이상해요.”

“맞아.”

니지카는 두 번호 사이에 굵은 금지 표시를 그었다.

“칸마다 따로 보는 확인은 ‘번호가 제대로 생겼는가’를 볼 수 있어. 하지만 ‘두 번호가 서로 다른가’는 두 칸을 함께 봐야 알 수 있지. 그래서 접수대에서 한 번, 실제 일을 맡는 자리에서 한 번 더 확인하는 거야.”

료가 빈 금액 칸에 0을 적었다가 지웠다.

“0원을 옮긴 거래를 백만 번 만들면, 돈은 그대로인데 종이만 산이 된다.”

“그 전에 네가 그 종이 산 아래에 깔릴 거야.”

히토리는 네 칸을 다시 읽었다. STARRY-첫이동, 파란 카드 번호, 초록 카드 번호, 1,000원. 표의 겉모양도 괜찮고, 두 카드가 다르다는 뜻도 맞았다. 이제 누가 부탁했는지를 확인할 차례였다.

손님은 신청표 한쪽에 자기 이름을 쓸 수 있었다. 하지만 니지카는 그 글씨만 믿지 않았다. 입구에서 비밀번호를 확인하고 손목에 채워 준 파란 띠를 보았다. 신청표에는 누구 이름이든 적을 수 있지만, 입구에서 확인된 띠는 실제로 확인을 거친 사람에게만 있었다.

“파란 카드의 주인이 이 손님인지 확인해 줘.”

키타가 보관 금고의 카드 기록을 살폈다.

“맞아요. 돈이 나갈 카드는 이 손님 것이고, 돈이 들어갈 카드는 다른 손님 것이에요.”

“들어갈 카드의 주인도 지금 여기 있어야 하나요?” 히토리가 물었다.

“이번 규칙에서는 보내는 사람이 자기 카드에서 돈을 꺼낼 권리가 있는지를 먼저 봐. 받는 사람은 현장에 없어도 돼. 다만 카드가 실제로 존재해야 하지.”

료가 파란 카드만 들어 올렸다.

“남의 카드에 돈을 넣어 주는 건 친절일 수 있지만, 남의 카드에서 꺼내는 건 친절이라고 주장하기 어렵다.”

“그 말은 이상하게 설득력 있네.”

니지카는 계산대 아래에서 긴 끈이 달린 투명 봉투를 꺼냈다. 봉투 앞면에는 다섯 칸이 그려져 있었다.

첫째 칸에는 파란 카드의 새 잔액. 둘째 칸에는 초록 카드의 새 잔액. 셋째 칸에는 ‘카드 이동 한 건’이라고 적힌 큰 영수증. 넷째 칸에는 파란 카드에서 1,000원이 나갔다는 장부 쪽지. 다섯째 칸에는 초록 카드에 1,000원이 들어왔다는 장부 쪽지.

“이 다섯 칸은 따로 제출하면 안 돼.” 니지카가 말했다. “완성된 봉투 하나로 들어가거나, 봉투째 들어가지 않거나. 중간만 남는 일은 없어야 해.”

두 잔액과 두 원장 영수증이 한 트랜잭션에 묶이는 흐름
그림 한눈에: 두 잔액과 세 기록은 따로 끝나지 않고 봉투 하나처럼 함께 끝난다.

키타가 파란 카드 잔액만 9,000원으로 고쳐 적고 펜을 멈췄다.

“여기서 갑자기 전기가 나가면 어떻게 돼요?”

“파란 카드에서만 돈이 사라진 채 끝나면 안 되지.”

히토리는 초록 카드 쪽을 손으로 덮었다. “그럼 화면에 파란 카드 9,000원이 잠깐 보였더라도, 봉투가 끝까지 완성되지 못하면 다시 10,000원으로 돌아가야 해요.”

료가 큰 영수증만 슬쩍 주머니에 넣는 시늉을 했다.

“큰 영수증은 남고 장부 두 줄은 사라지는 특별 상품.”

“반품.”

니지카는 료의 손에서 영수증을 빼앗아 봉투에 다시 넣었다.

“다섯 칸 가운데 하나라도 끝까지 못 쓰면 전부 취소야. 그래야 카드 잔액과 이유를 설명하는 기록이 서로 다른 이야기를 하지 않아.”

하지만 바로 잔액을 고쳐 쓸 수도 없었다. 공연 시작 전 계산대에는 손님이 몰렸고, 같은 카드로 두 사람이 거의 동시에 부탁할 수도 있었다. 히토리가 파란 카드의 10,000원을 읽은 순간, 다른 창구도 똑같은 10,000원을 읽고 서로 다른 계산을 시작하면 곤란했다.

키타가 보관함에서 작은 자물쇠 두 개를 가져왔다.

“계산하는 동안 두 카드를 잠깐 잡아 두는 건 어때요? 먼저 온 봉투가 끝날 때까지 다른 봉투는 기다리는 거예요.”

“좋아. 그런데 두 자물쇠를 잡는 순서도 정하자.”

니지카는 카드의 주인이나 방향이 아니라, 카드에 붙은 기계 번호를 보았다. 번호가 작은 카드를 먼저, 큰 카드를 나중에 잡기로 했다.

히토리가 눈을 깜빡였다.

“돈이 나가는 카드를 먼저 잡으면 더 자연스럽지 않나요?”

료가 파란 자물쇠와 초록 자물쇠를 양손에 들었다.

“한 봉투는 파랑 다음 초록, 반대 방향 봉투는 초록 다음 파랑을 잡으면 둘이 서로 하나씩 쥔 채 영원히 상대를 기다릴 수 있다.”

키타가 두 손을 교차했다가 꼼짝 못 하는 흉내를 냈다.

“그러니까 방향과 상관없이 모두가 작은 번호부터! 같은 약속이면 서로 마주 보고 얼어붙을 가능성을 줄일 수 있네요.”

잠금 순서만 정렬하고 이체 역할은 보존하는 과정
그림 한눈에: 자물쇠는 작은 번호부터 잡지만, 나가는 카드와 들어오는 카드의 역할은 바뀌지 않는다.

두 카드를 잡은 뒤에는 정말 두 장이 모두 있는지 확인했다. 하나만 찾았는데도 억지로 계속하면, 어느 순간 없는 카드의 잔액을 읽으려다 더 큰 문제가 생긴다. 두 장이 모두 있더라도 돈이 나갈 카드의 주인이 입구에서 확인된 손님과 같은지 다시 살폈다.

그리고 드디어 숫자가 움직였다.

파란 카드: 10,000→9,000.

초록 카드: 5,000→6,000.

보낸 카드와 받은 카드의 전후 잔액과 합계가 적힌 영수증
그림 한눈에: 1,000원이 이동하면 10,000원은 9,000원으로, 5,000원은 6,000원으로 바뀐다.

히토리는 계산을 세 번 확인했다.

“전체를 더하면 전에도 15,000원, 뒤에도 15,000원이에요. 어느 쪽에서도 돈이 새로 생기거나 사라지지 않았어요.”

“잔액 둘만 맞으면 충분할까?” 키타가 물었다.

이번에는 료가 먼저 장부 쪽지를 펼쳤다.

“파란 카드에는 나감표 -1000. 초록 카드에는 들어옴표 +1000. 둘을 저울에 올리면 sum0.”

니지카가 고개를 끄덕였다.

“각 쪽지에는 그때의 카드 잔액도 같이 적자. 나감표 뒤에는 9,000원, 들어옴표 뒤에는 6,000원. 나중에 장부를 한 줄씩 따라가도 어디까지 왔는지 알 수 있게.”

히토리는 두 쪽지 위에 같은 큰 영수증 번호를 적었다. 이제 둘은 서로 떨어진 낙서가 아니라, 같은 이동에서 태어난 한 쌍이었다.

같은 이체번호의 두 원장줄과 합계 0 표
그림 한눈에: 나간 -1000과 들어온 +1000을 함께 놓으면 합계는 sum0이다.

“그런데 둘의 합이 0이면 돈을 옮긴 적이 없다는 뜻 아닌가요?” 키타가 물었다.

히토리가 먼저 답했다.

“아니요. 전체 돈이 늘거나 줄지 않았다는 뜻이에요. 한쪽의 -1000과 다른 쪽의 +1000이 서로 설명되니까요. 카드별로 보면 분명히 달라졌고, 둘을 함께 보면 보존됐어요.”

료가 감탄한 표정으로 박수를 한 번 쳤다.

“히토리가 설명했다. 오늘 공연은 이미 성공이다.”

“아, 아니, 방금 건 우연히 문장이 끝난 것뿐이라서…….”

“문장이 끝나는 것도 성공으로 치자.”

다섯 칸을 모두 채운 봉투를 어디로 가져가야 하는지도 중요했다. 니지카는 손님이 서 있는 정식 계산창구에서 금고까지 이어지는 바닥 화살표를 붙였다. 입구에서 손님을 확인하고, 신청표를 받고, 확인 도장을 거쳐, 일을 맡은 담당자가 금고 봉투를 여는 길이었다.

“담당자가 자기 자리에서 자기 자신에게 바로 ‘이 일 좀 해’라고 말하면 더 짧지 않을까요?” 히토리가 조심스럽게 물었다.

키타가 화살표 바깥으로 작은 지름길을 그었다. “여기서 바로 금고로 들어가는 길이요?”

니지카는 지름길 위에 빨간 테이프를 붙였다.

“짧아 보여도 정식 문에 달린 장치를 지나지 않을 수 있어. 봉투를 하나로 묶어 주는 장치가 문 앞에 있다면, 뒷문으로 들어간 사람에게는 봉투가 생기지 않겠지.”

료가 빨간 테이프 아래에 ‘직원 할인 통로’라고 썼다가 지웠다.

“겉으로 같은 일을 부르는 것처럼 보여도, 어느 문을 통과했는지가 다르다.”

공식 길과 지름길을 나란히 비교한 그림
그림 한눈에: 정식 창구를 지나야 봉투를 묶는 장치까지 빠짐없이 통과한다.

히토리는 정식 창구에서 시작해 화살표를 한 칸씩 짚었다. 손님 확인, 신청표 확인, 두 카드 확인, 두 자물쇠, 두 잔액, 큰 영수증 하나, 장부 쪽지 둘. 마지막에는 봉투를 닫고 자물쇠를 풀었다.

새로 만든 이동이 성공했다는 표식과 함께 결과표가 나왔다. 손님이 처음 적은 부탁 이름, 금고가 새로 붙인 큰 영수증 번호, 완료 표식, 파란 카드 9,000원, 초록 카드 6,000원. 손님이 낸 이름과 금고가 붙인 번호는 서로 다른 것이어서 둘 다 남겼다.

201, COMPLETED, 두 카드의 잔액이 적힌 이동 완료 영수증
그림 한눈에: 성공 결과표에는 완료 표시와 두 카드의 새 잔액이 함께 적힌다.

손님은 두 카드의 금액을 확인하고 환하게 웃었다.

“이제 친구가 음료를 살 수 있겠네요.”

키타도 손님을 따라 웃었다. “맛있는 걸 고르세요!”

료가 작게 덧붙였다. “가장 비싼 것으로.”

“료, 네 카드 아니야.”

첫 이동은 무사히 끝났다. 그러나 세이카는 계산대 뒤에서 팔짱을 낀 채 말했다.

“한 번 잘된 건 알겠어. 이제 가장 불편한 순간에도 정말 다 같이 취소되는지 보여 줘.”

히토리의 어깨가 굳었다. “불편한 순간이요?”

“두 잔액을 바꾸고 큰 영수증과 장부 쪽지까지 쓴 바로 다음. 손님에게 결과표를 내주기 직전.”

“거의 다 했는데 일부러 멈추는 거네요.”

이번에는 니지카도 잠시 망설였다. 완성 직전의 봉투를 일부러 떨어뜨리는 일은 생각만 해도 아까웠다.

“그래도 그 순간을 확인해야 해. 앞부분만 취소되고 뒤쪽 종이가 남거나, 반대로 카드만 바뀌고 영수증이 사라지는 일이 없는지.”

료가 작은 종을 봉투 옆에 놓았다.

“이 종이 울리면 고장. 실제 공연에서는 울리지 않는 연습용 종.”

키타가 종을 보며 말했다. “고장 자체가 목적은 아니고, 고장이 난 다음 모두 제자리인지 보는 도구군요.”

두 번째 봉투가 시작됐다. 파란 카드의 잔액이 9,000원으로 바뀌고, 초록 카드가 6,000원이 됐다. 큰 영수증 한 장과 장부 쪽지 두 장도 작성됐다. 바로 그때 료가 종을 울렸다.

딸랑.

계산대 화면에는 결과표가 나오지 않았다. 니지카는 새로 금고를 열어, 손에 들고 있던 카드 모양이나 방금 본 화면이 아니라 실제로 남은 기록을 처음부터 다시 읽었다.

파란 카드 10,000원. 초록 카드 5,000원. 이동 큰 영수증 0장. 이동 장부 쪽지 0장.

다섯 칸 모두가 함께 이전 상태로 돌아가 있었다.

고장 전후 잔액과 기록 수가 같음을 보여 주는 표
그림 한눈에: 완성 직전 멈춰도 원래 잔액과 기록 0개가 한꺼번에 되살아난다.

히토리가 길게 숨을 내쉬었다.

“중간에 9,000원과 6,000원을 분명히 만들었는데, 완료하지 못하니까 실제 금고에는 하나도 남지 않았어요.”

“중간 화면을 믿지 않고 새로 읽은 게 중요해요.” 키타가 금고 문을 두드렸다. “우리 손에 들린 종이만 보고 확인했으면, 아직 바뀐 줄 착각했을 수도 있잖아요.”

료는 종을 주머니에 넣으려다 니지카에게 들켰다.

“연습용 종은 공연장 물품이야.”

“내 주머니에서 더 안전하다.”

“가장 위험해.”

마지막 확인은 손님 없이 이루어졌다. 니지카는 성공하는 부탁, 0원을 옮기겠다는 부탁, 같은 카드에서 같은 카드로 보내겠다는 부탁, 입구 확인 없이 들어온 부탁을 차례로 준비했다.

키타는 접수표의 빈칸을 살폈고, 료는 금고 앞에서 큰 영수증과 장부 수를 셌다. 히토리는 매번 새로 카드 잔액을 읽었다. 니지카는 손님에게 돌아갈 결과표의 모양을 확인했다. 한 사람만 같은 역할을 계속 맡지는 않았다. 서로 자리를 바꿔 보아야, “아마 다른 사람이 봤겠지”라는 구멍이 사라졌다.

0원을 적은 부탁은 접수대에서 멈췄고 이동 영수증은 생기지 않았다. 입구 확인이 없는 부탁은 창구 안으로 들어오지 못했다. 같은 카드 두 장을 적은 부탁은 실제 일을 시작하기 전에 멈췄다. 성공한 부탁만 두 잔액과 큰 영수증 하나, 장부 쪽지 두 장을 함께 남겼다.

실패 후 새 트랜잭션에서 최종 DB 상태를 읽는 시간선
그림 한눈에: 방금 만진 종이가 아니라 새 창구에서 실제 금고 값을 다시 읽어 확인한다.

“그럼 이제 완벽한가요?” 히토리가 물었다.

니지카는 잠시 생각하다가 고개를 가로저었다.

“오늘 약속한 범위는 확인했어. 하지만 같은 부탁을 두 번 보내는 일, 아주 많은 손님이 동시에 반대 방향으로 돈을 보내는 일, 금고 밖의 다른 가게까지 함께 움직여야 하는 일은 또 다른 준비가 필요해.”

“끝났다고 말하지 않는 것도 확인의 일부네요.” 키타가 말했다.

료가 계산대 위에 남은 종이를 한데 모았다.

“모르는 것을 모른다고 기록하면, 적어도 모르는 것이 몰래 완료 표식을 받지는 않는다.”

히토리는 노트 마지막 장에 오늘의 숫자를 큼직하게 적었다.

10,000→9,000.

5,000→6,000.

-1000, +1000, sum0.

그리고 그 아래에는 더 작은 글씨로 한 문장을 덧붙였다.

‘돈을 옮겼다는 말은 한쪽 숫자만 줄였다는 뜻이 아니다. 두 카드와 이유를 설명하는 기록이 한 봉투 안에서 끝까지 같은 이야기를 한다는 뜻이다.’

무대에서 료와 니지카와 히토리가 대화하는 공식 장면
그림 한눈에: 네 사람은 결과 하나만 보지 않고, 아직 확인하지 않은 범위까지 함께 짚는다.

공연이 시작될 시간이 되자 계산대 불빛이 조금 어두워졌다. 첫 손님은 초록 카드로 음료를 사고 무대 앞쪽으로 내려갔다. 두 카드 사이를 건넌 1,000원은 화면에서만 반짝이다 사라진 숫자가 아니었다. 어디에서 나와 어디로 갔는지, 그때 얼마가 남았는지, 어느 한 묶음의 일이었는지가 조용히 금고 안에 남아 있었다.

무대에서 니지카가 스틱을 들어 올렸다. 키타가 마이크 앞에서 숨을 골랐고, 료는 첫 음을 기다렸다. 히토리는 마지막으로 계산대 쪽을 돌아보았다. 성공한 한 봉투와 취소된 한 봉투가 서로 섞이지 않은 채, 각자 있어야 할 자리와 없어야 할 자리를 정확히 지키고 있었다.

네 사람이 함께 음료를 들고 대화하는 공식 장면
그림 한눈에: 공연이 끝난 뒤에도 성공한 기록과 취소된 기록은 섞이지 않고 제자리를 지킨다.

히토리는 기타 줄 위에 손가락을 올렸다.

이번에는 중간에 선을 뽑는 상상을 하지 않았다. 대신 네 사람이 같은 박자에 들어오는 순간을 기다렸다. 한 사람만 먼저 끝내지 않고, 모두가 함께 시작해서 함께 마치는 것. 오늘 계산대에서 배운 가장 중요한 규칙은, 이상하게도 합주와 아주 닮아 있었다.

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

히토리의 질문 노트 — 두 카드 사이의 1,000원은 실제로 어떻게 움직일까?

이야기에서 숨겨 두었던 이름을 하나씩 꺼내 보는 자리. 먼저 쉬운 말로 이해하고, 마지막 줄에서 실제 Java·Spring·JUnit·SQL 이름을 확인한다.

1손님이 내민 ‘이동 신청표’는 코드에서 무엇인가요?

히토리가 신청표의 네 칸을 다시 짚었다. “부탁 이름, 나갈 카드, 들어갈 카드, 금액을 한 번에 들고 다니는 종이였죠?”

“맞아. 코드에서는 TransferRequest라는 record가 그 일을 해.” 니지카가 답했다. 이 객체는 transactionId, fromAccountId, toAccountId, amount를 한 묶음으로 받는다. 예를 들어 W7-HTTP, 1, 2, 1000이라는 요청 한 건을 흩어진 변수 네 개 대신 한 장의 신청표처럼 전달한다.

record라고 해서 돈을 직접 옮기지는 않는다. 값을 담고, 같은 이름의 읽기 메서드인 transactionId(), amount() 등을 자동으로 제공하는 작은 운반 상자에 가깝다. 초보자가 자주 찾는 getAmount()가 아니라 amount()라는 점도 기억해 두자. 검사용 표시가 붙어 있어도 객체 생성 자체를 막는 것은 아니며, 검사를 실행하는 장치가 따로 필요하다.

숨겨진 기술 이름요청 DTO, Java record, record component, canonical constructor, accessor

2빈 부탁 이름과 0원은 누가 막나요?

키타가 transactionId 자리에 공백 한 칸을 쓰고, 나갈 카드에는 0, 들어갈 카드에는 -1, 금액에는 0을 적었다. “글자가 있긴 있는데, 이건 통과하면 안 되겠죠?”

@NotBlanknull, 빈 문자열, 공백뿐인 문자열을 거절한다. @Positive는 숫자가 반드시 0보다 크다고 요구한다. 그래서 new TransferRequest(" ", 0, -1, 0)을 검증하면 네 칸이 각각 한 번씩 걸려 위반이 정확히 4개다. longnull이 될 수 없는 기본형이지만 0이나 음수는 될 수 있으므로 @Positive가 필요하다.

료가 덧붙였다. “표시만 붙였다고 경비원이 생기는 건 아니다.” 실제로는 Validator.validate(...)를 부르거나, 웹 입구에서 @Valid가 검사를 요청해야 한다. 또한 이 네 표시는 각 칸을 따로 볼 뿐, 두 카드 번호가 같은지는 알지 못한다.

숨겨진 기술 이름Jakarta Bean Validation, @NotBlank, @Positive, Validator, constraint violation

거래번호와 ID와 금액을 검사하는 순서
그림 한눈에: 네 입력 칸은 각각 무엇을 확인하고, 어떤 관계 검사를 남겨 두는지 보여 준다.

3두 카드 번호가 모두 양수인데 서로 같으면 왜 별도 검사인가요?

히토리는 fromAccountId=3, toAccountId=3을 적었다. 두 숫자 모두 양수라서 각 칸의 표면 검사는 통과한다. 그러나 자기 카드에서 자기 카드로 보내는 일은 실제 이동이 아니고, 쓸데없는 거래와 장부를 만들 수 있다.

그래서 TransferService.validate(command)fromAccountId() == toAccountId()를 함께 비교하고 IllegalArgumentException("same account")을 던진다. 같은 메서드는 빈 actorId, 빈 transactionId, 0 이하 계좌 ID, 0 이하 금액도 다시 확인한다. 입구 검사는 잘못된 JSON 요청을 빨리 돌려보내고, 서비스 검사는 웹이 아닌 다른 호출 경로에서도 업무 규칙을 지키게 한다.

니지카가 선을 그었다. “@Positive를 붙였으니 같은 번호도 막겠지, 라고 생각하면 안 돼. 한 칸 규칙과 두 칸 관계 규칙은 다른 질문이야.”

숨겨진 기술 이름service-layer validation, cross-field validation, guard clause, IllegalArgumentException

4`@RequestBody`와 `@Valid`는 같은 일을 하나요?

“둘 다 요청 앞에 붙어 있으니 한 팀 아닌가요?” 키타가 물었다.

한 팀이지만 역할은 다르다. @RequestBody는 HTTP 요청의 JSON을 읽어 TransferRequest로 바꾸라고 Spring MVC에 알린다. @Valid는 그렇게 만든 객체에 Bean Validation 규칙을 실행하라고 알린다. JSON 문법이 깨지면 객체를 만들지 못해 @Valid까지 오지 못한다. 현재 코드에는 그 읽기 실패를 전용 400으로 바꾸는 처리가 보이지 않으므로, 별도 보강 전에는 amount=0 검증 실패와 같은 400 계약이라고 단정하면 안 된다.

히토리가 정리했다. “봉투를 접는 사람이 @RequestBody, 접힌 봉투의 칸을 검사하는 사람이 @Valid군요.” 그리고 이 단계가 통과해도 잔액, 소유권, 두 카드가 서로 다른지까지 보장되지는 않는다. 그것은 서비스가 확인한다.

숨겨진 기술 이름Spring MVC argument resolution, JSON deserialization, @RequestBody, @Valid, HTTP 400

5보내는 사람 이름은 왜 요청 JSON에서 받지 않나요?

료가 신청표에 “내 이름은 세이카”라고 적었다. 니지카는 표를 보지도 않고 입구에서 확인된 손목띠를 가리켰다. 누구나 JSON에 남의 이름을 쓸 수 있으므로, 보내는 사람은 인증을 거친 정보에서 가져와야 한다.

TransferControllerPrincipal principal을 받고 principal.getName()actorId로 넘긴다. 테스트의 httpBasic("customer-1", "password")가 인증에 성공하면 그 이름이 customer-1이 된다. 서비스는 잠근 출금 계좌의 ownerId와 이 actorId를 비교하고, 다르면 ACCESS_DENIED로 거절한다.

키타가 물었다. “그러면 Principal만 있으면 소유권도 자동 확인되나요?” 아니다. Principal은 ‘누가 로그인했는가’를 알려 줄 뿐이다. 그 사람이 이 출금 계좌의 주인인지 비교하는 코드는 여전히 필요하다. 인증 없이 보내면 보안 단계에서 401로 막혀 컨트롤러까지 오지 않는다.

숨겨진 기술 이름Spring Security, java.security.Principal, authenticated principal, authorization, ACCESS_DENIED, HTTP Basic

Controller에서 actorId를 받아 Service로 넘기는 전체 이체 흐름
그림 한눈에: 인증된 사람의 이름이 요청부터 서비스와 DB 확인까지 어떻게 이어지는지 따라간다.

6`TransferRequest`, `Command`, `Result`, `TransferResponse`는 왜 넷이나 있나요?

히토리는 이름이 비슷한 상자 네 개를 보고 잠시 멈췄다. 니지카가 이동 경로를 나눠 적었다. 웹에서 들어온 모양은 TransferRequest, 서비스가 일할 때 쓰는 명령은 TransferService.Command, 서비스가 끝낸 결과는 TransferService.Result, HTTP로 돌려줄 모양은 TransferResponse다.

컨트롤러는 요청의 transactionId, 두 계좌 ID, 금액에 인증된 actorId를 더해 Command를 만든다. 서비스는 새로 저장된 업무 거래의 UUID와 두 최종 잔액을 Result로 돌려준다. 컨트롤러는 처음 받은 transactionId와 새 businessTransactionId를 둘 다 넣고, 성공한 뒤에만 status="COMPLETED"인 응답을 만든다.

료가 두 번호를 가리켰다. “손님이 붙인 부탁 이름과 금고가 만든 거래 UUID는 같은 값이 아니다.” 이름이 비슷하다고 합치면 요청 추적과 저장 기록의 구분이 흐려진다.

숨겨진 기술 이름DTO mapping, application command/result, API response contract, correlation ID, UUID

7‘다섯 칸짜리 봉투 하나’의 실제 이름은 무엇인가요?

이체 한 번은 출금 잔액, 입금 잔액, business_tx 한 행, ledger_entry 두 행을 함께 바꾼다. TransferService.transfer(...) 위의 @Transactional은 이 메서드가 정상 종료되면 한꺼번에 확정하고, 처리 중 예외가 밖으로 나가면 한꺼번에 되돌릴 경계를 만든다.

예를 들어 처리 도중 잠시 10,000→9,000, 5,000→6,000이 되었더라도 RuntimeException이 발생해 롤백되면 새 조회 결과는 다시 10,000과 5,000이고 거래 행과 장부 행도 0개다. 반대로 정상 반환하면 데이터베이스 커밋이 이루어져 9,000과 6,000, 거래 1개, 장부 2개가 함께 남는다.

키타가 조심스럽게 물었다. “모든 예외가 무조건 똑같이 롤백되나요?” Spring의 기본 규칙은 보통 처리되지 않은 RuntimeExceptionError에 롤백하는 것이다. checked exception까지 같은 규칙으로 만들려면 rollbackFor 같은 별도 설정을 검토해야 한다.

숨겨진 기술 이름Spring transaction, @Transactional, atomicity, commit, rollback, default rollback rule

두 잔액과 두 원장 영수증이 한 트랜잭션에 묶이는 흐름
그림 한눈에: 두 잔액과 거래 한 줄·장부 두 줄이 트랜잭션 하나에 묶인다.
RuntimeException과 checked exception의 기본 롤백 차이
그림 한눈에: 예외 종류에 따라 Spring의 기본 롤백 규칙이 어떻게 달라지는지 비교한다.

8메서드 위에 `@Transactional`만 쓰면 언제나 봉투가 생기나요?

“간판만 붙이면 문이 생기는 건 아니야.” 이번에는 료가 먼저 설명했다. Spring이 관리하는 TransferService 빈을 바깥에서 호출하면 앞에 놓인 AOP 프록시가 호출을 가로채 트랜잭션을 시작한다. 그래서 @Service로 등록된 객체와 프록시를 통과하는 호출 경로가 중요하다.

같은 객체 안에서 this.transfer(...)처럼 자기 메서드를 직접 부르면 일반적인 프록시 경계를 건너지 않아 기대한 트랜잭션 조언이 적용되지 않을 수 있다. 또 private 메서드에 간판만 붙이는 방식도 이 프록시 모델의 정상적인 진입점으로 기대하면 안 된다. 이 자료는 외부에서 호출되는 public transfer(Command)에 경계를 둔다.

히토리가 시험 범위를 적었다. TransactionProxyIT는 실제 주입 객체가 프록시인지와 공개 메서드에 애너테이션이 있는지만 확인한다. 이 사실만으로 실전 롤백까지 증명한 것은 아니다.

숨겨진 기술 이름Spring AOP proxy, managed bean, AopUtils.isAopProxy, proxy boundary, self-invocation

바깥 호출과 자기 내부 호출의 프록시 통과 여부 비교
그림 한눈에: 바깥에서 공식 문을 통과한 호출과 같은 객체 안의 지름길 호출을 나란히 본다.

9두 계좌는 왜 번호가 작은 순서로 잠그나요?

파란 카드에서 초록 카드로 보내는 요청과 초록 카드에서 파란 카드로 보내는 요청이 동시에 시작할 수 있다. 각 요청이 ‘출금 카드부터’ 잡으면 서로 반대 자물쇠를 하나씩 들고 상대가 놓기만 기다리는 고리가 생길 수 있다.

서비스는 두 ID를 sorted()로 정렬한 뒤 findAllForUpdateOrderById(ids)로 읽는다. 저장소 메서드는 @Lock(LockModeType.PESSIMISTIC_WRITE)order by a.id를 사용한다. 방향과 무관하게 작은 ID부터 잡는 공통 규칙이 경쟁 순서를 일관되게 만든다. 자물쇠는 트랜잭션이 끝날 때까지 다른 쓰기를 기다리게 한다.

니지카는 단서를 붙였다. “정렬은 교착 가능성을 크게 줄이는 규칙이지, 세상의 모든 교착과 지연을 없애는 마법은 아니야.” 다른 자원까지 서로 다른 순서로 잠그거나 긴 트랜잭션을 만들면 별도 문제가 생길 수 있다.

숨겨진 기술 이름pessimistic write lock, LockModeType.PESSIMISTIC_WRITE, deterministic lock order, deadlock prevention

잠금 순서만 정렬하고 이체 역할은 보존하는 과정
그림 한눈에: 잠금 순서는 번호순으로 고정해도 FROM과 TO의 업무 역할은 원래대로 복원된다.

10정렬하고 나면 어느 계좌가 ‘나가는 쪽’인지 잃어버리지 않나요?

히토리의 질문에 키타가 카드 두 장을 번호순으로 놓은 뒤 색깔표를 붙였다. 잠그는 순서와 업무 역할은 따로 기억하면 된다. 서비스는 잠근 계좌가 정확히 두 개인지 확인하고, HashMap<Long, Account>id → Account로 담는다. 그다음 원래 명령의 fromAccountIdtoAccountId로 각각 from, to를 다시 꺼낸다.

예를 들어 받는 계좌 ID가 더 작아 먼저 잠겼더라도, to = byId.get(command.toAccountId())이므로 입금 역할은 바뀌지 않는다. locked.size() != 2면 하나라도 존재하지 않는 것으로 보고 ACCOUNT_NOT_FOUND를 던진다. 같은 ID 두 개는 앞선 검증에서 이미 거절되므로 ‘한 계좌를 두 번 찾았는데 크기가 1’인 모호함도 만들지 않는다.

숨겨진 기술 이름HashMap, ID-to-entity mapping, existence precondition, ACCOUNT_NOT_FOUND

11누구의 계좌인지 어느 쪽까지 확인하나요?

서비스는 from.getOwnerId().equals(command.actorId())를 검사한다. 즉 로그인한 사람이 돈이 나가는 계좌를 소유했는지 확인한다. 다르면 BusinessException(ErrorCode.ACCESS_DENIED, "access denied")가 발생하고 출금·입금·기록 저장으로 넘어가지 않는다.

“받는 카드의 주인도 로그인한 사람이어야 하나요?” 히토리가 묻자 료가 고개를 저었다. 이 규칙에서는 다른 사람에게 보낼 수 있어야 하므로 입금 계좌의 소유자는 같을 필요가 없다. 다만 입금 계좌 자체는 존재해야 한다. 또한 이 검사는 ‘로그인했다’와 ‘출금 권한이 있다’를 구분한다.

주의할 점은 소유권 확인 하나만으로 모든 금융 보안이 끝나지 않는다는 것이다. 계좌 상태, 금액 한도, 통화, 사기 탐지 같은 규칙은 별도의 범위다.

숨겨진 기술 이름ownership authorization, actor ID, BusinessException, ErrorCode.ACCESS_DENIED

12`withdraw`와 `deposit`을 부르면 왜 `accounts.save(...)`가 없나요?

니지카는 이미 잠겨서 데이터베이스가 관리 중인 두 Account 객체를 가리켰다. 트랜잭션 안에서 조회한 JPA 엔티티의 필드가 바뀌면, 커밋할 때 JPA가 변경을 감지해 UPDATE를 만든다. 그래서 이 서비스에는 계좌를 다시 저장하는 명시적 save가 없다.

from.withdraw(1000)은 계좌가 ACTIVE인지, 금액이 양수인지, 잔액이 충분한지 확인하고 10,000을 9,000으로 만든다. to.deposit(1000)도 활성 상태와 양수를 확인하고 Math.addExact로 5,000을 6,000으로 만든다. addExactlong 범위를 넘는 덧셈을 조용히 뒤집지 않고 예외로 알린다.

키타가 말했다. “서비스에서 금액을 검사했는데 계좌도 또 검사하네요.” 이것은 중복 실수라기보다 엔티티가 다른 호출 경로에서도 자기 불변식을 지키게 하는 방어다.

숨겨진 기술 이름JPA managed entity, dirty checking, flush, entity invariant, Math.addExact, insufficient balance

13큰 영수증 한 장인 `business_tx`에는 무엇이 남나요?

BusinessTransaction.completedTransfer(command.transactionId(), now)는 새 UUID를 만들고, 유형을 TRANSFER, 상태를 COMPLETED, 연결 이름을 요청의 transactionId로 둔다. 요청 시각과 완료 시각에는 같은 now가 들어간다. 저장된 UUID가 Result.businessTransactionId가 되어 응답으로 돌아간다.

히토리가 물었다. “코드가 먼저 COMPLETED라고 썼는데, 바로 뒤에서 고장 나면 거짓 완료 행이 남지 않나요?” 같은 트랜잭션 안에서 뒤쪽 RuntimeException이 발생하면 이 INSERT도 롤백된다. 즉 메모리 객체에 한때 완료 글자가 있었던 것과 데이터베이스에 완료 행이 커밋된 것은 다르다.

이 행은 이동 전체의 머리표다. 어느 계좌에서 얼마가 움직였는지 세부 흔적은 연결된 두 ledger_entry가 설명한다.

숨겨진 기술 이름BusinessTransaction, business_tx, UUID primary key, transaction type/status, correlation ID

14장부는 왜 한 줄이 아니라 두 줄인가요?

료가 한쪽에 TRANSFER_OUT, 다른 쪽에 TRANSFER_IN을 썼다. 출금 장부는 amount=1000, signed_amount=-1000, balance_after=9000이다. 입금 장부는 amount=1000, signed_amount=+1000, balance_after=6000이다. 두 줄은 같은 business_tx를 가리킨다.

signed_amount를 더한 sum0은 내부 이동에서 전체 돈이 새로 생기거나 사라지지 않았다는 보존 규칙을 보여 준다. 하지만 합계만 0이라고 충분한 것은 아니다. 엉뚱한 계좌 두 개에 -1000과 +1000을 써도 합계는 0이기 때문이다. 정확한 계좌, 항목 유형, 행 수 2개, 최종 잔액도 함께 확인해야 한다.

히토리는 amountsigned_amount를 구분해 적었다. 전자는 이동 크기를 양수로 보존하고, 후자는 방향까지 표시한다.

숨겨진 기술 이름LedgerEntry, ledger_entry, TRANSFER_OUT, TRANSFER_IN, signed amount, balanced pair, balance_after

같은 transferId의 -1,000원과 +1,000원 원장 두 줄
그림 한눈에: 같은 거래에 매달린 -1,000원과 +1,000원 두 장부 줄을 한눈에 대조한다.

15시간은 왜 `Instant.now()`를 한 번만 구하나요?

서비스는 잔액을 바꾼 뒤 Instant now = Instant.now()를 한 번 만들고 큰 거래와 두 장부에 같은 값을 전달한다. 키타가 세 번 시계를 봤다면 아주 짧은 이체 한 건 안에서도 기록 시간이 미세하게 갈릴 수 있다. 같은 순간 값을 공유하면 세 기록이 한 사건에서 만들어졌다는 설명이 단순해진다.

그렇다고 같은 시각이 데이터베이스 저장 순서나 전 세계의 절대적인 사건 순서를 보장하는 것은 아니다. 여러 서버의 시계 오차, 커밋 시점, DB 정렬 기준은 별도 문제다. 여기서는 한 이체 내부 기록의 시간 일관성을 얻는 선택으로 보면 된다.

숨겨진 기술 이름java.time.Instant, shared timestamp, temporal consistency

16일부러 멈추는 종, `TransferFailureHook`은 실제 서비스 기능인가요?

아니다. TransferFailureHook은 두 잔액과 거래·장부를 바꾼 직후에 테스트가 정확히 예외를 넣을 수 있게 만든 작은 시험 접점이다. 기본값 TransferFailureHook.NONE = () -> {}은 아무 일도 하지 않는다. 생성자에서는 ObjectProvider<TransferFailureHook>로 제공된 구현이 있으면 쓰고, 없으면 NONE을 쓴다.

TransferFailurePointIT@TestConfiguration@Bean으로 예외를 던지는 구현을 주입한다. 료가 종을 아무 때나 울리는 것이 아니라 afterBusinessMutation()이라는 정해진 위치에서 한 번 울리는 이유도, ‘일부 변경 뒤 롤백’이라는 어려운 경계를 재현 가능하게 만들기 위해서다.

니지카가 강조했다. “고장을 고객 기능으로 추가한 게 아니야. 테스트에서만 바꿔 끼우는 관찰 장치야.”

숨겨진 기술 이름test seam, failure injection, TransferFailureHook, ObjectProvider, no-op object, @TestConfiguration

17롤백 확인 때 왜 방금 만진 객체 대신 데이터베이스를 새로 읽나요?

실패 직전의 Account 객체는 메모리 안에서 이미 9,000과 6,000으로 바뀌었을 수 있다. 그 객체만 다시 보면 롤백된 데이터베이스와 다른 오래된 상태를 보고 착각할 수 있다. 그래서 실패 테스트는 호출이 끝난 뒤 JdbcClientaccount, business_tx, ledger_entry를 새로 조회한다.

확인 결과는 출금 10,000, 입금 5,000, 이체 거래 0, 이체 장부 0이어야 한다. AtomicBoolean invokedtrue라는 검사는 예외 접점까지 실제로 도달했다는 증거다. 그렇지 않으면 입력 검증에서 먼저 실패했는데도 ‘롤백 성공’으로 오해할 수 있다.

키타가 정리했다. “예외가 났다는 사실, 고장 지점까지 갔다는 사실, DB가 원상복구됐다는 사실은 서로 다른 확인이네요.”

숨겨진 기술 이름fresh database read, JdbcClient, rollback verification, stale in-memory state, AtomicBoolean

실패 후 새 트랜잭션에서 최종 DB 상태를 읽는 시간선
그림 한눈에: 실패한 호출이 끝난 뒤 새 조회로 최종 DB 상태를 확인하는 시간선을 보여 준다.

18성공 응답의 201과 `COMPLETED`는 각각 무엇을 말하나요?

컨트롤러는 성공한 새 이체에 ResponseEntity.status(HttpStatus.CREATED)를 사용하므로 HTTP 상태는 201이다. 본문 TransferResponse에는 손님의 transactionId, 저장된 businessTransactionId, 문자열 COMPLETED, 두 최종 잔액이 들어간다.

히토리가 “201이면 장부까지 전부 맞다는 뜻인가요?”라고 묻자 니지카가 고개를 저었다. 201은 API가 새 이체 결과를 성공적으로 만들었다는 외부 계약이다. 특정 테스트가 201만 봤다면 DB의 두 잔액과 장부 쌍까지 자동으로 검증한 것은 아니다. 반대로 DB가 맞더라도 컨트롤러가 200을 돌려주면 이 API의 201 계약은 깨진다.

즉 HTTP 응답과 저장 결과는 이어져 있지만, 테스트에서는 각각 확인해야 한다.

숨겨진 기술 이름ResponseEntity, HttpStatus.CREATED, HTTP 201, response body contract, COMPLETED

19SQL Q05와 Q06은 무엇을 찾는 질문인가요?

Q05는 business_tx에서 completed_at이 아직 없는 행을 찾는다. 핵심 조건은 completed_at IS NULL이다. SQL의 NULL은 보통 값 하나가 아니라 ‘알 수 없음/없음’을 뜻하므로 completed_at = NULL로 비교하면 원하는 참·거짓이 나오지 않는다.

Q06은 상태가 실패 또는 불명인 거래를 찾는다. status IN ('FAILED', 'UNKNOWN')이라고 쓰면 두 후보를 한 묶음으로 읽을 수 있다. status='FAILED' OR status='FAILED'처럼 같은 상태를 중복하거나, AND로 동시에 두 상태라고 요구하면 계약과 다르다.

료가 덧붙였다. “0행이 나와도 쿼리 실패는 아니다.” 또 Q05·Q06이 읽는 SQL workbook의 business_tx는 애플리케이션의 V001 운영 연습 스키마와 동일한 표라고 가정하면 안 되는 별도 SQL 연습장이다. workbook fixture에는 failure_reason처럼 이 문제를 위한 칸과 예시 상태가 따로 있다. 실습에서는 그 fixture를 기준으로 입력 표의 한 행 의미, 출력 한 행 의미, 예상 행 수와 반례까지 설명해야 한다. 두 문제에는 저장소의 정답 파일이 없으므로 문서의 SQL은 ‘예시 정답’으로 다룬다.

숨겨진 기술 이름SQL IS NULL, three-valued logic, IN, predicate, row grain, cardinality, Q05/Q06

IS NULL과 IN의 올바른 예시와 잘못된 NULL 비교
그림 한눈에: IS NULLIN의 올바른 조건식과 흔한 잘못을 함께 비교한다.

20`TransferRequestValidationTest` 하나가 보장하는 범위는 어디까지인가요?

이 JUnit 테스트의 메서드 이름은 blankTransactionAndNonPositiveIdsAndAmountAreRejected다. Validation.buildDefaultValidatorFactory()로 실제 Validator를 만들고 TransferRequest(" ", 0, -1, 0)을 검사한 뒤 위반이 4개인지 AssertJ로 확인한다. 따라서 네 record component의 @NotBlank@Positive가 붙어 작동한다는 것은 보장한다.

하지만 웹 요청을 보내지 않으므로 HTTP 400은 증명하지 않는다. 두 계좌 ID가 같은 경우, 인증, 소유권, 잔액 부족, 데이터베이스 롤백도 다루지 않는다. 테스트가 빠르다고 전체 이체가 안전하다는 뜻은 아니다.

히토리는 노트에 “입력표 네 칸의 개별 규칙만”이라고 테두리를 쳤다.

숨겨진 기술 이름JUnit 5 @Test, Bean Validation unit test, AssertJ hasSize(4), TransferRequestValidationTest

21`TransferBalanceIT`는 무엇을 새로 증명하나요?

oneTransactionMovesBothBalancesAndWritesOneBalancedPair는 Spring 애플리케이션과 PostgreSQL을 띄우고, 10,000원 출금 계좌와 5,000원 입금 계좌를 만든 다음 1,000원을 이체한다. 새 SQL 조회로 잔액이 9,000과 6,000인지, TRANSFER 업무 거래가 1개인지, 이체 장부의 SUM(signed_amount)가 0인지 확인한다.

그래서 서비스와 JPA·DB가 연결된 정상 경로의 핵심 결과는 증명한다. 그러나 장부 행이 정확히 2개인지는 이 테스트 하나에서 세지 않는다. 합계 0만으로 잘못된 네 줄까지 배제할 수는 없다. HTTP 201, 인증, 고장 뒤 롤백도 범위 밖이다.

키타가 말했다. “이 테스트는 성공한 금고 안을 보지만, 계산창구의 응답 모양까지 보진 않는군요.”

숨겨진 기술 이름@SpringBootTest, PostgreSQL integration test, JdbcClient, TransferBalanceIT, fresh SQL assertion

22`TransactionProxyIT`는 트랜잭션이 완벽하다고 증명하나요?

메서드 transferBeanIsProxiedAndPublicMethodOwnsTheBoundary는 두 가지를 본다. 주입된 TransferServiceAopUtils.isAopProxy(transfers)에서 참인지, 그리고 reflection으로 찾은 공개 transfer(Command) 메서드에 @Transactional이 실제 붙어 있는지 확인한다.

료가 체크 두 개를 그리고 멈췄다. 이 테스트는 이체를 호출하지 않는다. 따라서 커밋, 롤백, 잠금, 두 잔액, 장부를 직접 증명하지 않는다. 프록시와 애너테이션이 있어도 잘못된 호출 경로나 다른 예외 규칙 때문에 런타임 동작이 기대와 다를 수 있으므로, 실패 지점 통합 테스트가 별도로 필요하다.

숨겨진 기술 이름TransactionProxyIT, AopUtils.isAopProxy, Java reflection, isAnnotationPresent(Transactional.class)

23`TransferControllerHappyPathTest`의 ‘성공’은 얼마나 넓은 성공인가요?

newTransferUses201AndCompletedResponseMockMvc/api/transfers에 POST하고, httpBasic("customer-1", "password"), JSON 본문, application/json을 실제 웹 계층에 건넨다. 돌아온 상태가 201인지, 본문 문자열에 W7-HTTPCOMPLETED가 들어 있는지 확인한다.

히토리가 빈칸을 찾았다. 이 테스트는 본문의 전체 JSON 필드와 정확한 잔액 값을 비교하지 않는다. 데이터베이스에서 거래 1개·장부 2개·합계 0도 다시 조회하지 않는다. 따라서 “웹 성공 경로가 연결되었다”는 좋은 증거지만, 저장 결과 전체를 단독으로 보증하지 않는다.

숨겨진 기술 이름TransferControllerHappyPathTest, MockMvc, post, httpBasic, HTTP happy-path integration test

24`TransferFailurePointIT`가 아니면 알기 어려운 것은 무엇인가요?

runtimeExceptionAfterBusinessMutationRollsBackEveryTransferEffect는 시험용 훅이 실제 변경 뒤 RuntimeException("injected after business mutation")을 던지게 한다. AssertJ로 예외 종류와 메시지를 확인하고, 훅 도달 표시가 참인지 본다. 이어 새 SQL 조회로 10,000, 5,000, 거래 0, 장부 0을 한 번에 대조한다.

니지카가 범위를 표시했다. 이 테스트는 ‘일부 변경 뒤 처리되지 않은 RuntimeException’에 대한 전체 롤백을 강하게 증명한다. 정상 성공은 증명하지 않고, checked exception의 기본 규칙이나 HTTP 오류 코드도 확인하지 않는다. 훅은 이 테스트 설정에서만 예외 구현으로 바뀐다.

숨겨진 기술 이름TransferFailurePointIT, failure injection, assertThatThrownBy, @Import, @TestConfiguration, rollback integration test

25`TransferIntegrationTest`의 두 시험은 왜 한 쌍인가요?

첫 메서드 transferMovesTwoBalancesAndWritesBalancedLedgerPair는 서비스 결과가 9,000과 6,000인지, 업무 거래 1개, 장부 2개, signed 합계 0인지 확인한다. 여기서는 장부 개수까지 세므로 TransferBalanceIT의 합계 확인을 보강한다. 다만 두 잔액은 Result 값으로 보며 별도 SQL 재조회는 하지 않는다.

둘째 sameAccountIsRejectedBeforeAnyTransferWrite는 같은 계좌를 양쪽에 넣어 IllegalArgumentException("same account")이 나는지, TRANSFER 업무 거래가 0개인지 확인한다. 서비스 코드상 검증은 잔액 변경보다 먼저 실행된다. 다만 이 테스트가 DB에서 직접 세는 것은 business_tx뿐이므로, 장부 0개와 두 잔액 불변을 각각 조회해 증명한 테스트라고 넓혀 말하면 안 된다.

키타가 덧붙였다. “그래도 소유권 거절, 잔액 부족, HTTP 인증, 중간 고장 롤백은 이 두 메서드가 직접 보진 않아요.”

숨겨진 기술 이름TransferIntegrationTest, arrange-act-assert, success invariant, precondition and no-effect, two @Test methods

26일곱 테스트를 겹쳐 보면 어디까지 확인되나요?

히토리는 지금까지 본 @Test를 다시 세었다. 입력표 검사 1개, 잔액·장부 정상 이동 1개, 프록시 경계 1개, 웹 성공 응답 1개, 중간 고장 롤백 1개, 마지막 통합 테스트 2개. 합계는 정확히 7개다. 설정을 만드는 @TestConfiguration이나 실행 전 정리를 하는 @BeforeEach는 테스트 메서드 수에 넣지 않는다.

니지카가 순서를 연결했다. 첫 시험은 요청 네 칸의 개별 규칙을 본다. 둘째는 새 DB 조회로 9,000과 6,000, 거래 1개와 signed 합계 0을 본다. 셋째는 서비스가 프록시이고 공개 메서드가 트랜잭션 경계임을 본다. 넷째는 인증된 HTTP 성공 요청이 201과 COMPLETED를 받는지 본다. 다섯째는 실제 변경 뒤 RuntimeException이 나면 10,000과 5,000, 거래 0개, 장부 0개로 돌아오는지 본다. 마지막 둘은 정상 이체의 거래 1개·장부 2개·합계 0과, 같은 계좌 요청의 예외·거래 0개를 각각 본다.

료가 그림 바깥을 가리켰다. “겹친 범위도 무한하지 않다.” 예를 들어 대량 동시 요청, 반대 방향 교착 실험, checked exception의 별도 롤백 설정, 모든 JSON 필드의 정확한 응답 모양까지 이 일곱 메서드가 전부 증명하는 것은 아니다. 키타는 마지막에 적었다. “통과 개수보다 각 시험의 직접 관찰값을 말할 수 있어야 한다.”

숨겨진 기술 이름6 JUnit test classes, 7 @Test methods, guarantee boundary, test-scope composition, out-of-scope claim

일곱 테스트의 정확한 이름과 직접 확인하는 핵심
그림 한눈에: 여섯 테스트 클래스의 일곱 시험이 직접 확인하는 범위와 남겨 둔 범위를 연결해 본다.