리액트 프로젝트의 좋은 구조에 대해

Lennon·2024년 2월 16일
180
post-thumbnail

들어가며

안녕하세요. 2년 차 프론트엔드 개발자 Lennon입니다.
프론트엔드 개발을 하다보면 폴더 구조 및 프로젝트 구조 파일 컨벤션에 대해 많은 고민이 생기지 않나요?

경험상 리액트에서의 정말 보편적인 구조부터 시작해, 사내 프론트엔드 개발 유지보수 비용을 최대한 줄이기 위해 꾸준하게 팀원들과 논의 후 다양한 구조들을 개선해나가는 것 같습니다.

결론을 먼저 말씀드리면, 제가 생각하는 좋은 구조는 프로젝트의 목표, 성향에 따라 팀원들과 정한 구조가 가장 베스트 구조라고 생각합니다!

제가 이번 글에서 전해드릴 내용은 아래와 같습니다.

1. 컴포넌트 폴더 구조에 대해
2. 전역 상태 관리에 대한 폴더 구조에 대해
3. @tanstack/query의 mutation을 활용하면서 구조적인 것에 대한 고민
4. @tanstack/query의 query를 활용하면서 구조적인 것에 대한 고민
5. 모노레포 환경에서 package를 줄이고, 와일드카드 import에 대한 고민
6. 모노레포 환경에서 tsconfig, eslint 정적 분석 도구 컨벤션에 관한 개발 환경에 대한 고민
7. Typescript를 사용한다면

세상엔 다양한 프로젝트가 있고, 각자 적용하는 방법론이 다르니 이런 프로젝트에서 이런 구조를 짰구나 정도로 넘어가시면 좋을 것 같습니다.

당연하게도 정답은 아닙니다.

컴포넌트 구조

리액트의 보편적인 폴더 구조는 아래와 같습니다.

- page
- components
- hooks
- utils
- constants
- store
- ...

위 보편적인 구조라도 각 폴더 내부에서 컨벤션을 정할 게 정말 많습니다. 특히 매일 가장 많이 생산되는 컴포넌트의 컨벤션이 가장 논의가 많이되고, 그만큼 고민도 많은 것 같아요.

그냥 나열하기

components
	- Button
    - Header 
    - Skeleton
    - LandingModal
    - LandingButton
    - CreateProjectNavigation
    - GenerateProgressBar

관심사별로 구분하기

components
	- @common(공통되는 것)
    	- Button
        - Header 
        - Skeleton
	- Landing(관심사)
    	- LandingModal
        - LandingButton
        - CreateProjectNavigation
        - GenerateProgressBar

관심사 안에서도 역할별로 구분하기

components
	- @common(공통되는 것)
    	- Button
        - Header 
        - Skeleton
	- Landing(관심사)
    	- @common(관심사 안에서 공통되는 것)
        	- LandingModal
            - LandingButton
        - Create(역할별)
        	- CreateProjectNavigation
            - GenerateProgressBar

단순 common은 design system으로 빼기

components
	- Landing(관심사)
    	- @common(관심사 안에서 공통되는 것)
        	- LandingModal
            - LandingButton
        - Create(역할별)
        	- CreateProjectNavigation
            - GenerateProgressBar

특정 컴포넌트 내부에서만 사용되는 컴포넌트는 같은 Depth에 정의하기

components
	- Landing(관심사)
    	- @common(관심사 안에서 공통되는 것)
        	- LandingModal
            - LandingButton
        - Create(역할별)
        	- CreateProjectNavigation
            	- NavigationButton ✅
                	- index.tsx
            	- index.tsx
                - Fallback.tsx
                - ErrorFallback.tsx
            - GenerateProgressBar

등등...

위 설명을 제외하고 아토믹 디자인 패턴과 같이 다양한 폴더 구조 패턴들에 대한 방법이 정말 많을거에요.

저희는 충분한 고민 끝에 마지막 방법을 활용하고 있습니다.
각 관심사 내부에서 역할별로 컴포넌트를 구분하고 내부에서만 사용되는 컴포넌트는 같은 Depth에 정의해 사용하고 있습니다.
또한, Suspense, ErrorBoundary를 활용하는 컴포넌트에 대해선 Fallback, ErrorFallback 또한 같은 Depth에 선언해 응집도를 유지하였습니다.

저희 제품은 Landing이라는 관심사 안에서 Create, Edit 등의 역할에서 사용되는 컴포넌트가 너무 명확하게 잘 나눠져 있어 위 방법을 채택하였습니다.

