GitHub

리액트로 전자서명 만들기 [ react-signature-canvas nextjs ] feat : 폐쇄망

hojun lee · 11/09/2025
커버이미지

react-signature-canvas PWA 적용 완벽 가이드: Dynamic Import부터 오프라인 Base64 저장까지

Next.js output: 'export' 빌드가 터졌다고? 🤯 99% 이 캔버스 라이브러리 때문입니다

오프라인(비행 중) 환경에서도 동작해야 하는 태블릿 POS용 PWA를 Next.js 15 App Router로 개발하는 중. 배포 편의성과 네이티브 래핑을 위해 output: 'export' (정적 내보내기) 모드를 사용하고 있다.

문제인식

ㅇㅇ항공 : 기존에 인쇄한 종이에 서명했던 것을 온라인으로 옮겨야합니다.

"인수인계" 기능에 꼭 필요한 '터치 서명' 기능을 구현하기 위해 react-signature-canvas를 도입했는데, 이 녀석이 next build를 계속 터뜨리는 주범이었다. TypeError: a is not a function 같은 알 수 없는 프리렌더 에러와 함께 나를 귀찮게 한다.

오늘은 이 문제를 해결한 방법과, 오프라인 환경에서 서명 데이터를 IndexedDB에 저장하는 실전 구현까지 해본다.


왜 react-signature-canvas인가?

서명 기능을 직접 개발? <canvas> API는 생각보다 복잡하다. 직접하는건 의미가 없다.(있을수도) touchstart, touchmove, touchend 이벤트를 처리하고, 서명의 속도에 따라 획 굵기를 조절(스무딩)하는 등... 이걸 다 구현하는 건 배보다 배꼽이 더 큰 일이다.

react-signature-canvas는 이 모든 것을 해결해 준다. (감사감사)

  • 신뢰성: signature_pad라는, 업계 표준처럼 쓰이는 검증된 라이브러리의 React 래퍼(Wrapper)
  • 간편함: 터치, 펜 입력을 완벽하게 지원하며 API가 매우 간단.
  • 오프라인 최적화: 100% 클라이언트 사이드에서 동작합니다. 인터넷 연결이 전혀 필요 없음.

npm install react-signature-canvas

TypeScript 환경이라면 타입 정의도 함께 설치

npm install react-signature-canvas
npm install -D @types/react-signature-canvas

타입스크립트의 경우 @types/react-signature-canvas를 반드시 설치해야한다.


왜 output: 'export'에선 dynamic import가 필수인가?

nextjs의 축복이자 프리렌더를 모르는 사람에겐 재앙

next build가 터졌던 이유는 프리렌더링(Prerendering) 때문.

output: 'export' 모드는 빌드 시점에 모든 페이지를 미리 HTML로 렌더링한다. 이 과정은 브라우저가 아닌 Node.js 환경에서 실행된다.

하지만 react-signature-canvas는 내부적으로 <canvas>를 그리고 window 객체에 접근하는 등, 브라우저 전용 API에 의존한다. Node.js 환경에는 windowdocument가 없으니, 프리렌더링 단계에서 ... is not a function 같은 에러를 내뱉으며 빌드가 멈춰버린다.

"어? 저는 페이지 상단에 'use client' 붙였는데요?"

하지만 'use client'는 "이 컴포넌트를 서버에서 렌더링하지 마"라는 뜻이 아아니다. "초기 HTML은 서버에서 렌더링하되, 클라이언트에서 JS로 상호작용(Hydration)하게 해줘"라는 뜻이다. (대충 이해하자^^)

이 문제를 해결하는 방법은 Next.js에게 **"이 컴포넌트는 프리렌더링(SSR)에서 아예 제외하고, 오직 브라우저에서만 불러와!"**라고 명시적으로 알려주는 것이다.

바로 next/dynamicssr: false 옵션.


Dynamic Import로 빌드 에러 해결하기

'use client';

import { useRef } from 'react';
import { Button, Group } from '@mantine/core';
import dynamic from 'next/dynamic'; // 1. dynamic import
import type SignatureCanvas from 'react-signature-canvas';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';

// 2. ssr: false 옵션으로 프리렌더링(빌드) 시도를 막습니다.
const SignaturePad = dynamic(
  () => import('react-signature-canvas'), // ⭐️ 라이브러리 직접 임포트
  { 
    ssr: false, // ⭐️ 빌드 에러 해결의 핵심
    loading: () => <LoadingSpinner /> // 로드되는 동안 보여줄 UI
  }
);

export default function HandoverPage() {
  const sigPadRef = useRef<SignatureCanvas>(null);

  // ... (저장 로직은 다음 섹션에서) ...
  
  return (
    <div>
      <h2>서명</h2>
      <SignaturePad
        ref={sigPadRef}
        penColor="black"
        canvasProps={{ width: 500, height: 200, className: 'sigCanvas' }}
      />
      {/* ... */}
    </div>
  );
}

