나는 현재 모 대학 건축학과 온라인 졸업 전시회 웹사이트를 제작하는 사이드 프로젝트에 참여 중이다.
생애 첫 웹 프로젝트에 참여하면서 겁도 없이 기획자 포지션을 맡게 되었는데,
웹 기획에 대한 기초가 부족해 협업 및 소통 과정에서 많이 헤맸었다.
그러나 모든 게 처음이었던 나를 끝까지 격려하며 이끌어주시는 천사Y
님을 만난 덕분에,
많은 것을 보고 배우며 포기하지 않고 지금까지 함께 프로젝트를 이어가고 있다.
이번에
Y
님께서 직접 개발 과정에 참여할 수 있는 기회를 주셔서,
사이트 내 이메일 전송 기능 구현을 맡게 되었다.
처음엔 에이블스쿨에서 배웠던 SMTP를 활용해 가볍게 구현이 가능할 거라 생각했다.
그러나, 웹의 경우 보안 이슈로 인해 SMTP를 활용하는 길이 생각보다 녹록치 않았고... OTL
백엔드 지식이 약소했던 나는 결국Y
님의 도움을 받아서 겨우겨우 기능을 구현할 수 있었다. ^_T
늘 부족한 나를 멱살 잡고 끌어주시는Y
님의 노력과 열정이 헛되지 않도록,
그 과정들을 잘 기록해 보려 한다.
유저가 우리에게 문의 메일을 보낼 때, 외부 메일 시스템을 거치지 않고 홈페이지에서 곧바로 메일을 전송할 수 있는 환경을 구축하고자 했다.
우리는 유저들로부터 이메일을 받아오는 기능만 필요했고, 유저에게 이메일을 전송하는 기능은 필요하지 않았다.
따라서, 유저가 우리에게 이메일을 전송할 수 있는 기능만 구현하기로 했다.
nextjs
,typescript
를 활용해서 웹 앱 환경을 구축한다.Contactus form
컴포넌트을 통해이름
,이메일
,메시지
데이터를 받는다.- 이메일 전송 기능 구현을 위해
nodemailer
,axios
,react hook form
을 활용한다.
우리가 수행할 퀘스트는 다음과 같다.
Contactus form
컴포넌트에 form 구축nodemailer
를 활용해 이메일 전송axios
를 활용해 백엔드 API에 requestreact-hook-form
을 활용해 form 데이터 수집 및 유효성 검사이름 및 소속, 이메일 주소, 메시지를 받아볼 수 있는 간단한 형태의 폼을 구축했다.
nodemailer
설치
nodemailer
는Node.js
기반 모듈로, EmailEngine에 등록된 이메일 계정을 활용해 이메일을 송수신할 수 있도록 해준다. 우리는gmail
계정을 활용했으며, 아래 과정은 이미nextjs
와typescript
를 활용한 웹 앱 환경이 구축되어있는 상태를 전제로 한다.
npm i nodemailer
가장 먼저, nodemailer
를 설치를 위해 작업 중인 디렉토리 터미널에 위 명령어를 입력한다.
✅
transporter
생성에 앞서gmail
계정을 사용하기 위한 환경 세팅이 필요하다.
✔ 사용할 계정의 gmail 홈 >설정
>모든 설정 보기
>전달 및 POP/IMAP
>IMAP 사용
체크
✔ 사용할 계정의구글 계정 관리
>보안
>2단계 인증
사용,앱 비밀번호
생성
gmail
환경 설정 후, 디렉토리 최상단에 config
폴더를 생성해 nodemailer.ts
파일을 만들어 준다.
// nodemailer.ts
// 라이브러리 불러오기
import nodemailer from "nodemailer";
// 메일 주소 및 앱 비밀번호 선언하기 (gmail)
const email = process.env.NEXT_PUBLIC_EMAIL;
const pass = process.env.NEXT_PUBLIC_PASSWORD;
// transporter 생성하기
export const transporter = nodemailer.createTransport({
service: "gmail",
auth: {
user: email,
pass,
},
});
// 메일 옵션 정하기
export const mailOptions = {
from: email, // 송신할 이메일
to: email, // 수신할 이메일
};
메일 주소와 앱 비밀번호 등 민감 정보는 보안을 위해 별도의 파일에 보관해 불러온다.
createTransporter
를 활용해 메일을 보내기 위한 transporter 객체를 생성한다.
gmail
유저의 메일이 아닌 우리 팀 메일 계정을 통해 송수신을 총괄할 것이므로, mailOption
의 송수신 계정을 email
로 통일한다.
✅ 유저가
Contactus form
에 제출한 데이터를 백엔드 API에 전송할 수 있는 형식으로 변환하기 위한 타입 설정이 필요하다.
✔name
,message
데이터 타입 설정
✔ 폼을 구성하는input
,textarea
데이터 타입 설정
type
폴더에 email.ts
, textField.ts
파일을 생성한다.
email.ts
에 이름, 메일 주소, 메시지 데이터의 타입을 정의하는 IContactForm
인터페이스를 선언한다.// email.ts
// IContactForm 인터페이스 생성
export interface IContactForm {
name: string;
message: string;
email: string;
}
textField.ts
에 컴포넌트 내 입력 필드의 타입을 정의하는 TextFieldProps
인터페이스를 선언한다.// textField.ts
import { RefCallback } from "react";
export interface TextFieldProps {
id: string;
label?: string;
placeholder?: string;
error?: string;
autoComplete?: string;
inputProps?: {
onChange?: (ev: any) => unknown;
onBlur?: (ev: any) => unknown;
ref?: RefCallback<HTMLInputElement>;
name?: string; ...
// 생략
앞선 세팅이 완료되었다면, 이름과 이메일은 별도의 컴포넌트로 분리하고 메세지 입력 필드는
Controller
를 활용해 각 입력 필드를 완성한다.
NameField
, EmailField
컴포넌트에 인자로TextFieldProps
를 할당하여 필드를 생성한다.// NameField.tsx
import { TextFieldProps } from "@type/textField";
export default function NameField(props: TextFieldProps) {
return (... // 생략
<input
{...(props.inputProps ?? {})}
type="text"
id="name"
name="name"
... // 생략
/>
</div>
</div>
);
}
// EmailField.tsx
import { TextFieldProps } from "@type/textField";
export default function EmailField(props: TextFieldProps) {
return (... // 생략
<input
{...(props.inputProps ?? {})}
type="text"
id="email"
name="email"
... // 생략
/>
</div>
</div>
);
}
Controller
는react-hook-form
라이브러리에서 제공하는 컴포넌트로, 폼 데이터와의 연결을 관리한다. 여기서는message
데이터와Controller
를 연결한다.
Controller
의 구성은 다음과 같다.message
)useForm
의 control // index.tsx
{/* message textarea */}
<div className="w-full p-2">
// 생략
<Controller
{...register("message")}
name="message"
control={control}
defaultValue=""
render={({ field }) => (
<textarea
{...field}
// 생략
</div>
Controller
사용을 위해 useForm()
훅(hook) 함수로 control
객체를 생성했다.useForm()
은 react-hook-form
의 함수로, register
, handleSubmit
등 필요한 객체들을 한꺼번에 생성할 수 있게 해준다.
- 우선,
IContacntForm
,Controller
,useForm
,axios
,useState
, 이메일 및 이름 입력 필드 등 form 구성에 필요한 요소들을 불러온다.
// index.tsx
import { IContactForm } from "@type/email";
import { Controller, useForm } from "react-hook-form";
import EmailField from "./ContactUsItem/EmailField";
import NameField from "./ContactUsItem/NameField";
import axios from "axios";
import { useState } from "react";
ContactUsForm()
함수를 선언하고, 필요한 객체를 생성한다.
- ✅ useState
- ✅ useForm
- ✅ onValid
- ✅ try-catch
useState
훅을 활용해nameValue
와emailValue
변수를 선언한다.
useForm
훅을 활용해 필요한 객체를 생성한다.register()
함수는 각 입력 필드를 등록할 때 활용한다.handleSubmit()
함수는 form 요소에서 발생하는submit
이벤트를 처리할 때 활용한다.control
은Controller
활용을 위해 필요한 객체이다.
onValid
함수를 선언하고,axios
라이브러리를 사용하여POST
요청을 보낸다.- 이름, 메일, 메시지 입력란 중 하나라도 값이 없다면 아무것도 반환하지 않는다.
- 조건을 충족한다면,
form
데이터의name
,message
값을 전송한다.
이때, 성공 시response.data
를 출력하고 실패 시error
를 출력한다.
// index.tsx
export default function ContactUsForm() {
// useState
const [nameValue, setNameValue] = useState("");
const [emailValue, setEmailValue] = useState("");
// useForm
const {
register,
handleSubmit,
formState: { errors },
setError,
control,
reset,
} = useForm<IContactForm>({
defaultValues: {},
});
// onValid
const onValid = async (data: IContactForm) => {
if (!data || !data.name || !data.email || !data.message) return;
try {
const response = await axios.post("http://localhost:3000/api/sendemail", {
name: data.name,
email: data.email,
message: data.message,
});
console.log(response.data);
alert("메일 전송이 완료되었습니다.");
} catch (error) {
console.log(error);
alert("메일 전송에 실패했습니다. 다시 시도해주세요.");
}
};
<form>
태그의onSubmit
이벤트 핸들러로handleSubmit(onValid)
함수를 등록한다.register()
함수를 활용해NameField
와EmailField
를 등록한다.
return (
<div className="">
{/* 제목 */}
<div className="col-center">
<h1 className="mb-4 text-2xl font-bold font-title sm:text-xl">
*****
</h1>
</div>
{/* form */}
<form className="flex flex-wrap md:mx-4" onSubmit={handleSubmit(onValid)}>
{/* name textfield */}
<NameField
id="name"
inputProps={{
...register("name", {
onChange: (e) => {
setNameValue(e.target.value);
},
}),
}}
/>
{/* Email textfield */}
<EmailField
id="email"
inputProps={{
...register("email", {
onChange: (e) => {
setEmailValue(e.target.value);
},
}),
}}
/>
{/* message textarea */}
<div className="w-full p-2">
<div className="relative">
<label htmlFor="message" className="text-sm leading-8 font-body">
Message *
</label>
<Controller
{...register("message")}
name="message"
control={control}
defaultValue=""
render={({ field }) => (
<textarea
{...field}
// 생략
/>
)}
/>
</div>
</div>
{/* submit area */}
<div className="w-full gap-4 row-center">
<div className="w-full p-2 pt-2 mt-2 text-xs border-gray-200 text-start font-body">
<a className="text-gray-500">*******@gmail.com</a>
</div>
<div className="w-full p-2 pt-2 mt-2 col-end">
<button className="flex font-mono text-xl border-b">
<span>submit</span>
<i className="ri-arrow-right-line"></i>
</button>
</div>
</div>
</form>
</div>
);
}
- 마지막으로,
index.tsx
에서 전달 받은 데이터를 받아와서 이메일을 보내는 데에 활용할API 파일
을 생성한다.- 우선
pages
>api
폴더 안에sendemail.ts
파일을 만든다.
NextApiRequest
와NextApiResponse
인터페이스를 불러와 handler 함수의 매개변수로 전달한다. handler 함수는 클라이언트 요청을 처리하고, 적절한 응답을 반환한다.
- 함수는
POST
요청 메서드로 호출되며, 클라이언트로부터 전달된 IContactForm 타입의 데이터를 가져온다.- 가져온 데이터가 없거나
name
,message
중 하나라도 없을 경우,400 Bad Request
응답을 반환한다.
- 이메일 내용을 전달할 때는 앞서
Nodemailer
를 활용해 생성한transporter
객체를 사용한다.sendMail
메서드를 사용하여 이메일을 전송한다.
이때,mailOptions
객체와text
속성, 그리고 클라이언트가 제출한 이름과 시간을 조합하여 이메일 제목을 설정한다.
- 이메일 전송이 성공적으로 완료되면,
200 OK
응답을 반환한다.- 반대의 경우 콘솔에
error
를 출력하고400 Bad Request
응답과 오류 메시지를 반환한다.- 기타 오류가 발생한 경우, 마찬가지로
400 Bad Request
응답과 오류 메시지를 반환한다.
// sendemail.ts
// 라이브러리 불러오기
import { NextApiRequest, NextApiResponse } from "next";
import { mailOptions, transporter } from "../../config/nodemailer";
import { IContactForm } from "@type/email";
// 핸들러 함수 정의
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
// 포스트 요청
if (req.method === "POST") {
const data: IContactForm = req.body;
if (!data || !data.name || !data.email || !data.message) {
return res.status(400).send({ message: "Bad request" });
}
// 메일 제목 형식에 활용할 fixTime 객체 선언 : 전송 시간 기록
const fixTime = new Date().toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
// transperter 활용해 이메일 전송
try {
await transporter.sendMail({
...mailOptions,
...{
text: data.message,
},
subject: `${fixTime} ${data.name}`,
});
return res.status(200).json({ success: true });
} catch (err) {
console.log(err);
return res.status(400).json({ message: err.message });
}
}
return res.status(400).json({ message: "Bad request" });
}