본문 바로가기
Spring MVC

[MVC] 스프링 MVC - 웹 페이지 만들기(3)

by 개발 Blog 2025. 5. 31.

공부 내용을 정리하고 앞으로의 학습에 이해를 돕기 위해 작성합니다.

 

상품 수정

1. 상품 수정 폼 컨트롤러

상품 수정 요청을 처리하기 위한 폼을 보여주는 컨트롤러이다.
사용자가 수정하고자 하는 상품을 클릭했을 때, 해당 상품의 상세 정보를 보여주는 수정 폼이 필요하다.
이때 itemId를 URL 경로 변수로 받아서 itemRepository에서 해당 ID에 해당하는 상품을 조회하고, 조회된 상품을 Model 객체에 담아 뷰로 전달한다.
뷰는 editForm.html 템플릿 파일이며, 폼에는 기존 상품의 정보가 기본값으로 채워진다.

@GetMapping("/{itemId}/edit")
public String editForm(@PathVariable Long itemId, Model model) {
    Item item = itemRepository.findById(itemId);
    model.addAttribute("item", item);
    return "basic/editForm";
}

 

2. 상품 수정 폼 화면

수정 폼 화면은 상품 등록 폼과 유사하게 생겼다.
HTML의 <form> 태그를 사용하며, 수정 대상 상품의 정보(상품명, 가격, 수량)를 입력할 수 있는 입력 필드가 존재한다.

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
    <link th:href="@{/css/bootstrap.min.css}" 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 th:action="@{/basic/items/{itemId}/edit(itemId=${item.id})}" method="post">
        <div>
            <label for="id">상품 ID</label>
            <input type="text" id="id" name="id" class="form-control" th:value="${item.id}" readonly>
        </div>
        <div>
            <label for="itemName">상품명</label>
            <input type="text" id="itemName" name="itemName" class="form-control" 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="form-control" 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"
                        th:onclick="|location.href='@{/basic/items/{itemId}/edit(itemId=${item.id})}'|"
                        type="button">취소</button>
            </div>
        </div>
    </form>
</div> <!-- /container -->
</body>
</html>
  • 상품의 id는 수정되지 않아야 하므로 읽기 전용(readonly) 처리된다.
  • 입력 필드에는 타임리프의 th:value를 사용하여 기존 상품 정보를 미리 채워 넣는다.
  • 저장 버튼을 누르면 POST 방식으로 서버에 수정 요청을 보낸다.
  • 취소 버튼을 누르면 해당 상품의 상세 페이지로 이동하게 된다. 타임리프의 th:onclick을 이용해 동적으로 URL을 생성한다.

3. 상품 수정 처리 로직

상품 수정 폼에서 제출된 데이터는 POST 방식으로 서버에 전달되며, 이를 처리하는 컨트롤러가 존재한다.
서버는 전달된 데이터로 Item 객체를 만들어내고, itemId 경로 변수를 통해 어떤 상품을 수정할지 결정한다.
그 후, 기존 상품 정보를 새로 입력받은 데이터로 갱신한다.

수정이 완료되면 사용자에게 수정 결과를 보여주기 위해 해당 상품의 상세 화면으로 리다이렉트 한다.
스프링에서는 redirect:/경로 형태로 쉽게 리다이렉트를 구현할 수 있으며, 경로 내에서 {itemId} 같은 변수도 그대로 사용할 수 있다.

@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId, @ModelAttribute Item item) {
    itemRepository.update(itemId, item);
    return "redirect:/basic/items/{itemId}";
}

 

4. 리다이렉트의 의미와 장점

상품 정보를 수정한 후 단순히 뷰 템플릿을 반환하는 것이 아니라 리다이렉트를 사용하는 이유는 다음과 같다.

  • PRG 패턴(Post-Redirect-Get): POST 요청 후 브라우저가 새로고침되면 같은 요청이 다시 반복되지 않도록 GET 요청으로 리다이렉트함.
  • URL 일관성 유지: 수정 결과 페이지의 URL이 상품 상세 URL로 유지되어 사용자가 공유하거나 북마크 할 수 있음.

