본문 바로가기

스프링 (인프런)/스프링부트

웹 계층 개발

1. 홈 화면과 레이아웃

홈 컨트롤러 등록

@Controller
@Slf4j
public class HomeController {
    @RequestMapping("/")
    public String home() {
        log.info("home controller");
        return "home";
    }
}

- jpashop에 controller 패키지를 새로 파고 홈 컨트롤러를 생성했다.

- @Service나 @Repository처럼 이 클래스의 용도를 알려주는 @Controller를 붙여줬다.

- @Slf4j는 로깅 프레임 워크에 대한 추상화를 제공하는 SLF4J를 Lombok이 대신 등록해준다.

 

- @RequestMapping은 특정 url에 특정 메서드에 대한 요청이 들어왔을 때, 연관된 하단의 메서드로 처리한다는 어노테이션이다.

- 그래서 홈 화면으로 이동하면 home()이 호출되는 것이다.

 

- home()에서 "home"을 반환한다는 것은 스프링이 반환값과 동일한 이름(html 파일명)을 가진 뷰를 찾아 렌더링한다는 뜻이다.

- log.info("home controller")는 home()이 호출되었을 때 home controller라는 로그를 남기도록 한다.

 

 

 

home.html

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="fragments/header :: header">
  <title>Hello</title>
  <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>

<body>

<div class="container">
  <div th:replace="fragments/bodyHeader :: bodyHeader" />
  <div class="jumbotron"> <h1>HELLO SHOP</h1>
    <p class="lead">회원 기능</p> <p>
      <a class="btn btn-lg btn-secondary" href="/members/new">회원 가입</a>
      <a class="btn btn-lg btn-secondary" href="/members">회원 목록</a> </p>
    <p class="lead">상품 기능</p> <p>
      <a class="btn btn-lg btn-dark" href="/items/new">상품 등록</a>
      <a class="btn btn-lg btn-dark" href="/items">상품 목록</a> </p>
    <p class="lead">주문 기능</p> <p>
      <a class="btn btn-lg btn-info" href="/order">상품 주문</a>
      <a class="btn btn-lg btn-info" href="/orders">주문 내역</a> </p>
  </div>
  <div th:replace="fragments/footer :: footer" />
</div> <!-- /container -->

- 이 강의는 html를 다루지 않으므로 자세한 내용을 듣진 못했다.

- xmlns:th는 타임리프의 특별한 네임스페이스다.

- th:replace는 런타임에 해당 fragments 경로의 파일로 대체한다.

 

- 그리고 html을 보면 알겠지만 파일이 전체적으로 header, bodyheader, footer로 이뤄진다.

- 그리고 대체할 파일들을 만들어줘야 한다.

 

 

 

- 그러면 다음과 같은 결과를 localhost:8080/home 페이지에서 볼 수 있다.

 

 

 

참고!

https://www.thymeleaf.org/doc/articles/layouts.html

여기서 다루는 hierarchical-style 레이아웃을 사용하면

header와 footer의 반복 등을 모두 제거할 수 있다고 한다.

 

 

 

디자인 추가

- https://getbootstrap.com/docs/5.3/getting-started/download/

- 여기서 Compiled CSS and JS 파일을 다운받는다.

- 그리고 내 프로젝트의 "resources/static" 경로에 다운받은 'css'와 'js' 경로를 붙여넣어 준다.

 

- 그러면 위처럼 홈 디렉토리에 디자인이 생겨난 것을 볼 수 있다.

 

 

 

2. 회원 등록

MemberForm

@Getter @Setter
public class MemberForm {
    @NotEmpty( message = "회원 이름은 필수입니다." )
    private String name;

    private String city;
    private String street;
    private String zipcode;
}

- 회원을 등록하는 데 필요한 데이터 필드의 폼 객체다.

 

- 그래서 위 화면처럼 회원 등록에 필요한 필드를 입력받고, 이를 담는 역할을 한다.

 

 

 

MemberController

@Controller
@RequiredArgsConstructor
public class MemberController {
    private final MemberService memberService;

    @GetMapping("members/new")
    public String createForm(Model model) {
        model.addAttribute("memberForm", new MemberForm());
        return "members/createMemberForm";
    }

