diff --git a/.env.example b/.env.example index 446986d..8bc25a4 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,9 @@ AUTH_SECRET="replace-with-local-secret" NEXTAUTH_SECRET="replace-with-local-secret" AUTH_URL="http://localhost:3000" NEXTAUTH_URL="http://localhost:3000" +# Logging: APP_ENV=local|stage|production / LOG_LEVEL=debug|info|warn|error +APP_ENV=local +LOG_LEVEL=debug # Production example (.env.production) # DATABASE_URL="mysql://USER:PASSWORD@AWS_EC2_HOST:3306/chocomae" @@ -11,6 +14,8 @@ NEXTAUTH_URL="http://localhost:3000" # NEXTAUTH_SECRET="replace-with-production-secret" # AUTH_URL="https://NAS_HOST_OR_DOMAIN" # NEXTAUTH_URL="https://NAS_HOST_OR_DOMAIN" +# APP_ENV=production +# LOG_LEVEL=info # Stage example (.env.stage) # DATABASE_URL="mysql://USER:PASSWORD@STAGE_DB_HOST:3306/chocomae" @@ -18,3 +23,5 @@ NEXTAUTH_URL="http://localhost:3000" # NEXTAUTH_SECRET="replace-with-stage-secret" # AUTH_URL="https://STAGE_HOST_OR_DOMAIN" # NEXTAUTH_URL="https://STAGE_HOST_OR_DOMAIN" +# APP_ENV=stage +# LOG_LEVEL=debug diff --git a/AGENTS.md b/AGENTS.md index 35a99f5..a7b590d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,13 +32,17 @@ Admin panel for the chocomae service (mouse-typing), extracted into a standalone |---|---| | `auth.ts` | NextAuth config (Credentials Provider, MariaDB PASSWORD verify) | | `proxy.ts` | Middleware — redirects unauthenticated users to /login | -| `lib/db.ts` | Prisma Client singleton | +| `lib/db.ts` | Prisma Client singleton with SQL pretty-logging and result table logging | +| `lib/logger.ts` | Structured logger — `debug/info/warn/error`, level controlled by `LOG_LEVEL` / `APP_ENV` | +| `lib/errors.ts` | `ApiError` — unified error class with HTTP status code | +| `lib/api-handler.ts` | `withApiHandler` — wraps route handlers with auth, timing, and error logging | | `lib/constants.ts` | Status code constants (`ACTIVATE_STATUS`, `ACCOUNT_TYPE`, `REQUEST_STATUS`) | | `lib/maestros.ts` | Maestro DB queries | | `lib/extension-requests.ts` | Extension request queries and approval logic | | `lib/upgrade-requests.ts` | Upgrade request queries and approval logic | | `prisma/schema.prisma` | Generated via `prisma db pull` — do not hand-edit model fields | | `docs/business-rules.md` | Full reference for existing chocomae business logic | +| `docs/phase/phase9.md` | Log policy: levels, format, masking rules, Docker log operations | ## maestro_log conventions @@ -57,11 +61,11 @@ Always record a log entry after a successful DB change. Use these `Type` values: ## Implemented phases (do not re-implement) -- Phase 0–8 are complete: login, maestro list/detail, extension requests, upgrade requests, Docker setup, transaction safety, auth guards. +- Phase 0–8: login, maestro list/detail, extension requests, upgrade requests, Docker setup, transaction safety, auth guards. +- Phase 9 (substantially complete): structured logging (`lib/logger.ts`), `withApiHandler` wrapper (`lib/api-handler.ts`), `ApiError` class (`lib/errors.ts`), Prisma SQL pretty-logging and result table logging (`lib/db.ts`). Remaining: correlation IDs, stage/production log verification. ## Planned phases (not yet implemented) -- **Phase 9** — Structured logging (`lib/logger.ts`, correlation IDs, Prisma query logging) - **Phase 10** — Maestro info edit form + student list with pagination (`PATCH /api/maestros/[id]`, `GET /api/maestros/[id]/students`) - **Phase 11** — Email auto-send via Nodemailer after extension/upgrade approval (`lib/mail.ts`) diff --git a/README.md b/README.md index 1b45307..dfc9df7 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ chocomae(mouse-typing) 서비스의 관리자 기능을 독립 Next.js 서비스 - 연장 신청 목록 — 승인 / 취소 (트랜잭션 + `maestro_log`) - 업그레이드 신청 목록 — 승인 / 거절 (트랜잭션 + `maestro_log`) - 비인증 접근 차단 (`proxy.ts`, API route `auth()` 검사) +- 구조화 로깅 — API 처리 시간·결과, DB 쿼리 pretty 출력, slow query 감지, 비밀번호 마스킹 ## 개발 환경 설정 @@ -73,8 +74,9 @@ pnpm dev | `NEXTAUTH_SECRET` | ✅ | NextAuth 호환용 (동일 값) | | `AUTH_URL` | ✅ | 서비스 기본 URL | | `NEXTAUTH_URL` | ✅ | NextAuth 호환용 (동일 값) | -| `APP_ENV` | — | `local` \| `stage` \| `production` (Phase 9) | -| `LOG_LEVEL` | — | `debug` \| `info` \| `warn` \| `error` (Phase 9) | +| `APP_ENV` | — | `local` \| `stage` \| `production` — 미설정 시 `debug` 레벨 기본값 | +| `LOG_LEVEL` | — | `debug` \| `info` \| `warn` \| `error` — production 권장: `info` | +| `DB_SLOW_QUERY_MS` | — | slow query 임계값(ms), 기본 `1000` — 초과 시 `warn` 로그 | | `MAIL_FROM_NAME` | — | 발송자 이름 (Phase 11) | | `MAIL_FROM_ADDRESS` | — | 발송자 주소 (Phase 11) | | `MAIL_BCC_ADDRESS` | — | BCC 주소 (Phase 11) | @@ -110,7 +112,10 @@ chocoadmin/ │ ├── forms/ │ └── ui/ # shadcn/ui 컴포넌트 ├── lib/ -│ ├── db.ts # Prisma Client 싱글톤 +│ ├── db.ts # Prisma Client 싱글톤 (SQL/결과 로깅 포함) +│ ├── logger.ts # 공통 로거 (debug/info/warn/error) +│ ├── errors.ts # ApiError 공통 에러 클래스 +│ ├── api-handler.ts # API route 래퍼 (auth, 타이밍, 에러 처리) │ ├── constants.ts # 상태값 상수 │ ├── maestros.ts # 마에스트로 DB 쿼리 │ ├── extension-requests.ts @@ -131,3 +136,4 @@ chocoadmin/ - [`docs/plan/project-plan.md`](docs/plan/project-plan.md) — 전체 개발 단계 계획 - [`docs/business-rules.md`](docs/business-rules.md) — 기존 chocomae 비즈니스 규칙 - [`docs/deployment.md`](docs/deployment.md) — NAS 배포 절차 +- [`docs/phase/phase9.md`](docs/phase/phase9.md) — 로그 정책 및 Docker 로그 운영 절차 diff --git a/app/api/extension-requests/[id]/route.ts b/app/api/extension-requests/[id]/route.ts index fa232f6..8283b66 100644 --- a/app/api/extension-requests/[id]/route.ts +++ b/app/api/extension-requests/[id]/route.ts @@ -1,67 +1,51 @@ import { NextResponse } from "next/server"; import { z } from "zod"; -import { auth } from "@/auth"; import { approveExtensionRequest, cancelExtensionRequest, - ExtensionRequestActionError, } from "@/lib/extension-requests"; +import { ApiError } from "@/lib/errors"; +import { withApiHandler } from "@/lib/api-handler"; +import { logger } from "@/lib/logger"; + +const CTX = "extension-requests/[id]"; const patchBodySchema = z.object({ action: z.enum(["approve", "cancel"]), }); -type ExtensionRequestRouteContext = { - params: Promise<{ - id: string; - }>; -}; +export const PATCH = withApiHandler<{ id: string }>( + CTX, + async (request, { params, t0 }) => { + const { id } = await params; + const maestroExtensionID = Number(id); -export async function PATCH( - request: Request, - { params }: ExtensionRequestRouteContext -) { - const session = await auth(); + if (!Number.isInteger(maestroExtensionID) || maestroExtensionID <= 0) { + throw new ApiError("Invalid extension request id", 400); + } - if (!session) { - return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); - } - - const { id } = await params; - const maestroExtensionID = Number(id); - - if (!Number.isInteger(maestroExtensionID) || maestroExtensionID <= 0) { - return NextResponse.json( - { message: "Invalid extension request id" }, - { status: 400 } + const body = patchBodySchema.safeParse( + await request.json().catch(() => ({})) ); - } + if (!body.success) { + throw new ApiError("Invalid action", 400); + } - const body = patchBodySchema.safeParse( - await request.json().catch(() => ({})) - ); - - if (!body.success) { - return NextResponse.json({ message: "Invalid action" }, { status: 400 }); - } - - try { if (body.data.action === "approve") { const result = await approveExtensionRequest(maestroExtensionID); + logger.info(CTX, "approved", { + id: maestroExtensionID, + duration: Date.now() - t0, + }); return NextResponse.json({ ok: true, ...result }); } await cancelExtensionRequest(maestroExtensionID); + logger.info(CTX, "cancelled", { + id: maestroExtensionID, + duration: Date.now() - t0, + }); return NextResponse.json({ ok: true }); - } catch (error) { - if (error instanceof ExtensionRequestActionError) { - return NextResponse.json( - { message: error.message }, - { status: error.statusCode } - ); - } - - throw error; } -} +); diff --git a/app/api/extension-requests/route.ts b/app/api/extension-requests/route.ts index 302e77f..2d94ce5 100644 --- a/app/api/extension-requests/route.ts +++ b/app/api/extension-requests/route.ts @@ -1,17 +1,10 @@ import { NextResponse } from "next/server"; -import { auth } from "@/auth"; import { getExtensionRequests } from "@/lib/extension-requests"; +import { withApiHandler } from "@/lib/api-handler"; -export async function GET(request: Request) { - const session = await auth(); - - if (!session) { - return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); - } - +export const GET = withApiHandler("extension-requests", async (request) => { const url = new URL(request.url); const result = await getExtensionRequests(url.searchParams); - return NextResponse.json(result); -} +}); diff --git a/app/api/maestros/[id]/route.ts b/app/api/maestros/[id]/route.ts index 9d3cf1a..ff2250a 100644 --- a/app/api/maestros/[id]/route.ts +++ b/app/api/maestros/[id]/route.ts @@ -1,39 +1,25 @@ import { NextResponse } from "next/server"; -import { auth } from "@/auth"; import { getMaestroDetail } from "@/lib/maestros"; +import { ApiError } from "@/lib/errors"; +import { withApiHandler } from "@/lib/api-handler"; -type MaestroDetailRouteContext = { - params: Promise<{ - id: string; - }>; -}; +export const GET = withApiHandler<{ id: string }>( + "maestros/[id]", + async (_request, { params }) => { + const { id } = await params; + const maestroID = Number(id); -export async function GET( - _request: Request, - { params }: MaestroDetailRouteContext -) { - const session = await auth(); + if (!Number.isInteger(maestroID) || maestroID <= 0) { + throw new ApiError("Invalid maestro id", 400); + } - if (!session) { - return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); + const result = await getMaestroDetail(maestroID); + + if (!result) { + throw new ApiError("Not found", 404); + } + + return NextResponse.json(result); } - - const { id } = await params; - const maestroID = Number(id); - - if (!Number.isInteger(maestroID) || maestroID <= 0) { - return NextResponse.json( - { message: "Invalid maestro id" }, - { status: 400 } - ); - } - - const result = await getMaestroDetail(maestroID); - - if (!result) { - return NextResponse.json({ message: "Not found" }, { status: 404 }); - } - - return NextResponse.json(result); -} +); diff --git a/app/api/maestros/route.ts b/app/api/maestros/route.ts index 3e40dc9..5070fc6 100644 --- a/app/api/maestros/route.ts +++ b/app/api/maestros/route.ts @@ -1,17 +1,10 @@ import { NextResponse } from "next/server"; -import { auth } from "@/auth"; import { getMaestros } from "@/lib/maestros"; +import { withApiHandler } from "@/lib/api-handler"; -export async function GET(request: Request) { - const session = await auth(); - - if (!session) { - return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); - } - +export const GET = withApiHandler("maestros", async (request) => { const url = new URL(request.url); const result = await getMaestros(url.searchParams); - return NextResponse.json(result); -} +}); diff --git a/app/api/upgrade-requests/[id]/route.ts b/app/api/upgrade-requests/[id]/route.ts index 71e0164..75a66f1 100644 --- a/app/api/upgrade-requests/[id]/route.ts +++ b/app/api/upgrade-requests/[id]/route.ts @@ -1,67 +1,51 @@ import { NextResponse } from "next/server"; import { z } from "zod"; -import { auth } from "@/auth"; import { approveUpgradeRequest, rejectUpgradeRequest, - UpgradeRequestActionError, } from "@/lib/upgrade-requests"; +import { ApiError } from "@/lib/errors"; +import { withApiHandler } from "@/lib/api-handler"; +import { logger } from "@/lib/logger"; + +const CTX = "upgrade-requests/[id]"; const patchBodySchema = z.object({ action: z.enum(["approve", "reject"]), }); -type UpgradeRequestRouteContext = { - params: Promise<{ - id: string; - }>; -}; +export const PATCH = withApiHandler<{ id: string }>( + CTX, + async (request, { params, t0 }) => { + const { id } = await params; + const maestroUpgradeID = Number(id); -export async function PATCH( - request: Request, - { params }: UpgradeRequestRouteContext -) { - const session = await auth(); + if (!Number.isInteger(maestroUpgradeID) || maestroUpgradeID <= 0) { + throw new ApiError("Invalid upgrade request id", 400); + } - if (!session) { - return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); - } - - const { id } = await params; - const maestroUpgradeID = Number(id); - - if (!Number.isInteger(maestroUpgradeID) || maestroUpgradeID <= 0) { - return NextResponse.json( - { message: "Invalid upgrade request id" }, - { status: 400 } + const body = patchBodySchema.safeParse( + await request.json().catch(() => ({})) ); - } + if (!body.success) { + throw new ApiError("Invalid action", 400); + } - const body = patchBodySchema.safeParse( - await request.json().catch(() => ({})) - ); - - if (!body.success) { - return NextResponse.json({ message: "Invalid action" }, { status: 400 }); - } - - try { if (body.data.action === "approve") { const result = await approveUpgradeRequest(maestroUpgradeID); + logger.info(CTX, "approved", { + id: maestroUpgradeID, + duration: Date.now() - t0, + }); return NextResponse.json({ ok: true, ...result }); } await rejectUpgradeRequest(maestroUpgradeID); + logger.info(CTX, "rejected", { + id: maestroUpgradeID, + duration: Date.now() - t0, + }); return NextResponse.json({ ok: true }); - } catch (error) { - if (error instanceof UpgradeRequestActionError) { - return NextResponse.json( - { message: error.message }, - { status: error.statusCode } - ); - } - - throw error; } -} +); diff --git a/app/api/upgrade-requests/route.ts b/app/api/upgrade-requests/route.ts index 3861d8c..63a7c24 100644 --- a/app/api/upgrade-requests/route.ts +++ b/app/api/upgrade-requests/route.ts @@ -1,17 +1,10 @@ import { NextResponse } from "next/server"; -import { auth } from "@/auth"; import { getUpgradeRequests } from "@/lib/upgrade-requests"; +import { withApiHandler } from "@/lib/api-handler"; -export async function GET(request: Request) { - const session = await auth(); - - if (!session) { - return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); - } - +export const GET = withApiHandler("upgrade-requests", async (request) => { const url = new URL(request.url); const result = await getUpgradeRequests(url.searchParams); - return NextResponse.json(result); -} +}); diff --git a/auth.ts b/auth.ts index b6f27cd..66a6cb6 100644 --- a/auth.ts +++ b/auth.ts @@ -3,6 +3,9 @@ import Credentials from "next-auth/providers/credentials"; import { z } from "zod"; import { ACTIVATE_STATUSES } from "@/lib/constants"; +import { logger } from "@/lib/logger"; + +const AUTH_CTX = "auth"; const credentialsSchema = z.object({ adminName: z.string().trim().min(1), @@ -78,9 +81,11 @@ export const { const activateStatus = Number(admin?.ActivateStatus); if (!admin || activateStatus === ACTIVATE_STATUSES.PENDING) { + logger.warn(AUTH_CTX, "login failed", { adminName }); return null; } + logger.info(AUTH_CTX, "login success", { adminName, adminID }); return { id: String(adminID), name: admin.Name, diff --git a/docs/phase/phase9.md b/docs/phase/phase9.md new file mode 100644 index 0000000..639100a --- /dev/null +++ b/docs/phase/phase9.md @@ -0,0 +1,144 @@ +# Phase 9. 실행 로그 및 DB 쿼리 로그 + +## 로그 정책 + +### 환경 구분 + +| `APP_ENV` | 설명 | +|---|---| +| `local` | 맥미니 개발 환경 | +| `stage` | NAS에서 stage DB로 구동하는 검증 환경 | +| `production` | NAS에서 운영 DB로 구동하는 실제 서비스 환경 | + +`NODE_ENV`는 Next.js 빌드 방식(dev/prod)을 제어하고, `APP_ENV`는 로그 레벨·상세도를 제어한다. + +### 로그 레벨 + +| `LOG_LEVEL` | 출력 대상 | +|---|---| +| `debug` | debug + info + warn + error | +| `info` | info + warn + error | +| `warn` | warn + error | +| `error` | error만 | + +`LOG_LEVEL`을 명시하지 않으면 `APP_ENV=production`일 때 `info`, 그 외는 `debug`가 기본값. + +### 출력 형식 + +``` +[2026-06-22T10:00:00.000Z] [INFO ] [maestros] list fetched {"totalCount":120,"duration":45} +[2026-06-22T10:00:00.000Z] [WARN ] [extension-requests/[id]] 이미 처리된 연장 신청입니다. {"id":5,"status":409,"duration":12} +[2026-06-22T10:00:00.000Z] [ERROR] [upgrade-requests/[id]] unexpected error {"id":3,"error":"...","duration":5} +``` + +- `warn` / `error`는 stderr, 그 외는 stdout으로 출력한다. +- JSON 데이터는 한 줄로 직렬화한다 (Docker 로그 파싱 편의). + +### 민감정보 마스킹 대상 + +아래 값은 로그에 포함하지 않는다: + +- 관리자 패스워드 (form 입력값, DB 조회 파라미터) +- `AUTH_SECRET` / `NEXTAUTH_SECRET` +- `DATABASE_URL` (패스워드 포함) +- 세션 토큰 / JWT 페이로드 원문 +- SMTP 패스워드 + +현재 `auth.ts`의 raw SQL 파라미터에 패스워드가 포함될 수 있으므로 DB query 이벤트 핸들러 구현 시 `PASSWORD(` 포함 쿼리의 params를 마스킹한다 (Phase 9 DB query 로그 구현 시 처리). + +--- + +## 환경변수 + +| 변수 | 설명 | 기본값 | +|---|---|---| +| `APP_ENV` | 환경 구분 (`local`\|`stage`\|`production`) | `local` | +| `LOG_LEVEL` | 최소 로그 레벨 (`debug`\|`info`\|`warn`\|`error`) | `APP_ENV`에 따라 자동 | +| `DB_SLOW_QUERY_MS` | DB 쿼리 slow query 임계값(ms) — DB query 로그 구현 시 사용 | `1000` | + +### 권장 `.env` 설정 + +**.env.local (개발)** +```env +APP_ENV=local +LOG_LEVEL=debug +``` + +**.env.stage (검증)** +```env +APP_ENV=stage +LOG_LEVEL=debug +``` + +**.env.production (운영)** +```env +APP_ENV=production +LOG_LEVEL=info +DB_SLOW_QUERY_MS=500 +``` + +--- + +## 적용 현황 + +### 완료 + +- `lib/logger.ts` — 공통 logger 모듈 (레벨 필터링, 포맷, stderr/stdout 분기) +- 로그인 성공/실패 로그 (`auth.ts`) +- 마에스트로 목록/상세 조회 로그 +- 연장 신청 조회/승인/취소 로그 (409는 warn, 그 외 예외는 error) +- 업그레이드 신청 조회/승인/거절 로그 (409는 warn, 그 외 예외는 error) + +### 미완료 (별도 확인 후 진행) + +- **Correlation ID**: Edge Middleware(`proxy.ts`)에서 `x-correlation-id` 헤더 생성 → API route에서 AsyncLocalStorage로 전달하는 구조. 구현 전 설계 확정 필요. +- **DB query 로그**: `@prisma/adapter-mariadb` + Prisma query 이벤트 호환성 확인 필요. `$on('query', handler)` 발화 여부를 local에서 테스트한 뒤 진행. +- **결과 row/count 로그**: Prisma query 이벤트에 결과 데이터가 없음. `$extends` query 컴포넌트 방식으로 재설계하거나 계획 범위 축소 필요. + +--- + +## Docker / NAS 로그 운영 절차 + +### 애플리케이션 로그 확인 + +```bash +# 실시간 스트리밍 +docker compose logs -f chocoadmin + +# 최근 100줄 +docker compose logs --tail=100 chocoadmin + +# 특정 시간 이후 +docker compose logs --since="2026-06-22T10:00:00" chocoadmin +``` + +### warn/error만 필터링 + +```bash +docker compose logs -f chocoadmin 2>&1 | grep -E '\[(WARN |ERROR)\]' +``` + +### Nginx Proxy Manager 로그와 함께 확인 + +Synology NAS에서 Nginx Proxy Manager(NPM)와 chocoadmin 로그를 같이 보려면: + +```bash +# NPM access log (컨테이너 이름은 환경에 맞게 조정) +docker logs nginx-proxy-manager 2>&1 | tail -50 + +# 두 로그를 동시에 +docker compose logs -f chocoadmin & +docker logs -f nginx-proxy-manager +``` + +NPM의 access log에는 클라이언트 IP, HTTP method, path, status code, 응답 시간이 기록되고, chocoadmin 로그에는 인증 결과와 비즈니스 처리 결과가 기록된다. `status=409`를 NPM 로그에서 발견했을 때 chocoadmin의 `[WARN]` 로그와 함께 원인을 추적한다. + +### stage/production 로그 레벨 차이 + +| 항목 | stage | production | +|---|---|---| +| `LOG_LEVEL` | `debug` | `info` | +| 요청 시작 로그 | 출력 (debug) | 미출력 | +| 성공 응답 로그 | 출력 (info) | 출력 (info) | +| 인증 실패/409 로그 | 출력 (warn, stderr) | 출력 (warn, stderr) | +| 예외/500 로그 | 출력 (error, stderr) | 출력 (error, stderr) | diff --git a/docs/plan/project-plan.md b/docs/plan/project-plan.md index a270e2e..f41bb7e 100644 --- a/docs/plan/project-plan.md +++ b/docs/plan/project-plan.md @@ -390,43 +390,43 @@ chocoadmin/ > local/stage에서는 개발과 디버깅에 충분한 상세 로그를 남기고, production에서는 운영에 필요한 수준으로 로그 양과 민감정보 노출을 제한한다. -- [ ] 로그 정책 정의 문서 작성 (`docs/phase/phase9.md`) - - [ ] 환경 구분: `local`, `stage`, `production` - - [ ] 로그 레벨 구분: `debug`, `info`, `warn`, `error` - - [ ] 로그 출력 형식: 사람이 읽기 쉬운 콘솔 출력 + 필요 시 JSON line 확장 가능 구조 - - [ ] 민감정보 마스킹 대상 정의: 비밀번호, 인증 토큰, 쿠키, DB 접속 문자열, secret, 개인정보성 필드 -- [ ] 서버 실행 로그 유틸 구현 - - [ ] 공통 logger 모듈 추가 (`lib/logger.ts`) - - [ ] request 단위 correlation id 생성 및 로그에 포함 - - [ ] API route 시작/종료, 처리 시간, status code, action 결과 기록 - - [ ] 인증 실패, 권한 없음, 유효성 검증 실패, 비즈니스 예외를 구분해 기록 -- [ ] DB query 로그 구현 - - [ ] Prisma query event 또는 Prisma Client 설정 기반 query 로깅 적용 - - [ ] local/stage: query문, params, duration, 결과 row/count 또는 요약 결과를 사람이 보기 쉬운 format으로 출력 - - [ ] production: query문 전체/결과 전체 출력 금지, duration, table/action 추정값, affected count, slow query 중심으로 출력 - - [ ] production slow query threshold 설정 (`DB_SLOW_QUERY_MS`) - - [ ] 결과 로그 최대 길이 제한 (`DB_LOG_RESULT_LIMIT`) 및 초과 시 truncate 표시 -- [ ] 환경변수 추가 - - [ ] `APP_ENV=local|stage|production` - - [ ] `LOG_LEVEL=debug|info|warn|error` - - [ ] `DB_QUERY_LOG=off|summary|full` - - [ ] `DB_SLOW_QUERY_MS` - - [ ] `DB_LOG_RESULT_LIMIT` -- [ ] 주요 API에 로그 적용 - - [ ] 로그인 성공/실패 로그 - - [ ] 마에스트로 목록/상세 조회 로그 - - [ ] 연장 신청 조회/승인/취소 로그 - - [ ] 업그레이드 신청 조회/승인/거절 로그 - - [ ] 중복 처리 방지로 `409` 반환 시 warning 로그 -- [ ] Docker/NAS 로그 운영 절차 문서화 - - [ ] `docker compose logs -f` 확인 방법 - - [ ] stage와 production의 권장 `.env` 로그 설정 예시 - - [ ] Nginx Proxy Manager access/error log와 애플리케이션 로그를 같이 확인하는 절차 +- [x] 로그 정책 정의 문서 작성 (`docs/phase/phase9.md`) + - [x] 환경 구분: `local`, `stage`, `production` + - [x] 로그 레벨 구분: `debug`, `info`, `warn`, `error` + - [x] 로그 출력 형식: 사람이 읽기 쉬운 콘솔 출력 + 필요 시 JSON line 확장 가능 구조 + - [x] 민감정보 마스킹 대상 정의: 비밀번호, 인증 토큰, 쿠키, DB 접속 문자열, secret, 개인정보성 필드 +- [x] 서버 실행 로그 유틸 구현 + - [x] 공통 logger 모듈 추가 (`lib/logger.ts`) + - [ ] request 단위 correlation id 생성 및 로그에 포함 (보류 — Edge Middleware 제약으로 헤더 주입 방식 필요, `docs/phase/phase9.md` 참고) + - [x] API route 시작/종료, 처리 시간, status code, action 결과 기록 (`lib/api-handler.ts` `withApiHandler` 래퍼로 공통 처리) + - [x] 인증 실패, 권한 없음, 유효성 검증 실패, 비즈니스 예외를 구분해 기록 (`ApiError` + `withApiHandler`) +- [x] DB query 로그 구현 + - [x] Prisma query event 기반 query 로깅 적용 (`lib/db.ts` `$on("query", ...)`) + - [x] local/stage: query문, params, duration, 결과를 `sql-formatter`로 pretty 출력 (`$on` + `$extends.$allOperations`) + - [x] production: `LOG_LEVEL=info` 설정 시 debug 로그 전체 억제 — slow query만 warn으로 출력 + - [x] production slow query threshold 설정 (`DB_SLOW_QUERY_MS`, 기본 1000ms) + - [ ] 결과 로그 최대 길이 제한 (`DB_LOG_RESULT_LIMIT`) 및 초과 시 truncate 표시 (미구현) +- [x] 환경변수 추가 + - [x] `APP_ENV=local|stage|production` + - [x] `LOG_LEVEL=debug|info|warn|error` + - [ ] `DB_QUERY_LOG=off|summary|full` (미구현 — 현재는 `LOG_LEVEL`로 제어) + - [x] `DB_SLOW_QUERY_MS` + - [ ] `DB_LOG_RESULT_LIMIT` (미구현) +- [x] 주요 API에 로그 적용 + - [x] 로그인 성공/실패 로그 (`auth.ts`) + - [x] 마에스트로 목록/상세 조회 로그 (`lib/maestros.ts`) + - [x] 연장 신청 조회/승인/취소 로그 (`lib/extension-requests.ts`, `app/api/extension-requests/[id]/route.ts`) + - [x] 업그레이드 신청 조회/승인/거절 로그 (`lib/upgrade-requests.ts`, `app/api/upgrade-requests/[id]/route.ts`) + - [x] 중복 처리 방지로 `409` 반환 시 warning 로그 (`withApiHandler`가 `ApiError` 캐치 후 warn 출력) +- [x] Docker/NAS 로그 운영 절차 문서화 (`docs/phase/phase9.md`) + - [x] `docker compose logs -f` 확인 방법 + - [x] stage와 production의 권장 `.env` 로그 설정 예시 + - [x] Nginx Proxy Manager access/error log와 애플리케이션 로그를 같이 확인하는 절차 - [ ] 검증 - - [ ] local에서 query문과 결과 전체가 출력되는지 확인 + - [x] local에서 query문과 결과 전체가 출력되는지 확인 - [ ] stage에서 query문과 결과 전체가 출력되는지 확인 - [ ] production 설정에서 결과 전체가 출력되지 않고 summary/slow query 중심으로 출력되는지 확인 - - [ ] 로그에 비밀번호, token, cookie, secret, DB password가 노출되지 않는지 확인 + - [x] 로그에 비밀번호, token, cookie, secret, DB password가 노출되지 않는지 확인 (`onQuery`에서 `PASSWORD(` 포함 쿼리는 params 치환 생략) ### Phase 10. 마에스트로 정보 수정 및 학생 목록 고도화 diff --git a/lib/api-handler.ts b/lib/api-handler.ts new file mode 100644 index 0000000..edc0a42 --- /dev/null +++ b/lib/api-handler.ts @@ -0,0 +1,54 @@ +import { NextResponse } from "next/server"; + +import { auth } from "@/auth"; +import { ApiError } from "@/lib/errors"; +import { logger } from "@/lib/logger"; + +export type ApiHandlerContext
= { + params: Promise
; + t0: number; +}; + +export function withApiHandler
>( + ctx: string, + handler: (req: Request, ctx: ApiHandlerContext
) => Promise }
+ ): Promise = {
+ params: routeContext?.params ?? (Promise.resolve({}) as Promise ),
+ t0,
+ };
+
+ try {
+ return await handler(request, handlerCtx);
+ } catch (error) {
+ if (error instanceof ApiError) {
+ const level = error.statusCode >= 500 ? "error" : "warn";
+ logger[level](ctx, error.message, {
+ status: error.statusCode,
+ duration: Date.now() - t0,
+ });
+ return NextResponse.json(
+ { message: error.message },
+ { status: error.statusCode }
+ );
+ }
+ logger.error(ctx, "unexpected error", {
+ error: String(error),
+ duration: Date.now() - t0,
+ });
+ throw error;
+ }
+ };
+}
diff --git a/lib/db.ts b/lib/db.ts
index 4837a07..d8a72d9 100644
--- a/lib/db.ts
+++ b/lib/db.ts
@@ -1,11 +1,202 @@
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
+import { format } from "sql-formatter";
-import { PrismaClient } from "@/lib/generated/prisma/client";
+import { PrismaClient, Prisma } from "@/lib/generated/prisma/client";
+import { logger } from "@/lib/logger";
const globalForPrisma = globalThis as unknown as {
prisma?: PrismaClient;
};
+const SLOW_QUERY_MS = Number(process.env.DB_SLOW_QUERY_MS ?? "1000");
+
+// ── SQL 로깅 ($on) ────────────────────────────────────────────────
+
+function inlineParams(query: string, rawParams: string): string {
+ try {
+ const params = JSON.parse(rawParams) as unknown[];
+ let idx = 0;
+ return query.replace(/\?/g, () => {
+ const val = params[idx++];
+ if (val === null || val === undefined) return "NULL";
+ if (typeof val === "string") return `'${val.replace(/'/g, "''")}'`;
+ if (typeof val === "boolean") return val ? "1" : "0";
+ return String(val);
+ });
+ } catch {
+ return query;
+ }
+}
+
+function onQuery(e: Prisma.QueryEvent): void {
+ const isSlow = e.duration >= SLOW_QUERY_MS;
+ const level = isSlow ? "warn" : "debug";
+ if (!logger.isEnabled(level)) return;
+
+ const isSensitive = e.query.includes("PASSWORD(");
+ const sqlToFormat = isSensitive ? e.query : inlineParams(e.query, e.params);
+ const formatted = format(sqlToFormat, { language: "mysql", tabWidth: 2 });
+
+ const ts = new Date().toISOString();
+ const tag = level.toUpperCase().padEnd(5);
+ const slowLabel = isSlow ? " [SLOW]" : "";
+ const header = `[${ts}] [${tag}] [db:query] ${e.duration}ms${slowLabel}`;
+ const indented = formatted
+ .split("\n")
+ .map((l) => ` ${l}`)
+ .join("\n");
+
+ const out = `${header}\n${indented}`;
+ if (isSlow) {
+ console.error(out);
+ } else {
+ console.log(out);
+ }
+}
+
+// ── 결과 로깅 ($extends) ──────────────────────────────────────────
+
+const COL_MAX_WIDTH = 40;
+
+function formatValue(v: unknown): string {
+ if (v === null || v === undefined) return "NULL";
+ if (typeof v === "bigint") return v.toString();
+ if (v instanceof Date) return v.toISOString();
+ if (typeof v === "object") return JSON.stringify(v);
+ return String(v);
+}
+
+// 한글·한자 등 터미널에서 2칸을 차지하는 유니코드 블록 판별
+function isWide(cp: number): boolean {
+ return (
+ (cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
+ (cp >= 0x2e80 && cp <= 0x303f) || // CJK Radicals Supplement, Kangxi
+ (cp >= 0x3040 && cp <= 0x33ff) || // Hiragana, Katakana, Bopomofo, etc.
+ (cp >= 0x3400 && cp <= 0x4dbf) || // CJK Extension A
+ (cp >= 0x4e00 && cp <= 0x9fff) || // CJK Unified Ideographs
+ (cp >= 0xa960 && cp <= 0xa97f) || // Hangul Jamo Extended-A
+ (cp >= 0xac00 && cp <= 0xd7af) || // Hangul Syllables
+ (cp >= 0xd7b0 && cp <= 0xd7ff) || // Hangul Jamo Extended-B
+ (cp >= 0xf900 && cp <= 0xfaff) || // CJK Compatibility Ideographs
+ (cp >= 0xfe10 && cp <= 0xfe1f) || // Vertical Forms
+ (cp >= 0xfe30 && cp <= 0xfe6f) || // CJK Compatibility Forms
+ (cp >= 0xff01 && cp <= 0xff60) || // Fullwidth Latin
+ (cp >= 0xffe0 && cp <= 0xffe6) || // Fullwidth Currency
+ cp >= 0x20000 // CJK Extension B+
+ );
+}
+
+function displayWidth(s: string): number {
+ let w = 0;
+ for (const char of s) {
+ w += isWide(char.codePointAt(0) ?? 0) ? 2 : 1;
+ }
+ return w;
+}
+
+function truncateStr(s: string, maxWidth: number): string {
+ if (displayWidth(s) <= maxWidth) return s;
+ let w = 0;
+ let result = "";
+ for (const char of s) {
+ const cw = isWide(char.codePointAt(0) ?? 0) ? 2 : 1;
+ if (w + cw > maxWidth - 1) break; // 1칸은 "…"에 예약
+ w += cw;
+ result += char;
+ }
+ return result + "…";
+}
+
+function padEnd(s: string, width: number): string {
+ const truncated = truncateStr(s, width);
+ return truncated + " ".repeat(Math.max(0, width - displayWidth(truncated)));
+}
+
+function renderTable(headers: string[], rows: string[][]): string {
+ const colWidths = headers.map((h, i) =>
+ Math.min(
+ COL_MAX_WIDTH,
+ Math.max(displayWidth(h), ...rows.map((r) => displayWidth(r[i] ?? "")))
+ )
+ );
+
+ const rule = (l: string, m: string, r: string, s: string) =>
+ l + colWidths.map((w) => s.repeat(w + 2)).join(m) + r;
+
+ const hRow = "│ " + headers.map((h, i) => padEnd(h, colWidths[i])).join(" │ ") + " │";
+ const dRows = rows.map(
+ (r) => "│ " + r.map((c, i) => padEnd(c ?? "", colWidths[i])).join(" │ ") + " │"
+ );
+
+ return [
+ rule("┌", "┬", "┐", "─"),
+ hRow,
+ rule("├", "┼", "┤", "─"),
+ ...dRows,
+ rule("└", "┴", "┘", "─"),
+ ]
+ .map((l) => ` ${l}`)
+ .join("\n");
+}
+
+function buildResultLog(
+ model: string | undefined,
+ operation: string,
+ result: unknown
+): string {
+ const label = model ? `${model}.${operation}` : operation;
+
+ if (typeof result === "number") {
+ return `${label} → ${result}`;
+ }
+
+ if (result !== null && typeof result === "object" && "count" in result) {
+ return `${label} → count: ${(result as { count: number }).count}`;
+ }
+
+ if (Array.isArray(result)) {
+ const total = result.length;
+ if (total === 0) return `${label} → 0 rows`;
+
+ const preview = result.slice(0, 5);
+ const countLabel = `${total} row${total !== 1 ? "s" : ""}${total > 5 ? " (showing 5)" : ""}`;
+ const moreLabel = total > 5 ? `\n ... ${total - 5} more` : "";
+
+ if (typeof preview[0] === "object" && preview[0] !== null) {
+ const objRows = preview as Record