카카오스타일은 React에서 상태 관리를 위해 최근에 Jotai를 도입했습니다. Jotai에 대해 소개하기에 앞서 Jotai에 다다르기까지의 과정에 대해 설명해보려고 합니다.

선언형 UI

할일 관리 화면을 상상해봅시다. 초기 웹에서 보편적이였던 jQuery를 사용한다면, 할 일 추가를 위해 다음과 같이 작성할 것 같습니다.

const todos = [];
$('#text').change(function () {
  const text = $(this).val();
  $(this).val('');
  todos.push({ text, completed: false });
  $('#list').append(`<li>${text}</li>`);
});

이런 구성은 todos 변수(모델)와 #list 원소의 내용(뷰)을 동기화 시키는 것이 굉장히 어렵습니다. 그래서 React를 비롯한 대부분의 현대식 프레임워크는 모델과 뷰의 관계를 선언형(declarative)으로 정의하면 알아서 모델의 변경 사항을 뷰에 반영해주어서 뷰 코드를 단순하게 만들어줍니다.

최근에는 네이티브 앱 개발에서도 SwiftUI나 Jetpack compose 처럼 선언형 UI 프레임워크가 나오고 있습니다.

저는 어떤 화면을 표현하기 위한 데이터와 뷰와의 관계(매핑) 대부분은 직관적으로 정의할 수 있다고 생각합니다. 이렇게 구성한 뷰를 React가 효율적으로 최종 DOM에 반영해주기 때문에, 결국 우리가 가장 신경써야 할 부분은 데이터 모델링과 사용자 행위에 따라 데이터가 어떻게 바뀌는지에 대한 것입니다. 이 데이터를 React에서는 상태라 부르고, 극단적으로 (렌더링은 React가 책임져주므로) 상태 관리만 잘 하면 좋은 어플리케이션이 만들어진다고 생각합니다. 이게 중요하지만 그만큼 어렵다 보니 상태 관리 라이브러리가 다양하게 나온 것이라고 생각합니다.

이 데이터에는 UI를 위한 데이터(예를 들어 Collapse 컴포넌트의 접기 상태)도 있다는 것에서 일반적인 모델과 구분됩니다. 저는 이게 MVVM 패턴의 ViewModel이라고 생각합니다. 현재 네이티브 앱에서 선언형 UI까지는 하지 못햇지만 MVVM 패턴을 사용해 뷰와 데이터를 분리하고 있습니다.

Mithril

지그재그 초기에는 내부 툴을 위해 Mithril을 사용했는데 몇단계의 개선을 통해 최종적으로는 다음과 같이 MVVM 구성을 했습니다.

당시에는 제 기준으로 React와 큰 차이가 없었는데, 이후 React는 이라는 큰 변화가 생겼고, Mithril은 큰 변화없이 정체되다가 현재는 완전히 멈추었습니다. 2019년 이후 React를 사용하는 것으로 정책이 바뀌었지만, 일부 프로젝트(예 FAQ)에 아직 흔적이 남아있습니다.

interface SignupViewAttrs {
  token?: string;
}

class SignupViewModel {
  token: string;
  email: Stream.Stream<string>;
  password: Stream.Stream<string>;
  password_confirm: Stream.Stream<string>;
  enabled: Stream.Stream<boolean>;

  constructor(attrs: SignupViewAttrs) {
    this.token = attrs.token || '';
    this.email = Stream('');
    this.password = Stream('');
    this.password_confirm = Stream('');
    this.eanbled = Stream.merge([this.email, this.password, this.password_confirm]).map(
      ([email, password, password_confirm]) => {
        return email.length > 0 && password.length > 0 && password === password_confirm;
      },
    );
  }

  async signup(event) {
    // signup logic
  }
}

class SignupView implements m.ClassComponent<SignupViewAttrs> {
  vm: SignupViewModel;

  oninit({ attrs }: m.CVnode<SignupViewAttrs>) {
    this.vm = new SignupViewModel(attrs);
  }

