본문 바로가기
Spring/스프링 MVC 기본

#7 스프링 MVC - 웹 페이지 만들기

by 히포파타마스 2021. 7. 8.

스프링 MVC - 웹 페이지 만들기

 

스프링 MVC를 사용해 상품을 관리할 수 있는 기본적인 서비스를 만든다.

 

다음과 같은 Depndencies를 추가한다.

 

Spring Web, Thymeleaf, Lombok

 

 

1. 요구사항 분석

상품을 관리할 수 있는 서비스를 만든다.

 

■ 상품 도메인 모델

 

상품 도메인 모델은 다음과 같은 정보를 갖는다.

 

· 상품 ID

· 상품명

· 가격

· 수량

 

■ 상품 관리 기능

 

상품 관리에 대해 다음과 같은 서비스를 제공한다.

 

· 상품 목록

· 상품 상세

· 상품 등록

· 상품 수정

 

■ 서비스 화면

 

제공되는 서비스 화면은 다음과 같다.

 

[상품 목록]

상품들의 정보를 나열하고 상품 등록을 할 수 있다.

 

 

[상품 상세]

상품 모델의 정보를 나타낸다.

 

상품을 수정하거나 목록으로 이동할 수 있다.

 

 

[상품 등록]

새로운 상품을 등록한다.

 

상품 모델에 따라 상품명, 가격, 수량을 기입해야 한다.

 

 

[상품 수정]

기존의 상품을 수정한다.

 

상품 ID는 고정된채, 상품 모델에 따라 상품명, 가격, 수량을 수정할 수 있다.

 

 

■ 서비스 제공 흐름

 

[서비스 제공 흐름]

 

 

2. 상품 도메인 & 상품 저장소

[상품 도메인]

@Getter @Setter
public class Item {

    private Long 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;
    }
}

상품에 대한 객체를 구현한다.

 

Item 객체는 상품의 id, 이름, 가격, 개수를 멤버로 갖는다.

 

각 멤버 변수에대한 getter, setter는 @Getter, @Setter로 생략되었다.

 

 

[상품 저장소]

@Repository
public class ItemRepository {

    private static final Map<Long, Item> store = new HashMap<>();
    private static long sequence = 0L;

    public Item save(Item item) {
        item.setId(++sequence);
        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());
    }

    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();
    }
}

ItemRepository는 상품을 저장하는 객체이다.

 

상품이 저장될 store는 HashMap으로 (상품 Id, 상품 객체) 형식을 따른다.

 

· save(item) : 상품 Id를 부여하고 상품을 store에 저장한다.

· findById(id) : id로 상품을 조회한다.

· findAll() : 상품을 전부 조회한다.

· update(itemId, updateParam) : 새로운 Item 인스턴스 정보로 특정 id를 가진 상품의 정보를 변경한다. 

 

 

3. 상품 서비스 HTML

■ 부트스트랩

 

부트스트랩은 웹사이트를 쉽게 만들 수 있게 도와주는 HTML, CSS, JS 프레임워크이다.

 

다음의 사이트에서 Compiled CSS and JS 항목을 다운로드해서 압축을 푼 뒤, bootstrap.min.css를 복사해서 resources/static/css/bootstrap.min.css 와 같이 추가하면 된다.

 

다운 사이트 : https://getbootstrap.com/docs/5.0/getting-started/download/

 

■ 상품 목록

 

[상품 목록 컨트롤러]

@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";
    }
   
     /**
     * 테스트용 데이터 추가
     */
    @PostConstruct
    public void init() {
        itemRepository.save(new Item("itemA", 10000, 10));
        itemRepository.save(new Item("itemB", 20000, 20));
    }
}

itemRepository에서 모든 상품을 조회한 다음 모델에 담는다.

 

그리고 논리 뷰 이름("basic/items")에 따라 뷰 템플릿을 호출한다.

 

@PostConstruct로 테스트용 데이터를 두개 넣어주었다.

 

 

[상품 뷰 목록 - 타임리프]

<!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/{itemId}
                (itemId=${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 : 속성 변경

 -타임리프 뷰 템플릿을 거치게 되면 html내의 원래 값을 th:xxx 값으로 변경한다. 만약 값이 없다면 새로 생성한다.

 ex) href="value1" th:href="value2"    =>   타임리프가 적용되면 th:href="value2"가 사용된다.

 

대부분의 HTML 속성을 th:xxx 로 변경할 수 있다.

 

· URL 링크 표현식

 - 타임리프는 URL 링크를 사용하는 경우 @{...} 의 형식을 사용한다.

 - 경로 변수와 쿼리 파라미터도 생성할 수 있다.

 ex) th:href="@{/basic/items/{itemId}(itemId=${item.id}, query='test')}"

 => http://localhost:8080/basic/items/1?query=test

 - 리터럴 대체 문법을 써서 간단하게 변수를 넣을 수 있다.

 ex) th:href="@{/basic/items/${item.id}|}",

 

