구현
전체 흐름(한 번에 보기)
- 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 공식 문서에 맞춰 조정하세요.
(민감정보는 절대 코드/블로그에 노출하지 않기)
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로 두는 편이 운영이 편했습니다.
대신 남용 방지(레이트리밋/검증/캡차)는 꼭 고려해야 해요.
댓글
댓글 쓰기