Fastify에는 기본적인 라이프사이클이 정해져 있습니다. 그리고 이것을 이용하면 저희의 코드를 더욱 효과적으로 관리할 수 있습니다. 말 그대로 Fastify에서 만들어놓은 기능은 Fastify의 라이프사이클에 따라 움직이니 저희의 코드 또한 이에 맞춘다면 구현된 기능을 모두 효과적으로 사용함을 의미합니다.

Fastify 3.21.6‌            

애플리케이션 라이프사이클

Lifecycle
Fast and low overhead web framework, for Node.js

가장 먼저 Fastify의 라이프사이클에 관해 살펴봅니다. 사실 보기만 해도 굉장히 많은 과정을 거치는데 생각만큼 난잡하지는 않습니다. 각각이 역할을 가지고 정형화되어 있기 때문입니다. 그중에서 확인해보아야 할 사항들은 다음과 같습니다:

  • onResponse
  • preParsing
  • preValidation
  • preHandler
  • onError

각각은 Fastify에서 Hook(훅)이라고 불리는 것인데 마치 갈고리를 걸듯이 특정 시점에 실행될 함수를 걸어주는 것이라고 생각하면 됩니다. 혹은 Express 방식이라고 생각한다면 각각은 때에 맞춰서 실행되는 Middleware의 배열이라고 보아도 무방합니다.

Hook에 관해서는 이후에 더 자세히 다룰 예정입니다.

Fastify는 이러한 훅들을 순차적으로 실행해주면서 Request와 Reply 객체를 입맛에 맞도록 변형해갑니다. 물론 값 검증과 오류 처리에 관해서 사람마다 주관은 다를 것입니다. 하지만 저희가 실질적으로 개발을 하면서 주로 생각하게 될 과정은 다음과 같습니다.

  • onResponse
  • AJV Schema Validation
  • preHandler
  • handler
  • onError

가장 핵심적인 이유는 중간의 AJV Schema Validation 과정을 볼 수 있듯이 특히나 값의 검증 과정을 Fastify 쪽에서 제공하기 때문입니다. 이 말은 다시 말하면 Schema Validation 이후에 온전한 Type을 가진 값들, 혹은 보장된 값들을 가지고 추가로 검증이 필요하다면 preHandler 단에서 값 검증을 진행해야 한다는 뜻을 내포하고 있습니다.

preHandler

예를 들어서 다음과 같은 상황에서는 Fastify를 사용하는 경우 훨씬 효율적으로 처리할 수 있습니다. 아래와 같은 JSON Schema가 존재한다고 가정합니다.

{
    type: 'object',
    properties: {
        text: {
            type: 'string',
            minLength: 4
        }
    },
    required: ['text']
}

그리고 저는 추가로 이 값이 a로 시작하는지 확인하고 싶어합니다.

async function preHandler (request, reply) {
    if (request.body.text.startsWith('a')) {
        ...
    }
}

이 때 body.text의 보장을 위하여 저희는 a로 시작하는지 검증하는 위 Hook을 AJV Schema Validation 이후에 실행했습니다. 즉, 위 코드는 preHandler 단계에서 실행해야 합니다. 이렇게 하면 아래와 같이 2번 검증하는 것을 막을 수 있습니다. body의 값이 AJV에 의해서 미리 존재하는지 그리고 4글자인지 미리 검증되기 때문입니다.

async function onRequest ({ body }, reply) {
    if (body.text?.length >= 4 && body.text?.startsWith('a')) {
        ...
    }
}
onRequest 훅은 Validation 전에 실행됩니다.

onError

비슷한 방식으로 onError 훅도 효과적으로 코드 오류를 처리할 수 있습니다. 저희가 아래와 같은 코드를 handler로 작성해서 일단 어떻게든 오류가 발생한다는 상황을 가정해봅시다.

async function handler (request, reply) {
    throw new Error('Just handle it!')
}

이 경우에는 물론 개개인이나 팀의 성향에 따라서 갈리겠지만 try...catch 문 대신 아래와 같이 onError 훅을 만들면 더 적은 분기를 만들면서 한 번에 많은 오류를 처리할 수 있습니다.

async function onError (request, reply, error) => {
    console.error(error)
}

위와 같은 상황들에서는 함수에 단 하나의 분기만 존재했습니다. 하지만 분명히 분기가 많아지면 많아질수록 저희가 Promise-chain 내에서 오류를 추적하는 것은 어려워질 수 있고 이러한 패턴은 훨씬 더 디버깅을 수월하게 해줄 것입니다.

