스프링 MVC 웹 페이지 만들기
- 김영한님의 스프링 MVC 1편 강의를 통해 타임리프를 활용한 상품 관리 웹 애플리케이션을 만들면서 스프링 MVC의 실용적인 기능들인
@ModelAttribute, PRG 패턴,RedirectAttributes의 사용법과 동작 원리를 정리함
프로젝트 구조 및 설정
프로젝트 생성
- 빌드 도구
- Gradle
- 언어
- Java 21
- Spring Boot
- 4.0.1
- 패키징
- Jar (내장 톰캣 사용)
- Dependencies
- Spring Web
- Thymeleaf
- Lombok
- build.gradle 전체 코드
Welcome 페이지
- 위치
/resources/static/index.html
- 동작 확인
http://localhost:8080호출 시 Welcome 페이지 표시
요구사항 분석
상품 도메인 모델
- 상품 (Item)
- ID (Long)
- 상품명 (String)
- 가격 (Integer)
- 수량 (Integer)
상품 관리 기능
- 상품 목록
- 전체 상품 조회
- 상품 상세
- 개별 상품 조회
- 상품 등록
- 새 상품 추가
- 상품 수정
- 기존 상품 정보 변경
서비스 제공 흐름
- 타임리프 사용 시 (서버 사이드 렌터링)
- 백엔드 개발자가 HTML 템플릿을 작성하고 동적 데이터 바인딩
- 서버에서 모든 HTML을 완성해서 클라이언트에 전달
- 브라우저는 완성된 HTML을 받아서 바로 렌더링
- 장점
- 초기 로딩 속도가 빠름
- SEO(검색 엔진 최적화)에 유리
- 페이지별 전체 새로고침
- 단점
- 페이지 전환 시 서버 요청 필요
- 동적인 사용자 경험 제공에 제한적
- React, Vue.js 사용 시 (클라이언트 사이드 렌더링)
- 백엔드는 데이터만 JSON 형태로 제공 (HTTP API)
- 프론트엔드가 브라우저에서 JavaScript로 HTML을 동적 생성
- 화면 흐름과 렌더링을 프론트엔드가 완전히 제어
- 장점
- 페이지 전환 없이 부드러운 사용자 경험
- 화면과 비즈니스 로직의 명확한 분리
- 단점
- 초기 로딩이 느릴 수 있음
- SEO 처리에 별도 작업 필요
- 선택 기준
- 관리자 페이지, 내부 시스템 등 간단한 웹 애플리케이션 → 타임리프
- 복잡한 사용자 인터랙션이 필요한 서비스 → React/Vue.js
- 둘을 혼합해서 사용하는 것도 가능
도메인 개발
Item - 상품 객체
1
2
3
4
5
6
7
@Data
public class Item {
private Long id;
private String itemName;
private Integer price;
private Integer quantity;
}
- @Data (Lombok)
@Getter,@Setter,@ToString,@EqualsAndHashCode,@RequiredArgsConstructor
- 주의
Integer사용 이유null값 허용 필요int는 0이 기본값이라 null과 구분 불가
- Item 클래스 전체 코드
ItemRepository - 상품 저장소
1
2
3
4
5
6
7
8
9
10
@Repository
public class ItemRepository {
private static final Map<Long, Item> store = new HashMap<>();
private static long sequence = 0L;
public Item save(Item item) { }
public Item findById(Long id) { }
public List<Item> findAll() { }
public void update(Long itemId, Item updateParam) { }
}
- 설계 특징
static사용- 싱글톤 보장
HashMap사용- 실무에서는
ConcurrentHashMap권장
- 실무에서는
sequence- ID 자동 생성
- ItemRepository 전체 코드
타임리프 기본
타임리프 사용 선언
1
<html xmlns:th="http://www.thymeleaf.org">
타임리프 기본 개념
- Natural Templates (네츄럴 템플릿)
- 순수 HTML 파일을 브라우저에서 직접 열어도 내용 확인 가능
- 서버를 통해 뷰 템플릿을 거치면 동적으로 변경된 결과 확인
- JSP와의 차이
- JSP는 브라우저에서 직접 열면 소스코드와 HTML이 섞여 정상 확인 불가
- 동작 원리
th:xxx속성- 서버사이드에서 렌더링되어 기존 속성 대체
th:xxx없으면- 기존 HTML 속성 그대로 사용
- 브라우저는
th:속성을 모르므로 무시
주요 타임리프 문법
-
속성 변경 - th:href
1 2 3 4 5
<!-- 정적 --> <link href="../css/bootstrap.min.css" rel="stylesheet"> <!-- 타임리프 --> <link href="../css/bootstrap.min.css" th:href="@{/css/bootstrap.min.css}" rel="stylesheet">
-
URL 링크 표현식 - @{}
1
th:href="@{/css/bootstrap.min.css}"- 특징
- URL 링크 사용 시
@{}사용 - 서블릿 컨텍스트 자동 포함
- 경로 변수, 쿼리 파라미터 지원
- URL 링크 사용 시
-
경로 변수 예시
1 2
th:href="@{/basic/items/{itemId}(itemId=${item.id})}" <!-- 결과: /basic/items/1 --> -
쿼리 파라미터 예시
1 2
th:href="@{/basic/items/{itemId}(itemId=${item.id}, query='test')}" <!-- 결과: /basic/items/1?query=test -->
- 특징
-
**리터럴 대체 - ** 1 2 3 4 5
<!-- 복잡한 방식 --> th:onclick="'location.href=' + '\'' + @{/basic/items/add} + '\''" <!-- 리터럴 대체 사용 --> th:onclick="|location.href='@{/basic/items/add}'|"
-
리터럴 대체 간단 URL
1
th:href="@{|/basic/items/${item.id}|}"
-
-
반복 출력 - th:each
1 2 3 4 5 6
<tr th:each="item : ${items}"> <td th:text="${item.id}">1</td> <td th:text="${item.itemName}">상품명</td> <td th:text="${item.price}">10000</td> <td th:text="${item.quantity}">10</td> </tr>
- 동작
items컬렉션의 데이터를item변수에 하나씩 할당- 컬렉션 수만큼
<tr>태그와 하위 태그 생성
- 동작
-
변수 표현식 - ${}
1
<td th:text="${item.price}">10000</td>
- 특징
- 모델에 포함된 값 또는 타임리프 변수 조회
- 프로퍼티 접근법 사용 (
item.getPrice())
- 특징
-
내용 변경 - th:text
1
<td th:text="${item.price}">10000</td>
- 태그 내용을
th:text값으로 변경 - 여기서는
10000을${item.price}값으로 변경
- 태그 내용을
-
속성 변경 - th:value
1
<input type="text" id="itemId" name="itemId" value="1" th:value="${item.id}" readonly>
-
조건부 렌더링 - th:if
1
<h2 th:if="${param.status}" th:text="'저장 완료!'"></h2>
- 조건이 참일 때만 렌더링
${param.status}- 쿼리 파라미터 조회 (타임리프 내장 기능)
-
Form 액션 - th:action
1
<form action="item.html" th:action method="post">
- 특징
action에 값이 없으면 현재 URL에 데이터 전송- 동일 URL에서 GET(폼), POST(처리) 구분 가능
- 특징
상품 목록 기능
컨트롤러
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Controller
@RequestMapping("/basic/items")
@RequiredArgsConstructor
public class BasicItemController {
private final ItemRepository itemRepository;
@GetMapping
public String items(Model model) {
List<Item> items = itemRepository.findAll();
model.addAttribute("items", items);
return "basic/items";
}
@PostConstruct
public void init() {
itemRepository.save(new Item("testA", 10000, 10));
itemRepository.save(new Item("testB", 20000, 20));
}
}
- @RequiredArgsConstructor
final멤버변수로 생성자 자동 생성- 생성자가 1개면
@Autowired자동 적용
- @PostConstruct
- 빈의 의존관계가 모두 주입된 후 초기화 용도로 호출
- 테스트 데이터 추가용
뷰 템플릿
-
주요 부분
1 2 3 4 5 6
<tr th:each="item : ${items}"> <td th:text="${item.id}">회원id</td> <td th:text="${item.itemName}">상품명</td> <td th:text="${item.price}">10000</td> <td th:text="${item.quantity}">10</td> </tr>
상품 상세 기능
컨트롤러
1
2
3
4
5
6
@GetMapping("/{itemId}")
public String item(@PathVariable Long itemId, Model model) {
Item item = itemRepository.findById(itemId);
model.addAttribute("item", item);
return "basic/item";
}
- 동작
@PathVariable로 상품 ID 받기- Repository에서 상품 조회
- 모델에 담기
- 뷰 템플릿 호출
뷰 템플릿
-
주요 부분
1 2 3 4 5
<!-- 저장 완료 메시지 (PRG 패턴 사용 시) --> <h2 th:if="${param.status}" th:text="'저장 완료!'"></h2> <input type="text" th:value="${item.id}" readonly> <input type="text" th:value="${item.itemName}" readonly>
상품 등록 기능
등록 폼 컨트롤러
1
2
3
4
@GetMapping("/add")
public String addForm() {
return "basic/addForm";
}
- 단순히 뷰 템플릿만 호출
등록 폼 뷰
-
핵심 부분
1 2 3 4 5 6
<form action="item.html" th:action method="post"> <input type="text" name="itemName" placeholder="이름을 입력하세요"> <input type="text" name="price" placeholder="가격을 입력하세요"> <input type="text" name="quantity" placeholder="수량을 입력하세요"> <button type="submit">상품 등록</button> </form>
- th:action 특징
action값이 없으면 현재 URL에 데이터 전송- 상품 등록 폼
GET /basic/items/add
- 상품 등록 처리
POST /basic/items/add
- 하나의 URL로 폼과 처리를 깔끔하게 분리
- 등록 폼 뷰 템플릿 전체 코드
등록 처리 컨트롤러 구현 방식
-
v1 - @RequestParam 사용
1 2 3 4 5 6 7 8
@PostMapping("/add") public String addItemV1(@RequestParam String itemName, @RequestParam int price, @RequestParam Integer quantity, Model model) { Item item = new Item(); item.setItemName(itemName); }
- 특징
- 요청 파라미터를 하나하나 받아서 처리
- 객체 생성 과정이 번거로움
- 특징
-
v2 - @ModelAttribute 사용
1 2 3 4 5
@PostMapping("/add") public String addItemV2(@ModelAttribute("item") Item item) { itemRepository.save(item); return "basic/item"; }
- @ModelAttribute 기능
- 요청 파라미터 처리
- Item 객체 생성 + 프로퍼티 접근법(setXxx)으로 값 입력
- Model 자동 추가
@ModelAttribute("item")로 지정한 이름으로 모델에 자동 추가
- 요청 파라미터 처리
- @ModelAttribute 기능
-
v3 - @ModelAttribute 이름 생략
1 2 3 4 5
@PostMapping("/add") public String addItemV3(@ModelAttribute Item item) { itemRepository.save(item); return "basic/item"; }
- 클래스명 → 모델 이름 변환 규칙
- 클래스의 첫 글자를 소문자로 변경
- camelCase 형식 유지
- 클래스명 → 모델 이름 변환 규칙
-
v4 - @ModelAttribute 완전 생략
1 2 3 4 5
@PostMapping("/add") public String addItemV4(Item item) { itemRepository.save(item); return "basic/item"; }
- 애노테이션 생략 규칙
- 단순 타입(
String,int,Integer등) →@RequestParam적용 - 나머지 →
@ModelAttribute적용
- 단순 타입(
- 애노테이션 생략 규칙
상품 수정 기능
수정 처리 컨트롤러
1
2
3
4
5
@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId, @ModelAttribute Item item) {
itemRepository.update(itemId, item);
return "redirect:/basic/items/{itemId}";
}
- URL 구조
GET /items/{itemId}/edit- 상품 수정 폼
POST /items/{itemId}/edit- 상품 수정 처리
- 리다이렉트
redirect:/basic/items/{itemId}@PathVariable의 값을 redirect에도 사용 가능- 뷰 템플릿 대신 상품 상세 화면으로 리다이렉트
- 참고
- HTML Form은 PUT, PATCH 미지원 (GET, POST만 가능)
- 수정 기능 전체 코드
PRG 패턴 (Post/Redirect/Get)
발생 가능한 문제
- 예시: 상품 등록 후 새로고침 시 중복 등록
- 웹 브라우저의 새로고침은 마지막 서버 전송 데이터를 다시 전송
- POST 요청 후 새로고침 시 POST 데이터가 재전송됨
- 같은 상품이 여러 번 등록되는 문제 발생
PRG 패턴
- 동작 과정
- POST로 상품 등록
- 상세 화면으로 리다이렉트 (302 응답)
- 브라우저가 자동으로 GET 요청
- 마지막 요청이 GET이므로 새로고침 시 조회만 반복
PRG 패턴 적용 코드
1
2
3
4
5
6
7
8
/**
* PRG - Post/Redirect/Get
*/
@PostMapping("/add")
public String addItemV5(Item item) {
itemRepository.save(item);
return "redirect:/basic/items/" + item.getId();
}
-
주의사항
1
"redirect:/basic/items/" + item.getId()
- URL에 변수를
+로 더하는 방식은 URL 인코딩이 안되어 위험 RedirectAttributes사용 권장
- URL에 변수를
RedirectAttributes
활용
- 상품 저장 후 상세 화면으로 이동 시 저장 성공 메시지 표시
- 사용자에게 작업 완료 확인 제공
- 일회성 데이터를 쿼리 파라미터로 전달
RedirectAttributes 사용
1
2
3
4
5
6
7
8
9
10
/**
* RedirectAttributes
*/
@PostMapping("/add")
public String addItemV6(Item item, RedirectAttributes redirectAttributes) {
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/basic/items/{itemId}";
}
-
실행 결과
1
http://localhost:8080/basic/items/3?status=true
RedirectAttributes 동작
1
return "redirect:/basic/items/{itemId}";
- 파라미터 처리
{itemId}- PathVariable로 바인딩
- 나머지(
status)- 쿼리 파라미터로 처리
- 자동 처리 기능
- URL 인코딩
- PathVariable 바인딩
- 쿼리 파라미터 생성
뷰 템플릿에 메시지 추가
- 위치
resources/templates/basic/item.html
1 2 3 4 5 6 7 8 9 10
<div class="container"> <div class="py-5 text-center"> <h2>상품 상세</h2> </div> <!-- 추가 --> <h2 th:if="${param.status}" th:text="'저장 완료!'"></h2> <!-- 나머지 내용 --> </div>
- 타임리프 문법
th:if="${param.status}"- 조건이 참이면 실행
${param.status}- 쿼리 파라미터를 편리하게 조회하는 타임리프 기능
- 동작
- 상품 등록 후 이동 시
?status=true→ “저장 완료!” 메시지 표시 - 상품 목록에서 상세 이동 시 쿼리 파라미터 없음 → 메시지 표시 안 됨
- 상품 등록 후 이동 시
연습 문제
-
스프링 MVC로 동적 웹 페이지를 만들 때, 기본 템플릿 엔진 중 HTML 텍스트를 그대로 유지하면서 동적 처리를 가능하게 하는 템플릿 엔진은 무엇인가요?
a. Web, Thymeleaf
- 동적 웹 페이지를 만들려면 템플릿 엔진이 필요함
- Spring Boot Web 의존성은 웹 기능을, Thymeleaf 의존성은 서버사이드 HTML을 동적으로 만들어줌
-
MVC 패턴에서 사용자의 요청을 처음 받아 처리할 컨트롤러를 결정하는 중심적인 역할은 무엇인가요?
a. Controller
- MVC에서 컨트롤러는 클라이언트의 요청을 받아 적절한 서비스나 로직을 호출하고, 그 결과를 모델에 담아 뷰에게 전달하는 중심역할을 함
-
애플리케이션에서 내에서 데이터를 저장소에 넣기 가거나 가져오는 등의 데이터 접근 로직을 추상화하여 담당하는 역할은 무엇인가요?
a. Repository
- 레포지토리는 데이터 저장소(DB 등)에 접근하는 로직을 추상화하여, 데이터 관리와 관련된 일관된 인터페이스를 제공하는 역할을 맡음
-
타임리프(Thymeleaf)의 ‘내츄럴 템플릿(Natural Template)’이라고 부르는 이유는 무엇인가요?
a. 순수 HTML 파일을 브라우저에서 바로 열어도 레이아웃을 확인할 수 있기 때문
- 타임리프의 가장 큰 특징 중 하나는 순수 HTML 구조를 유지하면서 동적인 요소를 추가할 수 있어, 웹 서버 없이도 브라우저에서 레이아웃을 확인할 수 있다는 점임
-
컨트롤러 모델에서 담아 뷰로 전달한 데이터의 속성 값을 타임리프 템플릿에서 사용하려면 어떤 표현식을 쓸까요?
a.
${}${}형태의 변수 표현식은 타임리프에서 모델에 담긴 객체나 데이터의 속성 값에 접근하고 출력할 때 사용되는 기본적인 문법임
-
다중 스레드 환경에서 여러 쓰레드가 동시에 읽고 쓸 때 안전하게 데이터를 수정할 수 있는 환경에서 사용하기 어려운 맵은 무엇인가요?
a. HashMap
HashMap은 여러 스레드가 동시에 읽고 쓰면 데이터 변형이 발생할 수 있음- 안전하게 사용하려면
ConcurrentHashMap같은 자료구조를 써야 함
-
스프링 MVC의
@ModelAttribute는 HTTP 요청 파라미터를 자바 객체로 자동으로 만들면서 동시에 어떤 객체에 자동으로 추가해 줄까요?a. 폼 데이터 받아 객체 속성에 자동 바인딩
@ModelAttribute는 HTTP 요청 파라미터를 분석하여 지정된 객체의 속성에 자동으로 값을 채워주는 기능을 함- 이를 통해 컨트롤러에서 폼 데이터를 객체로 쉽게 다룰 수 있음
-
표준 HTML
<form>태그의method속성에서 직접 지원하여 데이터를 전송할 수 있는 HTTP 메서드는 보통 어떤 것인가요?a. GET, POST
- 표준 HTML 폼 태그는 데이터 전송을 위해 GET 방식과 POST 방식만 기본적으로 지원함
- PUT이나 DELETE는 다른 HTTP 메서드는 직접 사용할 수 없음
-
웹 페이지에서 폼 데이터를 POST로 제출한 후, 브라우저 새로고침 시 동일한 데이터가 다시 제출되어 중복 생성을 유발하는 문제를 방지하기 위한 디자인 패턴은 무엇인가요?
a. PRG 패턴
- PRG 패턴은 POST 요청 처리 후 Redirect 응답으로 리다이렉트하여, 새로고침 시 마지막 GET 요청이 아닌 GET 요청을 반복하게 만들어 중복 제출을 방지하는 방법임
-
스프링 MVC에서 POST 요청 처리 후 리다이렉트할 때, 리다이렉트된 페이지에 성공 메시지 같은 일회성 데이터를 함께 전달하고 싶을 때 사용하는 기능은 무엇인가요?
a. RedirectAttributes
RedirectAttributes는 리다이렉트 시 URL 경로 변수에 값을 추가하거나, 쿼리 파라미터 형태로 데이터를 자동으로 인코딩하여 다음 페이지에 전달할 수 있게 도와줌
요약 정리
- 프로젝트 설정
- Spring Boot, Thymeleaf, Lombok
- Jar 패키징으로 내장 톰캣 사용
- Welcome 페이지로 진입점 제공
- 도메인 개발
- Item
- 상품 엔티티 클래스
- ItemRepository
- 싱글톤 패턴
- HashMap으로 메모리 저장소 구현
- Item
- 타임리프 기본
- Natural Templates
- 순수 HTML 유지
- 주요 문법
th:text,th:value,th:href,th:action@{},${},||th:each,th:if
- Natural Templates
- CRUD 기능 구현
- 상품 목록
@GetMapping으로 조회
- 상품 상세
@PathVariable로 ID 받기
- 상품 등록
@ModelAttribute로 객체 자동 생성
- 상품 수정
@PathVariable+@ModelAttribute
- 상품 목록
- @ModelAttribute 구현 방식
@RequestParam으로 개별 파라미터@ModelAttribute("item")로 객체 + 모델 추가@ModelAttribute로 이름 생략- 완전 생략 (단순 타입 외 객체)
- PRG 패턴 적용
- RedirectAttributes 사용
- PRG 패턴
- 문제
- POST 후 새로고침 시 중복 등록
- 해결
- POST → Redirect → GET
- 마지막 요청을 GET으로
- 구현
redirect:/basic/items/{itemId}
- 문제
- RedirectAttributes
- URL 인코딩 자동
- PathVariable 바인딩
- 쿼리 파라미터 생성
- 일회성 데이터 전달
- 주요 원칙
- 역할 분리
- Controller, Model, View
- 타임리프로 뷰 렌더링
- 스프링 MVC의 편리한 기능 활용
- 역할 분리