tomatopickles404 님의 상세페이지[9팀 권지호] Chapter 2-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가 정상적으로 작동하는가?

최종과제

  • 폴더구조와 나의 멘탈모데일이 일치하나요?
  • 다른 사람이 봐도 이해하기 쉬운 구조인가요?

과제 셀프회고

배포 주소 https://tomatopickles404.github.io/front_6th_chapter2-3/

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

1. entites 와 features의 구별: 나만의 기준 세우기

이 전에 경험했던 FSD 적용은 entities와 features의 구분이 모호하게 느껴져 가장 큰 어려움이었습니다. 지금도 잘 이해하고 나누었다고 생각하진 않지만 전보다 고민하지 않고 나눌 수 있었던 것은 이번 고민을 통해 저만의 규칙이 생긴 것이기 때문이라고 생각합니다.

테오의 말대로 FSD의 원칙을 어떤 프로젝트든 꼭 들어맞게 적용할 수는 없다고 생각합니다. 다만 도메인의 행동을 기반으로 나누는 문서화된 레퍼런스가 생긴 것은 유의미한 것 같습니다. 이 부분에서 저만의 기준을 정할 때 합리적인 의사결정의 지표로 사용될 수 있었습니다.

[고민했던 지점]

1. features 슬라이스 구성에 대하여

src/features/
├── post/          # 게시물 관련 모든 기능
|		├── apis/               # API 함수들
│   │   └── api.ts
│   ├── hooks/               # 커스텀 훅들
│   │   └── usePostsParams.ts
│   ├── models/              # 타입과 쿼리키
│   │   ├── types.ts
│   │   └── queries.ts
│   ├── ui/                  # UI 컴포넌트들
│   │   └── PostForm.tsx
│   └── index.ts             # 배럴 익스포트
├── user/          # 사용자 관련 모든 기능
├── comment/       # 댓글 관련 모든 기능
└── tag/           # 태그 관련 모든 기능

[의문점] 행동 기반으로 디렉토리를 표현해야하는 것 아닌가?

(예시)

src/features/
├── create-post/   # 게시물 생성 기능
│   ├── CreatePostForm.tsx   # UI 컴포넌트
│   ├── useCreatePost.ts     # 커스텀 훅
│   ├── createPostApi.ts     # API 함수
│   ├── types.ts             # 타입 정의
│   └── index.ts
├── update-post/   # 게시물 수정 기능
├── delete-post/   # 게시물 삭제 기능
├── view-post/     # 게시물 조회 기능
└── manage-users/  # 사용자 관리 기능

이와 같은 고민에 대해 멘토링 시간에 얻은 조언은 다음과 같습니다.

분류를 쉽게 하기 위한 나만의 기준을 가지는 것.

준일님의 기준

  • Features 라이브러리나 프레임워크에 의존적인 영역(API 요청 같은 것) 혹은 브라우저에서만 사용할 수 있는 코드

  • Entities 라이브러리나 프레임워크에 의존적이지 않은 부분 ESCMAScript Spec으로 구성 되어있는, NodeJS 혹은 백엔드에서 실행해도 어색함이 없는 코드

나의 기준?

  • Features 사용자의 특정 행동(동사)을 유발하는 기능 예를 들어, "로그인 하기", "장바구니 담기", "좋아요 누르기" 처럼 Entities를 활용하여 특정 인터렉션을 완성하는 단위

  • Entities 비즈니스 도메인의 명사. 예를 들어, "사용자", "상품", "게시글"처럼 그 자체로 의미를 가지며 여러 곳에서 재사용될 수 있는 순수한 데이터와 UI 묶음

2. 쿼리키의 위치

[의문점]

export const TAG_QUERY_KEY = {
  all: ["tags"],
  tags: () => [...TAG_QUERY_KEY.all, "tags"],
  tag: () => [...TAG_QUERY_KEY.all, "tag"],
} as const

이 쿼리 키는 entites의 model인가? features의 모델인가? entities는 model 디렉토리가 존재하지 않는가?

나의 기준!

  1. 쿼리 키는 features의 models에 있어야 합니다.
    • 쿼리 키는 서버 데이터를 가져오는 행위(How)와 강하게 결합되어 있으므로 features의 model에 있어야 한다.
    • entities는 순수한 도메인 개념을 정의하는 것에 집중.
  2. Entities에는 models 디렉토리가 없어야 합니다.
    • entities는 비즈니스 도메인의 핵심 개념만 정의
    • 서버 상태나 API 관련 로직은 제외

3. features의 model과 entities는 어떤 점이 다른가?

  1. Entities (엔티티)
// src/entities/post/types.ts
export interface Post {
  id: number
  title: string
  body: string
  userId: number
  tags: string[]
  reactions: Reaction
}

export interface Reaction {
  likes: number
  dislikes: number
  userReaction?: 'like' | 'dislike' | null
}

// src/entities/post/helpers.ts
export const mapPostsWithUsers = (posts: Post[], users: User[]) => {
  // 순수 함수: 데이터 변환만 담당
  return posts.map((post) => ({
    ...post,
    author: users.find((user) => user.id === post.userId),
  }))
}

