Nuxt3 log4js를 이용해 json log 출력하기(feat. EFK Stack)

들어가며

Nuxt3 는 Node server 가 돌아가며 Server-Side Rendering(이하 SSR) 을 처리하고 있다. 그렇다보니 해당 Node server 에서 로그를 출력해야할 때가 생긴다. Nuxt 에서 예를 들면 페이지 요청시 서버가 요청을 받아 html 파일을 내려줘야하는데 처리도중 에러로 인해 내려주지 못하거나, Server side 에서만 작동하는 특정 모듈의 상태를 확인할때 로그를 출력해야할 때가 있겠다. 가장 간단하게는 console.log 로 출력한 다음, 실행되고 있는 컨테이너의 로그를 확인할 수도 있다.

 

하지만 현재 진행하고 있는 프로젝트는 k8s 환경EFK Stack(Elasticsearch + Fluent bit + Kibana) 을 사용해 각 pod 의 log 를 Kibana 를 통해 확인 할 수 있게 되어있다. API 를 담당하고 있는 모든 Spring server 들은 공통된 logback 설정을 통해 배포 환경에서 수집할 수 있는 로그 형태로 맞춰 출력하고 있다. 출력된 로그들을 Fluent bit 이 수집해 Elasticsearch 에 전송해 쌓아 Kibana 를 통해 확인하게 되는 구조이다. (현 프로젝트에서 DevOps 를 담당하지 않아 ELK의 정확한 설정은 잘 모른다...)

 

Node server 에도 같은 형태의 로그를 만들어 출력하게 하여 Kibana 를 통해 보기 쉽게 만들어보자.

 

준비

먼저 log4js 를 Nuxt 쪽에 설치한다. 문서는 여기서 확인 할 수 있다.

pnpm install log4js
 

log4js-node by log4js-node

log4js-node This is a conversion of the log4js framework to work with node. I started out just stripping out the browser-specific code and tidying up some of the javascript to work better in node. It grew from there. Although it’s got a similar name to t

log4js-node.github.io

 

실습

적용

설치 한 뒤 바로 사용하려고 하면 require_util(...).inspect is not a function 에러를 발생시키며 렌더링에 실패한다.

<template>
    <div>logger 테스트 페이지</div>
</template>
<script setup lang="ts">
import log4js from "log4js";

log4js.getLogger().info("test");
</script>

왜냐하면 log4js 는 기본적으로 node 에서 작동하게끔 만들어져있기 때문에 Browser 에서 실행할 수 없다. 해당 문제는 Console Appender 를 이용하면 해결 할 수 있으나 현재 원하는건 Server 환경에서만 Log 를 출력하는 것이기 때문에 원하는 해결법은 아니다.

 

Server 환경에서만 log4js를 활용하기 위해서는 여러가지 방법이 있겠지만 그 중 nuxt plugins 기능을 선택했다. plugin 는 Vue App 이 만들어질때 처음에 실행되며 server, client suffix 를 통해 원하는 환경에서만 실행하게끔 만들수도 있다. 해당 기능을 이용해 log4js 설정해 전역으로 사용하게 하고 server 에서만 실행되는 log 기능을 추가하는것이 적합해보인다.

 

/plugins/logger.server.ts

import log4js, { Logger, LoggingEvent } from "log4js";

