쌩로그

스프링 MVC 1편 - Ch07. 스프링 MVC - 웹 페이지 만들기 본문

Spring/Spring & Spring Boot

스프링 MVC 1편 - Ch07. 스프링 MVC - 웹 페이지 만들기

.쌩수. 2024. 4. 19. 06:55
반응형

목록

  1. 포스팅 개요
  2. 본론
      2-1. 요구사항 분석
      2-2. 상품 도메인 개발
      2-3. 상품 서비스 HTML
      2-4. 상품 목록 - 타임리프
      2-5. 상품 상세
      2-6. 상품 등록 폼
      2-7. 상품 등록 처리 - @ModelAttribute
      2-8. 상품 수정
      2-9. PRG Post/Redirect/GET
      2-10. RedirectAttribute
  3. 요약

1. 포스팅 개요

인프런에서 영한님의 스프링 MVC 1편 Section 07. 스프링 MVC - 웹 페이지 만들기를 학습하며 정리한 포스팅이다.

프로젝트를 생성하는데, 프로젝트 생성 부분은 생략했다.

build.gradle만 올리고, 이후 강의 내용부터 정리한다.

참고로 강의는 2.x대 부트지만, 필자는 그냥 3.x 사용한다.
만드는 건 3.x 밖에 못 만든다.

plugins {  
    id 'java'  
    id 'org.springframework.boot' version '3.2.4'  
    id 'io.spring.dependency-management' version '1.1.4'  
}  

group = 'hello'  
version = '0.0.1-SNAPSHOT'  

java {  
    sourceCompatibility = '21'  
}  

configurations {  
    compileOnly {  
        extendsFrom annotationProcessor  
    }  
}  

repositories {  
    mavenCentral()  
}  

dependencies {  
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'  
    implementation 'org.springframework.boot:spring-boot-starter-web'  
    compileOnly 'org.projectlombok:lombok'  
    annotationProcessor 'org.projectlombok:lombok'  
    testImplementation 'org.springframework.boot:spring-boot-starter-test'  
}  

tasks.named('test') {  
    useJUnitPlatform()  
}

인텔리제이 설정만 잘해주자.

  • 3.x는 빌드를 intelliJ가 아닌 gradle로 해야 원활하다.(고 한다.)
  • Settings에서 Build, Execution, Deployment > Compiler > Annotation Processors 에서 Enable annotation processing 을 체크 해줘야 롬복이 정상적으로 동작한다.

그리고 서버를 실행해보고, locatlhost:8080으로 간 후, 서버가 잘 띄어져있으면 된다.(오류 페이지도 ok)

resources/static 에서 아래의 index.html을 넣자.

<!DOCTYPE html>  
<html>  
<head>  
    <meta charset="UTF-8">  
    <title>Title</title>  
</head>  
<body>  
<ul>  
    <li>상품 관리  
        <ul>  
            <li><a href="/basic/items">상품 관리 - 기본</a></li>  
        </ul>    </li></ul>  
</body>  
</html>

아래와 같이 나온다

# 2. 본론

2-1. 요구사항 분석

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

상품 도메인 모델

  • 상품 ID
  • 상품명
  • 가격
  • 수량

상품 관리 기능

  • 상품 목록
  • 상품 상세
  • 상품 등록
  • 상품 수정
  • 삭제는 필요하면 우리가 만듬.

서비스 화면은 다음과 같다.

(JPA 1편 홈페이지랑 완전 판박이다..?)

상품 상세는 다음과 같다.
상품을 클릭하면 다음과 같이 나온다.

상품 등록 폼이다.

데이터를 넣은데로 들어간다.

수정 폼은 다음과 같다.

서비스 제공 흐름은 다음과 같다.

참고로 분업은 일반적으로 다음과 같이 이뤄진다.

  • 디자이너
    • 요구사항에 맞도록 디자인하고, 디자인 결과물을 웹 퍼블리셔에게 넘겨준다.
  • 웹 퍼블리셔
    • 다자이너에서 받은 디자인을 기반으로 HTML, CSS를 만들어 개발자에게 제공한다
  • 백엔드 개발자
    • 디자이너, 웹 퍼블리셔를 통해서 HTML 화면이 나오기 전까지 시스템을 설계하고, 핵심 비즈니스 모델을 개발한다. 이후 HTML이 나오면 이 HTML을 뷰 템플릿으로 변환해서 동적으로 화면을 그리고, 또 웹 화 면의 흐름을 제어한다.

