스프링 MVC-스프링 MVC 웹 구축

요구사항

지금까지 배운 스프링 MVC의 기능들로 간단한 웹 페이지를 구축해볼 것이다. 요구사항은 다음과 같다.

상품 도메인 모델

  • 상품 ID
  • 상품명
  • 가격
  • 수량

상품 관리 기능

  • 상품 목록
  • 상품 상세
  • 상품 등록
  • 상품 수정

상품 도메인 코드

package hello.itemservice.domain.item;

import lombok.Data;

@Data
public class Item {
    Long id;
    String itemName;
    Integer price;
    int quantity;

    public Item() {
    }

    public Item(String itemName, Integer price, int quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}

요구사항대로 작성한 코드이다. 생성자는 기본 생성자와 파라미터를 입력받는 타입 두가지를 만들었다.

상품 리포지토리 코드

@Repository
public class ItemRepository {
    static final Map<Long, Item> store=new HashMap<>();
    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();
    }
}

요구사항대로 구축한 리포지토리의 코드이다. 해쉬맵에 데이터를 저장하도록 했고 필요한 저장, 검색, 업데이트 기능이 구현되어있다. 이제 웹과 맵핑되어 리포지토리에 데이터를 저장하는 컨트롤러를 만들어야 한다.
그 이전에 html을 동적으로 바꿔줄 타임리프에 대해 간단히 알아보자.

타임리프

  
<html xmlns:th="http://www.thymeleaf.org">

뷰 템플릿인 타임리프는 기본적으로 위와 같이 선언하여 사용한다. 위와 같이 선언하고 동적으로 고쳐야할 부분에 ‘th:value=’ 과 같이 붙여주면, 해당 부분이 모델에서 받은 데이터를 동적으로 페이지에 입력해준다.
필요한 부분만 동적으로 치환하기 때문에 jsp 등과 달리 html 파일을 그대로 웹에서 출력해도 html이 깨지지 않는다. 따라서 서버를 띄우지 않아도 테스트가 용이한 장점이 있고, 이와 같이 html을 훼손하지 않기 때문에 네츄럴 템플릿이라고 불린다.

  
<div class="row">
    <div class="col">
        <button class="btn btn-primary float-end"
            onclick="location.href='addForm.html'"
            type="button">상품 등록</button>
    </div>
</div> 

우리가 동적으로 고쳐야 할 html이다. 기본 설정으로 정적인 리소스인 ‘addForm.html’을 띄우도록 되어있다.

  
<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> 
아래에 ‘th:onclick=” location.href=’@{/basic/items/add}’ ”’ 한 줄만 추가하여 페이지를 동적으로 만들었다. 위와 같이 작성하면 서버 사이드에서 렌더링되면서 아래의 값으로 치환되는 방식이다.

타임리프는 url 링크를 사용할 때엔 ’@{}’와 같은 형식을 사용하므로 위와 같이 되었다.
’||’ 표시는 리터럴 대체 문법인데, 원래는 타임리프에서 문자와 표현식이 분리되어있기 때문에 String을 사용하듯이 ‘location.href=’+’'’ 와 같이 불편하게 사용해야 했다. 하지만 리터럴 대체 문법을 사용하면 위에서 작성한 것과 같이 더하기 없이 편리하게 작성이 가능하다.

  
<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>

상품 목록을 띄우는 파트이다. ‘th:each=’를 사용하면 모델에 포함된 컬렉션 데이터에서 하나씩 뽑아서 출력할 수 있다. 위에선 모델의 items란 이름의 컬렉션에서 하나씩 뽑아 item이란 변수에 넣은 것을 볼 수 있다.

타임리프에서 변수의 값을 조회하려면 ‘${}’와 같은 표현식을 사용한다. 타임리프 내부에서 선언된 변수나 모델에 포함된 데이터를 조회할 때 사용한다. 위에서는 ${items}로 모델의 데이터를 조회하였다.

th:href=”@{ /basic/items/${item.id} }” 이 문장을 보면, url 경로 표현식에서 변수를 사용할 수 있음을 알 수 있다. 실제 웹에서는 item의 id가 들어간 동적인 url이 될 것이다.

