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

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

자바스크립트 대신, 타입스크립트를 사용하는 건 많은 부분에 있어서 장점이 있다. 그렇지만 필자 입장에서는 타입스크립트로 개발을 할 때 가장 공을 들이는 부분은 다름 아닌 세팅이다… (타입스크립트 세팅 너무 고민할 게 많아…) 이 글에서 보통 프로젝트를 제대로 프론트 개발자와 함께 시작하는 상황에서의 백앤드 개발자의 입장에서, 정리도 해둘 겸, 보통 고민하는 순서대로 한 번 세팅을 해보려고 한다.

완전히 모든 부분을 커버한 글이 아님을 미리 알립니다. 어느 정도는 JS, TS 기반 서버 구성을 해본 적 있는 분들이 보시기에 적합합니다.

일단 Typescript 서버사이드를 구성할 때, 아래 내용을 준비하는 편이다. 아래 내용들은 나름 독립적인 케이스가 많아서, 순서도 뭐 상관 없을 것 같고, 필요에 따라 선택해서 볼 수 있을 것 같긴 하다.

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

이번 글에서는 ESLint & Prettier 까지 설정해보려고 한다.

Express 기본 아키텍처 구성

이 부분은 타입스크립트가 아니더라도, 필자가 Node 앱을 만들 때 보통 비슷하게 하는 경향이 있어서 맨 처음 구성에, Node 백앤드 앱이라면 타입스크립트와 상관 없이 해야 하는 내용들 위주로 담았다.

타입스크립트 프로젝트 시작은 yarn inittsc --init으로 한다(typescript가 global에 설치 되어있어야 한다.). 그 다음, 보통 앱은 여기 링크에 나온 아키텍처를 선별적으로 사용하는 편이다. 최근에 커뮤니티에서 한글로 번역도 해주신 것 같은데, 링크를 어디에 저장해뒀는지 모르겠어서 일단 원래 보던 링크를 올렸다.

프로젝트 최상단에 src 디렉토리를 만들고 .gitignore를 만든다. .gitignore에는 여기 링크에 있는 .gitignore를 사용한다. 그 다음 .envs 디렉토리를 만들고 local.env, dev.env, prod.env를 만든다. 현재 프로젝트 상태는 아래와 같다.


글을 쓰면서 네이밍을 수정했습니다. 글에 나온 대로 .envs로 진행했습니다.

기본적으로 필요한 의존성 모듈을 먼저 설치하도록 한다. 그 아래부터는 디렉토리 구조와 역할을 간단하게 설명했다.

1
2
3
4
yarn add express cookie-parser @babel/polyfill helmet morgan dotenv

# 아래는 타입스크립트로 구성할 때 해당 글에 특수한 경우? 선택하기 나름인 경우에 설치하는 부분이다.
yarn add typedi typeorm reflect-metadata pg

@types/{...}에 해당하는 타입 모듈들은 사용하면서, 없으면 -D 옵션을 붙여서 설치하도록 하자.

@types

이 부분은 typeinterface를 정의하는 곳이다. 오픈 소스 중에서도 정의가 부실한 경우에도 사용하고, 빌드 중인 앱에서 주로 타입이 필요한 경우 이곳에 정의한다. [project] 부분에는 현재 프로젝트를 적는다.

1
2
3
4
5
6
7
// src/@types/[project]/return.d.ts

export interface ErrorSafety<T> {
success: boolean;
error?: any;
result?: T;
}
1
2
3
4
5
6
7
// src/@types/[project]/index.d.ts
import { ErrorSafety } from "./return";

declare global {
export interface Mutation<T = any> extends ErrorSafety<T> {}
export interface ServiceData<T = any> extends ErrorSafety<T> {}
}

configs

configs 디렉토리에서는 설정값을 모두 관리한다. 보통 index.ts에서 process.env 값을 관리하게 되어있다. process.env에 환경 변수들을 넣는 건 dotenv를 사용할 것이다. 대충 아래처럼 사용하는 편이다.

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

