67장. 실전 4 — 서비스 하나를 다른 언어로 넘기기
앞의 세 실전은 하나의 Kotlin 모놀리스 이야기였다.
이번은 출발점이 다르다.
구 시스템 자체 프레임워크 + 자체 쿼리 빌더 (37장의 상황)
새 시스템 Kotlin + Spring Boot + JPA
대상 포인트 도메인
기간 10주
66장에서 12주에 걸쳐 포인트의 경계를 정리했다.
그 정리된 도메인을 이번에 넘긴다.
⚠️ 순서가 이렇다는 것이 중요하다.
경계 정리 12주 + 이관 10주.
이관만 따로 하려 했으면 22주가 아니라 더 걸렸을 것이다.
0주 — 이관 후보인지 확인
45장의 조건을 대조했다.
✅ 쓰기 주체 단독 point_histories 는 PointCommandService 만 (66장)
✅ 경계 넘는 조인 0 7곳 전부 제거 (66장)
✅ 나가는 의존 0 역참조 3곳 제거 (66장)
⚠️ 인증 방식 세션이 구 시스템 자체 직렬화 형식
마지막이 걸렸다.
사람이 결정한 것 ① — 이관 전에 세션을 JWT로 먼저 바꾼다.
⚠️ 이 결정이 2주를 추가했다.
46장에서 말한 그대로다.
인증 방식을 먼저 확인해야 하는 이유가 여기 있다.
1~2주 — 계약 테스트
45장의 첫 단계다.
포인트 도메인의 공개 API 를 전부 찾아줘.
각각의 요청·응답 형식을 실제 트래픽 로그에서 확인해줘.
문서가 아니라 실제 응답 기준으로.
⚠️ 마지막 문장이 필요했다.
내부 문서에 적힌 응답 형식과
실제 응답이 세 곳에서 달랐다.
# tests/contract/point.yaml — 34건
- name: 잔액 조회
- name: 잔액 조회 - 없는 사용자
- name: 사용 - 정상
- name: 사용 - 잔액 부족
- name: 사용 - 만료 예정분 우선 차감
...
구 시스템에 돌려 전부 통과시켰다.
🔥 이 파일이 이관 전체의 기준이 된다.
36장의 특성화 테스트가
언어를 건너뛸 수 있는 형태로 바뀐 것이다.
3주 — 명세 추출
45장의 두 단계 중 첫 번째다.
포인트 도메인 코드를 읽고 동작 명세를 추출해줘.
구현 방식은 적지 말고 무엇을 하는지만.
분기 조건은 경계값까지 전부 적어줘.
명세를 뽑는 과정에서 세 가지가 드러났다.
⚠️ 발견
1. 만료 처리 분기 4개 중 status=3 경로가
어떤 조건에서 도달하는지 코드상 불명확
2. 금액 반올림이 두 곳에서 다름 (floor / round)
3. 잔액 0인 사용자 조회 시 404 와 200 이 경로에 따라 갈림
사람이 결정한 것 ② — 셋 다 그대로 이관한다.
45장의 원칙이다.
이관 중에는 버그도 이관한다.
3번은 실제 버그로 보였지만
클라이언트가 그 동작에 의존하고 있을 수 있었다.
이관 후 티켓으로 넘겼다.
4주 — 매핑표
37장에서 만든 쿼리 빌더 레퍼런스가
여기서 두 번째로 쓰였다.
## 쿼리 빌더 → JPA 매핑
| 구 쿼리 빌더 | JPA | 주의 |
|---|---|---|
| `->limit(10)` | `PageRequest.of(0, 10)` | 구 빌더는 페이지당 개수 |
| `->first()` | `findFirst()` | 없으면 null |
| `->forUpdate()` | `@Lock(PESSIMISTIC_WRITE)` | |
| `->join(...)` | ⚠️ 사용 금지 | 66장에서 전부 제거됨 |
⚠️ 첫 줄이 실제로 사고를 막았다.
37장에서 경고한 그 함정이다.
이름은 같은데 의미가 다른 것.
5~7주 — 구현
@docs/point-spec.md 의 명세대로 구현해줘.
- 구 코드는 보지 마. 명세만 보고 구현해
- @order/ 의 기준 구현 구조를 따라
- JPA 엔티티와 Repository 를 쓴다
- 쿼리는 @docs/query-mapping.md 를 참고
🔥 “구 코드는 보지 마” 가 결과를 갈랐다.
시험 삼아 구 코드를 보여주고 한 번 시켜봤는데,HashMap<String, Any> 를 반환하는 코드가 나왔다.
명세만 주니 sealed 결과 타입을 쓴 코드가 나왔다.
44장에서 말한 그 형태다.
계약 테스트 34건을 새 시스템에 돌렸다.
1차 19 / 34 통과
2차 31 / 34
3차 34 / 34
실패한 것들은 대부분 응답 필드 순서와
숫자 직렬화 형식이었다.
8주 — 섀도 트래픽
45장의 검증 단계다.
운영 요청을 복제해 새 시스템에도 보낸다.
새 시스템 응답은 버리고 구 시스템 응답만 사용자에게.
두 응답을 비교해 차이를 로그로 남긴다.
3일간 돌린 결과다.
요청 82,000건 / 차이 1,284건
숫자 포맷 "5000" vs 5000 811건 (직렬화)
null 처리 필드 누락 vs null 402건 (직렬화)
정렬 순서 동률 시 순서 다름 58건 (쿼리)
⚠️ 금액 소수점 처리 차이 13건 (로직)
앞의 셋은 형식이라 맞췄다.
마지막 13건이 진짜였다.
차이 원인: 만료 예정 포인트 계산에서
구 시스템은 일 단위 절삭, 새 시스템은 시간 단위 계산
명세 추출 단계에서 놓친 것이다.
사람이 결정한 것 ③ — 구 시스템 동작에 맞춘다.
45장의 판단표 그대로다.
새 시스템이 더 정확해 보여도 일단 맞춘다.
9주 — 라우팅 전환
46장의 단계다.
1일차 읽기 API, 내부 직원만
3일차 읽기 API, userId 해시 기준 5%
5일차 읽기 API, 30%
7일차 읽기 API, 100%
⚠️ 3일차에 문제가 하나 났다.
같은 사용자가 두 시스템을 오갔다.
원인: 게이트웨이 규칙이 요청마다 랜덤이었음
증상: 잔액 캐시가 다르게 보임
46장에서 경고한 고정 배정 문제다.
그날 규칙을 해시 기준으로 바꿨다.
쓰기 전환은 다음 주로 미뤘다.
사람이 결정한 것 ④ — 읽기 전환을 1주 더 관찰한 뒤 쓰기로.
10주 — 쓰기 전환
진짜 관문이다.
## 전환 순서
1. point_histories 쓰기 주체를 새 시스템으로 (테이블 단위)
2. 구 시스템의 해당 쓰기 경로를 차단 (코드는 남김)
3. 24시간 관찰
4. point_balances 동일 절차
46장의 “한 테이블의 쓰기는 한 시스템만” 규칙이다.
전환 직후 30분간 사람이 지켜봤다.
확인 항목
- 적립·차감 건수가 평소와 같은가
- 잔액 합계가 어긋나지 않는가
- 구 시스템 로그에 쓰기 시도가 없는가
세 번째에서 하나 걸렸다.
⚠️ 배치가 아직 구 시스템 경로로 쓰고 있음
PointExpireJob (66장에서 legacy 에 있다고 확인했던 그것)
66장 1주차 조사에서 발견해 기록해뒀던 항목이다.
이관 범위에서 제외했다가 여기서 걸렸다.
사람이 결정한 것 ⑤ — 배치를 즉시 중단하고
다음 주기 전까지 새 시스템 쪽에 구현.
🔥 34장에서 “진입점은 Controller만이 아니다” 라고 한 이유다.
그 뒤
11주~ 공존 기간 (구 코드 유지, 트래픽 0)
13주 라우트 제거
15주 구 코드 제거
46장의 3단계 삭제 절차다.
라우트만 제거하고 2주 관찰하는 동안
호출한 곳은 없었다.
10주를 돌아보면
계약 테스트 2주 ← 가장 값진 투자
명세 추출 1주
매핑표 1주
구현 3주 ← Agent 비중 가장 높음
섀도 트래픽 검증 1주
라우팅 전환 2주
⚠️ 구현이 3주로 가장 짧지 않다는 점이 이 장의 요지다.
45장에서 말한 그대로다.
번역은 싸졌고
같음을 증명하는 일이 비싸졌다.
검증과 전환에 6주,
번역에 3주였다.
사람이 결정한 것은 다섯 곳이었고
전부 되돌리기 어렵거나 도메인 판단이었다.
이 경험에서 하네스에 반영한 것
62장의 개선 루프다.
## 이관 체크리스트 (다음 도메인용)
- [ ] 인증 방식이 양쪽에서 통하는가 ← 1차 때 2주 손해
- [ ] 배치·스케줄러가 이 도메인 테이블을 쓰는가 ← 10주차 사고
- [ ] 게이트웨이 배정이 고정인가 ← 9주차 사고
- [ ] 시간·날짜 계산 단위를 명세에 명시했는가 ← 8주차 13건
네 항목 모두 이번에 겪은 것이다.
다음 도메인은 이 목록을 가지고 시작한다.
🔥 이것이 이 책 전체의 방식이다.
사고가 날 때마다 하네스가 두꺼워지고,
다음 작업이 조금 더 안전해진다.
이 장의 핵심
- 경계 정리 12주가 선행됐기에 이관이 10주로 끝났다
- 인증 방식을 먼저 확인하지 않아 2주를 손해 봤다
- 계약 테스트를 실제 응답 기준으로 만들어야 한다 — 문서와 달랐다
- 명세 추출 단계에서 이관 전에 물어볼 것 세 가지가 드러났다
- 발견한 버그를 그대로 이관했다 — 클라이언트가 의존하고 있을 수 있다
- “구 코드는 보지 마” 가 결과물의 언어 품질을 갈랐다
- 섀도 트래픽 차이 1,284건 중 진짜는 13건이었다
- 그 13건은 명세 추출에서 놓친 시간 단위 문제였다
- 게이트웨이 배정이 랜덤이면 같은 사용자가 두 시스템을 오간다
- 쓰기 전환에서 배치 호출이 걸렸다 — 진입점은 HTTP만이 아니다
- 번역 3주, 검증과 전환 6주였다
- 겪은 사고 네 개가 다음 도메인의 체크리스트가 됐다