    @PostMapping("members/new")
    public String create(@Valid MemberForm memberForm, BindingResult result) {    // @Valid는 MemberForm에서 비어 있으면 안되는 필드가 비어 있는지 확인
        if (result.hasErrors())
            return "members/createMemberForm";

        Address address = new Address(memberForm.getCity(), memberForm.getStreet(), memberForm.getZipcode());

        Member member = new Member();
        member.setName(memberForm.getName());
        member.setAddress(address);

        memberService.join(member);
        return "redirect:/";
    }
}

- create()를 살펴 보면 MemberForm에서 받은 회원 이름과 주소를 가지고 Member 객체를 생성해서 join()하는 것을 볼 수 있다.

- 즉 MemberService를 주입 받아야 하므로 @RequiredArgsConstructor가 붙는다.

 

- @GetMapping은 "members/new" 디렉토리에 대한 GET 요청이 들어왔을 때 호출될 메서드에 붙는다.

- model.addAttribute()를 통해 'memberForm'이라는 키에 새로 생성한 MemberForm 객체를 넘겨준다.

- 그리고 'memberForm'은 createMemberForm.html에서 다시 찾을 수 있다.

 

- @PostMapping은 "members/new" 디렉토리에서 POST 요청이 들어왔을 때 호출될 메서드에 붙는다.

- "members/new"에서 POST는 회원을 등록하는 것이므로 create()에서는 MemberForm으로 Member를 생성해서 join()한다.

 

- 그런데 메서드 시그니쳐 중에서 @Valid가 눈에 띈다.

- 이는 MemberForm의 @NotEmpty와 엮여서 사용된다.

- 만약 name 필드가 비어있다면 message의 내용이 나오면서 회원 등록에 실패해야 한다.

- 이를 @Valid를 통해 create()에서 이를 확인하는 것 같다.

 

- 그리고 시그니쳐 중에 BindingResult result도 눈여겨 보자.

- result.hasErrors()에서 에러가 발생하는지 확인하고 ( 회원명이 없을 때 ) 홈 화면이 아니라 다시 회원등록 화면으로 가도록 한다.

 

 

 

회원등록 폼 화면

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="fragments/header :: header" />
<style>
  .fieldError {
    border-color: #bd2130;
  } </style>
<body>
<div class="container">
  <div th:replace="fragments/bodyHeader :: bodyHeader"/>
  <form role="form" action="/members/new" th:object="${memberForm}" method="post">

    <div class="form-group">
      <label th:for="name">이름</label>
      <input type="text" th:field="*{name}" class="form-control" placeholder="이름을 입력하세요"
             th:class="${#fields.hasErrors('name')}? 'form-control fieldError' : 'form-control'">
      <p th:if="${#fields.hasErrors('name')}"
         th:errors="*{name}">Incorrect date</p>
    </div>

    <div class="form-group">
      <label th:for="city">도시</label>
      <input type="text" th:field="*{city}" class="form-control" placeholder="도시를 입력하세요"> </div>

    <div class="form-group">
      <label th:for="street">거리</label>
      <input type="text" th:field="*{street}" class="form-control" placeholder="거리를 입력하세요"> </div>

    <div class="form-group">
      <label th:for="zipcode">우편번호</label>
      <input type="text" th:field="*{zipcode}" class="form-control" placeholder="우편번호를 입력하세요"> </div>

    <button type="submit" class="btn btn-primary">Submit</button>
  </form>
  <br/>
  <div th:replace="fragments/footer :: footer" />
</div> <!-- /container -->
</body>
</html>

- <style> 태그는 회원 등록이 실패했을 때 다른 스타일의 페이지를 보여주기 위해 필요하다.

 

- <body>는 페이지의 실질적 요소에 대해 다룬다.

    - MemberController에서 다룬 memberForm이 먼저 보인다.

    - th:field는 id와 name이 자주 겹친다는 점을 해결하기 위해 사용한다.

    - 그리고 *{name}은 memberForm의 필드인 name과 대응되므로, 사용자로부터 입력값을 받아서 memberForm을 채운다.

    - th:class를 보면 이름을 입력하지 않은 경우 에러를 일으키기 위해 hasErrors()를 쓰는 것을 볼 수 있다.

    - th:errors는 @NotEmpty에 걸려 있는 에러 메시지를 출력한다.

 

    - name보다 아래에 있는 city, street, zipcode 등도 다 입력값을 받아서 memberForm을 채운다.

 

 

 

