노현진's Blog

[트러블슈팅] Next.js + Module not found

Next.js MDX 블로그 프로젝트에서 Module not found 오류를 해결한 과정에 대해 기록한 페이지입니다.

Posted
Preview Image
By HyunJinNo

Tags

Troubleshooting, Typescript, Next.js, FSD

1. ✅ 개요

Next.js MDX 블로그 프로젝트에서 Module not found 오류를 해결한 과정에 대해 기록한 페이지입니다.

2. ❓ 문제

2.1. ⚠️ 오류

Tips

발생한 버그를 간략히 설명해 주세요.

다음 사진과 같이 fs 모듈을 찾을 수 없다는 Module not found 오류가 발생하였습니다.

Module not found: Can't resolve 'fs'

2.2. 🖥️ 발생 환경

Tips

운영체제, 브라우저, 의존성 목록 등을 작성해 주세요.

  • OS: MacOS
  • Browser: Chrome
  • Next.js 16.1.4

2.3. 🕘 발생 일시

Tips

버그가 발생한 날짜와 시간을 입력해 주세요. (Ex. 2024년 10월 1일, 오후 3시 30분)

  • 2026년 2월 20일, 오후 4시 0분

3. 📖 해결 과정

먼저 문제가 발생하는 상황에 대해 분석하였습니다. 문제 상황을 분석한 결과 fs 모듈은 서버에서만 사용할 수 있지만, fs 모듈을 사용하는 서버 측 코드가 클라이언트 번들에 포함되어 발생한 오류였습니다.

좀 더 구체적으로 설명하자면, 먼저 다음과 같이 서버 컴포넌트인 layout.tsx 파일에서 클라이언트 컴포넌트인 SearchResultViewer를 사용하고 있었습니다.

typescript
1/* @/app/layout.tsx */
2
3import type { Metadata } from "next";
4import "./globals.css";
5import { Sidebar } from "@/widgets/sidebar";
6import localFont from "next/font/local";
7import { Header } from "@/widgets/header";
8import { RecentlyUpdatedPostList } from "@/widgets/recentlyUpdatedPostList";
9import { TrendingTagList } from "@/widgets/trendingTagList";
10import { ScrollToTopButton } from "@/features/scrollToTop";
11import { Footer } from "@/widgets/footer";
12import { SearchResultViewer } from "@/widgets/searchResultViewer";
13import { getAllPostList } from "@/entities/post";
14
15const pretendardFont = localFont({
16  src: "./PretendardVariable.woff2",
17  weight: "100 900",
18});
19
20export const metadata: Metadata = {
21  title: {
22    default: "노현진's Blog",
23    template: "%s | 노현진's Blog",
24  },
25  description: "Generated by create next app",
26};
27
28export default async function RootLayout({
29  children,
30}: Readonly<{
31  children: React.ReactNode;
32}>) {
33  const postList = await getAllPostList();
34
35  return (
36    <html
37      lang="ko"
38      className={pretendardFont.className}
39      suppressHydrationWarning
40    >
41      <head>
42        <link
43          rel="icon"
44          href="/images/icon/falling-star-logo.webp"
45          sizes="any"
46        />
47        <script
48          dangerouslySetInnerHTML={{
49            __html: `(function () {
50              try {
51                const theme = localStorage.getItem("theme");
52
53                if (theme === "dark") {
54                  document.documentElement.classList.add("dark");
55                }
56              } catch (e) {}
57            })();`,
58          }}
59        />
60      </head>
61      <body className="flex flex-row">
62        <Sidebar />
63        <div className="flex w-full flex-col pr-7 pl-75">
64          <Header />
65          <SearchResultViewer postList={postList} />
66          <div className="mt-12 flex w-full flex-row justify-between gap-8 pl-6">
67            <div className="flex w-full min-w-0 flex-col gap-12">
68              {children}
69              <Footer />
70            </div>
71            <div className="flex w-70 flex-col gap-16">
72              <RecentlyUpdatedPostList />
73              <TrendingTagList />
74            </div>
75          </div>
76          <aside className="fixed right-20 bottom-15">
77            <ScrollToTopButton />
78          </aside>
79        </div>
80      </body>
81    </html>
82  );
83}

또한 클라이언트 컴포넌트인 SearchResultViewer에서 FSD 아키텍처의 entities 레이어의 SearchResult 컴포넌트를 사용하고 있었습니다.

