항공 프론트엔드 구축기 (10/10): 폴더 이름 앞의 기호 하나
2026년 9월 10일
원문에서 보기 ↗글. 정용욱(Eric) / 서비스웹개발팀
항공 프론트엔드 구축기: 폴더 이름 앞의 기호 하나
안녕하세요. 서비스웹개발팀의 에릭입니다.
마지막 편입니다. 폴더 이름 앞의 기호 두 개가 어떻게 규칙이 됐는지 이야기합니다. 그리고 1편에 적어 둔 걱정 다섯 가지에 답하면서 시리즈를 마무리하겠습니다.
이 파일 지워도 되나
해외선 예약 상세 화면을 만들다 보면 파일이 수십 개가 됩니다. 그중 하나를 지우려고 합니다.
폴더가 이렇게 생겼다고 해보겠습니다.
sections/
├── FlightInfo.tsx
├── PassengerInfo.tsx
├── PaymentInfo.tsx
├── RequestCancel.tsx
├── CancelPopup.tsx
├── RefundView.tsx
├── SelectCancelPassenger.tsx
└── …
SelectCancelPassenger.tsx를 지워도 되는지 알려면 어디서 쓰는지 찾아봐야 합니다. 찾으면 나오긴 합니다.
문제는 매번 찾아야 한다는 것입니다.
파일 이름은 그게 무엇인지를 말해줍니다. 그런데 어디까지 영향을 주는지는 말해주지 않습니다. 이 팝업에서만 쓰는 건지, 화면 전체가 쓰는 건지, 다른 화면도 가져다 쓰는 건지가 이름에 없습니다.
화면이 커지고 만드는 사람이 늘수록 이 질문이 잦아집니다. 그리고 답을 아는 사람이 매번 다릅니다.
같은 질문이 반대 방향으로도 나옵니다. 비슷한 게 필요해서 만들려는데, 이미 있는 걸 가져다 써도 되는지가 안 보입니다. 그래서 각자 하나씩 더 만듭니다.
둘 다 파일이 어디까지 영향을 주는지가 이름에 없어서 생기는 일입니다.
제대로 해보려고 했습니다
FSD(Feature-Sliced Design)를 도입하자는 이야기가 나왔습니다. 서비스 개발을 시작하면서 동료 한 명이 꺼낸 이야기였습니다.
여럿이 함께 만들면 구조가 필요해진다는 걸 짚은 제안이었습니다. 제가 느끼고는 있으면서 이름을 못 붙이고 있던 것에, 이미 이름이 있는 해법을 가져온 셈이었습니다.
FSD는 이 문제를 정면으로 답합니다. 코드를 레이어로 나누고, 위 레이어만 아래 레이어를 참조할 수 있게 방향을 강제합니다. 어디에 있는지가 곧 누가 쓸 수 있는지가 되고, 그 규칙은 lint로 검사할 수도 있습니다.
FSD의 레이어와 슬라이스와 세그먼트 구조도
Feature-Sliced Design 공식 문서(feature-sliced.design)의 구조도. 나누는 축이 셋이다.
필요한 게 그거였습니다. 이름에 없는 정보를 위치에 담는 것이요.
게다가 이미 자리 잡은 체계입니다. 이름이 정해져 있고 문서가 있고 쓰는 곳이 많으니, 새로 합류하는 사람에게 "우리 규칙"을 설명하는 대신 이름 하나를 말하면 됩니다. 직접 규칙을 만들면 그 설명을 매번 우리가 해야 합니다.
그런데 도입하지 못했습니다. 두 가지가 걸렸습니다.
하나, 경계를 그을 수 있는 상태가 아니었습니다. 위 그림에서 보이듯 나누는 축이 셋입니다. 레이어를 고르고, 그 안에서 슬라이스를 정하고, 다시 세그먼트로 나눕니다. 이름을 외우는 게 아니라 어디까지가 어디인지를 팀이 같이 정해야 쓸 수 있는 체계인데, 그 합의를 시작할 만큼 이해가 모여 있지 않았습니다. 저부터 그랬습니다.
둘, 새 파일을 만들 때마다 판단이 하나 붙습니다. 이건 entity인가 feature인가. FSD를 써 본 사람들이 공통으로 이야기하는 지점이고, 실제로 여기서 걸렸습니다.
일단 이 정도로 가기로 했습니다
시점도 걸렸습니다.
구조 이야기를 하기에 시작만큼 좋은 때는 없습니다. 만들어 둔 게 적을수록 옮길 것도 적으니까요. 그래서 그때 나온 게 맞는 제안이었습니다.
문제는 같은 때가 일정이 제일 급한 때이기도 하다는 것입니다. 배우고, 경계를 합의하고, 이미 잡아 둔 기반에 적용하는 일을 그때 하려면 다른 것을 미뤄야 합니다. 그리고 그 시점에 미룰 만한 게 없었습니다.
구조가 필요해지는 이유와 구조를 잡을 시간이 없는 이유가 같았습니다. 사람이 늘어서 필요해지는데, 사람이 느는 때가 제일 바쁩니다.
그래서 이야기를 나누고 이렇게 정했습니다. 일단 지금 하던 정도로 가고, 차후에 제대로 도입해보자.
정하고 간 일이라 누가 무엇을 놓친 건 없습니다. 다만 그 차후가 아직 안 왔습니다.
그 사이에 폴더는 계속 생겼습니다
"일단 이 정도"가 정확히 어디까지인지는 그때 정하지 않았습니다. 정할 만한 것도 아니었고요.
화면이 붙고 파일이 쌓이는 동안 폴더를 계속 갈랐고, 가르다 보니 손에 익은 방식이 생겼습니다. 어느 시점에 정한 게 아니라, 나중에 보니 전부 같은 모양이 되어 있었습니다.
남은 건 세 줄입니다.
shared/ 프로젝트 전체가 쓴다
_이름/ 이 계층 안에서 여럿이 나눠 쓴다
+이름/ 기호를 뗀 이름의 파일 전용
파일과 폴더가 짝을 이룹니다
세 번째가 이 규칙의 핵심입니다. 아까 그 폴더의 실제 모습입니다.
sections/
├── FlightInfo.tsx
├── +FlightInfo/
├── RequestCancel.tsx
├── +RequestCancel/
│ ├── CancelPopup.tsx
│ └── +CancelPopup/
├── BookingPolicy.tsx
├── CsCenter.tsx
└── RefundInfo.tsx
+RequestCancel에 든 건 RequestCancel.tsx 말고는 아무도 안 씁니다. 폴더 이름이 그걸 말하고 있습니다.
그래서 아까 질문에 답이 바로 나옵니다. +CancelPopup 안에 있는 것은 CancelPopup.tsx를 지울 때 폴더째 같이 지우면 됩니다. 찾아볼 필요가 없습니다.
짝이 없는 파일도 있습니다. BookingPolicy.tsx나 CsCenter.tsx처럼 하위로 더 쪼갤 게 없으면 폴더를 안 만듭니다. 폴더가 있다는 것 자체가 "여기 딸린 게 있다"는 표시입니다.
파일 이름만 있을 때와 짝을 이루는 폴더가 있을 때의 비교
위쪽에서는 지워도 되는지 알려면 찾아봐야 한다. 아래쪽에서는 옆에 붙은 폴더 이름이 이미 답을 말하고 있다.
화면마다 같은 이름이 반복됩니다
두 번째 기호인 _는 한 화면 안에서 여럿이 나눠 쓰는 것에 붙입니다. 화면 폴더를 열면 대체로 이 이름들이 나옵니다.
srp/
├── _hooks/ 이 화면이 쓰는 훅
├── _ui/ 표시용 컴포넌트
├── _widget/ 화면을 이루는 큰 덩어리
├── _server/ 서버에서 돌아가는 것
└── _types/
여기서 이 프로젝트의 성질이 하나 보입니다. 국내선과 해외선에 같은 이름의 폴더가 대칭으로 있습니다. 검색 결과 화면도, 예약 화면도, 예약 상세도 그렇습니다.
한 벌로 간다는 게 파일 안에서만 일어나는 일이 아니었습니다. 국내선을 만들던 사람이 해외선 폴더를 열면 어디에 뭐가 있는지 이미 압니다.
새 화면을 만들 때 폴더 이름을 정할 일도 없습니다. 옆 화면과 같이 두면 됩니다.
언제 위로 올리나
세 번째가 남았습니다. 여러 화면이 같이 쓰는 것은 프로젝트 루트의 shared로 올립니다. 올릴지 정하는 질문도 같습니다. 두 화면 이상이 쓰면 올리고, 아니면 그 화면 안에 둡니다.
실제로 올라와 있는 것들을 보면 기준이 보입니다. 선택한 항공편 카드, 약관 동의, 총 결제 금액, 결제 수단 목록. 전부 국내선과 해외선과 마이페이지가 함께 쓰는 것들입니다.
다만 올린 다음에 한 번 더 갈랐습니다.
shared/ui는 값을 받아서 그리기만 하는 것들입니다. 가격 한 줄, 약관 동의, 토글, 구분선. 부모가 주는 값으로 끝나고 바깥을 보지 않습니다.
shared/widget은 자기가 필요한 걸 스스로 챙기는 덩어리입니다. 규정 팝업은 규정 데이터를 직접 가져오고, 웹 헤더는 로그인 상태를 읽습니다.
이 차이가 쓸 수 있는 자리를 가릅니다. ui는 아무 데나 놓아도 되는데, widget은 자기가 읽는 것이 있는 자리에만 놓입니다. 없는 곳에 놓으면 빈 화면이 나오거나 터집니다.
지난 편에서 공용 컴포넌트는 데이터가 있을 수도 없을 수도 있으니 prop으로 받는 게 맞다고 했습니다. widget은 그 반대를 택한 쪽이고, 쓰기 편한 대신 놓을 자리가 줄어듭니다.
화면 안의 _ui와 _widget도 같은 기준으로 갈립니다. 범위만 다르고 묻는 건 같습니다.
화면이 갈라지면 폴더도 갈라집니다
해외선 예약 상세는 사실 두 개의 화면입니다. 결제가 남아 있으면 승객 확인, 예약 확인, 결제 요청을 차례로 밟게 하고, 이미 끝난 예약이면 내역만 보여줍니다.
화면 파일에는 이 갈래가 한 줄로 있습니다.
{isStepView ? <StepView /> : <OnlyDetailView />}
폴더도 같이 갈라져 있습니다.
_widget/
├── StepView.tsx
├── +StepView/ 단계를 밟는 쪽
├── OnlyDetailView.tsx
├── +OnlyDetailView/ 내역만 보는 쪽
└── _shared/ 양쪽이 같이 쓰는 것
그런데 항공편 정보, 승객 목록, 결제 내역, 취소 요청은 어느 쪽에서든 보여줘야 합니다. 그래서 _shared로 나와 있습니다. 앞에서 본 sections 폴더가 여기 들어 있는 그것입니다.
폴더만 봐도 이 화면이 어떻게 생겼는지 대강 압니다. 두 갈래가 있고, 공통으로 쓰는 조각들이 있고, 각 갈래 전용이 따로 있다는 것까지요.
어느 깊이에서든 같은 뜻입니다
이 규칙이 배우기 쉬웠던 이유는 하나입니다. 재귀적입니다.
_widget/_shared/sections/+RequestCancel/_hooks/
│ │ │ └ +RequestCancel 안에서 여럿이 쓴다
│ │ └ RequestCancel.tsx 전용
│ └ _widget 안에서 여럿이 나눠 쓴다
└ 이 화면 안에서 여럿이 나눠 쓴다
네 단계 아래에서도 기호의 뜻이 같습니다. 한 번 배우면 어디서든 읽힙니다.
깊어지는 것도 그대로 뒀습니다. 제일 깊은 자리를 따라가 보면 화면 구조가 그대로 나옵니다.
파일과 짝 폴더가 한 칸씩 들어갈 때마다 화면도 한 겹씩 겹치는 모습
파일이 화면 한 겹을 그리고, 그 짝 폴더가 다음 겹을 담는다.
깊은 건 폴더가 아니라 화면입니다. 섹션 안에 팝업이 뜨고, 팝업 안에 화면이 있고, 그 안에 확인 단계가 있습니다. 실제로 그렇게 겹쳐 있으니 폴더도 그만큼 들어갑니다.
깊어서 못 찾겠다 싶으면 방법이 규칙 안에 있습니다. 팝업 안에서 화면 둘이 같이 쓰는 게 생기면 _를 붙여 한 단계 위로 올립니다.
+CancelPopup/
├── CancelView.tsx
├── RefundView.tsx
└── _shared/
└── SelectCancelPassenger.tsx 둘이 나눠 쓴다
올리는 판단도 같은 질문 하나로 합니다. 누가 쓰나.
결국 묻는 게 하나입니다
FSD와 갈리는 지점이 여깁니다.
FSD는 레이어를 프로젝트 꼭대기에 한 벌 세웁니다. 모든 코드가 그중 하나에 속하고, 그래서 파일을 만들 때마다 이건 어느 레이어인가를 묻게 됩니다. 그 판단이 사람마다 갈리면 구조가 흐트러지니, 경계를 미리 합의해 두는 게 중요해집니다.
여기서는 그 이름들이 꼭대기에 서지 않고 화면 안으로 접혀 들어갔습니다. 그래서 남은 질문이 하나입니다.
누가 쓰나.
나만 쓰면 +, 이 계층에서 여럿이 쓰면 _, 전체가 쓰면 shared입니다. 종류로 나누는 게 아니라 범위로 나눕니다. 그리고 범위는 대체로 판단이 갈리지 않습니다. 지금 쓰고 있는 곳을 세어 보면 되니까요.
의존 방향을 강제하는 규칙도 따로 두지 않았습니다. 접두어가 방향을 대신 만듭니다. +가 붙은 폴더는 밖에서 들어올 자리가 없습니다.
9편이 성립한 이유
지난 편에서 예약 상세 화면의 데이터를 훅으로 꺼내 쓰는 이야기를 했습니다. 같은 훅을 화면 안 여든 곳에서 부르는데 서버 요청은 한 번만 나갑니다.
그게 성립하는 조건 두 개를 적었는데, 첫 번째가 이 컴포넌트들이 이 화면 전용이라는 것이었습니다.
전용이라는 걸 무엇이 보장하느냐를 지난 편에서는 안 적었습니다. 이 규칙이 보장합니다.
+가 붙어 있으면 밖에서 가져다 쓸 수가 없습니다. 그러니 그 안에서는 "예약 상세 데이터는 당연히 있다"를 전제로 깔아도 됩니다.
공용 컴포넌트였다면 못 하는 일입니다. 어디서 쓰일지 모르니 데이터가 있을 수도 없을 수도 있고, 그러면 없을 때를 매번 처리해야 합니다. 결국 값을 prop으로 받는 쪽이 맞게 됩니다.
전용이라는 사실이 코드를 짧게 만듭니다. 그리고 그 사실을 폴더 이름이 보증하고 있습니다.
두 편이 같은 질문을 서로 다른 것에 하고 있었습니다. 지난 편은 값 의 주인을 물었고, 이번 편은 파일의 주인을 물었습니다.
Vue2에서 넘어온 것입니다
- 접두는 이번에 만든 게 아닙니다.
IntlMypageDetail.vue
└ +IntlMypageDetail/
기존 Vue2 웹뷰에도 해외선 예약 상세 화면이 있었고, 거기서 이미 이렇게 쓰고 있었습니다. 지난 편에서 provide/inject 이야기를 한 그 화면입니다.
FSD를 미룬 자리에 새로운 게 들어온 게 아니라, 원래 쓰던 게 그대로 남았다고 하는 편이 정확합니다.
돌아보면 이런 대목이 시리즈에 유난히 많습니다. 백키 처리도, 헤더와 본문과 하단 버튼을 묶는 레이아웃도, 화면 상태를 나눠 갖는 방식도 Vue2 웹뷰에서 한 번씩 설계해 본 것들입니다. 새 구성을 빠르게 세울 수 있었던 건 같은 문제를 먼저 만나 봤기 때문이기도 합니다.
이름으로만 맺은 관계는 조용히 깨집니다
이 방식의 대가가 있습니다.
+Foo 폴더 옆에 Foo.tsx가 실제로 있는지 전부 세어 봤습니다. 서른아홉 개 중 서른일곱 개는 맞고, 두 개가 어긋나 있었습니다.
둘 다 같은 경우입니다. 파일 이름을 바꾸고 폴더 이름을 안 바꿨습니다. 한쪽은 컴포넌트에 접두어가 붙으면서, 한쪽은 취소에서 환불로 뜻이 바뀌면서 그렇게 됐습니다.
짝이라는 사실이 이름에만 있어서 그렇습니다. import는 경로로 걸리니 멀쩡히 돌아가고, 컴파일러도 lint도 아무 말을 안 합니다. 짝이 깨졌다는 걸 아는 건 사람뿐입니다.
FSD였다면 이건 lint가 잡았을 겁니다. 의존 방향을 도구로 검사할 수 있다는 게 그 체계의 장점이니까요.
다만 여기서도 못 하는 게 아니라 안 하고 있는 것입니다. +Foo 옆에 Foo 파일이 있는지 보는 건 방금 제가 셸 명령 한 줄로 한 일입니다. 규칙 자체는 기계가 볼 수 있는 모양인데, 아직 안 짰습니다.
FSD보다 낫다는 이야기가 아닙니다
여기까지 쓰고 보니 결론이 하나로 몰리는 것 같아서 분명히 해두려고 합니다.
이 방식이 FSD보다 낫다고 말할 근거가 저에게는 없습니다. FSD를 써 보고 비교한 게 아니라 제대로 안 써 봤기 때문입니다. 그때 판단한 건 "이게 낫다"가 아니라 "지금은 여기 시간을 못 쓴다"였습니다.
지난 편도 결과적으로 비슷한 모양이었습니다. 상태관리 라이브러리를 못 고른 채로 시간이 지났고, 만들다 보니 필요가 없었습니다. 다만 그쪽은 없어도 된다는 걸 확인했습니다. 화면을 다 만들고 나서 남는 자리가 없다는 걸 봤으니까요.
이쪽은 확인한 적이 없습니다. 일단 이대로 가기로 했고, 그 사이에 이게 규칙이 됐고, 지금은 불편한 데가 없습니다. 다만 불편하지 않다는 것과 이게 맞다는 것은 다른 이야기입니다.
지금이라면 다르게 볼 수도 있습니다. 새 화면을 AI로 만드는 일이 많아졌고, 파일마다 붙던 그 판단은 이제 대신 해 줍니다. 걸리던 것 중 하나가 없어진 셈입니다.
다만 걸린 건 둘이었습니다. 남은 하나는 어디까지가 어디인지를 팀이 정하는 일인데, 이건 대신 해 줄 수 있는 종류가 아닙니다. 오히려 배치를 대신 해 주면 합의가 없어도 구조가 서 있는 것처럼 보입니다.
그러면 흐트러져도 알아볼 사람이 없습니다. 저는 이해하지 못한 채로 쓰는 구조가 규칙이 아예 없는 것보다 나쁘다고 봅니다. 없으면 없는 줄이라도 압니다.
그래도 남은 게 하나 있습니다. 배워야 할 것이 레이어 여섯 개와 의존 방향에서 기호 두 개로 줄었다는 사실이요. 줄이려고 줄인 게 아니라 결과가 그랬습니다.
이 시리즈에서 지금까지 다룬 것들은 대체로 반대 방향이었습니다. 뒤로가기 규약도, 브릿지도, 인증도, 저만 알고 있던 것을 아무도 몰라도 되게 코드로 옮긴 결과물입니다. 하나씩 정해서 만든 것들이고요.
이번 것은 그렇지 않습니다. 만들면서 굳었고, 굳고 나서 이름이 붙었습니다. 시리즈에 넣을지 망설였던 이유이기도 한데, 그래도 넣기로 한 건 결과가 앞의 것들과 같은 자리에 도착했기 때문입니다. 설명해야 알던 것을 이제 이름이 말합니다.
그리고 그 기호 두 개가 처음의 질문에는 답을 합니다. 이 파일 지워도 되나. 이름을 보면 압니다.
읽는 쪽이 사람만은 아니었습니다
앞에서 새 화면을 AI로 만드는 일이 많아졌다고 했습니다. 그때 AI가 읽는 것 중에 폴더 이름도 있습니다. 어디에 두라고 일러주지 않아도 옆 폴더를 보고 자리를 잡습니다. 화면 전용인지 여럿이 쓰는 건지도 대체로 맞게 가릅니다.
AI를 염두에 두고 만든 규칙은 아닙니다. Vue2에서 이어진 것이니까요. 다만 판단 재료를 이름에 적어 둔 것이라 따로 설명할 게 남지 않았고, 그걸 읽는 게 사람인지 도구인지는 규칙 입장에서 상관이 없었습니다.
열 편을 닫으며
1편에 걱정 다섯 가지를 적어 뒀습니다. 마지막 편이니 하나씩 답하고 마치겠습니다.
- 개발 기간. 논의를 시작해서 오픈까지 6개월이 걸렸습니다. 서비스 개발이 3~4개월이고 나머지가 검증이었습니다. 4~5년에 걸쳐 쌓인 국내선과 해외선을 그 기간에 다시 만들었습니다.
- 앱과 연관된 기능. 오픈 뒤에 몇 건이 나왔습니다. 다만 화면마다 흩어져 있지 않고 브릿지 한 곳에 모여 있어서, 고칠 자리를 찾는 데는 시간이 안 들었습니다.
- 환경별 코드 차이를 없애는 준비. 2편부터 여기까지가 전부 그 이야기였습니다.
- App Router 러닝커브. 지금은 팀 전체가 이 위에서 개발하고 있고, 러닝커브가 문제로 남지는 않았습니다.
- 국내선 웹뷰의 Vue2. 1편에 적은 대로 다르게 풀렸습니다. 국내선 웹뷰도 같이 새로 만들기로 하면서 Vue2는 안 남았고, 대신 할 일이 늘었습니다.
다른 앱들이 붙고 있습니다
항공은 열 편 내내 이야기한 이 구성 그대로 오픈해서 지금 운영 중입니다. 그리고 같은 저장소에 앱이 몇 개 더 만들어지고 있습니다. 항공이 아닌 다른 신규 프로젝트들입니다.
그쪽들은 공용 라이브러리를 그대로 가져다 씁니다. 디자인 시스템도, 뒤로가기도, 레이아웃 컨테이너도, 브릿지와 인증도요.
항공을 만들 때는 이게 맞는 추상화인지 확인할 방법이 없었습니다. 쓰는 쪽이 하나뿐이었으니까요.
쓰는 쪽이 늘어야 알 수 있는 것이었고, 지금 그 확인이 진행 중입니다.
한 문장으로 시작한 일이었습니다
1편에서 목표를 이렇게 적었습니다. 감출 수 있는 건 감추고, 감출 수 없는 건 한 줄로 갈리게. 열 편은 그 한 문장을 풀어 쓴 기록입니다.
항공 프론트엔드 구축기, 열 편으로 마칩니다.
항공 프론트엔드 구축기 (10/10): 폴더 이름 앞의 기호 하나 was originally published in 여기어때 기술블로그 on Medium, where people are continuing the conversation by highlighting and responding to this story.