스프링 MVC 웹 페이지 만들기

상품 도메인 개발

@Controller
@RequiredArgsConstructor
@RequestMapping("/basic/items")
public class BasicItemController {

    private final ItemRepository itemRepository;

    /*
    생성자 주입으로 빈을 가져온다. 생성자가 하나만 있으면 @Autowired를 생략할 수 있다.
    여기서 롬복 @RequiredArgsConstructor까지 쓰면 코드 모두 생략 가능하다.

    @Autowired
    public BasicItemController(ItemRepository itemRepository) {
        this.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("itemA", 10000, 10));
        itemRepository.save(new Item("itemB", 20000, 20));
    }
}

상품 등록 처리

addItemV1

@Controller
@RequiredArgsConstructor
@RequestMapping("/basic/items")
public class BasicItemController {

    private final ItemRepository itemRepository;

    ...

    @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);
        return "basic/item";
    }
    
    ...
}
  • 요청 파라미터 데이터를 하나하나 변수로 받는다.

  • ItemRepository에 저장한 뒤 item을 모델에 담아 뷰에 전달한다.

addItemV2

@Controller
@RequiredArgsConstructor
@RequestMapping("/basic/items")
public class BasicItemController {

    private final ItemRepository itemRepository;

    ...

    @PostMapping("/add")
    public String addItemV2(@ModelAttribute("item") Item item, Model model) {
        /*
        modelAttribute가 item을 V1이랑 똑같이 만들어준다.
        Item item = new Item();
        item.setItemName(itemName);
        item.setPrice(price);
        item.setQuantity(quantity);
        */

        itemRepository.save(item);

        /*
        @ModelAttribute("item")에 지정한 item 이란 이름으로 뷰에도 담아준다.
        모델에 들어가는 값이 일치하지 않으면 안된다.
        예를 들어 템플릿에 item이라고 되어있는데 @ModelAttribute("item2")라고 하면 실패한다.
        model.addAttribute(item);
        */

        return "basic/item";
    }
    
    ...
}
  • @ModelAttribute가 Item 객체를 생성하고 모델에 넣어주는 일을 대신 해준다.

  • 모델에 데이터를 담을 때는 이름이 필요한데 이때 애너테이션 안에 name 속성을 넣어주면 된다.

    • 이름 지정

      • @ModelAttribute("hello") Item item

    • 모델에 해당 이름으로 저장

      • model.addAttribute("hello", item);

addItemV3

@Controller
@RequiredArgsConstructor
@RequestMapping("/basic/items")
public class BasicItemController {

    private final ItemRepository itemRepository;

    ...

    @PostMapping("/add")
    public String addItemV3(@ModelAttribute Item item) {
        itemRepository.save(item);
        return "basic/item";
    }
    
    ...
}
  • 이름을 생략하면 클래스 이름의 맨 앞글자만 소문자로 바꾼 것이 이름이 된다.

    • HelloData -> helloData

addItemV4

@Controller
@RequiredArgsConstructor
@RequestMapping("/basic/items")
public class BasicItemController {

    private final ItemRepository itemRepository;

    ...

    @PostMapping("/add")
    public String addItemV4(Item item) {
        itemRepository.save(item);
        return "basic/item";
    }
    
    ...
}
  • @ModelAttribute를 완전히 생략해도 그대로 모델에 자동 등록된다. 동작 방식도 같다.

    • 단순 타입은 @RequestParam, 임의의 객체는 @ModelAttribute가 적용된다.

상품 수정

@Controller
@RequiredArgsConstructor
@RequestMapping("/basic/items")
public class BasicItemController {

    private final ItemRepository itemRepository;

    ...

    @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, @ModelAttribute Item item) {
        itemRepository.update(itemId, item);
        return "redirect:/basic/items/{itemId}";
    }
    
    ...
}
  • @GetMapping("/{itemId}/edit")

    • 상품 수정 폼으로 이동

  • @PostMapping("/{itemId}/edit")

    • 상품 수정 로직 처리

  • redirect:/basic/items/{itemId}

    • 뷰 템플릿 호출 대신, 상품 상세 화면으로 이동하도록 리다이렉트 한다.

    • 컨트롤러에 매핑된 @PathVariable 값도 사용할 수 있다.

HTML Form 전송

  • GET, POST만 지원한다.

    • PUT, PATCH는 HTTP API 전송 시에 사용한다.

    • HTTP POST로 Form 요청 시 히든 필드를 통해 PUT, PATCH를 사용할 수도 있지만 HTTP 요청 상으로는 POST다.

PRG Post/Redirect/Get

상픔 등록 폼에서 상품 등록을 요청하면 상품 상세 페이지가 나오도록 개발했다.

사실 이 애플리케이션엔 상품 등록 폼에서 상품 등록을 한 뒤 새로고침을 하면 계속 상품이 생성되는 버그가 있다.

웹 브라우저의 새로고침은 마지막에 서버에 전송한 데이터를 다시 전송한다는 의미다.

따라서 상품 등록을 요청한 뒤 새로고침을 누르면 다시 같은 데이터를 서버로 전송하게 된다.

확인하면 똑같은 데이터로 POST 요청을 하고 있음을 알 수 있다.

해결 방법

저장 후 리다이렉트를 하면 웹 브라우저 입장에서는 완전히 다른 url을 받게 된다. 그럼 새로고침을 해도 직전에 요청한 GET의 결과를 받게 된다. 이 방식을 PRG라고 부른다.

@Controller
@RequiredArgsConstructor
@RequestMapping("/basic/items")
public class BasicItemController {

    private final ItemRepository itemRepository;

    ...

    @PostMapping("/add")
    public String addItemV5(Item item) {
        itemRepository.save(item);

        // redirect로 바꾼다.
        return "redirect:/basic/items/" + item.getId();
    }
    
    ...
}

따라서 상품 등록 후 상품 상세로 가도록 redirect 하면 문제가 해결된다.

주의 사항

+ item.getId()처럼 URL에 변수를 더해서 넣으면 URL 인코딩이 안되기 때문에 위험하다. 다음에 나올 RedirectAttributes를 사용하면 해결된다.

RedirectAttributes

고객 입장에서 등록이 잘 된건지 확인이 어려우니 개선해보자.

@Controller
@RequiredArgsConstructor
@RequestMapping("/basic/items")
public class BasicItemController {

    private final ItemRepository itemRepository;

    ...

    @PostMapping("/add")
    public String addItemV6(Item item, RedirectAttributes redirectAttributes) {
        Item savedItem = itemRepository.save(item);

        // 여기 적힌 attributeName인 itemId가
        redirectAttributes.addAttribute("itemId", savedItem.getId());
        redirectAttributes.addAttribute("status", true);

        // 이곳 url로 들어온다.
        return "redirect:/basic/items/{itemId}";
    }
    
    ...
}

itemId와 status가 전달된 걸 확인할 수 있다.

...

<div class="container">
    <div class="py-5 text-center">
        <h2>상품 상세</h2></div>
    <div>
        <!-- 추가 -->
        <h2 th:if="${param.status}" th:text="'저장 완료!'"></h2>

        ...
    </div>
</div>

...

status가 true면 텍스트를 띄우도록 설정하면 등록이 확실하게 됐다는 걸 알 수 있다.

Last updated