-스프링 MVC를 활용한 간단한 웹페이지를 만들어본다.
-요구사항 분석 후 알맞은 도메인, HTML, 서비스 로직 등을 개발한다.
-모든 소스는 깃허브에서 관리한다.(https://github.com/coderahn/Spring-Lecture4_Item-Service)
7.스프링 MVC - 웹 페이지 만들기
1.프로젝트 생성
우선 프로젝트를 다음과 같이 생성한다.(https://start.spring.io/)
2.요구사항 분석
핵심 요구사항은 상품을 관리할 수 있는 서비스다.
상품도메인 모델은 다음과 같다.
- 상품ID, 상품명, 가격, 수량
관련된 기능은 다음과 같다.
- 상품목록, 상품상세, 상품등록, 상품수정
서비스 흐름은 다음과 같다.
- 클라이언트(사용자)는 상품 목록을 조회한다.
- 상품 등록 클릭시, 상품 등록 폼으로 이동하여 상품 내용 입력 후 '상품 저장'을 클릭한다. 그러면 상품 상세로 돌아간다.
- 상품 제목 클릭시, 상품 상세로 이동한다.
- 상품 상세에서 '상품 수정' 클릭시, 상품 수정 폼으로 이동한다. 수정 내용 입력 후 '상품 수정'을 클릭하면 상품상세로 리다이렉트된다.
그리고 다음과 같은 흐름을 가정하여 개발을 진행한다고 해보자.
- 요구사항이 정리된 후 디자이너는 요구사항에 맞게 화면 디자인을 한다.
- 웹퍼블리셔는 디자이너에게 받은 디자인을 활용하여 HTML, CSS 만든 후 개발자에게 제공한다.
- 개발자는 웹퍼블리셔를 통해 HTML화면이 나오기 전, 시스템 설계와 핵심 비지니스 모델을 개발한다. HTML이 나오면 HTML을 뷰템플릿 변환 후 동적으로 화면을 그린다.
3.상품 도메인 개발
우선 상품 도메인을 다음과 같이 개발한다. VO 역할을 한다.
[java/hello/itemservice/domain/item/Item.java]
//@Data는 위험. @Getter, @Setter만 쓰는게 좋음. 핵심도메인 모델에서는 가능하면 안 사용
//data 왔다갔다용 dto에서는 어느정도 써도 됨
@Data
public class Item {
private Long id;
private String itemName;
private Integer price; //price가 없을 수도 있으니 Integer
private Integer quantity; //수량이 없는 경우 있을 수도 있으니 Integer
public Item() {
}
public Item(String itemName, Integer price, Integer quantity) {
this.itemName = itemName;
this.price = price;
this.quantity = quantity;
}
}
다음으로 상품 저장소 역할의 서비스를 개발한다. 데이터베이스 역할은 HashMap을 사용한다. 서버가 올라간 후 한 번 생성되어 사용하기 위해 static변수로 선언한다.
[src/main/java/hello/itemservice/domain/item/ItemRepository.java]
@Repository
public class ItemRepository {
//스프링 컨테이너 안에서 쓰면 싱글톤이라 static을 안 써도 되긴 함.
//따로 ItemRepository를 new로 사용하면 static 필수
//멀티스레드 환경에서 여러개가 동시에 store 접근시 HashMap이 아닌 ConcurrentHashMap 사용
//HashMap은 멀티쓰레드 환경에서 사용하면 안됨.
private static final Map<Long, Item> store = new HashMap<>();
//멀티스레드 환경에서 long이 아닌 AtomicLong 사용
private static long sequance = 0L;
//아이템 저장
public Item save(Item item) {
item.setId(++sequance);
store.put(item.getId(), item);
return item;
}
public Item findById(Long id) {
return store.get(id);
}
public List<Item> findAll() {
//store.values()를 그대로 반환해도되지만, ArrayList를 감싸서 add작업을 해도 store에 영향 없도록 하기 위해 감쌈
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();
}
}
위의 코드에서 다음 코드가 DB역할을 한다.
private static final Map<Long, Item> store = new HashMap<>();
이 코드가 static으로 되어 있는데, 메모리에 한 번 올라간 후 서버가 내려갈 때까지 유지되야 되기 때문이다. 근데 실제로 스프링컨테이너는 ItemRepository를 싱글톤 기반 스프링 빈으로 등록하는데, 싱글톤이기 때문에 실제로 static을 붙이지 않아도 되긴 한다.
다음과 같이 서비스들에 대한 테스트코드를 작성할 수 있다.
[src/test/java/hello/itemservice/domain/item/ItemRepositoryTest.java]
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(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() {
//given
Item item = new Item("item1", 10000, 10);
Item savedItem = itemRepository.save(item);
Long itemId = savedItem.getId();
//when
Item updateParam = new Item("item2", 20000, 30);
itemRepository.update(itemId, updateParam);
Item findItem = itemRepository.findById(itemId);
//then
assertThat(findItem.getItemName()).isEqualTo(updateParam.getItemName());
assertThat(findItem.getPrice()).isEqualTo(updateParam.getPrice());
assertThat(findItem.getQuantity()).isEqualTo(updateParam.getQuantity());
}
}
4.상품서비스 HTML
이제 위에서 서비스 로직을 개발하였으니, HTML 화면을 개발한다.
여기서는 부트스트랩을 사용한다.
부트스트랩은 공식 사이트에서 다운로드 받은 후, bootstrap.min.css를 복사하여 resources/static/css에 추가한다.
HTML과 CSS파일은 다음과 같이 위치한다.
- resources/static/css/bootstrap.min.css //부트스트랩 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
5.상품목록 - 타임리프
이제 컨트롤러와 뷰템플릿을 개발한다.
[BasicItemController.java]
@Controller
@RequestMapping("/basic/items")
@RequiredArgsConstructor
public class BasicItemController {
private final ItemRepository itemRepository;
//생성자가 1개만 있으면 Autowired 생략 가능
//@RequiredArgsConstructor를 사용하면 final 필드가 있는 itemRepository를 자동 생성
// 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));
}
}
@RequiredArgsConstructor를 붙이면 final 멤버변수의 생성자를 자동으로 만들어준다.
@GetMapping된 items(Model model)은 호출된 경우 매핑되어 실행되는 컨트롤러 메소드다. findAll()을 통해 저장된 item들을 모두가져와서 model에 담은 후 basic/items.html로 랜더링한다.
@PostConstruct는 해당 빈 의존관계가 모두 주입되고 초기화 용도로 호출된다. 테스트데이터를 셋팅하기 위해 사용한다.
다음으로 /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="item.html" th:href="@{/basic/items/{itemId}(itemId=${item.id})}" th:text="${item.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>
th는 타임리프 속성을 사용할 때 사용하는 키워드다. 타임리프의 뷰템플릿을 거치면 완래값을 th:xxx로 변경한다. th:xxx가 붙은 부분은 서버사이드에서 랜더링되어 기존 것을 대체한다. html을 파일로 열었을 때 th:xxx가 있어도 웹브라우저는 th:속성을 몰라서 무시한다. 따라서 html을 파일보기로 유지하면서 템플릿 기능도 할 수 있다.
th:href는 속성을 변경한다. href="value1"을 th:href="value2"로 변경한다.
@{...}는 타임리프의 URL링크 표현식이다. @{...}를 사용하면 서블릿컨텍스트를 자동으로 포함한다.
참고로 서블릿 컨텍스트는 서블릿 컨테이너(Tomcat 등) 실행시 각 컨텍스트(웹어플리케이션)마다 한 개의 서블릿컨텍스트 객체를 생성하는데, 웹어플리케이션이 실행되면서 애플리케이션 전체의 자원이나 정보를 미리 바인딩해서 서블릿들이 공유하여 사용하는 객체다.(https://java117.tistory.com/18)
<button>상품등록</button>을 클릭하면 th:onclick 속성이 실행 된다.
th:onclick="|location.href='@{/basic/items/add}'|" 부분에 리터럴 대체 문법이 사용되었다. |...| 이렇게 사용한다.
- 리터럴 대체 문법을 사용 안 할 경우 문자와 표현식을 더하기(+)로 사용해야 한다.
- ex) <span th:text="'Welcome, ' + ${user.name} + '!'">
- 리터럴 대체 문법 사용시 더하기 없이 사용 가능하다.
- ex)<span th:text="|Welcome, ${user.name}!|">
th:each는 반복출력으로 jsp의 c:foreach같은 역할이다.
th:text는 내용 값을 th:text로 변경한다. <td th:text="${item.price}">10000</td>인 경우 10000이 ${item.price}로 대체된다.
위의 HTML에 다음과 같은 코드가있다.
<td><a href="item.html" th:href="@{/basic/items/{itemId}(itemId=${item.id})}" th:text="${item.id}">회원아이디</a></td>
여기서 th:href의 링크표현식에 (itemId=${item.id})라고 되어 있는 부분을 볼 수 있다. {itemId}라는 url 경로 부분에 ${item.id}를 넣어서 동적으로 URL을 만들 수 있다. 또한 (itemId=${item.id}, query'test')처럼 쿼리파라미터 추가도 가능하다. 위의 경우 url은 http://localhost:8080/basic/items/1?query=test같은 방식으로 URL이 생성된다.
6.상품 상세
상품 상세의 컨트롤러 매핑은 위의 BasicItemController에 다음과 같이 추가한다.
[BasicItemController.java]
//...
@GetMapping("/{itemId}")
public String item(@PathVariable long itemId, Model model) {
Item item = itemRepository.findById(itemId);
model.addAttribute("item", item);
return "basic/item";
}
//...
@PathVariable을 이용한다. 넘어온 상품ID로 findById(itemId)를 통해 상품 조회한다. 뷰는 다음과 같이 만든다.
[/resources/templates/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>
th:value는 모델에 있는 정보를 프로퍼티 접근법으로 출력한다. th:value="${item.id}"의 경우 item.id가 출력된다.
7.상품등록 폼
상품등록폼으로 이동하기 위한 컨트롤러 메소드를 만든다.
[BasicItemController.java]
//...
@GetMapping("/add")
public String addForm() {
return "basic/addForm";
}
//...
단순히 addForm.html로 이동하는 역할만 한다.
등록 폼은 다음과 같이 만든다.
[/resources/templates/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"> <!-- 같은 URL은 action을 비워도됨("/basic/items/add")-->
<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>
th:action을 사용하여 현재 URL에 데이터를 전송한다.
8.상품 등록 처리 - @ModelAttribute
상품등록 폼에서 전달된 데이터를 서버에서 처리해보자.
- POST방식의 HTML Form 방식으로 전달
- content-type은 application/x-www-form-urlencoded
- 메세지 바디에 쿼리파라미터 형식 전달
- itemName=itemA&price=20000&quantity=20
요청 파라미터들을 처리하는 방식은 앞서서 학습했듯이 여러가지 방법으로 처리 가능하다. 우선 @RequestParam으로 처리해본다.
[BasicItemController.java]
//...
//@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으로 받아서 Item 객체 setter를 통해 셋팅 후 save(item)으로 저장한다.
이 방법은 변수를 각각 받아 불편하다. @ModelAttribute를 통해 한 번에 처리해보자.
[BasicItemController.java]
//...
//ModelAttribute(네임)의 '네임'은 model.addAttribute 역할을 해준다. view에서 보여줄 attributeName이라고 볼 수 있다.
@PostMapping("/add")
public String addItemV2(@ModelAttribute("item") Item item, Model model) {
itemRepository.save(item);
//model.addAttribute("item", item); //ModelAttribute("item")으로 인해 생략 가능
return "basic/item";
}
//...
@ModelAttribute가 Item객체 생성 후 setter를 통해 입력까지 해준다.
여기서 또 중요한 기능이 있다. @ModelAttribute가 model.setAttribute(name, value) 역할도 해준다. model에 데이터를 담을 때 name이 필요하듯이, @ModelAttribute(name)의 name값이 model의 name역할을 해준다.
예를 들어 @ModelAttribute("name") Item item은 model.setAttribute("name", item)과 같다.
여기서 더 나아가면 ModelAttribute의 name을 생략할 수 있다.
[BasicItemController.java]
//...
//ModelAttribute(네임)의 '네임'생략시 클래스네임 앞을 소문자로 바꿔서 model.addAttribute(네임,value)가 된다.
//ex)ModelAttribute Item item은 model.addAttribute("item", value)와 같다.
//@PostMapping("/add")
public String addItemV3(@ModelAttribute Item item) {
itemRepository.save(item);
return "basic/item";
}
//...
더 더 나아가면, ModelAttribute까지 생략된다. 대상 객체는 모델에 자동 등록된다.
[BasicItemController.java]
//ModelAttribute 생략 가능(String등 단순타입인 경우 생략하면 @RequestParam)
@PostMapping("/add")
public String addItemV4(Item item) {
itemRepository.save(item);
return "basic/item";
}
9.상품 수정
상품 상세에서 상품 수정 버튼을 클릭하면 수정 화면으로 이동한다. 이 때 해당 item의 id를 컨트롤러에서 pathVariable로 사용하여 해당 데이터를 조회 후 model에 담아줘야 한다.
컨트롤러 매핑은 다음과 같이 만든다.
[BasicItemController.java]
//상품수정폼
@GetMapping("/{itemId}/edit")
public String editForm(@PathVariable Long itemId, Model model) {
Item item = itemRepository.findById(itemId);
model.addAttribute("item", item);
return "basic/editForm";
}
수정화면은 다음과 같다.(등록 폼과 비슷하다)
[/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"> <!-- HTML Form 전송은 PUT, PATCH 지원 안 함-->
<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>
이제 상품 수정 화면에서 '저장'을 클릭하면 수정 로직이 작동한다. 컨트롤러 매핑은 아래와 같다.
[BasicItemController.java]
//상품수정
@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId, @ModelAttribute Item item) {
itemRepository.update(itemId, item);
//뷰템플릿 호출 대신 상품상세로 리다이렉트(302)
//@PathVariable값 사용 가능
return "redirect:/basic/items/{itemId}";
}
업데이트 후 수정된 내용을 상품 상세에서 보여주기 위해 redirect한다.
10.PRG Post/Redirect/Get
생각해보면 상품 등록에서 상품 저장(/add) 후 redirect 처리를 안 했다. 이러면 새로고침할 때 POST방식의 /add가 계속 실행되기 때문에 동일한 데이터가 중복등록된다. 저장 후 상품 수정과 같이 redirect를 해주면 마지막 요청이 GET이 되기 때문에 새로고침을 해도 상품 상세가 호출되어 이런 문제를 방지할 수 있다.
저장(POST) -> 리다이렉트(Redirect) -> 상세(Get)의 앞 글자를 따서 PRG 패턴이라고 한다.
[BasicItemController.java]
//PRG 패턴 적용(저장로직 중복 방지)
@PostMapping("/add")
public String addItemV5(Item item) {
itemRepository.save(item);
return "redirect:/basic/items/" + item.getId();
}
11.RedirectAttributes
상품 저장 후 상세화면으로 이동하면 저장이 잘 된 것인지 판단이 안 선다. 화면에 저장여부를 텍스트로 표기해주려고 한다. 이 때 RedirectAttributes를 사용하면 리다이렉트 후 model 처리를 해준다.
//리다이렉트 후 model 처리할 수 있는 방버
@PostMapping("/add")
public String addItemV6(Item item, RedirectAttributes redirectAttributes) {
Item savedItem = itemRepository.save(item);
//{itemId}에 itemId가 들어간다. status는 쿼리파라미터 형태로 들어간다.
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/basic/items/{itemId}";
}
위에서 "status"는 뷰템플릿에서 텍스트 출력을 위한 플래그 역할을 한다. 실행 시 다음과 같은 리다이렉트 결과가 나온다.
http://localhost:8080/basic/items/3?status=true
위의 리다이렉트 결과에서 보듯이 RedirectAttributes는 URL 인코딩과 pathVariable, 쿼리 파라미터까지 처리해준다.
[/resources/templates/basic/item.html]
<div class="container">
<div class="py-5 text-center">
<h2>상품 상세</h2>
</div>
<!-- param은 쿼리파라미터 쓸 수 있는 객체 -->
<h2 th:if="${param.status}" th:text="'저장 완료!'"></h2>
<!-- 생략... -->
</div>
th:if 타임리프 문법을 사용하여 분기처리를 해준다. 여기서 param은 타임리프에서 자체적으로 제공해주는데 쿼리파라미터를 편리하게 조회하게 해준다.
결과화면은 아래와 같다. (상품 저장 후)
'개발자 일지 > Spring' 카테고리의 다른 글
[강의 후기] 인프런 김영한님 자바 ORM 표준 JPA 프로그래밍 (0) | 2024.10.22 |
---|---|
필터와 인터셉터 차이, 개념, 예제 (0) | 2024.09.20 |
[스프링]@ResponseBody 역할, 쓰는이유, 대체 어노테이션(@RestController) 알아보기 (0) | 2024.09.10 |
[개인학습]스프링부트 + Swagger + JPA + MySQL 설정 및 테스트 (2) | 2022.10.10 |
[인프런 김영한 로드맵4]스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술(6) (0) | 2022.06.18 |
[인프런 김영한 로드맵4]스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술(5) (0) | 2022.06.14 |
[인프런 김영한 로드맵4]스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술(4) (0) | 2022.06.04 |
[인프런 김영한 로드맵4]스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술(3) (2) | 2022.05.31 |