Jotai 레시피

13 Jan 2022

이번 글에서는 카카오스타일에서 Jotai를 어떤 식으로 사용하고 있는지 여러가지 패턴에 대해 설명하려고 합니다.

상태 정의하고 사용하기

Jotai의 기본 사용법은 간단합니다. useState로 정의하던 상태가 있으면, atom 메소드로 정의하고 useAtom을 써서 사용하면 됩니다.

import { atom, useAtom } from 'jotai';
import { FC } from 'react';

const count_atom = atom(0);

const App: FC = () => {
  const [count, setCount] = useAtom(count_atom);
  return (
    <div>
      <div>{count}</div>
      <button onClick={() => setCount(count + 1)}>Inc</button>
    </div>
  );
};

위 예제에서는 useState와 다른게 없지만, 하위 컴포넌트에서 해당 상태에 접근해야 할 경우 차이가 발생합니다.

import { atom } from 'jotai';
import { useAtomValue, useUpdateAtom } from 'jotai/utils';
import { FC } from 'react';

const count_atom = atom(0);

const Counter: FC = () => {
  const count = useAtomValue(count_atom);
  return <div>{count}</div>;
};

const IncButton: FC = () => {
  const setCount = useUpdateAtom(count_atom);
  return <button onClick={() => setCount((count) => count + 1)}>Inc</button>;
};

const App: FC = () => {
  return (
    <div>
      <Counter />
      <IncButton />
    </div>
  );
};

useAtomValueuseUpdateAtom은 읽기나 쓰기 중 하나만 필요할 때 사용가능한 유틸리티 함수입니다. 쓰기 함수는 useState의 setState 처럼 이전 상태를 받아 이용할 수 있습니다.

파생 atom

원시 atom의 값에서 파생된 atom을 정의할 수도 있습니다. MobX나 Vue의 computed 속성과 비슷합니다. useMemo와 같이 의존한 atom의 값이 바뀐 경우에만 재계산을 합니다.

const email_atom = atom('');
const password_atom = atom('');
const button_enabled_atom = atom((get) => {
  return get(email_atom).length > 0 && get(password_atom).length > 0;
});

같은 Provider가 제공한 atom의 값만 얻을 수 있습니다.

액션 atom

쓰기 전용 atom을 통해 비즈니스 로직을 정의할 수 있습니다.

// 전화번호 인증 과정 중 전화번호 입력 필드 값이 바뀔 때 호출한다
const updateMobileTelAtom = atom(null, (get, set, value: string) => {
  // 인증 과정이 완료된 경우 전화번호를 변경할 수 없다
  if (get(authenticated_atom)) {
    return;
  }
  // 전화번호 표시에서 -등 기타 문자는 제거한다
  set(mobile_tel_atom, value.replace(/[^0-9]/g, ''));
  // 인증 번호 입력 시간을 초기화한다
  set(remain_time_atom, 0);
});

대부분의 로직은 서버 API 호출을 필요로 하는 경우가 많습니다. 이 경우 응답을 기다리는, 즉 비동기 동작이 필요합니다. Redux에서는 thunk 같은 미들웨어가 필요하지만, Jotai에서는 자연스럽게 정의할 수 있습니다.

// 전화번호 인증 토큰을 발송한다
const sendAuthenticationTokenAtom = atom(null, async (get, set) => {
  // 중복 발송을 막는다
  if (get(submitting_atom)) {
    return;
  }
  try {
    set(submitting_atom, true);
    await fetch('/sendAuthenticationToken', { mobile_tel: get(mobile_tel_atom) });
    set(submitting_atom, false);
    // 이전 에러 메시지를 초기화한다
    set(authentication_error_atom, undefined);
    // 토큰 입력을 초기화한다
    set(token_atom, '');
    // 입력 시간을 2분으로 초기화한다
    set(remain_time_atom, 120);
  } catch (error: any) {
    set(submitting_atom, false);
    alert(error.message);
  }
});

디렉토리 구조

값과 액션이 사실 같은 atom이지만, 편의상 구분하고 있습니다. 이 둘을 묶은 용어를 고민하다가, Redux, MobX 등에서 쓰는 store라고 부르기로 했습니다. (ViewModel이라고 부를까도 고민했습니다.)

MobX에서 값과 액션을 한 클래스로 만들면 파일을 나누기가 어려운데 반해, Jotai는 atom의 모음이다 보니 나누기가 편리합니다. 값은 atoms 디렉토리, 액션은 actions 디렉토리에 둡니다. atoms는 연관성이 높은 것들을 한 파일로 묶고, 액션은 보통 길이가 길기 때문에 액션 별로 파일을 만들고 있습니다.

// src/components/model/detail/store/index.ts
export * from './atoms';
export * from './actions';
// src/components/model/detail/store/atoms/index.ts
import { atom } from 'jotai';

