aster®

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

마무리 · 2026-09

순환하는 화살표 고리 세 개가 위쪽에 나란히 놓여 있고, 그 아래를 초록색 가로 띠가 고리에 닿지 않은 채 화면 전체를 가로지른다.

이 글을 쓰면서 검출 명령 세 개를 돌려 봤다. 대상은 프로젝트 하나(autoqa, TypeScript 파일 51개)다. 400줄 넘는 파일 1개, 중복 후보 12건, 그리고 순환 의존 0건. 앞의 둘은 그대로 적어도 됐지만 마지막 결과는 더 확인해야 했다.

순환 의존은 A 가 B 를 import 하고 B 가 다시 A 를 import 하는 상태다. 그런데 이 명령은 from "./utils" 같은 상대 경로 import 만 보고 화살표를 이었다. 그 프로젝트에서 from "./..." 를 쓰는 파일은 0개였다. @/ 로 시작하는 별칭 경로를 쓰는 파일이 51개 중 45개였다. 별칭 경로를 분석하지 못했으므로 순환 의존이 있는지 확인할 수 없는 결과였다.

비슷한 함수를 하나 더 만들거나 급해서 반대 방향으로 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 별 위반 건수와 위치
5git 공변경률중복 후보 쌍의 함께 바뀐 비율

표 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개라 이 방법으로는 못 봤다" 다. 추가 결함을 확인한 것은 아니지만 검증하지 못한 범위를 기록했다.