F.S.D 모호함, CQRS와 DDD 두 축으로 구분합니다
1. AI 시대에 프론트엔드 설계를 공부하는 이유
요즘은 AI가 코드를 대신 써주는 시대입니다. 기능 하나를 만들기 위해 함수 단위로 직접 타자를 두드리는 시간이 점점 줄어들고 있어요. 그러니까 이제 설계도 별로 중요하지 않아진 걸까요?
오히려 반대입니다. AI가 코드를 쓰게 되면서 설계의 역할은 오히려 더 커졌습니다. 코드는 AI가 쓰더라도, AI에게 방향을 잡아주는 가이드라인은 여전히 사람이 잡아야 하기 때문이에요. 그 가이드라인이 바로 설계입니다.
(물론 AI 도 설계를 점점 더 잘하게 되고 있어요. 다만 아직은 사람이 개입해야할 부분이 많아 보입니다. 특히 현실의 복잡도를 충분히 잘 추상화해서 단순하게 해결하는 부분은 사람의 개입이 많이 필요 해보입니다.)
좋은 설계가 있으면 코드를 한 줄 한 줄 직접 읽지 않아도 어떻게 구현돼 있을지 예측 가능해집니다. "장바구니 담기는 단일 Cart Aggregate 내부의 쓰기니까 entities/cart에 있을 거야"처럼요. AI가 쓴 코드가 설계에서 벗어났을 때 바로잡을 수 있는 기준도 여기서 나옵니다.
설계를 공부하는 가치는 AI 시대에 오히려 더 커졌습니다. 이 글에서 다루는 F.S.D의 Features/Entities 구분도 같은 맥락이에요. 구분 기준이 명확할수록 AI에게 일관된 방향을 줄 수 있고, 결과물을 예측할 수 있습니다.
2. 프론트엔드 설계가 어려운 이유부터 짚고 갑니다
기능이 쌓일수록 프론트엔드 설계는 어려워집니다. 백엔드는 API 단위로 책임을 잘라 병렬로 다룰 수 있지만, 프론트엔드는 화면 하나에 여러 도메인 상태가 한꺼번에 얽혀요.
한 페이지에서 흔히 마주치는 상태만 나열해봐도 이렇게 많습니다.
- 서버 상태: API 응답, 캐시
- UI 상태: 모달 열림, 탭 활성, 스크롤 위치
- 비즈니스 상태: 장바구니, 선택한 상품, 결제 단계
- 폼 상태: 입력값, validation 결과, dirty 여부
- URL 상태: 쿼리 파라미터, 라우트 파라미터
기능이 쌓이면 한 컴포넌트가 위 상태들을 전부 알아야 하는 상황이 생기기도 합니다. 그 결과, 새로운 기능을 추가할 때마다 수정해야 할 파일 범위가 점점 예측 불가해집니다. 변경할 때마다 옆에 있는 기능이 깨지지 않을지 걱정하게 되고, 팀원은 이 코드가 왜 여기 있는지 다시 묻게 됩니다.
책임 분리는 이 부채를 막기 위한 가장 근본적인 방법입니다.
flowchart LR
subgraph Screen["한 화면 (Page Component)"]
direction LR
S1["서버 상태<br/>API 응답<br/>캐시"] ~~~ S2["UI 상태<br/>모달 · 탭<br/>스크롤 위치"] ~~~ S3["비즈니스 상태<br/>장바구니<br/>결제 단계"] ~~~ S4["폼 상태<br/>입력값<br/>validation"] ~~~ S5["URL 상태<br/>쿼리 파라미터<br/>라우트"]
end
New["새 기능 추가"] -.->|"모두 영향"| Screen
style Screen fill:#f8fafc,stroke:#64748b,color:#1f2937
style S1 fill:#dbeafe,stroke:#3b82f6,color:#1f2937
style S2 fill:#dcfce7,stroke:#22c55e,color:#1f2937
style S3 fill:#fef3c7,stroke:#f59e0b,color:#1f2937
style S4 fill:#fee2e2,stroke:#ef4444,color:#1f2937
style S5 fill:#f3e8ff,stroke:#a855f7,color:#1f2937
style New fill:#ffffff,stroke:#dc2626,color:#1f2937
3. 프론트엔드는 기능이 쌓일수록 책임 분리가 필요합니다
이 문제를 풀기 위해 프론트엔드 커뮤니티는 여러 방법론을 만들어왔습니다.
Container/Presentational은 데이터 로직과 표현을 한 컴포넌트 안에서 분리하자는 접근입니다. Atomic Design은 UI를 atoms, molecules, organisms 같은 재사용 단위로 쪼개 조합하자는 접근이에요. F.S.D(Feature Sliced Design) 는 앱 전체를 기능 단위 슬라이스로 잘라 책임을 나누자는 더 큰 단위의 구조입니다.
세 방법론은 서로를 대체하지 않습니다. 각자의 프레임으로 이 문제를 풀어왔습니다. 컴포넌트 내부, 시각적 조립, 앱 전체 구조 — 해결하려는 층위가 서로 다른 거예요.
%%{init: {"flowchart": {"subGraphTitleMargin": {"top": 10, "bottom": 10}}}}%%
flowchart LR
subgraph CP["Container/Presentational<br/>컴포넌트 내부 분리"]
CP1["데이터 로직"] --- CP2["표현 로직"]
end
subgraph AD["Atomic Design<br/>시각적 조립 단위"]
AD1["Atoms"] --> AD2["Molecules"] --> AD3["Organisms"]
end
subgraph FSD["F.S.D<br/>앱 전체 구조"]
FSD1["Shared"] --> FSD2["Entities"] --> FSD3["Features"]
end
CP2 ~~~ AD1
AD3 ~~~ FSD1
style CP fill:#fef3c7,stroke:#f59e0b,color:#1f2937
style AD fill:#dbeafe,stroke:#3b82f6,color:#1f2937
style FSD fill:#dcfce7,stroke:#22c55e,color:#1f2937
4. F.S.D의 구조와 Features/Entities 모호함
그중 F.S.D는 Entities, Features, Widgets, Pages, Shared 같은 레이어로 책임을 나누는 구조입니다. Entities는 도메인 객체를 표현하고, Features는 사용자 기능을 담고, Shared는 공통 유틸을 담당해요.
이 구조는 작은 앱에서는 잘 맞습니다. 그런데 앱 규모가 커질수록 Features와 Entities 구분이 모호해집니다.
예를 들어 장바구니(Cart) 기능을 설계한다고 해볼게요.
- "상품 담기"는 Feature인가, Cart Entity의 메서드인가?
- "수량 변경"은 Feature인가, Cart Entity의 메서드인가?
이런 질문이 PR마다 반복되고, 팀마다 다른 결정으로 흩어집니다. 공식 가이드조차 "팀과 상황에 따라 다르다"고 열어두고 있어서, 팀 내부 판단이 필요했습니다.
판단이 매번 필요한 구조는 컨벤션 비용을 꾸준히 쌓습니다. 새 팀원이 들어오면 "우리 팀은 이 기준으로 갑니다"를 설명해야 하고, 그 기준조차 시간이 지나면 흔들려요.
flowchart LR
subgraph FSD["F.S.D 레이어"]
direction TB
Pages["Pages"]
Widgets["Widgets"]
Features["Features"]
Entities["Entities"]
Shared["Shared"]
end
subgraph Cart["Cart 기능"]
direction TB
Q1["상품 담기"]
Q2["수량 변경"]
end
Q1 -.->|"?"| Features
Q1 -.->|"?"| Entities
Q2 -.->|"?"| Features
Q2 -.->|"?"| Entities
style FSD fill:#f8fafc,stroke:#64748b,color:#1f2937
style Pages fill:#f3f4f6,stroke:#9ca3af,color:#6b7280
style Widgets fill:#f3f4f6,stroke:#9ca3af,color:#6b7280
style Shared fill:#f3f4f6,stroke:#9ca3af,color:#6b7280
style Features fill:#fef3c7,stroke:#f59e0b,color:#1f2937
style Entities fill:#dbeafe,stroke:#3b82f6,color:#1f2937
style Cart fill:#ffffff,stroke:#dc2626,color:#1f2937
style Q1 fill:#fee2e2,stroke:#ef4444,color:#1f2937
style Q2 fill:#fee2e2,stroke:#ef4444,color:#1f2937
5. 그래서 CQRS를 응용해 봤습니다
이 모호함을 한 번에 풀 기준이 없을까 고민하다, 백엔드에서 쓰는 CQRS(Command Query Responsibility Segregation) 패턴을 떠올렸어요. 읽기 모델과 쓰기 모델을 분리해 각자 최적화하는 패턴입니다.
이걸 F.S.D 레이어에 적용해봤습니다. Entities는 읽기, Features는 쓰기로 구분하려는 시도였어요.
프론트엔드에서 "쓰기"는 조금 확장해서 정의합니다. DB에 저장하는 것만 쓰기가 아니라, 비즈니스 로직 관련 사용자 정의 상태(form, filter, sort 등) 변경도 쓰기에 포함합니다. "읽기"는 단순히 데이터를 서버에서 가져오거나 파생값을 계산하는 로직입니다.
Cart 기능에 적용해보면 이렇게 정리돼요.
| Cart 기능 | 읽기/쓰기 | 배치 |
|---|---|---|
| 장바구니 목록 조회 | 읽기 | entities/cart |
| 총액 계산 | 읽기 | entities/cart |
| 상품 담기 | 쓰기 | features/add-to-cart |
| 수량 변경 | 쓰기 | features/change-quantity |
이 축 하나로 다수 케이스가 정리됐습니다. 간단한 기능은 바로 답이 나옵니다.
flowchart LR
subgraph CQRS["CQRS 축"]
direction TB
Query["Query<br/>(읽기)"]
Command["Command<br/>(쓰기)"]
end
Entities["entities/cart<br/>(Entities 레이어)"]
Features["features/add-to-cart<br/>features/change-quantity<br/>(Features 레이어)"]
Query ==>|"매핑"| Entities
Command ==>|"매핑"| Features
style CQRS fill:#f8fafc,stroke:#64748b,color:#1f2937
style Query fill:#dbeafe,stroke:#3b82f6,color:#1f2937
style Command fill:#fef3c7,stroke:#f59e0b,color:#1f2937
style Entities fill:#eff6ff,stroke:#3b82f6,color:#1f2937
style Features fill:#fffbeb,stroke:#f59e0b,color:#1f2937
6. 그런데 Cart를 제대로 설계하려 보니, 구분이 어려운 케이스가 남았습니다
단순한 케이스는 충분히 구분이 가능해졌는데, Cart를 좀 더 깊이 다루자 구분이 어려운 케이스들이 발견되었어요. 세 가지 정도 예시를 살펴볼게요.
5-1. 장바구니에 담을 수 있는 상품인지 검증하는 로직
재고가 남아 있는지, 사용자 등급이 구매 가능 등급인지, 배송지 기준으로 배송 가능한 상품인지, 성인 인증이 필요한 상품인지 확인해야 합니다.
이 검증은 데이터를 읽기만 하는데, "담기" 기능 없이는 존재할 이유가 없습니다. 읽기 위치에 두면 담기 로직에 필요한데 위치가 어색하고, 쓰기 위치에 두면 로직 자체는 단순 읽기기에 어색합니다.
5-2. 수량 변경 시 낙관적 업데이트
버튼을 눌러 수량을 3에서 4로 UI에 즉시 반영합니다. 서버가 "재고 부족"으로 거절하면 이전 수량 3으로 복원하고 에러 토스트를 띄웁니다.
한 흐름 안에 낙관적 쓰기, 실패 판단 읽기, 보상 쓰기가 얽혀 있습니다. 어느 하나만 분리하기가 어려워요.
5-3. 게스트 장바구니와 로그인 후 서버 장바구니 병합
로그인 전에는 localStorage에 장바구니를 쌓아두다가, 로그인하는 순간 서버에 저장돼 있던 기존 장바구니와 합쳐야 합니다. 같은 상품이 양쪽에 있으면 수량을 더할지 큰 값을 쓸지 정책을 정해야 하고, 병합 도중 품절로 바뀐 상품은 제외하며, 가격이 바뀐 상품은 사용자에게 확인을 받아야 해요.
Cart 하나만 다루는 게 아닙니다. User 인증 상태와 서버 Cart까지 여러 도메인을 동시에 조율하는 흐름이에요. 읽기/쓰기 축으로는 "쓰기"라는 건 분명하지만, 어느 슬라이스에 둘지는 답이 안 나왔습니다.
읽기/쓰기 구분 만으로는 여러 도메인이 함께 변경될 때 구분이 어려웠습니다.
| 케이스 | 읽기인가 쓰기인가 | 왜 한 축으로 안 닫히는가 |
|---|---|---|
| 담기 검증 | 읽기처럼 보임 | 담기(쓰기) 없이는 존재 이유 없음 — 읽기 위치도 쓰기 위치도 어색 |
| 낙관적 업데이트 | 쓰기 + 읽기 + 쓰기 | 한 흐름 안에 낙관적 쓰기, 실패 판단 읽기, 보상 쓰기가 얽힘 |
| 게스트 + 로그인 병합 | 쓰기 | 어느 Features 슬라이스인지 답이 없음 — User 인증 + Cart 여러 도메인 조율 |
7. 또다른 축, DDD의 Aggregate를 가져왔습니다
세 케이스를 다시 보면 공통점이 있습니다. "이 로직이 단일 도메인 내부 일인지, 여러 도메인의 조율인지" — 이 관점이 빠져 있었던 거예요.
이 관점을 주는 개념이 DDD의 Aggregate입니다.
Aggregate는 한 덩어리로 다뤄지는 도메인 객체 묶음입니다. Aggregate Root를 통해서만 외부 접근이 허용되고, 내부 불변식이 항상 유지됩니다. 같은 맥락에서 Bounded Context는 "같은 용어가 맥락마다 다른 의미를 가질 수 있다"는 점을 명시화해요. User는 인증 맥락과 프로필 맥락과 관리자 맥락에서 서로 다른 모델입니다.
Cart로 풀어보면 Cart가 Root, CartItem은 내부입니다. 불변식은 이런 것들이에요.
- 각 CartItem 수량은 1 이상
- 총액 = 각 항목의 (가격 × 수량) 합에서 할인을 빼고 배송비를 더한 값
- 품절 상품은 결제 대상에서 자동 제외
이 규칙은 Cart 내부에서만 검증·유지되어야 하고, 외부 코드가 직접 CartItem을 건드리면 깨집니다. 예를 들어 외부에서 cart.items[0].quantity = -1을 직접 수정하게 허용하면 불변식이 무너져요. 반드시 cart.changeQuantity(itemId, newQuantity)처럼 Root 메서드를 거쳐야 합니다.
flowchart LR
External["외부 코드"]
subgraph Cart["Cart Aggregate"]
direction TB
Root["Cart (Root)<br/>changeQuantity()<br/>addItem()<br/>removeItem()"]
Item1["CartItem 1"]
Item2["CartItem 2"]
Item3["CartItem 3"]
Root --> Item1
Root --> Item2
Root --> Item3
end
External ==>|"허용<br/>Root 메서드 호출"| Root
External -.->|"금지<br/>cart.items[0].quantity = -1"| Item1
style Cart fill:#f8fafc,stroke:#64748b,color:#1f2937
style Root fill:#dcfce7,stroke:#22c55e,color:#1f2937
style Item1 fill:#eff6ff,stroke:#3b82f6,color:#1f2937
style Item2 fill:#eff6ff,stroke:#3b82f6,color:#1f2937
style Item3 fill:#eff6ff,stroke:#3b82f6,color:#1f2937
style External fill:#fef3c7,stroke:#f59e0b,color:#1f2937
8. 두 축으로 구분하면 Features/Entities가 명확해집니다
이제 기준이 두 개가 됩니다.
- CQRS 축: 이건 읽기인가 쓰기인가?
- DDD 축: 단일 Aggregate 내부인가, 여러 Aggregate 조율인가?
두 축을 교차시키면 4 분면이 나오고, 각 분면이 F.S.D의 어느 위치에 매핑되는지 명확해져요.
[단일 내부 × 읽기] → entities/cart의 selector
- Cart 총액 계산
- 담긴 항목 수 집계
- 결제 가능 상태 여부 판단
- 특정 상품이 이미 담겨 있는지 조회
[단일 내부 × 쓰기] → entities/cart의 command
- Cart에 상품 추가
- 수량 변경
- 항목 삭제
- Cart 비우기
[다수 조율 × 읽기] → features의 query
- Cart + 추천 상품 동시 조회
- Cart + 적용 가능한 쿠폰 목록 조회
- Cart + 배송지별 배송비 계산
[다수 조율 × 쓰기] → features/checkout의 command
- 결제 완료 후 Cart 비우기 + 재고 차감 + 주문 생성 + 배송 정보 저장
- 쿠폰 적용 (Cart 총액 변경 + 쿠폰 사용 처리)
여기서 한 가지 원칙을 덧붙입니다. Entities와 Features는 각자 독립적으로 interface를 관리합니다. Entities의 interface를 Features가 extends하면 읽기 모델 변경이 쓰기 모델로 전파돼서, CQRS 본래의 분리로 부터 얻는 이득이 약해져요. 그래서 로직이 일부 중복되는 비용은 감수합니다. LLM이 대신 코딩하면서 이중 모델 관리 비용이 어느정도 감당 가능 해졌기 때문입니다.
9. 앞서 어려웠던 세 케이스, 두 축으로 다시 풀어봅니다
두 축을 들고 §6의 세 케이스로 돌아가면, 이제는 답이 깔끔하게 나옵니다.
첫 번째, 담기 검증
Cart 내부 읽기 selector로 entities/cart/canAdd(productId)를 두고, 담기 수행은 같은 슬라이스의 쓰기 command entities/cart/addItem(productId)가 내부에서 canAdd를 호출합니다. 검증과 수행이 Cart Aggregate 안에서 짝지어지고, 외부에는 addItem 하나만 노출됩니다.
두 번째, 낙관적 업데이트
entities/cart/changeQuantity command가 이전 상태 스냅샷을 잡고 UI에 새 수량을 반영한 뒤 서버 호출을 합니다. 서버가 거절하면 같은 command가 이전 스냅샷으로 복원하고 에러를 반환해요.
낙관적 쓰기, 실패 판단, 보상 쓰기가 하나의 Aggregate 내부 command 안에 캡슐화됩니다.
세 번째, 게스트 + 로그인 Cart 병합
User 인증 상태, 게스트 Cart, 서버 Cart 세 Aggregate를 동시에 조율해야 하므로 features/cart-merge/command 로 올립니다.
내부에서 entities/user/getCurrentUser로 인증 상태를 확인하고, entities/cart/loadFromStorage로 게스트 데이터를 읽고, entities/cart/mergeWith(serverCart) Root 메서드에 병합 규칙(수량 합산, 품절 제외, 가격 변경 확인)을 맡깁니다.
병합 규칙 자체는 Cart Aggregate 내부의 불변식으로 유지되고, 여러 Aggregate를 조율하는 오케스트레이션만 Features가 담당합니다. 책임이 깔끔하게 나뉘어요.
| 케이스 | 사분면 | 실제 위치 |
|---|---|---|
| 담기 검증 | 단일 내부 × 읽기 + 쓰기 | entities/cart/canAdd (selector) + entities/cart/addItem (command가 내부에서 canAdd 호출) |
| 낙관적 업데이트 | 단일 내부 × 쓰기 | entities/cart/changeQuantity (스냅샷·반영·복원까지 캡슐화) |
| 게스트 + 로그인 Cart 병합 | 다수 조율 × 쓰기 | features/cart-merge/command (User 인증 + Cart 조율, 병합 규칙은 Cart 내부 불변식으로 유지) |
10. 이 방법론이 모든 프로젝트에 맞지는 않을 수 있습니다
여기까지 왔으면 한 가지는 분명해집니다. 이 방법론은 만능이 아니에요.
단순 CRUD 앱에는 이런 구분이 오히려 비용이 더 클수 있어요. 읽기, 쓰기 이중 모델 관리 비용이 도메인 복잡도 대비 과하게 큰건 사실입니다.
대신 도메인 복잡도가 커서 Features/Entities 구분이 훨씬더 어려워진 프로젝트에는 충분히 이점이 있습니다. 특히 장바구니·주문·결제처럼 불변식이 중요한 비즈니스, 실시간·협업이 필요한 앱, 여러 도메인 객체를 한 흐름에 조율하는 기능이 많은 프로젝트에서 진가를 발휘합니다.
도입을 고민해볼 신호 몇 가지를 정리하면
- Features/Entities 어디에 둘지 PR마다 토론이 생긴다
- 실시간·협업 기능이 있다 (WebSocket, CRDT, 낙관적 업데이트 등)
- 도메인 불변식이 중요한 비즈니스다
- 팀 규모가 커져 온보딩 비용이 부담된다
적용은 단계적으로 하시길 권합니다.
- 기존 F.S.D 레이어 구조 유지
- 읽기/쓰기 축(CQRS) 도입으로 단순 케이스부터 정리
- Aggregate 경계 식별: 도메인 객체의 Root와 불변식 정리
- 두 축 교차 적용으로 남은 모호 케이스 재배치
처음부터 완벽하게 가지 않아도 되어요. 한 축씩 점진적으로 적용하는 편이 안전합니다.
flowchart LR
S1["1단계<br/>F.S.D 레이어<br/>구조 유지"] ==> S2["2단계<br/>CQRS 축 도입<br/>읽기/쓰기 구분"]
S2 ==> S3["3단계<br/>Aggregate 경계<br/>Root와 불변식 정리"]
S3 ==> S4["4단계<br/>두 축 교차<br/>모호 케이스 재배치"]
style S1 fill:#fef3c7,stroke:#f59e0b,color:#1f2937
style S2 fill:#dbeafe,stroke:#3b82f6,color:#1f2937
style S3 fill:#dcfce7,stroke:#22c55e,color:#1f2937
style S4 fill:#e0e7ff,stroke:#6366f1,color:#1f2937
마무리 — Features와 Entities 구분에 어려움을 겪으신다면
F.S.D는 좋은 구조지만, Features와 Entities 경계가 모호하다는 현업에서의 피드백이 많았습니다. 이 모호함을 해결하고 싶다면 CQRS 한 축만으로는 충분하지 않을 수 있어요. 여러 도메인이 함께 움직이는 순간 추가적인 판단이 필요해지거든요.
Features와 Entities 어떻게 구분할지 어려움을 겪으신다면 "이건 쓰기인가 읽기인가", "단일 Aggregate 내부인가 조율인가" 두 축으로 구분해보시는건 어떨까요?
방법론은 완벽한 규칙을 주지 않습니다. 다만 판단을 도울 뿐입니다. 여러분 팀의 판단 기준을 이전보다 견고하게 하는데 이 글이 도움이 됐으면 좋겠습니다.