Published on

nextjs 서비스 개발부터 운영까지 (2) - nextjs 서비스 개발 경험 및 트러블슈팅

Authors

 충분하지 않았을 수 있지만 Part1 이후로 우리는 이제 nextjs 앱을 aws에 ecs를 활용해서 구성하고 배포할 수 있게 되었습니다. 사실 Part1은 nextjs와 관련된 내용은 거의 없었고 aws 인프라를 어떻게 구성할 수 있는가에 대한 글에 가까웠습니다. 그래서 대제목과 어울리게 이번 글에서는 구체적으로 nextjs로 개발하면서 어떤 이슈들에 부딪혔었고 어떻게 접근해왔었는지에 대한 이슈들에 대해서 이야기해보려고 합니다. 간단하게 목차를 적어 보았습니다.

목차

nextjs 한걸음 더 나아가기

SSG, SSR, SPA에 대해서 조금 더 이해해보기

 nextjs에서 어떤 페이지는 getServerSideProps를 사용해서 SSR을 하고, 어떤 페이지는 getStaticPaths와 getStaticProps를 사용하거나 아무것도 사용하지 않아서 pre-rendering을 해야할까와 같은 고민을 nextjs 서비스를 개발하면서 초기에 했었는데요. 블로그와 같이 생성 시를 제외하고 데이터가 자주 변할 일이 없다면 당연히 SSG가 모든 면에서 뛰어나기 때문에 next export를 사용해서 모두 빌드 시점에 html로 만들어두면 그것이 최선이라고 생각합니다. 하지만 대부분 우리가 만들려고 하는 앱들은 그것보다 조금 더 다이나믹한 앱일 경우가 많습니다. 그러면, SSG를 사용할 수 없는 경우에는 어떤 전략을 취하는게 맞을까요?

 SSR이냐 SPA이냐를 결정해야 할 때 제가 고려한 요소는 SEO와 FCP 두 가지였습니다. 프론트 개발할 때에 사용할 수 있는 접근 3가지가 두 요소에서 어떤 우위를 가지고 있는지를 표현해본다면 다음과 같을 것입니다. 아래에서 SPA라고 표현한 것은 서버에서 데이터를 미리 준비하고 html을 만들지 않고 js가 로드되고 데이터를 가져와서 화면을 구성하는 방법을 표현한 것입니다.

  • [SEO] SSG = SSR > SPA
  • [FCP] SSG >= SSR > SPA

 SSG를 할 수 없는 경우에 위의 요소만을 보았을 때에 우리는 항상 SSR을 하는 것이 맞지 않을까라고 생각할 수 있습니다. 하지만 SSR이 SPA보다 항상 우위를 가질 수 있는가를 판단하기 위해서는 캐시를 할 수 있는지에 대한 조건도 고려를 해야합니다. 위의 비교는 SSR에서 html response를 생성하기 위한 remote server request에 걸리는 시간을 제외하고 비교했기 때문입니다. 캐시를 하기 어려운 페이지는 다음 정도의 페이지들을 생각할 수 있습니다. 쿠키를 캐시키에 포함시키면 가능할 수 있겠지만 유저별로 다르게 표시가 되어야할 페이지는 캐시를 하기가 어렵습니다. 또는 유저의 특정 액션에 따라서 사용되는 데이터가 자주 변경될 수 있는 페이지는 캐시하기가 어렵습니다. 결론적으로 결국 nextjs로 개발할 때에 저는 다음과 같은 조건으로 SSR을 할 페이지들을 결정했습니다.

  • SEO가 필요한 페이지이고, 캐시를 할 수 있다면 SSR -> 상품목록 페이지 (1분 정도 캐시를 해도 크게 문제가 없을 수 있습니다.)
  • SEO가 필요없거나 캐시를 하기 어려운 페이지라면 SPA -> 상품 카트페이지 (유저마다 다르게 보여질 페이지이므로 SEO관련이 없고, 매번 자주 변하는 데이터들이기때문에 캐시를 하기 어렵습니다.)