참고!

- MemberForm의 필요성.

 

- 왜 Member가 아니라 MemberForm을 쓸까?

- Member에는 회원을 등록하는 데 있어 필요하지 않은 필드까지 있다.

- 게다가, @NotEmpty와 같은 어노테이션을 Member에 붙이기 시작하면 엔티티 클래스가 지저분해진다.

- 그리고 강의에서는 엔티티 클래스를 화면과 같은 부가적인 기능에 의존적이지 않고, 최대한 순수하게 유지할 것을 권장한다.

 

- 생각해보면, 어차피 Member는 새로 파서 결국 우리가 입력 받아 집어넣는 건 한 줄의 문자열일 뿐이다.

- 그러니까 처음에는 문자열(MemberForm)로 받고, 나중에 Member를 만들어서 넣어도 무관하다.

 

 

 

 

 

3. 회원 목록 조회

@GetMapping("/members")
public String list(Model model) {
    List<Member> members = memberService.findMembers();     // 모든 멤버 조회해서 members에 담음
    model.addAttribute("members", members);     // members를 활용하는 화면에 넘김
    return "members/memberList";
}

- @GetMapping에 의해 "/members" 디렉토리에 접속했을 때 list()가 호출된다.

- MemberService의 findMembers()로 모든 회원을 조회해서 List로 저장한다.

- html에서 'members' 네임태그로 찾으면 members를 넘겨준다.

 

<tr th:each="member : ${members}">
  <td th:text="${member.id}"></td>
  <td th:text="${member.name}"></td>
  <td th:text="${member.address?.city}"></td>
  <td th:text="${member.address?.street}"></td>
  <td th:text="${member.address?.zipcode}"></td>
</tr>

- membersList.html에서 출력을 위해 list()로부터 members를 넘겨받는 모습이다.

 

 

참고!

- 이제서야 알게 된 사실인데,

- 이 메서드의 return 값인 "members/memberList"는 "/members"에 대한 GET 요청의 결과를 보여줄 뷰의 이름이다.

 

 

 

 

4. 상품 등록

BookForm

@Getter @Setter
public class BookForm {
    // COMMON in Item
    private Long id;
    private String name;
    private int price;
    private int stockQuantity;

    // ONLY Item
    private String author;
    private String isbn;
}

- 상품을 등록하는 데 필요한 필드만 정의해뒀다.

- MemberForm과 동일한 기능을 한다.

- 참고로, Item 중에서 책만 등록하기로 했기 때문에 책과 관련한 필드만 추가로 정의되어 있다.

 

 

 

ItemController

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

    @GetMapping("/items/new")
    public String createForm(Model model) {
        model.addAttribute("form", new BookForm());
        return "items/createItemForm";
    }

    @PostMapping("/items/new")
    public String create(BookForm form) {
        Book book = new Book();
        book.setName(form.getName());
        book.setPrice(form.getPrice());
        book.setStockQuantity(form.getStockQuantity());
        book.setAuthor(form.getAuthor());
        book.setIsbn(form.getIsbn());

        itemService.saveItem(book);
        return "redirect:/items";
    }
}

- @GetMapping을 통해 "items/new"로 이동하면 createForm()이 호출된다.

- createForm()에서는 createFormItem.html에 BookForm 객체를 넘겨서 상품 등록에 필요한 입력값들을 담는 데 사용된다.

 

- @PostMapping을 통해 "items/new"에 POST 명령어가 들어가면 create()가 호출된다.

- create()에서는 BookForm에 저장된 입력값들을 새로운 Book 객체의 필드로 옮겨서 saveItem()한다.

- return "redirect:/items"를 통해 메서드의 수행이 종료되면 "/items"로 넘어간다.

 

 

 

createItemForm.html

<form th:action="@{/items/new}" th:object="${form}" method="post">
        <div class="form-group">
            <label th:for="name">상품명</label>
            <input type="text" th:field="*{name}" class="form-control"
                   placeholder="이름을 입력하세요"> </div>
        <div class="form-group">
            <label th:for="price">가격</label>
            <input type="number" th:field="*{price}" class="form-control" placeholder="가격을 입력하세요">
        </div>
        <div class="form-group">
            <label th:for="stockQuantity">수량</label>
            <input type="number" th:field="*{stockQuantity}" class="form-
