들어가며
예전에 FSD(Feature-Sliced Design)를 선택한 이유와 기본 레이어 구조를 소개했었어요. 이번에는 한 발 더 나아가 아래의 주제를 추가적으로 다뤄보려 해요.
- Layered Architecture와 FSD의 관계
- 아키텍처를 그리는 것보다 의존성을 어떻게 강제하는지가 더 중요하다는 것
- 실제 프로젝트에서 FSD를 적용하며 부딪혔던 한계와 고민
아키텍처가 필요한 이유
새 파일을 만들 때 어디에 넣을지를 5분 이상 고민해본 적 있다면, 그건 아키텍처가 필요하다는 신호예요.
기존에 쓰던 역할 중심 구조의 문제는 명확했어요. 하나의 기능과 관련된 코드가 여러 군데에 흩어져 있고, 그 코드들을 찾으려면 직접 생각의 흐름을 다시 꺼내 연결고리를 만들어가야 했어요. 처음엔 src/components, src/utils 두세 폴더로 시작했는데, 6개월 뒤엔 utils 안에 파일이 수십 개가 돼 있었어요.
아키텍처는 그 비효율을 개선하는 계획으로, 크게 Layered와 FSD 두 갈래로 나뉘어요.
Layered Architecture
Layered Architecture는 코드를 역할별 계층으로 나누는 방식이에요. 대표적인 4계층 구조는 아래와 같아요.

