Files
sam-docs/projects/e-sign/technical-design.md
김보곤 aab9dc0799 docs:E-Sign 기술 스택 문서 업데이트 (실제 구현 반영)
- FPDI/FPDF → FPDI/TCPDF (PDF 서명 합성, MNG PdfSignatureService)
- DOCX→PDF 변환 추가 (LibreOffice headless, MNG DocxToPdfConverter)
- GD 확장, 나눔 폰트, Lucide 아이콘 등 실제 사용 기술 반영
- 4개 문서 일괄 업데이트 (technical-design, implementation-guide, operations-guide, changelog)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 13:02:16 +09:00

1589 lines
55 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.

# 전자계약 서명 솔루션 (E-Sign) - 기술 설계 문서
> **프로젝트명**: SAM E-Sign (가칭)
> **작성일**: 2026-02-12
> **버전**: v1.1 (필드 템플릿 & 복사 기능 추가)
> **작성자**: DX 추진팀
---
## 1. 프로젝트 개요
### 1.1 목적
모두싸인과 유사한 **간편 전자계약 서명 솔루션**을 자체 구축한다.
두 당사자(계약 생성자 A, 상대방 B)가 온라인으로 계약서에 서명하고,
서명 완료된 문서를 법적 효력이 있는 형태로 보관하는 시스템이다.
### 1.2 핵심 가치
| 가치 | 설명 |
|------|------|
| **간편함** | PDF 업로드 → 서명 위치 지정 → 링크 발송, 3단계 완료 |
| **보안** | 문서 해시 검증, 본인인증, 감사 추적(Audit Trail) |
| **법적 효력** | 전자서명법 제2조에 부합하는 전자서명 요건 충족 |
### 1.3 범위 (v1)
| 포함 | 미포함 (v2 이후) |
|------|------------------|
| 2인 서명 (생성자/상대방) | N명 다자간 서명 |
| PDF 문서 기반 | 워드/한글 문서 직접 편집 |
| 이메일 OTP 인증 | 카카오/PASS 본인인증 |
| 순차 서명 (A→B 또는 B→A) | 동시 서명 |
| 캔버스 직접 서명 | 공인인증서 연동 |
| 감사 추적 로그 | 블록체인 기반 공증 |
| 완료 문서 PDF 다운로드 | API 외부 연동 |
### 1.4 기술 스택
| 영역 | 기술 | 비고 |
|------|------|------|
| Backend | Laravel 11 (PHP 8.3) | SAM MNG + API 프로젝트 |
| Frontend | React 18 + Babel (CDN) | 브라우저 트랜스파일링 |
| Navigation | HTMX | SPA 없이 네비게이션 |
| Styling | Tailwind CSS | 유틸리티 퍼스트 |
| Database | MySQL 8.0 (Multi-tenant) | 기존 SAM DB 공유 |
| PDF 렌더링 | PDF.js (프론트) | 브라우저 PDF 표시 |
| 서명 캡처 | signature_pad.js | 터치/마우스 서명 |
| DOCX→PDF 변환 | LibreOffice (headless) | MNG Docker 컨테이너 |
| PDF 서명 합성 | FPDI + TCPDF (백엔드) | 원본 PDF에 서명 오버레이 |
| 서명 이미지 처리 | GD 확장 | PNG 서명 이미지 처리 |
| 한글 지원 | 나눔 폰트 (fonts-nanum) | DOCX→PDF 한글 렌더링 |
| 아이콘 | Lucide | React 아이콘 라이브러리 |
| 파일 저장 | Laravel Storage (local) | MNG 로컬 스토리지 |
| 알림 | Laravel Mail | 이메일 발송 |
---
## 2. 시스템 아키텍처
### 2.1 전체 구조
```
┌─────────────────────────────────────────────────────────────────┐
│ 사용자 (브라우저) │
├────────────────────────┬────────────────────────────────────────┤
│ 계약 생성자 (A) │ 상대방 (B) │
│ - 로그인 사용자 │ - 비로그인 (토큰 기반 접근) │
│ - 계약서 업로드 │ - 이메일 링크로 접속 │
│ - 서명 위치 지정 │ - 본인인증 후 서명 │
└────────┬───────────────┴──────────────────┬─────────────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────────────┐
│ Nginx (sam-nginx-1) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────┐ ┌──────────────────────────────┐ │
│ │ MNG (sam-mng-1) │ │ API (sam-api-1) │ │
│ │ │ │ │ │
│ │ - 계약 관리 화면 │ │ - 계약 CRUD API │ │
│ │ - PDF 뷰어/서명 UI │ │ - 서명 처리 API │ │
│ │ - 대시보드 │ │ - 인증 API (OTP) │ │
│ │ - DOCX→PDF 변환 │ │ - 알림 서비스 (이메일) │ │
│ │ - PDF 서명 합성 │ │ - 감사 로그 서비스 │ │
│ │ │ │ - 문서 해시 검증 서비스 │ │
│ │ React 18 + HTMX │ │ │ │
│ │ + PDF.js │ │ │ │
│ │ + signature_pad │ │ │ │
│ └──────────┬───────────┘ └──────────────┬─────────────────┘ │
│ │ │ │
│ └────────────┬───────────────────┘ │
│ ▼ │
│ ┌──────────────────────────┐ │
│ │ MySQL (sam-mysql-1) │ │
│ │ │ │
│ │ - esign_contracts │ │
│ │ - esign_signers │ │
│ │ - esign_sign_fields │ │
│ │ - esign_audit_logs │ │
│ └──────────────────────────┘ │
│ │ │
│ ┌──────────────────────────┐ │
│ │ File Storage │ │
│ │ - 원본 PDF (암호화) │ │
│ │ - 서명 이미지 │ │
│ │ - 완료 PDF │ │
│ └──────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
### 2.2 서비스 레이어 구조
```
api/app/Services/ESign/
├── EsignContractService.php # 계약 CRUD, 상태 관리, 발송/리마인더
├── EsignSignService.php # 서명 처리, OTP 인증, 토큰 관리
├── EsignPdfService.php # 해시 생성/검증
└── EsignAuditService.php # 감사 추적 로그 기록
mng/app/Services/ESign/
├── DocxToPdfConverter.php # DOCX→PDF 변환 (LibreOffice headless)
└── PdfSignatureService.php # PDF 서명 합성 (FPDI/TCPDF)
```
---
## 3. 핵심 플로우
### 3.1 계약 생성 플로우
```
[A: 계약 생성자]
1. 로그인 상태에서 "새 계약" 클릭
2. 계약 정보 입력
- 계약 제목
- 계약 설명 (선택)
- 서명 기한 (기본 7일)
3. PDF 파일 업로드
→ 서버: 파일 저장 + SHA-256 해시 생성
4. 서명 위치 지정 화면으로 이동
→ pdf.js로 PDF 렌더링
→ 드래그&드롭으로 서명란 배치
→ A의 서명란 (파란색)
→ B의 서명란 (빨간색)
→ 날짜 필드, 텍스트 필드 추가 가능
5. 상대방(B) 정보 입력
- 이름
- 이메일 (필수)
- 전화번호 (선택)
6. 서명 순서 선택
- B 먼저 → A 확인 서명
- A 먼저 → B 확인 서명
7. "서명 요청 발송" 클릭
→ 서버: contract 상태를 'pending'으로 변경
→ 서버: 상대방에게 이메일 발송 (서명 링크 포함)
→ 감사 로그: 'contract_created', 'sign_requested'
```
### 3.2 서명 수행 플로우 (상대방 B)
```
[B: 서명 상대방]
1. 이메일에서 서명 링크 클릭
→ URL: /esign/sign/{access_token}
→ 서버: 토큰 유효성 검증 (만료, 사용 여부)
2. 본인인증 게이트
- 이메일로 6자리 OTP 발송
- OTP 입력 (5회 제한, 5분 유효)
→ 서버: 인증 성공 시 세션에 verified 상태 저장
→ 감사 로그: 'identity_verified'
3. 계약서 열람
→ pdf.js로 PDF 렌더링
→ 서명이 필요한 위치에 하이라이트 표시
→ 감사 로그: 'document_viewed'
4. 서명 수행
- 서명란 클릭 → 캔버스 서명 모달 팝업
- 터치/마우스로 서명
- "서명 완료" 클릭
→ 서버: 서명 이미지 저장 (PNG, base64→file)
→ 서버: 서명 시각, IP, User-Agent 기록
→ 감사 로그: 'signed'
5. 동의 확인
- [✓] 본 계약서의 내용을 확인하였으며 서명에 동의합니다
- [✓] 전자서명의 법적 효력에 동의합니다
- "최종 제출" 클릭
→ 서버: signer 상태를 'signed'로 변경
→ 서버: 다음 서명자(A)에게 알림 발송
→ 감사 로그: 'consent_agreed', 'submission_completed'
```
### 3.3 최종 서명 플로우 (생성자 A)
```
[A: 계약 생성자 - 최종 서명]
1. 알림 수신 (이메일 또는 대시보드)
"상대방 OOO님이 서명을 완료했습니다"
2. 본인인증 (동일 OTP 절차)
3. 계약서 열람 (B의 서명이 표시된 상태)
4. A의 서명란에 서명
5. 최종 제출
→ 서버: contract 상태를 'completed'로 변경
→ 서버: PDF 합성 (원본 + A서명 + B서명 + 감사정보)
→ 서버: 완료 PDF에 SHA-256 해시 생성
→ 서버: 양쪽에 완료 알림 + PDF 다운로드 링크 발송
→ 감사 로그: 'contract_completed'
```
### 3.4 상태 전이 다이어그램
```
┌───────┐
│ draft │ 계약서 작성 중 (저장만, 발송 전)
└───┬───┘
│ 서명 요청 발송
┌─────────┐
│ pending │ 서명 요청됨, 첫 서명자 대기
└────┬────┘
│ 첫 번째 서명 완료
┌────────────────────┐
│ partially_signed │ 한쪽만 서명 완료
└─────────┬──────────┘
│ 두 번째 서명 완료
┌───────────┐
│ completed │ 양쪽 서명 완료, PDF 합성됨
└───────────┘
[만료 시]
pending / partially_signed ──→ expired (서명 기한 초과)
[취소 시]
draft / pending ──→ cancelled (생성자가 취소)
[거절 시]
pending / partially_signed ──→ rejected (서명자가 거절)
```
---
## 4. 데이터베이스 스키마
### 4.1 ER 다이어그램 (텍스트)
```
esign_contracts (1) ──── (N) esign_signers
│ │
│ │
(1) (1)
│ │
(N) (N)
esign_sign_fields esign_audit_logs
esign_field_templates (1) ──── (N) esign_field_template_items
```
### 4.2 esign_contracts (계약서)
```sql
CREATE TABLE esign_contracts (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
-- 계약 정보
contract_code VARCHAR(30) NOT NULL UNIQUE, -- 'ES-20260212-A1B2C3'
title VARCHAR(255) NOT NULL, -- 계약 제목
description TEXT NULL, -- 계약 설명
sign_order_type ENUM('counterpart_first', 'creator_first') DEFAULT 'counterpart_first',
-- 문서 파일
original_file_path VARCHAR(500) NOT NULL, -- 원본 PDF 경로 (암호화 저장)
original_file_name VARCHAR(255) NOT NULL, -- 원본 파일명
original_file_hash VARCHAR(64) NOT NULL, -- SHA-256 해시
original_file_size INT UNSIGNED NOT NULL, -- 파일 크기 (bytes)
signed_file_path VARCHAR(500) NULL, -- 서명 완료 PDF 경로
signed_file_hash VARCHAR(64) NULL, -- 서명 완료 PDF 해시
-- 상태
status ENUM('draft', 'pending', 'partially_signed', 'completed', 'expired', 'cancelled', 'rejected')
DEFAULT 'draft',
expires_at DATETIME NOT NULL, -- 서명 기한
completed_at DATETIME NULL, -- 완료 시각
-- 생성자
created_by BIGINT UNSIGNED NOT NULL, -- 생성자 user_id
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL, -- soft delete
INDEX idx_tenant_status (tenant_id, status),
INDEX idx_created_by (created_by),
INDEX idx_expires_at (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
### 4.3 esign_signers (서명자)
```sql
CREATE TABLE esign_signers (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
contract_id BIGINT UNSIGNED NOT NULL,
-- 서명자 정보
role ENUM('creator', 'counterpart') NOT NULL,
sign_order TINYINT UNSIGNED NOT NULL DEFAULT 1, -- 서명 순서 (1 or 2)
name VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL,
phone VARCHAR(20) NULL,
-- 접근 토큰
access_token VARCHAR(128) NOT NULL UNIQUE, -- 서명 링크용 1회성 토큰
token_expires_at DATETIME NOT NULL, -- 토큰 만료 시각
-- 인증 정보
otp_code VARCHAR(10) NULL, -- OTP 코드 (해시 저장)
otp_expires_at DATETIME NULL, -- OTP 만료 시각
otp_attempts TINYINT UNSIGNED DEFAULT 0, -- OTP 시도 횟수
auth_verified_at DATETIME NULL, -- 본인인증 완료 시각
auth_method VARCHAR(20) DEFAULT 'email_otp', -- 인증 방식
-- 서명 정보
signature_image_path VARCHAR(500) NULL, -- 서명 이미지 경로
signed_at DATETIME NULL, -- 서명 시각
consent_agreed_at DATETIME NULL, -- 동의 시각
-- 서명 시점 환경 정보
sign_ip_address VARCHAR(45) NULL, -- IPv4/IPv6
sign_user_agent VARCHAR(500) NULL, -- 브라우저 정보
-- 상태
status ENUM('waiting', 'notified', 'authenticated', 'signed', 'rejected')
DEFAULT 'waiting',
rejected_reason TEXT NULL, -- 거절 사유
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (contract_id) REFERENCES esign_contracts(id) ON DELETE CASCADE,
INDEX idx_access_token (access_token),
INDEX idx_contract_role (contract_id, role)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
### 4.4 esign_sign_fields (서명 위치/필드)
```sql
CREATE TABLE esign_sign_fields (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
contract_id BIGINT UNSIGNED NOT NULL,
signer_id BIGINT UNSIGNED NOT NULL, -- esign_signers.id
-- 위치 정보
page_number INT UNSIGNED NOT NULL, -- PDF 페이지 번호 (1부터)
position_x DECIMAL(8,4) NOT NULL, -- X 좌표 (% 단위, 0~100)
position_y DECIMAL(8,4) NOT NULL, -- Y 좌표 (% 단위, 0~100)
width DECIMAL(8,4) NOT NULL, -- 너비 (% 단위, 1~100)
height DECIMAL(8,4) NOT NULL, -- 높이 (% 단위, 1~100)
-- 필드 정보
field_type ENUM('signature', 'stamp', 'text', 'date', 'checkbox') NOT NULL DEFAULT 'signature',
field_label VARCHAR(100) NULL, -- 필드 라벨 (예: "갑 서명")
field_value TEXT NULL, -- 입력된 값 (텍스트/날짜)
is_required BOOLEAN DEFAULT TRUE,
sort_order INT UNSIGNED DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (contract_id) REFERENCES esign_contracts(id) ON DELETE CASCADE,
FOREIGN KEY (signer_id) REFERENCES esign_signers(id) ON DELETE CASCADE,
INDEX idx_contract_page (contract_id, page_number)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
### 4.5 esign_audit_logs (감사 추적 로그)
```sql
CREATE TABLE esign_audit_logs (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
contract_id BIGINT UNSIGNED NOT NULL,
signer_id BIGINT UNSIGNED NULL, -- NULL이면 시스템 이벤트
-- 이벤트 정보
action VARCHAR(50) NOT NULL,
-- 가능한 값:
-- 'contract_created' : 계약서 생성
-- 'document_uploaded' : PDF 업로드
-- 'fields_configured' : 서명 위치 설정
-- 'sign_requested' : 서명 요청 발송
-- 'link_accessed' : 서명 링크 접속
-- 'otp_sent' : OTP 발송
-- 'otp_verified' : OTP 인증 성공
-- 'otp_failed' : OTP 인증 실패
-- 'document_viewed' : 계약서 열람
-- 'signed' : 서명 수행
-- 'consent_agreed' : 동의 체크
-- 'submission_completed' : 최종 제출
-- 'contract_completed' : 계약 완료 (양쪽 서명)
-- 'pdf_generated' : 완료 PDF 생성
-- 'document_downloaded' : 문서 다운로드
-- 'contract_cancelled' : 계약 취소
-- 'contract_rejected' : 서명 거절
-- 'contract_expired' : 계약 만료
-- 'reminder_sent' : 리마인더 발송
-- 환경 정보
ip_address VARCHAR(45) NULL,
user_agent VARCHAR(500) NULL,
metadata JSON NULL, -- 추가 데이터 (유연한 확장)
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (contract_id) REFERENCES esign_contracts(id) ON DELETE CASCADE,
INDEX idx_contract_action (contract_id, action),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
### 4.6 esign_field_templates (필드 배치 템플릿)
```sql
CREATE TABLE esign_field_templates (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
name VARCHAR(100) NOT NULL, -- 템플릿 이름
description TEXT NULL, -- 템플릿 설명
signer_count TINYINT UNSIGNED DEFAULT 2, -- 서명자 수
is_active BOOLEAN DEFAULT TRUE, -- 활성 여부 (삭제 시 false)
created_by BIGINT UNSIGNED NULL, -- 생성자 user_id
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_esign_field_templates_tenant (tenant_id),
INDEX idx_esign_field_templates_active (is_active)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
### 4.7 esign_field_template_items (템플릿 필드 항목)
```sql
CREATE TABLE esign_field_template_items (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
template_id BIGINT UNSIGNED NOT NULL,
signer_order TINYINT UNSIGNED NOT NULL, -- 서명자 순서 (1, 2, ...)
page_number SMALLINT UNSIGNED NOT NULL, -- 페이지 번호
position_x DECIMAL(8,2) NOT NULL, -- X 좌표 (%)
position_y DECIMAL(8,2) NOT NULL, -- Y 좌표 (%)
width DECIMAL(8,2) NOT NULL, -- 너비 (%)
height DECIMAL(8,2) NOT NULL, -- 높이 (%)
field_type ENUM('signature','stamp','text','date','checkbox') DEFAULT 'signature',
field_label VARCHAR(100) NULL, -- 필드 라벨
is_required BOOLEAN DEFAULT TRUE,
sort_order SMALLINT UNSIGNED DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (template_id) REFERENCES esign_field_templates(id) ON DELETE CASCADE,
INDEX idx_esign_field_template_items_template (template_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
> **핵심 설계**: `signer_id` 대신 `signer_order`(1, 2)를 저장합니다.
> 템플릿 적용 시 현재 계약 서명자의 `sign_order`와 매핑하여 `signer_id`를 결정합니다.
---
## 5. API 명세
### 5.1 계약 관리 API
#### 계약 목록 조회
```
GET /api/v1/esign/contracts
```
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| page | int | N | 페이지 번호 (기본 1) |
| size | int | N | 페이지 크기 (기본 20) |
| status | string | N | 상태 필터 |
| search | string | N | 제목 검색 |
| date_from | string | N | 시작일 |
| date_to | string | N | 종료일 |
**Response 200:**
```json
{
"data": [
{
"id": 1,
"contract_code": "ES-20260212-A1B2C3",
"title": "소프트웨어 개발 용역 계약서",
"status": "pending",
"signers": [
{ "name": "김갑순", "role": "creator", "status": "waiting" },
{ "name": "박을동", "role": "counterpart", "status": "notified" }
],
"expires_at": "2026-02-19T23:59:59",
"created_at": "2026-02-12T10:00:00"
}
],
"meta": { "total": 25, "page": 1, "size": 20 }
}
```
#### 계약 생성
```
POST /api/v1/esign/contracts
Content-Type: multipart/form-data
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| title | string | Y | 계약 제목 |
| description | string | N | 계약 설명 |
| document | file | Y | PDF 파일 (max 20MB) |
| expires_days | int | N | 서명 기한 일수 (기본 7) |
| sign_order_type | string | N | 'counterpart_first' 또는 'creator_first' |
| counterpart_name | string | Y | 상대방 이름 |
| counterpart_email | string | Y | 상대방 이메일 |
| counterpart_phone | string | N | 상대방 전화번호 |
**Response 201:**
```json
{
"data": {
"id": 1,
"contract_code": "ES-20260212-A1B2C3",
"status": "draft",
"original_file_hash": "a1b2c3d4e5f6...",
"signers": [
{ "id": 1, "role": "creator", "name": "김갑순" },
{ "id": 2, "role": "counterpart", "name": "박을동" }
]
}
}
```
#### 계약 상세 조회
```
GET /api/v1/esign/contracts/{id}
```
#### 계약 취소
```
POST /api/v1/esign/contracts/{id}/cancel
```
#### 계약 통계
```
GET /api/v1/esign/contracts/stats
```
**Response 200:**
```json
{
"data": {
"total": 50,
"draft": 3,
"pending": 10,
"partially_signed": 5,
"completed": 28,
"expired": 3,
"cancelled": 1
}
}
```
### 5.2 서명 필드 API
#### 서명 위치 설정
```
POST /api/v1/esign/contracts/{id}/fields
Content-Type: application/json
```
```json
{
"fields": [
{
"signer_id": 1,
"page_number": 3,
"position_x": 15.5,
"position_y": 82.0,
"width": 20.0,
"height": 8.0,
"field_type": "signature",
"field_label": "갑 (생성자) 서명",
"is_required": true
},
{
"signer_id": 2,
"page_number": 3,
"position_x": 55.5,
"position_y": 82.0,
"width": 20.0,
"height": 8.0,
"field_type": "signature",
"field_label": "을 (상대방) 서명",
"is_required": true
},
{
"signer_id": 1,
"page_number": 3,
"position_x": 15.5,
"position_y": 92.0,
"width": 15.0,
"height": 4.0,
"field_type": "date",
"field_label": "서명일",
"is_required": true
}
]
}
```
#### 서명 위치 조회
```
GET /api/v1/esign/contracts/{id}/fields
```
### 5.3 필드 템플릿 API
#### 템플릿 목록 조회
```
GET /esign/contracts/templates?signer_count=2
```
**Response 200:**
```json
{
"success": true,
"data": [
{
"id": 1,
"name": "기본 2인 서명 배치",
"description": "마지막 페이지 좌우 서명란",
"signer_count": 2,
"items": [
{
"signer_order": 1, "page_number": 3,
"position_x": 15.5, "position_y": 82.0,
"width": 20.0, "height": 8.0,
"field_type": "signature", "field_label": "갑 서명"
}
]
}
]
}
```
#### 템플릿 저장 (현재 필드를 템플릿으로)
```
POST /esign/contracts/templates
Content-Type: application/json
```
```json
{
"name": "기본 2인 서명 배치",
"description": "마지막 페이지 좌우 서명란",
"items": [
{
"signer_order": 1, "page_number": 3,
"position_x": 15.5, "position_y": 82.0,
"width": 20.0, "height": 8.0,
"field_type": "signature", "field_label": "갑 서명",
"is_required": true
}
]
}
```
> `signer_order`는 프론트엔드에서 `signer_id` → 해당 서명자의 `sign_order`로 변환하여 전송합니다.
#### 템플릿 삭제 (soft delete)
```
DELETE /esign/contracts/templates/{id}
```
> `is_active`를 `false`로 변경합니다.
#### 템플릿을 계약에 적용
```
POST /esign/contracts/{id}/apply-template
Content-Type: application/json
```
```json
{
"template_id": 1
}
```
> 기존 필드를 삭제하고 템플릿의 필드를 적용합니다.
> `signer_order` → 현재 계약의 `sign_order`에 해당하는 `signer_id`로 매핑합니다.
> 템플릿의 `signer_count`가 계약의 서명자 수보다 크면 422 에러를 반환합니다.
#### 다른 계약에서 필드 복사
```
POST /esign/contracts/{id}/copy-fields/{sourceId}
```
> 소스 계약의 필드를 대상 계약으로 복사합니다.
> 소스 서명자의 `sign_order` → 대상 서명자의 `sign_order`로 매핑합니다.
### 5.4 서명 요청 API
#### 서명 요청 발송
```
POST /api/v1/esign/contracts/{id}/send
```
> 상대방에게 이메일 발송, 상태를 `pending`으로 변경
#### 리마인더 발송
```
POST /api/v1/esign/contracts/{id}/remind
```
### 5.5 서명 수행 API (토큰 기반, 비로그인)
#### 서명 페이지 접속
```
GET /api/v1/esign/sign/{access_token}
```
> 토큰 검증 → 계약 정보 + 서명 필드 반환
#### OTP 발송 요청
```
POST /api/v1/esign/sign/{access_token}/otp/send
```
**Response 200:**
```json
{
"message": "인증코드가 이메일로 발송되었습니다",
"expires_in": 300,
"remaining_attempts": 5
}
```
#### OTP 인증
```
POST /api/v1/esign/sign/{access_token}/otp/verify
```
```json
{
"otp_code": "482917"
}
```
**Response 200 (성공):**
```json
{
"verified": true,
"sign_session_token": "eyJ..."
}
```
**Response 401 (실패):**
```json
{
"verified": false,
"remaining_attempts": 2,
"message": "인증코드가 일치하지 않습니다"
}
```
#### 서명 제출
```
POST /api/v1/esign/sign/{access_token}/submit
Authorization: Bearer {sign_session_token}
Content-Type: application/json
```
```json
{
"signatures": [
{
"field_id": 1,
"signature_image": "data:image/png;base64,iVBORw0KGgo...",
"field_type": "signature"
},
{
"field_id": 3,
"field_value": "2026-02-12",
"field_type": "date"
}
],
"consent_electronic_signature": true,
"consent_contract_content": true
}
```
#### 서명 거절
```
POST /api/v1/esign/sign/{access_token}/reject
```
```json
{
"reason": "계약 조건 수정이 필요합니다"
}
```
### 5.6 문서 API
#### 원본 PDF 조회 (인증 후)
```
GET /api/v1/esign/sign/{access_token}/document
Authorization: Bearer {sign_session_token}
```
> Content-Type: application/pdf
#### 완료 PDF 다운로드
```
GET /api/v1/esign/contracts/{id}/download
```
> 로그인 사용자만 접근, 완료 상태인 계약만
#### 문서 무결성 검증
```
GET /api/v1/esign/contracts/{id}/verify
```
**Response 200:**
```json
{
"original_hash": "a1b2c3...",
"signed_hash": "d4e5f6...",
"original_integrity": true,
"signed_integrity": true,
"verification_time": "2026-02-12T15:30:00"
}
```
### 5.7 감사 로그 API
#### 감사 로그 조회
```
GET /api/v1/esign/contracts/{id}/audit-logs
```
**Response 200:**
```json
{
"data": [
{
"action": "contract_created",
"signer_name": "김갑순",
"ip_address": "192.168.1.100",
"created_at": "2026-02-12T10:00:00"
},
{
"action": "sign_requested",
"signer_name": null,
"metadata": { "sent_to": "park@example.com" },
"created_at": "2026-02-12T10:05:00"
}
]
}
```
---
## 6. 보안 설계
### 6.1 문서 무결성 (Document Integrity)
```php
// 업로드 시 해시 생성
$hash = hash_file('sha256', $uploadedFile->getRealPath());
// 검증 시 해시 비교
$currentHash = hash_file('sha256', Storage::path($contract->original_file_path));
$isValid = hash_equals($contract->original_file_hash, $currentHash);
```
- 원본 PDF 업로드 시 SHA-256 해시 생성 및 DB 저장
- 서명 완료 PDF 생성 시에도 별도 해시 저장
- 문서 다운로드/열람 시 해시 비교로 위변조 여부 확인
### 6.2 서명자 인증 (Signer Authentication)
```
인증 플로우:
1. 서명 링크 접속 (access_token 검증)
2. OTP 발송 (이메일)
3. OTP 입력 (6자리, 5분 유효, 5회 제한)
4. 인증 성공 → sign_session_token 발급 (JWT, 30분 유효)
5. 이후 모든 서명 API 호출 시 sign_session_token 필요
```
**OTP 보안 규칙:**
| 규칙 | 값 | 설명 |
|------|-----|------|
| OTP 길이 | 6자리 숫자 | 무작위 생성 |
| 유효 시간 | 5분 | 초과 시 재발송 필요 |
| 시도 제한 | 5회 | 초과 시 토큰 무효화 |
| 재발송 간격 | 60초 | 연속 발송 방지 |
| 저장 방식 | bcrypt 해시 | DB에 평문 저장 금지 |
### 6.3 접근 제어 (Access Control)
**토큰 정책:**
| 토큰 | 용도 | 유효기간 | 특성 |
|------|------|---------|------|
| access_token | 서명 링크 URL | 계약 만료일까지 | 128자 랜덤, URL-safe |
| sign_session_token | OTP 인증 후 세션 | 30분 | JWT, 갱신 불가 |
**접근 규칙:**
- 서명 링크는 해당 서명자만 접근 가능 (토큰 + 이메일 검증)
- 완료/취소/만료 상태 계약은 서명 접근 차단
- 서명 순서가 아닌 서명자는 대기 화면 표시
- 모든 API 호출 시 IP/UA 기록
### 6.4 감사 추적 (Audit Trail)
모든 주요 행위를 `contract_audit_logs`에 기록:
```
기록 대상 행위:
- 계약서 생성/수정/삭제
- PDF 업로드
- 서명 요청 발송
- 서명 링크 접속 (성공/실패)
- OTP 발송/검증 (성공/실패)
- 계약서 열람
- 서명 수행
- 동의 체크
- 문서 다운로드
- 계약 취소/거절/만료
```
**감사 로그는 삭제 불가** (soft delete 미적용)
### 6.5 파일 보안
```
저장 구조:
storage/app/esign/
├── originals/ # 원본 PDF (AES-256 암호화)
│ └── {contract_id}/
│ └── {hash}.pdf.enc
├── signatures/ # 서명 이미지
│ └── {signer_id}/
│ └── {timestamp}.png
└── completed/ # 완료 PDF
└── {contract_id}/
└── {contract_code}_signed.pdf
```
- 원본 PDF는 AES-256으로 암호화 저장
- 서명 이미지는 별도 디렉토리에 격리
- 완료 PDF는 감사 증적 페이지를 포함하여 생성
- 파일 경로에 직접 접근 불가 (Controller를 통한 스트리밍만 허용)
### 6.6 완료 PDF에 포함되는 감사 정보
서명 완료 PDF의 마지막 페이지에 자동 추가:
```
┌──────────────────────────────────────────┐
│ 전자서명 감사 증적 (Audit Trail) │
├──────────────────────────────────────────┤
│ 계약 번호: ES-20260212-A1B2C3 │
│ 문서 해시: a1b2c3d4e5f6... │
│ │
│ [서명자 A - 갑] │
│ 이름: 김갑순 │
│ 인증 방식: 이메일 OTP │
│ 인증 시각: 2026-02-12 14:30:22 KST │
│ 서명 시각: 2026-02-12 14:32:15 KST │
│ IP: 203.xxx.xxx.100 │
│ │
│ [서명자 B - 을] │
│ 이름: 박을동 │
│ 인증 방식: 이메일 OTP │
│ 인증 시각: 2026-02-12 11:20:05 KST │
│ 서명 시각: 2026-02-12 11:23:41 KST │
│ IP: 121.xxx.xxx.55 │
│ │
│ 계약 완료: 2026-02-12 14:32:15 KST │
│ 본 문서는 전자서명법에 의거하여 │
│ 법적 효력을 가집니다. │
└──────────────────────────────────────────┘
```
---
## 7. 화면 목록
### 7.1 계약 생성자(A) 화면
| # | 화면ID | 화면명 | 경로 | 설명 |
|---|--------|--------|------|------|
| 1 | ES_DASH | 대시보드 | /esign | 계약 현황 통계 + 목록 |
| 2 | ES_CREATE | 계약 생성 | /esign/create | PDF 업로드 + 정보 입력 |
| 3 | ES_FIELDS | 서명 위치 지정 | /esign/{id}/fields | PDF 위에 서명란 배치 + 템플릿 저장/불러오기/복사 |
| 4 | ES_SEND | 서명 요청 발송 | /esign/{id}/send | 상대방 정보 입력 + 발송 |
| 5 | ES_DETAIL | 계약 상세 | /esign/{id} | 진행 상태 + 감사 로그 |
### 7.2 서명 상대방(B) 화면
| # | 화면ID | 화면명 | 경로 | 설명 |
|---|--------|--------|------|------|
| 6 | ES_AUTH | 본인인증 | /esign/sign/{token} | OTP 인증 게이트 |
| 7 | ES_SIGN | 서명 수행 | /esign/sign/{token}/sign | PDF 열람 + 서명 |
| 8 | ES_DONE | 서명 완료 | /esign/sign/{token}/done | 완료 안내 |
---
## 8. 구현 로드맵
### Phase 1: 기본 기능 (2주)
| 주차 | 작업 | 담당 |
|------|------|------|
| 1주차 | DB 마이그레이션 생성 | API |
| 1주차 | Contract 모델/서비스/컨트롤러 | API |
| 1주차 | PDF 업로드 + 해시 생성 | API |
| 1주차 | 대시보드 + 계약 생성 화면 | MNG |
| 2주차 | 서명 위치 지정 화면 (pdf.js + 드래그) | MNG |
| 2주차 | OTP 인증 + 서명 캡처 (signature_pad) | MNG + API |
| 2주차 | 이메일 발송 (서명 요청/완료) | API |
| 2주차 | PDF 서명 합성 (FPDI/TCPDF) + DOCX→PDF (LibreOffice) | MNG |
### Phase 2: 보안 강화 (1주)
| 작업 | 담당 |
|------|------|
| 감사 추적 로그 전체 구현 | API |
| 파일 암호화 저장 (AES-256) | API |
| 완료 PDF 감사 증적 페이지 추가 | API |
| 문서 무결성 검증 API | API |
| 토큰 만료/사용 횟수 제한 | API |
| Rate Limiting (OTP 발송 등) | API |
### Phase 3: UX 개선 (1주)
| 작업 | 담당 |
|------|------|
| 리마인더 자동 발송 (만료 3일 전) | API (Scheduler) |
| 만료 자동 처리 배치 | API (Scheduler) |
| 모바일 반응형 서명 UI | MNG |
| 계약 목록 필터/정렬/검색 | MNG + API |
| 서명 거절 + 사유 입력 | MNG + API |
### Phase 3.5: 필드 템플릿 & 복사 (구현 완료)
| 작업 | 담당 | 상태 |
|------|------|------|
| esign_field_templates / esign_field_template_items 테이블 생성 | API | 완료 |
| EsignFieldTemplate, EsignFieldTemplateItem 모델 | MNG | 완료 |
| 템플릿 CRUD API (목록/저장/삭제) | MNG | 완료 |
| 템플릿 적용 API (signer_order 매핑) | MNG | 완료 |
| 다른 계약에서 필드 복사 API | MNG | 완료 |
| 서명 위치 설정 화면에 템플릿 드롭다운 + 모달 3개 | MNG | 완료 |
### Phase 4: 확장 기능 (v2, 추후)
| 기능 | 설명 |
|------|------|
| SMS 인증 | Coolsms/NHN Cloud 연동 |
| 카카오 알림톡 | 카카오 비즈메시지 연동 |
| 다자간 서명 (3인 이상) | signers 테이블 확장 |
| 외부 API 제공 | 타 시스템에서 전자계약 호출 |
| 블록체인 공증 | 계약 해시를 블록체인에 기록 |
---
## 9. 법적 고려사항
### 9.1 전자서명법 요건
한국 전자서명법 제2조에 따른 전자서명 요건:
| 요건 | 충족 방안 |
|------|-----------|
| 서명자 확인 | 이메일 OTP 본인인증 |
| 서명 의사 확인 | 동의 체크박스 2개 (내용 확인 + 법적 효력 동의) |
| 문서 변경 감지 | SHA-256 해시 비교 |
| 서명 후 변경 불가 | 서명 완료 후 계약 수정 차단 + 별도 PDF 생성 |
### 9.2 개인정보보호
| 항목 | 조치 |
|------|------|
| 수집 정보 | 이름, 이메일, 전화번호 (선택), IP, 서명 이미지 |
| 보관 기간 | 계약 완료 후 5년 (전자상거래법) |
| 암호화 | 파일 AES-256, OTP bcrypt, 통신 HTTPS |
| 접근 통제 | 계약 당사자 + 테넌트 관리자만 접근 |
---
## 10. 구현 파일 구조
### 10.1 API 프로젝트 (`/home/aweso/sam/api`)
```
database/migrations/
├── 2026_02_12_100000_create_esign_contracts_table.php
├── 2026_02_12_110000_create_esign_signers_table.php
├── 2026_02_12_120000_create_esign_sign_fields_table.php
├── 2026_02_12_130000_create_esign_audit_logs_table.php
├── 2026_02_12_140000_create_esign_field_templates_table.php
└── 2026_02_12_140100_create_esign_field_template_items_table.php
app/Models/ESign/
├── EsignContract.php
├── EsignSigner.php
├── EsignSignField.php
└── EsignAuditLog.php
app/Services/ESign/
├── EsignContractService.php
├── EsignSignService.php
├── EsignPdfService.php
└── EsignAuditService.php
app/Http/Controllers/Api/V1/ESign/
├── EsignContractController.php # 인증 필요 (10 엔드포인트)
└── EsignSignController.php # 토큰 기반 (6 엔드포인트)
app/Http/Requests/ESign/
├── ContractStoreRequest.php
├── FieldConfigureRequest.php
├── SignSubmitRequest.php
└── SignRejectRequest.php
app/Mail/
└── EsignRequestMail.php
routes/api/v1/
└── esign.php # 16 엔드포인트 정의
```
### 10.2 MNG 프로젝트 (`/home/aweso/sam/mng`)
```
app/Models/ESign/
├── EsignContract.php
├── EsignSigner.php
├── EsignSignField.php
├── EsignAuditLog.php
├── EsignFieldTemplate.php # 필드 템플릿
└── EsignFieldTemplateItem.php # 템플릿 필드 항목
app/Http/Controllers/ESign/
├── EsignController.php # 인증 필요 (5 화면)
├── EsignApiController.php # 내부 API (9 메서드 + 템플릿 5 메서드)
└── EsignPublicController.php # 비인증 (3 화면)
resources/views/esign/
├── dashboard.blade.php # 대시보드 (React)
├── create.blade.php # 계약 생성 (React)
├── detail.blade.php # 계약 상세 (React)
├── fields.blade.php # 서명 위치 지정 (React + PDF.js)
├── send.blade.php # 서명 요청 발송 (React)
└── sign/
├── auth.blade.php # 본인인증 OTP (React)
├── sign.blade.php # 서명 수행 (React + SignaturePad)
└── done.blade.php # 서명 완료 (React)
routes/web.php # esign 라우트 그룹 추가
```
---
## 11. 모델 상세
### 11.1 공통 Traits
| Trait | 적용 모델 | 기능 |
|-------|----------|------|
| `BelongsToTenant` | 전체 4개 | tenant_id 기반 글로벌 스코프, 다중 테넌트 격리 |
| `Auditable` | EsignContract | created_by, updated_by, deleted_by 자동 기록 |
| `SoftDeletes` | EsignContract | 논리 삭제 (deleted_at) |
### 11.2 EsignContract 상수
```php
// 계약 상태
const STATUS_DRAFT = 'draft';
const STATUS_PENDING = 'pending';
const STATUS_PARTIALLY_SIGNED = 'partially_signed';
const STATUS_COMPLETED = 'completed';
const STATUS_EXPIRED = 'expired';
const STATUS_CANCELLED = 'cancelled';
const STATUS_REJECTED = 'rejected';
// 서명 순서
const SIGN_ORDER_COUNTERPART_FIRST = 'counterpart_first';
const SIGN_ORDER_CREATOR_FIRST = 'creator_first';
```
### 11.3 EsignSigner 상수 및 숨김 필드
```php
// 역할
const ROLE_CREATOR = 'creator';
const ROLE_COUNTERPART = 'counterpart';
// 서명자 상태
const STATUS_WAITING = 'waiting';
const STATUS_NOTIFIED = 'notified';
const STATUS_AUTHENTICATED = 'authenticated';
const STATUS_SIGNED = 'signed';
const STATUS_REJECTED = 'rejected';
// API 응답에서 제외 (보안)
protected $hidden = ['access_token', 'otp_code'];
```
### 11.4 EsignAuditLog 액션 타입
```php
const ACTION_CREATED = 'created';
const ACTION_SENT = 'sent';
const ACTION_VIEWED = 'viewed';
const ACTION_OTP_SENT = 'otp_sent';
const ACTION_AUTHENTICATED = 'authenticated';
const ACTION_SIGNED = 'signed';
const ACTION_REJECTED = 'rejected';
const ACTION_COMPLETED = 'completed';
const ACTION_CANCELLED = 'cancelled';
const ACTION_REMINDED = 'reminded';
const ACTION_DOWNLOADED = 'downloaded';
```
### 11.5 모델 관계도
```
EsignContract
├── signers() → HasMany → EsignSigner
├── signFields() → HasMany → EsignSignField
├── auditLogs() → HasMany → EsignAuditLog
└── creator() → BelongsTo → User
EsignSigner
├── contract() → BelongsTo → EsignContract
└── signFields() → HasMany → EsignSignField
EsignSignField
├── contract() → BelongsTo → EsignContract
└── signer() → BelongsTo → EsignSigner
EsignAuditLog
├── contract() → BelongsTo → EsignContract
└── signer() → BelongsTo → EsignSigner
EsignFieldTemplate
├── items() → HasMany → EsignFieldTemplateItem
└── creator() → BelongsTo → User
EsignFieldTemplateItem
└── template() → BelongsTo → EsignFieldTemplate
```
---
## 12. FormRequest 검증 규칙
### 12.1 ContractStoreRequest (계약 생성)
| 필드 | 규칙 | 설명 |
|------|------|------|
| title | required, string, max:200 | 계약 제목 |
| description | nullable, string, max:2000 | 계약 설명 |
| file | required, file, mimes:pdf, max:20480 | PDF 파일 (최대 20MB) |
| sign_order_type | nullable, in:counterpart_first,creator_first | 서명 순서 |
| expires_at | nullable, date, after:now | 서명 기한 |
| creator_name | required, string, max:100 | 생성자(갑) 이름 |
| creator_email | required, email, max:255 | 생성자(갑) 이메일 |
| creator_phone | nullable, string, max:20 | 생성자(갑) 전화번호 |
| counterpart_name | required, string, max:100 | 상대방(을) 이름 |
| counterpart_email | required, email, max:255 | 상대방(을) 이메일 |
| counterpart_phone | nullable, string, max:20 | 상대방(을) 전화번호 |
### 12.2 FieldConfigureRequest (서명 위치 설정)
| 필드 | 규칙 | 설명 |
|------|------|------|
| fields | required, array, min:1 | 서명 필드 배열 |
| fields.*.signer_id | required, exists:esign_signers,id | 서명자 ID |
| fields.*.page_number | required, integer, min:1 | PDF 페이지 번호 |
| fields.*.position_x | required, numeric, between:0,100 | X 좌표 (%) |
| fields.*.position_y | required, numeric, between:0,100 | Y 좌표 (%) |
| fields.*.width | required, numeric, between:1,100 | 너비 (%) |
| fields.*.height | required, numeric, between:1,100 | 높이 (%) |
| fields.*.field_type | required, in:signature,stamp,text,date,checkbox | 필드 유형 |
| fields.*.field_label | nullable, string, max:100 | 필드 라벨 |
| fields.*.is_required | nullable, boolean | 필수 여부 |
| fields.*.sort_order | nullable, integer, min:0 | 정렬 순서 |
### 12.3 SignSubmitRequest (서명 제출)
| 필드 | 규칙 | 설명 |
|------|------|------|
| signature_image | required, string | Base64 인코딩 서명 이미지 |
### 12.4 SignRejectRequest (서명 거절)
| 필드 | 규칙 | 설명 |
|------|------|------|
| reason | required, string, max:1000 | 거절 사유 |
---
## 13. 에러 처리 패턴
### 13.1 ApiResponse::handle() 패턴
모든 컨트롤러는 `ApiResponse::handle()` 래퍼를 사용하여 일관된 응답 형식을 보장합니다.
```php
// 성공 응답
return ApiResponse::handle(
fn() => $this->contractService->create($request->validated()),
__('message.esign.contract_created'), // i18n 메시지 키
201
);
// 에러 시 자동 처리
// → 400: 잘못된 요청 (Validation)
// → 403: 권한 없음
// → 404: 리소스 없음
// → 500: 서버 에러
```
### 13.2 응답 구조
```json
// 성공
{
"success": true,
"message": "계약이 성공적으로 생성되었습니다.",
"data": { ... }
}
// 실패
{
"success": false,
"message": "에러 메시지",
"errors": { ... }
}
```
### 13.3 i18n 메시지 키
```php
// 성공 메시지 (message.esign.*)
'contract_created' => '계약이 성공적으로 생성되었습니다.',
'contract_cancelled' => '계약이 취소되었습니다.',
'contract_sent' => '서명 요청이 발송되었습니다.',
'fields_configured' => '서명 위치가 설정되었습니다.',
'otp_sent' => '인증코드가 발송되었습니다.',
'otp_verified' => '본인인증이 완료되었습니다.',
'signature_submitted' => '서명이 완료되었습니다.',
'contract_rejected' => '서명이 거절되었습니다.',
// 에러 메시지 (error.esign.*)
'contract_not_found' => '계약을 찾을 수 없습니다.',
'invalid_status' => '현재 상태에서는 이 작업을 수행할 수 없습니다.',
'token_expired' => '서명 링크가 만료되었습니다.',
'otp_max_attempts' => 'OTP 입력 횟수를 초과했습니다.',
'otp_invalid' => '인증코드가 일치하지 않습니다.',
'already_signed' => '이미 서명이 완료되었습니다.',
```
---
## 14. Multi-tenant 아키텍처
### 14.1 데이터 격리
모든 E-Sign 테이블에 `tenant_id` 컬럼이 포함되어 있으며, `BelongsToTenant` trait의 글로벌 스코프에 의해 자동으로 현재 테넌트의 데이터만 조회됩니다.
```php
// BelongsToTenant trait의 글로벌 스코프
// → SELECT * FROM esign_contracts WHERE tenant_id = {현재 테넌트}
```
### 14.2 비인증 접근 시 (공개 서명)
서명자(B)는 로그인 없이 토큰 기반으로 접근하므로, 테넌트 스코프를 우회해야 합니다.
```php
// EsignAuditService::logPublic()
// → withoutGlobalScopes()를 사용하여 tenant 스코프 우회
// → tenant_id를 명시적으로 전달
public function logPublic(int $tenantId, int $contractId, string $action, ...): EsignAuditLog
{
return EsignAuditLog::withoutGlobalScopes()->create([
'tenant_id' => $tenantId,
'contract_id' => $contractId,
'action' => $action,
// ...
]);
}
```
### 14.3 계약 코드 생성
테넌트 간 충돌을 방지하기 위해 날짜 + 랜덤 문자열 형식을 사용합니다.
```
형식: ES-{YYYYMMDD}-{6자리 랜덤}
예시: ES-20260212-A1B2C3
```
---
## 15. 프론트엔드 아키텍처
### 15.1 React 하이브리드 패턴
모든 MNG 뷰는 Blade 레이아웃 안에서 React 18 컴포넌트를 마운트하는 하이브리드 방식입니다.
```
┌─────────────────────────────────────────────┐
│ Blade 레이아웃 (app.blade.php) │
│ ├── 사이드바 메뉴 (HTMX 기반) │
│ ├── 상단 헤더 │
│ └── 콘텐츠 영역 │
│ └── <div id="esign-xxx-root"></div> │
│ └── React 컴포넌트 마운트 │
│ └── API 호출 (fetch → api.sam.kr)│
└─────────────────────────────────────────────┘
```
### 15.2 CDN 의존성
```html
<!-- React 18 -->
<script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<!-- Babel (JSX 브라우저 트랜스파일링) -->
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<!-- PDF.js (서명 위치 지정, 서명 수행 화면) -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<!-- SignaturePad (서명 캡처) -->
<script src="https://cdn.jsdelivr.net/npm/signature_pad@4.1.7/dist/signature_pad.umd.min.js"></script>
```
### 15.3 HTMX 네비게이션
E-Sign 화면은 React를 사용하므로 HTMX 부분 로드 시 스크립트가 실행되지 않습니다.
따라서 모든 컨트롤러에서 `HX-Redirect` 헤더를 반환하여 전체 페이지를 로드합니다.
```php
public function dashboard(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)
->header('HX-Redirect', route('esign.dashboard'));
}
return view('esign.dashboard');
}
```
### 15.4 React Root ID 매핑
| 화면 | Root Element ID |
|------|----------------|
| 대시보드 | `#esign-dashboard-root` |
| 계약 생성 | `#esign-create-root` |
| 계약 상세 | `#esign-detail-root` |
| 서명 위치 지정 | `#esign-fields-root` |
| 서명 요청 발송 | `#esign-send-root` |
| 본인인증 OTP | `#esign-auth-root` |
| 서명 수행 | `#esign-sign-root` |
| 서명 완료 | `#esign-done-root` |
---
## 16. 필드 템플릿 사용법 (사용자 가이드)
### 16.1 개요
서명 필드를 매 계약마다 수동 배치하는 반복 작업을 줄이기 위한 기능입니다.
자주 쓰는 필드 배치를 **템플릿으로 저장**하거나, **기존 계약에서 복사**할 수 있습니다.
### 16.2 진입 경로
```
사이드바: 전자계약(E-Sign) → 대시보드 (또는 보관함)
→ 계약 클릭 → 상세 페이지
→ [서명 위치 설정] 버튼 → 필드 편집기 진입
```
### 16.3 Toolbar 메뉴
필드 편집기 상단 Toolbar 우측에 **[템플릿 ▾]** 드롭다운 버튼이 있습니다.
```
[← 뒤로] [] 100% [+] [▦] [↩ ↪] [템플릿 ▾] [저장]
├─ 📁 템플릿으로 저장
├─ 📂 템플릿 불러오기
└─ 📋 다른 계약에서 복사
```
### 16.4 시나리오별 사용법
#### A. 템플릿으로 저장 (반복 사용할 배치 저장)
1. 계약의 서명 위치 설정 화면에서 필드를 원하는 대로 배치합니다
2. **[템플릿 ▾]** → **📁 템플릿으로 저장** 클릭
3. 모달에서 **이름**과 **설명**(선택)을 입력합니다
4. **[저장]** 클릭 → 현재 필드 배치가 템플릿으로 저장됩니다
> 저장 시 각 필드의 `signer_id`는 자동으로 `signer_order`(1, 2)로 변환됩니다.
> 따라서 다른 계약에 적용해도 서명자 순서에 맞게 자동 매핑됩니다.
#### B. 템플릿 불러오기 (저장된 배치를 새 계약에 적용)
1. 새 계약의 서명 위치 설정 화면 진입
2. **[템플릿 ▾]** → **📂 템플릿 불러오기** 클릭
3. 모달에 저장된 템플릿 목록이 표시됩니다 (현재 계약의 서명자 수에 맞는 것만)
4. 원하는 템플릿 선택 → **[적용]** 클릭
5. 확인 대화상자에서 **확인** → 기존 필드가 삭제되고 템플릿 필드가 적용됩니다
> 템플릿의 서명자 수가 현재 계약보다 많으면 에러 메시지가 표시됩니다.
> 불필요한 템플릿은 목록에서 **[×]** 버튼으로 삭제할 수 있습니다.
#### C. 다른 계약에서 복사 (템플릿 없이 직접 복사)
1. 새 계약의 서명 위치 설정 화면 진입
2. **[템플릿 ▾]** → **📋 다른 계약에서 복사** 클릭
3. 모달에서 계약 **제목 또는 코드로 검색**
4. 복사할 계약 선택 → **[복사]** 클릭
5. 확인 대화상자에서 **확인** → 필드가 복사됩니다
> 소스 계약 서명자의 `sign_order`를 기준으로 대상 계약 서명자에 매핑됩니다.
> 현재 계약 자신은 목록에서 제외됩니다.
### 16.5 서명자 매핑 로직
```
[템플릿/복사 적용 시]
signer_order = 1 → 현재 계약에서 sign_order = 1인 서명자의 signer_id
signer_order = 2 → 현재 계약에서 sign_order = 2인 서명자의 signer_id
[예시]
템플릿: signer_order=1 (갑 서명란), signer_order=2 (을 서명란)
계약 A: 김갑순(sign_order=1, id=10), 박을동(sign_order=2, id=11)
결과: signer_order=1 → signer_id=10, signer_order=2 → signer_id=11
```
### 16.6 주의사항
- 템플릿/복사 적용 시 **기존 필드가 모두 삭제**됩니다 (확인 대화상자 표시)
- 적용 후 **[저장] 버튼을 눌러야** DB에 최종 반영됩니다
- 적용 후 필드 위치를 추가 조정할 수 있습니다
- Undo(Ctrl+Z)로 적용 전 상태로 되돌릴 수 없습니다 (서버에서 직접 적용되므로)
---
## 17. 미구현 기능 (v1.1 이후)
| 기능 | 현재 상태 | 구현 방안 |
|------|----------|----------|
| PDF 서명 합성 | **구현 완료** | FPDI + TCPDF로 원본 PDF에 서명 이미지 오버레이 (MNG PdfSignatureService) |
| DOCX→PDF 변환 | **구현 완료** | LibreOffice headless + 나눔 폰트 (MNG DocxToPdfConverter) |
| 감사 증적 페이지 | 미구현 | 완료 PDF 마지막 페이지에 서명 이력 자동 추가 |
| 파일 암호화 | 미구현 | AES-256-CBC로 원본 PDF 암호화 저장 |
| 자동 만료 처리 | 미구현 | Laravel Scheduler로 만료된 계약 상태 자동 변경 |
| 자동 리마인더 | 미구현 | 만료 3일 전 자동 알림 이메일 발송 |
| SMS OTP | 미구현 | CoolSMS/NHN Cloud 연동 |
| OTP bcrypt 해싱 | 미구현 | OTP 코드 DB 저장 시 bcrypt 적용 |
---
*이 문서는 SAM E-Sign v1.1 구현 기준 기술 설계서입니다. 최종 업데이트: 2026-02-12*