[8팀 현지수] Chapter 2-3. 관심사 분리와 폴더구조#34
Conversation
- 게시물 + 작성자 정보 복합 조회 (기본 목록) - 검색된 게시물 + 작성자 정보 복합 조회 - 태그별 게시물 + 작성자 정보 복합 조회
| /** | ||
| * 특정 게시물의 댓글 목록 조회 | ||
| */ | ||
| const fetchComments = async (postId: number): Promise<CommentsResponse> => { |
There was a problem hiding this comment.
오호 지수님 api와 tanstack-query를 같이 묶어놨네요. 어제 테오가 api = tanstack-query라고 생각해도 된다고 했는데 딱 잘 적용하신거같아요
| * Mock 댓글 객체 생성 | ||
| * 더미 환경에서 API 응답을 시뮬레이션하기 위함 | ||
| */ | ||
| export const createMockComment = (overrides: Partial<Comment>): Comment => ({ |
There was a problem hiding this comment.
더미데이터 만드는것도 이렇게 헬퍼로 뽑으셨네요. 짱이다
| const queryClient = useQueryClient() | ||
|
|
||
| return useMutation({ | ||
| mutationKey: ["createComment"], |
There was a problem hiding this comment.
mutation도 쿼리키로 넣어주셨네용. 혹시 이 쿼리키 어디서 쓰는지 알 수 있을까영?
There was a problem hiding this comment.
@ckdwns9121 mutationKey 약간 주석 같은 느낌 아닌가요???
| mutationFn: ({ id, likes }: { id: number; likes: number; postId: number }) => { | ||
| return isNewlyCreatedComment(id) | ||
| ? Promise.resolve(createMockComment({ id, likes })) | ||
| : likeCommentApi(id, likes) | ||
| }, |
| return <span>{parts.map((part, i) => (isMatch(part) ? <mark key={i}>{part}</mark> : part))}</span> | ||
| } | ||
|
|
||
| console.log(comment.body) |
| {isCreateModalOpen && ( | ||
| <CommentFormDialog mode="create" isOpen={isCreateModalOpen} onClose={handleCreateModalClose} postId={postId} /> | ||
| )} |
There was a problem hiding this comment.
isCreateModalOpen으로 분기처리를 하는 방법보단 이미 props로 isOpen을 받고있어서 CommentFormDialog 안에서 처리해도 될꺼같아요
| export * from "./CommentFormDialog" | ||
| export * from "./CommentControlPanel" |
There was a problem hiding this comment.
그리고 지금 구조가 features/comment-manangemt/shared/ui < 이런식으로 설계하셨는데 features에서 shared를 한번더 만들어주신게 궁금해요!
There was a problem hiding this comment.
features안에서 사용되는 shared 가 아닐까요? 추상화는 항상 진짜 잘해주시네요
| import * as React from "react" | ||
| import { forwardRef } from "react" | ||
|
|
||
| export const Card = forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => ( |
There was a problem hiding this comment.
React 19버전은 forwardRef보단 props로 바로 ref를 받는식으로 바꾸셔도 좋을거같아여
| import { useQuery } from "@tanstack/react-query" | ||
| import { api } from "../../../shared/lib" | ||
| import { CommentsResponse } from "../types" |
There was a problem hiding this comment.
import 경로가 상대경로와 별칭이 혼재되어 있어서 일관성 있게 통일하면 좋을 것 같아요!
| import { EXISTING_COMMENT_MAX_ID } from "../constants" | ||
| import { Comment } from "../types" | ||
|
|
||
| /** | ||
| * 새로 생성된 댓글인지 판별 | ||
| * 더미 환경에서 서버에 존재하지 않는 댓글을 구분하기 위함 | ||
| */ | ||
| export const isNewlyCreatedComment = (id: number): boolean => { | ||
| return id > EXISTING_COMMENT_MAX_ID | ||
| } | ||
|
|
||
| /** | ||
| * Mock 댓글 객체 생성 | ||
| * 더미 환경에서 API 응답을 시뮬레이션하기 위함 | ||
| */ | ||
| export const createMockComment = (overrides: Partial<Comment>): Comment => ({ | ||
| id: 0, | ||
| body: "", | ||
| postId: 0, | ||
| likes: 0, | ||
| user: { id: 1, username: "User", image: "" }, | ||
| ...overrides, | ||
| }) |
There was a problem hiding this comment.
mock을 위한 헬퍼함수 좋은거같습니다..!!! 이렇게 빼니 더 안정성있고 좋네요
한가지만 첨언하면 isNewlyCreatedComment 보다는 실제로는 "서버에 존재하지 않는" 댓글을 판별하는 용도니까 isLocalComment 또는 isMockComment 같은 이름이 더 명확할 수도 있을거 같아요~
| /** | ||
| * 게시물 목록 조회 | ||
| */ | ||
| export const fetchPosts = async (params: { | ||
| limit: number | ||
| skip: number | ||
| sortBy?: string | ||
| sortOrder?: "asc" | "desc" | ||
| }): Promise<PostsResponse> => { | ||
| const queryParams = new URLSearchParams({ | ||
| limit: params.limit.toString(), | ||
| skip: params.skip.toString(), | ||
| }) | ||
|
|
||
| const actualSortBy = params.sortBy === "none" ? "id" : params.sortBy | ||
|
|
||
| if (actualSortBy && params.sortOrder) { | ||
| queryParams.set("sortBy", actualSortBy) | ||
| queryParams.set("order", params.sortOrder) | ||
| } | ||
|
|
||
| return api.get<PostsResponse>(`/posts?${queryParams.toString()}`) | ||
| } | ||
|
|
||
| export const usePostsQuery = (params: { limit: number; skip: number; sortBy?: string; sortOrder?: "asc" | "desc" }) => { | ||
| return useQuery({ | ||
| queryKey: ["posts", params], | ||
| queryFn: () => fetchPosts(params), | ||
| }) | ||
| } |
There was a problem hiding this comment.
params 부분을 이렇게 바꿔봐도 좋을거같아요!
// 현재: 수동으로 URLSearchParams 생성
const queryParams = new URLSearchParams({...})
return api.get<PostsResponse>(`/posts?${queryParams.toString()}`)
// 제안: axios가 자동으로 처리하도록
return api.get<PostsResponse>("/posts", {
params: {
limit: params.limit,
skip: params.skip,
...(actualSortBy && params.sortOrder && {
sortBy: actualSortBy,
order: params.sortOrder
})
}
})| {/* 댓글 목록 */} | ||
| <div className="space-y-1"> | ||
| {commentsData?.comments.length === 0 ? ( | ||
| <div className="text-center text-gray-500 text-sm py-4">댓글이 없습니다.</div> | ||
| ) : ( | ||
| commentsData?.comments.map((comment: Comment) => ( | ||
| <CommentItem key={comment.id} comment={comment} searchQuery={searchQuery} /> | ||
| )) | ||
| )} | ||
| </div> |
| const handleCreate = useCallback( | ||
| (data: { body: string; postId: number }) => { | ||
| createComment( | ||
| { | ||
| body: data.body, | ||
| postId: data.postId, | ||
| userId: 1, // TODO: 실제 사용자 ID | ||
| }, | ||
| { | ||
| onSuccess: () => { | ||
| handleClose() | ||
| }, | ||
| onError: (error) => { | ||
| console.error("댓글 생성 오류:", error) | ||
| }, | ||
| }, | ||
| ) | ||
| }, | ||
| [createComment], | ||
| ) |
There was a problem hiding this comment.
handleCreate, handleUpdate를 useCallback으로 감싸주셨는데
의존성이 mutation 함수들뿐이라면 메모이제이션 효과가 미미할거같다는 생각도 듭니다..!
There was a problem hiding this comment.
어차피 과제라서 할수 있는 한 최대한 해보는 것도 의미 있다고 생각이 드네요
| export const useModal = ({ openOnMount = false }: UseModalProps = {}) => { | ||
| const [isModalOpen, setIsModalOpen] = useState<boolean>(false) | ||
|
|
||
| const handleModalOpen = useCallback(() => { | ||
| setIsModalOpen(true) | ||
| }, []) | ||
|
|
||
| const handleModalClose = useCallback(() => { | ||
| setIsModalOpen(false) | ||
| }, []) | ||
|
|
||
| useEffect(() => { | ||
| if (openOnMount) { | ||
| handleModalOpen() | ||
| } | ||
| }, [openOnMount, handleModalOpen]) | ||
|
|
||
| return { | ||
| isModalOpen, | ||
| handleModalOpen, | ||
| handleModalClose, | ||
| } | ||
| } |
There was a problem hiding this comment.
| export const useModal = ({ openOnMount = false }: UseModalProps = {}) => { | |
| const [isModalOpen, setIsModalOpen] = useState<boolean>(false) | |
| const handleModalOpen = useCallback(() => { | |
| setIsModalOpen(true) | |
| }, []) | |
| const handleModalClose = useCallback(() => { | |
| setIsModalOpen(false) | |
| }, []) | |
| useEffect(() => { | |
| if (openOnMount) { | |
| handleModalOpen() | |
| } | |
| }, [openOnMount, handleModalOpen]) | |
| return { | |
| isModalOpen, | |
| handleModalOpen, | |
| handleModalClose, | |
| } | |
| } | |
| export const useModal = ({ openOnMount = false }: UseModalProps = {}) => { | |
| const [isModalOpen, setIsModalOpen] = useState<boolean>(openOnMount) | |
| const handleModalOpen = useCallback(() => { | |
| setIsModalOpen(true) | |
| }, []) | |
| const handleModalClose = useCallback(() => { | |
| setIsModalOpen(false) | |
| }, []) | |
| return { | |
| isModalOpen, | |
| handleModalOpen, | |
| handleModalClose, | |
| } | |
| } |
모달 on, off 상태에 대한 관리를 훅으로 만드셨네요!! 👍 초기값을 인자로 받을 수 있는 형태라면 useEffect없이 state의 초기값으로 할당하면 어떨까요?!
| export const api = { | ||
| get: <T>(url: string): Promise<T> => apiClient.get(url), | ||
| post: <T>(url: string, data?: unknown): Promise<T> => apiClient.post(url, data), | ||
| put: <T>(url: string, data?: unknown): Promise<T> => apiClient.put(url, data), | ||
| patch: <T>(url: string, data?: unknown): Promise<T> => apiClient.patch(url, data), | ||
| delete: <T>(url: string): Promise<T> => apiClient.delete(url), | ||
| } |
There was a problem hiding this comment.
export const http = {
get: async <Response = unknown>(url: string, options: AxiosRequestConfig = {}) => {
const response = await axiosInstance.get<Response>(url, options)
return response.data
},
post: async <Request = unknown, Response = unknown>(url: string, data?: Request, options?: AxiosRequestConfig) => {
const response = await axiosInstance.post<Response>(url, data, options)
return response.data
},
put: async <Request = unknown, Response = unknown>(url: string, data?: Request) => {
const response = await axiosInstance.put<Response>(url, data)
return response.data
},
patch: async <Request = unknown, Response = unknown>(url: string, data?: Request) => {
const response = await axiosInstance.patch<Response>(url, data)
return response.data
},
delete: async <Response = unknown>(url: string) => {
const response = await axiosInstance.delete<Response>(url)
return response.data
},
download: async <Response = Blob>(url: string) => {
const response = await axiosInstance.get<Response>(url, { responseType: "blob" })
return response.data
},
}저도 api 메서드들을 객체로 관리하는 형태를 갱장히 좋아해요 제가 프로젝트 시작할 때마다 가져다 쓰는 형태의 http 객체가 있는데 소개해드리고싶어서 한번 적어놓고 가겠습니다🙇♂️
JunilHwang
left a comment
There was a problem hiding this comment.
안녕하세요, 현지수 님! 5주차 과제 잘 진행해주셨네요. 고생하셨습니다 👍🏻👍🏻
현재 남기는 코멘트는 GPT-5-mini model을 사용하여 AI가 남기는 피드백입니다 🤖
과제 피드백과는 별개이므로 참고해주세요!
1. 🏗️ FSD 아키텍처
💡 개념 정의
Feature-Sliced Design(FSD)은 기능 단위로 관심사를 분리하고 레이어 간 의존성 방향(app→pages→widgets→features→entities→shared)을 지키며 각 slice는 index를 통한 Public API만 외부에 노출하는 아키텍처 패턴입니다.
⚡ 중요성
올바른 FSD 적용은 요구사항 변화(새로운 feature, 도메인 변경, 아키텍처 전환) 시 변경 범위를 최소화하고 모듈 재사용성을 높여 팀 확장과 유지보수성에 직접적인 영향을 줍니다.
📊 현재 상황 분석
긍정적: 전반적인 폴더 구조가 FSD 철학을 따르고, 도메인별 응집성(entities)과 사용자행동 중심적 features 분리가 잘 되어 있음. 개선점: entities 레이어에 훅(useQuery)을 포함시킨 파일이 존재하여 'entities는 순수 API(비훅) 제공 → features에서 훅·쿼리 키 관리'라는 이상적 흐름과 일부 충돌합니다. 또한 각 slice의 Public API(index.ts)가 일부 존재하지만, 몇몇 places에서 상대경로/직접 경로로 내부 구현을 참조하거나 imports 패턴 일관성이 혼재돼 있어(예: shared/hooks/useModal import 경로들이 다양) 레이어 경계가 약화될 위험이 있습니다.
📝 상세 피드백
PR은 Feature-Sliced Design(FSD)을 의도적으로 적용해 entities/features/widgets/shared/pages/app 계층을 분리한 점이 명확합니다. entities에 순수 도메인 타입/API/유틸을 두고 features에서 비즈니스 결합(데이터 조합, UX 흐름)을 담당한 구조는 FSD의 핵심 원칙에 부합합니다. 다만 일부 세부에서 의존성 방향, Public API 노출, 레이어 책임의 경계에서 개선 여지가 있습니다. 예를 들어 entities/*/api 디렉터리는 useQuery 훅을 직접 내보내고 있는데(entities/post/api/listPostApi.ts 등), FSD 권장사항은 entities는 순수한 CRUD 함수(비훅)를 제공하고 features에서 훅을 래핑하는 것입니다. 이 PR에서는 features 레이어가 entities API를 활용해 조합 로직(fetchPostsWithAuthors 등)을 구현한 점은 적절하지만, entities가 이미 useQuery 훅을 노출하는 경우 features에서 중복으로 훅을 만드는 형태(또는 반대로)로 혼재될 위험이 있습니다.
❌ 현재 구조 (AS-IS)
// AS-IS: entities에서 useQuery를 직접 제공 (src/entities/post/api/listPostApi.ts)
export const usePostsQuery = (params) => {
return useQuery({
queryKey: ["posts", params],
queryFn: () => fetchPosts(params),
})
}
// 이 경우 features도 useQuery 래핑을 하거나 entities 훅을 사용해도 되지만, 책임이 섞일 수 있음.✅ 권장 구조 (TO-BE)
// TO-BE: entities는 순수한 API 함수만 제공
// entities/post/api/listPostApi.ts
export const fetchPosts = async (params) => { return api.get(`/posts?${qs}`) }
// features에서 TanStack Query 훅을 정의
// features/post-management/list/api/postsQueries.ts
export const usePostsQuery = (params) => useQuery({ queryKey: queryKeys.posts(params), queryFn: () => fetchPosts(params) })
// 이 방식은 entities가 UI/쿼리 라이브러리에 독립적이어서 재사용성과 이식성이 높음.🔄 변경 시나리오별 영향도
- UI 라이브러리 교체: shared/ui 컴포넌트만 수정하면 되는가? 현재 shared/ui 컴포넌트로 추상화가 잘 되어 있어 대부분의 view 변경은 shared 계층 수정으로 해결되지만, 일부 features가 직접 외부 컴포넌트(예: lucide-react 아이콘 사용, 클래스네임 하드코딩)를 포함하고 있어 수정 범위가 늘어날 수 있음.
- 모노레포 전환: 각 slice를 패키지로 분리할 때 entities에 비즈니스 종속성이 남아있지 않은가? entities가 비훅과 비-UI만 제공한다면 패키지화가 쉬움. 현재 useQuery를 entities에 둔 경우 분리 시 쿼리 의존성을 재배치해야 함.
- 도메인 변경(entities 타입 변경): entities의 타입/모델 변경이 features로 전파되는 범위는 제한적인가? features는 entities의 타입과 API 응답 형식에 의존하므로 entities API의 안정적 Public API가 있으면 변경 범위 최소화 가능.
🚀 개선 단계
- 1단계: 단기(1-2일): entities/*/api에서 useQuery 훅을 제거하거나 비훅(순수 fetchX)만 남기고, features에서 훅을 래핑하도록 리팩토링(파일 10~15개 예상 수정).
- 2단계: 중기(2-4일): 각 slice에 index.ts(또는 public API) 파일을 일관되게 추가해 외부가 내부 구현을 직접 참조하지 못하도록 강제(예: export * from './api' 패턴).
- 3단계: 장기(1주): CI에서 레이어 의존성 검사(예: eslint rule 또는 custom script)를 추가해 허용된 경로(app→pages→widgets→features→entities→shared) 위반을 자동으로 감지.
2. 🔄 TanStack Query
💡 개념 정의
TanStack Query는 서버 상태를 선언적으로 관리하는 라이브러리로, 쿼리 키(queryKey)로 캐싱과 갱신을 제어하고 useQuery/useMutation의 옵션으로 재시도, staleTime, garbage collection 등을 설정합니다.
⚡ 중요성
일관된 쿼리 키와 API/쿼리 레이어 분리는 API 변경, 새로운 데이터 소스 추가, 에러 처리 전략 변경 시 수정 범위를 낮춰 줍니다. 특히 대규모 코드베이스에서는 쿼리 키 팩토리와 중앙 API 계층이 변화에 대한 유연성을 높입니다.
📊 현재 상황 분석
긍정적: optimistic update 적용(댓글 생성/좋아요/수정/삭제), QueryClient 전역 설정 적용. 개선점: queryKeys 추상화 없음 → 변경 시 대규모 수정을 유발할 수 있음(예: queryKey 네이밍 또는 파라미터 형태 변경). 또한 entities에서 useQuery를 제공하는 곳이 있어 쿼리 책임이 분산될 수 있음.
📝 상세 피드백
TanStack Query 사용은 전반적으로 체계적이며 캐싱, staleTime, optimistic update 패턴이 적용된 부분(예: queryClient 설정, useCreateComment의 setQueryData, useLikeComment의 optimistic update)도 확인됩니다. 그러나 쿼리 키 설계와 쿼리 키 팩토리(queryKeys) 부재, entities와 features 간 'API 함수 vs 훅' 역할 분리가 혼재되어 있어 유지보수성이 다소 저하될 수 있습니다. 또한 일부 쿼리에서 queryKey에 객체(예: ["posts", params])를 직접 넣고 있어 직렬화 문제나 불필요한 refetch를 초래할 수 있습니다.
❌ 현재 구조 (AS-IS)
// AS-IS: 개별 훅에서 객체를 그대로 queryKey로 사용
useQuery({ queryKey: ["posts", params], queryFn: () => fetchPosts(params) })
// 문제: params 객체 참조가 바뀌면 키 식별성이 달라져 불필요한 캐시 미스 발생✅ 권장 구조 (TO-BE)
// TO-BE: 체계화된 queryKeys 팩토리와 분리된 API
export const queryKeys = { posts: (p) => ['posts', p.limit, p.skip, p.sortBy || 'none', p.sortOrder || 'asc'] }
useQuery({ queryKey: queryKeys.posts(params), queryFn: () => fetchPosts(params) })
// 또한 entities는 fetchPosts만 제공하고 features가 훅을 담당🔄 변경 시나리오별 영향도
- API 엔드포인트 변경: endpoints가 바뀌면 fetch 함수만 수정하면 되나, 쿼리 키가 params 객체 형태로 중복 사용되면 파라미터 변경 시 키를 참조하는 모든 훅이 영향을 받음(약 15~25 훅 예상).
- 새로운 데이터 소스 추가(예: WebSocket 실시간): 현재 캐싱 전략(staleTime 5분)은 실시간 요구에 맞지 않을 수 있으며, 실시간 동기화는 쿼리 무효화(invalidateQueries) 또는 setQueryData로 보완해야 함.
- 에러 처리 방식 변경(예: 글로벌 토스트/관찰기 도입): mutations.defaultOptions.onError는 설정되어 있으나, 일관된 에러 메시지/성공 토스트 정책을 위해 onSuccess/onError 패턴 표준화를 권장.
🚀 개선 단계
- 1단계: 단기(1일): queryKeys 팩토리 파일(shared/api/queryKeys.ts)을 도입하고 대표적인 쿼리 훅(posts, posts-with-authors, users, comments 등)을 쿼리키 팩토리로 교체(10~15 훅 수정 예상).
- 2단계: 중기(1-2일): entities는 순수 fetch 함수만 유지, features에서 useQuery 래퍼를 작성해 비즈니스 시나리오(조합 로직, enabled 조건)을 중앙화.
- 3단계: 장기(1주): 쿼리 에러/성공 처리 표준 정의(토스트/로그/트래킹) 및 Mutation의 낙관 업데이트 패턴을 문서화해 팀 컨벤션으로 고정.
3. 🎯 응집도 (Cohesion)
💡 개념 정의
응집도(Cohesion)는 모듈 내부의 요소들이 얼마나 서로 관련되어 있는지를 나타내며, 높은 응집도는 '함께 변경되는 것들이 한 곳에 모여 있음'을 의미합니다.
⚡ 중요성
높은 응집도는 변경 범위를 줄이고, 디버깅 및 온보딩을 빠르게 합니다. 모듈을 패키지로 분리할 때도 단일 책임의 모듈은 쉽게 떼어낼 수 있습니다.
📊 현재 상황 분석
현재 구조는 응집도가 높아 보이며, 기능 추가(예: 댓글 통계 필드 추가)는 해당 feature와 entities 내에서 대부분 해결 가능. 위험: features 내 공통(ui) 컴포넌트가 전역 shared로 승격되어야 할 경우, 참조를 교체해야 하며 파일 이동이 필요할 수 있음(약 3~8 파일 수정).
📝 상세 피드백
응집도는 대체로 양호합니다. 도메인별로 entities/post, entities/comment, features/post-management, features/comment-management 등으로 관련 파일이 잘 모여있습니다. 또한 UI(Features/shared)와 도메인(types, api, lib)가 분리되어 있어 한 기능 변경 시 수정이 집중되는 편입니다. 다만, 일부 공통 유틸(shared/lib/text.ts, client.ts 등)이 features에서 직접 사용되는 방식이 일관적이지만, features 내부에 domain-specific shared 디렉토리(post-management/shared/ui)가 존재해 전역 shared와 도메인 shared의 경계가 모호할 때가 있습니다.
❌ 현재 구조 (AS-IS)
// AS-IS: 댓글 관련 로직은 features/comment-management 및 entities/comment에 집중
- entities/comment/types
- features/comment-management/create/api
- features/comment-management/list/ui
// 기능 변경 시 관련 파일이 한 군데에 모여 있음✅ 권장 구조 (TO-BE)
// TO-BE: 응집도를 더욱 높이기 위해 domain 폴더 아래에 모든 comment 관련 파일을 모아 패키지화 가능
src/domains/comment/{api,model,ui,utils,index.ts}
// 이렇게 하면 comment 도메인 하나만 패키지로 분리해도 유지보수가 쉬움🔄 변경 시나리오별 영향도
- 댓글 기능에 '수정 이력' 추가: entities/comment/types와 features/comment-management/*에만 집중된 변경으로 처리 가능(예상 파일 수정 5~8개).
- 공통 컴포넌트 재활용: PostFormDialog를 여러 도메인에서 사용하도록 전역 shared로 옮길 경우, import 경로 교체 및 props 일반화 작업 필요(예상 5~10파일).
🚀 개선 단계
- 1단계: 단기(반나절): features/*/shared와 shared/ui의 경계(전역 vs 도메인 전용)를 README나 주석으로 명시.
- 2단계: 중기(1-2일): 도메인별 index.ts(혹은 barrel file)를 점검해 응집된 엔트리포인트를 제공하여 리팩토링 시 이동 비용을 줄임.
- 3단계: 장기(1주): 도메인별로 패키지화(모노레포) 시나리오를 시뮬레이션해 분리 가능한 경계를 문서화.
4. 🔗 결합도 (Coupling)
💡 개념 정의
결합도(Coupling)는 모듈 간 의존성의 강도를 말하며, 낮은 결합도는 한 모듈 변경 시 다른 모듈에 미치는 영향을 최소화합니다.
⚡ 중요성
낮은 결합도는 기술 스택 교체나 아키텍처 전환(예: axios→fetch, redux→zustand)시 영향을 최소화하여 적응성을 높입니다.
📊 현재 상황 분석
장점: api 추상화가 존재해 클라이언트 라이브러리 교체 시 영향도를 줄일 수 있음. 단점: queryKey/쿼리 훅 책임 분산, 일부 훅/컴포넌트는 구체적 shapes(예: response.data 구조)을 가정해 결합도가 높아짐.
📝 상세 피드백
결합도는 전반적으로 낮추려는 시도가 보입니다(Jotai atoms로 상태를 세분화, entities/feature 분리). 그러나 구체적 구현(예: shared/lib/api client 및 api helpers, features가 직접 api 클라이언트 호출)을 보면 몇몇 지점에서 구체적 구현에 의존하고 있어 라이브러리 교체 시 파급 범위가 존재합니다. 특히 api 클라이언트(api.get/post 등)를 래핑한 shared/lib/client.ts에서 axios 인스턴스와 반환값 처리 방식(자동 .data 반환)을 정의하고 있어 HTTP 라이브러리 변경 시 shared/lib/client.ts만 수정하면 되는 점은 좋지만, 일부 컴포넌트/훅에서 직접 axios 관련 타입/행위를 기대하는 코드가 있으면 추가 수정이 필요합니다.
❌ 현재 구조 (AS-IS)
// AS-IS: shared api 추상화는 있으나 entities 훅들이 이를 직접 사용
// src/entities/post/api/listPostApi.ts
return api.get<PostsResponse>(`/posts?${queryParams.toString()}`)
// features 쪽에서 api.get의 반환형(자동 .data 처리)에 의존하고 있을 가능성 있음.✅ 권장 구조 (TO-BE)
// TO-BE: 더 낮은 결합을 위해 API 추상화 인터페이스 정의
// shared/lib/http.ts
export type HttpClient = { get: <T>(url:string)=>Promise<T>; post:... }
export const httpClient: HttpClient = { get: (url) => fetchWrapper(url) }
// entities는 HttpClient에 의존(의존성 주입 가능)
export const makePostApi = (client: HttpClient) => ({ fetchPosts: (p) => client.get(`/posts?...`) })
// 이렇게 하면 런타임에 client만 교체하여 전역 변경을 피할 수 있음.🔄 변경 시나리오별 영향도
- HTTP 클라이언트 변경(axios → fetch): shared/lib/client.ts만 수정하면 대부분 커버 가능하지만, axios 응답의 자동 .data 처리 로직에 의존한 코드가 있다면(현재 api.get returns data) 그 부분을 점검해야 함. 예상 수정 파일: shared/lib/client.ts (1), entities/ 및 features에서 api 결과를 직접 구조 분해한 곳(약 5~12 파일) 검토 필요.
- 상태관리 변경(jotai→zustand): atoms에 의존하는 훅들이 직접 Jotai API를 사용하므로 전환 작업은 각 model 파일(특히 features/*/model/queryParams.ts 등) 리팩토링 필요(예상 5~10 파일).
🚀 개선 단계
- 1단계: 단기(1일): shared/lib/client.ts에 HttpClient 인터페이스(타입)를 문서화하고 entities가 이 인터페이스를 사용하도록 정리.
- 2단계: 중기(2-3일): 핵심 모델(atom) 추상화를 통해 상태관리 라이브러리 전환 시 영향을 받는 파일 목록(약 5~10개)을 파악하고 테스트 시나리오 작성.
- 3단계: 장기(1~2주): 의존성 주입 패턴 도입(특히 entities API 팩토리화)을 통해 런타임에서 구현 교체가 가능하도록 리팩토링.
5. 🧹 Shared 레이어 순수성
💡 개념 정의
Shared 레이어 순수성은 shared 코드가 특정 도메인(비즈니스 로직)에 의존하지 않고 범용적으로 재사용될 수 있는지를 의미합니다.
⚡ 중요성
shared가 도메인 독립적으로 유지되면 새로운 프로젝트나 다른 도메인으로의 이식성이 좋아지고 디자인 시스템 변경 시 영향 범위를 크게 줄일 수 있습니다.
📊 현재 상황 분석
대체로 순수성 만족. 개선 여지: shared/lib/client의 baseURL이 환경에 종속적이므로(현재 import.meta.env.MODE 사용) 다른 프로젝트로 옮길 때 환경 설정 분리가 필요할 수 있음. 또한 일부 UI 컴포넌트가 Tailwind 클래스에 의존하므로 디자인 시스템 교체 시 shared/ui 내부만 수정하면 충분하지만, features에서 Tailwind 클래스에 하드코딩한 부분(예: className strings in PostTableRow tag badge)도 존재하여 전역 스타일 전환 비용이 늘어날 가능성 있음.
📝 상세 피드백
shared 레이어는 UI 컴포넌트(Button, Dialog, Table 등), hooks(useModal), lib(client, queryClient, text)로 잘 분리되어 있습니다. shared가 전역 도메인에 의존하지 않도록 설계된 점(예: Button, Input 등 범용 컴포넌트 제공)은 재사용성 측면에서 우수합니다. 다만 일부 shared 함수/컴포넌트가 도메인 로직을 참조하는 흔적은 보이지 않으나 features에서 shared/lib/text.splitTextForHighlight를 도메인 검색 하이라이트용으로 사용하고 있어 범용성에 적절합니다.
❌ 현재 구조 (AS-IS)
// AS-IS: shared Button 사용
import { Button } from '../../../../shared/ui/button'
// 하지만 features에서 badge 스타일을 하드코딩함
className={`px-1 ... ${selectedTag === tag ? 'text-white bg-blue-500' : 'text-blue-800 bg-blue-100'}`}✅ 권장 구조 (TO-BE)
// TO-BE: badge도 shared/ui에서 추상화
// shared/ui/Badge.tsx
export const Badge = ({ variant }) => <span className={variantClass(variant)}>{children}</span>
// features use
<Badge variant={selectedTag===tag ? 'active' : 'default'}>{tag}</Badge>🔄 변경 시나리오별 영향도
- 디자인 시스템(Material UI → Chakra): shared/ui 컴포넌트만 교체하면 대부분 화면 변경을 커버 가능(예상 수정 20
30 개의 feature files 대신 shared/ui 내부12 파일).1015 파일 수정). 하지만 features에서 직접 사용하는 Tailwind 클래스(버튼이 아닌 개별 badge, layout 등)를 점검해야 함(약 5 - 새 프로젝트에서 shared 레이어 재사용: 환경(axios baseURL, theme)에 대한 설정 분리 및 문서화 필요.
🚀 개선 단계
- 1단계: 단기(0.5-1일): shared/ui 컴포넌트 목록과 의존성을 문서화하여 어떤 컴포넌트가 전역 변경에 영향을 받는지 파악.
- 2단계: 중기(1-2일): 반복적으로 사용되는 스타일(태그 배지 등)을 shared/ui로 추출해 features에서의 스타일 하드코딩 제거.
- 3단계: 장기(1주): shared 구성요소의 Prop 표준화 및 디자인 토큰(색상, spacing)을 도입해 스타일 라이브러리 교체를 쉽게 만듦.
6. 📐 추상화 레벨
💡 개념 정의
추상화는 복잡한 구현 세부사항을 숨기고, 재사용 가능한 인터페이스(함수/훅)를 통해 핵심 개념만 노출하는 정도를 의미합니다.
⚡ 중요성
적절한 추상화는 요구사항 변화(새 API 파라미터, 인증 방식 변경 등)를 최소한의 변경으로 수용하게 해 줍니다.
📊 현재 상황 분석
현 구조는 도메인 추상화(타입/헬퍼 분리)는 잘 되어 있음. 기술 추상화(HTTP client, queryKey, 훅 책임)는 부분적이며 개선시 유지보수성이 더 좋아짐.
📝 상세 피드백
추상화 수준은 적절한 편입니다. entities는 타입과 helper(createMockPost 등)를 제공하고 features는 비즈니스 조합 로직을 담당합니다. 그러나 일부 장소에서 비즈니스 로직과 기술적 세부사항(예: useQuery 훅, axios 응답 전제)이 섞여 있어 더 높은 수준의 추상화로 개선 여지가 있습니다. 예를 들어 fetchPostsWithAuthors는 좋은 비즈니스 추상화지만, queryKey 설계와 쿼리 래핑(entities vs features)이 더 명확하면 재사용성이 높아집니다.
❌ 현재 구조 (AS-IS)
// AS-IS: fetchPostsWithAuthors (features에서 구현) - 장점: 엔드유저가 필요한 형태로 조합
const fetchPostsWithAuthors = async (params) => {
const [postsData, usersData] = await Promise.all([fetchPosts(params), fetchUsers({ limit: 0, select: 'username,image' })])
return postsData.posts.map(p => ({...p, author: usersData.users.find(u => u.id === p.userId)}))
}
// 단점: fetchPosts가 어떤 형태로 데이터를 반환하는지 내부 구현에 따라 영향을 받을 수 있음.✅ 권장 구조 (TO-BE)
// TO-BE: 명확한 추상화 계층
// entities/post/api.ts -> fetchPosts (http client만 사용)
// features/post-management/queries.ts -> usePostsWithAuthorsQuery: fetchPosts + data mapping + queryKeys 사용
// interface 분리로 내부 변경 최소화🔄 변경 시나리오별 영향도
- API 응답 래퍼 포맷 변경(예: {data:...}→직접 body): shared/lib/client.ts에서 응답 파싱 로직을 바꾸면 대부분의 fetch 코드에 영향을 주지 않음. 다만, 일부 코드가 response.data?.data 형태를 가정하면 추가 수정 필요.
- 인증 추가(OAuth): api client에 인증 토큰 주입을 추가하면 대부분의 호출은 변경 없이 동작하게 할 수 있으나, 직접 fetch를 사용한 구간은 수정 필요.
🚀 개선 단계
- 1단계: 단기(반나절): entities와 features간의 책임 문서를 간단히 작성(entities: 순수 API + 타입, features: 훅/조합/비즈니스 로직).
- 2단계: 중기(1-2일): http client 인터페이스(documented)를 도입하고, fetch 함수들이 이 인터페이스를 사용하도록 정리.
- 3단계: 장기(1주): queryKeys, API 팩토리, 훅 래핑 정책을 팀 스타일가이드로 확정.
7. 🧪 테스트 용이성
💡 개념 정의
테스트 용이성은 코드가 단위 테스트/통합 테스트/목킹을 쉽게 작성할 수 있도록 설계되어 있는 정도를 말합니다.
⚡ 중요성
테스트가 용이하면 요구사항 변경 시 회귀를 방지하고 리팩토링에 대한 신뢰도를 높여 변화 수용 능력을 키웁니다.
📊 현재 상황 분석
긍정적: 순수 함수와 훅이 분리되어 있어 단위 테스트가 수월. 개선점: 테스트용 mock client(예: httpClient mock)와 React Query의 QueryClientProvider를 활용한 테스트 헬퍼가 있으면 E2E/통합 테스트도 간편해짐.
📝 상세 피드백
코드는 단일 책임 원칙을 부분적으로 잘 따르고 있어 테스트 작성이 가능한 구조입니다. UI는 작은 컴포넌트(예: PostTableRow, CommentItem)로 쪼개져 있고, 비즈니스 로직과 사이드이펙트는 훅(예: usePostsUrlSync, usePostsQuery)과 mutation 훅에 분리되어 있습니다. 그러나 entities가 useQuery 훅을 제공하는 경우 훅 직접 테스트와 유닛 테스트의 경계가 애매해질 수 있습니다. 또한 현재 PR에는 테스트 코드가 포함되어 있지 않으므로 테스트 전략(유닛/통합)을 문서화하고 mocking 가이드가 필요합니다.
❌ 현재 구조 (AS-IS)
// AS-IS: splitTextForHighlight는 순수 함수로 단위 테스트 용이
// 테스트 작성: 입력 텍스트/쿼리 → parts, isMatch 결과 검증 가능✅ 권장 구조 (TO-BE)
// TO-BE: 테스트 헬퍼 추가
// test/utils/renderWithProviders.tsx -> QueryClientProvider + JotaiProvider 래핑
// mockHttpClient를 주입해 네트워크 종속성 제거🔄 변경 시나리오별 영향도
- 외부 API 연동(새 엔드포인트 추가): entities의 fetch 함수만 모킹하면 대부분의 features 테스트는 수정 없이 통과 가능해야 함. 현재는 entities에 useQuery가 섞여 있어 모킹 범위가 넓어질 수 있음.
- UI 컴포넌트 리팩토링: 단일 책임의 컴포넌트가 많아 스냅샷/유닛 테스트 작성이 용이.
🚀 개선 단계
- 1단계: 단기(반나절): 테스트 헬퍼(renderWithProviders)를 추가해 React Query와 Jotai 컨텍스트를 자동으로 제공.
- 2단계: 중기(1-2일): entities fetch 함수들에 대한 단위 테스트와 splitTextForHighlight 같은 유틸 테스트를 작성(각 1~2시간).
- 3단계: 장기(1주): 대표적인 integration 테스트(게시물 목록 로딩/검색/페이지네이션) 작성 및 CI에 통합.
8. ⚛️ 현대적 React 패턴
💡 개념 정의
현대적 React 패턴은 커스텀 훅, Suspense, Error Boundary, 선언적 데이터 로딩(React Query) 등을 활용해 관심사 분리와 예측 가능한 렌더링을 달성하는 패턴입니다.
⚡ 중요성
이 패턴들을 활용하면 로딩/에러 처리 정책을 중앙화하고 UI 컴포넌트를 더 단순하게 유지해 변화 대응이 쉬워집니다.
📊 현재 상황 분석
구현은 현대적 방향으로 잘 되어 있으나, Suspense/React.lazy와 ErrorBoundary를 도입하면 컴포넌트 내에서의 로딩/에러 조건 분기 코드를 줄일 수 있습니다. 또한 useModal 훅은 상태 캡슐화를 잘 하고 있어 재사용성이 높음.
📝 상세 피드백
현대적 React 패턴 적용이 잘 되어 있습니다. 컴포넌트의 관심사 분리, 커스텀 훅(usePostsUrlSync, useModal), React Query 사용, Dialog 컴포넌트(박스형 포탈) 등은 장점입니다. 다만 Suspense와 Error Boundary 사용은 보이지 않습니다. useQuery의 enabled, onMutate, onError 등을 적절히 사용하고 있으므로 선언적 패턴은 잘 지켜지고 있습니다. 향후 Suspense 기반 데이터 로딩과 ErrorBoundary 도입을 고려하면 로딩/오류 UX를 더 선언적으로 관리할 수 있습니다.
❌ 현재 구조 (AS-IS)
// AS-IS: 개별 컴포넌트에서 isLoading/isError 체크
if (isLoading) return <div>로딩 중...</div>
if (isError) return <div>에러</div>
// TO-BE: Suspense + ErrorBoundary 상위에서 처리
<Suspense fallback={<Skeleton/>}>
<ErrorBoundary fallback={<ErrorFallback/>}>
<PostManagerList />
</ErrorBoundary>
</Suspense>✅ 권장 구조 (TO-BE)
위 예시처럼 Suspense/ErrorBoundary를 도입하면 컴포넌트 내부 분기가 줄어듭니다.🔄 변경 시나리오별 영향도
- 로딩 UX 정책 변경(글로벌 Skeleton으로 통합): Suspense 도입 시 각 useQuery를 Suspense-compatible 방식으로 변경하거나 useSuspenseQuery 래퍼를 추가해야 함(중간 수준 변경).
- 에러 처리 변경(글로벌 ErrorBoundary로 전환): 컴포넌트 단위의 try/catch 콘솔로그를 ErrorBoundary와 통합해 단순화 가능.
🚀 개선 단계
- 1단계: 단기(반나절): ErrorBoundary 컴포넌트와 간단한 ErrorFallback 구현하여 주요 페이지에 적용.
- 2단계: 중기(1-2일): Suspense 호환 useQuery 래퍼(useSuspenseQuery)를 도입해 일부 핵심 화면에 적용 (검색·목록 등).
- 3단계: 장기(1주): 전사적 로딩/에러 UX 정책을 문서화하고 컴포넌트 템플릿을 제공.
9. 🔧 확장성
💡 개념 정의
확장성은 새로운 기능 추가 또는 비기능적 요구사항 변화 시 코드 변경량이 적고, 구조적으로 쉽게 확장 가능한 정도입니다.
⚡ 중요성
높은 확장성은 빠른 프로토타이핑과 안정적인 런칭을 가능케 하며, 새로운 사업요구에 따른 코드 수정 비용을 줄입니다.
📊 현재 상황 분석
새 필터/정렬 추가는 주로 features/post-management/list 내부에서 해결 가능. 실시간 기능(웹소켓) 추가 시 QueryClient.setQueryData와 invalidateQueries로 통합할 수 있음. 다만 queryKeys의 통일과 entities의 순수 API 유지가 보완되면 더 유연함.
📝 상세 피드백
확장성 측면에서 PR은 긍정적입니다. entities/features로 역할을 잘 분리해 새로운 기능(다국어, A/B 테스트, 실시간 등) 추가 시 영향 범위를 제한할 수 있습니다. Jotai atoms로 세분화된 상태 설계와 URL 동기화(usePostsUrlSync)는 기능 확장(예: 추가 필터, 공유 가능한 URL)에 유리합니다. 한계는 쿼리 키 및 API 추상화 미비로 새로운 데이터 소스 추가 시 일부 훅을 고쳐야 할 가능성입니다.
❌ 현재 구조 (AS-IS)
// AS-IS: FilterSelect, SearchInput 등은 props 기반으로 확장 가능
// 새로운 필터 추가 시 FiltersContainer/FilterSelect에 옵션 추가만으로 대응 가능✅ 권장 구조 (TO-BE)
// TO-BE: 확장성 극대화를 위해 queryKeys/feature flag 추상화 도입
// features 로직은 flags/experiments에 따라 분기하도록 설계🔄 변경 시나리오별 영향도
- 다국어(i18n) 도입: UI 문구가 shared/ui 컴포넌트와 features 컴포넌트에 분산되어 있으므로 i18n 헬퍼(shared/i18n)와 번들화를 통해 전역 교체가 가능(예상 변경 20~30 위치).
- A/B 테스트 통합: feature-flag나 AB 라이브러리를 app/providers 또는 features 레벨에서 주입하면 컴포넌트 변경을 최소화할 수 있음.
🚀 개선 단계
- 1단계: 단기(0.5-1일): queryKeys 및 API 추상화 문서화로 외부 변경 시 영향 범위를 명확히 표기.
- 2단계: 중기(2-3일): i18n 초기 구조(예: react-i18next)와 전역 토큰화를 적용해 UI 문자열의 변경 비용을 낮춤.
- 3단계: 장기(1-2주): feature-flag 인프라(간단한 AB 추적 모듈) 도입해 실험/프로덕션 전환을 유연하게 만듦.
10. 📏 코드 일관성
💡 개념 정의
코드 일관성은 네이밍, 파일구조, import/export 패턴, 코드 스타일(들여쓰기/따옴표 등)이 프로젝트 전반에 걸쳐 일관된 정도를 말합니다.
⚡ 중요성
일관된 컨벤션은 신규 개발자 온보딩을 빠르게 하고, 코드 리뷰 시 스타일 관련 논쟁을 줄여 생산성을 향상시킵니다.
📊 현재 상황 분석
파일명과 훅 네이밍은 대체로 규칙을 따르나, export 패턴이 mix(default vs named) 없이 대부분 named로 보이는 점은 좋음. 문제: 일부 경로에서 .tsx 확장자를 임포트(./App.tsx)하거나 상대경로와 절대경로 혼용이 존재해 빌드/도구 체인에서 경고가 날 수 있음.
📝 상세 피드백
전반적인 파일 네이밍/구조는 비교적 일관적입니다(components와 UI 컴포넌트는 PascalCase, 훅은 use* 패턴, features/entities/shared 폴더 분리). 다만 import 경로 표현(절대 vs 상대)과 일부 index export 패턴이 혼재되어 있고, 파일 확장자(.tsx vs .ts) 표기가 일부 다르며 클래스네임/스타일 패턴(Tailwind usage)이 곳곳에 하드코딩되어 있어 통일할 여지가 있습니다.
❌ 현재 구조 (AS-IS)
// AS-IS: 일부 파일에서 확장자를 명시한 import
import App from './App.tsx'
// 권장: 확장자 없이 통일
import App from './App'✅ 권장 구조 (TO-BE)
// TO-BE: 일관된 export/import 규칙 예시
// components는 named export, 훅은 use* 규칙, 파일은 PascalCase
import { Button } from 'shared/ui'
import { usePostsUrlSync } from 'features/post-management/list/hooks'🔄 변경 시나리오별 영향도
- 다른 팀과 코드베이스 병합: import/export 컨벤션과 path alias 정책(절대경로 설정)을 문서화하고 ESLint/Prettier 규칙을 정하면 충돌을 줄일 수 있음.
- 자동화 도구(코드 분석) 적용: ESLint 규칙이 없으면 경로/네이밍 혼선이 커질 수 있음.
🚀 개선 단계
- 1단계: 단기(반나절): ESLint/Prettier와 경로 alias(tsconfig.paths) 규칙을 프로젝트에 적용해 자동 교정(확장자, 따옴표, import 순서 등).
- 2단계: 중기(1-2일): 코드베이스 전체에 규칙을 적용하고 CI에서 lint 체크를 실패 조건으로 설정.
- 3단계: 장기(1주): 컨벤션 문서화(README에 네이밍/파일구조 가이드) 및 새 파일 템플릿 제공.
🎯 일관성 체크포인트
파일명 규칙
- 대부분 PascalCase를 따르지만 일부 legacy 파일 참조에서 확장자(.tsx) 직접 표기 존재
- widgets/header/ui와 widgets/footer/ui의 경로 변경은 적절하나 이전 components/ 경로와 혼용 가능
Import/Export 패턴
- index barrel 파일이 잘 사용되지만 일부 모듈은 개별 파일에서 직접 export(useQuery 훅이 entities에 노출되는 경우)
- main.tsx, App.tsx에서의 import 스타일(따옴표 사용 등) 약간의 혼재
변수명 규칙
- 대부분 camelCase 사용. 특이점 없음 발견(일관성 양호).
코드 스타일
- 들여쓰기/따옴표 등은 대체로 일관적이지만 일부 파일은 single vs double quote 혼재(예: main.tsx 변경 전후)
11. 🗃️ 상태 관리
💡 개념 정의
데이터 흐름 및 상태 관리는 전역 상태(client state), 서버 상태(server state), 로컬 UI 상태를 적절히 분리해 예측가능성과 디버깅 용이성을 확보하는 것을 의미합니다.
⚡ 중요성
명확한 상태 분리는 Props Drilling을 줄이고, 성능 최적화(구독 범위 최소화) 및 테스트 용이성에 기여합니다.
📊 현재 상황 분석
설계는 매우 우수하며, atom을 세분화하여 불필요한 리렌더를 줄이려는 의도가 명확합니다. 한 가지 주의사항은 상태 초기화(예: resetPostsFiltersAtom)가 PostsManagerPage의 cleanup에서 호출되는 방식인데, Providers 레이어에서 앱 전체의 초기화 정책을 관리하는 것이 더 안전할 수 있습니다.
📝 상세 피드백
클라이언트 상태 관리는 Jotai로 깔끔하게 분리되어 있습니다. postsQueryStringAtom 같은 파생 상태와 setPostsPageAtom 액션 패턴은 URL 동기화와 페이징 계산 로직을 중앙화하여 Props Drilling을 효과적으로 제거했습니다. 서버 상태는 React Query로 분리되어 있어 책임 분리가 잘 이루어져 있습니다. 개선 여지는 전역 상태(예: 인증, 현재 사용자) 관리 위치를 전역 app/providers 또는 entities/user로 명확히 규정하면 더 좋습니다.
❌ 현재 구조 (AS-IS)
// AS-IS: postsQueryStringAtom 파생 상태로 URL 생성
export const postsQueryStringAtom = atom((get) => { /* limit, skip, search 등 조합 */ })
// usePostsUrlSync에서 atom -> URL, URL -> atom 양방향 동기화 구현✅ 권장 구조 (TO-BE)
// TO-BE: 더 명확한 경계로 auth/feature 상태를 app/providers에 둠
// AppProviders에서 AuthProvider, QueryClientProvider, ThemeProvider 등을 결합해 전역 상태 초기화 책임을 중앙화🔄 변경 시나리오별 영향도
- 오프라인 모드 추가: React Query의 캐시 및 Jotai의 로컬 상태를 조합해 오프라인 상태를 제공할 수 있으나, 캐시 persist 전략(react-query persist)을 추가해야 함.
- 실시간 동기화: WebSocket 이벤트로 QueryClient.setQueriesData를 호출해 서버 상태 동기화를 수행하면 됨.
🚀 개선 단계
- 1단계: 단기(반나절): 전역 상태(인증/설정)에 대한 위치 가이드(Providers vs features)를 문서화.
- 2단계: 중기(1-2일): React Query 캐시 persistence(오프라인/새로고침 보존) 검토 및 필요시 적용.
- 3단계: 장기(1주): 실시간 동기화 전략(웹소켓 이벤트 → setQueryData/invalidations) 템플릿 마련.
🤔 질문과 답변
질문란에 구체적 질문이 없어 일반적으로 리뷰받고 싶을 만한 포인트들을 정리합니다.
- "entities와 features의 API/훅 책임 분리는 어떻게 하는 게 좋을까요?"
- 권장: entities는 순수 fetch 함수와 타입/헬퍼만 제공하고, features는 해당 fetch를 useQuery로 래핑해 queryKey 정책과 enabled 조건, 비즈니스 데이터 조합을 담당하세요. 이렇게 하면 entities는 네트워크/데이터 소스 변경에 강해지고 features는 UI 요구사항 변화에 강해집니다.
- "queryKey 관리는 어떻게 통일할까요?"
- 권장: shared/lib/queryKeys.ts 같은 파일에 queryKeys 팩토리를 만들고 모든 useQuery에서 이를 사용하세요. 객체 대신 직렬화 가능한 primitive 조합(예: ['posts', limit, skip, sortBy, sortOrder])을 사용하면 캐시 식별이 안정적입니다.
- "디자인 시스템을 바꿔야 한다면 어디부터 손대야 할까요?"
- 권장: shared/ui 내부 컴포넌트를 먼저 교체하고, features에서 하드코딩된 클래스나 직접 사용한 외부 컴포넌트(icons 등)를 점검하세요. 예상으로 shared/ui만 바꾼다면 10
15파일 수정으로 대부분 커버되지만 features 내 하드코딩 스타일이 많으면 추가로 512파일을 더 수정해야 합니다.
- 권장: shared/ui 내부 컴포넌트를 먼저 교체하고, features에서 하드코딩된 클래스나 직접 사용한 외부 컴포넌트(icons 등)를 점검하세요. 예상으로 shared/ui만 바꾼다면 10
- "테스트를 시작하려면 어떤 파일부터 테스트하면 좋을까요?"
- 권장: splitTextForHighlight 같은 순수 유틸 → atoms와 usePostsUrlSync 같은 로직 훅 → 핵심 UI 컴포넌트(PostTableRow, CommentItem) 순으로 작성하세요. React Query와 Jotai 컨텍스트를 제공하는 renderWithProviders 헬퍼를 먼저 만들면 테스트 작성 속도가 빨라집니다.
🎯 셀프 회고 & 제안
작성하신 자기 회고(과제 체크포인트와 폴더구조 설명)는 교육 목적과 과제 목표에 충실하며, 특히 FSD의 레이어 구분과 Jotai를 통한 상태 분리는 과제 의도에 잘 부합합니다. 인사이트 중심 피드백은 다음과 같습니다:
- '작은 단위의 상태(atom) 설계'에 신경쓴 점: postsSearchQueryAtom, postsSelectedTagAtom 등으로 상태를 세분화한 접근은 성능(구독 범위 축소)과 유지보수성 모두에 도움이 됩니다. 추가 질문: 이 atom들을 향후 전역 상태(예: 사용자 설정)와 결합할 때 이름 충돌과 경계 관리를 어떻게 할 계획인가요?
- 'features에서의 데이터 조합' 선택: features에서 posts와 users를 병렬로 불러와 조합한 설계는 UI에 필요한 형태를 제공하는 좋은 선택입니다. 이어서 생각해볼 점: 만약 다른 화면에서도 동일한 posts-with-author가 필요하면 이 조합 훅을 얼마나 범용적으로 만들지(파라미터화) 고려해보면 좋습니다.
- 'shared 추상화' 관점: shared/ui 컴포넌트들을 잘 만든 편이나, features 내부에서 스타일을 직접 다루는 곳이 산발적으로 존재합니다. 계속 발전시키려면 디자인 토큰/Badge 같은 작은 UI 요소부터 shared로 이관해 보세요.
추가 질문들(스스로 더 고민해볼 거리):
- entities 쪽 훅(useQuery) 노출을 유지할지 아니면 순수 API로 되돌릴지 언제/어떻게 결정할 것인가?
- queryKeys를 단일 소스로 옮겼을 때 얻는 이득(변경 시 파일 수 감소)을 측정해 볼 수 있는가? (작업 전후로 영향을 받는 훅 수를 카운트해 보세요)
- Jotai 기반 상태를 다른 상태관리로 옮겨야 한다면(예: 요구사항으로 zustand 도입), 어떤 파일/패턴이 가장 먼저 리팩토링 대상이 될 것인가?
이 회고를 바탕으로 다음 단계에서 실제로 간단한 리팩토링(entities에서 fetch만 남기기, queryKeys 추가)를 시도해보면 학습 효과가 극대화될 것입니다.
추가 논의가 필요한 부분이 있다면 언제든 코멘트로 남겨주세요!
코드 리뷰를 통해 더 나은 아키텍처로 발전해 나가는 과정이 즐거웠습니다. 🚀
이 피드백이 도움이 되었다면 👍 를 눌러주세요!
There was a problem hiding this comment.
2-3 과제도 수고하셨습니다
확실히 지수님 관심사 분리를 굉장히 잘하시네요 따로 거슬리는 부분도 없엇고 속도도 빠르게 잘해주셔서 잘 해주신 것 같습니다.
중간 중간 다른분들이 코드리뷰 해준 것도 너무 좋은 정보들이라 저에게도 도움이 됐던 부분들이네요
mutationKey: ["comments", "delete"]처럼 리소스+액션으로 통일해주는 부분들로 Devtools에서 보거나 쿼리 무효화할 때도 가독성이 좋아지고, 팀 내 컨벤션 차원에서도 더 명확하게 관리할수 있또록 작성해주면 좋을것 같습니다.
더 자세한 부분들은 준일 코치님의 GPT가 잘 남겨준것 같아서 그걸 참고하시면 될것같아요
덕분에 저도 공부가 됐어요 감사합니다~
수고하셨습니다!
| const queryClient = useQueryClient() | ||
|
|
||
| return useMutation({ | ||
| mutationKey: ["createComment"], |
There was a problem hiding this comment.
@ckdwns9121 mutationKey 약간 주석 같은 느낌 아닌가요???
| const queryClient = useQueryClient() | ||
|
|
||
| return useMutation({ | ||
| mutationKey: ["likeComment"], |
There was a problem hiding this comment.
mutationKey: ["comments", "like"] 처럼 리소스+액션으로 맞추면 Devtools 가독성이 좋아요.
| mutationFn: ({ id, likes }: { id: number; likes: number; postId: number }) => { | ||
| return isNewlyCreatedComment(id) | ||
| ? Promise.resolve(createMockComment({ id, likes })) | ||
| : likeCommentApi(id, likes) | ||
| }, |
| const handleCreate = useCallback( | ||
| (data: { body: string; postId: number }) => { | ||
| createComment( | ||
| { | ||
| body: data.body, | ||
| postId: data.postId, | ||
| userId: 1, // TODO: 실제 사용자 ID | ||
| }, | ||
| { | ||
| onSuccess: () => { | ||
| handleClose() | ||
| }, | ||
| onError: (error) => { | ||
| console.error("댓글 생성 오류:", error) | ||
| }, | ||
| }, | ||
| ) | ||
| }, | ||
| [createComment], | ||
| ) |
There was a problem hiding this comment.
어차피 과제라서 할수 있는 한 최대한 해보는 것도 의미 있다고 생각이 드네요
| export * from "./CommentFormDialog" | ||
| export * from "./CommentControlPanel" |
There was a problem hiding this comment.
features안에서 사용되는 shared 가 아닐까요? 추상화는 항상 진짜 잘해주시네요
| export const api = { | ||
| get: <T>(url: string): Promise<T> => apiClient.get(url), | ||
| post: <T>(url: string, data?: unknown): Promise<T> => apiClient.post(url, data), | ||
| put: <T>(url: string, data?: unknown): Promise<T> => apiClient.put(url, data), | ||
| patch: <T>(url: string, data?: unknown): Promise<T> => apiClient.patch(url, data), | ||
| delete: <T>(url: string): Promise<T> => apiClient.delete(url), | ||
| } |
과제 체크포인트
기본과제
목표 : 전역상태관리를 이용한 적절한 분리와 계층에 대한 이해를 통한 FSD 폴더 구조 적용하기
체크포인트
심화과제
목표: 서버상태관리 도구인 TanstackQuery를 이용하여 비동기코드를 선언적인 함수형 프로그래밍으로 작성하기
체크포인트
최종과제
과제 셀프회고
https://hyunzsu.github.io/front_6th_chapter2-3/?limit=10&skip=0
이번 과제를 통해 이전에 비해 새롭게 알게 된 점이 있다면 적어주세요.
FSD 개념
본인이 과제를 하면서 가장 애쓰려고 노력했던 부분은 무엇인가요?
1. FSD 아키텍처 구현
전체 폴더 구조
1. Features 레이어 - 사용자 행동별 분리
전체 구조
각 도메인별 내부 구조:
create/- 새로운 데이터를 생성하는 행동list/- 데이터를 조회하고 표시하는 행동interactions/- 데이터와 상호작용하는 행동 (좋아요, 공유 등)update/- 기존 데이터를 수정/삭제하는 행동shared/- 해당 도메인 내에서 공통으로 사용되는 UI/로직Post Management 상세 구조
2. API 폴더 - Entities와 Features 분리 이유
과제에서 게시물 목록에 작성자 정보를 함께 표시해야 했습니다. API 응답 구조상 게시물과 사용자 정보가 분리되어 있어 데이터 조합이 필요했고, 데이터 조합 로직을 어디에 둘 것인지 고민이 많았습니다.
Entities 레이어: 순수한 도메인 API만
다른 feautres에서도 동일한 AP를 사용할 수 있도록 재사용성을 위해 단순하게 유지했습니다.
예를 들어 댓글 작성자 정보를 가져올 때도 동일한
fetchUsersAPI를 사용할 수 있고, 사용자 프로필 조회 기능에서도 같은 API를 활용할 수 있습니다.Features 레이어: 비즈니스 로직 처리
게시물 목록에서 작성자 이름과 이미지를 함께 표시하기 위해 features 레이어에 데이터 조합 로직을 위치시켰습니다. 이렇게 하면 UI 컴포넌트에서는 복잡한 데이터 처리 없이 필요한 정보를 바로 사용할 수 있습니다.
만약 나중에 게시물에 댓글 수 같은 추가 정보가 필요하다면, entities의 기본 API는 그대로 두고 features에서만 새로운 조합 로직을 추가하면 됩니다.
사용자 시나리오별 API 분리
게시물 목록에서 다양한 상황에 대응하기 위해 시나리오별로 API를 분리했습니다.
일반 목록, 검색 결과, 태그별 조회가 각각 독립적인 캐시 키를 가지므로 사용자가 검색하다가 태그를 클릭해도 기존 데이터가 유지됩니다. 또한 검색어가 없거나 태그가 선택되지 않았을 때는 해당 API를 호출하지 않도록 조건부 실행을 적용해서 불필요한 네트워크 요청을 방지했습니다.
3. Model 폴더 - 전역 상태 관리 설계
게시물 목록 화면에서 사용자는 검색어 입력, 태그 선택, 정렬 기준 변경, 페이지 이동 등 다양한 상호작용을 합니다. 이 모든 상태를 어떻게 관리하고, URL과 동기화할 것인지 고민이 많았습니다.
전역 상태 관리 라이브러리로 Jotai를 선택했습니다. Props Drilling을 제거할 수 있고, 각 컴포넌트가 필요한 상태만 구독해서 최소한의 리렌더링을 보장하기 때문입니다.
상태 설계: 도메인별 분리
상태를 사용자 입력과 페이지네이션으로 분리해서 각 atom이 명확한 역할을 갖도록 설계했습니다.
이렇게 세분화하면 컴포넌트에서 관심있는 상태만 구독할 수 있어 불필요한 리렌더링을 방지할 수 있습니다. 또 각 상태의 변경 영향 범위를 예측하기 쉬워져 유지보수하기 용이해집니다.
URL 자동 동기화
사용자의 모든 필터 상태를 URL 쿼리스트링으로 자동 변환하는 파생 상태를 구현했습니다. 이를 통해 사용자가 새로고침해도 현재 필터 상태가 유지되고, URL을 복사해서 공유하면 동일한 화면을 볼 수 있습니다.
Jotai의 액션 패턴 활용
페이지 변경 시 skip 값도 함께 업데이트해야 하는 로직을 액션 패턴으로 해결했습니다. 여러 상태를 동기화해서 변경하는 로직을 한 곳에 집중시켜서 일관성을 보장하고, 컴포넌트에서는 단순히 setPage(2) 호출만 하면 되도록 구성했습니다.
실제 사용 예시: 컴포넌트 간 상태 공유
4. shared 폴더 설계
게시물 생성과 수정 기능을 개발하면서 동일한 폼 UI가 필요했습니다. 이와 같은 공통 UI를 어디에 배치할지 고민이 많았습니다.
전역 shared vs 도메인 shared 구분
전역 shared에는 Button, Dialog, Input처럼 어떤 도메인에서든 사용할 수 있는 범용 컴포넌트를 배치했습니다. 반면 게시물 도메인의 shared에는 게시물의 생성/수정같은 특정 필드를 다루는 컴포넌트를 배치했습니다.
PostFormDialog - 생성/수정 모드 기반 공통 폼
하나의 컴포넌트에서 생성과 수정 두가지 모드를 모두 처리하도록 구현했습니다. mode prop으로 동작을 분기시키고, 각 모드에서 필요한 API 훅을 내부에서 호출합니다.
다양한 기능에서 공통 사용
2. Tanstack Query 낙관적 업데이트 구현
과제 더미 환경에서 DummyJSON API는 실제 데이터를 저장하지 않기 때문에 새로 생성된 댓글(ID > 340)을 수정하려 하면 404 에러가 발생했습니다.
구현 과정
1) mutationFn에서 직접 캐시 조작
Tanstack Query의 라이프사이클을 무시하고 에러 처리가 복잡해집니다.
2) onSuccess 패턴
사용자가 클릭하면 API에서 404 에러가 발생하고 onSuccess가 실행되지 않아 UI가 전혀 변화하지 않습니다.
3) onMutate 패턴
더미 환경에서 발생하는 404 에러 상황에서도 UI가 정상적으로 업데이트됩니다
onMutate 구현 코드
더미 환경
폴더 구조
구현 로직
아직은 막연하다거나 더 고민이 필요한 부분을 적어주세요.
entities와 features 경계선 설정:
현재 댓글과 게시물의 데이터 조합 로직을 features 레이어에 배치했지만, 이 결정이 완전히 명확한지 확신이 서지 않습니다. 예를 들어 fetchPostsWithAuthors는 두 개의 entities API를 조합하는 역할인데, 이것이 features에 있어야 하는지 아니면 별도의 레이어나 shared 영역에 있어야 하는지 고민됩니다. 특히 다른 기능에서도 동일한 조합이 필요할 때 중복 코드가 발생할 가능성이 있습니다.
이번에 배운 내용 중을 통해 앞으로 개발에 어떻게 적용해보고 싶은지 적어주세요.
이번에 "이 코드 어디에 둘까" 고민하는 시간이 크게 줄어든 경험을 바탕으로, 앞으로는 프로젝트 초기부터 명확한 폴더 구조를 설계해서 팀원들과 코드 배치에 대한 혼란을 최소화하고 싶습니다.
챕터 셀프회고
클린코드: 읽기 좋고 유지보수하기 좋은 코드 만들기
1주차 과제에서 더티코드를 처음 마주했을 때 정말 막막했습니다. 전체적인 코드 맥락을 파악하기 어려웠고, 어떤 상태가 어떤 함수와 연결되어 있는지 한눈에 들어오지 않았습니다. 함수 하나를 수정하려면 연관된 다른 코드들을 모두 추적해야 해서 시간이 오래 걸렸습니다.
클린코드의 가장 큰 가치는 유지보수 효율성이라고 생각해요. 코드를 처음 작성할 때는 모든 맥락을 기억하고 있지만, 시간이 지나면 작성자도 자신의 코드를 이해하기 어려워집니다. 읽기 좋은 코드는 한눈에 파악되는 직관적인 코드라고 생각합니다. 함수명과 변수명만 봐도 역할이 명확하고, 코드의 흐름이 자연스럽게 이해되어야 합니다. 또한 유지보수하기 쉬운 코드는 변경 범위가 예측 가능한 코드입니다. 한 부분을 수정했을 때 다른 부분에 미치는 영향을 쉽게 파악할 수 있어야 하죠.
결합도 낮추기: 디자인 패턴, 순수함수, 컴포넌트 분리, 전역상태 관리
처음 1주차 과제에서 거대한 단일 컴포넌트를 마주했을 때 정말 답답했습니다. 모든 상태와 로직이 한 곳에 얽혀있어서 어디서부터 손을 대야 할지 막막했고, 하나를 고치면 다른 부분이 망가지는 상황이 반복되었습니다. 디자인 패턴이라는 말은 더욱 어렵게만 느껴졌습니다.
이번 과제를 진행하면서 순수함수로 로직을 분리하기 시작했을 때, isNewlyCreatedComment나 createMockComment 같은 함수들을 독립적으로 만들어보니 테스트하기도 쉽고, 다른 곳에서 재사용하기도 편했습니다. 입력이 같으면 항상 같은 결과를 반환하는 순수함수의 특성 덕분에 예측 가능한 코드가 되었죠.
Jotai를 활용한 전역 상태 관리를 도입하면서 props drilling 없이도 필요한 상태만 구독할 수 있게 되어 컴포넌트들이 진정으로 독립적이 되었습니다. 한 컴포넌트를 수정해도 다른 컴포넌트에 영향을 주지 않는 구조가 만들어지면서, 코드 변경에 대한 두려움이 사라지고 자신감을 가지고 리팩토링할 수 있게 되었습니다.
응집도 높이기: 서버상태관리, 폴더 구조
TanStack Query를 본격적으로 활용하면서는 해방감을 느꼈습니다. 이전에는 서버에서 가져온 데이터를 로컬 상태로 관리하려니 동기화 문제, 로딩 상태 관리, 에러 처리 등이 모두 복잡하게 얽혀있었는데, TanStack Query로 서버 상태를 분리하니까 각각이 독립적으로 관리되었습니다. usePostsQuery, useCommentsQuery 같은 커스텀 훅들이 각자의 캐시를 가지고 알아서 데이터를 관리해주니까 컴포넌트는 정말 UI 로직에만 집중할 수 있게 되었죠. 더 이상 "데이터 어떻게 가져올까" 고민하지 않고 "어떻게 보여줄까"에만 집중할 수 있게 된 것이 가장 큰 변화였습니다.
리뷰 받고 싶은 내용이나 궁금한 것에 대한 질문
1. FSD 아키텍처 레이어 분리 기준
데이터 조합 로직 위치:
fetchPostsWithAuthors같은 복합 API를 features에 두었는데, 다른 기능에서도 같은 조합이 필요할 때 중복을 어떻게 해결해야 할까요? entities vs features 판단 기준이 궁금합니다.features 내 shared 폴더:
features/post-management/shared/ui/PostFormDialog.tsx에 생성/수정 공통 폼을 배치했습니다. 이렇게 특정 도메인 내에서만 공유되는 컴포넌트를 위해 features 안에 shared 폴더를 만드는 것이 FSD 표준에 부합하는지, 아니면 다른 구조가 더 적절한지 궁금합니다.2. React Query 캐시 키
쿼리 키 팩토리 패턴: 현재 쿼리 키가 하드코딩되어 있어서 오타나 불일치 위험이 있습니다. queryKeys.posts.withAuthors() 같은 쿼리 키 팩토리 패턴을 도입하는 것이 프로젝트 규모에 비해 과도한 엔지니어링인지, 아니면 초기부터 적용하는 것이 좋은지 의견을 듣고 싶습니다.
3. Jotai atom 분리 기준
atom 세분화: 현재 게시물 필터링 상태를 7개 atom으로 나누었습니다. 이렇게 세분화하면 컴포넌트가 필요한 상태만 구독해서 불필요한 리렌더링을 방지할 수 있지만, atom 개수가 많아져서 관리가 복잡합니다. 실무에서는 리렌더링 최적화와 코드 복잡성 사이에서 어떤 기준으로 판단하나요?
성능 vs 복잡성: 리렌더링 최적화를 위해 atom을 세분화했지만 관리 복잡도가 증가했습니다. 실무에서는 어떤 기준으로 균형을 잡는지 궁금해요!