CROSS Blog 홈
기술프론트엔드

폴더 구조 규칙을 문서가 아니라 코드로 지키기로 했어요

components와 feature의 경계가 모호하다는 메모를 닫는 데 여섯 달이 걸렸어요. 규칙을 코드로 만들고, 800개 가까운 파일을 옮기고, 아무도 갚지 않은 위반 69건이 0이 되는 걸 지켜본 과정이에요.

프론트엔드 · · 13

어드민을 맡고 처음 한 일은 코드를 읽으면서 메모를 남기는 거였어요. 그 메모에 이런 줄이 있어요.

components/feature 의 불분명성 components/common 도 추상화가 어렵고 그룹핑하기에 모호한 점이 다수 존재

구조가 나쁘다는 얘기는 아니었어요. 폴더는 잘 나뉘어 있었거든요. 다만 어떤 파일을 어디에 둘지 판단할 기준이 없었어요. 새 컴포넌트를 만들 때마다 components인지 feature인지 잠깐 고민하고, 대충 정하고, 다음 사람이 또 고민하는 식이었죠.

이 메모를 닫는 데 여섯 달이 걸렸어요. 솔직히 말하면 그 여섯 달 대부분은 아무것도 안 했어요. 문제를 알면서 화면을 계속 쌓았죠. 구조를 실제로 정리한 건 마지막 며칠이고요.

그래서 이 글은 그 여섯 달이 왜 그렇게 흘렀는지, 그리고 마지막에 뭐가 그걸 움직였는지에 대한 이야기예요.

문서로 정한 규칙은 지켜지지 않아요

처음엔 당연히 문서를 떠올렸어요. 폴더별 역할을 정의하고, 어디에 뭘 두는지 적고, 팀에 공유하는 것.

그런데 문서로 정한 규칙에는 구조적인 문제가 있어요. 위반해도 아무 일이 안 일어나거든요. 리뷰어가 보면 잡히고 못 보면 통과해요. 급하면 다들 통과시키고요. 그렇게 한 번 통과한 예외는 다음 사람에게 선례가 돼요.

더 나쁜 건 문서가 현실과 어긋나기 시작하면 아무도 안 읽는다는 거예요. 문서엔 "화면 간 공유는 feature로"라고 적혀 있는데 코드는 그렇지 않으면, 문서를 고칠 사람도 코드를 고칠 사람도 없어요. 규칙이 죽는 방식이죠.

그래서 방향을 바꿨어요. 읽는 규칙 말고 실행되는 규칙으로.

폴더 이름이 아니라 참조 방향

먼저 정할 게 있었어요. 뭘 검사할 것인가.

"이 파일은 components에 있어야 한다"는 검사할 수 없어요. 판단이 필요하니까요. 하지만 이건 검사할 수 있어요. 아래 계층이 위 계층을 참조하면 안 된다.

계층을 다섯으로 나눴어요. pagesdomain·featurecomponents·hooksshared·constants·typesapi. 그리고 dependency-cruiser로 옮겼습니다.

{
  name: 'components-no-upward',
  comment:
    'components/ hooks/ 는 L3 계층입니다. domain/feature/pages 를 참조할 수 없습니다.',
  severity: 'error',
  from: { path: '^src/(components|hooks)/' },
  to: {
    path: '^src/(domain|feature|pages)/',
    dependencyTypesNot: ['type-only'],
  },
}

이 규칙의 좋은 점은 판단이 필요 없다는 거예요. components 안의 파일이 domain을 import 했다면 그냥 위반이에요. 논쟁할 게 없어요. 그 파일이 진짜 공용인지 사람이 판정하지 않아도 되고요. 공용이면 애초에 domain을 몰라야 하니까.

type-only import는 뺐어요. 타입만 가져오는 건 런타임 결합이 아니라 계층을 무너뜨리지 않거든요. 여기까지 막으면 규칙이 과해지고, 과해지면 사람들이 우회로를 찾기 시작해요.

