안녕하세요, 다람쥐입니다.
최근 서버 프로젝트에 외부 로그 시스템을 연동한 경험을 소개합니다.
기본 연동 방법을 자세히 소개하지는 않고, 어떻게 로그 시스템을 구축했는지 소개하는 글이에요.
🙋♂️ 기존에는 어떻게 로그를 관리하셨나요?
Node.js 프로젝트에 다음 라이브러리를 사용하고 있어요.
1. winston
다양한 저장소에 로그 적재를 지원하는 라이브러리예요.info
/ warn
/ error
등의 로그 수준을 지원해요.
로그 포맷을 풍부하게 지원하고 프로젝트에 맞게 설정할 수 있어서 선택했어요.
단순히 로그 수준으로 구별하고, 전역 로거(Logger)로 통일하는 게 목적이었어요.
로그 출력은 어떻게 되는지 볼까요.
아래처럼 json
포맷으로 구조화해 파일로 적재하고 있어요.
{"level":"info","message":"[WebHook] 웹훅 검증이 성공했습니다.","timestamp":"2025-02-13T23:54:32.249Z"}
{"level":"error","message":"[WebHook] 웹훅 검증에 실패했습니다.","timestamp":"2025-02-13T23:54:32.249Z", "stack": "..."}
전역 로거 생성부는 아래와 같아요.
// src/utils/logger.ts
import winston from "winston";
import "winston-daily-rotate-file";
const { combine, timestamp, json } = winston.format;
const fileRotateTransport = new winston.transports.DailyRotateFile({
filename: "logs/application-%DATE%.log",
datePattern: "YYYY-MM-DD",
maxFiles: "14d",
// zippedArchive: true,
});
export const logger = winston.createLogger({
level: "info",
format: combine(timestamp(), json()),
transports: [new winston.transports.Console(), fileRotateTransport],
});
// src/app.ts
import "module-alias/register";
import app from "@/bootstrap";
import { logger } from "./utils/logger";
const port = process.env.PORT;
app.listen(port, () => {
logger.info(`[server]: Server is running on port:${port}`);
});
export default app;
info
로그 수준 이하인 로그를 대상으로 설정해요. (자세한 로그 수준)info
위는 debug
레벨밖에 없어 error
, warn
레벨도 포함해요.
타임스탬프 포맷을 붙이고 json 포맷으로 구조화해요.
콘솔 표준 출력 적재 방식, 날짜 기반으로 로테이션이 적용된 파일 적재 방식을 선택했어요.
날마다 로그가 많이 쌓이지는 않고 있어요.
만약 용량이 많이 쌓인다면 압축도 고려할 수 있어요.
개발 초기라 추이를 보고 결정하고자, 잠시 옵션을 꺼둔 상태예요.
압축 로테이션 기능과 외부 로그 수집 데몬이 타이밍 충돌을 일으킬 여지가 있는데요,
잠재적인 버그 확률을 높이고 분석하고 고치는 데 시간을 쏟고 싶지는 않았어요.
운영 이후에 충분한 검증을 거친 다음에 압축을 넣고자 합니다.
(참고로 .gz
로그 파일은 gzip.vim
플러그인이 깔린 vim
편집기에서 압축 해제해 바로 열 수 있습니다.)
2. morgan
HTTP 요청을 로그로 적재해주는 미들웨어 라이브러리예요.
어떤 엔드포인트에 어떻게 접근했는지, 응답 시간은 얼만지 표시해줍니다.
예시 로그는 아래와 같아요.
{"level":"info","message":"GET / 200 2 - 0.457 ms","timestamp":"2025-02-13T23:13:24.449Z"}
message
값을 보면 HTTP 메서드(GET)와 응답 코드(200), 응답 바이트(2), 응답 시간(0.457 ms)을 확인할 수 있어요.
🤔 데이터독(Datadog)을 사용하신 이유는 무엇인가요?
프론트와 백엔드 로그를 한 시스템에서 관리하고 싶었어요.
프론트엔드 팀원분이 처음 제안을 주셨어요.
센트리나 데이터독같은 플랫폼으로 로그를 관리하면,
운영 서비스 오류에 대응하기 수월한 경험을 소개해주셨어요.
처음에는 프론트엔드 로그를 쌓으려는 목적이었어요.
아무래도 사용자의 웹브라우저에서 일어난 버그를 알 수 없으니까요.
문득 서버 로그도 한 곳으로 관리하면 어떨까 싶었어요.
프론트 요청에서 서버까지,
서버에서 응답으로 내려주기 까지,
프론트에서 응답으로 처리하기 까지.
한 사용자가 겪은 일련의 과정을 한 번에 보면 편할 것 같았어요.
우선 데이터독을 포함한 센트리, 뉴렐릭 등 여러 유명한 APM 플랫폼을 조사했어요.
뉴렐릭은 저도 실무에서 유료 서비스로 2년 여간 썼었고, 센트리도 사이드 프로젝트에서 썼었습니다.
개인 서버에 그라파나 / 엘라스틱 스택으로 직접 로그 시스템을 구축하는 것도 고려했어요.
(사내 시스템에서 운영하고 구축한 경험도 있어 자신은 있었어요.)
그러나 저희가 필요한 건 아주 간단한 로그 수집이었어요.
구축 시간이 빠르고 설정이 간단했으면 하고, 접근도 24시간 쉬웠으면 했어요.
적은 비용이라면 충분히 지불할 생각도 있습니다.
그러던 중 데이터독의 로그 매니지먼트가 눈에 들어왔습니다.
GB당 0.13$ 적재 비용이 들며, 15일 색인 저장에 백만 건 당 2.13$ 비용이 든다고 나오네요.
활성 유저는 당분간 크지 않은 터라, 굉장히 저렴하고 이슈 대응에 큰 무리가 없겠다 싶었습니다.
비싸질 일이 있다면은 정말 좋겠죠. 😁
그리 된다면, 또는 시간이 지나 여유로울 때
개인 서버로 로그 / 메트릭 모니터링 시스템으로 이관할 듯 합니다.
❓ 데이터독 연동하는 방법을 알려주세요.
서버만 연동했기에 프론트는 따로 작성하지 않겠습니다.
서버 연동 방법은 두 가지가 있습니다.
1. 호스트에 데이터독 에이전트 설치하기
2. 애플리케이션에서 데이터독 API로 호출하기
이중 1번을 선택했어요.
애플리케이션 자원을 낭비하지 않고, 오로지 서비스 처리만 신경쓰도록 하고 싶었어요.
비동기 I/O로 여유로울 때 디스크 파일에 저장해요.
그다음 에이전트가 짧은 주기마다 파일 내용의 변화를 감지해요.
기록해둔 마지막 위치부터 현재 수집한 로그까지 데이터독에게 전달하는 방식이에요.
데몬 방식으로 애플리케이션 자원에 영향을 끼치지 않고
장애 발생 영향을 최소한으로 줄였어요.
에이전트 + 로그 파일 인식으로 가져갔기에,
앞서 winston + 로그 파일 로테이션 기능을 소개했어요. 😁
데이터독 에이전트 설치는 문서를 참고해주세요!
회원 가입 이후 처음으로 에이전트 설치를 도와줍니다.
아래처럼 계정 API 키가 설정된 설치 셸스크립트를 다운로드 받게끔
안내 문구가 친절하게 뜰 거예요.
DD_API_KEY=<DATADOG_API_KEY> DD_SITE="datadoghq.com" bash -c "$(curl -L https://install.datadoghq.com/scripts/install_script_agent7.sh)"
그다음 로그 수집 설정을 연동했어요.
datadog.yaml 파일에 logs_enabled 를 찾아 주석을 해제하고 true 로 변경해줍니다.
vim에서 '/logs_enabled/' 타이핑하고 엔터키를 눌러 검색 모드에 들어가, n (다음) / shift + n (이전) 으로 커서 위치를 옮길 수 있습니다. 약 24% 줄에 위치해 'Page Down'키로 조금 내려가다 보면 보입니다.
$ sudo vim /etc/datadog-agent/datadog.yaml
...
logs_enabled: true
(참고)
추가로 로그 기능만 사용하려고, 아래 설정도 넣었어요.
인프라 모니터링 기능을 사용하지 않을 거라, 별도 수집을 비활성화 시켰어요.
## only use to collect log
## Read more : https://docs.datadoghq.com/logs/guide/how-to-set-up-only-logs/?tab=host
enable_payloads:
series: false
events: false
service_checks: false
sketches: false
데몬을 시작한 뒤로 데이터독 대시보드에서 InfraStructure 에서 호스트 메트릭을 수집하는지 확인하세요!
로그 파일 수집 설정을 넣어줍니다.
conf.d 폴더에서 nodejs.d 이름으로 생성했어요.
$ sudo mkdir /etc/datadog-agent/conf.d/nodejs.d/
$ sudo vim /etc/datadog-agent/conf.d/nodejs.d/conf.yml
#Log section
logs:
# - type : file (mandatory) type of log input source (tcp / udp / file)
# port / path : (mandatory) Set port if type is tcp or udp. Set path if type is file
# service : (mandatory) name of the service owning the log
# source : (mandatory) attribute that defines which integration is sending the log
# sourcecategory : (optional) Multiple value attribute. Can be used to refine the source attribute
# tags: (optional) add tags to each log collected
- type: file
path: /home/ubuntu/xxx-backend/logs/application-*.log
service: xxx-backend
source: nodejs
sourcecategory: sourcecode
path 설정에 glob 형식으로 모든 애플리케이션 로그가 수집되도록 했어요.
파일명에 날짜를 붙이기에, 에이전트 수집 주기 중간에 로그 파일이 로테이션 되더라도 유실되지는 않을 거예요.
여기까지 설정하고 데이터독 에이전트를 재시작합니다.
$ sudo service datadog-agent restart
🧙 데이터독 로그에 직접 만든 필드를 넣어주고 싶어요.
1. winston 로거의 메타 데이터로 넣기
winston 로거에 첫 번째 매개변수는 message 예요.
두 번째 매개변수에 별도 메타 데이터를 넣어줄 수 있어요.
json() 포맷을 거치면, 아래처럼 포함돼요.
logger.info("message", { customField: "value" })
# {"customField":"value","level":"info","message":"message","timestamp":"2025-02-23T12:45:10.603Z"}
timestamp / level / message / 메타 데이터 순서로 강제하는
별도 포맷팅을 만들어서 사용하고 있어요.
파일로 볼 때도 일관성이 맞춰져 있어, 원하는 필드를 조회하기가 쉬워졌어요.
import winston from "winston";
import "winston-daily-rotate-file";
const { combine, timestamp, json, printf } = winston.format;
const jsonOrdering = printf(({ timestamp, level, message, ...meta }) => {
return JSON.stringify({
timestamp,
level,
message,
...meta,
});
});
export const logger = winston.createLogger({
...
format: combine(timestamp(), json(), jsonOrdering),
...
});
2. morgan 미들웨어에서 HTTP 추가 정보를 메타 데이터로 보내기
아까 보여드린 HTTP 요청 정보 말고도
서비스에 맞는 로그 정보를 추가하고 싶었어요.
대표적으로 유저를 식별할 수 있는 정보와 요청 Body 정보를 원했어요.
morgan에 새로운 토큰을 등록할 수 있어요.
express의 요청 및 응답을 매개변수로 받는 콜백으로,
유저 아이디와 유저 이메일, 요청 바디 정보를 가져왔어요.
참고로 아래 코드에선 express 요청,req: Request, 만 사용했어요.
res: Response 도 받을 수 있어요~
// src/middlewares/morganMiddlewares.ts
import { logger } from "@/utils/logger";
import { maskEmail, maskIPAddress } from "@/utils/masking";
import express, { Request, Response } from "express";
import morgan from "morgan";
morgan.token("userId", (req: Request) => {
return `${req.userId || ""}`;
});
morgan.token("userEmail", (req: Request) => {
return `${req.userEmail || ""}`;
});
morgan.token("body", (req: Request) => {
return JSON.stringify(req.body);
});
const jsonFormat = (tokens: any, req: Request, res: Response) => {
return JSON.stringify({
message: `${tokens["method"](req, res)} ${tokens["url"](req, res)} ${tokens["status"](req, res)} - ${tokens[
"response-time"
](req, res)} ms`,
remoteAddress: maskIPAddress(tokens["remote-addr"](req, res)),
method: tokens["method"](req, res),
url: tokens["url"](req, res),
httpVersion: tokens["http-version"](req, res),
status: tokens["status"](req, res),
contentLength: tokens["res"](req, res, "content-length"),
referrer: tokens["referrer"](req, res),
userAgent: tokens["user-agent"](req, res),
resposneTime: tokens["response-time"](req, res),
body: tokens["body"](req, res),
userEmail: maskEmail(tokens["userEmail"](req, res)),
});
};
export const setupMorgan = (app: express.Express) => {
const morganMiddleware = morgan(jsonFormat, {
stream: {
write: (message) => logger.info("", JSON.parse(message.trim())),
},
});
app.use(morganMiddleware);
};
객체를 JSON 문자열로 반환하고,
winston logger로 보내는 과정에서 다시 JSON 객체로 변환하는 과정이 보이시나요.
morgan 내부적으로 문자열로 만들어주는 포맷 메커니즘을 가지고 있어요.
따라서 포맷 함수에서 문자열로 반환하고, 이를 로거로 보낼 때 다시 JSON으로 반환하는 번거로운 작업을 거쳤어요.
아래 시그니처에서 format 매개변수의 타입인 FormatFn<>를 볼까요.
반환 값으로 string / undefined / null 로 설정되어 있다는 점을 확인할 수 있어요.
따라서 객체가 아닌 문자열로 반환하는 함수만 컴파일이 됩니다.
declare function morgan<
Request extends http.IncomingMessage = http.IncomingMessage,
Response extends http.ServerResponse = http.ServerResponse,
>(
format: morgan.FormatFn<Request, Response>,
options?: morgan.Options<Request, Response>,
): Handler<Request, Response>;
declare namespace morgan {
type FormatFn<
Request extends http.IncomingMessage = http.IncomingMessage,
Response extends http.ServerResponse = http.ServerResponse,
> = (
tokens: TokenIndexer<Request, Response>,
req: Request,
res: Response,
) => string | undefined | null;
...
}
로컬에서 실행하면 아래 로그로 나오게 돼요.
{"timestamp":"2025-02-23T13:05:39.145Z","level":"info","message":" GET /quick-sale 200 - 2263.510 ms","remoteAddress":"::1","method":"GET","url":"/quick-sale","httpVersion":"1.1","status":"200","contentLength":"2181750","referrer":"http://localhost:3000/","userAgent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36","resposneTime":"2263.510","body":"{}","userEmail":""}
morgan 미들웨어에서 요청 객체에 있는 정보를 가져와요.
따라서 요청 객체에 커스텀 값을 넣는 과정이, morgan 미들웨어보다 앞서야 해요.
morgan 미들웨어에서 유저 정보를 사용하려면,
유저 정보를 앞선 미들웨어 단계에서 추출해야 한다는 걸 의미합니다.
// src/bootstrap.ts
...
setupDataSource();
const app: Express = express();
/** Middleware */
...
setupAuthentication(app); // 요청 객체에 커스텀 값 추가
setupMorgan(app); // morgan 미들웨어 설정
setupExpress(app); // express 관련 미들웨어 설정
...
export default app;
// src/types/express/index.d.ts
...
export {};
declare global {
namespace Express {
interface Request {
userId?: number;
userEmail?: string;
}
}
}
이를 위해 JWT 미들웨어를 일부 수정했었어요.
기존에는 setupAuthentication() 단계가 없었어요.
Express 미들웨어의 라우팅 설정에 JWT 미들웨어를 넣었어요.
그 미들웨어에서 헤더를 추출해 유저 정보를 조회했어요.
만약 유저 정보가 없다면, 라우팅 접근을 막았었죠.
어쨌든 morgan 미들웨어보다 느리니, morgan 에서는 가져올 수 없었죠.
헤더를 추출해 JWT 정보가 있으면, 유저 정보를 추출하는 작업만 미들웨어로 분리했어요.
기존 미들웨어는 라우팅 접근을 막는 인가 로직만 하게 되었습니다.
< 기존 >
- Morgan 미들웨어
- Express 라우팅 미들웨어 + JWT 인증 / 인가 미들웨어
< 변경 >
- JWT 유저 정보 추출 미들웨어
- Morgan 미들웨어
- Express 라우팅 미들웨어 / 유저 인증&인가 미들웨어
3. 데이터독 로그에서 커스텀 필드 색인하기
기본 데이터독 로그는 아래와 같아요.
메시지만 담겨 있는데요.
커스텀 필드를 추가해, 유저 정보를 식별할 수 있도록 변경해 볼게요.
데이터독 로그 관리 화면에서 '+ Add (Add a facet)' 버튼을 누릅니다.
새로운 필드를 추가해볼게요.
morgan에 등록한 로그 필드명이 'body' 이면,
'@body' 으로 앞에 골뱅이를 붙여야 인식해요.
골뱅이가 없다면, 일종의 태그 그룹으로 인식합니다.
따라서 로그 JSON의 특정 키를 인식하라면, '@' 접두사를 붙여야 해요.
Facet 뿐 아니라 Measure 로 등록 가능해요.
Facet 은 일종의 카테고리로 색인하고,
Measure 은 범위를 선택하는 등의 데이터 형식에 맞게 추가 필터 기능을 적용할 수 있어요.
필터를 위해 모든 항목을 등록하지는 않았어요.
이미 공식으로 제공하고 있는, 기본 Path가 있지만 수정이 안되더라고요..!
이는 프론트 SDK에서 쓸 수도 있어 위해 남겨뒀어요.
서버용 색인 항목은 별도로 OTHERS 그룹으로 분리했어요.
마지막으로 로그에 커스텀 필드를 열로 추가해 볼까요.
유저 정보가 한 눈에 식별 가능하도록 했어요.
로그를 볼 때 한 눈에 파악하면 좋을 열만 추가했어요.
그외 자세한 정보는 로그를 선택해서 봅니다.
참고로 개인정보 보호를 위해 로그 수집에서 아이피와 이메일은 마스킹처리했습니다.
마무리
처음으로 데이터독을 이용해 봤네요.
비싸다는 이야기는 많이 들었는데, 로그 적재 비용 많아야 몇 천원 정도는 낼 만 한 것 같아요.
프론트엔드 연동은 아직 안했는데, 한 곳에서 관리할 수 있어
서비스 운영에 무척 편리할 것 같다는 생각이 드네요.
긴 글 봐주셔서 감사드립니다.
'프로젝트 > 장기 프로젝트' 카테고리의 다른 글
[트러블슈팅] Git Push 오류 (error: RPC failed; HTTP 400 curl 22 The requested URL returned error: 400) (0) | 2024.10.08 |
---|---|
EC2 인스턴스가 영문도 모른 채 접속 안되고 죽는 현상 (0) | 2024.08.03 |
부스패치 #4. PostgreSQL Docker로 로컬 개발 환경 구축하기 (0) | 2024.07.25 |
부스패치 #3. TypeORM DB 칼럼 스네이크 케이스 변경 대응 (0) | 2024.07.25 |
MOTI #6. Github Actions 활용한 API 서버 헬스 체크 (0) | 2024.07.23 |
댓글