프로젝트의 구조를 어떻게 구성할 것인가

 React로 서비스를 구성할 때에 어떻게 프로젝트의 구조 및 폴더 구조를 가져가면 좋은지에 대한 Best Practice같은 것들을 React에서 크게 제안하고 있는 내용은 따로 없습니다. 그래서 다들 여러 가지 방법들을 사용하고 있습니다. 그래도 이번에 작업하면서 저는 어떤 접근을 취했는지에 대해서 공유 해보려고 합니다.

  • next config에서 pageExtentionspage.tsx로 설정했습니다.
    • 위 설정을 통해서 pages폴더 안에서 페이지에서 사용하는 컴포넌트들을 pages 폴더 안에 최대한 가깝게 co-location시킬 수 있도록 접근했습니다.
    • 그리고 pages 폴더안에서 components, hooks와 같은 기능적 폴더 분리를 하지 않았습니다. 왜냐하면 하나의 페이지에 관련된 파일들이 많아봤자 보기에 불편할정도로 엄청 많아지지 않을 것이라고 생각했고(-> 아직까지는 그렇습니다. 하지만 나중에 어떤 상황에 의해서 변할 수도 있겠습니다.), 특히나 nextjs의 pages폴더는 pageExtensions를 사용했지만, route를 표현하는 폴더이기때문에 따로 폴더링을 하고 싶지 않았습니다.
  • libs, utils 폴더를 사용했습니다.
    • 서비스를 개발하다보면 공통적으로 사용해야하는 utils성 함수들이 존재할 수 있는데요. libs는 특정 라이브러리와 관련된 전역적인 utils 함수들을 모아두었고, utils 폴더에는 라이브러리와는 관계없지만 전역적으로 사용 함수들을 모아 두었습니다. (ex. format하는 함수)
  • domains라는 폴더를 사용했습니다.
    • domains라는 폴더는 주문, 장바구니와 같이 하나의 페이지에만 국한되는 것이 아니라 여러 페이지에서 공통적으로 사용할 수 있는 로직들을 모아두는 폴더를 생성해서 사용했습니다.

react@next, react-dom@next의 Suspense 이슈

  react@next, react-dom@next를 사용해야만 nextjs에서 suspense for data fetching을 이용할 수 있습니다. 그런데, suspense는 에러가 나면 바로 fallback으로 떨어지고 데이터가 준비가 되면 render를 진행하는 식의 동작방식을 가지고 있는데요. 이게 nextjs의 서버에서 작동할 떄에는 그냥 특정 컴포넌트에서 error가 발생하면, 그냥 fallback으로 떨어져 버리고 html생성을 안하고 그냥 빈 fallback을 return합니다. 그래서 이런 경우가 있었습니다. 특정 컴포넌트가 useEffect가 아닌 useState에서 그냥 window 객체에 접근하고 있었고 서버에서 해당 컴포넌트를 render하려고 했을 때에 에러가 나면서 그냥 html을 서버에서 만들지 않고 null을 내려주어서 SSR이 되어야하는 페이지인데도 불구하고 SSR이 안된 경우가 있었다. 아직은 사용자가 이런 실수를 하지 않도록 주의하는 것 밖에 방법을 찾진 못했는데, 더 좋은 방법이 분명히 있을 것 같다는 생각을 하고 있고 틈틈히 찾아볼 생각입니다.

import * as React from 'react'

// _app.tsx
function MyApp() {
  return (
    <React.Suspense fallback={null}>
      <div>Hello World</div>
      {/* window는 서버에서 실행시 없는 객체이므로 에러가 나면서 서버 렌더링 시에 fallback인 null로 render된다. */}
      <div>{window.location.pathname}</div>
    </React.Suspense>
  )
}

react-query의 suspense 모드를 사용함으로써 useQuery가 서버에서도 실행이 된다.

 react-query의 기본적인 작동방식은 useEffect안에서 queryFn을 실행하는 것이다. 그런데 suspense mode를 키게 되면 suspense의 기본 동작과는 다르게 render될 때에 queryFn이 불리게 된다. 그래서 실행되면 안될 useQuery가 실행되고 에러가 나고 있는 것을 cloudWatch 로그를 보면서 확인했습니다. 처음에는 이유를 정확히 몰랐으나 react-query discusstion에서 contributor들과 이야기를 나누면서 원인을 알게 되었습니다. 지금 이 이슈를 적절하게 처리할 수 있는 방법을 react-query에서 제공하고 있지는 않습니다. 그래서 현재는 server에서는 useQuery가 성공한 것처럼 request는 안보내고 그냥 바로 return해버리는 식으로 우회하고 있다.

// _app.tsx
import * as React from 'react';
import { Hydrate, QueryClientProvider } from 'react-query';

function MyApp({ Component, pageProps }) {
  // suspense 모드 true를 하면
  const [queryClient] = React.useState(() => new QueryClient({ defaultOptions: { queries: { suspense: true }}}));

  return (
    <React.Suspense fallback={null}>
      <QueryClientProvider client={queryClient}>
        <Component {...pageProps}>
      </QueryClientProvider>
    </React.Suspense>
  );
}

