PeekCart 는 주문, 결제, 상품, 알림, 회원을 서비스로 나눈 이커머스 프로젝트입니다. 모놀리스에서 시작해 단계(Phase)별로 서비스를 떼어 냈고 Phase 4 에서 서비스 분리까지 마쳤습니다. 그동안 설계 문서와 작업 목록에는 지난 단계의 내용이 그대로 쌓였고 다음 작업으로 넘어가기 전에 프로젝트를 한 번 정비할 시간이 필요했습니다. AI 를 사용해 개발하는 동안 불필요하거나 근거를 따라가기 어려운 코드와 문서가 쌓였다고 느낀 부분이 있었습니다. 그래서 main 의 Java 코드 약 2만 줄과 테스트, 설계 문서, 작업 목록, 스크립트, CI 설정을 실제 코드와 대조하며 정리할 대상을 골랐습니다. 이번 정리에서는 네 서비스에 복제돼 있던 메시징 실행 코드를 공유 모듈 하나로 합치고, 현재 문서에 남아 있던 과거 설계를 아카이브로 옮겼습니다. 정리는 파일을 지우고 옮기고 합치는 일입니다. 어떤 결과가 나와야 정리가 끝났다고 말할 수 있을까요? 이 글은 다음 질문을 따라갑니다.

줄이고 합친 뒤에도 필요한 책임과 정보가 그대로 남았다는 것을 어떻게 확인할까?

정리한 뒤에도 필요한 책임과 정보가 남았는지는 검사를 만들어 확인했습니다. 그런데 통과한 검사 가운데 일부는 생각보다 적은 것을 비교하고 있었습니다. 문서를 옮길 때 만든 검사부터 보겠습니다.

과거 단계의 설계를 그린 ERD 를 현재 설계 문서 두 개(아키텍처 문서와 데이터 설계 문서)에서 떼어 내 아카이브 파일로 옮겼습니다. 옮긴 뒤에는 원래 문서에 있던 줄이 하나도 빠지지 않았는지 확인하는 검사를 만들었습니다. 옮기기 전 문서의 줄을 하나씩 보면서 옮긴 뒤의 문서 어딘가에 같은 줄이 있는지 확인하는 방식입니다. 이 글에서는 이것을 “줄 집합 검사”라고 부르겠습니다. 그리고 이 검사가 실제로 누락을 검출하는지 보려고 일부러 내용을 망가뜨려 보았습니다.

그때 지운 줄 가운데 하나가 아카이브의 users 테이블 기본 키 선언인 bigint id PK 였습니다. 사용자 테이블의 id 가 사라졌으니 검사는 실패해야 합니다. 아래에서 직접 지워 보세요. 비교할 수 있도록 문서에 한 번만 나오는 절 제목을 지우는 경우도 함께 넣었습니다.

한 줄을 지우면 보존 검사는 실패할까요?

옮긴 아카이브 문서의 ERD 일부입니다. 줄 집합 검사는 옮기기 전의 각 줄이 옮긴 뒤 문서 어딘가에 있는지만 보고, 출현 횟수 검사는 각 줄이 몇 번 나왔는지까지 셉니다. 처음 만든 것은 줄 집합 검사입니다. 지울 줄을 골라 보세요.

넣을 변이
옮긴 뒤 세 문서의 bigint id PK 합계38 줄 집합 검사의 누락0 출현 횟수 검사의 부족0

실제 아카이브 문서에서 users 와 orders 두 테이블만 확대한 축소 예시입니다. 다른 테이블과 나머지 절은 생략했습니다. 실제 아카이브에서 users 다음 테이블은 orders 가 아니고, 맨 위 절 제목은 이 ERD 절이 아닌 다른 절(시스템 다이어그램)의 제목이며 기호를 빼고 적었습니다. 38은 옮긴 뒤 데이터 설계 문서 23개와 아카이브 15개를 합친 값입니다. 38과 37, 누락과 부족 수치, 각 검사의 통과와 실패는 정리 작업에 쓴 검사 코드를 실제 문서에 다시 적용한 로컬 재현 결과입니다. 원래 작업에서는 기본 키 줄 삭제를 다른 변이와 함께 넣었고, 절 제목 삭제를 따로 넣어 본 것은 이 재현입니다. DB 스키마의 적용이나 ERD 렌더링은 검증하지 않습니다.

기본 키 줄을 지웠는데 줄 집합 검사는 통과했습니다. 절 제목처럼 한 번만 나오는 줄을 지우면 같은 검사가 실패합니다. 이 절 제목 비교는 정리가 끝난 뒤 같은 검사 코드로 다시 재현한 결과입니다.

줄 집합 검사는 옮기기 전 문서의 각 줄이 옮긴 뒤의 문서 어딘가에 있는지만 봅니다. 코드로는 옮긴 뒤의 줄을 집합으로 만들고 원래 줄이 그 집합에 속하는지 확인합니다. 아래 코드에서 R 은 파일을 줄 목록으로 읽는 함수이고, rewritten 은 의도적으로 다시 쓴 줄 번호의 예외 목록입니다.

nowset = set(R(now))                                     # 옮긴 뒤 현재 문서의 줄
arch = set(R('docs/progress/design-archive-phase1-2.md')) # 옮긴 뒤 아카이브의 줄
miss = [(i, l[:70]) for i, l in enumerate(before, 1)
        if l.strip() and l not in nowset and l not in arch and i not in rewritten[k]]

bigint id PK 는 여러 테이블에 똑같은 들여쓰기로 반복되는 줄입니다. 옮기기 전 두 문서에 38번 나왔고, 옮긴 뒤에는 데이터 설계 문서에 23번, 아카이브에 15번 남아 합계가 같았습니다. users 의 줄을 지워도 orders 나 다른 테이블에 같은 문자열이 남아 있으니 집합에는 여전히 그 줄이 들어 있습니다. 집합으로 바꾸는 순간 그 줄이 몇 번 나왔는지는 사라집니다.

그래서 줄마다 몇 번 나왔는지를 옮기기 전과 옮긴 뒤로 나눠 세고 그 횟수를 비교하도록 바꿨습니다. 옮기기 전에 38번 나온 줄이 옮긴 뒤에 37번만 나오면 하나가 부족하다고 보고합니다. 이 글에서는 이것을 “출현 횟수 검사”라고 부르겠습니다.

before = Counter(R(S + '/02.before.md')) + Counter(R(S + '/05.before.md'))
after = Counter(R('docs/02-architecture.md')) + Counter(R('docs/05-data-design.md')) + Counter(R('docs/progress/design-archive-phase1-2.md'))
short = {l: n - after[l] for l, n in before.items() if n > after[l]}

원래 작업에서 같은 줄을 다시 지우자 이번에는 1x bigint id PK 가 부족하다고 보고하며 실패했습니다. 지운 줄을 되돌리면 다시 통과했습니다.

이 글의 중심 사례는 메시징 실행 코드를 공유 모듈로 합친 작업입니다. 다음 절부터 그 이야기를 이어 갑니다. 문서를 옮긴 작업은 결과를 확인하는 뒷부분에서 다시 다룹니다. 위 장면처럼 정리 전후에 무엇이 같아야 하는지를 정하는 문제이기 때문입니다.

