백엔드, 그러니까 화면 뒤에서 데이터를 저장하고 업무 규칙을 처리하는 서버 쪽 프로그램에는 3계층 구조가 있다. Controller → Service → Repository 순서로만 부르고, 거꾸로는 부르지 않는다. 더 나아가면 헥사고날 아키텍처가 있다. 업무 규칙을 가운데 두고 DB 같은 바깥 기술을 갈아 끼우게 만든다.
그럼 프론트엔드, 그러니까 사용자가 브라우저에서 보고 누르는 화면 쪽에는 이런 게 없나? 이 글은 그 질문에서 시작했다. 이 글은 내가 AI 대화 상대인 Claude(Anthropic이 만든 AI)와 대화하며 공부한 것을 옮긴 것이다. 아래에서 「대화」라고 하면 그 대화를 말한다.
질문이 그냥 궁금증이 아니었던 이유가 있다. 이 블로그는 지금까지 정한 코딩 스탠다드의 목록을 YAML 파일 하나(_data/coding-standards.yml)에 모아 둔다. 이 글에서는 그 파일을 「표준 목록」이라고 부른다. 표준 목록에서 프론트엔드 아키텍처 칸에는 「아직 정하지 않았다」는 표시(status: open)가 붙어 있다. 우리 Next.js 앱(앱이 여러 개인데, 이 글은 방문자가 보는 공개 사이트인 client 앱을 기준으로 본다)의 폴더는 app/(주소별 화면), components/(화면 조각), lib/(서버 호출과 데이터를 다루는 함수), styles/(화면 모양을 정하는 CSS 파일), 그리고 버튼·색깔 같은 공용 화면 부품을 모은 design-system/(디자인 시스템)으로 나뉘어 있지만, 그게 계층인지 그냥 폴더인지, 무엇이 무엇을 부를 수 있는지는 정한 적이 없다. 공부한 것이 그대로 이 빈칸을 채울 재료가 되기를 바랐다.
이 글은 표준이 아니다. 공부한 경로와, 공부하다가 우리 코드에 대 보고 세운 가설까지를 적는다. 가설은 아직 확인하지 않았다.
먼저: 프론트엔드의 복잡함은 도메인이 아니라 상태와 조립에서 나온다
3계층과 헥사고날이 지키려는 것은 하나다. 업무 규칙이 바깥 기술에 끌려다니지 않게 하는 것이다. 주문 가능 여부를 판단하는 코드가 「데이터를 MySQL이라는 DB 프로그램에 저장한다」는 사실까지 알고 있으면, 나중에 MySQL을 다른 DB 프로그램으로 바꿀 때 업무 규칙 코드까지 고쳐야 한다. 그래서 백엔드는 의존 방향, 즉 어느 코드가 어느 코드를 불러 쓸 수 있는지를 한쪽으로 고정한다. 이 글에서 「의존한다」는 「불러 쓴다」와 같은 뜻이다.
프론트엔드는 사정이 세 가지 다르다.
첫째, 지킬 업무 규칙이 얇다. 「이 주문을 취소할 수 있나」, 「총액이 얼마인가」 같은 진짜 규칙은 대부분 서버에 있다. 화면은 서버가 준 결과를 보여주고, 사용자의 입력을 서버로 넘기는 일이 대부분이다. 가운데 두고 지킬 도메인이 백엔드만큼 두껍지 않다.
둘째, 어려움이 다른 데서 나온다. 프론트에서 머리 아픈 건 상태다. 이 값을 어느 컴포넌트가 들고 있나, 언제 바뀌나, 서버 값과 화면 값이 어긋나면 어떻게 하나. 예를 들어 좋아요를 누르면 화면은 바로 하트를 채우는데, 서버가 저장에 실패하면 화면과 서버가 서로 다른 값을 들고 있게 된다. 그리고 조립이다. 작은 조각들을 어떻게 붙여 화면 하나를 만드나.
셋째, 프레임워크가 뼈대의 절반을 먼저 가져간다. 자바로 서버를 만드는 도구인 Spring은 패키지를 어떻게 나눌지 개발자에게 맡긴다. 그런데 Next.js의 App Router는 app/ 폴더 구조가 곧 웹 주소(URL)다. app/about 폴더를 만들면 /about 주소가 생긴다. 폴더를 내 마음대로 짜기 전에 프레임워크가 이미 한 축을 정해 둔다.
그래서 프론트엔드에는 「이거 하나면 된다」고 할 만한 대표 이름이 백엔드만큼 자리 잡지 못했다. 이름이 없다는 게 아니다. 여러 개가 각자 다른 질문에 답하고 있다.
프론트엔드 구조 후보 넷 중에서 FSD를 공부 대상으로 골랐다
정해야 했던 건 이거였다. 「무엇이 무엇을 부를 수 있나」라는 우리 빈칸에 가장 가까운 답을 주는 것은 무엇인가.
안 A: MVC / MVVM. 화면을 그리는 코드(View)와 데이터·로직을 다루는 코드(Model)를 나눈다. MVC는 Model · View · Controller(사용자 입력을 받아 Model과 View를 잇는 코드)의 머리글자이고, MVVM은 Model · View · ViewModel(View가 보여줄 값을 미리 준비해 두는 코드)의 머리글자다. 둘 다 View와 Model을 떼어 놓는다는 점이 같다. 백엔드 3계층과 같은 발상이고, Angular나 Vue처럼 React(화면을 컴포넌트로 나눠 만드는 자바스크립트 도구. 자바스크립트는 자바와 이름만 비슷한 다른 언어로, 주로 브라우저 안에서 돈다)와 같은 일을 하는 다른 화면 도구 쪽에서 자주 쓰는 말이다. 얻는 것은 「화면 코드에 로직을 섞지 않는다」는 분리다. 대화에서는 깊이 비교하지 않았다. 답하는 질문이 「화면과 로직을 나누나」라서, 「컴포넌트 수십 개 사이에서 누가 누구를 부르나」에는 답이 없었다.
안 B: Flux / Redux. 상태가 한 방향으로만 흐르게 한다. 사용자가 버튼을 누르면 「좋아요를 눌렀다」 같은 사건을 적은 쪽지(액션)가 나가고, 상태를 한곳에 모아 둔 보관함(스토어)이 그 쪽지를 보고 값을 바꾸고, 화면이 다시 그려진다. 얻는 것은 「이 값이 왜 바뀌었지」를 쪽지를 따라가 추적할 수 있다는 점이다. 이것도 깊이 비교하지 않았다. 방향을 정하긴 하지만 그 대상이 「상태」라서, 파일과 폴더 사이의 방향은 말하지 않는다.
안 C: 헥사고날을 프론트에 옮긴다. 업무 규칙을 가운데 두고, 서버에 데이터를 요청하는 함수 같은 바깥 기술을 「약속(인터페이스)과 구현」으로 떼어 낸다. 얻는 것은 「서버 사정이 바뀌어도 업무 규칙 코드는 그대로」다. 버린 이유는 첫째 사정, 즉 지킬 업무 규칙이 얇다는 점이다. 가운데가 얇은데 「약속(인터페이스)과 구현」 쌍은 기능마다 생긴다. 예를 들어 후기 목록을 가져오는 일 하나에 「후기를 가져온다」는 약속 파일과 「서버에서 실제로 가져온다」는 구현 파일 두 개가 생기고, 화면은 약속 파일만 안다. 화면 하나 고치려고 인터페이스와 구현 파일을 같이 고치는 일이 반복되고, 그렇게 지켜낸 가운데 코드는 서버가 준 값을 그대로 넘기는 몇 줄뿐이 된다.
게다가 우리 앱은 이미 가벼운 형태로 그 일을 하고 있다. 프론트엔드 API 계층 표준에서 두 가지를 정했다. 원칙 1은 화면이 서버에 보내는 요청은 전부 lib/api.js 파일 하나를 거친다는 것이다. 원칙 2는 서버가 보낸 데이터를 화면에 쓰기 좋은 모양으로 바꾸는 일은 lib/reviews.js처럼 업무 하나를 맡은 파일이 한다는 것이다. 헥사고날이 「약속과 구현」으로 얻으려는 것은 서버 주소나 서버가 쓰는 데이터 이름 같은 바깥 사정이 바뀌어도 화면 코드가 흔들리지 않는 것이다. 우리 앱에서는 서버 사정이 바뀌면 lib/api.js와 업무별 파일만 고치면 되고, 화면 컴포넌트는 그대로다. 인터페이스를 따로 만들지 않고도 같은 효과를 이미 얻고 있었다. 그래서 바깥 기술을 한 곳에 가두는 일은 그걸로 충분하다고 봤다.
안 D: Feature-Sliced Design(FSD). 코드를 층으로 나누고 위층만 아래층을 부를 수 있다는 규칙을 건다. 백엔드 3계층을 프론트에 가장 가깝게 옮긴 모양이다.
여기에 Atomic Design(원자 → 분자 → 유기체 → 템플릿 → 페이지)도 자주 같이 나오는데, 후보에서 뺐다. 버튼 하나 같은 가장 작은 조각을 원자, 입력 칸과 버튼을 붙인 검색창 같은 것을 분자, 그런 것들을 모은 머리 영역 같은 것을 유기체라고 부르며 크기 순서로 쌓아 올린다. 이건 아키텍처가 아니라 컴포넌트를 어떤 크기로 쪼갤지 정하는 기준이다. 누가 누구를 부를 수 있는지는 말하지 않는다.
골랐다: 안 D. 안 A는 화면과 로직을 나눌 뿐, 누가 누구를 불러도 되는지는 정하지 않는다. 안 B는 방향을 정하지만 그 대상이 「상태가 흐르는 길」이다. 안 C는 약속 파일과 구현 파일 사이에 방향을 걸지만, 그 방향이 걸리는 곳은 「업무 규칙과 바깥 기술 사이」라는 경계 하나뿐이다. 컴포넌트 수십 개가 서로 어떻게 불러도 되는지는 말하지 않고, 지킬 가운데가 얇은 우리 앱에서는 약속과 구현 쌍만 늘린다. 안 D만이 앱의 모든 파일을 층으로 줄 세워, 어느 파일이 어느 파일을 불러도 되는지 규칙으로 건다. 대신 FSD는 코드를 「성격」으로 분류하는 방식이라, 이미 정해 둔 우리 표준과 부딪치는 곳이 있다. 그 얘기는 뒤에서 다시 한다.
FSD는 Feature-Sliced Design, 「기능 단위로 써는 설계」라는 뜻이다
FSD는 Feature-Sliced Design의 약자다.
- Feature: 기능. 「좋아요 누르기」, 「장바구니에 담기」처럼 사용자가 하는 행동 하나
- Sliced: 잘게 썬
- Design: 설계
이름만 보면 「기능별로 폴더를 나눈다」가 전부인 것 같다. 처음 설명을 들었을 때 나도 그렇게 받아들였고, 그래서 이해가 안 됐다. 실제로는 세 번 썬다. 그게 왜 세 번이어야 하는지는 FSD가 없을 때 무엇이 불편한지부터 따라가야 보였다. 다음 세 섹션이 그 순서다.
예제로는 쇼핑몰 상품 목록 화면을 쓴다. 상품 카드가 여러 장 있고, 카드마다 이름, 가격, 「좋아요」 버튼, 「장바구니 담기」 버튼이 붙어 있다.
종류별 폴더는 기능 하나를 세 폴더에 흩어 놓는다
흔한 구조는 파일을 종류로 나누는 것이다.
src/
├── components/ 화면 조각 전부
│ ├── ProductCard.jsx
│ ├── LikeButton.jsx
│ └── CartButton.jsx
├── hooks/ 상태 로직 전부
│ ├── useLike.js
│ └── useCart.js
└── api/ 서버 호출 전부
├── likeApi.js
└── cartApi.js
맨 위의 src/는 프로젝트의 소스 코드를 모아 두는 폴더다. 그 아래 components/에는 화면 조각이, hooks/에는 상태를 다루는 함수가, api/에는 서버에 요청을 보내는 함수가 모인다. 파일 이름 끝의 .jsx는 화면 모양(JSX)이 들어 있는 자바스크립트 파일이라는 표시이고, .js는 보통 자바스크립트 파일이다. 다만 이건 관례일 뿐이라, 뒤에 나올 우리 앱처럼 화면 파일도 .js로 쓰는 프로젝트가 많다. Next.js는 둘을 똑같이 읽는다. useLike처럼 use로 시작하는 이름은 상태를 다루는 React 함수(훅)에 붙이는 약속이다.
자바로 치면 controller/, service/, repository/ 패키지를 만들고 모든 도메인을 그 안에 섞어 넣는 것과 같다. 처음에는 깔끔하다. 커지면 두 군데서 깨진다.
「좋아요」 하나를 고치려면 세 폴더를 돌아다녀야 한다. 버튼은 components/에, 상태는 hooks/에, 서버 호출은 api/에 있다. 「좋아요」 기능을 통째로 지울 때는 더 나쁘다. 어디에 흩어져 있는지 전부 찾아야 하고, 하나를 빠뜨리면 아무도 안 쓰는 파일이 남는다.
아무 파일이나 아무 파일을 불러올 수 있다. 자바스크립트의 import는 자바의 import와 같은 일을 한다. 다른 파일에 있는 함수나 컴포넌트를 가져다 쓰겠다는 선언이다. 막는 규칙이 없으니 ProductCard가 useCart를 부르고, useCart가 다시 ProductCard 쪽 무언가를 부르는 식으로 엉킨다. 하나를 고치면 어디가 깨질지 알 수 없게 된다.
기능별로 세로로 썰면 기능 하나가 폴더 하나에 모인다
첫 번째 문제부터 푼다. 기능 하나에 필요한 것을 한 폴더에 모은다.
src/
├── like/
│ ├── LikeButton.jsx
│ ├── useLike.js
│ └── likeApi.js
└── cart/
├── CartButton.jsx
├── useCart.js
└── cartApi.js
이제 「좋아요」를 고칠 때는 like/만 본다. 지울 때도 like/ 폴더 하나만 지우면 끝난다. 자바로 치면 계층별 패키지 대신 도메인별 패키지(order/, member/)로 나누는 것과 같다. 이게 FSD 이름에 들어 있는 Sliced, 세로로 썰기다. 이렇게 썰어 낸 like/, cart/ 같은 폴더 하나를 FSD는 「조각(slice)」이라고 부른다. 이 글에서도 그렇게 부른다. 그냥 「조각」이라고 하면 이 폴더를 말하고, 컴포넌트 하나는 「화면 조각」이나 「컴포넌트」라고 구별해 부른다.
여러 기능이 같이 쓰는 것은 아래층으로 내린다
세로로만 썰면 곧 막힌다. ProductCard는 어디에 두나?
like/에 두면, 장바구니 쪽에서 카드를 쓰려고 할 때 like/를 import 해야 한다. 장바구니가 좋아요에 기대는 이상한 관계가 생긴다. cart/에 두면 같은 문제가 반대로 생긴다. 문제는 여러 기능이 같이 쓰는 것에 있다.
그래서 FSD는 한 번 더 썬다. 이번엔 가로로.
여러 기능이 같이 쓰는 것은 아래층으로 내린다. 위층은 아래층을 부를 수 있지만, 아래층은 위층을 못 부른다.
ProductCard는 좋아요보다도, 장바구니보다도 더 기본적인 것이라 한 층 아래로 내린다. 그러면 좋아요와 장바구니가 둘 다 아래를 내려다보며 카드를 쓰고, 서로를 쳐다볼 일이 없어진다. 세로로 썰어서 생긴 문제를 가로로 썰어서 푸는 것이다.
FSD는 여섯 층으로 가로로 썰고 위층만 아래층을 부른다
가로로 썬 층은 위에서부터 이렇다.
앱 전체 설정"] --> PAGES["pages
화면 한 장"] PAGES --> WIDGETS["widgets
조립된 큰 덩어리"] WIDGETS --> FEATURES["features
사용자의 동사"] FEATURES --> ENTITIES["entities
업무의 명사"] ENTITIES --> SHARED["shared
업무를 모르는 도구"] style APP fill:#2d3748,stroke:#4299e1,stroke-width:2px,color:#e2e8f0 style PAGES fill:#2d3748,stroke:#4299e1,stroke-width:2px,color:#e2e8f0 style WIDGETS fill:#2d3748,stroke:#a0aec0,stroke-width:2px,stroke-dasharray:5,color:#e2e8f0 style FEATURES fill:#2d3748,stroke:#4299e1,stroke-width:2px,color:#e2e8f0 style ENTITIES fill:#2d3748,stroke:#4299e1,stroke-width:2px,color:#e2e8f0 style SHARED fill:#2d3748,stroke:#4299e1,stroke-width:2px,color:#e2e8f0
화살표는 「부를 수 있다」는 방향이다. 위는 아래를 부를 수 있고, 아래는 위를 못 부른다. FSD 공식 문서는 이걸 「층의 import 규칙」이라고 부른다.
A module (file) in a slice can only import other slices when they are located on layers strictly below.
조각 안의 파일은 자기보다 엄격히 아래 층에 있는 조각만 import 할 수 있다.
「엄격히 아래」는 같은 층은 빼고 아래층만이라는 뜻이다. 같은 층끼리 못 부르는 이유는 두 규칙 섹션의 규칙 2에서 본다.
이 문장과 이 글에 옮긴 FSD 문서 인용은 전부 FSD 공식 문서의 원본 파일에서 직접 확인했다. 원본은 GitHub에 있는 저장소 feature-sliced/documentation의 reference/layers.mdx(층 설명)와 reference/slices-segments.mdx(조각과, 뒤에서 볼 조각 안의 칸인 세그먼트 설명)이고, 2026-10-05에 받은 판(커밋 3a5a3a3)이다. .mdx는 마크다운 문서 안에 화면 조각을 섞어 쓸 수 있게 한 문서 파일 형식이다. 문서가 나중에 바뀌면 이 판 번호로 그때의 내용을 다시 찾을 수 있다.
자바 3계층에서 Repository가 Controller를 부르지 않는 것과 같은 쪽의 규칙이다. 다른 점이 하나 있다. 3계층은 바로 아래층만 부른다. Controller가 Repository를 건너뛰어 부르지 않는다. FSD는 아래이기만 하면 몇 층을 건너뛰어도 된다. 뒤의 코드에서 widgets가 entities를 바로 부르는 것이 그 예다. 둘이 같은 것은 「거꾸로는 안 된다」 쪽이다.
공식 문서에는 사실 층이 일곱 개 있다. pages와 app 사이에 회원가입처럼 여러 페이지에 걸친 흐름을 담던 processes가 있는데, 문서가 스스로 「폐기됨(deprecated)」이라고 적어 두었고 그 내용을 features와 app으로 옮기라고 권한다. 그래서 여섯 개로 공부했다. 그림에서 widgets만 점선으로 그린 이유는 widgets 섹션에서 설명한다.
여섯 층은 네 가지 질문을 아래층부터 차례로 던져 가른다
층 이름만 외워서는 내 코드를 어디에 둘지 모른다. 공부하면서 층마다 하나씩 질문을 붙였고, 아래층부터 차례로 던지면 대부분 자리가 정해졌다.
질문 넷을 던지기 전에 먼저 거르는 것이 하나 있다. 모든 페이지에 똑같이 붙는 틀(머리 영역, 바닥 영역)이면 질문 없이 app이다. 이건 공부할 때는 몰랐고, 공부를 마치고 글로 옮기기 전에 공식 문서를 읽다가 알았다(뒤의 widgets 섹션).
1. 우리 업무를 전혀 모르나? 그러면 shared다. 여기서 「업무를 안다」는 상품·주문 같은 우리 업무 데이터를 다루거나, 그 데이터로 무언가를 판단한다는 뜻이다. 데이터를 화면에 보여주기만 해도 「상품에는 이름과 가격이 있다」를 알고 있으니 업무를 아는 것이다. 버튼 모양(Button), 팝업 창 틀(Modal), 숫자에 쉼표를 찍는 함수(formatPrice(39000) → "39,000"), 서버에 요청을 보내는 공통 함수(api.js)가 여기 온다. 공부할 때는 질문을 「이 코드를 병원 예약 앱 같은 전혀 다른 앱에 붙여도 그대로 쓸 수 있나?」로 썼다. 자바의 String이나 ArrayList가 내가 쇼핑몰을 만드는지 게임을 만드는지 모르는 것과 같다. 그런데 공식 문서를 확인해 보니 기준이 이것보다 조금 느슨했다. 업무 로직만 없으면 회사 로고나 페이지 틀 모양처럼 「우리 회사 티가 나는」 것도 shared에 둬도 된다고 적혀 있다. 여기서 페이지 틀은 안에 아무 데이터도 없는 빈 껍데기 모양을 말한다. 그 틀에 메뉴 같은 데이터를 채워 모든 페이지에 실제로 붙이는 일은 app이 맡는다(뒤의 widgets 섹션). 그래서 질문을 「우리 업무를 전혀 모르나?」로 고쳤다. 모양에 회사 티가 나는 것은 상관없고, 업무 데이터를 다루느냐만 본다.
2. 우리 업무의 명사를 보여주기만 하나? 그러면 entities다. 「사용자가 상품을 보고, 장바구니에 담아서, 주문한다」에서 사용자, 상품, 주문 같은 명사다. 자바로 치면 class Product, class Order 같은 도메인 클래스다. 상품에 어떤 값(이름, 가격, 사진 파일이 있는 웹 주소)이 들어 있는지 적어 둔 정의, 상품을 서버에서 가져오는 함수, 상품을 보여주는 ProductCard가 entities/product/에 모인다. 핵심은 보여주기만 하고 행동은 모른다는 것이다.
3. 사용자가 하는 행동인가? 그러면 features다. 좋아요 누르기, 장바구니에 담기, 로그인하기, 리뷰 작성하기, 검색하기 같은 동사다. 행동이면 누른 뒤에 무언가가 바뀐다. 서버에 저장된 값(좋아요 수)이 바뀌기도 하고, 화면에 보이는 것(검색 결과 목록)만 바뀌기도 한다. features/cart/ 안에는 누르는 버튼, 「담는 중인지 담겼는지」 기억하는 상태, 서버에 「담아줘」라고 요청하는 함수가 같이 산다. entities는 보여주고, features는 바꾼다.
4. 화면의 한 구역이고, entities나 features 여러 개를 조립하나? 그러면 widgets다. shared 부품은 어느 층이든 다 가져다 쓰는 재료라서 조립으로 치지 않는다. 「상품 목록」 영역은 ProductCard(entities)에 LikeButton과 CartButton(features)을 붙여 여러 장 늘어놓은 것이다. 상품 상세 화면의 「리뷰 영역」은 리뷰 하나를 보여주는 컴포넌트 여러 개(entities)와 맨 아래 리뷰 작성 칸(features)을 조립한다. 레고로 치면 앞의 세 층이 블록 낱개이고, widgets는 블록을 조립한 자동차나 집 하나다.
위의 두 층은 질문이 필요 없을 만큼 분명했다. pages는 주소 하나에 화면 한 장이다. /products, /products/123, /cart가 각각 한 페이지이고, 페이지는 widgets를 위에서 아래로 배치한다. app은 모든 화면에 한 번만 깔리는 것이다. 모든 화면에 공통으로 쓰는 글꼴과 배경색 같은 CSS, 「지금 로그인한 사람이 누구인지」를 앱 맨 바깥에 한 번 넣어 두어 안쪽 어느 컴포넌트든 그 값을 꺼내 쓸 수 있게 하는 설정, 어떤 주소에 어떤 페이지를 보여줄지 정하는 길 안내가 여기 온다. 다만 Next.js에서는 이 길 안내를 app/ 폴더 구조가 대신 맡는다. 이 때문에 생기는 이름 충돌은 마지막 가설 섹션에서 다시 본다. 자바 프로그램에서 맨 처음 한 번 실행되는 main 메서드 자리와 비슷하다.
공부하면서 이 네 질문으로 쇼핑몰 부품 열여섯 개(Spinner, ReviewForm, SearchBar, Badge 같은 것)와 우리 앱의 실제 파일 여섯 개, 모두 스물두 개를 분류해 봤다. 실제 파일 여섯 개는 뒤의 표에서 본다. 정답은 대화 상대인 Claude가 위 네 질문을 기준으로 매겼다.
- 그때 채점으로는 스물두 개 중 스무 개를 맞혔다. 틀린 두 개는
SearchBar(정답 features)와ReviewCard(정답 entities)였는데, 내가 고른 답은 둘 다widgets였다 - 글을 다 쓴 뒤 검토하다가 하나가 더 틀린 것을 알았다. 나는
Header를 widgets로 골랐고 Claude도 정답으로 매겼지만, 사실은app이었다(뒤의 표에서 본다). 그래서 실제로 맞힌 것은 열아홉 개다 - 답이 둘로 갈리는
InfiniteReviewList는 내가 widgets로 골랐고, widgets도 맞는 답 중 하나라서 맞힌 것으로 셌다
쇼핑몰 부품 열여섯 개와 그때 매긴 정답은 이렇다.
- shared:
formatDate(날짜 모양 바꾸기),Input(글자 입력 칸),Spinner(로딩 중 빙글빙글),Badge(「NEW」 같은 작은 딱지),Tooltip(마우스를 올리면 뜨는 설명 말풍선) - entities:
UserAvatar(사용자 프로필 사진),OrderSummary(주문 번호·날짜·금액 보여주기),ReviewItem(남이 쓴 리뷰 하나 보여주기),CategoryTag(상품 카테고리 딱지) - features:
CartButton(장바구니 담기),ReviewForm(리뷰 작성 칸),LogoutButton(로그아웃),SearchBar(검색하기) - widgets:
ReviewSection(리뷰 목록과 작성 칸을 묶은 영역),Sidebar(카테고리 메뉴와 최근 본 상품을 묶은 왼쪽 영역) - pages:
/mypage(내 정보 화면)
Sidebar는 문제에서 어느 페이지에 붙는지 정하지 않았다. 모든 페이지에 붙는다면 앞의 「먼저 거르는 것」에 따라 app이 된다.
다음 widgets 섹션은 공부할 때 틀린 두 개(SearchBar, ReviewCard)를 다룬다.
widgets는 크기가 아니라 조립 여부로 정하는데, 그 경계가 흐려서 공식 가이드도 권하지 않는다
처음 틀린 건 SearchBar였다. 검색어를 입력하고 엔터를 누르면 상품을 검색하는 칸이다. 화면 위쪽에 눈에 띄게 자리 잡고 있으니 「한 구역」으로 보였고, 그래서 widgets로 골랐다. 그런데 질문을 차례로 던지면 3번에서 멈춘다. 사용자가 검색어를 넣고 엔터를 치는 행동이고, 치고 나면 화면의 상품 목록이 검색 결과로 바뀐다. 동사 「검색하기」다. 그리고 4번의 「entities나 features 여러 개를 조립하나?」에도 아니다. 입력 칸 하나와 검색 동작 하나뿐이다. 눈에 띄는 크기는 기준이 아니었다. 행동 하나짜리 부품이라 features이고, 보통은 화면 맨 위의 머리 영역이 이걸 가져다 끼운다.
두 번째로 틀린 건 우리 앱의 ReviewCard였다. 뒤에서 소개할 우리 공개 사이트 코드에 있는 파일로, 후기 하나를 사진, 제목, 조회수와 함께 보여주는 카드다. 재료가 여러 개라 조립처럼 보였다. 그런데 재료를 열어 보니 사진이 아직 없을 때 대신 회색 상자를 보여주는 ImagePlaceholder와 눈 아이콘(EyeIcon)이었고, 둘 다 shared였다. 질문을 아래부터 차례로 던지면 2번 「우리 업무의 명사를 보여주기만 하나?」에서 이미 멈춘다. 「후기」라는 명사 하나를 보여주기만 하니 entities다. 4번의 조립은 entities나 features를 붙일 때만 친다.
두 번 다 「크다」, 「여러 개가 들어 있다」는 겉모습에 끌렸다. 그래서 「widgets는 크기가 아니라 조립 여부로 정한다」를 기준으로 붙들었다.
그런데 공식 문서를 확인해 보니, 이 헷갈림은 나만 겪은 게 아니었다. 지금 FSD 가이드는 widgets 층을 쓰지 말라고 권한다.
This guide discourages using the Widgets layer.
이 가이드는 Widgets 층을 쓰는 것을 권하지 않는다.
이유도 적혀 있다. 실제 화면 덩어리에는 데이터를 불러오고, 상태를 다루고, 클릭을 처리하는 로직이 섞인다. 그러면 「사용자 행동을 다루는 features」와 「화면 덩어리를 다루는 widgets」의 책임이 겹쳐서 두 층의 경계가 흐려진다. 내가 SearchBar에서 겪은 게 정확히 이거였다. 앞의 여섯 층 그림에서 widgets만 점선으로 그린 것은 이 때문이다.
widgets를 안 쓰면 그 덩어리는 어디로 가나. 문서의 답은 이렇다. 한 화면에서만 쓰는 조립은 그 pages 안에 둔다. 여러 페이지에서 다시 쓰는 행동은 그 행동에 필요한 화면 모양(버튼과 입력 칸을 어떻게 배치하는지 같은 것)까지 통째로 features 조각 하나에 넣는다. widgets처럼 다른 features 조각 여러 개를 불러와 조립하는 게 아니라, 그 조각 안에서 직접 만든다. 업무를 모르는 화면 부품은 shared로 보낸다. 모든 페이지에 똑같이 붙는 머리·바닥 같은 틀(레이아웃)은 app이 맡는다. 그래서 쇼핑몰 예제라도 모든 페이지 맨 위에 붙는 머리 영역은 widgets가 아니라 app의 레이아웃이다.
남는 빈칸도 있다. 앞의 상품 목록처럼 entities와 features 여럿을 붙인 조립을 여러 페이지에서 다시 쓴다면, widgets 없이 어디에 둘지 문서에서 한 줄로 된 답을 찾지 못했다. 이 글 뒤쪽 두 규칙 섹션의 ProductList는 층 사이의 규칙을 보여주려고 widgets에 둔 예제이고, 지금 가이드가 권하는 자리를 보여주는 예제는 아니다. 이 답은 뒤에서 우리 표준과 견줄 때 다시 나온다.
features는 동사지만, 동사라고 다 features가 되는 건 아니다
공부할 때는 「사용자가 하는 행동이면 features」로 외웠다. 공식 문서는 여기에 조건을 하나 더 붙인다.
not everything needs to be a feature. A good indicator that something needs to be a feature is the fact that it is reused on several pages.
모든 것이 feature일 필요는 없다. feature가 되어야 한다는 좋은 신호는 여러 페이지에서 다시 쓰인다는 사실이다.
이유는 이렇게 적혀 있다. 조각은 코드를 빨리 찾게 해 주는 장치인데, features가 너무 많으면 중요한 것이 그 속에 묻힌다. 한 페이지에서만 쓰는 버튼 하나까지 features/에 꺼내 두면, 새로 온 사람이 features/ 목록을 훑어서 이 앱이 무엇을 하는지 파악할 수가 없다.
앞의 연습 문제는 이 조건을 알기 전에 채점했다. 문제에서 ReviewForm이나 SearchBar를 어느 페이지에서 쓰는지 정하지 않았으니, 한 페이지에서만 쓰인다면 features가 아니라 그 페이지 안에 두는 것이 맞다.
그래서 기준은 두 겹이다. 사용자가 하는 행동(동사)인가를 먼저 묻고, 그다음 여러 페이지에서 쓰나를 묻는다. 한 페이지에서만 쓰는 동사는 그 페이지 안에 둔다.
두 규칙은 import 한 줄로 드러난다
FSD의 규칙은 둘이다. 둘 다 코드에서는 import 한 줄로 보인다.
규칙 1. 아래층은 위층을 못 부른다.
// entities/product/ProductCard.jsx
import { LikeButton } from '@/features/like'; // 하지 않는다: entities가 features를 불렀다
이 한 줄을 풀면 이렇다.
@/는 프로젝트의src/폴더를 가리키는 줄임 표시다. 그러니'@/features/like'는src/features/like다. 자바스크립트 언어 규칙이 아니라 프로젝트 설정 파일(jsconfig.json)에 정해 둔 줄임이다. 우리 client 앱도 이 설정에"@/*": ["./src/*"]라고 적어 두었다. 「@/로 시작하는 경로는./src/로 바꿔 읽어라」라는 뜻이고,*는 그 뒤에 오는 아무 경로나 그대로 이어 붙인다는 표시다features/like는 파일이 아니라 폴더다. 자바스크립트는 폴더를 가리키면 그 안의index.js파일을 대신 읽는다. 이 창구 파일은 세그먼트 섹션에서 코드로 본다- 중괄호
{ LikeButton }은 그 파일이 내보낸 것들 중에서LikeButton하나만 골라 가져온다는 뜻이다. 자바는 클래스 하나를 통째로 import 하지만, 자바스크립트 파일은 함수나 컴포넌트 여러 개를 내보낼 수 있어서 이름을 골라 가져온다
결국 카드(명사, entities)가 좋아요 버튼(동사, features)을 알게 된다.
이게 왜 문제인지는 장면 하나로 보인다. 주문 내역 화면에서도 상품 카드를 보여주고 싶은데, 거기선 좋아요 버튼이 필요 없다. 그런데 카드 안에 좋아요가 박혀 있으니 좋아요 없는 카드를 쓸 수가 없다. 그래서 카드는 버튼이 무엇인지 모르고 「버튼이 들어갈 자리」만 비워 둔다.
// entities/product/ProductCard.jsx
export function ProductCard({ product, actions }) {
return (
<div>
<h3>{product.name}</h3>
<p>{product.price}원</p>
{actions}
</div>
);
}
한 줄씩 따라가면 이렇다.
export function ProductCard({ product, actions }): 컴포넌트는 자바스크립트 함수다. 앞의export는 이 함수를 다른 파일에서 import 할 수 있게 밖으로 내놓는다는 표시로, 자바의public과 비슷하다. 괄호 안은 부모가 넘겨준 값(props)에서 꺼내는 부분이다.{ product, actions }는 매개변수가 두 개라는 뜻이 아니다. 부모가 넘긴 값은 여러 값이 이름표와 함께 묶인 한 덩어리로 오고, 중괄호는 그 덩어리에서product와actions라는 이름의 값을 꺼내 각각 변수로 만든다. 여기서 부모는 이 컴포넌트를 불러 쓰는 바깥 컴포넌트다. 예를 들어 뒤에 나올ProductList가<ProductCard ... />라고 써서 이 카드를 부르면ProductList가 부모다(이 꺾쇠 모양으로 부르는 방법은 바로 아래와 뒤의 코드에서 설명한다). 부모가 넘겨준 값에서product(상품 데이터)와actions(버튼이 들어갈 자리)를 꺼내는 것이다. 자바의 메서드 매개변수와 비슷하다return ( <div> ... </div> ): 화면에 그릴 모양을 돌려준다. 자바스크립트 안에 HTML처럼 쓰는 이 문법이 JSX다. 꺾쇠로 감싼<div>같은 표시를 태그라고 부른다.<div>는 안의 것들을 묶는 상자,<h3>는 제목 한 줄,<p>는 문단 하나다{product.name},{product.price}: 중괄호 안의 값을 화면에 찍는다. 상품 이름과 가격이 나온다{actions}: 부모가 넘긴 것을 그대로 이 자리에 놓는다. 무엇이 들어올지는 카드가 모른다
버튼을 실제로 끼우는 건 위층이다.
// widgets/product-list/ProductList.jsx
import { ProductCard } from '@/entities/product'; // 아래층이라 된다
import { LikeButton } from '@/features/like'; // 아래층이라 된다
import { CartButton } from '@/features/cart'; // 아래층이라 된다
export function ProductList({ products }) {
return (
<div>
{products.map((p) => (
<ProductCard
key={p.id}
product={p}
actions={<><LikeButton id={p.id} /><CartButton id={p.id} /></>}
/>
))}
</div>
);
}
한 줄씩 따라가면 이렇다.
- 맨 위 세 줄의
import:ProductList는 widgets라서 아래층인 entities와 features를 둘 다 부를 수 있다. 세 줄이 전부 위에서 아래로 향한다 ProductList({ products }): 부모에게서 상품 목록products를 받는다products.map((p) => ( ... )): 자바의for문처럼 상품 목록에서 하나씩 꺼내p에 담고, 상품마다 괄호 안의 모양을 하나씩 만든다.(p) => (...)는 「p를 받아 괄호 안의 것을 돌려주는 함수」를 짧게 쓴 화살표 함수로, 자바의 람다p -> ...와 같다. 이렇게 만든 카드들이 바깥<div>안에 차례로 놓인다<ProductCard ... />: 컴포넌트(함수)를<div>같은 태그 모양으로 쓰면 그 함수가 불린다. 끝의/>는 안에 넣을 것이 없어서 여는 표시와 닫는 표시를 한 번에 쓴 것이다.product={p},actions={...}는 그 함수에 매개변수를 넘기는 것이고, 위ProductCard가{ product, actions }로 받은 게 바로 이 값이다key={p.id}: 같은 모양의 카드가 여러 장일 때 React가 카드를 서로 구분하는 이름표다. 상품 번호를 쓴다. 상품 하나가 목록에서 빠지거나 순서가 바뀌면 React는 어느 카드가 어느 상품인지 알아야 바뀐 카드만 다시 그릴 수 있다. 이름표를 빼면 React가 경고를 띄우고, 엉뚱한 카드를 다시 그릴 수 있다actions={<>...</>}: 두 버튼을 묶어 카드의 빈자리에 넣는다.<>...</>는 여러 조각을 한 묶음으로 넘길 때 쓰는 빈 껍데기다id={p.id}: 좋아요나 장바구니 담기를 눌렀을 때 어느 상품에 대한 것인지 버튼에게 알려 준다
주문 내역 화면이라면 actions를 넘기지 않으면 된다. 카드는 한 번도 고치지 않았다. 공식 문서도 entities의 화면 조각에 대해 「다른 업무 로직은 props나 빈자리(slot)로 붙인다」고 적어 두었다.
규칙 2. 같은 층의 조각끼리는 서로 못 부른다.
// features/like/LikeButton.jsx
import { useCart } from '@/features/cart'; // 하지 않는다: 같은 층(features)끼리
좋아요와 장바구니는 둘 다 features다. 서로 모르는 사이여야 한다. 이유는 종류별 폴더 섹션에서 본 엉킴이다. 같은 층끼리 부르기 시작하면 좋아요가 장바구니를 부르고 장바구니가 다시 좋아요를 부르는 식으로 서로 기대게 되고, 좋아요 하나를 고치거나 지울 때 장바구니까지 깨진다. 기능 하나를 폴더 하나로 떼어 낸 이득이 사라진다. 둘 다 필요한 것이 생기면 아래층(entities나 shared)으로 내리거나, 위층에서 둘을 조립한다. 앞에서 ProductCard를 아래로 내린 것이 바로 이 규칙이었다.
조각 안은 역할로 나누고 index.js 하나로만 밖에 내보낸다
세 번째로 써는 곳은 조각 안이다. features/like/ 같은 조각 하나를 다시 역할별 칸으로 나눈다. 이 칸을 세그먼트(segment)라고 부른다.
features/like/
├── ui/ 화면 (LikeButton.jsx)
├── model/ 상태와 규칙 (useLike.js)
├── api/ 서버 호출 (likeApi.js)
└── index.js 밖에 보여줄 것만 내보내는 창구
공식 문서가 정해 둔 세그먼트 이름은 다섯이다. 화면을 맡는 ui, 서버 호출을 맡는 api, 상태와 규칙을 맡는 model, 그 조각 안에서만 쓰는 작은 도우미 함수를 두는 lib, 설정 값을 두는 config다. 문서는 components(화면 조각 모음), hooks(훅 모음), types(데이터 모양 정의 모음) 같은 이름은 나쁜 세그먼트 이름이라고 적어 두었다. 「무엇인지(종류)」를 말할 뿐 「무엇을 위해 있는지(목적)」를 말하지 않아서, 코드를 찾을 때 도움이 안 된다는 이유다. api는 「서버와 주고받는다」는 목적을 말하는 이름이고, hooks는 「훅이라는 형식의 함수」라는 종류를 말하는 이름이다. 그리고 세그먼트 api는 앞의 종류별 폴더 api/처럼 앱 전체의 서버 호출을 모으는 곳이 아니라, 조각 하나 안에서 그 조각의 서버 호출만 담는다. 같은 이유로 세그먼트 lib도 우리 앱의 lib/ 폴더와 이름만 같다. 우리 lib/는 앱 전체의 서버 호출과 데이터 함수를 모은 폴더다. 종류 이름으로 폴더를 만들면 기능 하나가 여러 폴더에 흩어진다는, 종류별 폴더 섹션에서 본 문제와 같은 뿌리다.
창구 파일 index.js는 이렇게 생겼다.
// features/like/index.js
export { LikeButton } from './ui/LikeButton';
./ui/LikeButton 파일에 있는 LikeButton을 가져와서 그대로 밖으로 내놓는다는 한 줄이다. 맨 앞의 ./는 「이 index.js가 있는 폴더에서부터」라는 뜻이다. 파일 이름 끝의 .jsx는 적지 않아도 자바스크립트가 알아서 찾아 준다. useLike나 likeApi는 여기 적지 않았으니 밖에서는 가져갈 수 없는 것으로 친다. 밖에서는 @/features/like로만 가져가고, 안쪽 파일(@/features/like/model/useLike)을 직접 집어 가지 않는다. 문서는 이걸 「모든 조각은 공개 API를 정의해야 한다」는 규칙으로 적는다. 여기서 공개 API는 「밖에서 써도 된다고 열어 둔 창구」라는 뜻이다.
이유는 자바의 package-private이 안쪽 클래스를 숨기는 이유와 같다. 창구만 그대로면 안쪽 파일을 마음대로 옮기고 고쳐도 밖이 깨지지 않는다. 다만 자바와 달리 자바스크립트는 이걸 컴파일러가 막아 주지 않는다. 안쪽 파일 경로를 직접 적어도 그냥 돌아간다. 지금 상태로는 약속일 뿐이고, 강제하려면 import를 검사하는 도구를 따로 걸어야 한다. 이 글에서는 어떤 도구를 쓸지까지는 보지 않았다.
정리하면 FSD는 이렇게 세 번 썬다.
- 가로(층): 얼마나 기본적인가. 기본적일수록 아래로 간다. 의존은 위에서 아래로만 흐른다
- 세로(조각): 어떤 업무인가. 같은 층의 조각끼리는 서로 모르는 사이다
- 안쪽(세그먼트): 무슨 역할인가.
ui/model/api같은 역할로 나눈다. 공식 이름은lib,config까지 다섯이고, 필요한 것만 만든다
우리 client 앱의 components를 FSD 층에 대 보니 명사와 동사는 이미 갈려 있었다
공부한 것을 실제 코드에 대 봤다. 우리 Next.js 앱 코드는 arimom-project라는 이름의 Git 저장소에 있다.
그 전에 하나를 먼저 발견했다. 표준 목록에는 앱이 둘(client · admin)이라고 적혀 있는데, 지금은 셋이다. client는 방문자가 보는 공개 사이트, admin은 직원이 쓰는 관리자 화면이다. 셋째인 business(frontend/business/nextjs)는 계약서 전자서명과 프로필 입력 화면으로, 새로 생겨 있었다. 표준 목록이 실제보다 뒤처져 있다. 이 글에서는 고치지 않았다.
client 앱의 src/components/에서 여섯 파일을 골라, 각 파일이 실제로 무엇을 import 하고 무엇을 하는지 열어 본 뒤 층을 매겼다.
| 파일 | 실제로 하는 일 | FSD 층 |
|---|---|---|
icons.js |
하트, 눈, 시계 아이콘 모음. 아무것도 import 하지 않는다 | shared |
ReviewCard.js |
후기 하나를 사진·제목·조회수로 보여준다. 재료는 ImagePlaceholder와 아이콘뿐이다 |
entities |
ReviewActions.js |
후기에 좋아요 누르기·취소하기, 공유하기. lib/reviews.js의 likeReview로 서버에 요청한다. 후기 상세(/reviews/[id])와 정보 글(/info/[slug]) 두 페이지에서 쓴다. 대괄호 자리에는 글마다 다른 값(후기 번호, 글의 영문 이름)이 들어간다 |
features |
InquiryStatusBadge.js |
문의가 답변 완료인지 대기 중인지 딱지로 보여준다(11줄) | entities |
InfiniteReviewList.js |
스크롤을 내리면 후기를 더 불러와 ReviewCard를 늘어놓는다. 불러오는 동안은 회색 빈 카드를 보인다 |
widgets 또는 entities |
Header.js |
모든 페이지 맨 위에 붙는 머리 영역. 재료는 design-system/ 폴더의 버튼, icons.js의 아이콘, lib/site-data.js에 적힌 사이트 메뉴 목록뿐이고(업무 데이터는 없다), 모든 페이지를 감싸는 파일인 app/layout.js가 불러서 화면에 붙인다 |
app(레이아웃) |
Header는 공부할 때 widgets로 답했고 Claude도 정답으로 매겼는데, 글을 다 쓴 뒤 검토하다가 틀린 것을 알았다. 「화면의 한 구역」이라는 겉모습에 둘 다 끌린 것이다. 실제 재료를 열어 보면 버튼과 아이콘(shared), 사이트 메뉴 목록뿐이다. entities나 features를 하나도 조립하지 않는다. ReviewCard를 entities로 고친 것과 같은 기준을 대면 widgets가 아니다. 모든 페이지에 똑같이 붙는 틀이니, FSD 문서대로라면 「앱 전체 레이아웃은 app이 맡는다」에 해당한다.
InfiniteReviewList는 답이 둘이다. 「ReviewCard(entities)를 늘어놓고 불러오기를 더한 조립」으로 보면 widgets다. 「후기라는 명사 하나의 목록」으로 보면 entities/review 조각 안에 들어가는 컴포넌트다. 사람마다 답이 갈린다. 이게 다음 섹션의 쟁점과 이어진다.
여섯 개를 매기고 나니 세 가지가 보였다.
하나, 명사와 동사는 이미 다른 파일로 갈려 있었다. ReviewCard(보여주기)와 ReviewActions(좋아요 누르기)가 처음부터 따로다. FSD를 몰랐는데도 손이 자연스럽게 그렇게 나눴다.
둘, 반대로 섞여 있는 곳도 있었다. lib/reviews.js 한 파일에 searchReviews와 likeReview가 같이 있다. searchReviews는 이름에 「검색」이 들어 있지만, 하는 일은 서버에서 후기 목록을 가져오는 것이다. 「후기를 어떻게 가져오나」는 후기라는 명사에 딸린 일이라 명사 쪽이다. 앞의 SearchBar가 동사였던 건 「사용자가 검색어를 넣고 엔터를 치는 행동」을 맡았기 때문이다. 같은 「검색」이라도 사용자의 행동이면 동사, 데이터를 가져오는 방법이면 명사에 붙는다. 반면 likeReview는 사용자가 좋아요를 누르는 행동이라 동사 쪽이다. FSD였다면 각각 entities/review/api와 features/like-review/api로 갈렸을 것이다.
셋, 막는 규칙이 없었다. 지금은 ReviewCard가 ReviewActions를 import 하지 않는다. 그런데 그걸 막는 규칙이 어디에도 없다. 안 그런 건 지금까지 그렇게 짜 왔기 때문일 뿐이다.
FSD는 이미 정한 컴포넌트 자리 표준과 기준은 부딪치지만 결론은 많이 겹친다
우리 client 앱에서 맨 위 머리 영역인 Header는 components/ 바로 밑에 있고, 맨 아래 회사 정보와 약관 링크가 있는 바닥 영역인 Footer는 홈 화면의 큰 덩어리들을 모아 둔 components/sections/ 안에 있다. 둘 다 모든 페이지에 붙어야 하는 같은 종류의 조각인데 사는 곳이 다르다. 대화 도중에 나는 「이 문제가 FSD에서는 둘 다 widgets라 고민거리가 아니다」라는 설명을 듣고 그렇게 받아들였다. 그건 이미 정한 표준을 확인하지 않은 말이었다. 그리고 앞에서 본 대로 FSD 문서 기준으로도 모든 페이지에 붙는 틀은 widgets가 아니라 app이라서, FSD 기준으로도 맞지 않는 말이었다. 컴포넌트 자리 표준이 같은 문제를 먼저 풀어 두었다.
그 표준을 읽으려면 「갈래」라는 말을 먼저 알아야 한다. 갈래는 성격이 비슷한 주소들을 묶은 단위다. 예를 들어 자주 묻는 질문 목록 화면(/faq)과 그중 3번 질문 하나를 보여주는 화면(/faq/3)은 한 갈래다. 그리고 _components처럼 폴더 이름 앞에 붙은 _는 「이 폴더는 주소가 아니다」라고 Next.js에게 알리는 표시다. app/ 아래 폴더는 원래 전부 주소가 되는데, _가 붙은 폴더만 빠진다.
부딪치는 곳은 「무엇으로 가르나」다. 그 표준의 원칙 1은 두 안을 놓고 골랐다.
- 성격으로 가르는 안: 레이아웃(모든 페이지에 똑같이 붙는 머리·바닥 같은 틀)인가, 섹션(한 페이지 안의 큰 덩어리)인가, 도메인인가로 폴더를 나눈다. 버린 이유는 「성격은 경계에서 흐려지고, 판단이 필요한 기준은 사람마다 다르게 답한다」였다. 실제로
Header와Footer가 그렇게 갈렸다 - 누가 그리는가로 가르는 안: 모든 페이지를 감싸는 파일인 layout.js가 그리면
components/layout/, 한 갈래 안에서만 쓰면app/<갈래>/_components/(<갈래>자리에faq같은 실제 갈래 이름이 들어간다), 갈래 밖에서도 쓰면components/. 파일을 열어 보면 답이 나오는 기준이라 이쪽을 골랐다. 이 세 곳을 이 글에서는 「세 자리」라고 부른다
FSD의 「명사냐, 동사냐, 조립이냐」는 그 표준이 버린 「성격으로 가르는 안」이다. 이 글에서만 그 흐려짐이 네 번 나왔다. SearchBar, ReviewCard, InfiniteReviewList, 그리고 채점한 쪽까지 틀린 Header. FSD의 층 분류를 폴더 기준으로 그대로 들여오면, 그 표준이 버린 안을 다시 주워 오는 셈이 된다.
그런데 공식 문서를 읽고 나니 겹치는 곳이 더 많았다. widgets 섹션에서 본 대로 지금 FSD는 widgets를 권하지 않고, 그 덩어리를 이렇게 나눠 보낸다.
| FSD가 보내는 곳 | 우리 컴포넌트 자리 표준 |
|---|---|
한 화면에서만 쓰는 조립은 그 pages 안 |
한 갈래에서만 쓰면 app/<갈래>/_components/ |
앱 전체 레이아웃은 app |
layout이 그리면 components/layout/ |
업무를 모르는 화면 부품은 shared |
갈래 밖에서도 쓰면 components/. 버튼·색깔 같은 가장 기본 부품은 따로 design-system/ |
「한 화면」과 「한 갈래」는 범위가 조금 다르다. 우리 표준은 한 갈래 안의 여러 화면이 같이 쓰는 것까지 그 갈래에 둔다. FSD 문서도 아주 비슷한 페이지 여럿(회원가입과 로그인 같은)은 페이지 조각 하나로 묶어도 된다고 적어 두어서, 실제로 두는 자리는 거의 같아진다.
성격으로 가장 흐려지던 층(widgets)을 FSD가 스스로 줄였고, 줄이고 나서 남은 배치는 우리가 「누가 그리는가」로 정한 배치와 거의 같은 모양이 됐다. 우리 표준이 FSD를 몰랐는데도 비슷한 곳에 닿아 있었다.
맞지 않는 곳도 남는다. 우리 표준의 components/는 「갈래 밖에서도 쓴다」 하나로 모은다. FSD는 거기를 shared · entities · features 셋으로 다시 나눈다. 위 표의 세 번째 줄은 components/의 일부와만 맞는다. 그리고 그 표준을 정해 두었지만 client 앱에는 아직 적용되지 않았다. Footer는 여전히 components/sections/ 안에 있다.
그래서 FSD에서 가져올 것은 폴더가 아니라 의존 방향이라는 가설을 세웠다
여기까지 오니 FSD를 들여오는 방법이 둘로 보였다.
안 E: FSD 폴더 구조를 통째로 들여온다. src/ 아래를 app/ · pages/ · features/ · entities/ · shared/로 갈아엎는다(widgets는 지금 가이드가 권하지 않아서 뺐다). 얻는 것은 방향 규칙과 폴더 이름이 한 몸이 된다는 점이다. 폴더 이름만 보고 무엇이 무엇을 부를 수 있는지 안다. 그런데 Next.js가 app/을 라우팅(주소마다 어떤 화면을 보여줄지 정하는 일) 폴더로 먼저 차지하고 있어서, FSD의 app 층과 이름이 정면으로 부딪친다. 게다가 이미 정한 컴포넌트 자리 표준의 세 자리를 뒤집어야 하고, 그 표준이 버린 「성격으로 가르기」를 폴더 기준으로 되살린다.
안 F: 폴더는 지금 표준대로 두고, 「아래는 위를 못 부른다」는 방향 규칙만 가져온다. 우리 폴더 사이에 위아래를 매기고, 거꾸로 가는 import만 막는다. 대상은 처음에 말한 다섯 폴더 중 다른 파일을 불러 쓰는 넷(app/, components/, lib/, design-system/)이다. styles/는 CSS 파일이라 다른 파일을 불러 쓰지 않아서 뺐다.
위아래를 예로 들면 이렇다. 맨 위에 Next.js의 주소 폴더 app/(FSD의 app 층이 아니라 페이지 파일들이 사는 곳), 그 아래 components/layout/, 그 아래 components/, 맨 아래에 lib/과 design-system/이 같은 높이로 놓인다. 기준은 FSD의 층 이름이 아니라 지금 코드가 실제로 부르는 방향이다. 우리 폴더는 FSD 층과 한 칸씩 맞지 않아서(lib/reviews.js 한 파일에 명사 쪽과 동사 쪽이 섞여 있다) FSD의 「얼마나 기본적인가」를 그대로 댈 수 없다. 그래서 지금 코드가 실제로 지키고 있는 방향을 위아래로 굳히는 쪽으로 예를 들었다. 지키고 있는지는 아래 「확인한 것」에서 세어 봤다.
lib/과design-system/은 둘 다components/폴더의 파일을 불러 쓰지 않는다.lib/은 서버 호출과 후기·문의 같은 업무 데이터를 다루고,design-system/은 우리 업무를 전혀 모르는 버튼과 색깔이다. 업무를 아는 정도는 다르지만 서로 부를 일이 없어서, 「누가 누구를 부르나」 기준으로는 위아래를 매길 필요가 없다. 그래서 같은 높이에 둔다components/는 그 둘을 가져다 화면 조각을 그리니 그 위다components/layout/은 공용 조각들을 가져다 모든 페이지의 틀을 짜니 그보다 위다. 폴더로는components/안에 있지만, 하는 일이 「조각을 모아 틀을 짠다」라서 한 층 위로 셌다. client 앱에는 아직components/layout/폴더가 없다(business 앱에만 있다). 컴포넌트 자리 표준대로Header와Footer를 옮긴 뒤를 가정한 것이다
이 순서라면 lib/의 파일이 components/를 import 하는 것은 거꾸로 가는 것이다. 이 순서는 설명을 위한 예일 뿐, 정한 것이 아니다.
나는 안 F 쪽으로 기울어 있다. 앞 섹션에서 본 대로 「어디에 두나」는 이미 정해졌고 FSD 결론과도 많이 겹친다. 반면 「누가 누구를 부를 수 있나」는 표준 목록에 「정한 적이 없다」고 적힌 바로 그 빈칸이고, client 앱의 세 번째 관찰처럼 지금은 막는 규칙이 아무것도 없다. FSD에서 우리에게 없는 것은 폴더가 아니라 방향이다.
대신 안 F를 고르면 FSD가 주는 「조각끼리 서로 모른다(규칙 2)」는 그대로 따라오지 않는다. 우리 components/는 shared · entities · features가 한 폴더에 섞여 있어서 같은 층끼리인지 판별할 기준이 없다. lib/reviews.js처럼 명사와 동사가 한 파일에 섞인 것도 그대로 남는다.
확인한 것이 하나 있다. 글을 검토하다가 세 앱(client · admin · business)의 import를 폴더 단위로 세어 봤다. lib/과 design-system/의 파일 안에서 components/나 app/을 가리키는 import 줄을 찾았고, components/의 파일 안에서 app/을 가리키는 import 줄을 찾았다. 세 앱 모두 0개였다. 위의 예시 순서를 거꾸로 거스르는 import는 폴더 사이에서는 지금 하나도 없다.
처음에 나는 「거꾸로 가는 import가 하나도 없다면 규칙을 거는 비용 대비 얻는 게 적다」고 생각했다. 그런데 반대로 읽으면 지금 코드를 하나도 고치지 않고 규칙을 걸 수 있다는 뜻이기도 하다. 어느 쪽으로 읽을지는 아직 정하지 않았다.
이건 아직 가설이다. 확인하지 않은 것이 셋 있다.
- 폴더 안쪽의 방향. 위에서 센 것은 폴더와 폴더 사이뿐이다.
components/한 폴더 안에서ReviewCard(명사)가ReviewActions(동사)를 부르는 것 같은 거꾸로는 세지 않았다. 갈래끼리 서로의_components/를 부르는지도 세지 않았다 - 세 앱 모두에 같은 위아래가 맞나. import 방향은 세 앱을 다 셌지만, 컴포넌트를 FSD 층에 매겨 본 것은 client 앱의
components/여섯 파일뿐이다 - 무엇으로 막나. 자바스크립트는 컴파일러가 막아 주지 않는다. 검사 도구를 걸지, 코드를 합치기 전에 사람이 읽고 확인하는 코드 리뷰에서 볼지 정하지 않았다
판단 기준 정리
| 질문 | 답 | 근거 |
|---|---|---|
| 프론트엔드에도 계층 구조가 있나 | 있다. 대표 하나가 없을 뿐이다 | 복잡함이 도메인이 아니라 상태와 조립에서 나와서, 후보들이 서로 다른 질문에 답한다 |
| 무엇을 공부 대상으로 골랐나 | FSD | 앱의 모든 파일을 층으로 줄 세워 방향을 거는 건 FSD뿐이다 |
| 헥사고날은 왜 아닌가 | 지킬 가운데가 얇다 | 약속과 구현 쌍만 늘어난다. 바깥 기술을 가두는 일은 lib/api.js가 이미 한다 |
| FSD는 무엇을 하나 | 층·조각·세그먼트로 세 번 썬다 | 위는 아래만 부른다, 같은 층 조각끼리는 못 부른다 |
| 층은 어떻게 가르나 | 모든 페이지의 틀이면 app, 아니면 아래층부터 질문 넷 | 업무를 모름 → shared, 명사 보여주기 → entities, 사용자의 행동 → features, 조립 → widgets |
| widgets는 어떻게 가르나 | 크기가 아니라 조립 여부 | 그래도 경계가 흐려서 공식 가이드가 쓰지 말라고 권한다 |
| 동사면 다 features인가 | 아니다 | 여러 페이지에서 다시 쓰일 때만 꺼낸다 |
| 우리 표준과 부딪치나 | 기준은 부딪치고 결론은 겹친다 | 성격으로 가르기는 컴포넌트 자리 표준이 버린 안이다. widgets를 뺀 FSD 배치는 우리 세 자리와 거의 같다 |
| 무엇을 가져오나 | 방향 규칙만(가설) | 「어디에 두나」는 정해졌고 「누가 누구를 부르나」만 비어 있다. 폴더 사이의 거꾸로 가는 import는 세 앱 모두 0개였고, 폴더 안쪽은 아직 세지 않았다 |
이 정리에 닿기까지
시작은 「백엔드엔 3계층과 헥사고날이 있는데 프론트엔드엔 없나」였다. 표준 목록의 프론트엔드 아키텍처 칸이 status: open이라 공부한 것이 그대로 그 칸의 재료가 될 거라고 봤다. 후보로 MVC/MVVM, Flux/Redux, 헥사고날, FSD가 나왔고, Atomic Design은 아키텍처가 아니라 크기 기준이라 뺐다.
헥사고날 대신 FSD를 골랐다. 우리 화면 코드는 지킬 업무 규칙이 얇아서, 헥사고날을 옮기면 약속과 구현 쌍만 늘어난다고 판단했다. 바깥 기술을 가두는 일은 API 계층 표준이 이미 하고 있었다. 빈칸은 「누가 누구를 부르나」였고, 그걸 규칙으로 거는 건 FSD였다.
처음 설명은 한꺼번에 너무 많아서 이해가 안 됐다. 종류별 폴더, 기능별 폴더, 여섯 층, 두 규칙, 세그먼트를 한 번에 들었다. 「shared가 뭐야」라는 질문으로 되돌아갔고, 거기서부터 층 하나씩, 맨 아래부터, 문제를 풀면서 올라갔다. shared → entities → features → widgets → pages · app 순서다. 층마다 질문 하나를 붙이니 자리가 정해졌다.
공부할 때 틀린 두 개가 전부 widgets였다. SearchBar는 눈에 띄는 크기 때문에, ReviewCard는 재료가 여러 개라서 widgets로 골랐다. 둘 다 겉모습에 끌린 것이었다. 그래서 「크기가 아니라 조립 여부」를 기준으로 붙들었다.
실제 코드를 열어 보니 전제 둘이 틀려 있었다. 앱은 둘이 아니라 셋이었다. 그리고 대화 중에 「Header와 Footer는 FSD에선 둘 다 widgets라 고민할 게 없다」는 설명을 받아들였는데, 컴포넌트 자리 표준이 그 문제를 이미 「누가 그리는가」로 풀었고, FSD 같은 「성격으로 가르기」를 일부러 버린 표준이었다. FSD를 통째로 들여오는 건 버린 안을 되살리는 일이 된다.
공식 문서를 확인하자 공부한 내용 셋이 문서와 달랐다. 글로 남기기 전에 FSD 문서 원본을 읽었다. 첫째, shared는 「다른 앱에 붙여도 되나」보다 느슨한 「우리 업무 데이터를 다루지 않나」였다. 모양에 회사 티가 나는 것은 괜찮았다. 둘째, 동사라고 다 features가 아니라 여러 페이지에서 쓰일 때만이었다. 셋째, 문서가 widgets 층을 쓰지 말라고 권하고 있었다. 이유는 내가 SearchBar에서 겪은 그 흐려짐이었다. 그리고 widgets를 빼고 남는 FSD 배치가 우리 컴포넌트 자리 표준의 세 자리와 거의 같은 모양이었다.
글을 다 쓴 뒤 검토하다가 채점 하나가 틀린 것을 알았다. 공부할 때 정답으로 처리한 Header도 「조립 여부」 기준으로는 widgets가 아니었다. 재료가 전부 shared였고, 모든 페이지에 붙는 틀이라 FSD 문서대로라면 app이다. 채점한 쪽도 「화면의 한 구역」이라는 겉모습에 끌렸던 것이다. 대화 중에 들었던 「Header와 Footer는 FSD에선 둘 다 widgets」라는 설명도, 우리 표준을 확인하지 않았을 뿐 아니라 FSD 기준으로도 틀린 말이었던 셈이다.
그래서 결론이 「FSD를 쓰자」에서 「FSD의 방향 규칙만 가져오자」로 옮겨 갔다. 「어디에 두나」는 이미 정해졌고 FSD 결론과도 겹친다. 빈칸은 「누가 누구를 부르나」 하나다. 검토하며 세어 보니 폴더 사이의 거꾸로 가는 import는 세 앱 모두 0개였다. 폴더 안쪽은 아직 세지 않았으므로 가설로 남긴다.
정리
- 프론트엔드에도 계층 구조는 있다. 복잡함이 도메인이 아니라 상태와 조립에서 나와서, 여러 후보가 서로 다른 질문에 답할 뿐이다
- FSD는 세 번 썬다. 가로(층)로 위는 아래만 부르게 하고, 세로(조각)로 같은 층끼리 서로 모르게 하고, 안쪽(세그먼트)을 역할로 나눈다
- 층은 아래층부터 질문 넷으로 가른다. 업무를 전혀 모르나, 명사를 보여주기만 하나, 사용자가 하는 행동인가, 부품을 조립하나
- widgets는 크기가 아니라 조립 여부로 가른다. 그래도 경계가 흐려서 공식 가이드는 widgets를 권하지 않는다
- FSD의 「성격으로 가르기」는 우리 컴포넌트 자리 표준이 버린 안이다. 그런데 widgets를 뺀 FSD의 배치는 우리 세 자리와 거의 같다
- 우리에게 빠진 건 폴더가 아니라 방향으로 보인다. 아직 가설이다. 폴더 사이의 거꾸로 가는 import는 세 앱 모두 0개였다. 다음은 폴더 안쪽, 특히
components/안에서 명사가 동사를 부르는 곳이 있는지 세는 일이다
자신만의 철학을 만들어가는 중입니다.
댓글남기기