Spring

[Spring] MVC 21편 - 웹 페이지 만들기 (3)

dev-nadan 2025. 8. 18. 20:51

지난 편에서는 상품 목록, 상세, 등록까지 구현했다.

이번에는 상품 수정 기능을 추가하고, 등록 후 새로고침 시 발생하는 문제를 어떻게 해결하는지도 살펴본다.


1. 상품 수정 폼

컨트롤러

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

 

  • @PathVariable로 전달된 상품 ID를 이용해 상품을 조회
  • 모델에 담아 editForm.html에 전달

뷰 템플릿 (editForm.html)

<form th:action 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>
    <button class="btn btn-primary" type="submit">저장</button>
    <button class="btn btn-secondary"
            th:onclick="|location.href='@{/basic/items/{id}(id=${item.id})}'|"
            type="button">취소</button>
</form>

 

  • th:value: 수정할 기존 데이터를 미리 채워 넣는다.
  • 취소 버튼은 해당 상품 상세 화면으로 이동한다.

2. 상품 수정 처리

컨트롤러

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

 

  • 상품 수정 후 다시 상품 상세 화면으로 이동하도록 redirect:를 사용한다.
  • @ModelAttribute를 사용하면 파라미터 바인딩과 모델 등록이 자동 처리된다.

3. 새로고침 문제와 PRG 패턴

문제 상황

상품 등록 컨트롤러(addItemV1~V4)는 상품 등록 후 바로 뷰 템플릿을 반환했다.

이 상태에서 브라우저에서 새로고침(F5) 을 누르면, 직전의 POST 요청이 그대로 다시 실행되며 상품이 중복 등록되는 문제가 발생한다.

해결 방법: PRG (Post/Redirect/Get)

  • 상품 등록 후에는 뷰 템플릿으로 바로 이동하지 않고 리다이렉트를 수행한다.
  • 이렇게 하면 브라우저의 마지막 요청이 GET으로 바뀌어, 새로고침해도 POST가 반복 실행되지 않는다.

코드 예시

@PostMapping("/add")
public String addItemV5(Item item) {
    itemRepository.save(item);
    return "redirect:/basic/items/" + item.getId();
}
  • 등록 후 /basic/items/{id} 로 리다이렉트
  • 단, 문자열 결합 방식은 URL 인코딩 문제가 생길 수 있으므로 권장하지 않는다.

4. RedirectAttributes

상품 등록 후 “저장되었습니다” 같은 메시지를 표시하려면 어떻게 할까?

리다이렉트 시 쿼리 파라미터를 함께 전달하면 된다. 이때 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}";
}

 

  • RedirectAttributes는 URL 인코딩도 처리해주고, PathVariable과 쿼리 파라미터도 함께 지원한다.
  • 결과적으로 아래와 같은 리다이렉트가 발생한다.
http://localhost:8080/basic/items/3?status=true

 

뷰 템플릿 (item.html) 메시지 추가

<h2 th:if="${param.status}" th:text="'저장 완료!'"></h2>

 

  • ${param.status} : 타임리프에서 쿼리 파라미터를 직접 조회할 수 있다.
  • th:if : 조건이 참일 때만 해당 태그 렌더링

5. 회고

이번 장에서는 상품 수정 기능과 더불어 PRG(Post/Redirect/Get) 패턴을 학습했다.

특히, 새로고침 시 중복 등록 문제가 왜 발생하는지, 그리고 이를 리다이렉트로 어떻게 방지할 수 있는지 명확히 이해할 수 있었다.

 

과거 프로젝트에서 회원 가입 후 새로고침으로 계정이 여러 개 생기는 문제가 있었는데, 그때 단순히 클라이언트 쪽에서만 막으려 했었다.

이제 보니 서버 차원에서 PRG 패턴과 RedirectAttributes를 적용하는 것이 훨씬 근본적인 해결책이었다.

 

앞으로는 이런 플로우 제어까지 고려해서 API와 화면을 설계해야겠다고 느꼈다.