개발자 이민재입니다.

POSTS

[Nodejs] CommonJS에서 의존성 올바르게 주입하기

8min read

Background

    최근에 회사 API 서버 코드들에 여러 변화를 적용했습니다. 라우터에 모든 로직을 구현하는 기존 방식 대신에, 레이어로 분리된 객체들을 중심으로 로직을 처리하도록 했습니다. 흔히 Controller, Service, Repository로 레이어를 나눠서 개발하는 그 구조로 변경한 것입니다. 이는 Java, Spring에서 개발하던 방식에 익숙한 것도 있었지만, 더 큰 이유는 레이어로 분리했을 때 단위테스트가 더 용이했고, 역할과 책임을 분리해 더 변경하기 쉬운 컴포넌트를 만들 수 있었기 때문입니다. 다만 기존 코드가 Express 프레임워크에 의존하고 있던 터라, 컨트롤러 레이어는 크게 바꾸지 못했고, 서비스 레이어만 약식으로 먼저 도입했습니다. (컨트롤러 레이어는 데코레이터 패턴을 적용해 wrapping 모듈을 하나 씌워서 상세구현 하나를 숨기는 정도로 멈췄습니다.)
    이 과정에서 계속 해결하지 못한 문제가 발생했습니다. import한 객체가 계속 undefined로 평가되는 것이었습니다. 이를 해결하기 위해 CommonJS에서 모듈이 로딩되는 방식부터 다시 공부해야했습니다.

문제상황

    제가 맞닥뜨렸던 문제를 단순화한 버전입니다. 일단 진입점(index.js)에서 웹서버 컨테이너 역할인 application context를 호출합니다. Express 프레임워크라면, application context는 Application 객체에 해당합니다. 일반적으로 const app = express()로 호출해서 각종 configuration을 넣는 부분이라고 생각하면 됩니다. 일단 이 프로그램은 모든 컴포넌트를 불러와서, 클라이언트 역할을 하는 클래스의 funcA 메서드만 호출하고 종료합니다.

// index.js
const { ApplicationContext } = require("./app");

ApplicationContext();

// app.js - entry point of application
console.log("application launch");
const instances = require("./module"); // load all component

function ApplicationContext() {
  const clientInstance = instances.client;
  console.log(clientInstance.funcA());
}

module.exports = { ApplicationContext };

    여기서 서버의 building block인 컴포넌트를 불러오는 부분을 살펴보겠습니다. module.js는 컴포넌트들을 인스턴스(싱글톤 객체 역할)로 만들어주는 역할을 합니다. client는 server의 메서드를 사용합니다. 즉, client는 server에 의존합니다.

// module.js
const Server = require("./server");
console.log("module.js load server class :", Server);
const Client = require("./client");

const server = new Server();
const client = new Client();

module.exports = {
  client,
  server,
};

// server.js
class Server {
  serverField;
  constructor() {
    console.log("server instantiate");
    this.serverField = 1;
  }

  funcB() {
    return this.serverField;
  }
}

module.exports = Server;

// client.js
const { server } = require("./module");
console.log("client load server instance :", server);
class Client {
  constructor() {
    console.log("client instantiate");
  }
  funcA() {
    return server.funcB();
  }
}

module.exports = Client;

    여기서 출력 결과는 다음과 같습니다.

application launch
modules loaded
module.js load server class : [class Server]
client load server instance : undefined
server instantiate
client instantiate
/project-path/client.js:8
    return Server.funcB();
                  ^

TypeError: Cannot read properties of undefined (reading 'funcB')
    at Client.funcA (/project-path/client.js:8:19)
    at ApplicationContext (/project-path/module-test/app.js:7:29)
    at Object.<anonymous> (/project-path/module-test/index.js:3:1)
    at Module._compile (node:internal/modules/cjs/loader:1364:14)
    at Module._extensions..js (node:internal/modules/cjs/loader:1422:10)
    at Module.load (node:internal/modules/cjs/loader:1203:32)
    at Module._load (node:internal/modules/cjs/loader:1019:12)
    at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:128:12)
    at node:internal/main/run_main_module:28:49

    테스트 결과, 클라이언트가 module.js에서 직접 로드한 서버 인스턴스는 로드한 시점에 undefined입니다. 이는 module.js와 client.js가 순환의존 관계이기 때문입니다. 정확히 말하면, commonjs에서 순환의존성을 처리하는 방식 때문입니다. 즉, module은 client 인스턴스를 만들고, client는 module의 서버 인스턴스를 참조합니다. 그런데 module.js가 export하는 서버 인스턴스는 아직 module.js 스크립트에서 인스턴스가 되기 전입니다. 따라서 client.js는 undefined에서 함수를 호출하므로 오류가 발생하는 것입니다.

해결책

    이런 순환의존성에 의한 문제는 에러 발생 시점에 원인을 찾기 힘들고, 에러 발생 전에도 조심하지 않으면 다시 발생할 수 있기 때문에 해결 방식을 다각도로 접근해야 합니다.

  1. 현재 코드베이스에서 순환의존성 부분을 발견
  2. 1에서 발견된 순환의존성을 해소
  3. 개발팀에서 순환의존성이 발생하지 않도록 예방

먼저 모듈 간 순환의존성이 발생하는 부분을 찾으려면 madge 패키지같은 모듈의존성 시각화 도구를 사용해주면 좋습니다. 아래 명령어로 magde를 실행해주면 순환의존성을 찾아줍니다.

$ npx madge --circular [path]
## typescript인 경우
$ npx madge --ts-config ./tsconfig.json --circular [path]

    순환 의존성을 해소하는 방법 중 하나는, 두 의존성이 클래스인 경우 생성자로 의존성을 주입시키는 것입니다.

// module.js
console.log("modules loaded");
const Server = require("./server");
const Client = require("./client");
const server = new Server();
// dependency injection
const client = new Client(server);
module.exports = {
  server,
  client
};

// server.js
class Server {
  serverField;
  constructor() {
    console.log("server instantiate");
    this.serverField = 1;
  }

  funcB() {
    return this.serverField;
  }
}

module.exports = Server;

// client.js
// const { server } = require("./module");
// console.log("client load server instance :", server);
class Client {
  server;
  constructor(server) {
    console.log("client instantiate");
    this.server=server;
  }

  funcA() {
    return this.server.funcB();
  }
}

module.exports = Client;

    생성자로 서버 인스턴스를 할당할 것이므로, Client 클래스는 직접적으로 서버 인스턴스를 불러오지 않습니다. 생성자에만 의존성을 명시하면 됩니다. 만약 TypeScript라면 생성자에 타입을 명시해주어야 할텐데, 이 때 불러오는 서버 클래스 타입은 server.ts에서 불러오게 될 것입니다. 그러므로 타입을 명시하더라도 module.ts와 client.ts의 순환의존성은 없습니다.     사실 가장 바람직한 해결책은 스프링같은 DI 컨테이너를 사용하는 것입니다. 매번 개발자가 이걸 신경쓰면서 의존성을 관리하면 레이어를 분리하는 것이 더 큰 비용이기 때문입니다. JS 쪽에서는 nest.js를 사용하면 됩니다.

References

commonjs 내부 동작 분석하신 글