GraphQL이란 것은 대부분 들어보셨을 것으로 생각합니다. 그리고 페이스북이 만들었다는 것 정도는 아실 것 같습니다. 근데 여기서 말하는 GraphQL이란 뭘까요?

GraphQL 자체는 데이터 query를 어떻게 할지만 정해놓았습니다.

type User {
  id: ID!
  name: String!
}

type Query {
  user(id: ID!): User!
}

와 같이 정의된 스키마에

query {
  user(id: "10") {
    id
    name
  }
}

와 같이 질의하면

{
  "data": {
    "user": {
      "id": "10",
      "name": "Simon"
    }
  }
}

라는 결과만 내놓으면 됩니다.

하지만 개념이 간단하다고 그것을 동작하도록 구현하는 것까지 간단한 것은 아닙니다. GraphQL을 실제 제품에 적용하기까지는 많은 것들을 이해해야 합니다. 이에 대해 차례로 설명해보려고 합니다. 첫번째로 다뤄볼 내용은 스키마 정의입니다.

언어나 구현체 별로 세부 내용이 다를 수도 있습니다. 따라서 앞으로 설명할 내용은 주로 참조 구현인 GraphQL.js에 대한 내용이 됩니다. 여기에 더해서 대중적으로 많이 쓰이는 Java 계열의 라이브러리(graphql-java, DGS Framework등)를 일부 포함합니다. 다른 언어에서 사용하는 다른 라이브러리는 다른 생김새를 가지고 있을 수 있겠지만, 개념이 크게 다르지 않을 것으로 생각합니다.

날(raw) 객체를 사용해서 정의하기

GraphQL 스키마는 GraphQLSchema 클래스의 인스턴스입니다. 이런 클래스들을 이용해 직접 정의할 수 있습니다. GraphQL.js의 코드를 가져와 보겠습니다.

import { GraphQLObjectType, GraphQLSchema, GraphQLString, printSchema } from 'graphql';

const schema = new GraphQLSchema({
  query: new GraphQLObjectType({
    name: 'RootQueryType',
    fields: {
      hello: {
        type: GraphQLString,
      },
    },
  }),
});

console.log(printSchema(schema));

위 코드를 실행하면 다음과 같은 스키마가 만들어지는 것을 볼 수 있습니다.

schema {
  query: RootQueryType
}

type RootQueryType {
  hello: String
}

RootQueryType를 Query로 바꾸면 좀 더 익숙한 스키마가 만들어집니다.

type Query {
  hello: String
}

커스텀 타입도 추가할 수 있습니다.

import { GraphQLList, GraphQLNonNull, GraphQLObjectType, GraphQLSchema, GraphQLString, printSchema } from 'graphql';

const User = new GraphQLObjectType({
  name: 'User',
  fields: {
    name: {
      type: new GraphQLNonNull(GraphQLString),
    },
  },
});

const schema = new GraphQLSchema({
  query: new GraphQLObjectType({
    name: 'Query',
    fields: {
      users: {
        type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(User))),
      },
    },
  }),
});

console.log(printSchema(schema));

위 코드는 다음과 같은 스키마를 정의한 것입니다.

type Query {
  users: [User!]!
}

type User {
  name: String!
}

JVM 계열에서 기반이 되는 graphql-java는 이 형태의 스키마 생성을 지원합니다.

val userType = GraphQLObjectType.newObject()
    .name("User")
    .field(
        GraphQLFieldDefinition.newFieldDefinition()
            .name("name")
            .type(GraphQLNonNull(GraphQLString))
    )
    .build()

val schema = GraphQLSchema.newSchema()
    .query(
        GraphQLObjectType.newObject()
            .name("Query")
            .field(
                GraphQLFieldDefinition.newFieldDefinition()
                    .name("users")
                    .type(GraphQLNonNull(GraphQLList(GraphQLNonNull(userType))))
            )
    ).build()

println(SchemaPrinter().print(schema))

이 방식은 간단한 스키마를 정의하는데도 많은 노력이 들고 틀릴 가능성도 높습니다. (다만 리졸버를 정의시 같이 포함할 수 있다는 장점은 있습니다.) 그래서 GraphQL 도입 초기에 예제만 보고 무조건 이렇게 해야 하는 것으로 알고 있을 때 잠깐만 사용했고, 현재는 이렇게 정의하지 않습니다. 다만 내부에서는 이 형태이기 때문에 이해하고 있으면 GraphQL 실행 최적화를 할 때 도움이 됩니다.