특징

  • 순수한 데이터 구조 (비즈니스 로직이 없는 타입 정의)
  • 재사용 가능 -> 여러 도메인에서 공통으로 사용
  • 불변성: 데이터 편환 시 새로운 객체 반환
  • 도메인 독립적 -> 특정 기능에 종속되지 않음

  1. Features의 Model
// src/features/post/models/queries.ts
export const POST_QUERY_KEY = {
  all: ['posts'] as const,
  posts: (params: PostsParams) => [...POST_QUERY_KEY.all, 'posts', params] as const,
  post: (id: number) => [...POST_QUERY_KEY.all, 'post', id] as const,
  reaction: (postId: number) => [...POST_QUERY_KEY.all, 'reaction', postId] as const,
} as const

// src/features/post/models/types.ts
export interface PostsParams {
  limit?: number
  skip?: number
  search?: string
  tag?: string
}

export interface PostResponse {
  posts: Post[]
  total: number
  skip: number
  limit: number
}

특징

  • 도메인 특화: Post 기능에만 필요한 타입과 설정
  • React Query 설정: 쿼리 키, API 응답 타입 등
  • 기능 종속적: Post 기능을 위한 특정 구조
  • 상태 관리: React Query와 연관된 설정

구체적인 차이점 분석

  1. 책임의 차이

[Entities]

// Post는 무엇인가? - 게시글이라는 개념
export interface Post {
  id: number
  title: string
  body: string
  // ... Post의 본질적 속성들
}
  • "무엇" 을 표현

[Features Model]

// Post를 어떻게 조회할 것인가? - 쿼리 파라미터
export interface PostsParams {
  limit?: number    // 몇 개씩 가져올 것인가?
  skip?: number     // 어디서부터 시작할 것인가?
  search?: string   // 무엇을 검색할 것인가?
  tag?: string      // 어떤 태그로 필터링할 것인가?
}
  • "어떻게" 사용할지를 정의

  1. 사용 범위의 차이

[Entities]

// src/features/post/components/PostTable.tsx
import { Post } from "entities/post"  // Post 타입 사용

// src/features/comment/components/Comments.tsx
import { Post } from "entities/post"  // 다른 도메인에서도 Post 타입 사용

// src/features/user/components/UserDialog.tsx
import { User } from "entities/user"  // User 엔티티도 공통 사용
  • 전체 애플리케이션에서 공통 사용

[Features Model]

// src/features/post/hooks/usePostsQuery.ts
import { POST_QUERY_KEY } from "../models/queries"  // Post 도메인 내에서만 사용
import { PostsParams, PostResponse } from "../models/types"

export function usePostsQuery(params: PostsParams) {
  return useQuery({
    queryKey: POST_QUERY_KEY.posts(params),  // Post 전용 쿼리 키
    queryFn: () => fetchPosts(params),
  })
}
  • 해당 도메인 내에서만 주로 사용

  1. 변경 빈도의 차이

[Entities]

// Post 엔티티는 비즈니스 요구사항이 바뀌지 않는 한 안정적
export interface Post {
  id: number        // 변경될 일이 거의 없음
  title: string     // Post의 본질적 속성
  body: string      // Post의 본질적 속성
}
  • 안정적, 자주 변경되지 않음

[Features Model]

// PostsParams는 기능 요구사항에 따라 자주 변경될 수 있음
export interface PostsParams {
  limit?: number
  skip?: number
  search?: string
  tag?: string
  // 새로운 요구사항: category?: string
  // 새로운 요구사항: sortBy?: 'date' | 'title' | 'popularity'
}
  • 기능 요구사항에 따라 자주 변경됨

[결론]

Entities

  • Post, User, Comment, Tag 등의 인터페이스
  • 순수한 데이터 변환 함수 (mapPostsWithUsers, updatePostReaction)
  • 도메인 상수 (API 경로, 기본값 등)

Features Model

  • React Query 관련 설정 (쿼리 키, API 응답 타입)
  • 기능별 파라미터 타입 (PostsParams, CreatePostData)
  • 도메인 특화 유틸리티 (Post 검색, 필터링 등)

이런식으로 전과 다르게 의사결정 하나하나에 나름의 선명한 이유들을 덧붙여가게 되었습니다.


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

1. 응집도 높이기

저는 개인적으로 쿼리 상태를 개별 훅으로 관리하는 것을 선호합니다. 그리고 쿼리 키 팩토리로 쿼리 키를 관리하되, 함수는 재사용이 없다면 훅과 같은 모듈 내에서 관리하는 것을 선호합니다. 제가 생각하는 이 방식의 이점은 다음과 같습니다.

AS-IS

// 1. queries.ts에서 쿼리 키 팩토리 관리
// src/features/user/models/queries.ts
export const USER_QUERY_KEY = {
  all: ["users"],
  users: () => [...USER_QUERY_KEY.all, "list"],
  user: (id: number) => [...USER_QUERY_KEY.all, "detail", id],
} as const

// 2. api.ts에서 fetch 함수 관리
// src/features/user/apis/api.ts
export const fetchUsers = async () => {
  return api.get("/users?limit=0&select=username,image")
}

