ssr 데이터 동적 할당

천영석·2021년 9월 14일
0

데이터 동적으로 할당하기 위한 삽질기

그냥 서버에 요청해서 초기 데이터 할당하면 되잖아?

일단은 쿠키부터가 말썽이었다. 서버에서는 로컬스토리지에 접근할 방법이 없었고, 현재 사용하고 있는 인증 방법은 토큰이기 때문에 로컬스토리지에 저장되어 있는 토큰을 쿠키로 옮겨야만 했다.

로컬스토리지로 할 방법을 하루종일 찾아봤지만 찾지 못했고, 불가능하다고 판단했다.

쿠키로 변경하고나서 쿠키가 세팅되지 않는 버그를 발견했다. 이유는 path가 없어서였다. 깃허브 로그인 페이지에서는 로그인 시 github/callback 페이지에서 로그인 요청을 보내고, 토큰을 받아오는데 이 페이지에서의 path가 설정돼서 메인에서 사라지는 것 같았다. 그래서 path를 '/'로 설정해주고 버그를 해결했다.

recoil에게 초기 상태값만 할당하면 끝난다. initializeState props를 지원하고 있고, 해당 props로 atom을 세팅해줄 수 있다. 잘 봐야한다. atom이다. 어디에도 나와있진 않지만(못 찾았을 수도) selector의 초기 값을 설정해주지는 않는 것 같다. selector에는 default 값이 없기 때문이다. 단지 참조하고 있는 atom의 값이 변하면 업데이트를 할 뿐... 이 중요한 사실을 너무 늦게 알았다.

기존에는 딱 2번만 발생하고 끝나던 renderToString에서 Suspense를 지원하지 않는다는 문구가 이제 무한으로 떴다. 서버가 열리지 않는 것이다. 이유는 recoil 때문이었다. 사실 아직도 조금은 미스테리이다. 어쨋든 오류가 나던 이유를 적어보겠다. (정말 뇌피셜이고, 이슈를 다 찾아봤는데도 나오지 않았다. 실험을 하면서 그냥 이렇게 생각하기로 했다.)

우선, recoil에서는 selector에 요청할 때 selector가 비동기 함수라면 데이터를 받아올 때까지 Suspense로 요청을 보낸다. 메인 페이지에서 selector을 쓰는 곳이 있다. 바로 문제집을 가져오는 부분이다. 즉, 메인 페이지에서는 무조건 selector를 쓰기 때문에 Suspense가 발동한다.

예전에 Suspense를 노드 환경일 땐 children을 반환하도록 했다. 이것이 잘 동작할 줄 알았다. 하지만 다시 생각해보니 리코일에서 Suspense로 throw를 할 때, children을 보면 children은 Suspense가 아니기 때문에 더 상위까지 찾아 올라갈 것이다. 하지만 모든 Suspense가 children으로 동작한다.(node 환경이라서) 그러니 Suspense도 못찾았고.. 어쨌든 Suspense를 사용해야 하니 Suspense를 지원하지 않는다는 에러가 발생하는 것이다.

여기에서 난 초기 값을 할당하면 요청을 보내지 않을 것이기 때문에 Suspense가 작동하지 않을 것이라고 생각했다. 하지만 상대는 selector였다. 아무리 초기 값을 할당해도 다시 요청을 보내고 있었다. 하지만 유저 정보가 저장되어 있는 userState는 초기 값이 할당되어서 요청을 보내지 않고 있었다. 이유는 atom이기 때문이다. atom과 selector가 연결되어 있는 atom selector이지만 결국 default 값이 존재하기 때문에 초기 값 세팅이 되는 것이었다.

삽질을 그만하자는 생각이 들었고, 코드가 조금 더러워지더라도 리액트 18이 아니고, Suspense를 지원하지 않기 때문에 어쩔 수 없다고 생각했다. 지금와서 포기할 수는 없기 때문이다. 경험이라도 얻고 가야지... 그래서 중복 코드를 작성할 수 밖에 없게 되었다.

