상세 컨텐츠

본문 제목

[Node.js] Sparpet 3 - nodemailer

projects/node.js

by 서울의볼 2024. 2. 16. 14:49

본문

내 파트를 구현하는데 있어 발생한 문제들에 대해 얘기를 좀 해볼까 함.

 

일단 기본 뼈대가 되는 개인과제들 중 내 과제를 채택했는데 그 이유는 게시물과 댓글 기능 모두 구현했기 때문임.

근데 문제는 내가 구현하는 과정에 좀 코드를 더럽게 쓴 감이 있어,

가독성을 위해 결국 해설강의를 그대로 따라서 다시 커스텀을 하였음 (처음부터 다시 했다는 소리).

 

위 과정에서 이미 한 번 구현했기에 크게 어렵진 않았지만 문제가 발생한 지점은 DB테이블 값을 호출하는 부분들에 있었음.

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "mysql"
  url      = env("DATABASE_URL")
}

model Users {
  id           Int        @id @default(autoincrement()) @map("id")
  clientId     String?    @db.VarChar(255)
  email        String?    @unique @map("email")
  password     String?    @map("password")
  phone        String?    @map("phone")
  gender       Gender?    @map("gender")
  birth        String?    @map("birth")
  name         String     @map("name")
  profileImage String?    @map("profileImage")
  createdAt    DateTime   @default(now()) @map("createdAt")
  updatedAt    DateTime   @updatedAt @map("updatedAt")
  isVerified   Boolean    @default(false) @map("isVerified")   -----> 이메일 인증시 true
  comments     Comments[]
  followedBy   Follows[]  @relation("followedBy")
  following    Follows[]  @relation("following")
  likes        Likes[]
  posts        Posts[]

  @@map("Users")
}

model Posts {
  id         Int        @id @default(autoincrement()) @map("id")
  title      String     @map("title")
  content    String     @map("content") @db.Text
  userId     Int        @map("userId")
  countlike  Int        @default(0) @map("countlike")
  createdAt  DateTime   @default(now()) @map("createdAt")
  updatedAt  DateTime   @updatedAt @map("updatedAt")
  attachFile String?    @map("attachFile") @db.Text
  view       Int        @default(0) @map("view")
  comments   Comments[]
  likes      Likes[]
  user       Users      @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@index([userId], map: "Posts_userId_fkey")
  @@map("Posts")
}

model Likes {
  id        Int       @id @default(autoincrement()) @map("id")
  userId    Int       @map("userId")
  postId    Int       @map("postId")
  commentId Int?      @map("commentId")
  comment   Comments? @relation(fields: [commentId], references: [id], onDelete: Cascade)
  post      Posts     @relation(fields: [postId], references: [id], onDelete: Cascade)
  user      Users     @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@index([commentId], map: "Likes_commentId_fkey")
  @@index([postId], map: "Likes_postId_fkey")
  @@index([userId], map: "Likes_userId_fkey")
  @@map("Likes")
}

model Comments {
  id        Int      @id @default(autoincrement()) @map("id")
  postId    Int      @map("postId")
  userId    Int      @map("userId")
  content   String   @map("content") @db.Text
  countlike Int      @default(0) @map("countlike")
  createdAt DateTime @default(now()) @map("createdAt")
  updatedAt DateTime @updatedAt @map("updatedAt")
  post      Posts    @relation(fields: [postId], references: [id], onDelete: Cascade)
  user      Users    @relation(fields: [userId], references: [id], onDelete: Cascade)
  likes     Likes[]

  @@index([postId], map: "Comments_postId_fkey")
  @@index([userId], map: "Comments_userId_fkey")
  @@map("Comments")
}

model Follows {
  followedById Int      @map("followedById")
  followingId  Int      @map("followingId")
  createdAt    DateTime @default(now()) @map("createdAt")
  updatedAt    DateTime @updatedAt @map("updatedAt")
  followedBy   Users    @relation("followedBy", fields: [followedById], references: [id])
  following    Users    @relation("following", fields: [followingId], references: [id])

  @@id([followedById, followingId])
  @@index([followingId], map: "Follows_followingId_fkey")
  @@map("Follows")
}

