React로 이메일 전송 기능 구현하기 : nextjs, typescript, nodemailer, axios, react hook form

­chae-zero·2023년 4월 12일
3

FE

목록 보기
1/5
post-thumbnail

🎈 1. 들어가며

나는 현재 모 대학 건축학과 온라인 졸업 전시회 웹사이트를 제작하는 사이드 프로젝트에 참여 중이다.
생애 첫 웹 프로젝트에 참여하면서 겁도 없이 기획자 포지션을 맡게 되었는데,
웹 기획에 대한 기초가 부족해 협업 및 소통 과정에서 많이 헤맸었다.
그러나 모든 게 처음이었던 나를 끝까지 격려하며 이끌어주시는 천사 Y 님을 만난 덕분에,
많은 것을 보고 배우며 포기하지 않고 지금까지 함께 프로젝트를 이어가고 있다.

이번에 Y 님께서 직접 개발 과정에 참여할 수 있는 기회를 주셔서,
사이트 내 이메일 전송 기능 구현을 맡게 되었다.
처음엔 에이블스쿨에서 배웠던 SMTP를 활용해 가볍게 구현이 가능할 거라 생각했다.
그러나, 웹의 경우 보안 이슈로 인해 SMTP를 활용하는 길이 생각보다 녹록치 않았고... OTL
백엔드 지식이 약소했던 나는 결국 Y 님의 도움을 받아서 겨우겨우 기능을 구현할 수 있었다. ^_T
늘 부족한 나를 멱살 잡고 끌어주시는 Y 님의 노력과 열정이 헛되지 않도록,
그 과정들을 잘 기록해 보려 한다.


🎈 2. 개요

구현 목적

유저가 우리에게 문의 메일을 보낼 때, 외부 메일 시스템을 거치지 않고 홈페이지에서 곧바로 메일을 전송할 수 있는 환경을 구축하고자 했다.

우리는 유저들로부터 이메일을 받아오는 기능만 필요했고, 유저에게 이메일을 전송하는 기능은 필요하지 않았다.

따라서, 유저가 우리에게 이메일을 전송할 수 있는 기능만 구현하기로 했다.


🔨 활용 툴

  • nextjs, typescript를 활용해서 웹 앱 환경을 구축한다.
  • Contactus form 컴포넌트을 통해 이름, 이메일, 메시지 데이터를 받는다.
  • 이메일 전송 기능 구현을 위해 nodemailer, axios, react hook form을 활용한다.

우리가 수행할 퀘스트는 다음과 같다.

  • Contactus form 컴포넌트에 form 구축
  • nodemailer를 활용해 이메일 전송
  • axios를 활용해 백엔드 API에 request
  • react-hook-form을 활용해 form 데이터 수집 및 유효성 검사

🎈 3. 프로세스

1) UI 작업

이름 및 소속, 이메일 주소, 메시지를 받아볼 수 있는 간단한 형태의 폼을 구축했다.


2) nodemailer 설치

nodemailerNode.js 기반 모듈로, EmailEngine에 등록된 이메일 계정을 활용해 이메일을 송수신할 수 있도록 해준다. 우리는 gmail 계정을 활용했으며, 아래 과정은 이미 nextjstypescript를 활용한 웹 앱 환경이 구축되어있는 상태를 전제로 한다.

npm i nodemailer

가장 먼저, nodemailer 를 설치를 위해 작업 중인 디렉토리 터미널에 위 명령어를 입력한다.


3) transporter 생성

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 객체를 생성한다.

    • service: gmail
    • auth : 인증 데이터를 정의하는 옵션
      • user : username, 보내는 메일의 주소
      • pass : 위에서 생성한 앱 비밀번호
  • 유저의 메일이 아닌 우리 팀 메일 계정을 통해 송수신을 총괄할 것이므로, mailOption의 송수신 계정을 email로 통일한다.


4) 타입 설정

✅ 유저가 Contactus form에 제출한 데이터를 백엔드 API에 전송할 수 있는 형식으로 변환하기 위한 타입 설정이 필요하다.
name, email, 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; ...
   // 생략

5) 이름 필드, 이메일 필드 및 메시지 컨트롤러 생성

앞선 세팅이 완료되었다면, 이름과 이메일은 별도의 컴포넌트로 분리하고 메세지 입력 필드는 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>
  );
}

  • Controllerreact-hook-form 라이브러리에서 제공하는 컴포넌트로, 폼 데이터와의 연결을 관리한다. 여기서는 message 데이터와 Controller를 연결한다.
  • 메시지 입력란을 관리하는 Controller의 구성은 다음과 같다.
    • name : 가리킬 Form의 field 명 ( = message )
    • control : useForm의 control
    • render : field에 의존하는 children Node
// 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 등 필요한 객체들을 한꺼번에 생성할 수 있게 해준다.

6) 최종 코드 작성 (1) - index.tsx

  • 우선, 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 훅을 활용해 nameValueemailValue 변수를 선언한다.
  • useForm 훅을 활용해 필요한 객체를 생성한다.
  • register() 함수는 각 입력 필드를 등록할 때 활용한다.
  • handleSubmit() 함수는 form 요소에서 발생하는 submit 이벤트를 처리할 때 활용한다.
  • controlController 활용을 위해 필요한 객체이다.
  • onValid 함수를 선언하고, axios 라이브러리를 사용하여 POST 요청을 보낸다.
  • 이름, 메일, 메시지 입력란 중 하나라도 값이 없다면 아무것도 반환하지 않는다.
  • 조건을 충족한다면, form 데이터의 name, email, 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() 함수를 활용해 NameFieldEmailField를 등록한다.
  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>
  );
}

7) 최종 코드 작성 (2) - API

  • 마지막으로, index.tsx에서 전달 받은 데이터를 받아와서 이메일을 보내는 데에 활용할 API 파일을 생성한다.
  • 우선 pages > api 폴더 안에 sendemail.ts 파일을 만든다.
  • NextApiRequestNextApiResponse 인터페이스를 불러와 handler 함수의 매개변수로 전달한다. handler 함수는 클라이언트 요청을 처리하고, 적절한 응답을 반환한다.
  • 함수는 POST 요청 메서드로 호출되며, 클라이언트로부터 전달된 IContactForm 타입의 데이터를 가져온다.
  • 가져온 데이터가 없거나 name, email, 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" });
}
profile
사람 재미를 아는 길잡이가 될래요

0개의 댓글