Monolithic 서버사이드 타입스크립트 세팅 02

Monolithic 서버사이드 타입스크립트 세팅 02

지난 글에서 두 가지를 완료 했었다. 물론 잘 작동하는지 확인하기는 어려웠다. tsconfig, webpack까지만 잘 설정하고 나면, 확인은 가능할 것 같다. 맨 처음 목차와는 조금 다르긴 한데, tsconfigwebpack을 먼저 설정한 다음 스웨거를 설정하도록 하자.

  • Express 기본 아키텍처 구성
  • ESLint & Prettier
  • tsconfig, webpack 설정
  • Swagger
  • 개발 환경, 운영 환경 분리
  • 도커라이징

tsconfig

tsconfig는 그냥 자바스크립트 이용자는 alias를 VSCode가 인식하게 한다는 면에서, jsconfig로 대체할 수 있다. 그 방법을 이 글에서 다루지는 않지만, 갓글에서 많은 분들이 제공해주고 있다. 일단 결론적으로 말하자면 현재 tsconfig의 설정은 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
{
"compilerOptions": {
/* Basic Options */
"target": "ES2018" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */,
"module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */,
"allowJs": false /* Allow javascript files to be compiled. */,
"sourceMap": true /* Generates corresponding '.map' file. */,
"outDir": "./dist/" /* Redirect output structure to the directory. */,
"downlevelIteration": true /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */,

/* Strict Type-Checking Options */
"strict": true /* Enable all strict type-checking options. */,
"strictPropertyInitialization": false /* Enable strict checking of property initialization in classes. */,

/* Additional Checks */
"noUnusedLocals": true /* Report errors on unused locals. */,
"baseUrl": "./" /* Base directory to resolve non-absolute module names. */,
"paths": {
"@/*": ["src/*"]
} /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */,
"typeRoots": [
"node_modules/@types",
"src/@types"
] /* List of folders to include type definitions from. */,
"esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,

/* Source Map Options */
"sourceRoot": "./src" /* Specify the location where debugger should locate TypeScript files instead of source locations. */,
"mapRoot": "./src" /* Specify the location where debugger should locate map files instead of generated locations. */,
"experimentalDecorators": true /* Enables experimental support for ES7 decorators. */,
"emitDecoratorMetadata": true /* Enables experimental support for emitting type metadata for decorators. */,

/* Advanced Options */
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
},
"include": ["src/**/*.ts"]
}

위는 사용 중인 옵션을 제외하고는 모두 삭제한 것이다. 글을 쓰면서 평소에 아리송 했던 것들도 정리해봤다. 딱 보면 감이 오는 몇 가지를 제외하고, 먼저 baseUrlpaths는 VSCode상에서 부분에서 alias를 설정해주는 것에 의미가 있다. 실제로 컴파일할 때 이 부분을 고려해서 컴파일 해주지는 않는다. (?.. 왜지, 진짜 버그같음) jsconfig에서도 해당 설정이 존재한다. 마찬가지로 VSCode에서 인식할 수 있게 바꿔주는 역할 정도만 해준다. ts-node를 사용해서 실행할 때 조차도 인식을 못 해주는데, tsconfig-paths 모듈을 설치하고 -r tsconfig-paths/register 옵션을 넣어줘야 한다. 자세한 내용은 개발 환경 설정 하면서 작성하려고 한다.

emitDecoratorMetadataexperimentalDecorators는 데코레이터 사용을 가능하게 해주는 것과 관련된 설정인데, typeditypeorm 사용시 필요하다. 메타데이터와 관련된 내용은 확인해보지 못 했다. strictPropertyInitialization 이 부분도 typeorm 테이블 모델링을 할 때 문제가 있어서 추가한 부분이다. 클래스 내부의 프로퍼티가 컨스트럭터에서 초기화 되지 않을 때 에러를 뿜는 것인데 이 부분을 꺼줬다.

