목차
계약서나 신청서 양식을 한 번 만들어 두고, 여러 사람이 이름과 날짜만 채워 쓰게 하고 싶은 분들이 많아요. 화면은 금방 만들 수 있는데, 정작 막히는 곳은 그 양식을 어디에 어떤 모양으로 저장하느냐예요.
이 글은 PDF 양식과 칸의 위치를 저장하는 쪽을 다뤄요. 칸에 한글을 채우는 방법이 아니라, 저장할 데이터의 모양, 서버가 걸러야 할 값, 양식마다 공개 범위를 다르게 두는 법, 검색에 노출하는 주소를 나누는 법이에요.
혹시 이런 고민 있으신가요?
- 칸 위치를 저장할 테이블을 칸마다 한 행으로 쪼갤지, 한 덩어리로 둘지 정하지 못했어요.
- 잘못된 좌표가 저장돼서, 채우는 화면에서야 칸이 페이지 밖으로 나간 걸 알았어요.
- 어떤 양식은 누구나, 어떤 양식은 로그인한 사람만 쓰게 하고 싶은데 어디서 나눠야 할지 모르겠어요.
이 글을 읽으면
- 칸 목록을 JSON 한 칼럼에 담을 때의 이득과 대가를 따져서 고를 수 있어요.
- 업로드된 PDF 를 서버에서 열어 쪽 번호와 좌표를 검사하는 코드를 짤 수 있어요.
- 공개 범위 세 단계와 검색용 소개 주소를 나누는 규칙을 그대로 가져다 쓸 수 있어요.
1단계. 칸 목록은 JSON 한 칼럼에 담기
양식 하나에는 보통 칸이 열 개 안팎 있어요. 이 칸들을 어떻게 저장할지가 첫 선택이에요. 길은 둘이에요. 칸마다 행을 갖는 테이블을 따로 만들거나, 양식 행에 JSON 칼럼 하나를 두고 칸 배열을 통째로 넣거나요.
JSON 은 자바스크립트 객체를 글자로 적는 형식이에요. 데이터베이스 중에는 이 JSON 을 칼럼 하나에 담고 안쪽 값을 조회할 수 있게 해 주는 것이 있어요. PostgreSQL 의 JSONB 가 그런 칼럼이에요.
기준은 하나예요. 칸을 양식 없이 따로 읽거나 쓰는 일이 있느냐예요. 칸은 언제나 양식 한 벌과 함께 불러오고, 함께 저장돼요. 칸 하나만 조회하거나 칸을 가로질러 집계하는 질문이 기능 어디에도 없었어요. 그래서 저희는 한 칼럼으로 정했어요.
| 선택 | 얻는 것 | 치르는 것 |
|---|---|---|
| 칸마다 행 | 데이터베이스가 모양을 강제해요. 칸 단위로 질의할 수 있어요 | 조인, 순서 칼럼, 삭제 연쇄가 따라와요. 아직 필요 없는 질의를 위해서예요 |
| JSON 한 칼럼 | 양식을 한 번에 읽고 한 번에 써요. 테이블이 하나예요 | 모양 검사를 전부 애플리케이션이 져요. 나중에 칸 단위로 거르려면 JSON 연산자를 쓰거나 테이블을 쪼개야 해요 |
되돌릴 길도 미리 확인했어요. 배열을 행으로 펼치는 마이그레이션 하나면 되고, 값이 그대로 옮겨져서 잃는 데이터가 없어요. 되돌리기 쉬운 선택은 틀려도 싸요.
칸 하나의 모양은 이 정도면 충분했어요.
{ "id": "party-a-name", "page": 1, "rect": { "x": 120, "y": 640, "width": 180, "height": 24 }, "kind": "text", "label": "갑 이름", "fontSize": 11, "align": "left"}여기서 kind 는 칸의 종류예요. 글자를 받는 text 와 도장 이미지를 받는 stamp 두 가지를 뒀어요. 도장 자리를 위한 두 번째 목록을 만들지 않은 이유는 좌표가 같기 때문이에요. 쪽 번호와 사각형은 같고, 합성할 때 글자를 그리느냐 이미지를 그리느냐만 갈려요. 좌표 변환 코드가 두 벌이 되면 한쪽만 고치는 버그가 생겨요. 체크박스나 날짜 칸이 필요해지면 kind 에 값을 하나 더하면 돼요.
label 은 채우는 사람이 아니라 양식을 만드는 사람을 위한 이름표예요. 칸이 비어 있을 때 상자 안에 보여 줘요. "이름", "계약일", "도장" 같은 말이에요.
TIP
칸마다 value 를 저장하지 마세요. 양식 템플릿에는 칸의 위치와 모양만 두고, 사용자가 채운 값은 브라우저에서 PDF 를 만들 때만 쓰고 버리는 편이 안전해요. 이름, 주소, 계좌번호가 섞이기 쉬운 자리예요.
2단계. 서버에서 PDF 를 열어 쪽 번호와 좌표를 검사하기
JSON 칼럼의 약점이 여기서 나와요. 데이터베이스가 모양을 강제하지 않으니 저장 전에 직접 검사해야 해요. 저희는 TypeScript 의 zod 라는 검증 라이브러리로 칸의 모양을 검사해요.
모양 검사만으로는 부족해요. "쪽 번호는 1 이상 전체 쪽수 이하", "사각형은 그 쪽 안에" 라는 규칙은 PDF 파일을 열어 봐야 알 수 있어요. 쪽수와 쪽 크기는 데이터베이스에 따로 없고 PDF 안에만 있기 때문이에요.
그래서 서버가 업로드된 PDF 를 pdf-lib 로 열어 쪽마다 크기를 읽고, 그 값으로 검사 규칙을 요청마다 만들어요.
export function fieldsSchema(pageSizes: { width: number; height: number }[]) { return z.array(fieldShapeSchema).superRefine((fields, ctx) => { fields.forEach((field, index) => { if (field.page < 1 || field.page > pageSizes.length) { ctx.addIssue({ code: "custom", path: [index, "page"], message: `page must be between 1 and ${pageSizes.length}`, }) return } const size = pageSizes[field.page - 1]! const { x, y, width, height } = field.rect if (x < 0 || y < 0 || x + width > size.width || y + height > size.height) { ctx.addIssue({ code: "custom", path: [index, "rect"], message: "rect must be inside the page" }) } }) })}몇 가지를 지켜 두면 좋아요.
- 파일의 첫 바이트가 PDF 표식인지 먼저 보고, 열리지 않으면 400 으로 이유를 돌려줘요. 확장자만 믿지 않아요.
- 칸 목록만 고치는 요청에서도 저장해 둔 PDF 를 다시 받아 같은 검사를 해요. 쪽 크기를 따로 저장하는 칼럼을 늘리는 길도 있지만, 값이 둘로 갈라지는 쪽보다 매번 파일에서 읽는 쪽이 어긋날 일이 적다고 판단했어요.
- 오류에는 어느 칸의 어느 속성인지 경로를 실어요. 편집 화면이 그 칸을 바로 빨갛게 칠할 수 있어요.
참고
쪽을 돌려 둔 PDF 는 가로세로가 뒤바뀌어 보이고, 잘라낸 영역이 따로 있는 PDF 도 있어요. 라이브러리가 돌려주는 쪽 크기가 화면에 보이는 크기와 같은지 스캔본이나 가로 양식으로 한 번씩 확인하세요.
PDF 를 먼저 열어 쪽 크기를 읽고, 그 값으로 칸 목록을 검사한 뒤에 저장해요
3단계. 파일 저장과 행 저장의 순서를 정하기
PDF 파일은 오브젝트 스토리지에, 칸과 이름은 데이터베이스에 두면 저장이 두 곳에 걸쳐요. 한쪽만 성공하면 주인 없는 파일이나 파일 없는 행이 남아요.
순서는 이렇게 잡았어요. 먼저 파일을 올려요. 그다음 행을 만들어요. 행 만들기가 실패하면 방금 올린 파일을 지워요. 반대로 행을 먼저 만들면 파일이 없는 양식이 목록에 잠깐 보일 수 있어요. 파일 이름은 행의 번호로 만들어서, 지우기 쉽고 겹치지 않게 해요.
양식을 지울 때도 행과 파일을 함께 지워요. 나중에 청소할 계획은 세워 두지 않으면 영영 안 돼요.
여러분의 경우에 바꿀 점은 하나예요. 파일을 데이터베이스에 넣을 수 있는 작은 서비스라면 이 단계 자체가 필요 없어요. 파일이 커지거나 여러 쪽 PDF 가 들어오기 시작하면 그때 나눠도 늦지 않아요.
4단계. 공개 범위를 양식마다 세 단계로 두기
양식마다 누가 쓸 수 있는지가 다를 수 있어요. 처음부터 칼럼 하나로 두면 나중에 편해요. 이미 올린 양식을 전부 다시 분류하는 일이 가장 비싸요.
| 등급 | 뜻 | 동작 |
|---|---|---|
public | 로그인하지 않아도 열어 채워요 | 그대로 열려요 |
signed_in | 로그인해야 열려요 | 목록에는 보이고, 열 때 로그인을 요구해요 |
paid | 유료 플랜이 필요해요 | 목록에는 잠긴 채 보이고, 파일 요청은 모두 거절해요 |
유료 결제가 아직 없다면 paid 는 모든 요청을 막는 값으로 두면 돼요. 결제를 붙이는 날 그 거절 자리에 권한 확인만 넣으면 돼요. 단, 아직 살 수 없는 것을 살 수 있는 것처럼 보이게 하면 안 돼요. 잠긴 카드의 문구는 "준비 중" 정도로만 적어요.
운영자가 편집할 때는 등급에 걸리지 않게 별도 주소를 둬요. 그렇지 않으면 paid 양식은 운영자도 못 열어서 고칠 수가 없어요. 이 주소에는 운영자 권한 검사를 따로 걸어요.
TIP
목록 주소와 상세 주소의 순서를 조심하세요. 프레임워크에 따라 admin 같은 고정 문자열 경로를 번호 경로보다 먼저 선언해야 해요. 안 그러면 admin 이 번호 자리로 읽혀서 번호 형식이 아니라는 400 오류가 나요.
5단계. 소개 주소와 채우기 주소를 나누고, 주소는 고정하기
양식을 만들었다면 검색에서 찾히기를 바랄 거예요. 그런데 채우기 화면은 PDF 를 그리는 도구 화면이라 검색 엔진이 읽을 글이 제목과 설명뿐이에요. 글을 얹을 자리도 없고요.
그래서 주소를 둘로 나눴어요.
/templates/<이름>은 서버가 글을 그려서 내려주는 소개 화면이에요. 어디에 쓰는 양식인지, 어떤 칸이 있는지, 미리보기 이미지가 들어가요. 검색에 노출해요./templates/<이름>/fill은 실제로 채우는 도구 화면이에요.
목록의 카드는 클릭 이벤트로만 이동하는 버튼이 아니라 소개 화면으로 가는 링크로 만들어요. 버튼이면 검색 엔진이 따라갈 링크가 없어요. 사이트맵에도 소개 주소를 넣어요. 유료 양식은 소개 화면을 만들지 않고 색인 대상에서 빼요.
주소에 쓰는 이름은 영어 소문자와 하이픈으로 3자에서 80자까지, 번호 모양은 허용하지 않아요. 번호로 열리던 예전 주소가 이미 있다면 그 주소와 새 이름이 같은 자리를 두고 겹치면 안 되기 때문이에요. 예전 번호 주소는 이름이 있는 양식이면 새 채우기 주소로 영구 이동으로 넘겨요.
한 번 정한 이름은 바꾸지 못하게 막아요. 검색 엔진이 이미 색인했을 수 있는 주소이기 때문이에요. 바꾸려는 요청에는 충돌 오류를 돌려줘요.
미리보기 이미지는 첫 쪽을 JPEG 로 뽑아서 올려요. 서버에서 PDF 를 그리려면 렌더러를 새로 들여야 해서, 이미 첫 쪽을 그리고 있는 편집기가 저장할 때 브라우저에서 만들어 함께 올리게 했어요. 서버는 JPEG 인지, 2MB 이하인지만 확인해요. 이 이미지가 소개 화면의 공유 이미지로도 쓰여요.
목록과 소개 화면은 검색에 열려 있고, 채우기와 파일은 등급 검사를 거쳐요
TIP
소개 화면을 만들 때 첫 문단에는 "이 양식으로 무엇을 할 수 있는지"를 한 문장으로 쓰세요. 검색하는 사람이 가장 먼저 확인하는 곳이에요.
자주 묻는 질문
Q1. 남이 만든 공공 표준 양식을 올려도 되나요?
양식마다 이용 조건이 다르고, 출처 표시나 변경 금지 같은 조건이 붙는 경우가 있어요. 저희는 확인 비용을 줄이려고 직접 쓴 문서만 기본 양식으로 올리기로 했어요. 그러면 이용 조건을 확인할 대상 자체가 사라져요. 대신 표준이라는 이름이 주는 신뢰는 쓰지 못해요.
Q2. 사용자가 자기 PDF 로 만든 템플릿도 같은 테이블에 넣어도 되나요?
소유자 칼럼이 비어 있으면 운영자가 올린 기본 양식, 채워져 있으면 사용자의 양식으로 읽는 방식으로 한 테이블에 담을 수 있어요. 다만 이때는 소유자 확인과 개수 한도를 새로 정해야 해요. 이 글의 구조는 그 확장을 막지 않지만, 아직 그렇게 만들어 두지는 않았어요.
Q3. 좌표 검사를 브라우저에서만 하면 안 되나요?
편집 화면은 브라우저에서도 검사하지만 서버 검사는 따로 필요해요. 편집 화면을 거치지 않고 저장 요청을 직접 보내는 경우를 막을 곳이 서버뿐이기 때문이에요.
정리
- 칸 목록은 함께 읽고 함께 쓰는 한, JSON 한 칼럼이 가장 단순해요. 되돌리는 길도 미리 확인해 두세요.
- 모양은 검증 라이브러리로, 쪽 번호와 좌표는 PDF 를 열어 서버에서 검사해요.
- 파일을 먼저 올리고 행을 만들고, 실패하면 파일을 지워요.
- 공개 범위는 처음부터 칼럼으로 두고, 운영자용 주소는 따로 나눠요.
- 검색에 걸 소개 주소와 채우는 도구 주소를 나누고, 한 번 정한 주소는 바꾸지 않아요.
PDF 양식을 한 번 등록해 두고 여러 사람이 채우게 하는 일은 이 구조 위에서 가장 단순해져요. 셀루도에서 이런 양식을 골라 칸을 채워 PDF 로 내려받을 수 있어요.