nextjs 파일구조

joDMSoluth·2020년 8월 7일
35

react

목록 보기
1/5

React는 프레임워크와 라이브러리의 중간 단계인 만큼 사람들마다 파일구조가 다르다. 따라서 리액트로 협업을 할 때에는 Vue나 Angular보다 팀간의 코딩 컨벤션이 중요하다. 이번 게시물에서는 내가 주로 쓰는 Nextjs의 파일구조에 대해서 설명하도록 하겠다.

파일구조

// 어플리케이션에 사용되는 정적 파일들
public 						
├── image
├── css
└── video

// 어플리케이션의 기능에 사용되는 소스들
src                        			 
├── component                     // 컴포넌트와 컨테이너 파일들
│     ├── common                 // 공통적으로 사용되는 컴포넌트들(ex button, modal)
│     └── layout                 // 각 페이지들의 전체 레이아웃이자 컴포넌트들의 모임
│            ├── app
│            ├── home
│            └── directory
│		                  
│				
├── pages                         // 페이지를 담당하는 컴포넌트(폴더구조로 url 결정)
│	  └── [id]
│
├── core  
│     ├── config                 // 어플리케이션에서 사용되는 설정들 모임
│     ├── provider               
│     │     ├── helper          // store를 이용하는 공통 로직들 정리
│     │     └── provider        // store의 값들을 컨테이너에게 전달해 줌
│     ├── redux                  // Ducks 패턴으로 redux를 정의
│     └── api                    // axios를 이용하여 서버와의 통신 로직을 담당
│
└── util                          
       ├── hooks                 // 각 컴포넌트에서 사용되는 공통 로직들을 커스텀 훅으로 관리
	   └── reduxUtils            // redux ducks 패턴에서 사용되는 유틸함수

우리는 흔히 MVC 패턴이라고 하여 파일구조를 Model, View, Controller로 만들어서 많이들 사용한다. 나도 위의 MVC 패턴에 영감을 받아 위와 같은 파일 구조를 즐겨서 사용한다.

위 파일 구조에서 Model은 core - redux 폴더에서 담당한다. 여기서 redux는 우리가 어떤 컴포넌트에 위치해 있든 접근 가능한 어플리케이션의 공동 저장소라고 볼 수 있다.

Controller는 core - provider가 담당한다. redux store에 저장되어 있는 값들을 우리는 provider를 이용하여 각 컴포넌트에 전달을 하는 역할을 한다.

잠깐! 우리는 한가지 의문점이 생긴다. hooks을 사용하면 provider를 사용하지 않아도 어디서든 redux에 저장되어 있는 값들을 사용하지 않는가? 이 의문점은 아래에서 같이 해결해 보도록하자.

View는 component 폴더에서 담당한다. State와 Props를 이용하여 화면에 보이는 부분을 담당한다.

각 폴더들의 설명

component 폴더

기존에 우리가 Class Component를 주로 사용했을 때에는 Dump Component(이하 Conponenet), Smart Component(이하, Container) 패턴으로 나누어서 State를 담당하는 컴포넌트는 Smart Component로 이름짓고 순수하게 화면에 보이는 것만 담당하는 컴포넌트를 Dump Component라 하였다. 그래서 예전에는 파일구조를 Containers와 Compoenets 둘로 나누어서 관리한 것을 많이 보았을 것이다.
하지만 React hooks가 유행하면서 이제 이러한 패턴은 많이 선호하지 않는다. Class Component에서 Redux를 사용하려면 connect(mapStateToProps, mapDispatchToProps)(Component) 같은 함수를 이용해서 직접 Conainer에 매핑을 해주어야 했었다. 하지만 Redux도 Hooks를 채용하면서 useSelector와 useDispatch를 이용해서 어떤 컴포넌트에서든 쉽게 접근이 가능하기 때문에 굳이 Redux 값들을 Container에서 받아 Component에 뿌려줄 필요가 없는 것이다. 그래서 나는 Containers와 Componenets 들을 합쳐서 하나 폴더로 만들었다.

