[Spring] #7. 스프링 MVC - 웹 페이지 만들기 (김영한_인프런_스프링MVC1)

bien·2023년 4월 28일
0

Spring_MVC1

목록 보기
8/8

프로젝트 생성

스프링 부트 스타터 사이트로 이동해서 스프링 프로젝트 생성
https://start.spring.io

  • 프로젝트 선택
    - Project: Gradle Project
    - Language: Java
    - Spring Boot: 2.7.9
    (version 이걸로 변경하면 에러 안뜨고 프로젝트 실행가능)
  • Project Metadata
    - Group: hello
    - Artifact: item-service
    - Name: item-service
    - Package name: hello.itemservice
    패키지 명에 특수기호 같은 거 넣는 거 아님
    - Packaging: Jar (주의!)
    - Java: 11
  • Dependencies: Spring Web, Thymeleaf, Lombok

import할땐 항상 파일 들어가서 build.gradle 선택해야 함.
lombok 셋팅: settings-> annotation processors 검색 -> Enable annotation processing에 체크
gradle 설정: settings -> gradle검색 -> Build and run using, Run test using:둘다 intelliJ IDEA로 수정

셋팅 끝나면 실행 제대로 되는지 확인.
localhost8080 : whitelable error page 나오는지 확인

welcome 페이지 추가

/resources/static/index.html
정적 리소스 넣는 곳 : resources/static
동적 리소스 넣는 곳 : resources/templates

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

동작 확인

  • 기본 메인 클래스 실행(SpringmvcApplication.main()
  • http://localhost:8080 호출해서 Welcome 페이지가 나오면 성공

요구사항 분석

상품 도메인 모델

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

상품 관리 기능

  • 상품 목록
  • 상품 상세
  • 상품 등록
  • 상품 수정

서비스 화면

서비스 제공 흐름

검은색이 컨트롤러 (항상 컨트롤러를 통해서 뷰가 호출된다).

요구사항이 정리되고, 디자이너, 웹 퍼블리셔, 백엔드 개발자가 업무를 나누어 진행한다.

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

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

React, Vue.js 이런 부분은 프론트엔드 개발자가 되게 숙련되어 있다. 개발이라는 것이 일정이 있어서 이 부분까지 백엔드가 다 수행하면 작업이 병렬로 진행되기 힘들어 일정을 지키기가 힘들다. 사용자랑 직접 닿는 부분은 대부분 프론트엔드 개발자가 수행한다. admin 부분, 외부에 노출되지 않는 부분은 개발자들이 뚝딱뚝딱 만들고는 한다. admin이 외부에 제공되거나 동적이고 복잡하면 또 다른 이야기가 됨.


상품 도메인 개발

Item- 상품 객체

package hello.itemservice.domain.item;

import lombok.Data;
import lombok.Getter;
import lombok.Setter;

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

price, quantity 둘다 null일 가능성이 있어 Integer로 설정

@Data

@Data 사용하면 @Getter, @Setter, @RequiredArgsConstructor 등등 다 만들어준다.
=> 매우 위험하다. 위험한거 강조하려고 사용한 것.

getter, setter정도만 직접 애노테이션 찍어서 사용해야 한다. 핵심 도메인 모델에 @Data를 붙여 내가 정확히 설정하지 않은 기능들이 존재하는 것은 예측하지 못한 작동이 있을 수 있어 위험하다.

단순히 데이터를 왔다갔다할때 사용하는 DTO의 경우 비교적 @Data를 사용해도 괜찮다. 그래도 그것 역시 확인하고 사용해야한다.

@Data는 많은 주의가 필요하다. 우리는 예제니까 그냥쓰겟다!

ItemRepository - 상품 저장소

package hello.itemservice.domain.item;

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<>();
    private static Long sequence = 0L; //static

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

}

@Repository 안에 @Conponent가 있음 :컴포넌트 스캔의 대상이 된다.

priavte static final Map<Long, Item> store = new HashMap<>();

  • key가 Id(Long타입)이어서 Long타입 설정.
  • 실무에서는 HashMap 사용하지 않는다.
    멀티스레드 환경에서 여러 스레드가 동시에 store에 접근하는 경우 HashMap을 사용하면 안된다. 사용을 원하는 경우 ConcurrentHashMap을 사용해야 한다.
  • Long도 동시에 접근하면 값이 꼬일 수 있으므로 AtomicLong을 사용해야 한다.

