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

55 KiB
Raw Blame History

전자계약 서명 솔루션 (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 (계약서)

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 (서명자)

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 (서명 위치/필드)

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 (감사 추적 로그)

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 (필드 배치 템플릿)

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 (템플릿 필드 항목)

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:

{
  "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:

{
  "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:

{
  "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
{
  "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:

{
  "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
{
  "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_activefalse로 변경합니다.

템플릿을 계약에 적용

POST /esign/contracts/{id}/apply-template
Content-Type: application/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:

{
  "message": "인증코드가 이메일로 발송되었습니다",
  "expires_in": 300,
  "remaining_attempts": 5
}

OTP 인증

POST /api/v1/esign/sign/{access_token}/otp/verify
{
  "otp_code": "482917"
}

Response 200 (성공):

{
  "verified": true,
  "sign_session_token": "eyJ..."
}

Response 401 (실패):

{
  "verified": false,
  "remaining_attempts": 2,
  "message": "인증코드가 일치하지 않습니다"
}

서명 제출

POST /api/v1/esign/sign/{access_token}/submit
Authorization: Bearer {sign_session_token}
Content-Type: application/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
{
  "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:

{
  "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:

{
  "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)

// 업로드 시 해시 생성
$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 상수

// 계약 상태
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 상수 및 숨김 필드

// 역할
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 액션 타입

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() 래퍼를 사용하여 일관된 응답 형식을 보장합니다.

// 성공 응답
return ApiResponse::handle(
    fn() => $this->contractService->create($request->validated()),
    __('message.esign.contract_created'),  // i18n 메시지 키
    201
);

// 에러 시 자동 처리
// → 400: 잘못된 요청 (Validation)
// → 403: 권한 없음
// → 404: 리소스 없음
// → 500: 서버 에러

13.2 응답 구조

// 성공
{
  "success": true,
  "message": "계약이 성공적으로 생성되었습니다.",
  "data": { ... }
}

// 실패
{
  "success": false,
  "message": "에러 메시지",
  "errors": { ... }
}

13.3 i18n 메시지 키

// 성공 메시지 (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의 글로벌 스코프에 의해 자동으로 현재 테넌트의 데이터만 조회됩니다.

// BelongsToTenant trait의 글로벌 스코프
// → SELECT * FROM esign_contracts WHERE tenant_id = {현재 테넌트}

14.2 비인증 접근 시 (공개 서명)

서명자(B)는 로그인 없이 토큰 기반으로 접근하므로, 테넌트 스코프를 우회해야 합니다.

// 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 의존성

<!-- 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 헤더를 반환하여 전체 페이지를 로드합니다.

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