ldhldh07 님의 상세페이지[9팀 임두현] Chapter 2-3. 관심사 분리와 폴더구조

과제 체크포인트

배포링크

https://ldhldh07.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를 주제로 과제를 수행했습니다.

  • 폴더구조를 통한 클린코드의 의미를 이해한다.
  • FSD의 룰을 이해하고 수행한다.
  • FSD를 도구로써 개발의 편의성을 늘리는 현상을 직접 채험한다

위 내용들을 달성하고자 하는 마음으로 과제를 수행했습니다.

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

FSD 레이어과 프론트엔드의 기능의 선후관계가 명확해졌습니다. FSD가 임의로 레이어를 나누고 그에 끼워맞춰서 각 레이어들을 명명한게 아니었습니다.

실제로 현대 프론트엔드 개발에서 코드를 분리하고 추상화 단계를 지정할 때 사용되는 개념들을 옮겨놓은 것이 레이어었습니다.

FSD에서 어떤 코드를 어떤 계층에 놓을까 고민하는 과정에서 이를 느꼈습니다. 단순히 엔티티-코드 피쳐스-코드 1대1의 관계속에서 고민하면 답이 안나왔습니다. 반면, 전체 레이어의 큰 그림속에서 상대적인 관계를 생각하면서 코드를 짰을 때 자연스럽게 각 레이어의 역할이 이해됐습니다.

폴더 구조의 요구사항을 수행하기 위해 코드를 작성하다보면 자연스럽게 순수 함수를 만들고 추상화 단계를 맞추고 있었습니다.

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

레이어 구분하기

혼란스러웠던 점은 세그먼트는 다른데 레이어는 동일하게 적용해야한다는 점이었습니다. 기존 스스로의 개념 속에서 apiapi나름의 단계, uiui나름의 단계 modelmodel나름의 단계가 있었습니다. 하지만 FSD에선 해당 개념들은 같은 틀안에서 구분지어야 했습니다.

이를 포괄할만한 관념적인 경계를 만드는 것이 와닿지 않았습니다.

초반 시도

처음에는 FSD에 대해 가장 많이 설명되는 내용인 비즈니스 로직의 포함 여부로 생각했습니다.

  • Entity - 비즈니스 로직이 포함되지 않는 모델, 이 모델을 사용하는 api와 ui 로직
  • Features - Entity에 해당하는 모델에 비즈니스 로직을 적용한 사용자의 행위가 포함되는 모델과 그 사용처

이 개념으로 접근했을 때 혼란스러운 부분은 api였습니다.

// 엔티티
export const getPosts = async ({ limit, skip }: PostsParams): Promise<PostsResponse> => {
  const response = await fetch(`/api/posts?limit=${limit}&skip=${skip}`)
  if (!response.ok) throw new Error()
  return response.json()
};

// 피쳐

export const createPost (...) => {...};
export const deletePost (...) => {...};

고민 과정

자연스럽게 같은 추상화 단계의 모델을 다른 레이어에 두는 것에 대한 문제가 생겼습니다. 만약 피쳐스에 정의한 생성/수정/삭제 모델을 참조 혹은 결합해서 비즈니스 로직을 만든다면 어느 단계에 만들어야 하나 싶었습니다.

결론적으로 저 개념 자체는 크게 변하지 않았지만, 실제 구현에 있어서 추상화 단계에 좀 더 무게를 뒀습니다.

  • 엔티티 API는 도메인 리소스 I/O로 한정
  • 비즈니스 규칙·상호작용은 피처에 배치

또한 useCase의 맥락을 엔티티/피쳐 구분에 사용했습니다.

기존의 개념 접목

다른 많은 패턴들의 개념을 FSD에 적용해서 생소함을 최소화시키고자 했습니다.



  • Presemtational, Container UI를 엔티티-피쳐로 구분할 때 해당 패턴과 같은 로직을 적용했습니다. 엔티티의 UI는 순수 상태만 가지고 단순히 Prop받은 정보를 출력하는 역할만 하는 UI입니다. 피쳐스의 UI는 엔티티 UI에 핸들러와 상태를 주입해주는 역할만 수행합니다.
import { ChangeEventHandler } from "react";

import { Button } from "@shared/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@shared/ui/dialog";
import { Textarea } from "@shared/ui/textarea";

export interface CommentAddDialogProps {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  body: string;
  onChange: ChangeEventHandler<HTMLTextAreaElement> | undefined;
  onSubmit: () => Promise<void> | void;
}

