Header는 components/ 루트에 있고 Footer는 components/sections/ 안에 있다. 여기서 Header·Footer처럼 화면의 한 조각을 재사용할 수 있게 만들어 둔 것을 컴포넌트라고 부른다. 같은 레이아웃 조각인데 사는 곳이 다르다.
안 A 폴더 이름으로 가른다. 레이아웃은 layout/, 섹션은 sections/
안 B 붙이는 자리로 가른다. layout 이 그리는 것과 페이지가 그리는 것
처음엔 폴더를 정리하는 문제로 보였다. 그런데 세어 보니 폴더가 갈린 건 원인이 아니라 결과였다.
먼저: 이미 9곳에서 푸터가 빠져 있었다
이건 원칙이 아니라 이 표준을 급하게 만든 사실이다.
라우트(주소와 그 주소에서 보여줄 화면을 짝지어 둔 것) 13개를 하나씩 열어 셌다.
Header 13 / 13 곳 layout.js 가 그린다
Footer 4 / 13 곳 페이지가 손으로 import 한다
MobileCtaBar 12 / 13 곳 페이지가 손으로 import 한다
여기서 layout.js는 Next.js의 App Router가 정해 둔 특수한 파일 이름이다. 폴더 안에 이 파일을 두면 Next.js가 그 폴더 밑의 모든 페이지를 자동으로 이 파일로 감싼다 — 페이지마다 따로 불러올 필요가 없다. 반대로 Footer처럼 페이지가 직접 import해야 붙는 컴포넌트는 깜빡하면 빠진다. MobileCtaBar의 CTA는 “Call To Action”을 줄인 말로, “상담 신청하기”처럼 방문자가 다음에 눌러야 할 행동을 눈에 띄게 보여주는 버튼이나 바를 가리킨다.
푸터가 없는 9곳이다.
/faq /faq/[id] /inquiries /inquiries/detail /inquiries/new
/privacy /reviews /reviews/[id] /terms
여기서 [id]처럼 대괄호로 감싼 이름은 주소의 그 자리에 매번 다른 값이 들어간다는 Next.js의 표시다 — /faq/[id]는 /faq/3처럼 번호가 바뀌어도 같은 페이지 하나가 그 번호에 맞는 내용을 보여준다는 뜻이다.
/privacy와 /terms에도 없다. 회사 정보와 약관 링크가 푸터에 있는데 그 페이지들에서 빠졌다. 전부 누락이지 의도가 아니다.
원인은 폴더가 아니라 붙이는 방식이었다.
새 페이지를 만든다
→ Header 는 자동으로 붙는다 layout 이 그리므로
→ Footer 는 안 붙는다 페이지가 손으로 import 해야 하므로
→ 까먹으면 그 페이지만 푸터가 없다
Header가 components/ 루트에 있고 Footer가 sections/에 들어간 것도 그래서다. 붙이는 자리가 다르니 사는 곳도 갈렸다.
먼저: 정적 export라 서버가 화면 크기를 모른다
이 제약이 뒤의 원칙 5를 미리 정했다.
여기서 정적 export란 Next.js가 빌드할 때 모든 페이지를 미리 HTML 파일로 만들어 두는 방식이다. 방문자는 매번 새로 만들어지는 화면이 아니라 이미 만들어진 파일을 그대로 받는다.
lib/api.js 주석에 적혀 있다.
이 사이트는 정적 export(
output: 'export') 라 Next 서버가 없습니다.
빌드 때 HTML을 만들어 모든 기기에 같은 것을 보낸다. 서버가 User-Agent(브라우저가 요청을 보낼 때 자기가 어떤 기기·브라우저인지 적어 보내는 요청 헤더다)도 화면 크기도 모른다. 그래서 기기별로 다른 화면을 주는 방법이 CSS 뿐이다.
원칙 1. 붙이는 자리로 가른다 — 폴더 이름으로 가르지 않는다
무엇을 기준으로 컴포넌트를 나누는가.
안 A — 성격으로 가른다. 「레이아웃」·「섹션」·「도메인」·「유틸」로 이름 붙여 폴더를 나눈다. 얻는 것은 폴더 이름만 보고 무엇인지 아는 것이다.
버린 이유는 성격이 경계선에서 흐려진다는 것이다. MobileCtaBar는 레이아웃인가 섹션인가. InquiryStatusBadge는 도메인인가 UI인가. 판단이 필요한 기준은 사람마다 다르게 답하고, 실제로 Header와 Footer가 그렇게 갈렸다.
골랐다 — 안 B, 「누가 그리는가」로 가른다.
layout 이 그린다 → 페이지는 존재를 모른다
그 화면 갈래 안에서만 쓴다 → 그 갈래의 일부다
갈래 밖에서도 쓴다 → 공용 조각이다
여기서 화면 갈래는 /about·/faq처럼 성격이 비슷한 라우트(주소)들을 묶은 단위를 가리킨다. 구체적으로 어떻게 나누는지는 원칙 2와 원칙 4에서 다룬다.
안 A가 못 지켜주는 것은 답이 코드에 이미 있다는 것이다. 「이건 레이아웃인가」는 의견이지만 「layout.js 가 이걸 그리는가」는 파일을 열면 나온다. 세 갈래 어디에도 안 걸리는 컴포넌트가 없다.
그리고 이 기준이 푸터 누락을 없앤다. 「모든 페이지에 뜬다」가 곧 「layout 이 그린다」이므로, 페이지가 붙일 일이 없어 빠질 자리도 없다.
원칙 2. 자리는 셋이고, 페이지 전용은 그 페이지 옆에 둔다
세 갈래를 어디에 두는가.
| 갈래 | 어디에 | 이 프로젝트의 예 |
|---|---|---|
| layout 이 그린다 | components/layout/ |
Header · Footer · SocialRail · GoogleAnalytics |
| 그 화면 갈래 안에서만 쓴다 | app/<갈래>/_components/ |
Hero · WhyUs · Process (홈) · AccountForm (계정 화면) |
| 갈래 밖에서도 쓴다 | components/ |
ReviewCard · ImagePlaceholder · icons · AdminSelect |
여기서 app/는 Next.js의 App Router가 라우팅 전용으로 쓰는 특별한 폴더다. 이 폴더 밑의 폴더 구조가 그대로 주소가 된다 — app/about을 만들면 /about 주소가 생기는 식이다.
두 번째가 app/ 안으로 들어가는 것이 Next.js 가 권하는 방식이다. 밑줄로 시작하는 폴더는 라우팅에서 빠진다.
Private folders can be created by prefixing a folder with an underscore:
_folderName… opting the folder and all its subfolders out of routing.app/blog/_components/Post.tsx— Not routable; safe place for UI utilities
실측이 이 셋과 정확히 맞았다. sections/ 의 여덟(Hero·WhyUs·Process·ServiceIntro·Reviews·ContactCTA·BottomCTA·Footer)은 Footer 하나만 빼고 전부 홈 한 곳에서만 쓰인다. Footer 가 거기 섞여 있던 것이 어긋남이었다.
대가는 페이지 전용이 여러 곳으로 흩어지는 것이다. 지금은 sections/ 한 폴더에 모여 있어 한눈에 보이는데, 라우트마다 _components/ 가 생기면 전체 목록을 볼 수 없다. 그래도 그쪽을 고른 이유는 「이게 어디서 쓰이지」를 찾는 일이 「전체 목록을 보는」 일보다 훨씬 잦기 때문이다. 페이지 옆에 있으면 그 질문에 답이 필요 없다.
원칙 3. 공용인지는 개수가 아니라 갈래 밖에서 쓰이는지로 묻는다
「몇 곳에서 쓰나」로 묻지 않는다. admin 을 재보니 AccountForm · CaregiverForm · ReviewForm 이 전부 2곳에서 쓰인다 — 만들기 화면과 고치기 화면이 같은 폼을 쓰기 때문이다.
/accounts/new 계정 만들기 ┐
/accounts/[id]/edit 계정 고치기 ┘ 둘 다 AccountForm 을 쓴다
개수로 물으면 이건 「여러 곳」이라 공용으로 간다. 그런데 계정 화면 밖에서는 아무도 안 쓴다. 공용 폴더에 넣으면 「아무 데서나 쓰는 것」과 「계정 화면에서만 쓰는 것」이 섞이고, 지금 components/ 가 잡동사니가 된 그 과정이 다시 시작된다.
그래서 _components/ 는 페이지 하나가 아니라 갈래에 딸린다. Next.js 문서의 예시(app/blog/_components/Post.tsx)도 블로그 갈래 전체가 쓰는 것이라는 뜻이다. 여기서 .tsx는 TypeScript로 JSX를 쓸 수 있게 한 확장자다. 자바의 .java처럼 파일 종류를 나타내는 확장자이고, 보통 파일 하나가 컴포넌트 하나를 담는다.
app/(dashboard)/accounts/
_components/AccountForm.js ← 이 갈래 둘이 같이 쓴다. 밖에서는 안 쓴다
new/page.js
[id]/edit/page.js
올리기만 하고 내리지 않는다. 두 번째 갈래에서 쓰게 되면 그때 components/ 로 올린다. 반대로 「일단 공용에 두고 안 쓰이면 내린다」는 안은 버렸다 — 내리는 일은 급하지 않아 아무도 안 하고, 공용 폴더만 계속 커진다. 올릴 때는 반대로 이유가 눈앞에 생긴다: 다른 화면에서 ../../accounts/_components/ 를 import 하려는 순간 그 경로 자체가 「여기 있으면 안 된다」는 신호다.
내리는 규칙은 정하지 않는다. 「안 쓰이게 됐는지」를 알려면 매번 세어봐야 하는데 그건 아무도 안 한다. 올리는 쪽만 규칙으로 두면 잘못 올라간 것이 있어도 더 늘지는 않는다.
원칙 4. 성격이 같은 무리는 route group 으로 layout 을 나눈다
MobileCtaBar 는 13곳 중 12곳에 있고 /inquiries/new 하나에만 없다. 문의 작성 폼에서 하단 고정 바가 입력을 가려 뺀 것이고, 의도한 것이다.
그러면 원칙 1이 걸린다 — 「모든 페이지에 뜨는 것은 layout 이 그린다」인데 하나만 예외다.
안 A — layout 에 넣고 그 페이지에서 숨긴다. MobileCtaBar 안에서 usePathname()(지금 페이지의 주소를 돌려주는 React 훅이다)으로 경로를 보고 특정 경로면 null 을 돌려준다. 버린 이유는 컴포넌트가 자기가 어디 뜨는지 알게 되는 것이다. 그 목록을 페이지가 늘 때마다 고쳐야 하고, 까먹으면 빠진다 — 방금 푸터가 9곳에서 빠진 것과 똑같은 모양이다.
골랐다 — 안 B, route group 으로 layout 을 나눈다. 이건 지어낸 방식이 아니다 — 같은 프로젝트의 admin 이 이미 그렇게 하고 있다.
app/(auth)/layout.js 인증 화면 묶음
app/(dashboard)/layout.js Sidebar · AccessGate · SessionProvider 를 여기서 그린다
폴더 이름을 괄호로 감싼 것은 Next.js의 규칙이다. (auth)처럼 괄호로 감싼 폴더는 route group이라 불리고, 주소에는 전혀 나타나지 않는다 — /auth/login이 아니라 /login이 된다. 같은 묶음 안의 라우트들에 같은 layout을 적용하면서, 다른 묶음과는 다른 layout을 쓰고 싶을 때 이렇게 나눈다.
라우트 28개짜리 admin 은 레이아웃 조각을 layout 이 그리고 성격이 다른 무리를 그룹으로 갈라 두고 있었다. client 만 어긋나 있었다.
app/
layout.js Header · Footer · SocialRail · GoogleAnalytics 모든 페이지
(browse)/
layout.js + MobileCtaBar
page.js about/ care/ faq/ fee/ privacy/ reviews/ terms/
inquiries/page.js inquiries/detail/page.js
(form)/
layout.js 하단 바 없음 — 입력을 가린다
inquiries/new/page.js
그룹 이름을 (with-cta) 가 아니라 (browse) · (form) 으로 지었다. 전자는 「예외 하나를 위한 장치」로 읽히고, 후자는 「입력하는 페이지에는 하단 고정 바를 두지 않는다」는 규칙이 된다. 앞으로 입력 화면이 늘면 (form) 에 넣기만 하면 되고, 예외 목록을 어딘가 적어둘 일이 없다.
/inquiries 와 /inquiries/new 가 다른 그룹에 있어도 된다. Next.js 문서가 막는 건 같은 URL 이 되는 경우뿐이다.
Conflicting paths: Routes in different groups should not resolve to the same URL path.
여기서 URL path는 주소창에 뜨는 경로, 즉 웹 주소를 뜻한다.
대가는 폴더 깊이가 한 겹 늘고, 라우트가 어느 그룹에 있는지 봐야 하는 것이다.
원칙 5. 레이아웃 조각의 기기별 차이는 CSS 가 만든다
레이아웃은 모든 기기에서 같지 않다. 오히려 정반대인 것이 있다.
.mobile-cta-bar { display: none }
@media (max-width: 767px) { .mobile-cta-bar { display: flex } } /* 모바일에서만 */
.social-rail { display: none }
@media (min-width: 768px) { .social-rail { display: flex } } /* 데스크톱에서만 */
한 줄씩 따라가면 이렇다. display: none은 그 요소를 화면에서 안 보이게 숨기라는 CSS 속성이다 — 지운 게 아니라 HTML에는 그대로 있고 화면에만 안 그려진다. @media (max-width: 767px) { ... }는 “화면 폭이 767px 이하일 때만 중괄호 안의 규칙을 적용하라”는 조건문이다 — 그래서 첫 두 줄을 합치면 “.mobile-cta-bar는 기본적으로 숨기고, 화면 폭이 767px 이하일 때만 display: flex(가로로 늘어놓는 배치)로 바꿔 보이게 하라”는 뜻이 된다. 아래 두 줄은 반대로 @media (min-width: 768px) — “화면 폭이 768px 이상일 때만”을 조건으로 걸어 .social-rail을 그때만 보이게 한다.
같은 목적(연락 유도)을 모바일은 하단 고정 바로, 데스크톱은 우측 세로 레일로 둔다. Header 안의 CTA 링크도 768px 이상에서만 뜬다.
그래도 layout 은 둘 다 그린다. 페이지도 컴포넌트도 화면 크기를 모르고, CSS 만 안다.
안 A — JS 로 화면을 재서 조건부로 그린다. HTML 이 가벼워진다. 버린 이유는 정적 export 라 첫 HTML 에 답이 없다는 것이다. 브라우저가 JS 를 돌린 뒤에야 결정되므로 첫 화면에 깜빡임이 생기고, JS 가 꺼진 환경에서는 아예 안 뜬다.
골랐다 — 안 B, 전부 그리고 CSS 가 가른다. 안 A 가 못 지켜주는 것은 서버가 화면 크기를 몰라도 되는 것이다. 빌드 때 만든 HTML 하나가 모든 기기에서 옳다.
대가는 안 보이는 것도 HTML 에 남는 것이다. 모바일에서도 SocialRail 마크업이 실려 오고, 데스크톱에서도 MobileCtaBar 가 실려 온다. 레이아웃 조각은 몇 개 안 되므로 그 무게를 감수한다 — 대신 목록이 긴 것(후기 수십 건 같은)을 이렇게 양쪽 다 그리지는 않는다.
판단 기준 정리
| 질문 | 답 | 결론 |
|---|---|---|
| 무엇으로 가르나 | 누가 그리는가 | 성격은 의견이지만 「layout 이 그리는가」는 파일을 열면 나온다 |
| 모든 페이지에 뜬다 | components/layout/ |
layout 이 그린다. 페이지는 존재를 모른다 |
| 그 화면 갈래 안에서만 쓴다 | app/<갈래>/_components/ |
개수가 아니라 갈래로 묻는다. 밑줄 폴더는 라우팅에서 빠진다 |
| 갈래 밖에서도 쓴다 | components/ |
공용 조각 |
| 갈래 안에 있던 걸 밖에서도 쓰게 됐다 | 올린다 | 내리는 규칙은 두지 않는다 — 아무도 안 한다 |
| 대부분에 뜨는데 하나만 빼야 한다 | route group | 성격 이름으로 짓는다. 예외 목록을 만들지 않는다 |
| 컴포넌트가 경로를 봐도 되나 | 안 된다 | 페이지 늘 때마다 목록을 고쳐야 하고 까먹으면 빠진다 |
| 기기별로 다르게 보여야 한다 | CSS 가 가른다 | 정적 export 라 서버가 화면을 모른다 |
| 그럼 안 보이는 것도 실려 오나 | 그렇다 | 레이아웃 조각은 몇 개 안 되므로 감수한다 |
이 표준을 정하기까지
폴더 정리 문제로 보였다. Header 는 components/ 루트에 있고 Footer 는 components/sections/ 안에 있다 — 같은 레이아웃 조각인데 사는 곳이 다르니 폴더 규칙을 세우면 될 일로 보였다.
세어 보고 나서 원인이 뒤집혔다. 각 컴포넌트가 몇 곳에서 쓰이는지 셌더니 Header 가 1곳, Footer 가 4곳, MobileCtaBar 가 13곳이었다. Header 가 1곳인 게 이상해서 열어보니 layout.js 가 그리고 있었고, Footer 와 MobileCtaBar 는 페이지마다 손으로 붙이고 있었다. 폴더가 갈린 것은 결과였고 원인은 붙이는 자리였다.
그래서 라우트 13개를 하나씩 열어 확인했다. 푸터가 4곳에만 있었다 — 9곳에서 빠져 있었다. /privacy 와 /terms 에도 없었다. 「지금은 우연히 다 맞다」와 「이미 어긋나 있다」는 같은 규칙을 정하더라도 급한 정도가 달라서 재봤는데, 이미 어긋나 있었다.
기준을 「성격」에서 「누가 그리는가」로 바꾼 것이 이 표준의 전부다. 성격으로 가르면 MobileCtaBar 가 레이아웃인지 섹션인지에서 사람마다 답이 갈린다. 「layout.js 가 이걸 그리는가」는 파일을 열면 답이 나온다. 그리고 그 기준이 푸터 누락을 구조적으로 없앤다 — 페이지가 붙일 일이 없으면 빠질 자리도 없다.
예외 하나가 원칙을 시험했다. MobileCtaBar 가 12/13 이고 /inquiries/new 에만 없는데, 입력을 가려서 뺀 의도였다. 컴포넌트가 usePathname() 으로 경로를 보고 숨는 안이 제일 쉬웠지만, 그건 방금 푸터가 빠진 것과 똑같은 모양이다 — 목록을 사람이 관리하고 까먹으면 어긋난다. route group 으로 가르되 이름을 (browse) · (form) 으로 지어 예외가 아니라 부류가 되게 했다.
표준을 쓰고 나서 admin 을 재보다 구멍을 하나 찾았다. 원칙 2 를 「몇 곳에서 쓰나」로 물었는데, admin 의 폼들이 전부 2곳이었다 — 만들기 화면과 고치기 화면이 같은 폼을 쓴다. 개수로 물으면 「여러 곳」이라 공용으로 가는데 계정 화면 밖에서는 아무도 안 쓴다. 질문을 「어느 갈래 밖에서도 쓰나」로 바꾸자 답이 제자리를 찾았다. 어긋난 게 아니라 경우 하나를 안 다룬 것이라 장부에 열지 않고 그 자리에서 채웠다.
그리고 admin 이 원칙 3 을 이미 하고 있다는 것도 그때 알았다. route group 으로 (auth) 와 (dashboard) 를 가르고 레이아웃 조각을 layout 이 그린다. 제안한 방식이 「생각해낸 것」이 아니라 「한쪽이 이미 검증한 것」이었다.
마지막으로 「모든 기기에서 같은 레이아웃인가」를 물었고, 아니었다. MobileCtaBar 는 767px 이하에서만, SocialRail 은 768px 이상에서만 뜬다 — 정반대다. 같은 목적을 화면에 따라 다른 자리에 둔 것이다. 그런데 이 사이트는 정적 export 라 서버가 화면 크기를 모른다. 빌드 때 HTML 하나를 만들어 모두에게 보내므로 가르는 방법이 CSS 뿐이고, 그래서 「layout 은 전부 그리고 CSS 가 가른다」를 원칙으로 못 박았다. 선택지가 하나뿐인 자리라 안을 지어내지 않았다.
정리
- 폴더 이름이 아니라 「누가 그리는가」로 가른다. 성격은 의견이지만 붙이는 자리는 파일에 적혀 있다.
- 모든 페이지에 뜨는 것은 layout 이 그린다. 페이지가 손으로 붙이면 빠진다 — 실제로 푸터가 9곳에서 빠졌다.
- 자리는 셋이다.
components/layout/·app/<갈래>/_components/·components/. - 「몇 곳에서 쓰나」가 아니라 「어느 갈래 밖에서도 쓰나」로 묻는다. 만들기·고치기 화면이 같은 폼을 쓰는 것은 공용이 아니다.
- 올리기만 하고 내리지 않는다. 내리는 일은 아무도 안 해서, 규칙으로 두면 안 지켜지는 규칙이 하나 는다.
- 대부분에 뜨는데 하나만 빼야 하면 route group 으로 나눈다. 그룹 이름은 성격으로 짓는다.
- 컴포넌트가 경로를 보고 자기를 숨기지 않는다. 그 목록을 사람이 관리하게 되면 까먹는다.
- 기기별 차이는 CSS 가 만든다. 정적 export 라 서버가 화면을 모르고, layout 은 전부 그린다.
자신만의 철학을 만들어가는 중입니다.
댓글남기기