정적 블로그를 운영하다 보면 문의 폼이 생각보다 까다롭습니다. 화면만 보면 그냥 <form> 하나 추가하면 끝날 것 같지만, 실제로는 스팸 방지, 비밀키 보관, 메일 전송, 실패 처리, 응답 UX까지 함께 걸려 있습니다. 특히 정적 사이트는 전통적인 서버가 없기 때문에, “그럼 클라이언트에서 메일 API를 바로 호출하면 되나?” 같은 유혹이 금방 생깁니다.
결론부터 말하면, 저는 그 방식을 추천하지 않습니다. 문의 폼은 화면은 정적이어도, 제출 처리는 반드시 서버 측 경계가 있어야 합니다. 클라이언트에 API 키를 두거나, SMTP 정보를 브라우저 코드에 섞거나, 그냥 mailto:로 끝내는 방식은 장기 운영 관점에서 불안정합니다. 이건 제가 추론해서 말하는 실무 판단이기도 하지만, Cloudflare와 Turnstile 공식 문서가 강조하는 원칙과도 잘 맞습니다. 서버 측 검증과 서버 측 액션이 필요하다는 점입니다.
이 레포처럼 Cloudflare Pages에 정적 export로 배포하는 블로그라면, 가장 균형이 좋은 해법은 아래 구조입니다.
- 화면은 정적 페이지로 유지
- 폼 제출은 Cloudflare Pages Function 한 개가 받기
- 공개 폼에는 Turnstile을 붙여 기본적인 봇 제출을 줄이기
- 메일 전송은 서버 측에서만 외부 이메일 API를 호출하기
- API 키와 비밀값은 Pages 환경 변수로만 보관하기
이 방식이 좋은 이유는 단순합니다. 블로그 본체는 여전히 정적이라 가볍고, 문의 폼만 필요한 만큼의 서버 기능을 가지게 됩니다. Cloudflare Pages 공식 문서도 /functions 디렉터리를 프로젝트 루트에 두면 Pages Function을 라우트별로 만들 수 있다고 설명합니다. 즉 정적 사이트를 버리지 않고도 제출 처리용 엔드포인트를 붙일 수 있습니다.
이 글에서는 제가 정적 블로그에 문의 폼을 붙일 때 가장 먼저 선택하는 구조를 기준으로, 왜 이 방법이 단순하고 안전한지, 어떤 코드를 두면 되는지, 그리고 어디까지가 “최소 구성”인지 정리해보겠습니다. 문의 페이지 자체의 신뢰 설계가 왜 중요한지는 앞서 정리한 신뢰 페이지 구성 글과도 흐름이 이어집니다.
정적 블로그에서도 안전하게 문의를 받는 방법을 정리합니다. 클라이언트에 비밀키를 두지 않고, Cloudflare Pages Functions와 Turnstile, 서버 측 메일 전송을 조합하는 가장 현실적인 흐름을 설명합니다. 핵심 1 핵심 2 핵심 3정적 블로그에 문의 폼을 붙일 때 가장 단순하고 안전한 방법
먼저 결론: 정적 폼은 “브라우저에서 바로 메일 보내기”보다 “정적 화면 + 서버 경계 하나”가 낫다
왜 Pages Function 하나를 두는 편이 좋은가
클라이언트 폼은 최대한 단순하게 유지한다
먼저 결론: 정적 폼은 “브라우저에서 바로 메일 보내기”보다 “정적 화면 + 서버 경계 하나”가 낫다
정적 블로그 문의 폼에서 제가 기본값처럼 보는 구조는 이것입니다.
- 브라우저는 이름, 이메일, 메시지만 보낸다
- Pages Function이 입력값을 검증한다
- 필요하면 Turnstile 토큰을 서버에서 검증한다
- 검증을 통과한 요청만 메일 API나 저장소로 보낸다
- 성공/실패 응답만 브라우저에 돌려준다
이렇게 하면 브라우저는 비밀키를 전혀 알 필요가 없습니다. 실제 메일 전송도 서버 측에서만 일어나기 때문에 키 유출 위험이 줄고, 스팸 필터나 rate limiting을 나중에 붙이기도 쉽습니다.
반대로 저는 아래 방식은 가능하면 피합니다.
- 클라이언트 JavaScript에서 이메일 API를 직접 호출하는 방식
- 프런트 코드 안에 SMTP 계정이나 API 키를 넣는 방식
mailto:하나로 문의 창구를 끝내는 방식- 공개 폼인데 서버 측 CAPTCHA 검증이 없는 방식
특히 Turnstile 공식 문서는 클라이언트 위젯만으로는 폼을 보호할 수 없고, 반드시 Siteverify API를 서버에서 호출해야 한다고 설명합니다. 즉 화면에 위젯만 보인다고 끝이 아닙니다.
1. 왜 Pages Function 하나를 두는 편이 좋은가
Cloudflare Pages Functions 문서를 보면, Pages 프로젝트 루트의 /functions 디렉터리에 파일을 두는 것만으로 라우트별 서버 함수를 만들 수 있습니다. 예를 들어 functions/api/contact.ts 파일을 만들면 /api/contact 같은 경로에서 요청을 처리할 수 있습니다.
이게 정적 블로그와 잘 맞는 이유는 다음과 같습니다.
- 블로그 전체를 SSR로 바꿀 필요가 없습니다.
- 문의 처리 로직만 별도 서버 경계로 분리할 수 있습니다.
- 환경 변수와 비밀키를 브라우저가 아니라 서버 런타임에만 둘 수 있습니다.
- 제출 검증, 스팸 방지, 메일 전송 실패 처리를 한 곳에서 제어할 수 있습니다.
즉 문의 폼 때문에 사이트 전체 구조를 무겁게 만들지 않아도 됩니다. 정적 블로그의 장점을 유지하면서도 “제출 처리만 서버처럼” 다룰 수 있는 점이 핵심입니다.
2. 클라이언트 폼은 최대한 단순하게 유지한다
문의 폼 화면은 복잡할 필요가 없습니다. 제 기준에서 최소 구성은 아래 정도면 충분합니다.
- 이름
- 이메일
- 메시지
- 사람 검증용 Turnstile
- 봇 잡는 허니팟 필드 하나
정적 페이지에서는 Next.js 컴포넌트도 단순한 편이 좋습니다.
src/app/contact/page.tsx
import Script from "next/script";
export default function ContactPage() {
return (
<main>
<Script
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
strategy="afterInteractive"
/>
<form action="/api/contact" method="POST" className="contactForm">
<label>
이름
<input type="text" name="name" required />
</label>
<label>
이메일
<input type="email" name="email" required />
</label>
<label>
메시지
<textarea name="message" rows={8} required />
</label>
<input
type="text"
name="company"
tabIndex={-1}
autoComplete="off"
className="srOnly"
/>
<div
className="cf-turnstile"
data-sitekey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY}
data-theme="auto"
data-action="contact"
/>
<button type="submit">문의 보내기</button>
</form>
</main>
);
}
여기서 중요한 건 두 가지입니다.
- 폼은
/api/contact만 향하게 한다 - Turnstile site key는 공개값이므로 클라이언트에 있어도 되지만, secret key는 절대 브라우저로 보내지 않는다
Turnstile의 클라이언트 렌더링 문서도 <form> 안에 위젯을 넣으면 cf-turnstile-response라는 hidden input이 자동으로 추가되어 함께 제출된다고 설명합니다. 즉 브라우저는 토큰을 숨겨진 필드로 같이 보내고, 진짜 검증은 서버에서 하게 됩니다.
3. 서버에서는 입력 검증과 스팸 방지를 먼저 처리한다
문의 폼 엔드포인트에서 가장 먼저 해야 하는 일은 메일 전송이 아닙니다. 검증입니다.
제가 기본으로 넣는 순서는 아래와 같습니다.
POST만 허용FormData읽기- 허니팟 필드가 채워져 있으면 조용히 차단
name,email,message기본 검증- 메시지 길이 상한선 검증
- Turnstile 토큰 검증
- 그 뒤에야 메일 API 호출
허니팟은 아주 단순하지만 값싼 방어입니다. 사람은 보지 못하는 필드에 값이 들어오면 자동 제출일 가능성이 높기 때문입니다. 물론 이것만으로 충분하지는 않아서, 공개 폼이라면 Turnstile을 함께 두는 편이 안전합니다.
4. Turnstile은 “위젯 표시”가 아니라 “서버 검증”까지 끝나야 한다
이 부분은 꼭 분리해서 생각해야 합니다. Turnstile 서버 검증 문서는 명확합니다. Siteverify API를 서버에서 호출해야 구현이 완료됩니다. 이유도 같이 적혀 있습니다.
- 토큰은 위조될 수 있음
- 토큰은 5분 후 만료됨
- 토큰은 1회용이라 재사용 시 거절됨
즉 브라우저가 보내준 cf-turnstile-response 값을 그대로 믿으면 안 됩니다. 서버에서 Cloudflare의 Siteverify 엔드포인트로 다시 확인해야 합니다.
functions/api/contact.ts
type Env = {
TURNSTILE_SECRET_KEY: string;
RESEND_API_KEY: string;
CONTACT_TO_EMAIL: string;
CONTACT_FROM_EMAIL: string;
};
type TurnstileResponse = {
success: boolean;
"error-codes"?: string[];
};
export const onRequestPost: PagesFunction<Env> = async (context) => {
const formData = await context.request.formData();
if (formData.get("company")) {
return Response.json({ ok: true });
}
const token = formData.get("cf-turnstile-response");
if (typeof token !== "string" || !token) {
return Response.json({ message: "인증이 필요합니다." }, { status: 400 });
}
const verifyBody = new URLSearchParams({
secret: context.env.TURNSTILE_SECRET_KEY,
response: token,
});
const ip = context.request.headers.get("CF-Connecting-IP");
if (ip) {
verifyBody.set("remoteip", ip);
}
const verifyResponse = await fetch(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: verifyBody,
},
);
const verifyResult = (await verifyResponse.json()) as TurnstileResponse;
if (!verifyResult.success) {
return Response.json({ message: "사람 인증에 실패했습니다." }, { status: 400 });
}
return Response.json({ ok: true });
};
이 코드에서 핵심은 “메일 전송보다 먼저 검증”입니다. 인증 실패를 걸러낸 뒤에만 다음 단계로 넘어가야 합니다.
5. 메일 전송은 서버 측에서만 외부 API를 호출한다
문의를 실제로 받으려면 결국 어디론가 메시지를 보내야 합니다. 이때 정적 블로그에서 제일 현실적인 선택은 서버 측에서 이메일 API를 호출하는 방식입니다. Cloudflare Workers / Pages 런타임에서 메일을 보내는 예시는 Resend 공식 문서도 따로 제공합니다.
여기서 중요한 건 구현 세부보다 원칙입니다.
- 브라우저는 비밀키를 모른다
- Function만
RESEND_API_KEY를 안다 - 검증 통과 후에만 외부 API를 호출한다
- 실패하면 사용자에게 과도한 내부 정보를 노출하지 않는다
Resend의 이메일 API 문서에 따르면 POST /emails로 from, to, subject, html 또는 text 등을 보낼 수 있습니다. 그래서 Pages Function 안에서는 SDK를 굳이 쓰지 않아도 fetch로 단순하게 붙일 수 있습니다.
const name = formData.get("name");
const email = formData.get("email");
const message = formData.get("message");
if (
typeof name !== "string" ||
typeof email !== "string" ||
typeof message !== "string"
) {
return Response.json({ message: "입력값이 올바르지 않습니다." }, { status: 400 });
}
if (message.length > 5000) {
return Response.json({ message: "메시지가 너무 깁니다." }, { status: 400 });
}
const resendResponse = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${context.env.RESEND_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from: context.env.CONTACT_FROM_EMAIL,
to: [context.env.CONTACT_TO_EMAIL],
subject: `[Signal Ledger] ${name}님의 문의`,
reply_to: email,
text: [
`이름: ${name}`,
`이메일: ${email}`,
"",
message,
].join("\n"),
}),
});
if (!resendResponse.ok) {
return Response.json({ message: "문의 전송에 실패했습니다." }, { status: 502 });
}
return Response.json({ ok: true });
이 정도면 공개 폼의 첫 번째 운영 버전으로는 충분히 실용적입니다. 데이터베이스 없이도 메일 inbox로 문의를 모을 수 있고, 키는 서버에만 남습니다.
6. 환경 변수는 브라우저용과 서버용을 분리한다
실수하기 쉬운 부분이 이것입니다. Turnstile의 site key는 브라우저에서 써도 되지만, secret key와 메일 API 키는 절대 클라이언트에 노출되면 안 됩니다. 저는 보통 아래처럼 나눕니다.
NEXT_PUBLIC_TURNSTILE_SITE_KEYTURNSTILE_SECRET_KEYRESEND_API_KEYCONTACT_TO_EMAILCONTACT_FROM_EMAIL
여기서 NEXT_PUBLIC_ 접두사가 붙은 값만 브라우저로 전달됩니다. 나머지는 Pages Functions 런타임에서만 읽도록 둡니다.
이 구분이 중요한 이유는 문의 폼이 공개 페이지이기 때문입니다. 설정을 급하게 하다 보면 site key와 secret key를 헷갈리기 쉬운데, 이건 초반에 규칙으로 못 박는 편이 좋습니다.
7. 응답 UX는 “성공/실패가 보이되, 내부 구조는 숨기는 쪽”이 좋다
문의 폼이 자주 어색해지는 지점이 바로 실패 처리입니다. 사용자는 “보내졌는지 아닌지”만 알면 되는데, 서버가 던진 원문 에러를 그대로 보여주면 오히려 불안합니다. 그래서 저는 응답 UX를 아래처럼 단순하게 유지합니다.
- 성공: “문의가 접수되었습니다. 확인 후 답변드리겠습니다.”
- 사용자 입력 오류: “입력값을 다시 확인해 주세요.”
- 인증 실패: “사람 인증을 다시 시도해 주세요.”
- 서버 실패: “잠시 후 다시 시도해 주세요.”
즉 사용자를 돕는 수준까지만 보여주고, API 응답 본문이나 내부 공급자 이름, 키 에러 같은 정보는 로그에만 남기는 편이 좋습니다.
8. 제가 처음 버전에서 꼭 넣는 최소 방어선
처음부터 모든 보안 장치를 다 붙일 필요는 없습니다. 그래도 공개 폼이라면 아래 정도는 기본값처럼 가져가는 편이 좋습니다.
- 서버 측 Turnstile 검증
- 허니팟 필드
- 메시지 길이 제한
POST전용 엔드포인트- 이메일 형식 기본 검증
- 실패 시 generic error message
여기에 운영 중 스팸이 늘면 나중에 아래를 추가합니다.
- IP 기준 rate limiting
- 특정 단어/링크 패턴 필터
- 동일 메시지 중복 차단
- 로그 저장 또는 문의 이력 저장
중요한 건 “처음부터 완벽한 백오피스”를 만드는 게 아니라, 정적 사이트에서도 안전한 최소 경계를 먼저 세우는 것입니다.
9. 더 적은 코드가 필요하면 Cloudflare Static Forms 플러그인도 볼 수 있다
여기까지는 명시적인 /api/contact 엔드포인트를 기준으로 설명했지만, Cloudflare Pages에는 Static Forms 플러그인도 있습니다. 공식 문서에 따르면 이 플러그인은 data-static-form-name 속성이 있는 폼 제출을 가로채고, respondWith 함수 안에서 formData와 폼 이름을 받아 처리할 수 있습니다. 문서 예시에서도 KV 저장 같은 액션을 할 수 있다고 설명합니다.
즉 정말 코드 양을 더 줄이고 싶다면 이런 선택지도 있습니다.
- HTML 폼은 거의 그대로 유지
- Static Forms 플러그인으로 제출 가로채기
- 응답 함수 안에서 저장 또는 알림 처리
다만 제가 블로그 운영용 문의 폼에서는 여전히 명시적인 /api/contact를 조금 더 선호하는 이유가 있습니다.
- 검증 흐름이 눈에 더 잘 보입니다.
- 로컬 디버깅이 쉽습니다.
- 나중에 이메일 API, DB 저장, rate limiting을 붙이기 편합니다.
- 다른 플랫폼으로 옮길 때도 구조를 재사용하기 쉽습니다.
즉 Static Forms는 더 적은 코드가 필요할 때 좋은 선택이고, 명시적 endpoint는 장기 운영에서 더 확장성이 좋은 선택이라고 보는 편이 균형 잡혀 있습니다.
실무 체크리스트
- 브라우저에서 메일 API를 직접 호출하지 않는가
- secret key와 API key가 서버 런타임에만 있는가
- 폼 제출 경로가 Pages Function 같은 서버 경계로 가는가
- Turnstile 토큰을 서버에서 Siteverify로 검증하는가
- 허니팟과 메시지 길이 제한을 두었는가
- 메일 전송 실패 시 사용자에게 내부 에러를 그대로 보여주지 않는가
- 응답 성공/실패 문구가 명확한가
- 문의 페이지의 설명과 실제 응답 채널이 일치하는가
마무리
정적 블로그에 문의 폼을 붙이는 가장 단순하고 안전한 방법은, 정적 사이트라는 이유로 브라우저에 모든 책임을 넘기지 않는 것입니다. 화면은 정적으로 유지하되, 제출만큼은 Pages Function 같은 서버 경계 하나를 두고 처리하면 됩니다. 여기에 Turnstile과 기본 검증만 붙여도 초반 운영 안정성이 크게 좋아집니다.
이 방식은 구현이 과하지 않으면서도, 나중에 rate limiting, 저장소, 자동 응답, 관리자 대시보드로 확장하기 쉬운 구조이기도 합니다. 문의 페이지가 사이트 신뢰의 일부라는 점을 생각하면, 화려한 폼보다 이런 기본 설계가 더 오래 갑니다.
참고 자료: