Skip to Content
← 블로그
개편기

빌드에 규칙을 뒀는데, 빌드가 안 보는 곳이 있었습니다

모듈 경계 재설계기 2편. 실제로 나눠보니 가드 하나가 에러 없이 사라질 뻔했습니다

만화로 보는 요약 — 먼저 읽어보세요
만화로 보는 요약 — 먼저 읽어보세요

지난 글은 이렇게 끝났습니다.

문서는 잊히지만 빌드는 잊히지 않습니다.

그때는 설계서와 8개 태스크짜리 계획서까지가 전부였고, 코드는 한 줄도 옮기지 않은 상태였습니다. 이 글은 그 8개를 실제로 실행하고, 곧바로 다음 결제사를 그 구조 위에 붙여본 기록입니다.

결론부터 말하면 저 문장은 절반만 맞았습니다. 빌드도 자기가 보는 범위 밖은 잊습니다.


1. 가드는 코드를 따라오지 않습니다

스캔 범위가 옛 패키지에 박혀 있었습니다

이 프로젝트에는 결제 상태 전이를 한 곳으로 모으는 퍼널이 있습니다. 원장 상태를 바꾸는 동시에 단말 캐시 갱신 이벤트를 발행하는 자리라, 퍼널을 우회하면 상태는 바뀌는데 단말에는 안 알려지는 사고가 납니다.

그래서 퍼널 밖에서 상태 전이 메서드를 직접 부르면 빌드를 실패시키는 아키텍처 테스트가 있습니다. 지금 당장 못 고치는 호출부는 클래스#메서드 단위 면제 목록으로 빼두고, 그 목록이 비는 것이 다음 단계 착수 조건입니다.

문제는 그 테스트가 이렇게 생겼다는 것이었습니다.

ClassFileImporter().importPackages("<게이트웨이 패키지>")

스캔 범위가 옛 패키지에 고정돼 있습니다. 환불 구현을 새 모듈로 옮기는 순간, 그 코드는 이 테스트의 시야 밖으로 나갑니다.

이게 왜 까다로운가

아무 일도 일어나지 않기 때문입니다. 테스트는 통과합니다. 스캔 대상에 환불 코드가 없으니 위반이 없고, 위반이 없으니 초록불입니다. 면제 목록에 남아 있던 6건은 아무것도 면제하지 못한 채 목록만 채우고 있게 됩니다. 그리고 그 목록의 길이가 다음 단계 착수 조건이므로, 게이트가 조용히 오작동합니다.

이관 전에는 가드가 승인 코드와 환불 코드를 함께 보고 있었습니다.

환불 코드를 새 모듈로 옮기고 아무것도 하지 않으면 이렇게 됩니다.

면제 목록을 개수까지 그대로 옮겼습니다

같은 규칙을 새 모듈에도 세우고, 면제 목록 6건을 개수까지 그대로 옮겼습니다. 옛 모듈 쪽에서는 그 6건을 뺐습니다.

여기서 중요한 건 "옮겼다"가 아니라 "양을 보존했다"입니다. 이관을 핑계로 목록을 줄이면 부채가 사라진 것처럼 보이지만 실제로는 감시만 사라집니다. 반대로 늘리면 이관이 부채를 만든 것처럼 보입니다. 어느 쪽이든 이관 전후로 숫자가 같아야 나중에 "이 부채는 언제 생겼나"를 추적할 수 있습니다.

남는 교훈

규칙을 빌드에 두는 것만으로는 부족하고, 규칙이 코드와 함께 이동하는지까지 봐야 했습니다. 이건 리팩토링 체크리스트에 넣을 만한 항목입니다.

코드를 다른 패키지·모듈로 옮길 때, 그 코드를 감시하던 테스트의 스캔 범위를 함께 확인한다.


2. 새로 세운 규칙이 세우자마자 옛 결함을 잡았습니다

경계를 세 줄로 못 박았더니

새 모듈은 안쪽을 이렇게 나눴습니다. 브랜드 무관한 것은 core, 결제사별 구현은 brand/{결제사} 아래 수직 슬라이스로. 그리고 경계를 세 가지 아키텍처 규칙으로 못 박았습니다.

규칙어기면
새 모듈 → 게이트웨이 참조 금지배치가 다시 승인 코어를 끌고 온다
브랜드 슬라이스 간 상호 참조 금지한 결제사를 지웠을 때 다른 결제사가 깨진다
core → brand 참조 금지결제사를 추가할 때마다 core를 고쳐야 한다

세 번째 규칙을 켜자 즉시 빨간불이 떴습니다.

범용인 척한 포트가 걸렸습니다

