프로젝트에서 순환 참조(Circular Dependency) 에러를 만났다.

원인을 추적하다 보니 배럴 파일(barrel file) 이 주범이었다. 배럴 파일은 index.ts에 여러 모듈을 하나로 묶어 export하는 파일을 의미한다.
원인이 배럴 파일이란걸 알아 낸 후, 프로젝트에서 배럴 파일을 사용하는 패턴을 제거하기로 결정했고, 그 결과 얻었던 긍정적인 경험들과 그럼에도 불구하고 몇몇 케이스에서는 유지했던 이유를 남겨두고자 한다.
그냥 import를 예쁘게 하고 싶었어요
처음 배럴 파일을 도입한 이유는 단순했다. import 문을 깔끔하게 만들고 싶었다.
// 이렇게 하는 것보다
import { validateEmail } from "@/shared/helpers/validation/email";
import { formatDate } from "@/shared/helpers/format/date";
import { calculateAge } from "@/shared/helpers/calculate/age";
// 이게 더 깔끔하잖아!
import { validateEmail, formatDate, calculateAge } from "@/shared/helpers";사이드 이펙트 같은 건 생각하지 않았다. "한 줄로 줄이면 좋지 않나?"라는 단순한 생각이었다. 이미 많은 프로젝트에서 배럴 파일을 쓰고 있었기에, 이것이 나쁜 패턴이라는 의심조차 하지 않았다.
순환 참조 문제 발생!
그러다 어느 날, 결국 순환 참조 에러를 만났다. 문제는 다음과 같았다.
// 1. shared/helpers 폴더 내부의 어떤 파일에서
import { SnsPlatforms } from "@/shared/enums";
// 2. SnsPlatforms 파일 내부에서는
// @/shared/enums/SnsPlatforms.ts
import { createI18nMap } from "@/shared/helpers";
// 문제: 배럴 파일 때문에 순환 참조 발생
// shared/helpers/index.ts가 모든 export를 모아두고 있어서
// shared/helpers → shared/enums → shared/helpers 순환이 만들어짐
// 임시 해결: 배럴 파일을 거치지 않고 직접 참조
import { createI18nMap } from "@/shared/helpers/enum";배럴 파일이 모든 export를 한곳에 모아두다 보니, 서로 의존하는 파일들 사이에서 순환 참조가 쉽게 발생했다.
이 경험을 계기로 배럴 파일을 사용하는 것에 대해 다시 생각하게 됐다. 찾아보니 이미 많은 글들이 배럴 파일의 문제점을 지적하고 있었다
순환 참조 문제는 일부일 뿐..
배럴 파일을 사용하는 것은 순환 참조 문제 뿐만 아니라 다른 문제들도 가지고 있다.
트리 셰이킹 문제
최신 자바스크립트 번들러(Vite, Webpack 등)는 트리 셰이킹(Tree-shaking)을 통해 사용하지 않는 코드를 제거하여 최종 결과물을 최적화한다. 하지만 배럴 파일은 이 과정을 방해한다.
// 1. 배럴 파일 사용 (Bundler: "index.ts를 분석해야겠다.")
import { validateEmail } from "@/shared/helpers";
// 2. 직접 임포트 (Bundler: "email.ts만 분석하면 되네!")
import { validateEmail } from "@/shared/helpers/validation/email";1번의 경우, 번들러는 validateEmail 함수 하나만 필요한지 알기 위해 @/shared/helpers (index.ts) 파일을 통째로 분석해야 한다. 이 index.ts 파일이 다시 수십 개의 다른 파일을 export 하고 있다면, 번들러는 "이 중 어떤 것이 사이드 이펙트를 일으킬지 모르니, 일단 전부 번들에 포함하는 게 안전하겠다"라고 보수적으로 판단할 수 있다.
결국 validateEmail 함수 하나가 필요했을 뿐인데, helpers 폴더 안의 모든 유틸 함수가 최종 번들에 포함될 수 있다는 것이다.
배럴 파일을 제거하자
문제를 한 번 겪고, 모든배럴 파일을 제거하기로 했다. 배럴 파일을 제거하기 시작하니 아래와 같은 긍정적인 경험을 했다.
1. import 문은 여전히 깔끔하다
애초에 배럴 파일을 사용했던 이유였기 때문에 걱정을 했던 부분인데, 몇 줄 더 늘어나긴 했지만, 눈에 거슬릴 정도는 아니었다. 애초에 IDE에서 import 블록을 접으면 되니까 실질적인 문제가 없었다.
오히려 어디서 무엇을 가져오는지 명시적이 되어 코드 리딩이 훨씬 쉬워졌다. @/shared/helpers 라는 모호한 경로보다, @/shared/helpers/format/date 라는 명확한 경로가 의도를 100% 보여준다.
2. 불필요한 폴더/파일 구조가 사라졌다
배럴 파일을 쓰다 보니, "폴더가 생기면 index.ts도 만들어야 한다"는 암묵적인 강박이 생겼다. 그러다 보니 다음과 같이 파일 하나만 덩그러니 있는 비효율적인 구조도 생겨났다.
features/
└── helpers
└── something.ts
└── index.ts배럴 파일을 제거하니, 이런 불필요한 index.ts 파일들이 사라졌고 파일 구조가 더 직관적이고 단순해졌다.
3. '깜빡'하는 휴먼 에러가 사라졌다
배럴 파일을 사용하면, 새 유틸 함수나 컴포넌트를 추가할 때마다 두 곳을 수정해야 했다.
- 실제 파일 생성 (e.g.,
new-util.ts) - 배럴 파일에 export 추가 (
index.ts)
바쁘게 여러 파일을 작업하다 보면 2번을 누락하는 실수가 잦았다. 이제는 파일을 만들면 그 즉시 다른 곳에서 경로 그대로 가져다 쓸 수 있다.
4. 리팩토링이 훨씬 쉬워졌다
파일이 index에 묶여있어서 구조를 바꿀 때 번거로웠다. index 파일도 함께 수정해야 했고, IDE가 이걸 제대로 잡아주지 못하는 경우도 있어서 직접 찾아가며 고쳐야 했다. 이제는 index에 묶여있지 않으니 IDE가 자동으로 import 경로를 업데이트해준다. 리팩토링이 훨씬 자유로워졌다.
배럴파일이 무조건 악은 아니다
모든 배럴 파일을 삭제한 것은 아니다. 다음과 같이 명확한 목적이 있는 경우는 그대로 유지했다.
- Toast 기능을 가진 폴더처럼, 내부에 여러 파일이 있더라도 외부에는
useToast,ToastProvider등 정해진 인터페이스만 노출하면 되는 경우 - Table 처럼 Row, Cell, Header 등으로 컴포넌트를 나눠 놨지만, 결국에는 모두가 import 되어야 Table을 완성할 수 있는 경우
components/
Table/
index.ts # 배럴 파일 유지
Table.tsx
TableRow.tsx
TableCell.tsx
TableHeader.tsx느낀 점
모든 방식에는 장단이 있다. 배럴 파일도 마찬가지다. "항상 사용해야 하는 필수"가 아니라 "상황에 따라 선택할 수 있는 옵션"으로 생각하면 좋겠다. 중요한 건 왜 그 선택을 했는지 이해하고, 문제가 생겼을 때 다른 방식을 시도할 수 있는 유연함이 아닐까.