그러나 당연히 각각의 오류에 대해 다른 응답을 반환하는 경우에는 Promise의 catch를 이용해야 합니다.
onError 훅은 응답을 전송하기 위해서가 아닌 오류를 디버깅하기 위해서 고안되었습니다.

onRequest

onRequest Hook의 경우에는 조금 더 '빨리' 실행되는 훅입니다. 정말로 요청이 오자마자 실행됩니다. 그래서 요청의 본문(payload) 또한 해석되지 않은채로 훅에 요청 객체가 전달되게 됩니다. 이 말은 다르게 해석하면 body를 해석하는 로직이 굳이 실행될 필요가 없이 필터링될 수 있는 요청을 미리 필터링할 수 있다는 뜻입니다.

authorization header를 가지는 요청을 부가적인 기능을 Fastify와 플러그인이 실행하기 전에 미리 검증하여 body를 해석하는데 불필요한 성능을 낭비하지 않도록 할 수 있습니다.

async function onRequest ({ headers }, reply) {
    if (typeof headers.authorization !== 'undefined') {
        ...
    }
}

만약 이러한 코드가 preHandler나 preValidation 훅에 존재했다면 쓰이지 않을 body가 해석될 것이고 분명히 성능에 굉장한 영향을 끼칠 것입니다.


가장 처음으로 Fastify에서 각 훅이 실질적으로 어떤 역할을 해야 하는지 옅보았습니다. 여기에서부터는 테스트 코드에 대해 다룹니다. 다만 '유닛 테스트'를 두려워하지 마세요. Fastify는 자체적으로 요청을 내부 서버 코드로 통과시킬 수 있는 개별 함수를 가지고 있습니다. 타 프레임워크처럼 직접 서버를 켜고 일일이 request, got, 그리고 axios와 같은 패키지를 사용하여 요청을 날릴 필요가 없습니다. 아니, 서버를 켤 필요도 없습니다.

먼저 node-tap 패키지를 설치해줍니다. 물론 타 테스팅 프레임워크를 사용해도 무관합니다만 저희는 Fastify에서 공식적으로 추진하는 프레임워크를 사용할 것입니다. 시작하기 전에 혹시 이미 Fastify 코드를 작성했다면 아래와 같이 인스턴스를 반환하도록 수정해주시기 바랍니다.

TypeScript의 경우에는 @types/tap도 설치해야 합니다.
// src/index.ts
import type { FastifyInstance, FastifyServerOptions } from 'fastify'
import fastify from 'fastify'

export const instance = async (options: FastifyServerOptions) => {
  const server = fastify(options)
  
  return server
}

위와 같이 수정하는 이유는 근본적으로 테스트 코드에서 서버의 인스턴스가 필요하기 때문입니다. Fastify 인스턴스는 기본적으로 테스트를 위한 inject 함수를 가지고 있습니다. 이 inject 함수를 사용하면 더욱 쉽게 테스트 진행이 가능합니다. 혹은 테스트가 아니더라도 실제 실행을 대체할만한 코드를 더욱 쉽게 작성할 수도 있습니다.

// test/project.ts
import type { FastifyInstance } from 'fastify'
import type { Test } from 'tap'
import { instance } from '../src'
import {
  test,
  before,
  beforeEach,
  teardown
} from 'tap'

type TTapTest = typeof Test.prototype

interface ITapContext {
  instance: FastifyInstance
}
interface ITapTest extends TTapTest {
  context: ITapContext
}

let instance: FastifyInstance

before(() => ...)