export const fetchUser = async (id: number) => {
  return api.get(`/users/${id}`)
}

// 3. useUsersQuery에서 조합
// src/features/user/hooks/useUsersQuery.ts
export function useUsersQuery() {
  return useSuspenseQuery({
    queryKey: USER_QUERY_KEY.users(),  // 쿼리키 팩토리 사용
    queryFn: () => fetchUsers(),       // API 함수 사용
  })
}
  • api 함수와 query를 각각 개별파일에서 관리하는 것이 파편화되어 있다고 느껴짐
  • 함수가 재사용되지 않는데도 별도 파일에 분리되어 있음
  • 관련 로직이 여러 파일에 흩어져 있어 응집도가 낮음

TO-BE

// src/features/user/models/queries.ts
export const USER_QUERY_KEY = {
  all: ["users"],
  users: () => [...USER_QUERY_KEY.all],
} as const

// src/features/user/hooks/useUsersQuery.ts

interface UserResponse {
  users: User[]
  total: number
  skip: number
  limit: number
}

const fetchUser = async () => {
  return api.get<UserResponse>(`/users?limit=0&select=username,image`)
}

export function useUsersQuery() {
  return useSuspenseQuery({
    queryKey: USER_QUERY_KEY.users(),
    queryFn: () => fetchUser(),
  })
}
  • 높은 응집도와 적절한 관심사 분리
    • 도메인 레벨: 특정 도메인(user)과 관련된 모든 쿼리 키를 키 팩토리 파일에서 통합 관리하여 키들의 응집도를 높이고 일관성을 유지
    • 훅 레벨: 훅은 자신의 책임에만 집중합니다. 쿼리함수가 하나의 훅에서만 사용된다면, 함수를 훅과 함께 배치(co-location)하여 훅과 API 호출 로직 간의 응집도를 높입니다.
  • 불필요한 파편화 방지
    • 다른 곳에서 재사용되지 않는 API 함수를 api.ts 와 같은 공용 파일로 굳이 분리하지 않습니다.
    • 불필요한 파일 분리와 export/import 구문을 줄이며 특정 훅과 강하게 결합된 로직이 물리적으로 가까이 위치하도록 보장하여 유지보수성을 높이고자 했습니다.
  • 명확한 단일 책임 원칙
    • useUserQuery는 "사용자 조회"라는 하나의 책임만 가집니다.
    • 퀴리 키 팩토리 : User 도메인의 쿼리 키를 생성하고 관리하는 책임을 가집니다.
    • 이 구조에서 훅은 공유된 쿼리 키 팩토리를 "사용"할 뿐이며, 자신의 책임에만 특화된 비공개 API 함수를 가질 수 있어 각 모듈의 역할이 더 명확해지는 효과가 있습니다.
  • 코드 가독성
    • 훅을 읽는 개발자가 이 훅이 어떤 API를 호출하는지 확인하기 위해 다른 파일을 찾아다닐 필요가 없습니다.
    • 쿼리키 구조와 API 호출 로직의 관계를 직관적으로 이해할 수 있습니다.
  • 확장성
    • 새로운 사용자 관련 쿼리가 필요할 때 해당 훅만 수정하면 되며 기존 코드에 영향을 주지 않습니다.
    • 필요하다면 공유 쿼리 키 팩토리에 새로운 키 정의만 추가하면 되므로 변경의 영향 범위가 명확하고 작게 유지됩니다.

2. 인터페이스 설계

[고민했던 지점]

  • 이 컴포넌트는 어떤 props만을 필요로 하는가?
  • "도메인에 의존적인 props나 drilling을 위해 필요한 프롭을 뚫기 전에 과연 이것은 props로 필요한 상태나 행동일까?"
  • "훅으로 내부에서 관리하는게 더 적절할까?"
function PostManagerPage() {
  const { posts, searchQuery, selectedTag, handleSearch, handleTagSelect } = usePostManagement()
  const { selectedPost, isEditing, handleEdit, handleClose } = usePostDialog()
  
  return (
    <div>
      <PostFilters 
        searchQuery={searchQuery}
        selectedTag={selectedTag}
        onSearch={handleSearch}
        onTagSelect={handleTagSelect}
      />
      <PostTable 
        posts={posts}
        onEdit={handleEdit}
      />
      <PostDialog 
        post={selectedPost}
        isEditing={isEditing}
        onClose={handleClose}
      />
    </div>
  )
}

3. 폴더구조가 직관적인가?

도메인 기반 + 역할 기반 하이브리드 구조

[의사결정 과정] FSD를 적용하면서 entities와 features 슬라이스 구분에 신경을 썼지만, 모든 원칙을 완벽하게 따르기보다는 합리적이고 편한 방법을 조합하는 방향으로 접근했습니다.

