웹 백엔드 프레임워크를 처음 배우면 거의 항상 MVC라는 말을 듣게 된다. MVC가 무엇이고 왜 쓰는지, 실제 코드에서는 어떤 모습인지 Spring MVC 예제로 정리한다.
MVC란
MVC(Model-View-Controller)는 애플리케이션을 역할에 따라 세 부분으로 나누는 설계 패턴이다. 1979년 Trygve Reenskaug가 Smalltalk 환경의 GUI 프로그램을 위해 처음 고안했고, 이후 웹 프레임워크의 기본 구조로 널리 쓰이게 됐다.
| 구성 요소 | 역할 | 한 줄 요약 |
|---|---|---|
| Model | 데이터와 비즈니스 로직 | 무엇을 처리하는가 |
| View | 사용자에게 보여주는 화면 | 어떻게 보여주는가 |
| Controller | 사용자 요청을 받아 Model과 View를 연결 | 누가 받아서 넘겨주는가 |
핵심은 화면(View)과 로직(Model)을 분리하는 것이다. 화면 디자인이 바뀌어도 비즈니스 로직은 그대로 두고, 로직이 바뀌어도 화면 코드는 건드리지 않도록 하는 것이 목적이다.
웹에서의 요청 흐름
웹 애플리케이션에서 MVC는 다음 순서로 동작한다.
브라우저
│ ① GET /posts
▼
┌────────────┐ ② 데이터 요청 ┌────────────┐
│ Controller │ ───────────────▶ │ Model │ ◀──▶ DB
│ │ ◀─────────────── │ │
└────────────┘ ③ 결과 반환 └────────────┘
│ ④ 결과를 View에 전달
▼
┌────────────┐
│ View │ ⑤ HTML 생성
└────────────┘
│ ⑥ 응답
▼
브라우저
- 사용자가
/posts에 접속하면 요청이 Controller로 들어온다. - Controller는 필요한 데이터를 Model에 요청한다.
- Model은 DB 조회와 비즈니스 규칙 처리를 하고 결과를 돌려준다.
- Controller는 결과를 View에 넘긴다.
- View는 데이터를 화면(HTML)으로 만든다.
- 완성된 HTML이 브라우저로 응답된다.
Controller는 직접 계산하거나 화면을 그리지 않는다. 요청을 받아 일을 나눠 주는 교통정리 역할만 한다.
원래 MVC와 웹 MVC의 차이
MVC 설명 그림을 찾아보면 화살표 방향이 서로 다른 경우가 많다. 원래의 MVC와 웹에서 쓰는 MVC가 조금 다르기 때문이다.
| 원래 MVC (데스크톱 GUI) | 웹 MVC (Model 2) | |
|---|---|---|
| View와 Model의 관계 | View가 Model을 관찰하다가 변경되면 스스로 갱신 | View는 Controller가 넘겨준 데이터만 사용 |
| 화면 갱신 | Model이 바뀌면 즉시 반영 | 요청 → 응답 한 번으로 끝 |
| 연결 방식 | 세 요소가 서로 연결된 삼각형 | Controller가 중심인 일방향 흐름 |
HTTP는 요청이 오면 응답하고 연결이 끝나는 구조라서, 서버의 Model이 바뀌어도 이미 받은 브라우저 화면을 서버가 직접 갱신할 수 없다. 그래서 웹에서는 Controller가 중간에서 모든 흐름을 조율하는 형태(Model 2)로 바뀌었다. 이 글에서 말하는 MVC는 웹 MVC다.
Spring MVC 예제: 게시글 목록
“게시글 목록 조회”를 예로 각 계층이 어떤 코드가 되는지 살펴본다. (Spring Boot 3, Thymeleaf 기준)
Model — 데이터와 비즈니스 로직
Model은 하나의 클래스가 아니라 데이터(Entity)와 이를 다루는 로직(Service, Repository)을 합친 계층이다.
@Entity
public class Post {
@Id @GeneratedValue
private Long id;
private String title;
private String author;
private boolean published;
private LocalDateTime createdAt;
// getter 생략
}
public interface PostRepository extends JpaRepository<Post, Long> {
List<Post> findByPublishedTrueOrderByCreatedAtDesc();
}
@Service
@RequiredArgsConstructor
public class PostService {
private final PostRepository postRepository;
// 비즈니스 규칙: 공개된 글만, 최신순으로
public List<Post> getPublishedPosts() {
return postRepository.findByPublishedTrueOrderByCreatedAtDesc();
}
}
“공개된 글만 최신순으로 보여준다”는 규칙이 Service에 있다는 점이 중요하다. 이 규칙은 화면이 웹이든 앱이든 같아야 하므로 Controller나 View가 아닌 Model 쪽에 둔다. 비즈니스 로직이 무엇인지는 비즈니스 로직이란? 글에 따로 정리했다.
Controller — 요청을 받아 연결
@Controller
@RequiredArgsConstructor
public class PostController {
private final PostService postService;
@GetMapping("/posts")
public String list(Model model) {
List<Post> posts = postService.getPublishedPosts(); // ② Model에 요청
model.addAttribute("posts", posts); // ④ View에 넘길 데이터
return "posts/list"; // 사용할 View 이름
}
}
Controller가 하는 일은 세 가지뿐이다.
- 어떤 URL을 처리할지 정한다 (
@GetMapping("/posts")). - Service를 호출한다.
- 결과를 View에 넘기고 어떤 View를 쓸지 정한다.
메서드 파라미터의
Model은 Spring이 제공하는 View에 데이터를 전달하기 위한 상자다. MVC 패턴의 Model 계층과 이름이 같아서 헷갈리기 쉬운데, 패턴의 Model은 위의 Service·Entity 쪽이다.
View — 화면 생성
<!-- src/main/resources/templates/posts/list.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<h1>게시글 목록</h1>
<ul>
<li th:each="post : ${posts}">
<span th:text="${post.title}">제목</span>
- <span th:text="${post.author}">작성자</span>
</li>
</ul>
</body>
</html>
View는 받은 posts를 어떻게 보여줄지만 안다. 데이터가 어디서 왔는지, 어떤 조건으로 걸렀는지는 모른다.
Spring 내부에서 일어나는 일
위 코드에는 보이지 않지만, Spring MVC는 내부적으로 DispatcherServlet이라는 하나의 진입점이 모든 요청을 먼저 받는다(Front Controller 패턴).
요청 → DispatcherServlet
├─ HandlerMapping : /posts 를 처리할 Controller 메서드 찾기
├─ PostController.list() 실행
├─ ViewResolver : "posts/list" → templates/posts/list.html
└─ View 렌더링 → 응답
개발자는 Controller, Service, View만 작성하면 되고, 이들을 연결하는 일은 프레임워크가 맡는다.
REST API라면: View가 JSON이 된다
요즘 백엔드는 HTML 대신 JSON을 응답하고, 화면은 Vue·React 같은 프론트엔드가 그리는 경우가 많다. 이때는 @RestController를 쓴다.
@RestController
@RequiredArgsConstructor
public class PostApiController {
private final PostService postService;
@GetMapping("/api/posts")
public List<PostResponse> list() {
return postService.getPublishedPosts().stream()
.map(PostResponse::from)
.toList();
}
}
[
{ "id": 2, "title": "MVC 패턴 개요", "author": "jessica" },
{ "id": 1, "title": "첫 글", "author": "jessica" }
]
템플릿 파일은 없어지고, 반환한 객체가 JSON으로 변환되어 응답된다. JSON 응답이 View의 역할을 대신하는 셈이다. Model(Service)과 Controller의 역할 분리는 그대로 유지되므로, 같은 PostService를 HTML 화면과 API에서 함께 쓸 수 있다.
장점과 단점
장점
- 역할 분리 — 화면, 요청 처리, 비즈니스 로직이 나뉘어 코드가 어디 있는지 찾기 쉽다.
- 변경에 강함 — 화면 디자인을 바꿔도 Service는 그대로다. 위 예처럼 HTML 화면을 API로 바꿔도 비즈니스 로직은 재사용된다.
- 협업 — 퍼블리셔·프론트엔드는 View, 백엔드는 Model을 동시에 작업할 수 있다.
- 테스트 — 비즈니스 로직이 화면과 분리되어 있어 Service만 따로 단위 테스트하기 쉽다.
- 표준 구조 — 대부분의 웹 프레임워크가 같은 구조를 쓰므로, 처음 보는 프로젝트도 구조를 빠르게 파악할 수 있다.
단점
- 작은 기능에도 파일이 여러 개 — 간단한 화면 하나에도 Controller, Service, View가 필요해 오히려 번거로울 수 있다.
- Controller 비대화 — 역할 경계를 지키지 않으면 Controller에 로직이 쌓인다(아래 참고).
- 계층 간 의존 — View는 Controller가 넘겨주는 데이터 구조에 의존한다. 필드 이름 하나만 바뀌어도 여러 계층을 함께 고쳐야 할 수 있다.
- 복잡한 화면 상태에는 부족 — 화면 안에서 상태가 계속 바뀌는 인터랙티브한 UI(실시간 입력 검증, 드래그 등)는 요청-응답 구조의 웹 MVC로 다루기 어렵다. 이런 화면은 보통 브라우저 쪽 프론트엔드 프레임워크가 맡는다.
흔한 오해와 실무 구조
“Model = DB 테이블(Entity)“이 아니다
Model을 Entity 클래스 하나로 생각하는 경우가 많다. 하지만 MVC의 Model은 데이터와, 그 데이터에 적용되는 비즈니스 규칙 전체를 말한다. Spring 프로젝트로 치면 Entity, Repository, Service가 모두 Model 계층에 속한다.
Fat Controller
역할 분리가 무너지는 가장 흔한 형태는 Controller에 로직이 쌓이는 것이다.
// 좋지 않은 예: Controller가 비즈니스 규칙을 직접 처리
@GetMapping("/posts")
public String list(Model model) {
List<Post> posts = postRepository.findAll().stream()
.filter(Post::isPublished)
.sorted(Comparator.comparing(Post::getCreatedAt).reversed())
.toList();
model.addAttribute("posts", posts);
return "posts/list";
}
이렇게 하면 같은 규칙이 API Controller, 관리자 화면 Controller 등 여러 곳에 복사되고, 규칙이 바뀔 때 일부만 고쳐지는 문제가 생긴다. 규칙은 Service에 두고 Controller는 호출만 하도록 하는 것이 앞의 예제 구조다.
실무에서는 계층이 더 나뉜다
실제 Spring 프로젝트는 보통 다음처럼 나뉜다. MVC의 세 역할을 더 잘게 나눈 것이다.
Controller ─ 요청/응답 처리 ┐ Controller
DTO ─ 요청·응답 데이터 형식 ┘
Service ─ 비즈니스 로직 ┐
Repository ─ DB 접근 │ Model
Entity ─ 도메인 데이터 ┘
View / JSON ─ 화면 또는 API 응답 ─ View
MVC를 쓰는 대표 프레임워크
| 프레임워크 | 언어 | Model | View | Controller |
|---|---|---|---|---|
| Spring MVC | Java, Kotlin | Entity, Service, Repository | Thymeleaf, JSP | @Controller |
| Laravel | PHP | Eloquent Model | Blade | Controller 클래스 |
| Ruby on Rails | Ruby | Active Record | ERB | ApplicationController 상속 |
| ASP.NET Core MVC | C# | Model 클래스 | Razor | Controller 상속 |
| Django | Python | Model | Template | View |
Django는 구조는 같지만 이름이 다르다. Django는 스스로를 **MTV(Model-Template-View)**라고 부르는데, Django의 View가 MVC의 Controller 역할을, Template이 MVC의 View 역할을 한다. 이름만 보고 역할을 착각하기 쉬우니 주의한다.
Vue, React 같은 프론트엔드 프레임워크는 MVC 프레임워크로 분류하지 않는다. 이들은 화면을 컴포넌트 단위로 만드는 도구로, 전체 시스템에서 보면 View 계층을 담당한다.
정리
- MVC는 애플리케이션을 **Model(데이터·로직), View(화면), Controller(요청 처리)**로 나누는 패턴이다.
- 웹 MVC는 Controller가 요청을 받아 Model을 호출하고, 결과를 View에 넘기는 일방향 흐름이다.
- Model은 Entity 하나가 아니라 비즈니스 로직까지 포함한 계층이다. 로직이 Controller에 쌓이지 않도록 Service로 분리한다.
- REST API에서는 JSON 응답이 View 역할을 한다.