enum Gender {
  M
  F
}

이건 이번 팀프로젝트에서 쓴 prisma schema인데, 보다시피 기존과 다른 점이 있음.

각 DB테이블의 primary key들을 그냥 "id"라고 명명한 점인데, 기존엔 만약 comments 테이블이라면 commentId 이런식으로 primary key의 이름을 부여함.

근데, 한 튜터님께서 그렇게 쓰는 건 해당 테이블을 refer할 때 쓰는 거라 하셨고, 덧붙여 각 테이블의 primary는 그냥 id라고 쓰라 하심.

이 과정에서 상당히 많이 꼬임. 예컨데, user 변수 내 userId가 id로 받아와야 하는지, userId로 받아와야 하는지 이런 저런 고런 헷갈리는 포인트들이 많았음.

꽤 시간이 걸렸지만 어찌됐건 설 연휴 전 마무리해서 기본 틀을 팀원들에게 제공할 순 있었음.

 

-------------------

본격적으로 nodemailer 관련 얘기를 할거임.

nodemailer를 구현하는데 아주 많은 시행착오가 있었음.

 

구상

처음 구상한 아이디어는 회원가입 하고자 하는 사용자 이메일에 여섯자리의 난수로 이루어져 있는 인증코드를 보내고, 그 인증코드를 사이트 내 입력하여 인증되면 최종적으로 회원가입이 되는 그림을 생각함.

문제

하지만 여기서 가장 큰 문제점은 이메일로 인증코드를 보내는 것까진 완성하였는데, 킹리적 갓심으로 사이트에서 입력 받아 인증되는 인증코드와 값이 달라 자꾸 일치하지 않는다는 거임.

시도

그래서 인증코드 생성하는 걸 전역변수로 꺼내도 보고 (이렇게 하면 새로운 인증코드 발급이 안되고 계속 같은 숫자만 보냄), 이런 저런 시도를 해보았으나 개같이 실패함. --- 자료화면은 맥북에 있어 나중에 업로드 하겠음...

 

그리하여... 처음 구상한 내용이 아무리 해도 제대로 작동을 안해서 튜터님을 만나러 감. 

그리곤 충격적인 이야기를 들음.

보통 현업에선 나처럼 xx처럼(자의적 해석임) 하지 않는다는 것임...!!

해결 방법

일반적으론(?) 또 하나의 인증용 DB 테이블을 만들어서 발급되는 인증코드에 유효기간을 줘서 담은 다음, 사용자가 인증코드를 기입하면 그걸 DB에 query해서 대조하여 인증하는 방식을 사용한다고 함.

 

사실 튜터님이 말씀해주신 내용대로 구현해보고 싶었으나 그게 이미 저녁 아홉시를 지난 시점이라 너무 힘들고 뭔가 대공사의 느낌이 팍팍 들어서 어느정도 마음속으로 타협을 했음:

 

또 다른 시도

이후 두 번째로 시도한 건 링크로 인증하는 방식인데, 토큰을 발급하여 링크에 씌워 보내면, 회원가입 사용자가 링크를 클릭하여 토큰값을 검증하여 인증하는 방식임. 이를 구현하기 위해 nodemailer를 통해 이메일을 보내는 utils 파일 (보통 utils를 따로 둔다고 들음(?)), 토큰을 생성하는 middleware 파일을 각각 구성하여 작업하였으나, 또 링크까진 보내져도 인증이 안됐음. --- 얘도 맥북에 있음;

 

이미 이 시점에서 아침에 가까운 시간이라 집중력이 흐려진 상태였음.

 

 

이때부터 다른 수강생들의 코드들을 전부 까보기 시작함. 10개가 넘는 팀의 코드를 하나 하나 열어보고 분석해보았는데, 아주 흥미롭고 실력자들이 너무 많다는 걸 새삼 실감하게 되었음.

 

이 중 가장 깔끔 간단하게 기능 자체만을 구현한 팀과 다른 여러 팀의 유용한 코드를 적절히 섞어 아래와 같이 구현할 수 있었음:

