CQRS에 대한 정보는 아래 글을 참고해주세요.
https://saysimple.tistory.com/196
서론
스프링 부트에서는 CQRS를 구현하기 위해 Axon 라이브러리를 사용합니다. baeldung의 글을 참고해 스프링 부트에선 어떻게 CQRS를 구현하는지 알아보겠습니다. 해당 예제에선 Axon Server를 이벤트 스토어로 사용하며 Postman을 이용해 테스트합니다. 이번 글에선 API와 비즈니스 로직, 이벤트 소싱에 대해서 알아보겠습니다.
디펜던시
build.gradle.kts 파일입니다. 본 예제에선 코틀린 그래들을 사용하며 스프링 부트 3.2.4, 자바 17을 사용합니다. 원래 예제에선 pom.xml을 사용하지만 트랜드에 맞게 수정하였습니다.
plugins {
java
id("org.springframework.boot") version "3.2.4"
id("io.spring.dependency-management") version "1.1.4"
}
group = "com.saysimple"
version = "0.0.1-SNAPSHOT"
java {
sourceCompatibility = JavaVersion.VERSION_17
}
configurations {
compileOnly {
extendsFrom(configurations.annotationProcessor.get())
}
}
repositories {
mavenCentral()
}
dependencies {
implementation("org.axonframework:axon-spring-boot-starter")
implementation("org.springframework.boot:spring-boot-autoconfigure")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.axonframework.extensions.mongo:axon-mongo")
implementation("io.projectreactor:reactor-core:3.6.0")
implementation("io.projectreactor.addons:reactor-extra")
compileOnly("org.projectlombok:lombok")
annotationProcessor("org.projectlombok:lombok")
runtimeOnly("com.h2database:h2")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.axonframework:axon-test")
testImplementation("org.springframework:spring-test")
testImplementation("io.projectreactor:reactor-test:3.6.0")
testImplementation("org.awaitility:awaitility")
testImplementation("org.springframework:spring-webflux")
testImplementation("io.projectreactor.netty:reactor-netty-http")
testImplementation("org.testcontainers:junit-jupiter")
testImplementation("org.testcontainers:mongodb:1.17.6")
testImplementation("org.junit.jupiter:junit-jupiter:5.8.2")
}
dependencyManagement {
imports {
mavenBom("org.axonframework:axon-bom:4.9.3")
}
}
tasks.withType<Test> {
useJUnitPlatform()
}
Controller
OrderRestEndpoint.java에 있는 API 스펙입니다. 저는 주문을 생성하는 api를 약간 수정하였습니다.
이름 | URL | 설명 |
ship-order | fdfdsafdsa | 랜덤 주문을 생성하고 상품 추가, 주문 확인, 주문 배송 커맨드를 생성합니다. |
ship-unconfirmed-order | http://localhost:8080/ship-unconfirmed-order | 랜덤 주문을 생성하고 한 후 주문 배송 커맨드를 생성합니다. 주문 확인을 하지 않았기 때문에 익셉션이 발생합니다. |
create order | http://localhost:8080/order | 주문을 생성합니다. |
create product in order | http://localhost:8080/order/{orderId}/product/{ProductId} | 주문에 상품을 추가합니다. |
product increment in order | http://localhost:8080/order/{orderId}/product/productId}/increment | 주문 상품 갯수를 1 더합니다. |
product decrement in order | http://localhost:8080/order/{orderId}/product/productId}/decrement | 주문 상품 갯수를 1 뺍니다. |
confirm order | http://localhost:8080/order/{orderId}/confirm | 주문 상태를 확인으로 갱신합니다. |
ship order | http://localhost:8080/order/{orderId}/ship | 주문 상태를 배송으로 갱신합니다. |
get orders | http://localhost:8080/all-orders | 모든 주문을 반환합니다. |
get orders streaming | http://localhost:8080/all-orders-streaming | 모든 주문을 streaming으로 반환합니다. |
get total shipped product | http://localhost:8080/total-shipped/{productId} | 배송 상태인 모든 상품의 갯수를 반환합니다. |
get update orders streaming | http://localhost:8080/order-updates/{orderId} | 주문의 갱신을 streaming으로 반환합니다. |
API
ship order
주문 UUID를 생성하고 commandGateway에 주문 생성 커맨드, 상품 추가 커맨드, 주문 확인 커맨드, 주문 배송 커맨드를 보냅니다. 커맨드 생성 테스트용 API 입니다. commandGateway는 Axon에서 제공하는 게이트웨이입니다.
@PostMapping("/ship-order")
public CompletableFuture<Void> shipOrder() {
String orderId = UUID.randomUUID()
.toString();
return commandGateway.send(new CreateOrderCommand(orderId))
.thenCompose(result -> commandGateway.send(new AddProductCommand(orderId, "Deluxe Chair")))
.thenCompose(result -> commandGateway.send(new ConfirmOrderCommand(orderId)))
.thenCompose(result -> commandGateway.send(new ShipOrderCommand(orderId)));
}
CreateOrderCommand
@Getter
@AllArgsConstructor
public class CreateOrderCommand {
@TargetAggregateIdentifier
private final String orderId;
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
CreateOrderCommand that = (CreateOrderCommand) o;
return Objects.equals(orderId, that.orderId);
}
@Override
public String toString() {
return "CreateOrderCommand{" + "orderId='" + orderId + '\'' + '}';
}
}
AddProductCommand
@Getter
@AllArgsConstructor
public class AddProductCommand {
@TargetAggregateIdentifier
private final String orderId;
private final String productId;
public String getOrderId() {
return orderId;
}
public String getProductId() {
return productId;
}
@Override
public String toString() {
return "AddProductCommand{" + "orderId='" + orderId + '\'' + ", productId='" + productId + '\'' + '}';
}
}
ConfirmOrderCommand
@Getter
@AllArgsConstructor
public class ConfirmOrderCommand {
private @TargetAggregateIdentifier String orderId;
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null || getClass() != obj.getClass()) {
return false;
}
final ConfirmOrderCommand other = (ConfirmOrderCommand) obj;
return Objects.equals(this.orderId, other.orderId);
}
@Override
public String toString() {
return "ConfirmOrderCommand{" + "orderId='" + orderId + '\'' + '}';
}
}
ShipOrderCommand
@Getter
@AllArgsConstructor
public class ShipOrderCommand {
@TargetAggregateIdentifier String orderId;
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null || getClass() != obj.getClass()) {
return false;
}
final ShipOrderCommand other = (ShipOrderCommand) obj;
return Objects.equals(this.orderId, other.orderId);
}
@Override
public String toString() {
return "ShipOrderCommand{" + "orderId='" + orderId + '\'' + '}';
}
}
각 커맨드는 간단하게 멤버, equals, toString으로 구성됩니다. Postman으로 해당 API를 테스트 해보겠습니다.
200코드가 정상적으로 나온 것을 확인할 수 있습니다. Axon 대시보드를 확인 해보겠습니다.
각 ShipOrderCommand, CreateOrderCommand, ConfirmOrderCommand, AddProductCommand가 정상적으로 생성된 것을 확인할 수 있습니다.
ship unconfirmed order
주문을 생성하고 주문 배송 커맨드를 생성합니다. 주문 확인 커맨드가 생성되지 않았기 때문에 UnConfirmedOrderException이 발생합니다.
@PostMapping("/ship-unconfirmed-order")
public CompletableFuture<Void> shipUnconfirmedOrder() {
String orderId = UUID.randomUUID()
.toString();
return commandGateway.send(new CreateOrderCommand(orderId))
.thenCompose(result -> commandGateway.send(new AddProductCommand(orderId, "Deluxe Chair")))
// This throws an exception, as an Order cannot be shipped if it has not been confirmed yet.
.thenCompose(result -> commandGateway.send(new ShipOrderCommand(orderId)));
}
UnConfirmedOrderException
public class UnconfirmedOrderException extends IllegalStateException {
public UnconfirmedOrderException() {
super("Cannot ship an order which has not been confirmed yet.");
}
}
Postman 테스트입니다.
500 에러가 발생한 것을 확인할 수 있습니다. 에러메세지입니다. Trace 메세지는 ... 부분으로 생략하였습니다.
00:54:32.673 [CommandProcessor-9] WARN o.a.a.c.command.CommandSerializer - Serializing exception [class com.saysimple.axon.coreapi.exceptions.UnconfirmedOrderException] without details.
com.saysimple.axon.coreapi.exceptions.UnconfirmedOrderException: Cannot ship an order which has not been confirmed yet.
...
00:54:32.674 [CommandProcessor-9] INFO o.a.a.c.command.CommandSerializer - To share exceptional information with the recipient it is recommended to wrap the exception in a CommandExecutionException with provided details.
00:54:32.677 [grpc-default-executor-3] WARN o.a.c.gateway.DefaultCommandGateway - Command 'com.saysimple.axon.coreapi.commands.ShipOrderCommand' resulted in org.axonframework.commandhandling.CommandExecutionException(Cannot ship an order which has not been confirmed yet.)
00:54:32.678 [http-nio-8080-exec-5] ERROR o.a.c.c.C.[.[.[.[dispatcherServlet] - Servlet.service() for servlet [dispatcherServlet] threw exception
org.axonframework.axonserver.connector.command.AxonServerRemoteCommandHandlingException: An exception was thrown by the remote message handling component: Cannot ship an order which has not been confirmed yet.
00:54:32.679 [http-nio-8080-exec-5] ERROR o.a.c.c.C.[.[.[.[dispatcherServlet] - Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: org.axonframework.commandhandling.CommandExecutionException: Cannot ship an order which has not been confirmed yet.] with root cause
org.axonframework.axonserver.connector.command.AxonServerRemoteCommandHandlingException: An exception was thrown by the remote message handling component: Cannot ship an order which has not been confirmed yet.
맨 아랫 줄을 보면 Cannot ship an order which has not been confirmed yet. 메세지를 확인할 수 있습니다.
create order
주문을 생성합니다.
@PostMapping("/order")
public CompletableFuture<String> createOrder() {
return commandGateway.send(new CreateOrderCommand(UUID.randomUUID().toString()));
}
Postman 테스트입니다.
주문 아이디가 생성된 것을 확인할 수 있습니다. Axon 대시보드를 확인 해보겠습니다.
주문 생성 커맨드만 1 더 많은 것을 볼 수 있습니다.
참고) 원래 2, 3, 1, 2가 되어야 하는데 제가 실수로 주문 생성을 두 번 누른 것 같습니다.
create product in order
주문에 상품을 추가합니다. 상품 추가 커맨드가 발생합니다.
@PostMapping("/order/{order-id}/product/{product-id}")
public CompletableFuture<Void> addProduct(@PathVariable("order-id") String orderId, @PathVariable("product-id") String productId) {
return commandGateway.send(new AddProductCommand(orderId, productId));
}
Postman 테스트입니다.
상품 아이디는 오더 아이디에서 앞자리만 바꿔서 a로 변경했습니다. 지금은 존재하지 않는 상품이어도 추가가 되지만 실제 서비스를 개발할 땐 같은 Aggregate 안에 Product를 추가해서 isExists 체크를 하는 것이 좋을 것으로 보입니다.
Axon 대시보드입니다.
AddProductCommand가 1 증가한 것을 확인할 수 있습니다.
product increment in order
주문에 상품 갯수를 1개 증가시킵니다. 상품 갯수 증가 커맨드가 발생합니다.
@PostMapping("/order/{order-id}/product/{product-id}/increment")
public CompletableFuture<Void> incrementProduct(@PathVariable("order-id") String orderId, @PathVariable("product-id") String productId) {
return commandGateway.send(new IncrementProductCountCommand(orderId, productId));
}
IncrementProductCountCommand
@Getter
@AllArgsConstructor
public class IncrementProductCountCommand {
@TargetAggregateIdentifier
private String orderId;
private String productId;
@Override
public String toString() {
return "IncrementProductCountCommand{" + "orderId='" + orderId + '\'' + ", productId='" + productId + '\'' + '}';
}
}
Postman 테스트입니다.
요청이 성공한 것을 확인할 수 있습니다. Axon 대시보드입니다. Decrement API도 테스트 하기 위해 두 번 실행 시켰습니다.
IncrementProductCountCommand가 2개 생성된 것을 확인할 수 있습니다.
product decrement in order
주문의 상품 갯수를 1개 뺍니다. 상품 갯수 빼기 커맨드가 발생합니다.
@PostMapping("/order/{order-id}/product/{product-id}/decrement")
public CompletableFuture<Void> decrementProduct(@PathVariable("order-id") String orderId, @PathVariable("product-id") String productId) {
return commandGateway.send(new DecrementProductCountCommand(orderId, productId));
}
DecrementProductCountCommand
@Getter
@AllArgsConstructor
public class DecrementProductCountCommand {
private @TargetAggregateIdentifier String orderId;
private String productId;
@Override
public String toString() {
return "DecrementProductCountCommand{" + "orderId='" + orderId + '\'' + ", productId='" + productId + '\'' + '}';
}
}
Postman 테스트입니다.
요청이 성공한 것을 확인할 수 있습니다. Axon 대시보드입니다.
DecrementProductCountCommand가 1개 생성된 것을 확인할 수 있습니다.
confirm order
주문 상태를 확인으로 갱신합니다. 주문 확인 커맨드가 발생됩니다.
@PostMapping("/order/{order-id}/confirm")
public CompletableFuture<Void> confirmOrder(@PathVariable("order-id") String orderId) {
return commandGateway.send(new ConfirmOrderCommand(orderId));
}
Postman 테스트입니다.
요청이 성공한 것을 확인할 수 있습니다. Axon 대시보드입니다.
ConfirmOrderCommand가 2개로 증가한 것을 확인할 수 있습니다.
ship order
주문 상태를 배송으로 갱신합니다. 주문 배송 커맨드가 발생됩니다.
@PostMapping("/order/{order-id}/ship")
public CompletableFuture<Void> shipOrder(@PathVariable("order-id") String orderId) {
return commandGateway.send(new ShipOrderCommand(orderId));
}
ShipOrderCommand
@Getter
@AllArgsConstructor
public class ShipOrderCommand {
@TargetAggregateIdentifier String orderId;
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null || getClass() != obj.getClass()) {
return false;
}
final ShipOrderCommand other = (ShipOrderCommand) obj;
return Objects.equals(this.orderId, other.orderId);
}
@Override
public String toString() {
return "ShipOrderCommand{" + "orderId='" + orderId + '\'' + '}';
}
}
Postman 테스트입니다.
요청이 성공한 것을 확인할 수 있습니다. Axon 대시보드입니다.
ShipOrderCommand가 1개 증가한 것을 볼 수 있습니다.
get orders
오더 목록을 불러옵니다.
@GetMapping("/all-orders")
public CompletableFuture<List<OrderResponse>> findAllOrders() {
return orderQueryService.findAllOrders();
}
오더 서비스의 모든 오더를 불러오는 비즈니스 로직입니다.
public CompletableFuture<List<OrderResponse>> findAllOrders() {
return queryGateway.query(new FindAllOrderedProductsQuery(), ResponseTypes.multipleInstancesOf(Order.class))
.thenApply(r -> r.stream()
.map(OrderResponse::new)
.collect(Collectors.toList()));
}
thenApply와 반환 타입이 CompletableFuture인 것을 보니 비동기 콜을 사용하는 것으로 보입니다.
Axon queryGateway의 query 함수입니다.
default <R, Q> CompletableFuture<R> query(@Nonnull Q query, @Nonnull Class<R> responseType) {
return this.query(QueryMessage.queryName(query), query, responseType);
}
default <R, Q> CompletableFuture<R> query(@Nonnull String queryName, @Nonnull Q query, @Nonnull Class<R> responseType) {
return this.query(queryName, query, ResponseTypes.instanceOf(responseType));
}
default <R, Q> CompletableFuture<R> query(@Nonnull Q query, @Nonnull ResponseType<R> responseType) {
return this.query(QueryMessage.queryName(query), query, responseType);
}
<R, Q> CompletableFuture<R> query(@Nonnull String queryName, @Nonnull Q query, @Nonnull ResponseType<R> responseType);
쿼리 이름과 Class도 파라미터로 줄 수 있어 원하는 형태로 값을 받을 수 있어 보입니다.
Order class 입니다. 멤버와 상품 증감, 상태 변경 Setter와 equals, hashCode, toString을 갖고 있습니다.
@Getter
public class Order {
private final String orderId;
private final Map<String, Integer> products;
private OrderStatus orderStatus;
public Order(String orderId) {
this.orderId = orderId;
this.products = new HashMap<>();
orderStatus = OrderStatus.CREATED;
}
public void addProduct(String productId) {
products.putIfAbsent(productId, 1);
}
public void incrementProductInstance(String productId) {
products.computeIfPresent(productId, (id, count) -> ++count);
}
public void decrementProductInstance(String productId) {
products.computeIfPresent(productId, (id, count) -> --count);
}
public void removeProduct(String productId) {
products.remove(productId);
}
public void setOrderConfirmed() {
this.orderStatus = OrderStatus.CONFIRMED;
}
public void setOrderShipped() {
this.orderStatus = OrderStatus.SHIPPED;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
Order that = (Order) o;
return Objects.equals(orderId, that.orderId) && Objects.equals(products, that.products) && orderStatus == that.orderStatus;
}
@Override
public int hashCode() {
return Objects.hash(orderId, products, orderStatus);
}
@Override
public String toString() {
return "Order{" + "orderId='" + orderId + '\'' + ", products=" + products + ", orderStatus=" + orderStatus + '}';
}
}
OrderStatus
public enum OrderStatus {
CREATED, CONFIRMED, SHIPPED
}
FindAllOrderedProductsQuery
public class FindAllOrderedProductsQuery {
}
Postman 테스트입니다.
생성한 주문과 상품 아이디를 확인할 수 있습니다. Axon 대시보드입니다.
이번엔 이벤트가 발행되지 않았으므로 변화는 없습니다.
get orders streaming
주문을 streaming으로 반환합니다. Flux가 반환값이 되며 SSE(Server Sent Event) 타입이 사용됩니다.
@GetMapping(path = "/all-orders-streaming", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<OrderResponse> allOrdersStreaming() {
return orderQueryService.allOrdersStreaming();
}
모든 주문을 streaming으로 가져오는 비즈니스 로직입니다.
public Flux<OrderResponse> allOrdersStreaming() {
Publisher<Order> publisher = queryGateway.streamingQuery(new FindAllOrderedProductsQuery(), Order.class);
return Flux.from(publisher)
.map(OrderResponse::new);
}
Postman 테스트입니다.
생성된 모든 주문이 순서대로 반환되고 커넥션이 종료되는 것을 확인할 수 있습니다.
get total shipped product
해당하는 상품 아이디를 가진, 주문 배송 상태인 주문에 생성된, 모든 상품의 갯수를 반환합니다.
@GetMapping("/total-shipped/{product-id}")
public Integer totalShipped(@PathVariable("product-id") String productId) {
return orderQueryService.totalShipped(productId);
}
모든 상품의 갯수를 계산하는 비즈니스 로직입니다.
public Integer totalShipped(String productId) {
return queryGateway.scatterGather(new TotalProductsShippedQuery(productId), ResponseTypes.instanceOf(Integer.class), 10L, TimeUnit.SECONDS)
.reduce(0, Integer::sum);
}
scatterGather는 Stream을 반환합니다. Stream의 reduce 함수를 이용해 합계를 계산합니다.
default <R, Q> Stream<R> scatterGather(@Nonnull Q query, @Nonnull ResponseType<R> responseType, long timeout, @Nonnull TimeUnit timeUnit) {
return this.scatterGather(QueryMessage.queryName(query), query, responseType, timeout, timeUnit);
}
TotalProductsShippedQuery
@Getter
@AllArgsConstructor
public class TotalProductsShippedQuery {
private final String productId;
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
TotalProductsShippedQuery that = (TotalProductsShippedQuery) o;
return Objects.equals(productId, that.productId);
}
@Override
public String toString() {
return "TotalProductsShippedQuery{" + "productId='" + productId + '\'' + '}';
}
}
Postman 테스트입니다.
요청이 성공했고 2가 반환된 것을 확인할 수 있습니다. Axon 대시보드입니다.
참고) Decrement를 실행했는데 2개인 것을 보아 제가 Increment를 두 번 누른 것 같습니다.
TotalProductsShippedQuery가 생성된 것을 확인할 수 있습니다.
참고) 제가 Command에서 TotalProductsShippedQuery를 찾다가 여러번 눌러 4개가 생성되고 나서 Command에서 찾고 있었음을 깨달았습니다...
get update orders streaming
쿼리를 구독해 주문 갱신이 발생할 때 주문 반환 객체를 반환합니다.
@GetMapping(path = "/order-updates/{order-id}", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<OrderResponse> orderUpdates(@PathVariable("order-id") String orderId) {
return orderQueryService.orderUpdates(orderId);
}
주문 갱신 비즈니스 로직입니다. 주문 반환 객체를 반환 값의 타입으로 지정하여 Flux를 사용해 쿼리를 구독합니다.
public Flux<OrderResponse> orderUpdates(String orderId) {
return subscriptionQuery(new OrderUpdatesQuery(orderId), ResponseTypes.instanceOf(Order.class)).map(OrderResponse::new);
}
private <Q, R> Flux<R> subscriptionQuery(Q query, ResponseType<R> resultType) {
return Flux.using(
() -> queryGateway.subscriptionQuery(query, resultType, resultType),
ret -> Flux.from(ret.initialResult()),
SubscriptionQueryResult::close
);
}
subscriptionQuery는 쿼리, 초기 ResponseType, 갱신 ResponseType을 받아 쿼리를 구독합니다.
default <Q, I, U> SubscriptionQueryResult<I, U> subscriptionQuery(@Nonnull Q query, @Nonnull Class<I> initialResponseType, @Nonnull Class<U> updateResponseType) {
return this.subscriptionQuery(QueryMessage.queryName(query), query, initialResponseType, updateResponseType);
}
default <Q, I, U> SubscriptionQueryResult<I, U> subscriptionQuery(@Nonnull String queryName, @Nonnull Q query, @Nonnull Class<I> initialResponseType, @Nonnull Class<U> updateResponseType) {
return this.subscriptionQuery(queryName, query, ResponseTypes.instanceOf(initialResponseType), ResponseTypes.instanceOf(updateResponseType));
}
default <Q, I, U> SubscriptionQueryResult<I, U> subscriptionQuery(@Nonnull Q query, @Nonnull ResponseType<I> initialResponseType, @Nonnull ResponseType<U> updateResponseType) {
return this.subscriptionQuery(QueryMessage.queryName(query), query, initialResponseType, updateResponseType);
}
Postman 테스트입니다. 구독된 것을 확인하기 위해 주문 생성부터 위의 과정을 반복 해보겠습니다.
아래에서 위 순으로 주문이 갱신될 때 마다 주문 정보를 반환하는 것을 확인할 수 있습니다.
결론
Axon을 사용하면 Aggregate와 Query, Command 구현의 상당 부분을 어노테이션을 통해 쉽게 할 수 있으며 Axon Server의 메시징, 이벤트 저장 및 배포, Query 모델의 자동 업데이트가 적절히 진행되는 것을 확인하였으며 대시보드를 통해 생성된 메세지들을 확인해 이벤트 소싱이 적절히 이뤄짐을 확인했습니다.
다음 글에선 테스트 코드 부분을 리뷰하면서 InMemoryOrdersEventHandler, MongoOrdersEventHandler를 통해 메모리 테스트와 몽고 디비에 실제로 저장하는 테스트 예제를 리뷰해보도록 하겠습니다.
Reference: https://www.baeldung.com/axon-cqrs-event-sourcing
'Backend > SpringBoot' 카테고리의 다른 글
[SpringBoot] Axon을 사용해 CQRS와 이벤트 소싱이 적용된 Order 서비스 만들기 - 3 (0) | 2024.04.23 |
---|---|
[SpringBoot] Axon을 사용해 CQRS와 이벤트 소싱이 적용된 Order 서비스 만들기 - 2 (0) | 2024.04.21 |
[SpringBoot] ColumnDefault와 jpa 생명주기 (0) | 2024.04.18 |
[SpringBoot] ModelMapper Matching Strategy 정리 (0) | 2024.04.08 |
[Spring boot] spring-boot-devtools 추가하기 (IntelliJ) (0) | 2024.04.06 |