control" placeholder="수량을 입력하세요"> </div>
        <div class="form-group">
            <label th:for="author">저자</label>
            <input type="text" th:field="*{author}" class="form-control"
                   placeholder="저자를 입력하세요"> </div>
        <div class="form-group">
            <label th:for="isbn">ISBN</label>
            <input type="text" th:field="*{isbn}" class="form-control"
                   placeholder="ISBN을 입력하세요"> </div>
        <button type="submit" class="btn btn-primary">Submit</button>
    </form>

- 이는 html 파일의 일부로, BookForm 객체를 form 이름으로 들여와서 각 필드에 입력값을 담는 로직을 보여준다.

- 참고로 인텔리제이를 사용하면, {form}뿐만 아니라 {name} 등의 필드도 참조가 가능해서 바로 BookForm.class로 넘어갈 수 있다.

 

 

 

- 그래서 html 파일을 재컴파일하면 다음과 같은 상품등록 화면을 볼 수 있다.

- 그리고 아직은 "/items" 디렉토리를 구현하지 않았으므로 Submit을 누르면 whitelabel 페이지로 넘어가게 된다.

 

 

 

5. 상품 목록

@GetMapping("/items")
public String list(Model model) {
    List<Item> items = itemService.findItems();
    model.addAttribute("items", items);
    return "items/itemList";
}

- 상품 목록을 넘기는 list()를 ItemController에 추가해줬다.

- ItemService의 findItems()를 호출해서 모든 Item을 List로 받아서 itemList.html에 넘긴다.

 

 

 

itemList.html

<tr th:each="item : ${items}">
  <td th:text="${item.id}"></td>
  <td th:text="${item.name}"></td>
  <td th:text="${item.price}"></td>
  <td th:text="${item.stockQuantity}"></td>
  <td>
    <a href="#" th:href="@{/items/{id}/edit (id=${item.id})}" class="btn btn-primary" role="button">수정</a>
  </td> </tr>

- list()에서 보낸 items를 받아서 그대로 출력한다.

- '/items/{id}/edit'은 아래 UI에서 수정을 담당한다.

    - 그래서 수정 버튼을 클릭하면 "items/{id}/edit" 디렉토리로 이동한다.

    - 여기서 {id}는 해당 상품의 id가 들어간다.

 

 

 

6. 상품 수정

ItemController.updateItemForm()

@GetMapping("items/{id}/edit")
public String updateItemForm(@PathVariable("id") Long itemId, Model model) {
    Book item = (Book) itemService.findOne(itemId);

    BookForm form = new BookForm();
    form.setId(item.getId());
    form.setName(item.getName());
    form.setPrice(item.getPrice());
    form.setStockQuantity(item.getStockQuantity());
    form.setAuthor(item.getAuthor());
    form.setIsbn(item.getIsbn());

    model.addAttribute("form", form);
    return "items/updateItemForm";
}

- @GetMapping을 통해 해당 item을 수정하는 "items/{id}/edit"으로 넘어갔을 때 호출된다.

 

<a href="#" th:href="@{/items/{id}/edit (id=${item.id})}" class="btn btn-primary" role="button">수정</a>

- "items/{id}/edit"으로 넘어가는 itemsList.html이다.

 

 

 

- updateItemForm() 시그니쳐의 @PathVariable("id")는 "items/{id}/edit"에서 {id}에 있는 진짜 값을 itemId로 받는다.

- 그리고 itemId로 findOne()해서 가져온 item의 어트리뷰트를 새로운 BookForm 객체에 옮긴다.

- 그리고 다시 updateItemForm.html에 넘겨준다.

 

 

 

ItemController.updateItem()

@PostMapping("items/{id}/edit")
public String updateItem(@PathVariable("id") Long itemId, @ModelAttribute("form") BookForm bookForm) {   // Model에서 넘어오는 attribute를 자바에서 받기 위해
    Book book = new Book();

    book.setId(bookForm.getId());
    book.setName(bookForm.getName());
    book.setPrice(bookForm.getPrice());
    book.setStockQuantity(bookForm.getStockQuantity());
    book.setAuthor(bookForm.getAuthor());
    book.setIsbn(bookForm.getIsbn());

    itemService.saveItem(book);
    return "redirect:/items";
}