private static Long sequence = 0L;

  • static사용한 것 유의
  • 따로 new해서 객체를 생성한 경우 static을 붙여주지 않으면 객체 생성 수 만큼 별도의 store이 생성될 수 있다. 이때 static을 붙여주면 값을 공유하므로 이를 예방할 수 있다. 다만 스프링 컨테이너 안에서는 어차피 싱글톤이기 때문에 static 사용이 의무가 아니다.
    public List<Item> findALl() {
        return new ArrayList<>(store.values());
    }
  • ArrayList에 값을 감싸서 반환. 외부에서 ArrayList의 값을 조작해도 store의 값에는 영향을 미치지 못하므로 안전성을 위해 감싸서 반환함.
    public void update(Long itemId, Item updateParam) {
        Item findItem = findById(itemId);
        findItem.setItemName(updateParam.getItemName());
        findItem.setPrice(updateParam.getPrice());
        findItem.setQuantity(updateParam.getQuantity());
    }
  • 현재 해당 메서드에서 Item 객체 중 id만 사용하지 않는다. 이런 경우 사용되지 않는 id를 빼고 실제 사용되는 3개의 변수만 가지고 있는 ItemParam같은 DTO를 생성하는 것이 맞다.
  • 다른 개발자 입장에서 왜 updateParam.setId()는 하지 않았는지 의문이 생길 수 있다. 프로젝트가 작고 내가 인식할 수 있는 범위 안에 있기 때문에 그냥 엔티티를 전달했을 뿐, 프로젝트가 거대해지면 깔끔하게 DTO를 건내는 것이 맞다.
  • 하나 더 만드는게 귀찮다고 느낄 수 있다. 그러나 설계상 명확성은 중요하다. 중복이라는 문제에 거부감을 느낄 수 있지만 중복과 명확성 중 명확성을 더 우선시해야 한다.

ItemRepositoryTest - 상품 저장소 테스트

최근 junit5에는 public 없어도 됨.

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.*;

class ItemRepositoryTest {

    ItemRepository itemRepository = new ItemRepository();

    @AfterEach
    void afterEach() {
        itemRepository.clearStore();
    }

    @Test
    void save() {
        // give
        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() {
        // give
        Item item = new Item("item1", 10000, 1);

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

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

    }

}

상품 서비스 HTML

핵심 비즈니스 로직을 개발하는 동안, 웹 퍼블리셔는 HTML 마크업을 완료했다. 다음 파일들을 경로에 넣고 잘 작동하는지 확인해보자.

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

intellij가 복사하면 잘 인식을 못하는 오류가 생기곤 한다. out folder에서 build한 게 뜨는지 확인해보자. (css 폴더안의 bootstrap.min.css 파일이 보여야함.) 안보이는 경우 out 폴더를 지우고 서버 재시작. 그럼 서버가 다시 컴파일하면서 파일들을 만들어낸다.

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

참고로 /resources/static에 넣어두었기 때문에 스프링 부트가 정적 리소스를 제공한다.

  • http://localhost:8080/html/items.html

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

  • items.html에 우클릭 > Copy Path > Absolute Path > 주소창에 검색

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

타임리프 파일로 수정할 예정이어서 static 폴더안에 넣은 것. 실제 서비스를 제공하고 있는 상황에서는 이걸 특정폴더에 넣어놓는게 맞고, static안에 넣어 놓으면 안됨. 서비스를 오픈할 때 누군가 여길 열면 열려버림.

상품목록 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>

상품 상세 HTML

resources/static/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>

상품 등록 폼 HTML

resources/static/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="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>

상품 수정 폼 HTML

resources/static/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="form-control" 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="form-control" 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>

상품 목록 - 타임리프

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

BasicItemController

package hello.itemservice.web.basic;

import hello.itemservice.domain.item.Item;
import hello.itemservice.domain.item.ItemRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
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 javax.annotation.PostConstruct;
import java.util.List;

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

    private final ItemRepository itemRepository;

    // 생략가능
//    @Autowired
//    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));
    }

}

@Autowired: 우리가 만든 ItemRepository(스프링빈으로 등록된)이 주입된다. 스프링에서 이렇게 생성자가 딱 하나만 있으면 Autowired 생략 가능.

@RequiredArgsConstructor

  • final이 붙은 멤버변수만 사용해서 생성자를 자동으로 만들어준다.
public BasicItemController(ItemRepository itemRepository) {
    this.itemRepository = itemRepository;
}

이렇게 생성자가 딱 1개만 있으면 스프링이 해당 생성자에 @Autowired로 의존관계를 주입해준다.

  • 따라서 final 키워드를 빼면 안된다!, 그러면 ItemRepository의존관계 주입이 안된다.
  • 스프링 핵심원리 - 기본편 강의 참고

테스트용 데이터 추가
@PostContruct: 해당 빈의 의존관계가 모두 주입되고 나면 초기화 용도로 호출된다.

  • 테스트용 데이터가 없으면 회원 목록 기능이 정상 동작하는지 확인하기 어렵다. 여기서는 간단히 테스트용 데이털르 넣기 위해서 사용했다.

