
실제 상황을 가정하면, 디자이너는 요구사항에 맞추어 디자인 결과물을 웹 퍼블리셔에게 넘겨준다. 웹 퍼블리셔는 받은 디자인을 기반으로 HTML, CSS를 작성하여 개발자에게 전달한다.
백엔드 개발자는 HTML 화면이 나오기 전까지 시스템을 설계하고 핵심 비즈니스 모델을 개발한다. 또한, 웹 퍼블리셔로부터 HTML을 넘겨받으면 이를 뷰 템플릿으로 변환하여 동적으로 화면을 구성하고, 웹 화면의 흐름을 제어한다.
이와 같은 구조에서 Spring MVC를 활용할 수 있으며, 서버 사이드 렌더링 스타일(SSR)로 개발을 진행할 수 있다. 이 경우 프론트엔드 개발자는 별도로 존재하지 않는다.
만약 웹 프론트엔드 개발자가 별도로 존재한다면, 프론트엔드 개발자가 웹 퍼블리셔의 역할까지 포함하여 담당하는 경우가 많다. 이 경우 HTML을 동적으로 생성하는 역할을 Vue.js나 React 같은 기술로 프론트엔드 개발자가 담당한다. 백엔드 개발자는 HTTP API를 통해 데이터와 기능을 제공하며, View 영역의 처리는 프론트엔드 개발자에게 위임한다.
CSS는 부트스트랩을 활용하며, 이를 포함한 기본 디자인의 HTML이 넘어온다고 가정한다. 이러한 HTML 파일들은 /resources/static에 보관한다. 다만, 실제 서비스에서는 공개할 필요가 없는 HTML 파일은 /resources/static에 두지 않는다.
템플릿 엔진으로는 스프링에서 가장 선호하는 타임리프(Thymeleaf)를 활용한다. 정적 리소스와는 다르게 template 폴더에 HTML 파일을 구성한다.
HTML 태그에 xmlns:th="http://www.thymeleaf.org" 속성을 추가하여 타임리프 엔진을 HTML에 적용한다. 속성을 동적으로 처리하기 위해 th: 접두사를 활용한다. 예를 들어, href 속성을 동적으로 구성하기 위해 th:href="@{/css/bootstrap.min.css}"를 추가하면 기존의 href를 무시하고 th:href가 적용되도록 설정할 수 있다.
이와 같이 HTML 태그의 다양한 속성을 th:로 매핑하여 동적으로 렌더링할 수 있다. 타임리프의 뛰어난 점은 natural template engine이라는 특성에 있다. 순수 HTML 파일을 웹 브라우저에서 열어도 내용을 확인할 수 있으며, 서버를 통해 뷰 템플릿을 거쳐 동적으로 변경된 결과 또한 확인이 가능하다. 반면 JSP는 파일 확장자명부터 HTML이 아니며, 서버를 통해서만 열어야 한다는 점에서 차이가 있다.
package hello.itemservice.web.basic;
import hello.itemservice.domain.item.Item;
import hello.itemservice.domain.item.ItemRepository;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
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;
@GetMapping
public String items(Model model) {
List<Item> items = itemRepository.findAll();
model.addAttribute("items", items);
return "basic/items";
}
@GetMapping("/{itemId}")
public String item(@PathVariable long itemId, Model model) {
Item item = itemRepository.findById(itemId);
model.addAttribute("item", item);
return "basic/item";
}
@GetMapping("/add")
public String addForm() {
return "basic/addForm";
}
// @PostMapping("/add")
public String addItemV1(@RequestParam String itemName,
@RequestParam int price,
@RequestParam Integer quantity,
Model model) {
Item item = new Item(itemName, price, quantity);
itemRepository.save(item);
model.addAttribute("item", item);
return "basic/item";
}
// @PostMapping("/add")
public String addItemV2(@ModelAttribute("item") Item item) {
itemRepository.save(item);
// model.addAttribute("item", item); // 자동 추가. 생략가능
return "basic/item";
}
// @PostMapping("/add")
public String addItemV5(@ModelAttribute Item item) {
itemRepository.save(item);
// model.addAttribute("item", item); // 자동 추가. 생략가능
return "redirect:/basic/items/" + item.getId();
}
@PostMapping("/add")
public String addItemV6(@ModelAttribute Item item, RedirectAttributes redirectAttributes) {
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
// redirectAttributes의 어트리뷰트네임과 바인딩됨
// 바인딩안하면 나머지는 쿼리파라미터로 감
return "redirect:/basic/items/{itemId}";
}
@GetMapping("/{itemId}/edit")
public String editForm(@PathVariable long itemId, Model model) {
Item item = itemRepository.findById(itemId);
model.addAttribute("item", item);
return "basic/editForm";
}
@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId, Item item) {
itemRepository.update(itemId, item);
return "redirect:/basic/items/{itemId}";
}
/**
* 테스트용 데이터 추가
*/
@PostConstruct
public void init() {
itemRepository.save(new Item("itemA", 10000, 10));
itemRepository.save(new Item("itemB", 20000, 20));
}
}
@Controller : 디스패처 서블릿이 컨트롤러로 인식하며, 스프링 빈에 등록한다.@RequestMapping("/basic/items") : 기본 경로를 설정한다.@RequiredArgsConstructor : 생성자를 자동으로 생성한다.ItemRepository는 간단한 메모리 방식의 저장소로, 현재는 실제 데이터베이스와 연결하지 않는다.
items
itemRepository에서 모든 상품을 조회하고, 조회된 데이터를 Model 객체에 addAttribute를 통해 추가한 뒤 템플릿으로 보낸다.item
{itemId})를 사용하여 특정 상품의 ID를 받아, 해당 상품을 조회한 뒤 모델에 추가하고 템플릿으로 보낸다. {itemId}addForm
/add (GET) basic/addForm 템플릿을 바로 띄우는 역할을 한다. addItemV6
/add (POST) addForm 템플릿에서 제출된 폼 데이터가 전송된다. @ModelAttribute를 통해 전달된 쿼리 파라미터 데이터를 Item 객체에 바인딩한다. RedirectAttributes를 주입받아 리다이렉트 대상 컨트롤러로 전달할 데이터를 설정한다. Item 객체를 itemRepository에 저장한다.RedirectAttributes에 itemId와 status=true를 설정한다.?status=true 쿼리 파라미터를 포함하여 요청된다.editForm
/{itemId}/edit (GET) itemRepository에서 해당 상품을 조회해 기본 데이터를 폼에 채운다. basic/editForm 템플릿으로 전달한다.edit
/{itemId}/edit (POST) Item 객체를 수정하는 컨트롤러이다. itemRepository.update를 호출해 수정하고, 수정 완료 후 editForm으로 리다이렉트한다.addItemV5 이전의 비활성화된 컨트롤러들은 return에서 새로 등록된 item을 보여주기 위해 해당 item만 보여지는 상세 템플릿으로 리턴을 했다.
이는 실제 브라우저에서 다음과 같이 작동한다.