esModuleInterop 옵션은 commonjs로 작성된 모듈을 가져올 때, 원래는 import * as Module from "module"과 같은 형식으로 가져와야 하는데, import Module from "module"로 가져올 수 있도록, 컴파일 할 때 특수한 중간자를 둔다. namespace object를 만들어주는 함수를 사용하는 것 같다. 자세한 내용은 스택오버플로우에 있다.

모든 내용은 당연히 알지 못하고, (그거 하나하나 공부하면 시작을 못 한다고 생각하기 때문에…) 필요할 때마다 어떤 옵션을 써야 하는지 찾아보는 편인데, 타입스크립트는 tsc --init 명령어를 사용하면 모든 옵션이 있고, 설명도 주석으로 적당히 친절하게 달아둔 편이다. 지우지말고, 필요할 때 찾아가면서 옵션을 설정해주는 편이 좋다.

Webpack

우선, tsconfig를 통해 VSCode 상에서 alias로 코드를 가져온 것을 인식하게 만들었다. 그렇지만 tsc 명령어로 트랜스파일링을 시도한다고 해도, 이 부분이 실제로 반영되지 않는다. 그래서 webpack을 사용해서 해당 alias에 맞게 트랜스파일을 할 수 있게 만든다. 또, 번들링을 함으로써 코드를 최적화 하고 도커 이미지 크기를 줄이는 것에도 목적이 있다.

우선 웹팩으로 빌드하는 환경을 만들기 위해서 아래 의존 패키지를 설치한다.

1
yarn add webpack webpack-cli webpack-node-externals rimraf @babel/{core,preset-env,preset-typescript,plugin-proposal-decorators,plugin-proposal-class-properties} babel-loader -D
  • webpack, webpack-cli: 웹팩 설정 파일을 읽고 파일들을 번들링 해주는 핵심 역할을 한다.
  • webpack-node-externals: 필자는 node_modules 내용을 함께 번들링 하고 싶어하는 편이다. 여러번 시도를 해봤지만, 성공할 수가 없었다. 특히 serverless-webpack에서 이 부분을 함께 번들링 하지 않는 것을 보고, 그냥 포기 했다. node_modules를 번들링 대상에서 제외해주는 역할을 하는 패키지이다.
  • rimraf: OS에 관계없이 삭제하는 명령어를 수행할 수 있다. 빌드된 디렉토리를 삭제해준다.
  • @babel/{core,preset-env,preset-typescript}: 필자는 ts-loader를 사용하지 않고, 바벨을 통해 해결하는 편이다. 설정이 깔끔해지고, 더 빠르다고 한다. 그 이유와 자세한 내용은 대단하기로 소문난 Toast팀의 블로그를 확인해보자.
  • @babel/plugin-proposal-decorators: 바벨에서 데코레이터를 트랜스파일링 할 수 있게 해준다.
  • @babel/plugin-proposal-class-properties: 바벨에서 클래스의 프로퍼티(메서드 말고)를 트랜스파일링 할 수 있게 해준다. 위 두 플러그인은 설정값과 순서도 지켜줘야 한다.
  • babel-loader: webpack 모듈의 rules에서 로더로 작동하게 될 babel-loader이다.

플러그인들은 코드를 쓰다 보면 추가될 수 있다. 빌드 에러가 나면 해당 부분을 찾아서 필요한 플러그인을 추가해주면 된다.

설치 후 프로젝트 최상단에 webpack.config.js 파일을 만들어주면 된다. 내용은 결과적으로 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
const path = require("path");
const nodeExternals = require("webpack-node-externals");

module.exports = env => {
return {
// ReferenceError: regeneratorRuntime is not defined => polyfill
entry: { app: ["@babel/polyfill", "./src/index.ts"] },
devtool: "source-map",
output: {
filename: "[name].js",
path: path.resolve(__dirname, "dist")
},
mode: env.NODE_ENV,
target: "node",
node: {
__dirname: false,
__filename: false
},
externals: [nodeExternals()],
module: {
rules: [
{
test: /\.(ts|js)?$/,
use: {
loader: "babel-loader",
options: {
presets: ["@babel/preset-env", "@babel/preset-typescript"],
plugins: [
["@babel/plugin-proposal-decorators", { legacy: true }],
["@babel/plugin-proposal-class-properties", { loose: true }]
]
}
}
}
]
},
resolve: {
extensions: [".ts", ".js", ".json"],
alias: {
"@": path.join(__dirname, "src")
}
}
};
};

