리액트 생태계에는 다양한 상태 관리 관련 라이브러리들이 있습니다. 그 중에서, 리덕스 (Redux)가 가장 많이 사용되고 있지요. 통계에 따르면 약 48%의 개발자들이 리액트 프로젝트에서 현재 리덕스를 사용하고 있습니다 [1]. 그런만큼, 현업에서 리덕스를 사용중인 분들이 많을 것이라고 예상합니다.
리덕스의 사용방식에 있어서는 딱 정해진 규칙이 존재하지 않기 때문에 개발자들 모두 자신만의 방식으로 서로 다르게 사용을 하고 있습니다. 자신의 취향에 따라 사용할 수 있다는 것이 장점이기도 한데, 단점이기도 합니다. 제대로 쓰지 않으면 굉장히 불편할 수 있고, 유지 보수에 있어서 오히려 독이 될 수도 있거든요.
이 글에서는 제가 생각하는 리덕스의 올바른 사용법에 대하여 다뤄보고자 합니다. 주관적인 의견이 다수 내재되어 있으니 이 포스트에서 제시하는 방향이 무조건 정답이라고만 받아들이지는 마시고, 여러분이 유용하다고 생각하는 부분들을 여러분들의 프로젝트에 적용해보세요.
리덕스, 정말 필요할까?
리덕스가 필요한 프로젝트에서는 리덕스를 아주 유용하게 사용할 수 있지만, 그렇지 않은 프로젝트에선 그저 개발을 귀찮게 만드는 짐이 될 뿐입니다. 따라서, 리덕스를 사용하기 전에는 꼭 현재 여러분의 프로젝트에 리덕스가 정말 필요한지 고민을 하실 필요가 있습니다.
리액트 개발의 초창기(2015-2018)때는 리액트 프로젝트에서 리덕스를 사용하는 것이 당연시되어 왔습니다. 하지만, 이제는 그럴 필요가 없습니다. 리액트 자체적인 기능만을 사용해도 리덕스 없이 프로젝트를 충분히 개발 할 수 있고, 다른 라이브러리의 도움을 받아 훨씬 편하게 개발할 수 있는 방법들이 존재합니다.
Context API
단순히 글로벌 상태를 사용하고자 한다면 리액트의 Context API 만으로도 충분히 구현을 할 수 있습니다.
여기서 잠깐, 오해할 만한 부분이 있는데 Context 는 리덕스와의 비교 대상이 아닙니다. Context는 수단일 뿐 사실상 상태관리 자체는 리액트 컴포넌트의 useState
와 useReducer
로 하게 되는 것 입니다.
리덕스와의 주요 차이는 성능 면에서 나타나게 됩니다. 리덕스에서는 컴포넌트에서 글로벌 상태의 특정 값을 의존하게 될 때 해당 값이 바뀔 때에만 리렌더링이 되도록 최적화가 되어있습니다. 따라서, 글로벌 상태 중 의존하지 않는 값이 바뀌게 될 때에는 컴포넌트에서 낭비 렌더링이 발생하지 않겠지요. 반면 Context에는 이러한 성능 최적화가 이뤄지지 않았습니다. 컴포넌트에서 만약 Context의 특정 값을 의존하는 경우, 해당 값 말고 다른 값이 변경 될 때에도 컴포넌트에서는 리렌더링이 발생하게 됩니다.
따라서, Context 를 사용하게 될 때에는 관심사의 분리가 굉장히 중요합니다. 서로 관련이 없는 상태라면 같은 Context 에 있으면 안됩니다. Context를 따로 따로 만들어주어야하죠.
추가적으로, Context에서 상태를 업데이트를 하는 상황에서도 성능적으로 고려해야 될 부분이 있습니다. 우리가 Provider 를 만들게 될 때 다음과 같이 상태와, 상태를 업데이트하는 함수를 value
에 넣는 상황이 발생하기도 합니다. 다음 코드를 한번 확인해보세요.
import React, { createContext, useState, useContext } from "react";
const UserContext = createContext(null);
function UserProvider({ children }) {
const [user, setUser] = useState(null);
return (
<UserContext.Provider value={{ user, setUser }}>
{children}
</UserContext.Provider>
);
}
function useUser() {
return useContext(UserContext);
}
function UserInfo() {
const { user } = useUser();
if (!user) return <div>사용자 정보가 없습니다.</div>;
return <div>{user.username}</div>;
}
function Authenticate() {
const { setUser } = useUser();
const onClick = () => {
setUser({ username: "velopert" });
};
return <button onClick={onClick}>사용자 인증</button>;
}
export default function App() {
return (
<UserProvider>
<UserInfo />
<Authenticate />
</UserProvider>
);
}
위 코드는 작동 측면에서 전혀 문제가 없습니다. 하지만, 성능적인 부분에서는 조금은 아쉬운 점이 있습니다.
현재 UserInfo
컴포넌트에서는 Context에 담긴 사용자의 정보를 보여주고 있고, Authenticate
컴포넌트의 경우엔 사용자 상태를 업데이트하는 버튼을 보여주고 있습니다. 여기서, 사용자가 버튼을 누르게 됐을 때, UserInfo
컴포넌트가 리렌더링이 되는 것은 당연한데요, 문제는 Authenticate
도 함께 리렌더링이 된다는 것 입니다. setUser
함수가 바뀌지도 않았는데 말이죠.
물론 위와 같은 상황에서는 워낙 소규모의 뷰가 담겨져있는 컴포넌트이고 업데이트도 1회성이기 때문에 실질적으로 성능적으로 큰 문제는 없습니다. 리액트는 충분히 괜찮은 성능을 갖추고 있기 때문이죠. 하지만, 상태도 다양해지고, 뷰도 다양해지게 되면 위와 같은 구조로 Context를 사용하게 되면 낭비되는 렌더링이 너무 많이 발생하게 되어 성능적으로 좋지 못합니다.
따라서, 이를 해결하기 위해서는 다음과 같이 두개의 Context를 사용해야 합니다.
import React, { createContext, useState, useContext } from "react";
const UserContext = createContext(null);
const UserUpdateContext = createContext(null);
function UserProvider({ children }) {
const [user, setUser] = useState(null);
return (
<UserContext.Provider value={user}>
<UserUpdateContext.Provider value={setUser}>
{children}
</UserUpdateContext.Provider>
</UserContext.Provider>
);
}
function useUser() {
return useContext(UserContext);
}
function useUserUpdate() {
return useContext(UserUpdateContext);
}
function UserInfo() {
const user = useUser();
if (!user) return <div>사용자 정보가 없습니다.</div>;
return <div>{user.username}</div>;
}
function Authenticate() {
const setUser = useUserUpdate();
const onClick = () => {
setUser({ username: "velopert" });
};
return <button onClick={onClick}>사용자 인증</button>;
}
export default function App() {
return (
<UserProvider>
<UserInfo />
<Authenticate />
</UserProvider>
);
}
위 코드와 같이 UserUpdateContext
를 만들어서 상태를 위한 Context와 상태 업데이트를 위한 Context를 따로 사용하게 된다면 성능적인 부분이 많이 해소가 됩니다. 이제는 버튼을 눌러도 Authenticate
컴포넌트는 리렌더링을 하지 않게 됩니다.
Context를 사용한다면 관심사를 분리하는것도 중요하고, 이렇게 업데이트용과 상태용 Context를 분리하는것도 중요합니다. 만약 전역적으로 관리해야 할 상태가 많아지는 경우에는 성능을 챙기기 위해선 그만큼 다양한 종류의 Context를 위한 코드를 준비해야 합니다. 저는 개인적으로 이는 손이 많이 가기 때문에 좀 번거롭다고 생각합니다. 따라서, 글로벌 상태가 다양해지는 경우는 Context 의 사용은 적합하지 않을 수 있다고 말씀을 드리고 싶습니다.
constate
만약, Context를 사용하고 싶은데, 다뤄야하는 상태가 많아진 상황에서 성능도 챙겨가면서 개발도 편하게 하고 싶다면, constate 라는 라이브러리를 적극 추천드립니다.
이 라이브러리는 Context를 기반으로 작동하는데요, 우리가 아까 봤던 두번째 예시처럼 상태를 위한 Context와 상태 업데이트를 위한 Context를 따로 만들었던 작업을 하나의 함수로 간편하게 처리 할 수 있게 해줍니다.
이 라이브러리를 사용하면 아까 작성했던 코드를 다음과 같이 구현할 수 있습니다.
import React, { useState } from "react";
import constate from 'constate';
// Context를 따로 만들지 않고, 관리하고 싶은 상태를 위한 Hook 작성
function useUser() {
const [user, setUser] = useState(null);
return { user, setUser };
}
// useUser Hook을 기반으로 종류별로 Context를 만들고,
// 해당 Context를 사용하는 Provider와 Hook 생성
const [UserProvider, useUserValue, useUserUpdate] = constate(
useUser,
value => value.user,
value => value.setUser
);
function UserInfo() {
const user = useUserValue();
if (!user) return <div>사용자 정보가 없습니다.</div>;
return <div>{user.username}</div>;
}
function Authenticate() {
const setUser = useUserUpdate();
const onClick = () => {
setUser({ username: "velopert" });
};
return <button onClick={onClick}>사용자 인증</button>;
}
export default function App() {
return (
<UserProvider>
<UserInfo />
<Authenticate />
</UserProvider>
);
}
constate
함수에 넣는 selector들을 기반으로 Context들이 자동으로 만들어지고, 해당 Context를 사용하는 Hook도 자동으로 만들어집니다. 이 라이브러리를 사용하면 Context를 분리하는 작업이 엄청 쉬워지겠죠?
상태 관리 측면에서 useState
, useReducer
를 그냥 사용하면 되기 때문에 추가적으로 배울 것도 없답니다. 타입스크립트 지원도 잘 되고, Weekly 다운로드 수도 충분히 높고, 유지 보수도 잘 되고 있는 라이브러리이기 때문에 사용을 적극 추천합니다.
Recoil
Context 만을 사용하여 글로벌 상태 관리를 하는 것이 어느정도 제한이 있다는 것은 페이스북 개발팀에서도 인지를 하고 있습니다 [2]:
- 상태 업데이트를 하기 위해서는 공통 조상 컴포넌트로 상태를 끌어올려야 하는데, 이 과정에서 너무 큰 트리가 리렌더링 될 수 있습니다.
- Context를 사용 할 때 Consumer는 다양하게 사용되는데 Context에는 하나의 값만 담을수 있습니다.
- 위 두가지 방식 모두 상태가 만들어지는 곳과 상태가 사용되는 곳의 코드 분리가 어렵습니다.
Recoil은 페이스북 개발팀에서 위 문제들을 리액트 스러운 방법으로 개선하기 위하여 만든 상태 관리 라이브러리입니다.
이 라이브러리는 현재 experimental 상태이긴 합니다. 이 라이브러리의 AtomEffect 기능은 아직 개발중이며 나중에 스펙이 바뀔 수 있으니 주의가 필요하지만, 나머지 기능은 많이 안정화가 되어있는 상태이기 때문에 나머지 기능은 프로덕션에서 사용해도 큰 문제가 없습니다. 리디에서도 일부 기능에서 해당 라이브러리를 사용하고 있고, 커뮤니티에서도 프로덕션에서 사용하고 있다는 사람들이 종종 보입니다. 하지만, 공식적으로는 experimental 단계이기 때문에 보수적으로 다가가고자 한다면 조금 더 지켜보는것도 좋습니다.
Recoil을 사용해서 아까 작성했던 기능을 구현한다면 다음과 같이 사용 할 수 있습니다.
import React from "react";
import { RecoilRoot, atom, useSetRecoilState, useRecoilValue } from "recoil";
// 특정 상태를 고유 key 값과 함께 선언합니다
const userState = atom({
key: "userState",
default: null
});
function UserInfo() {
// useRecoilValue를 사용하면 원하는 상태 값을 조회 할 수 있습니다.
const user = useRecoilValue(userState);
if (!user) return <div>사용자 정보가 없습니다.</div>;
return <div>{user.username}</div>;
}
function Authenticate() {
// useSetRecoilState를 사용하면 원하는 상태 업데이터 함수를 가져올 수 있습니다.
const setUser = useSetRecoilState(userState);
const onClick = () => {
setUser({ username: "velopert" });
};
return <button onClick={onClick}>사용자 인증</button>;
}
export default function App() {
return (
<RecoilRoot>
<UserInfo />
<Authenticate />
</RecoilRoot>
);
}
위 코드에서 나타난 기능들 외에도, 상태에서 특정 값만을 조회하는 selector
기능과, 상태와 업데이트를 한꺼번에 가져올 수 있는 useRecoilState
라는 기능도 있습니다.
import { atom, selector, useRecoilValue, useRecoilState } from 'recoil;'
const messageState = atom({
key: "messageState",
default: ""
});
const messageLengthState = selector({
key: "messageLengthState",
get: ({ get }) => get(messageState).length
});
function MessageInput() {
// 상태와 업데이터를 한번에 가져오기
const [message, setMessage] = useRecoilState(messageState);
return <input value={message} onChange={(e) => setMessage(e.target.value)} />;
}
function MessageLength() {
// selector를 통하여 상태의 특정 부분만을 조회
const length = useRecoilValue(messageLengthState);
return <div>Length: {length}</div>;
}
Jotai
만약, Recoil 의 API가 참 마음에 드는데 Experimental인 부분이 걱정이라면, Jotai 를 추천드립니다. Jotai는 일본어 狀態 를 그대로 발음 한 것이라고 합니다. 또 다른 상태 관리 라이브러리중 Zustand 라는것도 있는데요, 이건 독일어로 상태라고 합니다 🤣 만약에 우리나라 사람이 만들면 Sangtae가 되겠네요.
본론으로 넘어와서, Jotai 에서는 Minimalistic API 를 강조합니다. Recoil과 비슷하지만 훨씬 간단합니다. 다운로드 수는 높은 편이 아니지만, react-spring을 개발했던 Poimandres 의 개발자들이 만들었기 때문에 충분히 신뢰하고 사용 할 수 있다고 생각합니다.
Jotai는 다음과 같이 사용 할 수 있습니다.
import React from "react";
import { atom, Provider, useAtom } from "jotai";
// Jotai에서는 모든걸 'atom' 이라고 부릅니다
// null 을 기본 상태로 가지는 atom
const userAtom = atom(null);
// 업데이트 함수만 사용 할 수 있게 해주는 atom
const updateUserAtom = atom(null, (get, set, arg) => set(userAtom, arg));
// 문자열 상태를 지닌 atom
const messageAtom = atom("");
// 문자열의 length 를 조회하는 atom
const messageLengthAtom = atom((get) => get(messageAtom).length);
function UserInfo() {
// 값을 조회 할 때는 useAtom 을 사용하며, 반환 값은 배열입니다
const [user] = useAtom(userAtom);
if (!user) return <div>사용자 정보가 없습니다.</div>;
return <div>{user.username}</div>;
}
function Authenticate() {
// 업데이트 함수만 사용하니까, 첫번째 배열 원소는 생략합니다
const [, setUser] = useAtom(updateUserAtom);
const onClick = () => {
setUser({ username: "velopert" });
};
return <button onClick={onClick}>사용자 인증</button>;
}
function MessageInput() {
// 상태와 업데이터를 한번에 가져오기
const [message, setMessage] = useAtom(messageAtom);
return <input value={message} onChange={(e) => setMessage(e.target.value)} />;
}
function MessageLength() {
// selector를 통하여 상태의 특정 부분만을 조회
const [length] = useAtom(messageLengthAtom);
return <div>Length: {length}</div>;
}
export default function App() {
return (
<Provider>
<UserInfo />
<Authenticate />
<div>
<MessageInput />
<MessageLength />
</div>
</Provider>
);
}
정말 간단하죠? Provider
, atom
, useAtom
이 3개로 모든 글로벌 상태 관리를 해버립니다. 이 라이브러리의 공식 홈페이지에 들어가면 꽤 공식 문서가 꽤 놀랍습니다.
정말 이게 전부입니다 😲. 모든 기능이 위 예시로 설명됩니다. 물론 더 다양한 상황에 대한 가이드는 GitHub Repo 에서 확인 할 수 있으니 궁금하다면 읽어보세요.
언제 리덕스가 필요할까?
이제는, 글로벌 상태 관리에 있어서 대체로 사용 할 수 있는 방법들에 대해서는 그만 알아보겠습니다. 정말 다양한 선택지가 있는데, 그럼 언제 리덕스를 사용해야 할까요? 이에 대하여 정답은 없겠지만 이에 대하여 제 생각을 정리해보겠습니다.
- 리덕스를 사용한 개발 스타일이 너무 마음에 들 때
제 주변에서 리덕스를 사용하는 사람들을 보면 두 집단으로 나뉩니다. 첫번째는 “와! 리덕스 정말 좋아!” 라는 반응을 갖고 있는 사람이고, 두번째는 “리덕스 너무 불편해!” 라는 반응을 갖고 있는 사람들입니다. 제가 느끼기엔, 적당한 상황에서 쓰면 정말 편하고 좋은 도구이고, 불필요한 상황이면 번거롭고 불편할 수도 있습니다. 특히, 처음 공부하는 과정에서 사용해야 하는 이유를 정확히 이해하지 못하고 무조건 사용하는 경우엔 더더욱 불편하게 느껴지겠죠. 모든 상태 업데이트를 액션으로 정의하고, 액션 정보에 기반하여 리듀서에서 상태를 업데이트하는 이 간단명료한 발상 덕분에, 상태를 더욱 쉽게 예측 가능하게 하여 유지보수 측면에 긍정적인 효과가 있죠. 이게 마음에 드는 사람들은 계속 리덕스를 사용하시면 됩니다. - 미들웨어
리덕스와 다른 라이브러리들의 특별한 차이점은 미들웨어가 존재한다는 것 입니다. 특정 액션이 디스패치 됐을 때 상태 업데이트외의 다른 작업들을 따로 처리 할 수가 있죠. 보통 API 요청을 할 때 미들웨어를 사용하곤 합니다. 이전에는 API 요청을 위하여 리덕스와 미들웨어를 사용하는 것이 당연시 되긴 했는데, 이제는 SWR 과 react-query 같은 라이브러리가 있기 때문에 단순 API 요청을 위하여 미들웨어를 사용 할 필요는 없습니다. 그렇지만, 비동기 작업에 대한 플로우에 대하여 더 많은 컨트롤을 필요로 할 때 미들웨어는 정말 유용하게 사용 될 수 있습니다. 미들웨어로 편하게 해결 할 수 있는 상황들에 대해서는 이 포스트에서 추후 다뤄보겠습니다. - 서버사이드 렌더링
리덕스를 사용하면, API 요청 결과를 사용하여 서버사이드 렌더링을 하는 것이 용이합니다. 이 과정에서 미들웨어가 정말 유용하게 사용되지요. 리덕스가 없어도 충분히 구현 할 수는 있긴 하지만 레퍼런스도 부족하고 번거로운 편입니다. 물론, Next.js 를 사용한다면 조금 다른 얘기이긴 합니다. 이 포스트에서 설명했던 다른 대안 Recoil, Jotai 등의 라이브러리는 아직 서버사이드 렌더링을 처리하기엔 준비가 되어있지 않습니다 (언젠간 정식적으로 지원을 할 것으로 보입니다.) - 더 쉬운 테스팅
리덕스를 사용한 앱은 테스트를 하기가 비교적 쉽습니다. 리듀서에서 다양한 상태 업데이트에 대한 로직을 테스트하기도 쉽고, 리덕스와 연동된 컴포넌트를 테스트 할 때 Mocking 할 수도 있고 미들웨어의 작동방식도 Mocking 할 수 있습니다. - 컴포넌트가 아닌 곳에서 글로벌 상태를 사용하거나 업데이트를 해야 할 때
WebSocket을 사용한다거나, 리액트 네이티브 브릿지에서 연동을 할 때getState
또는dispatch
를 바로 호출해서 사용하면 꽤 유용한 상황이 있기도 합니다. - 그냥 많이 사용 돼서
많은 개발자가 리덕스를 사용하는 이유중엔.. 이미 유지보수를 하고 있는 프로젝트에서 리덕스를 사용중이기 때문이 확률이 크다고 생각합니다. 분명히, 과거에는 선택지가 별로 없고, MobX가 그나마 유일했었으니까요. 프로젝트에 리덕스가 필수적이라고 느껴지지 않는다고 해서, 아예 걷어내는건 또 큰 공수가 드니 계속 유지하면서 사용하는 케이스도 많을 것이라 생각합니다. 다만, 그러한 경우엔 새로운 기능 또는 리팩토링 하는 기능에 있어선 다른 방식을 시도해보는것도 좋을 것이라 판단합니다.
만약 위 6개 상황에 해당하지 않는다면, 꼭 리덕스를 사용 할 특별한 이유는 없을 것으로 보입니다. 이 포스트를 읽고 계신 독자분들 중에선, 어떠한 이유로 리덕스를 사용하고 계신가요? 여기에 해당하지 않는 이유가 있다면 댓글로 적어주세요!
Redux Toolkit은 이제 필수템입니다
이제 드디어 포스트의 본론에 도달했습니다. 리덕스를 사용 할 필요가 없다면 사용하지 않는 것이 옳고, 만약 사용을 해야 된다면 어떻게 사용해야 좋을까요? 첫번째로 다루고 싶은 것은 Redux Toolkit 입니다. 아마 리덕스를 사용하시는 분들은 이미 많이 사용하고 계실 것이라 생각하는데요, 만약 아직 사용하고 있지 않으시다면 꼭 사용하기를 권장드립니다.
리덕스의 단점 중에선 보일러플레이트 코드를 참 많이 준비해야 한다는 것 입니다. 액션 타입, 액션 생성함수, 리듀서 이렇게 3가지 종류로 코드를 준비해야 합니다.
export const OPEN = 'msgbox/OPEN';
export const CLOSE = 'msgbox/CLOSE';
export const open = (message) => ({ type: OPEN, message });
const initialState = {
open: false,
message: '',
};
export default msgbox(state = initialState, action) {
switch (action.type) {
case OPEN:
return { ...state, open: true, message: action.message };
case CLOSE:
return { ...state, open: false };
default:
return state;
}
}
이러한 작업은 적응하면 괜찮긴 한데 프로젝트가 커질수록 이 작업이 귀찮아집니다. 그리고, 하나의 리듀서에서 관리하는 상태가 커지고, 세부적인 업데이트가 늘어날수록 불변성을 지키기 위하여 ...state
를 사용하는것도 꽤 번거롭습니다. 물론, 불변성 관련 부분은 immer 를 사용해서 간소화 할 수 있기는 합니다.
특히 이는 리덕스를 처음 사용하는 사람들에게는 이 작업이 번거로워서 리덕스에 대한 나쁜 인상을 주는 요소중 하나이죠.
그나마 코드를 더 간단하게 작성하기 위해서 redux-actions 같은걸 사용하긴 했었는데, 그걸 사용해도 조금 편해졌을 뿐 준비해야 할 코드는 여전히 많은 편에 속했습니다. 추가적으로, TypeScript 지원이 안돼서 TypeScript 지원을 위해서는 typesafe-actions 를 사용하기도 하죠.
저는 과거에 위 라이브러리들을 사용해왔었는데요, 2020년엔 리덕스 개발팀에서 드디어! 공식적으로 Redux Toolkit 이라는 라이브러리를 릴리즈했습니다. 이 라이브러리가 있다면, 리덕스가 불편하다는 편견을 깰 수 있다고 감히 말씀드릴 수 있습니다.
Redux Toolkit을 사용하면 리듀서, 액션타입, 액션 생성함수, 초기상태를 하나의 함수로 편하게 선언 할 수 있습니다. 이 라이브러리에선 이 4가지를 통틀어서 slice
라고 부릅니다.
만약, 위 코드에서 봤던 메시지 박스를 띄우는 기능에 대한 slice
를 생성한다면, 다음과 같이 작성 합니다.
import { createSlice } from '@reduxjs/toolkit';
const msgboxSlice = createSlice({
name: 'msgbox',
initialState: {
open: false,
message: '',
},
reducers: {
open(state, action) {
state.open = true;
state.message = action.payload
},
close(state) {
state.open = false;
}
}
});
export default msgboxSlice;
// reducer: msgboxSlice.reducer
// action creators: msgboxSlice.actions.open, msgboxSlice.actions.close
// actionType:
// - msgboxSlice.actions.open.type: 'msgbox/open'
// - msgboxSlice.actions.close.type: 'msgbox/close'
이 라이브러리를 사용하면 이렇게 리듀서와 액션 생성 함수를 한방에 만들 수가 있답니다. 그리고, Immer가 내장되어있기 때문에, 불변성을 유지하기 위하여 번거로운 코드들을 작성하지 않고 원하는 값을 직접 변경하면 알아서 불변셩 유지되면서 상태가 업데이트 됩니다.
TypeScript 꼭 사용하세요
TypeScript, 이미 사용하고 계신분들도 있고, 사용하고 계시지 않은 분들도 계실 것이라고 생각합니다. 기존에 JavaScript만 사용하던 개발자분들 중에선 TypeScript가 초반에 러닝커브가 있어서 배우는걸 망설이고 미루고 있으신 분들도 계실텐데요, 사실 알고보면 별거 없으니까 더 이상 미루시지 않고 공부하는 것을 행동을 옮기시는 것을 적극적으로 권장 드립니다.
리덕스를 사용 할 때, TypeScript를 사용하지 않으면, 우리가 컴포넌트에서 상태를 조회할때, 그리고 액션생성 함수를 사용 할 때 자동완성이 되지 않으므로 실수하기가 쉽습니다.
상태에 어떤 값이 있는지 프로젝트가 복잡해지면 우리가 외우기 힘들기 때문에 아마 JavaScript만 사용하시는 분들은 리덕스 관련 코드를 한쪽에 띄워놓고 참고해가면서 작업을 해야 할 것입니다. 만약 TypeScript를 쓴다면 다음과 같이 자동완성이 되기 때문에 생산성에 큰 도움을 줍니다.
액션 생성함수를 사용 할 때에도 파라미터에 무엇을 넣어야 할 지 리덕스 관련 코드를 열어보지 않고도 확인 할 수 있어서 매우 편합니다.
참고로, Redux Toolkit은 TypeScript 지원이 아주 잘 됩니다. 상태에 대한 타입과, 액션에 대한 타입만 명시하면 됩니다.
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
type MsgboxState = {
open: boolean;
message: string;
}
const initialState: MsgboxState = {
open: false,
message: ''
};
const msgboxSlice = createSlice({
name: 'msgbox',
initialState,
reducers: {
open(state, action: PayloadAction<string>) {
state.open = true;
state.message = action.payload;
},
close(state) {
state.open = false;
}
}
});
export default msgboxSlice;
TypeScript에서 리덕스를 사용 할 때, 리액트 컴포넌트에서 리덕스 상태를 조회하는 경우 다음과 같이 작성합니다.
const something = useSelector((state: RootState) => state.some.thing);
이 때 RootState
타입을 준비할 때는 다음과 같이 하면 편합니다.
const rootReducer = combineReducers({ ... })
export type RootState = ReturnType<typeof rootReducer>;
그리고 상태를 조회 할 때마다 useSelector
와 RootState
를 불러오는데, 저는 이 과정이 조금 귀찮아서 다음과 같이 따로 Hook을 만들어서 사용한답니다.
type StateSelector<T> = (state: RootState) => T;
type EqualityFn<T> = (left: T, right: T) => boolean;
export function useRootState<T>(selector: StateSelector<T>, equalityFn?: EqualityFn<T>) {
return useSelector(selector, equalityFn);
}
그럼 나중에 컴포넌트에선 다음과 같이 바로 바로 사용 할 수 있지요.
function MyComponent() {
const something = useRootState(state => state.some.thing);
// ...
}
Selector 사용하기
리덕스를 사용하여 프로젝트를 개발하시는 분들은 reselect 라는 라이브러리를 들어본적이 있는 분들이 계실 것입니다. 이 라이브러리를 사용하면 우리가 원하는 상태를 조회 하는 과정에서 memoization을 할 수 있습니다. 이 기능은 Redux Toolkit에도 내장되어 <a href="https://redux-toolkit.js.org/api/createSelector" target="_blank" rel="noreferrer noopener">createSelector</a>
를 통해 사용 할 수 있습니다. 이 섹션에서는 리덕스의 selector
에 대하여 얘기해보고자 합니다.
리덕스에서 selector
는 필수적인 요소는 아니지만 사용을 한다면 다음 두가지 상황에 유용하게 사용 될 수 있습니다.
- 상태의 위치 (key) 가 변경 될 때
- 컴포넌트 리렌더링 최적화를 할 때
상태의 위치가 변경되는 상황
리덕스에서 다음과 같이 상태를 지니고 있다고 가정해봅시다.
{
user: {
id: 1,
username: 'velopert',
displayName: 'MinJun'
},
settings: { /* ... */ }
}
컴포넌트에서 user
값을 사용한다면 다음과 같이 작성하겠죠?
const user = useSelector(state => state.user);
그런데 어느 날 서비스 로직이 좀 많이 변경돼서 이 사용자 상태의 위치를 다음과 같이 변경해야된다고 가정해봅시다.
{
auth: {
isCertified: false,
user: {
id: 1,
username: 'velopert',
displayName: 'MinJun',
}
},
settings: { /* ... */ }
}
state.user
가 더 이상 존재하지 않고 state.auth.user
를 조회해야 하는 상황이 왔는데요, 이렇게 되면 기존에 state.user
에 의존하던 모든 코드들을 찾아서 변경해주어야 합니다.
하지만, selector
를 따로 만들어서 사용했더라면 얘기가 조금 달라지지요. 이렇게, 기존에 useSelector
의 인자에 넣었었던 함수를 따로 선언하고
export const userSelector = state => state.user;
추후 사용할땐 이렇게 불러와서 사용했더라면
const user = useSelector(userSelector);
만약 상태의 위치가 변경되는 상황이 왔을 때 selector
만 다음과 같이 변경하면 나머지는 자동으로 반영이 되겠지요.
export const userSelector = state => state.auth.user;
리렌더링 최적화를 위해 Memoized Selector 사용하기
selector
의 주된 사용처는 컴포넌트 리렌더링 최적화를 위함이라고 생각합니다. 상태의 위치가 변경이 될 때 사용하면 유용하긴 하지만 IDE의 Find & Replace 기능으로 충분히 쉽게 반영 할 수 있기 때문에 상태 조회를 할 때 selector
를 만드는 건 불필요한 추가 절차라고 느껴지기도 합니다.
하지만, 리렌더링 최적화를 해야하는 상황에서는 정말 유용하게 사용 될 수 있습니다.
다음과 같이, todos
배열을 상태로 지닌 리듀서가 있다고 가정해봅시다.
[
{ id: 1, text: '책 읽기', done: true },
{ id: 2, text: '블로그 글 쓰기', done: true },
{ id: 3, text: '운동하기', done: false },
{ id: 4, text: '요리하기', done: false }
]
여기서 특정 컴포넌트에서 할 일 목록 중 하지 않는 작업만 필터링해서 보여준다고 가정해봅시다.
일반적으로는 다음과 같이 구현을 하게 되겠지요?
function UndoneTasks() {
const tasks = useSelector(state => state.todos.filter(todo => todo.done));
// ...
}
별 문제가 없어보일 수 있지만 사실 문제가 있습니다. 리덕스의 todos
말고 리덕스에서 관리되고 있는 관련 없는 상태가 변경 될 때에도 위 컴포넌트에선 리렌더링이 발생합니다. 그 이유는, 배열의 filter
함수는 새로운 배열를 생성하기 때문에 매번 값이 변경된 것으로 간주하여 리렌더링이 되는 것이죠.
이 때 Memoized Selector를 사용하면 최적화를 할 수 있습니다.
import { createSelector } from '@reduxjs/toolkit'
const todosSelector = (state) => state.todos;
const undoneTodos = createSelector(
todosSelector,
(todos) => todos.filter((todo) => !todo.done)
);
function UndoneTasks() {
const tasks = useSelector(undoneTodos);
// ...
}
createSelector
를 사용하여 Memoized Selector를 만들 수 있는데요, 이 함수에는 selector
들을 연달아서 넣을 수 있습니다. 위 코드에 대해서 설명을 하자면 우선 todosSelector
라는게 있죠. 이 함수가 createSelector
첫번째 인자로 지정이 됐는데, 만약 이 첫번째 selector
에서 반환된 값이 변경될 때에만 그 다음 selector
를 호출하여 원하는 값을 연산하여 조회합니다.
이렇게 하면 todos
배열에 실제로 변화가 있을 때에만 filter
함수를 돌리게 되고, 리렌더링을 하게 되지요.
그렇다고 이러한 상황에 꼭 Memoized Selector 를 사용해야만 최적화를 할 수 있을까요? 꼭 그런것은 아닙니다.
useMemo
를 활용하면 비슷한 최적화를 할 수 있습니다. 다음과 같이 말이죠.
function UndoneTasks() {
const tasks = useSelector(state => state.todos);
const undoneTasks = useMemo(() => tasks.filter(task => !tasks.done), [tasks])
// ...
}
이렇게, 리덕스에서 관리하고 있는 상태를 어떠한 연산을 처리한 후 조회해야 한다면 Memoized Selector 또는 useMemo
를 꼭 사용하시는 것을 권장드립니다.
Presentational & Container 컴포넌트는 이제 그만
2~3년 이상 리덕스를 사용해오신 분들께서는 프리젠테이셔널 컴포넌트, 컨테이너 컴포넌트 많이 들어보셨을 것입니다. 여기서 프리젠테이셔널 컴포넌트는 리덕스와 연동되어있지 않고 온전히 뷰만 담당하는 컴포넌트이고, 컨테이너 컴포넌트는 리덕스와 연동이 된 컴포넌트입니다. 옛날에는 이렇게 컴포넌트를 구분해서 작성하는 것이 권장됐었습니다. 애초에 리덕스 창시자 Dan Abramov가 그렇게 쓰면 좋다고 권장했었으니까요 [3]. 그 때는 오히려 이렇게 안하면 좀 이상했었습니다.
리액트에 Hook이 도입되고 나서 Dan Abramov는 이전의 주장을 정정하였습니다. 이제 그는 프리젠테이셔널 컴포넌트와 컨테이너 컴포넌트를 분리하는 것을 더 이상 권장하지 않는다고 했습니다. 그가 이 방식이 유용하고 느꼈던 이유는 상태 관련 로직을 컴포넌트에서 분리 시킬수 있기 때문이였는데 Hook을 통해서 동일한 작업을 할 수 있기 때문에 더 이상 컴포넌트의 구분이 불필요하다고 합니다.
저는 이 주장을 정정하는 문구를 읽었을 때 처음엔 잘 이해가 가질 않았습니다. 그렇다면 상태 또는 액션 함수가 필요한 컴포넌트에서 바로 useSelector
랑 useDispatch
를 사용해서 구현 하라는 것 일까요?
export default function ShopProducts() {
const loading = useSelector(state => state.shop.loading);
const products = useSelector(state => state.shop.products);
const dispatch = useDispatch();
useEffect(() => {
// products 조회 로직 ...
}, [dispatch])
const onPurchase = (product) => {
/* 결제 로직 ... */
}
return (
<div>{/* UI ... */}</div>
)
}
위와 같이 한다면, 유지보수성이 크게 저하되는 것 까지는 아니지만, 상태 관련 로직이 여전히 컴포넌트와 많이 얽혀있는 상황입니다.
여기서부터는 제 뇌피셜인데요, Hook을 통해서 동일한 작업을 할 수 있다는 건 다음과 같이 기능별로 리덕스의 상태와 액션을 사용하는 Custom Hook을 만들어서 사용하는게 아닐까 생각합니다.
다음과 같이 말이죠.
export function useShopProducts() {
const loading = useSelector(state => state.shop.loading);
const products = useSelector(state => state.shop.products);
const dispatch = useDispatch();
useEffect(() => {
// products 조회 로직 ...
}, [dispatch])
const onPurchase = (product) => {
/* 결제 로직 ... */
}
return { loading, products, onPurchase };
}
export default ShopProducts() {
const { loading, products, onPurchase } = useShopProducts();
return <div>{/* UI ... */}</div>;
}
이렇게 하면, UI 부분은 UI 만 집중하고, 상태 관련 로직은 상태 부분만 집중해서 코드를 작성 할 수 있어서 유지보수에도 도움이 되고, 편리하다고 생각합니다.
저희 프런트엔드 팀에서는 이전엔 containers / components 를 구분해서 컴포넌트들을 작성해왔었는데, 작년부터는 이를 따로 구분하지 않고 모두 components 라는 디렉터리에 저장하고 있으며 리덕스의 상태를 사용 할 때는 Custom Hook을 만들어서 사용하고 있습니다.
꼭 이렇게 사용하는 것이 정답이 아닐 수도 있긴 하겠지만, 저희에게는 이러한 방식이 아주 편하게 다가왔습니다.
API요청은 이제 react-query, SWR에게 맡기자
저희 팀에서는 API 요청에 관련한 코드들을 리덕스와 미들웨어로 관리해왔었습니다. 웹에서는 프로미스를 기반으로 API 요청 상태를 관리하는 미들웨어를 직접 만들어서 관리하다가 나중엔 redux-saga를 사용했습니다. 리덕스로 요청에 관련된 상태를 관리하려면 요청 시작, 요청 성공, 요청 실패에 대한 3가지 액션들을 준비해야 하고 해당 액션들을 처리하는 로직들도 준비해줘야 하지요.
예시) getEpisode, getEpisodeSuccess, getEpisodeError
모든 요청들에 대하여 위 액션 그리고 리듀서를 준비해주는 작업은 은근히 번거롭습니다. 그래서, 저희는 다양한 유틸 함수를 만들어서 최소한의 코드로 구현을 할 수 있도록 만들었었습니다.
2020년에는 react-query와 SWR 라는 라이브러리들이 릴리즈되었습니다. 두 라이브러리 모두, Hook을 사용하여 API 요청 상태를 관리하고, 또 캐시 관리도 아주 멋지게 해내죠. 위 라이브러리들을 사용하면 기존에 저희가 해오던 방식보다 더욱 효율적이고 편하게 API 상태 및 캐시 관리를 할 수 있었습니다. 저희는 이 라이브러리들이 성숙화 되어가는 것을 지켜보다가 2020년 하반기에 라프텔의 모든 클라이언트 프로젝트에 (Web, TV, React Native) react-query를 도입했고 아주 만족스럽게 사용하고 있습니다
저희는 서버사이드 렌더링 때문에 react-query와 SWR 중 react-query를 선택했습니다. SWR은 Next.js를 만든 Vercel팀에서 만든 것이기에 서버사이드 렌더링을 하는 경우 Next.js 와 함께 사용해야합니다 (적어도 공식 문서에서는 해당 내용만 다룹니다). 반면 라프텔에서는 Next.js를 사용하지 않기 때문에 SWR이 저희에겐 적합하지 않았습니다. 그리고, react-query의 queryCache
기능이 다양한 상황에 유용하게 사용 될 수 있어서 현재 매우 편하게 사용을 하고 있습니다.
위 두 라이브러리는 모두 훌륭한 솔루션들입니다. 여러분의 프로젝트에서 API 요청 하는 작업을 현재 리덕스와 미들웨어를 기반으로 구현을 했더라면, 점진적으로 둘 중 하나에게 해당 작업을 위임을 하는 것도 매우 좋은 선택지라고 생각합니다.
그럼에도 리덕스 미들웨어는 필요할까?
API 요청을 SWR이나 react-query를 사용하여 구현한다면 미들웨어는 더이상 불필요할까요? 꼭 그런 것은 아닙니다. 저희는 요즘 대부분의 새로운 기능의 API 연동 부분은 react-query로 구현하긴 하지만, 일부 기능은 계속 리덕스와 미들웨어를 사용하고 있습니다.
특정 기능들은 미들웨어의 힘을 빌렸을 때 더욱 쉽게 개발 할 수 있습니다. 현재 웹에선 redux-saga, 모바일 앱에서는 redux-observable 을 사용하고 있는데요, 다음과 같은 상황에 유용하게 사용하고 있습니다.
첫번째 상황은 요청을 연달아서 여러번 하게 될 때 이전 요청은 무시하도록 하고 맨 마지막의 요청만 처리하도록 할 때 입니다.
예를 들어서 검색어 자동완성을 하거나, 태그 검색에서 필터를 변경 할 때 입니다. 때로는, 이전에 요청한 API가 나중에 요청한 API보다 늦게 응답을 해서 원치 않는 데이터가 화면에 보여지는 상황이 있습니다. 이러한 상황엔 redux-saga 에선 <a href="https://redux-saga.js.org/docs/api/" target="_blank" rel="noreferrer noopener">takeLatest</a>
, redux-observable 에선 <a href="http://reactivex.io/rxjs/class/es6/Observable.js~Observable.html#instance-method-switchMap" target="_blank" rel="noreferrer noopener">switchMap</a>
을 사용하면 아주 쉽게 마지막 요청에 대한 응답만 처리하도록 구현을 할 수 있습니다.
두번째 상황은 첫번째 상황와 꽤나 비슷한건데 특정 조건이 만족됐을 때 이전에 시작한 요청을 취소하는 상황입니다.
예를 들어서, 라프텔의 작품상세 페이지는 에피소드들을 20개씩 페이지네이션 해서 불러오는데요, 에피소드의 하단에는 작품의 연관 작품들이 보여집니다. 대부분의 작품은 20~40개 내외의 에피소드를 가지고 있지만, 코난 1기 같은 작품들은 예외적으로 100 에피소드가 있습니다. 현재 페이지네이션은 무한 스크롤링 방식으로 구현을 했기 때문에, 기본적으로는 관련 작품을 보려면 무조건 100개의 에피소드를 모두 로딩해야 볼 수 있습니다. 이를 좀 더 개선하기 위해서 저희는 페이지를 로딩 중일 때 에피소드 영역을 초과하여 관련 작품을 보게 된다면 기존의 페이지 로딩을 취소하도록 만들었습니다. 이렇게 하면 유저가 모든 리스트를 불러오지 않아도 페이지 하단의 컨텐츠를 스크롤해서 확인 할 수 있게 됩니다. 이렇게 특정 조건이 만족 됐을 때 이전에 한 요청을 취소하는 작업은, 미들웨어를 사용하면 쉽게 구현 할 수 있습니다.
세번째 상황은 특정 콜백함수를 원하는 액션이 디스패치 됐을 때 호출하도록 등록을 하는 상황입니다.
저희는 사용자에게 팝업 메시지를 보여주는 기능을 공용 Dialog 를 만들어서 리덕스로 관리를 하고 있는데요, 사용자가 확인 또는 취소를 눌렀을 때 어떠한 작업을 수행해야 하는 상황이 있습니다. 그런 상황에 저희는 Dialog를 열때 액션 객체에 확인 또는 취소를 눌렀을 때 호출할 콜백 함수를 액션의 payload로 담습니다. 그리고, 확인 또는 취소 액션이 디스패치 되면, 우리가 사전에 등록한 콜백 함수를 호출하도록 구현을 했습니다. 다음은 예시 코드입니다.
export function* openSaga(action: ReturnType<typeof actions.open>) {
const { confirm } = yield race({
confirm: take(actions.confirm.type),
cancel: take(actions.cancel.type),
});
if (confirm) {
action.payload.onConfirm?.();
} else {
action.payload.onCancel?.();
}
}
export default function* dialogSaga() {
yield takeEvery(actions.open.type, openSaga);
}
이런 상황 말고도, 결제 쪽에서도 비슷한 작업을 하고있는데요, 저희는 결제 또한 리덕스로 관리를 하고 있는데, 결제 모달을 열때 상품에 대한 정보와 결제 성공시에 호출 할 콜백을 액션의 payload에 담습니다. 만약 에피소드를 구매했으면 결제 성공을 하면 에피소드 목록을 다시 불러와야 하고, 만약 멤버십을 구독했으면 멤버십 상태를 새로고침해야 하고, 포인트를 구매했으면 포인트를 새로고침 해야 합니다. 이러한 상황에도 미들웨어가 아주 편하게 사용됩니다. 만약 미들웨어를 사용하지 않는다면, 다른 방식으로 구현 할 수도 있겠죠. Context에 함수를 등록하거나, EventEmitter를 사용하는 방법도 있습니다. 다만, 저희는 이런 기능들을 미들웨어의 힘을 빌려서 구현하는 것이 공수도 적고, 코드도 명료하고, 테스트도 하기 쉽다고 생각합니다.
네번째 상황은 컴포넌트 밖에서 어떤 작업을 수행할 때 입니다. 저희는 리액트 네이티브 앱에서 영상 다운로드 기능을 사용 할 때 네이티브 모듈을 연동한 상태인데, 다운로드 상태를 트래킹하고 성공/실패 처리를 하는 과정에서 미들웨어를 유용하게 사용했었습니다.
저희는 현재 웹과 모바일 앱에서 다른 미들웨어를 사용하고 있는데요, 둘의 용도는 비슷한데 왜 통합을 하지 않는지 궁금해 하실 것이라 생각합니다. 웹에서 redux-saga를 사용하는 이유는, 서버사이드 렌더링을 하게 될 때 편리하기 때문입니다. 반면 redux-observable은 서버사이드 렌더링을 하는 것이 불가능하지는 않는데, 관련 레퍼런스가 너무 적어서 웹에서 사용을 못하고 있습니다.
서버사이드 렌더링이 아니라면, 저는 redux-observable을 사용하는 것을 더욱 좋다고 생각하는데요, 그 이유는 TypeScript와 함께 사용 될 때 호환이 더욱 잘 되기 때문입니다. redux-saga도 TypeScript와 함께 사용 할 수는 있긴 하지만, Generator를 사용하는 과정에서 타입 유추가 안되는 상황이 많아서 직접 설정해야 될 때가 있습니다.
redux-observable이 좋긴 하지만 RxJS를 공부해야 하기 때문에 워낙 공부해야 할 것이 방대해져서, 확실히 러닝 커브가 많이 있긴 합니다. 그래서 어떤 미들웨어를 사용하는게 정답이라고 딱 정하는건 어려울 것 같습니다. 다만 둘 중 하나의 라이브러리를 사용하면 분명히 유용하게 사용 할 수 있는 상황이 있습니다.
테스트 코드를 작성하자
리덕스는 테스트하기 쉽습니다. 이는 리덕스를 사용함으로써 얻을 수 있는 혜택이라고 생각합니다. 따라서, 가능하다면 리덕스 관련 코드에 대한 테스트를 작성해가면서 프로젝트 개발을 하는 것이 좋습니다 – 라고 생각하긴 하지만 저희도 일부 기능에는 테스트 코드를 작성하지 못할 때가 있습니다. 마음은 테스트 커버리지를 최대한 높이는 거지만, 솔직히 말하자면 상황과 여건에 따라 그렇게 못하게 될 때가 있지요. 이는 어떤 개발 팀이든 비슷 할 거라고 생각합니다.
그래도, 왠만하면 테스트 코드를 작성하려고 하고 있고 테스트 코드가 있는 코드와 없는 코드를 수정 할 때 확연한 차이가 있습니다. 테스트 코드가 있으면 확실히 코드의 안전성에 대한 자신감이 많이 생기죠. 그래서, 만약 리덕스를 사용한다면 테스트 코드를 적극적으로 작성 할 것을 권장합니다. 이 포스트에는 리덕스를 사용 할 때 어떤 테스트를 작성할 수 있는지 예시 코드들에 대해서 다뤄보겠습니다.
테스트 관련 예시 프로젝트는 이 CodeSandbox 에서 확인 할 수 있습니다. 프로젝트를 직접 열어서 코드를 보면서 이해하시면 더욱 좋을 것입니다. 이 프로젝트는 테스트 코드들이 도입된 투두 리스트 프로젝트입니다.
리듀서 테스팅
첫번째는 리듀서 테스팅입니다. todosSlice 가 다음과 같이 구현되어 있다고 가정해봅시다.
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { nanoid } from 'nanoid';
import { Todo } from '../types/Todo';
const todosSlice = createSlice({
name: 'todos',
initialState: [] as Todo[],
reducers: {
add: {
prepare: (text: string) => ({
payload: {
id: nanoid(),
done: false,
text
}
}),
reducer(state, action: PayloadAction<Todo>) {
state.push(action.payload);
}
},
toggle(state, action: PayloadAction<string>) {
const todo = state.find((todo) => todo.id === action.payload);
if (!todo) return;
todo.done = !todo.done;
},
remove(state, action: PayloadAction<string>) {
return state.filter((todo) => todo.id !== action.payload);
}
}
});
export const todosActions = todosSlice.actions;
export default todosSlice.reducer;
여기서 <a href="https://redux-toolkit.js.org/api/createSlice#customizing-generated-action-creators" target="_blank" rel="noreferrer noopener">prepare</a>
부분은 액션 생성 함수를 커스터마이징 하는 기능입니다. 할 일 항목을 생성, 토글, 삭제하는 액션들이 현재 구현이 되어있지요.
만약 이를 통해 만든 리듀서를 테스트 한다면 다음과 같이 작성합니다.
import todos, { todosActions } from './todos';
describe('todos reducer', () => {
it('has initial state', () => {
expect(todos(undefined, { type: '@@INIT' })).toEqual([]);
});
it('handles add', () => {
const state = todos([], todosActions.add('컴포넌트 만들기'));
expect(state[0].text === '컴포넌트 만들기');
});
const sampleState = [
{ id: '1', done: false, text: '컴포넌트 만들기' },
{ id: '2', done: false, text: '테스트 코드 작성하기' }
];
it('handles toggle', () => {
let state = todos(sampleState, todosActions.toggle('1'));
expect(state).toEqual([
{ id: '1', done: true, text: '컴포넌트 만들기' },
{ id: '2', done: false, text: '테스트 코드 작성하기' }
]);
state = todos(state, todosActions.toggle('1'));
expect(state).toEqual([
{ id: '1', done: false, text: '컴포넌트 만들기' },
{ id: '2', done: false, text: '테스트 코드 작성하기' }
]);
});
it('handles remove', () => {
let state = todos(sampleState, todosActions.remove('2'));
expect(state).toEqual([{ id: '1', done: false, text: '컴포넌트 만들기' }]);
state = todos(state, todosActions.remove('1'));
expect(state).toEqual([]);
});
});
우선 리듀서에서 초기상태가 잘 설정되는지 확인하고, 각 액션에 대해서 우리가 의도한 바 대로 처리가 되고 있는지 테스트 코드를 작성하면 됩니다.
Hook 테스팅
이 포스트에서 이전에 언급했던 “Presentational & Container 컴포넌트는 이제 그만” 부분 기억나시죠? 해당 섹션에서는 리덕스와 연동하는 작업을 Custom Hook을 만들어서 하면 좋다고 설명드렸었습니다. 이 할 일 목록 프로젝트에선 다음과 같이 useFilteredTodos
라는 Hook을 만들어서 사용하고 있습니다.
import { useMemo } from 'react';
import { useRootState } from '../modules';
import { useFilter } from './useFilter';
import { useTodoActions } from './useTodoActions';
export function useFilteredTodos() {
const todos = useRootState((state) => state.todos);
const [filter] = useFilter();
const filteredTodos = useMemo(
() =>
filter === 'ALL'
? todos
: todos.filter((todo) => todo.done === (filter === 'DONE')),
[todos, filter]
);
const actions = useTodoActions();
return [filteredTodos, actions] as const;
}
여기서 useTodoActions
는 dispatch
와 바인드된 액션 생성 함수들을 반환하고, useFilter
는 현재 filter
상태와 해당 값의 업데이트 함수를 반환합니다.
Custom Hook을 테스트 하는 방법은 기본적으로 두가지가 있습니다. 테스트용 컴포넌트, 예를 들어서 UseFilteredTodosExample
같은 컴포넌트를 만들어서 해당 컴포넌트의 테스트를 작성하는 것이구요, 또 다른 방법은 <a rel="noreferrer noopener" href="https://github.com/testing-library/react-hooks-testing-library#when-not-to-use-this-library" target="_blank">react-hooks-testing-library</a>
를 사용하는 것 입니다. 이 라이브러리를 사용 할 경우 컴포넌트를 따로 만들지 않고 Custom Hook을 테스트 할 수 있습니다. 참고로, 이 라이브러리의 문서에서는 하나의 컴포넌트에서만 사용되는 Hook이거나, 컴포넌트를 사용해서 테스트하기에 쉬운 Hook이라면 이 라이브러리를 사용하지 않을 것을 권고하고 있습니다 [4]. 현재 예시 프로젝트의 경우엔 이 상황에 해당되는데, 그럼에도 불구하고 공부 차원에서 Hook만 테스트 할 경우 어떻게 하는지 알아보겠습니다.
import { renderHook, act } from '@testing-library/react-hooks/dom';
import prepareReduxWrapper from '../lib/prepareReduxWrapper';
import { RootState } from '../modules';
import { filterActions } from '../modules/filter';
import { useFilteredTodos } from './useFilteredTodos';
describe('useFilteredTodos', () => {
const initialState: RootState = {
filter: 'ALL',
todos: [
{
id: '1',
text: '컴포넌트 만들기',
done: false
},
{
id: '2',
text: '테스트 코드 작성하기',
done: false
}
]
};
const setup = () => {
const [wrapper, store] = prepareReduxWrapper(initialState);
const { result } = renderHook(() => useFilteredTodos(), { wrapper });
return { store, result };
};
it('properly shows todos', () => {
const { result } = setup();
expect(result.current[0]).toHaveLength(2);
});
it('toggles todo', () => {
const { result } = setup();
// 첫번째 항목 토글
act(() => {
result.current[1].toggle('1');
});
expect(result.current[0][0].done).toBe(true);
act(() => {
result.current[1].toggle('1');
});
expect(result.current[0][0].done).toBe(false);
});
it('filters todos', () => {
const { result, store } = setup();
// 첫번째 항목 토글
act(() => {
result.current[1].toggle('1');
});
// store를 통하여 filter 직접 변경
store.dispatch(filterActions.applyFilter('DONE'));
expect(result.current[0][0].text).toBe('컴포넌트 만들기');
expect(result.current[0].length).toBe(1);
// UNDONE filter 확인
store.dispatch(filterActions.applyFilter('UNDONE'));
expect(result.current[0][0].text).toBe('테스트 코드 작성하기');
expect(result.current[0].length).toBe(1);
// ALL filter 확인
store.dispatch(filterActions.applyFilter('ALL'));
expect(result.current[0].length).toBe(2);
});
it('removes todo', () => {
const { result } = setup();
// 첫번째 항목 제거
act(() => {
result.current[1].remove('1');
});
expect(result.current[0][0].text).toBe('테스트 코드 작성하기');
});
});
renderHook
을 사용하면 별도의 컴포넌트를 만들지 않고도 Hook을 쉽게 테스트 할 수 있습니다.
여기서 prepareReduxWrapper
는 제가 준비한 리덕스 스토어와 Wrapper 컴포넌트를 준비해주는 함수입니다. 자세한 코드는 CodeSandbox에서 확인하세요.
이렇게 테스트 코드를 작성하면, 우리가 만든 Hook에 대한 완전한 테스트를 할 수 있겠죠. 상황에 따라 완전한 테스트는 생략하고 MockStore를 사용하여 단순히 액션이 잘 디스패치 되는지만 확인하는것도 좋을 수 있습니다.
다음 예시는 MockStore를 사용하는 테스트 예시입니다.
import { act, renderHook } from '@testing-library/react-hooks/dom';
import prepareMockReduxWrapper from '../lib/prepareMockReduxWrapper';
import { filterActions } from '../modules/filter';
import { useFilter } from './useFilter';
describe('useFilter', () => {
const setup = () => {
const [wrapper, store] = prepareMockReduxWrapper({
filter: 'ALL',
todos: []
});
const { result } = renderHook(() => useFilter(), { wrapper });
return { wrapper, store, result };
};
it('returns filter', () => {
const { result } = setup();
expect(result.current[0]).toEqual('ALL');
});
it('returns filter', () => {
const { store, result } = setup();
// applyFilter 함수를 호출하고
act(() => {
result.current[1]('DONE');
});
// 해당 액션이 디스패치 됐는지 확인
expect(store.getActions()).toEqual([filterActions.applyFilter('DONE')]);
});
});
여기서는 applyFilter
함수를 호출 한 다음에 상태가 업데이트하는 것 까지 확인하지는 않고, 단순히 해당 액션이 디스패치 됐는지만 확인합니다.
액션이 디스패치 됐을 때 리덕스 상태가 잘 업데이트 되는 것은 리듀서 테스트에서 이미 했기 때문에 생략 해도 문제가 없지요.
컴포넌트 테스팅
컴포넌트 테스트도 Hook 테스트와 비슷합니다. 완전한 테스트를 할 수도 있고, MockStore를 사용하여 간단한 테스트만 진행을 하면 됩니다.
컴포넌트 테스트는 @testing-library/react를 사용해서 진행하는 것이 편합니다.
다음 예시들은 이 라이브러리를 사용한 예시 테스트 코드입니다.
만약 새로운 할 일 등록을 하는 컴포넌트를 MockStore를 사용하여 테스트 할 경우엔 다음과 같이 테스트 코드를 작성 할 수 있습니다. 다음 테스트는 인풋에 텍스트를 입력하고 폼을 등록하는 과정을 테스트하고, 우리가 원하는 액션이 디스패치 됐는지 확인합니다.
import { render, fireEvent, screen } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import prepareMockReduxWrapper from '../lib/prepareMockReduxWrapper';
import TodoForm from './TodoForm';
import { todosActions } from '../modules/todos';
describe('TodoForm', () => {
const setup = () => {
const [Wrapper, store] = prepareMockReduxWrapper();
render(
<Wrapper>
<TodoForm />
</Wrapper>
);
return { store };
};
it('renders properly', () => {
setup();
});
it('submit new todo', async () => {
const { store } = setup();
const input = await screen.findByPlaceholderText('할 일을 입력하세요.');
fireEvent.change(input, {
value: '컴포넌트 만들기'
});
fireEvent.submit(input);
expect(input).toHaveValue(''); // 인풋이 비었는지 확인
expect(
store
.getActions()
.filter((action) => action.type === todosActions.add.type)
).toHaveLength(1); // 액션이 디스패치 됐는지 확인
});
});
그 다음엔, 할 일 목록 필터를 변경하는 컴포넌트에서 버튼을 누르고 상태가 잘 변경되는지 완전한 컴포넌트 테스트를 하는 예시를 확인해봅시다.
import { render, fireEvent, screen } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import prepareReduxWrapper from '../lib/prepareReduxWrapper';
import TodoFilters from './TodoFilters';
describe('TodoFilters', () => {
const setup = () => {
const [Wrapper, store] = prepareReduxWrapper();
render(
<Wrapper>
<TodoFilters />
</Wrapper>
);
return { store };
};
it('renders properly', () => {
setup();
});
it('submit new todo', async () => {
setup();
const allButton = await screen.findByText('전체');
expect(allButton).toBeDisabled();
const doneButton = await screen.findByText('완료');
fireEvent.click(doneButton);
expect(doneButton).toBeDisabled();
const undoneButton = await screen.findByText('미완료');
fireEvent.click(undoneButton);
expect(undoneButton).toBeDisabled();
});
});
이렇게 컴포넌트를 테스트하는 방식으로 했을 때 간단하게 할 수 있는 상황이라면 굳이 Hook 테스트를 따로 만들지 않고 컴포넌트 테스트만 진행해도 괜찮습니다.
마치면서
이 포스트에서 정말 다양한 내용들을 다뤘는데요, 요약하자면 다음과 같습니다.
- 꼭 리덕스를 사용할 필요가 없으면 다른 대체제를 선택해보자
- 리덕스를 사용한다면
- Redux Toolkit 꼭 사용하자
- TypeScript 꼭 사용하자
- Selector 사용하거나, 상태 조회 과정에서 발생하는 불필요한 리렌더링에 유의하자
- Presentational / Container 컴포넌트는 이제 그만 구분하고 Custom Hook을 만들자
- API 요청에 대한 로직은 가능하다면 react-query 또는 SWR에게 위임하자
- 적합한 상황에 미들웨어 잘 활용하자
- 테스트 코드를 잘 작성하자
References
[1] D. Schiemann, “State of Frontend 2020 Report | TSH.io”, The Software House, 2021. [Online]. Available: https://tsh.io/state-of-frontend/. [Accessed: 25- Jan- 2021].
[2] D. McCabe, “Motivation | Recoil”, Recoiljs.org, 2021. [Online]. Available: https://recoiljs.org/docs/introduction/motivation. [Accessed: 25- Jan- 2021].
[3] D. Abramov, “Presentational and Container Components”, Medium, 2021. [Online]. Available: https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0. [Accessed: 25- Jan- 2021].
[4] M. Peyper, “Introduction”, React-hooks-testing-library.com, 2021. [Online]. Available: https://react-hooks-testing-library.com/. [Accessed: 25- Jan- 2021].
고객과 발맞춰 새로운 콘텐츠 경험을 선보이는
리디와 함께할 당신을 기다립니다.