JUINTINATION

Express.js과 MVC 패턴, Controller & Service & Repository 본문

StudyNote

Express.js과 MVC 패턴, Controller & Service & Repository

DEOKJAE KWON 2024. 1. 20. 23:01
반응형

MVC 패턴이란?

MVC 패턴모델-뷰-컨트롤러(model–view–controller) 3가지로 분리된 형태의 3 Layer Architecture를 사용하는 디자인패턴이다. 각각의 구성요소들 사이에는 다음과 같은 관계가 있다.

 

모델-뷰-컨트롤러 - 위키백과, 우리 모두의 백과사전

위키백과, 우리 모두의 백과사전. 모델, 뷰, 컨트롤러의 관계를 묘사하는 간단한 다이어그램. 웹 애플리케이션에서 일반적인 MVC 구성요소 다이어그램 모델-뷰-컨트롤러(model–view–controller, MVC)

ko.wikipedia.org

  • 모델(Model)
    • 사용자에게 노출되지 않고 애플리케이션이 무엇을 할 것인지 정의하는 부분으로 DB와의 상호작용을 통한 연산처리가 주된 목적이다.
    • 컨트롤러에서 요청이 들어오면 DB에서 사용자가 입력한 데이터나 사용자에게 출력할 데이터를 다룬다.
  • 뷰(View)
    • 사용자에게 보여지는 부분으로 실제 비즈니스 로직을 구현한다.
    • HTML, Javascript, CSS 등을 사용한다.
  • 컨트롤러(Controller)
    • 사용자에게 요청을 받는 역할로 해당 요청 객체를 모델에 넘겨줌으로 모델의 상태를 변경한다.
    • View에서 Controller에게 요청하면 Model에서 데이터를 처리한 뒤에 Controller를 통해 View에게 응답하는 역할을 한다.

Controller & Service & Repository

위의 내용을 잘 기억하면서 지난 Express.js와 Prisma ORM을 사용한 CURD API 글에서 만든 crud-test에 대해 MVC 패턴을 적용해보려고 한다. Controller-Service-Repository는 쉽게 이해하자면 MVC에서의 M(model)를 Repository로 보고 C(controller)를 그대로 Controller로 봤을 때 Service는 그 사이의 보조라고 생각할 수 있다. 내가 작성한 코드에서는 C(controller)를 Route로 보면 된다.

프로젝트 세팅

$ express crud-mvc 커맨드를 실행하여 프로젝트의 틀을 나는 crud-mvc 디렉터리로 이동하여 $ npm install express 커맨드와 $ npm install prisma @prisma/client를 실행했다.

이후에 $ npx prisma init 커맨드와 $ npm init -y 커맨드를 순서대로 실행하여 기본 세팅을 한 후에 .env 파일과 schema.prisma 파일에 MySQL 데이터베이스 관련 설정을 한 후에 $ npx prisma db pull 커맨드를 실행하여 DB 정보를 가져온 후 $ npx prisma generate 커맨드를 실행하여 Express.js와 Prisma 관련 설정을 끝냈다.

기존의 app.js

위의 링크를 통해 들어가서 확인할 수 있지만 직관적인 비교를 위해 아래에 기존의 app.js의 전체 코드를 첨부하겠다.

const express = require('express');
const http = require('http');

const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();

const app = express();
app.set('port', process.env.PORT || 3000);

app.use(express.json());

const router = express.Router();
app.use(router);

app.use(express.static(__dirname + '/public'));
app.get('/', (req, res) => {
  res.sendFile(__dirname + '/index.html');
});

// 전체 유저 조회(READ)
const getAllUsers = async () => {
  try {
    return await prisma.user.findMany();
  } catch (err) {
    console.error('Error in getAllUsers: ', err.stack);
    throw new Error('Failed to get all users');
  }
};

const getAllUserInfo = async () => {
  try {
    return await getAllUsers();
  } catch (err) {
    console.error('Error in getAllUsersInfo: ', err.stack);
    throw new Error('Failed to get all users');
  }
};

