JUINTINATION
Express.js과 MVC 패턴, Controller & Service & Repository 본문
MVC 패턴이란?
MVC 패턴은 모델-뷰-컨트롤러(model–view–controller) 3가지로 분리된 형태의 3 Layer Architecture를 사용하는 디자인패턴이다. 각각의 구성요소들 사이에는 다음과 같은 관계가 있다.
- 모델(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에서 확인할 수 있다.
참고로 이후에 작성된 Express.js의 morgan과 cookie-parser, express-session과 Express.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을 사용했다.
위와 같이 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에서의 첫 리팩토링이라고 봐도 될 것 같은데 이렇게 배운 내용을 바탕으로 스퍼트 프로젝트도 잘 마무리했으면 좋겠다.
'StudyNote' 카테고리의 다른 글
Express.js와 passport-local을 사용한 로그인 테스트 (0) | 2024.01.21 |
---|---|
Express.js의 morgan과 cookie-parser, express-session (2) | 2024.01.21 |
Express.js와 Prisma ORM을 사용한 CRUD API (0) | 2024.01.20 |
Node.js와 Express.js 가볍게 입문해보기 - 4 (0) | 2024.01.19 |
Express.js와 Prisma ORM + MySQL (0) | 2024.01.15 |