스키마 정의 문자열에서 스키마 생성하기

날 객체를 쓰는 것보다 좀 더 나은 방법은 스키마 정의 문자열에서 스키마를 생성하는 것입니다. buildSchema 함수를 사용하면 됩니다.

import { buildSchema } from 'graphql';

const schema = buildSchema(`
type User {
  name: String!
}

type Query {
  users: [User!]!
}
`);

이 방식은 스키마 우선(schema-first) 접근으로 불립니다. graphql-java도 이 방식을 지원하고, DGS도 이 방식을 사용합니다. 이렇게 생성한 스키마에 리졸버는 따로 붙여줘야 합니다.

다른 방식으로 graphql-tag 모듈의 gql 태그를 쓰는 방법이 있습니다. 다만 이 태그는 parse 함수를 써서 GraphQLSchema 객체가 아닌 DocumentNode 객체를 만들어 내기 때문에, makeExecutableSchema를 사용해 스키마 객체로 변환할 필요가 있습니다. 이 방식은 IDE에서 문법 강조가 되는 장점이 있었지만, 최근에는 gql 태그가 아니더라도 문법 강조가 되는 것으로 알고 있습니다. 그리고 저희는 현재 GraphQL 질의를 .graphql 파일로 만들어 문법 강조를 받고 있기 때문에 이 방식은 사용하지 않습니다. (오래전에 시도해서 아직 일부 코드에 흔적이 남아있습니다.)

코드에서 스키마 생성하기

또 다른 방법으로는 코드에서 스키마를 유도해 내는 것입니다. 이 방법은 기본 라이브러리에서 지원하지 않고 TypeGraphQL이라는 다른 라이브러리를 사용합니다.

import 'reflect-metadata';
import { buildSchemaSync, ObjectType, Field, Resolver, Query } from 'type-graphql';

@ObjectType()
class User {
  @Field(() => String)
  name: string;
}

@Resolver()
class UserResolver {
  @Query(() => [User])
  users(): User[] {
    return [];
  }
}

const schema = buildSchemaSync({
  resolvers: [UserResolver],
});

이 방식은 코드 우선(code-first) 접근으로 불립니다. GraphQL Kotlin도 이 방식을 사용합니다.

리졸버 구현시 결국 코드로 된 모델(클래스)이 필요한데, 이 모델을 스키마 정의에 바로 사용할 수 있다는 장점이 있습니다. 그리고 같은 모델을 데이터베이스 테이블 정의에도 사용할 수 있다는 것(TypeORM이나 CORMO를 사용해서)이 좋아보여서 한때 전체에 적용했었습니다.

하지만 막상 시간이 지나니 거슬리는 점들이 꽤 나왔습니다.

  • GraphQL 모델(타입)과 데이터베이스 테이블이 미묘하게 달라서 한 클래스로 양쪽을 모두 지원하는게 어색한 경우가 많았습니다.
  • 타 서비스에서 GraphQL API를 이용할 때, 서비스가 제공하는 GraphQL API(스키마)를 한눈에 보고 싶을 때가 있는데 코드와 스키마가 섞여서 전체를 한눈에 보기 어렵습니다.
  • 원하는 스키마를 정의하기 위해 TypeGraphQL의 방식을 따로 배워야 합니다. 예를 들어 TypeScript 타입은 User[]로 쓰는데 타입 정의는 [User]로 해야 합니다. [User], [User!], [User]!, [User!]! 구분은 nullable 옵션으로 하는데 직관성이 아무래도 떨어진다고 보입니다.
  • TypeGraphQL로 안 되는 부분이 있었습니다. 당시에 enum의 주석을 추가하는게 불가능했고, 현재는 가능해졌지만 TypeScript의 한계로 자연스럽지 않습니다. (decorator를 사용하지 못하고, registerEnumType의 옵션으로 기술해야 합니다.)

그래서 현재는 스키마 우선 접근을 사용하는 것으로 바뀌었습니다. 스키마 타입과 코드 클래스를 각기 정의해야 하는 단점도 GraphQL Code Generator 같은 코드 생성기를 사용하면 어느 정도 해소가 됩니다.

