Next.js App Router 프로젝트에 FSD를 적용하며 고민한 경계들

성태경·2026년 9월 27일
post-thumbnail

이번 큐시즘 34기에서 진행한 Verty 기업 프로젝트는 서울과 제주 지역의 버티포트와 운항편을 선택해 UAM 탑승 과정을 체험할 수 있는 웹 기반 시뮬레이션 서비스다. Next.js App Router 기반의 프로젝트로 예약부터 비행 체험과 후기 작성까지 하나의 흐름으로 제공한다.

프로젝트를 시작할 때 폴더 구조는 비교적 명확했다.

app, widgets, features, entities, shared 레이어를 두고 상위 레이어가 하위 레이어만 참조하도록 정했다. 각 기능은 자신의 Public API를 통해 외부에 노출하고 범용 컴포넌트와 도메인 로직도 분리했다.

처음에는 이 규칙을 지키면 프로젝트가 커지더라도 구조가 자연스럽게 유지될 것이라 생각했다.

하지만 예약 기능에 실제 API와 지도, 현재 위치, 운항편 조회, 예약 생성이 차례로 연결되면서 새로운 고민이 생겼다.

  • 예약과 관련된 코드는 모두 하나의 feature에 있어도 될까?
  • 버티포트와 운항편은 feature일까 entity일까?
  • 여러 기능을 연결하는 widget은 어디까지 책임져야 할까?
  • ESLint로 의존성을 제한하면 FSD를 지켰다고 할 수 있을까?

이 글에서는 FSD를 프로젝트에 적용하면서 레이어와 slice의 경계를 어떻게 고민했는지 정리해보려고 한다.

프로젝트에서 사용한 FSD 구조

이번 프로젝트에서는 다음과 같은 의존 방향을 사용했다.

App → Widgets → Features → Entities → Shared

FSD에서 소개하는 모든 레이어를 사용하지는 않았다. Next.js App Router의 app 디렉터리가 라우팅과 페이지 구성을 담당하고 있었기 때문에 별도의 pages 레이어는 두지 않았다. 또한 processes 레이어는 현재 FSD 명세에서 권장되지 않아서 사용하지 않았다.

각 레이어의 역할은 다음과 같이 정했다.

  • app: 라우팅과 화면 배치
  • widgets: 여러 기능을 조합한 화면 영역
  • features: 사용자의 행동과 이에 필요한 상태 및 UI
  • entities: 비즈니스 객체의 모델과 표시
  • shared: 도메인을 모르는 공통 UI와 유틸리티

같은 slice 내부에서는 상대 경로를 사용하고 다른 slice를 참조할 때는 해당 slice의 Public API를 사용하도록 했다. 또한 하위 레이어가 상위 레이어를 참조하지 못하도록 ESLint 규칙도 추가했다.

규칙만 보면 비교적 단순했다. 프로젝트 초반에는 이 구조가 실제로도 잘 작동했다.

FSD가 실제로 도움이 된 부분

가장 먼저 체감한 장점은 페이지의 역할이 작아졌다는 점이었다.

예약 페이지는 URL에서 목적지를 확인하고 히어로 영역과 예약 흐름을 배치하는 역할만 맡는다. 버티포트 선택이나 운항편 조회처럼 예약 과정에서 발생하는 구체적인 로직은 페이지가 알지 않는다.

덕분에 페이지를 읽을 때는 “이 URL에서 어떤 화면을 구성하는가”에만 집중할 수 있었다.

공통 UI의 책임도 비교적 명확해졌다. 버튼과 아이콘 버튼 같은 요소는 예약이나 운항편의 존재를 알지 않는다. 반대로 예약 전용 컴포넌트는 해당 도메인의 용어와 상태를 자유롭게 사용할 수 있었다.

의존성의 방향이 정해져 있다는 점도 협업에 도움이 됐다. 새로운 파일을 만들 때 위치가 자동으로 결정되는 것은 아니었지만, 적어도 해당 코드가 어느 레이어를 참조할 수 있는지는 명확했다.

예를 들어 공통 버튼이 예약 feature를 참조하거나, 예약 feature가 전체 예약 화면을 구성하는 widget을 참조하는 구조는 허용하지 않았다. 이러한 역방향 의존은 ESLint가 감지하도록 했다.

FSD가 모든 설계 결정을 대신 내려주지는 않았지만 의존성이 잘못된 방향으로 흐르는 것은 막아줬다.

예약 기능이 커지면서 생긴 고민

문제는 예약 화면에 실제 기능이 연결되기 시작하면서 나타났다.

