추천 가젯

공간엔 사이트 구축기 (상담신청 폼 & SMS 알림 기능) - 2편

구축기 2편 · 상담신청 폼 & SMS 알림

상담신청 폼 구현과 Supabase Edge Function 연동기 (DB 저장 + Naver Cloud SENS LMS 발송)

🔗 결과물 확인
실제 적용된 사이트에서 흐름/UX를 같이 보면 더 감이 와요.
https://www.gongann.co.kr/

이번 편은 “상담신청이 들어오면 DB에 먼저 안전하게 저장하고, 그 다음에 Naver Cloud SENS로 LMS를 발송하는” 실무 흐름을 정리합니다. 핵심은 프론트 submit 흐름(저장 → 함수 호출)Edge Function에서 HMAC 서명 생성 후 SENS 호출, 그리고 secrets/배포 체크포인트예요.

요약(핵심 3~5줄)
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 저장으로 정의하고, “알림”은 뒤에서 트리거로 처리했습니다. 운영에서 가장 안전한 형태예요.

구현

전체 흐름(한 번에 보기)

  1. React 폼 submit → 입력값 검증/정리
  2. Supabase DBinquiries 테이블에 먼저 insert
  3. Edge Functionsend-inquiry-sms 호출
  4. 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로 두는 편이 운영이 편했습니다. 대신 남용 방지(레이트리밋/검증/캡차)는 꼭 고려해야 해요.

배포

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) verify_jwt=false 적용됐나?
4) DB insert가 RLS로 막히진 않나?
5) 함수 재배포 했나? (가장 자주 빠짐)

검증

운영에서 확인하는 3군데

  • Supabase logs: 함수 호출 들어왔는지, env 누락/서명/포맷 에러인지
  • SENS 콘솔: 발송 요청 접수/실패 사유(발신번호/정책/요금)
  • 실수신 단말: 관리자 폰에 실제 LMS 도착(지연/차단/스팸함)
검증 루틴
“DB 저장됨?” → “함수 로그 남음?” → “SENS 발송 이력 있음?” → “폰에 실제 도착?”

주의사항

민감정보
  • 실제 키/전화번호는 절대 글/코드/스크린샷에 노출 금지
  • 블로그에는 placeholder만 사용
운영에서 자주 터지는 포인트
  • 로컬에서 됐는데 운영에서 안 됨 → secrets 미설정 or 재배포 누락
  • SENS 실패 → 발신번호 미승인 / 서명 문자열 오타 / 요청 포맷 불일치
verify_jwt = false는 편하지만 공개 호출이 가능해집니다.
운영 트래픽이 늘면 레이트리밋/캡차/토큰 검증 같은 안전장치도 같이 붙이는 걸 추천해요.
※ 2편은 “폼 → DB 저장 → Edge Function → SENS LMS 발송”까지 정리했습니다. 다음 편에서는 운영 내구성(스팸 방지, 재시도/큐잉, 관리자 조회/재발송)을 더 올리는 쪽으로 확장해볼 예정!

댓글

가장 많이 본 글