1. 프로젝트 설정
Build : Gradle
Language : Java
Spring Boot : 3.2.3
Pakaging : Jar
Java : 17
Dependency : Spring Web, Thymeleaf, Lombok
UI : Bootstrap
2. 요구사항 분석
- 상품 도메인 모델
- 상품 ID
- 상품명
- 가격
- 수량
- 상품 관리 기능
- 상품 목록
- 상품 상세
- 상품 등록
- 상품 수정
- Usecase Diagram

3. 상품 도메인 개발
- Item
@Data // 롬복의 @Data 어노테이션 추가
public class Item {
private Long id; // id 필드 추가
private String itemName; // 상품명
private Integer price; // 가격
private Integer quantity; // 수량
// 기본 생성자
public Item() {
}
// 생성자
public Item(String itemName, Integer price, Integer quantity) {
this.itemName = itemName;
this.price = price;
this.quantity = quantity;
}
}
- ItemRepository
@Repository // 스프링 빈으로 등록
public class ItemRepository {
private static final Map<Long, Item> store = new HashMap<>(); // 상품 저장용 맵, 싱글톤으로 생성하기 때문에 동시 접근 시에는 ConcurrentHashMap 사용
private static long sequence = 0L; // 상품 id 생성용
// 상품 저장
public Item save(Item item) {
item.setId(++sequence); // 상품 id 세팅
store.put(item.getId(), item); // 상품 저장
return item; // 저장된 상품 반환
}
// id로 상품 찾기
public Item findById(Long id) {
return store.get(id); // id로 상품 조회
}
// 전체 상품 조회
public List<Item> findAll() {
return new ArrayList<>(store.values()); // 전체 상품 반환
}
// 상품 수정
public void update(Long itemId, Item updateParam) {
Item findItem = findById(itemId); // id로 상품 조회
findItem.setItemName(updateParam.getItemName()); // 상품명 수정
findItem.setPrice(updateParam.getPrice()); // 가격 수정
findItem.setQuantity(updateParam.getQuantity()); // 수량 수정
}
public void clearStore() {
store.clear(); // 상품 저장소 클리어
}
}
- ItemRepository 기능 테스트
class ItemRepositoryTest {
ItemRepository itemRepository = new ItemRepository(); // 테스트할 상품 저장소
@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); // 상품1 생성
Item item2 = new Item("item2", 20000, 20); // 상품2 생성
itemRepository.save(item1); // 상품1 저장
itemRepository.save(item2); // 상품2 저장
// when
List<Item> result = itemRepository.findAll(); // 전체 상품 조회
// then
assertThat(result.size()).isEqualTo(2); // 조회된 상품 개수가 2개인지 확인
}
// 상품 수정 테스트
@Test
void updateItem() {
// given
Item item = new Item("item1", 10000, 10); // 상품 생성
Item savedItem = itemRepository.save(item); // 상품 저장
Long itemId = savedItem.getId(); // 저장된 상품 id
// when
Item updateParam = new Item("item2", 20000, 30); // 수정할 상품 생성
itemRepository.update(itemId, updateParam); // 상품 수정
// then
Item findItem = itemRepository.findById(itemId); // 수정된 상품 조회
assertThat(findItem.getItemName()).isEqualTo(updateParam.getItemName()); // 수정된 상품명 확인
assertThat(findItem.getPrice()).isEqualTo(updateParam.getPrice()); // 수정된 가격 확인
assertThat(findItem.getQuantity()).isEqualTo(updateParam.getQuantity()); // 수정된 수량 확인
}
}
4. 컨트롤러, 뷰 템플릿 구현
- BasicItemController - 상품 목록
@Controller // 스프링 빈으로 등록
@RequestMapping("/basic/items") // 요청 URL 매핑
@RequiredArgsConstructor // final이 붙은 필드의 생성자를 생성해준다.
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"; // 뷰 이름 반환
}
// 테스트용 데이터 추가
@PostConstruct // 의존관계 주입이 이루어진 후 초기화를 수행하기 위한 메서드
public void init() {
itemRepository.save(new Item("itemA", 10000, 10)); // 상품 저장
itemRepository.save(new Item("itemB", 20000, 20)); // 상품 저장
}
}
<!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>
- 결과

