끄적끄적

백엔드 DTO 를 공유받고 싶어요. 본문

개발/js & ts & node.js

백엔드 DTO 를 공유받고 싶어요.

코리이 2023. 2. 26. 02:28

백엔드에서 다른 팀에게 API 문서를 전달하는 방법에는 여러가지가 있다. Swagger 를 사용해서 문서를 전달할 수도 있고 Postman 을 이용해서 API 문서화를 시킬 수도 있다. 자바(스프링) 진영에서는 Rest Docs 와 같이 테스트 코드를 강제화 해서 문서를 만들어내는 방식이 유행하는 것 같다. 하지만 어떻게 문서를 넘기던지간에 프런트 입장에서는 백엔드가 만들어진 문서를 보고 API DTO 들을 다시 만들어 내는 과정이 필수적이며 여간 귀찮은 작업이 아니다.

그런데 우리 회사는 백엔드는 node.js(Nest.js), 프런트는 react-native & react 로 모든 개발 언어를 Typescript 로 맞춰서 개발중에 있다. 그래서 작년 초에 프런트 개발자가 이런 요구를 해왔다. 

어차피 같은 언어로 만든 DTO 들인데 이거 공유해주시면 안되나요?

 

생각해보면 같은 언어로 만든 건데 당연한 요구사항인 것 같다. 그래서 이번 포스팅에서는 필자의 회사에서 어떤 방식으로 DTO 공유를 해 오고 있는지 설명할까 한다.

DTO 공유하기

1. DTO 패키지 분리

우선 프런트에서 공유를 원하는 부분은 단순히 DTO 들이다. 또한 서버 코드가 프런트에 있다면 보안적으로도 좋지 않은 형식이 될 수 있다. 그렇기 때문에 회사에서는 프런트에 공유해야 하는 DTO 파일들을 yarn workspace + lerna 를 이용해 모노레포로 분리하고 있다. yarn workspace + lerna 를 이용한 모노레포의 경우 시간이 나면 한번 정리하겠지만 이미 많은 블로그에서 잘 설명해주고 있으니 참고하면 좋을 듯 싶다. 이 포스팅에서는 생략하고자 한다. 개인적으로 이런 DTO 패키지는 내 소스 파일이 아니라 외부와 소통하는 일종의 "계약" 이라고 생각하여 "contract" 라는 이름을 사용한다.

아래는 이 DTO 파일들을 모아둔 패키지 예시이다. (commands 모듈은 request 를 의미하고, views 모듈은 response 를 의미한다)

contract 패키지

그 후 api 를 개발할때는 모노레포로 개발하듯이 contract 모듈을 불러와서 개발하면 된다. 

import { Body, Controller, Get, HttpCode, Param, Post } from '@nestjs/common';
import { CreateUser, UserView } from '@pawmi/app-sample-contract'; // 분리한 dto 를 불러온다.
import { UserService } from '../services/UserService';

@Controller('api/users')
export class UserController {
    constructor(private readonly userService: UserService) {}

    @Post('')
    @HttpCode(201)
    async createUser(@Body() command: CreateUser): Promise<UserView> {
        await this.userService.createUser(command);
        return this.userService.getUserById({ userId: command.id });
    }

    @Get(':userId')
    @HttpCode(200)
    async getUser(@Param('userId') userId: string): Promise<UserView> {
        return this.userService.getUserById({ userId });
    }
}

2. DTO 를 NPM 패키지로 공유

DTO 패키지 분리까지는 했는데 이를 어떻게 공유할까 고민을 했었다. 고려했던 선택지는 크게 3가지였다.

  1. npm package 로 배포
  2. S3 에 배포
  3. git submodule 활용

이 중에서 git submodule 은 이전에 branch 등 때문에 크고 작은 이슈들이 많이 생긴 경험 때문에 선택하지 않았다. 특히나 yarn workspace 로 분리한 경우에는 더더욱 어울리지 않을 것 같았다.

회사에서는 S3 를 이용해서 이것저것 빌드 파일들 관리를 이미 하고 있어서 이를 이용하는게 어떨까 고려해보았었다. 특히나 앱이 커지면서 CI 속도가 점점 느려지다 보니 성능 향상을 위해서 빌드 파일들을 S3 에 미리 캐시해두고 있다. 그러다보니 이미 DTO 패키지에 대한 빌드 파일이 업로드 되어 있어 프런트에서는 개발 전에 S3 를 다운로드 할 수 있는 스크립트 하나만 있으면 되겠다는 생각이 들었다. 물론 제목에서 나와 있듯이 결정하지 않았는데 그 이유는 프런트에서 파일을 다운로드 받고 사용하는 작업이 생각보다 매끈하게 이루어지지 않았기 때문이다. 하지만 결정하지 않은 가장 큰 이유는 프런트에서 첫번째 방법인 npm package 로 활용하는 것을 더 선호하기 때문이였다.

그래서 github 의 private npm package 를 이용해서 DTO 패키지를 공유했다. 이미 lerna 를 설정했다면 배포하는건 lerna publish from-package 명령어만 입력해주면 되니 배포도 편하게 이루어졌다.

아래는 배포했을때 github 에 나오는 예시이다.

그리고 이제 이를 프런트에 공유해주면 프런트에서는 아래 코드처럼 편하고 type-safe 하게 api 를 만들어 낼 수 있다.