const transporter = nodemailer.createTransport({
  service: process.env.EMAILSERVICE,
  auth: {
    user: process.env.USERMAIL,
    pass: process.env.PASSWORD,
  },
}); --> 여기 환경변수는 잘 먹혔으나 밑에는 왜인지 안됐음...
 
// 회원가입
router.post(
  '/sign-up',
  upload.single('profileImage'),
  async (req, res, next) => {
    try {
      const { email, password, passwordConfirm, name, phone, gender, birth } =
        req.body;
      if (!email) {
        // return res.status(400).json({ message: '이메일은 필수값입니다.' });
        return res
          .status(404)
          .send(
            "<script>alert('이메일은 필수값입니다.');window.location.replace('/users/sign-up')</script>"
          );
      }
      if (!password) {
        // return res.status(400).json({ message: '비밀번호는 필수값입니다.' });
        return res
          .status(404)
          .send(
            "<script>alert('비밀번호는 필수값입니다.');window.location.replace('/users/sign-up')</script>"
          );
      }
      if (!passwordConfirm) {
        // return res
        //   .status(400)
        //   .json({ message: '비밀번호 확인은 필수값입니다.' });
        return res
          .status(404)
          .send(
            "<script>alert('비밀번호 확인은 필수값입니다.');window.location.replace('/users/sign-up')</script>"
          );
      }
      if (password.email < 6) {
        // return res
        //   .status(400)
        //   .json({ message: '비밀번호는 최소 6자 이상입니다.' });
        return res
          .status(404)
          .send(
            "<script>alert('비밀번호는 최소 6자 이상입니다.');window.location.replace('/users/sign-up')</script>"
          );
      }
      if (password !== passwordConfirm) {
        // return res
        //   .status(400)
        //   .json({ message: '비밀번호가 일치하지 않습니다.' });
        return res
          .status(404)
          .send(
            "<script>alert('비밀번호가 일치하지 않습니다.');window.location.replace('/users/sign-up')</script>"
          );
      }
      if (!name) {
        // return res.status(400).json({ message: '이름은 필수값입니다.' });
        return res
          .status(404)
          .send(
            "<script>alert('이름은 필수값입니다.');window.location.replace('/users/sign-up')</script>"
          );
      }
      if (!gender) {
        // return res.status(400).json({ message: '성별을 입력해주세요.' });
        return res
          .status(404)
          .send(
            "<script>alert('성별을 입력해주세요.');window.location.replace('/users/sign-up')</script>"
          );
      }
      if (!birth) {
        // return res.status(400).json({ message: '생년월일을 입력해주세요.' });
        return res
          .status(404)
          .send(
            "<script>alert('생년월일을 입력해주세요.');window.location.replace('/users/sign-up')</script>"
          );
      }

      const user = await prisma.users.findFirst({
        where: {
          email,
        },
      });
      if (user) {
        return res
          .status(400)
          .json({ success: false, message: '사용할 수 없는 이메일입니다.' });
      }

      // profileImage가 req에 존재하면,
      let imageName = null;
      if (req.file) {
        // s3에 저장
        imageName = randomName();
        const params = {
          Bucket: bucketName,
          Key: imageName,
          Body: req.file.buffer,
          ContentType: req.file.mimetype,
        };
        const command = new PutObjectCommand(params);
        await s3.send(command); // command를 s3으로 보낸다.
      }

      // 이메일 인증 링크 생성
      const url = `http://localhost:3000/users/verification?email=${email}`;

      // 회원가입 및 이메일 발송 비동기처리
      Promise.all([
        prisma.users.create({
          data: {
            email,
            password: sha256(password).toString(),
            name,
            phone,
            gender,
            birth,
            profileImage: imageName,
            isVerified: false,
          },
        }),
        transporter.sendMail({
          from: 'test@gmail.com', --> 환경변수로 숨겨야 했으나 환경변수를 쓰니 자꾸 에러가 떴음 (원인불명)
          to: email,
          subject: '[스파르펫] 회원가입 인증코드',
          html: `<h3>스파르펫 회원가입 인증코드</h3> <p>아래의 "이메일 인증" 링크를 클릭해주세요</p>
          <a href="${url}">이메일 인증해버리기</a>`,
        }),
      ]);

      // '회원가입 완료' 메시지 즉시 반환
      return res.status(201).render('verified.ejs');
    } catch (error) {
      console.error(error);
      return res.status(500).json({ message: '오류가 발생하였습니다.' });
    }
  }
);
// 회원가입 email 인증
router.get('/verification', async (req, res, next) => {
  try {
    const { email } = req.query;
    if (!email)
      return res.status(412).send({ message: '비정상적인 접근입니다.' });

    const verifiedEmail = await prisma.users.findFirst({
      where: { email: email },
      select: {
        email: true,
        isVerified: true,
      },
    });

    if (!verifiedEmail)
      return res.status(412).send({
        message: '요청된 이메일이 아닙니다.',
      });
    if (verifiedEmail.isVerified)
      return res.status(412).send({ message: '이미 인증된 이메일 입니다.' });

    await prisma.users.update({
      where: { email: email },
      data: { isVerified: true },
    });

    return res.status(201).render('sign-in.ejs');
  } catch (err) {
    console.error(err);
    return res.status(400).send({ message: '오류가 발생하였습니다.' });
  }
});