// pages/index.tsx
function Page() {
  // 아래의 queryFn이 서버에서도 실행이 됨을 기억해야 합니다.
  const query = useQuery('queryKey', async () => {
    if (typeof window === 'undefined') {
      return { success: true, };
    }

    const result = await Promise.resolve({})
    return { success: true, ...result };
  });
  return (
    <div>
      <div>Hello World: {query.data}</div>
    </div>
  );
}

Persistent Layout 구성하기

 nextjs는 react-router와는 다르게 nested router같은 것들을 구현할 수 없기 때문에 공통된 layout 컴포넌트 등을 사용하는 방법에 대해서 꽤 헷갈릴 수 있습니다. 페이지별로 매번 똑같은 Layout 컴포넌트를 둘러 싸서 사용한다고 하더라고 페이지간 state를 공유하고 싶은 경우를 해결할 수는 없습니다. 이 상황에서 우리는 react의 특성에 대해서 조금 더 이해할 필요가 있습니다. react가 dom을 그리고 업데이트할 때에는 React의 Element의 종류와 순서 등을 고려해서 이전의 React Element를 그대로 유지하고 사용할지 새로 만들지를 결정하게 됩니다. 그렇기 때문에 각 페이지에서 순서를 유지한채로 동일한 컴포넌트를 사용하게 되면 페이지가 변경될 때에도 동일한 state를 가지고 있는 persistent layout을 구현할 수 있습니다. 자세한 내용은 nextjs 문서 링크에서 자세히 확인할 수 있습니다.

// pages/_app.tsx
export default function MyApp({ Component, pageProps }) {
  // Use the layout defined at the page level, if available
  const Layout =
    Component.Layout ?? (({ children }: { children: React.ReactNode }) => <>{children}</>)

  return (
    <Layout>
      <Component {...pageProps} />
    </Layout>
  )
}

// pages/index.tsx
import Layout from '../components/layout'
import NestedLayout from '../components/nested-layout'

export default function Page() {
  return <div>Hello Word</div>
}

Page.Layout = function Layout({ children }) {
  return (
    <>
      <nav>NavBar</nav>
      <main>{children}</main>
    </>
  )
}

.env, .env.local의 사용

 서비스를 개발하면서 dotenv와 같이 환경변수를 관리하는 라이브러리를 사용해보신 경험이 있으실 수도 있을텐데, nextjs에서는 관련 기능을 조금 더 디테일하게 제공합니다. 두 가지로 나눠서 사용할 수 있는데 관련된 내용을 간단하게 설명해보겠습니다. 다음 설명은 실제 nextjs 공식문서에도 자세히 설명되어 있습니다.

  • .env, .env.development, .env.staging과 같이 환경에 따라서 사용되는 환경변수를 지정할 수 있습니다. local이라는 suffix가 없는 경우는 repository에 포함되고 노출되어도 괜찮은 환경변수만을 포함시켜야합니다.
  • .env.local, .env.development.local과 같이 local이라는 단어가 포함이 되면 repository에 포함되지 않는 환경변수를 사용할 것임을 선언하는 것입니다. 실제 빌드시에 이런 값들은 Github secrets등을 이용해 빌드 시에만 추가해서 사용해야 합니다.

next/router를 사용하면서 주의해야할 것

 nextjs로 개발하면서 next/router를 사용하면서 몇 가지 주의해야할 사항들이 있습니다. 첫 번째로 next는 page를 이동하면서 SPA처럼 바로바로 페이지가 바뀌지 않습니다. 왜냐하면 getServerSideProps를 지정되어있는 경우 해당 함수 실행이 완료되고 페이지를 render하기 시작하기 때문에 그렇고 getServerSideProps가 없더라도 pages별로 asset파일들이 따로 생성이 되기 때문에 asset들을 다운 받는데에도 시간이 걸릴 수 있습니다. (물론, production앱에서는 nextjs가 next/link를 사용해서 잘 prefetch를 해주고 직접 코드로 prefetch를 할 수 있는 방법을 제공하기도 합니다.) 그래서 저는 유저에게 page가 변경되고 있다는 사실을 잘 전달 할 수 있도록 nprogress라는 라이브러리를 활용해서 router의 이벤트가 발생할 때마다 loading bar를 최상단에 보여주도록 작업했습니다. (nprogress)

 두 번째로, next/router의 useRouter에서 return되는 query object가 server-side rendering 페이지가 아닌 경우에는 처음에는 empty object로 내려옵니다. 그래서 실제 pageParams과 querystring에 접근하기 위해서는 useRouter().isReady값이 true가 된 이후에 해당 값을 사용해야 합니다. 하지만, 저는 위 내용이 왜 그렇게 구현되었는지에 대한 이유는 정확히 모르고 이해가 잘 안되었습니다. 또한, React Suspense For data fetching을 사용하고 있었기 때문에 처음 rendering에서부터 해당 값이 필요했는데요. 그래서 직접 router.asPathwindow.location에 존재하는 값들을 매핑해서 직접 pageParams와 queryString을 초기 렌더링 시에도 사용할 수 있도록 작업하긴 했습니다. 뭔가 이유가 있기 때문에 nextjs에서 위와 같이 구현했을 것 같긴 한데, 우선 제가 불편해서 위와 같이 개선해서 사용해보고 있습니다.

