본 내용은 내일배움캠프에서 활동한 내용을 기록한 글입니다.
3 Layered Architecture Pattern은 Controller - Service - Repository로 나눠짐
기존에는 Router에서 모든 기능들이 복합적으로 사용되었는데, 기능이 점점 많아지게 되면 Router도 거대해지기 때문에 Controller - Service - Repository로 분리해서 사용함
Controller
Service
Repository
Layer 간의 결합도를 낮추기 위해 의존성이 있는 코드는 생성 시 주입 받을 수 있도록 만듦
그래서 기본적으로 Router와 같은 곳에서 클래스의 인스턴스를 관리할 수 있음
이를 통해 테스트 진행 시 손 쉽게 모듈을 변경할 수도 있음
// src/routers/auth.router.js
import express from 'express';
import { prisma } from '../utils/prisma.util.js';
import { authRefreshTokenMiddleware } from '../middlewares/auth.refresh.token.middleware.js';
import { signUpValidator } from '../middlewares/validators/sign-up.validator.middleware.js';
import { signInValidator } from '../middlewares/validators/sign-in.validator.middleware.js';
import { AuthRepository } from '../repositories/auth.repository.js';
import { AuthService } from '../services/auth.service.js';
import { AuthController } from '../controllers/auth.controller.js';
const router = express.Router();
const authRepository = new AuthRepository(prisma);
const authService = new AuthService(authRepository);
const authController = new AuthController(authService);
// 회원가입 API
router.post('/sign-up', signUpValidator, authController.signUp);
// 로그인 API
router.post('/sign-in', signInValidator, authController.signIn);
// 토큰 재발급 API
router.post('/refresh', authRefreshTokenMiddleware(authService), authController.refresh);
// 로그아웃 API
router.post('/sign-out', authRefreshTokenMiddleware(authService), authController.signOut);
export default router;
// 회원가입 기능
signUp = async (req, res, next) => {
try {
const { email, password, passwordConfirm, name, age, gender, profileImage } = req.body;
// 이메일 중복 확인
const isExistUser = await this.authService.getUserByEmail(email);
if (isExistUser) {
throw new HttpError.Conflict(MESSAGES.AUTH.COMMON.EMAIL.DUPLICATED);
}
// 비밀번호 확인 결과
if (password !== passwordConfirm) {
throw new HttpError.BadRequest(MESSAGES.AUTH.COMMON.PASSWORD_CONFIRM.INCONSISTENT);
}
// 비밀번호 암호화
const hashedPassword = await this.authService.getHashedPassword(password, AUTH_CONSTANT.HASH_SALT);
// 사용자 생성
const user = await this.authService.createUser(
email,
hashedPassword,
name,
age,
gender.toUpperCase(),
profileImage,
);
return res
.status(HTTP_STATUS.CREATED)
.json({ status: HTTP_STATUS.CREATED, message: MESSAGES.AUTH.SIGN_UP.SUCCEED, data: { user } });
} catch (err) {
next(err);
}
};
// 회원가입 기능
test('signUp Method', async () => {
/* 설정 부분 */
// Controller의 signUp 메서드가 실행되기 위한 Body 입력값
const createUserRequestBodyParams = {
email: 'spartan@spartacodingclub.kr',
password: 'aaaa4321!!',
passwordConfirm: 'aaaa4321!!',
name: '스파르탄',
age: 28,
gender: 'MALE',
profileImage: 'https://prismalens.vercel.app/header/logo-dark.svg',
};
// Request의 body에 입력할 인자값 설정
mockRequest.body = createUserRequestBodyParams;
// Service의 createUser 메서드 반환값 형식 설정
const userInfoSample = {
userId: 1,
email: createUserRequestBodyParams.email,
name: createUserRequestBodyParams.name,
age: createUserRequestBodyParams.age,
gender: createUserRequestBodyParams.gender,
role: 'APPLICANT',
profileImage: createUserRequestBodyParams.profileImage,
createdAt: '2024-06-13T08:53:46.951Z',
updatedAt: '2024-06-13T08:53:46.951Z',
};
// 해시된 비밀번호
const hashedPassword = 'hashedPassword123';
// Promise 객체들이 무조건 resolved 되었다는 가정
// Service의 getUserByEmail 메서드 반환값을 null로 설정 (중복 없다는 의미로 진행)
mockAuthService.getUserByEmail.mockResolvedValue(null);
// Service의 getHashedPassword 메서드 반환값을 설정
mockAuthService.getHashedPassword.mockResolvedValue(hashedPassword);
// Service의 createUser 메서드 반환값을 설정
mockAuthService.createUser.mockResolvedValue(userInfoSample);
/* 실행 부분, Controller의 signUp 메서드 실행 */
await authController.signUp(mockRequest, mockResponse, mockNext);
/* 테스트(조건) 부분 */
// Service의 getUserByEmail 메서드가 1번만 실행되었는지 검사
expect(mockAuthService.getUserByEmail).toHaveBeenCalledTimes(1);
// Service의 getUserByEmail 메서드가 매개변수와 함께 호출되었는지 검사
expect(mockAuthService.getUserByEmail).toHaveBeenCalledWith(createUserRequestBodyParams.email);
// Service의 getHashedPassword 메서드가 1번만 실행되었는지 검사
expect(mockAuthService.getHashedPassword).toHaveBeenCalledTimes(1);
// Service의 getHashedPassword 메서드가 매개변수와 함께 호출되었는지 검사
expect(mockAuthService.getHashedPassword).toHaveBeenCalledWith(
createUserRequestBodyParams.password,
AUTH_CONSTANT.HASH_SALT,
);
// Service의 createUser 메서드가 1번만 실행되었는지 검사
expect(mockAuthService.createUser).toHaveBeenCalledTimes(1);
// Service의 createUser 메서드에 데이터가 매개변수와 함께 호출되었는지 검사
expect(mockAuthService.createUser).toHaveBeenCalledWith(
createUserRequestBodyParams.email,
hashedPassword,
createUserRequestBodyParams.name,
createUserRequestBodyParams.age,
createUserRequestBodyParams.gender,
createUserRequestBodyParams.profileImage,
);
// Response의 status 메서드가 1번만 실행되었는지 검사
expect(mockResponse.status).toHaveBeenCalledTimes(1);
// Response의 status 메서드가 매개변수와 함께 호출되었는지 검사
expect(mockResponse.status).toHaveBeenCalledWith(HTTP_STATUS.CREATED);
// Response의 json 메서드가 1번만 실행되었는지 검사
expect(mockResponse.json).toHaveBeenCalledTimes(1);
// Response의 json 메서드가 매개변수와 함께 호출되었는지 검사
expect(mockResponse.json).toHaveBeenCalledWith({
status: HTTP_STATUS.CREATED,
message: MESSAGES.AUTH.SIGN_UP.SUCCEED,
data: { user: userInfoSample },
});
});
// 이메일로 사용자 정보 조회
getUserByEmail = async (email) => {
const user = await this.authRepository.getUserByEmail(email);
return user;
};
// 비밀번호 암호화
getHashedPassword = async (password, hashSalt) => {
const hashedPassword = await bcrypt.hash(password, hashSalt);
return hashedPassword;
};
// 사용자 생성
createUser = async (email, password, name, age, gender, profileImage) => {
const user = await this.authRepository.createUser(email, password, name, age, gender, profileImage);
// 반환값에서 비밀번호 제외
const { password: pw, ...userData } = user;
return userData;
};
// 이메일로 사용자 정보 조회
test('getUserByEmail Method', async () => {
/* 설정 부분 */
// Repository의 getUserByEmail 메서드 임시 결과값
const userInfoSample = {
userId: 1,
email: 'spartan@spartacodingclub.kr',
name: '스파르탄',
age: 28,
gender: 'MALE',
role: 'RECRUITER',
profileImage: 'https://prismalens.vercel.app/header/logo-dark.svg',
createdAt: '2024-06-09T13:56:19.906Z',
updatedAt: '2024-06-09T13:56:19.906Z',
};
// Repository의 getUserByEmail 메서드의 결과값 설정
mockAuthRepository.getUserByEmail.mockReturnValue(userInfoSample);
/* 실행 부분, Service의 getUserByEmail 메서드 실행 */
const user = await authService.getUserByEmail(userInfoSample.email);
/* 테스트(조건) 부분 */
// Service의 getUserByEmail 메서드 결과값과
// Repository의 getUserByEmail 메서드 결과값이 같은지 검사
expect(user).toBe(userInfoSample);
// Repository의 getUserByEmail 메서드가 1번만 실행되었는지 검사
expect(mockAuthRepository.getUserByEmail).toHaveBeenCalledTimes(1);
// Repository의 getUserByEmail 메서드가 매개변수와 함께 호출되었는지 검사
expect(mockAuthRepository.getUserByEmail).toHaveBeenCalledWith(userInfoSample.email);
});
// 사용자 생성
test('createUser Method', async () => {
/* 설정 부분 */
// Repository의 createUser 메서드 임시 결과값
const userInfoSample = {
userId: 1,
email: 'spartan@spartacodingclub.kr',
name: '스파르탄',
age: 28,
gender: 'MALE',
role: 'RECRUITER',
profileImage: 'https://prismalens.vercel.app/header/logo-dark.svg',
createdAt: '2024-06-09T13:56:19.906Z',
updatedAt: '2024-06-09T13:56:19.906Z',
};
// Repository의 createUser 메서드의 결과값 설정
mockAuthRepository.createUser.mockReturnValue(userInfoSample);
// Service의 createUser 메서드 매개변수 임시 값 설정
const createUserParams = {
email: 'spartan44@spartacodingclub.kr',
password: 'aaaa4321!!',
name: '스파르탄44',
age: 28,
gender: 'MALE',
profileImage: 'https://prismalens.vercel.app/header/logo-dark.svg',
};
/* 실행 부분, 실제 Service의 createUser 메서드 실행 */
const user = await authService.createUser(
createUserParams.email,
createUserParams.password,
createUserParams.name,
createUserParams.age,
createUserParams.gender,
createUserParams.profileImage,
);
// 반환값에서 비밀번호 제외
const { password: pw, ...userData } = user;
/* 테스트(조건) 부분 */
// Service의 createUser 메서드의 결과값과
// Repository의 createUser 메서드의 결과값이 같은지 검사
expect(userData).toStrictEqual(userInfoSample);
// Repository의 createUser 메서드가 1번만 실행되었는지 검사
expect(mockAuthRepository.createUser).toHaveBeenCalledTimes(1);
// Repository의 createUser 메서드가 매개변수와 함께 호출되었는지 검사
expect(mockAuthRepository.createUser).toHaveBeenCalledWith(
createUserParams.email,
createUserParams.password,
createUserParams.name,
createUserParams.age,
createUserParams.gender,
createUserParams.profileImage,
);
});
// 이메일로 사용자 정보 조회
getUserByEmail = async (email) => {
const user = await this.prisma.user.findFirst({ where: { email } });
return user;
};
// 사용자 생성
createUser = async (email, password, name, age, gender, profileImage) => {
const user = await this.prisma.user.create({
data: {
email,
password,
name,
age,
gender,
profileImage,
},
});
return user;
};
// 이메일로 사용자 정보 조회 테스트 코드
test('getUserByEmail Method', async () => {
/* 설정 부분 */
// 모킹된 Prisma의 findFirst 메서드 임시 결과값
const userInfoSample = {
userId: 1,
email: 'spartan@spartacodingclub.kr',
name: '스파르탄',
age: 28,
gender: 'MALE',
role: 'RECRUITER',
profileImage: 'https://prismalens.vercel.app/header/logo-dark.svg',
createdAt: '2024-06-09T13:56:19.906Z',
updatedAt: '2024-06-09T13:56:19.906Z',
};
// 모킹된 Prisma의 findFirst 메서드의 결과값 설정
mockPrisma.user.findFirst.mockReturnValue(userInfoSample);
/* 실행 부분, Repository의 getUserByEmail 메서드 실행 */
const user = await authRepository.getUserByEmail(userInfoSample.email);
/* 테스트(조건) 부분 */
// Repository의 getUserByEmail 메서드 결과값과
// 모킹된 Prisma의 findFirst 메서드 결과값이 같은지 검사
expect(user).toBe(userInfoSample);
// 모킹된 Prisma의 findFirst 메서드가 1번만 실행되었는지 검사
expect(mockPrisma.user.findFirst).toHaveBeenCalledTimes(1);
// 모킹된 Prisma의 findFirst 메서드가 매개변수와 함께 호출되었는지 검사
expect(mockPrisma.user.findFirst).toHaveBeenCalledWith({
where: { email: userInfoSample.email },
});
});
// 사용자 생성
test('createUser Method', async () => {
/* 설정 부분 */
// 모킹된 Prisma의 findFirst 메서드 임시 결과값
const userInfoSample = {
userId: 1,
email: 'spartan@spartacodingclub.kr',
name: '스파르탄',
age: 28,
gender: 'MALE',
role: 'RECRUITER',
profileImage: 'https://prismalens.vercel.app/header/logo-dark.svg',
createdAt: '2024-06-09T13:56:19.906Z',
updatedAt: '2024-06-09T13:56:19.906Z',
};
// 모킹된 Prisma의 findFirst 메서드의 결과값 설정
mockPrisma.user.create.mockReturnValue(userInfoSample);
// Repository의 createUser 메서드 매개변수 임시 값 설정
const createUserParams = {
email: 'spartan44@spartacodingclub.kr',
password: 'aaaa4321!!',
name: '스파르탄44',
age: 28,
gender: 'MALE',
profileImage: 'https://prismalens.vercel.app/header/logo-dark.svg',
};
/* 실행 부분, 실제 저장소(Repository)의 getUserInfo 메서드 실행 */
const user = await authRepository.createUser(
createUserParams.email,
createUserParams.password,
createUserParams.name,
createUserParams.age,
createUserParams.gender,
createUserParams.profileImage,
);
/* 테스트(조건) 부분 */
// Repository의 createUser 메서드의 결과값과
// 모킹된 Prisma의 create 메서드의 결과값이 같은지 검사
expect(user).toBe(userInfoSample);
// 모킹된 Prisma의 create 메서드가 1번만 실행되었는지 검사
expect(mockPrisma.user.create).toHaveBeenCalledTimes(1);
// 모킹된 Prisma의 create 메서드가 매개변수와 함께 호출되었는지 검사
expect(mockPrisma.user.create).toHaveBeenCalledWith({
data: {
email: createUserParams.email,
password: createUserParams.password,
name: createUserParams.name,
age: createUserParams.age,
gender: createUserParams.gender,
profileImage: createUserParams.profileImage,
},
});
});
이력서 부분의 테스트 코드를 작성하지 못했지만 일단 제출 시간이 있기 때문에 제출할 예정
마무리로 API 테스트 한 번만 더 진행하기
ReadMe 작성하기
과제 더 고민해 보기 질문에 대한 답변 작성하기
우선 3 Layered Architecture Patternd으로 리팩토링을 진행함
그래도 기존의 코드를 분리하기만 하면 되기에 큰 어려움 없이 분리함
하지만 코드의 양이 많아서 분리에 시간이 걸림
이후에는 Jest를 이용한 테스트 코드를 작성함
강의 예제를 통해서 조금은 익숙해졌다고 생각했지만 막상 구현에 들어가니 뭘 어떻게 작성해야 할지 감이 오지 않음
3-Layered Architecture Pattern 리팩토링 중 내 정보 조회 API를 분리하고 있었음
어느 정도 분리가 끝나서 AccessToken 미들웨어도 코드의 구조 변화가 필요했음
도저히 어디를 고쳐야 할지 모르겠어서 튜터님께 도움을 요청함
튜터님께서 간단한 흐름을 알려주셨기에 조금 더 찾아보면서 구현을 시작함
찾은 방법은 인증 미들웨어에서 Prisma 클라이언트를 직접 사용하는 부분을 Service 클래스의 인스턴스로 접근하는 방식을 찾음
그러기 위해서는 인증 미들웨어에 Service 클래스의 인스턴스가 매개변수로 들어가야 함
그래서 다시 찾아보니 미들웨어도 매개변수를 가지는 형태로 바꿀 수 있다고 함
기존의 Prisma 클라이언트를 사용하는 방식은 다음과 같음
import { HTTP_STATUS } from '../constants/http-status.constant.js';
import { MESSAGES } from '../constants/message.constant.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'];
if (!authorization) throw new Error(MESSAGES.AUTH.COMMON.JWT.NO_TOKEN);
// Access 토큰이 Bearer 형식인지 확인
const [tokenType, token] = authorization.split(' ');
if (tokenType !== 'Bearer') throw new Error(MESSAGES.AUTH.COMMON.JWT.NOT_SUPPORTED_TYPE);
// 서버에서 발급한 JWT가 맞는지 검증
const decodedToken = jwt.verify(token, process.env.ACCESS_TOKEN_SECRET_KEY);
const userId = decodedToken.userId;
// JWT에서 꺼낸 userId로 실제 사용자가 있는지 확인
const user = await prisma.user.findFirst({ where: { userId: +userId }, omit: { password: true } });
if (!user) {
return res.status(HTTP_STATUS.UNAUTHORIZED).json({ status: HTTP_STATUS.UNAUTHORIZED, message: MESSAGES.AUTH.COMMON.JWT.NO_USER });
}
// 조회된 사용자 정보를 req.user에 넣음
req.user = user;
// 다음 동작 진행
next();
} catch (err) {
switch (err.name) {
case 'TokenExpiredError':
return res.status(HTTP_STATUS.UNAUTHORIZED).json({ status: HTTP_STATUS.UNAUTHORIZED, message: MESSAGES.AUTH.COMMON.JWT.EXPIRED });
case 'JsonWebTokenError':
return res.status(HTTP_STATUS.UNAUTHORIZED).json({ status: HTTP_STATUS.UNAUTHORIZED, message: MESSAGES.AUTH.COMMON.JWT.INVALID });
default:
return res
.status(HTTP_STATUS.UNAUTHORIZED)
.json({ status: HTTP_STATUS.UNAUTHORIZED, message: err.message ?? MESSAGES.AUTH.COMMON.JWT.ETC });
}
}
};
import jwt from 'jsonwebtoken';
import { AUTH_CONSTANT } from '../constants/auth.constant.js';
import { ERROR_CONSTANT } from '../constants/error.constant.js';
import { MESSAGES } from '../constants/message.constant.js';
import { HttpError } from '../errors/http.error.js';
// AccessToken 인증 미들웨어
export const authAccessTokenMiddleware = (userService) => {
return async (req, res, next) => {
try {
// 헤더에서 Access 토큰 가져옴
const authorization = req.headers[AUTH_CONSTANT.AUTHORIZATION];
if (!authorization) throw new HttpError.Unauthorized(MESSAGES.AUTH.COMMON.JWT.NO_TOKEN);
// Access 토큰이 Bearer 형식인지 확인
const [tokenType, token] = authorization.split(' ');
if (tokenType !== AUTH_CONSTANT.BEARER)
throw new HttpError.Unauthorized(MESSAGES.AUTH.COMMON.JWT.NOT_SUPPORTED_TYPE);
// 서버에서 발급한 JWT가 맞는지 검증
const decodedToken = jwt.verify(token, process.env.ACCESS_TOKEN_SECRET_KEY);
const userId = decodedToken.userId;
// 사용자 정보를 UserService에게 요청
const user = await userService.getUserInfo(userId);
if (!user) throw new HttpError.Unauthorized(MESSAGES.AUTH.COMMON.JWT.NO_USER);
// 조회된 사용자 정보를 req.user에 넣음
req.user = user;
// 다음 동작 진행
next();
} catch (err) {
switch (err.name) {
case ERROR_CONSTANT.NAME.EXPIRED:
next(new HttpError.Unauthorized(MESSAGES.AUTH.COMMON.JWT.EXPIRED));
break;
case ERROR_CONSTANT.NAME.JWT:
next(new HttpError.Unauthorized(MESSAGES.AUTH.COMMON.JWT.INVALID));
break;
default:
next(new HttpError.Unauthorized(err.message ?? MESSAGES.AUTH.COMMON.JWT.ETC));
break;
}
}
};
};
처음 접하는 내용이긴 하지만 너무나도 생소 했음
Jest에서 편리한 메서드들을 제공한다고 하는데 생각보다 에러를 고치느라 찾아볼 겨를이 없었음
결국 GPT의 도움을 받아서 코드를 구현함
한줄 한줄 주석을 달면서 작성했지만 아직도 이해가 쉽지 않음
Jest관련 메서드들을 조금 더 찾아봐야겠음