폴더 구조를 감이 아니라 grep으로 정한다

26년 08월 29일

폴더를 나누는 기준을 감에서 실측으로

nakchalai 웹 클라이언트의 _components 디렉터리가 파일 스물몇 개짜리 평면 구조로 자라 있었다. 어떤 파일은 사이드바 전용인데 어떤 파일은 검색·매물 상세·셸 여기저기서 import되는 공유 컴포넌트다. 줄곧 “이건 폴더로 묶어야 하는데”라는 감으로만 생각해왔는데 이번에 그 감을 데이터로 바꿨다.

방법은 단순하다. 각 파일마다 grep -rn "<이름>" src --include=*.tsx --include=*.ts로 소비자를 전수조사했다.

  • 소비자가 정확히 하나면 → 그 기능 폴더로 옮겨서 index.tsx만 공개한다
  • 같은 기능 가족 안에서만 쓰이면 → 그 가족 폴더로 흡수한다
  • 서로 다른 기능 둘 이상이 쓰면 → 평면 구조 그대로 둔다
  • 소비자가 하나뿐인 파일은 그 소비자가 어디로 가든 따라간다 (연쇄 흡수)

평면 디렉터리가 곧 공유 레이어 역할을 한다는 발상이 재밌었다. 폴더로 묶는 순간 내부 구현은 자유로워지고, 밖으로 내보낼 심볼만 index.tsx에 남긴다. 사이드바 가족(app-sidebar, sidebar-nav, use-current-user, mobile-nav-drawer)이 폴더화 적격, panel-session-tracker처럼 소비자가 넷인 파일은 평면 유지로 판정이 떨어졌다.

단, index.tsx 프라이버시는 관행 수준이라는 점을 알아뒀다. TypeScript는 깊은 import를 막아주지 않는다. 강제하려면 biome의 import 제한 규칙이 필요한데, 이건 뒤에 다시 등장한다.

git mv가 조용히 무너지는 두 방식

실행은 git mv 5단계를 && 체인으로 묶어서 돌렸다. 4단계에서 실패했는데, 앞의 3개 이동은 이미 적용된 상태였다. 트리가 반쯤 옮겨진 채 import가 끊어진 것이다.

실패 원인을 파 보니 두 가지였다.

첫째, git은 디렉터리가 아니라 파일만 추적한다. 옮기려는 폴더 내부가 전부 untracked면 git 관점에서 이동할 엔트리가 0개라 “source directory is empty”로 거부한다.

둘째, && 체인이 4단계에서 끊기면서 앞 단계의 mkdir이 실행되지 않았다. 이후 재시도한 git mv는 목적지 디렉터리가 없어서 “No such file or directory”로 또 실패했다. 오류 메시지는 소스 경로를 지목하지만 실제 원인은 목적지인 셈이다. git mv 실패 시 ls로 양쪽을 모두 확인해야 하는 이유다.

접어둔 결론도 하나 남겨둔다. 긴 이동 체인은 &&로 묶지 말고 실패 지점과 무관하게 멱등하게 재작성하는 편이 안전하다. mkdir -p로 목적지를 먼저 만들고 tracked 파일은 git mv, untracked 파일은 평범한 mv로 분리 처리한다.

어쨌든 나는 반쯤 옮겨진 트리를 원복하는 쪽을 택했다. 깨진 상태로 계속하는 것보다 안정 상태로 되돌리고 다시 시작하는 게 낫다고 판단했다. 기존 5단계 계획은 폐기하고 범위를 사이드바에서 워크스페이스 전체로 확장해 다시 세웠다.

대규모 리팩터링은 사람 손으로 하지 않는다

범위가 커지니 이번에는 직접 하지 않고 루프 에이전트에 넘기기로 했다. 붙여넣을 골(goal) 텍스트를 쓰는데, 여기서 배운 게 크다.

골은 지시가 아니라 계약이라고 생각하면 된다. 루프 에이전트는 새 세션으로 떠서 대화 기억이 없고 스스로 계획을 세우고 지정된 검증자가 채점한다. 골 텍스트가 유일한 입력이므로 검증된 사실과 관찰 가능한 완료 상태만 들어가야 한다.

그래서 이동 순서, 파일 경로 맵, 코드 스니펫은 전부 뺐다. 에이전트가 다르게 해도 제품이 안 깨지는 줄은 방법이므로 계약에 없어도 된다. 대신 소비자 조사로 검증된 흡수 경계와 완료 판정 7개 항목은 인라인으로 박아 넣었다.