  view({ attrs }: m.CVnode<SignupViewAttrs>) {
    return (
      <form class='form-horizontal' onsubmit={(event) => this.vm.signup(event)}>
        <div class='form-group'>
          <input class='form-control' value={this.vm.email()} onchange={m.withAttr('value', this.vm.email)} />
        </div>
        <div class='form-group'>
          <input
            type='password'
            class='form-control'
            value={this.vm.password()}
            onchange={m.withAttr('value', this.vm.password)}
          />
        </div>
        <div class='form-group'>
          <input
            type='password'
            class='form-control'
            value={this.vm.password_confirm()}
            onchange={m.withAttr('value', this.vm.password_confirm)}
          />
        </div>
        <div class='form-group'>
          <button type='submit' class='btn btn-default' disabled={!this.vm.enabled()}>
            가입하기
          </button>
        </div>
      </form>
    );
  }
}

MobX

이후에 2019년 초 커머스 플랫폼으로 전환을 시작하면서 웹으로 구성한 화면들이 생겨나기 시작했는데 이때 React와 Next.js를 선택했습니다. 그리고 상태관리 라이브러리를 선택해야 했는데, 당시 Redux가 대새였지만 저희팀은 MobX를 선택했습니다. 그 선택에 제가 관여를 하진 않았는데, 아마 OOP 스타일인게 영향을 미치지 않았을까 싶습니다. 거기에 지금도 그렇지만 Redux를 잘 쓰려면 알아야 할 개념과 부가적인 라이브러리가 많습니다. 개인적으로 상태는 최소한의 범위만 영향을 주는 것이 부수 효과가 적다고 생각하기 때문에, 글로벌 상태를 하나의 스토어에 두는 Redux 컨셉에 거부감이 있습니다.

import { computed, observable, observer } from 'mobx';

class Store {
  @observable email: string = '';
  @observable password: string = '';
  @observable password_confirm: string = '';

  @computed
  get enabled() {
    return this.email.length > 0 && this.password.length > 0 && this.password === this.password_confirm;
  }
}

@observer
class LoginView extends React.Component<{ store: Store }> {
  render() {
    const store = this.props.store;
    return (
      <div>
        <input value={store.email} onChange={(e) => (store.email = e.target.value)} />
        <input value={store.password} onChange={(e) => (store.password = e.target.value)} />
        <input value={store.password_confirm} onChange={(e) => (store.password_confirm = e.target.value)} />
        <button disabled={!store.enabled}>가입하기</button>
      </div>
    );
  }
}

useReducer

이후 회사 내부에 여러 웹 프로젝트가 생기면서 기술에 파편화가 일어나고 있던 상황에서, 2021년 중반에 새로 시작한 프로젝트를 새로운 표준으로 삼기 위해, 상태 관리에 대해 다시 한번 조사해봤습니다. 이미 훅이 대세가 됐고 너무 좋아해서 기본 훅을 최대한 활용해 보기로 했습니다. MobX는 데코레이터를 활용하는 것과 훅과 어울리지 않는 다는 인상이 있어 배제했습니다. (최신 버전에서는 달라진 것으로 알고 있습니다)

처음 시도는 useReducer였습니다. 다만 이것만으로는 반복되는 코드가 많아서 Redux Toolkit의 도움을 받았습니다. 그리고 Ducks 패턴을 참고해서 파일 구성을 했습니다.

