카테고리 없음

웹 계층 개발

Stair 2024. 11. 25. 11:03
반응형

https://surrealcode.tistory.com/98

 

애플리케이션 구현(도메인 개발)

구현 전 앞서 만든 예제를 통해 도메인 분석과 설계를 하여 구현 준비를 한다https://surrealcode.tistory.com/97 도메인 분석 설계JPA 프로그래밍의 이론을 알기 전 실전 코딩을 통해 직접 코드를 만들어

surrealcode.tistory.com

애플리케이션까지 구현이 마무리 되었다면, 웹 계층을 개발하도록 한다.

Controller 관련과 front 관련 개발이다.

thymeleaf를 사용할 예정이다.

 

개발 순서는 다음과 같이 진행한다.

홈 화면과 레이아웃 -> 회원 등록 -> 회원 목록 조회 -> 상품 등록 -> 상품 목록 -> 상품 수정 -> 변경 감지와 병합(merge) -> 상품 주문 -> 주문 목록 검색, 취소

 

컨트롤러를 통해 웹과 연결을 하기 때문에 Controller 부분을 개발할 것이다. 우선 기본 홈 화면을 위한 컨트롤러를 생성한다.

@Controller
@Slf4j
public class HomeController {

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

@RequestMapping("/")이기에

localhost8080/를 찍게 되면 home.html을 렌더링해준다.

 

@Sl4j 는 롬복에서 지원해주는 로그 기능으로 로그를 찍을 수 있게 해준다.

<!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 -->
</body>
</html>

home에 대한 html파일은 위와 같다.

href 태그들을 잘 확인하자 위 태그를 통해 버튼을 클릭하면 URL 변경이 될 예정이다.

회원가입은 /member/new

회원 목록은 /members

상품 등록은 /items/new

상품 목록은 /items

상품 주문은 /order

주문 내역은 /orders이다.

 

좀 더 살펴보면 header, body, footer에 fragment가 있는데, fragment가 UI를 예쁘게 꾸며주는데 도움을 주는 것이다.

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head th:fragment="header">
  <!-- Required meta tags -->
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, shrinkto-fit=no">
  <!-- Bootstrap CSS -->
  <link rel="stylesheet" href="/css/bootstrap.min.css" integrity="sha384-
ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T"
        crossorigin="anonymous">
  <!-- Custom styles for this template -->
  <link href="/css/jumbotron-narrow.css" rel="stylesheet">
  <title>Hello, world!</title>
</head>
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<div class="header" th:fragment="bodyHeader">
    <ul class="nav nav-pills pull-right">
        <li><a href="/">Home</a></li>
    </ul>
    <a href="/"><h3 class="text-muted">HELLO SHOP</h3></a>
</div>
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<div class="footer" th:fragment="footer">
    <p>&copy; Hello Shop V2</p>
</div>

 

frament들은 위와 같다.

부트스트랩은 v4.3.1버전을 사용하자.(https://getbootstrap.com/)

 

 

회원 등록

- 폼 객체를 만들고 사용하여 화면 계층과 서비스 계층을 명확하게 분리할 계획이다.

@Getter @Setter
public class MemberForm {

    @NotEmpty(message = "회원 이름은 필수 입니다.")
    private String name;

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

 

 

NotEmpty는 필드에 값이 없으면 안된다는 조건을 강제하는 어노테이션이다.

 

위 home.html에 확인했듯 회원 가입의 URL은 localhost:8080/members/new이다.

@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 form, BindingResult result) {

        if (result.hasErrors()) {
            return "members/createMemberForm";
        }

        Address address = new Address(form.getCity(), form.getStreet(), form.getZipcode());
        Member member = new Member();
        member.setName(form.getName());
        member.setAddress(address);
        memberService.join(member);
        return "redirect:/";
    }


    @GetMapping("/members")
    public String list(Model model) {
        List<Member> members = memberService.finaAll();
        model.addAttribute("members", members);
        return "members/memberList";
    }
}
<!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>

 

** Model **

model.addAttribute("memberForm", new MemberForm());

return "members/createMemberForm

--> controller에서 view로 넘어갈때 new MemberForm() 객체를 memberForm이라는 이름으로 members/createMemberForm.html로 객체를 실어 넘긴다.

 

 

@PostMapping("members/new")
public String create(@Valid MemberForm form, BindingResult result) {
     if (result.hasErrors()) {
        return "members/createMemberForm";
    }

    Address address = new Address(form.getCity(), form.getStreet(), form.getZipcode());
    Member member = new Member();
    member.setName(form.getName());
    member.setAddress(address);
    memberService.join(member);
    return "redirect:/";
}

 

PostMapping은 클라이언트가 요청한 데이터를 서버에 전달할때 사용된다. getMapping을 통해 전달받은 memberForm 객체에 데이터를 넣고 있다.

@valid는 제약조건의 유효성을 검증한다. 즉 회원 이름은 필수인 제약조건의 유효성이다. 또한 BindingResult를 통해 제약조건에 문제가 발생하면 기존 화면이 리다이렉션 되며 에러 문구가 나타날 것이다.

 

** 현재 PostMapping에서 set을 통해 데이터를 저장하였으나, 이는 그렇게 좋은 방법의 코딩은 아니다. Controller에선 controller가 하는 역할에만 충실할 수 있도록 해주어야 한다.

 

 

회원 목록 조회

회원 목록 조회는 간단하다. 위의 컨트롤러에 아래 코드를 추가해준다.

@GetMapping("/members")
public String list(Model model) {
    List<Member> members = memberService.finaAll();
    model.addAttribute("members", members);
    return "members/memberList";
}

memberService.findAll()의 리턴을 List로 받는다.

받은 members를 member/memberList.html에 뿌려주면 되는데, 값이 여러개일때 뿌려주는 것을 어떻게 처리할까?

이 부분은 thymeleaf에서 처리를 해준다.

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="fragments/header :: header" />
<body>
<div class="container">
  <div th:replace="fragments/bodyHeader :: bodyHeader" />
  <div>
    <table class="table table-striped">
      <thead>
      <tr>
        <th>#</th>
        <th>이름</th>
        <th>도시</th>
        <th>주소</th> <th>우편번호</th>
      </tr>
      </thead>
      <tbody>
      <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>
      </tbody>
    </table>
  </div>
  <div th:replace="fragments/footer :: footer" />
</div> <!-- /container -->
</body>
</html>

위의 코드를 잘 보면 마치 향상된 for문인 for each문과 같이 되어있다. 데이터가 여러개더라도, 위처럼 thymeleaf를 통해서 뿌려줄 수 있다.

 

 

상품 등록

- 상품 등록 또한 폼 객체를 만들고 사용하여 화면 계층과 서비스 계층을 명확하게 분리한다.(현재 아이템은 책 하나로 제한하였다)

@Getter
@Setter
public class BookForm {

