Spring

[Spring] 스프링 MVC로 웹 페이지 만들기

이덩우 2023. 7. 15. 17:30

- 요구사항 분석

상품을 관리할 수 있는 서비스를 만들어볼 것이다.

스프링 MVC와 타임리프를 사용해 만들어보자.

 

상품 도메인 모델

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

상품 관리 기능

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

서비스 제공 흐름

현재 상황은 웹 퍼블리셔가 HTML, CSS를 만들어 제공한 상황이이다.

핵심 비지니스 로직을 개발하고 뷰 템플릿을 통해 동적인 HTML을 제공하도록 해보자!

 


 

- 타임리프

타임리프의 핵심은 th:xxx 가 붙은 부분은 서버사이드에서 렌더링 되고, 기존것을 대체한다는 점이다.

th:xxx 이 없으면 기존 HTML의 xxx 속성이 그대로 사용된다.

HTML을 파일로 직접 열었을 때, JSP 같은 뷰 템플릿 엔진의 경우 자바 코드가 섞여있어 깨지는 상황이 많지만

타임리프에서 웹 브라우저는 th: 속성을 알지 못하므로 무시하고 기존의 속성이 사용된다.

따라서 HTML 파일 보기를 유지하면서 뷰 템플릿 기능도 할 수 있다.

타임리프의 핵심 사용법들을 알아보자.

 

내용 변경 - th:text

<td th:text="${item.price}">10000</td>
  • '10000'이라고 써놓은 내용의 값을 th:text 의 값으로 변경한다.
  • 여기서는 ${item.price}의 값으로 변경한다.

변수 표현식 - ${....}

<td th:text="${item.price}">10000</td>
  • 위와 동일한 코드이다.
  • 모델에 포함된 값이나, 타임리프 변수로 선언한 값을 조회할 수 있다.
  • 프로퍼티 접근법을 사용한다. (item.price는 item.getPrice()랑 동일한 의미)

 

URL 링크 표현식 - @{....}

<link href="../css/bootstrap.min.css"
          th:href="@{/css/bootstrap.min.css}"
          rel="stylesheet">
  • 타임리프는 URL링크를 사용하는 경우 @{...} 을 사용한다.
<td><a href="items.html" th:href="@{/basic/items/{itemId}(itemId=${item.id})}"
                       th:text="${item.id}">회원id</a></td>
  • 다른 방식으로 URL 내에 타임리프 변수를 사용할 수 있다.
  • {itemId}으로 변수를 설정하고 (itemId=${item.id}) 로 변수에 값을 넣을 수 있다.
  • 추가로, th:href="@{/basic/items/{itemId}(itemId=${item.id}, query='test')}" 처럼 쿼리 파라미터를 넣어줄 수 있다.

 

반복 출력 - th:each

<tr th:each = "item : ${items}">
                <td><a href="items.html" th:href="@{/basic/items/{itemId}(itemId=${item.id})}"
                       th:text="${item.id}">회원id</a></td>
                <td><a href="items.html" th:href="@{|/basic/items/${item.id}|}"
                       th:text="${item.itemName}">상품명</a></td>
                <td th:text="${item.price}">가격</td>
                <td th:text="${item.quantity}">10</td>
</tr>
  • 자바의 for-each문과 같은 동작을 한다.

 

속성 변경 - th:onclick

<button class="btn btn-primary float-end"
	onclick="location.href='addForm.html'"
	th:onclick="|location.href='@{/basic/items/add}'|"
	type="button">상품 등록
</button>
  • 버튼 클릭시 URL 경로를 옮기는 것이다.

 

리터럴 대체 - |....| 

  • 타임리프에서 문자와 표현식 등은 분리되어 있기 때문에 원래는 더해서 사용해야 한다.
th:onclick="'location.href=' + '\'' + @{/basic/items/add} + '\''"
  • 리터럴 대체 문법을 사용하면 다음과 같이 편리하게 사용할 수 있다.
th:onclick="|location.href='@{/basic/items/add}'|"

 


 

- 상품  도메인 개발

Item.java

@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;
    }
}
  • 상품 ID, 이름, 수량, 가격을 멤버 변수로 갖는다.
  • 생성자로 상품ID를 제외한 나머지를 추가해놨다. 
  • 상품ID는 직접 만드는게 아닌, 상품등록시 자동으로 ++되면서 번호를 부여받을 것이다.

 

ItemRepository.java

@Repository
public class ItemRepository {

    private static final Map<Long, Item> store = new HashMap<>();
    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 item = findById(itemId);
        item.setItemName(updateParam.getItemName());
        item.setPrice(updateParam.getPrice());
        item.setQuantity(updateParam.getQuantity());
    }

    public void clearStore() {
        store.clear();
    }
}
  • 상품 저장시 sequence가 증가하며 상품 ID를 부여받는다.
  • 그 외 필요한 findById, findAll, update 등을 설계했다.

 


 

