본문 바로가기
빌드노트 검색

빌드노트

순환 require에서 나중에 넣은 exports 값이 undefined인 이유

CommonJS 순환 require에서 초기화 중 저장한 값은 undefined로 남아도, 같은 객체를 로딩 후 다시 읽으면 값이 보일 수 있다. 두 모듈의 실행 순서와 실제 경고를 비교하고, 의존을 끊을지 읽는 시점을 늦출지 판단한다.

CommonJS 모듈 두 개가 서로 require()할 때, 분명 exports.label = 'ready'를 썼는데 상대 모듈에서는 undefined가 보일 수 있다. 순환 로딩 중에는 실행이 끝나지 않은 모듈의 exports를 받을 수 있기 때문이다. 객체를 받았다는 것과 필요한 필드까지 채워졌다는 것은 다르다. Node.js v22.23.2의 순환 require 설명 (새 탭에서 열림)

자기 코드에서는 값을 내보내는 파일의 require()와 필드 대입 순서부터 본다. 상대 파일을 읽는 줄보다 뒤에 대입한다면, 그 상대 파일이 초기화 도중 되돌아와 아직 없는 필드를 읽는지 확인한다. 소비자에서 const { label } = require(...)처럼 처음 읽은 값을 저장했는지도 함께 본다. 나중에 필드가 생겨도 그 지역 변수가 다시 읽어 주지는 않는다.

이 경우에는 exports를 쓰는 문법보다 의존 방향과 읽는 시점을 바꿔야 한다. 로딩 도중 꼭 필요한 값이면 순환 의존을 끊을 구성을 찾고, 로딩 후에만 필요한 값이면 그때 속성을 읽도록 옮긴다. 아래 예제는 같은 객체를 유지한 채 이 두 읽기 시점의 차이를 확인한다.

같은 객체인데 초기화 중에는 필드가 없다

Windows 11, PowerShell 7.6.6, Node.js v22.23.2에서 새 파일로 재현했다. 실제 앱의 장애 기록은 아니다. 직접 비교하려면 Node.js가 설치된 환경의 새 빈 폴더에 아래 세 파일을 같은 위치에 저장한다. 별도 패키지는 필요 없다. .cjs는 CommonJS로 해석되므로 package.json의 형식을 바꿀 필요도 없다. CommonJS 파일 조건 (새 탭에서 열림)

catalog.cjs는 먼저 view.cjs를 읽고, 그 호출이 돌아온 뒤에 label을 넣는다. 어느 지점까지 실행했는지 보려고 phase와 시작·끝 로그를 함께 둔다.

console.log('catalog: start');
exports.phase = 'loading';
const view = require('./view.cjs');
console.log('catalog: view.phase =', view.phase);
exports.label = 'ready';
exports.phase = 'done';
console.log('catalog: end');

view.cjs는 다시 catalog.cjs를 요구한다. 여기서는 즉시 읽어 저장한 labelAtLoad와, 호출할 때 읽는 readLabel()을 나란히 둔다. sameCatalog()는 나중에 실행 파일이 받은 객체와 같은지 확인하는 용도다.

console.log('view: start');
exports.phase = 'loading';
const catalog = require('./catalog.cjs');
console.log('view: catalog.phase =', catalog.phase);
exports.labelAtLoad = catalog.label;
console.log('view: label at load =', exports.labelAtLoad);
exports.readLabel = () => catalog.label;
exports.sameCatalog = (candidate) => catalog === candidate;
exports.phase = 'done';
console.log('view: end');

main.cjs는 catalog부터 읽는다. 두 require()가 돌아온 뒤 필드, 저장한 값, 함수의 반환값을 비교한다.

console.log('main: start');
const catalog = require('./catalog.cjs');
const view = require('./view.cjs');
console.log('main: catalog.label =', catalog.label);
console.log('main: view.labelAtLoad =', view.labelAtLoad);
console.log('main: view.readLabel() =', view.readLabel());
console.log('main: same catalog =', view.sameCatalog(catalog));

세 파일이 있는 폴더의 터미널에서 다음 명령을 실행한다. --trace-warnings는 경고가 발생한 코드 위치까지 보여 준다. Node의 경고 추적 옵션 (새 탭에서 열림)

node --trace-warnings ./main.cjs

표준 출력은 다음과 같았다. catalog: end보다 먼저 나온 view의 읽기와, 그 뒤 main의 읽기를 비교하면 된다.

main: start
catalog: start
view: start
view: catalog.phase = loading
view: label at load = undefined
view: end
catalog: view.phase = done
catalog: end
main: catalog.label = ready
main: view.labelAtLoad = undefined
main: view.readLabel() = ready
main: same catalog = true

