본문 바로가기
개발/Spring

Spring MVC 구현

by BellOne4222 2024. 3. 2.

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로 매핑하여 해결
@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