같은 식으로 순환 참조 금지, 페이지끼리 직접 참조 금지, 다른 도메인은 배럴로만 참조하기를 넣었습니다.

규칙을 켜는 건 쉬웠어요

켜자마자 위반이 69건 나왔어요. 기존 건 baseline에 기록하고 새로 생기는 것만 막았습니다. pre-push 훅에 걸었고요.

여기까지가 6월이에요. 그리고 이 상태로 두 달을 보냈어요.

규칙이 있으면 새 위반은 안 들어와요. 근데 기존 구조가 규칙을 지킬 수 없는 모양이면, 규칙은 방어만 할 뿐 아무것도 개선하지 않아요. 깃에서 폴더별 파일 수를 월별로 세어 보면 그게 그대로 보입니다.

시점 pages feature domain
2월 224 19 0
3월 394 39 0
4월 552 38 53
5월 822 38 91
6월 918 38 105
7월 918 54 111
8월 초 969 67 114
8월 중순 198 180 935

2월부터 6월까지 pages가 224개에서 918개로 네 배가 됐어요. 화면을 빠르게 쌓던 시기죠. domain은 4월에 생겼는데 105개에서 멈춰 있고요. 새로 만드는 부품이 domain이 아니라 화면 옆으로 갔다는 뜻이에요.

규칙을 켠 6월 이후를 보면 더 분명해요. 8월 초까지 pages가 918개에서 969개로 오히려 늘었어요. 계층 규칙은 참조 방향만 보거든요. 화면 옆에 부품을 쌓는 건 계층 위반이 아니에요. 규칙은 통과하는데 구조는 계속 나빠진 거죠. 그 사이 pages/ 아래 코로케이션 폴더가 263개까지 쌓였어요.

이 표에서 제일 정직하게 읽어야 할 부분이 6월에서 8월 초 구간이에요. 규칙을 켜놓고도 저희는 코로케이션을 그대로 뒀어요. 규칙이 그걸 안 막아서이기도 하지만, 더 큰 이유는 따로 있어요. 정리할 이유가 없었거든요. 화면은 계속 나가야 했고, 구조는 불편해도 돌아가고 있었으니까요. 불편은 비용으로 계산되지 않아요.

계기는 두 번째 앱이었어요

7월에 두 번째 관리자 앱을 만들기 시작했어요. 새 앱이라 기존 부채가 없으니 처음부터 3층 구조로 갔죠. pages는 라우트만 잇고, 화면 조립은 feature, 재사용 부품은 domain이 갖는 구조로요.

7월 중순엔 그쪽도 pages 56개뿐이었어요. 2주 만에 이렇게 됐고요.

pages feature domain
새 앱 (8월 초) 100 48 220
기존 어드민 (8월 초) 969 67 114

같은 팀이 두 앱을 오가는데 구조가 이만큼 다르면, 그때부터는 불편이 비용이 돼요. 화면 하나 만들 때마다 "이 앱은 어느 쪽이었지"를 먼저 떠올려야 하니까요.

그리고 새 앱에서 그 구조가 실제로 굴러가는 걸 두 주 동안 봤어요. 규칙이 종이 위에 있을 땐 옳은지 알 수 없었는데, 돌아가는 앱이 하나 생기니 판단이 쉬워지더군요. 그래서 이관이 "좋은 구조를 설계하는 일"이 아니라 "검증된 쪽에 맞추는 일"이 됐어요. 여섯 달 동안 안 움직이던 게 며칠 만에 움직인 진짜 이유예요.

옮기기 전에 알아야 했던 것들

그렇다고 바로 옮기지는 않았어요. 먼저 코드를 재봤는데 세 가지가 나왔습니다.

