RE-Heat 개발자 일지

[JPA 활용1] [5] 웹 계층 개발(상편) 본문

백엔드/JPA

[JPA 활용1] [5] 웹 계층 개발(상편)

RE-Heat 2023. 8. 26. 20:01

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-JPA-%ED%99%9C%EC%9A%A9-1/

 

실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발 - 인프런 | 강의

실무에 가까운 예제로, 스프링 부트와 JPA를 활용해서 웹 애플리케이션을 설계하고 개발합니다. 이 과정을 통해 스프링 부트와 JPA를 실무에서 어떻게 활용해야 하는지 이해할 수 있습니다., 스프

www.inflearn.com

인프런 김영한 님의 강의를 듣고 작성한 글입니다.

 

[1] 홈 화면과 레이아웃

■ 홈 컨트롤러 등록

HomeController

@Controller
@Slf4j
public class HomeController {

    @RequestMapping("/")
    public String home() {
        log.info("home controller");
        return "home";
    }
}
  • resources/templates/home.html로 연결

 

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>

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, shrink-to-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>

 

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>

 

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>

 

참고]

1. 부트스트랩 4.31 버전을 다운로드하고 css·js를 템플릿에 추가

  • jumbotron은 부트스트랩 5버전 이후부턴 사용 X

2. 부트스트랩이 적용이 안 되면 resources를 우클릭 후 Reload From Disk를 해주면 해결된다. 

3. jumbotron을 적용하려면 resources/static/css/jumbotron-narrow.css를 추가해줘야 한다.

 

참고] 뷰템플릿 변경사항을 서버 재시작 없이 즉시 반영하기
 1. spring-boot-devtools 추가 

 2. html 파일 build-> Recompile

 

■ 결과화면

 

[2] 회원 등록

MemberForm

@Getter @Setter
public class MemberForm {

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

    private String city;
    private String street;
    private String zipcode;
}
  • 실무에선 엔티티는 핵심 비즈니스 로직만 가지게 하고, 화면에 맞는 폼객체나 DTO를 따로 만든다. 그 이유는 엔티티가 화면에 종속적으로 변하는 것을 피하기 위해서다.
  • Bean Validation 등록 필요

  • @NotEmpty로 회원 이름을 필수 입력하도록 만듦.

참고] BeanValidation 관련 내용은 

스프링 MVC 2편 - [5] 검증2- Bean Validation(상편)

스프링 MVC 2편 - [5] 검증2- Bean Validation(하편) 확인

 

MemberController

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

    @GetMapping("/members/new")
    public String createForm(Model model) {
        model.addAttribute("memberForm", new MemberForm());//validation 용
        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:/";
    }
}

① GET /members/new

  • model에 validation용 MemberForm의 빈 객체를 담아 넘긴다.
  • 참고로 타임리프에서 form태그에 작성하는 th:object는 Model 객체에서 넘어온 객체의 필드값을 참조하게 한다.

② POST /members/new

  • @Valid는 @NotEmpty 같은 애노테이션을 확인 후 검증을 수행한다. 검증 오류가 발생하면 Error를 생성해 BindingResult에 담아준다. 그리고 그 값을 createMemberForm.html로 넘긴다.
  • 모든 작업이 완료되면 홈 화면으로 redirect한다.  

 

members/createMemberForm.html

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

① 회원 이름을 빼먹으면 @NotEmpty의 message에 담은 "회원 이름은 필수입니다"라는 에러 메시지가 넘어가게 세팅

② th:filed *{name}처럼 *이 붙으면 th:object를 참조한다.

 

■ 에러 발생 화면

[3] 회원 목록 조회

MemberController

@GetMapping("/members")
public String list(Model model){
    List<Member> members = memberService.findMembers();
	model.addAttribute("members", members);
	return "members/memberList";
}
  • member를 전체 조회한 뒤, model에 담아 넘김

 

memberlist.html

<!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>
  • th:each로 members 객체에서 member를 하나씩 꺼내온 후 id, name, city, street, zipcode를 출력

 

실행화면

 

■ 폼 객체 vs 엔티티 직접 사용앞서 언급했듯 요구사항이 정말 단순하면 폼 객체(MemberForm) 없이 엔티티를 직접 써도 된다.(List<Member>)하지만 화면 요구사항이 복잡해지면 엔티티에 화면을 처리하는 기능이 점점 증가해 결국 유지보수하기 어렵게 된다. 따라서 실무에선 엔티티는 핵심 비즈니스 로직만 가지고 있고 화면을 위한 로직을 가지게 해선 안 된다.

 

다시 말해 화면이나 API에 맞는 폼 객체나 DTO를 쓰는 게 바람직하다.

 

[4] 상품 등록

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";
    }
  • 어설프게 엔티티를 생성하지 말고, creatBook() 같은 생성자 메서드를 써서 넘기는 게 바람직하나 예제라 setter 사용

 

createItemForm.html

<!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: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>
    <br/>
    <div th:replace="fragments/footer :: footer"/>
</div> <!-- /container -->
</body>
</html>

 

실행화면 & DB 확인

 

 

[5] 상품 목록

ItemController

@GetMapping("/items")
 public String itemList(Model model) {
     List<Item> items = itemService.findItems();
     model.addAttribute("items", items);
     return "items/itemList";
}
  • 상품 목록을 조회 후 model에 담아 itemList.html로 넘김

 

itemList.html

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

 

실행화면