저는 도메인 기반 분류를 선호하지만, FSD 는 아직까지 크게 와닿지 않는 것 같습니다. 그래서 사이드 프로젝트에서는 features 안에 도메인을 관리하되, 내부는 역할 기반으로 나누어서 관리했었습니다. 작은 프로젝트에서 역할 기반 폴더구조가 직관적인 이유는 개발자가 코드를 찾을 때 "어떤 역할의 코드를 찾고 있는가?"를 먼저 생각하기 때문이라고 생각합니다. 이 이점이 강하다고 생각했기 때문에, 도메인 기반의 폴더구조를 취하더라도 역할 기반의 이점까지 함께 가져가고 싶었습니다.

결국 "적절한 확장성 + 높은 응집도"가 제가 가장 중요하게 생각하는 것이었고, 느슨한 결합도는 아직 체화하기 어려워서 우선순위가 밀렸습니다. 응집도를 먼저 높여놓은 뒤에, 그 안에서 결합도를 낮추는 방법을 찾아가는 것이 저에게는 가장 직관적이었습니다.

[최종 적용된 구조]

src/
├── entities/
│   ├── post/
│   │   ├── types.ts
│   │   ├── constants.ts
│   │   └── helpers.ts
│   ├── user/
│   └── comment/
├── features/
│   ├── post/
│   │   ├── components/
│   │   ├── hooks/
│   │   ├── models/
│   │   └── apis/
│   ├── user/
│   └── comment/
├── shared/
│   ├── components/
│   ├── hooks/
│   └── utils/
└── widgets/
    ├── header/
    ├── footer/
    └── layouts/

4. 중복이 존재하는 세 개의 Dialog를 어떻게 효율적으로 구성할 수 있을까?

[문제 상황]

  • 세 종류의 다이얼로그(알림, 확인, 입력)를 각각 별도 컴포넌트로 구현
  • 응집도 해치고 확장성 저해
  • 코드 중복 및 유지보수 어려움

[개선]

  1. CommonDialog 컴포넌트로 동적 렌더링
// src/shared/components/Dialog.tsx
export function Dialog({ isOpen, onClose, children }: DialogProps) {
  if (!isOpen) return null
  
  return (
    <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center">
      <div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
        {children}
      </div>
    </div>
  )
}

  1. useDialog 커스텀 훅으로 상태 관리
// src/shared/hooks/useDialog.ts
import { useState } from "react"

export function useDialog() {
  const [isOpen, setIsOpen] = useState(false)

  const toggleDialog = () => {
    setIsOpen((prev) => !prev)
  }

  return {
    isOpen,
    toggleDialog,
  }
}

  1. 타입 기반 다이얼로그 통합
export function PostDialog({
  type,
  open,
  onOpenChange,
  post,
  search,
}: {
  type: "create" | "edit" | "view"
  open: boolean
  onOpenChange: (open: boolean) => void
  post?: Post
  search?: string
}) {
  const queryClient = useQueryClient()
  const { mutate: createPost } = useCreatePostMutation()
  const { mutate: updatePost } = useUpdatePostMutation()

  const handleCreate = (formData: PostFormData) => {
    createPost(formData, {
      onSuccess: () => {
        queryClient.invalidateQueries({ queryKey: POST_QUERY_KEY.all })
        onOpenChange(false)
      },
    })
  }

  const handleEdit = (formData: PostFormData) => {
    if (!post) return

    updatePost(
      { id: post.id, post: formData },
      {
        onSuccess: () => {
          queryClient.invalidateQueries({ queryKey: POST_QUERY_KEY.all })
          onOpenChange(false)
        },
      },
    )
  }

  const getTitle = () => {
    switch (type) {
      case "create":
        return "새 게시물 추가"
      case "edit":
        return "게시물 수정"
      case "view":
        return post?.title || ""
    }
  }

  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent className={type === "view" ? "max-w-3xl" : undefined}>
        <DialogHeader>
          <DialogTitle>{getTitle()}</DialogTitle>
        </DialogHeader>

        {type === "create" && <PostCreateForm onSubmit={handleCreate} />}
        {type === "edit" && post && <PostEditForm post={post} onSubmit={handleEdit} />}
        {type === "view" && post && <PostViewContent post={post} search={search} />}
      </DialogContent>
    </Dialog>
  )
}
  • Props 설계의 장점
    • type: 다이얼로그의 동작 모드를 명확하게 구분하여 예측 가능한 인터페이스 제공
    • open, onOpenChange: 일관된 다이얼로그 열기/닫기 패턴으로 재사용성 향상
    • post: edit과 view 모드에서 필요한 데이터를 선택적으로 전달하여 유연성 확보
    • search: view 모드에서 검색어 하이라이트 기능을 위한 선택적 데이터
  • 비즈니스 로직 처리 방식
    • handleCreate: 새 게시물 생성 후 쿼리 무효화 및 다이얼로그 닫기로 사용자 경험 개선
    • handleEdit: 기존 게시물 수정 후 동일한 패턴으로 일관성 유지
    • getTitle: 동적으로 제목을 생성하여 각 모드별 명확한 컨텍스트 제공
  • UI 렌더링 전략
    • 조건부 렌더링: {type === "create" && } 패턴으로 필요한 컴포넌트만 렌더링
    • 반응형 레이아웃: view 모드일 때 max-w-3xl 클래스로 더 넓은 레이아웃 적용
    • 컴포넌트 분리: PostCreateForm, PostEditForm, PostViewContent로 각 모드별 로직을 독립적으로 관리

