배포링크 : https://yeongseoyoon-hanghae.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가 정상적으로 작동하는가?
최종과제
- 폴더구조와 나의 멘탈모데일이 일치하나요?
- 다른 사람이 봐도 이해하기 쉬운 구조인가요?
과제 셀프회고
이번 과제도 쉽지 않았습니다...연속 3주차 쉽지 않았따고만 말하는거같은데... 셀프로 인생하드모드를 찍어보니까 진짜 쉽지 않네요... 한 3일정도를 다른 곳에 에너지를 투자하다보니 과제에 투자할 시간이 없어지고 결국엔 또 밤을 새게 된...우하하 그래도 다음주부터는 좀 더 시간이 날 것 같아서(아마?) 좀 더 과제에 투자할 시간이 있지 않을까 싶습니다.
이번 프로젝트의 전체 폴더구조
src/
├── app/ # 애플리케이션 레벨 설정
│ ├── layouts/ # 레이아웃 컴포넌트
│ │ ├── Footer.tsx
│ │ └── Header.tsx
│ └── providers/ # 전역 프로바이더
│ └── index.tsx
├── assets/ # 정적 자산
│ └── react.svg
├── entities/ # 도메인 엔티티 (가장 하위 레이어)
│ ├── comment/ # 댓글 도메인
│ │ ├── api/ # API 관련
│ │ │ ├── api.ts
│ │ │ ├── index.ts
│ │ │ ├── mutations.ts # TanStack Query mutations
│ │ │ └── queries.ts # TanStack Query queries
│ │ ├── model/ # 타입 정의
│ │ │ └── index.ts
│ │ └── ui/ # 순수 UI 컴포넌트
│ │ ├── CommentItem.tsx
│ │ ├── CommentList.tsx
│ │ └── index.ts
│ ├── post/ # 게시물 도메인
│ │ ├── api/
│ │ │ ├── api.ts
│ │ │ ├── index.ts
│ │ │ ├── mutations.ts
│ │ │ └── queries.ts
│ │ ├── model/
│ │ │ └── index.ts
│ │ └── ui/
│ │ ├── PostItem.tsx
│ │ ├── PostTagItem.tsx
│ │ └── index.ts
│ └── user/ # 사용자 도메인
│ ├── api/
│ │ ├── api.ts
│ │ ├── index.ts
│ │ └── queries.ts
│ ├── model/
│ │ └── index.ts
│ └── ui/
│ ├── UserDetail.tsx
│ └── index.ts
├── features/ # 사용자 기능 (비즈니스 로직)
│ ├── comment/ # 댓글 관련 기능
│ │ ├── add-comment/ # 댓글 추가 기능
│ │ │ ├── model/ # 비즈니스 로직
│ │ │ │ ├── useAddComment.ts
│ │ │ │ └── useAddCommentDialog.tsx
│ │ │ ├── ui/ # 기능별 UI
│ │ │ │ └── AddCommentDialog.tsx
│ │ │ └── index.ts
│ │ ├── delete-comment/ # 댓글 삭제 기능
│ │ │ ├── model/
│ │ │ │ └── useDeleteComment.ts
│ │ │ └── index.ts
│ │ ├── edit-comment/ # 댓글 수정 기능
│ │ │ ├── model/
│ │ │ │ ├── useEditComment.ts
│ │ │ │ └── useEditCommentDialog.tsx
│ │ │ ├── ui/
│ │ │ │ └── EditCommentDialog.tsx
│ │ │ └── index.ts
│ │ └── like-comment/ # 댓글 좋아요 기능
│ │ ├── model/
│ │ │ └── useLikeComment.ts
│ │ └── index.ts
│ ├── post/ # 게시물 관련 기능
│ │ ├── add-post/ # 게시물 추가
│ │ │ ├── model/
│ │ │ │ ├── useAddPost.ts
│ │ │ │ └── useAddPostDialog.tsx
│ │ │ ├── ui/
│ │ │ │ └── AddPostDialog.tsx
│ │ │ └── index.ts
│ │ ├── delete-post/ # 게시물 삭제
│ │ │ ├── model/
│ │ │ │ └── useDeletePost.ts
│ │ │ └── index.ts
│ │ ├── edit-post/ # 게시물 수정
│ │ │ ├── model/
│ │ │ │ ├── useEditPost.ts
│ │ │ │ └── useEditPostDialog.tsx
│ │ │ ├── ui/
│ │ │ │ └── EditPostDialog.tsx
│ │ │ └── index.ts
│ │ └── view-posts/ # 게시물 조회
│ │ ├── model/
│ │ │ ├── usePostsBrowseParams.ts
│ │ │ └── usePostsList.ts
│ │ ├── ui/
│ │ │ └── PostsFilterBar.tsx
│ │ └── index.ts
│ └── user/ # 사용자 관련 기능
│ └── view-profile/ # 프로필 조회
│ ├── model/
│ │ └── useUserProfileDialog.tsx
│ ├── ui/
│ │ └── UserProfileDialog.tsx
│ └── index.ts
├── mocks/ # MSW 모킹
│ ├── handlers.ts
│ └── server.ts
├── pages/ # 페이지 레벨 (최상위)
│ ├── __tests__/ # 테스트
│ │ └── PostsManagerPage.test.tsx
│ └── posts-manager/ # 게시물 관리 페이지
│ ├── model/
│ │ └── usePostsManagerStore.tsx # Zustand store
│ └── ui/
│ └── PostsManagerPage.tsx
├── shared/ # 공통 유틸리티
│ ├── config/ # 설정
│ │ └── query-client.ts # TanStack Query 설정
│ ├── lib/ # 공통 로직
│ │ ├── modal/
│ │ ├── highlightText.tsx # 검색어 하이라이트
│ │ ├── httpClient.ts # HTTP 클라이언트
│ │ ├── index.ts
│ │ ├── useOverlay.tsx # 모달 관리
│ │ └── useQueryParamsPagination.ts # URL 상태 동기화
│ ├── types/ # 공통 타입
│ │ ├── index.ts
│ │ ├── pagination.ts
│ │ └── sort.ts
│ └── ui/ # 공통 UI 컴포넌트
│ ├── button/
│ │ └── index.tsx
│ ├── card/
│ │ └── index.tsx
│ ├── dialog/
│ │ └── index.tsx
│ ├── input/
│ │ └── index.tsx
│ ├── pagination/
│ │ └── PaginationBar.tsx
│ ├── post-actions/
│ ├── select/
│ │ └── index.tsx
│ ├── table/
│ │ └── index.tsx
│ ├── textarea/
│ │ └── index.tsx
│ └── index.ts
├── widgets/ # 재사용 가능한 위젯
│ ├── post-detail/ # 게시물 상세 위젯
│ │ ├── model/
│ │ │ └── usePostDetailDialog.tsx
│ │ ├── ui/
│ │ │ └── PostDetailDialog.tsx
│ │ └── index.ts
│ ├── posts-pagination/ # 페이지네이션 위젯
│ │ ├── ui/
│ │ │ ├── PostsPagination.tsx
│ │ │ └── index.ts
│ │ └── index.ts
│ └── posts-table/ # 게시물 테이블 위젯
│ └── ui/
│ ├── PostsTable.tsx
│ └── index.ts
├── App.tsx # 루트 컴포넌트
├── index.css
├── main.tsx # 진입점
├── setupTests.ts
└── vite-env.d.ts
이번 과제를 통해 이전에 비해 새롭게 알게 된 점이 있다면 적어주세요.
fsd에 대해서 모호했던 개념들을 잡을 수 있었던것 같습니다.
본인이 과제를 하면서 가장 애쓰려고 노력했던 부분은 무엇인가요?
어떻게하면 FSD의 단방향 의존성을 지킬 수 있을까에 대해서 많이 고민했던 것 같습니다. 그리고 코드의 응집도를 신경쓰려고 노력했습니다.
FSD 계층별로 단방향 흐름을 가져가기 위해서 entities는 순수한 데이터, 내부적으로 액션이 없는 Ui, 그리고 API 계층으로만 구성하고, features는 사용자 행동과 비즈니스 로직만 담당하도록 했습니다. entities/comment/ui/CommentItem.tsx는 외부 의존성 없이 props만으로 동작하는 순수한 UI 컴포넌트로 만들었고, features/comment/add-comment/useAddComment.ts에서는 entities의 mutation을 사용하여 위에서 아래로만 의존하도록 구성했습니다. 이렇게 해서 entities는 외부 의존이 전혀 없고, features는 entities에만 의존하고, widgets는 features와 entities에 의존하고, pages는 모든 계층에 의존하는 단방향 의존성을 구현할 수 있었습니다.
TanStack Query를 활용한 낙관적 업데이트 구현에도 신경을 썼습니다. 지금 현재는 dummy데이터를 사용하고 있어 get요청인 경우에는 문제가 없으나, 댓글 추가 혹은 게시글 추가를 통해 새로운 글, 댓글이 추가되는 경우 계속해서 똑같은 Id값이 응답값으로 반환되어 react의 렌더링 구분값인 key값이 중복되는 경우가 존재하게 되었습니다. 따라서 post, put, delete시에는 key값으로 둘만한 id값이 필요했는데요. comment 추가 시에는 먼저 queryClient.cancelQueries로 race condition을 방지하고 이전 데이터를 백업한 후, 음수로 된 임시 ID를 생성해서 즉시 UI에 반영했습니다. 만약 API 호출이 실패하면 이전 데이터로 자동 롤백되고 성공하면 임시 데이터를 실제 서버 데이터로 교체하는 방식으로 구현했습니다. 또한 낙관적 업데이트 시 생성되는 임시 데이터에는 음수 ID와 isTemporary: true 플래그를 부여해서 실제 서버 데이터와 구분했습니다. 그래서 임시 상태의 아이템에 대해서는 실제 API 호출을 하지 않고 Promise.resolve()를 반환하도록 하여 게시글 삭제, 수정등과 같은 작업시에 404 에러가 발생하지 않도록 구분이 가능하게 되었습니다. 또한 게시글의 경우에는 임시로 등록되는 Id값이 음수값이 되어 나타나 사용자 입장에서는 불편할 것 같아 id자리에 id를 보이는 게 아니라 NEW라는 뱃지를 띄우도록 구현하였습니다.
https://github.com/user-attachments/assets/48e490de-6b86-41e8-84c3-ff83e12499eb
https://github.com/user-attachments/assets/6afa0c74-2754-4954-9464-cb257b5d327f
또한 쿼리 옵션을 통해서 도메인별로 쿼리키 및 쿼리함수에 대한 응집도를 생각했습니다. postQueries.listQuery, postQueries.searchQuery, postQueries.listByTagQuery처럼 각 쿼리가 자체 옵션을 포함하도록 해서 응집도를 높였습니다.
import { queryOptions } from "@tanstack/react-query"
import { postApi, FetchPostsBySearchParams, FetchPostsByTagParams, FetchPostsParams } from "./api"
export const postQueries = {
all: () => ["post"] as const,
list: () => [...postQueries.all(), "list"] as const,
listQuery: (params: FetchPostsParams) =>
queryOptions({
queryKey: [...postQueries.list(), params],
queryFn: () => postApi.getPosts(params),
}),
listByTag: () => [...postQueries.list(), "byTag"] as const,
listByTagQuery: (params: FetchPostsByTagParams) =>
queryOptions({
queryKey: [...postQueries.listByTag(), params],
queryFn: () => postApi.getPostsByTag(params),
enabled: !!params.tag,
}),
search: () => [...postQueries.list(), "search"] as const,
searchQuery: (params: FetchPostsBySearchParams) =>
queryOptions({
queryKey: [...postQueries.search(), params],
queryFn: () => postApi.searchPosts(params),
enabled: !!params.search,
}),
detail: () => [...postQueries.all(), "detail"] as const,
detailQuery: (id: string) =>
queryOptions({
queryKey: [...postQueries.detail(), id],
queryFn: () => postApi.getPosts({ limit: 1, skip: parseInt(id) - 1 }),
enabled: !!id,
}),
tag: () => [...postQueries.all(), "tag"] as const,
tagQuery: () =>
queryOptions({
queryKey: [...postQueries.all(), "tag"],
queryFn: postApi.getTags,
}),
}
기존 코드에 sortBy가 없음으로 되어있는 경우에 검색 또는 필터링이 되지 않는 버그가 있어 공식문서를 참고하여 필터링 되지 않는 버그를 수정했습니다.
그리고, shared 레이어에서는 횡단 관심사를 체계적으로 분리했습니다.
import axios, { AxiosInstance, AxiosRequestConfig } from "axios"
const API_BASE_URL = import.meta.env.DEV ? "/api" : "https://dummyjson.com"
class HttpClient {
private client: AxiosInstance
constructor() {
this.client = axios.create({
baseURL: API_BASE_URL,
timeout: 10000,
headers: {
"Content-Type": "application/json",
},
})
}
async get<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
const response = await this.client.get<T>(url, config)
return response.data
}
async post<T>(url: string, data?: unknown): Promise<T> {
const response = await this.client.post<T>(url, data)
return response.data
}
async put<T>(url: string, data?: unknown): Promise<T> {
const response = await this.client.put<T>(url, data)
return response.data
}
async patch<T>(url: string, data?: unknown): Promise<T> {
const response = await this.client.patch<T>(url, data)
return response.data
}
async delete<T>(url: string): Promise<T> {
const response = await this.client.delete<T>(url)
return response.data
}
}
const httpClient = new HttpClient()
export const http = {
get: <T>(url: string, config?: AxiosRequestConfig) => httpClient.get<T>(url, config),
post: <T>(url: string, data?: unknown) => httpClient.post<T>(url, data),
put: <T>(url: string, data?: unknown) => httpClient.put<T>(url, data),
patch: <T>(url: string, data?: unknown) => httpClient.patch<T>(url, data),
delete: <T>(url: string) => httpClient.delete<T>(url),
}
위 코드처럼 HttpClient를 구현하여 사용하는 쪽에서는 HTTP 응답 구조를 신경쓰지 않고 비즈니스 데이터만 다룰 수 있도록 구현했습니다. useOverlay를 통해서는 모달이나 다이얼로그를 선언적으로 관리할 수 있게 구현했습니다. 기존의 명령적인 방식이었다면 useState로 isOpen 상태를 관리하고, 사용자 입력을 또 다른 상태로 관리해야 했겠지만, useOverlay는 Promise를 반환해서 모달의 결과값을 직접 받을 수 있게 만들었습니다.
리팩토링 작업을 시작하기 전에 최소한의 ui에 대한 유지를 보장하기 위한 테스트를 작성했습니다.
또한 zustand를 도입하면서 스코프를 좁혀주기 위해서 React Context와 결합하는 패턴을 사용했습니다. 이 패턴은 tkdodo의 글에도 언급되어있는데요. 전역 zustand store를 사용하면 애플리케이션 전체에서 하나의 상태를 공유하게 되는데, 이는 같은 컴포넌트를 여러 번 인스턴스화할 때 상태가 서로 간섭하는 문제가 발생합니다. 예를 들어 PostDetail 컴포넌트를 여러 개 렌더링하면 각각이 독립적인 댓글 상태를 가져야 하는데, 전역 store를 사용하면 모든 인스턴스가 같은 댓글 상태를 공유하게 되어 의도하지 않은 부작용이 발생하게 됩니다.
이를 해결하기 위해 Context Provider 내부에서 zustand store를 생성하고, 해당 Provider의 스코프 내에서만 그 store를 사용할 수 있도록 제한하는 방식을 채택했습니다. 이렇게 하면 각 Provider가 독립적인 store 인스턴스를 갖게 되어, 같은 컴포넌트라도 서로 다른 Context Provider 안에 있다면 완전히 분리된 상태를 유지할 수 있습니다. 제가 생각했을때 이부분에 있어서 의존성 주입 관점에서의 이점을 가져갈 수 있다고 생각하는데, 컴포넌트가 "나는 이런 종류의 store가 필요하다"고 Context를 통해 명시적으로 선언하게 되어 암묵적으로 전역 store에 의존하는 것보다 훨씬 명확한 의존성 관계를 형성한다고 생각하고 해당 방향으로 진행했습니다.
아직은 막연하다거나 더 고민이 필요한 부분을 적어주세요.
features를 어디까지 분리해야할까에 대한 고민
features를 좀 더 잘라야했을까? 라는 고민이 드는 부분이 있는 것 같습니다. 지금은 파일이 너무 잘게 쪼개지고 응집도가 너무 떨어지는 것이 아닌가 하는 생각에 ui를 적당히 잘랐는데요, 단순히 훅뿐만아니라 만약 add-post라면... add post를 하는 button까지 잘라 ui로 배치했어야했을지에 대한 의문이 듭니다.
제가 생각했을때 ui를 너무 잘게 쪼개게되면 단순히 컴포넌트의 '분리'에만 초점이 두어지는 것 같고 이렇게되면 아무리 결합도가 낮아지는 구조라고해도 같은 버튼에 onClick시 기능만 달라짐에도 다른 기능의 button의 의미가 될 것 같아 더이상 나누지는 않았습니다. 예를 들어
전역상태 관련 고민
그리고 전역상태에 대해서는 계속해서 의문이 드는 것 같습니다. 지금 현재로서 FSD구조를 가져가고 있으므로 단방향으로 의존성을 가져가게되는데, 단순히 props 드릴링을 해결하기 위해서 전역상태 관리 라이브러리를 사용하면 하위 레이어에서도 상위 레이어의 액션을 호출하거나 상태를 변경할 수 있게 되어서 의존성 방향이 역전될 수 있지 않나 하는 우려가 있다고 생각했습니다.
예를 들어 제 코드를 기준으로 했을때 props 드릴링을 해결하기 위해 entities 레이어에 있는 CommentItem 컴포넌트에서 zustand store를 호출했다고 생각해보겠습니다. props 드릴링을 피하기 위해서 만들었으니 zustand store에서는 값 뿐만 아니라 액션도 존재할 것이라고 생각하고, 단순히 props를 주입하여 사용할때는 문제가 되지 않았으나 store를 호출하여 CommentItem을 사용하게 되는 경우에는 다음과 같이 entities 레이어가 widgets 또는 features 레이어의 store에 직접 의존하게 되는 상황이 발생합니다. 이렇게 되면 FSD의 핵심 원칙인 "상위→하위로만 의존"이라는 규칙이 깨지게 된다고 생각합니다. entities는 가장 하위 레이어로서 어떤 상위 레이어에도 의존하지 않아야 하는데, store를 통해 features나 widgets 레이어에 직접 의존하게 될 것이라 생각했습니다.
// widgets/post-detail/ui/PostDetailDialog.tsx
import { CommentList } from "@entities/comment/ui"
import { useAddComment } from "@features/comment/add-comment"
import { useEditComment } from "@features/comment/edit-comment"
// entities/comment/ui/CommentList.tsx
// 외부 의존성 없음, 순수 props ✅
// entities/comment/ui/CommentItem.tsx
interface CommentItemProps {
comment: CommentType
searchQuery: string
onEdit: (comment: CommentType) => void // props로만 받음 ✅
onDelete: (id: number) => void
onLike: (id: number) => void
}
제 원래 코드는 위와 같았는데요.
만약 처음부터 zustand를 고려하여 설계를 했더라면 문제가 없는 설계로 방향을 잡을 수 있었을 것 같습니다만, 지금과 같은 설계에서는 zustand를 이후에 도입하게되면서 이전까지는 문제가 없었음에도 불구하고 zustand를 도입함으로써 구조를 변경해야하는 상황이 된다고 느껴졌습니다.
zustand를 도입하기 전까지도 props 드릴링 외에는 문제가 존재하지 않았었기도 하고, 만약 정말 필요하다면 서버 상태는 TanStack Query로, 클라이언트 상태는 Context + useState로 충분히 관리 가능한 것 같은데, 어떤 경우에 Zustand나 Jotai 같은 라이브러리를 도입해야 하는지 아직 명확한 기준이 서지 않은 것 같습니다. 특히 현재 코드베이스에서는
// 이부분에서 단방향 의존성이 깨진다고 느껴짐
// entities/comment/ui/CommentItem.tsx
import { useCommentStore } from "@widgets/post-detail/model/commentStore"
const CommentItem = ({ comment }) => {
// entities가 widgets에 의존
const { deleteComment, editComment } = useCommentStore()
}
각 레이어가 명확한 역할을 가지고 있고, 의존성 방향도 올바르다고 생각했습니다. props drilling이 2단계 정도 발생하지만, 이는 아키텍처 건전성을 해치는 것보다는 훨씬 나은 트레이드오프라고 생각하는데, 코치님께서는 어떻게 생각하시는지 궁금합니다.
일단 과제에서 props드릴링 방지를 위하여 전역상태 라이브러리를 사용하라고 되어있어 props 드릴링이 일어나고있는 댓글 부분에 대해 적용했고, 단방향 흐름을 위해 CommentList를 features로 끌어올렸지만, 전역 상태관리 라이브러리 없이 서버 상태를 통해서도 충분히 가능할 것 같다는 생각이 자꾸 듭니다.
위 두가지에 대해서는 하위 질문에도 동일하게 적어두겠습니다.
이번에 배운 내용 중을 통해 앞으로 개발에 어떻게 적용해보고 싶은지 적어주세요.
솔직하게는 저희 회사에서 fsd를 직접적으로 적용하려고하면 쉽지 않을까 하는 생각이 드는데요, 지금 저희 회사같은 경우에는 shared-features-pages의 삼단구조를 차용하고 있어 만약 fsd 구조를 가져가면 좀 더 상세한 분리가 필요하고 이에 대한 리소스가 많이 들어가게 될 것 같다는 생각이 듭니다. 그러나 각각의 엔티티에 대해서 어떻게 하면 독립적인 엔티티를 가져갈지에 대한 충분한 고민이 되었다고 생각하고, 추후에 기업과제를 할때 좀 더 체계적으로 폴더구조를 가져갈 수 있지 않을까 기대됩니다.
챕터 셀프회고
클린코드와 아키테쳑 챕터 함께 하느라 고생 많으셨습니다! 지난 3주간의 여정을 돌이켜 볼 수 있도록 준비해보았습니다. 아래에 적힌 질문들은 추억(?)을 회상할 수 있도록 도와주려고 만든 질문이며, 꼭 질문에 대한 대답이 아니어도 좋으니 내가 느꼈던 인사이트들을 자유롭게 적어주세요.
클린코드: 읽기 좋고 유지보수하기 좋은 코드 만들기
- 더티코드를 접했을 때 어떤 기분이었나요? ^^; 클린코드의 중요성, 읽기 좋은 코드란 무엇인지, 유지보수하기 쉬운 코드란 무엇인지에 대한 생각을 공유해주세요 더티코드를 봤을 때 가장 힘들었던 건 단순히 "지저분하다"가 아니라 "이 코드의 의도를 파악하는 데 드는 인지적 부하"였다고 생각합니다. 예를 들어
for (var i=0; i < cartItems.length; i++) {
(function () {
var curItem;
for (var j=0; j < prodList.length; j++) {
if(prodList[j].id === cartItems[i].id) {
curItem=prodList[j];
break;
}
}
var q=parseInt(cartItems[i].querySelector('span').textContent.split('x ')[1]);
var itemTot=curItem.val * q;
var disc=0;
itemCnt += q;
subTot += itemTot;
//중략
totalAmt += itemTot * (1 - disc);
})();
}
과제 2-1에 있던 이 코드를 처음 봤을 때 이해가 쉽지 않았던 이유는 머릭속에서 동시에 여러 층의 추상화를 파싱해야 했기 때문이 아닌가 생각합니다. 첫 번째 for문의 목적, IIFE, DOM 조작, 할인 계산 등등 머리는 하나인데 지저분한 변수명까지 이 모든걸 한번에 인지하려고 하다보니 머릿속에서 오버플로우가 일어나는 느낌이었습니다. 더티 코드를 클린 코드로 전환하면서 읽기 좋은 코드란 "인지적 복잡도를 최소화하는 코드"라는 걸 깨달았던것 같습니다. 각각 함수가 명확한 역할을하고, 인풋과 아웃풋이 정확해서 부수효과가 일어나지 않는 계산과, 부수효과는 있지만 예측이 가능한 액션으로 분리가 될때 좀 더 읽기가 쉬운 코드가 되었다고 생각합니다. 일전에 2-1과제를 마치고나서 선언적 프로그래밍이 무엇인지에 대한 대화를 나누었던 적이 있는데요.
제가 생각하는 선언적 프로그래밍에 대해서 작성해보자면... 보통 선언적 프로그래밍을 설명할 때 "What vs How"라는 예시를 많이 사용하는 것 같아요. 이게 프로그래밍론적으로 정말 유명한 구분법이긴 한데, 저한테는 이 설명이 좀 어려웠어요. What과 How의 기준이 사실 상당히 불분명하다고 생각했거든요. 처음엔 저도 '선언적인 코드라는건 내부를 들여다볼 필요없이 사람이 함수의 의미만 보더라도 이해할 수 있게 작성한 코드인가?' 하고 생각하다가, '아니 어차피 고장나면 다 내부를 뜯어다 봐야하잖아?' 하고 생각했거든요.(더 인지부조화가 온..) 게다가 코드라는 건 어떻게 보면 하나의 연속적인 스펙트럼 같은데, 이걸 딱 둘로 나누어 떨어뜨릴 수 있을까 하는 의문이 들었습니다. 물론 두 분의 앞선 대화에서 어느 정도 정리가 되긴 했지만요 ㅎㅎ 제가 생각하는 선언적 프로그래밍은 인간과 컴퓨터 사이의 간극을 줄여주는 철학이라고 생각해요. 이렇게 말하면 좀 거창하게 들릴 수도 있을 것 같은데요... 예를 들어 사람은 기본적으로 절차적인 것보다는 결과 지향적으로 생각하잖아요. 'A를 넣으면 B가 나온다'를 기대하고 코드를 작성하고요. 반면에 컴퓨터는 과정 지향적이라고 생각합니다. 'A를 넣으면 먼저 C가 처리하고, 그 다음에 D가 계산되고, 메모리에서 뭔가를 읽어오고... 어쩌고저쩌고 해서 결국엔 B가 됩니다'라는 식으로 모든 걸 단계별로 처리하잖아요. 그래서 선언적 프로그래밍이 주는 가장 큰 가치는 이 인식론적 차이를 줄여주는 거라고 생각해요. 우리가 머릿속으로 생각하는 방식과 실제로 코드를 작성하는 방식 사이의 거리를 좁혀주는 것이 아닐까요? 예를 들어 "로그인한 사용자에게는 대시보드를 보여주고, 그렇지 않으면 로그인 폼을 보여줘"라고 생각했을 때, 이걸 그대로 user ?
: 로 표현할 수 있는 것처럼요. 물론 컴퓨터는 뒤에서 여전히 조건문을 평가하고, 컴포넌트를 렌더링하고, DOM을 업데이트하는 복잡한 과정을 거치지만, 개발자는 그런 세부사항에 신경 쓰지 않고 자신의 의도를 직접적으로 표현할 수 있다고 생각합니다. 약간 역 컴파일 같은 느낌이라고 생각했어요 ㅋㅋㅋ 암튼 글이 작성하다 보니까 장황해졌는데, 결국적으로는 선언적 프로그래밍은 인간이 좀 더 본질적인 문제 해결에 집중할 수 있게 해주는 철학이라고 생각합니다... 구현의 세부사항에 시간을 쏟는 대신, 정말 중요한 비즈니스 로직이나 사용자 경험에 더 많은 에너지를 투자할 수 있게 해주는 것 같아요..!
이런 선언적 프로그래밍에 대한 관점에서 보면, 읽기 좋은 코드란 결국 "인간의 사고 과정과 가장 가까운 형태로 의도를 표현한 코드"라고 정의할 수 있을 것 같습니다. 우리가 머릿속으로 "이런 조건이면 이렇게 하고, 저런 상황이면 저렇게 해야지"라고 생각하는 자연스러운 흐름을 코드에서도 그대로 따라갈 수 있으면 그게 읽기 쉬운 코드가 되는 것이라고 생각합니다.
유지보수하기 쉬운 코드는 "변화에 대한 예측 가능성을 제공하는 코드"라고 생각했습니다. 새로운 요구사항이 들어왔을 때 "이 부분을 수정하면 되겠구나"라고 직관적으로 파악할 수 있고, 수정시의 영향범위를 예상할 수 있는 코드가 그렇지 않을까 생각했습니다.
말이 길어졌는데 결국에 클린코드의 궁극적인 목표는 "개발자의 인지적 에너지를 본질적인 문제 해결에 집중할 수 있게 해주는 것"이라고 생각합니다. 이전에는 클린 코드가 어떤 것인지에 대해서 정의하기가 어려웠는데, 다른 분들과 토의하고 의견을 개진해보면서 좀 더 클린코드에 대해서 정의할 수 있었던 것 같아 의미있는 시간이었습니다.
결합도 낮추기: 디자인 패턴, 순수함수, 컴포넌트 분리, 전역상태 관리
- 거대한 단일 컴포넌트를 봤을때의 느낌! 처음엔 막막했던 상태관리, 디자인 패턴이라는 말이 어렵게만 느껴졌던 시절, 순수함수로 분리하면서 "아하!"했던 순간, 컴포넌트가 독립적이 되어가는 과정에서의 깨달음을 들려주세요
처음에 천줄이 넘는 App.tsx를 봤을때 '아 또 이걸 어떻게 하지ㅋㅋㅋ...'하는 생각만 들었습니다. useEffect를 통해서 서버상태를 동기화함에 있어서도 서버 로직이 어디있는지를 파악하는 것 자체가 굉장히 에너지가 들었고, 각각 함수의 역할이 너무 모호해서 하나를 수정하려면 다른 여러 부분도 함께 확인해야 하는 상황이었습니다.
단계별로 코드를 분리해나가면서 중요하게 생각한건 "관심사의 분리"였다고 생각합니다. 예를 들어 기존에는 handleSubmit 함수 안에서 폼 검증, 데이터 변환, API 호출, 에러 처리, UI 업데이트가 모두 뒤섞여 있었는데, 이걸 각각의 순수함수로 분리하니까 역할이 분명해지고 이해하기 쉬운 코드가 되었던 것 같습니다.
특히 2-2 과제를 할때 관심사의 분리를 고려하기 위해 FSD구조를 사용했었는데, 계산함수에서 도메인 로직을 빼고 순수한 비즈니스 룰들을 entities 레이어 혹은 shared로 옮기니까 코드의 의도가 훨씬 명확해졌다고 생각합니다. 예를 들어 장바구니 총액 계산에서 할인율 적용이나 세금 계산 같은 비즈니스 로직들을 calculateItemTotal, applyDiscount, calculateTax 같은 순수함수로 분리하고, UI 레이어에서는 단순히 이 함수들을 조합해서 사용하기만 하는 구조를 가져갈 수 있었습니다. 이렇게 하니까 만약 추가적인 요구사항이 들어온다고 생각했을때 해당 도메인 함수만 수정하면 되고 고려해야할 영향 범위가 줄어든다는 느낌을 받았습니다. 이전에 준일님께서 레고블록에 비유한 Deview영상을 추천해주신적이 있는데 그것과 흐름을 비슷하게 가져갈 수 있었던 것 같습니다. 특히 이렇게 분리된 함수들은 사이드 이펙트가 없어서 좀 더 예측이 가능하다고 생각했습니다.
또 이전에 준일님께서 주신 피드백에서 onSuccess, onError와 같이 콜백을 전달하는 Observer 패턴을 통해 좀 더 선언적으로 코드를 작성할 수 있을 것 같다! 라고 말씀주셔서 2-2에서는 신경써서 해당 부분을 적용해보았었는데요. "성공했을 때는 이렇게 해줘, 실패했을 때는 저렇게 해줘"라고 선언을 하는 방식에서 의도가 훨씬 명확해졌던것 같습니다. 이벤트가 발생하면 알려달라는 식으로 관심사를 위임하고, 상위에서는 각각의 관심사를 가진 함수들을 콜백을 통해서 넘겨주어 조합을 해서 사용하면 되었던 것 같습니다.
전역상태 관리같은 경우에 대해서는 사실 저희 회사에서는 전역상태 관리 라이브러리라고하면 클라이언트 상태 관리 라이브러리는 따로 사용하지 않고, 서버상태 관리 라이브러리를 사용하고 있는데요. 이부분에 대해서는 사실 아직도 약간의 의문이 있는것 같습니다. 과연 props 드릴링이 나쁜걸까요? 탄스택쿼리를 통해 서버상태 관리 라이브러리를 사용하고 있음에도 불구하고 전역상태관리 라이브러리가 무조건적으로 필요한 것일까요? 사실 과제를 하면서도, context+useState를 통해서 혹은 서버상태관리 라이브러리를 통해 충분히 관리가 가능한 지점이라고 생각이 되어서.... 아직은 이부분에 대한 답아 명확하게 내려진 것 같진 않습니다.
응집도 높이기: 서버상태관리, 폴더 구조
- "이 코드는 대체 어디에 둬야 하지?"라고 고민했던 시간, FSD를 적용해보면서의 느낌, 나만의 구조를 만들어가는 과정, TanStack Query로 서버 상태를 분리하면서 느낀 해방감(?)등을 공유해주세요
이번에 FSD를 진행하면서 정말 많은 질문을 받았었는데, 보통적으로 어떻게 features를 나눠야하냐, widgets은 어떻게 나눠야하냐 이런 질문들이었던 것 같습니다. 그리고 저에게 질문을 주신 분들에게 드렸던 답변은 항상 동일했습니다. 'FSD는 사실 폴더구조를 팀에서 유리하게 가져가기 위한 철학일뿐이지 무조건적으로 지켜야할 룰이 아니고, 자기가 생각하는 FSD와 그에대한 컨벤션이 있다면 자유롭게 해도 된다. 다만 FSD에서는 의존성을 계층화시켜두고 단방향으로 의존하게 하는 특성이 있으니 그 부분에 대해서만 지키면 될 것 같다.' 물론 아직도 이부분에 대해서 저도 명확하게 생각하진 못하고 있고, 매번 "이게 정답인가?" 싶은 순간들이 있지만...FSD가 모두 정답은 아니고 제가 편리하게 계층화 해서 사용하고 명확한 기준이 있다면 그게 정답이지 않을까 생각하면서 과제에 임했던 것 같습니다.
FSD를 분리하는 부분에 있어서 고민이 있었다면 이번 과제를 구현하면서는 얼마나 작은 단위로 features를 나눠야 할지에 대한 고민이 있었던 것 같습니다. 예를 들어 게시물 관련 기능에서 add-post라는 기능이있다면 지금 코드와 같이 단순히 훅으로만 묶어둘지 혹은 AddPostButton과 같이 분리할지에 대한 부분이었습니다. 결국 제가 내린 결론은 UI의 분리를 "재사용성"과 "비즈니스 로직의 복잡도"를 기준으로 판단하자 였습니다.
만약 AddPostButton이 여러 페이지에서 동일한 모습과 동작으로 재사용된다면 features/post/add-post/ui에 두는 게 맞다고 생각했고, 반대로 각 페이지마다 다른 스타일이나 추가적인 로직이 필요하다면 해당 페이지나 widgets에서 useAddPost 훅만 가져다 쓰는 방식이 더 유연하다고 판단했습니다. 그래서 보통적으로 AddPostButton은 단순히 컴포넌트의 '분리'에만 초점이 두어지는 것 같고, 오히려 너무 많은 파일을 왔다갔다 하면서 확인해야할것 같아서 이부분은 features나 entity에 퉁쳐진 형태로 그대로 두었습니다...ㅎㅎ...
물론 제가 FSD에 대해서 질문 주시는 분들에 대해서 정답은 없다라고 말씀드리긴 했지만 저도 이걸 어떻게 둬야할지에 대해서 고민되는 부분이 많았았던 것 같습니다. 사실 이번 과제의 경우에는 Post라는 엔티티에 user객체가 넘어오는것이 아니라 userId만 넘어오는 상태라 분리가 편했지만 만약에 userId가 아니라 user객체로 넘어오게 된다면... 엔티티 분리를 하기 좀 더 어려워졌을거라는 생각이 들었습니다.
또한 TanStack Query를 각각의 entity에 배치하고 쿼리키와 쿼리펑션을 가깝게 두는 QueryKey Factory형태로 설계하면서 좀 더 응집도 높은 코드를 작성할 수 있었다고 생각했습니다. 외부에서 사용할때는 내부적으로 어떤 쿼리키를 가졌는지 어떤 동작을 해야하는지에 대해서 생각하지 않아도 되도록 캡슐화했던 게 만족스러웠던 것 같습니다.
과제를 하면서 FSD는 결국 '코드를 찾는 시간을 줄이고, 변경의 영향 범위를 예측 가능하게 만드는' 도구라는 생각이 들었습니다. 완벽한 분류보다는 코드의 위치나 예상 범위를 직관적으로 예상할 수 있는 일관성이 더 중요하다는 생각이 들었습니다. (아마 그게 철학일 것 같고요)
리뷰 받고 싶은 내용이나 궁금한 것에 대한 질문
-
앞서 언급한 features 분리에 대한 고민과 연결되니 위쪽 질문 봐주시면 감사하겠습니다..!
-
앞서 언급한 전역상태 관련 고민과 연결되니 위쪽 질문 봐주시면 감사하겠습니다..!
과제 피드백
이번주 과제도 훌륭합니다. FSD규칙을 명확하게 지켰고, 명확한 규칙을 가지고 잘 나눴습니다. 모범사례로 볼 수 있을것 같아요.
features를 어디까지 분리해야할까에 대한 고민
말씀해주신 부분이 적절한 분석인 것 같아요. 버튼까지 나눌 필요가 있나 싶긴해요. 단순히 '분리' 관점에서 해당 부분을 고민하고 나눈다면, 재사용성에 대한 판단이 흐려지고 일단 나누고 보는 관점이다보니 응집도도 낮아지구요. 지금과 동일한 규칙대로 운영하면 될 것 같아요.
전역상태 관련 고민
저도 구체적이게 작성한 설명을 보니까 어떤 고민을 하게 되었는지 명확하게 이해가 되었는데요. 일단 필요하지 않으면 굳이 사용할 필요는 당연히 없습니다. 결국 서버 상태를 제외하고 전역 라이브러리를 사용한다는 것은 드릴링을 방지하는것도 있지만, 복잡한 상태 동기화 등에서도 고려할 수 있는 부분이지만 설명을 들어보면 사용하지 않아도 된다고 저는 생각해요.
지금의 구조가 comment에 대한 UI가 모든 곳에서 재사용 될 수 있는 관점이라면 entity에 위치하는게 적합할 수는 있지만, 지금같으면 아예 page에 컴포넌트 자체를 끌어올려야 할 수도 있겠네요.
제 개인적으로는 이렇게 생각하는데, 이 의도에 대해서는 발제자님께 명확하게 질문해보는게 가장 명쾌한 것 같습니다.
다음주도 화이팅입니다.