export const workbookState = selector<WorkbookState>({
  key: 'workbookState',
  get: async ({ get }) => {
    get(workbookUpdateTrigger);
    try {
      return {
        data: get(userState) ? await getWorkbooksAsync() : [],
        errorMessage: null,
      };
    } catch (error) {
      return {
        data: [],
        errorMessage: '문제집을 불러오지 못했어요.',
      };
    }
  },
  set: ({ set }, value) => {
    if (value instanceof DefaultValue) {
      set(workbookUpdateTrigger, (prevState) => prevState + 1);
      set(shouldWorkbookUpdateState, false);
    }
  },
});

일단 이렇게 데이터를 받아오고 있던 selector를 아래의 atom selector로 변경했다.

export const workbookState = atom<WorkbookState>({
  key: 'workbookState',
  default: selector({
    key: 'workbookRequest',
    get: async ({ get }) => {
      try {
        return {
          data: get(userState) ? await getWorkbooksAsync() : [],
          errorMessage: null,
        };
      } catch (error) {
        return {
          data: [],
          errorMessage: '문제집을 불러오지 못했어요.',
        };
      }
    },
  }),
});

여기까지는 괜찮았다. 하지만 atom은 reset을 하면 초기 값이 설정되고, Suspense를 지원하지 않는다. atom selector라서 reset을 하면 다시 selector가 발동돼서 Suspense로 넘어갈 줄 알았지만 어림도 없었다. atom은 atom이다. 초기 값을 불러올 때만 selector가 발동되는 것이다.

즉, 해당 atom에 데이터를 할당하기 위해서는 해당 atom을 사용하는 훅에서 직접 setWorkbook을 통해 데이터를 set 해야 한다.

const [{ data: workbooks, errorMessage }, setWorkbooks] = useRecoilState(workbookState);

const updateWorkbooks = async () => {
  try {
    const workbookResponse = await getWorkbooksAsync();
    setWorkbooks({ data: workbookResponse, errorMessage: null });
  } catch (error) {
    setWorkbooks({
      data: [],
      errorMessage: '문제집을 불러오지 못했어요.',
    });
  }
};

위의 코드와 비슷한 코드가 한번 더 쓰였다. 해당 함수는 문제집을 추가, 삭제, 수정했을 때 트리거되어서 새롭게 데이터를 요청하는 함수이다. 후... 비슷한 코드가 두번 쓰였지만 도저히 추상화 할 용기가 나지 않았다. 그래서 그냥 두기로 결심했다. (메인 페이지만 ssr을 해야겠다는 다짐도 같이 하면서)

이젠 문제 없이 잘 동작하는 것을 확인했다. preview에서 데이터까지 모두 넘어온다. 그리고 이제 2번 이후에야 서버가 열리는 오류도 사라졌다. 좋은(?) 경험이었다.

아직 문제는 더 있다. express 서버의 동작 원리를 잘 모르겠는데, 이전 데이터가 유지될 때가 있다. 아니 대체 왜??? 어떻게 알게 되었냐면 예전에 2번씩 Suspense를 지원하지 않는다고 오류가 떴었는데, 오늘 console을 찍어보면서 알게 되었다. 처음에 한번 userState에서 selector가 존재하기 때문에 Suspense 요청 보내서 에러가 뜨고, 다음에 요청을 보내면 userState는 이미 default 값이 null로 설정돼서 그런지 userState는 건너 뛰고 workbookState에 접근한다. 근데 workbookState도 selector라서 오류가 뜬다. 그러고 값이 설정되어서 그 다음부터는 오류가 안뜨는 것이다.

이 말은 새로고침을 해서 서버에 접속했을 때, express에서 이전 App의 데이터를 날리지 않고 유지하다가 그 뒤부터 실행해서 준다는 말이다. 어떻게 가능한 것인지 모르겠다. 에러 처리를 하지 않아서 그럴 수도 있겠다는 생각이 문득 든다. 내일은 에러 처리를 해봐야겠다. 에러가 나면 콘솔에만 에러를 띄우는 것이 아니라 곧바로 index.html을 내보내도록 해야겠다. ssr이 안되면 csr이라도 해야 하기 때문이다.