pages 내부 상대 import가 527건이었어요. 그중 ../../로 시작하는 게 232건. 폴더가 움직이면 이게 전부 깨져요. 그래서 이관 전에 절대경로로 정규화하는 작업을 먼저 했어요. 안 했으면 파일 이동과 import 수정이 한 diff에 섞여서 리뷰가 불가능해졌을 거예요.

앱 밖에서 pages 안쪽을 당겨 쓰는 곳도 있었어요. 테스트 파일이 @/pages/**/_schema를 27건 import 하고 있었거든요. 폴더가 움직이면 같이 고쳐야 하니 미리 세어 뒀습니다.

세 번째는 좀 반가운 발견이었어요. 버킷 컨벤션이 이미 같더라고요. (table) 114개, (dialog) 36개, (filter) 31개, (panel) 21개가 이미 그 이름으로 존재했어요. 바꿔야 하는 건 버킷이 아니라 버킷이 매달린 위치뿐이었던 거죠. 이걸 미리 확인한 덕분에 이관이 "전부 다시 짜기"가 아니라 "옮기기"로 줄었어요.

그리고 도메인 경계 지도를 그렸어요. 업무 area 19개를 정하고 각 area 아래 어떤 도메인이 오는지 표로 만들었습니다. 여기서 정한 원칙 하나가 나중에 계속 쓰였어요. 부품의 소속은 "어디서 쓰는가"가 아니라 "무엇에 대한 것인가"로 정한다.

최악의 화면은 이렇게 생겼어요

제일 큰 덩어리가 고객 목록·상세였어요. 한 폴더 아래 97파일 16,516줄. 제일 큰 파일은 666줄이었고요.

pages/users/manage/
├── index.tsx                    ← 목록 화면
├── [idx].tsx          255 LOC   ← 상세 화면
├── _constants/  _hooks/  _utils/
└── _components/                 ★ 91 파일
    ├── __tests__/
    ├── _schema/       (4)
    ├── (filter)/      (1)       ← 목록 화면용
    ├── (table)/       (1)       ← 목록 화면용
    ├── (tabs)/        (9)       일반 666 · 계좌 388 · 거래 282 · 개인정보 267
    │   └── _fields/   (4)       457 · 522 · 493 · 337
    ├── (sidebar)/     (5)
    ├── (nav)/         (18)      ← 전부 다른 업무 명사
    ├── (panel)/       (22)
    ├── (dialogs)/     (4)
    ├── (items)/       (3)
    └── (detail)/      (2)

문제는 파일 수가 아니에요. _ 접두사가 이미 거짓말이라는 점이죠.

_components는 "이 페이지 전용"이라는 뜻인데, 목록 화면과 상세 화면이 그걸 함께 쓰고 있었어요. 이미 두 화면짜리 공용 폴더인 거예요. 안쪽 파일끼리 절대경로로 서로를 당겨 쓰기도 했고, _schema는 바깥의 테스트가 import 하고 있었고요. 코로케이션이 아니라 그냥 이름만 _가 붙은 공개 모듈이었습니다.

그리고 (nav) 아래 18개 섹션은 전부 다른 업무 명사예요. 로그, 이력, 계좌, 메시지… 이것들이 한 폴더에 있는 이유는 하나뿐이었어요. 같은 화면에서 쓰이니까.

소유를 "무엇에 대한 것인가"로 다시 정했어요

앞서 정한 원칙을 대면 답이 나와요. 부품의 소속은 어디서 쓰는지가 아니라 무엇에 대한 것인지로 정한다. 화면이 여러 명사를 걸치면 그건 도메인이 아니라 조립이고요.

그래서 97개 파일을 13개 도메인으로 갈랐어요. 계좌 탭 부품은 계좌 도메인으로, 거래 이력 섹션은 거래 도메인으로, 본인확인 카드는 고객 도메인으로. 도메인과 무관한 것들, 그러니까 테이블 셀 스타일이나 아이콘 버튼 같은 건 공용 컴포넌트로 올라갔고요.

결과는 이렇게 됐습니다.