반복해서 실수가 발생해서 수많은 사람들의 시간을 낭비하는데, 사실 시스템 개선에 조금만 시간을 썼으면 발생하지 않았을, 그런 문제들이 있습니다. 이런 상황을 알면서도 바쁘다는 핑계로 넘어가곤 하는데, 이번에 1년 이상 머리 한 구석에만 뒀던 이슈를 해결해 공유해볼까 합니다.

문제

카카오스타일 정도의 규모에서는 당연히 어떤 기능을 프로덕션에 바로 배포하지 않습니다. 먼저 테스트 서버에 배포해 어느 정도 검증을 하고 프로덕션에 반영하게 됩니다. (물론 아무리 이런 과정을 거쳐도 문제는 발생하긴 합니다.) 카카오스타일은 스테이징 서버란 용어는 사용하지 않고, 알파란 용어를 사용하고 있습니다. 알파용 환경은 프로덕션 환경과 동일한 구조를 가지고 있지만, DB는 별개로 존재하는 환경입니다. (추가로 베타는 어플리케이션은 별도이지만, DB는 프로덕션을 사용하는 환경을 의미합니다. 피쳐 플래그 대신 주로 베타 환경을 이용해 최종 검증을 합니다.)

알파 환경은 하나이기 때문에 여러 팀이 동시에 작업하는 상황에서 충돌이 발생하곤 합니다. 어떤 기능을 알파에 배포해서 확인하던 도중에 다른 팀이 작업 덮어 씌워서 혼란이 발생하는데, 이를 완전히 막기는 어렵습니다. 이 부분은 일정 시간동안 알파 환경을 점유하겠다는 의사 표현을 하거나(아직 시스템화까진 생각하고 있지 않습니다), 알파 전 단계인 팀별 개발 환경을 만들어 해결하고 있습니다.

문제는 테스트도 끝나 정식으로 프로덕션까지 반영된 기능이 알파 환경에서 사라지는 케이스가 꽤 높은 비율로 발생한다는 점이였습니다. 오래전의 코드에서 브랜치를 만들어 내 기능을 작업한 이후에 최신 수정 사항을 제대로 반영하지 않은 채 알파에 배포를 하는 거죠. 나는 내 기능을 배포했을 뿐인데, 무관해 보이는 기능이 갑자기 동작하지 않으면서 이를 해결하기 위해 많은 사람들이 시간을 낭비하는 일이 발생하곤 했습니다.

해결책은 단순합니다. 정식으로 반영된 기능을 되돌리는 배포를 할 수 없게 막으면 됩니다. 다만 그동안 기술적 해결책을 찾지 못해, 규칙으로 정했지만(알파 배포시에는 main 브랜치를 포함해서 주세요!) 당연히 문제는 계속 발생했습니다.

작업 내용

카카오스타일도 당연히 배포 시스템이 갖춰져 있습니다. 명령을 내리면 GitHub에서 소스를 가져와 도커 이미지를 빌드하게 됩니다. 이때 브랜치도 같이 지정하게 됩니다. (프로덕션 배포시에는 브랜치가 고정 - 팀에 따라 master, main, release등을 사용 - 되어 있습니다.)

모든 소스를 가져오는 것은 오래 걸리기 때문에 얕은 복제(shallow clone)을 사용합니다.

$ git clone git@github.com:croquiscom/$SERVICE -b $BRANCH --depth 1 --jobs 2

이런 상황에서 배포하려는 브랜치가 주 브랜치(master, main)의 내용을 포함하고 있는지를 검사하는것이 이번 문제의 기술적인 이슈였습니다.

처음에는 복제(clone) 전에 브랜치 비교하는 방법을 찾아봤습니다. 하지만 아쉽게도 git 명령 중에 원격 저장소에서 동작하는 건 ls-remote 밖에 없는 것 같습니다.

로컬 저장소에서 브랜치 비교는 가능하지만, 문제는 얕은 복제여서 브랜치간 비교가 불가능하다는 것이였습니다. 전체 복제 대신 처음 고민한 옵션은 깊이를 늘리는 것이였습니다(--depth 또는 --shallow-since). 복제시 지정한 브랜치만 받아졌기 때문에 --no-single-branch 옵션도 필요합니다. 다만 아무리 적당히 큰 값을 준다고해도 배포할 브랜치와 주 브랜치의 공통 조상이 복제에 포함될지 여부를 보장할 수 없는 문제가 있습니다.