- BasicItemController - 상품 상세
// 상품 상세 조회
@GetMapping("/{itemId}") // itemId는 @PathVariable로 조회
public String item(@PathVariable(name = "itemId") Long itemId, Model model) { // itemId로 상품 조회
Item item = itemRepository.findById(itemId); // 상품 조회
model.addAttribute("item", item); // 상품을 모델에 담아서 뷰에 전달
return "basic/item"; // 뷰 이름 반환
}
- 상품 상세페이지 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>
- 결과

- 에러
- itemId를 조회하기 위해 @PathVariable을 사용중 에러 발생
- java.lang.IllegalArgumentException: Name for argument of type [java.lang.String]
not specified, and parameter name information not found in class file either.- 스프링 부트 3.2 매개변수 이름 인식 문제
- 스프링 부트 3.2부터 자바 컴파일러에 -parameters 옵션을 넣어주어야 애노테이션의 이름을 생략할 수 있다.
- 해결
- -parameter를 name = "itemId"로 넣어서 해결
@PathVariable(name = "itemId")
- @RequestParam, @PathVariable, @Autowired, @ConfigurationProperties 의 애노테이션을 사용 할 때 주로 에러 발생
- 상품 등록 폼
- BasicItemController - 상품 상세
// 상품 등록 폼
@GetMapping("/add")
public String addForm() {
return "basic/addForm"; // 뷰 이름 반환
}
- addForm.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>
<h4 class="mb-3">상품 입력</h4>
<form action="item.html" th:action 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'"
th:onclick="|location.href='@{/basic/items}'|"
type="button">취소</button>
</div>
</div>
</form>
</div> <!-- /container -->
</body>
</html>
- 결과

- 실제 상품 등록 처리 구현
- BasicItemController - 상품 등록 처리
// 상품 저장
@PostMapping("/add") // POST 요청 매핑
public String save(@RequestParam("itemName") String itemName, @RequestParam("price") int price, @RequestParam("quantity") int quantity, Model model) { // 요청 파라미터 조회를 위해서 @RequestParam 사용
Item item = new Item(); // 상품 생성
item.setItemName(itemName); // 상품명
item.setPrice(price); // 가격
item.setQuantity(quantity); // 수량
itemRepository.save(item); // 상품 저장
model.addAttribute("item", item); // 상품을 모델에 담아서 뷰에 전달, 상품을 저장한 결과를 보여주기 위해 addAttribute로 item을 모델에 담아서 뷰에 전달
return "basic/item"; // 뷰 이름 반환
}
- 에러
- Error creating bean with name 'requestMappingHandlerMapping' defined in class path resource
- 컨트롤러의 @RequestMapping 으로 설정된 경로 중 특정 맵핑 경로가 중복되어 발생한 오류
- springmvc.itemservice.web.basic.BasicItemController#addForm()을 보아 addForm에서 맵핑 경로 중복이 일어난 것 같아서 확인을 해보니 상품 등록 폼에서는 @GetMapping으로 get요청을 하고 등록 처리에서는 @PostMapping으로 경로를 맵핑했어야했다.
- 해결
- 기존의 @GetMapping을 Post로 매핑하여 해결
- Error creating bean with name 'requestMappingHandlerMapping' defined in class path resource
@PostMapping("/add") // POST 요청 매핑
- BasicItemController - 상품 등록 처리를 @RequestParam으로 하나하나 처리 하는 방식 대신 한번에 처리 할 수 있는 @ModelAttribute를 사용하여 리팩토링
- @ModelAttribute 는 Item 객체를 생성하고, 요청 파라미터의 값을 프로퍼티 접근법(setXxx)으로 입력
- @ModelAttribute는 모델(Model)에 @ModelAttribute 로 지정한 객체를 자동으로 넣어준다.
- 모델에 데이터를 담을 때는 이름이 필요하다. 이름은 @ModelAttribute 에 지정한 name(value) 속성을 사용한
다.- @ModelAttribute 의 이름을 다르게 지정하면 다른 이름으로 모델에 포함된다.
- @ModelAttribute 의 이름을 생략할 수 있다.
- @ModelAttribute 의 이름을 생략하면 모델에 저장될 때 클래스명을 사용한다. 이때 클래스의 첫글자만 소문자로 변경해서 등록
- @ModelAttribute 자체도 생략가능하다. 대상 객체는 모델에 자동 등록된다.
// 상품 등록 처리
@PostMapping("/add") // POST 요청을 처리
public String addItem(Item item) { // 요청 파라미터를 자동으로 받아서 Item 객체에 담아준다.
itemRepository.save(item); // 상품 저장
return "basic/item"; // 뷰 이름 반환
}
- 결과
- 상품 등록

