Next.js 버그 픽스로 기여한 후기 🎉

Sming·2024년 5월 18일
54
post-thumbnail

이번에 Next.js의 버그 픽스를 하여 Contributor가 되었는데요.

그 과정을 함께 살펴보는 글을 작성해보았습니다.

문제 원인 - next/script의 이상한 동작

현재 회사에서 next/script를 사용하여 패키지를 제공하고 있는데요.
next/script 와 동일하게 strategy에 값을 넣지 않은 경우 default 동작인 afterInteractive를 넣어주고 값이 있는 경우 그 값(afterInteractive, beforeInteractive, worker)로 동작하도록 하고 있습니다.

그런데 nextjs page router_document.tsxHEAD 태그안에 strategy prop을 빈 값으로 주는 경우 동작하지 않았습니다.

// _document.tsx

export default Document() {
  return (
    <HTML>
      <HEAD>
        <Script src="example.com" /> // not working
      </HEAD>   
    </HTML>
  )
}

그래서 이 원인을 파악하기 위해 여러가지 테스트를 해보았습니다.

테스트 1 - HEAD가 아닌 다른 곳이라면?

먼저 HEAD 태그가 아닌 다른 환경에서도 테스트를 해보았습니다.

BODY

동일한 _document.tsx내의 HEAD가 아닌 BODY태그에 Script를 넣어보니 동일하게 동작하지 않았습니다.