2년 동안 두 개의 기업에서 매일매일 컴포넌트를 만들며 느낀 가장 좋은 방법은 팀원들과 함께 꾸준히 논의하고 결정한 구조가 아닐까 생각이 드네요 :)
결국 프로젝트는 회사에 종속되고, 사내 개발자들이 보기에 읽기 쉬운 코드 및 구조가 좋은 구조일거니까요!

전역 상태 구조

요즘에 FE 트렌드로 보통 서버 상태 관리에 @tanstack/query 클라이언트 전역 상태 관리에 jotai, zustand, recoil 을 많이 활용하시는 것 같습니다.

저는 각 역할을 가진 건 컨테이너화를 해야 가독성 및 유지보수가 좋아진다고 생각합니다.
tanstack/query를 예로 들면 일단 서버 상태 관리라는 큰 틀 안에서 key도 관리하고, api도 관리하고, 각 hook들도 관리하는 경우가 많은데, 파편화되어있으면 유지보수 측면에서 좋은 이점을 가져가긴 힘들다고 생각이 들어요.

기존

- store
- hooks
	- queries
    - mutations
  	- ... 다양한 custom hook

기존엔 mutations, queries도 결국 hook이니 hooks 폴더에 함께 넣어주는 구조였습니다.
useOnClickOutside와 같이 기본적인 util hook들은 따로 monorepo package에 넣어줬음에도 불구하고 다양한 custom hook들이 생기면서 뒤섞여 찾아가기 힘들어지는 문제점이 생겼습니다.
또한, hooks라는 폴더의 역할이 너무 방대해진 느낌이 들어 hooks엔 정말 custom hook 캡슐화의 역할만 해주는 것들을 관심사에 맞게 모아놓기로 하였습니다.

개선

- hooks
	- ... 다양한 custom hook
- state
	- mutations
    - queries
    - store

16일 금요일에 팀원분께 관련해 말씀드리고 이야기를 나눈 결과 위처럼 사용할 것 같습니다.
Root에 state를 만들고, 각 Server 상태 관리(mutations, queries), Client 상태 관리(store)로 관리가 될 것 같습니다.

위 폴더 구조에 대해 조금 더 상세히 설명드리면 아래처럼 사용합니다.

- state
	- mutations
    	- landing (관심사)
        	- useCreateLanding.ts (역할에 맞는 api, hook 정의)
            - useDeleteLanding.ts
            - usePatchLandingDomain.ts
            - ...
        - generate (관심사)
        	- useCompleteAdCopy.ts
            - useRequestAdCopy.ts
            - useCompleteWebScrapping.ts
            - useRequestWebScrapping.ts
            - ...
        - auth (관심사)
        - ...
    - queries
    	- landing (관심사)
        	- key.ts
            - useGetLanding.ts
            - useGetLandingAdditionalInfo.ts
            - ...
        - generate (관심사)
        	- key.ts
        	- useGetAdCopyStatus.ts
            - useGetLandingStatus.ts
    - store
    	- landing (관심사)
        	- create (역할)
            - edit (역할)
        	- index.atom.ts
        - index.atom.ts

정말 다양하고 좋은 구조가 있겠지만, 저희는 위처럼 사용하고 있습니다. 각 관심사 및 역할에 맞게 구조를 나누다 보면 공통되는 것들이 많습니다. 컴포넌트랑 관심사 => 역할 구조가 state 폴더와 비슷하지 않나요?

Mutation 구조 관련

컴포넌트에서 핵심 로직이 바로 보이지 않고 컴포넌트 라인수가 길어지는 걸 팀원을 포함해 별로 좋아하지 않아, 정말 핵심 로직에 필요한 정보만 가져올 수 있도록 캡슐화한 custom hook을 많이 사용합니다.
기존엔 mutation에서 특정 key에 대한 removeQueires, invalidateQueires를 컴포넌트에서 하는 걸 지양하는 걸 목적으로 custom hook으로 만들어 처리를 하였습니다.

기존

mutations (api, mutation) => custom hook (hook) => component

예로 useCreateLanding.ts에서 onSuccess 이후 invalidateQueires가 되어야 하면 관련 custom hook을 만들어 내부에서 onSuccess로직을 인자로 받아 처리하고 컴포넌트에서 import 하여 사용하였는데요,
중간 과정을 거치면 결국 개발자가 할 일이 늘어나고, 하나의 mutation을 여러 컴포넌트에서 사용할 때 유연하게 대응하기도 힘들 것 같았습니다.
아마 얕게 파편화가 되어있었던 것 같네요.

