순환 의존 검사에서 누락된 경로 별칭 확인하기

X Facebook

Written by

in

이 글을 쓰면서 검출 명령 세 개를 돌려 봤다. 검사 대상은 TypeScript 파일 51개로 구성된 autoqa 프로젝트였다. 400줄 넘는 파일 1개, 중복 후보 12건, 그리고 순환 의존 0건. 파일 크기와 중복 후보는 기록할 수 있었지만, 순환 의존 0건이라는 결과는 추가 확인이 필요했다.

순환 의존은 A가 B를 import 하고 B가 다시 A를 import 하는 상태다. 그런데 이 명령은 from "./utils" 같은 상대 경로 import만 보고 화살표를 이었다. 그 프로젝트에서 from "./..."를 쓰는 파일은 0개였다. @/로 시작하는 별칭 경로를 쓰는 파일이 51개 중 45개였다. 0건은 사이클이 없다는 뜻이 아니라 아무것도 안 본 결과였다.

비슷한 함수를 하나 더 만들거나 급해서 반대 방향으로 import를 넣어도 그날은 문제가 없다. 그런 코드가 쌓이다 보면 한 곳을 고쳤을 때 세 곳이 같이 깨지고 구조에 손대기 어려워진다. 출력이 0건일 때 그게 “없다”인지 “못 봤다”인지는 그 명령이 무엇을 봤는지로 구분한다.

curvez의 structure-audit은 명령 다섯 개의 출력을 판정 표에 대입해 코드 구조를 검사한다.

1. 눈으로 찾은 목록은 왜 달라지나

SKILL.md 절차 2단계는 검출 이야기보다 앞에 이 문장을 놓는다. “눈으로 훑어 찾은 결과를 보고하지 마라. 명령을 먼저 돌리고, 그 출력 위에서만 판단한다.”

눈으로 찾은 중복은 재현되지 않기 때문이다. 다음 주에 다른 목록을 내놓으면 “지난번엔 지적 안 했잖아” 라는 말을 듣게 되고 지적의 권위도 사라진다. 명령은 같은 입력에 같은 출력을 낸다. 판단은 사람마다 달라도 되지만 목록이 매번 달라지면 안 된다.

두 번째 이유가 더 실무적이다. 기계 출력이 있어야 핸드오프에 근거를 붙일 수 있다. 핸드오프는 에이전트가 일을 마치며 남기는 인수인계 JSON이다. 지적은 그 안의 findings[]에 들어간다. evidence 칸에 명령과 실제 출력을 같이 적는다. 그러면 지적받은 구현 에이전트가 그 명령을 다시 돌려 재현한다. 없으면 취향으로 읽히고 무시된다.

보는 범위도 좁혀 놨다. 구조 감사는 “이 코드가 여기 있어도 되는가”만 보고 동작이 맞는지는 quality-gate와 curvez-reviewer가 본다. 함께 보면 버그가 눈에 띌 때마다 구조 검사를 멈추고 정확성 문제를 먼저 살피게 된다. 그러다 보니 구조 지적이 매 실행마다 달라진다.

2. 무엇을 어떤 명령으로 세나

검사 대상 경로부터 추측하지 않고 .curvez/profile.json의 paths.*에서 읽는다. stack이 monorepo 면 paths.web과 paths.domain을 각각 따로 돌린다. 합쳐 돌리면 앱 사이의 “중복”이 잡히는데, 그건 애초에 분리하기로 한 것이라 지적이 아니다.

profile.json이 없으면 검사를 시작하지 않고 status: blocked로 돌려보낸다. architecture.md가 없으면 경계 위반 판정만 건너뛰고 status: partial로 낸다.

검출에는 순서가 있다. 앞 단계의 출력이 뒤 단계의 입력이 된다.