router.get('/getAllUsers', async (req, res) => {
  try {
    const users = await getAllUserInfo();
    res.status(200).json(users);
  } catch (err) {
    console.error('Error is getAllUsers route: ', err.stack);
    res.status(500).json({ error: 'Internal Server Error' });
  }
});

// 특정 유저 조회(READ)
const getUserById = async (userId) => {
  try {
    return await prisma.user.findUnique({ where: { id: userId }});
  } catch (err) {
    console.error('Error in getUserById: ', err.stack);
    throw new Error('Failed to get user by ID');
  }
};

const getUserInfoById = async (userId) => {
  try {
    return await getUserById(userId);
  } catch (err) {
    console.error('Error in getUserById: ', err.stack);
    throw new Error('Failed to get user by ID');
  }
};

router.get('/getUserById/:userId', async (req, res) => {
  try {
    const userId = parseInt(req.params.userId);
    const user = await getUserInfoById(userId);
    res.status(200).json(user);
  } catch (err) {
    console.error('Error is getUserById route: ', err.stack);
    res.status(500).json({ error: 'Internal Server Error' });
  }
});

// 새로운 유저 추가(CREATE)
const createUser = async (userData) => {
  try {
    return await prisma.user.create({
      data: userData,
    })
  } catch (err) {
    console.error('Error in createUser: ', err.stack);
    throw new Error('Failed to create user');
  }
};

const createUserByData = async (userData) => {
  try {
    return await createUser(userData);
  } catch (err) {
    console.error('Error in createUserByData: ', err.stack);
    throw new Error('Failed to create user');
  }
};

router.post('/createUser', async (req, res) => {
  try {
    const user = await createUserByData(req.body);
    res.status(200).json(user);
  } catch (err) {
    console.error('Error in createUserByData route: ', err.stack);
    res.status(500).json({ error: 'Failed to create user' });
  }
});

// 유저 정보 수정(UPDATE)
const updateUser = async (userId, userData) => {
  try {
    return await prisma.user.update({
      where: { id: userId },
      data: userData,
    })
  } catch (err) {
    console.error('Error in createUser: ', err.stack);
    throw new Error('Failed to update user');
  }
};

const updateUserById = async (userId, userData) => {
  try {
    return await updateUser(userId, userData);
  } catch (err) {
    console.error('Error in updateUserById: ', err.stack);
    throw new Error('Failed to update user');
  }
};

router.put('/updateUser/:userId', async (req, res) => {
  try {
    const userId = parseInt(req.params.userId);
    const updatedUser = await updateUserById(userId, req.body);
    res.status(200).json(updatedUser);
  } catch (err) {
    console.error('Error is updateUserById route: ', err.stack);
    res.status(500).json({ error: 'Internal Server Error' });
  }
});

// 유저 삭제(DELETE)
const deleteUser = async (userId) => {
  try {
    return await prisma.user.delete({
      where: { id: userId },
    })
  } catch (err) {
    console.error('Error in createUser: ', err.stack);
    throw new Error('Failed to delete user');
  }
};

const deleteUserById = async (userId) => {
  try {
    return await deleteUser(userId);
  } catch (err) {
    console.error('Error in deleteUserById: ', err.stack);
    throw new Error('Failed to update user');
  }
};

router.delete('/deleteUser/:userId', async (req, res) => {
  try {
    const userId = parseInt(req.params.userId);
    await deleteUserById(userId, req.body);
    res.status(204).end();
  } catch (err) {
    console.error('Error is deleteUserById route: ', err.stack);
    res.status(500).json({ error: 'Internal Server Error' });
  }
});

http.createServer(app).listen(app.get('port'), () => {
  console.log('Express server listening on port ' + app.get('port'));
});

지난 글에서도 언급했지만 공부를 위해 일부러 MVC 패턴을 적용하지 않고 하나의 파일 안에 모든 기능을 구현했기 때문에 가독성도 떨어지고 유지 보수에도 치명적일 것이다.