· 리터럴 대체 

 - 타임리프에서 문자와 표현식 등은 분리되어 있기 때문에 더해서 사용해야 한다.

 ex) <span th:text="'Welcome to our application, ' + ${user.name} + '!'">

 

 다음과 같이 리터럴 대체 문법을 사용하면, 더하기 없이 편리하게 사용할 수 있다.

 

 <span th:text="|Welcome to our application, ${user.name}!|">

 

· 반복 출력 

 - 반복은 th:each를 사용한다.

 - 위 예제의 <tr th:each="item : ${items}"> 처럼 사용하면 items 컬렉션 데이터가 item 변수에 하나씩 포함된다.

 

· 변수 표현식

 - ${...} 의 형식을 따른다.

 - 모델에 포함된 값이나, 타임리프 변수로 선언한 값을 조회할 수 있다.

 - 프로퍼티 접근법을 사용한다.

 ex) item.id

 

■ 상품 상세

 

상품 상세를 조회하는 컨트롤러와 뷰를 구현한다.

 

 

[상품 상세 컨트롤러]

@Controller
@RequestMapping("/basic/items")
@RequiredArgsConstructor
public class BasicItemController {

    private final ItemRepository itemRepository;

    @GetMapping("/{itemId}")
    public String item(@PathVariable long itemId, Model model) {
        Item item = itemRepository.findById(itemId);
        model.addAttribute("item", item);
        return "basic/item";
    }
}

PathVariable로 넘어온 상품ID로 상품을 조회하고 모델에 담아둔다. 그리고 뷰 템플릿을 호출한다.

 

 

[상품 상세 뷰 - 타임 리프]

<!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>
    
    <h2 th:if="${param.status}" th:text="'저장 완료'"></h2>

    <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>

상품 상세화면을 구성한다.

 

상품이 새로 추가되면 상품 상세 뷰로 간게 되는데 이 때 status값이 true이면 "저장 완료!" 메시지를 출력해준다.

 

각 value를 th:value="${item.*}"로 대체해서 상품 정보를 나타낼 수 있게 했다.

 

상품수정 링크를 다음과 같이 재 정의 하였다.

 

th:onclick="|location.href='@{/basic/items/{itemId}/edit(itemId=${item.id})}'|"

 

목록으로 가는 링크를 다음과 같이 재 정의하였다.

 

th:onclick="|location.href='@{/basic/items}'|"

 

■ 상품 등록

 

상품등록 컨트롤러와 뷰를 구현한다.

 

 

[상품 등록 폼 컨트롤러]

@Controller
@RequestMapping("/basic/items")
@RequiredArgsConstructor
public class BasicItemController {

    @GetMapping("/add")
    public String addForm() {
        return "basic/addForm";
    }
}

상품 등록 폼은 단순히 뷰 템플릿만 호출한다.

 

 

[상품 등록 폼 뷰 - 타임 리프]

<!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>
    <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>

· <form action="item.html" th:action method="post">

 - HTML form에서 action에 값이 없으면 현재 URL에 데이터를 전송한다.

 - 위 방식대로 하면 현재 URL이 POST 메소드로 요청된다.

 - 이런 방식으로 URL은 동일하지만 HTTP 메서드로 두 기능을 구분할 수 있다.

 

취소시 상품 목록으로 이동한다.

 

 

[상품 등록 처리 컨트롤러]

@Controller
@RequestMapping("/basic/items")
@RequiredArgsConstructor
public class BasicItemController {

    private final ItemRepository itemRepository;
    
    @PostMapping("/add")
    public String save(@ModelAttribute Item item, RedirectAttributes redirectAttributes) {

        Item savedItem = itemRepository.save(item);
        redirectAttributes.addAttribute("itemId", savedItem.getId());
        redirectAttributes.addAttribute("status", true);

        return "redirect:/basic/items/{itemId}";
    }
}

추가된 상품을 저장소에 저장하고 저장된 상품의 상품 상세 컨트롤러로 리다이렉트 한다.

 

redirectAttributes에 itemID와 status를 넣어 리다이렉트할 URL을 반환한다.

 

status가 true이면 상품 상세 뷰에서 "저장 완료!"라는 메시지가 추가로 출력된다(상품 상세 뷰 예제 참고).

 

위의 예제와 같이 반환하면 결과적으로 다음과 같은 URL로 재요청하게 된다.

 

http://localhost:8080/basic/items/3?status=true

 

■PRG

 

상품을 등록할 때 리다이렉트 하지 않고 바로 논리 뷰 이름을 반환해서 뷰 템플릿을 호출한다고 하자.

 

이 경우 상품 등록(POST)을 한 후 새로고침을 하게되면 어떻게 될까?

 

 

[POST 새로고침]

새로고침은 마지막에 서버에 전송한 데이터를 다시 전송한다.

 

상품 등록 폼에서 데이터를 입력하고 저장을 선택하면 POST /add + 상품 데이터를 서버로 전송한다.

 

이 상태에서 새로 고침을 하면 마지막에 전송한 POST /add + 상품 데이터를 서버로 다시 전송하게 된다.

 

그래서 내용은 같고 ID만 다른 상품 데이터가 계속 쌓이게 된다.

 

