EMP 프로젝트 타입스크립트 마이그레이션 회고
리펙토링 한걸음 내딛기
이전에 글로 소개했었던 EMP 프로젝트를 리펙토링 하기로 결정했다.
두번째 팀프로젝트를 타입스크립트를 진행하고, 세번째 팀프로젝트를 진행했을때 기술 스택이 JS로 정해져서 굉장히 아쉬웠다. 그러나 어쩌겠는가 혼자하는게 아니라 팀프로젝트이고 완성은 혼자 할 수 없다.
그렇게 진행해서 벡엔드 단에서 정해진건 Express와 JavaScript를 사용해서 만들었다. 이러쿵 저러쿵 다사다난하지만 완성을 했다. 프로젝트를 많이 해본건 아니지만 완성의 기분은 좋다(뒤에 찜찜한 느낌이 있었지만)
리펙토링을 항상 염두해서 작성했지만 프로젝트가 끝나고 나서 막상 타입스크립트로 전환 시작해야지 했는데 일단 벡엔드 소스코드를 보는데 답답하게 느껴져서 프로젝트 구조부터 다르게 진행해보기로 했다.
아키텍쳐 재구성
몇가지를 필수적으로 생각하고 기능에 대한 로직만 이사하고 새로만들기로 했다.
- 당연히 프로젝트 구성
1 2 3
client <-> Express server(API server) <-> MySQL DB + ML server이 아키텍쳐는 내가 건들수 없는 부분이 아니었으니 pass
3-tier 아키텍쳐
프레젠테이션 레이어(컨트롤러), 비즈니스 레이어(서비스), 데이터 엑세스 레이어(프리즈마)
아직 무료 크레딧 남은 GCP DB(이제는 끝낫다.. 잘가라..)
말이 필요한가? 무료다
테스트 계정들과 테스트 플레이리스트들도 있으니 그대로 쓰면된다
에러처리 전략
AppError를 확장해서 cause로 체이닝하는 개념을 적용한 에러처리는 맘에들어서 이삿짐에 넣었다
- winston 로깅
도입기
1차적으로 도입하고 싶던 tool이 있었다.
eslint인데 프로젝트에 이미 존재는 했다. 팀원 모두 lint에 익숙하지 않아 lint오류가 있는 상태에서 커밋해서 올리는 경우가 많아서 lint staged + husky를 도입했다.
1
2
3
4
5
6
7
8
9
10
{
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"*.{ts,tsx}": ["eslint --fix", "prettier --write"]
}
}
1. eslint(lint staged + husky)
간단하게 정리하면 git staged에 올라가있는 파일들에 대해 커밋완료 이전에 lint 설정에 어긋나지 않았는지 확인하는 역할을 한다.
1
2
3
4
5
> git add 0000
# add 된 파일들에 대해 정의한 lint설정 및 체크
> git commit
# commit이 완료되었다면 정의한 lint 설정들을 통과한 파일들
# 추가로 prettier도 설정해두었다면 prettier까지 적용되어서 파일 내용이 재작성된다
후기:
처음에 도입했을땐 tsconfig와 eslint설정으로 인해서 commit조차 시도를 못했다. 필자는 작동 확인 후에 커밋을 해서 잘 작동되는지 test commit만 시도 한 후에 tsconfig와 lint 규칙이 작동하는지 확인을 안하고 진행했었다. 이 부분에서 문제였는데 lint을 warn으로 두는게 아니라 error로 규칙을 잡아두어서 해결하기 전까지는remote repository에 올릴 수 조차 없었다.
아주 멘붕인 상황 + 이전에도 lint 잘 지킬걸 공부좀 할걸 하는 후회가 엄습했다. 결국 작성한 코드가 타입체크와 lint 규칙을 통과하게 아주 만족스러운 리펙토링 과정을 거쳐서 remote repository에 올릴 수 있었다 ㅋㅋ…
2. 타입정의
애초부터 잘 만들어야했다.
이번에 마이그레이션하면서 뼈아프게 느꼇다.
프로젝트에 대해서 대부분 알고 있다고 생각했는데 그게 아니었다.
처음부터 타입들을 생각하고 진행한게 아닌 기능 완성 및 시간에 쫓기다시피 작성을 하다보니 코드에 대한 허점도 많고, 여기에 뭐가 들어가는지만 알지 세세하게 알지 못했다.
당당하게 any 안쓰는게 당연하지 하고 tsconfig에 noImplicitAny를 설정하고 '@typescript-eslint/no-explicit-any': 'error'로 설정했다가 피봤다.
any를 지양하는 것은 좋지만, 외부 라이브러리에서 가져올때는 any가 필요하다는걸 크게 배웠다.
Prisma Client로 데이터베이스에서 정의한 타입을 사용해서 동적 쿼리 처리할때 any로 반환되는 경우를 생각조차 못하고 있었다
prisma에서 지정한 타입을 타입스크립트에서 자동으로 변환해주지 않아서 왜 오류가 뜨는지 모르는 상태로 몇시간 삽질을 했다.
역시 인터넷은 지식 저장소가 아니라 삽질의 저장소다. 남이 삽질한거 보면 내 문제도 해결, 아주 집단 지성이 좋지만 남의 삽질과 내 삽질을 비교해야하니 힘들다.
삽질은 크게
- 타입스크립트에서만 지정 -> prisma 클라이언트와 다름 오류
- DTO 정의하면서 타입 지정 -> prisma 와 불일치
- 프리즈마 import로만 사용 + 타입스크립트에서 지정 -> 역시 오류
해결은 prisma와 prisma client import로 import { Provider } from '@prisma/client'; 이런식으로 확실하게 불러줘야한다.
또 한가지 문제가 있었는데 prisma 이벤트 핸들링 할때 any문제 였다. 프리즈마 이벤트들도 로깅하려고 프리즈마 이벤트 핸들링을 정의했어야했는데
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
private static setupLogging(): void {
if (!PrismaService.instance) return;
const prisma = PrismaService.instance;
// Prisma의 타입 시스템 제한으로 인해 any 타입 사용
(prisma as any).$on('query', (event: PrismaEventType['query']) => {
logger.debug('Prisma Query:', {
query: event.query,
params: event.params,
duration: event.duration,
timestamp: event.timestamp,
});
});
(prisma as any).$on('error', (event: PrismaEventType['error']) => {
logger.error('Prisma Error:', {
message: event.message,
timestamp: event.timestamp,
target: event.target,
});
});
(prisma as any).$on('info', (event: PrismaEventType['info']) => {
logger.info('Prisma Info:', {
message: event.message,
timestamp: event.timestamp,
});
});
(prisma as any).$on('warn', (event: PrismaEventType['warn']) => {
logger.warn('Prisma Warning:', {
message: event.message,
timestamp: event.timestamp,
});
});
}
이 부분은 gpt가 답을 찾았다. 현재 prisma의 이벤트 타입(query, error, info, warn)들은 Prisma.QeuryEvent와 Prisma.LogEvent 타입으로 정의되어서 $on으로 핸들링 했을때 핸들링이 안된다.
필자는 결국 as any를 채택해서 any를 사용했지만 여러 방안이 있다.
타입 가드 활용: 특정 타입 가드 함수를 만들어서 query/error/info 등… 상황에 맞는 이벤트를 검사해서 각 타입에 맞게 캐스팅해서 쓰는 경우
any 대신에 unknown 활용: 타입스크립트에서는 unknown이 any보다 안전하니까(any는 다 통과시키고, unknown은 타입 추론 이전까지는 제한이다) unknown을 사용하는 것도 방법이다
prisma에서 이 부분은 빠르게 지원해줬으면 좋겠다.
3. gracefulShutdown
이 기능은 JS로 프로젝트 설계했을때에도 만들었어야 하는 기능인데 시간에 쫓겨서 못 넣은 기능이었다.
서버가 갑작스럽게 종료되는걸 방지(어떤 요인에 의해서 강제종료되더라도)
실행중인 작업을 안전하게 종료(작업이 끝난 후에 종료)
서버 종료 시에 db 연결을 적절하게 정리
로그를 남겨서 어떻게 종료되었는지 확인
이정도로 구성했다.
코드는 이런식으로 구성했다
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
private setupGracefulShutdown(): void {
const gracefulShutdown = async (signal: string) => {
logger.info(`${signal} signal received: closing HTTP server`);
try {
await PrismaService.disconnect();
logger.info('Server closed');
process.exit(0);
} catch (error) {
logger.error('Error during graceful shutdown:', error);
process.exit(1);
}
};
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
process.on('unhandledRejection', (reason: unknown) => {
logger.error('Unhandled Promise rejection:', reason);
});
process.on('uncaughtException', (error: Error) => {
logger.error('Uncaught Exception:', error);
process.exit(1);
});
}
보통 시그널을 받아서 종료하는데
SIGTERM: 프로세스 종료 시그널로 일반적인 종료시에 요청하는 시그널SIGINT: 이건 keyboard interrupt 시그널로 많이하는 ctrl+c로 종료할때 받는 시그널이다.
프로세스를 단계별로 정리하자면
시그널 수신 + 로깅
DB 연결 종료
성공 실패에 따라서 종료코드로 종료하기
3.1 종료 코드
process.exit(0)은 정상 종료이고 3.2 종료 코드process.exit(1)은 비정상 종료이다
이런식으로 정리할 수 있을 것 같다.
현재는 DB연결에 대한 부분에 사용하고 있는데 서비스가 복잡해지는 경우에는 도커같은 서비스 사용할때 정상적으로 task를 마무리하고 종료하는데 좋게 쓰이지 않을까 싶다.
4. Validation
JS-EMP 플젝에서에서 만들다가 유저 정보는 validation까지 필요없을것 같아 진행 도중에 폐기한 기능이다. 타입스크립트의 타입체크는 물론이고 플레이리스트관련은 validation이 있었으면 해서 생각하던 중, AI model server에서만 아주 약하게 validation하고 넘어갓는데 API서버에서 요청을 넘기는 부분에서 막으면 좋을 것 같아서 기능을 추가했다.
AI model server에서 걸러진다는건 결국 API 서버를 거쳐서 리소스를 사용해서 응답을 받는 것이기 때문에 API 서버 자체에서 blocking을 하는 것이 성능적으로 더 이득일것이라고 생각했다.
단계별로 코드를 작성했다.
- 기본 validation type 정의하기(shared/types/validation.types.ts)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
// 검증 시의 오류들 어디에서 발생했는지와 message export interface ValidationError { field: string; message: string; } // 전체 검증경과 불리언과 에러들 파악 export interface ValidationResult { isValid: boolean; errors: ValidationError[]; } // 검증 규칙 정의 export type ValidationRule<T> = { validate: (value: T) => boolean; // 여기에서 값을 검증하는 함수로 message: string; // 여기에서 검증 실패하면 message }; // 각 필드마다 적용할 규칙 정의 export type Validator<T> = { [K in keyof T]?: ValidationRule<T[K]>[]; };
validationUtils (shared/utils/validatort.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 40 41
// 문자열, 빈 문자열 확인, 숫자인지, 불리언인지 등 미리 정의 export class ValidationUtils { static isString(value: unknown): value is string { return typeof value === 'string'; } static isNumber(value: unknown): value is number { return typeof value === 'number' && !isNaN(value); } static isBoolean(value: unknown): value is boolean { return typeof value === 'boolean'; } static isEmail(value: string): boolean { const emailRegex = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,6}$/; return emailRegex.test(value); } static isNotEmpty(value: string): boolean { return value.trim().length > 0; } static isLength(value: string, min: number, max: number): boolean { const length = value.trim().length; return length >= min && length <= max; } static isArray(value: unknown): value is unknown[] { return Array.isArray(value); } static isValidURL(value: string): boolean { try { new URL(value); return true; } catch { return false; } } }
DTO 검증 (DTOValidator.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 40 41 42 43 44 45 46 47 48 49 50 51 52 53
// dto.validator.ts export class DTOValidator { static validate<T extends object>(validator: Validator<T>) { return async (req: Request, _res: Response, next: NextFunction) => { try { const validationResult = this.validateObject(req.body, validator); if (!validationResult.isValid) { throw new AppError( COMMON_ERROR.VALIDATION_ERROR.name, '입력값 검증 실패', { statusCode: COMMON_ERROR.VALIDATION_ERROR.statusCode, cause: new Error(JSON.stringify(validationResult.errors)) } ); } next(); } catch (error) { next(error); } }; } private static validateObject<T extends object>( data: T, validator: Validator<T> ): ValidationResult { const errors: ValidationError[] = []; Object.entries(validator).forEach(([field, rules]) => { const value = data[field as keyof T]; if (rules && Array.isArray(rules)) { for (const rule of rules) { if (!rule.validate(value)) { errors.push({ field, message: rule.message }); break; } } } }); return { isValid: errors.length === 0, errors }; } }
5. 싱글톤 패턴
서비스 클래스에 싱글톤을 도입했다. 싱글톤의 특징은 특정 클래스가 하나의 인스턴스 만을 가지게 보장하여, 이 인스턴스에 전역적으로 접근 가능하게 하는 것이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
export class UserService {
private static instance: UserService;
private readonly logger = createLogger(config);
private constructor() {} // 여기서 다른 곳에서 생성자를 못만들게 막고
// 여기서 싱글톤 인스턴스에 접근할 수 있다.
public static getInstance(): UserService {
if (!UserService.instance) {
UserService.instance = new UserService();
}
return UserService.instance;
}
}
알기 쉬운 예로, 설정을 불러 올때 사용했다.
현재는 Express를 사용해서 Typescript만으로는 의존성 주입(Dependency Injection)을 구현하기 어려울 것 같고, Nest.js를 사용했을때 구현해보려고 한다.
마치며
프로젝트 기간 동안 정말 기능 구현에만 급급해서 코드 퀄리티는 거의 없다시피 만든걸 확인한 리팩토링이었다.
지금도 보완할 점들이 많이 보이는데 다음 플젝의 프레임워크를 nest.js를 사용해서 조금더 높은 수준을 구현해보려고한다.