useRef로 DOM에 접근하는 패턴이 핵심. 타입스크립트에서는 useRef<SignatureCanvas>(null) 형태로 명확한 타입 지정 필요.


서명 데이터 저장: Base64

서명패드는 그저 그림판일 뿐, 데이터를 저장하려면 캔버스에서 값을 꺼내와야 한다. react-signature-canvas는 두 가지 방식으로 데이터를 내보낸다.

1. Base64 (Data URL) - ⭐️ 오프라인 DB 저장에 최적

방법: ref.current?.toDataURL('image/png')
결과: data:image/png;base64,iVBORw0KGgo...

왜 Base64인가?

Base64는 이미지 자체를 하나의 거대한 텍스트 문자열로 변환한 것. 이 방식은 다음과 같은 장점:

  • 그 자체로 이미지: 브라우저는 이 문자열을 이미지 파일 그 자체로 인식다. <img> 태그나 next/imagesrc 속성에 이 문자열을 넣으면 바로 이미지가 렌더링된다.
  • 그 자체로 텍스트: 본질적으로 긴 텍스트 문자열이기 때문에, IndexedDB나 JSON 객체에 아무 변환 없이 그대로 저장할 수 있습니다.
  • 브라우저 표준: 100% 브라우저 표준 기능이므로 Node.js API에 의존할 필요가 전혀 없습니다.

2. Blob (이미지 파일 객체)

방법: ref.current?.getCanvas().toBlob(...)
결과: Blob 객체

이미지를 파일로 다뤄야 할 때(예: FormData로 업로드) 사용한다. 하지만 IndexedDB에 저장했다가 나중에 API로 보낼 경우, Base64 문자열이 훨씬 다루기 편합니다.


🚨 Buffer 로직은 왜 제거해야 하는가?

초기 구현에서는 File 객체와 Blob을 거쳐 Buffer로 변환하는 복잡한 로직을 사용했다. 하지만 이 방식은 치명적인 문제가 있었음.

Buffer is not defined 에러의 원인

"Buffer는 브라우저의 표준 기능이 아니기 때문입니다."

node.js 전용이라는 말은 nextjs 서버사이드에서 해야된다는 얘기임

  • Buffer는 Node.js 전용: Buffer는 Node.js 환경(서버)에서 바이너리(이진) 데이터를 다루기 위해 만들어진 전용 기능
  • 브라우저 환경: 우리가 만드는 PWA는 사용자의 태블릿, 즉 브라우저 환경에서 실행됨. 브라우저에는 Buffer 객체가 존재하지 않으므로, Buffer.from(...) 코드를 만나는 순간 Buffer is not defined라는 에러를 내며 앱이 중단된다.
  • output: 'export': 정적 빌드 모드에서는 모든 코드가 브라우저에서 실행될 것을 전제로 하므로, Node.js 전용 기능을 사용하면 안 됨.

npm run dev 됨! npm run build 환경 안 됨


리팩토링: 복잡한 로직을 단순하게

❌ 이전 코드 (문제가 있던 방식)

// 🚫 이 코드는 브라우저에서 작동하지 않습니다
const handleSave = async () => {
  const canvas = sigPadRef.current?.getCanvas();
  
  canvas?.toBlob(async (blob) => {
    if (!blob) return;
    
    // File 객체로 변환
    const file = new File([blob], 'signature.png', { type: 'image/png' });
    
    // ArrayBuffer로 변환
    const arrayBuffer = await file.arrayBuffer();
    
    // ⚠️ Buffer는 Node.js 전용 - 브라우저에서 에러 발생!
    const buffer = Buffer.from(arrayBuffer);
    const base64 = buffer.toString('base64');
    
    await db.handovers.put({
      id: `ho_${Date.now()}`,
      signatureImage: `data:image/png;base64,${base64}`,
      createdAt: new Date(),
    });
  });
};

문제점:

  • Blob → File → ArrayBuffer → Buffer → Base64라는 불필요하게 복잡한 변환 과정
  • Buffer.from()은 Node.js API로, 브라우저에서 ReferenceError: Buffer is not defined 에러 발생
  • PWA 환경에서 앱이 중단됨

✅ 리팩토링 후 (올바른 방식)

'use client';
import { useRef } from 'react';
import dynamic from 'next/dynamic';
import type SignatureCanvas from 'react-signature-canvas';
import { Button, Group } from '@mantine/core';
import { db } from '@/lib/db'; // Dexie (가정)

const SignaturePad = dynamic(
  () => import('react-signature-canvas'),
  { ssr: false }
);

