스프링 MVC 1편 (7 - 마지막), 웹 페이지 만들기

2025. 12. 21. 14:52Spring 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의 역할

  1. 요청 파라미터(itemName, price, quantity)를 읽어서 Item 객체를 생성한다.
  2. 중요: 생성된 Item 객체를 자동으로 Model에 담아준다. (key는 클래스명 첫 글자를 소문자로 바꾼 item)
  3. 따라서 뷰(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)이나 로그인 같은 기능은 없다. 특히 사용자가 가격에 숫자가 아닌 문자를 입력하면 오류 페이지가 뜰 것이다. 다음 섹션부터는 이러한 문제들을 해결하고 완성도를 높이는 방법들을 학습하게 된다.