React, Vue.js 같은 웹 클라이언트 기술을 사용하고, 웹 프론트엔드 개발자가 별도로 있으면, 웹 프론트엔드 개발 자가 웹 퍼블리셔 역할까지 포함해서 하는 경우도 있다.
웹 클라이언트 기술을 사용하면, 웹 프론트엔드 개발자가 HTML을 동적으로 만드는 역할과 웹 화면의 흐름을 담당한다.
이 경우 백엔드 개발자는 HTML 뷰 템플릿을 직접 만지는 대신에, HTTP API를 통해 웹 클라이언트가 필요로 하는 데이터와 기능을 제공하면 된다.(진정한 백엔드)

2-2. 상품 도메인 개발

먼저 Item 상품 객체를 만든다.

itemservice.domain.item 패키지에서 Item 클래스를 작성한다.

import lombok.Data;  

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

@Data는 학습용이기 때문에 사용한다.


import org.springframework.stereotype.Repository;  

import java.util.ArrayList;  
import java.util.HashMap;  
import java.util.List;  
import java.util.Map;  

@Repository  
public class ItemRepository {  

    private static final Map<Long, Item> store = new HashMap<>(); // static  
    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);  
    }  


    // ArrayList로 감싼 이유 : 데이터를 담으면, 값을 넣어도 이 데이터의 스토어는 변함이 없기 때문에 안전하게 한 번 감싼 것이다.  
    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();  
    }  

}

참고로 실무에서는 HashMap을 쓰지 않아야 한다.
왜냐하면 멀티 스레드 환경에서 여러 개가 동시에 store에 접근하게 되면, HashMap이 싱글톤으로 생성되어있고, 또한 static이기 때문에 데이터가 꼬여버린다.
이럴 땐 ConcurrentHashMap을 사용해야 한다.

long 도 동시에 접근하면 값이 꼬일 수 있다.
이럴 때는 AtomicLong 과 같이 다른 타입을 사용해야 한다.

그리고 update를 할 때는 현재는 객체를 그대로 받고 있지만, 실무에서는 Dto를 만들고, 거기에 따라 파라미터를 넣어두는 게 더욱 더 좋다.

중복이냐 명확성이냐 중 무엇을 따라야 하는지 고민한다면 항상 명확성을 따르는 게 더 낫다.

이제 테스트를 짜보자.

package hello.itemservice.domain.item;  

import org.assertj.core.api.Assertions;  
import org.junit.jupiter.api.AfterEach;  
import org.junit.jupiter.api.Test;  

import java.util.List;  

import static org.assertj.core.api.Assertions.*;  
import static org.junit.jupiter.api.Assertions.*;  

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(savedItem.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("itemA", 10000, 10);  

        Item savedItem = itemRepository.save(item);  
        Long itemId = savedItem.getId();  

        // when  
        Item updateParam = new Item("item2", 200000, 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());  
    }  

}

결과는 다음과 같다.

2-3. 상품 서비스 HTML

참고로 HTML을 편리하게 개발하기 위해 부트스트랩 사용했다.
먼저 필요한 부트스트랩 파일을 설치한다.

참고
부트스트랩(Bootstrap)은 웹사이트를 쉽게 만들 수 있게 도와주는 HTML, CSS, JS 프레임워크이다.
하나의 CSS로 휴대폰, 태블릿, 데스크탑까지 다양한 기기에서 작동한다.
다양한 기능을 제공하여 사용자가 쉽게 웹사이 트를 제작, 유지, 보수할 수 있도록 도와준다. - 출처: 위키백과

참고로 인텔리제이에서 인식이 되지 않을 수 있으니, 서버를 실행하고,

http://localhost:8080/css/bootstrap.min.css url로 접속해서 잘 나오는지 확인해봐야 한다.

이렇게 나오면 잘 나온 것이다.
혹시 안 나오는데, 프로젝트 경로에 out폴더가 있으면 지우고 다시 서버를 실행하면 된다.

HTML, css 파일

  • /resources/static/css/bootstrap.min.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

그런데 정적 리소스여서 해당 파일을 탐색기를 통해 직접 열어도 동작하는 것을 확인할 수 있다.