글의 수치는 출처를 나눠 적었습니다. “원래 작업”은 정리하면서 실행한 검사입니다. “다시 재현”은 정리가 끝난 뒤 macOS arm64 로컬에서 같은 입력으로 다시 돌린 실험이고 “CI”는 GitHub Actions 의 실행 기록입니다. 파일 수, 바이트 수, 해시처럼 커밋에서 그대로 다시 계산한 값에는 따로 표지를 붙이지 않았습니다.

정리 대상마다 남아야 할 것을 먼저 적었습니다

같은 코드가 여러 곳에 복제돼 있다는 사실, 오래된 문서라는 사실, 호출하는 곳이 없다는 사실은 조사를 시작할 이유가 됩니다. 그것만으로 지울지 남길지를 정할 수는 없었습니다. 같은 코드라도 서비스마다 다르게 바뀌어야 할 수 있습니다. 오래된 문서라도 다른 문서가 그 안의 근거를 참조할 수 있고 호출되지 않는 검사라도 그 검사가 지키려던 규칙은 여전히 필요할 수 있습니다.

PeekCart 저장소에는 코드 외에도 여러 문서가 함께 있습니다. 설계 문서, 작업마다 ID 를 붙여 한 행씩 관리하는 작업 목록(마크다운 표), 작업별 계획서와 검토 기록, 설계 결정 기록(ADR)입니다. 작업 목록의 행은 작업이 끝나면 완료 표시가 붙은 채 남습니다.

대상마다 정리한 뒤에도 남아 있어야 하는 것을 먼저 적고 그것을 지키는 방법을 골랐습니다.

대상정리 뒤에도 남아야 하는 것고른 방법
네 서비스에 복제된 메시징 실행 코드같은 실행 규칙, 각 서비스가 그 코드를 쓰는 경로전용 공유 모듈로 합침
서비스별 실패 메시지 구독과 원장 소유자 설정어느 서비스가 어떤 실패를 기록하는지서비스 코드로 남김
서비스별 Flyway 마이그레이션각 서비스가 자기 스키마를 적용하는 책임서비스에 남김
서비스별 배선 통합 테스트서비스마다 공유 기능이 올바로 연결됐는지서비스에 남김
현재 설계 문서 안의 과거 단계 설계과거 구조와 결정의 근거, 그곳으로 찾아갈 경로아카이브로 옮기고 원래 자리에 안내 한 줄
작업 목록에 쌓인 완료 행다른 문서가 작업 ID 로 참조하는 세부 근거완료 아카이브로 원문 그대로 옮김
별도 폴더에 남은 검토 기록 두 개완료 계획서에 없던 기록해당 완료 계획서에 병합
현재 문서와 내용이 겹치는 Notion 내보내기 원본분리된 내용과 원본을 되살릴 이력이관을 확인한 뒤 작업 트리에서 삭제
어디서도 호출하지 않던 검사 스크립트 두 개스케줄러 락 규약과 빈 응답 판정 규칙의 검사지우지 않고 CI 에 연결
코드 주석에 남은 내부 작업 식별자주석이 설명하려던 근거지우거나 근거를 문장으로 풂, 적용된 마이그레이션은 제외

같은 “오래된 문서”라도 고른 방법이 달랐습니다. Notion 내보내기 원본은 이미 역할별 문서 일곱 개로 나뉘어 있었습니다. 작업 트리에 남아 있으면 같은 내용이 두 곳에 있어 어느 쪽이 정본인지 헷갈릴 수 있었습니다. 원본은 Git 이력이 보존하므로 아카이브로 옮기지 않고 지웠습니다. 반면 작업 목록의 완료 행은 ADR 들이 작업 ID 로 세부 근거를 가리키고 있어 원문을 그대로 옮겼습니다.

옮기는 곳도 나눴습니다. 완료 행은 작업이 끝날 때마다 계속 늘어나는 기록이고 과거 단계의 작업 표는 한 번 확정된 뒤 바뀌지 않는 기록입니다. 둘을 한 아카이브에 넣으면 파일 하나에 두 가지 의미가 섞이므로 파일을 나눴습니다.

이 표의 가운데 열은 각 정리가 끝난 뒤 무엇을 확인해야 하는지도 정합니다. 문서라면 내용과 그 내용에 도달하는 경로를, 실행 코드라면 동작과 서비스별 책임을 확인해야 합니다. 이 글의 나머지에서는 그 확인이 실제로 무엇을 비교했는지 봅니다.

네 서비스가 같은 코드를 함께 고치고 있었습니다

PeekCart 의 서비스들은 Kafka 로 이벤트를 주고받습니다. 메시지를 안정적으로 다루기 위한 실행 코드가 세 묶음 있습니다.

  • Outbox: 이벤트를 먼저 자기 DB 의 outbox 테이블에 기록하고 주기적으로 도는 poller 가 Kafka 로 발행합니다. 여러 인스턴스가 같은 행을 동시에 발행하지 않도록 ShedLock 으로 스케줄 작업에 락을 겁니다.
  • 멱등성 처리: consumer 가 이미 처리한 이벤트 ID 를 기록해 같은 메시지를 두 번 처리하지 않습니다.
  • 실패 원장: 처리에 실패해 .dlq 토픽으로 간 메시지를 DB 원장에 기록하고 다시 발행(replay)할 수 있게 합니다.

이 코드가 주문, 결제, 상품, 알림 서비스에 똑같이 복제돼 있었습니다.

처음 복제한 데는 이유가 있었습니다. 모놀리스에서 상품 서비스를 처음 떼어 낼 때, 상품 서비스가 혼자 컴파일되려면 모놀리스에만 있던 발행 코드가 필요했습니다. 이 코드를 공통 모듈로 옮기려면 모놀리스 쪽 코드까지 함께 고쳐야 했고 그만큼 분리 작업의 범위가 커졌습니다. 그래서 분리 작업에서는 발행 코드를 복제했습니다. 아직 DB 를 함께 쓰던 시기에는 각 발행기가 자기 이벤트 타입만 처리하도록 처리 대상 목록과 락 이름까지 나눴습니다. 이 구분이 언제 사라졌는지는 이 글에서 따로 추적하지 않았습니다. 합치기 직전 커밋에서는 아래에서 보듯 대부분의 파일이 네 서비스에서 바이트까지 같았습니다.

멀티 모듈 구조를 정할 때도 Outbox 와 ShedLock 설정은 발행 서비스 전속으로, 멱등성 처리는 consumer 별 복제로 분류했습니다. 데이터 처리 책임을 근거로 삼았습니다. 멱등성 기록 테이블은 consumer 마다 자기 DB 에 있어야 하고 공통 추상은 공통 모듈에 두더라도 테이블은 서비스 DB 에 둔다는 결정이었습니다.

그 뒤 사정이 바뀌었습니다. 실패한 메시지를 다시 발행하는 경로가 Outbox 를 거치게 되면서 원래 이벤트를 발행하지 않던 알림 서비스에도 다시 발행용 Outbox 와 같은 실행 세트가 생겼습니다. 복제본이 어긋나지 않도록 검사 스크립트가 일부 파일이 네 서비스에서 바이트까지 같은지 비교하기 시작했습니다.