import { CreateUser, UserView } from '@pawmi/app-sample-contract';
import axios, { AxiosResponse } from 'axios';

// type safe api
const postCreateUser = async (body: CreateUser): Promise<UserView> => {
    const result = await axios.post<UserView, AxiosResponse<UserView>, CreateUser>(
        `http://localhost:3000/api/users`,
        body
    );
    return result.data
}

// 사용하기
await postCreateUser({
    id: "514a1115-f21e-49ee-b6a6-4cd3fab75255",
    type: "customer",
    age: 25,
    email: "sample@kscory.com",
    password: "1234"
});

SDK 공유하기(Nestia)

지금의 회사에서는 메인으로 사용하는 서버는 지금과 같이 npm package 로 DTO 만을 공유하고 있었다. 그런데 최근 Nest js Korea 밋업에서 발표자 분이 직접 만드신 흥미로운 라이브러리를 소개해 주셨다. 프런트에 DTO 뿐만 아니라 API 요청을 할 수 있는 SDK 를 자동으로 만들어주는 라이브러리다. 즉, 현재 우리 회사에서 하는 방식보다 더 업그레이드 된 방식이라고 생각되었다. 또한 Nestia 를 사용하면 Json 상하차 성능도 비약적으로 상승시킬 수 있다고 하니 일석이조가 아닐 수 없을 것 같았다. 그래서 일단 회사의 다른 간단한 내부 api (프런트 api 가 아니라 서버끼리 소통하는 api) 를 새롭게 개발할때 사용해봤다. 결론부터 이야기하자면 메인으로 사용하는 서버도 전부 바꾸고 싶다는 생각이 들었다.

Nestia 를 세팅할 때 문서 자체가 아직 자세히 나와있지 않아서 애를 좀 먹었었는데 그래도 한번 세팅하고 나니 딱히 건드릴 것이 없이 편하게 개발할 수 있었다. 나중에 시간나면 포스팅으로 남겨봐야 겠다.

우선 Nestia 를 사용하기 위해서는 Controller 의 데코레이터를 변경해 주어야 한다.

import { Controller, HttpCode } from '@nestjs/common';
import { TypedBody, TypedParam, TypedRoute } from '@nestia/core'; // 이 부분들과 관련된 부분들을 변경해준다.
import { CreateUser, UserView } from '../dto';
import { UserService } from '../services/UserService';

@Controller('api/users')
export class UserController {
    constructor(private readonly userService: UserService) {}

    @TypedRoute.Post('')
    @HttpCode(201)
    async createUser(@TypedBody() command: CreateUser): Promise<UserView> {
        await this.userService.createUser(command);
        return this.userService.getUserById({ userId: command.id });
    }

    @TypedRoute.Get(':userId')
    @HttpCode(200)
    async getUser(@TypedParam('userId', 'uuid') userId: string): Promise<UserView> {
        return this.userService.getUserById({ userId });
    }
}

그리고 nestia.config 를 세팅하고

// nestia configuration file
import type sdk from "@nestia/sdk";
    
const NESTIA_CONFIG: sdk.INestiaConfig = {
    input: "src/controllers",
    output: "lib/api", // lib 디렉토리에 생성한다.
    primitive: false,
};
export default NESTIA_CONFIG;

"npx nestia sdk" 명령어를 실행시키면 자동으로 api sdk 가 생성된다. (lib/api 디렉토리 하위에 여러 파일들이 생성된다.)

그리고 이 파일을 공유해주면 아래처럼 사용할 수 있다. 이전과 달라진 점이라면 프런트에서는 dto 를 불러올 필요도 없고 직접 api 를 정의할 필요도 없어지고 단순히 내부 라이브러리 사용하듯 사용하면 된다.

import * as userApi from '../lib/api/functional/api/users' // 공유한다면 공유된 방식으로 불러오면 된다.

const con = { host: "http://127.0.0.1:3000" };
await userApi.createUser(con, {
    id: "514a1115-f21e-49ee-b6a6-4cd3fab75255",
    type: "customer",
    age: 25,
    email: "sample@pawmi.com",
    password: "1234"
})

내부 서버끼리 소통할 때도 API 를 주로 활용하는데 이를 위해서 이것저것 정의하지 않고 통일된 규격으로 쉽게 활용할 수 있다는 점이 편리했다. 물론 현재 회사는 npm package 로 DTO 를 공유하는 방식을 주로 사용하고 있지만 시간이 날 때마다 조금씩 바꿔보는 것도 좋을 듯 싶다. 다만 모노레포를 적용하는 방법을 잘 몰라서 테스트는 좀 더 필요할 것 같다.

결론

nodejs 를 활용하는 많은 회사들은 대부분 DTO 를 어떤 방식이던지 공유하고자 하는 니즈가 있을 것 같다. 만약 백엔드와 프런트가 다른 언어를 사용한다면 이런 방식을 활용하기는 어려울 것이다. 그래서 이런 부분때문에 grpc 같은 프로토콜이 유행하고 있는게 아닐까 싶다.

하지만 nodejs 를 사용한다면 누릴 수 있는 얼마 안되는 특권인 DTO 공유는 무조건 활용하는 방식으로 가는게 생산성 향상에 있어 정말로 큰 도움이 될 것이다.