Logo

Prolip's Blog

블로그(7) - Serverless, API Routes 알아보기
  • #Blog
  • #Next.js

블로그(7) - Serverless, API Routes 알아보기

Prolip

2024-03-16

API Routes와 node-mailer로 메일 전송하기 근데 Serverless 개념을 곁들인..

시작…

이번 포스팅은 node-mailer와 API Routes를 이용해 메일 전송 기능을 어떻게 구현했는지 기록해보려 합니다..

node-mailer

Nodemailer :: Nodemailer 공식 페이지 입니다.

노드메일러는 node.js 환경에서 동작하는 간편한 메일 전송 모듈입니다.
메일 전송이 가능하며, html 형식으로도 전송 가능합니다.

자세한 설명은 공식문서를 참조해보시면 좋습니다.

export type formData = { email: string; title: string; text: string; }; export async function sendMailToAdmin(data: formData) { const { NAVER_EMAIL, NAVER_PASS, GMAIL_ID } = process.env; const transporter = nodemailer.createTransport({ service: "naver", host: "smtp.naver.com", port: 587, auth: { user: NAVER_EMAIL, pass: NAVER_PASS }, }); transporter.use("compile", htmlToText()); const mailOptions = { from: NAVER_EMAIL, to: GMAIL_ID, subject: data.title, html: ` <h1>제목 : ${data.title}</h1> <h3>보낸이 : ${data.email}</h3> <h2>===== 내용 =====</h2> <p>${data.text}</p> `, }; transporter.sendMail(mailOptions, (error, info) => { if (error) { console.log("에러 발생! " + error); } else { console.log("전송 성공! " + info); } }); }

createTransport를 이용해 transporter라는 객체를 생성해 메일 서버와의 연결을 설정할 수 있습니다.

대표적으로 gmail을 사용하지만 저는 naver를 이용해 전송하기 때문에 naver의 smtp 서버를 사용하도록 설정했습니다.

auth는 user와 pass로 이루어진 객체로 메일 전송에 사용되는 메일 아이디와 비밀번호를 작성하는데 비밀번호를 코드상에 노출 시킬 수 없기 때문에 env에 등록해 사용힙나다.

제목과 보낸이 그리고 내용을 조금이나마 예쁘게 html 형식으로 보낼 수 있는 nodemailer-html-to-text를 설치해봤습니다.

이후 mailOptions 객체를 생성해 transporter의 sendMail 함수로 메일을 전송하는게 끝입니다.

너무.. 간단하죠?

이제 이렇게 구현한 sendMailToAdmin 함수를 멋있게 API로 어떻게 사용할까요??

API Routes

바로 API Routes를 사용해 통신하는 것입니다..

우선 Serverless Function이 무엇인지 짚고 넘어가는게 좋겠습니다.
API routes는 Serverless Function 컨셉으로 이루어져있으니까요..

Serverless

Serverless란 무엇일까요?

쉽게 설명하자면 Serverless란 우리가 직접 서버를 만들고 관리하는 것이 아닌, 우리에게서 서버가 사라진 개념입니다.

근데 서버가 사라졌다는 것이 영원히 사라진다는 개념은 아닙니다.

여전히 물리적인 서버는 운영되지만 개발자가 직접 서버를 구축하지 않고 우리의 서비스를 클라우드 측에서 사용자 규모에 따라 동적으로 서버의 자원을 할당해 알아서 운영해주는 것입니다.

따라서 개발자는 하드웨어 스펙, 운영체제에 관련된 유지보수, 서버 모니터링, 프로비저닝 스케일링 등 서버를 관리하는데 필요한 리소스를 오로지 개발에 집중해 사용할 수 있게 되는 것입니다.

이를 FaaS (Function as a Service) 라고도 부릅니다.

Serverless Function

이제 Serverless Function의 컨셉을 알아봅시다!

간단히 설명하자면 ‘요청이 들어오면 지정된 함수를 실행시킨다.’ 입니다.

