[React] Context API를 사용하여 전역에서 로그인 상태 관리하기
밈글밈글 프로젝트 코드를 리팩토링 하려고 보는데 로그인 상태 여부를 확인해서 조건부 렌더링하는 컴포넌트가 굉장히 많았다. 그런데 로그인 상태를 전역에서 관리하면서 컴포넌트에서 구독하고 있는 게 아니라 각 컴포넌트마다 웹스토리지에 유저 정보가 있는지 확인하여 로그인 상태인지, 로그아웃 상태인지를 연산하는 작업을 하고 있었다. 이미 상태 관리 툴로 리덕스를 사용하고 있었지만 사실상 서비스 운영은 종료돼서 공부하는 김에 React에서 기본적으로 제공하는 Context API를 사용하여 로그인 상태를 전역으로 관리하고, 로그인 상태가 필요한 컴포넌트에서는 구독할 수 있도록 수정해보았다.
Context API란?
React는 데이터가 부모 컴포넌트에서 자식 컴포넌트로, 즉 단방향으로 흘러야 한다. 컴포넌트 depth가 깊거나 여러 컴포넌트에서 동일한 데이터를 구독해야 하는 경우 이 과정은 복잡해질 수 있다. 그래서 React는 context라는 기본 기능을 제공하고 있으며, 이 context를 사용하면 데이터를 글로벌하게 관리하고 사용할 수 있다.
Context API를 사용하려면 createContext, Provider, Consumer의 개념을 알아야 한다.
createContext
const MyContext = React.createContext(defaultValue);
createContext는 context 객체를 생성하여 해당 context를 구독하고 있는 컴포넌트가 렌더링될 때 Provider로부터 현재 상태를 읽는다. defaultValue는 짝이 맞는 Provider를 찾지 못했을 때만 사용되는 값으로, 컴포넌트를 독립적으로 테스트할 때 유용하다.
Provider
<MyContext.Provider value={/* 어떤 값 */}>
Provider는 value prop을 받아 이 값을 context를 구독하고 있는 하위의 컴포넌트들에게 context의 변화를 알리는 역할을 하는 컴포넌트이다. Provider 하위에 또 다른 Provider를 배치하는 것도 가능하며, 이 경우 하위의 Provider의 value가 우선한다. context를 구독하고 있는 하위의 모든 컴포넌트는 Provider의 value prop이 바뀔 때마다 부모 컴포넌트와의 업데이트와는 상관 없이 다시 렌더링된다.
Consumer
<MyContext.Consumer>
{value => /* context 값을 이용한 렌더링 */}
</MyContext.Consumer>
Consumer는 context의 변화를 구독하는 컴포넌트로, 이 컴포넌트를 사용하면 함수 컴포넌트 안에서 context를 구독할 수 있다. Context.Consumer의 자식 컴포넌트는 함수여야 하며, 이 함수는 context의 현재 상태를 받아 React 노드를 반환한다. 이 함수가 받는 value는 context의 Provider 중 상위 트리에서 가장 가까운 Provider의 value prop과 동일하다. 상위에 적절한 Provider를 찾지 못했다면 value 값은 createContext()에서 보냈던 defaultValue가 된다.
useContext
const value = useContext(MyContext);
함수형 컴포넌트에서는 Consumer를 대신하여 useContext 훅을 사용하여 현재 상태를 구독할 수 있다. useContext()는 context를 읽고 상태 변화를 구독하는 것만 가능하므로, context 상태를 업데이트하고 싶다면 Provider를 사용해야 한다. useContext 훅을 사용하려면 아래의 두 가지를 유의하는 것이 좋다.
1. useContext의 파라미터는 context 객체 그 자체여야 한다.
- 맞는 사용: useContext(MyContext)
- 틀린 사용: useContext(MyContext.Consumer)
- 틀린 사용: useContext(MyContext.Provider)
2. 리렌더링 트리거
상위 컴포넌트에서 React.memo를 사용했다 하더라도 Provider가 업데이트되면 useContext를 사용하고 있는 컴포넌트자체에서부터 리렌더링된다. 리액트 깃허브에서는 이 경우 메모이제이션을 사용하여 최적화 할 수 있는 몇 가지 옵션을 제안하고 있으니 참고하면 좋을 것 같다.
Context API 사용하기
1. context 파일을 생성한다.
// IsLoginContext.js
import React, { createContext, useContext, useState, useMemo } from 'react'
const userId = sessionStorage.getItem('id')
const token = sessionStorage.getItem('token')
export const IsLoginContext = createContext({ isLogin: userId !== null && token !== null ? true : false })
export function IsLoginProvider({ children }) {
const [isLogin, setIsLogin] = useState(userId !== null && token !== null ? true : false)
// useMemo로 캐싱하지 않으면 value가 바뀔 때마다 state를 사용하는 모든 컴포넌트가 매번 리렌더링됨
const value = useMemo(() => ({ isLogin, setIsLogin }), [isLogin, setIsLogin])
return <IsLoginContext.Provider value={value}>{children}</IsLoginContext.Provider>
}
createContext와 Provider를 분리해서 사용하는 경우도 있지만 되도록 실행 파일 내용은 최소한으로 하고 싶어서 context 파일에 함께 작성하였다.
로그인 상태 여부는 로그인이 성공하면 자동으로 웹스토리지에 저장되는 유저 정보와 토큰으로 확인한다. defaultValue와 useState의 기본 state에 userId와 token이 모두 null이 아닌 경우에는 isLogin의 상태가 true가 되도록, 하나라도 null인 경우에는 false가 되도록 설정하였다.
추가로 Provider에서 value prop으로 전달하는 게 객체면 구독하고 있는 하위 컴포넌트가 매번 리렌더링이 되므로 useMemo 훅을 사용하여 연산 작업을 최소화한다.
2. 상태를 구독할 수 있는 커스텀 훅을 만든다.
// IsLoginContext.js
export function useIsLoginState() {
const context = useContext(IsLoginContext)
if (!context) {
throw new Error('Cannot find IsLoginProvider')
}
return context.isLogin
}
커스텀 훅을 만들면 상태를 구독하는 컴포넌트마다 useContext 훅을 사용하지 않아도 된다. 커스텀 훅은 별도의 파일로 만들어도 되지만 내용이 길지 않아서 context 파일에 함께 작성했다. Provider에서 전달하는 value에는 isLogin과 setIsLogin 두 가지가 포함되어 있는데 컴포넌트에서 구독하는 상태는 isLogin이므로 isLogin 값만 반환하도록 설정한다.
3. App.js에서 Provider로 하위 컴포넌트들을 감싸준다.
// App.js
import { IsLoginProvider } from './shared/IsLoginContext'
function App() {
return (
<>
<IsLoginProvider>
// ...
</IsLoginProvider>
</>
)
}
4. 상태를 업데이트하는 컴포넌트
// Login.js
import { useContext } from 'react'
import { IsLoginContext } from '../../shared/IsLoginContext'
const Login = () => {
const { setIsLogin } = useContext(isLoginContext)
// ...
const handleLogin = () => {
// ...
setIsLogin(true)
}
return //...
}
// ProfileBottom.js
import { useContext } from 'react'
import { IsLoginContext } from '../shared/IsLoginContext'
const ProfileBottom = () => {
const { setIsLogin } = useContext(IsLoginContext)
// ...
const handleLogOut = () => {
// ...
setIsLogin(false)
}
return // ...
}
아쉬운 건 로그인, 로그아웃에 리덕스를 사용했는데 리덕스 모듈은 함수형 컴포넌트가 아니라서 useContext 훅을 사용하지 못한다는 점이다. 로그아웃 버튼은 로그인 상태일 때만 보이기 때문에 상관 없는데, 로그인은 로그인을 성공했을 때만 isLogin이 true로 변경되어야 한다. 그리고 로그인이 성공했는지의 여부는 리덕스 모듈에서 알 수 있게 되어 있다. 리덕스도 상태 관리 라이브러리인데 그 안에서 Context API를 사용하는 게 이상한 것 같아서 로그인 컴포넌트 안에서 로그인을 성공했을 때만 상태를 업데이트 할 수 있는 방법을 더 찾아보고 수정해야겠다.
5. 상태를 구독하는 컴포넌트
// Footer.js
import { useIsLoginState } from '../shared/IsLoginContext'
const Footer = () => {
const isLogin = useIsLoginState()
console.log(isLogin)
return //...
}
로그인 상태를 전역으로 관리하는 건 원래 쓰고 있던 리덕스를 사용해도 됐었지만, 로그인 상태를 필요로 하는 컴포넌트가 너무 많아서 모든 컴포넌트마다 예외처리로 새로고침 시 다시 액션을 디스패치하는 코드를 작성해야 하는 게 번거롭다고 느껴졌다. Context API를 사용해 본 적이 없어서 공부하는 김에 로그인 상태 전역 관리로 사용해봤는데 어렵지 않은 방법으로 코드가 훨씬 간결해져서 좋은 것 같다.