0. 아래와 같은 Auth.js 기본 세팅 + apollo 세팅은 되어있다는 가정
// lib/auth.ts
import NextAuth from "next-auth";
import Google from "next-auth/providers/google";
import { HasuraAdapter } from "@auth/hasura-adapter";
import { JWT } from "next-auth/jwt";
declare module "next-auth/jwt" {
interface JWT {
id: string;
role: string;
}
}
export const { handlers, signIn, signOut, auth } = NextAuth({
adapter: HasuraAdapter({
endpoint: process.env.NEXT_PUBLIC_HASURA_GRAPHQL_URL!,
adminSecret: process.env.HASURA_GRAPHQL_ADMIN_SECRET!,
}),
providers: [
Google({
clientId: process.env.AUTH_GOOGLE_ID!,
clientSecret: process.env.AUTH_GOOGLE_SECRET!,
}),
],
session: {
strategy: "jwt",
maxAge: 30 * 24 * 60 * 60, // 30일
},
callbacks: {
async jwt({ token, user, account }) {
// 초기 로그인 시
if (user) {
token.id = user.id;
token.email = user.email;
token.role = "user"; // 기본 역할
// Hasura에 사용자 정보 저장
await createOrUpdateUser({
id: user.id,
email: user.email!,
name: user.name,
image: user.image,
});
}
return token;
},
async session({ session, token }) {
if (token && session.user) {
session.user.id = token.id;
session.user.role = token.role;
session.user.email = token.email!;
}
return session;
},
},
});
// app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;
1. 릴레이션 테이블 필요할 듯? 대략 이런 느낌
-- SMS 인증 코드 테이블
CREATE TABLE sms_verifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
phone VARCHAR(20) NOT NULL,
code VARCHAR(6) NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
verified BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(),
INDEX idx_phone_code (phone, code),
INDEX idx_expires (expires_at)
);
2. 알리고 가입하고, 키 받기, 환경변수
# SMS 서비스 (알리고 예시)
ALIGO_API_KEY=your-aligo-api-key
ALIGO_USER_ID=your-aligo-user-id
ALIGO_SENDER=01012345678 # 발신번호
3. 6자리 인증번호 생성 유틸 함수 작성
import crypto from "crypto";
// 6자리 인증번호 생성
export function generateVerificationCode(): string {
return crypto.randomInt(100000, 999999).toString();
}
// 알리고 SMS 발송
export async function sendSMS(phone: string, message: string) {
const formData = new URLSearchParams({
key: process.env.ALIGO_API_KEY!,
user_id: process.env.ALIGO_USER_ID!,
sender: process.env.ALIGO_SENDER!,
receiver: phone,
msg: message,
testmode_yn: process.env.NODE_ENV === "development" ? "Y" : "N",
});
// 발송 api POST 요청
try {
const response = await fetch("<https://apis.aligo.in/send/>", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: formData,
});
const result = await response.json();
if (result.result_code !== "1") {
throw new Error(`SMS 발송 실패: ${result.message}`);
}
return result;
} catch (error) {
console.error("SMS 발송 에러:", error);
throw error;
}
}
5. send 라우트 핸들러 작성(코드, 만료시간, 알리고 api 호출 부분)
import { gql } from '@apollo/client';
// 이런 식의 뮤테이션이 있다고 가정
const INSERT_VERIFICATION = gql`
mutation InsertVerification($phone: String!, $code: String!, $expiresAt: timestamptz!) {
insert_sms_verifications_one(object: {
phone: $phone,
code: $code,
expires_at: $expiresAt
}) {
id
}
}
`;
// 최근 요청 확인
const CHECK_RECENT_REQUEST = gql`
query CheckRecentRequest($phone: String!, $since: timestamptz!) {
sms_verifications(
where: {
phone: { _eq: $phone },
created_at: { _gt: $since }
},
limit: 1
) {
id
created_at
}
}
`;
// app/api/auth/sms/send/route.ts
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { generateVerificationCode, sendSMS } from "@/lib/sms";
import { getClient } from '@/lib/apollo/server-client'
import { ApolloError } from '@apollo/client';
export async function POST(req: NextRequest) {
try {
// 1. 세션 확인
const session = await auth();
if (!session) {
return NextResponse.json(
{ error: "로그인이 필요합니다" },
{ status: 401 }
);
}
// 2. 요청 데이터 파싱
const { phone } = await req.json();
// 3. 전화번호 형식 검증
const phoneRegex = /^01([0|1|6|7|8|9])-?([0-9]{3,4})-?([0-9]{4})$/;
if (!phoneRegex.test(phone)) {
return NextResponse.json(
{ error: "올바른 전화번호 형식이 아닙니다" },
{ status: 400 }
);
}
// 4. 하이픈 제거
const cleanPhone = phone.replace(/-/g, "");
// 5. Apollo Client 가져오기
const client = getClient();
// 6. ⭐⭐ 레이트 리미팅: 1분 내 중복 요청 확인
const oneMinuteAgo = new Date(Date.now() - 60 * 1000).toISOString();
try {
const { data: recentData } = await client.query({
query: CHECK_RECENT_REQUEST,
variables: { phone: cleanPhone, since: oneMinuteAgo },
fetchPolicy: 'network-only' // 캐시 사용 안 함
});
if (recentData.sms_verifications.length > 0) {
const lastRequest = new Date(recentData.sms_verifications[0].created_at);
const waitSeconds = Math.ceil((60000 - (Date.now() - lastRequest.getTime())) / 1000);
return NextResponse.json(
{ error: `${waitSeconds}초 후에 다시 시도해주세요` },
{ status: 429 } // Too Many Requests
);
}
// ⭐⭐ 명확한 에러 응답을 바로 반환 - 중복 확인 실패는 바로 리턴해서 사용자에게 명확히 알리는게 좋음
} catch (error) {
console.error("중복 요청 확인 에러:", error);
return NextResponse.json(
{ error: "요청 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요." },
{ status: 500 }
);
}
// 7. 인증번호 생성
const code = generateVerificationCode();
const expiresAt = new Date(Date.now() + 3 * 60 * 1000).toISOString(); // 3분
// 8. DB에 저장
try {
await client.mutate({
mutation: INSERT_VERIFICATION,
variables: { phone: cleanPhone, code, expiresAt }
});
} catch (error) {
console.error("인증번호 저장 에러:", error);
if (error instanceof ApolloError) {
console.error("GraphQL 에러:", error.graphQLErrors);
return NextResponse.json(
{ error: "인증번호 저장에 실패했습니다" },
{ status: 500 }
);
}
throw error; // 예상치 못한 에러는 외부 catch로
}
// 9. SMS 발송
try {
const message = `[서비스명] 인증번호는 [${code}]입니다. 3분 이내에 입력해주세요.`;
await sendSMS(cleanPhone, message);
} catch (error) {
// ⭐⭐ SMS 발송 실패했지만 DB에는 저장되어 있으므로
// 인증번호는 유효 => 재발송 API 로~~
// 이후 클라이언트에서 적절한 재시도 UI 제공 필수
return NextResponse.json(
{
error: "SMS 발송에 실패했습니다",
canResend: true, // ⭐⭐ 재시도 가능 플래그
retryEndpoint: "/api/auth/sms/resend" // ⭐⭐ 재발송 API 경로
},
{ status: 503 }
);
}
// 10. 성공 응답
return NextResponse.json({
success: true,
message: "인증번호가 발송되었습니다",
});
} catch (error) {
console.error("SMS 발송 에러:", error);
let errorMsg = "인증번호 발송에 실패했습니다";
let statusCode = 500;
if (error instanceof ApolloError) {
console.error("GraphQL 에러:", error.graphQLErrors);
console.error("Network 에러:", error.networkError);
if (error.graphQLErrors.length > 0) {
errorMsg = "데이터베이스 오류가 발생했습니다";
}
if (error.networkError) {
errorMsg = "네트워크 오류가 발생했습니다";
statusCode = 503;
}
}
return NextResponse.json(
{ error: errorMsg },
{ status: statusCode }
);
}
}
6. verify 라우트 핸들러 작성(인증)
import { gql } from '@apollo/client';
// 이런식의 쿼리, 뮤테이션이 있다고 가정
// 인증번호 확인
const VERIFY_CODE_QUERY = gql`
query VerifyCode($phone: String!, $code: String!, $now: timestamptz!) {
sms_verifications(
where: {
phone: { _eq: $phone },
code: { _eq: $code },
verified: { _eq: false },
expires_at: { _gt: $now } # ⭐ "now()"는 변수로 전달
},
order_by: { created_at: desc },
limit: 1
) {
id
}
}
`;
// 인증 완료 처리
const UPDATE_VERIFICATION_AND_USER = gql`
mutation UpdateVerificationAndUser($verificationId: uuid!, $userId: uuid!, $phone: String!) {
update_sms_verifications_by_pk(
pk_columns: { id: $verificationId },
_set: { verified: true }
) {
id
}
update_users_by_pk(
pk_columns: { id: $userId },
_set: { phone: $phone, phone_verified: true }
) {
id
}
}
`;
// app/api/auth/sms/verify/route.ts
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { ApolloError } from '@apollo/client';
import { getClient } from '@/lib/apollo/server-client'
export async function POST(req: NextRequest) {
try {
// 1. 세션 확인
const session = await auth();
if (!session) {
return NextResponse.json(
{ error: "로그인이 필요합니다" },
{ status: 401 }
);
}
// 2. 요청 데이터 파싱
const { phone, code } = await req.json();
// 3. 입력값 검증
if (!phone || !code) {
return NextResponse.json(
{ error: "전화번호와 인증번호를 입력해주세요" },
{ status: 400 }
);
}
if (code.length !== 6 || !/^\\d+$/.test(code)) {
return NextResponse.json(
{ error: "인증번호는 6자리 숫자여야 합니다" },
{ status: 400 }
);
}
const cleanPhone = phone.replace(/-/g, "");
// 4. Apollo Client 가져오기
const client = getClient();
// 5. 인증번호 확인
const now = new Date().toISOString();
let queryData;
try {
const result = await client.query({
query: VERIFY_CODE_QUERY,
variables: { phone: cleanPhone, code, now },
fetchPolicy: 'network-only' // 캐시 무시
});
queryData = result.data;
} catch (error) {
console.error("인증번호 조회 에러:", error);
if (error instanceof ApolloError) {
console.error("GraphQL 에러:", error.graphQLErrors);
return NextResponse.json(
{ error: "인증번호 확인 중 오류가 발생했습니다" },
{ status: 500 }
);
}
throw error;
}
// 6. 인증번호 검증
if (queryData.sms_verifications.length === 0) {
return NextResponse.json(
{ error: "인증번호가 올바르지 않거나 만료되었습니다" },
{ status: 400 }
);
}
const verificationId = queryData.sms_verifications[0].id;
// 7. 인증 완료 처리
try {
await client.mutate({
mutation: UPDATE_VERIFICATION_AND_USER,
variables: {
verificationId,
userId: session.user.id,
phone: cleanPhone,
},
});
} catch (error) {
console.error("인증 완료 처리 에러:", error);
if (error instanceof ApolloError) {
console.error("GraphQL 에러:", error.graphQLErrors);
return NextResponse.json(
{ error: "인증 처리 중 오류가 발생했습니다" },
{ status: 500 }
);
}
throw error;
}
// 8. 성공 응답
return NextResponse.json({
success: true,
message: "인증이 완료되었습니다",
});
} catch (error) {
console.error("인증 확인 에러:", error);
let errorMsg = "인증 확인에 실패했습니다";
let statusCode = 500;
if (error instanceof ApolloError) {
console.error("GraphQL 에러:", error.graphQLErrors);
console.error("Network 에러:", error.networkError);
if (error.graphQLErrors.length > 0) {
errorMsg = "데이터베이스 오류가 발생했습니다";
}
if (error.networkError) {
errorMsg = "네트워크 오류가 발생했습니다";
statusCode = 503;
}
}
return NextResponse.json(
{ error: errorMsg },
{ status: statusCode }
);
}
}
7. resend 재발송 라우트 핸들러 (5. send 에서 sms 발송 실패시 클라이언트에서 retryEndpoint로 호출)
import { gql } from '@apollo/client';
const GET_LATEST_CODE = gql`
query GetLatestCode($phone: String!, $since: timestamptz!) {
sms_verifications(
where: {
phone: { _eq: $phone },
verified: { _eq: false },
expires_at: { _gt: "now()" },
created_at: { _gt: $since }
},
order_by: { created_at: desc },
limit: 1
) {
id
code
expires_at
}
}
`;
// app/api/auth/sms/resend/route.ts
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { sendSMS } from "@/lib/sms";
import { getClient } from '@/lib/apollo/server-client';
import { ApolloError } from '@apollo/client';
export async function POST(req: NextRequest) {
try {
const session = await auth();
if (!session) {
return NextResponse.json(
{ error: "로그인이 필요합니다" },
{ status: 401 }
);
}
const { phone } = await req.json();
const cleanPhone = phone.replace(/-/g, "");
const client = getClient();
// ⭐ 최근 5분 내 생성된 인증번호 조회
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString();
const { data } = await client.query({
query: GET_LATEST_CODE,
variables: { phone: cleanPhone, since: fiveMinutesAgo },
fetchPolicy: 'network-only'
});
if (data.sms_verifications.length === 0) {
return NextResponse.json(
{ error: "유효한 인증번호가 없습니다. 처음부터 다시 시도해주세요." },
{ status: 404 }
);
}
const { code, expires_at } = data.sms_verifications[0];
// ⭐ 기존 인증번호로 SMS 재발송
const message = `[서비스명] 인증번호는 [${code}]입니다. 3분 이내에 입력해주세요.`;
try {
await sendSMS(cleanPhone, message);
} catch (error) {
console.error("SMS 재발송 에러:", error);
return NextResponse.json(
{ error: "SMS 재발송에 실패했습니다" },
{ status: 503 }
);
}
return NextResponse.json({
success: true,
message: "인증번호가 재발송되었습니다",
expiresAt: expires_at
});
} catch (error) {
console.error("재발송 에러:", error);
let errorMsg = "재발송에 실패했습니다";
let statusCode = 500;
if (error instanceof ApolloError) {
console.error("GraphQL 에러:", error.graphQLErrors);
console.error("Network 에러:", error.networkError);
if (error.graphQLErrors.length > 0) {
errorMsg = "데이터베이스 오류가 발생했습니다";
}
if (error.networkError) {
errorMsg = "네트워크 오류가 발생했습니다";
statusCode = 503;
}
}
return NextResponse.json(
{ error: errorMsg },
{ status: statusCode }
);
}
}