본문 바로가기

FrontEnd

Javascript ES module과 순환 참조 해결하기

반응형

TL;DR

esm을 통해,

  • 효과적인 변수 관리
  • 모듈 정보 수집과 실행 단계를 분리하여 로더가 비동기 import, 코드 transform등 다양한 것을 쉽게 처리할 수 있게 되었다.
    • esm, cjs를 쓸지 결정한 뒤 코드를 해석하는 것은 로더의 역할이다.
      • esmodule과 전혀 다른 명세, 사양
      • 브라우저는 html 사양을 이용하여 js 처리
        • html에 어떤 방식으로 처리할지(ex: type="module" 있음) 적혀있음

es module의 3단계

  • 구성 : import, export 정보를 먼저 수집 (module record, modulemap)
    • import, export 정보는 코드 실행 순서와 분리되어 있다.
  • 인스턴스화 : module record 정보로 module environment record 만든다.
    • "모듈 정보의 인스턴스화 "
    • module environment record는 코드 실행 후 객체가 할당될 메모리 참조를 먼저 만든다.
  • 구문 분석:
    • 구성, 인스턴스화 정보를 이용하여 실제로 코드를 실행한다.
      • 인스턴스화 단계를 통해 module environment record가 가리키는 메모리 영역에 실제 인스턴스를 만든다.
    • import, export는 구성 단계에서 만든 친구들을 이용해 수행한다.

 

순환 참조 문제 해결

  • 파일을 잘 쪼갠다
  • index.js에서 모듈 로딩 순서를 오케스트레이션 한다.

해당 게시물은 아래 4개의 게시물을 학습하며 정리한 내용입니다.

간단한 설명을 위해 제거한 디테일이 많으니 원문도 꼭 봐주세요!

(저는 아래 번역글의 번역본과 원문을 전부 참조하였습니다)

https://ui.toast.com/weekly-pick/ko_20180402

 

ES modules - 만화로 보는 심층 탐구

