Use Cases (업데이트: 2026. 6. 2.)

Claude Code로 Twilio SMS 연동하기: 알림, Verify, Webhook 실전

Claude Code로 Twilio SMS 알림을 구현합니다. E.164, 동의, 멱등성, 재시도, Verify, 상태 콜백까지.

Claude Code로 Twilio SMS 연동하기: 알림, Verify, Webhook 실전

SMS는 사용자가 앱을 열지 않아도 도달할 수 있는 강한 알림 채널입니다. 배송 안내, 예약 리마인드, 장애 알림, 로그인 확인, 고객 문의 접수 안내처럼 이메일이나 푸시만으로 부족한 상황에 잘 맞습니다.

하지만 Twilio SMS 연동은 “API 한 번 호출”로 끝나지 않습니다. 실제 서비스에서는 전화번호 형식, 사용자 동의, 중복 발송 방지, 실패 재시도, 상태 콜백, Webhook 서명 검증, 개인정보가 남지 않는 로그까지 함께 설계해야 합니다.

이 글은 Claude Code에 Twilio SMS 연동을 맡길 때 필요한 프롬프트와 검토 관점을 정리합니다. 예시는 Express + TypeScript로 작성하며, SMS 발송, Twilio Verify, 상태 콜백, 멱등성, 재시도, 로그, 동의와 보안 주의점을 포함합니다. 관련 배경은 인증 구현, Webhook 구현, Secrets Management도 함께 보세요.

Twilio SMS를 쉽게 이해하기

Twilio는 통신 기능을 API로 제공하는 서비스입니다. 애플리케이션은 Twilio에 “이 발신 번호에서 이 수신 번호로 이 문자를 보내 달라”고 요청합니다. Twilio는 통신망으로 메시지를 전달하고, 추적에 사용할 Message SID를 반환합니다.

전화번호는 E.164 형식을 사용합니다. +15558675310이나 +819012345678처럼 더하기 기호, 국가 코드, 번호 본문으로 구성된 국제 형식입니다. 사용자가 보는 지역 표기와 달리 API가 안정적으로 이해하는 형식이라고 보면 됩니다. 자세한 기준은 Twilio의 국제 전화번호 형식 안내를 확인하세요.

발송 API가 성공해도 SMS 흐름은 끝나지 않습니다. 상태는 queued, sent, delivered, undelivered, failed처럼 나중에 바뀔 수 있습니다. 이 변경을 받는 것이 Status Callback입니다. 구현할 때는 Twilio의 Programmable Messaging, Node.js SMS 튜토리얼, Messaging Webhooks, 상태 콜백 가이드를 공식 기준으로 삼으세요.

현실적인 사용 사례

처음부터 범용 SMS 함수를 만들기보다, 업무 이벤트와 실패 규칙을 먼저 정하는 편이 안전합니다.

사용 사례SMS가 유용한 이유주의할 점
주문 및 배송 알림이메일을 놓친 고객에게도 상태 변경을 알릴 수 있음추적 URL 오류, 중복 발송, 수신 거부
예약 리마인드노쇼와 당일 혼선을 줄일 수 있음시간대, 심야 발송, 동의 기록
장애 및 관리자 알림Slack이나 이메일을 못 본 담당자에게 도달알림 폭주, rate limit, escalation
로그인 확인 및 2FA계정 보호에 사용직접 OTP를 만들기보다 Twilio Verify 검토
문의 접수 회신요청이 접수되었음을 바로 알림본문에 민감 정보를 넣지 않기

요금, 지원 국가, 발신자 등록, A2P와 같은 제도와 규정은 바뀔 수 있습니다. 이 글에서는 고정된 가격이나 법적 요구 사항을 단정하지 않습니다. 출시 전에는 Twilio Console, 최신 공식 문서, 필요한 법무 검토를 기준으로 삼아야 합니다.

Claude Code 프롬프트

Claude Code에는 단순 발송 코드가 아니라 운영 조건을 함께 전달합니다.

