Fastify 서버 API 정의

byron1st·2021년 8월 17일
3

Fastify 사용기

목록 보기
2/3

Route의 정의는 웹서버 개발에서 가장 중심이 된다고 할 수 있다. 해당 웹서버가 제공하는 API가 무엇인지를 정의하고, 구현하는 일이기 때문이다. 웹서버의 모든 것은 사실 이 API를 제공하기 위해 존재한다고 할 수 있다. Java Spring과 같은 MVC의 세상에서 오신 분들이라면, 내가 말하는 Route는 Controller 라고 이해해도 좋다.

코드 구조

JavaScript/TypeScript 에서 파일과 폴더는 곧 모듈이다. 하나의 단일 파일이 하나의 모듈이 될 수 있고, 폴더 안에 index.ts 을 두어 폴더 자체를 하나의 모듈로 인식되게 할 수 있다. 그렇기에 폴더 구조를 짜는 것이 곧 코드 구조를 짜는 걸로 볼 수 있다.

코드 구조를 바꾸기에 앞서, TypeScript 에서 ../../ 이 난무하는 상대경로에서 내가 정의한 구조를 기반으로 모듈 호출 경로를 정의할 수 있도록 바꾸어보자. 이를 위해, TypeScript 의 Path Alias 기능을 사용할 것이다.

TypeScript Path Alias 정의

TypeScript 에서 Path Alias 를 쓰기 위해, tsconfig.json 파일의 compilerOptions 속성의 baseUrlpaths 속성을 수정한다.

{
  "compilerOptions": {
    //...
    "baseUrl": ".",
    "paths": {
      "@server": ["src/server"],
      "@routes": ["src/routes"],
      "@routes/*": ["src/routes/*"],
      // ...
    }
  }
  // ...
}

paths 의 정의는 내가 이 프로젝트에서 생각하는 모듈의 단위를 보여준다. 위의 정의를 예로 들면, 난 기본적으로 routes 자체르 하나의 커다란 모듈로 보고, routes 폴더 내의 서브 폴더들도 하나씩 서브 모듈로 정의한다. 그래서 예를 들면, 아래와 같이 호출을 하게 된다.

// routes/index.ts
import {createUser} from '@routes/users/create-user'

// server.ts
import routes from '@routes'