pages/users/manage/
├── index.tsx    3 LOC   → export { default } from '@/feature/user/profile/user-list-page'
└── [idx].tsx    3 LOC   → export { default } from '@/feature/user/profile/user-detail-page'

feature/user/profile/
├── user-list-page.tsx           ← 조립: user/profile 부품만 사용
└── user-detail-page.tsx         ← 조립: 13개 도메인에서 필요한 것만 골라 옴
                                   (탭 셸·사이드바 레이아웃·라우팅만 담당)

domain/user/profile/
├── schema/  hooks/  utils/  constants.ts
└── ui/
    ├── list/(table)/            목록 컬럼·필터
    └── detail/(section)/        헤더·내비·탭·필드
        (panel)/                 편집 패널들

라우트 파일이 3줄이 됐어요. 화면을 여는 일만 합니다. 조립은 feature가 하고, 부품은 각 도메인이 갖고요.

"화면이 여러 명사를 걸치면 그건 domain이 아니라 feature다"라는 원칙이 실제로 성립하는지 확인하는 자리이기도 했는데, 성립했어요.

섹션 단위로 끊어서 옮겼어요

전체를 한 번에 옮기진 않았어요. 섹션 단위로 끊었습니다.

단계 내용 규모
0 규칙 문서 신설 (area 목록·과도기 규칙) 코드 0
1 코로케이션 차단 ESLint 규칙 2개 + baseline 설정
2 잘못된 층에 있던 것 재배치 110 파일
3 섹션 2개 · 16화면 33 파일
4 섹션 3개 · 40화면 316 파일
5 섹션 3개 · 32화면 174 파일
6 섹션 1개 · 14화면 (상세 97파일 분해 포함) 159 파일
+ 계획에 없던 잔여 13섹션 · 53화면

끊은 이유는 진행 중인 기능 브랜치와의 충돌 때문이에요. 이관은 폴더를 통째로 움직이니까 충돌 범위가 크거든요. 짧게 끊어서 자주 머지하는 것 말곤 뾰족한 수가 없었어요. 이건 지금도 좋은 방법을 못 찾았습니다.

그리고 단계 1이 중요했어요. 옮기는 동안에도 새 화면은 계속 만들어지고, 새 화면은 기존 관례를 따라 또 _ 폴더를 파거든요. 정리하는 속도보다 쌓이는 속도가 빠르면 영원히 안 끝나요. 그래서 새 _ 폴더 생성을 막는 커스텀 ESLint 규칙을 먼저 켜고 시작했습니다.

여기서 baseline 단위를 폴더로 잡은 게 도움이 됐어요. 기존 _ 폴더 안에 파일을 추가하는 건 허용해요. 이관이 안 끝난 화면을 손볼 때 그 화면의 현재 구조를 따르는 건 자연스러우니까요. 막는 건 새 폴더를 만드는 것뿐. 규칙이 과하면 사람들이 우회하거나 무시하기 시작하는데, 그러면 있으나 마나예요.

69건은 아무도 갚지 않았는데 0이 됐어요

이관 첫 단계를 마치고 baseline을 다시 만들어 봤더니 0건이 나왔어요. 69건을 고친 사람은 없었는데요.

기록 당시엔 전부 실제 위반이었어요. 그런데 두 달 사이에 화면이 개편되면서 그 코드들이 쓰이지 않게 된 거예요. components-no-upward 11건은 공용 셸의 헤더 파일을 가리키고 있었는데, 그 사이 셸이 다른 위치로 옮겨 가면서 파일 자체가 없어졌더라고요. 나머지도 비슷했고요. 갚은 게 아니라 제품이 바뀌면서 같이 사라진 거죠.

