-
반응형
-스프링 MVC를 활용한 간단한 웹페이지를 만들어본다.
-요구사항 분석 후 알맞은 도메인, HTML, 서비스 로직 등을 개발한다.-모든 소스는 깃허브에서 관리한다.(https://github.com/coderahn/Spring-Lecture4_Item-Service)
7.스프링 MVC - 웹 페이지 만들기
1.프로젝트 생성
우선 프로젝트를 다음과 같이 생성한다.(https://start.spring.io/)
2.요구사항 분석
핵심 요구사항은 상품을 관리할 수 있는 서비스다.
상품도메인 모델은 다음과 같다.- 상품ID, 상품명, 가격, 수량
관련된 기능은 다음과 같다.- 상품목록, 상품상세, 상품등록, 상품수정
서비스 흐름은 다음과 같다.- 클라이언트(사용자)는 상품 목록을 조회한다.
- 상품 등록 클릭시, 상품 등록 폼으로 이동하여 상품 내용 입력 후 '상품 저장'을 클릭한다. 그러면 상품 상세로 돌아간다.
- 상품 제목 클릭시, 상품 상세로 이동한다.
- 상품 상세에서 '상품 수정' 클릭시, 상품 수정 폼으로 이동한다. 수정 내용 입력 후 '상품 수정'을 클릭하면 상품상세로 리다이렉트된다.
그리고 다음과 같은 흐름을 가정하여 개발을 진행한다고 해보자.- 요구사항이 정리된 후 디자이너는 요구사항에 맞게 화면 디자인을 한다.
- 웹퍼블리셔는 디자이너에게 받은 디자인을 활용하여 HTML, CSS 만든 후 개발자에게 제공한다.
- 개발자는 웹퍼블리셔를 통해 HTML화면이 나오기 전, 시스템 설계와 핵심 비지니스 모델을 개발한다. HTML이 나오면 HTML을 뷰템플릿 변환 후 동적으로 화면을 그린다.
3.상품 도메인 개발
우선 상품 도메인을 다음과 같이 개발한다. VO 역할을 한다.
[java/hello/itemservice/domain/item/Item.java]//@Data는 위험. @Getter, @Setter만 쓰는게 좋음. 핵심도메인 모델에서는 가능하면 안 사용 //data 왔다갔다용 dto에서는 어느정도 써도 됨 @Data public class Item { private Long id; private String itemName; private Integer price; //price가 없을 수도 있으니 Integer private Integer quantity; //수량이 없는 경우 있을 수도 있으니 Integer public Item() { } public Item(String itemName, Integer price, Integer quantity) { this.itemName = itemName; this.price = price; this.quantity = quantity; } }
다음으로 상품 저장소 역할의 서비스를 개발한다. 데이터베이스 역할은 HashMap을 사용한다. 서버가 올라간 후 한 번 생성되어 사용하기 위해 static변수로 선언한다.
[src/main/java/hello/itemservice/domain/item/ItemRepository.java]@Repository public class ItemRepository { //스프링 컨테이너 안에서 쓰면 싱글톤이라 static을 안 써도 되긴 함. //따로 ItemRepository를 new로 사용하면 static 필수 //멀티스레드 환경에서 여러개가 동시에 store 접근시 HashMap이 아닌 ConcurrentHashMap 사용 //HashMap은 멀티쓰레드 환경에서 사용하면 안됨. private static final Map<Long, Item> store = new HashMap<>(); //멀티스레드 환경에서 long이 아닌 AtomicLong 사용 private static long sequance = 0L; //아이템 저장 public Item save(Item item) { item.setId(++sequance); store.put(item.getId(), item); return item; } public Item findById(Long id) { return store.get(id); } public List<Item> findAll() { //store.values()를 그대로 반환해도되지만, ArrayList를 감싸서 add작업을 해도 store에 영향 없도록 하기 위해 감쌈 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(); } }
위의 코드에서 다음 코드가 DB역할을 한다.private static final Map<Long, Item> store = new HashMap<>();
이 코드가 static으로 되어 있는데, 메모리에 한 번 올라간 후 서버가 내려갈 때까지 유지되야 되기 때문이다. 근데 실제로 스프링컨테이너는 ItemRepository를 싱글톤 기반 스프링 빈으로 등록하는데, 싱글톤이기 때문에 실제로 static을 붙이지 않아도 되긴 한다.
다음과 같이 서비스들에 대한 테스트코드를 작성할 수 있다.
[src/test/java/hello/itemservice/domain/item/ItemRepositoryTest.java]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); 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()); } }
4.상품서비스 HTML
이제 위에서 서비스 로직을 개발하였으니, HTML 화면을 개발한다.
여기서는 부트스트랩을 사용한다.
부트스트랩은 공식 사이트에서 다운로드 받은 후, bootstrap.min.css를 복사하여 resources/static/css에 추가한다.
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
5.상품목록 - 타임리프
이제 컨트롤러와 뷰템플릿을 개발한다.
[BasicItemController.java]@Controller @RequestMapping("/basic/items") @RequiredArgsConstructor public class BasicItemController { private final ItemRepository itemRepository; //생성자가 1개만 있으면 Autowired 생략 가능 //@RequiredArgsConstructor를 사용하면 final 필드가 있는 itemRepository를 자동 생성 // public BasicItemController(ItemRepository itemRepository) { // this.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)); } }
@RequiredArgsConstructor를 붙이면 final 멤버변수의 생성자를 자동으로 만들어준다.
@GetMapping된 items(Model model)은 호출된 경우 매핑되어 실행되는 컨트롤러 메소드다. findAll()을 통해 저장된 item들을 모두가져와서 model에 담은 후 basic/items.html로 랜더링한다.
@PostConstruct는 해당 빈 의존관계가 모두 주입되고 초기화 용도로 호출된다. 테스트데이터를 셋팅하기 위해 사용한다.
다음으로 /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 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}">회원아이디</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는 타임리프 속성을 사용할 때 사용하는 키워드다. 타임리프의 뷰템플릿을 거치면 완래값을 th:xxx로 변경한다. th:xxx가 붙은 부분은 서버사이드에서 랜더링되어 기존 것을 대체한다. html을 파일로 열었을 때 th:xxx가 있어도 웹브라우저는 th:속성을 몰라서 무시한다. 따라서 html을 파일보기로 유지하면서 템플릿 기능도 할 수 있다.
th:href는 속성을 변경한다. href="value1"을 th:href="value2"로 변경한다.
@{...}는 타임리프의 URL링크 표현식이다. @{...}를 사용하면 서블릿컨텍스트를 자동으로 포함한다.
참고로 서블릿 컨텍스트는 서블릿 컨테이너(Tomcat 등) 실행시 각 컨텍스트(웹어플리케이션)마다 한 개의 서블릿컨텍스트 객체를 생성하는데, 웹어플리케이션이 실행되면서 애플리케이션 전체의 자원이나 정보를 미리 바인딩해서 서블릿들이 공유하여 사용하는 객체다.(https://java117.tistory.com/18)
<button>상품등록</button>을 클릭하면 th:onclick 속성이 실행 된다.
th:onclick="|location.href='@{/basic/items/add}'|" 부분에 리터럴 대체 문법이 사용되었다. |...| 이렇게 사용한다.- 리터럴 대체 문법을 사용 안 할 경우 문자와 표현식을 더하기(+)로 사용해야 한다.
- ex) <span th:text="'Welcome, ' + ${user.name} + '!'">
- 리터럴 대체 문법 사용시 더하기 없이 사용 가능하다.
- ex)<span th:text="|Welcome, ${user.name}!|">
th:each는 반복출력으로 jsp의 c:foreach같은 역할이다.
th:text는 내용 값을 th:text로 변경한다. <td th:text="${item.price}">10000</td>인 경우 10000이 ${item.price}로 대체된다.
위의 HTML에 다음과 같은 코드가있다.<td><a href="item.html" th:href="@{/basic/items/{itemId}(itemId=${item.id})}" th:text="${item.id}">회원아이디</a></td>
여기서 th:href의 링크표현식에 (itemId=${item.id})라고 되어 있는 부분을 볼 수 있다. {itemId}라는 url 경로 부분에 ${item.id}를 넣어서 동적으로 URL을 만들 수 있다. 또한 (itemId=${item.id}, query'test')처럼 쿼리파라미터 추가도 가능하다. 위의 경우 url은 http://localhost:8080/basic/items/1?query=test같은 방식으로 URL이 생성된다.
6.상품 상세
상품 상세의 컨트롤러 매핑은 위의 BasicItemController에 다음과 같이 추가한다.
[BasicItemController.java]//... @GetMapping("/{itemId}") public String item(@PathVariable long itemId, Model model) { Item item = itemRepository.findById(itemId); model.addAttribute("item", item); return "basic/item"; } //...
@PathVariable을 이용한다. 넘어온 상품ID로 findById(itemId)를 통해 상품 조회한다. 뷰는 다음과 같이 만든다.
[/resources/templates/basic/item.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"> <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는 모델에 있는 정보를 프로퍼티 접근법으로 출력한다. th:value="${item.id}"의 경우 item.id가 출력된다.
7.상품등록 폼
상품등록폼으로 이동하기 위한 컨트롤러 메소드를 만든다.
[BasicItemController.java]//... @GetMapping("/add") public String addForm() { return "basic/addForm"; } //...
단순히 addForm.html로 이동하는 역할만 한다.
등록 폼은 다음과 같이 만든다.
[/resources/templates/basic/addForm.html]<!DOCTYPE HTML> <html> <head> <meta charset="utf-8"> <link th:href="@{/css/bootstrap.min.css}" 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"> <!-- 같은 URL은 action을 비워도됨("/basic/items/add")--> <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>
th:action을 사용하여 현재 URL에 데이터를 전송한다.
8.상품 등록 처리 - @ModelAttribute
상품등록 폼에서 전달된 데이터를 서버에서 처리해보자.- POST방식의 HTML Form 방식으로 전달
- content-type은 application/x-www-form-urlencoded
- 메세지 바디에 쿼리파라미터 형식 전달
- itemName=itemA&price=20000&quantity=20
요청 파라미터들을 처리하는 방식은 앞서서 학습했듯이 여러가지 방법으로 처리 가능하다. 우선 @RequestParam으로 처리해본다.
[BasicItemController.java]//... //@PostMapping("/add") public String addItemV1(@RequestParam String itemName, @RequestParam int price, @RequestParam Integer quantity, Model model) { Item item = new Item(); item.setItemName(itemName); item.setPrice(price); item.setQuantity(quantity); itemRepository.save(item); model.addAttribute("item", item); return "basic/item"; } //...
요청 파라미터들을 각각 @RequestParam으로 받아서 Item 객체 setter를 통해 셋팅 후 save(item)으로 저장한다.
이 방법은 변수를 각각 받아 불편하다. @ModelAttribute를 통해 한 번에 처리해보자.
[BasicItemController.java]//... //ModelAttribute(네임)의 '네임'은 model.addAttribute 역할을 해준다. view에서 보여줄 attributeName이라고 볼 수 있다. @PostMapping("/add") public String addItemV2(@ModelAttribute("item") Item item, Model model) { itemRepository.save(item); //model.addAttribute("item", item); //ModelAttribute("item")으로 인해 생략 가능 return "basic/item"; } //...
@ModelAttribute가 Item객체 생성 후 setter를 통해 입력까지 해준다.
여기서 또 중요한 기능이 있다. @ModelAttribute가 model.setAttribute(name, value) 역할도 해준다. model에 데이터를 담을 때 name이 필요하듯이, @ModelAttribute(name)의 name값이 model의 name역할을 해준다.
예를 들어 @ModelAttribute("name") Item item은 model.setAttribute("name", item)과 같다.
여기서 더 나아가면 ModelAttribute의 name을 생략할 수 있다.
[BasicItemController.java]//... //ModelAttribute(네임)의 '네임'생략시 클래스네임 앞을 소문자로 바꿔서 model.addAttribute(네임,value)가 된다. //ex)ModelAttribute Item item은 model.addAttribute("item", value)와 같다. //@PostMapping("/add") public String addItemV3(@ModelAttribute Item item) { itemRepository.save(item); return "basic/item"; } //...
더 더 나아가면, ModelAttribute까지 생략된다. 대상 객체는 모델에 자동 등록된다.
[BasicItemController.java]//ModelAttribute 생략 가능(String등 단순타입인 경우 생략하면 @RequestParam) @PostMapping("/add") public String addItemV4(Item item) { itemRepository.save(item); return "basic/item"; }
9.상품 수정
상품 상세에서 상품 수정 버튼을 클릭하면 수정 화면으로 이동한다. 이 때 해당 item의 id를 컨트롤러에서 pathVariable로 사용하여 해당 데이터를 조회 후 model에 담아줘야 한다.컨트롤러 매핑은 다음과 같이 만든다.
[BasicItemController.java]
//상품수정폼 @GetMapping("/{itemId}/edit") public String editForm(@PathVariable Long itemId, Model model) { Item item = itemRepository.findById(itemId); model.addAttribute("item", item); return "basic/editForm"; }
수정화면은 다음과 같다.(등록 폼과 비슷하다)
[/resources/templates/basic/editForm.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"> <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"> <!-- HTML Form 전송은 PUT, PATCH 지원 안 함--> <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" value="10000" th:value="${item.price}"> </div> <div> <label for="quantity">수량</label> <input type="text" id="quantity" name="quantity" class="form-control" value="10" 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>
이제 상품 수정 화면에서 '저장'을 클릭하면 수정 로직이 작동한다. 컨트롤러 매핑은 아래와 같다.
[BasicItemController.java]
//상품수정 @PostMapping("/{itemId}/edit") public String edit(@PathVariable Long itemId, @ModelAttribute Item item) { itemRepository.update(itemId, item); //뷰템플릿 호출 대신 상품상세로 리다이렉트(302) //@PathVariable값 사용 가능 return "redirect:/basic/items/{itemId}"; }
업데이트 후 수정된 내용을 상품 상세에서 보여주기 위해 redirect한다.
10.PRG Post/Redirect/Get
생각해보면 상품 등록에서 상품 저장(/add) 후 redirect 처리를 안 했다. 이러면 새로고침할 때 POST방식의 /add가 계속 실행되기 때문에 동일한 데이터가 중복등록된다. 저장 후 상품 수정과 같이 redirect를 해주면 마지막 요청이 GET이 되기 때문에 새로고침을 해도 상품 상세가 호출되어 이런 문제를 방지할 수 있다.
저장(POST) -> 리다이렉트(Redirect) -> 상세(Get)의 앞 글자를 따서 PRG 패턴이라고 한다.
[BasicItemController.java]
//PRG 패턴 적용(저장로직 중복 방지) @PostMapping("/add") public String addItemV5(Item item) { itemRepository.save(item); return "redirect:/basic/items/" + item.getId(); }
11.RedirectAttributes
상품 저장 후 상세화면으로 이동하면 저장이 잘 된 것인지 판단이 안 선다. 화면에 저장여부를 텍스트로 표기해주려고 한다. 이 때 RedirectAttributes를 사용하면 리다이렉트 후 model 처리를 해준다.
//리다이렉트 후 model 처리할 수 있는 방버 @PostMapping("/add") public String addItemV6(Item item, RedirectAttributes redirectAttributes) { Item savedItem = itemRepository.save(item); //{itemId}에 itemId가 들어간다. status는 쿼리파라미터 형태로 들어간다. redirectAttributes.addAttribute("itemId", savedItem.getId()); redirectAttributes.addAttribute("status", true); return "redirect:/basic/items/{itemId}"; }
위에서 "status"는 뷰템플릿에서 텍스트 출력을 위한 플래그 역할을 한다. 실행 시 다음과 같은 리다이렉트 결과가 나온다.
http://localhost:8080/basic/items/3?status=true
위의 리다이렉트 결과에서 보듯이 RedirectAttributes는 URL 인코딩과 pathVariable, 쿼리 파라미터까지 처리해준다.
[/resources/templates/basic/item.html]
<div class="container"> <div class="py-5 text-center"> <h2>상품 상세</h2> </div> <!-- param은 쿼리파라미터 쓸 수 있는 객체 --> <h2 th:if="${param.status}" th:text="'저장 완료!'"></h2> <!-- 생략... --> </div>
th:if 타임리프 문법을 사용하여 분기처리를 해준다. 여기서 param은 타임리프에서 자체적으로 제공해주는데 쿼리파라미터를 편리하게 조회하게 해준다.
결과화면은 아래와 같다. (상품 저장 후)
반응형'BackEnd > Spring' 카테고리의 다른 글
[개인학습]스프링부트 + Swagger + JPA + MySQL 설정 및 테스트 (2) 2022.10.10 [인프런 김영한 로드맵4]스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술(6) (0) 2022.06.18 [인프런 김영한 로드맵4]스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술(5) (0) 2022.06.14 [인프런 김영한 로드맵4]스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술(4) (0) 2022.06.04 [인프런 김영한 로드맵4]스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술(3) (2) 2022.05.31 댓글