core에 있던 포트 인터페이스 하나가 시그니처에 특정 결제사 타입을 노출하고 있었습니다. 이름은 범용 포트처럼 지어져 있었지만, 다른 결제사가 재사용할 수 없는 상태였습니다.

이건 이관이 만든 결함이 아니라 이관 전부터 있던 것입니다. 옛 구조에서는 그걸 잡아줄 규칙이 없었을 뿐입니다.

지난 글에서 저는 두 번 틀렸고, 둘 다 원인이 "이름을 보고 내용을 추측한 것"이라고 썼습니다. 이번 것도 같은 종류입니다. 이름이 범용이라 범용인 줄 알았던 것이죠.

차이가 있다면 이번엔 사람이 아니라 빌드가 잡았다는 점입니다. 규칙을 세우는 비용은 한 번이고, 그 규칙이 잡아주는 건 앞으로 계속입니다.

추가로 발견한 것

같은 흐름에서, 옮기려던 패키지 안에 환불과 무관한 클래스 다섯 개가 섞여 있는 것도 다시 확인했습니다(지난 글에서 계획 단계에 발견했던 그것). 그중 하나는 같은 패키지에 있어서 import 문이 아예 없었습니다.

// 같은 패키지라 import 가 없다 → FQN 검색에 안 잡힌다
public record SomeResponse(MsgResponse status, ...) { }

전체 텍스트 검색으로 참조를 세는 방식은 이걸 놓칩니다. 컴파일에서야 드러났고, 이관 작업 내내 세 번 같은 함정을 밟았습니다. 클래스를 옮길 때는 "import를 지운 게 아니라 원래 없었던" 참조를 따로 세어야 합니다.


3. 초록불인데 테스트가 돌지 않았습니다

exit 0 이 통과가 아니었습니다

새 모듈의 테스트를 검증하려고 이렇게 돌렸습니다.

gradle build -x test :module-refund:test

exit 0. 통과로 읽고 넘어갈 뻔했는데, 결과 XML을 열어보니 failures="1"이었습니다.

옵션 두 개가 서로를 지웠습니다

-x test는 그 뒤에 명시한 테스트 태스크까지 제외합니다. 태스크 이름으로 제외하는 옵션이라, 뒤에 같은 이름의 태스크를 붙여도 살아나지 않습니다. 결국 테스트는 한 번도 돌지 않았고, 파일에 남아 있던 건 이전 실행의 결과였습니다.

빌드 도구가 거짓말을 한 건 아닙니다. 시킨 대로 했을 뿐이고, 제가 두 옵션의 상호작용을 몰랐습니다.

그래서 빌드와 테스트를 분리했습니다

그 뒤로는 빌드와 테스트를 항상 분리하고, 테스트는 캐시를 무시하도록 강제했습니다.

gradle build -x test
gradle test --rerun-tasks

통과 여부는 exit code가 아니라 결과 XML의 집계로 확인했습니다. 스킵 건수까지 포함해서요. 실패 0만 보면 "실행 자체가 0건"인 경우를 못 걸러냅니다.

같은 날 밟은 다른 함정

패키지 경로를 일괄 치환하려고 sed -i를 돌렸다가 241개 파일이 변경된 걸로 잡혔습니다. 실제 내용 변경은 15개였고 나머지는 파일 전체의 줄바꿈 문자가 바뀐 것이었습니다. 그중 200개는 수정하면 안 되는 참조용 스냅샷 영역이었습니다.

되돌리는 건 어렵지 않았지만(내용 변경이 없으니 인덱스 기준으로 복원하면 됩니다), 교훈은 남았습니다.

일괄 치환 도구는 대상 파일을 먼저 좁히고 실행한다. 실행 후에는 변경 파일 개수부터 확인한다. 예상과 자릿수가 다르면 내용을 보기 전에 멈춘다.


4. 다음 결제사가 구조를 검증하면서 같은 진단을 다른 답으로 들고 왔습니다

자료를 열었더니 지난 글과 같은 문장이 있었습니다

모듈 분리를 끝낸 다음 날, 새 결제사(편의상 결제사 R)를 붙이는 작업이 들어왔습니다. 외부에서 넘어온 연동 자료에는 운영 소스와 가이드 문서가 함께 있었습니다.

그 자료를 열었더니 이렇게 적혀 있었습니다.

배치 모듈은 게이트웨이 모듈을 의존하지 않는다. 거기 취소 전략을 두면 배치의 클래스패스에 빈이 없어 팩토리가 결제사를 찾지 못하고, 승인 통지만 나간 거래가 회수되지 않는다. 그래서 취소 전략은 공통 모듈에 둔다.

진단이 지난 글과 한 글자도 다르지 않았습니다. 남이 독립적으로 같은 결론에 도달했다는 건, 그 진단이 우리 프로젝트의 특수성이 아니라 구조에서 나온다는 뜻입니다. 다만 답이 달랐습니다. 자료는 공통 모듈로, 우리는 새 모듈로 갔습니다.