import dotenv from "dotenv";

if (!process.env?.ENV) {
dotenv.config();
}

export default {
ENV: String(process.env.ENV),
SECRET: String(process.env.SECRET),
APP: {
PORT: Number(process.env.APP_PORT),
LOGSTAGE: String(process.env.APP_LOGSTAGE)
}
};

errors

에러를 정의한다. 특히 response로 돌려줘야 하는 에러인 경우 statusCode라는 프로퍼티를 담아서 next() 함수에 모아준다. 아래 loaders에서 마지막 에러처리 하는 함수에서 해당 코드를 인지해서 에러를 반환한다.

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
// src/errors/errRequest.ts
import {
BAD_REQUEST,
INTERNAL_SERVER_ERROR,
NOT_FOUND
} from "http-status-codes";

abstract class RequestError extends Error {
statusCode: number;

constructor(message?: string) {
super(message);
}
}

export class BadRequest extends RequestError {
constructor(message: string = "유효하지 않은 요청입니다.") {
super(message);
this.statusCode = BAD_REQUEST;
}
}

export class NotFound extends RequestError {
constructor(message: string = "리소스가 존재하지 않습니다.") {
super(message);
this.statusCode = NOT_FOUND;
}
}

loaders

loaders는 Express Application이 앱을 시작하기 전에, 올려야 하는 부분들이다. 앱을 설정해주는 부분이라고 볼 수도 있는데, Database를 연결해주는 것, 애플리케이션 설정해주는 것 등을 나눠서 코드를 짜는 편이다.

1
2
3
4
5
6
7
8
9
10
11
// src/loaders/index.ts
import { Express } from "express";
import appLoader from "./appLoader";
import dbLoader from "./dbLoader";

const loaders = async (app: Express) => {
appLoader(app);
await dbLoader();
};

export default loaders;
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
// src/loaders/appLoader.ts

import express, { Express, Request, Response, NextFunction } from "express";
import helmet from "helmet";
import logger from "morgan";
import cookieParser from "cookie-parser";
import configs from "@/configs";
import routers from "@/routers";

