일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
- 스프링 mvc1 - 서블릿
- 스프링 db2 - 데이터 접근 기술
- 자바의 정석 기초편 ch2
- 스프링 mvc1 - 스프링 mvc
- 스프링 mvc2 - 로그인 처리
- 자바의 정석 기초편 ch14
- 자바의 정석 기초편 ch7
- 코드로 시작하는 자바 첫걸음
- 자바의 정석 기초편 ch9
- 스프링 db1 - 스프링과 문제 해결
- jpa 활용2 - api 개발 고급
- 타임리프 - 기본기능
- 자바의 정석 기초편 ch12
- 스프링 입문(무료)
- 스프링 고급 - 스프링 aop
- 자바의 정석 기초편 ch11
- 자바의 정석 기초편 ch4
- 자바의 정석 기초편 ch6
- 자바의 정석 기초편 ch13
- jpa - 객체지향 쿼리 언어
- 게시글 목록 api
- 2024 정보처리기사 수제비 실기
- 자바의 정석 기초편 ch1
- 자바의 정석 기초편 ch3
- 자바의 정석 기초편 ch5
- 자바의 정석 기초편 ch8
- 2024 정보처리기사 시나공 필기
- @Aspect
- 스프링 mvc2 - 타임리프
- 스프링 mvc2 - 검증
- Today
- Total
나구리의 개발공부기록
스프링 MVC - 웹페이지 만들기, 프로젝트 생성 및 요구사항 분석, 요구사항 분석, 상품 도메인 개발, 상품 서비스 HTML, 상품 목록 - 타임리프 본문
스프링 MVC - 웹페이지 만들기, 프로젝트 생성 및 요구사항 분석, 요구사항 분석, 상품 도메인 개발, 상품 서비스 HTML, 상품 목록 - 타임리프
소소한나구리 2024. 3. 4. 23:35 출처 : 인프런 - 스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술 (유료) / 김영한님
유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용
1. 프로젝트 생성 및 요구사항 분석
1) 프로젝트 생성
(1) Project
- Gradle, Java, 최신 스프링 부트
(2) Project Metadata
- Group : hello
- Artifact, Name : item-service
- Packaging Name : hello.itemservice(패키지 네임에는 특수기호 없이 설정)
- Packaging: Jar
- Java : 17
(3) Dependencies
- Spring Web
- Thymeleaf
- Lombok
(4) Welcome 페이지 추가
2) 요구사항 분석
(1) 상품 도메인 모델
- 상품 ID, 상품명, 가격, 수량
(2) 상품 관리기능
- 상품 목록, 상품 상세, 상품 등록, 상품 수정, 상품 삭제는 직접 구현 해볼 수 있으면 구현
(3) 서비스 화면
(4) 서비스 제공 흐름
- 검은색 네모 -> 컨트롤러
- 하얀색 네모 -> 뷰
(5) 개발 환경 가상 시나리오
- 백엔드 개발자는 기본적으로 이런 시나리오에서 개발할 줄 알아야 상황에 따라 개발을 할 수 있음
- 디자이너 : 요구사항에 맞춰 디자인, 디자인 결과물을 웹 퍼블리셔에게 전달
- 웹 퍼블리셔 : 디자이너에게 받은 디자인을 기반으로 HTML, CSS를 만들어서 개발자에게 제공
- 백엔드 개발자 : 디자이너, 웹 퍼블리셔를 통해 HTML 화면이 나오기 전까지 시스템 설계, 핵심 비즈니스 모델 개발, HTML이 나오면 이 HTML을 뷰 템플릿으로 변환해서 동적으로 화면을 그리고 웹 화면을 제어
(6) 웹 프론트엔드 개발자가 있으면? (대부분의 기업 환경)
- React, Vue.js 같은 웹 클라이언트 기술을 사용하고 웹 프론트엔드 개발자가 있으면 웹 퍼블리셔의 역할을 프론트엔드 개발자가 대신 하기도 함
- 웹 프론트엔드 개발자가 웹 클라이언트 기술들을 사용하여 HTML을 동적으로 만드는 역할과 웹 화면의 흐름을 담당하므로 백엔드 개발자는 HTTP API를 통해 웹 클라이언트가 필요로하는 기능과 데이터만 제공하면 됨
- 백엔드 개발자가 내부에서 사용하는 간단한 어드민의 경우 React, Vue.js 을 조금 배워서 만드는 경우도 있음(대부분은 개발 일정을 맞추기 위해 따로 따로 개발을 함)
2. 상품 도메인 개발
1) 개발
(1) Item - 상품객체
- domain.item 패키지 구조를 만들어서 작성
- 실무에서는 @Data를 domain 객체에 사용하진않으며 필요한 것만 분리해서 사용하는 것을 권장하며 DTO에서는 사용해도 괜찮음
package hello.itemservice.domain.item;
// 실무 에서는 핵심 도메인에 사용 하게 되면 많이 위험함 -> @Getter, @Setter 정도나 필요한 것만 분리 해서 사용 해야함
@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;
}
}
(2) ItemRepository - 상품저장소
- Map<Long, Item> store = new HashMap<>(): 실무에서는 멀티쓰레드에서 동시성 문제가 있으므로 ConcurrentHashMap을 사용해야함
- long sequence: 마찬가지로 동시성 문제로 AtomicLong이나 다른 것을 사용해야함
- 위 두 변수를 static으로 생성하여 계속 객체가 생성되지 않도록 방지
- return new ArrayList<>(store.values()): store.values()를 직접 반환해도 되지만 안전하게 한번더 감싸서 반환
- update(): 원래는 id가 없는 별도의 DTO객체를 생성한 후 그 DTO객체를 파라미터로 받아서 명확하게 도메인과 구분해서 사용해야하지만 예제이므로 간단하게 작성
@Repository // ComponentScan 대상이 됨
public class ItemRepository {
// 실무에서는 HashMap을 사용하면 안됨 -> 멀티쓰레드에서 동시성 문제가 있어서 ConcurrentHashMap<>() 을 사용해야함
private static final Map<Long, Item> store = new HashMap<>();
// 마찬가지로 동시성 문제로 실무에서는 AtomicLong 등등 다른것을 사용해야함
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);
}
// store.values()를 직접 반환해도 되지만 안전하게 ArrayList로 감싸서 반환(Map의 values에 변경을 가할 수 없게 됨)
public List<Item> findAll() {
return new ArrayList<>(store.values());
}
// 지금은 프로젝트가 작아서 직접 코딩하지만 정석대로 개발하려면 id가없는 별도의 객체를 만드는 것이 좋음(ex: ItemParamDto)
// 언뜻 보기에는 코드중복처럼 보이지만 코드의 명확성이 더 중요함
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();
}
}
(3) ItemRepositoryTest - 상품 저장소 테스트
- ItemRepository의 메서드들을 검증
package hello.itemservice.domain.item;
class ItemRepositoryTest {
ItemRepository itemRepository = new ItemRepository();
// 테스트 후 데이터 초기화
@AfterEach
void afterEach() {
itemRepository.clearStore();
}
@Test
void save() {
// given
Item item = new Item("itemA", 10000, 10);
// when
Item saveItem = itemRepository.save(item);
// then
Item findItem = itemRepository.findById(item.getId());
assertThat(findItem).isEqualTo(saveItem);
}
@Test
void findAll() {
// given
Item item1 = new Item("itemA", 10000, 10);
Item item2 = new Item("itemB", 10000, 10);
Item item3 = new Item("itemC", 10000, 10);
itemRepository.save(item1);
itemRepository.save(item2);
itemRepository.save(item3);
// when
List<Item> result = itemRepository.findAll();
// then
assertThat(result).contains(item1, item2, item3);
assertThat(result).hasSize(3);
assertThat(result.size()).isEqualTo(3); // 위와 같은 테스트
}
@Test
void update() {
// given
Item item1 = new Item("itemA", 10000, 10);
Item saveItem = itemRepository.save(item1);
Long itemId = saveItem.getId();
// when
Item updateItem = new Item("itemB", 20000, 30);
itemRepository.update(itemId, updateItem);
// then
Item findItem = itemRepository.findById(itemId);
assertThat(findItem.getItemName()).isEqualTo(updateItem.getItemName());
assertThat(findItem.getPrice()).isEqualTo(updateItem.getPrice());
assertThat(findItem.getQuantity()).isEqualTo(updateItem.getQuantity());
}
@Test
void delete() {
// given
Item item1 = new Item("itemA", 10000, 10);
Item saveItem = itemRepository.save(item1);
Long itemId = saveItem.getId();
// when
itemRepository.delete(itemId);
// then
Item findItem = itemRepository.findById(itemId);
assertThat(findItem).isNull();
}
}
3. 상품서비스 - HTML
1) 부트스트랩
(1) HTML을 편리하게 개발하기 위해 부트스트랩을 사용
- 웹사이트를 쉽게 만들수 있게 도와주는 HTML, CSS, JS 프레임워크
- 하나의 CSS로 휴대폰, 태블릿, 데스크탑까지 다양한기기에서 동작하고 다양한 기능을 제공하여 사용자가 쉽게 웹사이트를 제작, 유지, 보수할 수 있도록 도와줌
- 백엔드 개발자가 어드민 페이지등을 만들때 자주 사용함
(2) HTML, CSS 파일 적용
- 해당 프로젝트에서는 부트스트랩 V5.0 사용
- 다운받은 css 파일은 /resources/static 경로에 css 디렉토리를 만들어서 복사 붙혀넣기(bootstrap.min.css파일 사용)
- HTML도 /resources/static 경로에 /html 디렉토리를 만들어서 만들고자 하는 html파일을 생성
(3) items.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>
(4) 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>
(5) 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>
(6) 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>
** 참고
- /resources/static 경로에 넣어두었기 때문에 스프링부트가 정적 리소스를 제공하여 웹 브라우저에서 URL을 입력하면 정상적으로 뜨는 것을 확인할 수 있음
- http://localhost:8080/html/items.html
- 정적 리소스여서 해당 파일을 탐색기를 통해 직접 열어도 동작함
- 이렇게 정적 리소스가 공개되는 /resources/static폴더에 HTML을 넣어두면 실제 서비스에서도 공개가 되므로 주의가 필요함
- 서비스를 운영한다면 공개할 필요가 없는 HTML은 해당 경로에 두면 안됨
4. 상품목록 - 타임리프
1) 본격적인 컨트롤러와 뷰 템플릿 개발
(1-1) BasicItemController
- itemservice하위에 web.item.basic 패키지 경로를 만들어서 작성
- itemRepository에서 모든 상품을 조회 후 모델에 담고 뷰 템플릿을 호출
- @RequiredArgsConstructor: final이 붙은 멤버변수만 사용해서 생성자를 자동 생성, 생성자 주입코드를 생략 할 수 있음
- 생성자가 1개만 있으면 @Autowired를 생략해도 의존관계가 자동으로 주입이 되는데 final을 키워드를 빼면 ItemRepository 의존관계 주입이 안되므로 주의해야함
(1-2) 테스트용 데이터 추가
- 테스트용 데이터가 없으면 회원 목록 기능이 정상 동작하는지 확인하기 어려워서 간단히 테스트용 데이터를 추가
- @PostConstruct: 해당 빈의 의존관계가 모두 주입이되고 나면 초기화 용도로 호출됨
package hello.itemservice.web.item.basic;
@Controller
@RequestMapping("/basic/items")
@RequiredArgsConstructor // final이 붙은 멤버변수만 사용해서 생성자를 자동으로 생성(롬복라이브러리)
public class BasicItemController {
private final ItemRepository itemRepository;
// @Autowired //생성자 주입 -> 객체를 스프링 빈으로 등록, 생성자가 1개만 있어서 생략이 가능
// 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));
}
}
(2) items.html 정적 HTML을 뷰 템플릿 영역으로 복사후 수정
- 타임리프를 활용하여 동적으로 만들기위해 static 디렉토리에 위치한 items.html 파일을 templates 디렉토리에 basic디렉토리를 만들어서 복사 후 수정
<!DOCTYPE HTML>
<!--타임리프 적용-->
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<!--타임리프를 이용하여 css를 절대경로로 수정-->
<link href="../css/bootstrap.min.css"
th:href="@{/css/bootstrap.min.css}" rel="stylesheet">
</head>
... 동일 코드 생략
<div class="col">
<!-- th:onclick="|...'@{...}'|" 문법 -> 상품등록 버튼을 클릭했을때 접속되는 경로를 수정-->
<button class="btn btn-primary float-end"
onclick="location.href='addForm.html'"
th:onclick="|location.href='@{/basic/items/add}'|"
type="button">상품 등록</button>
</div>
</div>
... 동일 코드 생략
<tbody>
<!-- 타임리프 문법으로 루프 돌리기-->
<tr th:each="item : ${items}"> <!-- 모델에있는 items를 꺼내서 item에 저장 -->
<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>
<!-- 루프 대상 html 코드
<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> 루프 대상 -->
... 동일 코드 생략
</html>
2) 타임리프 간단히 알아보기
(1) 타임리프 사용 선언
<html xmlns:th="http://www.thymeleaf.org">
(2-1) 속성 변경 - th:href
- href="value1"을 th:href="value2"의 값으로 변경
- 타임리프 뷰 템플릿을 거치면 원래 값을 th:xxx의 값으로 변경하고 만약 값이 없으면 새로 생성함
- HTML을 그대로 볼 때는 href속성이 사용되고 뷰 템플릿을 거치면 th:href의 값이 href로 대체되면서 동적으로 값을 변경할 수 있음
- 대부분의 HTML 속성을 th:xxx로 변경할 수 있음
<link th:href="@{/css/bootstrap.min.css}"
href="../css/bootstrap.min.css" rel="stylesheet">
(2-2) 타임리프 핵심
- th:xxx가 붙은 부분은 서버사이드에서 렌더링되고 기존것을 대체함
- th:xxx가 없으면 기존 html의 xxx속성이 그대로 사용됨
- HTML을 파일로 직접 열었을 때 th:xxx가 있어도 웹브라우저는 th:xxx 속성을 알지 못하므로 무시 되므로 HTML 파일보기를 유지하면서 템플릿 기능도 할 수 있음
(2-3) URL 링크 표현식 - @{...}
- 타임리프는 URL 링크를 사용하는 경우 @{...}를 사용하여 URL 링크 표현식이라고 함
- URL 링크 표현식을 사용하면 서블릿 컨텍스트를 자동으로 포함함
- 링크 표현식을 사용하면 경로를 템플릿처럼 편리하게 사용할 수 있고 경로 변수{itemId}뿐 아니라 쿼리 파라미터도 생성함
<td><a href="item.html" th:href="@{/basic/items/{itemId}(itemId=${item.id})}" th:text="${item.id}">
예시
<th:href="@{/basic/items/{itemId}(itemId=${item.id}, query='test')}">
url 결과
http://localhost:8080/basic/items/1?query=test
(3) 속성 변경 - th:onclick(상품 등록 폼으로 이동)
- onclick="location.href='addForm.html'"
- th:onclick="|location.href='@{/basic/items/add}'|"
(4) 리터럴 대체 문법 - |...|
- 타임리프에서 문자와 표현식 등은 분리 되어있어서 더해서 사용 해야함
- 리터럴 대체 문법을 사용하면 더하기 기호 없이 편리하게 사용할 수 있음
<!-- 리터럴 대체 사용 전 -->
<span th:text="'Welcome to our application, ' + ${user.name} + '!'">
<button class="btn btn-primary float-end" th:onclick="'location.href=' + '\'' + @{/basic/items/add} + '\''">
<!-- 리터럴 대체 사용 후 -->
<span th:text="|Welcome to our application, ${user.name}!|">
<button class="btn btn-primary float-end" th:onclick="|location.href='@{/basic/items/add}'|">
(5) 반복 출력 - th:each
- 모델에 포함된 items 컬렉션 데이터가 item 변수에 하나씩 포함되고 반복문 안에서 item 변수를 사용할 수 있음
- 컬렉션의 수만큼 <tr>...</tr>이 하위 태그를 포함해서 생성됨
<tr th:each="item : ${items}">
(6) 변수 표현식 -${...}
- 모델에 포함된 값이나 타임리프 변수로 선언한 값을 조회할 수 있음
- 프로퍼티 접근법을 사용함,(item.getPrice())
<td th:text="${item.price}">10000</td>
<td th:text="${item.quantity}">10</td>
(7) 내용 변경 - th:text
- 내용의 값을 th:text의 값으로 변경
- 위의 코드에서는 10000의 값을 ${item.price}의 값으로, 10의 값을${item.quantity}의 값으로 변경함
** 참고
- 타임리프는 순수한 HTML파일을 웹 브라우저에서 열어도 내용을 확인해도 되고 서버를 통해 뷰 템플릿을 거쳐 동적으로 변경된 결과를 확인할 수 있음
- JSP는 웹 브라우저에서 그냥 열면 JSP 소스코드와 HTML이 뒤죽박죽 되어서 정상적인 확인이 불가능해 오직 서버를 통해서 열어야 함
- 순수 HTML을 그대로 유지하면서 뷰 템플릿도 사용할 수 있는 타임리프의 특징을 네츄럴 템플릿 이라함