본문 바로가기

Node.js 도전기

Node.js_3 Layerd Architecture 로 리팩토링하기 ! (2)

앞선 포스팅에서 리팩토링을 위한 전초작업을 완료했다면 이제 작성한 코드들을 3 계층 구조로 리팩토링을 본격적으로 해보도록 하겠다.

[해보고자 하는 것]
1. 작성한 API를 3 Layerd Architecture 형식으로 리팩토링 진행

 

#1.  Router 파일

기존 라우터 파일의 경우 모든 기능과 메서드가 통합되어 관리되어 있었기 때문에 코드를 명확히 구분하기가 어려웠다. 

// 수정 전 router 파일 예

import express from 'express';
import db from '../models/index.cjs';
import authMiddleware from '../middlwares/need-signin.middlware.js';
import { Op } from 'sequelize';

const { Products } = db;
const { Users } = db;
const router = express.Router();


// Create 상품 등록 //
router.post('/products', authMiddleware, async (req, res) => {
  try {
    const { id } = res.locals.user;
    const { productName, contents } = req.body;

    // 데이터 형식이 바르지 않을 경우
    if (!req.body) { return res.status(400).json({ errorMessage: " 데이터 형식이 올바르지 않습니다. " }) };

    // 데이터가 모두 입력되지 않았을 경우
    if (!productName || !contents) {
      return res.status(400).json({ errorMessage: "데이터를 모두 입력해주세요." });
    }

    // 상품 생성 (생성 시 userId는 res.locals.user의 id값을 가져온다.)
    await Products.create({ userId: id, productName, contents })

    res.status(201).json({ message: "상품등록에 성공하였습니다." });
  } catch (error) {
    console.error("상품 등록 실패", error);
    res.status(500).json({ errorMessage: "상품 등록에 실패했습니다." });
  }
});

... 이하 코드 생략 

export default router;

 

하나의 파일에서 요청을 받고 비즈니스 로직을 실행하고 응답을 보내는게 통합되어 있다. 

 

앞서 세팅한 계층대로 리팩토링하기 위해 router 파일을 아래와 같이 수정했다. 

// products.router.js 예시

import express from 'express';
import { ProductsController } from '../controllers/products.controller.js';
import authenticate from '../middlewares/auth.middleware.js';

const router = express.Router();

const productsController = new ProductsController();

/* 상품조회 API (Read) */
router.get('/products', productsController.getProducts);

/* 특정 상품 상세 조회 (Read) */
router.get('/products/:productId', productsController.getProductById);


/* 아래 API는 모두 로그인 후 이용이 가능하여, auth 미들웨어 적용 */
router.use(authenticate);

/* 상품등록 API (Create) */
router.post('/products', productsController.createProduct);

/* 특정 상품 수정 (Update) */
router.put('/products/:productId', productsController.updateProduct);

/* 특정 상품 삭제하기 (Delete) */
router.delete('/products/:productId', productsController.deleteProduct);

export default router;

변경된 라우터 파일에서는 클라이언트의 요청을 해당 URL로 받았을 때 컨트롤러의 특정 메서드를 실행하도록 되어 있다. 

즉 직접적인 응답이나 비즈니스 로직 등을 하는 것이 아닌 3 계층 파일들로 연결해주는 역할을 해주고 있다.

 

추가로 authenticate라는 미들웨어를 사용하여 로그인 이후 사용이 가능한 API들에 대해서는 인증 절차를 거치도록 하고 있다.

해당 파일에서 내보낸 라우터는 app.js에서 받아 사용할 수 있다. 

// app.js 예시

import express from "express"
import cookieParser from "cookie-parser";
import productsRouter from "./routers/products.router.js";
import userRouter from "./routers/users.router.js";
import errorHandling from "./middlewares/error.middleware.js";

import dotenv from 'dotenv'; // .env 패키지를 사용하기 위해 불러오고 실행함
dotenv.config();

const app = express();
const port = 3000

app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());

app.use('/api', [userRouter, productsRouter]);

app.use(errorHandling);


app.listen(port, () => {
  console.log(port, '포트 연결 성공 !');
})

 

#2. 컨트롤러 (Controller) 파일

앞선 라우터 파일에서 호출받은 메서드는 컨트롤러 파일에서 정의된다. 

 

// products.controller.js 예시


import { ProductsService } from '../services/products.service.js'

export class ProductsController {
  productsService = new ProductsService();

  createProduct = async (req, res, next) => {
    try {
      const { userId } = res.locals.user;

      const { productName, contents } = req.body

      const createdProduct = await this.productsService.createProduct(
        userId,
        productName,
        contents,
      );

      return res.status(201).json({ data: createdProduct })
    } catch (error) {
      next(error);
    };
  };


  getProducts = async (req, res, next) => {
    try {
      // query string에서 정렬값 받아오기 없으면 DESC 고정
      const sortValue = req.query.sort ? (req.query.sort).toUpperCase() : "DESC";
      const products = await this.productsService.findAllProducts(sortValue);

      return res.status(200).json({ products });
    } catch (error) {
      next(error);
    };
  };


  getProductById = async (req, res, next) => {
    try {
      const { productId } = req.params;

      const product = await this.productsService.findProductById(productId);

      return res.status(200).json({ product });
    } catch (error) {
      next(error);
    };
  };


  updateProduct = async (req, res, next) => {
    try {
      const { productId } = req.params;
      const { userId } = res.locals.user; // 로컬 유저에 들어있는 id 키값을 userId 변수로 받는다.
      const { productName, contents, status } = req.body;

      const updatedProduct = await this.productsService.updateProduct(
        productId,
        userId,
        productName,
        contents,
        status
      );

      return res.status(200).json(updatedProduct);

    } catch (error) {
      next(error);
    };
  };


  deleteProduct = async (req, res, next) => {
    try {
      const { productId } = req.params;
      const { userId } = res.locals.user;

      const deletedProduct = await this.productsService.deleteProduct(productId, userId);

      return res.status(200).json({ message: "상품 삭제가 완료되었습니다.", deletedProduct });
    } catch (error) {
      next(error);
    };
  };

};

 

1) service 계층의 클래스를 멤버변수로 할당

코드에서도 컨트롤러는 받은 요청을 처리하기 위해 productsService 클래스의 또 다른 메서드를 호출하고 있다. 

이렇게 컨트롤러와 서비스 계층을 연결하기 위해 ProductsService 클래스를 import 해주었다. 

 

이후 가져온 서비스 클래스를 컨트롤러 클래스의 멤버변수로 할당 하였다. 

productsService = new ProductsService();

 

 

 2) 에러핸들링 미들웨어 사용을 위한 try...catch 문 사용

컨트롤러 안에는 각 API에 해당하는 메서드들이 있고 메서드 안에서는 try...catch 문을 사용하였다. 

이는 이후 작업할 에러핸들링 미들웨어를 사용하기 위함이다. 

 

간략하게 try 문 안에서 서비스 계층의 메서드를 호출하게 되는데 

결과적으로 서비스 계층에서 에러가 발생하면 컨트롤러로 에러가 반환되어 나오고

try 문에서 에러가 반환되었으니 catch 문 안에 코드들이 실행된다. 

 

catch 에서는 error를 인자로 갖는 next 메서드를 호출하는데

next 메서드의 경우 다음 라우터 혹은 미들웨어를 실행되도록 하는 함수이다. 

(next 메서드 뒤에 어떤 코드가 있더라도 아래 코드는 실행되지 않고 미들웨어로 넘어가는 점을 유의해야한다.) 

 

위 코드에서는 next 메서드를 통해 에러핸들링 미들웨어를 실행시키고자 하며 그때 error를 매개변수로 함께 넘겨준다.

// 예시 

  deleteProduct = async (req, res, next) => {
    try {
      const { productId } = req.params;
      const { userId } = res.locals.user;

      const deletedProduct = await this.productsService.deleteProduct(productId, userId);
		// 서비스 계층의 deleteProduct 메서드를 호출한다. 
        // 정상으로 수행된다면 return res. ~ 코드가 실행된다.
        // 수행 중 에러가 발생된다면 catch 문의 next 메서드가 실행된다.
      return res.status(200).json({ message: "상품 삭제가 완료되었습니다.", deletedProduct });
    } catch (error) {
      next(error);
    };
  };

};

 

3) 서비스 메서드 호출 시 매개변수 전달

만약 메서드를 실행할 때 필요한 정보들이 있다면 매개변수로 전달한다. 

 

여기에서는 req.params / req.body / res.locals.user 등에서 필요한 정보들을 구조분해할당으로 가져와서 매개변수로 전달했다.

#3. 서비스(Service) 파일

컨트롤러가 호출한 메서드를 실행하기 위해서 서비스 클래스를 멤버변수로 할당했기에 서비스 계층에는 호출한 메서드가 정의되어야 한다.

// products.service.js 예시

