본 내용은 내일배움캠프에서 활동한 내용을 기록한 글입니다.
API 명세서
ERD
// src/utils/prisma.util.js
import { PrismaClient } from '@prisma/client';
export const prisma = new PrismaClient({
// Prisma를 이용해 데이터베이스를 접근할 때, SQL을 출력해줍니다.
log: ['query', 'info', 'warn', 'error'],
// 에러 메시지를 평문이 아닌, 개발자가 읽기 쉬운 형태로 출력해줍니다.
errorFormat: 'pretty',
}); // PrismaClient 인스턴스를 생성합니다.
try {
await prisma.$connect();
console.log('DB 연결에 성공했습니다.');
} catch (error) {
console.error('DB 연결에 실패했습니다.', error);
}
// prisma/schema.prisma
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
model Users {
userId Int @id @default(autoincrement()) @map("userId")
email String @map("email")
password String @map("password")
name String @map("name")
age Int @map("age")
gender String @map("gender")
role String @default("APPLICANT") @map("role")
profileImage String @map("profileImage")
createdAt DateTime @default(now()) @map("createdAt")
updatedAt DateTime @updatedAt @map("updatedAt")
Resume Resumes[] // 1명의 사용자는 여러 개의 이력서 작성 가능 (1:N 관계 형성)
@@map("Users")
}
model Resumes {
resumeId Int @id @default(autoincrement()) @map("resumeId")
UserId Int @map("UserId") // Users 테이블을 참조하는 외래키
title String @map("title")
introduce String @map("introduce") @db.Text
state String @default("APPLY") @map("state")
createdAt DateTime @default(now()) @map("createdAt")
updatedAt DateTime @updatedAt @map("updatedAt")
ResumeHistory ResumeHistories[] // 1개의 이력서에는 여러 개의 이력서 로그 기록이 존재 (1:N 관계 형성)
// Users 테이블과의 관계 설정
User Users @relation(fields: [UserId], references: [userId], onDelete: Cascade)
@@map("Resumes")
}
model ResumeHistories {
resumeLogId Int @id @default(autoincrement()) @map("resumeLogId")
ResumeId Int @map("ResumeId") // Resumes 테이블을 참조하는 외래키
RecruiterId Int @map("RecruiterId")
oldState String @map("oldState")
newState String @map("newState")
reason String @map("reason")
createdAt DateTime @default(now()) @map("createdAt")
// Rsumes 테이블과의 관계 설정
Resume Resumes @relation(fields: [ResumeId], references: [resumeId], onDelete: Cascade)
@@map("ResumeHistories")
}
// src/schemas/joi.schema.js
import Joi from 'joi';
import { USER_GENDER } from '../constants/user.gender.constant.js';
// 회원가입 유효성 검사
export const signUpSchema = Joi.object({
email: Joi.string()
.email({ minDomainSegments: 2, tlds: { allow: ['com', 'net', 'kr'] } })
.required()
.messages({
'string.base': '이메일은 문자열이어야 합니다.',
'string.empty': '이메일을 입력해주세요.',
'string.email': '이메일의 형식이 올바르지 않습니다',
'any.required': '이메일을 입력해주세요.',
}),
password: Joi.string().required().pattern(new RegExp('^(?=.*[a-zA-Z])(?=.*[!@#$%^*+=-])(?=.*[0-9]).{6,15}$')).messages({
'string.base': '비밀번호는 문자열이어야 합니다.',
'string.empty': '비밀번호를 입력해주세요.',
'any.required': '비밀번호를 입력해주세요.',
'string.pattern.base': '비밀번호가 형식에 맞지 않습니다. (영문, 숫자, 특수문자 포함 6~15자)',
}),
passwordConfirm: Joi.string().required().pattern(new RegExp('^(?=.*[a-zA-Z])(?=.*[!@#$%^*+=-])(?=.*[0-9]).{6,15}$')).messages({
'string.base': '비밀번호 확인은 문자열이어야 합니다.',
'string.empty': '비밀번호 확인을 입력해주세요.',
'any.required': '비밀번호 확인을 입력해주세요.',
'string.pattern.base': '비밀번호 확인의 형식이 맞지 않습니다. (영문, 숫자, 특수문자 포함 6~15자)',
}),
name: Joi.string().required().messages({
'string.base': '이름은 문자열이어야 합니다.',
'string.empty': '이름을 입력해주세요.',
'any.required': '이름을 입력해주세요.',
}),
age: Joi.number().integer().required().messages({
'number.base': '나이는 정수를 입력해주세요.',
'any.required': '나이를 입력해주세요.',
}),
gender: Joi.string()
.valid(...Object.values(USER_GENDER))
.required()
.messages({
'string.base': '성별은 문자열이어야 합니다.',
'any.only': '성별은 [MALE, FEMALE] 중 하나여야 합니다.',
}),
profileImage: Joi.string().required().messages({
'string.base': '프로필 사진은 문자열이어야 합니다.',
'string.empty': '프로필 사진을 입력해주세요.',
'any.required': '프로필 사진을 입력해주세요.',
}),
});
// 로그인 유효성 검사
export const signInSchema = Joi.object({
email: Joi.string()
.email({ minDomainSegments: 2, tlds: { allow: ['com', 'net', 'kr'] } })
.required()
.messages({
'string.base': '이메일은 문자열이어야 합니다.',
'string.empty': '이메일을 입력해주세요.',
'string.email': '이메일의 형식이 올바르지 않습니다',
'any.required': '이메일을 입력해주세요.',
}),
password: Joi.string().required().pattern(new RegExp('^(?=.*[a-zA-Z])(?=.*[!@#$%^*+=-])(?=.*[0-9]).{6,15}$')).messages({
'string.base': '비밀번호는 문자열이어야 합니다.',
'string.empty': '비밀번호를 입력해주세요.',
'any.required': '비밀번호를 입력해주세요.',
'string.pattern.base': '비밀번호가 형식에 맞지 않습니다. (영문, 숫자, 특수문자 포함 6~15자)',
}),
});
// 이력서 작성 유효성 검사
export const resumeWriteSchema = Joi.object({
title: Joi.string().required().messages({
'string.base': '제목은 문자열이어야 합니다.',
'string.empty': '제목을 입력해주세요.',
'any.required': '제목을 입력해주세요.',
}),
introduce: Joi.string().min(150).required().messages({
'string.base': '제목은 문자열이어야 합니다.',
'string.min': '자기소개는 150자 이상 작성해야 합니다.',
'string.empty': '제목을 입력해주세요.',
'any.required': '제목을 입력해주세요.',
}),
});
이메일, 비밀번호, 비밀번호 확인, 이름을 Request Body(req.body
)로 전달 받음
Joi를 통한 유효성 검사
사용자 ID, 역할, 생성일시, 수정일시는 자동 생성됨
보안을 위해 비밀번호는 평문(Plain Text)으로 저장하지 않고 Hash 된 값을 저장
// src/routers/auth.router.js
// 회원가입 API
router.post('/auth/sign-up', async (req, res, next) => {
try {
// 사용자 입력 유효성 검사
const validation = await signUpSchema.validateAsync(req.body);
const { email, password, passwordConfirm, name, age, gender, profileImage } = validation;
// 이메일 중복 확인
const isExistUser = await prisma.users.findFirst({ where: { email } });
if (isExistUser) {
return res.status(400).json({ status: 400, message: '이미 가입 된 사용자입니다.' });
}
// 비밀번호 확인 결과
if (password !== passwordConfirm) {
return res.status(400).json({ status: 400, message: '입력 한 두 비밀번호가 일치하지 않습니다.' });
}
// 비밀번호 암호화
const hashedPassword = await bcrypt.hash(password, 10);
// 사용자 생성
const user = await prisma.users.create({
data: {
email,
password: hashedPassword,
name,
age,
gender: gender.toUpperCase(),
profileImage,
},
});
const { password: pw, ...userData } = user;
return res.status(201).json({ status: 201, message: '회원가입에 성공했습니다.', data: { userData } });
} catch (err) {
next(err);
}
});
이메일, 비밀번호를 Request Body(req.body
)로 전달 받음
Joi를 통한 유효성 검사
AccessToken(Payload에 사용자 ID
를 포함하고, 유효기한이 12시간
)을 생성
RefreshToken(Payload에 사용자 ID
를 포함하고, 유효기한이 7일
)을 생성
데이터베이스에 RefreshToken을 생성 또는 갱신
// src/routers/auth.router.js
// 로그인 API
router.post('/auth/sign-in', async (req, res, next) => {
try {
const validation = await signInSchema.validateAsync(req.body);
const { email, password } = validation;
// 입력받은 이메일로 사용자 조회
const user = await prisma.users.findFirst({ where: { email } });
if (!user) {
return res.status(401).json({ status: 401, message: '인증 정보가 유효하지 않습니다.' });
}
// 사용자 비밀번호와 입력한 비밀번호 일치 확인
if (!(await bcrypt.compare(password, user.password))) {
return res.status(401).json({ status: 401, message: '인증 정보가 유효하지 않습니다.' });
}
// 로그인 성공하면 JWT 토큰 발급
const AccessToken = jwt.sign({ userId: user.userId }, process.env.ACCESS_TOKEN_SECRET_KEY, { expiresIn: '12h' });
const RefreshToken = jwt.sign({ userId: user.userId }, process.env.REFRESH_TOKEN_SECRET_KEY, { expiresIn: '7d' });
// res.setHeader('authorization', `Bearer ${AccessToken}`);
// 현재 사용자의 Refresh토큰이 DB에 있는지 조회
const refreshToken = await prisma.refreshTokens.findFirst({ where: { UserId: user.userId } });
if (!refreshToken) {
// 없으면 새로운 토큰 생성
await prisma.refreshTokens.create({
data: {
UserId: user.userId,
token: RefreshToken,
ip: req.ip,
userAgent: req.headers['user-agent'],
},
});
} else {
// 있으면 토큰 갱신
await prisma.refreshTokens.update({
where: { UserId: user.userId },
data: {
token: RefreshToken,
ip: req.ip,
userAgent: req.headers['user-agent'],
createdAt: new Date(Date.now()),
},
});
}
return res.status(200).json({ status: 200, message: '로그인에 성공했습니다.', data: { AccessToken, RefreshToken } });
} catch (err) {
next(err);
}
});
AccessToken을 Request Header의 Authorization 값(req.headers.authorization
)으로 전달 받음
조건문과 try ~ catch문
을 이용해서 유효성 검사
Payload에 담긴 사용자 ID를 이용하여 사용자 정보를 조회
조회 된 사용자 정보를 req.user
에 담고, 다음 동작을 진행
// src/middlewares/auth.access.token.middleware.js
import { prisma } from '../utils/prisma.util.js';
import jwt from 'jsonwebtoken';
// AccessToken 인증 미들웨어
export default async (req, res, next) => {
try {
// 헤더에서 Access 토큰 가져옴
const authorization = req.headers['authorization'];
console.log(req.headers);
if (!authorization) throw new Error('인증 정보가 없습니다.');
// Access 토큰이 Bearer 형식인지 확인
const [tokenType, token] = authorization.split(' ');
if (tokenType !== 'Bearer') throw new Error('지원하지 않는 인증 방식입니다.');
// 서버에서 발급한 JWT가 맞는지 검증
const decodedToken = jwt.verify(token, process.env.CUSTOMIZED_SECRET_KEY);
const userId = decodedToken.userId;
// JWT에서 꺼낸 userId로 실제 사용자가 있는지 확인
const user = await prisma.users.findFirst({ where: { userId: +userId } });
if (!user) {
return res.status(401).json({ status: 401, message: '인증 정보와 일치하는 사용자가 없습니다.' });
}
// 조회된 사용자 정보를 req.user에 넣음
req.user = user;
// 다음 동작 진행
next();
} catch (err) {
switch (err.name) {
case 'TokenExpiredError':
return res.status(401).json({ status: 401, message: '인증 정보가 만료되었습니다.' });
case 'JsonWebTokenError':
return res.status(401).json({ status: 401, message: '인증 정보가 유효하지 않습니다.' });
default:
return res.status(401).json({ status: 401, message: err.message ?? '비정상적인 요청입니다.' });
}
}
};
사용자 정보는 인증 Middleware(req.user
)를 통해서 전달 받음
userId값으로 Users 테이블에서 사용자 정보를 조회
사용자 ID, 이메일, 이름, 역할, 생성일시, 수정일시를 반환
// src/routers/users.router.js
// 내 정보 조회 API
router.get('/users', authMiddleware, async (req, res) => {
const { userId } = req.user;
const user = await prisma.users.findFirst({
where: { userId },
select: {
userId: true,
email: true,
name: true,
role: true,
createdAt: true,
updatedAt: true,
},
});
return res.status(200).json({ message: '내 정보 조회에 성공했습니다.', data: { user } });
});
사용자 정보는 인증 Middleware(req.user
)를 통해서 전달 받음
제목, 자기소개는 Request Body(req.body
)로 전달 받음
Joi를 통한 유효성 검사
이력서 ID, 지원 상태, 생성일시, 수정일시는 자동 생성
// src/routers/resumes.router.js
//이력서 생성 API
router.post('/resumes', authMiddleware, async (req, res, next) => {
try {
// 사용자 ID를 가져옴
const { userId } = req.user;
// 사용자가 입력한 제목과 자기소개에 대한 유효성 검사
const validation = await resumeWriteSchema.validateAsync(req.body);
const { title, introduce } = validation;
// 이력서 생성
const resume = await prisma.resumes.create({
data: {
title,
introduce,
UserId: +userId,
},
});
return res.status(201).json({ status: 201, message: '이력서 생성에 성공했습니다.', data: { resume } });
} catch (err) {
next(err);
}
});
사용자 정보는 인증 Middleware(req.user
)를 통해서 전달 받음
Query Parameters(req.query
)으로 정렬 조건을 받음
Query Parameters(req.query
)으로 필터링 조건을 받음
지원 상태 별 필터링 조건을 받음 ex) sort=desc&status=APPLY
현재 로그인 한 사용자가 작성한 이력서 목록만 조회
역할이 RECRUITER
인 경우 모든 사용자의 이력서를 조회할 수 있음
// src/routers/resumes.router.js
// 이력서 목록 조회 API
router.get('/resumes', authMiddleware, async (req, res) => {
// 사용자를 가져옴
const user = req.user;
// 정렬 조건을 req.query로 가져옴
const sortType = req.query.sort.toLowerCase();
// 필터링 조건을 가져옴
const stateFilter = req.query.status.toUpperCase();
const resumes = await prisma.resumes.findMany({
where: {
// AND 배열 연산을 통해서 필터링
AND: [user.role === 'RECRUITER' ? {} : { UserId: +user.userId }, stateFilter === '' ? {} : { state: stateFilter }],
},
select: {
resumeId: true,
User: { select: { name: true } },
title: true,
introduce: true,
state: true,
createdAt: true,
updatedAt: true,
},
orderBy: { createdAt: sortType },
});
return res.status(200).json({ status: 200, message: '이력서 목록 조회에 성공했습니다.', data: { resumes } });
});
사용자 정보는 인증 Middleware(req.user
)를 통해서 전달 받음
이력서 ID를 Path Parameters(req.params
)로 전달 받음
현재 로그인 한 사용자가 작성한 이력서만 조회
역할이 RECRUITER
인 경우 이력서 작성 사용자와 일치하지 않아도 이력서를 조회할 수 있음
작성자 ID가 아닌 작성자 이름을 반환하기 위해 스키마에 정의 한 Relation을 활용해 조회 (중첩 SELECT 문법 사용)
// src/routers/resumes.router.js
// 이력서 상세 조회 API
router.get('/resumes/:resumeId', authMiddleware, async (req, res) => {
// 사용자를 가져옴
const user = req.user;
// 이력서 ID를 가져옴
const { resumeId } = req.params;
// 이력서 ID, 작성자 ID가 모두 일치한 이력서 조회
const resume = await prisma.resumes.findFirst({
where: user.role === 'RECRUITER' ? { resumeId: +resumeId } : { resumeId: +resumeId, UserId: +user.userId },
select: {
resumeId: true,
User: { select: { name: true } },
title: true,
introduce: true,
state: true,
createdAt: true,
updatedAt: true,
},
});
if (!resume) {
return res.status(401).json({ status: 401, message: '이력서가 존재하지 않습니다.' });
}
return res.status(200).json({ status: 200, message: '이력서 상세 조회에 성공했습니다.', data: { resume } });
});
사용자 정보는 인증 Middleware(req.user
)를 통해서 전달 받음
이력서 ID를 Path Parameters(req.params
)로 전달 받음
제목, 자기소개를 Request Body(req.body
)로 전달 받음
Joi를 통한 유효성 검사
현재 로그인 한 사용자가 작성한 이력서만 수정할 수 있음
이력서 조회 시 이력서 ID, 작성자 ID가 모두 일치해야 함
// src/routers/resumes.router.js
// 이력서 수정 API
router.patch('/resumes/:resumeId', authMiddleware, async (req, res, next) => {
try {
// 사용자 ID를 가져옴
const { userId } = req.user;
// 이력서 ID를 가져옴
const { resumeId } = req.params;
// 제목, 자기소개를 가져옴 (유효성 검사 진행)
const validation = await resumeWriteSchema.validateAsync(req.body);
const { title, introduce } = validation;
// 이력서 ID, 작성자 ID가 모두 일치한 이력서 조회
const resume = await prisma.resumes.findFirst({
where: { resumeId: +resumeId, UserId: +userId },
});
if (!resume) {
return res.status(401).json({ status: 401, message: '이력서가 존재하지 않습니다.' });
}
// 이력서 수정
const updatedResume = await prisma.resumes.update({
where: { resumeId: +resumeId, UserId: +userId },
data: { title, introduce },
});
return res.status(201).json({ status: 201, message: '이력서 수정이 성공했습니다.', data: { updatedResume } });
} catch (err) {
next(err);
}
});
사용자 정보는 인증 Middleware(req.user
)를 통해서 전달 받음
이력서 ID를 Path Parameters(req.params
)로 전달 받음
현재 로그인 한 사용자가 작성한 이력서만 삭제
이력서 조회 시 이력서 ID, 작성자 ID가 모두 일치해야 함
DB에서 이력서 정보를 직접 삭제
// src/routers/resumes.router.js
// 이력서 삭제 API
router.delete('/resumes/:resumeId', authMiddleware, async (req, res, next) => {
try {
// 사용자 ID를 가져옴
const { userId } = req.user;
// 이력서 ID를 가져옴
const { resumeId } = req.params;
// 이력서 ID, 작성자 ID가 모두 일치한 이력서 조회
const resume = await prisma.resumes.findFirst({
where: { resumeId: +resumeId, UserId: +userId },
});
if (!resume) {
return res.status(401).json({ status: 401, message: '이력서가 존재하지 않습니다.' });
}
const deletedResume = await prisma.resumes.delete({
where: { resumeId: +resumeId, UserId: +userId },
select: { resumeId: true },
});
return res.status(201).json({ status: 201, message: '이력서 삭제가 성공했습니다.', data: { deletedResume } });
} catch (err) {
next(err);
}
});
아직 선택 구현 사항과 배포하는 과정이 남음
선택 구현 사항은 최대한 구현해보고 시간이 부족하다싶으면 포기하고 배포나 다른 필수 사항들을 작성할 예정
지난번 과제에서 배포를 진행해보니 생각보다 시간이 오래 걸렸음
그렇기에 이번에는 조금 여유를 두고 전날 배포를 진행할 예정
오늘 하루 동안 개인과제의 70%정도 구현함
단순히 기능 API를 만드는 것은 쉬웠지만, 인증 또는 인가가 적용되니 코드가 훨씬 복잡해졌음
그래도 auth.middleware 부분을 이해하니 나머지는 그저 활용만 하기 되기에 기능 구현에 가속이 붙었음
예비군 때문에 늦은 만큼 더욱 빠르고 꼼꼼하게 진행해야겠음
기존에 쿠키에 AccessToken을 넣는 방식에서 헤더에 넣는 방식으로 변경함
헤더에 토큰을 넣는 방식은 직접 Insomnia에서 Auth메뉴에 토큰을 복붙하는 방식임
로그인 후 auth.middleware를 사용하는 API에서 req.headers를 출력하니 authorization이라는 이름의 토큰이 생성되었음
생각해보면 나는 헤더에 authorization이라는 이름의 값을 넣어준 적이 없음
그냥 Insomnia의 Auth에 로그인 후 반환된 토큰값을 넣어줬음
팀원들에게 물어보니 정답을 찾을 수 있었음
https://docs.insomnia.rest/insomnia/authentication#bearer-token
결과적으로 저 authorization은 Insomnia에서 자동으로 만들어서 header에 넣어준 것임
그냥 Insomnia에서 편의성을 제공해준 것임