function useRouterQuery() {
  const router = useRouter()
  return React.useMemo(
    () =>
      router.isReady
        ? router.query
        : (() => {
            return {
              ...getRouterParams({
                asPath: router.asPath,
                pathname: window.location.pathname,
              }),
              ...toSearchObject(new URLSearchParams(window.location.search)),
            }
          })(),
    [router.asPath, router.isReady, router.query]
  )
}

next/image를 사용하면서 주의해야할 것

 nextjs에서는 next/image를 통해서 이미지를 optimize할 수 있는 기능을 제공합니다. 결론적으로 저는 현재 unoptimized라는 props를 추가해서 optimize하는 기능을 사용하고 있지는 않습니다. image를 optimize하는 작업과 html을 서빙하는 일을 하나의 서버에게 맡기기에는 꽤 무리가 있어 보였고, cache가 잘되지 않는다면 서비스 운영에 큰 영향을 끼칠 수 있다고 생각했기 때문입니다. 왜냐하면 이미지가 로드가 조금 오래걸리는 것보다 html이 생성이 안되는게 훨씬 더 크리티컬한 오류이기 때문입니다. 실제로 캐시 정책이 제대로 작동하지 않는 상황에서 next/image의 optimize를 사용하면서 nextjs 서버의 cpu가 100%를 찍는 것까지 목격했었습니다. 그래서 이미지 최적화에 대한 작업은 서버가 이미지를 업로드할 때에 잘 해줄 것을 기대하고 믿으며 작업하고 있습니다. 실제로 nextjs의 image optimize 기능을 production에서 사용할 때에는 html 서빙하는 서버에서 직접 이 일들을 하기 보다는 다른 provider들을 사용하는게 맞을 것 같은데 다른 분들은 어떻게 접근하고 계신지 궁금하긴 합니다. vercel에서 배포해서 사용했을 때에는 따로 optimize하고 서빙해주는 worker같은 것들이 돌아가는 것처럼 확인됩니다.

Vercel makes it easy to accomplish ideal loading times and prevent layout shifts for images when deploying Next.js applications by providing you with a built-in next/image component that automatically optimizes your images on demand and serves them from our globally distributed Edge Network.

 그 이외에도 그래도 next/image의 흥미로운 점들은 최신 image format들(avif, webp)등도 제공한다는 것이고, imagesSize, deviceSizes등의 설정을 통해서 srcset를 생성해주는 것을 직접 설정해서 사용할 수도 있습니다.

 그리고 minimumCacheTTL을 사용해서 optimized된 이미지들의 cacheTime을 조절할 수도 있지만, nextjs에서 결국 image의 response를 돌려줄 때에 origin의 cache-control을 기준으로 돌려주기 때문에 minimumCacheTTL 설정은 origin의 cache-control보다 우선할 수는 없습니다.

next.config.js에서 headers 옵션 사용하기

 Part1에서도 말했던 것처럼 cloudFront는 cache-control header가 없으면 무제한으로 캐싱을 한다. 그렇기 때문에 nextjs에서 아무런 설정을 안하면 html response에 대해서 무한 캐싱을 해버리기때문에 html response에 대해서 캐시를 아예 하지 않도록 아래와 같은 코드 추가가 필요했다. 아래와 같이 추가하더라도 assets(js, css)에 대한 cache-control은 nextjs에서 알아서 무제한으로 override해주니 그것에 대해서는 걱정 안해도 된다.

export default {
  headers: () => {
    return [
      {
        source: '/:path*',
        headers: [
          {
            key: 'Cache-Control',
            value: 'no-cache,no-store,max-age=0,must-revalidate',
          },
        ],
      },
    ]
  },
}

next.config.js assetPrefix의 사용

 assetPrefix라는 설정을 사용하면 nextjs 서버에서 직접 asset들을 서빙하지 않고 다른 cdn에서 assets 파일들을 서빙할 수 있습니다. 이 설정을 하는 것이 더 권장되는 이유는 nextjs 서버는 실제로 유저의 인증 등을 위해서 cookie값들 혹은 querystring 값들을 계속해서 들고 다니는데 사실 assets파일들을 서빙할 때에는 그런 값들이 필요없을 수 있는데 불필요한 network resource가 낭비될 수 있기 때문에 더 최적으로 서비스를 운영하기 위해서는 assetPrefix를 사용하는 것이 더 좋습니다. 또 cloudFront를 따로 사용해야 더 적절한 error pages 설정 등을 세팅하기에 좋습니다.