import { ProductsRepository } from '../repositories/products.repository.js';

export class ProductsService {
  productsRepository = new ProductsRepository;

  createProduct = async (userId, productName, contents) => {

    if (!productName || !contents) {
      throw new Error("상품 정보를 모두 입력해주세요.")
    };

    const product = await this.productsRepository.createProduct(
      userId,
      productName,
      contents
    );

    return {
      productId: product.productId,
      UserId: product.UserId,
      productName: product.productName,
      contents: product.contents,
      createdAt: product.createdAt,
      updatedAt: product.updatedAt
    };
  };


  findAllProducts = async (sortValue) => {
    const products = await this.productsRepository.findAllproducts();

    // 정렬값 결정하기
    if (sortValue === 'ASC') { // sortValue가 있고, 정렬값이 정해진 경우
      products.sort((a, b) => {
        return a.createdAt - b.createdAt
      });
    } else if (sortValue === 'DESC') { // 아니면 내림차순(최신순)으로 정렬한다.
      products.sort((a, b) => {
        return b.createdAt - a.createdAt
      });
    }
    return products.map((product) => {
      return {
        productId: product.productId,
        UserId: product.UserId,
        productName: product.productName,
        contents: product.contents,
        status: product.status,
        createdAt: product.createdAt,
        updatedAt: product.updatedAt,
        userName: product.Users.userName
      }
    })
  };


  findProductById = async (productId) => {
    const product = await this.productsRepository.findProductById(productId)

    if (!product) {
      throw new Error("존재하는 상품이 없습니다.");
    };


    return {
      productId: product.productId,
      productName: product.productName,
      contents: product.contents,
      status: product.status,
      UserId: product.UserId,
      createdAt: product.createdAt,
      updatedAt: product.updatedAt,
      userName: product.Users.userName
    };
  };

  updateProduct = async (productId, userId, productName, contents, status) => {
    const product = await this.productsRepository.findProductById(productId);

    if (!product) {
      throw new Error("존재하는 상품이 없습니다.");
    };

    // 로그인 한 유저가 다른 사람의 상품을 수정할 때
    if (product.UserId !== userId) {
      throw new Error("권한이 없습니다.");
    };

    if (status !== "FOR_SALE" || status !== "SOLD_OUT") {
      throw new Error("상태값을 확인해주세요.");
    };

    const updatedProduct = await this.productsRepository.updateProduct(productId, userId, productName, contents, status);


    return {
      productId: updatedProduct.productId,
      productName: updatedProduct.productName,
      contents: updatedProduct.contents,
      status: updatedProduct.status,
      UserId: updatedProduct.UserId,
      createdAt: updatedProduct.createdAt,
      updatedAt: updatedProduct.updatedAt
    };
  };


  deleteProduct = async (productId, userId) => {
    const product = await this.productsRepository.findProductById(productId);

    if (!product) {
      throw new Error("존재하는 상품이 없습니다.");
    };

    if (product.UserId !== userId) {
      throw new Error("권한이 없습니다.");
    };

    await this.productsRepository.deleteProduct(productId)

    // 삭제한 상품을 리턴해준다.
    return {
      productId: product.productId,
      productName: product.productName,
      contents: product.contents,
      status: product.status,
      UserId: product.UserId,
      createdAt: product.createdAt,
      updatedAt: product.updatedAt
    };
  };

};

 

1) Repository 계층 클래스를 멤버변수로 할당

컨트롤러에서도 마찬가지로 서비스 계층에서의 메서드를 호출하기 위해서는 레포지토리(DB)에서의 작업이 필요한 경우가 있다.

그래서 레포지토리 계층의 메서드를 호출하게 되는데 이때 연결해주기 위해 멤버변수로 할당하였다. 

productsRepository = new ProductsRepository();

 

2) 메서드 호출 시 DB에서의 작업이 필요할 경우 Repository 메서드 호출

Products 라우터에서는 상품을 등록, 조회, 수정, 삭제(CRUD)가 가능하기 때문에 DB에서의 작업이 필요하다. 

따라서 연결된 레포지토리 계층에서의 메서드를 호출하였다. 

    const product = await this.productsRepository.createProduct(
      userId,
      productName,
      contents
    );

위 예제 코드에서는 상품을 등록하는 메서드에서 등록할 상품을 정의하는 코드이다. 

이때 등록할 상품은 레포지토리의 createProduct 메서드를 통해서 반환된 값을 의미한다. 

 

3) 유효성 검사 진행