[또 다른 문제점]

  • 여러개의 Dialog에 대한 상태들을 개별적으로 관리 + 해당 상태들이 다른 컴포넌트에도 사용이 되는 구조였기 때문에 페이지 레벨에서 Prop Drilling이 불가피했음

  const { isOpen: showAddCommentDialog, toggleDialog: toggleShowAddCommentDialog } = useDialog()
  const { isOpen: showEditCommentDialog, toggleDialog: toggleShowEditCommentDialog } = useDialog()

 {/* 댓글 추가 대화상자 */}
      <Dialog open={showAddCommentDialog} onOpenChange={toggleShowAddCommentDialog}>
        <DialogContent>
          <DialogHeader>
            <DialogTitle>새 댓글 추가</DialogTitle>
          </DialogHeader>
          <div className="space-y-4">
            <Textarea
              placeholder="댓글 내용"
              value={newComment.body}
              onChange={(e) => setNewComment({ ...newComment, body: e.target.value })}
            />
            <Button onClick={() => toggleShowAddCommentDialog()}>댓글 추가</Button>
          </div>
        </DialogContent>
      </Dialog>

      {/* 댓글 수정 대화상자 */}
      ...
      </Dialog>

[최종 해결책: Toss OverlayKit 적용]

// src/pages/PostsManagerPage.tsx
import { overlay } from "overlay-kit"

const openPostDetail = async (id: number) => {
  const post = postsToDisplay.find((post) => post.id === id)
  if (post) {
    await overlay.openAsync(({ isOpen, close }) => (
      <PostDialog type="view" open={isOpen} onOpenChange={close} post={post} />
    ))
  }
}

const openEditDialog = async (post: Post) => {
  const fullPost = postsToDisplay.find((p) => p.id === post.id)
  if (fullPost) {
    await overlay.openAsync(({ isOpen, close }) => (
      <PostDialog type="edit" open={isOpen} onOpenChange={close} post={fullPost} />
    ))
  }
}

const openUserDialog = (user: { id: number; username: string; image: string }) => {
  overlay.open(({ isOpen, close }) => <UserDialog open={isOpen} onOpenChange={close} userId={user.id} />)
}

Toss OverlayKit 적용의 장점 • 선언적 Dialog 관리 -> 상태 관리 불필요 • Prop Drilling 제거 -> 각 컴포넌트에서 직접 다이얼로그 호출 • 코드 단순화 -> 복잡한 상태 관리 로직 제거 • 재사용성 향상 -> 어디서든 쉽게 다이얼로그 사용 • 일관된 UX -> 모든 다이얼로그가 동일한 동작 방식


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

여전히 컴포넌트 인터페이스를 설계하는 것이 가장 어렵습니다. 특히 여러 컴포넌트가 공유해야 하지만 전역 상태로 관리하기에는 범위가 작은 상태들을 다룰 때 많은 고민을 했습니다. 예를 들어, A 컴포넌트의 상태가 형제 컴포넌트인 B에도 영향을 줘야 할 때, 공통 부모로 상태를 끌어올리는(lifting state up) 것이 최선인지, 아니면 다른 방법이 있는지 계속 탐색하게 됩니다. '냅다 전역상태로 때려박는' 안티패턴을 피하면서, 어떻게 하면 상태의 범위를 적절하게 지역화하고 응집도를 높일 수 있을지가 저의 가장 큰 숙제입니다.


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

이번 챕터를 통해 얻은 가장 큰 수확은 '컴포넌트의 인터페이스를 먼저 생각하는 습관'입니다. 앞으로는 새로운 기능을 개발할 때, 구현에 앞서 다음 질문을 스스로에게 던지는 것을 습관화 하고자 합니다.

  1. "이 컴포넌트의 책임은 무엇인가?"
    • 컴포넌트가 수행해야 할 명확한 단일 책임을 정의하기
  2. "그래서 어떤 Props가 필요한가?"
    • 책임에 필요한 최소한의 데이터와 액션만을 props로 받도록 인터페이스를 설계해보기
  3. "이 상태는 어디에 위치해야 하는가?"
    • 상태의 영향 범위를 고려하여, 지역 상태, 부모 컴포넌트의 상태(Lifting State Up), 혹은 React Context 등을 활용해 가장 적절한 위치를 찾아보기

챕터 셀프회고

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

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

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

클린코드는 개발자 경험의 관점에서 그 중요성이 명확하다고 생각합니다. DX는 컴퓨터가 아닌 사람에게 편의성을 제공하는 것이고, 클린코드는 결국 기계가 아닌 사람을 위한 가장 근본적인 활동이기 때문입니다. 코드는 한 번 작성되고 끝나는 것이 아니라 수십에서 수백 번 읽히고 수정됩니다. 잘 짜인 코드는 미래의 나와 동료 개발자에게 불필요한 시간 낭비와 스트레스를 줄여주는 기술 문서이자 지속 가능한 개발 환경을 만드는 투자라고 생각합니다.

