진짜 개발자
본문 바로가기

FrameWork/Spring JPA

Spring JPA - JPA를 이용해 Commerce App 만들기 - 5 (컨트롤러 개발 및 뷰 개발)

728x90

이번 포스팅에서는, 실질적으로 사용자가 접근 요청을 하는 부분인 Controller와 사용자가 보게될 화면을 구현하도록 하겠습니다.

전체코드 : https://gitlab.com/galid1/jpa-commerce




1. 표현 계층의 역할

우리가 오늘 작성할 표현계층에서는 아래와 같은 일들을 수행합니다.

  • 사용자의 요청값을 검증
  • 사용자의 요청을 Service 계층에서 처리할 수 있는 형태로 변환합니다.
  • Service 계층에 비즈니스 로직을 위임하고 결과값을 Model에 맵핑하여 View에 전달
  • 결과값을 사용자에게 반환합니다.




2. 구현

2.1 ItemController 구현

@Controller
@RequiredArgsConstructor
public class ItemController {
    private final ItemService itemService;

    @GetMapping("/items/new")
    public String getNewItemPage(Model model) {
        model.addAttribute("form", new AddItemRequest());
        return "items/registerItemForm";
    }

    @PostMapping("/items/new")
    public String createItem(@ModelAttribute @Valid AddItemRequest addItemRequest) {
        Long newItemId = itemService.saveItem(addItemRequest);
        return "redirect:/items/"+ newItemId;
    }

    @GetMapping("/items/{itemId}")
    public String getItemDetailsPage(@PathVariable("itemId") Long itemId,
                                     Model model) {
        ItemDetails item = itemService.findItem(itemId);
        model.addAttribute("item", item);
        return "items/itemDetails";
    }
}

완성된 ItemController인데요, 전체 그림을 본뒤, 하나하나 천천히 살펴보며 진행하도록 하겠습니다.




1. getNeItemPage()

이 핸들러는 @GetMapping 어노테이션이 부여되어 있습니다. 따라서 사용자가 어떤 값을 달라고 요청하는 것을 의미합니다. 즉, 새로운 Item을 등록하는 페이지를 반환하는 핸들러입니다.

Model에 AddItemRequest 객체를 넣어주는 이유는, Thymeleaf에서 사용하기 위해서 입니다.


@Getter
@Setter
public class AddItemRequest {
    private String name;
    private String imagePath;
    private int price;
    private int stockQuantity;
}

AddItemRequest는 상품 추가를 위한 사용자의 요청을 맵핑할 클래스입니다.



바로 여기서 앞서 넣어준 AddItemRequest 객체를 사용하게 되는데요, thymeleaf에서, 사용자가 post 요청을 할때 같이 전달한 form내용을 특정 객체에 맵핑할 수 있도록 합니다. 이렇게 맵핑하는 이유는, View단에서, 객체에 존재하는 항목에 field를 맵핑하는지 알 수 있도록 하기 위해서 입니다.



8

th:field=*{}의 중괄호 안에, 맵핑 대상인 AddItemRequest에 존재하지 않는 값을 넣으면 위와 같이 IDE단에서 알려주게됩니다.



10

또한 이를 지나치고 서버를 실행시켜도, 해당 페이지에 접근하는 순간, 에러를 발생시켜줍니다.




2. createItem()

이 핸들러는 @PostMapping어노테이션이 부여되어있으며, 따라서, 생성에 대한 요청을 처리하는 핸들러입니다.

    @PostMapping("/items/new")
    public String createItem(@ModelAttribute @Valid AddItemRequest addItemRequest) {
        Long newItemId = itemService.saveItem(addItemRequest);
        return "redirect:/items/"+ newItemId;
    }

사용자가 앞서 items/registerItemForm을 통해서 submit을 하면, 이 핸들러로 요청이 전달되는데요, form안의 내용이 AddItemRequest 매개변수에 맵핑됩니다.



@Valid

이 어노테이션이 부여되어있으며, 사용자가 요청시 전달한 객체에 constraints 어노테이션이 부여되어있다면, 해당 객체를 조건에 맞게 검증해줍니다.


public class AddItemRequest {
    @Length(min = 3)
    private String name;
    ...
}