// store.ts
import { createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit';

interface State {
  email: string;
  password: string;
  submitting: boolean;
}

export const initial_state: State = {
  email: '',
  password: '',
  submitting: false,
};

const login_slice = createSlice({
  name: 'login',
  initialState: initial_state,
  reducers: {
    setEmail(state, action: PayloadAction<string>) {
      state.email = action.payload;
    },
    setPassword(state, action: PayloadAction<string>) {
      state.password = action.payload;
    },
    startLogin(state) {
      state.submitting = true;
    },
    endLogin(state) {
      state.submitting = false;
    },
  },
});

export const { setEmail, setPassword, startLogin, endLogin } = login_slice.actions;
export default login_slice.reducer;

const self_selector = (state: State) => state;
const button_enabled_selecter = createSelector(self_selector, (state) => {
  return state.email.length > 0 && state.password.length > 0 && !state.submitting;
});

export const selector = createSelector(button_enabled_selecter, (button_enabled) => {
  return {
    button_enabled,
  };
});

// Login.tsx
import { useReducer, ReactElement } from 'react';
import reducer, { initial_state, selector, setEmail, setPassword, startLogin, endLogin } from './store';

export default function Login(): ReactElement {
  const [state, dispatch] = useReducer(reducer, initial_state);
  const { button_enabled } = selector(state);

  const login = async () => {
    try {
      dispatch(startLogin());
      // login logic
      DefaultRouter.push('/');
    } catch (error) {
      dispatch(endLogin());
      alert(error);
    }
  };

  return (
    <div className='max-w-screen-md mx-auto mt-10'>
      <div>
        <div className='text-4xl'>이메일</div>
        <div className='my-4'>
          <input
            className='w-full border-2 border-black text-4xl px-6 py-4'
            name='email'
            value={state.email}
            onChange={(e) => dispatch(setEmail(e.target.value))}
          />
        </div>
      </div>
      <div>
        <div className='text-4xl'>암호</div>
        <div className='my-4'>
          <input
            className='w-full border-2 border-black text-4xl px-6 py-4'
            name='password'
            type='password'
            value={state.password}
            onChange={(e) => dispatch(setPassword(e.target.value))}
          />
        </div>
      </div>
      <div className='mt-8'>
        <button
          className='bg-blue-300 w-full border-2 border-black text-4xl px-6 py-4 disabled:opacity-50'
          onClick={login}
          disabled={!button_enabled}
        >
          로그인하기
        </button>
      </div>
    </div>
  );
}

UI와 로직을 분리하는게 가장 큰 목표인데, login 로직이 아직 컴포넌트에 남아있습니다. useReducer로는 비동기 처리가 어려워서 use-reducer-async 모듈을 참고해 비동기 리듀서를 만들었습니다.

import {
  Dispatch,
  Reducer,
  ReducerAction,
  ReducerState,
  useCallback,
  useEffect,
  useLayoutEffect,
  useReducer,
  useRef,
} from 'react';
import { AnyAction } from 'redux';

export type AsyncAction<S, A = AnyAction> = (dispatch: AsyncDispatch<S, A>, getState: () => S) => void;

export type AsyncDispatch<S, A> = Dispatch<A | AsyncAction<S, A>>;

const isBrowser = typeof window !== 'undefined';
const useIsomorphicLayoutEffect = isBrowser ? useLayoutEffect : useEffect;

export function useAsyncReducer<R extends Reducer<any, any>>(
  reducer: R,
  initialState: ReducerState<R>,
  initState?: (state: ReducerState<R>) => ReducerState<R>,
): [ReducerState<R>, AsyncDispatch<ReducerState<R>, ReducerAction<R>>] {
  const [state, dispatch] = useReducer(reducer, initialState, initState as any);

  const last_state = useRef(state);
  useIsomorphicLayoutEffect(() => {
    last_state.current = state;
  }, [state]);
  const getState = useCallback(() => last_state.current, []);

  const async_dispatch = useCallback(
    (action: ReducerAction<R> | AsyncAction<ReducerState<R>, ReducerAction<R>>) => {
      if (typeof action === 'function') {
        (action as AsyncAction<ReducerState<R>, ReducerAction<R>>)(async_dispatch, getState);
      } else {
        return dispatch(action);
      }
    },
    [dispatch, getState],
  );

  return [state, async_dispatch];
}

이후에 다음과 같이 login 액션을 분리했습니다.

export function login(): AsyncAction<State> {
  return async (dispatch, getState) => {
    try {
      const state = getState();
      dispatch(slice.actions.startLogin());
      // login logic
      DefaultRouter.push('/');
    } catch (error) {
      dispatch(slice.actions.endLogin());
      alert(error);
    }
  };
}

Jotai

useReducuer를 사용해 한 페이지에 있는 여러 개의 상태를 개별 useState 대신 별도 파일로 분리하는 목적은 달성했지만 썩 맘에 들지 않았습니다.

  • 개인적으로 그냥 비지니스 로직이 담긴 함수를 리듀서라고 정의하는 것에 대한 거부감이 좀 있습니다. (로직을 처리하는게 아니라, state → state 변환을 처리하는 개념에 치중)
  • 동기 액션과 비동기 액션 처리 정의가 다르게 생겼습니다.
  • button_enabled 같은 계산된 속성과 일반 속성 사용법이 너무 다릅니다.
  • 개별 상태를 하위 컴포넌트에 일일이 전달할 필요는 없어졌지만 (Prop Drilling), state와 dispatch는 하위 컴포넌트에 전달해야 했습니다. (Context 적용을 고민해봤지만, 뭔가 잘 어울리지 않았습니다.)

연구를 더 하면 더 낫게 구성할 가능성도 있지만, 근본적으로 제가 원하는 모습이 아닌 것 같다는 생각이 들었습니다.

우선 Redux 같은 복잡한 라이브러리를 쓰지 않고, React 기본 기능을 최대한 활용하면서 Prop Drilling을 피할 수 있는 방향, 즉 Context를 어떻게 활용하면 좋을까 여러가지 찾아봤습니다. 그런데 해당 화면에 필요한 상태를 한 Context에 모두 담아버리면 성능 이슈가 있다고 나왔습니다. (그래서 useContextSelector 같은게 나온 것으로 알고 있습니다.)

그러던 중 프론트엔드 미팅에서 누군가 Jotai를 써봤다는 얘기가 나와서 한번 조사를 해보게 됐습니다. 처음에는 어떻게 쓰면 좋을지 감이 안 잡혔는데, 일단 큰 state를 정의하는 대신, 개별 상태(atom)를 정의해서 조합해서 사용한다는 것이 맘에 와 닿았고 좀 더 깊에 연구를 들어갔습니다. 그 결과 제가 원하는 것을 대부분 달성할 수 있는 것으로 확인됐습니다. (계산된 속성/반응형 속성 관련해서는 아직 해결책을 못 찾은 부분이 있습니다) 무엇보다 개념이 단순하고 코드 크기가 작다는 것이 맘에 들었습니다. Recoil도 Facebook이 직접 관리해서 장기적으로 유지가 될 가능성이 높다고 생각해 살펴봤지만, key가 존재하고, selector가 atom과 구분되는 등 비슷한 개념을 더 복잡하게 정의하고 있다고 생각해 최종적으로는 선택하지 않았습니다. (사업 초기에 Mithril을 선택한 이유도 비슷했습니다. React, RxJS등에서 제게 필요한 최소한의 기능만 작은 코드 베이스로 제공했습니다.)

Jotai 사용법은 단순합니다. 원하는 상태가 있으면 atom으로 정의하고, useState 대신, useAtom을 사용하면 됩니다. 그러면 다른 컴포넌트에서도 useAtom을 통해 그 값을 얻거나 변경할 수 있습니다.

import { atom } from 'jotai'

const count_atom = atom(0);

const Counter: FC = () => {
  const [count, setCount] = useAtom(count_atom);
  ...
};

액션(로직)의 경우 쓰기 전용 atom으로 정의하면 됩니다. 값을 담은 변수는 snake_case, 함수는 camelCase로 정의하는 저희 컨벤션에 따라 로직을 담은 atom은 camelCase로 정의합니다.

const incCountAtom = atom(null, (get, set, inc) => {
  set(count_atom, get(count_atom) + inc);
});

const Counter: FC = () => {
  const incCount = useUpdateAtom(incCountAtom);
  ...
};

그냥 정의하면 모든 atom은 전역으로 공유됩니다. 독립된 공간이 필요하다면 Provider로 감싸주면 됩니다. 이번에 만든 어플리케이션은 대부분 Next.js 페이지 단위로 상태를 관리하고 있어서 페이지 컴포넌트를 Provider로 감싸서 사용하고 있습니다.

다음 글에서는 Jotai 활용에 대해 좀 더 자세히 설명하도록 하겠습니다.

Java로 코드를 작성해보신 분이라면 throws에 의해 컴파일 에러가 발생했을 때 뭔지는 잘 모르겠고 IDE가 제시한 대로 catch로 감싸고 넘어간 경험이 누구에게나 있을 것이라 생각합니다. 그만큼 예외 상황은 잘 이해하고 적절히 처리하기는 쉽지 않은 것 같습니다. 오늘은 카카오스타일이 사용하고 있는 GraphQL에서 에러를 어떻게 전달하고 처리하고 있는지 설명하려고 합니다.

GraphQL 에러 형식

GraphQL은 다음과 같은 데이터를 반환합니다.

{
  "data": {
    "root_field": ...
  },
  "errors": [
    { "message": "error message", "locations": [], "path": [] }
  ]
}

GraphQL을 처음 접했을 때는 errors가 배열이라는 것을 이해하지 못했습니다. 그래서 다음과 같이 API를 설계하는 실수를 하기도 합니다.

type Mutation {
  authenticate(input: AuthenticateInput!): AuthenticateResult!
}

type AuthenticateResult {
  success: Boolean!
  user_id: ID
  error_code: String
}

errors가 배열인 이유는 명확합니다. GraphQL은 여러 데이터를 한번에 요청할 수 있는데, 그 중 일부 데이터만 반환하는 것이 가능합니다. 반환에 실패에 한 경우 그 이유(데이터 없음, 권한 없음등)를 반환해야 하는데 그 이유가 여러가지 일 수 있는 것이죠.

현재 카카오스타일은 에러 발생시 errors에 데이터를 채워서 반환합니다. 다만 에러가 여러개인 경우 적절한 처리방법을 알지 못해 첫번째 에러만 유의미하게 처리합니다. API에 따라 null을 반환할 수도 있기 때문에 data 필드가 null인 것을 에러로 처리하지는 않고 errors 배열에 데이터가 하나 이상있으면 에러로 처리합니다. 반대로 errors 배열에 데이터가 있으면 data 필드에 값이 있어도 에러로 처리하고 있습니다.

message 필드와 에러 코드

사용자에게는 사용자 친화적인 에러 메시지를 표시해줘야 합니다. 이 메시지를 어디서 관리해야 하는지도 쟁점입니다.

같은 상황이여도 사용자마다 다른 메시지가 필요할 수도 있고, UI 이슈라고 보면 클라이언트에서 관리하는게 맞을 것 같기도 합니다만, 클라이언트는 수정이 어렵다라는 이슈가 있습니다. (특히 앱인 경우) 또한 에러 상황마다 일일이 분기 처리를 해야 합니다. 테이블로 메시지를 관리할 수도 있습니다만 어떤 에러 메시지는 고정 메시지가 아닐 수도 있습니다. (예, xxx 상품은 구매할 수 없습니다)

이런 이유로 에러 메시지는 서버가 관리하고 있습니다. 클라이언트는 대부분의 경우 서버가 보내주는 에러 메시지를 맥락에 대한 이해없이 그대로 보여주는 식으로 동작합니다. 다만 이 경우 다국어 처리에 대한 고민이 필요합니다. 클라이언트가 에러 메시지를 처리하면 클라이언트 언어를 인식해서 적절한 메시지를 표시할 수 있지만, 서버는 클라이언트에게서 언어 정보를 받아서 요청별로 에러 메시지를 다르게 구성하는 처리를 해야 합니다.

일부 에러 상황은 클라이언트가 특별히 처리를 해야 할 수도 있습니다. (예를 들어 로그인이 안 되어 있다는 에러를 만난 경우 로그인 페이지로 이동) 이런 경우 사용자 친화적인 메시지를 보고 처리하는 것은 무리기 때문에 (특히 언어마다 메시지가 다르다면) 에러 코드도 같이 보내야 합니다.

GraphQL 에러 형식에서 message 필드에는 사용자 친화적인 메시지를 담고 있습니다. 에러 코드에 대한 표준은 없기 때문에 extensions에 담아서 반환해야 합니다. 다음은 이 형식에 따른 에러 메시지 예입니다

{
  "errors": [
    {
      "message": "로그인을 해주세요.",
      "extensions": {
        "code": "auth_not_logged_in"
      }
    }
  ],
  "data": null
}

추가 정보

에러 메시지와 에러 코드만 있어도 대부분은 문제가 없지만 추가 정보가 필요한 경우가 있습니다. 이런 정보는 extensions에 담고 있습니다.

에러가 발생할 경우 로그에는 주어진 에러 코드로 에러를 기록합니다. 에러 코드가 있다고 모두 오류 상황은 아니고 프로세스상 일상적으로 발생할 수 밖에 없는 에러가 있기도 합니다. 사용자에게는 에러를 반환하는 것이 맞지만, 모니터링에서는 에러로 취급하지 않을 에러인 경우 ignorable 란 필드를 true 로 설정하고 있습니다.

어떤 클라이언트/상황에서는 단순히 에러 메시지가 아니라 더 많은 정보를 표시하고 싶을 수도 있습니다. (예를 들어 에러 팝업 타이틀이나, 아이콘, 닫기 버튼 메시지등) 이런 경우를 위해 contents?: { type: string; title: string; message: string; link_title?: string; link_url?: string }; 와 같이 스키마를 정해서 소통하는 API도 있습니다. (전반적으로 적용해도 될 것 같지만 아직 표준화가 덜 됐습니다)

어떤 에러는 부가적인 정보가 필요한 경우가 있습니다. 예를 들어 소셜 로그인시 이미 해당 이메일의 계정이 존재할 수 있습니다. 이메일이 같다고 무조건 로그인을 시키면 안 되고, 대신 사용자에게 이메일이 이미 사용중이니 해당 계정으로 로그인을 시도하도록 유도하기로 했고, 이를 위해 마스킹된 이메일 문자열을 email 필드에 담아서 반환하도록 했습니다.

HTTP 상태 코드

GraphQL 스펙은 전달 방식에 대해 정의하고 있지 않지만 일반적으로는 HTTP 프로토콜을 사용합니다. 이때 HTTP 상태 코드를 사용할지 여부도 쟁점입니다.

처음 GraphQL 서빙을 위해 사용했던 express-graphql는 에러시 500 에러를 반환했습니다. 그 다음으로 사용한 apollo-server-express는 에러 상황에서도 200을 반환해서 약간의 혼란이 있었습니다.

현재 논의가 진행 중인 스펙에서는 2xx이 아닌 에러를 반환하는 것으로 진행되고 있습니다만 (부분 성공의 경우는 200이여야 합니다) 카카오스타일에서는 현재 무조건 200을 반환하는 것으로 정했습니다.

GraphQL 문법에 어긋나는 경우에만 400 에러 등을 반환하고, 내부 리졸버를 정상적으로 수행한 경우에는 200입니다. 비지니스 로직의 실패는 HTTP 상태 코드가 아닌 errors 필드를 보고 판단하게 됩니다.

다만 앞으로 바뀔 여지가 있기에 HTTP 상태 코드를 의존하지 않는 형태로 클라이언트를 작성하고 있습니다.

시작하며

프로젝트가 시작된지 얼마 되지 않은 경우에 소스는 비교적 일관성을 가지고 있습니다. 하지만 시간이 지남에 따라 여러 사람이 참여하고, 비슷한 새로운 프로젝트가 만들어지면서 점점 일관성이 떨어지게 됩니다. (문서나 리뷰 과정이 있으면 비교적 낫지만, 완전히 방지하기는 어려운 것 같습니다) 또한 새로운 기술이 생기면서 (예를 들어 React Hook) 기존에 설정한 구조가 전혀 적합하지 않게 되는 경우가 생깁니다.

그런 의미에서 주기적으로 프로젝트 구성에 관한 가이드를 주기적으로 점검하고 갱신할 필요성이 있습니다. 이번 글에서는 2021년 7월 현재 React 프로젝트의 컴포넌트 구성에 대한 가이드를 설명하려고 합니다. (항상 예외 상황이 있기 마련이고, 이에 따른 변형을 허용하기에 가이드라는 용어를 쓰고 있습니다.)

React 자체는 UI 구성을 위한 라이브러리이기 때문에 구성에 아무 제약이 없습니다. 반면 카카오스타일의 일부 프로젝트에서는 Next.js를 쓰고 있는데, 이는 프레임워크이기 때문에 여러가지 규칙이 있습니다. 전체적인 통일성을 위해 Next.js를 사용하지 않는 프로젝트에서도 Next.js와 유사한 구성을 하도록 가이드를 정했습니다.

라우트

Next.js 에서는 라우트를 pages 디렉토리에서 정하고 있습니다. 다만 카카오스타일에서는 pages를 프로젝트 루트에 두지 않고, src 밑에 모아두는 것을 선택했습니다. 비 Next.js 프로젝트에서도 마찬가지로 pages 밑에 두지만, 라우트 연결은 수동으로 해야 합니다.

다음은 비 Next.js에서의 라우트 설정 예입니다.

import { BrowserRouter as Router, Switch, Route } from 'react-router-dom';
import HomePage from 'pages';
import LoginPage from 'pages/login';
import SignupPage from 'pages/signup';
import QnaQuestionMainPage from 'pages/qna/questions';
import QnaQuestionNewPage from 'pages/qna/questions/new';
import QnaQuestionDetailPage from 'pages/qna/questions/[question_id]';
import ChatRoomMainPage from 'pages/chat/rooms';
import ChatRoomDetailPage from 'pages/chat/rooms/[room_id]';

function App() {
  return (
    <Router>
      <Switch>
        <Route exact path='/'>
          <HomePage />
        </Route>
        <Route exact path='/login'>
          <LoginPage />
        </Route>
        <Route exact path='/signup'>
          <SignupPage />
        </Route>
        <Route exact path='/qna/questions'>
          <QnaQuestionMainPage />
        </Route>
        <Route exact path='/qna/questions/new'>
          <QnaQuestionNewPage />
        </Route>
        <Route path='/qna/questions/:question_id'>
          <QnaQuestionDetailPage />
        </Route>
        <Route exact path='/chat/rooms'>
          <ChatRoomMainPage />
        </Route>
        <Route path='/chat/rooms/:room_id'>
          <ChatRoomDetailPage />
        </Route>
      </Switch>
    </Router>
  );
}

export default App;

Next.js에서는 같은 위치에 페이지 파일이 있으면 자동으로 라우트 설정이 됩니다.

페이지를 실제 구성하는 컴포넌트

간단한 뷰는 페이지 파일에 넣을 수 있겠지만 복잡한 경우 여러 컴포넌트로 나누어야 하는데 이를 pages 밑에 두면 Next.js 라우트로 인식되므로 별개의 위치에 둬야 합니다. 따라서 이를 components 디렉토리에 두기로 했습니다. 이때 RESTful한 주소를 갖도록 위치한 페이지와 달리 도메인별로 묶어서 구성합니다.

QnaQuestionMainPage(/pages/qna/questions/index.tsx)에 대응하는 컴포넌트는 /components/qna/question/main/index.tsx에 위치하고, ChatRoomDetailPage(/pages/chat/rooms/[room_id]/index.tsx)에 대응하는 컴포넌트는 /components/chat/room/detail/index.tsx에 위치합니다. 컴포넌트 이름은 각각 QnaQuestionMain과 ChatRoomDetail입니다. 파일명을 컴포넌트 이름과 일치시킬지 여부를 내부에서 논의할 결과 진입점은 index.tsx로 통일하는 것으로 결정했습니다.

예를 들어 QnaQuestionMainPage는 대략 다음과 같은 형태가 됩니다.

import { FC } from 'react';
import QnaQuestionMain from 'components/qna/question/main';

const QnaQuestionMainPage: FC = () => {
  return <QnaQuestionMain />;
};

export default QnaQuestionMainPage;

데이터 가져오기

Next.js 프로젝트와 비 Next.js 프로젝트는 데이터를 가져오는 시점이 다릅니다. Next.js는 클라이언트에 내보내기 전에 React와 무관하게 데이터를 가져오는데 반해, 비 Next.js 프로젝트는 클라이언트 로딩이 끝난 후 데이터를 가져와야 합니다. 어느 경우에든 비슷하게 구성하기 위해 데이터 가져오는 것을 fetchData라는 함수로 분리해 컴포넌트쪽에 둡니다. 각 컴포넌트는 상위 페이지로 부터 데이터를 props로 받아옵니다. 이렇게 구성하면 컴포넌트에 대한 스토리북 구성시 데이터를 다르게 부여하기 편하다라는 장점도 있습니다. 단점으로는 데이터가 고정이 아닌 경우(예를 들어 필터 설정, 정렬 옵션등에 의해 페이지 이동 없이 바뀌어야 하는 경우), 상위 페이지로 이를 전달하는 방법을 고민해 봐야 한다는 점이 있습니다.

다음은 위 규칙에 따라 구성한 컴포넌트 내용입니다.

interface Question {
  id: string;
  title: string;
  date: number;
}

export interface Props {
  total_count: number;
  question_list: Question[];
}

const QnaQuestionMain: FC<Props> = (props) => {
  return (
    <div>
      {props.question_list.map((question) => (
        <QuestionView question={question} key={question.id} />
      ))}
    </div>
  );
};

export default QnaQuestionMain;

export async function fetchData(context: GetServerSidePropsContext | undefined): Promise<Props> {
  const result = await fetch(...);
  return {
    total_count: result.total_count,
    question_list: result.question_list,
  };
}

Next.js 프로젝트에서는 getServerSideProps 메소드에서 fetchData를 호출합니다.

import type { GetServerSideProps } from 'next';
import QnaQuestionMain, { fetchData, Props } from 'components/qna/question/main';

export const getServerSideProps: GetServerSideProps<Props> = async (context) => {
  return {
    props: await fetchData(context),
  };
};

const QnaQuestionMainPage: FC<Props> = (props) => {
  return <QnaQuestionMain {...props} />;
};

export default QnaQuestionMainPage;

반면 비 Next.js 프로젝트에서는 useEffect 안에서 호출합니다.

import QnaQuestionMain, { fetchData, Props } from 'components/qna/question/main';

const QnaQuestionMainPage: FC = () => {
  const [props, setProps] = useState<Props>({ total_count: 0, question_list: [] });

  useEffect(() => {
    const run = async () => {
      setProps(await fetchData());
    };
    run();
  }, []);

  return <QnaQuestionMain {...props} />;
};

export default QnaQuestionMainPage;

자잘한 규칙

컴포넌트를 내보낼 때는 default export를 사용하고 있습니다. 이렇게 하면 내부적으로 컴포넌트 이름을 바꿔도 사용하는 쪽에 영향이 없다는 장점이 있습니다. (예를 들어 jotai를 적용하면서 Provider로 감쌀 필요가 있었습니다) 다만 정의하는 쪽과 사용하는 쪽의 이름을 다르게 줄 수 있어서 찾기 어려워지는 단점도 있습니다. (이는 컴포넌트 이름을 잘 부여하고 주의깊게 사용하면 되긴 합니다) 여기에 스토리북에서 컴포넌트 Props를 제대로 인식하지 못하는 작은 문제도 있습니다. (default export를 사용하고 파일 이름이 컴포넌트와 다른 index.tsx일 경우 발생)

한 페이지 컴포넌트를 작게 쪼갠 경우 그 컴포넌트 사이에는 상대 경로로 참조하면 되지만, pages → components 처럼 멀리 떨어진 컴포넌트를 참조할 경우에는 절대 경로로 참조하는게 좋습니다. 이렇게 구성해야 파일을 /pages/qna/questions/index.tsx 에서 /pages/questions/index.tsx로 옮겨도 import 변경이 필요없습니다. 상대 경로로 참조 가능한 범위에 대해서는 사람마다 다르게 판단하기도 합니다.

절대 경로로는 src 밑의 디렉토리들(예 components, pages, hooks)을 사용하고 있습니다. 저는 @/components/qna/question/main 형태를 제안했는데, components/qna/question/main를 쓰는 것으로 정해졌습니다.