그래도 얕은 복제 후 필요한 만큼 커밋을 더 받는 옵션을 찾아봤는데 deepen 옵션을 알게 됐습니다. 여기에 더해 공통 조상이 발견될 때까지 원격에서 커밋을 가져오는 스크립트를 스택오버플로우에서 발견하게 됩니다.

while [ -z $( git merge-base master feature ) ]; do
  git fetch -q --deepen=1 origin master feature;
done

위 스크립트가 동작하려면 master 브랜치도 로컬에 존재해야 하는데 --no-single-branch 옵션 대신 master 브랜치만 추가로 가져오는 건 fetch-through-merge-base라는 기능의 코드에서 찾았습니다.

git fetch --depth=1 origin "+refs/heads/$BASE_REF:refs/heads/$BASE_REF"

이를 조합해 이미지 빌드 서버에 최종적으로 적용된 스크립트는 다음과 같습니다. (배포할 브랜치가 주 브랜치와 같은 경우에도 동작합니다.)

git clone git@github.com:croquiscom/$SERVICE -b $BRANCH --depth 1 --jobs 2
cd $SERVICE
git fetch --depth 1 origin "+refs/heads/$BASE_BRANCH:refs/heads/$BASE_BRANCH" || echo 'same branch'
while [ -z "$( git merge-base "$BASE_BRANCH" "$BRANCH" )" ]; do
  git fetch -q --deepen=20 origin "$BASE_BRANCH" "$BRANCH"
done
if [[ "$(git rev-list --left-only --count $BASE_BRANCH...$BRANCH)" != "0" ]]; then
  echo "$BASE_BRANCH is not merged"
  exit 1
fi

rev-list 명령에서 --count 옵션을 사용하면 브랜치가 얼마나 앞서 있는지 수치로 알 수 있습니다. 주 브랜치(left)가 0 만큼 앞서 있다면 배포하려는 브랜치가 주 브랜치의 내용을 모두 포함하고 있다는 뜻이 됩니다.

한편..

초기에 구축한 배포 시스템은 AWS의 CodeBuild를 사용하고 있습니다. 한편 근래에 작업중인 서비스에서는 GitHub Actions를 사용중입니다. 위 방법은 GitHub Actions에도 역시 적용 가능하지만, GitHub API를 쓰면 복제전에 검사가 가능합니다. (사실 이쪽을 먼저 해결했다 보니, git만 가지고 원격에서 가능한 방식이 있나 한참 찾아봤습니다.)

- uses: actions/github-script@v6
  with:
    script: |
      const result = await github.rest.repos.compareCommitsWithBasehead({
        owner: context.repo.owner,
        repo: context.repo.repo,
        basehead: `master...${context.sha}`,
      });
      if (result.data.behind_by === 0) {
        return;
      }
      throw new Error('master not merged');      

결론

몇 줄 안 되는 코드지만, 일반적으로 겪는 문제가 아니다보니 잠깐 검색해본 것으로는 잘 나오지 않는 내용이였습니다. 그러다보니 해결책을 찾는게 많이 미뤄진 것 같습니다. 하지만 마음 먹고 작업하니 하루에 끝날 정도의 분량이였고, 이게 적용되면 많은 시간 낭비를 없앨 것으로 기대됩니다.

이번에 작업한 내용과 유사하게 실수를 방지 하기 위한 조치를 하는 것을 포카 요케라고 부릅니다. 카카오스타일에서는 실수를 반복하지 않게 하기 위해 포카 요케 목록을 만들어 해결하는데 최근 많은 시간을 투자하고 있습니다.

ESM 삽질기

09 Apr 2022

저희는 주기적으로 Node.js 모듈을 최신 버전으로 업데이트하고 있습니다. Node.js를 10년째 사용 중인데, CoffeeScript → TypeScript, 콜백 → Async.js → Promise(& async, await) 전환 하면서 몇 번 혼란의 시기가 있었습니다. 하지만 모듈 시스템은 쭉 이어져왔습니다. 그런데 최근에 이 모듈 시스템에 큰 변화가 생겼고 기존 변화와 다르게 양립이 잘 안 되서 모듈 업데이트를 제대로 못 하고 있습니다. 이 문제를 일으킨 ESM이 뭐고 어떤 작업이 필요한지 알아보려고 합니다. (개인적으로 만족하는 깔끔한 해결책이 나오지 못했습니다)