아마도 구조를 짜는 데 아래와 같은 흐름적으로 짠 것 같아요.

mutation이 이루어지는 모든 곳에서 invalidate가 되어야하면 굳이 컴포넌트에서 선언하지 않아도 될 것 같다.
=> 기본적으로 invalidate, onSuccess, onError toast 로직을 겸비한 custom hook으로 만들자!
=> custom hook에서 onSuccess, onError, mutation 각 인자를 받아 처리

어떻게 보면 괜찮은 방법처럼 보일 수 있지만, 결국 하나의 mutation이 여러 폴더에 파편화되어있어 좋은 방법은 아닌 것 같았습니다.
이를 아래와 같이 개선하였습니다.

개선

mutation (api, mutation, mutationWithInvalidate) => component

queryClient의 후처리가 필요한 mutation hook들의 경우 각 mutation 파일에서 api, mutation 에 추가적으로 mutationWithInvalidate을 선언하고 컴포넌트에서 mutationWithInvalidate 관련 hook을 import 하고 onSuccess, onError는 컴포넌트 단에서 hook에 넘겨줘 처리할 수 있도록 역할을 위임하였습니다.

개선 결과 정말 다양한 custom hook들이 삭제가 되었고, 프로젝트적으로도 각 mutation마다 응집도가 높아져 사용 및 추가하기 간편하게 개선되었습니다.

아래는 예시 코드입니다.

// usePostVelog.ts

const postVelog = ({
  postId,
}: PostVelogRequest): Promise<ApiResponse> => {
  return put("route/post", {
	postId,
  });
};

const usePostVelog = (
  options?: MutationOptions<unknown, PostVelogRequest>,
): MutationResult<unknown, PostVelogRequest> => {
  return useMutation({
    mutationFn: postVelog,
    ...options,
  });
};

export const usePostVelogWithInvalidation = ({
  postId,
  onSuccess,
  onError,
}: UsePostVelogInvalidationParams): {
  mutate: () => void;
  isPending: boolean;
} => {
  const queryClient = useQueryClient();

  const { mutate: postVelogMutate, isPending } = usePostVelog();

  const postVelogHandler = (): void => {
    postVelogMutate(
      {
		postId: string,
      },
      {
        onSuccess: () => {
          void queryClient.invalidateQueries({
            queryKey: velogKey.post(postId),
          });
          if (isFunction(onSuccess)) onSuccess();
        },
        onError: () => {
          if (isFunction(onError)) onError();
        },
      },
    );
  };

  return { mutate: putLandingHandler, isPending };
};

물론, usePostVelogWithInvalidationg 함수의 인자 자체를 postVelogHandler에서 받아서 처리해도 무방합니다!

query 구조 관련

혹시 @tanstack/query v4를 사용해 보셨나요?
v5 이전 버전들은 useQuery를 선언할 때 두 가지 방법이 있습니다.

// 1
const useGetLanding = (
  landingPageId: number,
): QueryResult<GetLandingResponse> => {
  return useQuery({
    queryKey: landingKey.landing(landingPageId),
    queryFn: getLanding,
    retry: 1,
  });
};

// 2
const useGetLanding = (
  landingPageId: number,
): QueryResult<GetLandingResponse> => {
  return useQuery(
    landingKey.landing(landingPageId),
	getLanding, 
    {
       retry: 1 
    }
  );
};

혼자 개발하는 게 아니다 보니, 실제 저희 프로덕트도 각각 useQuery가 위처럼 두 가지 방법이 모두 사용되었습니다.
v5부턴 위 코드에서 첫 번째 방식만 허용되도록 재설계가 되었습니다. 물론 위 컨벤션을 위해서만 버전업을 한 건 아닙니다.
useSuspenseQuery가 정식 출시되었고, useQuery의 역할이 더 분명해졌으며, 추가적으로 위의 이점들이 있어 선택하였습니다.

버전업 후 기존에 있던 Suspense 기능이 있던 useQuery를 모두 useSuspenseQuery로 바꾸고, 모든 컨벤션도 통일하였습니다.

@tanstack/query의 메인 기여자 중 한 분인 TkDodo께서 queryFunctionContext 패턴을 제안하시더라구요.
queryFn에 params이 계속 추가되면, 동시에 queryKey도 계속 추가해줘야하니 많이 활용하시는 것 같습니다.

사용법은 아래와 같습니다.

function Todos({ status, page }) {
  const result = useQuery({
    queryKey: ['todos', { status, page }],
    queryFn: fetchTodoList,
  })
}