예를들어, AddItemRequest에 위와 같이 설정한뒤



11

두글자만 입력한 뒤 요청을 하면,



12

검증을 통해 에러를 검출해줍니다. 이런 검증은, 사용자의 경험을 위해서 Front 단에서도 수행되어야 하며, 또 요청이 중간에 변조 되는것을 염두하여, 서버 측에서도 위와 같이 검증을 추가적으로 해주어야 합니다.



Long newItemId = itemService.saveItem(addItemRequest);

요청이 모두 정상이면, 이 핸들러는, 지난 포스팅에서 구현한 ItemService에게 아이템 생성 요청을 위임합니다.



중복 서브밋 방지

        return "redirect:/items/"+ newItemId;

마지막으로 Post요청에 대해서, 처리가 완료되면, 위와 같이, 다른 요청으로 redirect를 시켜서, 사용자의 중복 서브밋을 방지해야 합니다.

https://galid1.tistory.com/561

자세한 내용은 위의 링크에 정리 해두었습니다.



3. getItemDetailsPage()

    @GetMapping("/items/{itemId}")
    public String getItemDetailsPage(@PathVariable("itemId") Long itemId,
                                     Model model) {
        ItemDetails item = itemService.findItem(itemId);
        model.addAttribute("item", item);
        return "items/itemDetails";
    }

이 핸들러는, @GetMapping 이 부여되어있으며, 상품의 자세한 내용을 요청할 때 반응하는 핸들러 입니다. itemService에 상품정보 반환을 위임하고, 이를 통해 얻은 상품정보를 model에 맵핑하고, 올바른 view를 리턴하는것이 전부입니다.



사용자에게 Entity를 직접 노출하지 말자

itemService에 한 상품의 자세한 정보를 요청합니다. 중요한 점은, ItemService에서 반환한 값이 Entity가 아닌 DTO라는 점입니다. 당연히 이렇게 구현해오셨다면, 그대로 구현하시되 왜 그래야하는지 정도는 아셔야합니다. Entity를 그대로 반환하게 되는 경우 아래와 같은 단점들이 존재합니다.


첫째

불필요한 정보들이 함께 전달되어 성능저하를 일으킨다.
=> 정보가 적은경우는 문제가 없겠지만, 엔티티 내의 정보의 양이 많은 경우, 성능저하를 일으킬 수 있습니다.


둘째

API별 스펙을 구현하기 어렵다.
=> 이번에는 사용자의 요청을 엔티티에 직접 맵핑하는 경우를 예로 들수 있습니다. 사용자의 요청을 Entity에 매핑하는 것은 모든 API에서 같은 요청 형식으로 사용자의 요청을 받겠다는 의미 입니다. 예를들어, 어떤 API에서는 Member의 name이 3글자 이상이어야 하지만, 특정 API에서는 3글자 미만이더라도 문제가 없는 기능을 구현하기 위해서 어떻게 처리를 해야할까요? 이런 문제때문에 Entity를 직접 매핑하면 안됩니다.


셋째

API를 통해 응답을 예측하기 어렵다

=> API의 응답형태를 보고 API의 스펙을 알 수 있어야 잘 설계된 API라고 볼 수 있는데요, Entity를 반환하는 경우, 해당 API에서 어떤 값을 반환하는지 어떤 데이터가 null인지를 알 수 없습니다.


넷째

도메인과 관계없는 검증로직이 엔티티에 포함된다.

=> 정말 심각한 문제인데요, Domain은 핵심 비즈니스 로직이 모인곳으로, 어떠한 의존성이든 최대한 줄이는 것이 이후 유지보수에 많은 도움이 됩니다. 하지만, 검증로직이 포함된다면 필요없는 의존성이 벌써 하나 생기는 것입니다.


다섯째

양방향 연관관계가 설정된 경우, 무한루프를 방지 하기 위해, 한곳을 @JsonIgnore 처리를 해야한다.




2.2 View 구현

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<div th:replace="fragments/configHeader"/>
<body>
<div th:replace="fragments/head"/>

