[2024.06.13 TIL] 내일배움캠프 41일차 (개인 과제 구현, 3-Layered Architecture Pattern 리팩토링, Jest 테스트 코드 구현)

My_Code·2024년 6월 13일
0

TIL

목록 보기
53/113
post-thumbnail

본 내용은 내일배움캠프에서 활동한 내용을 기록한 글입니다.


💻 TIL(Today I Learned)

📌 Today I Done

✏️ 3-Layered Architecture Pattern으로 코드 리팩토링하기

  • 3 Layered Architecture Pattern은 Controller - Service - Repository로 나눠짐

  • 기존에는 Router에서 모든 기능들이 복합적으로 사용되었는데, 기능이 점점 많아지게 되면 Router도 거대해지기 때문에 Controller - Service - Repository로 분리해서 사용함

  • Controller

    • 사용자로 부터 받은 Request 데이터의 유효성 검사를 진행
    • 유효한 데이터를 Service로 전달
    • 에러 처리
    • 사용자에게 Response 보냄
  • Service

    • Controller로 부터 받은 데이터 유효성 검사 (논리적 유효성)
    • 비즈니스 로직 수행
    • DB 접근을 위해 Repository의 메서드 호출
  • Repository

    • DB 조작 (CRUD)
    • Table과 1:1 매핑

✏️ 의존성 주입(Dependency Injection, DI)

  • 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;

✏️ Controller의 Unit Test 작성

  • Auth Controller 실행 코드
    // 회원가입 기능
    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);
        }
    };
  • Auth Controller 단위 테스트 코드
// 회원가입 기능
    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 },
        });
    });

✏️ Service의 Unit Test 작성

  • Auth Service 실행 코드
    // 이메일로 사용자 정보 조회
    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;
    };
  • Auth Service 단위 테스트 코드
    // 이메일로 사용자 정보 조회
    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,
        );
    });

✏️ Repository의 Unit Test 작성

  • Auth Repository 실행 코드
    // 이메일로 사용자 정보 조회
    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;
    };
  • Auth Repository 단위 테스트 코드
    // 이메일로 사용자 정보 조회 테스트 코드
    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,
            },
        });
    });


📌 Tomorrow's Goal

✏️ Node.js 심화 개인과제 마무리

  • 이력서 부분의 테스트 코드를 작성하지 못했지만 일단 제출 시간이 있기 때문에 제출할 예정

  • 마무리로 API 테스트 한 번만 더 진행하기

  • ReadMe 작성하기

  • 과제 더 고민해 보기 질문에 대한 답변 작성하기



📌 Today's Goal I Done

✔️ Node.js 심화 개인과제 구현

  • 우선 3 Layered Architecture Patternd으로 리팩토링을 진행함

  • 그래도 기존의 코드를 분리하기만 하면 되기에 큰 어려움 없이 분리함

  • 하지만 코드의 양이 많아서 분리에 시간이 걸림

  • 이후에는 Jest를 이용한 테스트 코드를 작성함

  • 강의 예제를 통해서 조금은 익숙해졌다고 생각했지만 막상 구현에 들어가니 뭘 어떻게 작성해야 할지 감이 오지 않음



⚠️ 구현 시 발생한 문제

✔️ 인증 미들웨어의 Prisma 코드 변경

  • 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 });
        }
    }
};
  • 이번에 찾은 Service 클래스의 메서드에게 그 역할을 넘기는 방법은 다음과 같음
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관련 메서드들을 조금 더 찾아봐야겠음


profile
조금씩 정리하자!!!

0개의 댓글