Skip to content

[1팀 신희원] Chapter 2-3. 관심사 분리와 폴더구조🧦#40

Open
Amelia-Shin wants to merge 34 commits intohanghae-plus:mainfrom
Amelia-Shin:main
Open

[1팀 신희원] Chapter 2-3. 관심사 분리와 폴더구조🧦#40
Amelia-Shin wants to merge 34 commits intohanghae-plus:mainfrom
Amelia-Shin:main

Conversation

@Amelia-Shin
Copy link

@Amelia-Shin Amelia-Shin commented Aug 14, 2025

배포링크 : https://amelia-shin.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 폴더 구조를 봤을 때는 정말 복잡하고 어렵게 느껴졌습니다.
entities, features, widgets 같은 생소한 용어들이 많았고, 파일을 여러 폴더에 나누는 방식이 비효율적으로 보이기도 했습니다.

하지만 하나씩 이해해 나가면서 생각이 바뀌었습니다. 복잡해 보이는 구조가 오히려 코드를 더 쉽게 관리할 수 있게 도와준다는 것을 알게 되었습니다.
예를 들어, 댓글 기능을 수정할 때 예전에는 여러 파일을 뒤져야 했지만, 이제는 features/comment 폴더만 보면 됩니다.
처음에는 폴더가 많아 헷갈렸지만, 실제로는 기능을 예측하고 빠르게 찾을 수 있도록 도와주는 구조였던 거죠.

과제 초반에는 entity, feature, widget의 개념을 명확히 알지 못해 어려움이 있었습니다. 발제 당일 팀 회의 때도 제대로 설명하지 못했었습니다.
그래서 과제를 시작하기 전, 다른 사람들이 FSD 구조를 어떻게 나눴는지 찾아보고 팀원들에게 왜 그렇게 분리했는지 많이 물어봤습니다.
대화를 나누면서 개념을 조금씩 이해하게 되었고, “엔티티는 정보, 피처는 행동”이라는 정의가 기억에 남았습니다. (휘린님의 “엔티티는 장보고, 피처는 맥주”라는 비유도 재밌어서 인상 깊었습니다. 😊)

이번 과제는 막막하게 시작했지만, 팀원들과 적극적으로 소통하면서 많은 도움을 받았습니다.
아직 부족한 부분이 많지만, 매주 사람들과 이야기하며 배우는 과정이 재미있고 값진 경험이라고 느꼈습니다.
앞으로는 저도 의견을 나누고 누군가에게 도움을 줄 수 있는 사람이 되고 싶습니다.

이번 과제를 통해 이전에 비해 새롭게 알게 된 점이 있다면 적어주세요.

FSD

FSD를 알기 전에 컴포넌트, 훅, API 등등 각 폴더를 지정하여 UI (=widget) 에 합치면 된다고 생각

  1. 계층별 역할 분담
src/
├── entities/     # 핵심 데이터 (사용자, 게시물, 댓글)
├── features/     # 사용자 행동 (추가, 수정, 삭제)
├── widgets/      # 재사용 가능한 큰 컴포넌트
└── shared/       # 공통으로 쓰는 것들
  1. 각 폴더의 정확한 역할
    entities: 데이터의 "무엇"을 정의 (타입, API 호출)
    features: 사용자가 "어떻게" 하는지 정의 (이벤트 처리)
    widgets: 화면에 "어떻게 보여줄지" 정의 (UI 조합)
    shared: "여러 곳에서 공통으로" 쓰는 것들

  2. 실제 코드에서의 적용

// entities/user/model/types.ts - 사용자 데이터 구조 정의
export interface User {
  id: number
  username: string
}

// features/comment/add-comments/hooks.ts - 댓글 추가 기능
export const useAddComment = () => { /* 댓글 추가 로직 */ }

// widgets/comment-list/CommentList.tsx - 댓글 목록 화면
export const CommentList = () => { /* 댓글 목록 UI */ }
  1. 내가 생각했을 때 FSD의 장점
  • 코드를 찾기 쉬워진다. (댓글 관련 코드는 features/comment에 다 있음)
  • 유지보수성 향상 : 한 기능을 바꿀 때 한 곳만 보면 된다.

TanStack Query

  1. useQuery - 데이터 가져오기
// 기존 방식 (복잡함)
const [posts, setPosts] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)

useEffect(() => {
  setLoading(true)
  fetch('/api/posts')
    .then(res => res.json())
    .then(data => setPosts(data))
    .catch(err => setError(err))
    .finally(() => setLoading(false))
}, [])

// TanStack Query 방식 (간단함)
const { data: posts, isLoading, error } = useQuery({
  queryKey: ['posts'],
  queryFn: () => fetch('/api/posts').then(res => res.json())
})
  1. useMutation - 데이터 변경하기
// 이전 방식 (복잡함)
const addPost = async (postData) => {
  try {
    setLoading(true)
    const response = await fetch('/api/posts', {
      method: 'POST',
      body: JSON.stringify(postData)
    })
    const newPost = await response.json()
    setPosts([...posts, newPost])
  } catch (error) {
    setError(error)
  } finally {
    setLoading(false)
  }
}

// TanStack Query 방식 (간단함)
const addPostMutation = useMutation({
  mutationFn: (postData) => fetch('/api/posts', {
    method: 'POST',
    body: JSON.stringify(postData)
  }).then(res => res.json())
})

// 사용할 때
addPostMutation.mutate(postData)
  1. 쿼리 키의 마법
// 쿼리 키로 데이터를 구분하고 관리
['posts']                    // 모든 게시물
['posts', 1]                // ID가 1인 게시물
['comments', 5]         // 게시물 5번의 댓글들
['user', 123]               // ID가 123인 사용자

// 이 키들로 캐시 관리, 자동 갱신, 데이터 무효화 등을 자동으로 처리
  1. 자동 캐싱과 동기화
    캐싱: 한 번 가져온 데이터를 자동으로 저장
    동기화: 같은 데이터를 여러 곳에서 사용할 때 자동으로 동기화
    백그라운드 업데이트: 사용자가 모르게 최신 데이터로 갱신

낙관적 업데이트

  • 낙관적 업데이트란?
    낙관적: "아마 성공할 거야"라고 믿고 미리 화면을 바꿈
    즉시성: 서버 응답을 기다리지 않고 바로 화면 업데이트

기존: 제출 → 로딩 화면 → 성공/실패 메시지
변경: 제출 → 즉시 화면에 댓글 표시 → 백그라운드에서 저장

queryClient.setQueryData: 캐시를 직접 조작하는 방법

본인이 과제를 하면서 가장 애쓰려고 노력했던 부분은 무엇인가요?

  • Zustand를 활용한 다이얼로그 상태 관리
    다이얼로그 상태를 전역으로 관리하는 시스템을 구축했습니다.
// dialogStore.ts - 6개 다이얼로그의 상태 관리
interface DialogState {
  showAddDialog: boolean
  showEditDialog: boolean
  showAddCommentDialog: boolean
  showEditCommentDialog: boolean
  showPostDetailDialog: boolean
  showUserModal: boolean
  
  // 각각의 열기/닫기 함수
  openAddDialog: () => void
  closeAddDialog: () => void
  // ... 기타 함수들
}

처음에는 각 컴포넌트마다 개별적으로 다이얼로그 상태를 관리했습니다.
그런데 여러 다이얼로그가 동시에 열릴 수 있고, 상태가 꼬이는 문제가 발생했습니다.

// 이전 방식 - 각 컴포넌트마다 개별 상태 관리
const [showAddDialog, setShowAddDialog] = useState(false)
const [showEditDialog, setShowEditDialog] = useState(false)
const [showCommentDialog, setShowCommentDialog] = useState(false)

// 문제: 다이얼로그 간 상태가 독립적으로 관리되어 동기화 문제 발생

구체적인 문제점들

  • 동시 열림 문제: 게시물 추가와 댓글 추가 다이얼로그가 동시에 열림
  • 상태 불일치: 다이얼로그를 닫았는데 다른 곳에서 여전히 열려있다고 인식
  • 코드 중복: 각 다이얼로그마다 비슷한 열기/닫기 로직 반복

해결 과정

  1. Zustand store로 모든 다이얼로그 상태 통합
    모든 다이얼로그 상태를 하나의 store에서 관리하는 구조를 만들었습니다.

  2. 일관된 패턴으로 함수 구현

export const useDialogStore = create<DialogState>((set) => ({
  // 초기 상태 - 모든 다이얼로그는 닫힌 상태
  showAddDialog: false,
  showEditDialog: false,
  showAddCommentDialog: false,
  showEditCommentDialog: false,
  showPostDetailDialog: false,
  showUserModal: false,

  // 열기 함수들 - 모두 동일한 패턴
  openAddDialog: () => set({ showAddDialog: true }),
  openEditDialog: () => set({ showEditDialog: true }),
  openAddCommentDialog: () => set({ showAddCommentDialog: true }),
  openEditCommentDialog: () => set({ showEditCommentDialog: true }),
  openPostDetailDialog: () => set({ showPostDetailDialog: true }),
  openUserModal: () => set({ showUserModal: true }),

  // 닫기 함수들 - 모두 동일한 패턴
  closeAddDialog: () => set({ showAddDialog: false }),
  closeEditDialog: () => set({ showEditDialog: false }),
  closeAddCommentDialog: () => set({ showAddCommentDialog: false }),
  closeEditCommentDialog: () => set({ showEditCommentDialog: false }),
  closePostDetailDialog: () => set({ showPostDetailDialog: false }),
  closeUserModal: () => set({ showUserModal: false }),

  // 핵심 해결책: 모든 다이얼로그를 한 번에 닫기
  closeAllDialogs: () => set({
    showAddDialog: false,
    showEditDialog: false,
    showAddCommentDialog: false,
    showEditCommentDialog: false,
    showPostDetailDialog: false,
    showUserModal: false,
  }),
}))
// src/pages/PostsManagerPage.tsx
const {
  showAddDialog,
  showEditDialog,
  showAddCommentDialog,
  showEditCommentDialog,
  showPostDetailDialog,
  showUserModal,
  openAddDialog,
  closeAddDialog,
  openEditDialog,
  closeEditDialog,
  openAddCommentDialog,
  closeAddCommentDialog,
  openEditCommentDialog,
  closeEditCommentDialog,
  openPostDetailDialog,
  closePostDetailDialog,
  openUserModal,
  closeUserModal,
} = useDialogStore()

