detail.html 을 만들어야 한다.
template.html 을 복사붙여넣기 해서 detail.html 로 이름을 변경해주자.
기존 template.html 코드는 아래와 같다.
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Thymeleaf Bootstrap Template</title>
<!-- Bootstrap CSS -->
<link href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<!-- Header Section -->
<div th:replace="fragments/header :: header"></div>
<!--MainContentSection-->
<div class="container my-5">
</div>
<!-- Footer Section -->
<div th:replace="fragments/footer :: footer"></div>
</body>
</html>
MainContentSection 부분을 손보면 되겠다.
- 상세보기,
- 리뷰목록,
- 리뷰 및 평점 쓰기,
- 리스트로 돌아가기(root page로 돌아가기)
을 만들자.
여기서부터는, html 을 공부해본 적이 없기 때문에 강의에서 알려주는 걸 일단은 따라해보고, chatGPT의 도움을 받아서 이해를 많이 해보고 공부해보려고 한다.
<detail.html>
<!--MainContentSection-->
<div class="container my-5">
<!-- 상세보기 START -->
<div class="card">
<div class="card-header">
<h2>상세보기</h2>
</div>
<div class="card-body">
<div class="row">
<div class="col-8">
<h3 th:text="${book.subject}"></h3>
<h6 th:text="${book.author}"></h6>
<p>
Price:<span th:text="${book.price}"></span><br/>
Page:<span th:text="${book.page}"></span><br/>
Created At:<span th:text="${book.createdAt}"></span>
</p>
<p>추가적인 설명을 출력하세요......</p>
</div>
<div class="col-4">
<img th:src="@{/images/book.jpg}" class="img-fluid" alt="Book Image">
</div>
</div>
</div>
</div>
<!-- 상세보기 END -->
<!-- 리뷰목록 START -->
<div class="card">
<div class="card-header">
<h2>리뷰목록</h2>
<span class="badge badge-secondary">
<span th:text="${#lists.size(book.reviews)}"></span> 개,
평점:<span th:text="${averageRating}">?</span>/5
</span>
</div>
<div class="card-body">
<div th:if="${book.reviews != null and #lists.size(book.reviews) > 0}">
<div class="list-group" th:each="reviews : ${book.reviews}">
<div class="list-group-item">
<p th:text="${reviews.content}">리뷰내용</p>
<small th:text="${reviews.createdAt}">작성일자</small>
<p><small>평점:<span th:text="${reviews.rating}"></span>/5</small></p>
</div>
</div>
</div>
<div th:if="${book.reviews == null and #lists.size(book.reviews) == 0}">
<p>작성된 리뷰가 없습니다.</p>
</div>
</div>
</div>
<!-- 리뷰목록 END -->
<!-- 리뷰 및 평점 쓰기 START -->
<div class="card">
<div class="card-header">
<h2>리뷰 및 평점 쓰기</h2>
</div>
<div class="card-body">
<form th:action="@{/register}" method="post">
<input type="hidden" name="book_id" th:value="${book.id}">
<div class="form-group">
<label for="content">리뷰내용</label>
<textarea class="form-control" id="content" name="content" rows="3" required></textarea>
</div>
<div class="form-group">
<label for="rating">평점</label>
<select class="form-control" id="rating" name="rating" required>
<option value="1">1 - Poor</option>
<option value="2">2 - Fair</option>
<option value="3">3 - Good</option>
<option value="4">4 - Very Good</option>
<option value="5">5 - Excellent</option>
</select>
</div>
<button type="submit" class="btn btn-secondary btn-sm">리뷰등록</button>
</form>
</div>
</div>
<!-- 리뷰 및 평점 쓰기 END -->
<!-- 리스트로 돌아가기-->
<div class="text-right mt-5">
<a href="/" class="btn btn-primary btn-sm">Back to lists</a>
</div>
</div>
<BookController.java>
@Controller
public class BookController { // new HomeController
@Autowired
private BookService bookService;
@Autowired
private ReviewService reviewService;
@GetMapping("/") // / 이라고만 하면 root 페이지를 뜻함.
public String home(Model model) {
List<Book> books=bookService.getAll();
// books -> 번지 (List<Book> books)
model.addAttribute("books", books); //객체바인딩
return "home"; // home.html
}
@GetMapping("/detail/{id}")
public String getDetails(@PathVariable Long id, Model model) {
Optional<Book> bookOptional = bookService.getById(id);
if (bookOptional.isPresent()) {
Book book = bookOptional.get();
model.addAttribute("book", book);
// 평균 평점을 계산하여 모델에 추가
double averageRating = book.getReviews().stream().mapToInt(Review::getRating).average().orElse(0.0);
String formattedAverageRating = String.format("%.1f", averageRating);
model.addAttribute("averageRating", formattedAverageRating);
return "detail"; // 상세보기 View(detail.html) : ${book}
}else{
return "redirect:/";
}
}
@PostMapping("/register")
public String reviewRegister(@RequestParam("book_id") Long book_id, Review review) {
Book book = bookService.getById(book_id).orElseThrow(()->new IllegalArgumentException("Invalid book id: " + book_id));
review.setBook(book);
review.setCreatedAt(LocalDateTime.now());
reviewService.save(review); // 등록성공
return "redirect:/detail/" + book.getId();
}
}
이 코드의 핵심은 **상세보기 페이지(detail.html)**와 그것을 제어하는 BookController.java의 동작을 이해하는 것입니다. 단계별로 상세하게 설명하겠습니다.
1. HTML 파일: detail.html
이 HTML 파일은 책의 상세 정보를 보여주고, 리뷰를 출력하거나 등록할 수 있게 합니다.
1.1 메인 컨테이너 (<div class="container my-5">)
Bootstrap의 container 클래스를 사용해 웹 페이지의 레이아웃을 중앙에 정렬하고, 여백(my-5)을 추가합니다.
1.2 상세보기 섹션
- card: Bootstrap 카드 컴포넌트입니다.
- th:text: Thymeleaf 문법으로, 서버에서 넘겨준 book 객체의 속성을 출력합니다.
- ${book.subject} → 책 제목
- ${book.author} → 저자 이름
- ${book.price}, ${book.page} → 가격과 페이지 수
- 이미지 출력:
- th:src="@{/images/book.jpg}" → /images/book.jpg 경로에 있는 이미지를 보여줍니다.
- img-fluid: 이미지가 부모 컨테이너에 맞게 조절됩니다.
1.3 리뷰 목록
- 리뷰 개수: #lists.size(book.reviews) → 리뷰 목록의 개수를 가져옵니다.
- 평점 평균: ${averageRating} → BookController에서 계산된 평점 평균입니다.
- th:each: 리뷰 리스트를 반복하며 각각의 리뷰를 출력합니다.
- 조건부 렌더링:
- th:if를 사용해 리뷰가 없으면 "작성된 리뷰가 없습니다"라는 메시지를 출력합니다.
1.4 리뷰 및 평점 쓰기
- th:action="@{/register}": 폼 제출 시 POST 요청을 /register URL로 보냅니다.
- hidden input: book_id를 숨겨서 같이 보냅니다.
- textarea & select: 리뷰 내용과 평점을 입력받습니다.
1.5 뒤로 가기 버튼
- 루트 페이지(/)로 이동합니다.
2. BookController.java
이 컨트롤러는 detail.html과 연동되어 데이터를 가져오고 처리합니다.
2.1 책 상세보기: @GetMapping("/detail/{id}")
- @PathVariable: URL의 {id} 값을 받아옵니다.
- 책 가져오기:
- bookService.getById(id) → Optional<Book>으로 책 데이터를 가져옵니다.
- book.isPresent() → 책이 존재하는지 확인합니다.
- 평점 계산:
- book.getReviews().stream() → 리뷰 리스트를 스트림으로 변환합니다.
- .mapToInt(Review::getRating).average() → 평점을 평균 내고, 값이 없으면 0.0 반환.
2.2 리뷰 등록: @PostMapping("/register")
- @RequestParam: 폼에서 전달된 book_id 값을 받습니다.
- 리뷰 저장:
- review.setBook(book) → 리뷰를 해당 책에 연결합니다.
- review.setCreatedAt() → 현재 시간을 기록합니다.
- reviewService.save(review) → 리뷰를 데이터베이스에 저장합니다.
- 리다이렉트:
- 저장 후 /detail/{book.id} 페이지로 돌아갑니다.
전체 흐름 정리
- 사용자가 /detail/{id} URL에 접근하면, BookController가 책 데이터를 조회하고 detail.html에 전달합니다.
- detail.html에서는 책의 정보와 리뷰 목록을 출력합니다.
- 사용자가 리뷰를 작성하면 POST 요청으로 /register가 호출되고, 리뷰가 저장된 후 다시 상세보기 페이지로 리다이렉트됩니다.
상세보기 부분에서 질문 1. img-fluid 이미지가 부모 컨테이너에 맞게 조절된다고 했는데, 부모 컨테이너가 어떤 걸 의미하는것인지, 어디에서 부모 컨테이너라고 지정이 된 것인지.
상세보기 부분에서 질문 2. card-body 다음에 row, 그리고 나서 col-8, col-4 가 있는데, 이게 어떤 의미인지, 그리고 왜 저런 순서로 작성을 한 것인지, 어떤 원리와 원칙이 있는 것인지.
상세보기 부분
질문 1: img-fluid의 부모 컨테이너란?
- 부모 컨테이너란, 특정 HTML 요소를 감싸는 상위 요소를 의미합니다.
- 이미지 <img> 태그는 다음과 같이 작성되어 있습니다:
<div class="col-4">
<img th:src="@{/images/book.jpg}" class="img-fluid" alt="Book Image">
</div>
- 여기서 부모 컨테이너는 바로 <div class="col-4">입니다.
- **img-fluid**는 Bootstrap의 클래스이며, 부모 컨테이너의 너비에 맞게 이미지의 크기를 자동으로 조절합니다.
- 즉, col-4가 화면의 4/12 너비를 차지하게 되면, img-fluid 이미지도 그 안에 맞게 축소 또는 확대됩니다.
질문 2: card-body, row, col-8, col-4의 의미와 순서
- 원리: Bootstrap의 Grid 시스템을 사용하여 레이아웃을 만들었습니다.
- row: 하나의 "행(row)"을 생성합니다.
- col-8, col-4: "열(column)"을 의미하며, 12개의 그리드 중 8과 4를 차지하도록 나누었습니다.
- 왜 이 순서인가?
- card-body: 카드 내부의 내용 영역입니다.
- row: 내용을 행으로 나누기 위해 사용됩니다.
- col-8, col-4: 각각 8/12와 4/12 너비로 설정하여 내용과 이미지를 나란히 배치합니다.
- 결과:
- 왼쪽(col-8)에 책 제목과 설명
- 오른쪽(col-4)에 책 이미지
리뷰 목록 부분에서 질문 1. badge badge-secondary 가 무슨 뜻인지
리뷰 목록 부분에서 질문 2. ${} 기호는 {} 안에 arguments, parameters 를 입력하기 위한 기호라는 걸로 이해하고 있어. 그런데, ${#} 이라고 되어있는데 # 은 뭐야?
리뷰 목록 부분에서 질문 3. list-group 은 뭐야? list-group-item은 뭐야?
리뷰 목록 부분에서 질문 4. <small></small> 은 무슨 뜻이야?
리뷰 목록 부분
질문 1: badge badge-secondary
- badge: Bootstrap의 클래스이며, 작은 라벨이나 카운터를 표시하는 용도입니다.
- badge-secondary: 회색 배경색을 가지는 보조 뱃지입니다.
질문 2: ${#}의 의미
- ${}: Thymeleaf 문법으로 데이터를 출력할 때 사용합니다.
- #: Thymeleaf의 유틸리티 객체를 나타냅니다.
- 예: #lists → 리스트 관련 기능을 제공하는 객체입니다.
- #lists.size(book.reviews): book.reviews의 크기(개수)를 가져옵니다.
질문 3: list-group과 list-group-item
- list-group: Bootstrap에서 제공하는 클래스로, 리스트 형식의 그룹을 만듭니다.
- list-group-item: 개별 항목을 나타내며, 디자인적으로 리스트의 아이템을 강조합니다.
질문 4: <small> 태그
- <small>: 글씨를 작게 표시하기 위해 사용됩니다. 주로 보조 정보나 부가 설명을 나타낼 때 쓰입니다.
리뷰 및 평점 쓰기 부분에서 질문 1. <form> 은 어떤 거야? action은 뭐야?
리뷰 및 평점 쓰기 부분에서 질문 2. <input> 은 어떤 원리 인거지? type, name, th:value 가 어떤 의미이고 어떤 원리인거지?
리뷰 및 평점 쓰기 부분에서 질문 3. type=hidden? hidden 은 뭘까?
특히, 다른 건 다 <div></div> 이런식으로 시작과 끝을 알려주는데, 왜 <input>은 </input> 이 안 보이는걸까?
리뷰 및 평점 쓰기 부분에서 질문 4. <label> 은 뭐야?
리뷰 및 평점 쓰기 부분에서 질문 5. form-group 은 뭐야? from-control 은 뭐야?
리뷰 및 평점 쓰기 부분에서 질문 6. <textarea> 는 뭐야?
리뷰 및 평점 쓰기 부분에서 질문 7. class="form-group" id="content" name="content" rows="3" required 는 무슨 뜻이지? required 가 뭐야?
리뷰 및 평점 쓰기 부분에서 질문 8. <select> 는 뭐야? <option> 은?
<button> type="submit" class="btn btn-secondary btn-sm"> 에 대해서도 설명해줄래?
리뷰 및 평점 쓰기 부분
질문 1: <form>과 action
- <form>: 사용자가 입력한 데이터를 서버로 보내기 위한 HTML 태그입니다.
- action: 폼이 제출될 때 데이터를 보낼 URL입니다.
질문 2: <input>의 원리
- <input>: 사용자가 값을 입력할 수 있는 태그입니다.
- 속성:
- type: 입력 타입 (e.g., text, number, hidden)
- name: 서버로 전송할 데이터의 이름
- th:value: Thymeleaf를 사용해 값을 설정합니다.
질문 3: type="hidden"
- hidden: 화면에 표시되진 않지만, 폼에 데이터를 담아 전송할 때 사용됩니다.
- 닫는 태그가 없는 이유: <input>은 self-closing 태그입니다.
질문 4: <label>
- <label>: 입력 필드에 **이름(레이블)**을 붙이는 태그입니다.
- for 속성은 특정 **id**를 가진 입력 필드와 연결됩니다.
질문 5: form-group과 form-control
- form-group: Bootstrap에서 입력 필드와 레이블을 그룹화하는 클래스입니다.
- form-control: 입력 필드에 스타일을 입혀서 가로 길이를 조절하고 일관된 디자인을 제공합니다.
질문 6: <textarea>
- <textarea>: 여러 줄을 입력할 수 있는 텍스트 영역입니다.
질문 7: class, id, name, rows, required
- class: CSS나 Bootstrap 스타일 적용
- id: 요소를 고유하게 식별
- name: 데이터를 전송할 때 이름으로 사용
- rows: 텍스트 영역의 높이(줄 수)
- required: 필수 입력 필드임을 나타냅니다.
질문 8: <select>와 <option>
- <select>: 드롭다운 목록을 생성합니다.
- <option>: 목록의 항목을 나타냅니다.
<button> 설명
<button type="submit" class="btn btn-secondary btn-sm">리뷰등록</button>
- type="submit": 폼 데이터를 제출합니다.
- class="btn btn-secondary btn-sm": Bootstrap 버튼 스타일을 적용합니다.
BookController 부분에서 질문 1. PathVariable 이 뭐고, 왜 필요한거지?
BookController 부분에서 질문 2. stream(), mapToInt(Review::getRating) 에 대해서 설명해줄래?
BookController 부분에서 질문 3. String.format(%.1f 는 무슨 뜻이야?
BookController 부분에서 질문 4. GetMapping과 PostMapping 의 차이가 뭐고, 어떤 차이가 있어? 어떤 상황에서 뭘 쓰는거야?
BookController 부분에서 질문 5. GetMapping 에서는 Model model 에 객체바인딩을 했는데, 왜 PostMapping 에서는 Model model 이 왜 안 쓰인거야? 이유가 뭐야?
BookController 부분
질문 1: @PathVariable
- URL 경로에 포함된 값을 변수로 받아오는 데 사용됩니다.
- 예: /detail/{id} → {id} 부분을 가져옵니다.
질문 2: stream()과 mapToInt
- stream(): 컬렉션(리스트)을 스트림으로 변환하여 처리할 수 있습니다.
- mapToInt(Review::getRating): 리뷰에서 평점을 숫자(int)로 변환합니다.
질문 3: String.format("%.1f", value)
- %.1f: 소수점 첫째 자리까지 표시하라는 뜻입니다.
질문 4: @GetMapping과 @PostMapping의 차이
- @GetMapping: 데이터를 조회할 때 사용 (예: 페이지 표시)
- @PostMapping: 데이터를 서버에 제출할 때 사용 (예: 폼 제출)
질문 5: Model model이 PostMapping에서 안 쓰이는 이유
- Post 요청은 데이터를 서버에 보내기 위한 목적이기 때문입니다. 페이지를 렌더링할 필요가 없으므로 Model을 사용하지 않습니다.
<추가질문 1> Bootstrap 의 Grid 시스템은 뭐야? 구체적이고 상세하게 설명해줄래? col은 12너비를 기준으로 한다면, row 는 그러면 어느 정도인거지?
<추가질문 2> 왜 다른 태그들은 다 closing 이 있는데 <input>만 self-closing 태그인거야? <input> 말고도 self-closing 태그가 있어?
<추가질문 3> required 가 있으면 필수입력항목인걸 알겠어. 그러면, required 가 충족되지 않으면 어디까지가 제한을 받는거야? 그러니까, required 도 부모 컨테이너 영역에서만 필수입력항목일건데, 그게 어디까지 영향을 주냐는거야.
<추가질문 4> stream 이 뭔데? 구체적이고 상세하게 설명해줄래? mapToInt 도 마찬가지야, 뭔지 모르겠어. 구체적이고 상세하게 설명해줄래? ::기호도 뭔지 이해가 안 되는데, 구체적이고 상세하게 설명해줄래?
<추가질문 5> String.format 으로 소숫점 표시 자리를 정한다는 말은 이해가 되는데, 소수는 실수의 영역이잖아. 숫자에 대한 것이라면 long, double 의 data type 이어야 할 거 같은데, 왜 String 이냐는 질문이었어.
<추가질문 6> 페이지를 렌더링한다는게 무슨뜻이야? 내가 이해하고 있는 Model model 객체바인딩은, view (any.html) 에서 어떤 값을 이용하고 싶을때, Model model 에다가 addAttribute 를 해주고 난 다음에 view 에서 그 값을 이용할 수 있도록 해주는 걸로 이해하고 있어.
추가질문 1: Bootstrap의 Grid 시스템
Bootstrap의 Grid 시스템은 **12개 열(column)**을 기반으로 레이아웃을 구성하는 시스템입니다.
화면을 행(row)과 열(column)로 나누어 반응형(responsive) 웹페이지를 만들 수 있게 해줍니다.
기본 개념
- container: 콘텐츠를 감싸는 부모 요소입니다. 레이아웃의 시작점입니다.
- row: 하나의 "행"을 의미하며, 이 안에 여러 개의 "열(column)"을 배치합니다.
- col-*: 개별 열의 너비를 설정합니다. 숫자는 12 너비 중 차지하는 비율을 의미합니다.
예시
<div class="container">
<div class="row">
<div class="col-8">왼쪽 영역</div>
<div class="col-4">오른쪽 영역</div>
</div>
</div>
- row: 부모 행으로, 전체 너비는 12입니다.
- col-8과 col-4: 8과 4를 합쳐서 12 너비를 꽉 채웁니다.
row의 너비는?
- row의 너비는 부모 container의 너비에 맞게 자동 조절됩니다.
- 즉, row는 컨테이너에 맞는 "가로 폭"을 가지며, 그 안에서 12 그리드로 나눠집니다.
추가질문 2: Self-closing 태그
Self-closing 태그란?
- Self-closing 태그는 닫는 태그 없이도 유효한 태그입니다.
- HTML5에서는 일부 태그가 내용이 필요하지 않아서 스스로 닫힙니다.
왜 <input>이 Self-closing 태그인가?
- <input> 태그는 사용자의 입력만 받으면 되므로 내용이 필요하지 않기 때문입니다.
예시: <input type="text" />
다른 Self-closing 태그들
다음은 대표적인 Self-closing 태그입니다:
- <br>: 줄 바꿈
- <hr>: 수평선
- <img>: 이미지 삽입
- <meta>: 메타데이터 제공
- <link>: CSS 파일 연결
- <input>: 사용자 입력 필드
추가질문 3: required의 제한 범위
- **required**는 현재 <form> 요소 내에서만 유효합니다.
- 입력 필드에 required가 적용되면:
- 폼을 제출할 때 필수 항목이 비어 있으면 제출이 막힙니다.
- 브라우저 레벨에서 검증이 실행됩니다.
브라우저의 검증
- 영향 범위: <form> 요소에 포함된 required 입력 필드만 제한됩니다.
- 동작 원리:
- 폼을 제출할 때 필수 항목이 채워지지 않으면 브라우저가 폼 제출을 막고 경고 메시지를 표시합니다.
추가질문 4: Stream과 mapToInt
Stream이란?
- Stream은 Java에서 데이터를 처리하기 위한 API입니다.
- 컬렉션(리스트, 배열 등)의 요소를 한 번에 하나씩 처리하는 구조를 가지고 있습니다.
Stream의 특징
- 연속적 처리: 데이터 흐름을 생성 → 변환 → 결과로 출력.
- 불변성: 원본 데이터는 변경되지 않습니다.
- 중간 연산과 최종 연산:
- 중간 연산: 데이터를 가공 (예: map, filter)
- 최종 연산: 결과를 반환 (예: sum, average)
mapToInt란?
- **mapToInt**는 스트림 요소를 int로 변환하는 중간 연산입니다.
- 예제:
book.getReviews().stream()
.mapToInt(Review::getRating) // 리뷰의 평점을 int로 변환
.average().orElse(0.0); // 평균값 계산
- Review::getRating:
:: 기호는 메서드 참조를 의미합니다.
→ Review 객체의 getRating() 메서드를 호출합니다.
추가질문 5: String.format()과 String 타입
왜 String 타입인가?
- **String.format()**은 결과를 문자열(String)로 반환합니다.
- 소수점 자릿수를 정렬한 결과를 화면에 표시하거나 전달하려면 문자열이 필요하기 때문입니다.
예제
double value = 4.56789;
String formatted = String.format("%.1f", value);
System.out.println(formatted); // 출력: 4.6
- 숫자(실수)는 연산에 필요하지만, 화면 출력과 같은 표현에서는 문자열로 변환됩니다.
추가질문 6: 페이지 렌더링이란?
렌더링의 정의
- 렌더링은 서버에서 데이터를 받아 화면에 보여주는 과정입니다.
- View(HTML 파일)와 Model(데이터)를 결합해서 최종 웹페이지를 만듭니다.
Model 객체 바인딩
- Model model: 데이터를 View로 전달하는 역할.
- addAttribute: 데이터를 View에 바인딩합니다.
예시
- Controller에서 데이터를 준비:
model.addAttribute("book", book);
- HTML에서 데이터를 출력:
<h3 th:text="${book.subject}"></h3>
- 결과: 렌더링된 페이지에 book.subject의 값이 표시됩니다.
이미지를 넣어줘야 하는데, static 에서 디렉토리로 images 를 생성하면, 원래는 static/images 이런식으로 표시가 되는데 아랫단에 들여진 저 상태가 되려면
Mark Directory as Excluded 를 눌러줘야 한다.
이미지 파일을 넣어주었다.
이미지가 적용된 걸 확인했다.
그런데, 아직은 구현되지 않는 기능들이 많이 있다. 다음 게시물에서 계속 해보겠다.
'Spring Boot (+ RESTful)' 카테고리의 다른 글
Spring Boot - JPQL / 리뷰 삭제 기능 (1) | 2024.12.21 |
---|---|
Spring Boot - # Project 03 - Spring 의 데이터바인딩 / html - form / @RequestParam (1) | 2024.12.20 |
Spring Boot - # Project 03 - 웹 버튼 클릭 / (1) | 2024.12.18 |
Spring Boot - 자동 구성 사용자 정의 / # Project 03 - 1:N (OneToMany) DB/Entity Modeling (1) | 2024.12.17 |
Spring Boot - Maven/Gradle을 사용한 종속성 관리 (1) | 2024.12.16 |