    private Long id;
    private String name;
    private int price;
    private int stockQuantity;
    private String isbn;
    private String author;
}
@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:/";
    }
}

상품 등록 또한 위와 같은 방법으로 등록한다.

 

 

상품 목록

다음은 상품 목록이다. 위에 만들어 놓았던 ItemController에 아래의 코드를 추가하여준다.

@GetMapping("/items")
public String list(Model model) {
    List<Item> items = itemService.findItems();
    model.addAttribute("items", items);
    return "items/itemList";
}
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="fragments/header :: header" />
<body>
<div class="container">
  <div th:replace="fragments/bodyHeader :: bodyHeader"/>
  <div>
    <table class="table table-striped">
      <thead>
      <tr>
        <th>#</th>
        <th>상품명</th>
        <th>가격</th>
        <th>재고수량</th>
        <th></th>
      </tr>
      </thead>
      <tbody>
      <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>
      </tbody>
    </table>
  </div> <div th:replace="fragments/footer :: footer"/>
</div> <!-- /container -->
</body>
</html>

여기도 마찬가지로 thymeleaf에서 items를 받아 뿌려준다.

 

 

상품 수정

다음은 상품 수정이다. 마찬가지로 ItemController에 상품 수정 폼을 추가해준다.

//    @GetMapping("/items/{itemId}/edit")
//    public String updateItemForm(@PathVariable("itemId") 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";
//    }
    @PostMapping("/items/{itemId}/edit")
    public String updateItem(@PathVariable Long itemId, @ModelAttribute("form") BookForm form) {
//        Book book = new Book();
//        book.setId(form.getId());
//        book.setName(form.getName());
//        book.setPrice(form.getPrice());
//        book.setStockQuantity(form.getStockQuantity());
//        book.setIsbn(form.getIsbn());
//        book.setAuthor(form.getAuthor());
//
//        itemService.saveItem(book);

        itemService.updateItem(itemId,form.getName(), form.getPrice(), form.getStockQuantity());

        return "redirect:/items";
    }
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="fragments/header :: header" />
<body>
<div class="container">
  <div th:replace="fragments/bodyHeader :: bodyHeader"/>
  <form th:object="${form}" method="post">
    <!-- id --> <input type="hidden" th:field="*{id}" />
    <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="formcontrol"
             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>
  <div th:replace="fragments/footer :: footer" />