- updateItemForm.html의 POST 명령어를 처리할 updateItem()이다.

- 메서드 시그니쳐를 보면 @ModelAttribute가 등장하는데, 이때 모델로부터 BookForm 객체를 하나 받는다.

    - 그리고 이 객체는 updateItemForm()에서 완성해서 'form'의 이름으로 넘긴 BookForm 객체이다.

- BookForm의 값을 그대로 새로운 book에 옮기고, 이를 saveItem()한다.

 

 

 

@Transactional
public void saveItem(Item item) {
    itemRepository.save(item);
}

- saveItem()에서는 다시 리포지토리의 save()를 호출한다.

 

 

 

public void save(Item item) {
    if (item.getId() == null)       // persist() 이전에 item 인스턴스에는 id가 없기 때문에 if문으로 새로운 item이라는 보장을 받을 수 있다.
        em.persist(item);           // 새로운 객체로 등록한다.
    else
        em.merge(item);             // 이미 DB에 있고, update하는 것
}

- save()에서는 if문을 통해 id가 이미 있는 경우와 없는 경우를 나눈다.

- 그러면 지금의 케이스에서는 persist()와 다른, merge()가 호출될 것이다.

 

 

 

7. 변경 감지와 병합

준영속 엔티티

- 정의 : persistent context가 더이상 관리하지 않는 엔티티를 뜻한다.

 

@PostMapping("items/{id}/edit")
public String updateItem(@PathVariable("id") Long itemId, @ModelAttribute("form") BookForm bookForm) {   // Model에서 넘어오는 attribute를 자바에서 받기 위해
    Book book = new Book();

    book.setId(bookForm.getId());
    book.setName(bookForm.getName());
    book.setPrice(bookForm.getPrice());
    book.setStockQuantity(bookForm.getStockQuantity());
    book.setAuthor(bookForm.getAuthor());
    book.setIsbn(bookForm.getIsbn());

    itemService.saveItem(book);     // book은 자바에서 방금 만든 객체라도, 이미 DB에 식별자가 존재하는 객체이므로 준영속 엔티티다.
                                    // 준영속 엔티티는 JPA가 관리하지 않으므로 필드 값이 바뀌어도 수정 쿼리가 자동으로 나가지 않는다.
    return "redirect:/items";
}

- 지금의 상황을 예시로 들면, book이 준영속 엔티티에 해당한다.

- book은 이 메서드 안에서 새롭게 만든 객체이지만, setId()로 설정한 PK 값이 이미 DB에 있는 값이기 때문이다.

 

- 그런데 준영속 엔티티는 JPA에서 관리하지 않는다.

- 그래서 book은 수정되었다고 하더라도 단순한 persist()로 DB에 수정 쿼리가 자동으로 나가지 않는다.

 

- 그런데 지금까지 수정이 잘 되었는데, 그 이유는?

- 전 장에서 살펴본 merge() 호출 때문이었다.

- 그러나 merge()는 권장되지 않는 방법이었고, 솔루션은 따로 있었는데 ...

 

 

 

변경 감지 기능

- JPA는 em.find() 등을 통해서 들여온 객체를 영속성 컨텍스트 속에서 관리한다.

- 그리고 만약 그 객체의 필드 값이 바뀌었다면, 커밋 시점에 더티 체킹을 통해 수정 쿼리를 날려준다.

 

@Transactional
public void updateItem(Long itemId, Book paramBook) {
    Item findItem = itemRepository.findOne(itemId);
    findItem.setPrice(paramBook.getPrice());
    findItem.setName(paramBook.getName());
    findItem.setStockQuantity(paramBook.getStockQuantity());
}

- 그래서 이러한 방법으로 ItemService에 다음과 같이 updateItem()을 작성한다.

- 그러면 트랜잭션 안에서 찾고, 값을 바꾸기 때문에 persist(), save() 등을 호출하지 않아도 알아서 수정 쿼리가 날라간다.

 

 

 

merge() 사용

- merge()는 단순하게 말하자면 파라미터로 넘어온 준영속 상태의 객체를 영속 상태로 변경하는 기능을 가진다.

    - 즉 updateItem()에서 했던 것처럼 영속 상태의 새로운 객체에 값을 전부 덮어쓰는 일을 한다.

 

 

 

