편리한 Node.js 플랫폼

한 가지 언어를 넘어서 여러가지 언어 플랫폼을 경험하다 보면, 각각의 언어 플랫폼이 지닌 특징과 장단점을 알 수 있습니다. 저는 Java를 위주로, JVM 플랫폼을 공유하는 여러 언어 사용해서 개발을 했습니다. 그리고 서브로 자바스크립트를 사용해서 주로 웹 애플리케이션을 만들었습니다.

사실 전에는 자바스크립트로 무언가 대단한 프로그램을 만들 수 있겠다는 생각을 하지 못했습니다. 적어도 Node.js 런타임과 npm 패키지 매니저를 알기 전까지는 그랬습니다. 처음 사용할 당시 JavaScript의 스펙은 많이 아쉬운 ES5였고, 가장 많은 변화가 있었던 ES2015를 거쳐 현재는 ES2019 스펙까지 발전했습니다. 예전의 부족한 부분을 빠르게 개선해가고 있어서, 수년 후에는 지금보다 더 좋은 환경에서 개발할 수 있을 것 같습니다.

Java와 비교했을 때, 자바스크립트에서는 코드를 수정하고 실행하고 평가하는 사이클이 빠릅니다. 저는 페이스북에서 만든 jest 테스트 프레임워크를 watch 모드로 실행해놓고 테스트를 만들면서 코딩을 합니다. 빨리 실행해보고 고치고 확인할 수 있어서 시원시원한 맛이 있습니다.

부족한 모듈 시스템

한편 무스마에서는 TypeScript를 도입해서 정적 타입시스템과 안전성을 확보할 수 있었습니다.

[링크] TypeScript 사용합시다!

그러나 다른 부분에서는, 지금까지 많은 개선이 이루어졌음에도 아직까지 Java와 비교해서 불편한 점이 몇가지 있습니다.

│
├─ project-a
│   ├─ src
│   │   └─ ...
│   └─ package.json
│
├─ shared
│   ├─ src
│   │   └─ ...
│   └─ package.json
│
└─ project-b
    ├─ src
    │   └─ ...
    └─ package.json

위와 같이 하나의 저장소 아래 여러 개의 프로젝트가 있는 multi-project 구조이면서, 프로젝트 간에 의존하는 때가 있습니다.

Java에는 classpath와 package 논리 구조가 있습니다. JVM을 실행할 때 classpath를 지정해주면(현재 프로젝트의 소스코드 디렉터리, 다른 프로젝트의 소스코드 디렉터리 혹은 *.class 파일이 있는 빌드 결과 디렉터리, jar 파일 경로), ClassLoader가 모든 클래스를 package에 논리적으로 맵핑해줍니다.

그래서 위치에 상관없이 package를 통해 접근해서 사용할 수 있습니다.

Node의 require()나 ES6의 import 구문이 등장하기 전에는 그나마 다른 모듈을 참조하는 방법조차 없었으니, 사실 지금 정도로도 ES5시절에 비하면 감지덕지입니다.

널리 알려진 방법이 없기 때문에 그냥 하나의 프로젝트에 모든 것을 다 집어넣고 개발을 하든지, 별도의 패키지를 npm에 올려서 dependencies로 등록하든지 해야 했습니다.

저는 이 부분을 해결하고 싶었고, 다음의 방법들을 시도했습니다.

Try: 무식한(?) 상대 참조

예를 들어, 프로젝트 project-a/src/components/SomeComponent에서 프로젝트 shared/src/models/User 모듈을 참조하려고 합니다.

이렇게 하면 될까요?

import { User } from '../../shared/src/models/User'

혹은

const User = require('../../shared/src/models/User')

(치~직) 안 된다.

  • 아… 뭔가 아름답지가 않습니다.
  • tsconfig.json에서 오류라고 뜹니다. tsc로 빌드가 안 됩니다.

Try: npm 패키지로 등록해서 사용하기

npm 저장소에 올려서 의존 패키지로 참조하는 방법입니다.

이 방법에는 아쉬운 점이 있습니다.

  • npm private 저장소는 유료다.
  • github repository를 npm 저장소로 사용할 수 있지만, 위와 같은 멀티 프로젝트 구조에는 적절하지 않다.
  • 사설 npm 저장소: 귀찮고 번거롭다.

귀찮고 번거롭다!

Succeed!: yarn workspaces

Node 플랫폼에는 이름 그대로 Node Package Manager인 Node와 같이 제공되는 npm이 있고, 또 Facebook에서 만든 yarn이라는 도구가 있습니다.

둘은 같은 기능을 합니다만, 저는 예전부터 npm 대신에 yarn을 사용하고 있습니다.

그 이유는 아래와 같습니다.

  • 패키지 다운로드 속도가 더 빠르다.
  • 기능이 더 많다. (그리고 기능이 더 빨리 추가된다.)

요즘에는 yarn 또한 거의 Node 빌트인으로 취급해줍니다. 예를 들어 Node 도커 이미지에서 아예 yarn도 포함시켜서 제공합니다.