참고
이렇게 정적 리소스가 공개되는 /resources/static 폴더에 HTML을 넣어두면, 실제 서비스에서도 공개가 된다.
서비스를 운영한다면 지금처럼 공개할 필요없는 HTML을 두는 것은 주의하자.

resources/static/html 디렉터리를 만들고, 다음 파일들을 순서대로 생성한다.

items.html이다.

<!DOCTYPE HTML>  
<html>  
<head>  
    <meta charset="utf-8">  
    <link 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'" 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>                <td><a href="item.html">1</a></td>  
                <td><a href="item.html">테스트 상품1</a></td>  
                <td>10000</td>  
                <td>10</td>  
            </tr>            <tr>                <td><a href="item.html">2</a></td>  
                <td><a href="item.html">테스트 상품2</a></td>  
                <td>20000</td>  
                <td>20</td>  
            </tr>            </tbody>        </table>    </div></div> <!-- /container -->  
</body>  
</html>

다음 item.html이다.

<!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>    <div>        <label for="itemId">상품 ID</label>  
        <input type="text" id="itemId" name="itemId" class="form-control"  
               value="1" readonly>  
    </div>    <div>        <label for="itemName">상품명</label>  
        <input type="text" id="itemName" name="itemName" class="form-control"  
               value="상품A" readonly>  
    </div>    <div>        <label for="price">가격</label>  
        <input type="text" id="price" name="price" class="form-control"  
               value="10000" readonly>  
    </div>    <div>        <label for="quantity">수량</label>  
        <input type="text" id="quantity" name="quantity" class="form-control"  
               value="10" 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'" type="button">상품 수정</button>  
        </div>        <div class="col">  
            <button class="w-100 btn btn-secondary btn-lg"  
                    onclick="location.href='items.html'" type="button">목록으로</button>  
        </div>    </div></div> <!-- /container -->  
</body>  
</html>

다음으로 addForm.html 이다.

<!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>    <h4 class="mb-3">상품 입력</h4>  
    <form action="item.html" method="post">  
        <div>            <label for="itemName">상품명</label>  
            <input type="text" id="itemName" name="itemName" class="formcontrol" 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="formcontrol" 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>

다음으론 editForm.html이다.


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

되는지 확인하려면 2가지 방법이 있다.

  1. 서버를 끈 상태에서 html파일을 우클릭한 후 copy path/References.. 에서 Absolute Path를 복사한 후, 브라우저에서 열어보면 열린다.

그리고 html마다 <link href="../css/bootstrap.min.css" rel="stylesheet"> 이렇게 되어있는데, css를 현재 html이 있는 경로의 상위 경로의 css 디렉터리의 bootstrap.min.css 를 참조하겠다는 의미다.

  1. 확인하는 두 번째 방법은 서버를 띄워보면 된다.

서버를 실행하고, http://localhost:8080/html/item.html 경로고 들어가보면 위와 같이 나온다.

참고로 각 버튼을 누르면 버튼에 매핑되어있는 경로로 들어간다.

그리고 정적 리소스에서 (상품)저장을 하면, POST 방식으로 보내게 되는데, 이 때 405 코드를 던져줄 것이다.

정적 리소스는 GET 매핑일 때만 받을 수 있다.

2-4. 상품 목록 - 타임리프

본격적으로 컨트롤러와 뷰 템플릿을 개발해보자.

itemservice.domain.basic 패키지에서 ItemController를 작성한다.

package hello.itemservice.domain.basic;  

import hello.itemservice.domain.item.Item;  
import hello.itemservice.domain.item.ItemRepository;  
import jakarta.annotation.PostConstruct;  
import lombok.RequiredArgsConstructor;  
import org.springframework.stereotype.Controller;  
import org.springframework.ui.Model;  
import org.springframework.web.bind.annotation.GetMapping;  
import org.springframework.web.bind.annotation.RequestMapping;  

import java.util.List;  

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

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

}

현재 GetMapping 부분은 "basic/items" view이름을 반환하는데,

resources/templates/basic 경로에서 items.html view를 반환한다는 것이다.

이전 시간 items.html을 html 경로에 만들어놨지만, 이를 Thymeleaf를 사용해서 동적으로 데이터를 뿌려줄 수 있도록 해줘야 한다.

resources/templates/basic 경로에 items.html을 다음과 같이 작성하자.