주석 5줄을 포함한 전체 코드는 178줄.. 그 누구도 이 코드를 해석하고 싶지 않을 것이다. 이제 이 코드에 MVC 패턴을 적용해보자. Github에도 crud-mvc로 올려놨으니 만약 코드가 수정되거나 추가될 수 있으니 한 번씩 확인해 보면 좋을 것 같다.

디렉터리 구조

app.js

const express = require('express');
const http = require('http');

const app = express();
app.set('port', process.env.PORT || 3000);

app.use(express.json());

/* /user로 시작하는 모든 경로의 요청에 대해 실행되는 사용자 라우터 설정 */
const userRouter = require('./routes/users');
app.use('/user', userRouter);

app.use(express.static(__dirname + '/public'));
app.get('/', (req, res) => {
  res.sendFile(__dirname + '/index.html');
});

http.createServer(app).listen(app.get('port'), () => {
  console.log('Express server listening on port ' + app.get('port'));
});

불러오는 모듈도 많이 줄고 코드 길이도 확실히 줄어든 것을 확인할 수 있다. 그리고 기존 코드와 다르게 const router = express.Router();const userRouter = require(’./routes/user-router’); 로 대체되었고 여기서 API 테스트를 진행할 url이 기존 코드와 달라진 것을 app.use('/user', userRouter); 부분을 보면 확인할 수 있다. Express.js의 Router 객체를 생성하는 것이 아닌 ./routes 디렉터리의 user-router.js 에서 export한 모듈을 불러오는 것인데 그 이유는 다음과 같다.

routes/users.js

const express = require('express');
const router = express.Router();

const {
  getAllUsers,
  getUserById,
  createUser,
  updateUser,
  deleteUser,
} = require('../services/user-service');

// 전체 유저 조회(READ)
router.get('/all', async (req, res) => {
  try {
    const users = await getAllUsers();
    res.status(200).json(users);
  } catch (err) {
    console.error('Error is getAllUsers route: ', err.stack);
    res.status(500).json({ error: 'Internal Server Error' });
    throw new Error('Failed to get all users');
  }
});

// 특정 유저 조회(READ)
router.get('/:userId', async (req, res) => {
  try {
    const userId = parseInt(req.params.userId);
    const user = await getUserById(userId);
    res.status(200).json(user);
  } catch (err) {
    console.error('Error is getUserById route: ', err.stack);
    res.status(500).json({ error: 'Internal Server Error' });
    throw new Error('Failed to get user by ID');
  }
});

// 새로운 유저 추가(CREATE)
router.post('/create', async (req, res) => {
  try {
    const userData = req.body;
    const user = await createUser(userData);
    res.status(200).json(user);
  } catch (err) {
    console.error('Error in createUserByData route: ', err.stack);
    res.status(500).json({ error: 'Failed to create user' });
    throw new Error('Failed to create user');
  }
});

// 유저 정보 수정(UPDATE)
router.put('/update/:userId', async (req, res) => {
  try {
    const userId = parseInt(req.params.userId);
    const userData = req.body;
    const updatedUser = await updateUser(userId, userData);
    res.status(200).json(updatedUser);
  } catch (err) {
    console.error('Error is updateUserById route: ', err.stack);
    res.status(500).json({ error: 'Internal Server Error' });
    throw new Error('Failed to update user');
  }
});

// 유저 삭제(DELETE)
router.delete('/delete/:userId', async (req, res) => {
  try {
    const userId = parseInt(req.params.userId);
    await deleteUser(userId);
    res.status(204).end();
  } catch (err) {
    console.error('Error is deleteUserById route: ', err.stack);
    res.status(500).json({ error: 'Internal Server Error' });
    throw new Error('Failed to update user');
  }
});

module.exports = router;