pages/*/index.tsx

하지만 _document.tsx를 벗어난 pages내의 페이지들에서는 정상적으로 동작하는 것을 확인할 수 있었습니다.

테스트 2 - 다른 strategy prop에서도 동일한가?

이어서 다른 strategy prop에서도 이렇게 동작을 하지 않는지 확인을 해보려고 beforeInteractive, worker 값을 넣어 테스트를 해보니 모두 정상적으로 동작을 하였습니다.

그리고 당연히 afterInteractive에서는 동작하지 않겠지라고 생각하고 afterInteractive값을 넣어 테스트를 해보니 동작을 하였습니다.

여기서 이상한 점을 발견했습니다.
next/script 태그의 strategy prop은 기본이 afterInteractive인데 이 값을 넣지 않았을때는 동작하지 않고, afterInteractive라고 명시적으로 작성을 해줘야 동작을 하는 것이였습니다.

테스트 3 - app router의 layout에서는?

현재 page router의 _document.tsx에서 strategy값을 넘기지 않고 _document.tsx에 사용하는 경우 동작을 하지 않는 것까지 확인하였습니다.

그렇다면 page router의 _document.tsx가 아닌 app router의 _document.tsx와 비슷한 layout.tsx에서는 어떤 동작을 할까 싶어 테스트를 해보았습니다.

결과는 정상적으로 동작하였습니다.

테스트 결과

최종적으로 확인한 결과는 app router가 아닌 page router 에서의 _document.tsx 파일 내에 Script가 어디에 위치하든 동작하지 않는다. 였습니다.

브라우저 분석

이제 나오는 원인은 확실히 확인하였으니 왜 이런 동작을 하는지 파악이 필요합니다.

일단 제가 생각한 가설은 다음과 같았습니다.

_document.tsx는 next의 서버에 속하는데, next/script태그를 사용할때 정상적으로 스크립트를 로드해온다?
이것은 서버에서 브라우저로 무언가를 심어줘서 브라우저에서 그것을 실행하는 것이다. 라고 생각하였습니다.

스크립트를 서버에서 불러올 수는 없으니까요.

보통 이렇게 서버에서 브라우저로 무언가를 공유시켜야될때 script 태그내에 이러한 메타데이터들을 넘겨서 브라우저에서 처리하는 경우가 있기에 next가 심는 metadata 태그가 있나 확인을 하였습니다.

확인한 결과 웹에서 __NEXT_DATA__라는 네이밍을 가진 script 태그를 발견했습니다.

<script id="__NEXT_DATA__" type="application/json">
{
  "props":{"pageProps":{"_sentryTraceData":"xxx","xxx":"sentry-environment=xxx,sentry-release=xxx,sentry-public_key=xxx,sentry-trace_id=xxx"},"userAgent":"xxx"},
  "page":"/",
  "query:{},
  "buildId":"OHsykGRpFeEG_sfr_boVp",
  "assetPrefix":"",
  "isFallback":false,
  "appGip":true,
  "scriptLoader":[]
}
</script>

이 부분을 이제 상황에 따라 확인해보았습니다.

strategy가 빈 값인 경우에는 다음과 같이 scriptLoader가 빈 값이지만 strategy가 afterInteractive로 명시한 경우에는 scriptLoader내에 이에 대한 메타데이터가 존재하는 것을 확인했습니다.

Next.js 코드 분석

이제 문제 원인과 scriptLoader의 이상함까지 파악했으니 Next.js의 코드에서 직접 수정을 확인을 해주면 됩니다.

이 방대한 코드에서 어디를 수정해야되는지는 막막하지만 scriptLoader라는 키워드를 알아냈으니 이를 기반으로 검색하여 찾아가면 가능할 것 같아 진행했습니다.

그렇게 packages/next/scr/pages/_document.tsx 라는 파일을 발견했습니다. 확인해보니 _document.tsx에서 일어나는 동작을 처리하는 패키지로 보였습니다.

무려 1250줄이나 되는 어마어마한 코드더라고요. 하지만 저희는 여기서 afterInteractive를 검색해서 추려보도록 합시다.

드디어 이 로직을 처리하는 함수를 발견했습니다.

function handleDocumentScriptLoaderItems(
  scriptLoader: { beforeInteractive?: any[] },
  __NEXT_DATA__: NEXT_DATA,
  props: any
): void {
  if (!props.children) return

  const scriptLoaderItems: ScriptProps[] = []

  const children = Array.isArray(props.children)
    ? props.children
    : [props.children]

  const headChildren = children.find(
    (child: React.ReactElement) => child.type === Head
  )?.props?.children
  const bodyChildren = children.find(
    (child: React.ReactElement) => child.type === 'body'
  )?.props?.children

  // Scripts with beforeInteractive can be placed inside Head or <body> so children of both needs to be traversed
  const combinedChildren = [
    ...(Array.isArray(headChildren) ? headChildren : [headChildren]),
    ...(Array.isArray(bodyChildren) ? bodyChildren : [bodyChildren]),
  ]

  React.Children.forEach(combinedChildren, (child: any) => {
    if (!child) return

    // When using the `next/script` component, register it in script loader.
    if (child.type?.__nextScript) {
      if (child.props.strategy === 'beforeInteractive') {
        scriptLoader.beforeInteractive = (
          scriptLoader.beforeInteractive || []
        ).concat([
          {
            ...child.props,
          },
        ])
        return
      } else if (
        ['lazyOnload', 'afterInteractive', 'worker'].includes(
          child.props.strategy
        )
      ) {
        scriptLoaderItems.push(child.props)
        return
      }
    }
  })

  __NEXT_DATA__.scriptLoader = scriptLoaderItems
}

이 로직을 확인해보니 React.Children를 읽어와서 script태그를 읽어서 그 태그의 strategy를 읽어서 처리하는 로직으로 보입니다.

여기에 beforeInteractive, lazyOnLoad, afterInteractive, worker라는 strategy를 읽어 scriptLoaderItems에 push하고 마지막에 __NEXT_DATA__에 넣는 로직인데요.

여기서 안되는 이유를 확인했습니다.

현재 Script tag에는 기본 동작이 afterInteractive로 걸려있는데 React.Children 같은 경우 그 태그 자체를 가져와서 읽는 거라서 default로 잡는 afterInteractive가 없을 수 밖에 없는 것입니다.

수정하기

이제 문제 해결법도 알았으니 코드를 수정하면 됩니다. 수정은 간단한데 이제 next.js측에 issue를 남겨야합니다.

https://github.com/vercel/next.js/issues/65580

겪었던 이슈를 제보하고 코드를 수정하면 됩니다.

    // When using the `next/script` component, register it in script loader.
    if (child.type?.__nextScript) {
      if (child.props.strategy === 'beforeInteractive') {
        scriptLoader.beforeInteractive = (
          scriptLoader.beforeInteractive || []
        ).concat([
          {
            ...child.props,
          },
        ])
        return
      } else if (
        ['lazyOnload', 'afterInteractive', 'worker'].includes(
          child.props.strategy
        )
      ) {
        scriptLoaderItems.push(child.props)
        return
      } 
      // 이 부분!!
      else if (typeof child.props.strategy === 'undefined') {
        scriptLoaderItems.push({ ...child.props, strategy: 'afterInteractive' })
        return
      }
  })

간단하게 scriptLoaderItems에 메타데이터를 삽입하는 로직에 empty prop일때의 처리를 추가해주면 됩니다.

회귀 테스트 추가

이제 로직을 추가했으니 이에 대한 회귀테스트를 추가해줘야합니다.

제가 추가한 로직이 없는 경우에는 테스트가 실패하고 제가 추가한 로직이 있는 경우에만 테스트가 통과하도록 하는 것이죠.

describe('empty strategy in document Head', () => {
  let next: NextInstance

  beforeAll(async () => {
    next = await createNext({
      files: {
        'pages/_document.js': `
          import { Html, Head, Main, NextScript } from 'next/document'
          import Script from 'next/script'
          export default function Document() {
            return (
              <Html>
                <Head>
                  <Script
                    src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.20/lodash.min.js"
                  ></Script>
                </Head>
                <body>
                  <Main />
                  <NextScript />
                </body>
              </Html>
            )
          }
        `,
        'pages/index.js': `
          export default function Home() {
            return (
              <>
                <p>Home page</p>
              </>
            )
          }
        `,
      },
      dependencies: {
        react: '19.0.0-beta-4508873393-20240430',
        'react-dom': '19.0.0-beta-4508873393-20240430',
      },
    })
  })
  afterAll(() => next.destroy())

  it('Script is injected server-side', async () => {
    let browser: BrowserInterface

    try {
      browser = await webdriver(next.url, '/')

      const script = await browser.eval(
        `document.querySelector('script[data-nscript="afterInteractive"]')`
      )
      expect(script).not.toBeNull()
    } finally {
      if (browser) await browser.close()
    }
  })
})

이렇게 제가 추가한 empty prop에 대한 처리가 없으면 터지고 있는 경우에만 성공하도록 하는 테스트를 추가했습니다.

머지!

next.js팀에서 개인이 작업한 pr들은 아직 opened로 남아 있는 경우가 많고 리뷰가 안달리는 경우가 많더라고요.

다행히 5일만에 ok리뷰를 받고 다행히 머지가 되었습니다!

관련 pr

후기

이번에 첫 오픈소스 기여를 Next.js의 버그 픽스로 하게 되어 매우 좋은 경험이였습니다. 여러분들도 라이브러리에서 문제가 생기면 직접 해결을 해보는 경험도 좋을 것 같습니다.

profile
딩구르르

4개의 댓글

comment-user-thumbnail
2024년 5월 19일

그냥 넘어갈 수도 있었을텐데 직접 문제를 해결하고 기여하시는게 진짜 멋있네요!

답글 달기
comment-user-thumbnail
2024년 5월 20일

잘 보고 갑니다~

답글 달기
comment-user-thumbnail
2024년 5월 23일

꼼꼼한 청년

답글 달기
comment-user-thumbnail
2024년 5월 27일

문제를 찾아가는 과정이 흥미롭고 어떻게 기여하는지에 대해서도 알 수 있었어요! 좋은 글 감사합니다!

답글 달기