마지막. Redis를 활용한 Persistant storage적용하기

유원근·2022년 12월 29일
5
post-thumbnail

이전에 Hydration이 무엇인지, Next서버에서 어떻게 스토어를 관리해야 하는지에 대하여 알아보았다면,

조금더 효율적으로 persist미들웨어와 함께 스토어를 초기화 하고, Hydration Warning을 방지하는 방법에 대하여 알아보겠습니다.

localStoragesessionStorage에서 store 데이터를 저장하고 불러와 초기화 해주는 것은 매우 빠르기 때문에 아마 가장 효율적인 방법이 었을 것입니다.

물론 위 두가지 Storage를 서버에서도 사용할 수 있었다면 말이죠..

그래서 고민끝에 도입한 방법은 Redis를 이용해 스토어의 상태값을 캐싱해 주는 작업입니다.

이유는 다음과 같습니다.

  • store의 데이터를 유지해야 하는 기간이 길지 않다.
  • 빠른속도로 데이터를 쓰고 받을 수 있다.
  • 연결 과정이 간단하다.
  • Nextjs에서 serverless function을 이용한 API를 만들 수 있기 때문에 별도의 서버 없이 Nextjs안에서 모든 로직을 실행 할 수 있다.

그렇다면 그 과정을 살펴보겠습니다.

1. Redis셋팅 및 Serverless API 생성

1-1. DB 생성 및 연결

먼저 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에서만 이용할 수 있기이기 때문에 파일은 분리해서 만들어 주어야 합니다.

  • Redis는 서버에서 연결할 수 있기 때문에 Next severless Function 이 아닌 리액트에서 사용하면 에러가 납니다.

Client에서 사용할 기능과 API에서 사용할 기능을 분리하는 이유는 같은 폴더안에 기능을 export하고 그중 하나를 Client에서 사용하는 경우, 타입스크립트에서 컴파일시에 모든 코드를 함께 하기때문에 redis같은 경우에는 클라이언트에서 dns와 같은 서버에서 사용하는 모듈을 찾을 수 없다고 나오게 됩니다.

1-2. Client단에서 사용할 Redis 함수 및 IP주소 찾는 기능 만들어주기

// 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에서 인자로 받아오는 reqgetServerSideProps에서 페이지를 요청한 브라우저의 context를 인자로 전달받기 때문에 context.reqgetAddressFromServer에서 사용해 요청을 전송한 클라이언트 IP를 추출합니다.

1-3. 마지막준비로 pages/api/persistStore.ts API를 만들어줍니다.

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 storeserverSideProps에서 사용을 해주면 마무리 되는데요, 먼저 그 과정이 어떻게 될까요?

2. zustand persist미들웨어와 nextjs에서 사용하기

먼저 persist 미들웨어에 대하여 이해할 필요가 있습니다.

persist미들웨어는 꼭 LocalStorage, SessionStorage가 아니어도, 다음 3가지 매서드를 가진 객체를 만들어서 CustomStorage를 만들어 줄 수 있는데요,

  • getItem - store가 초기화 될 때마다 Storage에 저장된 데이터를 store로 불러옵니다.
  • setItem - store의 상태가 변경될 때마다, Storage에 상태값을 저장을 해 줍니다.
  • removeItem - store를 초기화 해줍니다.

여기서 중요한 부분은 getItemsetItem을 이용해 스토어를 커스텀해 주는 것입니다.

2-1. customPersistStorage 만들기

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),
};
  • 상태가 업데이트될 때마다 매번 계속해서 redis에 변경된 상태를 업데이트 요청을 보내기 보다는, debounce를 이용해 500ms의 텀을 두고, 마지막 변경된 상태값만 저장하게 됩니다.
  • SSR 적용없이 CSR만 적용하는 페이지도 있을 수 있기 때문에 localStorage에도 함께 persist를 진행합니다.
  • exiplocalStorage에 저장되어 있는 현재 사용자의 IP입니다. redis에서 어떤 사용자의 state인지 식별을 위해 함께 키로 전달하게 됩니다.
    • exip를 저장하는 방법은 2-3에서 확인해 볼 것이며, addressManager1-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의 값이 저장될 것입니다.

  1. zustand userStore의 state값이 변한다.
  2. customStoragesetItem이 실행된다. (0.5초 기간동안 마지막 상태값)
  3. nextjs serverless API 인 POST:/api/persistStore로 요청이 된다.
  4. ‘클라이언트ip+store명’ 의 key로 현재 상태값 value가 redis에 저장된다.

그럼 마지막으로 저장된 state값을 사용하는 방법을 알아보겠습니다.

2-2. 캐싱된 상태값 불러오기 (+ 클라이언틑 IP 저장하기)

먼저 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를 통해 전달받은 clientIpsessionStorage에 저장해 2-1customStorage에서 사용한 것처럼 redis로 store값을 저장할때 key로 사용할 수 있도록 저장해 줍니다.

진짜 끝!

지금까지 이번 프로젝트를 zustand + nextjs + swr을 이용해 진행하며 마주했던 문제, 그리고 우회법을 알아보았습니다.

현재 nextjs@13 + react@18버전이 release된지 오래 지나지 않았기 때문에, hydrationcontents mismatch 등의 여러가지 문제들이 발생하고 있는것 같습니다.

persist와 ssr을 함께 사용할때에 contents가 다르게 보여질 수 있는 문제를 포함해 여러가지 문제들이 각 라이브러리 커뮤니티 혼자의 힘으로 해결할 수 있는 부분이 아닌, nextjs의 vercel과 커뮤니티가 함께 문제에 대한 의논이 진행되고 있기 때문에, 빠른 시일에 해결될 수 있을 부분으로 보여지지만,

저같은 경우에는 빠르게 프로젝트를 진행해야 했기 때문에 Redis를 이용한 persistStore를 만드는 방법을 선택하게 되었고, serverless function을 가지고 있는 nextjs와의 합도 괜찮았던 것 같다고 생각되어서 이번 글을 공유하게 되었습니다.

그럼 여러분 모두 즐거운 코딩라이프와 함께 화이팅!

궁금하신 내용이 있으시다면 yhg0337@gmail.com 이나, 댓글 부탁드립니다!

1개의 댓글

comment-user-thumbnail
2023년 8월 25일

멋진 글 잘 읽었습니다

답글 달기