이 검사가 붙은 파일에서는 복제로 얻으려던 자유가 사라졌습니다. 한 서비스의 사본만 고치면 검사가 실패하므로 고칠 때마다 네 서비스의 사본을 똑같이 고쳐야 했습니다. 서비스마다 따로 바뀔 수 있다는 이점은 없고 사본 네 개를 유지하는 비용만 남은 상태였습니다.

여기서는 “같은 코드”와 “함께 바뀌어야 하는 코드”를 구분합니다. 네 서비스의 코드가 우연히 같을 뿐 각자 다른 이유로 바뀔 수 있다면 복제는 여전히 합리적인 선택입니다. 이 실행 코드는 이미 함께 바뀌고 있었습니다. 처음의 복제는 분리 작업의 범위와 데이터 처리 책임을 지키는 선택이었습니다. 그 뒤 소비자와 실행 경로가 늘어나 결정의 전제가 달라졌기 때문에 경계를 다시 검토했습니다.

같다던 32개 중 31개만 같았습니다

합칠 대상으로 정한 Java 파일은 32개였습니다. Outbox 9개, 멱등성 처리 6개, 실패 원장 16개, ShedLock 설정 1개입니다. 설계 결정 기록에는 32개 모두 네 서비스의 사본이 바이트까지 같다고 적었습니다.

합치기 직전 커밋에서 네 서비스의 파일을 하나씩 SHA-256 으로 다시 계산하면 31개는 네 서비스의 사본이 모두 같습니다. 나머지 하나는 Outbox 행의 상태를 나타내는 OutboxEventStatus 입니다. 주문과 상품 서비스에는 BACKFILL 값이 있었고 결제와 알림 서비스에는 없었습니다.

// 주문, 상품 서비스
public enum OutboxEventStatus {
    PENDING,
    PUBLISHED,
    FAILED,
    BACKFILL
}

// 결제, 알림 서비스
public enum OutboxEventStatus {
    PENDING,
    PUBLISHED,
    FAILED
}

BACKFILL 은 데이터 이관 마이그레이션이 쓰는 값입니다. 이 마이그레이션은 기존 행을 Outbox 형식으로 옮깁니다. 이관 SQL 이 행을 BACKFILL 로 넣고 다음 UPDATE 문에서 payload 를 완성하며 PENDING 으로 바꿉니다. 사용처는 주문 서비스의 V5, 상품 서비스의 V4 마이그레이션이었습니다. 설계 결정 기록에는 둘 다 V5 로 잘못 적었는데 실제 파일 번호는 서로 달랐습니다.

32개 모두를 같은 파일의 이동으로 설명할 수는 없었습니다. 31개는 같은 구현이 있는 위치를 하나로 합쳤고 enum 하나는 필요한 값의 합집합으로 통일했습니다. 결제와 알림 서비스에 BACKFILL 이 생겨도 괜찮은지 따로 확인해야 했습니다. 두 서비스에는 이 값을 쓰는 이관 마이그레이션이 없습니다. 발행 poller 는 PENDING 행을, 발행이 끝난 행을 지우는 작업은 PUBLISHED 행을 조회합니다. 상태 컬럼은 VARCHAR(20) 문자열이어서 enum 에 값을 하나 추가하려고 스키마를 바꿀 필요는 없었습니다. Java 조회 코드와 컬럼 정의를 확인한 범위의 결론입니다. 이관 SQL 의 UPDATE 문은 BACKFILL 행을 골라 PENDING 으로 바꾸므로, “어떤 조회도 BACKFILL 을 대상으로 하지 않는다”는 말은 Java 쪽 조회에만 맞습니다.

바이트 비교 검사가 지키던 범위도 다시 셌습니다.

묶음파일 수기존 검사의 비교 방식
실패 원장16네 서비스 바이트 비교
Outbox (상태 enum 제외)8네 서비스 바이트 비교
Outbox 상태 enum1주문과 상품, 결제와 알림의 두 묶음으로 비교
멱등성 처리6비교하지 않음
ShedLock 설정1비교하지 않음

합칠 대상은 32개, 네 서비스에서 실제로 같았던 파일은 31개, 검사가 네 서비스 사이의 동일성을 강제한 파일은 24개였습니다. “복제본이 검사로 보호되고 있었다”고 한 줄로 말하면 멱등성 처리와 락 설정까지 보호받았다고 범위를 넓혀 말하게 됩니다. 세 수치는 답하는 질문이 각각 다릅니다.

공통 모듈 대신 네 서비스 전용 모듈로 합쳤습니다

common 에 넣으면 회원 서비스까지 스캔 대상이 됩니다

PeekCart 에는 이미 common 모듈이 있었습니다. 공통 응답 형식, 이벤트 데이터 타입, Slack 알림 포트 같은 것이 들어 있습니다. 실행 코드를 여기에 넣으면 모듈을 하나 더 만들지 않아도 됩니다.

common 의 소비자가 문제였습니다. 회원 서비스도 common 에 의존하는데 회원 서비스는 메시징 실행 코드가 필요 없습니다. 회원 서비스의 진입점 클래스는 com.peekcart 패키지에 있습니다. Spring Boot 는 기본적으로 @SpringBootApplication 이 붙은 클래스의 패키지와 그 하위 패키지를 컴포넌트 스캔과 엔티티 검색의 범위로 삼습니다(Spring Boot 문서). 실행 코드가 들어갈 com.peekcart.global 도 그 범위 안입니다.

공유할 코드에는 @Entity 인 OutboxEvent, @Component 인 OutboxPollingScheduler, @Configuration 인 DeadLetterKafkaConfig 와 ShedLockConfig 가 있었습니다. 이것들을 common 에 넣으면 회원 서비스에도 스캔 대상이 늘어납니다. 회원 서비스는 Kafka 자동 구성을 제외하고 있었지만 그 설정은 Spring Boot 의 자동 구성을 끌 뿐 직접 작성한 @Component 나 @Configuration 을 제외하지 않습니다.

여기서 말할 수 있는 범위를 나눠야 했습니다. 클래스가 classpath 와 스캔 범위에 들어간다는 것과 애플리케이션이 실제로 그 빈을 만들고 스케줄러를 실행한다는 것은 다른 사실입니다. 실제 빈 생성에는 필요한 의존 빈, 프로퍼티, 활성화 조건이 함께 관여합니다. 이 코드를 common 에 넣은 회원 서비스를 실제로 띄워 보지는 않았습니다. common 에 합치면 필요 없는 소비자에게도 실행 구성 요소가 노출되고 그것을 막으려면 스캔 대상이 되는 엔티티와 빈 클래스마다 조건부 활성화나 스캔 제외를 따로 관리해야 한다는 데까지 확인했습니다.

검토한 대안과 비용

대안얻는 것비용
복제를 유지하고 바이트 비교 검사를 계속 사용서비스 사이 컴파일 의존이 생기지 않음검사가 적용되는 파일은 따로 바꿀 수 없어서, 사본 네 개를 고치는 비용만 반복됨
기존 common 에 편입새 모듈이 필요 없음회원 서비스까지 실행 구성 요소의 스캔 대상이 되고, 제외 조건을 따로 관리해야 함
consumer 까지 공유하고 구독 토픽을 설정으로 주입서비스 쪽 코드가 더 줄어듦구독 책임을 소스만 보고 확인할 수 없음 (아래 “서비스에 남긴 것” 참고)
네 서비스만 의존하는 전용 모듈필요한 소비자에게만 실행 코드를 공유공유 모듈을 바꾸면 네 서비스가 함께 영향을 받음 (고른 대안)