# 검출 산출물
1 파일 크기 (400줄 / 이름별 200줄 초과) 비대 파일 목록 — 중복의 선행 지표
2 중복 블록 (정규화 후 연속 창) N줄 x 파일M곳 후보 목록
3 순환 의존 (상대 import 그래프 + DFS) 사이클 목록, 타입 전용 표시
4 경계 위반 (## 금지 import 표 파싱 후 grep) 규칙 ID 별 위반 건수와 위치
5 git 공변경률 중복 후보 쌍의 함께 바뀐 비율

표 1. 다섯 검출의 순서와 산출물

1번은 이렇게 생겼다. 소스 폴더 아래 파일의 줄 수를 센다. 400줄 넘는 것만 큰 순으로 20개까지 뽑는다.

find "$SRC" -type f \( -name "*.ts" -o -name "*.tsx" -o -name "*.js" -o -name "*.jsx" \) -print0 \
  | xargs -0 wc -l | awk '$2 != "total" && $1 > 400 {print $1, $2}' | sort -rn | head -20

utils.ts helpers.ts index.ts는 200줄 기준으로 한 번 더 본다.

2번에서는 변수 이름이나 줄바꿈 위치가 다른 두 코드를 어떻게 비교할지가 까다롭다. 글자로만 비교하면 다른 코드로 나오기 때문이다. 그래서 비교 전에 공백을 하나로 줄이고 import 줄과 주석과 괄호만 있는 줄을 빼서 표기를 맞춘다. 그렇게 다듬은 줄을 일정 길이로 묶어 한 칸씩 밀며 다른 파일과 맞춰 본다. 겹치는 묶음은 하나로 합친다. 그 묶음이 위 표의 “연속 창”이다.

2번과 3번은 node -e로 도는 20줄짜리 스크립트라 여기 옮기지 않는다. 명령 전문은 references/detection-commands.md에 있고 SKILL.md는 그걸 그대로 복사해 실행하라고 못을 박는다. 손으로 다시 쓰면 방금 그 다듬는 규칙이나 묶음 합치기를 빠뜨린다. 그러면 같은 코드에서 매번 다른 목록이 나온다. SRC를 하드코딩하지 말라는 경고도 같은 자리에 있다.

3~6단계에서는 여기까지 나온 목록만 판정한다. 목록에 없는 것을 지적에 추가하지 않는다.

3. 후보로 잡혔다고 다 고치지 않는다

2단계 출력의 N줄 x 파일M곳을 표에 그대로 넣는다.

조건 판정
연속 5줄 이상이 서로 다른 파일 3곳 이상에서 반복 추출 후보
연속 20줄 이상이 서로 다른 파일 2곳에서 반복 추출 후보
연속 5줄 이상이 파일 2곳에서만 반복 후보 아님. priority: P3로 기록만
4줄 이하, 또는 한 줄짜리 표현식·타입 별칭 보고하지 않는다
같은 파일 안에서의 반복 보고하되 우선순위를 한 단계 낮춘다
테스트 코드의 반복 보고하지 않는다

표 2. 중복 후보를 추출 대상으로 올릴지의 판정

5줄·3곳에는 근거가 붙어 있다. 추출에는 고정 비용이 든다. 반복 2회로는 그 비용을 못 넘고 우연히 닮았을 확률도 그 구간이 가장 높다. 세 번째가 나타나야 앞으로도 늘어난다는 증거가 된다. 20줄 예외는 분량이 커지면 한쪽만 고치고 다른 쪽을 잊는 이슈가 실제로 생겨서 둔 것이고 테스트를 빼는 건 그 반복이 대개 의도된 것이라서다.

후보에 오른 코드는 아래 세 질문을 차례로 확인한 뒤 추출 여부를 정한다. 지금 닮았다는 것만으로는 합칠 수 없다.

# 질문 확인 방법
1 두 코드가 함께 변해 왔는가 git 공변경률 50% 이상이면 예. 등장 커밋 3건 미만이면 판정 불가로 두고 2·3번만 쓴다
2 두 코드가 같은 이유로 존재하는가 형태가 아니라 이유를 본다. “합계를 낸다”가 아니라 “주문 금액 정책을 적용한다”인지
3 플래그 없이 합칠 수 있는가 합치는 순간 mode / boolean 파라미터가 필요하면 아니오

표 3. 추출 여부를 정하는 세 질문

1번에서 늘 같이 고쳐 온 코드인지 보는 건, 그런 코드일수록 합쳐도 될 가능성이 높기 때문이다.

“아니오”가 2개 이상이면 중복을 남긴다. 이때도 findings에 move_to: "이동하지 않는다"로 기록한다. 그 판단은 나중에 다시 볼 수 있게 decisions에 reversible_at과 함께 남긴다.

레이어가 다른 중복은 아예 추출 금지다. 도메인의 계산과 UI의 표시 계산이 우연히 같아 보일 때가 있다. 거기서 공용 모듈을 만들면 양쪽이 그걸 의존하고 한 방향은 반드시 의존 방향 규칙을 거스른다. 중복 하나를 없애려고 경계 위반 하나를 만드는 거래인데, 경계 위반은 P0이고 2곳 중복은 P3 다. 예외는 추출 대상이 두 레이어 모두의 하류일 때뿐이다. 그때는 move_to에 경로와 “의존 방향을 거스르지 않는다”는 근거를 같이 적는다.

4. 사이클은 어느 참조를 끊나

3번 명령은 전수 검사다. 사이클의 모든 import가 값이 아니라 타입만 가져오는 import type 뿐일 때는 [타입 전용]으로 표시하고 그것도 보고한다. 실행 중에 서로를 부르는 사이클은 아니지만 모듈 경계가 잘못 그어졌다는 같은 신호다. 대신 우선순위를 한 단계 낮춘다.

끊는 방향은 순서대로 시도한다. 먼저 성립하는 것을 move_to에 적는다.

# 방법 쓰는 때
1 안쪽 → 바깥쪽 import를 끊는다. 저수준·도메인이 고수준·UI를 참조하는 쪽이 항상 잘못이다 사이클의 두 끝이 다른 레이어
2 공통으로 쓰는 타입·상수를 제3 모듈로 추출하고 양쪽이 그것을 본다 서로 상대의 타입만 필요로 함
3 인터페이스를 안쪽에 두고 구현을 바깥에서 주입한다 (의존성 역전) 안쪽이 바깥의 동작을 실제로 필요로 함
4 두 모듈을 하나로 합친다 위 셋이 전부 억지스럽다. 애초에 한 개념이었다는 뜻

표 4. 사이클을 끊는 방법과 쓰는 때

이 넷으로도 정할 수 없으면 사이클 안에서 참조 지점이 가장 적은 것을 끊는다. 끊는 비용이 호출 지점 수에 비례하기 때문이다.

도입에서 말한 0건의 근거가 여기 있다. 검출 명령은 상대 경로 import만 본다. @/lib/format 같은 별칭 경로는 타입 검사기가 알아듣지만 상대 경로 모양만 찾는 이 스크립트에는 아무것도 아니다. 그래서 별칭을 쓰는 코드베이스에서 0건이 나오면 이렇게 적으라고 되어 있다. “순환 없음”이 아니라 “이 방법으로는 못 봤다” 를 summary에 넣으라는 것이다. 이 문장을 읽기 전에 이미 0건 출력이 나와 있었다. 별칭 설명을 읽고 나서 상대 import 개수를 세어 봤다. 0개였다.

5. 경계 위반은 누구 규칙으로 세나

판정 기준은 .curvez/architecture.md 안의 ## 의존 방향과 ## 금지 import(ARCH-NNN 표)로 한정한다. “도메인은 순수해야 한다” 같은 일반론으로 지적하지 않는다. 그 표를 만드는 이야기는 앞 편에 있다. 에이전트가 따를 DDD 폴더 구조와 의존성 규칙 정하기 다. 이 스킬은 확정된 표를 읽어 위반을 세기만 한다. 감사가 규칙까지 만들면 기준이 두 개가 되고 구현 에이전트는 어느 쪽을 따를지 알 수 없다.

상황 판단
ARCH-NNN 금지 패턴에 걸린다 위반. 예외 없음. kind: "boundary", id에 규칙 ID를 인용한다
의존 방향이 문서와 반대 (예: domain → ui) 위반. 예외 없음
문서에 안 적힌 새 패턴 위반으로 세지 않는다. kind: "placement"로 관찰만 남긴다
같은 규칙 위반이 파일 5개 이상에서 반복 개별 지적을 접고 한 건으로 묶어 규칙 재검토 이의를 낸다
표를 한 줄도 파싱하지 못했다 위반 0건이 아니라 판정 불가. blocked_on에 who: "curvez-architect"

표 5. 경계 위반 판정과 그때의 처리

5개라는 기준의 근거는 표 마지막 줄에 있다. who: "curvez-architect" 다. 한두 곳의 위반은 실수다. 다섯 곳에서 똑같이 어겼다면 규칙이 현실과 안 맞는 것이다. 실수는 구현 에이전트가, 규칙 문제는 아키텍트가 고친다. 전자를 후자로 처리하면 다음 라운드에 같은 위반이 다시 생긴다. 라운드는 에이전트들을 한 바퀴 돌리는 단위다.

파싱 함정도 문서에 적혀 있다. 표의 필드 구분자는 공백-파이프-공백인 ' | ' 다. 패턴 안의 \|는 읽어낸 뒤 |로 되돌려야 한다. 둘 중 하나만 빠져도 패턴이 잘려 위반 0건이 나오고 그 0건은 “깨끗하다”로 읽힌다.

6. 왜 최우선은 다섯 건까지인가

발견한 것을 전부 같은 무게로 보고하지 않는다. 영향 범위로 등급을 매긴다.

등급 조건 처리
P0 순환 의존, 또는 ARCH-NNN 정면 위반 이번 라운드에 반드시 고친다
P1 영향 파일 4개 이상. 또는 3곳 이상 반복 중복 이번 라운드 권고
P2 영향 파일 2~3개 다음 라운드로 미뤄도 된다
P3 영향 파일 1개, 또는 판정이 애매한 것 기록만. 고치라고 요구하지 않는다

표 6. 지적의 등급과 처리

최우선 목록(P0 + P1)은 최대 5건이다. 넘으면 영향 범위가 큰 순으로 자르고 잘린 것은 지우지 않고 P2로 내려 findings에 남긴다. 정리 항목이 30개면 구현 에이전트는 어디부터 손댈지 정할 근거가 없어, 결국 쉬운 것 두어 개만 고치고 위험한 순환 의존을 그대로 남긴다.

순환이 P0 인 근거도 따로 적혀 있다. 이미 서로 참조하고 있으면 새 참조를 더 붙이기 쉬워 시간이 갈수록 참조가 늘어난다. 나중에 끊는 비용이 지금의 몇 배가 되는 유일한 항목이라고 되어 있다.

등급으로도 순서가 안 정해질 때 쓰는 규칙이 뒤에 붙어 있다. ① 기계적 증거가 있는 쪽을 택한다. 출력이 뒷받침 못 하는 지적은 버린다. ② architecture.md의 규칙이 일반 원칙을 이긴다. ③ 그래도 애매하면 “지금은 고치지 않는다” 를 고른다. ④ 판단이 안 서면 P2로 남긴다. blocked_on에 질문을 붙여 넘긴다.

세 번째는 둘 다 틀릴 수 있다면 되돌리는 비용이 적은 쪽을 고르라는 뜻이다.

curvez-structure-reviewer는 읽기 전용이라 결과가 파일이 아니라 응답 텍스트 전체로 나간다. 그래서 최종 응답을 핸드오프 JSON 하나로 반환하고 앞뒤에 인사말을 붙이면 안 된다. 오케스트레이터에게 그걸 잘라낼 규칙이 없어 파싱이 깨진다. 각 항목의 필수 칸은 id kind where what why priority 다. severity는 curvez-reviewer의 등급 체계라 섞어 쓰지 않는다. status는 구조 문제의 유무가 아니라 검사 완수 여부로 정한다. P0를 10건 찾아도 검사를 다 돌렸으면 done이다.

7. 도입 비용과 한계

거짓 양성은 사람이 걸러야 한다. 없는 걸 있다고 잡는 쪽이다. 돌려서 나온 중복 후보 12건 중 파일이 가장 많이 걸린 항목은 5곳짜리였는데, 열어 보니 여러 줄로 나눠 쓴 import 블록의 이어지는 줄들이었다. Card, CardContent, CardDescription, CardHeader, CardTitle 다섯 줄이 다섯 파일에 똑같이 있다. 필터는 import로 시작하는 줄만 빼고 두 번째 줄부터는 코드로 세어 버리니, 표대로면 5줄 3곳 이상이라 추출 후보가 된다. 그런데 실제로 추출할 것은 없다. 이런 후보를 거르려면 판정하는 쪽이 3문 판정에 시간을 써야 한다.

거짓 음성은 아무 신호도 남기지 않는다. 있는 걸 없다고 하는 쪽이다. 순환 검출은 상대 경로 import만 본다. 별칭을 쓰는 프로젝트에서는 0건이 나온다. 그 0건은 아무 경고 없이 “깨끗하다”로 읽힌다. 경계 위반 쪽도 같다. 표 파싱에 실패해도 출력은 그냥 비어 있다. SKILL.md는 두 자리 모두 “판정 불가로 적어라”라고 정해 놨다. 검사가 자동으로 구분해 주지는 않으니 그 규칙을 안 지키면 다음 라운드도 잘못된 결과를 믿고 시작한다.

검출은 되는데 이 스킬은 못 고친다. 정리 방안만 내고 실제 수정은 curvez-nextjs가 한다. 셸로 우회하는 것도 막아 뒀다. >, >>, tee, sed -i, mv, rm, git add, git commit이다. 구조 감사는 쓰는 파일이 없어 어떤 에이전트와도 병렬로 돌릴 수 있다. 한 번 우회하면 병렬 안전성의 근거가 사라져, 이 감사는 순차 실행으로 내려가야 한다.

P2와 P3는 대체로 안 고쳐진다. 5건으로 제한한 건 그게 “한 라운드에서 실제로 닫을 수 있는 상한”이기 때문이다. 나머지는 기록으로 남는다. 지우지 않는 것과 고쳐지는 것은 다르다. 쌓인 P3를 언제 다시 보는지는 SKILL.md에 없다.

공변경률 지표는 새 코드에서 못 쓴다. 등장 커밋이 3건 미만이면 표본이 없다. 판정 불가로 두고 나머지 두 질문만으로 정한다. 갓 만든 파일이 전부 추출 금지가 되는 걸 막으려는 처리다. 뒤집어 보면 프로젝트 초반에는 3문 판정의 3분의 1이 늘 비어 있다.

검사가 얼마나 걸리는지는 확인하지 못했다. 돌려 본 파일 51개짜리 트리에서는 세 명령 모두 체감상 즉시 끝났다. 큰 트리에서 어떤지는 재보지 않았고 SKILL.md에도 시간 상한이 없다. 다만 5번 명령은 중복 후보 쌍마다 git log를 따로 돌린다. 후보 수만큼 반복된다는 건 명령 형태에서 보인다. 전용 스크립트는 아직 없고 “반복 실행이 굳어지면 scripts/structure-audit.mjs로 옮길 수 있다” 고만 적혀 있다.


순환 의존 검사는 통과 여부로만 읽히기 쉽다. 지금은 감사 결과에 한 줄이 더 붙는다. “상대 경로 import를 쓰는 파일이 0개라 이 방법으로는 못 봤다” 다. 고칠 것이 늘지는 않았지만 모른다는 사실은 기록에 남았다.

Comments

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다