안녕하세요. 프론트엔드 개발자 Lennon입니다.
이전 리액트 프로젝트 구조에 대한 글을 많은 분들이 읽고 공감을 해주셨습니다.
그만큼 많은 프론트엔드 개발자분들께서도 좋은 구조에 대해 항상 관심을 가지고 계신 것 같아요.
뜬금없는 이야기지만 프론트엔드에서 한 번 구조를 짜 놓으면 그대로 계속 개발하기 십상인 것 같아요.
사내 개발이던 팀 프로젝트던 전체 구조를 바꾸는 리팩토링 시간을 할애하기가 쉽지 않고, 전체 구조를 바꾸는 동안 제품은 가만히 있지 않고 다른 개발자들로 인해 개발이 이루어질 테니까요.
이전에 언급만 하고 넘어갔었던 FSD(기능 분할 설계)가 이젠 기업에서도, 최근엔 현재 멘토링을 하고 있는 프론트엔드 국비 수강생들도 적용할 만큼 대중화가 된 것 같아요.
이처럼 기능 응집화 관련 글들을 자주 접하다보니 저희 사내 프로젝트에 적용하면 좋은 점들이 많을 것 같았고, 프로젝트가 더 커지기전에 서둘러 구조를 바꿔야겠다는 생각이 문득 들었습니다.
최근 사내 스프린트가 끝나고, 다음 기획을 토대로 업무 분담을 한 결과 운이 좋게 일주일 정도 리팩토링할 기간이 생기기도 했고, 새롭게 런칭하기 위해 개발하고 있는 서비스가 더욱더 유저가 필요한 기능을 겸비하여 기획이 바뀔 예정이라, 해당 기획을 기다리는 동안 위 생각을 실행하기로 했습니다.
본 글에서 설명한 구조는 FSD 글 링크
와 사실 방법론적으론 많은 관련이 없습니다. 그냥 저희 개발자들이 생각하기에 좋은 방안을 착안해 구조를 설계하였습니다.
그래서 당연하게도 정답은 아닙니다.
구조를 변경하는 이유는 위가 가장 명확해야 합니다. 나만 아는, 멋있어 보이는 마이너한 구조 및 폴더 명은 당연하게도 피해야겠죠.
먼저, 저희 제품은 Turborepo를 활용한 모노레포
로 구성되어 있습니다.
모노레포의 특성을 잘 활용해 이미 app 별로 공통되는 것들은 모두 깔끔하게 패키지화가 되어있습니다.
집중해야 할 건 당연하게도 주로 많은 시간을 할애해 개발이 이루어지는 각각의 app들입니다.
📚 모노레포를 모르시는 분들이 계실 수 있으니, app은 Vite
or Next.js
등으로 만든 하나의 React 프로젝트라고 생각하시면 됩니다.
프론트엔드 개발자가 개발하기 편하다는 건 무엇을 의미할까요??
제 짧은 경험상 아래 작성한 정도가 있을 것 같아요.
기능 개발 및 수정을 하기 위해 컴포넌트, 전역 상태 관련 파일, Api 관련 파일,
Constant 및 Util, Hook을 만들 때 어디에 만들어야 할지, 어딜 수정해야 할지 명확하다.
개발하기 위해 만들어진 공통 요소들이 있다. (디자인 시스템, common Constants, common Utils, Package)
기획상 삭제나 수정되어야 하는 기능이 있으면, 사이드 이펙트가 최소화되어야 한다.
기존 기능 수정 및 추가가 아닌 신규 개발이라도 기존 구조를 참고하면 금방 똑같이 구조를 잡아갈 수 있다.
프로젝트 내부 구조가 일관성이 있어야한다.
저희 기업을 포함한 대부분 스타트업은 빠르게 기능을 추가하고 수정하고 삭제해야 합니다.
그걸 빠르고, 정확하게 도와줄 수 있는 구조, 쉽게 파악할 수 있는 구조가 개발하기 편한 구조인 것 같아요.
가장 많이 작업하실 새로운 기능 개발이 편해야 한다에 대해 조금 더 상세하게 알아보겠습니다.
새로운 기능 개발이 편하려면 어떻게 해야 할까요? 개인적으론 기능별로 응집화가 잘 되어있어야 한다고 생각합니다.
기능 개발에서 끝이 아닌, 추후에 수정을 하게 될 상황에도 다른 개발자들에 봤을 때 어딜 수정해야할지 명확하게 잘 보여야할 것 같아요.
기능별로 응집화가 잘 되어있는 구조를 보면 금방 새롭게 폴더를 만들어 컨벤션에 맞게 기능을 개발할 수 있고, 수정 및 삭제 유지보수에도 큰 도움이 된다고 생각합니다.
🤔 단순 나열
- 🗂️ components
- 📑 LandingPage.tsx
- 📑 LandingSettingItems.tsx
- 📑 Dashboard.tsx
- 📑 ProjectItems.tsx
- 📑 ProjectCreateInput.tsx
- 📑 Header.tsx
- 🗂️ utils
- 📑 getCurrentLanding.ts
- 📑 getCurrentProjdctItem.ts
만약 위처럼 나열이 되어있으면 각 컴포넌트 및 유틸들이 어디에서 어떤 기능을 하는지 헷갈리고,
만드는 기능에 대한 공통 컴포넌트가 있는지 없는지도 모르는 상태라 쉽지 않을 것 같아요.
(저는 이전에 사내에 이미 있는 컴포넌트를 많은 시간을 들여 만든 적도 있답니다 😂)
🤔 특정 관심사로 묶기
- 🗂️ components
- 🗂️ @common
- 📑 Header.tsx
- 🗂️ landing
- 📑 LandingPage.tsx
- 📑 LandingSettingItems.tsx
- 🗂️ dashboard
- 📑 Dashboard.tsx
- 🗂️ project
- 📑 ProjectItems.tsx
- 📑 ProjectCreateInput.tsx
그나마 아래처럼 특정 관심사 아래 존재한다면 관심사 별로 컴포넌트들이 응집화되어 있어 한 층 이해하기 쉬울 것 같습니다.
다만, 점점 관심사가 커져서 컴포넌트만 관심사별로 수십, 수백씩 쌓인다면 어떤 기능을 하는지는 상위 컴포넌트로 이동하면서 확인을 해야 할 것 같아요.
🤔 특정 기능으로 묶기
- 🗂️ components
- 🗂️ @common
- 📑 Header.tsx
- 🗂️ landing-setting
- 📑 LandingSettingItems.tsx
- 🗂️ landing-viewer
- 📑 LandingPage.tsx
- 🗂️ dashboard
- 📑 Dashboard.tsx
- 🗂️ project-create
- 📑 ProjectCreateInput.tsx
- 🗂️ project-setting
- 📑 ProjectItems.tsx
- 🗂️ utils
- 🗂️ project
- 📑 getCurrentProjectId.ts
- 📑 getCurrentProjectInfo.ts
- 🗂️ constants
- 🗂️ product
- 📑 productStep.ts
그렇다면 위처럼 관심사가 커졌을 때 컴포넌트 내부에서 기능별로 구분을 해야 할까요??
뭔가 조금 더 기능적으로 잘 세분화된 것처럼 보이긴 하지만, 세분화를 어디까지 하냐에 따라 계속 개발이 이루어진다면 금방 망가지기 쉬운 구조
로 보이는 것 같아요.
위처럼 상수와 유틸 함수, 훅 등도 함께 있다고 가정해 보면 관심사 및 역할에 대한 관리를 여러곳에서 해야하니 더욱더 금방 망가지는 구조가 될 것 같아요.
그럼 이제 위를 기능별로 응집화해볼까요?
이제부터 설명할 기능 응집 구조에 대해 간략히 설명드리면 총 4개의 폴더가 있습니다.
- 🗂️ app (next app router)
- 🗂️ features (기능별)
- 🗂️ shared (공유)
각 폴더별로 순차적으로 살펴보겠습니다.
기능별 응집화를 한다면 위 구조는 아마 아래처럼 이루어질 것 같습니다. 각각 기능별 상수와 유틸 함수도 존재한다고 가정하겠습니다.
- 🗂️ features
- 🗂️ @common
- 🗂️ ui
- 📑 Header.tsx
- 🗂️ landing-setting
- 🗂️ ui
- 📑 LandingSettingItems.tsx
- 🗂️ utils
- 🗂️ constants
- 🗂️ landing-viewer
- 🗂️ ui
- 📑 LandingPage.tsx
- 🗂️ utils
- 🗂️ constants
- 🗂️ dashboard
- 🗂️ ui
- 📑 Dashboard.tsx
- 🗂️ utils
- 🗂️ constants
- 🗂️ project-create
- 🗂️ ui
- 📑 ProjectCreateInput.tsx
- 🗂️ utils
- 🗂️ constants
- 🗂️ project-setting
- 🗂️ ui
- 📑 ProjectItems.tsx
- 🗂️ utils
- 🗂️ constants
일단 이렇게 하면 유틸 함수, 상수, 컴포넌트들이 같은 역할을 가진 곳으로 응집이 된 것 같아요.
상수와 유틸 함수, 훅 등도 함께 있다고 가정해 보면 관심사 및 역할에 대한 관리를 여러곳에서 해야하니 더욱더 금방 망가지는 구조가 될 것 같아요.
상단에서 걱정한 위 요소는 해결이 된 것 같아요!
그다음 단계론 각 features
를 포함한 app 전역에서 사용되는 것들은 shared
폴더로 분류하면 조금 더 명확할 것 같아요.
- 🗂️ features
- 🗂️ landing-setting
- 🗂️ landing-viewer
- 🗂️ dashboard
- 🗂️ project-create
- 🗂️ project-setting
- 🗂️ shared
- 🗂️ ui
- 📑 Header.tsx
- 🗂️ utils
- 🗂️ constants
shared 폴더는 app 내부 features에서 공용적으로 사용되는 것들을 모아놓은 폴더입니다.
여기까지 봤을 때 어떠셨나요? 위 구조들은 응집화가 되어있으니 좋은 구조일까요?
기능별 응집화에서 가장 중요한 게 있는데요, features
를 어떻게 분류할 건지 즉 기능을 나누는 기준을 팀원과 정하는 게 가장 중요합니다.
응집도를 할 기능 단위를 정하는 건데요, 이건 프로젝트마다 정말 다를 것 같아요.
현재 저희가 개발하고 있는 서비스는 아래처럼 순차적인 단계가 있습니다.
프로덕트 생성 => 프로덕트 세팅 => 프로젝트 생성 => 프로젝트 세팅 => 랜딩 페이지 생성 => 랜딩 페이지 세팅 => 랜딩 페이지 수정 등등 액션
순차적인 단계가 있으니 공통된 UI를 분류하기가 좋기도 하고, 랜딩 페이지 수정을 제외한 부분들은 공통되는 UI들이 많아 핵심 로직에 집중할 수 있는 도메인별로 features
를 나눌 수 있게 되었습니다.
아래부터는 도메인별로 폴더명을 뭐로해야할지 고민한 지점을 조금 작성해보았습니다.
🤔 도메인 네임
- 🗂️ features
- 🗂️ login /login
- 🗂️ create-product /create/product
- 🗂️ create-product-[id] /create/product/[id]
- 🗂️ main-[productId]-[projectid]-setting-project /main/[productId]/[projectid]/setting/project
...
처음엔 위처럼 아예 도메인 네임으로 폴더 구조를 짰었는데요,
위가 정말 명확할 수 있지만, 폴더 명이 너무 긴 경우가 있을 때 가독성이 안 좋기도 해서 정말 기능별로 네이밍을 나누기로 했습니다.
🤔 큰 기능 => 역할로 구분
- 🗂️ features
- 🗂️ product
- 🗂️ @common
- 🗂️ create
- 🗂️ setting
...
그 다음은 위처럼 관심사 => 역할
구분하고, 역할에서 공통되는 건 @common
폴더 내에 넣도록 구성을 했었는데요.
이렇게 되면 전역 shared 폴더
의 역할이 모호해진다는 생각이 들었어요.
예로 main 페이지에서 product 관련 Api를 활용한다고 했을 때 feature 경로로 import 해야하는데, 그러면 뭔가 shared의 역할과 혼동이 올 수 있다고 생각했습니다.
features 폴더를 열었을 때 역할로 구분이 된 것 처럼 보이지 않는 것도 한 몫한 것 같아요.
✅ 역할로 구분
- 🗂️ features
- 🗂️ auth /login
- 🗂️ product-create /create/product
- 🗂️ product-setting /create/product/[id]
- 🗂️ project-create /create/project
- 🗂️ project-setting /create/project/[id]
- 🗂️ landing-setting /landing/[id]
- 🗂️ landing-edit /landing/edit/[id]
- 🗂️ main-dashboard /main
- 🗂️ main-landing-detail /main/[productId]/[projectid]/landing
- 🗂️ main-landing-setting /main/[productId]/[projectid]/setting
- 🗂️ main-project-setting /main/[productId]/[projectid]/setting/project
사실 프로젝트를 조금만 이해하면 위 각각의 기능들에 대해 명확하게 알 수 있었기도 했고, 각 도메인별로 역할이 너무 분명하기로해서 위처럼 구성하였습니다.
하나의 feature의 내부 폴더
는 아래와 같습니다. 내부 폴더 모두 옵셔널입니다.
- 🗂️ features
- 🗂️ landing-edit
- 🗂️ ui
- 🗂️ utils
- 🗂️ store (jotai)
- 🗂️ types
- 🗂️ constants
- 🗂️ hooks
- 🗂️ context
- 🗂️ (해당 도메인에서만 사용되는 것에 대한 폴더 네임)
모두 landing-edit
에서만 사용되는 것들로 구성되도록 하였습니다. 즉 저기 있는 폴더 내부 파일들은 모두 landing-edit (/landing/edit/[id]
) 도메인에서만 사용되는 파일들입니다.
조금이라도 다른 역할과 공통 속성이 있다면 shared 폴더 안에 파일을 정의하게 될거에요.
다음은 Shared 폴더에 대해 설명하겠습니다.
shared 폴더는 말그대로 공통되는 것들을 묶어주는 개념입니다.
shared 내부 폴더는 app 전체적으로 공유되는 @common
과 상위 관심사(landing
, project
...)로 묶어줬습니다. 이유는 landing-setting
, landing-edit
처럼 역할별로 분리되어 있지만 저 둘에서 공통적으로 쓰이는 요소들이 존재했거든요.
- 🗂️ shared
- 🗂️ @common (app 단위 내부에서 전역적으로 다 함께 쓰이는 것들)
- 🗂️ ui
- Provider
- Header
- 🗂️ utils
- 🗂️ store (jotai)
- 🗂️ types
- 🗂️ constants
- 🗂️ hooks
- 🗂️ context
- 🗂️ landing (landing-edit, landing-setting 에서 공통으로 쓰이는 것들)
- 🗂️ ui
- 🗂️ utils
- 🗂️ store (jotai)
- 🗂️ types
- 🗂️ constants
- 🗂️ hooks
- 🗂️ context
위에서 shared/@common
은 말 그대로 features
의 11개의 폴더에서 전체적으로 공유 가능한 것들을 넣습니다.
위 features 폴더 내부 폴더들을 한 번 더 살펴볼까요??
- 🗂️ features
- 🗂️ landing-edit
- 🗂️ ui
- 🗂️ utils
- 🗂️ store (jotai)
- 🗂️ types
- 🗂️ constants
- 🗂️ hooks
- 🗂️ context
- 🗂️ (해당 도메인에서만 사용되는 폴더 아무거나..)
위 features
의 특정 도메인 내부 폴더를 보니 Api 관련한 폴더가 없는 것 같아요.
Api 관련은 shared 폴더에서 관리되도록 하였습니다.
이유는 하나의 feature(ex) landing-edit
)에서 정의한 Api들이 다른 곳에 쓰이는 경우가 많았기 때문입니다.
예로 main-dashboard
에서는 ProjectList 관련 Get 요청을 합니다. main-landing-detail
에서는 Landing 정보 관련 Get 요청을 하여 미리 보기로 보여줘야 합니다.
또한, main-landing-setting
쪽에선 Landing, Project 삭제 및 수정 Delete, Patch들이 이루어집니다.
즉, Api는 하나의 feature에서만 쓰이기도 하지만, share 하게 사용되기도 합니다. 파편화되어있으면 가장 첫 번째 원칙인 프론트엔드 개발자가 개발하기 편해야 한다.
에 위배가 되는 행위였습니다.
landing 관련 Api 파편화 예시
- 🗂️ features
- 🗂️ landing-edit
- 🗂️ api
- 🗂️ mutations
- 📑 usePatchLanding.ts
- 🗂️ shared
- 🗂️ landing
- 🗂️ api
- 🗂️ mutations
- 📑 usePostLanding.ts
위처럼 usePostLanding
처럼 landing 관심사에서 공통되는 Api는 shared/landing
, landing-edit
에서만 사용되는 Api는 features/landing-edit/api
에 작성해 줘야 하니까요.
또, 특정 도메인에서만 사용되는 Api를 새로운 기능 개발에 활용해야 된다면 규칙에 의거해 관련 Api를 shared
에 또 옮겨야 되는 불필요한 작업들이 이루어질 것 같아요.
생각만해도 개발 및 유지보수하기가 힘들 것 같았습니다.
위 사항에 따라 아래와 같이 shared 폴더에 각 관심사별로 컨테이너 역할을 하는 Api 폴더를 만들어주었습니다.
- 🗂️ shared
- 🗂️ @common
- 🗂️ landing
- 🗂️ ui
- 🗂️ api ✅
- 📑 key.ts (queryKey)
- 🗂️ queries
- 📑 useVelog.ts
- 🗂️ mutations
- 📑 useSaveLanding.ts
- 📑 usePublishLanding.ts
- 🗂️ utils
- 🗂️ store (jotai)
- 🗂️ types
- 🗂️ constants
- 🗂️ hooks ✅
- 📑 useSaveAndPublish.ts
- 🗂️ context
- 🗂️ product
- 🗂️ api
- 🗂️ project
- 🗂️ api
- 🗂️ main
- 🗂️ api
위처럼 활용하면 각 도메인별로 필요한 Api를 가져다 사용하면 되고, query key
또한 관리하기 편하다는 장점이 있었습니다.
만약 main에서 project 관련 Api를 활용하려면 shared/project/api/qureies/...
에서 가져와서 사용하면 되겠죠? 또한 project 관련 features에서 가져오는 게 아닌 shared에서 가져오니까 위계적으로도 잘 맞는 것 같아요.
여기까지가 features
, shared
분류였습니다.
다음은 Views, App 관련 폴더를 살펴보겠습니다.
사내 제품은 next.js app router
를 활용하고 있습니다. views
는 app에서 사용할 view 컴포넌트를 넣어놓은 폴더입니다.
views를 pages로 네이밍하고 싶었지만, next.js를 활용해서 굳이 혼동있게 하지 않았습니다.
(src 폴더 내부에 넣는 방법도 있었지만, 굳이 그러지 않았습니다)
(Views라는 폴더없이 특정 path의 page.tsx와 같은 Depth에 클라이언트 컴포넌트로 만드셔도 무방합니다.)
- 🗂️ views
- 🗂️ auth
- 🗂️ LoginPage
- 📑 ErrorFallback.tsx
- 📑 Fallback.tsx
- 📑 index.tsx
- 🗂️ product
- 🗂️ ProductCreatePage
- 📑 ErrorFallback.tsx
- 📑 Fallback.tsx
- 📑 index.tsx
- 🗂️ ProductSettingPage
- 📑 ErrorFallback.tsx
- 📑 Fallback.tsx
- 📑 index.tsx
- 🗂️ project
- 🗂️ landing
- 🗂️ main
예로 위 views/product
내부 ProductCreatePage는
features/product-create
, shared/@common
, shared/product
요소들을 활용하여 조립만 하면 됩니다.
app 라우팅 폴더 내부 page.tsx
, layout.tsx
또한 features/product-create
, shared
, views
를 조립하면 됩니다.
즉 약속된 위계는 아래와 같습니다.
Folder | Available |
---|---|
app | views , features , shared |
views | features , shared |
features | shared |
shared | ❌ |
위계를 지키면서 개발하는 건 참 어렵다고 생각이 들지만, 사실 폴더가 4개 뿐이라 위 구조를 잘 이해했다면 위계를 신경쓰지 않아도 자연스럽게 절차적으로 개발이 이루어진다고 생각합니다.
각 전역 폴더(features
, shared
, views
) 에서 각각의 위계에 따라 조립해 사용하므로 tsconfig에서 paths을 설정해 alias으로 사용하면 불필요한 경로(../../../
)들이 없어져 가독성이 좋을 것 같아요.
{
"extends": "../../tsconfig.next.json",
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules", "dist"],
"compilerOptions": {
"outDir": "dist",
"paths": {
"@/*": ["./*"]
},
}
}
// as-is
import { ActionButton } from "../../../../../shared/@common/ui";
// to-be
import { ActionButton } from "@/shared/@common/ui";
지금까지 작성한 글에 따르면 특정 app 내에서만 공통적인 부분들은 shared
, app 전체적으로 공통되는 건 package(@landwich/shared)
에 정의하기로 했습니다. 이 둘을 구분하는 컨벤션이 중요할 것 같아요.
예로 현재 features/landing-edit
, features/project-setting
에서만 useOnClickOutside
hook을 사용하고 있다고 하겠습니다.
위 규칙을 따르면 shared/@common/hooks
에 정의하는 게 맞지만, 조금만 더 생각해 보면 다른 app에서 충분히 사용할 수 있는 utils hook이니 저희 공용 패키지인 @landwich/shared
에 정의해주면 어떨까요?
다른 app에서도 사용할 때 옮기면 되지 않나요?
위 의견도 충분히 존중하지만, 위에서 말씀드린 것처럼 다른 app을 개발하고 있을 때 저 hook이 이미 정의되어 있는 걸 모른다면 새롭게 hook을 만들 수 있을 것 같아요. 즉 구조적으로 품질이 떨어질 수 있을 것 같습니다.
저희는 공통적으로 사용될 수 있는 요소들은 웬만하면 공용 패키지에 정의하고 있습니다. 예로 순수 유틸 함수 및 Context, hook, type 정도가 있을 것 같아요.
최근 유행하는 FSD구조를 보고 프로젝트 구조를 재구성해보았습니다.
저희 프로젝트는 도메인별로 기능이 세분화가 되어있어 자연스럽게 도메인별로 features가 분류되었습니다.
즉 도메인적으로 착안한 위 구조는 하나의 도메인이 너무 큰 역할들을 한다면 잘 안 맞을 확률이 높습니다.
다만, features를 어떻게 분류할지 팀원과 잘 논의하고 역할별로 잘 세분화한다면 개인적으론 기능별로 응집화가 되어있어 사이드이펙트를 최소화해 유지보수하기 좋은 구조라고 생각합니다.
시작하며 말씀드린 것처럼 당연하게도 정답은 아닙니다. 조금 더 개선할 수 있는 부분이 보인다면 언제든지 댓글 달아주세요 🙂
긴 글 읽어주셔셔 감사합니다.
보시는 분들 모두 앞으로의 여정에서 더 큰 성과를 이루시길 응원하며 마무리하겠습니다.
레논님 경험을 공유해주신 덕분에 저희팀에서도 도움 많이 되었어요!
하나 수정이 필요한 부분이 있습니다.
Features, Shared 폴더
에서
landing-viewer
가 두개 존재하네요 ㅎㅎㅎ