// Access the key, status and page variables in your query function!
function fetchTodoList({ queryKey }) {
  const [_key, { status, page }] = queryKey
  return new Promise()
}

또한, 기획에 따라 어떤 useQuery, useMutation, useSuspenseQuery가 와도 서비스에 맞게 타입이 적용이 되도록
ApiResponse, ApiErrorResponse, SuspenseQueryResult, QueryResult, MutationResult, MutationOptions 등등을 커스텀 해 쉽게 Mutation, Query를 추가할 수 있도록 개선하였습니다.

모노레포 구조

저희는 터보레포를 활용하고 있습니다.
각 App, Package를 어떻게 효율적으로 분리하고 사용할지 많은 고민을 했었던 것 같아요.

결론적으로 두 개의 앱에서 함께 사용되는 것들 및 사용될 가능성이 있는 것들은 패키지로 분리하기로 했습니다.

초기 Packages

- packages
	- eslint
    - tsconfig
	- design-system
    - viewer
    - constants
    - utils
    - hooks
    - async-boundary
    - types
    - styles

처음엔 패키지가 정말 많았습니다.
패키지가 구조적으로 잘 나눠져 있는 것도 물론 좋지만, 실행할 때 --concurrency=13를 붙여줘야 되는 문제도 있고 굳이 공유되는 패키지의 개수를 늘릴 필요는 없다고 판단하였습니다.

이후엔 아래와 같이 패키지 구조를 수정하였습니다.

- packages
	- shared
   		- utils
        - types
        - constants
        - hooks
        - styles
	- design-system
    - async-boundary
    - viewer

eslint 및 tsconfig는 전역에 선언한 것들을 각 App, Packages 성격에 맞게 설정하도록 수정하였으며, shared라는 패키지를 만들어 각 App에서 정말 공통되는 속성들(또는 충분한 가능성이 있는) constants, utils, hooks, types, styles를 넣어줬습니다.