경로에 변수 뿐만 아니라 쿼리 파라미터도 사용이 가능한데, (itemId=${item.id}, query=’test’)와 같이 작성하면 변수 값을 url에 넣어줄 뿐만 아니라 쿼리 파라미터도 작성하여 입력해준다. 예를 들면 ‘http://localhost:8080/basic/items/1?query=test’ 와 같은 링크가 될 것이다.

html에서 action은 form 내부에서 데이터를 전송(submit) 했을 때 도착할 url을 의미하는데, ‘th:action’과 같이 값을 넣지 않으면 현재 url에 그대로 데이터를 전송한다.
현재 url에 그대로 데이터를 전송하고, 컨트롤러에서 @GetMapping, @PostMapping으로 같은 url을 메서드에 따라 다르게 받음으로써 하나의 url로 폼과 등록 처리를 동시에 깔끔하게 처리할 수 있다.

컨트롤러 구현

이제 html은 어느정도 구현이 됬으니, 웹과 맵핑할 컨트롤러를 만들어보자. html은 위에서 사용한 테크닉들로 모델에서 데이터를 적절히 받아 출력한다고 가정하고 넘어가겠다.

  
@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));
    }
}

기본적인 상품목록 조회 기능만 있는 컨트롤러이다. 클래스 단에 @RequestMapping(“/basic/items”) 어노테이션을 달아 메서드들은 논리 경로만 맵핑하도록 하였다.
뷰 템플릿인 타임리프를 사용하므로 return “basic/items”; 과 같이 String을 반환하면, resources/templates/basic/items.html 이라는 뷰를 렌더링하여 띄워줄 것이다.
아래에는 테스트가 용이하게 하기 위해 기본적인 데이터를 입력해주었다.

상품 상세

  
    @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에 넘겨주는 역할을 한다.

상품 등록

등록할 상품의 데이터는 POST-HTML Form 형식으로 메세지 바디에 쿼리 파라미터로 데이터가 들어온다고 가정하겠다.
@RequestParam으로 쿼리 파라미터를 하나씩 받아 객체를 생성하는 방법도 있지만, 우리는 더욱 효율적인 방법을 이미 알고있다. 따라서 @ModelAttribute를 사용하여 구현하도록 하겠다.

  
@PostMapping("/add")
public String addItemV2(@ModelAttribute("item") Item item, Model model) {
    itemRepository.save(item);
    //model.addAttribute("item", item); //자동 추가, 생략 가능
    return "basic/item";
}

@ModelAttribute 어노테이션을 통해 쿼리 파라미터를 객체에 입력한 코드이다. 그런데 한가지 이상한 점이 보인다. 모델에 객체를 추가해주는 코드가 주석처리 되어있는데, 이 코드는 정상적으로 작동한다.
이는 우리가 몰랐던 @ModelAttribute 어노테이션의 기능 때문인데, 위와 같이 어노테이션에 값을 넣고 객체에 값을 바인딩하면, 해당 값을 이름으로 하여 모델에도 해당 객체가 자동으로 들어간다.
해당 이름을 생략하면 클래스 이름의 첫 글자를 소문자로 변환한 것이 모델에 들어간 객체의 이름이 된다. 어노테이션 자체도 생략은 가능하지만 그렇게까지 생략하면 가독성이 불분명해지므로 지양하는 것이 좋을 것 같다.

상품 수정

상품의 수정은 상품 수정 폼을 띄우고, 폼에 정보를 입력한 후 수정 버튼을 누르면 데이터가 업데이트 되는 2가지 과정을 거친다. 따라서 위에서 한번 봤던것처럼 하나의 url에 HTTP 메서드만 다르게 하여 처리하면 깔끔할 것이다.

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

상품 수정 폼을 띄우는 코드이다. @PathVariable 어노테이션으로 url로부터 변수를 받아 객체를 찾고 폼에 띄우는 역할을 한다. 상품 수정 폼을 적절히 Thymeleaf를 사용하여 수정하고 수정 로직으로 넘어가자.

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

