웹 백엔드

Node.js - MVC 기본 개념 및 구현

토리쟁이 2024. 2. 14. 23:55

 

 

이전 포스팅에서 node.js로  간단한 게시판을 만들어 글을 등록하고 삭제하는 기능을 구현했었다. 하지만, 기본 실행 파일인 app.js에서 요청된 경로에 따라 응답을 처리하는 코드를 작성하니, 간단한 게시판 구현임에도 불구하고 기능별 코드 구분이 어렵고 코드가 정리되지 않은 느낌을 쉽게 받을 수 있다.

 

개인의 작은 프로젝트도 하나의 파일에 로직을 처리하는 부분을 몰아넣으면, 코드를 쉽게 찾을 수 없고 한 눈에 파악하기도 힘든데, 규모가 큰 기업의 프로젝트는 얼마나 더 힘들까?

 

이러한 점에서, 패턴들을 배워 해당 패턴의 규칙에 따라 프로젝트를 설계하고 유지보수하는 것은 굉장히 중요하다라는 것을 알 수 있다.

이번 포스팅에서는, 그 중 하나인 MVC 패턴에 대해 공부해볼 것이며, MVC 패턴은 여러 디자인 패턴 중 현업에서 가장 흔하게 사용되고 면접에서도 빈번하게 출제되는 부분이니 자세히 살펴보고 넘어가도록 하자.

 


 

 

MVC 패턴은 디자인 패턴 중 가장 많이 쓰이는 패턴이라고 했는데, 그렇다면 디자인 패턴은 무엇일까?

 

 

디자인 패턴

  • 프로그램 개발에서 자주 나타나는 문제들을 해결하기 위한 방법 중 하나로, 과거의 sw 개발 과정에서 발견된 설계의 노하우를 축적하여 이름을 붙이고, 이후에 재이용하기 좋은 형태로 특정 규약을 묶어서 정리한 것
  • 개발하면서 발생하는 반복적인 문제들을 어떻게 해결할 것인지에 대한 해결 방안으로 현업에서 비즈니스 요구 사항을 프로그래밍으로 처리하면서 만들어진 다양한 해결책 중에서 많은 사람들이 인정한 모범 사례
  • 디자인 패턴은 객체 지향 4대 특성(캡슐화, 추상화, 상속, 다형성)과 설계 원칙(SOLID)을 기반으로 구현되어 있음
    • 캡슐화: 데이터와 메서드를 하나의 단위로 묶어 외부에서 접근하지 못하도록 보호하는 개념
    • 추상화: 객체의 공통적인 속성과 기능을 추출하여 정의하는 것
    • 상속: 기존의 클래스를 재활용하여 새로운 클래스를 작성하는 자바의 문법 요소
      • 클래스간 공유될 수 있는 속성과 기능들을 상위 클래스로 추상화시켜 해당 상위 클래스로부터 확장된 여러 개의 하위 클래스들이 모두 상위 클래스의 속성과 기능을 간편하게 사용할 수 O
    • 다형성: 어떤 객체의 속성이나 기능이 상황에 따라 여러 가지 형태를 가질 수 있는 성질
      • 오버라이딩: 부모 클래스로부터 상속받은 메소드를 자식 클래스에서 재정의하는 것(오버로딩과 달리, 오버라이딩하고자 하는 메소드의 이름, 매개변수, 리턴 값이 모두 같아야 )
      • 오버로딩:이미 같은 이름을 가진 메소드가 있더라도, 매개변수의 개수/타입이 다르다면, 같은 이름을 사용해서 메소드를 정의할 수 있음
    • SOLID 설계 원칙
      • Single Responsibility Principle (단일 책임 원칙)
      • Open Closed Principle (개방 - 폐쇄 원칙)
      • Liskov Substitution Principle (리스코프 치환 원칙)
      • Interface Segregation Principle (인터페이스 분리 원칙)
      • Dependency Inversion Principle (의존 역전 원칙)
  • 장점
    • 재사용성, 가독성, 유지보수성, 확장성, 안정성, 신뢰성
  • 종류
    • 생성, 구조, 행위 패턴으로 나누어져 수많은 패턴들이 존재하니, 그때그때 찾아서 공부하자.
    • 생성: 싱글톤 패턴, 팩토리 메서드 패턴, 추상 팩토리 패턴, 빌더 패턴, 프로토타입 패턴
    • 구조: 어댑터 패턴, 브릿지 패턴, 컴포지트 패턴, 데코레이터 패턴, 퍼사드 패턴, 플라이웨이트 패턴, 프록시 패턴
    • 행위: 옵저버, 전략, 커맨드, 상태, 책임연쇄, 방문자, 인터프리터, 메멘토, 중재자, 템플릿 메서드, 이터레이터 패턴

 

 

 

 

 

 

