과제 체크포인트
배포 링크
https://chan9yu.github.io/front_6th_chapter2-3/
기본과제
목표 : 전역상태관리를 이용한 적절한 분리와 계층에 대한 이해를 통한 FSD 폴더 구조 적용하기
- 전역상태관리를 사용해서 상태를 분리하고 관리하는 방법에 대한 이해
- Context API, Jotai, Zustand 등 상태관리 라이브러리 사용하기
- FSD(Feature-Sliced Design)에 대한 이해
- FSD를 통한 관심사의 분리에 대한 이해
- 단일책임과 역할이란 무엇인가?
- 관심사를 하나만 가지고 있는가?
- 어디에 무엇을 넣어야 하는가?
체크포인트
- 전역상태관리를 사용해서 상태를 분리하고 관리했나요?
- Props Drilling을 최소화했나요?
- shared 공통 컴포넌트를 분리했나요?
- shared 공통 로직을 분리했나요?
- entities를 중심으로 type을 정의하고 model을 분리했나요?
- entities를 중심으로 ui를 분리했나요?
- entities를 중심으로 api를 분리했나요?
- feature를 중심으로 사용자행동(이벤트 처리)를 분리했나요?
- feature를 중심으로 ui를 분리했나요?
- feature를 중심으로 api를 분리했나요?
- widget을 중심으로 데이터를 재사용가능한 형태로 분리했나요?
심화과제
목표: 서버상태관리 도구인 TanstackQuery를 이용하여 비동기코드를 선언적인 함수형 프로그래밍으로 작성하기
- TanstackQuery의 사용법에 대한 이해
- TanstackQuery를 이용한 비동기 코드 작성에 대한 이해
- 비동기 코드를 선언적인 함수형 프로그래밍으로 작성하는 방법에 대한 이해
체크포인트
- 모든 API 호출이 TanStack Query의 useQuery와 useMutation으로 대체되었는가?
- 쿼리 키가 적절히 설정되었는가?
- fetch와 useState가 아닌 선언적인 함수형 프로그래밍이 적절히 적용되었는가?
- 캐싱과 리프레시 전략이 올바르게 구현되었는가?
- 낙관적인 업데이트가 적용되었는가?
- 에러 핸들링이 적절히 구현되었는가?
- 서버 상태와 클라이언트 상태가 명확히 분리되었는가?
- 코드가 간결하고 유지보수가 용이한 구조로 작성되었는가?
- TanStack Query의 Devtools가 정상적으로 작동하는가?
최종과제
- 폴더구조와 나의 멘탈모데일이 일치하나요?
- 다른 사람이 봐도 이해하기 쉬운 구조인가요?
과제 셀프회고
이번 과제를 통해 이전에 비해 새롭게 알게 된 점이 있다면 적어주세요.
FSD를 써보면서 느낀 점
매번 하던 고민이었던 "파일을 어디에 둘 것인가?"에 대한 나름의 해답을 찾았습니다. FSD는 단순한 폴더 구조가 아니라 하나의 디자인패턴이라고 생각합니다.
특히 shared 레이어는 어떤 비즈니스 로직에도 얽매이지 않은 범용적인 코드들의 모임으로, 각 레이어마다 역할이 잘 나누어져 있다고 느꼈습니다.
src/
├── app/ # 앱 설정, 라우터, 전역 스타일
├── pages/ # 페이지 컴포넌트
├── widgets/ # 재사용 가능한 UI 블록
├── features/ # 비즈니스 기능 단위
├── entities/ # 도메인 엔티티
└── shared/ # 공통 라이브러리
ky 라이브러리에서 영감을 받은 공통 API 모듈
이번과제에서 api 요청 로직은 어떻게 만들어야 좋을까 고민을 하다가 우현히 ky라는 라이브러리를 접하게 되었습니다 https://www.npmjs.com/package/ky
처음에는 그냥 의존성 설치해서 사용해볼까? 라고 하다가 직접만들 수 있겠다 싶어서 base 레이어에 ky 라이브러리에서 영감을 받아 공통 fetch 모듈을 만들었습니다.
// base/api/base.ts - ky 스타일의 fetch 래퍼
export async function fetcher(url: string, options: FetcherOptions = {}) {
const { body, searchParams, method = "GET", ...fetchOptions } = options
const fetcherUrl = createFetcherUrl(url, { searchParams })
const requestOptions: RequestInit = {
...fetchOptions,
method,
}
if (body !== undefined) {
requestOptions.body = JSON.stringify(body)
requestOptions.headers = {
"Content-Type": "application/json",
...requestOptions.headers,
}
}
const response = await fetch(fetcherUrl, requestOptions)
return new FetcherResponse(response)
}
// 메서드별 단축 함수들
fetcher.get = (url: string, options?: FetcherOptions) => fetcher(url, { ...options, method: "GET" })
fetcher.post = (url: string, options?: FetcherOptions) => fetcher(url, { ...options, method: "POST" })
// ...
네임스페이스를 활용한 API 타입 관리
각 API의 요청/응답 타입을 네임스페이스로 구조화하여 보기 쉽고 사용하기 편하게 만들었습니다.
// entities/comment/model/api.ts
export namespace FetchCommentsByPostId {
export type Payload = {
postId: number
}
export type Response = PaginatedResponse<Comment, "comments">
}
export namespace AddComment {
export type Payload = {
body: string
postId: number
userId: number
}
export type Response = Omit<Comment, "likes">
}
실제 사용할 때는 이렇게 깔끔하게 쓸 수 있습니다.
// entities/comment/api/comments.ts
import type * as CommentModels from "@/entities/comment/model"
export async function fetchCommentsByPostId({ postId }: CommentModels.FetchCommentsByPostId.Payload) {
const response = await fetcher.get(`/comments/post/${postId}`)
return response.json<CommentModels.FetchCommentsByPostId.Response>()
}
이런 방식으로 타입 안전성을 보장하면서도 API 호출 코드를 간결하게 유지할 수 있었습니다.
쿼리 키 팩토리 패턴을 통한 체계적인 캐시 관리
처음에는 쿼리 키를 하드코딩된 문자열로 관리했습니다
useQuery({ queryKey: ["posts", filters], ... })
useQuery({ queryKey: ["posts-search", searchTerm], ... })
useQuery({ queryKey: ["post", id], ... })
하지만 이런 방식은
- 키 중복·충돌 가능성
- 캐시 무효화 시 키 혼동
- 타입 안전성 부족
등의 문제가 있었습니다.
중앙집중식 상수 관리도 시도했지만, base 레이어에서 각 엔티티의 비즈니스 로직을 알아야 하는 구조라 FSD 아키텍처와 맞지 않았습니다.
도메인별 쿼리 키 팩토리 적용
각 엔티티가 스스로 키를 생성·관리하는 방식입니다.
// entities/post/lib/queryKeys.ts
export const postKeys = {
all: ["posts"] as const,
lists: () => [...postKeys.all, "list"] as const,
list: (filters: Record) => [...postKeys.lists(), filters] as const,
detail: (id: number) => [...postKeys.all, "detail", id] as const,
} as const
장점
- 일관된 키 생성 → 낙관적 업데이트/무효화 시 오류 방지
- 타입 안전성 → as const로 정확한 타입 추론
- 계층적 구조 → all → lists → list처럼 세분화 가능
- 응집도 향상 → 도메인 내부에서만 키 관리
useQuery({
queryKey: postKeys.list({ limit: 10, skip: 0 }),
queryFn: () => fetchPosts({ limit: 10, skip: 0 }),
})
queryClient.invalidateQueries({ queryKey: postKeys.all })
queryClient.setQueryData(postKeys.detail(data.id), data)
쿼리 키 팩토리 패턴을 도입한 이후, 키 불일치로 인한 캐시 버그를 해결할 수 있었습니다
본인이 과제를 하면서 가장 애쓰려고 노력했던 부분은 무엇인가요?
API 기준 Feature 분리 시도
기존의 도메인 중심이 아닌 API 기준으로 feature를 나누는 시도을 해봤습니다
features/
├── get-post/ # 게시글 조회
├── create-post/ # 게시글 생성
├── update-post/ # 게시글 수정
├── delete-post/ # 게시글 삭제
├── get-comments/ # 댓글 조회
├── create-comment/ # 댓글 생성
├── update-comment/ # 댓글 수정
├── delete-comment/ # 댓글 삭제
└── like-comment/ # 댓글 좋아요
이 접근법의 장점
- 하나의 기능 수정 시 해당 feature 폴더만 보면 됨
- API 구조와 일치하여 직관적
- 단순한 CRUD 중심 애플리케이션에 적합
나름 괜찮고 깔끔한 방법인거 같아서 팀 스크럼 시간에서도 공유했습니다.
팀 토론 - Header 컴포넌트는 어느 레이어에?
과제를 진행하면서 팀원들과 "Header는 어떤 레이어에 있어야 할까?"라는 주제로 흥미로운 토론을 했습니다.
결국 각자 나름의 논리가 있어서 정답은 없다는 결론에 도달했지만, FSD를 적용하면서 이런 고민을 하게 된다는 것 자체가 의미 있다고 생각했습니다.
https://feature-sliced.design/kr/docs/guides/examples/page-layout FSD 2.0 공식문서에서도 찾아봤는데, Header 같은 Layout은 shared/ui 또는 app/layouts에 두면 된다고 되어 있긴 했지만 명확한 이유는 부족한 느낌이었어요. 만약 누군가 "왜 이걸 여기에 넣었어?"라고 물어본다면 대답하기 어려울 것 같아서 고민이었습니다.
app 파 (내 의견)
- 재사용이 안되는데 shared는 아니지 않나?
- widgets는 도메인이 섞여야 하는데 Header는 순수 레이아웃 아닌가?
- Header는 여러 레이어의 요소들(widgets, features)을 조합할 여지가 있음
- 앱 전체의 전역적 레이아웃을 담당하는 복합 컴포넌트이기 때문에 의존성 규칙상 app 레이어가 맞다고 생각
widgets 파
- 하나의 독립된(완결된) 컴포넌트 조각이라고 생각
- Header나 Footer는 나중에 추가적인 기능이 들어갈 수도 있을 것 같은데, 확장성을 고려해서 미리 widget에 넣어두는 게 맞지 않을까?
shared 파
- Header의 기능을 넣는게 아니라 UI만 넣으면 되지 않을까?
- 단순한 레이아웃 역할이라면 shared가 맞다
- 페이지에서 필요한 로직을 주입해서 사용하면 된다
하지만 팀원들과 계속 이야기를 나누면서 어디에 넣으면 좋을지 생각이 정리된 것 같습니다.
⭐️ 폴더 순서와 의존성 방향 일치시키기
FSD를 적용하면서 하나의 문제점을 발견했습니다. FSD의 의존성 방향은 shared → entities → features → widgets → pages → app 순서인데, IDE에서는 알파벳 순으로 정렬되어 실제로는 다음과 같이 보입니다.
기존 FSD 폴더 순서 (알파벳 정렬)
├── app/ # 최상위 레이어인데 맨 위에
├── entities/
├── features/
├── pages/
├── shared/ # 최하위 레이어인데 아래에
└── widgets/
이렇게 되면 의존성 방향과 시각적 순서가 맞지 않아 헷갈릴 수 있다고 생각했습니다. 그래서 최소한의 별칭 변경으로 순서를 맞춰보았습니다.
개선된 폴더 구조
├── base/ # shared (기반, 토대)
├── entities/ # 그대로 유지
├── features/ # 그대로 유지
├── modules/ # widgets (재사용 가능한 모듈)
├── pages/ # 그대로 유지
└── root/ # app (애플리케이션 루트)
이렇게 하면 b → e → f → m → p → r 순서로 FSD 의존성 방향과 완벽하게 일치하면서도, 기존 FSD 용어와 크게 다르지 않아 헷갈림을 최소화할 수 있습니다.
이 폴더구조 개선안은 FSD를 적용하면서 실제로 겪은 불편함에서 출발했습니다. AI와 여러 번 논의하며 다양한 접근법을 시도해본 결과, 현재의 별칭 방식이 가장 실용적이라는 결론에 도달했습니다.
아직은 막연하다거나 더 고민이 필요한 부분을 적어주세요.
API 기준 분리의 한계점
현재는 간단한 CRUD라 API 기준이 잘 맞지만, 복잡한 비즈니스 로직이 들어가면 어떨지 모르겠습니다
- 여러 API를 조합해야 하는 기능
- 사용자 플로우가 복잡한 기능
- 실무에서는 정말 이 기준이 통할까?
Props Drilling 제거 과정에서 전역상태를 과도하게 사용하고 있는 건 아닌지 궁금합니다.
FSD 구조를 적용하면서 Props Drilling을 없애기 위해 Zustand를 도입했습니다. 각 컴포넌트가 필요한 store를 직접 구독하도록 구현했는데, 예를 들어 URL 파라미터(검색, 필터, 페이징)나 모달 상태 같은 것들을 전역으로 관리하고 있어요.
모든 Props Drilling을 피하기 위해 전역상태를 쓰는 게 맞는 접근일까요? 오히려 과한 전역상태 사용이 문제가 되지 않을까 걱정됩니다.
물론 불필요한 Props Drilling은 당연히 개선해야 한다고 생각하지만, 어디까지를 전역으로 관리하고 어디서부터는 props로 내려주는 게 적절한지 기준이 애매한거 같습니다
이번에 배운 내용 중을 통해 앞으로 개발에 어떻게 적용해보고 싶은지 적어주세요.
이번 과제에서의 AI의 활용
이번 과제에서는 코딩보다는 아이디어나 방향성, 테스트코드 작성, 리뷰 같은 개발 외적인 부분에 AI를 많이 썼어요. 특히 API 기준으로 Feature를 나누는 아이디어나 FSD 폴더 순서 개선 방안을 AI와 계속 대화하면서 다듬었는데, 처음엔 별로인 답변만 나왔지만 결국 만족스러운 결과를 얻을 수 있었습니다.
전반적으로 만족스러웠어요. AI를 그냥 코드 짜주는 도구가 아니라 같이 고민해주는 동료?처럼 쓴 게 가장 좋았던 것 같아요 테스트코드도 AI가 작성해줘서 개발에 더 집중할 수 있었습니다
앞으로도 AI를 그냥 코드만 짜주는 도구가 아니라 진짜 같이 일하는 동료처럼 써보고 싶어요.
챕터 셀프회고
클린코드: 읽기 좋고 유지보수하기 좋은 코드 만들기
FSD를 통한 클린코드의 새로운 관점
이전에는 "함수 하나하나를 깔끔하게"에 집중했다면, 이번에는 전체 구조의 클린함을 고민하게 되었습니다. 코드 한 줄보다 "이 파일이 여기 있는 것이 맞나?"가 더 중요하다는 걸 깨달았습니다.
관련된 파일들을 같은 폴더에 두고, 같은 역할은 같은 패턴으로 만들고, feature와 widgets로 기능별 그룹핑을 하니 정말로 "보기만 해도 아는" 구조가 되었습니다.
결합도 낮추기: 디자인 패턴, 순수함수, 컴포넌트 분리, 전역상태 관리
Props Drilling 문제 해결
기존 프로젝트에서 5-6단계로 props를 내려주던 상황을 경험했었는데, Zustand + TanStack Query 조합으로 완전히 해결했습니다.
// Before: Props Drilling 지옥
<Page>
<Container posts={posts} onUpdate={onUpdate}>
<Table posts={posts} onUpdate={onUpdate}>
<Row post={post} onUpdate={onUpdate}>
<Button onUpdate={onUpdate} />
// After: 필요한 곳에서 직접 접근
const Button = () => {
const { mutate } = useUpdatePostMutation()
return <button onClick={() => mutate(data)} />
}
단일 책임 원칙을 실제로 적용
각 레이어가 정말 하나의 책임만 가지도록 설계하니 디버깅이 쉬워졌습니다. 버그가 생기면 어느 레이어의 문제인지 바로 알 수 있게 되었습니다.
응집도 높이기: 서버상태관리, 폴더 구조
"이 코드는 대체 어디에 둬야 하지?" 고민 해결
과제 초반에는 정말 매번 고민이었습니다. "이게 feature인가? entity인가? widget인가?"
하지만 점점 직관적으로 판단할 수 있게 되었습니다
- 순수한 도메인 데이터 → entities
- 사용자 행동 → features
- 재사용 가능한 UI 조합 → widgets
TanStack Query 사용 경험
// Before: 상태 관리 지옥
const [posts, setPosts] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
useEffect(() => {
setLoading(true)
fetchPosts()
.then(setPosts)
.catch(setError)
.finally(() => setLoading(false))
}, [])
// After: 훨씬 간단해짐
const { data: posts, isLoading, error } = usePostsQuery()
정말 편리하다고 느꼈습니다. 서버 상태와 클라이언트 상태를 이렇게 명확하게 분리할 수 있구나 하는 생각이 들었어요.
리뷰 받고 싶은 내용이나 궁금한 것에 대한 질문
API 기준 Feature 분리에 대한 피드백
현재 create-post, update-post, delete-post 처럼 API 단위로 feature를 나눴는데
장점
- 기능 수정 시 해당 폴더만 보면 됨
- CRUD 중심의 단순한 앱에서는 직관적
- API 구조와 일치하여 백엔드와 소통이 쉬움
의문점
- 실무의 복잡한 비즈니스 로직에서도 이 기준이 유효할까요?
- 여러 API를 조합하는 기능은 어떻게 처리해야 할까요?
전역 상태 관리의 적절한 범위
현재 다음과 같이 상태를 분리했는데 적절할까요?
Zustand 관리
- URL 파라미터 (검색, 필터, 페이징)
- 모달 상태 (열림/닫힘)
TanStack Query 관리
- 서버 데이터 (posts, comments, users)
- API 요청 상태 (loading, error)
실무 관점에서의 FSD 적용
- 팀에서 FSD를 도입할 때 어떤 순서로 진행하는 것이 좋을까요?
- 기존 프로젝트를 FSD로 마이그레이션하는 실무적인 접근법이 궁금합니다.
fetcher 함수 구현에 대해
ky 라이브러리에서 영감을 받아 만든 공통 fetch 모듈의 메서드 단축 함수 부분입니다. (src/base/api/base.ts (40-66줄))
fetcher.get = (url: string, options?: FetcherOptions) => fetcher(url, { ...options, method: "GET" })
fetcher.post = (url: string, options?: FetcherOptions) => fetcher(url, { ...options, method: "POST" })
fetcher.put = (url: string, options?: FetcherOptions) => fetcher(url, { ...options, method: "PUT" })
fetcher.patch = (url: string, options?: FetcherOptions) => fetcher(url, { ...options, method: "PATCH" })
fetcher.delete = (url: string, options?: FetcherOptions) => fetcher(url, { ...options, method: "DELETE" })
해당 코드의 의도된 점은
- ky 라이브러리와 유사한 간결한 API
- 타입 안전성이 보장됨
- entities 레이어에서 fetcher.get(), fetcher.post() 형태로 직관적 사용 가능 이렇습니다
고민되는 부분들은
- 스프레드 연산자 사용: { ...options, method: "GET" } 방식이 매번 새 객체를 생성하는데, 성능상 문제가 없을까요?
- 타입 체크: options에서 method를 덮어쓰는 방식인데, 사용자가 실수로 options에 method를 넣어도 무시되는 게 맞는 설계일까요?
실제 사용할 때는
export async function fetchCommentsByPostId({ postId }: CommentModels.FetchCommentsByPostId.Payload) {
const response = await fetcher.get(`/comments/post/${postId}`)
return response.json<CommentModels.FetchCommentsByPostId.Response>()
}
이렇게 사용하고 있습니다 현재로서는 사용성이 만족스럽지만, 확장 가능한 구조로 개선할 방법이나 놓친 부분이 있다면 피드백을 받아보고 싶습니다
과제 피드백
찬규님 고생하셨어요. 꼼꼼하게 작성해주신 회고를 읽으니까 어떤 식으로 과제를 진행하셨는지 너무 잘 이해할 수 있었던 것 같네요 ㅎㅎ 엔티티에서 feature를 접근해서 사용하고 있거나 , 피처에서 피처로 접근하는 코드들이 있는것 같아요. 이런 부분 잘 체크해보시면 좋겠네요! 그 외에 ky는 제가 별을 찍어놨었는데ㅋㅋㅋㅋㅋ이제서야 다시 꼼꼼하게 보게 되었네요. 좋은 시도였던것 같습니다 :+1 덕분에 다시 보게 되었어요.
말씀해주신것도 있고 성진님 피드백에도 남겨뒀지만 저희는 회사에서 이 규칙을 적용하고 있는데요. 사실 지금의 토론이 많이 갈리는 주제이겠지만 생각을 바꿔보면 저런 규칙이 없이 코드를 작성하다 보면 각자의 생각이 그냥 아무런 제약없이 녹여진다 라고 볼 수 있을 것 같아요. 그러다 보면 리뷰를 할 때 서로의 의견이 들어가다보니 반복적이게 논의를 해야 하는 경우도 많아지고 주제들이 프로젝트를 진행하면서 점점 산발적이게 생길것 같아요. 그럼 이 프로젝트를 운영하는 장기적 관점에서 이전에 논의했던것들은 자연스럽게 잊혀지고 결국 엉망진창 레거시 코드가 탄생하는 것 같아요. 하지만 FSD는 팀 내에서 미리 논의되어야 하는 주제거리를 던져 합의를 하게 하고 추후에 새로 들어오는 인원들은 별도의 논의 없이 이 규칙을 지키게 할 수 있게 되는거죠. 참여하고 있는 사람들도 '내 맘에는 안들지 몰라도' 적어도 프로젝트 자체가 잘 운영이되도록 코드가 작성되는것은 반박할 수 없을거에요. 그런 관점에서 접근을 한다면 나쁘지 않은 주제다!라고 생각할 수 있지 않을까요? ㅎㅎ(개발에 일가견 있는 분들끼리 저런거 하나하나 주제잡고 정하는건 늘 어렵고 빡센 일이니까요)
API 기준 Feature 분리에 대한 피드백
이런 기준은 너무 좋지만, 사실 말씀해주신것처럼 복잡한 비즈니스 로직에서는 유효하지 않을 수 있고..API자체를 순수하게 잘 나눠주시는게 아니라면 적용하기 어려울 수 있어요. 저희가 커뮤니케이션을 BE측과 명확하게 할 수 있다면 이 패턴이 좋겠지만 아니라면 어댑터 형태로 저희가 처리하는게 필요하지 않을까 싶습니다.
전역 상태 관리의 적절한 범위
좋습니다!
실무 관점에서의 FSD 적용
이 부분은 사실 새로 시작하는 프로젝트가 아닌경우에는 약간 어려운 것 같아요. 엔티티를 기반으로 잡고 개선해나가는게 필요한데 이 관점이 점진적으로 옮기면서 검증을 하면서 진행하기에는 어렵죠.. 그럼에도 옮겨야 한다면 명확한 단위의 계획이 필요할 것 같고 명확한 테스트도 함께 미리 구획을 한 다음 작은 단위로 하나하나 옮기는게 필요할 것 같습니다. 그리고 절대 이 이전 작업을 중간에 끊어서도 안될것 같아요.
스프레드 연산자 사용: { ...options, method: "GET" } 방식이 매번 새 객체를 생성하는데, 성능상 문제가 없을까요?
넵 크게 문제없을거에요!
타입 체크: options에서 method를 덮어쓰는 방식인데, 사용자가 실수로 options에 method를 넣어도 무시되는 게 맞는 설계일까요?
넵!
고생하셨고 다음 과제도 잘 부탁드립니다!!!