</div> <!-- /container -->
</body>
</html>

-- 여기서 @PathVariable이란 경로 변수를 표기하기 위해 사용된 것이다.

 

1. 수정 버튼을 선택하면 /items/{itemId}/edit URL을 GET 방식으로 요청한다.

2. 그 결과로 updateItemForm()메서드를 실행하는데 이 메서드는 itemService.findOne(itemId)를 호출해서 수정할 상품을 조회한다.

3. 조회 결과를 모델 객체에 담아서 뷰(items/updateItemForm)에 전달한다.

 

상품 수정은 다음과 같다.

상품 수정 폼HTML에는 상품의 id, 상품명, 가격, 수량 정보가 있다.

1. 상품 수정 폼에서 정보를 수정하고 submit 버튼을 선택하면

2. /items/{itemId}/edit URL을 POST 방식으로 요청하고 updateItem()메서드를 실행한다.

3. 이때 컨트롤러에 파라미터로 넘어온 item 엔티티 인스턴스는 현재 준영속 상태이다. 따라서 영속성 컨텍스트의 지원을 받을 수 없고, 데이터를 수정해도 변경감지 기능은 동작하지 않는다.

 

 

변경 감지와 병합(merge)

정말 중요한 내용이니, 완벽하게 이해해야 한다.

 

준영속 엔티티 : 영속성 컨텍스트가 더는 관리하지 않는 엔티티를 말한다.(JPA를 거쳐서 데이터베이스에 한번 갔다온 놈, JPA가 식별할 수 있는 ID를 가지고있다.)

(우리 코드에서는 itemService.saveItem(book)에서 수정을 시도하는 Book 객체가 준영속 엔티티이다. Book 객체는 이미 DB에 한번 저장되어서 식별자가 존재한다. 이렇게 임의로 만들어낸 엔티티도 기존 식별자를 가지고 있으면 준영속엔티티로 볼 수 있다.)

 

준영속 엔티티를 수정하는 2가지 방법

1. 변경 감지 기능 사용(dirty checking)

2. 병합('merge') 사용

 

1. 변경 감지 기능 사용

@Transactional
void update(Item itemParam) { //itemParam: 파리미터로 넘어온 준영속 상태의 엔티티
 Item findItem = em.find(Item.class, itemParam.getId()); //같은 엔티티를 조회한다.
 findItem.setPrice(itemParam.getPrice()); //데이터를 수정한다.
}

 

영속성 컨텍스트에서 엔티티를 다시 조회한 후에 데이터를 수정하는 방법

트랜잭션 안에서 엔티티를 다시 조회, 변경할 값 선택 -> 트랜잭션 커밋 시점에 변경감지(Dirty Checking)이 동작해서 데이터베이스에 UPDATE SQL 실행

 

 

2. 병합('merge') 사용

병합은 준영속 상태의 엔티티를 영속 상태로 변경할 때 사용하는 기능이다.

 

@Transactional
void update(Item itemParam) { //itemParam: 파리미터로 넘어온 준영속 상태의 엔티티
 Item mergeItem = em.merge(itemParam);
}

병합 동작 방식

1. merge()를 실행한다.

2. 파라미터로 넘어온 준영속 엔티티의 식별자 값으로 1차 캐시에서 엔티티를 조회한다.

2-1. 만약 1차 캐시에 엔티티가 없으면 데이터베이스에서 엔티티를 조회하고, 1차 캐시에 저장한다.

3. 조회한 영속 엔티티(mergeMember)에 member 엔티티의 값을 채워넣는다.

4. 영속상태인 mergeMember를 반환한다.

 

준영속 엔티티의 식별자 값으로 영속 엔티티 조회 -> 영속 엔티티의 값을 준영속 엔티티의 값으로 모두 교체 -> 트랜잭션 커밋 시점에 변경 감지 기능이 동작해서 DB에 update SQL 실행

 

주의 : 변경 감지 기능을 사용하면 원하는 속성만 선택해서 변경할 수 있지만, 병합을 사용하면 모든 속성이 변경된다. 병합시 값이 없으면 'null'로 업데이트 할 위험도 있다.

 

 

정리

엔티티를 변경할 때는 항상 변경 감지를 사용하자.

 

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

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

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

 

 

 

 

 

 

 

 

 

 

 

 

반응형