Implement Twilio SMS notifications in Express + TypeScript.

Requirements:
- Read Twilio credentials, sender number, and Verify Service SID from env vars
- Validate phone numbers in E.164 format with Zod
- Add POST /api/order-shipped-sms for order shipment SMS
- Use eventId as the idempotency key so duplicate events do not send twice
- Retry only 429 and 5xx-style transient failures
- Never log full phone numbers, full message bodies, Auth Tokens, or OTP codes
- Receive status callbacks at POST /twilio/status-callback
- Require Twilio signature validation in production
- Add Twilio Verify start/check endpoints
- Include .env.example, package.json, run commands, and curl examples

멱등성이란 같은 업무 이벤트가 여러 번 들어와도 결과가 안전하게 유지되는 성질입니다. SMS는 발송 후 취소하기 어렵기 때문에 큐 재시도, Webhook 재전송, 배치 재실행, 운영자 수동 처리 모두 같은 키로 보호해야 합니다.

flowchart LR
  A["주문 상태 변경"] --> B["멱등성 확인"]
  B --> C["Twilio Messaging API"]
  C --> D["SMS 전달"]
  C --> E["Message SID 저장"]
  D --> F["Status Callback"]
  F --> G["서명 검증"]
  G --> H["전달 로그 업데이트"]
  I["로그인 확인"] --> J["Twilio Verify"]

최소 프로젝트 만들기

아래 코드는 그대로 복사해 실행할 수 있는 작은 Express 프로젝트입니다. 실제 SMS 전송에는 Twilio 인증 정보가 필요하지만, 환경 변수 파싱, 입력 검증, 중복 이벤트 처리, 로컬 콜백 파싱은 먼저 확인할 수 있습니다.

mkdir twilio-sms-demo
cd twilio-sms-demo
npm init -y
npm install express twilio dotenv zod
npm install -D typescript tsx @types/express
{
  "type": "module",
  "scripts": {
    "dev": "tsx src/app.ts"
  },
  "dependencies": {
    "dotenv": "latest",
    "express": "latest",
    "twilio": "latest",
    "zod": "latest"
  },
  "devDependencies": {
    "@types/express": "latest",
    "tsx": "latest",
    "typescript": "latest"
  }
}
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  }
}
# .env.example
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_AUTH_TOKEN=replace-with-your-auth-token
TWILIO_FROM_NUMBER=+15551234567
TWILIO_VERIFY_SERVICE_SID=VAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
PUBLIC_BASE_URL=https://example.ngrok-free.app
REQUIRE_TWILIO_SIGNATURE=true
PORT=3000

PUBLIC_BASE_URL은 Twilio가 접근할 수 있는 HTTPS 주소여야 합니다. 로컬 개발에서는 ngrok이나 Cloudflare Tunnel을 사용할 수 있습니다. Twilio 서명 검증은 정확한 URL에 민감하므로 프로토콜, 프록시, 쿼리 문자열, 끝 슬래시 차이를 확인하세요.

SMS, 멱등성, Status Callback 구현

src/app.ts를 만들고 다음 코드를 붙여 넣습니다. 예시는 메모리 Map을 사용하지만, 운영 환경에서는 PostgreSQL, Redis, DynamoDB 같은 영속 저장소와 unique constraint를 사용하세요.

import "dotenv/config";
import express from "express";
import twilio from "twilio";
import { z } from "zod";

const e164Schema = z.string().regex(/^\+[1-9]\d{1,14}$/, {
  message: "Use E.164 format, for example +819012345678.",
});

const envSchema = z.object({
  TWILIO_ACCOUNT_SID: z.string().regex(/^AC[a-fA-F0-9]{32}$/),
  TWILIO_AUTH_TOKEN: z.string().min(20),
  TWILIO_FROM_NUMBER: e164Schema,
  TWILIO_VERIFY_SERVICE_SID: z.string().regex(/^VA[a-fA-F0-9]{32}$/).optional(),
  PUBLIC_BASE_URL: z.string().url(),
  REQUIRE_TWILIO_SIGNATURE: z.enum(["true", "false"]).default("true"),
  PORT: z.coerce.number().int().positive().default(3000),
});

