내 파트를 구현하는데 있어 발생한 문제들에 대해 얘기를 좀 해볼까 함.
일단 기본 뼈대가 되는 개인과제들 중 내 과제를 채택했는데 그 이유는 게시물과 댓글 기능 모두 구현했기 때문임.
근데 문제는 내가 구현하는 과정에 좀 코드를 더럽게 쓴 감이 있어,
가독성을 위해 결국 해설강의를 그대로 따라서 다시 커스텀을 하였음 (처음부터 다시 했다는 소리).
위 과정에서 이미 한 번 구현했기에 크게 어렵진 않았지만 문제가 발생한 지점은 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 에러핸들링이 안되어 있는 부분이 꽤 보이는데 외부 라이브러리를 많이 사용할수록 스스로 처리할 수 없는 에러는 꼭 핸들링이 필요합니다!!
보완할 점