PDF 서명 넣기, 브라우저에서 pdf-lib 로 서버 없이 하는 법

PDF 서명 넣기를 서버에 파일을 올리지 않고 브라우저 안에서 끝내는 방법이에요. pdf-lib 로 서명 이미지를 정확한 자리에 붙이는 좌표 변환과, 서버에 남길 것을 정해 사용자에게 알리는 법까지 따라 할 수 있어요.

셀루모2026.09.30 · 14분 읽기
목차

계약서나 동의서를 다루는 웹 도구를 만들고 있다면, 첫 질문은 기능이 아니라 파일이 어디로 가느냐예요. 이 글은 PDF 를 서버에 올리지 않고 브라우저 안에서 서명 이미지를 붙여 내려받게 하는 방법을 순서대로 정리해요.

여기서 만드는 결과물은 서명 그림을 PDF 위에 올린 파일이에요. 본인 확인이나 위조 방지 장치가 들어간, 법적 효력이 있는 전자서명과는 다르다는 점을 먼저 짚고 갈게요.

혹시 이런 고민 있으신가요?

  • 사용자가 올린 계약서를 서버에 두는 게 찜찜한데, 서버 없이 PDF 를 고칠 수 있는지 모르겠어요.
  • 화면에서 맞춘 자리에 서명을 놓았는데, 내려받은 PDF 에서는 엉뚱한 곳에 찍혀 나와요.
  • 파일을 남기지 않는다고 말하고 싶은데, 어디에 어떻게 써야 믿어 줄지 모르겠어요.

이 글을 읽으면

  • 어떤 처리를 브라우저에 두고 어떤 처리를 서버에 둘지 기준을 세울 수 있어요.
  • pdf-lib 로 PDF 를 열고, 이미지를 정확한 좌표에 붙이고, 새 파일로 내려받게 하는 코드를 쓸 수 있어요.
  • 서버에 남는 것의 목록을 정하고, 그 약속을 화면과 방침에 같은 문장으로 적을 수 있어요.

1단계. 브라우저와 서버의 일을 나누기

웹 서비스의 일은 두 곳에서 일어날 수 있어요. 사용자의 컴퓨터에서 도는 브라우저와, 저희가 운영하는 서버예요. 파일을 서버에서 처리하려면 먼저 인터넷으로 서버에 보내야 하고, 브라우저에서 처리하면 파일이 사용자의 컴퓨터를 떠나지 않아요.

그래서 처리마다 "이 일에 서버가 꼭 필요한가"를 물어요. 필요하지 않다면 파일은 브라우저 밖으로 나갈 이유가 없어요. 보내지 않은 편지는 도중에 뜯길 일도, 버릴 일도 없는 것과 같아요.

기준은 단순해요. 계산에 무거운 모델이나 영상처리 라이브러리가 필요하면 서버에서, 파일을 열고 그리고 합치는 일이면 브라우저에서 해요. PDF 에 이미지를 붙이는 일은 뒤쪽이에요.

저희 서명 서비스를 예로 들면, 일이 이렇게 나뉘어 있어요.

처리어디서이유
서명, 도장 사진의 배경 지우기서버조명 보정과 잉크 농도 계산에 영상처리 라이브러리를 써요
PDF 를 화면에 그리기브라우저react-pdf 가 pdf.js 로 그려요
서명 이미지를 PDF 에 넣어 새 파일 만들기브라우저pdf-lib 가 해요
저장을 누른 서명, 도장, 완성 PDF 보관서버사용자가 다시 쓰려고 고른 것만 올라가요

서버로 가야 하는 일도 흔적을 최소로 남길 수 있어요. 저희의 배경 지우기는 업로드를 디스크에 쓰지 않고 메모리에만 받아서, 디코딩하고 처리하고 다시 인코딩한 결과만 돌려줘요. 원본 사진도 결과도 저장소에 쓰지 않아요. 메모리는 요청이 끝나면 비워지는 임시 작업대이고, 디스크는 전원을 꺼도 남는 서랍이라고 생각하면 돼요.