items.html 정적 HTML 을뷰 템플릿(templates)영역으로 복사하고 다음과 같이 수정하자
/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="items.html" th:href="@{/basic/items/{itemId}(itemId=${item.id})}" th:text="${item.id}">회원id</a></td>
                <td><a href="items.html" th:href="@{|/basic/items/${item.id}|}" th:text="${item.itemName}">상품명</a></td>
                <td><a th:text="${item.price}"10000</a></td>
                <td><a th:text="${item.quantity}"10</a></td>
            </tr>
            </tbody>
        </table>
    </div>

</div> <!-- /container -->

</body>
</html>

타임리프 간단히 알아보기

th가 있으면 href를 기존걸 날리고 입력한걸로 덮어버림. 항상 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가 붙은 부분은 서버사이드에서 렌더링되고, 기존 것을 대체한다. 없으면 기존 html의 xxx 속성이 그대로 사용된다.
  • HTML을 파일로 직접 열었을 때, th:xxx가 있어도 웹브라우저는 th: 속성을 알지 못하므로 무시한다.
  • 따라서 HTML을 파일 보기를 유지하면서 템플릿 기능도 할 수 있다.
    JSP의 경우 HTML파일 형식을 전혀 유지하지 못함.

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

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

서블릿 컨텍스트: 옛날에는 경로가 하나 더 있었다. 한 톰캣 서버에 여러 war 파일을 넣어서 돌릴때는 경로를 지정해줘야 했었음. 지금은 basic/items이렇게 여는데 얘전에는 applicationA/basic/items이런식으로 경로를 지정하는게 있었다. 이런 것을 서블릿 컨텍스트라 하는데 지금은 거의 안쓴다.

상품 등록 폼으로 이동
속성 변경 - th:onclick
onclick="location.href='addForm.html'"
th:onclick="|location.href='@{/basic/items/add}'|"
여기에는 다음에 설명하는 리터럴 대체 문법이 사용되었다. 자세히 알아보자

리터럴 대체 - |...|
|...|: 이렇게 사용한다.
타임리프에서 문자와 표현식 등은 분리되어 있기 때문에 더해서 사용해야 한다.
타임리프도 java String 더하듯이 +해서 더해줘야 한다.( String name = "userName:" + username; 처럼) 근데 리터럴 대체안에 다 욱여넣으면 알아서 대체해준다. 아주그냥 할 수 있는건 다 생략해버리는구나.
<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 th:text="${item.price}">10000</td>
  • 내용의 값을 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)이라한다.

그냥 IDE에서 파일을 열면, 웹에서 나는 th는 모르는데? 하고 무시하고 우리가 변경되리라 예상하고 입력한 값으로만 화면을 열어줌. jsp의 경우 loop를 도는 등 템플릿 코드가 함께있으면 이미 html이라 볼 수 없을정도로 깨진 화면을 보여주게 되는 반면, 타임리프는 코드가 완전히 html처럼 보여준다! 뷰 화면을 html코드는 그대로 살리면서 뷰 템플릿으로 렌더링 될때만 조금씩 치환한다. 그래서 화면은 크게 깨뜨리지 않는다.

파일을 바로 연 경우

서버를 올려서 렌더링을 한 경우


상품 상세

상품 상세 컨트롤러와 뷰를 개발하자.

BasicItemController 추가

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

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

상품 상세 뷰
정적 HTML을 뷰템플릿(templates) 영역으로 복사하고 다음과 같이 수정하자.
/resources/static/item.html -> 복사 -> /resources/templates/basic/item.html

/resources/templates/basic/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>

속성 변경 - 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}'|"


상품 등록 폼

상품 등록 폼 - BasicItemController에 추가

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

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

상품 등록 폼 뷰

정적 HTML을뷰템플릿(templates) 영역으로 복사하고 다음과 같이 수정하자.
/resources/static/addForm.html -> 복사 /resources/templates/basic/addForm.html

/resources/templates/basic/addForm.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>

    <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="/basic/items/add"
th:action

  • HTML form에서 action에 값이 없으면 현재 URL에 데이터를 전송한다.
  • 상품 등록 폼의 URL과 실제 상품 등록을 처리하는 URL을 똑같이 맞추고 HTTP 메서드로 두 기능을 구분한다.
    - 상품 등록 폼: GET /basic/items/add
    • 상품 등록 처리: POST /basic/items/add
  • 이렇게 하면 하나의 URL로 등록 폼과, 등록 처리를 깔끔하게 처리할 수 있따.
    <span style="color"grey/>같은 주소를 공유하는 경우, th:action이후 주소를 입력하지 않아도 된다. 같다는 것을 강조하기위해 주소를 아예 입력하지 않는것도 좋은 방법.

취소

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

상품 등록 처리 - @ModelAttribute

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

POST-HTML Form

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

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

상품 등록처리 - @RequestParam

addItemV1 - BasicItemController에 추가

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

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

