Next.js 순환참조 에러 해결하기

Dev
2025-03-20

Column is not defined
Header 컴포넌트에 언어 선택 드롭다운을 붙이려던 그때, 오류가 발생했다!
Column이 정의되지 않은 상태에서 사용되었다는 오류였지만 분명 import를 했는데도 이런 문제가 생겼다.

결론적으로는 모듈의 export 방식에 대한 이해 부족이 문제였다.
지금까지 아무 생각 없이 export default ~~~를 사용해왔는데, 이 방식이 순환 참조를 유발하고 있었다.
그렇다면 Named ExportDefault Export의 차이는 무엇이고, 어떤 상황에서 각각을 사용해야 할까?


Named Export vs Default Export

Named Export는 한 파일에서 여러 모듈을 한꺼번에 export할 수 있다.
이 경우 import할 때는 반드시 중괄호 {} 를 사용해야 하며, export한 이름과 동일한 이름으로 가져와야 한다.

반면, Default Export는 한 파일당 하나의 모듈만 export할 수 있다. 중괄호 없이 import할 수 있으며, 가져오는 쪽에서 원하는 이름으로 사용할 수 있다는 유연함이 있다.

// Named Export 예시
import { Button } from './Button'; // 이름이 정확히 일치해야 함
export function Button() {}
 
// Default Export 예시
import Button from './Button'; // 자유롭게 이름 지정 가능
export default function Button() {}

📌 주의할 점
Default export한 값을 named import 방식({})으로 가져오려 하면 에러가 발생한다.

hoc 폴더 구조와 index.tsx 캡쳐화면

hoc 캡쳐본hoc 폴더 내의 index.tsx 캡쳐본

언제 어떤 export 방식을 쓰면 좋을까?

Default Export는 보편적으로 한 파일에서 하나의 컴포넌트만 export 할 때 사용하기 적합하다. import할 때 이름을 자유롭게 정할 수 있다는 유연함이 장점이지만, 이 점이 오히려 단점이 될 수 있다. 예를 들어 동일한 default export를 각자 다른 이름으로 import하면 협업 시 혼란을 초래할 수 있고, 특정 기능을 검색하거나 추적하기 어려워지는 문제가 생길 수 있다.

또한 default export는 **트리 쉐이킹(Tree Shaking)**에도 불리할 수 있다.

트리 쉐이킹이란 사용되지 않는 코드를 최종 번들에서 제거하여 번들 크기를 줄이는 최적화 기법이다.
번들러(Webpack, Rollup 등)는 어떤 코드가 실제로 사용되었는지를 분석하여 사용되지 않은 코드를 제거하여 결과물에 포함시키지 않는다.

하지만 default export는 이름이 고정되어 있지 않고, import할 때 자유롭게 변경할 수 있기 때문에 번들러 입장에선 어떤 값이 실제로 사용되었는지 파악하기 어려워, 전체 모듈을 포함하는 경우가 생길 수 있다.

반면 Named Export는 여러 유틸 함수, HOC, 커스텀 훅 등 여러 개의 모듈을 하나의 파일에서 묶어 export할 때 유용하다. 특히 index.ts를 활용해 관련 모듈들을 정리해두면 접근성과 유지보수성이 크게 향상된다. 또한 이름이 고정되어 있어 기능 검색이 쉽고, 트리 쉐이킹도 더 효과적으로 작동한다. 이는 불필요한 코드가 최종 번들에서 제거되기 때문에 성능 최적화에도 이점이 있다.

예를 들어 아래와 같이 default export를 여러 파일에서 사용할 경우, import 문이 길어지고 중복된 경로가 반복되어 가독성이 떨어지고 유지보수가 어려워질 수 있다. Default Export 캡쳐본

구분Default ExportNamed Export
정의한 파일당 하나의 값을 export여러 값을 동시에 export 가능
import 방식중괄호 없이 자유롭게 이름 지정 가능중괄호 사용, export한 이름과 동일하게 import해야 함
사용 적합 상황핵심 기능 하나만 제공하는 파일여러 유틸, 컴포넌트를 묶어 export할 때
장점import 이름을 자유롭게 지정 가능하나의 파일에서 여러 값을 한꺼번에 가져올 수 있음
단점import 경로가 많아질 경우 가독성 저하이름이 일치해야 하므로 실수할 가능성 있음

순환 참조는 어떻게 발생했을까?

문제의 원인은 다음과 같은 구조에서 발생했다:

📌 순환 참조 흐름
Dropdown → Column (import { Column } from '@/styles/layouts/Layout')
Layout.tsx → withDefaultProps (import { withDefaultProps } from '@/hoc')
hoc/index.tsx → withLayout (import { withLayout } from '@/hoc/withLayout')
withLayout.tsx → BaseLayout (import { BaseLayout } from '@/components/layouts')
BaseLayout.tsx → Column (import { Column } from '@/styles/layouts/Layout')
➡️ 결국 Column이 BaseLayout을 참조하고, BaseLayout이 다시 Column을 참조하는 순환 참조 발생!

현재 hoc/index.tsx에서 withDefaultProps, withAuth, withLayout을 한꺼번에 export하고 있기 때문에, 어느 한 곳에서 withDefaultProps만 import하더라도 hoc 폴더 내부 전체가 로드된다. 즉, withDefaultProps를 가져오는 것만으로 withLayout도 같이 불려오면서, 결과적으로 withLayout.tsx 내부의 BaseLayout이 불려와 순환 참조가 발생한 것이었다!
이처럼 하나의 모듈이 다른 모듈을 참조하고, 다시 자신이 참조되는 구조가 형성되면 런타임 시 참조 에러가 발생할 수 있다.

🔧 해결책
hoc/index.tsx를 통해 한꺼번에 export하지 않고, 필요한 곳에서 개별적으로 import하도록 변경하여 순환 참조 에러를 해결할 수 있었다.

리액트 공식문서에서도 설명하듯, export 방식에 따라 import 방식이 달라지며, 컴포넌트 명명 규칙과 구조 관리가 중요하다는 점을 꼭 기억하자! 나중에 디버깅과 유지보수를 편하게 하기 위해서는 export 방식을 의도적으로 잘 설계하는 것이 중요하다.
이번 경험을 통해 export 방식의 선택과 순환 참조를 예방하는 설계의 중요성을 제대로 체감했다✨