// 모든 다이얼로그에서 동일한 패턴 사용
<Dialog 
  open={showAddDialog} 
  onOpenChange={(open) => open ? openAddDialog() : closeAddDialog()}
>
  <DialogContent>
    <DialogHeader>
      <DialogTitle>게시물 추가</DialogTitle>
    </DialogHeader>
    <AddPostForm />
  </DialogContent>
</Dialog>
  1. 컴포넌트에서 체계적으로 사용
// src/pages/PostsManagerPage.tsx
const {
  showAddDialog,
  showEditDialog,
  showAddCommentDialog,
  showEditCommentDialog,
  showPostDetailDialog,
  showUserModal,
  openAddDialog,
  closeAddDialog,
  openEditDialog,
  closeEditDialog,
  openAddCommentDialog,
  closeAddCommentDialog,
  openEditCommentDialog,
  closeEditCommentDialog,
  openPostDetailDialog,
  closePostDetailDialog,
  openUserModal,
  closeUserModal,
} = useDialogStore()

// 모든 다이얼로그에서 동일한 패턴 사용
<Dialog 
  open={showAddDialog} 
  onOpenChange={(open) => open ? openAddDialog() : closeAddDialog()}
>
  <DialogContent>
    <DialogHeader>
      <DialogTitle>게시물 추가</DialogTitle>
    </DialogHeader>
    <AddPostForm />
  </DialogContent>
</Dialog>
  • Props Drilling 문제 해결 시도 (하지만 완전히 해결하지 못함)
    가장 어려웠던 부분 중 하나는 Props Drilling을 최소화하려고 노력했지만 완전히 해결하지 못했다는 점입니다.

어려웠던 점 :

  • 상태 관리 복잡성: 댓글 관련 상태가 여러 컴포넌트에 분산
  • 함수 전달 체인: 이벤트 핸들러가 3-4단계를 거쳐 전달
  • 데이터 일관성: 같은 데이터가 여러 곳에서 중복 관리
  • 컴포넌트 결합도: 각 컴포넌트가 상위 컴포넌트의 구조에 의존

해결해보려고 시도한 방법:

// 1. Zustand store로 다이얼로그 상태는 분리했지만...
export const useDialogStore = create<DialogState>((set) => ({
  showAddCommentDialog: false,
  showEditCommentDialog: false,
  // ... 다이얼로그 열기/닫기 함수들
}))

// 2. 하지만 댓글 데이터와 함수들은 여전히 props로 전달해야 함
// 3. Context API 도입을 고려했지만 복잡성 증가 우려

아직은 막연하다거나 더 고민이 필요한 부분을 적어주세요.

  • Context API vs Zustand: 어떤 상태를 어디서 관리할지
  • 컴포넌트 분리 전략: 어느 수준까지 분리해야 하는지
  • 상태 공유 범위: 전역 상태와 지역 상태의 경계 설정
// 옵션 1: 댓글 관련 상태를 Zustand store로 이동
interface CommentStore {
  selectedComment: Comment | null
  newComment: CommentForm
  setSelectedComment: (comment: Comment | null) => void
  setNewComment: (comment: CommentForm) => void
}

// 옵션 2: Context API로 댓글 관련 상태 관리
const CommentContext = createContext<CommentContextType>()

// 옵션 3: 컴포넌트 합성(Composition) 패턴 사용
<CommentProvider>
  <CommentAddForm />
  <CommentEditForm />
</CommentProvider>

이번에 배운 내용 중을 통해 앞으로 개발에 어떻게 적용해보고 싶은지 적어주세요.

  • 유지보수하기 쉬운 코드 구조를 만들어가고 싶습니다!
낙관적 업데이트: 모든 사용자 액션에 즉시 피드백 제공
체계적 상태 관리: Zustand로 UI 상태, TanStack Query로 서버 상태 분리
일관된 코드 패턴: FSD 구조와 커스텀 훅으로 재사용 가능한 코드

챕터 셀프회고

클린코드와 아키테쳑 챕터 함께 하느라 고생 많으셨습니다!
지난 3주간의 여정을 돌이켜 볼 수 있도록 준비해보았습니다.
아래에 적힌 질문들은 추억(?)을 회상할 수 있도록 도와주려고 만든 질문이며, 꼭 질문에 대한 대답이 아니어도 좋으니 내가 느꼈던 인사이트들을 자유롭게 적어주세요.

클린코드: 읽기 좋고 유지보수하기 좋은 코드 만들기

  • 더티코드를 접했을 때 어떤 기분이었나요? ^^; 클린코드의 중요성, 읽기 좋은 코드란 무엇인지, 유지보수하기 쉬운 코드란 무엇인지에 대한 생각을 공유해주세요

4주차때 받은 더티코드 잊지 못할거 같습니다. ^^...
클린코드의 원칙은 실무에 있을 때도 많이 보았던 내용들이었습니다. 하지만 이게 실제로 잘 지켜지고 있는지 스스로 많이 생각했습니다. 관련해서 테오와 멘토링할 때, 위 내용에 대해서 이야기 했었는데 클린코드란 정답이 없고, 나 스스로가 아 잘짰다! 깔끔하다! 읽기 쉽다! 하고 만족하는게 바로 클린코드라고 생각합니다. 물론 자신감이 많이 붙기 위해서 다른 사람들의 코드를 많이 보고 제 기준에서 괜찮다고 생각한 부분들만 배우는게 중요한 것 같습니다.

결합도 낮추기: 디자인 패턴, 순수함수, 컴포넌트 분리, 전역상태 관리

  • 거대한 단일 컴포넌트를 봤을때의 느낌! 처음엔 막막했던 상태관리, 디자인 패턴이라는 말이 어렵게만 느껴졌던 시절, 순수함수로 분리하면서 "아하!"했던 순간, 컴포넌트가 독립적이 되어가는 과정에서의 깨달음을 들려주세요

처음 PostsManagerPage.tsx 파일을 봤을 때 모든 기능이 한 파일에 모여있어, "이걸 어떻게 정리해야 하지?"라는 걱정이 들었습니다. 마치 "정리되지 않은 창고"를 보는 것 같았습니다.

상태관리의 막막함:
"전역상태관리가 뭐야?": useState만으로도 충분한 줄 알았는데, 왜 더 복잡한 것을 써야 하지?
-> useState만으로는 부족하다는 것을 체감
게시물 목록 관리에서 겪은 문제:

// PostsManagerPage.tsx에서 기존 코드
const [posts, setPosts] = useState([])
const [selectedPost, setSelectedPost] = useState(null)
const [showAddDialog, setShowAddDialog] = useState(false)
const [showEditDialog, setShowEditDialog] = useState(false)
const [showCommentDialog, setShowCommentDialog] = useState(false)

// 문제: 이 상태들이 다른 컴포넌트에서도 필요함
// PostTable에서 selectedPost가 필요하고
// CommentForm에서 showCommentDialog가 필요하고
// AddPostForm에서 showAddDialog가 필요함

결합도 vs 응집도?": 이론은 알겠는데, 실제 코드에 어떻게 적용하지?

처음에는 PostsManager에 게시물, 댓글, 사용자 관련 상태와 함수가 전부 몰려 있어서 결합도가 매우 높았습니다.
어떤 기능을 수정하려면 다른 기능 코드까지 함께 신경 써야 했고, 작은 변경도 리스크가 컸습니다.

FSD 구조를 적용하면서 기능을 폴더 단위로 분리하고, 컴포넌트도 아래처럼 나눠봤습니다:

const PostsManager = () => {
  return (
    <div>
      <PostTable />      {/* 게시물만 담당 */}
      <CommentSection /> {/* 댓글만 담당 */}
      <UserInfo />       {/* 사용자만 담당 */}
    </div>
  )
}

이렇게 나누고 나니 각 컴포넌트가 자신의 역할에 집중하고, 독립적으로 동작하게 되었습니다.
특히 댓글 기능을 수정할 때는 CommentSection만 보면 되었고, 사용자 정보는 전혀 건드릴 필요가 없었습니다.
그 과정에서 결합도를 낮추고 응집도를 높이면 재사용성도 올라가고, 유지보수도 훨씬 쉬워진다는 걸 체감했습니다.

결국, “결합도는 낮게, 응집도는 높게”라는 말이 단순한 이론이 아니라, 실제 코드에서 큰 차이를 만드는 중요한 원칙이라는 걸 배웠습니다.

응집도 높이기: 서버상태관리, 폴더 구조

  • "이 코드는 대체 어디에 둬야 하지?"라고 고민했던 시간, FSD를 적용해보면서의 느낌, 나만의 구조를 만들어가는 과정, TanStack Query로 서버 상태를 분리하면서 느낀 해방감(?)등을 공유해주세요