nodeExternals() 덕분에 과거에 node 옵션으로 추가 했던 많은 부분을 사용 안해도 되는 것 같다 (확실하지는 않다. 간단하게 실험해봤을 때 unresolve되는 경우는 없었다.). 다만, __dirnamefilename은 그냥 사용하면 /, index.js가 나온다. 제대로 반영하게 하기 위해서 node 옵션에 __dirname, __filenamefalse 값으로 둔다. (아마 이것도 추측이지만, webpack은 기본적으로 웹 환경에서 돌아가는 걸 상정해서 __dirname, __filename을 덮어 씌우는 것 같다. 그 기능을 꺼주는 걸로 알고 있다.)

@babel/polyfillasync await 문법을 컴파일 할 때 문제가 생기지 않게 해준다. 그냥 사용하게 되면 regeneratorRuntime is not defined라는 에러가 발생한다.

module 부분에 타입스크립트를 어떻게 컴파일할 것인지 나와있다. ts-loader 역할을 대신하게 된다.

resolve 부분에서 @/*src/*로 바꿔준다. 이제 컴파일을 하게 되면 온전하게 파일 경로를 설정해준다.

컴파일을 위한 webpack 설정은 얼추 마무리 됐지만, Swagger를 설정하면서, 데이터베이스를 붙이면서, 도커라이징을 하면서 조금씩 추가되거나 수정되는 부분이 생기게 된다. 기타 자세한 옵션은 webpack 공식 문서나 간단하게는 이 링크를 확인해봐도 좋을 것 같다.

마지막으로 웹팩 빌드를 위한 스크립트를 붙여주자.

1
2
3
4
5
6
7
8
9
10
11
// package.json
{
//...
"scripts": {
"build:dev": "rimraf ./dist && webpack --env.NODE_ENV=development",
"build:prod": "rimraf ./dist && webpack --env.NODE_ENV=production",
"start": "node ./dist/app.js",
"lint": "eslint \"./src/**/*.ts\""
}
//...
}

Swagger

스웨거는 API 문서 만들어주는 툴이다. 사용 방법에 대해서 자세히 다루지는 못할 것 같고, 붙이는 것만 확인해보자. 우선 목표는, 주석으로 문서화를 할 수 있게 되어야 하고, 개발 서버에 배포될 때, 웹팩이 해당 주석을 지우지 않아야 한다.

우선 express에서 개발 서버와 함께 띄우기 위해 swagger-ui-expressswagger-jsdoc을 사용한다.

1
yarn add swagger-ui-express swagger-jsdoc
  • swagger-ui-express: Express 서버에 Swagger 서버가 뜰 수 있게 미들웨어를 제공해준다.
  • swagger-jsdoc: JSDoc 코멘트 형식으로 API Doc을 구성하게 되면 인식하고 swagger.json을 만들어준다. 이 내용을 swagger-ui-express에 넣어주면 된다.

문제는 코멘트 형식이라 빌드할 때 웹팩을 사용하면 지워진다. 두 가지 방식으로 해당 문제를 해결할 수 있다(있을 것으로 추정된다.). 한 가지 방법은 NODE_ENV에 따라서 @swagger가 붙은 코멘트를 지우지 않는 옵션을 넣어주는 것이고, 두 번째는 스타 2개 붙은 webpack 플러그인을 사용하는 것인데, 필자는 그래도 이 템플릿을 프로덕트에도 사용할 예정이라 그냥 첫 번째 방법으로 하기로 했다.