const env = envSchema.parse(process.env);
const client = twilio(env.TWILIO_ACCOUNT_SID, env.TWILIO_AUTH_TOKEN);
const app = express();

type Delivery = {
  status: "pending" | "sent" | "failed";
  attempts: number;
  updatedAt: string;
  sid?: string;
  error?: string;
};

const deliveries = new Map<string, Delivery>();

const orderSmsSchema = z.object({
  eventId: z.string().min(6).max(120),
  phone: e164Schema,
  orderId: z.string().min(1).max(80),
  trackingUrl: z.string().url().optional(),
  consentAt: z.string().datetime(),
});

const statusCallbackSchema = z.object({
  MessageSid: z.string().min(2),
  MessageStatus: z.string().min(2),
  To: z.string().optional(),
  ErrorCode: z.string().optional(),
}).passthrough();

function maskPhone(phone: string) {
  return phone.replace(/\d(?=\d{4})/g, "*");
}

function delay(ms: number) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

function getErrorStatus(error: unknown) {
  if (typeof error === "object" && error && "status" in error) {
    return Number((error as { status?: number }).status ?? 0);
  }
  return 0;
}

function getErrorMessage(error: unknown) {
  return error instanceof Error ? error.message : String(error);
}

function shouldRetry(error: unknown) {
  const status = getErrorStatus(error);
  return status === 429 || status >= 500;
}

async function sendSmsWithRetry(params: {
  to: string;
  body: string;
  statusCallback: string;
  maxAttempts?: number;
}) {
  const maxAttempts = params.maxAttempts ?? 3;

  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
    try {
      const message = await client.messages.create({
        body: params.body,
        from: env.TWILIO_FROM_NUMBER,
        statusCallback: params.statusCallback,
        to: params.to,
      });

      return { sid: message.sid, attempts: attempt };
    } catch (error) {
      if (attempt === maxAttempts || !shouldRetry(error)) {
        throw error;
      }
      await delay(500 * attempt);
    }
  }

  throw new Error("SMS retry loop ended unexpectedly.");
}

function verifyTwilioSignature(req: express.Request) {
  const signature = req.header("x-twilio-signature") ?? "";
  const callbackUrl = new URL(req.originalUrl, env.PUBLIC_BASE_URL).toString();
  return twilio.validateRequest(env.TWILIO_AUTH_TOKEN, signature, callbackUrl, req.body);
}

app.use(express.json());

app.post("/api/order-shipped-sms", async (req, res) => {
  const parsed = orderSmsSchema.safeParse(req.body);

  if (!parsed.success) {
    return res.status(400).json({
      error: "invalid_request",
      details: parsed.error.flatten(),
    });
  }

  const input = parsed.data;
  const idempotencyKey = `order-shipped:${input.eventId}`;
  const existing = deliveries.get(idempotencyKey);

  if (existing?.status === "sent") {
    return res.status(200).json({
      duplicate: true,
      sid: existing.sid,
      status: existing.status,
    });
  }

  if (existing?.status === "pending") {
    return res.status(202).json({
      duplicate: true,
      status: existing.status,
    });
  }

  deliveries.set(idempotencyKey, {
    attempts: 0,
    status: "pending",
    updatedAt: new Date().toISOString(),
  });

  const trackingText = input.trackingUrl ? ` Tracking: ${input.trackingUrl}` : "";
  const body = `Your order ${input.orderId} has shipped.${trackingText}`;
  const statusCallback = new URL("/twilio/status-callback", env.PUBLIC_BASE_URL).toString();

  try {
    const result = await sendSmsWithRetry({
      body,
      statusCallback,
      to: input.phone,
    });

    deliveries.set(idempotencyKey, {
      attempts: result.attempts,
      sid: result.sid,
      status: "sent",
      updatedAt: new Date().toISOString(),
    });

    console.log("sms_sent", {
      idempotencyKey,
      sid: result.sid,
      to: maskPhone(input.phone),
    });

    return res.status(202).json({ accepted: true, sid: result.sid });
  } catch (error) {
    deliveries.set(idempotencyKey, {
      attempts: 3,
      error: getErrorMessage(error),
      status: "failed",
      updatedAt: new Date().toISOString(),
    });

    console.error("sms_failed", {
      idempotencyKey,
      message: getErrorMessage(error),
      status: getErrorStatus(error),
      to: maskPhone(input.phone),
    });

    return res.status(502).json({ error: "sms_delivery_failed" });
  }
});