이러한 문제 때문에 POST 메서드로 새로 리소스를 등록할 경우 바로 뷰 템플릿을 호출하는 것이 아니라 GET 메서드의 컨트롤러를 호출하도록 리다이렉트를 해줄 필요가 있다.

 

 

[PRG 방식의 구조]

 

위와 같은 구성에서는 상품 저장(POST /add)을 하면 이제 상품 상세 폼을 호출하는 컨트롤러(GET /items/{id}로 리다이렉트된다.

 

때문에 새로고침을 해도 상품 상세 폼이 호출될뿐 더이상 중복해서 상품이 등록되지 않는다.

 

이런 문제 해결 방식을 PRG(Post/Redirect/Get) 라 한다.

 

■ RedirectAttributes

 

컨트롤러에서 특정 URL로 리다이렉트를 지정하고 싶으면 다음과 같은 형식으로 URL을 반환하면된다.

 

return "redirect:URL"

 

이 리다이렉트할 URL을 좀 더 처리하기 쉽게 해주는 객체가 redirectAttributes이다.

 

만약 리다이렉트할 URL에 변수나 쿼리 파라미터를 넣고싶다면 redirectAttributes.addAttribute()를 사용하면 된다.

 

 

[redirectAttribute 사용 예]

redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/basic/items/{itemId}";

앞의 상품 등록 컨틀롤러의 일부분이다.

 

redirectAttribute.addAttribute()에 Key, Value 형식으로 값을 넣을 수 있다.

 

저장된 값은 리다이렉트 되는 URL에 Key값을 이용해 변수로 사용할 수 있으며, 변수로 사용되지 않으면 쿼리문으로 넘겨진다.

 

■ 상품 수정

 

상품 수정 폼과 상품 수정 컨트롤러를 만들고 상품 수정 폼 뷰를 구현한다.

 

 

[상품 수정 폼 컨트롤러]

@Controller
@RequestMapping("/basic/items")
@RequiredArgsConstructor
public class BasicItemController {

    private final ItemRepository itemRepository;
    
    @GetMapping("/{itemId}/edit")
    public String editFrom(@PathVariable Long itemId, Model model) {
        Item item = itemRepository.findById(itemId);
        model.addAttribute("item", item);
        return "basic/editForm";
    }
 }

수정에 필요한 정보를 조회하고, 수정용 폼 뷰를 호출한다.

 

 

[상품 수정 폼 뷰 - 타임리프]

<!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="formcontrol"
                   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="formcontrol"
                   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>

상품 등록과 유사하고 특별히 크게 바뀐 부분은 없다.

 

 

[상품 수정 컨트롤러]

@Controller
@RequestMapping("/basic/items")
@RequiredArgsConstructor
public class BasicItemController {

    private final ItemRepository itemRepository;
    
    @PostMapping("{itemId}/edit")
    public String edit(@PathVariable Long itemId, @ModelAttribute Item item) {
        itemRepository.update(itemId, item);
        return "redirect:/basic/items/{itemId}";
    }
}

itemRepository의 update 메서드를 사용해서 상품을 수정하고 POST의 중복을 막기위해 상품 상세 컨트롤러로 리다이렉트 한다.

 

■ 전체 컨트롤러 모습

 

[전체 컨트롤러]

@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";
    }

    @GetMapping("/{itemId}")
    public String item(@PathVariable long itemId, Model model) {
        Item item = itemRepository.findById(itemId);
        model.addAttribute("item", item);
        return "basic/item";
    }

    @GetMapping("/add")
    public String addForm() {
        return "basic/addForm";
    }

    @PostMapping("/add")
    public String save(@ModelAttribute("item") Item item, 
    RedirectAttributes redirectAttributes) {

        Item savedItem = itemRepository.save(item);
        redirectAttributes.addAttribute("itemId", savedItem.getId());
        redirectAttributes.addAttribute("status", true);

        return "redirect:/basic/items/{itemId}";
        //return "redirect:/basic/items/";
    }

    @GetMapping("/{itemId}/edit")
    public String editFrom(@PathVariable Long itemId, Model model) {
        Item item = itemRepository.findById(itemId);
        model.addAttribute("item", item);
        return "basic/editForm";
    }

    @PostMapping("{itemId}/edit")
    public String edit(@PathVariable Long itemId, @ModelAttribute Item item) {
        itemRepository.update(itemId, item);
        return "redirect:/basic/items/{itemId}";
    }

    /**
     * 테스트용 데이터 추가
     */
    @PostConstruct
    public void init() {
        itemRepository.save(new Item("itemA", 10000, 10));
        itemRepository.save(new Item("itemB", 20000, 20));
    }
}

'Spring > 스프링 MVC 기본' 카테고리의 다른 글

#6 스프링 MVC 기본 기능  (0) 2021.07.07
#5 스프링 MVC 구조 이해  (0) 2021.06.30
#4 MVC 프레임워크  (0) 2021.06.29
#3 JSP & MVC 패턴 적용  (0) 2021.06.28
#2 서블릿  (0) 2021.06.23

댓글