💬 @ReqeustParma에서 변수명은 어떻게 아나요?
✔️ html에서 태그 안의 name에 입력한 값으로 넘어옵니다. 따라서 html에서의 name이 해당 변수명이 됩니다.

상품 등록처리 - @ModelAttribute

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

addItemV2 - 상품등록처리 - ModelAttribute

   /**
     * @ModelAttribute("item") Item item
     * model.addAttribute("item", item); 자동 추가 
     */
    @PostMapping("/add")
    public String addItemV2(@ModelAttribute("item") Item item, Model model) {
        itemRepository.save(item);
//        model.addAttribute("item", item); //생략가능

        return "basic/item";
    }

@ModelAttribute - 요청 파라미터 처리 기능
1. Item객체를 생성.
2. 요청 파라미터와 값을 프로퍼티 접근법(setXxx)으로 입력

@ModelAttribute - Model 추가 기능
모델(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,

addItemV3 - 상품등록처리 - ModelAttribute 이름 생략

    /**
     * @ModelAttribute name 생략 가능
     * model.addAttribute(item); 자동 추가, 생략 가능
     * 생략시 model에 저장되는 name은 클래스명 첫글자만 소문자로 등록 Item -> item
     */
     
    @PostMapping("/add")
    public String addItemV3(@ModelAttribute Item item) {
        // @ModelAttribute("item") Item item
        itemRepository.save(item);
        return "basic/item";
    }

@ModelAttribute의 이름을 생략할 수 있다.

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

  • Item -> item
  • HelloWorld -> helloWorld

addItemV4 - 상품등록처리 - ModelAttribute 전체 생략

    /**
     * @ModelAttibute 자체 생략 가능
     * model.addAttribute(item) 자동 추가
     */
    @PostMapping("/add")
    public String addItemV4(Item item) {
        itemRepository.save(item);
        return "basic/item";
    }

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

이렇게까지 생략하는게 맞는걸까. 너무 생략을 해버려서 코드가 신뢰가 안갈지경인디


상품 수정

상품 수정 폼 컨트롤러

BasicItemController에 추가

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

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

상품 수정 폼 뷰
정적 HTML을 뷰 템플릿(templates)영역으로 복사하고 다음과 같이 수정하자.
/resources/static/editForm.html => 복사 => /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">
        <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>

상품 수정 폼은 상품 등록과 유사하고, 특별한 내용이 없다.

상품 수정 개발

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

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

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

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

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

참고
리다이렉트에 대한 자세한 내용은 모든 개발자를 위한 HTTP 웹 기본 지식 강의를 참고하자.

참고
HTML Form 전송은 PUT, PATCH를 지원하지 않는다. GET(query URL에 붙어서 넘어감), POST만 사용할 수 있다. PUT, PATCH는 HTTP API 전송시에 사용. 스프링에서 HTTP POST로 Form 요청할 때 히든 필드를 통해서 PUT, PATCH 매핑을 사용하는 방법이 있지만, HTTP 요청상 POST 요청이다.


PRG Post/Redirect/Get

사실 지금까지 진행한 상품 등록 처리 컨트롤러는 심각한 문제가 있다.(addItemV1 ~ addItemV4) 상품 등록을 완료하고 웹 브라우저의 새로고침 버튼을 클릭해보자. 상품이 계속해서 중복 등록된는 것을 확인할 수 있다.

전체 흐름

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

POST 등록 후 새로 고침

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

POST, Rediret GET


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

BasicItemController에 추가

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

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

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

RedirectAttributes

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

BasicItemController에 추가

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

리다이렉트 할 때 간단히 status-true를 추가해보자. 그리고 뷰 템플릿에서 이 값이 있으면, 저장되었습니다. 라는 메시지를 출력해보자.

실행해보면 다음과 같은 리다이렉트 결과가 나온다.
http://localhost:8080/basic/items/3?status=true

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

  • redirect:/basci/items/{itemId}
    • pathVariable 바인딩: {itemId}
    • 나머지는 쿼리 파라미터로 처리: ?status=true

뷰 템플릿 메시지 추가
resources/templates/basic/item.html

        <h2>상품 상세</h2>
    </div>

    <h2 th:if="${param.status}" th:text="'저장 완료'"></h2>
  • th:if: 해당 조건이 참이면 실행
  • ${param.status}: 타임리프에서 쿼리 파라미터를 편리하게 조회하는 기능
    • 원래는 컨트롤러에서 모델에 직접 담고 값을 꺼내야 한다. 그런데 쿼리 파라미터는 자주 상용해서 타임 리프에서 직접 지원한다.
    • {param}: 예약어. 기본적으로 제공하는 것. httpReqeustParameter를 그냥 가져다 쓸 수 있음.

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

profile
Good Luck!

0개의 댓글