MVC 패턴

 

 

MVC 패턴

  • Model View Controller
  • 모델, 뷰, 컨트롤러 3개의 컴포넌트로 나누고 각 컴포넌트에게 고유한 역할을 부여하는 개발 방식
  • MVC 패턴을 도입하면 도메인(비즈니스 로직) 영역과 UI 영역이 분리되므로, 서로 영향을 주지 않고 유지보수가 가능
  • Model - 데이터 가공을 책임지는 컴포넌트
  • View - 사용자에게 보여지는 부분(유저 인터페이스)
  • Controller - Model 과 View를 이어주는 다리 역할, 메인 로직 담당
  • 이처럼, 각자의 역할을 갖춘 3가지 컴포넌트로 나누어져 있다. 모델과 뷰는 서로 직접적인 통신을 하지 않고, 그저 변경사항이 생기면 외부에 알릴 뿐인데, 이를 컨트롤러가 해석해서 각각의 구성요소에게 통지하는 것이다.
  • 복잡한 대규모 프로그램의 경우, 다수의 model과 view가 controller를 통해 연결되기 때문에 controller가 불필요하게 커지는 현상이 발생하는데, 이러한 문제점을 보완하기 위한 다양한 패턴들이 파생되었다.(MVP, MVVM, Flux, Redux, RxMVVM 패턴 등)
  • MVC 이용 웹 프레임워크
    • PHP, Django, Express, Angular 등
  • 장점
    • 패턴들을 구분해 개발
    • 유지보수 및 협업 용이
    • 높은 유연성 & 확장성
  • 단점
    • 완벽한 의존성 분리의 어려움
    • 설계가 복잡하여 시간 소모가 큼
    • 클래스(단위)의 증가

 

 

 

 

 

 

위의 MVC 패턴을 웹에 적용해보면 다음과 같은 흐름이다.

 

웹에 접속한다고 가정해보자.

controller은 사용자가 요청한 웹 페이지를 서비스하기 위해서 모델을 호출한다.(Manipulates) Model은 관련된 데이터 소스를 처리한 후 그 결과를 리턴한다. Controller는 Model이 리턴한 결과를 View에 반영하고(Updates) 데이터가 반영된 View를 사용자에게 보여준다.(Sees)

즉, 클라이언트로부터 받은 요청을 Controller가 Model을 통해 데이터를 가지고 오고 그 정보를 바탕으로 View를 통해 시각적 표현을 제어하여 사용자에게 전달하게 되는 것이다.

 

 

 


 

 

실습)

 

이제, Node.js에서의 MVC 패턴으로 간단한 웹 페이지를 직접 구현해보자.

해당 실습은 댓글 목록과 댓글을 누르면 해당 댓글을 상세히 볼 수 있는 기능을 구현한다.

+ 유저 정보

 

 

초기 설정은 다음과 같이 진행한다.

 

 

 

내가 직접 구현한 MVC 패턴의 폴더 구조는 아래와 같다.

 

 

  • Controller - view와 model을 연결

 

  • Model - 데이터 처리

 

  • routes - 경로 설정

 

  • Views - UI 처리

 

 

 

 

 

 

 

 

 

이전까지는, 뷰에 관련된 폴더만 만든 후, app.js에서 모든 로직을 처리했었는데 MVC 로 나누어 각각의 폴더를 생성한 후, 파일을 생성했다. 여기서, routes폴더는 경로 설정을 위한 폴더이다.

 

 

 

실습은 DB 연결을 하지 않고 진행했기 때문에, model 파일에서 임시 데이터를 넣어서 사용한다.

 

 

model > Comment.js

// 임시 DB
// 배열을 return하는 함수
exports.commentsInfo = () => {
  return [
    // 임시 DB
    {
      id: 1,
      userid: "apple",
      date: "2022-10-31",
      comment: "안녕하세요",
    },
    {
      id: 2,
      userid: "banana",
      date: "2023-11-01",
      comment: "반가워요",
    },
    {
      id: 3,
      userid: "cocoa",
      date: "2024-01-01",
      comment: "코코아는 맛있어요",
    },
    {
      id: 4,
      userid: "donut",
      date: "2023-08-17",
      comment: "점심시간",
    },
  ];
};

 

 

model > User.js

exports.userInfo = () => {
  return {
    id: "cocoa",
    pw: "qwer1234",
  };
};

 

 

 

 

 

 

app.js 코드

const express = require("express");
const app = express();
const PORT = 8089;

