문의 페이지에 "폼 전송이 아직 설정되지 않았습니다"라는 제목이 떠 있었다.
틀린 말은 아니었다. 폼 서비스의 키 없이 빌드하면 폼 대신 이메일 안내를 띄우도록 내가 만들어 뒀다. 조용히 실패하는 폼보다는 낫다고 생각했다. 그런데 그 문장을 읽는 사람은 나 같은 개발자가 아니라 처음 회사를 찾아온 방문자다. 방문자에게 "설정되지 않았습니다"는 이 회사 홈페이지가 덜 만들어졌다는 말로 읽힌다.
방문자가 읽을 문장이 아니었다
애드소프트 홈페이지는 Astro 로 빌드한 정적 사이트다. 서버에서 돌아가는 코드가 없으니 문의 폼을 받아 줄 곳도 없다. 그래서 전송은 외부 폼 서비스인 Web3Forms 에 맡겼다. 폼 내용을 그 서비스의 API 로 POST 하면, 서비스가 미리 등록해 둔 메일 주소로 내용을 보내 준다. 어느 메일로 보낼지는 접근 키 하나가 정한다.
폼 컴포넌트는 빌드할 때 키가 있으면 폼을, 없으면 메일 링크가 달린 안내 상자를 그린다. 안내 상자의 제목은 "이메일로 문의 주세요"로 바꾸고, 본문에는 언제쯤 회신하는지와 무엇을 적어 보내면 상담이 빨라지는지를 넣었다.
키가 빠졌다는 사실 자체는 여전히 누군가 알아야 한다. 그건 방문자가 아니라 빌드하는 사람의 몫이라, 컴포넌트의 프런트매터에서 console.warn 으로 빌드 로그에 경고를 찍게 했다. 프런트매터는 빌드 때 Node 에서 한 번 돌 뿐이라, 여기서 찍은 로그는 HTML 에 한 글자도 들어가지 않는다.
공개해도 되는 키
문구를 고치고 나니 질문이 하나 남았다. 왜 키가 없는 빌드가 나왔는가.
처음에는 키를 .env 에 두었다. 키라는 이름이 붙은 값은 저장소에 넣지 않는다는 습관대로였다. 그런데 .env 는 저장소에 올리지 않으니, 그 파일이 없는 PC 에서 빌드하면 폼이 통째로 사라진다. 배포를 어느 PC 에서 하느냐에 따라 문의 폼이 있다가 없다가 하는 구조였다.
그래서 이 키가 정말 숨겨야 하는 값인지부터 다시 봤다. 답은 이미 화면에 있었다. 폼을 그릴 때 키는 hidden input 의 value 로 들어간다. 페이지 소스 보기만 하면 누구나 읽는다. 애초에 Web3Forms 의 접근 키는 HTML 에 그대로 박아 쓰라고 만든 값이다. 이 키로 할 수 있는 일은 등록된 주소로 메일을 보내는 것뿐이고, 받은 메일을 읽거나 설정을 바꿀 수는 없다. 이미 공개된 값을 .env 에 숨기는 건 보안이 아니라 불편이었다.
const DEFAULT_WEB3FORMS_KEY = "…"; // 페이지 HTML 에 그대로 공개되는 값
export const WEB3FORMS_KEY =
import.meta.env.PUBLIC_WEB3FORMS_KEY || DEFAULT_WEB3FORMS_KEY;
기본값을 소스에 내장하고, 다른 폼으로 바꿔 끼울 때만 .env 로 덮어쓰게 했다. 여기서 연산자 하나를 바꿨다. 예전 코드는 ?? "" 였다. ?? 는 값이 undefined 나 null 일 때만 오른쪽으로 넘어간다. .env.example 을 복사하면 PUBLIC_WEB3FORMS_KEY= 처럼 빈 줄이 생기고, 그러면 값은 빈 문자열이다. 빈 문자열은 ?? 를 그대로 통과해 기본값을 가린다. || 로 바꿔야 빈 값도 기본값으로 떨어진다.
브라우저가 먼저 막는다
키가 들어간 폼이 화면에 나와도 전송이 된다는 보장은 없다. 이 사이트는 IIS 의 web.config 에서 Content-Security-Policy 헤더를 내보낸다. 기본은 default-src 'self', 즉 자기 출처 말고는 아무 데도 연결하지 못한다.
connect-src 'self' https://api.web3forms.com;
form-action 'self' https://api.web3forms.com;
두 지시어는 막는 길이 다르다. 폼 스크립트는 submit 이벤트를 preventDefault 로 멈추고, FormData 를 fetch 로 보낸 뒤 JSON 응답의 success 를 보고 성공 문구나 실패 문구를 띄운다. 페이지가 다른 곳으로 넘어가지 않으니 방문자는 쓰던 화면에서 결과를 본다. 이 fetch 를 통과시키는 게 connect-src 다. 여기에 폼 서비스 주소가 없으면 요청은 네트워크에 나가지도 못하고, 스크립트에는 TypeError 하나만 남는다.
form-action 은 <form> 이 HTML 그대로 제출될 때 어디로 갈 수 있는지를 정한다. Web3Forms 의 기본 사용법이 바로 form 의 action 에 API 주소를 적고 POST 하는 방식이라, 그 길도 같은 주소로만 열었다. 문의 내용이 나갈 수 있는 곳을 'self' 와 폼 서비스 하나로 못 박아 둔 셈이다. 폼에는 사람 눈에 보이지 않는 botcheck 체크 칸도 하나 넣었다. 봇은 보이는 칸이든 아니든 다 채우는 경향이 있어서, 이 칸이 체크된 제출은 서비스 쪽에서 걸러진다.
받는 쪽이 있는 폼
같은 구성으로 고객사 A/S 회사 홈페이지를 만들었다. 겉모습은 같은 정적 사이트지만 폼의 성격이 달랐다. 문의 메일이 아니라 A/S 신청과 가맹 문의를 받아야 했고, 그 신청은 메일함이 아니라 고객사가 이미 쓰고 있는 업무 시스템에 쌓여야 했다. 본사 담당자가 그 화면에서 보고 바로 A/S 건으로 넘길 수 있어야 하니까. 그래서 폼은 업무 시스템의 접수함 API 로 JSON 을 보낸다.
홈페이지와 업무 시스템은 출처가 다르고, Content-Type: application/json 을 단 POST 는 브라우저가 본 요청 전에 OPTIONS 예비 요청부터 보낸다. 업무 시스템 쪽 CORS 정책은 좁게 잡았다. 허용하는 출처는 설정에 적은 홈페이지 주소뿐이고, 메서드는 POST, 헤더는 Content-Type 과 Accept 만이다. 이 정책은 사이트 전체가 아니라 홈페이지가 부르는 엔드포인트에만 붙였다. 업무 시스템의 나머지 화면은 여전히 다른 출처의 호출을 받지 않는다. 홈페이지 쪽 CSP 는 connect-src 에 업무 시스템 주소 하나만 더하고, HTML 그대로 제출하는 길이 없으니 form-action 은 'self' 로 닫았다.
로그인 없이 누구나 부를 수 있는 API 라 스팸 대비는 받는 쪽이 해야 했다. 홈페이지 폼에는 보이지 않는 website 칸을 넣었다. 접수함은 이 칸이 채워져 들어오면 저장하지 않고 성공이라고 답한다. 막혔다는 걸 알려 주면 봇은 방법을 바꾼다. 요청 본문 크기에 상한을 두고, 일정 시간 안에 같은 IP 에서 들어올 수 있는 건수와 전체 건수를 두 겹으로 제한했다. 한도에 걸리면 "잠시 후 다시 시도해 주세요. 급하시면 고객센터로 전화 주세요" 같은 안내를 돌려준다. 입력 검사도 서버가 다시 한다. 브라우저의 required 는 요청을 직접 만드는 쪽에겐 아무것도 아니다.
접수함은 실패할 때도 { ok: false, error: "안내 문구" } 모양으로 답한다. 폼 스크립트는 이 error 를 그대로 방문자에게 보여 준다. "연락처는 휴대폰 번호 또는 유선 전화번호로 입력해 주세요" 같은 문장이 서버에서 바로 나온다. 다만 걸러야 할 경우가 있다. 네트워크가 끊겼거나 CORS 에서 막히면 fetch 는 TypeError 를 던지고, 그 메시지는 "Failed to fetch" 같은 브라우저 문장이다. 응답이 JSON 이 아니면 내가 만든 HTTP 502 같은 문자열이 남는다. 이 두 경우는 미리 준비한 "전송에 실패했습니다. 잠시 후 다시 시도하시거나…" 문구로 바꾼다. 첫머리의 그 문장과 같은 이유다.
모델명은 고르게 했다
A/S 신청 폼에서 가장 마음에 걸린 칸은 모델명이었다. 처음에는 "제품 · 모델명(선택)"이라는 자유 입력 칸이었다. 비워 둬도 되고, 무엇을 적어도 들어갔다. 그런데 업무 시스템에 없는 모델로 신청이 들어오면 맞는 부품을 챙길 수 없다. 결국 담당자가 신청자에게 일일이 전화를 걸어 모델을 다시 물어야 한다. 온라인 신청을 받는 의미가 절반은 사라진다.
그래서 A/S 신청일 때는 모델명을 필수로 바꾸고, 업무 시스템에 등록된 모델 중에서 고르게 했다. 두 글자 이상 입력하면 접수함의 모델 찾기 API 가 맞는 모델을 몇 개 돌려주고, 칸 아래에 목록이 열린다. 목록에는 모델명과 제품군만 나온다. 그 모델을 어느 회사가 만들었는지는 보여 주지 않는다.
const search = async () => {
const q = input.value.trim();
const mine = ++seq;
if (q.replace(/[^\p{L}\p{N}]/gu, "").length < 2) { close(); return; }
try {
const response = await fetch(endpoint, { method: "POST", headers, body: JSON.stringify({ q }) });
const result = await response.json().catch(() => ({}));
// 더 늦게 입력한 검색이 있거나 칸을 벗어났으면 버린다
if (mine !== seq || document.activeElement !== input) return;
...
} catch {
// 목록을 못 불러와도 직접 입력해 보낼 수 있다 (접수 API 가 최종 확인)
if (mine === seq) close();
}
};
입력 이벤트마다 250ms 를 기다렸다가 검색한다. 그래도 응답이 순서대로 온다는 보장은 없다. "AB" 로 찾은 응답이 "AB-12" 로 찾은 응답보다 늦게 도착하면, 짧은 검색어의 목록이 긴 검색어의 목록을 덮어쓴다. 그래서 검색마다 번호를 매기고, 돌아온 응답의 번호가 가장 최근 번호가 아니면 버린다. 칸을 이미 떠났을 때도 버린다. 목록의 항목은 click 이 아니라 mousedown 에서 고른다. click 은 마우스를 뗄 때 오는데, 그 전에 입력 칸의 blur 가 목록을 닫아 버려 누른 항목이 사라진다.
목록은 편의일 뿐이고 최종 확인은 접수함이 한다. 비교할 때는 대소문자, 공백, 하이픈 같은 기호를 다 빼고 글자와 숫자만 맞춰 본다. "ab 123c" 로 적어도 등록된 "AB-123C" 를 찾고, 저장은 등록된 이름 그대로 한다. 끝내 맞는 모델이 없으면 라벨을 다시 확인해 달라는 안내와 함께 접수를 받지 않는다.
받지 않는다고만 하면 방문자는 막힌다. 그래서 칸 아래에 모델명 확인 방법을 세 줄로 붙였다. 제품 뒷면이나 옆면의 라벨(명판 스티커)을 찾을 것, 영문과 숫자가 섞인 부분이 모델명이라는 것, 그리고 모델명이 맞아야 기사가 방문 전에 부품을 준비해 헛걸음을 줄인다는 이유다. 이 안내와 필수 표시는 문의 유형이 A/S 신청일 때만 보인다.
사진은 본사만 본다
라벨을 찾아도 읽기 어려운 사람이 있다. 그럴 땐 찍어서 보내게 하는 편이 빠르다. 그래서 A/S 신청에 사진 칸을 붙이고, 안내 둘째 줄 끝에 "라벨을 선명하게 찍어 사진 칸에 올려 주세요"를 더했다. 제품 라벨이나 고장 난 부위를 3장까지 올릴 수 있다.
휴대폰 사진 원본은 한 장에 몇 MB 씩 하고, 촬영 위치 같은 메타데이터도 들어 있다. 그래서 보내기 전에 브라우저에서 다시 그린다.
async function shrinkPhoto(file: File): Promise<Blob> {
const bitmap = await createImageBitmap(file);
const scale = Math.min(1, MAX_SIDE / Math.max(bitmap.width, bitmap.height));
const canvas = document.createElement("canvas");
canvas.width = Math.round(bitmap.width * scale);
canvas.height = Math.round(bitmap.height * scale);
const context = canvas.getContext("2d")!;
context.fillStyle = "#fff";
context.fillRect(0, 0, canvas.width, canvas.height);
context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
bitmap.close();
return new Promise((resolve, reject) =>
canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error("toBlob"))), "image/jpeg", 0.85));
}
긴 변을 1600px 이하로 줄여 JPEG 로 다시 굽는다. canvas 에 그린 건 픽셀뿐이라 원본의 메타데이터는 따라오지 않는다. 투명한 PNG 가 검게 나오지 않게 흰색을 먼저 칠한다. 파일 이름도 고객 기기의 이름 대신 photo1.jpg 처럼 새로 붙인다. 사진이 있으면 신청 JSON 을 data 칸에, 사진을 photos 칸에 담아 multipart 로 보내고, 사진이 없으면 예전처럼 JSON 으로 보낸다. 미리보기는 URL.createObjectURL 로 만든 blob: 주소라, CSP 의 img-src 에 blob: 을 더해야 미리보기가 보였다.
접수함은 로그인 없이 들어오는 파일을 받는 셈이라 브라우저를 믿지 않는다. 장 수와 한 장의 크기에 상한을 두고, 형식은 확장자가 아니라 파일 앞부분의 시그니처 바이트로 판별해 JPEG, PNG, WebP 만 받는다. 사진 저장이 실패해도 신청 자체는 접수한다. 사진보다 신청 내용이 더 중요하니까.
마지막은 누가 보느냐였다. 고객이 보낸 사진에는 집 안이 찍힐 수 있다. 사진 칸 안내에 "사진은 본사 담당자만 확인합니다"라고 적었고, 개인정보 동의 문구에도 사진을 넣었다. 적은 대로 되게 하는 건 업무 시스템 쪽 일이었다. 사진 주소는 본사 관리자 계정으로만 열리고, 신청을 A/S 건으로 넘겨도 사진은 복사하지 않는다. A/S 를 배정받은 협력 센터는 신청 내용으로 일하고, 사진까지 받아 가지는 않는다.
폼 두 개는 결국 같은 질문을 풀었다. 이 값을 누가 보는가. 빌드 경고는 개발자가, 안내 문구는 방문자가, 접근 키는 누구나, 사진은 본사만 본다.
다음 편은 신청 그다음 이야기다. 맡긴 A/S 가 지금 어디까지 왔는지, 로그인 없이 홈페이지에서 조회하게 만든 과정을 쓴다.