시작 계기

스터디를 목적으로 프로젝트를 진행하다 보면, 기능 구현에 더 많은 시간을 쏟는 바람에 자신도 모르게 가독성을 떨어지게 코드를 짤 때가 많다.

기능별로 분담을 해서 진행하는 소수 인원으로 구성된 프로젝트를 진행하거나, 개인 프로젝트를 진행하는 경우에 더욱 그렇다고 생각하는 것이

일단 나 자신만 해도 그랬기 때문이다.

이 프로젝트를 시작하기 전, 아키텍처에 관한 개념과 구성 방식을 접할 기회가 있었고, 이 방식이 그동안 해왔던 방식보다

보기에 더 깔끔하다고 생각했기 때문에 적용을 해보기로 결심했다.

아키텍처란 무엇인가?

단어 그대로의 의미는 건축학이라는 의미지만 여기서는 시스템, 소프트웨어 또는 컴퓨터 시스템의 구조 또는 설계의 의미를 가진다.

웹 / 앱의 서비스 구조, 구성요소, 데이터 흐름 및 처리 방식을 구조화하여 시스템의 유연성, 확장성, 유지 보수성을 향상 시키는데

도움을 줄 수 있는 것으로 알려져 있으며, 개인적으로는 서비스 내용을 보다 명확하게 이해하는데 큰 도움이 된다고 느꼈다.

예를 들어, 아래와 같이 회원 가입 / 로그인 서비스를 구현했다고 가정하자.

const express = require("express");
const usersRouter = require('./routes/users.js');
const sellersRouter = require('./routes/sellers.js');
const buyersRouter = require('./routes/buyers.js');

const app = express();
const port = 3000;

app.use(express.json());
app.use('/api', [usersRouter, sellersRouter, buyersRouter]);

app.listen(port, () => {
    console.log(`Server listen at ${port}`);
})
// routes/users.js
const express = require("express");
const { Users } = require("../models");
const jwt = require("jsonwebtoken");
const router = express.Router();

// 회원가입
router.post('/join', async(req, res) => {
    try {
        const { userName, userType, password } = req.body;
        const existUser = await Users.findOne({
            where: { userName }
        })

        // 유저가 존재하는 경우
        if (existUser) {
            return res.status(401).json({ errorMessage: "존재하는 ID 입니다." });
        }

        const newUser = await Users.create({ userName, userType, password });
        return res.status(200).json({ message: "회원가입이 완료되었습니다. " });

    } catch (error) {
        return res.status(400).json({ errorMessage: "회원 가입에 실패했습니다.", detail: error });
    }
});

router.post("/login", async(req, res) => {
    try {
        const { userName, userType, password } = req.body;
        const loginUser = await Users.findOne({
            where: { userName, userType }
        });

        // 회원 정보가 없을 때, (id 없음 or 가입 유형 다름) + 비밀번호 불일치
        if (!loginUser || password !== loginUser.password) {
            return res.status(412).json({ errorMessage: "로그인 정보를 확인해주세요." });
        }

        const token = jwt.sign({ userId: loginUser.userId }, "scerect-key");
        return res.status(200).json({ token: `Bearer ${token}`, userName: loginUser.userName, userType: loginUser.userType });

    } catch (error) {
        return res.status(400).json({ errorMessage: "로그인에 실패했습니다." });
    }
})
module.exports = router;

위 코드에서는, api/join, api/login 의 url 에 접근했을 때 request body의 값을 받아와서 검증 및 DB 접근 등 서비스 구현에 필요한

모든 처리를 한 곳에서 진행한다는 특징이 있다.

위의 경우는 코드 양이 많지 않아 이해하기 쉬울 수 있으나, 여러 가지의 로직이 추가된다면, 어디까지가 유효성 관련 코드이고,

또 어디까지가 DB 관련 코드인지 한 눈에 보기 어려워진다. 또, 에러가 발생하는 경우에, 어느 부분에서 발생했는지 직관적으로 알기가 어렵다.

이런 문제를 해결하기 위해 서비스 로직을 세부적으로 쪼개는 아키텍처가 도입되었는데, 이를 구성하는 패턴은 다양하게 존재하지만

이 글에서는 학습한 내용인 3계층 아키텍처를 적용해 볼 예정이다.

3계층 아키텍처는 서비스 구성요소를 크게 3가지로 분리하여 관리한다는 의미로 이름이 붙었는데, 3가지 요소는 아래와 같이 구분한다.

  1. 요청이 들어왔을 때, 처리할 로직을 정하는 Controller
  2. Controller에 의해 비즈니스 로직을 처리하는 Services
  3. DB에 접근하는 Repositories

각 계층의 예를 들어보면 (회원 가입) 

// routes/users.js
const express = require("express");
const router = express.Router();

const UsersController = require("../controllers/UsersController");
const usersController = new UsersController();

router.get('/join', usersController.userJoin);

module.exports = router;

routes 파일에서는 users와 관련된 로직을 처리하기 위해, 컨트롤러 폴더에서 관련 컨트롤러를 호출한 후

요청을받은 url에 따라 어떤 컨트롤러로 처리할지를 명시하고 있다.

다음은 Controller 에서는 request 에서 필요한 데이터를 추출한 뒤 하위 단계인 service 폴더 내 users.js 로 요청을 보낸다.

// controllers/users.js

const UsersService = require("../services/usersService");

class UsersController {
    usersServeice = new UsersServeice();

    userJoin = async(req, res, next) => {
    	const { userName, userType, password } = req.body;
        const join = await this.UsersServeice.userJoin(userName, userType, password);
        res.status(200).json({ data: "sucess" });
    };

controller/users.js 파일 내에서는 service 폴더 내 users.js파일을 호출하고, userJoin 로직이 완료된 처리 결과를 반환한다.

마찬가지로, service 폴더 내 users.js 파일에서는 DB에 접근하는 Repositories 폴더 내 users.js 파일을 호출하고

회원 가입에 필요한 정보들 (추출한 데이터)을 전달한다.

// services/users.js

const UsersRepository = require("../repositories/usersRepositories");

class UsersService {
    usersRepository = new UsersRepository();

    userJoin = async(userName, userType, password) => {
        const join = await this.UsersRepository.userJoin(userName, userType, password);
        return join;
    };
    
module.exports = UsersService;

마지막으로 Repositories/users.js 파일에서는 db에 접근해서 신청한 userName의 사용자가 있는지 등을 확인한 후

회원 가입 로직을 끝내고 메세지를 반환한다.

// /users.js
const { Users } = require("../models");
const jwt = require("jsonwebtoken");

// 회원가입
class UsersRepository {

    userJoin = async(userName, userType, password) => {
        try {
            const existUser = await Users.findOne({
                where: { userName }
            })

            // 유저가 존재하는 경우
            if (existUser) {
                return res.status(401).json({ errorMessage: "존재하는 ID 입니다." });
            }

            const newUser = await Users.create({ userName, userType, password });
            return res.status(200).json({ message: "회원가입이 완료되었습니다. " });

        } catch (error) {
            return res.status(400).json({ errorMessage: "회원 가입에 실패했습니다.", detail: error });
        }
    });

위 예시의 경우, 모든 로직이 DB에서 확인하는 것으로 구성되어있기 때문에, Services/user.js 파일이 불필요해 보이지만,

DB 접근과는 무관한 로직, 분기 Or 전처리 등이 필요할 때 추가적인 코드를 추가할 때 사용한다.

이렇게 서비스를 분리하여 관리함으로써 각 서비스 파트를 파일로 구별하여 관리할 수 있어 다수의 사람들과 협업 및 서비스 관리가 

용이하다는 장점이 있다.