Next.js + Hono: FE 개발자가 우아하게 풀스택 개발하기
이 글에서 Next.js 단독으로 풀스택 개발했을때의 단점과, Hono를 통해 어떻게 해결하고 Next.js와 통합할 수 있는지 간단한 Todo 앱을 만들어 보며 알아봅니다.
개요
저는 프론트엔드 개발자입니다. 최근 회사에서 Next.js를 활용해 풀스택으로 개발을 하고 있습니다. 진행하는 프로젝트는 사내 내부 웹 서비스를 위한 REST API를 제공해야 했습니다.
Next.js 단독으로 API 개발을 진행하다보니 여러가지 어려움을 겪게 되었고, 해결하기위해 조사해보다가 Hono를 알게되었습니다.
이 글을 작성하면서 Next.js와 Hono를 잘 사용해 우아하게 풀스택을 개발할 수 있는 방법을 알아보려고 합니다.
Hono
우선 Hono가 무엇인지 설명드리겠습니다. 공식 문서에 따르면 Hono는 “경량 웹 프레임워크로서 웹 표준에 기반해 설계되었으며, 다양한 JavaScript 런타임에서 동작”할 수 있습니다.
Hono is a small, simple, and ultrafast web framework built on Web Standards. It works on any JavaScript runtime: Cloudflare Workers, Fastly Compute, Deno, Bun, Vercel, Netlify, AWS Lambda, Lambda@Edge, and Node.js.
Hono가 다양한 런타임에서 동작할 수 있는 이유는 기존 백엔드 프레임워크인 Express, Fastify, Nest.js 등 과 달리 서버리스 환경에 최적화 되어있기 때문입니다.
다양한 런타임 어댑터가 존재하고, 이 어댑터는 HTTP 요청을 처리하고 Hono에게 요청을 fetch 합니다.
어댑터 코드 살펴보기
// AWS Lambda
// <https://hono.dev/docs/getting-started/aws-lambda>
const handle = <E extends Env = Env, S extends Schema = {}, BasePath extends string = '/'>(
app: Hono<E, S, BasePath>
): ((event: LambdaEvent, lambdaContext?: LambdaContext) => Promise<APIGatewayProxyResult>) => {
return async (event, lambdaContext?) => {
// ...
const res = await app.fetch(req, {
event,
requestContext,
lambdaContext,
})
return processor.createResult(event, res)
}
}
const app = new Hono()
export const handler = handle(app)
// ---
// Vercel
// <https://hono.dev/docs/getting-started/vercel>
const handle =
(app: Hono<any, any, any>) =>
(req: Request, requestContext: FetchEventLike): Response | Promise<Response> => {
return app.fetch(req, {}, requestContext as any)
}
const app = new Hono()
export const GET = handle(app)
// ---
// Node.js
// <https://hono.dev/docs/getting-started/nodejs>
const serve = (options: Options): ServerType => {
const server = createAdaptorServer(options)
// ...
}
const createAdaptorServer = (options: Options): ServerType => {
const fetchCallback = options.fetch
const requestListener = getRequestListener(fetchCallback, {})
// ...
}
const getRequestListener = (fetchCallback: FetchCallback) => {
// ...
return async (
incoming: IncomingMessage | Http2ServerRequest,
outgoing: ServerResponse | Http2ServerResponse
) => {
// ...
res = fetchCallback(req, { incoming, outgoing } as HttpBindings) as
| Response
| Promise<Response>
// ...
}
const app = new Hono()
serve(app)
위 코드들은 차례대로 FE개발자들에게 익숙한 환경인 AWS Lambda, Vercel, Node.js 환경의 어댑터 코드입니다. 코드를 잘 살펴보면 요청을 가공해 app.fetch
로 넘겨주는것을 확인 할 수 있습니다.
Node.js 어댑터의 경우 지속적으로 유지되는 서버까지 생성하는 코드가 포함되어 있습니다.
Next.js 단독으로 API 개발하면서 마주친 어려움
1. 폴더 구조 기반 라우팅
폴더 구조 기반 라우팅의 장점은 직관적으로 이해하기 쉽다는 점입니다. 프론트엔드의 라우팅은 백엔드의 라우팅에 비교해서 비교적 단순하게 구성되기 때문에 이러한 장점이 더 돋보입니다.
하지만 백엔드에서 REST API의 라우팅은 API 설계가 복잡해질수록, 폴더 구조도 그만큼 복잡해집니다. 예를 들어, 게시글의 댓글 관련 API를 구현하면 아래와 같이 폴더 구조를 가지게 됩니다.
/api/posts/[postId]/comments/route.ts
/api/posts/[postId]/comments/[commentId]/route.ts
더욱 복잡한 리소스를 다루게 된다면 폴더 구조는 더욱 깊어지고, 코드의 가독성과 생산성을 저하시킬 수 있습니다.
Hono를 사용한다면 Next.js의 Catch-all Segments를 이용해 API를 구성할 수 있습니다.
// @/app/api/[...routes]/route.ts
import { Hono } from 'hono'
import { handle } from 'hono/vercel'
const app = new Hono().basePath('/api')
app.get('/api/posts/:postId/comments', async (ctx) => {})
app.patch('/api/posts/:postId/comments/:commentId', async (ctx) => {})
export const GET = handle(app)
export const PATCH = handle(app)
2. Swagger 문서 작성
Next.js에서 Swgger 문서를 작성하기 위해서는 next-swagger-doc 이라는 라이브러리를 사용해 작성할 수 있습니다. 이 라이브러리는 JSDoc을 활용해 문서를 생성하는 방식이기 때문에 주석으로 문서를 작성하면서 휴먼 에러가 발생할 확률이 매우 높고, IDE의 자동완성 기능에 도움을 받기 어렵습니다.
Hono를 사용한다면 hono의 middleware를 사용해 코드로 문서를 관리할 수 있습니다. 코드로 작성한 query, params, body 등 요청을 자동으로 검증하고 안전한 타입으로 개발을 진행할 수 있습니다.
// @/app/api/[...routes]/route.ts
import { swaggerUI } from '@hono/swagger-ui'
import { OpenAPIHono, createRoute, z } from '@hono/zod-openapi'
import { handle } from 'hono/vercel'
const app = new OpenAPIHono().basePath('/api')
const getPostCommentsRoute = createRoute({
method: 'get',
path: '/posts/{postId}/comments',
request: {
params: z.object({ postId: z.string().min(2) })
},
responses: {
200: {
content: {
'application/json': {
schema: z.object({
postId: z.string(),
comments: z.string().array()
}),
},
},
},
},
})
app.openapi(getPostRoute, async (ctx) => {
const { postId } = c.req.valid('param') // postId: string
return ctx.json({ postId, comments: [] })
})
app.doc('/open-api.json', {
openapi: '3.0.0',
info: {
version: '1.0.0',
title: 'My API',
},
})
app.get('/swagger', swaggerUI({ url: '/api/open-api.json' }))
export const GET = handle(app)
3. 브라우저 환경을 위한 API 호출 함수 개발
API를 개발하고 사용하기 위해서는 클라이언트에서 fetch 혹은 axios를 활용해 API를 호출하는 함수를 개발해야 합니다. 이는 API 개발과 API 호출 함수의 코드 중복이 발생할 수 있습니다. 또 API 코드가 변경되면 클라이언트에서 확인하기 어렵습니다.
Hono를 사용하면 RPC 기능을 통해 서버와 클라이언트 사이의 API 스펙을 손쉽게 공유할 수 있습니다.
// @/app/api/[...routes]/route.ts
import { swaggerUI } from '@hono/swagger-ui'
import { OpenAPIHono, createRoute, z } from '@hono/zod-openapi'
import { handle } from 'hono/vercel'
const app = new OpenAPIHono()
.basePath('/api')
.doc('/open-api.json', {
openapi: '3.0.0',
info: {
version: '1.0.0',
title: 'My API',
},
})
.get('/swagger', swaggerUI({ url: '/api/open-api.json' }))
.openapi(getPostCommentsRoute, async (ctx) => {
const { postId } = c.req.valid('param') // postId: string
return ctx.json({ id: postId, content: 'post', comments: [] })
})
export type App = typeof app
export const GET = handle(app)
// client side
import { hc } from 'hono/client'
import type { App } from '@/app/api/[...routes]/route'
const client = hc<App>('<http://localhost:3000>')
const response = await client.posts[':postId'].comments.$get({
param: { postId: '123' },
})
마무리
간단하게 Todo 예제 앱을 만들어 봤습니다. 실제로 사용해보니 장점도 있었고, 크진 않지만 단점도 있었습니다.
단점으로 RPC의 타입추론을 잘 사용하기 위해서는 핸들러들을 체이닝 하는 부분이 필요하다는 점입니다. RPC의 Known issue인 타입추론에 의해 IDE 퍼포먼스 저하 이슈가 있습니다.
장점으로는 앞서 설명한 어려움들이 크게 해소 된다는 점이고, Next.js 단독으로 사용할때 보다 확실히 Hono와 같이 사용할 때 생산성이 올라간다는 점 입니다.