우선 swagger 디렉토리를 src 아래 만들었다. 안에는 index.ts를 만든다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// src/swagger/index.ts

import path from "path";
import swaggerJSDoc from "swagger-jsdoc";
import configs from "@/configs";
import components from "./components";

const options = () => {
const isLocal = configs.ENV === "local";
const apiPath = isLocal
? path.join(__dirname, "..", "routers", "v1", "*.*")
: path.join(__dirname, "app.js");

return {
swaggerDefinition: {
info: {
title: "프로젝트 타이틀",
version: "프로젝트 버전"
},
host: configs.APP.HOST, // localhost 등
basePath: "/api",
schemas: ["http", "https"],
components
},

apis: [apiPath]
};
};

// Initialize swagger-jsdoc -> returns validated swagger spec in json format
const swaggerSpec = swaggerJSDoc(options());
export default swaggerSpec;

빌드를 하면, 하나의 파일인 app.js에 모든 주석이 들어가게 되기 때문에 ts-node를 사용해 동작시키는 로컬 환경이 아니라면, app.js에서 주석을 찾아야 한다. 반면 타입스크립트 파일을 그대로 돌리는 ts-node를 사용하면, router 아래 라우터들마다 존재하게 된다. 따라서 apiPath는 컴파일 여부에 따라서 바뀐다. 이제, 일반적으로 사용되는 부분들을 컴포넌트로 만들기 위해서 swagger 폴더에 components.ts롤 만든다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import { MESSAGE } from "@/errors/errRequest";

const components = {
response: {
BadRequest: {
description: MESSAGE.BAD_REQUEST,
schema: {
$ref: "#/components/errorResult/Error"
}
},
NotFound: {
description: MESSAGE.NOT_FOUND,
schema: {
$ref: "#/components/errorResult/Error"
}
}
},
errorResult: {
Error: {
type: "object",
properties: {
message: {
type: "string",
description: "error message"
},
error: {
type: "string",
description: "error stack"
},
statusCode: {
type: "number",
description: "error code"
}
}
}
}
};

export default components;

에러 메시지를 실제로 만들어내는 Default한 메시지 값과 동일하게 하기 위해서 src/errors/errRequest 아래 메시지를 컨스트로 관리한다. 그리고 errorResult의 형태는 express에서 마지막에 에러를 모아서 보내주는 부분과 동일하게 맞춰준다. 에러 컴포넌트 말고도, 일반적으로 사용되는 Response를 위와 같이 만들어두고 사용하면 편하다.

아래는 errors에서 constants를 관리하는 코드이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// src/errors/errReqeust.ts

import { BAD_REQUEST, NOT_FOUND } from "http-status-codes";

export const MESSAGE = {
BAD_REQUEST: "유효하지 않은 요청입니다." as const,
NOT_FOUND: "리소스가 존재하지 않습니다." as const
// ...
};

// ...

export class BadRequest extends RequestError {
constructor(message: string = MESSAGE.BAD_REQUEST) {
super(message);
this.statusCode = BAD_REQUEST;
}
}

// ...

이제 appLoader/api-docs로 이어지는 부분을 추가해주자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// src/app.ts
// ...

const appLoader = async (app: Express) => {
// ...

if (configs.NODE_ENV === "development") {
const { default: swaggerUi } = await import("swagger-ui-express");
const { default: swaggerSpec } = await import("@/swagger");

app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(swaggerSpec));
}

app.use("/api", routers);

app.use((err: any, req: Request, res: Response, next: NextFunction) => {
console.log(err);
res.status(err.statusCode || 500).json({
message: err.message,
statusCode: err.statusCode,
error: configs.ENV === "prod" ? null : err
});
});
};

export default appLoader;

src/loaders/index.ts에서 appLoaderawait을 붙여줘야 한다.

이제 그럼 webpack에서 prod 모드로 빌드 하면 /api-docs에는 아무 것도 없어야 하고, dev 모드로 빌드하면 어떤 UI가 나와야 한다. 테스트를 위해서 console.log를 붙이고 확인해봤다.


production 모드


develoment 모드


Swagger의 UI

이제 스웨거가 붙긴 했다. 그렇지만 여전히 주석 형태로 API Doc을 달았을 때 웹팩이 지워버리는 문제가 있다. 이 부분은 웹팩의 optimization 옵션과 관련이 있다. minimizerterser-webpack-plugin을 이용해서, NODE_ENV에 따라서, 다르게 번들링 하도록 설정했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
const path = require("path");
const TerserPlugin = require("terser-webpack-plugin");
const nodeExternals = require("webpack-node-externals");

module.exports = env => {
const isDev = env.NODE_ENV === "development";

const terserPlugin = isDev
? new TerserPlugin({
terserOptions: {
output: {
comments: /@swagger/i
}
}
})
: new TerserPlugin();

return {
// ...
output: {
filename: "[name].js",
path: path.resolve(__dirname, "dist")
},
optimization: {
minimize: true,
minimizer: [terserPlugin]
},

//...
};

테스트를 위해 health를 체크해주는 앤드포인트에 해당 주석을 달았다. 그리고, 실행 환경인 ENV는 원래는 local인데, 개발 서버에 올라갔을 경우의 문제를 해결하는 중이니까 ENVdev로 바꿔준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* @swagger
* /health:
* get:
* summary: 서버 헬스 체크
* responses:
* 200:
* description: 서버 헬스 체크용도, Swagger 체크 용도
*/

router.get("/health", (req, res) => {
res.status(OK).json({ server: "on" });
});

이제 다시 빌드를 해보고나서 같은 앤드포인트에 가보면 우리가 만든 스웨거가 등장한다.

로컬 환경에서는 어짜피 웹팩이 동작하지 않아서 무조건 살아있게 된다. 따라서 테스트 해볼 필요는 없겠지만, 다음 글에서 로컬 개발환경을 설정하고 나면 확인할 수 있다.

환경변수에 대해서 헷갈릴 수 있을 것이라 생각이 되어 주석을 달자면, NODE_ENV는 Node에서 정해진 환경변수이다. NODE_ENVdevelopment, production 두 가지 중에 하나이다. ENV는 필자가 정한 환경 변수이다. .env에 따라서 수정할 수 있다. NODE_ENV에 대해서 검색해보면 더 많은 정보를 확인해볼 수 있다.


이번 글은 이 정도로 마무리 지으려고 한다. 자세한 Swagger 사용법에 대해서는 다루지 않는다. (필자도 잘 모름… 쓰던 것만 쓰는 편임) 그래도 찾아보면 잘 나오니까 우리는 찾아가면서 쓰도록 하자.

다음 글에서 도커라이징한 다음, 환경 별로 .env를 설정해두는 방법, 로컬에서 돌리는 방법 등을 설정해두자.

테스트를 해보면서 이 글에 나오지 않은 부분들이 레포지토리에 추가 되긴 했다. 중요한 부분은 아니라서 글에 작성하지 않았다.

지금까지 구성된 내용은 이 링크에서 확인할 수 있다.

후기

개발 서버에서 SwaggerUI를 보내는 방식이 깔끔한 방식인지 모르겠다. 일단 이상한 버그가 있는데, 필자는 함수 안에 주석을 넣고 싶다. 에디터에서 함수를 fold 했을 때, 함께 접히면 좋을 것 같아서. 그런데 어떤 이상한 확률로 그런 식으로 작성된 주석을 웹팩이 지워버렸다. 이유를 모르겠다. 그래서 밖에다 빼고 사용한지 좀 됐는데 그 이후로는 그런 문제가 생기지는 않았다.

1, 2, 3편으로 이어지는 이 글이 사실은 한 번에 설정하는 내용인데 끊어 정리 하느라, 매끄럽지 않은 느낌도 있다. 최대한 독립적인 부분을 독립적으로 수행하게 구성하려 했는데 그런 면에서는 성공적이지 못 했다.

Reference

댓글

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×