처음 예약 feature에는 날짜 선택, 탑승 인원 선택, 버티포트 선택과 같은 UI가 중심이었다. 이후 실제 서비스를 구성하기 위한 기능들이 하나씩 추가됐다.

  • 전체 버티포트 조회
  • 출발지에 따른 도착 가능 포트 조회
  • 도착지에 따른 출발 가능 포트 조회
  • 브라우저 현재 위치 조회
  • 역지오코딩
  • 주변 버티포트 조회
  • 지도 마커와 경로 표시
  • 운항편 조회
  • 교통수단별 이동 시간 비교
  • 예약 생성
  • 예약 완료 상태 전달

사용자 관점에서는 모두 예약 과정의 일부다. 따라서 하나의 reservation feature 안에 두는 것도 자연스럽게 보였다.

하지만 변경 이유를 기준으로 보면 서로 다른 기능이었다.

버티포트 조회 API가 변경되는 이유와 지도 렌더링이 변경되는 이유는 다르다. 탑승 인원 선택 UI가 변경되는 이유와 예약 생성 요청이 변경되는 이유도 다르다. 단지 하나의 화면에서 함께 사용된다는 이유로 같은 경계 안에 들어가 있었다.

이 과정에서 한 가지 사실을 알게 됐다.

같은 화면에 등장하는 코드가 반드시 같은 feature에 속하는 것은 아니다.

그렇다고 파일이 많아졌다는 이유만으로 무조건 분리하는 것도 정답은 아니었다.

예약과 관련된 모델과 UI는 대부분 예약 화면에서만 사용됐다. 이를 여러 slice로 잘게 나누면 각 slice의 Public API와 import만 늘어나고 실질적인 독립성이나 재사용성은 생기지 않을 수 있었다.

결국 중요한 것은 파일의 개수가 아니라 각 코드가 변경되는 이유와 소비되는 범위였다.

Feature와 Entity의 경계

버티포트와 운항편은 사용자의 행동이라기보다 비즈니스 객체에 가깝다. FSD의 기준만 보면 entities에 두는 것이 자연스러워 보인다.

하지만 프로젝트에서는 버티포트와 운항편 모델을 예약 feature 내부에서 관리했다.

이 선택이 완전히 잘못됐다고 생각하지는 않는다. 당시에는 해당 모델을 예약 기능 이외의 영역에서 사용하는 경우가 거의 없었기 때문이다.

버티포트라는 명사가 존재한다는 이유만으로 entity를 만들면 아직 재사용되지 않는 모델을 위해 별도의 slice와 Public API를 운영하게 된다. 구조는 더 세분화되지만 실질적인 독립성은 생기지 않을 수 있다.

반면 이후 다음과 같은 기능이 추가된다면 판단이 달라질 수 있다.

  • 버티포트 상세 화면
  • 실시간 운항 현황
  • 버티포트 검색
  • 즐겨찾기
  • 관리자용 노선 관리

동일한 도메인 객체를 여러 기능이 사용하기 시작한다면 예약 feature에만 두는 것이 오히려 결합을 만들게 된다. 그 시점에는 버티포트를 entity로 분리하는 편이 더 자연스럽다.

이 경험을 통해 entity의 분리 기준을 다음과 같이 생각하게 됐다.

도메인 명사인지 여부만으로 entity를 만들기보다 여러 기능에서 독립적으로 소비되는 모델인지 확인해야 한다.

FSD의 분류 기준은 출발점이 될 수 있지만 실제 경계는 프로젝트의 변경 방향과 재사용 범위를 함께 살펴야 했다.

Widget에는 얼마나 많은 책임이 있어야 할까

예약 화면의 전체 흐름은 widget에서 조율한다.

예약 widget은 다음과 같은 역할을 연결한다.

  • 현재 예약 단계 관리
  • 입력한 예약 조건 보존
  • 버티포트 및 운항편 API 호출
  • 선택 조건에 따른 데이터 변환
  • 지도에 전달할 출발지와 도착지 계산
  • 검색 조건 변경 시 이전 결과 무효화
  • 예약 생성 성공 후 다음 화면으로 이동

Widget이 여러 feature를 조합하는 것은 FSD에서 기대하는 역할과 잘 맞는다. 하지만 기능이 늘어나면서 widget은 단순히 컴포넌트를 배치하는 수준을 넘어 전체 예약 흐름의 중심이 됐다.

처음에는 이를 widget의 책임이 지나치게 커진 신호라고 생각했다. 그러나 모든 orchestration을 하위 feature로 옮긴다고 문제가 사라지는 것은 아니었다.

