본문 바로가기
Dev/JPA

[JPA] JPA 활용 I - 웹 계층 개발 (1)

by dev_jsk 2021. 9. 24.
728x90
반응형

홈 화면과 레이아웃

Thymeleaf를 이용하여 홈 화면을 구성한다.

홈 컨트롤러 구성

package jpabook.jpashop.controller;

@Controller
@Slf4j
public class HomeController {

    @RequestMapping("/")
    public String home() {
        log.info("home controller");
        return "home";
    }
}
  • Slf4j를 이용하여 로그를 출력한다. 이때 Lombok의 @Slf4j 어노테이션을 사용하면 Logger logger = LoggerFactory.getRootLogger(getClass());와 동일한 동작을 한다.
  • 리턴한 문자열과 스프링 부트의 타임리프 설정 값을 통해 렌더링 할 뷰를 찾는다.

스프링 부트 타임리프 설정

spring.thymeleaf.prefix: classpath:/templates/
spring.thymeleaf.suffix: .html
  • 설정된 prefix, suffix를 사용하여 resources/templates/ + ViewName + .html이 뷰 파일 경로가 된다.

타임리프 템플릿 등록

resources/templates/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 -->
  </body>
</html>
  • th:replace는 import 의미이며 import 한 파일의 지정된 fragments 속성 값을 명시한다.

resources/templates/fragments/header.html

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

resources/templates/fragments/bodyHeader.html

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

resources/templates/fragments/footer.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
  <div class="footer" th:fragment="footer">
    <p>&copy; Hello Shop V2</p>
  </div>
</html>

View 리소스 등록

  • Bootstrap을 다운받아 resources/static내에 추가 (Bootstrap은 4버전 다운)
  • 사용자 정의 CSS 파일(jumbotron-narrow.css)을 생성하여 resources/static내에 추가

jumbotron-narrow.css

/* Space out content a bit */
body {
  padding-top: 20px;
  padding-bottom: 20px;
}
/* Everything but the jumbotron gets side spacing for mobile first views */
.header,
.marketing,
.footer {
  padding-left: 15px;
  padding-right: 15px;
}
/* Custom page header */
.header {
  border-bottom: 1px solid #e5e5e5;
}
/* Make the masthead heading the same height as the navigation */
.header h3 {
  margin-top: 0;
  margin-bottom: 0;
  line-height: 40px;
  padding-bottom: 19px;
}
/* Custom page footer */
.footer {
  padding-top: 19px;
  color: #777;
  border-top: 1px solid #e5e5e5;
}
/* Customize container */
@media (min-width: 768px) {
  .container {
    max-width: 730px;
  }
}
.container-narrow > hr {
  margin: 30px 0;
}
/* Main marketing message and sign up button */
.jumbotron {
  text-align: center;
  border-bottom: 1px solid #e5e5e5;
}
.jumbotron .btn {
  font-size: 21px;
  padding: 14px 24px;
}
/* Supporting marketing content */
.marketing {
  margin: 40px 0;
}
.marketing p + h4 {
  margin-top: 28px;
}
/* Responsive: Portrait tablets and up */
@media screen and (min-width: 768px) {
  /* Remove the padding we set earlier */
  .header,
  .marketing,
  .footer {
    padding-left: 0;
    padding-right: 0;
  }
  /* Space out the masthead */
  .header {
    margin-bottom: 30px;
  }
  /* Remove the bottom border on the jumbotron for visual effect */
  .jumbotron {
    border-bottom: 0;
  }
}

타임리프 레이아웃

  • Include-style layouts
    - 파일을 import하여 레이아웃을 구성하는 것
  • Hierarchical-style layouts
    - 계층형으로 구성하여 중복을 제거하여 필요한 부분만 코딩하여 레이아웃을 구성하는 것

홈 화면 구성 결과

홈 화면

회원가입

엔티티를 사용하는 것이 아닌 폼 객체를 사용하여 화면 계층과 서비스 계층을 분리하여 구성한다.

회원가입 폼 객체

package jpabook.jpashop.controller;

@Getter @Setter
public class MemberForm {
    
    // javax가 validation 해준다.
    @NotEmpty(message = "회원 이름은 필수 입니다.")
    private String name;
    private String city;
    private String street;
    private String zipcode;
}
  • @NotEmpty
    - javax가 제공하는 어노테이션으로 필수 여부를 체크한다.

회원가입 컨트롤러 구성

package jpabook.jpashop.controller;

@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) {

        // error 처리
        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:/";    // 첫페이지로 이동
    }
}
  • GetMapping을 사용하여 회원가입 페이지로 이동하고 PostMapping을 사용하여 회원가입 처리를 한다.
  • @Valid
    - javax가 제공하는 Validation 어노테이션으로 Validation 대상임을 명시하여 해당 객체 내 다른 Validation 어노테이션을 확인하여 스프링에서 Validation을 실행한다.
  • BindingResult
    - @Valid 다음에 BindingResult가 있으면 오류 발생 시 튕겨내는 것이 아닌 아래 코드를 실행한다. 따라서 BindingResult.hasErrors()를 사용하여 에러 발생 시 처리가 가능하며 스프링이 BindingResult를 화면까지 가져가기 때문에 화면에서 어떠한 에러가 있는지 확인할 수 있다.

엔티티 대신 폼 객체를 사용하는 이유

요구사항이 단순할 때는 폼 객체 없이 엔티티를 사용해도 무방하지만 화면 요구사항이 복잡해지기 시작하면 엔티티에 화면을 위한 기능이 점점 늘어나게 된다. 이렇게 되면 엔티티가 화면에 종속적으로 변하게 되면서 지저분해져 유지보수하기 어려워진다. 실무에서는 엔티티는 핵심 비즈니스 로직만 갖고 있고 화면을 위한 로직은 존재하지 않도록 최대한 순수하게 구성하며 화면이나 API에 맞는 폼 객체나 DTO를 사용한다.

※ DTO란 데이터 전송을 위한 Getter, Setter만 있는 객체

※ 엔티티를 API에 사용 시 엔티티 변경이 발생하면 API 스펙이 변하는 문제와 중요한 필드값이 노출되는 문제가 발생하기 때문에 엔티티를 API에 사용하면 안된다.

회원가입 화면

<!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>
  </body>
</html>
  • 타임리프 *{} 문법은 해당 오브젝트 필드를 사용한다. Getter, Setter를 이용한 프로퍼티 접근법
  • th:field는 id와 name 속성을 지정한다.

회원가입 구성 결과

회원가입 화면

회원가입 화면

 

회원가입 동작 결과

회원가입 insert SQL 및 파라미터 바인딩 내역 로그
회원가입한 데이터

 

회원가입 Validation

Validation 미처리 시 에러 페이지 표시
Validation 처리 시 @NotEmpty에 설정한 메시지 표시

회원목록 조회

회원목록 컨트롤러 구성

package jpabook.jpashop.controller;

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

    @GetMapping("/members")
    public String list(Model model) {
        List<Member> members = memberService.findMembers();
        model.addAttribute("members", members);
        return "members/memberList";
    }
}
  • 조회한 데이터를 뷰에 전달하기 위해 스프링 MVC가 제공하는 Model 객체에 저장

회원목록 화면 구성

<!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>
  </body>
</html>
  • 타임리프는 자바의 foreach와 유사한 문법을 제공한다.
  • 타임리프에서 ?는 NULL일 경우 진행하지 않고 무시한다.

회원목록 화면

가입한 회원의 정보가 표시된다.

 

728x90
반응형

댓글