이 외에도 개인적으로 @config, @db, @utils/*, @errors 등을 정의해서 사용한다.

TypeScript Path Alias 실행 및 빌드

TypeScript의 Path Alias 는 Visual Studio Code 내에서만 인식이 된다. 그래서 tsc 로 해당 코드를 빌드하게 되면, 빌드된 JavaScript 파일들은 @... 같은 import 경로를 인식하지 못한다. 그래서 이를 상대 경로로 변경해주는 과정이 필요하다.

경로를 바꿔주는 작업은 실행시와 빌드시가 다르다. 물론 항상 빌드해서 JavaScript 파일을 실행할 수도 있다만. 나 같은 경우, 실행 시에는 ts-node 를 이용하고, 빌드 시에는 JavaScript 코드로 바꾼 후 Docker 컨테이너로 만든다. 그래서 두 과정이 다르고, 사용되는 라이브러리도 다르다.

ts-node 로 실행 시

ts-node 로 실행할 때는 tsconfig-paths 라이브러리를 이용한다.

// package.json
{
  "scripts": {
    "start": "ts-node -r tsconfig-paths/register src/index.ts",
    // ...
  },
  // ...
}

빌드 시

빌드 때는 tscpaths를 사용한다. 왠지 tsconfig-pathstsc 와 사용하는 법이 있을 것 같은데, 개인적으로 아직 찾지 못했고, tscpaths 가 문제없이 잘 동작해서 일단은 이것을 쓰고 있다.

tsc -p . && tscpaths -p tsconfig.json -s ./src -o ./dist

사실 ts-node 자체도 매우 성숙한 프로젝트이고, TypeScript 프로젝트는 node 대신 ts-node 를 사용한다고 생각하고 써도 되지 않을까 싶은데, 그래도 TypeScript 번역을 실행 중 한다는 것은 뭔가 심리적 거부감이 있다. 불필요한 성능을 낭비하는 느낌?

Routes 구조 정의

내가 Routes 모듈의 구조를 정의할 때 원칙은 다음과 같다.

  • 서버의 전체 API 개요를 한눈에 볼 수 있어야 함

서버 전체 API 개요를 한눈에 봐야 코드를 내비게이션 하는데 있어 편리하다. 이 서버의 기능이 충분한지 확인하기도 쉽고, 빠진 기능을 체크하기도 쉽다.

  • 각 API 별로 독립적인 파일에 정의되어야 함 (한 파일에 여러 API 동시 개발 금지)

각 API 들은 각자의 Request, Response 정의와 독립적인 기능이 포함되어야 한다. 그래서 온전히 하나의 파일 안에 정해진 템플릿대로 구현이 되어야, 나중에 확인도 쉽고, 버그 추적도 용이하다. 특히 파일 이름을 잘 정해두면, 해당 API에서 문제가 발생했을 때, 빠르게 해당 파일로 이동할 수 있다.

그래서 내가 사용하는 구조는 대충 다음과 같다.

routes
+-- users
|   +-- create-user.ts
|   +-- update-user.ts
|   ...
+-- communities
|   +-- ...
+-- authenticate.ts // 공통 미들웨어
+-- ...             // 공통 미들웨어
+-- index.ts        // API 개요 위치

routes 내의 폴더들은 서버에서 관리하는 Resource 들의 가장 큰 단위로 매핑한다. REST API 라는 것은 서버에서 관리하는 Resource, 즉, 자원들에 대한 경로와 이에 대한 동작(method)을 정의하는 것이다. 이러한 REST API의 정의를 고려하여, routes 폴더의 구조도, 가장 기본이 되는 Resource들 단위로 묶어준다. 가령 이 서버에서 사용자 리스트를 관리하고 있고, 이를 호출하는 REST API 경로가 /users/... 로 시작한다면, users 폴더를 만들어 사용자에 대한 API를 모두 위치시켜준다.

각 API 파일 이름은 API가 하는 일을 이름으로 바로 알 수 있도록 동사+명사로 자세히 적어준다. 그리고 이는 해당 파일이 export default 로 export 하는 함수의 이름으로 정한다.

// create-user.ts

export default async function createUser(/*...*/) {
  // ...
}

이 파일 안에는 오직 사용자를 생성하는 기능에 대한 코드만 위치하게 된다.

index.ts 파일 안에는 전체 API의 개요가 코드로 들어가게 된다. 이를테면, 이런 형태다:

// routes/index.ts

export default function routes(server: FastifyInstance, _: FastifyPluginOptions, done: (err?: Error) => void): void {
  // Users
  server.post('/users', { schema: createUserSchema }, createUser)
  server.get('/users/:userId', { schema: createUserSchema, preValidation: authenticate }, getUser)
  //...

  // Communities
  //...

  done()
}

이런 식으로 하면, index.ts 에서 이 서버가 제공하는 API 들의 리스트가 한눈에 들어오고, REST API의 기본 정보인 method와 자원 경로가 보이고, 해당 API가 수행하는 기능의 개략적인 설명이 함수 이름을 통해 보이게 된다. 그리고 preValidation: authenticate 를 통해, 인증이 필요한 API를 바로 식별할 수 있다.

이 정의를 각 자원별로 분리할 수도 있다.

// routes/index.ts

export default function routes(server: FastifyInstance, _: FastifyPluginOptions, done: (err?: Error) => void): void {
  server.register(usersRoutes, { prefix: '/users' })
  server.register(communitiesRoutes, { prefix: '/communities' })
  //...

  done()
}

하지만, 개인적으로 폴더의 depth 가 너무 깊어지는걸 좋아하지 않아서, index.ts 파일 하나에 API 모두 다 나열하는걸 선호한다.

API 정의

Schema 정의

