취준생으로써 작은 단위에 프로젝트만 진행하다 보니 전역 상태를 사용할 일이 많지 않았다. 웬만한거는 모두 props나 애초에 전역 상태를 사용할 정도로 컴포넌트 단위가 크지 않았기 때문이다.
만약 사용할 일이 있다면 프로젝트 진행하면서 학습하면 되지 않을까? 라는 안일한 생각을 갖고 있다가 정작 프로젝트 때에는 시간에 쫓겨 '가장 쉬운', '가장 간단한' 라이브러리들을 사용했다 그래서 사용한 것이 Recoil이었다.
전역 상태 관리 라이브러리의 시초격인 Redux는 딱봐도 작성해야 할 코드도 많았고, 코드는 짧고 간단하면 좋은 것이라는 또 다른 안일한 생각으로 지내왔다.
시간이 흘러, 본격적인 취준 생활때에 이러한 생각들이 발목을 잡았다. 회사 스택들은 대부분 Redux를 사용했고, Recoil은 거의 사용하지 않았다. 또한, '왜 Recoil을 사용했나요?'라는 면접 질문을 받으면 '그냥... 학습으로요...?' 정도로만 답하여 광탈했던 경험이 있다.
이러한 실패 경험들 때문에 Redux에 대한 학습 열망이 커졌고, 더불어 '여러가지 라이브러리들 중에 어떤것이 가장 좋은가?' 라는 궁금증도 항상 있었다. 그리하여, 총 6개의 전역 상태관리 라이브러리들을 직접 사용해보고 props로 상태를 상속할때와 contextAPI와는 어떻게 다른지도 직접 비교해보면서 경험하는 프로젝트를 만들었다.
전역 상태를 사용하면 오히려 상태 추적이 어려워지므로 몇 가지 사용할 수 있는 상황에만 사용하는 것이 활용적이다. 이것에 정답은 없지만, 리액트 훅을 활용한 마이크로 상태관리 책을 읽고, 여러 아티클의 의견을 찾아보고 종합해본 결과 나만의 조건을 찾을 수 있었다.
부모 컴포넌트가 다른 자식 컴포넌트들의 상태 공유가 필요할 때
상태 데이터가 리액트 환경 외에 있을 때
뎁스가 너무 깊어졌다고 생각이 될 때
도저히 전역 상태를 사용하지 않고서는 사용할 수 없을 때
라고 나만의 시나리오를 추려보았다. 이 중 간단하게 프로젝트에 적용할 방법이 있을까? 라고 생각해 봤을 때 다크 모드를 구현할 때 가장 전역 상태 관리를 하기에 적합한 환경이라는 생각이 들었다.
다크 모드는 같은 부모 컴포넌트에게 상태를 상속 받아서 구현할 수 있지만, 뎁스가 너무 깊어짐에 따라 props를 일일히 받아줘야 된다고 생각하니 너무 귀찮고, 라우터에서 상태를 넘길 때 불편하기에 이럴 때는 간단하게 모든 컴포넌트가 하나의 상태를 바라보고 그에 따른 스타일 변화를 주면 좋다고 생각이 들었다. 이는 나만의 시나리오 1번에 적합하다고 생각했다.
또한, 이용자가 브라우저를 닫아도 다크모드가 유지되게끔 하기 위해 로컬스토리지를 사용했고, 이는 나만의 시나리오 2번에도 적합하기에 1번과 2번을 결합한 프로젝트가 되었다.
주의
- 각 라이브러리를 간단하게 경험하기 위한 학습 목적이기에 실제 실무 구현과는 다를 수 있음
- 한 프로젝트 안에 구현되었기 때문에 라이브러리들끼리 상태 호환이 안되어 불가피하게
useEffect
를 사용해 마운트 시에 전역상태를 초기화 할 필요가 있었음
이번 프로젝트에서 사용해 본 라이브러리들은 총 6개로 Redux-toolkit, Zustand, Recoil, Jotai, MobX, Valtio를 사용했다. 모두 리액트 훅을 활용한 마이크로 상태관리 책에서 비교한 개념으로 직접 경험해보기 위해 선정했고 리액트에서 제공하는 Context API와 Props로 상속하는 구조도 구현해보면서 차이를 느껴봤다.
상태 상속 페이지를 먼저 마운트하고 상속 받을 컴포넌트를 반환하는 구조를 갖고 있음
// PropsStatePage.tsx
import PropsPage from "./PropsPage"
import { useState } from "react";
import { ThemeType } from "../../types/types";
const PropsStatePage = () => {
const [theme, setTheme] = useState<ThemeType>(() => {
const themeStorage = localStorage.getItem("theme");
return themeStorage === "dark" ? "dark" : "light";
});
return <PropsPage theme={theme} setTheme={setTheme}/>
}
export default PropsStatePage
import { ThemeStateType } from "../../types/types"
import styles from "../../public/App.module.css"
import { Link } from "react-router-dom"
const PropsPage:React.FC<ThemeStateType> = (props) => {
const { theme, setTheme } = props
const toggleTheme = () => {
const newTheme = theme === "light" ? "dark" : "light"
setTheme(newTheme)
localStorage.setItem("theme", newTheme)
}
return (
<div className={`${theme === "light" ? styles.light : styles.dark} ${styles.container}`}>
<header className={styles.header}>
<h1>This page is for Props</h1>
<button onClick={toggleTheme}>
Switch to {theme === "light" ? 'Dark' : 'Light'} Mode
</button>
</header>
<Link to="/">
<button>
Back to Main
</button>
</Link>
</div>
)
}
export default PropsPage
"상태 끌어올리기" (lifting state up)
를 사용한다.Props로 상태를 전달하는 것을 Context API로 유사하게 전달할 수 있다. Context API
로는 Provider
로 지정한 컴포넌트의 하위 컴포넌트들은 모두 해당 Provider
가 공유하는 상태를 공유받을 수 있다.
//ContextAPIState.tsx
import { ReactNode, useState } from "react";
import { ThemeType } from "../../types/types";
import { themeContext } from "./themeContext";
const ContextAPIState = ({children}: {children: ReactNode}) => {
const [theme, setTheme] = useState<ThemeType>(() => {
const themeStorage = localStorage.getItem("theme");
return themeStorage === "dark" ? "dark" : "light";
});
const toggleTheme = (): void => {
const newTheme = theme === "dark" ? "light" : "dark";
setTheme(newTheme);
localStorage.setItem('theme', newTheme)
};
return (
<themeContext.Provider value={{ theme, toggleTheme }}>
{children}
</themeContext.Provider>
);
};
export default ContextAPIState;
//themeContext.ts
import { createContext } from "react";
import { ContextAPIType } from "../../types/types";
export const themeContext = createContext<ContextAPIType>({
theme: "light",
toggleTheme: () => {}
});
//ContextAPI.tsx
import styles from "../../public/App.module.css";
import { Link } from "react-router-dom";
import { useContext } from "react";
import { themeContext } from "./themeContext";
const ContextAPI = () => {
const { theme, toggleTheme } = useContext(themeContext);
return (
<div className={`${theme === "light" ? styles.light : styles.dark} ${styles.container}`}>
<header className={styles.header}>
<h1>This page is for Context API</h1>
<button onClick={toggleTheme}>
Switch to {theme === "light" ? 'Dark' : 'Light'} Mode
</button>
</header>
<Link to="/">
<button>
Back to Main
</button>
</Link>
</div>
);
};
export default ContextAPI;
useContext()
훅을 사용한다.Context
를 사용하는 하위 컴포넌트도 재렌더링될 우려가 있다.Redux를 사용할 수 있지만, 작성해야 할 보일러 플레이트 코드가 너무 많고, 한 번 경험해 보기 위해서는 과하다고 생각이 들어 많은 코드 양을 줄인 Redux-toolkit을 사용했다.
기본적으로 Redux를 사용하기 위해서는 몇 가지 개념을 알고 가야한다.
라이브러리들은 스토어라는 개념을 사용하는데 상태를 저장하는 곳이라고 생각하면 된다. 스토어에 상태를 저장하고 업데이트 하면서 상태를 하위 컴포넌트에서 받을 수 있게끔 설계 되어 있다.
store의 상태를 업데이트 할 수 있는 유일한 수단이다. type으로 상태에 속성을 지정할 수 있다.
리듀서는 상태와 액션을 입력으로 받아서 새로운 상태를 반환하는 순수 함수이다. 리듀서는 상태 변경 로직을 정의하며, 상태는 불변성을 유지해야 하므로 기존 상태를 복사하여 새로운 상태를 반환한다. RTK(Redux-toolkit)에서는 createSlice()
로 reducer와 action을 한꺼번에 사용할 수 있다.
Selector는 스토어에서 특정 상태를 추출하는 함수이다. Selector를 사용하면 컴포넌트에서 필요한 상태를 쉽게 가져올 수 있다.
먼저 slice와 store를 구현한다.
//themeSlice.ts
import { createSlice } from '@reduxjs/toolkit';
const initialTheme = "light"
const initialState = {// 초기상태
theme: initialTheme
};
export const themeSlice = createSlice({
name: 'theme', // 슬라이스의 이름
initialState, // 초기 상태
reducers: { // 리듀서 함수
reduxToggleTheme: state => {
const newTheme = state.theme === 'light' ? 'dark' : 'light';
localStorage.setItem('theme', newTheme);
state.theme = newTheme;
},
initState: state => {
state.theme = localStorage.getItem("theme") || "light"
}
}
});
export const { reduxToggleTheme, initState } = themeSlice.actions;
export default themeSlice.reducer;
// store.ts
import { configureStore } from '@reduxjs/toolkit';
import themeSlice from './themeSlice';
const initialTheme = "light"
const store = configureStore({
reducer: {
theme: themeSlice
}
});
store.subscribe(() => {
console.log('changed!')
});
export default store;
// ReduxToolkitStatePage.tsx
import ReduxToolkitPage from "./ReduxToolkitPage";
import { Provider } from "react-redux";
import store from "../../store/reduxStore/store";
const ReduxTookitStatePage = () => {
return (
<Provider store={store}>
<ReduxToolkitPage/>
</Provider>
)
}
export default ReduxTookitStatePage
// ReduxToolkitPage.tsx
import styles from "../../public/App.module.css"
import { Link } from "react-router-dom"
import { initState, reduxToggleTheme } from "../../store/reduxStore/themeSlice"
import { useDispatch, useSelector } from "react-redux"
import { useEffect } from "react"
import { rootStateType } from "../../types/rootStateType"
const ReduxToolkitPage = () => {
const { theme } = useSelector((state: rootStateType) => state.theme)
const dispatch = useDispatch()
useEffect(() => {
dispatch(initState())
},[])
return (
<div className={`${theme === "light" ? styles.light : styles.dark} ${styles.container}`}>
<header className={styles.header}>
<h1>This page is for Redux Toolkit</h1>
<button onClick={() => dispatch(reduxToggleTheme())}>
Switch to {theme === "light" ? 'Dark' : 'Light'} Mode
</button>
</header>
<Link to="/">
<button>
Back to Main
</button>
</Link>
</div>
)
}
export default ReduxToolkitPage
useSelector
로 스토어에 전역 상태를 받아온다.useDispatch
로 액션을 트리거해준다.reduxToggleTheme
리듀서를 실행한다.Redux의 reducer와store개념을 모티브로 삼아 더욱 간단하게 나온 전역 상태 관리 라이브러리이다. 독일어로 Zustand는 곰이라는 뜻으로 귀여운 곰이 마스코트이다.
Redux를 모티브로 삼았기에 store와 reducer을 구현해야 한다. 하지만, Redux와 비교했을 때 굉장히 간단하다.
//store.ts
import { create } from "zustand";
interface types{
theme: "light" | "dark"
toggleTheme: () => void
initState: () => void
}
export const useStore = create<types>((set) => ({
theme: "light",
toggleTheme: () => {
set((state) => {
const newTheme = state.theme === "light" ? "dark" : "light";
localStorage.setItem("theme", newTheme);
return { theme: newTheme };
});
},
initState: () => {
const storedTheme = localStorage.getItem("theme");
if (storedTheme !== null) {
set({ theme: storedTheme as "light" | "dark"});
}
},
}));
create()
와 set
을 사용해 store
를 생성한다. 특이한 점은 store
에 reducer
가 함께 구현된다.localStorage값
을 가져오게 구현했다.//ZustandStatePage.tsx
import ZustandPage from "./ZustandPage"
const ZustandStatePage = () => {
return <ZustandPage/>
}
export default ZustandStatePage
provider
가 필요없다. 따라서 따로 하위 컴포넌트를 감싸 줄 필요가 없다. 학습을 하기 위해 일부러 뎁스를 하나 늘렸다.//Zustand.tsx
import { useEffect } from 'react';
import styles from '../../public/App.module.css'
import { useStore } from '../../store/zustandStore/store'
import { Link } from 'react-router-dom';
const ZustandPage = () => {
const { theme, toggleTheme, initState } = useStore(state => ({ theme: state.theme, toggleTheme: state.toggleTheme, initState: state.initState }));
useEffect(() => {
initState()
},[])
return (
<div className={`${theme === "light" ? styles.light : styles.dark} ${styles.container}`}>
<header className={styles.header}>
<h1>This page is for Zustand</h1>
<button onClick={toggleTheme}>
Switch to {theme === "light" ? 'Dark' : 'Light'} Mode
</button>
</header>
<Link to="/">
<button>
Back to Main
</button>
</Link>
</div>
)
}
export default ZustandPage
store
에서 reducer
와 state
를 모두 꺼내서 바로 사용할 수 있다. dispatch
로 action
을 트리거 할 필요없다. reducer
로 바로 값을 업데이트 할 수 있다.Recoil은 페이스북이 2020년 5월에 소개한 React 전용으로 나온 상태 관리 라이브러리다. Redux보다 훨씬 간편하여 당시 많은 사람들이 관심을 보였다.
Recoil은 React전용인 만큼 React와 아주 유사한 코드 구조를 가지고 있다.
// store.ts
import { atom } from "recoil";
interface StatusType {
theme: "light" | "dark";
}
export const themeState = atom<StatusType>({
key: 'theme',
default: {
theme: "light"
}
});
atom
이라는 키워드로 상태를 지정한다.key
로 상태의 이름을 지정한다.default
로 초기 상태를 지정한다.//RecoilStatePage.tsx
import { RecoilRoot } from 'recoil'
import RecoilPage from './RecoilPage'
const RecoilStatePage = () => {
return (
<RecoilRoot>
<RecoilPage/>
</RecoilRoot>
)
}
export default RecoilStatePage
//RecoilPage.tsx
import { useEffect } from 'react';
import styles from '../../public/App.module.css'
import { Link } from 'react-router-dom';
import { useSetRecoilState, useRecoilValue } from 'recoil';
import { themeState } from '../../store/recoilStore/store';
const RecoilPage = () => {
const setTheme = useSetRecoilState(themeState);
const recoilValue = useRecoilValue(themeState)
const { theme } = recoilValue
const toggleTheme = (prevTheme: "light" | "dark") => {
if(prevTheme === "light"){
setTheme({theme: "dark"})
localStorage.setItem("theme", "dark")
}else{
setTheme({theme: "light"})
localStorage.setItem("theme", "light")
}
}
useEffect(() => {
const newTheme = localStorage.getItem("theme") === "dark" ? "dark" : "light"
if(newTheme === null){
return
}
setTheme({theme: newTheme})
},[])
return (
<div className={`${theme === "light" ? styles.light : styles.dark} ${styles.container}`}>
<header className={styles.header}>
<h1>This page is for Recoil</h1>
<button onClick={() => toggleTheme(theme)}>
Switch to {theme === "light" ? 'Dark' : 'Light'} Mode
</button>
</header>
<Link to="/">
<button>
Back to Main
</button>
</Link>
</div>
)
}
export default RecoilPage
useRecoilState
, useRecoilValue
와 같이 리액트의 훅과 같은 작명으로 친근함이 든다.useRecoilState
로는 상태를 업데이트한다.useRecoilValue
로는 상태를 가져온다.
Recoil을 모티브로 만든 전역 상태 관리 라이브러리로 Recoil의 Atom개념을 그대로 가지고 왔고, Recoil보다 더욱 간편한 환경을 제공한다.
//store.ts
import { atom } from "jotai"
export const themeAtom = atom<"light" | "dark">("light")
key
가 빠지고 초기 상태를 직접 전달한다.//JotaiStatePage.tsx
import JotaiPage from "./JotaiPage"
const JotaiStatePage = () => {
return <JotaiPage/>
}
export default JotaiStatePage
import { useEffect } from 'react';
import styles from '../../public/App.module.css'
import { Link } from 'react-router-dom';
import { useAtom } from 'jotai';
import { themeAtom } from '../../store/jotaiStore/store';
const JotaiPage = () => {
const [theme, setTheme] = useAtom(themeAtom)
const toggleTheme = (prevTheme: "light" | "dark") => {
if(prevTheme === "light"){
setTheme("dark")
localStorage.setItem("theme", "dark")
}else{
setTheme("light")
localStorage.setItem("theme", "light")
}
}
useEffect(() => {
const newTheme = localStorage.getItem("theme") === "dark" ? "dark" : "light"
if(newTheme === null){
return
}
setTheme(newTheme)
},[setTheme])
return (
<div className={`${theme === "light" ? styles.light : styles.dark} ${styles.container}`}>
<header className={styles.header}>
<h1>This page is for Jotai</h1>
<button onClick={() => toggleTheme(theme)}>
Switch to {theme === "light" ? 'Dark' : 'Light'} Mode
</button>
</header>
<Link to="/">
<button>
Back to Main
</button>
</Link>
</div>
)
}
export default JotaiPage
전역 상태관리 라이브러리를 깊게 사용해보지 나에게는 조금 생소한 라이브러리였다. 하지만, 재미있는 사실이 있었는데 타 라이브러리들과 다른 방법으로 전역상태를 관리한다 바로 Proxy 객체를 사용한다.
Proxy 객체는 객체의 기본 동작을 가로채서 사용자가 원하는 방식으로 처리할 수 있다. MobX는 이를 활용하여 상태 객체를 관찰하고, 상태 변경을 추적하여 필요한 곳에 자동으로 업데이트를 반영한다.
MobX에서는 다른 라이브러리들과 다른 3가지 개념이 있다.
Redux에서는 Reducer를 실행하는 트리거 역할을 했지만, MobX는 Action자체가 상태를 업데이트 한다.
Observable 상태는 상태 변화를 자동으로 감지하고 관리할 수 있도록 돕는 객체이다. MobX는 observable 객체 내의 속성이 변경되는 것을 감지할 수 있지만, 중첩된 객체나 배열의 내부까지 깊게 관찰하지는 않는다. 따라서 객체의 속성이 변경될 때는 별도의 처리가 필요하다.
Derivation은 '유도', '파생'이라는 단어로 MobX에서는 2가지로 나뉜다.
Computed는 MobX에서 파생된 값(computed value)이다. 이는 다른 observable
상태의 변화에 의해 자동으로 업데이트되는 값이다.
MobX는 이러한 Computed 값을 자동으로 관리하여, 값이 의존하는 상태가 변할 때마다 적절하게 업데이트한다.
Reaction은 특정 상태의 변화에 반응하여 부작용(side effect)을 실행하는 것을 말한다. MobX의 reaction은 주로 외부 상태 변화에 대응하여 특정 동작을 수행하거나, UI를 업데이트하는 등의 작업을 할 때 사용된다. Reaction은 특정 observable
상태를 관찰하고, 그 상태가 변경될 때마다 실행할 콜백 함수를 등록한다.
//store.ts
import { makeAutoObservable } from 'mobx';
const createThemeStore = () => {
const theme = localStorage.getItem('theme') || 'light';
const store = {
theme,
setTheme(newTheme: "light" | "dark") {
store.theme = newTheme;
localStorage.setItem('theme', newTheme);
},
toggleTheme() {
store.setTheme(store.theme === 'light' ? 'dark' : 'light');
}
};
makeAutoObservable(store);
return store;
};
const themeStore = createThemeStore();
export default themeStore;
//MobXPageState.tsx
import MobXPage from "./MobXPage"
const MobXStatePage = () => {
return <MobXPage/>
}
export default MobXStatePage
//MobXPage.tsx
import { useEffect } from 'react';
import { Link } from 'react-router-dom';
import themeStore from '../../store/mobXStore/store';
import styles from '../../public/App.module.css'
import { observer } from 'mobx-react';
const MobXPage = observer(() => {
const { theme, toggleTheme, setTheme } = themeStore
useEffect(() => {
const newTheme = localStorage.getItem("theme") === "dark" ? "dark" : "light"
if(newTheme === null){
return
}
setTheme(newTheme)
},[theme])
return (
<div className={`${theme === "light" ? styles.light : styles.dark} ${styles.container}`}>
<header className={styles.header}>
<h1>This page is for MobX</h1>
<button onClick={toggleTheme}>
Switch to {theme === "light" ? 'Dark' : 'Light'} Mode
</button>
</header>
<Link to="/">
<button>
Back to Main
</button>
</Link>
</div>
)
})
export default MobXPage
Valtio는 MobX를 모티브로 하지 않았지만, 유사한 구조를 가지고 있다. Derivation
개념과 Observable 상태
개념을 사용하고 있다.
//store.ts
import { proxy } from 'valtio';
const themeStore = proxy({
theme: localStorage.getItem('theme') || 'light',
setTheme(newTheme: "light" | "dark") {
themeStore.theme = newTheme;
localStorage.setItem('theme', newTheme);
},
toggleTheme() {
themeStore.setTheme(themeStore.theme === 'light' ? 'dark' : 'light');
}
});
export default themeStore;
//VatioStatePage.tsx
import ValtioPage from "./ValtioPage"
const ValtioStatePage = () => {
return <ValtioPage/>
}
export default ValtioStatePage
//ValtioPage.tsx
import { useEffect } from 'react';
import { Link } from 'react-router-dom';
import themeStore from '../../store/valtioStore/store';
import { useSnapshot } from 'valtio';
import styles from '../../public/App.module.css'
const ValtioPage = () => {
const { theme, toggleTheme, setTheme } = useSnapshot(themeStore)
useEffect(() => {
const newTheme = localStorage.getItem("theme") === "dark" ? "dark" : "light"
if(newTheme === null){
return
}
setTheme(newTheme)
},[theme])
return (
<div className={`${theme === "light" ? styles.light : styles.dark} ${styles.container}`}>
<header className={styles.header}>
<h1>This page is for Valtio</h1>
<button onClick={toggleTheme}>
Switch to {theme === "light" ? 'Dark' : 'Light'} Mode
</button>
</header>
<Link to="/">
<button>
Back to Main
</button>
</Link>
</div>
)
}
export default ValtioPage
useSnapShot()
이라는 함수를 사용하는데 이를 통해 store에 접근해서 Action과 State을 받는다.자! 나름 열심히 끙끙대며 모든 라이브러리들을 써보는 특별한 경험을 했다. 그렇다면 어떤 라이브러리를 써야 할 지 정답을 알 수 있었을까?
리액트를 훅을 활용한 마이크로 상태관리 책에서는 전역 상태관리를 마이크로하게 관리하라 하고 이렇게 설명한다.
마이크로 상태관리란?
- 컴포넌트 단위에서 로컬 상태를 관리하고, 필요에 따라 적절하게 전역 상태와 결합하는 방식이다.
- 이는 애플리케이션의 상태 관리 복잡성을 줄이고, 상태 관리를 보다 직관적이고 간단하게 하기 위한 접근법이다.
- 이를 위해 경량화 된 라이브러리들을 사용한다.
정답은 경량화 된 라이브러리를 사용해야하는 것이다. 애초에 전역 상태 관리 자체가 그렇게 비용을 소모할 필요가 없다고 생각한다. 그러므로 최대한 경량화 된 라이브러리를 사용하는 것을 권장한다.
여기까지 글을 읽은 독자들도 나와 생각이 같다면 Jotai가 이러한 조건에 가장 적합하다고 생각이 든다. 필자가 속한 오픈 카톡방에서 실무 개발자들의 후기들을 살펴볼 수 있었다.
카톡 내용에서도 보다시피 Jotai가 편한것은 사실이지만 모든 프로젝트를 Jotai로 지정할 필요도 없고 때에 맞게 사용하면 된다.
굳이 잘 굴러가는 프로젝트에 Jotai를 마이그레이션 하여 비용을 소모할 필요도 없고, 팀원들을 설득할 필요도 없다. 더군다나 나같은 신입은 회사가 Redux를 사용하면 그냥 Redux를 사용하면 된다. 까라면 까 아까도 언급했지만, 애초에 전역 상태 관리가 크게 역량이 필요한 기능이 아니라서 팀원들이 불편하지 않다면 그냥 사용해도 무관하다고 생각한다.
중요한 것은 무엇을 사용하느냐 보다 전역상태를 어떨 때 사용하는지 파악하고 어떻게 해야 상태를 잘 추적해서 버그나 이슈를 안 발생시킬 지 고민하는 것이 먼저라는 생각이 들었다.
https://state-comparison.vercel.app/
https://github.com/joshyeom/state-comparison