인증을 어떻게 할 수 있을까

 nextjs에서는 인증을 서버에서도 확인할 수 있고 브라우저에서도 확인이 필요합니다. 보통 SPA를 개발할 때면 인증을 받고 token을 받아서 localStroage에 저장해두고 그 값을 기준으로 로그인 유지를 구현하곤 합니다. 하지만 nextjs에서는 서버에서도 인증이 필요하기 떄문에 localStorage만으로는 사용할 수 없습니다. 그래서 저는 nookies라는 라이브러리를 활용해서 쿠키 기반으로 nextjs 서버에서도 인증을 하고 브라우저에서도 인증을 하고 있습니다. 혹시나 모를 상황에 대비해서 fallback으로 클라이언트에서 localStorage를 확인해서 인증을 확인하고 있기는 합니다. (nookies)

nextjs와 같이 사용하고 있는 라이브러리들

react-query를 SSR에서 활용하기

 react-query를 애용해왔지만, SSR에서 사용한 것은 이번이 처음이었는데요. SSR 관련된 API나 docs가 존재한다는 생각을 하지 못하고 무작정 SPA에서 사용하던 방식대로 react-query를 사용하다가 몇가지 시행착오를 겪었습니다.

  • 첫 번째로는 new QueryClient()를 어떤 식으로 생성해서 사용해야하는가에 대한 문제입니다. 전역적으로 new QueryCLient()를 작성해두고 사용해도 SPA를 사용할 때에는 user의 request마다 새로운 queryClient가 만들어지고 사용될 것이 보장되었지만, nextjs에서 전역적으로 선언한 변수는 유저 request별로 공유될 수 있는 가능성이 있기 때문에 매번 _app.tsx파일에서 new QueryClient()를 새로 만들어 줘야 합니다. 위 내용은 react-query ssr 관련 문서에 자세히 작성되어있습니다.
  • 두 번째로는 getServerSideProps에서 이미 fetch해온 정보를 어떻게 컴포넌트에 작성되어 있는 useQuery에서 바로 사용할 수 있게 만들 수 있을까에 대한 고민이었습니다. 이것 또한 react-query의 ssr 문서를 보면 getServerSideProps에서 prefetchQuery를 하고 _app.tsx에서 <Hydrate />를 사용하는 방식이 소개되어 있었는데 관련 내용을 읽지 못했었고 나중에야 발견하고 적용했던 경험을 했습니다.

 따로 경험은 없지만, nextjs를 만든 vercel에서 react-query 역할을 하는 swr이라는 라이브러리를 만들었는데, nextjs와 swr가 훨씬 궁함이 잘맞을 수도 있겠다는 생각이 들긴 했지만, 회사 내부에서 react-query를 더 많이 사용하고 있기에 이번 nextjs 프로젝트에서도 react-query를 사용하게 되었습니다. 다음에는 swr도 사용해볼 기회가 있다면 해보면 또 다른 좋은 경험을 할 수 있지 않을까도 생각해봤습니다.

// _app.tsx
import * as React from 'react';
import { Hydrate, QueryClientProvider } from 'react-query';

function MyApp({ Component, pageProps }) {
  // 매번 user request마다 QueryClient를 새로 생성합니다. (React useState lazy initialization)
  const [queryClient] = React.useState(() => new QueryClient());

  return (
    <React.Suspense fallback={null}>
      <QueryClientProvider client={queryClient}>
        {/* Hydrate 컴포넌트를 활용해서 state를 주입 */}
        <Hydrate state={pageProps?.dehydratedState}>
          <Component {...pageProps}>
        </Hydrate>
      </QueryClientProvider>
    </React.Suspense>
  );
}