발생하는 문제

chalk 란 모듈이 5.0으로 가면서 Pure ESM 모듈이 됐습니다. 이를 가져다 쓰면 다음과 같은 에러가 발생합니다.

import chalk from 'chalk';
console.log(chalk.yellow('Hello'));
Error [ERR_REQUIRE_ESM]: require() of ES Module /a/node_modules/chalk/source/index.js from /a/a.ts not supported.
Instead change the require of index.js in /a/a.ts to a dynamic import() which is available in all CommonJS modules.

ESM이란 무엇인가?

초기 JavaScript는 모듈 시스템이 없었습니다. 클라이언트쪽에서는 Require.js란 것이 많이 쓰였습니다. 한편 서버(Node.js)에서는 CommonJS가 적용되어 따로 발전을 했습니다. 라이브러리가 클라이언트, 서버를 모두 지원하기 위한 패턴들이 개발되어 적용됐던 기억이 나네요. 그후 Browserify, webpack 같은 번들 시스템이 나오면서 CommonJS 쪽으로 통일되었습니다.

대부분의 경우 잘 동작하지만 뭔가 문제가 있으시까 새로운 시스템이 나왔겠죠? 상세한 히스토리는 잘 모르지만 제가 아는 CommonJS의 가장 큰 문제는 런타임에 모듈을 읽는 다는 것입니다. 다음 예를 보겠습니다.

// a.js
console.log('a1');
console.log(require('./b').b);
console.log('a2');
exports.a = 1;

// b.js
console.log('b1');
console.log(require('./a').a);
console.log('b2');
exports.b = 2;
$ node a.js 
a1
b1
undefined
b2
2
a2

위의 예에서 보다시피 require는 그 줄에 다다를 때 실행됩니다. 순환 참조가 발생하는 경우(require('./a')) 모듈을 다시 읽지는 않습니다. 이때문에 주의를 기울이지 않으면 위 예처럼 모듈이 내보낸 값이 얻어지지 않는 문제가 발생할 수 있습니다.

TypeScript의 import문은 사실 require로 변환되는 코드이기 때문에 기존 컴파일 언어에 익숙하신 분이 보면 당황할만한 부분이 좀 있습니다.

// a.ts
console.log('a1');
import { b } from './b';
console.log(b);
console.log('a2');
export const a = 1;

// b.ts
console.log('b1');
import { a } from './a';
console.log(a);
console.log('b2');
export const b = 2;
$ ts-node a.ts 
a1
b1
undefined
b2
2
a2

아마 이런 저런 이유 때문에 ESM이란 것이 나왔으리라 생각합니다. (몇년전에 .cjs, .mjs 확장자 얘기가 나오는 글들 보면서 무슨 얘기가 진행되는 거야 라고 넘어간 기억이 있습니다.)

async/await 문법이 나온 이후에 가장 아쉬운 점 중 하나가 최상위 단계에서 await가 불가능하다는 것입니다.

$ cat a.js                                                                                                                                                                         1 ↵
await Promise.resolve(1);
$ node a.js 
/a/a.js:1
await Promise.resolve(1);
^^^^^

SyntaxError: await is only valid in async functions and the top level bodies of modules

따라서 함수를 만들거나 IIFE를 사용해야 했습니다. 이것은 CommonJS의 한계로 인한 것이고 ESM에서는 가능해졌습니다. 이런 이유도 있어서 CommonJS에서 ESM 모듈을 require 하는 것이 불가능합니다. 반대로 ESM 모듈에서 CommonJS 모듈을 읽는 것은 가능하지만 신경써야 할 것들이 있습니다.

이렇게 ESM이 만들어진 후에 Sindre Sorhus란 분이 ESM 전환을 선언했습니다. 그리고 이 분이 만드는 많은 모듈들(예 file-type. npm 개인 홈에 들어가보면 1165개의 모듈에 관여하고 있다고 나오네요)이 Pure ESM 모듈로 전환됐습니다. Pure ESM은 모듈이 CommonJS/ESM 양쪽을 지원하도록 구성할 수도 있지만, 굳이 ESM만 제공한다는 뜻입니다.