상품 저장이 이루어지기 위해 사용자는 상품 등록 폼에서 저장 버튼을 누른다. 저장 버튼이 눌리면 submit 동작에 의해 form에 설정된 action의 URI로 POST 요청이 전송된다. POST 요청을 처리하는 컨트롤러는 비즈니스 로직을 수행한 뒤, 상품 상세 정보를 포함한 템플릿을 브라우저에 반환한다.
이때, 사용자가 브라우저에서 새로고침을 하면 가장 최근의 요청이 다시 실행되는데, 이 요청은 POST 요청이다. 새로고침을 여러 번 하면 동일한 POST 요청이 반복적으로 전송되어 상품이 중복 저장되는 문제가 발생할 수 있다. 이러한 문제를 방지하기 위해 PRG(Post/Redirect/Get) 패턴을 적용한다.

POST 요청을 처리한 이후, 결과로 바로 201이나 200 상태 코드를 반환하는 대신 302 상태 코드와 함께 리다이렉트를 반환한다. 이때, 302 응답을 받은 브라우저는 HTTP 응답을 확인하고, 그 안에 포함된 리다이렉트 URI로 자동으로 GET 요청을 보낸다.
GET 요청의 URI를 구성하기 위해 V6에서는 스프링이 제공하는 RedirectAttributes를 사용하여 itemId와 status를 전달했다. 전달된 itemId는 리다이렉트 URI의 경로 변수로 포함되고, 남은 status는 쿼리 파라미터로 추가된다. 스프링은 이를 바탕으로 최종 URI를 구성하며, 브라우저는 해당 URI로 GET 요청을 보낸다.
이 과정으로 인해 새로고침 시 마지막 요청은 이전의 GET 요청이 되며, POST 요청이 새로고침으로 인해 반복 실행되는 문제를 방지할 수 있다.