<!DOCTYPE HTML>  
<html xmlns:th="http://www.thymeleaf.org">  
<head>  
    <meta charset="utf-8">  
    <!-- 이렇게 두면, 서버를 실행한 후 렌더링하는 페이지에서 기존의 href는 날리고, th의 href로 덮어버린다. -->  
    <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/${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>
<link th:href="@{/css/bootstrap.min.css}"  
        href="../css/bootstrap.min.css" rel="stylesheet">

이렇게 두면, 서버를 실행한 후 렌더링하는 페이지에서 기존의 href는 날리고, th의 href로 덮어버린다.

첫 페이지는 다음과 같이 나온다.

Thymeleaf를 Natural Template라고 하는데,
서버를 실행시키지 않고, HTML 파일을 열려고 할 때
JSP나 다른 템플릿을 쓰면 태그들이 깨지는데, Thymeleaf는 HTML 모양은 살려놓고, 서버를 실행시킬 때만 뷰 템플릿 요소들로 나오도록 한다.

정적인 경로로 열면 아래와 같다.

하지만, 서버를 실행시키고 보면 다음과 같이 th 요소들로 덮어씌어지는 부분은 덮어씌어진다.

타임리프 간단히 알아보기

타임리프 사용 선언

  • <html xmlns:th="http://www.thymeleaf.org">

속성 변경 - th:href
th:href="@{/css/bootstrap.min.css}"

  • href="value1"th:href="value2" 의 값으로 변경한다.
  • 타임리프 뷰 템플릿을 거치게 되면 원래 값을 th:xxx 값으로 변경한다. 만약 값이 없다면 새로 생성한다.
  • HTML을 그대로 볼 때는 href 속성이 사용되고, 뷰 템플릿을 거치면 th:href 의 값이 href 로 대체되면서 동적으로 변경할 수 있다.
  • 대부분의 HTML 속성을 th:xxx 로 변경할 수 있다.

타임리프 핵심

  • 핵심은 th:xxx 가 붙은 부분은 서버사이드에서 렌더링 되고, 기존 것을 대체한다. th:xxx 이 없으면 기존 html 의 xxx 속성이 그대로 사용된다.
  • HTML을 파일로 직접 열었을 때, th:xxx 가 있어도 웹 브라우저는 th: 속성을 알지 못하므로 무시한다.
  • 따라서 HTML을 파일 보기를 유지하면서 템플릿 기능도 할 수 있다.

URL 링크 표현식 - @{...},
th:href="@{/css/bootstrap.min.css}"

  • @{...} : 타임리프는 URL 링크를 사용하는 경우 @{...} 를 사용한다. 이것을 URL 링크 표현식이라 한다.
  • URL 링크 표현식을 사용하면 서블릿 컨텍스트를 자동으로 포함한다.

상품 등록 폼으로 이동
속성 변경 - th:onclick

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

여기에는 다음에 설명하는 리터럴 대체 문법이 사용되었다.
자세히 알아보자.

리터럴 대체 - |...|
|...| :이렇게 사용한다.

  • 타임리프에서 문자와 표현식 등은 분리되어 있기 때문에 더해서 사용해야 한다.
    • <span th:text="'Welcome to our application, ' + ${user.name} + '!'">
  • 다음과 같이 리터럴 대체 문법을 사용하면, 더하기 없이 편리하게 사용할 수 있다.
    • <span th:text="|Welcome to our application, ${user.name}!|">
  • 결과를 다음과 같이 만들어야 하는데
    • location.href='/basic/items/add'
  • 그냥 사용하면 문자와 표현식을 각각 따로 더해서 사용해야 하므로 다음과 같이 복잡해진다.
    • th:onclick="'location.href=' + '\'' + @{/basic/items/add} + '\''"
  • 리터럴 대체 문법을 사용하면 다음과 같이 편리하게 사용할 수 있다.
    • th:onclick="|location.href='@{/basic/items/add}'|"

반복 출력 - th:each

  • <tr th:each="item : ${items}">
  • 반복은 th:each 를 사용한다. 이렇게 하면 모델에 포함된 items 컬렉션 데이터가 item 변수에 하나씩 포함되고, 반복문 안에서 item 변수를 사용할 수 있다.
  • 컬렉션의 수 만큼 <tr>..</tr> 이 하위 테그를 포함해서 생성된다.

변수 표현식 - ${...}

  • <td th:text="${item.price}">10000</td>
  • 모델에 포함된 값이나, 타임리프 변수로 선언한 값을 조회할 수 있다.
  • 프로퍼티 접근법을 사용한다. ( item.getPrice() )

내용 변경 - th:text

  • <td ht:text=${item.price}">10000
  • 내용의 값을 th:text 의 값으로 변경한다.
  • 여기서는 10000을 ${item.price} 의 값으로 변경한다.

URL 링크 표현식2 - @{...},

  • th:href="@{/basic/items/{itemId}(itemId=${item.id})}"
  • 상품 ID를 선택하는 링크를 확인해보자.
  • URL 링크 표현식을 사용하면 경로를 템플릿처럼 편리하게 사용할 수 있다.
  • 경로 변수( {itemId} ) 뿐만 아니라 쿼리 파라미터도 생성한다.
  • 예) th:href="@{/basic/items/{itemId}(itemId=${item.id}, query='test')}"
    • 생성 링크: http://localhost:8080/basic/items/1?query=test