또한, 읽기 좋은 코드란 거슬림이 없는 것이라고 표현하고 싶습니다. 잘 쓴 글이 막힘 없이 술술 읽히듯, 좋은 코드는 물음표를 던질 필요 없이 자연스럽게 의도가 파악되는 코드라고 생각합니다.

읽기 좋은 코드를 구체적으로 나열해본다면 다음과 같을 것 같습니다.

  1. 예측 가능한 코드
  2. 일관성 있는 코드
  3. 단일 책임 원칙을 지키는 코드

예측 가능한 코드는 이름만 보고도 역할을 짐작하게 합니다. getUserData()라는 함수는 당연히 유저 데이터를 가져와야지, 갑자기 데이터를 수정하거나 삭제해서는 안 됩니다. d나 list1 같은 변수명이 아니라 elapsedTimeInDays나 filteredUserList처럼 명확한 이름을 사용하면, 코드를 읽는 사람은 불필요한 추측을 할 필요가 없어집니다.

일관성이 있는 코드는 프로젝트 전반에 걸쳐 코드 스타일, 컨벤션, 아키텍처 패턴을 일관되게 제공합니다. 일관성이 깨진다면 사소하더라도 읽는 사람에게는 계속해서 인지적 마찰을 일으키는 "거슬림"이 됩니다.

저는 명령 절차가 어렵게 작성되어 있으면 눈에 잘 들어오지 않습니다. 하나의 함수가 너무 많은 일을 수행한다면 복잡성이 증가한다고 생각합니다. 좋은 코드는 하나의 함수가 "한가지 일"만 명확하게 수행하는 것이라고 생각합니다.

유지보수하기 쉬운코드란 수정하기 쉬운 코드라고 생각합니다. 그렇다면 "어떤 코드가 수정하기 쉬울까" 고민해본다면, 변경의 영향 범위가 명확하고 작은 코드인 것 같습니다.

변경의 범위가 명확하고 작은 코드

  1. 낮은 결합도
  2. 높은 응집도
  3. 테스트 가능한 코드

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

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

지난 주차에 직접 리팩토링을 해보면서 이런 과정을 겪었던 것 같습니다. 이 연습을 하는 본질적인 이유는 "요구사항을 어떻게 하면 유지보수하기 쉽게 생산할까" 라고 생각하는데, 저는 "어떻게 하면 goal을 전략적으로 수행할까" 에만 너무 포커싱을 둔 채로 과제에 임했다는 것을 회고했었습니다.

1차 시도에는 컴포넌트 먼저 분리했었는데, 분리하기에 가장 눈에 잘보이기 때문이었습니다. 그러나 props를 먼저 만들어버리는 구조가 되어버려 코드베이스 파악이 더 어려워졌습니다. 그 기반에서 순수함수를 분리하려고 하니까 난이도가 더 체감되었습니다.

이 기반으로 순수함수 분리를 시도했으나, 함수를 분리할수록 리팩토링할 코드만 생성하고 있다는 느낌을 받았습니다. 고민 끝에 만들었던 것들을 리셋하고 리드미 순서대로 다시 진행했었고, 컴포넌트 분리보다 순수함수 분리하는 감각과 훅을 추상화 하는 것들이 저에게는 더 필요한 경험이라고 생각했습니다. (경험이 적어서!) 컴포넌트 추상화는 훅만 잘 분리되어 있다면 어렵지 않을 것이라고 생각했고, 그 정도는 AI에게 위임해도 괜찮은 작업이라고 생각했습니다. 예상대로 순수함수부터 분리한 뒤에는 상태와 관련된 로직들이 눈에 잘 보이기 시작했습니다. 상태를 개별 훅으로 나누고 나서야 정리가 됨을 느꼈습니다. 이때가 저의 아하! 포인트 였던 것 같습니다.

그래서 이번주 과제는 조금 더 잘 해볼 자신이 있었습니다. 그러나 진행을 할수록 숨겨져 있는 이슈(?)라고 해야할까요.. 해야 할 것들이 많고, 누락된 기능들을 구현하면 다시 코드가 더러워지고.. 의 반복이었습니다 ㅠㅠ 결국 이번주도 스스로에게는 만족스럽지 못한 마무리가 된 것 같습니다. 하지만 스스로 만족스러웠다면 이 과제를 다시 돌아보지 않을 것 같은데, 이런 찝찝한 마무리이기에 계속 돌아보게 되는 것 같습니다! 제출 기간은 지났지만 오늘까지는 코드를 계속 보완해 볼 계획 입니다!


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

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

앞의 내용에서 비슷한 언급을 했었지만, 이런 고민을 해본 끝에 내린 저의 결론은 내가 편하게 생각하는 구조는 무엇이고, 그걸 왜 편하게 생각하는지에 대한 정의를 내리는 것이 이 과제에서 제가 얻어가야 할 경험이고 필요한 생각이라고 생각했습니다.

제가 중요하게 생각했던 것은 "응집도" 였고, 그 것이 어떤 판단을 하는 것에 대한 하나의 기준이 된 것 같습니다. 응집도를 높이기 위해서 했던 첫번째 고민은 폴더 구조 였습니다. FSD를 살펴보며 '도메인 기반(features/post)'과 '행동 기반(features/create-post)' 구조 사이에 고민을 했고, 둘다 일리가 있어 두 방식의 장점을 결합한 구조를 적용했습니다.