이게 baseline 전략에 대해 뭘 말해 주냐면요. 두 달 동안 저희는 기존 위반을 고치는 데 시간을 한 번도 안 썼어요. 그런데도 0이 됐고요. 규칙이 새 위반의 유입을 막고 있는 동안, 기존 위반은 정상적인 제품 변화에 밀려 자연히 줄어든 거예요. 부채를 갚는 작업을 따로 잡지 않아도 되는 종류가 꽤 있다는 뜻이기도 하고요.

대신 이런 것도 알게 됐어요. baseline의 항목 수는 지금 남아 있는 부채의 크기가 아니라는 것. 만든 시점의 스냅샷일 뿐인데 "무시할 목록"이라 아무도 열어 보지 않아요. 그래서 실제보다 부풀려진 숫자를 두 달이나 들고 있었어요. 69건이 부담스러워 보여서 정리를 더 미뤘을 수도 있고요.

지금은 이관 단계마다 baseline을 다시 만들어요. 숫자가 줄어드는 걸 확인하는 용도이기도 하고, 그 숫자가 진짜인지 확인하는 용도이기도 합니다.

지금은 둘 다 0이에요

규칙 도입 시점 현재
계층·순환참조 위반 69건 0건
pages 코로케이션 폴더 263개 0개

baseline 파일이 비어 있으니 이제 위반이 생기면 유예 없이 바로 막혀요.

돌아보면

componentsfeature의 경계가 모호했던 진짜 이유는 이름이 비슷해서가 아니었어요. 둘의 관계를 규정하지 않아서였죠. "공용이란 무엇인가"는 논쟁이 되지만 "공용 코드는 업무 코드를 몰라야 한다"는 검사할 수 있어요. 후자를 정하면 전자는 저절로 따라오더군요.

그리고 규칙을 켜는 것과 규칙을 지킬 수 있게 만드는 건 완전히 다른 일이었어요. 규칙은 하루면 켜요. 그 뒤 두 달 동안 구조는 그대로였고, 규칙은 새 위반만 막고 있었어요. 실제로 나아진 건 800개 가까운 파일을 옮기고 나서고요. 규칙이 정리를 대신해 주지는 않아요. 다만 정리하는 동안 뒤에서 쌓이는 걸 막아 주죠. 그 덕분에 끝이 있었어요.

제일 크게 배운 건 이거예요. 문제를 안다고 고쳐지지 않아요. 고칠 이유가 생겨야 고쳐져요. 저희는 연초부터 알고 있었고 6월엔 규칙까지 켰는데도 두 달을 그대로 뒀어요. 불편은 비용으로 계산되지 않으니까요. 실제로 움직인 건 두 번째 앱이 생겨서 구조가 갈라졌을 때예요. 그때 처음으로 정리하지 않는 쪽이 더 비쌌거든요. 구조 개선을 미루고 있다면, 설득할 논리를 다듬기보다 비용이 드러나는 시점을 찾는 게 빠를 수도 있어요.

비슷한 맥락에서, 종이 위의 구조와 돌아가는 구조는 설득력이 달라요. 새 앱에서 3층 구조가 두 주 동안 굴러가는 걸 보고 나니 판단이 쉬워졌어요. 큰 구조 변경을 앞두고 있다면 기존 코드에서 논쟁하는 것보다 새로 만드는 곳에서 먼저 해 보는 게 나을 수 있습니다.

마지막으로 두 가지. 옮기기 전에 재는 시간이 옮기는 시간을 줄여요. 상대 import 527건, 이미 새 나가 있던 _ 스키마 27건, 이미 같았던 버킷 이름들. 이걸 먼저 확인한 덕분에 실제 이관이 며칠로 끝났어요. 그리고 baseline을 만들었으면 주기적으로 다시 만들어 봐야 해요. 저희는 69건인 줄 알고 두 달을 보냈는데 열어 보니 0이었거든요. 남은 부채를 실제보다 크게 알고 있으면 정리를 시작할 결심도 그만큼 늦어져요.

도움이 되었다면 눌러주세요

댓글 0

첫 댓글을 남겨보세요.