왜 우리 답을 유지했나

지난 글에서 "구현을 공통 모듈로" 안을 기각한 이유는 이랬습니다. 공통 모듈은 배포 모듈 전부가 의존하므로, 취소가 필요 없는 모듈까지 전략과 메시지 컨슈머를 떠안습니다.

실제로 자료를 그대로 적용했다면 감수해야 했을 것이 두 가지 더 있었습니다.

자료가 감수한 것우리 구조에서는
부모 추상 클래스를 공통 모듈로 이동 (상속 2곳 수정 + 옛 파일 삭제)불필요 — 이미 새 모듈의 core에 있다
공통 모듈이 HTTP 지원 모듈을 의존할 수 없어(순환) 빈을 문자열 이름으로만 참조불필요 — 새 모듈은 그 의존을 명시적으로 선언한다

두 번째가 특히 그렇습니다. 문자열로 빈을 참조하면 컴파일러가 그 연결을 검사하지 못합니다. 빈 이름이 바뀌면 기동 시점에야 터집니다. 구조 하나를 바꿔서 타입 검사를 되찾은 셈입니다.

헌장이 약속한 게 성립했나

모듈을 만들면서 "결제사 추가는 폴더 하나로 끝난다"고 문서에 적어뒀습니다. 실제로 결제사 R을 붙일 때 새 모듈 쪽 변경은 이랬습니다.

  • 신규: brand/{결제사}/ 아래 3개 파일
  • 수정: 1개 (그마저도 아키텍처 테스트의 면제 목록)

약속은 지켜졌습니다. 다만 그 수정 1건이 다음 문제로 이어집니다.

자료의 구현에서 발견한 것

옮겨 붙이는 김에 자료의 코드도 읽었는데, 환산금액을 조회하는 리더를 새로 만들어 두었더군요. 우리 프로젝트에는 같은 컬럼을 읽는 클래스가 이미 있었고, 결정적으로 자료 쪽에는 비동기 적재 전 상태를 위한 폴백이 없었습니다.

그 폴백이 필요한 상황은 승인 직후 취소가 들어오는 경우입니다. 그리고 이 연동이 메우려던 시나리오가 바로 그 경우였습니다. 자료대로였다면 가장 중요한 케이스에서 값을 못 찾았을 겁니다.

기존 클래스에서 조회부만 떼어내 "없어도 실패시키지 않는" 입구를 추가하고, 두 결제사가 같은 조회 지점을 쓰게 했습니다.


5. 정작 가장 위험한 건 아무도 잡지 못했습니다

821건이 전부 통과했는데 둘이 빠져 있었습니다

결제사 R의 승인 전략을 자료 그대로 옮겼습니다. 빌드 통과. 테스트 821건 전부 통과. 아키텍처 규칙 네 종류 전부 통과.

그런데 다른 결제사의 승인 전략과 나란히 놓고 읽어보니, 자료의 전략에는 두 가지가 없었습니다.

  • 통합 거래내역 적재 (정산·대사가 읽는 원장)
  • 단말 결과 통지 (푸시)

없으면 어떻게 되나

둘 다 오류가 아니라 침묵으로 나타납니다. 적재가 없으면 그 결제사의 승인 거래가 정산·대사에 잡히지 않습니다. 통지가 없으면 단말은 푸시 없이 폴링으로만 결과를 받으니, 조용히 느려질 뿐 화면은 정상입니다.

왜 아무 도구도 못 잡았나

아키텍처 테스트에는 통지 관련 규칙이 이미 있었습니다. 그런데 그 규칙은 이렇게 생겼습니다.

통지 발행 지점을 우회해서 토픽에 직접 발행하면 실패시킨다.

우회 발행은 막지만 우회 누락은 막지 않습니다. 아예 안 부르는 건 위반이 아니니까요.

그리고 이 함정은 이미 코드에 적혀 있었습니다. 발행 지점을 단일화한 클래스의 주석입니다.

이전에는 발행 코드가 5곳에 흩어져 있어서, 신규 연동사를 붙이는 개발자가 "여기서도 발행해야 한다"를 알아낼 방법이 없었다.

발행 지점을 한 곳으로 모으는 리팩토링은 이미 되어 있었습니다. 그런데 호출을 빠뜨리는 것 자체는 여전히 못 막습니다. 단일화는 "어디서 발행하나"를 풀지 "발행해야 한다는 걸 어떻게 아나"를 풀지 않습니다. 결국 사람이 다른 결제사와 나란히 놓고 비교해서 찾았습니다.

잡힌 것과 안 잡힌 것의 차이

