Skip to content

Commit 806c076

Browse files
committed
리팩토링: API/코어 로그 포맷 통합 및 로깅 경로 정리
1 parent b9c74ef commit 806c076

14 files changed

Lines changed: 208 additions & 104 deletions

File tree

apps/api/src/app.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ export async function initServices(db: DbProvider): Promise<AppServices> {
185185
const refreshSecret = env.JWT_REFRESH_SECRET ?? 'dev-refresh-secret-change-in-production';
186186

187187
if (env.NODE_ENV === 'production' && !env.JWT_SECRET) {
188-
throw new Error('[fieldstack][api] JWT_SECRET must be set in production');
188+
throw new Error('JWT_SECRET must be set in production');
189189
}
190190

191191
const jwtManager = new JwtSessionManagerImpl(db, accessSecret, refreshSecret);

apps/api/src/config/env.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { z } from 'zod';
2+
import { formatLogLine } from '../logging/format';
23

34
const EnvSchema = z.object({
45
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
@@ -23,9 +24,9 @@ export function validateEnv(raw: NodeJS.ProcessEnv): Env {
2324
const result = EnvSchema.safeParse(raw);
2425

2526
if (!result.success) {
26-
console.error('[fieldstack][api] Invalid environment variables:');
27+
console.error(formatLogLine('api', 'Invalid environment variables:', 'error'));
2728
for (const [field, issue] of Object.entries(result.error.flatten().fieldErrors)) {
28-
console.error(` ${field}: ${(issue as string[]).join(', ')}`);
29+
console.error(formatLogLine('api', `${field}: ${(issue as string[]).join(', ')}`, 'error'));
2930
}
3031
process.exit(1);
3132
}

apps/api/src/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,12 @@ function printSetupBanner(apiPort: number, isDev: boolean) {
7777
}
7878
lines.push(' ╚══════════════════════════════════════════════════════╝');
7979
lines.push('');
80-
console.log(lines.join('\n'));
80+
for (const line of lines) {
81+
// 빈 줄은 스킵하고, 배너 각 줄도 동일한 공통 로그 포맷으로 남긴다.
82+
if (line.length > 0) {
83+
log.info('setup', line);
84+
}
85+
}
8186
}
8287

8388
// ── Setup 모드 ─────────────────────────────────────────────────

apps/api/src/logging/format.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
export type LogLevel = 'info' | 'warn' | 'error' | 'success';
2+
3+
// 운영에서는 ANSI 색상을 제거하고, 개발 환경에서만 색상을 적용한다.
4+
const IS_COLOR_ENABLED = process.env['NODE_ENV'] !== 'production';
5+
6+
const LOG_COLORS = IS_COLOR_ENABLED
7+
? {
8+
reset: '\x1b[0m',
9+
dim: '\x1b[2m',
10+
green: '\x1b[32m',
11+
yellow: '\x1b[33m',
12+
red: '\x1b[31m',
13+
cyan: '\x1b[36m',
14+
blue: '\x1b[34m',
15+
gray: '\x1b[90m',
16+
}
17+
: {
18+
reset: '',
19+
dim: '',
20+
green: '',
21+
yellow: '',
22+
red: '',
23+
cyan: '',
24+
blue: '',
25+
gray: '',
26+
};
27+
28+
export { LOG_COLORS };
29+
30+
// 모든 API 로그가 동일한 밀리초 타임스탬프를 사용하도록 통일.
31+
export function logTimestamp(): string {
32+
return new Date().toISOString().replace('T', ' ').slice(0, 23);
33+
}
34+
35+
function tagColor(level: LogLevel): string {
36+
if (level === 'warn') return LOG_COLORS.yellow;
37+
if (level === 'error') return LOG_COLORS.red;
38+
if (level === 'success') return LOG_COLORS.green;
39+
return LOG_COLORS.cyan;
40+
}
41+
42+
function messageColor(level: LogLevel): string {
43+
if (level === 'warn') return LOG_COLORS.yellow;
44+
if (level === 'error') return LOG_COLORS.red;
45+
if (level === 'success') return LOG_COLORS.green;
46+
return '';
47+
}
48+
49+
export function formatLogLine(tag: string, message: string, level: LogLevel = 'info'): string {
50+
// 목표 포맷: "YYYY-MM-DD HH:mm:ss.SSS [tag] message"
51+
// 태그/메시지 색상은 level에 따라 선택적으로 적용한다.
52+
const tagStyled = `${tagColor(level)}[${tag}]${LOG_COLORS.reset}`;
53+
const msgColor = messageColor(level);
54+
const messageStyled = msgColor.length > 0
55+
? `${msgColor}${message}${LOG_COLORS.reset}`
56+
: message;
57+
return `${LOG_COLORS.gray}${logTimestamp()}${LOG_COLORS.reset} ${tagStyled} ${messageStyled}`;
58+
}

apps/api/src/middleware/error.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import type { ErrorRequestHandler } from 'express';
2+
import { formatLogLine } from '../logging/format';
23

34
export const errorHandler: ErrorRequestHandler = (err, _req, res, _next) => {
45
const isDev = process.env['NODE_ENV'] === 'development';
56

6-
console.error('[fieldstack][api] unhandled error:', err);
7+
const detail = err instanceof Error ? err.stack ?? err.message : String(err);
8+
console.error(formatLogLine('api', `unhandled error: ${detail}`, 'error'));
79

810
res.status(500).json({
911
error: 'Internal server error',

apps/api/src/middleware/logger.ts

Lines changed: 36 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,39 @@
11
import type { NextFunction, Request, Response } from 'express';
22

3-
// ── ANSI 색상 (개발 모드용) ────────────────────────────────────
4-
5-
const IS_DEV = process.env['NODE_ENV'] !== 'production';
6-
7-
const c = IS_DEV
8-
? {
9-
reset: '\x1b[0m',
10-
dim: '\x1b[2m',
11-
bold: '\x1b[1m',
12-
green: '\x1b[32m',
13-
yellow: '\x1b[33m',
14-
red: '\x1b[31m',
15-
cyan: '\x1b[36m',
16-
magenta: '\x1b[35m',
17-
blue: '\x1b[34m',
18-
gray: '\x1b[90m',
19-
}
20-
: Object.fromEntries(
21-
['reset', 'dim', 'bold', 'green', 'yellow', 'red', 'cyan', 'magenta', 'blue', 'gray'].map(
22-
(k) => [k, ''],
23-
),
24-
);
3+
import { LOG_COLORS, formatLogLine } from '../logging/format';
254

26-
// ── 타임스탬프 ────────────────────────────────────────────────
5+
// ── 스킵 경로 ────────────────────────────────────────────────
6+
// health 체크는 매 30초마다 호출되어 로그를 오염시키므로 제외
277

28-
function ts(): string {
29-
return new Date().toISOString().replace('T', ' ').slice(0, 23);
30-
}
8+
const SKIP_PATHS = new Set(['/health', '/health/']);
319

3210
// ── 상태코드 → 색상 ───────────────────────────────────────────
3311

3412
function statusColor(status: number): string {
35-
if (status >= 500) return c['red'] as string;
36-
if (status >= 400) return c['yellow'] as string;
37-
if (status >= 300) return c['cyan'] as string;
38-
return c['green'] as string;
13+
if (status >= 500) return LOG_COLORS.red;
14+
if (status >= 400) return LOG_COLORS.yellow;
15+
if (status >= 300) return LOG_COLORS.cyan;
16+
return LOG_COLORS.green;
3917
}
4018

4119
// ── HTTP 메서드 → 색상 ────────────────────────────────────────
4220

4321
function methodColor(method: string): string {
4422
switch (method.toUpperCase()) {
4523
case 'GET':
46-
return c['blue'] as string;
24+
return LOG_COLORS.blue;
4725
case 'POST':
48-
return c['green'] as string;
26+
return LOG_COLORS.green;
4927
case 'PUT':
5028
case 'PATCH':
51-
return c['yellow'] as string;
29+
return LOG_COLORS.yellow;
5230
case 'DELETE':
53-
return c['red'] as string;
31+
return LOG_COLORS.red;
5432
default:
55-
return c['gray'] as string;
33+
return LOG_COLORS.gray;
5634
}
5735
}
5836

59-
// ── 스킵 경로 ────────────────────────────────────────────────
60-
// health 체크는 매 30초마다 호출되어 로그를 오염시키므로 제외
61-
62-
const SKIP_PATHS = new Set(['/health', '/health/']);
63-
6437
// ── HTTP 요청 로거 미들웨어 ────────────────────────────────────
6538

6639
export function requestLogger(req: Request, res: Response, next: NextFunction): void {
@@ -70,23 +43,29 @@ export function requestLogger(req: Request, res: Response, next: NextFunction):
7043
}
7144

7245
const start = Date.now();
73-
const method = req.method.padEnd(6);
46+
const method = req.method.toUpperCase().padEnd(6);
7447
const path = req.path;
7548
const query = Object.keys(req.query).length > 0 ? `?${new URLSearchParams(req.query as Record<string, string>)}` : '';
49+
// 메서드/쿼리 문자열은 메시지 본문에서만 색상을 주고,
50+
// 공통 포맷(formatLogLine)의 timestamp/tag 포맷은 그대로 유지한다.
51+
const methodStyled = `${methodColor(req.method)}${method}${LOG_COLORS.reset}`;
52+
const queryStyled = query.length > 0 ? `${LOG_COLORS.dim}${query}${LOG_COLORS.reset}` : '';
7653

7754
// 요청 수신 로그
78-
console.log(
79-
`${c['gray']}${ts()}${c['reset']} ${c['dim']}${c['reset']} ${methodColor(req.method)}${method}${c['reset']} ${path}${c['dim']}${query}${c['reset']}`,
80-
);
55+
console.log(formatLogLine('http', `${LOG_COLORS.dim}${LOG_COLORS.reset} ${methodStyled} ${path}${queryStyled}`));
8156

8257
// 응답 완료 후 로그
8358
res.on('finish', () => {
8459
const ms = Date.now() - start;
8560
const status = res.statusCode;
8661
const durationStr = ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(2)}s`;
8762

63+
const statusStyled = `${statusColor(status)}${status}${LOG_COLORS.reset}`;
8864
console.log(
89-
`${c['gray']}${ts()}${c['reset']} ${c['dim']}${c['reset']} ${statusColor(status)}${status}${c['reset']} ${methodColor(req.method)}${method}${c['reset']} ${path} ${c['dim']}(${durationStr})${c['reset']}`,
65+
formatLogLine(
66+
'http',
67+
`${LOG_COLORS.dim}${LOG_COLORS.reset} ${statusStyled} ${methodStyled} ${path} ${LOG_COLORS.dim}(${durationStr})${LOG_COLORS.reset}`,
68+
),
9069
);
9170
});
9271

@@ -98,35 +77,26 @@ export function requestLogger(req: Request, res: Response, next: NextFunction):
9877

9978
export const log = {
10079
info(tag: string, msg: string, meta?: Record<string, unknown>): void {
101-
const metaStr = meta ? ` ${c['dim']}${JSON.stringify(meta)}${c['reset']}` : '';
102-
console.log(
103-
`${c['gray']}${ts()}${c['reset']} ${c['cyan']}[${tag}]${c['reset']} ${msg}${metaStr}`,
104-
);
80+
const metaStr = meta ? ` ${JSON.stringify(meta)}` : '';
81+
console.log(formatLogLine(tag, `${msg}${metaStr}`, 'info'));
10582
},
10683

10784
warn(tag: string, msg: string, meta?: Record<string, unknown>): void {
108-
const metaStr = meta ? ` ${c['dim']}${JSON.stringify(meta)}${c['reset']}` : '';
109-
console.warn(
110-
`${c['gray']}${ts()}${c['reset']} ${c['yellow']}[${tag}]${c['reset']} ${c['yellow']}${msg}${c['reset']}${metaStr}`,
111-
);
85+
const metaStr = meta ? ` ${JSON.stringify(meta)}` : '';
86+
console.warn(formatLogLine(tag, `${msg}${metaStr}`, 'warn'));
11287
},
11388

11489
error(tag: string, msg: string, err?: unknown): void {
115-
const errStr =
116-
err instanceof Error
117-
? ` — ${err.message}${IS_DEV && err.stack ? `\n${c['dim']}${err.stack}${c['reset']}` : ''}`
118-
: err != null
119-
? ` — ${String(err)}`
120-
: '';
121-
console.error(
122-
`${c['gray']}${ts()}${c['reset']} ${c['red']}[${tag}]${c['reset']} ${c['red']}${msg}${c['reset']}${errStr}`,
123-
);
90+
const errStr = err == null
91+
? ''
92+
: err instanceof Error
93+
? ` — ${err.stack ?? err.message}`
94+
: ` — ${String(err)}`;
95+
console.error(formatLogLine(tag, `${msg}${errStr}`, 'error'));
12496
},
12597

12698
success(tag: string, msg: string, meta?: Record<string, unknown>): void {
127-
const metaStr = meta ? ` ${c['dim']}${JSON.stringify(meta)}${c['reset']}` : '';
128-
console.log(
129-
`${c['gray']}${ts()}${c['reset']} ${c['green']}[${tag}]${c['reset']} ${c['green']}${msg}${c['reset']}${metaStr}`,
130-
);
99+
const metaStr = meta ? ` ${JSON.stringify(meta)}` : '';
100+
console.log(formatLogLine(tag, `${msg}${metaStr}`, 'success'));
131101
},
132102
};

apps/api/src/setup/mode.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import fs from 'node:fs';
22
import path from 'node:path';
33

4+
import { formatLogLine } from '../logging/format';
5+
46
// 프로젝트 루트: apps/api/src/setup → ../../../../
57
const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..', '..');
68

@@ -72,9 +74,9 @@ export function applyConfigToEnv(): void {
7274
// PM2/Docker/tsx watch 등 프로세스 매니저가 exit(0)을 감지해 자동 재시작한다.
7375

7476
export function scheduleRestart(delayMs = 500): void {
75-
console.log(`[fieldstack][setup] Server restart scheduled in ${delayMs}ms`);
77+
console.log(formatLogLine('setup', `Server restart scheduled in ${delayMs}ms`));
7678
setTimeout(() => {
77-
console.log('[fieldstack][setup] Exiting for restart.');
79+
console.log(formatLogLine('setup', 'Exiting for restart.'));
7880
process.exit(0);
7981
}, delayMs);
8082
}

packages/core/src/db/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,15 @@ export async function getDb(): Promise<DbProvider> {
3030

3131
if (provider === 'postgres') {
3232
const url = process.env['DATABASE_URL'];
33-
if (!url) throw new Error('[fieldstack][db] DATABASE_URL is required for postgres provider');
33+
if (!url) throw new Error('DATABASE_URL is required for postgres provider');
3434
const { PostgresProvider } = await import('./providers/postgres.js');
3535
_db = new PostgresProvider({ connectionString: url });
3636
} else if (provider === 'sqlite') {
3737
const path = process.env['SQLITE_PATH'] ?? './data/database.db';
3838
const { SqliteProvider } = await import('./providers/sqlite.js');
3939
_db = new SqliteProvider({ connectionString: path });
4040
} else {
41-
throw new Error(`[fieldstack][db] Unknown DB_PROVIDER: "${provider}"`);
41+
throw new Error(`Unknown DB_PROVIDER: "${provider}"`);
4242
}
4343

4444
await _db.connect();

packages/core/src/db/migrations/index.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { readdir, readFile } from 'node:fs/promises';
22
import { join } from 'node:path';
33

44
import type { DbProvider, DbRow } from '../index.js';
5+
import { coreLog } from '../../logging.js';
56

67
// ── _migrations 테이블 ────────────────────────────────────────
78

@@ -62,9 +63,7 @@ export class FileMigrationRunner {
6263
const files = await this.getPendingFiles();
6364
if (files.length === 0) return;
6465

65-
console.log(
66-
`[fieldstack][migrations] ${this.moduleName}: ${files.length} pending migration(s)`,
67-
);
66+
coreLog.info('migrations', `${this.moduleName}: ${files.length} pending migration(s)`);
6867

6968
for (const filename of files) {
7069
await this.applyFile(filename);
@@ -85,6 +84,7 @@ export class FileMigrationRunner {
8584
.filter((f) => f.endsWith('.sql'))
8685
.sort();
8786
} catch {
87+
// 모듈에 migrations 디렉터리가 없는 경우도 정상 시나리오이므로 빈 목록 반환.
8888
return [];
8989
}
9090

@@ -104,14 +104,16 @@ export class FileMigrationRunner {
104104
const sql = applyDialect(rawSql, this.db.name);
105105

106106
await this.db.transaction(async (tx) => {
107+
// SQL 실행과 _migrations 기록을 하나의 트랜잭션으로 묶어
108+
// 중간 실패 시 "실행은 됐는데 기록은 없는" 불일치를 막는다.
107109
await tx.query(sql);
108110
await tx.query(
109111
'INSERT INTO _migrations (module, filename) VALUES ($1, $2)',
110112
[this.moduleName, filename],
111113
);
112114
});
113115

114-
console.log(`[fieldstack][migrations] ${this.moduleName}: applied ${filename}`);
116+
coreLog.info('migrations', `${this.moduleName}: applied ${filename}`);
115117
}
116118
}
117119

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { DbConnectionConfig, DbProvider, DbRow } from '../index.js';
2+
import { coreLog } from '../../logging.js';
23

34
/** MongoDB Provider — scaffold (미구현, Phase later) */
45
export class MongoDbProvider implements DbProvider {
@@ -7,20 +8,18 @@ export class MongoDbProvider implements DbProvider {
78
public constructor(private readonly config: DbConnectionConfig) {}
89

910
public async connect(): Promise<void> {
10-
console.warn(
11-
`[fieldstack][db] MongoDB provider is not yet implemented. uri="${this.config.connectionString}"`,
12-
);
11+
coreLog.warn('db', `MongoDB provider is not yet implemented. uri="${this.config.connectionString}"`);
1312
}
1413

1514
public async disconnect(): Promise<void> {
1615
return Promise.resolve();
1716
}
1817

1918
public async query<T extends DbRow = DbRow>(_sql: string, _params?: unknown[]): Promise<T[]> {
20-
throw new Error('[fieldstack][db] MongoDB provider is not yet implemented');
19+
throw new Error('MongoDB provider is not yet implemented');
2120
}
2221

2322
public async transaction<T>(_fn: (tx: DbProvider) => Promise<T>): Promise<T> {
24-
throw new Error('[fieldstack][db] MongoDB provider is not yet implemented');
23+
throw new Error('MongoDB provider is not yet implemented');
2524
}
2625
}

0 commit comments

Comments
 (0)