openapi-typescript로 api의 type generation을 자동화하기

 API에서 데이터를 받아올 때에 서버가 graphql를 사용하고 있지 않다면 type을 직접 작성해줘야하는 일들이 발생하는데요. 이번 프로젝트도 마찬가지였습니다. API는 rest로 구성되어 있었고, typescript를 쓰는 클라이언트에서 type checking을 하기 위해서 직접 typing을 작성해줘야만 했습니다. 그런데 이런 불필요한 작업들을 줄이기 위해서 이번에 도입한 것이 openapi-typescript라는 라이브러리를 사용해서 API서버에서 swagger의 메타정보를 이용해서 바로 type을 generation을 할 수 있도록 했습니다. 위 방식을 도입하기 위해서는 백엔드팀에서도 swagger 정보를 yml혹은 json으로 뽑아줄 수 있는 endpoint를 만들어줘야하는 수고를 해주어야 하는데요. 다른 팀에서도 백엔드팀과 논의시 이것이 프론트팀에게 얼마나 큰 도움이 되는지를 잘 설득하고 할 수 있다면 굉장히 좋을 것 같습니다.

 우선, 위 내용들을 성공하고 openapi-typesciprt를 통해서 type을 만들었다고 해도 조금 불편했던 점은 만들어준 type의 형태가 꽤나 많이 nested 되어있는 object의 형태였고, 조금 더 쉽게 원하는 type들에 접근하기 위해서 좋은 방법이 없을까 생각하다가 typescript utility library인 ts-toolbelt를 활용해서 필요한 type들을 조금 더 쉽게 얻고 활용할 수 있었습니다. 관련해서 몇 가지만 적어보겠습니다.

import { O } from 'ts-toolbelt'
import type { components, paths } from '~/types/generated'

export type OAIMethods = 'get' | 'put' | 'post' | 'delete' | 'patch'

// (O.Selectkeys 사용) 특정 OAIMehotds에 해당하는 path keys들의 타입
export type OAIPathKeysByMethod<TMethod extends OAIMethods> = O.SelectKeys<
  paths,
  Record<TMethod, unknown>
>
// ex) get endpoint를 가지고 있는 모든 endpoint들
type GetPathKeys = OAIPathKeysByMethod<'get'>

// (O.Path 사용) 특정 path에 해당하는 pageParams들을 가져오는 type
export type OAIPathParameters<TPath extends OAIPathKeys, TMethod extends OAIMethods> = O.Path<
  paths,
  [TPath, TMethod, 'parameters', 'path']
>

// (O.Path 사용) 특정 path에 해당하는 queryString들을 가져오는 type
export type OAIQueryParameters<TPath extends OAIPathKeys, TMethod extends OAIMethods> = O.Path<
  paths,
  [TPath, TMethod, 'parameters', 'query']
>

react-query, immer를 사용해서 optimistic update 구현하기

 장바구니에서 장바구니에 담긴 item들의 수량 변경, 체크상태 등을 browser의 상태에서만 관리하는 것이 아니라 서버에 기록을 하고 서버의 데이터에 의존해서 페이지를 만들어야 했습니다. 그런데, 수량을 변경하거나 checkbox 체크를 할 때마다 API call을 하게 되면 빠르게 input에 대한 feedback을 받고 싶어하는 유저의 입장에서는 매번 수량 변경을 하거나 체크박스에 체크를 할 때마다 로딩 화면을 보여주는 것은 좋은 UX가 아니라고 생각했습니다.

 위 상황을 해결하기 위해서 생각했던 방법은 다음 두 가지정도였습니다.

  • optimistic update를 구현하여 매번 API를 보내지만, API의 결과와 상관없이 우선 예상되는 결과를 반영하고 연속적인 요청이 끝났을 때에 서버에 마지막으로 확인하여 UI를 업데이트합니다.
  • 혹은 초기값을 서버에서 받아와서 클라이언트의 state에 저장해두고, 그 이후의 동작들은 클라이언트에서만 변경을 시키다가 특정 시점에 서버에 싱크를 시킵니다.

 초기에는 클라이언트에서 state를 관리하는 방향으로 했었지만 react-query의 특성상 cache가 있을 수 있고 초기 클라이언트 state를 init할 때에 캐시에 들어있던 data를 기준으로 state를 세팅해버리기 떄문에 그걸 업데이트하는 코드 등을 추가해줘야하는 등 처리해야 하는 방식이 복잡해지기도 했고 data의 source를 react-query로 통일하고 싶었습니다. 그래서 react-query에서 제공하는 API중에서 onMutate, onSettled, cancelQueries, invalidateQueries등을 활용하여 optimistic update를 구현하는 것으로 방향으로 잡았습니다. 그리고 data가 꽤 nested되어 있는 Object였기에 해당 Object를 immutable하게 업데이트를 코드를 길게 쓰지 않고 작업하기 위해서 immer 라이브러리르 사용해서 immutable하게 업데이트했습니다. (react-query optimistic update / immer)

