TSOA로 HTTP API 개발하기
안녕하세요, 비브로스 백엔드 팀에서 백오피스 개발자로 일하고 있는 허준석입니다. 최근에 비브로스에 입사해 비브로스 백엔드 팀이 일하는 방법을 배우며 TSOA라는 라이브러리를 알게 되었습니다. 타입스크립트로 NodeJS API 서버를 개발해 본 분들이라면 고민했을 몇 가지 문제들을 TSOA가 우아하게 해결하고 있다고 생각되어서 짧은 글로 공유해보려 합니다.
쉽고 편한 Express, 하지만..
NodeJS 로 웹서버를 개발할 때 가장 보편적으로 사용되는 라이브러리는 Express 일 겁니다. NodeJS 애플리케이션으로 들어오는 request의 메서드, uri, 헤더 등을 읽고 그에 맞는 응답을 하는 과정을 아주 손쉽게 처리할 수 있게 해줍니다. 기능이 안정적이고, 유연하며, 확장성도 좋기 때문에 NodeJS 웹서버 생태계에서 Express의 입지는 수년째 굳건합니다.
하지만 타입스크립트 기반의 엔터프라이즈급 API 서버를 Express만 사용해서 만들기 시작하면 여러 아쉬운 부분이 생깁니다.
Promise 미지원
Express는 자바스크립트에 Promise가 정식 도입되기 이전부터 존재했던 라이브러리인 만큼, 비동기 처리를 콜백 방식으로 하도록 만들어져 있습니다. 비즈니스로직에서 비동기 작업을 Promise 기반으로 처리하더라도, 최종적으로 각각의 미들웨어에선 비동기로직 결과를 Express 미들웨어 콜백으로 반환하는 로직으로 일일이 감싸줘야 한다는 것이죠.
// 이렇게 모든 미들웨어는 항상 응답 콜백 또는 next 를 호출해줘야 합니다.
app.get('/mydata/:id', async (req, res, next) => {
try {
const data = await getMyData(req.params.id);
res.status(200).json(data);
} catch (err) {
next(err);
}
});
비즈니스 로직의 비동기 결과를 Express 미들웨어에 맞게 처리해주는 wrapper (또는 interceptor)를 라우터에서 고차함수로 연결해주는 방법을 사용한다면 나름대로 깔끔하게 처리할수 있긴 합니다.
// 아주 간단한 wrapper 함수
const withControl = (control: (req: Request) => any): RequestHandler => {
return async (req, res, next) => {
try {
const response = await control(req);
res.status(200).json(response);
} catch (err) {
res.status(500).json({ message: err.message });
}
}
}
app.get('/mydata/:id', withControl(async (req) => {
const data = await getMyData(req.params.id);
return data;
}));
하지만 결국 Express 라우터에 일일이 wrapper를 걸어줘야 한다는 건 변하지 않으며, 개발자가 애플리케이션 레이어 개발에 꽤나 공을 들여 신경 써야 하는 만큼 비즈니스로직에 집중할 시간을 잃을 수 있다는 우려가 있습니다.
request 타입 검증
Express는 기본적으로 바닐라 자바스크립트 기반으로 작동합니다. 타입 정의가 가능하긴 하지만 실제 request로 들어온 값에 대한 타입 검증 과정을 거치진 않습니다. 때문에 request 데이터에 대한 타입 밸리데이션 로직이 별도로 필요한데요. 밸리데이션 로직도 결국 사람이 일일이 만드는 것인 만큼 타입 밸리데이션 로직과 타입 정의 간 괴리가 생길 위험이 있습니다.
interface MyData {
name: string;
age: number;
}
const submitSomeData: RequestHandler = (req, res, next) => {
const const myData: MyData = req.body
// myData.age 가 실제로 존재할지, 그리고 그 타입이 실제로 숫자일지는 알 수 없습니다.
res.send(myData.age + 1);
}
사실 이건 Express 의 문제라기보다는 자바스크립트와 타입스크립트의 태생적인 한계라고도 할 수 있을 것 같습니다. 자바스크립트는 아주 대표적인 약타입 언어이며, 타입스크립트는 실행시점엔 자바스크립트나 다름없습니다. 이는 타입스크립트를 사용하는 개발자라면 다들 알고 있을 겁니다. 타입스크립트를 자바와 같은 강타입 언어처럼 작동할 거라고 기대하면 안되는 것이겠죠.
그러나 타입 밸리데이션은 제대로 이루어지지 않으면 서비스에 장애가 생기거나, 비즈니스적으로 취약점을 만들 가능성이 있을 정도로 중요한 부분입니다. 아무리 언어적 한계가 있다 하더라도, 이걸 온전히 개발자의 꼼꼼함에만 의존해 처리하는 것은 매우 위험하다고 생각됩니다.
문서화 도구
높은 자유도를 가진 Express는 프로젝트 내의 어느 곳에서 어떻게 API 라우터가 작성되고 사용되어야 하는지 특별히 정해진 게 없습니다. Express를 통해 파싱 된 request 정보를 어떻게 사용하고 어떤 방식으로 응답할지도 온전히 Express를 사용하는 개발자가 마음대로 정할 수 있습 니다.
하지만 이런 자유도는 swagger와 같은 도구로 API 문서화를 하려 하게 되면 약점이 됩니다. 자유도가 없다는 것은 정해진 규칙이나 형식이 없다는 것이고, 문서를 자동 생성하는 게 불가능하다는 뜻이 됩니다. 실제로 현재 시점에서 Express router를 직접 사용해서 만든 API의 문서 자동화 도구는 없다시피 합니다. 개발자가 일일이 작성되어 있는 라우터 소스코드를 읽어가면서 손으로 문서화 작업을 해야 합니다.
이는 단지 귀찮다의 문제로 끝나지 않습니다. 작성한 API 문서와 실제 API 작동 방식에 큰 차이가 있을 수 있고, API의 작동 방식을 수정하면서 API 문서를 업데이트하는 걸 까먹고 넘어갈 가능성이 생기게 됩니다. 이것은 다수의 개발자들이 협업해야 하는 상황에서 개발효율성의 발목을 잡는 큰 요인이 됩니다.
Nest를 사용하면 해결할수 있다?
이런 부족한 부분을 해결할 수 있는 여러 가지 방법들 역시 존재합니다. 대표적으로 Nest 와 같은 프레임워크가 있습니다. Nest는 앞서 언급한 세 가지의 한계점들을 거의 완벽하게 해결할 수 있는 방법들을 내장하고 있습니다. 최근의 Nest 인기가 괜한 게 아님을 알 수 있죠. 비브로스에서도 일부 백엔드 서비스를 Nest로 개발해서 운영하고 있습니다.
그렇 지만 Nest는 기존에 운영되고 있는 서버에 점진적으로 적용하기가 어렵다는 문제가 있습니다. Spring과 같은 프레임워크를 지향하는 Nest는 정해진 파일구조나 제시된 기술 스택을 따라 개발하지 않으면 여러 가지로 어려움이 발생합니다. 운영 중인 프로젝트의 규모가 크다면, Nest에 맞는 구조로 갈아엎는 게 아주 부담스러운 작업이 됩니다.
TSOA
TSOA(TypeScript Open API)는 특정 방식으로 작성된 컨트롤러 코드를 정적 분석해 openAPI 스펙에 맞게 express 등 http 라이브러리에 대응하는 코드로 빌드 해주는 라이브러리입니다.
Nest는 컨트롤러 레이어에서 request 밸리데이션, api 문서화 등을 하기 위해 nest에 내장된 기능들과, nest에서 지원하기로 명시된 서드파티 패키지들을 사용해야 합니다. 예를 들어 class-validator, class-transformer, @nestjs/swagger 등이 있습니다. 그리고 앞서 언급했듯이, 애플리케이션에서 컨트롤러 레이어의 정보를 읽어올 수 있도록 프로젝트 전체 구조가 Nest의 가이드에서 요구하는 대로 구성되어 있어야 합니다.
Nest는 타입스크립트의 타입 정의가 실제 런타임에서 적용되지 않는 괴리를 해결하기 위해 클래스, 데코레이터, 그리고 reflect-metadata를 사용합니다.
그에 비해 TSOA는 프로젝트의 전체 구조를 바꿀 필요는 없고, 함께 사용하도록 강제하는 서드파티 패키지들도 거의 없다시피 합니다. 컨트롤러 클래스를 TSOA에서 요구하는 방법대로 작성하고, 작성한 컨트롤러 파일이 프로젝트의 어느 경로에 위치하는지 등의 정보를 설정 파일로 잘 정의만 해준다면 개발자 스스로 원하는 파일구조를 정해서 개발할 수 있습니다.
TSOA는 런타임에서의 타입 괴리를 해결하기 위해 독특한, (어쩌면 무식한) 방법을 사용합니다. 컨트롤러 코드, 그리고 컨트롤러가 참조하는 타입 파일들을 읽어서 소스코드를 파싱해 그 결과를 런타임에서도 읽을 수 있는 코드로 변환합니다. 이때 타입 정의뿐 아니라 주석까지도 파싱 해냅니다.
그 결과, TSOA를 사용해서 API 개발을 할 때, 인터페이스에 제대로 타입 정의를 하고 주석만 꼼꼼히 써주는 것 만으로도, 별도 작업 없이 request에 대한 타입 밸리데이션, API 문서 자동 생성 등을 할수 있게 됩니다.
컨트롤러 코드
Nest의 Controller 처럼 TSOA 도 class와 decorator로 컨트롤러 레이어를 작성 하도록 하고 있습니다.
// Nest 의 컨트롤러 코드
@Controller('users')
@UseInterceptors(UserInterceptor)
export class UsersController {
constructor(
private readonly usersService: UsersService,
) {}
@Post('register')
@ApiOperation({ summary: '회원가입' })
@ApiResponse({
status: 200,
description: '회원가입 성공',
type: ReadOnlyUser,
})
async register(@Body() body: UserRegisterRequestDTO) {
const user = await this.usersService.register(body);
return user;
}
@Get(':id')
@ApiOperation({ summary: '회원 상세조회' })
@ApiResponse({
status: 200,
description: '회원 상세조회 성공',
type: ReadOnlyUser,
})
getOneUser(
@Param('id', ParseIntPipe, PositiveIntPipe) userId: number,
) {
const user = await this.usersService.getOneUser(userId);
return user;
}
}
// TSOA 의 컨트롤러 코드
@Route('/users')
@Tags('User')
export class UsersController extends Controller {
/**
* 회원 가입
*/
@Post('register')
@OperationId('회원가입')
@SuccessResponse(201, 'Created')
public async registerUser(
@Body() body: RegisterDto
): Promise<CreateUserResponse> {
return UsersService.emailRegister(body);
}
/**
* 유저 상세 조회
*
* @example UserId "5eaf959e29f3034fdb5f04ee"
*/
@Get('{userId}')
@OperationId('유저 상세 조회')
public async getUser(
/**
* User ID
*/
@Path() userId: ObjectIdString
): Promise<GetUserResponse> {
return UsersService.getUser(userId);
}
}
Nest 와 TSOA의 컨트롤러만 같이 놓고 비교해 보면 사용방법이 유사하다는 것을 알 수 있습니다.
둘다 Restful API를 지향하기 위해 엔티티 단위로 컨트롤러 클래스를 만들고, 클래스의 메서드에 decorator를 달아두는 방법으로 실제 route를 만들 수 있도록 하고 있습니다. (컨트롤러에 대해선 Spring의 영향을 받았기에 Nest와 유사한 게 아닐까라고 생각됩니다.)
개인적으로도 이 구조를 좋아하는데, Nest나 TSOA처럼 데코레이터를 지원해 주는 환경에서 더 빛을 발하는 것 같습니다. 앞서 언급했던 express가 Promise를 미지원 하는 문제가 데코레이터를 통해 해결되는 것이기 때문입니다.
각 메서드에 붙어있는 @Get
, @Post
같은 데코레이터가 Promise로 동작하는 메서드의 결과를 express의 콜백과 연결해 주고 express router에 미들웨어로 등록해 주는 일을 내부에서 수행해 주고 있다고 볼 수 있습니다.
라이브러리 내부에서 수작업을 대신해 주니 서비스 개발자는 이런 부분을 크게 신경 쓰지 않고 비즈니스 로직에 더 집중할 수 있게 되는 것이죠.
DTO
TSOA에서 DTO는 클래스일 필요가 없습니다. 인터페이스로 작성해도 됩니다. 그리고 데코레이터가 아닌 jsdoc 형식으로 TSOA가 읽어들일 정보를 작성할 수 있습니다.
/**
* 회원가입 DTO
* @tsoaModel
*/
export interface RegisterDto {
/**
* 이메일 주소
* @pattern ^[a-z0-9\.+_-]+@[a-z]+(\.[a-z]+){1,2}$
* @example "test123@test.com"
*/
email: string;
/**
* 패스워드
* @example "password1234!"
* @minLength 8
* @description 최소 8자 이상 아무 문자열
*/
password: string;
}
이런 식으로 인터페이스를 작성하면, jsdoc 주석 안에 들어있는 정보를 tsoa가 정적 분석해서, 밸리데이션에 활용할 정보는 밸리데이션 로직으로 만들어내고, API 문서화에 활용할 정보는 swagger 스펙 파일로 만들어냅니다. 물론 인터페이스에 정의된 타입 역시 TSOA가 밸리데이션에 사용합니다.
마무리
TSOA의 특징에 대해서 간단하게 공유해 보았습니다. 자세한 사용 방법은 블로그 포스트 하나에 담기에는 많기 때문에 관심이 있으시다면 TSOA 공식 문서를 직접 읽어보는 게 좋을 것 같습니다.
비브로스는 개발자 채용중입니다. [https://www.bbros.co.kr/career]