URL 링크 간단히

  • th:href="@{|/basic/items/${item.id}|}"
  • 상품 이름을 선택하는 링크를 확인해보자.
  • 리터럴 대체 문법을 활용해서 간단히 사용할 수도 있다.

참고

타임리프는 순수 HTML 파일을 웹 브라우저에서 열어도 내용을 확인할 수 있고, 서버를 통해 뷰 템플릿을 거치면 동적으로 변경된 결과를 확인할 수 있다.
JSP를 생각해보면, JSP 파일은 웹 브라우저에서 그냥 열면 JSP 소스코드와 HTML이 뒤죽박죽 되어서 정상적인 확인이 불가능하다.
오직 서버를 통해서 JSP를 열어야 한다.
이렇게 순수 HTML을 그대로 유지하면서 뷰 템플릿도 사용할 수 있는 타임리프의 특징을 네츄럴 템플릿(natural templates)이라 한다.

2-5. 상품 상세

ItemController 클래스에 다음과 같이 GET 매핑 메서드를 작성하자.

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

위와 같은 매핑으로 오면, basic/item.html이 보여진다.

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>

여기서

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

이렇게 해서 아래와 같이 출력된다.

그리고

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

목록으로를 누르면 목록으로,
상품 수정을 누르면 다음과 같은 경로로 간다.

아직 구현되지 않았기 때문에 URL만 보이게 했다.

속성 변경 - th:value
th:value="${item.id}"

  • 모델에 있는 item 정보를 획득하고 프로퍼티 접근법으로 출력한다. ( item.getId() )
  • value 속성을 th:value 속성으로 변경한다.

상품수정 링크

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

목록으로 링크

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

네츄럴 템플릿이기 때문에 수정을해도, 서버를 띄우지 않고, 바로바로 확인할 수 있다는 장점이 있다.

주의! 스프링 부트 3.2 파라미터 이름 인식 문제

스프링 부트 3.2부터 자바 컴파일러에 -parameters 옵션을 넣어주어야 애노테이션의 이름을 생략할 수 있다.
발생하는 예외 는 다음과 같다.

java.lang.IllegalArgumentException: Name for argument of type [java.lang.String] not specified, and parameter name information not found in class file either. 

주로 다음 두 애노테이션에서 문제가 발생한다. @RequestParam , @PathVariable

이 경우 다음 링크를 참고해보자.

2-6. 상품 등록 폼

이제 상품 등록 폼을 작성해보자.

BasicItemController 클래스에 다음 메서드를 작성하자.

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

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

그리고 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">  
        <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>

일단은 취소, 그리고 매핑만 맞춰주었다.
바로 이후에서 등록처리를 어떻게 하는지 알아본다.

속성 변경 - th:action

  • th:action
  • HTML form에서 action 에 값이 없으면 현재 URL에 데이터를 전송한다.
  • 상품 등록 폼의 URL과 실제 상품 등록을 처리하는 URL을 똑같이 맞추고 HTTP 메서드로 두 기능을 구분한다.
    • 상품 등록 폼: GET /basic/items/add
    • 상품 등록 처리: POST /basic/items/add
  • 이렇게 하면 하나의 URL로 등록 폼과, 등록 처리를 깔끔하게 처리할 수 있다.

취소

  • 취소시 상품 목록으로 이동한다.
  • th:onclick="|location.href='@{/basic/items}'|"

2-7. 상품 등록 처리 - @ModelAttribute