- 상품 목록 개발

서비스 화면

 

Controller

@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("itemA", 10000, 10));
        itemRepository.save(new Item("itemB", 30000, 12));
    }
}

 

  • 현재 저장되어 있는 전체 상품 목록을 타임리프로 넘겨줘야 한다.
  • 따라서 URL이 호출되면, 모델에 전체 상품을 리스트에 담아 반환한다.
  • 이후 타임리프를 통해 모델에 있는 값을 th:each 문을 통해 하나씩 꺼내어 상품정보를 표시해주면 된다.
  • 상품 ID나 상품명을 클릭하면 해당 상품의 상세 페이지를 열 수 있도록 링크를 달아놨다.
  • 상품 상세 URL은 현재 URL인 /basic/items 밑에 상품번호를 붙이도록 했다.
<td><a href="items.html" th:href="@{/basic/items/{itemId}(itemId=${item.id})}"
                       th:text="${item.id}">회원id</a></td>
  • 상품 등록 버튼은 나중에 처리하고 우선 상세 페이지로 이동해 마저 개발해보자.

 


 

- 상품 상세 개발

서비스 화면

 

Controller

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

    private final ItemRepository itemRepository;

    @GetMapping("/{itemId}")
    public String item(@PathVariable Long itemId, Model model) {
        Item item = itemRepository.findById(itemId);
        model.addAttribute("item",item);
        return "basic/item";
    }
}
  • 이전의 상품 목록 HTML에서 링크를 클릭할 시 basic/items/{itemId} 경로를 호출하도록 만들었다.
  • 해당 경로의 컨트롤러가 호출되며, 경로로 들어온 상품ID를 기반으로 findById()를 통해 상품을 가져온다.
  • 해당 상품정보를 모델에 담아 상품 상세 HTML 경로로 반환한다.
  • 타임리프를 통해 상품 정보들이 표시된다.
  • '목록으로' 버튼을 클릭하면 /basic/items 경로를 호출해 다시 돌아간다.
  • '상품 수정' 버튼을 클릭하면 /basic/items/{itemId}/edit 경로를 호출한다.

 


 

- 상품 수정 개발

서비스 화면

 

Controller

@Controller
@RequestMapping("/basic/items")
@RequiredArgsConstructor
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 editSaveForm(@PathVariable Long itemId, @ModelAttribute Item item) {
        itemRepository.update(itemId, item);

        return "redirect:/basic/items/{itemId}";
    }
}
  • 이전의 상품 상세 페이지에서 '상품 수정' 버튼을 클릭하면 GET 요청으로 수정 Form이 담긴 HTML 경로를 반환해 렌더링 해준다.
  • 이 후 폼에서 수정을 마치고 '저장' 버튼을 클릭하면 해당 폼의 데이터가 POST 방식으로 전달된다.
  • 재밌는건, 두 가지의 기능을 모두 같은 경로에서 처리했다는 점이다!
  • 다만 초기에 상품 수정 폼을 요청 받을 때는 GetMapping으로, 이 후 버튼을 눌러 저장할 때는 PostMapping으로 데이터를 받아와 처리한다. 실무에서도 이 방식을 많이 사용한다.
  • 또 재밌는건, 수정 후 저장버튼을 누르면 아예 제품 상세 페이지 경로로 리다이렉트 하고있다.
  • 리다이렉트 하는것과 상세 페이지 뷰 경로를 반환해주는 것과 무슨 차이가 있을까?
  • 우선 상세 페이지 뷰 경로를 반환해주면, 실제 URL 경로는 그대로 ...../edit으로 남아있는 상태이다.
  • 이때 만약 새로고침을 누른다면 가장 마지막 요청이 실행되므로 저장을 실행하는 POST 액션이 실행될 것이다.
  • 만약 지금 이 상황이 상품 수정이 아니고 상품 등록이었다면, 새로고침을 할 때마다 같은 상품이 계속 등록되는것이다.
  • 이런 상황을 방지하기 위해 리다이렉트를 통해 URL 경로 자체를 상세 페이지로 옮겨버리는 것이다.
  • 이런 구조를 PRG(Post, Redirect, Get)이라고 부른다!
  • Post로 저장하고 --> Redirect로 이동하고 --> Get으로 해당 페이지를 요청하는 사이클이다.

 


 

- 상품 등록 개발

서비스 화면

 

Controller

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

    private final ItemRepository itemRepository;
    
    @GetMapping("/add")
    public String addForm() {
        return "basic/addForm";
    }