문제는 CommonJS 프로젝트에서는 ESM 모듈을 불러오는 것이 불가능하다는 것에 있습니다. 따라서 프로젝트가 ESM으로 전환해야지만 Pure ESM 모듈을 사용할 수 있습니다.

무엇이 문제인가?

TypeScript에서 원인 찾기

위 예제에서 다시 시작하겠습니다

import chalk from 'chalk';
console.log(chalk.yellow('Hello'));

ts-node로 실행해보면 다음과 같은 에러가 발생합니다.

$ ts-node a.ts 
Error [ERR_REQUIRE_ESM]: require() of ES Module /a/node_modules/chalk/source/index.js from /a/a.ts not supported.
Instead change the require of index.js in /a/a.ts to a dynamic import() which is available in all CommonJS modules.

컴파일된 JavaScript 결과물을 보면 원인을 알 수 있습니다.

"use strict";
exports.__esModule = true;
var chalk_1 = require("chalk");
console.log(chalk_1["default"].yellow('Hello'));

ESM 모듈은 require가 아니라 import를 해야 합니다.

JavaScript에서 문제 해결

TypeScript가 아니라 JavaScript로 import 구문을 사용해 작성해 봅니다. 이번에는 다른 에러가 납니다.

$ cat a.js 
import chalk from 'chalk';
console.log(chalk.yellow('Hello'));
$ node a.js
(node:72179) Warning: To load an ES module, set "type": "module" in the package.json or use the .mjs extension.
/a/a.js:1
import chalk from 'chalk';
^^^^^^

SyntaxError: Cannot use import statement outside a module

파일명을 .mjs로 바꾸면 잘 실행됩니다.

$ node a.mjs 
Hello

파일명을 바꾸는 대신 전체 프로젝트를 ESM을 사용하는 것으로 선언할 수 있습니다. package.json에 "type": "module"을 추가합니다.

$ cat a.js 
import chalk from 'chalk';
console.log(chalk.yellow('Hello'));
$ cat package.json 
{
  "type": "module",
  "dependencies": {
    "chalk": "^5.0.1"
  }
}
$ node a.js 
Hello

또는 TypeScript 실행시 보여준 에러 처럼 dynamic import를 사용할 수 있습니다.

$ cat a.js 
(async () => {
  const chalk = await import('chalk');
  console.log(chalk.default.yellow('Hello'));
})();
$ node a.js 
Hello

정리하면 JavaScript에서 Pure ESM 모듈을 읽기 위해서는 다음 세가지 방법이 있습니다.

  • .mjs 확장자 사용
  • 전체 프로젝트를 ESM으로 전환
  • dynamic import 사용

TypeScript로 되돌아 가서

.mts 란 확장자도 인식하고, tsc로 컴파일 해보면 .mjs로 나오긴 하지만 적절한 변환은 안 됩니다.

dynamic import 구문을 사용해도 여전히 실행이 안 됩니다. 컴파일된 결과물을 보면 여전히 require로 변환이 됩니다. 이를 해결하려면 모듈시스템을 ES 것을 사용한다고 선언해야 합니다. tsconfig에서 module 설정을 적절히 해줘야 합니다.

$ cat a.ts 
(async () => {
  const chalk = await import('chalk');
  console.log(chalk.default.yellow('Hello'));
})();
$ cat tsconfig.json 
{
  "compilerOptions": {
    "target": "es2017",
    "module": "es2020",
    "moduleResolution": "node"
  }
}
$ ts-node a.ts 
Hello

import를 require로 변환하지 않고 그대로 두는 건 module 설정이지만, Node.js에서 import 구문을 이해하는 것을 별개입니다. package.json에 "type": "module" 도 추가하면 일반 import 구문도 동작합니다. ts-node로 실행시에는 --esm 옵션을 줘야 동작합니다.

$ cat a.ts 
import chalk from 'chalk';
console.log(chalk.yellow('Hello'));
$ cat package.json 
{
  "type": "module",
  "dependencies": {...}
}
$ ts-node --esm a
Hello
$ tsc --module es2020 
$ cat a.js
import chalk from 'chalk';
console.log(chalk.yellow('Hello'));

실전 적용

이제 ESM의 동작 원리에 대해 약간 감이 옵니다. (저는 이 글을 쓰면서 정리하는데도 아직도 헷갈립니다) 이제 실전 적용을 해봅니다.