하위 폴더로는 대표적으로 common 폴더, layout 폴더가 있는데 common 폴더에는 Modal, Button, Select 등과 같은 어디서든 많이 활용되는 작은 단위의 공통 컴포넌트가 들어간다. layout폴더에는 각 페이지에서 사용하는 레이아웃을 담당하는 컴포넌트가 들어간다. layout 폴더의 하위폴더로는 app폴더가 있는데 app 폴더에는 어플리케이션에서 공통적으로 사용되는 Header.js, Footer.js, Nav.js 등이 들어간다. 또한 하위폴더로 각 페이지 별로 폴더를 만들어서 그 페이지에 사용되는 레이아웃을 정의한다. (ex, home, directory 등)

그리고 특정한 곳에서만 사용되거나 재사용 되기 어렵거나 좀 복잡한 컴포넌트를 만들 때에는 나만의 컨벤션을 따르며 각각폴더를 만들어서 관리한다. 각 폴더에는 모양을 담당하는 index.js파일 그리고 각 모양의 로직을 담당하는 파일(Login.js)로 크게 두가지로 나뉜다. 추가로 세부로직이 필요하다면 따로 파일을 만들어서 관리하자, 이에 대한 설명으로 컴포넌트 하나를 만들어 보자
나는 작은 컴포넌트를 만들 때 Compound Component라는 패턴을 자주 사용하는데 가독성 면에서 아주 뛰어난 패턴이다. ant design에서 자주 사용되는 패턴인데 ant design의 <Form.Input>이라는 컴포넌트를 써봤다면 아주 익숙할 것이다. 예시로 Login 컴포넌트 살펴보자

Component/login/index.js

import React from 'react';
import Login from './Login';

export default function LoginLayout() {
  return (
    <>
      <Login>
        <Login.Title>Register / Log in</Login.Title>
        <Login.Text>
          매일 300개 채널에서 큐레이션 되는 디자인 콘텐츠를
          <br />
          가장 간편하게 받아보실 수 있어요.
        </Login.Text>
        <Login.Button />
      </Login>
    </>
  );
}

위의 코드에서는 login component의 모양을 알려주는 것을 담당한다. 한번만 보면 어떤 모양의 컨포넌트인지 바로 알 수 있게 네이밍 하는 것이 중요하다

Component/login/Login.js

import React, { useMemo } from 'react';
import PropTypes from 'prop-types';
import styled from 'styled-components';
import Paragraph from '../paragraph/Paragraph';
import WithLoginButton from './button/WithLoginButton';
import GoogleLoginButton from './button/GoogleLoginButton';
import GithubLoginButton from './button/GithubLoginButton';

export default function Login({ children, ...props }) {
  return <S.Container {...props}>{children}</S.Container>;
}

Login.Title = function LoginTitle({ children, ...props }) {
  const style = useMemo(() => ({ fontSize: '40px', textAlign: 'center' }), []);
  return (
    <Paragraph type="home_card" title style={style} {...props}>
      {children}
    </Paragraph>
  );
};

Login.Text = function LoginText({ children, ...props }) {
  const style = useMemo(() => ({ fontSize: '15px', textAlign: 'center' }), []);
  return (
    <Paragraph type="home_card" content style={style} {...props}>
      {children}
    </Paragraph>
  );
};

Login.Button = function LoginButton() {
  return (
    <S.ButtonWrap>
      <WithLoginButton provider="GOOGLE">
        {(props) => <GoogleLoginButton {...props} />}
      </WithLoginButton>
      <WithLoginButton provider="GITHUB">
        {(props) => <GithubLoginButton {...props} />}
      </WithLoginButton>
    </S.ButtonWrap>
  );
};

Login.propTypes = {
  children: PropTypes.node.isRequired,
};
Login.Title.propTypes = {
  children: PropTypes.string.isRequired,
};
Login.Text.propTypes = {
  children: PropTypes.array.isRequired,
};

const S = {};

S.Container = styled.div`
  width: 36.25rem;
  height: 23.625rem;
  padding: 4.5rem 4rem;
`;

S.ButtonWrap = styled.div`
  padding-top: 25px;
  display: flex !important;
  justify-content: space-evenly !important;
`;