react-virtuoso를 사용해서 빠른 무한스크롤링 리스트를 구현하기

 상품목록 페이지에서는 무한스크롤링 리스트를 구현해야하는 이슈가 있었습니다. 무한스크롤링 리스트를 구현할 때에 virtualized list가 아닌 그냥 돔을 계속 추가하는 방향으로 그리게 되면 dom이 굉장히 무거워지고 업데이트가 느려지기 때문에 흔히 virutlized list를 구현합니다. 그래서 이전에도 사용해본 경험이 있던 react-virtualized라는 라이브러리를 사용해서 처음에 구현하려고 했는데, 이 라이브러리의 경우 window scroll에 맞게 구현하는 API가 따로 없었고 다른 라이브러리를 같이 혼용해서 사용해야했습니다. 그런데 동료로부터 더 API가 간단하고 좋다고 추천받은 react-virtuoso를 알게 되었고 해당 라이브러리를 사용해서 조금 더 수월하게 구현했습니다.

 처음에는 API가 꽤나 편하고 쉬워서 만족스럽게 사용하고 있습니다. 그런데 갑자기 react-virtuoso가 SSR에서는 render가 안되는 것을 발견했습니다. 상품 목록이야말로 SEO가 중요한 페이지일 수 있는데 react-virtuoso가 아무리 좋더라도 SSR을 시작한 이유와 더 직결된 이 이점을 포기할 수가 없어서 이리저리 찾아봤는데 처음에는 못찾다가, 나중에야 initialCount와 FixedItemHeight라는 props를 사용해서 SSR에서도 필요한 만큼 잘 render되도록 만들 수 있었습니다. (react-virtuoso)

useQuery의 refetchOnMount, refethcOnWindwoFocus 활용하기

 useQuery를 사용하면서 react-query staleTime의 기본값인 0을 사용하기 보다 staleTime을 조금 높은 값을 주고 정말로 변화가 필요한 값에 대해서만 invalidate해주는 방식으로 접근했었습니다. 그런데 예를 들어서 다른 페이지의 특정 액션을 통해서 변경이 될 수 있는 query들의 경우는 recthOnMount, refetchOnWindowFocus를 킴으로써 최산값을 최대한 유지하도록 하는 방향으로 접근했습니다. 여러 페이지에서 계속해서 어떤 query들을 invalidate해야하는 지를 항상 잘 기억하기가 어렵기 때문입니다. 장바구니의 전체 상품 목록들에 대한 API, 장바구니 상품 목록의 가격을 계산하는 API등은 다른 페이지에서 장바구니에 상품을 추가할 때마다 새로 invalidate를 해줘야만 했는데 각각 invalidate하기보다는 refetch하도록 하는 것이 작업자의 멘탈모델에서도 훨씬 더 까먹지 않고 실수없이 작성할 수 있을 것이라고 생각했습니다. 그리고 invalidate를 해주는 경우는 한 페이지에서 특정 액션을 통해서 변화가 있을 수 있는 query들은 invalidate하는 방식으로 접근했습니다.

window.history가 있을 때에만 BackButton 노출하기

 모바일 웹뷰 상황에서 push, replace등으로 페이지를 옮기는 것이 아니라 완전히 history의 새로운 맥락을 만드는 경우에 Layout에서 엑스버튼 뿐만 아니라 그 안에서도 history가 생기는 경우에만 BackButton을 보여주고 싶었습니다. 그런데 기본적으로 browser에서 history에서 back을 할 수 있는 상황인지에 대해서 정확하게 알 수 있는 방법은 없습니다. 그렇지만 react-router나 next/router와 같은 라이브러리에서 직접 routing을 관리하면서 window.history.state.idx라는 값에 현재 어떤 순서에 와있는지를 기록합니다. 공식적으로 제공하는 방법은 아니지만 router가 변할 때마다 해당 값의 변화에 따라서 BackButton을 보여주고 말고 등을 결정하는 식으로 위 문제를 해결했습니다.

const ModalAppBar = ({ title }) => {
  const router = useRouter()
  const [showBackButton, setShowBackButton] = React.useState(false)

  React.useEffect(() => {
    const updateShowBackButton = () => {
      setShowBackButton((window.history.state?.idx || 0) > 0)
    }

    router.events.on('routeChangeComplete', updateShowBackButton)
    return () => router.events.off('routeChangeComplete', updateShowBackButton)
  }, [router.events])

  return (
    <nav>
      <div>
        {showBackButton && (
          <button type="button" className={styles.left} onClick={() => router.back()}>
            <MoveBack />
          </button>
        )}
      </div>
      <div>{title}</div>
      <div>
        <button>
          <CloseIcon />
        </button>
      </div>
    </nav>
  )
}

nextjs와 관련이 없을 수도 있는 트러블슈팅들