말씀드렸다시피, npm보다 yarn이 더 잘 관리되고 업데이트가 빠릅니다.

yarn에는 있고 npm에는 없는 기능 중에, workspaces가 있습니다.

Workspaces

이클립스 IDE에서 여러 프로젝트들이 모여 있는 공간을 작업 공간, 즉 워크스페이스라고 부릅니다. yarn에서 말하는 workspace도 같은 뜻입니다.

이 워크스페이스에 있는 프로젝트들은 서로 참조하는 연관 관계를 가질 수 있습니다.

워크스페이스를 만드는 방법은 이렇습니다. 저장소 루트에 package.json 파일을 만들고 아래의 내용을 입력합니다.

{
  "private": true,
  "workspaces": [
    "project-a",
    "project-b",
    "shared"
  ]
}

보통의 package.json 파일에 비해 내용이 없습니다. 대신에 "workspaces"라는 속성이 들어가고, 여기에 프로젝트 이름(각각의 프로젝트의 package.json에 정의된 name)을 나열합니다.

yarn은 이제 저장소를 워크스페이스로 인식하게 됩니다.

  • 각각의 프로젝트는 마치 npm 패키지의 로컬 사본처럼 저장소 루트의 node_modules에 저장됩니다.
  • 각각의 프로젝트별로 node_modules, yarn.lock 등의 파일이 생성되는 대신에, 저장소 루트에 하나만 생성됩니다.

Before

│
├─ project-a
│   ├─ node_modules
│   ├─ yarn.lock
│   └─ package.json
│
├─ shared
│   ├─ node_modules
│   ├─ yarn.lock
│   └─ package.json
│
└─ project-b
    ├─ node_modules
    ├─ yarn.lock
    └─ package.json

After

│
├─ project-a
│   └─ package.json
│
├─ shared
│   └─ package.json
│
├─ shared
│   └─ package.json
│
├─ node_modules
├─ yarn.lock
└─ package.json

단, 일부 devDependencies에서 사용하는 바이너리 때문에 각 프로젝트 별로 모듈이 몇 개 들어간 node_modules 디렉터리가 생길 수 있습니다.

이제 yarn은 워크스페이스 내의 패키지와 프로젝트를 통합 관리합니다.

yarn install을 실행하면 워크스페이스 전체에 대해서 패키지를 설치하고 yarn.lock 파일을 갱신합니다.

그리고 워크스페이스 아래 프로젝트들은 로컬 npm 패키지 처럼 인식됩니다.

즉 프로젝트 project-apackage.json 파일에 아래와 같이 추가하면 프로젝트에 대한 참조가 됩니다.

{
  "name": "project-a",
  ...,
  "dependencies": {
    ...,
    "shared": "^x.y.z",
    ...,
  },
  ...
}

언제 유용합니까?

멀티 프로젝트를 구성해서 프로젝트 간에 의존 참조를 하면 아래와 같은 이점이 있습니다.

  • 도메인 모델 클래스, 인터페이스, 메서드를 정의한 모듈을 공유 프로젝트에 작성해서 frontend, backend 프로젝트에 공유해서 사용한다.
  • 여러 프로젝트에서 공통 사용하는 유틸리티를 별도의 공유 프로젝트에 작성하고, 참조해서 사용한다.

한계점

모양은 비슷하게 흉내를 냈지만, 그럼에도 불구하고 JVM 패키지 시스템에 비해서 부족한 점이 아직 많습니다.

예를 들면, 같은 프로젝트 내에서는 디렉터리를 기준으로 상대 참조를 해서 갈 수 있지만, node_modules에 들어가 있는 패키지를 참조할 때는 규칙이 있습니다.

└─ node_modules
    ├─ ...
    └─ shared
        ├─ dist
        ├─ src
        └─ package.json

위에서 shared 프로젝트의 빌드 결과가 dist/ 디렉터리 아래에 있으므로, 아래와 같이 참조 가능합니다.

import { User } from 'shared/dist/models/User'

만약 sharedpackage.json 파일에 "main" 속성을 지정하면 아래와 같이도 가능합니다.

{
  "name": "shared",
  ...,
  "main": "dist/index.js",
  ...
}

단, 이때 dist/index.js에 shared의 모든 요소를 export 해야합니다.

예를 들어 index.ts 파일이 다음과 같다면,

export { User } from 'models/User'
...

다른 프로젝트에서 이렇게 참조할 수 있습니다.

import { User } from 'shared'

이 방법은 index.ts 파일에 모든 하위 모듈을 export해야 하기 때문에 귀찮습니다.

제 생각에는 그냥 아래와 같이 하는 게 낫습니다.

import { User } from 'shared/dist/models/User'

편하게 합시다. 편하게

결론

  • 무조건 한 프로젝트에 다 넣을 필요 없으니 적절히 여러 프로젝트로 분리해서 개발한다.
  • Node 플랫폼에 JVM 수준의 패키지 참조 시스템이 나오기 전에는 yarn workspaces로 대신한다.

도움이 되셨다면, 하트를 찍고 댓글을 남겨주시고, SNS에 공유해주세요!

References