호출은 main → catalog → view → catalog 순서로 이어졌다. 마지막 require('./catalog.cjs')에서는 catalog를 처음부터 재실행하지 않고 진행 중인 exports를 받는다. 그때 phase는 이미 있지만 label 대입문에는 아직 도달하지 않았다. view가 끝나고 catalog의 실행이 재개된 뒤에야 label이 생겼다. 이는 공식 문서의 부분 초기화 설명과 이번 로그를 대조한 해석이다.

완료 뒤에도 view.labelAtLoad는 undefined였다. 처음 읽은 값을 저장해 두었기 때문이다. 반면 readLabel()은 호출 시점에 catalog.label을 다시 읽어 ready를 반환했다. same catalog = true도 확인했으므로, 이 비교에서 앞의 모듈이 별도의 오래된 복사본을 받은 것은 아니다.

이 예제에는 exports = ...나 module.exports = ...가 없다. 같은 exports 객체의 필드만 추가·갱신했다. exports 별칭에 새 객체를 대입하는 문제나, JSON 파일을 고친 뒤 재로드하는 문제와 구별하기 위한 조건이다. exports 별칭의 적용 조건 (새 탭에서 열림)

표준 오류에는 아래 경고도 나왔고, 추적에는 view.cjs:5:31이 표시됐다. 경고 첫 줄에서 프로세스 번호 부분만 생략했다.

Warning: Accessing non-existent property 'label' of module exports inside circular dependency

종료 코드는 0이었다. 이 실행에서 경고는 프로세스를 중단시키지 않았고, 잘못 읽은 값도 그대로 남았다. 자기 코드에서도 종료 성공만 보지 말고 해당 속성을 어디서 처음 읽었는지 확인해야 한다. 경고가 보이지 않는 경우에도 같은 버전·코드·시작 모듈인지와 경고 출력 억제 설정을 확인한다. 경고 유무만으로 순환 의존의 존재를 판정하지는 않는다.

읽기 함수의 호출도 로딩 뒤로 옮긴다

이 예제에서 값을 로딩 후에만 쓸 계획이라면, view.cjs의 exports.labelAtLoad = catalog.label;과 바로 다음 출력 줄을 지운다. main.cjs에서도 view.labelAtLoad를 출력하는 줄을 지운다. 초기화 중의 값 저장을 없애고, 남아 있는 view.readLabel() 호출로 읽게 하는 변경이다. 같은 명령을 새 Node 프로세스로 실행하자 마지막 세 줄은 다음과 같았고 표준 오류는 비어 있었다.

main: catalog.label = ready
main: view.readLabel() = ready
main: same catalog = true

함수로 감싸는 것만으로 충분하지는 않다. 수정한 view.cjs에서 exports.readLabel = () => catalog.label; 바로 아래에 다음 한 줄을 넣으면, 호출을 너무 일찍 했을 때의 차이를 확인할 수 있다.

console.log('view: readLabel during load =', exports.readLabel());

다시 실행했을 때 이 줄은 view: readLabel during load = undefined를 출력했고 같은 경고가 발생했다. 이후 main의 호출은 여전히 ready였다. 값이 필요해진 시점에 필드가 준비되어 있는지가 기준이다. 함수를 정의한 위치만 바꿔도 호출부가 초기화 도중 실행되면 문제가 남는다.

자기 코드에서 로딩 중 계산에 그 값이 반드시 필요하다면, 읽기를 늦추기보다 의존 구조를 바꿔야 한다. 예를 들어 공유 상수·계산을 양쪽에 의존하지 않는 별도 모듈로 옮기거나, 실행 파일에서 필요한 값을 만들어 소비자에 인자로 전달하는 구성을 검토할 수 있다. 이 재현에서는 그런 앱 구조 변경까지 실행하지 않았다. 로딩 후로 미룰 수 있다면 const { label } = catalog처럼 미리 꺼내 둔 값을 함수에서 반환하지 말고, 함수 안에서 catalog.label을 읽어야 한다.

지연 읽기 후에도 undefined라면 해당 필드의 대입이 실제로 실행됐는지, 호출이 대입보다 빠른지, exports 객체를 중간에 교체하는 코드가 있는지 확인한다. 이번 결과는 명시한 순서로 시작한 로컬 CommonJS 파일과 동기 초기화에 한정된다. 비동기 자원의 준비 완료, 다른 진입점·Node 버전, ES 모듈과 번들러의 순환 처리까지 검증한 것은 아니다.

← 전체 글 보기