Files
sam-kd/ocr/dev.md
hskwon aca1767eb9 초기 커밋: 5130 레거시 시스템
- URL 하드코딩 → .env APP_URL 기반 동적 URL로 변경
- DB 연결 하드코딩 → .env 기반으로 변경
- MySQL strict mode DATE 오류 수정
2025-12-10 20:14:31 +09:00

858 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

질문 : deepseek-OCR 이 공개 프로그램을 어떻게 사용할 수 있나?
답변 :
딥시크(DeepSeek)의 공개 OCR 모델 **DeepSeek-OCR**를 로컬/서버에서 바로 돌릴 수 있어요. 핵심은 두 가지 실행 경로(vLLM 또는 Hugging Face Transformers) 중 하나를 고르는 것.
### 1) 이게 뭐고, 뭘 잘하나
* 문서/표/차트까지 포함한 **레이아웃 보존형 OCR → Markdown 변환**이 강점. 프롬프트로 “Convert the document to markdown” 같은 지시를 주면 구조화해서 뽑아줘요. ([GitHub][1])
* 오픈소스(MIT) + 공개 가중치(Hugging Face). 최근 **vLLM 업스트림 공식 지원**도 들어갔습니다. ([GitHub][1])
---
## 빠른 설치 (CUDA 11.8, PyTorch 2.6 기준)
```bash
# 0) 필수: NVIDIA 드라이버 + CUDA 11.8 런타임 환경
# 1) 클론
git clone https://github.com/deepseek-ai/DeepSeek-OCR.git
cd DeepSeek-OCR
# 2) 가상환경 (conda 예시)
conda create -n deepseek-ocr python=3.12.9 -y
conda activate deepseek-ocr
# 3) 패키지 (vLLM 경로)
pip install torch==2.6.0 torchvision==0.21.0 torchaudio==2.6.0 --index-url https://download.pytorch.org/whl/cu118
# vLLM 0.8.5 wheel 설치 (리드미의 .whl 사용) + requirements
pip install vllm-0.8.5+cu118-cp38-abi3-manylinux1_x86_64.whl
pip install -r requirements.txt
pip install flash-attn==2.7.3 --no-build-isolation
```
> 위 버전 고정과 설치 순서는 프로젝트 리드미의 권장 스펙입니다. ([GitHub][1])
---
## 실행 ①: vLLM 경로 (성능/동시성 유리)
```bash
cd DeepSeek-OCR-master/DeepSeek-OCR-vllm
# config.py 에 INPUT_PATH / OUTPUT_PATH 등 경로 설정
# 1) 단일/다중 이미지 OCR (스트리밍 출력)
python run_dpsk_ocr_image.py
# 2) PDF 대량 처리 (예: A100-40G 기준 ~2500 tokens/s 표기)
python run_dpsk_ocr_pdf.py
```
* vLLM 최신 야간 빌드로도 구동 가능 (업스트림 공식 지원). 예시 스니펫은 HF 모델카드 참고. ([GitHub][1])
---
## 실행 ②: Transformers 경로 (간단 통합/커스터마이즈 용이)
```python
from transformers import AutoModel, AutoTokenizer
import torch, os
os.environ["CUDA_VISIBLE_DEVICES"] = '0'
m = 'deepseek-ai/DeepSeek-OCR'
tok = AutoTokenizer.from_pretrained(m, trust_remote_code=True)
model = AutoModel.from_pretrained(
m, _attn_implementation='flash_attention_2',
trust_remote_code=True, use_safetensors=True
).eval().cuda().to(torch.bfloat16)
prompt = "<image>\n<|grounding|>Convert the document to markdown. "
image_file = "sample.jpg"
output_dir = "./out"
res = model.infer(tok, prompt=prompt, image_file=image_file,
output_path=output_dir, base_size=1024,
image_size=640, crop_mode=True,
save_results=True, test_compress=True)
```
* 모델/토크나이저는 HF에서 받아옵니다. **Tiny/Small/Base/Large** 입력 해상도 프리셋도 제공. ([Hugging Face][2])
---
## 주요 프롬프트 예시
* 문서 → 마크다운:
`<image>\n<|grounding|>Convert the document to markdown.`
* 단순 문자만:
`<image>\nFree OCR.`
* 피겨/차트 파싱:
`<image>\nParse the figure.`
리드미에 여러 프롬프트가 예시로 포함되어 있어요. ([GitHub][1])
---
## PDF 배치 처리·API로 감싸기
* **공식 스크립트**: `run_dpsk_ocr_pdf.py` 로 폴더 단위 처리. ([GitHub][1])
* **빠른 웹/API 템플릿**(비공식): FastAPI+React로 감싼 샘플들이 여럿 있어요.
예: “PDF→Markdown 변환” REST API, 또는 데모 웹앱. ([GitHub][3])
* **러스트 경량 서버**(CPU/Metal/초기 CUDA): OpenAI 호환 HTTP 서버/CLI 포함. 로컬 파이프라인 구성에 유용. ([GitHub][4])
---
## 리소스/성능 메모
* HF 카드 기준 모델은 약 **3B 파라미터, BF16**, GPU 구동 전제 예시가 제공됩니다(Transformers 섹션). CPU도 이론상 가능하나 실사용 속도는 크게 떨어질 수 있어요. vLLM 경로는 대용량 처리에 유리. ([Hugging Face][2])
* 리드미에는 **A100-40G에서 PDF 처리 체감치(토큰/s)** 예시가 실려 있습니다. ([GitHub][1])
---
## 보안/정책 유의(특히 기업 환경)
일부 공공기관·기업은 DeepSeek 계열 모델 사용을 제한하기도 합니다. 예컨대 **미 상무부 일부 국·실국은 2025-03-17 공지로 DeepSeek 사용 금지**를 안내했습니다. 회사 정책과 고객사 보안요건을 먼저 확인하세요. ([Reuters][5])
---
## 공식 문서/레퍼런스
* GitHub(설치·스크립트·프롬프트·PDF 처리): ([GitHub][1])
* Hugging Face 모델 카드(버전 고정·vLLM 예시): ([Hugging Face][2])
* vLLM 가이드(오프라인 배치 레시피): ([VLLM Documentation][6])
* 연구/아티클(컨텍스트 압축 아이디어): ([arXiv][7])
---
### 다음 선택지
* 운영 관점: **배치 파이프라인(PDF 폴더→MD/JSON)** 또는 **REST API** 중 하나로 고정.
* 프롬프트/후처리: 표/수식/도형은 “Convert to markdown” + 후처리(표 파서, 수식 라텍스 보정) 조합이 안정적.
* 우리 스택(Nginx/Apache + PHP/Laravel + Jenkins)에서라면: **별도 GPU 호스트 + FastAPI 서비스**로 세워 두고, Admin/API에서 비동기 잡 큐로 호출하는 구조가 깔끔합니다.
원하시면 **PDF 폴더 일괄 변환 스크립트**(입력/출력 디렉토리, 동시성, 실패 리트라이 포함)나 **FastAPI 라우터 샘플**까지 바로 짜드릴게요.
[1]: https://github.com/deepseek-ai/DeepSeek-OCR "GitHub - deepseek-ai/DeepSeek-OCR: Contexts Optical Compression"
[2]: https://huggingface.co/deepseek-ai/DeepSeek-OCR "deepseek-ai/DeepSeek-OCR · Hugging Face"
[3]: https://github.com/Bogdanovich77/DeekSeek-OCR---Dockerized-API?utm_source=chatgpt.com "Bogdanovich77/DeekSeek-OCR---Dockerized-API"
[4]: https://github.com/TimmyOVO/deepseek-ocr.rs?utm_source=chatgpt.com "TimmyOVO/deepseek-ocr.rs"
[5]: https://www.reuters.com/technology/artificial-intelligence/us-commerce-department-bureaus-ban-chinas-deepseek-government-devices-sources-2025-03-17/?utm_source=chatgpt.com "US Commerce department bureaus ban China's DeepSeek on government devices, sources say"
[6]: https://docs.vllm.ai/projects/recipes/en/latest/DeepSeek/DeepSeek-OCR.html?utm_source=chatgpt.com "DeepSeek-OCR Usage Guide - vLLM Recipes"
[7]: https://arxiv.org/html/2510.18234v1?utm_source=chatgpt.com "DeepSeek-OCR: Contexts Optical Compression"
질문 :
사업자등록증 pdf를 업로드 해서 AI를 활용한 OCR 기술로 그 사업자 등록증의 형태에 맞게 웹이나 앱에서 이 입력부분을 자동으로 처리하는 것을 만들려고 한다. 만약 php와 자바스크립트를 이용해서 이 로직구현이 가능할까? 추후 python등의 서버를 구비해서 API형태로 만들고 싶다.
AI 답변 :
충분히 됩니다. 카페24 **웹호스팅(PHP 7.3 / MySQL 8.0)** 기준으로, 서버에 바이너리를 깔 수 없으니 **브라우저에서 Tesseract.js + pdf.js**로 OCR을 돌리고, **추출 결과만** PHP로 저장하는 구조가 가장 현실적이에요. 그대로 가져다 쓸 수 있게 “파일 단위”로 정리했습니다.
---
# 0) DB 테이블 (MySQL 8.0)
```sql
CREATE TABLE biz_cert (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
biz_no VARCHAR(20) NOT NULL, -- 000-00-00000
company_name VARCHAR(255) NOT NULL,
representative VARCHAR(100) NOT NULL,
open_date DATE NULL,
address TEXT NULL,
type VARCHAR(255) NULL,
item VARCHAR(255) NULL,
issue_date DATE NULL,
raw_text LONGTEXT NULL, -- 원문 텍스트(디버그용)
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_biz_no (biz_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
---
# 1) `/config/db.php` (PDO 연결 공통)
```php
<?php
// /config/db.php
define('DB_HOST', 'localhost');
define('DB_NAME', 'YOUR_DB_NAME');
define('DB_USER', 'YOUR_DB_USER');
define('DB_PASS', 'YOUR_DB_PASS');
define('DB_CHARSET', 'utf8mb4');
function db_connect(): PDO {
$dsn = 'mysql:host='.DB_HOST.';dbname='.DB_NAME.';charset='.DB_CHARSET;
$opt = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
];
return new PDO($dsn, DB_USER, DB_PASS, $opt);
}
```
---
# 2) `/public/index.html` (업로드→브라우저 OCR→자동채움→저장)
```html
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>사업자등록증 OCR (MVP, PHP7.3/MySQL8)</title>
<style>
body{font:14px/1.4 system-ui,Segoe UI,Apple SD Gothic Neo,Malgun Gothic}
.row{display:flex;gap:24px}
.col{flex:1}
#preview{max-width:100%;border:1px solid #ddd}
.hl{background:#fff7cc}
.mono{font-family:ui-monospace,Consolas,monospace}
</style>
</head>
<body>
<h2>사업자등록증 OCR (AI 없이, 브라우저 Tesseract.js)</h2>
<input id="file" type="file" accept="application/pdf,image/*" />
<span id="status">대기중</span>
<div class="row" style="margin-top:16px">
<div class="col">
<canvas id="pdf-canvas" style="display:none"></canvas>
<img id="preview" alt="미리보기" />
<pre id="raw" class="mono" style="white-space:pre-wrap;background:#f7f7f7;padding:8px"></pre>
</div>
<div class="col">
<form id="biz-form">
<p><input class="hl" name="biz_no" placeholder="사업자등록번호 (000-00-00000)" style="width:100%"></p>
<p><input class="hl" name="company_name" placeholder="상호" style="width:100%"></p>
<p><input class="hl" name="representative" placeholder="대표자" style="width:100%"></p>
<p><input class="hl" name="open_date" placeholder="개업연월일 (YYYY-MM-DD)" style="width:100%"></p>
<p><input class="hl" name="address" placeholder="사업장 소재지" style="width:100%"></p>
<p><input class="hl" name="type" placeholder="업태" style="width:100%"></p>
<p><input class="hl" name="item" placeholder="종목" style="width:100%"></p>
<p><input name="issue_date" placeholder="발급일자 (선택)" style="width:100%"></p>
<button type="button" id="save">저장</button>
</form>
<div id="save-status"></div>
</div>
</div>
<!-- pdf.js (브라우저에서 PDF → canvas 렌더링) -->
<script src="https://cdn.jsdelivr.net/npm/pdfjs-dist@4.6.82/build/pdf.min.js"></script>
<script>pdfjsLib.GlobalWorkerOptions.workerSrc="https://cdn.jsdelivr.net/npm/pdfjs-dist@4.6.82/build/pdf.worker.min.js";</script>
<!-- Tesseract.js (브라우저 OCR) -->
<script src="https://cdn.jsdelivr.net/npm/tesseract.js@5/dist/tesseract.min.js"></script>
<script>
const $ = (q)=>document.querySelector(q);
const statusEl = $('#status');
const rawEl = $('#raw');
const previewEl = $('#preview');
const pdfCanvas = $('#pdf-canvas');
function normalizeBiz(v){
const d=(v||'').replace(/\D/g,'');
if(d.length===10) return d.slice(0,3)+'-'+d.slice(3,5)+'-'+d.slice(5);
return v||'';
}
function isValidBizNo(v){
const d=(v||'').replace(/\D/g,''); if(d.length!==10) return false;
const w=[1,3,7,1,3,7,1,3,5]; let sum=0;
for(let i=0;i<9;i++) sum += Number(d[i])*w[i];
sum += Math.floor((Number(d[8])*5)/10);
const check=(10-(sum%10))%10; return check===Number(d[9]);
}
function toDateISO(s){
if(!s) return '';
const d=s.replace(/[년월.\-\/\s]/g,'-').replace(/-+/g,'-').replace(/-$/, '');
const m=d.match(/(\d{4})-(\d{1,2})-(\d{1,2})/);
return m ? `${m[1]}-${m[2].padStart(2,'0')}-${m[3].padStart(2,'0')}` : '';
}
// 간단 파서 (OCR 텍스트 → 필드)
function parseBiz(text){
const T=text||'';
const pick=(re)=>{const m=T.match(re); return m?m[1].trim():''};
// 앵커 기반
let biz = pick(/사업자등록번호[^\d]*(\d{3}[\s\-]?\d{2}[\s\-]?\d{5})/u);
let company = pick(/상\s*호[^\S\r\n]*[:]?\s*([^\r\n]+)/u);
let rep = pick(/대표자[^\S\r\n]*[:]?\s*([^\r\n]+)/u);
let open = pick(/개업[^\r\n]*(\d{4}[.\-년]\s*\d{1,2}[.\-월]?\s*\d{1,2})/u);
let addr = pick(/사업장\s*소재지[^\S\r\n]*[:]?\s*([^\r\n]+(?:\r?\n[^\r\n]+)?)/u);
let type = pick(/업태[^\S\r\n]*[:]?\s*([^\r\n]+)/u);
let item = pick(/종목[^\S\r\n]*[:]?\s*([^\r\n]+)/u);
let issue = pick(/발급일자[^\d]*(\d{4}[.\-\/]\s*\d{1,2}[.\-\/]?\s*\d{1,2})/u);
biz = normalizeBiz(biz);
open = toDateISO(open);
issue = toDateISO(issue);
return { biz_no: biz, company_name: company, representative: rep,
open_date: open, address: addr, type, item, issue_date: issue };
}
// PDF → canvas → dataURL
async function pdfFirstPageToDataURL(file){
const buf = await file.arrayBuffer();
const pdf = await pdfjsLib.getDocument({data:buf}).promise;
const page = await pdf.getPage(1);
const viewport = page.getViewport({scale: 2.0});
const canvas = pdfCanvas; const ctx = canvas.getContext('2d');
canvas.width = viewport.width; canvas.height = viewport.height;
await page.render({canvasContext:ctx, viewport}).promise;
return canvas.toDataURL('image/png');
}
async function runOCR(imageDataURL){
statusEl.textContent = 'OCR 시작...';
const { data } = await Tesseract.recognize(
imageDataURL,
'kor+eng',
{
logger: m => { if(m.status==='recognizing text'){ statusEl.textContent = `인식중... ${Math.round(m.progress*100)}%`; } },
// 학습 데이터 경로 (공개 CDN)
langPath: 'https://tessdata.projectnaptha.com/4.0.0'
}
);
statusEl.textContent = 'OCR 완료';
return data.text || '';
}
$('#file').addEventListener('change', async (e)=>{
const f = e.target.files[0]; if(!f) return;
rawEl.textContent = ''; statusEl.textContent='준비중...';
let imageDataURL=null;
if(f.type==='application/pdf'){
imageDataURL = await pdfFirstPageToDataURL(f); // 1페이지만 예시
previewEl.src = imageDataURL;
} else if(f.type.startsWith('image/')){
imageDataURL = URL.createObjectURL(f);
previewEl.src = imageDataURL;
} else {
statusEl.textContent = '지원하지 않는 형식';
return;
}
const text = await runOCR(imageDataURL);
rawEl.textContent = text;
const fields = parseBiz(text);
for(const k in fields){
const el=document.querySelector(`[name="${k}"]`);
if(el) el.value = fields[k] || '';
}
// 즉시 유효성 힌트
const biz = document.querySelector('[name="biz_no"]').value;
if(biz && !isValidBizNo(biz)) statusEl.textContent = '참고: 사업자번호 체크섬 불일치(수정 검토)';
});
$('#save').addEventListener('click', async ()=>{
const form = $('#biz-form'); const obj = {};
new FormData(form).forEach((v,k)=>obj[k]=v);
obj['raw_text'] = rawEl.textContent;
const res = await fetch('/save_biz.php', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify(obj)
});
const data = await res.json();
$('#save-status').textContent = data.ok ? `저장 완료 (id=${data.id})` : `실패: ${data.error||'unknown'}`;
});
</script>
</body>
</html>
```
> 포인트
>
> * **PDF도 브라우저에서 1페이지만 렌더** → 이미지처럼 OCR. (여러 페이지면 반복 처리)
> * **언어팩**: `kor+eng`, `langPath`는 공개 CDN 사용.
> * **정규식 파싱**은 앵커(“사업자등록번호”, “상호”… ) 기반이라 양식이 조금 달라도 견딥니다.
---
# 3) `/save_biz.php` (추출 결과 저장 API)
```php
<?php
// /save_biz.php
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__.'/config/db.php';
function normalize_biz($v){
$d = preg_replace('/\D/','',$v??'');
return (strlen($d)===10) ? substr($d,0,3).'-'.substr($d,3,2).'-'.substr($d,5) : ($v??'');
}
function valid_biz_no($s){
$d = preg_replace('/\D/','',$s??'');
if(strlen($d)!=10) return false;
$w=[1,3,7,1,3,7,1,3,5]; $sum=0;
for($i=0;$i<9;$i++){ $sum += intval($d[$i])*$w[$i]; }
$sum += intdiv(intval($d[8])*5,10);
$check = (10 - ($sum % 10)) % 10;
return $check == intval($d[9]);
}
function to_date($s){
if(!$s) return null;
$d = preg_replace('/[년월.\-\/\s]+/','-',$s);
if(preg_match('/^(\d{4})-(\d{1,2})-(\d{1,2})$/',$d,$m)){
return sprintf('%04d-%02d-%02d',$m[1],$m[2],$m[3]);
}
return null;
}
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$biz_no = normalize_biz($input['biz_no'] ?? '');
$company_name = trim($input['company_name'] ?? '');
$representative = trim($input['representative'] ?? '');
$open_date = to_date($input['open_date'] ?? '');
$address = trim($input['address'] ?? '');
$type = trim($input['type'] ?? '');
$item = trim($input['item'] ?? '');
$issue_date = to_date($input['issue_date'] ?? '');
$raw_text = $input['raw_text'] ?? '';
if(!$biz_no || !$company_name){
echo json_encode(['ok'=>false,'error'=>'필수값 누락(biz_no/company_name)']); exit;
}
try{
$pdo = db_connect();
$stmt = $pdo->prepare("
INSERT INTO biz_cert
(biz_no, company_name, representative, open_date, address, type, item, issue_date, raw_text)
VALUES (:biz_no, :company_name, :representative, :open_date, :address, :type, :item, :issue_date, :raw_text)
");
$stmt->execute([
':biz_no'=>$biz_no,
':company_name'=>$company_name,
':representative'=>$representative,
':open_date'=>$open_date,
':address'=>$address,
':type'=>$type,
':item'=>$item,
':issue_date'=>$issue_date,
':raw_text'=>$raw_text,
]);
echo json_encode(['ok'=>true,'id'=>$pdo->lastInsertId(),'valid_biz_no'=>valid_biz_no($biz_no)]);
} catch(Throwable $e){
http_response_code(500);
echo json_encode(['ok'=>false,'error'=>$e->getMessage()]);
}
```
---
# 4) `/list.php` (최근 20건 확인용)
```php
<?php
// /list.php
header('Content-Type: text/html; charset=utf-8');
require_once __DIR__.'/config/db.php';
$pdo = db_connect();
$rows = $pdo->query("SELECT id,biz_no,company_name,representative,open_date,created_at FROM biz_cert ORDER BY id DESC LIMIT 20")->fetchAll();
?>
<!doctype html><meta charset="utf-8">
<h3>최근 20건</h3>
<table border="1" cellpadding="6" cellspacing="0">
<tr><th>ID</th><th>사업자번호</th><th>상호</th><th>대표자</th><th>개업일</th><th>저장일</th></tr>
<?php foreach($rows as $r): ?>
<tr>
<td><?=htmlspecialchars($r['id'])?></td>
<td><?=htmlspecialchars($r['biz_no'])?></td>
<td><?=htmlspecialchars($r['company_name'])?></td>
<td><?=htmlspecialchars($r['representative'])?></td>
<td><?=htmlspecialchars($r['open_date'])?></td>
<td><?=htmlspecialchars($r['created_at'])?></td>
</tr>
<?php endforeach; ?>
</table>
```
---
## 운영 팁 (카페24 웹호스팅 한정)
* **서버 exec 미사용**: Tesseract, Poppler 같은 바이너리 설치 불가하므로 브라우저 OCR이 현실적.
* **용량/속도**: PDF가 크면 1페이지만 먼저 처리 → “추가 페이지 인식” 버튼으로 확장.
* **언어팩 캐시**: Tesseract.js가 언어 데이터(약 수~수십 MB)를 내려받습니다. CDN 캐시 이점이 큼.
* **정확도 올리기**: PDF 렌더 스케일을 `2.0→2.5~3.0`으로, 이미지면 `<img>``<canvas>`로 리샘플 후 OCR.
* **보정 UX**: 자동 채움 후 노란 배경(샘플처럼 `.hl`) + 탭 이동 → 저장.
* **보안**: 저장 전 **숫자 마스킹**(예: `대표자 주민번호 일부`)이 필요하면 프런트/백 모두에서 처리.
---
## 다음 단계(나중에 고도화)
* Python API(DeepSeek-OCR/PaddleOCR) 별도 서버 → 같은 방식으로 **/save_biz.php** 앞단에서 교체 호출.
* 앵커 크롭 후 2차 OCR(속도/정확도↑), 주소 정규화, 체크섬 실패 시 재인식 루틴 등.
필요하면 **여러 페이지 PDF 지원**, **라라벨 컨트롤러 버전**, **Docker-Compose 모식도**, **Nginx 리버스 프록시 샘플**까지 이어서 드릴게요.
질문 :
카페24에서 웹호스팅을 해서 사용하는 사이트가 있는데, 위의 라이브러리를 사용해서 직접 구현이 가능하게 단계별 코드를 만들어줘. php 7.3 mysql 8.0 기준으로 보면됩니다.
답변 :
충분히 됩니다. 카페24 **웹호스팅(PHP 7.3 / MySQL 8.0)** 기준으로, 서버에 바이너리를 깔 수 없으니 **브라우저에서 Tesseract.js + pdf.js**로 OCR을 돌리고, **추출 결과만** PHP로 저장하는 구조가 가장 현실적이에요. 그대로 가져다 쓸 수 있게 “파일 단위”로 정리했습니다.
---
# 0) DB 테이블 (MySQL 8.0)
```sql
CREATE TABLE biz_cert (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
biz_no VARCHAR(20) NOT NULL, -- 000-00-00000
company_name VARCHAR(255) NOT NULL,
representative VARCHAR(100) NOT NULL,
open_date DATE NULL,
address TEXT NULL,
type VARCHAR(255) NULL,
item VARCHAR(255) NULL,
issue_date DATE NULL,
raw_text LONGTEXT NULL, -- 원문 텍스트(디버그용)
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_biz_no (biz_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
---
# 1) `/config/db.php` (PDO 연결 공통)
```php
<?php
// /config/db.php
define('DB_HOST', 'localhost');
define('DB_NAME', 'YOUR_DB_NAME');
define('DB_USER', 'YOUR_DB_USER');
define('DB_PASS', 'YOUR_DB_PASS');
define('DB_CHARSET', 'utf8mb4');
function db_connect(): PDO {
$dsn = 'mysql:host='.DB_HOST.';dbname='.DB_NAME.';charset='.DB_CHARSET;
$opt = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
];
return new PDO($dsn, DB_USER, DB_PASS, $opt);
}
```
---
# 2) `/public/index.html` (업로드→브라우저 OCR→자동채움→저장)
```html
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>사업자등록증 OCR (MVP, PHP7.3/MySQL8)</title>
<style>
body{font:14px/1.4 system-ui,Segoe UI,Apple SD Gothic Neo,Malgun Gothic}
.row{display:flex;gap:24px}
.col{flex:1}
#preview{max-width:100%;border:1px solid #ddd}
.hl{background:#fff7cc}
.mono{font-family:ui-monospace,Consolas,monospace}
</style>
</head>
<body>
<h2>사업자등록증 OCR (AI 없이, 브라우저 Tesseract.js)</h2>
<input id="file" type="file" accept="application/pdf,image/*" />
<span id="status">대기중</span>
<div class="row" style="margin-top:16px">
<div class="col">
<canvas id="pdf-canvas" style="display:none"></canvas>
<img id="preview" alt="미리보기" />
<pre id="raw" class="mono" style="white-space:pre-wrap;background:#f7f7f7;padding:8px"></pre>
</div>
<div class="col">
<form id="biz-form">
<p><input class="hl" name="biz_no" placeholder="사업자등록번호 (000-00-00000)" style="width:100%"></p>
<p><input class="hl" name="company_name" placeholder="상호" style="width:100%"></p>
<p><input class="hl" name="representative" placeholder="대표자" style="width:100%"></p>
<p><input class="hl" name="open_date" placeholder="개업연월일 (YYYY-MM-DD)" style="width:100%"></p>
<p><input class="hl" name="address" placeholder="사업장 소재지" style="width:100%"></p>
<p><input class="hl" name="type" placeholder="업태" style="width:100%"></p>
<p><input class="hl" name="item" placeholder="종목" style="width:100%"></p>
<p><input name="issue_date" placeholder="발급일자 (선택)" style="width:100%"></p>
<button type="button" id="save">저장</button>
</form>
<div id="save-status"></div>
</div>
</div>
<!-- pdf.js (브라우저에서 PDF → canvas 렌더링) -->
<script src="https://cdn.jsdelivr.net/npm/pdfjs-dist@4.6.82/build/pdf.min.js"></script>
<script>pdfjsLib.GlobalWorkerOptions.workerSrc="https://cdn.jsdelivr.net/npm/pdfjs-dist@4.6.82/build/pdf.worker.min.js";</script>
<!-- Tesseract.js (브라우저 OCR) -->
<script src="https://cdn.jsdelivr.net/npm/tesseract.js@5/dist/tesseract.min.js"></script>
<script>
const $ = (q)=>document.querySelector(q);
const statusEl = $('#status');
const rawEl = $('#raw');
const previewEl = $('#preview');
const pdfCanvas = $('#pdf-canvas');
function normalizeBiz(v){
const d=(v||'').replace(/\D/g,'');
if(d.length===10) return d.slice(0,3)+'-'+d.slice(3,5)+'-'+d.slice(5);
return v||'';
}
function isValidBizNo(v){
const d=(v||'').replace(/\D/g,''); if(d.length!==10) return false;
const w=[1,3,7,1,3,7,1,3,5]; let sum=0;
for(let i=0;i<9;i++) sum += Number(d[i])*w[i];
sum += Math.floor((Number(d[8])*5)/10);
const check=(10-(sum%10))%10; return check===Number(d[9]);
}
function toDateISO(s){
if(!s) return '';
const d=s.replace(/[년월.\-\/\s]/g,'-').replace(/-+/g,'-').replace(/-$/, '');
const m=d.match(/(\d{4})-(\d{1,2})-(\d{1,2})/);
return m ? `${m[1]}-${m[2].padStart(2,'0')}-${m[3].padStart(2,'0')}` : '';
}
// 간단 파서 (OCR 텍스트 → 필드)
function parseBiz(text){
const T=text||'';
const pick=(re)=>{const m=T.match(re); return m?m[1].trim():''};
// 앵커 기반
let biz = pick(/사업자등록번호[^\d]*(\d{3}[\s\-]?\d{2}[\s\-]?\d{5})/u);
let company = pick(/상\s*호[^\S\r\n]*[:]?\s*([^\r\n]+)/u);
let rep = pick(/대표자[^\S\r\n]*[:]?\s*([^\r\n]+)/u);
let open = pick(/개업[^\r\n]*(\d{4}[.\-년]\s*\d{1,2}[.\-월]?\s*\d{1,2})/u);
let addr = pick(/사업장\s*소재지[^\S\r\n]*[:]?\s*([^\r\n]+(?:\r?\n[^\r\n]+)?)/u);
let type = pick(/업태[^\S\r\n]*[:]?\s*([^\r\n]+)/u);
let item = pick(/종목[^\S\r\n]*[:]?\s*([^\r\n]+)/u);
let issue = pick(/발급일자[^\d]*(\d{4}[.\-\/]\s*\d{1,2}[.\-\/]?\s*\d{1,2})/u);
biz = normalizeBiz(biz);
open = toDateISO(open);
issue = toDateISO(issue);
return { biz_no: biz, company_name: company, representative: rep,
open_date: open, address: addr, type, item, issue_date: issue };
}
// PDF → canvas → dataURL
async function pdfFirstPageToDataURL(file){
const buf = await file.arrayBuffer();
const pdf = await pdfjsLib.getDocument({data:buf}).promise;
const page = await pdf.getPage(1);
const viewport = page.getViewport({scale: 2.0});
const canvas = pdfCanvas; const ctx = canvas.getContext('2d');
canvas.width = viewport.width; canvas.height = viewport.height;
await page.render({canvasContext:ctx, viewport}).promise;
return canvas.toDataURL('image/png');
}
async function runOCR(imageDataURL){
statusEl.textContent = 'OCR 시작...';
const { data } = await Tesseract.recognize(
imageDataURL,
'kor+eng',
{
logger: m => { if(m.status==='recognizing text'){ statusEl.textContent = `인식중... ${Math.round(m.progress*100)}%`; } },
// 학습 데이터 경로 (공개 CDN)
langPath: 'https://tessdata.projectnaptha.com/4.0.0'
}
);
statusEl.textContent = 'OCR 완료';
return data.text || '';
}
$('#file').addEventListener('change', async (e)=>{
const f = e.target.files[0]; if(!f) return;
rawEl.textContent = ''; statusEl.textContent='준비중...';
let imageDataURL=null;
if(f.type==='application/pdf'){
imageDataURL = await pdfFirstPageToDataURL(f); // 1페이지만 예시
previewEl.src = imageDataURL;
} else if(f.type.startsWith('image/')){
imageDataURL = URL.createObjectURL(f);
previewEl.src = imageDataURL;
} else {
statusEl.textContent = '지원하지 않는 형식';
return;
}
const text = await runOCR(imageDataURL);
rawEl.textContent = text;
const fields = parseBiz(text);
for(const k in fields){
const el=document.querySelector(`[name="${k}"]`);
if(el) el.value = fields[k] || '';
}
// 즉시 유효성 힌트
const biz = document.querySelector('[name="biz_no"]').value;
if(biz && !isValidBizNo(biz)) statusEl.textContent = '참고: 사업자번호 체크섬 불일치(수정 검토)';
});
$('#save').addEventListener('click', async ()=>{
const form = $('#biz-form'); const obj = {};
new FormData(form).forEach((v,k)=>obj[k]=v);
obj['raw_text'] = rawEl.textContent;
const res = await fetch('/save_biz.php', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify(obj)
});
const data = await res.json();
$('#save-status').textContent = data.ok ? `저장 완료 (id=${data.id})` : `실패: ${data.error||'unknown'}`;
});
</script>
</body>
</html>
```
> 포인트
>
> * **PDF도 브라우저에서 1페이지만 렌더** → 이미지처럼 OCR. (여러 페이지면 반복 처리)
> * **언어팩**: `kor+eng`, `langPath`는 공개 CDN 사용.
> * **정규식 파싱**은 앵커(“사업자등록번호”, “상호”… ) 기반이라 양식이 조금 달라도 견딥니다.
---
# 3) `/save_biz.php` (추출 결과 저장 API)
```php
<?php
// /save_biz.php
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__.'/config/db.php';
function normalize_biz($v){
$d = preg_replace('/\D/','',$v??'');
return (strlen($d)===10) ? substr($d,0,3).'-'.substr($d,3,2).'-'.substr($d,5) : ($v??'');
}
function valid_biz_no($s){
$d = preg_replace('/\D/','',$s??'');
if(strlen($d)!=10) return false;
$w=[1,3,7,1,3,7,1,3,5]; $sum=0;
for($i=0;$i<9;$i++){ $sum += intval($d[$i])*$w[$i]; }
$sum += intdiv(intval($d[8])*5,10);
$check = (10 - ($sum % 10)) % 10;
return $check == intval($d[9]);
}
function to_date($s){
if(!$s) return null;
$d = preg_replace('/[년월.\-\/\s]+/','-',$s);
if(preg_match('/^(\d{4})-(\d{1,2})-(\d{1,2})$/',$d,$m)){
return sprintf('%04d-%02d-%02d',$m[1],$m[2],$m[3]);
}
return null;
}
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$biz_no = normalize_biz($input['biz_no'] ?? '');
$company_name = trim($input['company_name'] ?? '');
$representative = trim($input['representative'] ?? '');
$open_date = to_date($input['open_date'] ?? '');
$address = trim($input['address'] ?? '');
$type = trim($input['type'] ?? '');
$item = trim($input['item'] ?? '');
$issue_date = to_date($input['issue_date'] ?? '');
$raw_text = $input['raw_text'] ?? '';
if(!$biz_no || !$company_name){
echo json_encode(['ok'=>false,'error'=>'필수값 누락(biz_no/company_name)']); exit;
}
try{
$pdo = db_connect();
$stmt = $pdo->prepare("
INSERT INTO biz_cert
(biz_no, company_name, representative, open_date, address, type, item, issue_date, raw_text)
VALUES (:biz_no, :company_name, :representative, :open_date, :address, :type, :item, :issue_date, :raw_text)
");
$stmt->execute([
':biz_no'=>$biz_no,
':company_name'=>$company_name,
':representative'=>$representative,
':open_date'=>$open_date,
':address'=>$address,
':type'=>$type,
':item'=>$item,
':issue_date'=>$issue_date,
':raw_text'=>$raw_text,
]);
echo json_encode(['ok'=>true,'id'=>$pdo->lastInsertId(),'valid_biz_no'=>valid_biz_no($biz_no)]);
} catch(Throwable $e){
http_response_code(500);
echo json_encode(['ok'=>false,'error'=>$e->getMessage()]);
}
```
---
# 4) `/list.php` (최근 20건 확인용)
```php
<?php
// /list.php
header('Content-Type: text/html; charset=utf-8');
require_once __DIR__.'/config/db.php';
$pdo = db_connect();
$rows = $pdo->query("SELECT id,biz_no,company_name,representative,open_date,created_at FROM biz_cert ORDER BY id DESC LIMIT 20")->fetchAll();
?>
<!doctype html><meta charset="utf-8">
<h3>최근 20건</h3>
<table border="1" cellpadding="6" cellspacing="0">
<tr><th>ID</th><th>사업자번호</th><th>상호</th><th>대표자</th><th>개업일</th><th>저장일</th></tr>
<?php foreach($rows as $r): ?>
<tr>
<td><?=htmlspecialchars($r['id'])?></td>
<td><?=htmlspecialchars($r['biz_no'])?></td>
<td><?=htmlspecialchars($r['company_name'])?></td>
<td><?=htmlspecialchars($r['representative'])?></td>
<td><?=htmlspecialchars($r['open_date'])?></td>
<td><?=htmlspecialchars($r['created_at'])?></td>
</tr>
<?php endforeach; ?>
</table>
```
---
## 운영 팁 (카페24 웹호스팅 한정)
* **서버 exec 미사용**: Tesseract, Poppler 같은 바이너리 설치 불가하므로 브라우저 OCR이 현실적.
* **용량/속도**: PDF가 크면 1페이지만 먼저 처리 → “추가 페이지 인식” 버튼으로 확장.
* **언어팩 캐시**: Tesseract.js가 언어 데이터(약 수~수십 MB)를 내려받습니다. CDN 캐시 이점이 큼.
* **정확도 올리기**: PDF 렌더 스케일을 `2.0→2.5~3.0`으로, 이미지면 `<img>``<canvas>`로 리샘플 후 OCR.
* **보정 UX**: 자동 채움 후 노란 배경(샘플처럼 `.hl`) + 탭 이동 → 저장.
* **보안**: 저장 전 **숫자 마스킹**(예: `대표자 주민번호 일부`)이 필요하면 프런트/백 모두에서 처리.
---
## 다음 단계(나중에 고도화)
* Python API(DeepSeek-OCR/PaddleOCR) 별도 서버 → 같은 방식으로 **/save_biz.php** 앞단에서 교체 호출.
* 앵커 크롭 후 2차 OCR(속도/정확도↑), 주소 정규화, 체크섬 실패 시 재인식 루틴 등.
필요하면 **여러 페이지 PDF 지원**, **라라벨 컨트롤러 버전**, **Docker-Compose 모식도**, **Nginx 리버스 프록시 샘플**까지 이어서 드릴게요.