Claude Code로 Twilio SMS 연동하기: 알림, Verify, Webhook 실전
Claude Code로 Twilio SMS 알림을 구현합니다. E.164, 동의, 멱등성, 재시도, Verify, 상태 콜백까지.
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 Verify와 Verification API가 이 영역을 담당합니다.
다음 코드를 src/app.ts의 app.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와 콜백 상태를 끝까지 추적하세요.
무료 PDF: Claude Code 치트시트
이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.
개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.
작성자 소개
Masa
Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.
관련 글
Obsidian 메모를 CLAUDE.md로 바꾸는 Claude Code 워크플로
Obsidian 작업 메모를 CLAUDE.md 운영 노트로 정리해 Claude Code 세션의 문맥 반복을 줄입니다.
Claude Code Revenue CTA Routing: 글에서 PDF, Gumroad, 상담으로 보내기
독자 의도에 따라 무료 PDF, Gumroad 상품, 상담으로 나누는 Claude Code CTA 설계입니다.
Claude Code 팀 인계 규칙: 리뷰 증거, 권한, 롤백, 수익 경로까지 넘기는 법
Claude Code 작업을 팀에 넘길 때 필요한 증거, 권한 규칙, 롤백, 무료 PDF, Gumroad, 상담 경로 체크리스트.