네 서비스만 의존하는 모듈을 만들었습니다

실행 코드를 peekcart-common-messaging 이라는 일반 JAR 모듈로 옮겼습니다. 실행 가능한 bootJar 를 만들지 않는 라이브러리 모듈입니다. 주문, 결제, 상품, 알림 서비스가 이 모듈에 의존하고 회원 서비스는 의존하지 않습니다. 패키지와 클래스 이름을 그대로 두어서 기존 서비스 코드의 import 는 한 줄도 바뀌지 않았습니다.

dependencies {
    // 공개 시그니처에 노출되는 타입: SlackPort/LedgerOwner/DlqTopology/retention properties(:common), CommitAwareMetrics(observability)
    api project(':common')
    api project(':peekcart-common-observability')

    // ShedLockConfig 의 LockProvider 빈 + 서비스 자체 @SchedulerLock
    api 'net.javacrumbs.shedlock:shedlock-spring:6.3.1'
    api 'net.javacrumbs.shedlock:shedlock-provider-jdbc-template:6.3.1'

    // DeadLetterEndpoint 의 replay 권한 검사(SecurityContextHolder/AccessDeniedException)
    implementation 'org.springframework.security:spring-security-core'
}

Gradle 의 java-library 플러그인에서 api 로 선언한 의존성은 이 모듈을 쓰는 쪽의 컴파일 classpath 에도 들어가고 implementation 은 들어가지 않습니다(Gradle 문서). 공유 모듈의 공개 생성자와 메서드가 common 의 LedgerOwner, observability 모듈의 CommitAwareMetrics 같은 타입을 받으므로 소비자도 그 타입을 볼 수 있어야 합니다. 서비스가 직접 @SchedulerLock 을 붙이므로 ShedLock 도 api 입니다. 권한 검사에만 쓰는 Spring Security 는 implementation 으로 두었습니다. 모든 의존성을 공개한 것은 아닙니다.

구독, 원장 소유자, 마이그레이션은 서비스에 남겼습니다

실행 규칙은 합쳤지만 어느 서비스가 어떤 실패를 처리하는지는 서비스마다 달라서 서비스 코드에 남겼습니다.

@Component
@RequiredArgsConstructor
public class DeadLetterConsumer {

    private static final PeekcartService SELF = PeekcartService.PAYMENT;

    private final DeadLetterRecorder recorder;

    @KafkaListener(
            id = DeadLetterContainerGuard.LISTENER_ID_PREFIX + "payment-consumption",
            topics = {
                    "order.created.dlq",
                    "order.cancelled.dlq",
                    "stock.reservation.result.dlq",
                    "stock.compensation.requested.dlq",
                    "order.compensation.requested.dlq"
            },
            groupId = DlqTopology.PAYMENT_DLQ_GROUP,
            containerFactory = "deadLetterKafkaListenerContainerFactory")
    public void consume(ConsumerRecord<String, String> record) {
        DlqOrigin origin = DlqHeaders.parse(record);

        if (!DlqTopology.ownsConsumption(SELF, record.topic(), origin.failedConsumerGroup())) {
            return;
        }

        recorder.record(origin);
    }
}

.dlq 토픽 하나에는 여러 서비스의 실패가 함께 쌓입니다. 결제 서비스의 consumer 는 자기 consumer group 이 실패한 메시지만 골라 공유 모듈의 DeadLetterRecorder 에 넘깁니다. 원장의 주인을 정하는 LedgerOwnerConfig 도 서비스마다 new LedgerOwner(PeekcartService.PAYMENT) 처럼 다른 값을 주입합니다. 공유 모듈의 지표와 기록 기능은 이 값을 받아 그 서비스의 실패를 대상으로 동작합니다. 헤더를 읽을 수 없으면 어느 group 의 실패인지 알 수 없습니다. 이런 메시지는 원래 토픽을 발행한 서비스의 quarantine consumer 가 맡습니다. 알림 서비스는 원래 이벤트를 발행하는 서비스가 아니어서 이 consumer 가 없습니다. 알림 서비스의 Outbox 는 실패한 메시지를 다시 발행하는 데만 씁니다.

consumer 까지 합치고 토픽 목록을 설정으로 주입하면 서비스 쪽 파일은 더 줄어듭니다. 그 대신 두 가지를 잃습니다. 각 서비스의 구독 책임을 소스 코드만 보고 확인할 수 없게 되고 소스의 토픽 리터럴을 읽어 구독 계약을 검사하던 기존 lint 가 그대로 동작하지 않습니다. 이 부분은 서비스마다 구체적인 코드로 남겨 두는 편을 택했습니다.

데이터도 서비스에 남았습니다. 네 서비스가 같은 OutboxEvent 엔티티 클래스와 같은 테이블 이름을 쓰지만 각 서비스는 자기 데이터소스에 연결하고 자기 src/main/resources/db/migration 의 Flyway 마이그레이션을 적용합니다. 이번 정리에서 마이그레이션 파일은 하나도 옮기지 않았고 변경 diff 도 0줄이었습니다. Java 엔티티와 저장소 구현까지만 공유했습니다. 테이블의 데이터와 스키마 변경 이력은 여전히 각 서비스가 소유합니다.

아래 장면에서 합치기 전과 후를 바꿔 보세요. 옮겨지는 것과 서비스에 그대로 남는 것을 함께 볼 수 있습니다.

실행 코드는 한 곳에, 소유자는 서비스마다

서비스 카드 안의 회색 칩은 네 서비스에 복제된 실행 코드이고, 서비스 색 칩은 서비스마다 다른 구성입니다. 합치기 전과 후를 바꿔 보세요.

상태
서비스 안의 실행 코드 파일128 공유 모듈의 실행 코드 파일0 삭제한 사본0

소스 파일의 위치와 소유를 그린 도식입니다. 칩 하나는 파일 묶음이며, 숫자는 합칠 대상 32개 파일의 구성(Outbox 8, 상태 enum 1, 멱등성 처리 6, 실패 원장 16, ShedLock 설정 1)입니다. 상태 enum 은 주문과 상품 서비스 판에 BACKFILL 값이 있어 값이 4개, 결제와 알림 판은 3개였습니다. 서비스별 구성 칩은 DLQ consumer, 원장 소유자 설정, 식별할 수 없는 실패를 맡는 quarantine consumer, 자기 DB 와 Flyway 마이그레이션이고, 알림 서비스에는 quarantine consumer 가 없습니다. 내용이 같은 사본 가운데 어느 서비스 것을 옮겼는지는 결과에 영향이 없어서, 장면에서는 주문 서비스 칩을 옮겼습니다. 실행 중인 JVM 의 메모리나 DB 가 하나로 합쳐진다는 뜻이 아니며, 배포나 성능 측정값이 아닙니다. common 모듈과 observability 모듈, gateway 는 그리지 않았습니다.

파일을 옮기면 그 경로를 읽는 도구도 고쳐야 합니다

CI, Dockerfile, 검사 스크립트의 경로를 바꿨습니다