// 미들웨어
// 뷰 설정
app.set("view engine", "ejs");
app.set("views", "./views");

// body-parser 설정
app.use(express.urlencoded({ extended: false }));
app.use(express.json());

// 라우팅 설정
// const indexRouter = require("./routes/index"); // index만 생략가능 (만약 다른 파일명이면 생략 불가)
const indexRouter = require("./routes");
// localhost:8088/ 경로를 기본으로 하는 경로는
// indexRouter에서 처리
app.use("/", indexRouter); // 기본 경로는 라우트 안의 인덱스 파일에서 처리

// localhost:8089/user 경로를 기본으로 하는 경로는 userRouter에서 처리
const userRouter = require("./routes/user");
app.use("/user", userRouter);

// 404 에러 페이지(가장 마지막에 작성할 것)
app.get("*", (req, res) => {
  res.render("404");
});

app.listen(PORT, () => {
  // 포트가 실행됐을 때 동작할 함수
  console.log(`http://localhost:${PORT}`);
});

 

 

위 코드를 보면, 전과 달리 다른 로직에 대한 처리없이 설정을 위한 코드만 작성되어 있다는 것을 알 수 있다. 초반에 view와 body-parser 설정을 하고, 그 다음 라우팅 미들웨어 설정을 해주었다. 이렇게, 라우터 모듈로 분리해 사용하면 라우팅을 깔끔하게 관리할 수 있다는 이점이 있다. routes 폴더에서 라우터를 모듈화한 파일들을 생성하고 app.js에서 사용할 파일을 설정해주면 된다. 말로 설명해서 와닿지 않는다면, 다음과 같은 예시를 들 수 있다.

 

 

www.youtube.com/feed/library
www.youtube.com/feed/history
www.youtube.com/feed/downloads

 

 

이렇게 /feed 경로까지는 동일하지만 그 뒤에 경로가 달아짐에 따라 페이지가 달라진다. 이렇게 각각 다른 페이지에 따른 처리를 따로한다면, 코드가 복잡하고 보기에도 좋지 않기 때문에, 라우팅을 이용해서 경로를 대분류 - 중분류 - 소분류식으로 나누는 작업을 하여 정리하는 것이라고 볼 수 있다.

 

 

  • const indexRouter = require("./routes");
    • 라우터를 모듈화한 파일의 경로를 작성하여 모듈화된 라우터를 변수에 저장하여 객체 생성
    • 이 코드는 require("./routes/index"); 와 동일한데 그 이유는, 여기서 index는 생략할 수 있기 때문이다.
    • 즉, routes 폴더 안의 index.js 모듈을 블러와 라우터 객체를 생성한 것이다.
  • app.use('원하는 기본 경로', 사용할 라우터 파일); - 모듈 호출 및 루트 설정
  • app.use('/', indexRouter);
    • 루트 경로인 localhost:8089/ 경로를 기본으로 하는 경로는 위에서 생성한 라우터 객체인 indexRouter에서 처리
  • indexRouter 말고도 userRouter도 생성하여 사용했는데, 이처럼 여러 개의 라우터 모듈을 만들어 사용할 수 있다.
  • const userRouter = require("./routes/user");
    • routes 폴더 안의 user.js 모듈을 블러와 라우터 객체를 생성
  • app.use('/user', userRouter);
    • /user 경로(localhost:8089/user)를 기본으로 하는 경로는 위에서 생성한 라우터 객체인 userRouter에서 처리

 

 

 

이렇게, 라우터를 모듈화한 파일을 생성하고 app.js에서 해당 모듈을 불러와 객체를 생성하여 라우팅 미들웨어 설정을 해주었는데, 이제 해당 라우터 모듈들을 살펴보자.

 

 

 

routes > index.js

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

const controller = require("../controller/Cmain");

router.get("/", controller.main);

router.get("/comments", controller.comments);

router.get("/comment/:id", controller.comment);

// 한 번에 내보내기
module.exports = router;

 

 

모듈식 마운팅 가능한 핸들러 작성을 위해 express.Router를 인스턴스화하여 사용한다.

