이전에 Hydration이 무엇인지, Next서버에서 어떻게 스토어를 관리해야 하는지에 대하여 알아보았다면,
조금더 효율적으로 persist미들웨어와 함께 스토어를 초기화 하고, Hydration Warning을 방지하는 방법에 대하여 알아보겠습니다.
localStorage
나 sessionStorage
에서 store 데이터를 저장하고 불러와 초기화 해주는 것은 매우 빠르기 때문에 아마 가장 효율적인 방법이 었을 것입니다.
물론 위 두가지 Storage를 서버에서도 사용할 수 있었다면 말이죠..
그래서 고민끝에 도입한 방법은 Redis를 이용해 스토어의 상태값을 캐싱해 주는 작업입니다.
이유는 다음과 같습니다.
그렇다면 그 과정을 살펴보겠습니다.
먼저 Redis서버를 하나 만들어주고 ioredis를 이용해 connect를 생성해 줍니다.
저희 서비스 같은 경우에는 Azure Cache for Redis | Microsoft Azure 를 이용하여 생성하고 진행을 하였습니다.
// src/lib/redisConn.ts
import Redis from 'ioredis';
export const redisConnect = new Redis({
host: '디비 호스트',
port: 6379,
password: '패스워드',
});
이 파일은 pages/api
경로. 즉, 브라우저가 아닌 severless Function에서만 이용할 수 있기이기 때문에 파일은 분리해서 만들어 주어야 합니다.
Client에서 사용할 기능과 API에서 사용할 기능을 분리하는 이유는 같은 폴더안에 기능을 export하고 그중 하나를 Client에서 사용하는 경우, 타입스크립트에서 컴파일시에 모든 코드를 함께 하기때문에 redis같은 경우에는 클라이언트에서
dns
와 같은 서버에서 사용하는 모듈을 찾을 수 없다고 나오게 됩니다.
// src/lib/redisClient.ts
import axios from 'axios';
export const setRedisValue = (key: string, value: string) => {
// pages/api/persistStore에 API를 만들어 줄 것이기 때문에 아래 경로
axios.post(`http://localhost:3000/api/persistStore`, { key, value });
};
// IP를 받아서 key를 ip+key로 만들어주는 함수
export const getRedisValue = async (key: 'user-storage', ip: string) => {
const { data } = await axios.get(
// pages/api/persistStore에 API를 만들어 줄 것이기 때문에 아래 경로
`http://localhost:3000/api/persistStore?key=${ip + key}`
);
if (data) return data;
};
// src/lib/addressManager.ts
export const addressManager = {
saveAddressToStorage: (address: string) => {
sessionStorage.setItem('address', address);
},
getAddressFromStorage: () => {
const data = sessionStorage.getItem('address');
return data;
},
// getAddressFromServer는 getServerSideProps에서 사용됩니다.
getAddressFromServer: (
req: IncomingMessage;
}
) => {
const forwarded = req.headers['x-forwarded-for'];
const ip =
typeof forwarded === 'string' ? forwarded.split(/, /)[0] : req.socket.remoteAddress;
return ip;
},
};
getAddressFromServer
에서 인자로 받아오는 req
는 getServerSideProps
에서 페이지를 요청한 브라우저의 context
를 인자로 전달받기 때문에 context.req
를 getAddressFromServer
에서 사용해 요청을 전송한 클라이언트 IP를 추출합니다.
import type { NextApiRequest, NextApiResponse } from 'next';
import { redisConnect } from 'src/lib/redisConnection';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
let data;
switch (req.method) {
case 'GET':
data = await redisConnect.get(req.query.key as string);
break;
case 'POST':
await redisConnect.del([req.body.key]);
//redis에 데이터를 담을때 유효시간을 짧게 설정해 주는것이 좋습니다.
data = await redisConnect.set(req.body.key as string, req.body.value, 'EX', 18000);
break;
}
res.status(200).json(data);
}
이제 모든 준비는 끝났습니다. persist store
와 serverSideProps
에서 사용을 해주면 마무리 되는데요, 먼저 그 과정이 어떻게 될까요?
먼저 persist 미들웨어에 대하여 이해할 필요가 있습니다.
persist미들웨어는 꼭 LocalStorage
, SessionStorage
가 아니어도, 다음 3가지 매서드를 가진 객체를 만들어서 CustomStorage
를 만들어 줄 수 있는데요,
getItem
- store가 초기화 될 때마다 Storage에 저장된 데이터를 store로 불러옵니다.setItem
- store의 상태가 변경될 때마다, Storage에 상태값을 저장을 해 줍니다.removeItem
- store를 초기화 해줍니다.여기서 중요한 부분은 getItem
과 setItem
을 이용해 스토어를 커스텀해 주는 것입니다.
import debounce from 'lodash/debounce';
import { setRedisValue } from 'src/lib/clientRedis';
import { addressManager } from 'src/lib/managingAddress';
const debouncePersist = debounce((name: string, value: string) => {
const exip = addressManager.getAddressFromStorage();
if (!exip) return;
// 1-2serRedisValue를 이용해 1-3API로 redis에 저장 요청
// 이때 키는 사용자를 식별할 수 있어야 하기 때문에 ip와 storeName을 합친다.
setRedisValue(exip + name, value);
}, 500);
export const customStorage: StateStorage = {
getItem: (name: string) => {
return localStorage.getItem(name);
},
setItem: (name: string, value: string) => {
debouncePersist(name, value);
localStorage.setItem(name, value);
},
removeItem: async (name: string) => localStorage.removeItem(name),
};
debounce
를 이용해 500ms의 텀을 두고, 마지막 변경된 상태값만 저장하게 됩니다.exip
는 localStorage
에 저장되어 있는 현재 사용자의 IP입니다. redis에서 어떤 사용자의 state인지 식별을 위해 함께 키로 전달하게 됩니다.exip
를 저장하는 방법은 2-3에서 확인해 볼 것이며, addressManager
는 1-2순서에서 생성해 주었습니다.이 스토어를 활용하기 위한 persist미들웨어의 옵션은 다음과 같습니다.
export const initializeUserStore = (preloadedState: Partial<UserState> = {}) => {
return create(
persist(
immer<UserState>(set => ({
...getDefaultUserState(),
...preloadedState,
updateUser: payload =>
set((state: UserState) => {
if (state.user) state.user = { ...state.user, ...payload };
else state.user = payload as User;
}),
})),
{
// name 이 store의 이름이고 setStorage,getStorage의 인자로 전송됨
name: 'user-storage',
getStorage: () => (typeof window !== 'undefined' ? customStorage : dummyStorage),
}
)
);
};
위 코드에 대한 자세한 설명은 직전글 3번에 있습니다.
이제 상태값이 변경되면 다음과 같은 순서로 redis에 store의 값이 저장될 것입니다.
customStorage
의 setItem
이 실행된다. (0.5초 기간동안 마지막 상태값)POST:/api/persistStore
로 요청이 된다.key
로 현재 상태값 value
가 redis에 저장된다.그럼 마지막으로 저장된 state값을 사용하는 방법을 알아보겠습니다.
먼저 getServerSideProps
에서 Redis에 저장된 상태값을 불러와 store의 기본값으로 초기화 시켜주는 방법입니다.
//pages/index.tsx
export default function Home() {
const { user } = useUserStore();
return (
<Layout>
<Top />
<span>{Object(user).toString()}</span>
</Layout>
);
}
export const getServerSideProps: GetSSP = async ({ req }) => {
// 1-2에서 만들어준 클라이언트 요청 req를 이용해 ip주소를 찾는 매서드
const ip = addressManager.getAddressFromServer(req);
let store;
if (ip) {
// key가 ip+store이름이기 때문에 ip가 있으면 redis에서 값을 가져온다.
store = await getRedisValue('user-storage', ip);
}
return {
props: {
// 클라이언트 요청 주소를 _app.tsx의 AppProps로 전달한다.
clientIp: ip || null,
// persist할때에는 JSON.stringify()해서 값을 저장하기 때문에 파싱후 state를 AppProps로 전달
initialUserStore: store ? JSON.parse(store).state : null,
},
};
};
위 코드를 통해 redis에서 받아온 새로고침 이전의 state값과, clientIp
를 받아온 _app.tsx
에서는 다음과 같은 과정을 처리해주면 끝입니다.
또한 redis에서 받아온 이전 상태값을 다른 정보를 조회해 수정해 return해줘서 값을 다르게 최신화 시켜줄 수도 있습니다.
// pages/_app.tsx
export default function App({ Component, pageProps }: _AppProps) {
const userStore = useCreateUserStore(pageProps.initialUserStore);
useEffect(() => {
if (pageProps.clientIp) addressManager.saveAddressToStorage(pageProps.clientIp);
}, [pageProps.clientIp]);
return (
<UserProvider createStore={userStore}>
<GlobalStyle />
<Component {...pageProps} />
</UserProvider>
);
}
마지막으로 _app.tsx
에서는 위 getServerSideProps
를 통해 전달받은 clientIp
를 sessionStorage
에 저장해 2-1customStorage에서 사용한 것처럼 redis로 store값을 저장할때 key로 사용할 수 있도록 저장해 줍니다.
지금까지 이번 프로젝트를 zustand
+ nextjs
+ swr
을 이용해 진행하며 마주했던 문제, 그리고 우회법을 알아보았습니다.
현재 nextjs@13 + react@18버전이 release된지 오래 지나지 않았기 때문에, hydration시 contents mismatch 등의 여러가지 문제들이 발생하고 있는것 같습니다.
persist와 ssr을 함께 사용할때에 contents가 다르게 보여질 수 있는 문제를 포함해 여러가지 문제들이 각 라이브러리 커뮤니티 혼자의 힘으로 해결할 수 있는 부분이 아닌, nextjs의 vercel과 커뮤니티가 함께 문제에 대한 의논이 진행되고 있기 때문에, 빠른 시일에 해결될 수 있을 부분으로 보여지지만,
저같은 경우에는 빠르게 프로젝트를 진행해야 했기 때문에 Redis를 이용한 persistStore를 만드는 방법을 선택하게 되었고, serverless function을 가지고 있는 nextjs와의 합도 괜찮았던 것 같다고 생각되어서 이번 글을 공유하게 되었습니다.
그럼 여러분 모두 즐거운 코딩라이프와 함께 화이팅!
궁금하신 내용이 있으시다면 yhg0337@gmail.com 이나, 댓글 부탁드립니다!
멋진 글 잘 읽었습니다