이 구조가 저에게 편안했던 이유는 "응집도의 두 가지 레벨"을 모두 만족하기 때문입니다. 먼저, "게시물"과 관련된 모든 기능이 features/post에 모여 도메인 단위의 거시적인 응집도를 높여주었습니다. 그 안에서 components, hooks 등 역할 기반으로 파일을 분류하여 코드를 찾고 수정할 때 논리적 흐름의 미시적인 응집도를 확보할 수 있다고 판단했습니다.

그 다음에 개별 기능의 내부 코드 단위에서는 하나의 행위에 관련된 모든 것들은 최대한 한 곳에 모여있어야 한다는 규칙을 세웠습니다.

AS-IS (낮은 응집도)

  • queries.ts: 쿼리 키 관리
  • api.ts: API 함수 관리
  • hooks.ts: useQuery 훅 관리

TO-BE(내가 정의한 높은 응집도)

// src/features/user/models/queries.ts
export const USER_QUERY_KEY = {
  all: ["users"],
  users: () => [...USER_QUERY_KEY.all],
} as const

// src/features/user/hooks/useUsersQuery.ts

interface UserResponse {
  users: User[]
  total: number
  skip: number
  limit: number
}

const fetchUser = async () => {
  return api.get<UserResponse>(`/users?limit=0&select=username,image`)
}

export function useUsersQuery() {
  return useSuspenseQuery({
    queryKey: USER_QUERY_KEY.users(),
    queryFn: () => fetchUser(),
  })
}


이 구조는 재사용 없는 API 함수를 훅과 함께 배치함으로써 불필요한 파일 분리를 지양하고 특정 쿼리에 대한 컨텍스트를 한눈에 파악할 수 있게 해줍니다.

이번 과제를 통해 저는 "응집도"라는 저만의 기준이 생긴 것 같습니다. "어디에 둬야 하지?" 보다는 "어떻게 해야 관련 있는 것 끼리 뭉쳐놓지?" 에 대한 기준으로 판단하고, 판단 근거들 또한 명확해졌습니다. 결국 좋은 구조란 외부의 규칙을 맹목적으로 따르는 것이 아니라, 일관된 철학을 바탕으로 스스로 질서를 만들어나가는 과정이라는 것을 배울 수 있었습니다.


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

Q1.

여전히 컴포넌트 인터페이스를 설계하는 것이 가장 어렵습니다. 특히 여러 컴포넌트가 공유해야 하지만 전역 상태로 관리하기에는 범위가 작은 상태들을 다룰 때 많은 고민을 했습니다. 예를 들어, A 컴포넌트의 상태가 형제 컴포넌트인 B에도 영향을 줘야 할 때, 공통 부모로 상태를 끌어올리는(lifting state up) 것이 최선인지, 아니면 다른 방법이 있는지 계속 탐색하게 됩니다. 스스로의 명확한 기준 없이 '냅다 전역상태로 때려박는' 안티패턴을 피하면서, 어떻게 하면 상태의 범위를 적절하게 지역화하고 응집도를 높일 수 있을지가 저의 가장 큰 숙제입니다.

이 부분에 대해 조언해주실 말씀이 있으실까요? 코치님은 이럴때 어떤 포인트에서 아하!를 경험 하셨는지, 코치님의 기준은 무엇인지 궁금합니다!

Q2. 코치님에게 글쓰기란?

Q3. 저의 디렉토리 구조 결정 방향성에 대한 코멘트 궁금합니다!!

Q4. 재사용성을 고려한 컴포넌트 설계 방향성이 적절했는지, 확장성을 잘 고려했는지 모르겠습니다! 코치님이 보셨을때 이 리팩토링의 인상은 어떤지 궁금합니다!

export function PostDialog({
  type,
  open,
  onOpenChange,
  post,
  search,
}: {
  type: "create" | "edit" | "view"
  open: boolean
  onOpenChange: (open: boolean) => void
  post?: Post
  search?: string
}) {
  const queryClient = useQueryClient()
  const { mutate: createPost } = useCreatePostMutation()
  const { mutate: updatePost } = useUpdatePostMutation()

  const handleCreate = (formData: PostFormData) => {
    createPost(formData, {
      onSuccess: () => {
        queryClient.invalidateQueries({ queryKey: POST_QUERY_KEY.all })
        onOpenChange(false)
      },
    })
  }

  const handleEdit = (formData: PostFormData) => {
    if (!post) return

    updatePost(
      { id: post.id, post: formData },
      {
        onSuccess: () => {
          queryClient.invalidateQueries({ queryKey: POST_QUERY_KEY.all })
          onOpenChange(false)
        },
      },
    )
  }

  const getTitle = () => {
    switch (type) {
      case "create":
        return "새 게시물 추가"
      case "edit":
        return "게시물 수정"
      case "view":
        return post?.title || ""
    }
  }

  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent className={type === "view" ? "max-w-3xl" : undefined}>
        <DialogHeader>
          <DialogTitle>{getTitle()}</DialogTitle>
        </DialogHeader>

        {type === "create" && <PostCreateForm onSubmit={handleCreate} />}
        {type === "edit" && post && <PostEditForm post={post} onSubmit={handleEdit} />}
        {type === "view" && post && <PostViewContent post={post} search={search} />}
      </DialogContent>
    </Dialog>
  )
}