encodeURI, encodeURIComponent의 차이

 querystring에 url을 기록하고 넘기고 싶은 경우에 우리는 어떤 encode 내장 함수를 써야할까. 정답은 endcodeURIComponent이다. encodeURIComponent, encodeURI 두가지 API가 있다는 것은 알고 있는데 둘의 정확한 차이는 잘 몰랐는데 이번에 알았다. encodeURI는 http://와 같이 프로토콜에 관련된 내용은 encode안하는데 encodeURIComponent는 해당 부분까지 encode해주는 API다. 그러므로 encodeURIComponent를 사용해야한다. 그리고 encode관련해서 꽤 복잡했던 이슈가 있었는데, encode된 스트링을 또 encode할 수있는데 이런 경우 모바일웹뷰가 제대로 해당 string을 decode할 수 없고 정확한 querystring을 못넘기는 경우가 있다. 관련해서는 주의를 해야한다.

window.scroll smooth safari에서는 작동이 안된다

  window.scroll(0, { behavior: 'smooth' })와 같은 코드는 크롬에서는 진짜 유저가 스크롤하는 것처럼 작동하지만 safari에서는 작동하지 않는다. 그래서 이 경우에는 polyfill이 필요하다. 저는 smoothscroll-polyilll을 사용해서 polyfill을 추가했습니다. (smoothscroll-polyfill)

모바일 웹뷰에서 사용할 수 있는 유용한 패턴들

 현재 진행하고 있는 프로젝트에서는 /app/post-form, /app/modal-close와 같은 utils성의 페이지들이 있다. 다른 서비스를 이용할 때에 특정 로직을 심을 수는 없고 url만 지정할 수 있을 때 특정 로직을 실행시키기 위해서 위와 같은 페이지들을 만들어서 사용하고 있다. /app/post-form은 새로운 웹뷰를 열면서 form post action으로 다른 페이지로 이동하고 싶을 때 사용하고 있고, /app/modal-close의 경우 다른 서비스에서 다녀오면서 웹뷰 창을 닫고 싶을 때 해당 로직을 담아둔 페이지를 따로 파서 사용하고 있다.

모바일 웹뷰에서 postMessage없이 postMessage하기

 모바일에서 웹뷰로 작업할 때에는 로드된 웹페이지 위에 또다른 웹이 뜰 수 있고 위에 떠있던 웹에서 아래 웹뷰 특정 데이터를 넘기고 싶을 떄가 있을 수 있다. 이럴 때 보통은 postMessage를 할 수 있는 모바일앱인터페이스를 만들어서 사용하지만 그와 같은 인터페이스가 없다면, localStorage에 필요한 정보를 전달하고 위에 떠있던 웹뷰가 닫히고 아래에 있던 웹뷰가 focus될 때에 localStorage에 정보를 확인하고 특정 로직을 실행시킬지 결정할 수 있다.

 window storage event를 사용할 수도 있지만, window storage event는 실제로 위 쪽에 떠있는 웹뷰가 닫히기도 전에 실행될 수 있을 것 같았다. 실제로 원하는 것은 위쪽 웹뷰가 닫히고 포커스되는 시점에 토스트를 띄워주는 로직을 실행해야되었기 때문에 localStorage + focus event로 해결했다.

Horizontal 스크롤을 발생시키는 컴포넌트에서 디바이스의 크기에 따라서 완전히 보이는 컴포넌트와 걸치는 컴포넌트를 유지하기

  Horizontal 스크롤을 overflow를 auto로 설정하여 작동하도록 만들어 두었어도, 디바이스 크기에 따라 가로 스크롤이 가능하는 것을 인지하지 못하게 정확하게 컴포넌트가 크기가 딱 맞게 render가 되어서 유저가 가로 스크롤링이 가능한지 인지가 안될 수 있고 시도하지 않을 수 있다. 그럴 때에 item들의 width를 vw unit을 사용해서 디바이스 width가 달라지더라도 꼭 가로 스크롤링이 가능하다는 것을 인지할 수 있게 컴포넌트가 겹치게 rendering할 수 있었다.

마무리

 처음에 글을 쓰기 시작할 때에 어느정도 목차를 작성하고 쓰기 시작했지만, 결국 나온 글은 사실과 의견들의 나열 정도를 벗어나지 못했다는 생각이 드네요. 앞으로 쓰게 될 글들은 더 집중된 주제로 일목요연하게 쓰고 싶다는 생각을 했습니다. 또 예시 코드들도 최대한 많이 포함해야겠다는 생각도 드네요. 부족함을 그대로 노출하는 것 같아 부끄럽지만, 조금이라도 저의 시행착오들에서 도움을 얻는 분들이 있길 바랍니다.