juncci 님의 블로그

FSD 아키텍처 적용기: 코드 위치를 설계하다 본문

[FE]

FSD 아키텍처 적용기: 코드 위치를 설계하다

juncci 2026. 2. 8. 16:58

FSD(Feature-Sliced Design) 아키텍처를 알고 계신가요?

 

프론트엔드 프로젝트를 몇 번 반복하다 보면 일정 시점부터 비슷한 문제를 만나시지 않으셨나요? <(˶`ロ´˶)>
초기에는 빠르게 개발되던 코드가 기능이 늘어날수록 점점 더 많은 맥락을 끌어안게 됩니다.
그 순간부터 구조는 개발 속도를 받쳐주는 기반이 아니라 오히려 변경을 어렵게 만드는 제약이 되었던 것 같습니다.

 

저 역시 첫 프로젝트를 진행하면서 바로 이 문제를 겪었습니다.

화면은 돌아가고 기능도 붙어 갔지만, 프로젝트가 조금만 커지면 가장 먼저 무너지는 것은 UI가 아니라 경계였습니다.

특히 다음과 같은 문제가 반복되었습니다.

  • 재사용을 기대했던 컴포넌트가 특정 페이지 로직에 강하게 묶이는 문제
  • API 호출, 상태, UI가 한곳에 섞이면서 수정 범위가 커지는 문제
  • 코드 위치를 결정하는 데 매번 고민이 필요한 구조

결국 저는 이런 질문을 계속 하게 됐습니다.

“이 코드는 어디에 둬야 하는가?”

“이 변경은 어디까지 영향을 주는가?”

“이 구조를 다음 사람은 이해할 수 있는가?”


이 질문들이 반복되면서, 폴더 구조를 포함한 아키텍처 자체를 개선해야 한다는 결론에 도달했습니다.


이 과정에서 처음 알게 된 것이 바로 FSD 아키텍처였습니다.

아래 영상은 FSD를 이해하고 실제 프로젝트에 적용하는 데 큰 도움이 됐습니다.

https://www.youtube.com/watch?v=64Fx5Y1gEOA

아직도 React 폴더 구조로 고민하고 계신가요? FSD 한 번 써보세요[제로초뉴스]

다만 중요한 건 단순히 “알게 되었다”가 아니라, 이후에도 계속 이 구조를 선택하게 된 이유였습니다.

저에게 FSD는 처음부터 “예쁜 폴더 구조”가 아니었습니다.

프로젝트가 커질수록 커지는 복잡도를 어떻게 통제할 것인가에 대한 해답이었고, 그래서 여러 프로젝트를 거치면서도 계속 사용하게 되었습니다.

FSD는 코드 배치 취향이 아니라, 복잡도를 통제하는 방식이다.


FSD를 왜 도입했는가

제가 FSD를 도입한 이유는 단순했습니다.

아키텍처가 예뻐 보였기 때문이 아니라, 실제로 해결하고 싶은 문제가 있었기 때문입니다.

정리하면 아래 세 가지였습니다.

  1. 코드 위치를 결정하는 데 드는 반복적인 판단 비용
  2. 변경 시 영향 범위를 예측하기 어려운 구조
  3. 구조를 유지할 수 있는 강제 장치의 부재

이 문제를 해결하기 위해 FSD를 선택했습니다.

그리고 실제로 적용하면서 느낀 장점은 명확했습니다.

  • 기능 단위 확장에 유리함
  • 신규 기능 추가 시 위치가 명확함
  • 정적 검사로 구조를 강제할 수 있음

특히 마지막이 중요했습니다.

FSD는 단순한 폴더 구조가 아니라, 의존성 규칙을 강제할 수 있는 구조다


과거의 첫 도입 기록을 다시 보면서

이번 글과는 별개로, 저는 과거에 FSD를 처음 도입했던 기록이 있습니다.

2025.03.19 - [[refactor: advICE]] - [refactor] Lv.1 아키텍처 개선기: FSD 도입 이야기

 

[refactor] Lv.1 아키텍처 개선기: FSD 도입 이야기

리팩토링의 시작: 폴더 구조부터 정리하기이 프로젝트를 시작할 때 폴더 구조나 아키텍처에 대한 개념이 부족했다. 기능을 추가하는 데 집중하다 보니 폴더 구조는 점점 복잡해졌고 유지보수가

juncci.tistory.com

 

지금 기준에서 보면 분명 아쉬운 부분이 있습니다.

레이어를 형식적으로 나누기도 했고, Feature와 Widget의 경계도 명확하지 않았으며,

도메인 기준보다 화면 기준으로 구조를 잡는 경우도 있었습니다. 

 

그럼에도 불구하고 그 기록을 다시 보면 한 가지는 분명합니다.

“구조를 고민하기 시작했다”는 점

 

그 시점의 저는 “기능은 늘어나는데 구조는 왜 더 빨리 무너지는가”라는 문제를 분명하게 인식하고 있었고,

이를 해결하기 위해 설계를 시도하고 있었습니다. 그 경험이 있었기 때문에 지금의 기준도 만들어질 수 있었습니다.

 

그래서 이 글은 단순한 도입기가 아니라

FSD를 어떻게 다시 정의하고, 어떤 기준으로 적용해 봤는지를 끄적끄적 거렸던 것 같습니닷!!

FSD란 무엇인가

 

FSD는 코드를 components, hooks, utils처럼 기술 기준으로 나누는 방식이 아니라,

비즈니스 기능과 도메인 중심으로 분해하는 설계 방식입니다.

그리고 이 구조를 유지하기 위한 핵심 원칙은 두 가지라고 생각합니다.

  • 단방향 의존성
  • 명확한 경계 (Clear Boundaries)

단방향 의존성은 구조를 단순하게 만듭니다.

상위 레이어는 하위 레이어를 사용할 수 있지만, 하위 레이어는 상위를 알지 못합니다.

이로 인해 의존 방향이 명확해지고, 변경 영향 범위도 예측 가능해집니다.

 

명확한 경계는 캡슐화를 보장합니다.

각 슬라이스는 자신의 책임 범위를 가지며, 외부에는 공개된 인터페이스만 노출합니다.

내부 구현은 언제든 바꿀 수 있지만, 외부와의 계약은 유지됩니다.

이 두 가지 원칙이 지켜질 때 얻을 수 있는 효과는 다음과 같습니다.

  • 응집도 상승
  • 결합도 감소
  • 변경 비용 감소

결국 FSD는 단순히 폴더를 나누는 방법이 아니라,

변경을 안전하게 만들기 위한 구조 설계 방식입니다


(NEW) 새롭게 정리한 FSD 적용 전략

최근 프로젝트에서는 FSD를 단순히 “도입”하는 것이 아니라,

어떤 기준으로 사용할 것인지 명확하게 정의했습니다.

Layer — 역할이 아니라 책임으로 나눈다

레이어는 “무엇을 하는 코드인가”가 아니라

  • “어디까지 책임지는가”로 나눴습니다.
  • 상위 레이어: 조합, 흐름
  • 하위 레이어: 도메인, 기능
  • 공통 레이어: 인프라, 유틸

이 기준을 적용하니 자연스럽게 다음 구조가 만들어졌습니다.

상위는 하위를 조합하고, 하위는 자기 책임만 수행한다

 

이 변화만으로도 코드의 배치 기준과 변경 영향 범위가 훨씬 명확해졌습니다.


Slice — 화면이 아니라 도메인 기준으로 나눈다

슬라이스는 페이지 기준이 아니라 도메인 기준으로 나눴습니다.

이 기준을 적용한 이유는 명확합니다.

  • 같은 도메인 정책은 같은 곳에 있어야 한다
  • 변경이 한 곳에 모여야 한다
  • 재사용보다 “변경 단위”가 더 중요하다

예를 들어 인증, 이력서, 알림과 같은 도메인 단위로 나누면,

관련된 API, 상태, UI가 모두 한 슬라이스 안에 모이게 됩니다.

src/
  features/               # (layer)
    auth/                 # (slice) 인증 도메인(사용자 액션 중심)
      api/                # (segment) 로그인 요청
        login.ts          
      model/              # (segment) 인증 폼 상태/로직
        useAuthForm.ts    
      ui/                 # (segment) 인증 UI
        LoginForm.tsx     
      index.ts            # (Clear Boundaries)외부 공개 진입점

 

여기서 가장 중요한 기준은 이것입니다.

같이 바뀌는 코드는 같은 곳에 둔다


다만 이 기준을 적용하다 보면 한 가지 고민이 생깁니다.

“여러 도메인에서 공통으로 사용하는 로직은 어디에 둬야 하는가?”


대표적인 예가 이메일 인증과 같은 케이스입니다.

회원가입, 로그인, 비밀번호 재설정 등 다양한 흐름에서 사용되지만,

특정 하나의 도메인에 완전히 종속되지는 않는 로직입니다.

이런 경우 저는 다음과 같이 판단했습니다.

  • 특정 도메인에 강하게 종속되지 않는다
  • 여러 흐름에서 동일한 절차로 반복된다
  • 변경 시 여러 도메인에 동시에 영향을 준다
# 여러 도메인에서 함께 쓰이는 절차(예: 이메일 인증)는
# auth 내부에 넣기보다 독립 slice로 분리

src/
  features/                         # (layer)
    email-verification/             # (slice) 공통 인증 절차 도메인
      api/                          # (segment) 인증번호 발송/검증 요청
        sendVerificationCode.ts
        verifyCode.ts
      model/                        # (segment) 인증 상태/타이머/재시도 로직
        useEmailVerification.ts
      ui/                           # (segment) 인증 입력/요청 UI
        EmailVerificationField.tsx
      lib/                          # (segment) 포맷/검증 유틸
        emailValidation.ts
      index.ts                      # (Clear Boundaries) 외부 공개 진입점


이 조건을 만족한다면, 해당 로직은 특정 도메인 슬라이스 내부에 두는 것이 아니라

독립적인 슬라이스로 분리하는 것이 더 적절하다고 판단했습니다.

 

이렇게 분리했을 때의 장점은 명확합니다.

  • 도메인 경계가 흐려지지 않음
  • 공통 로직의 위치가 명확해짐
  • 여러 기능에서 동일한 방식으로 재사용 가능
  • 변경 시 영향 범위를 한 곳으로 집중시킬 수 있음

결국 기준은 동일합니다. “같이 바뀌는가?”를 기준으로 슬라이스를 나눈다

  • 특정 도메인과 함께 바뀌면 → 도메인 슬라이스
  • 여러 도메인과 함께 바뀌면 → 독립 슬라이스

이 기준을 적용하면서 슬라이스 경계가 훨씬 명확해졌고,

구조를 이해하는 데 드는 비용도 크게 줄일 수 있었습니다.


Segment — 내부 구조를 표준화한다

슬라이스 내부는 다음과 같이 구성했습니다.

api/
model/
ui/
lib/

이 구조의 목적은 단순합니다.

코드를 잘 나누는 것이 아니라, 찾기 쉽게 만드는 것

 

이 기준 덕분에 다음과 같은 흐름이 자연스럽게 만들어졌습니다.

  • UI 수정 → ui
  • 정책 변경 → model
  • API 변경 → api

결과적으로 코드 탐색 비용이 크게 줄었습니다.


가장 어려웠던 지점: Feature와 Widget의 경계

실제로 적용하면서 가장 고민이 많았던 부분은 Feature와 Widget의 경계였습니다.

개념적으로는 다음과 같이 구분됩니다.

  • Feature: 사용자 행동/시나리오
  • Widget: 조합된 UI 블록

하지만 실제 구현에서는 이 경계가 자주 겹칩니다.

그래서 이번에는 기준을 이렇게 정리했습니다.

  • 조합과 배치가 핵심이면 → Widget
  • 행동과 실행이 핵심이면 → Feature
  • 억지로 레이어를 모두 사용하지 않는다

FSD는 엄격하지만, 그 엄격함이 형식주의가 되는 순간 오히려 복잡도를 증가시키는 것 같습니다.


단방향 의존성과 경계가 만든 변화

이 구조를 적용한 이후 가장 크게 달라진 점은

변경 작업의 예측 가능성이었습니다.

  • 수정 범위가 슬라이스 안에 머무름
  • 코드리뷰에서 설계 위반을 빠르게 발견
  • 기능 추가 시 기존 코드 침범 감소
  • 리팩터링 안정성 증가

특히 내부 구현을 감추고 public API만 노출하는 구조는

장기 유지보수에서 큰 차이를 만들었습니다.

어디까지 영향이 가는지가 보이기 시작했다


ESLint로 아키텍처를 “규칙”으로 강제하기

아키텍처는 문서로만 유지되지 않는다는 생각이 들었습니다.

시간이 지나고 기능이 쌓이면
“이 정도는 괜찮겠지”라는 예외가 생기고,
그 예외가 쌓이면서 구조는 빠르게 흐트러집니다.

그래서 저는 FSD를 단순한 설계가 아니라,
규칙으로 강제되는 구조로 운영했습니다.

그 방법이 바로 ESLint였습니다.

[1] 레이어 의존성 제한

레이어 간 의존 방향을 명확하게 제한했습니다.

  • 상위 레이어 → 하위 레이어만 허용
  • 하위 레이어 → 상위 레이어 접근 금지

이 규칙을 통해 다음이 보장됩니다.

  • 단방향 의존성 유지
  • 설계 위반을 코드 레벨에서 즉시 차단
  • 구조가 시간이 지나도 무너지지 않음

[2] Deep Import 금지

슬라이스 내부 구현에 직접 접근하는 것을 금지했습니다.

// 잘못된 방식
import { something } from "@/features/auth/model/internalLogic";

// 올바른 방식
import { something } from "@/features/auth";
 

이 규칙의 목적은 단순합니다.

내부 구현에 의존하지 못하게 한다

 

이렇게 해야

  • 내부 구조 변경이 자유로워지고
  • 외부 코드와의 결합이 줄어듭니다

[3] Public API 강제 (Clear Boundaries)

이 부분이 가장 중요하게 신경 쓴 지점입니다.

각 슬라이스는 반드시 index.ts를 통해서만 외부에 노출되도록 했습니다.

 
// features/auth/index.ts
export { login } from "./model/login";
export { AuthButton } from "./ui/AuthButton";

 

외부에서는 오직 이 진입점만 사용합니다.

import { login, AuthButton } from "@/features/auth";
 

[4] ESLint 코드 일부

위 제약들을 ESLint로 구조를 강제했습니다.

이 방식의 핵심은 다음과 같습니다.

 
// eslint.config.mjs (핵심 부분 발췌)
import { defineConfig } from "eslint/config";
import boundaries from "eslint-plugin-boundaries";

export default defineConfig([
  {
    plugins: { boundaries },
    settings: {
      "boundaries/elements": [
        { type: "shared", pattern: "src/shared/**" },
        { type: "entities", pattern: "src/entities/**" },
        { type: "features", pattern: "src/features/**" },
        { type: "widgets", pattern: "src/widgets/**" },
        { type: "app", pattern: "src/app/**" },
      ],
    },
    rules: {
      // 1) 레이어 의존 방향 강제 (단방향)
      "boundaries/element-types": [
        "error",
        {
          default: "disallow",
          rules: [
            { from: "shared", allow: ["shared"] },
            { from: "entities", allow: ["shared", "entities"] },
            { from: "features", allow: ["shared", "entities", "features"] },
            { from: "widgets", allow: ["shared", "entities", "features", "widgets"] },
            { from: "app", allow: ["shared", "entities", "features", "widgets", "app"] },
          ],
        },
      ],

      // 2) 슬라이스 내부 deep import 금지 (public API만 사용)
      "no-restricted-imports": [
        "error",
        {
          patterns: [
            {
              group: [
                "@/shared/*/*/*",
                "@/shared/*/*/*/**",
                "@/entities/*/*",
                "@/entities/*/*/**",
                "@/features/*/*",
                "@/features/*/*/**",
                "@/widgets/*/*",
                "@/widgets/*/*/**",
              ],
              message:
                "FSD 규칙: 레이어 내부 직접 경로 대신 public index.ts를 통해 import 해야 합니다.",
            },
          ],
        },
      ],
    },
  },
]);

 

마치며

FSD 아키텍처는 공식 문서에서도 명확하게 말하고 있듯이,
정해진 하나의 정답이 있는 구조가 아닙니다.

 

중요한 것은 레이어와 슬라이스를 어떻게 나누느냐 자체가 아니라,
의존성 방향과 경계라는 규칙을 지키면서 프로젝트에 맞게 설계하는 것입니다.

 

이 글에서 정리한 내용 역시 하나의 정답이 아니라,
제가 실제 프로젝트를 진행하면서 필요에 의해 정의하고 적용한 기준입니다.

  • 어떤 코드는 어디에 두는 것이 적절한지
  • 슬라이스를 어떻게 나누면 변경이 덜 퍼지는지
  • 구조를 어떻게 하면 무너지지 않게 유지할 수 있는지

이 질문들에 대한 저만의 답을 정리한 기록에 가깝습니다.

 

이 글이 FSD 아키텍처를 고민하고 있는 분들에게

정답이 아니라, 작은 방향성 정도가 되었으면 합니다.