참고!

- 변경 감지 기능은 원하는 속성만 선택해서 변경할 수 있다.

- 그러나 merge()의 경우, 파라미터의 필드 중 일부가 비어 있다면 그 "null"도 동일하게 엔티티에 업데이트 되어버린다.

- 즉, merge()는 일부만 덮어 쓰는 것이 불가능하다.

 

@PostMapping("items/{id}/edit")
public String updateItem(@PathVariable("id") Long itemId, @ModelAttribute("form") BookForm bookForm) {   // Model에서 넘어오는 attribute를 자바에서 받기 위해
    Book book = new Book();

    book.setId(bookForm.getId());
    book.setName(bookForm.getName());
    book.setPrice(bookForm.getPrice());
    // book.setStockQuantity(bookForm.getStockQuantity());
    book.setAuthor(bookForm.getAuthor());
    book.setIsbn(bookForm.getIsbn());

    itemService.saveItem(book);     // book은 자바에서 방금 만든 객체라도, 이미 DB에 식별자가 존재하는 객체이므로 준영속 엔티티다.
                                    // 준영속 엔티티는 JPA가 관리하지 않으므로 필드 값이 바뀌어도 수정 쿼리가 자동으로 나가지 않는다.
    return "redirect:/items";
}

- 그래서 만약 이 updateItem()처럼 수량을 설정하지 않고 saveItem()을 호출하면, 수량이 null로 수정될 수 있다.

- 그러니 merge()가 권장될 수가 없다.

 

 

 

@Transactional
public void updateItem(Long itemId, Book paramBook) {
    Item findItem = itemRepository.findOne(itemId);
    findItem.setPrice(paramBook.getPrice());
    findItem.setName(paramBook.getName());
    findItem.setStockQuantity(paramBook.getStockQuantity());
}

- 다시 변경 감지 방법으로 돌아와서, 추가적으로 신경쓸 것이 있다.

- 지금 updateItem()에는 Setter가 무분별하게 쓰이고 있다.

- Setter는 최대한 엔티티 클래스 안에 두는 것이 맞으니, 차라리 바꿔야 할 필드가 확실하다면 changeItem() 등의 메서드를 엔티티 클래스 안에 두도록 하자.

 

 

 

솔루션

1) 컨트롤러에서 어설프게 엔티티를 생성하지 말자.

2) 트랜잭션이 있는 서비스 계층에 식별자와 변경할 데이터를 명확하게 전달하자.

3) 트랜잭션이 있는 서비스 계층에서 영속 상태의 엔티티를 조회하고, 엔티티의 데이터를 직접 변경하자.

4) 트랜잭션 커밋 시점에 변경 감지가 실행된다.

 

 

 

- 위의 ItemController의 updateItem()을 보면 book을 새롭게 생성하고 있다.

- 그보다는 ItemService 안에 updateItem() 메서드를 생성하고, 컨트롤러에서는 form의 값을 넘겨서 위임하는 방식을 추천한다.

 

 

 

솔루션 계층

@Getter @Setter
public class UpdateItemDTO {
    public UpdateItemDTO(String name, int price, int stockQuantity) {
        this.name = name;
        this.price = price;
        this.stockQuantity = stockQuantity;
    }

    private String name;
    private int price;
    private int stockQuantity;
}

- 회원 변경시에 수정될 필드만 정의해둔 DTO 클래스를 새롭게 판다.

 

 

 

public void change(UpdateItemDTO itemDTO) {
    this.setName(itemDTO.getName());
    this.setPrice(itemDTO.getPrice());
    this.setStockQuantity(itemDTO.getStockQuantity());
}

- Item 엔티티 클래스에서 DTO를 참조하여 Setter를 호출할 change() 메서드를 정의한다.

 

 

 

@Transactional
public void updateItem(Long itemId, UpdateItemDTO itemDTO) {
    Item findItem = itemRepository.findOne(itemId);

    findItem.change(itemDTO);
}

- Item 엔티티 클래스의 change()를 호출하는 ItemService의 updateItem()은 DTO만 넘겨서 필드 변경을 위임한다.

 

 

 