Router 인스턴스는 완전한 미들웨어이자, 라우팅 시스템이다.

 

  • const router = express.Router(); : 라우터 인스턴스화
  • const controller = require("../controller/Cmain");
    • controller 폴더 하위에 있는 Cmain 컨트롤러 불러오기
  • 이전 실습에서는, app.js 파일에서 app.요청방식('요청경로', 처리함수); 이런 형태로 작성했었는데 이제는 라우터 파일에서 컨트롤러에 있는 함수를 넣으면 된다. 컨트롤러에 있는 함수에 처리할 로직을 작성해 놓았기 때문이다.
  • router.get('/comment/:id', controller.comment);
    • 경로에서 /:뒤의 변수는 파라미터로 받겠다는 뜻으로, 경로에 값을 넣어 파라미터를 전송할 수 있다.
    • 주의할 점은, 해당 코드의 작성 위치가 제일 아래에 있어야 한다. 만약 /comment/like 경로로 요청을 받으면 어떠한 페이지를 렌더링시켜준다고 가정하자. 만일, 이런 렌더링 로직 코드보다 이 코드가 위에 위치하게 된다면, like가 파라미터로 인식되어 예상 밖의 결과를 불러올수도 있기 때문이다. 따라서, 라우터를 작성할 때는 무엇보다 실행 순서에 주의해서 작성해야 한다.
  • module.exports = router;
    • express.Router()가 반환한 객체(router)를 수정한 → 최종 객체를 모듈로 반환하겠다는 것
    • 이렇게 export한 최종 router 객체를 app.js에서require하여 사용한 것이다. 

 

 

 

routes > user.js

const express = require("express");
const router = express.Router();
const controller = require("../controller/Cuser");

// GET /user
/*
주의) router.get("/user", controller.user); 에러 발생
app.js에서 경로 설정을 할 때 /user 경로를 기본으로 하는 것은 다 routes 폴더의 user.js에서 처리한다고 설정해놓았기 때문에, 여기서 /는 /user을 의미한다.
따라서, 요청 경로가 /user인 경우엔 /user/user 경로가 되므로 에러가 발생한다.
*/
// GET /user/user
// 기본적으로 앞에 /user
// router.get('/user', controller.user);


router.get("/", controller.user);


// 한 번에 내보내기
module.exports = router;

 

 

위의 코드는 또 다른 라우터 모듈 파일인데, index.js 처럼 원하는 controller를 불러와서 요청된 경로에 따라 controller의 함수로 응답을 처리하는 코드이다. index.js 파일에서 원하는 controller를 불러와서 설정한 경로로 요청을 받으면, 응답하는 함수를 작성해도 구동에는 문제가 없다. 하지만, 라우터 모듈화하여 관리하는 이유에 경로에 따라 좀 더 보기 좋고 정리되어 있는 코드 작성을 위함이 있으니 이를 위해 파일을 분리한 것이다. /user뿐만 아니라 /user/~ 경로에 대한 처리는 이 파일에서 해주면 된다. 

 

여기서 주의할 점은, app.js에서 해당 라우터 모듈을 설정할 때, 기본 경로를 /user 라고 설정했으므로

const userRouter = require("./routes/user");

app.use("/user", userRouter);

이 user.js에서의 기본 경로인 /는 실제 요청 경로가 /user가 된다.

 즉, user.js 파일에서 경로로 "/user"을 작성했다면, 실제 경로는 /user/user 이 되는 것이다.

 

 

 

 

위의 2개의 라우터 모듈 파일에서 controller를 불러와서 사용했는데, 이제 이 controller 코드를 살펴보자.

보통, 파일명 맨 앞에 C를 붙이는데 controller의 약자를 의미한다.

 

 

controller > Cmain.js

// DB 연결을 안 했기 때문에, 임시 DB인 Comment를 사용하기 위해 Comment 모델을 불러와야 함
const Comment = require("../model/Comment"); // 상대경로로 불러오기

// GET /
exports.main = (req, res) => {
  res.render("index");
};

// GET /comments
exports.comments = (req, res) => {
  console.log(Comment.commentsInfo()); // app.js에서 상단에 선언해둔 배열
  res.render("comments", { commentInfo: Comment.commentsInfo() });
};

