압축 풀어서 build.gradle로 열기 !!
➡️ 조금 더 빠른 실행을 위함
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body> <ul>
<li>상품 관리 <ul>
<li><a href="/basic/items">상품 관리 - 기본</a></li> </ul>
</li> </ul>
</body>
</html>
📌 요구사항
상품 도메인 모델
- 상품 ID / 상품명 / 가격 / 수량
상품 관리 기능
- 상품 목록 / 상품 상세 / 상품 등록 / 상품 수정
기본적으로 백엔드 개발자는 아래의 흐름을 알고 있어야 함.
디자이너, 웹 퍼블리셔, 백엔드 개발자로 역할을 나눠 수행하기로 했을 때,
디자이너: 요구사항에 맞게 디자인, 디자인 결과를 웹 퍼블리셔에게 넘겨줌
웹 퍼블리셔: 디자이너에게 받은 디자인을 기반으로 HTML, CSS를 만들어 개발자에게 제공
백엔드 개발자: 디자이너, 웹 퍼블리셔를 통해 HTML 화면이 나오기 전까지 시스템 설계, 핵심 비즈니스 모델을 개발.
HTML이 나오면 뷰 템플릿으로 변환해 동적으로 화면과 웹 화면 흐름을 제어.
📌 파일 구조
package hello.itemservice.domain.item;
import lombok.Data;
@Data // 이걸 쓰면 getter, setter 이외에도 모두 생성해주기 때문에 !위험! 원랜 아래와 같이 분리해 쓰는 것을 추천
//@Getter @Setter
public class Item {
private Long id;
private String itemName;
private Integer price; // Integer로 선언하는 이유는 값이 안 들어갈 때를 대비 (null로 들어갈 수 있음)
private Integer quantity;
public Item() {
}
public Item(String itemName, Integer price, Integer quantity) {
this.itemName = itemName;
this.price = price;
this.quantity = quantity;
}
}
📌 import는 항상 java.util!!
그리고 지금이야 작은 프로젝트니까 이렇게 해도 규모가 조금 커진다고 했을 때 중복과 명확성을 따지자면 중복 >>>> 명확성 !
package hello.itemservice.domain.item;
import org.springframework.stereotype.Repository;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Repository
public class ItemRepository {
// static 사용했다는 것에 주의
private static final Map<Long, Item> store = new HashMap<>();
private static long sequence = 0L;
// item을 저장하는 기능
public Item save(Item item) {
item.setId(++sequence);
store.put(item.getId(), item);
return item;
}
public Item findById(Long id) {
return store.get(id);
}
// List import 할 때 java.util로
public List<Item> findAll() {
return new ArrayList<>(store.values());
}
public void update(Long itemId, Item updateParam) {
Item findItem = findById(itemId);
findItem.setItemName(updateParam.getItemName());
findItem.setPrice(updateParam.getPrice());
findItem.setQuantity(updateParam.getQuantity());
}
public void clearStore() {
store.clear();
}
}
이렇게 생성됨.
package hello.itemservice.domain.item;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class ItemRepositoryTest {
ItemRepository itemRepository = new ItemRepository();
// 하나 실행될 때마다 호출돼 데이터를 clear 시킴
@AfterEach
void afterEach() {
itemRepository.clearStore();
}
@Test
void save() {
// given
Item item = new Item("itemA", 10000, 10);
// when
Item savedItem = itemRepository.save(item);
// then
Item findItem = itemRepository.findById(item.getId());
assertThat(findItem).isEqualTo(savedItem);
}
@Test
void findAll() {
// given
Item item1 = new Item("item1", 10000, 10);
Item item2 = new Item("item2", 20000, 20);
itemRepository.save(item1);
itemRepository.save(item2);
// when
List<Item> result = itemRepository.findAll();
// then
assertThat(result.size()).isEqualTo(2);
assertThat(result).contains(item1, item2);
}
@Test
void updateItem() {
// given
Item item = new Item("item1", 10000, 10);
Item savedItem = itemRepository.save(item);
Long itemId = savedItem.getId();
// when
Item updateParam = new Item("item2", 20000, 30);
itemRepository.update(itemId, updateParam);
Item findItem = itemRepository.findById(itemId);
// then
assertThat(findItem.getItemName()).isEqualTo(updateParam.getItemName());
assertThat(findItem.getPrice()).isEqualTo(updateParam.getPrice());
assertThat(findItem.getQuantity()).isEqualTo(updateParam.getQuantity());
}
}
여기에서 Compiled CSS and JS 다운로드 후 다음 폴더에 복사 붙여넣기
그리고 되는지 먼저 테스트 해보자.
이렇게 안 열리면 out 이라는 폴더를 지우고 서버 다시 키고 다시 해보기 (http://localhost:8080/css/bootstrap.min.css)
원래 이렇게 뜨는 것이 정상
/resources/static 경로에 넣어두어서 스프링 부트가 정적 리소스를 제공 가능. 정적 리소스기 때문에 해당 파일을 탐색기에서 직접 열어도 동작하는 것을 확인 가능.
‼️ 정적 리소스가 공개되는 /resources/static 폴더에 HTML을 넣어두면 실제 서비스에서도 공개되기 때문에 서비스를 운영할 땐 공개할 필요가 없는 HTML은 여기에 두지 않는 것이 좋다.
📌 html 파일 구조
<!DOCTYPE HTML>
<html>
<head>
<meta charset="utf-8">
<link href="../css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div class="container" style="max-width: 600px">
<div class="py-5 text-center">
<h2>상품 목록</h2>
</div>
<div class="row">
<div class="col">
<button class="btn btn-primary float-end"
onclick="location.href='addForm.html'" type="button">상품 등록</button>
</div>
</div>
<hr class="my-4">
<div>
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>상품명</th>
<th>가격</th>
<th>수량</th>
</tr>
</thead>
<tbody>
<tr>
<td><a href="item.html">1</a></td>
<td><a href="item.html">테스트 상품1</a></td>
<td>10000</td>
<td>10</td>
</tr>
<tr>
<td><a href="item.html">2</a></td>
<td><a href="item.html">테스트 상품2</a></td>
<td>20000</td>
<td>20</td>
</tr>
</tbody>
</table>
</div>
</div> <!-- /container -->
</body>
</html>
<!DOCTYPE HTML>
<html>
<head>
<meta charset="utf-8">
<link href="../css/bootstrap.min.css" rel="stylesheet">
<style>
.container {
max-width: 560px;
}
</style>
</head>
<body>
<div class="container">
<div class="py-5 text-center">
<h2>상품 상세</h2>
</div>
<div>
<label for="itemId">상품 ID</label>
<input type="text" id="itemId" name="itemId" class="form-control" value="1" readonly>
</div>
<div>
<label for="itemName">상품명</label>
<input type="text" id="itemName" name="itemName" class="form-control" value="상품A" readonly>
</div>
<div>
<label for="price">가격</label>
<input type="text" id="price" name="price" class="form-control" value="10000" readonly>
</div>
<div>
<label for="quantity">수량</label>
<input type="text" id="quantity" name="quantity" class="form-control" value="10" readonly>
</div>
<hr class="my-4">
<div class="row">
<div class="col">
<button class="w-100 btn btn-primary btn-lg" onclick="location.href='editForm.html'" type="button">상품 수정</button>
</div>
<div class="col">
<button class="w-100 btn btn-secondary btn-lg" onclick="location.href='items.html'" type="button">목록으로</button>
</div>
</div>
</div> <!-- /container -->
</body>
</html>
<!DOCTYPE HTML>
<html>
<head>
<meta charset="utf-8">
<link href="../css/bootstrap.min.css" rel="stylesheet">
<style>
.container {
max-width: 560px;
}
</style>
</head>
<body>
<div class="container">
<div class="py-5 text-center">
<h2>상품 등록 폼</h2>
</div>
<h4 class="mb-3">상품 입력</h4>
<form action="item.html" method="post">
<div>
<label for="itemName">상품명</label>
<input type="text" id="itemName" name="itemName" class="form-control" placeholder="이름을 입력하세요">
</div>
<div>
<label for="price">가격</label>
<input type="text" id="price" name="price" class="form-control" placeholder="가격을 입력하세요">
</div>
<div>
<label for="quantity">수량</label>
<input type="text" id="quantity" name="quantity" class="form-control" placeholder="수량을 입력하세요">
</div>
<hr class="my-4">
<div class="row">
<div class="col">
<button class="w-100 btn btn-primary btn-lg" type="submit">상품 등록</button>
</div>
<div class="col">
<button class="w-100 btn btn-secondary btn-lg" onclick="location.href='items.html'" type="button">취소</button>
</div>
</div>
</form>
</div> <!-- /container -->
</body>
</html>
<!DOCTYPE HTML>
<html>
<head>
<meta charset="utf-8">
<link href="../css/bootstrap.min.css" rel="stylesheet">
<style>
.container {
max-width: 560px;
}
</style>
</head>
<body>
<div class="container">
<div class="py-5 text-center">
<h2>상품 수정 폼</h2>
</div>
<form action="item.html" method="post">
<div>
<label for="id">상품 ID</label>
<input type="text" id="id" name="id" class="form-control" value="1" readonly>
</div>
<div>
<label for="itemName">상품명</label>
<input type="text" id="itemName" name="itemName" class="form-control" value="상품A">
</div>
<div>
<label for="price">가격</label>
<input type="text" id="price" name="price" class="form-control" value="10000">
</div>
<div>
<label for="quantity">수량</label>
<input type="text" id="quantity" name="quantity" class="form-control" value="10">
</div>
<hr class="my-4">
<div class="row">
<div class="col">
<button class="w-100 btn btn-primary btn-lg" type="submit">저장</button>
</div>
<div class="col">
<button class="w-100 btn btn-secondary btn-lg" onclick="location.href='item.html'" type="button">취소</button>
</div>
</div>
</form>
</div> <!-- /container -->
</body>
</html>
html이 잘 되는지 확인! 이렇게 전체 경로를 복사해 경로 붙여넣기를 하면 잘 되는지 바로 확인 가능한
다 잘 되는지 확인 했음 ! 따로 잘 나오는 html 결과는 넣지 않겠음
컨트롤러와 뷰 템플릿 개발하기
package hello.itemservice.web;
import hello.itemservice.domain.item.Item;
import hello.itemservice.domain.item.ItemRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import javax.annotation.PostConstruct;
import java.util.List;
@Controller
@RequestMapping("/basic/items")
@RequiredArgsConstructor
public class BasicItemController {
private final ItemRepository itemRepository;
@GetMapping
public String items(Model model) {
List<Item> items = itemRepository.findAll();
model.addAttribute("items", items);
return "basic/items";
}
/*
test용 데이터를 추가
*/
@PostConstruct
public void init() {
itemRepository.save(new Item("itemA", 10000, 10));
itemRepository.save(new Item("itemB", 20000, 20));
}
}
컨트롤러 로직은 itemRepository에서 모든 상품을 조회한 다음 모델에 담기 → 그리고 뷰 템플릿 호출
📌 @RequiredArgsContructor
: final이 붙은 멤버 변수만 사용해 생성자를 자동 생성
➡️ 따라서 final 키워드 절대 빼지 않기(빼면 ItemRepository 의존 관계 주입 X)
그리고 생성자가 딱 하나 @Autowired
(의존관계 주입) 생략 가능
📌 테스트용 데이터를 추가하는 이유는 데이터가 아무것도 없으면 회원 목록 기능이 정상 작동하는지 확인하기 어렵다.
@PostConstruct
: 해당 빈의 의존관계가 모두 주입되고 나면 초기화 용도로 호출
여기서는 간단히 테스트용 데이터를 넣기 위해 사용
(이미 있던 items.html 을 복붙해서 코드 수정하기)
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<link th:href="@{/css/bootstrap.min.css}"
href="../css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div class="container" style="max-width: 600px">
<div class="py-5 text-center">
<h2>상품 목록</h2>
</div>
<div class="row">
<div class="col">
<button class="btn btn-primary float-end"
onclick="location.href='addForm.html'"
th:onclick="|location.href='@{/basic/items/add}'|"
type="button">상품 등록</button>
</div>
</div>
<hr class="my-4">
<div>
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>상품명</th>
<th>가격</th>
<th>수량</th>
</tr>
</thead>
<tbody>
<tr th:each="item : ${items}">
<td><a href="item.html" th:href="@{/basic/items/{itemId}(itemId=${item.id})}" th:text="${item.id}">회원id</a></td>
<td><a href="item.html" th:href="@{|/basic/items/${item.id}|}" th:text="${item.itemName}">상품명</a></td>
<td th:text="${item.price}">10000</td>
<td th:text="${item.quantity}">10</td>
</tr>
</tbody>
</table>
</div>
</div> <!-- /container -->
</body>
</html>
✔️ 타임리프 사용 선언
<html xmlns:th="http://www.thymeleaf.org">
✔️ 속성 변경 - th:href
th:href="@{/css/bootstrap.min.css}"
변경사항:
href=
→th:href=
타임리프 뷰 템플릿을 거치면 원래 값을 th:xxx값으로 변경하고 만약 값이 없다면 생성한다.
HTML을 그대로 볼 땐 href 속성이 사용되고 뷰 템플릿을 거치면 th:href 값이 href로 대체되면서 동적으로 변경 가능해진다.
대부분의 HTML 속성을 th:xxx로 변경할 수 있다.
✔️ 타임리프 핵심
: th:xxx가 붙은 부분은 서버사이드에서 렌더링 되고, 기존 것을 대체한다. 없다면 html의 xxx 속성이 그대로 사용된다.
HTML을 파일로 열었을 때 th:xxx가 있어도 웹 브라우저는 th: 속성을 알지못해 무시한다.
따라서 HTML을 파일 보기를 유지하면서 템플릿 기능도 수행한다.
✔️ URL 링크 표현식 - @{...}
th:href="@{/css/bootstrap.min.css}"
@{...}
: 타임리프는 url 링크를 사용하는 경우@{...}
를 사용한다. ➡️ URL 링크 표현식
이걸 사용하면 서블릿 컨텍스트를 자동으로 포함한다.(현재는 서블릿 컨텍스트가 필요 없어짐. 링크 앞에 applicationA 막 이런식으로 붙였던,,)
✔️ 속성 변경 - th:onclick
변경사항:
onclick="location.href='addForm.html'"
→th:onclick="|location.href='@{/basic/items/add}'|"
리터럴 대체 문법 (| |)이 사용
✔️ 리터럴 대체 - |...|
타임리프에서 문자와 표현식 등은 분리되어 있기 때문에 다음과 같이 더해서 사용해야 한다.
<span th:text="'Welcome to our application, ' + ${user.name} + '!'">
하지만 다음과 같이 리터럴 대체 문법을 사용하면 더하기 없이도 편리하게 사용 가능하다.
<span th:text="|Welcome to our application, ${user.name}!|">
예를 들어
location.href='/basic/items/add'
이런 결과가 필요할 때,
th:onclick="'location.href=' + '\'' + @{/basic/items/add} + '\''"
이렇게 표현하면 굉장히 복잡해지지만,
th:onclick="|location.href='@{/basic/items/add}'|"
이렇게 리터럴 대체 문법(| |)을 사용한다면 훨씬 짧게 표현 가능하다.
✔️ 반복 출력 - th:each
<tr th:each="item : ${items}">
반복을 위해 사용하며 모델에 포함된 items 컬렉션 데이터가 item 변수에 하나씩 포함되고 반복문 안에서 item 변수를 사용할 수 있다.
컬렉션의 수 만큼<tr>..</tr>
하위 태그를 포함해 생성된다.
✔️ 변수 표현식 - ${...}
<td th:text="${item.price}">10000</td>
모델에 포함된 값이나, 타임리프 변수로 선언한 값을 조회할 수 있다.
item.getPrice() 처럼 property 접근법을 사용한다.
✔️ 내용 변경 - th:text
<td th:text="${item.price}">10000</td>
내용의 값을 th:text 값으로 변경한다.
✔️ URL 링크 표현식2 - @{...}
th:href="@{/basic/items/{itemId}(itemId=${item.id})}"
(상품 ID를 선택하는 링크를 확인)
url링크 표현식을 사용하면 경로를 템플릿처럼 편리하게 사용할 수 있다.
경로 변수 ({itemId}) 뿐만 아니라 쿼리 파라미터도 생성Ex.
th:href="@{/basic/items/{itemId}(itemId=${item.id},query='test')}"
→ 생성 링크: http://localhost:8080/basic/items/1?query=test
✔️ URL 링크 간단히
th:href="@{|/basic/items/${item.id}|}"
(상품 이름을 선택하는 링크를 확인)
리터럴 대체 문법을 활용해 간단히 사용 가능
📌 순수 HTML도 그대로 유지하고, 뷰 템플릿도 사용할 수 있는 타임 리프의 특징을 네츄럴 템플릿(natural templates)이라고 한다.
내용이 길어서 자르고 2편으로