구현

이전 글에 시연영상이 있지만,

우선 회원가입을 정상적으로 할 수 있게 하고, 회원가입을 함과 동시에 인증메일이 가도록 함.

인증 메일에서 "이메일 인증해버리기" 링크를 클릭하면 프리즈마의 isVerified 컬럼에 true 값이 부여되어 게시글, 댓글, 프로필 등 CUD 기능을 사용할 수 있게 됨.

 

사실 권한 부여는 따로 하지 않았던 상태인데, 팀원 중 한 분이 그냥 미들웨어로 처리하면 되지 않냐고 했고, 그냥 내가 화면공유를 한 채 라이브로 instruction을 줘서 간단히 구현함 (여기서 그녀의 이해도와 응용력에 조금 내 자신이 비교되었음 ㅋㅋ;;).

export default function (req, res, next) {
  if (req.cookies.isVerified === 'false') {
    return res.status(400).json({ message: '인증되지 않은 사용자입니다.' });
  }
  next();
}

 

튜터님께서 했었던 말들 중 기억에 남는게 UX상 회원가입을 일단 완료시킨 후 이메일 인증을 시키는 게 회원을 끌어와서 유지시키는데(?) 유리하기에 저런식으로 가입 후 권한을 달리하여 CUD를 위해 인증을 하도록 만듦.

 

예시 코드:

router.get('/following', jwtValidate, verifiedEmail, async (req, res, next) => {       --> 여기서 verifiedEmail이 위의 미들웨어임
  const followedByUserId = res.locals.user.id; // me
  let followingUsersIdList = await prisma.users.findMany({
    where: {
      following: {
        some: {
          followedById: +followedByUserId,
        },
      },
    },
    select: {
      id: true,
    },
  });

  if (followingUsersIdList.length == 0)
    // return res.status(404).json({ message: '게시물이 없습니다.' });
    return res.status(404).send("<script>alert('게시물이 없습니다.');window.location.replace('/posts')</script>")
  followingUsersIdList = followingUsersIdList.map((v) => v.id);

  const posts = await prisma.posts.findMany({
    where: {
      userId: { in: followingUsersIdList },
    },
  });

  res.render('followingposts.ejs', { posts: posts });
});

 

우여곡절 끝에 필요한 기능은 구현하였으나, 아무리봐도 반쪽짜리 결과물임.

보완사항

보완해야할 부분으론, 해당 링크를 토큰으로 하여 유효기간을 부여해야할 것 같고, DB를 활용하여야 보다 완벽한 개발이 될 것 같음.

한 팀은 레디스를 활용하여 앞서 언급한 이상적인 방향으로 구현하였던 것을 기억함. (나는 왜 안되지?)

 

노드메일러 관련 작고 하찮은 이슈들은 개발하며 힘들어서 따로 메모하지 못했음.

다음엔 차곡차곡 메모해둬야겠음...

 

커멘트

튜터님의 주옥같은 커멘트들 여기에도 남김 (상당히 디테일하게 해주셔서 감사했음):

 

대부분 내가 개발한 부분에 대한 얘기 같아서 좀 면목 없었음 ;;