packages를 수정하면서 가장 유의했던 건 개발 생산성을 위해 자동으로 @/shared/* 로 import 추론이 되도록 수정하는 것이었습니다.

// ❌
import { getVelog, useVelog } from '@velog/shared';

// ❌
import { getVelog } from '@velog/shared/dist/utils';

// ✅
import { getVelog } from '@velog/shared/utils';
import { useVelog } from '@velog/shared/hooks';

처음엔 Package.json 설정에서 와일드카드 exports를 통해 해결해 보려 했지만, 관련 이슈를 찾아보니 Turbo 관련해서 매커니즘적으로 문제가 있는 것 같았습니다.

결국엔 각 App의 tsconfig에서 compilerOptions의 paths에서 와일드카드를 등록해 자동으로 추론되도록 수정하였습니다. 아마 더 좋은 해결책은 있겠지만, 해당 작업에 시간을 더 쏟을 수 없는 상황이라 임시적으로 해결하였습니다.

개발 환경

개발 환경에서 tsconfig, eslint와 같은 정적 코드 분석 도구의 컨벤션을 맞추는 것이 아주 중요하다고 생각합니다.

또한, 모노레포 구성에서는 더더욱 중요하다고 생각합니다. 모노레포로 띄어진 각 App, Package마다 컨벤션이 다르다면 그건 좋은 구조의 프로젝트는 아니라고 생각합니다.

Typescript + 모노레포 구조이고 browser, node 환경의 앱과 패키지가 있다면 각 tsconfig 세부적인 속성이 다를 것 같습니다.

이럴 땐 모듈화를 통해 생산적으로 활용할 수 있을 것 같아요.

Root에 browser, node 환경에서 공통되는 compilerOptions을 모아놓은 tsconfig.base.json을 두고 tsconfig.browser.json, tsconfig.node.json에서 tsconfig.base.json을 extends 해 본인 프로젝트에 맞게 커스텀 할 수 있을 것 같네요.

구조를 그려보면 아래와 같을 것 같습니다.

- apps
	- client
    	- tsconfig.json (browser extends)
- packages
    - shared
    	- tsconfig.json (browser extends) 
	- design-system
    	- tsconfig.json (browser extends) 
    - async-boundary
	    - tsconfig.json (browser extends) 
    - viewer
    	- tsconfig.json (node extends) 
- tsconfig.base.json
- tsconfig.browser.json (base extends + custom)
- tsconfig.node.json (base extends + custom) 

또한, root eslintrc.js또한 위처럼 base로 작업해 extends 하셔도 되고, 전체가 공통된다면 Root eslint에서 overrides 속성을 활용해 각 앱 및 패키지를 정의하셔도 좋을 것 같습니다.

Typescript

여러분들은 Typescript를 왜 사용하시나요?

저는 절대적으로 타입 추론이라고 생각합니다. 즉 추론되지 않도록 설계된 함수 및 컴포넌트는 개발 생산적인 측면에서 큰 의미가 없습니다.

즉, 좋은 구조를 가진 Typescript 프로젝트라면 기본적인 추론이 되어야한다고 생각합니다.

취업 준비생분들, 부트캠프 수강생분들과 멘토링을 하다 보면 함수형 컴포넌트의 Props의 타입을 지정해주는 건 기본적으로 잘 하지만, 같은 함수형인 hook이나 함수의 return 값을 명시하지 않는 분들이 많이 계십니다.

명시적으로 반환타입을 지정하면 타입 검사 속도를 증진할 수 있습니다. 다만 성능상 유의미한 검사 속도 증진은 아닐테지만, 추론은 물론 잘 되지만, 명시적으로 반환 타입을 지정하면 예상하지 못한 에러를 방지할 수 있으니 습관화하는 것이 좋을 것 같습니다.
✅ 모든 함수에 return 값을 명시하는 관련한 ESlint를 설정하고 적용해보세요!

interface Return {
  imageRef: React.RefObject<HTMLDivElement>;
  headingRef: React.RefObject<HTMLDivElement>;
  explanationRef: React.RefObject<HTMLHeadingElement>;
  boxRef: React.RefObject<HTMLDivElement>;
  heading: {
    heading: SectionText;
    setHeading: <T>(value: T) => void;
    changeHeadingWithKey: <V>(key: keyof SectionText, value: V) => void;
  };
  explanation: {
    explanation: SectionText;
    setExplanation: <T>(value: T) => void;
    changeExplanationWithKey: <V>(key: keyof SectionText, value: V) => void;
  };
  box: {
    box: Box;
    setBox: <T>(value: T) => void;
    changeBoxWithKey: <V>(key: keyof Box, value: V) => void;
  };
  image: {
    image: Image | undefined;
    setImage: <T>(value: T) => void;
    changeImageWithKey: <V>(key: keyof Image, value: V) => void;
  };
  options: {
    options: AboutUsOptions;
    setOptions: <T>(value: T) => void;
    changeOptionsWithKey: <V>(key: keyof AboutUsOptions, value: V) => void;
  };
}

마무리

최근 유행하는 FSD구조, 코드적으로도 사실 컴파운드 패턴, 헤드리스 패턴, 합성 컴포넌트 등 설명드릴 게 정말 많지만,
최근 진행한 프로젝트에서 많이 고민한 부분들을 담아 폴더 구조 및 코드를 설명해 보았습니다.

시작하며 말씀드린 것처럼 당연하게도 정답은 아닙니다.

긴 글 읽어주셔셔 감사합니다.
보시는 분들 모두 앞으로의 여정에서 더 큰 성과를 이루시길 응원하며 마무리하겠습니다.

profile
좋은 글을 쓰려 노력합니다. 제 경험이 누군가에게 도움이 되기를 바랍니다 🌊

13개의 댓글

comment-user-thumbnail
2024년 2월 18일

좋은 개발 경험을 공유해주셔셔 감사합니다.
저도 최근에 @tanstack/query 관련해서 구조적으로 고민이 많았는데, 많은 도움이 되었습니다 😃

1개의 답글
comment-user-thumbnail
2024년 2월 18일

선배님, 랜딩하고 제너레이터 등등에대해서 궁금한게있는데요,
랜딩은 화면에 랜더링하는 코드들을 중점으로둔거고,
제너레이터?코드는 뭔가를 생성,편집하는 코드들을 중점으로둔걸까요?

1개의 답글
comment-user-thumbnail
2024년 2월 19일

componenst -> components 오타가 있습니다.

1개의 답글
comment-user-thumbnail
2024년 2월 20일

안녕하세요. 저도 hook의 return타입을 지정하지 않는 많은 분들 중 한명입니다... 그런데 혹시 hook의 return타입은 자동으로 타입추론이 되던데 왜 정확하게 명시하여야 하는지 궁금합니다!

1개의 답글
comment-user-thumbnail
2024년 2월 29일

평소에 폴더 구조와 파일 관리에 대해 항상 고민하고 있지만 확실한 해답은 얻지 못했는데 힌트가 될 만한 내용이라 정말 기쁜 마음으로 읽어 나갔습니다.
좋은 글 작성해 주셔서 감사드립니다.

1개의 답글