const appLoader = (app: Express) => {
app.use(helmet());
app.set("port", configs.APP.PORT);
app.use(logger(configs.APP.LOGSTAGE));
app.use(cookieParser(configs.SECRET));
app.use(express.urlencoded({ extended: true }));
app.use(express.json());

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;

@/configs, @/routers에서 @/*src/*와 동일하다. 상대경로가 길어지는 것이 싫어서 alias 설정을 해주는 편인데, 이 부분은 tsconfig, webpack 설정을 마치면 사용할 수 있게 된다.

ORM은 TypeORM을 주로 사용하는 편이다. 각자가 편한 ORM을 dbLoader에서 설정해주면 되지만, 아무튼 필자는 typeorm에서 필요한 모듈을 추가로 설치한 다음 아래와 같이 연결을 해준다. (typeorm은 프로젝트 최상단에 ormconfig.json을 두면 별 다른 옵션을 만들어줄 필요가 없다.)

1
2
3
4
// loaders/dbLoaders.ts
import { createConnection } from "typeorm";

const dbLoader = () => createConnection();

models

모델에는 ORM 로직들을 담는다. models/entities 디렉토리에는 ORM으로 데이터베이스를 설계하는 로직(데이터베이스 테이블을 정의하는 부분들)이 들어간다.

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
// src/models/entities/User.entity.ts

import {
Entity,
BaseEntity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn
} from "typeorm";

@Entity()
class User extends BaseEntity {
@PrimaryGeneratedColumn()
id: number;

@Column({ type: "varchar", length: 20 })
username: string;

@CreateDateColumn({ type: "timestamp with time zone" })
createdAt: Date;

@UpdateDateColumn({ type: "timestamp with time zone" })
updatedAt: Date;
}

export default User;
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
46
47
48
49
50
51
52
53
// src/models/userModel.ts
import User from "./entities/User.entity";
import { DeepPartial, FindOneOptions } from "typeorm";
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";

class UserModel {
getAll(): Promise<User[]> {
return User.find();
}

getOneById(id: number): Promise<User | undefined> {
return User.findOne(id);
}

getOne(findOneOptions: FindOneOptions): Promise<User | undefined> {
return User.findOne(findOneOptions);
}

async createOne(user: DeepPartial<User>): Promise<Mutation<User>> {
try {
const result = await User.create(user).save();
return {
success: true,
result
};
} catch (e) {
return {
success: false,
error: e
};
}
}

async updateOneById(
id: number,
partialData: QueryDeepPartialEntity<User>
): Promise<Mutation> {
try {
await User.update(id, partialData);
return {
success: true
};
} catch (e) {
return {
success: false,
error: e
};
}
}
}

export { User };
export default UserModel;

위 예시처럼, 작은 앱일 수록 src/models/userModel.ts 부분이 불필요한 로직만 추가하게 되는 경우가 많았는데, 복잡한 ORM 로직을 작성해야 하는 경우가 많아질 수록 이 부분이 참 쓸모가 있었다. 예를 들어서 User.update() 함수로 많은 업데이트가 해결 되지만, 디테일하게 ORM을 사용해야 하거나 Raw Query를 작성해야 하는 경우, services에 담고 싶지 않아지는 경우가 많이 생긴다.

따라서 작은 앱을 계획 중이라면, entities 부분을 models에 두고, ORM 로직을 services에 같이 두는게 훨씬 좋을 수 있다.

아래 User 부분을 다시 export한 이유는 딱히 없는데, 그냥 models/entities에 접근하는 파일이 models에만 있길 바라서 그랬다. 취향따라 빼면 된다. 보통 저렇게 export 하면, 타입 선언할 때 자주 사용된다.

routers

컨트롤러 로직이 담기게 된다. v1 디렉토리를 안에 두는 편인데, 버전이 크게 변하면 v2로 업데이트 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// src/routers/index.ts
import express from "express";
// http-status-codes는 상수를 담고 있는 패키지일 뿐이니, 취향대로 사용
import { OK } from "http-status-codes";
import v1 from "./v1";

const router = express.Router();

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

router.use("", v1);

export default router;
1
2
3
4
5
6
7
8
9
10
11
// src/routers/v1/index.ts
import express from "express";
import userRouter from "./userRouter";
import { isAuthenticated, isNotAuthenticated } from "@/routers/middlewares";

const router = express.Router();

router.use("/users", isNotAuthenticated, userRouter);
router.use("/auth", isAuthenticated, authRouter);

export default router;

routers에 있는 middlewares 부분은 로그인이 되어있는지 아닌지 판단해주는 미들웨어를 달았다 (실제로 만든 건 아니고, middlewares는 여기에 둔다는 정도). 디테일한 부분은 글에서 다루지는 않는다.

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
import express, { Request, NextFunction, Response } from "express";
import { Container } from "typedi";
import { OK } from "http-status-codes";
import UserService from "@/services/userService";
import { BadRequest } from "@/errors/errRequest";

const router = express.Router();

router.get("", async (req: Request, res: Response, next: NextFunction) => {
const userService = Container.get(UserService);
const userList = await userService.getAllUser();
res.status(OK).json(userList);
});

router.post("", async (req: Request, res: Response, next: NextFunction) => {
const { username } = req.body;
try {
if (!username) {
throw new BadRequest("필수 필드가 필요합니다.");
}
const userService = Container.get(UserService);
const ret = await userService.createOneUser(username);
if (!ret.success) throw ret.error;

res.status(OK).json(ret.result);
} catch (e) {
next(e);
// error는 모아져서 loaders에 정의해둔 에러처리 미들웨어로 들어가게 된다.
}
});

export default router;

typedi에 대해서는 레포지토리 링크나, 위에서 언급한 이 프로젝트의 기반이 되는 링크를 참조하길 바란다. 사실 없어도 되고 있어도 되는데, 테스트 함수를 작성하기 훨씬 쉬워진다고 한다 (체감은 뭐… 그냥 그럼. 어짜피 테스트 코드는 힘드렁).

UserService를 작성한 부분은 아직 작성되지 않았고 바로 다음에 이 부분을 확인할 수 있다.

services

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// src/services/userService.ts
import { Service } from "typedi";
import UserModel, { User } from "@/models/userModel";

@Service()
class UserService {
constructor(private userModel: UserModel) {}

getAllUser() {
return this.userModel.getAll();
}

createOneUser(username: string): Promise<ServiceData<User>> {
return this.userModel.createOne({ username });
}
}

export default UserService;

models는 데이터베이스 정의된 로직과 관계가 있다. 하나의 테이블만 사용하는 로직을 짜야 하고, services는 여러 models의 로직을 조합해 controller에서 사용될 로직을 만드는 것이다. 따라서 네이밍도 UserServices가 되었지만 데이터베이스 테이블과 연관되어있기 보단, 라우터에 /users와 연관 되어 있다고 볼 수 있다. 즉, authModel은 없지만, 라우터에 /auth가 있으므로 AuthServices는 생성될 수 있는 것이다. (ServiceData는 필자가 정의한 서비스 로직에서의 리턴값이다. models.d.ts에서 Mutation과 값이 같다.)

함수 네이밍도 이와 관련 있다. UserService에서는 항상 User와 관련된 처리만 하는 경우가 아닐 수도 있다. /users 라우팅에서 처리하는 로직과 관련 되어있을 뿐이다. 따라서 모델 함수처럼 getAll에서 끝나는 것이 아니라, getAllUser라고 이름 붙이는 편이다.

app.ts

앱을 시작하는 로직을 담았다. 실제로 시작되는 곳은 index.ts이긴 한데, 여기서 시작해도 별 상관은 없다.

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

import express, { Express } from "express";
import loaders from "@/loaders";

class App {
private app: Express;
constructor() {
this.app = express();
}

async bootstrap() {
await loaders(this.app);
this.app.listen(this.app.get("port"), () => {
console.log("Server is On");
});
}
}

export default App;

index.ts

이 부분이 시작점이 된다.

1
2
3
4
5
6
7
// src/index.ts
import "reflect-metadata";
import App from "./app";

const app = new App();

app.bootstrap();

최종적으로는 아래와 같은 모습이다.

대충 디렉토리 구조는 이렇게 생겼다. 이후로 redis라든지, socketIO 등을 붙일 때도 어디에 맞는 것인지 판단하면서 추가하면 된다.

ESLint && Prettier

혼자 할 때는 사실 프리티어만 사용하는 편이다. 이유가 있긴 한데, ESLint가 솔직히 TS에 맞추려면 너무 귀찮게 할 게 많다. 그리고, 이 섹션은 그 귀찮은 것들을 모두 해결하지 않고 그냥 규칙을 off 한다. 근데 이제 팀 프로젝트가 되다 보면, 코드를 강제할 필요가 있기 때문에 진행하기는 하는데, 이거 말고, gts를 사용해보길 추천하는 바이다.

ESLint 설정은 eslint --init으로 만들 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
? How would you like to use ESLint? To check syntax, find problems, and enforce code style
? What type of modules does your project use? JavaScript modules (import/export)
? Which framework does your project use? None of these
? Does your project use TypeScript? Yes
? Where does your code run? Node
? How would you like to define a style for your project? Use a popular style guide
? Which style guide do you want to follow? Airbnb: https://github.com/airbnb/javascript
? What format do you want your config file to be in? JSON
Checking peerDependencies of eslint-config-airbnb-base@latest
Local ESLint installation not found.
The config that you've selected requires the following dependencies:

@typescript-eslint/eslint-plugin@latest eslint-config-airbnb-base@latest eslint@^5.16.0 || ^6.1.0 eslint-plugin-import@^2.18.2 @typescript-eslint/parser@latest
? Would you like to install them now with npm? Yes

린트에서 typescript를 설치하라고 하기 때문에, 개발 의존성에 추가한다. prettier, eslint-plugin-prettiereslint-config-prettier도 함께 설치한다.

  • eslint-plugin-prettier: prettier와 함께 쓸 수 있도록 해준다.
  • eslint-config-prettier: eslint에서 prettier config를 찾게 함
1
yarn add typescript prettier eslint-plugin-prettier eslint-config-prettier -D

.prettierrc 파일을 프로젝트 최상단에 추가한다. 옵션들은 본인 취향에 맞게 설정하면 된다.

1
2
3
4
5
6
7
// .prettierrc
{
"semi": true,
"singleQuote": false,
"tabWidth": 2,
"useTabs": false
}

package.json에 린트 명령어를 추가한다.

1
2
3
4
5
6
7
8
// package.json
{
...
"scripts": {
"lint": "eslint \"./src/**/*.ts\""
},
...
}