예를 들어보겠습니다.

  1. 데이터베이스에 등록된 이용자의 정보를 받아오는 함수를 하나 만듭니다.
  2. 어떤 요청을 통해 함수가 실행될지 명시해 클라우드 호스팅 플랫폼에 등록해줍니다.
  3. 해당 요청이 들어올 때 이용자의 정보를 읽어 사용자에게 보내줍니다.

이 과정 속에서 사용자가 없다면 서버는 대기 상태 속에서 자원을 할당하지 않습니다.

하지만 요청이 들어온다면 서버의 자원을 할당해 요청을 처리하고, 다시 대기 상태로 들어갑니다.

즉, 서버가 항상 운영되지 않고 요청에 의해 함수가 실행되기 때문에 실제 사용 자원에 대한 비용만 청구되어 비용적인 측면에서 굉장히 합리적이라고 볼 수 있습니다.

설명만 들어보면 정말 좋아보이지만 단점도 존재합니다.

  1. 람다 함수로 등록할 수 있는 함수의 메모리 사이즈가 정해져있습니다. 너무 복잡하고 큰 함수는 등록하지 못하는 겁니다.
  2. 항상 서버가 운영되는 것이 아닌 요청에 의해 함수가 실행되기 때문에 해당 함수를 실행하기 위해 환경 조성 및 인스턴스 시작까지 시간이 소요됩니다. - 이를 Cold Start라고도 하
  3. 타임아웃이 존재합니다. 즉 함수가 시작되고 끝나는 시간까지 주어진 시간을 초과하면 종료됩니다. 만약 작업을 끝내지 못했다면 주기마다 함수를 재요청하게 됩니다.

“API 라우트 쓰면 그냥 단순하게 풀스택 가능합니다!”가 아니라 어떤 개념을 가지고 동작하는지 알아두면 좋을 거 같아 기록해봤습니다..

다시 돌아와 API Routes

Next에선 API 라우트를 사용해 API를 호출할 수 있는 엔드포인트를 쉽게 생성할 수 있습니다.. 쉽게 말하자면 별도의 백엔드 서버 없이 도메인을 통해 API 통신이 가능하다는 것입니다.

Serverless Function에 대해 정리해보며 처음에 정리한 개념이 있습니다. 바로 요청이 들어오면 지정된 함수를 실행한다는 개념입니다.

Routing: API Routes | Next.js (nextjs.org) 공식문서를 참고해봅시다.

export default async function handler(req ,res) { if (req.method === "GET") { res.status(200).json({ message: 'Hello from Next.js!' }); } else if (req.method === 'POST') { 요청에 해당하는 기능 } else if (req.method === 'PUT') { 요청에 해당하는 기능 } res.status(200); }

Serverless Function의 컨셉에 맞게 Endpoint에 해당하는 함수 handler 하나로 동작하게 됩니다.

흠.. 그런데 request가 무엇인지 if 문을 통해 분기 처리하려니 코드가 굉장히 지저분해질 거 같은데요..

// app/api/init/route.ts export async function GET(request: NextRequest) { return NextResponse.json({ message: 'Hello from Next.js!' }); } export async function POST(request: NextRequest) { 해당하는 기능 } export async function PUT(request: NextRequest) { 해당하는 기능 }

짜잔 사실 13버전부터는 함수명을 HTTP 메소드로 설정해 직관적인 설계가 가능합니다.

위와 같이 함수 이름을 GET, POST, PUT, DELETE ... 등 http method 이름으로 지정해 함수를 만들면 해당 경로에 요청이 들어올 때 route 파일에 요청에 해당하는 함수를 실행하게 됩니다.

이제 API Routes가 뭔지도 알았고 어떻게 사용하는지까지 알아냈으니 요청하는 부분부터 만들어보겠습니다.

요청

node-mailer를 이용해 mailData를 받아 메일을 전송하는 로직은 구현했습니다.

그럼 이제 어떻게 클라이언트 단에서 서버로 요청을 보낼까요??