export function CommentAddDialog({ open, onOpenChange, body, onChange, onSubmit }: Readonly<CommentAddDialogProps>) {
  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>새 댓글 추가</DialogTitle>
        </DialogHeader>
        <div className="space-y-4">
          <Textarea placeholder="댓글 내용" value={body} onChange={onChange} />
          <Button onClick={onSubmit}>댓글 추가</Button>
        </div>
      </DialogContent>
    </Dialog>
  );
}

import { CommentAddDialog } from "@/entities/comment";

import { useCommentEditor } from "../model/edit-comment.hook";

export function CommentAddDialogContainer() {
  const { newComment, setNewComment, isAddOpen, setIsAddOpen, addComment } = useCommentEditor();

  const handleSubmit = async () => {
    if (newComment.postId == null) return;
    await addComment({ body: newComment.body, postId: newComment.postId, userId: newComment.userId });
  };

  return (
    <CommentAddDialog
      open={isAddOpen}
      onOpenChange={setIsAddOpen}
      body={newComment.body}
      onChange={(e) => setNewComment((prev) => ({ ...prev, body: e.currentTarget.value }))}
      onSubmit={handleSubmit}
    />
  );
}

  • Atomic Design

엔티티/피쳐스가 순수 UI와 비즈니스 로직 컨테이너의 조합으로서 한 덩어리라면 Widget의 UI는 그 덩어리들의 조합이라 설계했습니다. 그리고 그 개념은 아토믹 디자인 패턴의 Organism과 같이 인식했습니다.

이처럼 기존에 익숙한 개념들을 통해 FSD를 일단 유연하게 사용해보았습니다. 사용하는 과정에서 이해도가 높아지고 활용법이 단계별로 발전하는 것을 기대하고 있습니다.

최종 구조

front_6th_chapter2-3/
  - src/
    - app/
      - ui/
    - entities/
      - comment/
        - api/
        - index.ts
        - model/
        - ui/
      - post/
        - api/
        - model/
        - ui/
      - user/
        - api/
        - model/
        - ui/
    - features/
      - comment-edit/
        - model/
        - ui/
      - post-edit/
        - model/
        - ui/
      - post-filter/
        - index.ts
        - model/
        - ui/
      - post-load/
        - api/
        - model/
        - ui/
      - post-pagination/
        - ui/
      - user-load/
        - index.ts
        - model/
        - ui/
    - pages/
      - posts-manager-page.tsx
    - shared/
      - api/
      - lib/
      - ui/
    - widgets/
      - footer/
        - ui/
      - header/
        - ui/
      - posts-manager/
        - ui/

Tanstack Query

기본과제를 통해 tanstack-query를 사용하지 않고 기존의 상태를 전역 관리로 바꾼 뒤 tanstack-query를 도입하는 단계로 진행했습니다.

이 방식을 통해 생각보다 느낀 점이 많았습니다. 기존에 익숙한 툴이었음에도, 정확히 어떤 좋은점이 있고 다른형식의 어떤 기능을 대체하는지 구체적으로 파악했습니다.

기존에는 선언된 전역 상태가 있었습니다. 그리고 이를 명령형으로 조작을 해야합니다. 이를 tanstack-query의 상태와 서버 통신 관리를 결합한 선언적 형태로 바꾸자 많은 보일러 플레이트 코드들이 사라졌습니다.

이는 commit diff로 확인이 가능했습니다.

// 전역 상태 선언
export const commentsAtom = atom<CommentsByPostId>({});
export const isCommentsLoadingAtom = atom<boolean>(false);

// 복잡한 상태 조작 훅
export const useComments = () => {
  const [comments, setComments] = useAtom(commentsAtom);
  const [isLoading, setIsLoading] = useAtom(isCommentsLoadingAtom);

  const setCommentsForPost = (postId: number, comments: Comment[]) => {
    setComments((prev) => ({ ...prev, [postId]: comments }));
  };

  const appendComment = (comment: Comment) => {
    setComments((prev) => ({
      ...prev,
      [comment.postId]: [...(prev[comment.postId] ?? []), comment],
    }));
  };

  const changeComment = (comment: Comment) => {
    setComments((prev) => ({
      ...prev,
      [comment.postId]: (prev[comment.postId] ?? []).map((c) => (c.id === comment.id ? comment : c)),
    }));
  };

  const removeComment = (commentId: number, postId: number) => {
    setComments((prev) => ({
      ...prev,
      [postId]: (prev[postId] ?? []).filter((c) => c.id !== commentId),
    }));
  };

  return {
    comments, isLoading, setComments, setCommentsForPost, 
    appendComment, changeComment, removeComment, // 모든 조작 함수들...
  };
};
// 컴포넌트에서 수동 데이터 패칭 + 상태 동기화
const handleOpenDetail = useCallback(
  async (post: Post) => {
    setSelectedPost(post);
    if (comments[post.id]) {
      setIsDetailOpen(true);
      return;
    }
    // 수동으로 API 호출하고 상태에 저장
    const { comments: selectedPostComments } = await commentApi.get(post.id);
    setComments((prev) => ({ ...prev, [post.id]: selectedPostComments }));
    setIsDetailOpen(true);
  },
  [comments, setComments, setIsDetailOpen, setSelectedPost],
);
// 선언적 쿼리 - 캐싱, 로딩, 에러 처리 자동화
export function useCommentsQuery(postId?: number) {
  return useQuery({
    enabled: !!postId,
    queryKey: commentQueryKeys.byPost(postId),
    queryFn: () => commentApi.get(postId as number).then((res) => res.comments),
  });
}