프로젝트는 VSCode를 종료했다 새로 열면 빨간 줄을 열심히 보여준다. rules 중에 본인이 편한 방식과 안 맞는다면, rules에 해당 이름을 추가해서 바꿔주거나 끌 수 있다.

그런데, 필자의 방식에서 해결하지 못 한게 있는데, import/no-unresolved, no-unresolved와 Type을 사용할 때 no-unused-vars가 에러를 나타낸다는 점을 해결하지 못했다. 그리고 코드 스타일에 맞게 여기 저기 rules를 껐는데, 결과적으로 rules 부분은 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
...
"rules": {
"quotes": [2, "double"],
"class-methods-use-this": "off",
"import/no-named-as-default-member": "off",
"import/no-unused-vars": "off",
"import/no-unresolved": "off",
"no-unused-vars": "off",
"max-classes-per-file": "off",
"import/no-extraneous-dependencies": "off",
"import/extensions": "off",
"no-useless-constructor": "off",
"no-empty-function": "off"
}
...
}

코드 스타일에 맞게도 그렇지만, TS를 제대로 인식 못 해서 useless하지 않은 것들을 useless라고 판단하는 이상한 것들이 많았다. TSLint는 ESLint와 통합된다고 한 걸로 알고 있는데, 이렇게 구지면 대안이 뭔가 싶다. (gts를 사용해볼 것을 추천한다… 필자는 써본 적은 없는데, 갓글이니까 훨씬 낫겠지)


참고로 alias 설정 때문에, 위 과정을 쭉 따라오고 있는 사람은 바로 돌려 볼 수도 없다. 데이터베이스도 연결 안 되어 있기 때문에. 데이터베이스는 마지막에 연결할 예정이다. 지금까지 과정을 확인하려면, import할 때 alias 사용하지 말고, dbLoader를 실행하는 부분 주석처리 하고, 디렉토리가 아니라 파일 .env를 최상단에 만든 다음, router 부분에서 v1으로 라우팅 되는 부분을 주석 해주면 된다. 서버가 돌아가긴 한다 (물론 ts-loader를 사용해서 index.ts를 실행 시켜야 한다. 또는 간단하게 빌드 해서 빌드된 파일을 돌려보면 된다.).

다음 글에서는 .env가 최상단에 있는 상황을 가정하고 진행한다. (.envs 디렉토리도 있음) .env는 이후 local.env로 사용하시면 됩니다.

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

후기

글이 생각보다 너무 길어져서 끊어서 작성하려 하지만, 이미 너무 긴 감이 있다. 그리고 글쓰기 너무 힘들었다. 귀찮아서 대충 한 부분이 있나 싶기도 하다.

Reference

댓글

Your browser is out-of-date!

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

×