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

32 KiB
Raw Permalink Blame History

질문 : deepseek-OCR 이 공개 프로그램을 어떻게 사용할 수 있나?

답변 : 딥시크(DeepSeek)의 공개 OCR 모델 DeepSeek-OCR를 로컬/서버에서 바로 돌릴 수 있어요. 핵심은 두 가지 실행 경로(vLLM 또는 Hugging Face Transformers) 중 하나를 고르는 것.

1) 이게 뭐고, 뭘 잘하나

  • 문서/표/차트까지 포함한 레이아웃 보존형 OCR → Markdown 변환이 강점. 프롬프트로 “Convert the document to markdown” 같은 지시를 주면 구조화해서 뽑아줘요. (GitHub)
  • 오픈소스(MIT) + 공개 가중치(Hugging Face). 최근 vLLM 업스트림 공식 지원도 들어갔습니다. (GitHub)

빠른 설치 (CUDA 11.8, PyTorch 2.6 기준)

# 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)


실행 ①: vLLM 경로 (성능/동시성 유리)

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)

실행 ②: Transformers 경로 (간단 통합/커스터마이즈 용이)

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)

주요 프롬프트 예시

  • 문서 → 마크다운: <image>\n<|grounding|>Convert the document to markdown.
  • 단순 문자만: <image>\nFree OCR.
  • 피겨/차트 파싱: <image>\nParse the figure. 리드미에 여러 프롬프트가 예시로 포함되어 있어요. (GitHub)

PDF 배치 처리·API로 감싸기

  • 공식 스크립트: run_dpsk_ocr_pdf.py 로 폴더 단위 처리. (GitHub)
  • 빠른 웹/API 템플릿(비공식): FastAPI+React로 감싼 샘플들이 여럿 있어요. 예: “PDF→Markdown 변환” REST API, 또는 데모 웹앱. (GitHub)
  • 러스트 경량 서버(CPU/Metal/초기 CUDA): OpenAI 호환 HTTP 서버/CLI 포함. 로컬 파이프라인 구성에 유용. (GitHub)

리소스/성능 메모

  • HF 카드 기준 모델은 약 3B 파라미터, BF16, GPU 구동 전제 예시가 제공됩니다(Transformers 섹션). CPU도 이론상 가능하나 실사용 속도는 크게 떨어질 수 있어요. vLLM 경로는 대용량 처리에 유리. (Hugging Face)
  • 리드미에는 A100-40G에서 PDF 처리 체감치(토큰/s) 예시가 실려 있습니다. (GitHub)

보안/정책 유의(특히 기업 환경)

일부 공공기관·기업은 DeepSeek 계열 모델 사용을 제한하기도 합니다. 예컨대 미 상무부 일부 국·실국은 2025-03-17 공지로 DeepSeek 사용 금지를 안내했습니다. 회사 정책과 고객사 보안요건을 먼저 확인하세요. (Reuters)


공식 문서/레퍼런스

  • GitHub(설치·스크립트·프롬프트·PDF 처리): (GitHub)
  • Hugging Face 모델 카드(버전 고정·vLLM 예시): (Hugging Face)
  • vLLM 가이드(오프라인 배치 레시피): (VLLM Documentation)
  • 연구/아티클(컨텍스트 압축 아이디어): (arXiv)

다음 선택지

  • 운영 관점: 배치 파이프라인(PDF 폴더→MD/JSON) 또는 REST API 중 하나로 고정.
  • 프롬프트/후처리: 표/수식/도형은 “Convert to markdown” + 후처리(표 파서, 수식 라텍스 보정) 조합이 안정적.
  • 우리 스택(Nginx/Apache + PHP/Laravel + Jenkins)에서라면: 별도 GPU 호스트 + FastAPI 서비스로 세워 두고, Admin/API에서 비동기 잡 큐로 호출하는 구조가 깔끔합니다.

원하시면 PDF 폴더 일괄 변환 스크립트(입력/출력 디렉토리, 동시성, 실패 리트라이 포함)나 FastAPI 라우터 샘플까지 바로 짜드릴게요.

질문 : 사업자등록증 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)

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
// /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→자동채움→저장)

<!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
// /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
// /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)

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
// /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→자동채움→저장)

<!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
// /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
// /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 리버스 프록시 샘플까지 이어서 드릴게요.