/* * 트윈 로그의 문 — **모든 줄에 시각을 찍는다.** 순수(프레임워크 의존 없음). * * ── 왜 필요한가 (2026-08-22) ──────────────────────────────────────────────── * 「서버가 왜 느린가」를 조사하면서 루프가 55초 막힌 것을 계기로 알아냈는데(`loop-lag.ts`), * **그 시각에 무슨 일이 있었는지 로그에서 찾을 수 없었다.** 트윈의 로그가 `console.log` 로 나가 * 시각이 없었기 때문이다. 프레임워크 로그(`2026-08-22T05:54:12+09:00 info: …`)와 섞여 있어서, 어느 * 줄이 언제 찍혔는지 앞뒤 프레임워크 줄로 짐작해야 했다. * * ── 왜 프레임워크 로거를 그대로 쓰지 않나 ────────────────────────────────── * 재발명을 피하려고 `@things-factory/env` 의 `logger` 를 쓰려 했는데, 실측으로 막혔다: 앱 **밖**에서는 * 그 로거에 전송지가 하나도 없다(설정이 앱에서 주입된다). 그래서 시험·도구에서 부르면 * * [winston] Attempt to write logs with no transports … {"message":"…","level":"info"} * * 가 나오고 **메시지 자체가 사라진다.** 트윈 엔진은 시험과 CLI 도구에서도 불려 다니므로, 그 조건에서 * 로그가 없어지는 것은 바꿔선 안 되는 거동이다(로그가 사라지는 것이 지금 문제의 원인이었다). * * 그래서 **싱크는 그대로 두고**(console) 빠진 사실 하나만 채운다 — 시각. 형식은 프레임워크와 같은 * 모양으로 맞춰 한 파일에서 섞여 읽히게 하고, **밀리초까지** 남긴다(계기의 값이 epoch ms 라 * 초 단위로는 같은 초 안의 순서를 가릴 수 없다). */ /** 두 자리·세 자리 0 채우기 — 형식이 흔들리면 정렬해 읽을 수 없다. */ const p2 = (n: number) => String(n).padStart(2, '0') const p3 = (n: number) => String(n).padStart(3, '0') /** * 지역 시각 + 오프셋 — `2026-08-22T05:54:12.345+09:00`. * * UTC 로 찍지 않는다: 이 로그를 읽는 사람은 현장의 시각으로 이야기하고, 프레임워크의 다른 줄도 * 지역 시각이다. 두 줄이 다른 기준이면 나란히 놓고 읽을 수 없다. */ export function stamp(at: Date = new Date()): string { const off = -at.getTimezoneOffset() const sign = off >= 0 ? '+' : '-' const abs = Math.abs(off) return ( `${at.getFullYear()}-${p2(at.getMonth() + 1)}-${p2(at.getDate())}` + `T${p2(at.getHours())}:${p2(at.getMinutes())}:${p2(at.getSeconds())}.${p3(at.getMilliseconds())}` + `${sign}${p2(Math.floor(abs / 60))}:${p2(abs % 60)}` ) } /** * 인자 여럿을 한 줄로 — **어떤 인자도 버리지 않는다.** * * `console.error(msg, err)` 처럼 쓰던 자리가 많다. 프레임워크 로거에 그대로 넘기면 두 번째 인자는 * meta 로 들어가 우리 형식(`message` 만 찍는다)에서 **오류 없이 사라진다.** 그래서 여기서 문자열로 편다. * 오류는 스택까지 — 스택이 없으면 「어디서 났나」를 잃는다. */ export function line(args: unknown[]): string { return args .map(a => { if (typeof a === 'string') return a if (a instanceof Error) return a.stack ?? a.message if (a === undefined) return 'undefined' if (a === null) return 'null' try { return typeof a === 'object' ? JSON.stringify(a) : String(a) } catch { /* 순환 참조 등 — 값을 못 적어도 **무엇이 있었다는 사실**은 남긴다. */ return String(a) } }) .join(' ') } /* 수준 표기는 프레임워크와 같은 낱말을 쓴다(`info`·`warn`·`error`) — 같은 파일에서 걸러 읽는다. */ export const twinLog = (...args: unknown[]): void => console.log(`${stamp()} info: ${line(args)}`) export const twinWarn = (...args: unknown[]): void => console.warn(`${stamp()} warn: ${line(args)}`) export const twinError = (...args: unknown[]): void => console.error(`${stamp()} error: ${line(args)}`)