์ด ๋ฌธ์๋ Minesweeper(์ฑ์ฉ ์ดํด์ถฉ๋ ๊ด๊ณ์ ์ถ์ถ ์์คํ )๋ฅผ ํ๋ก๋์ ์์ ๊ธฐ๋ํ๊ณ ์ด์ํ๋ ์ ์ฐจ๋ฅผ ๋ค๋ฃฌ๋ค. ์์คํ ์ ๋ ๊ฐ์ ์ฅ๊ธฐ ์คํ ํ๋ก์ธ์ค๋ก ๊ตฌ์ฑ๋๋ค.
| ํ๋ก์ธ์ค | ์ญํ | ๊ธฐ๋ ๋ช ๋ น |
|---|---|---|
| web | Next.js 14 App Router ์๋ฒ (์ ๋ก๋ API + UI + ์กฐํ/๊ฒ์ ํ๋ฉด) | next start |
| worker | tsx ๋ฐฑ๊ทธ๋ผ์ด๋ ์์ปค. ํ๋ฅผ ํด๋งํ๋ฉฐ 4๋จ ํ์ดํ๋ผ์ธ(IngestโTypeโExtractโAggregate)์ ์คํ |
tsx src/worker/index.ts |
๋ ํ๋ก์ธ์ค๋ ๊ฐ์ ๋จธ์ ์์ ๋์ผํ DATABASE_URL(embedded libsql ํ์ผ)๊ณผ UPLOAD_DIR์ ๊ณต์ ํ๋ค. ๋ฐ์ดํฐ๋ฒ ์ด์ค๋ ๋จ์ผ ํ์ผ(./data/minesweeper.db)์ด๋ฏ๋ก, ๋ ํ๋ก์ธ์ค๋ ๋ฐ๋์ ๊ฐ์ ํ์ผ์์คํ
(๊ฐ์ ํธ์คํธ)์ ์์ด์ผ ํ๋ค.
๊ด๋ จ ๋ฌธ์: ์ถ์ถ๊ธฐ ์ ์ (extractors) ยท ๋ณด์(security) ยท ๊ฐ๋ฐ ํ๊ฒฝ(development) ยท ํ์ดํ๋ผ์ธ(pipeline)
package.json์ ์คํฌ๋ฆฝํธ๋ ๋ค์๊ณผ ๊ฐ๋ค(๋ฐ์ท).
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"worker": "tsx src/worker/index.ts",
"dev:all": "concurrently -n web,worker -c blue,green \"next dev\" \"tsx watch src/worker/index.ts\"",
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx src/db/migrate.ts",
"typecheck": "tsc --noEmit",
"test": "vitest run"
}ํ๋ก๋์ ์ ์ฐจ:
npm ci # lockfile ๊ธฐ์ค ์ฌํ ๊ฐ๋ฅํ ์ค์น
npm run db:migrate # ./drizzle ์ ๋ง์ด๊ทธ๋ ์ด์
์ DATABASE_URL ์ ์ ์ฉ
npm run build # next build (RSC/route ์ปดํ์ผ)๋น๋ ์์ ์ฑ ์ฃผ์.
src/db/client.ts์getDb()๋ lazy singleton์ด๋ค. ๋ชจ๋์ import ํ๋ ๊ฒ๋ง์ผ๋ก๋ DB ํ์ผ์ ์ด์ง ์์ผ๋ฉฐ, ์ฒซ ์ค์ ์ฌ์ฉ ์์ ์ ์ฐ๊ฒฐํ๋ค.// src/db/client.ts let _db: DB | null = null; export function getDb(): DB { if (!_db) _db = createDb(defaultUrl); return _db; }์ด ์ค๊ณ ๋๋ถ์
next build๊ฐ ์๋ฒ ๋ชจ๋์ ํ๊ฐํ ๋ ํ์ผ ํธ๋ค์ ์ก์ง ์๋๋ค. ๋ํnext.config.mjs๋ ๋ค์ดํฐ๋ธ/์๋ฒ ์ ์ฉ ํจํค์ง(@libsql/client,libsql,pdfjs-dist,exceljs,adm-zip)๋ฅผserverComponentsExternalPackages๋ก ๋นผ์ ํด๋ผ์ด์ธํธ/RSC ๋ฒ๋ค์ ๋๋ ค๋ค์ด๊ฐ์ง ์๊ฒ ํ๋ค. ๊ฐ์ ํ์ผ์์eslint: { ignoreDuringBuilds: true }๋ก ๋์ด, lint ์ค์ ์ด ์๋ ์ ์ ํ ํด๋ก ์์๋next build๊ฐ ๋งํ์ง ์๋๋ค(ํ์ ์์ ์ฑ์npm run typecheck๋ก ๋ณ๋ ๊ฐ์ ).
๋ ํ๋ก์ธ์ค๋ฅผ ๋์์ ๋์์ผ ํ๋ค. web๋ง ๋์ฐ๋ฉด ์ ๋ก๋๋ ํ์ ์์ด์ง๋ง ์์ํ ์ฒ๋ฆฌ๋์ง ์๋๋ค.
์ต์
A โ concurrently (๋จ์ผ ํธ์คํธ, ๋จ์ ์ด์).
์ด๋ฏธ devDependency๋ก ๋ค์ด์๋ concurrently๋ฅผ ํ๋ก๋์
๊ธฐ๋์๋ ์ธ ์ ์๋ค(dev:all์ dev ์ ์ฉ์ด๋ฏ๋ก ํ๋ก๋์
์ ์๋์ฒ๋ผ ๋ณ๋ ์กฐํฉ ์ฌ์ฉ).
npx concurrently -n web,worker -c blue,green \
"next start" \
"tsx src/worker/index.ts"์ต์
B โ pm2 (๊ถ์ฅ: ์๋ ์ฌ์์ยท๋ก๊ทธยท์์ ์ด์).
์ ์ฅ์์ ์ค์ ecosystem.config.cjs๊ฐ ํฌํจ๋ผ ์๋ค โ web(:3100) + worker, envยท๋ก๊ทธ ๊ฒฝ๋ก(data/logs/)๊น์ง ์ ์๋ผ ์๋ค.
npm run build
pm2 start ecosystem.config.cjs # minesweeper-web(:3100) + minesweeper-worker
pm2 save # ํ๋ก์ธ์ค ๋ชฉ๋ก ์ ์ฅ(์ฌ์์ ๋ณต๊ตฌ)
pm2 startup # (์ ํ) ๋ถํ
์๋๊ธฐ๋ โ ์ถ๋ ฅ๋ sudo ๋ช
๋ น ์คํ ํ pm2 save
pm2 logs minesweeper-worker| ๋ช ๋ น | ์ค๋ช |
|---|---|
pm2 status |
ํ๋ก์ธ์ค ์ํ |
pm2 restart minesweeper-web |
์ฌ์์ |
MINESWEEPER_PORT=<ํฌํธ> pm2 restart ecosystem.config.cjs --update-env |
ํฌํธ ๋ณ๊ฒฝ |
- ๊ธฐ๋ณธ ํฌํธ 3100 (
PORT/MINESWEEPER_PORT). ๋ก๊ทธ ํ์ผ์data/logs/. - env(
DATABASE_URLยทUPLOAD_DIRยทEXTRACTOR_MODEยทVLM_*)๋ecosystem.config.cjs์env๋ธ๋ก์ ์ ์๋ผ ๋ ์ฑ์ด ๋์ผํ๊ฒ ๋ณธ๋ค. - ์ถ์ถ๊ธฐ ์ ํ:
EXTRACTOR_MODE๋ฅผstub/hybrid/vlm/ensemble์ค ํ๋๋ก ๋ฐ๊พผ ๋คpm2 restart ecosystem.config.cjs --update-env. (ํ์ฌ ์ด์ฉ๊ฐ:vlmโ ์ค๋ฌธ์ ์ ์๋ธ๋ก/์ธ์ค ํ์ด์ง๋ LLM์ด๋ผ์ผ ์ถ์ถ๋จ, stub์ 0๊ฑด)
์์ปค(src/worker/index.ts)๋ ๋จ์ผ ํ๋ฅผ ๋ฌดํ ํด๋งํ๋ค. ํ ํฑ์ ์ต๋ ํ ๊ฑด์ ์์
์ ์ฒ๋ฆฌํ๋ค.
// src/worker/index.ts
export async function runWorkerTick(db: DB, extractor?: Extractor): Promise<string | null> {
const job = await claimNextJob(db);
if (!job) return null;
try {
await processApplicant(db, job.payload.applicantId, extractor);
await completeJob(db, job.id);
} catch (err) {
await failJob(db, job.id, err instanceof Error ? err.message : String(err));
}
return job.id;
}๊ธฐ๋ ์ ๋ก๊ทธ๋ก ํด๋ง ์ฃผ๊ธฐ์ ์ถ์ถ๊ธฐ ๋ชจ๋๋ฅผ ์ถ๋ ฅํ๋ค.
[worker] polling every 2000ms (extractor=stub)
ํ ๋น์๋? โโnoโโโถ claimNextJob โถ processApplicant โถ completeJob/failJob โถ (์ฆ์ ๋ค์ ํฑ)
โ
yes
โผ
sleep(WORKER_POLL_INTERVAL_MS) โถ ๋ค์ ํด๋ง
์์
์ด ์์ผ๋ฉด ๊ณง๋ฐ๋ก ๋ค์ ํฑ์ผ๋ก ์ด์ด์ง๊ณ , ๋น์์ ๋๋ง WORKER_POLL_INTERVAL_MS ๋งํผ ์ฐ๋ค. ์ฒ๋ฆฌ๋์ ๋์ด๋ ค๋ฉด ์์ปค ์ธ์คํด์ค๋ฅผ ์ํ ํ์ฅํ๋ฉด ๋๋ค(๊ฐ์ DB๋ฅผ ๋ณด๋ ์ฌ๋ฌ ์์ปค ํ๋ก์ธ์ค). claimNextJob์ด ์์
ํด๋ ์์ ๋ด๋นํ๋ฏ๋ก ๋์ผ ์์
์ ์ค๋ณต ์ฒ๋ฆฌ๋ฅผ ๋ฐฉ์งํ๋ค โ ์์ธํ ํ ์๋ฏธ๋ก ์ pipeline.md ์ฐธ์กฐ.
๊ฐ๊ณผ ๊ธฐ๋ณธ๊ฐ์ ๋ชจ๋ .env.example ๋ฐ ์ฝ๋์ fallback์์ ๊ทธ๋๋ก ๊ฐ์ ธ์จ ๊ฒ์ด๋ค.
| ๋ณ์ | ๊ธฐ๋ณธ๊ฐ | ์ฝ๋ ๊ณณ | ์๋ฏธ |
|---|---|---|---|
DATABASE_URL |
file:./data/minesweeper.db |
src/db/client.ts, src/db/migrate.ts |
embedded libsql DB ์์น. file: ์ ๋ ํ์ผ ๊ฒฝ๋ก ๋๋ :memory:(ํ
์คํธ). ๋ง์ด๊ทธ๋ ์ดํฐ๋ file: ๊ฒฝ๋ก์ ์์ ๋๋ ํฐ๋ฆฌ๋ฅผ ์๋ ์์ฑํ๋ค. |
UPLOAD_DIR |
./data/uploads |
์ ๋ก๋/์๋ณธ/ํฌ๋กญ ์ ์ฅ | ์ ๋ก๋ zip, ์ถ์ถ๋ ์๋ณธ, ํฌ๋กญ ์ด๋ฏธ์ง๊ฐ ์ ์ฅ๋๋ ๋ก์ปฌ ๋๋ ํฐ๋ฆฌ. |
EXTRACTOR_MODE |
stub |
src/worker/index.ts ๋ฑ |
Stage 3 ์ถ์ถ๊ธฐ ์ ํ. stub=๊ฒฐ์ ์ ํด๋ฆฌ์คํฑ(๊ธฐ๋ณธยทGPU ๋ถํ์ยทํ
์คํธ์ฉ), hybrid=ํ
์คํธ๋ stubยท์ด๋ฏธ์ง๋ VLM OCR, vlm=์ ์ ์จํ๋ ๋น์ /LLM(์ด์ฉ๊ฐ), ensemble=๋ค์ค ๋ชจ๋ธ ํฌํ. extractors.md ์ฐธ์กฐ. |
VLM_BASE_URL |
http://localhost:11434/v1 |
VLM ์ถ์ถ๊ธฐ | OpenAI ํธํ ์๋ํฌ์ธํธ base URL. ๊ธฐ๋ณธ๊ฐ์ ๋ก์ปฌ Ollama๋ฅผ ๊ฐ๋ฆฌํจ๋ค. |
VLM_API_KEY |
ollama |
VLM ์ถ์ถ๊ธฐ | OpenAI ํธํ API ํค. Ollama๋ ์์ ๋ฌธ์์ด ํ์ฉ(ollama). vLLM ๋ฑ์์๋ ์ค์ ํ ํฐ ์ฌ์ฉ. |
VLM_MODEL |
qwen3.5:9B |
VLM ์ถ์ถ๊ธฐ | ์ฌ์ฉํ ๋ชจ๋ธ ์ด๋ฆ(์๋ํฌ์ธํธ์ ๋ฏธ๋ฆฌ ๋ฐ์๋ ๋ชจ๋ธ๊ณผ ์ผ์นํด์ผ ํจ). |
VLM_TIMEOUT_MS |
120000 |
VLM ์ถ์ถ๊ธฐ | VLM ํธ์ถ ํ์์์(ms). ๊ธฐ๋ณธ 120์ด. |
WORKER_POLL_INTERVAL_MS |
2000 |
src/worker/index.ts |
ํ๊ฐ ๋น์์ ๋ ํด๋ง ๊ฐ๊ฒฉ(ms). Number(process.env.WORKER_POLL_INTERVAL_MS ?? 2000). |
MAX_UPLOAD_BYTES |
209715200 (200 MB) |
์ ๋ก๋ ๊ฐ๋ | zip ์ ๋ก๋ ์ต๋ ํฌ๊ธฐ. zip-bomb/DoS ๋ฐฉ์ง. |
MAX_ZIP_ENTRIES |
5000 |
์ ๋ก๋ ๊ฐ๋ | zip ๋ด ์ต๋ ํ์ผ ์. |
MAX_ZIP_TOTAL_BYTES |
524288000 (500 MB) |
์ ๋ก๋ ๊ฐ๋ | zip ์์ถ ํด์ ํ ๋์ ์ต๋ ํฌ๊ธฐ. |
EXTRACTOR_MODE=stub์ด๋ฉดVLM_*๋ณ์๋ ๋ฌด์๋๋ค(stub์ GPUยท๋คํธ์ํฌ๊ฐ ํ์ ์์). ์ ๋ก๋/unzip ๊ฐ๋ 3์ข ์ ๋ณด์์ ๋ฐฐ๊ฒฝ์ security.md๋ฅผ ์ฐธ์กฐ.
๊ธฐ๋ณธ .env ์์์ :
cp .env.example .env
# ํ์ํ ๊ฐ๋ง ์์ (์: ํ๋ก๋์
์ ๋๊ฒฝ๋ก, EXTRACTOR_MODE=vlm ๋ฑ)EXTRACTOR_MODE=vlm์ OpenAI ํธํ ์ถ๋ก ์๋ฒ๋ฅผ ํธ์ถํ๋ค. ์ธ๋ถ ํด๋ผ์ฐ๋๋ก PII๋ฅผ ๋ณด๋ด์ง ์๋๋ก, ์จํ๋ /์ฌ๋ด๋ง์ ์๋ฒ๋ฅผ ๋๋ค. ๋ ์ค ํ๋๋ฅผ ๊ณ ๋ฅธ๋ค.
# 1) ์ค์น ํ ์๋น์ค ๊ธฐ๋ (๊ธฐ๋ณธ ํฌํธ 11434, /v1 ๊ฐ OpenAI ํธํ)
ollama serve
# 2) ๋ชจ๋ธ ๋ฐ๊ธฐ (VLM_MODEL ๊ณผ ๋์ผํ ํ๊ทธ)
ollama pull qwen3.5:9B.env:
EXTRACTOR_MODE=vlm
VLM_BASE_URL=http://localhost:11434/v1
VLM_API_KEY=ollama # Ollama ๋ ํค ๊ฒ์ฆ ์ ํจ (์์ ๋ฌธ์์ด)
VLM_MODEL=qwen3.5:9B
VLM_TIMEOUT_MS=120000# OpenAI ํธํ ์๋ฒ๋ก ๋ชจ๋ธ ์๋น (์์)
python -m vllm.entrypoints.openai.api_server \
--model <hf-org>/<vlm-model> \
--port 8000.env:
EXTRACTOR_MODE=vlm
VLM_BASE_URL=http://localhost:8000/v1
VLM_API_KEY=<your-token>
VLM_MODEL=<served-model-name> # vLLM ์ ๋ฑ๋ก๋ ๋ชจ๋ธ ์ด๋ฆ๊ณผ ์ ํํ ์ผ์น
VLM_TIMEOUT_MS=120000curl -s "$VLM_BASE_URL/models" -H "Authorization: Bearer $VLM_API_KEY"VLM_MODEL์ด ์๋ต ๋ชฉ๋ก์ ๋ณด์ด๋ฉด ์ ์์ด๋ค. ์ถ์ถ๊ธฐ ์ธํฐํ์ด์คยทํ๋กฌํํธยทํ๋๊ทธ(seal|handwriting|signature|low_confidence|ambiguous|needs_vision) ์ฒ๋ฆฌ ๋ฐฉ์์ extractors.md์์ ๋ค๋ฃฌ๋ค.
์์น ์ฌํ์ธ. ์๋์ถ์ถ์ ์ด์์ด๋ฉฐ ์ต์ข ํ๋จ์ ์ฌ๋์ด ํ๋ค. VLM์ ์ด๋ฆ์ ์ง์ด๋ด์ง ์๋๋ค โ ๋ถํ์คํ๋ฉด ํ๋๊ทธ๋ฅผ ๋จ๊ธฐ๊ณ ์ฌ๋ ๊ฒ์๋ก ๋๊ธด๋ค.
์ฌ๋ฌ OCR-VLM์ ๋ก์ปฌ vLLM์ผ๋ก ๋์ ํฌํ(์์๋ธ)ํฉ๋๋ค. ์ธ๋ถ API ์์.
# 1) ๋ชจ๋ธ ์บ์(๋์คํฌ๋ง, GPU ๋ถํ์)
bash scripts/download-ocr-models.sh # MODEL1/2/3 env ๋ก ๊ต์ฒด ๊ฐ๋ฅ
# 2) 3๊ฐ vLLM OpenAI ์๋ฒ ๊ธฐ๋ (ํฌํธ 8010/8011/8012) โ ์์ VRAM ํ์
bash scripts/serve-ocr.sh # VRAM ๋ถ์กฑ/๊ณต์ ์ ์ ์ ์๋ abort
# 3) ์์๋ธ๋ก ์ ํ
# ecosystem.config.cjs(or .env): EXTRACTOR_MODE=ensemble,
# VLM_ENSEMBLE="http://localhost:8010/v1|MODEL1,http://localhost:8011/v1|MODEL2,http://localhost:8012/v1|MODEL3"
pm2 restart minesweeper-worker --update-env- ํฉ์=์ ๋ขฐ๋, ๋ถ์ผ์น=์ฌ๋ ๊ฒํ (ํํฐ๋ง).
VLM_ENSEMBLE_MIN_VOTES๋ก ์ ๋ํ ๋๋กญ ๊ฐ๋ฅ(๊ธฐ๋ณธ 1). - ๊ถ์ฅ ๊ธฐ๋ณธ ๋ชจ๋ธ:
Qwen/Qwen2.5-VL-7B-Instruct,OpenGVLab/InternVL3-8B,zai-org/GLM-4.1V-9B-Thinkingโ vLLM ์ง์ยทํ๊ตญ์ด/๋์ฅ ์ ํ๋๋ ์ค์ ์ํ๋ก ๊ฒ์ฆ ํ ํ์ (model-evaluation.md). - VRAM: 7โ9B VLM ร3 โ 48โ60GB โ A40 2์ฅ(92GB) ์ฌ์ ์ ์ถฉ๋ถ. ํ ์ฅ์ ๋ค ๋ชป ์ฌ๋ฆฌ๋ฉด
GPU1/2/3,VLM_GPU_UTIL๋ก ์นด๋๋ณ ๋ฐฐ์น.
๋์ 3๋ชจ๋ธ ์์๋ธ์ VRAM ๋ถ๋ด์ด ์ปค์, ํ์ฌ๋ ๋จ์ผ ๊ฒฝ๋ VLM์ผ๋ก ์ด์ฉํ๋ค.
# GPU1(์ฌ์ ์นด๋)์ Qwen2.5-VL-7B ๋์ฐ๊ธฐ (๋ก์ปฌ, --api-key ์์๊ฐ)
CUDA_VISIBLE_DEVICES=1 /data/vllm/env/bin/vllm serve Qwen/Qwen2.5-VL-7B-Instruct \
--served-model-name Qwen2.5-VL-7B-Instruct --port 8010 --api-key local \
--gpu-memory-utilization 0.5 --max-model-len 16384 --limit-mm-per-prompt '{"image":4}'
# ์ฑ ํ๊ฒฝ(ecosystem): EXTRACTOR_MODE=hybrid(ํ
์คํธ=stub/์ด๋ฏธ์ง=VLM OCR) + ๊ฐ์ง on
# DETECT_MARKS=1 VLM_BASE_URL=http://localhost:8010/v1 VLM_API_KEY=local VLM_MODEL=Qwen2.5-VL-7B-Instruct
pm2 restart ecosystem.config.cjs --update-env
# ๋ผ์ด๋ธ ์ค๋ชจํฌ: npm run detect:smoke (์ด๋ฆ ์ถ์ถ + ๋์ฅ bbox ๊ฐ์ง)- ์ถ์ถ ๋ชจ๋:
EXTRACTOR_MODE=vlm(ํ์ฌ) โ ๋ชจ๋ ๋ฌธ์๋ฅผ ๋ก์ปฌ VLM์ด ์ถ์ถ. ์ค์ ๋ ผ๋ฌธ ์ ์๋ธ๋กยทํ๊ตญ์ด ์ธ์ค ํ์ด์ง๋ LLM์ด๋ผ์ผ ์ฝํ๋ค(๊ฒฐ์ ์ stub์ ์ค๋ฌธ์์์ 0๊ฑด).hybrid=ํ ์คํธ๋ stubยท์ด๋ฏธ์ง๋ VLM,stub=GPU ๋ฏธ์ฌ์ฉ/ํ ์คํธ. VLM ๋ฏธ๊ฐ๋ ์ ์ถ์ถ์ ๋ฌธ์๋ณ๋ก ์คํจ ๊ฐ๋ฑ(๊ฒํ ํ๋๊ทธ)๋๋ฉฐ job์ ์๋ฃ๋๋ค. (ยงextractors.md 3) - ๊ฐ์ง(
DETECT_MARKS=1): ๊ด๋ จ ํ์ด์ง ๋ ๋ โ VLM์ ๋์ฅ/์๋ช ์์น ์ง์ โ ํฌ๋กญ + ๊ฒํ ํ(ยงextractors.md 3c). - GPU1 ์นด๋ 1์ฅ์ด ๋น๋ฉด(์๋ systemd util ์กฐ์ ์ฐธ๊ณ ) 7B VLM์ util 0.5(~23GB)๋ก ๋๋ํ ์ฌ๋ผ๊ฐ๋ค.
์ vllm ํ๋ก์ธ์ค๋ nohup/์๋์ด๋ฉด ์ฌ๋ถํ
์ ์ฌ๋ผ์ง๋ค. ์ฝ๋ ์๋น์ค์ ๋์ผ ํจํด์ ์ ๋ ํ์ผ์
deploy/vllm-ocr-8010.service๋ก ์ ๊ณตํ๋ค. ์ค์น(์ต์ด 1ํ, sudo ํ์):
sudo cp deploy/vllm-ocr-8010.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable vllm-ocr-8010.service # ๋ถํ
์ ์๋ ์์
# ํ์ฌ ์๋ ์คํ ์ค์ด๋ฉด systemd ๊ด๋ฆฌ๋ก ์ ํ:
pkill -f 'vllm serve Qwen/Qwen2.5-VL-7B' ; sleep 3
sudo systemctl start vllm-ocr-8010.service
systemctl status vllm-ocr-8010.service --no-pager
curl -s localhost:8010/v1/models -H 'authorization: Bearer local'์ ๋์ GPU1(CUDA_VISIBLE_DEVICES=1)ยทHF_HOME=/home/mooner92/.cache/huggingface(๋ชจ๋ธ ์บ์ ์์น)๋ฅผ
์ด๋ค. ๋ฑ๋ก ์ nvidia-smi๋ก GPU1์ด ์ด ๋ชจ๋ธ์ฉ์ผ๋ก ๋ง๋์ง ํ์ธํ ๊ฒ.
โน๏ธ GPU ๋ฉ๋ชจ๋ฆฌ ํ๋ณด ํ: ๊ณต์ ์ฝ๋ vLLM์ด
--gpu-memory-utilization 0.85๋ก ์นด๋๋ฅผ ์ ์ ํ๋ฉด ์ฌ์ ๊ฐ ๊ฑฐ์ ์๋ค. ์์ ์๊ฐ ํด๋น systemd ์ ๋์ util์ ๋ฎ์ถ๊ฑฐ๋(์: 0.55) TP=1๋ก ํ ์นด๋์ ๋ชฐ๋ฉด ๋ค๋ฅธ ์นด๋๊ฐ ํต์งธ๋ก ๋น๋ค. ํ ์ฌ์ฉ์ ์๋น์ค๋ ์ข ๋ฃ/๋ณ๊ฒฝํ์ง ๋ง ๊ฒ โ ๋ณธ์ธ ์์ ์๋น์ค๋ง ์กฐ์ .
๊ธฐ๋ณธ ์ถ์ถ๊ธฐ(stub)๋ GPU๊ฐ ์ ํ ํ์ ์๋ค. CI/ํ
์คํธ(51 tests)์ ๋ก์ปฌ ๊ฐ๋ฐ์ GPU ์์ด ์ ๋ถ ๋๋ค. GPU๋ EXTRACTOR_MODE=vlm์ผ ๋๋ง ์๋ฏธ๊ฐ ์๋ค.
- OCR-VLM์ ์ํ(0.9B๊ธ)์ผ๋ก ์ถฉ๋ถ. ์ด ์์คํ
์ Stage 3์ ํ์๋
ผ๋ฌธยท๋ํ์ฐ๊ตฌยท์ ๋ยทh-index ๋ฑ ๋ฌธ์์์ ์ด๋ฆ/์ญํ ํ๋ณด๋ฅผ ๋ฝ๋ OCR+๊ตฌ์กฐํ ์์
์ด๋ผ, ๋ํ ๋ฒ์ฉ LLM์ด ์๋๋ผ ์์ OCR ํนํ VLM์ผ๋ก ์ถฉ๋ถํ ์ฒ๋ฆฌํ ์ ์๋ค.
.env.example์ ๊ธฐ๋ณธ ํ๊ทธ(qwen3.5:9B)๋ ์์์ ์ผ ๋ฟ, ์ด์ ๋ถํ์ ๋ง์ถฐ ๋ ์์/์ ํฉํ OCR-VLM์ผ๋ก ๊ต์ฒดํ๋ ๊ฒ์ ๊ถ์ฅํ๋ค. - VRAM ๊ฐ์ด๋(๋๋ต). 0.9B๊ธ ์์ํ ๋ชจ๋ธ์ ๋ณดํต ์ GB VRAM์ผ๋ก ๋จ์ผ GPU์์ ์ถฉ๋ถํ ์๋น๋๋ค. 9B๊ธ์ ๊ทธ๋๋ก ์ฐ๋ฉด ์์ํ ์ฌ๋ถ์ ๋ฐ๋ผ 8~20 GB๊ฐ ํ์ํ ์ ์๋ค.
- ๋์์ฑ. ์์ปค๋ ํ ํฑ์ 1๊ฑด์ ์ฒ๋ฆฌํ๋ฏ๋ก, ๋์ ์ถ๋ก ์์ฒญ์ ๊ณง ๋์์ ๊ธฐ๋ํ ์์ปค ์ธ์คํด์ค ์์ ๊ฐ๋ค. ์ฌ๋ฌ ์์ปค๋ฅผ ๋์ธ ๊ณํ์ด๋ฉด vLLM์ฒ๋ผ ๋ฐฐ์น/๋์์์ฒญ์ ๊ฐํ ์๋ฒ๊ฐ ์ ๋ฆฌํ๋ค.
- ํ์์์. GPU๊ฐ ์ฝํ๋ฉด
VLM_TIMEOUT_MS(๊ธฐ๋ณธ 120000)๋ฅผ ๋๋ ค์ผ ํฐ ํ์ด์ง์์ ์คํจํ์ง ์๋๋ค. - CPU ํด๋ฐฑ. Ollama๋ GPU๊ฐ ์์ผ๋ฉด CPU๋ก๋ ๋์ํ์ง๋ง ๋๋ฆฌ๋ค โ ๊ฒ์ ๋๊ธฐ์ด์ด ๊ธธ์ด์ง ์ ์์ผ๋ ์ฒ๋ฆฌ๋ ๊ธฐ๋์น๋ฅผ ๋ฎ์ถฐ ์ก์๋ผ.
๋ชจ๋ ์์ ์ํ๋ ./data ํ ๊ณณ์ ๋ชจ์ธ๋ค.
data/
โโโ minesweeper.db # libsql ์๋ฒ ๋๋ DB (applicants/jobs/extractions/people ...)
โโโ uploads/ # ์
๋ก๋ zip, ์ถ์ถ๋ ์๋ณธ, ํฌ๋กญ ์ด๋ฏธ์ง (UPLOAD_DIR)
DB์ ์
๋ก๋ ํ์ผ์ ์๋ก๋ฅผ ์ฐธ์กฐํ๋ฏ๋ก(์ถ์ถ ๋ ์ฝ๋๊ฐ uploads/์ ํฌ๋กญ ๊ฒฝ๋ก๋ฅผ ๊ฐ๋ฆฌํด) ํญ์ ํจ๊ป ์ผ๊ด๋ ์์ ์ผ๋ก ๋ฐฑ์
ํด์ผ ํ๋ค.
๊ถ์ฅ ๋ฐฑ์ ์ ์ฐจ(์ ํฉ์ฑ ์ฐ์ ):
# 1) ์งง์ ์ ์ง๋ก ์ ํฉ์ฑ ๋ณด์ฅ (web + worker ๋ชจ๋ ๋ฉ์ถค)
pm2 stop minesweeper-web minesweeper-worker
# 2) data ๋๋ ํฐ๋ฆฌ ํต์งธ ์ค๋
์ท
tar czf "backup-$(date +%F-%H%M).tar.gz" ./data
# 3) ์ฌ๊ธฐ๋
pm2 start minesweeper-web minesweeper-worker๋ฌด์ ์ง ๋ฐฑ์
์ด ํ์ํ๋ฉด libsql/SQLite ์จ๋ผ์ธ ๋ฐฑ์
(์: VACUUM INTO๋ก DB ์ค๋
์ท ์์ฑ) ํ uploads/๋ฅผ ๋ณ๋ ๋ณต์ฌํ๋ค. ๋จ, ์ด ๊ฒฝ์ฐ DB ์ค๋
์ท ์์ ๊ณผ ํ์ผ ๋ณต์ฌ ์์ ์ฌ์ด์ ์ ๊ท ์
๋ก๋๊ฐ ํ์ชฝ์๋ง ์กด์ฌํ ์ ์์ผ๋ฏ๋ก, ์ ์ง ๋ฐฑ์
์ด ๊ฐ์ฅ ๋จ์ยท์์ ํ๋ค.
๋ณต์์ ์ญ์์ด๋ค: ํ๋ก์ธ์ค ์ ์ง โ data/ ๊ต์ฒด โ ์ฌ๊ธฐ๋. DB ์คํค๋ง๊ฐ ๋ฐ๋์๋ค๋ฉด ๋ณต์ ํ npm run db:migrate๋ฅผ ํ ๋ฒ ๋ ๋๋ฆฐ๋ค(๋ง์ด๊ทธ๋ ์ด์
์ ๋ฉฑ๋ฑ).
์ ํธ์คํธ์ ์ฎ๊ธฐ๋ ๋ฐ ํ์ํ ๊ฒ์ ์ฝ๋์ ./data๋ฟ์ด๋ค. ์ธ๋ถ ๊ด๋ฆฌํ DB๊ฐ ์๊ณ (์๋ฒ ๋๋ libsql ํ์ผ), ์ถ์ถ๊ธฐ๋ ์จํ๋ ๋ผ ํด๋ก ํ ๋ค ๋จ๊ณ๋ฉด ๋๋๋ค.
git clone <repo> minesweeper && cd minesweeper
npm ci # (๋๋ npm i) ์์กด์ฑ ์ค์น
cp .env.example .env # ํ๊ฒฝ๋ณ์ ์ค์
npm run db:migrate # ์คํค๋ง ์ ์ฉ (data/ ๋๋ ํฐ๋ฆฌ ์๋ ์์ฑ)src/db/migrate.ts๋ DATABASE_URL์ด file:์ด๋ฉด ์์ ๋๋ ํฐ๋ฆฌ๋ฅผ ์๋ ์์ฑํ๋ค.
// src/db/migrate.ts (main)
const url = process.env.DATABASE_URL ?? 'file:./data/minesweeper.db';
if (url.startsWith('file:')) {
const filePath = url.slice('file:'.length);
if (filePath && filePath !== ':memory:') {
mkdirSync(dirname(filePath) || '.', { recursive: true });
}
}๊ทธ ๋ค์ ๋น๋ยท๊ธฐ๋:
npm run build
npx concurrently -n web,worker -c blue,green "next start" "tsx src/worker/index.ts"
# ๋๋ pm2 (ยง1.2 ์ต์
B)์ ํธ์คํธ๋ก ๋ฐ์ดํฐ๊น์ง ๊ทธ๋๋ก ์ด์ ํ๋ ค๋ฉด, ์ ์ ์ฐจ์์ npm run db:migrate ๋์ ๊ธฐ์กด ./data ๋๋ ํฐ๋ฆฌ๋ฅผ ๋ณต์ฌํด ๋ฃ์ผ๋ฉด ๋๋ค(ํ์ ์ ๊ทธ ํ ๋ง์ด๊ทธ๋ ์ด์
1ํ).
๊ฒ์ฆ(์ด์ ์งํ ๊ถ์ฅ):
npm run typecheck # tsc --noEmit โ 0 errors
npm test # vitest run โ 51 tests
npm run build # next build โ 0 errorsPM2๋ก ๋์ด ๋ก์ปฌ ์๋น์ค(http://localhost:3100)๋ฅผ cloudflared๋ก ๋๋ฉ์ธ์ ์ฐ๊ฒฐํ๋ค.
๋ช ๋ช ํฐ๋ (๋๋ฉ์ธ + ์์):
cloudflared tunnel login # CF ๊ณ์ ์ธ์ฆ(๋ธ๋ผ์ฐ์ )
cloudflared tunnel create minesweeper # ํฐ๋ + ์๊ฒฉ์ฆ๋ช
ํ์ผ ์์ฑ
cloudflared tunnel route dns minesweeper <๋๋ฉ์ธ> # ์: minesweeper.example.com~/.cloudflared/config.yml:
tunnel: minesweeper
credentials-file: /home/<user>/.cloudflared/<TUNNEL-UUID>.json
ingress:
- hostname: <๋๋ฉ์ธ>
service: http://localhost:3100
- service: http_status:404cloudflared tunnel run minesweeper # ์คํ
sudo cloudflared service install # (์ ํ) systemd ์์ ๋ฑ๋ก*๋น ๋ฅธ ํ ์คํธ (๋๋ฉ์ธยท๋ก๊ทธ์ธ ๋ถํ์, ์์ .trycloudflare.com URL):
cloudflared tunnel --url http://localhost:3100
โ ๏ธ ์ธ์ฆ ๊ฒฝ๊ณ ํ์. ์ด ์ฑ์ Phase 1์ด๋ผ ์์ฒด ์ธ์ฆ์ด ์๋ค(ยงsecurity.md). ํฐ๋ hostname์ ๊ทธ๋๋ก ๊ณต๊ฐํ๋ฉด ์ง์์ PII๊ฐ ๋ ธ์ถ๋๋ฏ๋ก, ๋ฐ๋์ Cloudflare Access(Zero Trust) ์ ์ฑ ์ ๊ทธ hostname์ ์ ์ฉํด ์ด๋ฉ์ผ/์กฐ์ง ๋จ์๋ก ์ ๊ทผ์ ์ ํํ๋ผ. ํฌํธ(3100)๋ ์ธ๋ถ์ ์ง์ ๊ฐ๋ฐฉํ์ง ๋ง๊ณ ํฐ๋ ๊ฒฝ์ ๋ก๋ง ๋ ธ์ถํ๋ค.
Cloudflare ๋ฌด๋ฃ/ํ๋ก ํ๋์ ์์ฒญ ๋ณธ๋ฌธ์ ์ฝ 100MB๋ก ์ ํํ๋ค. 100MB๋ฅผ ๋๋ ์์ถํ์ผ์ edge์์ ์ฐจ๋จ๋์ด
์๋ฒ์ ๋๋ฌํ์ง ๋ชปํ๋ค(์๋ฒ ๋ก๊ทธยท์
๋ก๋ ๋๋ ํฐ๋ฆฌ์ ํ์ ์ด ๋จ์ง ์์ โ "์
๋ก๋ ์คํจ"๋ก๋ง ๋ณด์). ์๋ฒ ์์ฒด ํ๋๋
MAX_UPLOAD_BYTES(๊ธฐ๋ณธ 200MB)๋ค.
- ํด๊ฒฐ 1 โ ๋ด๋ถ๋ง ์ง์ ์
๋ก๋(๊ถ์ฅ): ๊ฐ์ ๋ง์์
http://<์๋ฒ-LAN-IP>:3100(์:http://192.168.1.105:3100)์ผ๋ก ์ ์ํด ์ ๋ก๋ํ๋ฉด Cloudflare๋ฅผ ์ฐํํ๋ค(์๋ฒ 200MB๊น์ง).next start๋0.0.0.0:3100์ ๋ฐ์ธ๋ฉ๋์ด LAN์์ ์ ๊ทผ ๊ฐ๋ฅ. - ํด๊ฒฐ 2 โ zip ๋ถํ : ์นดํ ๊ณ ๋ฆฌ ๋จ์๋ก ๋๋ 100MB ๋ฏธ๋ง์ผ๋ก ์ ๋ก๋.
- ํด๊ฒฐ 3 โ ํ๋ ์ํฅ: Cloudflare Business(200MB)/Enterprise๋ก ์ ๊ทธ๋ ์ด๋(์ธํ๋ผ ๊ฒฐ์ ).
UploadForm์ ๊ณต๊ฐ ๋๋ฉ์ธ์์ 100MB ์ด๊ณผ ํ์ผ์ ์
๋ก๋ ์ ์ ๊ฐ์งํด ์ ์๋ด ๋ฉ์์ง๋ฅผ ๋์ด๋ค.
๋ณด์ ๊ฒฝ๊ณ (์ฌ๊ฒ์). Phase 1์๋ ๋ด์ฅ ์ธ์ฆ์ด ์๋ค. ๋ฆฌ์์ค๋ id๋ก ์ฃผ์ํ๋๋ฏ๋ก ์๋ฒ์ ๋๋ฌํ ์ ์๋ ๋๊ตฌ๋ ์ง์์ PII๋ฅผ ์ฝ์ ์ ์๋ค. ๋ฐ๋์ ์ฌ๋ด๋ง ๋๋ ์ธ์ฆ์ ๊ฐ์ ํ๋ ๋ฆฌ๋ฒ์ค ํ๋ก์ ๋ค์ ๋๊ณ , ๊ณต๊ฐ ์ธํฐ๋ท์ ๋ ธ์ถํ์ง ๋ง๋ผ. ์์ธํ ์ํ ๋ชจ๋ธ๊ณผ ์ํ์ฑ ์ security.md ์ฐธ์กฐ. ๊ฐ๋ฐ ์ํฌํ๋ก(ํ ์คํธยท์๋ยท๋ก์ปฌ ์คํ)๋ development.md์ ์๋ค.