PDF 열기, 합성, 내려받기는 사용자 브라우저 안에서 일어나고, 서버는 서명 사진의 배경을 메모리에서만 지워 돌려주며 저장을 누른 것만 보관하는 흐름PDF 는 브라우저 안에만 있고, 서버에는 사진 처리와 저장을 누른 것만 가요

TIP

서버가 파일을 받아야 한다면, 업로드 처리기가 파일을 어디에 두는지 코드 주석으로 적어 두세요. 저희 API 는 업로드 미들웨어(요청이 본래 처리 코드에 닿기 전에 파일을 받아 주는 중간 단계)에 저장소를 따로 지정하지 않아 파일을 메모리에만 받고, 그 사실을 바로 그 자리에 적어 두었어요. 나중에 누가 설정을 바꿔도 약속이 무엇이었는지 코드에서 보여요.

2단계. 브라우저에서 PDF 를 열고 한도부터 확인하기

브라우저에서 PDF 를 다루려면 두 라이브러리가 필요해요. 화면에 그리는 쪽은 pdf.js 로, 브라우저에서 PDF 를 그림처럼 보여 주는 무료 라이브러리예요. React(화면을 부품 단위로 만드는 자바스크립트 라이브러리)에서는 react-pdf 로 감싸 쓰는 경우가 많아요. 내용을 고쳐 새 파일로 만드는 쪽은 pdf-lib 로, 브라우저에서 PDF 를 만들고 고칠 수 있는 라이브러리예요. 둘은 역할이 달라서 함께 써요.

두 라이브러리는 가볍지 않아요. 그래서 PDF 를 여는 화면에서만 react-pdf 와 pdf.js 워커(화면을 멈추지 않도록 뒤에서 따로 도는 작업 스레드)를 불러오고, pdf-lib 는 합성을 처음 할 때 따로 떨어진 묶음으로 불러와요. 첫 화면을 여는 사용자는 이 비용을 내지 않아요.

브라우저 처리의 한계는 사용자 기기의 메모리예요. pdf-lib 가 문서를 불러와 저장하는 동안에는 원본, 파싱(파일을 읽어 프로그램이 다룰 수 있는 구조로 푸는 일)한 구조, 새로 만든 출력을 동시에 들고 있어서 순간적으로 원본의 몇 배를 쓸 수 있어요. 그래서 크기와 쪽수에 상한을 두고, 파일을 고른 그 자리에서 확인하는 게 좋아요.

저희의 상한은 50MiB(메비바이트, 약 52MB)와 2,000쪽이에요. 화면 없이 도는 크롬 브라우저인 헤드리스 Chromium 에서 CPU(중앙처리장치) 속도를 4배 느리게 걸어 약한 기기를 흉내 내고, 쪽수와 크기를 늘려 가며 느려지는 지점을 잰 뒤 여유를 두고 정했어요. 바이트는 파일을 읽기 전에 파일 객체의 크기로, 쪽수는 pdf.js 가 문서를 연 직후에 비교해요.

참고

이 측정은 느린 CPU 만 흉내 냈고, 저사양 기기의 실제 메모리 상한은 재지 못했어요. 그래서 잰 값보다 낮게 잡았어요. 여러분의 서비스에서도 숫자를 감으로 정하지 말고, 대상 기기에 가까운 조건에서 재 보는 것을 권해요.

암호가 걸린 PDF 도 여는 자리에서 걸러요. pdf-lib 는 문서에 암호화 정보가 있으면 불러오기를 거부해요. 이걸 저장을 누를 때 알게 되면, 사용자는 위치를 맞추고 크기를 조절한 작업을 전부 잃어요. pdf.js 는 문서를 열면서 메타데이터(문서에 대한 설명 정보)의 EncryptFilterName 값으로 암호화 여부를 이미 알려 주니, 새로 파싱할 필요 없이 그 값을 읽으면 돼요. 여는 암호까지 걸린 문서는 pdf.js 가 PasswordException 이라는 오류를 던지니 그 경우도 같은 자리에서 안내해요.

TIP

