2026.04.09
MVC 패턴은 하나의 Servlet이 혼자 모든 것을 처리하던 문제를 해결하기 위해 애플리케이션의 코드를 3가지 역할로 명확하게 나누는 설계 방식임.
타임리프는 서버에서 HTML을 동적으로 만들어주는 자바 기반의 도구, 즉 템플릿 엔진임.
주된 역할
자바 코드(스프링 컨트롤러)가 보내주는 데이터를 실제 HTML 코드와 결합해서 내용이 채워진 완성된 웹 페이지를 만드는 것.
SSR(Server Side Rendering)은 서버가 웹 페이지에 필요한 모든 데이터를 채워 넣어 완벽하게 조립된 HTML 페이지를 브라우저에게 전달하는 방식. (타임리프가 SSR 방식임.)
사용자는 거의 즉시 콘텐츠를 볼 수 있어 초기 로딩 속도가 빠르고, 모든 내용이 HTML에 포함되어 있어 검색 엔진 최적화(SEO)에 유리함.
CSR(Client Side Rendering)은 서버가 거의 텅 빈 HTML 뼈대와 자바스크립트 파일만 보내주는 방식. 그래서 사용자 브라우저(클라이언트)가 자바스크립트를 실행해 필요한 데이터를 서버에 다시 요청하고, 브라우저가 직접 페이지를 조립하여 화면에 그림.
CSR은 초기 로딩은 느릴 수 있지만, 이후에는 앱처럼 부드럽고 빠른 화면 전환이 가능함.
프론트 컨트롤러 패턴은 모든 클라이언트 요청을 단일 진입점(Single Point of Entry)에서 처리하는 디자인 패턴임.
요청에 대한 공통 처리(보안, 로깅, 인코딩 등)를 중앙에서 효율적으로 관리할 수 있고, 개별 요청을 처리할 핸들러(Controller)로 작업을 위임하는 역할을 함.

어댑터 패턴은 서로 다른 인터페이스를 가진 클래스들을 연결해주는 패턴임.
즉 다형성을 잘 활용한 패턴이라고 볼 수 있음.
// HandlerAdapter 인터페이스 (공통 인터페이스)
public interface HandlerAdapter {
boolean supports(Object handler); // 처리 가능 여부 판단(boolean)
ModelAndView handle( // 어떻게 처리할 것인지에 대한 로직
HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception;
}
// Controller 구현 1 (HandlerAdapter를 implements)
public class RequestMappingHandlerAdapter implements HandlerAdapter {
@Override
public boolean supports(Object handler) {
return handler instanceof HandlerMethod; // @RequestMapping 메서드인지 확인
}
@Override
public ModelAndView handle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
// @Controller 방식으로 처리
return invokeHandlerMethod((HandlerMethod) handler, request, response);
}
}
// Controller 구현 2 (HandlerAdapter를 implements)
public class SimpleControllerHandlerAdapter implements HandlerAdapter {
@Override
public boolean supports(Object handler) {
return handler instanceof Controller;
}
@Override
public ModelAndView handle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
// Controller 인터페이스 방식으로 처리
return ((Controller) handler).handleRequest(request, response);
}
}
DispatcherServlet은 프론트 컨트롤러 패턴(Front Controller Pattern)을 구현한 스프링 MVC의 핵심적인 프론트 컨트롤러임.
hello 프로젝트를 생성하는데 롬복, 스프링 웹, 타임리프를 의존성으로 넣어주고 만들어줌.
HelloController 클래스를 만들고 아래 코드를 채워줌. 필요한 임포트는 해줘야함!
@Controller
public class HelloController {
@GetMapping("/hello")
public String hello(Model model) {
model.addAttribute("message", "Hello, Spring!");
return "hello"; // view
}
}
그리고 templates 경로에 hello.html 파일을 하나 만들어주고 아래 코드를 채워줌.
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>Hello Page</title>
</head>
<body>
<h1 th:text="${message}">(메시지가 보여지는 곳)</h1>
</body>
</html>
스프링을 실행 시키고 아래 링크로 들어가보면
http://localhost:8080/hello

