Node.js 백엔드 API를 통해 데이터베이스의 데이터나 로그를 그라파나에서 시각화하는 방법을 다뤄보았다. 이 목표를 위해 Prometheus 메트릭 형식으로 데이터를 노출하고 그라파나 대시보드로 모니터링하는 전체 과정을 다뤄보았다.
아키텍처

1. 프로젝트 초기 설정
1.1 프로젝트 생성
mkdir nodejs-grafana-db-logger
cd nodejs-grafana-db-logger
npm init -y1.2 필수 패키지 설치
# 기본 패키지
npm install express prom-client
# 데이터베이스 드라이버
npm install mysql2 pg # MySQL, PostgreSQL
# 로깅
npm install winston winston-loki
# 환경 변수
npm install dotenv
# 개발 도구
npm install --save-dev nodemon1.3 프로젝트 구조
nodejs-grafana-db-logger/
├── src/
│ ├── config/
│ │ ├── database.js
│ │ └── logger.js
│ ├── metrics/
│ │ ├── dbMetrics.js
│ │ └── appMetrics.js
│ ├── routes/
│ │ ├── metrics.js
│ │ └── api.js
│ ├── services/
│ │ └── dbService.js
│ └── app.js
├── docker-compose.yml
├── .env
└── package.json2. 데이터베이스 연결 설정
2.1 환경 변수 (.env)
# 서버 설정
PORT=3000
NODE_ENV=production
# MySQL 설정
MYSQL_HOST=localhost
MYSQL_PORT=3306
MYSQL_USER=root
MYSQL_PASSWORD=password
MYSQL_DATABASE=myapp
# PostgreSQL 설정
PG_HOST=localhost
PG_PORT=5432
PG_USER=postgres
PG_PASSWORD=password
PG_DATABASE=myapp
# Loki 설정
LOKI_HOST=http://localhost:31002.2 데이터베이스 연결 (MySQL)
src/config/database.js
const mysql = require('mysql2/promise');
require('dotenv').config();
const pool = mysql.createPool({
host: process.env.MYSQL_HOST,
port: process.env.MYSQL_PORT,
user: process.env.MYSQL_USER,
password: process.env.MYSQL_PASSWORD,
database: process.env.MYSQL_DATABASE,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
enableKeepAlive: true,
keepAliveInitialDelay: 0
});
// 연결 테스트
pool.getConnection()
.then(connection => {
console.log('MySQL connected successfully');
connection.release();
})
.catch(err => {
console.error('MySQL connection failed:', err.message);
});
module.exports = pool;2.3 PostgreSQL 연결
const { Pool } = require('pg');
const pool = new Pool({
host: process.env.PG_HOST,
port: process.env.PG_PORT,
user: process.env.PG_USER,
password: process.env.PG_PASSWORD,
database: process.env.PG_DATABASE,
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
pool.on('connect', () => {
console.log('PostgreSQL connected');
});
pool.on('error', (err) => {
console.error('PostgreSQL error:', err);
});
module.exports = pool;3. Winston 로거 설정
src/config/logger.js
const winston = require('winston');
const LokiTransport = require('winston-loki');
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
defaultMeta: {
service: 'nodejs-db-logger',
environment: process.env.NODE_ENV
},
transports: [
// 콘솔 출력
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
)
}),
// 파일 저장
new winston.transports.File({
filename: 'logs/error.log',
level: 'error'
}),
new winston.transports.File({
filename: 'logs/combined.log'
}),
// Loki 전송
new LokiTransport({
host: process.env.LOKI_HOST,
labels: {
app: 'nodejs-db-logger',
env: process.env.NODE_ENV
},
json: true,
format: winston.format.json(),
replaceTimestamp: true,
onConnectionError: (err) => console.error('Loki connection error:', err)
})
]
});
module.exports = logger;4. Prometheus 메트릭 설정
4.1 데이터베이스 메트릭
src/metrics/dbMetrics.js
const client = require('prom-client');
const logger = require('../config/logger');
// 기본 메트릭 수집
const register = new client.Registry();
client.collectDefaultMetrics({ register });
// DB 쿼리 실행 시간
const dbQueryDuration = new client.Histogram({
name: 'db_query_duration_seconds',
help: 'Duration of database queries in seconds',
labelNames: ['query_type', 'table', 'status'],
buckets: [0.001, 0.01, 0.1, 0.5, 1, 2, 5]
});
// DB 연결 풀 상태
const dbConnectionPool = new client.Gauge({
name: 'db_connection_pool',
help: 'Database connection pool status',
labelNames: ['state'] // active, idle, total
});
// DB 쿼리 카운터
const dbQueryCounter = new client.Counter({
name: 'db_query_total',
help: 'Total number of database queries',
labelNames: ['query_type', 'table', 'status']
});
// DB 에러 카운터
const dbErrorCounter = new client.Counter({
name: 'db_error_total',
help: 'Total number of database errors',
labelNames: ['error_type', 'table']
});
// DB 레코드 수
const dbRecordCount = new client.Gauge({
name: 'db_record_count',
help: 'Number of records in database tables',
labelNames: ['table']
});
// DB 테이블 크기
const dbTableSize = new client.Gauge({
name: 'db_table_size_bytes',
help: 'Size of database tables in bytes',
labelNames: ['table']
});
// 메트릭 등록
register.registerMetric(dbQueryDuration);
register.registerMetric(dbConnectionPool);
register.registerMetric(dbQueryCounter);
register.registerMetric(dbErrorCounter);
register.registerMetric(dbRecordCount);
register.registerMetric(dbTableSize);
module.exports = {
register,
dbQueryDuration,
dbConnectionPool,
dbQueryCounter,
dbErrorCounter,
dbRecordCount,
dbTableSize
};4.2 애플리케이션 메트릭
src/metrics/appMetrics.js
const client = require('prom-client');
const { register } = require('./dbMetrics');
// HTTP 요청 카운터
const httpRequestCounter = new client.Counter({
name: 'http_requests_total',
help: 'Total HTTP requests',
labelNames: ['method', 'route', 'status_code']
});
// HTTP 요청 시간
const httpRequestDuration = new client.Histogram({
name: 'http_request_duration_seconds',
help: 'HTTP request duration in seconds',
labelNames: ['method', 'route', 'status_code'],
buckets: [0.01, 0.05, 0.1, 0.5, 1, 2, 5]
});
// 활성 사용자 수
const activeUsers = new client.Gauge({
name: 'active_users',
help: 'Number of active users'
});
// 비즈니스 메트릭 예시
const orderTotal = new client.Counter({
name: 'orders_total',
help: 'Total number of orders',
labelNames: ['status']
});
const revenueTotal = new client.Counter({
name: 'revenue_total',
help: 'Total revenue amount',
labelNames: ['currency']
});
register.registerMetric(httpRequestCounter);
register.registerMetric(httpRequestDuration);
register.registerMetric(activeUsers);
register.registerMetric(orderTotal);
register.registerMetric(revenueTotal);
module.exports = {
httpRequestCounter,
httpRequestDuration,
activeUsers,
orderTotal,
revenueTotal
};5. DB 서비스 구현
src/services/dbService.js
const db = require('../config/database');
const logger = require('../config/logger');
const {
dbQueryDuration,
dbQueryCounter,
dbErrorCounter,
dbRecordCount,
dbTableSize,
dbConnectionPool
} = require('../metrics/dbMetrics');
class DBService {
// 쿼리 실행 with 메트릭
async executeQuery(query, params = [], options = {}) {
const { queryType = 'SELECT', table = 'unknown' } = options;
const end = dbQueryDuration.startTimer({ query_type: queryType, table });
try {
logger.info('Executing query', { query, params, queryType, table });
const [rows] = await db.execute(query, params);
// 성공 메트릭
dbQueryCounter.inc({ query_type: queryType, table, status: 'success' });
end({ status: 'success' });
logger.info('Query executed successfully', {
rowCount: rows.length,
queryType,
table
});
return rows;
} catch (error) {
// 에러 메트릭
dbQueryCounter.inc({ query_type: queryType, table, status: 'error' });
dbErrorCounter.inc({ error_type: error.code || 'unknown', table });
end({ status: 'error' });
logger.error('Query execution failed', {
error: error.message,
query,
queryType,
table
});
throw error;
}
}
// 테이블 레코드 수 조회
async getTableRecordCount(tableName) {
try {
const query = `SELECT COUNT(*) as count FROM ${tableName}`;
const rows = await this.executeQuery(query, [], {
queryType: 'COUNT',
table: tableName
});
const count = rows[0].count;
dbRecordCount.set({ table: tableName }, count);
return count;
} catch (error) {
logger.error('Failed to get record count', {
table: tableName,
error: error.message
});
return 0;
}
}
// 테이블 크기 조회 (MySQL)
async getTableSize(tableName) {
try {
const query = `
SELECT
data_length + index_length as size_bytes
FROM information_schema.TABLES
WHERE table_schema = DATABASE()
AND table_name = ?
`;
const rows = await this.executeQuery(query, [tableName], {
queryType: 'SELECT',
table: 'information_schema'
});
const sizeBytes = rows[0]?.size_bytes || 0;
dbTableSize.set({ table: tableName }, sizeBytes);
return sizeBytes;
} catch (error) {
logger.error('Failed to get table size', {
table: tableName,
error: error.message
});
return 0;
}
}
// 연결 풀 상태 업데이트
async updateConnectionPoolMetrics() {
try {
const pool = db.pool;
dbConnectionPool.set({ state: 'total' }, pool.config.connectionLimit);
dbConnectionPool.set({ state: 'active' }, pool._allConnections.length);
dbConnectionPool.set({ state: 'idle' }, pool._freeConnections.length);
logger.debug('Connection pool metrics updated', {
total: pool.config.connectionLimit,
active: pool._allConnections.length,
idle: pool._freeConnections.length
});
} catch (error) {
logger.error('Failed to update connection pool metrics', {
error: error.message
});
}
}
// 모든 테이블 메트릭 수집
async collectAllTableMetrics() {
try {
const [tables] = await db.query('SHOW TABLES');
for (const tableObj of tables) {
const tableName = Object.values(tableObj)[0];
await this.getTableRecordCount(tableName);
await this.getTableSize(tableName);
}
logger.info('All table metrics collected');
} catch (error) {
logger.error('Failed to collect table metrics', {
error: error.message
});
}
}
// 슬로우 쿼리 로깅
async getSlowQueries() {
try {
const query = `
SELECT
query_time,
lock_time,
rows_sent,
rows_examined,
sql_text
FROM mysql.slow_log
ORDER BY query_time DESC
LIMIT 10
`;
const rows = await this.executeQuery(query, [], {
queryType: 'SELECT',
table: 'mysql.slow_log'
});
rows.forEach(row => {
logger.warn('Slow query detected', {
query_time: row.query_time,
rows_examined: row.rows_examined,
sql: row.sql_text.substring(0, 200)
});
});
return rows;
} catch (error) {
logger.error('Failed to get slow queries', {
error: error.message
});
return [];
}
}
}
module.exports = new DBService();6. API 라우트 구현
6.1 메트릭 엔드포인트
src/routes/metrics.js
const express = require('express');
const router = express.Router();
const { register } = require('../metrics/dbMetrics');
const dbService = require('../services/dbService');
// Prometheus 메트릭 엔드포인트
router.get('/metrics', async (req, res) => {
try {
// 최신 메트릭 수집
await dbService.updateConnectionPoolMetrics();
await dbService.collectAllTableMetrics();
res.set('Content-Type', register.contentType);
res.end(await register.metrics());
} catch (error) {
res.status(500).json({ error: error.message });
}
});
module.exports = router;6.2 애플리케이션 API
src/routes/api.js
const express = require('express');
const router = express.Router();
const dbService = require('../services/dbService');
const logger = require('../config/logger');
const { orderTotal, revenueTotal } = require('../metrics/appMetrics');
// 사용자 조회
router.get('/users', async (req, res) => {
try {
const users = await dbService.executeQuery(
'SELECT * FROM users LIMIT 100',
[],
{ queryType: 'SELECT', table: 'users' }
);
logger.info('Users fetched', { count: users.length });
res.json({ success: true, data: users });
} catch (error) {
logger.error('Failed to fetch users', { error: error.message });
res.status(500).json({ success: false, error: error.message });
}
});
// 주문 생성 (비즈니스 메트릭)
router.post('/orders', async (req, res) => {
try {
const { user_id, amount, currency = 'USD' } = req.body;
const result = await dbService.executeQuery(
'INSERT INTO orders (user_id, amount, currency, status) VALUES (?, ?, ?, ?)',
[user_id, amount, currency, 'pending'],
{ queryType: 'INSERT', table: 'orders' }
);
// 비즈니스 메트릭 업데이트
orderTotal.inc({ status: 'pending' });
revenueTotal.inc({ currency }, amount);
logger.info('Order created', {
orderId: result.insertId,
userId: user_id,
amount
});
res.json({
success: true,
orderId: result.insertId
});
} catch (error) {
logger.error('Failed to create order', { error: error.message });
res.status(500).json({ success: false, error: error.message });
}
});
// 로그 조회 API
router.get('/logs', async (req, res) => {
try {
const { level = 'info', limit = 100 } = req.query;
const logs = await dbService.executeQuery(
'SELECT * FROM application_logs WHERE level = ? ORDER BY timestamp DESC LIMIT ?',
[level, parseInt(limit)],
{ queryType: 'SELECT', table: 'application_logs' }
);
res.json({ success: true, data: logs });
} catch (error) {
logger.error('Failed to fetch logs', { error: error.message });
res.status(500).json({ success: false, error: error.message });
}
});
module.exports = router;7. Express 애플리케이션 설정
src/app.js
const express = require('express');
const logger = require('./config/logger');
const metricsRouter = require('./routes/metrics');
const apiRouter = require('./routes/api');
const { httpRequestCounter, httpRequestDuration } = require('./metrics/appMetrics');
const app = express();
const PORT = process.env.PORT || 3000;
// 미들웨어
app.use(express.json());
// HTTP 메트릭 미들웨어
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const duration = (Date.now() - start) / 1000;
const route = req.route ? req.route.path : req.path;
httpRequestCounter.inc({
method: req.method,
route: route,
status_code: res.statusCode
});
httpRequestDuration.observe({
method: req.method,
route: route,
status_code: res.statusCode
}, duration);
logger.info('HTTP Request', {
method: req.method,
path: req.path,
statusCode: res.statusCode,
duration: `${duration}s`
});
});
next();
});
// 라우트
app.use('/metrics', metricsRouter);
app.use('/api', apiRouter);
// 헬스 체크
app.get('/health', (req, res) => {
res.json({ status: 'healthy', timestamp: new Date().toISOString() });
});
// 에러 핸들러
app.use((err, req, res, next) => {
logger.error('Unhandled error', {
error: err.message,
stack: err.stack
});
res.status(500).json({
error: 'Internal server error',
message: err.message
});
});
// 서버 시작
app.listen(PORT, () => {
logger.info(`Server running on port ${PORT}`);
console.log(`Server: http://localhost:${PORT}`);
console.log(`Metrics: http://localhost:${PORT}/metrics`);
});
module.exports = app;8. Docker Compose 설정
docker-compose.yml
version: '3.8'
services:
# Node.js 애플리케이션
nodejs-app:
build: .
container_name: nodejs-app
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- MYSQL_HOST=mysql
- MYSQL_DATABASE=myapp
depends_on:
- mysql
- prometheus
- loki
restart: unless-stopped
# MySQL
mysql:
image: mysql:8.0
container_name: mysql
environment:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: myapp
ports:
- "3306:3306"
volumes:
- mysql-data:/var/lib/mysql
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
restart: unless-stopped
# Prometheus
prometheus:
image: prom/prometheus:latest
container_name: prometheus
ports:
- "9090:9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
restart: unless-stopped
# Grafana
grafana:
image: grafana/grafana:latest
container_name: grafana
ports:
- "3001:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=admin
volumes:
- grafana-data:/var/lib/grafana
- ./grafana/provisioning:/etc/grafana/provisioning
depends_on:
- prometheus
- loki
restart: unless-stopped
# Loki (로그 수집)
loki:
image: grafana/loki:latest
container_name: loki
ports:
- "3100:3100"
volumes:
- ./loki-config.yml:/etc/loki/local-config.yaml
- loki-data:/loki
command: -config.file=/etc/loki/local-config.yaml
restart: unless-stopped
volumes:
mysql-data:
prometheus-data:
grafana-data:
loki-data:9. Prometheus 설정
prometheus.yml
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: 'nodejs-app'
static_configs:
- targets: ['nodejs-app:3000']
metrics_path: /metrics
scrape_interval: 10s10. Grafana 대시보드 쿼리
10.1 데이터베이스 메트릭 쿼리
# 쿼리 실행 속도 (평균)
rate(db_query_duration_seconds_sum[5m]) / rate(db_query_duration_seconds_count[5m])
# 쿼리 실행 속도 (95 percentile)
histogram_quantile(0.95, rate(db_query_duration_seconds_bucket[5m]))
# 테이블별 쿼리 수
sum(rate(db_query_total[5m])) by (table)
# DB 에러율
rate(db_error_total[5m])
# 연결 풀 사용률
db_connection_pool{state="active"} / db_connection_pool{state="total"} * 100
# 테이블 레코드 수
db_record_count
# 테이블 크기 (MB)
db_table_size_bytes / 1024 / 1024
# 쿼리 성공률
sum(rate(db_query_total{status="success"}[5m])) / sum(rate(db_query_total[5m])) * 10010.2 애플리케이션 메트릭 쿼리
# HTTP 요청 수
sum(rate(http_requests_total[5m])) by (method, route)
# HTTP 응답 시간 (평균)
rate(http_request_duration_seconds_sum[5m]) / rate(http_request_duration_seconds_count[5m])
# HTTP 에러율
sum(rate(http_requests_total{status_code=~"5.."}[5m])) / sum(rate(http_requests_total[5m])) * 100
# 주문 생성 속도
rate(orders_total[5m])
# 매출 추이
rate(revenue_total[1h])10.3 Loki 로그 쿼리
# 에러 로그 조회
{app="nodejs-db-logger"} |= "error"
# 특정 테이블 쿼리 로그
{app="nodejs-db-logger"} | json | table="users"
# 슬로우 쿼리 감지
{app="nodejs-db-logger"} | json | duration > 1s
# HTTP 5xx 에러
{app="nodejs-db-logger"} | json | status_code =~ "5.."
# 로그 레벨별 카운트
sum(count_over_time({app="nodejs-db-logger"} [5m])) by (level)11. 샘플 데이터베이스 스키마
init.sql
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS orders (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
amount DECIMAL(10, 2) NOT NULL,
currency VARCHAR(3) DEFAULT 'USD',
status ENUM('pending', 'completed', 'failed') DEFAULT 'pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS application_logs (
id INT AUTO_INCREMENT PRIMARY KEY,
level ENUM('error', 'warn', 'info', 'debug') NOT NULL,
message TEXT NOT NULL,
metadata JSON,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_level (level),
INDEX idx_timestamp (timestamp)
);
-- 샘플 데이터 추가
INSERT INTO users (username, email) VALUES
('john_doe', 'john@example.com'),
('jane_smith', 'jane@example.com'),
('bob_wilson', 'bob@example.com');
INSERT INTO orders (user_id, amount, status) VALUES
(1, 99.99, 'completed'),
(2, 149.50, 'pending'),
(3, 79.99, 'completed');12. Grafana 대시보드 JSON
{
"dashboard": {
"title": "Node.js DB Monitoring",
"panels": [
{
"title": "Database Query Duration (p95)",
"targets": [
{
"expr": "histogram_quantile(0.95, rate(db_query_duration_seconds_bucket[5m]))",
"legendFormat": "{{table}} - {{query_type}}"
}
],
"type": "graph"
},
{
"title": "Query Rate by Table",
"targets": [
{
"expr": "sum(rate(db_query_total[5m])) by (table)",
"legendFormat": "{{table}}"
}
],
"type": "graph"
},
{
"title": "Connection Pool Usage",
"targets": [
{
"expr": "db_connection_pool{state=\"active\"} / db_connection_pool{state=\"total\"} * 100",
"legendFormat": "Pool Usage %"
}
],
"type": "gauge"
},
{
"title": "Table Record Counts",
"targets": [
{
"expr": "db_record_count",
"legendFormat": "{{table}}"
}
],
"type": "stat"
},
{
"title": "Error Logs (Last 1h)",
"targets": [
{
"expr": "{app=\"nodejs-db-logger\"} |= \"error\"",
"refId": "A"
}
],
"type": "logs",
"datasource": "Loki"
}
]
}
}13. 실행 및 테스트
13.1 애플리케이션 실행
npm run dev
npm start
docker-compose up -d13.2 메트릭 확인
# 메트릭 엔드포인트 호출
curl http://localhost:3000/metrics
# API 테스트
curl http://localhost:3000/api/users
curl -X POST http://localhost:3000/api/orders \
-H "Content-Type: application/json" \
-d '{"user_id": 1, "amount": 99.99}'13.3 접속 URL
- Node.js API: http://localhost:3000
- Prometheus: http://localhost:9090
- Grafana: http://localhost:3001 (admin/admin)
- Loki: http://localhost:3100
14. 모범 사례
14.1 메트릭 네이밍
// good
db_query_duration_seconds
http_requests_total
db_connection_pool_active
// bad
queryTime
requests
connections14.2 로깅 구조화
logger.info('User action', {
userId: user.id,
action: 'purchase',
amount: 99.99,
timestamp: new Date().toISOString()
});14.3 에러 처리
try {
await dbService.executeQuery(query, params);
} catch (error) {
logger.error('Query failed', {
error: error.message,
stack: error.stack,
query: query.substring(0, 100)
});
throw error;
}마치며
Node.js 로 데이터베이스 로그를 그라파나에서 시각화하는 전체 파이프라인을 구축해보았으며 이로 인해 이러한 장점이 있다.
- 실시간 DB 성능 모니터링
- 슬로우 쿼리 감지 및 최적화
- 애플리케이션 로그 중앙 관리
- 비즈니스 메트릭 추적
운영 환경에서는 보안(API 키, DB 암호화), 성능(쿼리 최적화), 확장성(샤딩, 읽기 복제본)을 추가로 고려해야 할 것 같다.
참고 자료
'DevOps > 모니터링' 카테고리의 다른 글
| [모니터링] - 그라파나 알림 매니저 (Grafana AlertManager) 활용하기 (0) | 2024.02.16 |
|---|---|
| [모니터링] - Grafana + Prometheus + cAdvisor로 컨테이너 상태(리소스) 수집하기 (0) | 2024.02.16 |
| [DevOps] - Jenkins와 Spring Boot로 구축하는 CI/CD 파이프라인 (0) | 2024.02.16 |
| [모니터링] - Grafana Loki로 도커 컨테이너 로그 보기 (0) | 2024.02.15 |
| [모니터링] - 그라파나 + 프로메테우스로 네트워크 로그 수집하기 (0) | 2024.02.15 |