본문 바로가기

[Nextjs] 리액트에서 Nextjs로 이전하던 중 생긴 이슈들

현재 사내 모든 서비스는 단 하나의 저장소에 모두 몰려있다.

 

따라서 내가 맡은 서비스만 배포할 수 없는 불편함+코드가 너무 방대해져서 유지보수의 어려움 등등의 이유로 각 서비스 별로 저장소를 분리하는 과정에 있다.

 

내가 맡은 서비스 또한 저번 주 신규 서비스 런칭이 끝났으므로 저번주부터 저장소 분리에 착수했다.

이전 저장소는 CRA 기반 React js 환경이었다면 새로 분리하는 저장소에서는 Nextjs + Typescript를 도입하였다.

 

Nextjs 기본 Setup을 끝내고 나서부터는 코드를 옮기는 작업을 진행하였는데, 역시 그대로 옮겨질리가 없다. (ㅋ)

 

1. Link

이전 cra 기반 reactjs 환경에서는 react-router-dom 의 Link 컴포넌트를 사용하였다.

Next js에서는 next/link 를 임포트한 Link를 사용한다.

 

next.js의 Link가 받는 props는 다음과 같다.

  • href 이동할 경로
  • as 브라우저 URL바에 표시될 URL, 9.5.3 이전에는 Dynamic Routes에 사용됨
  • passHref href property를 children에 전달함, children이 함수형 컴포넌트 혹은 custom <a> 엘리먼트일 때 사용
  • prefetch (production 모드에서만 활성화) 페이지를 prefetch할지 여부
  • replace push대신 replace할지 여부
  • scroll 페이지 상단으로 스크롤 (default true)
  • shallow 서버단에서 실행되는 메소드들을 재실행하지 않고 이동할지 여부 (getStaticProps, getServerSideProps, getInitialProps)
  • locale 지역에 따라 언어 설정을 달리할지 여부 (false일 시, href에 locale을 포함해야 함)

 

next/link의 Link 구현체를 뜯어본 결과, Link에 설정된 props는 일련의 로직을 거쳐 children의 props로 전달된다.

참고 : next/link

React.cloneElement로 설정한 childProps 전달

따라서 Link에 사용자가 중복으로 페이지이동 함수를 추가하지 않아도 되었는데 동적 라우트로 이동하는 Link에서 이슈를 보았다.

 

 

동작 흐름

 

 

페이지 1에서 페이지 2로 이동했다가 router.back() 을 호출하여 뒤로가기를 했는데 브라우저 URL바에서는 이전 url인 /guide/[id] 였지만 렌더링된건 메인화면이었다. 😳

 

수상해서 _app.tsx 에 router.pathname과 router.asPath를 차례로 찍어보았는데 두 값이 다른게 아닌가..!?

  • pathname 현재 라우트
  • asPath 브라우저에 보이는 path
// in _app.tsx
import {useRouter} from 'next/router'

// ...중략
const router = useRouter()

useEffect(() => {
  console.log(router.pathname, router.asPath) // '/', '/guide/[id]'
}, [router])

/guide/[id].tsx 를 정적 라우터로 바꾸면 문제없이 동작하는것으로 보아 동적 라우팅에서 생긴 문제임을 캐치하여 9.5.3버전에서 명시된것 처럼 as props에서 실제 pathname을 설정하니 잘 동작되었다😭 나는 지금 10버전을 쓰고 있는데 왜그런걸까...

 

// before

// ROUTES.GUIDE = '/guide'
<Link href={`${ROUTES.GUIDE}/1`}> 
  <a className={cx('link')}>
    <IconArrowRightBold />
    <span className={cx('blind')}>안내화면</span>
  </a>
</Link>
// after

// ROUTES.GUIDE = '/guide/[id]'
<Link href={`${ROUTES.GUIDE}`} as={'/guide/1'}> 
  <a className={cx('link')}>
    <IconArrowRightBold />
    <span className={cx('blind')}>안내화면</span>
  </a>
</Link>

 

2. Dynamic Import

맡은 서비스가 PC, 모바일 환경에 따라 디자인이 조금씩 다르기 때문에 UserAgent로 사용자 환경을 인식하는 react-device-detect 라이브러리를 사용하고 있다.

 

가장 최상단에서 'type-pc'라는 데스크톱 PC에서만 적용되는 클래스가 있다.

// in _app.tsx
import {isMobileOnly} from 'react-device-detect'

// ...중략

return (
  <div className={cx('article', {'type-pc': !isMobileOnly})}>
    {children}
  </div>
)

그런데 개발환경에서 빌드해서 보면 모바일 환경에서도 'type-pc' 클래스가 적용되어 있었다.

서버사이드에서 isMobileOnly를 false로 인식하고, 브라우저(모바일 뷰)에서는 true로 인식해버려 생긴 이슈였다.

 

따라서 서버사이드에서는 해당 라이브러리를 임포트하지 않기 위해 nextjs에서 제공하는 Dynamic Import를 사용했다.

 

먼저 children의 인자로 라이브러리의 데이터 객체를 넘긴다.

