2025. 12. 21. 14:52ㆍSpring RoadMap/Spring MVC
백엔드 웹 개발 핵심 기술, 스프링 MVC - 웹 페이지 만들기
이전 섹션까지는 이론과 개별 기능 테스트에 집중했다면, 이번에는 실제 웹 애플리케이션을 만들어볼 차례다. 우리는 상품 관리 서비스를 만들 것이다. 이 과정에서 스프링 MVC와 템플릿 엔진인 타임리프(Thymeleaf)가 어떻게 유기적으로 동작하는지 확실하게 이해할 수 있다.
요구사항 분석
상품을 관리할 수 있는 서비스
상품 도메인 모델
- 상품 ID
- 상품명
- 가격
- 수량
상품 관리 기능
- 상품 목록
- 상품 상세
- 상품 등록
- 상품 수정
서비스 제공 흐름

요구사항이 정리되고 디자이너, 웹 퍼블리셔, 백엔드 개발자가 업무를 나누어 진행한다.
- 디자이너: 요구사항에 맞도록 디자인하고, 디자인 결과물을 웹 퍼블리셔에게 넘겨준다.
- 웹 퍼블리셔: 다자이너에서 받은 디자인을 기반으로 HTML, CSS를 만들어 개발자에게 제공한다.
- 백엔드 개발자: 디자이너, 웹 퍼블리셔를 통해서 HTML 화면이 나오기 전까지 시스템을 설계하고, 핵심 비즈니스 모델을 개발한다. 이후 HTML이 나오면 이 HTML을 뷰 템플릿으로 변환해서 동적으로 화면을 그리고, 또 웹 화면의 흐름을 제어한다.
참고 React, Vue.js 같은 웹 클라이언트 기술을 사용하고, 웹 프론트엔드 개발자가 별도로 있으면, 웹 프론트엔드 개발자가 웹 퍼블리셔 역할까지 포함해서 하는 경우도 있다. 웹 클라이언트 기술을 사용하면, 웹 프론트엔드 개발자가 HTML을 동적으로 만드는 역할과 웹 화면의 흐름을 담당한다. 이 경우 백엔드 개발자는 HTML 뷰 템플릿을 직접 만지는 대신에, HTTP API를 통해 웹 클라이언트가 필요로 하는 데이터와 기능을 제공하면 된다.
1. 도메인과 리포지토리 설계
가장 먼저 데이터의 그릇이 될 도메인 객체와, 데이터를 저장할 저장소가 필요하다.
1-1. 상품 도메인 (Item)
package org.example.springmvcpractice2.doamin.item;
import lombok.Data;
@Data
public class Item {
private Long id;
private String itemName;
private Integer price;
private Integer quantity;
public Item() {}
public Item(String itemName, Integer price, Integer quantity){
this.itemName = itemName;
this.price = price;
this.quantity = quantity;
}
}
- @Data: 롬복(Lombok)을 사용하여 Getter, Setter, toString 등을 자동 생성한다. (핵심 도메인 모델에서는 @Getter, @Setter를 분리해서 사용하는 것이 더 안전하지만, 예제이므로 편리함을 위해 사용했다.)
1-2. 상품 리포지토리 (ItemRepository)
데이터베이스 연동은 복잡하므로, 여기서는 Map을 사용하여 메모리에 저장하는 간단한 저장소를 만든다.
package org.example.springmvcpractice2.doamin.item;
import org.springframework.stereotype.Repository;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Repository
public class ItemRepository{
private static final Map<Long, Item> store = new HashMap<>();
// long = 원시타입, 성능 좋음, null 불가
// Long = 참조타입, null 가능 => id값이 지정되지 않았을 때를 대비해 Item 클래스엔 Long 참조타입을 사용하는 것이다
private static long sequence = 0L;
public Item save(Item item){
item.setId(++sequence);
store.put(item.getId(), item);
return item;
}
public Item findById(Long id){
return store.get(id);
}
public List<Item> findAll(){
return new ArrayList<>(store.values());
}
public void update(Long itemId, Item updateParam){
Item findItem = findById(itemId);
findItem.setItemName(updateParam.getItemName());
findItem.setPrice(updateParam.getPrice());
findItem.setQuantity(updateParam.getQuantity());
}
public void clearStore(){
store.clear();
}
}
- @Repository: 컴포넌트 스캔의 대상이 되어 스프링 빈으로 등록된다.
- 동시성 문제: 실무에서는 여러 쓰레드가 동시에 접근하므로 ConcurrentHashMap, AtomicLong 등을 사용해야 하지만, 예제 단순화를 위해 기본 컬렉션을 사용했다.
테스트 코드
package org.example.springmvcpractice2;
import org.example.springmvcpractice2.doamin.item.Item;
import org.example.springmvcpractice2.doamin.item.ItemRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
public class ItemRepositoryTest {
ItemRepository itemRepository = new ItemRepository();
@AfterEach
void afterEach(){
itemRepository.clearStore();
}
@Test
void save(){
// given
Item item = new Item("itemA", 10000, 10);
// when
Item savedItem = itemRepository.save(item);
// then
Item findItem = itemRepository.findById(item.getId());
assertThat(findItem).isEqualTo(savedItem);
}
@Test
void findAll(){
// given
Item item1 = new Item("itemA", 10000, 10);
Item item2 = new Item("itemB", 10000, 10);
itemRepository.save(item1);
itemRepository.save(item2);
// when
List<Item> result = itemRepository.findAll();
// then
assertThat(result.size()).isEqualTo(2);
assertThat(result).contains(item1, item2);
}
@Test
void updateItem(){
// given
Item item = new Item("itemA", 10000, 10);
Item savedItem = itemRepository.save(item);
Long itemId = savedItem.getId();
// when
Item updateParam = new Item("itemB", 10000, 10);
itemRepository.update(itemId, updateParam);
Item findItem = itemRepository.findById(itemId);
// then
assertThat(findItem.getItemName()).isEqualTo(updateParam.getItemName());
assertThat(findItem.getPrice()).isEqualTo(updateParam.getPrice());
assertThat(findItem.getQuantity()).isEqualTo(updateParam.getQuantity());
}
}
테스트 결과 정상작동한다.
참고 : 타임리프 사용
상품 서비스 HTML
부트스트랩
부트스트랩(Bootstrap)은 웹사이트를 쉽게 만들 수 있게 도와주는 HTML, CSS, JS 프레임워크이다. 하나의 CSS로 휴대폰, 태블릿, 데스크탑까지 다양한 기기에서 작동한다. 다양한 기능을 제공하여 사용자가 쉽게 웹사이트를 제작, 유지, 보수할 수 있도록 도와준다. - 출처: 위키백과
정적 리소스가 공개되는 /resources/static 폴더에 HTML을 넣어두면, 실제 서비스에서도 공개된다. 서비스를 운영한다면 지금처럼 공개할 필요없는 HTML을 두는 것은 주의하자.
상품 목록 - 타임리프
BasicItemController
package hello.itemservice.web.item.basic;
import hello.itemservice.domain.item.Item;
import hello.itemservice.domain.item.ItemRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import javax.annotation.PostConstruct;
import java.util.List;
@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이 붙은 멤버변수만 사용해서 생성자를 자동으로 만들어준다.
public BasicItemController(ItemRepository itemRepository) {
this.itemRepository = itemRepository;
}
- 이렇게 생성자가 딱 1개만 있으면 스프링이 해당 생성자에 @Autowired로 의존관계를 주입해준다.
- 따라서 final 키워드를 빼면 ItemRepository 의존관계 주입이 안되므로 빼면 안된다.
테스트용 데이터 추가
- 테스트용 데이터가 없으면 회원 목록 기능이 정상 동작하는지 확인하기 어렵다.
- @PostConstruct : 해당 빈의 의존관계가 모두 주입되고 나면 초기화 용도로 호출된다.
- 여기서는 간단히 테스트용 테이터를 넣기 위해서 사용했다.
resources/templates/basic/items.html
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<link href="../css/bootstrap.min.css"
th:href="@{/css/bootstrap.min.css}" rel="stylesheet">
</head>
<body>
<div class="container" style="max-width: 600px">
<div class="py-5 text-center">
<h2>상품 목록</h2>
</div>
<div class="row">
<div class="col">
<button class="btn btn-primary float-end"
onclick="location.href='addForm.html'"
th:onclick="|location.href='@{/basic/items/add}'|"
type="button">상품 등록</button>
</div>
</div>
<hr class="my-4">
<div>
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>상품명</th>
<th>가격</th>
<th>수량</th>
</tr>
</thead>
<tbody>
<tr th:each="item : ${items}">
<td><a href="item.html" th:href="@{/basic/items/{itemId}
(itemId=${item.id})}" th:text="${item.id}">회원id</a></td>
<td><a href="item.html" th:href="@{|/basic/items/${item.id}|}"
th:text="${item.itemName}">상품명</a></td>
<td th:text="${item.price}">10000</td>
<td th:text="${item.quantity}">10</td>
</tr>
</tbody>
</table>
</div>
</div> <!-- /container -->
</body>
</html>
타임리프 간단히 알아보기
타임리프 사용 선언
속성 변경 - th:href
th:href="@{/css/bootstrap.min.css}"
- href="value1" 을 th:href="value2" 의 값으로 변경한다.
- 타임리프 뷰 템플릿을 거치게 되면 원래 값을 th:xxx 값으로 변경한다. 만약 값이 없다면 새로 생성한다.
- HTML을 그대로 볼 때는 href 속성이 사용되고, 뷰 템플릿을 거치면 th:href 의 값이 href 로 대체되면서 동적으로 변경할 수 있다.
- 대부분의 HTML 속성을 th:xxx 로 변경할 수 있다.
타임리프 핵심
- 핵심은 th:xxx 가 붙은 부분은 서버사이드에서 렌더링 되고, 기존 것을 대체한다. th:xxx 이 없으면 기존 html의 xxx 속성이 그대로 사용된다.
- HTML을 파일로 직접 열었을 때, th:xxx 가 있어도 웹 브라우저는 th: 속성을 알지 못하므로 무시한다.
- 따라서 HTML을 파일 보기를 유지하면서 템플릿 기능도 할 수 있다.
URL 링크 표현식 - @{…},
th:href="@{/css/bootstrap.min.css}"
- @{...} : 타임리프는 URL 링크를 사용하는 경우 @{...} 를 사용한다. 이것을 URL 링크 표현식이라 한다.
- URL 링크 표현식을 사용하면 서블릿 컨텍스트를 자동으로 포함한다.
상품 등록 폼으로 이동
속성 변경 - th:onclick
- onclick="location.href='addForm.html'"
- th:onclick="|location.href='@{/basic/items/add}'|"
| **리터럴 대체 - | … | ** |
|...| :이렇게 사용한다.
- 타임리프에서 문자와 표현식 등은 분리되어 있기 때문에 더해서 사용해야 한다. <span th:text="'Welcome to our application, ' + ${user.name} + '!'">
- 다음과 같이 리터럴 대체 문법을 사용하면, 더하기 없이 편리하게 사용할 수 있다. <span th:text="|Welcome to our application, ${user.name}!|">
- 결과를 다음과 같이 만들어야 하는데 location.href='/basic/items/add' 그냥 사용하면 문자와 표현식을 각각 따로 더해서 사용해야 하므로 다음과 같이 복잡해진다. th:onclick="'location.href=' + '\'' + @{/basic/items/add} + '\''" 리터럴 대체 문법을 사용하면 다음과 같이 편리하게 사용할 수 있다. th:onclick="|location.href='@{/basic/items/add}'|"
반복 출력 - th:each <tr th:each="item : ${items}">
- 반복은 th:each 를 사용한다. 이렇게 하면 모델에 포함된 items 컬렉션 데이터가 item 변수에 하나씩 포함되고, 반복문 안에서 item 변수를 사용할 수 있다.
- 컬렉션의 수 만큼 <tr>..</tr> 이 하위 테그를 포함해서 생성된다.
변수 표현식 - ${…}
<td th:text="${item.price}">10000</td>
- 모델에 포함된 값이나, 타임리프 변수로 선언한 값을 조회할 수 있다.
- 프로퍼티 접근법을 사용한다. ( item.getPrice() )
내용 변경 - th:text
<td th:text="${item.price}">10000</td>
- 내용의 값을 th:text 의 값으로 변경한다. 여기서는 10000을 ${item.price} 의 값으로 변경한다.
URL 링크 표현식2 - @{…},
th:href="@{/basic/items/{itemId}(itemId=${item.id})}"
- URL 링크 표현식을 사용하면 경로를 템플릿처럼 편리하게 사용할 수 있다.
- 경로 변수( {itemId} ) 뿐만 아니라 쿼리 파라미터도 생성한다.
- 예) th:href="@{/basic/items/{itemId}(itemId=${item.id}, query='test')}" 생성 링크: http://localhost:8080/basic/items/1?query=test
URL 링크 간단히
th:href="@{|/basic/items/${item.id}|}"
- 리터럴 대체 문법을 활용해서 간단히 사용할 수도 있다.
참고 타임리프는 순수 HTML을 파일을 웹 브라우저에서 열어도 내용을 확인할 수 있고, 서버를 통해 뷰 템플릿을 거치면 동적으로 변경된 결과를 확인할 수 있다. JSP를 생각해보면, JSP 파일은 웹 브라우저에서 그냥 열면 JSP 소스코드와 HTML이 뒤죽박죽 되어서 정상적인 확인이 불가능하다. 오직 서버를 통해서 JSP를 열어야 한다. 이렇게 순수 HTML을 그대로 유지하면서 뷰 템플릿도 사용할 수 있는 타임리프의 특징을 네츄럴 템플릿 (natural templates)이라 한다.
2. 컨트롤러와 뷰 템플릿 (목록 조회)
이제 본격적으로 컨트롤러를 만들고 화면을 띄워보자.
2-1. BasicItemController 생성
package org.example.springmvcpractice2.web.item.basic;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import org.example.springmvcpractice2.doamin.item.Item;
import org.example.springmvcpractice2.doamin.item.ItemRepository;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import java.util.List;
@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이 붙은 멤버변수의 생성자를 자동으로 만들어준다. 생성자가 하나면 스프링이 자동으로 의존관계(@Autowired)를 주입해준다.
- Model: 컨트롤러에서 조회한 데이터를 뷰로 넘길 때 사용하는 바구니 역할을 한다.
2-2. 타임리프 (items.html)
/resources/templates/basic/items.html 경로에 뷰 파일을 만든다. 타임리프의 핵심 문법을 살펴보자.
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<link href="../css/bootstrap.min.css"
th:href="@{/css/bootstrap.min.css}" rel="stylesheet">
</head>
<body>
<div class="container" style="max-width: 600px">
<div class="py-5 text-center">
<h2>상품 목록</h2>
</div>
<div class="row">
<div class="col">
<button class="btn btn-primary float-end"
onclick="location.href='addForm.html'"
th:onclick="|location.href='@{/basic/items/add}'|"
type="button">상품 등록</button>
</div>
</div>
<hr class="my-4">
<div>
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>상품명</th>
<th>가격</th>
<th>수량</th>
</tr>
</thead>
<tbody>
<tr th:each="item : ${items}">
<td><a href="item.html" th:href="@{/basic/items/{itemId}
(itemId=${item.id})}" th:text="${item.id}">회원id</a></td>
<td><a href="item.html" th:href="@{|/basic/items/${item.id}|}"
th:text="${item.itemName}">상품명</a></td>
<td th:text="${item.price}">10000</td>
<td th:text="${item.quantity}">10</td>
</tr>
</tbody>
</table>
</div>
</div> <!-- /container -->
</body>
</html>
- th:href: 뷰 템플릿을 거치면 기존 href 값을 대체한다.
- @{...}: 타임리프의 URL 링크 표현식이다. (itemId=${item.id}) 부분은 경로 변수({itemId})에 값을 채워준다.
- th:text: 태그 안의 텍스트를 서버에서 받은 값으로 변경한다.
- th:each: 반복문이다. 자바의 for (Item item : items)와 유사하다.
3. 상품 상세 조회
상품 목록에서 링크를 클릭했을 때 보여질 상세 화면이다.
Controller
@GetMapping("/{itemId}")
public String item(@PathVariable long itemId, Model model) {
Item item = itemRepository.findById(itemId);
model.addAttribute("item", item);
return "basic/item";
}
@PathVariable로 URL 경로의 ID를 받아서 상품을 조회하고 모델에 담는다.
View (item.html)
<div>
<label for="itemName">상품명</label>
<input type="text" id="itemName" name="itemName" class="form-control"
value="상품A" th:value="${item.itemName}" readonly>
</div>
- th:value: HTML value 속성을 대체하여 모델에 있는 값을 화면에 출력한다.
4. 상품 등록 (중요!)
상품 등록은 폼을 보여주는 GET 요청과, 실제 데이터를 저장하는 POST 요청 두 가지로 나뉜다.
4-1. 등록 폼 보여주기 (GET)
@GetMapping("/add")
public String addForm() {
return "basic/addForm";
}
단순히 등록용 뷰만 반환하면 된다.
View (addForm.html)
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<link href="../css/bootstrap.min.css"
th:href="@{/css/bootstrap.min.css}" rel="stylesheet">
<style>
.container {
max-width: 560px;
}
</style>
</head>
<body>
<div class="container">
<div class="py-5 text-center">
<h2>상품 등록 폼</h2>
</div>
<h4 class="mb-3">상품 입력</h4>
<form action="item.html" th:action method="post">
<div>
<label for="itemName">상품명</label>
<input type="text" id="itemName" name="itemName" class="formcontrol"
placeholder="이름을 입력하세요">
</div>
<div>
<label for="price">가격</label>
<input type="text" id="price" name="price" class="form-control"
placeholder="가격을 입력하세요">
</div>
<div>
<label for="quantity">수량</label>
<input type="text" id="quantity" name="quantity" class="formcontrol"
placeholder="수량을 입력하세요">
</div>
<hr class="my-4">
<div class="row">
<div class="col">
<button class="w-100 btn btn-primary btn-lg" type="submit">상품
등록</button>
</div>
<div class="col">
<button class="w-100 btn btn-secondary btn-lg"
onclick="location.href='items.html'"
th:onclick="|location.href='@{/basic/items}'|"
type="button">취소</button>
</div>
</div>
</form>
</div> <!-- /container -->
</body>
</html>
- th:action: 값이 비어있으면 현재 URL(/basic/items/add)로 요청을 보낸다. 단, 메서드는 post가 된다.
4-2. 등록 처리 (POST) - @ModelAttribute의 마법
이전 섹션에서 배운 @ModelAttribute가 여기서 빛을 발한다. 네 단계에 거쳐 설명해보겠다
addItemV1 - BasicItemController에 추가
@PostMapping("/add")
public String addItemV1(@RequestParam String itemName,
@RequestParam int price,
@RequestParam Integer quantity,
Model model) {
Item item = new Item();
item.setItemName(itemName);
item.setPrice(price);
item.setQuantity(quantity);
itemRepository.save(item);
model.addAttribute("item", item);
return "basic/item";
}
- 먼저 @RequestParam String itemName : itemName 요청 파라미터 데이터를 해당 변수에 받는다.
- Item 객체를 생성하고 itemRepository 를 통해서 저장한다.
- 저장된 item 을 모델에 담아서 뷰에 전달한다.
중요: 여기서는 상품 상세에서 사용한 item.html 뷰 템플릿을 그대로 재활용한다.
상품 등록 처리 - @ModelAttribute
@RequestParam 으로 변수를 하나하나 받아서 Item 을 생성하는 과정은 불편했다. 이번에는 @ModelAttribute 를 사용해서 한번에 처리해보자.
addItemV2 - 상품 등록 처리 - ModelAttribute
/**
* @ModelAttribute("item") Item item
* model.addAttribute("item", item); 자동 추가
*/
@PostMapping("/add")
public String addItemV2(@ModelAttribute("item") Item item, Model model) {
itemRepository.save(item);
//model.addAttribute("item", item); //자동 추가, 생략 가능
return "basic/item";
}
@ModelAttribute 는 Item 객체를 생성하고, 요청 파라미터의 값을 프로퍼티 접근법(setXxx)으로 입력해준다.
@ModelAttribute - Model 추가
@ModelAttribute 는 중요한 한가지 기능이 더 있는데, 바로 모델(Model)에 @ModelAttribute 로 지정한 객체를 자동으로 넣어준다. 지금 코드를 보면 model.addAttribute("item", item) 가 주석처리되어 있어도 잘 동작하는 것을 확인할 수 있다.
모델에 데이터를 담을 때는 이름이 필요하다. 이름은 @ModelAttribute 에 지정한 name(value) 속성을 사용한다. 만약 다음과 같이 @ModelAttribute 의 이름을 다르게 지정하면 다른 이름으로 모델에 포함된다.
addItemV3 - 상품 등록 처리 - ModelAttribute 이름 생략
/**
* @ModelAttribute name 생략 가능
* model.addAttribute(item); 자동 추가, 생략 가능
* 생략시 model에 저장되는 name은 클래스명 첫글자만 소문자로 등록 Item -> item
*/
@PostMapping("/add")
public String addItemV3(@ModelAttribute Item item) {
itemRepository.save(item);
return "basic/item";
}
@ModelAttribute 의 이름을 생략할 수 있다.
주의
@ModelAttribute 의 이름을 생략하면 모델에 저장될 때 클래스명을 사용한다. 이때 클래스의 첫글자만 소문자로 변경해서 등록한다.
addItemV4 - 상품 등록 처리 - ModelAttribute 전체 생략
/**
* @ModelAttribute 자체 생략 가능
* model.addAttribute(item) 자동 추가
*/
@PostMapping("/add")
public String addItemV4(Item item) {
itemRepository.save(item);
return "basic/item";
}
@ModelAttribute 자체도 생략가능하다. 대상 객체는 모델에 자동 등록된다. 나머지 사항은 기존과 동일하다.
@ModelAttribute의 역할
- 요청 파라미터(itemName, price, quantity)를 읽어서 Item 객체를 생성한다.
- 중요: 생성된 Item 객체를 자동으로 Model에 담아준다. (key는 클래스명 첫 글자를 소문자로 바꾼 item)
- 따라서 뷰(basic/item)에서 즉시 ${item}으로 데이터를 꺼내 볼 수 있다.
만약 @ModelAttribute("myData")라고 이름을 지정하면, 모델 키가 myData가 된다.
4-3. 수정 처리 (POST)
상품 수정 폼 뷰
/resources/templates/basic/editForm.html
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<link href="../css/bootstrap.min.css"
th:href="@{/css/bootstrap.min.css}" rel="stylesheet">
<style>
.container {
max-width: 560px;
}
</style>
</head>
<body>
<div class="container">
<div class="py-5 text-center">
<h2>상품 수정 폼</h2>
</div>
<form action="item.html" th:action method="post">
<div>
<label for="id">상품 ID</label>
<input type="text" id="id" name="id" class="form-control" value="1"
th:value="${item.id}" readonly>
</div>
<div>
<label for="itemName">상품명</label>
<input type="text" id="itemName" name="itemName" class="formcontrol"
value="상품A" th:value="${item.itemName}">
</div>
<div>
<label for="price">가격</label>
<input type="text" id="price" name="price" class="form-control"
th:value="${item.price}">
</div>
<div>
<label for="quantity">수량</label>
<input type="text" id="quantity" name="quantity" class="formcontrol"
th:value="${item.quantity}">
</div>
<hr class="my-4">
<div class="row">
<div class="col">
<button class="w-100 btn btn-primary btn-lg" type="submit">저장
</button>
</div>
<div class="col">
<button class="w-100 btn btn-secondary btn-lg"
onclick="location.href='item.html'"
th:onclick="|location.href='@{/basic/items/{itemId}(itemId=${item.id})}'|"
type="button">취소</button>
</div>
</div>
</form>
</div> <!-- /container -->
</body>
</html>
상품 수정은 마지막에 뷰 템플릿을 호출하는 대신에 상품 상세 화면으로 이동하도록 리다이렉트를 호출한다.
- 스프링은 redirect:/... 으로 편리하게 리다이렉트를 지원한다.
- redirect:/basic/items/{itemId}"
- 컨트롤러에 매핑된 @PathVariable 의 값은 redirect 에도 사용 할 수 있다.
- redirect:/basic/items/{itemId} : {itemId} 는 @PathVariable Long itemId 의 값을 그대로 사용한다.
참고 HTML Form 전송은 PUT, PATCH를 지원하지 않는다. GET, POST만 사용할 수 있다. PUT, PATCH는 HTTP API 전송시에 사용 스프링에서 HTTP POST로 Form 요청할 때 히든 필드를 통해서 PUT, PATCH 매핑을 사용하는 방법이 있지만, HTTP 요청상 POST 요청이다.
5. PRG 패턴 (Post/Redirect/Get)
위의 등록 처리는 한 가지 심각한 문제가 있다. 상품 등록을 완료하고 (POST /add), 웹 브라우저의 '새로고침' 버튼을 누르면 어떻게 될까? 마지막에 전송한 요청이 다시 전송되므로, 똑같은 상품이 계속 중복 등록되는 문제가 발생한다.