app.post("/twilio/status-callback", express.urlencoded({ extended: false }), (req, res) => {
  if (env.REQUIRE_TWILIO_SIGNATURE === "true" && !verifyTwilioSignature(req)) {
    return res.status(403).send("invalid signature");
  }

  const parsed = statusCallbackSchema.safeParse(req.body);

  if (!parsed.success) {
    return res.status(400).send("invalid callback");
  }

  console.log("twilio_status", {
    errorCode: parsed.data.ErrorCode,
    sid: parsed.data.MessageSid,
    status: parsed.data.MessageStatus,
    to: parsed.data.To ? maskPhone(parsed.data.To) : undefined,
  });

  return res.status(204).send();
});

app.listen(env.PORT, () => {
  console.log(`Twilio SMS demo listening on http://localhost:${env.PORT}`);
});

서버를 실행하고 요청을 보냅니다. 실제 발송에는 유효한 Twilio 인증 정보, 발신 번호, 접근 가능한 콜백 URL, 계정에서 허용된 수신 번호가 필요합니다.

npm run dev
curl -X POST http://localhost:3000/api/order-shipped-sms \
  -H "Content-Type: application/json" \
  -d '{
    "eventId": "order_1001_shipped_v1",
    "phone": "+15558675310",
    "orderId": "1001",
    "trackingUrl": "https://example.com/track/1001",
    "consentAt": "2026-06-02T09:00:00.000Z"
  }'

같은 eventId로 다시 요청하면 두 번째 SMS를 보내지 않고 기존 상태를 반환합니다. 운영 환경에서는 이 상태를 영속 DB로 옮기세요.

로컬에서 콜백 형태만 확인하려면 일시적으로 REQUIRE_TWILIO_SIGNATURE=false를 사용할 수 있습니다. 운영에서는 반드시 true로 둡니다.

curl -X POST http://localhost:3000/twilio/status-callback \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "MessageSid=SMxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  --data-urlencode "MessageStatus=delivered" \
  --data-urlencode "To=+15558675310"

OTP는 Twilio Verify를 먼저 검토

로그인 확인이나 2FA는 6자리 난수만으로 끝나지 않습니다. 만료, 재전송 제한, 무차별 대입 방지, 채널 전환, 감사 로그가 필요합니다. Twilio VerifyVerification API가 이 영역을 담당합니다.

다음 코드를 src/app.tsapp.listen 앞에 추가합니다.

const verifyStartSchema = z.object({
  phone: e164Schema,
});

const verifyCheckSchema = z.object({
  code: z.string().min(4).max(10),
  phone: e164Schema,
});

function requireVerifyServiceSid() {
  if (!env.TWILIO_VERIFY_SERVICE_SID) {
    throw new Error("TWILIO_VERIFY_SERVICE_SID is required for Verify.");
  }
  return env.TWILIO_VERIFY_SERVICE_SID;
}

app.post("/api/verify/start", async (req, res) => {
  const parsed = verifyStartSchema.safeParse(req.body);

  if (!parsed.success) {
    return res.status(400).json({ error: "invalid_request" });
  }

  const verification = await client.verify.v2
    .services(requireVerifyServiceSid())
    .verifications.create({
      channel: "sms",
      to: parsed.data.phone,
    });

  return res.status(202).json({ sid: verification.sid, status: verification.status });
});