각 개별 API의 정의는 무엇이 필요할까? API도 결국은 함수이기 때문에, 기본은 입력 파라미터들, 그리고 출력값을 정의하는 것이 출발이다. 이에 대한 정의를 나는 파일의 최상단에 {함수명}Schema 라는 FastifySchema 타입의 변수에 정의한다.

// create-user.ts

export const createUser: FastifySchema = {
  tags: ['users'], // Swagger에서 사용할 정의이다.
  headers: {...},
  params: {...},
  body: {...},
  response: {
    200: {...},
    400: {...}
  }
}

앞서 말했듯이, 이를 schema 속성에 할당하면, Fastify 가 자동으로 Headers, URL params, URL queries, Request Body 등을 검사한다. 형식은 JSON Schema 이다.

이 schema 변수를 routes/index.ts 에서 사용해준다.

// routes/index.ts

export default function routes(server: FastifyInstance, _: FastifyPluginOptions, done: (err?: Error) => void): void {
  // Users
  server.post('/users', { schema: createUserSchema }, createUser)
  // ...
}

Request 정의

아쉽게도 Schema 정의는 TypeScript 와 관계없다. 그래서 TypeScript 내에서 사용하기 위해서는 별도의 interface 를 정의해주어야 한다. FastifyRequest 타입은 아래와 같이 정의되어 있다.

FastifyRequest<RouteGenericInterface, RawServerDefault, RawRequestDefaultExpression<RawServer>>

여기서 우리가 Request Body, Headers, Queries, Params 타입을 정의해주기 위해서는 제일 앞에 있는 RouteGenericInterface 를 상속받아 타입을 정의해주면 된다.

interface CreateUserRequest extends RouteGenericInterface {
  Headers: {...}
  Params: {...}
  Body: {...}
  Querystring: {...}
}

그리고 이 Request 타입을 아래와같이 실제 함수에 할당해주자.

export default async function createUser(request: FastifyRequest<CreateUserRequest>) {
  // request.body. ...
}

Response 정의

Fastify 는 async/await 함수 내에서 throw 를 하면 자동으로 에러로 처리(기본은 500 Internal Server Error 를 던지고, message에 에러 객체의 메세지를 할당함)하고, return 하면 자동으로 200 코드로 return 된 값을 반환한다.

export default async function createUser(request: FastifyRequest<CreateUserRequest>) {
  try{
    //...
  } catch(err) {
    throw new Error('some user message') // 500 Internal Server Error
  }

  return { success: true } // 200, {success:true} JSON 반환
}

그래서 Response의 타입은 간단히 아래와 같이 정의하면 좋다.

interface Response {
  success: boolean
}

export default async function createUser(request: FastifyRequest<CreateUserRequest>): Promise<Response> {
  // ...
}

Fastify Swagger

Fastify 팀은 Swagger 에 대한 지원 또한 공식 지원한다. fastify-swagger 패키지를 설치하자.

import swagger from 'fastify-swagger'

const server: FastifyInstance = fastify()

server.register(swagger, {
  routePrefix: '/doc',
  exposeRoute: true,
  swagger: {
    info: {...},
    host: 'localhost:8080',
    ...
  }
})

Fastify Swagger 지원의 묘미는 각 라우트의 JSON Schema 를 읽어들여 자동으로 문서를 생성한다는 것에 있다. Fastify 는 각 라우터의 옵션의 schema 속성에 JSON Schema 문법으로 정의된 스키마 객체를 추가할 경우, 정의된 내용에 따라 Request Body, URL Params, URL Queries, Headers, Response Body 등의 타입, 필수 조건 등을 자동으로 검사하고 틀릴 경우 400 Bad Request 에러를 반환한다 (물론 이도 모두 커스텀 가능하다). 즉, Request, Response Valdiation에 쓰기 위해 스키마를 정의해두는 건데, Fastify Swagger 는 이 정보를 읽어 Swagger 문서도 자동으로 작성해준다. 그야말로 일석이조라고 할 수 있다.

profile
Fullstack software engineer specialized for Blockchain

0개의 댓글