// GET /comments/:id(params 사용)
exports.comment = (req, res) => {
  const commentId = req.params.id; // 1, 2, 3, 4
  const comments = Comment.commentsInfo(); // 댓글 목록 배열 [{},{},{},{},,,,]
  console.log(req.params);
  //res.render("comment", { commentInfo: comments[commentId - 1] });

  //   if (commentId < 1 || commentId > comments.length) {
  //     return res.render("404");
  //   }

  //   if (isNaN(commentId)) {
  //     return res.render("404");
  //   }

  //   한번에 처리
  if (!comments[commentId - 1]) return res.render("404");

  res.render("comment", { commentInfo: comments[commentId - 1] });
};

 

  • controller에서는 model에서 받은 데이터를 가공해서 view에 전달한다.
  • const = require("../model/Comment");
    • 데이터를 넣어놨던 model을 불러오기
    • 상대 경로 사용
  • exports.main = (req, res) =>{응답 처리};
    • 요청이 들어왔을 때 응답 처리를 하는 함수를 작성하여 export
    • 응답 함수로는 send, sendFile, json, redirect, render 등이 있다.
    • res.render('렌더링할 파일명', 보낼 데이터);
      • 보통, 데이터를 객체로 묶어서 보낸다. {CommentInfo:Comment.commentsInfo()};
        • CommentInfo라는 객체의 이름으로 model 파일에서 만들어둔 데이터 배열을 반환하는 함수인 commentsInfo()를 호출하여 전송
    • 경로에 파라미터 값을 넣어서 전송할 수 있다고 했는데, 이 값은 req.params.파라미터명에 있다.
    • '/comment/:id' 경로면, 파라미터인 id를 받고 있으므로, 해당 값은 req.params.id에 담기는 것이다.
    • (만약 해당 댓글을 클릭하면 해당 댓글의 id 값을 파라미터 값으로 넘겨주고 그 id 값으로 해당 데이터에 접근하여 그 데이터를 사용자에게 보여주는 로직을 작성하기 위함이다.)
    • 중간의 주석 처리된 로직은, 해당 id인 댓글이 존재하지 않을 경우 404 페이지를 렌더링하는 로직인데 맨 아랫 줄을 보면 알 수 있듯 한 번에 처리가 가능하다. id는 배열 인덱스보다 1이 크므로 받은 id 값에 1을 빼서 해당 댓글에 접근한다음, 해당 댓글이 없으면 404 페이지를 렌더링하, 있으면 해당 댓글을 객체로 묶어 응답해주면 된다.

 

 

controller > Cuser.js

const User = require("../model/user"); // 상대경로로 모델 객체 불러오기
// User.userInfo(); // {id:~, pw:~}

exports.user = (req, res) => {
  res.render("user", { userInfo: User.userInfo() });
};

 

위와 로직 동일

 

 

뷰는 일부만 첨부하겠다.

views > comments.ejs - 댓글 목록

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>댓글 목록 보기</title>
    <!-- commentInfo:[
    {
        id: 1,
        userid: "apple",
        date: "2022-10-31",
        comment: "안녕하세요~",},{}] -->
  </head>
  <body>
    <h1>댓글 목록</h1>
    <a href="/">홈으로 이동하기</a>

    <ul>
        <%for(let i=0; i<commentInfo.length;i++){%>
            <li>
                <b><%=commentInfo[i].userid%></b>
                <a href="/comment/<%=i+1%>"><%=commentInfo[i].comment%></a>
            </li>
        <%}%>
    </ul>
  </body>
</html>

 

응답받은 데이터 배열의 길이 만큼 반복문을 돌려 화면에 보여준다.  

 

 

views > comment.ejs - 댓글 상세 보기

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>댓글 상세 보기</title>
  </head>
  <body>
    <!-- 
      commentInfo:{
        id: 1,
        userid: "apple",
        date: "2022-10-31",
        comment: "안녕하세요~",
    },
     -->
    <h1><%=commentInfo.userid%>님의 댓글입니다.</h1>
    <a href="/comments">댓글 목록 보기</a>
    <p>작성일: <%=commentInfo.date%></p>
    <p>댓글 내용: <%=commentInfo.comment%></p>
  </body>
</html>

 

 


 

 

실행 페이지 일부

 

댓글 목

 

맨 위 댓글 상세 보기

 

 

 


참고 

디자인 패턴

https://ittrue.tistory.com/550

 

객체 지향 프로그래밍의 특징

https://www.codestates.com/blog/content/%EA%B0%9D%EC%B2%B4-%EC%A7%80%ED%96%A5-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D-%ED%8A%B9%EC%A7%95

 

SOLID 원칙

https://hckcksrl.medium.com/solid-%EC%9B%90%EC%B9%99-182f04d0d2b

 

mvc

https://m.blog.naver.com/jhc9639/220967034588

https://junhyunny.github.io/information/design-pattern/mvc-pattern/

 

 

router

https://velog.io/@neulhere/Node.js-express-Router%EC%97%90-%EB%8C%80%ED%95%B4%EC%84%9C

https://abangpa1ace.tistory.com/entry/Expressjs-Routing-Middleware

https://backback.tistory.com/341

 

 

export 문법

https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Statements/export

 

export - JavaScript | MDN

export 문은 JavaScript 모듈에서 함수, 객체, 원시 값을 내보낼 때 사용합니다. 내보낸 값은 다른 프로그램에서 import 문으로 가져가 사용할 수 있습니다.

developer.mozilla.org