// app/components/ContactForm.tsx const onSubmit = async (data: formData) => { await sendContactMail(data) .then((res) => { reset({ email: "", title: "", text: "" }); setBanner({ message: res.message, state: "success" }); }) .catch((error) => { setBanner({ message: error.message, state: "error" }); }) .finally(() => { setTimeout(() => { setBanner(null); }, 3000); }); }; // services/contact.ts export async function sendContactMail(mailData: formData) { const response = await fetch("/api/contact", { method: "POST", body: JSON.stringify(mailData), headers: { "Content-Type": "application/json", }, }); const data = await response.json(); if (!response.ok) { throw new Error(data.message || "서버 요청에 실패했습니다."); } return data; }

저는 이렇게 구현해봤습니다.

  1. 입력 폼에서 submit 이벤트가 발생하면 sendContactMail 함수로 폼 데이터를 보냅니다.
  2. sendContactMail 함수는 mailData를 받아 /api/contact 경로로 POST 요청을 보냅니다.

그럼 POST 요청은 어떻게 처리할까요?

처리

// api/contact/route.ts export async function POST(req: NextRequest) { const body = await req.json(); return await sendMailToAdmin(body) // .then( () => new NextResponse( JSON.stringify({ message: "메일을 성공적으로 전송했습니다." }), { status: 200 } ) ) // .catch((error) => { console.error(error); return new NextResponse( JSON.stringify({ message: "메일 전송에 실패했습니다." }), { status: 500 } ); }); }

이렇게 POST 요청에 해당하는 함수를 작성합니다.
sendMailToAdmin으로 body에 담긴 mailData를 전달하고, 해당 작업에 따른 response와 http 상태 코드를 반환합니다.

그리고 요청에 따라 반환된 message를 Banner에 전달해 사용자에게 메일 전송 결과를 보여주는 것이죠.
와! 메일 전송 기능 구현에 성공했습니다!!

검증

근데 만약 사용자가 ‘안녕하세요@이메일.컴’ 이런 이메일 형식에 맞지 않는 값을 전송했다고 가정해보겠습니다.

그럼 저는 잘못된 형식의 이메일을 전달 받기 때문에 회신을 할 수 없을 뿐더러 불필요한 요청을 처리하게 됩니다.

yup

yup은 검증하고자 하는 모델의 스키마를 정의할 수 있으며, 해당 스키마를 통해 값이 유효한지 검증해 안정성 있는 설계와 구현이 가능하도록 도와주는 라이브러리 입니다.

사용법에 대한 자세한 설명은 다루지 않겠습니다.

import * as yup from "yup"; const bodySchema = yup.object().shape({ email: yup.string().email().required(), title: yup.string().required(), text: yup.string().required(), }); export async function POST(req: NextRequest) { const body = await req.json(); if (!bodySchema.isValidSync(body)) { return new NextResponse( JSON.stringify({ message: "유효하지 않은 포맷입니다." }), { status: 400 } ); } 이후 동일 }

이렇게 검증 모델의 스키마를 정의해 body에 들어온 mailData를 먼저 검증하고, 유효한 포맷에 한해서만 sendMailToAdmin 함수를 실행하도록 구현이 가능합니다.

마치며..

이번 게시물에선 API Routes의 컨셉인 Serverless Function에 대해 다뤄봤습니다..

함수 이름을 http method로 설정하도록 변경되어 코드를 더 직관적으로 설계할 수 있어 좋은 거 같습니다.

이제 블로그에 관련된 포스팅은 그만하고 이전에 진행했던 프로젝트에 관련된 게시물을 좀 올려볼까 합니다.

셀레니움 ec2에 올렸다가 ssh 마비 돼서 멘붕온 썰, 로컬 상에선 문제 없던 셀레니움 ec2에선 작동 안 해서 프로젝트 접을뻔한 썰 등… 고뇌에 빠져서 새벽 5시까지 머리털 뽑혀라 고생했던 것들 기록해볼까 합니다..

.

.

.

.

뿅..

블로그(6) - metadata, opengraph 설정

블로그(6) - metadata, opengraph 설정

Headless CMS란 무엇일까?

Headless CMS란 무엇일까?