폴더 구조를 짤 때 다른 사람들과 이야기를 해보면 모두 각자 다른 생각을 가지고 있었습니다. 누구는 API 분리할 떄 read 관련한 것은 entity에 있어야하고, create, update, delete 은 feature에 있어야한다. 또 다른 누구는 API는 결국엔 데이터고 값을 바꾸는게 없으니 entity에 있어야한다. 등등 의견이 다양했습니다. 이야기를 들으면서 저는 후자에 대해 동의를 했고 그렇게 다른 사람들의 의견을 들어보며 저만의 구조를 만들어 나갔습니다.

TanStack Query는 처음 접해보았고 어떻게 상태관리를 할 수 있다는 거지?라는 생각을 했습니다.
기존에는 useState와 useEffect로 복잡하게 상태를 관리했는데, 이게 정말 더 간단해질 수 있을까 의심스러웠습니다.
tanstack query를 사용하면서 코드가 간결해졌고 (useState, fetch 사용 X), 자동 캐싱이 정말 편리했습니다. (setState를 안해줘도됨 👍 )
낙관적 업데이트 구현도 훨씬 쉬워졌습니다. setQueryData로 간단하게 처리할 수 있다는 점에서 큰 매력을 느끼게 되었습니다.

리뷰 받고 싶은 내용이나 궁금한 것에 대한 질문

Q1. 현재 entities, features, widgets 구조로 작은 프로젝트는 잘 동작하지만, 기능이 많아질 때 어떻게 구조를 관리해야 할지 궁금합니다.
Q2. 실제 대기업/스타트업에서 FSD를 적용하는 사례가 있나요? 어떤 규모 이상의 프로젝트에서 FSD를 쓰는 게 좋나요?

Comment on lines +1 to +62
import { create } from "zustand"

interface DialogState {
showAddDialog: boolean
showEditDialog: boolean
showAddCommentDialog: boolean
showEditCommentDialog: boolean
showPostDetailDialog: boolean
showUserModal: boolean

// 다이얼로그 열기
openAddDialog: () => void
openEditDialog: () => void
openAddCommentDialog: () => void
openEditCommentDialog: () => void
openPostDetailDialog: () => void
openUserModal: () => void

// 다이얼로그 닫기
closeAddDialog: () => void
closeEditDialog: () => void
closeAddCommentDialog: () => void
closeEditCommentDialog: () => void
closePostDetailDialog: () => void
closeUserModal: () => void

// 모든 다이얼로그 닫기
closeAllDialogs: () => void
}

export const useDialogStore = create<DialogState>((set) => ({
showAddDialog: false,
showEditDialog: false,
showAddCommentDialog: false,
showEditCommentDialog: false,
showPostDetailDialog: false,
showUserModal: false,

openAddDialog: () => set({ showAddDialog: true }),
openEditDialog: () => set({ showEditDialog: true }),
openAddCommentDialog: () => set({ showAddCommentDialog: true }),
openEditCommentDialog: () => set({ showEditCommentDialog: true }),
openPostDetailDialog: () => set({ showPostDetailDialog: true }),
openUserModal: () => set({ showUserModal: true }),

closeAddDialog: () => set({ showAddDialog: false }),
closeEditDialog: () => set({ showEditDialog: false }),
closeAddCommentDialog: () => set({ showAddCommentDialog: false }),
closeEditCommentDialog: () => set({ showEditCommentDialog: false }),
closePostDetailDialog: () => set({ showPostDetailDialog: false }),
closeUserModal: () => set({ showUserModal: false }),

closeAllDialogs: () =>
set({
showAddDialog: false,
showEditDialog: false,
showAddCommentDialog: false,
showEditCommentDialog: false,
showPostDetailDialog: false,
showUserModal: false,
}),
}))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shared 레이어에 다이얼로그 관련 전역 스토어를 만들어주신 거 같아요.
일단 저의 생각을 말씀드리자면 shared는 범용으로 사용하는 서비스에 관여하지 않은 코드들의 모음이라고 생각합니다!

그런 시점으로 희원님 코드를 봤을 때

  // ...
  showAddDialog: false, // 게시글 추가를 위한 다이얼로그 상태같고..
  showEditDialog: false, // 이건 게시글 수정
  showAddCommentDialog: false, // 이건 댓글 추가...?
  showEditCommentDialog: false,
  showPostDetailDialog: false,
  showUserModal: false,
  // 나중에 다이얼로그가 추가될 때마다 상태도 불가피하게 늘어날 듯..
  // ...

위 코드들은 게시글 추가에 대한 다이얼로그, 댓글 수정을 위한 다이얼로그 등 비즈니스 성격을 띠는 상태가 보여있는 거 같아요
저렇게 되면 결국 추후에 어떠한 서비스에 다이얼로그가 추가된다면 굴비 엮는 거처럼 주렁주렁 매달리게 될 거 같습니다

저였다면 상태를 하나로 압축시키고 스토어 내부에서는 어떤 다이얼로그를 열지 모르겠지만 외부에서 원하는 다이얼로그를 열 수 있도록 동적으로 관리한다면 좀 더 범용적인 전역 상태가 되지 않을까요?!

import { create } from "zustand"

interface DialogState {
  currentDialog: string | null
  openDialog: (type: string) => void
  closeDialog: () => void
}

export const useDialogStore = create<DialogState>((set) => ({
  currentDialog: null,
  openDialog: (type) => set({ currentDialog: type }),
  closeDialog: () => set({ currentDialog: null }),
}))

boolean 값이 아닌 어떤 다이얼로그를 띄울 건지 문자열로 받는 방식입니다
일단 이 정도로 리팩토링하면 전보다 훨씬 코드량도 줄고 범용성도 올라갈 거 같아요
지금은 type을 그냥 string으로 선언하긴 했는데 이 부분을 좀 더 개선하면 정말 좋은 코드가 될 거 같습니다~

export function useDialog(type: string) {
  const currentDialog = useDialogStore((state) => state.currentDialog)
  const openDialog = useDialogStore((state) => state.openDialog)
  const closeDialog = useDialogStore((state) => state.closeDialog)

  return {
    isOpen: currentDialog === type,
    open: () => openDialog(type),
    close: closeDialog,
  }
}

그리고 이런 식으로 다이얼로그 헬퍼 커스텀 훅을 만들 수도 있을 거 같아요!

Comment on lines +31 to +154
/**
* API 클라이언트 클래스
* 기본 경로를 설정하고 HTTP 메서드를 제공합니다.
*/
export class ApiClient {
private basePath: string

constructor(basePath: string = "") {
this.basePath = basePath
}

/**
* 전체 URL 생성
* @param path - 상대 경로
* @returns 전체 URL
*/
private buildUrl(path: string): string {
// GitHub Pages 배포 환경 감지
const isGitHubPages = window.location.hostname === 'amelia-shin.github.io'

// GitHub Pages에서는 전체 URL 사용
if (isGitHubPages) {
return `https://dummyjson.com${this.basePath}${path}`
}

// 개발 환경에서는 상대 경로 사용
if (this.basePath.startsWith("/api")) {
return this.basePath + path
}
return "/api" + this.basePath + path
}

/**
* 요청 처리
* @param path - 요청 경로
* @param init - 요청 옵션
* @returns 요청 결과
*/
private async request<T>(path: string, init: RequestInit): Promise<T> {
const url = this.buildUrl(path)
const res = await fetch(url, init)

if (!res.ok) {
throw new Error(`API ${res.status} ${res.statusText}`)
}

if (res.status === 204) {
return undefined as unknown as T
}

return res.json() as Promise<T>
}

/**
* GET 요청
* @param path - 요청 경로
* @param opts - 요청 옵션
* @returns 요청 결과
*/
protected get<T>(path: string, opts: BaseOpts = {}): Promise<T> {
return this.request<T>(`${path}${queryString(opts.params)}`, {
method: "GET",
headers: opts.headers,
})
}

/**
* POST 요청
* @param path - 요청 경로
* @param body - 요청 본문
* @param opts - 요청 옵션
* @returns 요청 결과
*/
protected post<T, B = unknown>(path: string, body?: B, opts: BaseOpts = {}): Promise<T> {
return this.request<T>(path, {
method: "POST",
headers: { "Content-Type": "application/json", ...(opts.headers || {}) },
body: body == null ? undefined : JSON.stringify(body),
})
}

/**
* PUT 요청
* @param path - 요청 경로
* @param body - 요청 본문
* @param opts - 요청 옵션
* @returns 요청 결과
*/
protected put<T, B = unknown>(path: string, body?: B, opts: BaseOpts = {}): Promise<T> {
return this.request<T>(path, {
method: "PUT",
headers: { "Content-Type": "application/json", ...(opts.headers || {}) },
body: body == null ? undefined : JSON.stringify(body),
})
}

/**
* PATCH 요청
* @param path - 요청 경로
* @param body - 요청 본문
* @param opts - 요청 옵션
* @returns 요청 결과
*/
protected patch<T, B = unknown>(path: string, body?: B, opts: BaseOpts = {}): Promise<T> {
return this.request<T>(path, {
method: "PATCH",
headers: { "Content-Type": "application/json", ...(opts.headers || {}) },
body: body == null ? undefined : JSON.stringify(body),
})
}

/**
* DELETE 요청
* @param path - 요청 경로
* @param opts - 요청 옵션
* @returns 요청 결과
*/
protected delete<T>(path: string, opts: BaseOpts = {}): Promise<T> {
return this.request<T>(path, {
method: "DELETE",
headers: opts.headers,
})
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오~ APIClient 클래스로 묶어서 구현하셨군여 좋아 보입니다!
protected 키워드를 사용하신 이유가 궁금해요. 상속받은 자식 클래스에서만 접근할 수 있도록 설계하신 것 같은데, 어떤 의도로 이런 구조를 선택하셨나요?

저라면 이런 경우에 추상 클래스를 활용해서 추상 메서드를 통해 자식 클래스에서 더 유연하게 구현할 수 있도록 했을 것 같아요. 물론 이 정도 복잡도면 인터페이스 설계도 함께 고려해볼 만하다고 생각합니다.

개인적으로는 이 정도 로직이라면 클래스보다는 함수형 접근이 더 간결할 수도 있을 것 같다는 생각이 드네요. 하지만 클래스로 접근하신 방법도 정말 보기 좋습니다! 클래스 옹호파로서 객체지향적인 구조가 주는 명확함과 확장성은 장점이라고 생각합니다 ㅎㅎ

@@ -0,0 +1,154 @@
// GitHub Pages 배포 환경 감지
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'amelia-shin.github.io' 를 하드코딩해서 비교하는것 보다는 vite의 환경변수를 통해 분기처리를 해보는건 어떨까요?

const isProd = import.meta.env.MODE === "production"

이런식으로 env의 MODE를 활용하면 지금 로컬서버에서 돌아가는지 빌드된 프로덕션 모드인지 확인할 수 있습니다

const isProd = import.meta.env.MODE === "production"
const BASE_URL = isProd ? "https://dummyjson.com" : ""

이렇게 바꾸면 좀 더 정확한 코드가 되지않을까요?

Copy link
Member

@chan9yu chan9yu Aug 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

참고로 import.meta.env.MODE는 vite에서 제공해주는 내장 상수입니다!
https://ko.vite.dev/guide/env-and-mode
여러가지 값들이 많으니 나중에 사용해보시는것도 나쁘지 않을 거 같아요!

Comment on lines +16 to +29
/**
* 쿼리 스트링 생성
* @param params - 쿼리 파라미터
* @returns 쿼리 스트링
*/
function queryString(params?: Params): string {
if (!params) return ""
const urlSearchParams = new URLSearchParams()
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== null) urlSearchParams.set(k, String(v))
})
const queryString = urlSearchParams.toString()
return queryString ? `?${queryString}` : ""
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이건 따로 유틸함수로 분리하면 좋을 거 같습니다
범용으로 사용하기 좋은 순수한 함수로 만들 수 있을거 같아요~

