- 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>
1589 lines
55 KiB
Markdown
1589 lines
55 KiB
Markdown
# 전자계약 서명 솔루션 (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*
|