import {ReactNode} from 'react'
import * as rdd from 'react-device-detect'

interface Props {
  children: (props: typeof rdd) => ReactNode
}
export default function Device({children}: Props) {
  return children(rdd)
}

 

next/dynamic 을 사용하여 동적으로 임포트 후 SSR로 실행하지 않기 위해 {ssr: false} 옵션을 추가한다.

참고 : With no SSR

import dynamic from 'next/dynamic'

const Device = dynamic(() => import('./Device'), { ssr: false })

export default Device

 

이제 사용할 곳에서 Device 컴포넌트를 Wrap하여 사용한다.

// in _app.tsx

return (
  <Device>
    {
      // rdd 구조분해할당
      ({isMobileOnly}) => ( 
        <div className={cx('article', {'type-pc': !isMobileOnly})}>
          {children}
        </div>
      )
    }
  </Device>
)

참고.

 

3. dev와 production(build)의 CSS Import 순서 차이

개발환경에서 이전 결과물과 함께 비교하면서 보다가 한 곳에서 CSS가 다른 문제를 발견했다.

What?!

const Notice = () => {
  // Notice의 클래스를 props로 넘김
  return (
    <DotList className={cx('list')} />
  )
}

const DotList = ({className}) => {
  return (
    <div className={cx('article', className)} />
  )
}

위 코드처럼 Notice에서 DotList로 별도의 class를 주고 있다.

원래 순서대로라면 Notice의 'list' class가 후에 적용되었으므로 덮어써야 하는데 로드된 순서가 반대였다 ㅠㅠ

 

다행이었던건 next dev 시에만 그렇고, next build 는 이전과 동일하게 되었다. dev와 production 간에 CSS를 로드하는 방법이 다른거 같다.

 

dev는 <head>에 static하게 박아버리고, production은 특별할 것 없이 mini-css-extract-plugin만 적용하고 청크파일이 로드된다.

 

비슷한 이슈가 작년에 올라왔는데 뭔가 흐지부지 Closed 되어서 뭐가 뭔지...@_@

 

아무튼 이 이슈는 여러모로 대외적 사정을 고려해서 마크업 수정을 요청드리는 게 깔끔할 것 같아서 DotList 내부에서 분기되는 것으로 요청을 드려보려고 한다.

 

4. Scroll Restoration

_app.tsx 에서 감사하게도 팀원분이 미리 만들어주신 스크롤 복원 로직이 있었다.

const router = useRouter()

useEffect(() => {
  window.history.scrollRestoration = 'auto'
  
  const cacheScrollPositions: Array<[number, number]> = []
  let shouldScrollRestore : null | {x: number; y: number}
  
  router.events.on('routeChangeStart', () => {
    cacheScrollPositions.push([window.scrollX, window.scrollY])
  })
  
  router.events.on('routeChangeComplete', () => {
    if (shouldScrollRestore) {
      const {x, y} = shouldScrollRestore
      window.scrollTo(x, y)
      shouldScrollRestore = null
    }
    window.history.scrollRestoration = 'auto'
  })
  
  router.beforePopState(() => {
    if (cacheScrollPositions.length > 0) {
      const scrollPosition = cacheScrollPositions.pop()
      if (scrollPosition) {
        shouldScrollRestore = {
          x: scrollPosition[0],
          y: scrollPosition[1]
        }
      }
    }
    window.history.scrollRestoration = 'manual'
    return true
  })
}, [])

라우터 이동 시작 시 cacheScrollPosition에 현재 window의 scroll x,y 위치값을 저장하고, pop되기 전에 가장 위에 있는 값을 가져와서 shouldScrollRestore에 설정 후 라우터 이동이 완료되면 해당 값으로 스크롤하는 로직이다.

 

그런데 왠지 내 개발환경에서는 스크롤이 복원되지 않는 이슈가 있었다.

 

내가 router를 잘못 설정했나 싶어서 혼자 끙끙대다가 동료 개발자님께 문의드리니 한방에 해결되었다 (GOD...)

 

Race condition에 따라 발생한 이슈로 렌더링에 영향을 미치는 다른 요소들로 인해 스크롤을 이동하라는 DOM 변경이 Block되었다.

따라서 setTimeout을 적용하여 비동기로 실행하니 해결되었다.

setTimeout(() => window.scrollTo(x, y), 0)

참고. setTimeout(fn, 0)

 


정리

이전과 빌드 환경이 달라져서 코드를 일부 수정하는 것은 불가피해보인다. 그러므로 Nextjs의 SSR 동작 방식을 잘 이해하고 항상 CSR로 동작했던 코드 중 서버사이드에서 실행할만한 로직들, 서버사이드에서 실행되면 안되는 로직들을 추려내어 적절한 위치에 이전해야 겠다.

사내에 마크업을 담당하시는 퍼블리셔님들도 계시기 때문에 css나 컴포넌트 구조를 마음대로 바꿀 수는 없어서 성공적으로 저장소 분리를 위해서는 협업이 중요하겠다.

 

그리고 공부가 하기 싫은 글쓴이였다.