모듈을 만들었다고 그 모듈의 테스트가 CI 에서 실행되지는 않습니다. 파일 위치를 바꿀 때는 그 위치를 읽는 도구를 함께 찾아야 했습니다.

도구바꾼 것
복제본 바이트 비교 검사의 Java 부분비교할 사본이 없어져 제거
복제본 바이트 비교 검사의 SQL 부분마이그레이션은 서비스마다 남아 있어 유지
빌드의 중복 클래스 검사공유 모듈 목록에 새 모듈 추가
CI 테스트 매트릭스모듈을 나눠 병렬로 도는 테스트 잡(shard) 중 하나에 새 모듈 추가
통합 테스트 규약 검사 (테스트가 공용 테스트 컨테이너만 쓰는지 보는 lint)검사 대상 모듈 목록에 추가
Dockerfilebuild.gradle 복사와 소스 복사 두 곳에 추가

공유 모듈의 단위 테스트 두 개는 주문 서비스에서 옮겼습니다. 서비스마다 공유 기능이 제대로 배선됐는지 보는 통합 테스트는 각 서비스에 남겼습니다. 공유 로직을 고칠 때마다 네 서비스의 통합 테스트를 같이 고쳐야 하는 일이 생기면 다시 검토할 대상입니다.

Dockerfile 을 고치면서 공유 모듈의 영향 범위도 다시 셌습니다. 하나의 숫자로 말하기 어려웠습니다. 설계 결정 기록에는 공유 모듈을 바꾸면 네 소비자의 이미지를 다시 빌드한다고 적었습니다. 코드 소비자는 네 서비스가 맞습니다. 그런데 CI 의 이미지 빌드 매트릭스는 알림, 회원, 상품, 주문, 결제, gateway 여섯 개입니다. 변경된 경로를 보고 네 개만 고르는 구성도 아닙니다. Dockerfile 도 모든 모듈의 build.gradle 과 소스를 복사한 뒤 고른 서비스의 bootJar 를 만듭니다. 코드 소비자 수, CI 빌드 잡 수, Docker 레이어 캐시가 무효화되는 범위는 각각 따로 확인할 대상입니다. 이 차이로 빌드 시간이 얼마나 달라지는지는 측정하지 않았습니다.

문서를 옮기자 완료 개수가 60에서 44로 줄었습니다

작업 목록이나 변경 이력처럼 사람이 쓰는 문서를 스크립트도 함께 읽는 저장소라면 문서를 옮길 때 그 스크립트가 무엇을 세는지가 바뀝니다. PeekCart 에서는 완료된 행이 쌓여 작업 목록이 커졌기 때문에 문서를 옮겼습니다.

시점작업 목록 파일 크기
옮기기 전153,426바이트 (그중 열린 작업 세 행은 1,380바이트)
완료 행을 아카이브로 옮긴 직후4,091바이트
이 분리 작업까지 완료돼 아카이브로 옮겨진 뒤3,579바이트

목록 전체의 크기와 열린 행만의 크기는 서로 다른 수치입니다.

개수를 세는 도구에서는 세는 대상이 바뀌면 사라진 것이 없어도 숫자가 줄 수 있습니다. 작업 목록과 완료 아카이브를 읽어 진행 상황을 요약하는 셸 도구는 완료 개수를 60 대신 44로 보고했습니다. 16개가 사라진 것처럼 보이지만 이전의 60에는 완료 작업 44행 외에 과거 단계 작업 표의 상태 행 16개가 함께 세어져 있었습니다. 과거 단계 표를 별도 아카이브로 옮기면서 집계 대상이 완료 작업 목록으로 좁아졌습니다. 이 수치만으로 작업 16개가 유실됐다고 볼 수는 없습니다.

상대 링크도 문서의 새 위치를 기준으로 다시 해석됩니다. 완료 행에 있던 ./adr/... 링크는 한 단계 아래 폴더로 옮겨지면서 ../adr/... 로 바꿔야 했습니다. 이것도 원래 작업에서 옮기기 전에 코드를 검토하며 찾았고 분리 스크립트가 옮기면서 함께 바꾸도록 했습니다.

번호만 찾던 완료 판정이 “약 170줄”에 일치했습니다

ID 나 번호를 문자열로 찾는 스크립트는 그 숫자가 본문의 다른 곳에 나와도 일치합니다. 문서를 옮기면 찾아야 할 파일까지 바뀝니다. PeekCart 는 작업 하나를 구현, PR 생성, 완료 처리까지 셸 스크립트로 진행합니다. 중간에 끊긴 작업을 이어 갈 때 어느 단계부터 다시 시작할지 정하는 함수가 이 경우였습니다. 이 함수는 PR 이 이미 완료 처리됐는지 작업 목록에서 찾는데 문서 이동에 더 직접적으로 영향을 받았습니다.

# 바꾸기 전
if ! grep -q "$(basename "$pr")" docs/TASKS.md 2>/dev/null; then
# 바꾼 뒤
if ! grep -qsF "/pull/$(basename "$pr"))" docs/TASKS.md docs/progress/TASKS-done.md; then

바꾸기 전에는 PR 번호 숫자만 작업 목록에서 찾았습니다. 완료 행이 다른 파일로 옮겨지면 완료된 PR 을 찾지 못합니다. 숫자만 찾는 방식에는 다른 문제도 있었습니다. 작업 목록 본문의 “약 170줄” 이라는 문구 때문에 번호가 170 인 PR 은 아직 완료되지 않았는데도 완료로 일치했습니다. 원래 작업에서 수정 전에 코드를 검토하다 발견했고 같은 입력으로 다시 재현해서도 확인했습니다. 바꾼 식은 실제 PR 링크의 끝부분인 /pull/170) 을 고정 문자열로 찾고 열린 목록과 완료 아카이브를 함께 읽습니다. 다시 재현한 범위는 이 매칭식뿐이고 Git 과 GitHub 상태를 확인하는 함수의 앞부분은 실행하지 않았습니다.

결과를 확인한 방법과 확인하지 않은 것

정리 결과를 확인한 방법과, 그 방법으로 확인하지 않은 것을 먼저 표로 모았습니다. 각 행은 아래 절에서 하나씩 봅니다.

결과확인한 방법이 방법으로 확인하지 않은 것
실행 코드 32개 이동, 나머지 세 서비스의 사본 96개 삭제머지 커밋의 변경 파일 목록과 합치기 직전 해시이동한 코드의 런타임 의미
회원 서비스에 메시징 모듈 없음, 결제 서비스에 있음각 서비스의 runtimeClasspath 출력 (다시 재현)회원 서비스의 기동과 빈 구성
사본이 다시 생기면 빌드 실패결제 서비스에 사본을 넣고 중복 클래스 검사 실행 (다시 재현)다른 서비스와 다른 모듈 조합
호출되지 않던 검사 두 개가 CI 에서 실행CI 로그검사기 자체가 무력화됐을 때의 실패
마이그레이션은 바꾸지 않음주석 정리 작업과 모듈 이동 작업의 SQL diff 0마이그레이션 외 SQL 의미

같은 변경도 무엇을 세느냐에 따라 숫자가 다릅니다. 정리 결과를 “파일 N개 감소”처럼 숫자 하나로 말하면 어느 단위인지가 사라지므로 따로 셌습니다.

