💡 API mocking of the next generation
Service Worker API - Web API | MDN
😀 이해를 돕기 위해 개발 과정의 이상과 현실을 먼저 알아봅시다.
모두가 바라는 이상적인 개발 과정은 다음과 같습니다.
이처럼 순서대로 진행되면, 전 단계에서 미리 정해진 요구사항과 만들어진 스펙에 따라 개발을 진행하면 되기 때문에 파트 별로 충돌이 발생할 일이 적어집니다. (하지만, 이건 이상에 가깝고 이렇게 됐다면 이 글을 작성하지도 않았겠죠..)
현실에서의 대부분의 경우에는 기획이 제대로 되어있지 않은 경우도 많고, 중간에 기획이 바뀌는 경우도 많습니다. 이런 상황에서 백엔드 개발도 이루어지며, API가 없는 상황에서도 프론트엔드 개발이 진행 되어야 합니다.
💡 프론트엔드에 필요한 데이터를 내려줄 API가 없는 상태에서 어떻게 화면 개발이 진행될까요?
API가 없지만 무작정 완성될 때까지 기다릴 수는 없습니다. 데이터를 제외하고 UI를 완성할 수 있는 부분은 완성하고, 데이터가 필요한 부분은 아직 API가 완성되지 않은 상태에서 실제 서버에 있는 데이터를 보여줄 수 없기 때문에 프론트엔드 코드에 가짜 더미 데이터
를 만들어 화면 개발을 진행하게 됩니다. 그 다음, 백엔드가 완성될 때까지 기다렸다가 완성이 되면 API를 호출하는 코드를 추가하는 과정을 거쳐 실제 데이터로 대체하게 됩니다.
이 과정이 그닥 문제가 없어보일 수도 있습니다. 하지만, 프론트엔드를 구현하면서 생기는 대부분의 문제는 백엔드와의 통신 과정에서 발생하는 비동기 로직
을 다루면서 발생합니다. 서버가 없는 상황에서 실제로 통신을 하지 않고, 그저 가짜 더미 데이터를 받아오기만 할 때는 아무런 문제가 없었다가 나중에 실제로 서버와의 통신을 할 때는 기존에 발생하지 않았던 네트워크
단의 여러 가지 문제를 만나기 마련입니다.
예를 들면, 가짜 더미 데이터는 데이터를 즉각적으로 받아오기 때문에 로딩 상태를 어떻게 처리할 지, 혹은 네트워크 에러가 발생했을 때는 어떻게 처리할 지와 같은 상황에 대한 코드를 미리 작성하기 어렵습니다. 미리 작성하더라도 서버가 완성되기 전까지 결과를 볼 수 없으니, 실제로 서버와 연결이 됐을 때 잘 작동할지에 대한 보장도 없습니다. 만약 잘 작동하지 않는 코드라면, 다시 코드를 짜야되는 불상사가 벌어질지도 모릅니다.
그리고 이런 코드를 작성하는 일 자체가 나중에는 결국 쓸모 없어질, 지워야 할 코드를 작성하는 일입니다. 간단한 가짜 서버
를 만드는 것도 방법이 될 수 있겠지만, 이도 적지 않은 수고가 들어가는 일이고 비용적인 문제도 함께 수반됩니다.
결국, API가 완성되어 있지 않은 상태에서 회원가입
이나 로그인
같은 상황이 처리 되거나 유저 인터랙션
에 따라 화면이 변화하는 구체적으로 완성된 상태를 누군가에게 보여주기 힘듭니다.
이런 과정에서 프론트엔드는 다음과 같은 상황을 자주 맞이하게 됩니다. 🥲
그러다가 최근에 네트워크 호출을 가로채는 서비스 워커
라는게 등장하면서 이를 이용한 MSW
라는 API 모킹 라이브러리
가 등장하게 됐습니다. 이는 API가 없는 상황에서도 네트워크 레벨에서 실제로 API를 호출하는 것처럼 동작하여 프론트엔드에서 API 로직을 미리 작성할 수 있게 도와줍니다.
MSW
는 브라우저의 서비스 워커를 이용해 브라우저의 네트워크 호출을 가로챕니다.
예를 들어, https://backend.dev/book 이라는 주소에 fetch 요청을 보낼 때 해당 URL을 MSW에 등록해두면 네트워크 요청을 가로채어 실제로는 해당 URL에 요청이 전달되지 않았지만, API가 요청을 받은 것처럼 무언가 응답을 보내 주는게 가능합니다.
단순히 응답을 보내는 것 외에도 상태 코드나 딜레이 시간을 임의로 지정하여 실제 API가 동작하는 것과 거의 동일한 환경을 만들 수 있습니다. 또한 서비스 워커는 브라우저 위에서 동작하기 때문에, 타 라이브러리나 프레임워크에 대한 의존성도 가지고 있지 않고 인터넷 익스플로러를 제외한 대부분의 모던 브라우저에서 지원하고 있습니다. 때문에 MSW를 사용하는 데 있어 별다른 설정이 필요없고, 사용하는 방법도 굉장히 간단한 편입니다. 또한 TypeScript
를 지원하며 REST API
와 GrpahQL API
방식의 모킹을 모두 지원합니다.
next.js/examples/with-msw at canary · vercel/next.js
npx msw init public/ --save
/* eslint-disable */
/* tslint:disable */
/**
* Mock Service Worker (0.49.2).
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
* - Please do NOT serve this file on production.
*/
const INTEGRITY_CHECKSUM = '3d6b9f06410d179a7f7404d4bf4c3c70'
const activeClientIds = new Set()
self.addEventListener('install', function () {
self.skipWaiting()
})
self.addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim())
})
self.addEventListener('message', async function (event) {
const clientId = event.source.id
if (!clientId || !self.clients) {
return
}
const client = await self.clients.get(clientId)
if (!client) {
return
}
const allClients = await self.clients.matchAll({
type: 'window',
})
switch (event.data) {
case 'KEEPALIVE_REQUEST': {
sendToClient(client, {
type: 'KEEPALIVE_RESPONSE',
})
break
}
case 'INTEGRITY_CHECK_REQUEST': {
sendToClient(client, {
type: 'INTEGRITY_CHECK_RESPONSE',
payload: INTEGRITY_CHECKSUM,
})
break
}
case 'MOCK_ACTIVATE': {
activeClientIds.add(clientId)
sendToClient(client, {
type: 'MOCKING_ENABLED',
payload: true,
})
break
}
case 'MOCK_DEACTIVATE': {
activeClientIds.delete(clientId)
break
}
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId)
const remainingClients = allClients.filter((client) => {
return client.id !== clientId
})
// Unregister itself when there are no more clients
if (remainingClients.length === 0) {
self.registration.unregister()
}
break
}
}
})
self.addEventListener('fetch', function (event) {
const { request } = event
const accept = request.headers.get('accept') || ''
// Bypass server-sent events.
if (accept.includes('text/event-stream')) {
return
}
// Bypass navigation requests.
if (request.mode === 'navigate') {
return
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') {
return
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been deleted (still remains active until the next reload).
if (activeClientIds.size === 0) {
return
}
// Generate unique request ID.
const requestId = Math.random().toString(16).slice(2)
event.respondWith(
handleRequest(event, requestId).catch((error) => {
if (error.name === 'NetworkError') {
console.warn(
'[MSW] Successfully emulated a network error for the "%s %s" request.',
request.method,
request.url,
)
return
}
// At this point, any exception indicates an issue with the original request/response.
console.error(
`\
[MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`,
request.method,
request.url,
`${error.name}: ${error.message}`,
)
}),
)
})
async function handleRequest(event, requestId) {
const client = await resolveMainClient(event)
const response = await getResponse(event, client, requestId)
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
;(async function () {
const clonedResponse = response.clone()
sendToClient(client, {
type: 'RESPONSE',
payload: {
requestId,
type: clonedResponse.type,
ok: clonedResponse.ok,
status: clonedResponse.status,
statusText: clonedResponse.statusText,
body:
clonedResponse.body === null ? null : await clonedResponse.text(),
headers: Object.fromEntries(clonedResponse.headers.entries()),
redirected: clonedResponse.redirected,
},
})
})()
}
return response
}
// Resolve the main client for the given event.
// Client that issues a request doesn't necessarily equal the client
// that registered the worker. It's with the latter the worker should
// communicate with during the response resolving phase.
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId)
if (client?.frameType === 'top-level') {
return client
}
const allClients = await self.clients.matchAll({
type: 'window',
})
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === 'visible'
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id)
})
}
async function getResponse(event, client, requestId) {
const { request } = event
const clonedRequest = request.clone()
function passthrough() {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const headers = Object.fromEntries(clonedRequest.headers.entries())
// Remove MSW-specific request headers so the bypassed requests
// comply with the server's CORS preflight check.
// Operate with the headers as an object because request "Headers"
// are immutable.
delete headers['x-msw-bypass']
return fetch(clonedRequest, { headers })
}
// Bypass mocking when the client is not active.
if (!client) {
return passthrough()
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return passthrough()
}
// Bypass requests with the explicit bypass header.
// Such requests can be issued by "ctx.fetch()".
if (request.headers.get('x-msw-bypass') === 'true') {
return passthrough()
}
// Notify the client that a request has been intercepted.
const clientMessage = await sendToClient(client, {
type: 'REQUEST',
payload: {
id: requestId,
url: request.url,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
mode: request.mode,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: await request.text(),
bodyUsed: request.bodyUsed,
keepalive: request.keepalive,
},
})
switch (clientMessage.type) {
case 'MOCK_RESPONSE': {
return respondWithMock(clientMessage.data)
}
case 'MOCK_NOT_FOUND': {
return passthrough()
}
case 'NETWORK_ERROR': {
const { name, message } = clientMessage.data
const networkError = new Error(message)
networkError.name = name
// Rejecting a "respondWith" promise emulates a network error.
throw networkError
}
}
return passthrough()
}
function sendToClient(client, message) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel()
channel.port1.onmessage = (event) => {
if (event.data && event.data.error) {
return reject(event.data.error)
}
resolve(event.data)
}
client.postMessage(message, [channel.port2])
})
}
function sleep(timeMs) {
return new Promise((resolve) => {
setTimeout(resolve, timeMs)
})
}
async function respondWithMock(response) {
await sleep(response.delay)
return new Response(response.body, response)
}
import { rest } from 'msw'
import { Book, Review } from './types'
export const handlers = [
rest.get('https://my.backend/book', (_req, res, ctx) => {
return res(
ctx.json<Book>({
title: 'Lord of the Rings',
imageUrl: '/book-cover.jpg',
description:
'The Lord of the Rings is an epic high-fantasy novel written by English author and scholar J. R. R. Tolkien.',
})
)
}),
rest.get('/reviews', (_req, res, ctx) => {
return res(
ctx.json<Review[]>([
{
id: '60333292-7ca1-4361-bf38-b6b43b90cb16',
author: 'John Maverick',
text: 'Lord of The Rings, is with no absolute hesitation, my most favored and adored book by‑far. The trilogy is wonderful‑ and I really consider this a legendary fantasy series. It will always keep you at the edge of your seat‑ and the characters you will grow and fall in love with!',
},
])
)
}),
]
import { setupWorker } from 'msw'
import { handlers } from './handlers'
export const worker = setupWorker(...handlers)
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
export const server = setupServer(...handlers)
async function initMocks() {
if (typeof window === 'undefined') {
const { server } = await import('./server')
server.listen()
} else {
const { worker } = await import('./browser')
worker.start()
}
}
initMocks()
export {}
...Mock Service Worker
What is Mock Service Worker (MSW)?
Mocking으로 생산성까지 챙기는 FE 개발
MSW로 API 모킹하기
Service worker overview - Chrome Developers
우와.. 진짜 열심히하셨네요. 잘 보고 갑니다.