export default function HandoverPage() {
  const sigPadRef = useRef<SignatureCanvas>(null);

  const handleSave = async () => {
    // 1. 서명이 비었는지 확인
    if (sigPadRef.current?.isEmpty()) {
      alert("서명을 입력해주세요.");
      return;
    }

    // 2. ⭐️ toDataURL()로 Base64를 직접 추출 (단 한 줄!)
    const signatureData = sigPadRef.current.toDataURL('image/png');

    try {
      // 3. IndexedDB에 그대로 저장
      await db.handovers.put({
        id: `ho_${Date.now()}`,
        signatureImage: signatureData, // ⭐️ Base64 문자열 저장
        createdAt: new Date(),
      });
      
      alert("서명이 로컬(IndexedDB)에 안전하게 저장되었습니다.");
      sigPadRef.current.clear();
      
    } catch (error) {
      console.error('IndexedDB 저장 실패:', error);
      alert('오프라인 저장 중 오류가 발생했습니다.');
    }
  };

  const handleClear = () => {
    sigPadRef.current?.clear();
  };

  return (
    <div>
      <h2>인수인계서 서명</h2>
      <SignaturePad
        ref={sigPadRef}
        penColor="black"
        canvasProps={{ width: 500, height: 200, className: 'sigCanvas' }}
      />
      <Group mt="md">
        <Button variant="default" onClick={handleClear}>다시 서명</Button>
        <Button onClick={handleSave}>서명 완료 및 저장</Button>
      </Group>
    </div>
  );
}

개선점:

  • 단 한 줄로 Base64 추출: toDataURL() 메서드 하나면 충분
  • Buffer 의존성 제거: 브라우저 표준 API만 사용하여 Buffer is not defined 에러 완전히 해결
  • 100% 브라우저 호환: PWA 오프라인 환경에서 안전하게 작동

리팩토링으로 달라진 점 정리

구분 이전 코드 (❌) 리팩토링 후 (✅)
변환 과정 Blob → File → ArrayBuffer → Buffer → Base64 toDataURL() 한 줄로 즉시 Base64
코드 길이 ~15줄 (복잡한 변환 로직) ~3줄 (간결함)
브라우저 호환성 ❌ Buffer is not defined 에러 발생 ✅ 100% 브라우저 표준 API
PWA 오프라인 동작 ❌ 앱 중단 ✅ 완벽하게 작동
유지보수성 ❌ 복잡한 로직, 이해하기 어려움 ✅ 직관적이고 명확함

저장된 Base64 이미지 미리보기

IndexedDB에 저장된 서명을 불러와서 화면에 표시하는 것도 간단

import Image from 'next/image';

// IndexedDB에서 불러온 데이터 (가정)
const handover = await db.handovers.get(handoverId);

// ⭐️ Base64 문자열을 그대로 src에 넣으면 끝!
<Image 
  src={handover.signatureImage} 
  alt="서명 이미지"
  width={500}
  height={200}
/>

별도의 URL.createObjectURL() 같은 변환이 필요 없다. Base64 문자열 자체가 이미지 URL이기 때문


주요 Props 커스터마이징

<SignaturePad
  ref={sigPadRef}
  penColor="black"              // 펜 색상
  minWidth={0.5}                // 최소 선 두께
  maxWidth={2.5}                // 최대 선 두께
  velocityFilterWeight={0.7}    // 속도 필터 가중치
  canvasProps={{ 
    width: 500, 
    height: 200, 
    className: 'sigCanvas' 
  }}
/>

velocityFilterWeight, minWidth, maxWidth, dotSize 등 다양한 프로퍼티로 서명 느낌을 세밀하게 조정할 수 있음.


CSS로 반응형 캔버스 만들기

.canvasContainer {
  width: 70%;
  height: 70%;
}

.sigCanvas {
  width: 100%;
  height: 100%;
  border: 1px solid #ccc;
  border-radius: 8px;
}

부모 컨테이너의 크기를 먼저 지정하고, 캔버스가 width: 100%, height: 100%를 따라가도록 설정하면 반응형 레이아웃을 구현할 수 있다.


요약

react-signature-canvas는 오프라인 서명 기능에 무조건 선택해야함.

Next.js output: 'export' 빌드 에러는 next/dynamic과 **{ ssr: false }**로 완벽하게 해결할 수 있음.

서명 데이터는 **toDataURL() (Base64)**로 추출하여 IndexedDB에 '문자열'로 저장하는 것이 오프라인 동기화에 가장 유리하다.

Buffer를 사용하면 안 됨: Buffer는 Node.js 전용 API로, 브라우저/PWA 환경에서 Buffer is not defined 에러 발생.

toDataURL() 한 줄이면 충분: 복잡한 Blob/File/Buffer 변환 없이, Base64를 직접 추출하여 사용하는 것이 가장 간결하고 안전한 방법.


결론

무엇으로 저장할 것인가 (base64 / file.img) 어떻게 저장할 것인가

개발 환경과 비즈니스 환경에 골고루 맞춰서 개발을 진행해야 한다. ( 맨날 바뀔 수 있음 )

매일 새로운 문제를 마주한다. 왜 개발이 문제해결력을 요구하는지 알겠다.

하지만 해결이 된다는 것 또한 재밌는 일이다.

폐쇄망을 벗어날 때까지 새로운 문제를 더 딥하게 알아보자.