라이켓은 다양한 문화생활 정보를 공유하고 나만의 문화생활 기록을 남길 수 있는 서비스를 제공하고 있습니다.
태그별, 지역별로 관심있는 정보들만 골라보고 쉽게 문화생활을 즐겨보세요.
-> 사이트 바로가기: https://liket.site
-> IOS 앱 링크
안녕하세요. LIKET 백엔드 개발자 민경찬입니다.
LIKET의 백엔드 개발을 시작한 지도 어느덧 1년이 넘었습니다.
1년이라는 시간 동안 서버에는 많은 변화가 있었습니다.
그 중에서도, Nestia에 대해서 얘기해보려합니다.
LIKET 백엔드는 초창기 Express로 구현되어 있었으나, 몇 가지 이유로 Nest.js로 넘어갔어요.
Nest.js를 처음 사용해보았고 그 편리함에 감탄했습니다.
API를 추가하는 작업도 간편했으며, 레이어를 나누고 테스트 코드를 작성하는게 별다른 설정 없이 쉽게 이루어졌으니 말이죠.
하지만, 그런 Nest.js의 편리함 속에서도 API 문서화라는 것은 점점 문제가 되고 있었어요.
저희는 API 문서를 Notion으로 관리했으니 말이에요.
서비스가 점점 발전하면서 모델 수정이 빈번해졌고, API를 추가할 때마다 노션에 문서를 업데이트 해야했습니다.
그러던 중, AuthGuard
의 예외 상태 코드를 추가해야하는 상황이 생겼어요. 이미 많은 컨트롤러에서 AuthGuard
를 사용하고 있었고, 상태 코드 하나를 추가하려면 노션의 모든 API 예외 상태 코드를 수정해야했죠.
이 상황을 해결해고자, Nest.js 오픈 톡방에 많은 질문을 하였고 Nestia가 좋은 해결책이 될 것 같았습니다.
Nestia는 Typescript 기반의 자동 API 문서화 및 클라이언트 SDK 생성을 지원하는 라이브러리입니다.
Nestia
는 자동 API 문서화 기능이 있어, 기존 문서화에 대한 문제점을 해결해주었습니다.
타입과 주석들을 가지고 스웨거가 자동으로 생겼으니 크게 건드려줄 것도 없었어요.
/**
* Login API
*
* @summary Login API
*
* @tag Auth
*/
@Post('/local')
@HttpCode(200)
@TypedException<ExceptionDto>(400, 'Invalid email or password format')
@TypedException<ExceptionDto>(401, 'Wrong email or password')
@TypedException<ExceptionDto>(403, 'Suspended User')
@TypedException<ExceptionDto>(500, 'Server Error')
public async login(@Body() loginDto: LoginDto): Promise<LoginResponseDto> {
const token = await this.authService.login(loginDto);
return { token };
}
코드 작성에 큰 어려움도 없었습니다.
Nestia를 사용하여, 어렵지않게 원하는 바를 달성했어요.
그러나 시간이 지나고 프로젝트 코드가 1차 완성을 향해 달려갈 때쯤 느꼈어요.
코드가 제손을 떠난다고 말이죠
무엇 때문이였을까 고민해봤습니다.
Nest.js의 첫 인상은 말 그대로 신세계였습니다. 이미 필요한 준비물이 다 있는 것 같았죠.
API를 하나 만들었다고 가정해볼게요.
@Controller("something")
export class AppController {
constructor(private readonly appService: AppService) {}
@Get("/all")
public async getSomethingAll() {
return await this.appService.getSomethingAll();
}
}
이 코드에서, appService
는 어떻게 만들어지는 것이며, getSomethingAll
메서드는 어떻게 자동으로 API가 되는 것일까요?
저는 이 의문을 해결하지 않고 서비스를 개발했어요.
이 사실을 모르더라도 개발에 지장이 없었거든요!
저에게 Nest.js란 하나의 블랙박스와도 같았어요.
그런 와중에 Nestia를 도입하였습니다.
@Controller("something")
export class AppController {
constructor(private readonly appService: AppService) {}
/**
* 무언가를 하는 메서드
*
* @tag Something
*/
@TypedRoute.Get("/all") // Get -> TypedRoute.Get
public async getSomethingAll(): Promise<void> {
return await this.appService.getSomethingAll();
}
}
Nestia를 사용하게 되며, 메서드 데코레이터는 TypedRoute
로 변경 되었습니다. Body
, Query
등등 데코레이터도 마찬가지이죠.
그러나, 왜 그것을 사용해야하는지 전혀 이해할 수 없었어요.
문서를 본다고해서 이해할 수 있는 것도 아니였습니다. 뭐 그래도 빠르다고 하니까요! Typia 검증이 돌아가려면 써야한다고 하니까요...!
이젠 블랙 박스를 더 깊은 블랙 박스로 감싸버린 셈이네요.
그러나, 이런 해결되지 않은 지식들은 결국 문제가 되고 말았어요.
Nestia는 훌륭한 기술이지만, Nest.js조차 어려웠던 당시 저에겐 독이었습니다.
결국, 기본 모델 설계조차 점점 꼬이기 시작했습니다.
서비스 기능을 완전히 구현하기 위해서는 아래 요구 사항을 만족할 필요가 있었습니다.
이런 요구 사항을 반영하려니, 모델은 점점 복잡해졌습니다.
그러다보니, 모델의 프로퍼티를 수정해야할 때, 그 코드 변경에 대한 영향이 어디까지 미칠지 계산이 되지 않았어요.
게다가, 몇몇 프로퍼티는 Generic을 사용하면서 스웨거 문서에 제대로 표시되지 않는 문제도 있었죠.
이 시점에서 모델의 기본 설계 조차 잘못되었구나를 느꼈습니다.
그리고 , E2E 테스트를 도입하려던 당시, Prisma
객체를 프록시 패턴으로 주입하려던 때도 있었어요.
각 테스트가 독립적으로 실행되고 병렬 수행이 가능하도록, 매 테스트마다 새로운 트랜잭션을 생성하려 했죠.
그러나 이를 구현하는 과정에서 서버에서 여러 문제가 발생했습니다.
더 큰 문제는 이 문제가 Nest.js를 잘못 사용한 것인지, Nestia를 잘못 사용해서 발생한 문제인지조차 판단하기 어려웠다는 점이었습니다.
(누가봐도 Nest.js 잘못 사용한 것일텐데 말이에요)
그렇기에 큰 결심을 했죠...
Nestia를 제거한다는 것은 Typia를 비롯한 모든 검증/변환 코드들을 CV로 바꾼다는 것이였고 코드 전반을 재설계해야하는 일이였어요.
하지만 코드가 제 손을 벗어나 책임자인 저 조차도 다룰 수 없는 일은 절대로 발생해서는 안 된다고 생각했습니다.
이에 눈물을 머금고, Nestia를 제거하였습니다.
꽤 오래 걸렸지만, Nestia를 걷어낸 후 Nest.js의 진짜 모습을 보기 시작했습니다.
@ApiProperty
와 @ApiResponse
같은 데코레이터들은 코드가 너무 지저분해진다고 생각했어요.
Nestia없이는 다시 이놈들에게 신세를 져야할 줄 알았죠.
그러나 Nest.js에서도 타입 기반 스웨거 문서 작성 기능을 지원하고 있습니다.
/**
* 프로필 수정하기
*
* @author jochongs
*/
@Put('/my/profile')
@HttpCode(201)
@LoginAuth()
public async updateUserInfo(
@User() loginUser: LoginUser,
@Body() updateDto: UpdateProfileDto,
): Promise<void> {
await this.userService.updateProfile(loginUser.idx, updateDto);
return;
}
Nest.js CLI 플러그인을 사용하면 이를 간단하게 구현할 수 있어요.
덕분에, 컨트롤러 메서드의 리턴 타입과 파라미터 데코레이터들로만 스웨거를 구성할 수 있게 되었습니다.
처음에는 "왜 모델을 클래스 기반으로 만들어야 하지?" 하는 의문이 들었어요.
하지만 인터페이스와 타입을 사용하던 방식과 비교해보니, 클래스는 데이터 모델링을 더 직관적으로 표현할 수 있었습니다.
// 인터페이스 기반 모델
interface TosEntity {
idx: number;
title: string;
contents: string;
isEssential: boolean;
}
const entity: TosEntity = {
idx: 1,
title: "약관",
contents: "내용",
isEssential: true
};
인터페이스는 단순히 데이터 구조를 정의할 뿐, 데이터 자체를 다루는 기능은 포함되지 않아요.
즉, Prisma에서 데이터를 가져와서 가공하려면 별도의 변환 함수를 만들어야 하죠.
하지만 클래스를 사용하면, 정적 메서드와 생성자를 통해 이를 함께 묶을 수 있어 관리가 용이해지더라구요.
export class TosEntity {
public idx: number;
public title: string;
public contents: string;
public isEssential: boolean;
constructor(data: TosEntity) {
Object.assign(this, data);
}
public static fromPrisma(data: Prisma.Tos): TosEntity {
return new TosEntity({
idx: data.idx,
title: data.title,
contents: data.contents,
isEssential: data.isEssential
});
}
}
게다가 클래스는 런타임에도 살아 있기 때문에, Nest.js에서 데이터를 다룰 때 직관적으로 이해할 수 있어요.
반면, 인터페이스와 타입은 컴파일 이후에 사라지기 때문에 실제 실행 중에는 타입 정보가 남지 않죠.
이해가 깊어질수록, Nest.js에서 제공하는 PickType
, PartialType
, OmitType
같은 Mapped Type도 훨씬 자연스럽게 다가왔어요.
Mapped Type이 직관적으로 다가온 이유는 다음과 같아요.
즉, 어디까지 JavaScript 런타임에서 사용 가능한 코드인지 경계가 명확해지니, 이해가 더 쉬웠던 것 같습니다.
class-validator(이하 cv)는 오픈 톡방에서 들었던 것만큼 불편하지 않았어요.
오히려 Nest.js 환경에서는 직관적인 방식으로 활용할 수 있어서, 입문자에게 더 이해하기 쉬운 도구라고 느꼈습니다.
export class ResetPwDto {
/**
* 현재 비밀번호
*
* @example aA12341234**
*/
@Matches(/정규표현식/)
currPw: string;
/**
* 변경하려는 비밀번호
*
* @example 변경하려는비밀번호
*/
@Matches(/정규표현식/)
resetPw: string;
}
Typescript의 타입은 컴파일 타임에서 사라지지만, 데코레이터를 사용하면 이 타입 정보를 런타임에서도 활용할 수 있습니다.
즉, DTO를 클래스로 정의하면 TypeScript에서 타입을 지정하는 동시에, 런타임에서도 이 정보가 유지되어 검증 로직에서 그대로 사용됩니다.
이런 점에서 class-validator는 Typescript를 사용하면서도 Javascript에서 실제로 어떤 코드가 동작하는지 직관적으로 파악하기 좋은 도구라고 생각해요.
물론 처음에는 reflect-metadata가 낯설어 어렵게 느껴질 수 있습니다.
그러나, 다양한 예제 코드가 존재하기 때문에 "이 검증이 런타임에 어떻게 적용될까?" 라는 질문에 대한 답을 비교적 쉽게 찾을 수 있었어요.
반면에, nestia + typia 조합은 순수 Type으로 모든 것을 해결할 수 있어, 어떤 코드가 Javascript에서 살아있을 지 직관적으로 이해하기 힘들었어요.
interface ResetPw {
/**
* 현재 비밀번호
*
* @example aA12341234**
*/
currPw: string & tags.Pattern<"정규표현식">;
/**
* 변경하려는 비밀번호
*
* @example 변경하려는비밀번호
*/
resetPw: string & tags.Pattern<"정규표현식">;
}
타입이란게 기본적으로 Javascript 세상에서 사라지기 때문에, 직관적으로 이해가 잘 되지 않더군요.
당시에는 데코레이터도 하나의 마법처럼 보였으니 typia의 마법은 고도로 진화한 흑마법 처럼 보일 수 밖에 없었나봅니다.
class와 cv사용을 통해, typia와 interface 문법으로 만들어졌던 기존 모델의 문제점을 점점 명확하게 드러낼 수 있었어요.
class와 cv는 쉽게 배울 예시 코드도 많았습니다.
(삼촌님 코드는 아직 제게 너무 어렵더군요...)
정리해보자면, 아래 두 가지 이유로 초보자 분들께는 CV를 먼저 사용해보는 것을 추천드려요.
Nestia 없이 순수한 Nest.js만으로 프로젝트를 운영한 지 어느덧 1년이 지났습니다.
그 사이에 서버는 많은 일이 있었죠.
Transactional
데코레이터와 프록시 주입 패턴을 사용한 서비스 코드에 테스트 별 DB 독립성을 보장할 수 있도록 테스트 환경 마련서버에는 300개의 E2E 테스트가 존재하며 서버도 안정적으로 잘 운영되고 있습니다.
Nest.js를 사용하면 할 수록, 데코레이터와 친해지고 Type이 런타임에서 어떻게 사용되고 어떻게 꺼내지는 것인지 하나씩 이해되기 시작했어요.
또한, Caching 데코레이터나 Error 예외를 다른 예외로 변경하는 커스텀 데코레이터를 discoveryService
로 만들어 사용할 실력이 되었어요.
이젠, Nestia가 다르게 보이기 시작했습니다.
아마도, 이미 Nest.js를 잘 다루시는 대부분의 개발자분들은 앞서 제가 얘기했던 모델과 Class 이야기를 할 때 의문이 드셨을 것 같아요.
음? 그냥 모델 잘못짠거아님?
주입 안되는게 왜 Nestia 잘못?
음? nestia써도 class 쓸 수 있는데?
맞습니다. 앞서 언급한 문제들은 전부 Nestia를 썼기 때문에 발생한 문제점이 아니였어요. Nestia를 사용 여부와는 전혀 관계 없는 문제들이죠.
그러나, Nest.js 조차도 버거운 시절에 Nestia를 도입했고 부족한 지식 속에 도입한 새로운 기술은 저에게 너무나도 큰 독이 되었습니다.
Nestia는 정말 편리한 기술이었지만, 내부 동작을 깊이 이해하지 못한 채 사용하다 보니, 예상치 못한 문제들이 하나둘 터지기 시작했습니다. 결국 Nest.js도 Nestia도 저에게는 마치 블랙박스 같은 존재였고, 디버깅이 불가능한 상황까지 발생했어요.
대가는 혹독했죠. Nestia를 걷어내던 당시 프로젝트가 거의 새로 개발되었을 정도로 엎어야 했으니 말이에요.
Nestia는 컨트롤러 메서드의 리턴 타입을 통해 응답을 검증합니다.
위 이미지처럼, IBbsArticle
타입과 실제 리턴되는 값이 일치하지 않으면 에러가 발생합니다.
처음에는 왜 응답을 검증해야 하는지 이해하기 어려웠습니다.
하지만, 모델을 설계하면서 몇 번의 코드 실수를 겪고 나서야 그 필요성을 깨달았어요.
Typescript는 초과 프로퍼티를 검사해주지 않습니다.
type User = {
nickname: string;
}
class SomeController {
@Get()
public async someMethod(): Promise<User> {
const user = {
nickname: 'jochongs',
매우매우매우민감한데이터: '작성자는바보다.',
};
return user;
}
}
컨트롤러에서는 매우매우매우민감한데이터
를 리턴하고 있어요.
그러나, TypeScript는 이를 오류로 인식하지 않습니다. 결국, 원치 않는 데이터가 클라이언트에 노출될 수도 있죠.
Nestia의 응답 검증 기능은 이런 실수를 방지하는 데 큰 도움이 됩니다.
Nest.js가 기본적으로 지원하는 Mapped Type은 불필요한 추상화 과정이 꼭 발생하더라구요.
예를 들어, UserModel
과 게시글의 모델인 BoardModel
을 설계해볼게요.
class UserModel {
id: string;
nickname: string;
proflieImg: string | null;
초초초예민한데이터: Choyemin;
}
class BoardModel {
id: string;
title: string;
contents: string;
author: ???
}
작성자 모델에는 초예민한 데이터가 포함되어있습니다. 따라서 BoardModel
에서는 author의 타입이 UserModel
이 될 수 없죠.
어떻게 해야할까요?
BoardAuthorModel
을 만드는 것입니다.
class BoardAuthorModel extends OmitType(UserModel, ['초초초예민한데이터'] as const) {}
이렇게 말이죠.
그러나,모델이 복잡해지면 이런 중간 모델이 너무 많이 생기게 됩니다.
Typescript의 Utility Type은 어떨까요?
class UserModel {
id: string;
nickname: string;
proflieImg: string | null;
초초초예민한데이터: Choyemin;
}
class BoardModel {
id: string;
title: string;
contents: string;
author: Omit<UserModel, '초초초예민한데이터'>;
}
중간 모델이 굳이 정의될 필요가 없죠.
Nestia 없이도 Omit 유틸리티 타입은 사용할 수 있어요. 다만, 런타임에 author 필드는 날아가죠. author의 메타 타입은 클래스가 아니기 때문이에요.
E2E 테스트 케이스를 300개 가량 만들면서 테스트 시딩은 항상 문제가 되었어요.
흔히, 떠올릴 수 있는 [게시판] - [댓글] - [사용자] 기능이 있다고 가정해볼게요.
만약, 댓글 수정 기능을 만들었다면 테스트 케이스도 있어야겠죠.
그런데 댓글이 존재하려면 게시판도 존재해야하고 게시판을 작성한 사용자도 있어야해요.
댓글 수정 테스트 케이스를 만드려면 다음 절차가 필요합니다.
이 과정이 너무나도 불필요하다고 느꼈어요.
그래서, 미리 필요한 모든 데이터를 DB에 넣어놨습니다.
그러나 엣지 케이스에 대한 테스트를 만들 때 마다, 테스트 시드 데이터는 복잡해져만갔습니다.
테스트 코드를 만들 때 마다, 시딩된 데이터가 무엇이 있는지 공부 해야하는 것은 덤이에요.
이것을 해결하기 위해, 한 두 줄의 코드로 랜덤 시드 데이터를 삽입하는 시딩 헬퍼 클래스를 설계했어요.
이것으로 모든 테스트 케이스는 독립적인 테스트 환경 속에서 테스트가 진행되죠.
그제서야 typia.random
이 눈에 들어왔어요.
이미 타입은 전부 정의되어있고 fake.js
로 구현했던 시딩 헬퍼 클래스를 이 단 한 줄로 교체할 수 있어보이니 말이에요.
const user = typia.random<User>();
타입만 정의하면 타입에 맞게 랜덤으로 값을 뽑아준다니... 테스트 코드를 작성할 때 이보다도 더 완벽한 기술은 없을 것 같아요.
저는 아직 Nestia를 다 이해하지 못했어요. 그럼에도 불구하고, Nest.js를 다루면 다룰 수록 Nestia의 기능이 점점 매력적으로 보여요.
하지만 Nest.js 입문자 분들께는 꼭 Nest.js만을 먼저 사용해보는 것을 추천드리고 싶습니다.
Nestia는 Typescript와 Nest.js에 대한 선수 지식이 필요한 것 같아요.
혹시 삼촌님께서... Nest.js 공식 문서처럼 쉬운 코드 예시를 만들어주신다면... 크흠 아닙니다.
읽어주셔서 감사드립니다.
P.S. Nestia를 왜 안 썼는지 질문을 받을 때가 종종 있었어요. 그래서 글로 한 번 남겨봤습니다. ㅎㅎ
문제인식 => 문제해결 과정이 진짜 깔끔하네요 잘읽고갑니다