5. HTML form에서 PUT, PATCH를 사용하지 않는 이유

HTML에서 <form> 태그는 기본적으로 GET과 POST만 지원한다.
HTTP API에서는 자원을 업데이트할 때 PUT이나 PATCH를 사용하지만, HTML 폼에서는 사용할 수 없다.

스프링에서는 히든 필드나 JavaScript를 활용해 PUT, PATCH를 흉내 낼 수는 있으나, 이 예제에서는 단순하게 POST로 처리한다.

 

RPG Post/Redirect/Get

상품 등록 처리를 진행하면서 단순히 POST 요청 이후 상품 상세 뷰 템플릿으로 바로 이동하게 할 경우, 브라우저 새로고침 시 중복 등록 문제가 발생한다. 이 문제를 해결하기 위한 방식이 바로 PRG(Post/Redirect/Get) 패턴이다.

 

문제 상황: POST 후 새로고침

  • 사용자가 상품을 등록하면 POST 방식으로 서버에 데이터를 전송하고, 서버는 응답으로 상품 상세 HTML을 내려준다.
  • 그런데 사용자가 이 페이지에서 새로고침을 하면 이전의 POST 요청이 다시 전송되어 상품이 한 번 더 등록된다.
  • 이때 ID만 다르고 내용이 같은 상품이 계속 생성된다.

해결책: PRG(Post/Redirect/Get)

  • POST 요청으로 상품을 저장한 이후, 뷰를 바로 반환하지 않고 Redirect로 상품 상세 URL로 이동시킨다.
  • 브라우저는 리다이렉트를 받아서 새로운 GET 요청을 보내게 되고, 이때 상품 상세 페이지를 보여준다.
  • 이 흐름으로 인해 새로고침을 해도 마지막 요청은 GET이므로 중복 저장이 발생하지 않는다.

흐름 요약

  1. 사용자가 GET /add로 상품 등록 폼을 요청
  2. 사용자가 상품 정보를 입력하고 POST /add 요청
  3. 서버는 상품을 저장한 후 redirect:/basic/items/{id} 응답
  4. 브라우저는 상품 상세 URL인 GET /basic/items/{id}로 이동
  5. 이후 새로고침해도 다시 GET 요청만 전송되어 데이터 중복 저장이 발생하지 않음

리다이렉트 주의 사항

  • 리다이렉트 시 문자열 결합으로 URL을 만들면 인코딩 문제가 생길 수 있으므로 RedirectAttributes와 같은 방식으로 URL 변수를 전달하는 것이 더 안전하다.

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}";
}

변경 전

  • POST 요청으로 상품 저장
  • 저장 후 뷰 템플릿(item.html) 직접 반환
  • 문제: 새로고침 시 POST 재전송 → 상품 중복 저장

변경 후 (addItemV6)

  • POST 요청 처리 후 RedirectAttributes를 활용해 리다이렉트 URL에 쿼리 파라미터 status=true를 포함
  • 등록 완료 후 상품 상세 화면으로 리다이렉트
  • 뷰 템플릿에서는 쿼리 파라미터를 감지해 메시지 출력

컨트롤러에 새로 추가된 기능 

  • RedirectAttributes 객체 파라미터 추가
  • addAttribute()를 이용해 itemId, status 값을 전달
  • redirect:/basic/items/{itemId}를 반환하여 리다이렉트 수행

타임리프 뷰 템플릿 변화

상품 상세 화면에 다음과 같은 구문 추가

<h2 th:if="${param.status}" th:text="'저장 완료!'"></h2>
  • 쿼리 파라미터 status 가 있을 경우 메시지를 출력
  • 타임리프는 ${param. 변수명}을 통해 쿼리 파라미터 접근 가능

정리

  • 컨트롤러는 상품 등록 성공 후, RedirectAttributes를 통해 URL 경로 변수와 쿼리 파라미터를 함께 전달하도록 변경함
  • 사용자에게 등록 성공 메시지를 전달할 수 있도록 개선됨
  • PRG(Post-Redirect-Get) 패턴을 적용해 새로고침 시 중복 등록 문제도 함께 해결됨