같은 연동 작업에서 이렇게 갈렸습니다.

결함무엇이 잡았나언제
상태 전이 퍼널 우회아키텍처 테스트빌드 즉시
모듈 경계 노출 위반모듈 검증 테스트빌드 즉시
브랜드 전용 포트가 core에아키텍처 테스트빌드 즉시
통합 거래내역 미적재사람 (다른 결제사와 대조)코드 리뷰
단말 통지 누락사람 (다른 결제사와 대조)코드 리뷰

차이는 하나입니다. 규칙이 코드인가, 주석인가. 위 셋은 "하면 안 되는 것"이라 표현할 수 있어서 테스트로 옮겨졌습니다. 아래 둘은 "해야 하는 것"이고, 그건 정적 규칙으로 표현하기가 훨씬 어렵습니다. "이 인터페이스를 구현하는 클래스는 반드시 저 메서드를 호출해야 한다"를 검사하려면 호출 그래프를 봐야 하는데, 우회 경로가 하나만 있어도 규칙이 무너집니다.

이건 아직 못 푼 문제라 백로그에 재발 사례로 기록했습니다. 같은 항목에 "이 함정이 재발할 것"이라는 예측이 이미 적혀 있었고, 예측대로 재발했습니다.


부채는 줄지 않고 늘었습니다

블록 4에서 "수정 1건"이라고 했던 그 파일이 면제 목록입니다. 새 결제사의 환불 전략은 다른 결제사와 같은 전이 메서드 집합을 씁니다. 그런데 퍼널에는 그중 두 개에 대응하는 입구가 없습니다. 라이브러리 쪽 메서드가 상태와 다른 필드를 분리할 수 없게 묶어서 세팅하기 때문입니다.

그래서 새 결제사만 따로 퍼널로 전환할 수 없었고, 면제 목록이 6건에서 10건이 됐습니다.

새로운 종류의 부채는 아닙니다. 같은 부채의 네 번째 사례입니다. 문제는 방향입니다.

착수 조건이 "목록이 빈다"인데, 결제사를 늘릴 때마다 목록이 함께 늡니다. 미룬 비용에 이자가 붙는 구조이고, 퍼널을 먼저 넓히지 않으면 이 방향은 안 바뀝니다.

부채를 면제 목록으로 관리하는 방식 자체는 좋았습니다. 눈에 보이고, 숫자로 세어지고, 게이트에 걸 수 있으니까요. 다만 면제를 추가하는 것이 너무 쉬웠습니다. 한 줄 추가하면 빌드가 다시 초록불이 되니, 그 순간의 압력이 "퍼널을 넓히자"보다 "일단 넣자" 쪽으로 기웁니다.


마치며

숫자로 남은 것은 이렇습니다.

항목결과
옮긴 파일64개 (이름변경 21 · 삭제 4 · 신규 8 · 수정 31)
배치 부트 jar의 승인 코어 클래스0개 (의존 그래프로 확인)
테스트821건 통과 (실패 0 · 스킵 0)
늘어난 모듈1개 · 의존 깊이 3 → 3 · 순환 0
면제 목록6 → 10건

목적은 달성했습니다. 배치가 승인 코어를 받지 않으면서 취소 전략은 찾을 수 있게 됐고, 바로 다음 결제사가 그 구조 위에 폴더 하나로 붙었습니다.

다만 아직 기동해보지 못했습니다. 이 환경은 개발망 인프라가 없어 컴파일과 단위 테스트까지만 확인됩니다. 기동 로그에 전략 목록이 실제로 찍히는지, 배치가 실제로 취소를 거는지는 저쪽에서 처음 돕니다. 그래서 이관 문서에 "어디까지 실행했는가"를 절 하나로 따로 적었습니다 — 넘겨받는 사람이 첫 에러에서 자기 실수를 의심하지 않도록.

지난 글의 마지막 문장은 이렇게 고쳐야겠습니다.

문서는 잊히지만 빌드는 잊히지 않습니다.

빌드는 자기가 보는 범위 안에서만 잊지 않습니다. 그리고 "하면 안 되는 것"은 잘 잡지만 "해야 하는 것"은 거의 못 잡습니다.

첫 글에서 얻은 기준이 "이름 말고 소비자를 세어보라"였다면, 이번에 얻은 건 이겁니다.

규칙을 빌드에 옮겼다고 끝이 아니라, 그 규칙이 무엇을 볼 수 있고 무엇을 못 보는지까지 알아야 합니다. 못 보는 자리에는 사람이 서야 하고, 그 자리가 어디인지는 문서에 적어둬야 합니다. 결국 문서로 돌아오긴 하는데, 범위가 훨씬 좁아진 문서라는 게 차이입니다.