이를 해결하기 위해 PRG 패턴을 사용한다. POST 요청 처리가 끝나면 뷰 템플릿을 바로 보여주는 것이 아니라, 상품 상세 화면 등으로 리다이렉트(Redirect) 시키는 것이다.

@PostMapping("/add")
public String addItemV2(@ModelAttribute Item item) {
itemRepository.save(item);
return "redirect:/basic/items/" + item.getId();
}
- redirect:/...: 스프링은 이 문자열을 보고 HTTP 응답 코드를 302(리다이렉트)로 설정하고 Location 헤더에 URL을 담아 보낸다.
- 이제 새로고침을 해도 마지막 요청은 GET 조회 요청이므로 중복 등록 문제가 해결된다.
RedirectAttributes 리다이렉트 시 사용자에게 "저장되었습니다" 같은 메시지를 보여주고 싶거나, URL에 변수를 더 안전하게 넣고 싶다면 RedirectAttributes를 사용한다.
@PostMapping("/add")
public String addItemV3(@ModelAttribute Item item, RedirectAttributes redirectAttributes) {
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true); // 쿼리 파라미터로 붙음 (?status=true)
return "redirect:/basic/items/{itemId}";
}
- URL 인코딩 문제도 해결해주며, pathVariable에 없는 값은 쿼리 파라미터로 붙여준다.
6. 타임리프 핵심 정리
이번 섹션에서 사용한 타임리프의 주요 기능들이다.
- th:href="@{...}": URL 링크 생성. 컨텍스트 경로를 자동으로 포함해준다.
- th:text="${...}": 텍스트 출력.
- th:value="${...}": Input 태그의 값 출력.
- th:action: Form 제출 URL 지정. (비우면 현재 URL)
- th:each="변수 : ${목록}": 반복 출력.
- |...| (리터럴 대체): th:text="|hello ${data}|" 처럼 문자와 변수를 + 기호 없이 편하게 합칠 수 있다.
전체 코드 확인
Item
package org.example.springmvcpractice2.doamin.item;
import lombok.Data;
@Data
public class Item {
private Long id;
private String itemName;
private Integer price;
private Integer quantity;
public Item() {}
public Item(String itemName, Integer price, Integer quantity){
this.itemName = itemName;
this.price = price;
this.quantity = quantity;
}
}
ItemRepository
package org.example.springmvcpractice2.doamin.item;
import org.springframework.stereotype.Repository;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Repository
public class ItemRepository{
private static final Map<Long, Item> store = new HashMap<>();
// long = 원시타입, 성능 좋음, null 불가
// Long = 참조타입, null 가능 => id값이 지정되지 않았을 때를 대비해 Item 클래스엔 Long 참조타입을 사용하는 것이다
private static long sequence = 0L;
public Item save(Item item){
item.setId(++sequence);
store.put(item.getId(), item);
return item;
}
public Item findById(Long id){
return store.get(id);
}
public List<Item> findAll(){
return new ArrayList<>(store.values());
}
public void update(Long itemId, Item updateParam){
Item findItem = findById(itemId);
findItem.setItemName(updateParam.getItemName());
findItem.setPrice(updateParam.getPrice());
findItem.setQuantity(updateParam.getQuantity());
}
public void clearStore(){
store.clear();
}
}
BasicItemController
package org.example.springmvcpractice2.web.item.basic;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import org.example.springmvcpractice2.doamin.item.Item;
import org.example.springmvcpractice2.doamin.item.ItemRepository;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import java.util.List;
@Controller
@RequestMapping("/basic/items")
@RequiredArgsConstructor
public class BasicItemController {
private final ItemRepository itemRepository;
// 1. 상품 목록
@GetMapping
public String items(Model model){
List<Item> items = itemRepository.findAll();
model.addAttribute("items", items);
return "basic/items";
}
// 2. 상품 상세
// PathVariable 로 넘어온 상품ID로 상품을 조회하고, 모델에 담아둔다. 그리고 뷰 템플릿을 호출한다.
@GetMapping("/{itemId}")
public String item(@PathVariable Long itemId, Model model){
Item item = itemRepository.findById(itemId);
model.addAttribute("item", item);
return "basic/item";
}
// 3. 상품 등록 폼
@GetMapping("/add")
public String addForm(){
return "basic/addForm";
}
// 4. 상품 등록 처리
@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}";
}
// 5. 상품 수정 폼
@GetMapping("/{itemId}/edit")
public String editForm(@PathVariable Long itemId, Model model) {
Item item = itemRepository.findById(itemId);
model.addAttribute("item", item);
return "basic/editForm";
}
// 5. 상품 수정 처리
@PostMapping("/{itemId}/edit")
public String editItem(@PathVariable Long itemId, @ModelAttribute Item item){
itemRepository.update(itemId, item);
return "redirect:/basic/items/{itemId}";
}
// 테스트용 데이터 추가
@PostConstruct
public void init(){
itemRepository.save(new Item("testA", 10000, 10));
itemRepository.save(new Item("testB", 20000, 20));
}
}
Items.html
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<link href="../css/bootstrap.min.css"
th:href="@{/css/bootstrap.min.css}" rel="stylesheet">
<style>
.container { max-width: 600px; }
</style>
</head>
<body>
<div class="container">
<div class="py-5 text-center">
<h2>상품 목록</h2>
</div>
<div class="row">
<div class="col">
<button class="btn btn-primary float-end"
onclick="location.href='addForm.html'"
th:onclick="|location.href='@{/basic/items/add}'|"
type="button">상품 등록</button>
</div>
</div>
<hr class="my-4">
<div>
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>상품명</th>
<th>가격</th>
<th>수량</th>
</tr>
</thead>
<tbody>
<tr th:each="item : ${items}">
<td><a href="item.html" th:href="@{/basic/items/{itemId}(itemId=${item.id})}" th:text="${item.id}">회원id</a></td>
<td><a href="item.html" th:href="@{/basic/items/{itemId}(itemId=${item.id})}" th:text="${item.itemName}">상품명</a></td>
<td th:text="${item.price}">10000</td>
<td th:text="${item.quantity}">10</td>
</tr>
</tbody>
</table>
</div>
</div>
</body>
</html>
Item.html
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<link href="../css/bootstrap.min.css"
th:href="@{/css/bootstrap.min.css}" rel="stylesheet">
<style>
.container {
max-width: 560px;
}
</style>
</head>
<body>
<div class="container">
<div class="py-5 text-center">
<h2>상품 상세</h2>
</div>
<div>
<label for="itemId">상품 ID</label>
<input type="text" id="itemId" name="itemId" class="form-control"
value="1" th:value="${item.id}" readonly>
</div>
<div>
<label for="itemName">상품명</label>
<input type="text" id="itemName" name="itemName" class="form-control"
value="상품A" th:value="${item.itemName}" readonly>
</div>
<div>
<label for="price">가격</label>
<input type="text" id="price" name="price" class="form-control"
value="10000" th:value="${item.price}" readonly>
</div>
<div>
<label for="quantity">수량</label>
<input type="text" id="quantity" name="quantity" class="form-control"
value="10" th:value="${item.quantity}" readonly>
</div>
<hr class="my-4">
<div class="row">
<div class="col">
<button class="w-100 btn btn-primary btn-lg"
onclick="location.href='editForm.html'"
th:onclick="|location.href='@{/basic/items/{itemId}/edit(itemId=${item.id})}'|"
type="button">상품 수정</button>
</div>
<div class="col">
<button class="w-100 btn btn-secondary btn-lg"
onclick="location.href='items.html'"
th:onclick="|location.href='@{/basic/items}'|"
type="button">목록으로</button>
</div>
</div>
</div> </body>
</html>
addForm.html
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<link href="../css/bootstrap.min.css"
th:href="@{/css/bootstrap.min.css}" rel="stylesheet">
<style>
.container {
max-width: 560px;
}
</style>
</head>
<body>
<div class="container">
<div class="py-5 text-center">
<h2>상품 등록 폼</h2>
</div>
<h4 class="mb-3">상품 입력</h4>
<form action="items.html" th:action method="post">
<div>
<label for="itemName">상품명</label>
<input type="text" id="itemName" name="itemName" class="form-control"
placeholder="이름을 입력하세요">
</div>
<div>
<label for="price">가격</label>
<input type="text" id="price" name="price" class="form-control"
placeholder="가격을 입력하세요">
</div>
<div>
<label for="quantity">수량</label>
<input type="text" id="quantity" name="quantity" class="form-control"
placeholder="수량을 입력하세요">
</div>
<hr class="my-4">
<div class="row">
<div class="col">
<button class="w-100 btn btn-primary btn-lg" type="submit">상품 등록</button>
</div>
<div class="col">
<button class="w-100 btn btn-secondary btn-lg"
onclick="location.href='items.html'"
th:onclick="|location.href='@{/basic/items}'|"
type="button">취소</button>
</div>
</div>
</form>
</div> </body>
</html>
editForm.html
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<link href="../css/bootstrap.min.css"
th:href="@{/css/bootstrap.min.css}" rel="stylesheet">
<style>
.container {
max-width: 560px;
}
</style>
</head>
<body>
<div class="container">
<div class="py-5 text-center">
<h2>상품 수정 폼</h2>
</div>
<form action="item.html" th:action method="post">
<div>
<label for="id">상품 ID</label>
<input type="text" id="id" name="id" class="form-control" value="1"
th:value="${item.id}" readonly>
</div>
<div>
<label for="itemName">상품명</label>
<input type="text" id="itemName" name="itemName" class="form-control"
value="상품A" th:value="${item.itemName}">
</div>
<div>
<label for="price">가격</label>
<input type="text" id="price" name="price" class="form-control"
value="10000" th:value="${item.price}">
</div>
<div>
<label for="quantity">수량</label>
<input type="text" id="quantity" name="quantity" class="form-control"
value="10" th:value="${item.quantity}">
</div>
<hr class="my-4">
<div class="row">
<div class="col">
<button class="w-100 btn btn-primary btn-lg" type="submit">저장</button>
</div>
<div class="col">
<button class="w-100 btn btn-secondary btn-lg"
onclick="location.href='item.html'"
th:onclick="|location.href='@{/basic/items/{itemId}(itemId=${item.id})}'|"
type="button">취소</button>
</div>
</div>
</form>
</div> </body>
</html>
정리
이번 섹션에서는 스프링 MVC의 기본 기능들을 종합하여 상품 관리 서비스라는 하나의 완성된 웹 애플리케이션을 만들었다.
- @Controller와 @RequestMapping으로 요청을 매핑했다.
- @ModelAttribute로 폼 데이터를 객체로 한 번에 받고, 모델에 자동 등록까지 처리했다.
- PRG 패턴을 적용하여 등록 후 새로고침 시 발생하는 중복 등록 문제를 해결했다.
- RedirectAttributes를 통해 리다이렉트 시 데이터를 안전하게 전달하는 법을 익혔다.
- 무엇보다 타임리프를 사용하여 서버의 데이터를 HTML에 동적으로 렌더링하는 과정을 경험했다.
이제 기본적인 웹 페이지를 만들 수 있게 되었다. 하지만 아직 입력 값에 대한 검증(Validation)이나 로그인 같은 기능은 없다. 특히 사용자가 가격에 숫자가 아닌 문자를 입력하면 오류 페이지가 뜰 것이다. 다음 섹션부터는 이러한 문제들을 해결하고 완성도를 높이는 방법들을 학습하게 된다.
'Spring RoadMap > Spring MVC' 카테고리의 다른 글
| 스프링 MVC 2편 (4), 검증 1 - Validation (0) | 2025.12.21 |
|---|---|
| 스프링 MVC 2편 (1, 2, 3), Thymeleaf (0) | 2025.12.21 |
| 스프링 MVC 1편 (6), 스프링 MVC 기본 기능 (1) | 2025.12.21 |
| 스프링 MVC 1편 (5), 스프링 MVC 구조 이해 (0) | 2025.12.20 |
| 스프링 MVC 1편 (4), MVC 프레임워크 만들기 (0) | 2025.12.20 |