export * from './orderer';

export const submitting_atom = atom(false);
// src/components/model/detail/store/atoms/orderer.ts
import { atom } from 'jotai';

export const orderer_email_atom = atom('');
export const orderer_mobile_tel_atom = atom('');
export const orderer_name_atom = atom('');
// src/components/model/detail/store/actions/index.ts
export * from './purchase';
// src/components/model/detail/store/actions/purchase.ts
import { atom } from 'jotai';
import {
  orderer_email_atom,
  ...
} from '../atoms';

export const purchaseAtom = atom(null, async (get, set) => {
  const orderer_email = get(orderer_email_atom);
  ...
});

Provider와 초기값

atom의 값은 글로벌에 존재하는 atom에 저장되는 것이 아니라 Context와 비슷하게 컴포넌트 트리 상에 저장됩니다. Provider를 사용하면 atom이 저장될 Context를 제공할 수 있습니다. (즉 참조하는 Provider가 다르면 같은 atom을 use해도 값이 다릅니다) Provider를 지정하지 않으면 기본 저장소가 사용됩니다.

컴포넌트 구성 아티클에서 설명했듯이 페이지와 내부 구성 컴포넌트가 분리되어 있는데, 스토리북에서도 정상 동작하도록 내부 컴포넌트쪽에 Provider 선언을 두고 있습니다.

// Jotai 적용 전
const ProfileAddress: FC<{address1: string; address2: string}> = (props) => {
  const [address1, setAddress1] = useState(props.address1);
  const [address2, setAddress2] = useState(props.address2);
  return <div></div>;
};

export default ProfileAddress;
// Jotai 적용 후
const address1_atom = atom('');
const address2_atom = atom('');

const ProfileAddress: FC = () => {
  const [address1, setAddress1] = useAtom(address1_atom);
  const [address2, setAddress2] = useAtom(address2_atom);
  return <div></div>;
};

const ProfileAddressWithProvider: FC<{ address1: string; address2: string }> = (props) => {
  return (
    <Provider
      initialValues={[
        [address1_atom, props.address1],
        [address2_atom, props.address2],
      ] as Array<[Atom<unknown>, unknown]>}
    >
      <ProfileAddress />
    </Provider>
  );
};

export default ProfileAddressWithProvider;

위와 같이 외부에서 Props로 받아 온 값을 initialValues로 atom에 넣어주면 이후 Props 참조 없이, atom에서 값을 읽을 수 있습니다.

디렉토리 구조에서 설명했듯이 atom 정의는 하위 디렉토리에서 하고 있습니다. 이런 atom을 초기화하는 코드도 atom 정의와 같이 있는 것이 바람직하기 때문에, 초기화 내용을 분리해 store/atoms/index.ts 에 만들고 있습니다.

// store/atoms/index.ts
import { Atom, atom } from 'jotai';

export const address1_atom = atom('');
export const address2_atom = atom('');

export function getInitialValues(
  address1: string,
  address2: string,
): Array<[Atom<unknown>, unknown]> {
  return [
    [address1_atom, address1],
    [address2_atom, address2],
  ];
}

onMount과 localStorage

atom에 고정된 값 대신 처음 사용되는 시점에 값을 정해지도록 할 수 있습니다. 예를 들어 atom 값을 localStorage에서 가져와 초기화 시키고 싶을 수 있습니다. 이 경우 onMount를 사용하면 됩니다.

const toolip_seen_atom = atom<boolean>(true);
toolip_seen_atom.onMount = (set) => {
  set(window.localStorage.getItem('toolip_seen') === 'true');
};

그런데 위 코드로는 atom 값을 갱신했을 때 localStorage에 반영되지 않습니다. 쓰기시 커스텀 동작을 하면서, 저장도 하고 싶은 경우, 저장용 atom을 분리해야 합니다.

const toolip_seen_base_atom = atom<boolean>(true);
toolip_seen_base_atom.onMount = (set) => {
  set(window.localStorage.getItem('toolip_seen') === 'true');
};
const toolip_seen_atom = atom(
  (get) => get(toolip_seen_base_atom),
  (_, set, seen: boolean) => {
    set(toolip_seen_base_atom, seen);
    window.localStorage.setItem('toolip_seen', seen ? 'true' : 'false');
  },
);

같은 역할을 하는 것으로 atomWithStorage 유틸리티 함수가 있는데, 일부 환경에서 오동작을 하는 듯 해서 사용하지 못하고 있습니다. (Promise를 사용해 비동기적으로 동작하는 부분을 의심하고 있습니다.)

atom 조합 및 분리

개별 atom도 사용하지만, 그 값을 합친 것도 필요한 경우가 있습니다. 파생 atom으로 이를 구현할 수 있습니다.