거의 모든 프로젝트에 사용되는 DB는 테이블이 1개가 아니다. 그래서 어떤 요청이 들어오냐에 따라 어떤 테이블에 어떤 연산을 할지 지정해줘야 한다.
userRouter 는 prisma_test_db.USER에 대한 라우터이다. 그래서 app.js 에서 app.use('/user', userRouter); 를 통해 /user 로 시작하는 모든 경로의 요청에 대해 실행되는 사용자 라우터를 설정한 것이고 기존의 중구난방이었던 경로 설정을 USER 테이블 관련 요청만 받을 수 있게 정리한 것이라고 보면 될 것 같다.
그리고 이 users.js는 ./services 디렉터리의 user-service.js 에서 export한 모듈을 불러와서 연산을 실행한다. MVC에서의 C(controller)의 역할을 수행한다.

controllers/user-controller.js

240121 수정

작성한 코드에 대해 피드백을 받았는데 MVC의 C(controller) 역할은 Route가 하게 된다. 나의 기존 코드는 요청 -> route -> controller -> service -> repository 를 통해 DB와 상호작용 하는, 즉 3계층 아키텍처가 아니라 4계층 아키텍처였던 것이었다.
그래서 기존 user-controller의 기능을 users에 통합하도록 수정했다. 수정된 코드는 Github crud-mvc에서 확인할 수 있다.

 

GitHub - juintination/Express-Study: A summary of what I studied about Express.js

A summary of what I studied about Express.js. Contribute to juintination/Express-Study development by creating an account on GitHub.

github.com

참고로 이후에 작성된 Express.js morgan cookie-parser, express-sessionExpress.js passport-local 사용한 로그인 테스트에서 기존 문제의 4계층 코드를 그대로 사용했는데 리팩토링은 따로 해주지 않을 예정이다.

services/user-service.js

const userRepository = require('../repositories/user-repository');

// 전체 유저 조회(READ)
async function getAllUsers() {
  try {
    return await userRepository.getAllUsers();
  } catch (err) {
    console.error('Error in getAllUsers: ', err.stack);
    throw new Error('Failed to get all users');
  }
}

// 특정 유저 조회(READ)
async function getUserById(userId) {
  try {
    return await userRepository.getUserById(userId);
  } catch (err) {
    console.error('Error in getUserById: ', err.stack);
    throw new Error('Failed to get user by ID');
  }
}

// 새로운 유저 추가(CREATE)
async function createUser(userData) {
  try {
    return await userRepository.createUser(userData);
  } catch (err) {
    console.error('Error in createUser: ', err.stack);
    throw new Error('Failed to create user');
  }
}

// 유저 정보 수정(UPDATE)
async function updateUser(userId, userData) {
  try {
    return await userRepository.updateUser(userId, userData);
  } catch (err) {
    console.error('Error in updateUser: ', err.stack);
    throw new Error('Failed to update user');
  }
}

// 유저 삭제(DELETE)
async function deleteUser(userId) {
  try {
    return await userRepository.deleteUser(userId);
  } catch (err) {
    console.error('Error in deleteUser: ', err.stack);
    throw new Error('Failed to delete user');
  }
}

// 외부에서 직접 호출할 수 있도록 함수들을 export
module.exports = {
  getAllUsers,
  getUserById,
  createUser,
  updateUser,
  deleteUser,
};

user-controller.js는 ./repositories 디렉터리의 user-repository.js 에서 export한 모듈을 불러와서 연산을 실행한다. MVC에서의 V(view) 역할을 수행하며 user-repository.js 의 함수 결과를 사용자에게 보여주는 것이라고 보면 된다.

services/user-service.js

const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();

// 전체 유저 조회(READ)
const getAllUsers = async () => {
  try {
    return await prisma.user.findMany();
  } catch (err) {
    console.error('Error in getAllUsers: ', err.stack);
    throw new Error('Failed to get all users');
  }
};

// 특정 유저 조회(READ)
const getUserById = async (userId) => {
  try {
    return await prisma.user.findUnique({ where: { id: userId }});
  } catch (err) {
    console.error('Error in getUserById: ', err.stack);
    throw new Error('Failed to get user by ID');
  }
};