@PostMapping("items/{id}/edit")
public String updateItem(@PathVariable("id") Long itemId, @ModelAttribute("form") BookForm bookForm) {   // Model에서 넘어오는 attribute를 자바에서 받기 위해
    UpdateItemDTO itemDTO = new UpdateItemDTO(bookForm.getName(), bookForm.getPrice(), bookForm.getStockQuantity());
    itemService.updateItem(itemId, itemDTO);

    return "redirect:/items";
}

- 컨트롤러의 updateItem()은 model에서 받은 form을 DTO로 변환한다.

- 그리고 ItemService의 updateItem()을 호출한다. 

- 이처럼 리팩토링한다면 엔티티를 직접 생성할 필요도 없고, 훨씬 깔끔한데다가 유지보수성도 늘어난다.

 

 

 

8. 상품 주문

상품 주문 컨트롤러

@Controller
@RequiredArgsConstructor
public class OrderController {
    private final OrderService orderService;
    private final MemberService memberService;
    private final ItemService itemService;

    @GetMapping("/order")
    public String createForm(Model model) {
        List<Member> members = memberService.findMembers();
        List<Item> items = itemService.findItems();

        model.addAttribute("members", members);
        model.addAttribute("items", items);

        return "order/orderForm";
    }

    @PostMapping("/order")
    public String order(@RequestParam("memberId") Long memberId,        // form-submit 방식으로 <select>나 <input> 태그에 붙어 있는 값들이 넘어온다
                        @RequestParam("itemId") Long itemId,
                        @RequestParam("count") int count) {

        orderService.order(memberId, itemId, count);                    // 컨트롤러에서는 PK만 넘겨주고, 실제 조회는 서비스 계층에서 @Transactional 안에서 이뤄져야만
        return "redirect:/orders";                                      // JPA가 영속성 컨텍스트 내에서 이들을 관리할 수 있다.
    }                                                                   // 예를 들어, 지금 여기서 Member 객체를 서비스 계층 order()로 넘겨주면, 얘는 영속성 컨텍스트에서 관리가 안된다.
}

- 하나의 주문에는 한 명의 회원과 한 개의 상품이 관련되기 때문에 @RequiredArgsController에 회원과 아이템 서비스도 걸려 있다.

- "/order"로 넘어갈 때 createForm()이 호출된다.

- 회원과 상품 목록을 넘겨서 위 페이지처럼 어떤 회원이 어떤 주문을 할 지 선택할 수 있게 한다.

 

 

createForm.html

<div class="form-group">
  <label for="member">주문회원</label>
  <select name="memberId" id="member" class="form-control">
    <option value="">회원선택</option>
    <option th:each="member : ${members}"
            th:value="${member.id}"
            th:text="${member.name}" />
  </select>
</div>

<div class="form-group">
  <label for="item">상품명</label>
  <select name="itemId" id="item" class="form-control"> <option value="">상품선택</option>
    <option th:each="item : ${items}" th:value="${item.id}" />
  </select>
</div>

<div class="form-group">
  <label for="count">주문수량</label>
  <input type="number" name="count" class="form-control" id="count" placeholder="주문 수량을 입력하세요">
</div>

- "/order"에서 submit했을 때 order()가 호출된다.

- order() 시그니쳐의 파라미터를 보면 @RequiredParam이 붙어 있다.

 

- orderForm.html의 내용인데, <select>와 <input> 태그를 보면 name에 memberId, itemId, count를 찾을 수 있다.

- 얘네들이 html에서 order()로 넘어와서 각각 대응되는 자바 변수로 변환되는 것이다.

 

- 그리고 order()의 로직은 의외로 간단하다.

- 서비스 계층의 order()에게 핵심 비즈니스 로직을 전부 넘기고 있는 형국이다.

 

- 그리고 이러한 방식이 최대한 권장된다.

- OrderService의 order()에는 @Transactional 안에서 로직이 진행된다.

- 그래서 서비스 계층에서 회원과 상품 등을 조회할 때 이 객체들을 JPA의 영속성 컨텍스트 안에서 관리할 수 있게 된다.

 

 

 

9. 주문 목록 검색, 취소

주문 목록 검색 컨트롤러

@GetMapping("/orders")
public String orderList(@ModelAttribute("orderSearch") OrderSearch orderSearch,
                        Model model) {
    List<Order> orders = orderService.findOrders(orderSearch);
    model.addAttribute("orders", orders);

    return "/order/orderList";
}

- "/orders"에 접속해서 회원명 혹은 주문상태를 설정하고 "검색"을 눌렀을 때 orderList()가 호출된다.

 

- @ModelAttribute를 통해 클라이언트가 완성해준 OrderSearch 객체를 받아서 주문을 검색하는 데 사용한다.

    - OrderSearch에는 회원명과 주문상태가 필드로 들어가있다.

    - 그래서 클라이언트가 회원명 혹은 주문상태를 입력하면, Model의 어트리뷰트가 되는 것이고, 이를 orderList()에서 사용한다.

 

- 서비스 계층의 findOrders()에게 조회 서비스를 위임하면서 OrderSearch를 넘겨준다.

    - 참고로 findOrders()는 이전 장에서 QueryDSL 언급하면서 우선은 문자열 방식과 Criteria 방식으로 구현한 바 있다.

    - 실제로 로직이 구현된 것은 리포지토리 계층이다.

 

- findOrders()로 주문 List를 받아온 것은, 다시 addAttribute()하여 "/orders"의 화면에 뿌려준다.

 

 

 

orderList.html

<div>
  <div>
    <form th:object="${orderSearch}" class="form-inline">
      <div class="form-group mb-2">
        <input type="text" th:field="*{memberName}" class="form- control" placeholder="회원명"/>
      </div>
      <div class="form-group mx-sm-1 mb-2">
        <select th:field="*{orderStatus}" class="form-control">
          <option value="">주문상태</option>
          <option th:each="status : ${T(jpabook.jpashop.domain.OrderStatus).values()}"
                  th:value="${status}"
                  th:text="${status}">option
          </option>
        </select>
      </div>
    <button type="submit" class="btn btn-primary mb-2">검색</button>
    </form>
</div>

- orderList.html에서 위 사진 부분을 맡고 있다.

- 회원명과 주문상태를 조합하여 검색해서 submit한다.

 

- 만약 'konu'라는 회원명을 가진 주문이 있고, 이를 활용해서 '검색'했을 경우

    - "http://localhost:8080/orders?memberName=konu&orderStatus="

    - 이와 같은, 검색 조건이 반영된 새로운 url을 얻는 것을 볼 수 있다.

 

 

 

주문 취소

<a th:if="${item.status.name() == 'ORDER'}" href="#"
   th:href="'javascript:cancel('+${item.id}+')'"
   class="btn btn-danger">CANCEL</a>

- orderList.html에서 위 사진의 'CANCEL' 부분을 맡고 있다.

- 만약 버튼이 눌릴 경우, javascript의 cancel이 호출되는데 이는 html 파일 최하단에 있다.

 

 

 

<script>
  function cancel(id) {
    var form = document.createElement("form");
    form.setAttribute("method", "post");
    form.setAttribute("action", "/orders/" + id + "/cancel");
    document.body.appendChild(form);
    form.submit();
  }
</script>

- cancel()은 form을 만들어서 "/orders/{id}/cancel"에 POST 명령어를 호출하게 된다.

 

 

 

@PostMapping("/orders/{id}/cancel")
public String cancelOrder(@PathVariable("id") Long orderId) {
    orderService.cancelOrder(orderId);
    
    return "redirect:/orders";
}

- 그리고 이는 컨트롤러에서 @PostMapping으로 맡아서 사용할 수 있다.

- cancelOrder()에서는 @PathVariable로 url의 {id}에 해당하는 id 값을 파라미터로 받는다.

- 그리고 서비스 계층의 cancelOrder()를 호출해서 식별자만 넘기면서 위임한다.

    - cancelOrder()는 Order 객체를 findOne()해서 찾고, 도메인에 정의된 인스턴스 메서드 cancel()를 호출한다.

    - cancel()에서는 주문 객체가 가진 필드로서의 주문 상태를 변경하고, 연관된 주문 상품들 각각에 cancel()을 호출한다.

'스프링 (인프런) > 스프링부트' 카테고리의 다른 글

API 개발 고급 - 준비  (0) 2023.07.13
API 개발 기본  (1) 2023.07.13
주문 도메인 개발  (0) 2023.07.07
상품 도메인 개발  (0) 2023.07.06
애플리케이션 구현 준비  (0) 2023.07.05