JavaScript에서는 require와 dynamic import를 혼합해서 쓸 수 있습니다. 다시 말해 ESM 전환("type": "module")을 하지 않아도 ESM 모듈을 쓰는 것이 가능합니다. (static import가 안 되므로 코드를 다르게 작성하는 불편함은 있습니다.)

하지만 TypeScript에서는 import를 require로 변환 또는 모드 import로 유지, 두 가지 선택지 밖에 없습니다. require로는 ESM 모듈을 쓸 수 없으므로 import 유지("module": "es2020")를 해야 합니다. 이 경우 Node.js에서 import 구문을 사용하므로 ESM 전환("type": "module")도 해야 합니다.

단순히 import 구문만 쓰면 되는게 아닙니다. 불러올 파일의 확장자를 모두 기술해줘야 합니다. TypeScript 임에도 불구하고 .js 확장자를 써줘야 합니다. 또 저는 디렉토리명으로 import 하는 것을 종종 사용하는데 모두 파일명으로 바꿔줘야 합니다. (from './services' → from '.services/index.js') 이 과정을 도와주는 도구(fix-esm-import-path)도 있습니다.

샘플로 만들어본 프로젝트에서는 이걸로 동작했습니다. 하지만 실제 프로젝트로 가니 또 다른 이슈가 있었습니다. __dirname 을 쓸 수 없다고 해서 변환을 해야 했습니다. (예 loadSchemaSync(join(__dirname, './index.graphql'))

이제 컴파일도 되고 구동해봅니다. 안 됩니다. ESM 모듈에서는 require 구문을 쓰는게 아예 안 됩니다.

$ node a.js 
file:///a/a.js:1
const http = require('http');
             ^

ReferenceError: require is not defined in ES module scope, you can use import instead
This file is being treated as an ES module because it has a '.js' file extension and '/a/package.json' contains "type": "module". To treat it as a CommonJS script, rename it to use the '.cjs' file extension.

그런데 저희 주요 프로젝트 구조에는 TypeScript 내에서 require를 의존하는 곳이 있습니다.

// tools/server.js
process.env.TZ = 'Etc/UTC';
require('ts-node/register/transpile-only');
require('../app/server');

// config/index.ts
function loadConfig<T>(dir: string, env?: string): T {
  const base = cloneDeep(require(`${dir}/default`).default as T);
  ...
  return base;
}

다른 건 몰라도 config는 당장 고치기 어려워 보였습니다. 또 즐겨쓰고 있는 REPL도 제대로 동작을 안 하는 건 좀 심각했습니다.

그래서 해결책은?

결국 이 이상 시간을 쏟기는 어려워서 어플리케이션을 ESM 모드로 전환하는 것은 포기했습니다. 하지만 ESM 모듈은 여전히 필요했습니다.

다행히 이번에 조사할 때는 저번에 찾지 못했던 회피 방법을 찾았습니다. TypeScript가 변환하지 않도록 import 문을 감추는 것입니다.

new Function('specifier', 'return import(specifier)')

eval도 가능하다는데 테스트해보진 않았습니다.

다만 위 코드보다는 라이브러리를 활용하는 솔루션이 더 직관적인 것 같아서 이 방법을 사용했습니다. tsimportlib 라이브러리를 사용하면 됩니다.

아쉬운 대로 동작합니다만 나중에 프로젝트가 ESM으로 전환됐을 때 dynamic import를 static import로 바꿔줘야 할 것 같아 꼭 필요한 곳에만 쓰는 것으로 생각하고 있습니다.

다행히 dynamic import는 서버에서만 필요했고, 프론트엔드 코드에서는 Next.js등이 잘 처리해주는지 static import로도 잘 동작했습니다.

마무리

개인적인 느낌으로 ESM은 Python2 → Python3를 전환시의 혼란을 보는 것 같습니다. 저를 포함해 많은 사람들이 당황해하고 불만을 터트리네요

https://github.com/sindresorhus/meta/discussions/15#discussioncomment-2495719 https://github.com/sindresorhus/meta/discussions/15#discussioncomment-2495719

하지만 좋은 싫든 ESM으로의 전환은 시작된 것 같습니다. 조금 더 생태계가 안정되면 다시 전환 시도를 해야 할 것 같습니다.

Appendix