<div class="container">
    <div class="itemContainer">
        <div class="imageContainer">
            <img class="itemImage" th:src="@{${item.imagePath}}" alt="">
        </div>
        <div class="informationContainer">
            <div class="itemNameContainer">
                <h2 class="itemName" th:text="${item.name}"></h2>
            </div>
            <div class="itemPriceContainer">
                <h2 class="itemPrice" th:text="${#numbers.formatInteger(item.price, 3, 'COMMA')} + ' 원'"></h2>
            </div>

            <div th:if="${item.stockQuantity &lt; 1}" class="soldOutContainer">
                <i class="fas fa-ban" style="color: #CCC;"></i> 품절
            </div>
            <div th:unless="${item.stockQuantity &lt; 1}" class="orderContainer">
                <form th:onsubmit="return validateOrderCount()">
                    <div class="form-group">
                        <input id="itemIdInput" type="text" th:name="itemId" th:value="${item.itemId}"
                               style="display: none;">
                        <input id="orderCountInput" type="number" th:value="1" th:min="1" th:max="${item.stockQuantity}"
                               th:name="orderCount">
                        <button th:formmethod="post" th:formaction="@{/carts}" id="addToCartBtn" class="btn btn-light">
                            장바구니
                        </button>
                    </div>
                </form>
                <div class="form-group">
                    <button id="orderBtn" class="btn btn-primary">
                        바로구매
                    </button>
                </div>
            </div>
        </div>
    </div>
</div>

</body>
</html>

<style>
...
</style>

<script>
  function validateOrderCount() {
    let orderCount = parseInt(document.getElementById("orderCountInput").value);

    if(orderCount <= 0) {
        alert("주문 수량은 1개 이상이어야 합니다.");
        return false;
    }
    return true;
  }

 $(function () {
    // 바로 구매 ====================
    function getCartInputList() {
      let cartInputList = "";
      let itemId = $("#itemIdInput").val();
      let orderCount = $("#orderCountInput").val();
      cartInputList += "<input type='text' name='orderLineList[0].itemId' value='" + itemId + "'>";
      cartInputList += "<input type='text' name='orderLineList[0].orderCount' value='"+ orderCount + "'>";
      return cartInputList;
    }

    $("#orderBtn").on("click", function() {
        let form = $("<form action='/orders/direct' method='post'>" +
                        getCartInputList() +
                    "</form> ");
        $("body").append(form);
        form.submit();
    });

  });
</script>

우선 thymeleaf 문법 검사를 위해 <html lang="en" xmlns:th="http://www.thymeleaf.org">를 추가합니다.

이제 원하는 형태로 html을 짜면되는데요, 중요한것은 Handler에서 model에 맵핑한 데이터를 어떻게 가져오느냐 일 것입니다.



Model 데이터 가져오기

<div th:text=${item.name}/>

방법은 간단한데요 위와 같이 태그 요소에 th:text=${모델데이터} 이런 형태로 값을 넣어주면 됩니다. 자세한 내용은 thymeleaf 공식홈페이지를 참고하는 편이 더 좋을것 같습니다.



주문 요청

 $(function () {
    // 바로 구매 ====================
    function getCartInputList() {
      let cartInputList = "";
      let itemId = $("#itemIdInput").val();
      let orderCount = $("#orderCountInput").val();
      cartInputList += "<input type='text' name='orderLineList[0].itemId' value='" + itemId + "'>";
      cartInputList += "<input type='text' name='orderLineList[0].orderCount' value='"+ orderCount + "'>";
      return cartInputList;
    }

    $("#orderBtn").on("click", function() {
        let form = $("<form action='/orders/direct' method='post'>" +
                        getCartInputList() +
                    "</form> ");
        $("body").append(form);
        form.submit();
    });

  });

주문 요청은 form대신 jquery를 이용해 처리 했습니다. form태그를 중간에 넣어 html을 짜기가 어려워, 필요한 요소들을 직접 가져와, form을 jquery를 이용해 직접 구성하여 요청을 처리하도록 했습니다.




이제, Controller와 View의 구현 과정은 어느정도 감이 잡히셨을거라 생각하고 나머지는 여러분에게 맡기겠습니다.

전체코드 : https://gitlab.com/galid1/jpa-commerce