난 API 서버를 구현할 때, 보통 API Logger 를 가장 먼저 구현한다. 여기서 API Logger라 함은, API에 대한 Request, Response를 기록하는 기본적인 Logger를 의미한다. 예를 들어, express.js 의 morgan 미들웨어 같은 것 말이다.
내가 원하는 API Logger의 조건은 보통 다음과 같다.
내가 Go 언어에서 주로 쓰는 서버 프레임워크인 Fiber, 또는 최근에 Node.js 서버 프레임워크로 자주 썼던 Fastify 의 경우, 모두 위의 요구사항들은 기본적으로 제공해주어서 코드 한두줄로 미들웨어를 추가하고, 설정 객체를 넘겨주는 정도로 API Logger 를 추가해줄 수 있었다.
Next.js 의 경우, 공식 문서를 보아도, 별다른 얘기가 없다. console.log
를 사용할 경우, 클라이언트 측에서는 브라우저에, API 서버 측에서는 stdout/stderr 로 출력된다는 언급 외에, 별도로 Logger에 대한 언급을 찾을 수는 없었다. JSON으로 구조화된 로깅을 원할 경우 pino
라는 라이브러리를 쓸 것을 추천하고 있지만, 그냥 해당 라이브러리를 소개하는 정도이지, Fastify 처럼 프레임워크 내부에서 자동으로 해당 라이브러리를 추가해주는 수준은 아니다.
그래서 미들웨어 형태로 API Logger 를 console.log
와 console.error
를 이용하여 구현해주기로 했다.
Next.js 의 API Handler 함수는 export default
형태로 하나의 파일에 단독으로 구현된다. 예를 들어 pages/api/hello.ts
라는 파일에 export default function handler(...)
로 구현을 하면, http://localhost:3000/api/hello
로 API 호출을 할 수 있게 된다. 이때, 이 Handler 함수의 시작과 끝에 API Logger 관련 코드를 삽입해주면, 해당 Handler 함수가 호출될 때 마다 API 호출 정보를 로깅할 것이다.
모든 Handler 함수마다 앞뒤로 로깅 함수를 호출해주는건 매우 번거로운 일이다. Handler 함수에는 해당 API가 수행하는 비즈니스 로직에만 집중해서 구현이 되어야지, 로깅도 하고, (나중에 말하겠지만) 라우팅도 하고, 세션 객체도 넣고 등등... 이런 공통 코드들을 지루하게 나열하게 되면, 읽기도 불편할 뿐더러, 실수로 호출을 빼먹을 수도 있는 노릇이다. 게다가, 나중에 위의 공통 코드 파트에서 수정 사항이라도 발생하면, 온갖 곳에서 수정을 해줘야 할 판이다.
그래서 보통 미들웨어라는 코딩 패턴을 사용한다. 미들웨어는 비즈니스 로직이 담긴 Handler 함수 앞 또는 뒤에 실행되는 함수를 의미한다. express.js 또는 Fastify에서는 미들웨어 함수를 등록해두면, API에 대한 호출이 들어왔을 때, 라우터에서 미리 정해진 순서대로 미들웨어들(a.k.a 함수들)과 Handler 함수를 실행한다. 그래서 공통된 로직들인 API 요청 로깅, 에러 처리, 인증 등을 미들웨어로 구현한다. express.js 나 Fastify 에는 미들웨어를 위한 정해진 함수 타입과 미들웨어를 등록하는 별도의 함수, 방법 등이 정의가 되어 있다.
하지만, Next.js 에는 미들웨어는 그저 개념상으로 존재하고 '이런식으로 구현하면 미들웨어처럼 동작해요' 수준으로 예제가 있을 뿐, 사실 미들웨어를 위한 프레임워크 차원에서의 지원이 존재하진 않는다. 그도 그럴 것이, Next.js 에는 미들웨어를 공통적으로 등록할만한 서버 객체나 라우터 객체 개념이 없으니 그럴만하다. (그래도 withMiddlewares
와 같은 이름으로 일관된 방법으로 미들웨어를 추가해줄 수 있는 함수 정도를 프레임워크 차원에서 지원해주었으면 더 좋았을 것 같다는 생각은 든다)
그래서 Next.js 에서의 미들웨어 함수 구현은 React의 HOC(Higher Order Component, 또는 고차 컴포넌트) 또는 간단히 Wrapper 함수 구현 패턴과 유사하다. 예를 들어, Next.js의 대표적은 인증 미들웨어인 Iron Session 의 withIronSessionApiRoute
함수 코드(아래 코드 블록)를 살펴보자. 이 함수는 NextApiHandler
함수를 반환하는, 함수를 생성하는 함수이다. 이 때, withIronSessionApiRoute
가 생성하는 NextApiHandler
함수는 암호화된 쿠키로부터 세션 객체를 복원하여, NextApiResponse
객체에 추가해준 후, 기존 Handler 함수를 실행해준다. 이 때, "암호화된 쿠키로부터 세션 객체를 복원하여, NextApiResponse
객체에 추가" 부분이 미들웨어라고 할 수 있다.
export function withIronSessionApiRoute(
handler: NextApiHandler,
options: IronSessionOptions,
): NextApiHandler {
return async function nextApiHandlerWrappedWithIronSession(req, res) {
const session = await getIronSession(req, res, options);
Object.defineProperty(
req,
"session",
getPropertyDescriptorForReqSession(session),
);
return handler(req, res);
};
}
withLogger
함수withIronSessionApiRoute
함수를 참고하여, 우리의 withLogger
함수를 구현해보자. 이 함수는 기본적으로 "비즈니스 로직이 담긴 Handler" 함수를 입력으로 받고, 출력으로는 NextApiHandler
타입의 함수를 반환해야 한다. 대충 Pseudo code 를 작성해보면 다음과 같다.
function withLogger(runBusinessLogic: HandlerFunctionType): NextApiHandler {
return async (req: NextApiRequest, res: NextApiResponse) => {
let resJson: unknown
try {
resJson = await runBusinessLogic(req, res)
res.status(200)
} catch (err) {
logErr(err)
res.status(500)
resJson = { error: err.message }
}
logAPI(req)
res.json(resJson)
}
}
Fastify 에서 Handler는 error를 throw 할 경우, 자동으로 Status Code에 500
을 부여하고, { status: number; error: string; message: string}
타입의 JSON 응답을 반환한다. 반대로, Handler 함수가 정상적으로 어떤 JSON 객체를 반환할 경우, 자동으로 Status Code에 200
을 부여하고, 반환한 JSON 객체를 그대로 응답에 싣어 반환한다. 개인적으로 이러한 Handler 구현이 비즈니스 로직에 집중하기 좋다고 생각한다.
위의 코드 블록에서 runBussinessLogic
은 Fastify 의 Handler 역할을 한다. runBusinessLogic
에서 throw 된 에러는 try-catch
를 통해 잡아서, 응답 Status Code에 일관되게 500
을 부여하고, 반환할 에러 객체를 만들어 resJson
변수에 할당한다.
반대로 runBussinessLogic
이 정상적으로 객체를 반환하면, 이를 그대로 resJson
에 할당하고, Status Code 에 200
을 부여한다. (200
이 기본값으로 할당되기 때문에, 명시적으로 부여하지 않아도 된다.)
try-catch
문이 끝나면, logAPI
함수를 통해 API 정보를 출력하고, res.json(resJson)
코드로 응답을 반환한다.
withIronSessionApiRoute
함수와는 다르게, 우리의 withLogger
함수는 NextApiHandler
타입의 Handler를 입력받지 않는다. NextApiHandler
는 (req: NextApiRequest, res: NextApiResponse<T>) => void | Promise<void>
인데, 우리의 HandlerFunctionType
은 반환값이 void
가 아니기 때문이다.
앞서 말했듯이 Next.js 는 pages/api/hello.ts
라는 파일에 export default function handler(...)
로 구현을 하면, http://localhost:3000/api/hello
로 API 호출을 할 수 있게 된다. 이때, /api/hello
API는 모든 HTTP Method(POST, GET, ...)를 가리지 않고 받아들이게 된다. 하지만 REST API를 구현할 경우, 해당 경로의 자원(Resource)에 대해 읽고(GET), 쓰고(POST), 수정하고(PUT, PATCH), 지우기(DELETE) 마련이다. 즉, 하나의 경로에 복수개의 HTTP Method가 있을 수 있다.
Next.js 에서는 예제들을 볼 경우, NextApiRequest
객체의 method
속성값에 대해 if-else
또는 switch-case
로 분기하여 처리하는 듯하다. 그래서 이 코드를 공통 코드로 분리하도록 한다. 이를 위해 일단 Routes
라는 객체 타입을 아래와 같이 정의한다.
type Routes = Record<string, HandlerFunctionType>
그리고 withLogger
에서 HTTP Method 에 맞춰서 정해진 Handler 를 꺼내주고, 만약 없다면 404
응답 에러를 반환해주는 코드를 추가해주자 (이쯤되면, withLogger
라는 이름이 어울리지는 않는다)
function withLogger(routes: Routes): NextApiHandler {
return async (req: NextApiRequest, res: NextApiResponse) => {
let resJson: unknown
try {
// HTTP Method 에 할당된 Handler 가 없으면 404 반환
if (!req.method || !routes[req.method]) {
res.status(404)
throw new Error('no such api')
}
// HTTP Method에 할당된 handler를 가져와서 실행
const handler = routes[req.method]
resJson = await handler(req, res)
res.status(200)
} catch (err) {
logErr(err)
if (res.statusCode === 200) {
// Status Code의 기본값은 200인데, 만약 별도의 에러 코드가 할당되어 있지 않는다면 500을 할당하고
// 별도로 에러 코드(예: 404)가 할당되어있다면, 500을 할당하지 않는다.
res.status(500)
}
resJson = { error: err.message }
}
logAPI(req)
res.json(resJson)
}
}
나는 개인적으로 몇가지 설정을 더 붙여서 withMiddlewares
라는 Wrapping 함수를 만들었다. 우선, Session 인증을 요구할 경우, 이를 옵션으로 받아서, withLogger
로 Wrapping 된 Handler 함수를 withIronSessionApiRoute
함수로 Wrapping 해준다. 예를 들면, 대략 이런식이다.
function withMiddlewares(routes: Routes, middlewares: { session?: boolean }): NextApiHandler {
let handler: NextApiHandler = withLogger(routes)
if (middlewares?.session) {
handler = withIronSessionApiRoute(handler, sessionOptions)
}
// middleware 가 더 필요할 경우, 여기에 추가
return handler
}
이제 /api/hello
API에 withMiddlewares 를 추가하기 위해, /pages/api/hello.ts
파일을 작성해보자.
// /pages/api/hello.ts
export default withMiddlewares({
POST: createHello,
GET: readHello,
}, { session: false })
async function createHello(req: NextApiRequest, res: NextApiResponse): Promise<{ world: string }> {
// ...
return { world: "World!!" }
}
async function readHello(...){...}
Next.js 는 이미 파일 경로를 통해 REST API가 접근을 허가하는 자원(Resource)을 명시적으로 볼 수 있다.(예: /pages/api/users/index.ts
-> User 자원에 접근) 그리고 해당 파일에 들어가서 첫줄 withMiddlewares
의 첫번째 파라미터인 Routes
타입 객체를 보면 해당 API가 해당 자원에 대해 지원하는 동작(POST, GET, PUT, ...등)을 확인할 수 있다. 그리고 그 동작에 할당된 함수 이름을 통해, 해당 동작이 무엇인지 개략적으로 확인할 수 있다. 마지막으로, Routes
객체에 할당된 Handler 함수로 이동해서, 해당 함수의 반환값 타입을 보면, 해당 API가 어떤 형태의 반환값을 클라이언트로 보내는지 확인할 수 있다.
이를 통해, 좀 더 Readable 한 코드가 되었다. 👏 👏
감사합니다~ pino도 좋은데 Nest Logger에 너무 익숙해져 버렸네요