이제 상품 등록 폼에서 전달된 데이터로 실제 상품을 등록 처리해본다.
상품 등록 폼은 다음 방식으로 서버에 데이터를 전달한다.

  • POST - HTML Form
    • content-type: application/x-www-form-urlencoded
    • 메시지 바디에 쿼리 파리미터 형식으로 전달 itemName=itemA&price=10000&quantity=10
    • 예) 회원 가입, 상품 주문, HTML Form 사용

요청 파라미터 형식을 처리해야 하므로 @RequestParam 을 사용하자.

참고로 입력 폼에 다음과 같이 값을 넣으면,

name의 변수 값으로 들어간다.

아래는 구글 크롬 개발자의 검사-네트워크 메뉴에서 확인한 내용이다.

상품 등록 처리 - @RequestParam

BasicItemController 클래스에 save() 메서드를 다음과 같이 작성하자.

@PostMapping("/add")  
public String addItemV1(@RequestParam String itemName,  
                   @RequestParam Integer 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 String itemName : itemName 요청 파라미터 데이터를 해당 변수에 받는다.
  • Item 객체를 생성하고 itemRepository 를 통해서 저장한다.
  • 저장된 item 을 모델에 담아서 뷰에 전달한다.

중요
여기서는 상품 상세에서 사용한 item.html 뷰 템플릿을 그대로 재활용한다.

상품 등록 처리 - @ModelAttribute

@RequestParam 으로 변수를 하나하나 받아서 Item 을 생성하는 과정은 불편했다.
이번에는 @ModelAttribute 를 사용해서 한번에 처리해보자.

다음과 같이 ItemControlleraddItemV2 메서드를 만든다.
기본의 @PostMapping("/add")는 주석처리해야 한다.

@PostMapping("/add")  
public String addItemV2(@ModelAttribute("item") Item item, Model model) {  

    itemRepository.save(item);  
    model.addAttribute("item", item);  

    return "basic/item";  
}

위와 같이 등록을 했는데, 상세로 나온다.

그런데 참고로 파라미터를 @ModelAttribute("item") Item item 이렇게 받았으면, 아래의 코드는 생략해도된다.

model.addAttribute("item", item);

왜냐하면, @ModelAttribute를 사용하면, 자동적으로, 애너테이션에 value로 속성된 문자열의 키로, 파라미터로 받은 객체가 들어가기 때문이다.

즉, 파라미터로 되어있는 @ModelAttribute("item") Item item 에서
model.addAtribute("item", item) 로 그래도 들어간다는 것이다.

그렇기 때문에 model도 파라미터로 받지 않아도 된다.

따라서 이와 같이 간단해진다.

@PostMapping("/add")  
public String addItemV2(@ModelAttribute("item") Item item) {  
        itemRepository.save(item);  
        return "basic/item";  
    }

@ModelAttribute - 요청 파라미터 처리
@ModelAttributeItem 객체를 생성하고, 요청 파라미터의 값을 프로퍼티 접근법(setXxx)으로 입력해준다.

@ModelAttribute - Model 추가
@ModelAttribute 는 중요한 한가지 기능이 더 있는데, 바로 모델(Model)에 @ModelAttribute 로 지정한 객체를 자동으로 넣어준다.
지금 코드를 보면 model.addAttribute("item", item) 가 주석처리 되어 있어도 잘 동 작하는 것을 확인할 수 있다.

모델에 데이터를 담을 때는 이름이 필요하다.
이름은 @ModelAttribute 에 지정한 name(value) 속성을 사용한다.
만약 다음과 같이 @ModelAttribute 의 이름을 다르게 지정하면 다른 이름으로 모델에 포함된다.

@ModelAttribute("hello") Item item -> 이름을 hello 로 지정
model.addAttribute("hello", item); -> 모델에 hello 이름으로 저장

주의
실행 전에 이전 버전인 addItemV1@PostMapping("/add") 를 꼭 주석처리 해주어야 한다.
그렇지 않으면 중복 매핑으로 오류가 발생한다.

//@PostMapping("/add") 이전 코드의 매핑 주석처리! 
public String addItemV1(@RequestParam String itemName,

또한 @ModelAttribute 의 이름(name(value))을 생략할 수 있다.

주의
@ModelAttribute 의 이름을 생략하면 모델에 저장될 때 클래스명을 사용한다. 이때 클래스의 첫글자만 소문자로 변경해서 등록한다.

  • 예) @ModelAttribute 클래스명 - > 모델에 자동 추가되는 이름
    • Item -> item
    • HelloWorld -> helloWorld

즉 수정된 코드는 다음과 같다.

addItemV4

addItemV3도 간편하지만, 더욱 줄일 수 있다.

@PostMapping("/add")  
public String addItemV4(Item item) {  
    itemRepository.save(item);  
    return "basic/item";  
}****

단순 타입 같은 경우는 @RequestParam이 적용되지만, 임의의 객체인 경우는 @ModelAttribute가 적용된다.

참고로 클래스이름의 첫 글자를 소문자로 바꾼 이름이 model의 key가 된다.

@ModelAttribute자체도 생략가능하다.
대상 객체는 모델에 자동 등록된다.
나머지 사항은 기존과 동일하다.

2-8. 상품 수정

상품 수정 폼 컨트롤러부터 만들자.

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

일단 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">  
        <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"  
                        th:onclick="|location.href='@{/basic/items/{itemId}(itemId=${item.id})}'|"  
                        onclick="location.href='item.html'"  
                        type="button">취소
                </button>  
            </div>        
        </div>    
    </form>
</div> <!-- /container -->  
</body>  
</html>

취소시엔 상세로 돌아가야 한다.

이제 수정할 때 저장하는 로직을 작성하자.

BasicItemController 에 작성한다.

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

서버를 실행해서 확인하면 다음과 같다.

itemB를 itemC로 바꾼다.
가격은 20000원에서 30000으로 바꾸고,
수량은 20에서 100으로 바꾼다.

저장을 누르면 다음과 같이 수정된 후, 상세로 넘어간다.

그리고 redirect가 되었음을 확인할 수도 있다.

상품 수정은 상품 등록과 전체 프로세스가 유사하다.

  • GET /items/{itemId}/edit : 상품 수정 폼
  • POST /items/{itemId}/edit : 상품 수정 처리

리다이렉트
상품 수정은 마지막에 뷰 템플릿을 호출하는 대신에 상품 상세 화면으로 이동하도록 리다이렉트를 호출한다.

  • 스프링은 redirect:/... 으로 편리하게 리다이렉트를 지원한다.
  • redirect:/basic/items/{itemId}
    • 컨트롤러에 매핑된 @PathVariable 의 값은 redirect 에도 사용 할 수 있다.
    • redirect:/basic/items/{itemId} -> {itemId}@PathVariable Long itemId 의 값 을 그대로 사용한다.

참고
HTML Form 전송은 PUT, PATCH를 지원하지 않는다.
GET, POST만 사용할 수 있다.
PUT, PATCH는 HTTP API 전송 시에 사용

스프링에서 HTTP POST로 Form 요청할 때 히든 필드를 통해서 PUT, PATCH 매핑을 사용하는 방법이 있지만, HTTP 요청상 본래는 POST 요청이다.

2-9. PRG Post/Redirect/GET

상품 등록을 완료하고 웹 브라우저의 새로고침 버튼을 클릭해보자.
상품이 계속해서 중복 등록되는 것을 확인할 수 있다.

상품등록을 다음과 같이 하자.

다음과 같이 상세로 나온다.

그리고 새로고침을 하는데, 3번을 반복한다.

그리고 목록을 확인해보자.

전체흐름은 다음과 같다.

그 이유는 다음 그림을 통해서 확인할 수 있다

POST 등록 후 새로 고침

웹 브라우저의 새로 고침은 마지막에 서버에 전송한 데이터를 다시 전송한다.
상품 등록 폼에서 데이터를 입력하고 저장을 선택하면 POST /add + 상품 데이터를 서버로 전송한다.
이 상태에서 새로 고침을 또 선택하면 마지막에 전송POST /add + 상품 데이터를 서버로 다시 전송하게 된다.
그래서 내용은 같고, ID만 다른 상품 데이터가 계속 쌓이게 된다.

이 문제를 어떻게 해결할 수 있을까? 다음 그림을 보자.

POST, Redirect GET

웹 브라우저의 새로 고침은 마지막에 서버에 전송한 데이터를 다시 전송한다.
새로 고침 문제를 해결하려면 상품 저장 후에 뷰 템플릿으로 이동하는 것이 아니라, 상품 상세 화면으로 리다이렉트를 호출해주면 된다.
웹 브라우저는 리다이렉트의 영향으로 상품 저장 후에 실제 상품 상세 화면으로 다시 이동한다.
따라서 마지막에 호출한 내용이 상품 상세 화면인 GET /items/{id} 가 되는 것이다.
이후 새로고침을 해도 상품 상세 화면으로 이동하게 되므로 새로 고침 문제를 해결할 수 있다.

BasicItemController에 다음과 같이 추가하자.

@PostMapping("/add")  
public String addItemV5(Item item) {  
    itemRepository.save(item);  
    return "redirect:/basic/items/" + item.getId() ;  
}

상품 등록 처리 이후에 뷰 템플릿이 아니라 상품 상세 화면으로 리다이렉트 하도록 코드를 작성해보자. 이런 문제 해결 방식을 PRG Post/Redirect/Get 라 한다.

서버를 실행하고 확인해보자.

위와 같이 등록을 한다.
그리고 상품 등록을 누르면 다음과 같이 된다.

이후 몇번의 새로고침을 하더라도 목록은 다음과 같이 더이상 추가되지 않는다.

그리고 POST 요청을 하면, 302 코드를 통해서 redirect하는 것을 알 수 있다.

주의
"redirect:/basic/items/" + item.getId() redirect에서 +item.getId() 처럼 URL에 변수를 더해서 사용하는 것은 URL 인코딩이 안되기 때문에 위험하다.
다음에 설명하는 RedirectAttributes 를 사용하자.

2-10. RedirectAttribute

상품을 저장하고 상품 상세 화면으로 리다이렉트 한 것 까지는 좋지만,고객 입장에서 저장이 잘 된 것인지 안 된 것인지 확신이 들지 않는다.
그래서 저장이 잘 되었으면 상품 상세 화면에 "저장되었습니다"라는 메시지를 보여달라는 요구사항이 왔다고 가정하고 이를 간단하게 해결해보자.

redirect에서 파라미터를 붙여서 저장이 되었다는 표시를 할 것이다.

BasicItemController 클래스에 다음과 같이 추가하자.

@PostMapping("/add")  
public String addItemV6(Item item, RedirectAttributes redirectAttributes) {  
    Item savedItem = itemRepository.save(item);  
    redirectAttributes.addAttribute("itemId", savedItem.getId());  
    redirectAttributes.addAttribute("status", true);  
    return "redirect:/basic/items/{itemId}";  
}

이렇게 하면, redirect url에 쿼리파라미터로 붙는다.
참고로 기본적인 URL인코딩 문제도 해결된다.

상품 등록 후, url을 보면 다음과 같이 되어있다.

그리고 redirect 하는 view가 resources/template/baisc/item.html이기 때문에 여기서 뭔가 작업을 해줘야 한다.

아래와 같이 해주자.

...
<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>
...
  • th:if : 해당 조건이 참이면 실행한다.
  • ${param.status} : 타임리프에서 쿼리 파라미터를 편리하게 조회하는 기능이다.
    • 원래는 컨트롤러에서 모델에 직접 담고 값을 꺼내야 한다. 그런데 쿼리 파라미터는 자주 사용해서 타임리프 에서 직접 지원한다.

상품 등록시 다음과 같이 나온다.

그리고 저장된 후의 상세 뷰의 페이지 소스를 보면 다음과 같다.

아래는 그냥 상세 보기 페이지의 페이지 소스다.

이처럼 뷰 템플릿에 메시지를 추가하고 실행해보면 "저장 완료" 라는 메시지가 나오는 것을 확인할 수 있다.
물론 상품 목록에 서 상품 상세로 이동한 경우에는 해당 메시지가 출력되지 않는다.

RedirectAttributes
RedirectAttributes 를 사용하면 URL 인코딩도 해주고, pathVariable , 쿼리 파라미터까지 처리해준다.

  • redirect:/basic/items/{itemId}
  • pathVariable 바인딩: {itemId}
  • 나머지는 쿼리 파라미터로 처리: ?status=true
    • JS의 Alert를 추가해줄 수도 있다.

      3. 요약

지금까지 스프링 MVC를 이용해서 간단하게 페이지를 하나 만들어보았다.
특히 마지막에 PRG(=post/redirect/get)가 인상적이었다.

그리고 타임리프도 간략하게 사용하면서 간단하게 페이지의 기능을 구현해봤다.
이로써 MVC 1편이 끝났다.

다음부턴 스프링 MVC 2편이다...!!

728x90
Comments