카카오, 네이버, 구글 로그인 연동 시도하셨네요. 좋습니다!! 필수구현 사항들과 더해서 추가적인 부분까지 구현해주신 점이 좋고, 깃 커밋 내역을 보니 꼼꼼하게 작성해주신 느낌이 나서 정말 좋네요!
validation도 꼼꼼하게 해주신 것이 인상깊습니다!
전체적으로 불필요한 주석은 삭제해주시면 좋을 것 같아요! 추가적으로 try catch 에러핸들링이 안되어 있는 부분이 꽤 보이는데 외부 라이브러리를 많이 사용할수록 스스로 처리할 수 없는 에러는 꼭 핸들링이 필요합니다!!

보완할 점

  • 회원가입 api - 이메일 인증 링크 localhost가 하드코딩되어있는데 배포한 서버 주소로 바꾸려면 어떻게 관리하면 좋을까요?
  • user를 create하는 건 기다리고, 메일 보내는 건 비동기처리하는 게 맞지 않을까요!? 비동기로 실행한 user create이 실패했는데 201을 보내게 될 수도 있지 않을까요!!?
  • 로그인 clientId는 무엇을 위해 필요한 걸까요!!? 코드만 봐서는 의도를 파악하기가 힘드네요!
  • 로그아웃할때 isVerified도 같이 지워주면 좋을 것 같아요!
  • 프로필 조회시 post를 join해서 가져올 수도 있어요!
  • 프로필 수정할 때 모든 parameter가 필수인가요? 검증은 모든 값이 없을 때만 오류를 반환하는데 새비밀번호와 새비밀번호 확인을 바로 검증하고 있습니다. 두 값이 있는지 확인한 후에 비교해야할 것 같아요!
  • 프로필 이미지를 수정하려고 할 때, 기존 이미지를 삭제하는 로직은 굉장히 좋습니다. 쉽게 떠올리기 힘든데 구현까지 해주셔서 인상깊었습니다!
  • 근데 전체적으로 외부에 의존하는 부분이 있는데 try catch가 없는 건 아쉬워요! 예상치 못한 에러에 대처할 수 있게 핸들링이 필요할 것 같아요!
  • followedByUserId, followedById라는 이름이 명확하지 않아요. 무슨 뜻인지 한번에 이해가 되지 않는 것 같아요..!
  • 팔로우한 사용자들의 게시물 조회 때 jwt미들웨어랑 verifiedEmail미들웨어가 있는데요..! 누군가를 팔로우하기 위해서는 이메일 인증까지 완료한 사용자여야 한다는 걸까요..? 이러면 이메일 인증이 완료되면 관련한 쿠키를 update해줘야할 것 같습니다!!
  • 게시물 조회시 해당 코드를 타게 될 일이 있을까요? default값을 지정하고 있어서 orderKey의 유효성을 검사하는 게 더 나을 것 같아요!!
  • if (!orderKey) {
        return res.status(400).json({
          success: false,
          message: 'orderKey가 올바르지 않습니다.',
        });
      }
    
     
  • 게시물 조회시 댓글을 join해서 가져와보시면 좋을 것 같아요!
  • 게시물 생성은 이메일 인증여부가 상관없는데 수정하거나 삭제하려면 이메일 인증이 되어있어야 하네요..! 생성도 인증 후에 할 수 있게 하는 게 좀 더 일관성이 있을 것 같아요!
  • 이미지를 다루는 부분에 중복코드가 많은데 리팩토링을 통해 중복을 줄여보세요!
  • 좋아요 생성에서 좋아요 취소기능까지 같이 하고 있는데 따로 api가 있어서 중복되니 하나는 없애는 게 좋을 것 같아요!
  • 좋아요 누른 게시물은 Like에서 where를 userId로 걸고 post를 join하면 한번에 가져올 수 있을 것 같아요!
  • 리소스를 꼭 한시간만 유효한 url을 제공해야하는 이유가 있는게 아니라면 굳이..! 이렇게 구현하실 필요는 없습니다..! url을 그냥 저장하시면 조금 더 코드가 깔끔해질 것 같아요!

관련글 더보기