콘텐츠로 이동

OTP 인증 메시지

OTP(일회용 비밀번호) 인증은 본인 확인, 비밀번호 재설정, 2단계 인증 등 보안이 중요한 시나리오에서 널리 사용됩니다. 이 가이드에서는 K-Message를 활용해 안정적인 OTP 인증 시스템을 구축하는 방법을 알아봅니다.

OTP 인증 메시지의 핵심 요구사항:

  1. 높은 도달률: 사용자가 OTP를 받지 못하면 서비스 이용이 불가능합니다
  2. 빠른 전송: OTP는 보통 3~5분 내에 만료되므로 즉시 전송되어야 합니다
  3. 일관된 포맷: 사용자가 쉽게 코드를 인식하고 입력할 수 있어야 합니다
  4. 재전송 방지: 동일 번호로 과도한 요청을 방지해야 합니다

OTP 인증에는 SMS를 권장합니다.

채널장점단점
SMS모든 휴대폰에서 수신 가능, 높은 도달률건당 비용 발생
알림톡비용 절감, 높은 오픈율카카오톡 미설치 사용자 제외

SMS를 선택하는 이유:

  • 모든 사용자가 수신 보장 (카카오톡 설치 여부 무관)
  • 네트워크 상태와 무관하게 도달
  • 간단한 구현 (템플릿 사전 등록 불필요)
import { KMsg } from "k-msg";
import { SolapiProvider } from "@k-msg/provider/solapi";
// KMsg 인스턴스 생성
const kmsg = new KMsg({
providers: [
new SolapiProvider({
apiKey: process.env.SOLAPI_API_KEY!,
apiSecret: process.env.SOLAPI_API_SECRET!,
defaultFrom: "01000000000",
}),
],
});
// OTP 생성 함수
function generateOTP(length = 6): string {
const digits = "0123456789";
let otp = "";
for (let i = 0; i < length; i++) {
otp += digits[Math.floor(Math.random() * digits.length)];
}
return otp;
}
// OTP 발송 함수
async function sendOTP(phoneNumber: string): Promise<{
success: boolean;
otp?: string;
error?: string;
}> {
const otp = generateOTP(6);
const expiresAt = Date.now() + 3 * 60 * 1000; // 3분 후 만료
// OTP 저장 (Redis, DB 등)
// await redis.set(`otp:${phoneNumber}`, otp, "EX", 180);
const result = await kmsg.send({
to: phoneNumber,
text: `[인증번호] ${otp}\n인증번호는 3분 내에 입력해주세요.`,
});
if (result.isSuccess) {
return { success: true, otp };
}
return { success: false, error: result.error.message };
}
// 사용 예시
const result = await sendOTP("01012345678");
if (result.success) {
console.log("OTP 발송 성공:", result.otp);
} else {
console.log("OTP 발송 실패:", result.error);
}

과도한 OTP 요청을 방지하기 위한 속도 제한 구현입니다.

