공간엔 사이트 구축기 (상담신청 폼 & SMS 알림 기능) - 2편
구축기
2편 · 상담신청 폼 & SMS 알림
상담신청 폼 구현과 Supabase Edge Function 연동기 (DB 저장 + Naver Cloud SENS LMS 발송)
🔗 결과물 확인
실제 적용된 사이트에서 흐름/UX를 같이 보면 더 감이 와요.
https://www.gongann.co.kr/
실제 적용된 사이트에서 흐름/UX를 같이 보면 더 감이 와요.
이번 편은 “상담신청이 들어오면 DB에 먼저 안전하게 저장하고, 그 다음에 Naver Cloud SENS로 LMS를 발송하는” 실무 흐름을 정리합니다. 핵심은 프론트 submit 흐름(저장 → 함수 호출)과 Edge Function에서 HMAC 서명 생성 후 SENS 호출, 그리고 secrets/배포 체크포인트예요.
1) 프론트는 입력 수집 후 inquiries 테이블에 먼저 저장하고, 성공 시 Edge Function(
send-inquiry-sms)을 호출합니다.2) Edge Function은 Supabase secrets에서 환경변수를 읽고, SENS 요청용 HMAC 서명을 만들어 API를 호출합니다.
3) 로컬
.env는 “내 컴퓨터”에서만 유효하고, 운영 반영은 secrets 설정 + 함수 재배포가 필수입니다.4) 운영 검증은 Supabase logs + SENS 콘솔 + 실수신 단말까지 세트로 확인합니다.
문제/요구사항
운영 기준
- 알림 실패가 있어도 신청 데이터는 유실되면 안 됨 (원장 보존)
- 운영자는 즉시 인지해야 함 (최종: LMS/SMS 중심)
- 민감정보(키/토큰)는 클라이언트 노출 금지
구현 요구사항
- React 폼 submit → Supabase inquiries 저장
- 저장 성공 후 Edge Function 호출(
send-inquiry-sms) - Edge Function → Naver Cloud SENS LMS API
- 환경변수는 Supabase secrets로 관리(하드코딩 금지)
verify_jwt = false필요(공개 폼 호출)- 발신번호는 SENS에 사전 등록/승인 필요
설계 원칙
“접수 성공”은 DB 저장으로 정의하고, “알림”은 뒤에서 트리거로 처리했습니다. 운영에서 가장 안전한 형태예요.
“접수 성공”은 DB 저장으로 정의하고, “알림”은 뒤에서 트리거로 처리했습니다. 운영에서 가장 안전한 형태예요.
구현
전체 흐름(한 번에 보기)
- React 폼 submit → 입력값 검증/정리
- Supabase DB →
inquiries테이블에 먼저 insert - Edge Function →
send-inquiry-sms호출 - SENS API → HMAC 서명 생성 후 LMS 발송
1) 프론트 submit 처리 (DB 저장 → SMS 함수 호출)
포인트는 2개예요. (1) DB 저장을 먼저 해서 “원장”을 남기기, (2) 함수 호출 실패는 운영에서 재처리 가능하도록 분리하기.
React submit 핵심 (insert → invoke)
// ✅ 민감정보(키/전화번호) 절대 노출 금지: placeholder만 사용
// ✅ UX 포인트: "접수 완료"와 "알림 성공"을 분리해서 안내할 수 있음
import { createClient } from "@supabase/supabase-js";
const supabase = createClient("<SUPABASE_URL>", "<SUPABASE_ANON_KEY>");
export async function submitInquiry(form) {
// 0) 프론트 1차 검증(필수값/형식)
// - phone은 숫자만, 길이 체크, 공백 trim 등
const payload = {
name: form.name?.trim(),
phone: form.phone?.replace(/[^0-9]/g, ""),
message: form.message?.trim(),
};
// 1) DB에 먼저 저장(원장)
const { data, error } = await supabase
.from("inquiries")
.insert(payload)
.select("id")
.single();
if (error) {
// 여기서 실패하면 "접수 실패" (사용자에게 재시도 안내)
throw new Error("DB 저장 실패");
}
// 2) 저장 성공 후 Edge Function 호출(알림 트리거)
const { error: fnError } = await supabase.functions.invoke("send-inquiry-sms", {
body: {
inquiryId: data.id, // 운영/추적을 위해 id 전달
// 필요 최소한만 전달 (원하면 함수에서 DB로 조회하도록 설계 가능)
name: payload.name,
phone: payload.phone,
message: payload.message,
},
});
// 알림 실패해도 DB는 남았으니 운영자가 확인/재발송 가능
if (fnError) {
console.warn("send-inquiry-sms 실패(접수는 완료)", fnError);
}
return { inquiryId: data.id, smsTriggered: !fnError };
}
실무 팁
- 사용자 화면에서는 “접수 완료”를 우선 보장하고, 알림 실패는 운영에서 로그로 추적하는 구조가 안정적이었어요.
- 나중에 스팸이 늘면
- 사용자 화면에서는 “접수 완료”를 우선 보장하고, 알림 실패는 운영에서 로그로 추적하는 구조가 안정적이었어요.
- 나중에 스팸이 늘면
verify_jwt=false 구간에 레이트리밋/캡차/토큰 검증을 붙이는 게 좋습니다.
2) Edge Function: env(secrets) 읽기 + 하드코딩 방지
로컬 .env는 로컬에서만 먹고, 운영 반영은 Supabase secrets가 기준입니다.
그래서 함수 시작에서 env 누락을 빠르게 터뜨리게 만들면(early fail) 운영 장애 찾기가 쉬워요.
Edge Function env 읽기(필수 키 체크)
// supabase/functions/send-inquiry-sms/index.ts (핵심만)
// ✅ 실제 키/전화번호 절대 금지: placeholder만 사용
const REQUIRED = [
"NCP_ACCESS_KEY",
"NCP_SECRET_KEY",
"NCP_SENS_SERVICE_ID",
"NCP_SMS_FROM",
"NCP_SMS_ADMIN_TO",
];
function mustEnv(key) {
const v = Deno.env.get(key);
if (!v) throw new Error(`Missing env: ${key}`);
return v;
}
export function readEnv() {
// early fail: 누락이면 바로 에러
return {
NCP_ACCESS_KEY: mustEnv("NCP_ACCESS_KEY"),
NCP_SECRET_KEY: mustEnv("NCP_SECRET_KEY"),
NCP_SENS_SERVICE_ID: mustEnv("NCP_SENS_SERVICE_ID"),
NCP_SMS_FROM: mustEnv("NCP_SMS_FROM"),
NCP_SMS_ADMIN_TO: mustEnv("NCP_SMS_ADMIN_TO"),
};
}
3) SENS 호출: HMAC 서명 생성(개념) + LMS 발송 스켈레톤
SENS는 요청마다 HMAC-SHA256 서명을 요구합니다. “메서드/경로/타임스탬프/AccessKey”로 서명 문자열을 만들고, SecretKey로 서명한 값을 헤더에 넣는 방식이에요.
중요
아래 코드는 “흐름 이해용 스켈레톤”입니다. 실제 헤더/경로/요청 바디는 SENS 공식 문서에 맞춰 조정하세요. (민감정보는 절대 코드/블로그에 노출하지 않기)
아래 코드는 “흐름 이해용 스켈레톤”입니다. 실제 헤더/경로/요청 바디는 SENS 공식 문서에 맞춰 조정하세요. (민감정보는 절대 코드/블로그에 노출하지 않기)
HMAC 서명 + SENS 호출(핵심 흐름)
import { encode as b64encode } from "https://deno.land/std@0.224.0/encoding/base64.ts";
async function hmacSignature(secretKey, message) {
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secretKey),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(message));
return b64encode(new Uint8Array(sig));
}
export async function sendLms(env, content) {
const timestamp = Date.now().toString();
const method = "POST";
const path = `/sms/v2/services/${env.NCP_SENS_SERVICE_ID}/messages`; // 예시
const toSign = `${method} ${path}\n${timestamp}\n${env.NCP_ACCESS_KEY}`;
const signature = await hmacSignature(env.NCP_SECRET_KEY, toSign);
// ⚠️ 발신번호는 SENS에서 사전 등록/승인된 번호만 사용 가능
const body = {
type: "LMS",
from: env.NCP_SMS_FROM,
content,
messages: [{ to: env.NCP_SMS_ADMIN_TO, content }],
};
const res = await fetch(`https://sens.apigw.ntruss.com${path}`, {
method,
headers: {
"content-type": "application/json; charset=utf-8",
"x-ncp-apigw-timestamp": timestamp,
"x-ncp-iam-access-key": env.NCP_ACCESS_KEY,
"x-ncp-apigw-signature-v2": signature,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`SENS failed: ${res.status} ${text}`);
}
return await res.json();
}
4) verify_jwt = false 설정 포인트
공개 폼에서 호출해야 하면 JWT 검증이 켜져 있을 때 막히는 경우가 많아서,
이 함수는 verify_jwt = false로 두는 편이 운영이 편했습니다.
대신 남용 방지(레이트리밋/검증/캡차)는 꼭 고려해야 해요.
배포
secrets vs 로컬 .env (로컬 파일 != 운영 반영)
로컬(.env)
- 내 PC에서만 유효
- 로컬 테스트 편함
- 운영 배포엔 영향 없음
운영(Supabase secrets)
- 배포된 함수가 읽는 값
- 콘솔/CLI로 설정
- 누락 시 운영에서 즉시 실패
secrets 설정 + 함수 재배포
# ✅ 실제 값은 절대 공유하지 말 것 (placeholder 사용)
# ✅ 설정 후 "재배포"까지 해야 반영되는 흐름이 많아서 체크!
npx supabase secrets set \
NCP_ACCESS_KEY="<NCP_ACCESS_KEY>" \
NCP_SECRET_KEY="<NCP_SECRET_KEY>" \
NCP_SENS_SERVICE_ID="<NCP_SENS_SERVICE_ID>" \
NCP_SMS_FROM="<REGISTERED_SENDER_NUMBER>" \
NCP_SMS_ADMIN_TO="<ADMIN_PHONE_NUMBER>" \
--project-ref <PROJECT_REF>
# 질문에서 준 예시 그대로
npx supabase functions deploy send-inquiry-sms --project-ref dxxpktldogctkpatiqzk
배포 체크리스트
1) 발신번호 SENS 등록/승인 완료?
2) secrets 다 들어갔나?(오타/공백 포함)
3)
4) DB insert가 RLS로 막히진 않나?
5) 함수 재배포 했나? (가장 자주 빠짐)
1) 발신번호 SENS 등록/승인 완료?
2) secrets 다 들어갔나?(오타/공백 포함)
3)
verify_jwt=false 적용됐나?4) DB insert가 RLS로 막히진 않나?
5) 함수 재배포 했나? (가장 자주 빠짐)
검증
운영에서 확인하는 3군데
- Supabase logs: 함수 호출 들어왔는지, env 누락/서명/포맷 에러인지
- SENS 콘솔: 발송 요청 접수/실패 사유(발신번호/정책/요금)
- 실수신 단말: 관리자 폰에 실제 LMS 도착(지연/차단/스팸함)
검증 루틴
“DB 저장됨?” → “함수 로그 남음?” → “SENS 발송 이력 있음?” → “폰에 실제 도착?”
“DB 저장됨?” → “함수 로그 남음?” → “SENS 발송 이력 있음?” → “폰에 실제 도착?”
주의사항
민감정보
- 실제 키/전화번호는 절대 글/코드/스크린샷에 노출 금지
- 블로그에는 placeholder만 사용
운영에서 자주 터지는 포인트
- 로컬에서 됐는데 운영에서 안 됨 → secrets 미설정 or 재배포 누락
- SENS 실패 → 발신번호 미승인 / 서명 문자열 오타 / 요청 포맷 불일치
verify_jwt = false는 편하지만 공개 호출이 가능해집니다.
운영 트래픽이 늘면 레이트리밋/캡차/토큰 검증 같은 안전장치도 같이 붙이는 걸 추천해요.
운영 트래픽이 늘면 레이트리밋/캡차/토큰 검증 같은 안전장치도 같이 붙이는 걸 추천해요.

댓글
댓글 쓰기