Delightful React File/Directory Structure

2022.03.17
7 minutes read
615 views
thumbnail

https://www.joshwcomeau.com/react/file-structure/
이 글을 읽고 번역과 저의 생각을 담은 글입니다.

프로그래밍을 할 때 개인적으로 중요한 몇 가지 요소가 있습니다.
그 중에서 가장 많은 고민과 시간을 쓰지만 딱히 답이 안나오는 것이 바로 파일,디렉토리 구조입니다.

워낙 다양한 상황과 조건들이 있고, 적절한 구조를 선택하고 시작했다고 하더라도 파일이 한 두개 쌓이면서 더 나은 구조로 변경하면서 시작되는 리팩토링 굴레에 많은 시간을 소비하게 되는 경우가 있습니다.

항상 이 부분에 대해서 고민하고 있었는데 그러던 중 이 글을 읽었고 좋은 참고가 될 것 같아서 번역과 저의 짧은 생각을 작성해보려고 합니다.
(이 글의 저자인 Josh W. Comeau블로그를 운영중이며 https://css-for-js.dev 라는 교육 플랫폼을 운영하고 있습니다.)

know-how

리액트는 파일/디렉토리 구조와 관련하여 딱히 의견이 없는 것으로 알려져 있습니다.

애플리케이션에서 파일과 디렉토리를 어떻게 구성해야 될까요?

"옳은" 방법은 없습니다. 하지만 리액트를 7년 이상 사용하면서 많은 다양한 접근들을 시도해왔습니다. 그리고 정말 만족스러운 솔루션으로 나만의 방법을 반복해왔습니다.

이 글에서, 현재 저의 프로젝트에 사용되는 구조를 공유할 것입니다.

시작에 앞서

https://www.joshwcomeau.com/react/file-structure/#interactive-file-explorer

예제 소스

만들어진 파일 구조를 미리 보면서 차근차근 설명하는 방향으로 글이 진행됩니다.
(저자는 js파일을 이용했고, 저는 ts로 적용했습니다.)

src
├── components
│   ├── App
│   │   ├── App.tsx
│   │   └── index.tsx
│   ├── Header
│   │   ├── Header.tsx
│   │   └── index.tsx
│   └── Widget
│       ├── Widget.constants.ts
│       ├── Widget.helpers.ts
│       ├── Widget.tsx
│       ├── Widget.types.ts
│       ├── WidgetChild.tsx
│       ├── index.tsx
│       └── use-stuff.hook.ts
├── index.tsx

Priorities

어떤 것을 우선순위에 두고 구조를 최적화 했는지에 대해...

첫 번째는 component를 쉽게 import하기를 원했습니다. 그리고 아래와 같이 쓰기를 바랬습니다.

import Button from '../Button';

// Or, using aliases:
import Button from '@components/Button';
js

그 다음으로는 IDE에서 작업할 때 index.js이 넘쳐나는 것을 원하지 않습니다.
(아래와 같은 상단 표시줄은 한 번쯤은 경험해보지 않았을까 싶습니다.)

index.js-files

실제로는 대부분의 IDE는 동일한 파일이 열려있으면 상위 디렉토리를 포함해서 표시해줍니다만 각 탭은 훨씬 더 많은 공간을 차지합니다.

index.js-more-detail

저의 목표는 아래와 같이 멋지고 깔끔한 component 파일명을 갖도록 하는 것입니다.

image.png

마지막으로 구조적 측면에서 feature별이 아닌 function별로 정리하고 싶습니다.
components, hooks, helpers 폴더 등이 필요합니다.

대게 복잡한 component는 많은 파일들이 관련될 것입니다.

  • sub-components: main component만 사용하는 더 작은 components
  • Helper functions
  • Custom hooks
  • 관련된 파일과 component에서 공유되어 사용되는 Constants or Data

이제 예제로 만든 FileViewer component를 봅시다.

https://www.joshwcomeau.com/react/file-structure/#bonus-exploring-the-fileviewer-component

제가 Clone한 소스코드

  • FileViewer.tsx - main component
  • FileContent.tsx
  • Sidebar.tsx
  • Directory.tsx
  • File.tsx
  • FileViewer.helpers.ts

이상적으로는 이러한 모든 파일이 보이지 않는 곳에 숨겨져 있어야 합니다.
오직 FileViewer 컴포넌트에서 작업할 때만 필요합니다. 그래서 FileViewer에서 작업할 때 그들이 보여야만 합니다.

One component per file? (한 파일에 한 개의 component?)

오랫동안, ESLint rule은 한 파일에 하나 이상의 컴포넌트가 정의되면 그것에 경고를 했습니다.
제가 생각하기에 이건 silly(바보같은) rule 입니다. 파일에는 원하는 만큼 Component를 포함할 수 있어야 합니다.
다시말해, 기본 기능이 동작한다면 non-trivial component를 자체 파일로 가져와야 합니다.
이렇게 하면 구조를 정리하는데 도움을 줍니다. 그리고 imports/styles/등등, 어떤 컴포넌트에 의해 사용되는지를 분명하게 만들어줍니다.

The implementation

위에 얘기한 우선순위를 어떻게 반영하여 구현했는지 알아봅시다.

Components

src
└── components
    └── FileViewer
        ├── ChevronRight.tsx
        ├── Directory.tsx
        ├── Expander.tsx
        ├── File.tsx
        ├── FileContent.tsx
        ├── FileIcon.tsx
        ├── FileViewer.helpers.ts
        ├── FileViewer.tsx
        ├── FileViewer.types.ts
        ├── Sidebar.tsx
        └── index.tsx

FileViewer component에 필요한 파일들입니다. index.tsx를 제외하고 말이죠.

index.tsx를 열어보겠습니다.

export * from './FileViewer';
export { default } from './FileViewer';
tsx

기본적으로 redirection 입니다. 우리가 이 파일을 import할 때 같은 폴더 내 FileViewer.tsx를 가르키도록 합니다. FileViewer.tsxactual 코드를 가지고 있습니다.

index.js에 코드를 바로 작성하지 않습니까?

위에서 설명했다 싶이 IDE에서는 index.js로 가득차게 될 것입니다. 그것을 원하진 않습니다.

왜 모두 컴포넌트에서 이 파일을 가집니까?

import를 단순화하기 위해섭니다. 그렇지 않다면 component 폴더 안으로 drill 해야만 합니다.

import FileViewer from '../FileViewer/FileViewer';
tsx

index.js를 통한다면,

import FileViewer from '../FileViewer';
tsx

왜 이렇게 사용합니까?

FileViewer는 폴더입니다. 그리고 우리가 폴더를 import 하려면, 번들러는 index file을 찾을 것입니다. 이것은 web server 에서 계승된 convention입니다.(my-website.comindex.html을 자동적으로 load 합니다. 그래서 my-website.com/index.html 처럼 쓰지 않아도 됩니다.)

사실, HTTP request 처럼 생각하는 것이 도움이 된다고 생각합니다. src/components/FileViewer를 import할 때, 번들러는 폴더를 가져오고 자동적으로 index.js를 load할 것입니다. index.js301 REDIRECT를 수행하는 것처럼 src/components/FileViewer/FileViewer를 가르킬 것입니다.

이것이 over-engineer된 것처럼 보일 수 있습니다. 그러나 이 구조는 제 모든 폴더에 있고, 저는 그것을 좋아합니다.

Hooks

hook이 특정한 component에서 사용한다면, 저는 위 구조를 유지할 것입니다. 그러나 만약 hook이 공통적으로 사용된다면 어떻게 할까요?

공통적으로 사용되고, 재사용가능한 hook은 src/hooks 폴더에 정의해두고, 다음과 같이 사용하고 있습니다.

src
└── hooks
    ├── use-boop.hook.js
    ├── use-bounding-box.hook.js
    ├── use-effect-on-change.hook.js
    ├── use-has-mounted.hook.js
    ├── use-is-onscreen.hook.js
    ├── use-throttled-state.hook.js
    ├── use-window-demensions.hook.js
    └── use-worker.hook.js

Naming convention YOLO policy (내 맘대로 규칙)

위에서 보았듯이 제가 2가지 규칙을 정한 것을 알 수 있습니다.

  1. camelCase 대신에 kebab-case를 사용합니다.
  2. 각 파일 명 끝에 .hook을 붙힙니다.

솔직히 이 결정에 딱히 이유가 있지는 않습니다. 단지 보기 좋아서 이렇게 사용합니다.
use-thing.hook.js 대신에 useThing.js를 사용하는 것이 더 좋을 수도 있습니다. 다른것도 다 괜찮습니다.
파일 이름에 당신이 어떠한 convention을 사용하든 전혀 문제가 되지 않습니다.

Helpers

특정 component에 직접 연결되어 있지 않고 프로젝트 일부 목표를 달성하는 데 도움이 되는 function이 있으면 어떻게 될까요?

특정 component에서 사용하다가 공통적으로 사용된다면 위 hooks처럼 한 곳으로 이동시킵니다.

FileViewer/FileViewer.helpers.js에서 사용되었지만 여러 곳에서 필요하다는 것이 나타나면 src/helpers로 이동하여 category.helpers.js와 같은 이름으로 옮깁니다.

Utils

먼저 한 가지 설명이 필요합니다.

많은 개발자들은 "helpers"와 "utils"를 동일한 것으로 다룹니다. 그러나 저는 그 2개를 구분합니다.

Helper는 주어진 프로젝트에 특정한 어떤것입니다. 이것은 일반적으로 다른 프로젝트와 공유되어 사용될 수 없습니다.

Utility는 추상화된 작업을 수행하기 위한 공통 function 입니다. 제 정의에 따르면 lodash 라이브러리의 모든 function이 utility입니다.

왜 lodash와 같은 utility library를 사용하지 않나요?

제가 쉽게 만들 수 없는 것들은 가끔 사용하기도 합니다. 그러나 제가 필요한 모든 utility가 library에 있는 것은 아닙니다.

몇 가지 src/utils.js에 사용되는 것이 있습니다. 새 프로젝트를 만들 때 파일을 복사합니다. 프로젝트 간 일관성을 보장하기 위해 NPM에 publish할 수 있지만 상당한 마찰을 추가하고 저에게 가치있는 trade-off가 아닙니다. 언젠가 될 수 있지만 아직은 아닙니다.

Constants

마지막으로 저는 contants.js 파일을 가집니다. 이 파일은 앱 전반의 constants를 가집니다. 대부분은 스타일과 관련된 것들입니다. 그러나 public key와 다른 "app data"를 저장하기도 합니다.

Pages

"pages"와 관련해서는 여기서 다루지 않았습니다.

저는 이 section을 뺐습니다. 왜냐하면 그것은 사용되는 tool에 의존하고 있는 경우가 많기 때문입니다.

create-react-app과 같은 것을 사용할 때, pages를 가지지 않습니다. 모두 components 폴더 아래에 있습니다.

그러나 Next.js를 사용할 땐, /src/pages를 사용해야합니다. 각 라우트를 위해서 정해진 structure를 사용해야 합니다.

Tradeoffs

모든 전략은 장단점을 가집니다. 여기서 언급된 구조에 몇 가지 단점에 대해서 말해보겠습니다.

More boilerplate

하나의 컴포넌트를 만들 때마다 아래와 같은 파일을 생성해야합니다.

  • Widget/ 폴더
  • Widget/Widget.js 파일
  • Widget/index.js 파일

코드를 작성하기 전에 해야할 일이 많습니다.

운이 좋게도 수동으로 하지 않아도 됩니다. 저는 NPM에 new-component 을 publish했습니다. 그리고 이것은 자동으로 위 파일을 생성해줄 것입니다.

new-component

이 cli를 이용해 boilerplate를 만들 수 있습니다.
자신에 맞는 convention을 사용하기 위해 이 package를 fork하는 것을 환영합니다.

Organized by function

일반적으로 구조화하는데 널리 사용되는 2가지 방법이 있습니다.

  • By function (components, hooks, helpers ...)
  • By feature (search, users, admin ...)

아래는 feature로 구조화된 코드 예시입니다.

src/
├── base/
│   └── components/
│       ├── Button.js
│       ├── Dropdown.js
│       ├── Heading.js
│       └── Input.js
├── search/
│   ├── components/
│   │   ├── SearchInput.js
│   │   └── SearchResults.js
│   └── search.helpers.js
└── users/
    ├── components/
    │   ├── AuthPage.js
    │   ├── ForgotPasswordForm.js
    │   └── LoginForm.js
    └── use-user.hook.js

여기에서 장점은 high-level의 views와 pages에서 low-level의 재사용한 "component library"를 구분하는 것을 가능하게 해줍니다. 또한 앱이 어떻게 구성되어 있는지 빠르게 이해할 수 있습니다.

그러나 여기에 문제가 있습니다: 실제로는 이와 같이 아주 좋게 구분하기 어렵습니다. 그리고 categorization하는 것은 실제 정말로 어렵습니다.

제가 이 구조를 가진 몇 가지 프로젝트에서 일을 했습니다. 그리고 매 번 중요한 마찰의 원인들 이었습니다.

컴포넌트를 만들 때 마다, component가 어디에 속하는지를 결정해야 합니다.
만약 특정 사용자를 검색하는 search component를 만든다면 그것은 "search"에 포함되어야 할까요? "users"에 포함되어야 할까요?

경계가 모호하고 다른 개발자들은 동일한 것에 대해서도 다른 결정을 할 것입니다.

새로운 feature에서 일을 시작할 때 그 파일들을 찾아야 하는데 파일이 예상했던 위치에 없을 수도 있습니다.
그 프로젝트에 있는 모든 개발자들은 어디로 가야하는지에 대한 자신들의 관념 모델을 가지고 있으며 그들의 관점에 적응하는 데 시간을 소비해야 합니다.

그런데 진짜 중요한 문제가 있습니다: refactoring

제품은 항상 발전하고 바뀝니다. 우리가 오늘 feature로 구분한 경계는 내일은 말이 안될 수도 있습니다. 제품이 변경되면 모든 파일을 이동하고 이름을 바꾸고 제품의 다음 버전과 조화를 이루도록 모든 항목을 재분류하는 데 많은 작업이 필요합니다.

현실적으로, 그 일은 사실상 끝나지 않을 것입니다.
많은 문제가 있습니다; 팀은 이미 작업을 진행 중이며, 우리가 모든 파일을 이리저리 옮기면 더 이상 존재하지 않을 파일들에 반쯤 완성된 PRs이 있습니다. 이러한 갈등을 관리하는 것은 가능하지만 큰 고통입니다.

결국, 제품 feature와 코드 feature 사이의 거리는 더욱더 멀어질 것입니다. 결국, 코드베이스에 있는 feature는 더 이상 존재하지 않는 제품을 중심으로 개념적으로 구성될 것입니다. 그래서 모든 구성원은 단지 어디로 가야하는 지를 기억해야만 합니다. 나누어진 경계는 직관적이 아닌 기껏해야 완전히 임의적이고 최악의 경우 오해를 가져옵니다.

사실상, 이 최악의 시나리오를 피하는 것은 가능하지만 상대적으로 적은 이익을 위해 많은 추가 작업이 필요하다 라는게 제 의견입니다.

그러나 대안이 너무 혼란스럽지 않을까요? 대규모 프로젝트에 수천 개의 React 컴포넌트가 있는 것은 드문 일이 아닙니다. function-based 접근을 사용한다면, src/components 내에 나란히 배치되지 않은 방대한 집합을 가진다는 것을 의미합니다.

이것은 big deal처럼 들릴 수도 있습니다. 그러나 사실, 저는 작은 비용이라고 느낍니다.
적어도 어디를 봐야하는지 정확이 알 수 있습니다. 원하는 파일을 찾기 위해 수십 가지 feature를 검색할 필요가 없습니다. 그리고 각각의 새 파일을 어디에 둘 것인지 파악하는데 0초가 걸립니다.

Webpack aliases

"@helpers": "./src/heleprs"

번들러를 사용할 때 alias를 사용할 경우가 있습니다.
(Webpack이 가장 많이 사용되기에 예시를 든 것 같습니다.)

Webpack은 aliases 를 만들어 쓸 수 있게 해줍니다.

// This:
import { sortCategories } from '../../helpers/category.helpers';

// …turns into this:
import { sortCategories } from '@helpers/category.helpers';
js

어떻게 동작하는지: @helpers/src/helpers 폴더를 alias 합니다.
파일을 이동할 때 import 경로를 수정할 필요가 없습니다.
(요지는 generic function폴더들을 alias해서 쓴다는 것 같습니다. 편한대로 사용하면 될 것 같습니다.)

Alias tradeoffs

모든 좋은 것들과 마찬가지로 Webpack aliases도 장단점을 가집니다.

가장 큰 이슈는 표준이 아니라는 것입니다. native javascript import에서 벗어나 custom하게 작업합니다. 이것은 당신을 lock 합니다.

또한 third-party 서비스들과 함께 사용하는 것을 더 어렵게 만들 수 있습니다.
Storybook으로 컴포넌트 라이브러리를 구축하거나 Jest로 단위 테스트를 하려면 webpack aliase를 이해하도록 이러한 tool을 구성되어야 합니다.
대부분의 경우, 이것이 가능하지만 알아내기 위해 까다로울 수 있습니다.

또한, 일부 에디터는 자동 완성에 어려움을 겪을 수 있지만 이 역시 일반적으로 구성할 수 있습니다. VSCode의 경우 alias를 jsconfig.json에서 구성할 수 있습니다.

{
  "exclude": ["./node_modules"],
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@components/*": ["./src/components/*"],
      "@constants": ["./src/constants/index.js"],
      "@helpers/*": ["./src/helpers/*"],
      "@hooks/*": ["./src/hooks/*"],
      "@utils": ["./src/utils/index.js"]
    }
  }
}
json

마무리하며

저자 마무리:

제가 어떻게 React 구조를 잡는지에 대한 내용이었습니다.

위에서 언급했듯이, 파일 구조를 다루는데 옳고 그른 방법은 없습니다. 모든 접근 방식은 서로 다른 우선 순위를 지정하고 서로 다른 tradeoff를 만듭니다.

그러나 개인적으로 이 구조가 잘 맞았습니다. 저는 제 시간을 제가 좋아하는 것(좋은 user interface를 만드는 것)에 쓸 수 있었습니다.

React는 매우 즐겁습니다. 2015년 이후부터 쭉 사용하고 있고 React로 작업할 때는 여전히 즐겁습니다.

React(다른 프론트엔드 프로젝트를 할 때도) 프로젝트를 하면서 파일 구조 잡는게 항상 힘들고 어려운 것 같습니다.

이 구조와 비슷하게 사용하고 있었지만 매 번 조금씩 다르고 리팩토링 할 때도 고통을 겪은 것 같아 이 글이 저 스스로에게 조금은 도움이 되었으면 좋겠습니다.

이 글이 정답이라는 것은 절대 아닙니다. 저자가 말했듯이 옳고 그른 방법은 없습니다.
이 구조가 참고가 되어 더 즐거운 개발을 할 수 있으면 좋겠습니다.

제가 이 글에서 Clone하여 만든 파일 구조 예시 프로젝트는 여기서 확인하시면 됩니다.

reference