그렉 턴키스트, 『스프링 부트 실전 활용 마스터』의 2장 스프링부트 웹 애플리케이션 만들기를 요약한 내용입니다.
리액티브 데이터 스토어의 요건
몽고디비 적용
도메인 객체 정의
레파지토리 생성
테스트 데이터 로딩
장바구니 보여주기
장바구니에 상품 담기
리액티브 프로그래밍은 원래 빠르지 않다.
단일 스레드의 처리 속도 기준으로 보면 리액티브 프로그래밍은 여러가지 오버헤드를 수반하므로 성능이 저하된다.
대규모의 트래픽이 발생하고 백엔드에서 대용량의 데이터를 처리하는 환경에서는 시스템 자원의 한도 내에서 스레드 사용 효율이 극대화되기 때문에 유용하다.
리액티브가 제대로 동작하려면 데이터베이스도 리액티브하게 동작해야 한다.
리액티브 패러다임을 지원하는 데이터베이스에는 MongoDB, Redis, Cassandra, Elasticsearch, Neo4J, Couchbase가 있다.
최신 조사에 따르면 현장에서 사용중인 80%의 데이터베이스는 관계형 데이터베이스라고 한다.
자바에서 관계형 데이터베이스를 사용할 때 JDBC, JPA, Jdbi, jOOQ 기술을 사용한다.
JPA와 JDBC는 블로킹 API이다. 모든 데이터베이스 호출은 응답을 받을 때까지 블로킹되어 기다려야 한다.
따라서 관계형 데이터베이스는 리액티브 프로그래밍에 사용할 수 없다.
JDBC나 JPA를 감싸서 리액티브 스트림 계층에서 사용하는 방법이 있긴 하지만 이는 숨겨진 내부 스레드 풀을 사용해서 동작한다.
하지만 코어의 수보다 많은 스레드를 사용하는 것은 스위칭 오버헤드의 증가로 효율이 급격하게 떨어진다.
비동기, 논블로킹 방식으로 동작하는 단일 스레드 애플리케이션이 블로킹 방식으로 도작하면서 스레드를 100개를 사용하는 애플리케이션보다 처리량이 더 높게 나온다고 한다.
위의 내용을 요약하자면 '리액티브 프로그래밍에는 리액티브한 데이터베이스가 필요하다'이다.
pom.xml의 dependencies에 아래의 dependency를 추가한다.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb-reactive</artifactId>
</dependency>
<dependency>
<groupId>de.flapdoodle.embed</groupId>
<artifactId>de.flapdoodle.embed.mongo</artifactId>
</dependency>
<dependency>
<groupId>org.mongodb</groupId>
<artifactId>mongodb-driver-sync</artifactId>
</dependency>
데이터 스토어에 맞는 적절한 관례를 사용해야 한다.
몽고디비에 저장할 객체를 정의할 때는 몽고디비 애너테이션을 사용해야 한다.
어떤 필드를 몽고디비의 ObjectId로 사용할지 결정해야 한다.
스프링 데이터 커먼즈에서 제공하는 @Id 애너테이션을 사용해서 특정 필드를 ObjectId 필드로 지정한다.
이커머스 시스템을 만들기 위해 Item, CartItem, Cart 도메인을 작성한다
public class Item {
private @Id String id;
private String name;
private double price;
public Item(String name, double price) {
this.name = name;
this.price = price;
}
public String getId() {
return id;
}
public String getName() {
return name;
}
public double getPrice() {
return price;
}
public void setId(String id) {
this.id = id;
}
public void setName(String name) {
this.name = name;
}
public void setPrice(double price) {
this.price = price;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Item item = (Item) o;
return Double.compare(item.price, price) == 0 && Objects.equals(id, item.id) && Objects.equals(name, item.name);
}
@Override
public int hashCode() {
return Objects.hash(id, name, price);
}
}
public class CartItem {
private Item item;
private int quantity;
private CartItem(){};
public CartItem(Item item) {
this.item = item;
this.quantity = 1;
}
public Item getItem() {
return item;
}
public int getQuantity() {
return quantity;
}
public void setItem(Item item) {
this.item = item;
}
public void setQuantity(int quantity) {
this.quantity = quantity;
}
public void increment(){
this.quantity++;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
CartItem cartItem = (CartItem) o;
return quantity == cartItem.quantity && Objects.equals(item, cartItem.item);
}
@Override
public int hashCode() {
return Objects.hash(item, quantity);
}
}
public class Cart {
private @Id String id;
private List<CartItem> cartItems;
private Cart() { }
public Cart(String id){
this(id, new ArrayList<>());
}
public Cart(String id, List<CartItem> cartItems) {
this.id = id;
this.cartItems = cartItems;
}
public String getId() {
return id;
}
public List<CartItem> getCartItems() {
return cartItems;
}
public void setId(String id) {
this.id = id;
}
public void setCartItems(List<CartItem> cartItems) {
this.cartItems = cartItems;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Cart cart = (Cart) o;
return Objects.equals(id, cart.id) && Objects.equals(cartItems, cart.cartItems);
}
@Override
public int hashCode() {
return Objects.hash(id, cartItems);
}
}
레파지토리는 저장, 조회, 삭제와 같은 단순하고 공통적인 연산을 추상화해서 표준적인 방식으로 접근하도록 도와준다.
레파지토리는 ReactiveCrudRepository 인터페이스를 상속받아 정의할 수 있다.
ReactiveCurdRepository에는 save(), saveAll(), findById(), findAll(), findAllById(), existsById(), count(), deleteById(), delete(), deleteAll()과 같은 메소드가 구현되어 있다.
모든 메소드의 반환타입은 Mono나 Flux 둘 중 하나이다.
Mono나 Flux을 구독하고 있다가 데이터가 준비되었을 때 데이터를 받을 수 있게 된다.
메소드 중 일부는 리액티브 스트림의 Publisher 타입을 인자로 받을 수 있다
다음과 같이 ItemRepository, CartRepository를 정의한다.
public interface ItemRepository extends ReactiveCrudRepository<Item, String> {
}
public interface CartRepository extends ReactiveCrudRepository<Cart, String> {
}
테스트 데이터를 저장하기 위해서 아래와 같이 작성하면 된다.
ReactiveCurdRepository.save()는 Mono<T>를 반환한다.
Mono는 구독하지 않으면 아무 일도 하지 않기 때문에 반드시 subscribe를 해주어야 한다.
itemRepository
.save(new Item("Alf alarm clock", 19.99))
.subscribe()
하지만 문제가 있다.
애플리케이션이 시작되는 과정에서 네티와 충돌해 데드락 상태에 빠질 수 있다.
따라서 애플리케이션 시작 시점에 어떤 작업을 하려면 블로킹 버전의 스프링데이터 몽고디비를 사용해야 한다.
테스트 환경 구축에서는 약간의 블로킹 코드를 사용해도 문제가 되지 않지만 실제 운영환경에선 절대 사용하면 안된다
아래와 같이 CurdRepository를 상속받아 블로킹 레포지토리를 정의할 수 있다
public interface BlockingItemRepository extends CrudRepository<Item, String> {
}
테스트데이터를 데이터베이스에 저장하는 RepositoryDatabaseLoader 클래스를 작성한다
@Component
public class RepositoryDatabaseLoader {
@Bean
CommandLineRunner initialize(BlockingItemRepository repository){
return args -> {
repository.save(new Item("test item 1", 2000));
repository.save(new Item("test item 2", 5000));
};
}
}
@Component는 클래스가 Bean으로 등록되게 해주는 어노테이션이다.
@Bean은 메소드의 반환 객체가 빈으로 등록되게 해주는 어노테이션이다.
CommandLineRunner는 애플리케이션이 시작된 후에 자동으로 실행되는 스프링부트 컴포넌트로서, run()메서드 하나만 갖고 있는 함수형 인터페이스이다. 모든 컴포넌트가 등록된 후 run() 메소드가 자동으로 실행된다.
하지만 위와 같은 블로킹 레파지토리를 생성하는 방법은 다른 개발자가 블로킹 방식의 레파지토리를 호출할 수 있다는 문제점이 있다.
따라서 위와 같이 BlockingItemRepository와 RepositoryDatabaseLoader를 사용하는 방법은 권장하지 않는다.
대신 MongTemplate를 사용하여 데이터를 로딩하면 된다.
MongoTemplate는 블로킹버전이고 ReactiveMongoTemplate는 비동기, 논블로킹 버전인데 MongoTemplate를 사용하면 블로킹 레파지토리를 사용하지 않고 블로킹 방식으로 데이터를 로딩할 수 있다.
아래와 같이 MongoTemplate를 사용해 데이터베이스에 데이터를 저장하는 TemplateDatabaseLoader 클래스를 작성한다.
@Component
public class TemplateDatabaseLoader {
@Bean
CommandLineRunner initialize(MongoOperations mongo){
return args -> {
mongo.save(new Item("test item 1", 2000));
mongo.save(new Item("test item 2", 5000));
};
}
}
MongoOperations란 MongoTemplate에서 추출된 인터페이스이다.
애플리케이션과 몽고디비의 결합도를 낮추려면 MongoOperations 인터페이스를 사용하는 것이 좋다.
우선 새로 만든 레파지토리를 HomeController에 주입한다.
@Controller
public class HomeController {
private ItemRepository itemRepository;
private CartRepository cartRepository;
public HomeController(ItemRepository itemRepository, CartRepository cartRepository) {
this.itemRepository = itemRepository;
this.cartRepository = cartRepository;
}
...
}
item 목록과 장바구니를 보여주기 위해 home() 메서드를 수정한다.
@GetMapping
Mono<Rendering> home(){
return Mono.just(Rendering.view("home.html")
.modelAttribute("items",
this.itemRepository.findAll())
.modelAttribute("cart",
this.cartRepository.findById("My Cart")
.defaultIfEmpty(new Cart("My Cart")))
.build()
);
}
반환타입을 뷰/애트리버트를 포함하는 웹플럭스 컨테이너인 Mono<Rendering>으로 수정한다
Rendering.view()메서드로 렌더링에 사용할 템플릿 이름을 지정한다.
modelAttribute()로 템플릿에 사용할 데이터를 지정한다.
defaultIfEmpty()로 데이터베이스에서 조회해서 없으면 새로운 Cart를 생성해 반환한다.
현재 판매 상품 목록 테이블을 보여주기 위해 home.html의 body에 아래 코드를 추가한다.
<h2>Inventory Management</h2>
<table>
<thead>
<tr>
<td>Id</td>
<td>Name</td>
<td>Price</td>
</tr>
</thead>
<tbody>
<tr th:each="item : ${items}">
<td th:text="${item.id}"></td>
<td th:text="${item.name}"></td>
<td th:text="${item.price}"></td>
<td>
<form method="post" th:action="@{'/add/'+${item.id}}">
<input type="submit" value="Add to Cart" />
</form>
</td>
<td>
<form method="delete" th:action="@{'/delete/'+${item.id}}">
<input type="submit" value="Delete" />
</form>
</td>
</tr>
</tbody>
</table>
HTML은 기본적으로 GET, POST 두가지 요청 방식을 지원하는데, 타임리프에서는 th:method="delete"를 써서 DELETE 요청을 보낼 수 있다.
th:method="delete"가 실제 HTML로 렌더링될 때는, <input type="hidden" name="_method" value="delete" />와 같이 변환되고 이 hidden 값을 POST 요청으로 전송한다.
스프링 웹플럭스에는 @DeleteMapping 어노테이션이 붙은 컨트롤러 메소드로 요청을 전달하는 특수 필터가 존재한다.
이 필터를 활성화하기 위해 application.properties에 아래 코드를 추가한다.
spring.webflux.hiddenmethod.filter.enabled=true
home.html에 현재 장바구니를 보여주는 테이블을 추가한다.
<h2>My Cart</h2>
<table>
<thead>
<tr>
<td>Id</td>
<td>Name</td>
<td>Quantity</td>
</tr>
</thead>
<tbody>
<tr th:each="cartItem : ${cart.cartItems}">
<td th:text="${cartItem.item.id}"></td>
<td th:text="${cartItem.item.name}"></td>
<td th:text="${cartItem.quantity}"></td>
</tr>
</tbody>
</table>
애플리케이션을 실행한 후 인덱스페이지에 접속해보면 아래와 같이
데이터베이스에 추가한 Item들과 장바구니가 화면에 출력되는 것을 확인할 수 있다.
장바구니 담기 기능을 구현하기 위해 해야할 작업은 다음과 같다.
1. 현재 장바구니 조회, 만약 장바구니가 존재하지 않으면 새 장바구니 생성
2. 이미 장바구니에 담긴 상품이라면 수량 1 증가, 기존에 없던 상품이라면 상품 정보를 표시하고 수량을 1로 설정
3. 장바구니 저장
HomeController에 장바구니에 상품을 추가하는 웹메서드를 작성한다.
@PostMapping("/add/{id}")
Mono<String> addToCart(@PathVariable String id){
return this.cartRepository.findById("My Cart")
.defaultIfEmpty(new Cart("My Cart"))
.flatMap(cart -> cart.getCartItems().stream()
.filter(cartItem -> cartItem.getItem()
.getId().equals(id))
.findAny()
.map(cartItem -> {
cartItem.increment();
return Mono.just(cart);
})
.orElseGet(()-> {
return this.itemRepository.findById(id)
.map(item -> new CartItem(item))
.map(cartItem -> {
cart.getCartItems().add(cartItem);
return cart;
});
}))
.flatMap(cart -> this.cartRepository.save(cart))
.thenReturn("redirect:/");
}
PathVariable로 카트에 추가할 Item의 id를 받는다.
Cart를 조회하고 없으면 새로 만든다.
Cart에 담긴 아이템들을 순회하면서 추가할 아이템이 존재하는지 확인한다.
findAny()는 Optional<T>를 반환한다.
findAny()가 반환한 값이 존재하면 map()이, 존재하지 않으면 orElseGet()이 실행된다.
추가하려는 아이템이 기존에 카트에 존재하지 않는 아이템이라면 새로운 CartItem을 생성하고 cart.cartItems에 추가한다.
카트에 아이템을 추가하는 작업이 완료되면 save()를 통해 데이터베이스에 저장한다.
데이터베이스에 저장이 완료되면 인덱스페이지로 리다이렉트한다.
컨트롤러는 웹 요청 처리만 담당하는 것이 좋다.
따라서 장바구니를 조회하고 상품을 담는 기능은 서비스로 추출해야 한다.
다음과 같이 CartService를 작성한다.
@Service
public class CartService {
private final ItemRepository itemRepository;
private final CartRepository cartRepository;
public CartService(ItemRepository itemRepository, CartRepository cartRepository) {
this.itemRepository = itemRepository;
this.cartRepository = cartRepository;
}
Mono<Cart> addToCart(String cartId, String id){
return this.cartRepository.findById(cartId)
.defaultIfEmpty(new Cart(cartId))
.flatMap(cart -> cart.getCartItems().stream()
.filter(cartItem -> cartItem.getItem()
.getId().equals(id))
.findAny()
.map(cartItem -> {
cartItem.increment();
return Mono.just(cart);
})
.orElseGet(()->
this.itemRepository.findById(id)
.map(CartItem::new)
.doOnNext(cartItem ->
cart.getCartItems().add(cartItem))
.map(cartItem -> cart)))
.flatMap(this.cartRepository::save);
}
}
CartController에 CartService를 생성자로 주입한 후
다음과 같이 addToCart() 메서드를 간결하게 만들 수 있다.
@PostMapping("/add/{id}")
Mono<String> addToCart(@PathVariable String id){
return this.cartService.addToCart("My Cart", id)
.thenReturn("redirect:/");
}
애플리케이션을 실행하고 Add to Cart 버튼을 누르면 My Cart에 상품이 추가되는 것을 확인할 수 있다.
Ajax로 첨부파일 다운로드 구현 | 스프링 MVC (2) | 2024.03.08 |
---|---|
[SpringBoot] 검색기능 구현, 몽고디비 쿼리 방법 정리 (0) | 2023.03.03 |
[SpringBoot] 리액티브 프로그래밍, 웹플럭스, 타임리프 간단한 예제 (0) | 2023.02.24 |
스프링부트 리액티브 프로그래밍 | 웹플럭스 (1) | 2023.02.22 |
JPA-style positional param was not an integral ordinal 오류 해결 (1) | 2022.07.28 |