실패할 수 있는 조건은 사용자가 공을 들이기 전에 알려 주세요. 합성 단계의 검사는 그대로 두고, 여는 단계에 한 번 더 두면 돼요. 앞의 검사가 놓친 경우도 뒤에서 이유를 말할 수 있어요.

3단계. 화면 좌표를 PDF 좌표로 바꾸기

여기서 가장 많이 틀려요. 좌표는 "어디에"를 숫자로 적는 방법인데, 화면과 PDF 는 그 약속이 달라요. 같은 건물이라도 한 지도는 북쪽이 위이고 다른 지도는 남쪽이 위라면, 한 지도의 위치를 다른 지도에 옮길 때 뒤집어야 하는 것과 같아요.

구분화면PDF
원점왼쪽 위왼쪽 아래
단위CSS 픽셀사용자 공간 단위, 보통 포인트
보이는 영역그려진 쪽 전체CropBox, 원점이 0 이 아닐 수 있어요
회전이미 돌려서 보여 줘요/Rotate 값으로 따로 가져요

표의 낱말을 풀면 이래요. CSS 픽셀은 웹 페이지가 크기를 잴 때 쓰는 단위예요. 포인트는 인쇄에서 쓰는 단위로, 1포인트가 1/72인치예요. CropBox 는 PDF 한 쪽 가운데 실제로 보여 줄 영역을 정한 상자로, 종이를 재단한 자리라고 보면 돼요. 이 상자가 쪽의 모서리에서 시작하지 않을 수 있어서, 시작점을 더해 줘야 해요. /Rotate 는 쪽을 몇 도 돌려 보여 줄지 적어 둔 값이에요.

변환에 필요한 값은 셋이에요. pdf.js 가 준 보이는 영역 [x0, y0, x1, y1], 쪽의 회전값, 그리고 화면 픽셀을 PDF 단위로 나누는 배율이에요. 배율은 화면에 그려진 너비를 PDF 쪽 너비로 나눠 구해요. 저희는 화면이 쓴 이 값을 그대로 합성에 넘겨서, 상자가 보였던 자리에 이미지가 떨어지게 해요.

화면은 왼쪽 위가 원점이고 y 가 아래로 커지며, PDF 는 왼쪽 아래가 원점이고 y 가 위로 커져서, 상자의 왼쪽 위 모서리가 PDF 에서는 왼쪽 아래 모서리로 바뀌는 좌표 변환배율로 나누고 위아래를 뒤집으면 화면의 상자가 PDF 의 같은 자리에 떨어져요

회전이 없는 쪽이라면 변환은 이렇게 돼요. 아래는 서명 이미지 하나를 여러 쪽에 붙여 새 PDF 로 내려받게 하는 최소 예제예요.