// 새로운 유저 추가(CREATE)
const createUser = async (userData) => {
  try {
    return await prisma.user.create({
    data: userData,
    })
  } catch (err) {
    console.error('Error in createUser: ', err.stack);
    throw new Error('Failed to create user');
  }
};

// 유저 정보 수정(UPDATE)
const updateUser = async (userId, userData) => {
  try {
    return await prisma.user.update({
    where: { id: userId },
    data: userData,
    })
  } catch (err) {
    console.error('Error in createUser: ', err.stack);
    throw new Error('Failed to update user');
  }
};

// 유저 삭제(DELETE)
const deleteUser = async (userId) => {
  try {
    return await prisma.user.delete({
    where: { id: userId },
    })
  } catch (err) {
    console.error('Error in createUser: ', err.stack);
    throw new Error('Failed to delete user');
  }
};

module.exports = {
  getAllUsers,
  getUserById,
  createUser,
  updateUser,
  deleteUser,
};

user-repository.js는 Prisma Client 모듈을 불러와서 연산을 DB에 직접 실행한다. MVC에서의 M(model) 역할을 수행하며 Prisma ORM을 통해 DB와 상호작용한다.

API 테스트

나는 기존의 crud-test 프로젝트와 마찬가지로 CRUD API 테스트를 위해 Postman을 사용했다.

 

Postman API Platform | Sign Up for Free

Postman is an API platform for building and using APIs. Postman simplifies each step of the API lifecycle and streamlines collaboration so you can create better APIs—faster.

www.postman.com

위와 같이 CRUD-MVC라는 Collection을 만들고 POST, GET, DELETE, PUT 각 http 메서드에 대한 Request를 생성해준 뒤에 테스트를 진행했다. 마찬가지로 관련 내용은 나중에 추가하도록 하겠다.

 


먼저 GET 메서드를 테스트해보자. 현재 실행중인 서버의 포트번호는 3000이므로 localhost:3000/user/all/ 를 입력하고 우측 상단의 Send 버튼을 누르면 다음과 같이 정상적으로 현재 DB에 저장되어있는 test1과 test2에 대한 정보가 뜨는 것을 확인할 수 있다.

또한 추가적으로 localhost:3000/user/1/ 를 입력하고 우측 상단의 Send 버튼을 누르면 다음과 같이 정상적으로 현재 DB에 저장되어있는 test1에 대한 정보가 뜨는 것을 확인할 수 있다.

 


다음으로 POST 메서드를 테스트해보자. localhost:3000/user/create/ 를 입력하고 아래와 같이 해당 추가할 내용을 테이블에 맞게 json 파일을 작성해준 뒤에 우측 상단의 Send 버튼을 누르면 다음과 같이 정상적으로 User 테이블에 새로운 유저 test3가 추가된 것을 확인할 수 있다.

 


이번엔 DELETE 메서드를 테스트해보자. localhost:3000/user/delete/2/ 를 입력하고 우측 상단의 Send 버튼을 누르면 다음과 같이 정상적으로 User 테이블에서 id가 3인 유저 test3가 삭제된 것을 확인할 수 있다.

 


마지막으로 PUT 메서드를 테스트해보자. localhost:3000/user/update/1/ 를 입력하고 아래와 같이 "name"을 "test1"에서 "test_put"으로 바꾸도록 json 파일을 작성한 뒤에 우측 상단의 Send 버튼을 누르면 다음과 같이 정상적으로 User 테이블에서 id가 1인 유저의 "name"이 "test1"에서 "test_put"으로 바뀐 것을 확인할 수 있다.

 


결론

오늘은 MVC 패턴에 대해 알아보고 지난 crud-test 프로젝트에 대해 MVC 패턴을 적용해봤다. Express.js에서의 첫 리팩토링이라고 봐도 될 것 같은데 이렇게 배운 내용을 바탕으로 스퍼트 프로젝트도 잘 마무리했으면 좋겠다.

728x90
Comments