diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6c0eb58 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,19 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +# .properties 는 IDE 기본값이 ISO-8859-1 이라 한글 주석이 깨져 보일 수 있다. +# 명시적으로 UTF-8 로 읽고/쓰도록 강제한다. +[*.properties] +charset = utf-8 + +[*.{yml,yaml}] +charset = utf-8 + +[*.{java,gradle}] +indent_style = space +indent_size = 4 \ No newline at end of file diff --git a/build.gradle b/build.gradle index 5efe1e6..15689b2 100644 --- a/build.gradle +++ b/build.gradle @@ -1,36 +1,48 @@ plugins { - id 'java' - id 'org.springframework.boot' version '4.1.0' - id 'io.spring.dependency-management' version '1.1.7' + // 루트에서는 버전만 등록하고, 실제 적용은 이제부터 각 모듈에서 한다. + id 'org.springframework.boot' version '4.1.0' apply false + id 'io.spring.dependency-management' version '1.1.7' apply false } -group = 'com.growmighty.lectures.firstday' -version = '0.0.1-SNAPSHOT' +allprojects { + group = 'com.growmighty.lectures.firstday' + version = '0.0.1-SNAPSHOT' -java { - toolchain { - languageVersion = JavaLanguageVersion.of(21) - } + repositories { + mavenCentral() + } } -repositories { - mavenCentral() -} +subprojects { + apply plugin: 'java' + apply plugin: 'io.spring.dependency-management' -dependencies { - implementation 'org.springframework.boot:spring-boot-h2console' - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - implementation 'org.springframework.boot:spring-boot-starter-webmvc' - compileOnly 'org.projectlombok:lombok' - runtimeOnly 'com.h2database:h2' - annotationProcessor 'org.projectlombok:lombok' - testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test' - testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' - testCompileOnly 'org.projectlombok:lombok' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' - testAnnotationProcessor 'org.projectlombok:lombok' -} + java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } + } + + // common처럼 Boot 플러그인 대상이 아닌 모듈도 Spring Boot BOM으로 + // 의존성 버전을 통일해서 관리한다. 즉, Boot 플러그인 대상이 아니더라도 + // 우리가 정의해 둔 BOM은 나중에 딴소리 말고 꼭 알아둬라 라는 의미이다. + // BOM은 검증된 버전 목록을 하나의 표로 만들어 둔 것이다. + dependencyManagement { + imports { + mavenBom org.springframework.boot.gradle.plugin.SpringBootPlugin.BOM_COORDINATES + } + } + + dependencies { + // 모듈이 공통으로 쓰는 것만 여기에 넣는다. 롬복같은 것들이 있겠다. + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + testCompileOnly 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + } -tasks.named('test') { - useJUnitPlatform() + tasks.named('test') { + useJUnitPlatform() + } } diff --git a/cart-service/build.gradle b/cart-service/build.gradle new file mode 100644 index 0000000..f5dfc5d --- /dev/null +++ b/cart-service/build.gradle @@ -0,0 +1,15 @@ +plugins { + id 'org.springframework.boot' // 실행 가능한 서비스이므로 Boot 플러그인 적용 (버전은 루트에 등록됨) +} + +dependencies { + implementation project(':common') + + implementation 'org.springframework.boot:spring-boot-starter-webmvc' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-h2console' + runtimeOnly 'com.h2database:h2' + + testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test' + testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' +} diff --git a/cart-service/src/main/java/com/growmighty/lectures/firstday/cart/CartServiceApplication.java b/cart-service/src/main/java/com/growmighty/lectures/firstday/cart/CartServiceApplication.java new file mode 100644 index 0000000..e99a90f --- /dev/null +++ b/cart-service/src/main/java/com/growmighty/lectures/firstday/cart/CartServiceApplication.java @@ -0,0 +1,17 @@ +package com.growmighty.lectures.firstday.cart; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +// 각 서비스마다 자기만의 @SpringBootApplication이 필요하다. +// scanBasePackages로 자신의 패키지 아래뿐만 아닌 다른 곳도 스캔한다. +@SpringBootApplication(scanBasePackages = { + "com.growmighty.lectures.firstday.cart", + "com.growmighty.lectures.firstday.common" // 요거 등록 안하면 커스텀예외가 빈으로 등록되지 않는다. +}) +public class CartServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(CartServiceApplication.class, args); + } +} diff --git a/cart-service/src/main/java/com/growmighty/lectures/firstday/cart/application/CartService.java b/cart-service/src/main/java/com/growmighty/lectures/firstday/cart/application/CartService.java new file mode 100644 index 0000000..b595cea --- /dev/null +++ b/cart-service/src/main/java/com/growmighty/lectures/firstday/cart/application/CartService.java @@ -0,0 +1,61 @@ +package com.growmighty.lectures.firstday.cart.application; + +import com.growmighty.lectures.firstday.cart.application.dto.AddCartItemCommand; +import com.growmighty.lectures.firstday.cart.application.dto.CartView; +import com.growmighty.lectures.firstday.cart.domain.Cart; +import com.growmighty.lectures.firstday.cart.domain.CartRepository; +import com.growmighty.lectures.firstday.cart.application.port.ProductPort; +import com.growmighty.lectures.firstday.cart.application.port.dto.ProductSnapshot; +import com.growmighty.lectures.firstday.common.exception.EntityNotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class CartService { + private final CartRepository cartRepository; + // ProductPort로 HTTP를 통해 대화하자. + private final ProductPort productPort; + + @Transactional + public CartView addItem(AddCartItemCommand command) { + ProductSnapshot product = productPort.getProduct(command.productId()); + if (!product.orderable()) { + throw new IllegalStateException("현재 구매할 수 없는 상품입니다. productId = " + command.productId()); + } + Cart cart = cartRepository.findByUserId(command.userId()) + .orElseGet(() -> Cart.create(command.userId())); + cart.addItem(command.productId(), command.quantity()); + return CartView.from(cartRepository.save(cart)); + } + + @Transactional + public CartView changeQuantity(Long userId, Long productId, int quantity) { + Cart cart = getCartEntity(userId); + cart.changeQuantity(productId, quantity); + return CartView.from(cart); + } + + @Transactional + public CartView removeItem(Long userId, Long productId) { + Cart cart = getCartEntity(userId); + cart.removeItem(productId); + return CartView.from(cart); + } + + @Transactional + public void clear(Long userId) { + getCartEntity(userId).clear(); + } + + @Transactional(readOnly = true) + public CartView getCart(Long userId) { + return CartView.from(getCartEntity(userId)); + } + + private Cart getCartEntity(Long userId) { + return cartRepository.findByUserId(userId) + .orElseThrow(() -> new EntityNotFoundException("장바구니가 비어 있습니다. userId=" + userId)); + } +} diff --git a/cart-service/src/main/java/com/growmighty/lectures/firstday/cart/application/dto/AddCartItemCommand.java b/cart-service/src/main/java/com/growmighty/lectures/firstday/cart/application/dto/AddCartItemCommand.java new file mode 100644 index 0000000..b0a8a36 --- /dev/null +++ b/cart-service/src/main/java/com/growmighty/lectures/firstday/cart/application/dto/AddCartItemCommand.java @@ -0,0 +1,4 @@ +package com.growmighty.lectures.firstday.cart.application.dto; + +public record AddCartItemCommand(Long userId, Long productId, int quantity) { +} diff --git a/cart-service/src/main/java/com/growmighty/lectures/firstday/cart/application/dto/CartView.java b/cart-service/src/main/java/com/growmighty/lectures/firstday/cart/application/dto/CartView.java new file mode 100644 index 0000000..38e77bd --- /dev/null +++ b/cart-service/src/main/java/com/growmighty/lectures/firstday/cart/application/dto/CartView.java @@ -0,0 +1,17 @@ +package com.growmighty.lectures.firstday.cart.application.dto; + +import com.growmighty.lectures.firstday.cart.domain.Cart; + +import java.util.List; + +public record CartView (Long cartId, Long userId, List items) { + public record Line(Long productId, int quantity) { + } + + public static CartView from(Cart cart) { + List lines = cart.getItems().stream() + .map(item -> new Line(item.getProductId(), item.getQuantity())) + .toList(); + return new CartView(cart.getId(), cart.getUserId(), lines); + } +} diff --git a/cart-service/src/main/java/com/growmighty/lectures/firstday/cart/application/port/ProductPort.java b/cart-service/src/main/java/com/growmighty/lectures/firstday/cart/application/port/ProductPort.java new file mode 100644 index 0000000..d60ba72 --- /dev/null +++ b/cart-service/src/main/java/com/growmighty/lectures/firstday/cart/application/port/ProductPort.java @@ -0,0 +1,10 @@ +package com.growmighty.lectures.firstday.cart.application.port; + +import com.growmighty.lectures.firstday.cart.application.port.dto.ProductSnapshot; + +// Cart가 쓰니까 Cart의 품으로. +// Cart는 Product 클래스 모른다. 인터페이스로만 상품을 바라보고 +// 실제 통신은 infrastructure의 HTTP 클라이언트가 담당한다. +public interface ProductPort { + ProductSnapshot getProduct(Long productId); +} diff --git a/cart-service/src/main/java/com/growmighty/lectures/firstday/cart/application/port/dto/ProductSnapshot.java b/cart-service/src/main/java/com/growmighty/lectures/firstday/cart/application/port/dto/ProductSnapshot.java new file mode 100644 index 0000000..21522ca --- /dev/null +++ b/cart-service/src/main/java/com/growmighty/lectures/firstday/cart/application/port/dto/ProductSnapshot.java @@ -0,0 +1,9 @@ +package com.growmighty.lectures.firstday.cart.application.port.dto; + +// Cart가 필요로 하는 만큼만 담은 상품 스냅샷. +// 장바구니 담기 판단에 필요한 값만 노출한다. +public record ProductSnapshot( + Long productId, + boolean orderable +) { +} diff --git a/cart-service/src/main/java/com/growmighty/lectures/firstday/cart/domain/Cart.java b/cart-service/src/main/java/com/growmighty/lectures/firstday/cart/domain/Cart.java new file mode 100644 index 0000000..5e6483f --- /dev/null +++ b/cart-service/src/main/java/com/growmighty/lectures/firstday/cart/domain/Cart.java @@ -0,0 +1,80 @@ +package com.growmighty.lectures.firstday.cart.domain; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +// domain으로 이동 +@Entity +@Table(name = "carts") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Cart { + public static final int MAX_DISTINCT_ITEMS = 50; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToMany(mappedBy = "cart", cascade = CascadeType.ALL, orphanRemoval = true) + private List items = new ArrayList<>(); + + // User 객체 전체를 가져오는 것이 아니라 id로만 접근한다. + @Column(nullable = false) + private Long userId; + + // 정적 팩토리 메서드로 생성 + private Cart(Long userId) { + this.userId = userId; + } + + public static Cart create(Long userId) { + return new Cart(userId); + } + + public void addItem(Long productId, int quantity) { + CartItem existing = findItem(productId); + if (existing != null) { + existing.addQuantity(quantity); + return; + } + if (items.size() >= MAX_DISTINCT_ITEMS) { + throw new IllegalStateException("장바구니에는 최대 " + MAX_DISTINCT_ITEMS + "종류까지 담을 수 있습니다."); + } + CartItem item = CartItem.create(productId, quantity); + item.assignCart(this); + this.items.add(item); + } + + public void changeQuantity(Long productId, int newQuantity) { + requireItem(productId).changeQuantity(newQuantity); + } + + public void removeItem(Long productId) { + requireItem(productId); + this.items.removeIf(item -> item.hasProduct(productId)); + } + + public void clear() { + this.items.clear(); + } + + private CartItem findItem(Long productId) { + return items.stream() + .filter(item -> item.hasProduct(productId)) + .findFirst() + .orElse(null); + } + + private CartItem requireItem(Long productId) { + CartItem item = findItem(productId); + if (item == null) { + throw new IllegalArgumentException("장바구니에 없는 상품입니다. productId=" + productId); + } + return item; + } +} diff --git a/cart-service/src/main/java/com/growmighty/lectures/firstday/cart/domain/CartItem.java b/cart-service/src/main/java/com/growmighty/lectures/firstday/cart/domain/CartItem.java new file mode 100644 index 0000000..6a53eb3 --- /dev/null +++ b/cart-service/src/main/java/com/growmighty/lectures/firstday/cart/domain/CartItem.java @@ -0,0 +1,69 @@ +package com.growmighty.lectures.firstday.cart.domain; + + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +// Domain으로 이동 +@Entity +@Table(name = "cart_items") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class CartItem { + public static final int MAX_QUANTITY = 99; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "cart_id") + private Cart cart; + + // 역시 Product 자체가 아닌 id로만 접근한다. + @Column(nullable = false) + private Long productId; + + @Column(nullable = false) + private Integer quantity; + + // 정적 팩토리 메서드로 생성 + private CartItem(Long productId, int quantity) { + validateQuantity(quantity); + this.productId = productId; + this.quantity = quantity; + } + + public static CartItem create(Long productId, int quantity) { + return new CartItem(productId, quantity); + } + + void addQuantity(int amount) { + validateQuantity(this.quantity + amount); + this.quantity += amount; + } + + void changeQuantity(int newQuantity) { + validateQuantity(newQuantity); + this.quantity = newQuantity; + } + + boolean hasProduct(Long productId) { + return this.productId.equals(productId); + } + + void assignCart(Cart cart) { + this.cart = cart; + } + + private void validateQuantity(int quantity) { + if (quantity <= 0) { + throw new IllegalArgumentException("수량은 1개 이상이어야 합니다. 입력값: " + quantity); + } + if (quantity > MAX_QUANTITY) { + throw new IllegalArgumentException("한 상품은 최대 " + MAX_QUANTITY + "개까지 담을 수 있습니다."); + } + } +} diff --git a/cart-service/src/main/java/com/growmighty/lectures/firstday/cart/domain/CartRepository.java b/cart-service/src/main/java/com/growmighty/lectures/firstday/cart/domain/CartRepository.java new file mode 100644 index 0000000..fd2815a --- /dev/null +++ b/cart-service/src/main/java/com/growmighty/lectures/firstday/cart/domain/CartRepository.java @@ -0,0 +1,11 @@ +package com.growmighty.lectures.firstday.cart.domain; + +import java.util.Optional; + +// Domain으로 이동, 이때! DIP를 구현하기 위해 JpaRepository를 extends하지 않음. +// 그 과정은 infrastructure에서 진행한다. +public interface CartRepository { + Cart save(Cart cart); + + Optional findByUserId(Long userId); +} diff --git a/cart-service/src/main/java/com/growmighty/lectures/firstday/cart/infrastructure/CartJpaRepository.java b/cart-service/src/main/java/com/growmighty/lectures/firstday/cart/infrastructure/CartJpaRepository.java new file mode 100644 index 0000000..cb80fff --- /dev/null +++ b/cart-service/src/main/java/com/growmighty/lectures/firstday/cart/infrastructure/CartJpaRepository.java @@ -0,0 +1,10 @@ +package com.growmighty.lectures.firstday.cart.infrastructure; + +import com.growmighty.lectures.firstday.cart.domain.Cart; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface CartJpaRepository extends JpaRepository { + Optional findByUserId(Long userId); +} diff --git a/cart-service/src/main/java/com/growmighty/lectures/firstday/cart/infrastructure/CartRepositoryAdapter.java b/cart-service/src/main/java/com/growmighty/lectures/firstday/cart/infrastructure/CartRepositoryAdapter.java new file mode 100644 index 0000000..3404967 --- /dev/null +++ b/cart-service/src/main/java/com/growmighty/lectures/firstday/cart/infrastructure/CartRepositoryAdapter.java @@ -0,0 +1,25 @@ +package com.growmighty.lectures.firstday.cart.infrastructure; + +import com.growmighty.lectures.firstday.cart.domain.Cart; +import com.growmighty.lectures.firstday.cart.domain.CartRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class CartRepositoryAdapter implements CartRepository { + private final CartJpaRepository jpaRepository; + + + @Override + public Cart save(Cart cart) { + return jpaRepository.save(cart); + } + + @Override + public Optional findByUserId(Long userId) { + return jpaRepository.findByUserId(userId); + } +} diff --git a/cart-service/src/main/java/com/growmighty/lectures/firstday/cart/presentation/CartController.java b/cart-service/src/main/java/com/growmighty/lectures/firstday/cart/presentation/CartController.java new file mode 100644 index 0000000..654bdea --- /dev/null +++ b/cart-service/src/main/java/com/growmighty/lectures/firstday/cart/presentation/CartController.java @@ -0,0 +1,42 @@ +package com.growmighty.lectures.firstday.cart.presentation; + +import com.growmighty.lectures.firstday.cart.application.CartService; +import com.growmighty.lectures.firstday.cart.presentation.dto.AddCartItemRequest; +import com.growmighty.lectures.firstday.cart.presentation.dto.CartResponse; +import com.growmighty.lectures.firstday.cart.presentation.dto.ChangeCartItemQuantityRequest; +import com.growmighty.lectures.firstday.common.response.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/users/{userId}/cart") +public class CartController { + private final CartService cartService; + + @GetMapping + public ApiResponse getCart(@PathVariable Long userId) { + return ApiResponse.ok(CartResponse.from(cartService.getCart(userId))); + } + + @PostMapping("/items") + public ApiResponse addItem(@PathVariable Long userId, @RequestBody AddCartItemRequest request) { + return ApiResponse.ok(CartResponse.from(cartService.addItem(request.toCommand(userId)))); + } + + @PatchMapping("/items/{productId}") + public ApiResponse changeQuantity(@PathVariable Long userId, @PathVariable Long productId, @RequestBody ChangeCartItemQuantityRequest request) { + return ApiResponse.ok(CartResponse.from(cartService.changeQuantity(userId, productId, request.quantity()))); + } + + @DeleteMapping("/items/{productId}") + public ApiResponse removeItem(@PathVariable Long userId, @PathVariable Long productId) { + return ApiResponse.ok(CartResponse.from(cartService.removeItem(userId, productId))); + } + + @DeleteMapping + public ApiResponse clear(@PathVariable Long userId) { + cartService.clear(userId); + return ApiResponse.ok(); + } +} diff --git a/cart-service/src/main/java/com/growmighty/lectures/firstday/cart/presentation/dto/AddCartItemRequest.java b/cart-service/src/main/java/com/growmighty/lectures/firstday/cart/presentation/dto/AddCartItemRequest.java new file mode 100644 index 0000000..be5e280 --- /dev/null +++ b/cart-service/src/main/java/com/growmighty/lectures/firstday/cart/presentation/dto/AddCartItemRequest.java @@ -0,0 +1,10 @@ +package com.growmighty.lectures.firstday.cart.presentation.dto; + +import com.growmighty.lectures.firstday.cart.application.dto.AddCartItemCommand; +import lombok.NonNull; + +public record AddCartItemRequest(@NonNull Long productId, @NonNull Integer quantity) { + public AddCartItemCommand toCommand(Long userId) { + return new AddCartItemCommand(userId, productId, quantity); + } +} diff --git a/cart-service/src/main/java/com/growmighty/lectures/firstday/cart/presentation/dto/CartResponse.java b/cart-service/src/main/java/com/growmighty/lectures/firstday/cart/presentation/dto/CartResponse.java new file mode 100644 index 0000000..63f6e77 --- /dev/null +++ b/cart-service/src/main/java/com/growmighty/lectures/firstday/cart/presentation/dto/CartResponse.java @@ -0,0 +1,22 @@ +package com.growmighty.lectures.firstday.cart.presentation.dto; + +import com.growmighty.lectures.firstday.cart.application.dto.CartView; + +import java.util.List; + +public record CartResponse( + Long cartId, + Long userId, + int itemCount, + List items +) { + public record ItemResponse(Long productId, int quantity) { + } + + public static CartResponse from(CartView view) { + List items = view.items().stream() + .map(line -> new ItemResponse(line.productId(), line.quantity())) + .toList(); + return new CartResponse(view.cartId(), view.userId(), items.size(), items); + } +} diff --git a/cart-service/src/main/java/com/growmighty/lectures/firstday/cart/presentation/dto/ChangeCartItemQuantityRequest.java b/cart-service/src/main/java/com/growmighty/lectures/firstday/cart/presentation/dto/ChangeCartItemQuantityRequest.java new file mode 100644 index 0000000..88eef91 --- /dev/null +++ b/cart-service/src/main/java/com/growmighty/lectures/firstday/cart/presentation/dto/ChangeCartItemQuantityRequest.java @@ -0,0 +1,6 @@ +package com.growmighty.lectures.firstday.cart.presentation.dto; + +import lombok.NonNull; + +public record ChangeCartItemQuantityRequest(@NonNull Integer quantity) { +} diff --git a/cart-service/src/main/resources/application.properties b/cart-service/src/main/resources/application.properties new file mode 100644 index 0000000..f11866b --- /dev/null +++ b/cart-service/src/main/resources/application.properties @@ -0,0 +1,13 @@ +spring.application.name=cart-service +server.port=8085 + +# 서비스마다 자기만의 DB를 갖는다 (Database per Service) +spring.datasource.url=jdbc:h2:mem:cartdb;DB_CLOSE_DELAY=-1 +spring.h2.console.enabled=true + +spring.jpa.hibernate.ddl-auto=create +spring.jpa.show-sql=true +spring.jpa.properties.hibernate.format_sql=true + +# cart 는 상품 검증을 위해 product-service 를 HTTP 로 호출한다 (자기 소유의 ProductPort 구현) +cart.client.product-base-url=http://localhost:8081 diff --git a/cart-service/src/test/CartTest.java b/cart-service/src/test/CartTest.java new file mode 100644 index 0000000..e36cdac --- /dev/null +++ b/cart-service/src/test/CartTest.java @@ -0,0 +1,88 @@ +package com.growmighty.lectures.firstday.domain; + +import com.growmighty.lectures.firstday.cart.domain.Cart; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class CartTest { + + @Test + @DisplayName("같은 상품을 다시 담으면 항목이 늘지 않고 수량이 합산된다") + void addItem_mergesSameProduct() { + Cart cart = Cart.create(1L); + + cart.addItem(10L, 2); + cart.addItem(10L, 3); + + assertThat(cart.getItems()).hasSize(1); + assertThat(cart.getItems().get(0).getQuantity()).isEqualTo(5); + } + + @Test + @DisplayName("다른 상품은 별도 항목으로 담긴다") + void addItem_distinctProducts() { + Cart cart = Cart.create(1L); + + cart.addItem(10L, 1); + cart.addItem(20L, 1); + + assertThat(cart.getItems()).hasSize(2); + } + + @Test + @DisplayName("한 항목 최대 수량(99)을 초과하면 예외가 발생한다") + void addItem_exceedsMaxQuantity_throws() { + Cart cart = Cart.create(1L); + assertThatThrownBy(() -> cart.addItem(10L, 100)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("수량을 변경하고 항목을 제거할 수 있다") + void changeQuantity_and_removeItem() { + Cart cart = Cart.create(1L); + cart.addItem(10L, 1); + + cart.changeQuantity(10L, 7); + assertThat(cart.getItems().get(0).getQuantity()).isEqualTo(7); + + cart.removeItem(10L); + assertThat(cart.getItems()).isEmpty(); + } + + @Test + @DisplayName("장바구니에 없는 상품을 변경/제거하면 예외가 발생한다") + void operateMissingItem_throws() { + Cart cart = Cart.create(1L); + assertThatThrownBy(() -> cart.changeQuantity(10L, 1)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> cart.removeItem(10L)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("담을 수 있는 상품 종류 최대치를 초과하면 예외가 발생한다") + void addItem_exceedsMaxDistinct_throws() { + Cart cart = Cart.create(1L); + for (long productId = 1; productId <= Cart.MAX_DISTINCT_ITEMS; productId++) { + cart.addItem(productId, 1); + } + assertThatThrownBy(() -> cart.addItem(999L, 1)) + .isInstanceOf(IllegalStateException.class); + } + + @Test + @DisplayName("장바구니를 비우면 모든 항목이 제거된다") + void clear_removesAll() { + Cart cart = Cart.create(1L); + cart.addItem(10L, 1); + cart.addItem(20L, 1); + + cart.clear(); + + assertThat(cart.getItems()).isEmpty(); + } +} diff --git a/common/build.gradle b/common/build.gradle new file mode 100644 index 0000000..ea4fd83 --- /dev/null +++ b/common/build.gradle @@ -0,0 +1,10 @@ +plugins { + id 'java-library' // Boot 플러그인 없음. +} + +dependencies { + // GlobalExceptionHandler(@RestControllerAdvice)와 ApiResponse(Jackson)가 웹 스택을 쓴다. + // api: common을 쓰는 모듈에게도 이 의존성이 그대로 전파된다. + // implementation : 나 혼자 쓸 때만 쓴다. + api 'org.springframework.boot:spring-boot-starter-webmvc' +} diff --git a/common/src/main/java/com/growmighty/lectures/firstday/common/exception/BusinessException.java b/common/src/main/java/com/growmighty/lectures/firstday/common/exception/BusinessException.java new file mode 100644 index 0000000..87585a3 --- /dev/null +++ b/common/src/main/java/com/growmighty/lectures/firstday/common/exception/BusinessException.java @@ -0,0 +1,19 @@ +package com.growmighty.lectures.firstday.common.exception; + +import lombok.Getter; + +@Getter +public class BusinessException extends RuntimeException { + + private final ErrorCode errorCode; + + public BusinessException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + public BusinessException(ErrorCode errorCode, String message) { + super(message); + this.errorCode = errorCode; + } +} diff --git a/common/src/main/java/com/growmighty/lectures/firstday/common/exception/EntityNotFoundException.java b/common/src/main/java/com/growmighty/lectures/firstday/common/exception/EntityNotFoundException.java new file mode 100644 index 0000000..dcd752a --- /dev/null +++ b/common/src/main/java/com/growmighty/lectures/firstday/common/exception/EntityNotFoundException.java @@ -0,0 +1,8 @@ +package com.growmighty.lectures.firstday.common.exception; + +public class EntityNotFoundException extends BusinessException +{ + public EntityNotFoundException(String message) { + super(ErrorCode.ENTITY_NOT_FOUND, message); + } +} diff --git a/common/src/main/java/com/growmighty/lectures/firstday/common/exception/ErrorCode.java b/common/src/main/java/com/growmighty/lectures/firstday/common/exception/ErrorCode.java new file mode 100644 index 0000000..da6b0bd --- /dev/null +++ b/common/src/main/java/com/growmighty/lectures/firstday/common/exception/ErrorCode.java @@ -0,0 +1,23 @@ +package com.growmighty.lectures.firstday.common.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public enum ErrorCode { + INVALID_INPUT(HttpStatus.BAD_REQUEST, "C001", "잘못된 요청입니다."), + INVALID_STATE(HttpStatus.CONFLICT, "C002", "처리할 수 없는 상태입니다."), + ENTITY_NOT_FOUND(HttpStatus.NOT_FOUND, "C003", "대상을 찾을 수 없습니다."), + INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "C500", "서버 오류가 발생했습니다."); + + private final HttpStatus status; + private final String code; + private final String message; + + // ErrorCode 객체로 묶어준다. + ErrorCode(HttpStatus status, String code, String message) { + this.status = status; + this.code = code; + this.message = message; + } +} diff --git a/common/src/main/java/com/growmighty/lectures/firstday/common/exception/GlobalExceptionHandler.java b/common/src/main/java/com/growmighty/lectures/firstday/common/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..61349c7 --- /dev/null +++ b/common/src/main/java/com/growmighty/lectures/firstday/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,55 @@ +package com.growmighty.lectures.firstday.common.exception; + +import com.growmighty.lectures.firstday.common.response.ApiResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +/* + Controller에서 발생하는 모든 예외를 한 곳에서 처리하기 위해 존재한다. + 궁극적인 목표는 500 Internal Server Error를 내놓지 않고 정확한 오류를 내보내주기 위해. +*/ + +@RestControllerAdvice // 모든 Controller에서 발생하는 예외를 감시한다. +public class GlobalExceptionHandler { + + @ExceptionHandler(BusinessException.class) + public ResponseEntity> handleBusiness(BusinessException e) { + ErrorCode errorCode = e.getErrorCode(); + return build(errorCode.getStatus(), errorCode.getCode(), e.getMessage()); + } + + // IllegalArgumentException이 발생하면 아래 메서드를 실행하라. + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleIllegalArgument(IllegalArgumentException e) { + return build(ErrorCode.INVALID_INPUT.getStatus(), ErrorCode.INVALID_INPUT.getCode(), e.getMessage()); + } + + // IllegalStateException이 발생하면 아래 메서드를 실행하라. + @ExceptionHandler(IllegalStateException.class) + public ResponseEntity> handleIllegalState(IllegalStateException e) { + return build(ErrorCode.INVALID_STATE.getStatus(), ErrorCode.INVALID_STATE.getCode(), e.getMessage()); + } + + @ExceptionHandler({ + HttpMessageNotReadableException.class, + MethodArgumentTypeMismatchException.class, + MissingServletRequestParameterException.class}) + public ResponseEntity> handleBadRequest(Exception e) { + return build(ErrorCode.INVALID_INPUT.getStatus(), ErrorCode.INVALID_INPUT.getCode(), "요청 형식이 올바르지 않습니다."); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleException(Exception e) { + return build(ErrorCode.INTERNAL_ERROR.getStatus(), ErrorCode.INTERNAL_ERROR.getCode(), ErrorCode.INTERNAL_ERROR.getMessage()); + } + + // 예외를 HTTP 응답으로 만들어주는 공통 메서드 + private ResponseEntity> build(HttpStatus status, String code, String message) { + return ResponseEntity.status(status).body(ApiResponse.fail(new ApiResponse.ApiError(code, message))); + } +} diff --git a/common/src/main/java/com/growmighty/lectures/firstday/common/response/ApiResponse.java b/common/src/main/java/com/growmighty/lectures/firstday/common/response/ApiResponse.java new file mode 100644 index 0000000..bf2cf47 --- /dev/null +++ b/common/src/main/java/com/growmighty/lectures/firstday/common/response/ApiResponse.java @@ -0,0 +1,30 @@ +package com.growmighty.lectures.firstday.common.response; + +import com.fasterxml.jackson.annotation.JsonInclude; + +/* + Presentation 계층에서 응답 형식을 통일시키기 위해 공통 응답 객체 생성 + - 데이터 있는 성공 응답 + - 데이터 없는 성공 응답 + - 실패 응답 + - ApiError : 오류 코드와 예외 처리에서 작성한 메시지를 묶어줌. +*/ + +// null인 필드는 JSON에서 제외하라. +@JsonInclude(JsonInclude.Include.NON_NULL) +public record ApiResponse(boolean success, T data, ApiError error) { + + public static ApiResponse ok(T data) { + return new ApiResponse<>(true, data, null); + } + + public static ApiResponse ok() { + return new ApiResponse<>(true, null, null); + } + + public static ApiResponse fail(ApiError error) { + return new ApiResponse<>(false, null, error); + } + + public record ApiError(String code, String message){} +} diff --git a/order-service/build.gradle b/order-service/build.gradle new file mode 100644 index 0000000..7e618af --- /dev/null +++ b/order-service/build.gradle @@ -0,0 +1,16 @@ +plugins { + id 'org.springframework.boot' // 실행 가능한 서비스이므로 Boot 플러그인 적용 (버전은 루트에 등록됨) +} + +dependencies { + implementation project(':common') + + implementation 'org.springframework.boot:spring-boot-starter-webmvc' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-batch' + implementation 'org.springframework.boot:spring-boot-h2console' + runtimeOnly 'com.h2database:h2' + + testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test' + testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' +} diff --git a/order-service/src/main/java/com/growmighty/lectures/firstday/order/OrderServiceApplication.java b/order-service/src/main/java/com/growmighty/lectures/firstday/order/OrderServiceApplication.java new file mode 100644 index 0000000..42b0324 --- /dev/null +++ b/order-service/src/main/java/com/growmighty/lectures/firstday/order/OrderServiceApplication.java @@ -0,0 +1,17 @@ +package com.growmighty.lectures.firstday.order; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +// 각 서비스마다 자기만의 @SpringBootApplication이 필요하다. +// scanBasePackages로 자신의 패키지 아래뿐만 아닌 다른 곳도 스캔한다. +@SpringBootApplication(scanBasePackages = { + "com.growmighty.lectures.firstday.order", + "com.growmighty.lectures.firstday.common" // 요거 등록 안하면 커스텀예외가 빈으로 등록되지 않는다. +}) +public class OrderServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(OrderServiceApplication.class, args); + } +} diff --git a/order-service/src/main/java/com/growmighty/lectures/firstday/order/application/OrderApiService.java b/order-service/src/main/java/com/growmighty/lectures/firstday/order/application/OrderApiService.java new file mode 100644 index 0000000..389514a --- /dev/null +++ b/order-service/src/main/java/com/growmighty/lectures/firstday/order/application/OrderApiService.java @@ -0,0 +1,57 @@ +package com.growmighty.lectures.firstday.order.application; + +import com.growmighty.lectures.firstday.order.application.dto.OrderItemCommand; +import com.growmighty.lectures.firstday.order.application.dto.OrderLine; +import com.growmighty.lectures.firstday.order.application.dto.OrderResult; +import com.growmighty.lectures.firstday.order.application.port.PaymentPort; +import com.growmighty.lectures.firstday.order.application.port.ProductPort; +import com.growmighty.lectures.firstday.order.application.port.dto.PaymentResult; +import com.growmighty.lectures.firstday.order.application.port.dto.ProductSnapshot; +import com.growmighty.lectures.firstday.order.domain.Order; +import com.growmighty.lectures.firstday.order.domain.OrderItem; +import com.growmighty.lectures.firstday.order.domain.OrderRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; + +/*법 + - API 기법 + Order 도메인이 Product/Payment 도메인의 내부 구현에 직접 의존하지 않게 만들었다. + 원래는 OrderService가 ProductService, PaymentService를 직접 맡고 있었다. + 따라서 상대 도메인의 DTO까지 import해야 했고, 영향 받는다. + 이제 Order는 다른 도메인의 Service는 모른다. 대신 Order가 필요로 하는 기능만 interface로 모아놓은 Port에 의존한다. + DIP를 적용해 도메인 간 결합도를 낮추고 나중에 API 호출이나 MSA 분리로 확장하기 쉬운 구조로 리팩토링 되었다. +*/ + +@Service +@RequiredArgsConstructor +public class OrderApiService { + private final OrderRepository orderRepository; + private final ProductPort productPort; + private final PaymentPort paymentPort; + + public OrderResult placeOrder(OrderItemCommand command) { + List lines = command.lines(); + if (lines == null || lines.isEmpty()) { + throw new IllegalArgumentException("주문할 상품이 없습니다."); + } + + List orderItems = new ArrayList<>(); + for (OrderLine line : lines) { + ProductSnapshot product = productPort.getProduct(line.productId()); + if (!product.orderable()) { + throw new IllegalStateException("현재 구매할 수 없는 상품입니다. productId= " + product.productId()); + } + orderItems.add(OrderItem.create(product.name(), product.price(), product.productId(), line.quantity())); + productPort.decreaseStock(line.productId(), line.quantity()); + } + Order order = Order.create(command.userId(), orderItems); + + PaymentResult payment = paymentPort.pay(order.getTotalAmount().getValue()); + order.completePayment(payment.paymentId()); + + return OrderResult.from(orderRepository.save(order)); + } +} diff --git a/order-service/src/main/java/com/growmighty/lectures/firstday/order/application/dto/OrderConsistencyView.java b/order-service/src/main/java/com/growmighty/lectures/firstday/order/application/dto/OrderConsistencyView.java new file mode 100644 index 0000000..5341c80 --- /dev/null +++ b/order-service/src/main/java/com/growmighty/lectures/firstday/order/application/dto/OrderConsistencyView.java @@ -0,0 +1,13 @@ +package com.growmighty.lectures.firstday.order.application.dto; + +import java.math.BigDecimal; + +// 주문에 저장된 총액(storedTotal)과 항목들로 다시 더한 총액(recalculatedTotal) 비교 클래스 +// application의 DTO로 이동. +public record OrderConsistencyView ( + Long orderId, + BigDecimal storedTotal, + BigDecimal recalculatedTotal, + boolean consistent +) { +} diff --git a/order-service/src/main/java/com/growmighty/lectures/firstday/order/application/dto/OrderItemCommand.java b/order-service/src/main/java/com/growmighty/lectures/firstday/order/application/dto/OrderItemCommand.java new file mode 100644 index 0000000..1f3cc01 --- /dev/null +++ b/order-service/src/main/java/com/growmighty/lectures/firstday/order/application/dto/OrderItemCommand.java @@ -0,0 +1,6 @@ +package com.growmighty.lectures.firstday.order.application.dto; + +import java.util.List; + +public record OrderItemCommand(Long userId, List lines){ +} diff --git a/order-service/src/main/java/com/growmighty/lectures/firstday/order/application/dto/OrderLine.java b/order-service/src/main/java/com/growmighty/lectures/firstday/order/application/dto/OrderLine.java new file mode 100644 index 0000000..da14c9c --- /dev/null +++ b/order-service/src/main/java/com/growmighty/lectures/firstday/order/application/dto/OrderLine.java @@ -0,0 +1,4 @@ +package com.growmighty.lectures.firstday.order.application.dto; + +public record OrderLine(Long productId, int quantity) { +} diff --git a/order-service/src/main/java/com/growmighty/lectures/firstday/order/application/dto/OrderResult.java b/order-service/src/main/java/com/growmighty/lectures/firstday/order/application/dto/OrderResult.java new file mode 100644 index 0000000..a1b3b4c --- /dev/null +++ b/order-service/src/main/java/com/growmighty/lectures/firstday/order/application/dto/OrderResult.java @@ -0,0 +1,24 @@ +package com.growmighty.lectures.firstday.order.application.dto; + +import com.growmighty.lectures.firstday.order.domain.OrderStatus; +import com.growmighty.lectures.firstday.order.domain.Order; + +import java.math.BigDecimal; + +public record OrderResult( + Long id, + OrderStatus status, + BigDecimal itemsAmount, + BigDecimal shippingFee, + BigDecimal totalAmount +) { + public static OrderResult from(Order order) { + return new OrderResult( + order.getId(), + order.getStatus(), + order.getItemsAmount().getValue(), + order.getShippingFee().getValue(), + order.getTotalAmount().getValue() + ); + } +} diff --git a/order-service/src/main/java/com/growmighty/lectures/firstday/order/application/port/PaymentPort.java b/order-service/src/main/java/com/growmighty/lectures/firstday/order/application/port/PaymentPort.java new file mode 100644 index 0000000..9535e8d --- /dev/null +++ b/order-service/src/main/java/com/growmighty/lectures/firstday/order/application/port/PaymentPort.java @@ -0,0 +1,13 @@ +package com.growmighty.lectures.firstday.order.application.port; + +import com.growmighty.lectures.firstday.order.application.port.dto.PaymentResult; + +import java.math.BigDecimal; + +// API 호출 방법. Order는 PaymentService를 모른다. 대신 PaymentPort만 안다. +// API는 Facade와 달리 결합을 끊는 기술이다. DIP 구현. +public interface PaymentPort { + PaymentResult pay(BigDecimal amount); + + void cancel(Long paymentId); +} diff --git a/order-service/src/main/java/com/growmighty/lectures/firstday/order/application/port/ProductPort.java b/order-service/src/main/java/com/growmighty/lectures/firstday/order/application/port/ProductPort.java new file mode 100644 index 0000000..7ae39bc --- /dev/null +++ b/order-service/src/main/java/com/growmighty/lectures/firstday/order/application/port/ProductPort.java @@ -0,0 +1,11 @@ +package com.growmighty.lectures.firstday.order.application.port; + +import com.growmighty.lectures.firstday.order.application.port.dto.ProductSnapshot; + +public interface ProductPort { + + ProductSnapshot getProduct(Long productId); + + void decreaseStock(Long productId, int quantity); + + void restoreStock(Long productId, int quantity);} diff --git a/order-service/src/main/java/com/growmighty/lectures/firstday/order/application/port/dto/PaymentResult.java b/order-service/src/main/java/com/growmighty/lectures/firstday/order/application/port/dto/PaymentResult.java new file mode 100644 index 0000000..af1f34b --- /dev/null +++ b/order-service/src/main/java/com/growmighty/lectures/firstday/order/application/port/dto/PaymentResult.java @@ -0,0 +1,10 @@ +package com.growmighty.lectures.firstday.order.application.port.dto; + +import java.math.BigDecimal; + +public record PaymentResult( + Long paymentId, + BigDecimal amount, + String status +) { +} diff --git a/order-service/src/main/java/com/growmighty/lectures/firstday/order/application/port/dto/ProductSnapshot.java b/order-service/src/main/java/com/growmighty/lectures/firstday/order/application/port/dto/ProductSnapshot.java new file mode 100644 index 0000000..a639852 --- /dev/null +++ b/order-service/src/main/java/com/growmighty/lectures/firstday/order/application/port/dto/ProductSnapshot.java @@ -0,0 +1,12 @@ +package com.growmighty.lectures.firstday.order.application.port.dto; + +import java.math.BigDecimal; + +public record ProductSnapshot( + Long productId, + String name, + BigDecimal price, + int stockQuantity, + boolean orderable +) { +} diff --git a/order-service/src/main/java/com/growmighty/lectures/firstday/order/domain/Money.java b/order-service/src/main/java/com/growmighty/lectures/firstday/order/domain/Money.java new file mode 100644 index 0000000..427b653 --- /dev/null +++ b/order-service/src/main/java/com/growmighty/lectures/firstday/order/domain/Money.java @@ -0,0 +1,86 @@ +package com.growmighty.lectures.firstday.order.domain; + +// Money라는 값 객체(VO) 적용 +// Value Object는 값 자체가 중요한 객체이기 때문이다. +// 이 역시 Entity이기 때문에 기본적으로 정적 팩토리 메서드로 구현하는 것을 지향한다. +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.Objects; + +@Embeddable // 이 선언으로 Money는 엔티티에 포함될 수 있는 값 객체가 되었다. +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Money { + private BigDecimal value; + + // 생성자를 private로 잠근다. + private Money(BigDecimal value) { + this.value = value; + } + + // from + static 메서드를 활용한다. + // 생성 전에 예외 처리로 검증할 수 있다. 불변식을 보장하는 것이다. 생성되는 순간부터 항상 올바른 상태여야 하는 DDD의 철학. + // 정적 팩토리 메서드는 이름을 붙일 수 있다. 고로, 목적이 드러난다. + public static Money from(BigDecimal value) { + Objects.requireNonNull(value, "금액은 null일 수 없습니다."); + + if (value.compareTo(BigDecimal.ZERO) < 0) { + throw new IllegalArgumentException("금액은 0원 이상이어야 합니다. 입력값: " + value); + } + + return new Money(value); + } + + public static Money zero() { + return new Money(BigDecimal.ZERO); + } + + public Money plus(Money other) { + return new Money(this.value.add(other.value)); + } + + // 뺄셈은 음수가 나올 수 있으므로 from으로 생성. + public Money minus(Money other) { + return Money.from(this.value.subtract(other.value)); + } + + public Money times(int quantity) { + return new Money(this.value.multiply(BigDecimal.valueOf(quantity))); + } + + public Money percentage(int percent) { + BigDecimal amount = this.value + .multiply(BigDecimal.valueOf(percent)) + .divide(BigDecimal.valueOf(100), 0, RoundingMode.HALF_UP); + return new Money(amount); + } + + public boolean isGreaterThanOrEqual(Money other) { + return this.value.compareTo(other.value) >= 0; + } + + public boolean isSameAmount(Money other) { + return this.value.compareTo(other.value) == 0; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof Money other)) { + return false; + } + return this.value.compareTo(other.value) == 0; + } + + @Override + public int hashCode() { + return value.stripTrailingZeros().hashCode(); + } +} diff --git a/order-service/src/main/java/com/growmighty/lectures/firstday/order/domain/Order.java b/order-service/src/main/java/com/growmighty/lectures/firstday/order/domain/Order.java new file mode 100644 index 0000000..07f5b53 --- /dev/null +++ b/order-service/src/main/java/com/growmighty/lectures/firstday/order/domain/Order.java @@ -0,0 +1,138 @@ +package com.growmighty.lectures.firstday.order.domain; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "orders") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Order { + private static final Money FREE_SHIPPING_THRESHOLD = Money.from(BigDecimal.valueOf(50_000)); + private static final Money BASE_SHIPPING_FEE = Money.from(BigDecimal.valueOf((3_000))); + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // User를 id로만 접근 + @Column(nullable = false) + private Long userId; + + @OneToMany(mappedBy = "order", cascade = CascadeType.PERSIST) + private List items = new ArrayList<>(); + + // Payment를 id로만 접근 + @Column + private Long paymentId; + + @Embedded // VO를 적용한다. + // Money.value를 total_amount 컬럼에 매핑해준다. + @AttributeOverride( + name = "value", + column = @Column(name = "total_amount", nullable = false) + ) + private Money totalAmount; // BigDecimal에서 Money로 바꿔준다. + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "items_amount", nullable = false)) + private Money itemsAmount; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "shipping_fee", nullable = false)) + private Money shippingFee; + + // @Setter를 제거하여 status를 외부에서 아무렇게나 바꾸지 못하게 한다. + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private OrderStatus status; + + private Order(Long userId, List items) { + validateItems(items); // 생성 규칙을 캡슐화했다. + items.forEach(this::addOrderItem); + this.userId = userId; + this.status = OrderStatus.CREATED; + // 총액 계산 책임을 Service가 아닌 Order가 책임진다. + recalculateAmounts(); + } + + public static Order create(Long userId, List items) { + return new Order(userId, items); + } + + private void validateItems(List items) + { + if (items == null || items.isEmpty()) { + throw new IllegalStateException("주문할 상품이 없습니다."); + } + } + + private void addOrderItem(OrderItem item) { + this.items.add(item); + + if (item.getOrder() != this) { + item.assignOrder(this); + } + } + + private void recalculateAmounts() { + this.itemsAmount = items.stream() + .map(OrderItem::subtotal) + .reduce(Money.zero(), Money::plus); + this.shippingFee = calculateShippingFee(this.itemsAmount); + this.totalAmount = this.itemsAmount.plus(this.shippingFee); + } + + private Money calculateShippingFee(Money itemsAmount) { + return itemsAmount.isGreaterThanOrEqual(FREE_SHIPPING_THRESHOLD) + ? Money.zero() + : BASE_SHIPPING_FEE; + } + + public Money recalculatedTotal() { + Money items = this.items.stream() + .map(OrderItem::subtotal) + .reduce(Money.zero(), Money::plus); + return items.plus(calculateShippingFee(items)); + } + + // 상태 변경을 의미 있는 메서드로 제한했다. + public void completePayment(Long paymentId) { + if (this.status != OrderStatus.CREATED) { + throw new IllegalStateException("결제 가능한 상태가 아닙니다. 현재 상태: " + this.status); + } + this.status = OrderStatus.PAID; + this.paymentId = paymentId; + } + + public void cancel() { + if (this.status == OrderStatus.CANCELLED) { + throw new IllegalStateException("이미 취소된 주문입니다."); + } + this.status = OrderStatus.CANCELLED; + } + + // 다음 두 메서드는 변경 사항 감지 시 총액을 재계산하여 정합성을 유지한다. + public void changeItemPrice(Long orderItemId, BigDecimal newPrice) { + findItem(orderItemId).changePrice(newPrice); + recalculateAmounts(); + } + + public void changeItemQuantity(Long orderItemId, int newQuantity) { + findItem(orderItemId).changeQuantity(newQuantity); + recalculateAmounts(); + } + + private OrderItem findItem(Long orderItemId) { + return items.stream() + .filter(e -> e.getId().equals(orderItemId)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("주문에 없는 항목입니다.")); + } +} diff --git a/order-service/src/main/java/com/growmighty/lectures/firstday/order/domain/OrderItem.java b/order-service/src/main/java/com/growmighty/lectures/firstday/order/domain/OrderItem.java new file mode 100644 index 0000000..d2517fa --- /dev/null +++ b/order-service/src/main/java/com/growmighty/lectures/firstday/order/domain/OrderItem.java @@ -0,0 +1,75 @@ +package com.growmighty.lectures.firstday.order.domain; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +@Entity +@Table(name = "order_items") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class OrderItem { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "order_id") + private Order order; + + // name 가져온다. + @Column(nullable = false) + private String name; + + // price 가져온다. + // 역시 money와 연관되어 있으므로 VO를 이용한다. + @Embedded + @AttributeOverride( + name = "value", + column = @Column(name = "price", nullable = false) + ) + private Money price; + + // productId로 접근 + @Column(nullable = false) + private Long productId; + + @Column(nullable = false) + private Integer quantity; + + public static OrderItem create(String name, BigDecimal price, Long productId, int quantity) { + if (quantity <= 0) { + throw new IllegalArgumentException("주문 수량은 1개 이상이어야 합니다."); + } + OrderItem orderItem = new OrderItem(); + orderItem.name = name; + orderItem.price = Money.from(price); // from 메서드 활용 + orderItem.productId = productId; + orderItem.quantity = quantity; + + return orderItem; + } + + public Money subtotal() { + return price.times(quantity); + } + + // from을 이용한다. + public void changePrice(BigDecimal newPrice) { + this.price = Money.from(newPrice); + } + + public void changeQuantity(int newQuantity) { + if (newQuantity <= 0) { + throw new IllegalArgumentException("주문 수량은 1개 이상이어야 합니다."); + } + this.quantity = newQuantity; + } + + void assignOrder(Order order) { + this.order = order; + } +} diff --git a/order-service/src/main/java/com/growmighty/lectures/firstday/order/domain/OrderRepository.java b/order-service/src/main/java/com/growmighty/lectures/firstday/order/domain/OrderRepository.java new file mode 100644 index 0000000..df88c2a --- /dev/null +++ b/order-service/src/main/java/com/growmighty/lectures/firstday/order/domain/OrderRepository.java @@ -0,0 +1,17 @@ +package com.growmighty.lectures.firstday.order.domain; + +import java.util.List; +import java.util.Optional; + +public interface OrderRepository { + Order save(Order order); + + Optional findById(Long id); + + List findAll(); + + // 페이지 단위 조회하기 : 정산 데모 실험 할 때 조금씩 읽기에서 사용하려고 만듦. + List findPage(int page, int size); + + long count(); +} diff --git a/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/order/OrderStatus.java b/order-service/src/main/java/com/growmighty/lectures/firstday/order/domain/OrderStatus.java similarity index 50% rename from src/main/java/com/growmighty/lectures/firstday/tangledmonolith/order/OrderStatus.java rename to order-service/src/main/java/com/growmighty/lectures/firstday/order/domain/OrderStatus.java index add18e3..56beeec 100644 --- a/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/order/OrderStatus.java +++ b/order-service/src/main/java/com/growmighty/lectures/firstday/order/domain/OrderStatus.java @@ -1,4 +1,4 @@ -package com.growmighty.lectures.firstday.tangledmonolith.order; +package com.growmighty.lectures.firstday.order.domain; public enum OrderStatus { CREATED, diff --git a/order-service/src/main/java/com/growmighty/lectures/firstday/order/infrastructure/OrderJpaRepository.java b/order-service/src/main/java/com/growmighty/lectures/firstday/order/infrastructure/OrderJpaRepository.java new file mode 100644 index 0000000..5ee3e04 --- /dev/null +++ b/order-service/src/main/java/com/growmighty/lectures/firstday/order/infrastructure/OrderJpaRepository.java @@ -0,0 +1,8 @@ +package com.growmighty.lectures.firstday.order.infrastructure; + +// 인프라 -> 도메인 (DIP) +import com.growmighty.lectures.firstday.order.domain.Order; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface OrderJpaRepository extends JpaRepository { +} diff --git a/order-service/src/main/java/com/growmighty/lectures/firstday/order/infrastructure/OrderRepositoryImpl.java b/order-service/src/main/java/com/growmighty/lectures/firstday/order/infrastructure/OrderRepositoryImpl.java new file mode 100644 index 0000000..df9a7ad --- /dev/null +++ b/order-service/src/main/java/com/growmighty/lectures/firstday/order/infrastructure/OrderRepositoryImpl.java @@ -0,0 +1,43 @@ +package com.growmighty.lectures.firstday.order.infrastructure; + +// infrastructure에서 domain으로 참조 (DIP) +// OrderJpaRepository를 DI하는 중. +import com.growmighty.lectures.firstday.order.domain.Order; +import com.growmighty.lectures.firstday.order.domain.OrderRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class OrderRepositoryImpl implements OrderRepository { + private final OrderJpaRepository jpaRepository; + + @Override + public Order save(Order order) { + return jpaRepository.save(order); + } + + @Override + public Optional findById(Long id ) { + return jpaRepository.findById(id); + } + + @Override + public List findAll() { + return jpaRepository.findAll(); + } + + @Override + public List findPage(int page, int size) { + return jpaRepository.findAll(PageRequest.of(page, size)).getContent(); + } + + @Override + public long count() { + return jpaRepository.count(); + } +} diff --git a/order-service/src/main/java/com/growmighty/lectures/firstday/order/infrastructure/client/OrderClientConfig.java b/order-service/src/main/java/com/growmighty/lectures/firstday/order/infrastructure/client/OrderClientConfig.java new file mode 100644 index 0000000..7fc696a --- /dev/null +++ b/order-service/src/main/java/com/growmighty/lectures/firstday/order/infrastructure/client/OrderClientConfig.java @@ -0,0 +1,27 @@ +package com.growmighty.lectures.firstday.order.infrastructure.client; + + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestClient; + +/* + 모놀리식은 자기 자신을 호출했지만, 이제 상품과 결제가 다른 프로세스에 있다. + 이제 자기 자신이 아닌, 목적지별 RestClient 2개로 확장한다. + */ +@Configuration // Bean을 생성하는 클래스가 있어요! +public class OrderClientConfig { + + @Bean + RestClient productRestClient( + @Value("${order.client.product-base-url:http://localhost:8081}") String baseUrl) { + return RestClient.builder().baseUrl(baseUrl).build(); + } + + @Bean + RestClient paymentRestClient( + @Value("${order.client.payment-base-url:http://localhost:8082}") String baseUrl) { + return RestClient.builder().baseUrl(baseUrl).build(); + } +} diff --git a/order-service/src/main/java/com/growmighty/lectures/firstday/order/infrastructure/client/PaymentHttpClient.java b/order-service/src/main/java/com/growmighty/lectures/firstday/order/infrastructure/client/PaymentHttpClient.java new file mode 100644 index 0000000..c05f134 --- /dev/null +++ b/order-service/src/main/java/com/growmighty/lectures/firstday/order/infrastructure/client/PaymentHttpClient.java @@ -0,0 +1,43 @@ +package com.growmighty.lectures.firstday.order.infrastructure.client; + +import com.growmighty.lectures.firstday.order.application.port.PaymentPort; +import com.growmighty.lectures.firstday.order.application.port.dto.PaymentResult; +import com.growmighty.lectures.firstday.order.infrastructure.client.dto.ApiResponseBody; +import com.growmighty.lectures.firstday.order.infrastructure.client.dto.PayBody; +import com.growmighty.lectures.firstday.order.infrastructure.client.dto.PaymentApiData; +import lombok.RequiredArgsConstructor; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import java.math.BigDecimal; + +@Component +@RequiredArgsConstructor +public class PaymentHttpClient implements PaymentPort { + // 이 친구도 마찬가지. paymentRestClient를 주입받도록 필드명을 수정해주자. + private final RestClient paymentRestClient; + + @Override + public PaymentResult pay(BigDecimal amount) { + ApiResponseBody body = paymentRestClient.post() // POST를 보낸다. + .uri("/payments") // POST /payment + .contentType(MediaType.APPLICATION_JSON) // Content-Type: application/json + .body(new PayBody(amount)) // JSON의 Body 구성. "amount":10000 이렇게 뜰 것이다. + .retrieve() // 라는 요청을 실제로 보낸다. + .body(new ParameterizedTypeReference<>() { // 응답을 JSON에서 Java 객체로 변환한다. + }); + + PaymentApiData data = body.data(); // 변환된 Java 객체를 꺼낸다. + return new PaymentResult(data.paymentId(), data.amount(), data.status()); // 꺼낸 객체를 Order가 쓰는 객체로 변환한다. + } + + @Override + public void cancel(Long paymentId) { + paymentRestClient.post() + .uri("/payments/{paymentId}/cancel", paymentId) + .retrieve() + .toBodilessEntity(); // 응답은 필요 없다. + } +} diff --git a/order-service/src/main/java/com/growmighty/lectures/firstday/order/infrastructure/client/ProductHttpClient.java b/order-service/src/main/java/com/growmighty/lectures/firstday/order/infrastructure/client/ProductHttpClient.java new file mode 100644 index 0000000..3cbc015 --- /dev/null +++ b/order-service/src/main/java/com/growmighty/lectures/firstday/order/infrastructure/client/ProductHttpClient.java @@ -0,0 +1,60 @@ +package com.growmighty.lectures.firstday.order.infrastructure.client; + +import com.growmighty.lectures.firstday.order.application.port.ProductPort; +import com.growmighty.lectures.firstday.order.application.port.dto.ProductSnapshot; +import com.growmighty.lectures.firstday.order.infrastructure.client.dto.ApiResponseBody; +import com.growmighty.lectures.firstday.order.infrastructure.client.dto.ProductApiData; +import com.growmighty.lectures.firstday.order.infrastructure.client.dto.StockChangeBody; +import lombok.RequiredArgsConstructor; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +@Component +@RequiredArgsConstructor +public class ProductHttpClient implements ProductPort { + // productRestClient를 주입받도록 필드명 변경. + // 필드명이 빈 이름과 일치하면 스프링이 이름에 맞는 빈을 알아서 주입해 준다. + private final RestClient productRestClient; + + + @Override + public ProductSnapshot getProduct(Long productId) { + ApiResponseBody body = productRestClient.get() + .uri("/products/{productId}", productId) + .retrieve() + .body(new ParameterizedTypeReference<>() { + }); + + ProductApiData data = body.data(); + return new ProductSnapshot( + data.id(), + data.name(), + data.price(), + data.stockQuantity(), + data.orderable() + ); + } + + @Override + public void decreaseStock(Long productId, int quantity) { + productRestClient.post() + .uri("/products/{productId}/decrease-stock", productId) + .contentType(MediaType.APPLICATION_JSON) + .body(new StockChangeBody(quantity)) + .retrieve() + .toBodilessEntity(); + } + + @Override + public void restoreStock(Long productId, int quantity) { + productRestClient.post() + .uri("/products/{productId}/restore-stock", productId) + .contentType(MediaType.APPLICATION_JSON) + .body(new StockChangeBody(quantity)) + .retrieve() + .toBodilessEntity(); + } + +} diff --git a/order-service/src/main/java/com/growmighty/lectures/firstday/order/infrastructure/client/dto/ApiResponseBody.java b/order-service/src/main/java/com/growmighty/lectures/firstday/order/infrastructure/client/dto/ApiResponseBody.java new file mode 100644 index 0000000..854f9f4 --- /dev/null +++ b/order-service/src/main/java/com/growmighty/lectures/firstday/order/infrastructure/client/dto/ApiResponseBody.java @@ -0,0 +1,5 @@ +package com.growmighty.lectures.firstday.order.infrastructure.client.dto; + +public record ApiResponseBody(boolean success, T data, ErrorBody error) { + public record ErrorBody(String code, String message){} +} diff --git a/order-service/src/main/java/com/growmighty/lectures/firstday/order/infrastructure/client/dto/PayBody.java b/order-service/src/main/java/com/growmighty/lectures/firstday/order/infrastructure/client/dto/PayBody.java new file mode 100644 index 0000000..3debda4 --- /dev/null +++ b/order-service/src/main/java/com/growmighty/lectures/firstday/order/infrastructure/client/dto/PayBody.java @@ -0,0 +1,6 @@ +package com.growmighty.lectures.firstday.order.infrastructure.client.dto; + +import java.math.BigDecimal; + +public record PayBody(BigDecimal amount) { +} diff --git a/order-service/src/main/java/com/growmighty/lectures/firstday/order/infrastructure/client/dto/PaymentApiData.java b/order-service/src/main/java/com/growmighty/lectures/firstday/order/infrastructure/client/dto/PaymentApiData.java new file mode 100644 index 0000000..8d5c217 --- /dev/null +++ b/order-service/src/main/java/com/growmighty/lectures/firstday/order/infrastructure/client/dto/PaymentApiData.java @@ -0,0 +1,10 @@ +package com.growmighty.lectures.firstday.order.infrastructure.client.dto; + +import java.math.BigDecimal; + +public record PaymentApiData( + Long paymentId, + BigDecimal amount, + String status +) { +} diff --git a/order-service/src/main/java/com/growmighty/lectures/firstday/order/infrastructure/client/dto/ProductApiData.java b/order-service/src/main/java/com/growmighty/lectures/firstday/order/infrastructure/client/dto/ProductApiData.java new file mode 100644 index 0000000..b8a089a --- /dev/null +++ b/order-service/src/main/java/com/growmighty/lectures/firstday/order/infrastructure/client/dto/ProductApiData.java @@ -0,0 +1,14 @@ +package com.growmighty.lectures.firstday.order.infrastructure.client.dto; + +import java.math.BigDecimal; + +public record ProductApiData( + Long id, + Long sellerId, + String name, + BigDecimal price, + int stockQuantity, + String status, + boolean orderable +) { +} diff --git a/order-service/src/main/java/com/growmighty/lectures/firstday/order/infrastructure/client/dto/StockChangeBody.java b/order-service/src/main/java/com/growmighty/lectures/firstday/order/infrastructure/client/dto/StockChangeBody.java new file mode 100644 index 0000000..d49086d --- /dev/null +++ b/order-service/src/main/java/com/growmighty/lectures/firstday/order/infrastructure/client/dto/StockChangeBody.java @@ -0,0 +1,4 @@ +package com.growmighty.lectures.firstday.order.infrastructure.client.dto; + +public record StockChangeBody(int quantity) { +} diff --git a/order-service/src/main/java/com/growmighty/lectures/firstday/order/presentation/OrderController.java b/order-service/src/main/java/com/growmighty/lectures/firstday/order/presentation/OrderController.java new file mode 100644 index 0000000..9a4a94b --- /dev/null +++ b/order-service/src/main/java/com/growmighty/lectures/firstday/order/presentation/OrderController.java @@ -0,0 +1,33 @@ +package com.growmighty.lectures.firstday.order.presentation; + + +import com.growmighty.lectures.firstday.common.response.ApiResponse; +import com.growmighty.lectures.firstday.order.application.OrderApiService; +import com.growmighty.lectures.firstday.order.presentation.dto.OrderResponse; +import com.growmighty.lectures.firstday.order.presentation.dto.PlaceOrderRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + + +/* + OrderService, OrderFacade 삭제. + OrderService용 엔드포인트가 담긴 기존 OrderController 삭제 + 우리는 서비스끼리는 HTTP로만 대화한다. + 따라서 implementation으로 product-service를 추가하는 방법은 옳지 않다. + 여기서만 orderApiService로 대화할 것이다. +*/ +@RestController +@RequiredArgsConstructor +@RequestMapping("/orders") +public class OrderController { + + private final OrderApiService orderApiService; + + @PostMapping + public ApiResponse placeOrder(@RequestBody PlaceOrderRequest request) { + return ApiResponse.ok(OrderResponse.from(orderApiService.placeOrder(request.toCommand()))); + } +} diff --git a/order-service/src/main/java/com/growmighty/lectures/firstday/order/presentation/dto/ChangeOrderItemPriceRequest.java b/order-service/src/main/java/com/growmighty/lectures/firstday/order/presentation/dto/ChangeOrderItemPriceRequest.java new file mode 100644 index 0000000..7e7cd81 --- /dev/null +++ b/order-service/src/main/java/com/growmighty/lectures/firstday/order/presentation/dto/ChangeOrderItemPriceRequest.java @@ -0,0 +1,8 @@ +package com.growmighty.lectures.firstday.order.presentation.dto; + +import lombok.NonNull; + +import java.math.BigDecimal; + +public record ChangeOrderItemPriceRequest(@NonNull BigDecimal price) { +} diff --git a/order-service/src/main/java/com/growmighty/lectures/firstday/order/presentation/dto/ChangeOrderItemQuantityRequest.java b/order-service/src/main/java/com/growmighty/lectures/firstday/order/presentation/dto/ChangeOrderItemQuantityRequest.java new file mode 100644 index 0000000..b70bfea --- /dev/null +++ b/order-service/src/main/java/com/growmighty/lectures/firstday/order/presentation/dto/ChangeOrderItemQuantityRequest.java @@ -0,0 +1,6 @@ +package com.growmighty.lectures.firstday.order.presentation.dto; + +import lombok.NonNull; + +public record ChangeOrderItemQuantityRequest(@NonNull Integer quantity) { +} diff --git a/order-service/src/main/java/com/growmighty/lectures/firstday/order/presentation/dto/OrderConsistencyResponse.java b/order-service/src/main/java/com/growmighty/lectures/firstday/order/presentation/dto/OrderConsistencyResponse.java new file mode 100644 index 0000000..ca83e4c --- /dev/null +++ b/order-service/src/main/java/com/growmighty/lectures/firstday/order/presentation/dto/OrderConsistencyResponse.java @@ -0,0 +1,18 @@ +package com.growmighty.lectures.firstday.order.presentation.dto; + +import com.growmighty.lectures.firstday.order.application.dto.OrderConsistencyView; + +import java.math.BigDecimal; + +public record OrderConsistencyResponse( + Long orderId, + BigDecimal storedTotal, + BigDecimal recalculatedTotal, + boolean consistent +) { + public static OrderConsistencyResponse from(OrderConsistencyView view) { + return new OrderConsistencyResponse( + view.orderId(), view.storedTotal(), view.recalculatedTotal(), view.consistent() + ); + } +} diff --git a/order-service/src/main/java/com/growmighty/lectures/firstday/order/presentation/dto/OrderResponse.java b/order-service/src/main/java/com/growmighty/lectures/firstday/order/presentation/dto/OrderResponse.java new file mode 100644 index 0000000..6f5a5f0 --- /dev/null +++ b/order-service/src/main/java/com/growmighty/lectures/firstday/order/presentation/dto/OrderResponse.java @@ -0,0 +1,23 @@ +package com.growmighty.lectures.firstday.order.presentation.dto; + +import com.growmighty.lectures.firstday.order.application.dto.OrderResult; + +import java.math.BigDecimal; + +public record OrderResponse( + Long id, + String status, + BigDecimal itemsAmount, + BigDecimal shippingFee, + BigDecimal totalAmount +) { + public static OrderResponse from(OrderResult result) { + return new OrderResponse( + result.id(), + result.status().name(), + result.itemsAmount(), + result.shippingFee(), + result.totalAmount() + ); + } +} diff --git a/order-service/src/main/java/com/growmighty/lectures/firstday/order/presentation/dto/PlaceOrderFromCartRequest.java b/order-service/src/main/java/com/growmighty/lectures/firstday/order/presentation/dto/PlaceOrderFromCartRequest.java new file mode 100644 index 0000000..1384aa5 --- /dev/null +++ b/order-service/src/main/java/com/growmighty/lectures/firstday/order/presentation/dto/PlaceOrderFromCartRequest.java @@ -0,0 +1,6 @@ +package com.growmighty.lectures.firstday.order.presentation.dto; + +import lombok.NonNull; + +public record PlaceOrderFromCartRequest(@NonNull Long userId) { +} diff --git a/order-service/src/main/java/com/growmighty/lectures/firstday/order/presentation/dto/PlaceOrderRequest.java b/order-service/src/main/java/com/growmighty/lectures/firstday/order/presentation/dto/PlaceOrderRequest.java new file mode 100644 index 0000000..bc8ed49 --- /dev/null +++ b/order-service/src/main/java/com/growmighty/lectures/firstday/order/presentation/dto/PlaceOrderRequest.java @@ -0,0 +1,22 @@ +package com.growmighty.lectures.firstday.order.presentation.dto; + +import com.growmighty.lectures.firstday.order.application.dto.OrderItemCommand; +import com.growmighty.lectures.firstday.order.application.dto.OrderLine; +import lombok.NonNull; + +import java.util.List; + +public record PlaceOrderRequest( + @NonNull Long userId, + @NonNull List requests +) { + public record OrderItemRequest(@NonNull Long productId, @NonNull Integer quantity) { + } + public OrderItemCommand toCommand() { + List lines = requests.stream() + .map(r -> new OrderLine(r.productId(), r.quantity())) + .toList(); + + return new OrderItemCommand(userId, lines); + } +} diff --git a/order-service/src/main/resources/application.properties b/order-service/src/main/resources/application.properties new file mode 100644 index 0000000..15dd976 --- /dev/null +++ b/order-service/src/main/resources/application.properties @@ -0,0 +1,13 @@ +spring.application.name=order-service +server.port=8080 + +spring.datasource.url=jdbc:h2:mem:orderdb;DB_CLOSE_DELAY=-1 +spring.h2.console.enabled=true + +spring.jpa.hibernate.ddl-auto=create +spring.jpa.show-sql=true + +# 이 두 줄이 "base-url만 바꾸면 MSA"의 실체. +# 배포 환경에서는 http://product-service.internal 같은 주소로만 바꾸면 된다. +order.client.product-base-url=http://localhost:8081 +order.client.payment-base-url=http://localhost:8082 diff --git a/src/test/java/com/growmighty/lectures/firstday/tangledmonolith/OrderRepositoryTests.java b/order-service/src/test/OrderRepositoryTests.java similarity index 51% rename from src/test/java/com/growmighty/lectures/firstday/tangledmonolith/OrderRepositoryTests.java rename to order-service/src/test/OrderRepositoryTests.java index 24e91d3..39a4d08 100644 --- a/src/test/java/com/growmighty/lectures/firstday/tangledmonolith/OrderRepositoryTests.java +++ b/order-service/src/test/OrderRepositoryTests.java @@ -1,16 +1,18 @@ -package com.growmighty.lectures.firstday.tangledmonolith; - -import com.growmighty.lectures.firstday.tangledmonolith.order.Order; -import com.growmighty.lectures.firstday.tangledmonolith.order.OrderItem; -import com.growmighty.lectures.firstday.tangledmonolith.order.OrderRepository; -import com.growmighty.lectures.firstday.tangledmonolith.product.Product; -import com.growmighty.lectures.firstday.tangledmonolith.seller.Seller; -import com.growmighty.lectures.firstday.tangledmonolith.user.User; +package com.growmighty.lectures.firstday; + +import com.growmighty.lectures.firstday.order.domain.Order; +import com.growmighty.lectures.firstday.order.domain.OrderItem; +import com.growmighty.lectures.firstday.order.domain.OrderRepository; +import com.growmighty.lectures.firstday.order.infrastructure.OrderRepositoryAdapter; +import com.growmighty.lectures.firstday.product.domain.Product; +import com.growmighty.lectures.firstday.seller.domain.Seller; +import com.growmighty.lectures.firstday.user.domain.User; import jakarta.persistence.EntityManager; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.context.annotation.Import; import java.math.BigDecimal; import java.util.ArrayList; @@ -19,8 +21,8 @@ import static org.assertj.core.api.Assertions.assertThat; @DataJpaTest +@Import(OrderRepositoryAdapter.class) class OrderRepositoryTests { - @Autowired private OrderRepository orderRepository; @@ -31,15 +33,17 @@ class OrderRepositoryTests { @DisplayName("주문 저장 및 조회 테스트") void saveAndFindOrderTest() { User user = User.register("dannyseo@growmighty", "aaa33242", "Danny", "010-0000-0000"); - Seller seller = Seller.create(user); - Product product = Product.create(seller, "Dofia 이동식 접이식 식탁 의자 4개 세트 가정용 소형주택 신축식", BigDecimal.valueOf(179000), 10, "test"); entityManager.persist(user); + + Seller seller = Seller.apply(user.getId(), "테스트 셀러"); entityManager.persist(seller); + + Product product = Product.register(seller.getId(), "Dofia 이동식 접이식 식탁 의자 4개 세트 가정용 소형주택 신축식", BigDecimal.valueOf(179000), 10, "test"); entityManager.persist(product); List items = new ArrayList<>(); - items.add(OrderItem.create(product, 1)); - Order order = Order.create(user, items); + items.add(OrderItem.create(product.getName(), product.getPrice(), product.getId(), 1)); + Order order = Order.create(user.getId(), items); Order saved = orderRepository.save(order); entityManager.flush(); @@ -50,8 +54,8 @@ void saveAndFindOrderTest() { assertThat(found.getItems()).hasSize(1); OrderItem foundItem = found.getItems().get(0); assertThat(foundItem.getQuantity()).isEqualTo(1); - assertThat(foundItem.getProduct().getId()).isEqualTo(product.getId()); - assertThat(foundItem.getProduct().getName()).isEqualTo(product.getName()); - assertThat(foundItem.getProduct().getPrice()).isEqualByComparingTo(product.getPrice()); + assertThat(foundItem.getProductId()).isEqualTo(product.getId()); + assertThat(foundItem.getName()).isEqualTo(product.getName()); + assertThat(foundItem.getPrice().getValue()).isEqualByComparingTo(product.getPrice()); } } diff --git a/order-service/src/test/application/OrderServiceTest.java b/order-service/src/test/application/OrderServiceTest.java new file mode 100644 index 0000000..925c551 --- /dev/null +++ b/order-service/src/test/application/OrderServiceTest.java @@ -0,0 +1,119 @@ +package com.growmighty.lectures.firstday.order.application; + +import com.growmighty.lectures.firstday.cart.application.CartService; +import com.growmighty.lectures.firstday.common.exception.EntityNotFoundException; +import com.growmighty.lectures.firstday.order.application.dto.OrderLine; +import com.growmighty.lectures.firstday.order.application.dto.OrderResult; +import com.growmighty.lectures.firstday.order.application.dto.PlaceOrderCommand; +import com.growmighty.lectures.firstday.order.domain.Order; +import com.growmighty.lectures.firstday.order.domain.OrderItem; +import com.growmighty.lectures.firstday.order.domain.OrderRepository; +import com.growmighty.lectures.firstday.order.domain.OrderStatus; +import com.growmighty.lectures.firstday.payment.application.PaymentService; +import com.growmighty.lectures.firstday.payment.application.dto.PaymentInfo; +import com.growmighty.lectures.firstday.payment.domain.PaymentStatus; +import com.growmighty.lectures.firstday.product.application.ProductService; +import com.growmighty.lectures.firstday.product.application.dto.ProductInfo; +import com.growmighty.lectures.firstday.product.domain.ProductStatus; +import com.growmighty.lectures.firstday.user.application.UserService; +import com.growmighty.lectures.firstday.user.application.dto.UserInfo; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class OrderServiceTest { + + @Mock + private OrderRepository orderRepository; + @Mock + private UserService userService; + @Mock + private ProductService productService; + @Mock + private PaymentService paymentService; + @Mock + private CartService cartService; + + @InjectMocks + private OrderService orderService; + + @Test + @DisplayName("주문 생성: 재고 차감·결제 승인을 호출하고 결제 ID를 주문에 연결한다") + void placeOrder_orchestratesStockAndPayment() { + PlaceOrderCommand command = new PlaceOrderCommand(1L, List.of(new OrderLine(10L, 2))); + when(userService.getUser(1L)) + .thenReturn(new UserInfo(1L, "buyer@growmighty.co.kr", "구매자", "010-1111-1111")); + when(productService.getProductInfo(10L)) + .thenReturn(new ProductInfo(10L, 1L, "원목 식탁", BigDecimal.valueOf(10_000), 5, ProductStatus.ON_SALE)); + when(paymentService.pay(any())) + .thenReturn(new PaymentInfo(99L, BigDecimal.valueOf(23_000), PaymentStatus.PAID)); + when(orderRepository.save(any(Order.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + OrderResult result = orderService.placeOrder(command); + + assertThat(result.status()).isEqualTo(OrderStatus.PAID); + assertThat(result.totalAmount()).isEqualByComparingTo("23000"); + verify(productService).decreaseStock(10L, 2); + + ArgumentCaptor paidAmount = ArgumentCaptor.forClass(BigDecimal.class); + verify(paymentService).pay(paidAmount.capture()); + assertThat(paidAmount.getValue()).isEqualByComparingTo("23000"); + + ArgumentCaptor saved = ArgumentCaptor.forClass(Order.class); + verify(orderRepository).save(saved.capture()); + assertThat(saved.getValue().getPaymentId()).isEqualTo(99L); + assertThat(saved.getValue().getStatus()).isEqualTo(OrderStatus.PAID); + } + + @Test + @DisplayName("주문 생성: 라인이 비어 있으면 재고/결제를 건드리지 않고 예외가 발생한다") + void placeOrder_emptyLines_throws() { + PlaceOrderCommand command = new PlaceOrderCommand(1L, List.of()); + + assertThatThrownBy(() -> orderService.placeOrder(command)) + .isInstanceOf(IllegalArgumentException.class); + + verify(productService, never()).decreaseStock(any(), org.mockito.ArgumentMatchers.anyInt()); + verify(paymentService, never()).pay(any()); + } + + @Test + @DisplayName("주문 취소: 재고를 복원하고 결제를 취소하며 상태가 CANCELLED로 전이된다") + void cancelOrder_restoresStockAndRefunds() { + Order order = Order.create(1L, List.of(OrderItem.create("원목 식탁", BigDecimal.valueOf(10_000), 10L, 2))); + order.completePayment(99L); + when(orderRepository.findById(5L)).thenReturn(Optional.of(order)); + + OrderResult result = orderService.cancelOrder(5L); + + assertThat(result.status()).isEqualTo(OrderStatus.CANCELLED); + assertThat(order.getStatus()).isEqualTo(OrderStatus.CANCELLED); + verify(productService).restoreStock(10L, 2); + verify(paymentService).cancel(99L); + } + + @Test + @DisplayName("주문 취소: 존재하지 않는 주문이면 EntityNotFoundException이 발생한다") + void cancelOrder_notFound_throws() { + when(orderRepository.findById(404L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> orderService.cancelOrder(404L)) + .isInstanceOf(EntityNotFoundException.class); + } +} diff --git a/order-service/src/test/domain/MoneyTest.java b/order-service/src/test/domain/MoneyTest.java new file mode 100644 index 0000000..9c07405 --- /dev/null +++ b/order-service/src/test/domain/MoneyTest.java @@ -0,0 +1,79 @@ +package com.growmighty.lectures.firstday.order.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class MoneyTest { + + @Test + @DisplayName("음수 금액으로 생성하면 예외가 발생한다") + void from_negative_throws() { + assertThatThrownBy(() -> Money.from(new BigDecimal("-1"))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("null 금액으로 생성하면 예외가 발생한다") + void from_null_throws() { + assertThatThrownBy(() -> Money.from(null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + @DisplayName("더하기/빼기/곱하기/퍼센트 계산이 올바르다") + void arithmetic() { + Money ten = Money.from(BigDecimal.valueOf(10_000)); + + assertThat(ten.plus(Money.from(BigDecimal.valueOf(5_000))).getValue()) + .isEqualByComparingTo("15000"); + assertThat(ten.minus(Money.from(BigDecimal.valueOf(4_000))).getValue()) + .isEqualByComparingTo("6000"); + assertThat(ten.times(3).getValue()).isEqualByComparingTo("30000"); + assertThat(ten.percentage(10).getValue()).isEqualByComparingTo("1000"); + } + + @Test + @DisplayName("빼기 결과가 음수가 되면 예외가 발생한다") + void minus_belowZero_throws() { + Money small = Money.from(BigDecimal.valueOf(1_000)); + assertThatThrownBy(() -> small.minus(Money.from(BigDecimal.valueOf(2_000)))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("크기 비교가 올바르다") + void comparison() { + Money fifty = Money.from(BigDecimal.valueOf(50_000)); + assertThat(fifty.isGreaterThanOrEqual(Money.from(BigDecimal.valueOf(50_000)))).isTrue(); + assertThat(fifty.isGreaterThanOrEqual(Money.from(BigDecimal.valueOf(49_999)))).isTrue(); + assertThat(fifty.isGreaterThanOrEqual(Money.from(BigDecimal.valueOf(50_001)))).isFalse(); + } + + @Nested + @DisplayName("값 객체 동등성") + class Equality { + + @Test + @DisplayName("scale이 달라도 금액이 같으면 동등하고 hashCode도 같다") + void equals_ignoresScale() { + Money a = Money.from(new BigDecimal("50000")); + Money b = Money.from(new BigDecimal("50000.00")); + + assertThat(a).isEqualTo(b); + assertThat(a).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("금액이 다르면 동등하지 않다") + void notEquals_whenDifferent() { + assertThat(Money.from(BigDecimal.valueOf(100))) + .isNotEqualTo(Money.from(BigDecimal.valueOf(200))); + } + } +} diff --git a/order-service/src/test/domain/OrderTest.java b/order-service/src/test/domain/OrderTest.java new file mode 100644 index 0000000..b4892ef --- /dev/null +++ b/order-service/src/test/domain/OrderTest.java @@ -0,0 +1,101 @@ +package com.growmighty.lectures.firstday.order.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class OrderTest { + + private OrderItem item(long productId, String price, int quantity) { + return OrderItem.create("상품-" + productId, new BigDecimal(price), productId, quantity); + } + + @Test + @DisplayName("주문 생성 시 항목 합계와 총액을 스스로 계산한다") + void create_calculatesAmounts() { + Order order = Order.create(1L, List.of(item(1L, "10000", 2))); + + assertThat(order.getItemsAmount().getValue()).isEqualByComparingTo("20000"); + assertThat(order.getTotalAmount().getValue()).isEqualByComparingTo("23000"); + assertThat(order.getStatus()).isEqualTo(OrderStatus.CREATED); + } + + @Test + @DisplayName("상품 합계가 무료배송 기준(50000) 미만이면 배송비 3000원이 붙는다") + void shippingFee_charged_belowThreshold() { + Order order = Order.create(1L, List.of(item(1L, "10000", 1))); + + assertThat(order.getShippingFee().getValue()).isEqualByComparingTo("3000"); + assertThat(order.getTotalAmount().getValue()).isEqualByComparingTo("13000"); + } + + @Test + @DisplayName("상품 합계가 무료배송 기준 이상이면 배송비가 0원이다") + void shippingFee_free_atOrAboveThreshold() { + Order order = Order.create(1L, List.of(item(1L, "50000", 1))); + + assertThat(order.getShippingFee().getValue()).isEqualByComparingTo("0"); + assertThat(order.getTotalAmount().getValue()).isEqualByComparingTo("50000"); + } + + @Test + @DisplayName("주문 항목이 없으면 생성할 수 없다") + void create_withoutItems_throws() { + assertThatThrownBy(() -> Order.create(1L, List.of())) + .isInstanceOf(IllegalStateException.class); + } + + @Test + @DisplayName("결제 완료 시 상태가 PAID로 전이되고 결제 ID가 연결된다") + void completePayment_transitions() { + Order order = Order.create(1L, List.of(item(1L, "10000", 1))); + + order.completePayment(99L); + + assertThat(order.getStatus()).isEqualTo(OrderStatus.PAID); + assertThat(order.getPaymentId()).isEqualTo(99L); + } + + @Test + @DisplayName("CREATED가 아닌 상태에서 결제 완료를 호출하면 예외가 발생한다") + void completePayment_whenNotCreated_throws() { + Order order = Order.create(1L, List.of(item(1L, "10000", 1))); + order.completePayment(99L); + + assertThatThrownBy(() -> order.completePayment(100L)) + .isInstanceOf(IllegalStateException.class); + } + + @Test + @DisplayName("주문을 취소하면 상태가 CANCELLED로 전이된다") + void cancel_transitions() { + Order order = Order.create(1L, List.of(item(1L, "10000", 1))); + + order.cancel(); + + assertThat(order.getStatus()).isEqualTo(OrderStatus.CANCELLED); + } + + @Test + @DisplayName("이미 취소된 주문을 다시 취소하면 예외가 발생한다") + void cancel_twice_throws() { + Order order = Order.create(1L, List.of(item(1L, "10000", 1))); + order.cancel(); + + assertThatThrownBy(order::cancel) + .isInstanceOf(IllegalStateException.class); + } + + @Test + @DisplayName("재계산 총액은 저장된 총액과 항상 일치한다") + void recalculatedTotal_matchesStored() { + Order order = Order.create(1L, List.of(item(1L, "10000", 2), item(2L, "5000", 1))); + + assertThat(order.recalculatedTotal().isSameAmount(order.getTotalAmount())).isTrue(); + } +} diff --git a/order-service/src/test/presentation/OrderControllerTest.java b/order-service/src/test/presentation/OrderControllerTest.java new file mode 100644 index 0000000..2a2f7bd --- /dev/null +++ b/order-service/src/test/presentation/OrderControllerTest.java @@ -0,0 +1,85 @@ +package com.growmighty.lectures.firstday.order.presentation; + +import com.growmighty.lectures.firstday.common.exception.EntityNotFoundException; +import com.growmighty.lectures.firstday.order.application.OrderService; +import com.growmighty.lectures.firstday.order.application.dto.OrderResult; +import com.growmighty.lectures.firstday.order.domain.OrderStatus; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.math.BigDecimal; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(OrderController.class) +class OrderControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private OrderService orderService; + + @Test + @DisplayName("주문 생성 성공 시 success=true 와 data를 감싼 응답을 반환한다") + void placeOrder_success_envelope() throws Exception { + when(orderService.placeOrder(any())).thenReturn(new OrderResult( + 1L, OrderStatus.PAID, BigDecimal.valueOf(20_000), BigDecimal.ZERO, BigDecimal.valueOf(20_000))); + + mockMvc.perform(post("/orders") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"userId":1,"requests":[{"productId":1,"quantity":1}]} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.id").value(1)) + .andExpect(jsonPath("$.data.status").value("PAID")) + .andExpect(jsonPath("$.error").doesNotExist()); + } + + @Test + @DisplayName("존재하지 않는 주문 조회는 404와 ENTITY_NOT_FOUND 코드를 반환한다") + void inspectOrder_notFound_404() throws Exception { + when(orderService.inspectOrder(999L)) + .thenThrow(new EntityNotFoundException("존재하지 않는 주문입니다. orderId=999")); + + mockMvc.perform(get("/orders/999/inspect")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.error.code").value("C003")) + .andExpect(jsonPath("$.data").doesNotExist()); + } + + @Test + @DisplayName("비즈니스 상태 위반(이미 취소)은 409와 INVALID_STATE 코드를 반환한다") + void cancelOrder_conflict_409() throws Exception { + when(orderService.cancelOrder(1L)) + .thenThrow(new IllegalStateException("이미 취소된 주문입니다.")); + + mockMvc.perform(post("/orders/1/cancel")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.error.code").value("C002")); + } + + @Test + @DisplayName("잘못된 JSON 본문은 400과 INVALID_INPUT 코드를 반환한다") + void placeOrder_malformedBody_400() throws Exception { + mockMvc.perform(post("/orders") + .contentType(MediaType.APPLICATION_JSON) + .content("{")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error.code").value("C001")); + } +} diff --git a/orders.http b/orders.http index 46223b8..21a3aee 100644 --- a/orders.http +++ b/orders.http @@ -1,10 +1,40 @@ -### 주문 생성 -POST http://localhost:8080/orders -Content-Type: application/json +############################################### +# 정산 배치 세션 - 실습용 요청 모음 +# +# 사전 준비: 대용량 시드와 함께 실행 +# ./gradlew bootRun --args='--settlement.seed.enabled=true --settlement.seed.count=1000000 --spring.jpa.show-sql=false --logging.level.org.hibernate.orm.jdbc.bind=off' +# +# OOM 데모는 힙을 좁혀야 잘 터진다. main() 을 Run/Profile 로 띄우고 VM options 에: +# -Xmx1g -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=./oom.hprof -Xlog:gc*:file=gc.log:tags,uptime +############################################### -{ - "userId": 1 -} +### [상태 확인] 주문/정산 건수 + 현재 힙 사용량 (used/max MB) +GET http://localhost:8080/settlements/status -### 전체 주문 목록 조회 -GET http://localhost:8080/orders +### [데모2] "절벽으로 걸어가기" — limit 만큼만 메모리에 쌓으며 정산 +# limit 을 100000 → 300000 → 500000 으로 올려가며 elapsedMs / peakHeapMb 가 +# 어떻게 증가하는지 추세를 보여준다. 콘솔의 [mem:naive-climb] 막대도 같이 본다. +POST http://localhost:8080/settlements/naive?limit=100000 + +### [데모2-반복] 정산 비우고 limit 키우기 (먼저 DELETE 후 재실행) +POST http://localhost:8080/settlements/naive?limit=300000 + +### [데모1] "한 줄의 함정" — findAll 전량 적재 → 대용량(100만)에서 즉시 OOM +# 콘솔 [mem:naive-findAll] 막대가 max 에 붙는 순간 OutOfMemoryError + oom.hprof 생성 +POST http://localhost:8080/settlements/naive + +### 정산 결과 비우기 (데모 반복용) +DELETE http://localhost:8080/settlements + +############################################### +# 아래는 오전/오후 실습에서 Spring Batch Job 을 추가하면 사용 +############################################### + +### [실습] 정산 배치 Job 실행 (Chunk 지향 처리 - 메모리 일정하게 유지) +# POST http://localhost:8080/settlements/batch + +### [실습] 50% 지점 강제 실패 후 재시작 (Restartability) +# POST http://localhost:8080/settlements/batch?failAt=500000 + +### H2 콘솔 (시드/정산 결과를 직접 SQL 로 확인) — JDBC URL: jdbc:h2:mem:tangled +GET http://localhost:8080/h2-console diff --git a/payment-service/build.gradle b/payment-service/build.gradle new file mode 100644 index 0000000..f5dfc5d --- /dev/null +++ b/payment-service/build.gradle @@ -0,0 +1,15 @@ +plugins { + id 'org.springframework.boot' // 실행 가능한 서비스이므로 Boot 플러그인 적용 (버전은 루트에 등록됨) +} + +dependencies { + implementation project(':common') + + implementation 'org.springframework.boot:spring-boot-starter-webmvc' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-h2console' + runtimeOnly 'com.h2database:h2' + + testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test' + testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' +} diff --git a/payment-service/src/main/java/com/growmighty/lectures/firstday/payment/PaymentServiceApplication.java b/payment-service/src/main/java/com/growmighty/lectures/firstday/payment/PaymentServiceApplication.java new file mode 100644 index 0000000..6374efb --- /dev/null +++ b/payment-service/src/main/java/com/growmighty/lectures/firstday/payment/PaymentServiceApplication.java @@ -0,0 +1,17 @@ +package com.growmighty.lectures.firstday.payment; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +// 각 서비스마다 자기만의 @SpringBootApplication이 필요하다. +// scanBasePackages로 자신의 패키지 아래뿐만 아닌 다른 곳도 스캔한다. +@SpringBootApplication(scanBasePackages = { + "com.growmighty.lectures.firstday.payment", + "com.growmighty.lectures.firstday.common" // 요거 등록 안하면 커스텀예외가 빈으로 등록되지 않는다. +}) +public class PaymentServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(PaymentServiceApplication.class, args); + } +} diff --git a/payment-service/src/main/java/com/growmighty/lectures/firstday/payment/application/PaymentGateway.java b/payment-service/src/main/java/com/growmighty/lectures/firstday/payment/application/PaymentGateway.java new file mode 100644 index 0000000..1990929 --- /dev/null +++ b/payment-service/src/main/java/com/growmighty/lectures/firstday/payment/application/PaymentGateway.java @@ -0,0 +1,12 @@ +package com.growmighty.lectures.firstday.payment.application; + +import java.math.BigDecimal; + +public interface PaymentGateway { + PgApproval approve(BigDecimal amount); + + void cancel(String pgTransactional); + + record PgApproval(String transactionId){ + } +} diff --git a/payment-service/src/main/java/com/growmighty/lectures/firstday/payment/application/PaymentService.java b/payment-service/src/main/java/com/growmighty/lectures/firstday/payment/application/PaymentService.java new file mode 100644 index 0000000..aa7c782 --- /dev/null +++ b/payment-service/src/main/java/com/growmighty/lectures/firstday/payment/application/PaymentService.java @@ -0,0 +1,50 @@ +package com.growmighty.lectures.firstday.payment.application; + +import com.growmighty.lectures.firstday.common.exception.EntityNotFoundException; +import com.growmighty.lectures.firstday.payment.application.dto.PaymentInfo; +import com.growmighty.lectures.firstday.payment.domain.Payment; +import com.growmighty.lectures.firstday.payment.domain.PaymentRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; + +// Repo와 소통하는 기능은 Service 계층으로 +@Service +@RequiredArgsConstructor +public class PaymentService { + private final PaymentRepository paymentRepository; + private final PaymentGateway paymentGateway; + + // Transactional!!! + @Transactional + public PaymentInfo pay(BigDecimal amount) { + Payment payment = Payment.ready(amount); + try { + PaymentGateway.PgApproval approval = paymentGateway.approve(amount); + payment.approve(approval.transactionId()); + } catch (RuntimeException e) { + payment.fail(); + paymentRepository.save(payment); + throw new IllegalStateException("결제 승인에 실패했습니다. amount= " + amount, e); + } + return PaymentInfo.from(paymentRepository.save(payment)); + } + + @Transactional + public PaymentInfo cancel(Long paymentId) { + Payment payment = paymentRepository.findById(paymentId) + .orElseThrow(() -> new EntityNotFoundException("존재하지 않는 결제입니다. paymentId= " + paymentId)); + paymentGateway.cancel(payment.getPgTransactionId()); + payment.cancel(); + return PaymentInfo.from(paymentRepository.save(payment)); + } + + @Transactional(readOnly = true) + public PaymentInfo getPayment(Long paymentId) { + Payment payment = paymentRepository.findById(paymentId) + .orElseThrow(() -> new EntityNotFoundException("존재하지 않는 결제입니다. paymentId= " + paymentId)); + return PaymentInfo.from(payment); + } +} diff --git a/payment-service/src/main/java/com/growmighty/lectures/firstday/payment/application/dto/PaymentInfo.java b/payment-service/src/main/java/com/growmighty/lectures/firstday/payment/application/dto/PaymentInfo.java new file mode 100644 index 0000000..f6f00d8 --- /dev/null +++ b/payment-service/src/main/java/com/growmighty/lectures/firstday/payment/application/dto/PaymentInfo.java @@ -0,0 +1,16 @@ +package com.growmighty.lectures.firstday.payment.application.dto; + +import com.growmighty.lectures.firstday.payment.domain.Payment; +import com.growmighty.lectures.firstday.payment.domain.PaymentStatus; + +import java.math.BigDecimal; + +public record PaymentInfo( + Long paymentId, + BigDecimal amount, + PaymentStatus status +) { + public static PaymentInfo from(Payment payment) { + return new PaymentInfo(payment.getId(), payment.getAmount(), payment.getStatus()); + } +} diff --git a/payment-service/src/main/java/com/growmighty/lectures/firstday/payment/domain/Payment.java b/payment-service/src/main/java/com/growmighty/lectures/firstday/payment/domain/Payment.java new file mode 100644 index 0000000..85cd504 --- /dev/null +++ b/payment-service/src/main/java/com/growmighty/lectures/firstday/payment/domain/Payment.java @@ -0,0 +1,70 @@ +package com.growmighty.lectures.firstday.payment.domain; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.math.BigDecimal; + +@Entity +@Table(name = "payments") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Payment { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private BigDecimal amount; + + @Setter + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private PaymentStatus status; + + @Column + private String pgTransactionId; + + // 팩토리 메서드 + private Payment(BigDecimal amount) { + if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { + throw new IllegalArgumentException("결제 금액은 0원보다 커야 합니다. 입력값: " + amount); + } + this.amount = amount; + this.status = PaymentStatus.READY; + } + + public static Payment ready(BigDecimal amount) { + return new Payment(amount); + } + + public void approve(String pgTransactionId) { + if (this.status != PaymentStatus.READY) { + throw new IllegalStateException("승인 대기(READY) 상태에서만 승인할 수 있습니다. 현재 상태: " + this.status); + } + this.pgTransactionId = pgTransactionId; + this.status = PaymentStatus.PAID; + } + + public void fail() { + if (this.status != PaymentStatus.READY) { + throw new IllegalStateException("승인 대기(READY) 상태에서만 실패 처리할 수 있습니다. 현재 상태: " + this.status); + } + this.status = PaymentStatus.FAILED; + } + + public void cancel() { + if (this.status != PaymentStatus.PAID) { + throw new IllegalStateException("결제 완료(PAID) 상태에서만 취소할 수 있습니다. 현재 상태: " + this.status); + } + this.status = PaymentStatus.CANCELLED; + } + + public boolean isPaid() { + return this.status == PaymentStatus.PAID; + } +} + diff --git a/payment-service/src/main/java/com/growmighty/lectures/firstday/payment/domain/PaymentRepository.java b/payment-service/src/main/java/com/growmighty/lectures/firstday/payment/domain/PaymentRepository.java new file mode 100644 index 0000000..73fd4d6 --- /dev/null +++ b/payment-service/src/main/java/com/growmighty/lectures/firstday/payment/domain/PaymentRepository.java @@ -0,0 +1,10 @@ +package com.growmighty.lectures.firstday.payment.domain; + +import java.util.Optional; + +// DIP 구현! +public interface PaymentRepository { + Payment save(Payment payment); + + Optional findById(Long id); +} diff --git a/payment-service/src/main/java/com/growmighty/lectures/firstday/payment/domain/PaymentStatus.java b/payment-service/src/main/java/com/growmighty/lectures/firstday/payment/domain/PaymentStatus.java new file mode 100644 index 0000000..e8fe5f4 --- /dev/null +++ b/payment-service/src/main/java/com/growmighty/lectures/firstday/payment/domain/PaymentStatus.java @@ -0,0 +1,8 @@ +package com.growmighty.lectures.firstday.payment.domain; + +public enum PaymentStatus { + READY, + PAID, + FAILED, + CANCELLED +} diff --git a/payment-service/src/main/java/com/growmighty/lectures/firstday/payment/infrastructure/FakePaymentGateway.java b/payment-service/src/main/java/com/growmighty/lectures/firstday/payment/infrastructure/FakePaymentGateway.java new file mode 100644 index 0000000..5c9acb3 --- /dev/null +++ b/payment-service/src/main/java/com/growmighty/lectures/firstday/payment/infrastructure/FakePaymentGateway.java @@ -0,0 +1,24 @@ +package com.growmighty.lectures.firstday.payment.infrastructure; + +import com.growmighty.lectures.firstday.payment.application.PaymentGateway; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.util.concurrent.atomic.AtomicLong; + +// Bean 생성 +@Component +public class FakePaymentGateway implements PaymentGateway { + private final AtomicLong sequence = new AtomicLong(1); + + + @Override + public PgApproval approve(BigDecimal amount) { + String transactionId = "PG-" + sequence.getAndIncrement(); + return new PgApproval(transactionId); + } + + @Override + public void cancel(String pgTransactional) { + } +} diff --git a/payment-service/src/main/java/com/growmighty/lectures/firstday/payment/infrastructure/PaymentJpaRepository.java b/payment-service/src/main/java/com/growmighty/lectures/firstday/payment/infrastructure/PaymentJpaRepository.java new file mode 100644 index 0000000..01f15c8 --- /dev/null +++ b/payment-service/src/main/java/com/growmighty/lectures/firstday/payment/infrastructure/PaymentJpaRepository.java @@ -0,0 +1,7 @@ +package com.growmighty.lectures.firstday.payment.infrastructure; + +import com.growmighty.lectures.firstday.payment.domain.Payment; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PaymentJpaRepository extends JpaRepository { +} diff --git a/payment-service/src/main/java/com/growmighty/lectures/firstday/payment/infrastructure/PaymentRepositoryAdapter.java b/payment-service/src/main/java/com/growmighty/lectures/firstday/payment/infrastructure/PaymentRepositoryAdapter.java new file mode 100644 index 0000000..9c7e83a --- /dev/null +++ b/payment-service/src/main/java/com/growmighty/lectures/firstday/payment/infrastructure/PaymentRepositoryAdapter.java @@ -0,0 +1,24 @@ +package com.growmighty.lectures.firstday.payment.infrastructure; + +import com.growmighty.lectures.firstday.payment.domain.Payment; +import com.growmighty.lectures.firstday.payment.domain.PaymentRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class PaymentRepositoryAdapter implements PaymentRepository { + private final PaymentJpaRepository jpaRepository; + + @Override + public Payment save(Payment payment) { + return jpaRepository.save(payment); + } + + @Override + public Optional findById(Long id) { + return jpaRepository.findById(id); + } +} diff --git a/payment-service/src/main/java/com/growmighty/lectures/firstday/payment/presentation/PaymentController.java b/payment-service/src/main/java/com/growmighty/lectures/firstday/payment/presentation/PaymentController.java new file mode 100644 index 0000000..e90d3fd --- /dev/null +++ b/payment-service/src/main/java/com/growmighty/lectures/firstday/payment/presentation/PaymentController.java @@ -0,0 +1,30 @@ +package com.growmighty.lectures.firstday.payment.presentation; + +import com.growmighty.lectures.firstday.common.response.ApiResponse; +import com.growmighty.lectures.firstday.payment.application.PaymentService; +import com.growmighty.lectures.firstday.payment.presentation.dto.PayRequest; +import com.growmighty.lectures.firstday.payment.presentation.dto.PaymentResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/payments") +public class PaymentController { + private final PaymentService paymentService; + + @PostMapping + public ApiResponse pay(@RequestBody PayRequest request) { + return ApiResponse.ok(PaymentResponse.from(paymentService.pay(request.amount()))); + } + + @GetMapping("/{paymentId}") + public ApiResponse getPayment(@PathVariable Long paymentId) { + return ApiResponse.ok(PaymentResponse.from(paymentService.getPayment(paymentId))); + } + + @PostMapping("/{paymentId}/cancel") + public ApiResponse cancel(@PathVariable Long paymentId) { + return ApiResponse.ok(PaymentResponse.from(paymentService.cancel(paymentId))); + } +} diff --git a/payment-service/src/main/java/com/growmighty/lectures/firstday/payment/presentation/dto/PayRequest.java b/payment-service/src/main/java/com/growmighty/lectures/firstday/payment/presentation/dto/PayRequest.java new file mode 100644 index 0000000..a3bc6bf --- /dev/null +++ b/payment-service/src/main/java/com/growmighty/lectures/firstday/payment/presentation/dto/PayRequest.java @@ -0,0 +1,8 @@ +package com.growmighty.lectures.firstday.payment.presentation.dto; + +import lombok.NonNull; + +import java.math.BigDecimal; + +public record PayRequest(@NonNull BigDecimal amount) { +} diff --git a/payment-service/src/main/java/com/growmighty/lectures/firstday/payment/presentation/dto/PaymentResponse.java b/payment-service/src/main/java/com/growmighty/lectures/firstday/payment/presentation/dto/PaymentResponse.java new file mode 100644 index 0000000..8e18bfb --- /dev/null +++ b/payment-service/src/main/java/com/growmighty/lectures/firstday/payment/presentation/dto/PaymentResponse.java @@ -0,0 +1,15 @@ +package com.growmighty.lectures.firstday.payment.presentation.dto; + +import com.growmighty.lectures.firstday.payment.application.dto.PaymentInfo; + +import java.math.BigDecimal; + +public record PaymentResponse( + Long paymentId, + BigDecimal amount, + String status +) { + public static PaymentResponse from(PaymentInfo info) { + return new PaymentResponse(info.paymentId(), info.amount(), info.status().name()); + } +} diff --git a/payment-service/src/main/resources/application.properties b/payment-service/src/main/resources/application.properties new file mode 100644 index 0000000..2a638ae --- /dev/null +++ b/payment-service/src/main/resources/application.properties @@ -0,0 +1,8 @@ +spring.application.name=payment-service +server.port=8082 + +spring.datasource.url=jdbc:h2:mem:paymentdb;DB_CLOSE_DELAY=-1 +spring.h2.console.enabled=true + +spring.jpa.hibernate.ddl-auto=create +spring.jpa.show-sql=true diff --git a/payment-service/src/test/PaymentTest.java b/payment-service/src/test/PaymentTest.java new file mode 100644 index 0000000..03c871e --- /dev/null +++ b/payment-service/src/test/PaymentTest.java @@ -0,0 +1,65 @@ +package com.growmighty.lectures.firstday.domain; + +import com.growmighty.lectures.firstday.payment.domain.Payment; +import com.growmighty.lectures.firstday.payment.domain.PaymentStatus; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PaymentTest { + + @Test + @DisplayName("0 이하 금액으로는 결제를 생성할 수 없다") + void ready_invalidAmount_throws() { + assertThatThrownBy(() -> Payment.ready(BigDecimal.ZERO)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("승인하면 PAID로 전이되고 거래번호가 저장된다") + void approve_transitions() { + Payment payment = Payment.ready(BigDecimal.valueOf(10000)); + + payment.approve("PG-1"); + + assertThat(payment.getStatus()).isEqualTo(PaymentStatus.PAID); + assertThat(payment.getPgTransactionId()).isEqualTo("PG-1"); + assertThat(payment.isPaid()).isTrue(); + } + + @Test + @DisplayName("이미 승인된 결제를 다시 승인하면 예외가 발생한다") + void approve_twice_throws() { + Payment payment = Payment.ready(BigDecimal.valueOf(10000)); + payment.approve("PG-1"); + + assertThatThrownBy(() -> payment.approve("PG-2")) + .isInstanceOf(IllegalStateException.class); + } + + @Test + @DisplayName("결제 완료 상태에서만 취소할 수 있다") + void cancel_onlyFromPaid() { + Payment paid = Payment.ready(BigDecimal.valueOf(10000)); + paid.approve("PG-1"); + paid.cancel(); + assertThat(paid.getStatus()).isEqualTo(PaymentStatus.CANCELLED); + + Payment ready = Payment.ready(BigDecimal.valueOf(10000)); + assertThatThrownBy(ready::cancel).isInstanceOf(IllegalStateException.class); + } + + @Test + @DisplayName("승인 대기 상태에서 실패 처리하면 FAILED로 전이된다") + void fail_transitions() { + Payment payment = Payment.ready(BigDecimal.valueOf(10000)); + + payment.fail(); + + assertThat(payment.getStatus()).isEqualTo(PaymentStatus.FAILED); + } +} diff --git a/product-service/build.gradle b/product-service/build.gradle new file mode 100644 index 0000000..f5dfc5d --- /dev/null +++ b/product-service/build.gradle @@ -0,0 +1,15 @@ +plugins { + id 'org.springframework.boot' // 실행 가능한 서비스이므로 Boot 플러그인 적용 (버전은 루트에 등록됨) +} + +dependencies { + implementation project(':common') + + implementation 'org.springframework.boot:spring-boot-starter-webmvc' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-h2console' + runtimeOnly 'com.h2database:h2' + + testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test' + testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' +} diff --git a/product-service/src/main/java/com/growmighty/lectures/firstday/product/ProductDataInitializer.java b/product-service/src/main/java/com/growmighty/lectures/firstday/product/ProductDataInitializer.java new file mode 100644 index 0000000..c8c1c9a --- /dev/null +++ b/product-service/src/main/java/com/growmighty/lectures/firstday/product/ProductDataInitializer.java @@ -0,0 +1,26 @@ +package com.growmighty.lectures.firstday.product; + +import com.growmighty.lectures.firstday.product.application.ProductService; +import com.growmighty.lectures.firstday.product.application.dto.RegisterProductCommand; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; + +@Component +@Profile("!test") +@RequiredArgsConstructor +// 이제 각 서비스마다 자신만의 시드 데이터를 가진다. +public class ProductDataInitializer implements CommandLineRunner { + + private final ProductService productService; + + @Override + public void run(String... args) { + // sellerId는 이제 다른 서비스의 식별자다. 임의 값을 사용해도 된다. + productService.register(new RegisterProductCommand(1L, "청축 키보드", BigDecimal.valueOf(120_000), 10, "설명: 청축 키보드")); + productService.register(new RegisterProductCommand(1L, "무선 마우스", BigDecimal.valueOf(45_000), 20, "설명: 무선 마우스")); + } +} diff --git a/product-service/src/main/java/com/growmighty/lectures/firstday/product/ProductServiceApplication.java b/product-service/src/main/java/com/growmighty/lectures/firstday/product/ProductServiceApplication.java new file mode 100644 index 0000000..7497d6b --- /dev/null +++ b/product-service/src/main/java/com/growmighty/lectures/firstday/product/ProductServiceApplication.java @@ -0,0 +1,17 @@ +package com.growmighty.lectures.firstday.product; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +// 각 서비스마다 자기만의 @SpringBootApplication이 필요하다. +// scanBasePackages로 자신의 패키지 아래뿐만 아닌 다른 곳도 스캔한다. +@SpringBootApplication(scanBasePackages = { + "com.growmighty.lectures.firstday.product", + "com.growmighty.lectures.firstday.common" // 요거 등록 안하면 커스텀예외가 빈으로 등록되지 않는다. +}) +public class ProductServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(ProductServiceApplication.class, args); + } +} diff --git a/product-service/src/main/java/com/growmighty/lectures/firstday/product/application/ProductService.java b/product-service/src/main/java/com/growmighty/lectures/firstday/product/application/ProductService.java new file mode 100644 index 0000000..754281c --- /dev/null +++ b/product-service/src/main/java/com/growmighty/lectures/firstday/product/application/ProductService.java @@ -0,0 +1,57 @@ +package com.growmighty.lectures.firstday.product.application; + +import com.growmighty.lectures.firstday.common.exception.EntityNotFoundException; +import com.growmighty.lectures.firstday.product.application.dto.ProductInfo; +import com.growmighty.lectures.firstday.product.application.dto.RegisterProductCommand; +import com.growmighty.lectures.firstday.product.domain.Product; +import com.growmighty.lectures.firstday.product.domain.ProductRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; + +// Repository를 사용해서 UseCase를 조율하고 있으므로 +// Entity에 로직을 넣는 것이 아니라 Service 계층으로 따로 빼 준다. +@Service +@RequiredArgsConstructor +public class ProductService { + private final ProductRepository productRepository; + // Seller와 관련된 것들은 이제 여기서 안 한다. + + @Transactional + public ProductInfo register(RegisterProductCommand command) { + Product product = Product.register( + command.sellerId(), command.name(), command.price(), command.stockQuantity(), command.description() + ); + + return ProductInfo.from(productRepository.save(product)); + } + + @Transactional + public ProductInfo changePrice(Long productId, BigDecimal newPrice) { + Product product = getProductEntity(productId); + product.changePrice(newPrice); + return ProductInfo.from(product); + } + + @Transactional + public void decreaseStock(Long productId, int quantity) { + getProductEntity(productId).decreaseStock(quantity); + } + + @Transactional + public void restoreStock(Long productId, int quantity) { + getProductEntity(productId).restoreStock(quantity); + } + + @Transactional(readOnly = true) + public ProductInfo getProductInfo(Long productId) { + return ProductInfo.from(getProductEntity(productId)); + } + + private Product getProductEntity(Long productId) { + return productRepository.findById(productId) + .orElseThrow(() -> new EntityNotFoundException("존재하지 않는 상품입니다. productId=" + productId)); + } +} diff --git a/product-service/src/main/java/com/growmighty/lectures/firstday/product/application/dto/ProductInfo.java b/product-service/src/main/java/com/growmighty/lectures/firstday/product/application/dto/ProductInfo.java new file mode 100644 index 0000000..11eb48c --- /dev/null +++ b/product-service/src/main/java/com/growmighty/lectures/firstday/product/application/dto/ProductInfo.java @@ -0,0 +1,39 @@ +package com.growmighty.lectures.firstday.product.application.dto; + +import com.growmighty.lectures.firstday.product.domain.Product; +import com.growmighty.lectures.firstday.product.domain.ProductStatus; + +import java.math.BigDecimal; + +/* + 과정 : DB -> Product(Entity) -> ProductInfo(Application DTO) + -> Presentation DTO -> JSON + ProductInfo는 Product를 외부에서 사용할 수 있도록 만든 읽기 전용 DTO이다. + Entity를 그대로 변환하지 않고 Product -> ProductInfo로 변환해서 사용한다. +*/ +public record ProductInfo( + Long id, + Long sellerId, + String name, + BigDecimal price, + int stockQuantity, + ProductStatus status +) { + // Entity -> DTO 변환 메서드 + public static ProductInfo from(Product product) { + return new ProductInfo( + product.getId(), + product.getSellerId(), + product.getName(), + product.getPrice(), + product.getStockQuantity(), + product.getStatus() + ); + } + + // 순수하게 말하면 Entity에 메서드가 있는 것이 맞지만, 조회 전용 DTO에서도 + // 간단한 계산이나 편의 메서드는 넣어도 된다. + public boolean isOrderable() { + return this.status == ProductStatus.ON_SALE; + } +} diff --git a/product-service/src/main/java/com/growmighty/lectures/firstday/product/application/dto/RegisterProductCommand.java b/product-service/src/main/java/com/growmighty/lectures/firstday/product/application/dto/RegisterProductCommand.java new file mode 100644 index 0000000..0e59b59 --- /dev/null +++ b/product-service/src/main/java/com/growmighty/lectures/firstday/product/application/dto/RegisterProductCommand.java @@ -0,0 +1,18 @@ +package com.growmighty.lectures.firstday.product.application.dto; + +import java.math.BigDecimal; + +/* + RegisterProductCommand는 비즈니스 객체가 아니라 데이터 전달만 한다. + 현재 이 Command 객체는 상품 등록을 해달라는 요청을 표현하는 객체다. + HTTP 요청 -> Controller -> RegisterProductCommand -> Application Service + +*/ +public record RegisterProductCommand( + Long sellerId, + String name, + BigDecimal price, + int stockQuantity, + String description +) { +} diff --git a/product-service/src/main/java/com/growmighty/lectures/firstday/product/domain/Product.java b/product-service/src/main/java/com/growmighty/lectures/firstday/product/domain/Product.java new file mode 100644 index 0000000..0e8a32f --- /dev/null +++ b/product-service/src/main/java/com/growmighty/lectures/firstday/product/domain/Product.java @@ -0,0 +1,109 @@ +package com.growmighty.lectures.firstday.product.domain; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +@Entity +@Table(name = "products") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Product { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // Seller의 id로만 접근 + @Column(nullable = false) + private Long sellerId; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private BigDecimal price; + + // @Setter 삭제, 객체는 자신의 상태를 스스로 관리한다. + @Column(nullable = false) + private Integer stockQuantity; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ProductStatus status; + + @Lob + private String description; + + // private 생성자, 정적 팩토리 메서드로 생성 + private Product(Long sellerId, String name, BigDecimal price, Integer stockQuantity, String description) { + validatePrice(price); + if (stockQuantity == null || stockQuantity < 0) { + throw new IllegalArgumentException("재고는 0개 이상이어야 합니다. 입력값: " + stockQuantity); + } + this.sellerId = sellerId; + this.name = name; + this.price = price; + this.stockQuantity = stockQuantity; + this.description = description; + this.status = stockQuantity == 0 ? ProductStatus.OUT_OF_STOCK : ProductStatus.ON_SALE; + } + + public static Product register(Long sellerId, String name, BigDecimal price, Integer stockQuantity, String description) { + return new Product(sellerId, name, price, stockQuantity, description); + } + + // 재고는 Product 자신의 상태이며 변경 규칙도 자신이 알고 있기 때문에, 책임은 Product가 진다. + public void decreaseStock(int quantity) { + if (quantity <= 0) { + // 매개변수의 이상 때문에 수행할 수 없다. -> IllegalArgumentException + throw new IllegalArgumentException("차감 수량은 1개 이상이어야 합니다."); + } + if (this.status == ProductStatus.DISCONTINUED) { + // 객체의 현재 상태 때문에 수행할 수 없다. -> IllegalStateException + throw new IllegalStateException("판매 종료된 상품입니다. product=" + this.name); + } + if (this.stockQuantity < quantity) { + throw new IllegalStateException( + "재고가 부족합니다. product =" + this.name + ", 재고 = " + this.stockQuantity + ", 요청 = " + quantity + ); + } + this.stockQuantity -= quantity; + + if (this.stockQuantity == 0) { + this.status = ProductStatus.OUT_OF_STOCK; + } + } + + public void restoreStock(int quantity) { + if (quantity <= 0) { + throw new IllegalArgumentException("복원 수량은 1개 이상이어야 합니다."); + } + this.stockQuantity += quantity; + + if (this.status == ProductStatus.OUT_OF_STOCK) { + this.status = ProductStatus.ON_SALE; + } + } + + public void changePrice(BigDecimal newPrice) { + validatePrice(newPrice); + this.price = newPrice; + } + + public void discontinue() { + this.status = ProductStatus.DISCONTINUED; + } + + public boolean isOrderable() { + return this.status == ProductStatus.ON_SALE; + } + + private void validatePrice(BigDecimal price) { + if (price == null || price.compareTo(BigDecimal.ZERO) <= 0) { + throw new IllegalArgumentException("가격은 0원보다 커야 합니다. 입력값: " + price); + } + } +} diff --git a/product-service/src/main/java/com/growmighty/lectures/firstday/product/domain/ProductRepository.java b/product-service/src/main/java/com/growmighty/lectures/firstday/product/domain/ProductRepository.java new file mode 100644 index 0000000..7186dc0 --- /dev/null +++ b/product-service/src/main/java/com/growmighty/lectures/firstday/product/domain/ProductRepository.java @@ -0,0 +1,10 @@ +package com.growmighty.lectures.firstday.product.domain; + +import java.util.Optional; + +// DIP 구현 +public interface ProductRepository { + Product save(Product product); + + Optional findById(Long id); +} diff --git a/product-service/src/main/java/com/growmighty/lectures/firstday/product/domain/ProductStatus.java b/product-service/src/main/java/com/growmighty/lectures/firstday/product/domain/ProductStatus.java new file mode 100644 index 0000000..94d7056 --- /dev/null +++ b/product-service/src/main/java/com/growmighty/lectures/firstday/product/domain/ProductStatus.java @@ -0,0 +1,7 @@ +package com.growmighty.lectures.firstday.product.domain; + +public enum ProductStatus { + ON_SALE, + OUT_OF_STOCK, + DISCONTINUED +} diff --git a/product-service/src/main/java/com/growmighty/lectures/firstday/product/infrastructure/ProductJpaRepository.java b/product-service/src/main/java/com/growmighty/lectures/firstday/product/infrastructure/ProductJpaRepository.java new file mode 100644 index 0000000..5651436 --- /dev/null +++ b/product-service/src/main/java/com/growmighty/lectures/firstday/product/infrastructure/ProductJpaRepository.java @@ -0,0 +1,7 @@ +package com.growmighty.lectures.firstday.product.infrastructure; + +import com.growmighty.lectures.firstday.product.domain.Product; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProductJpaRepository extends JpaRepository { +} diff --git a/product-service/src/main/java/com/growmighty/lectures/firstday/product/infrastructure/ProductRepositoryAdapter.java b/product-service/src/main/java/com/growmighty/lectures/firstday/product/infrastructure/ProductRepositoryAdapter.java new file mode 100644 index 0000000..cf31466 --- /dev/null +++ b/product-service/src/main/java/com/growmighty/lectures/firstday/product/infrastructure/ProductRepositoryAdapter.java @@ -0,0 +1,24 @@ +package com.growmighty.lectures.firstday.product.infrastructure; + +import com.growmighty.lectures.firstday.product.domain.Product; +import com.growmighty.lectures.firstday.product.domain.ProductRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class ProductRepositoryAdapter implements ProductRepository { + private final ProductJpaRepository jpaRepository; + + @Override + public Product save(Product product) { + return jpaRepository.save(product); + } + + @Override + public Optional findById(Long id) { + return jpaRepository.findById(id); + } +} diff --git a/product-service/src/main/java/com/growmighty/lectures/firstday/product/presentation/ProductController.java b/product-service/src/main/java/com/growmighty/lectures/firstday/product/presentation/ProductController.java new file mode 100644 index 0000000..aadb818 --- /dev/null +++ b/product-service/src/main/java/com/growmighty/lectures/firstday/product/presentation/ProductController.java @@ -0,0 +1,44 @@ +package com.growmighty.lectures.firstday.product.presentation; + +import com.growmighty.lectures.firstday.common.response.ApiResponse; +import com.growmighty.lectures.firstday.product.application.ProductService; +import com.growmighty.lectures.firstday.product.presentation.dto.ChangeProductPriceRequest; +import com.growmighty.lectures.firstday.product.presentation.dto.ChangeStockRequest; +import com.growmighty.lectures.firstday.product.presentation.dto.ProductResponse; +import com.growmighty.lectures.firstday.product.presentation.dto.RegisterProductRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/products") +class ProductController { + private final ProductService productService; + + @PostMapping + public ApiResponse register(@RequestBody RegisterProductRequest request) { + return ApiResponse.ok(ProductResponse.from(productService.register(request.toCommand()))); + } + + @GetMapping("/{productId}") + public ApiResponse getProduct(@PathVariable Long productId) { + return ApiResponse.ok(ProductResponse.from(productService.getProductInfo(productId))); + } + + @PatchMapping("/{productId}/price") + public ApiResponse changePrice(@PathVariable Long productId, @RequestBody ChangeProductPriceRequest request) { + return ApiResponse.ok(ProductResponse.from(productService.changePrice(productId, request.price()))); + } + + @PostMapping("/{productId}/decrease-stock") + public ApiResponse decreaseStock(@PathVariable Long productId, @RequestBody ChangeStockRequest request) { + productService.decreaseStock(productId, request.quantity()); + return ApiResponse.ok(); + } + + @PostMapping("/{productId}/restore-stock") + public ApiResponse restoreStock(@PathVariable Long productId, @RequestBody ChangeStockRequest request) { + productService.restoreStock(productId, request.quantity()); + return ApiResponse.ok(); + } +} diff --git a/product-service/src/main/java/com/growmighty/lectures/firstday/product/presentation/dto/ChangeProductPriceRequest.java b/product-service/src/main/java/com/growmighty/lectures/firstday/product/presentation/dto/ChangeProductPriceRequest.java new file mode 100644 index 0000000..2d1301d --- /dev/null +++ b/product-service/src/main/java/com/growmighty/lectures/firstday/product/presentation/dto/ChangeProductPriceRequest.java @@ -0,0 +1,8 @@ +package com.growmighty.lectures.firstday.product.presentation.dto; + +import lombok.NonNull; + +import java.math.BigDecimal; + +public record ChangeProductPriceRequest(@NonNull BigDecimal price) { +} diff --git a/product-service/src/main/java/com/growmighty/lectures/firstday/product/presentation/dto/ChangeStockRequest.java b/product-service/src/main/java/com/growmighty/lectures/firstday/product/presentation/dto/ChangeStockRequest.java new file mode 100644 index 0000000..e5d7025 --- /dev/null +++ b/product-service/src/main/java/com/growmighty/lectures/firstday/product/presentation/dto/ChangeStockRequest.java @@ -0,0 +1,6 @@ +package com.growmighty.lectures.firstday.product.presentation.dto; + +import lombok.NonNull; + +public record ChangeStockRequest(@NonNull Integer quantity) { +} diff --git a/product-service/src/main/java/com/growmighty/lectures/firstday/product/presentation/dto/ProductResponse.java b/product-service/src/main/java/com/growmighty/lectures/firstday/product/presentation/dto/ProductResponse.java new file mode 100644 index 0000000..85f062e --- /dev/null +++ b/product-service/src/main/java/com/growmighty/lectures/firstday/product/presentation/dto/ProductResponse.java @@ -0,0 +1,32 @@ +package com.growmighty.lectures.firstday.product.presentation.dto; + +import com.growmighty.lectures.firstday.product.application.dto.ProductInfo; + +import java.math.BigDecimal; + +/* + HTTP 요청 -> RegisterProductRequest (PresentationDTO) -> RegisterProductCommand (Application DTO) + -> Product (Entity) -> ProductInfo (Application DTO) -> ProductResponse (Presentation DTO) + -> HTTP 응답 +*/ +public record ProductResponse( + Long id, + Long sellerId, + String name, + BigDecimal price, + int stockQuantity, + String status, + boolean orderable +) { + public static ProductResponse from(ProductInfo info) { + return new ProductResponse( + info.id(), + info.sellerId(), + info.name(), + info.price(), + info.stockQuantity(), + info.status().name(), + info.isOrderable() + ); + } +} diff --git a/product-service/src/main/java/com/growmighty/lectures/firstday/product/presentation/dto/RegisterProductRequest.java b/product-service/src/main/java/com/growmighty/lectures/firstday/product/presentation/dto/RegisterProductRequest.java new file mode 100644 index 0000000..e04c18e --- /dev/null +++ b/product-service/src/main/java/com/growmighty/lectures/firstday/product/presentation/dto/RegisterProductRequest.java @@ -0,0 +1,21 @@ +package com.growmighty.lectures.firstday.product.presentation.dto; + +import com.growmighty.lectures.firstday.product.application.dto.RegisterProductCommand; +import lombok.NonNull; + +import java.math.BigDecimal; + +public record RegisterProductRequest( + // Presentation 계층으로 들어오는 요청이 null이면 안 된다. + // Product는 create를 통해 생성되고 그 안에 검증 장치가 다 있으니 안 해도 됨. + // 반면 Request는 API 요청이고 DTO밖에 검증 수단이 없어 여기서 NonNull 조치를 취함. + @NonNull Long sellerId, + @NonNull String name, + @NonNull BigDecimal price, + @NonNull Integer stockQuantity, + String description +) { + public RegisterProductCommand toCommand() { + return new RegisterProductCommand(sellerId, name, price, stockQuantity, description); + } +} diff --git a/product-service/src/main/resources/application.properties b/product-service/src/main/resources/application.properties new file mode 100644 index 0000000..78fef8a --- /dev/null +++ b/product-service/src/main/resources/application.properties @@ -0,0 +1,13 @@ +# 설정 파일도 각 서비스마다 모두 다 만들어줘야 한다. + +spring.application.name=product-service +# 포트 번호를 모두 다 다르게 해야 한다! +server.port=8081 + +# 서비스마다 자기만의 DB를 갖는다 (Database per Service) +spring.datasource.url=jdbc:h2:mem:productdb;DB_CLOSE_DELAY=-1 +spring.h2.console.enabled=true + +spring.jpa.hibernate.ddl-auto=create +spring.jpa.show-sql=true +spring.jpa.properties.hibernate.format_sql=true diff --git a/product-service/src/test/ProductTest.java b/product-service/src/test/ProductTest.java new file mode 100644 index 0000000..7fad692 --- /dev/null +++ b/product-service/src/test/ProductTest.java @@ -0,0 +1,85 @@ +package com.growmighty.lectures.firstday.domain; + +import com.growmighty.lectures.firstday.product.domain.Product; +import com.growmighty.lectures.firstday.product.domain.ProductStatus; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +class ProductTest { + + private Product product(int stock) { + return Product.register(1L, "원목 식탁", BigDecimal.valueOf(259000), stock, "설명"); + } + + @Test + @DisplayName("재고가 있으면 판매중, 0이면 품절 상태로 등록된다") + void register_statusByStock() { + assertThat(product(10).getStatus()).isEqualTo(ProductStatus.ON_SALE); + assertThat(product(0).getStatus()).isEqualTo(ProductStatus.OUT_OF_STOCK); + } + + @Test + @DisplayName("가격이 0 이하이거나 재고가 음수면 등록할 수 없다") + void register_invalidValues_throw() { + assertThatThrownBy(() -> Product.register(1L, "x", BigDecimal.ZERO, 1, "d")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> Product.register(1L, "x", BigDecimal.valueOf(1000), -1, "d")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("재고를 차감하면 수량이 줄고, 0이 되면 품절로 전이된다") + void decreaseStock_transitionsToOutOfStock() { + Product product = product(5); + + product.decreaseStock(2); + assertThat(product.getStockQuantity()).isEqualTo(3); + assertThat(product.getStatus()).isEqualTo(ProductStatus.ON_SALE); + + product.decreaseStock(3); + assertThat(product.getStockQuantity()).isZero(); + assertThat(product.getStatus()).isEqualTo(ProductStatus.OUT_OF_STOCK); + } + + @Test + @DisplayName("재고보다 많이 차감하면 예외가 발생한다") + void decreaseStock_insufficient_throws() { + Product product = product(1); + assertThatThrownBy(() -> product.decreaseStock(2)) + .isInstanceOf(IllegalStateException.class); + } + + @Test + @DisplayName("판매 종료된 상품은 재고를 차감할 수 없다") + void decreaseStock_discontinued_throws() { + Product product = product(10); + product.discontinue(); + assertThatThrownBy(() -> product.decreaseStock(1)) + .isInstanceOf(IllegalStateException.class); + } + + @Test + @DisplayName("재고를 복원하면 품절 상품이 다시 판매중으로 전이된다") + void restoreStock_backToOnSale() { + Product product = product(1); + product.decreaseStock(1); + assertThat(product.getStatus()).isEqualTo(ProductStatus.OUT_OF_STOCK); + + product.restoreStock(2); + assertThat(product.getStockQuantity()).isEqualTo(2); + assertThat(product.getStatus()).isEqualTo(ProductStatus.ON_SALE); + } + + @Test + @DisplayName("가격을 0 이하로 변경하면 예외가 발생한다") + void changePrice_invalid_throws() { + Product product = product(10); + assertThatThrownBy(() -> product.changePrice(BigDecimal.valueOf(-1))) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/seller-service/build.gradle b/seller-service/build.gradle new file mode 100644 index 0000000..f5dfc5d --- /dev/null +++ b/seller-service/build.gradle @@ -0,0 +1,15 @@ +plugins { + id 'org.springframework.boot' // 실행 가능한 서비스이므로 Boot 플러그인 적용 (버전은 루트에 등록됨) +} + +dependencies { + implementation project(':common') + + implementation 'org.springframework.boot:spring-boot-starter-webmvc' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-h2console' + runtimeOnly 'com.h2database:h2' + + testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test' + testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' +} diff --git a/seller-service/src/main/java/com/growmighty/lectures/firstday/seller/SellerServiceApplication.java b/seller-service/src/main/java/com/growmighty/lectures/firstday/seller/SellerServiceApplication.java new file mode 100644 index 0000000..18c7746 --- /dev/null +++ b/seller-service/src/main/java/com/growmighty/lectures/firstday/seller/SellerServiceApplication.java @@ -0,0 +1,17 @@ +package com.growmighty.lectures.firstday.seller; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +// 각 서비스마다 자기만의 @SpringBootApplication이 필요하다. +// scanBasePackages로 자신의 패키지 아래뿐만 아닌 다른 곳도 스캔한다. +@SpringBootApplication(scanBasePackages = { + "com.growmighty.lectures.firstday.seller", + "com.growmighty.lectures.firstday.common" // 요거 등록 안하면 커스텀예외가 빈으로 등록되지 않는다. +}) +public class SellerServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(SellerServiceApplication.class, args); + } +} diff --git a/seller-service/src/main/java/com/growmighty/lectures/firstday/seller/application/SellerService.java b/seller-service/src/main/java/com/growmighty/lectures/firstday/seller/application/SellerService.java new file mode 100644 index 0000000..7662cf8 --- /dev/null +++ b/seller-service/src/main/java/com/growmighty/lectures/firstday/seller/application/SellerService.java @@ -0,0 +1,51 @@ +package com.growmighty.lectures.firstday.seller.application; + +import com.growmighty.lectures.firstday.common.exception.EntityNotFoundException; +import com.growmighty.lectures.firstday.seller.application.dto.ApplySellerCommand; +import com.growmighty.lectures.firstday.seller.application.dto.SellerInfo; +import com.growmighty.lectures.firstday.seller.domain.Seller; +import com.growmighty.lectures.firstday.seller.domain.SellerRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class SellerService { + private final SellerRepository sellerRepository; + + @Transactional + public SellerInfo apply(ApplySellerCommand command) { + // userId는 이제 다른 서비스이니 여기서 존재 검증 안 한다. + if (sellerRepository.existsByUserId(command.userId())) { + throw new IllegalStateException("이미 입점한 유저입니다. userId = " + command.userId()); + } + + Seller seller = Seller.apply(command.userId(), command.businessName()); + return SellerInfo.from(sellerRepository.save(seller)); + } + + @Transactional + public void suspend(Long sellerId) { + // Seller의 suspend() 사용 + getSellerEntity(sellerId).suspend(); + } + + @Transactional + public SellerInfo getSeller(Long sellerId) { + return SellerInfo.from(getSellerEntity(sellerId)); + } + + @Transactional(readOnly = true) + public void validateSellable(Long sellerId) { + Seller seller = getSellerEntity(sellerId); + if (!seller.canSell()) { + throw new IllegalStateException("판매 가능한 셀러가 아닙니다. sellerId = "+ sellerId); + } + } + + private Seller getSellerEntity(Long sellerId) { + return sellerRepository.findById(sellerId) + .orElseThrow(() -> new EntityNotFoundException("존재하지 않는 셀러입니다. sellerId = " + sellerId)); + } +} diff --git a/seller-service/src/main/java/com/growmighty/lectures/firstday/seller/application/dto/ApplySellerCommand.java b/seller-service/src/main/java/com/growmighty/lectures/firstday/seller/application/dto/ApplySellerCommand.java new file mode 100644 index 0000000..e3d2c86 --- /dev/null +++ b/seller-service/src/main/java/com/growmighty/lectures/firstday/seller/application/dto/ApplySellerCommand.java @@ -0,0 +1,7 @@ +package com.growmighty.lectures.firstday.seller.application.dto; + +public record ApplySellerCommand( + Long userId, + String businessName +) { +} diff --git a/seller-service/src/main/java/com/growmighty/lectures/firstday/seller/application/dto/SellerInfo.java b/seller-service/src/main/java/com/growmighty/lectures/firstday/seller/application/dto/SellerInfo.java new file mode 100644 index 0000000..e98984c --- /dev/null +++ b/seller-service/src/main/java/com/growmighty/lectures/firstday/seller/application/dto/SellerInfo.java @@ -0,0 +1,15 @@ +package com.growmighty.lectures.firstday.seller.application.dto; + +import com.growmighty.lectures.firstday.seller.domain.Seller; +import com.growmighty.lectures.firstday.seller.domain.SellerStatus; + +public record SellerInfo( + Long id, + Long userId, + String businessName, + SellerStatus status +) { + public static SellerInfo from(Seller seller) { + return new SellerInfo(seller.getId(), seller.getUserId(), seller.getBusinessName(), seller.getStatus()); + } +} diff --git a/seller-service/src/main/java/com/growmighty/lectures/firstday/seller/domain/Seller.java b/seller-service/src/main/java/com/growmighty/lectures/firstday/seller/domain/Seller.java new file mode 100644 index 0000000..20fa6b1 --- /dev/null +++ b/seller-service/src/main/java/com/growmighty/lectures/firstday/seller/domain/Seller.java @@ -0,0 +1,58 @@ +package com.growmighty.lectures.firstday.seller.domain; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter // get 추가 +@Table(name = "sellers") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Seller { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // 판매자와 상품은 서로 다른 애그리게잇이니 지우자. + + // User를 id로만 접근 + @Column(nullable = false) + private Long userId; + + // 두 가지의 컬럼 추가 + @Column(nullable = false) + private String businessName; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private SellerStatus status; + + // 생성자 + private Seller(Long userId, String businessName) { + if (businessName == null || businessName.isBlank()) { + throw new IllegalArgumentException("상호명은 필수입니다."); + } + this.userId = userId; + this.businessName = businessName; + this.status = SellerStatus.ACTIVE; + } + + // 판매자로 등록 요청 + public static Seller apply(Long userId, String businessName) { + return new Seller(userId, businessName); + } + + // 상태 변경, Seller 자신의 상태를 변경하는 것이니 Seller 책임! + public void suspend() { + this.status = SellerStatus.SUSPENDED; + } + + public void activate() { + this.status = SellerStatus.ACTIVE; + } + + public boolean canSell() { + return this.status == SellerStatus.ACTIVE; + } +} diff --git a/seller-service/src/main/java/com/growmighty/lectures/firstday/seller/domain/SellerRepository.java b/seller-service/src/main/java/com/growmighty/lectures/firstday/seller/domain/SellerRepository.java new file mode 100644 index 0000000..f151184 --- /dev/null +++ b/seller-service/src/main/java/com/growmighty/lectures/firstday/seller/domain/SellerRepository.java @@ -0,0 +1,12 @@ +package com.growmighty.lectures.firstday.seller.domain; + +import java.util.Optional; + +// DIP 위해 JPA 삭제 +public interface SellerRepository { + Seller save(Seller seller); + + Optional findById(Long id); + + boolean existsByUserId(Long userId); +} diff --git a/seller-service/src/main/java/com/growmighty/lectures/firstday/seller/domain/SellerStatus.java b/seller-service/src/main/java/com/growmighty/lectures/firstday/seller/domain/SellerStatus.java new file mode 100644 index 0000000..3e40acf --- /dev/null +++ b/seller-service/src/main/java/com/growmighty/lectures/firstday/seller/domain/SellerStatus.java @@ -0,0 +1,6 @@ +package com.growmighty.lectures.firstday.seller.domain; + +public enum SellerStatus { + ACTIVE, + SUSPENDED +} diff --git a/seller-service/src/main/java/com/growmighty/lectures/firstday/seller/infrastructure/SellerJpaRepository.java b/seller-service/src/main/java/com/growmighty/lectures/firstday/seller/infrastructure/SellerJpaRepository.java new file mode 100644 index 0000000..5283b35 --- /dev/null +++ b/seller-service/src/main/java/com/growmighty/lectures/firstday/seller/infrastructure/SellerJpaRepository.java @@ -0,0 +1,8 @@ +package com.growmighty.lectures.firstday.seller.infrastructure; + +import com.growmighty.lectures.firstday.seller.domain.Seller; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SellerJpaRepository extends JpaRepository { + boolean existsByUserId(Long userId); +} diff --git a/seller-service/src/main/java/com/growmighty/lectures/firstday/seller/infrastructure/SellerRepositoryAdapter.java b/seller-service/src/main/java/com/growmighty/lectures/firstday/seller/infrastructure/SellerRepositoryAdapter.java new file mode 100644 index 0000000..d131ca4 --- /dev/null +++ b/seller-service/src/main/java/com/growmighty/lectures/firstday/seller/infrastructure/SellerRepositoryAdapter.java @@ -0,0 +1,29 @@ +package com.growmighty.lectures.firstday.seller.infrastructure; + +import com.growmighty.lectures.firstday.seller.domain.Seller; +import com.growmighty.lectures.firstday.seller.domain.SellerRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class SellerRepositoryAdapter implements SellerRepository { + private final SellerJpaRepository jpaRepository; + + @Override + public Seller save(Seller seller) { + return jpaRepository.save(seller); + } + + @Override + public Optional findById(Long id) { + return jpaRepository.findById(id); + } + + @Override + public boolean existsByUserId(Long userId) { + return jpaRepository.existsByUserId(userId); + } +} diff --git a/seller-service/src/main/java/com/growmighty/lectures/firstday/seller/presentation/SellerController.java b/seller-service/src/main/java/com/growmighty/lectures/firstday/seller/presentation/SellerController.java new file mode 100644 index 0000000..6bbd177 --- /dev/null +++ b/seller-service/src/main/java/com/growmighty/lectures/firstday/seller/presentation/SellerController.java @@ -0,0 +1,31 @@ +package com.growmighty.lectures.firstday.seller.presentation; + +import com.growmighty.lectures.firstday.common.response.ApiResponse; +import com.growmighty.lectures.firstday.seller.application.SellerService; +import com.growmighty.lectures.firstday.seller.presentation.dto.ApplySellerRequest; +import com.growmighty.lectures.firstday.seller.presentation.dto.SellerResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/sellers") +public class SellerController { + private final SellerService sellerService; + + @PostMapping + public ApiResponse apply(@RequestBody ApplySellerRequest request) { + return ApiResponse.ok(SellerResponse.from(sellerService.apply(request.toCommend()))); + } + + @GetMapping("/{sellerId}") + public ApiResponse getSeller(@PathVariable Long sellerId) { + return ApiResponse.ok(SellerResponse.from(sellerService.getSeller(sellerId))); + } + + @PostMapping("/{sellerId}/suspend") + public ApiResponse suspend(@PathVariable Long sellerId) { + sellerService.suspend(sellerId); + return ApiResponse.ok(); + } +} diff --git a/seller-service/src/main/java/com/growmighty/lectures/firstday/seller/presentation/dto/ApplySellerRequest.java b/seller-service/src/main/java/com/growmighty/lectures/firstday/seller/presentation/dto/ApplySellerRequest.java new file mode 100644 index 0000000..041c57b --- /dev/null +++ b/seller-service/src/main/java/com/growmighty/lectures/firstday/seller/presentation/dto/ApplySellerRequest.java @@ -0,0 +1,10 @@ +package com.growmighty.lectures.firstday.seller.presentation.dto; + +import com.growmighty.lectures.firstday.seller.application.dto.ApplySellerCommand; +import lombok.NonNull; + +public record ApplySellerRequest(@NonNull Long userId, @NonNull String businessName) { + public ApplySellerCommand toCommend() { + return new ApplySellerCommand(userId, businessName); + } +} diff --git a/seller-service/src/main/java/com/growmighty/lectures/firstday/seller/presentation/dto/SellerResponse.java b/seller-service/src/main/java/com/growmighty/lectures/firstday/seller/presentation/dto/SellerResponse.java new file mode 100644 index 0000000..a0b45fb --- /dev/null +++ b/seller-service/src/main/java/com/growmighty/lectures/firstday/seller/presentation/dto/SellerResponse.java @@ -0,0 +1,14 @@ +package com.growmighty.lectures.firstday.seller.presentation.dto; + +import com.growmighty.lectures.firstday.seller.application.dto.SellerInfo; + +public record SellerResponse( + Long id, + Long userId, + String businessName, + String status +) { + public static SellerResponse from(SellerInfo info) { + return new SellerResponse(info.id(), info.userId(), info.businessName(), info.status().name()); + } +} diff --git a/seller-service/src/main/resources/application.properties b/seller-service/src/main/resources/application.properties new file mode 100644 index 0000000..04b75d6 --- /dev/null +++ b/seller-service/src/main/resources/application.properties @@ -0,0 +1,13 @@ +spring.application.name=cart-service +server.port=8084 + +# 서비스마다 자기만의 DB를 갖는다 (Database per Service) +spring.datasource.url=jdbc:h2:mem:cartdb;DB_CLOSE_DELAY=-1 +spring.h2.console.enabled=true + +spring.jpa.hibernate.ddl-auto=create +spring.jpa.show-sql=true +spring.jpa.properties.hibernate.format_sql=true + +# cart 는 상품 검증을 위해 product-service 를 HTTP 로 호출한다 (자기 소유의 ProductPort 구현) +cart.client.product-base-url=http://localhost:8081 diff --git a/seller-service/src/test/SellerTest.java b/seller-service/src/test/SellerTest.java new file mode 100644 index 0000000..2e79bad --- /dev/null +++ b/seller-service/src/test/SellerTest.java @@ -0,0 +1,43 @@ +package com.growmighty.lectures.firstday.domain; + + +import com.growmighty.lectures.firstday.seller.domain.Seller; +import com.growmighty.lectures.firstday.seller.domain.SellerStatus; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + + +class SellerTest { + + @Test + @DisplayName("입점하면 ACTIVE 상태이고 판매가 가능하다") + void apply_isActive() { + Seller seller = Seller.apply(1L, "그로마이티 가구"); + + assertThat(seller.getStatus()).isEqualTo(SellerStatus.ACTIVE); + assertThat(seller.canSell()).isTrue(); + } + + @Test + @DisplayName("상호명이 비어 있으면 입점할 수 없다") + void apply_blankBusinessName_throws() { + assertThatThrownBy(() -> Seller.apply(1L, " ")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("정지하면 판매가 불가하고, 재개하면 다시 가능하다") + void suspend_and_activate() { + Seller seller = Seller.apply(1L, "그로마이티 가구"); + + seller.suspend(); + assertThat(seller.getStatus()).isEqualTo(SellerStatus.SUSPENDED); + assertThat(seller.canSell()).isFalse(); + + seller.activate(); + assertThat(seller.canSell()).isTrue(); + } +} diff --git a/settings.gradle b/settings.gradle index a9a8448..d0150a4 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1,12 @@ rootProject.name = 'tangled-monolith' + +include 'common' + +// 실행 가능한 서비스 모듈들 (각자 다른 포트, 각자 다른 DB) +include 'product-service' // :8081 +include 'payment-service' // :8082 +include 'user-service' // :8083 +include 'seller-service' // :8084 +include 'cart-service' // :8085 +include 'settlement-service' // :8086 +include 'order-service' // :8080 diff --git a/settlement-service/build.gradle b/settlement-service/build.gradle new file mode 100644 index 0000000..7e618af --- /dev/null +++ b/settlement-service/build.gradle @@ -0,0 +1,16 @@ +plugins { + id 'org.springframework.boot' // 실행 가능한 서비스이므로 Boot 플러그인 적용 (버전은 루트에 등록됨) +} + +dependencies { + implementation project(':common') + + implementation 'org.springframework.boot:spring-boot-starter-webmvc' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-batch' + implementation 'org.springframework.boot:spring-boot-h2console' + runtimeOnly 'com.h2database:h2' + + testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test' + testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' +} diff --git a/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/SettlementServiceApplication.java b/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/SettlementServiceApplication.java new file mode 100644 index 0000000..a68b26e --- /dev/null +++ b/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/SettlementServiceApplication.java @@ -0,0 +1,17 @@ +package com.growmighty.lectures.firstday.settlement; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +// 각 서비스마다 자기만의 @SpringBootApplication이 필요하다. +// scanBasePackages로 자신의 패키지 아래뿐만 아닌 다른 곳도 스캔한다. +@SpringBootApplication(scanBasePackages = { + "com.growmighty.lectures.firstday.settlement", + "com.growmighty.lectures.firstday.common" // 요거 등록 안하면 커스텀예외가 빈으로 등록되지 않는다. +}) +public class SettlementServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(SettlementServiceApplication.class, args); + } +} diff --git a/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/application/NaiveSettlementService.java b/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/application/NaiveSettlementService.java new file mode 100644 index 0000000..7579ec3 --- /dev/null +++ b/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/application/NaiveSettlementService.java @@ -0,0 +1,117 @@ +package com.growmighty.lectures.firstday.settlement.application; + +/* + 실험용. 배치의 필요성에 대해 테스트하는 서비스 파일이다. + 실무 운영에서는 절대 쓰지 않는다. + 첫 번째는 @code findAll()로 전량을 한 번에 메모리로 올린다. OutOfMemoryError 예외가 발생할 것이다. + 두 번째는 페이지 단위로 조금씩 읽되 전부 메모리에 쌓으며 정산한다. + limit을 올릴수록 메모리와 시간이 어떻게 증가하는지 확인해보자. 점점 OOM 예외에 가까워질 것이다. + + */ + + +import com.growmighty.lectures.firstday.settlement.application.dto.SettleReport; +import com.growmighty.lectures.firstday.settlement.domain.Settlement; +import com.growmighty.lectures.firstday.settlement.domain.SettlementRepository; +import com.growmighty.lectures.firstday.settlement.read.Order; +import com.growmighty.lectures.firstday.settlement.read.OrderRepository; +import com.growmighty.lectures.firstday.settlement.read.OrderStatus; +import com.growmighty.lectures.firstday.settlement.support.HeapMonitor; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class NaiveSettlementService { + + /** 플랫폼 수수료율 3% */ + private static final BigDecimal FEE_RATE = new BigDecimal("0.03"); + + /** 한 번에 읽어오는 페이지 크기 */ + private static final int PAGE_SIZE = 10_000; + + private final OrderRepository orderRepository; + private final SettlementRepository settlementRepository; + + /** + * [데모1] "한 줄의 함정" — 전량을 메모리로 적재(findAll)한 뒤 정산. + * 100만 건이면 findAll 그 한 줄에서 OOM 이 발생한다. + */ + @Transactional + public SettleReport settleAll() { + try (HeapMonitor monitor = HeapMonitor.start("naive-findAll", 500)) { + long startedAt = System.currentTimeMillis(); + log.warn("[NAIVE] findAll() 시작 — 전체 주문을 한 번에 메모리로 올립니다. (대용량이면 여기서 OOM)"); + + // ⚠️ 바로 이 한 줄이 100만 엔티티를 통째로 힙에 올린다. + List orders = orderRepository.findAll(); + log.warn("[NAIVE] findAll() 완료 — 적재된 주문 수 = {}", orders.size()); + + long settled = settleEach(orders); + long elapsed = System.currentTimeMillis() - startedAt; + + SettleReport report = new SettleReport( + orders.size(), settled, 0, elapsed, monitor.peakUsedMb(), monitor.maxHeapMb(), "COMPLETED"); + log.warn("[NAIVE] 완료 리포트 = {}", report); + return report; + } + } + + /** + * [데모2] "절벽으로 걸어가기" — 페이지로 조금씩 읽되 전부 메모리에 누적하며 정산. + * limit 을 올려갈수록 메모리/시간이 어떻게 늘어나는지 추세를 보여준다. + * + * @param limit 정산할 최대 주문 수 (전체를 보려면 매우 크게) + */ + @Transactional + public SettleReport settleUpTo(int limit) { + try (HeapMonitor monitor = HeapMonitor.start("naive-climb", 500)) { + long startedAt = System.currentTimeMillis(); + log.warn("[NAIVE] '절벽으로 걸어가기' 시작 — limit={} 까지 메모리에 쌓으며 정산", limit); + + // 안티패턴 재현: 읽은 주문을 절대 버리지 않고 계속 보관한다. + List holding = new ArrayList<>(); + long settled = 0; + int page = 0; + + while (holding.size() < limit) { + List batch = orderRepository.findPage(page++, PAGE_SIZE); + if (batch.isEmpty()) { + break; + } + holding.addAll(batch); + settled += settleEach(batch); + + long elapsed = System.currentTimeMillis() - startedAt; + log.warn("[NAIVE] 누적 {}건 보관 / 정산 {}건 / 경과 {}ms (메모리는 위 [mem] 로그 참고)", + holding.size(), settled, elapsed); + } + + long elapsed = System.currentTimeMillis() - startedAt; + SettleReport report = new SettleReport( + holding.size(), settled, 0, elapsed, monitor.peakUsedMb(), monitor.maxHeapMb(), "COMPLETED"); + log.warn("[NAIVE] 완료 리포트 = {}", report); + return report; + } + } + + private long settleEach(List orders) { + long settled = 0; + for (Order order : orders) { + if (order.getStatus() != OrderStatus.PAID || order.getPaymentId() == null) { + continue; + } + BigDecimal amount = order.getTotalAmount().getValue(); + settlementRepository.save(Settlement.of(order.getId(), order.getPaymentId(), amount, FEE_RATE)); + settled++; + } + return settled; + } +} diff --git a/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/application/SettlementBatchService.java b/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/application/SettlementBatchService.java new file mode 100644 index 0000000..350b906 --- /dev/null +++ b/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/application/SettlementBatchService.java @@ -0,0 +1,155 @@ +package com.growmighty.lectures.firstday.settlement.application; + + +import com.growmighty.lectures.firstday.settlement.application.dto.SettleReport; +import com.growmighty.lectures.firstday.settlement.batch.SettlementFaultBox; +import com.growmighty.lectures.firstday.settlement.batch.SettlementParallelJobFactory; +import com.growmighty.lectures.firstday.settlement.domain.SettlementRepository; +import com.growmighty.lectures.firstday.settlement.read.OrderRepository; +import com.growmighty.lectures.firstday.settlement.support.HeapMonitor; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.job.Job; +import org.springframework.batch.core.job.JobExecution; +import org.springframework.batch.core.job.parameters.JobParameters; +import org.springframework.batch.core.job.parameters.JobParametersBuilder; +import org.springframework.batch.core.launch.JobInstanceAlreadyCompleteException; +import org.springframework.batch.core.launch.JobOperator; +import org.springframework.batch.core.step.StepExecution; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +/** + * [Step2] 정산 배치 Job 실행기 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class SettlementBatchService { + + private final JobOperator jobOperator; + private final Job settlementJob; + private final SettlementFaultBox faultBox; + private final OrderRepository orderRepository; + private final SettlementRepository settlementRepository; + private final SettlementParallelJobFactory parallelJobFactory; + + // 멀티스레드 테스트 + // 기본 스레드 수 + @Value("${settlement.batch.thread-count:8}") + private int defaultThreadCount; + + // 파티셔닝의 기본 gridSize + @Value("${settlement.batch.grid-size:8}") + private int defaultGridSize; + + public SettleReport run() { + faultBox.disarm(); // 깨끗한 정상 상태에서 시작. + JobParameters params = newRunParams(); + log.warn("[BATCH] 정산 Job 실행 (새 인스턴스)"); + return launch(settlementJob, params, "batch"); + } + + // 멀티 스레드 실행, 그러나 스레드만 늘리는 순진한 가속이다. 과연 이 방법이 먹힐까? + // 읽기 깔때기 : 물통을 아무리 많이 갖다 놔도 수도꼭지에서 나오는 물의 양과 속도는 일정하다. + // 처리량 천장 : 스레드가 일정량 이상 늘어나면 1초에 처리하는 정보의 양이 더 이상 늘어나지 않는다. 그 값. + public SettleReport runMultiThreaded(Integer threads) { + faultBox.disarm(); + int t = (threads != null) ? threads : defaultThreadCount; + // 효과가 있다면 처리량 천장이 드라마틱하게 늘어날 것이다. 과연? + log.warn("[BATCH] Multi-threaded Step 실행 - threads = {} (읽기 깔때기, 처리량 천장 측정)", t); + return launch(parallelJobFactory.multiThreadedJob(t), newRunParams(), "batch-mt"); + } + + // 멀티 스레드의 재시작 위치 상실 시 재시작 진행을 지켜보자. + // 멀티스레드에서 Reader는 saveState가 false라 내가 어디까지 읽었는지를 알지 못한다. + // 따라서 실패 후 재시작하면 무조건 처음부터 다시 읽는다. -> 멱등성이 이중정산은 막지만, "이어서 재개"의 장점은 사라진다. + public SettleReport runMultiThreadedRestartable(long runId, Double failRatio) { + if (failRatio == null) { + faultBox.disarm(); + } else { + long remaining = orderRepository.count() - settlementRepository.count(); + faultBox.arm(Math.max(1, Math.round(remaining * failRatio))); + } + JobParameters params = new JobParametersBuilder() + .addLong("runId", runId) + .toJobParameters(); + log.warn("[BATCH] Multi-Threaded 재시작 시도 -> runId = {}, 장애 = {} (saveState가 false이므로 처음부터 다시 읽는다.)", + runId, faultBox.armed()); + return launch(parallelJobFactory.multiThreadedJob(defaultThreadCount), params, "batch-mt-restart"); + } + + // id 범위를 gridSize개로 나눠 워커마다 전용 Reader로 병렬 처리. + public SettleReport runPartitioned(Integer gridSize) { + faultBox.disarm(); + int g = (gridSize != null) ? gridSize : defaultGridSize; // 1 + log.warn("[BATCH] Partitioning 실행 -> gridSize = {} (구조적 해법 : 속도와 재시작 둘 다 용이)", g); + return launch(parallelJobFactory.partitionedJob(g), newRunParams(), "batch-part"); + } + + // failRatio는 전체 일의 failRatio 비율만큼 성공 후 실패 + public SettleReport runFailing(double failRatio) { + long remaining = orderRepository.count() - settlementRepository.count(); + long failAfter = Math.max(1, Math.round(remaining * failRatio)); + faultBox.arm(failAfter); // 실패 지점 전달 + + JobParameters params = newRunParams(); + log.warn("[BATCH] 장애 주입 실행 예정 -> 남은 {}건 중 {}건 처리 후 실패 예정", remaining, failAfter); + + return launch(settlementJob, params, "batch-fail"); + } + + public SettleReport runRestartable(long runId, Double failRatio) { + if (failRatio == null) { + faultBox.disarm(); + } + else { + long remaining = orderRepository.count() - settlementRepository.count(); + faultBox.arm(Math.max(1, Math.round(remaining * failRatio))); + } + + // runId가 같다면 같은 JobInstance니까 재시작 대상이다. + JobParameters params = new JobParametersBuilder() + .addLong("runId", runId) + .toJobParameters(); + + log.warn("[BATCH] 재시작 가능 실행: runId = {}, 장애 = {}", runId, faultBox.armed()); + return launch(settlementJob, params, "batch-restart"); + } + + private SettleReport launch(Job job, JobParameters params, String label) { + try (HeapMonitor monitor = HeapMonitor.start(label, 500)) { + long startedAt = System.currentTimeMillis(); + JobExecution execution = jobOperator.start(job, params); + + long read = 0, written = 0, skipped = 0; + for (StepExecution step : execution.getStepExecutions()) { + // 파티셔닝의 워커 StepExecution은 마스터 Step에 합산되어있으니 뺀다. + if (step.getStepName().contains(":")) { + continue; + } + + read += step.getReadCount(); + written += step.getWriteCount(); + skipped += step.getFilterCount(); + } + long elapsed = System.currentTimeMillis() - startedAt; + + SettleReport report = new SettleReport(read, written, skipped, elapsed, + monitor.peakUsedMb(), monitor.maxHeapMb(), execution.getStatus().toString()); + log.warn("[BATCH] 정상 종료. status={}, 리포트={}", execution.getStatus(), report); + return report; + } catch (JobInstanceAlreadyCompleteException e) { + throw new IllegalStateException( + "이미 완료된 정산 인스턴스입니다! (같은 파라미터, 멱등성 실현 성공) 새 runId 를 쓰거나 DELETE /settlements 후 다시 시작하세요.", e); + } catch (Exception e) { + throw new IllegalStateException("정산 배치 실행 실패: " + e.getMessage(), e); + } + } + + private JobParameters newRunParams() { + return new JobParametersBuilder() + .addLong("timestamp", System.currentTimeMillis()) + .toJobParameters(); + } +} diff --git a/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/application/dto/SettleReport.java b/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/application/dto/SettleReport.java new file mode 100644 index 0000000..637fb65 --- /dev/null +++ b/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/application/dto/SettleReport.java @@ -0,0 +1,13 @@ +package com.growmighty.lectures.firstday.settlement.application.dto; + +// 배치 테스트 리포트를 위한 DTO +public record SettleReport( + long readCount, // 메모리로 읽어들인 주문 수 + long settledCount, // 실제 정산 생성 건수 + long skippedCount, // 멱등성 검사에서 이미 정산돼 건너뛴(즉 멱등성 구현에 의해 필터링된) 주문 수 + long elapsedMs, // 소요 시간 + long peakHeapMb, // 작업 중 피크 힙 사용량 + long maxHeapMb, // -Xmx 상한 + String status // 멱등성 검사 결과 상태 +) { +} diff --git a/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/batch/OrderRangePartitioner.java b/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/batch/OrderRangePartitioner.java new file mode 100644 index 0000000..7df335a --- /dev/null +++ b/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/batch/OrderRangePartitioner.java @@ -0,0 +1,63 @@ +package com.growmighty.lectures.firstday.settlement.batch; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.partition.Partitioner; +import org.springframework.batch.infrastructure.item.ExecutionContext; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.util.HashMap; +import java.util.Map; + +// 전체 주문을 여러 구간 (id 범위를 근거)으로 잘라서 각 Worker에게 나눠주는 역할 +// gridSize로 나눈다. +@Slf4j +@RequiredArgsConstructor +public class OrderRangePartitioner implements Partitioner { + private final JdbcTemplate jdbcTemplate; + + @Override + public Map partition(int gridSize) { + Long min = jdbcTemplate.queryForObject( + "SELECT MIN(id) FROM orders WHERE status = 'PAID'", Long.class + ); + Long max = jdbcTemplate.queryForObject( + "SELECT MAX(id) FROM orders WHERE status = 'PAID'", Long.class + ); + + // Spring Batch에게 전달할 파티션 목록 + Map partitions = new HashMap<>(); + + // 대상이 없으면 빈 파티션 1개 (minId > maxId인 경우. Reader가 읽은 것이 아무것도 없다.) + if (min == null || max == null) { + ExecutionContext empty = new ExecutionContext(); + empty.putLong("minId", 1L); + empty.putLong("maxId", 0L); + partitions.put("partition0", empty); + log.warn("[PARTITION] 정산 대상 주문이 없습니다. 빈 파티션 1개 생성"); + return partitions; + } + + long total = max - min + 1; // 전체 범위 계산 + long rangeSize = (long) Math.ceil((double) total / gridSize); // 파티션 당 Id의 폭 (한 파티션의 크기) + + long start = min; + int index = 0; + while (start <= max) { + long end = Math.min(start + rangeSize - 1, max); + + ExecutionContext context = new ExecutionContext(); + context.putLong("minId", start); + context.putLong("maxId", end); + partitions.put("partition" + index, context); + log.warn("[PARTITION] partition{} -> id {} ~ {}", index, start, end); + + start = end + 1; + index++; + } + + log.warn("[PARTITION] 전체 id {} ~ {} 를 {}개 파티션으로 분할 (요청 gridSize = {}", + min, max, partitions.size(), gridSize); + return partitions; + } +} diff --git a/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/batch/OrderToSettlementProcessor.java b/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/batch/OrderToSettlementProcessor.java new file mode 100644 index 0000000..1ba50ce --- /dev/null +++ b/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/batch/OrderToSettlementProcessor.java @@ -0,0 +1,54 @@ +package com.growmighty.lectures.firstday.settlement.batch; + +// 읽어온 주문 1건을 정산 1건으로 가공한다. + +import com.growmighty.lectures.firstday.settlement.domain.Settlement; +import com.growmighty.lectures.firstday.settlement.domain.SettlementRepository; +import com.growmighty.lectures.firstday.settlement.read.Order; +import com.growmighty.lectures.firstday.settlement.read.OrderStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.infrastructure.item.ItemProcessor; + +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.util.concurrent.atomic.AtomicLong; + +@Component +@StepScope // 원래 Component는 싱글턴이지만 지금은 배치이므로 배치 단위로 빈을 생성하고 삭제한다. +@RequiredArgsConstructor +public class OrderToSettlementProcessor implements ItemProcessor { + private static final BigDecimal FEE_RATE = new BigDecimal("0.03"); + + private final SettlementRepository settlementRepository; + private final SettlementFaultBox faultBox; + + // 실제 정산 건수, 스킵 제외. 이걸로 멱등성을 실현했는지 확인 가능. + private final AtomicLong produced = new AtomicLong(); + + @Override + public Settlement process(Order order) { + // 1차 방어 : 비즈니스 레벨의 방어 + // 멱등성 실현 : 이미 정산된 주문이면 건너뛰어라. -> 재실행/재시작 시 중복 정산을 방지한다. + if (settlementRepository.existsByOrderId(order.getId())) { + return null; + } + + // 2차 방어 + // 멱등성 실현 : 상태를 확인하고 정산 대상이 아니면 넘어간다. + if (order.getStatus() != OrderStatus.PAID || order.getPaymentId() == null) { + return null; + } + + // 정해진 건수를 넘는 순간 강제로 오류를 발생시켜 멱등성을 깨뜨리려 시도한다. + long n = produced.incrementAndGet(); + if (faultBox.armed() && n > faultBox.failAfter()) { + throw new SettlementFaultException( + "의도적인 장애 발동 : %d건 정산 직후 강제 실패. 발현된 해당 청크는 롤백된다.".formatted(faultBox.failAfter())); + } + + BigDecimal amount = order.getTotalAmount().getValue(); + return Settlement.of(order.getId(), order.getPaymentId(), amount, FEE_RATE); + } +} diff --git a/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/batch/SettlementFaultBox.java b/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/batch/SettlementFaultBox.java new file mode 100644 index 0000000..98b365a --- /dev/null +++ b/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/batch/SettlementFaultBox.java @@ -0,0 +1,32 @@ +package com.growmighty.lectures.firstday.settlement.batch; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class SettlementFaultBox { + // volatile로 메모리 가시성 확보 + private volatile long failAfter = 0; // 이 건수를 초과해 정산을 만들려는 순간 장애 발생. 정상은 당연히 이 값이 0이어야... + + // 지정한 건수만큼 정산한 뒤 터지도록 장전 + public void arm(long failAfter) { + this.failAfter = failAfter; + log.warn("[FAULT] 장애 장전: {}건 정산 후 강제로 실패하게 하여 중복 결제 유도", failAfter); + } + + public void disarm() { + if (failAfter > 0) { + log.warn("[fault] 장애 해제, 정상화 되었음."); + } + this.failAfter = 0; + } + + public boolean armed() { + return failAfter > 0; + } + + public long failAfter() { + return failAfter; + } +} diff --git a/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/batch/SettlementFaultException.java b/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/batch/SettlementFaultException.java new file mode 100644 index 0000000..49a518a --- /dev/null +++ b/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/batch/SettlementFaultException.java @@ -0,0 +1,7 @@ +package com.growmighty.lectures.firstday.settlement.batch; + +public class SettlementFaultException extends RuntimeException { + public SettlementFaultException(String message) { + super(message); + } +} diff --git a/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/batch/SettlementJobConfig.java b/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/batch/SettlementJobConfig.java new file mode 100644 index 0000000..f179a1a --- /dev/null +++ b/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/batch/SettlementJobConfig.java @@ -0,0 +1,79 @@ +package com.growmighty.lectures.firstday.settlement.batch; + + +import com.growmighty.lectures.firstday.settlement.domain.Settlement; +import com.growmighty.lectures.firstday.settlement.read.Order; +import com.growmighty.lectures.firstday.settlement.read.OrderStatus; +import jakarta.persistence.EntityManagerFactory; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.job.Job; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.Step; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.infrastructure.item.database.JpaItemWriter; +import org.springframework.batch.infrastructure.item.database.JpaPagingItemReader; +import org.springframework.batch.infrastructure.item.database.builder.JpaItemWriterBuilder; +import org.springframework.batch.infrastructure.item.database.builder.JpaPagingItemReaderBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +import java.util.Map; + + +@Configuration +public class SettlementJobConfig { + + public static final String JOB_NAME = "settlementJob"; + + @Value("${settlement.batch.chunk-size:1000}") + private int chunkSize; + + // pageSize를 chunkSize와 맞춰 한 페이지 = 한 청크 = 한 트랜잭션이 되게 한다. + @Bean + @StepScope + public JpaPagingItemReader settlementOrderReader(EntityManagerFactory emf) { + return new JpaPagingItemReaderBuilder() + .name("settlementOrderReader") + .entityManagerFactory(emf) + .queryString("SELECT o FROM Order o WHERE o.status = :status ORDER BY o.id ASC") + .parameterValues(Map.of("status", OrderStatus.PAID)) + .pageSize(chunkSize) + .build(); + } + + /** + * [ItemWriter] 정산 엔티티를 chunk 단위로 적재. + * (대용량 INSERT 최적화는 오후 세션에서 JdbcBatchItemWriter / Bulk Insert 로 다룬다.) + */ + @Bean + public JpaItemWriter settlementWriter(EntityManagerFactory emf) { + return new JpaItemWriterBuilder() + .entityManagerFactory(emf) + .build(); + } + + @Bean + public Step settlementStep(JobRepository jobRepository, + PlatformTransactionManager transactionManager, + JpaPagingItemReader settlementOrderReader, + OrderToSettlementProcessor settlementProcessor, + JpaItemWriter settlementWriter) { + return new StepBuilder("settlementStep", jobRepository) + .chunk(chunkSize) + .reader(settlementOrderReader) + .processor(settlementProcessor) + .writer(settlementWriter) + .transactionManager(transactionManager) + .build(); + } + + @Bean + public Job settlementJob(JobRepository jobRepository, Step settlementStep) { + return new JobBuilder(JOB_NAME, jobRepository) + .start(settlementStep) + .build(); + } +} diff --git a/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/batch/SettlementParallelJobConfig.java b/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/batch/SettlementParallelJobConfig.java new file mode 100644 index 0000000..a3c8c43 --- /dev/null +++ b/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/batch/SettlementParallelJobConfig.java @@ -0,0 +1,71 @@ +package com.growmighty.lectures.firstday.settlement.batch; + +import com.growmighty.lectures.firstday.settlement.read.Order; +import com.growmighty.lectures.firstday.settlement.read.OrderStatus; +import com.growmighty.lectures.firstday.settlement.domain.Settlement; +import jakarta.persistence.EntityManagerFactory; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.Step; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.infrastructure.item.database.JpaItemWriter; +import org.springframework.batch.infrastructure.item.database.JpaPagingItemReader; +import org.springframework.batch.infrastructure.item.database.builder.JpaPagingItemReaderBuilder; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +import java.util.Map; + + +@Configuration +public class SettlementParallelJobConfig { + + @Value("${settlement.batch.chunk-size:1000}") + private int chunkSize; + + // 멀티스레드에서는 Reader가 하나라서 Reader 병목이 있었다. + // Partitioning은 아예 여러 리더를 만들어서 읽기 자체를 병렬화한다. + // 워커 Reader : 자기 파티션의 id 범위에 해당하는 PAID 주문만 페이지로 읽는다. + @Bean + @StepScope // Partition마다 새 Reader를 Bean을 이용해 생성한다. + public JpaPagingItemReader settlementWorkerReader( + EntityManagerFactory emf, + // 여기서 Partitioner가 넣어준 minId, maxId를 꺼낸다. + @Value("#{stepExecutionContext['minId']}") Long minId, + @Value("#{stepExecutionContext['maxId']}") Long maxId) { + return new JpaPagingItemReaderBuilder() + .name("settlementWorkerReader") + .entityManagerFactory(emf) + // JPQL 이용, 정해진 id만큼의 정보를 읽는다. + .queryString("SELECT o FROM Order o " + + "WHERE o.status = :status AND o.id BETWEEN :minId AND :maxId " + + "ORDER BY o.id ASC") + .parameterValues(Map.of( + "status", OrderStatus.PAID, + "minId", minId, + "maxId", maxId + )) + .pageSize(chunkSize) + .build(); + } + + // 받은 범위를 Chunk 지향을 정산한다. + @Bean + public Step settlementWorkerStep( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + @Qualifier("settlementWorkerReader") JpaPagingItemReader settlementWorkerReader, + OrderToSettlementProcessor settlementProcessor, + JpaItemWriter settlementWriter) { + return new StepBuilder("settlementWorkerReader", jobRepository) + .chunk(chunkSize) + .reader(settlementWorkerReader) + .processor(settlementProcessor) // Order를 Settlement로 변환 + .writer(settlementWriter) + .transactionManager(transactionManager) // Chunk마다 커밋한다. + .build(); + } +} diff --git a/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/batch/SettlementParallelJobFactory.java b/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/batch/SettlementParallelJobFactory.java new file mode 100644 index 0000000..7abf19f --- /dev/null +++ b/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/batch/SettlementParallelJobFactory.java @@ -0,0 +1,89 @@ +package com.growmighty.lectures.firstday.settlement.batch; + + +import com.growmighty.lectures.firstday.settlement.domain.Settlement; +import com.growmighty.lectures.firstday.settlement.read.Order; +import com.growmighty.lectures.firstday.settlement.read.OrderStatus; +import jakarta.persistence.EntityManagerFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.job.Job; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.Step; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.infrastructure.item.database.JpaItemWriter; +import org.springframework.batch.infrastructure.item.database.JpaPagingItemReader; +import org.springframework.batch.infrastructure.item.database.builder.JpaPagingItemReaderBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.task.SimpleAsyncTaskExecutor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.PlatformTransactionManager; + +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class SettlementParallelJobFactory { + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final OrderToSettlementProcessor settlementProcessor; + private final JpaItemWriter settlementWriter; + private final Step settlementWorkerStep; + private final EntityManagerFactory entityManagerFactory; + private final JdbcTemplate jdbcTemplate; + + @Value("${settlement.batch.chunk-size:1000}") + private int chunkSize; + + // Chunk들을 threads 개수만큼 동시에 각자 나눠 통째로 처리한다. + @SuppressWarnings("removal") + public Job multiThreadedJob(int threads) { + // 멀티스레드를 실행할 Executor를 생성한다. 실행되는 스레드 이름은 mt-worker-1 ... + SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor("mt-worker-"); + executor.setConcurrencyLimit(threads); // 최대 threads 개수만큼의 thread를 생성한다. + + Step step = new StepBuilder("settlementMultiThreadedStep", jobRepository) + .chunk(chunkSize, transactionManager) // transactionManager 과정을 하나의 chunk로 만든다. + .reader(multiThreadedReader()) // Order를 읽어온다. + .processor(settlementProcessor) // Order를 로직을 통과한 Settlement로 만든다. + .writer(settlementWriter) // Settlement를 DB에 저장한다. + .taskExecutor(executor) // Chunk를 여러 스레드에서 처리하라. + .build(); // Step 객체 생성 완료. + + return new JobBuilder("settlementMultiThreadedJob", jobRepository) + .start(step) + .build(); + } + + // 진짜 정답은 이것이다. + public Job partitionedJob(int gridSize) { + SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor("part-worker-"); + executor.setConcurrencyLimit(gridSize); + + Step masterStep = new StepBuilder("settlementPartitionedStep", jobRepository) + .partitioner("settlementWorkerStep", new OrderRangePartitioner(jdbcTemplate)) + .step(settlementWorkerStep) + .gridSize(gridSize) + .taskExecutor(executor) + .build(); + + return new JobBuilder("settlementPArtitionedJob", jobRepository) + .start(masterStep) + .build(); + } + + // PAID 상태인 Order를 페이지 단위로 읽어오는 Reader 생성. + private JpaPagingItemReader multiThreadedReader() { + return new JpaPagingItemReaderBuilder() + .name("settlementMultiThreadedReader") + .entityManagerFactory(entityManagerFactory) // JPA를 활용한다. + // JPQL를 활용하여 SQL 쿼리를 직접 작성한다. 정렬은 필수다. + .queryString("SELECT o FROM Order o WHERE o.status = :status ORDER BY o.id ASC") + .parameterValues(Map.of("status", OrderStatus.PAID)) // SQL 구문의 status에 넣는다. + .pageSize(chunkSize) // 페이지 사이즈는 청크 사이즈로 정의한다. + .saveState(false) // 멀티스레드이므로 상태 저장을 끈다. 재시작 기능을 포기하는 대신 멀티스레드 안전성을 얻는다. + .build(); + } +} diff --git a/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/domain/Settlement.java b/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/domain/Settlement.java new file mode 100644 index 0000000..49617dd --- /dev/null +++ b/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/domain/Settlement.java @@ -0,0 +1,107 @@ +package com.growmighty.lectures.firstday.settlement.domain; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDateTime; + +/* + 주문 금액(orderAmount) = 수수료(feeAmount) + 지급액(payoutAmount)가 + 단 1원의 오차도 허용해선 안 된다. 수수료는 반올림으로 계산하되, 지급액은 반올림하지 않는다. + 주문금액 - 수수료의 결과로 나머지를 취한다. + order_id에 Unique 제약을 두어 같은 주문이 두 번 정산되지 않도록 막는다. + */ +@Entity +@Table( + name = "settlements", + uniqueConstraints = @UniqueConstraint(name = "uk_settlement_order_id", columnNames = "order_id") +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Settlement { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** 멱등성 키: 한 주문은 한 번만 정산된다. */ + @Column(name = "order_id", nullable = false) + private Long orderId; + + @Column(name = "payment_id", nullable = false) + private Long paymentId; + + /** 정산 대상 주문 금액 (= 결제 금액) */ + @Column(name = "order_amount", nullable = false) + private BigDecimal orderAmount; + + /** 플랫폼 수수료율 (예: 0.030 = 3%) */ + @Column(name = "fee_rate", nullable = false) + private BigDecimal feeRate; + + /** 수수료 = round(orderAmount * feeRate) */ + @Column(name = "fee_amount", nullable = false) + private BigDecimal feeAmount; + + /** 판매자 지급액 = orderAmount - feeAmount (반올림하지 않은 나머지) */ + @Column(name = "payout_amount", nullable = false) + private BigDecimal payoutAmount; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private SettlementStatus status; + + @Column(name = "settled_at", nullable = false) + private LocalDateTime settledAt; + + private Settlement(Long orderId, Long paymentId, BigDecimal orderAmount, BigDecimal feeRate) { + if (orderId == null || paymentId == null) { + throw new IllegalArgumentException("정산에는 주문/결제 식별자가 필요합니다."); + } + if (orderAmount == null || orderAmount.compareTo(BigDecimal.ZERO) <= 0) { + throw new IllegalArgumentException("정산 대상 금액은 0원보다 커야 합니다. 입력값: " + orderAmount); + } + this.orderId = orderId; + this.paymentId = paymentId; + this.orderAmount = orderAmount; + this.feeRate = feeRate; + + // 수수료만 반올림(원 단위), 지급액은 나머지로 계산 → 1원 무결성 보장 + this.feeAmount = orderAmount.multiply(feeRate).setScale(0, RoundingMode.HALF_UP); + this.payoutAmount = orderAmount.subtract(this.feeAmount); + + this.status = SettlementStatus.COMPLETED; + this.settledAt = LocalDateTime.now(); + + verifyIntegrity(); + } + + /** + * 주문/결제 정보로부터 정산 1건을 생성한다. + * + * @param orderId 정산 대상 주문 id + * @param paymentId 해당 주문의 결제 id + * @param orderAmount 정산 대상 금액 + * @param feeRate 수수료율 (예: 0.03) + */ + public static Settlement of(Long orderId, Long paymentId, BigDecimal orderAmount, BigDecimal feeRate) { + return new Settlement(orderId, paymentId, orderAmount, feeRate); + } + + /** + * 1원의 무결성 검증: 수수료 + 지급액 == 주문금액 이어야 한다. + * 단 1원이라도 어긋나면 정산을 만들지 않고 즉시 실패시킨다. + */ + public void verifyIntegrity() { + BigDecimal sum = feeAmount.add(payoutAmount); + if (sum.compareTo(orderAmount) != 0) { + throw new IllegalStateException( + "정산 무결성 위반: 수수료(%s) + 지급액(%s) = %s, 주문금액(%s) 과 일치하지 않습니다." + .formatted(feeAmount, payoutAmount, sum, orderAmount)); + } + } +} diff --git a/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/domain/SettlementRepository.java b/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/domain/SettlementRepository.java new file mode 100644 index 0000000..dc85a1f --- /dev/null +++ b/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/domain/SettlementRepository.java @@ -0,0 +1,16 @@ +package com.growmighty.lectures.firstday.settlement.domain; + +import java.util.Optional; + +public interface SettlementRepository { + Settlement save(Settlement settlement); + + Optional findByOrderId(Long orderId); + + // 멱등성 체크: 이미 정산된 주문인지 확인 (재시작 시 중복 정산 방지) + boolean existsByOrderId(Long orderId); + + long count(); + + void deleteAll(); +} diff --git a/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/domain/SettlementStatus.java b/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/domain/SettlementStatus.java new file mode 100644 index 0000000..bec99e7 --- /dev/null +++ b/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/domain/SettlementStatus.java @@ -0,0 +1,7 @@ +package com.growmighty.lectures.firstday.settlement.domain; + +public enum SettlementStatus { + PENDING, // 확정 전, 정산 대상 + COMPLETED, // 정산 확정 완료 + FAILED // 정산 처리 실패 +} diff --git a/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/infrastructure/SettlementJpaRepository.java b/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/infrastructure/SettlementJpaRepository.java new file mode 100644 index 0000000..380cf24 --- /dev/null +++ b/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/infrastructure/SettlementJpaRepository.java @@ -0,0 +1,12 @@ +package com.growmighty.lectures.firstday.settlement.infrastructure; + +import com.growmighty.lectures.firstday.settlement.domain.Settlement; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface SettlementJpaRepository extends JpaRepository { + Optional findByOrderId(Long orderId); + + boolean existsByOrderId(Long orderId); +} diff --git a/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/infrastructure/SettlementRepositoryAdapter.java b/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/infrastructure/SettlementRepositoryAdapter.java new file mode 100644 index 0000000..cc1001e --- /dev/null +++ b/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/infrastructure/SettlementRepositoryAdapter.java @@ -0,0 +1,39 @@ +package com.growmighty.lectures.firstday.settlement.infrastructure; + +import com.growmighty.lectures.firstday.settlement.domain.Settlement; +import com.growmighty.lectures.firstday.settlement.domain.SettlementRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class SettlementRepositoryAdapter implements SettlementRepository { + private final SettlementJpaRepository jpaRepository; + + @Override + public Settlement save(Settlement settlement) { + return jpaRepository.save(settlement); + } + + @Override + public Optional findByOrderId(Long orderId) { + return jpaRepository.findByOrderId(orderId); + } + + @Override + public boolean existsByOrderId(Long orderId) { + return jpaRepository.existsByOrderId(orderId); + } + + @Override + public long count() { + return jpaRepository.count(); + } + + @Override + public void deleteAll() { + jpaRepository.deleteAllInBatch(); + } +} diff --git a/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/presentation/SettlementController.java b/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/presentation/SettlementController.java new file mode 100644 index 0000000..0d7ee80 --- /dev/null +++ b/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/presentation/SettlementController.java @@ -0,0 +1,94 @@ +package com.growmighty.lectures.firstday.settlement.presentation; + +import com.growmighty.lectures.firstday.common.response.ApiResponse; + +import com.growmighty.lectures.firstday.settlement.application.NaiveSettlementService; +import com.growmighty.lectures.firstday.settlement.application.SettlementBatchService; +import com.growmighty.lectures.firstday.settlement.application.dto.SettleReport; +import com.growmighty.lectures.firstday.settlement.domain.SettlementRepository; +import com.growmighty.lectures.firstday.settlement.read.OrderRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +// 정산 실습용 트리거 엔드포인트의 모임 +@RestController +@RequiredArgsConstructor +@RequestMapping("/settlements") +public class SettlementController { + + private static final long MB = 1024 * 1024; + + private final NaiveSettlementService naiveSettlementService; + private final SettlementBatchService settlementBatchService; + private final SettlementRepository settlementRepository; + private final OrderRepository orderRepository; + + /** + * [데모1/데모2] limit 이 없으면 findAll 전량 적재(OOM 데모), + * limit 이 있으면 그 수만큼만 메모리에 쌓으며 정산(추세 관찰). + */ + @PostMapping("/naive") + public ApiResponse settleNaive(@RequestParam(required = false) Integer limit) { + SettleReport report = (limit == null) + ? naiveSettlementService.settleAll() + : naiveSettlementService.settleUpTo(limit); + return ApiResponse.ok(report); + } + + // failAt까지 정상 실행 후 강제 에러 + @PostMapping("/batch") + public ApiResponse settleBatch(@RequestParam(required = false) Double failAt) { + SettleReport report = (failAt ==null) + ? settlementBatchService.run() + : settlementBatchService.runFailing(failAt); + + return ApiResponse.ok(report); + } + + // 재시작 시 실패한 인스턴스부터 이어서 재개한다. + @PostMapping("/batch/restart") + public ApiResponse restartBatch(@RequestParam(defaultValue = "1") long runId, + @RequestParam(required = false) Double failAt) { + return ApiResponse.ok(settlementBatchService.runRestartable(runId, failAt)); + } + + // 단순히 스레드만 늘리는 순진한 가속 + // threads : 동시 스레드 수 + @PostMapping("/batch/multi-threaded") + public ApiResponse settleMultiThreaded(@RequestParam(required = false) Integer threads) { + return ApiResponse.ok(settlementBatchService.runMultiThreaded(threads)); + } + + // 멀티스레드에서 재시작 경우 재현해보기. 같은 runId로 다시 호출해본다. 과연 멱등성이 성립될까? + @PostMapping("/batch/multi-threaded/restart") + public ApiResponse restartMultiThreaded(@RequestParam(defaultValue = "1") long runId, + @RequestParam(required = false) Double failAt) { + return ApiResponse.ok(settlementBatchService.runMultiThreadedRestartable(runId, failAt)); + } + + // 이번에는 gridSize개로 나눠 워커마다 전용 Reader로 병렬 처리. + @PostMapping("/batch/partitioned") + public ApiResponse settlePartitioned(@RequestParam(required = false) Integer gridSize) { + return ApiResponse.ok(settlementBatchService.runPartitioned(gridSize)); + } + + @GetMapping("/status") + public ApiResponse> status() { + Runtime rt = Runtime.getRuntime(); + long usedMb = (rt.totalMemory() - rt.freeMemory()) / MB; + long maxMb = rt.maxMemory() / MB; + return ApiResponse.ok(Map.of( + "orderCount", orderRepository.count(), + "settlementCount", settlementRepository.count(), + "heapUsedMb", usedMb, + "heapMaxMb", maxMb)); + } + + @DeleteMapping + public ApiResponse clear() { + settlementRepository.deleteAll(); + return ApiResponse.ok(); + } +} diff --git a/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/read/Money.java b/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/read/Money.java new file mode 100644 index 0000000..8ab8506 --- /dev/null +++ b/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/read/Money.java @@ -0,0 +1,18 @@ +package com.growmighty.lectures.firstday.settlement.read; + +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +/** + * 정산 read-model 전용 금액 값 타입. 읽기만 하므로 getValue() 만 있으면 충분하다! + */ +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Money { + private BigDecimal value; +} diff --git a/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/read/Order.java b/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/read/Order.java new file mode 100644 index 0000000..8b34a87 --- /dev/null +++ b/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/read/Order.java @@ -0,0 +1,53 @@ +package com.growmighty.lectures.firstday.settlement.read; + +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/* + 정산 서비스가 소유한 주문 + 서비스가 분리되면서 정산 DB 는 주문 DB 와 물리적으로 분리됐다. + 주문의 쓰기 모델(items 등)은 정산에 필요 없으므로 담지 않는다 — 필요한 만큼만 읽는다. + */ +@Entity +@Table(name = "orders") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Order { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long userId; + + @Column + private Long paymentId; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "items_amount", nullable = false)) + private Money itemsAmount; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "shipping_fee", nullable = false)) + private Money shippingFee; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "total_amount", nullable = false)) + private Money totalAmount; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private OrderStatus status; +} diff --git a/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/read/OrderJpaRepository.java b/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/read/OrderJpaRepository.java new file mode 100644 index 0000000..1f16b87 --- /dev/null +++ b/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/read/OrderJpaRepository.java @@ -0,0 +1,6 @@ +package com.growmighty.lectures.firstday.settlement.read; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface OrderJpaRepository extends JpaRepository { +} diff --git a/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/read/OrderRepository.java b/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/read/OrderRepository.java new file mode 100644 index 0000000..8148f10 --- /dev/null +++ b/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/read/OrderRepository.java @@ -0,0 +1,15 @@ +package com.growmighty.lectures.firstday.settlement.read; + +import java.util.List; + +/** + * 정산이 주문 read-model 을 읽기 위한 저장소 계약. 정산은 조회만 한다. + */ +public interface OrderRepository { + List findAll(); + + /** 페이지 단위 조회 (정산 데모에서 "조금씩 읽기"에 사용) */ + List findPage(int page, int size); + + long count(); +} diff --git a/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/read/OrderRepositoryAdapter.java b/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/read/OrderRepositoryAdapter.java new file mode 100644 index 0000000..4ba2567 --- /dev/null +++ b/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/read/OrderRepositoryAdapter.java @@ -0,0 +1,28 @@ +package com.growmighty.lectures.firstday.settlement.read; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class OrderRepositoryAdapter implements OrderRepository { + private final OrderJpaRepository jpaRepository; + + @Override + public List findAll() { + return jpaRepository.findAll(); + } + + @Override + public List findPage(int page, int size) { + return jpaRepository.findAll(PageRequest.of(page, size)).getContent(); + } + + @Override + public long count() { + return jpaRepository.count(); + } +} diff --git a/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/read/OrderStatus.java b/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/read/OrderStatus.java new file mode 100644 index 0000000..6ee2c3f --- /dev/null +++ b/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/read/OrderStatus.java @@ -0,0 +1,10 @@ +package com.growmighty.lectures.firstday.settlement.read; + + +// 정산 서비스가 읽기 위해 소유한 주문 상태 read-model. +// order-service 의 OrderStatus 와 값은 같지만, 서비스가 분리된 지금은 별개의 타입이다. +public enum OrderStatus { + CREATED, + PAID, + CANCELLED +} diff --git a/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/payment/Payment.java b/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/read/Payment.java similarity index 50% rename from src/main/java/com/growmighty/lectures/firstday/tangledmonolith/payment/Payment.java rename to settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/read/Payment.java index 97216e8..aba9065 100644 --- a/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/payment/Payment.java +++ b/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/read/Payment.java @@ -1,18 +1,26 @@ -package com.growmighty.lectures.firstday.tangledmonolith.payment; +package com.growmighty.lectures.firstday.settlement.read; -import jakarta.persistence.*; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; -import lombok.Setter; import java.math.BigDecimal; +/* + 정산 read-model 의 결제 테이블 매핑. +*/ @Entity @Table(name = "payments") @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Payment { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @@ -20,15 +28,6 @@ public class Payment { @Column(nullable = false) private BigDecimal amount; - @Setter - @Enumerated(EnumType.STRING) @Column(nullable = false) - private PaymentStatus status; - - public static Payment ready(BigDecimal amount) { - Payment payment = new Payment(); - payment.amount = amount; - payment.status = PaymentStatus.READY; - return payment; - } + private String status; } diff --git a/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/support/HeapMonitor.java b/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/support/HeapMonitor.java new file mode 100644 index 0000000..3721f3f --- /dev/null +++ b/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/support/HeapMonitor.java @@ -0,0 +1,79 @@ +package com.growmighty.lectures.firstday.settlement.support; + +import lombok.extern.slf4j.Slf4j; + +// 백그라운드 스레드로 일정 주기마다 힙 사용량을 로그로 찍는 클래스 +@Slf4j +public final class HeapMonitor implements AutoCloseable { + + private static final long MB = 1024 * 1024; + + private final String label; + private final long intervalMs; + private final long startedAt; + private final Thread thread; + + private volatile boolean running = true; + private volatile long peakUsedMb = 0; + + private HeapMonitor(String label, long intervalMs) { + this.label = label; + this.intervalMs = intervalMs; + this.startedAt = System.currentTimeMillis(); + this.thread = new Thread(this::loop, "heap-monitor-" + label); + this.thread.setDaemon(true); + } + + public static HeapMonitor start(String label, long intervalMs) { + HeapMonitor monitor = new HeapMonitor(label, intervalMs); + monitor.thread.start(); + return monitor; + } + + private void loop() { + while (running) { + sample(); + try { + Thread.sleep(intervalMs); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + } + } + + private void sample() { + Runtime rt = Runtime.getRuntime(); + long max = rt.maxMemory() / MB; // -Xmx + long used = (rt.totalMemory() - rt.freeMemory()) / MB; + long elapsed = System.currentTimeMillis() - startedAt; + if (used > peakUsedMb) { + peakUsedMb = used; + } + int percent = max > 0 ? (int) (used * 100 / max) : 0; + log.warn("[mem:{}] used={}MB / max={}MB ({}%) {} elapsed={}ms", + label, used, max, percent, bar(percent), elapsed); + } + + /** used 비율을 막대그래프로 (콘솔에서 차오르는 게 보이도록) */ + private static String bar(int percent) { + int filled = Math.min(20, percent / 5); + return "[" + "#".repeat(filled) + "-".repeat(20 - filled) + "]"; + } + + public long peakUsedMb() { + return peakUsedMb; + } + + public long maxHeapMb() { + return Runtime.getRuntime().maxMemory() / MB; + } + + @Override + public void close() { + running = false; + thread.interrupt(); + sample(); // 종료 직전 마지막 스냅샷 + log.warn("[mem:{}] 종료. peakUsed={}MB / max={}MB", label, peakUsedMb, maxHeapMb()); + } +} diff --git a/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/support/SettlementDataSeeder.java b/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/support/SettlementDataSeeder.java new file mode 100644 index 0000000..c53da49 --- /dev/null +++ b/settlement-service/src/main/java/com/growmighty/lectures/firstday/settlement/support/SettlementDataSeeder.java @@ -0,0 +1,104 @@ +package com.growmighty.lectures.firstday.settlement.support; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.core.annotation.Order; +import org.springframework.jdbc.core.BatchPreparedStatementSetter; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.sql.PreparedStatement; +import java.sql.SQLException; + +// 대용량 정산 실습을 위한 시드 데이터 생성기 +@Slf4j +@Component +@Order(0) // DataInitializer 보다 먼저 실행되도록 (순서 자체는 무관하지만 로그 가독성) +@RequiredArgsConstructor +@ConditionalOnProperty(name = "settlement.seed.enabled", havingValue = "true") +public class SettlementDataSeeder implements CommandLineRunner { + + private final JdbcTemplate jdbcTemplate; + + @Value("${settlement.seed.count:1000000}") + private long count; + + @Value("${settlement.seed.batch-size:10000}") + private int batchSize; + + @Override + public void run(String... args) { + + log.warn("[SEED] 대용량 시드 시작: count={}, batchSize={}", count, batchSize); + long startedAt = System.currentTimeMillis(); + + long inserted = 0; + while (inserted < count) { + final long base = inserted; + final int rows = (int) Math.min(batchSize, count - inserted); + + // 1) 결제 데이터 + jdbcTemplate.batchUpdate( + "INSERT INTO payments (id, amount, status) VALUES (?, ?, ?)", + new BatchPreparedStatementSetter() { + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + long id = base + i + 1; + ps.setLong(1, id); + ps.setBigDecimal(2, amountOf(id)); + ps.setString(3, "PAID"); + } + + @Override + public int getBatchSize() { + return rows; + } + }); + + // 2) 주문 데이터 (payment_id = id 로 1:1 매핑, 배송비 0, 전액 상품금액) + jdbcTemplate.batchUpdate( + "INSERT INTO orders (id, user_id, payment_id, items_amount, shipping_fee, total_amount, status) " + + "VALUES (?, ?, ?, ?, ?, ?, ?)", + new BatchPreparedStatementSetter() { + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + long id = base + i + 1; + BigDecimal amount = amountOf(id); + ps.setLong(1, id); + ps.setLong(2, (id % 1000) + 1); // user_id 분산 + ps.setLong(3, id); // payment_id + ps.setBigDecimal(4, amount); // items_amount + ps.setBigDecimal(5, BigDecimal.ZERO); // shipping_fee + ps.setBigDecimal(6, amount); // total_amount + ps.setString(7, "PAID"); + } + + @Override + public int getBatchSize() { + return rows; + } + }); + + inserted += rows; + if (inserted % (batchSize * 10L) == 0 || inserted == count) { + log.warn("[SEED] 진행률 {}/{} ({}%)", inserted, count, inserted * 100 / count); + } + } + + long elapsed = System.currentTimeMillis() - startedAt; + log.warn("[SEED] 완료: orders={}건, payments={}건, 소요시간={}ms", count, count, elapsed); + } + + /** + * 3% 로 나누어 떨어지지 않는 금액을 만들기 위한 의도적으로 들쭉날쭉한 금액. + * 1,000 ~ 약 100,000 사이. + */ + private static BigDecimal amountOf(long id) { + long won = 1_000 + (id * 37) % 99_000; + return BigDecimal.valueOf(won); + } +} diff --git a/settlement-service/src/main/resources/application.properties b/settlement-service/src/main/resources/application.properties new file mode 100644 index 0000000..4fc82f6 --- /dev/null +++ b/settlement-service/src/main/resources/application.properties @@ -0,0 +1,35 @@ +spring.application.name=cart-service +server.port=8086 + +# 서비스마다 자기만의 DB를 갖는다 (Database per Service) +# 정산은 주문 read-model(orders/payments)과 정산 결과(settlements)를 자기 DB에 둔다. +spring.datasource.url=jdbc:h2:mem:settlementdb;DB_CLOSE_DELAY=-1 +spring.h2.console.enabled=true + +spring.jpa.hibernate.ddl-auto=create +spring.jpa.properties.hibernate.format_sql=true +spring.jpa.show-sql=true + +# SQL 바인딩 파라미터까지 로그로 출력 +logging.level.org.hibernate.orm.jdbc.bind=trace + +# --- Spring Batch --- +# 부팅 시 등록된 Job 자동 실행 끄기 (정산 Job 은 컨트롤러에서 수동 실행) +spring.batch.job.enabled=false +# H2 에 배치 메타데이터 테이블(BATCH_JOB_*, BATCH_STEP_*) 자동 생성 +spring.batch.jdbc.initialize-schema=always +# 정산 Job 의 chunk 크기 (= Reader pageSize) +settlement.batch.chunk-size=1000 + +# --- [Step4] 병렬 처리 (멀티스레드 / 파티셔닝) --- +spring.datasource.hikari.maximum-pool-size=20 +spring.datasource.hikari.connection-timeout=3000 +settlement.batch.thread-count=8 +settlement.batch.grid-size=8 + +# --- 대용량 시드 데이터 (정산 배치 실습용) --- +# true 로 켜면 부팅 시 이 서비스의 orders/payments 테이블에 대량 데이터를 적재한다. +# ./gradlew :settlement-service:bootRun --args='--settlement.seed.enabled=true --settlement.seed.count=1000000 --spring.jpa.show-sql=false' +settlement.seed.enabled=false +settlement.seed.count=1000000 +settlement.seed.batch-size=10000 diff --git a/settlement-service/src/test/SettlementPartitionIntegrationTest.java b/settlement-service/src/test/SettlementPartitionIntegrationTest.java new file mode 100644 index 0000000..a4fb2e8 --- /dev/null +++ b/settlement-service/src/test/SettlementPartitionIntegrationTest.java @@ -0,0 +1,98 @@ +package com.growmighty.lectures.firstday.settlement.batch; + +import com.growmighty.lectures.firstday.settlement.application.SettlementBatchService; +import com.growmighty.lectures.firstday.settlement.application.dto.SettleReport; +import com.growmighty.lectures.firstday.settlement.domain.SettlementRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * [Step4-4] 파티셔닝 통합 테스트. + * + *

