From 6168f7b71cc005e015b17f353a03c4f28709c818 Mon Sep 17 00:00:00 2001 From: Henry Jang Date: Thu, 2 Jul 2026 12:50:55 +0900 Subject: [PATCH] =?UTF-8?q?fix(db):=20Workers=EC=97=90=EC=84=9C=20postgres?= =?UTF-8?q?/mysql=20=EC=97=B0=EA=B2=B0=EC=9D=84=20=EC=9A=94=EC=B2=AD?= =?UTF-8?q?=EB=8B=B9=20=EC=83=9D=EC=84=B1=C2=B7=ED=95=B4=EC=A0=9C=20?= =?UTF-8?q?=E2=80=94=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20hang=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 프로덕션(idp.hyochan.site)에서 POST /login 이 Worker hang → 500 으로 취소되는 문제 수정. D1→PostgreSQL(Hyperdrive) 전환 후 발생. 근본 원인: getDb 가 postgres/mysql 연결을 모듈 전역 싱글턴으로 캐시하고 닫지 않았다. Cloudflare Workers 는 I/O 객체(TCP 소켓)를 요청 간 공유할 수 없으므로, 첫 요청이 만든 소켓을 다음 invocation 이 재사용하다 죽은 소켓 I/O 에서 영원히 hang 한다. D1(요청별 바인딩)에선 드러나지 않던 결함. - getDb 가 { db, dispose? }(DbHandle) 반환. platform.ctx.waitUntil 유무로 Workers 판별 — Workers 는 요청당 연결 생성 + dispose, Node(adapter-node)는 기존 전역 재사용 유지. mysql 은 Workers 에서 disableEval:true 추가. sqlite(libSQL HTTP)/d1 은 소켓 장기점유가 없어 그대로. - hooks.server.ts: 응답 후 ctx.waitUntil(dispose()) 로 요청당 연결 정리 (응답 지연 없음). 아울러 DB 초기화 게이트가 platform.env.DB(D1 전용)로 하드코딩돼 postgres 배포에서 초기화가 스킵될 수 있던 것을 방언 인식으로 교체. Cloudflare Hyperdrive + postgres-js 권장 패턴(요청당 연결, max:5, ctx.waitUntil(sql.end()))과 일치. Co-Authored-By: Claude Opus 4.8 --- src/hooks.server.ts | 30 +++++++++++++++++--- src/lib/server/db/index.ts | 58 ++++++++++++++++++++++++++++++++------ 2 files changed, 75 insertions(+), 13 deletions(-) diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 8e72dc9..d50d86f 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -3,7 +3,7 @@ import { ensureAuthBaseline } from "$lib/server/auth/bootstrap"; import { SESSION_COOKIE_NAME, SESSION_TOUCH_INTERVAL_MS } from "$lib/server/auth/constants"; import { getRuntimeConfig } from "$lib/server/auth/runtime"; import { clearSessionCookie, getSessionContext, touchSession } from "$lib/server/auth/session"; -import { getDb } from "$lib/server/db"; +import { getDb, DB_DIALECT } from "$lib/server/db"; // CSRF: state-changing 요청에 대해 same-origin을 강제할 라우트 // ctrls H-AUTH-1: /oidc/end-session 추가 — POST 가 cookie 기반 세션을 폐기하므로 @@ -77,9 +77,20 @@ export const handle: Handle = async ({ event, resolve }) => { // baseline 쿼리가 불필요한 경로 (정적 메타데이터, 헬스체크) const skipBaseline = path.startsWith("/.well-known/") || path === "/api/health" || path === "/favicon.ico" || path === "/robots.txt"; + // postgres/mysql(Workers) 경로에서 요청당 연 DB 연결을 응답 후 닫기 위한 정리 함수. + // D1/sqlite/Node 전역 재사용 경로에서는 undefined. + let disposeDb: (() => Promise) | undefined; + try { - if (event.platform?.env?.DB) { - const db = await getDb(event.platform); + // DB 초기화 게이트는 방언별로 다르다: + // - d1: platform.env.DB(D1 바인딩)가 반드시 있어야 한다(Workers 전용). + // - postgres/mysql/sqlite: 연결 정보를 platform.env(HYPERDRIVE/DATABASE_URL) 또는 + // process.env 에서 getDb 가 해석한다. 없으면 getDb 가 throw → catch 에서 runtimeError. + const shouldInitDb = DB_DIALECT === "d1" ? Boolean(event.platform?.env?.DB) : true; + if (shouldInitDb) { + const handle = await getDb(event.platform); + const db = handle.db; + disposeDb = handle.dispose; event.locals.db = db; event.locals.tenant = skipBaseline ? null : await ensureAuthBaseline(db, event.platform); @@ -107,7 +118,18 @@ export const handle: Handle = async ({ event, resolve }) => { console.error(event.locals.runtimeError, error); } - const response = await resolve(event); + let response: Response; + try { + response = await resolve(event); + } finally { + // 요청당 연 postgres/mysql 연결 정리. Workers 는 waitUntil 로 응답을 막지 않고 + // 백그라운드에서 닫는다. Node 경로엔 dispose 가 없으므로 no-op. + if (disposeDb) { + const wait = event.platform?.ctx?.waitUntil?.bind(event.platform.ctx); + const closing = disposeDb().catch((e) => console.error("DB dispose 실패", e)); + if (wait) wait(closing); + } + } // ── 보안 헤더 ────────────────────────────────────────────────────────────── // Clickjacking 방지 diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts index 368d2a8..ef4e6c7 100644 --- a/src/lib/server/db/index.ts +++ b/src/lib/server/db/index.ts @@ -19,12 +19,26 @@ export type { DB }; // dead-code-elimination 하여, 번들에는 활성 방언의 드라이버만 포함된다. declare const __DB_DIALECT__: "d1" | "sqlite" | "postgres" | "mysql" | undefined; -// 드라이버 클라이언트는 isolate/프로세스 전역에서 재사용해 연결 재사용을 극대화한다 -// (요청마다 새 연결을 열지 않는다). +// 순수 Node(adapter-node) 처럼 프로세스가 장수하는 환경에서는 드라이버 클라이언트를 +// 전역에서 재사용해 연결 생성 비용을 아낀다. 반대로 Cloudflare Workers 는 요청마다 +// 격리(isolate)되며 I/O 객체(소켓)를 요청 간 공유할 수 없으므로 절대 전역 재사용하면 +// 안 된다 — 이전 요청의 죽은 소켓을 다음 요청이 붙잡고 hang 한다. Workers 여부는 +// `platform.ctx.waitUntil` 존재로 판별하고, Workers 에서는 요청당 새 연결을 열고 +// 응답 후 dispose() 로 닫는다. let pgSql: unknown; let mysqlPool: unknown; let libsqlClient: unknown; +/** + * getDb() 결과. `dispose` 는 Workers 에서 요청당 연 연결을 응답 완료 후 닫기 위한 + * 정리 함수다(hooks 가 `ctx.waitUntil(dispose())` 로 호출). Node 전역 재사용 경로나 + * D1 처럼 닫을 필요가 없으면 undefined. + */ +export interface DbHandle { + db: DB; + dispose?: () => Promise; +} + /** * libSQL(sqlite) 연결 정보를 해석한다. * url: DATABASE_URL 또는 SQLITE_URL. `file:`/`libsql:`/`http(s):` 스킴이 없으면 @@ -77,31 +91,57 @@ function resolveConnectionString(platform: App.Platform | undefined, dialect: st * - sqlite: libSQL 로컬 파일(file:) 또는 Turso — DATABASE_URL/SQLITE_URL. * - postgres: Hyperdrive / DATABASE_URL → postgres-js. * - mysql: Hyperdrive / DATABASE_URL → mysql2. + * + * 반환값의 `dispose` 는 Workers 에서 요청당 연 postgres/mysql 연결을 응답 완료 후 + * 닫기 위한 함수다(호출부가 `ctx.waitUntil(dispose())`). Node 전역 재사용 경로와 + * D1/sqlite 는 닫지 않으므로 undefined. */ -export async function getDb(platform: App.Platform | undefined): Promise { +export async function getDb(platform: App.Platform | undefined): Promise { + // Cloudflare Workers 판별: isolate 는 요청 간 I/O 객체(소켓)를 공유할 수 없으므로 + // postgres/mysql 연결을 전역 재사용하면 hang 한다. Workers 에서는 요청당 연결을 + // 새로 열고 dispose 로 닫는다. Node(adapter-node)는 platform.ctx 가 없어 전역 재사용. + const isWorkers = typeof platform?.ctx?.waitUntil === "function"; + if (__DB_DIALECT__ === "postgres") { const connectionString = resolveConnectionString(platform, "postgres"); const { drizzle } = await import("drizzle-orm/postgres-js"); const postgres = (await import("postgres")).default; + // Hyperdrive 뒤에서는 fetch_types 조회가 불필요/불가하므로 비활성화. + // max: Workers 는 invocation 당 최대 6 연결 — Hyperdrive 권장값 5. + if (isWorkers) { + const client = postgres(connectionString, { max: 5, fetch_types: false }); + return { + db: drizzle(client, { schema: schema as never }) as unknown as DB, + dispose: () => client.end({ timeout: 5 }), + }; + } if (!pgSql) { - // Hyperdrive 뒤에서는 fetch_types 조회가 불필요/불가하므로 비활성화. pgSql = postgres(connectionString, { max: 5, fetch_types: false }); } - return drizzle(pgSql as never, { schema: schema as never }) as unknown as DB; + return { db: drizzle(pgSql as never, { schema: schema as never }) as unknown as DB }; } if (__DB_DIALECT__ === "mysql") { const connectionString = resolveConnectionString(platform, "mysql"); const { drizzle } = await import("drizzle-orm/mysql2"); const mysql = (await import("mysql2/promise")).default; + if (isWorkers) { + // disableEval: Workers 런타임에서 mysql2 의 eval 기반 코드 생성이 금지됨. + const pool = mysql.createPool({ uri: connectionString, connectionLimit: 5, disableEval: true }); + return { + db: drizzle(pool, { schema: schema as never, mode: "default" }) as unknown as DB, + dispose: () => pool.end(), + }; + } if (!mysqlPool) { mysqlPool = mysql.createPool({ uri: connectionString, connectionLimit: 5 }); } - return drizzle(mysqlPool as never, { schema: schema as never, mode: "default" }) as unknown as DB; + return { db: drizzle(mysqlPool as never, { schema: schema as never, mode: "default" }) as unknown as DB }; } if (__DB_DIALECT__ === "sqlite") { - // libSQL — 로컬 파일(file:) 또는 Turso 원격 + // libSQL — 로컬 파일(file:) 또는 Turso 원격. HTTP 기반이라 소켓을 장기 점유하지 + // 않으므로 전역 재사용해도 안전하다. const { url, authToken } = resolveLibsqlConfig(platform); const { drizzle } = await import("drizzle-orm/libsql"); const { createClient } = await import("@libsql/client"); @@ -111,7 +151,7 @@ export async function getDb(platform: App.Platform | undefined): Promise { const db = drizzle(libsqlClient as never, { schema: schema as never }); // SQLite 은 연결마다 FK 제약이 비활성화됨 — 명시적으로 활성화 await db.run(sql`PRAGMA foreign_keys = ON`); - return db as unknown as DB; + return { db: db as unknown as DB }; } if (__DB_DIALECT__ === "d1" || typeof __DB_DIALECT__ === "undefined") { @@ -125,7 +165,7 @@ export async function getDb(platform: App.Platform | undefined): Promise { const db = drizzle(platform.env.DB, { schema: schema as never }); // D1(SQLite)은 연결마다 FK 제약이 비활성화됨 — 매 요청에 명시적으로 활성화 await db.run(sql`PRAGMA foreign_keys = ON`); - return db as unknown as DB; + return { db: db as unknown as DB }; } throw new Error(`Unknown DB_DIALECT: ${String(__DB_DIALECT__)}`);