import { atom } from 'jotai';

const orderer_email_atom = atom('');
const orderer_mobile_tel_atom = atom('');
const orderer_name_atom = atom('');
const orderer_atom = atom((get) => ({
  email: get(orderer_email_atom),
  mobile_tel: get(orderer_mobile_tel_atom),
  name: get(orderer_name_atom),
}));

반대로 atom이 전체 값을 가지고 있는데, 그 중 일부만 필요한 경우도 있습니다. selectAtom을 사용하면 됩니다. 원본 atom의 값을 바꿀 일이 없을 때 사용하면 편리합니다.

import { atom } from 'jotai';
import { selectAtom } from 'jotai/utils';

const order_atom = atom<Order>(...);
const ordered_product_atom = selectAtom<OrderProduct>(order_atom, (order) => order.product);

immer

atom이 복잡한 객체일 때, 일부 속성만 편하게 갱신하기 위해서 immer를 사용할 수 있습니다. 도움을 주는 유틸리티 모듈도 있습니다.

// immer를 사용하지 않는 경우
const selected_atom = atom<{ [key: string]: boolean }>({ apple: true, banana: false });
const setSelectedAtom = atom(null, (get, set, value: { fruit: string; checked: boolean }) => {
  const selected = get(selected_atom);
  set(selected_atom, { ...selected, ...{ [value.fruit]: value.checked } });
});

// immer를 사용하는 경우
import { produce } from 'immer';
const selected_atom = atom<{ [key: string]: boolean }>({ apple: true, banana: false });
const setSelectedAtom = atom(null, (get, set, value: { fruit: string; checked: boolean }) => {
  set(
    selected_atom,
    produce(get(selected_atom), (draft) => {
      draft[value.fruit] = value.checked;
    }),
  );
});

// immer 연동 모듈을 사용하는 경우
import { atomWithImmer } from 'jotai/immer';
const selected_atom = atomWithImmer<{ [key: string]: boolean }>({ apple: true, banana: false });
const setSelectedAtom = atom(null, (get, set, value: { fruit: string; checked: boolean }) => {
  set(selected_atom, (draft) => {
    draft[value.fruit] = value.checked;
  });
});

공용 컴포넌트

여러 페이지에서 사용하는 공통 컴포넌트가 있을 수 있습니다. 그리고 그 컴포넌트가 제공하는 상태를 atom으로 정의해 부모가 읽을 수 있도록 만들 수 있습니다. 이때 부모가 읽을 수 있도록 공용 컴포넌트에 Provider를 별도로 두지 않았습니다. 다만 Provider가 별도로 존재하지 않으므로 인스턴스를 여러개 만들 수는 없습니다. (Input 컴포넌트 같은 것은 불가능)

// common/EditReceiverView/index.tsx
const receiver_name_atom = atom('');
const receiver_mobile_tel_atom = atom('');
const receiver_postcode_atom = atom('');
const receiver_address1_atom = atom('');
const receiver_address2_atom = atom('');

export const receiver_atom = atom((get) => ({
  name: get(receiver_name_atom),
  mobile_tel: get(receiver_mobile_tel_atom),
  postcode: get(receiver_postcode_atom),
  address1: get(receiver_address1_atom),
  address2: get(receiver_address2_atom),
}));

export function getInitialValues(
  default_receiver: {
    name: string;
    mobile_tel: string;
    postcode: string;
    address1: string;
    address2: string | null;
  } | null,
): Array<[Atom<unknown>, unknown]> {
  return [
    [receiver_name_atom, default_receiver?.name ?? ''],
    [receiver_mobile_tel_atom, default_receiver?.mobile_tel ?? ''],
    [receiver_postcode_atom, default_receiver?.postcode ?? ''],
    [receiver_address1_atom, default_receiver?.address1 ?? ''],
    [receiver_address2_atom, default_receiver?.address2 ?? ''],
  ];
}

const EditReceiverView: FC = () => {
  const [receiver_name, setReceiverName] = useAtom(receiver_name_atom);
  ...

  return (
    <div>
      <Input
        label='받는 분'
        type='text'
        placeholder='이름을 입력해주세요'
        value={receiver_name}
        onChange={setReceiverName}
      />
      ...
    </div>
  );
};

export default EditReceiverView;
// order-sheet/index.tsx
import { receiver_atom, getInitialValues as EditReceiverView_getInitialValues } from 'common/EditReceiverView';

const order_sheet_atom = atom<OrderSheet>({});

function getInitialValues(
  order_sheet: OrderSheet,
  default_receiver: {
    name: string;
    mobile_tel: string;
    postcode: string;
    address1: string;
    address2: string | null;
  } | null,
): Array<[Atom<unknown>, unknown]> {
  return [
    [order_sheet_atom, order_sheet],
    ...EditReceiverView_getInitialValues(default_receiver),
  ];
}

