과제 체크포인트
배포링크
기본과제
목표 : 전역상태관리를 이용한 적절한 분리와 계층에 대한 이해를 통한 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가 정상적으로 작동하는가?
최종과제
- 폴더구조와 나의 멘탈모델이 일치하나요?
- 다른 사람이 봐도 이해하기 쉬운 구조인가요?
과제 셀프회고
쿼리키옵션
쿼리키팩토리 라이브러리를 매번 사용하다가 불편함을 느꼇던것은 setQueryData / getQueryData에 쿼리키 전달할떄 타입추론이 잘 되지않는 문제가 되지않는 문제가 발생했었습니다. 그런데 queryOptions를 사용하니 너무나 깔끔하게 타입추론이 잘되어서 놀라웠습니다. 실제로 저희 회사팀원분중에 다른팀의 코드(윤영서님)를 보고 이거 좋은데요라고 까지 해주셨었는데, 이번과제에서 한번써보고 3일전에 신규피처에는 쿼리키팩토리를 사용하지않고, 사용해봤는데 너무 좋았습니다. 실제로 신규피처에서 낙관적업데이트를 사용해야해서 더욱더 만족스러웠습니다.
내가 새롭게 체감한 점
처음에는 views / entities / shared만으로 시작했습니다. 화면에서만 쓰는 값은 views, 서버에서 온 데이터와 그 규칙은 entities, 어느 레이어에서나 쓸 수 있는 도메인 무관 도구는 shared에 두었습니다. 이 기준을 지키며 리팩터링하다 보니, 사용자 행동 단위인 features와 화면의 독립 구역인 widgets가 자연스럽게 필요해졌고, 그때서야 추가했습니다. 덕분에 "데이터는 어디에, 행동은 어디에, 섹션은 어디에"라는 자리가 분명해졌다고 그렇게 생각합니다.
- shared: 전역 도구(HTTP, QueryClient, 공용 UI/훅)
// 기본 HTTP 클라이언트 인스턴스 생성
export const httpClient = new HttpClient({
timeout: 10000,
headers: {
"Content-Type": "application/json",
},
})
// 편의 함수들
export const api = {
get: <T = unknown>(url: string, options?: RequestOptions) => httpClient.get<T>(url, options),
post: <T = unknown>(url: string, data?: unknown, options?: RequestOptions) => httpClient.post<T>(url, data, options),
put: <T = unknown>(url: string, data?: unknown, options?: RequestOptions) => httpClient.put<T>(url, data, options),
patch: <T = unknown>(url: string, data?: unknown, options?: RequestOptions) =>
httpClient.patch<T>(url, data, options),
delete: <T = unknown>(url: string, options?: RequestOptions) => httpClient.delete<T>(url, options),
}
- entities: 도메인 근처의 서버 상태, API 요청/응답 모양, 검증
export const getPosts = async (requestParams: z.infer<typeof getPostsRequestParamsSchema>) => {
const parsedRequestParams = getPostsRequestParamsSchema.parse(requestParams)
const response = await httpClient.get<z.infer<typeof getPostsResponseSchema>>("/api/posts", {
params: parsedRequestParams,
})
return getPostsResponseSchema.parse(response.data)
}
- features: 사용자 행동 하나(추가/수정/삭제)와 폼·검증·성공/실패 흐름
export const openAddPostDialog = (options: Options) => {
overlay.open(({ isOpen, close }) => (
<Dialog open={isOpen} onOpenChange={() => isOpen && close()}>
<AddPostDialog onSubmit={options.onSubmit} close={close} />
</Dialog>
))
}
- widgets: 화면의 독립 구역(목록/상세 등) 조합
const postsQuery = useQuery({
...postEntityQueries.getPosts({ ...queryParams }),
})
const usersQuery = useQuery({
...userEntityQueries.getUsers({ limit: 0, select: "username,image" }),
enabled: postsQuery.isFetched,
select: (response) => ({
data: response.users,
pagination: { limit: response?.limit ?? 0, skip: response?.skip ?? 0, total: response?.total ?? 0 },
}),
})
- views: 페이지 조립자. 언제 어떤 다이얼로그를 열고, 어떤 행동을 부를지 결정
const { addPost, updatePost } = usePostMutations({ queryParams })
const { deleteComment, likeComment } = useCommentMutations({
postId: selectedPostId as number,
})
const handleOpenPostDetailDialog = (post: Post) => {
setSelectedPostId(post.id)
openPostDetailDialog({
post: post,
searchQuery: queryParams.searchQuery,
onDeleteComment: (commentId) => deleteComment.mutate({ id: commentId }),
onLikeComment: (commentId, likes) => likeComment.mutate({ id: commentId, likes }),
onCloseCallback: () => setSelectedPostId(null),
})
}
역할 단위 폴더구조와 FSD의 차이, 왜 FSD는 화면 만들기 편할까?
역할 단위 구조(components, hooks, services처럼 기술별로 나누는 방식)는 상자 정리는 깔끔하지만, 화면은 여러 도메인(게시글, 사용자, 댓글)을 한 번에 엮는 일이 많습니다. 그러다 보면 컴포넌트가 데이터와 규칙을 여기저기서 직접 가져와 가공하게 되고, "누가 어떤 책임을 져야 하는지"가 흐려지는 경험을 자주 했습니다. FSD는 데이터 소유와 검증을 도메인 가까이에 고정하고(entities), 사용자 행동은 features로, 여러 도메인을 엮는 화면 구역은 widgets로, 페이지 조립은 views로, 기술 공용은 shared로 두어 위에서 아래로만 의존하도록 유도하니, 화면 작업 흐름과 잘 맞는다고 그렇게 생각합니다.
도메인 근처에서 서버 IO를 검증하고 키를 표준화하고, 상위 레이어는 이 계약을 신뢰해 가져다 씁니다.
export const postEntityQueries = {
all: ["post"] as const,
getPostsKey: (requestParams: z.infer<typeof getPostsRequestParamsSchema>) =>
[...postEntityQueries.all, "getPosts", requestParams] as const,
getPosts: (requestParams: z.infer<typeof getPostsRequestParamsSchema>) =>
queryOptions({
queryKey: postEntityQueries.getPostsKey(requestParams),
queryFn: () => getPosts(requestParams),
}),
}
여러 도메인을 엮는 구역은 widgets에서 "조합"으로만 처리하고, 데이터 소유나 키 정의는 관여하지 않도록 했습니다.
const postsQuery = useQuery({
...postEntityQueries.getPosts({ ...queryParams }),
})
const usersQuery = useQuery({
...userEntityQueries.getUsers({ limit: 0, select: "username,image" }),
enabled: postsQuery.isFetched,
select: (response) => ({
data: response.users,
pagination: { limit: response?.limit ?? 0, skip: response?.skip ?? 0, total: response?.total ?? 0 },
}),
})
페이지는 views에서 오케스트레이션만 담당해, 어떤 구역을 배치하고 언제 어떤 행동을 부를지만 연결합니다.
const { addPost, updatePost } = usePostMutations({ queryParams })
const { deleteComment, likeComment } = useCommentMutations({
postId: selectedPostId as number,
})
공용 기술은 shared에 모아두어, 바꿔 끼워도 도메인 경계가 흔들리지 않게 했습니다.
export const httpClient = new HttpClient({
timeout: 10000,
headers: {
"Content-Type": "application/json",
},
})
export const api = {
get: <T = unknown>(url: string, options?: RequestOptions) => httpClient.get<T>(url, options),
post: <T = unknown>(url: string, data?: unknown, options?: RequestOptions) => httpClient.post<T>(url, data, options),
}
또한, 성공 이후 캐시 반영 같은 "데이터의 사후 처리"를 도메인 가까이에 두면 책임이 흐트러지지 않는다고 느꼈습니다.
const addPost = useMutation({
mutationFn: addPostAction,
onError: (error) => {
console.error("게시물 추가 오류:", error)
},
onSuccess: (addPostResponse) => {
if (queryParams) {
queryClient.setQueryData(
postEntityQueries.getPosts({ ...queryParams }).queryKey,
(prevPostResponse) => optimisticAddPost(prevPostResponse, addPostResponse),
)
}
},
})
아토믹 디자인과 FSD의 차이, 경계가 흐려지는 지점
아토믹 디자인은 UI를 잘 그리는 데에는 큰 도움이 됩니다. 다만 실제 화면에서는 데이터와 서버 상태가 얽히는 순간이 많고, 그때 molecules/organisms가 상태와 규칙까지 떠안게 되면 UI 층이 무거워진다고 느꼈습니다. FSD는 UI 프리미티브를 shared/ui에서 "그리기"에만 집중시키고, 데이터와 규칙은 entities, 행동은 features, 섹션 조합은 widgets로 흘려보내 경계를 좀 더 선명하게 지키는 데 도움이 된다고 그렇게 생각합니다.
실제 상세 다이얼로그에서도, widgets는 여러 도메인을 엮고 행동을 연결하지만, 서버 상태의 키/검증은 전부 entities가 책임집니다.
export const PostDetailDialog = ({ post, onDeleteComment, onLikeComment, searchQuery }: Props) => {
const { data: comments } = useQuery({
...commentEntityQueries.getCommentsByPostId(post.id),
select: (response) => response.comments,
})
const { addComment, updateComment } = useCommentMutations({ postId: post.id })
DDD 관점에서의 선택: 무겁게 들고 가지 않고, 실용적으로 쓰자
프론트엔드에서 바운디드 컨텍스트를 너무 엄격히 나누면, 화면을 만들 때마다 경계를 넘나드는 비용이 커진다고 느꼈습니다. 그래서 DDD의 큰 철학(소유, 경계, 계약)은 존중하되, 구현은 FSD의 레이어에 담아 가볍게 쓰기로 했습니다. 바운디드 컨텍스트는 결과적으로 "화면을 구성하는 엔티티 묶음"으로 보고, entities에서 스키마/쿼리 키/서버 상태 소유를 맡기되, 서로는 느슨하게만 연결하는 쪽을 택했습니다. 컨텍스트 간의 만남은 주로 widgets나 views에서 조합으로 일어나도록 했습니다.
- 위젯에서 여러 컨텍스트를 얕게 매칭하는 예시입니다.
const postWithAuthors = postsQuery.data?.posts.map((post) => ({
...post,
author: usersQuery.data?.data.find((user) => user.id === post.userId),
}))
- 각 컨텍스트는 자신의 키/요청을 스스로 정의하고, 다른 곳은 이 계약만 신뢰합니다.
export const userEntityQueries = {
all: ["users"] as const,
getUsersKey: (requestParams: z.infer<typeof getUsersRequestParamsSchema>) =>
[...userEntityQueries.all, "getUsers", requestParams] as const,
getUsers: (requestParams: z.infer<typeof getUsersRequestParamsSchema>) =>
queryOptions({
queryKey: userEntityQueries.getUsersKey(requestParams),
queryFn: () => getUsers(requestParams),
}),
이런 방식이면 DDD의 핵심(소유와 경계)은 지키면서, 화면 조립은 FSD의 상위 레이어에서 유연하게 할 수 있어 실용적이라고 그렇게 생각합니다. "엔티티 같은 레이어 간 참조"도 타입/키/셀렉터 수준에서 얕게 이뤄지면 괜찮다고 그렇게 생각합니다.
내가 가장 애쓴 부분
행동(features)과 구역(widgets), 도메인(entities)의 경계를 지키는 데 집중했습니다. 위에서 크게 만들기보다 아래에서 필요한 것만 차곡차곡 쌓았고, 두세 군데 이상에서 실제로 쓰인 뒤에야 공통화해 성급한 추상화를 피했습니다.
const addPost = useMutation({
mutationFn: addPostAction,
onError: (error) => {
console.error("게시물 추가 오류:", error)
},
onSuccess: (addPostResponse) => {
if (queryParams) {
queryClient.setQueryData(
postEntityQueries.getPosts({ ...queryParams }).queryKey,
(prevPostResponse) => optimisticAddPost(prevPostResponse, addPostResponse),
)
}
},
})
FSD를 넘어선 자신만의 폴더 구조에 대한 고민
FSD를 적용하면서 여러 아키텍처 패러다임을 비교해본 결과, 현재 구조가 이미 colocation과 layering의 균형을 잘 맞춘 형태라는 결론에 도달했습니다.
Colocation의 가치를 깨닫다
여러 구조를 경험해보니 **관련된 것끼리 가까이 두기(colocation)**가 가장 중요하다고 느꼈습니다. 과거 역할별 폴더 구조에서는 하나의 기능을 위해 여러 폴더를 오가며 파일을 찾아야 했지만, 현재는 도메인별로 필요한 모든 것이 한 곳에 모여 있어 훨씬 효율적입니다.
entities/posts/
├── api/ # API 요청/응답
├── hooks/ # 상태 관리 훅
├── model/ # 타입 정의
└── index.ts # 공개 인터페이스
Layering의 명확함
FSD의 레이어 구조(shared → entities → features → widgets → views)가 의존성 방향을 명확히 해줍니다. 이는 단순히 폴더 구조를 넘어서 사고의 체계를 제공합니다. 새로운 기능을 추가할 때도 "어느 레이어의 책임인가?"를 먼저 생각하게 되어 더 나은 설계로 이어집니다.
다른 패턴들을 고려해본 결과
도메인별 Feature 구조 (Vertical Slice Architecture)
features/
├── post-management/
│ ├── components/
│ ├── hooks/
│ ├── services/
│ └── types/
└── user-management/
├── components/
├── hooks/
├── services/
└── types/
이 구조도 고려했지만, 도메인 간 데이터 공유가 필요한 순간(게시글 목록에서 작성자 정보 표시 등)에 경계가 애매해진다는 단점이 있었습니다.
기능별 Monorepo 스타일
packages/
├── post-domain/
├── user-domain/
├── shared-ui/
└── shared-utils/
너무 과하다고 판단했습니다. 중소 규모 프로젝트에서는 복잡성 대비 이득이 적었습니다.
현재 구조가 최선인 이유
- 점진적 복잡성 관리: 작은 것부터 시작해서 필요에 따라 레이어를 추가할 수 있음
- 명확한 책임 분리: 각 레이어가 명확한 역할을 가지고 있어 새 팀원도 쉽게 이해
- 실용적 유연성: 너무 엄격하지 않아서 실제 개발 시 적용하기 용이
- 도메인과 기술의 균형: 도메인 로직은 entities에, 기술적 관심사는 shared에 적절히 분리
더 나아가: 진정한 격리를 위한 고민
FSD의 핵심 가치 중 하나는 레이어 간 격리입니다. 하지만 자바스크립트 모듈 시스템만으로는 이런 격리를 강제하기 어렵다는 한계가 있습니다. 개발자가 실수로 views에서 직접 entities를 건너뛰고 shared를 import하는 것을 막을 방법이 없죠.
패키지 기반 격리 아이디어
// package.json (workspace root)
{
"workspaces": [
"packages/shared",
"packages/entities",
"packages/features",
"packages/widgets",
"packages/views"
]
}
// packages/features/package.json
{
"dependencies": {
"@myapp/shared": "workspace:*",
"@myapp/entities": "workspace:*"
}
// widgets나 views는 의존성에 없음 -> 물리적으로 import 불가
}
이렇게 하면:
- 컴파일 타임에 의존성 위반을 잡을 수 있음
- 각 레이어가 독립적인 빌드/테스트 단위가 됨
- 진정한 의미의 경계 강제
하지만 과할 수 있다고 생각하는 이유는:
- 개발 복잡성 증가: 간단한 변경에도 여러 패키지를 넘나들어야 함
- 빌드 시간 증가: 각 패키지별 빌드 과정이 필요
- 타입 공유 복잡성: 패키지 간 타입 전파가 까다로워짐
현실적 절충안
현재로서는 FSD의 멘탈 모델을 잘 지키는 것이 더 실용적이라고 판단했습니다. 하지만 프로젝트가 더 커지고 팀이 늘어난다면, 중요한 경계(특히 entities 레이어)는 실제 패키지로 분리하는 것도 고려해볼 만합니다.
// 미래의 가능한 구조
packages/
├── shared/ # 완전 독립 패키지
├── post-domain/ # entities 레벨을 패키지로
├── user-domain/ # entities 레벨을 패키지로
└── app/ # features, widgets, views
├── features/
├── widgets/
└── views/
결론: 새로운 구조보다 현재 구조의 심화
새로운 폴더 구조를 만드는 것보다, 현재 FSD 구조에서 colocation과 layering 원칙을 더 깊이 적용하는 것이 더 가치 있다고 결론지었습니다.
예를 들어:
entities내에서 도메인별 colocation 강화- 각 레이어 내에서 일관된 파일 명명 규칙 정착
- 레이어 간 의존성 규칙을 더 엄격히 준수
- (선택적으로) 핵심 도메인을 패키지로 분리해 물리적 격리 확보
이것이야말로 **"조금 더 현대적이면서도 기능 중심"**인 접근법이라고 생각합니다.
아직 더 고민이 필요한 부분
수정/추가 후 어떤 캐시를 언제 무효화할지, 이 책임을 entities에서 표준화할지 features에서 상황별로 결정할지는 더 고민중 입니다.
리뷰 받고 싶은 내용이나 궁금한 것에 대한 질문
- 캐시 무효화 책임: 엔티티 표준(쿼리 키 기반)으로 강제할까요, 아니면 피처가 상황별로 결정하도록 둘까요? 팀 합의가 가능한 단일 기준이 있나요?
- 낙관적 업데이트/롤백: 실패 시 UI와 캐시를 어떻게 되돌릴까요? 동일 리소스에 대한 동시 업데이트 충돌은 어떤 규칙으로 중재하나요?
- 도메인 모델 변환: 서버 DTO 변화에 대비해 변환 레이어를
entities/model에 고정할까요? 언제 공유 타입으로 승격하고 언제 도메인에 남길지 기준이 있나요?
과제 피드백
준형님 이번 주 과제도 잘하셨네요. 뭔가 AI의 향이 느껴지는 회고 부분 부분도 있었는데 내용 전반적으로 잘 작성해주신 것 같아요 ㅎㅎ 작성해주신대로 모두 느끼고 이해하고 적용하셨다면 더할나위 없는 한주였을것 같네요. (개인적으로는 views로 보다는 pages로 그대로 작성하는게 더 의미상으로 맞아 보이네요 ㅎㅎ 각각 ) 탠스택 쿼리를 현재는 entity에 속해있는 경우가 있는데, 이 부분도 해석하는 사람마다 다르겠지만 단순히 해당 엔티티에 대한 페칭이 아닌 여러 액션을 담고 있는 부분이 있어 각 피처에서 다뤄야 한다는 얘기가 많이 있긴해요! 지금의 구조가 단순히 데이터를 조회하는 맥락이라면 엔티티의 위치도 좋지만 상태에 대한게 함께 전달이 되니 이렇게 되면 왔다갔다 하니까 규칙을 아예 만들어버리는 경우도 있구요 ㅎㅎ 전반적으로 타입에 대한 것도 zod를 활용해서 별도로 많이 작성해주셨고 필요로 하는 여러 기능들도 부가적으로 잘 작성해주셨네요.
캐시 무효화 책임: 엔티티 표준(쿼리 키 기반)으로 강제할까요, 아니면 피처가 상황별로 결정하도록 둘까요? 팀 합의가 가능한 단일 기준이 있나요?
요런것들 때문에 엔티티에 쿼리가 들어가게 되면 여러 액션이 섞이게 되어 적절하지 않을 수 있다였는데요. 조회에 대한것만 entity에 있다면 표준적이게 쿼리키로 무효화를 하고 아니면 피처에서 별도로 처리하는 형태로 구현하는게 좋을 것 같아요. 아니면 키만 엔티티에 두고 feature에 애초에 쿼리를 두고 각각 사용에 맞춰 쓰는거죠.
낙관적 업데이트/롤백: 실패 시 UI와 캐시를 어떻게 되돌릴까요? 동일 리소스에 대한 동시 업데이트 충돌은 어떤 규칙으로 중재하나요?
요거도 위 질문과 이어지지 않을까 싶네요! l
도메인 모델 변환: 서버 DTO 변화에 대비해 변환 레이어를 entities/model에 고정할까요? 언제 공유 타입으로 승격하고 언제 도메인에 남길지 기준이 있나요?
이 질문도 약간은 이어지는 것 같은데요. 모델에 위치시킨다는건 결국 그 모델에 대한 조회가 발생된다는 거에 해당이 될텐데, 실제로 조회를 했을 때 해당 정보만 내려오는게 아니라면 (또는 그것만 깔끔하게 발라내서 사용하는 딱떨어지는 형태가 아니라면) 모델에 두는게 적절하지 않을수도 있을것 같아요. 정보의 특성에 맞게 위치하게 두는게 좋지 않을까 싶습니다. 페이지에 가까우면 페이지에 두는것처럼요. 아니면 쿼리의 select같은 것들을 활용해서 어댑터 처럼 사용하는 방식도 있을 것 같구요. 공유 타입 승격에 대해 고민하게 되는것도 결국 자연스럽게 동일하게 사용되는 위치, 정보에 가깝게 두면 되지 않을까 싶습니다.
고생하셨고 다음주 테스트도 기대되네요. 화이팅입니다!