- 상품 등록 후 상세 페이지

- 상품 등록 확인

- 에러
- 상품 등록 후 브라우저를 새로고침 하면 상품이 계속해서 중복 등록 되는 현상
- 원인
- 웹 브라우저에서 새로 고침을 하면 마지막에 서버에 전송한 데이터를 다시 전송 한다.
- 상품 등록 폼에서 데이터를 입력하고 저장을 선택하면 POST /add + 상품 데이터를 서버로 전송한다.
- 이 상태에서 새로 고침을 또 선택하면 마지막에 전송한 POST /add + 상품 데이터를 서버로 다시 전송하게 된다.
- 결과적으로 내용은 같고, ID만 다른 상품 데이터가 계속 쌓이게 된다.
- 해결
- 기존에는 상품 등록 후 return "basic/item"; // 뷰 이름 반환 방식으로 뷰 템플릿으로 이동하였다.
- 상품 상세 화면으로 리다이렉트를 호출해서 마지막에 호출한 내용이 상품 상세 화면인 GET /items/{id}가 되게 변경해서 새로고침을 해도 상품 상세 화면으로 이동하게 해서 문제 해결(PRG Post/Redirect/Get)
- RedirectAttributes : URL 인코딩도 해주고, pathVariable , 쿼리 파라미터까지 처리
- RedirectAttributes를 사용해서 저장이 잘 되었으면 상품 상세 화면에 "저장되었습니다"라는 메시지까지 출력하는 기능 추가해서 리팩토링
// 상품 등록 처리
@PostMapping("/add") // POST 요청을 처리
public String addItem(Item item, RedirectAttributes redirectAttributes) { // 상품 정보를 받아서 처리
Item savedItem = itemRepository.save(item); // 상품 저장
redirectAttributes.addAttribute("itemId", savedItem.getId()); // 상품 ID를 리다이렉트 URL에 추가
redirectAttributes.addAttribute("status", true); // 상품 등록 성공 여부를 리다이렉트 URL에 추가
return "redirect:/basic/items/{itemId}"; // 뷰 이름 반환
}
- 상품 상세 페이지 item.html에서 저장 완료 문구가 표시될 수 있게 수정
<div class="container">
<div class="py-5 text-center">
<h2>상품 상세</h2>
</div>
<!-- 추가 -->
<h2 th:if="${param.status}" th:text="'저장 완료!'"></h2>
- 결과
- 리다이렉트 결과 : http://localhost:8080/basic/items/3?status=true
- 저장 완료 문구 출력 확인

- BasicItemController - 상품 수정
// 상품 수정 폼
@GetMapping("/{itemId}/edit") // itemId는 @PathVariable로 조회
public String editForm(@PathVariable("itemId") Long itemId, Model model) { // itemId로 상품 조회
Item item = itemRepository.findById(itemId); // 상품 조회
model.addAttribute("item", item); // 상품을 모델에 담아서 뷰에 전달
return "basic/editForm"; // 뷰 이름 반환
}
// 상품 수정 처리
@PostMapping("/{itemId}/edit") // POST 요청을 처리
public String edit(@PathVariable("itemId") Long itemId, @ModelAttribute Item item) { // itemId로 상품 조회
itemRepository.update(itemId, item); // 상품 수정
return "redirect:/basic/items/{itemId}"; // 뷰 이름 반환
}
- editForm.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>
<form action="item.html" th:action method="post">
<div>
<label for="id">상품 ID</label>
<input type="text" id="id" name="id" 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}">
</div>
<div>
<label for="price">가격</label>
<input type="text" id="price" name="price" class="form-control"
th:value="${item.price}">
</div>
<div>
<label for="quantity">수량</label>
<input type="text" id="quantity" name="quantity" class="form-
control" th:value="${item.quantity}">
</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'"
th:onclick="|location.href='@{/basic/items/{itemId}(itemId=${item.id})}'|"
type="button">취소</button>
</div>
</div>
</form>
</div> <!-- /container -->
</body>
</html>
- 결과
- 상품 상세

- 상품 수정 폼
- itemB를 itemC로 변경

- itemC로 수정된 것을 확인

'개발 > Spring' 카테고리의 다른 글
JWT(Json Web Token) (0) | 2024.03.01 |
---|---|
Spring Security Config (1) | 2024.03.01 |
Spring security Architecture, Filter (0) | 2024.02.29 |
Spring security Test (0) | 2024.02.28 |
Spring Security 구현 (0) | 2024.02.27 |