위의 코드는 LoginLayout에서 보여주었던 각 컴포넌트들의 로직을 정의한다. Login.Button에서는 세부적인 로직이 또 따로 필요하여 새로 파일을 만들어 주었고 로직에 대한 정의가 끝나면 props-types에 대한 정의와 props의 default value를 기술해주고 마지막으로 styled-component에 대한 내용을 적어준다. 나는 styled-component 코드를 적으로 때 꼭 const S = {}라는 객체를 만들어서 S 객체에 컴포넌트를 담는데 이렇게 하는 이유는 보는 사람들에게 <S.Component> 와 같이 앞에 S가 들어가면 styled-component로 만든 컴포넌트라는 것을 알려주기 위함이다.

추가로) 나는 위의 Component들의 공통 로직들을 Custom Hooks으로 만들 것이다. Custom Hooks에 대한 설명은 나중에 하도록 하겠다.

pages 폴더

Nextjs에서는 Routing 시스템이 파일구조로 되어있다. 따라서 Nextjs를 사용한다면 pages 폴더에는 파일 이름과 구조에 _document.js, _app.js 등과 같이 나름대로 컨벤션이 존재한다.
아래에 설명할 코드들의 내용은 그다지 중요하지 않다. 각각의 파일들이 어떤 기능을 하는 파일인지만 이해하고 넘어가자

_app.js

import React from 'react';
import PropTypes from 'prop-types';
import { Normalize } from 'styled-normalize';
import Head from 'next/head';
import wrap from '../core/config/redux';
import { GlobalStyle } from '../util/GlobalStyle';

// 각 페이지(index.js)의 return 부분이 Component에 들어간다.
const App = ({ Component }) => (
  <>
    <Head>
      <meta charSet="utf-8" />
      <meta
        name="viewport"
        content="width=device-width, initial-scale=1.0, shrink-to-fit=no"
      />
      <title>Node Lab</title>
    </Head>
    <Component />
    <GlobalStyle />
    <Normalize />
  </>
);

App.propTypes = {
  Component: PropTypes.elementType.isRequired,
};

export default wrap.withRedux(App);

먼저 _app.js는 각 페이지별로 공통적으로 쓰는 부분에 대한 리펙토링을 해주는 곳이라고 생각하면 된다. props로 Component라는 걸 받는데 이게 각 페이지에서 리턴하는 컴포넌트라고 생각하면 된다. 아래의 index.js 코드에서 보면

    <>
      <AppLayout>
        <Head>
          <title>Node Lab |</title>
        </Head>
        <HomeLayout />
      </AppLayout>
    </>

를 받는 것이다. 이곳에서는 모든 페이지에서 쓰는 스타일, 레이아웃, 메타데이터 등을 넣어주면 된다. create-react-app을 써봤다면 src파일의 index.js 정도로 생각하면 된다.

pages/index.js

import React from 'react';
import Head from 'next/head';
import { END } from 'redux-saga';
import axios from 'axios';
import wrapper from '../core/config/redux';
import AppLayout from '../components/layout/AppLayout';
import HomeLayout from '../components/layout/crawl/HomeLayout';

const Home = () => {
  return (
    <>
      <AppLayout>
        <Head>
          <title>Node Lab |</title>
        </Head>
        <HomeLayout />
      </AppLayout>
    </>
  );
};

export const getServerSideProps = wrapper.getServerSideProps(
  async (context) => {
    // 프론트 서버에 쿠키 넣어주기
    console.log('getServerSideProps start');
    console.log(context.req.headers);
    const cookie = context.req ? context.req.headers.cookie : '';
    // 브라우저에서 받은 요청에 쿠키가 있을 때만 넣어주기
    axios.defaults.headers.Cookie = '';
    if (context.req && cookie) {
      axios.defaults.headers.Cookie = cookie;
    }
    context.store.dispatch(
      login.request({ eamil: 'email', password: 'password' }),
    );
    dispatch가 끝났음을 알려줌
    context.store.dispatch(END);
    console.log('getServerSideProps end');
    // saga의 비동기 이벤트 설정
    await context.store.sagaTask.toPromise();
  },
);

export default Home;