const createMessageBy = (logEvent: LoggingEvent) =>
    logEvent.data
        .filter((d, i) => i !== 0) // (1)
        .map(data =>
            typeof data === "object" 
            ? JSON.stringify(data).replace(/"/g, "") // (2)
            : data,
        )
        .join(" ");

const format = (logEvent: LoggingEvent) => ({
    "@timestamp": logEvent.startTime,
    level: logEvent.level.levelStr,
    message: createMessageBy(logEvent),
    logger_name: logEvent.data[0], // (3)
    application_name: "nuxt_app",
});

const configLogger = () => { // (4)
    const logType = "json"; 

    log4js.configure({
        appenders: {
      	  out: { type: "stdout", layout: { type: logType } },
        },
        categories: {
        	default: { appenders: ["out"], level: "info" },
        },
    });

    log4js.addLayout(
        logType,
        () => (logEvent: LoggingEvent) => JSON.stringify(format(logEvent)),
    );
};

const createLogger = (name: string, logger: Logger) => ({
    info: (arg: any, ...args: any) => logger.info(name, arg, ...args),
    warn: (arg: any, ...args: any) => logger.warn(name, arg, ...args),
    trace: (arg: any, ...args: any) => logger.trace(name, arg, ...args),
    debug: (arg: any, ...args: any) => logger.debug(name, arg, ...args),
    error: (arg: any, ...args: any) => logger.error(name, arg, ...args),
});

export default defineNuxtPlugin(() => {
    configLogger();

    return {
        provide: {
            logger: (name?: string) =>
                createLogger(
                    name || getCurrentInstance()?.type.__name || "unknown", // (5)
                    log4js.getLogger(),
                ),
        },
    };
});

(1) logEvent.data 는 logger 의 매개변수로 넘어오는 값이다. 매개변수는 varargs 형태로, 배열로 오는데 그 중 첫번째 값을 logger_name 에 쓰기위해 message 에서는 0번째는 제거한다.

(2) 객체 타입을 넘길경우 JSON.stringify 로 직렬화해야 server log 로 표시 할 수 있다. JSON.stringify 를 사용할 시 결과값에  "  가 생기는데 logEvent.data 는 배열형태이며 직렬화시 너무 많은  "  로 인해 지저분해 보이니 정리해준다.

(3) 1에서 언급했듯, 0번째는 logger_name 로 활용한다.

(4) 해당 설정은 문서를 참고했다. Fluent bit 이 log 를 수집할때 json 형태로 수집하기 때문에 출력 형태를 Fluent bit 이 원하는 json 형태로 만들어준다.

(5) logger_name 같은 경우 사용시 파라미터로 넘길수 있는데 넘기지 않았을때는 log 가 호출된 메서드의 component 이름을, 컴포넌트에서 호출된게 아니라면 unknown 이라고 표시했다. (파일 이름을 출력하고 싶었으나 build 시 모든 파일의 이름이 바뀌어 쉽지않아 포기했다.)

 

결과

logger.vue

<template>
    <div>logger 테스트 페이지</div>
</template>

<script setup lang="ts">
const { $logger } = useNuxtApp(); // (1)
const test1 = { a: "1" };
const test2 = { b: "2" };
$logger?.().info("test info"); // (2)
$logger?.("/pages/logger.vue").warn("test warn");
$logger?.("logger page").error("test1 :", test1, "test2 :", test2);
</script>

(1) plugins 에서 provide 를 하면 useNuxtApp() 을 이용해 사용할 수 있다.

(2) server 에서만 사용할 수 있게 만들었으므로 client 에서는 $loggerundefined 이다. 그렇기 때문에 Optional chaining 을 통해 $logger 가 있으면 실행하게끔 만든다.

 

최종코드

배포하고 나서 적용해보니 해당 logger 를 사용한 부분은 Kibana 에서 잘 확인 할 수 있었다. 하지만 개발환경에서 쓰기에는 밋밋해보여 색깔넣는법을 찾아보니 chalk 라는 라이브러리가 있어 활용해보았다. 해당 라이브러리는 이미 포함되어 있었는데 그게 nuxt 에서 오는건지 log4js 에서 오는건지는 모르겠다. 또한 로그레벨도 개발환경에서는 낮춰서 보기위해 몇가지를 수정했다.

 

logger.server.vue

import log4js, { Logger, LoggingEvent } from "log4js";
import chalk from "chalk";
import dayjs from "dayjs";

const createMessageBy = (logEvent: LoggingEvent) =>
    logEvent.data
        .filter((d, i) => i !== 0)
        .map(data =>
        	typeof data === "object" ? JSON.stringify(data).replace(/"/g, "") : data,
        )
        .join(" ");

const format = (logEvent: LoggingEvent) => ({
    "@timestamp": logEvent.startTime,
    level: logEvent.level.levelStr,
    message: createMessageBy(logEvent),
    logger_name: logEvent.data[0],
    application_name: "nuxt_app",
});

const prettyPrint = (logEvent: LoggingEvent) =>
    chalk`{gray [${dayjs(logEvent.startTime).format("YYYY-MM-DD hh:mm:ss")}]} {${
    	logEvent.level.colour
    } [${logEvent.level.levelStr}] ${JSON.stringify(
    	createMessageBy(logEvent),
    )}} - (${logEvent.data[0]})`;

const configLogger = () => {
    const isProd = import.meta.env.PROD;
    const logType = isProd ? "json" : "messagePassThrough";
    const logLevel = isProd ? "info" : "trace";

    log4js.configure({
        appenders: {
        	out: { type: "stdout", layout: { type: logType } },
        },
        categories: {
        	default: { appenders: ["out"], level: logLevel },
        },
    });

    log4js.addLayout(
        logType,
        () => (logEvent: LoggingEvent) =>
        	isProd ? JSON.stringify(format(logEvent)) : prettyPrint(logEvent),
    );
};

const createLogger = (name: string, logger: Logger) => ({
    info: (arg: any, ...args: any) => logger.info(name, arg, ...args),
    warn: (arg: any, ...args: any) => logger.warn(name, arg, ...args),
    trace: (arg: any, ...args: any) => logger.trace(name, arg, ...args),
    debug: (arg: any, ...args: any) => logger.debug(name, arg, ...args),
    error: (arg: any, ...args: any) => logger.error(name, arg, ...args),
});

export default defineNuxtPlugin(() => {
    configLogger();

    return {
        provide: {
            logger: (name?: string) =>
                createLogger(
                    name || getCurrentInstance()?.type.__name || "unknown",
                    log4js.getLogger(),
                ),
        },
    };
});

마치며

만들다보니 log4js 없이 만들어도 비슷한 아웃풋을 낼 수 있을것 같다. 처음에는 log4js 의 stack 을 추적하는 기능을 이용해 어느 파일에서 로그를 찍었는지 알기위해 사용했으나 Nuxt 특성상 빌드시 파일명이 변하는것 때문에 사용을 포기하다보니 이렇게 되었다. 하지만 

log4js 설정중 기존 console 들을 대체하게 하는 등 재미있는 설정이 많이 있어 기회가 되면 사용해 보기 좋아보인다.

 

위의 logger 를 사용하면 Kibana 에서 다른 Spring server log 들과 비슷한 포맷으로 나와 확인하기 편해졌다. 위의 코드를 이용해 각자 원하는 형태로 로그를 바꿔서 출력하기 어렵지 않을것이라 믿는다.