jotai를 통해 모든 상태를 리팩토링 한 이후에 한번에 tanstack-query를 적용했기에 완연하게 느낄 수 있는 좋은 부분이었습니다.

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

멘토링을 통해 접했던 네임스페이스를 통해 가독성과 명시성을 높이려는 시도를 해봤습니다.

export const postApi = {
  get({ limit, skip, sortBy, order }: PostsParams): Promise<PostsResponse> {
    return http.get<PostsResponse>("/posts", { params: { limit, skip, sortBy, order } });
  },
  create(payload: CreatePostParams): Promise<Post> {
    return http.post<Post>("/posts/add", payload);
  },
  update({ postId, params }: UpdatePostPayload): Promise<Post> {
    return http.put<Post>(`/posts/${postId}`, params);
  },
  remove(id: number): Promise<void> {
    return http.delete<void>(`/posts/${id}`);
  },
} as const;

이 경우 네임스페이스 충돌에 대한 우려도 없을 뿐더러 변수명을 직관적으로 이해할 수 있습니다. 이 활용을 앞으로도 적극적으로 하고자 합니다.

챕터 셀프회고

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

유지보수라는 새로운 접근

앞서 언급된 클린코드의 서술에서 읽기 좋은 것이 클린코드라는 것은 당연하고 익숙한 개념입니다. 가독성이 좋은 것이 왜 좋냐도 직관적인 영역입니다.

하지만 이번 주제를 겪으면서 유지보수하기 좋은 코드에 대한 이해도가 높아졌습니다. 순수 함수로 분리하고 함수를 독립적으로 만들 경우, 수정/삭제가 쉽고 추적이 쉽다는 것을 체득할 수 있었습니다.

개인적으로 코드를 유지보수하는 단계에 있어본 적이 없기 때문에 더 흥미롭게 느껴졌습니다.

왜 읽기 좋고 유지보수하기 좋으면 좋은가

한 단계 더 나아가서 왜 가독성이 좋고 유지 보수가 쉬우면 좋으냐를 개념화하고 싶었습니다.

클린코드를 짜기 위해 소모되는 수고/시간보다 아낄 수 있는 수고/시간이 더 크다.

개발자 경험의 관점에서 클린코드로 인해 얻을 수 있는 수고/시간의 개념을 따져봤습니다.

  • 잃는것 : 작성하는 데 쓰는 시간, 클린코드의 규칙을 결정하는데 드는 커뮤니케이션 비용 등등
  • 얻는것 : 팀원 그리고 스스로의 줄어든 코드 해석 시간

행동은 하면할수록 익숙해지고 편해집니다.

클린코드를 작성함으로서 잃는 것은 시간이 갈수록 줄어듭니다. 이번 과제에서도 같은 양의 코드를 리팩토링하더라도 후반부에 작업한 것은 전반부보다 빨리 작업했습니다.

그 때문에 처음에는 잃는 것이 더 클지 몰라도 잃는 것에 속하는 코드 작성 시간이 우상향함에 따라 얻는 것이 늘어날 수 밖에 없습니다.

그리고 쓰는 사람보다 읽는 사람들이 더 많습니다. 10사람이 2시간씩 아낀다면 20시간이 더 생깁니다.

이 관점에서 클린코드가 개인 그리고 단체에게 큰 효용을 제공합니다.

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

// features/post-load/model/post-load.hook.ts
import { usePosts } from "@/entities/post";
import { getPostsWithAuthors } from "../api/post-load.api";
import { usePostFilter } from "@/features/post-filter"; // 다른 피처에 직접 의존