이런게 나옴…! 귀엽 ㅎ
ViewResolver는 컨트롤러가 반환한 논리적인 뷰 이름을 실제 뷰로 바꿔주는 번역기 역할을 함.
이전 강의에서 실습했던 HelloController의 코드로 보면
@Controller
public class HelloController {
@GetMapping("/hello")
public String hello(Model model) {
model.addAttribute("message", "Hello, Spring!");
return "hello"; // view
}
}
위 코드에서 return "hello"; 코드는 ViewResolver에 의해 ”/templates/hello.html”이라는 실제 뷰 파일 경로와 매핑됨.
여기서 “hello”는 논리적인 뷰 이름이 되는 것이며, “/templates/hello.html”는 실제 뷰가 되는 것임. (사실 뭔 말인지 잘 모르겠음.)
Model은 Controller에서 View로 데이터를 전달하기 위한 Spring MVC의 핵심 인터페이스임.
위 코드에서 model.addAttribute("message", "Hello, Spring!"); 부분이 바로 Model 객체를 활용하는 코드임.
model.addAttribute("키_이름", 값);
이걸 보면 우선은 첫 번째 파라미터 (키, Key) : View에서 사용할 변수명, 두 번째 파라미터 (값, Value) : 전달할 데이터
즉, model.addAttribute("message", "Hello, Spring!"); 이 코드에서는 Key가 “message”이고, Value가 “Hello, Spring!” 인 Model 객체를 세팅한 것임.
그럼 프론트에서 보여지는 View에서 “message” 키로부터 “Hello, Spring!” 이라는 값을 불러와서 유저에게 데이터를 보여줄 수 있게 되는 것.
텍스트 출력(th:text)
th:text 는 HTML 태그의 텍스트 내용을 서버에서 전달된 데이터로 변경하는 데 사용됨.
<p>기본 텍스트</p>가 있더라도 th:text 안의 값이 그 자리를 완전히 대체함.
<!-- 기본 사용법 -->
<h1 th:text="${message}">기본 메시지</h1>
<!-- 결과: <h1>Hello, Spring MVC!</h1> -->
<!-- 문자열 결합 -->
<p th:text="'안녕하세요, ' + ${name} + '님!'">안녕하세요, 사용자님!</p>
<!-- 조건부 텍스트 -->
<span th:text="${user != null ? user.name : '게스트'}">사용자</span>
속성 설정(th:href, th:src, th:class)
th:* 문법을 사용하면 href, src, class 등 HTML 태그의 다양한 속성 값을 동적으로 설정할 수 있음.
<!-- 링크 생성 -->
<a th:href="@{/user/profile(id=${user.id})}">프로필 보기</a>
<!-- 결과: <a href="/user/profile?id=123">프로필 보기</a> -->
<!-- 이미지 경로 -->
<img th:src="@{/images/logo.png}" alt="로고">
<!-- CSS 클래스 조건부 적용 -->
<div th:class="${user.isActive ? 'active' : 'inactive'}">상태</div>
반복 처리(th:each)
th:each는 Java의 for-each 루프처럼 컬렉션(List, Set 등)의 데이터를 반복하여 여러 개의 HTML 요소를 생성할 때 사용함.
<!-- 리스트 반복 -->
<ul>
<li th:each="product : ${products}" th:text="${product.name}">상품명</li>
</ul>
<!-- 인덱스와 함께 반복 -->
<tr th:each="user, status : ${users}">
<td th:text="${status.index + 1}">번호</td>
<td th:text="${user.name}">이름</td>
</tr>
조건 처리(th:if, th:unless)
th:if와 th:unless는 특정 조건이 참이거나 거짓일 때 HTML 블록을 렌더링하거나 렌더링하지 않도록 제어하는 속성임.
<!-- 조건부 표시 -->
<div th:if="${user.isAdmin}">관리자 메뉴</div>
<div th:unless="${user.isAdmin}">일반 사용자 메뉴</div>
<!-- 삼항 연산자 활용 -->
<span th:class="${user.isOnline ? 'online' : 'offline'}"
th:text="${user.isOnline ? '온라인' : '오프라인'}">상태</span>
movies 라는 새로운 프로젝트를 만들어주고 Lombok, Spring Web, Thymeleaf 넣어서 세팅해줌.
MovieController 클래스 만들어주고
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import java.util.List;
import java.util.Random;
@Controller
public class MovieController {
@GetMapping("/movies")
public String getMovieRecommendation(Model model) {
String recommendedMovie = getMovieRecommendation();
model.addAttribute("title", "🎬 영화 추천 결과");
model.addAttribute("recommendedMovie", recommendedMovie);
model.addAttribute("totalMovies", recommendedMovies.size());
model.addAttribute("allMovies", recommendedMovies);
return "movies"; // templates/movies.html 로 매핑
}
private final List<String> recommendedMovies = List.of(
"쇼생크 탈출",
"대부",
"다크 나이트",
"인생은 아름다워",
"기생충",
"타이타닉",
"아바타",
"어벤져스: 엔드게임"
);
private final Random random = new Random();
private String getMovieRecommendation() {
int randomIndex = random.nextInt(recommendedMovies.size());
return recommendedMovies.get(randomIndex);
}
}
이 코드를 넣어주고, movies.html 파일을 만들어줌. 그 안에는 아래 코드를 넣음.
<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org"
th:with="
m=${#strings.trim(recommendedMovie?:'')},
posterUrl=${
m=='쇼생크 탈출' ? 'https://github.com/user-attachments/assets/4eea166b-65ef-4360-8ec1-402b70cf8817' :
m=='대부' ? 'https://github.com/user-attachments/assets/a1910767-2cf6-485e-8816-9aae3c9f5fc2' :
m=='다크 나이트' ? 'https://github.com/user-attachments/assets/dd172069-78e7-4945-b637-4a8ef10df995' :
m=='인생은 아름다워' ? 'https://github.com/user-attachments/assets/93ea654a-906b-4516-8d47-91b7a63516cb' :
m=='기생충' ? 'https://github.com/user-attachments/assets/cb4d5ded-0442-4fde-92d2-341c0a64bbb5' :
m=='타이타닉' ? 'https://github.com/user-attachments/assets/1e67b339-2137-46f3-8642-24ae7a9be114' :
m=='아바타' ? 'https://github.com/user-attachments/assets/f30ac710-7103-4fd4-8fd0-010c616e55f4' :
m=='어벤져스: 엔드게임' ? 'https://github.com/user-attachments/assets/0afe4084-2db7-4465-bf14-1791e746e3c3' :
null
}">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title th:text="${title}">영화 추천</title>
<link rel="dns-prefetch" href="https://github.com"/>
<link rel="preconnect" href="https://github.com" crossorigin>
<link rel="dns-prefetch" href="https://private-user-images.githubusercontent.com"/>
<link rel="preconnect" href="https://private-user-images.githubusercontent.com" crossorigin>
<link rel="dns-prefetch" href="https://user-images.githubusercontent.com"/>
<link rel="preconnect" href="https://user-images.githubusercontent.com" crossorigin>
<link rel="preload" as="image" th:if="${posterUrl}" th:href="${posterUrl}">
<style>*{margin:0;padding:0;box-sizing:border-box}body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f8f9fa;min-height:100vh;display:flex;align-items:center;justify-content:center;color:#2c3e50}.container{max-width:500px;width:90%;background:#fff;border-radius:16px;box-shadow:0 4px 20px rgba(0,0,0,.08);padding:40px 30px;text-align:center}.title{font-size:1.8em;font-weight:600;margin-bottom:30px;color:#2c3e50}.movie-poster{width:400px;height:600px;margin:0 auto 30px;border-radius:12px;overflow:hidden;box-shadow:0 8px 25px rgba(0,0,0,.15);background:#e9ecef;display:flex;align-items:center;justify-content:center;color:#6c757d;position:relative}.movie-poster img{width:100%;height:100%;object-fit:cover;object-position:center;transition:transform .3s ease}.movie-poster:hover img{transform:scale(1.05)}.recommendation-section{margin-bottom:30px}.recommendation-label{font-size:1.1em;color:#6c757d;margin-bottom:8px}.movie-title{font-size:2em;font-weight:700;color:#2c3e50;margin-bottom:10px}.movie-subtitle{color:#6c757d;font-size:1em}.btn{background:#007bff;color:#fff;border:none;padding:12px 24px;border-radius:8px;font-size:1em;font-weight:500;cursor:pointer;text-decoration:none;display:inline-block;transition:all .2s ease}.btn:hover{background:#0056b3;transform:translateY(-1px)}.btn:active{transform:translateY(0)}</style>
<script th:inline="javascript">
(function(){
var current = /*[[${posterUrl}]]*/ null;
var urls = [
'https://github.com/user-attachments/assets/4eea166b-65ef-4360-8ec1-402b70cf8817',
'https://github.com/user-attachments/assets/a1910767-2cf6-485e-8816-9aae3c9f5fc2',
'https://github.com/user-attachments/assets/dd172069-78e7-4945-b637-4a8ef10df995',
'https://github.com/user-attachments/assets/93ea654a-906b-4516-8d47-91b7a63516cb',
'https://github.com/user-attachments/assets/cb4d5ded-0442-4fde-92d2-341c0a64bbb5',
'https://github.com/user-attachments/assets/1e67b339-2137-46f3-8642-24ae7a9be114',
'https://github.com/user-attachments/assets/f30ac710-7103-4fd4-8fd0-010c616e55f4',
'https://github.com/user-attachments/assets/0afe4084-2db7-4465-bf14-1791e746e3c3'
];
for (var i=0; i<urls.length; i++) {
var u = urls[i];
if (!u || u === current) continue;
var img = new Image();
try { img.referrerPolicy = 'no-referrer'; } catch(e) {}
img.decoding = 'async';
img.src = u;
}
})();
</script>
</head>
<body>
<div class="container">
<h1 class="title" th:text="${title}">🎬 영화 추천 결과</h1>
<div class="movie-poster">
<img
th:if="${posterUrl}"
th:src="${posterUrl}"
th:alt="${recommendedMovie}"
loading="eager"
fetchpriority="high"
decoding="async"
referrerpolicy="no-referrer"
onerror="this.style.display='none'; this.parentElement.innerHTML='🎬 영화 포스터';"
/>
<span th:unless="${posterUrl}">🎬 영화 포스터</span>
</div>
<div class="recommendation-section">
<div class="recommendation-label">🎉 오늘의 추천 영화</div>
<div class="movie-title" th:text="${recommendedMovie}">영화 제목</div>
<div class="movie-subtitle">좋은 선택이에요! 즐거운 시청 되세요 🍿</div>
</div>
<a th:href="@{/movies}" class="btn">다른 영화 추천받기</a>
</div>
</body>
</html>
실행을 시킨 뒤에
localhost:8080/movies
이 링크로 들어가보면

이렇게 영화 추천을 해줌…!