세는 대상수
공유 모듈로 옮긴 실행 코드32개
삭제한 사본 (결제, 상품, 알림 서비스)96개
같은 변경에서 바뀐 경로167개 (이름 변경 41, 삭제 96, 수정 26, 추가 4)
이름 변경 41개의 구성실행 코드 32, 단위 테스트 2, 계획 문서 아카이브 7
줄 수393줄 추가, 10,619줄 삭제

옮긴 32개 중 상태 enum 은 BACKFILL 이 있는 판을 썼고 나머지 31개는 네 서비스의 사본이 같았습니다. Git 의 이름 변경 감지는 내용이 같은 파일 가운데 하나를 골라 짝짓기 때문에 어느 서비스의 사본이 옮겨졌는지는 diff 로 가릴 수 없습니다.

사본을 다시 넣자 빌드가 실패했습니다

합친 뒤에도 누군가 서비스에 같은 클래스를 다시 만들 수 있습니다. 빌드에는 중복 클래스 검사가 있습니다. 이 검사는 같은 앱 classpath 안에 com/peekcart/global/** 의 같은 완전한 클래스 이름(FQCN)이 두 모듈에 있으면 실패합니다. 검사 대상에 새 모듈을 추가했습니다.

다시 재현에서 공유 모듈의 OutboxEvent 를 그대로 결제 서비스 경로에 복사하고 검사를 실행했습니다.

> 게이트 i 위반 (중복 FQCN / JwtProvider 잔존):
    [payment-service] 중복 FQCN com.peekcart.global.outbox.OutboxEvent in [:payment-service, :peekcart-common-messaging]

정상 상태에서는 통과했습니다. 사본을 넣으면 실패했고 사본을 지우면 다시 통과했습니다. 이 검사는 루트 프로젝트의 check 에 연결돼 있고 CI 는 ./gradlew :build 로 실행합니다. 서비스 모듈만 ./gradlew :payment-service:build 로 빌드하면 이 검사는 실행되지 않습니다.

CI 에서 통과한 self-test 는 한 항목을 건너뛰었습니다

로컬에만 있는 자료를 읽는 검사는 CI 에서 그 항목을 건너뛰고도 성공으로 끝날 수 있습니다. 검사를 CI 에 연결했다면 통과 여부와 함께 건너뛴 항목이 있는지도 봐야 합니다.

호출되지 않던 검사 두 개는 지우지 않고 CI 의 lint 잡에 연결했습니다. 하나는 모든 스케줄 메서드에 @SchedulerLock 이 붙어 있는지 보는 lint 이고 다른 하나는 코드 리뷰 결과를 보여 주는 렌더러가 빈 응답을 오류로 처리하는지 시험하는 self-test 입니다. CI 로그에는 [SCHED-LOCK] OK 와 렌더러 self-test 의 통과 34, 실패 0, 건너뜀 1 이 남았습니다. 건너뛴 한 항목은 과거 검토 기록을 읽는 회귀 검사입니다. 그 기록은 로컬에만 있고 CI 에는 없습니다. 연결하기 전 로컬에서 돌렸을 때는 35개가 모두 통과했으므로 CI 의 self-test 가 같은 조건으로 실행됐다고 말할 수는 없습니다.

다시 재현에서는 스케줄러 메서드의 @SchedulerLock 선언 한 줄을 지우고 락 규약 검사를 실행했습니다. 검사는 규칙 위반으로 실패했고 되돌리면 통과했습니다.

주석만 바꿔도 Flyway 체크섬이 바뀝니다

Flyway 처럼 적용한 파일의 체크섬을 검증하는 도구를 쓴다면 주석 정리도 이미 적용한 마이그레이션에는 내용 변경입니다. 그래서 주석을 일괄로 정리할 때는 마이그레이션 디렉터리를 따로 다뤄야 합니다.

PeekCart 에서는 내부 작업 식별자 가운데 코드만 읽어서는 어느 문서를 가리키는지 알기 어려운 것은 지우거나 근거를 문장으로 풀었습니다. 대상은 main 코드와 Gradle 파일 174개였고 594줄을 지우고 571줄을 추가했습니다. 문자열 값이 바뀐 곳은 API 문서 설명 1건, Gradle 작업 설명 2건, 오류 메시지 1건이었습니다.

이미 적용된 Flyway 마이그레이션의 주석은 건드리지 않았습니다. Flyway 는 적용한 마이그레이션의 체크섬을 기록해 두고 파일이 바뀌면 검증에서 실패합니다. 주석만 바꿔도 체크섬이 바뀌는지는 이 프로젝트가 실제로 쓰는 Flyway 버전에서 확인해야 했습니다. 처음 분석에서는 12.4.0 의 체크섬 계산기를 근거로 들었는데 프로젝트가 실제로 해석하는 버전은 11.7.2 였습니다.

다시 재현에서 두 버전의 ChecksumCalculator 를 같은 SQL 문자열로 직접 호출했습니다. 표본은 주석 한 줄과 CREATE TABLE 한 줄로 만든 두 줄짜리 문자열이고, 프로젝트의 마이그레이션 파일은 쓰지 않았습니다. 두 버전의 결과는 모든 표본에서 같았습니다.

원본에서 바꾼 것체크섬
없음-765512047
주석 문구-244944236 (바뀜)
빈 줄 추가-765512047 (같음)
줄바꿈을 CRLF 로-765512047 (같음)
마지막 줄바꿈 제거-765512047 (같음)
공백만 있는 줄 추가-288562834 (바뀜)
SQL 문939283386 (바뀜)

이 계산기는 파일을 줄 단위로 읽고 각 줄의 UTF-8 바이트를 CRC32 에 넣습니다(Flyway 11.7.2 소스). 줄바꿈 문자는 들어가지 않고 빈 줄은 빈 바이트 배열이라 값을 바꾸지 않습니다. 주석 문구는 들어가므로 체크섬을 바꿉니다. 근거로 든 버전은 틀렸지만 마이그레이션 주석을 정리 대상에서 뺀 결론은 실제 버전에서도 유지됩니다. 이 실험은 계산 메서드만 호출한 것이고 DB 를 대상으로 Flyway 검증을 실행한 결과는 아닙니다.

지운 원본은 이력에는 있지만 검색에는 나오지 않습니다

지운 파일은 Git 이력으로 되살릴 수 있지만 작업 트리를 대상으로 하는 검색에는 더 이상 나오지 않습니다. 지우기 전에는 내용이 다른 곳으로 옮겨졌는지를, 지운 뒤에는 원본을 어디서 찾을 수 있는지를 남겨야 합니다.

Notion 내보내기 원본(68,102바이트)은 프로젝트 초기에 역할별 문서 일곱 개로 나뉘었습니다. 그 분리 커밋에서 원본의 비어 있지 않은 줄을 일곱 문서와 글자 그대로 비교하면 17줄이 일치하지 않습니다. 목차 헤딩 1줄과 Notion 페이지로 연결되는 목차 16줄입니다. 이 비교는 문자 그대로의 줄이 남았는지를 본 것이고 모든 문맥과 순서와 링크의 의미가 같다는 증명은 아닙니다.

원본은 git show f3692ed^:docs/00-lagacy.md 로 되살릴 수 있습니다. 다만 작업 트리에서 지웠기 때문에 git grep 같은 현재 검색으로는 더 이상 찾을 수 없습니다. Git 이력이 원본을 보존해도 지금 찾기는 어려우므로 현재 정본이 일곱 문서라는 안내와 이력 복원 방법을 함께 남겨 두어야 합니다.

별도 폴더에 남아 있던 검토 기록 두 개는 완료 계획서에 병합했습니다. 병합 뒤에는 원래 기록의 비어 있지 않은 줄이 병합한 파일에 모두 있는지 grep -v '^$' | grep -vxFf <병합 파일> | wc -l 로 확인했고 0줄이 나왔습니다. 그런데 이 방식도 줄의 존재만 봅니다. 다시 재현에서는 wc -l 없이 grep 을 직접 실행했습니다. 두 파일에서 반복되는 줄을 각각 하나씩 지웠더니 grep 은 여전히 미일치 줄을 출력하지 않았고 출현 횟수 비교는 파일마다 부족 1을 보고했습니다. 이때 grep 의 종료 코드 1은 “출력할 미일치 줄이 없다”는 뜻이어서 검출에 성공했다고 읽으면 안 됩니다. 이 한계는 다음 절에서 비교 단위별로 정리합니다.

통과한 검사가 놓친 변경들

출현 횟수 검사도 순서와 소속은 보지 못했습니다

처음 장면으로 돌아가 보겠습니다. 출현 횟수를 세는 검사는 반복 줄의 누락을 검출했습니다. 그러면 이 검사가 통과하면 문서가 그대로 옮겨졌다고 말할 수 있을까요? 아래에서 줄을 지우는 대신 바꾸거나, 순서를 바꾸거나, 다른 문서로 옮겨 보세요.

출현 횟수가 같으면 문서가 그대로일까요?

이번에는 users 의 기본 키 줄을 지우는 대신 바꾸거나, 순서를 바꾸거나, 다른 문서로 옮겨 보세요.

넣을 변이
옮긴 뒤 세 문서의 bigint id PK 합계38 줄 집합 검사의 누락0 출현 횟수 검사의 부족0

실제 아카이브 문서에서 users 와 orders 두 테이블만 확대한 축소 예시입니다. 다른 테이블과 나머지 절은 생략했습니다. 실제 아카이브에서 users 다음 테이블은 orders 가 아니고, 맨 위 절 제목은 이 ERD 절이 아닌 다른 절(시스템 다이어그램)의 제목이며 기호를 빼고 적었습니다. 38은 옮긴 뒤 데이터 설계 문서 23개와 아카이브 15개를 합친 값입니다. 38과 37, 누락과 부족 수치, 각 검사의 통과와 실패는 정리 작업에 쓴 검사 코드를 실제 문서에 다시 적용한 로컬 재현 결과입니다. 원래 작업에서는 기본 키 줄 삭제를 다른 변이와 함께 넣었고, 절 제목 삭제를 따로 넣어 본 것은 이 재현입니다. 교체는 같은 줄을 다른 컬럼 선언으로 바꾼 것이고, 옮기기는 그 줄을 아카이브에서 현재 아키텍처 문서 끝으로 옮긴 것입니다. DB 스키마의 적용이나 ERD 렌더링은 검증하지 않습니다.

같은 원래 검사 코드를 실제로 옮긴 뒤의 문서에 적용하고 변이를 하나씩 넣은 결과입니다(다시 재현).

넣은 변이줄 집합 검사출현 횟수 검사
없음통과통과
한 번만 나오는 절 제목 삭제실패실패
반복되는 bigint id PK 한 줄 삭제통과실패 (부족 1)
같은 줄을 다른 내용으로 교체통과실패 (부족 1)
같은 줄과 다음 줄의 순서 교환통과통과
같은 줄을 아카이브에서 현재 아키텍처 문서로 이동통과통과

교체는 처음에 집합 검사도 실패할 것으로 예상했습니다. 이전 문자열이 다른 테이블에 남아 있으니 집합 검사는 여기서도 통과했습니다. 교체로 새로 생긴 bigint changed_id PK 는 두 검사 모두 보지 않습니다. 두 검사는 옮기기 전에 있던 줄이 남았는지만 묻기 때문입니다. 출현 횟수 비교는 순서와 소속을 보지 않습니다. 기본 키 선언이 users 블록에서 빠져 다른 문서로 가도 전체 개수는 38 그대로입니다.

완료 행을 옮긴 작업의 검사는 또 다른 단위를 썼습니다. 원래 검사는 옮기기 전 줄의 집합과 작업 ID 의 집합을 비교했습니다. 다시 재현에서 완료 행 하나를 지우면 줄과 ID 가 모두 부족해 실패했습니다. 같은 행을 한 번 더 넣으면 ID 집합이 같아 통과했습니다. 잘못된 상대 링크를 넣어도 줄과 ID 검사는 통과했고 링크 목적지 파일이 있는지 따로 확인했을 때만 드러났습니다. 원래 작업에서도 ../adr 링크를 ./adr 로 되돌리는 변이는 링크를 따로 보는 검사가 잡았습니다. 링크 목적지 파일이 있어도 그 링크가 의도한 절을 가리키는지는 또 다른 확인 대상입니다.

정리하면 같은 “보존 검사”라도 비교 단위에 따라 지키는 것이 다릅니다.

비교 단위검출하는 변이놓치는 변이
줄 집합한 번만 나오는 줄의 삭제반복 줄의 삭제와 교체, 새로 생긴 줄, 순서, 소속, 다른 문서로의 이동
줄의 출현 횟수반복 줄의 삭제와 교체새로 생긴 줄, 순서, 소속, 다른 문서로의 이동
ID 집합행 삭제같은 행의 중복
링크 목적지 존재없는 파일을 가리키는 링크있는 파일의 엉뚱한 절을 가리키는 링크

중복 클래스 검사는 없는 중복을 보고하고 있는 중복을 놓쳤습니다

손으로 쓴 검사 대상 목록은 모듈 사이 의존이 바뀌어도 따라 바뀌지 않습니다. 중복 클래스 검사도 같은 질문으로 다시 볼 수 있습니다. 결제 서비스의 재복제를 검출한 것만으로는 검사 범위를 알 수 없어서 비교 대상 목록을 만드는 코드를 다시 읽었습니다.

def commonDeps = [':common', ':peekcart-common-auth', ':peekcart-common-observability', ':peekcart-common-messaging']
// ...
def apps = ['root-app': [':'] + commonDeps]
subprojects.findAll { it.name.endsWith('-service') }.each { svc ->
    apps[svc.name] = [svc.path] + commonDeps
}

이 검사는 서비스의 실제 의존과 관계없이 모든 -service 모듈에 고정된 공통 모듈 네 개를 붙여 비교합니다. 이름이 -service 로 끝나지 않는 gateway 는 목록에 없습니다. 반대로 모놀리스 시절 루트 프로젝트를 가리키는 root-app 은 지금은 기동하는 앱이 없는데도 남아 있습니다. 다시 재현에서 앞의 결제 서비스 사례에 두 가지 변이를 더 넣어 보았습니다.

넣은 변이실제 의존검사 결과
결제 서비스에 OutboxEvent 사본결제 서비스는 메시징 모듈에 의존실패 (맞게 검출)
회원 서비스에 메시징 모듈의 OutboxRecordKind 사본회원 서비스의 runtimeClasspath 에 메시징 모듈 없음실패 (실제 중복이 아닌데 보고)
gateway 에 observability 모듈의 CommitAwareMetrics 사본gateway 는 observability 모듈에 의존통과 (실제 중복을 놓침)

회원 서비스의 결과는 검사 범위가 실제 의존보다 넓어서 생긴 오탐이고 gateway 의 결과는 검사해야 할 앱이 빠져서 생긴 누락입니다. 이 검사는 com/peekcart/global 아래의 클래스만 보므로 외부 JAR 나 다른 패키지의 중복도 검사하지 않습니다.

보존을 확인한 뒤의 커밋이 아카이브를 덮어썼습니다

과거 설계를 옮긴 작업을 마무리하면서 완료 아카이브에 그 작업의 완료 행을 추가했습니다. 이 커밋이 기존 내용을 지우고 새 행만 남겨 아카이브 파일이 875바이트, 2줄이 됐습니다. 같은 PR 안에서 68,427바이트, 60줄로 복구했습니다. 이 60줄은 파일의 줄 수이고 앞에서 본 완료 개수 60과는 다른 값입니다. 문서를 옮긴 직후의 보존 검사는 통과했지만 그 검사는 일회성 스크립트였고 이후의 쓰기는 확인하지 않았습니다.

상태 파일을 읽어 요약을 내는 도구에도 같은 구분이 필요합니다. 파일을 읽을 수 있는지 보는 검사와 읽은 내용이 맞는지 보는 검사는 다릅니다. 앞에서 완료 개수를 보고한 요약 도구에는 --check 옵션이 있어 이것도 같은 질문으로 다시 돌려 보았습니다(다시 재현).

완료 아카이브의 상태--check 종료 코드출력
정상0ok — 모든 구획을 읽었다
파일 없음3파서 고장 1건 / 읽기 실패: docs/progress/TASKS-done.md
읽을 수 있는 빈 파일0ok — 모든 구획을 읽었다
같은 행이 두 번0ok — 모든 구획을 읽었다

출력은 도구가 낸 문자열을 그대로 옮겼고 두 줄로 나온 출력은 / 로 구분했습니다.

이 도구는 완료 행이 몇 개인지 보여 줄 뿐, 그 개수가 줄어들거나 중복되지 않았는지는 검사하지 않습니다.

CI 에 연결한 검사에도 같은 구분이 필요합니다. 연결한 검사가 위반을 검출하는지는 변이를 넣어 확인했지만 누군가 검사 스크립트 자체를 비활성화하거나 CI 단계에서 빼도 무언가가 실패하는지는 확인하지 않았습니다.

다시 한다면 비교 단위부터 정하겠습니다

이 절의 내용은 정리 작업을 마친 뒤 결과를 다시 대조하며 만든 제안입니다. 정리하는 동안 처음부터 이 순서로 작업한 것은 아닙니다.

정리를 시작하기 전에 대상마다 “무엇이 같아야 하는가”를 비교 단위로 적어 둡니다.

  1. 원문을 그대로 옮기는 문서라면 출처 구간과 목적지 구간을 대응시켜 순서대로 비교합니다. 그러면 반복 줄의 누락, 순서 변경, 다른 문서로의 이동을 함께 검출할 수 있습니다. 의도적으로 바꾼 헤딩이나 상대 링크는 허용한 변경 목록에 적고 링크는 옮긴 문서를 기준으로 목적지가 있는지 확인합니다.
  2. ID 로 참조되는 행이라면 ID 의 집합만 보지 않고 각 ID 가 정확히 한 번 나오는지도 확인합니다.
  3. 계속 쓰이는 파일이라면 옮긴 직후 한 번만 확인하지 않고 행을 추가할 때마다 기존 행이 줄거나 중복되지 않았는지 확인하는 조건을 둡니다.
  4. 검사 대상 목록은 손으로 쓴 목록 대신 실제 의존에서 만듭니다. 중복 클래스 검사라면 각 앱의 runtimeClasspath 를 읽고 포함되지 않아야 할 모듈과 빠지면 안 되는 앱을 각각 변이로 대조합니다.
  5. 어떤 검사든 정상 대조와 놓칠 것으로 예상하는 변이를 함께 돌립니다. 통과할 것으로 예상한 변이가 실제로 통과하는 것을 확인하면 그 검사가 보장하지 않는 범위를 문서에 적을 수 있습니다.

이 방법에도 적용 조건이 있습니다. 문장을 다시 쓰거나 여러 절을 재구성하는 편집에서는 원문과 순서가 다른 것이 의도일 수 있습니다. 그때는 바이트 동일성을 요구할 수 없으니 허용한 변경과 보존해야 할 사실을 먼저 정해야 합니다. 실행 코드에서도 해시가 같다는 것은 같은 파일이라는 뜻일 뿐입니다. 공유한 코드가 서비스마다 같은 의미로 동작하는지는 서비스별 통합 테스트가 맡아야 합니다. Java 타입을 공유한다고 해서 서비스 사이에 오가는 JSON 의 호환성까지 보장되지는 않습니다.

정리하며

네 서비스에 복제돼 있던 메시징 실행 코드 32개를 공유 모듈 하나로 옮기고 나머지 세 서비스의 사본 96개를 지웠습니다. 32개 중 31개는 네 서비스의 사본이 같았고 상태 enum 하나는 값의 합집합으로 통일했습니다. 공유 모듈은 실제로 필요한 네 서비스만 의존합니다. 구독 토픽과 원장 소유자, 마이그레이션은 각 서비스에 남겼습니다. 과거 설계와 완료 행은 원문과 찾아갈 경로를 남겨 옮겼습니다. 현재 문서와 겹치던 원본은 이관을 확인해 지웠고 계속 필요한 검사는 CI 에 연결했습니다.

처음 질문으로 돌아가면, 정리 뒤에 남아야 하는 것은 검사가 통과했다는 사실만으로 확인되지 않았습니다. 같은 “보존 검사”라도 줄의 집합은 반복 줄의 누락을 놓쳤고 출현 횟수는 순서와 소속을 보지 못했습니다. 같은 “중복 클래스 검사”도 실제 의존과 다른 목록을 쓰면 없는 중복을 보고하고 있는 중복을 놓쳤습니다. 각 정리의 완료 근거에는 검사 결과와 함께 그 검사가 실제로 비교한 단위와 놓치는 변이를 적어야 했습니다.

아직 고치지 않은 것도 있습니다. 중복 클래스 검사의 대상 목록, 줄과 횟수와 순서와 링크를 함께 보는 문서 보존 검사와 이후 쓰기의 확인, 빈 파일과 중복 행을 정상으로 보는 --check, “32개 모두 바이트 동일”과 마이그레이션 번호 같은 설계 결정 기록의 설명 오류가 그렇습니다. 현재 아키텍처 문서에는 지금은 없는 디렉터리 구조를 그린 그림이 남아 있고, 이번에는 main 코드의 주석만 정리해서 테스트 코드 주석의 내부 작업 식별자도 그대로입니다.

참고