// Redis 기반 속도 제한 (의사 코드)
async function checkRateLimit(phoneNumber: string): Promise<{
allowed: boolean;
remainingSeconds?: number;
}> {
const key = `otp:limit:${phoneNumber}`;
const count = await redis.get(key);
if (count && parseInt(count) >= 5) {
const ttl = await redis.ttl(key);
return { allowed: false, remainingSeconds: ttl };
}
await redis.incr(key);
await redis.expire(key, 3600); // 1시간 윈도우
return { allowed: true };
}
// 속도 제한이 적용된 OTP 발송
async function sendOTPWithRateLimit(phoneNumber: string) {
const rateCheck = await checkRateLimit(phoneNumber);
if (!rateCheck.allowed) {
return {
success: false,
error: `요청 횟수 초과. ${rateCheck.remainingSeconds}초 후 다시 시도해주세요.`,
};
}
return sendOTP(phoneNumber);
}
async function verifyOTP(
phoneNumber: string,
userInput: string,
): Promise<{ valid: boolean; reason?: string }> {
const key = `otp:${phoneNumber}`;
const storedOTP = await redis.get(key);
if (!storedOTP) {
return { valid: false, reason: "인증번호가 만료되었습니다." };
}
if (storedOTP !== userInput) {
return { valid: false, reason: "인증번호가 일치하지 않습니다." };
}
// 사용된 OTP 삭제
await redis.del(key);
return { valid: true };
}
// 사용 예시
const verifyResult = await verifyOTP("01012345678", "123456");
if (verifyResult.valid) {
console.log("인증 성공");
} else {
console.log("인증 실패:", verifyResult.reason);
}
const result = await kmsg.send({
to: phoneNumber,
text: `[인증번호] ${otp}`,
});
if (result.isFailure) {
const error = result.error;
switch (error.code) {
case "NETWORK_ERROR":
console.log("네트워크 오류. 잠시 후 다시 시도해주세요.");
break;
case "PROVIDER_ERROR":
console.log("메시지 서비스 오류. 관리자에게 문의해주세요.");
break;
case "INVALID_PHONE_NUMBER":
console.log("올바르지 않은 전화번호 형식입니다.");
break;
default:
console.log("알 수 없는 오류:", error.message);
}
}

주 프로바이더 장애 시 자동으로 폴백 프로바이더를 사용합니다.

const kmsg = new KMsg({
providers: [
new SolapiProvider({
apiKey: process.env.SOLAPI_API_KEY!,
apiSecret: process.env.SOLAPI_API_SECRET!,
defaultFrom: "01000000000",
}),
new IWINVProvider({
apiKey: process.env.IWINV_API_KEY!,
}),
],
routing: {
defaultProviderId: "solapi",
// SMS는 첫 번째 프로바이더 실패 시 다음 프로바이더 사용
byType: {
SMS: ["solapi", "iwinv"],
},
strategy: "first",
},
});

일관된 포맷으로 사용자 경험을 개선합니다.

// 권장 포맷
const otpMessage = `[인증번호] ${otp}
인증번호는 3분 내에 입력해주세요.
문의: 고객센터 1588-0000`;
// 피해야 할 포맷
const badMessage = `인증번호는 ${otp}입니다 3분안에 입력하세요`; // 모호함

OTP 발송 이력을 추적합니다.

const kmsg = new KMsg({
providers: [/* ... */],
hooks: {
onSuccess: (ctx, result) => {
logger.info("OTP 발송 성공", {
messageId: result.messageId,
to: maskPhoneNumber(ctx.options.to),
timestamp: ctx.timestamp,
});
},
onError: (ctx, error) => {
logger.error("OTP 발송 실패", {
to: maskPhoneNumber(ctx.options.to),
errorCode: error.code,
errorMessage: error.message,
});
},
},
});
// 전화번호 마스킹
function maskPhoneNumber(phone: string): string {
return phone.replace(/(\d{3})(\d{4})(\d{4})/, "$1-****-$3");
}

개발 환경에서는 Mock Provider를 사용합니다.

import { MockProvider } from "@k-msg/provider";
const kmsg = new KMsg({
providers: process.env.NODE_ENV === "production"
? [
new SolapiProvider({
apiKey: process.env.SOLAPI_API_KEY!,
apiSecret: process.env.SOLAPI_API_SECRET!,
defaultFrom: "01000000000",
}),
]
: [new MockProvider()],
});
용도길이문자 집합
로그인 2FA6자리숫자만
결제 인증6자리숫자만
본인 확인4~6자리숫자만

보안 요구사항에 따라 적절한 길이를 선택하세요. 6자리가 일반적으로 권장됩니다.

  • OTP 인증에는 SMS가 가장 안정적입니다
  • 속도 제한으로 남용을 방지하세요
  • Result 패턴으로 명시적인 에러 처리를 하세요
  • 폴백 프로바이더로 가용성을 높이세요
  • 로깅으로 이슈 추적이 가능하게 하세요