과제 피드백

안녕하세요 지호님! 역시 믿고 보는 지호님의 과제네요 ㅎㅎ 너무 잘 해주셨어요!!!

여전히 컴포넌트 인터페이스를 설계하는 것이 가장 어렵습니다. 특히 여러 컴포넌트가 공유해야 하지만 전역 상태로 관리하기에는 범위가 작은 상태들을 다룰 때 많은 고민을 했습니다. 예를 들어, A 컴포넌트의 상태가 형제 컴포넌트인 B에도 영향을 줘야 할 때, 공통 부모로 상태를 끌어올리는(lifting state up) 것이 최선인지, 아니면 다른 방법이 있는지 계속 탐색하게 됩니다. 스스로의 명확한 기준 없이 '냅다 전역상태로 때려박는' 안티패턴을 피하면서, 어떻게 하면 상태의 범위를 적절하게 지역화하고 응집도를 높일 수 있을지가 저의 가장 큰 숙제입니다. 이 부분에 대해 조언해주실 말씀이 있으실까요? 코치님은 이럴때 어떤 포인트에서 아하!를 경험 하셨는지, 코치님의 기준은 무엇인지 궁금합니다!

말씀해주신 내용이 도메인 상태와 관련이 있다면 전역상태로 만들어서 관리할 것 같아요! 그게 아니라 UI 상태와 관련 있다면 Context를 만들어서 관리하면 좋지 않을까!? 라는 생각이 들어요!

컴포넌트를 재활용해야 하는 상황이 아니라면 대체로 Context를 통해 묶어주는게 좋다고 생각해요.

혹은 useImperativeHandle 을 통해 ref로 연결하는 방법도 있을 것 같아요! 이건 멘토링 때 이야기를 나눠봐도 좋을 것 같네요..!

코치님에게 글쓰기란?

저의 생각을 정리하는 시간이라고 생각해요 ㅎㅎ 뜬금없지만, 군대에 있을 때 매일매일 일기를 썼었는데 일기를 쓰는 시간이 하루 중 제일 소중했었던 것 같아요. 그 시간이 소중했던 이유가 뭘까 고민을 해보자면... 그냥 생각이 되게 많았고 이걸 어딘가에 배설(?) 하고 싶었어요. 일기에 배설을 한거죠 ㅋㅋ

지금도 마찬가지인데요, 생각이 많아질 때 이걸 결국 일기나 블로그에 쓰면서 생각을 정리하는 방식으로 활용하고 있답니다!

"어디에 둬야 하지?" 보다는 "어떻게 해야 관련 있는 것 끼리 뭉쳐놓지?" 에 대한 기준으로 판단하고, 판단 근거들 또한 명확해졌습니다. 결국 좋은 구조란 외부의 규칙을 맹목적으로 따르는 것이 아니라, 일관된 철학을 바탕으로 스스로 질서를 만들어나가는 과정이라는 것을 배울 수 있었습니다. 저의 디렉토리 구조 결정 방향성에 대한 코멘트 궁금합니다!!

결정의 과정을 잘 드러내주셔서 다 납득했답니다! 여기에 저의 생각을 한스푼 더 얹어보자면...

packages/domain
   posts
     /entities
     /feautres
     /shared

저희 팀의 경우 이렇게 별도의 패키지로 분리한 다음에 도메인과 관련된 것들으 한 번에 묶어서 표현하는 방식으로 사용하고 있답니다 ㅎㅎ 이렇게 하면 더 응집도를 높이는 방식으로 관리할 수 있어요!

다만... ui는 전부다 제외하고 로직만 관리해요 ㅋㅋ

재사용성을 고려한 컴포넌트 설계 방향성이 적절했는지, 확장성을 잘 고려했는지 모르겠습니다! 코치님이 보셨을때 이 리팩토링의 인상은 어떤지 궁금합니다!

흠 재사용성만 놓고 보면, 컴포넌트가 tanstack-query를 직접적으로 의존하고 있다보니 어떻게 재사용을 할 수 있을까? 에 대한 고민이 들었어요.

이렇게 children을 적극적으로 활용해주면 어떨까 싶어요!

export function PostDialog({
  className,
  open,
  title,
  children,
  onOpenChange,
}) {
  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent className={className}>
        <DialogHeader>
          <DialogTitle>{title}</DialogTitle>
        </DialogHeader>

        {children}
      </DialogContent>
    </Dialog>
  )
}

그리고 PostDialog 하위에 각각의 컴포넌트를 표현하는 방식으로 만들면 좋을 것 같아요 ㅎㅎ

더 정확히 표현하자면.. "UI와 Logic을 완전히 분리하는 것" 이라고 봐주시면 좋겠습니다!