typescript
1/* @/widgets/searchResultViewer/ui/SearchResultViewer.tsx */
2
3"use client";
4
5import { SearchResult } from "@/entities/post";
6import { useSearchInputStore } from "@/features/searchPostListByTitle";
7import { useEffect, useEffectEvent, useState } from "react";
8
9interface SearchResultViewerProps {
10  postList: {
11    title: string;
12    description: string;
13    category: string;
14    tagList: string[];
15    postPath: string;
16  }[];
17}
18
19export const SearchResultViewer = ({ postList }: SearchResultViewerProps) => {
20  const [searchResultList, setSearchResultList] = useState<
21    {
22      title: string;
23      description: string;
24      category: string;
25      tagList: string[];
26      postPath: string;
27    }[]
28  >([]);
29
30  const updateSearchResultList = useEffectEvent((input: string) => {
31    setSearchResultList(
32      postList.filter((post) =>
33        post.title
34          .replaceAll(" ", "")
35          .toLowerCase()
36          .includes(input.replaceAll(" ", "").toLowerCase()),
37      ),
38    );
39  });
40
41  const { input } = useSearchInputStore();
42
43  useEffect(() => {
44    updateSearchResultList(input);
45  }, [input]);
46
47  return (
48    <div className="w-full px-6">
49      {searchResultList.length === 0 ? (
50        <div className="mt-24 flex items-center justify-center">
51          <p className="text-custom-gray">검색 결과가 없습니다.</p>
52        </div>
53      ) : (
54        <div className="grid grid-cols-2 gap-x-12 gap-y-4">
55          {searchResultList.map((post) => (
56            <SearchResult
57              key={post.postPath}
58              title={post.title}
59              description={post.description}
60              category={post.category}
61              tagList={post.tagList}
62              postPath={post.postPath}
63            />
64          ))}
65        </div>
66      )}
67    </div>
68  );
69};
typescript
1/* @/entities/ui/SearchResult.tsx */
2
3import Link from "next/link";
4import { FaRegFolder } from "@react-icons/all-files/fa/FaRegFolder";
5import { FaTag } from "@react-icons/all-files/fa/FaTag";
6
7interface SearchResultProps {
8  title: string;
9  description: string;
10  category: string;
11  tagList: string[];
12  postPath: string;
13}
14
15export const SearchResult = ({
16  title,
17  description,
18  category,
19  tagList,
20  postPath,
21}: SearchResultProps) => {
22  return (
23    <article className="mt-12 flex flex-col gap-1">
24      <header className="flex flex-col gap-2">
25        <h2>
26          <Link
27            className="text-custom-blue text-2xl font-semibold underline-offset-4 hover:text-teal-500 hover:underline"
28            href={`/posts/${postPath}`}
29          >
30            {title}
31          </Link>
32        </h2>
33        <div className="text-custom-gray flex flex-row items-center gap-6 text-sm">
34          <div className="flex flex-row items-center gap-1">
35            <FaRegFolder className="text-xs" />
36            {category}
37          </div>
38          <div className="flex flex-row items-center gap-1">
39            <FaTag className="text-xs" />
40            {tagList.join(", ")}
41          </div>
42        </div>
43      </header>
44      <p className="text-custom-gray">{description}</p>
45    </article>
46  );
47};

위의 코드를 보면 알 수 있듯이, SearchResult 컴포넌트에서는 fs 모듈을 사용하고 있지 않지만, SearchResultViewerSearchResult 두 컴포넌트는 레이어가 서로 다르며 @/entities/post/index.ts 파일을 통해 SearchResult 컴포넌트를 가져오고 있습니다. 이 때, index.ts 파일은 아래와 같이 fs 모듈을 사용하는 getAllPostList.ts 파일을 포함하고 있습니다.

typescript
1/* @entities/post/index.ts */
2
3export { getArchiveList } from "./model/getArchiveList";
4export { getCategoryList } from "./model/getCategoryList";
5export { getAllPostList } from "./model/getAllPostList";
6export { getPostListByCategory } from "./model/getPostListByCategory";
7export { getPostListByTag } from "./model/getPostListByTag";
8export { getRecentlyUpdatedPostList } from "./model/getRecentlyUpdatedPostList";
9export { getTagList } from "./model/getTagList";
10export { getTrendingTagList } from "./model/getTrendingTagList";
11export { type PostMetadata } from "./model/postMetadata";
12
13export { SearchResult } from "./ui/SearchResult";
14export { CategoryCard } from "./ui/CategoryCard";
15export { PostCard } from "./ui/PostCard";
typescript
1/* @/entities/post/model/getAllPostList.ts */
2
3import fs from "fs";
4import path from "path";
5import { PostMetadata } from "./postMetadata";
6
7const postsDirectory = path.join(process.cwd(), "src/content");
8
9export const getAllPostList = async () => {
10  const fileList = fs.readdirSync(postsDirectory);
11
12  const postList = await Promise.all(
13    fileList.reverse().map(async (file) => {
14      const mod = await import(`@/content/${file}`);
15      const metadata: PostMetadata = mod.metadata;
16
17      return {
18        title: metadata.title,
19        description: metadata.description,
20        date: metadata.date,
21        category: metadata.category,
22        imagePath: metadata.imagePath,
23        tagList: metadata.tags,
24        postPath: file.split(".")[0],
25      };
26    }),
27  );
28
29  return postList;
30};

기존 폴더 구조

정리하자면, 클라이언트 컴포넌트인 SearchResultViewer 컴포넌트에서 SearchResult 컴포넌트를 가져오기 위해 index.ts를 import할 때 fs 모듈을 사용하는 서버 전용 파일까지 함께 가져와서 발생한 문제였습니다.

plaintext
1SearchResultViewer (Client)
23entities/post/index.ts
45getAllPostList.ts (fs 사용)

즉, 이 문제를 해결하기 위한 핵심은 서버 측 코드가 클라이언트 번들에 포함되지 않도록 분리하는 것이었습니다. 따라서 SearchResult 컴포넌트를 import할 때 서버 측 코드가 클라이언트 번들에 포함되지 않도록 SearchResult.tsx 파일을 SearchResultViewer.tsx 파일과 같은 폴더 내에 위치시키면 문제를 해결할 수 있습니다.

위와 같이 문제를 해결한 결과는 다음과 같습니다.

변경 이후 폴더 구조

문제 해결 결과

4. 📚 참고 자료

© HyunJinNo. Some rights reserved.