app.post("/api/verify/check", async (req, res) => {
  const parsed = verifyCheckSchema.safeParse(req.body);

  if (!parsed.success) {
    return res.status(400).json({ error: "invalid_request" });
  }

  const check = await client.verify.v2
    .services(requireVerifyServiceSid())
    .verificationChecks.create({
      code: parsed.data.code,
      to: parsed.data.phone,
    });

  return res.json({ approved: check.status === "approved", status: check.status });
});

Verify가 승인되면 자체 DB의 phoneVerifiedAt 또는 mfaEnabledAt 같은 값을 갱신합니다. 인증 전체 흐름은 인증 구현 가이드Zod 검증 가이드를 함께 참고하세요.

동의, 컴플라이언스, 보안

SMS는 개인 전화번호로 직접 도달하므로 민감한 채널입니다. 사용자가 어떤 목적의 SMS에 동의했는지, 어디서 동의했는지, 수신 거부를 어떻게 처리하는지 기록하세요. 국가, 발신자 유형, 메시지 내용에 따라 요구 사항이 달라지므로 최신 Twilio 문서와 법무 검토를 기준으로 삼아야 합니다.

실제 Account SID와 Auth Token을 코드, 프롬프트, 스크린샷, 로그에 넣지 마세요. .env는 Git에서 제외하고, 운영 환경에서는 호스팅 플랫폼이나 secret manager로 주입합니다. 로그에는 전체 전화번호, 전체 본문, OTP 코드를 남기지 않습니다.

운영에 필요한 정보는 보통 Message SID, 이벤트 ID, 메시지 유형, 마스킹된 번호, Twilio 오류 코드, 시도 횟수, 타임스탬프입니다. 본문 보관이 필요하다면 보관 기간과 접근 권한을 먼저 정하세요.

자주 나오는 실패

지역 전화번호를 그대로 API에 보내는 것, 큐 재시도로 같은 SMS를 여러 번 보내는 것, 공개 Status Callback에서 서명을 검증하지 않는 것, OTP를 직접 만드는 것, 그리고 Message SID 없는 로그가 대표적인 실패입니다. 비동기 처리 설계는 큐 시스템 가이드, 방어 관점은 보안 베스트 프랙티스를 참고하세요.

Claude Code 리뷰 프롬프트

Review this Twilio SMS implementation before production.

Check:
- E.164 validation always runs before sending
- Consent timestamp and message purpose are tracked
- eventId idempotency holds under parallel requests
- Only 429 and 5xx transient failures are retried
- Twilio status callback signature validation is required in production
- Auth Tokens, OTP codes, full phone numbers, and full bodies never reach logs
- Pricing, countries, or regulatory rules are not hard-coded in comments
- Support can trace a failure by Message SID

SMS 연동을 실제 제품에 넣는 팀은 인증, 큐, Webhook, 로그, CLAUDE.md, 리뷰 게이트까지 함께 정리해야 하는 경우가 많습니다. ClaudeCodeLab의 Claude Code 교육과 컨설팅에서 실제 저장소 기준으로 이 흐름을 설계할 수 있습니다.

마무리

Twilio SMS는 짧은 API 호출로 시작하지만, 운영 품질은 E.164, 동의, 멱등성, 재시도, 콜백 서명 검증, 개인정보를 보호하는 로그에서 결정됩니다. Claude Code에 처음부터 이 조건을 주고, 단순 helper가 아니라 운영 통합으로 검토하세요.

이 글의 실습 확인에서는 로컬 E.164 검증, 중복 eventId 처리, Status Callback 파싱, 마스킹 로그 형태를 확인했습니다. 실제 SMS 전달은 Twilio 인증 정보, 발신자 설정, 수신 국가 설정, 현재 Twilio 규칙에 따라 달라지므로 출시 전 작은 테스트 번호로 Message SID와 콜백 상태를 끝까지 추적하세요.

#Claude Code #Twilio #SMS #알림 #API 연동
무료

무료 PDF: Claude Code 치트시트

이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.

개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.

Masa

작성자 소개

Masa

Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.