전수조사 범위도 이때 정정됐다. app/_components만 보면 안 되고 워크스페이스 전체를 훑어야 했고, 결과는 파일명 규칙 위반 31개를 발견했다. 매물 상세 라우트가 25개로 최악이었다. 검색 가족 파일명 5개만 문제인 줄 알았는데 빙산의 일각이었던 것이다.

실행 결과와 함정 네 개

루프를 돌려 실제로 옮기고 리네임까지 마쳤다. 폴더 세 개를 만들고 31개 파일을 리네임하고 소비자 import 26곳을 갱신했다. 과정에서 비자명한 함정 네 개를 만났다.

첫째, 폴더 인덱스 alias import는 파일 이동에 면역이다. @/app/_components/app-shell처럼 폴더를 가리키는 import는 내부 파일이 어디로 가든 경로 문자열이 불변이라 소비자 수정이 필요 없었다. 대량 리네임 전에 alias로 통일해두면 이동 비용이 폴더 내부로 국소화된다.

둘째, 폴더화 직후 상대경로 depth가 전부 1 증가한다. search-client/index.tsx../(search)/_hooks/...를 참조 중이었는데 한 단계 깊어지면서 경로가 깨졌다. 프로젝트 린트 규칙(상대경로 depth 2 이상 금지)에 따라 alias로 교체해 해결했다.

셋째, biome 설정의 override가 구 파일명을 가리키고 있으면 규칙이 조용히 재활성화된다. JsonLd.tsxjson-ld.tsx로 리네임하자 override의 면제 대상이 사라져서 noDangerouslySetInnerHtml 에러가 새로 터졌다. 코드는 한 줄도 안 고쳤는데 말이다. 리네임할 때 설정 파일 안의 파일명 문자열을 별도 체크리스트로 점검해야 한다.

넷째, git index.lock 충돌이 뜨면 블라인드 삭제 금지. 타 세션의 일시적 git 작업이었고 프로세스 확인 전에 지웠다면 더 큰 문제가 됐을 것이다.

마지막으로 파일명 규칙 자체를 잠갔다. biome의 useFilenamingConvention을 켜고 filenameCases를 kebab으로, 위반 시 에러로 지정했다. 드리프트가 다시 자라도 이제는 린트가 잡는다.

검증 루프에서도 같은 패턴이 반복됐다

Playwright 라이브 스모크와 검증 루프에서도 배울 게 많았다.

React Aria Components 드로어는 Esc 닫힘이 신뢰된 이벤트만 받는다. evaluate 안에서 합성 KeyboardEvent를 dispatch해선 무시당하고 page.keyboard.press로 쏴야 닫혔다.

Next.js dev 오버레이는 로케이터 클릭을 가로채서 타임아웃을 낸다. 프로덕션 빌드에서는 재현되지 않는 dev 환경 아티팩트라 evaluate 안의 프로그래매틱 클릭으로 우회했다.

검증 루프 쪽이 더 인상적이었다. 검증자가 매 라운드 “문자 그대로 일치, 전부 확인” 같은 승인 보고를 쓰고는 정작 판정 도구를 호출하지 않고 종료했다. 4라운드 연속 같은 패턴이었다. 판정 미제출은 루프 종료 조건을 채우지 못하니 그냥 재검증이 반복될 뿐이다. 테스크 첫머리와 끝에 판정 제출 의무를 명문화하고 나서야 5라운드 만에 공식 판정이 기록됐다.

보고서를 쓰는 지시만으로는 부족하고 도구 호출 의무를 명시해야 한다는 결론이다. 사람 조직에서도 똑같이 벌어질 것 같은 일이라 메모로 남겨둔다.

다음 생각

이번 작업의 수확은 구조 자체보다 판정 기준을 코드가 내리도록 옮긴 것이다. “어디가 폴더가 되어야 하는가”라는 질문은 이제 grep이 대답한다.

앞으로 남은 건 검증된 경계가 실제 코드와 계속 일치하는지를 지키는 일이다. 드리프트가 쌓이면 소비자 조사를 다시 돌리고 규칙이 어긋나면 린트가 막는다. 구조 결정을 사람의 감에서 도구의 판정으로 옮기는 순간, 유지 비용이 검사 비용으로 바뀐다. 검사는 싸다. 그래서 이렇게 정리해둔다.