TS
import { PDFDocument } from "pdf-lib" type Box = { x: number; y: number; width: number; height: number }type Placement = {  pageIndex: number // 0 부터  box: Box // 화면에서 잰 상자, CSS 픽셀, 왼쪽 위 원점  view: [number, number, number, number] // pdf.js 가 준 [x0, y0, x1, y1]  scale: number // 화면 픽셀 / PDF 단위} // /Rotate 가 0 인 쪽만 다뤄요. 원점을 뒤집고, CropBox 의 시작점을 더해요.function toPdfRect({ box, view, scale }: Placement): Box {  const [x0, , , y1] = view  const width = box.width / scale  const height = box.height / scale  return { x: x0 + box.x / scale, y: y1 - box.y / scale - height, width, height }} export async function placeSignature(pdf: Uint8Array, png: Uint8Array, placements: Placement[]) {  const doc = await PDFDocument.load(pdf)  const image = await doc.embedPng(png) // 한 번만 넣고 여러 쪽에서 다시 써요  for (const placement of placements) {    doc.getPage(placement.pageIndex).drawImage(image, toPdfRect(placement))  }  const bytes = (await doc.save()) as Uint8Array<ArrayBuffer> // 최신 TypeScript 의 Blob 타입에 맞춰요  const url = URL.createObjectURL(new Blob([bytes], { type: "application/pdf" }))  const link = document.createElement("a")  link.href = url  link.download = "signed.pdf"  link.click()  setTimeout(() => URL.revokeObjectURL(url), 60_000) // 느린 브라우저를 위해 늦게 풀어요}

코드의 흐름은 네 단계예요. 원본 PDF 를 불러오고, 서명 PNG 를 문서에 한 번 넣고, 쪽마다 변환한 자리에 그 이미지를 그리고, 새 파일을 만들어 내려받기 링크를 눌러 줘요. 이 모든 일이 브라우저 안에서 일어나서, 파일은 서버로 가지 않아요.

실제 문서에서는 여기에 두 가지가 더해져요. 첫째, 쪽에 /Rotate 가 90 이나 270 이면 화면의 너비와 높이가 PDF 에서는 서로 바뀌어요. 둘째, 사용자가 이미지를 돌렸다면 방향이 반대예요. CSS 의 rotate 는 상자 가운데를 축으로 시계 방향으로 돌리지만, pdf-lib 의 drawImage 는 왼쪽 아래 모서리를 축으로 반시계 방향으로 돌려요. 그래서 상자 가운데를 먼저 구하고, 쪽의 회전에서 이미지의 회전을 뺀 각도로 왼쪽 아래 모서리 자리를 다시 계산해야 해요.

TIP

변환 함수는 PDF 라이브러리를 들이지 않는 순수 함수(같은 입력에 늘 같은 결과를 내고 바깥을 건드리지 않는 함수)로 따로 두고, 회전 0, 90, 180, 270 과 CropBox 원점이 0 이 아닌 쪽을 pdf.js 가 읽은 값과 맞춰 보는 테스트를 붙이세요. 저희도 이렇게 해 두어서, 화면과 결과가 어긋나는 버그를 브라우저를 열지 않고 잡아요.

pdf-lib 의 오류 판별에도 함정이 하나 있어요. 1.17.1 에서는 필요할 때 따로 불러온 모듈이 내보내는 EncryptedPDFError 와 실제로 던져진 오류의 뿌리가 맞지 않아, "이 오류가 그 종류인가"를 묻는 instanceof 검사가 빗나가는 경우가 있었어요. 저희는 instanceof 와 함께 오류 메시지에 "is encrypted" 가 들어 있는지도 봐요.

4단계. 서버에 남는 것의 목록을 정하기

브라우저에서 합성해도 서버에 남는 것은 생겨요. 사용자가 다시 쓰고 싶어 하는 것, 오류 기록, 분석 이벤트(사용자가 어떤 단계를 지났는지 남기는 기록)예요. 셋 다 미리 목록을 정해 두지 않으면 조금씩 늘어나요.

저희가 정한 목록은 이래요.

대상서버에서의 처리
촬영한 원본 사진, 배경을 지운 결과요청을 처리하는 동안 메모리에만 있어요
열어 본 PDF, 템플릿에 채운 내용서버로 보내지 않아요
저장을 누른 서명, 도장, 완성 PDF객체 저장소에 보관해요. 목록에서 지우면 서버에서도 바로 지워요

객체 저장소는 파일을 이름표를 붙여 통째로 넣고 꺼내는 파일 보관 창고 같은 서비스예요. 데이터베이스와 따로 두어 큰 파일을 다루기 쉬워요.

무료 사용자의 저장 한도는 서명 5개, 도장 5개, 완성 PDF 20개예요. 붙이고 내려받는 흐름 자체는 무료이고 워터마크(파일 위에 겹쳐 찍는 표시)도 없어요.

오류 기록과 이벤트에서 문서 내용이 새지 않게 하는 것도 중요해요. 예외 메시지에는 파일 이름이나 문서 속 글자가 섞여 들어오기 쉬워요. 저희 오류 기록에는 미리 정한 실패 코드, 예외 클래스 이름, 쪽수와 바이트 같은 숫자만 남아요. 합성 실패 이벤트도 이유, 저장과 내려받기 중 어느 길이었는지, 붙인 이미지 개수, 쪽수, 크기만 담아요. 쪽수는 문서에 대한 숫자이지 문서의 내용이 아니라서 넣어요.

참고

이 방식은 서버가 원본을 들여다볼 수 없으니, 사용자가 겪은 오류를 원본으로 재현할 수는 없어요. 실패 이유를 코드로 잘게 나눠 두는 것이 그 빈자리를 메워요.

5단계. 보관 방침을 한 문장으로 쓰고 모든 곳에 같은 문장을 두기

지키는 것만큼 알리는 것도 중요해요. 사용자가 "이 파일은 어디로 가나요"를 궁금해하는 순간은 파일을 고르기 직전이에요.

저희는 보관 방침을 한 문장으로 정해 한 곳에 두고, 여러 화면이 그 문장을 가져다 써요.

TEXT
촬영한 원본 사진과 배경을 지운 결과는 남기지 않고, PDF는 저장을 누를 때만 보관하며,문서 템플릿 파일은 저희가 미리 만들어 제공하고 채운 내용은 아무것도 보관하지 않습니다.

이 문장은 사진을 올리는 자리, 서비스 소개, 계정 화면, 개인정보처리방침의 "남기지 않는 것" 조항에 똑같이 들어가요. PDF 를 여는 화면에는 "PDF는 이 브라우저 안에서만 열고 서버로 보내지 않습니다. 저장을 누를 때만 보관합니다." 라는 안내가 따로 붙어요.

TIP

방침 문장을 화면마다 따로 쓰지 말고 상수(코드에서 한 번 정해 두고 여러 곳이 가져다 쓰는 고정 값) 하나로 두세요. 한 곳만 고쳐지면 화면과 방침이 서로 다른 말을 하게 되고, 그러면 두 문장 모두 믿기 어려워질 거라고 봐요.

자주 묻는 질문

Q1. 이렇게 만든 PDF 는 법적 효력이 있는 전자서명인가요?

아니에요. 이 방법은 서명 이미지를 PDF 에 그려 넣는 것이에요. 본인인증, 감사추적(누가 언제 무엇을 했는지 바꿀 수 없게 남기는 기록), 상대방에게 서명 요청을 보내는 기능이 없고, 저희도 이 셋을 일부러 넣지 않았어요. 법적 효력이 필요한 계약이라면 그 요건을 갖춘 전자계약 서비스를 쓰세요.

Q2. 큰 PDF 는 결국 서버에서 처리해야 하지 않나요?

그 선택지도 있지만, 그 순간 민감한 문서를 서버가 받게 돼요. 저희는 상한을 두고 넘는 파일은 여는 자리에서 이유와 함께 거절하는 쪽을 골랐어요. 대용량 처리가 정말 필요해지면 그때 보관 방침과 함께 다시 정하는 게 순서예요.

Q3. react-pdf 없이 pdf-lib 만으로는 안 되나요?

pdf-lib 는 PDF 를 만들고 고치는 라이브러리이고 화면에 그리는 기능은 없어요. 사용자가 위치를 고르려면 쪽이 보여야 하니, 그리기는 pdf.js 계열에 맡기고 합성은 pdf-lib 에 맡기는 조합이 자연스러워요.

정리

  • 서버가 꼭 필요한 처리만 서버에 두고, 여는 일과 합치는 일은 브라우저에서 해요.
  • 크기, 쪽수, 암호 여부는 파일을 고른 자리에서 확인해 사용자의 작업이 버려지지 않게 해요.
  • 좌표는 원점, 단위, CropBox, 회전 네 가지를 모두 맞추고, 변환 함수는 테스트로 지켜요.
  • 서버에 남는 것의 목록을 정하고, 오류 기록과 이벤트에도 문서 내용이 들어가지 않게 해요.
  • 보관 방침은 한 문장으로 두고 모든 화면과 방침에 그대로 보여 줘요.

셀루도는 서명이나 도장 사진의 배경을 지우고, 그 이미지를 브라우저 안에서 PDF 에 붙여 내려받게 해 주는 도구예요. 법적 효력이 있는 전자서명이 아니라 서명 이미지를 넣는 도구라는 점만 기억해 주세요.

이어서 읽기 좋은 글