diff --git a/.docs/design/01-requirements.md b/.docs/design/01-requirements.md
new file mode 100644
index 000000000..d500ef3de
--- /dev/null
+++ b/.docs/design/01-requirements.md
@@ -0,0 +1,137 @@
+# **요구사항 명세서**
+
+## **1. 상품 목록 조회**
+
+### 1.1. 유저 시나리오
+> "사용자가 상품을 둘러보기 위해 사이트에 접속했다. 그는 어떤 제품이 인기가 많은지 보기 위해 상품 목록을 조회한다."
+
+### 1.2. 핵심 기능 정의
+- **F-1:** 상품 목록을 조회할 수 있다.
+- **F-2:** 상품 목록을 '좋아요순'으로 정렬할 수 있다.
+- **F-3:** 상품 목록을 '최신순', '가격순' 등 다른 기준으로도 정렬할 수 있다.
+- **F-4:** 상품 목록을 페이지 단위로 나누어 볼 수 있다.
+
+### 1.3. 유스케이스 흐름
+* **Main Flow (기본 조회):**
+ 1. 사용자가 상품 목록 페이지에 진입한다.
+ 2. 최신순으로 첫 번째 페이지의 상품 목록을 반환한다.
+ 3. 화면에 상품 목록이 표시된다.
+* **Alternate Flow (정렬 변경):**
+ 1. 사용자가 '인기순' 정렬 버튼을 클릭한다.
+ 2. 인기순으로 상품 목록을 다시 조회하여 반환한다.
+ 3. 화면에 인기순으로 정렬된 목록이 표시된다.
+* **Alternate Flow (페이징):**
+ 1. 사용자가 다른 페이지를 클릭한다.
+ 2. 다른 페이지의 상품 목록을 조회하여 반환한다.
+ 3. 화면에 다음 상품 목록이 이어서 표시된다.
+* **Exception Flow (결과 없음):**
+ 1. 사용자가 특정 필터를 적용했으나, 해당하는 상품이 하나도 없다.
+ 2. "조회된 상품이 없습니다."라는 메시지를 반환한다.
+
+---
+
+## **2. 상품 상세 조회**
+
+### 2.1. 유저 시나리오
+> "사용자가 마음에 드는 상품이 있어서 해당 상품을 클릭했다. 그는 해당 상품의 상세 정보를 확인하고 싶어 한다."
+
+### 2.2. 핵심 기능 정의
+- **F-1:** 상품 상세정보를 조회할 수 있다.
+- **F-2:** 상품의 총 좋아요 수와 나의 좋아요 여부를 조회할 수 있다.
+- **F-3:** 구매할 상품의 수량을 선택할 수 있다.
+
+### 2.3. 유스케이스 흐름
+* **Main Flow (상세 조회):**
+ 1. 사용자가 상품 목록에서 특정 상품을 클릭한다.
+ 2. 해당 상품의 상세 정보(이름, 가격, 설명, 총 좋아요 수, 나의 좋아요 여부 등)를 조회하여 반환한다.
+ 3. 화면에 상품 상세 정보가 표시되고, 수량은 '1'로 기본 설정된다.
+* **Alternate Flow (수량 변경):**
+ 1. 사용자가 '+' 버튼을 눌러 수량을 '3'으로 변경한다.
+ 2. 화면에 수량이 '3'으로 갱신된다.
+* **Exception Flow (상품 없음):**
+ 1. 사용자가 존재하지 않는 상품 ID의 URL로 직접 접근한다.
+ 2. 상품을 찾을 수 없으므로, "상품을 찾을 수 없습니다."라는 오류를 반환한다.
+
+---
+
+## **3. 브랜드별 상품 조회**
+
+### 3.1. 유저 시나리오
+> "사용자가 마음에 드는 특정 브랜드를 찾고 있다. 그는 해당 브랜드에서 나오는 상품만 모아서 조회하고 싶어 한다."
+
+### 3.2. 핵심 기능 정의
+- **F-1:** 특정 브랜드에 속한 상품만 필터링하여 조회할 수 있다.
+- **F-2:** 상품 목록을 '인기순'으로 정렬할 수 있다.
+- **F-3:** 상품 목록을 '최신순', '가격순' 등 다른 기준으로도 정렬할 수 있다.
+- **F-4:** 상품 목록을 페이지 단위로 나누어 볼 수 있다.
+
+### 3.3. 유스케이스 흐름
+* **Main Flow (브랜드별 조회):**
+ 1. 사용자가 'A 브랜드'를 조회한다.
+ 2. 해당 브랜드 상품 목록을 반환한다.
+ 3. 화면에 'A 브랜드'의 상품 목록만 표시된다.
+* **Exception Flow (브랜드 없음):**
+ 1. 사용자가 존재하지 않는 브랜드 ID의 URL로 직접 접근한다.
+ 2. "브랜드를 찾을 수 없습니다."라는 오류(404 Not Found)를 반환한다.
+
+---
+
+## **4. 상품 좋아요 (등록/취소)**
+
+### 4.1. 유저 시나리오
+> (등록) "사용자가 마음에 드는 상품을 발견했다. 그는 나중에 마음에 드는 상품을 모아보기 위해 '좋아요'를 누른다."
+> (취소) "사용자가 이전에 '좋아요' 했던 상품이 더 이상 마음에 들지 않아 '좋아요'를 취소한다."
+
+### 4.2. 핵심 기능 정의
+- **F-1:** 상품에 대해 '좋아요'를 등록할 수 있다.
+- **F-2:** 이미 '좋아요'가 등록된 상품의 '좋아요'를 취소할 수 있다.
+
+### 4.3. 유스케이스 흐름
+* **Main Flow (좋아요 등록):**
+ 1. 사용자가 좋아요 아이콘을 클릭한다.
+ 2. 좋아요 여부를 판단한다.
+ 3. 해당 상품이 좋아요가 안되어있으면 좋아요 등록이 된다.
+* **Alternate Flow (좋아요 취소):**
+ 1. 사용자가 좋아요 아이콘을 클릭한다.
+ 2. 좋아요 여부를 판단한다.
+ 3. 해당 상품이 좋아요가 되어있으면 좋아요가 취소된다.
+* **Exception Flow (비회원):**
+ 1. 비로그인 사용자가 좋아요 아이콘을 클릭한다.
+ 2. "로그인이 필요한 기능입니다."라는 오류 메시지를 반환한다.
+
+---
+
+## **5. 주문 생성 (결제)**
+
+### 5.1. 유저 시나리오
+> "사용자가 특정 상품을 구매하고 싶어, 해당 상품을 결제한다."
+
+### 5.2. 핵심 기능 정의
+- **F-1:** 사용자가 선택한 상품을 구매할 수 있다.
+- **F-2:** 사용자의 포인트가 충분할 경우 결제할 수 있으며, 결제 시 포인트가 차감된다.
+- **F-3:** 상품의 재고가 충분할 경우 결제할 수 있으며, 결제 시 재고가 차감된다.
+- **F-4:** 결제 성공 시 주문 정보가 외부 시스템으로 전송된다.
+
+### 5.3. 유스케이스 흐름
+* **Main Flow (주문 성공):**
+ 1. 로그인한 사용자가 상품과 수량을 선택하고 '결제하기'를 요청한다.
+ 2. 상품 재고가 1개 이상인지 확인한다.
+ 3. 사용자 포인트가 총 결제 금액 이상인지 확인한다.
+ 4. 상품 재고를 1 차감한다.
+ 5. 사용자 포인트를 차감한다.
+ 6. '주문' 및 '주문 항목'을 저장한다.
+ 7. 주문 정보를 외부 시스템으로 전송한다.
+ 8. 사용자에게 "주문 완료" 응답을 반환한다.
+* **Alternate Flow (여러 수량 구매):**
+ 1. 사용자가 수량을 '3'으로 선택하고 '결제하기'를 요청한다.
+ 2. 재고 및 포인트를 수량 기준으로 검증, 차감, 저장한다.
+ 3. 주문에 성공한다.
+* **Exception Flow (재고 부족):**
+ 1. 시스템이 재고를 확인했으나, 요청 수량보다 재고가 부족하다.
+ 2. "재고가 부족하여 주문할 수 없습니다."라는 오류 메시지를 반환한다.
+* **Exception Flow (포인트 부족):**
+ 1. 시스템이 사용자의 포인트를 확인했으나, 총 결제 금액보다 포인트가 부족하다.
+ 2. "포인트가 부족합니다."라는 오류 메시지를 반환한다.
+* **Exception Flow (비회원):**
+ 1. 비로그인 사용자가 '결제하기'를 요청한다.
+ 2. "로그인이 필요한 기능입니다."라는 오류 메시지를 반환한다.
\ No newline at end of file
diff --git a/.docs/design/02-sequence-diagrams.md b/.docs/design/02-sequence-diagrams.md
new file mode 100644
index 000000000..bd6d61447
--- /dev/null
+++ b/.docs/design/02-sequence-diagrams.md
@@ -0,0 +1,67 @@
+
+### 1. 상품 목록/상세 조회 및 브랜드 조회
+```mermaid
+sequenceDiagram
+ participant User
+ participant ProductController
+ participant ProductReader
+
+ %% 상품 목록 조회 %%
+ User->>ProductController: GET /api/v1/products?sort=likes_desc
+ ProductController->>ProductReader: getProducts(sort)
+ ProductReader-->>ProductController: productList
+ ProductController-->>User: productList
+
+ %% 상품 상세 조회 %%
+ User->>ProductController: GET /api/v1/products/{productId}
+ ProductController->>ProductReader: getProduct(productId)
+ ProductReader-->>ProductController: product
+ ProductController-->>User: product
+
+ participant BrandController
+ participant BrandReader
+
+ %% 브랜드 조회 %%
+ User->>BrandController: GET /api/v1/brands/{brandId}
+ BrandController->>BrandReader: getBrand({brandId})
+ BrandReader-->>BrandController: brand
+ BrandController-->>User: brand
+```
+### 2. 주문 생성
+
+```mermaid
+sequenceDiagram
+ participant User
+ participant OrderController
+ participant OrderService
+ participant ProductReader
+ participant PointReader
+ participant ProductService
+ participant PointService
+ participant OrderRepository
+
+ User->>OrderController: POST /api/v1/orders (body: {productId, quantity})
+ OrderController->>OrderService: createOrder(userId, {productId, quantity})
+
+ %% 조회 및 검증 (서버가 가격/포인트 확인) %%
+ OrderService->>ProductReader: getProduct({productId})
+ ProductReader -->>OrderService: product(현재가격, 재고)
+
+ OrderService->>PointReader: getPoint(userId)
+ PointReader-->>OrderService: point (현재 잔여 포인트)
+
+ %% 서버가 totalPrice를 직접 계산하는 로직 명시 %%
+ Note right of OrderService: 3. 서버가 totalPrice를 직접 계산
(product.getPrice() * quantity)
+
+ %% 재고 및 포인트 차감 (계산된 totalPrice 사용) %%
+ critical Transaction Block
+ OrderService ->>ProductService: decreaseStock(productId, quantity)
+ OrderService ->>PointService: deductPoint(calculatedTotalPrice)
+ OrderService->> OrderRepository: save(new Order(..., calculatedTotalPrice))
+ OrderRepository-->>OrderService: orderInfo
+ end
+
+ %% 응답 %%
+ OrderService -->> OrderController:orderInfo
+ OrderController-->> User:orderInfo
+ ```
\ No newline at end of file
diff --git a/.docs/design/03-class-diagram.md b/.docs/design/03-class-diagram.md
new file mode 100644
index 000000000..d72b631aa
--- /dev/null
+++ b/.docs/design/03-class-diagram.md
@@ -0,0 +1,50 @@
+classDiagram
+ class User {
+ Long id
+ String name
+ int point
+ }
+ class Product {
+ Long id
+ String name
+ Price price %% int -> Price (VO)
+ Quantity quantity %% int -> Quantity (VO)
+ }
+ class Brand {
+ Long id
+ String name
+ }
+ class Like {
+ User user
+ Product product
+ %% boolean liked 제거
+ }
+ class Order {
+ Long id
+ User user
+ int totalPrice
+ Timestamp created_at
+ }
+ class OrderItem {
+ Order order
+ Product product
+ int quantity
+ int orderPrice
+ }
+
+ %% --- VO 정의 ---
+ class Price { <> }
+ class Quantity { <> }
+
+ %% --- 관계 정의 ---
+ Product --> Brand : (상품은 브랜드를 가짐)
+ Product --> Price
+ Product --> Quantity
+
+ Order --> User : (주문은 유저를 가짐)
+
+ OrderItem --> Order : (주문 항목은 주문에 속함)
+ OrderItem --> Product : (주문 항목은 상품을 가짐)
+
+ Like --> User : (좋아요는 유저를 가짐)
+ Like --> Product : (좋아요는 상품을 가짐)
\ No newline at end of file
diff --git a/.docs/design/04-erd.md b/.docs/design/04-erd.md
new file mode 100644
index 000000000..59f99fff5
--- /dev/null
+++ b/.docs/design/04-erd.md
@@ -0,0 +1,47 @@
+erDiagram
+ users {
+ %% BaseEntity가 id, created_at 등을 제공합니다.
+ varchar name
+ }
+ points {
+ bigint id PK
+ bigint user_id FK
+ int point
+ }
+ products {
+ %% BaseEntity가 id, created_at 등을 제공합니다.
+ varchar name
+ int price
+ int stock_quantity
+ bigint brand_id FK
+ }
+ brands {
+ %% BaseEntity가 id, created_at 등을 제공합니다.
+ varchar name
+ }
+ likes {
+ bigint user_id PK, FK
+ bigint product_id PK, FK
+ }
+ orders {
+ %% BaseEntity가 id, created_at 등을 제공합니다.
+ bigint user_id FK
+ int total_price
+ %% Timestamp created_at 제거
+ }
+ orderitems {
+ %% BaseEntity가 id, created_at 등을 제공합니다.
+ bigint order_id FK
+ bigint product_id FK
+ int quantity
+ int order_price
+ }
+
+ %% --- 관계 정의 (1:N) ---
+ users ||--o{ likes : "likes"
+ users ||--o{ orders : "places"
+ users ||--o{ points : "has"
+ products ||--o{ likes : "is_liked"
+ products ||--o{ orderitems : "is_in"
+ brands ||--o{ products : "has"
+ orders ||--o{ orderitems : "contains"
\ No newline at end of file
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java
new file mode 100644
index 000000000..225b57ef7
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java
@@ -0,0 +1,93 @@
+package com.loopers.application.like;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import com.loopers.domain.like.LikeService;
+import com.loopers.domain.user.UserService;
+import com.loopers.domain.product.ProductService;
+import com.loopers.domain.user.UserModel;
+import com.loopers.domain.product.ProductModel;
+import com.loopers.domain.user.UserId;
+import java.util.List;
+import java.util.Map;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import org.springframework.transaction.annotation.Transactional;
+
+@RequiredArgsConstructor
+@Component
+public class LikeFacade {
+
+ private final LikeService likeService;
+
+ private final UserService userService;
+
+ private final ProductService productService;
+
+ @Transactional
+ public void toggleLike(UserId userId, Long productId) {
+ UserModel user = userService.getUser(userId);
+ if (user == null) {
+ throw new CoreException(ErrorType.NOT_FOUND, "유저를 찾을 수 없습니다.");
+ }
+ ProductModel product = productService.getProduct(productId);
+ if (product == null) {
+ throw new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.");
+ }
+ likeService.toggleLike(user, product);
+ }
+
+ @Transactional
+ public void addLike(UserId userId, Long productId) {
+ UserModel user = userService.getUser(userId);
+ if (user == null) {
+ throw new CoreException(ErrorType.NOT_FOUND, "유저를 찾을 수 없습니다.");
+ }
+ ProductModel product = productService.getProduct(productId);
+ if (product == null) {
+ throw new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.");
+ }
+ likeService.addLike(user, product);
+ }
+
+ @Transactional
+ public void removeLike(UserId userId, Long productId) {
+ UserModel user = userService.getUser(userId);
+ if (user == null) {
+ throw new CoreException(ErrorType.NOT_FOUND, "유저를 찾을 수 없습니다.");
+ }
+ ProductModel product = productService.getProduct(productId);
+ if (product == null) {
+ throw new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.");
+ }
+ likeService.removeLike(user, product);
+ }
+
+ @Transactional(readOnly = true)
+ public boolean isLiked(UserId userId, Long productId) {
+ UserModel user = userService.getUser(userId);
+ ProductModel product = productService.getProduct(productId);
+ return likeService.isLiked(user, product);
+ }
+
+ @Transactional(readOnly = true)
+ public List getLikedProducts(UserId userId) {
+ UserModel user = userService.getUser(userId);
+ return likeService.getLikedProducts(user);
+ }
+
+ @Transactional(readOnly = true)
+ public long getLikeCount(Long productId) {
+ ProductModel product = productService.getProduct(productId);
+ return likeService.getLikeCount(product);
+ }
+
+ @Transactional(readOnly = true)
+ public Map getLikeCounts(List productIds) {
+
+ List products = productService.getProductsByIds(productIds);
+
+ return likeService.getLikeCounts(products);
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java
new file mode 100644
index 000000000..319ea330f
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java
@@ -0,0 +1,13 @@
+package com.loopers.application.like;
+
+import com.loopers.domain.like.LikeModel;
+
+public record LikeInfo(Long id, Long userId, Long productId) {
+ public static LikeInfo from(LikeModel model) {
+ return new LikeInfo(
+ model.getId(),
+ model.getUser().getId(),
+ model.getProduct().getId()
+ );
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
new file mode 100644
index 000000000..04f24f1fa
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
@@ -0,0 +1,55 @@
+package com.loopers.application.order;
+
+import com.loopers.domain.order.OrderModel;
+import com.loopers.domain.order.OrderService;
+import com.loopers.domain.user.UserModel;
+import com.loopers.domain.user.UserService;
+import com.loopers.domain.user.UserId;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+@RequiredArgsConstructor
+@Component
+public class OrderFacade {
+
+ private final OrderService orderService;
+ private final UserService userService;
+
+ @Transactional(readOnly = true)
+ public OrderInfo getOrder(Long id) {
+ OrderModel order = orderService.getOrder(id);
+ if (order == null) {
+ throw new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.");
+ }
+ return OrderInfo.from(order);
+ }
+
+ @Transactional(readOnly = true)
+ public List getUserOrders(UserId userId) {
+ UserModel user = userService.getUser(userId);
+ if (user == null) {
+ throw new CoreException(ErrorType.NOT_FOUND, "유저를 찾을 수 없습니다.");
+ }
+ List orders = orderService.getUserOrders(user);
+ return orders.stream()
+ .map(OrderInfo::from)
+ .collect(Collectors.toList());
+ }
+
+ @Transactional
+ public OrderInfo createOrder(UserId userId, List items) {
+ UserModel user = userService.getUser(userId);
+ if (user == null) {
+ throw new CoreException(ErrorType.NOT_FOUND, "유저를 찾을 수 없습니다.");
+ }
+
+ OrderModel order = orderService.createOrder(user, items);
+ return OrderInfo.from(order);
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java
new file mode 100644
index 000000000..7d039bbb8
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java
@@ -0,0 +1,24 @@
+package com.loopers.application.order;
+
+import com.loopers.domain.common.Money;
+import java.util.List;
+import com.loopers.domain.order.OrderModel;
+import com.loopers.application.order.OrderItemInfo;
+
+public record OrderInfo(Long id, Long userId, Money totalPrice, List orderItems) {
+ public static OrderInfo from(OrderModel order) {
+ List items = order.getOrderItems().stream()
+ .map(item -> new OrderItemInfo(
+ item.getProduct().getId(),
+ item.getQuantity().quantity(),
+ item.getOrderPrice()
+ ))
+ .toList();
+ return new OrderInfo(
+ order.getId(),
+ order.getUser().getId(),
+ order.getTotalPrice(),
+ items
+ );
+ }
+ }
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java
new file mode 100644
index 000000000..8897a01dc
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java
@@ -0,0 +1,15 @@
+package com.loopers.application.order;
+
+import com.loopers.domain.common.Money;
+import com.loopers.domain.order.OrderItemModel;
+import com.loopers.application.order.OrderItemInfo;
+
+public record OrderItemInfo(Long productId, Integer quantity, Money orderPrice) {
+ public static OrderItemInfo from(OrderItemModel item) {
+ return new OrderItemInfo(
+ item.getProduct().getId(),
+ item.getQuantity().quantity(),
+ item.getOrderPrice()
+ );
+ }
+ }
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java
new file mode 100644
index 000000000..75b33104d
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java
@@ -0,0 +1,46 @@
+package com.loopers.application.point;
+
+import com.loopers.domain.point.PointModel;
+import com.loopers.domain.point.PointService;
+import com.loopers.domain.user.UserModel;
+import com.loopers.domain.user.UserService;
+import com.loopers.domain.user.UserId;
+import com.loopers.domain.common.Money;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+@RequiredArgsConstructor
+@Component
+public class PointFacade {
+ private final PointService pointService;
+ private final UserService userService;
+
+ public PointInfo getPoint(UserId userId) {
+ UserModel user = userService.getUser(userId);
+ if (user == null) {
+ throw new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 요청입니다.");
+ }
+ PointModel pointModel = new PointModel(user, new Money(0));
+ PointModel point = pointService.findPoint(pointModel);
+
+ if (point == null) {
+ throw new CoreException(ErrorType.NOT_FOUND, "포인트 정보가 없습니다.");
+ }
+
+ return PointInfo.from(point);
+ }
+
+ public PointInfo chargePoint(UserId userId, Money point) {
+ UserModel user = userService.getUser(userId);
+ if (user == null) {
+ throw new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 요청입니다.");
+ }
+ PointModel pointModel = new PointModel(user, point);
+ pointService.charge(pointModel);
+
+ PointModel charged = pointService.findPoint(new PointModel(user, point));
+ return PointInfo.from(charged);
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/point/PointInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/point/PointInfo.java
new file mode 100644
index 000000000..5e14d764f
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/point/PointInfo.java
@@ -0,0 +1,14 @@
+package com.loopers.application.point;
+
+import com.loopers.domain.point.PointModel;
+import com.loopers.domain.user.UserModel;
+import com.loopers.domain.common.Money;
+
+public record PointInfo(Long id, UserModel user, Money point) {
+ public static PointInfo from(PointModel model) {
+ return new PointInfo(model.getId(), model.getUser(), model.getPoint());
+ }
+ public Money getPoint() {
+ return point;
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
new file mode 100644
index 000000000..fb6084268
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
@@ -0,0 +1,43 @@
+package com.loopers.application.product;
+
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import com.loopers.domain.product.ProductService;
+import com.loopers.domain.product.ProductModel;
+import com.loopers.domain.common.Quantity;
+
+@RequiredArgsConstructor
+@Component
+public class ProductFacade {
+
+ private final ProductService productService;
+
+ // 상품 다건 조회 - 페이징 지원
+ public Page getProducts(Pageable pageable, String sort, String brandName) {
+ Page productPage = productService.getProducts(pageable, sort, brandName);
+ return productPage.map(ProductInfo::from);
+ }
+
+ // 상품 단건 조회
+ public ProductInfo getProduct(Long id){
+ ProductModel product = productService.getProduct(id);
+ if (product == null) {
+ throw new CoreException(ErrorType.NOT_FOUND, "상품이 존재하지 않습니다.");
+ }
+ return ProductInfo.from(product);
+ }
+
+ // 상품 재고 조회
+ public Quantity getQuantity(Long id) {
+ return productService.getQuantity(id).orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품이 존재하지 않습니다."));
+ }
+
+ public void updateQuantity(Long id, Quantity quantity) {
+ productService.updateQuantity(id, quantity);
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java
new file mode 100644
index 000000000..714742604
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java
@@ -0,0 +1,17 @@
+package com.loopers.application.product;
+
+import com.loopers.domain.product.Brand;
+import com.loopers.domain.common.Money;
+import com.loopers.domain.product.ProductModel;
+
+public record ProductInfo(Long id, String name, Brand brand, Money price, Long likeCount) {
+ public static ProductInfo from(ProductModel model) {
+ return new ProductInfo(
+ model.getId(),
+ model.getName(),
+ model.getBrand(),
+ model.getPrice(),
+ model.getLikeCount()
+ );
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java
new file mode 100644
index 000000000..17c59c944
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java
@@ -0,0 +1,32 @@
+package com.loopers.application.user;
+
+import com.loopers.domain.user.Email;
+import com.loopers.domain.user.UserId;
+import com.loopers.domain.user.BirthDate;
+import com.loopers.domain.user.Gender;
+import com.loopers.domain.user.UserModel;
+import com.loopers.domain.user.UserService;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+@RequiredArgsConstructor
+@Component
+public class UserFacade {
+ private final UserService userService;
+
+ public UserInfo signup(String userId, String email, String gender, String birthDate) {
+ UserModel userModel = new UserModel(new UserId(userId), new Email(email), new Gender(gender), new BirthDate(birthDate));
+ UserModel savedUser = userService.signUp(userModel);
+ return UserInfo.from(savedUser);
+ }
+
+ public UserInfo getUser(UserId userId) {
+ UserModel user = userService.getUser(userId);
+ if (user == null) {
+ throw new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 요청입니다.");
+ }
+ return UserInfo.from(user);
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java
new file mode 100644
index 000000000..3821e5395
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java
@@ -0,0 +1,14 @@
+package com.loopers.application.user;
+
+import com.loopers.domain.user.UserModel;
+
+public record UserInfo(String userId, String email, String birthDate, String gender) {
+ public static UserInfo from(UserModel model) {
+ return new UserInfo(
+ model.getUserId().userId(),
+ model.getEmail().email(),
+ model.getBirthDate().birthDate(),
+ model.getGender().gender()
+ );
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java b/apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java
new file mode 100644
index 000000000..67887a971
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java
@@ -0,0 +1,37 @@
+package com.loopers.domain.common;
+
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import jakarta.persistence.Embeddable;
+
+@Embeddable
+public class Money {
+ private long value;
+
+ protected Money() {
+ }
+
+ public Money(long value) {
+ if (value < 0) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "가격은 0 이상이어야 합니다.");
+ }
+ this.value = value;
+ }
+
+ public long value() {
+ return value;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ Money money = (Money) o;
+ return value == money.value;
+ }
+
+ @Override
+ public int hashCode() {
+ return Long.hashCode(value);
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/common/Quantity.java b/apps/commerce-api/src/main/java/com/loopers/domain/common/Quantity.java
new file mode 100644
index 000000000..a701e72e3
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/common/Quantity.java
@@ -0,0 +1,37 @@
+package com.loopers.domain.common;
+
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import jakarta.persistence.Embeddable;
+
+@Embeddable
+public class Quantity {
+ private int quantity;
+
+ protected Quantity() {
+ }
+
+ public Quantity(int quantity) {
+ if (quantity < 0) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "수량은 0 이상이어야 합니다.");
+ }
+ this.quantity = quantity;
+ }
+
+ public int quantity() {
+ return quantity;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ Quantity quantity1 = (Quantity) o;
+ return quantity == quantity1.quantity;
+ }
+
+ @Override
+ public int hashCode() {
+ return Integer.hashCode(quantity);
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java
new file mode 100644
index 000000000..c3f000741
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java
@@ -0,0 +1,43 @@
+package com.loopers.domain.like;
+
+import com.loopers.domain.BaseEntity;
+import com.loopers.domain.user.UserModel;
+import com.loopers.domain.product.ProductModel;
+import jakarta.persistence.Entity;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import jakarta.persistence.UniqueConstraint;
+
+@Entity
+@Table(
+ name = "like",
+ uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "product_id"})
+)
+public class LikeModel extends BaseEntity {
+
+ @ManyToOne
+ @JoinColumn(name = "user_id")
+ private UserModel user;
+ @ManyToOne
+ @JoinColumn(name = "product_id")
+ private ProductModel product;
+
+ public LikeModel() {
+ }
+
+ public LikeModel(UserModel user, ProductModel product) {
+ this.user = user;
+ this.product = product;
+ }
+
+ public UserModel getUser() {
+ return user;
+ }
+
+ public ProductModel getProduct() {
+ return product;
+ }
+
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java
new file mode 100644
index 000000000..f4d8f074c
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java
@@ -0,0 +1,29 @@
+package com.loopers.domain.like;
+
+import com.loopers.domain.user.UserModel;
+import com.loopers.domain.product.ProductModel;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+public interface LikeRepository {
+
+ // 좋아요 여부 조회
+ Optional findByUserAndProduct(UserModel user, ProductModel product);
+
+ // 사용자가 좋아요한 상품 목록 조회
+ List findLikedProductsByUser(UserModel user);
+
+ // 상품의 좋아요 수 조회
+ long countByProductLiked(ProductModel product);
+
+ // 상품의 좋아요 수 일괄 집계
+ Map countByProductIdsLiked(Collection productIds);
+
+ // 좋아요 등록
+ LikeModel save(LikeModel like);
+
+ // 좋아요 삭제
+ void delete(LikeModel like);
+}
\ No newline at end of file
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java
new file mode 100644
index 000000000..e3b39b328
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java
@@ -0,0 +1,72 @@
+package com.loopers.domain.like;
+
+import com.loopers.domain.user.UserModel;
+import com.loopers.domain.product.ProductModel;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+@RequiredArgsConstructor
+@Component
+public class LikeService {
+
+ private final LikeRepository likeRepository;
+
+ // 좋아요 취소 또는 등록
+ @Transactional
+ public void toggleLike(UserModel user, ProductModel product) {
+ var existing = likeRepository.findByUserAndProduct(user, product);
+
+ if (existing.isPresent()) {
+ likeRepository.delete(existing.get());
+ } else {
+ LikeModel newLike = new LikeModel(user, product);
+ likeRepository.save(newLike);
+ }
+ }
+
+ // 좋아요 등록: 좋아요가 없으면 추가, 있으면 취소
+ @Transactional
+ public void addLike(UserModel user, ProductModel product) {
+ var existing = likeRepository.findByUserAndProduct(user, product);
+ if (existing.isEmpty()) {
+ LikeModel newLike = new LikeModel(user, product);
+ likeRepository.save(newLike);
+ }
+ }
+
+ // 좋아요 취소: 좋아요가 있으면 취소, 없으면 추가
+ @Transactional
+ public void removeLike(UserModel user, ProductModel product) {
+ var existing = likeRepository.findByUserAndProduct(user, product);
+ existing.ifPresent(likeRepository::delete);
+ }
+
+ // 좋아요 여부 확인
+ @Transactional(readOnly = true)
+ public boolean isLiked(UserModel user, ProductModel product) {
+ return likeRepository.findByUserAndProduct(user, product).isPresent();
+ }
+
+ // 좋아요한 상품 목록 조회
+ @Transactional(readOnly = true)
+ public List getLikedProducts(UserModel user) {
+ return likeRepository.findLikedProductsByUser(user);
+ }
+
+ // 좋아요 수 조회
+ @Transactional(readOnly = true)
+ public long getLikeCount(ProductModel product) {
+ return likeRepository.countByProductLiked(product);
+ }
+
+ // 좋아요 수 일괄 집계
+ @Transactional(readOnly = true)
+ public Map getLikeCounts(List products) {
+ var ids = products.stream().map(ProductModel::getId).collect(Collectors.toSet());
+ return likeRepository.countByProductIdsLiked(ids);
+ }
+}
\ No newline at end of file
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java
new file mode 100644
index 000000000..9692a57a0
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java
@@ -0,0 +1,63 @@
+package com.loopers.domain.order;
+
+import com.loopers.domain.BaseEntity;
+import com.loopers.domain.product.ProductModel;
+import com.loopers.domain.common.Quantity;
+import com.loopers.domain.common.Money;
+
+import jakarta.persistence.Entity;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import jakarta.persistence.Embedded;
+import jakarta.persistence.AttributeOverride;
+import jakarta.persistence.Column;
+
+@Entity
+@Table(name = "orderitems")
+public class OrderItemModel extends BaseEntity {
+
+ @ManyToOne
+ @JoinColumn(name = "order_id", nullable = false)
+ private OrderModel order;
+
+ @ManyToOne
+ @JoinColumn(name = "product_id", nullable = false)
+ private ProductModel product;
+
+ @Embedded
+ private Quantity quantity;
+
+ @Embedded
+ @AttributeOverride(name = "value", column = @Column(name = "order_price"))
+ private Money orderPrice;
+
+ protected OrderItemModel() {
+ }
+
+ public OrderItemModel(ProductModel product, Quantity quantity, Money orderPrice) {
+ this.product = product;
+ this.quantity = quantity;
+ this.orderPrice = orderPrice;
+ }
+
+ public OrderModel getOrder() {
+ return order;
+ }
+
+ public ProductModel getProduct() {
+ return product;
+ }
+
+ public Quantity getQuantity() {
+ return quantity;
+ }
+
+ public Money getOrderPrice() {
+ return orderPrice;
+ }
+
+ protected void setOrder(OrderModel order) {
+ this.order = order;
+ }
+}
\ No newline at end of file
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java
new file mode 100644
index 000000000..b90595a2b
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java
@@ -0,0 +1,60 @@
+package com.loopers.domain.order;
+
+import com.loopers.domain.BaseEntity;
+import com.loopers.domain.user.UserModel;
+import com.loopers.domain.common.Money;
+
+import jakarta.persistence.Entity;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import jakarta.persistence.OneToMany;
+import jakarta.persistence.CascadeType;
+import jakarta.persistence.Embedded;
+import jakarta.persistence.AttributeOverride;
+import jakarta.persistence.Column;
+
+import java.util.List;
+import java.util.ArrayList;
+
+@Entity
+@Table(name = "orders")
+public class OrderModel extends BaseEntity {
+
+ @ManyToOne
+ @JoinColumn(name = "user_id")
+ private UserModel user;
+
+ @Embedded
+ @AttributeOverride(name = "value", column = @Column(name = "total_price"))
+ private Money totalPrice;
+
+ @OneToMany(cascade = CascadeType.ALL, mappedBy = "order")
+ private List orderItems = new ArrayList<>();
+
+ public OrderModel() {
+ }
+
+ public OrderModel(UserModel user, Money totalPrice, List orderItems) {
+ this.user = user;
+ this.totalPrice = totalPrice;
+ orderItems.forEach(this::addOrderItem);
+ }
+
+ public UserModel getUser() {
+ return user;
+ }
+
+ public Money getTotalPrice() {
+ return totalPrice;
+ }
+
+ public List getOrderItems() {
+ return orderItems;
+ }
+
+ public void addOrderItem(OrderItemModel orderItem) {
+ orderItems.add(orderItem);
+ orderItem.setOrder(this);
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java
new file mode 100644
index 000000000..3b10eb87a
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java
@@ -0,0 +1,15 @@
+package com.loopers.domain.order;
+
+import com.loopers.domain.user.UserModel;
+import java.util.Optional;
+import java.util.List;
+
+public interface OrderRepository {
+ // 주문 저장
+ OrderModel save(OrderModel order);
+ // 주문 단건 조회
+ Optional findById(Long id);
+ // 사용자 주문 조회
+ List findByUserId(UserModel user);
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java
new file mode 100644
index 000000000..567b2bf09
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java
@@ -0,0 +1,82 @@
+package com.loopers.domain.order;
+
+import com.loopers.domain.user.UserModel;
+import com.loopers.domain.product.ProductModel;
+import com.loopers.domain.product.ProductService;
+import com.loopers.domain.point.PointService;
+import com.loopers.domain.common.Money;
+import com.loopers.domain.common.Quantity;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+import java.util.ArrayList;
+
+@RequiredArgsConstructor
+@Component
+public class OrderService {
+
+ private final OrderRepository orderRepository;
+ private final ProductService productService;
+ private final PointService pointService;
+
+ @Transactional(readOnly = true)
+ public OrderModel getOrder(Long id) {
+ return orderRepository.findById(id).orElse(null);
+ }
+
+ @Transactional(readOnly = true)
+ public List getUserOrders(UserModel user) {
+ return orderRepository.findByUserId(user);
+ }
+
+ @Transactional
+ public OrderModel createOrder(UserModel user, List items) {
+
+ if (items == null || items.isEmpty()) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "주문 항목이 비어있습니다.");
+ }
+
+ List orderItems = new ArrayList<>();
+ long totalPriceValue = 0;
+
+ // 각 상품에 대해 재고 확인 및 차감, 주문 항목 생성
+ for (OrderItemRequest item : items) {
+ if (item.quantity() == null || item.quantity() <= 0) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "주문 수량은 1개 이상이어야 합니다.");
+ }
+
+ ProductModel product = productService.getProduct(item.productId());
+ if (product == null) {
+ throw new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다. productId: " + item.productId());
+ }
+
+ Quantity quantity = new Quantity(item.quantity());
+
+ // 재고 차감
+ productService.updateQuantity(item.productId(), quantity);
+
+ // 주문 항목 가격 계산 (상품 가격 * 수량)
+ Money orderPrice = new Money(product.getPrice().value() * quantity.quantity());
+ totalPriceValue += orderPrice.value();
+
+ // 주문 항목 생성
+ OrderItemModel orderItem = new OrderItemModel(product, quantity, orderPrice);
+ orderItems.add(orderItem);
+ }
+
+ Money totalPrice = new Money(totalPriceValue);
+
+ // 포인트 차감
+ pointService.use(user, totalPrice);
+
+ // 주문 생성 및 저장
+ OrderModel order = new OrderModel(user, totalPrice, orderItems);
+ return orderRepository.save(order);
+ }
+
+ public record OrderItemRequest(Long productId, Integer quantity) {}
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointModel.java
new file mode 100644
index 000000000..27162addb
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointModel.java
@@ -0,0 +1,60 @@
+package com.loopers.domain.point;
+
+import com.loopers.domain.BaseEntity;
+import com.loopers.domain.common.Money;
+import com.loopers.domain.user.UserModel;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import jakarta.persistence.Entity;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import jakarta.persistence.Embedded;
+
+
+@Entity
+@Table(name = "point")
+public class PointModel extends BaseEntity {
+
+ @ManyToOne
+ @JoinColumn(name = "user_model_id")
+ private UserModel user;
+ @Embedded
+ private Money point;
+
+ public PointModel() {
+ }
+
+ public PointModel(UserModel user, Money point) {
+
+ this.user = user;
+ this.point = point;
+ }
+
+ public UserModel getUser() {
+ return user;
+ }
+
+ public Money getPoint() {
+ return point;
+ }
+
+ public void charge(Money chargePoint) {
+ long newPointValue = this.point.value() + chargePoint.value();
+ this.point = new Money(newPointValue);
+ }
+
+ public void use(Money usePoint) {
+ if (this.point.value() < usePoint.value()) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "포인트가 부족합니다.");
+ }
+
+ if (usePoint.value() > this.point.value()) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "사용 금액이 보유 포인트를 초과합니다.");
+ }
+
+ long newPointValue = this.point.value() - usePoint.value();
+ this.point = new Money(newPointValue);
+
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointRepository.java
new file mode 100644
index 000000000..dd96643db
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointRepository.java
@@ -0,0 +1,10 @@
+package com.loopers.domain.point;
+
+import com.loopers.domain.user.UserModel;
+
+import java.util.Optional;
+
+public interface PointRepository {
+ Optional findPoint(UserModel user);
+ PointModel save(PointModel pointModel);
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java
new file mode 100644
index 000000000..ec8e4d64c
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java
@@ -0,0 +1,57 @@
+package com.loopers.domain.point;
+
+import com.loopers.domain.user.UserRepository;
+import com.loopers.domain.common.Money;
+import lombok.RequiredArgsConstructor;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.stereotype.Component;
+
+
+import com.loopers.domain.user.UserModel;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+
+@RequiredArgsConstructor
+@Component
+public class PointService {
+
+ private final PointRepository pointRepository;
+ private final UserRepository userRepository;
+
+ @Transactional(readOnly = true)
+ public PointModel findPoint(PointModel point) {
+ UserModel requestUser = point.getUser();
+ var foundUser = userRepository.findById(requestUser.getId());
+ if (foundUser.isEmpty()) {
+ return null;
+ }
+ return pointRepository.findPoint(foundUser.get()).orElse(null);
+ }
+
+ @Transactional
+ public void charge(PointModel point) {
+ UserModel user = point.getUser();
+ var foundUser = userRepository.findById(user.getId())
+ .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "유저가 존재하지 않습니다."));
+
+ var existing = pointRepository.findPoint(foundUser);
+ if (existing.isPresent()) {
+ existing.get().charge(point.getPoint());
+ pointRepository.save(existing.get());
+ return;
+ }
+ pointRepository.save(new PointModel(foundUser, point.getPoint()));
+ }
+
+ @Transactional
+ public void use(UserModel user, Money usePoint) {
+ var foundUser = userRepository.findById(user.getId())
+ .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "유저가 존재하지 않습니다."));
+
+ var existing = pointRepository.findPoint(foundUser)
+ .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "포인트 정보가 없습니다."));
+
+ existing.use(usePoint);
+ pointRepository.save(existing);
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Brand.java
new file mode 100644
index 000000000..129f4a572
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Brand.java
@@ -0,0 +1,37 @@
+package com.loopers.domain.product;
+
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import jakarta.persistence.Embeddable;
+
+@Embeddable
+public class Brand {
+ private String name;
+
+ protected Brand() {
+ }
+
+ public Brand(String name) {
+ if (name == null || name.isBlank()) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 이름은 비어있을 수 없습니다.");
+ }
+ this.name = name;
+ }
+
+ public String name() {
+ return name;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ Brand brand = (Brand) o;
+ return name != null ? name.equals(brand.name) : brand.name == null;
+ }
+
+ @Override
+ public int hashCode() {
+ return name != null ? name.hashCode() : 0;
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java
new file mode 100644
index 000000000..d16899c0e
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java
@@ -0,0 +1,75 @@
+package com.loopers.domain.product;
+
+import com.loopers.domain.BaseEntity;
+import com.loopers.domain.common.Quantity;
+import com.loopers.domain.common.Money;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Table;
+import jakarta.persistence.Embedded;
+import jakarta.persistence.AttributeOverride;
+import jakarta.persistence.Column;
+
+@Entity
+@Table(name = "product")
+public class ProductModel extends BaseEntity {
+
+ private String name;
+ @Embedded
+ @AttributeOverride(name = "name", column = @Column(name = "brand_name"))
+ private Brand brand;
+ @Embedded
+ private Money price;
+ @Embedded
+ private Quantity quantity;
+ private Long likeCount;
+
+ public ProductModel() {
+ }
+
+ public ProductModel(String name, Brand brand, Money price, Quantity quantity) {
+ if (name == null || name.isBlank()) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "상품 이름은 비어있을 수 없습니다.");
+ }
+ this.name = name;
+ this.brand = brand;
+ this.price = price;
+ this.quantity = quantity;
+ this.likeCount = 0L;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public Brand getBrand() {
+ return brand;
+ }
+
+ public Money getPrice() {
+ return price;
+ }
+
+ public Quantity getQuantity() {
+ return quantity;
+ }
+
+ public Long getLikeCount() {
+ return likeCount;
+ }
+
+ public void setLikeCount(Long likeCount) {
+ this.likeCount = likeCount;
+ }
+
+ public void decreaseQuantity(Quantity quantityToDecrease) {
+ if (this.quantity.quantity() < quantityToDecrease.quantity()) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다.");
+ }
+
+ this.quantity = new Quantity(this.quantity.quantity() - quantityToDecrease.quantity());
+
+ }
+
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java
new file mode 100644
index 000000000..4ec09074b
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java
@@ -0,0 +1,24 @@
+package com.loopers.domain.product;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+
+public interface ProductRepository {
+ // 상품 목록 조회(다건)
+ Page findAll(Pageable pageable);
+
+ // 브랜드로 상품 목록 조회(다건)
+ Page findByBrandName(String brandName, Pageable pageable);
+
+ // 상품 상세 조회(단건)
+ Optional findById(Long id);
+
+ // 상품 ID 목록으로 조회
+ List findAllById(Set ids);
+
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java
new file mode 100644
index 000000000..53e4dd15e
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java
@@ -0,0 +1,91 @@
+package com.loopers.domain.product;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.PageImpl;
+
+import com.loopers.domain.like.LikeRepository;
+import com.loopers.domain.common.Quantity;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+
+@RequiredArgsConstructor
+@Component
+public class ProductService {
+
+ private final ProductRepository productRepository;
+
+ private final LikeRepository likeRepository;
+
+ @Transactional(readOnly = true)
+ public Page getProducts(Pageable pageable, String sort, String brandName) {
+ Page productPage;
+ if (brandName != null && !brandName.isBlank()) {
+ productPage = productRepository.findByBrandName(brandName, pageable);
+ } else {
+ productPage = productRepository.findAll(pageable);
+ }
+
+ List products = productPage.getContent();
+ Map likeCounts = likeRepository
+ .countByProductIdsLiked(products.stream().map(ProductModel::getId).collect(Collectors.toSet()));
+ products.forEach(product -> product.setLikeCount(likeCounts.getOrDefault(product.getId(), 0L)));
+
+ // likes_desc 정렬은 메모리에서 처리
+ if ("likes_desc".equals(sort)) {
+ products.sort((a, b) -> Long.compare(
+ b.getLikeCount() != null ? b.getLikeCount() : 0L,
+ a.getLikeCount() != null ? a.getLikeCount() : 0L));
+
+ // 정렬된 리스트로 새로운 Page 객체 생성하여 반환
+ return new PageImpl<>(products, pageable, productPage.getTotalElements());
+ }
+
+ return productPage;
+ }
+
+
+ @Transactional(readOnly = true)
+ public ProductModel getProduct(Long id) {
+ ProductModel product = productRepository.findById(id).orElse(null);
+ if (product != null) {
+ product.setLikeCount(likeRepository.countByProductLiked(product));
+ }
+ return product;
+ }
+
+ @Transactional(readOnly = true)
+ public Optional getQuantity(Long id) {
+ ProductModel product = productRepository.findById(id)
+ .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품이 존재하지 않습니다."));
+ return Optional.of(product.getQuantity());
+ }
+
+ @Transactional
+ public void updateQuantity(Long id, Quantity quantityToDecrease) {
+ ProductModel product = productRepository.findById(id)
+ .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품이 존재하지 않습니다."));
+
+ product.decreaseQuantity(quantityToDecrease);
+ }
+
+ @Transactional(readOnly = true)
+ public List getProductsByIds(List productIds) {
+ Set ids = productIds.stream().collect(Collectors.toSet());
+ List products = productRepository.findAllById(ids);
+ Map likeCounts = likeRepository
+ .countByProductIdsLiked(products.stream().map(ProductModel::getId).collect(Collectors.toSet()));
+ products.forEach(product -> product.setLikeCount(likeCounts.getOrDefault(product.getId(), 0L)));
+ return products;
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDate.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDate.java
new file mode 100644
index 000000000..97eab71e3
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDate.java
@@ -0,0 +1,41 @@
+package com.loopers.domain.user;
+
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import jakarta.persistence.Embeddable;
+
+@Embeddable
+public class BirthDate {
+ private String birthDate;
+
+ protected BirthDate() {
+ }
+
+ public BirthDate(String birthDate) {
+ //생년월일 validation check
+ if (birthDate == null || birthDate.isBlank()) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 비어있을 수 없습니다.");
+ }
+ if (!birthDate.matches("^\\d{4}-\\d{2}-\\d{2}$")) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "생년월일이 `yyyy-MM-dd` 형식에 맞아야 합니다.");
+ }
+ this.birthDate = birthDate;
+ }
+
+ public String birthDate() {
+ return birthDate;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ BirthDate birthDate1 = (BirthDate) o;
+ return birthDate != null ? birthDate.equals(birthDate1.birthDate) : birthDate1.birthDate == null;
+ }
+
+ @Override
+ public int hashCode() {
+ return birthDate != null ? birthDate.hashCode() : 0;
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/Email.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/Email.java
new file mode 100644
index 000000000..9d7829096
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/Email.java
@@ -0,0 +1,44 @@
+package com.loopers.domain.user;
+
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import jakarta.persistence.Embeddable;
+
+@Embeddable
+public class Email {
+ private String email;
+
+ protected Email() {
+ }
+
+ public Email(String email) {
+ // 이메일 validation check
+ if (email == null || email.isBlank()) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "이메일은 비어있을 수 없습니다.");
+ }
+
+ if (!email.matches("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,63}$")) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "이메일이 `xx@yy.zz` 형식에 맞아야 합니다.");
+ }
+ this.email = email;
+ }
+
+ public String email() {
+ return email;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o)
+ return true;
+ if (o == null || getClass() != o.getClass())
+ return false;
+ Email email1 = (Email) o;
+ return email != null ? email.equals(email1.email) : email1.email == null;
+ }
+
+ @Override
+ public int hashCode() {
+ return email != null ? email.hashCode() : 0;
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/Gender.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/Gender.java
new file mode 100644
index 000000000..0767c451a
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/Gender.java
@@ -0,0 +1,38 @@
+package com.loopers.domain.user;
+
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import jakarta.persistence.Embeddable;
+
+@Embeddable
+public class Gender {
+ private String gender;
+
+ protected Gender() {
+ }
+
+ public Gender(String gender) {
+ //성별 체크
+ if (gender == null || gender.isBlank()) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "성별은 비어있을 수 없습니다.");
+ }
+ this.gender = gender;
+ }
+
+ public String gender() {
+ return gender;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ Gender gender1 = (Gender) o;
+ return gender != null ? gender.equals(gender1.gender) : gender1.gender == null;
+ }
+
+ @Override
+ public int hashCode() {
+ return gender != null ? gender.hashCode() : 0;
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserId.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserId.java
new file mode 100644
index 000000000..db192f67b
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserId.java
@@ -0,0 +1,40 @@
+package com.loopers.domain.user;
+
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import jakarta.persistence.Embeddable;
+
+@Embeddable
+public class UserId {
+ private String userId;
+
+ protected UserId() {
+ }
+
+ public UserId(String userId) {
+ if (userId == null || userId.isBlank()) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "UserId는 비어있을 수 없습니다.");
+ }
+ if (!userId.matches("^[a-zA-Z0-9_-]{1,10}$")) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "ID 가 `영문 및 숫자 10자 이내` 형식에 맞아야 합니다.");
+ }
+ this.userId = userId;
+ }
+
+ public String userId() {
+ return userId;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ UserId userId1 = (UserId) o;
+ return userId != null ? userId.equals(userId1.userId) : userId1.userId == null;
+ }
+
+ @Override
+ public int hashCode() {
+ return userId != null ? userId.hashCode() : 0;
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java
new file mode 100644
index 000000000..b50e14e37
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java
@@ -0,0 +1,49 @@
+package com.loopers.domain.user;
+
+import com.loopers.domain.BaseEntity;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Table;
+import jakarta.persistence.Embedded;
+
+@Entity
+@Table(name = "user")
+public class UserModel extends BaseEntity {
+
+ @Embedded
+ private UserId userId;
+
+ @Embedded
+ private Email email;
+
+ @Embedded
+ private Gender gender;
+
+ @Embedded
+ private BirthDate birthDate;
+
+ protected UserModel() {
+ }
+
+ public UserModel(UserId userId, Email email, Gender gender, BirthDate birthDate) {
+ this.userId = userId;
+ this.email = email;
+ this.gender = gender;
+ this.birthDate = birthDate;
+ }
+
+ public UserId getUserId() {
+ return this.userId;
+ }
+
+ public Email getEmail() {
+ return this.email;
+ }
+
+ public Gender getGender() {
+ return this.gender;
+ }
+
+ public BirthDate getBirthDate() {
+ return this.birthDate;
+ }
+}
\ No newline at end of file
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java
new file mode 100644
index 000000000..a6caeebe5
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java
@@ -0,0 +1,10 @@
+package com.loopers.domain.user;
+
+import java.util.Optional;
+
+public interface UserRepository {
+ Optional find(UserId userId);
+
+ Optional findById(Long id);
+ UserModel save(UserModel userModel);
+}
\ No newline at end of file
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java
new file mode 100644
index 000000000..af6ee8d1c
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java
@@ -0,0 +1,31 @@
+package com.loopers.domain.user;
+
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Optional;
+
+@RequiredArgsConstructor
+@Component
+public class UserService {
+
+ private final UserRepository userRepository;
+
+ @Transactional(readOnly = true)
+ public UserModel getUser(UserId userId) {
+ return userRepository.find(userId).orElse(null);
+ }
+
+ @Transactional
+ public UserModel signUp(UserModel userModel) {
+ Optional user = userRepository.find(userModel.getUserId());
+
+ if (user.isPresent()) {
+ throw new CoreException(ErrorType.CONFLICT, "[userId = " + userModel.getUserId().userId() + "] 아이디가 중복되었습니다.");
+ }
+ return userRepository.save(userModel);
+ }
+}
\ No newline at end of file
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java
new file mode 100644
index 000000000..d8120f35d
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java
@@ -0,0 +1,28 @@
+package com.loopers.infrastructure.like;
+
+import com.loopers.domain.like.LikeModel;
+import com.loopers.domain.user.UserModel;
+import com.loopers.domain.product.ProductModel;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+
+public interface LikeJpaRepository extends JpaRepository {
+
+ Optional findByUserAndProduct(UserModel user, ProductModel product);
+
+ List findByUser(UserModel user);
+
+ long countByProduct(ProductModel product);
+
+ @Query("SELECT l.product.id as productId, COUNT(l) as likeCount " +
+ "FROM LikeModel l " +
+ "WHERE l.product.id IN :productIds " +
+ "GROUP BY l.product.id")
+ Map countByProductIds(@Param("productIds") Set productIds);
+}
\ No newline at end of file
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java
new file mode 100644
index 000000000..a718d1cb1
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java
@@ -0,0 +1,59 @@
+package com.loopers.infrastructure.like;
+
+import com.loopers.domain.like.LikeModel;
+import com.loopers.domain.like.LikeRepository;
+import com.loopers.domain.user.UserModel;
+import com.loopers.domain.product.ProductModel;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Collection;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+@RequiredArgsConstructor
+@Component
+public class LikeRepositoryImpl implements LikeRepository {
+
+ private final LikeJpaRepository likeJpaRepository;
+
+ // 좋아요 여부 조회
+ @Override
+ public Optional findByUserAndProduct(UserModel user, ProductModel product) {
+ return likeJpaRepository.findByUserAndProduct(user, product);
+ }
+
+ // 사용자가 좋아요한 상품 목록 조회
+ @Override
+ public List findLikedProductsByUser(UserModel user) {
+ return likeJpaRepository.findByUser(user).stream()
+ .map(LikeModel::getProduct)
+ .collect(Collectors.toList());
+ }
+
+ // 상품의 좋아요 수 조회
+ @Override
+ public long countByProductLiked(ProductModel product) {
+ return likeJpaRepository.countByProduct(product);
+ }
+
+ // 상품의 좋아요 수 일괄 집계
+ @Override
+ public Map countByProductIdsLiked(Collection productIds) {
+ return likeJpaRepository.countByProductIds(productIds.stream().collect(Collectors.toSet()));
+ }
+
+ // 좋아요 등록
+ @Override
+ public LikeModel save(LikeModel like) {
+ return likeJpaRepository.save(like);
+ }
+
+ // 좋아요 삭제
+ @Override
+ public void delete(LikeModel like) {
+ likeJpaRepository.delete(like);
+ }
+}
\ No newline at end of file
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java
new file mode 100644
index 000000000..3de048b1c
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java
@@ -0,0 +1,11 @@
+package com.loopers.infrastructure.order;
+
+import com.loopers.domain.order.OrderModel;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+
+public interface OrderJpaRepository extends JpaRepository {
+
+ List findByUserId(Long id);
+}
\ No newline at end of file
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java
new file mode 100644
index 000000000..e63cbe72c
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java
@@ -0,0 +1,32 @@
+package com.loopers.infrastructure.order;
+
+import com.loopers.domain.order.OrderModel;
+import com.loopers.domain.order.OrderRepository;
+import com.loopers.domain.user.UserModel;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+import java.util.Optional;
+
+@RequiredArgsConstructor
+@Component
+public class OrderRepositoryImpl implements OrderRepository {
+
+ private final OrderJpaRepository orderJpaRepository;
+
+ @Override
+ public OrderModel save(OrderModel order) {
+ return orderJpaRepository.save(order);
+ }
+
+ @Override
+ public Optional findById(Long id) {
+ return orderJpaRepository.findById(id);
+ }
+
+ @Override
+ public List findByUserId(UserModel user) {
+ return orderJpaRepository.findByUserId(user.getId());
+ }
+}
\ No newline at end of file
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointJpaRepository.java
new file mode 100644
index 000000000..e4ac7e4ab
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointJpaRepository.java
@@ -0,0 +1,11 @@
+package com.loopers.infrastructure.point;
+
+import com.loopers.domain.point.PointModel;
+import com.loopers.domain.user.UserModel;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.Optional;
+
+public interface PointJpaRepository extends JpaRepository {
+ Optional findByUser(UserModel user);
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointRepositoryImpl.java
new file mode 100644
index 000000000..4b81b3b0b
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointRepositoryImpl.java
@@ -0,0 +1,25 @@
+package com.loopers.infrastructure.point;
+
+import com.loopers.domain.point.PointModel;
+import com.loopers.domain.point.PointRepository;
+import com.loopers.domain.user.UserModel;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import java.util.Optional;
+
+@RequiredArgsConstructor
+@Component
+public class PointRepositoryImpl implements PointRepository {
+ private final PointJpaRepository pointJpaRepository;
+
+ @Override
+ public Optional findPoint(UserModel user) {
+ return pointJpaRepository.findByUser(user);
+ }
+
+ @Override
+ public PointModel save(PointModel pointModel) {
+ return pointJpaRepository.save(pointModel);
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java
new file mode 100644
index 000000000..8a74951e8
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java
@@ -0,0 +1,15 @@
+// ProductJpaRepository.java (수정)
+package com.loopers.infrastructure.product;
+
+import com.loopers.domain.product.ProductModel;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+public interface ProductJpaRepository extends JpaRepository {
+
+ @Query("SELECT p FROM ProductModel p WHERE p.brand.name = :brandName ORDER BY p.likeCount DESC")
+ Page findByBrandName(@Param("brandName") String brandName, Pageable pageable);
+}
\ No newline at end of file
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java
new file mode 100644
index 000000000..9d0c5a366
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java
@@ -0,0 +1,39 @@
+package com.loopers.infrastructure.product;
+
+import com.loopers.domain.product.ProductModel;
+import com.loopers.domain.product.ProductRepository;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+@RequiredArgsConstructor
+@Component
+public class ProductRepositoryImpl implements ProductRepository {
+ private final ProductJpaRepository productJpaRepository;
+
+ @Override
+ public Optional findById(Long id) {
+ return productJpaRepository.findById(id);
+ }
+
+ @Override
+ public Page findAll(Pageable pageable) {
+ return productJpaRepository.findAll(pageable);
+ }
+
+ @Override
+ public Page findByBrandName(String brandName, Pageable pageable) {
+ return productJpaRepository.findByBrandName(brandName, pageable);
+ }
+
+ @Override
+ public List findAllById(Set ids) {
+ return productJpaRepository.findAllById(ids);
+ }
+
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java
new file mode 100644
index 000000000..2e06ebfd8
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java
@@ -0,0 +1,11 @@
+package com.loopers.infrastructure.user;
+
+import com.loopers.domain.user.UserModel;
+import com.loopers.domain.user.UserId;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.Optional;
+
+public interface UserJpaRepository extends JpaRepository {
+ Optional findByUserId(UserId userId);
+}
\ No newline at end of file
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java
new file mode 100644
index 000000000..d6dfe16cc
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java
@@ -0,0 +1,30 @@
+package com.loopers.infrastructure.user;
+
+import com.loopers.domain.user.UserModel;
+import com.loopers.domain.user.UserRepository;
+import com.loopers.domain.user.UserId;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import java.util.Optional;
+
+@RequiredArgsConstructor
+@Component
+public class UserRepositoryImpl implements UserRepository {
+ private final UserJpaRepository userJpaRepository;
+
+ @Override
+ public Optional find(UserId userId) {
+ return userJpaRepository.findByUserId(userId);
+ }
+
+ @Override
+ public Optional findById(Long id) {
+ return userJpaRepository.findById(id);
+ }
+
+ @Override
+ public UserModel save(UserModel userModel) {
+ return userJpaRepository.save(userModel);
+ }
+}
\ No newline at end of file
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java
index 20b2809c8..28b7bb38d 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java
@@ -8,6 +8,9 @@
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.validation.FieldError;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.MissingRequestHeaderException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@@ -46,6 +49,21 @@ public ResponseEntity> handleBadRequest(MissingServletRequestPara
return failureResponse(ErrorType.BAD_REQUEST, message);
}
+ @ExceptionHandler
+ public ResponseEntity> handleBadRequest(MissingRequestHeaderException e) {
+ String headerName = e.getHeaderName();
+ String message = String.format("필수 요청 헤더 '%s'가 누락되었습니다.", headerName);
+ return failureResponse(ErrorType.BAD_REQUEST, message);
+ }
+
+ @ExceptionHandler
+ public ResponseEntity> handleBadRequest(MethodArgumentNotValidException e) {
+ String errorMessage = e.getBindingResult().getFieldErrors().stream()
+ .map(FieldError::getDefaultMessage)
+ .collect(Collectors.joining(", "));
+ return failureResponse(ErrorType.BAD_REQUEST, errorMessage);
+ }
+
@ExceptionHandler
public ResponseEntity> handleBadRequest(HttpMessageNotReadableException e) {
String errorMessage;
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1ApiSpec.java
new file mode 100644
index 000000000..7142ac9be
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1ApiSpec.java
@@ -0,0 +1,43 @@
+package com.loopers.interfaces.api.like;
+
+import com.loopers.domain.user.UserId;
+import com.loopers.interfaces.api.ApiResponse;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+
+@Tag(name = "Like V1 API", description = "Loopers 좋아요 API 입니다.")
+public interface LikeV1ApiSpec {
+
+ @Operation(
+ summary = "상품 좋아요 등록",
+ description = "상품에 좋아요를 등록합니다. 멱등하게 동작하여 이미 좋아요가 있으면 아무것도 하지 않습니다."
+ )
+ ApiResponse addLike(
+ @Parameter(name = "X-USER-ID", description = "좋아요를 등록할 유저의 ID", required = true)
+ UserId userId,
+ @Parameter(name = "productId", description = "좋아요를 등록할 상품의 ID", required = true)
+ Long productId
+ );
+
+ @Operation(
+ summary = "상품 좋아요 취소",
+ description = "상품의 좋아요를 취소합니다. 멱등하게 동작하여 이미 좋아요가 없으면 아무것도 하지 않습니다."
+ )
+ ApiResponse removeLike(
+ @Parameter(name = "X-USER-ID", description = "좋아요를 취소할 유저의 ID", required = true)
+ UserId userId,
+ @Parameter(name = "productId", description = "좋아요를 취소할 상품의 ID", required = true)
+ Long productId
+ );
+
+ @Operation(
+ summary = "내가 좋아요 한 상품 목록 조회",
+ description = "유저가 좋아요한 상품 목록을 조회합니다."
+ )
+ ApiResponse getLikedProducts(
+ @Parameter(name = "X-USER-ID", description = "조회할 유저의 ID", required = true)
+ UserId userId
+ );
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java
new file mode 100644
index 000000000..4f4bdd7b5
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java
@@ -0,0 +1,50 @@
+package com.loopers.interfaces.api.like;
+
+import com.loopers.application.like.LikeFacade;
+import com.loopers.domain.product.ProductModel;
+import com.loopers.domain.user.UserId;
+import com.loopers.interfaces.api.ApiResponse;
+import jakarta.validation.constraints.NotBlank;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/api/v1/like")
+public class LikeV1Controller implements LikeV1ApiSpec {
+
+ private final LikeFacade likeFacade;
+
+ @PostMapping("/products/{productId}")
+ @Override
+ public ApiResponse addLike(
+ @RequestHeader(value = "X-USER-ID") UserId userId,
+ @PathVariable("productId") Long productId
+ ) {
+ likeFacade.addLike(userId, productId);
+ return ApiResponse.success(null);
+ }
+
+ @DeleteMapping("/products/{productId}")
+ @Override
+ public ApiResponse removeLike(
+ @RequestHeader(value = "X-USER-ID") @NotBlank(message = "X-USER-ID는 필수입니다.") UserId userId,
+ @PathVariable("productId") Long productId
+ ) {
+ likeFacade.removeLike(userId, productId);
+ return ApiResponse.success(null);
+ }
+
+ @GetMapping("/products")
+ @Override
+ public ApiResponse getLikedProducts(
+ @RequestHeader(value = "X-USER-ID") @NotBlank(message = "X-USER-ID는 필수입니다.") UserId userId
+ ) {
+ List products = likeFacade.getLikedProducts(userId);
+ LikeV1Dto.LikedProductsResponse response = LikeV1Dto.LikedProductsResponse.from(products);
+ return ApiResponse.success(response);
+ }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java
new file mode 100644
index 000000000..ea9c7b05f
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java
@@ -0,0 +1,38 @@
+package com.loopers.interfaces.api.like;
+
+import com.loopers.application.product.ProductInfo;
+import com.loopers.domain.product.ProductModel;
+import java.util.List;
+import java.util.stream.Collectors;
+
+public class LikeV1Dto {
+ public record LikeResponse(boolean liked) {
+ public static LikeResponse from(boolean liked) {
+ return new LikeResponse(liked);
+ }
+ }
+
+ public record LikedProductsResponse(List products) {
+ public static LikedProductsResponse from(List products) {
+ List productInfos = products.stream()
+ .map(product -> new ProductInfo(
+ product.getId(),
+ product.getName(),
+ product.getBrand(),
+ product.getPrice(),
+ product.getLikeCount()
+ ))
+ .collect(Collectors.toList());
+ return new LikedProductsResponse(productInfos);
+ }
+ }
+
+ public record LikeCountResponse(Long count) {
+ public static LikeCountResponse from(long count) {
+ return new LikeCountResponse(count);
+ }
+ }
+
+ public record ToggleLikeRequest(Long productId) {}
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java
new file mode 100644
index 000000000..3011b1d3b
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java
@@ -0,0 +1,42 @@
+package com.loopers.interfaces.api.order;
+
+import com.loopers.domain.user.UserId;
+import com.loopers.interfaces.api.ApiResponse;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.tags.Tag;
+
+@Tag(name = "Order V1 API", description = "Loopers 주문 API 입니다.")
+public interface OrderV1ApiSpec {
+
+ @Operation(
+ summary = "주문 생성",
+ description = "여러 상품을 주문하고 결제합니다. 재고 차감 및 포인트 차감이 자동으로 처리됩니다."
+ )
+ ApiResponse createOrder(
+ @Parameter(name = "X-USER-ID", description = "주문할 유저의 ID", required = true)
+ UserId userId,
+ @Schema(name = "주문 요청", description = "주문할 상품 목록")
+ OrderV1Dto.CreateOrderRequest request
+ );
+
+ @Operation(
+ summary = "주문 조회",
+ description = "주문 ID로 주문을 조회합니다."
+ )
+ ApiResponse getOrder(
+ @Parameter(name = "id", description = "조회할 주문의 ID", required = true)
+ Long id
+ );
+
+ @Operation(
+ summary = "유저 주문 목록 조회",
+ description = "유저의 주문 목록을 조회합니다."
+ )
+ ApiResponse getUserOrders(
+ @Parameter(name = "X-USER-ID", description = "조회할 유저의 ID", required = true)
+ UserId userId
+ );
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java
new file mode 100644
index 000000000..f72bd7887
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java
@@ -0,0 +1,65 @@
+package com.loopers.interfaces.api.order;
+
+import com.loopers.application.order.OrderFacade;
+import com.loopers.application.order.OrderInfo;
+import com.loopers.domain.order.OrderService;
+import com.loopers.domain.user.UserId;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import com.loopers.interfaces.api.ApiResponse;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/api/v1/orders")
+public class OrderV1Controller implements OrderV1ApiSpec {
+
+ private final OrderFacade orderFacade;
+
+ @PostMapping
+ @ResponseStatus(HttpStatus.CREATED)
+ @Override
+ public ApiResponse createOrder(
+ @RequestHeader(value = "X-USER-ID") @NotBlank(message = "X-USER-ID는 필수입니다.") UserId userId,
+ @Valid @RequestBody OrderV1Dto.CreateOrderRequest request
+ ) {
+ if (request.items() == null || request.items().isEmpty()) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "주문 항목은 비어 있을 수 없습니다.");
+ }
+ List items = request.items().stream()
+ .map(item -> new OrderService.OrderItemRequest(item.productId(), item.quantity()))
+ .collect(Collectors.toList());
+
+ OrderInfo info = orderFacade.createOrder(userId, items);
+ OrderV1Dto.OrderResponse response = OrderV1Dto.OrderResponse.from(info);
+ return ApiResponse.success(response);
+ }
+
+ @GetMapping("/{id}")
+ @Override
+ public ApiResponse getOrder(
+ @PathVariable("id") Long id
+ ) {
+ OrderInfo info = orderFacade.getOrder(id);
+ OrderV1Dto.OrderResponse response = OrderV1Dto.OrderResponse.from(info);
+ return ApiResponse.success(response);
+ }
+
+ @GetMapping
+ @Override
+ public ApiResponse getUserOrders(
+ @RequestHeader(value = "X-USER-ID") @NotBlank(message = "X-USER-ID는 필수입니다.") UserId userId
+ ) {
+ List orders = orderFacade.getUserOrders(userId);
+ OrderV1Dto.OrdersResponse response = OrderV1Dto.OrdersResponse.from(orders);
+ return ApiResponse.success(response);
+ }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java
new file mode 100644
index 000000000..efed3f2b1
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java
@@ -0,0 +1,46 @@
+package com.loopers.interfaces.api.order;
+
+import com.loopers.application.order.OrderInfo;
+import com.loopers.domain.common.Money;
+import com.loopers.application.order.OrderItemInfo;
+import java.util.List;
+
+public class OrderV1Dto {
+ public record OrderItemResponse(Long productId, Integer quantity, Money price) {
+ public static OrderItemResponse from(OrderItemInfo item) {
+ return new OrderItemResponse(
+ item.productId(),
+ item.quantity(),
+ item.orderPrice()
+ );
+ }
+ }
+
+ public record OrderResponse(Long id, String userId, Money totalPrice, List orderItems) {
+ public static OrderResponse from(OrderInfo info) {
+ List items = info.orderItems().stream()
+ .map(item -> OrderItemResponse.from(item))
+ .toList();
+ return new OrderResponse(
+ info.id(),
+ info.userId().toString(),
+ info.totalPrice(),
+ items
+ );
+ }
+ }
+
+ public record OrdersResponse(List orders) {
+ public static OrdersResponse from(List orders) {
+ List orderResponses = orders.stream()
+ .map(info -> OrderResponse.from(info))
+ .toList();
+ return new OrdersResponse(orderResponses);
+ }
+ }
+
+ public record CreateOrderRequest(List items) {
+ public record OrderItemRequest(Long productId, Integer quantity) {}
+ }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java
new file mode 100644
index 000000000..f61b108f9
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java
@@ -0,0 +1,32 @@
+package com.loopers.interfaces.api.point;
+
+import com.loopers.domain.user.UserId;
+import com.loopers.interfaces.api.ApiResponse;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.tags.Tag;
+
+@Tag(name = "Point V1 API", description = "Loopers 포인트 API 입니다.")
+public interface PointV1ApiSpec {
+
+ @Operation(
+ summary = "포인트 조회",
+ description = "헤더의 유저 ID로 포인트를 조회합니다."
+ )
+ ApiResponse getPoint(
+ @Parameter(name = "X-USER-ID", description = "조회할 유저의 ID", required = true)
+ UserId userId
+ );
+
+ @Operation(
+ summary = "포인트 충전",
+ description = "헤더의 유저 ID로 포인트를 충전합니다."
+ )
+ ApiResponse chargePoint(
+ @Parameter(name = "X-USER-ID", description = "충전할 유저의 ID", required = true)
+ UserId userId,
+ @Schema(name = "포인트 충전 요청", description = "충전할 포인트 정보")
+ PointV1Dto.ChargeRequest request
+ );
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java
new file mode 100644
index 000000000..ffa4bc458
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java
@@ -0,0 +1,40 @@
+package com.loopers.interfaces.api.point;
+
+import com.loopers.application.point.PointFacade;
+import com.loopers.application.point.PointInfo;
+import com.loopers.domain.common.Money;
+import com.loopers.interfaces.api.ApiResponse;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+import jakarta.validation.constraints.NotBlank;
+import com.loopers.domain.user.UserId;
+
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/api/v1/points")
+public class PointV1Controller implements PointV1ApiSpec {
+
+ private final PointFacade pointFacade;
+
+ @GetMapping
+ @Override
+ public ApiResponse getPoint(
+ @RequestHeader(value = "X-USER-ID") @NotBlank(message = "X-USER-ID는 필수입니다.") UserId userId
+ ) {
+ PointInfo info = pointFacade.getPoint(userId);
+ PointV1Dto.PointResponse response = PointV1Dto.PointResponse.from(info);
+ return ApiResponse.success(response);
+ }
+
+ @PostMapping("/charge")
+ @Override
+ public ApiResponse chargePoint(
+ @RequestHeader(value = "X-USER-ID") @NotBlank(message = "X-USER-ID는 필수입니다.") UserId userId,
+ @Valid @RequestBody PointV1Dto.ChargeRequest request
+ ) {
+ PointInfo info = pointFacade.chargePoint(userId, request.point());
+ PointV1Dto.PointResponse response = PointV1Dto.PointResponse.from(info);
+ return ApiResponse.success(response);
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java
new file mode 100644
index 000000000..81a805d96
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java
@@ -0,0 +1,19 @@
+package com.loopers.interfaces.api.point;
+
+import com.loopers.application.point.PointInfo;
+import com.loopers.domain.common.Money;
+
+public class PointV1Dto {
+ public record PointResponse(String userId, Money point) {
+ public static PointResponse from(PointInfo info) {
+ return new PointResponse(
+ info.user().getUserId().userId(),
+ info.point()
+ );
+ }
+ }
+
+ public record ChargeRequest(
+ Money point
+ ) {}
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java
new file mode 100644
index 000000000..803e9f0ad
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java
@@ -0,0 +1,43 @@
+package com.loopers.interfaces.api.product;
+
+import com.loopers.interfaces.api.ApiResponse;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.data.domain.Pageable;
+
+@Tag(name = "Product V1 API", description = "Loopers 상품 API 입니다.")
+public interface ProductV1ApiSpec {
+
+ @Operation(
+ summary = "상품 목록 조회",
+ description = "상품 목록을 페이징하여 조회합니다. page, size, sort, brandId 파라미터를 사용할 수 있습니다. sort 옵션: latest(최신순), price_asc(가격 오름차순), likes_desc(좋아요 수 내림차순)"
+ )
+ ApiResponse getProducts(
+ @Parameter(name = "sort", description = "정렬 옵션 (latest, price_asc, likes_desc)", required = false)
+ String sort,
+ @Parameter(name = "brandId", description = "브랜드 이름으로 필터링 (brandId는 브랜드 이름을 의미)", required = false)
+ String brandId,
+ @Parameter(name = "pageable", description = "페이징 정보 (page: 페이지 번호, size: 페이지 크기)", required = false)
+ Pageable pageable
+ );
+
+ @Operation(
+ summary = "상품 상세 조회",
+ description = "상품 ID로 상품 상세 정보를 조회합니다."
+ )
+ ApiResponse getProduct(
+ @Parameter(name = "id", description = "조회할 상품의 ID", required = true)
+ Long id
+ );
+
+ @Operation(
+ summary = "상품 재고 조회",
+ description = "상품 ID로 상품 재고를 조회합니다."
+ )
+ ApiResponse getQuantity(
+ @Parameter(name = "id", description = "조회할 상품의 ID", required = true)
+ Long id
+ );
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java
new file mode 100644
index 000000000..db8f9ebc0
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java
@@ -0,0 +1,84 @@
+package com.loopers.interfaces.api.product;
+
+import com.loopers.application.product.ProductFacade;
+import com.loopers.application.product.ProductInfo;
+import com.loopers.domain.common.Quantity;
+import com.loopers.interfaces.api.ApiResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Sort;
+import org.springframework.data.web.PageableDefault;
+import org.springframework.web.bind.annotation.*;
+
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/api/v1/products")
+public class ProductV1Controller implements ProductV1ApiSpec {
+
+ private final ProductFacade productFacade;
+
+ @GetMapping
+ @Override
+ public ApiResponse getProducts(
+ @RequestParam(value = "sort", required = false, defaultValue = "latest") String sort,
+ @RequestParam(value = "brandId", required = false) String brandId,
+ @PageableDefault(size = 20) Pageable pageable
+ ) {
+ Pageable sortedPageable = convertSortToPageable(sort, pageable);
+ Page productPage = productFacade.getProducts(sortedPageable, sort, brandId);
+ ProductV1Dto.ProductsResponse response = ProductV1Dto.ProductsResponse.from(productPage);
+ return ApiResponse.success(response);
+ }
+
+ private Pageable convertSortToPageable(String sort, Pageable pageable) {
+ Sort.Direction direction;
+ String property;
+
+ switch (sort) {
+ case "latest":
+ property = "id";
+ direction = Sort.Direction.DESC;
+ break;
+ case "price_asc":
+ property = "price";
+ direction = Sort.Direction.ASC;
+ break;
+ case "likes_desc":
+ // likes_desc는 메모리에서 정렬하므로 여기서는 기본 정렬 사용
+ property = "id";
+ direction = Sort.Direction.DESC;
+ break;
+ default:
+ property = "id";
+ direction = Sort.Direction.DESC;
+ }
+
+ return org.springframework.data.domain.PageRequest.of(
+ pageable.getPageNumber(),
+ pageable.getPageSize(),
+ Sort.by(direction, property)
+ );
+ }
+
+ @GetMapping("/{id}")
+ @Override
+ public ApiResponse getProduct(
+ @PathVariable("id") Long id
+ ) {
+ ProductInfo info = productFacade.getProduct(id);
+ ProductV1Dto.ProductResponse response = ProductV1Dto.ProductResponse.from(info);
+ return ApiResponse.success(response);
+ }
+
+ @GetMapping("/{id}/quantity")
+ @Override
+ public ApiResponse getQuantity(
+ @PathVariable("id") Long id
+ ) {
+ Quantity quantity = productFacade.getQuantity(id);
+ ProductV1Dto.QuantityResponse response = ProductV1Dto.QuantityResponse.from(quantity);
+ return ApiResponse.success(response);
+ }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java
new file mode 100644
index 000000000..52accf147
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java
@@ -0,0 +1,48 @@
+package com.loopers.interfaces.api.product;
+
+import com.loopers.application.product.ProductInfo;
+import com.loopers.domain.common.Quantity;
+import org.springframework.data.domain.Page;
+
+public class ProductV1Dto {
+ public record ProductResponse(Long id, String name, String brand, long price, Long likeCount) {
+ public static ProductResponse from(ProductInfo info) {
+ return new ProductResponse(
+ info.id(),
+ info.name(),
+ info.brand().name(),
+ info.price().value(),
+ info.likeCount()
+ );
+ }
+ }
+
+ public record PageInfo(int page, int size, long totalElements, int totalPages, boolean hasNext, boolean hasPrevious) {
+ public static PageInfo from(Page> page) {
+ return new PageInfo(
+ page.getNumber(),
+ page.getSize(),
+ page.getTotalElements(),
+ page.getTotalPages(),
+ page.hasNext(),
+ page.hasPrevious()
+ );
+ }
+ }
+
+ public record ProductsResponse(java.util.List products, PageInfo pageInfo) {
+ public static ProductsResponse from(Page productPage) {
+ java.util.List productResponses = productPage.getContent().stream()
+ .map(ProductResponse::from)
+ .toList();
+ return new ProductsResponse(productResponses, PageInfo.from(productPage));
+ }
+ }
+
+ public record QuantityResponse(Integer quantity) {
+ public static QuantityResponse from(Quantity quantity) {
+ return new QuantityResponse(quantity.quantity());
+ }
+ }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java
new file mode 100644
index 000000000..f2beac356
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java
@@ -0,0 +1,39 @@
+package com.loopers.interfaces.api.user;
+
+import com.loopers.domain.user.UserId;
+import com.loopers.interfaces.api.ApiResponse;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.tags.Tag;
+
+@Tag(name = "User V1 API", description = "Loopers 유저 API 입니다.")
+public interface UserV1ApiSpec {
+
+ @Operation(
+ summary = "유저 회원가입",
+ description = "새로운 유저를 회원가입합니다."
+ )
+ ApiResponse signup(
+ @Schema(name = "회원가입 요청", description = "회원가입할 유저의 정보")
+ UserV1Dto.SignupRequest request
+ );
+
+ @Operation(
+ summary = "내 정보 조회",
+ description = "X-USER-ID 헤더를 통해 현재 유저의 정보를 조회합니다."
+ )
+ ApiResponse getMe(
+ @Parameter(name = "X-USER-ID", description = "조회할 유저의 ID", required = true)
+ UserId userId
+ );
+
+ @Operation(
+ summary = "유저 조회",
+ description = "ID로 유저를 조회합니다."
+ )
+ ApiResponse getUser(
+ @Schema(name = "유저 ID", description = "조회할 유저의 ID")
+ UserId userId
+ );
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java
new file mode 100644
index 000000000..bd1c4abe6
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java
@@ -0,0 +1,50 @@
+package com.loopers.interfaces.api.user;
+
+import com.loopers.application.user.UserFacade;
+import com.loopers.application.user.UserInfo;
+import com.loopers.domain.user.UserId;
+import com.loopers.interfaces.api.ApiResponse;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.*;
+
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/api/v1/users")
+public class UserV1Controller implements UserV1ApiSpec {
+
+ private final UserFacade userFacade;
+
+ @PostMapping
+ @ResponseStatus(HttpStatus.CREATED)
+ @Override
+ public ApiResponse signup(
+ @Valid @RequestBody UserV1Dto.SignupRequest request
+ ) {
+ UserInfo info = userFacade.signup(request.userId(), request.email(), request.gender(), request.birthDate());
+ UserV1Dto.UserResponse response = UserV1Dto.UserResponse.from(info);
+ return ApiResponse.success(response);
+ }
+
+ @GetMapping("/me")
+ @Override
+ public ApiResponse getMe(
+ @RequestHeader(value = "X-USER-ID") @NotBlank(message = "X-USER-ID는 필수입니다.") UserId userId
+ ) {
+ UserInfo info = userFacade.getUser(userId);
+ UserV1Dto.UserResponse response = UserV1Dto.UserResponse.from(info);
+ return ApiResponse.success(response);
+ }
+
+ @GetMapping("/{userId}")
+ @Override
+ public ApiResponse getUser(
+ @PathVariable(value = "userId") UserId userId
+ ) {
+ UserInfo info = userFacade.getUser(userId);
+ UserV1Dto.UserResponse response = UserV1Dto.UserResponse.from(info);
+ return ApiResponse.success(response);
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java
new file mode 100644
index 000000000..1ddf041e5
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java
@@ -0,0 +1,27 @@
+package com.loopers.interfaces.api.user;
+
+import com.loopers.application.user.UserInfo;
+import jakarta.validation.constraints.NotBlank;
+
+public class UserV1Dto {
+ public record UserResponse(String userId, String email, String birthDate) {
+ public static UserResponse from(UserInfo info) {
+ return new UserResponse(
+ info.userId(),
+ info.email(),
+ info.birthDate()
+ );
+ }
+ }
+
+ public record SignupRequest(
+ @NotBlank(message = "userId는 필수입니다.")
+ String userId,
+ @NotBlank(message = "email은 필수입니다.")
+ String email,
+ @NotBlank(message = "gender는 필수입니다.")
+ String gender,
+ @NotBlank(message = "birthDate는 필수입니다.")
+ String birthDate
+ ) {}
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.java
new file mode 100644
index 000000000..1cc2ef6ba
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.java
@@ -0,0 +1,40 @@
+package com.loopers.domain.like;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertAll;
+import com.loopers.domain.user.UserModel;
+import com.loopers.domain.user.UserId;
+import com.loopers.domain.user.Email;
+import com.loopers.domain.user.Gender;
+import com.loopers.domain.user.BirthDate;
+import com.loopers.domain.product.ProductModel;
+import com.loopers.domain.product.Brand;
+import com.loopers.domain.common.Money;
+import com.loopers.domain.common.Quantity;
+
+class LikeModelTest {
+ @DisplayName("좋아요 행위 ")
+ @Nested
+ class Create {
+ @DisplayName("좋아요 등록이 정상 처리된다")
+ @Test
+ void createsLikeModel_whenLikeIsCreated() {
+ // arrange
+ UserModel user = new UserModel(new UserId("user123"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01"));
+ ProductModel product = new ProductModel("product123", new Brand("Apple"), new Money(10000), new Quantity(100));
+
+ // act
+ LikeModel likeModel = new LikeModel(user, product);
+
+ // assert
+ assertAll(
+ () -> assertThat(likeModel.getUser()).isEqualTo(user),
+ () -> assertThat(likeModel.getProduct()).isEqualTo(product)
+ );
+ }
+ }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java
new file mode 100644
index 000000000..a4fe27545
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java
@@ -0,0 +1,179 @@
+package com.loopers.domain.like;
+
+import com.loopers.domain.product.ProductModel;
+import com.loopers.domain.product.Brand;
+import com.loopers.domain.common.Money;
+import com.loopers.domain.common.Quantity;
+import com.loopers.domain.user.UserModel;
+import com.loopers.domain.user.UserId;
+import com.loopers.domain.user.Email;
+import com.loopers.domain.user.Gender;
+import com.loopers.domain.user.BirthDate;
+import com.loopers.infrastructure.like.LikeJpaRepository;
+import com.loopers.infrastructure.product.ProductJpaRepository;
+import com.loopers.infrastructure.user.UserJpaRepository;
+import com.loopers.utils.DatabaseCleanUp;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertAll;
+
+@SpringBootTest
+class LikeServiceIntegrationTest {
+ @Autowired
+ private LikeService likeService;
+
+ @Autowired
+ private LikeJpaRepository likeJpaRepository;
+
+ @Autowired
+ private ProductJpaRepository productJpaRepository;
+
+ @Autowired
+ private UserJpaRepository userJpaRepository;
+
+ @Autowired
+ private DatabaseCleanUp databaseCleanUp;
+
+ @AfterEach
+ void tearDown() {
+ databaseCleanUp.truncateAllTables();
+ }
+
+ @DisplayName("좋아요 등록/취소")
+ @Nested
+ class LikeManagement {
+
+ @DisplayName("좋아요 등록이 정상 처리된다")
+ @Test
+ void createsLike_whenAddLikeIsCalled() {
+ // arrange
+ UserModel user = userJpaRepository.save(
+ new UserModel(new UserId("user123"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01"))
+ );
+ ProductModel product = productJpaRepository.save(
+ new ProductModel("product123", new Brand("Apple"), new Money(10000), new Quantity(100))
+ );
+
+ // act
+ likeService.addLike(user, product);
+
+ // assert
+ boolean isLiked = likeService.isLiked(user, product);
+ assertThat(isLiked).isTrue();
+ }
+
+ @DisplayName("좋아요 취소가 정상 처리된다")
+ @Test
+ void removesLike_whenRemoveLikeIsCalled() {
+ // arrange
+ UserModel user = userJpaRepository.save(
+ new UserModel(new UserId("user123"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01"))
+ );
+ ProductModel product = productJpaRepository.save(
+ new ProductModel("product123", new Brand("Apple"), new Money(10000), new Quantity(100))
+ );
+ likeService.addLike(user, product);
+
+ // act
+ likeService.removeLike(user, product);
+
+ // assert
+ boolean isLiked = likeService.isLiked(user, product);
+ assertThat(isLiked).isFalse();
+ }
+
+ @DisplayName("좋아요 등록: 이미 좋아요가 있으면 취소한다")
+ @Test
+ void cancelsLike_whenAddLikeIsCalledOnLikedProduct() {
+ // arrange
+ UserModel user = userJpaRepository.save(
+ new UserModel(new UserId("user123"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01"))
+ );
+ ProductModel product = productJpaRepository.save(
+ new ProductModel("product123", new Brand("Apple"), new Money(10000), new Quantity(100))
+ );
+ likeService.addLike(user, product); // 첫 번째 호출: 좋아요 추가
+ assertThat(likeService.isLiked(user, product)).isTrue();
+
+ // act
+ likeService.addLike(user, product); // 두 번째 호출: 좋아요 취소
+
+ // assert
+ assertThat(likeService.isLiked(user, product)).isFalse();
+ }
+
+ @DisplayName("좋아요 취소: 이미 좋아요가 없으면 추가한다")
+ @Test
+ void addsLike_whenRemoveLikeIsCalledOnUnlikedProduct() {
+ // arrange
+ UserModel user = userJpaRepository.save(
+ new UserModel(new UserId("user123"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01"))
+ );
+ ProductModel product = productJpaRepository.save(
+ new ProductModel("product123", new Brand("Apple"), new Money(10000), new Quantity(100))
+ );
+ // 처음부터 좋아요 없음
+ assertThat(likeService.isLiked(user, product)).isFalse();
+
+ // act
+ likeService.removeLike(user, product); // 좋아요 추가
+
+ // assert
+ assertThat(likeService.isLiked(user, product)).isTrue();
+ }
+
+ @DisplayName("좋아요 토글이 정상 동작한다")
+ @Test
+ void togglesLike_whenToggleLikeIsCalled() {
+ // arrange
+ UserModel user = userJpaRepository.save(
+ new UserModel(new UserId("user123"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01"))
+ );
+ ProductModel product = productJpaRepository.save(
+ new ProductModel("product123", new Brand("Apple"), new Money(10000), new Quantity(100))
+ );
+
+ // act & assert - 첫 번째 호출: 좋아요 등록
+ likeService.toggleLike(user, product);
+ assertThat(likeService.isLiked(user, product)).isTrue();
+
+ // act & assert - 두 번째 호출: 좋아요 취소
+ likeService.toggleLike(user, product);
+ assertThat(likeService.isLiked(user, product)).isFalse();
+ }
+ }
+
+ @DisplayName("좋아요 수 조회")
+ @Nested
+ class LikeCount {
+
+ @DisplayName("상품의 좋아요 수를 정확히 조회한다")
+ @Test
+ void returnsCorrectLikeCount_whenMultipleUsersLikeProduct() {
+ // arrange
+ UserModel user1 = userJpaRepository.save(
+ new UserModel(new UserId("user1"), new Email("user1@user.com"), new Gender("male"), new BirthDate("1999-01-01"))
+ );
+ UserModel user2 = userJpaRepository.save(
+ new UserModel(new UserId("user2"), new Email("user2@user.com"), new Gender("female"), new BirthDate("2000-01-01"))
+ );
+ ProductModel product = productJpaRepository.save(
+ new ProductModel("product123", new Brand("Apple"), new Money(10000), new Quantity(100))
+ );
+ likeService.addLike(user1, product);
+ likeService.addLike(user2, product);
+
+ // act
+ long likeCount = likeService.getLikeCount(product);
+
+ // assert
+ assertThat(likeCount).isEqualTo(2L);
+ }
+ }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java
new file mode 100644
index 000000000..6e352674c
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java
@@ -0,0 +1,55 @@
+package com.loopers.domain.order;
+
+import com.loopers.domain.product.ProductModel;
+import com.loopers.domain.product.Brand;
+import com.loopers.domain.common.Money;
+import com.loopers.domain.common.Quantity;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertAll;
+
+class OrderItemModelTest {
+ @DisplayName("주문 항목 모델 생성")
+ @Nested
+ class Create {
+
+ @DisplayName("주문 항목이 정상적으로 생성된다")
+ @Test
+ void createsOrderItem_whenValidParameters() {
+ // arrange
+ ProductModel product = new ProductModel("product1", new Brand("Apple"), new Money(10000), new Quantity(10));
+ Quantity quantity = new Quantity(3);
+ Money orderPrice = new Money(30000);
+
+ // act
+ OrderItemModel orderItem = new OrderItemModel(product, quantity, orderPrice);
+
+ // assert
+ assertAll(
+ () -> assertThat(orderItem).isNotNull(),
+ () -> assertThat(orderItem.getProduct()).isEqualTo(product),
+ () -> assertThat(orderItem.getQuantity()).isEqualTo(quantity),
+ () -> assertThat(orderItem.getOrderPrice()).isEqualTo(orderPrice)
+ );
+ }
+
+ @DisplayName("주문 항목의 가격이 상품 가격과 수량의 곱과 일치한다")
+ @Test
+ void createsOrderItem_withCorrectPriceCalculation() {
+ // arrange
+ ProductModel product = new ProductModel("product1", new Brand("Apple"), new Money(10000), new Quantity(10));
+ Quantity quantity = new Quantity(2);
+ Money expectedOrderPrice = new Money(20000); // 10000 * 2
+
+ // act
+ OrderItemModel orderItem = new OrderItemModel(product, quantity, expectedOrderPrice);
+
+ // assert
+ assertThat(orderItem.getOrderPrice().value()).isEqualTo(expectedOrderPrice.value());
+ }
+ }
+}
+
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java
new file mode 100644
index 000000000..64b5b5bf7
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java
@@ -0,0 +1,128 @@
+package com.loopers.domain.order;
+
+import com.loopers.domain.user.UserModel;
+import com.loopers.domain.user.UserId;
+import com.loopers.domain.user.Email;
+import com.loopers.domain.user.Gender;
+import com.loopers.domain.user.BirthDate;
+import com.loopers.domain.product.ProductModel;
+import com.loopers.domain.product.Brand;
+import com.loopers.domain.common.Money;
+import com.loopers.domain.common.Quantity;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertAll;
+
+class OrderModelTest {
+ @DisplayName("주문 모델 생성")
+ @Nested
+ class Create {
+
+ @DisplayName("주문이 정상적으로 생성된다")
+ @Test
+ void createsOrder_whenValidParameters() {
+ // arrange
+ UserModel user = new UserModel(new UserId("user123"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01"));
+ Money totalPrice = new Money(30000);
+ ProductModel product = new ProductModel("product1", new Brand("Apple"), new Money(10000), new Quantity(10));
+ OrderItemModel orderItem = new OrderItemModel(product, new Quantity(3), new Money(30000));
+ List orderItems = List.of(orderItem);
+
+ // act
+ OrderModel order = new OrderModel(user, totalPrice, orderItems);
+
+ // assert
+ assertAll(
+ () -> assertThat(order).isNotNull(),
+ () -> assertThat(order.getUser()).isEqualTo(user),
+ () -> assertThat(order.getTotalPrice()).isEqualTo(totalPrice),
+ () -> assertThat(order.getOrderItems()).hasSize(1),
+ () -> assertThat(order.getOrderItems().get(0)).isEqualTo(orderItem)
+ );
+ }
+
+ @DisplayName("여러 주문 항목이 포함된 주문이 정상적으로 생성된다")
+ @Test
+ void createsOrder_whenMultipleOrderItems() {
+ // arrange
+ UserModel user = new UserModel(new UserId("user123"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01"));
+ Money totalPrice = new Money(50000);
+ ProductModel product1 = new ProductModel("product1", new Brand("Apple"), new Money(10000), new Quantity(10));
+ ProductModel product2 = new ProductModel("product2", new Brand("Samsung"), new Money(20000), new Quantity(5));
+ OrderItemModel orderItem1 = new OrderItemModel(product1, new Quantity(2), new Money(20000));
+ OrderItemModel orderItem2 = new OrderItemModel(product2, new Quantity(1), new Money(20000));
+ List orderItems = List.of(orderItem1, orderItem2);
+
+ // act
+ OrderModel order = new OrderModel(user, totalPrice, orderItems);
+
+ // assert
+ assertAll(
+ () -> assertThat(order).isNotNull(),
+ () -> assertThat(order.getUser()).isEqualTo(user),
+ () -> assertThat(order.getTotalPrice()).isEqualTo(totalPrice),
+ () -> assertThat(order.getOrderItems()).hasSize(2),
+ () -> assertThat(order.getOrderItems().get(0)).isEqualTo(orderItem1),
+ () -> assertThat(order.getOrderItems().get(1)).isEqualTo(orderItem2)
+ );
+ }
+ }
+
+ @DisplayName("주문 항목 추가")
+ @Nested
+ class AddOrderItem {
+
+ @DisplayName("주문 항목이 정상적으로 추가된다")
+ @Test
+ void addsOrderItem_whenValidOrderItem() {
+ // arrange
+ UserModel user = new UserModel(new UserId("user123"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01"));
+ Money totalPrice = new Money(10000);
+ OrderModel order = new OrderModel(user, totalPrice, new java.util.ArrayList<>());
+ ProductModel product = new ProductModel("product1", new Brand("Apple"), new Money(10000), new Quantity(10));
+ OrderItemModel orderItem = new OrderItemModel(product, new Quantity(1), new Money(10000));
+
+ // act
+ order.addOrderItem(orderItem);
+
+ // assert
+ assertAll(
+ () -> assertThat(order.getOrderItems()).hasSize(1),
+ () -> assertThat(order.getOrderItems().get(0)).isEqualTo(orderItem),
+ () -> assertThat(orderItem.getOrder()).isEqualTo(order)
+ );
+ }
+
+ @DisplayName("여러 주문 항목이 순차적으로 추가된다")
+ @Test
+ void addsMultipleOrderItems_whenCalledMultipleTimes() {
+ // arrange
+ UserModel user = new UserModel(new UserId("user123"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01"));
+ Money totalPrice = new Money(30000);
+ OrderModel order = new OrderModel(user, totalPrice, new java.util.ArrayList<>());
+ ProductModel product1 = new ProductModel("product1", new Brand("Apple"), new Money(10000), new Quantity(10));
+ ProductModel product2 = new ProductModel("product2", new Brand("Samsung"), new Money(20000), new Quantity(5));
+ OrderItemModel orderItem1 = new OrderItemModel(product1, new Quantity(1), new Money(10000));
+ OrderItemModel orderItem2 = new OrderItemModel(product2, new Quantity(1), new Money(20000));
+
+ // act
+ order.addOrderItem(orderItem1);
+ order.addOrderItem(orderItem2);
+
+ // assert
+ assertAll(
+ () -> assertThat(order.getOrderItems()).hasSize(2),
+ () -> assertThat(order.getOrderItems().get(0)).isEqualTo(orderItem1),
+ () -> assertThat(order.getOrderItems().get(1)).isEqualTo(orderItem2),
+ () -> assertThat(orderItem1.getOrder()).isEqualTo(order),
+ () -> assertThat(orderItem2.getOrder()).isEqualTo(order)
+ );
+ }
+ }
+}
+
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java
new file mode 100644
index 000000000..b18554270
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java
@@ -0,0 +1,236 @@
+package com.loopers.domain.order;
+
+import com.loopers.domain.user.UserModel;
+import com.loopers.domain.user.UserId;
+import com.loopers.domain.user.Email;
+import com.loopers.domain.user.Gender;
+import com.loopers.domain.user.BirthDate;
+import com.loopers.domain.product.ProductModel;
+import com.loopers.domain.product.Brand;
+import com.loopers.domain.common.Money;
+import com.loopers.domain.common.Quantity;
+import com.loopers.domain.point.PointModel;
+import com.loopers.infrastructure.order.OrderJpaRepository;
+import com.loopers.infrastructure.product.ProductJpaRepository;
+import com.loopers.infrastructure.user.UserJpaRepository;
+import com.loopers.infrastructure.point.PointJpaRepository;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import com.loopers.utils.DatabaseCleanUp;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+@SpringBootTest
+class OrderServiceIntegrationTest {
+ @Autowired
+ private OrderService orderService;
+
+ @Autowired
+ private OrderJpaRepository orderJpaRepository;
+
+ @Autowired
+ private ProductJpaRepository productJpaRepository;
+
+ @Autowired
+ private UserJpaRepository userJpaRepository;
+
+ @Autowired
+ private PointJpaRepository pointJpaRepository;
+
+ @Autowired
+ private DatabaseCleanUp databaseCleanUp;
+
+ @AfterEach
+ void tearDown() {
+ databaseCleanUp.truncateAllTables();
+ }
+
+ @DisplayName("주문 생성")
+ @Nested
+ class CreateOrder {
+
+ @DisplayName("정상 주문이 성공적으로 생성된다")
+ @Test
+ void createsOrder_whenValidOrderRequest() {
+ // arrange
+ UserModel user = userJpaRepository.save(
+ new UserModel(new UserId("user123"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01"))
+ );
+ pointJpaRepository.save(
+ new PointModel(user, new Money(50000))
+ );
+ ProductModel product1 = productJpaRepository.save(
+ new ProductModel("product1", new Brand("Apple"), new Money(10000), new Quantity(10))
+ );
+ ProductModel product2 = productJpaRepository.save(
+ new ProductModel("product2", new Brand("Samsung"), new Money(20000), new Quantity(5))
+ );
+
+ List items = List.of(
+ new OrderService.OrderItemRequest(product1.getId(), 2),
+ new OrderService.OrderItemRequest(product2.getId(), 1)
+ );
+
+ // act
+ OrderModel order = orderService.createOrder(user, items);
+
+ // assert
+ assertAll(
+ () -> assertThat(order).isNotNull(),
+ () -> assertThat(order.getUser()).isEqualTo(user),
+ () -> assertThat(order.getTotalPrice().value()).isEqualTo(40000), // 10000*2 + 20000*1
+ () -> assertThat(order.getOrderItems()).hasSize(2),
+ () -> assertThat(order.getOrderItems().get(0).getProduct().getId()).isEqualTo(product1.getId()),
+ () -> assertThat(order.getOrderItems().get(0).getQuantity().quantity()).isEqualTo(2),
+ () -> assertThat(order.getOrderItems().get(1).getProduct().getId()).isEqualTo(product2.getId()),
+ () -> assertThat(order.getOrderItems().get(1).getQuantity().quantity()).isEqualTo(1)
+ );
+ }
+
+ @DisplayName("재고 부족 시 주문 생성이 실패한다")
+ @Test
+ void throwsException_whenInsufficientStock() {
+ // arrange
+ UserModel user = userJpaRepository.save(
+ new UserModel(new UserId("user123"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01"))
+ );
+ pointJpaRepository.save(
+ new PointModel(user, new Money(50000))
+ );
+ ProductModel product = productJpaRepository.save(
+ new ProductModel("product1", new Brand("Apple"), new Money(10000), new Quantity(5))
+ );
+
+ List items = List.of(
+ new OrderService.OrderItemRequest(product.getId(), 10) // 재고보다 많은 수량
+ );
+
+ // act
+ CoreException exception = assertThrows(CoreException.class, () -> {
+ orderService.createOrder(user, items);
+ });
+
+ // assert
+ assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
+ assertThat(exception.getMessage()).contains("재고가 부족합니다");
+ }
+
+ @DisplayName("포인트 부족 시 주문 생성이 실패한다")
+ @Test
+ void throwsException_whenInsufficientPoints() {
+ // arrange
+ UserModel user = userJpaRepository.save(
+ new UserModel(new UserId("user123"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01"))
+ );
+ pointJpaRepository.save(
+ new PointModel(user, new Money(10000)) // 부족한 포인트
+ );
+ ProductModel product = productJpaRepository.save(
+ new ProductModel("product1", new Brand("Apple"), new Money(20000), new Quantity(10))
+ );
+
+ List items = List.of(
+ new OrderService.OrderItemRequest(product.getId(), 1) // 20000원 필요
+ );
+
+ // act
+ CoreException exception = assertThrows(CoreException.class, () -> {
+ orderService.createOrder(user, items);
+ });
+
+ // assert
+ assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
+ assertThat(exception.getMessage()).contains("포인트가 부족합니다");
+ }
+
+ @DisplayName("존재하지 않는 상품으로 주문 시 실패한다")
+ @Test
+ void throwsException_whenProductNotFound() {
+ // arrange
+ UserModel user = userJpaRepository.save(
+ new UserModel(new UserId("user123"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01"))
+ );
+ pointJpaRepository.save(
+ new PointModel(user, new Money(50000))
+ );
+
+ List items = List.of(
+ new OrderService.OrderItemRequest(999L, 1) // 존재하지 않는 상품 ID
+ );
+
+ // act
+ CoreException exception = assertThrows(CoreException.class, () -> {
+ orderService.createOrder(user, items);
+ });
+
+ // assert
+ assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND);
+ assertThat(exception.getMessage()).contains("상품을 찾을 수 없습니다");
+ }
+
+ @DisplayName("주문 생성 시 재고가 정확히 차감된다")
+ @Test
+ void decreasesStock_whenOrderIsCreated() {
+ // arrange
+ UserModel user = userJpaRepository.save(
+ new UserModel(new UserId("user123"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01"))
+ );
+ pointJpaRepository.save(
+ new PointModel(user, new Money(50000))
+ );
+ ProductModel product = productJpaRepository.save(
+ new ProductModel("product1", new Brand("Apple"), new Money(10000), new Quantity(10))
+ );
+ int initialQuantity = product.getQuantity().quantity();
+
+ List items = List.of(
+ new OrderService.OrderItemRequest(product.getId(), 3)
+ );
+
+ // act
+ orderService.createOrder(user, items);
+
+ // assert
+ ProductModel updatedProduct = productJpaRepository.findById(product.getId()).orElseThrow();
+ assertThat(updatedProduct.getQuantity().quantity()).isEqualTo(initialQuantity - 3);
+ }
+
+ @DisplayName("주문 생성 시 포인트가 정확히 차감된다")
+ @Test
+ void decreasesPoints_whenOrderIsCreated() {
+ // arrange
+ UserModel user = userJpaRepository.save(
+ new UserModel(new UserId("user123"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01"))
+ );
+ PointModel point = pointJpaRepository.save(
+ new PointModel(user, new Money(50000))
+ );
+ ProductModel product = productJpaRepository.save(
+ new ProductModel("product1", new Brand("Apple"), new Money(10000), new Quantity(10))
+ );
+ long initialPoints = point.getPoint().value();
+
+ List items = List.of(
+ new OrderService.OrderItemRequest(product.getId(), 2)
+ );
+
+ // act
+ orderService.createOrder(user, items);
+
+ // assert
+ PointModel updatedPoint = pointJpaRepository.findByUser(user).orElseThrow();
+ assertThat(updatedPoint.getPoint().value()).isEqualTo(initialPoints - 20000);
+ }
+ }
+}
+
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointModelTest.java
new file mode 100644
index 000000000..f84cfac9e
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointModelTest.java
@@ -0,0 +1,42 @@
+package com.loopers.domain.point;
+
+import com.loopers.domain.user.Email;
+import com.loopers.domain.user.UserId;
+import com.loopers.domain.user.BirthDate;
+import com.loopers.domain.user.UserModel;
+import com.loopers.domain.user.Gender;
+import com.loopers.domain.common.Money;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import java.time.LocalDate;
+
+class PointModelTest {
+ @DisplayName("포인트 ")
+ @Nested
+ class Create {
+
+ @DisplayName("0 이하의 정수로 포인트를 충전 시 실패한다.")
+ @Test
+ void pointModel_whenPointIsLessThan0() {
+ UserModel user = new UserModel(new UserId("user123"), new Email("email@email.com"), new Gender("male"), new BirthDate("1999-01-01"));
+
+ CoreException result = assertThrows(CoreException.class, () -> {
+ new PointModel(user, new Money(-1));
+ });
+
+ // assert
+ assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
+ assertThat(result.getMessage()).isEqualTo("가격은 0 이상이어야 합니다.");
+ }
+
+ //포인트 사용하기
+
+ }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java
new file mode 100644
index 000000000..d6df530a8
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java
@@ -0,0 +1,103 @@
+package com.loopers.domain.point;
+
+import com.loopers.domain.user.Email;
+import com.loopers.domain.user.UserId;
+import com.loopers.domain.user.BirthDate;
+import com.loopers.domain.user.UserModel;
+import com.loopers.domain.user.UserRepository;
+import com.loopers.domain.user.Gender;
+import com.loopers.domain.common.Money;
+import com.loopers.infrastructure.point.PointJpaRepository;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import com.loopers.utils.DatabaseCleanUp;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.SpyBean;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+@SpringBootTest
+class PointServiceIntegrationTest {
+ @Autowired
+ private PointService pointService;
+
+ @Autowired
+ private PointJpaRepository pointJpaRepository;
+
+ @Autowired
+ private UserRepository userRepository;
+
+ @Autowired
+ private DatabaseCleanUp databaseCleanUp;
+
+ @SpyBean
+ private PointRepository pointRepository;
+
+ @AfterEach
+ void tearDown() {
+ databaseCleanUp.truncateAllTables();
+ }
+
+ @DisplayName("포인트 조회")
+ @Nested
+ class GetPoint {
+ @DisplayName("해당 ID 의 회원이 존재할 경우, 보유 포인트가 반환된다.")
+ @Test
+ void returnsPoint_whenValidUserIdIsProvided() {
+ // arrange
+ UserModel user = new UserModel(new UserId("userId"), new Email("email@email.com"), new Gender("male"), new BirthDate("1999-01-01"));
+ userRepository.save(user);
+ PointModel pointModel = new PointModel(user, new Money(10));
+ pointService.charge(pointModel);
+
+ // act
+ PointModel result = pointService.findPoint(pointModel);
+
+ // assert
+ assertAll(
+ () -> assertThat(result).isNotNull(),
+ () -> assertThat(result.getPoint().value()).isEqualTo(10)
+ );
+ }
+
+ @DisplayName("해당 ID 의 회원이 존재하지 않을 경우, null 이 반환된다.")
+ @Test
+ void returnsNull_whenInvalidUserIdIsProvided() {
+ // arrange
+ UserModel user = new UserModel(new UserId("notUserId1"), new Email("email@email.com"), new Gender("male"), new BirthDate("1999-01-01"));
+ PointModel pointModel = new PointModel(user, new Money(10));
+
+ // act
+ PointModel result = pointService.findPoint(pointModel);
+
+ // assert
+ assertAll(
+ () -> assertThat(result).isNull()
+ );
+ }
+
+ }
+
+ @DisplayName("포인트 충전")
+ @Nested
+ class ChargePoint {
+ @DisplayName("존재하지 않는 유저 ID 로 충전을 시도한 경우, 실패한다.")
+ @Test
+ void throwsException_whenInvalidUserIdIsProvided() {
+ // arrange
+ UserModel user = new UserModel(new UserId("notUserId1"), new Email("email@email.com"), new Gender("male"), new BirthDate("1999-01-01"));
+ PointModel pointModel = new PointModel(user, new Money(10));
+
+ // assert
+ assertThrows(CoreException.class, () -> pointService.charge(pointModel));
+ }
+
+ }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java
new file mode 100644
index 000000000..b05ae7376
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java
@@ -0,0 +1,104 @@
+package com.loopers.domain.product;
+
+import com.loopers.domain.common.Quantity;
+import com.loopers.domain.common.Money;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+class ProductModelTest {
+ @DisplayName("상품 모델을 생성할 때, ")
+ @Nested
+ class Create {
+
+ @DisplayName("상품 재고는 0 이상이어야 한다.")
+ @Test
+ void productModel_whenCreateQuantityIsLessThan0() {
+ // arrange & act
+ CoreException result = assertThrows(CoreException.class, () -> {
+ new Quantity(-1);
+ });
+
+ // assert
+ assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
+ }
+
+ @DisplayName("상품 재고를 차감할 때, 재고가 부족하면 BAD_REQUEST 예외가 발생한다.")
+ @Test
+ void productModel_whenDecreaseQuantityIsLessThan0() {
+ // arrange
+ ProductModel product = new ProductModel("제목", new Brand("Apple"), new Money(10000), new Quantity(10));
+
+ // act
+ CoreException result = assertThrows(CoreException.class, () -> {
+ product.decreaseQuantity(new Quantity(11));
+ });
+
+ // assert
+ assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
+ assertThat(result.getMessage()).contains("재고가 부족합니다");
+ }
+
+ @DisplayName("상품 재고를 정상적으로 차감한다")
+ @Test
+ void decreasesQuantity_whenValidQuantityIsProvided() {
+ // arrange
+ ProductModel product = new ProductModel("제목", new Brand("Apple"), new Money(10000), new Quantity(10));
+ int initialQuantity = product.getQuantity().quantity();
+
+ // act
+ product.decreaseQuantity(new Quantity(3));
+
+ // assert
+ assertThat(product.getQuantity().quantity()).isEqualTo(initialQuantity - 3);
+ }
+
+ @DisplayName("상품 재고를 0까지 차감할 수 있다")
+ @Test
+ void decreasesQuantityToZero_whenQuantityEqualsStock() {
+ // arrange
+ ProductModel product = new ProductModel("제목", new Brand("Apple"), new Money(10000), new Quantity(10));
+
+ // act
+ product.decreaseQuantity(new Quantity(10));
+
+ // assert
+ assertThat(product.getQuantity().quantity()).isEqualTo(0);
+ }
+
+ @DisplayName("상품 등록 시 브랜드가 빈칸이면 BAD_REQUEST 예외가 발생한다.")
+ @Test
+ void productModel_whenCreateBrandIsBlank() {
+ // arrange & act
+ CoreException result = assertThrows(CoreException.class, () -> {
+ new Brand("");
+ });
+
+ // assert
+ assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
+ }
+
+ @DisplayName("상품 등록 시 이름이 빈칸이면 BAD_REQUEST 예외가 발생한다.")
+ @Test
+ void productModel_whenCreateNameIsBlank() {
+ // arrange
+ String name = "";
+
+ // act
+ CoreException result = assertThrows(CoreException.class, () -> {
+ new ProductModel(name, new Brand("Apple"), new Money(10000), new Quantity(10));
+ });
+
+ // assert
+ assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
+ }
+
+
+ }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java
new file mode 100644
index 000000000..ea42e67cf
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java
@@ -0,0 +1,75 @@
+package com.loopers.domain.product;
+
+import com.loopers.infrastructure.product.ProductJpaRepository;
+import com.loopers.domain.common.Quantity;
+import com.loopers.domain.common.Money;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import com.loopers.utils.DatabaseCleanUp;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertAll;
+
+import org.springframework.data.domain.Pageable;
+
+@SpringBootTest
+class ProductServiceIntegrationTest {
+ @Autowired
+ private ProductService productService;
+
+ @Autowired
+ private ProductJpaRepository productJpaRepository;
+
+ @Autowired
+ private DatabaseCleanUp databaseCleanUp;
+
+ @AfterEach
+ void tearDown() {
+ databaseCleanUp.truncateAllTables();
+ }
+
+ @DisplayName("상품를 조회할 때,")
+ @Nested
+ class Get {
+
+ @DisplayName("상품 다건 조회 시 상품이 없으면 NOT_FOUND 예외가 발생한다.")
+ @Test
+ void productService_whenGetProductsIsNotFound() {
+ // arrange
+ productJpaRepository.save(new ProductModel("제목", new Brand("Apple"), new Money(10000), new Quantity(10)));
+
+ // act
+ CoreException result = assertThrows(CoreException.class, () -> {
+ productService.getProducts(Pageable.ofSize(10), "latest", "Apple");
+ });
+
+ // assert
+ assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND);
+ }
+
+ @DisplayName("상품 단건 조회 시 상품이 없으면 NOT_FOUND 예외가 발생한다.")
+ @Test
+ void productService_whenGetProductIsNotFound() {
+ // arrange
+ Long id = 1L;
+ productJpaRepository.findById(id).orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품이 존재하지 않습니다."));
+
+ // act
+ CoreException result = assertThrows(CoreException.class, () -> {
+ productService.getProduct(id);
+ });
+
+ // assert
+ assertAll(
+ () -> assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND)
+ );
+ }
+ }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java
new file mode 100644
index 000000000..915cacff5
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java
@@ -0,0 +1,121 @@
+package com.loopers.domain.user;
+
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+class UserModelTest {
+ @DisplayName("회원가입 시 User 객체를 생성할 때, ")
+ @Nested
+ class Create {
+
+ /*
+ - [ ] ID 가 `영문 및 숫자 10자 이내` 형식에 맞지 않으면, User 객체 생성에 실패한다.
+ - [ ] 이메일이 `xx@yy.zz` 형식에 맞지 않으면, User 객체 생성에 실패한다.
+ - [ ] 생년월일이 `yyyy-MM-dd` 형식에 맞지 않으면, User 객체 생성에 실패한다.
+ */
+
+ //입력한 아이디가 빈칸칸이거나 공백이면, User 객체 생성에 실패한다.
+ @DisplayName("입력한 ID 가 비어있으면, User 객체 생성에 실패한다.")
+ @Test
+ void createsUserModel_whenUserIdIsBlank() {
+ // arrange
+ String userId = " ";
+
+ // act
+ CoreException result = assertThrows(CoreException.class, () -> {
+ new UserModel(new UserId(userId), new Email("user123@example.com"), new Gender("male"), new BirthDate("1999-01-01"));
+ });
+
+ // assert
+ assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
+ }
+
+
+ @DisplayName("ID 가 `영문 및 숫자 10자 이내` 형식에 맞지 않으면, User 객체 생성에 실패한다.")
+ @Test
+ void createUserModel_whenUserIdIsNotValid() {
+ // arrange
+ String userId = "user123123123";
+
+ // act
+ CoreException result = assertThrows(CoreException.class, () -> {
+ new UserModel(new UserId(userId), new Email("user123@example.com"), new Gender("male"), new BirthDate("1999-01-01"));
+ });
+
+ //assert
+ assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
+
+ }
+
+ @DisplayName("입력한 이메일이 비어있으면, User 객체 생성에 실패한다.")
+ @Test
+ void createsUserModel_whenEmailIsBlank() {
+ // arrange
+ String email = " ";
+
+ // act
+ CoreException result = assertThrows(CoreException.class, () -> {
+ new UserModel(new UserId("userId"), new Email(email), new Gender("male"), new BirthDate("1999-01-01"));
+ });
+
+ // assert
+ assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
+ }
+
+ @DisplayName("이메일이 `xx@yy.zz` 형식에 맞지 않으면, User 객체 생성에 실패한다.")
+ @Test
+ void createUserModel_whenEmailIsNotValid() {
+ // arrange
+ String email = "user123123123";
+
+ // act
+ CoreException result = assertThrows(CoreException.class, () -> {
+ new UserModel(new UserId("user123"), new Email(email), new Gender("male"), new BirthDate("1999-01-01"));
+ });
+
+ //assert
+ assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
+
+ }
+
+ @DisplayName("생년월일이 `yyyy-MM-dd` 형식에 맞지 않으면, User 객체 생성에 실패한다.")
+ @Test
+ void createUserModel_whenBirthDateIsNotValid() {
+ // arrange
+ String birthDate = "19991-01-01";
+
+ // act
+ CoreException result = assertThrows(CoreException.class, () -> {
+ new UserModel(new UserId("user123"), new Email("user123@user.com"), new Gender("male"), new BirthDate(birthDate));
+ });
+
+ //assert
+ assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
+
+ }
+
+
+ //입력한 생년월일이 빈칸이거나 공백이면, User 객체 생성에 실패한다.
+ @DisplayName("입력한 생년월일이 null이면, User 객체 생성에 실패한다.")
+ @Test
+ void createsUserModel_whenBirthDateIsNull() {
+ // arrange
+ String birthDate = null;
+ // act
+ CoreException result = assertThrows(CoreException.class, () -> {
+ new UserModel(new UserId("userId"), new Email("user123@example.com"), new Gender("male"), new BirthDate(birthDate));
+ });
+
+ // assert
+ assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
+ }
+
+ }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java
new file mode 100644
index 000000000..a9e837172
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java
@@ -0,0 +1,129 @@
+package com.loopers.domain.user;
+
+import com.loopers.infrastructure.user.UserJpaRepository;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import com.loopers.utils.DatabaseCleanUp;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.SpyBean;
+import com.loopers.infrastructure.user.UserRepositoryImpl;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.times;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+@SpringBootTest
+class UserServiceIntegrationTest {
+
+ @Autowired
+ private UserService userService;
+
+ @Autowired
+ private UserJpaRepository userJpaRepository;
+
+ @SpyBean
+ private UserRepositoryImpl userRepositoryImpl;
+
+ @Autowired
+ private DatabaseCleanUp databaseCleanUp;
+
+ @AfterEach
+ void tearDown() {
+ databaseCleanUp.truncateAllTables();
+ }
+
+ /*
+ * - [ ] 회원 가입시 User 저장이 수행된다. ( spy 검증 )
+ * - [ ] 이미 가입된 ID 로 회원가입 시도 시, 실패한다.
+ * - [ ] 해당 ID 의 회원이 존재할 경우, 회원 정보가 반환된다.
+ * - [ ] 해당 ID 의 회원이 존재하지 않을 경우, null 이 반환된다.
+ */
+
+ @DisplayName("회원가입")
+ @Nested
+ class SignUp {
+
+ @DisplayName("회원 가입시 User 저장이 수행된다. ( spy 검증 )")
+ @Test
+ void returnsUserInfo_whenSignUp() {
+ // arrange
+ UserModel userModel = new UserModel(new UserId("userId1"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01"));
+
+ // act
+ UserModel user = userService.signUp(userModel);
+
+ // assert
+
+ verify(userRepositoryImpl, times(1)).save(any(UserModel.class));
+
+ assertAll(
+ () -> assertThat(user).isNotNull(),
+ () -> assertThat(user.getUserId()).isNotNull(),
+ () -> assertThat(user.getUserId()).isEqualTo("userId1"),
+ () -> assertThat(user.getEmail()).isEqualTo("user123@user.com"),
+ () -> assertThat(user.getBirthDate()).isEqualTo("1999-01-01"));
+
+ }
+
+ @DisplayName("이미 가입된 ID 로 회원가입 시도 시, 실패한다.")
+ @Test
+ void throwsException_whenUserIdIsDuplicated() {
+ // arrange
+ UserModel userModel = new UserModel(new UserId("userId1"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01"));
+ userService.signUp(userModel);
+
+ UserModel dupUserModel = new UserModel(new UserId("userId1"), new Email("user1234@user.com"), new Gender("male"), new BirthDate("1999-01-11"));
+
+ // act
+ CoreException exception = assertThrows(CoreException.class, () -> userService.signUp(dupUserModel));
+
+ // assert
+ assertThat(exception.getErrorType()).isEqualTo(ErrorType.CONFLICT);
+ }
+ }
+
+ @DisplayName("내 정보 조회")
+ @Nested
+ class MyPage {
+ @DisplayName("해당 ID 의 회원이 존재할 경우, 회원 정보가 반환된다.")
+ @Test
+ void returnsUserInfo_whenValidIdIsProvided() {
+ // arrange
+ UserModel userModel = new UserModel(new UserId("userId1"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01"));
+ userService.signUp(userModel);
+
+ // act
+ UserModel result = userService.getUser(userModel.getUserId());
+
+ // assert
+ assertAll(
+ () -> assertThat(result).isNotNull(),
+ () -> assertThat(result.getUserId()).isEqualTo(userModel.getUserId()),
+ () -> assertThat(result.getEmail()).isEqualTo(userModel.getEmail()),
+ () -> assertThat(result.getBirthDate()).isEqualTo(userModel.getBirthDate())
+ );
+ }
+
+ @DisplayName("해당 ID 의 회원이 존재하지 않을 경우, null 이 반환된다.")
+ @Test
+ void returnsNull_whenInvalidUserIdIsProvided() {
+ // arrange
+ UserModel userModel = new UserModel(new UserId("userId1"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01"));
+
+ // act
+ UserModel result = userService.getUser(userModel.getUserId());
+
+ // assert
+ assertThat(result).isNull();
+ }
+ }
+}
\ No newline at end of file
diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointV1ApiE2ETest.java
new file mode 100644
index 000000000..47a8d8914
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointV1ApiE2ETest.java
@@ -0,0 +1,168 @@
+package com.loopers.interfaces.api;
+
+import com.loopers.domain.point.PointModel;
+import com.loopers.domain.point.PointRepository;
+import com.loopers.domain.user.Email;
+import com.loopers.domain.user.UserId;
+import com.loopers.domain.user.BirthDate;
+import com.loopers.domain.user.UserModel;
+import com.loopers.domain.user.UserRepository;
+import com.loopers.domain.user.Gender;
+import com.loopers.interfaces.api.point.PointV1Dto;
+import com.loopers.domain.common.Money;
+import com.loopers.utils.DatabaseCleanUp;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.core.ParameterizedTypeReference;
+import org.springframework.http.*;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+class PointV1ApiE2ETest {
+
+ private static final String ENDPOINT_GET = "/api/v1/points";
+ private static final String ENDPOINT_CHARGE = "/api/v1/points/charge";
+
+ private final TestRestTemplate testRestTemplate;
+ private final UserRepository userRepository;
+ private final PointRepository pointRepository;
+ private final DatabaseCleanUp databaseCleanUp;
+
+ @Autowired
+ public PointV1ApiE2ETest(
+ TestRestTemplate testRestTemplate,
+ UserRepository userRepository,
+ PointRepository pointRepository,
+ DatabaseCleanUp databaseCleanUp
+ ) {
+ this.testRestTemplate = testRestTemplate;
+ this.userRepository = userRepository;
+ this.pointRepository = pointRepository;
+ this.databaseCleanUp = databaseCleanUp;
+ }
+
+ @AfterEach
+ void tearDown() {
+ databaseCleanUp.truncateAllTables();
+ }
+
+ /*
+ 포인트 조회
+ - [x] 포인트 조회에 성공할 경우, 보유 포인트를 응답으로 반환한다.
+ - [x] `X-USER-ID` 헤더가 없을 경우, `400 Bad Request` 응답을 반환한다.
+
+ 포인트 충전
+ - [x] 존재하는 유저가 1000원을 충전할 경우, 충전된 보유 총량을 응답으로 반환한다.
+ - [x] 존재하지 않는 유저로 요청할 경우, `404 Not Found` 응답을 반환한다.
+ */
+
+ @DisplayName("GET /api/v1/points")
+ @Nested
+ class GetPoint {
+ @DisplayName("포인트 조회에 성공할 경우, 보유 포인트를 응답으로 반환한다.")
+ @Test
+ void returnsPoint_whenValidUserIdHeaderIsProvided() {
+ // arrange
+ UserModel user = userRepository.save(
+ new UserModel(new UserId("user123"), new Email("user123@example.com"), new Gender("male"), new BirthDate("1999-01-01"))
+ );
+ pointRepository.save(new PointModel(user, new Money(500)));
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.set("X-USER-ID", user.getUserId().userId());
+
+ // act
+ ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {};
+ ResponseEntity> response =
+ testRestTemplate.exchange(ENDPOINT_GET, HttpMethod.GET, new HttpEntity<>(headers), responseType);
+
+ // assert
+ assertAll(
+ () -> assertTrue(response.getStatusCode().is2xxSuccessful()),
+ () -> assertThat(response.getBody()).isNotNull(),
+ () -> assertThat(response.getBody().data().userId()).isEqualTo(user.getUserId().userId()),
+ () -> assertThat(response.getBody().data().point().value()).isEqualTo(500)
+ );
+ }
+
+ @DisplayName("`X-USER-ID` 헤더가 없을 경우, `400 Bad Request` 응답을 반환한다.")
+ @Test
+ void throwsBadRequest_whenUserIdHeaderIsMissing() {
+ // arrange
+ HttpHeaders headers = new HttpHeaders();
+ // X-USER-ID 헤더를 의도적으로 설정하지 않음
+
+ // act
+ ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {};
+ ResponseEntity> response =
+ testRestTemplate.exchange(ENDPOINT_GET, HttpMethod.GET, new HttpEntity<>(headers), responseType);
+
+ // assert
+ assertAll(
+ () -> assertTrue(response.getStatusCode().is4xxClientError()),
+ () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST)
+ );
+ }
+ }
+
+ @DisplayName("POST /api/v1/points/charge")
+ @Nested
+ class ChargePoint {
+ @DisplayName("존재하는 유저가 1000원을 충전할 경우, 충전된 보유 총량을 응답으로 반환한다.")
+ @Test
+ void chargesPoint_when1000AmountIsProvided() {
+ // arrange
+ UserModel user = userRepository.save(
+ new UserModel(new UserId("user123"), new Email("user123@example.com"), new Gender("male"), new BirthDate("1999-01-01"))
+ );
+ PointV1Dto.ChargeRequest request = new PointV1Dto.ChargeRequest(new Money(1000));
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.set("X-USER-ID", user.getUserId().userId());
+ headers.setContentType(MediaType.APPLICATION_JSON);
+
+ // act
+ ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {};
+ ResponseEntity> response =
+ testRestTemplate.exchange(ENDPOINT_CHARGE, HttpMethod.POST, new HttpEntity<>(request, headers), responseType);
+
+ // assert
+ assertAll(
+ () -> assertTrue(response.getStatusCode().is2xxSuccessful()),
+ () -> assertThat(response.getBody()).isNotNull(),
+ () -> assertThat(response.getBody().data().userId()).isEqualTo(user.getUserId().userId()),
+ () -> assertThat(response.getBody().data().point().value()).isEqualTo(1000)
+ );
+ }
+
+ @DisplayName("존재하지 않는 유저로 요청할 경우, `404 Not Found` 응답을 반환한다.")
+ @Test
+ void throwsNotFoundException_whenUserDoesNotExist() {
+ // arrange
+ PointV1Dto.ChargeRequest request = new PointV1Dto.ChargeRequest(new Money(1000));
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.set("X-USER-ID", "nonexistent");
+ headers.setContentType(MediaType.APPLICATION_JSON);
+
+ // act
+ ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {};
+ ResponseEntity> response =
+ testRestTemplate.exchange(ENDPOINT_CHARGE, HttpMethod.POST, new HttpEntity<>(request, headers), responseType);
+
+ // assert
+ assertAll(
+ () -> assertTrue(response.getStatusCode().is4xxClientError()),
+ () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND)
+ );
+ }
+ }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java
new file mode 100644
index 000000000..f88d29e26
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java
@@ -0,0 +1,165 @@
+package com.loopers.interfaces.api;
+
+import com.loopers.domain.user.Email;
+import com.loopers.domain.user.UserId;
+import com.loopers.domain.user.BirthDate;
+import com.loopers.domain.user.UserModel;
+import com.loopers.domain.user.UserRepository;
+import com.loopers.domain.user.Gender;
+import com.loopers.interfaces.api.user.UserV1Dto;
+import com.loopers.utils.DatabaseCleanUp;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.core.ParameterizedTypeReference;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+
+import java.util.function.Function;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+class UserV1ApiE2ETest {
+
+ private static final String ENDPOINT_SIGNUP = "/api/v1/users/signup";
+ private static final Function ENDPOINT_GET = userId -> "/api/v1/users/" + userId;
+
+ private final TestRestTemplate testRestTemplate;
+ private final UserRepository userRepository;
+ private final DatabaseCleanUp databaseCleanUp;
+
+ @Autowired
+ public UserV1ApiE2ETest(
+ TestRestTemplate testRestTemplate,
+ UserRepository userRepository,
+ DatabaseCleanUp databaseCleanUp
+ ) {
+ this.testRestTemplate = testRestTemplate;
+ this.userRepository = userRepository;
+ this.databaseCleanUp = databaseCleanUp;
+ }
+
+ @AfterEach
+ void tearDown() {
+ databaseCleanUp.truncateAllTables();
+ }
+
+ /*
+ 회원가입
+ - [x] 회원 가입이 성공할 경우, 생성된 유저 정보를 응답으로 반환한다.
+ - [x] 회원 가입 시에 필수 필드가 없을 경우, `400 Bad Request` 응답을 반환한다.
+
+ 내 정보 조회
+ - [x] 내 정보 조회에 성공할 경우, 해당하는 유저 정보를 응답으로 반환한다.
+ - [x] 존재하지 않는 ID 로 조회할 경우, `404 Not Found` 응답을 반환한다.
+ */
+
+ @DisplayName("POST /api/v1/users/signup")
+ @Nested
+ class Signup {
+ @DisplayName("회원 가입이 성공할 경우, 생성된 유저 정보를 응답으로 반환한다.")
+ @Test
+ void returnsUserInfo_whenSignupIsSuccessful() {
+ // arrange
+ UserV1Dto.SignupRequest request = new UserV1Dto.SignupRequest(
+ "user123",
+ "user123@example.com",
+ "male",
+ "1999-01-01"
+ );
+
+ // act
+ ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {};
+ ResponseEntity> response =
+ testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, new HttpEntity<>(request), responseType);
+
+ // assert
+ assertAll(
+ () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED),
+ () -> assertThat(response.getBody()).isNotNull(),
+ () -> assertThat(response.getBody().data().userId()).isEqualTo("user123"),
+ () -> assertThat(response.getBody().data().email()).isEqualTo("user123@example.com"),
+ () -> assertThat(response.getBody().data().birthDate()).isEqualTo("1999-01-01")
+ );
+ }
+
+ @DisplayName("회원 가입 시에 성별이 없을 경우, 400 Bad Request 응답을 반환한다.")
+ @Test
+ void throwsBadRequest_whenRequiredFieldIsMissing() {
+ // arrange - gender 필드를 null로 설정
+ UserV1Dto.SignupRequest request = new UserV1Dto.SignupRequest(
+ "user123",
+ "user123@example.com",
+ null,
+ "1999-01-01"
+ );
+
+ // act
+ ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {};
+ ResponseEntity> response =
+ testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, new HttpEntity<>(request), responseType);
+
+ // assert
+ assertAll(
+ () -> assertTrue(response.getStatusCode().is4xxClientError()),
+ () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST)
+ );
+ }
+ }
+
+ @DisplayName("GET /api/v1/users/{userId}")
+ @Nested
+ class GetUser {
+ @DisplayName("내 정보 조회에 성공할 경우, 해당하는 유저 정보를 응답으로 반환한다.")
+ @Test
+ void returnsUserInfo_whenValidUserIdIsProvided() {
+ // arrange
+ UserModel userModel = userRepository.save(
+ new UserModel(new UserId("user123"), new Email("user123@example.com"), new Gender("male"), new BirthDate("1999-01-01"))
+ );
+ String requestUrl = ENDPOINT_GET.apply(userModel.getUserId().userId());
+
+ // act
+ ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {};
+ ResponseEntity> response =
+ testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType);
+
+ // assert
+ assertAll(
+ () -> assertTrue(response.getStatusCode().is2xxSuccessful()),
+ () -> assertThat(response.getBody()).isNotNull(),
+ () -> assertThat(response.getBody().data().userId()).isEqualTo(userModel.getUserId().userId()),
+ () -> assertThat(response.getBody().data().email()).isEqualTo(userModel.getEmail().email()),
+ () -> assertThat(response.getBody().data().birthDate()).isEqualTo(userModel.getBirthDate().birthDate())
+ );
+ }
+
+ @DisplayName("존재하지 않는 ID 로 조회할 경우, 404 Not Found 응답을 반환한다.")
+ @Test
+ void throwsNotFoundException_whenUserIdDoesNotExist() {
+ // arrange
+ String invalidUserId = "nonexistent";
+ String requestUrl = ENDPOINT_GET.apply(invalidUserId);
+
+ // act
+ ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {};
+ ResponseEntity> response =
+ testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType);
+
+ // assert
+ assertAll(
+ () -> assertTrue(response.getStatusCode().is4xxClientError()),
+ () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND)
+ );
+ }
+ }
+}