ES 모듈은 자바스크립트의 공식적이며 표준화된 모둘 시스템이 되었다. 표준이 된 지는 얼마 지나지 않았지만 거의 10년간의 표준화 작업을 거쳤다. 이제 기다림은 거의 끝나간다. 5월에 Firefox 60(

ui.toast.com

https://ljs0705.medium.com/js-모듈-시스템과-순환-참조-문제-a9e0c90c07e5

 

JS 모듈 시스템과 순환 참조 문제

자바스크립트의 모듈 시스템이 동작하는 방식을 이해해보자. 그리고 순환 참조 시 발생할 수 있는 문제와 해결책을 알아보자.

ljs0705.medium.com

https://www.rinae.dev/posts/fix-circular-dependency-kr

 

[번역] 자바스크립트 & 타입스크립트의 순환 참조를 한방에 해결하는 방법

리내(안도형)의 개발블로그입니다. 좋은 기술 문서 번역을 주력으로 삼고 있습니다. 또한 개인적으로 공부한 내용이나, 모아두었던 글을 공유하고 있습니다.

www.rinae.dev

모듈이 해결하는 문제

자바스크립트 코딩은 변수를 관리하는것

  • 변수에 값을 할당하거나, 변수에 숫자를 더하는 것, 그리고 두 변수를 합쳐서 또 다른 변수에 넣는 것 등이다.

스코프{}는 변수의 유효 범위를 한정함

  • 해당 범위에 소수의 변수를 한정하여 가볍게 개발할 수 있음
  • 프로젝트의 모든 변수를 생각하게 된다면...

스코프 간 변수 공유는 불가능

스코프의 단점 : 함수 간의 변수 공유를 어렵게 만든다.

 

해결 방안 1 

  • 일반적으로는 전역 스코프처럼 상위의 스코프에 변수를 배치하는 방법이 있을 것이다.(과거)

Jquery 모듈 : 누군가 나를 필요로 할 테니 내 참조를 전역에 노출시켜야지

해결 방안 1의 단점

  • 암시적 의존성 유발. 깨짐 파악이 쉽지 않음
  • 스크립트 태그의 로드 순서가 중요해짐

다른 모듈 : JQuery 어딨지? 난 JQuery없으면 아무것도 못하는데?

 

해결 방안 2: 모듈 스코프 도입

자박스크립트의 모듈 기능을 이용하면 파일 단위의 스코프를 생성하는게 가능해짐.

(모듈이란 단위는 언어 x 언어이므로)

  • 모듈을 사용하면 관련이 있는 변수와 함수를 한데 모을 수 있다.
    • 응집도 up, 결합도 down
  • 모듈은 함수와 변수를 모듈 스코프에 넣는다.
    • 모듈 스코프를 통해 모듈의 함수 사이에서 변수를 공유할 수 있다.
  • 모듈 범위는 변수는 함수 스코프와 달리 다른 모듈에서도 사용할 수 있는 방법을 제공한다.
    • 외부에서 어떤 변수나 클래스, 함수를 사용할지 명시적으로 알려줄 수 있다.
    • 해당 모듈 스코프의 변수를 다른 모듈에서 사용할 수 있게 해주는 것이 바로 export다.
    • 한번 export 하면, 다른 모듈에서 명시적으로 그 변수, 클래스, 함수를 의존한다고 할 수 있다.
  • 모듈 간에 변수를 내보내고(export) 가져올 수(import) 있게 되면, 코드들을 각각 독립적으로 작동할 수 있는 단위로 나누기 수월해진다.

모듈 간에 변수를 내보내고(export) 가져올 수(import) 있게 되어, 명시적인 관계로 인해, 한 모듈을 제거하면 어떤 모듈이 손상되는지 알 수 있다.

 


ES module의 동작 방식

모듈을 사용한다는 것은 모듈 간의 의존성 그래프를 만든다는 것이다.

하나의 모듈은

  • import를 통해 children을 가져온다.
  • export를 통해 children이 될 수 있다.

이런 import 문은 브라우저 또는 Node가 어떤 코드를 불러와야 하는지 인식하는 데 사용된다.

하나의 entry point는 모듈 간 의존성 그래프(프로그램)의 진입점이 된다.

  • import만 따라가도 의존성이 보인다.

파일 자체는 브라우저가 사용할 수 있는 것이 아니다.

  • 파일은 네트워크를 통해 가져오던가 해야 한다.

모듈 레코드(Module Record)라고 하는 데이터 구조로 변환하려면 이 파일들을 모두 구문분석해야 한다.

이를 통해 실제로 파일 안에서 어떤 일이 일어나는지 알 수 있다.

Module 레코드 안에는 다음과 같은 것들이 있다.

모듈 레코드의 구성 요소

  • 코드
    • 해당 모듈의 실행 전의 js 코드다
    • 코드는 실행되면 상태를 갖는다.
      • 변수를 통한 메모리 참조
  • RequestModules
    • 어떤 파일들을 필요로 하는지 저장한다.
  • ImportEntries
    • 어떤 모듈에서 어떤 변수들을 가져오는지 기억한다
      • 변수는 함수일 수도 있음.
  • 그외에도 많은 정보...
    • LocalExport라는 해당 모듈에서 export하는 변수들 정보 등...

그런 다음, 모듈 레코드를 모듈 인스턴스로 변환한다.

인스턴스는 코드와 상태 두 가지를 결합한다:

코드는 기본적으로 명령어의 집합이다.

  • 무언가를 만드는 제조법과 비슷하다.
    • 코드 자체로는 아무것도 할 수 없다. 이런 명령들에 사용할 원료가 필요하다.

 

  • 상태(state)는 원료를 제공해준다.
    • 상태는 그 시점의 실제 변숫값이다.
    • 물론 변수는 값을 가진 메모리의 공간 이름에 불과하다.

따라서 모듈 인스턴스는 코드(명령어 목록)와 상태(모든 변숫값들)를 결합한다.

모듈 인스턴스는 상태 + 코드다

우리는 각 모듈에 대한 모듈 인스턴스가 필요하다.

모듈을 불러오는 과정은 entry 파일이 모듈 인스턴스의 전체 그래프를 그리는 것으로 진행된다.

ES 모듈의 경우 세 가지 단계로 나누어 모듈 그래프를 구성한다

  1. 구성(construction) - 모든 파일을 찾아 다운로드하고 모듈 레코드로 파싱한다.
  2. 인스턴스화(instantiation) - export 된 값을 모두 배치하기 위해 메모리에 있는 공간들을 찾는다(아직 실제 값은 채우지 않음). 그다음 export와 import들이 이런 메모리 공간들을 가리키도록 한다. 이를 연결(linking) 이라고 한다.
  3. 평가(evaluation) - 코드를 실행하여 상자의 값을 변수의 실제 값으로 채운다.

모듈 불러오기의 3가지 단계

ES 모듈 사양은 비동기식이라고 할 수 있다.

  • 간단히 생각해보면 모듈화 작업은 구성, 인스턴스 화 및 평가 세 단계로 나뉘어있으며 독립적으로 수행될 수 있기 때문이다.
  • CommonJS는 동기식으로 수행됨
  • CJS에서는 모듈과 그 아래의 의존성이 로드되고, 인스턴스화 되어 한꺼번에 모든 평가가 이루어진다.

(주; 보통 브라우저에 파일을 로딩하기 전에, 웹팩, 롤업같은 번들러로 선처리 하는데,

번들러는 모듈 사양에 맞게 하나의 entrypoint 내에서 각 모듈를 로딩할 수 있도록 해준다.)

 

그러나 단계 자체가 반드시 비동기는 아니다. 무엇을 불러오느냐에 따라 동기식으로 수행할 수 있다.

왜냐하면 모든 것이 ES 모듈 명세에 의해 제어되지 않기 때문이다.

실제로 작업은 두 가지로 나뉘어있는데, 서로 다른 명세로 이루어져 있다.

로더 명세

  • 파일을 처음에 어떻게 얻는지 알려준다
로더는 모듈이 로드되는 방식을 정확하게 제어한다.
  • ES 모듈 메서드 — ParseModule, Module.Instantiate 및 Module.Evaluate를 호출
  • JS 엔진의 문자열을 제어하는 ​​꼭두각시 인형과 같음

로더는 JS 엔진의 문자열을 제어하는 ​​꼭두각시 인형과 같음

ES 모듈 명세

  • 모듈 레코드에 파일을 구문분석하는 방법
  • 인스턴스화 하는 방법,
  • 그리고 그 모듈을 평가하는 방법을 알려준다.

(주 : 물론 두 가지 작업 둘 다 브라우저가 처리한다)

html 명세 : 어떻게 모듈 파일을 찾아 다운로드 받는지 안다. JS 명세 : 어떻게 JS를 구문 분석하고, 인스턴스화하고, 모듈을 평가하는지 안다.

 

 

로더가 ES Module 사양에 따라 각 파일을 처리하는 과정

로더는 파일을 불러와 ES Module 사양에 맞게 각 파일을 처리한다.

중간에 구성 단계에서 파일을 불러오는 역할도 로더가 수행한다.

파일 찾기 및 가져오기(로딩)

로더는 파일을 찾아서 다운로드한다. 그러기 위해서는 먼저 entry point 파일을 찾아야 한다.

HTML에서는 스크립트 태그를 사용해서 로더에게 어디에서 entry point 파일을 찾을 수 있는지 알려준다.

(주: 원문 번역은 진입점이라 했는데, 실무에서는 entry point란 용어를 더 활용하는듯 ^^;)

entry point 파일을 esmodule 시스템을 이용해 불러와야 함을 브라우저에 알려준다

main.js을 가져와 직접 의존하는 번들을 import 구문을 이용해 가져온다. 

그 후 해당 모듈을 구성한다.

1. 구성 단계

구성 단계에서 각 모듈에는 세 가지 일이 일어난다.

  1. 모듈이 들어있는 파일을 어디서 다운로드 할 것인지 확인한다
    • (module resolution이라고도 함).
  2. 파일을 가져온다(URL을 통해 다운로드 하거나 파일 시스템에서 불러옴).
  3. 파일을 모듈 레코드로 구문분석한다.

1. 1 모듈이 들어있는 파일을 어디서 다운로드할 것인지 확인한다

import 문의 한 부분을 모듈 지정자라고 한다. 이것은 로더가 어디서 다음 모듈을 찾아야 할지 알려준다.

이제 import 문이 올 차례다. import 문의 한 부분을 모듈 지정자라고 한다.

모듈 지정자의 URL에서 모듈 파일을 로드한다.

하지만 모든 그래프에서 동시에 발생하지는 않는다.

파일을 구문분석할 때까지 모듈이 가져와야 하는 의존성을 알 수 없고,

파일을 가져올 때까지 파일을 구문분석할 수도 없다.

 

즉, 하나의 파일을 구문분석한 다음 트리의 의존성을 파악하고, 해당 의존성을 찾아 불러와야 한다.

하나의 파일을 구문분석한 다음 트리의 의존성을 파악하고, 해당 의존성을 찾아 불러와야 한다. 그리고 각 모듈을 구성한다.

1. 2 파일을 가져온다(URL을 통해 다운로드 하거나 파일 시스템에서 불러옴)

ES Module의 명세가 왜 세단계로 나누어져 있을까?

 

주 스레드가 이들 파일 각각을 다운로드할 때까지 대기해야 한다면 많은 작업이 대기열에 쌓일 것이다.

이것이 바로 브라우저에서 작업할 때 다운로드 시간이 가장 긴 이유이다.

  • CPU가 L1캐시 접근할 때 1초가 걸리면
  • 메모리 접근에는 6분
  • 미국 끝에서 끝에 있는 서버에서 데이터 가져오는 건 4년이 걸림

주 스레드가 이들 파일 각각을 다운로드할 때까지 대기해야 한다면 많은 작업이 대기열에 쌓일 것이다.

이처럼 주 스레드를 차단하게 된다면 실제로 애플리케이션에서 모듈을 사용하기엔 너무 느릴 것이다.

이것이 ES 모듈 명세가 알고리즘을 여러 단계로 나눠놓은 이유이다.

  • 구성과 인스턴스화 단계를 나눈다면 인스턴스화 작업을 동기적으로 처리하기 전에, 브라우저가 파일을 불러오고 모듈 그래프를 구성할 수 있다.

각 단계를 나누는 알고리즘을 사용하는 접근법은 ES 모듈과 CommonJS 모듈 간의 주요 차이점 중 하나이다.

  • CommonJS는 파일 시스템에서 파일을 로드하므로 인터넷을 통해 다운로드 하는 것보다 시간이 훨씬 적게 든다.
    • 이 말은 Node는 파일을 불러오는 동안 주 스레드를 차단한다는 것을 의미한다.
    • 그리고 이 때문에 파일은 이미 로드되어 있으므로 바로 인스턴스 화 하고 평가하면 된다(CommonJS에서는 분리된 단계가 아니다).
    • 이것은 또한 모듈 인스턴스를 반환하기 전에 전체 트리를 순회하고 로드, 인스턴스 화 및 모든 의존성 평가를 한다는 것을 의미한다.(동기적)

CommonJS 모듈 사양은 main을 전부 처리하기 전에 module-en.js를 처리하고 다시 넘어와 main을 동기적으로 처리

CommonJS 모듈을 사용하는 Node에서 모듈 지정자에 변수를 사용할 수 있다.

  • 모듈 인스턴스를 반환하기 전에 전체 트리를 순회하고 동기적으로 로드, 인스턴스화 및 모든 의존성 평가를 하기 때문
  • 다음 모듈을 보기 전에 현재 모듈의 모든 코드(require문 까지)가 실행된다.
    • 즉 모듈 resolution을 수행할 때 변수에 값이 있음을 의미한다.

하지만 ES 모듈을 사용하면 평가를 하기 전에 미리 전체 모듈 그래프를 작성해야 한다.

  • 즉, 변수에 아직 값이 없으므로 모듈 지정자에 변수를 넣을 수 없다.

그러나 때로는 모듈 경로에 변수를 사용하는 것이 유용하다.

예를 들면, 코드가 수행 중인 작업이나 실행 중인 환경에 따라 로드할 모듈을 전환할 수 있다.

ES모듈에서 이것을 가능케 하는 동적 import라는 제안이 있다.

이를 사용하면 다음과 같이 사용할 수 있다.

 import(`${path}/foo.js`)

이것이 동작하는 방식은 다음과 같다.

  1. import()를 통해 불러온 파일은 별개의 그래프의 진입점으로 취급된다.
  2. 동적으로 import 한 모듈은 새로운 그래프를 시작하고 별개로 처리된다.
    • new entry point
    • code splitting!!!

동적으로 import 한 모듈은 새로운 그래프를 시작하고 별개로 처리된다.

한가지 주의해야 할 점은, 이 두 그래프에 모두 있는 모듈은 모듈 인스턴스를 공유한다는 점이다.

  • 모듈 인스턴스는 싱글톤

이는 로더가 모듈 인스턴스를 캐시 하기 때문이다.

  • 특정 전역 스코프의 각 모듈에는 하나의 모듈 인스턴스만 존재한다.

이것은 엔진의 작업을 줄여준다.

예를 들어, 여러 모듈이 해당 모듈에 의존하고 있어도, 모듈 파일은 오로지 한번 불러 들여진다.

이것은 모듈을 캐시 하는 이유 중 한가지이다.

(평가 부문에서 다른 이유를 설명할 것이다.)

 

로더는 모듈맵을 이용해서 캐시를 관리한다.

  • 각 전역 스코프(전역 객체 global; 주의! 프로세스 별로 단 하나)는 별도의 모듈 맵에서 모듈을 관리한다.
  • 로더는 URL에서 에셋을 가져올 때, 해당 URL을 모듈 맵에 넣고 현재 파일을 가져오는 중임을 나타낸다.
    • 아래 그림의 fetching
  • 요청을 보내고 나서 다음 파일을 가져오기 시작한다.

로더는 URL에서 에셋을 가져올 때, 해당 URL을 모듈 맵에 넣고 현재 파일을 가져오는 중임을 나타낸다.

다른 모듈이 같은 파일에 의존하면 어떻게 될까?

로더는 모듈 맵에서 각 URL을 검색한다.

  • 만약 불러오는 중임을 확인하면 다음 URL로 넘어갈 것이다.
  • 그러나 모듈 맵은 가져오는 파일을 추적하지 않는다.

모듈맵은 모듈에 대한 캐시로도 사용된다.

1.3 파일을 모듈 레코드로 구문분석한다.

이제 파일 불러오기는 끝났다.

다음으로는 그걸 모듈 레코드로 해석해야 한다.

이를 통해 브라우저가 모듈의 다른 부분이 무엇인지 이해하게 해준다.

구문 분석은 브라우저가 모듈의 다른 부분이 무엇인지 이해하게 해준다.

모듈 레코드가 한번 만들어지고 나면, 그 레코드는 모듈맵에 추가된다.

  • 이전엔 fetching이었음

이는 다음부터 필요할 때 마다 로더가 해당 레코드를 모듈맵에서 가져올 수 있다는 것을 의미한다.

모듈 레코드가 한번 만들어지고 나면,  그 레코드는 모듈맵에 추가된다. 이는 다음부터는 필요할 때 마다 로더가 모듈맵에서 가져올 수 있다는 것을 의미한다.

실제로는 큰 의미가 있는 사소한 특징이 한가지 있다.

  • 모든 모듈은 코드 상단에 "use strict"가 있는 것처럼 구문분석 된다.
  • await 문은 모듈의 최상위 레벨의 코드에 예약어이며, 이 키워드가 가진 값은 undefined다.

구문분석 논리가 예상하는 입력 형태를 구문분석 목표라 한다.

같은 파일을 파싱하더라도 다른 목표(다른 로직)를 사용하면 결과가 달라진다.

따라서 모듈인지 아닌지에 관계없이 구문분석 목표를 분석 전에 알아야 한다.

 

브라우저에서는 이를 처리하기 매우 쉽다.

그저 type="module"을 스크립트 태그 내부에 적어두기만 하면 된다.

이를 통해 브라우저에 이 파일은 모듈로 구문분석 되어야 한다고 알려주는 것이다.

이런 방법으로는 모듈만 가져올 수 있기 때문에 브라우저는 이것이 모듈이라고 알고 수행하게 된다.

 

Node에서는 HTML 태그를 사용하지 않으므로 type 속성을 사용할 수 있는 옵션이 없다.

커뮤니티는 이를 해결하기 위한 한 가지 방법으로 .mjs확장자를 고안했다.

"이 확장자를 사용하는 파일은 모듈이다"라고 Node에 알려주는 것이다.

여러 방법으로 구문분석 목표를 지정한다.

어느 쪽이든, 로더는 파일을 모듈로 구문 분석할지 말지 결정한다.

어떤 파일이 모듈이며 import 문이 있다면 모든 파일을 불러와서 모듈로 구문분석하는 단계를 계속 수행한다.

 

불러오기 단계가 끝나면,

우리는 진입점 파일부터 시작해서 모듈 레코드의 묶음을 얻게 된다.

불러오기 단계가 끝나면, 우리는 진입점 파일부터 시작해서 모듈 레코드의 묶음을 얻게 된다.

 

다음 단계는 이 모듈을 인스턴스 화 하고 모든 인스턴스를 함께 연결하는 것이다.

인스턴스화

export들을 먼저 연결(처리)해서 import들이 모두 각각의 export들에 연결되는 것을 보장한다.

인스턴스는 코드와 상태를 결합한다.

상태는 메모리에 있으므로 인스턴스 화단계는 모든 것을 메모리에 연결하는 것이라고 할 수 있다.

주의 : 이 단계는 실제 코드를 평가하는 것이 아니다.
구성 단계에서 코드를 파싱하면서 만든 import, export 관계를 메모리 상 참조로 연결하는 관계다.
해당 메모리 주소에 코드가 실행된 후(평가), 실제 객체, 변수가 인스턴스화 혹은 할당된다.
  1. 먼저, JS 엔진은 모듈 환경 레코드를 생성한다. 이를 통해 모듈 레코드의 변수를 관리한다.
  2. 그 다음 모든 export에 대해 메모리에 있는 상자를 찾는다.
  3. 모듈 환경 레코드는 각 export와 연관된 메모리의 상자를 추적한다.
  4. 메모리에 있는 이 상자들은 아직 값을 가지지 않으며, 평가가 끝난 후에 실제 값이 채워진다.
    1. 이 규칙에는 한가지 주의사항이 있다. export 된 함수 선언은 이 단계에서 초기화된다. 이를 통해 평가가 더 쉬워진다.

모듈 그래프를 인스턴스화 하기 위해 엔진은 깊이 우선 순회를 수행한다.

이는 그래프의 맨 아래, 즉 다른 것에 의존하지 않는 최하단의 의존성까지 조사한 뒤 해당 export를 설정한다.

 

모듈 환경 레코드의 구성 요소

  • Bindings
    • 해당 모듈이 내보내는 변수의 메모리 영역 참조
      • Module Record의 참조 정보(importEntries)와 모듈맵, 다른 모듈의 모듈 환경 레코드의 Bindings이용

모듈 환경 레코드를 통해 실제 메모리 영역을 참조한다.

엔진은 모듈 하위의 모든 export(모듈이 의존하는 모든 export) 연결을 마무리한다.

그다음 해당 모듈에서 import 한 항목들을 연결하기 위해 수준을 하나 올린다.

(binding를 위한 메모리를 할당 받고 있다가, 실제 객체로 연결)

  • 원래 모듈의 정보는 Module Record에 불변으로
  • 해당 모듈을 인스턴스화한 환경 정보는 Module Environment Record 객체에 가변으로 유지

한 모듈에 대한 export와 import는 같은 메모리의 주소를 가리키는 점을 주목하자.

  • 다른 모듈이 임포트할 때 같은 모듈
  • 해당 모듈이 export하는 것은 해당 모듈 싱글턴

export들을 먼저 연결해서 import들이 모두 각각의 export들에 연결되는 것을 보장한다.

이것은 CommonJS 모듈과 다르다.

CommonJS에서는 전체 export 객체가 내보낼 때 복사된다.

  • 즉, export하는 값(예 : 숫자)은 사본이다.
  • 즉, 나중에 export 하는 모듈이 해당 값을 변경하면, 그 모듈을 import 하는 모듈은 해당 변경 사항을 알 수 없다. 

CommonJS에서는 전체 export 객체가 내보낼 때 복사된다. 즉, export하는 값(예 : 숫자)은 사본이다.

반대로 ES 모듈에서는 라이브 바인딩이라고 하는 것을 사용한다.

두 모듈 모두 메모리의 같은 위치를 가리킨다.

즉, export 한 모듈에서 값을 변경하면 해당 변경 내용이 import 한 모듈에 표시된다.

 

값을 export 하는 모듈은 언제든지 값을 변경할 수 있지만, import 하는 모듈은 가져온 값을 변경할 수 없다.

  • **만일 모듈이 객체를 가져오는 경우에는 해당 객체에 있는 속성값은 변경할 수 있다.

이처럼 라이브 바인딩을 사용하는 이유는 코드를 실행하지 않고 모든 모듈을 연결할 수 있기 때문이다.

이는 순환 의존성을 가질 때 평가 과정에 도움이 된다.

이 단계가 끝나면 export/import 한 변수에 대한 모든 인스턴스와 메모리 위치가 연결된다.

(아직 변수 실제값, 객체 인스턴스화 안했다!)

이제 메모리 주소에 각각의 값을 채우기 위해 코드를 평가할 차례이다.

평가

마지막 단계는 이 메모리 상자들을 채우는 것이다.

이를 평가라고 하며,

JS 엔진은 함수 외부 코드인 최상위 레벨 코드를 실행하여 평가를 수행한다.

메모리의 상자들을 채우는 것 외에도 코드를 평가하면 부작용이 발생할 수 있다.

예를 들면, 평가 도중에 모듈이 서버에 무언가를 요청할 수도 있다.

JS 엔진은 함수 외부 코드인 최상위 레벨 코드를 실행하여 평가를 수행한다.

혹시나 생길 수 있는 부작용 가능성 때문에 모듈은 한 번만 평가하도록 한다.

  • 평가는 수행한 횟수에 따라 다른 결과를 가질 수 있다.
    • 인스턴스 화에서 생성되는 연결이 여러 번 수행하더라도 정확히 같은 것과는 대조적이다.

이것이 모듈 맵을 사용하는 한 가지 이유이다.

모듈 맵은 표준 URL로 모듈을 캐시 하므로 각 모듈에 대해 하나의 모듈 레코드만 있다.

이렇게 하면 각 모듈이 한 번만 실행된다. 인스턴스 화와 마찬가지로 깊이 우선 탐색 순회로 수행된다.

 

순환 참조 문제(circular reference problem)

순환이 있는 의존성에서는 그래프에 루프가 생기고 일반적으로는 긴 루프이다.

문제점을 설명하기 위해 짧은 루프로 만들어진 예제를 보자.

순환이 있는 의존성 그래프

CommonJS 모듈의 동작

1. 먼저 main 모듈은 require 문까지 실행한다.

2. 그 다음 counter 모듈을 로드한다.

3. 그런 다음 counter 모듈은 export 객체의 message에 접근을 시도한다.

4. 그러나 이것이 아직 main 모듈에서 평가되지 않았으므로, 이것은 undefined를 반환할 것이다.

5. JS 엔진은 지역 변수에 대한 메모리 공간을 할당하고 값을 undefined로 설정한다.

5. JS 엔진은 지역 변수에 대한 메모리 공간을 할당하고 값을 undefined로 설정한다.

6. 평가는 counter 모듈의 최상위 레벨 코드 끝까지 계속된다.

  • 우리가 확인하고 싶은 것은 마지막에 message에 올바른 값이 저장되는지(main.js가 평가된 후에) 이다.
  • 그러므로 우리는 setTimeout() 코드를 추가한다.
  • 그리고 main.js의 평가는 계속된다.

7. message 변수가 초기화되어 메모리에 추가된다. 하지만 둘 사이에 아무런 연결이 없으므로 불러온 모듈에서는 undefined 상태 그대로다.

  • 왜일까?
    • 연결이 없기 때문
    • 복사했기 때문

라이브 바인딩을 사용하여 export 된 경우 counter 모듈은 결국 올바른 값을 보게 된다.

setTimeout()이 실행되기 전에 main.js의 평가가 완료되어 메모리 상자에 값을 채운다.

ES 모듈이 이렇게 설계된 가장 큰 이유는 순환 의존성을 지원하기 위해서라고 할 수 있다.

이러한 순환 의존성 지원은 앞서 얘기한 3단계 설계를 통해 가능해졌다.

circular reference, 과연 문제가 없을까?

위와 같은 경우 CommonJS는 버그를 숨기며 넘어간다.

 

하지만 아래 글이 다루는 경우와 같이 클래스를 사용하는 경우는, esm을 사용하나 CommonJS를 사용하나 동일하게 오류가 발생한다.

아래 예제는 esm을 사용하며, 나는 CJS로 변경해서 테스트해 보았다.

https://www.rinae.dev/posts/fix-circular-dependency-kr

 

[번역] 자바스크립트 & 타입스크립트의 순환 참조를 한방에 해결하는 방법

리내(안도형)의 개발블로그입니다. 좋은 기술 문서 번역을 주력으로 삼고 있습니다. 또한 개인적으로 공부한 내용이나, 모아두었던 글을 공유하고 있습니다.

www.rinae.dev

이 문제를 해결하는 방법은 다음과 같다.

1. 임포트 순서를 명시적으로 정의하는 모듈을 선언하여 해당 모듈을 통해서만 다른 모듈이 접근할 수 있도록 한다.(인터널 모듈 패턴)

  • index.js
  • 위의 글에서 매우 잘 설명하고 있다.

2. 파일을 잘 쪼갠다.

다른 글들에는 해당 내용이 없더라구... 사실 제일 적절한 방법인것 같은데

리덕스 툴킷 공식문서에서는 덕스 패턴을 사용했을 때의 문제점을 묘사한다.

https://redux-toolkit.js.org/usage/usage-guide#exporting-and-using-slices

덕스 모듈 간 상호 참조

보통 액션은 다양하게 재사용 되기 때문에, 서로의 액션을 참조하게 되기만 하더라도 상호 참조 문제에 봉착한다.

하지만 잘 생각해보면

  • 액션(생성자) 타입은 다양한 리듀서에서 참조할 수 있다.
    • 액션 타입 간에 참조할 이유는 없다.
    • 따라서 액션 타입을 분리하면 리듀서 -> 액션 단방향 참조만 생성된다.
  • 구체적인 리듀서를 임포트 할 필요가 있는가?
    • 인터페이스, API는 일반적일 수록 좋다.
    • rootReducer를 임포트하면 reducer 간에 임포트 할 필요가 없다.

의존성을 잘 나누어서 상호 참조 제거

물론 은탄환은 없다. 상황에 따라 적절한 도구를 사용하자.

 

다음번에는 HMR과 웹팩, 롤업의 로더, 트리 셰이킹에 대해 알아볼 예정이다.

 

반응형