핵심 검증: 워커마다 겹치지 않는 id 범위를 전용 Reader 로 받아 병렬로 처리해도 + * 정확히 전체 건수만큼, 중복 0 으로 정산된다. 멱등 재실행도 안전하다. + * + *

chunk-size 를 10 으로 좁혀 파티션마다 여러 chunk 로 쪼개지게 한다(병렬성을 실제로 태운다). + * 처리량(4-2 멀티스레드 대비 확장성)과 재시작 보존은 하드웨어/타이밍에 의존하는 성능 특성이라 + * 단위 테스트가 아니라 라이브 세션의 측정으로 보여준다(여기선 정확성·멱등성만 단정한다). + */ +@SpringBootTest +@TestPropertySource(properties = { + "settlement.batch.chunk-size=10", + "spring.jpa.show-sql=false" +}) +class SettlementPartitionIntegrationTest { + + private static final int TOTAL = 200; + + @Autowired + private SettlementBatchService batchService; + @Autowired + private SettlementRepository settlementRepository; + @Autowired + private JdbcTemplate jdbc; + + @BeforeEach + void seed() { + settlementRepository.deleteAll(); + jdbc.update("DELETE FROM orders"); + jdbc.update("DELETE FROM payments"); + for (long id = 1; id <= TOTAL; id++) { + BigDecimal amount = BigDecimal.valueOf(1_000 + (id * 37) % 99_000); + jdbc.update("INSERT INTO payments (id, amount, status) VALUES (?, ?, 'PAID')", id, amount); + jdbc.update("INSERT INTO orders (id, user_id, payment_id, items_amount, shipping_fee, total_amount, status) " + + "VALUES (?, ?, ?, ?, 0, ?, 'PAID')", id, (id % 1000) + 1, id, amount, amount); + } + } + + @Test + @DisplayName("파티셔닝: 범위를 4개로 나눠 병렬 처리해도 정확히 전체 건수, 중복 0") + void partitioned_run_settles_all_without_duplicates() { + SettleReport report = batchService.runPartitioned(4); + + assertThat(report.status()).isEqualTo("COMPLETED"); + assertThat(report.readCount()).isEqualTo(TOTAL); // 워커 합산 = 전체 + assertThat(report.settledCount()).isEqualTo(TOTAL); // 전부 정산 + assertThat(settlementRepository.count()).isEqualTo(TOTAL); + assertThat(distinctOrderIds()).isEqualTo(TOTAL); // 겹침 없음 + } + + @Test + @DisplayName("파티셔닝도 멱등: 다시 돌리면 이미 정산된 건은 스킵하고 중복을 만들지 않는다") + void partitioned_run_is_idempotent() { + batchService.runPartitioned(4); + + SettleReport second = batchService.runPartitioned(4); + assertThat(second.status()).isEqualTo("COMPLETED"); + assertThat(second.settledCount()).isZero(); // 새로 만든 정산 없음 + assertThat(second.skippedCount()).isEqualTo(TOTAL); // 전부 멱등 스킵 + + assertThat(settlementRepository.count()).isEqualTo(TOTAL); + assertThat(distinctOrderIds()).isEqualTo(TOTAL); + } + + @Test + @DisplayName("정산 대상이 없으면(빈 파티션) 깨지지 않고 0건으로 정상 종료한다") + void partitioned_run_handles_no_target() { + jdbc.update("DELETE FROM orders"); // 대상 주문 제거 → Partitioner 가 빈 범위 파티션 1개 생성 + + SettleReport report = batchService.runPartitioned(4); + + assertThat(report.status()).isEqualTo("COMPLETED"); + assertThat(report.settledCount()).isZero(); + assertThat(settlementRepository.count()).isZero(); + } + + private long distinctOrderIds() { + return jdbc.queryForObject("SELECT COUNT(DISTINCT order_id) FROM settlements", Long.class); + } +} diff --git a/settlement-service/src/test/SettlementRestartIntegrationTest.java b/settlement-service/src/test/SettlementRestartIntegrationTest.java new file mode 100644 index 0000000..40aa01a --- /dev/null +++ b/settlement-service/src/test/SettlementRestartIntegrationTest.java @@ -0,0 +1,110 @@ +package com.growmighty.lectures.firstday.batch; + +import com.growmighty.lectures.firstday.settlement.application.SettlementBatchService; +import com.growmighty.lectures.firstday.settlement.application.dto.SettleReport; +import com.growmighty.lectures.firstday.settlement.domain.SettlementRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; + + +/** + * [Step3] 멱등성/재시작 통합 테스트. + * + *

chunk-size 를 10 으로 좁혀, 주문 100건이 10개 chunk 로 나뉘게 한다. + * 그래야 "50% 지점 실패 → 직전 5 chunk(50건)는 커밋, 6번째 chunk 는 롤백" 이 깔끔히 재현된다. + * (운영 기본값 1000 이면 100건이 단일 chunk 라 50%가 통째로 롤백돼 데모가 안 된다.) + */ +@SpringBootTest +@TestPropertySource(properties = { + "settlement.batch.chunk-size=10", + "spring.jpa.show-sql=false" +}) +class SettlementRestartIntegrationTest { + + private static final int TOTAL = 100; + + @Autowired + private SettlementBatchService batchService; + @Autowired + private SettlementRepository settlementRepository; + @Autowired + private JdbcTemplate jdbc; + + @BeforeEach + void seed() { + // 깨끗한 출발점: 정산/주문/결제 비우고 PAID 주문 100건 적재 (payment_id = id 로 1:1) + settlementRepository.deleteAll(); + jdbc.update("DELETE FROM orders"); + jdbc.update("DELETE FROM payments"); + for (long id = 1; id <= TOTAL; id++) { + BigDecimal amount = BigDecimal.valueOf(1_000 + (id * 37) % 99_000); + jdbc.update("INSERT INTO payments (id, amount, status) VALUES (?, ?, 'PAID')", id, amount); + jdbc.update("INSERT INTO orders (id, user_id, payment_id, items_amount, shipping_fee, total_amount, status) " + + "VALUES (?, ?, ?, ?, 0, ?, 'PAID')", id, (id % 1000) + 1, id, amount, amount); + } + } + + @Test + @DisplayName("멱등성: 50%에서 실패해도 다시 실행하면 이미 정산된 건은 건너뛰고 나머지만 처리해 총 100건") + void idempotent_rerun_skips_already_settled() { + // 1차: 50%에서 강제 실패 (새 인스턴스) + SettleReport failed = batchService.runFailing(0.5); + assertThat(failed.status()).isEqualTo("FAILED"); + assertThat(settlementRepository.count()).isEqualTo(50); // 직전 5 chunk 만 커밋 + + // 2차: 정상 재실행 (새 인스턴스) → 이미 정산된 50건은 스킵, 남은 50건만 정산 + SettleReport recovered = batchService.run(); + assertThat(recovered.status()).isEqualTo("COMPLETED"); + assertThat(recovered.skippedCount()).isEqualTo(50); // 멱등 스킵된 건수 + assertThat(recovered.settledCount()).isEqualTo(50); // 새로 정산한 건수 + + // 최종: 정확히 100건, 중복 0 (order_id 유니크 + existsByOrderId) + assertThat(settlementRepository.count()).isEqualTo(TOTAL); + assertThat(distinctOrderIds()).isEqualTo(TOTAL); + } + + @Test + @DisplayName("재시작: 같은 runId 로 다시 실행하면 실패 지점부터 이어서 처리해 총 100건") + void native_restart_resumes_from_checkpoint() { + // 1차: 같은 runId 로 50%에서 실패 + SettleReport failed = batchService.runRestartable(1L, 0.5); + assertThat(failed.status()).isEqualTo("FAILED"); + assertThat(settlementRepository.count()).isEqualTo(50); + + // 2차: 같은 runId, 장애 해제 → Spring Batch 가 실패한 인스턴스를 이어서 재개 + SettleReport resumed = batchService.runRestartable(1L, null); + assertThat(resumed.status()).isEqualTo("COMPLETED"); + + // 최종: 정확히 100건, 중복 0 + assertThat(settlementRepository.count()).isEqualTo(TOTAL); + assertThat(distinctOrderIds()).isEqualTo(TOTAL); + } + + @Test + @DisplayName("정상 실행을 두 번 해도(멱등) 중복 정산이 생기지 않는다") + void run_twice_is_idempotent() { + SettleReport first = batchService.run(); + assertThat(first.status()).isEqualTo("COMPLETED"); + assertThat(first.settledCount()).isEqualTo(TOTAL); + + SettleReport second = batchService.run(); + assertThat(second.status()).isEqualTo("COMPLETED"); + assertThat(second.settledCount()).isZero(); // 새로 만든 건 없음 + assertThat(second.skippedCount()).isEqualTo(TOTAL); // 전부 스킵 + + assertThat(settlementRepository.count()).isEqualTo(TOTAL); + } + + private long distinctOrderIds() { + return jdbc.queryForObject("SELECT COUNT(DISTINCT order_id) FROM settlements", Long.class); + } +} diff --git a/settlement.http b/settlement.http new file mode 100644 index 0000000..056b963 --- /dev/null +++ b/settlement.http @@ -0,0 +1,160 @@ +############################################### +# 정산 배치 세션 - 실습용 요청 모음 +# +# 사전 준비: 대용량 시드와 함께 실행 +# ./gradlew bootRun --args='--settlement.seed.enabled=true --settlement.seed.count=1000000 --spring.jpa.show-sql=false --logging.level.org.hibernate.orm.jdbc.bind=off' +# +# OOM 데모는 힙을 좁혀야 잘 터진다. main() 을 Run/Profile 로 띄우고 VM options 에: +# -Xmx1g -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=./oom.hprof -Xlog:gc*:file=gc.log:tags,uptime +############################################### + +### [상태 확인] 주문/정산 건수 + 현재 힙 사용량 (used/max MB) +GET http://localhost:8080/settlements/status + +### [데모2] "절벽으로 걸어가기" — limit 만큼만 메모리에 쌓으며 정산 +# limit 을 100000 → 300000 → 500000 으로 올려가며 elapsedMs / peakHeapMb 가 +# 어떻게 증가하는지 추세를 보여준다. 콘솔의 [mem:naive-climb] 막대도 같이 본다. +POST http://localhost:8080/settlements/naive?limit=100000 + +### [데모2-반복] 정산 비우고 limit 키우기 (먼저 DELETE 후 재실행) +POST http://localhost:8080/settlements/naive?limit=300000 + +### [데모1] "한 줄의 함정" — findAll 전량 적재 → 대용량(100만)에서 즉시 OOM +# 콘솔 [mem:naive-findAll] 막대가 max 에 붙는 순간 OutOfMemoryError + oom.hprof 생성 +POST http://localhost:8080/settlements/naive + +### 정산 결과 비우기 (데모 반복용) +DELETE http://localhost:8080/settlements + +############################################### +# [Step2] Spring Batch Chunk 지향 처리 +############################################### + +### [Step2] 정산 배치 Job 실행 (Reader 페이지 → Processor 변환 → Writer 적재) +# naive 와 달리 대용량(100만)이어도 chunk 크기만큼만 메모리를 쓰며 완주한다. +# 응답의 peakHeapMb 를 naive 결과와 비교. (먼저 DELETE 로 비운 깨끗한 상태에서 실행) +POST http://localhost:8080/settlements/batch + +### chunk 크기 바꿔 관찰: 기동 시 --settlement.batch.chunk-size=5000 처럼 조절 +# (작게=트랜잭션 자주/메모리 적게, 크게=빠르지만 메모리 많이) + +############################################### +# [Step3] 멱등성(Idempotency) & 실패 재시작(Restartability) +# +# 데모가 잘 보이려면 chunk 가 여러 개로 쪼개지게 chunk-size 를 낮춰서 기동: +# ./gradlew bootRun --args='--settlement.seed.enabled=true --settlement.seed.count=2000 --settlement.batch.chunk-size=100 --spring.jpa.show-sql=false' +# (2000건 / chunk 100 = 20 chunk → 50% = 정확히 10 chunk 커밋 후 실패) +############################################### + +# ───────── [Step3-1 + 멱등성] 새 인스턴스로 재실행해 복구 ───────── + +### (1) 깨끗한 출발: 정산 비우기 +DELETE http://localhost:8080/settlements + +### (2) [Step3-1] 50% 지점에서 강제 실패 — 응답 status=FAILED, settledCount≈50% +# 콘솔에 [FAULT] 장전 로그 → SettlementFaultException → Step FAILED. +# 직전까지 커밋된 chunk(약 50%)는 DB 에 그대로 남는다. +POST http://localhost:8080/settlements/batch?failAt=0.5 + +### (3) 상태 확인 — settlementCount 가 전체의 약 50% 인지 본다 +GET http://localhost:8080/settlements/status + +### (4) [멱등성] 그냥 다시 실행 → 이미 정산된 50%는 skippedCount 로 건너뛰고 나머지만 정산 +# 응답: status=COMPLETED, skippedCount≈50%, settledCount≈50%. 총합은 전체와 일치, 중복 0. +POST http://localhost:8080/settlements/batch + +### (5) 한 번 더 실행해도 멱등 — settledCount=0, skippedCount=전체 (새로 만드는 정산 없음) +POST http://localhost:8080/settlements/batch + +# ───────── [Step3-2] Spring Batch 네이티브 재시작(같은 runId 로 이어서) ───────── + +### (1) 깨끗한 출발 +DELETE http://localhost:8080/settlements + +### (2) runId=1 로 50%에서 실패 → status=FAILED +POST http://localhost:8080/settlements/batch/restart?runId=1&failAt=0.5 + +### (3) [재시작] 같은 runId=1, 장애 없이 다시 → 실패한 인스턴스를 "이어서" 재개해 COMPLETED +# 새 인스턴스가 아니라 같은 JobInstance 를 재시작한다. Reader 체크포인트가 앞 50%를 건너뛴다. +POST http://localhost:8080/settlements/batch/restart?runId=1 + +### (참고) 완료된 runId 를 또 실행하면? → "이미 완료" 안내 (같은 파라미터는 1회만 완료 가능) +POST http://localhost:8080/settlements/batch/restart?runId=1 + +### H2 콘솔 (시드/정산 결과를 직접 SQL 로 확인) — JDBC URL: jdbc:h2:mem:tangled +# 중복 검증: SELECT COUNT(*), COUNT(DISTINCT order_id) FROM settlements; (두 값이 같아야 함) +GET http://localhost:8080/h2-console + +############################################### +# [Step4] 병렬 처리의 한계와 함정 (스레드 → 깔때기 → 재시작 상실 → 파티셔닝) +# +# 처리량 비교가 보이려면 데이터를 좀 많이(2만), chunk 도 키워서 기동: +# ./gradlew bootRun --args='--settlement.seed.enabled=true --settlement.seed.count=20000 \ +# --settlement.batch.chunk-size=200 --spring.jpa.show-sql=false --logging.level.org.hibernate.orm.jdbc.bind=off' +# +# ⚠️ Batch 6 메모: 표준 paging Reader 의 read() 는 내부 Lock 으로 thread-safe 하다(데이터는 안 꼬임). +# 그 안전함의 비결이 곧 "읽기를 한 줄로 세우는 것" = 4-2 의 깔때기. (SETTLEMENT_SESSION.md 참고) +############################################### + +# ───────── [Step4-1] 스레드만 늘리면 터진다 — 커넥션 풀 ───────── + +### (사전) 풀을 작게 걸고 기동해야 터진다: +# ./gradlew bootRun --args='... --spring.datasource.hikari.maximum-pool-size=2' + +### [Step4-1] 풀(2) < 동시 스레드(10) → 커넥션 고갈 +# status=FAILED, 로그: "Connection is not available, request timed out after 3000ms" +# 처방: maximum-pool-size 를 스레드 수에 맞게 키워 재기동 → 같은 호출이 COMPLETED. +POST http://localhost:8080/settlements/batch/multi-threaded?threads=10 + +# ───────── [Step4-2] 분명 고쳤는데 왜 안 빨라지죠? — 읽기 깔때기(처리량 천장) ───────── + +### threads 를 1 → 2 → 4 → 8 로 올려가며 응답의 elapsedMs 를 표로 적는다. +# 2배 늘려도 시간이 절반으로 안 준다(예: 1240 → 660 → 500 → 455ms, 8배가 아니라 ~2.7배). +# 데이터는 안 꼬인다(read=settled=전체). 위험이 사라진 대신 '속도의 천장'으로 모습만 바꾼 것 = 깔때기. +POST http://localhost:8080/settlements/batch/multi-threaded?threads=1 +### +POST http://localhost:8080/settlements/batch/multi-threaded?threads=2 +### +POST http://localhost:8080/settlements/batch/multi-threaded?threads=4 +### +POST http://localhost:8080/settlements/batch/multi-threaded?threads=8 + +# ───────── [Step4-3] 게다가 정산에선 — 재시작까지 잃는다 (saveState=false) ───────── + +### (1) 깨끗한 출발 +DELETE http://localhost:8080/settlements + +### (2) 같은 runId 로 50%에서 실패 → FAILED +POST http://localhost:8080/settlements/batch/multi-threaded/restart?runId=5&failAt=0.5 + +### (3) 같은 runId 로 재개 → COMPLETED 이지만 readCount 가 전체에 가깝다(처음부터 다시 읽음) +# saveState=false 라 체크포인트가 없어 '이어서'가 아니라 '처음부터'. 멱등성이 데이터는 지켜주지만 +# 1부의 '이어서 재개' 이점은 사라졌다. (멱등성이 없었다면 재시작이 곧 이중정산 사고) +POST http://localhost:8080/settlements/batch/multi-threaded/restart?runId=5 + +# ───────── [Step4-4] 정답 — Partitioning (입구를 여러 개로: 속도 + 재시작 둘 다) ───────── + +### (1) 깨끗한 출발 +DELETE http://localhost:8080/settlements + +### (2) gridSize 를 1 → 2 → 4 → 8 로 올리며 elapsedMs 를 멀티스레드(4-2)와 비교한다. +# 깔때기가 사라져 잘 확장된다(예: 1070 → 524 → 260 → 201ms, ~5.3배 — 멀티스레드 2.7배와 대비). +# 항상 정확히 전체 건수, 중복 0. +POST http://localhost:8080/settlements/batch/partitioned?gridSize=1 +### +POST http://localhost:8080/settlements/batch/partitioned?gridSize=2 +### +POST http://localhost:8080/settlements/batch/partitioned?gridSize=4 +### +POST http://localhost:8080/settlements/batch/partitioned?gridSize=8 + +### (3) 멱등 재실행 — 다시 돌려도 settled=0, skipped=전체 (안전). 단, gridSize > 커넥션 풀이면 4-1 처럼 터진다. +POST http://localhost:8080/settlements/batch/partitioned?gridSize=8 + +### (4) 검증 — 정확히 전체 건수, 중복 0 +GET http://localhost:8080/settlements/status +# H2 콘솔: SELECT COUNT(*), COUNT(DISTINCT order_id) FROM settlements; (둘 다 전체와 일치해야 함) + +### H2 콘솔 (시드/정산 결과를 직접 SQL 로 확인) — JDBC URL: jdbc:h2:mem:tangled +# 중복 검증: SELECT COUNT(*), COUNT(DISTINCT order_id) FROM settlements; (두 값이 같아야 함) +GET http://localhost:8080/h2-console diff --git a/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/DataInitializer.java b/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/DataInitializer.java deleted file mode 100644 index 34af07d..0000000 --- a/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/DataInitializer.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.growmighty.lectures.firstday.tangledmonolith; - -import com.growmighty.lectures.firstday.tangledmonolith.cart.Cart; -import com.growmighty.lectures.firstday.tangledmonolith.cart.CartItem; -import com.growmighty.lectures.firstday.tangledmonolith.cart.CartRepository; -import com.growmighty.lectures.firstday.tangledmonolith.product.Product; -import com.growmighty.lectures.firstday.tangledmonolith.product.ProductRepository; -import com.growmighty.lectures.firstday.tangledmonolith.seller.Seller; -import com.growmighty.lectures.firstday.tangledmonolith.seller.SellerRepository; -import com.growmighty.lectures.firstday.tangledmonolith.user.User; -import com.growmighty.lectures.firstday.tangledmonolith.user.UserRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.boot.CommandLineRunner; -import org.springframework.stereotype.Component; - -import java.math.BigDecimal; - -@Component -@RequiredArgsConstructor -public class DataInitializer implements CommandLineRunner { - - private final UserRepository userRepository; - private final SellerRepository sellerRepository; - private final ProductRepository productRepository; - private final CartRepository cartRepository; - - @Override - public void run(String... args) { - User buyer = userRepository.save( - User.register("buyer@growmighty.co.kr", "encoded-pw", "구매자", "010-1111-1111")); - User sellerOwner = userRepository.save( - User.register("seller@growmighty.co.kr", "encoded-pw", "판매자", "010-2222-2222")); - - Seller seller = sellerRepository.save(Seller.create(sellerOwner)); - - Product chair = productRepository.save( - Product.create(seller, "Dofia 이동식 접이식 식탁 의자 4개 세트", BigDecimal.valueOf(179000), 10, "가정용 소형주택 신축식")); - Product table = productRepository.save( - Product.create(seller, "원목 4인용 식탁", BigDecimal.valueOf(259000), 5, "북유럽 스타일 원목 식탁")); - - Cart cart = Cart.create(buyer); - cart.addItem(CartItem.create(chair, 2)); - cart.addItem(CartItem.create(table, 1)); - cartRepository.save(cart); - - System.out.printf( - "[seed] 구매자 id=%d, 장바구니 id=%d 준비 완료. 예) POST /orders?userId=%d%n", - buyer.getId(), cart.getId(), buyer.getId()); - } -} diff --git a/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/TangledMonolithApplication.java b/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/TangledMonolithApplication.java deleted file mode 100644 index fe80347..0000000 --- a/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/TangledMonolithApplication.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.growmighty.lectures.firstday.tangledmonolith; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; - -@SpringBootApplication -public class TangledMonolithApplication { - - public static void main(String[] args) { - SpringApplication.run(TangledMonolithApplication.class, args); - } - -} diff --git a/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/cart/Cart.java b/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/cart/Cart.java deleted file mode 100644 index 90aa862..0000000 --- a/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/cart/Cart.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.growmighty.lectures.firstday.tangledmonolith.cart; - -import com.growmighty.lectures.firstday.tangledmonolith.user.User; -import jakarta.persistence.*; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.util.ArrayList; -import java.util.List; - -@Entity -@Table(name = "carts") -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class Cart { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @OneToMany(mappedBy = "cart", cascade = CascadeType.ALL, orphanRemoval = true) - private List items = new ArrayList<>(); - - @OneToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id") - private User user; - - public static Cart create(User user) { - Cart cart = new Cart(); - cart.user = user; - return cart; - } - - public void addItem(CartItem item) { - this.items.add(item); - - if (item.getCart() != this) { - item.assignCart(this); - } - } - - public void clear() { - this.items.clear(); - } -} diff --git a/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/cart/CartItem.java b/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/cart/CartItem.java deleted file mode 100644 index 6560bd5..0000000 --- a/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/cart/CartItem.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.growmighty.lectures.firstday.tangledmonolith.cart; - - -import com.growmighty.lectures.firstday.tangledmonolith.product.Product; -import jakarta.persistence.*; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Entity -@Table(name = "cart_items") -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class CartItem { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "cart_id") - private Cart cart; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "product_id") - private Product product; - - @Column(nullable = false) - private Integer quantity; - - public static CartItem create(Product product, int quantity) { - CartItem cartItem = new CartItem(); - cartItem.product = product; - cartItem.quantity = quantity; - return cartItem; - } - - void assignCart(Cart cart) { - this.cart = cart; - } -} diff --git a/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/cart/CartRepository.java b/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/cart/CartRepository.java deleted file mode 100644 index 3bc2a96..0000000 --- a/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/cart/CartRepository.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.growmighty.lectures.firstday.tangledmonolith.cart; - -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.Optional; - -public interface CartRepository extends JpaRepository { - Optional findByUserId(Long userId); -} diff --git a/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/order/Order.java b/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/order/Order.java deleted file mode 100644 index 80cafd5..0000000 --- a/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/order/Order.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.growmighty.lectures.firstday.tangledmonolith.order; - -import com.growmighty.lectures.firstday.tangledmonolith.payment.Payment; -import com.growmighty.lectures.firstday.tangledmonolith.user.User; -import jakarta.persistence.*; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; - -import java.math.BigDecimal; -import java.util.ArrayList; -import java.util.List; - -@Entity -@Table(name = "orders") -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class Order { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "user_id") - private User user; - - @OneToMany(mappedBy = "order", cascade = CascadeType.PERSIST) - private List items = new ArrayList<>(); - - @OneToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "payment_id") - private Payment payment; - - @Setter - @Column(nullable = false) - private BigDecimal totalAmount; - - @Setter - @Enumerated(EnumType.STRING) - @Column(nullable = false) - private OrderStatus status; - - public static Order create(User user, List items) { - Order order = new Order(); - order.user = user; - order.status = OrderStatus.CREATED; - order.totalAmount = BigDecimal.ZERO; - - for (OrderItem item : items) { - order.addOrderItem(item); - } - - return order; - } - - public void assignPayment(Payment payment) { - this.payment = payment; - } - - private void addOrderItem(OrderItem item) { - this.items.add(item); - - if (item.getOrder() != this) { - item.assignOrder(this); - } - } -} diff --git a/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/order/OrderController.java b/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/order/OrderController.java deleted file mode 100644 index 3c8b39f..0000000 --- a/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/order/OrderController.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.growmighty.lectures.firstday.tangledmonolith.order; - -import lombok.NonNull; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - -import java.util.List; - -@RestController -@RequiredArgsConstructor -@RequestMapping("/orders") -public class OrderController { - - private final OrderService orderService; - - @GetMapping - public List getOrders() { - return orderService.getOrders(); - } - - @PostMapping - public OrderResult placeOrder(@RequestBody OrderRequest request) { - return orderService.placeOrder(request.userId(), request.items()); - } - - public record OrderRequest(@NonNull Long userId, @NonNull List items) { - - } - - public record OrderLine(@NonNull Long productId, @NonNull Integer quantity) { - - } -} diff --git a/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/order/OrderItem.java b/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/order/OrderItem.java deleted file mode 100644 index 26fe4ca..0000000 --- a/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/order/OrderItem.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.growmighty.lectures.firstday.tangledmonolith.order; - -import com.growmighty.lectures.firstday.tangledmonolith.product.Product; -import jakarta.persistence.*; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Entity -@Table(name = "order_items") -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class OrderItem { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "order_id") - private Order order; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "product_id") - private Product product; - - @Column(nullable = false) - private Integer quantity; - - public static OrderItem create(Product product, int quantity) { - OrderItem orderItem = new OrderItem(); - orderItem.product = product; - orderItem.quantity = quantity; - - return orderItem; - } - - void assignOrder(Order order) { - this.order = order; - } -} diff --git a/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/order/OrderRepository.java b/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/order/OrderRepository.java deleted file mode 100644 index 260b82b..0000000 --- a/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/order/OrderRepository.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.growmighty.lectures.firstday.tangledmonolith.order; - -import org.springframework.data.jpa.repository.JpaRepository; - -public interface OrderRepository extends JpaRepository { -} diff --git a/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/order/OrderResult.java b/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/order/OrderResult.java deleted file mode 100644 index 235eb37..0000000 --- a/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/order/OrderResult.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.growmighty.lectures.firstday.tangledmonolith.order; - -public record OrderResult(Long id) { -} diff --git a/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/order/OrderService.java b/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/order/OrderService.java deleted file mode 100644 index e8e79f2..0000000 --- a/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/order/OrderService.java +++ /dev/null @@ -1,76 +0,0 @@ -package com.growmighty.lectures.firstday.tangledmonolith.order; - -import com.growmighty.lectures.firstday.tangledmonolith.payment.Payment; -import com.growmighty.lectures.firstday.tangledmonolith.payment.PaymentRepository; -import com.growmighty.lectures.firstday.tangledmonolith.payment.PaymentStatus; -import com.growmighty.lectures.firstday.tangledmonolith.product.Product; -import com.growmighty.lectures.firstday.tangledmonolith.product.ProductRepository; -import com.growmighty.lectures.firstday.tangledmonolith.user.User; -import com.growmighty.lectures.firstday.tangledmonolith.user.UserRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.math.BigDecimal; -import java.util.ArrayList; -import java.util.List; - -@Service -@RequiredArgsConstructor -public class OrderService { - - private final UserRepository userRepository; - private final ProductRepository productRepository; - private final PaymentRepository paymentRepository; - private final OrderRepository orderRepository; - - @Transactional - public OrderResult placeOrder(Long userId, List items) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다. userId=" + userId)); - - if (items == null || items.isEmpty()) { - throw new IllegalStateException("주문할 상품이 없습니다."); - } - - List orderItems = new ArrayList<>(); - BigDecimal totalAmount = BigDecimal.ZERO; - - for (OrderController.OrderLine item : items) { - Long productId = item.productId(); - int quantity = item.quantity(); - - Product product = productRepository.findById(productId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 상품입니다. productId=" + productId)); - - if (product.getStockQuantity() < quantity) { - throw new IllegalStateException("재고가 부족합니다. product=" + product.getName()); - } - product.setStockQuantity(product.getStockQuantity() - quantity); - - OrderItem orderItem = OrderItem.create(product, quantity); - orderItems.add(orderItem); - - BigDecimal lineAmount = product.getPrice().multiply(BigDecimal.valueOf(quantity)); - totalAmount = totalAmount.add(lineAmount); - } - - Order order = Order.create(user, orderItems); - order.setTotalAmount(totalAmount); - - Payment payment = Payment.ready(totalAmount); - payment.setStatus(PaymentStatus.PAID); - paymentRepository.save(payment); - - order.assignPayment(payment); - order.setStatus(OrderStatus.PAID); - - Order saved = orderRepository.save(order); - return new OrderResult(saved.getId()); - } - - public List getOrders() { - List orders = orderRepository.findAll(); - return orders.stream().map(e -> new OrderResult(e.getId())).toList(); - } -} diff --git a/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/payment/PaymentRepository.java b/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/payment/PaymentRepository.java deleted file mode 100644 index b418805..0000000 --- a/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/payment/PaymentRepository.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.growmighty.lectures.firstday.tangledmonolith.payment; - -import org.springframework.data.jpa.repository.JpaRepository; - -public interface PaymentRepository extends JpaRepository { -} diff --git a/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/payment/PaymentStatus.java b/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/payment/PaymentStatus.java deleted file mode 100644 index cd6c7d0..0000000 --- a/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/payment/PaymentStatus.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.growmighty.lectures.firstday.tangledmonolith.payment; - -public enum PaymentStatus { - READY, - PAID, - FAILED -} diff --git a/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/product/Product.java b/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/product/Product.java deleted file mode 100644 index 7d3572c..0000000 --- a/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/product/Product.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.growmighty.lectures.firstday.tangledmonolith.product; - -import com.growmighty.lectures.firstday.tangledmonolith.seller.Seller; -import jakarta.persistence.*; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; - -import java.math.BigDecimal; - -@Entity -@Table(name = "products") -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class Product { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "seller_id") - private Seller seller; - - @Column(nullable = false) - private String name; - - @Column(nullable = false) - private BigDecimal price; - - @Setter - @Column(nullable = false) - private Integer stockQuantity; - - @Lob - private String description; - - public static Product create(Seller seller, String name, BigDecimal price, Integer stockQuantity, String description) { - Product product = new Product(); - product.seller = seller; - product.name = name; - product.price = price; - product.stockQuantity = stockQuantity; - product.description = description; - return product; - } -} diff --git a/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/product/ProductRepository.java b/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/product/ProductRepository.java deleted file mode 100644 index 217aa72..0000000 --- a/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/product/ProductRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.growmighty.lectures.firstday.tangledmonolith.product; - -import org.springframework.data.jpa.repository.JpaRepository; - -public interface ProductRepository extends JpaRepository { - -} diff --git a/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/seller/Seller.java b/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/seller/Seller.java deleted file mode 100644 index 21584f3..0000000 --- a/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/seller/Seller.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.growmighty.lectures.firstday.tangledmonolith.seller; - -import com.growmighty.lectures.firstday.tangledmonolith.product.Product; -import com.growmighty.lectures.firstday.tangledmonolith.user.User; -import jakarta.persistence.*; -import lombok.AccessLevel; -import lombok.NoArgsConstructor; - -import java.util.ArrayList; -import java.util.List; - -@Entity -@Table(name = "sellers") -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class Seller { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @OneToMany(mappedBy = "seller") - private List products = new ArrayList<>(); - - @OneToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id") - private User user; - - public static Seller create(User user) { - Seller seller = new Seller(); - seller.user = user; - - return seller; - } -} diff --git a/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/seller/SellerRepository.java b/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/seller/SellerRepository.java deleted file mode 100644 index 82d4b2b..0000000 --- a/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/seller/SellerRepository.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.growmighty.lectures.firstday.tangledmonolith.seller; - -import org.springframework.data.jpa.repository.JpaRepository; - -public interface SellerRepository extends JpaRepository { -} diff --git a/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/user/User.java b/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/user/User.java deleted file mode 100644 index fa5b508..0000000 --- a/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/user/User.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.growmighty.lectures.firstday.tangledmonolith.user; - -import jakarta.persistence.*; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Entity -@Table(name = "users") -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class User { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(nullable = false, unique = true) - private String email; - - @Column(nullable = false) - private String password; - - @Column(nullable = false) - private String name; - - @Column(nullable = false) - private String phoneNumber; - - public static User register(String email, String encodedPassword, String name, String phoneNumber) { - User user = new User(); - user.email = email; - user.password = encodedPassword; - user.name = name; - user.phoneNumber = phoneNumber; - return user; - } -} diff --git a/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/user/UserRepository.java b/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/user/UserRepository.java deleted file mode 100644 index f570ae4..0000000 --- a/src/main/java/com/growmighty/lectures/firstday/tangledmonolith/user/UserRepository.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.growmighty.lectures.firstday.tangledmonolith.user; - -import org.springframework.data.jpa.repository.JpaRepository; - -public interface UserRepository extends JpaRepository { -} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index a026a44..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1,14 +0,0 @@ -spring.application.name=tangled-monolith - -# --- H2 (in-memory) --- -spring.datasource.url=jdbc:h2:mem:tangled;DB_CLOSE_DELAY=-1 -spring.h2.console.enabled=true - -# --- JPA / Hibernate --- -spring.jpa.hibernate.ddl-auto=create -# 실행되는 SQL을 보기 좋게 포맷팅해서 출력 -spring.jpa.properties.hibernate.format_sql=true -spring.jpa.show-sql=true - -# SQL 바인딩 파라미터(? 에 들어가는 실제 값)까지 로그로 출력 -logging.level.org.hibernate.orm.jdbc.bind=trace diff --git a/src/test/java/com/growmighty/lectures/firstday/tangledmonolith/TangledMonolithApplicationTests.java b/src/test/java/com/growmighty/lectures/firstday/tangledmonolith/TangledMonolithApplicationTests.java deleted file mode 100644 index 5308bda..0000000 --- a/src/test/java/com/growmighty/lectures/firstday/tangledmonolith/TangledMonolithApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.growmighty.lectures.firstday.tangledmonolith; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class TangledMonolithApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/user-service/build.gradle b/user-service/build.gradle new file mode 100644 index 0000000..f5dfc5d --- /dev/null +++ b/user-service/build.gradle @@ -0,0 +1,15 @@ +plugins { + id 'org.springframework.boot' // 실행 가능한 서비스이므로 Boot 플러그인 적용 (버전은 루트에 등록됨) +} + +dependencies { + implementation project(':common') + + implementation 'org.springframework.boot:spring-boot-starter-webmvc' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-h2console' + runtimeOnly 'com.h2database:h2' + + testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test' + testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' +} diff --git a/user-service/src/main/java/com/growmighty/lectures/firstday/user/UserServiceApplication.java b/user-service/src/main/java/com/growmighty/lectures/firstday/user/UserServiceApplication.java new file mode 100644 index 0000000..2a8ab1d --- /dev/null +++ b/user-service/src/main/java/com/growmighty/lectures/firstday/user/UserServiceApplication.java @@ -0,0 +1,17 @@ +package com.growmighty.lectures.firstday.user; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +// 각 서비스마다 자기만의 @SpringBootApplication이 필요하다. +// scanBasePackages로 자신의 패키지 아래뿐만 아닌 다른 곳도 스캔한다. +@SpringBootApplication(scanBasePackages = { + "com.growmighty.lectures.firstday.user", + "com.growmighty.lectures.firstday.common" // 요거 등록 안하면 커스텀예외가 빈으로 등록되지 않는다. +}) +public class UserServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(UserServiceApplication.class, args); + } +} diff --git a/user-service/src/main/java/com/growmighty/lectures/firstday/user/application/PasswordEncoder.java b/user-service/src/main/java/com/growmighty/lectures/firstday/user/application/PasswordEncoder.java new file mode 100644 index 0000000..c7a5f69 --- /dev/null +++ b/user-service/src/main/java/com/growmighty/lectures/firstday/user/application/PasswordEncoder.java @@ -0,0 +1,7 @@ +package com.growmighty.lectures.firstday.user.application; + +public interface PasswordEncoder { + String encode(String rawPassword); + + boolean matches(String rawPassword, String encodedPassword); +} diff --git a/user-service/src/main/java/com/growmighty/lectures/firstday/user/application/UserService.java b/user-service/src/main/java/com/growmighty/lectures/firstday/user/application/UserService.java new file mode 100644 index 0000000..67b3373 --- /dev/null +++ b/user-service/src/main/java/com/growmighty/lectures/firstday/user/application/UserService.java @@ -0,0 +1,47 @@ +package com.growmighty.lectures.firstday.user.application; + + +import com.growmighty.lectures.firstday.common.exception.EntityNotFoundException; +import com.growmighty.lectures.firstday.user.application.dto.LoginCommand; +import com.growmighty.lectures.firstday.user.application.dto.RegisterUserCommand; +import com.growmighty.lectures.firstday.user.application.dto.UserInfo; +import com.growmighty.lectures.firstday.user.domain.User; +import com.growmighty.lectures.firstday.user.domain.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class UserService { + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional + public UserInfo register(RegisterUserCommand command) { + if (userRepository.existsByEmail(command.email())) { + throw new IllegalStateException("이미 가입된 이메일입니다. email = " + command.email()); + } + String encoded = passwordEncoder.encode(command.rawPassword()); + User user = User.register(command.email(), encoded, command.name(), command.phoneNumber()); + return UserInfo.from(userRepository.save(user)); + } + + @Transactional(readOnly = true) + public UserInfo authenticate(LoginCommand command) { + User user = userRepository.findByEmail(command.email()) + .orElseThrow(() -> new IllegalArgumentException("이메일 또는 비밀번호가 올바르지 않습니다.")); + // encode해서 equals를 하지 않는다. + if (!passwordEncoder.matches(command.rawPassword(), user.getPassword())) { + throw new IllegalArgumentException("이메일 또는 비밀번호가 올바르지 않습니다."); + } + return UserInfo.from(user); + } + + @Transactional(readOnly = true) + public UserInfo getUser(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new EntityNotFoundException("존재하지 않는 유저입니다. userId = " + userId)); + return UserInfo.from(user); + } +} diff --git a/user-service/src/main/java/com/growmighty/lectures/firstday/user/application/dto/LoginCommand.java b/user-service/src/main/java/com/growmighty/lectures/firstday/user/application/dto/LoginCommand.java new file mode 100644 index 0000000..15e0754 --- /dev/null +++ b/user-service/src/main/java/com/growmighty/lectures/firstday/user/application/dto/LoginCommand.java @@ -0,0 +1,4 @@ +package com.growmighty.lectures.firstday.user.application.dto; + +public record LoginCommand(String email, String rawPassword) { +} diff --git a/user-service/src/main/java/com/growmighty/lectures/firstday/user/application/dto/RegisterUserCommand.java b/user-service/src/main/java/com/growmighty/lectures/firstday/user/application/dto/RegisterUserCommand.java new file mode 100644 index 0000000..2094d86 --- /dev/null +++ b/user-service/src/main/java/com/growmighty/lectures/firstday/user/application/dto/RegisterUserCommand.java @@ -0,0 +1,9 @@ +package com.growmighty.lectures.firstday.user.application.dto; + +public record RegisterUserCommand( + String email, + String rawPassword, + String name, + String phoneNumber +) { +} diff --git a/user-service/src/main/java/com/growmighty/lectures/firstday/user/application/dto/UserInfo.java b/user-service/src/main/java/com/growmighty/lectures/firstday/user/application/dto/UserInfo.java new file mode 100644 index 0000000..6aa2927 --- /dev/null +++ b/user-service/src/main/java/com/growmighty/lectures/firstday/user/application/dto/UserInfo.java @@ -0,0 +1,15 @@ +package com.growmighty.lectures.firstday.user.application.dto; + +import com.growmighty.lectures.firstday.user.domain.User; + +public record UserInfo( + Long id, + String email, + String name, + String phoneNumber +) { + // User 객체로부터 현재 객체인 UserInfo를 만들겠다. + public static UserInfo from(User user) { + return new UserInfo(user.getId(), user.getEmail(), user.getName(), user.getPhoneNumber()); + } +} diff --git a/user-service/src/main/java/com/growmighty/lectures/firstday/user/domain/User.java b/user-service/src/main/java/com/growmighty/lectures/firstday/user/domain/User.java new file mode 100644 index 0000000..a0df43e --- /dev/null +++ b/user-service/src/main/java/com/growmighty/lectures/firstday/user/domain/User.java @@ -0,0 +1,55 @@ +package com.growmighty.lectures.firstday.user.domain; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "users") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class User { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String email; + + @Column(nullable = false) + private String password; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private String phoneNumber; + + // 생성자 팩토리 메서드 + private User(String email, String encodedPassword, String name, String phoneNumber) { + if (email == null || email.isBlank()) { + throw new IllegalArgumentException("이메일은 필수입니다."); + } + this.email = email; + this.password = encodedPassword; + this.name = name; + this.phoneNumber = phoneNumber; + } + + public static User register(String email, String encodedPassword, String name, String phoneNumber) { + return new User(email, encodedPassword, name, phoneNumber); + } + + public void changePassword(String newEncodedPassword) { + if (newEncodedPassword == null || newEncodedPassword.isBlank()) { + throw new IllegalArgumentException("새 비밀번호는 비어 있을 수 없습니다."); + } + this.password = newEncodedPassword; + } + + public void updateProfile(String name, String phoneNumber) { + this.name = name; + this.phoneNumber = phoneNumber; + } +} diff --git a/user-service/src/main/java/com/growmighty/lectures/firstday/user/domain/UserRepository.java b/user-service/src/main/java/com/growmighty/lectures/firstday/user/domain/UserRepository.java new file mode 100644 index 0000000..918b1e1 --- /dev/null +++ b/user-service/src/main/java/com/growmighty/lectures/firstday/user/domain/UserRepository.java @@ -0,0 +1,14 @@ +package com.growmighty.lectures.firstday.user.domain; + +import java.util.Optional; + +// DIP 구현, 도메인은 인프라 기술을 몰라야 한다. +public interface UserRepository { + User save(User user); + + Optional findById(Long id); + + Optional findByEmail(String email); + + boolean existsByEmail(String email); +} diff --git a/user-service/src/main/java/com/growmighty/lectures/firstday/user/infrastructure/Sha256PasswordEncoder.java b/user-service/src/main/java/com/growmighty/lectures/firstday/user/infrastructure/Sha256PasswordEncoder.java new file mode 100644 index 0000000..6f72eef --- /dev/null +++ b/user-service/src/main/java/com/growmighty/lectures/firstday/user/infrastructure/Sha256PasswordEncoder.java @@ -0,0 +1,29 @@ +package com.growmighty.lectures.firstday.user.infrastructure; + +import com.growmighty.lectures.firstday.user.application.PasswordEncoder; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +// Bean 등록해줘야 한다. +@Component +public class Sha256PasswordEncoder implements PasswordEncoder { + @Override + public String encode(String rawPassword) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashed = digest.digest(rawPassword.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(hashed); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 알고리즘을 사용할 수 없습니다.", e); + } + } + + @Override + public boolean matches(String rawPassword, String encodedPassword) { + return encode(rawPassword).equals(encodedPassword); + } +} diff --git a/user-service/src/main/java/com/growmighty/lectures/firstday/user/infrastructure/UserJpaRepository.java b/user-service/src/main/java/com/growmighty/lectures/firstday/user/infrastructure/UserJpaRepository.java new file mode 100644 index 0000000..480e337 --- /dev/null +++ b/user-service/src/main/java/com/growmighty/lectures/firstday/user/infrastructure/UserJpaRepository.java @@ -0,0 +1,12 @@ +package com.growmighty.lectures.firstday.user.infrastructure; + +import com.growmighty.lectures.firstday.user.domain.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserJpaRepository extends JpaRepository { + Optional findByEmail(String email); + + boolean existsByEmail(String email); +} diff --git a/user-service/src/main/java/com/growmighty/lectures/firstday/user/infrastructure/UserRepositoryAdapter.java b/user-service/src/main/java/com/growmighty/lectures/firstday/user/infrastructure/UserRepositoryAdapter.java new file mode 100644 index 0000000..355f38f --- /dev/null +++ b/user-service/src/main/java/com/growmighty/lectures/firstday/user/infrastructure/UserRepositoryAdapter.java @@ -0,0 +1,35 @@ +package com.growmighty.lectures.firstday.user.infrastructure; + +import com.growmighty.lectures.firstday.user.domain.User; +import com.growmighty.lectures.firstday.user.domain.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class UserRepositoryAdapter implements UserRepository { + private final UserJpaRepository jpaRepository; + + + @Override + public User save(User user) { + return jpaRepository.save(user); + } + + @Override + public Optional findById(Long id) { + return jpaRepository.findById(id); + } + + @Override + public Optional findByEmail(String email) { + return jpaRepository.findByEmail(email); + } + + @Override + public boolean existsByEmail(String email) { + return jpaRepository.existsByEmail(email); + } +} diff --git a/user-service/src/main/java/com/growmighty/lectures/firstday/user/presentation/UserController.java b/user-service/src/main/java/com/growmighty/lectures/firstday/user/presentation/UserController.java new file mode 100644 index 0000000..ad73148 --- /dev/null +++ b/user-service/src/main/java/com/growmighty/lectures/firstday/user/presentation/UserController.java @@ -0,0 +1,31 @@ +package com.growmighty.lectures.firstday.user.presentation; + +import com.growmighty.lectures.firstday.common.response.ApiResponse; +import com.growmighty.lectures.firstday.user.application.UserService; +import com.growmighty.lectures.firstday.user.presentation.dto.LoginRequest; +import com.growmighty.lectures.firstday.user.presentation.dto.RegisterUserRequest; +import com.growmighty.lectures.firstday.user.presentation.dto.UserResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/users") +public class UserController { + private final UserService userService; + + @PostMapping + public ApiResponse register(@RequestBody RegisterUserRequest request) { + return ApiResponse.ok(UserResponse.from(userService.register(request.toCommand()))); + } + + @PostMapping("/login") + public ApiResponse login(@RequestBody LoginRequest request) { + return ApiResponse.ok(UserResponse.from(userService.authenticate(request.toCommand()))); + } + + @GetMapping("{userId}") + public ApiResponse getUser(@PathVariable Long userId) { + return ApiResponse.ok(UserResponse.from(userService.getUser(userId))); + } +} diff --git a/user-service/src/main/java/com/growmighty/lectures/firstday/user/presentation/dto/LoginRequest.java b/user-service/src/main/java/com/growmighty/lectures/firstday/user/presentation/dto/LoginRequest.java new file mode 100644 index 0000000..26dbe31 --- /dev/null +++ b/user-service/src/main/java/com/growmighty/lectures/firstday/user/presentation/dto/LoginRequest.java @@ -0,0 +1,10 @@ +package com.growmighty.lectures.firstday.user.presentation.dto; + +import com.growmighty.lectures.firstday.user.application.dto.LoginCommand; +import lombok.NonNull; + +public record LoginRequest(@NonNull String email, @NonNull String password) { + public LoginCommand toCommand() { + return new LoginCommand(email, password); + } +} diff --git a/user-service/src/main/java/com/growmighty/lectures/firstday/user/presentation/dto/RegisterUserRequest.java b/user-service/src/main/java/com/growmighty/lectures/firstday/user/presentation/dto/RegisterUserRequest.java new file mode 100644 index 0000000..a4abc38 --- /dev/null +++ b/user-service/src/main/java/com/growmighty/lectures/firstday/user/presentation/dto/RegisterUserRequest.java @@ -0,0 +1,15 @@ +package com.growmighty.lectures.firstday.user.presentation.dto; + +import com.growmighty.lectures.firstday.user.application.dto.RegisterUserCommand; +import lombok.NonNull; + +public record RegisterUserRequest( + @NonNull String email, + @NonNull String password, + @NonNull String name, + @NonNull String phoneNumber +) { + public RegisterUserCommand toCommand() { + return new RegisterUserCommand(email, password, name, phoneNumber); + } +} diff --git a/user-service/src/main/java/com/growmighty/lectures/firstday/user/presentation/dto/UserResponse.java b/user-service/src/main/java/com/growmighty/lectures/firstday/user/presentation/dto/UserResponse.java new file mode 100644 index 0000000..8bb520f --- /dev/null +++ b/user-service/src/main/java/com/growmighty/lectures/firstday/user/presentation/dto/UserResponse.java @@ -0,0 +1,14 @@ +package com.growmighty.lectures.firstday.user.presentation.dto; + +import com.growmighty.lectures.firstday.user.application.dto.UserInfo; + +public record UserResponse( + Long id, + String email, + String name, + String phoneNumber +) { + public static UserResponse from(UserInfo info) { + return new UserResponse(info.id(), info.email(), info.name(), info.phoneNumber()); + } +} diff --git a/user-service/src/main/resources/application.properties b/user-service/src/main/resources/application.properties new file mode 100644 index 0000000..9d29e14 --- /dev/null +++ b/user-service/src/main/resources/application.properties @@ -0,0 +1,13 @@ +# 설정 파일도 각 서비스마다 모두 다 만들어줘야 한다. + +spring.application.name=product-service +# 포트 번호를 모두 다 다르게 해야 한다! +server.port=8083 + +# 서비스마다 자기만의 DB를 갖는다 (Database per Service) +spring.datasource.url=jdbc:h2:mem:productdb;DB_CLOSE_DELAY=-1 +spring.h2.console.enabled=true + +spring.jpa.hibernate.ddl-auto=create +spring.jpa.show-sql=true +spring.jpa.properties.hibernate.format_sql=true