export const useLoadPost = () => {
  const { setPosts, setTotal, setIsLoading } = usePosts();
  const { skip, limit, selectedTag, sortBy, sortOrder, searchQuery } = usePostFilter();

  const getPosts = async () => {
    setIsLoading(true);
    try {
      const { posts, total } = await getPostsWithAuthors({ limit, skip, selectedTag, sortBy, sortOrder, searchQuery });
      setPosts(posts);
      setTotal(total);
    } finally {
      setIsLoading(false);
    }
  };

  return { getPosts } as const;
};

사고를 많이 하지 않고 훅을 만들었습니다. Post를 가져오는 훅으로서 url 파라미터와 연동하는 필터 데이터들을 입력해야 했습니다. 하지만 이는 같은 단계인 features를 참조했습니다.

// features/post-load/model/post-load.hook.ts
import { usePosts } from "@/entities/post";
import { getPostsWithAuthors } from "../api/post-load.api";

export const useLoadPost = () => {
  const { setPosts, setTotal, setIsLoading } = usePosts();

  const getPosts = async (params: {
    limit: number;
    skip: number;
    selectedTag?: string;
    sortBy?: string;
    sortOrder?: string;
    searchQuery?: string;
  }) => {
    setIsLoading(true);
    try {
      const { posts, total } = await getPostsWithAuthors(params);
      setPosts(posts);
      setTotal(total);
    } finally {
      setIsLoading(false);
    }
  };

  return { getPosts } as const;
};
// features/post-browse/ui/post-browse-container.tsx
import { useEffect } from "react";
import { usePostFilter } from "@/features/post-filter";
import { useLoadPost } from "@/features/post-load/model/post-load.hook";

export function PostBrowseContainer() {
  const { skip, limit, selectedTag, sortBy, sortOrder, searchQuery } = usePostFilter();
  const { getPosts } = useLoadPost({skip, limit});

  ...
  return null;
}

이를 해결하기 위해 원시타입을 인자로 받는 훅을 만들었습니다. 그리고 그 상단에서 인자를 입력했습니다.

FSD의 룰을 지키기 위한 시도가 자연스럽게 각 영역을 독립적으로 만들고 자동으로 추상화 계층을 통일시키고 있었습니다. 이 경험을 하면서 적절한 분리에 대한 개념이해를 발전시켰습니다.

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

이번 과제는 특히 레퍼런스를 많이 봤습니다. FSD를 구현한 프로젝트들은 가지각색의 방식들이 있었습니다.

공통점은 응집도를 높이려는 시도가 있었습니다. 세부적인 구현방식은 생각보다도 많이 달랐습니다.

그 방식들을 보면서 개인적으로 맘에 드는 방식들을 쏙쏙 뽑았습니다. 그중 파일 이름을 도메인 혹은 도메인-행위로 통합하고 확장자로 역할을 구분하는 형태의 방식이 FSD와 어울린다 생각해서 적용하기도 했습니다.

image

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

FSD의 경우 리팩토링을 상향식으로 하는 것이 좋을 지 하향식으로 하는 것이 좋을까요 다른 세그먼트들이 하나의 레이어에 묶여있습니다.

개인적으로 상태/모델은 작은것부터 분리해서 조립하여 올라가는 방식이 편하고 UI는 큰 덩어리부터 작은 덩어리로 쪼개나가는 것이 편합니다.

이번 과제를 통해 같은 레이어 단위 속에서 공존하여 이 때 어던식으로 판단하시는지 여쭤보고자 합니다.

과제 피드백

안녕하세요 두현님! 클린코드 마지막 과제 잘 진행해주셨네요 ㅎㅎ

FSD의 경우 리팩토링을 상향식으로 하는 것이 좋을 지 하향식으로 하는 것이 좋을까요 다른 세그먼트들이 하나의 레이어에 묶여있습니다. 개인적으로 상태/모델은 작은것부터 분리해서 조립하여 올라가는 방식이 편하고 UI는 큰 덩어리부터 작은 덩어리로 쪼개나가는 것이 편합니다. 이번 과제를 통해 같은 레이어 단위 속에서 공존하여 이 때 어던식으로 판단하시는지 여쭤보고자 합니다.

말씀하시는 상향식/하향식이 아래에서부터 조립하는지, 위에서부터 분리하는지로 따져보자면... 저는 위에서부터 분해해서 만들어가는걸 선호하는 편이랍니다!

특히 지금처럼 리팩토링을 하는 과정에서는 더더욱 그렇게 하는 것 같아요 ㅎㅎ 처음부터 프로젝트를 구성하고 만들어간다면 작은 단위의 코드부터 구성할 수 있겠지만 지금은 개선을 하는 작업이니까요!