앞서 Nextjs가 폴더구조 방식으로 routing을 진행한다고 말했다. 이게 무슨 뜻인가 하니 만약 pages/directory/[id].js 라는 파일이 있다고 하자. 이 파일에 접근하기 위해서 url에 http://localhost:3000/directory/12345678 이라고 쳤다면 directory 페이지에 접근할 수 있다. 그렇다면 [id].js라는 파일이름은 무슨 뜻일까? pages/directory/[id].js 파일에서

import React from 'react';
import { useRouter } from 'next/router';

export default function DirectoryPage() {
  const router = useRouter();
  const { filter } = router.query;
  // filter에는 위에 url에서 넣은 12345678이 string으로 들어가있다.
  return (
    <>
       <div>{filter}</div>
    </>
  )

다음과 같이 id를 이용한 동적 라우팅을 사용할 수 있다.

_document.js

import React from 'react';
import Document, { Html, Head, Main, NextScript } from 'next/document';
import { ServerStyleSheet } from 'styled-components';

export default class MyDocument extends Document {
  // styled-components를 nextjs에 적용 자세한 내용 styled-compoenets doc 참고
  static async getInitialProps(ctx: any) {
    const sheet = new ServerStyleSheet();
    const originalRenderPage = ctx.renderPage;

    try {
      ctx.renderPage = () =>
        originalRenderPage({
          enhanceApp: (App: any) => (props: any) =>
            sheet.collectStyles(<App {...props} />),
        });

      const initialProps = await Document.getInitialProps(ctx);
      return {
        ...initialProps,
        styles: (
          <>
            {initialProps.styles}
            {sheet.getStyleElement()}
          </>
        ),
      };
    } finally {
      sheet.seal();
    }
  }

  render() {
    return (
      <Html>
        <Head />
        <body>
          <Main />
          <script src="https://polyfill.io/v3/polyfill.min.js?features=es6,es7,es8,es9,NodeList.prototype.forEach&flags=gated" />
          <NextScript />
        </body>
      </Html>
    );
  }
}

_document 파일은 index.html정도라고 보면 된다. 즉, 어플리케이션의 구조를 만들어 주는 파일이다. 위에 코드에서는 polyfill과 styled-component, nextjs에서 만들어주는 스크립트들을 적용시켜주었다.

core 폴더

core 폴더에는 MVC에서 View를 제외한 Model과 Controller에 대한 코드를 관리하는데 중점을 두었다.

config 폴더

config 폴더는 Nextjs 어플리케이션에 이용되는 환경변수 뿐 아니라 redux, axios 등과 같이 어플리케이션 내에서 핵심적으로 동작하는 라이브러리에 대한 설정을 관리하는 폴더이다.

api 폴더

서버로의 요청을 보내기 위한 로직들을 정리한 폴더이다 주로 axios에서 사용되는 fetcher나 redux saga에서 사용하는 api들을 정의해 놓는다.

redux 폴더

redux 폴더는 src/utils/redux/reduxUtils.js 에서 정의한 파일들을 이용하여 ducks 패턴으로 만들었다.

src/util/reduxUtils.js

import { createAction } from 'redux-actions';
import { call, put } from 'redux-saga/effects';

// 비동기 액션 타입명 생성기
export const asyncActionCreator = (actionName) => {
  const asyncTypeAction = ['_REQUEST', '_SUCCESS', '_FAILURE'];
  return {
    REQUEST: actionName + asyncTypeAction[0],
    SUCCESS: actionName + asyncTypeAction[1],
    FAILURE: actionName + asyncTypeAction[2],
  };
};

// 비동기 액션 생성기
export const createAsyncAction = (asyncAction) => {
  return {
    request: createAction(asyncAction.REQUEST),
    success: createAction(asyncAction.SUCCESS),
    failure: createAction(asyncAction.FAILURE),
  };
};

// 사가 제너레이터 생성기
export default function createAsyncSaga(asyncAction, asyncFunction) {
  return function* saga(action) {
    try {
      const result = yield call(asyncFunction, action?.payload); // api 호출 이때 파라미터는 request()에서 받은 값으로 전달
      console.log('result : ', result);
      yield put(asyncAction.success(result)); // success  액션함수를 dispatch 하여 api결과값 반환
    } catch (e) {
      console.log(
        '=====================================error========================================',
      );
      yield put(asyncAction.failure({ error: '' })); // failure  액션함수를 dispatch 하여 error 반환
    }
  };
}

src/core/redux/auth.js

import { produce } from 'immer';
import { handleActions } from 'redux-actions';
import { takeEvery } from 'redux-saga/effects';
import createAsyncSaga, {
  asyncActionCreator,
  createAsyncAction,
} from '../../util/redux/reduxUtils';
import { getCurrentUserApi, loginApi } from '../api/saga/auth';

// 0. 더미 데이터
const dummyMyInfo = {
  name: '홍길동',
  age: 27,
};

// 1. 각 모듈별 함수 구분을 위한 prefix 각 모듈 파일명 + '/' 의 조합으로 구성합니다.
const prefix = 'auth/';

// 2. 액션타입에 대해서 정의합니다.
const LOG_IN = asyncActionCreator(`${prefix}LOG_IN`);
const GET_CURRENT_USER = asyncActionCreator(`${prefix}GET_CURRENT_USER`);

const LOG_OUT = `${prefix}LOG_OUT`;
const UNLOAD_USER = `${prefix}UNLOAD_USER`;

// 3. 액션함수에 대해서 정의합니다.
export const login = createAsyncAction(LOG_IN);
export const getCurrentUser = createAsyncAction(GET_CURRENT_USER);

// 4. saga 비동기 관련 함수가 필요할 경우 작성 합니다. (optional) saga함수들의 모음은 최하단에 나열합니다.
const loginSaga = createAsyncSaga(login, loginApi);
const getCurrentUserSaga = createAsyncSaga(getCurrentUser, getCurrentUserApi);

// 5. 초기 상태 정의
const initialState = {
  data: null,
  loading: false,
  error: null,
};

// 6. 리듀서 정의
export default handleActions(
  {
    [LOG_IN.SUCCESS]: (state, action) =>
      produce(state, (draft) => {
        // draft.data = action.payload;
        draft.data = action.payload;
        draft.loading = false;
      }),
    [LOG_IN.FAILURE]: (state, action) =>
      produce(state, (draft) => {
        // draft.error = action.payload;
        draft.error = null;
        draft.loading = false;
      }),
    [LOG_OUT]: (state, action) =>
      produce(state, (draft) => {
        // draft.data = action.payload;
        draft.data = null;
        draft.loading = false;
      }),
    [GET_CURRENT_USER.SUCCESS]: (state, action) =>
      produce(state, (draft) => {
        // draft.data = action.payload;
        draft.data = null;
        draft.loading = false;
      }),
    [GET_CURRENT_USER.FAILURE]: (state, action) =>
      produce(state, (draft) => {
        // draft.error = action.payload;
        draft.error = null;
        draft.loading = false;
        draft.data = dummyMyInfo;
      }),
    [UNLOAD_USER]: () => initialState,
  },
  initialState,
);

// 7. `4`번에서 작성한 saga함수들에 대해 구독 요청에 대한 정의를 최하단에 해주도록 합니다.
export function* authSaga() {
  yield takeEvery(LOG_IN.REQUEST, loginSaga);
  yield takeEvery(GET_CURRENT_USER.REQUEST, getCurrentUserSaga);
}

Redux를 사용하는 어플리케이션을 구축하다 보면 기능별로 여러 개의 액션 타입과, 액션, 리듀서 한 세트를 만들어야 한다. 이들은 관습적으로 여러 개의 폴더로 나누어져서, 하나의 기능을 수정할 때는 이 기능과 관련된 여러 개의 파일을 수정해야 하는 일이 생긴다. 여기서 불편함을 느껴 나온 것이 Ducks 구조이다.

덕스 구조는 다음과 같은 순서로 이루어진다.

1. 필요한 것들을 불러오기
2. 액션을 정의
3. 액션 크리에이터 정의
4. 리듀서 정의
5. 리듀서 함수 만들기
6. Export action Created
7. Export reducer

출처: https://hoony-gunputer.tistory.com/170 [후니의 컴퓨터]

위에 방식은 기존의 방식이고 나는 위 방식에서 추가로 redux saga도 포함시켜서 ducks패턴으로 만들었다. 그래서 위에 기존방식에서 아래 3단계를 추가하였다.

2-1. 비동기 액션 정의
3-1. 비동기 액션 크리에이터 정의
8. Saga 함수 구독시키기

내가 reducer를 정의할 때 즐겨 사용하는 모듈이 있는데 그 모듈은 바로 immer와 redux-actions 모듈이다. immer는 reducer에서 지켜져야 되는 불변성 속성을 좀 더 쉬운 방식으로 지킬 수 있고 redux-actions는 액션 크리에이터를 좀더 쉽게 정의하고 reducer에서 보기 싫은 switch를 사용하지 않고도 reducer를 정의할 수 있다.

추가로 redux-actions는 타입스크립트에서는 사용할 수 없으니 주의하도록 하자

provider 폴더

core/provider/authHelper.js

import Router from 'next/router';

// 토큰을 쿠키로 저장
const saveTokenInCookies = (cookieName, value, days) => {
  const exdate = new Date();
  exdate.setDate(exdate.getDate() + days);

  // 혹시 한글 쓸까봐
  const cookieValue = escape(value);
  document.cookie = `${cookieName} = ${cookieValue}`;
};

// 쿠키 삭제
const removeTokenFromCookies = () => {
  saveTokenInCookies('userInfo', '');
};

// 로그아웃
const logout = () => {
  removeTokenFromCookies();
  Router.reload();
};

export { logout, saveTokenInCookies };

core/provider/authProvider.js

import React, { createContext, useContext } from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';

import { logout } from './authHelper'

const AuthContext = createContext([{ data: null }, logout]);

const AuthProvider = ({ children }) => {
  const { data, error } = useSelector((state) => ({
    data: state.auth.data,
    error: state.auth.error,
  }));

  console.log('auth data', data);

  if (error) {
    return (<LoginPage />);
  }

  return (
    <AuthContext.Provider value={[{ data }, logout]}>
      {children}
    </AuthContext.Provider>
  );
};

AuthProvider.propTypes = {
  children: PropTypes.node.isRequired,
};

const useAuth = () => useContext(AuthContext);

export { AuthProvider, useAuth };

provider는 특별한 폴더이다. 원래대로 라면 추가하지 않아도 되는 폴더임에 틀림없다. provider가 사용되는 되는 예는 다음과 같다.

1. 인증로직
2. 서버로부터 받는 정보를 관리하지 않는 reducer의 props drills를 막기 위해
3. reducer에서 받은 정보들에 따라 사전 처리가 필요한 경우 (예, 유저 데이터가 없으면 로그인 페이지로 이동하자)

provider-pattern-with-react-context-api 참고

provider에는 다음과 같은 장점이 있습니다.
1. 테스팅하기 쉽다
2. reducer에서 받은 정보를 이용한 로직의 리팩토링이 쉽다.
3. hooks로 재사용 가능이 가능하다
4. 서버부터 받는 정보를 관리하지 않는 reducer를 redux를 사용하지 않고 관리할 수 있다. redux와 별개로 사용 가능하다

나는 주로 인증 로직과 글작성 editor 컴포넌트나 댓글 컴포넌트 등 prop drills 이루어질 만한 곳에서만 사용한다.

결론

개인적인 견해로는 리액트는 component -> dispatch -> action -> reducer -> redux -> provider -> container -> component 방향으로 순환하는 구조라고 생각한다. 이 구조를 파악하고 폴더 구조를 짠다면 좀더 쉽게 폴더 구조에 대한 컨벤션을 만들 수 있을 것 같다.

profile
풀스택이 되고 싶은 주니어 웹 개발자입니다.

4개의 댓글

comment-user-thumbnail
2021년 7월 4일

글이 좋은데 퍼가도 될까요 ?

답글 달기
comment-user-thumbnail
2021년 7월 15일

감사합니다.

답글 달기
comment-user-thumbnail
2022년 1월 14일

S.Component 붙이는 방법 굉장히 좋네요 ㅎㅎ 좋은 코드 공유 감사합니다!

답글 달기
comment-user-thumbnail
2022년 4월 18일

감사합니다. next.js 마이그레이션 하고 있는데 CRA와는 구조가 많이 달라서 좋은 참고가 되었던 것 같습니다.

답글 달기