여러 요청의 순서를 결정하고, 선택 상태를 연결하고, 현재 단계에 따라 다른 화면을 보여주는 책임은 어디엔가 존재해야 한다. 이를 무작정 여러 feature에 분산하면 오히려 전체 흐름을 파악하기 어려워질 수 있다.

여기서 FSD에 대해 가지고 있던 기대를 수정하게 됐다.

FSD는 복잡도를 제거하는 방법이라기보다 복잡도가 위치할 장소와 의존 방향을 정하는 방법에 가까웠다.

따라서 widget에 orchestration이 존재한다는 사실 자체가 문제는 아니었다. 중요한 것은 widget이 하위 feature의 내부 구현까지 알고 있는지, 서로 독립적인 흐름까지 한곳에서 처리하고 있는지를 확인하는 것이었다.

Public API는 내부 구조를 얼마나 숨겨줬을까

각 slice는 외부에서 필요한 항목만 Public API로 노출하도록 했다.

이를 통해 외부 레이어는 feature 내부의 세부 폴더 구조를 알 필요가 없었다. API 요청 함수나 내부 요청 제어 도구처럼 외부에서 사용할 필요가 없는 구현도 감출 수 있었다.

하지만 예약 기능이 커지자 Public API 역시 함께 커졌다.

예약 widget에서는 지도, 조회 hook, mapper, UI 모델, 예약 생성 기능 등 다양한 항목이 필요했다. 외부에서 필요한 항목을 하나씩 공개하다 보니 Public API가 feature의 여러 책임을 그대로 보여주기 시작했다.

Public API를 사용한다고 해서 자동으로 캡슐화가 만들어지는 것은 아니었다. 내부 구현을 직접 참조하지 않는 것과 slice의 책임이 적절하게 나뉘어 있는 것은 서로 다른 문제였다.

공개 항목이 계속 늘어난다는 것은 해당 feature가 여러 종류의 책임을 외부에 제공하고 있다는 신호일 수 있었다.

이후에는 Public API를 단순한 export 목록이 아니라 경계를 점검하는 지표로 바라보게 됐다.

  • 외부에서 정말 필요한 항목인가?
  • 내부 구현이 편하다는 이유로 노출하고 있지는 않은가?
  • 서로 다른 소비자를 위한 API가 하나의 진입점에 섞이고 있지는 않은가?
  • 공개 항목이 많아진 이유가 feature의 책임 확장 때문은 아닌가?

Public API는 내부를 숨기는 도구이면서 동시에 slice의 책임이 얼마나 넓어졌는지를 보여주는 목록이기도 했다.

Next.js의 서버와 클라이언트 경계

Next.js App Router에서는 FSD의 레이어뿐만 아니라 서버와 클라이언트의 경계도 함께 고려해야 했다.

예약 기능에는 브라우저에서 사용하는 UI와 hook뿐만 아니라 서버 환경에서만 사용할 수 있는 기능도 존재했다. 서버 전용 기능을 일반 Public API에서 함께 노출하면 클라이언트 코드가 서버 의존성을 참조할 가능성이 생긴다.

프로젝트에서는 일반 Public API와 서버 전용 진입점을 분리했다.

같은 예약 도메인에 속하더라도 실행 환경에 따라 접근 경로를 구분한 것이다.

이 경험을 통해 FSD의 Public API만으로는 Next.js의 모든 경계를 표현할 수 없다는 점을 알게 됐다. 레이어와 slice는 코드의 비즈니스 책임을 나타내지만, Server Component와 Client Component의 경계는 실행 환경을 나타낸다.

두 경계는 서로 대체할 수 없었다.

Next.js에서 FSD를 적용할 때는 다음 두 가지를 동시에 확인해야 했다.

  • 이 코드는 어느 레이어와 slice에 속하는가?
  • 이 코드는 어느 실행 환경에서 사용되는가?

FSD 규칙을 지키더라도 서버 전용 코드가 클라이언트 진입점을 통해 노출된다면 안전한 구조라고 보기 어렵다.

ESLint가 지켜준 경계와 지켜주지 못한 경계

프로젝트에서는 ESLint를 이용해 레이어 간 의존 방향을 제한했다.

이 규칙은 분명히 효과가 있었다. 하위 레이어가 상위 레이어를 참조하는 실수를 코드 리뷰 전에 발견할 수 있었고 기본적인 방향을 유지할 수 있었다.

ESLint는 다음과 같은 문제를 감지할 수 있었다.

  • Shared가 Feature를 참조하는 문제
  • Entity가 Feature를 참조하는 문제
  • Feature가 Widget을 참조하는 문제
  • Widget이 App에 의존하는 문제

하지만 ESLint가 보장할 수 있는 범위에는 한계가 있었다.