Comment on lines +3 to +9
interface PaginationProps {
skip: number
limit: number
total: number
onSkipChange: (skip: number) => void
onLimitChange: (limit: number) => void
}
Copy link
Member

@chan9yu chan9yu Aug 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

페이지네이션을 위젯으로 분리한 건 완전 좋아보이네여
다만 위젯컴포넌트 내부를 보니 props로 관련데이터를 받고 있는데여

  // ...
  skip: number
  limit: number
  total: number
  // ...

이런 값들은 전역으로 관리해보는 건 어떨까요?!

Comment on lines +4 to +7
class CommentAPI extends ApiClient {
constructor() {
super("/comments")
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

클래스 상속 코드 좋네유 👍

import { useAddPost } from "./hooks"

export const AddPostForm = () => {
const { openAddDialog, closeAddDialog } = useDialogStore()

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

제안) 이런 느낌은 어떤가요?

  const { addDialog } = useDialogStore()
  addDialog.open();
  addDialog.close();

Copy link

@CreatiCoding CreatiCoding left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

6주차 고생 많으셨습니다~!

Copy link
Contributor

@JunilHwang JunilHwang left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

안녕하세요, 신희원 님! 5주차 과제 잘 진행해주셨네요. 고생하셨습니다 👍🏻👍🏻
현재 남기는 코멘트는 GPT-5-mini model을 사용하여 AI가 남기는 피드백입니다 🤖
과제 피드백과는 별개이므로 참고해주세요!

1. 🏗️ FSD 아키텍처

💡 개념 정의

Feature-Sliced Design(FSD)은 애플리케이션을 역할(entities, features, widgets, shared, pages, app) 단위로 나누어 관심사를 분리하고, 계층 간 의존성을 단방향으로 유지해 확장성과 유지보수성을 높이는 아키텍처 원칙입니다. 핵심 규칙은 하위 계층(entities 등)을 상위 계층(features 등)이 참조하되 반대는 허용하지 않는다는 점입니다.

⚡ 중요성

FSD를 제대로 지키면 새로운 기능 추가나 아키텍처 변경(모노레포, 마이크로프론트엔드 등) 시 수정 범위가 국소화되어 팀간 충돌을 줄이고 온보딩 시간을 단축합니다. 반대로 규칙이 흐려지면 변경이 전파되어 리팩토링 비용이 기하급수적으로 증가합니다.

📊 현재 상황 분석

AS-IS: 대부분의 핵심 규칙(entities 제공, features 조합)은 지켜지고 있으나, 다음과 같은 어긋남이 관찰됩니다.

  • features/del-comments/hooks.ts 등에서 entities/comment/api/CommentAPI를 직접 사용 (권장되는 패턴은 entities의 훅(useDeleteComment 등)을 사용하는 것)
  • 일부 훅/모듈이 서로 다른 위치에 동명으로 존재하거나(예: useUpdatePost가 entities와 features 양쪽에 정의되어 있음) public API가 산발적으로 사용됨
  • 바렐(index.ts) 미비로 인해 상대 경로가 복잡하고 계층 경계가 약해짐

이로 인해 기능 추가 시 실제 변경 파일 수가 불필요하게 증가할 소지가 큽니다.

📝 상세 피드백

FSD 개념은 전반적으로 잘 적용되었습니다. entities / features / widgets / shared 계층으로 분리되어 있고, 많은 파일이 해당 책임에 맞게 위치합니다. 다만 몇몇 구현에서 계층 경계(특히 "features가 entities의 public API 대신 entities 내부 구현 또는 shared 구현 세부사항에 직접 접근")가 흐려지는 부분과, slice별 Public API(바렐 인덱스)를 통한 외부 노출 규칙이 일관되게 지켜지지 않아 향후 아키텍처 변화 시 수정 범위가 넓어질 가능성이 있습니다.

❌ 현재 구조 (AS-IS)

// ❌ AS-IS: features에서 직접 API 호출
// src/features/comment/del-comments/hooks.ts
import CommentAPI from "../../../entities/comment/api/CommentAPI"

export const useDeleteCommentFeature = () => {
  const deleteCommentMutation = useMutation({
    mutationFn: (id: number) => CommentAPI.deleteComment(id),
  })

  // ...
}

// 문제: features가 entities의 API 구현에 직접 의존함

✅ 권장 구조 (TO-BE)

// ✅ TO-BE: features는 entities의 public 훅에 의존
// src/entities/comment/model/hooks.ts
export const useDeleteComment = () => useMutation({ mutationFn: (id) => CommentAPI.deleteComment(id) })

// src/features/comment/del-comments/hooks.ts
import { useDeleteComment } from "../../../entities/comment/model/hooks"

export const useDeleteCommentFeature = () => {
  const deleteCommentMutation = useDeleteComment()
  // 비즈니스 로직/뷰 관련 처리만 이 레이어에서 담당
}

🔄 변경 시나리오별 영향도

  1. UI 라이브러리 교체(예: Tailwind component -> MUI): shared/ui에 컴포넌트가 모여있어 변화는 주로 shared/ui 파일들(약 20~30개)에서 발생. 그러나 features나 widgets가 shared 내부 구조(예: props, className 사용 방식)에 의존적이면 더 많은 파일 수정 필요.
  2. entities API 경로 변경(예: /api -> /v2/api): ApiClient(buildUrl)와 entities//api/ 파일(약 6개)을 수정하면 되지만, features에서 직접 fetch/CommentAPI를 호출한 경우 추가 수정 필요(약 5~15개).
  3. 도메인 변경(댓글 스키마 변경): entities/comment/model/types.ts와 entities/comment/api/CommentAPI.ts, entities/comment/hooks.ts(총 3파일)만 수정하면 되는 이상적 시나리오. 반대로 댓글 로직이 PostsManagerPage 등 상위에 흩어져 있으면 10+ 파일 수정 필요

🚀 개선 단계

  • 1단계: 단기(1-2일): entities 폴더마다 index.ts(바렐)를 만들어 public API(타입, 훅, api 인스턴스)를 노출하도록 통일합니다. (예: entities/comment/index.ts -> export * from './model/hooks'; export * from './model/types')
  • 2단계: 단기(1-2일): features에서 entities의 public API 대신 내부 api/impl을 직접 참조하는 곳을 찾아 리팩토링 (예: features/* 에서 '../../../entities//api/' 직접 import 하는 곳 변경). 예상 수정 파일 수: 5~15개
  • 3단계: 중기(2-5일): ESLint module boundary 규칙(import/order, import/no-restricted-paths 등) 적용하여 계층(entities ← features ← widgets ← pages) 위반을 CI에서 차단
  • 4단계: 중기~장기(3-7일): 바렐을 통한 절대/앱별 alias(예: @entities/comment)를 도입하면 경로 안정성이 올라가고 리팩토링 비용이 감소
  • 5단계: 장기(1-2주): 엔티티/피처 책임에 대한 팀 컨벤션 문서화(FSD 룰북), PR 템플릿에 체크리스트 추가

2. 🔄 TanStack Query

💡 개념 정의

TanStack Query는 서버 상태(원격 데이터)를 관리하는 라이브러리로, 쿼리 키(queryKey)를 통해 캐시를 구분하고, useQuery/useMutation 훅으로 선언적 데이터 패칭·변경을 수행합니다. 최적의 사용법은 일관된 queryKey 팩토리, entities(또는 api) 레이어의 API 추상화, mutation의 optimistic update(onMutate), rollback(onError), invalidate(onSettled) 전략을 사용하는 것입니다.

⚡ 중요성

일관된 Query 패턴은 API 변경(엔드포인트 변경, 데이터 모델 변경), 새로운 데이터 소스 추가 시 수정 범위를 최소화하고 캐시 일관성 및 UX(로딩/갱신 전략)를 보장합니다. 반대로 키가 흩어지고 캐시 업데이트 코드가 중복되면 버그(오류 캐시, stale 데이터 재노출)가 발생하기 쉽습니다.

📊 현재 상황 분석

AS-IS: 기능은 동작하나 유지보수성 측면에서 일관성이 부족합니다. 예: entities/post/model/hooks.ts는 QUERY_KEYS를 사용하지만 다른 엔티티(댓글, 사용자)는 리터럴을 사용합니다. 또한 optimistic update 구현에서 onMutate/onError/onSettled 패턴 대신 수동 setQueryData/직접 조작을 혼용하고 있어 rollback 구현이 어렵습니다.

📝 상세 피드백

TanStack Query를 적극적으로 사용해 서버 상태를 관리하고 낙관적 업데이트 패턴을 적용하려는 시도가 잘 보입니다. queryClient를 사용해 캐시를 직접 업데이트하는 패턴(addPostToCache, setQueryData를 이용한 임시 댓글 추가 등)을 사용하고 있으나, 쿼리 키 관리와 일관된 패턴(팩토리 형태의 queryKeys) 도입이 미흡하고, 일부 훅에서는 invalidateQueries를 제거하고 직접 queryClient.getQueriesData로 탐색/수정하는 비일관적 접근이 있습니다. 또한 일부 features에서 entities 훅 대신 직접 CommentAPI를 사용해 useMutation을 만드는 등 패턴 중복이 관찰됩니다.

❌ 현재 구조 (AS-IS)

// ❌ AS-IS: 불일관한 쿼리 키와 직접적인 queryClient 탐색
// src/features/post/add-posts/hooks.ts (onSuccess)
const queries = queryClient.getQueriesData({ queryKey: ["posts"] })
queries.forEach(([queryKey, data]) => {
  if (data && typeof data === "object" && "posts" in data) {
    const currentData = data as Post
    const updatedData: Post = { ...currentData, posts: [createdPost, ...currentData.posts], total: currentData.total + 1 }
    queryClient.setQueryData(queryKey, updatedData)
  }
})

✅ 권장 구조 (TO-BE)

// ✅ TO-BE: 중앙화된 queryKeys와 onMutate 기반의 낙관적 업데이트
// shared/api/queryKeys.ts
export const queryKeys = {
  posts: () => ['posts'] as const,
  postsList: (params) => [...queryKeys.posts(), params] as const,
  comments: (postId) => ['comments', postId] as const,
}

// entities/post/model/hooks.ts (useCreatePost)
return useMutation(createPostApi, {
  onMutate: async (newPost) => {
    await queryClient.cancelQueries(queryKeys.posts())
    const previous = queryClient.getQueryData(queryKeys.posts())
    queryClient.setQueryData(queryKeys.posts(), (old: any) => ({ ...old, posts: [newPostOptimistic, ...old.posts] }))
    return { previous }
  },
  onError: (err, newPost, context) => {
    queryClient.setQueryData(queryKeys.posts(), context.previous)
  },
  onSettled: () => {
    queryClient.invalidateQueries(queryKeys.posts())
  }
})

🔄 변경 시나리오별 영향도

  1. API 엔드포인트 경로가 변경될 때: 중앙화된 queryKeys + entities API 사용 시 변경 범위는 entities/api/.ts 와 queryKeys 파일(1-3 파일). 현재 구조: 엔티티 훅과 features에서 API를 직접 호출하고 있으므로 추가로 features/ 파일 5~15개 수정 필요할 수 있음.
  2. 새로운 데이터 소스(예: websocket) 추가: 서버 상태와 로컬 UI 상태 경계를 지켜두면 useQuery → subscription 패턴으로 쉽게 확장 가능. 반대로 데이터 흐름이 분산되어 있으면 여러 컴포넌트 수정 필요.
  3. 에러 처리 방식(전역 에러 바운더리 vs 훅 수준) 변경: 일관된 onError 핸들러가 없으면 모든 mutation 훅(약 20개)에서 에러처리 패턴을 바꿔야 함

🚀 개선 단계

  • 1단계: 단기(1-2일): shared에 queryKeys 팩토리 파일(src/shared/api/queryKeys.ts) 생성. 모든 useQuery/useMutation에서 이 팩토리를 사용하도록 리팩토링(예상 수정 파일 수: 10~20).
  • 2단계: 단기(1-3일): entities 레이어에서 모든 CRUD 훅을 제공(useCreateComment, useDeleteComment 등). features에서는 이들 훅만 사용하도록 통일.
  • 3단계: 중기(2-4일): optimistic update는 onMutate/onError/onSettled 패턴으로 통일하고 rollback 구현. 현재 수동 setQueryData 사용부를 표준화.
  • 4단계: 중기(2-5일): mutation 훅의 공통 에러/성공 로직(토스트, 로깅)을 추상화하는 유틸(예: useMutationWithHandlers) 구현
  • 5단계: 장기: React Query DevTools와 CI에서 쿼리 키 일관성 검사(린트 룰) 도입

3. 🎯 응집도 (Cohesion)

💡 개념 정의

응집도(Cohesion)는 한 모듈 내 요소들이 얼마나 관련성을 가지고 함께 존재하는지를 나타냅니다. 높은 응집도는 관련 코드가 한 폴더/모듈에 모여 있어 변경 시 추적 범위가 작습니다.

⚡ 중요성

높은 응집도는 유지보수 비용을 줄이고, 기능 추가 시 수정해야 할 파일 수를 줄입니다. 또한 모듈을 패키지로 분리(패키지화)할 때 명확한 경계를 제공합니다.

📊 현재 상황 분석

AS-IS: 댓글, 게시물, 사용자 도메인은 개념적으로 응집되어 있으나 실제 페이지(PostsManagerPage)에 많은 orchestration 로직이 집중되어 있음. 이로 인해 해당 페이지를 수정할 때 여러 Slice를 동시에 건드릴 가능성이 있습니다.

📝 상세 피드백

대부분의 도메인(게시물, 댓글, 사용자)은 해당 폴더에 모여 있어 응집도가 높습니다(예: entities/post, features/post). 그러나 일부 책임 혼재(예: PostsManagerPage 내 과도한 로직에서 분리된 부분들이 여전히 남아 있음)와 같은 사례가 있어 특정 변경 시 관련 파일이 분산될 가능성이 있습니다.

❌ 현재 구조 (AS-IS)

// AS-IS: PostsManagerPage에 많은 책임 집중
// 검색, 페이징, 캐시 조작, 다이얼로그 제어, 댓글 낙관 업데이트 등 여러 관심사가 한 파일에 섞여 있음 (수백 라인)

✅ 권장 구조 (TO-BE)

// TO-BE: 역할별 분리
// pages/PostsManagerPage.tsx -> 페이지 오케스트레이션(상태 결합)
// widget/post-table/* -> 목록 렌더링
// features/post/get-posts/hooks.ts -> 데이터 조합(서버 데이터 + 사용자 데이터)
// widget/post-detail/* -> 상세 및 댓글 섹션
// 결과: 특정 기능(댓글 수정)은 features/comment + widget/comment-list에서만 수정

🔄 변경 시나리오별 영향도

  1. 새로운 댓글 필드(예: pinned)가 추가될 때: 이상적 구조에서는 entities/comment/* 타입 + api + hooks (약 3파일)만 수정하면 됨. 현재 구조에서 PostsManagerPage나 widget/post-detail 등 UI에서 직접 데이터 필드를 사용하는 곳이 있으면 추가로 3~8개 파일 수정이 필요할 수 있음.

🚀 개선 단계

  • 1단계: 단기(1일): PostsManagerPage의 책임을 줄이기 위해 현재 페이지에 남아있는 데이터 결합 로직(예: users 결합, 검색 처리)을 features/hooks 또는 widget으로 이동(예상 수정 파일 수: 3~6).
  • 2단계: 단기(1-2일): 페이지는 오직 '조합'만 담당하게 하고, 로직은 커스텀 훅(useGetPosts 등)에 위임
  • 3단계: 중기(2-4일): 기능 단위 폴더(예: features/comment) 내부에 모든 관련 UI/훅/로직이 모이도록 정리

4. 🔗 결합도 (Coupling)

💡 개념 정의

결합도(Coupling)는 모듈 간 의존성의 강도를 뜻합니다. 낮은 결합도는 모듈 교체나 기술 스택 변경(예: axios → fetch) 시 영향을 최소화합니다.

⚡ 중요성

낮은 결합도는 기술 스택 변경(HTTP 클라이언트, 상태관리 라이브러리) 또는 아키텍처 변화(모노레포 전환) 시 리스크와 수정 비용을 줄여줍니다.

📊 현재 상황 분석

AS-IS: 구현이 동작하지만 높은 결합 포인트가 존재합니다. 예: HTTP 클라이언트를 axios로 교체하거나 API 경로가 바뀔 때, entities/api 및 features에서 직접 API를 호출한 모든 파일을 찾아 수정해야 함.

정량적 예측: 현재 구조에서 HTTP 클라이언트 변경(예: fetch → axios)을 한다면

  • 이상적(entities API만 사용): entities//api/.ts 파일 6~8개만 수정
  • 현재(일부 features가 직접 API 호출): 추가로 features/, pages/, widgets/* 약 815개 파일이 수정 필요 — 총 1423개 파일

📝 상세 피드백

현재 구조는 비교적 모듈화되어 있지만 몇 가지 높은 결합도 포인트가 있습니다. 가장 눈에 띄는 문제는 features 레이어에서 entities의 세부 구현(특히 API 클래스)을 직접 호출하는 경우와, 일부 훅/함수의 시그니처 불일치로 인해 모듈 간 교체 비용이 커지는 점입니다.

❌ 현재 구조 (AS-IS)

// ❌ AS-IS: features에서 API 직접 호출
import CommentAPI from "../../../entities/comment/api/CommentAPI"
const deleteCommentMutation = useMutation({ mutationFn: (id) => CommentAPI.deleteComment(id) })

✅ 권장 구조 (TO-BE)

// ✅ TO-BE: features는 entities의 public 훅에 의존
import { useDeleteComment } from "../../../entities/comment"
const deleteCommentMutation = useDeleteComment()
// HTTP 클라이언트 변경 시 entities/api만 수정하면 됨

🔄 변경 시나리오별 영향도

  1. HTTP 클라이언트 변경(axios → fetch): entities/api/*만 추상화되어 있으면 68 파일 수정으로 충분. 현재 구조라면 추가로 features나 pages의 직접 호출부(약 815파일) 수정 필요.
  2. 상태관리 라이브러리 전환(Zustand → Context/Recoil): dialogStore 하나만 바꾸면 되는 설계이면 영향은 작음(10개 미만 참조). 현재는 props로 전달하는 부분이 남아있어 15~25개 파일을 건드려야 할 수 있음.

🚀 개선 단계

  • 1단계: 단기(1-2일): features에서 entities의 api 클래스를 직접 참조한 곳을 entities 훅을 사용하도록 리팩토링(수정 파일 추정 5~15개)
  • 2단계: 단기: 공통 ApiClient와 HTTP 레이어를 환경변수(VITE_API_BASE_URL) 기반으로 바꾸어 배포환경 의존성 제거
  • 3단계: 중기: 의존성 주입(Dependency Injection) 패턴을 도입하거나, entities의 API를 팩토리(예: createPostApi(httpClient))로 노출하여 테스트/교체 용이성 확보

5. 🧹 Shared 레이어 순수성

💡 개념 정의

Shared 레이어의 순수성은 shared 코드가 특정 도메인(게시물/댓글 등)에 의존하지 않고, 재사용 가능한 일반 유틸/컴포넌트만 포함하는지 여부를 말합니다.

⚡ 중요성

shared가 도메인에 의존하면 다른 프로젝트에서 재사용하거나 라이브러리로 분리하기 어렵고, 디자인 시스템 변경 시 영향을 받는 범위가 넓어집니다.

📊 현재 상황 분석

AS-IS: shared/ui는 범용적으로 잘 만들어져 있어 UI 라이브러리 교체 시 이 레이어만 수정하면 되는 이상적 구조에 가깝습니다. BUT: shared/lib/cache나 shared/api가 도메인/환경에 직접 의존하는 부분은 분리 필요.

📝 상세 피드백

shared 레이어는 UI 컴포넌트(Button, Card, Dialog 등)와 공통 유틸(api client, cache helpers)을 포함하고 있어 재사용 관점에서 잘 설계되어 있습니다. 다만 ApiClient 내부의 배포환경 분기(window.location.hostname)와 shared/lib/cache에서 queryKey를 직접 참조하는 구조는 shared의 순수성(도메인 독립성)을 일부 저해합니다.

❌ 현재 구조 (AS-IS)

// ❌ AS-IS: shared/lib/cache/postCache.ts가 'posts' 키에 직접 의존
export const addPostToCache = (queryClient, newPost) => {
  updatePostsCache(queryClient, (oldData) => { if (!oldData?.posts) return oldData; ... })
}

// 문제: shared 폴더가 특정 엔티티('posts')에 의존함

✅ 권장 구조 (TO-BE)

// ✅ TO-BE: shared는 도메인 독립적 유틸만 제공
// shared/lib/cache/index.ts
export const updateCache = (queryClient, queryKey, updater) => queryClient.setQueryData(queryKey, updater)

// entities/post에서 queryKeys.posts()를 사용하여 shared의 범용 updateCache를 호출

🔄 변경 시나리오별 영향도

  1. 디자인 시스템 변경(Material -> Chakra): shared/ui 파일군(약 20개)을 중심으로 변경하면 되므로 큰 이득. 현재 구조에서는 widgets/features가 직접 MUI 컴포넌트에 의존하지 않으므로 영향은 shared/ui 범위 내에 국한될 가능성이 높음.
  2. 다른 프로젝트로 shared 재사용: shared/lib/cache가 도메인 키를 포함하면 재사용성 낮음. queryKeys/도메인별 캐시 로직을 분리 필요

🚀 개선 단계

  • 1단계: 단기(half-day): shared/lib/cache의 도메인 의존성 제거. 범용 함수(updateCache(queryClient, queryKey, updater))로 교체
  • 2단계: 단기(half-day): ApiClient는 window.location 대신 import.meta.env.VITE_API_BASE_URL를 사용하도록 변경
  • 3단계: 중기(1-2일): shared/ui 컴포넌트의 props contract(variant, size 등)를 문서화하여 디자인 시스템 교체 시 지침 확보

6. 📐 추상화 레벨

💡 개념 정의

추상화 수준은 구현 세부사항을 감추고 재사용 가능한 인터페이스(함수 시그니처, 훅 API)를 제공하는 정도를 말합니다.

⚡ 중요성

높은 추상화는 구현체 교체(HTTP 클라이언트 교체, 엔티티 API 변경)를 쉽게 하고 테스트에서 목 대체를 단순화합니다.

📊 현재 상황 분석

AS-IS: 일부 계층은 잘 추상화되어 있으나, 일관된 훅 API 계약이 필요합니다. 통일된 훅 시그니처(예: useCreateX, useUpdateX({id, payload}))를 정의하면 변경과 테스트가 쉬워집니다.

📝 상세 피드백

API 호출은 ApiClient 클래스로 추상화되어 있고 entities/*/api에서 상속받아 사용하고 있어 좋은 추상화 수준을 보입니다. 반면 훅과 features 간 인터페이스가 일관되지 않아 상위 레이어에서 하위 구현을 대체하거나 테스트용 목(mock)을 주입하기 어렵습니다(예: useUpdatePost가 여러 형태로 정의됨).

❌ 현재 구조 (AS-IS)

// ❌ AS-IS: 훅 시그니처 불일치
// src/entities/post/model/hooks.ts
export const useUpdatePost = (id: number) => useMutation({ mutationFn: (post) => PostAPI.updatePost(id, post) })

// src/features/post/update-posts/hooks.ts
export const useUpdatePost = (post: PostItem, onSuccess?) => { /* 내부에서 useMutation 생성 */ }

// 문제: 같은 이름의 훅이 서로 다른 계약을 가짐

✅ 권장 구조 (TO-BE)

// ✅ TO-BE: 일관된 훅 계약
// entities/post/model/hooks.ts
export const useUpdatePost = (id?: number) => useMutation({ mutationFn: ({ id, payload }) => PostAPI.updatePost(id, payload) })

// features는 entities의 훅을 호출하고 필요한 인자만 전달

🔄 변경 시나리오별 영향도

  1. 비즈니스 로직 복잡도 증가: 잘 정의된 인터페이스가 없으면 구현을 수정할 때 호출부(여러 features/components)를 함께 수정해야 함
  2. 인증 도입(토큰 발급/갱신) 시 ApiClient를 교체하면 되지만, 직접 fetch를 사용한 코드가 있으면 그만큼 수정 범위 증가

🚀 개선 단계

  • 1단계: 단기(1-2일): 훅 네이밍/시그니처 가이드라인 문서화(예: useCreateX(payload), useUpdateX({id, payload}), useDeleteX(id))
  • 2단계: 단기: 중복된 훅(useUpdatePost) 제거 및 하나의 계약으로 통합(리팩토링 예상 2~4 파일)
  • 3단계: 중기: 의존성 주입을 고려해 테스트에서 목 주입이 쉬운 구조로 변경

7. 🧪 테스트 용이성

💡 개념 정의

테스트 용이성은 유닛/통합 테스트를 작성하기 쉬운 구조인지(순수 함수 분리, 의존성 주입 가능성, 사이드 이펙트 격리)입니다.

⚡ 중요성

테스트 가능한 구조는 리팩토링과 요구사항 변화에 대한 안전망을 제공합니다. 변경 시 회귀를 자동화된 테스트로 빠르게 검증할 수 있습니다.

📊 현재 상황 분석

AS-IS: 단위 테스트는 구현 가능하지만, 실제 작성 시 테스트 경계(어느 레이어를 mock할지)가 불분명해져 테스트 유지보수 비용이 커질 수 있습니다.

📝 상세 피드백

컴포넌트/훅으로 로직이 분리되어 있어 테스트 작성을 위한 기본 조건은 잘 갖춰져 있습니다. 다만 일부 훅이 내부 useState를 많이 사용하거나 ApiClient에 환경 의존 코드(window.location)를 포함하는 등으로 단위 테스트에서 mocking이 번거로울 수 있습니다. 또한 features에서 API를 직접 호출하는 경우 모킹 경계가 불명확해집니다.

❌ 현재 구조 (AS-IS)

// 문제 예: useAddPost 내부에 useState 존재
export const useAddPost = (onSuccess) => {
  const [newPost, setNewPost] = useState(initialPost)
  const mutation = useMutation(...) // 내부 상태와 mutation이 섞여 있음
}
// 테스트 시 newPost 상태를 초기화하거나 내부 동작 검증이 복잡해짐

✅ 권장 구조 (TO-BE)

// 권장: 훅은 비즈니스 로직만 담당, form state는 컴포넌트에서 관리
// useAddPost 는 mutation 반환
export const useAddPost = () => {
  const mutation = useMutation(createPost)
  return mutation
}

// AddPostForm 컴포넌트는 로컬 state를 가지고 mutation.mutate를 호출

🔄 변경 시나리오별 영향도

  1. 외부 API 계약 변경 시: entities/api 레이어를 모킹하면 대부분의 기능을 테스트할 수 있으나, features가 직접 API 호출하면 해당 features 테스트에서 직접 네트워크 모킹을 추가해야 함

🚀 개선 단계

  • 1단계: 단기(1-2일): ApiClient를 환경 변수 기반으로 변경하여 테스트 환경에서 쉽게 mock 가능하도록 수정
  • 2단계: 단기: use* 훅과 폼 상태를 분리(훅은 mutation만 노출, 컴포넌트가 form state 관리)하여 유닛 테스트 단순화
  • 3단계: 중기: 핵심 훅(entities)의 단위 테스트를 우선 작성하고, features 테스트는 최소한의 통합/목(Mock)을 사용하여 작성

8. ⚛️ 현대적 React 패턴

💡 개념 정의

현대적 React 패턴은 Suspense, ErrorBoundary, 커스텀 훅, hooks를 통한 관심사 분리 등을 포함합니다. 선언적 로딩/에러 처리는 UI와 로직 분리를 돕습니다.

⚡ 중요성

이 패턴들은 로딩/에러 처리 일관성을 제공하고, 컴포넌트의 단일 책임을 보장해 변화에 유연하게 대응하게 합니다.

📊 현재 상황 분석

AS-IS: modern hook 패턴을 잘 도입했으나 선언적 로딩/에러 패턴(Suspense, ErrorBoundary)과 mutation lifecycle(onMutate/onError/onSettled)을 통한 일관된 낙관적 업데이트 구현은 추가로 도입 권장.

📝 상세 피드백

React Query, custom hooks, Suspense/ Error Boundary 같은 현대 패턴 일부가 적용되어 있습니다(QueryClientProvider, custom hooks). 그러나 Suspense 및 ErrorBoundary의 선언적 사용은 활용되지 않았고, mutation의 onMutate/onError 낙관적 업데이트 패턴도 표준화되어 있지 않습니다. 또한 많은 컴포넌트가 로직과 UI를 혼합하고 있어 더 많은 custom hook 분리가 가능해보입니다.

❌ 현재 구조 (AS-IS)

// 현재: 명시적 로딩/에러 상태 처리 (imperative)
const { data, isLoading, error } = useQuery(...)
if (isLoading) return <div>로딩</div>
if (error) return <div>에러</div>

// 권장: Suspense + ErrorBoundary로 선언적 처리

✅ 권장 구조 (TO-BE)

// 권장 구조
<ErrorBoundary fallback={<ErrorFallback />}>
  <Suspense fallback={<Skeleton />}>
    <PostList />
  </Suspense>
</ErrorBoundary>

// useQuery는 useSuspenseQuery(react-query v5 패턴)로 전환 가능

🔄 변경 시나리오별 영향도

  1. 로딩 UX 전략 변경(스켈레톤, Suspense 활용) 시: 컴포넌트를 단순화하고 Suspense-friendly한 훅으로 전환하면 PostsManagerPage와 widgets의 변경량이 감소

🚀 개선 단계

  • 1단계: 단기(1-2일): 핵심 페이지(PostsManagerPage)에서 Suspense와 ErrorBoundary로 로딩/에러 처리 전환(점진적 도입)
  • 2단계: 단기: useMutation에서 onMutate/onError/onSettled 패턴으로 낙관적 업데이트 표준화
  • 3단계: 중기: 컴포넌트 로직을 더 많은 custom hook으로 분리하여 UI는 표시만 담당하도록 리팩토링

9. 🔧 확장성

💡 개념 정의

확장성은 새로운 기능 추가나 기존 기능 변경을 할 때 수정해야 하는 범위가 얼마나 작은지를 뜻합니다.

⚡ 중요성

확장성은 제품 성장과 팀 규모 증가 시 빠른 기능 개발과 안정적 배포를 가능하게 합니다.

📊 현재 상황 분석

AS-IS: 확장성은 양호하지만 몇 가지 구조 개선으로 더 좋아질 수 있음(대체로 중간 수준).

📝 상세 피드백

구조 자체는 기능 추가에 유리하도록 설계되어 있습니다(entities/feature/widget 분리, shared 컴포넌트). 그러나 몇몇 결합/응집도 이슈(직접 API 호출, 중복 훅)와 dialogStore의 boolean 나열 방식은 확장성(새 dialog 추가, 상태 공유 확장)에 소소한 제약을 줍니다.

❌ 현재 구조 (AS-IS)

// 확장성 제약 예: dialogStore boolean 나열
interface DialogState { showAddDialog: boolean; showEditDialog: boolean; ... }
// 새로운 다이얼로그 추가 시 store와 사용하는 컴포넌트 모두 수정 필요

✅ 권장 구조 (TO-BE)

// 개선안: 키/맵 기반 다이얼로그 관리
interface DialogState { dialogs: Record<string, boolean>; open: (key) => void; close: (key) => void; }
// 새로운 dialog 등록 시 내부 키만 추가하면 됨

🔄 변경 시나리오별 영향도

  1. 다국어 지원(i18n) 추가: UI 텍스트가 컴포넌트에 하드코딩된 경우 텍스트 리팩토링 필요. shared/ui로 모아두면 영향은 적음.
  2. A/B 테스트 도입: feature flag/variation 제공을 위해 feature 레벨에서 래퍼를 도입하면 영향범위 최소화 가능

🚀 개선 단계

  • 1단계: 단기(half-day): dialogStore를 map 기반 API로 리팩토링하여 다이얼로그 추가/삭제 비용을 낮춤
  • 2단계: 단기: queryKeys 중앙화 및 entities 훅 일원화로 새로운 API 추가 시 수정 파일 수 최소화
  • 3단계: 중기: 기능이 많아질 경우 domain별 패키지(예: packages/post, packages/comment)로 분리 검토

10. 📏 코드 일관성

💡 개념 정의

코드 일관성은 파일명, 네이밍, Import/Export 패턴, 코드 스타일이 프로젝트 전반에서 일관되게 유지되는지 여부입니다.

⚡ 중요성

일관된 컨벤션은 코드 읽기 쉬움, 자동화 도구 적용, 온보딩 속도 개선에 큰 영향을 미칩니다.

📊 현재 상황 분석

AS-IS: 전반적 일관성은 양호하나 세부 규칙(훅의 위치, 바렐 사용, queryKey 사용 등)을 통일하면 협업 생산성이 올라갑니다.

📝 상세 피드백

전반적으로 파일/컴포넌트 네이밍, export 패턴이 일관적이며 shared/ui에서 컴포넌트 패턴도 잘 정리되어 있습니다. 다만 일부 경로/파일명과 훅 네이밍(모델/hooks.ts vs hooks.ts), query key 방식(상수 vs 리터럴) 등에서 일관성 문제가 있어 신규 팀원이 학습할 때 혼란이 발생할 가능성이 있습니다.

❌ 현재 구조 (AS-IS)

// 혼재된 패턴 예
// 파일명/위치: src/entities/user/hooks.ts vs src/entities/user/model/hooks.ts
// queryKey 예시: ['comments', postId] (리터럴) vs QUERY_KEYS.POSTS (상수)

✅ 권장 구조 (TO-BE)

// 일관된 규칙 예시
// 파일구조: entities/user/model/types.ts, entities/user/model/hooks.ts
// queryKeys: import { queryKeys } from 'shared/api/queryKeys'
// hooks 네이밍: useGetUserList, useGetUserInfo

🔄 변경 시나리오별 영향도

  1. 새 팀 멤버 합류 시: 컨벤션이 명확하지 않으면 코드 스타일/구조에 대한 PR 코멘트가 늘고 협업 비용 상승
  2. 코드스캐닝/자동화(예: codemods) 적용 시: 규칙이 불일치하면 스크립트 작성을 위한 추가 비용 발생

🚀 개선 단계

  • 1단계: 단기(half-day): 코드 컨벤션 문서화(파일명 규칙, 훅 위치, export 방식, queryKey 사용 규칙) 및 PR 템플릿에 체크리스트 추가
  • 2단계: 단기(1-2일): ESLint/Prettier + 프로젝트 규칙(예: import ordering, quote 스타일) 적용
  • 3단계: 중기: 바렐(index.ts) 및 절대경로(alias(@/entities)) 도입으로 import 경로 일관화

🎯 일관성 체크포인트

파일명 규칙

  • hooks 파일의 위치가 일관되지 않음 (src/entities/user/hooks.ts vs src/entities/user/model/hooks.ts)
  • 일부 컴포넌트/훅 파일이 Camel/Pascal 혼용 경향

Import/Export 패턴

  • 상대 경로가 깊게 중첩되어 있음('../../../entities/...') - 절대 alias 적용 권장
  • export 패턴은 대체로 named export이나 프로젝트 전반의 바렐 사용 일관성 부족

변수명 규칙

  • 쿼리 키 상수(QUERY_KEYS) 사용과 문자열 리터럴 사용이 혼재
  • isLoading/isPending 혼용 (React Query v5에서의 상태명 혼동 가능)

코드 스타일

  • 따옴표 스타일이 대체로 double-quote이나 혼용된 곳 존재
  • 일부 컴포넌트에서 JSX/TSX 들여쓰기와 공백 스타일이 일관되지 않음

11. 🗃️ 상태 관리

💡 개념 정의

상태관리는 서버 상태(원격 데이터)와 클라이언트 상태(UI 상태, 로컬 폼 등)를 명확히 분리하여 각자의 책임과 생명주기를 관리하는 것을 의미합니다.

⚡ 중요성

명확한 상태 분리는 Props Drilling을 줄이고 디버깅/성능 최적화(불필요한 렌더링 최소화)를 쉽게 합니다.

📊 현재 상황 분석

AS-IS: 기본 원칙은 잘 지켜지고 있으나 세부 경계(어떤 상태를 전역으로, 어떤 상태를 지역으로 유지할지)에 대한 일관된 정책이 필요합니다. 특히 댓글 입력/선택 등 단기간의 UI 상태는 컴포넌트 로컬로 두는 것이 바람직합니다.

📝 상세 피드백

서버 상태는 TanStack Query, UI 상태는 Zustand로 분리하려는 의도가 명확히 보입니다(예: useDialogStore로 다이얼로그 상태 중앙화, useCurrentUser/usePosts로 서버 상태 관리). 전체적으로 역할 분리는 잘 되어 있으나 일부 Props Drilling이 아직 남아있고, dialogStore의 boolean 나열 패턴은 확장 시 관리가 번거롭습니다. 또한 currentUser를 useCurrentUser 훅에서 하드코딩 ID(1)로 가져오는 부분은 실제 인증 도입 시 수정 포인트입니다.

❌ 현재 구조 (AS-IS)

// AS-IS: 다이얼로그 상태를 전역으로 관리
export const useDialogStore = create((set) => ({ showAddDialog:false, openAddDialog:()=>set({showAddDialog:true}), ... }))

// 댓글 데이터는 여전히 props로 전달되는 곳이 있어 Props Drilling 존재

✅ 권장 구조 (TO-BE)

// TO-BE: 전역으로 관리할 상태 최소화, 단기 UI 상태는 로컬로
// 전역: 다이얼로그의 open/close key만 관리
// 로컬: 댓글 폼 입력값 등은 컴포넌트 내부에 위치

🔄 변경 시나리오별 영향도

  1. 오프라인 모드 및 로컬 큐잉 추가: 서버 상태와 로컬 큐 상태 분리가 중요. 현재는 일부 낙관적 업데이트가 queryClient에 의존하므로 로컬 큐 추가 시 조정 필요
  2. 실시간 기능 추가(WebSocket): React Query의 subscription 패턴과 Zustand의 UI 상태를 함께 고려하여 데이터 흐름 설계 필요

🚀 개선 단계

  • 1단계: 단기(half-day): dialogStore를 key 기반으로 변경(예: dialogs: Record<string, boolean>)하여 확장성 개선
  • 2단계: 단기(1-2일): 댓글 관련 상태에서 props로 전달되는 함수/데이터를 feature 훅으로 추출(useCommentForm)하여 Props Drilling 축소
  • 3단계: 중기: 상태 분리 정책 문서화(서버 상태: React Query, UI 상태: Zustand/local state, 폼 상태: local 또는 form library)

🤔 질문과 답변

Q1. 현재 entities, features, widgets 구조로 작은 프로젝트는 잘 동작하지만, 기능이 많아질 때 어떻게 구조를 관리해야 할지?
답변: 현재와 같은 FSD 기반 분리는 확장성 측면에서 좋은 출발입니다. 기능이 늘어날 때 권장되는 전략은 다음과 같습니다:

  1. 도메인별 바렐과 네임스페이스: entities/post, entities/comment처럼 각 도메인별 index.ts(barrel)를 제공해 public API를 명확히 합니다. 내부 구현(예: api/.ts, model/)은 외부에 노출하지 않습니다.
  2. queryKeys 중앙화: shared/api/queryKeys.ts 같은 곳에 모든 쿼리 키 및 키 생성기를 두어 key 변경 시 영향범위를 축소합니다.
  3. 폴더의 서브도메인 분리: 기능이 많아지면 도메인 안에서 sub-feature(예: entities/post/comments)를 만들고 필요시 패키지화(모노레포 내 packages/post)합니다. 팀 경계가 명확하면 서로 다른 패키지로 분리해 배포 단위를 나눌 수 있습니다.
  4. 모듈 경계 강제화: ESLint 규칙(import/no-restricted-paths 등)으로 계층 위반을 CI에서 차단하면 대형 프로젝트에서 코드 무결성을 유지하기 쉽습니다.
  5. 상태관리 가이드: 어떤 상태는 React Query(서버), 어떤 상태는 Zustand(전역 UI), 어떤 상태는 local(useState/form lib)로 관리할지 규약을 만들면 확장 시 혼란을 줄입니다.
    정량적 예측(대략):
  • UI 라이브러리 교체: 지금처럼 shared/ui로 추상화되어 있으면 shared/ui 파일군(약 20~30파일)만 수정하면 되는 반면, UI가 각 컴포넌트에 흩어져 있으면 80+ 파일을 수정해야 할 수 있습니다.
  • 상태 라이브러리 변경(Zustand → Recoil): dialogStore 사용 부분(참조하는 컴포넌트 약 1020개)을 교체 필요. 단, Props Drilling이 남아있다면 추가로 1030개 수정 필요.

Q2. 실제 대기업/스타트업에서 FSD를 적용하는 사례가 있나요? 어떤 규모 이상에서 FSD를 쓰는 게 좋은가요?
답변: Feature-Sliced Design은 러시아의 유지보수 커뮤니티에서 기원한 실무 중심 아키텍처로, 특히 여러 팀(또는 많은 기능)을 가진 프로젝트에서 유용합니다. 공식 레퍼런스와 사례는 Feature-Sliced Design 공식 문서와 커뮤니티 자료를 참고하세요. 일반적으로 권장되는 기준:

  • 소규모(1~3명, <5000 LOC): 단순한 폴더 구조로도 충분합니다. FSD는 오버헤드가 될 수 있음.
  • 중간 규모(팀 310명, 5k50k LOC): FSD 도입 추천 — 관심사 분리와 책임 소재가 명확해져 팀 협업에 큰 도움.
  • 대규모(여러팀, >50k LOC): 반드시 계층과 경계를 명확히 해 모듈 독립화를 진행해야 하며, 패키지화(모노레포)와 결합하여 사용하면 좋습니다.
    많은 스타트업/기업에서 FSD와 유사한 도메인 분리(도메인 기반 폴더 구조)를 사용합니다. 핵심은 팀과 조직의 협업 방식에 맞춰 규칙을 단순명료하게 정하는 것입니다.

🎯 셀프 회고 & 제안

작성하신 셀프회고는 학습 과정과 사고의 진화(초기 혼란 → 구조의 가치 인식)를 잘 담고 있습니다. 특히 '엔티티는 정보, 피처는 행동'이라는 핵심 인사이트를 체감하신 점이 매우 좋습니다. 다음 질문들을 스스로 더 생각해보시면 더 단단한 설계 판단을 할 수 있습니다:

  1. Props Drilling을 완전히 제거하려면 어느 상태를 전역(Zustand)으로 옮기고, 어느 상태는 로컬로 남겨야 할까요? (판단 기준: 재사용성, 수명주기, 빈도)
  2. 다이얼로그 상태는 boolean 나열형 vs 키/맵 기반 중 어느 것이 더 유지보수성이 높은가요? (확장성과 테스트 관점에서 비교)
  3. 현재 낙관적 업데이트는 잘 동작하지만 실패 시 rollback 전략을 어떻게 설계하면 사용자 경험을 더 안전하게 만들 수 있을까요? (onMutate/onError 패턴 고려)
  4. 팀에 FSD 규칙을 적용하려면 어떤 문서/린트 규칙/PR 체크리스트가 효과적일까요? (예: module-boundaries, queryKey 사용 규칙)

추가 제안: 지금처럼 적극적으로 동료에게 질문하고 해결책을 찾아나가는 태도가 매우 좋습니다. 다음 단계로는 위의 개선작업(바렐, queryKeys, 훅 시그니처 정리)을 작은 PR 단위로 나누어 적용해보세요. 각 PR은 인수 테스트(간단한 E2E 또는 스냅샷)로 변경 효과를 검증하면 학습 생산성이 높아집니다.


추가 논의가 필요한 부분이 있다면 언제든 코멘트로 남겨주세요!

코드 리뷰를 통해 더 나은 아키텍처로 발전해 나가는 과정이 즐거웠습니다. 🚀

이 피드백이 도움이 되었다면 👍 를 눌러주세요!

@ckdwns9121
Copy link
Member

고봉밥 PR 잘읽고 갑니다!.! 한주 고생많으셨어요~

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants