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

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

이놈의 Hexo 블로그가 future라는 옵션을 켜면 미래 글까지 보여준다는 걸 처음 알았다. 원래 2편이 12일에 올라가길 바랐던 글인데, 3시간 후라는 이름으로 11일에 올라갔다, 귀찮기 때문에 내리진 않았다.

이 주제로 마지막 글이다. 이번 글에서는 도커라이징을 해서 로컬 개발 환경을 구성하고, 개발 서버, 운영 서버로 나눠서 배포할 수 있는 환경 구성을 해보려고 한다. 도커라이징과 관련된 내용은 과거에 개발 환경과 관련된 글을 썼는데, 그 글에서 조금 더 자세하게 확인할 수 있긴 하다. 지금까지 진행된 내용은 아래와 같다.

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

위에서 언급한 것처럼 도커와 관련된 자세한 세부 사항들은 그전 글에서 확인하는 편이 낫다. 이번 글에서는 자세한 설명은 생략한다.

도커라이징과 개발, 운영 환경 분리

도커라이징, 개발 운영 환경 분리라는 주제는 하나의 주제로 진행된다. (배포까지 하는 글이 아니기 때문에 환경별로 도커라이징까지 하고 끝난다. 도커 배포 글은 Fargate로 배포하기를 참고해보면 좋을 것 같다.)

로컬 개발 환경 구성

우선 Dockerfiles라는 폴더를 만든다. 로컬에서 작업할 도커 파일과, 개발 서버에 배포 하는 것과, 운영 환경에서 배포할 도커 파일 이렇게 세 개를 보통은 구성한다. 네이밍에는 항상 고민이 있긴 한데, 서버만 관리하는 입장에서 여러 도커 파일을 만들 때, 메인 애플리케이션 하나만 도커라이징 되는 경우가 제일 많았기 때문에 이름은 별 다른 거 없이 local.Dockerfile, dev.Dockerfile, prod.Dockerfile로 만들어준다. 로컬 환경에서는 도커 내부와, 현재 작업하는 src 디렉토리가 공유되어야 한다. 별로 어려울 게 없으니 일단 구성해보자.

1
2
3
4
5
6
# Dockerfiles/local.Dockerfile

FROM node:12.16.1-alpine
WORKDIR /usr/src/app

CMD ["yarn", "docker:dev"]

볼륨 공유와 포트포워딩은 docker-compose.yml에서 설정할 것이다. 다만 위 설정에서 아쉬운 점은, 로컬 개발 환경이 완벽하게 운영, 개발 서버와 같지 않다는 점이다. 완전히 같아지려면, node_modules를 도커 내에서 설치해야 하고, docker-compose.yml에서 node_modules를 공유하지 않는 방식으로 가야 한다. 방법이 있긴 한데 문제는 로컬 개발을 진행하면서 패키지 인스톨이 필요할 때마다 도커 컨테이너 내부에서 yarn add를 해줘야 한다. 적당히 타협해서, 다음과 같이 진행했다.

데이터베이스와 기타 필요한 것들을 편하게 사용하기 위해서 docker-compose를 로컬 개발 환경에서는 사용한다. 프로젝트 최상단에 docker-compose.yml 파일을 만든다.

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
45
# docker-compose.yml

version: "3.7"

services:
app:
container_name: app
build:
context: .
dockerfile: Dockerfiles/local.Dockerfile
ports:
- "3000:80"
volumes:
- ./:/app
env_file:
- .envs/local.env
depends_on:
- database
- pgadmin

database:
container_name: database
image: postgres:10
ports:
- "5432:5432"
volumes:
- db-volume:/var/lib/postgresql/data

pgadmin:
container_name: pg_admin
image: dpage/pgadmin4
logging:
driver: none
ports:
- "5555:80"
volumes:
- pg-volume:/var/lib/pgadmin
depends_on:
- database
env_file:
- .envs/local.env

volumes:
db-volume: {}
pg-volume: {}

내부 내용들에서 구체적인 부분들은 위에서 언급한 시리즈 2탄에서 자세하게 다룬다.

pgadmin을 위해서 필요한 환경 변수는 두 가지이다.

1
2
3
4
5
6
7
8
9
# envs/local.env
PGADMIN_DEFAULT_EMAIL=your@email.com
PGADMIN_DEFAULT_PASSWORD=1234

NODE_ENV=development
ENV=local
SECRET=mysecret
APP_PORT=80
APP_LOGSTAGE=dev

위는 로컬 환경변수로 필요한 것들이다. 데이터베이스를 붙일 때 추가될 예정이지만, pgadmin을 도커로 돌리게 되면 로그인을 하게 되는데, 로그인할 이메일과 비밀번호를 위 이름으로 설정해줘야 한다. pgadmin을 위한 볼륨을 사용하고 있어서 만약 한 번 저 내용이 설정되고 나면 해당하는 volume 위치를 찾아서 설정을 변경해줘야 한다. 바뀔 일이 없어서 상관 없는데 만약 재설정 하고 싶다면, 그냥 해당 볼륨을 지우고 다시 빌드하는게 맘 편하다. 위 두가지 설정을 해주지 않으면 pgadmin이 켜지지 않는다.

app이라는 이름으로 서버를 띄울텐데, context가 최상단에 위치해서, 이미지를 만드는 데 불필요한 부분들까지 모두 context에 복사된다. .dockerignore 파일을 만들어서 해결해주도록 하자. 최상단에 .dockerignore를 만들어준다. (로컬 개발 환경에서는 볼륨이 공유되어서 아래 내용들도 모두 존재하긴 한다. 다만 prod.Dockerfile, dev.Dockerfile에서 빌드될 때 불필요한 부분들을 넣었다.)

1
2
3
4
5
6
7
8
9
# .dockerignore
.git
Dockefiles
node_modules
.eslintrc.json
.gitignore
.prettierrc
docker-compose.yml
ormconfig.json

스크립트도 docker-compose로 시작할 수 있게 바꿔주도록 하자. 파일이 변경되면 재시작되는 nodemon을 설치하고, 타입스크립트를 바로 실행할 수 있는 ts-node와, alias 설정을 반영해주는 tsconfig-paths 패키지를 설치한다.

1
yarn add nodemon ts-node tsconfig-paths -D
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// package.json

{
//...
"scripts": {
"docker:dev": "nodemon --exec ts-node -r tsconfig-paths/register src/index.ts",
"build:dev": "rimraf ./dist && webpack --env.NODE_ENV=development",
"build:prod": "rimraf ./dist && webpack --env.NODE_ENV=production",
"dev": "docker-compose up",
"start": "node ./dist/app.js",
"lint": "eslint \"./src/**/*.ts\""
}
//...
}

yarn dev 명령어로 실행해볼 수 있다. 로컬 개발 환경이 잘 작동하고, 앱에서는 현재 만들어진 데이터베이스가 없기 때문에 데이터베이스가 없다는 에러가 뜰 수 있다. ORM 별로 다르겠지만, 이 글에서 사용한 TypeORM을 사용하고 있다. pg_admin을 통해 원하는 데이터베이스 이름을 만들어주고 (또는 database 도커 컨테이너 내부로 가서 직접 만들어줘도 된다.) 실행하면 원활하게 실행이 된다.

TypeORM은 프로젝트 최상단에 ormconfig.json을 두든, 앱 내부에서 설정 값을 넘겨줘야 한다. ormconfig.json을 사용하면 데이터베이스 설정값을 여러개로 관리해야 하지만, 내부에 넘겨주게 되면, .env에서 해결할 수 있다. 또한 내부에 뒀을 때 생기는 문제점들을 해결해 줄 수 있지도 않다. 따라서 createConnection에 옵션을 넘기는 방식을 사용하겠지만, 내부에서 옵션을 직접 지정하면, entities 옵션을 설정할 때 빌드된 다음 과정을 고민해봐야 한다. 빌드 되면 해당 경로가 무의미 해진다. 따라서 경로로 설정해주는 것이 아니라 직접 모델을 넣어줘야 하는데, 그렇게 되면 minify되는 이름들 때문에 테이블 이름을 온전하게 찾지 못하는 문제가 발생한다.

경로 지정을 해서 생기는 문제와, 직접 모델을 import 해줄 때 생기는 문제 중에 해결하기 쉬운 건 이름이 바뀌는 문제이다. 이 부분도 웹팩의 optimization과 관련이 있다.

클래스 이름이 변경되는 것을 막기 위해서 다음 옵션을 TerserPlugin에 추가한다.

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

module.exports = env => {
// ...

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

return {
// ...
};
};

src/configs/index.tssrc/loaders/dbLoader.ts에 다음과 같이 설정 값을 추가한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// src/configs/index.ts

// ...

export default {
// ...

DB: {
HOST: String(process.env.DB_HOST),
DATABASE: String(process.env.DB_DATABASE),
USERNAME: String(process.env.DB_USERNAME),
PASSWORD: String(process.env.DB_PASSWORD)
}
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// src/loaders/dbLoader.ts

import { createConnection, ConnectionOptions, getConnection } from "typeorm";
import configs from "@/configs";
import { User } from "@/models/userModel";

const defaultOrmConfig: ConnectionOptions = {
type: "postgres",
logging: ["warn", "error", "migration"],
port: 5432,
host: configs.DB.HOST,
database: configs.DB.DATABASE,
username: configs.DB.USERNAME,
password: configs.DB.PASSWORD,
entities: [User],
synchronize: configs.ENV === "local"
};

const dbLoader = (ormConfig: ConnectionOptions = defaultOrmConfig) =>
createConnection(ormConfig);

export const clearDatabase = () => getConnection().close();

export default dbLoader;

ormConfig를 인자로 받는 함수를 만든 이유는 테스트 함수에서 컨넥트 할 때 같은 함수를 사용하고 싶어서이다.

지금까지 로컬 개발 환경에서 필요한 local.env는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
PGADMIN_DEFAULT_EMAIL=your@email.com
PGADMIN_DEFAULT_PASSWORD=1234

NODE_ENV=development
ENV=local
SECRET=mysecret

APP_PORT=80
APP_LOGSTAGE=dev
APP_HOST=localhost:3000

DB_HOST=database
DB_USERNAME=postgres
DB_PASSWORD=postgres
DB_DATABASE=database

이제 서버가 잘 돌고 완전히 로컬에서 개발할 준비가 마쳐졌다.

현재 프로젝트는 아래와 같이 생겼다.

이 방식으로 하면, 최상단의 ormconfig.json은 사용하지 않아도 된다. ormconfig.json은 로컬 환경에서만 개발할 때는 사용하기 편하지만, 설정 값이 많아지는 건 귀찮은 일이다. 다만 typeorm cli를 사용하는 사람은 이 옵션이 필요하다. 주로 migration을 하는 일인데, 이 부분은 ormconfig.jsonmigrations폴더를 지정하고 타입스크립트로 코드를 돌리도록 하면 해결 되긴 한다. 필자는 운영, 개발 서버는 pgAdmin을 사용해서 migration을 해주는 편이기 때문에 사용하지 않는다. 로컬 환경은 sync를 맞춰주는 걸로 한다. 다만 이 프로젝트에서는 지우지 않고 migrations를 사용하시는 분들을 위해 기본적인 세팅을 해뒀다.

이제 공백으로 남겨뒀던 dev.Dockerfileprod.Dockerfile을 작성해보자. 지난 번에 도커 베스트 프랙티스를 번역 했으니, 그걸 좀 활용해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# Dockerfiles/dev.Dockerfile

FROM node:12.16.1-alpine AS builder
WORKDIR /usr/src/app
COPY src .
COPY package.json .
COPY yarn.lock .
RUN yarn install
RUN yarn build:dev

FROM node:12.16.1-alpine
COPY --from=builder /usr/src/app/dist ./dist
COPY --from=builder /usr/src/app/node_modules .
COPY .envs/dev.env ./.env

EXPOSE 80

CMD ["yarn", "start"]

실행 할 때 node_modules를 새로 설치하는게 더 빠를까 COPY 하는 게 더 빠를까 고민을 했는데, 정확하지는 않지만, 이게 더 나을 것 같아서 위와 같이 작성했다. 아래는 prod.Dockerfile이다.

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
# Dockerfiles/prod.Dockerfile

FROM node:12.16.1-alpine AS builder
WORKDIR /usr/src/app

COPY .envs/prod.env ./.env
COPY webpack.config.js .
COPY tsconfig.json .

COPY package.json .
COPY yarn.lock .

COPY src ./src

RUN yarn install
RUN yarn build:prod

FROM node:12.16.1-alpine
COPY --from=builder /usr/src/app/dist ./dist
COPY package.json .
COPY yarn.lock .

RUN yarn install --production

EXPOSE 80

CMD ["yarn", "start"]

둘 모두 빌드가 잘 된다.

추가: 테스트 코드

Jest를 사용한 테스트 코드 사용도 추가해보려고 한다. 아래 필요한 패키지를 추가로 설치한다.

1
yarn add jest ts-jest @types/jest -D

그 다음 package.json에 타입스크립트 코드를 테스트할 수 있게 Jest 설정과 스크립트를 추가한다.

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
// package.json

{
// ...

"scripts": {
// ...

"test": "jest --detectOpenHandles --forceExit"
},

// ...

"jest": {
"transform": {
"^.+\\.ts$": "ts-jest"
},
"testRegex": "\\.test\\.ts$",
"collectCoverage": true,
"moduleFileExtensions": ["ts", "js", "json"],
"moduleNameMapper": {
"@/(.*)$": "<rootDir>/src/$1"
},
"globals": {
"ts-jest": {
"diagnostics": true
}
}
}
}

collectCoverage는 테스트 결과와 함게 커버리지를 커멘드라인으로 띄워 보여주는 옵션이다.

moduleNameMapper는 테스트 코드 내에서 @/* 형태로 모듈을 임포트 할 수 있게 지정해주는 옵션이다.

--forceExit 옵션은 Jest를 종료 해야 하는 경우, 종료되지 않는 프로세스 때문에 꺼지지 않는 것을 막아주는데 이 옵션을 사용하려면 --detectOpenHandles 옵션이 필요하다. 이 옵션은 Jest가 깔끔하게 종료되지 않게 방해하는 것을 모아서 프린트해준다.

프로젝트 최상단에 tests 디렉토리를 만들고, src프로젝트에서 테스트 해야 할 디렉토리와 동일한 이름으로 만든다. 필자는 보통 models, services를 테스트 하는 편이다. 테스트 되어야 하는 파일에 .test.ts 확장자로 테스트 파일을 만든다.

tsconfiginclude 부분에도 tests/**/*.ts를 추가한다.

테스트를 할 때 데이터베이스가 필요한 경우가 있다보니, 어떻게 해야 할지 고민을 하게 됐는데, 마찬가지로 docker-compose.test.yml을 만들어서 전체 테스트를 실행하게 할려 했다. 실제로 해봤는데 생각보다 너무 느려서, localhost:5432에 붙는 데이터베이스(yarn dev 했을 때 뜨는 데이터베이스도 localhost:5432에 포트포워딩 되어있다.)에다가 testdb라는 데이터베이스를 만들어서 진행했다. 이게 훨씬 빨라서 로컬 테스트는 그렇게 진행하기로 했다.

아래는 userService.test.tsuserModel.test.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
// tests/services/userService.test.ts
import UserModel, { User } from "@/models/userModel";
import { DeepPartial } from "typeorm";
import UserService from "@/services/userService";

const userModel: Partial<UserModel> = {
createOne: ({ username }: DeepPartial<User>): Promise<Mutation<User>> => {
const date = new Date();
const userMock = {
id: 1,
username,
createdAt: date,
updatedAt: date
};
return new Promise(resolve =>
resolve({ result: userMock as User, success: true })
);
}
};

describe("UserService Test", () => {
describe("Signup", () => {
test("username을 넣으면 회원가입이 된다.", async () => {
const userService = new UserService(userModel as UserModel);
const username = "testUser";
const ret = await userService.signup(username);

expect(ret.success).toBe(true);
expect(ret.result?.username).toBe(username);
});
});
});
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
// tests/models/userModels.test.ts

import UserModel from "@/models/userModel";
import dbLoader, { clearDatabase } from "@/loaders/dbLoader";
import ormConfig from "../ormConfig";

beforeAll(async () => {
await dbLoader(ormConfig);
});

afterAll(async () => {
await clearDatabase();
});

describe("UserModel Test", () => {
describe("createOne", () => {
test("username이 있으면 데이터베이스에 저장된다.", async () => {
const userModel = new UserModel();
const username = "testUser";
const ret = await userModel.createOne({ username });

expect(ret.success).toBe(true);
expect(ret.result?.username).toBe(username);
});
});
});

아래는 테스트에서 사용되는 ormConfig이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// tests/ormConfig.ts;

import { ConnectionOptions } from "typeorm";
import { User } from "@/models/userModel";

const ormConfig: ConnectionOptions = {
type: "postgres",
logging: ["warn", "error", "migration"],
port: 5432,
host: "localhost",
database: "testdb",
username: "postgres",
password: "postgres",
entities: [User],
synchronize: true
};

export default ormConfig;

테스트를 돌려보면 다음과 같이 리포트를 뽑아준다. 프로젝트 최상단에 coverage라는 리포트도 웹 형태로 빌드해준다.


TADA~


자동으로 만들어주던 보고서

후기

정말 긴 시리즈였다. 중간 중간 추가해가면서 진행해서 글을 다듬는 것도 많았다. 잘 읽히게 쓰여진 건지 잘 모르겠지만, 아무튼 템플릿 프로젝트를 하나 완성했다. 이제 타입스크립트 보일러 플레이트는 이걸 계속 업데이트해가면서 쓸 계획이다.

Reference

댓글

Your browser is out-of-date!

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

×