유효성 검사를 어디에서 진행하는지는 정답이 없어 보이긴 하나 서비스 계층에서 유효성 검사를 진행했다. 

기존 유효성 검사에서는 if문에서 리턴 시 res 를 바로 보내주어 api를 종료시켰는데 

 

리팩토링 시 에러핸들링 미들웨어에서 전체적인 에러를 관리하고자 res가 아닌 throw new Error를 통해 에러를 발생시켰다.

에러가 발생하면 컨트롤러에 에러를 반환해주기 때문에 앞서 말했듯 catch에 next(error) 메서드가 호출되어 에러 핸들링 미들웨어가 작동한다.

 

#4. 레포지토리(Repository) 파일

서비스 계층에서 메서드를 수행할 때 DB의 작업이 필요할 경우 레포지토리 메서드를 호출한다고 했다. 

즉 레포지토리 파일은 직접적으로 DB와 연결되어 코드를 수행하므로 DB와 연결 후 ORM에서 제공하는 메서드를 수행한다. 

// products.repository.js
import { prisma } from "../utils/prisma/index.js"

export class ProductsRepository {
  findAllproducts = async () => {
    const products = await prisma.Products.findMany(
      {
        select: {
          UserId: true,
          productId: true,
          productName: true,
          status: true,
          createdAt: true,
          updatedAt: true,
          Users: {
            select: { // 중첩 select 문으로 유저 이름 추가 
              userName: true,
            }
          }
        }
      }
    );
    return products;
  };


  createProduct = async (userId, productName, contents) => {
    const createdProduct = await prisma.Products.create({
      data: {
        UserId: userId,
        productName,
        contents
      }
    });
    return createdProduct;
  };


  findProductById = async (productId) => {
    const product = await prisma.Products.findFirst({
      where: { productId: +productId },
      select: {
        UserId: true,
        productId: true,
        productName: true,
        status: true,
        contents: true,
        createdAt: true,
        updatedAt: true,
        Users: {
          select: { // 중첩 select 문으로 유저 이름 추가 
            userName: true,
          }
        }
      }
    }
    );
    return product;
  };


  updateProduct = async (productId, userId, productName, contents, status) => {
    const updatedProduct = await prisma.Products.update({
      where: {
        productId: +productId,
        UserId: userId
      },
      data: {
        productName,
        contents,
        status,
        updatedAt: new Date()
      }
    });

    return updatedProduct;
  };


  deleteProduct = async (productId, userId) => {
    await prisma.Products.delete({
      where: {
        productId: +productId,
        UserId: userId
      }
    });
  };
};

 

1) prisma를 통해 DB와 연결

Prisma 객체를 이용하기 위해 import를 진행했고 서비스에서 호출된 메서드 안에서 prisma의 메서드가 수행된다. 

import { prisma } from "../utils/prisma/index.js"

... 코드 생략

createProduct = async (userId, productName, contents) => {
    const createdProduct = await prisma.Products.create({
      data: {
        UserId: userId,
        productName,
        contents
      }
    });
    return createdProduct;
  };
  
 ... 코드 생략

 

2) JOIN 이 필요할 경우 prisma 의 중첩 select를 사용한다. 

  findAllproducts = async () => {
    const products = await prisma.Products.findMany(
      {
        select: {
          UserId: true,
          productId: true,
          productName: true,
          status: true,
          createdAt: true,
          updatedAt: true,
          Users: {
            select: { // 중첩 select 문으로 유저 이름 추가 
              userName: true,
            }
          }
        }
      }
    );
    return products;
  };

 

 

즉 레포지토리 계층의 경우 DB를 활용하여 특정 값을 서비스 계층에 전달하고, 

서비스 계층은 전달받은 데이터를 가공하여 컨트롤러 계층에 전달하고,

컨트롤러 계층은 받은 가공된 데이터를 클라이언트에게 응답한다. 


이렇게 나눠서 파일을 관리하는게 처음엔 불편하고 불필요한 작업이라고 생각이 들었으나, 

특정한 부분을 교체하거나 문제가 생겨 고치려고 할 때 각 계층별 나눠져 있으니 훨씬 유지보수가 쉬울것 같다는 느낌이 들었다. 

 

또한 계층별 역할이 명확히 나눠지게 되니 어디서 문제가 생겼는지 파악하기에도 쉬울듯 하다. 

 

다음 포스팅에는 서비스 계층에서 진행한 유효성 검사를 어떻게 처리했는지 별도로 정리하도록 하겠다.