1. 부트스트랩 적용하기
부트스트랩(Bootstrap)
간단한 웹 페이지를 만들기 위해 프로젝트를 생성한 뒤, HTML을 편리하게 개발하기 위해 부트스트랩을 적용했다.
부트스트랩(Bootstrap)은 웹 사이트를 쉽게 만들 수 있도록 도와주는 HTML, CSS, JS 프레임워크다. 하나의 CSS로 휴대폰, 태블릿, 데스크탑까지 다양한 기기에서 작동한다. 또, 다양한 기능을 통해 웹 사이트를 제작, 유지, 보수할 수 있다.
3학년 1학기 웹프레임워크개발 강의에서 Django를 배울 때 함께 배운 적이 있는데, 이걸 사용하면 백엔드(?)스러운 딱딱한 화면도 그럴싸하게 변한다. CSS 말고 JS도 적용할 수 있어 간단하게 움직이는 웹 페이지를 만들 수 있다.
부트스트랩 공식 사이트에서 파일을 다운받고 압축을 풀어 사용해야 한다.
CSS 폴더와 JS 폴더가 있는데, CSS 폴더에서 bootstrap.min.css 파일을 복사해 내 프로젝트의 아래 경로에 붙여 넣으면 된다.
- resources/static/css/bootstrap.min.css
/resources/static 경로에 파일을 넣어두면 스프링 부트가 정적 리소스로 제공한다.
- 이 경로에 넣어둔 정적 리소스는 실제 서비스에서도 공개된다. 따라서 서비스를 운영할 경우, 공개할 필요 없는 HTML 등의 파일은 다른 곳에 두는 게 좋다.
적용하기
아래 코드처럼 부트스트랩에서 지원하는 class와 style을 적용할 수 있다.
- <link href="../css/bootstrap.min.css" rel="stylesheet">
- 부트스트랩에서 지원하는 stylesheet를 적용하겠다는 의미다.
- <div class="py-5 text-center">
- y축 패딩값을 5만큼 늘리고 텍스트를 가운데 정렬하겠다는 의미다.
- 이런 식으로 미리 정해둔 class와 style을 적당히 사용하면 된다.
<!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>
...
2. Thymeleaf 사용하기
Controller와 뷰 템플릿을 개발하면서 예전에 배운 내용도 복습하고 중요한 내용만 정리해 본다.
Controller 구현 예시
@RequestMapping("/basic/items")
- class에 이 애노테이션을 달아 입력되는 URL에서 공통으로 입력되는 부분만 적어 코드에서 중복되는 부분을 줄일 수 있다.
@Autowired
- class 멤버변수가 final로 선언되어 있다면 선언할 때 미리 값을 넣어두거나 생성자를 통해 값을 넣어줘야 한다.
- final 키워드를 지우면 의존 관계가 주입되지 않는다.
- 이때, 생성자가 1개인 경우엔 이 애노테이션을 적지 않아도 스프링이 해당 생성자에 의존관계를 주입해 준다.
- 자동으로 이미 만들어진 ItemRepository 빈을 가져와 넣어준다.
@Controller
@RequestMapping("/basic/items")
public class BasicItemController {
private final ItemRepository itemRepository;
@Autowired
public BasicItemController(ItemRepository itemRepository) {
this.itemRepository = itemRepository;
}
}
@RequiredArgsConstructor
- 아래 코드처럼 이 애노테이션을 적용하면 위에 있는 생성자를 적지 않아도 된다.
- final이 붙은 모든 멤버변수를 사용한 생성자가 자동으로 만들어진다. 우리 눈엔 안 보이지만 위와 같은 코드가 자동으로 들어가게 된다.
@PostConstruct
- 해당 빈의 의존 관계가 모두 주입되고 나면 초기화 용도로 호출된다.
- 아래에선 테스트용 데이터를 넣기 위해 사용했다.
@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));
}
}
뷰 템플릿 만들기 - 상품 목록, 상품 상세, 상품 등록 폼
위에서 정적 리소스는 /resources/static 경로에 넣어둔다고 했다. 뷰 템플릿은 동적 리소스를 /resources/templates 경로에 넣어둬야 한다.
정적 리소스를 하나하나 뷰 템플릿으로 바꾸면서 Thymeleaf 문법을 간단하게 정리해 보자.
a. 상품 목록
<html xmls:th="http://www.thymeleaf.org">
- Thymeleaf를 사용하겠다는 의미다.
<link href="..." th:href="@{/css/bootstrap.min.css}" ...>
- 프로젝트에서 절대 경로에 있는 boostrap.min.css 파일을 가져오겠다는 의미다.
- URL 링크 표현식(@{})
- Thymeleaf에서 URL 링크를 사용할 땐 @{...}을 사용한다.
- URL 링크 표현식을 사용하면 서블릿 컨텍스트를 자동으로 포함한다. (참고)
- 속성 변경(th:href)
- 'th:'를 앞에 붙이면 기존 HTML 코드를 날려버리고 'th:'가 붙은 코드로 덮어 사용하겠다는 뜻이 된다.
- href="v1"을 th:href="v2"의 값으로 변경한다. → 만약 값이 없다면 새로 생성한다.
- HTML을 그대로 볼 때는 href 속성이 사용되고, 뷰 템플릿을 거치면 th:href 값이 href로 대체되면서 동적으로 변경할 수 있다.
- 'th:'를 앞에 붙이면 기존 HTML 코드를 날려버리고 'th:'가 붙은 코드로 덮어 사용하겠다는 뜻이 된다.
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<link href="../css/bootstrap.min.css"
th:href="@{/css/bootstrap.min.css}" rel="stylesheet">
</head>
...
Thymeleaf 핵심
'th:xxx'가 붙은 부분은 서버 사이드에서 렌더링(SSR) 되고, 기존 것을 대체한다. 'th:xxx'가 없으면 기존 HTML의 'xxx' 속성이 그대로 사용된다.
HTML 파일을 직접 열었을 때, 'th:xxx'가 있어도 웹 브라우저는 'th:' 속성을 알지 못하기 때문에 무시한다. 따라서 HTML 파일 보기를 유지하면서 템플릿 기능도 할 수 있다.
<th:onclick="|location.href='@{/basic/items/add}'|">
- 리터럴 대체(|...|)
- Thymeleaf에서 문자와 표현식 등은 분리돼 있기 때문에 아래처럼 '+' 기호로 더해서 사용해야 한다.
- th:onclick="'location.href=' + '/' + @{/basic/items/add} + '/'"
- 다음과 같이 리터럴 대체 문법을 사용하면, 기호를 추가하지 않고 편리하게 사용할 수 있다.
- th:onclick="|location.href='@{/basic/items/add}'|"
- Thymeleaf에서 문자와 표현식 등은 분리돼 있기 때문에 아래처럼 '+' 기호로 더해서 사용해야 한다.
<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>
...
<tr th:each="item : ${items}">
- 반복 출력(th:each)
- 모델에 포함된 items 컬렉션 데이터가 item 변수에 하나씩 포함되고, 반복문 안에서 item 변수를 사용할 수 있다.
- 컬렉션의 수만큼 <tr>...</tr>이 하위 태그를 포함해서 생성된다.
<tr th:text="${item.price}">10000</td>
- 변수 표현식(${...})
- 모델에 포함된 값이나, Thymeleaf 변수로 선언한 값을 조회할 수 있다.
- 프로퍼티 접근법을 사용한다. → item.getPrice()
- 내용 변경(th:text)
- 내용의 값을 'th:text' 값으로 변경한다.
- 여기서는 10000이 ${item.price} 값으로 변경된다.
th:href="@{/basic/items/{itemId}(itemId=${item.id})}"
- URL 링크 표현식2(@{...})
- 경로를 템플릿처럼 치환할 수 있도록 하여 편리하게 사용할 수 있다.
- 경로를 나타내고 뒤에 괄호를 통해 경로에 들어갈 변수를 선언한다.
- 경로 변수({itemId})뿐만 아니라 쿼리 파라미터도 생성한다.
- ex. th:href="@{/basic/items/{itemId}(itemId=${item.id}, query='test')}"
- 생성 링크: http://localhost:8080/basic/items/1?query=test
- ex. th:href="@{/basic/items/{itemId}(itemId=${item.id}, query='test')}"
- 경로를 템플릿처럼 치환할 수 있도록 하여 편리하게 사용할 수 있다.
- URL 링크 간단하게 만들기
- 리터럴 대체 문법을 사용해 간단히 줄일 수 있다.
- th:href="@{|/basic/items/${item.id}|}"
<div>
<table class="table">
...
<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>
참고
Thymeleaf는 순수 HTML 파일을 웹 브라우저에서 열어도 내용을 확인할 수 있고, 서버를 통해 뷰 템플릿을 거치면 동적으로 변경된 결과를 확인할 수 있다.
JSP를 생각해 보면, JSP 파일은 웹 브라우저에서 그냥 열면 JSP 소스 코드와 HTML이 뒤죽박죽 돼서 정상적인 확인이 불가능하다. 오직 서버를 통해서 JSP를 열어야 한다.
이렇게 순수 HTML을 유지하면서 뷰 템플릿도 사용할 수 있는 Thymeleaf의 특징을 Natural Templates라고 한다.
b. 상품 상세
th:value="${item.id}"
- 속성 변경(th:value)
- 모델에 있는 item 정보를 얻어 프로퍼티 접근법으로 출력한다. → item.getId()
- value 속성을 th:value 속성으로 변경한다.
<div>
<label for="itemId">상품 ID</label>
<input type="text" id="itemId" name="itemId" class="form-control"
value="1" th:value="${item.id}" readonly>
</div>
c. 상품 등록 폼
<form action="item.html" th:action method="post">
- 속성 변경(th:action)
- HTML form에서 action에 값이 없으면 현재 URL에 데이터를 전송한다.
- 상품 등록 폼의 URL과 실제 상품 등록을 처리하는 URL을 똑같이 맞추고 HTTP 메서드로 두 기능을 구분한다.
- 상품 등록 폼: GET /basic/items/add
- 상품 등록 처리: POST /basic/items/add
<div class="py-5 text-center">
<h2>상품 등록 폼</h2>
</div>
<h4 class="mb-3">상품 입력</h4>
<form action="item.html" th:action method="post">
...
뷰 템플릿 만들기 - 상품 등록 처리(@ModelAttribute)
상품 등록 폼은 POST(HTML form) 방식으로 서버에 데이터를 전달한다. 요청 파라미터 형식을 처리해야 하므로, @RequestParam 애노테이션을 사용하면 요청 파라미터 데이터를 변수에 받아올 수 있다. 그러나 변수를 하나하나 받아 Item을 만드는 과정은 파라미터가 많아질수록 길이만 길어진다. 이때 @ModelAttribute 애노테이션을 사용하면 코드를 간결하게 작성할 수 있다.
a. @ModelAttribute 사용
- Item 객체를 생성하고, 요청 파라미터의 값을 프로퍼티 접근법으로 입력해 준다.
- Model에 @ModelAttribute로 지정한 객체를 자동으로 넣어준다. 따라서 아래 주석 처리된 부분이 없어도 잘 동작한다.
- Model에 데이터를 담을 때는 이름이 필요하다. name(value) 속성을 사용해 지정할 수 있다.
- ex. @ModelAttribute("hello") Item item → 이름을 hello로 지정
- ex. model.addAttribute("hello", item); → Model에 hello 이름으로 저장
@PostMapping("/add")
public String addItemV2(@ModelAttribute("item") Item item, Model model) {
itemRepository.save(item);
//model.addAttribute("item", item); // 자동 추가, 생략 가능
return "basic/item";
}
b. @ModelAttribute 이름 생략
이름을 생략하면 Model에 저장될 때 class 명을 사용한다. 이때 class의 첫 글자만 소문자로 변경해 등록한다.
- ex. Item → item
@PostMapping("/add")
public String addItemV3(@ModelAttribute Item item) {
itemRepository.save(item);
return "basic/item";
}
c. @ModelAttribute 자체 생략
애노테이션 자체를 생략해도 대상 객체는 Model에 자동 등록된다.
@PostMapping("/add")
public String addItemV4(Item item) {
itemRepository.save(item);
return "basic/item";
}
뷰 템플릿 만들기 - 상품 수정
상품 수정은 상품 등록과 전체 프로세스가 비슷하다.
- 상품 수정 폼: GET /items/{itemId}/edit
- 상품 수정 처리: POST /items/{itemId}/edit
상품 수정은 마지막에 뷰 템플릿을 호출하는 대신, 상품 상세 화면으로 이동하도록 리다이렉트를 호출한다.
- 스프링은 'redirect:/...'로 리다이렉트를 지원한다.
- Controller에 매핑된 @PathVariable 값은 redirect에도 사용할 수 있다. → {itemId}에 itemId가 들어간다.
@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId, @ModelAttribute Item item) {
itemRepository.update(itemId, item);
return "redirect:/basic/items/{itemId}";
}
리다이렉트(HttpStatus 300)에 관한 내용은 아래 링크를 참고하자.
3. 리다이렉트
PRG Post/Redirect/Get
지금까지 구현한 상품 등록 처리 Controller는 심각한 문제가 있다.
- 상품 등록을 완료하고 웹 브라우저의 새로고침 버튼을 클릭하면 상품이 계속해서 중복으로 등록된다.
그 이유는 다음 그림을 통해 확인할 수 있다.
- 웹 브라우저의 새로고침은 마지막에 서버에 전송한 데이터를 다시 전송한다. → 중복 데이터 발생
- 상품 등록 폼에서 데이터를 입력하고 저장을 선택하면, POST /add + 상품 데이터를 서버로 전송한다.
- 이 상태에서 새로고침을 또 선택하면 마지막에 전송한 위의 데이터를 다시 서버로 전송하게 된다.
아래 그림을 보면서 이 문제를 해결해 보자.
- 새로고침 문제를 해결하려면, 상품 저장 후에 뷰 템플릿으로 이동하는 게 아니라 상품 상세 화면으로 리다이렉트를 호출해 주면 된다.
- 웹 브라우저는 리다이렉트의 영향으로 상품 저장 후에 실제 상품 상세 화면으로 다시 이동한다. 따라서 마지막에 호출한 내용이 상품 상세 화면인 GET /items/{id}가 된다.
코드로 나타내보면 아래와 같다.
@PostMapping("/add")
public String addItemV5(Item item) {
itemRepository.save(item);
return "redirect:/basic/items/" + item.getId();
}
주의
redirect에서 + item.getId()처럼 URL에 변수를 더해 사용하면 URL 인코딩이 안되기 때문에 위험하다. 따라서 다음에 설명하는 RedirectAttributes를 사용하자.
RedirectAttributes
고객 입장에서 상품이 저장이 된 것인지 아닌지 확인할 수 있는 화면을 보여주면 고객 만족도가 올라간다. 간단하게 추가해 보자.
- 리다이렉트를 할 때 간단히 status=true를 추가하고, 뷰 템플릿에서 이 값이 있으면 "저장되었습니다."라는 메시지를 출력하게 하자. 실행해 보면 다음과 같은 리다이렉트 결과가 나온다.
- http://localhost:8080/basic/items/3?status=true
@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}";
}
a. RedirectAttributes
- 이걸 사용하면 URL 인코딩도 해주고, pathVariable과 쿼리 파라미터까지 처리해 준다.
- redirect:/basic/items/{itemId}
- pathVariable 바인딩: {itemId}
- 나머지는 쿼리 파라미터로 처리: ?status=true
- redirect:/basic/items/{itemId}
b. 뷰 템플릿에 메시지 추가
th:if
- 해당 조건이 참이면 실행한다.
<div class="container">
<div class="py-5 text-center">
<h2>상품 상세</h2>
</div>
<!-- 추가 -->
<h2 th:if="${param.status}" th:text="'저장 완료!'"></h2>
...