여기서 더 큰 문제가 있는데, 가끔 다른 사람의 계정으로 로그인이 된 상태가 있다는 것이다. 하... 정말 모르겠지만 내일 다시 한번 봐야겠다.

캐시 설정

새로운 사실을 알았다. nginx에서 캐시 설정을 한 부분과 express에서 캐시 설정을 하는 부분이 겹치는 것이다. nginx에서는 이전에 index.html은 Cache-Control: no-store을 적용해놨어서 별 생각을 안하고 있었는데, 이번에 확인해보고 놀랐다. Cache-Control: max-age=31536000이 들어있는 것이다.

왜 그런지 잠깐 생각해봤다. 우선 nginx로 들어오는 모든 것에 Cache-Control: max-age=31536000을 적용했다. 그러고 index.html만 따로 Cache-Control: no-store을 적용해서 덮어씌웠다. 이게 nginx 안에서만 발생할 때는 괜찮았는데, 이젠 reverse-proxy로 express로 보내고 있기 때문에 nginx의 index.html에 접근하지 못해 Cache-Control: no-store가 적용이 안되는 것이었다. 게다가 express의 res.send()의 기본 캐시값은 없기 때문에 nginx에서 설정된 Cache-Control: max-age=31536000이 그대로 적용되고 있었다. 즉, index.html이 캐싱되고 있었다. 이 과정에서 새로운 것도 알게 되었는데, index.js같은 자바스크립트 파일은 max-age만 붙여도 캐싱이 되지만 index.html은 no-cache처럼 한번 서버에서 변경된 사항이 있는지 점검을 받는다는 것이다. 변경되지 않았다면 304 Not Modified를 보내준다.

더 놀라운 것은 nginx의 Cache 설정과 express의 Cache 설정이 둘 다 response headers에 들어온다는 것이다. 그래서 자바스크립트는 Cache가 두개였다. 한개는 max-age=31536000, 한개는 public max-age=0으로 되어 있었다. 후자는 express의 기본 캐시이다.(express.static을 사용했을 경우 https://expressjs.com/en/api.html#express.static)

당장 고쳐야겠다는 생각을 했고, 이제 nginx는 express로 전달하는 역할만 할 뿐 아무 역할도 아니기 때문에 nginx에서 설정하고 있던 headers를 모두 날렸다. 그리고 express에서 app.set을 통해 헤더를 설정해주었다. 기본 값에 Keep-Alive: timeout=5도 포함되어 있기 때문에 timeout=0으로 설정해서 없애주었다. mdn에서는 Keep-Alive를 권장하고 있지 않기 때문이다. 아무래도 5초 간 네트워크를 유지한다는 것이 큰 부담일 수도 있을 것 같다.(https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Keep-Alive)

추가적으로 express의 캐시 기본 값은 Cache-Control: public, max-age=0, Connection: keep-alive, Keep-Alive: timeout=5, eTag, lastModified 모두 설정되어 있다.(express.static)

이제 캐시 설정도 모두 끝났다. 남은 것은 이전에 겪은 문제인 쿠키가 공유된다는 것이다. 가장 큰 문제이지만 어떻게 해결해야 할지 모르겠다. (이건 아무리 실험해봐도 이젠 나타나지 않는 것을 보니 해결이 된 것 같다.)

로그아웃시 쿠키가 사라지지 않는 문제를 해결했다. path가 일치하지 않아서 생기는 문제라서 path를 설정해줬다. (로그아웃은 github/callbak페이지에서 발생하는데 쿠키의 path는 '/'라서 path가 일치하지 않아서 발생하는 문제였다. 기본적으로 /에서 만들어진 쿠키는 /에서만 삭제할 수 있다.)

profile
느려도 꾸준히 발전하려고 노력하는 사람입니다.

0개의 댓글