const purchaseAtom = atom(null, async (get, set) => {
  const order_sheet = get(order_sheet_atom);
  const receiver = get(receiver_atom);
  ...
});

const OrderSheet: FC = () => {
  const purchase = useUpdateAtom(purchaseAtom);

  return (
    <div>
      ...
      <EditReceiverView />
      ...
      <button onClick={() => purchase()}>구매하기</button>
      ...
    </div>
  );
};

const OrderSheetWithProvider: FC<Props> = (props) => {
  return (
    <Provider
      initialValues={getInitialValues(
        props.order_sheet,
        props.default_receiver,
      )}
    >
      <OrderSheet />
    </Provider>
  );
};

export default OrderSheetWithProvider;

테스트

테스트는 중요하지만, 잘 하기는 쉽지 않습니다. 특히 수시로 변경되는 UI는 안정적이고 믿을 수 있는 테스트를 작성하기가 더 어렵습니다. 하지만 상태를 뷰와 분리하면 그나마 테스트가 좀 쉬워집니다. Jotai로 만든 store는 뷰와 무관하기 때문에 테스트 작성이 비교적 용이합니다.

다음과 같이 포인트 최대 적용이라는 액션을 만들었습니다.

import { atom } from 'jotai';

// 주문액
const payment_amount_atom = atom(0);

// 소유한 포인트 금액
const available_point_atom = atom(0);

// 최대 사용가능한 포인트 금액
const maximum_usable_point_atom = atom((get) => {
  const available_point = get(available_point_atom);
  if (available_point <= 0) {
    return 0;
  }
  const payment_amount = get(payment_amount_atom);
  return available_point <= payment_amount ? available_point : payment_amount;
});

// 사용할 포인트 금액
const point_to_use_atom = atom(0);

// 사용가능한 최대 포인트를 적용한다
const applyAllPointAtom = atom(null, (get, set) => {
  set(point_to_use_atom, get(maximum_usable_point_atom));
});

// Provider의 initialValues를 위한 도움 함수
function getInitialValues(
  payment_amount: number,
  available_point: number,
): Array<[Atom<unknown>, unknown]> {
  return [
    [payment_amount_atom, payment_amount],
    [available_point_atom, available_point],
  ];
}

다음은 이 액션에 대한 테스트 코드입니다.(action 파일과 같은 디렉토리에 위치시켰습니다) 현재 카카오스타일은 Jest 프레임워크를 사용중이고(서버는 Mocha를 사용하고 있습니다), Testing Library의 도움을 받고 있습니다.

import { renderHook, act } from '@testing-library/react-hooks';
import { Provider } from 'jotai';
import { useAtomValue, useUpdateAtom } from 'jotai/utils';
import { getInitialValues, maximum_usable_point_atom, point_to_use_atom } from '../atoms';
import { applyAllPointAtom } from './applyAllPoint';

describe('applyAllPoint', () => {
  it('포인트 사용액 기본값은 0이다', () => {
    const { result } = renderHook(() => ({
      point_to_use: useAtomValue(point_to_use_atom),
    }));

    expect(result.current.point_to_use).toBe(0);
  });

  it('사용 가능한 금액만큼 적용된다', () => {
    const initial_values = getInitialValues(
      92500,
      10000,
    );
    const { result } = renderHook(
      () => ({
        point_to_use: useAtomValue(point_to_use_atom),
        maximum_usable_point: useAtomValue(maximum_usable_point_atom),
        applyAllPoint: useUpdateAtom(applyAllPointAtom),
      }),
      {
        wrapper: ({ children }) => <Provider initialValues={initial_values}>{children}</Provider>,
      },
    );

    act(() => {
      result.current.applyAllPoint();
    });

    expect(result.current.maximum_usable_point).toBe(10000);
    expect(result.current.point_to_use).toBe(10000);
  });

  it('사용 가능한 금액이 주문 금액보다 많으면 주문 금액만큼 적용된다', () => {
    const initial_values = getInitialValues(
      92500,
      200000,
    );
    const { result } = renderHook(
      () => ({
        point_to_use: useAtomValue(point_to_use_atom),
        maximum_usable_point: useAtomValue(maximum_usable_point_atom),
        applyAllPoint: useUpdateAtom(applyAllPointAtom),
      }),
      {
        wrapper: ({ children }) => <Provider initialValues={initial_values}>{children}</Provider>,
      },
    );

    act(() => {
      result.current.applyAllPoint();
    });

    expect(result.current.maximum_usable_point).toBe(92500);
    expect(result.current.point_to_use).toBe(92500);
  });
});

카카오스타일은 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 상태 코드를 의존하지 않는 형태로 클라이언트를 작성하고 있습니다.