[Spring MVC] 스프링 MVC(5)
7.스프링 MVC - 웹 페이지 만들기
1) 요구사항 분석
- 상품 도메인 모델
- 상품 ID, 상품명, 가격, 수량
- 상품 관리 기능
- 상품 목록, 상품 상세, 상품 등록, 상품 수정
- 서비스 제공 흐름
- 클라이언트(사용자)는 상품 목록 조회
- 상품 등록 클릭 시, 상품 등록 폼으로 이동하여 상품 내용 입력 후 "상품 저장" 클릭하면 상품 상세로 돌아간다.
- 상품 제목 클릭 시, 상품 상세로 이동한다.
- 상품 상세에서 "상품 수정" 클릭 시, 상품 수정 폼으로 이동한다. 수정 내용 입력 후 "상품 수정" 클릭하면 상품 상세로 리다이렉트 된다.
- 클라이언트(사용자)는 상품 목록 조회
2) 상품 도메인 개발
[java/hello/itemservice/domain/item/Item.java] - 상품 객체
@Data
//@Getter @Setter 최대한 @Data 쓰지말고 필요한 거만 골라서 쓰기(주의할 필요 있음)
public class Item {
private Long id;
private String itemName;
private Integer price; //price가 안 들어갈 떄도 있어서 Integer사용
private Integer quantity; //수량
public Item() {
}
public Item(String itemName, Integer price, Integer quantity) {
this.itemName = itemName;
this.price = price;
this.quantity = quantity;
}
}
[src/main/java/hello/itemservice/domain/item/ItemRespository.java] - 상품 저장소
@Repository //내부에 @Component있어서 컴포넌트 스캔 대상이 됨
public class ItemRespository {
private static final Map<Long, Item> store = new HashMap<>(); //static
//DB역할
//상품 아이디를 Long타입으로 지정했기 때문
private static long sequesnce = 0L; //static
public Item save(Item item) {
item.setId(++sequesnce);
store.put(item.getId(),item);
return item;
}
public Item findById(Long id) {
return store.get(id);
}
public List<Item> findAll(){
return new ArrayList<>(store.values());
//한 번 감싸서 반환하게되면 ArrayList에 값을 넣어도 실제 store에는 변함이 없음(안전하게 하기 위해)
}
public void update(Long itemId, Item updateParam) {
Item findItem = findById(itemId); //itemId로 찾고
findItem.setItemName((updateParam.getItemName()));//찾은 item에 업데이트 파라미터 정보 넘어옴
findItem.setPrice(updateParam.getPrice());
findItem.setQuantity(updateParam.getQuantity());
}
public void clearStroe(){
store.clear(); //hashmap데이터 다 날라감
}
}
웹 애플리케이션은 기본적으로 멀티 쓰레드 환경이다. 그래서 실제로는 HashMap 사용 금지 -> 동시성 문제 발생 가능성 때문
동시성 문제가 되는 상황에서만 ConcurrentHashMap을 사용하면 된다.
예를 들어 스프링 빈이 싱글톤인데, 여기에 멤버 변수를 사용하게 되면 여러 쓰레드에서 해당 멤버 변수를 공유해서 사용하게 되어 문제가 발생할 수 있다.
지역 변수는 쓰레드 간 공유되지 않기 때문에(각각 따로 전용 공간이 할당됨) HashMap을 사용하는 것이 옳다.
private static final Map<Long, Item> store = new HashMap<>(); //static
DB역할을 하는 코드인데, 메모리에 한 번 올라간 후에 서버가 내려갈 때까지 유지가 되어야하므로 static으로 설정되어있다. 하지만 실제 스프링 컨테이너는 ItemRepository를 싱글톤 기반 스프링 빈으로 등록하기 때문에 실제로 static을 붙이지 않아도 된다.
[src/test/java/hello/itemservice/domain/item/ItemRepositoryTest.java]
class ItemRespositoryTest { //최근 JUnit5에는 Public 없어도 됨
ItemRespository itemRespository = new ItemRespository();
@AfterEach
void afterEach() {
itemRespository.clearStroe();
}
@Test
void save() {
//given
Item item = new Item("item A", 10000, 10);
//when
Item saveItem = itemRespository.save(item);
//then
Item findItem = itemRespository.findById(item.getId());
assertThat(findItem).isEqualTo(saveItem); //조회한 값이랑 저장된 값이랑 같은지
}
@Test
void findAll() {
//given
Item item1 = new Item("item1", 10000, 10);
Item item2 = new Item("item2", 20000, 20);
itemRespository.save(item1);
itemRespository.save(item2);
//when
List<Item> result = itemRespository.findAll();
//then
assertThat(result.size()).isEqualTo(2);
assertThat(result).contains(item1, item2); //item1,2를 가지고 있는지
}
@Test
void update() {
//given
Item item = new Item("item1", 10000, 10);
Item savedItem = itemRespository.save(item);
Long itemId = savedItem.getId();
//when
Item updateParam = new Item("item2", 20000, 30);
itemRespository.update(itemId, updateParam);
//then
Item findItem = itemRespository.findById(itemId);
assertThat(findItem.getItemName()).isEqualTo(updateParam.getItemName());
assertThat(findItem.getPrice()).isEqualTo(updateParam.getPrice());
assertThat(findItem.getQuantity()).isEqualTo(updateParam.getQuantity());
}
}
3) 상품 서비스 HTML
HTML을 편리하게 개발하기 위하여 부트 스트랩을 사용하는데 부트스트랩 공식 사이트에서 다운로드 받고, bootstrap.min.css를 복사하여 resources/static/css에 추가한다.
부트스트랩이란 웹사이트를 쉽게 만들 수 있게 도와주는 HTML, CSS, JS 프레임 워크이다.
HTML, css파일은 다음과 같이 위치하는데
- /resources/static/css/bootstrap.min.css -> 부트스트랩 css 팡리
- /resources/static/html/items.html -> 상품 목록
- /resources/static/html/item.html -> 상품 상세
- /resources/static/html/addForm.html -> 상품 등록
- /resources/static/html/editForm.html -> 상품 수정
resources/static에 넣어두었기 때문에 스프링부트는 정적리소스를 제공한다. 정적 리소스이기 때문에 http://localhost:8080/html/items.html로 접근이 가능하다.
4) 상품 목록 - 타임리프
이제 컨트롤러와 뷰 템플릿을 개발해야한다.
[BasicItemController.java]
@Controller
@RequestMapping("/basic/items")
@RequiredArgsConstructor //final 붙은 멤버 변수로 생성자를 자동으로 만들어줌
public class BasicItemController {
private final ItemRespository itemRespository;
//BasicItemController가 스프링빈에 등록이 되면서 생성자 주입으로 앞서 만들었던 ItemRepository가 주입된다.
//@Autowired
//스프링에서 생성자가 딱 1개 뿐인 경우 @Autowired 생략 가능
/*@RequiredArgsConstructor -> final 붙은 거로 기본 생성자 만들어주기 때문에 생략 가능
public BasicItemController(ItemRespository itemRespository) {
this.itemRespository = itemRespository;
}
*/
@GetMapping
public String items(Model model){ //item 목록 출력
List<Item> items = itemRespository.findAll();
model.addAttribute("items", items);
return "basic/items"; //view의 위치
}
/*
* 테스트용 data추가
* 테스트용 data가 없으면 회원 목록 기능이 정상적으로 동작하는지 확인하기 어려움
*/
@PostConstruct //해당 빈 의존관계가 모두 주입되고 초기화 용도로 호출
public void init() {
itemRespository.save(new Item("itemA", 10000, 10));
itemRespository.save(new Item("itemB", 20000, 20));
}
}
@GetMapping된 items(Model model)은 호출된었을 때 매핑되어 실행되는 컨트롤러 메소드로 findAll()을 통하여 저장된 item들을 모두 가져와 model에 담은 후에 basic/tiems.html로 랜더링한다.
다음은 items.html 정적 HTML을 뷰 템플릿 영역으로 복사한 후 다음과 같이 수정한다.
/resources/static/items.html -> 복사하여 /resources/templates/basic/items.html
[/resources/templates/basic/items.html]
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<link href="../css/bootstrap.min.css"
th: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>
타임리프 선언할 때에는 다음과 같은 코드를 추가해주어야 한다.(th)
<html xmlns:th="http://www.thymeleaf.org">
타임리프의 뷰템플릿을 거치게 되면 원래값을 th:xxx로 변경한다. 만약 값이 없다면 새로 생성한다. HTML을 그대로 볼 때는 href 속성이 사용되고, 뷰 템플릿을 거치면 th:href의 값이 href로 대체 되면서 동적으로 변경할 수 있다.
th:xxx가 붙은 부분은 서버사이드에서 랜더링되어 기존 것을 대체한다. html파일로 열었을 떄 th:xxx가 있어도 웹브라우저는 th: 속성을 몰라서 무시한다. 따라서 html을 파일 보기로 유지하면서 템플릿 기능도 할 수 있다.
th:href는 속성을 변경하는데 href="value1"을 th:href="value2"로 변경해준다.
@{...}는 타임리프의 URL링크 표현식으로 서블릿컨텍스트를 자동으로 포함한다.
-서블릿 컨텍스트 : 서블릿 컨테이너(톰캣 등)을 시행할 때 각 컨텍스트(웹어플리케이션)마다 한 개의 서블릿 컨텍스트 객체를 생성하는데, 웹 어플리케이션이 실행되면서 애플리케이션 전체의 자원이나 정보를 미리 바인딩해서 서블릿들이 공유하여 사용하는 객체
<button> 상품등록 </button>을 클릭하면 th:onclick 속성 실행
onclick="location.href='addForm.html' "
-> th:onclick=" |location.href='@{/basic/items/add}'|"
이와같이 리터럴 대체는 |...| 이렇게 사용한다.
- 리터럴 대체 문법을 사용 안 할 경우 문자와 표현식을 더하기(+)로 사용해야 한다.
- ex) <span th:text="'Welcome, ' + ${user.name} + '!'">
- 리터럴 대체 문법 사용시 더하기 없이 사용 가능하다.
- ex)<span th:text="|Welcome, ${user.name}!|">
<tr th:each="item : ${items}">
th:each 는 반복출력이다. 모델에 포함된 items 컬렉션 데이터가 item 변수에 하나씩 포함되고, 반복문 안에서 item 변수를 사용할 수 있게 된다.
컬렉션 수 만큼 <tr>..</tr>이 하위 태그를 포함해서 생성된다.
나머지 내용은 타임리프 편에서 정리
5) 상품 상세
상품 상세 컨트롤러와 뷰를 개발해보도록 하자.
[BasicItemController.java]에 추가
@GetMapping("/{itemId}")
public String item(@PathVariable long itemId, Model model) {
Item item = itemRespository.findById(itemId);
model.addAttribute("item", item);
return "basic/item";
}
PathVariable로 넘어온 상품 ID로 findById(itemId)를 통해 상품을 조회한다.
뷰는 다음과 같이 정적 HTML을 뷰 템플릿 영역으로 복사한 후 수정을 한다.
/resources/static/item.html을 /resources/templates/basic/item.html로 복사
[/resources/templates/basic/item.html]
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<link href="../css/bootstrap.min.css"
th: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" th:value="${item.id}" readonly>
</div>
<div>
<label for="itemName">상품명</label>
<input type="text" id="itemName" name="itemName" class="form-control"
value="상품A" th:value="${item.itemName}" readonly>
</div>
<div>
<label for="price">가격</label>
<input type="text" id="price" name="price" class="form-control"
value="10000" th:value="${item.price}" readonly>
</div>
<div>
<label for="quantity">수량</label>
<input type="text" id="quantity" name="quantity" class="form-control"
value="10" th:value="${item.quantity}" 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'"
th:onclick="|location.href='@{/basic/items/{itemId}/edit(itemId=${item.id})}'|" type="button">상품 수정</button>
</div>
<div class="col">
<button class="w-100 btn btn-secondary btn-lg"
onclick="location.href='items.html'"
th:onclick="|location.href='@{/basic/items}'|"
type="button">목록으로</button>
</div>
</div>
</div> <!-- /container -->
</body>
</html>
th : value는 모델에 있는 item정보를 프로퍼티 접근법으로 출력한다. th:value="${item.id}"의 경우에는 item.id가 출력된다.