beforeEach(async (t: ITapTest) => {
  instance ??= await instance()
    
  t.context.instance = instance
}

teardown(async () => {
  await instance.close()
}
Test Lifecycle Events
A Test-Anything-Protocol library for JavaScript

위는 간단한 테스트 라이프사이클 코드입니다. beforeEach에서는 선택적으로 인스턴스가 없을 때 새로 생성해줍니다. 굳이 beforeEach인 이유는 t.context API를 사용할 수 있기 때문입니다. 이는 동일한 before, beforeEach, teardown 등 라이프사이클 함수를 만들어두고 여러 테스트 코드에서 공유하기 위함입니다.

테스트 코드를 위해서 기존 서버 코드를 약간 수정해주었습니다.

// src/index.ts
import type { FastifyInstance, FastifyServerOptions } from 'fastify'
import fastify from 'fastify'

export const instance = async (options: FastifyServerOptions) => {
  const server = fastify(options)
  
  server.route({
    url: '/',
    method: 'POST',
    handler: async ({ body }, reply) => {
      return {
        success: true
      }
    }
  })
  
  return server
}

그리고 다시 테스트 코드로 돌아옵니다. 여기에서는 단순히 테스트를 하는 방법만 소개해드릴 예정입니다. 그 이후의 과정은 분명 케이스별로 더 많은 생각이 필요한 과정일 것이기 때문입니다. 코드를 보면아래와 같이 단순히 inject 함수로 테스트가 가능합니다. 이 경우에는 실제로 서버로 요청을 가는 것이 아닌 임의의 요청 객체를 생성해서 바로 서버에 주입하는 경우입니다.

// test/index.ts

...

test('POST /', async (t: ITapTest) => {
  const { statusCode, ...response } = await t.context.instance.inject({
    url: '/',
    method: 'POST'
  })
  
  t.equal(statusCode, 200, 'return a status code of 200')
  t.same(response.json(), { success: true }, 'return a valid response')
})

다만 한 가지 주의해야 하는 것이 있는데 LightMyRequest(Fastify에서 사용) Response 객체의 구현 특성상 this의 와해로 인해서 response 변수를 통째로 사용해야 한다는 것입니다.

// DO NOT
const { json } = response

json()

// DO
response.json()

서버 그리고 테스트 코드 간의 일관성 유지

다음 단계는 서버와 테스트 코드 간의 일관성을 유지하는 것입니다. 아래와 같이 테스트 코드와 서버 코드가 따로따로 중복되어 있는 상황을 예시로 들어보겠습니다.

TypeScript를 사용하는 경우 특히 유용한 섹션입니다. 하지만 JavaScript 프로젝트에서도 이 방법을 사용하면 훨씬 더 유연하게 테스트 코드를 관리할 수 있습니다.
// server.ts
import fastify from 'fastify'

export const instance = async () => {
  const server = fastify()
  
  server.route({
    url: '/',
    method: 'GET',
    handler: async () => {
      return {
        code: 'FST_EXAMPLE',
        success: true
      }
    }
  })
}

export const main = async () => {
  const server = await instance()
  
  server.listen(3000)
}

main()
// test/index.ts

...

test('POST /', async (t: ITapTest) => {
  const { statusCode, ...response } = await t.context.instance.inject({
    url: '/',
    method: 'GET'
  })
  
  t.equal(statusCode, 200, 'return a status code of 200')
  t.same(response.json(), { code: 'FST_EXAMPLE', success: true }, 'return a valid response')
})

이 경우에는 아래와 같이 응답을 검증하기 위해 응답 본문이 2번 쓰였습니다. 하지만 수많은 API를 관리하는 경우에는 이는 굉장히 배드패턴이 될 수 있습니다. 시도 때도없이 변경될 때마다 테스트 코드와 서버 코드를 계속 변경해주어야 하기 때문입니다.

{ code: 'FST_EXAMPLE', success: true }

대신에 다음과 같은 접근을 사용합니다. 먼저 replies라는 모듈을 만들어주겠습니다. 그리고 여기에서 저희는 모든 응답 포맷을 관리할 것입니다.

변수명은 프로젝트별로 적절히 직접 지어주세요. 그게 더 낫습니다.
// src/replies.ts
export const sampleReply = (isSuccess: boolean) => ({
  code: 'FST_EXAMPLE',
  success: isSuccess
})

기본적으로 위와 같은 모듈만 하나를 만들어도 테스트 코드와 서버 코드 사이에 일관성을 유지할 수 있습니다. 응답 포맷이 바뀌는 경우에는 저희는 실제로 sampleReply 함수만 변경해도 무관하게 되는 것입니다.

변경된 코드는 그럼 다음과 같은 형태가 됩니다.

// server.ts
...

import * as replies from './replies'

export const instance = async () => {
  const server = fastify()
  
  server.route({
    url: '/',
    method: 'GET',
    handler: async () => {
      return replies.sampleReply(true)
    }
  })
}

...
// test/index.ts

...

import * as replies '../replies'

...

test('POST /', async (t: ITapTest) => {
  const { statusCode, ...response } = await t.context.instance.inject({
    url: '/',
    method: 'GET'
  })
  
  t.equal(statusCode, 200, 'return a status code of 200')
  t.same(response.json(), replies.sampleReply(true), 'return a valid response')
})

이번 글에서는 기본적인 라이프사이클 그리고 테스트 코드 관리를 하는 방법에 대해 다루었습니다. 물론 이것보다 더 좋은 방법이 많을 것입니다. 제 글이 정답이 아니라 기반으로 하여금 언제나 더 좋은 방법을 찾아 갈구하고 자신의 코드를 업데이트하셨으면 좋겠습니다. 다음 시리즈에서는 라우팅 정의 그리고 훅, 데코레이터(JavaScript 문법이 아닌) 그리고 기본적인 성능 튜닝에 대해 더 자세히 알아볼 것입니다.