상품을 수정하는 로직이다. 위의 코드와 같은 url에 get과 post만 다른 것을 확인할 수 있다. 객체를 폼에서부터 쿼리 파라미터로 받아 리포지토리에 업데이트 해주는 역할을 한다.
상품을 수정하면 상품 상세 페이지로 리다이렉트 되어 제대로 수정이 되었는지 확인할 수 있는데, 스프링은 ‘redirect:’을 사용하여 편리하게 리다이렉트를 지원한다. 뒤에 경로만 넣어주면 해당 url로 리다이렉트 된다.

PRG 패턴

현재의 컨트롤러는 사실 큰 결함이 있다. 위에 있는 상품 등록 코드를 다시 확인해보자.

  
@PostMapping("/add")
public String addItemV2(@ModelAttribute("item") Item item, Model model) {
    itemRepository.save(item);
    //model.addAttribute("item", item); //자동 추가, 생략 가능
    return "basic/item";
}

상품을 저장한 후 상품의 상세 폼을 띄우는 코드이다. 그런데 여기서 새로고침을 한다면 같은 상품이 계속해서 등록되는 것을 확인할 수 있다.
그 이유는 새로고침이란 기본적으로 이전에 했던 요청을 다시 하는 것이기 때문이다. post 메서드를 통해 데이터를 쿼리파라미터로 서버에 요청하는 과정이 다시 한번 일어나기 때문에 상품이 반복해서 등록되는 것이다.
이를 방지하기 위한 것이 PRG 패턴이다. PRG는 POST,Redirect, Get의 약자이다. 즉 Post를 한 이후에 상품 상세 폼을 띄우는 것이 아니라 상품 상세 폼을 띄우는 Get 메서드로 리다이렉트 시키는 것이다.
이렇게 한다면 Post 이후에 상품 상세로 이동하면서, 새로고침을 해도 Get 메서드이므로 서버의 데이터에는 변경이 없도록 할 수 있다.

  
@PostMapping("/add")
public String addItemV5(Item item) {
    itemRepository.save(item);
    return "redirect:/basic/items/" + item.getId();
}

PRG 패턴을 사용하여 수정한 상품 등록 메서드이다. 상품 상세 폼을 확인하는 url로 리다이렉트 되도록 수정한 것을 확인할 수 있다.

RedirectAttribute

사실 위의 코드와 같이 url에 +로 변수를 더하는 것은 url 인코딩이 되지않기 때문에 위험한 방식이다. 간단한 정수이기 때문에 작동했지만 만약 한글, 또는 url로 사용할 수 없는 기타 문자라면 제대로 작동하지 않을 것이다.
변수를 리다이렉트에 추가하고 싶다면 RedirectAttribute를 사용하면 된다.

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

RedirectAttribute를 사용하여 수정한 코드이다. RedirectAttribute를 사용한다면 변수를 PathVariable과 같이 사용할 수도 있고, url에 바인딩되지 않은 값은 쿼리 파라미터로 사용된다.
즉 위의 코드와 같이 사용하면 리턴값으로는 ‘http://localhost:8080/basic/items/3?status=true’라는 링크를 받을 수 있을 것이다. 이제 뷰 템플릿에서 파라미터의 status가 true이면 ‘저장 완료’메세지를 띄우도록 수정하면 끝이다.

완강

이번 챕터를 끝으로 강의가 끝났다. 이제 기본적으로 스프링을 통해 웹을 어떻게 구축하는지에 대해 알 수 있었던 강의였다.
하지만 내가 계획중인 개인 프로젝트를 위해서는 서버에 db를 연동하는 법도 배워야하고, 최근 백엔드는 스프링 기본 뿐만 아니라 스프링 부트와 jpa까지 배우는 것이 좋아보여 앞으로 강의를 계속해서 들어야 할 것 같다. 스프링으로 구축하고 부트로 리팩토링 할지 부트까지 듣고 프로젝트를 시작할지는 고민이 된다.