다음과 같은 문제는 정적 규칙만으로 판단하기 어려웠다.

  • 하나의 feature가 너무 많은 책임을 갖는가?
  • Widget의 orchestration이 과도한가?
  • Entity를 분리할 시점이 됐는가?
  • Public API가 지나치게 커졌는가?
  • 두 파일이 의미적으로 강하게 결합돼 있는가?
  • 같은 레이어의 slice끼리 참조해도 괜찮은 상황인가?

도구는 허용되지 않은 의존 방향을 차단할 수 있지만 좋은 경계를 대신 만들어주지는 않았다.

FSD 규칙을 ESLint에 추가했다고 해서 아키텍처에 대한 고민이 끝나는 것은 아니었다. 오히려 기계적으로 검사할 수 없는 영역이 무엇인지 더 명확하게 드러났다.

다시 적용한다면 어떤 기준을 사용할까

프로젝트를 다시 시작하더라도 FSD를 사용할 것 같다.

다만 처음부터 모든 도메인 객체를 entity로 만들거나 가능한 한 작은 feature로 분리하려고 하지는 않을 것이다. 대신 다음 기준을 사용하려 한다.

App은 화면을 구성하는 역할만 맡는다

라우팅과 URL 해석, 서버 데이터 접근의 진입점, 최상위 배치만 담당한다. 구체적인 사용자 흐름은 하위 레이어에 둔다.

Shared는 재사용 횟수보다 도메인 독립성을 기준으로 판단한다

여러 곳에서 사용된다고 무조건 shared로 옮기지 않는다. 하나의 도메인 용어라도 알고 있다면 shared가 아닐 가능성이 높다.

Entity는 두 번째 소비자가 생겼을 때 다시 검토한다

도메인 명사라는 이유만으로 미리 분리하지 않는다. 여러 기능이 동일한 모델과 표시 규칙을 필요로 하기 시작할 때 entity 추출을 검토한다.

Feature는 화면이 아니라 사용자 행동을 기준으로 본다

같은 화면에서 사용된다는 이유만으로 모든 코드를 하나의 feature에 넣지 않는다. 서로 다른 이유로 변경되고 독립적으로 테스트할 수 있는 행동인지 확인한다.

Widget의 복잡도는 크기보다 결합 방식으로 판단한다

Widget이 크더라도 여러 feature를 명시적으로 조율한다면 역할에 맞을 수 있다. 반대로 하위 feature의 내부 상태와 API 세부사항을 지나치게 많이 알고 있다면 경계를 다시 살펴본다.

Public API의 증가는 경계를 점검하는 신호로 사용한다

Export가 늘어날 때 단순히 공개 항목을 추가하는 것으로 끝내지 않는다. 하나의 slice가 너무 많은 역할을 제공하고 있지는 않은지 확인한다.

자동화할 규칙과 리뷰할 규칙을 구분한다

의존 방향이나 import 경로는 ESLint로 강제할 수 있다. Slice의 크기와 도메인 경계처럼 의미적인 판단은 코드 리뷰에서 확인해야 한다.

FSD는 폴더 구조가 아니었다

처음에는 FSD를 폴더와 import 규칙에 가깝게 이해했다.

정해진 레이어에 파일을 배치하고 의존 방향을 지키면 구조가 자연스럽게 유지될 것이라 생각했다. 실제로 FSD는 페이지의 책임을 줄이고, 공통 코드와 도메인 코드를 구분하며, 잘못된 의존 방향을 방지하는 데 도움이 됐다.

하지만 프로젝트가 커지면서 폴더 구조만으로 해결되지 않는 문제도 나타났다.

  • 하나의 feature는 어디까지 커질 수 있는가?
  • 도메인 모델은 언제 entity가 되어야 하는가?
  • 전체 흐름을 조율하는 책임은 어디에 있어야 하는가?
  • Public API가 커지는 것은 어떤 신호인가?
  • 같은 레이어 안의 결합은 어떻게 판단해야 하는가?

이 질문들에는 하나의 정답이 없었다.

결국 FSD가 제공한 가장 큰 가치는 파일의 정답 위치가 아니라 의존성을 바라보는 기준이었다. 어떤 코드가 무엇을 알 수 있는지, 변경이 어느 방향으로 전파되는지, 외부에 무엇을 공개할지를 계속 질문하게 만들었다.

FSD는 복잡도를 없애주지 않았다. 대신 복잡도가 무질서하게 퍼지지 않도록 방향을 제한해줬다.

그리고 그 방향 안에서 실제 경계를 결정하는 일은 여전히 프로젝트와 개발자의 몫이었다.

0개의 댓글