Web
Frontend
Next.js
Web - June 4, 2025
next.js 15.4.4 기준, 이 버그는 해결되었습니다.
(2025.07.29. 수정)
next.js 15 에서 react 19 를 완전히 지원한다는 것을 알았고 다른 프로젝트에서 15 버전으로 개발했을 때의 큰 불편함이 없었어서 이 블로그도 슬금슬금 버전 업 각을 보고 있었다. 동시에 tailwindcss 도 v3 에서 v4 로 업그레이드 할까 생각했지만, 완전히 달라진 config 방법으로 인해 한 번에 migration하는 것은 조금 복잡해질 것 같아서, 일단은 next 밑 다른 패키지들만 업그레이드 하고자 하였다.
프로젝트는 yarn 으로 패키지를 관리하고 있었어서, 다음 명령어를 통해 인터랙티브 하게 원하는 패키지만 업그레이드를 진행할 수 있었다.
yarn upgrade-interactive --latest
간단히 next config 파일의 확장자를 ts 로 바꾸고, eslint 설정 파일을 재구성하는 등의 간단한 migration 과정을 거쳤다. 나아가 params 등을 await 해서 받아오게 바꾸었다.
그렇게 업그레이드를 하고 블로그를 살펴보던 중, open graph 태그가 제대로 적용되고 있지 않다는 것을 알게 되었다. 실제 배포된 블로그에서 개발자 도구로 요소 검사 했을때는 제대로 <head>
태그 아래에 open graph 메타 태그가 있었지만, 그것을 제대로 외부 검사 툴들이 크롤링하지 못하는 것으로 보였다.
Open graph 뿐만 아니라, title, description 과 같은 메타데이터도 제대로 적용되지 않고 있었다. 이는 SEO에 큰 악영향을 끼칠 수 있기 때문에 블로그로서는 상당히 큰 문제였다.
참고로, 메타데이터는 공식 문서를 따라 다음과 같이 app 의 최상위 layout.tsx
에 정의하였다.
export const metadata: Metadata = {
metadataBase: new URL(
isProduction ? process.env.NEXT_PUBLIC_URI! : 'http://localhost:3000',
),
title: 'monognuisy blog',
description: 'Technical blog about web development, programming, and more.',
openGraph: {
title: 'monognuisy blog',
description: 'Technical blog about web development, programming, and more.',
url: `${process.env.NEXT_PUBLIC_URI}`,
siteName: 'monognuisy blog',
locale: 'ko_KR',
type: 'website',
images: [
{
url: `/images/cover/blog-cover.webp`,
width: 1200,
height: 630,
alt: 'monognuisy blog cover image',
},
],
},
twitter: {
card: 'summary_large_image',
title: 'monognuisy blog',
description: 'Technical blog about web development, programming, and more.',
images: [`/images/cover/blog-cover.webp`],
},
verification: {
google: `${process.env.NEXT_PUBLIC_GOOGLE_VERIFICATION}`,
},
icons: {
icon: `/icons/favicon-32x32.png`,
shortcut: `/icons/favicon.ico`,
apple: `/icons/apple-touch-icon.png`,
},
};
이는 버전 업 과정에서 크게 건드리지 않았기 때문에 문제의 원인이 될 확률은 희박했다.
외부에서 open graph 등을 받아오는 방법은 어쨌든 내 블로그 주소로 HTTP GET 요청을 보내서 받아온 초기 html 파일을 분석하는 것이라고 생각해서, 개발자 도구의 네트워크 탭에서 최초로 오는 html 요청을 확인하기로 하였다.
next 14 때의 head 태그 내부. 제대로 metadata 가 적용되어있다.
next 15 로 버전 업 후의 head 태그 내부. 있어야 할 metadata 들이 보이지 않는다.
빠진 것은 메타데이터 뿐만이 아니었다. 원래는 pre-rendered 되어야 했을 body 태그 내의 여러 서버 컴포넌트(SSG)들이 전혀 렌더링되지 않고, 그것들을 대신하여 많은 <script>
태그만 있었다.
즉, 마치 CSR 과 같이 작동되고 있음을 알게 되었다.
문제점들을 요약하면 다음과 같다:
처음 이 문제를 발견했을 때는 원인을 찾기 위해 여러 수정한 파일들을 다시 검토했다. next.config.ts
, tsconfig.json
같은 파일들 말이다. 물론, 단순히 확장자만 바꾸거나 컴파일 타겟만 추가 했을 뿐이기에 아무 소득이 없었다.
결국, 버전 업 보다는 일단 블로그의 SEO 를 크게 방해하는 버그를 해결하는 것이 우선이었기 때문에 next.js 14, react 18 로 롤백하였다.
거짓말처럼 문제는 해결되었지만, 계속 원인을 찾기 위해 여러 이슈, 공식 문서들을 찾아보았다. 15.3 이상에서는 Suspense 와 같이 사용하였을 때, 클라이언트 컴포넌트에서 메타데이터가 추가되지 않는 버그가 발생할 수 있다고 알게 되었지만, Suspense 도, 메타데이터를 클라이언트 컴포넌트에서 관리하지도 않았기 때문에 큰 도움은 되지 않았다. 그래서 일단 손수 디버깅을 해보기로 하였다.
문제를 찾기 위해서 15 버전에서 디버깅을 진행하였다.
일단 body 내의 컴포넌트들이 모두 CSR 되는 문제와도 연관이 있어보였기 때문에, body 내의 클라이언트 컴포넌트들을 살펴보았다.
// layout.tsx
// ...
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="ko">
<head></head>
<body className="antialiased">
<QueryProvider>
<CustomThemeProvider attribute="class">
<ThemeColorSetter />
{/* ... */}
{ children }
</CustomThemeProvider>
</QueryProvider>
</body>
</html>
);
}
// CustomThemeProvider.tsx
'use client';
import { ThemeProvider } from 'next-themes';
import { useEffect, useState } from 'react';
const CustomThemeProvider = ({ children }: { children: React.ReactNode }) => {
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
return (
<>
{isMounted && <ThemeProvider attribute="class">{children}</ThemeProvider>}
</>
);
};
export default CustomThemeProvider;
그리고, CustomThemeProvider
를 주석처리 하니 더 이상 위의 문제가 발생하지 않았다!
초기 해당 컴포넌트가 클라이언트에서 마운트 되기 이전에는 null
을 반환하고, 마운트 이후에 ThemeProvider
로 감싼 children 이 렌더링된다.
// 서버 측
null
// 클라이언트 측 (마운트 후)
<ThemeProvider attribute="class">{children}</ThemeProvider>
당연히, 서버 측에서는 null
을 보내기 때문에 body 내에 아무것도 없었던 것이다. (이렇게 허무할 수가!)
따라서, 다음과 같이 마운트가 되지 않았을 때는 그냥 children 을 리턴하게 하면 문제는 해결된다. 😓 (코드리뷰의 필요성 및 중요성…)
if (!isMounted) {
return children;
}
return (
<ThemeProvider attribute="class">{children}</ThemeProvider>
);
혹은, ThemeProvider 이 발생시킬 수 있는 hydration warning 을 막기(무시하기) 위해 suppressHydrationWarning
을 최상위 html 태그에 넣어주고, 그대로 사용해도 된다.
<html lang="ko" suppressHydrationWarning>
<head></head>
<body className="antialiased">
<QueryProvider>
<ThemeProvider attribute="class">
<ThemeColorSetter />
{/* ... */}
{ children }
</ThemeProvider>
</QueryProvider>
</body>
</html>
이렇게 코드를 수정하면 발생하는 문제를 해결할 수 있다.
해당 코드는 업그레이드 과정에서 수정하지 않았다. 그렇다는 것은, next 14 에서도 동일하게 잘못된 코드를 이용했다는 것인데, 이 때는 정상적으로 메타데이터가 head 내에 들어가 있었다.
사실, 생각해봐도 body 내부가 null 이라고 해서 head 에 들어가야 할 메타데이터가 들어가지 않는다는 것은 조금 이상했다. 실제 브라우저의 요소 검사 툴을 사용해서 보면 들어가있지만, 대부분의 봇이나 open graph scrapper 들1↗에게 보여지는 첫 html 요청에는 포함되지 않았다.
처음에 한 추측이다.
react 19 가 되면서 meta 태그를 어디든 배치하더라도 자동으로 head 내에 넣어주는 기능이 도입되었다. 기존에는 react-helmet↗ 과 같은 라이브러리를 사용하거나 useEffect
등으로 넣어주어야 했었지만, 이제는 자동으로 head 로 호이스팅해준다.
next.js 도 이러한 react 의 API 를 사용한다면, 단순히 body 에 <meta>
태그를 넣고, head 로 호이스팅하는 방법을 사용할 수도 있다. 그러면,
<meta>
태그가 렌더링되지 않음 → 메타데이터가 head 에 들어있지 않다가 (첫 HTTP 요청)<meta>
태그가 렌더링 및 호이스팅 됨 → 메타데이터가 head 에 들어감 (브라우저에서)하지만, next 15 + react 18 조합으로 해도 동일한 문제가 발생했기 때문에, react 의 문제는 아니라는 것을 확인했다.
이 의문을 해결하기 위해서 next.js 레포 discussion↗ 에 물어보았다. 비슷한 의문 및 현상을 경험한 사람들이 많았고, next.js 15 의 streaming metadata↗ 때문이라는 것을 알았다.
요약하자면 다음과 같다.
이렇게 어떤 봇에게 streaming 을 해줄 것인지는 next 에서 미리 지정해 놓았고↗, 이를 변경하기 위해서는 config 의 htmlLimitedBots
를 재정의 해주면 된다.
htmlLimitedBots
로 재정의 하면 기존의 정의된 봇들이 덮어씌워진다.
이는 사용자들에게 더욱 빠른 렌더링된 페이지를 제공하기 위함으로, 원래는 generateMetadata
등의 함수를 이용하면 메타데이터가 다 만들어질 때 까지 렌더링을 막았다(blocked). 자바스크립트를 해석할 수 있는 클라이언트에 대해서는 일단 페이지를 렌더링 하고, 백그라운드에서 메타데이터를 스트리밍해서 알아서 파싱하도록 하는 것이 더 좋으므로 이러한 방식을 채택한 것으로 보인다.
그래서 정상적인 테스트 프로젝트를 하나 만들어서, Slurp 에이전트2↗를 이용해서 요청해보니, 메타데이터가 head 내에 잘 들어가 있는 것을 확인할 수 있다.
curl -A "Slurp" http://localhost:3000 | npx prettier --parser html
<head>
--
<title>Create Next App</title>
<meta name="description" content="Generated by create next app" />
--
</head>
자바스크립트를 읽을 수 있는 봇으로 동일한 요청을 보내면, body 내에 메타데이터가 들어가있는 것을 확인할 수 있고 (이미 스트리밍 된 메타데이터를 body 에 넣은 것으로 보임), 이러한 봇들은 이를 해석해서 메타데이터를 인식하는 것으로 보인다.
curl -A "Googlebot" http://localhost:3000 | npx prettier --parser html
<head>
--
</head>
<body>
--
<title>Create Next App</title>
<meta name="description" content="Generated by create next app" />
--
<body>
하지만 결국 body 내에 children 이 없으면, body 내에 <meta>
대신 <script>
태그들만 있는 것으로 보아, 제대로 streaming metadata 를 처리하지 못하는 것으로 보인다.
물론, 이런 상황이 거의 발생하지 않는 errorful 한 상황일수도 있으나 의도된 것인지 확인이 필요할 것 같다.
따라서 어느 상황에 이렇게 메타데이터가 렌더링 되지 않는지 파악하기 위하여, agent, { children }
의 유무, dev 인지 prod 인지를 변화시켜 실험해보았다.
사용한 agent 는 streaming bot(googlebot
), html-limited bot(facebookexternalhit
) 이다.
children? | Streaming bot (dev) | Streaming bot (prod) | Html bot (dev) | Html bot (prod) |
---|---|---|---|---|
O | head or body | head | head | head |
X | X | X | head | X |
역시 streaming bot 들에 대해서는 children 이 없을 때 모두 문제가 발생한다. 특이한 점은, html-limited bot 또한 배포환경에서는 문제가 생긴다는 것이다. 이는, facebookexternalhit
로 검사한 내 블로그에 open graph 태그들이 보이지 않는 이유였다!
이렇게 실험을 한 내용들도 포함해 github issue↗ 에 올렸고, 다행이라고 해야할지, canary 빌드에서는 잘 작동한다는 답변을 받았다. 확인해보니 최신 canary 빌드 뿐만 아니라, 문제를 발견한 당시로부터 약 두 달 전인 15.3.1-canary.12
버전부터 정상 작동한듯 했다.
따라서, 변경된 파일들을 보고 실제 원인을 파악해보고자 하였다. 다행히, 중요한 부분은 단 몇 줄밖에 수정되지 않았어서 코드 리딩이 크게 어렵지는 않았다.
ee579bf1..c61b479d
를 비교했다. 먼저, 기존(c61b479d
) 코드에서 바라본 원인을 서술하고, 이후에 이것을 해결한 커밋(ee579bf1
)의 코드를 보도록 하자.
일단, 가장 크게 바뀐 코드는 app-render.tsx
(packages/next/src/server/app-render/app-render.tsx) 이다. 따라서, 이 파일을 집중적으로 살펴보면 원인을 찾을 수 있을 것 같다.
createDivergedMetadataComponents
함수기존에는 agent 가 streaming bot 인지를 판별하여 그에 맞는 메타데이터 컴포넌트를 반환하는 createDivergedMetadataComponents
함수가 있었다.
// packages/next/src/server/app-render/app-render.tsx
// 함수 정의
function createDivergedMetadataComponents(
Metadata: React.ComponentType,
serveStreamingMetadata: boolean
): {
StaticMetadata: React.ComponentType<{}>
StreamingMetadata: React.ComponentType<{}> | null
} {
function EmptyMetadata() {
return null
}
const StreamingMetadata: React.ComponentType | null = serveStreamingMetadata
? Metadata
: null
const StaticMetadata: React.ComponentType<{}> = serveStreamingMetadata
? EmptyMetadata
: Metadata
return {
StaticMetadata,
StreamingMetadata,
}
}
// 실제 호출 예시
const { StreamingMetadata, StaticMetadata } =
createDivergedMetadataComponents(() => {
return (
// Adding requestId as react key to make metadata remount for each render
<MetadataTree key={requestId} />
)
}, serveStreamingMetadata)
// 해당 컴포넌트 사용 예시
// 대부분은 이렇게 StreamingMetadata 와 StaticMetadata 를 같이 사용한다
<React.Fragment key={flightDataPathHeadKey}>
{/* noindex needs to be blocking */}
<NonIndex
pagePath={ctx.pagePath}
statusCode={ctx.res.statusCode}
isPossibleServerAction={ctx.isPossibleServerAction}
/>
{/* Adding requestId as react key to make metadata remount for each render */}
<ViewportTree key={requestId} />
{StreamingMetadata ? <StreamingMetadata /> : null}
<StaticMetadata />
</React.Fragment>
스트리밍이 가능하면 받은 컴포넌트를 StreamingMetadata
에, 그렇지 않으면 StaticMetadata
에 넣는 로직이었다. 따라서, 대부분은 이 둘에 모두 대응할 수 있도록 두 컴포넌트를 같이 사용하는 것을 볼 수 있다.
하지만, 초기 head 를 만드는 부분에서는 StreamingMetadata
가 누락되어있다.
getRSCPayload
함수layout 의 head 를 렌더링 하는 코드의 일부분은 다음과 같다.
// packages/next/src/server/app-render/app-render.tsx
async function getRSCPayload(...) {
// ...
const { StreamingMetadata, StaticMetadata } =
createDivergedMetadataComponents(() => {
return (
// Not add requestId as react key to ensure segment prefetch could result consistently if nothing changed
<MetadataTree />
)
}, serveStreamingMetadata)
// 하위 react 컴포넌트 트리를 생성 (page 단 메타데이터 처리)
const seedData = await createComponentTree({
ctx,
loaderTree: tree,
parentParams: {},
injectedCSS,
injectedJS,
injectedFontPreloadTags,
rootLayoutIncluded: false,
getViewportReady,
getMetadataReady,
missingSlots,
preloadCallbacks,
authInterrupts: ctx.renderOpts.experimental.authInterrupts,
StreamingMetadata,
StreamingMetadataOutlet,
})
// ...
// 오직 StaticMetadata 만 있음을 확인할 수 있다.
const initialHead = (
<React.Fragment key={flightDataPathHeadKey}>
<NonIndex
pagePath={ctx.pagePath}
statusCode={ctx.res.statusCode}
isPossibleServerAction={ctx.isPossibleServerAction}
/>
<ViewportTree key={ctx.requestId} />
<StaticMetadata />
</React.Fragment>
)
// ...
return {
// See the comment above the `Preloads` component (below) for why this is part of the payload
P: <Preloads preloadCallbacks={preloadCallbacks} />,
b: ctx.sharedContext.buildId,
p: ctx.assetPrefix,
c: prepareInitialCanonicalUrl(url),
i: !!couldBeIntercepted,
f: [
[
initialTree,
seedData,
initialHead,
isPossiblyPartialHead,
] as FlightDataPath,
],
m: missingSlots,
G: [GlobalError, globalErrorStyles],
s: typeof ctx.renderOpts.postponed === 'string',
S: workStore.isStaticGeneration,
}
}
여기서의 initialHead
에는 StaticMetadata
밖에 존재하지 않았다. 하지만, 어떻게 그럼에도 불구하고 정상적인 상황(children 이 있는 경우)에는 메타데이터가 잘 렌더링될 수 있을까?
이를 파악하기 위해서는 저 seedData
를 만드는 createComponentTree
함수를 들여다봐야 한다.
createComponentTree
함수createComponentTree
함수는 단순히 createComponentTreeInternal
을 호출하는 wrapper 함수이다. 따라서, 내부 함수의 일부분을 보도록 하자.
// packages/next/src/server/app-render/create-component-tree.tsx
async function createComponentTreeInternal(
// ...
ctx, // app 렌더링 컨텍스트 (streaming 가능 여부 포함)
StreamingMetadata, // createDivergedMetadataComponents 함수로 생성된 컴포넌트
// ...
) {
// ...
const isPage = typeof page !== 'undefined'
const metadata = StreamingMetadata ? <StreamingMetadata /> : undefined
if (isPage) {
return [
actualSegment,
<React.Fragment key={cacheNodeKey}>
{pageElement}
{/*
* The order here matters since a parent might call findDOMNode().
* findDOMNode() will return the first child if multiple children are rendered.
* But React will hoist metadata into <head> which breaks scroll handling.
*/}
{metadata} {/* page 단에서 메타데이터를 넣어준다 */}
{layerAssets}
<OutletBoundary>
<MetadataOutlet ready={getViewportReady} />
{/* Blocking metadata outlet */}
<MetadataOutlet ready={getMetadataReady} />
{/* Streaming metadata outlet */}
{metadataOutlet}
</OutletBoundary>
</React.Fragment>,
parallelRouteCacheNodeSeedData,
loadingData,
isPossiblyPartialResponse,
]
} else {
// layout 인 경우 특별히 metadata 처리를 하지 않는다 (상위 함수에서 담당)
return [
actualSegment,
segmentNode,
parallelRouteCacheNodeSeedData,
loadingData,
isPossiblyPartialResponse,
]
}
}
여기서는 page 인 경우, 인자로 넘겨준 StreamingMetadata
를 렌더링하는 것을 확인할 수 있다. 따라서, children 이 있으면 어차피 page 가 렌더링 될 테니까, 상위 함수 (getRSCPayload
) 의 head 에서 메타데이터를 렌더링하지 않더라도 page 내부에서 메타데이터를 처리해줌으로써 정상적으로 작동되는 것임을 알 수 있다.
동시에, 이는 children 이 없으면 메타데이터가 렌더링 되지 않는 이유이기도 하다!
정리해보면:
봇 종류 | children? | 렌더링 과정 | 메타데이터 여부 |
---|---|---|---|
Streaming | O | StaticMetadata 가 null -> children 있으니 page 렌더링 가능 -> page 에서 StreamingMetadata 처리 | O |
Streaming | X | StaticMetadata 가 null -> children 없으니 page 렌더링 불가능 -> page 에서 StreamingMetadata 처리 안됨 | X |
HTML | O | StaticMetadata 사용 | O |
HTML | X | StaticMetadata 사용 | O |
하지만, 이상한 점은 배포 (build 후 start) 환경에서는 html bot 들도 제대로 메타데이터를 렌더링하지 못했다. html bot 이라면 serveStreamingMetadata
가 false
가 되면서 StaticMetadata
가 제대로 렌더링되어야 하는데, 의외의 결과였다.
확인한 결과 빌드하면 serveStreamingMetadata
가 true
가 된다. 이는 PR #77434↗ 에 따르면 동적인 메타데이터가 빌드 실패되지 않게 하면서도 DIO 규칙을 적용할 수 있는 방법이기 때문이라고 한다.
// packages/next/src/export/worker.ts
async function exportPageImpl(...) {
const renderOpts: WorkerRenderOpts = {
...components,
...input.renderOpts,
ampPath: renderAmpPath,
params,
optimizeCss,
disableOptimizedLoading,
locale,
supportsDynamicResponse: false,
// serveStreamingMetadata 를 true 로 설정
// During the export phase in next build, we always enable the streaming metadata since if there's
// any dynamic access in metadata we can determine it in the build phase.
// If it's static, then it won't affect anything.
// If it's dynamic, then it can be handled when request hits the route.
serveStreamingMetadata: true,
experimental: {
...input.renderOpts.experimental,
isRoutePPREnabled,
},
}
}
따라서, html bot 또한 마치 streaming bot 처럼 처리되어서 children 이 없는 경우 제대로 StaticMetadata
를 처리하지 못하는 것이다. 반대로 children 이 있는 경우에는 page 단에서의 StreamingMetadata
컴포넌트를 이용해서 메타데이터를 처리하는 것이 된다.
사실, 위 원인 부분을 읽으면서 추측할 수 있었겠지만, initialHead
내에 StaticMetadata
뿐만 아니라 StreamingMetadata
를 넣어주면 문제는 해결된다.
그렇게 되면 결국 static, streaming 으로 나누는 의미가 없으므로 (다 함께 사용되니까) createDivergedMetadataComponents
함수를 사용하지 않아도 된다.
더욱이, 이 함수의 인자로 건네지는 MetadataTree
컴포넌트 내에서 streaming 가능 여부를 판별하는 로직이 이미 있다.
// packages/next/src/lib/metadata/metadata.tsx
// MetadataTree 컴포넌트
function MetadataTree() {
return (
<MetadataBoundary>
<Metadata />
</MetadataBoundary>
)
}
// Metadata 컴포넌트
async function Metadata() {
const promise = resolveFinalMetadata()
// 이미 streaming 가능 여부 코드 존재
if (serveStreamingMetadata) {
return (
<Suspense fallback={null}>
<AsyncMetadata promise={promise} />
</Suspense>
)
}
const metadataState = await promise
return metadataState.metadata
}
따라서 이를 수정한 커밋(ee579bf1
) 에서는 StreamingMetadata
나 StaticMetadata
대신 MetadataTree
를 사용하여 이 문제를 해결했다.
결론적으로는, 나의 사소한 실수 때문에 발견한 문제였고, 코드 하나하나 짤 때마다 생각하고 짜야한다는 것을 다시금 깨닫게 되었다. 그래도 그 작은 실수 덕분에 next.js, 특히 15 버전에서 도입된 streaming metadata 에 대해 잘 알게 되었고, 내부적으로는 어떻게 구현되어있는지 파악해볼 수 있었다.
그래도 아직까지는 streaming metadata 가 과연 그정도의 효용을 내는가 라는 생각을 머릿속에서 지울 수가 없다. vercel 이 아닌 다른 플랫폼에서는 제대로 작동이 안된다는 이야기도 있고… 무엇보다 이번에 코드를 보면서 14 버전의 코드도 살펴보았는데, 지나치게 메타데이터 처리 부분이 복잡해진 느낌이 들었다. (그래서 처음에 로직 파악하기가 힘들었던…) 뭐, 이것저것 개선되면 더 나아지지 않을까. 그렇지 않다면 블로그 같은 가벼운 서비스의 개발은 다른 프레임워크를 고려해봐야겠다.
아무래도 오픈소스 코드는 방대하기 때문에 그 모든 것을 다 읽어보고 판단하는 것이 쉽지 않아 cursor 등의 AI 툴의 도움을 많이 받았다. 모든 경우에 단번에 완벽하게 문제점을 찾아주지는 못했어도, 꽤 큰 힌트들을 줬다.
문제점을 파악하고 나니, 생각보다 간단하게 해결될수도 있었던 문제였다는 점이 신기했다. 이번에는 해결된 문제를 본 것이었지만, 앞으로는 이러한 오픈소스에 기여할 수 있도록 관심을 가지고 코드를 봐야겠다는 다짐을 했다.