규칙은 위에서 아래로만 import한다는 것만 지키면 됩니다.
Layered의 한계
그러나 이 구조에는 분명한 단점이 있어요. 기능 하나를 추가하려면 4개 레이어를 동시에 건드려야 하고, 기능 하나를 추적하려면 4개 폴더를 동시에 열어야 해요. 기능이 레이어에 흩어지는 구조가 되는 거예요. 그리고 경계가 모호한 코드는 자연스럽게 utils나 shared로 모이는 함정이 생겨요.
FSD: Layered의 단점을 보완한 구조
FSD는 이 구조적 결함에 대한 하나의 답이에요. 수평으로 자르지 말고 수직으로 자르자는 거예요.
Layered가 하나의 코드를 4계층으로 나눈다면, FSD는 코드를 도메인 구역별로 먼저 나눠요. 이걸 한 줄로 정의하면 이렇게 돼요.
FSD = Layered + 수직 슬라이싱
큰 틀의 단방향 의존성 규율은 그대로 가져가면서, 그 안에서 기능별로 다시 자르는 거예요. FSD는 코드를 두 가지 기준으로 바라봐요.
- 어떻게 묶고 분리하는지 = 도메인·기능 단위로 코드를 묶어요. (예:
features/job) - 다른 기능에 의존되는 정도 = 역할이나 재사용 측면에서 레이어를 나눠요. (예: 비즈니스 로직 없는 날짜 포맷 함수는
shared에 위치)
실제 프로젝트에서의 FSD
저희 팀은 FSD 구조에 따라 widget 레이어에서 큰 기능 단위를 만들고, page에서 widget을 조립해 레이아웃을 배치하고 있어요.
// pages/dashboard
const DashboardPage = () => {
return (
<>
<VisaStatusListSection />
<PhaseOverviewSection />
<MyBookmarkedJobsSection />
</>
);
};
page 코드는 깔끔해요. 하위 레이어로 내려가면 이렇게 이어져요.
// widgets/dashboard/ui/VisaStatusListSection
const VisaStatusListSection = () => {
return (
<Section title="My Status">
<VisaStatusList />
</Section>
);
};
// widgets/dashboard/ui/VisaStatusList
export const VisaStatusList = () => {
const { data } = useQuery({ ...USER_STATUS_QUERY_OPTIONS.GET_USER_STATUS() });
const renderData = getVisaStatusRenderData(data);
const { currentVisa, graduation, remaining } = renderData;
return (
<div className={styles.container}>
<VisaStatusCard icon={<DefaultStatusIcon />} {...currentVisa} />
{data?.visaType === 'D2' ? (
<VisaStatusCard icon={<GraduationCountDownIcon />} {...graduation} />
) : (
<VisaStatusCard icon={<RemainingStayIcon />} {...remaining} />
)}
</div>
);
};
현재 구조는 page → widget → widget → entities 순으로 레이어가 내려가요.
☝️ 한 가지 고민
이렇게 분리하다 보니 자연스럽게 의문이 들었어요. 한 페이지에서만 쓰이는 단순한 컴포넌트도 widget으로 분리하는 게 맞을까? page 코드는 깔끔해졌지만, 재사용성이 없어도 큰 기능 단위이기만 하면 widget에 배치해야 하는지 기준이 모호하게 느껴졌어요. 과도한 depth가 생기는 것 아닌가 하는 걱정도 들었고요..
그런데 좋은 기회로 토스 개발자분께 이에 대한 질문을 직접 여쭤볼 수 있는 기회가 생겨서 의견을 정리해서 물어본 적이 있어요! 그리고 돌아온 반응은 예상보다 부정적이었어요.
질문 원본: https://yooncandooit.notion.site/vs-depth-35ba2cfafcde802fb5e6d13571a6ecfe?source=copy_link
클린 아키텍처나 헥사고날 아키텍처 같은 엄격한 아키텍처를 기준으로 보면, 프레임워크와 독립적이어야 할 Domain 계층 안에 UI 요소인 components가 들어가는 건 명백한 아키텍처 위반이라는 거예요. FSD는 사실 지양해야 하는 디자인 패턴일 수 있다는 말이었어요. 이걸 '아키텍처'라고 부를 수 있는지 자체에도 의문이라는 개인적인 생각도 덧붙여주셨어요.
생각할 거리가 많은 피드백이었어요.
FSD는 아키텍처인가, 컨벤션인가
이 피드백 이후로 FSD를 다시 바라보게 됐어요.
FSD 공식 문서도 스스로를 "아키텍처 방법론"이라고 표현하지, 아키텍처 그 자체라고 말하지 않아요. 엄밀하게 보면 FSD는 폴더 구조 컨벤션에 가깝다는 생각이 들었어요. 클린 아키텍처처럼 도메인을 프레임워크에서 완전히 분리하거나, 의존성 역전을 구조적으로 강제하는 메커니즘이 없으니까요.
그렇다면 FSD는 쓸모없는 걸까요? 저는 아니라고 결론을 내렸어요.
FSD가 해결하려는 문제는 클린 아키텍처와 달라요. 프론트엔드의 현실적인 복잡도, 즉 도메인이 많아질수록 파일이 어디에 있는지 모르게 되고 팀원 간 기준이 달라져 코드 위치가 제각각이 되는 문제를 다루는 거니까요.
| 관점 | 클린 아키텍처 | FSD |
|---|---|---|
| 목표 | 도메인을 프레임워크에서 분리 | 기능별 응집도와 단방향 의존성 |
| 강점 | 구조적 엄격함, 테스트 용이성 | 직관적 파일 위치, 팀 협업 효율 |
| 약점 | 프론트엔드에 적용 시 오버헤드 | 엄격한 의존성 분리 메커니즘 없음 |
| 적합한 팀 | 도메인 로직이 복잡한 대형 프로젝트 | 빠른 개발, 다수 도메인 협업 팀 |
둘은 비교 대상이 아니라, 서로 다른 문제를 푸는 도구라고 생각해요.
의존성을 어떻게 강제하느냐
어떤 구조를 선택하든, 폴더를 잘 나눠놓는 것만으론 부족해요. 진짜 아키텍처 고민은 의존성을 어떻게 강제하느냐에서 출발합니다. "주의하세요"라고 말하는 건 한계가 있어서, ESLint로 규칙을 코드 레벨에서 강제하도록 했어요.
ESLint로 의존성 규칙 강제하기
// shared는 상위 레이어를 import할 수 없어요
{
files: ["src/shared/**/*.{ts,tsx}"],
rules: {
"no-restricted-imports": [
"error",
{
patterns: [
{
group: ["@/app/*", "@/widgets/*", "@/entities/*"],
message: "FSD 위반: shared는 상위 레이어를 import할 수 없어요.",
},
],
},
],
},
},
// 슬라이스 내부 경로 직접 접근 금지
{
files: ["src/widgets/**/*.{ts,tsx}"],
rules: {
"no-restricted-imports": [
"error",
{
patterns: [
{
group: ["@/widgets/*/*"],
message: "FSD 위반: 다른 슬라이스 내부 경로 접근 금지. index.ts(Public API)만 사용하세요.",
},
],
},
],
},
},
순환 참조도 별도로 막아요.
"import/no-cycle": ["error", { maxDepth: 10 }],
"import/no-self-import": "error",
Husky pre-commit으로 첫 방어선 세우기
# .husky/pre-commit
pnpm exec lint-staged
// lint-staged.config.mjs
export default {
"**/*.{js,jsx,ts,tsx}": [
"eslint --fix",
"prettier --write",
() => "tsc --noEmit",
],
};
git commit을 누르면 ESLint, Prettier, TypeScript 타입 체크가 자동으로 돌아요. 하나라도 실패하면 commit 자체가 막히도록 세팅할 수 있어요.
Public API 패턴: 옵션과 필수 구분하기
Public API 얘기에서 두 가지가 섞여 혼란이 생기는 경우가 있어서 정리해둘게요.
① 슬라이스 내부 export를 index.ts에 정리하기 = 옵션 코드 한 줄 추가할 때마다 index.ts도 같이 손봐야 하는 게 부담이 될 수 있어요. 팀 상황에 맞게 결정하면 돼요.
② 슬라이스 간 import는 반드시 index.ts를 통해서만 = 필수!
// OK
import { Header } from "@/widgets/header";
// 금지
import { Header } from "@/widgets/header/ui/Header";
내부 파일에 직접 의존하기 시작하면 리팩토링 자유도가 사라져요. 파일 이름 하나를 바꿔도 의존하는 슬라이스들을 전부 수정해야 하거든요. ①은 팀 상황에 따른 선택이지만, ②는 구조 자체를 지키기 위한 필수 규칙이에요.
마무리
이번 글을 한 줄로 요약하면, 아키텍처는 폴더 구조가 아니라 의존성 규율이라는 점!
FSD가 완벽한 아키텍처는 아니에요. 엄격한 클린 아키텍처 관점에서 보면 분명한 한계가 있고, 토스 개발자분의 피드백처럼 FSD를 '아키텍처'라고 부를 수 있는지에 대한 의문도 충분히 유효한 관점이라고 생각해요.
그럼에도 FSD를 택한 이유는, 저희가 풀어야 할 문제가 "도메인이 커질수록 파일 위치를 팀 전체가 직관적으로 알 수 있는가" 였기 때문이에요. 그 문제에 FSD는 꽤 실용적인 답을 줬어요.
widget의 depth가 과하게 깊어지는 문제나, 재사용하지 않는 컴포넌트를 widget으로 분리하는 게 맞는지의 문제는 아직도 고민 중인 주제예요. FSD는 정해진 답이 아니라 방향을 제시하는 방법론이니까요. 앞으로도 팀에 맞게 조율해나갈 것 같아요!
읽어주셔서 감사하고, 궁금한 점이 있다면 언제든 댓글 남겨주세요 💬
'web' 카테고리의 다른 글
| React 테스트 전략 - TDD (0) | 2026.07.08 |
|---|---|
| 렌더링 최적화 가이드 및 적용 (ft. Profiler, LightHouse, Memoization, 그리고 React Compiler) (0) | 2026.07.08 |
| TanStack Query 원리부터 제대로 쓰기 (0) | 2026.07.08 |
| 컴포넌트를 설계하는 원칙 (0) | 2026.07.08 |
| 엄격한 FSD 아키텍쳐 구조와 커스터 마이징 (0) | 2026.07.08 |