//    @PostMapping("/add")
    public String saveFormV1(@RequestParam String itemName,
                           @RequestParam int price,
                           @RequestParam int quantity,
                           Model model) {

        Item item = new Item(itemName, price, quantity);
        itemRepository.save(item);
        model.addAttribute("item", item);

        return "basic/item";
    }

//    @PostMapping("/add")
    public String saveFormV2(@ModelAttribute("item") Item item) {   //모델 받아와서 직접 안넣어줘도 애노테이션 뒤에 이름 지정해주면
                                                                    // 해당 이름으로 받아온 객체 저장해줌.
        itemRepository.save(item);
        //        model.addAttribute("item", item); 자동 추가됨. 생략 가능
        return "basic/item";
    }

   // @PostMapping("/add")
    public String saveFormV3(@ModelAttribute Item item) {  // 이름을 지정 안하면 클래스명의 맨 앞글자만 소문자로 바꿔서
                                                            // 모델 이름으로 저장
        itemRepository.save(item);
        //        model.addAttribute("item", item); 자동 추가됨. 생략 가능
        return "basic/item";
    }

    //@PostMapping("/add")
    public String saveFormV4(Item item) {  // @ModelAttribute는 생략가능!
        // 이 경우에도 Item -> item 으로 바뀌어서 모델이름으로 담기는거 잊지말자.
        itemRepository.save(item);
        //        model.addAttribute("item", item); 자동 추가됨. 생략 가능
        return "basic/item";
    }

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

    @PostMapping("/add")
    public String saveFormV6(Item item, RedirectAttributes redirectAttributes) {
        Item saveditem = itemRepository.save(item);
        redirectAttributes.addAttribute("itemId", saveditem.getId());
        redirectAttributes.addAttribute("status", true);
        return "redirect:/basic/items/{itemId}";
    }
}
  • 상품 수정과 비슷한 과정이다.
  • 최초 상품 목록 페이지에서 '상품 등록' 버튼을 클릭할 시 Get 요청으로 상품 등록 폼을 받게된다.
  • 이후 상품 정보를 입력하고 '상품 등록' 버튼을 클릭할 시 Post 방식으로 동일한 URL로 요청을 보낸다.
  • 컨트롤러 입장에서는 같은 URL로 요청이 들어왔지만 HTTP 메소드가 다르므로 구분할 수 있다.
  • 이후 실제 상품을 등록하는 방법을 여러가지 버전으로 만들어봤다.
  • 첫 번째는 @RequestParam을 통해 직접 요청 파라미터를 하나씩 꺼내서 상품을 저장하는 것이다.
  • HTML 폼으로 데이터를 전송하면 요청 파라미터로 처리할 수 있기 때문에 가능한 것이다.
  • 두 번째는 @ModelAttribute를 사용해 원하는 객체타입으로 데이터를 처음부터 받아온 것이다.
    사실 이후에 model.addAttribute() 과정이 필요하지만, 해당 애노테이션이 자동으로 처리해준다. 편리하다!
    모델에 저장 할 때 이름을 지정하고 싶으면 @ModelAttribute("item"), 이런 식으로 지정해주면 된다.
  • 세 번째도 동일한데 모델에 저장할 이름을 지정하지 않으면 객체 클래스 이름의 맨 앞글자만 소문자로 치환하고,
    해당 이름을 모델에 저장할 이름으로 인식한다.
  • 네 번째는 @ModelAttribute 애노테이션까지 생략한 것이다. 파라미터에 애노테이션이 생략되어 있을 경우 String, int, Integer 등의 기본 타입은 @RequestParam이 생략되어 있다고 간주하고, 그 외는 @ModelAttribute 애노테이션이 생략되어있다고 간주한다.
  • 마지막 두개는 리다이렉트를 적용한 모습이다.
  • 상품 수정 때도 이야기했지만, 첫 번째 ~ 네 번째 방식처럼 단순히 상품 등록 후 해당 상품의 상세 페이지 뷰를 반환해준다고 URL 자체가 옮겨진게 아니다. 단순히 해당 HTML 뷰만 현재 경로에 반환된 것이다!
  • 따라서 이 경우는 리다이렉트 해주지 않으면 새로고침 시 무한히 상품이 등록되는 불상사가 벌어질 것이다. 
  • 추가로 RedirectAttributes라는 객체를 사용해 인코딩 + 리다이렉트하면서 쿼리파라미터까지 붙여줄 수 있다.
  • 해당 쿼리파라미터를 이용해 "저장 완료!" 등의 메세지를 동적으로 HTML에 보여줄 수 있겠다.

 

 

 

 

 

 

출처 : 인프런, 김영한의 스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술