From ac04d0815ead3aaeaa687431094859d1718b3f20 Mon Sep 17 00:00:00 2001
From: green <green@mobidoc.us>
Date: Thu, 13 Nov 2025 08:23:06 +0900
Subject: [PATCH 01/21] =?UTF-8?q?feat:=20=EA=B3=B5=ED=86=B5=20Value=20Obje?=
 =?UTF-8?q?cts=20=EC=B6=94=EA=B0=80=20(Money,=20Stock,=20Quantity)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- Money: 금액 표현 VO (null/음수 검증, 연산 메서드)
- Stock: 재고 수량 VO (증가/감소, 재고 부족 검증)
- Quantity: 주문 수량 VO (0 이상 검증)
- 각 VO별 단위 테스트 작성 (경계값, 예외 케이스)
---
 .../java/com/loopers/domain/money/Money.java  |  38 +++++++
 .../com/loopers/domain/quantity/Quantity.java |  33 ++++++
 .../java/com/loopers/domain/stock/Stock.java  |  59 ++++++++++
 .../com/loopers/domain/money/MoneyTest.java   |  81 ++++++++++++++
 .../loopers/domain/quantity/QuantityTest.java |  89 +++++++++++++++
 .../com/loopers/domain/stock/StockTest.java   | 102 ++++++++++++++++++
 6 files changed, 402 insertions(+)
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/money/Money.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/quantity/Quantity.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/stock/Stock.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/money/MoneyTest.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/quantity/QuantityTest.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/stock/StockTest.java

diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/money/Money.java b/apps/commerce-api/src/main/java/com/loopers/domain/money/Money.java
new file mode 100644
index 000000000..8b1dc934d
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/money/Money.java
@@ -0,0 +1,38 @@
+package com.loopers.domain.money;
+
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import jakarta.persistence.Embeddable;
+import lombok.AccessLevel;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+
+@Embeddable
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@EqualsAndHashCode
+public class Money {
+
+  private Long value;
+
+  private Money(Long value) {
+    if (value == null) {
+      throw new CoreException(ErrorType.INVALID_MONEY_VALUE);
+    }
+    if (value < 0) {
+      throw new CoreException(ErrorType.NEGATIVE_MONEY_VALUE);
+    }
+    this.value = value;
+  }
+
+  public static Money zero() {
+    return new Money(0L);
+  }
+
+  public static Money of(Long value) {
+    return new Money(value);
+  }
+
+  public Long getValue() {
+    return value;
+  }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/quantity/Quantity.java b/apps/commerce-api/src/main/java/com/loopers/domain/quantity/Quantity.java
new file mode 100644
index 000000000..ceb52181e
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/quantity/Quantity.java
@@ -0,0 +1,33 @@
+package com.loopers.domain.quantity;
+
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import jakarta.persistence.Embeddable;
+import lombok.AccessLevel;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+
+@Embeddable
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@EqualsAndHashCode
+@Getter
+public class Quantity {
+
+  private Integer value;
+
+  private Quantity(Integer value) {
+    if (value == null) {
+      throw new CoreException(ErrorType.INVALID_QUANTITY_VALUE);
+    }
+    if (value < 0) {
+      throw new CoreException(ErrorType.INVALID_QUANTITY_RANGE);
+    }
+    this.value = value;
+  }
+
+  public static Quantity of(Integer value) {
+    return new Quantity(value);
+  }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/stock/Stock.java b/apps/commerce-api/src/main/java/com/loopers/domain/stock/Stock.java
new file mode 100644
index 000000000..1cc2815da
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/stock/Stock.java
@@ -0,0 +1,59 @@
+package com.loopers.domain.stock;
+
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import jakarta.persistence.Embeddable;
+import lombok.AccessLevel;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+
+@Embeddable
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@EqualsAndHashCode
+public class Stock {
+
+  private Long value;
+
+  private Stock(Long value) {
+    if (value == null) {
+      throw new CoreException(ErrorType.INVALID_STOCK_VALUE);
+    }
+    if (value < 0) {
+      throw new CoreException(ErrorType.NEGATIVE_STOCK_VALUE);
+    }
+    this.value = value;
+  }
+
+  public static Stock zero() {
+    return new Stock(0L);
+  }
+
+  public static Stock of(Long value) {
+    return new Stock(value);
+  }
+
+  public Stock increase(Long amount) {
+    if (amount == null || amount < 0) {
+      throw new CoreException(ErrorType.INVALID_STOCK_INCREASE_AMOUNT);
+    }
+    return Stock.of(this.value + amount);
+  }
+
+  public Stock decrease(Long amount) {
+    if (amount == null || amount < 0) {
+      throw new CoreException(ErrorType.INVALID_STOCK_DECREASE_AMOUNT);
+    }
+    if (this.value < amount) {
+      throw new CoreException(ErrorType.INSUFFICIENT_STOCK);
+    }
+    return Stock.of(this.value - amount);
+  }
+
+  public boolean isAvailable() {
+    return this.value > 0;
+  }
+
+  public Long getValue() {
+    return value;
+  }
+}
\ No newline at end of file
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/money/MoneyTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/money/MoneyTest.java
new file mode 100644
index 000000000..b989a19db
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/money/MoneyTest.java
@@ -0,0 +1,81 @@
+package com.loopers.domain.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.assertj.core.api.Assertions.assertThatThrownBy;
+
+@DisplayName("Money 도메인 테스트")
+class MoneyTest {
+
+  @DisplayName("Money를 생성할 때")
+  @Nested
+  class Create {
+
+    @DisplayName("올바른 금액으로 생성하면 성공한다")
+    @Test
+    void shouldCreate_whenValid() {
+      Money money = Money.of(10000L);
+
+      assertThat(money).extracting("value").isEqualTo(10000L);
+    }
+
+    @DisplayName("0원으로 생성할 수 있다")
+    @Test
+    void shouldCreate_whenZero() {
+      Money zero = Money.zero();
+
+      assertThat(zero).extracting("value").isEqualTo(0L);
+    }
+  }
+
+  @DisplayName("value 검증")
+  @Nested
+  class ValidateValue {
+
+    @DisplayName("null이면 예외가 발생한다")
+    @Test
+    void shouldThrowException_whenNull() {
+      assertThatThrownBy(() -> Money.of(null))
+          .isInstanceOf(CoreException.class)
+          .hasMessage("금액은 비어있을 수 없습니다.")
+          .extracting("errorType").isEqualTo(ErrorType.INVALID_MONEY_VALUE);
+    }
+
+    @DisplayName("음수면 예외가 발생한다")
+    @Test
+    void shouldThrowException_whenNegative() {
+      assertThatThrownBy(() -> Money.of(-1000L))
+          .isInstanceOf(CoreException.class)
+          .hasMessage("금액은 음수일 수 없습니다.")
+          .extracting("errorType").isEqualTo(ErrorType.NEGATIVE_MONEY_VALUE);
+    }
+  }
+
+  @DisplayName("동등성")
+  @Nested
+  class Equality {
+
+    @DisplayName("같은 금액의 Money는 동등하다")
+    @Test
+    void shouldBeEqual_whenSameValue() {
+      Money money1 = Money.of(1000L);
+      Money money2 = Money.of(1000L);
+
+      assertThat(money1).isEqualTo(money2);
+    }
+
+    @DisplayName("다른 금액의 Money는 동등하지 않다")
+    @Test
+    void shouldNotBeEqual_whenDifferentValue() {
+      Money money1 = Money.of(1000L);
+      Money money2 = Money.of(2000L);
+
+      assertThat(money1).isNotEqualTo(money2);
+    }
+  }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/quantity/QuantityTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/quantity/QuantityTest.java
new file mode 100644
index 000000000..12672ea9f
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/quantity/QuantityTest.java
@@ -0,0 +1,89 @@
+package com.loopers.domain.quantity;
+
+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.assertj.core.api.Assertions.assertThatThrownBy;
+
+@DisplayName("Quantity VO 테스트")
+class QuantityTest {
+
+  @DisplayName("Quantity를 생성할 때")
+  @Nested
+  class Create {
+
+    @DisplayName("0 이상의 값으로 생성하면 성공한다")
+    @Test
+    void shouldCreate_whenValidValue() {
+      Quantity quantity = Quantity.of(0);
+
+      assertThat(quantity.getValue()).isEqualTo(0);
+    }
+
+    @DisplayName("1로 생성하면 성공한다")
+    @Test
+    void shouldCreate_whenOne() {
+      Quantity quantity = Quantity.of(1);
+
+      assertThat(quantity.getValue()).isEqualTo(1);
+    }
+
+    @DisplayName("큰 값으로 생성하면 성공한다")
+    @Test
+    void shouldCreate_whenLargeValue() {
+      Quantity quantity = Quantity.of(999);
+
+      assertThat(quantity.getValue()).isEqualTo(999);
+    }
+  }
+
+  @DisplayName("값 검증")
+  @Nested
+  class Validation {
+
+    @DisplayName("null이면 예외가 발생한다")
+    @Test
+    void shouldThrowException_whenNull() {
+      assertThatThrownBy(() -> Quantity.of(null))
+          .isInstanceOf(CoreException.class)
+          .hasMessage("수량은 비어있을 수 없습니다.")
+          .extracting("errorType").isEqualTo(ErrorType.INVALID_QUANTITY_VALUE);
+    }
+
+    @DisplayName("음수이면 예외가 발생한다")
+    @Test
+    void shouldThrowException_whenNegative() {
+      assertThatThrownBy(() -> Quantity.of(-1))
+          .isInstanceOf(CoreException.class)
+          .hasMessage("수량은 0 이상이어야 합니다.")
+          .extracting("errorType").isEqualTo(ErrorType.INVALID_QUANTITY_RANGE);
+    }
+  }
+
+  @DisplayName("동등성 비교")
+  @Nested
+  class Equality {
+
+    @DisplayName("같은 값이면 동등하다")
+    @Test
+    void shouldBeEqual_whenSameValue() {
+      Quantity quantity1 = Quantity.of(5);
+      Quantity quantity2 = Quantity.of(5);
+
+      assertThat(quantity1).isEqualTo(quantity2);
+    }
+
+    @DisplayName("다른 값이면 동등하지 않다")
+    @Test
+    void shouldNotBeEqual_whenDifferentValue() {
+      Quantity quantity1 = Quantity.of(5);
+      Quantity quantity2 = Quantity.of(10);
+
+      assertThat(quantity1).isNotEqualTo(quantity2);
+    }
+  }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockTest.java
new file mode 100644
index 000000000..732ec207a
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockTest.java
@@ -0,0 +1,102 @@
+package com.loopers.domain.stock;
+
+import com.loopers.support.error.CoreException;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class StockTest {
+
+  @Test
+  @DisplayName("음수 재고로 Stock을 생성할 수 없다")
+  void createStockWithNegative() {
+    assertThatThrownBy(() -> Stock.of(-10L))
+        .isInstanceOf(CoreException.class)
+        .hasMessage("재고 수량은 음수일 수 없습니다.");
+  }
+
+  @Test
+  @DisplayName("0 재고로 Stock을 생성할 수 있다")
+  void createZeroStock() {
+    Stock zero = Stock.zero();
+
+    assertThat(zero).extracting("value").isEqualTo(0L);
+  }
+
+  @Test
+  @DisplayName("재고를 증가할 수 있다")
+  void increaseStock() {
+    Stock stock = Stock.of(100L);
+
+    Stock result = stock.increase(50L);
+
+    assertThat(result).extracting("value").isEqualTo(150L);
+  }
+
+  @Test
+  @DisplayName("재고를 감소할 수 있다")
+  void decreaseStock() {
+    Stock stock = Stock.of(100L);
+
+    Stock result = stock.decrease(30L);
+
+    assertThat(result).extracting("value").isEqualTo(70L);
+  }
+
+  @Test
+  @DisplayName("재고가 부족하면 감소할 수 없다")
+  void decreaseInsufficientStock() {
+    Stock stock = Stock.of(50L);
+
+    assertThatThrownBy(() -> stock.decrease(100L))
+        .isInstanceOf(CoreException.class)
+        .hasMessage("재고가 부족합니다.");
+  }
+
+  @Test
+  @DisplayName("음수 수량으로 증가할 수 없다")
+  void increaseWithNegativeAmount() {
+    Stock stock = Stock.of(100L);
+
+    assertThatThrownBy(() -> stock.increase(-10L))
+        .isInstanceOf(CoreException.class)
+        .hasMessage("증가 수량은 0 이상이어야 합니다.");
+  }
+
+  @Test
+  @DisplayName("음수 수량으로 감소할 수 없다")
+  void decreaseWithNegativeAmount() {
+    Stock stock = Stock.of(100L);
+
+    assertThatThrownBy(() -> stock.decrease(-10L))
+        .isInstanceOf(CoreException.class)
+        .hasMessage("감소 수량은 0 이상이어야 합니다.");
+  }
+
+  @Test
+  @DisplayName("같은 수량의 Stock은 동등하다")
+  void stockEquality() {
+    Stock stock1 = Stock.of(100L);
+    Stock stock2 = Stock.of(100L);
+
+    assertThat(stock1).isEqualTo(stock2);
+  }
+
+  @Test
+  @DisplayName("재고가 0보다 크면 available하다")
+  void isAvailableWithPositiveStock() {
+    Stock stock = Stock.of(1L);
+
+    assertThat(stock.isAvailable()).isTrue();
+  }
+
+  @Test
+  @DisplayName("재고가 0이면 available하지 않다")
+  void isNotAvailableWithZeroStock() {
+    Stock stock = Stock.zero();
+
+    assertThat(stock.isAvailable()).isFalse();
+  }
+}
\ No newline at end of file

From 6bd5e8d9166f6c3a61ee62b000fa67a8217c2da4 Mon Sep 17 00:00:00 2001
From: green <green@mobidoc.us>
Date: Thu, 13 Nov 2025 08:23:24 +0900
Subject: [PATCH 02/21] =?UTF-8?q?feat:=20Brand=20=EB=8F=84=EB=A9=94?=
 =?UTF-8?q?=EC=9D=B8=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- Brand 엔티티 (브랜드명 검증, 삭제 상태 관리)
- Brands 일급컬렉션 (브랜드 목록 변환)
- BrandService (브랜드 조회, 생성, 삭제)
- BrandFacade (브랜드 목록 조회 응용 서비스)
- Repository 3계층 구조 적용
- 단위/통합 테스트 작성
---
 .../application/brand/BrandFacade.java        | 20 +++++
 .../application/brand/BrandResponse.java      | 18 +++++
 .../java/com/loopers/domain/brand/Brand.java  | 59 ++++++++++++++
 .../loopers/domain/brand/BrandRepository.java | 15 ++++
 .../loopers/domain/brand/BrandService.java    | 29 +++++++
 .../java/com/loopers/domain/brand/Brands.java | 28 +++++++
 .../brand/BrandJpaRepository.java             | 11 +++
 .../brand/BrandRepositoryImpl.java            | 31 +++++++
 .../application/brand/BrandFacadeTest.java    | 60 ++++++++++++++
 .../com/loopers/domain/brand/BrandTest.java   | 80 +++++++++++++++++++
 .../com/loopers/domain/brand/BrandsTest.java  | 38 +++++++++
 11 files changed, 389 insertions(+)
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/brand/BrandResponse.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/Brands.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandsTest.java

diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java
new file mode 100644
index 000000000..2aab05e28
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java
@@ -0,0 +1,20 @@
+package com.loopers.application.brand;
+
+import com.loopers.domain.brand.Brand;
+import com.loopers.domain.brand.BrandService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+@Component
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class BrandFacade {
+
+  private final BrandService brandService;
+
+  public BrandResponse getBrand(Long brandId) {
+    Brand brand = brandService.getById(brandId);
+    return BrandResponse.from(brand);
+  }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandResponse.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandResponse.java
new file mode 100644
index 000000000..634f2d27e
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandResponse.java
@@ -0,0 +1,18 @@
+package com.loopers.application.brand;
+
+import com.loopers.domain.brand.Brand;
+
+public record BrandResponse(
+    Long brandId,
+    String name,
+    String description
+) {
+
+  public static BrandResponse from(Brand brand) {
+    return new BrandResponse(
+        brand.getId(),
+        brand.getName(),
+        brand.getDescription()
+    );
+  }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java
new file mode 100644
index 000000000..4ff4d243d
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java
@@ -0,0 +1,59 @@
+package com.loopers.domain.brand;
+
+import com.loopers.domain.BaseEntity;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Table;
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+
+@Entity
+@Table(name = "brand")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class Brand extends BaseEntity {
+
+  private static final int MAX_NAME_LENGTH = 50;
+
+  @Column(nullable = false, length = MAX_NAME_LENGTH)
+  private String name;
+
+  @Column(length = 500)
+  private String description;
+
+  private Brand(String name, String description) {
+    validateName(name);
+    this.name = name;
+    this.description = description;
+  }
+
+  public static Brand of(String name, String description) {
+    return new Brand(name, description);
+  }
+
+  public static Brand of(String name) {
+    return new Brand(name, null);
+  }
+
+  private void validateName(String name) {
+    if (name == null || name.isBlank()) {
+      throw new CoreException(ErrorType.INVALID_BRAND_NAME_EMPTY);
+    }
+    if (name.length() > MAX_NAME_LENGTH) {
+      throw new CoreException(ErrorType.INVALID_BRAND_NAME_LENGTH);
+    }
+  }
+
+  public boolean isSameId(Long otherId) {
+    return this.getId().equals(otherId);
+  }
+
+  public String getName() {
+    return name;
+  }
+
+  public String getDescription() {
+    return description;
+  }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java
new file mode 100644
index 000000000..df11b6dca
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java
@@ -0,0 +1,15 @@
+package com.loopers.domain.brand;
+
+import java.util.List;
+import java.util.Optional;
+
+public interface BrandRepository {
+
+
+  Optional<Brand> findById(Long id);
+
+
+  List<Brand> findByIdIn(List<Long> ids);
+
+  Brand save(Brand brand);
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java
new file mode 100644
index 000000000..b713cefaa
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java
@@ -0,0 +1,29 @@
+package com.loopers.domain.brand;
+
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class BrandService {
+
+  private final BrandRepository brandRepository;
+
+
+  public Brand getById(Long brandId) {
+    return brandRepository.findById(brandId)
+        .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다."));
+  }
+
+
+  public List<Brand> findByIdIn(List<Long> brandIds) {
+    return brandRepository.findByIdIn(brandIds);
+  }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brands.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brands.java
new file mode 100644
index 000000000..bb6aeb66e
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brands.java
@@ -0,0 +1,28 @@
+package com.loopers.domain.brand;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class Brands {
+
+  private final List<Brand> values;
+
+  private Brands(List<Brand> values) {
+    this.values = values;
+  }
+
+  public static Brands from(List<Brand> brands) {
+    return new Brands(new ArrayList<>(brands));
+  }
+
+  public Brand getBrandById(Long brandId) {
+    return values.stream()
+        .filter(brand -> brand.isSameId(brandId))
+        .findFirst()
+        .orElse(null);
+  }
+
+  public List<Brand> toList() {
+    return List.copyOf(values);
+  }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java
new file mode 100644
index 000000000..c27f73b73
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java
@@ -0,0 +1,11 @@
+package com.loopers.infrastructure.brand;
+
+import com.loopers.domain.brand.Brand;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+
+public interface BrandJpaRepository extends JpaRepository<Brand, Long> {
+
+  List<Brand> findByIdIn(List<Long> ids);
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java
new file mode 100644
index 000000000..7584155f6
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java
@@ -0,0 +1,31 @@
+package com.loopers.infrastructure.brand;
+
+import com.loopers.domain.brand.Brand;
+import com.loopers.domain.brand.BrandRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+import java.util.Optional;
+
+@Repository
+@RequiredArgsConstructor
+public class BrandRepositoryImpl implements BrandRepository {
+
+  private final BrandJpaRepository jpaRepository;
+
+  @Override
+  public Optional<Brand> findById(Long id) {
+    return jpaRepository.findById(id);
+  }
+
+  @Override
+  public List<Brand> findByIdIn(List<Long> ids) {
+    return jpaRepository.findByIdIn(ids);
+  }
+
+  @Override
+  public Brand save(Brand brand) {
+    return jpaRepository.save(brand);
+  }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java
new file mode 100644
index 000000000..7e1b417d1
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java
@@ -0,0 +1,60 @@
+package com.loopers.application.brand;
+
+import com.loopers.domain.brand.Brand;
+import com.loopers.domain.brand.BrandService;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.BDDMockito.given;
+
+@ExtendWith(MockitoExtension.class)
+@DisplayName("BrandFacade 테스트")
+class BrandFacadeTest {
+
+  @InjectMocks
+  private BrandFacade sut;
+
+  @Mock
+  private BrandService brandService;
+
+  @Test
+  @DisplayName("브랜드 ID로 브랜드 조회 성공")
+  void getBrand_success() {
+    // given
+    Long brandId = 1L;
+    Brand brand = Brand.of("나이키", "스포츠 브랜드");
+
+    given(brandService.getById(brandId)).willReturn(brand);
+
+    // when
+    BrandResponse result = sut.getBrand(brandId);
+
+    // then
+    assertThat(result)
+        .extracting("brandId", "name", "description")
+        .containsExactly(brand.getId(), "나이키", "스포츠 브랜드");
+  }
+
+  @Test
+  @DisplayName("존재하지 않는 브랜드 ID 조회 시 CoreException 발생")
+  void getBrand_notFound() {
+    // given
+    Long brandId = 999L;
+
+    given(brandService.getById(brandId))
+        .willThrow(new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다."));
+
+    // when & then
+    assertThatThrownBy(() -> sut.getBrand(brandId))
+        .isInstanceOf(CoreException.class)
+        .hasMessageContaining("브랜드를 찾을 수 없습니다.");
+  }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java
new file mode 100644
index 000000000..0c80c6453
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java
@@ -0,0 +1,80 @@
+package com.loopers.domain.brand;
+
+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 org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.NullAndEmptySource;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+@DisplayName("Brand 도메인 테스트")
+class BrandTest {
+
+  @DisplayName("Brand를 생성할 때")
+  @Nested
+  class Create {
+
+    @DisplayName("브랜드명과 설명으로 생성하면 성공한다")
+    @Test
+    void shouldCreate_whenNameAndDescription() {
+      String name = "Nike";
+      String description = "스포츠 브랜드";
+
+      Brand brand = Brand.of(name, description);
+
+      assertThat(brand).extracting("name", "description")
+          .containsExactly(name, description);
+    }
+
+    @DisplayName("브랜드명만으로 생성하면 성공한다")
+    @Test
+    void shouldCreate_whenNameOnly() {
+      String name = "Adidas";
+
+      Brand brand = Brand.of(name);
+
+      assertThat(brand).extracting("name", "description")
+          .containsExactly(name, null);
+    }
+  }
+
+  @DisplayName("name 검증")
+  @Nested
+  class ValidateName {
+
+    @DisplayName("50자 이내면 정상 생성된다")
+    @Test
+    void shouldCreate_whenValidLength() {
+      String maxLengthName = "a".repeat(50);
+
+      Brand brand = Brand.of(maxLengthName, "설명");
+
+      assertThat(brand).extracting("name").isEqualTo(maxLengthName);
+    }
+
+    @DisplayName("null 또는 빈 문자열이면 예외가 발생한다")
+    @ParameterizedTest
+    @NullAndEmptySource
+    void shouldThrowException_whenNullOrEmpty(String invalidName) {
+      assertThatThrownBy(() -> Brand.of(invalidName, "설명"))
+          .isInstanceOf(CoreException.class)
+          .hasMessage("브랜드명은 비어있을 수 없습니다.")
+          .extracting("errorType").isEqualTo(ErrorType.INVALID_BRAND_NAME_EMPTY);
+    }
+
+    @DisplayName("50자를 초과하면 예외가 발생한다")
+    @Test
+    void shouldThrowException_whenTooLong() {
+      String tooLongName = "a".repeat(51);
+
+      assertThatThrownBy(() -> Brand.of(tooLongName, "설명"))
+          .isInstanceOf(CoreException.class)
+          .hasMessage("브랜드명은 50자 이내여야 합니다.")
+          .extracting("errorType").isEqualTo(ErrorType.INVALID_BRAND_NAME_LENGTH);
+    }
+  }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandsTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandsTest.java
new file mode 100644
index 000000000..cbc6a529d
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandsTest.java
@@ -0,0 +1,38 @@
+package com.loopers.domain.brand;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@DisplayName("Brands 일급컬렉션 테스트")
+class BrandsTest {
+
+  @Test
+  @DisplayName("Brand 리스트로 일급컬렉션을 생성한다")
+  void from() {
+    Brand brand1 = Brand.of("브랜드1", "설명1");
+    Brand brand2 = Brand.of("브랜드2", "설명2");
+    List<Brand> brandList = List.of(brand1, brand2);
+
+    Brands brands = Brands.from(brandList);
+
+    assertThat(brands.toList()).hasSize(2);
+  }
+
+  @Test
+  @DisplayName("내부 리스트를 불변 복사본으로 반환한다")
+  void toList() {
+    Brand brand = Brand.of("브랜드1", "설명1");
+    Brands brands = Brands.from(List.of(brand));
+
+    List<Brand> list1 = brands.toList();
+    List<Brand> list2 = brands.toList();
+
+    assertThat(list1)
+        .isNotSameAs(list2)
+        .isEqualTo(list2);
+  }
+}

From ac71e9554071324ae1191b83ee7bca78eecef234 Mon Sep 17 00:00:00 2001
From: green <green@mobidoc.us>
Date: Thu, 13 Nov 2025 08:23:44 +0900
Subject: [PATCH 03/21] =?UTF-8?q?feat:=20Product=20=EB=8F=84=EB=A9=94?=
 =?UTF-8?q?=EC=9D=B8=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Product 엔티티:
- Money/Stock VO 활용 (가격, 재고)
- Brand ID 참조 (느슨한 결합)
- 상품 기본 정보 관리

ProductDetail:
- Product + Brand 조합 VO
- 상품 상세 정보 표현
- ProductDetailDomainService (조합 로직)

응용 계층:
- ProductFacade (상품 목록/상세 조회)
- 페이징 처리 (ProductListResponse)

테스트:
- 통합 테스트 (Facade, DomainService)
- 단위 테스트 (Product, VO, 일급컬렉션)
---
 .../application/product/BrandSummary.java     |  16 +
 .../product/ProductDetailResponse.java        |  42 ++
 .../application/product/ProductFacade.java    |  55 ++
 .../product/ProductListResponse.java          |  36 ++
 .../com/loopers/domain/product/Product.java   | 140 +++++
 .../domain/product/ProductRepository.java     |  22 +
 .../domain/product/ProductService.java        |  42 ++
 .../com/loopers/domain/product/Products.java  |  41 ++
 .../domain/product/detail/ProductDetail.java  |  75 +++
 .../detail/ProductDetailDomainService.java    |  34 ++
 .../domain/product/detail/ProductDetails.java |  30 ++
 .../product/ProductJpaRepository.java         |  21 +
 .../product/ProductRepositoryImpl.java        |  48 ++
 .../product/ProductFacadeIntegrationTest.java | 492 ++++++++++++++++++
 .../ProductDetailDomainServiceTest.java       |  69 +++
 .../domain/product/ProductDetailsTest.java    |  46 ++
 .../loopers/domain/product/ProductTest.java   | 211 ++++++++
 .../loopers/domain/product/ProductsTest.java  |  55 ++
 18 files changed, 1475 insertions(+)
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/BrandSummary.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailResponse.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductListResponse.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/Products.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/detail/ProductDetail.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/detail/ProductDetailDomainService.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/detail/ProductDetails.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeIntegrationTest.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductDetailDomainServiceTest.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductDetailsTest.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductsTest.java

diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/BrandSummary.java b/apps/commerce-api/src/main/java/com/loopers/application/product/BrandSummary.java
new file mode 100644
index 000000000..5bb03e023
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/product/BrandSummary.java
@@ -0,0 +1,16 @@
+package com.loopers.application.product;
+
+import com.loopers.domain.brand.Brand;
+
+public record BrandSummary(
+    Long id,
+    String name
+) {
+
+  public static BrandSummary from(Brand brand) {
+    return new BrandSummary(
+        brand.getId(),
+        brand.getName()
+    );
+  }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailResponse.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailResponse.java
new file mode 100644
index 000000000..bd122f1cf
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailResponse.java
@@ -0,0 +1,42 @@
+package com.loopers.application.product;
+
+import com.loopers.domain.product.Product;
+import com.loopers.domain.product.detail.ProductDetail;
+
+public record ProductDetailResponse(
+    Long id,
+    String name,
+    Long price,
+    String description,
+    Long stock,
+    BrandSummary brand,
+    Long likeCount,
+    boolean isLiked
+) {
+
+  public static ProductDetailResponse of(Product product, BrandSummary brandSummary, boolean isLiked) {
+    return new ProductDetailResponse(
+        product.getId(),
+        product.getName(),
+        product.getPriceValue(),
+        product.getDescription(),
+        product.getStockValue(),
+        brandSummary,
+        product.getLikeCount(),
+        isLiked
+    );
+  }
+
+  public static ProductDetailResponse from(ProductDetail productDetail) {
+    return new ProductDetailResponse(
+        productDetail.getProductId(),
+        productDetail.getProductName(),
+        productDetail.getPrice(),
+        productDetail.getDescription(),
+        productDetail.getStockValue(),
+        new BrandSummary(productDetail.getBrandId(), productDetail.getBrandName()),
+        productDetail.getLikeCount(),
+        productDetail.isLiked()
+    );
+  }
+}
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..f10aec869
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
@@ -0,0 +1,55 @@
+package com.loopers.application.product;
+
+import com.loopers.domain.brand.Brand;
+import com.loopers.domain.brand.BrandService;
+import com.loopers.domain.brand.Brands;
+import com.loopers.domain.product.Product;
+import com.loopers.domain.product.detail.ProductDetail;
+import com.loopers.domain.product.detail.ProductDetailDomainService;
+import com.loopers.domain.product.detail.ProductDetails;
+import com.loopers.domain.product.ProductService;
+import com.loopers.domain.product.Products;
+import com.loopers.domain.productlike.ProductLikeService;
+import com.loopers.domain.productlike.ProductLikeStatuses;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Component;
+
+@Component
+@RequiredArgsConstructor
+public class ProductFacade {
+
+  private final ProductService productService;
+  private final BrandService brandService;
+  private final ProductLikeService productLikeService;
+  private final ProductDetailDomainService productDetailDomainService;
+
+  public Page<ProductListResponse> getProducts(Long brandId, Long userId, Pageable pageable) {
+    Page<Product> productPage = brandId != null
+        ? productService.findByBrandId(brandId, pageable)
+        : productService.findAll(pageable);
+
+    Products products = Products.from(productPage.getContent());
+    Brands brands = Brands.from(brandService.findByIdIn(products.getBrandIds()));
+    ProductLikeStatuses likeStatuses = userId != null
+        ? productLikeService.findLikeStatusByUser(userId, products.getProductIds())
+        : ProductLikeStatuses.empty();
+
+    ProductDetails productDetails = productDetailDomainService.create(products, brands, likeStatuses);
+
+    return productPage.map(product ->
+        ProductListResponse.from(productDetails.get(product.getId()))
+    );
+  }
+
+  public ProductDetailResponse getProduct(Long productId, Long userId) {
+    Product product = productService.getById(productId);
+    Brand brand = brandService.getById(product.getBrandId());
+    boolean isLiked = productLikeService.isLiked(userId, productId);
+
+    ProductDetail productDetail = productDetailDomainService.get(product, brand, isLiked);
+
+    return ProductDetailResponse.from(productDetail);
+  }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductListResponse.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductListResponse.java
new file mode 100644
index 000000000..301bd51ab
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductListResponse.java
@@ -0,0 +1,36 @@
+package com.loopers.application.product;
+
+import com.loopers.domain.product.Product;
+import com.loopers.domain.product.detail.ProductDetail;
+
+public record ProductListResponse(
+    Long id,
+    String name,
+    Long price,
+    BrandSummary brand,
+    Long likeCount,
+    boolean isLiked
+) {
+
+  public static ProductListResponse of(Product product, BrandSummary brandSummary, boolean isLiked) {
+    return new ProductListResponse(
+        product.getId(),
+        product.getName(),
+        product.getPriceValue(),
+        brandSummary,
+        product.getLikeCount(),
+        isLiked
+    );
+  }
+
+  public static ProductListResponse from(ProductDetail productDetail) {
+    return new ProductListResponse(
+        productDetail.getProductId(),
+        productDetail.getProductName(),
+        productDetail.getPrice(),
+        new BrandSummary(productDetail.getBrandId(), productDetail.getBrandName()),
+        productDetail.getLikeCount(),
+        productDetail.isLiked()
+    );
+  }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java
new file mode 100644
index 000000000..a079e501e
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java
@@ -0,0 +1,140 @@
+package com.loopers.domain.product;
+
+import com.loopers.domain.BaseEntity;
+import com.loopers.domain.money.Money;
+import com.loopers.domain.stock.Stock;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import jakarta.persistence.AttributeOverride;
+import jakarta.persistence.Column;
+import jakarta.persistence.Embedded;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Table;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@Table(name = "product")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Getter
+public class Product extends BaseEntity {
+
+  private static final int MAX_NAME_LENGTH = 100;
+
+  @Column(nullable = false, length = MAX_NAME_LENGTH)
+  private String name;
+
+  @Embedded
+  @AttributeOverride(name = "value", column = @Column(name = "price"))
+  private Money price;
+
+  @Column(length = 1000)
+  private String description;
+
+  @Embedded
+  @AttributeOverride(name = "value", column = @Column(name = "stock"))
+  private Stock stock;
+
+  @Column(nullable = false)
+  private Long likeCount = 0L;
+
+  @Column(name = "ref_brand_id", nullable = false)
+  private Long brandId;
+
+  private Product(String name, Money price, String description, Stock stock, Long brandId) {
+    validateName(name);
+    validatePrice(price);
+    validateStock(stock);
+    validateBrandId(brandId);
+    this.name = name;
+    this.price = price;
+    this.description = description;
+    this.stock = stock;
+    this.brandId = brandId;
+  }
+
+  public static Product of(String name, Money price, String description, Stock stock, Long brandId) {
+    return new Product(name, price, description, stock, brandId);
+  }
+
+  private void validateName(String name) {
+    if (name == null || name.isBlank()) {
+      throw new CoreException(ErrorType.INVALID_PRODUCT_NAME_EMPTY);
+    }
+    if (name.length() > MAX_NAME_LENGTH) {
+      throw new CoreException(ErrorType.INVALID_PRODUCT_NAME_LENGTH);
+    }
+  }
+
+  private void validatePrice(Money price) {
+    if (price == null) {
+      throw new CoreException(ErrorType.INVALID_PRODUCT_PRICE_EMPTY);
+    }
+  }
+
+  private void validateStock(Stock stock) {
+    if (stock == null) {
+      throw new CoreException(ErrorType.INVALID_PRODUCT_STOCK_EMPTY);
+    }
+  }
+
+  private void validateBrandId(Long brandId) {
+    if (brandId == null) {
+      throw new CoreException(ErrorType.INVALID_PRODUCT_BRAND_EMPTY);
+    }
+  }
+
+  public void decreaseStock(Long amount) {
+    this.stock = this.stock.decrease(amount);
+  }
+
+  public boolean isAvailable() {
+    return stock.isAvailable();
+  }
+
+  public boolean isNotAvailable() {
+    return !isAvailable();
+  }
+
+  public boolean isSameId(Long id) {
+    return id != null && id.equals(getId());
+  }
+
+  public void validateStockForOrder(Integer requestedQuantity) {
+    if (isNotAvailable()) {
+      throw new CoreException(ErrorType.INSUFFICIENT_STOCK,
+          String.format("상품 [%s]의 재고가 부족합니다.", name));
+    }
+
+    if (getStockValue() < requestedQuantity) {
+      throw new CoreException(ErrorType.INSUFFICIENT_STOCK,
+          String.format("상품 [%s]의 재고가 부족합니다. (요청: %d, 재고: %d)",
+              name, requestedQuantity, getStockValue()));
+    }
+  }
+
+  public void increaseLikeCount() {
+    this.likeCount++;
+  }
+
+  public void increaseLikeCount(int count) {
+    if (count > 0) {
+      this.likeCount += count;
+    }
+  }
+
+  public void decreaseLikeCount() {
+    if (this.likeCount > 0) {
+      this.likeCount--;
+    }
+  }
+
+  public Long getPriceValue() {
+    return price.getValue();
+  }
+
+  public Long getStockValue() {
+    return stock.getValue();
+  }
+}
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..e580b6d96
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java
@@ -0,0 +1,22 @@
+package com.loopers.domain.product;
+
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+
+import java.util.List;
+import java.util.Optional;
+
+public interface ProductRepository {
+
+  Page<Product> findAll(Pageable pageable);
+
+  Page<Product> findByBrandId(Long brandId, Pageable pageable);
+
+  Optional<Product> findById(Long id);
+
+  List<Product> findAllById(List<Long> ids);
+
+  List<Product> findAllByIdWithLock(List<Long> ids);
+
+  Product save(Product product);
+}
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..8501a8b27
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java
@@ -0,0 +1,42 @@
+package com.loopers.domain.product;
+
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class ProductService {
+
+  private final ProductRepository productRepository;
+
+  public Product getById(Long productId) {
+    return productRepository.findById(productId)
+        .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
+  }
+
+  public List<Product> getByIds(List<Long> productIds) {
+    List<Long> distinctIds = productIds.stream().distinct().toList();
+    return productRepository.findAllById(distinctIds);
+  }
+
+  public List<Product> getByIdsWithLock(List<Long> productIds) {
+    List<Long> distinctIds = productIds.stream().distinct().toList();
+    return productRepository.findAllByIdWithLock(distinctIds);
+  }
+
+  public Page<Product> findByBrandId(Long brandId, Pageable pageable) {
+    return productRepository.findByBrandId(brandId, pageable);
+  }
+
+  public Page<Product> findAll(Pageable pageable) {
+    return productRepository.findAll(pageable);
+  }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Products.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Products.java
new file mode 100644
index 000000000..9cda7addc
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Products.java
@@ -0,0 +1,41 @@
+package com.loopers.domain.product;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+public class Products {
+
+  private final List<Product> values;
+
+  private Products(List<Product> values) {
+    this.values = values;
+  }
+
+  public static Products from(List<Product> products) {
+    return new Products(new ArrayList<>(products));
+  }
+
+  public List<Long> getBrandIds() {
+    return values.stream()
+        .map(Product::getBrandId)
+        .distinct()
+        .toList();
+  }
+
+  public List<Long> getProductIds() {
+    return values.stream()
+        .map(Product::getId)
+        .toList();
+  }
+
+  public Optional<Product> findById(Long productId) {
+    return values.stream()
+        .filter(p -> p.isSameId(productId))
+        .findFirst();
+  }
+
+  public List<Product> toList() {
+    return List.copyOf(values);
+  }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/detail/ProductDetail.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/detail/ProductDetail.java
new file mode 100644
index 000000000..8a530d327
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/detail/ProductDetail.java
@@ -0,0 +1,75 @@
+package com.loopers.domain.product.detail;
+
+import com.loopers.domain.brand.Brand;
+import com.loopers.domain.product.Product;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import lombok.Getter;
+
+/**
+ * Product와 Brand, ProductLike 정보를 조합한 도메인 객체
+ */
+public class ProductDetail {
+
+  private final Product product;
+  private final Brand brand;
+  @Getter
+  private final boolean isLiked;
+
+  private ProductDetail(Product product, Brand brand, boolean isLiked) {
+    validateProduct(product);
+    validateBrand(brand);
+
+    this.product = product;
+    this.brand = brand;
+    this.isLiked = isLiked;
+  }
+
+  public static ProductDetail of(Product product, Brand brand, boolean isLiked) {
+    return new ProductDetail(product, brand, isLiked);
+  }
+
+  private void validateProduct(Product product) {
+    if (product == null) {
+      throw new CoreException(ErrorType.INVALID_PRODUCT_DETAIL_PRODUCT_EMPTY, "상품 정보는 필수입니다.");
+    }
+  }
+
+  private void validateBrand(Brand brand) {
+    if (brand == null) {
+      throw new CoreException(ErrorType.INVALID_PRODUCT_DETAIL_BRAND_EMPTY, "브랜드 정보는 필수입니다.");
+    }
+  }
+
+  public Long getProductId() {
+    return product.getId();
+  }
+
+  public String getProductName() {
+    return product.getName();
+  }
+
+  public Long getPrice() {
+    return product.getPriceValue();
+  }
+
+  public String getDescription() {
+    return product.getDescription();
+  }
+
+  public Long getStockValue() {
+    return product.getStockValue();
+  }
+
+  public String getBrandName() {
+    return brand.getName();
+  }
+
+  public Long getBrandId() {
+    return brand.getId();
+  }
+
+  public Long getLikeCount() {
+    return product.getLikeCount();
+  }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/detail/ProductDetailDomainService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/detail/ProductDetailDomainService.java
new file mode 100644
index 000000000..7f73611c2
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/detail/ProductDetailDomainService.java
@@ -0,0 +1,34 @@
+package com.loopers.domain.product.detail;
+
+import com.loopers.domain.brand.Brand;
+import com.loopers.domain.brand.Brands;
+import com.loopers.domain.product.Product;
+import com.loopers.domain.product.Products;
+import com.loopers.domain.productlike.ProductLikeStatuses;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+@Component
+public class ProductDetailDomainService {
+
+  public ProductDetail get(Product product, Brand brand, boolean isLiked) {
+    return ProductDetail.of(product, brand, isLiked);
+  }
+
+  public ProductDetails create(
+      Products products,
+      Brands brands,
+      ProductLikeStatuses likeStatuses
+  ) {
+    List<ProductDetail> details = products.toList().stream()
+        .map(product -> ProductDetail.of(
+            product,
+            brands.getBrandById(product.getBrandId()),
+            likeStatuses.isLiked(product.getId())
+        ))
+        .toList();
+
+    return ProductDetails.from(details);
+  }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/detail/ProductDetails.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/detail/ProductDetails.java
new file mode 100644
index 000000000..21f2e992d
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/detail/ProductDetails.java
@@ -0,0 +1,30 @@
+package com.loopers.domain.product.detail;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+public class ProductDetails {
+
+  private final List<ProductDetail> values;
+  private final Map<Long, ProductDetail> detailMap;
+
+  private ProductDetails(List<ProductDetail> values) {
+    this.values = values;
+    this.detailMap = values.stream()
+        .collect(Collectors.toMap(ProductDetail::getProductId, pd -> pd));
+  }
+
+  public static ProductDetails from(List<ProductDetail> details) {
+    return new ProductDetails(new ArrayList<>(details));
+  }
+
+  public ProductDetail get(Long productId) {
+    return detailMap.get(productId);
+  }
+
+  public List<ProductDetail> toList() {
+    return List.copyOf(values);
+  }
+}
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..9be61c7c0
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java
@@ -0,0 +1,21 @@
+package com.loopers.infrastructure.product;
+
+import com.loopers.domain.product.Product;
+import jakarta.persistence.LockModeType;
+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.Lock;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import java.util.List;
+
+public interface ProductJpaRepository extends JpaRepository<Product, Long> {
+
+  Page<Product> findByBrandId(Long brandId, Pageable pageable);
+
+  @Lock(LockModeType.PESSIMISTIC_WRITE)
+  @Query("SELECT p FROM Product p WHERE p.id IN :ids")
+  List<Product> findAllByIdWithLock(@Param("ids") List<Long> ids);
+}
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..f6a27e335
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java
@@ -0,0 +1,48 @@
+package com.loopers.infrastructure.product;
+
+import com.loopers.domain.product.Product;
+import com.loopers.domain.product.ProductRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+import java.util.Optional;
+
+@Repository
+@RequiredArgsConstructor
+public class ProductRepositoryImpl implements ProductRepository {
+
+  private final ProductJpaRepository jpaRepository;
+
+  @Override
+  public Page<Product> findAll(Pageable pageable) {
+    return jpaRepository.findAll(pageable);
+  }
+
+  @Override
+  public Page<Product> findByBrandId(Long brandId, Pageable pageable) {
+    return jpaRepository.findByBrandId(brandId, pageable);
+  }
+
+  @Override
+  public Optional<Product> findById(Long id) {
+    return jpaRepository.findById(id);
+  }
+
+  @Override
+  public List<Product> findAllById(List<Long> ids) {
+    return jpaRepository.findAllById(ids);
+  }
+
+  @Override
+  public List<Product> findAllByIdWithLock(List<Long> ids) {
+    return jpaRepository.findAllByIdWithLock(ids);
+  }
+
+  @Override
+  public Product save(Product product) {
+    return jpaRepository.save(product);
+  }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeIntegrationTest.java
new file mode 100644
index 000000000..3bdb4d9f2
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeIntegrationTest.java
@@ -0,0 +1,492 @@
+package com.loopers.application.product;
+
+import com.loopers.domain.brand.Brand;
+import com.loopers.domain.brand.BrandRepository;
+import com.loopers.domain.money.Money;
+import com.loopers.domain.product.Product;
+import com.loopers.domain.product.ProductRepository;
+import com.loopers.domain.productlike.ProductLike;
+import com.loopers.domain.productlike.ProductLikeRepository;
+import com.loopers.domain.stock.Stock;
+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.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Sort;
+
+import java.time.LocalDateTime;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.tuple;
+
+@SpringBootTest
+class ProductFacadeIntegrationTest {
+
+  private static final LocalDateTime LIKED_AT_2025_10_30 = LocalDateTime.of(2025, 10, 30, 0, 0, 0);
+
+  @Autowired
+  private ProductFacade productFacade;
+
+  @Autowired
+  private BrandRepository brandRepository;
+
+  @Autowired
+  private ProductRepository productRepository;
+
+  @Autowired
+  private ProductLikeRepository productLikeRepository;
+
+  @Autowired
+  private DatabaseCleanUp databaseCleanUp;
+
+  @AfterEach
+  void tearDown() {
+    databaseCleanUp.truncateAllTables();
+  }
+
+  @Nested
+  @DisplayName("상품 목록 조회 시")
+  class GetProducts {
+
+    @Test
+    @DisplayName("브랜드 필터 없이 전체 상품을 조회한다")
+    void getsAllProducts_withoutBrandFilter() {
+      Brand brand = brandRepository.save(Brand.of("브랜드A", "설명A"));
+      productRepository.save(
+          Product.of("상품1", Money.of(10000L), "설명1", Stock.of(100L), brand.getId())
+      );
+      productRepository.save(
+          Product.of("상품2", Money.of(20000L), "설명2", Stock.of(50L), brand.getId())
+      );
+
+      Pageable pageable = PageRequest.of(0, 20);
+
+      Page<ProductListResponse> result = productFacade.getProducts(null, null, pageable);
+
+      assertThat(result.getContent()).hasSize(2);
+      assertThat(result.getContent())
+          .extracting("name", "price")
+          .containsExactlyInAnyOrder(
+              tuple("상품1", 10000L),
+              tuple("상품2", 20000L)
+          );
+    }
+
+    @Test
+    @DisplayName("특정 브랜드로 필터링하여 상품을 조회한다")
+    void getsProducts_withBrandFilter() {
+      Brand brand1 = brandRepository.save(Brand.of("브랜드A", "설명A"));
+      Brand brand2 = brandRepository.save(Brand.of("브랜드B", "설명B"));
+
+      productRepository.save(
+          Product.of("상품1", Money.of(10000L), "설명1", Stock.of(100L), brand1.getId())
+      );
+      productRepository.save(
+          Product.of("상품2", Money.of(20000L), "설명2", Stock.of(50L), brand1.getId())
+      );
+      productRepository.save(
+          Product.of("상품3", Money.of(30000L), "설명3", Stock.of(30L), brand2.getId())
+      );
+
+      Pageable pageable = PageRequest.of(0, 20);
+
+      Page<ProductListResponse> result = productFacade.getProducts(brand1.getId(), null, pageable);
+
+      assertThat(result.getContent()).hasSize(2);
+      assertThat(result.getContent())
+          .extracting("name", "price")
+          .containsExactlyInAnyOrder(
+              tuple("상품1", 10000L),
+              tuple("상품2", 20000L)
+          );
+    }
+
+    @Test
+    @DisplayName("사용자가 좋아요한 상품은 isLiked가 true로 반환된다")
+    void includesLikeStatus_whenUserLikesProduct() {
+      Brand brand = brandRepository.save(Brand.of("브랜드A", "설명A"));
+      Product product1 = productRepository.save(
+          Product.of("상품1", Money.of(10000L), "설명1", Stock.of(100L), brand.getId())
+      );
+      productRepository.save(
+          Product.of("상품2", Money.of(20000L), "설명2", Stock.of(50L), brand.getId())
+      );
+
+      Long userId = 1L;
+      productLikeRepository.save(
+          ProductLike.of(userId, product1.getId(), LIKED_AT_2025_10_30)
+      );
+
+      Pageable pageable = PageRequest.of(0, 20);
+
+      Page<ProductListResponse> result = productFacade.getProducts(null, userId, pageable);
+
+      assertThat(result.getContent())
+          .extracting("name", "isLiked")
+          .containsExactlyInAnyOrder(
+              tuple("상품1", true),
+              tuple("상품2", false)
+          );
+    }
+
+    @Test
+    @DisplayName("userId 없이 조회 시 모든 상품의 isLiked가 false로 반환된다")
+    void returnsAllLikedFalse_whenUserIdIsNull() {
+      Brand brand = brandRepository.save(Brand.of("브랜드A", "설명A"));
+      productRepository.save(
+          Product.of("상품1", Money.of(10000L), "설명1", Stock.of(100L), brand.getId())
+      );
+
+      Pageable pageable = PageRequest.of(0, 20);
+
+      Page<ProductListResponse> result = productFacade.getProducts(null, null, pageable);
+
+      assertThat(result.getContent())
+          .extracting("isLiked")
+          .containsOnly(false);
+    }
+
+    @Test
+    @DisplayName("페이지네이션이 정상 동작한다")
+    void supportsPagination() {
+      Brand brand = brandRepository.save(Brand.of("브랜드A", "설명A"));
+      for (int i = 1; i <= 5; i++) {
+        productRepository.save(
+            Product.of("상품" + i, Money.of(10000L), "설명" + i, Stock.of(100L), brand.getId())
+        );
+      }
+
+      Pageable pageable = PageRequest.of(0, 2);
+
+      Page<ProductListResponse> result = productFacade.getProducts(null, null, pageable);
+
+      assertThat(result.getContent()).hasSize(2);
+      assertThat(result.getTotalElements()).isEqualTo(5);
+      assertThat(result.getTotalPages()).isEqualTo(3);
+    }
+
+    @Test
+    @DisplayName("상품이 하나도 없을 때 빈 페이지를 반환한다")
+    void returnsEmptyPage_whenNoProducts() {
+      Pageable pageable = PageRequest.of(0, 20);
+
+      Page<ProductListResponse> result = productFacade.getProducts(null, null, pageable);
+
+      assertThat(result.getContent()).isEmpty();
+      assertThat(result.getTotalElements()).isZero();
+    }
+
+    @Test
+    @DisplayName("존재하지 않는 브랜드로 필터링 시 빈 페이지를 반환한다")
+    void returnsEmptyPage_whenBrandHasNoProducts() {
+      Brand brand = brandRepository.save(Brand.of("빈브랜드", "상품없음"));
+
+      Pageable pageable = PageRequest.of(0, 20);
+      Page<ProductListResponse> result = productFacade.getProducts(brand.getId(), null, pageable);
+
+      assertThat(result.getContent()).isEmpty();
+    }
+
+    @Test
+    @DisplayName("마지막 페이지를 정상 조회한다")
+    void getsLastPage() {
+      Brand brand = brandRepository.save(Brand.of("브랜드A", "설명A"));
+      for (int i = 1; i <= 5; i++) {
+        productRepository.save(
+            Product.of("상품" + i, Money.of(10000L), "설명", Stock.of(100L), brand.getId())
+        );
+      }
+
+      Pageable pageable = PageRequest.of(2, 2);
+
+      Page<ProductListResponse> result = productFacade.getProducts(null, null, pageable);
+
+      assertThat(result.getContent()).hasSize(1);
+      assertThat(result.isLast()).isTrue();
+    }
+
+    @Test
+    @DisplayName("범위를 벗어난 페이지 조회 시 빈 페이지를 반환한다")
+    void returnsEmptyPage_whenPageExceedsRange() {
+      Brand brand = brandRepository.save(Brand.of("브랜드A", "설명A"));
+      productRepository.save(
+          Product.of("상품1", Money.of(10000L), "설명", Stock.of(100L), brand.getId())
+      );
+
+      Pageable pageable = PageRequest.of(10, 20);
+
+      Page<ProductListResponse> result = productFacade.getProducts(null, null, pageable);
+
+      assertThat(result.getContent()).isEmpty();
+      assertThat(result.getTotalElements()).isEqualTo(1);
+    }
+
+    @Test
+    @DisplayName("여러 사용자가 좋아요한 상품의 총 좋아요 수가 정확하다")
+    void showsCorrectLikeCount_whenMultipleUsersLike() {
+      Brand brand = brandRepository.save(Brand.of("브랜드A", "설명A"));
+      Product product = productRepository.save(
+          Product.of("인기상품", Money.of(10000L), "설명", Stock.of(100L), brand.getId())
+      );
+
+      for (long userId = 1L; userId <= 3L; userId++) {
+        productLikeRepository.save(
+            ProductLike.of(userId, product.getId(), LIKED_AT_2025_10_30)
+        );
+        product.increaseLikeCount(1);
+      }
+      productRepository.save(product);
+
+      Pageable pageable = PageRequest.of(0, 20);
+      Page<ProductListResponse> result = productFacade.getProducts(null, 1L, pageable);
+
+      assertThat(result.getContent())
+          .hasSize(1)
+          .first()
+          .extracting("likeCount", "isLiked")
+          .containsExactly(3L, true);
+    }
+
+    @Test
+    @DisplayName("좋아요가 0인 상품도 정상 조회된다")
+    void getsProduct_withZeroLikes() {
+      Brand brand = brandRepository.save(Brand.of("브랜드A", "설명A"));
+      productRepository.save(
+          Product.of("인기없는상품", Money.of(10000L), "설명", Stock.of(100L), brand.getId())
+      );
+
+      Pageable pageable = PageRequest.of(0, 20);
+      Page<ProductListResponse> result = productFacade.getProducts(null, null, pageable);
+
+      assertThat(result.getContent())
+          .hasSize(1)
+          .first()
+          .extracting("likeCount", "isLiked")
+          .containsExactly(0L, false);
+    }
+
+    @Test
+    @DisplayName("응답에 브랜드 정보가 정확히 포함된다")
+    void includesBrandSummary_inResponse() {
+      Brand brand = brandRepository.save(Brand.of("나이키", "설명"));
+      productRepository.save(
+          Product.of("에어맥스", Money.of(150000L), "설명", Stock.of(100L), brand.getId())
+      );
+
+      Pageable pageable = PageRequest.of(0, 20);
+      Page<ProductListResponse> result = productFacade.getProducts(null, null, pageable);
+
+      assertThat(result.getContent())
+          .hasSize(1)
+          .first()
+          .extracting("brand.id", "brand.name")
+          .containsExactly(brand.getId(), "나이키");
+    }
+
+    @Test
+    @DisplayName("존재하지 않는 브랜드 ID로 필터링 시 빈 결과를 반환한다")
+    void returnsEmpty_whenBrandIdDoesNotExist() {
+      Brand brand = brandRepository.save(Brand.of("브랜드A", "설명A"));
+      productRepository.save(
+          Product.of("상품1", Money.of(10000L), "설명", Stock.of(100L), brand.getId())
+      );
+
+      Long nonExistentBrandId = 999L;
+      Pageable pageable = PageRequest.of(0, 20);
+
+      Page<ProductListResponse> result = productFacade.getProducts(nonExistentBrandId, null, pageable);
+
+      assertThat(result.getContent()).isEmpty();
+    }
+  }
+
+  @Nested
+  @DisplayName("상품 상세 조회 시")
+  class GetProduct {
+
+    @Test
+    @DisplayName("정상적으로 상품 정보와 브랜드 정보를 반환한다")
+    void returnsProductWithBrand() {
+      Brand brand = brandRepository.save(Brand.of("브랜드A", "설명A"));
+      Product product = productRepository.save(
+          Product.of("상품1", Money.of(10000L), "상세설명", Stock.of(100L), brand.getId())
+      );
+
+      ProductDetailResponse result = productFacade.getProduct(product.getId(), null);
+
+      assertThat(result)
+          .extracting("name", "price", "description", "stock")
+          .containsExactly("상품1", 10000L, "상세설명", 100L);
+
+      assertThat(result.brand())
+          .extracting("name")
+          .isEqualTo("브랜드A");
+    }
+
+    @Test
+    @DisplayName("좋아요한 상품 조회 시 isLiked가 true로 반환된다")
+    void returnsIsLikedTrue_whenUserLikesProduct() {
+      Brand brand = brandRepository.save(Brand.of("브랜드A", "설명A"));
+      Product product = productRepository.save(
+          Product.of("상품1", Money.of(10000L), "설명1", Stock.of(100L), brand.getId())
+      );
+
+      Long userId = 1L;
+      productLikeRepository.save(
+          ProductLike.of(userId, product.getId(), LIKED_AT_2025_10_30)
+      );
+
+      ProductDetailResponse result = productFacade.getProduct(product.getId(), userId);
+
+      assertThat(result.isLiked()).isTrue();
+    }
+
+    @Test
+    @DisplayName("좋아요 안 한 상품 조회 시 isLiked가 false로 반환된다")
+    void returnsIsLikedFalse_whenUserDoesNotLikeProduct() {
+      Brand brand = brandRepository.save(Brand.of("브랜드A", "설명A"));
+      Product product = productRepository.save(
+          Product.of("상품1", Money.of(10000L), "설명1", Stock.of(100L), brand.getId())
+      );
+
+      Long userId = 1L;
+
+      ProductDetailResponse result = productFacade.getProduct(product.getId(), userId);
+
+      assertThat(result.isLiked()).isFalse();
+    }
+
+    @Test
+    @DisplayName("존재하지 않는 상품 ID 조회 시 404 예외가 발생한다")
+    void throwsNotFoundException_whenProductDoesNotExist() {
+      Long nonExistentProductId = 999L;
+
+      assertThatThrownBy(() ->
+          productFacade.getProduct(nonExistentProductId, null)
+      )
+          .isInstanceOf(CoreException.class)
+          .extracting("errorType", "message")
+          .containsExactly(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.");
+    }
+
+    @Test
+    @DisplayName("브랜드가 존재하지 않으면 404 예외가 발생한다")
+    void throwsNotFoundException_whenBrandDoesNotExist() {
+      Long nonExistentBrandId = 999L;
+      Product product = productRepository.save(
+          Product.of("상품1", Money.of(10000L), "설명1", Stock.of(100L), nonExistentBrandId)
+      );
+      Long productId = product.getId();
+
+      assertThatThrownBy(() ->
+          productFacade.getProduct(productId, null)
+      )
+          .isInstanceOf(CoreException.class)
+          .extracting("errorType", "message")
+          .containsExactly(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.");
+    }
+  }
+
+  @Nested
+  @DisplayName("상품 정렬 시")
+  class SortProducts {
+
+    @Test
+    @DisplayName("가격 오름차순 정렬이 정상 동작한다")
+    void sortsByPriceAscending() {
+      Brand brand = brandRepository.save(Brand.of("브랜드A", "설명A"));
+      productRepository.save(
+          Product.of("상품C", Money.of(30000L), "설명C", Stock.of(100L), brand.getId())
+      );
+      productRepository.save(
+          Product.of("상품A", Money.of(10000L), "설명A", Stock.of(100L), brand.getId())
+      );
+      productRepository.save(
+          Product.of("상품B", Money.of(20000L), "설명B", Stock.of(100L), brand.getId())
+      );
+
+      Pageable pageable = PageRequest.of(0, 20, Sort.by("price").ascending());
+
+      Page<ProductListResponse> result = productFacade.getProducts(null, null, pageable);
+
+      assertThat(result.getContent())
+          .extracting("name", "price")
+          .containsExactly(
+              tuple("상품A", 10000L),
+              tuple("상품B", 20000L),
+              tuple("상품C", 30000L)
+          );
+    }
+
+    @Test
+    @DisplayName("좋아요순 정렬이 정상 동작한다")
+    void sortsByLikesDescending() {
+      Brand brand = brandRepository.save(Brand.of("브랜드A", "설명A"));
+      Product product1 = productRepository.save(
+          Product.of("상품1", Money.of(10000L), "설명1", Stock.of(100L), brand.getId())
+      );
+      Product product2 = productRepository.save(
+          Product.of("상품2", Money.of(20000L), "설명2", Stock.of(100L), brand.getId())
+      );
+      Product product3 = productRepository.save(
+          Product.of("상품3", Money.of(30000L), "설명3", Stock.of(100L), brand.getId())
+      );
+
+      product1.increaseLikeCount(5);
+      product2.increaseLikeCount(10);
+      product3.increaseLikeCount(2);
+
+      productRepository.save(product1);
+      productRepository.save(product2);
+      productRepository.save(product3);
+
+      Pageable pageable = PageRequest.of(0, 20, Sort.by("likeCount").descending());
+
+      Page<ProductListResponse> result = productFacade.getProducts(null, null, pageable);
+
+      assertThat(result.getContent())
+          .extracting("name", "likeCount")
+          .containsExactly(
+              tuple("상품2", 10L),
+              tuple("상품1", 5L),
+              tuple("상품3", 2L)
+          );
+    }
+
+    @Test
+    @DisplayName("최신순 정렬이 정상 동작한다")
+    void sortsByLatest() {
+      Brand brand = brandRepository.save(Brand.of("브랜드A", "설명A"));
+      productRepository.save(
+          Product.of("상품A", Money.of(10000L), "설명A", Stock.of(100L), brand.getId())
+      );
+      productRepository.save(
+          Product.of("상품B", Money.of(20000L), "설명B", Stock.of(100L), brand.getId())
+      );
+      productRepository.save(
+          Product.of("상품C", Money.of(30000L), "설명C", Stock.of(100L), brand.getId())
+      );
+
+      Pageable pageable = PageRequest.of(0, 20, Sort.by("createdAt").descending());
+
+      Page<ProductListResponse> result = productFacade.getProducts(null, null, pageable);
+
+      assertThat(result.getContent())
+          .extracting("name", "price")
+          .containsExactly(
+              tuple("상품C", 30000L),
+              tuple("상품B", 20000L),
+              tuple("상품A", 10000L)
+          );
+    }
+  }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductDetailDomainServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductDetailDomainServiceTest.java
new file mode 100644
index 000000000..c573cb830
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductDetailDomainServiceTest.java
@@ -0,0 +1,69 @@
+package com.loopers.domain.product;
+
+import com.loopers.domain.brand.Brand;
+import com.loopers.domain.brand.Brands;
+import com.loopers.domain.money.Money;
+import com.loopers.domain.product.detail.ProductDetail;
+import com.loopers.domain.product.detail.ProductDetailDomainService;
+import com.loopers.domain.product.detail.ProductDetails;
+import com.loopers.domain.productlike.ProductLikeStatuses;
+import com.loopers.domain.stock.Stock;
+import org.junit.jupiter.api.BeforeEach;
+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;
+
+@DisplayName("ProductDetailDomainService 테스트")
+class ProductDetailDomainServiceTest {
+
+  private ProductDetailDomainService sut;
+
+  @BeforeEach
+  void setUp() {
+    sut = new ProductDetailDomainService();
+  }
+
+  @DisplayName("단일 ProductDetail 생성")
+  @Nested
+  class GetSingle {
+
+    @Test
+    @DisplayName("Product와 Brand, 좋아요 정보로 ProductDetail 생성 성공")
+    void get() {
+      Product product = Product.of("나이키 에어맥스", Money.of(150000L), "편안한 운동화", Stock.of(100L), 1L);
+      Brand brand = Brand.of("나이키");
+
+      ProductDetail result = sut.get(product, brand, true);
+
+      assertAll(
+          () -> assertThat(result.getProductName()).isEqualTo("나이키 에어맥스"),
+          () -> assertThat(result.getPrice()).isEqualTo(150000L),
+          () -> assertThat(result.getDescription()).isEqualTo("편안한 운동화"),
+          () -> assertThat(result.getBrandName()).isEqualTo("나이키"),
+          () -> assertThat(result.isLiked()).isTrue()
+      );
+    }
+  }
+
+  @DisplayName("ProductDetails 일급컬렉션 생성")
+  @Nested
+  class CreateCollection {
+
+    @Test
+    @DisplayName("빈 상품 리스트로 빈 ProductDetails 생성")
+    void create_withEmptyProducts() {
+      Products products = Products.from(List.of());
+      Brands brands = Brands.from(List.of());
+      ProductLikeStatuses likeStatuses = ProductLikeStatuses.empty();
+
+      ProductDetails result = sut.create(products, brands, likeStatuses);
+
+      assertThat(result.toList()).isEmpty();
+    }
+  }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductDetailsTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductDetailsTest.java
new file mode 100644
index 000000000..75a66f3f2
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductDetailsTest.java
@@ -0,0 +1,46 @@
+package com.loopers.domain.product;
+
+import com.loopers.domain.brand.Brand;
+import com.loopers.domain.money.Money;
+import com.loopers.domain.product.detail.ProductDetail;
+import com.loopers.domain.product.detail.ProductDetails;
+import com.loopers.domain.stock.Stock;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@DisplayName("ProductDetails 일급컬렉션 테스트")
+class ProductDetailsTest {
+
+  @Test
+  @DisplayName("ProductDetail 리스트로 일급컬렉션 생성")
+  void from() {
+    Product product = Product.of("상품1", Money.of(1000L), "설명1", Stock.of(10L), 1L);
+    Brand brand = Brand.of("브랜드1", "설명1");
+    ProductDetail detail = ProductDetail.of(product, brand, true);
+    List<ProductDetail> detailList = List.of(detail);
+
+    ProductDetails details = ProductDetails.from(detailList);
+
+    assertThat(details.toList()).hasSize(1);
+  }
+
+  @Test
+  @DisplayName("내부 리스트를 불변 복사본으로 반환")
+  void toList() {
+    Product product = Product.of("상품1", Money.of(1000L), "설명1", Stock.of(10L), 1L);
+    Brand brand = Brand.of("브랜드1", "설명1");
+    ProductDetail detail = ProductDetail.of(product, brand, true);
+    ProductDetails details = ProductDetails.from(List.of(detail));
+
+    List<ProductDetail> list1 = details.toList();
+    List<ProductDetail> list2 = details.toList();
+
+    assertThat(list1)
+        .isNotSameAs(list2)
+        .isEqualTo(list2);
+  }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java
new file mode 100644
index 000000000..429de12ca
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java
@@ -0,0 +1,211 @@
+package com.loopers.domain.product;
+
+import com.loopers.domain.money.Money;
+import com.loopers.domain.stock.Stock;
+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 org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.NullAndEmptySource;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+@DisplayName("Product 도메인 테스트")
+class ProductTest {
+
+  @DisplayName("Product를 생성할 때")
+  @Nested
+  class Create {
+
+    @DisplayName("올바른 정보로 생성하면 성공한다")
+    @Test
+    void shouldCreate_whenValid() {
+      String name = "나이키 에어맥스";
+      Money price = Money.of(150000L);
+      String description = "편안한 운동화";
+      Stock stock = Stock.of(100L);
+      Long brandId = 1L;
+
+      Product product = Product.of(name, price, description, stock, brandId);
+
+      assertThat(product).extracting("name", "description", "brandId")
+          .containsExactly(name, description, brandId);
+      assertThat(product).extracting("price").isEqualTo(price);
+      assertThat(product).extracting("stock").isEqualTo(stock);
+      assertThat(product).extracting("likeCount").isEqualTo(0L);
+    }
+  }
+
+  @DisplayName("name 검증")
+  @Nested
+  class ValidateName {
+
+    @DisplayName("100자 이내면 정상 생성된다")
+    @Test
+    void shouldCreate_whenValidLength() {
+      String maxLengthName = "a".repeat(100);
+      Money price = Money.of(10000L);
+      Stock stock = Stock.of(10L);
+
+      Product product = Product.of(maxLengthName, price, "설명", stock, 1L);
+
+      assertThat(product).extracting("name").isEqualTo(maxLengthName);
+    }
+
+    @DisplayName("null 또는 빈 문자열이면 예외가 발생한다")
+    @ParameterizedTest
+    @NullAndEmptySource
+    void shouldThrowException_whenNullOrEmpty(String invalidName) {
+      Money price = Money.of(10000L);
+      Stock stock = Stock.of(10L);
+
+      assertThatThrownBy(() -> Product.of(invalidName, price, "설명", stock, 1L))
+          .isInstanceOf(CoreException.class)
+          .hasMessage("상품명은 비어있을 수 없습니다.")
+          .extracting("errorType").isEqualTo(ErrorType.INVALID_PRODUCT_NAME_EMPTY);
+    }
+
+    @DisplayName("100자를 초과하면 예외가 발생한다")
+    @Test
+    void shouldThrowException_whenTooLong() {
+      String tooLongName = "a".repeat(101);
+      Money price = Money.of(10000L);
+      Stock stock = Stock.of(10L);
+
+      assertThatThrownBy(() -> Product.of(tooLongName, price, "설명", stock, 1L))
+          .isInstanceOf(CoreException.class)
+          .hasMessage("상품명은 100자 이내여야 합니다.")
+          .extracting("errorType").isEqualTo(ErrorType.INVALID_PRODUCT_NAME_LENGTH);
+    }
+  }
+
+  @DisplayName("price 검증")
+  @Nested
+  class ValidatePrice {
+
+    @DisplayName("null이면 예외가 발생한다")
+    @Test
+    void shouldThrowException_whenNull() {
+      Stock stock = Stock.of(10L);
+
+      assertThatThrownBy(() -> Product.of("상품명", null, "설명", stock, 1L))
+          .isInstanceOf(CoreException.class)
+          .hasMessage("가격은 비어있을 수 없습니다.")
+          .extracting("errorType").isEqualTo(ErrorType.INVALID_PRODUCT_PRICE_EMPTY);
+    }
+  }
+
+  @DisplayName("stock 검증")
+  @Nested
+  class ValidateStock {
+
+    @DisplayName("null이면 예외가 발생한다")
+    @Test
+    void shouldThrowException_whenNull() {
+      Money price = Money.of(10000L);
+
+      assertThatThrownBy(() -> Product.of("상품명", price, "설명", null, 1L))
+          .isInstanceOf(CoreException.class)
+          .hasMessage("재고는 비어있을 수 없습니다.")
+          .extracting("errorType").isEqualTo(ErrorType.INVALID_PRODUCT_STOCK_EMPTY);
+    }
+  }
+
+  @DisplayName("brandId 검증")
+  @Nested
+  class ValidateBrandId {
+
+    @DisplayName("null이면 예외가 발생한다")
+    @Test
+    void shouldThrowException_whenNull() {
+      Money price = Money.of(10000L);
+      Stock stock = Stock.of(10L);
+
+      assertThatThrownBy(() -> Product.of("상품명", price, "설명", stock, null))
+          .isInstanceOf(CoreException.class)
+          .hasMessage("브랜드는 비어있을 수 없습니다.")
+          .extracting("errorType").isEqualTo(ErrorType.INVALID_PRODUCT_BRAND_EMPTY);
+    }
+  }
+
+  @DisplayName("비즈니스 로직")
+  @Nested
+  class BusinessLogic {
+
+    @DisplayName("재고가 충분하면 차감에 성공한다")
+    @Test
+    void shouldDecreaseStock_whenSufficient() {
+      Product product = Product.of("상품명", Money.of(10000L), "설명", Stock.of(100L), 1L);
+
+      product.decreaseStock(30L);
+
+      assertThat(product).extracting("stock").isEqualTo(Stock.of(70L));
+    }
+
+    @DisplayName("재고가 부족하면 예외가 발생한다")
+    @Test
+    void shouldThrowException_whenInsufficientStock() {
+      Product product = Product.of("상품명", Money.of(10000L), "설명", Stock.of(10L), 1L);
+
+      assertThatThrownBy(() -> product.decreaseStock(20L))
+          .isInstanceOf(CoreException.class)
+          .hasMessage("재고가 부족합니다.")
+          .extracting("errorType").isEqualTo(ErrorType.INSUFFICIENT_STOCK);
+    }
+
+    @DisplayName("재고가 있으면 구매 가능하다")
+    @Test
+    void shouldReturnTrue_whenAvailable() {
+      Product product = Product.of("상품명", Money.of(10000L), "설명", Stock.of(1L), 1L);
+
+      boolean available = product.isAvailable();
+
+      assertThat(available).isTrue();
+    }
+
+    @DisplayName("재고가 0이면 구매 불가능하다")
+    @Test
+    void shouldReturnFalse_whenNotAvailable() {
+      Product product = Product.of("상품명", Money.of(10000L), "설명", Stock.zero(), 1L);
+
+      boolean available = product.isAvailable();
+
+      assertThat(available).isFalse();
+    }
+
+    @DisplayName("좋아요 수를 증가시킨다")
+    @Test
+    void shouldIncreaseLikeCount() {
+      Product product = Product.of("상품명", Money.of(10000L), "설명", Stock.of(10L), 1L);
+
+      product.increaseLikeCount();
+
+      assertThat(product).extracting("likeCount").isEqualTo(1L);
+    }
+
+    @DisplayName("좋아요 수를 감소시킨다")
+    @Test
+    void shouldDecreaseLikeCount() {
+      Product product = Product.of("상품명", Money.of(10000L), "설명", Stock.of(10L), 1L);
+      product.increaseLikeCount();
+      product.increaseLikeCount();
+
+      product.decreaseLikeCount();
+
+      assertThat(product).extracting("likeCount").isEqualTo(1L);
+    }
+
+    @DisplayName("좋아요 수가 0일 때 감소시켜도 음수가 되지 않는다")
+    @Test
+    void shouldNotDecreaseLikeCount_whenZero() {
+      Product product = Product.of("상품명", Money.of(10000L), "설명", Stock.of(10L), 1L);
+
+      product.decreaseLikeCount();
+
+      assertThat(product).extracting("likeCount").isEqualTo(0L);
+    }
+  }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductsTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductsTest.java
new file mode 100644
index 000000000..28568d400
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductsTest.java
@@ -0,0 +1,55 @@
+package com.loopers.domain.product;
+
+import com.loopers.domain.money.Money;
+import com.loopers.domain.stock.Stock;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@DisplayName("Products 일급컬렉션 테스트")
+class ProductsTest {
+
+  @Test
+  @DisplayName("Product 리스트로 일급컬렉션을 생성한다")
+  void from() {
+    Product product1 = Product.of("상품1", Money.of(1000L), "설명1", Stock.of(10L), 1L);
+    Product product2 = Product.of("상품2", Money.of(2000L), "설명2", Stock.of(20L), 2L);
+    List<Product> productList = List.of(product1, product2);
+
+    Products products = Products.from(productList);
+
+    assertThat(products.toList()).hasSize(2);
+  }
+
+  @Test
+  @DisplayName("브랜드 ID 목록을 중복 제거하여 추출한다")
+  void getBrandIds() {
+    Product product1 = Product.of("상품1", Money.of(1000L), "설명1", Stock.of(10L), 1L);
+    Product product2 = Product.of("상품2", Money.of(2000L), "설명2", Stock.of(20L), 1L);
+    Product product3 = Product.of("상품3", Money.of(3000L), "설명3", Stock.of(30L), 2L);
+    Products products = Products.from(List.of(product1, product2, product3));
+
+    List<Long> brandIds = products.getBrandIds();
+
+    assertThat(brandIds)
+        .hasSize(2)
+        .containsExactlyInAnyOrder(1L, 2L);
+  }
+
+  @Test
+  @DisplayName("내부 리스트를 불변 복사본으로 반환한다")
+  void toList() {
+    Product product = Product.of("상품1", Money.of(1000L), "설명1", Stock.of(10L), 1L);
+    Products products = Products.from(List.of(product));
+
+    List<Product> list1 = products.toList();
+    List<Product> list2 = products.toList();
+
+    assertThat(list1)
+        .isNotSameAs(list2)
+        .isEqualTo(list2);
+  }
+}

From eca19236ff39c8cc143220d2ed772dce52cd4563 Mon Sep 17 00:00:00 2001
From: green <green@mobidoc.us>
Date: Thu, 13 Nov 2025 08:24:01 +0900
Subject: [PATCH 04/21] =?UTF-8?q?feat:=20ProductLike=20=EB=8F=84=EB=A9=94?=
 =?UTF-8?q?=EC=9D=B8=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

ProductLike 엔티티:
- User ID, Product ID 참조
- 좋아요 생성 시각 (Clock 주입)
- 복합키 기반 중복 방지

일급컬렉션:
- ProductLikes (좋아요 목록 → Product ID 추출)
- ProductLikeStatuses (상품별 좋아요 상태 매핑)

응용 계층:
- LikeFacade (좋아요 토글 기능)

테스트:
- Clock 기반 시간 테스트
- 일급컬렉션 변환 로직 테스트
---
 .../loopers/application/like/LikeFacade.java  |  41 ++++
 .../domain/productlike/ProductLike.java       |  71 +++++++
 .../productlike/ProductLikeRepository.java    |  14 ++
 .../productlike/ProductLikeService.java       |  49 +++++
 .../productlike/ProductLikeStatuses.java      |  28 +++
 .../domain/productlike/ProductLikes.java      |  46 +++++
 .../productlike/ProductLikeJpaRepository.java |  21 ++
 .../ProductLikeRepositoryImpl.java            |  41 ++++
 .../application/like/LikeFacadeTest.java      | 178 +++++++++++++++++
 .../productlike/ProductLikeStatusesTest.java  |  60 ++++++
 .../domain/productlike/ProductLikeTest.java   |  97 +++++++++
 .../domain/productlike/ProductLikesTest.java  | 184 ++++++++++++++++++
 12 files changed, 830 insertions(+)
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLike.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLikeRepository.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLikeService.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLikeStatuses.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLikes.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/productlike/ProductLikeJpaRepository.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/productlike/ProductLikeRepositoryImpl.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/productlike/ProductLikeStatusesTest.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/productlike/ProductLikeTest.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/productlike/ProductLikesTest.java

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..9721f6403
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java
@@ -0,0 +1,41 @@
+package com.loopers.application.like;
+
+import com.loopers.domain.product.Product;
+import com.loopers.domain.product.ProductService;
+import com.loopers.domain.productlike.ProductLikeService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+@Component
+@RequiredArgsConstructor
+@Transactional
+public class LikeFacade {
+
+  private final ProductLikeService productLikeService;
+  private final ProductService productService;
+
+  public void likeProduct(Long userId, Long productId) {
+    Product product = productService.getById(productId);
+
+    boolean alreadyLiked = productLikeService.isLiked(userId, productId);
+    if (alreadyLiked) {
+      return;
+    }
+
+    productLikeService.createLike(userId, productId);
+    product.increaseLikeCount();
+  }
+
+  public void unlikeProduct(Long userId, Long productId) {
+    Product product = productService.getById(productId);
+
+    boolean liked = productLikeService.isLiked(userId, productId);
+    if (!liked) {
+      return;
+    }
+
+    productLikeService.deleteLike(userId, productId);
+    product.decreaseLikeCount();
+  }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLike.java b/apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLike.java
new file mode 100644
index 000000000..80bfa2955
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLike.java
@@ -0,0 +1,71 @@
+package com.loopers.domain.productlike;
+
+import com.loopers.domain.BaseEntity;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+
+/**
+ * ProductLike Entity
+ * User-Product 좋아요 관계를 추적
+ * UNIQUE 제약: userId + productId 조합 (중복 좋아요 방지)
+ */
+@Entity
+@Table(
+    name = "product_like",
+    uniqueConstraints = {
+        @UniqueConstraint(
+            name = "uk_product_like_user_product",
+            columnNames = {"ref_user_id", "ref_product_id"}
+        )
+    }
+)
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class ProductLike extends BaseEntity {
+
+  @Column(name = "ref_user_id", nullable = false)
+  private Long userId;
+
+  @Column(name = "ref_product_id", nullable = false)
+  private Long productId;
+
+  @Column(name = "liked_at", nullable = false)
+  private LocalDateTime likedAt;
+
+  private ProductLike(Long userId, Long productId, LocalDateTime likedAt) {
+    validateUserId(userId);
+    validateProductId(productId);
+
+    this.userId = userId;
+    this.productId = productId;
+    this.likedAt = likedAt;
+  }
+
+
+  public static ProductLike of(Long userId, Long productId, LocalDateTime likedAt) {
+    return new ProductLike(userId, productId, likedAt);
+  }
+
+
+  public boolean isLikedBy(Long userId) {
+    return this.userId.equals(userId);
+  }
+
+  private void validateUserId(Long userId) {
+    if (userId == null) {
+      throw new CoreException(ErrorType.INVALID_PRODUCT_LIKE_USER_EMPTY);
+    }
+  }
+
+  private void validateProductId(Long productId) {
+    if (productId == null) {
+      throw new CoreException(ErrorType.INVALID_PRODUCT_LIKE_PRODUCT_EMPTY);
+    }
+  }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLikeRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLikeRepository.java
new file mode 100644
index 000000000..a3c2546e5
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLikeRepository.java
@@ -0,0 +1,14 @@
+package com.loopers.domain.productlike;
+
+import java.util.List;
+
+public interface ProductLikeRepository {
+
+  boolean existsByUserIdAndProductId(Long userId, Long productId);
+
+  List<ProductLike> findByUserIdAndProductIdIn(Long userId, List<Long> productIds);
+
+  ProductLike save(ProductLike productLike);
+
+  void deleteByUserIdAndProductId(Long userId, Long productId);
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLikeService.java b/apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLikeService.java
new file mode 100644
index 000000000..eba7d0994
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLikeService.java
@@ -0,0 +1,49 @@
+package com.loopers.domain.productlike;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class ProductLikeService {
+
+  private final ProductLikeRepository productLikeRepository;
+
+  public boolean isLiked(Long userId, Long productId) {
+    return productLikeRepository.existsByUserIdAndProductId(userId, productId);
+  }
+
+
+  public ProductLikeStatuses findLikeStatusByUser(Long userId, List<Long> productIds) {
+    if (userId == null || productIds == null || productIds.isEmpty()) {
+      return ProductLikeStatuses.empty();
+    }
+
+    List<ProductLike> likes = productLikeRepository.findByUserIdAndProductIdIn(userId, productIds);
+    ProductLikes productLikes = ProductLikes.from(likes);
+
+    return productLikes.toStatuses(productIds);
+  }
+
+
+  @Transactional
+  public void createLike(Long userId, Long productId) {
+    if (productLikeRepository.existsByUserIdAndProductId(userId, productId)) {
+      return;
+    }
+
+    ProductLike like = ProductLike.of(userId, productId, LocalDateTime.now());
+    productLikeRepository.save(like);
+  }
+
+
+  @Transactional
+  public void deleteLike(Long userId, Long productId) {
+    productLikeRepository.deleteByUserIdAndProductId(userId, productId);
+  }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLikeStatuses.java b/apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLikeStatuses.java
new file mode 100644
index 000000000..c867b9b54
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLikeStatuses.java
@@ -0,0 +1,28 @@
+package com.loopers.domain.productlike;
+
+import java.util.Map;
+
+public class ProductLikeStatuses {
+
+  private final Map<Long, Boolean> values;
+
+  private ProductLikeStatuses(Map<Long, Boolean> values) {
+    this.values = values;
+  }
+
+  public static ProductLikeStatuses from(Map<Long, Boolean> statuses) {
+    return new ProductLikeStatuses(Map.copyOf(statuses));
+  }
+
+  public static ProductLikeStatuses empty() {
+    return new ProductLikeStatuses(Map.of());
+  }
+
+  public boolean isLiked(Long productId) {
+    return values.getOrDefault(productId, false);
+  }
+
+  public Map<Long, Boolean> toMap() {
+    return Map.copyOf(values);
+  }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLikes.java b/apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLikes.java
new file mode 100644
index 000000000..cbf21d991
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLikes.java
@@ -0,0 +1,46 @@
+package com.loopers.domain.productlike;
+
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+public class ProductLikes {
+
+  private final List<ProductLike> values;
+
+  private ProductLikes(List<ProductLike> productLikes) {
+    this.values = List.copyOf(productLikes);
+  }
+
+  public static ProductLikes from(List<ProductLike> productLikes) {
+    if (productLikes == null) {
+      throw new IllegalArgumentException("productLikes cannot be null");
+    }
+    return new ProductLikes(productLikes);
+  }
+
+  public static ProductLikes empty() {
+    return new ProductLikes(List.of());
+  }
+
+  public Map<Long, Boolean> toLikedMap() {
+    return values.stream()
+        .collect(Collectors.toMap(ProductLike::getProductId, like -> true));
+  }
+
+  public ProductLikeStatuses toStatuses(List<Long> allProductIds) {
+    if (allProductIds == null || allProductIds.isEmpty()) {
+      return ProductLikeStatuses.empty();
+    }
+
+    Map<Long, Boolean> likedMap = toLikedMap();
+
+    Map<Long, Boolean> statusMap = allProductIds.stream()
+        .collect(Collectors.toMap(
+            productId -> productId,
+            productId -> likedMap.getOrDefault(productId, false)
+        ));
+
+    return ProductLikeStatuses.from(statusMap);
+  }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/productlike/ProductLikeJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/productlike/ProductLikeJpaRepository.java
new file mode 100644
index 000000000..479780ac5
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/productlike/ProductLikeJpaRepository.java
@@ -0,0 +1,21 @@
+package com.loopers.infrastructure.productlike;
+
+import com.loopers.domain.productlike.ProductLike;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+import java.util.Optional;
+
+public interface ProductLikeJpaRepository extends JpaRepository<ProductLike, Long> {
+
+
+  Optional<ProductLike> findByUserIdAndProductId(Long userId, Long productId);
+
+
+  boolean existsByUserIdAndProductId(Long userId, Long productId);
+
+
+  List<ProductLike> findByUserIdAndProductIdIn(Long userId, List<Long> productIds);
+
+  void deleteByUserIdAndProductId(Long userId, Long productId);
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/productlike/ProductLikeRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/productlike/ProductLikeRepositoryImpl.java
new file mode 100644
index 000000000..46a575529
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/productlike/ProductLikeRepositoryImpl.java
@@ -0,0 +1,41 @@
+package com.loopers.infrastructure.productlike;
+
+import com.loopers.domain.productlike.ProductLike;
+import com.loopers.domain.productlike.ProductLikeRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+
+@Repository
+@RequiredArgsConstructor
+public class ProductLikeRepositoryImpl implements ProductLikeRepository {
+
+  private final ProductLikeJpaRepository jpaRepository;
+
+  @Override
+  public boolean existsByUserIdAndProductId(Long userId, Long productId) {
+    if (userId == null) {
+      return false;
+    }
+    return jpaRepository.existsByUserIdAndProductId(userId, productId);
+  }
+
+  @Override
+  public List<ProductLike> findByUserIdAndProductIdIn(Long userId, List<Long> productIds) {
+    if (userId == null || productIds == null || productIds.isEmpty()) {
+      return List.of();
+    }
+    return jpaRepository.findByUserIdAndProductIdIn(userId, productIds);
+  }
+
+  @Override
+  public ProductLike save(ProductLike productLike) {
+    return jpaRepository.save(productLike);
+  }
+
+  @Override
+  public void deleteByUserIdAndProductId(Long userId, Long productId) {
+    jpaRepository.deleteByUserIdAndProductId(userId, productId);
+  }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java
new file mode 100644
index 000000000..8e88c75e0
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java
@@ -0,0 +1,178 @@
+package com.loopers.application.like;
+
+import com.loopers.domain.money.Money;
+import com.loopers.domain.product.Product;
+import com.loopers.domain.product.ProductService;
+import com.loopers.domain.productlike.ProductLikeService;
+import com.loopers.domain.stock.Stock;
+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 org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.BDDMockito.then;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+
+@ExtendWith(MockitoExtension.class)
+@DisplayName("LikeFacade 테스트")
+class LikeFacadeTest {
+
+  @InjectMocks
+  private LikeFacade sut;
+
+  @Mock
+  private ProductLikeService productLikeService;
+
+  @Mock
+  private ProductService productService;
+
+  @Nested
+  @DisplayName("좋아요 등록")
+  class LikeProduct {
+
+    @Test
+    @DisplayName("좋아요 등록 성공")
+    void likeProduct_success() {
+      // given
+      Long userId = 1L;
+      Long productId = 1L;
+      Product product = Product.of(
+          "상품1",
+          Money.of(10000L),
+          "설명",
+          Stock.of(100L),
+          1L
+      );
+
+      given(productService.getById(productId)).willReturn(product);
+      given(productLikeService.isLiked(userId, productId)).willReturn(false);
+
+      // when
+      sut.likeProduct(userId, productId);
+
+      // then
+      then(productLikeService).should(times(1)).createLike(userId, productId);
+      then(productService).should(times(1)).getById(productId);
+    }
+
+    @Test
+    @DisplayName("이미 좋아요한 상품 재등록 시 멱등성 보장 (아무것도 하지 않음)")
+    void likeProduct_idempotent() {
+      // given
+      Long userId = 1L;
+      Long productId = 1L;
+      Product product = Product.of(
+          "상품1",
+          Money.of(10000L),
+          "설명",
+          Stock.of(100L),
+          1L
+      );
+
+      given(productService.getById(productId)).willReturn(product);
+      given(productLikeService.isLiked(userId, productId)).willReturn(true);
+
+      // when
+      sut.likeProduct(userId, productId);
+
+      // then
+      then(productLikeService).should(never()).createLike(userId, productId);
+    }
+
+    @Test
+    @DisplayName("존재하지 않는 상품 좋아요 시 CoreException 발생")
+    void likeProduct_productNotFound() {
+      // given
+      Long userId = 1L;
+      Long productId = 999L;
+
+      given(productService.getById(productId))
+          .willThrow(new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
+
+      // when & then
+      assertThatThrownBy(() -> sut.likeProduct(userId, productId))
+          .isInstanceOf(CoreException.class)
+          .hasMessageContaining("상품을 찾을 수 없습니다.");
+    }
+  }
+
+  @Nested
+  @DisplayName("좋아요 취소")
+  class UnlikeProduct {
+
+    @Test
+    @DisplayName("좋아요 취소 성공")
+    void unlikeProduct_success() {
+      // given
+      Long userId = 1L;
+      Long productId = 1L;
+      Product product = Product.of(
+          "상품1",
+          Money.of(10000L),
+          "설명",
+          Stock.of(100L),
+          1L
+      );
+
+      given(productService.getById(productId)).willReturn(product);
+      given(productLikeService.isLiked(userId, productId)).willReturn(true);
+
+      // when
+      sut.unlikeProduct(userId, productId);
+
+      // then
+      then(productLikeService).should(times(1)).deleteLike(userId, productId);
+      then(productService).should(times(1)).getById(productId);
+    }
+
+    @Test
+    @DisplayName("이미 취소한 좋아요 재취소 시 멱등성 보장 (아무것도 하지 않음)")
+    void unlikeProduct_idempotent() {
+      // given
+      Long userId = 1L;
+      Long productId = 1L;
+      Product product = Product.of(
+          "상품1",
+          Money.of(10000L),
+          "설명",
+          Stock.of(100L),
+          1L
+      );
+
+      given(productService.getById(productId)).willReturn(product);
+      given(productLikeService.isLiked(userId, productId)).willReturn(false);
+
+      // when
+      sut.unlikeProduct(userId, productId);
+
+      // then
+      then(productLikeService).should(never()).deleteLike(userId, productId);
+    }
+
+    @Test
+    @DisplayName("존재하지 않는 상품 좋아요 취소 시 CoreException 발생")
+    void unlikeProduct_productNotFound() {
+      // given
+      Long userId = 1L;
+      Long productId = 999L;
+
+      given(productService.getById(productId))
+          .willThrow(new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
+
+      // when & then
+      assertThatThrownBy(() -> sut.unlikeProduct(userId, productId))
+          .isInstanceOf(CoreException.class)
+          .hasMessageContaining("상품을 찾을 수 없습니다.");
+    }
+  }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/productlike/ProductLikeStatusesTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/productlike/ProductLikeStatusesTest.java
new file mode 100644
index 000000000..85e828638
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/productlike/ProductLikeStatusesTest.java
@@ -0,0 +1,60 @@
+package com.loopers.domain.productlike;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@DisplayName("ProductLikeStatuses 일급컬렉션 테스트")
+class ProductLikeStatusesTest {
+
+  @Test
+  @DisplayName("Map으로 일급컬렉션을 생성한다")
+  void from() {
+    Map<Long, Boolean> statusMap = Map.of(1L, true, 2L, false);
+
+    ProductLikeStatuses statuses = ProductLikeStatuses.from(statusMap);
+
+    assertThat(statuses.toMap()).hasSize(2);
+  }
+
+  @Test
+  @DisplayName("빈 일급컬렉션을 생성한다")
+  void empty() {
+    ProductLikeStatuses statuses = ProductLikeStatuses.empty();
+
+    assertThat(statuses.toMap()).isEmpty();
+  }
+
+  @Test
+  @DisplayName("특정 상품의 좋아요 여부를 확인한다")
+  void isLiked() {
+    Map<Long, Boolean> statusMap = Map.of(1L, true, 2L, false);
+    ProductLikeStatuses statuses = ProductLikeStatuses.from(statusMap);
+
+    assertThat(statuses.isLiked(1L)).isTrue();
+    assertThat(statuses.isLiked(2L)).isFalse();
+  }
+
+  @Test
+  @DisplayName("존재하지 않는 상품 ID는 false를 반환한다")
+  void isLiked_whenProductNotExists() {
+    ProductLikeStatuses statuses = ProductLikeStatuses.from(Map.of(1L, true));
+
+    assertThat(statuses.isLiked(999L)).isFalse();
+  }
+
+  @Test
+  @DisplayName("내부 Map을 불변 복사본으로 반환한다")
+  void toMap() {
+    Map<Long, Boolean> statusMap = Map.of(1L, true);
+    ProductLikeStatuses statuses = ProductLikeStatuses.from(statusMap);
+
+    Map<Long, Boolean> map1 = statuses.toMap();
+    Map<Long, Boolean> map2 = statuses.toMap();
+
+    assertThat(map1).isEqualTo(map2);
+  }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/productlike/ProductLikeTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/productlike/ProductLikeTest.java
new file mode 100644
index 000000000..ead588de6
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/productlike/ProductLikeTest.java
@@ -0,0 +1,97 @@
+package com.loopers.domain.productlike;
+
+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 java.time.LocalDateTime;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+@DisplayName("ProductLike 도메인 테스트")
+class ProductLikeTest {
+
+  private static final LocalDateTime LIKED_AT_2025_10_30 = LocalDateTime.of(2025, 10, 30, 0, 0, 0);
+
+  @DisplayName("ProductLike를 생성할 때")
+  @Nested
+  class Create {
+
+    @DisplayName("사용자 ID, 상품 ID, 좋아요 시각을 모두 제공하면 ProductLike가 생성된다")
+    @Test
+    void shouldCreate_whenValid() {
+      Long userId = 1L;
+      Long productId = 100L;
+
+      ProductLike productLike = ProductLike.of(userId, productId, LIKED_AT_2025_10_30);
+
+      assertThat(productLike).extracting("userId", "productId", "likedAt")
+          .containsExactly(userId, productId, LIKED_AT_2025_10_30);
+    }
+  }
+
+  @DisplayName("userId 검증")
+  @Nested
+  class ValidateUserId {
+
+    @DisplayName("null이면 예외가 발생한다")
+    @Test
+    void shouldThrowException_whenNull() {
+      Long productId = 100L;
+
+      assertThatThrownBy(() -> ProductLike.of(null, productId, LIKED_AT_2025_10_30))
+          .isInstanceOf(CoreException.class)
+          .hasMessage("사용자는 비어있을 수 없습니다.")
+          .extracting("errorType").isEqualTo(ErrorType.INVALID_PRODUCT_LIKE_USER_EMPTY);
+    }
+  }
+
+  @DisplayName("productId 검증")
+  @Nested
+  class ValidateProductId {
+
+    @DisplayName("null이면 예외가 발생한다")
+    @Test
+    void shouldThrowException_whenNull() {
+      Long userId = 1L;
+
+      assertThatThrownBy(() -> ProductLike.of(userId, null, LIKED_AT_2025_10_30))
+          .isInstanceOf(CoreException.class)
+          .hasMessage("상품은 비어있을 수 없습니다.")
+          .extracting("errorType").isEqualTo(ErrorType.INVALID_PRODUCT_LIKE_PRODUCT_EMPTY);
+    }
+  }
+
+  @DisplayName("비즈니스 로직")
+  @Nested
+  class BusinessLogic {
+
+    @DisplayName("해당 사용자의 좋아요이면 true를 반환한다")
+    @Test
+    void shouldReturnTrue_whenLikedByUser() {
+      Long userId = 1L;
+      Long productId = 100L;
+      ProductLike productLike = ProductLike.of(userId, productId, LIKED_AT_2025_10_30);
+
+      boolean result = productLike.isLikedBy(userId);
+
+      assertThat(result).isTrue();
+    }
+
+    @DisplayName("다른 사용자의 좋아요이면 false를 반환한다")
+    @Test
+    void shouldReturnFalse_whenNotLikedByUser() {
+      Long userId = 1L;
+      Long otherUserId = 2L;
+      Long productId = 100L;
+      ProductLike productLike = ProductLike.of(userId, productId, LIKED_AT_2025_10_30);
+
+      boolean result = productLike.isLikedBy(otherUserId);
+
+      assertThat(result).isFalse();
+    }
+  }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/productlike/ProductLikesTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/productlike/ProductLikesTest.java
new file mode 100644
index 000000000..c2a45e635
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/productlike/ProductLikesTest.java
@@ -0,0 +1,184 @@
+package com.loopers.domain.productlike;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+@DisplayName("ProductLikes 일급 컬렉션 테스트")
+class ProductLikesTest {
+
+  private static final LocalDateTime LIKED_AT_2025_10_30 = LocalDateTime.of(2025, 10, 30, 0, 0, 0);
+
+  @Nested
+  @DisplayName("생성")
+  class Create {
+
+    @Test
+    @DisplayName("빈 리스트로 생성 성공")
+    void from_emptyList() {
+      ProductLikes productLikes = ProductLikes.from(List.of());
+
+      assertThat(productLikes).isNotNull();
+    }
+
+    @Test
+    @DisplayName("여러 개의 ProductLike로 생성 성공")
+    void from_multipleProductLikes() {
+      List<ProductLike> likes = List.of(
+          ProductLike.of(1L, 100L, LIKED_AT_2025_10_30),
+          ProductLike.of(1L, 200L, LIKED_AT_2025_10_30)
+      );
+
+      ProductLikes productLikes = ProductLikes.from(likes);
+
+      assertThat(productLikes).isNotNull();
+    }
+
+    @Test
+    @DisplayName("null 리스트로 생성 시 IllegalArgumentException 발생")
+    void from_nullList() {
+      assertThatThrownBy(() -> ProductLikes.from(null))
+          .isInstanceOf(IllegalArgumentException.class)
+          .hasMessage("productLikes cannot be null");
+    }
+
+    @Test
+    @DisplayName("empty() 정적 팩토리 메서드로 빈 컬렉션 생성")
+    void empty() {
+      ProductLikes productLikes = ProductLikes.empty();
+
+      assertThat(productLikes).isNotNull();
+    }
+  }
+
+  @Nested
+  @DisplayName("toLikedMap 변환")
+  class ToLikedMap {
+
+    @Test
+    @DisplayName("빈 리스트를 빈 Map으로 변환")
+    void toLikedMap_empty() {
+      ProductLikes productLikes = ProductLikes.empty();
+
+      Map<Long, Boolean> result = productLikes.toLikedMap();
+
+      assertThat(result).isEmpty();
+    }
+
+    @Test
+    @DisplayName("ProductLike 리스트를 좋아요 Map으로 변환 (모두 true)")
+    void toLikedMap_success() {
+      List<ProductLike> likes = List.of(
+          ProductLike.of(1L, 100L, LIKED_AT_2025_10_30),
+          ProductLike.of(1L, 200L, LIKED_AT_2025_10_30),
+          ProductLike.of(1L, 300L, LIKED_AT_2025_10_30)
+      );
+      ProductLikes productLikes = ProductLikes.from(likes);
+
+      Map<Long, Boolean> result = productLikes.toLikedMap();
+
+      assertThat(result)
+          .hasSize(3)
+          .containsEntry(100L, true)
+          .containsEntry(200L, true)
+          .containsEntry(300L, true);
+    }
+  }
+
+  @Nested
+  @DisplayName("toStatuses 변환")
+  class ToStatuses {
+
+    @Test
+    @DisplayName("null productIds로 호출 시 빈 ProductLikeStatuses 반환")
+    void toStatuses_nullProductIds() {
+      ProductLikes productLikes = ProductLikes.empty();
+
+      ProductLikeStatuses result = productLikes.toStatuses(null);
+
+      assertThat(result).isNotNull();
+    }
+
+    @Test
+    @DisplayName("빈 productIds로 호출 시 빈 ProductLikeStatuses 반환")
+    void toStatuses_emptyProductIds() {
+      ProductLikes productLikes = ProductLikes.from(List.of(
+          ProductLike.of(1L, 100L, LIKED_AT_2025_10_30)
+      ));
+
+      ProductLikeStatuses result = productLikes.toStatuses(List.of());
+
+      assertThat(result).isNotNull();
+    }
+
+    @Test
+    @DisplayName("좋아요한 상품과 안 한 상품 혼합 상태로 변환")
+    void toStatuses_mixed() {
+      List<ProductLike> likes = List.of(
+          ProductLike.of(1L, 100L, LIKED_AT_2025_10_30),
+          ProductLike.of(1L, 300L, LIKED_AT_2025_10_30)
+      );
+      ProductLikes productLikes = ProductLikes.from(likes);
+      List<Long> allProductIds = List.of(100L, 200L, 300L, 400L);
+
+      ProductLikeStatuses result = productLikes.toStatuses(allProductIds);
+
+      assertThat(result.isLiked(100L)).isTrue();
+      assertThat(result.isLiked(200L)).isFalse();
+      assertThat(result.isLiked(300L)).isTrue();
+      assertThat(result.isLiked(400L)).isFalse();
+    }
+
+    @Test
+    @DisplayName("모든 상품을 좋아요하지 않은 경우")
+    void toStatuses_allNotLiked() {
+      ProductLikes productLikes = ProductLikes.empty();
+      List<Long> allProductIds = List.of(100L, 200L, 300L);
+
+      ProductLikeStatuses result = productLikes.toStatuses(allProductIds);
+
+      assertThat(result.isLiked(100L)).isFalse();
+      assertThat(result.isLiked(200L)).isFalse();
+      assertThat(result.isLiked(300L)).isFalse();
+    }
+
+    @Test
+    @DisplayName("모든 상품을 좋아요한 경우")
+    void toStatuses_allLiked() {
+      List<ProductLike> likes = List.of(
+          ProductLike.of(1L, 100L, LIKED_AT_2025_10_30),
+          ProductLike.of(1L, 200L, LIKED_AT_2025_10_30),
+          ProductLike.of(1L, 300L, LIKED_AT_2025_10_30)
+      );
+      ProductLikes productLikes = ProductLikes.from(likes);
+      List<Long> allProductIds = List.of(100L, 200L, 300L);
+
+      ProductLikeStatuses result = productLikes.toStatuses(allProductIds);
+
+      assertThat(result.isLiked(100L)).isTrue();
+      assertThat(result.isLiked(200L)).isTrue();
+      assertThat(result.isLiked(300L)).isTrue();
+    }
+
+    @Test
+    @DisplayName("좋아요 목록에 없는 상품 ID 조회 시 false 반환")
+    void toStatuses_productNotInList() {
+      List<ProductLike> likes = List.of(
+          ProductLike.of(1L, 100L, LIKED_AT_2025_10_30)
+      );
+      ProductLikes productLikes = ProductLikes.from(likes);
+      List<Long> allProductIds = List.of(100L, 999L);
+
+      ProductLikeStatuses result = productLikes.toStatuses(allProductIds);
+
+      assertThat(result.isLiked(999L)).isFalse();
+    }
+  }
+}

From f533d96ad026d780d9671bc8acc6d8125f1b534b Mon Sep 17 00:00:00 2001
From: green <green@mobidoc.us>
Date: Thu, 13 Nov 2025 08:24:22 +0900
Subject: [PATCH 05/21] =?UTF-8?q?feat:=20Order=20=EB=8F=84=EB=A9=94?=
 =?UTF-8?q?=EC=9D=B8=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Order Aggregate:
- Order (주문 엔티티, OrderStatus Enum)
- OrderItem (주문 항목, 양방향 관계)
- OrderPrice VO (주문 가격)
- OrderItems 일급컬렉션 (총액 계산)

비즈니스 로직:
- OrderFacade (주문 생성 트랜잭션)
- 재고 검증 → 포인트 검증 → 재고 차감 → 포인트 차감 → 주문 생성
- 트랜잭션 롤백 보장

객체 참조 설계:
- Order ↔ OrderItem 양방향 (Aggregate Root)
- cascade=ALL, orphanRemoval=true

테스트:
- Facade 통합 테스트 (트랜잭션 롤백 검증)
- Service 통합/단위 테스트
- Repository 통합 테스트
- Race Condition 테스트
---
 .../application/order/OrderFacade.java        |  60 +++++
 .../java/com/loopers/domain/order/Order.java  | 104 ++++++++
 .../loopers/domain/order/OrderListDto.java    |  17 ++
 .../loopers/domain/order/OrderRepository.java |  24 ++
 .../loopers/domain/order/OrderService.java    |  53 +++++
 .../com/loopers/domain/order/OrderStatus.java |  18 ++
 .../domain/order/orderitem/OrderItem.java     | 106 +++++++++
 .../order/orderitem/OrderItemCommand.java     |  28 +++
 .../domain/order/orderitem/OrderItems.java    |  55 +++++
 .../domain/order/orderitem/OrderPrice.java    |  34 +++
 .../order/OrderJpaRepository.java             |  31 +++
 .../order/OrderRepositoryImpl.java            |  38 +++
 .../order/OrderFacadeIntegrationTest.java     | 196 +++++++++++++++
 .../order/OrderFacadeRaceConditionTest.java   | 196 +++++++++++++++
 .../order/OrderRepositoryIntegrationTest.java | 174 ++++++++++++++
 .../order/OrderServiceIntegrationTest.java    | 112 +++++++++
 .../domain/order/OrderServiceTest.java        | 175 ++++++++++++++
 .../com/loopers/domain/order/OrderTest.java   | 225 ++++++++++++++++++
 .../domain/orderitem/OrderItemTest.java       | 174 ++++++++++++++
 .../domain/orderitem/OrderPriceTest.java      |  98 ++++++++
 20 files changed, 1918 insertions(+)
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderListDto.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/orderitem/OrderItem.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/orderitem/OrderItemCommand.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/orderitem/OrderItems.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/orderitem/OrderPrice.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeRaceConditionTest.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderRepositoryIntegrationTest.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/orderitem/OrderItemTest.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/orderitem/OrderPriceTest.java

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..be4cdd375
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
@@ -0,0 +1,60 @@
+package com.loopers.application.order;
+
+import com.loopers.domain.order.Order;
+import com.loopers.domain.order.orderitem.OrderItemCommand;
+import com.loopers.domain.order.orderitem.OrderItems;
+import com.loopers.domain.order.OrderService;
+import com.loopers.domain.order.orderitem.OrderItem;
+import com.loopers.domain.point.PointService;
+import com.loopers.domain.product.Products;
+import com.loopers.domain.product.ProductService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * OrderFacade (Application Layer)
+ * 주문 생성 시 여러 도메인 서비스를 조율
+ * - 상품 재고 검증 및 차감
+ * - 포인트 잔액 검증 및 차감
+ * - 주문 생성
+ */
+@Component
+@RequiredArgsConstructor
+public class OrderFacade {
+
+  private final OrderService orderService;
+  private final ProductService productService;
+  private final PointService pointService;
+
+  @Transactional
+  public Order createOrder(Long userId, List<OrderItemCommand> commands, LocalDateTime orderedAt) {
+    List<OrderItem> requestedOrderItems = commands.stream()
+        .map(cmd -> OrderItem.of(
+            cmd.productId(),
+            cmd.productName(),
+            cmd.quantity(),
+            cmd.orderPrice()
+        ))
+        .toList();
+
+    OrderItems orderItems = OrderItems.from(requestedOrderItems);
+
+    Products products = Products.from(productService.getByIdsWithLock(orderItems.getProductIds()));
+
+    orderItems.validateStock(products);
+
+    Long totalAmount = orderItems.calculateTotalAmount();
+
+    pointService.checkBalance(userId, totalAmount);
+
+    orderItems.decreaseStock(products);
+
+    pointService.deduct(userId, totalAmount);
+
+    return orderService.create(userId, orderItems.getItems(), orderedAt);
+  }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java
new file mode 100644
index 000000000..e5d557538
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java
@@ -0,0 +1,104 @@
+package com.loopers.domain.order;
+
+import com.loopers.domain.BaseEntity;
+import com.loopers.domain.money.Money;
+import com.loopers.domain.order.orderitem.OrderItem;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Order Entity (Aggregate Root)
+ * 사용자의 주문 정보
+ * OrderItem의 생명주기를 관리
+ */
+@Entity
+@Table(name = "orders")
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class Order extends BaseEntity {
+
+  @Column(name = "ref_user_id", nullable = false)
+  private Long userId;
+
+  @Enumerated(EnumType.STRING)
+  @Column(nullable = false)
+  private OrderStatus status;
+
+  @Embedded
+  @AttributeOverride(name = "value", column = @Column(name = "total_amount"))
+  private Money totalAmount;
+
+  @Column(name = "ordered_at", nullable = false)
+  private LocalDateTime orderedAt;
+
+  @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
+  private List<OrderItem> items = new ArrayList<>();
+
+  private Order(Long userId, OrderStatus status, Money totalAmount, LocalDateTime orderedAt) {
+    validateUserId(userId);
+    validateStatus(status);
+    validateTotalAmount(totalAmount);
+    validateOrderedAt(orderedAt);
+
+    this.userId = userId;
+    this.status = status;
+    this.totalAmount = totalAmount;
+    this.orderedAt = orderedAt;
+  }
+
+  public static Order of(Long userId, OrderStatus status, Long totalAmount, LocalDateTime orderedAt) {
+    return new Order(userId, status, Money.of(totalAmount), orderedAt);
+  }
+
+  public Long getTotalAmountValue() {
+    return totalAmount.getValue();
+  }
+
+  /**
+   * OrderItem 추가 (양방향 관계 설정)
+   */
+  public void addItem(OrderItem item) {
+    this.items.add(item);
+    item.assignOrder(this);
+  }
+
+  /**
+   * 주문 상태 변경 (결제 실패 시 PAYMENT_PENDING으로 변경 등)
+   */
+  public void updateStatus(OrderStatus status) {
+    validateStatus(status);
+    this.status = status;
+  }
+
+  private void validateUserId(Long userId) {
+    if (userId == null) {
+      throw new CoreException(ErrorType.INVALID_ORDER_USER_EMPTY);
+    }
+  }
+
+  private void validateStatus(OrderStatus status) {
+    if (status == null) {
+      throw new CoreException(ErrorType.INVALID_ORDER_STATUS_EMPTY);
+    }
+  }
+
+  private void validateTotalAmount(Money totalAmount) {
+    if (totalAmount == null) {
+      throw new CoreException(ErrorType.INVALID_ORDER_TOTAL_AMOUNT_EMPTY);
+    }
+  }
+
+  private void validateOrderedAt(LocalDateTime orderedAt) {
+    if (orderedAt == null) {
+      throw new CoreException(ErrorType.INVALID_ORDER_ORDERED_AT_EMPTY);
+    }
+  }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderListDto.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderListDto.java
new file mode 100644
index 000000000..afe4d70dc
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderListDto.java
@@ -0,0 +1,17 @@
+package com.loopers.domain.order;
+
+import java.time.LocalDateTime;
+
+/**
+ * Order 목록 조회용 DTO (페이징)
+ * DTO 프로젝션 + SIZE() 사용으로 N+1 및 메모리 페이징 방지
+ */
+public record OrderListDto(
+    Long id,
+    Long userId,
+    OrderStatus status,
+    Long totalAmount,
+    LocalDateTime orderedAt,
+    Integer itemCount
+) {
+}
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..109b4e27a
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java
@@ -0,0 +1,24 @@
+package com.loopers.domain.order;
+
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+
+import java.util.Optional;
+
+public interface OrderRepository {
+
+
+  Optional<Order> findById(Long id);
+
+  /**
+   * 주문 ID로 OrderItem과 함께 조회 (@EntityGraph 사용)
+   */
+  Optional<Order> findWithItemsById(Long id);
+
+  /**
+   * 사용자별 주문 목록 조회 (DTO 프로젝션, 페이징)
+   */
+  Page<OrderListDto> findOrderList(Long userId, Pageable pageable);
+
+  Order save(Order order);
+}
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..893f7198b
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java
@@ -0,0 +1,53 @@
+package com.loopers.domain.order;
+
+import com.loopers.domain.order.orderitem.OrderItem;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class OrderService {
+
+  private final OrderRepository orderRepository;
+
+  public Order getById(Long orderId) {
+    return orderRepository.findById(orderId)
+        .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다."));
+  }
+
+  public Order getWithItemsById(Long orderId) {
+    return orderRepository.findWithItemsById(orderId)
+        .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다."));
+  }
+
+  @Transactional
+  public Order create(Long userId, List<OrderItem> orderItems, LocalDateTime orderedAt) {
+    Long totalAmount = calculateTotalAmount(orderItems);
+
+    Order order = Order.of(userId, OrderStatus.PAYMENT_PENDING, totalAmount, orderedAt);
+
+    orderItems.forEach(order::addItem);
+
+    return orderRepository.save(order);
+  }
+
+  @Transactional
+  public Order updateOrderStatus(Long orderId, OrderStatus newStatus) {
+    Order order = getById(orderId);
+    order.updateStatus(newStatus);
+    return order;
+  }
+
+  private Long calculateTotalAmount(List<OrderItem> orderItems) {
+    return orderItems.stream()
+        .mapToLong(item -> item.getOrderPriceValue() * item.getQuantityValue())
+        .sum();
+  }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java
new file mode 100644
index 000000000..ba556d5cd
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java
@@ -0,0 +1,18 @@
+package com.loopers.domain.order;
+
+
+public enum OrderStatus {
+  PENDING("주문 생성 완료"),
+  COMPLETED("결제 완료"),
+  PAYMENT_PENDING("결제 재시도 실패");
+
+  private final String description;
+
+  OrderStatus(String description) {
+    this.description = description;
+  }
+
+  public String getDescription() {
+    return description;
+  }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/orderitem/OrderItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/orderitem/OrderItem.java
new file mode 100644
index 000000000..82f8ba819
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/orderitem/OrderItem.java
@@ -0,0 +1,106 @@
+package com.loopers.domain.order.orderitem;
+
+import com.loopers.domain.BaseEntity;
+import com.loopers.domain.order.Order;
+import com.loopers.domain.quantity.Quantity;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/**
+ * OrderItem Entity
+ * 주문에 포함된 개별 상품
+ * 주문 당시의 상품명, 가격, 수량을 스냅샷으로 저장 (Product 변동 영향 없음)
+ */
+@Entity
+@Table(name = "order_item")
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class OrderItem extends BaseEntity {
+
+  private static final int MAX_PRODUCT_NAME_LENGTH = 100;
+
+  @ManyToOne(fetch = FetchType.LAZY)
+  @JoinColumn(name = "ref_order_id", nullable = false)
+  private Order order;
+
+  @Column(name = "ref_product_id", nullable = false)
+  private Long productId;
+
+  @Column(name = "product_name", nullable = false, length = MAX_PRODUCT_NAME_LENGTH)
+  private String productName;
+
+  @Embedded
+  @AttributeOverride(name = "value", column = @Column(name = "quantity", nullable = false))
+  private Quantity quantity;
+
+  @Embedded
+  @AttributeOverride(name = "value", column = @Column(name = "order_price", nullable = false))
+  private OrderPrice orderPrice;
+
+  private OrderItem(Long productId, String productName, Quantity quantity, OrderPrice orderPrice) {
+    validateProductId(productId);
+    validateProductName(productName);
+    validateQuantity(quantity);
+    validateOrderPrice(orderPrice);
+
+    this.productId = productId;
+    this.productName = productName;
+    this.quantity = quantity;
+    this.orderPrice = orderPrice;
+  }
+
+  /**
+   * OrderItem 생성 팩토리 메서드
+   * Order는 addItem()을 통해 설정됨
+   */
+  public static OrderItem of(Long productId, String productName, Quantity quantity, OrderPrice orderPrice) {
+    return new OrderItem(productId, productName, quantity, orderPrice);
+  }
+
+  /**
+   * Order 할당 (양방향 관계 설정용)
+   * Order.addItem()에서만 호출되어야 함
+   */
+  public void assignOrder(Order order) {
+    this.order = order;
+  }
+
+  public Long getOrderPriceValue() {
+    return orderPrice.getValue();
+  }
+
+  public Integer getQuantityValue() {
+    return quantity.getValue();
+  }
+
+  private void validateProductId(Long productId) {
+    if (productId == null) {
+      throw new CoreException(ErrorType.INVALID_ORDER_ITEM_PRODUCT_EMPTY);
+    }
+  }
+
+  private void validateProductName(String productName) {
+    if (productName == null || productName.isBlank()) {
+      throw new CoreException(ErrorType.INVALID_ORDER_ITEM_PRODUCT_NAME_EMPTY);
+    }
+    if (productName.length() > MAX_PRODUCT_NAME_LENGTH) {
+      throw new CoreException(ErrorType.INVALID_ORDER_ITEM_PRODUCT_NAME_LENGTH);
+    }
+  }
+
+  private void validateQuantity(Quantity quantity) {
+    if (quantity == null) {
+      throw new CoreException(ErrorType.INVALID_ORDER_ITEM_QUANTITY_EMPTY);
+    }
+  }
+
+  private void validateOrderPrice(OrderPrice orderPrice) {
+    if (orderPrice == null) {
+      throw new CoreException(ErrorType.INVALID_ORDER_PRICE_VALUE);
+    }
+  }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/orderitem/OrderItemCommand.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/orderitem/OrderItemCommand.java
new file mode 100644
index 000000000..a4a10062e
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/orderitem/OrderItemCommand.java
@@ -0,0 +1,28 @@
+package com.loopers.domain.order.orderitem;
+
+import com.loopers.domain.quantity.Quantity;
+
+public record OrderItemCommand(
+    Long productId,
+    String productName,
+    Quantity quantity,
+    OrderPrice orderPrice
+) {
+
+  public static OrderItemCommand of(
+      Long productId,
+      String productName,
+      Quantity quantity,
+      OrderPrice orderPrice
+  ) {
+    return new OrderItemCommand(productId, productName, quantity, orderPrice);
+  }
+
+  public Integer getQuantityValue() {
+    return quantity.getValue();
+  }
+
+  public Long getOrderPriceValue() {
+    return orderPrice.getValue();
+  }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/orderitem/OrderItems.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/orderitem/OrderItems.java
new file mode 100644
index 000000000..d29efbb0d
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/orderitem/OrderItems.java
@@ -0,0 +1,55 @@
+package com.loopers.domain.order.orderitem;
+
+import com.loopers.domain.product.Product;
+import com.loopers.domain.product.Products;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+
+import java.util.List;
+
+
+public class OrderItems {
+
+  private final List<OrderItem> items;
+
+  public OrderItems(List<OrderItem> items) {
+    this.items = items;
+  }
+
+  public static OrderItems from(List<OrderItem> items) {
+    return new OrderItems(items);
+  }
+
+
+  public Long calculateTotalAmount() {
+    return items.stream()
+        .mapToLong(item -> item.getOrderPriceValue() * item.getQuantityValue())
+        .sum();
+  }
+
+  public List<Long> getProductIds() {
+    return items.stream()
+        .map(OrderItem::getProductId)
+        .toList();
+  }
+
+  public void validateStock(Products products) {
+    for (OrderItem item : items) {
+      Product product = products.findById(item.getProductId())
+          .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
+      product.validateStockForOrder(item.getQuantityValue());
+    }
+  }
+
+  public void decreaseStock(Products products) {
+    for (OrderItem item : items) {
+      Product product = products.findById(item.getProductId())
+          .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
+      product.decreaseStock(item.getQuantityValue().longValue());
+    }
+  }
+
+  public List<OrderItem> getItems() {
+    return List.copyOf(items);
+  }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/orderitem/OrderPrice.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/orderitem/OrderPrice.java
new file mode 100644
index 000000000..71ed6ea4a
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/orderitem/OrderPrice.java
@@ -0,0 +1,34 @@
+package com.loopers.domain.order.orderitem;
+
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import jakarta.persistence.Embeddable;
+import lombok.AccessLevel;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+
+@Embeddable
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@EqualsAndHashCode
+public class OrderPrice {
+
+  private Long value;
+
+  private OrderPrice(Long value) {
+    if (value == null) {
+      throw new CoreException(ErrorType.INVALID_ORDER_PRICE_VALUE);
+    }
+    if (value < 0) {
+      throw new CoreException(ErrorType.NEGATIVE_ORDER_PRICE_VALUE);
+    }
+    this.value = value;
+  }
+
+  public static OrderPrice of(Long value) {
+    return new OrderPrice(value);
+  }
+
+  public Long getValue() {
+    return value;
+  }
+}
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..e5c27a4fd
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java
@@ -0,0 +1,31 @@
+package com.loopers.infrastructure.order;
+
+import com.loopers.domain.order.Order;
+import com.loopers.domain.order.OrderListDto;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.EntityGraph;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import java.util.Optional;
+
+public interface OrderJpaRepository extends JpaRepository<Order, Long> {
+
+  /**
+   * OrderItem과 함께 조회 (@EntityGraph로 N+1 방지)
+   */
+  @EntityGraph(attributePaths = "items")
+  @Query("SELECT o FROM Order o WHERE o.id = :id")
+  Optional<Order> findWithItemsById(@Param("id") Long id);
+
+  /**
+   * 사용자별 주문 목록 조회 (DTO 프로젝션, SIZE()로 개수만 조회)
+   * HHH000104 경고 없음, 메모리 페이징 발생하지 않음
+   */
+  @Query("SELECT new com.loopers.domain.order.OrderListDto(" +
+      "o.id, o.userId, o.status, o.totalAmount.value, o.orderedAt, SIZE(o.items)) " +
+      "FROM Order o WHERE o.userId = :userId")
+  Page<OrderListDto> findOrderListByUserId(@Param("userId") Long userId, Pageable pageable);
+}
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..0984ab6a1
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java
@@ -0,0 +1,38 @@
+package com.loopers.infrastructure.order;
+
+import com.loopers.domain.order.Order;
+import com.loopers.domain.order.OrderListDto;
+import com.loopers.domain.order.OrderRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Repository;
+
+import java.util.Optional;
+
+@Repository
+@RequiredArgsConstructor
+public class OrderRepositoryImpl implements OrderRepository {
+
+  private final OrderJpaRepository jpaRepository;
+
+  @Override
+  public Optional<Order> findById(Long id) {
+    return jpaRepository.findById(id);
+  }
+
+  @Override
+  public Optional<Order> findWithItemsById(Long id) {
+    return jpaRepository.findWithItemsById(id);
+  }
+
+  @Override
+  public Page<OrderListDto> findOrderList(Long userId, Pageable pageable) {
+    return jpaRepository.findOrderListByUserId(userId, pageable);
+  }
+
+  @Override
+  public Order save(Order order) {
+    return jpaRepository.save(order);
+  }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java
new file mode 100644
index 000000000..f8e77c191
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java
@@ -0,0 +1,196 @@
+package com.loopers.application.order;
+
+import com.loopers.domain.brand.Brand;
+import com.loopers.domain.brand.BrandRepository;
+import com.loopers.domain.money.Money;
+import com.loopers.domain.order.Order;
+import com.loopers.domain.order.orderitem.OrderItemCommand;
+import com.loopers.domain.order.OrderStatus;
+import com.loopers.domain.order.orderitem.OrderPrice;
+import com.loopers.domain.point.Point;
+import com.loopers.domain.point.PointRepository;
+import com.loopers.domain.product.Product;
+import com.loopers.domain.product.ProductRepository;
+import com.loopers.domain.quantity.Quantity;
+import com.loopers.domain.stock.Stock;
+import com.loopers.domain.user.Gender;
+import com.loopers.domain.user.User;
+import com.loopers.domain.user.UserRepository;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import org.junit.jupiter.api.BeforeEach;
+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.transaction.annotation.Transactional;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+@SpringBootTest
+@Transactional
+@DisplayName("OrderFacade 통합 테스트")
+class OrderFacadeIntegrationTest {
+
+  @Autowired
+  private OrderFacade orderFacade;
+
+  @Autowired
+  private ProductRepository productRepository;
+
+  @Autowired
+  private BrandRepository brandRepository;
+
+  @Autowired
+  private UserRepository userRepository;
+
+  @Autowired
+  private PointRepository pointRepository;
+
+  private User user;
+  private Brand brand;
+  private Product product1;
+  private Product product2;
+
+  private static final LocalDateTime ORDERED_AT_2025_10_30 = LocalDateTime.of(2025, 10, 30, 0, 0, 0);
+  private static final LocalDate BIRTH_DATE_1990_01_01 = LocalDate.of(1990, 1, 1);
+
+  @BeforeEach
+  void setUp() {
+    user = userRepository.save(User.of("testuser", "test@example.com", BIRTH_DATE_1990_01_01, Gender.MALE));
+    brand = brandRepository.save(Brand.of("테스트브랜드"));
+    product1 = productRepository.save(
+        Product.of("상품1", Money.of(10000L), "설명1", Stock.of(10L), brand.getId())
+    );
+    product2 = productRepository.save(
+        Product.of("상품2", Money.of(30000L), "설명2", Stock.of(5L), brand.getId())
+    );
+    pointRepository.save(Point.of(user.getId(), 100000L));
+  }
+
+  @Nested
+  @DisplayName("주문 생성")
+  class CreateOrder {
+
+    @Test
+    @DisplayName("정상 주문 생성 - 재고 차감, 포인트 차감 확인")
+    void createOrder_success() {
+      // given
+      List<OrderItemCommand> commands = List.of(
+          OrderItemCommand.of(product1.getId(), "상품1", Quantity.of(2), OrderPrice.of(10000L)),
+          OrderItemCommand.of(product2.getId(), "상품2", Quantity.of(1), OrderPrice.of(30000L))
+      );
+
+      // when
+      Order order = orderFacade.createOrder(user.getId(), commands, ORDERED_AT_2025_10_30);
+
+      // then
+      assertThat(order.getTotalAmountValue()).isEqualTo(50000L);
+      assertThat(order.getStatus()).isEqualTo(OrderStatus.PAYMENT_PENDING);
+
+      // 재고 차감 확인 (실제 DB 조회)
+      Product updatedProduct1 = productRepository.findById(product1.getId()).orElseThrow();
+      assertThat(updatedProduct1.getStockValue()).isEqualTo(8L);
+
+      Product updatedProduct2 = productRepository.findById(product2.getId()).orElseThrow();
+      assertThat(updatedProduct2.getStockValue()).isEqualTo(4L);
+
+      // 포인트 차감 확인
+      Point updatedPoint = pointRepository.findByUserId(user.getId()).orElseThrow();
+      assertThat(updatedPoint.getAmountValue()).isEqualTo(50000L);
+    }
+
+    @Test
+    @DisplayName("재고 부족 시 예외 발생 - 롤백 확인")
+    void createOrder_insufficientStock() {
+      // given
+      List<OrderItemCommand> commands = List.of(
+          OrderItemCommand.of(product1.getId(), "상품1", Quantity.of(100), OrderPrice.of(10000L))
+      );
+
+      // when & then
+      assertThatThrownBy(() -> orderFacade.createOrder(user.getId(), commands, ORDERED_AT_2025_10_30))
+          .isInstanceOf(CoreException.class)
+          .hasFieldOrPropertyWithValue("errorType", ErrorType.INSUFFICIENT_STOCK)
+          .hasMessageContaining("재고가 부족합니다");
+
+      // 롤백 확인 - 재고 변경 안 됨
+      Product unchangedProduct = productRepository.findById(product1.getId()).orElseThrow();
+      assertThat(unchangedProduct.getStockValue()).isEqualTo(10L);
+
+      // 포인트도 변경 안 됨
+      Point unchangedPoint = pointRepository.findByUserId(user.getId()).orElseThrow();
+      assertThat(unchangedPoint.getAmountValue()).isEqualTo(100000L);
+    }
+
+    @Test
+    @DisplayName("재고 0인 상품 - 예외 발생")
+    void createOrder_zeroStock() {
+      // given
+      Product zeroStockProduct = productRepository.save(
+          Product.of("재고0상품", Money.of(10000L), "설명", Stock.of(0L), brand.getId())
+      );
+
+      List<OrderItemCommand> commands = List.of(
+          OrderItemCommand.of(zeroStockProduct.getId(), "재고0상품", Quantity.of(1), OrderPrice.of(10000L))
+      );
+
+      // when & then
+      assertThatThrownBy(() -> orderFacade.createOrder(user.getId(), commands, ORDERED_AT_2025_10_30))
+          .isInstanceOf(CoreException.class)
+          .hasFieldOrPropertyWithValue("errorType", ErrorType.INSUFFICIENT_STOCK);
+    }
+
+    @Test
+    @DisplayName("포인트 부족 시 예외 발생 - 롤백 확인")
+    void createOrder_insufficientPoint() {
+      // given
+      List<OrderItemCommand> commands = List.of(
+          OrderItemCommand.of(product1.getId(), "상품1", Quantity.of(1), OrderPrice.of(10000L))
+      );
+
+      // 포인트를 5000으로 설정 (부족)
+      Point point = pointRepository.findByUserId(user.getId()).orElseThrow();
+      point.deduct(95000L);
+
+      // when & then
+      assertThatThrownBy(() -> orderFacade.createOrder(user.getId(), commands, ORDERED_AT_2025_10_30))
+          .isInstanceOf(CoreException.class)
+          .hasFieldOrPropertyWithValue("errorType", ErrorType.INSUFFICIENT_POINT_BALANCE);
+
+      // 롤백 확인 - 재고 변경 안 됨
+      Product unchangedProduct = productRepository.findById(product1.getId()).orElseThrow();
+      assertThat(unchangedProduct.getStockValue()).isEqualTo(10L);
+
+      // 포인트는 차감 전 상태 유지
+      Point unchangedPoint = pointRepository.findByUserId(user.getId()).orElseThrow();
+      assertThat(unchangedPoint.getAmountValue()).isEqualTo(5000L);
+    }
+
+    @Test
+    @DisplayName("총액 계산 정확성 검증")
+    void createOrder_totalAmountCalculation() {
+      // given
+      List<OrderItemCommand> commands = List.of(
+          OrderItemCommand.of(product1.getId(), "상품1", Quantity.of(3), OrderPrice.of(5000L)),
+          OrderItemCommand.of(product2.getId(), "상품2", Quantity.of(2), OrderPrice.of(12500L))
+      );
+
+      // when
+      Order order = orderFacade.createOrder(user.getId(), commands, ORDERED_AT_2025_10_30);
+
+      // then
+      assertThat(order.getTotalAmountValue()).isEqualTo(40000L);
+
+      // 포인트 차감 확인
+      Point updatedPoint = pointRepository.findByUserId(user.getId()).orElseThrow();
+      assertThat(updatedPoint.getAmountValue()).isEqualTo(60000L);
+    }
+  }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeRaceConditionTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeRaceConditionTest.java
new file mode 100644
index 000000000..77fbe4735
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeRaceConditionTest.java
@@ -0,0 +1,196 @@
+package com.loopers.application.order;
+
+import com.loopers.domain.brand.Brand;
+import com.loopers.domain.brand.BrandRepository;
+import com.loopers.domain.money.Money;
+import com.loopers.domain.order.orderitem.OrderItemCommand;
+import com.loopers.domain.order.orderitem.OrderPrice;
+import com.loopers.domain.point.Point;
+import com.loopers.domain.point.PointRepository;
+import com.loopers.domain.product.Product;
+import com.loopers.domain.product.ProductRepository;
+import com.loopers.domain.quantity.Quantity;
+import com.loopers.domain.stock.Stock;
+import com.loopers.domain.user.Gender;
+import com.loopers.domain.user.User;
+import com.loopers.domain.user.UserRepository;
+import com.loopers.support.error.CoreException;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@SpringBootTest
+@DisplayName("OrderFacade 동시성 테스트")
+class OrderFacadeRaceConditionTest {
+
+  @Autowired
+  private OrderFacade orderFacade;
+
+  @Autowired
+  private ProductRepository productRepository;
+
+  @Autowired
+  private BrandRepository brandRepository;
+
+  @Autowired
+  private UserRepository userRepository;
+
+  @Autowired
+  private PointRepository pointRepository;
+
+  private static final LocalDateTime ORDERED_AT_2025_10_30 = LocalDateTime.of(2025, 10, 30, 0, 0, 0);
+  private static final LocalDate BIRTH_DATE_1990_01_01 = LocalDate.of(1990, 1, 1);
+
+  @Test
+  @DisplayName("동시에 2개 주문 시 하나만 성공하고 재고가 음수가 되지 않음")
+  void concurrentOrders_onlyOneSucceeds() throws InterruptedException {
+    Brand brand = brandRepository.save(Brand.of("테스트브랜드"));
+    Product product = productRepository.save(
+        Product.of("재고1개상품", Money.of(10000L), "재고 1개 상품", Stock.of(1L), brand.getId())
+    );
+
+    int random = (int) (System.nanoTime() % 10000);
+    User user1 = userRepository.save(User.of("u1" + random, "u1" + random + "@t.com", BIRTH_DATE_1990_01_01, Gender.MALE));
+    User user2 = userRepository.save(User.of("u2" + random, "u2" + random + "@t.com", BIRTH_DATE_1990_01_01, Gender.FEMALE));
+    pointRepository.save(Point.of(user1.getId(), 50000L));
+    pointRepository.save(Point.of(user2.getId(), 50000L));
+
+    int threadCount = 2;
+    CountDownLatch latch = new CountDownLatch(threadCount);
+    AtomicInteger successCount = new AtomicInteger(0);
+    AtomicInteger failureCount = new AtomicInteger(0);
+
+    List<OrderItemCommand> commands = List.of(
+        OrderItemCommand.of(product.getId(), "재고1개상품", Quantity.of(1), OrderPrice.of(10000L))
+    );
+
+    try (ExecutorService executorService = Executors.newFixedThreadPool(threadCount)) {
+      executorService.submit(() -> {
+        try {
+          orderFacade.createOrder(user1.getId(), commands, ORDERED_AT_2025_10_30);
+          successCount.incrementAndGet();
+        } catch (CoreException e) {
+          failureCount.incrementAndGet();
+        } finally {
+          latch.countDown();
+        }
+      });
+
+      executorService.submit(() -> {
+        try {
+          orderFacade.createOrder(user2.getId(), commands, ORDERED_AT_2025_10_30);
+          successCount.incrementAndGet();
+        } catch (CoreException e) {
+          failureCount.incrementAndGet();
+        } finally {
+          latch.countDown();
+        }
+      });
+
+      latch.await();
+    }
+
+    assertThat(successCount.get()).isEqualTo(1);
+    assertThat(failureCount.get()).isEqualTo(1);
+
+    Product updatedProduct = productRepository.findById(product.getId()).orElseThrow();
+    assertThat(updatedProduct.getStockValue()).isZero();
+  }
+
+  @Test
+  @DisplayName("동시에 10개 주문 시 재고 5개 상품은 5개만 성공")
+  void concurrentOrders_multipleThreads() throws InterruptedException {
+    Brand brand = brandRepository.save(Brand.of("테스트브랜드"));
+    Product product = productRepository.save(
+        Product.of("재고5개상품", Money.of(5000L), "재고 5개 상품", Stock.of(5L), brand.getId())
+    );
+
+    int threadCount = 10;
+    CountDownLatch latch = new CountDownLatch(threadCount);
+    AtomicInteger successCount = new AtomicInteger(0);
+    AtomicInteger failureCount = new AtomicInteger(0);
+
+    List<OrderItemCommand> commands = List.of(
+        OrderItemCommand.of(product.getId(), "재고5개상품", Quantity.of(1), OrderPrice.of(5000L))
+    );
+
+    AtomicInteger counter = new AtomicInteger(0);
+    try (ExecutorService executorService = Executors.newFixedThreadPool(threadCount)) {
+      for (int i = 0; i < threadCount; i++) {
+        executorService.submit(() -> {
+          try {
+            int id = counter.getAndIncrement();
+            String loginId = "u" + id;
+            User user = userRepository.save(User.of(loginId, loginId + "@t.com", BIRTH_DATE_1990_01_01, Gender.MALE));
+            pointRepository.save(Point.of(user.getId(), 50000L));
+            orderFacade.createOrder(user.getId(), commands, ORDERED_AT_2025_10_30);
+            successCount.incrementAndGet();
+          } catch (CoreException e) {
+            failureCount.incrementAndGet();
+          } finally {
+            latch.countDown();
+          }
+        });
+      }
+
+      latch.await();
+    }
+
+    assertThat(successCount.get()).isEqualTo(5);
+    assertThat(failureCount.get()).isEqualTo(5);
+
+    Product updatedProduct = productRepository.findById(product.getId()).orElseThrow();
+    assertThat(updatedProduct.getStockValue()).isZero();
+  }
+
+  @Test
+  @DisplayName("동시 주문 시 재고가 음수가 되지 않음")
+  void concurrentOrders_stockNeverNegative() throws InterruptedException {
+    Brand brand = brandRepository.save(Brand.of("테스트브랜드"));
+    Product product = productRepository.save(
+        Product.of("재고3개상품", Money.of(3000L), "재고 3개 상품", Stock.of(3L), brand.getId())
+    );
+
+    int threadCount = 5;
+    CountDownLatch latch = new CountDownLatch(threadCount);
+
+    List<OrderItemCommand> commands = List.of(
+        OrderItemCommand.of(product.getId(), "재고3개상품", Quantity.of(1), OrderPrice.of(3000L))
+    );
+
+    AtomicInteger counter = new AtomicInteger(100);
+    try (ExecutorService executorService = Executors.newFixedThreadPool(threadCount)) {
+      for (int i = 0; i < threadCount; i++) {
+        executorService.submit(() -> {
+          try {
+            int id = counter.getAndIncrement();
+            String loginId = "u" + id;
+            User user = userRepository.save(User.of(loginId, loginId + "@t.com", BIRTH_DATE_1990_01_01, Gender.MALE));
+            pointRepository.save(Point.of(user.getId(), 50000L));
+            orderFacade.createOrder(user.getId(), commands, ORDERED_AT_2025_10_30);
+          } catch (CoreException ignored) {
+            // 재고 부족 시 예외 발생 예상
+          } finally {
+            latch.countDown();
+          }
+        });
+      }
+
+      latch.await();
+    }
+
+    Product finalProduct = productRepository.findById(product.getId()).orElseThrow();
+    assertThat(finalProduct.getStockValue()).isGreaterThanOrEqualTo(0L);
+  }
+}
\ No newline at end of file
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderRepositoryIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderRepositoryIntegrationTest.java
new file mode 100644
index 000000000..bb255975b
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderRepositoryIntegrationTest.java
@@ -0,0 +1,174 @@
+package com.loopers.domain.order;
+
+import com.loopers.domain.order.orderitem.OrderItem;
+import com.loopers.domain.order.orderitem.OrderPrice;
+import com.loopers.domain.quantity.Quantity;
+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.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@SpringBootTest
+@Transactional
+@DisplayName("OrderRepository 통합 테스트")
+class OrderRepositoryIntegrationTest {
+
+  private static final LocalDateTime ORDERED_AT_2025_10_30 = LocalDateTime.of(2025, 10, 30, 0, 0, 0);
+
+  @Autowired
+  private OrderRepository orderRepository;
+
+  @Autowired
+  private DatabaseCleanUp databaseCleanUp;
+
+  @AfterEach
+  void tearDown() {
+    databaseCleanUp.truncateAllTables();
+  }
+
+  @DisplayName("주문 목록 조회 (@Query + DTO)")
+  @Nested
+  class FindOrderList {
+
+    @DisplayName("사용자별 주문 목록을 페이징 조회하면 DTO로 반환된다")
+    @Test
+    void shouldReturnDtoPage() {
+      // given
+      Long userId = 1L;
+      LocalDateTime orderedAt = ORDERED_AT_2025_10_30;
+
+      Order order1 = Order.of(userId, OrderStatus.COMPLETED, 30000L, orderedAt);
+      order1.addItem(OrderItem.of(100L, "상품1", Quantity.of(1), OrderPrice.of(10000L)));
+      order1.addItem(OrderItem.of(200L, "상품2", Quantity.of(2), OrderPrice.of(10000L)));
+      orderRepository.save(order1);
+
+      Order order2 = Order.of(userId, OrderStatus.PENDING, 50000L, orderedAt.plusDays(1));
+      order2.addItem(OrderItem.of(300L, "상품3", Quantity.of(5), OrderPrice.of(10000L)));
+      orderRepository.save(order2);
+
+      PageRequest pageRequest = PageRequest.of(0, 10);
+
+      // when
+      Page<OrderListDto> result = orderRepository.findOrderList(userId, pageRequest);
+
+      // then
+      assertThat(result.getContent())
+          .hasSize(2)
+          .element(0).extracting("userId", "status", "itemCount")
+          .containsExactly(userId, OrderStatus.COMPLETED, 2);
+      assertThat(result.getContent())
+          .element(0).extracting("totalAmount")
+          .isEqualTo(30000L);
+      assertThat(result.getContent())
+          .element(1).extracting("userId", "status", "itemCount")
+          .containsExactly(userId, OrderStatus.PENDING, 1);
+      assertThat(result.getContent())
+          .element(1).extracting("totalAmount")
+          .isEqualTo(50000L);
+    }
+
+    @DisplayName("다른 사용자의 주문은 조회되지 않는다")
+    @Test
+    void shouldFilterByUserId() {
+      // given
+      Long userId1 = 1L;
+      Long userId2 = 2L;
+      LocalDateTime orderedAt = ORDERED_AT_2025_10_30;
+
+      Order order1 = Order.of(userId1, OrderStatus.COMPLETED, 10000L, orderedAt);
+      order1.addItem(OrderItem.of(100L, "상품1", Quantity.of(1), OrderPrice.of(10000L)));
+      orderRepository.save(order1);
+
+      Order order2 = Order.of(userId2, OrderStatus.PENDING, 20000L, orderedAt);
+      order2.addItem(OrderItem.of(200L, "상품2", Quantity.of(2), OrderPrice.of(10000L)));
+      orderRepository.save(order2);
+
+      PageRequest pageRequest = PageRequest.of(0, 10);
+
+      // when
+      Page<OrderListDto> result = orderRepository.findOrderList(userId1, pageRequest);
+
+      // then
+      assertThat(result.getContent())
+          .hasSize(1)
+          .element(0).extracting("userId")
+          .isEqualTo(userId1);
+    }
+
+    @DisplayName("15개 주문 중 두 번째 페이지(size=10)를 조회하면 5개가 반환된다")
+    @Test
+    void shouldPaginateCorrectly() {
+      // given
+      Long userId = 1L;
+      LocalDateTime orderedAt = ORDERED_AT_2025_10_30;
+
+      for (int i = 0; i < 15; i++) {
+        Order order = Order.of(userId, OrderStatus.COMPLETED, 10000L, orderedAt.plusDays(i));
+        order.addItem(OrderItem.of(100L + i, "상품" + i, Quantity.of(1), OrderPrice.of(10000L)));
+        orderRepository.save(order);
+      }
+
+      PageRequest pageRequest = PageRequest.of(1, 10);
+
+      // when
+      Page<OrderListDto> result = orderRepository.findOrderList(userId, pageRequest);
+
+      // then
+      assertThat(result.getTotalElements()).isEqualTo(15);
+      assertThat(result.getTotalPages()).isEqualTo(2);
+      assertThat(result.getContent()).hasSize(5);
+    }
+  }
+
+  @DisplayName("주문 상세 조회 (@EntityGraph)")
+  @Nested
+  class FindWithItemsById {
+
+    @DisplayName("OrderItem과 함께 조회된다 (N+1 방지)")
+    @Test
+    void shouldFetchWithItems() {
+      // given
+      Long userId = 1L;
+      LocalDateTime orderedAt = ORDERED_AT_2025_10_30;
+
+      Order order = Order.of(userId, OrderStatus.COMPLETED, 30000L, orderedAt);
+      order.addItem(OrderItem.of(100L, "상품1", Quantity.of(1), OrderPrice.of(10000L)));
+      order.addItem(OrderItem.of(200L, "상품2", Quantity.of(2), OrderPrice.of(10000L)));
+      Order savedOrder = orderRepository.save(order);
+
+      // when
+      Optional<Order> result = orderRepository.findWithItemsById(savedOrder.getId());
+
+      // then
+      assertThat(result).isPresent();
+      assertThat(result.get().getItems())
+          .hasSize(2)
+          .extracting("productName")
+          .containsExactly("상품1", "상품2");
+    }
+
+    @DisplayName("존재하지 않는 ID로 조회하면 empty를 반환한다")
+    @Test
+    void shouldReturnEmpty_whenNotFound() {
+      // given
+      Long nonExistentId = 999L;
+
+      // when
+      Optional<Order> result = orderRepository.findWithItemsById(nonExistentId);
+
+      // then
+      assertThat(result).isEmpty();
+    }
+  }
+}
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..750344a7a
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java
@@ -0,0 +1,112 @@
+package com.loopers.domain.order;
+
+import com.loopers.domain.order.orderitem.OrderItem;
+import com.loopers.domain.order.orderitem.OrderPrice;
+import com.loopers.domain.quantity.Quantity;
+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.time.LocalDateTime;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@SpringBootTest
+@DisplayName("OrderService 통합 테스트")
+class OrderServiceIntegrationTest {
+
+  @Autowired
+  private OrderService orderService;
+
+  @Autowired
+  private OrderRepository orderRepository;
+
+  @Autowired
+  private DatabaseCleanUp databaseCleanUp;
+
+  private static final LocalDateTime ORDERED_AT_2025_10_30 = LocalDateTime.of(2025, 10, 30, 0, 0, 0);
+
+  @AfterEach
+  void tearDown() {
+    databaseCleanUp.truncateAllTables();
+  }
+
+  @Nested
+  @DisplayName("주문 생성 및 조회")
+  class CreateAndGetOrder {
+
+    @Test
+    @DisplayName("주문을 생성한 뒤 ID로 조회하면 생성된 주문 정보가 정확히 반환된다")
+    void createOrder_thenGet_dataMatches() {
+      // given
+      Long userId = 1L;
+      List<OrderItem> orderItems = List.of(
+          OrderItem.of(100L, "상품1", Quantity.of(2), OrderPrice.of(10000L)),
+          OrderItem.of(200L, "상품2", Quantity.of(1), OrderPrice.of(30000L))
+      );
+
+      // when
+      Order savedOrder = orderService.create(userId, orderItems, ORDERED_AT_2025_10_30);
+      Order foundOrder = orderService.getById(savedOrder.getId());
+
+      // then
+      assertThat(foundOrder)
+          .extracting("userId", "status", "orderedAt")
+          .containsExactly(userId, OrderStatus.PAYMENT_PENDING, ORDERED_AT_2025_10_30);
+      assertThat(foundOrder.getTotalAmountValue()).isEqualTo(50000L);
+    }
+
+    @Test
+    @DisplayName("주문 생성 시 OrderItem이 cascade로 함께 저장되어 조회된다")
+    void createOrder_itemsCascadePersisted() {
+      // given
+      Long userId = 1L;
+      List<OrderItem> orderItems = List.of(
+          OrderItem.of(100L, "상품1", Quantity.of(1), OrderPrice.of(10000L)),
+          OrderItem.of(200L, "상품2", Quantity.of(2), OrderPrice.of(20000L))
+      );
+
+      // when
+      Order savedOrder = orderService.create(userId, orderItems, ORDERED_AT_2025_10_30);
+      Order foundOrder = orderService.getWithItemsById(savedOrder.getId());
+
+      // then
+      assertThat(foundOrder.getItems())
+          .hasSize(2)
+          .extracting("productId", "productName")
+          .containsExactlyInAnyOrder(
+              org.assertj.core.groups.Tuple.tuple(100L, "상품1"),
+              org.assertj.core.groups.Tuple.tuple(200L, "상품2")
+          );
+    }
+  }
+
+  @Nested
+  @DisplayName("주문 상태 변경")
+  class UpdateOrderStatus {
+
+    @Test
+    @DisplayName("주문 상태를 PAYMENT_PENDING에서 COMPLETED로 변경하면 변경된 상태가 조회된다")
+    void updateOrderStatus_thenGet_statusChanged() {
+      // given
+      Long userId = 1L;
+      List<OrderItem> orderItems = List.of(
+          OrderItem.of(100L, "상품1", Quantity.of(1), OrderPrice.of(10000L))
+      );
+      Order savedOrder = orderService.create(userId, orderItems, ORDERED_AT_2025_10_30);
+      assertThat(savedOrder.getStatus()).isEqualTo(OrderStatus.PAYMENT_PENDING);
+
+      // when
+      orderService.updateOrderStatus(savedOrder.getId(), OrderStatus.COMPLETED);
+      Order foundOrder = orderService.getById(savedOrder.getId());
+
+      // then
+      assertThat(foundOrder.getStatus()).isEqualTo(OrderStatus.COMPLETED);
+    }
+  }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java
new file mode 100644
index 000000000..56dd804ff
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java
@@ -0,0 +1,175 @@
+package com.loopers.domain.order;
+
+import com.loopers.domain.order.orderitem.OrderItem;
+import com.loopers.domain.order.orderitem.OrderPrice;
+import com.loopers.domain.quantity.Quantity;
+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 org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.BDDMockito.then;
+import static org.mockito.Mockito.times;
+
+@ExtendWith(MockitoExtension.class)
+@DisplayName("OrderService 단위 테스트")
+class OrderServiceTest {
+
+  @Mock
+  private OrderRepository orderRepository;
+
+  @InjectMocks
+  private OrderService sut;
+
+  @Captor
+  private ArgumentCaptor<Order> orderCaptor;
+
+  private static final LocalDateTime ORDERED_AT_2025_10_30 = LocalDateTime.of(2025, 10, 30, 0, 0, 0);
+
+  @Nested
+  @DisplayName("주문 생성")
+  class CreateOrder {
+
+    @Test
+    @DisplayName("사용자 ID와 주문 항목 목록으로 주문을 생성하면 총액이 계산되고 OrderItem이 추가된다")
+    void createOrder_success() {
+      // given
+      Long userId = 1L;
+      List<OrderItem> orderItems = List.of(
+          OrderItem.of(100L, "상품1", Quantity.of(2), OrderPrice.of(10000L)),
+          OrderItem.of(200L, "상품2", Quantity.of(1), OrderPrice.of(30000L))
+      );
+      LocalDateTime orderedAt = ORDERED_AT_2025_10_30;
+
+      Order savedOrder = Order.of(userId, OrderStatus.PAYMENT_PENDING, 50000L, orderedAt);
+      given(orderRepository.save(any(Order.class))).willReturn(savedOrder);
+
+      // when
+      Order result = sut.create(userId, orderItems, orderedAt);
+
+      // then
+      then(orderRepository).should(times(1)).save(orderCaptor.capture());
+      Order capturedOrder = orderCaptor.getValue();
+
+      assertThat(result)
+          .extracting("userId", "status", "orderedAt")
+          .containsExactly(userId, OrderStatus.PAYMENT_PENDING, orderedAt);
+      assertThat(result.getTotalAmountValue()).isEqualTo(50000L);
+
+      assertThat(capturedOrder)
+          .extracting("userId", "status")
+          .containsExactly(userId, OrderStatus.PAYMENT_PENDING);
+    }
+
+    @Test
+    @DisplayName("주문 생성 시 OrderItem이 Order에 추가되고 양방향 관계가 설정된다")
+    void createOrder_bidirectionalRelationship() {
+      // given
+      Long userId = 1L;
+      List<OrderItem> orderItems = List.of(
+          OrderItem.of(100L, "상품1", Quantity.of(1), OrderPrice.of(10000L))
+      );
+
+      Order savedOrder = Order.of(userId, OrderStatus.PAYMENT_PENDING, 10000L, ORDERED_AT_2025_10_30);
+      OrderItem item = OrderItem.of(100L, "상품1", Quantity.of(1), OrderPrice.of(10000L));
+      savedOrder.addItem(item);
+
+      given(orderRepository.save(any(Order.class))).willReturn(savedOrder);
+
+      // when
+      Order result = sut.create(userId, orderItems, ORDERED_AT_2025_10_30);
+
+      // then
+      assertThat(result.getItems()).hasSize(1);
+    }
+  }
+
+  @Nested
+  @DisplayName("주문 조회")
+  class GetOrder {
+
+    @Test
+    @DisplayName("주문 ID로 조회 시 해당 주문이 존재하면 주문 정보가 반환된다")
+    void getById_success() {
+      // given
+      Long orderId = 1L;
+      Order order = Order.of(1L, OrderStatus.COMPLETED, 50000L, ORDERED_AT_2025_10_30);
+      given(orderRepository.findById(orderId)).willReturn(Optional.of(order));
+
+      // when
+      Order result = sut.getById(orderId);
+
+      // then
+      assertThat(result)
+          .extracting("userId", "status")
+          .containsExactly(1L, OrderStatus.COMPLETED);
+      then(orderRepository).should(times(1)).findById(orderId);
+    }
+
+    @Test
+    @DisplayName("주문 ID로 조회 시 해당 주문이 존재하지 않으면 NOT_FOUND 예외가 발생한다")
+    void getById_notFound() {
+      // given
+      Long orderId = 999L;
+      given(orderRepository.findById(orderId)).willReturn(Optional.empty());
+
+      // when & then
+      assertThatThrownBy(() -> sut.getById(orderId))
+          .isInstanceOf(CoreException.class)
+          .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND)
+          .hasMessageContaining("주문을 찾을 수 없습니다.");
+    }
+
+    @Test
+    @DisplayName("주문 ID로 조회 시 @EntityGraph를 사용하여 OrderItem과 함께 조회된다")
+    void getWithItemsById_success() {
+      // given
+      Long orderId = 1L;
+      Order order = Order.of(1L, OrderStatus.COMPLETED, 50000L, ORDERED_AT_2025_10_30);
+      given(orderRepository.findWithItemsById(orderId)).willReturn(Optional.of(order));
+
+      // when
+      Order result = sut.getWithItemsById(orderId);
+
+      // then
+      assertThat(result).isNotNull();
+      then(orderRepository).should(times(1)).findWithItemsById(orderId);
+    }
+  }
+
+  @Nested
+  @DisplayName("주문 상태 업데이트")
+  class UpdateOrderStatus {
+
+    @Test
+    @DisplayName("주문 ID와 새로운 상태로 상태를 업데이트하면 주문의 상태가 변경된다")
+    void updateOrderStatus_success() {
+      // given
+      Long orderId = 1L;
+      Order order = Order.of(1L, OrderStatus.PAYMENT_PENDING, 50000L, ORDERED_AT_2025_10_30);
+      given(orderRepository.findById(orderId)).willReturn(Optional.of(order));
+
+      // when
+      Order result = sut.updateOrderStatus(orderId, OrderStatus.COMPLETED);
+
+      // then
+      assertThat(result.getStatus()).isEqualTo(OrderStatus.COMPLETED);
+      then(orderRepository).should(times(1)).findById(orderId);
+    }
+  }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java
new file mode 100644
index 000000000..782c7fa3c
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java
@@ -0,0 +1,225 @@
+package com.loopers.domain.order;
+
+import com.loopers.domain.order.orderitem.OrderItem;
+import com.loopers.domain.order.orderitem.OrderPrice;
+import com.loopers.domain.quantity.Quantity;
+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 java.time.LocalDateTime;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+@DisplayName("Order 도메인 테스트")
+class OrderTest {
+
+  private static final LocalDateTime ORDERED_AT_2025_10_30 = LocalDateTime.of(2025, 10, 30, 0, 0, 0);
+
+  @DisplayName("Order를 생성할 때")
+  @Nested
+  class Create {
+
+    @DisplayName("사용자 ID, 주문 상태, 총액, 주문 시각을 모두 제공하면 Order가 생성된다")
+    @Test
+    void shouldCreate_whenValid() {
+      Long userId = 1L;
+      OrderStatus status = OrderStatus.COMPLETED;
+      Long totalAmount = 50000L;
+      LocalDateTime orderedAt = ORDERED_AT_2025_10_30;
+
+      Order order = Order.of(userId, status, totalAmount, orderedAt);
+
+      assertThat(order).extracting("userId", "status", "orderedAt")
+          .containsExactly(userId, status, orderedAt);
+      assertThat(order.getTotalAmountValue()).isEqualTo(totalAmount);
+    }
+  }
+
+  @DisplayName("userId 검증")
+  @Nested
+  class ValidateUserId {
+
+    @DisplayName("null이면 예외가 발생한다")
+    @Test
+    void shouldThrowException_whenNull() {
+      OrderStatus status = OrderStatus.COMPLETED;
+      Long totalAmount = 50000L;
+      LocalDateTime orderedAt = ORDERED_AT_2025_10_30;
+
+      assertThatThrownBy(() -> Order.of(null, status, totalAmount, orderedAt))
+          .isInstanceOf(CoreException.class)
+          .hasMessage("사용자는 비어있을 수 없습니다.")
+          .extracting("errorType").isEqualTo(ErrorType.INVALID_ORDER_USER_EMPTY);
+    }
+  }
+
+  @DisplayName("status 검증")
+  @Nested
+  class ValidateStatus {
+
+    @DisplayName("null이면 예외가 발생한다")
+    @Test
+    void shouldThrowException_whenNull() {
+      Long userId = 1L;
+      Long totalAmount = 50000L;
+      LocalDateTime orderedAt = ORDERED_AT_2025_10_30;
+
+      assertThatThrownBy(() -> Order.of(userId, null, totalAmount, orderedAt))
+          .isInstanceOf(CoreException.class)
+          .hasMessage("주문 상태는 비어있을 수 없습니다.")
+          .extracting("errorType").isEqualTo(ErrorType.INVALID_ORDER_STATUS_EMPTY);
+    }
+  }
+
+  @DisplayName("totalAmount 검증")
+  @Nested
+  class ValidateTotalAmount {
+
+    @DisplayName("null이면 예외가 발생한다")
+    @Test
+    void shouldThrowException_whenNull() {
+      Long userId = 1L;
+      OrderStatus status = OrderStatus.COMPLETED;
+      LocalDateTime orderedAt = ORDERED_AT_2025_10_30;
+
+      assertThatThrownBy(() -> Order.of(userId, status, null, orderedAt))
+          .isInstanceOf(CoreException.class)
+          .hasMessage("금액은 비어있을 수 없습니다.")
+          .extracting("errorType").isEqualTo(ErrorType.INVALID_MONEY_VALUE);
+    }
+
+    @DisplayName("음수이면 예외가 발생한다")
+    @Test
+    void shouldThrowException_whenNegative() {
+      Long userId = 1L;
+      OrderStatus status = OrderStatus.COMPLETED;
+      LocalDateTime orderedAt = ORDERED_AT_2025_10_30;
+
+      assertThatThrownBy(() -> Order.of(userId, status, -1000L, orderedAt))
+          .isInstanceOf(CoreException.class)
+          .hasMessage("금액은 음수일 수 없습니다.")
+          .extracting("errorType").isEqualTo(ErrorType.NEGATIVE_MONEY_VALUE);
+    }
+
+    @DisplayName("0이면 정상 생성된다")
+    @Test
+    void shouldCreate_whenZero() {
+      Long userId = 1L;
+      OrderStatus status = OrderStatus.COMPLETED;
+      Long totalAmount = 0L;
+      LocalDateTime orderedAt = ORDERED_AT_2025_10_30;
+
+      Order order = Order.of(userId, status, totalAmount, orderedAt);
+
+      assertThat(order.getTotalAmountValue()).isEqualTo(0L);
+    }
+  }
+
+  @DisplayName("orderedAt 검증")
+  @Nested
+  class ValidateOrderedAt {
+
+    @DisplayName("null이면 예외가 발생한다")
+    @Test
+    void shouldThrowException_whenNull() {
+      Long userId = 1L;
+      OrderStatus status = OrderStatus.COMPLETED;
+      Long totalAmount = 50000L;
+
+      assertThatThrownBy(() -> Order.of(userId, status, totalAmount, null))
+          .isInstanceOf(CoreException.class)
+          .hasMessage("주문 시각은 비어있을 수 없습니다.")
+          .extracting("errorType").isEqualTo(ErrorType.INVALID_ORDER_ORDERED_AT_EMPTY);
+    }
+  }
+
+  @DisplayName("비즈니스 로직")
+  @Nested
+  class BusinessLogic {
+
+    @DisplayName("주문 상태를 COMPLETED에서 PAYMENT_PENDING으로 변경할 수 있다")
+    @Test
+    void shouldUpdateStatus() {
+      Long userId = 1L;
+      OrderStatus initialStatus = OrderStatus.COMPLETED;
+      Long totalAmount = 50000L;
+      LocalDateTime orderedAt = ORDERED_AT_2025_10_30;
+      Order order = Order.of(userId, initialStatus, totalAmount, orderedAt);
+
+      order.updateStatus(OrderStatus.PAYMENT_PENDING);
+
+      assertThat(order).extracting("status").isEqualTo(OrderStatus.PAYMENT_PENDING);
+    }
+
+    @DisplayName("상태 변경 시 null이면 예외가 발생한다")
+    @Test
+    void shouldThrowException_whenUpdateStatusWithNull() {
+      Long userId = 1L;
+      OrderStatus initialStatus = OrderStatus.COMPLETED;
+      Long totalAmount = 50000L;
+      LocalDateTime orderedAt = ORDERED_AT_2025_10_30;
+      Order order = Order.of(userId, initialStatus, totalAmount, orderedAt);
+
+      assertThatThrownBy(() -> order.updateStatus(null))
+          .isInstanceOf(CoreException.class)
+          .hasMessage("주문 상태는 비어있을 수 없습니다.")
+          .extracting("errorType").isEqualTo(ErrorType.INVALID_ORDER_STATUS_EMPTY);
+    }
+  }
+
+  @DisplayName("Order-OrderItem 협업 테스트")
+  @Nested
+  class OrderWithOrderItem {
+
+    @DisplayName("OrderItem을 추가하면 items에 포함된다")
+    @Test
+    void shouldAddItem() {
+      // given
+      Order order = Order.of(1L, OrderStatus.PENDING, 0L, ORDERED_AT_2025_10_30);
+      OrderItem item = OrderItem.of(100L, "테스트 상품", Quantity.of(2), OrderPrice.of(10000L));
+
+      // when
+      order.addItem(item);
+
+      // then
+      assertThat(order.getItems()).hasSize(1);
+      assertThat(order.getItems()).contains(item);
+    }
+
+    @DisplayName("여러 OrderItem을 추가할 수 있다")
+    @Test
+    void shouldAddMultipleItems() {
+      // given
+      Order order = Order.of(1L, OrderStatus.PENDING, 0L, ORDERED_AT_2025_10_30);
+      OrderItem item1 = OrderItem.of(100L, "상품1", Quantity.of(1), OrderPrice.of(10000L));
+      OrderItem item2 = OrderItem.of(200L, "상품2", Quantity.of(2), OrderPrice.of(20000L));
+
+      // when
+      order.addItem(item1);
+      order.addItem(item2);
+
+      // then
+      assertThat(order.getItems()).hasSize(2);
+      assertThat(order.getItems()).containsExactly(item1, item2);
+    }
+
+    @DisplayName("OrderItem 추가 시 양방향 관계가 설정된다")
+    @Test
+    void shouldSetBidirectionalRelationship() {
+      // given
+      Order order = Order.of(1L, OrderStatus.PENDING, 0L, ORDERED_AT_2025_10_30);
+      OrderItem item = OrderItem.of(100L, "테스트 상품", Quantity.of(2), OrderPrice.of(10000L));
+
+      // when
+      order.addItem(item);
+
+      // then
+      // OrderItem을 블랙박스로 취급하여 최소한의 검증만 수행
+      assertThat(item.getOrder()).isEqualTo(order);
+    }
+  }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/orderitem/OrderItemTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/orderitem/OrderItemTest.java
new file mode 100644
index 000000000..357eff5ce
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/orderitem/OrderItemTest.java
@@ -0,0 +1,174 @@
+package com.loopers.domain.orderitem;
+
+import com.loopers.domain.order.orderitem.OrderItem;
+import com.loopers.domain.order.orderitem.OrderPrice;
+import com.loopers.domain.quantity.Quantity;
+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.assertj.core.api.Assertions.assertThatThrownBy;
+
+@DisplayName("OrderItem 도메인 테스트")
+class OrderItemTest {
+
+  @DisplayName("OrderItem을 생성할 때")
+  @Nested
+  class Create {
+
+    @DisplayName("올바른 정보로 생성하면 성공한다")
+    @Test
+    void shouldCreate_whenValid() {
+      // given
+      Long productId = 100L;
+      String productName = "테스트 상품";
+      Quantity quantity = Quantity.of(3);
+      OrderPrice orderPrice = OrderPrice.of(10000L);
+
+      // when
+      OrderItem orderItem = OrderItem.of(productId, productName, quantity, orderPrice);
+
+      // then
+      assertThat(orderItem).extracting("productId", "productName")
+          .containsExactly(productId, productName);
+      assertThat(orderItem).extracting("quantity", "orderPrice")
+          .containsExactly(quantity, orderPrice);
+    }
+  }
+
+  @DisplayName("productId 검증")
+  @Nested
+  class ValidateProductId {
+
+    @DisplayName("null이면 예외가 발생한다")
+    @Test
+    void shouldThrowException_whenNull() {
+      // given
+      String productName = "테스트 상품";
+      Quantity quantity = Quantity.of(1);
+      OrderPrice orderPrice = OrderPrice.of(10000L);
+
+      // when & then
+      assertThatThrownBy(() -> OrderItem.of(null, productName, quantity, orderPrice))
+          .isInstanceOf(CoreException.class)
+          .hasMessage("상품은 비어있을 수 없습니다.")
+          .extracting("errorType").isEqualTo(ErrorType.INVALID_ORDER_ITEM_PRODUCT_EMPTY);
+    }
+  }
+
+  @DisplayName("productName 검증")
+  @Nested
+  class ValidateProductName {
+
+    @DisplayName("null이면 예외가 발생한다")
+    @Test
+    void shouldThrowException_whenNull() {
+      // given
+      Long productId = 100L;
+      Quantity quantity = Quantity.of(1);
+      OrderPrice orderPrice = OrderPrice.of(10000L);
+
+      // when & then
+      assertThatThrownBy(() -> OrderItem.of(productId, null, quantity, orderPrice))
+          .isInstanceOf(CoreException.class)
+          .hasMessage("상품명은 비어있을 수 없습니다.")
+          .extracting("errorType").isEqualTo(ErrorType.INVALID_ORDER_ITEM_PRODUCT_NAME_EMPTY);
+    }
+
+    @DisplayName("빈 문자열이면 예외가 발생한다")
+    @Test
+    void shouldThrowException_whenBlank() {
+      // given
+      Long productId = 100L;
+      Quantity quantity = Quantity.of(1);
+      OrderPrice orderPrice = OrderPrice.of(10000L);
+
+      // when & then
+      assertThatThrownBy(() -> OrderItem.of(productId, "  ", quantity, orderPrice))
+          .isInstanceOf(CoreException.class)
+          .hasMessage("상품명은 비어있을 수 없습니다.")
+          .extracting("errorType").isEqualTo(ErrorType.INVALID_ORDER_ITEM_PRODUCT_NAME_EMPTY);
+    }
+
+    @DisplayName("100자를 초과하면 예외가 발생한다")
+    @Test
+    void shouldThrowException_whenTooLong() {
+      // given
+      Long productId = 100L;
+      String productName = "a".repeat(101);
+      Quantity quantity = Quantity.of(1);
+      OrderPrice orderPrice = OrderPrice.of(10000L);
+
+      // when & then
+      assertThatThrownBy(() -> OrderItem.of(productId, productName, quantity, orderPrice))
+          .isInstanceOf(CoreException.class)
+          .hasMessage("상품명은 100자 이내여야 합니다.")
+          .extracting("errorType").isEqualTo(ErrorType.INVALID_ORDER_ITEM_PRODUCT_NAME_LENGTH);
+    }
+  }
+
+  @DisplayName("quantity 검증")
+  @Nested
+  class ValidateQuantity {
+
+    @DisplayName("null이면 예외가 발생한다")
+    @Test
+    void shouldThrowException_whenNull() {
+      // given
+      Long productId = 100L;
+      String productName = "테스트 상품";
+      OrderPrice orderPrice = OrderPrice.of(10000L);
+
+      // when & then
+      assertThatThrownBy(() -> OrderItem.of(productId, productName, null, orderPrice))
+          .isInstanceOf(CoreException.class)
+          .hasMessage("수량은 비어있을 수 없습니다.")
+          .extracting("errorType").isEqualTo(ErrorType.INVALID_ORDER_ITEM_QUANTITY_EMPTY);
+    }
+  }
+
+  @DisplayName("orderPrice 검증")
+  @Nested
+  class ValidateOrderPrice {
+
+    @DisplayName("null이면 예외가 발생한다")
+    @Test
+    void shouldThrowException_whenNull() {
+      // given
+      Long productId = 100L;
+      String productName = "테스트 상품";
+      Quantity quantity = Quantity.of(1);
+
+      // when & then
+      assertThatThrownBy(() -> OrderItem.of(productId, productName, quantity, null))
+          .isInstanceOf(CoreException.class)
+          .hasMessage("주문 가격은 비어있을 수 없습니다.")
+          .extracting("errorType").isEqualTo(ErrorType.INVALID_ORDER_PRICE_VALUE);
+    }
+  }
+
+  @DisplayName("비즈니스 로직")
+  @Nested
+  class BusinessLogic {
+
+    @DisplayName("주문 가격 값을 조회한다")
+    @Test
+    void shouldGetOrderPriceValue() {
+      // given
+      Long productId = 100L;
+      String productName = "테스트 상품";
+      Quantity quantity = Quantity.of(1);
+      OrderPrice orderPrice = OrderPrice.of(20000L);
+      OrderItem orderItem = OrderItem.of(productId, productName, quantity, orderPrice);
+
+      // when
+      Long priceValue = orderItem.getOrderPriceValue();
+
+      // then
+      assertThat(priceValue).isEqualTo(20000L);
+    }
+  }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/orderitem/OrderPriceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/orderitem/OrderPriceTest.java
new file mode 100644
index 000000000..bbb9c9cf0
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/orderitem/OrderPriceTest.java
@@ -0,0 +1,98 @@
+package com.loopers.domain.orderitem;
+
+import com.loopers.domain.order.orderitem.OrderPrice;
+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.assertj.core.api.Assertions.assertThatThrownBy;
+
+@DisplayName("OrderPrice Value Object 테스트")
+class OrderPriceTest {
+
+  @DisplayName("OrderPrice를 생성할 때")
+  @Nested
+  class Create {
+
+    @DisplayName("올바른 값으로 생성하면 성공한다")
+    @Test
+    void shouldCreate_whenValid() {
+      // given
+      Long value = 10000L;
+
+      // when
+      OrderPrice orderPrice = OrderPrice.of(value);
+
+      // then
+      assertThat(orderPrice.getValue()).isEqualTo(value);
+    }
+
+    @DisplayName("0원으로 생성하면 성공한다")
+  @Test
+    void shouldCreate_whenZero() {
+      // given
+      Long value = 0L;
+
+      // when
+      OrderPrice orderPrice = OrderPrice.of(value);
+
+      // then
+      assertThat(orderPrice.getValue()).isZero();
+    }
+  }
+
+  @DisplayName("value 검증")
+  @Nested
+  class ValidateValue {
+
+    @DisplayName("null이면 예외가 발생한다")
+    @Test
+    void shouldThrowException_whenNull() {
+      // when & then
+      assertThatThrownBy(() -> OrderPrice.of(null))
+          .isInstanceOf(CoreException.class)
+          .hasMessage("주문 가격은 비어있을 수 없습니다.")
+          .extracting("errorType").isEqualTo(ErrorType.INVALID_ORDER_PRICE_VALUE);
+    }
+
+    @DisplayName("음수이면 예외가 발생한다")
+    @Test
+    void shouldThrowException_whenNegative() {
+      // when & then
+      assertThatThrownBy(() -> OrderPrice.of(-1000L))
+          .isInstanceOf(CoreException.class)
+          .hasMessage("주문 가격은 음수일 수 없습니다.")
+          .extracting("errorType").isEqualTo(ErrorType.NEGATIVE_ORDER_PRICE_VALUE);
+    }
+  }
+
+  @DisplayName("동등성 비교")
+  @Nested
+  class Equality {
+
+    @DisplayName("같은 값이면 동등하다")
+    @Test
+    void shouldBeEqual_whenSameValue() {
+      // given
+      OrderPrice orderPrice1 = OrderPrice.of(10000L);
+      OrderPrice orderPrice2 = OrderPrice.of(10000L);
+
+      // when & then
+      assertThat(orderPrice1).isEqualTo(orderPrice2);
+    }
+
+    @DisplayName("다른 값이면 동등하지 않다")
+    @Test
+    void shouldNotBeEqual_whenDifferentValue() {
+      // given
+      OrderPrice orderPrice1 = OrderPrice.of(10000L);
+      OrderPrice orderPrice2 = OrderPrice.of(20000L);
+
+      // when & then
+      assertThat(orderPrice1).isNotEqualTo(orderPrice2);
+    }
+  }
+}

From a319d37808446b80547ac6a64bb7fecc0e0196c7 Mon Sep 17 00:00:00 2001
From: green <green@mobidoc.us>
Date: Thu, 13 Nov 2025 08:24:54 +0900
Subject: [PATCH 06/21] =?UTF-8?q?docs:=20=EC=9D=B4=EC=BB=A4=EB=A8=B8?=
 =?UTF-8?q?=EC=8A=A4=20=EC=84=A4=EA=B3=84=20=EB=AC=B8=EC=84=9C=20=ED=86=B5?=
 =?UTF-8?q?=ED=95=A9=20=EB=B0=8F=20=EC=A0=95=EB=A6=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

설계 문서 구조화:
- docs/week2/ → docs/design/ 디렉토리로 통합
- 요구사항, 시퀀스 다이어그램, 클래스 다이어그램, ERD 통합 관리

ADR 문서 추가:
- ADR-001: Order-OrderItem 양방향 관계 설계 결정
- ADR-002: ProductLike Hard Delete 전략

내용 업데이트:
- Order 도메인 요구사항 및 설계 추가
- ProductLike 기능 명세 및 구조 추가
- Order Aggregate 구조 및 ERD 반영
---
 ...er-orderitem-bidirectional-relationship.md |  89 ++++++
 ...DR-002-productlike-hard-delete-strategy.md |  75 +++++
 docs/{week2 => design}/01-requirements.md     |   5 +-
 .../{week2 => design}/02-sequence-diagrams.md |   0
 docs/design/03-class-diagram.md               | 300 ++++++++++++++++++
 docs/{week2 => design}/04-erd.md              |   2 +-
 docs/{ => week1}/loopers-1-week.md            |   0
 docs/week2/03-class-diagram.md                | 170 ----------
 8 files changed, 469 insertions(+), 172 deletions(-)
 create mode 100644 docs/adr/ADR-001-order-orderitem-bidirectional-relationship.md
 create mode 100644 docs/adr/ADR-002-productlike-hard-delete-strategy.md
 rename docs/{week2 => design}/01-requirements.md (97%)
 rename docs/{week2 => design}/02-sequence-diagrams.md (100%)
 create mode 100644 docs/design/03-class-diagram.md
 rename docs/{week2 => design}/04-erd.md (98%)
 rename docs/{ => week1}/loopers-1-week.md (100%)
 delete mode 100644 docs/week2/03-class-diagram.md

diff --git a/docs/adr/ADR-001-order-orderitem-bidirectional-relationship.md b/docs/adr/ADR-001-order-orderitem-bidirectional-relationship.md
new file mode 100644
index 000000000..eb5b8af90
--- /dev/null
+++ b/docs/adr/ADR-001-order-orderitem-bidirectional-relationship.md
@@ -0,0 +1,89 @@
+# ADR-001: Order-OrderItem은 양방향 관계로 구현
+
+## 결정사항
+**Order-OrderItem 관계는 양방향 객체 참조(cascade + orphanRemoval)로 구현한다.**
+
+```java
+@Entity
+public class Order {
+  @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
+  private List<OrderItem> items = new ArrayList<>();
+
+  public void addItem(OrderItem item) {
+    this.items.add(item);
+    item.assignOrder(this);
+  }
+}
+
+@Entity
+public class OrderItem {
+  @ManyToOne(fetch = FetchType.LAZY)
+  @JoinColumn(name = "ref_order_id", nullable = false)
+  private Order order;
+}
+```
+
+## 상황
+Order-OrderItem은 다른 엔티티 관계와 다른 특성을 가짐:
+- OrderItem은 Order 없이 존재할 수 없음
+- 주문 생성 시 items와 totalAmount가 정합성을 유지해야 함
+- Order가 OrderItem의 생명주기를 완전히 제어해야 함
+
+## 선택 근거
+
+### 1. DDD Aggregate Root 패턴
+Order가 OrderItem의 생명주기를 완전히 제어:
+- Order 생성 → OrderItem 생성
+- Order 삭제 → OrderItem 자동 삭제
+- OrderItem 독립 생성 불가
+
+### 2. 트랜잭션 일관성 보장
+```java
+Order order = Order.create(userId, totalAmount);
+order.addItem(OrderItem.of(productId, quantity, price));
+orderRepository.save(order); // items도 함께 저장
+```
+
+### 3. 비즈니스 로직 캡슐화
+Order 도메인이 items 관리 로직 포함:
+- items 추가/제거 검증
+- totalAmount 자동 재계산
+- 양방향 관계 설정
+
+## 페이징 처리 전략
+
+양방향 관계에서 `JOIN FETCH` + 페이징은 메모리 페이징 문제가 발생한다.
+
+### ✅ 해결 방안
+
+**목록 조회**: DTO 프로젝션 (DB 레벨 페이징)
+```java
+@Query("SELECT new OrderListDto(o.id, o.orderedAt, o.totalAmount, o.status, SIZE(o.items)) " +
+       "FROM Order o WHERE o.userId = :userId")
+Page<OrderListDto> findOrderList(Long userId, Pageable pageable);
+```
+
+**상세 조회**: @EntityGraph (단건은 페이징 불필요)
+```java
+@EntityGraph(attributePaths = "items")
+Optional<Order> findWithItemsById(Long orderId);
+```
+
+**원칙**: 목록은 DTO, 상세는 Entity
+
+## 트레이드오프
+
+### ✅ 장점
+- 트랜잭션 일관성 보장 (cascade)
+- 비즈니스 로직 캡슐화
+- DDD Aggregate Root 패턴 구현
+- 명확한 생명주기 관리
+
+### ⚠️ 단점
+- 목록/상세 쿼리 패턴 분리 필요
+- DTO 프로젝션 필요
+- 양방향 관계 설정 필수
+
+## 참고
+- 요구사항: `docs/design/01-requirements.md`
+- ERD: `docs/design/04-erd.md`
\ No newline at end of file
diff --git a/docs/adr/ADR-002-productlike-hard-delete-strategy.md b/docs/adr/ADR-002-productlike-hard-delete-strategy.md
new file mode 100644
index 000000000..b3ae4adc8
--- /dev/null
+++ b/docs/adr/ADR-002-productlike-hard-delete-strategy.md
@@ -0,0 +1,75 @@
+# ADR-002: ProductLike는 Hard Delete 전략 사용
+
+## 결정사항
+**ProductLike 삭제 시 Hard Delete(물리 삭제)를 사용한다.**
+
+```java
+public void unlikeProduct(Long userId, Long productId) {
+  // Hard Delete 실행
+  productLikeRepository.deleteByUserIdAndProductId(userId, productId);
+
+  // Product likeCount 감소
+  product.decreaseLikeCount();
+}
+```
+
+## 상황
+ProductLike 삭제 전략 선택 시 고려사항:
+- 좋아요는 선호도 표시이며 법적 보관 의무가 없음
+- 대규모 트래픽 환경 가정 (요구사항 2.1.3)
+- 사용자가 언제든지 재등록 가능한 데이터
+- 조회 성능 최적화 필요
+
+## 선택 근거
+
+### 1. 법적 보관 의무 없음
+- 좋아요는 금전 거래 아님
+- 세법, 전자상거래법 적용 대상 아님
+- Order/Payment와 달리 이력 보관 불필요
+
+### 2. 성능 최적화
+**Soft Delete 시 문제점**:
+- 모든 조회 쿼리에 `deleted_at IS NULL` 필터 필수
+- 삭제된 데이터가 인덱스에 계속 남음
+- 테이블 크기 증가 → 조회 성능 저하
+
+**Hard Delete 장점**:
+- 테이블 크기 최소화 → 인덱스 효율 증가
+- 조회 쿼리가 단순함 (필터링 불필요)
+
+### 3. 비즈니스 의미 일치
+- 좋아요 취소 = 데이터 삭제 (직관적)
+- 재등록 가능하므로 복구 불필요
+
+### 4. 멱등성 보장
+요구사항: "이미 취소한 좋아요 재취소 → 200 OK"
+- DELETE 실행 (0건 영향) → 에러 없음
+- Product.decreaseLikeCount()에 음수 방어 로직 포함
+
+## 대안: 이력이 필요한 경우
+
+이벤트 기반 아키텍처로 이력 데이터를 분리:
+```
+ProductLike Delete → Event 발행 → 데이터 웨어하우스 저장
+```
+
+**장점**:
+- 메인 DB 성능 유지 (Hard Delete)
+- 이력은 분석 시스템에서 관리
+- 관심사 분리
+
+## 트레이드오프
+
+### ✅ 장점
+- 조회 성능 향상 (테이블 크기 최소화)
+- 코드 단순성 (필터링 로직 불필요)
+- 비즈니스 의미 일치
+- 멱등성 보장
+
+### ⚠️ 단점
+- 메인 DB에서 즉시 복구 불가
+- 이력 추적 제한 (이벤트로 해결 가능)
+
+## 참고
+- 요구사항: `docs/design/01-requirements.md` (2.1.3 좋아요 취소 멱등성)
+- ERD: `docs/design/04-erd.md`
diff --git a/docs/week2/01-requirements.md b/docs/design/01-requirements.md
similarity index 97%
rename from docs/week2/01-requirements.md
rename to docs/design/01-requirements.md
index 552feda1a..2338d7cb2 100644
--- a/docs/week2/01-requirements.md
+++ b/docs/design/01-requirements.md
@@ -21,7 +21,7 @@
 - **제외**: 회원가입, 포인트 충전 (1주차 구현 완료)
 
 ### 0.2 인증/인가
-- 모든 API는 별도의 인증없이 X-USER-ID 헤더로 사용자 식별
+- 모든 API는 별도의 인증없이 X-USER-LOGIN-ID 헤더로 사용자 식별
 - 토큰이 없으면 비회원 사용자
 - 회원가입 API는 1주차 완료 (설계 범위 제외)
 
@@ -290,6 +290,7 @@
         7. 외부 시스템 전송 (Mock 가능)
     - **응답 데이터**:
         - 주문 ID, 주문 일시
+        - 주문 상태 (status): PENDING, COMPLETED, PAYMENT_PENDING
         - 주문 상품 목록: 상품 ID, 상품명, 수량, 가격
         - 총 결제 금액
 
@@ -299,12 +300,14 @@
         - `size`: 페이지당 주문 수 (기본값: 20)
     - **응답 데이터**:
         - 주문 ID, 주문 일시, 총 금액
+        - 주문 상태 (status): PENDING, COMPLETED, PAYMENT_PENDING
         - 주문 상품 수
         - 페이지네이션 정보: 총 개수, 현재 페이지, 전체 페이지 수, 페이지당 개수
 
 - **주문 상세 조회**: `GET /api/v1/orders/{orderId}`
     - **응답 데이터**:
         - 주문 ID, 주문 일시
+        - 주문 상태 (status): PENDING, COMPLETED, PAYMENT_PENDING
         - 주문 상품 목록: 상품 ID, 상품명, 수량, 가격
         - 총 결제 금액
 
diff --git a/docs/week2/02-sequence-diagrams.md b/docs/design/02-sequence-diagrams.md
similarity index 100%
rename from docs/week2/02-sequence-diagrams.md
rename to docs/design/02-sequence-diagrams.md
diff --git a/docs/design/03-class-diagram.md b/docs/design/03-class-diagram.md
new file mode 100644
index 000000000..bda12f016
--- /dev/null
+++ b/docs/design/03-class-diagram.md
@@ -0,0 +1,300 @@
+# 03-class-diagram.md - 클래스 다이어그램
+
+## 📑 목차
+
+- [1. 도메인 객체 설계](#1-도메인-객체-설계)
+- [2. 도메인 설명](#2-도메인-설명)
+  - [2.1 User (사용자)](#21-user-사용자)
+  - [2.2 Point (포인트)](#22-point-포인트)
+  - [2.3 Product (상품)](#23-product-상품)
+  - [2.4 Brand (브랜드)](#24-brand-브랜드)
+  - [2.5 ProductLike (좋아요)](#25-productlike-좋아요)
+  - [2.6 Order (주문)](#26-order-주문)
+  - [2.7 OrderItem (주문 항목)](#27-orderitem-주문-항목)
+  - [2.8 OrderPrice (주문 가격)](#28-orderprice-주문-가격)
+- [3. 관계 설명](#3-관계-설명)
+- [4. Order 상태 다이어그램](#4-order-상태-다이어그램)
+- [5. 참고사항](#5-참고사항)
+
+---
+
+## 1. 도메인 객체 설계
+
+```mermaid
+classDiagram
+    class User {
+        +Long id
+        +String loginId
+        +String email
+        +String gender
+        +LocalDate birthDate
+    }
+
+    class Point {
+        +Long id
+        +Long userId
+        +Long amount
+        +deduct(amount: Long) void
+        +charge(amount: Long) void
+        +hasEnough(amount: Long) boolean
+    }
+
+    class Product {
+        +Long id
+        +String name
+        +Long price
+        +String description
+        +Integer stock
+        +Long likeCount
+        +Long brandId
+        +LocalDateTime createdAt
+        +decreaseStock(quantity: Integer) void
+        +isAvailable() boolean
+        +increaseLikeCount() void
+        +decreaseLikeCount() void
+    }
+
+    class Brand {
+        +Long id
+        +String name
+        +String description
+    }
+
+    class ProductLike {
+        +Long id
+        +Long userId
+        +Long productId
+        +LocalDateTime likedAt
+        +isLikedBy(userId: Long) boolean
+    }
+
+    class Order {
+        +Long id
+        +Long userId
+        +OrderStatus status
+        +Long totalAmount
+        +LocalDateTime orderedAt
+        +updateStatus(status: OrderStatus) void
+    }
+
+    class OrderItem {
+        +Long id
+        +Long orderId
+        +Long productId
+        +String productName
+        +Integer quantity
+        +OrderPrice orderPrice
+        +calculateSubtotal() Long
+        +getOrderPriceValue() Long
+    }
+
+    class OrderPrice {
+        <<Value Object>>
+        +Long value
+        +of(value: Long) OrderPrice
+        +getValue() Long
+    }
+
+    Point "1" --> "1" User : references (userId)
+    Product "N" --> "1" Brand : references (brandId)
+    ProductLike "N" --> "1" User : references (userId)
+    ProductLike "N" --> "1" Product : references (productId)
+    Order "N" --> "1" User : references (userId)
+    OrderItem "N" --> "1" Order : references (orderId)
+    OrderItem "N" --> "1" Product : references (productId)
+```
+
+## 2. 도메인 설명
+
+### 2.1 User (사용자)
+- 회원 정보를 담는 엔티티
+- 좋아요와 주문의 주인
+
+### 2.2 Point (포인트)
+- 사용자의 포인트 잔액
+- User 1명당 Point 1개 (1:1 관계)
+- **비즈니스 로직**:
+  - `deduct()`: 포인트 차감 (부족 시 예외)
+  - `charge()`: 포인트 충전
+  - `hasEnough()`: 잔액 충분 여부 확인
+
+### 2.3 Product (상품)
+- 판매 상품 정보
+- 재고, 가격, 브랜드 정보를 가짐
+- `likeCount`: 좋아요 수 (비정규화된 집계 값, 성능 최적화용)
+- **비즈니스 로직**:
+  - `decreaseStock()`: 재고 차감 (부족 시 예외)
+  - `isAvailable()`: 판매 가능 여부 (재고 > 0)
+  - `increaseLikeCount()`, `decreaseLikeCount()`: 좋아요 수 관리
+
+### 2.4 Brand (브랜드)
+- 브랜드 정보
+- 여러 상품을 포함함
+
+### 2.5 ProductLike (좋아요)
+- 사용자가 상품에 누른 좋아요
+- 한 상품당 한 번만 좋아요 가능 (중복 방지)
+- `likedAt`: 좋아요 누른 시각 (최근 순 정렬용)
+- **비즈니스 로직**:
+  - `isLikedBy()`: 특정 사용자가 좋아요했는지 확인
+
+### 2.6 Order (주문)
+- 사용자의 주문 정보
+- `status`: 주문 상태 (OrderStatus Enum: PENDING, COMPLETED, PAYMENT_PENDING)
+- `totalAmount`: 주문 총액 (OrderItem 금액 합계)
+- `orderedAt`: 주문한 시각
+- **비즈니스 로직**:
+  - `updateStatus()`: 주문 상태 변경 (결제 실패 시 PAYMENT_PENDING으로 변경)
+- **주문 항목 조회**: OrderItemRepository를 통해 `findByOrderId()`로 조회
+- **상태 다이어그램**: [4. Order 상태 다이어그램](#4-order-상태-다이어그램) 참고
+
+### 2.7 OrderItem (주문 항목)
+- 주문에 포함된 개별 상품
+- **스냅샷 저장**: 주문 당시의 상품명, 가격, 수량을 저장하여 Product 변동에 영향받지 않음
+  - `productName`: 주문 시점 상품명 (Product 삭제 시에도 주문 내역 표시 가능)
+  - `orderPrice`: 주문 시점 가격 (OrderPrice Value Object, Product 가격 변동과 무관)
+  - `quantity`: 주문 수량
+- **Entity로 설계**: 독립 테이블로 관리되며 향후 확장 가능
+- **비즈니스 로직**:
+  - `calculateSubtotal()`: 항목 소계 (orderPrice × quantity)
+  - `getOrderPriceValue()`: 주문 가격 값 조회
+
+### 2.8 OrderPrice (주문 가격)
+- **Value Object**: 주문 시점의 가격 스냅샷
+- Product의 Money와 별도 타입으로 스냅샷 의도 명확화
+- **불변성**: 생성 후 변경 불가
+- **검증**: null 방지, 음수 방지
+
+## 3. 관계 설명
+
+| 관계 | 유형 | 설명 |
+|------|------|------|
+| **Point → User** | 1:1 (ID 참조) | 사용자 1명당 포인트 1개, `userId` 필드로 참조 |
+| **Product → Brand** | N:1 (ID 참조) | 여러 상품이 하나의 브랜드 소속, `brandId` 필드로 참조 |
+| **ProductLike → User** | N:1 (ID 참조) | 사용자가 여러 상품에 좋아요 가능, `userId` 필드로 참조 |
+| **ProductLike → Product** | N:1 (ID 참조) | 상품 하나에 여러 좋아요 가능, `productId` 필드로 참조 |
+| **Order → User** | N:1 (ID 참조) | 사용자가 여러 주문 가능, `userId` 필드로 참조 |
+| **OrderItem → Order** | N:1 (ID 참조) | 주문 항목이 주문 참조, `orderId` 필드로 참조 |
+| **OrderItem → Product** | N:1 (ID 참조) | 주문 항목이 상품 참조, `productId` 필드로 참조 |
+
+### 참조 방식
+- 참조 컬럼명: `ref_` 접두사 사용 (예: `ref_user_id`, `ref_order_id`)
+
+## 4. Order 상태 다이어그램
+
+### 4.1 상태 정의
+
+| 상태 | 설명 | 비고 |
+|------|------|------|
+| **PENDING** | 주문 생성 완료 | 외부 결제 API 호출 전 초기 상태 |
+| **COMPLETED** | 결제 완료 | 외부 결제 API 성공 시 최종 상태 |
+| **PAYMENT_PENDING** | 결제 재시도 실패 | 외부 결제 실패 후 재시도 실패 시 |
+
+**참고**: `FAILED` 상태는 존재하지 않음
+- 이유: 결제 실패 시 트랜잭션 롤백으로 주문 데이터가 DB에 저장되지 않음
+- 재시도 실패한 주문만 `PAYMENT_PENDING` 상태로 DB에 저장됨
+
+### 4.2 상태 전이 다이어그램
+
+```mermaid
+stateDiagram-v2
+    [*] --> PENDING: 주문 생성
+    PENDING --> COMPLETED: 외부 결제 성공
+    PENDING --> PAYMENT_PENDING: 외부 결제 재시도 실패
+    COMPLETED --> [*]
+    PAYMENT_PENDING --> [*]
+
+    note right of PENDING
+        초기 상태
+        외부 결제 API 호출 전
+    end note
+
+    note right of COMPLETED
+        최종 성공 상태
+        결제 완료
+    end note
+
+    note right of PAYMENT_PENDING
+        재시도 실패 상태
+        수동 처리 필요
+    end note
+```
+
+### 4.3 상태 전이 조건
+
+| 전이 | 조건 | 동작 |
+|------|------|------|
+| `[*] → PENDING` | 주문 생성 요청 | 1. 재고 확인 및 차감<br>2. 포인트 차감<br>3. Order, OrderItem 생성 (status: PENDING) |
+| `PENDING → COMPLETED` | 외부 결제 API 성공 | 1. Order.status를 COMPLETED로 업데이트<br>2. 트랜잭션 커밋 |
+| `PENDING → PAYMENT_PENDING` | 외부 결제 재시도 실패 | 1. Order.status를 PAYMENT_PENDING으로 업데이트<br>2. 트랜잭션 커밋 (수동 처리 위해 주문 기록 보존) |
+| `PENDING → [트랜잭션 롤백]` | 외부 결제 실패 (재시도 전) | 1. 전체 트랜잭션 롤백<br>2. DB에 주문 데이터 저장 안 됨 |
+
+### 4.4 주문 처리 흐름도
+
+```mermaid
+flowchart TD
+    Start([주문 요청]) --> ValidateStock{재고 확인}
+    ValidateStock -->|부족| Error1[재고 부족 예외]
+    ValidateStock -->|충분| DeductStock[재고 차감]
+
+    DeductStock --> ValidatePoint{포인트 확인}
+    ValidatePoint -->|부족| Error2[포인트 부족 예외]
+    ValidatePoint -->|충분| DeductPoint[포인트 차감]
+
+    DeductPoint --> CreateOrder[Order 생성<br/>status: PENDING]
+    CreateOrder --> CreateOrderItem[OrderItem 생성]
+
+    CreateOrderItem --> CallPayment[외부 결제 API 호출]
+    CallPayment --> PaymentSuccess{결제 성공?}
+
+    PaymentSuccess -->|성공| UpdateCompleted[status → COMPLETED]
+    UpdateCompleted --> Commit1[트랜잭션 커밋]
+    Commit1 --> End([주문 완료])
+
+    PaymentSuccess -->|실패| Retry{재시도 성공?}
+    Retry -->|성공| UpdateCompleted
+    Retry -->|실패| UpdatePending[status → PAYMENT_PENDING]
+    UpdatePending --> Commit2[트랜잭션 커밋<br/>수동 처리용 기록 보존]
+    Commit2 --> End2([재시도 실패])
+
+    Retry -->|재시도 전 실패| Rollback[트랜잭션 롤백]
+    Rollback --> End3([주문 실패<br/>DB 저장 안 됨])
+
+    Error1 --> End3
+    Error2 --> End3
+```
+
+### 4.5 상태별 비즈니스 규칙
+
+#### PENDING
+- **허용 동작**:
+  - 외부 결제 API 호출
+  - 상태 변경 → COMPLETED (결제 성공)
+  - 상태 변경 → PAYMENT_PENDING (재시도 실패)
+- **제약 사항**:
+  - 사용자에게 주문 상세 조회 시 "결제 처리 중" 표시
+  - 이 상태의 주문은 재고/포인트 이미 차감된 상태
+
+#### COMPLETED
+- **허용 동작**:
+  - 주문 조회 (주문 목록, 상세)
+- **제약 사항**:
+  - 더 이상 상태 변경 불가 (최종 상태)
+  - 취소/환불 기능은 현재 요구사항에 없음
+
+#### PAYMENT_PENDING
+- **허용 동작**:
+  - 관리자 수동 처리
+  - 주문 조회
+- **제약 사항**:
+  - 자동 재시도 불가 (이미 재시도 실패한 상태)
+  - 수동 처리 필요 (환불 또는 결제 재처리)
+
+## 5. 참고사항
+
+- User, Point는 1주차에서 이미 구현됨 (관계 표현 위해 포함)
+- **공통 필드** (BaseEntity 상속):
+  - `id`: 엔티티 식별자 (Long)
+  - `createdAt`: 생성 일시
+  - `updatedAt`: 수정 일시
+  - `deletedAt`: 삭제 일시 (Soft Delete용)
diff --git a/docs/week2/04-erd.md b/docs/design/04-erd.md
similarity index 98%
rename from docs/week2/04-erd.md
rename to docs/design/04-erd.md
index eb97c52db..f41060ce3 100644
--- a/docs/week2/04-erd.md
+++ b/docs/design/04-erd.md
@@ -32,7 +32,7 @@ erDiagram
 
     USERS {
         bigint id PK "사용자 식별자"
-        varchar(50) user_id UK "로그인 ID"
+        varchar(50) login_id UK "로그인 ID"
         varchar(100) email UK "이메일 주소"
         varchar(10) gender "성별"
         date birth_date "생년월일"
diff --git a/docs/loopers-1-week.md b/docs/week1/loopers-1-week.md
similarity index 100%
rename from docs/loopers-1-week.md
rename to docs/week1/loopers-1-week.md
diff --git a/docs/week2/03-class-diagram.md b/docs/week2/03-class-diagram.md
deleted file mode 100644
index aad7721f0..000000000
--- a/docs/week2/03-class-diagram.md
+++ /dev/null
@@ -1,170 +0,0 @@
-# 03-class-diagram.md - 클래스 다이어그램
-
-## 📑 목차
-
-- [1. 도메인 객체 설계](#1-도메인-객체-설계)
-- [2. 도메인 설명](#2-도메인-설명)
-  - [2.1 User (사용자)](#21-user-사용자)
-  - [2.2 Point (포인트)](#22-point-포인트)
-  - [2.3 Product (상품)](#23-product-상품)
-  - [2.4 Brand (브랜드)](#24-brand-브랜드)
-  - [2.5 ProductLike (좋아요)](#25-productlike-좋아요)
-  - [2.6 Order (주문)](#26-order-주문)
-  - [2.7 OrderItem (주문 항목)](#27-orderitem-주문-항목)
-- [3. 관계 설명](#3-관계-설명)
-- [4. 참고사항](#4-참고사항)
-
----
-
-## 1. 도메인 객체 설계
-
-```mermaid
-classDiagram
-    class User {
-        +Long id
-        +String userId
-        +String email
-        +String gender
-        +LocalDate birthDate
-    }
-
-    class Point {
-        +Long id
-        +Long amount
-        +deduct(amount: Long) void
-        +charge(amount: Long) void
-        +hasEnough(amount: Long) boolean
-    }
-
-    class Product {
-        +Long id
-        +LocalDateTime createdAt
-        +String name
-        +Long price
-        +String description
-        +Integer stock
-        +Long likeCount
-        +Long brandId
-        +decreaseStock(quantity: Integer) void
-        +isAvailable() boolean
-        +increaseLikeCount() void
-        +decreaseLikeCount() void
-    }
-
-    class Brand {
-        +Long id
-        +String name
-        +String description
-    }
-
-    class ProductLike {
-        +Long id
-        +Long userId
-        +Long productId
-        +LocalDateTime likedAt
-        +isLikedBy(userId: Long) boolean
-    }
-
-    class Order {
-        +Long id
-        +Long userId
-        +String status
-        +Long totalAmount
-        +LocalDateTime orderedAt
-        +List~OrderItem~ items
-        +addItem(product: Product, quantity: Integer) void
-        +calculateTotal() Long
-        +getItemCount() Integer
-        +updateStatus(status: String) void
-    }
-
-    class OrderItem {
-        +Long id
-        +Long orderId
-        +Long productId
-        +Integer quantity
-        +Long price
-        +calculateSubtotal() Long
-    }
-
-
-    Point "1" --> "1" User : references (userId)
-    Product "N" --> "1" Brand : references (brandId)
-    ProductLike "N" --> "1" User : references (userId)
-    ProductLike "N" --> "1" Product : references (productId)
-    Order "N" --> "1" User : references (userId)
-    Order "1" *-- "N" OrderItem : contains
-    OrderItem "N" --> "1" Product : references (productId)
-```
-
-## 2. 도메인 설명
-
-### 2.1 User (사용자)
-- 회원 정보를 담는 엔티티
-- 좋아요와 주문의 주인
-- ※ 1주차 구현 완료
-
-### 2.2 Point (포인트)
-- 사용자의 포인트 잔액
-- User 1명당 Point 1개 (1:1 관계)
-- **비즈니스 로직**:
-  - `deduct()`: 포인트 차감 (부족 시 예외)
-  - `charge()`: 포인트 충전
-  - `hasEnough()`: 잔액 충분 여부 확인
-- ※ 1주차 구현 완료
-
-### 2.3 Product (상품)
-- 판매 상품 정보
-- 재고, 가격, 브랜드 정보를 가짐
-- `likeCount`: 좋아요 수 (비정규화된 집계 값, 성능 최적화용)
-- **비즈니스 로직**:
-  - `decreaseStock()`: 재고 차감 (부족 시 예외)
-  - `isAvailable()`: 판매 가능 여부 (재고 > 0)
-  - `increaseLikeCount()`, `decreaseLikeCount()`: 좋아요 수 관리
-
-### 2.4 Brand (브랜드)
-- 브랜드 정보
-- 여러 상품을 포함함
-
-### 2.5 ProductLike (좋아요)
-- 사용자가 상품에 누른 좋아요
-- 한 상품당 한 번만 좋아요 가능 (중복 방지)
-- `likedAt`: 좋아요 누른 시각 (최근 순 정렬용)
-- **비즈니스 로직**:
-  - `isLikedBy()`: 특정 사용자가 좋아요했는지 확인
-
-### 2.6 Order (주문)
-- 사용자의 주문 정보
-- 여러 주문 항목(OrderItem)을 포함
-- `status`: 주문 상태 (COMPLETED, PAYMENT_PENDING 등)
-- `orderedAt`: 주문한 시각
-- **비즈니스 로직**:
-  - `addItem()`: 주문 항목 추가
-  - `calculateTotal()`: 총 주문 금액 계산
-  - `getItemCount()`: 주문 항목 수 조회
-  - `updateStatus()`: 주문 상태 변경 (결제 실패 시 PAYMENT_PENDING으로 변경)
-
-### 2.7 OrderItem (주문 항목)
-- 주문에 포함된 개별 상품
-- 주문 당시의 가격과 수량을 저장 (가격 변동 영향 없음)
-- **Entity로 설계**: 독립 테이블로 관리되며 향후 확장 가능
-- **비즈니스 로직**:
-  - `calculateSubtotal()`: 항목 소계 (가격 × 수량)
-
-## 3. 관계 설명
-
-- **Point ↔ User**: 1:1 관계
-- **Product → Brand**: 여러 상품이 하나의 브랜드 소속
-- **ProductLike → User/Product**: 사용자가 상품에 좋아요
-- **Order → User**: 사용자가 여러 주문 가능
-- **Order → OrderItem**: 주문에 여러 상품 포함
-- **OrderItem → Product**: 주문 항목이 상품 참조
-
-## 4. 참고사항
-
-- User, Point는 1주차에서 이미 구현됨 (관계 표현 위해 포함)
-- **공통 필드** (BaseEntity 상속):
-  - `id`: 엔티티 식별자 (Long)
-  - `createdAt`: 생성 일시
-  - `updatedAt`: 수정 일시
-  - `deletedAt`: 삭제 일시 (Soft Delete용)

From 9ad3b97fc2a6f73fdd76cc49f85b1cf1362162f2 Mon Sep 17 00:00:00 2001
From: green <green@mobidoc.us>
Date: Thu, 13 Nov 2025 08:30:34 +0900
Subject: [PATCH 07/21] =?UTF-8?q?refactor:=20User=20=EB=8F=84=EB=A9=94?=
 =?UTF-8?q?=EC=9D=B8=20=EA=B0=9C=EC=84=A0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

도메인 계층:
- ID 참조 방식으로 변경 (느슨한 결합)
- 도메인 검증 로직 강화
- 위임 메서드 추가 (캡슐화)
- Repository 3계층 구조 적용

API 계층:
- ApiSpec 개선 (문서화, 검증)
- Dto 개선 (응답 구조 최적화)
- E2E 테스트 보완

테스트:
- @Nested 구조 적용
- 경계값 테스트 추가
---
 .../loopers/application/user/UserFacade.java  |  8 +--
 .../loopers/application/user/UserResult.java  |  4 +-
 .../java/com/loopers/domain/user/User.java    | 50 ++++++++++---------
 .../loopers/domain/user/UserRepository.java   |  2 +-
 .../com/loopers/domain/user/UserService.java  | 10 ++--
 .../user/UserJpaRepository.java               |  2 +-
 .../user/UserRepositoryImpl.java              |  4 +-
 .../interfaces/api/user/UserApiSpec.java      |  6 +--
 .../interfaces/api/user/UserController.java   |  8 +--
 .../loopers/interfaces/api/user/UserDto.java  | 20 ++++----
 .../user/UserServiceIntegrationTest.java      | 36 ++++++-------
 .../com/loopers/domain/user/UserTest.java     | 43 ++++++++--------
 .../interfaces/api/user/UserApiE2ETest.java   | 24 ++++-----
 13 files changed, 110 insertions(+), 107 deletions(-)

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
index 7d0cc9ed0..d1e9bf74b 100644
--- 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
@@ -15,13 +15,13 @@
 public class UserFacade {
     private final UserService userService;
 
-    public UserResult register(String userId, String email, LocalDate birth, Gender gender) {
-        User user = userService.registerUser(userId, email, birth, gender);
+    public UserResult register(String loginId, String email, LocalDate birth, Gender gender) {
+        User user = userService.registerUser(loginId, email, birth, gender);
         return UserResult.from(user);
     }
 
-    public UserResult getUser(String userId) {
-        User user = userService.findById(userId);
+    public UserResult getUser(String loginId) {
+        User user = userService.findById(loginId);
         if (user == null) {
             throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.");
         }
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserResult.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserResult.java
index 566ca0272..1e9b68e48 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserResult.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserResult.java
@@ -5,10 +5,10 @@
 
 import java.time.LocalDate;
 
-public record UserResult(String userId, String email, LocalDate birth, Gender gender) {
+public record UserResult(String loginId, String email, LocalDate birth, Gender gender) {
     public static UserResult from(User user) {
         return new UserResult(
-            user.getUserId(),
+            user.getLoginId(),
             user.getEmail(),
             user.getBirth(),
             user.getGender()
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java
index 035d205dc..e7758661a 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java
@@ -1,6 +1,8 @@
 package com.loopers.domain.user;
 
 import com.loopers.domain.BaseEntity;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
 import jakarta.persistence.Column;
 import jakarta.persistence.Entity;
 import jakarta.persistence.Enumerated;
@@ -17,14 +19,14 @@
 @Getter
 public class User extends BaseEntity {
 
-  private static final int MAX_USER_ID_LENGTH = 10;
+  private static final int MAX_LOGIN_ID_LENGTH = 10;
   // 영문 대소문자 + 숫자만 허용 (특수문자, 공백, 한글 불가)
-  private static final Pattern USER_ID_PATTERN = Pattern.compile("^[a-zA-Z0-9]+$");
+  private static final Pattern LOGIN_ID_PATTERN = Pattern.compile("^[a-zA-Z0-9]+$");
   // 이메일 형식: xxx@yyy.zzz (공백 불가)
   private static final Pattern EMAIL_PATTERN = Pattern.compile("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$");
 
-  @Column(unique = true, nullable = false, length = MAX_USER_ID_LENGTH)
-  private String userId;
+  @Column(name = "login_id", unique = true, nullable = false, length = MAX_LOGIN_ID_LENGTH)
+  private String loginId;
 
   @Column(nullable = false)
   private String email;
@@ -39,62 +41,62 @@ public class User extends BaseEntity {
   protected User() {
   }
 
-  private User(String userId, String email, LocalDate birth, Gender gender, Clock clock) {
-    String normalizedUserId = userId != null ? userId.trim() : null;
+  private User(String loginId, String email, LocalDate birth, Gender gender, Clock clock) {
+    String normalizedLoginId = loginId != null ? loginId.trim() : null;
     String normalizedEmail = email != null ? email.toLowerCase().trim() : null;
 
-    validateUserId(normalizedUserId);
+    validateLoginId(normalizedLoginId);
     validateEmail(normalizedEmail);
     validateBirth(birth, LocalDate.now(clock));
     validateGender(gender);
 
-    this.userId = normalizedUserId;
+    this.loginId = normalizedLoginId;
     this.email = normalizedEmail;
     this.birth = birth;
     this.gender = gender;
   }
 
-  public static User of(String userId, String email, LocalDate birth, Gender gender) {
-    return of(userId, email, birth, gender, Clock.systemDefaultZone());
+  public static User of(String loginId, String email, LocalDate birth, Gender gender) {
+    return of(loginId, email, birth, gender, Clock.systemDefaultZone());
   }
 
-  public static User of(String userId, String email, LocalDate birth, Gender gender, Clock clock) {
-    return new User(userId, email, birth, gender, clock);
+  public static User of(String loginId, String email, LocalDate birth, Gender gender, Clock clock) {
+    return new User(loginId, email, birth, gender, clock);
   }
 
-  private void validateUserId(String userId) {
-    if (userId == null || userId.isBlank()) {
-      throw new IllegalArgumentException("사용자 ID는 비어있을 수 없습니다.");
+  private void validateLoginId(String loginId) {
+    if (loginId == null || loginId.isBlank()) {
+      throw new CoreException(ErrorType.INVALID_LOGIN_ID_EMPTY);
     }
-    if (userId.length() > MAX_USER_ID_LENGTH) {
-      throw new IllegalArgumentException("사용자 ID는 10자 이내여야 합니다.");
+    if (loginId.length() > MAX_LOGIN_ID_LENGTH) {
+      throw new CoreException(ErrorType.INVALID_LOGIN_ID_LENGTH);
     }
-    if (!USER_ID_PATTERN.matcher(userId).matches()) {
-      throw new IllegalArgumentException("사용자 ID는 영문/숫자만 허용됩니다.");
+    if (!LOGIN_ID_PATTERN.matcher(loginId).matches()) {
+      throw new CoreException(ErrorType.INVALID_LOGIN_ID_FORMAT);
     }
   }
 
   private void validateEmail(String email) {
     if (email == null || email.isBlank()) {
-      throw new IllegalArgumentException("이메일은 비어있을 수 없습니다.");
+      throw new CoreException(ErrorType.INVALID_EMAIL_EMPTY);
     }
     if (!EMAIL_PATTERN.matcher(email).matches()) {
-      throw new IllegalArgumentException("이메일 형식이 올바르지 않습니다.");
+      throw new CoreException(ErrorType.INVALID_EMAIL_FORMAT);
     }
   }
 
   private void validateBirth(LocalDate birth, LocalDate currentDate) {
     if (birth == null) {
-      throw new IllegalArgumentException("생년월일은 비어있을 수 없습니다.");
+      throw new CoreException(ErrorType.INVALID_BIRTH_EMPTY);
     }
     if (birth.isAfter(currentDate)) {
-      throw new IllegalArgumentException("생년월일은 미래일 수 없습니다.");
+      throw new CoreException(ErrorType.INVALID_BIRTH_FUTURE);
     }
   }
 
   private void validateGender(Gender gender) {
     if (gender == null) {
-      throw new IllegalArgumentException("성별은 비어있을 수 없습니다.");
+      throw new CoreException(ErrorType.INVALID_GENDER_EMPTY);
     }
   }
 }
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
index b067aae6e..68d69bc3e 100644
--- 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
@@ -6,5 +6,5 @@ public interface UserRepository {
 
   User save(User user);
 
-  Optional<User> findByUserId(String userId);
+  Optional<User> findByLoginId(String loginId);
 }
\ 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
index b870b694b..ff52f14dc 100644
--- 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
@@ -17,15 +17,15 @@ public class UserService {
   private final UserRepository userRepository;
 
   @Transactional
-  public User registerUser(String userId, String email, LocalDate birth, Gender gender) {
+  public User registerUser(String loginId, String email, LocalDate birth, Gender gender) {
     try {
-      return userRepository.save(User.of(userId, email, birth, gender));
+      return userRepository.save(User.of(loginId, email, birth, gender));
     } catch (DataIntegrityViolationException e) {
-      throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 사용자 ID입니다.");
+      throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 로그인 ID입니다.");
     }
   }
 
-  public User findById(String userId) {
-    return userRepository.findByUserId(userId).orElse(null);
+  public User findById(String loginId) {
+    return userRepository.findByLoginId(loginId).orElse(null);
   }
 }
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
index 1a78b9a69..d89ee854b 100644
--- 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
@@ -6,5 +6,5 @@
 import java.util.Optional;
 
 public interface UserJpaRepository extends JpaRepository<User, Long> {
-    Optional<User> findByUserId(String userId);
+    Optional<User> findByLoginId(String loginId);
 }
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
index 591fdea66..3c0544a4a 100644
--- 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
@@ -18,7 +18,7 @@ public User save(User user) {
     }
 
     @Override
-    public Optional<User> findByUserId(String userId) {
-        return userJpaRepository.findByUserId(userId);
+    public Optional<User> findByLoginId(String loginId) {
+        return userJpaRepository.findByLoginId(loginId);
     }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserApiSpec.java
index 2ac5d9eb5..34ab3524b 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserApiSpec.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserApiSpec.java
@@ -19,10 +19,10 @@ ApiResponse<UserDto.UserResponse> register(
 
     @Operation(
         summary = "사용자 조회",
-        description = "사용자 ID로 사용자 정보를 조회합니다."
+        description = "로그인 ID로 사용자 정보를 조회합니다."
     )
     ApiResponse<UserDto.UserResponse> getUser(
-        @Schema(description = "사용자 ID")
-        String userId
+        @Schema(description = "로그인 ID")
+        String loginId
     );
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java
index b1277821b..b5615d782 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java
@@ -25,7 +25,7 @@ public ApiResponse<UserDto.UserResponse> register(
         @Valid @RequestBody UserDto.RegisterRequest request
     ) {
         UserResult userResult = userFacade.register(
-            request.userId(),
+            request.loginId(),
             request.email(),
             request.birth(),
             request.gender()
@@ -35,10 +35,10 @@ public ApiResponse<UserDto.UserResponse> register(
         return ApiResponse.success(response);
     }
 
-    @GetMapping("/{userId}")
+    @GetMapping("/{loginId}")
     @Override
-    public ApiResponse<UserDto.UserResponse> getUser(@PathVariable String userId) {
-        UserResult userResult = userFacade.getUser(userId);
+    public ApiResponse<UserDto.UserResponse> getUser(@PathVariable String loginId) {
+        UserResult userResult = userFacade.getUser(loginId);
         UserDto.UserResponse response = UserDto.UserResponse.from(userResult);
         return ApiResponse.success(response);
     }
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserDto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserDto.java
index b3d828a7e..be1c2e3e6 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserDto.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserDto.java
@@ -11,14 +11,14 @@
 import java.time.LocalDate;
 
 public class UserDto {
-    private static final String VALID_USER_ID_PATTERN = "^[A-Za-z0-9]{1,10}$";
+    private static final String VALID_LOGIN_ID_PATTERN = "^[A-Za-z0-9]{1,10}$";
     private static final String VALID_EMAIL_PATTERN = "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$";
     private static final String YYYY_MM_DD = "yyyy-MM-dd";
 
     public record RegisterRequest(
-        @NotBlank(message = "사용자 ID는 비어있을 수 없습니다.")
-        @Pattern(regexp = VALID_USER_ID_PATTERN, message = "사용자 ID 형식이 올바르지 않습니다.")
-        String userId,
+        @NotBlank(message = "로그인 ID는 비어있을 수 없습니다.")
+        @Pattern(regexp = VALID_LOGIN_ID_PATTERN, message = "로그인 ID 형식이 올바르지 않습니다.")
+        String loginId,
 
         @NotBlank(message = "이메일은 비어있을 수 없습니다.")
         @Pattern(regexp = VALID_EMAIL_PATTERN, message = "이메일 형식이 올바르지 않습니다.")
@@ -34,18 +34,18 @@ public record RegisterRequest(
     ) {}
 
     public record UserResponse(
-        String userId,
+        String loginId,
         String email,
         @JsonFormat(pattern = YYYY_MM_DD)
         LocalDate birth,
         Gender gender
     ) {
-        public static UserResponse from(UserResult info) {
+        public static UserResponse from(UserResult result) {
             return new UserResponse(
-                info.userId(),
-                info.email(),
-                info.birth(),
-                info.gender()
+                result.loginId(),
+                result.email(),
+                result.birth(),
+                result.gender()
             );
         }
     }
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
index 6ff3b3568..34130901e 100644
--- 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
@@ -53,17 +53,17 @@ void saveUser_whenRegisterUser() {
       UserRepository spyRepository = spy(userRepository);
       UserService spyService = new UserService(spyRepository);
 
-      String userId = "testuser";
+      String loginId = "testuser";
       String email = "test@example.com";
       LocalDate birth = LocalDate.of(1990, 1, 1);
       Gender gender = MALE;
 
       // when
-      spyService.registerUser(userId, email, birth, gender);
+      spyService.registerUser(loginId, email, birth, gender);
 
       // then
       verify(spyRepository, times(1)).save(argThat(user ->
-          user.getUserId().equals(userId) &&
+          user.getLoginId().equals(loginId) &&
               user.getEmail().equals(email) &&
               user.getBirth().equals(birth) &&
               user.getGender().equals(gender)
@@ -72,19 +72,19 @@ void saveUser_whenRegisterUser() {
 
     @Test
     @DisplayName("이미 가입된 ID로 회원가입 시도 시 실패한다")
-    void throwsException_whenDuplicateUserId() {
+    void throwsException_whenDuplicateLoginId() {
       // given
-      String userId = "existuser";
+      String loginId = "existuser";
       String firstEmail = "first@example.com";
       String secondEmail = "second@example.com";
       LocalDate firstBirth = LocalDate.of(1990, 1, 1);
       LocalDate secondBirth = LocalDate.of(1995, 5, 5);
       Gender gender = MALE;
 
-      userService.registerUser(userId, firstEmail, firstBirth, gender);
+      userService.registerUser(loginId, firstEmail, firstBirth, gender);
 
       // when & then
-      assertThatThrownBy(() -> userService.registerUser(userId, secondEmail, secondBirth, gender))
+      assertThatThrownBy(() -> userService.registerUser(loginId, secondEmail, secondBirth, gender))
           .isInstanceOf(CoreException.class)
           .extracting("errorType")
           .isEqualTo(ErrorType.CONFLICT);
@@ -94,18 +94,18 @@ void throwsException_whenDuplicateUserId() {
     @DisplayName("올바른 정보로 회원가입하면 User가 반환된다")
     void returnsUser_whenValidInfoIsProvided() {
       // given
-      String userId = "newuser";
+      String loginId = "newuser";
       String email = "new@example.com";
       LocalDate birth = LocalDate.of(1995, 3, 15);
       Gender gender = FEMALE;
 
       // when
-      User result = userService.registerUser(userId, email, birth, gender);
+      User result = userService.registerUser(loginId, email, birth, gender);
 
       // then
       assertThat(result)
-          .extracting("userId", "email", "birth", "gender")
-          .containsExactly(userId, email, birth, gender);
+          .extracting("loginId", "email", "birth", "gender")
+          .containsExactly(loginId, email, birth, gender);
 
     }
   }
@@ -118,29 +118,29 @@ class Get {
     @DisplayName("해당 ID의 회원이 존재할 경우, 회원 정보가 반환된다")
     void returnsUser_whenUserExists() {
       // given
-      String userId = "testuser";
+      String loginId = "testuser";
       String email = "test@example.com";
       LocalDate birth = LocalDate.of(1990, 1, 1);
       Gender gender = MALE;
-      userService.registerUser(userId, email, birth, gender);
+      userService.registerUser(loginId, email, birth, gender);
 
       // when
-      User result = userService.findById(userId);
+      User result = userService.findById(loginId);
 
       // then
       assertThat(result)
-          .extracting("userId", "email", "birth", "gender")
-          .containsExactly(userId, email, birth, gender);
+          .extracting("loginId", "email", "birth", "gender")
+          .containsExactly(loginId, email, birth, gender);
     }
 
     @Test
     @DisplayName("해당 ID의 회원이 존재하지 않을 경우, null이 반환된다")
     void returnsNull_whenUserDoesNotExist() {
       // given
-      String nonExistentUserId = "nonexistent";
+      String nonExistentLoginId = "nonexistent";
 
       // when
-      User result = userService.findById(nonExistentUserId);
+      User result = userService.findById(nonExistentLoginId);
 
       // then
       assertThat(result).isNull();
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java
index 2cd6d0227..aef7563ca 100644
--- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java
@@ -1,5 +1,6 @@
 package com.loopers.domain.user;
 
+import com.loopers.support.error.CoreException;
 import org.junit.jupiter.api.DisplayName;
 import org.junit.jupiter.api.Nested;
 import org.junit.jupiter.api.Test;
@@ -31,45 +32,45 @@ class Create {
         @DisplayName("올바른 정보로 생성하면 성공한다")
         @Test
         void shouldCreate_whenValid() {
-            String userId = "user123";
+            String loginId = "user123";
             String email = "user@example.com";
             LocalDate birth = LocalDate.of(1990, 1, 15);
             Gender gender = MALE;
 
-            User result = User.of(userId, email, birth, gender, TEST_CLOCK);
+            User result = User.of(loginId, email, birth, gender, TEST_CLOCK);
 
             assertThat(result)
-                .extracting("userId", "email", "birth", "gender")
-                .containsExactly(userId, email, birth, gender);
+                .extracting("loginId", "email", "birth", "gender")
+                .containsExactly(loginId, email, birth, gender);
         }
     }
 
-    @DisplayName("userId 검증")
+    @DisplayName("loginId 검증")
     @Nested
-    class ValidateUserId {
+    class ValidateLoginId {
 
         @DisplayName("영문+숫자 10자 이내면 정상 생성된다")
         @Test
-        void shouldCreate_whenValidUserId() {
-            String validUserId = "user123";
+        void shouldCreate_whenValidLoginId() {
+            String validLoginId = "user123";
             LocalDate validBirth = LocalDate.of(1990, 1, 1);
 
-            User result = User.of(validUserId, "test@example.com", validBirth, MALE, TEST_CLOCK);
+            User result = User.of(validLoginId, "test@example.com", validBirth, MALE, TEST_CLOCK);
 
-            assertThat(result.getUserId()).isEqualTo(validUserId);
+            assertThat(result.getLoginId()).isEqualTo(validLoginId);
         }
 
         @DisplayName("null 또는 빈 문자열이면 예외가 발생한다")
         @ParameterizedTest
         @NullAndEmptySource
-        void shouldThrowException_whenNullOrEmpty(String invalidUserId) {
+        void shouldThrowException_whenNullOrEmpty(String invalidLoginId) {
             LocalDate validBirth = LocalDate.of(1990, 1, 1);
 
             assertThatThrownBy(() ->
-                User.of(invalidUserId, "test@example.com", validBirth, MALE, TEST_CLOCK)
+                User.of(invalidLoginId, "test@example.com", validBirth, MALE, TEST_CLOCK)
             )
-                .isInstanceOf(IllegalArgumentException.class)
-                .hasMessage("사용자 ID는 비어있을 수 없습니다.");
+                .isInstanceOf(CoreException.class)
+                .hasMessage("로그인 ID는 비어있을 수 없습니다.");
         }
 
         @DisplayName("영문+숫자 10자 이내가 아니면 예외가 발생한다")
@@ -83,13 +84,13 @@ void shouldThrowException_whenNullOrEmpty(String invalidUserId) {
             "홍길동",          // 한글만
             "user name"       // 공백
         })
-        void shouldThrowException_whenInvalidFormat(String invalidUserId) {
+        void shouldThrowException_whenInvalidFormat(String invalidLoginId) {
             LocalDate validBirth = LocalDate.of(1990, 1, 1);
 
             assertThatThrownBy(() ->
-                User.of(invalidUserId, "test@example.com", validBirth, MALE, TEST_CLOCK)
+                User.of(invalidLoginId, "test@example.com", validBirth, MALE, TEST_CLOCK)
             )
-                .isInstanceOf(IllegalArgumentException.class);
+                .isInstanceOf(CoreException.class);
         }
     }
 
@@ -117,7 +118,7 @@ void shouldThrowException_whenNullOrEmpty(String invalidEmail) {
             assertThatThrownBy(() ->
                 User.of("user123", invalidEmail, validBirth, MALE, TEST_CLOCK)
             )
-                .isInstanceOf(IllegalArgumentException.class)
+                .isInstanceOf(CoreException.class)
                 .hasMessage("이메일은 비어있을 수 없습니다.");
         }
 
@@ -138,7 +139,7 @@ void shouldThrowException_whenInvalidFormat(String invalidEmail) {
             assertThatThrownBy(() ->
                 User.of("user123", invalidEmail, validBirth, MALE, TEST_CLOCK)
             )
-                .isInstanceOf(IllegalArgumentException.class);
+                .isInstanceOf(CoreException.class);
         }
     }
 
@@ -162,7 +163,7 @@ void shouldThrowException_whenNull() {
             assertThatThrownBy(() ->
                 User.of("user123", "test@example.com", null, MALE, TEST_CLOCK)
             )
-                .isInstanceOf(IllegalArgumentException.class)
+                .isInstanceOf(CoreException.class)
                 .hasMessage("생년월일은 비어있을 수 없습니다.");
         }
 
@@ -174,7 +175,7 @@ void shouldThrowException_whenFutureDate() {
             assertThatThrownBy(() ->
                 User.of("user123", "test@example.com", futureDate, MALE, TEST_CLOCK)
             )
-                .isInstanceOf(IllegalArgumentException.class)
+                .isInstanceOf(CoreException.class)
                 .hasMessage("생년월일은 미래일 수 없습니다.");
         }
     }
diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiE2ETest.java
index 439ad925f..a5daba7a8 100644
--- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiE2ETest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiE2ETest.java
@@ -56,11 +56,11 @@ class Register {
     @Test
     void returnsCreatedUserInfo_whenRegistrationIsSuccessful() {
       // given
-      String userId = "testuser1";
+      String loginId = "testuser1";
       String email = "test@example.com";
       LocalDate birth = LocalDate.of(1990, 1, 1);
       Gender gender = MALE;
-      UserDto.RegisterRequest request = new UserDto.RegisterRequest(userId, email, birth, gender);
+      UserDto.RegisterRequest request = new UserDto.RegisterRequest(loginId, email, birth, gender);
 
       // when
       ResponseEntity<ApiResponse<UserDto.UserResponse>> response =
@@ -70,7 +70,7 @@ void returnsCreatedUserInfo_whenRegistrationIsSuccessful() {
       assertAll(
           () -> assertTrue(response.getStatusCode().is2xxSuccessful()),
           () -> assertThat(response.getBody().data()).isNotNull(),
-          () -> assertThat(response.getBody().data().userId()).isEqualTo(userId),
+          () -> assertThat(response.getBody().data().loginId()).isEqualTo(loginId),
           () -> assertThat(response.getBody().data().email()).isEqualTo(email),
           () -> assertThat(response.getBody().data().birth()).isEqualTo(birth),
           () -> assertThat(response.getBody().data().gender()).isEqualTo(gender)
@@ -81,10 +81,10 @@ void returnsCreatedUserInfo_whenRegistrationIsSuccessful() {
     @Test
     void throwsBadRequest_whenGenderIsMissing() {
       // given
-      String userId = "testuser2";
+      String loginId = "testuser2";
       String email = "test2@example.com";
       LocalDate birth = LocalDate.of(1995, 5, 15);
-      UserDto.RegisterRequest request = new UserDto.RegisterRequest(userId, email, birth, null);
+      UserDto.RegisterRequest request = new UserDto.RegisterRequest(loginId, email, birth, null);
 
       // when
       ResponseEntity<ApiResponse<UserDto.UserResponse>> response =
@@ -95,7 +95,7 @@ void throwsBadRequest_whenGenderIsMissing() {
     }
   }
 
-  @DisplayName("GET /api/v1/users/{userId}")
+  @DisplayName("GET /api/v1/users/{loginId}")
   @Nested
   class GetUser {
 
@@ -103,23 +103,23 @@ class GetUser {
     @Test
     void returnsUserInfo_whenUserExists() {
       // given
-      String userId = "testuser1";
+      String loginId = "testuser1";
       String email = "test@example.com";
       LocalDate birth = LocalDate.of(1990, 1, 1);
       Gender gender = MALE;
-      UserDto.RegisterRequest registerRequest = new UserDto.RegisterRequest(userId, email, birth, gender);
+      UserDto.RegisterRequest registerRequest = new UserDto.RegisterRequest(loginId, email, birth, gender);
       testRestTemplate.exchange(ENDPOINT_REGISTER, HttpMethod.POST, new HttpEntity<>(registerRequest),
           USER_RESPONSE_TYPE);
 
       // when
       ResponseEntity<ApiResponse<UserDto.UserResponse>> response =
-          testRestTemplate.exchange("/api/v1/users/" + userId, HttpMethod.GET, null, USER_RESPONSE_TYPE);
+          testRestTemplate.exchange("/api/v1/users/" + loginId, HttpMethod.GET, null, USER_RESPONSE_TYPE);
 
       // then
       assertAll(
           () -> assertTrue(response.getStatusCode().is2xxSuccessful()),
           () -> assertThat(response.getBody().data()).isNotNull(),
-          () -> assertThat(response.getBody().data().userId()).isEqualTo(userId),
+          () -> assertThat(response.getBody().data().loginId()).isEqualTo(loginId),
           () -> assertThat(response.getBody().data().email()).isEqualTo(email),
           () -> assertThat(response.getBody().data().birth()).isEqualTo(birth),
           () -> assertThat(response.getBody().data().gender()).isEqualTo(gender)
@@ -130,11 +130,11 @@ void returnsUserInfo_whenUserExists() {
     @Test
     void returnsNotFound_whenUserDoesNotExist() {
       // given
-      String nonExistentUserId = "nonexistent";
+      String nonExistentLoginId = "nonexistent";
 
       // when
       ResponseEntity<ApiResponse<UserDto.UserResponse>> response =
-          testRestTemplate.exchange("/api/v1/users/" + nonExistentUserId, HttpMethod.GET, null, USER_RESPONSE_TYPE);
+          testRestTemplate.exchange("/api/v1/users/" + nonExistentLoginId, HttpMethod.GET, null, USER_RESPONSE_TYPE);
 
       // then
       assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);

From 6aff3ad2aa8a1983f4837b20eba3f887519b4214 Mon Sep 17 00:00:00 2001
From: green <green@mobidoc.us>
Date: Thu, 13 Nov 2025 08:30:47 +0900
Subject: [PATCH 08/21] =?UTF-8?q?refactor:=20Point=20=EB=8F=84=EB=A9=94?=
 =?UTF-8?q?=EC=9D=B8=20=EA=B0=9C=EC=84=A0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

도메인 계층:
- PointAmount VO 활용
- 잔액 부족 검증 강화
- Repository 3계층 구조 적용

API 계층:
- ApiSpec 개선 (문서화, 검증)
- Dto 개선 (응답 구조 최적화)
- E2E 테스트 보완

테스트:
- @Nested 구조 적용
- 경계값 테스트 추가
---
 .../application/point/PointFacade.java        |  11 +-
 .../application/point/PointResult.java        |   2 +-
 .../java/com/loopers/domain/point/Point.java  |  51 ++++++---
 .../com/loopers/domain/point/PointAmount.java |  30 +++--
 .../loopers/domain/point/PointRepository.java |  13 ++-
 .../loopers/domain/point/PointService.java    |  27 ++++-
 .../point/PointJpaRepository.java             |  15 ++-
 .../point/PointRepositoryImpl.java            |  14 ++-
 .../interfaces/api/point/PointApiSpec.java    |   8 +-
 .../interfaces/api/point/PointController.java |  11 +-
 .../interfaces/api/point/PointDto.java        |   4 +-
 .../loopers/domain/point/PointAmountTest.java |  17 +--
 .../point/PointServiceIntegrationTest.java    |  35 +++---
 .../com/loopers/domain/point/PointTest.java   | 108 ++++++------------
 .../interfaces/api/point/PointApiE2ETest.java |   8 +-
 15 files changed, 196 insertions(+), 158 deletions(-)

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
index 07f30f516..8b0eb3162 100644
--- 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
@@ -2,6 +2,7 @@
 
 import com.loopers.domain.point.PointService;
 import com.loopers.domain.point.Point;
+import java.util.Optional;
 import lombok.RequiredArgsConstructor;
 import org.springframework.stereotype.Component;
 
@@ -11,13 +12,13 @@ public class PointFacade {
 
     private final PointService pointService;
 
-    public PointResult findPointOrNull(String userId) {
-      Point point = pointService.findByUserId(userId);
-      return point == null ? null : PointResult.from(point);
+    public Optional<PointResult> findPoint(String loginId) {
+      return pointService.findByUserLoginId(loginId)
+          .map(PointResult::from);
     }
 
-    public PointResult charge(String userId, Long amount) {
-      return PointResult.from(pointService.charge(userId, amount));
+    public PointResult charge(String loginId, Long amount) {
+      return PointResult.from(pointService.charge(loginId, amount));
     }
 
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/point/PointResult.java b/apps/commerce-api/src/main/java/com/loopers/application/point/PointResult.java
index 595f53a06..1cdb83fb1 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/point/PointResult.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/point/PointResult.java
@@ -4,7 +4,7 @@
 import java.util.Objects;
 
 public record PointResult(
-    String userId,
+    Long userId,
     Long amount
 ) {
 
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java
index af92be24e..fb16a62ad 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java
@@ -1,16 +1,18 @@
 package com.loopers.domain.point;
 
 import com.loopers.domain.BaseEntity;
-import com.loopers.domain.user.User;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
 import jakarta.persistence.*;
+import lombok.Getter;
 
 @Entity
 @Table(name = "point")
 public class Point extends BaseEntity {
 
-  @OneToOne(fetch = FetchType.LAZY, optional = false)
-  @JoinColumn(name = "user_id", nullable = false, unique = true)
-  private User user;
+  @Getter
+  @Column(name = "ref_user_id", nullable = false, unique = true)
+  private Long userId;
 
   @Embedded
   private PointAmount amount;
@@ -18,27 +20,27 @@ public class Point extends BaseEntity {
   protected Point() {
   }
 
-  private Point(User user, PointAmount amount) {
-    validateUser(user);
-    this.user = user;
+  private Point(Long userId, PointAmount amount) {
+    validateUserId(userId);
+    this.userId = userId;
     this.amount = amount;
   }
 
-  public static Point of(User user, Long amount) {
-    return new Point(user, PointAmount.of(amount));
+  public static Point of(Long userId, Long amount) {
+    return new Point(userId, PointAmount.of(amount));
   }
 
-  public static Point of(User user, PointAmount amount) {
-    return new Point(user, amount);
+  public static Point of(Long userId, PointAmount amount) {
+    return new Point(userId, amount);
   }
 
-  public static Point zero(User user) {
-    return new Point(user, PointAmount.zero());
+  public static Point zero(Long userId) {
+    return new Point(userId, PointAmount.zero());
   }
 
-  private void validateUser(User user) {
-    if (user == null) {
-      throw new IllegalArgumentException("사용자는 비어있을 수 없습니다.");
+  private void validateUserId(Long userId) {
+    if (userId == null) {
+      throw new CoreException(ErrorType.INVALID_POINT_USER_EMPTY);
     }
   }
 
@@ -46,12 +48,23 @@ public void charge(Long chargeAmount) {
     this.amount = this.amount.add(chargeAmount);
   }
 
-  public String getUserId() {
-    return user.getUserId();
+  public void deduct(Long deductAmount) {
+    this.amount = this.amount.subtract(deductAmount);
+  }
+
+  public boolean hasEnoughBalance(Long requiredAmount) {
+    if (requiredAmount == null || requiredAmount < 0) {
+      return false;
+    }
+    return this.amount.getValue() >= requiredAmount;
+  }
+
+  public boolean isNotEnough(Long requiredAmount) {
+    return !hasEnoughBalance(requiredAmount);
   }
 
   public Long getAmountValue() {
-    return amount.getAmount();
+    return amount.getValue();
   }
 
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointAmount.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointAmount.java
index 3c6ad1ea8..4e0bebe78 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointAmount.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointAmount.java
@@ -1,5 +1,7 @@
 package com.loopers.domain.point;
 
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
 import jakarta.persistence.Column;
 import jakarta.persistence.Embeddable;
 import lombok.AccessLevel;
@@ -14,16 +16,16 @@
 public class PointAmount {
 
   @Column(name = "amount", nullable = false)
-  private Long amount;
+  private Long value;
 
-  private PointAmount(Long amount) {
-    if (amount == null) {
-      throw new IllegalArgumentException("포인트 금액은 비어있을 수 없습니다.");
+  private PointAmount(Long value) {
+    if (value == null) {
+      throw new CoreException(ErrorType.INVALID_POINT_AMOUNT_EMPTY);
     }
-    if (amount < 0) {
-      throw new IllegalArgumentException("포인트 금액은 음수일 수 없습니다.");
+    if (value < 0) {
+      throw new CoreException(ErrorType.NEGATIVE_POINT_AMOUNT);
     }
-    this.amount = amount;
+    this.value = value;
   }
 
   public static PointAmount zero() {
@@ -36,9 +38,19 @@ public static PointAmount of(Long amount) {
 
   public PointAmount add(Long chargeAmount) {
     if (chargeAmount == null || chargeAmount <= 0) {
-      throw new IllegalArgumentException("충전 금액은 0보다 커야 합니다.");
+      throw new CoreException(ErrorType.INVALID_CHARGE_AMOUNT);
     }
-    return PointAmount.of(this.amount + chargeAmount);
+    return PointAmount.of(this.value + chargeAmount);
+  }
+
+  public PointAmount subtract(Long deductAmount) {
+    if (deductAmount == null || deductAmount <= 0) {
+      throw new CoreException(ErrorType.INVALID_DEDUCT_AMOUNT);
+    }
+    if (this.value < deductAmount) {
+      throw new CoreException(ErrorType.INSUFFICIENT_POINT_BALANCE);
+    }
+    return PointAmount.of(this.value - deductAmount);
   }
 
 }
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
index 5f93cc325..c44517fda 100644
--- 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
@@ -3,7 +3,14 @@
 import java.util.Optional;
 
 public interface PointRepository {
-    Point save(Point point);
-    Point findByUserId(String userId);
-    Optional<Point> findByUserIdWithLock(String userId);
+
+  Point save(Point point);
+
+  Optional<Point> findByUserId(Long userId);
+
+  Optional<Point> findByUserIdWithLock(Long userId);
+
+  Optional<Point> findByUserLoginId(String loginId);
+
+  Optional<Point> findByUserLoginIdWithLock(String loginId);
 }
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
index d03efb8a2..858f40414 100644
--- 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
@@ -2,6 +2,7 @@
 
 import com.loopers.support.error.CoreException;
 import com.loopers.support.error.ErrorType;
+import java.util.Optional;
 import lombok.RequiredArgsConstructor;
 import org.springframework.stereotype.Component;
 import org.springframework.transaction.annotation.Transactional;
@@ -13,17 +14,37 @@ public class PointService {
 
     private final PointRepository pointRepository;
 
-    public Point findByUserId(String userId) {
+    public Optional<Point> findByUserId(Long userId) {
       return pointRepository.findByUserId(userId);
     }
 
+    public Optional<Point> findByUserLoginId(String loginId) {
+      return pointRepository.findByUserLoginId(loginId);
+    }
 
     @Transactional
-    public Point charge(String userId, Long chargeAmount) {
-        Point point = pointRepository.findByUserIdWithLock(userId)
+    public Point charge(String loginId, Long chargeAmount) {
+        Point point = pointRepository.findByUserLoginIdWithLock(loginId)
             .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND,
                 "포인트 정보를 찾을 수 없습니다."));
         point.charge(chargeAmount);
         return pointRepository.save(point);
     }
+
+    public void checkBalance(Long userId, Long requiredAmount) {
+        Point point = pointRepository.findByUserId(userId)
+            .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "포인트 정보를 찾을 수 없습니다."));
+        if (point.isNotEnough(requiredAmount)) {
+            throw new CoreException(ErrorType.INSUFFICIENT_POINT_BALANCE);
+        }
+    }
+
+    @Transactional
+    public Point deduct(Long userId, Long deductAmount) {
+        Point point = pointRepository.findByUserIdWithLock(userId)
+            .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND,
+                "포인트 정보를 찾을 수 없습니다."));
+        point.deduct(deductAmount);
+        return pointRepository.save(point);
+    }
 }
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
index 8e6b85146..97664d6db 100644
--- 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
@@ -11,11 +11,18 @@
 
 public interface PointJpaRepository extends JpaRepository<Point, Long> {
 
-  @Query("SELECT p FROM Point p JOIN FETCH p.user WHERE p.user.userId = :userId")
-  Point findByUserId(@Param("userId") String userId);
+  @Query("SELECT p FROM Point p WHERE p.userId = :userId")
+  Optional<Point> findByUserId(@Param("userId") Long userId);
 
   @Lock(LockModeType.PESSIMISTIC_WRITE)
-  @Query("SELECT p FROM Point p JOIN FETCH p.user WHERE p.user.userId = :userId")
-  Optional<Point> findByUserIdWithLock(@Param("userId") String userId);
+  @Query("SELECT p FROM Point p WHERE p.userId = :userId")
+  Optional<Point> findByUserIdWithLock(@Param("userId") Long userId);
+
+  @Query("SELECT p FROM Point p JOIN User u ON p.userId = u.id WHERE u.loginId = :loginId")
+  Optional<Point> findByUserLoginId(@Param("loginId") String loginId);
+
+  @Lock(LockModeType.PESSIMISTIC_WRITE)
+  @Query("SELECT p FROM Point p JOIN User u ON p.userId = u.id WHERE u.loginId = :loginId")
+  Optional<Point> findByUserLoginIdWithLock(@Param("loginId") String loginId);
 
 }
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
index a9b7dbc11..c13b4a65d 100644
--- 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
@@ -19,13 +19,23 @@ public Point save(Point point) {
   }
 
   @Override
-  public Point findByUserId(String userId) {
+  public Optional<Point> findByUserId(Long userId) {
     return pointJpaRepository.findByUserId(userId);
   }
 
   @Override
-  public Optional<Point> findByUserIdWithLock(String userId) {
+  public Optional<Point> findByUserIdWithLock(Long userId) {
     return pointJpaRepository.findByUserIdWithLock(userId);
   }
 
+  @Override
+  public Optional<Point> findByUserLoginId(String loginId) {
+    return pointJpaRepository.findByUserLoginId(loginId);
+  }
+
+  @Override
+  public Optional<Point> findByUserLoginIdWithLock(String loginId) {
+    return pointJpaRepository.findByUserLoginIdWithLock(loginId);
+  }
+
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointApiSpec.java
index 7e7b52c5a..fbab8a00b 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointApiSpec.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointApiSpec.java
@@ -19,8 +19,8 @@ public interface PointApiSpec {
         description = "사용자의 포인트를 조회합니다."
     )
     ApiResponse<PointResponse> getPoint(
-        @Parameter(description = "사용자 ID", required = true)
-        @RequestHeader(ApiHeaders.USER_ID) String userId
+        @Parameter(description = "사용자 로그인 ID", required = true)
+        @RequestHeader(ApiHeaders.USER_LOGIN_ID) String loginId
     );
 
     @Operation(
@@ -28,8 +28,8 @@ ApiResponse<PointResponse> getPoint(
         description = "사용자의 포인트를 충전합니다."
     )
     ApiResponse<ChargeResponse> chargePoint(
-        @Parameter(description = "사용자 ID", required = true)
-        @RequestHeader(ApiHeaders.USER_ID) String userId,
+        @Parameter(description = "사용자 로그인 ID", required = true)
+        @RequestHeader(ApiHeaders.USER_LOGIN_ID) String loginId,
         @RequestBody(description = "충전 요청", required = true)
         ChargeRequest request
     );
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointController.java
index bc795b61f..8e7518d20 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointController.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointController.java
@@ -25,20 +25,21 @@ public class PointController implements PointApiSpec {
 
   @Override
   @GetMapping
-public ApiResponse<PointResponse> getPoint(@RequestHeader(ApiHeaders.USER_ID) String userId) {
-    PointResult result = pointFacade.findPointOrNull(userId);
-    PointResponse response = result == null ? null : PointResponse.from(result);
+public ApiResponse<PointResponse> getPoint(@RequestHeader(ApiHeaders.USER_LOGIN_ID) String loginId) {
+    PointResponse response = pointFacade.findPoint(loginId)
+        .map(PointResponse::from)
+        .orElse(null);
     return ApiResponse.success(response);
   }
 
   @Override
   @PatchMapping("/charge")
   public ApiResponse<ChargeResponse> chargePoint(
-      @RequestHeader(ApiHeaders.USER_ID) String userId,
+      @RequestHeader(ApiHeaders.USER_LOGIN_ID) String loginId,
       @Valid @RequestBody ChargeRequest request
   ) {
     ChargeResponse response = ChargeResponse.from(
-        pointFacade.charge(userId, request.amount())
+        pointFacade.charge(loginId, request.amount())
     );
     return ApiResponse.success(response);
   }
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointDto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointDto.java
index 2447baead..15bac5a54 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointDto.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointDto.java
@@ -6,7 +6,7 @@
 public class PointDto {
 
   public record PointResponse(
-      String userId,
+      Long userId,
       Long balance
   ) {
 
@@ -24,7 +24,7 @@ public record ChargeRequest(
   }
 
   public record ChargeResponse(
-      String userId,
+      Long userId,
       Long balance
   ) {
 
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointAmountTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointAmountTest.java
index 5af5bc232..93204aa15 100644
--- a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointAmountTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointAmountTest.java
@@ -1,5 +1,6 @@
 package com.loopers.domain.point;
 
+import com.loopers.support.error.CoreException;
 import org.junit.jupiter.api.DisplayName;
 import org.junit.jupiter.api.Nested;
 import org.junit.jupiter.api.Test;
@@ -26,7 +27,7 @@ void shouldCreate_whenValidAmount() {
       PointAmount result = PointAmount.of(validAmount);
 
       // then
-      assertThat(result.getAmount()).isEqualTo(validAmount);
+      assertThat(result.getValue()).isEqualTo(validAmount);
     }
 
     @Test
@@ -36,7 +37,7 @@ void shouldCreate_whenZeroAmount() {
       PointAmount result = PointAmount.zero();
 
       // then
-      assertThat(result.getAmount()).isZero();
+      assertThat(result.getValue()).isZero();
     }
 
     @Test
@@ -45,7 +46,7 @@ void shouldThrowException_whenNull() {
       assertThatThrownBy(() ->
           PointAmount.of(null)
       )
-          .isInstanceOf(IllegalArgumentException.class)
+          .isInstanceOf(CoreException.class)
           .hasMessage("포인트 금액은 비어있을 수 없습니다.");
     }
 
@@ -59,7 +60,7 @@ void shouldThrowException_whenNegative() {
       assertThatThrownBy(() ->
           PointAmount.of(negativeAmount)
       )
-          .isInstanceOf(IllegalArgumentException.class)
+          .isInstanceOf(CoreException.class)
           .hasMessage("포인트 금액은 음수일 수 없습니다.");
     }
   }
@@ -79,8 +80,8 @@ void shouldAdd_whenValidAmount() {
       PointAmount result = original.add(chargeAmount);
 
       // then
-      assertThat(result.getAmount()).isEqualTo(1500L);
-      assertThat(original.getAmount()).isEqualTo(1000L); // 불변성 확인
+      assertThat(result.getValue()).isEqualTo(1500L);
+      assertThat(original.getValue()).isEqualTo(1000L); // 불변성 확인
     }
 
     @ParameterizedTest
@@ -94,7 +95,7 @@ void shouldThrowException_whenZeroOrNegative(Long invalidAmount) {
       assertThatThrownBy(() ->
           original.add(invalidAmount)
       )
-          .isInstanceOf(IllegalArgumentException.class)
+          .isInstanceOf(CoreException.class)
           .hasMessage("충전 금액은 0보다 커야 합니다.");
     }
 
@@ -108,7 +109,7 @@ void shouldThrowException_whenNull() {
       assertThatThrownBy(() ->
           original.add(null)
       )
-          .isInstanceOf(IllegalArgumentException.class)
+          .isInstanceOf(CoreException.class)
           .hasMessage("충전 금액은 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
index 23b9d99de..8589e4fbc 100644
--- 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
@@ -62,29 +62,26 @@ void returnsPoint_whenUserExists() {
       User user = User.of(userId, email, birth, gender, TEST_CLOCK);
       User savedUser = userRepository.save(user);
 
-      Point point = Point.zero(savedUser);
+      Point point = Point.zero(savedUser.getId());
       pointRepository.save(point);
 
       // when
-      Point result = pointService.findByUserId(userId);
+      Point result = pointService.findByUserId(savedUser.getId()).orElse(null);
 
       // then
       assertThat(result)
-          .extracting("user.userId", "amount.amount")
-          .containsExactly(userId, 0L);
+          .extracting("userId", "amount.value")
+          .containsExactly(savedUser.getId(), 0L);
     }
 
     @Test
-    @DisplayName("해당 ID의 회원이 존재하지 않을 경우, null이 반환된다")
-    void returnsNull_whenUserDoesNotExist() {
+    @DisplayName("해당 ID의 회원이 존재하지 않을 경우, empty Optional이 반환된다")
+    void returnsEmpty_whenUserDoesNotExist() {
       // given
-      String nonExistentUserId = "nonexistent";
+      Long nonExistentUserId = 999999L;
 
-      // when
-      Point result = pointService.findByUserId(nonExistentUserId);
-
-      // then
-      assertThat(result).isNull();
+      // when & then
+      assertThat(pointService.findByUserId(nonExistentUserId)).isEmpty();
     }
   }
 
@@ -96,25 +93,25 @@ class Charge {
     @DisplayName("존재하는 유저 ID로 충전하면 성공한다")
     void succeeds_whenUserExists() {
       // given
-      String userId = "testuser";
+      String loginId = "testuser";
       String email = "test@example.com";
       LocalDate birth = LocalDate.of(1990, 1, 1);
       Gender gender = Gender.MALE;
 
-      User user = User.of(userId, email, birth, gender, TEST_CLOCK);
+      User user = User.of(loginId, email, birth, gender, TEST_CLOCK);
       User savedUser = userRepository.save(user);
 
-      Point point = Point.of(savedUser, 1000L);
+      Point point = Point.of(savedUser.getId(), 1000L);
       pointRepository.save(point);
 
       Long chargeAmount = 500L;
 
       // when
-      Point result = pointService.charge(userId, chargeAmount);
+      Point result = pointService.charge(loginId, chargeAmount);
 
       // then
       assertThat(result)
-          .extracting("amount.amount")
+          .extracting("amount.value")
           .isEqualTo(1500L);
     }
 
@@ -122,12 +119,12 @@ void succeeds_whenUserExists() {
     @DisplayName("존재하지 않는 유저 ID로 충전하면 실패한다")
     void fails_whenUserDoesNotExist() {
       // given
-      String nonExistentUserId = "nonexistent";
+      String nonExistentLoginId = "nonexistent";
       Long chargeAmount = 500L;
 
       // when & then
       assertThatThrownBy(() ->
-          pointService.charge(nonExistentUserId, chargeAmount)
+          pointService.charge(nonExistentLoginId, chargeAmount)
       )
           .isInstanceOf(CoreException.class)
           .extracting("errorType", "message")
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointTest.java
index 54a5c99b0..aeb7bf0b1 100644
--- a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointTest.java
@@ -1,84 +1,70 @@
 package com.loopers.domain.point;
 
-import com.loopers.domain.user.Gender;
-import com.loopers.domain.user.User;
+import com.loopers.support.error.CoreException;
 import org.junit.jupiter.api.DisplayName;
 import org.junit.jupiter.api.Nested;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.ValueSource;
 
-import java.time.Clock;
-import java.time.Instant;
-import java.time.LocalDate;
-import java.time.ZoneId;
-
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
-@DisplayName("Point 엔티티")
+@DisplayName("Point 도메인 테스트")
 class PointTest {
 
-  private static final Clock TEST_CLOCK = Clock.fixed(
-      Instant.parse("2025-10-30T00:00:00Z"),
-      ZoneId.systemDefault()
-  );
-
   @Nested
-  @DisplayName("생성 시")
-  class Constructor {
+  @DisplayName("Point를 생성할 때")
+  class Create {
 
     @Test
-    @DisplayName("User가 null이면 예외가 발생한다")
-    void throwsException_whenUserIsNull() {
+    @DisplayName("userId가 null이면 예외가 발생한다")
+    void shouldThrowException_whenUserIdIsNull() {
       assertThatThrownBy(() -> Point.zero(null))
-          .isInstanceOf(IllegalArgumentException.class)
+          .isInstanceOf(CoreException.class)
           .hasMessage("사용자는 비어있을 수 없습니다.");
     }
 
     @Test
-    @DisplayName("Point.zero(user)로 생성하면 balance가 0으로 초기화된다")
-    void initializesBalanceToZero_whenValidUserIsProvided() {
-      // given
-      String userId = "testuser";
-      String email = "test@example.com";
-      LocalDate birth = LocalDate.of(1990, 1, 1);
-      Gender gender = Gender.MALE;
-      User user = User.of(userId, email, birth, gender, TEST_CLOCK);
-
-      // when
-      Point point = Point.zero(user);
-
-      // then
+    @DisplayName("Point.zero()로 생성하면 amount가 0으로 초기화된다")
+    void shouldCreate_whenZero() {
+      Long userId = 1L;
+
+      Point point = Point.zero(userId);
+
       assertThat(point)
-          .extracting("user.userId", "amount.amount")
+          .extracting("userId", "amount.value")
           .containsExactly(userId, 0L);
     }
+
+    @Test
+    @DisplayName("올바른 userId와 amount로 생성하면 성공한다")
+    void shouldCreate_whenValid() {
+      Long userId = 1L;
+      Long amount = 1000L;
+
+      Point point = Point.of(userId, amount);
+
+      assertThat(point)
+          .extracting("userId", "amount.value")
+          .containsExactly(userId, amount);
+    }
   }
 
   @Nested
-  @DisplayName("포인트 충전 시")
+  @DisplayName("포인트 충전")
   class Charge {
 
     @Test
     @DisplayName("양수 금액으로 충전하면 성공한다")
     void shouldCharge_whenValidAmount() {
-      // given
-      String userId = "testuser";
-      String email = "test@example.com";
-      LocalDate birth = LocalDate.of(1990, 1, 1);
-      Gender gender = Gender.MALE;
-      User user = User.of(userId, email, birth, gender, TEST_CLOCK);
-
-      Point point = Point.of(user, 1000L);
+      Point point = Point.of(1L, 1000L);
       Long chargeAmount = 500L;
 
-      // when
       point.charge(chargeAmount);
 
-      // then
       assertThat(point)
-          .extracting("amount.amount")
+          .extracting("amount.value")
           .isEqualTo(1500L);
     }
 
@@ -86,38 +72,20 @@ void shouldCharge_whenValidAmount() {
     @DisplayName("0 이하의 금액으로 충전하면 예외가 발생한다")
     @ValueSource(longs = {0L, -1L, -100L, -1000L})
     void shouldThrowException_whenZeroOrNegative(Long invalidAmount) {
-      // given
-      String userId = "testuser";
-      String email = "test@example.com";
-      LocalDate birth = LocalDate.of(1990, 1, 1);
-      Gender gender = Gender.MALE;
-      User user = User.of(userId, email, birth, gender, TEST_CLOCK);
-      Point point = Point.of(user, 1000L);
-
-      // when & then
-      assertThatThrownBy(() ->
-          point.charge(invalidAmount)
-      )
-          .isInstanceOf(IllegalArgumentException.class)
+      Point point = Point.of(1L, 1000L);
+
+      assertThatThrownBy(() -> point.charge(invalidAmount))
+          .isInstanceOf(CoreException.class)
           .hasMessage("충전 금액은 0보다 커야 합니다.");
     }
 
     @Test
     @DisplayName("null로 충전하면 예외가 발생한다")
     void shouldThrowException_whenNull() {
-      // given
-      String userId = "testuser";
-      String email = "test@example.com";
-      LocalDate birth = LocalDate.of(1990, 1, 1);
-      Gender gender = Gender.MALE;
-      User user = User.of(userId, email, birth, gender, TEST_CLOCK);
-      Point point = Point.of(user, 1000L);
-
-      // when & then
-      assertThatThrownBy(() ->
-          point.charge(null)
-      )
-          .isInstanceOf(IllegalArgumentException.class)
+      Point point = Point.of(1L, 1000L);
+
+      assertThatThrownBy(() -> point.charge(null))
+          .isInstanceOf(CoreException.class)
           .hasMessage("충전 금액은 0보다 커야 합니다.");
     }
   }
diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/point/PointApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/point/PointApiE2ETest.java
index 0b287c245..5a5b5b86c 100644
--- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/point/PointApiE2ETest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/point/PointApiE2ETest.java
@@ -101,7 +101,7 @@ void returnsPoint_whenPointExists() {
       User user = User.of(userId, email, birth, gender, TEST_CLOCK);
       User savedUser = userJpaRepository.save(user);
 
-      Point point = Point.of(savedUser, 5L);
+      Point point = Point.of(savedUser.getId(), 5L);
       pointJpaRepository.save(point);
 
       // when
@@ -123,7 +123,7 @@ void returnsPoint_whenPointExists() {
       assertAll(
           () -> assertTrue(response.getStatusCode().is2xxSuccessful()),
           () -> assertThat(response.getBody()).isNotNull(),
-          () -> assertThat(response.getBody().data().userId()).isEqualTo(userId),
+          () -> assertThat(response.getBody().data().userId()).isEqualTo(savedUser.getId()),
           () -> assertThat(response.getBody().data().balance()).isEqualTo(5L)
       );
     }
@@ -175,7 +175,7 @@ void returnsTotalBalance_whenChargingThousand() {
       User user = User.of(userId, email, birth, gender, TEST_CLOCK);
       User savedUser = userJpaRepository.save(user);
 
-      Point point = Point.zero(savedUser);
+      Point point = Point.zero(savedUser.getId());
       pointJpaRepository.save(point);
 
       ChargeRequest chargeRequest = new ChargeRequest(1000L);
@@ -199,7 +199,7 @@ void returnsTotalBalance_whenChargingThousand() {
       assertAll(
           () -> assertTrue(response.getStatusCode().is2xxSuccessful()),
           () -> assertThat(response.getBody()).isNotNull(),
-          () -> assertThat(response.getBody().data().userId()).isEqualTo(userId),
+          () -> assertThat(response.getBody().data().userId()).isEqualTo(savedUser.getId()),
           () -> assertThat(response.getBody().data().balance()).isEqualTo(1000L)
       );
     }

From 6ffe26a67edce7c7507c2b4753ff0b48fb025278 Mon Sep 17 00:00:00 2001
From: green <green@mobidoc.us>
Date: Thu, 13 Nov 2025 08:31:01 +0900
Subject: [PATCH 09/21] =?UTF-8?q?refactor:=20=EA=B3=B5=ED=86=B5=20?=
 =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B0=9C=EC=84=A0=20(ErrorType,=20API=20?=
 =?UTF-8?q?=EA=B3=B5=ED=86=B5=20=EC=B2=98=EB=A6=AC)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

ErrorType:
- 도메인별 에러 코드 체계적 정리
- 일관된 네이밍 (INVALID_*, NEGATIVE_*, INSUFFICIENT_*)
- 명확한 에러 메시지

ApiControllerAdvice:
- 도메인 에러 코드 기반 응답 처리
- ErrorType의 code 필드 활용
- 일관된 에러 응답 구조

ApiHeaders:
- 공통 헤더 상수 관리 (X-USER-ID 등)
---
 .../interfaces/api/ApiControllerAdvice.java   |  7 --
 .../interfaces/api/support/ApiHeaders.java    |  2 +-
 .../com/loopers/support/error/ErrorType.java  | 74 ++++++++++++++++++-
 3 files changed, 74 insertions(+), 9 deletions(-)

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 e4296d092..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
@@ -56,13 +56,6 @@ public ResponseEntity<ApiResponse<?>> handleBadRequest(MissingRequestHeaderExcep
         return failureResponse(ErrorType.BAD_REQUEST, message);
     }
 
-    @ExceptionHandler
-    public ResponseEntity<ApiResponse<?>> handleBadRequest(IllegalArgumentException e) {
-        log.warn("IllegalArgumentException : {}", e.getMessage(), e);
-        String message = e.getMessage() != null ? e.getMessage() : "잘못된 요청입니다.";
-        return failureResponse(ErrorType.BAD_REQUEST, message);
-    }
-
     @ExceptionHandler
     public ResponseEntity<ApiResponse<?>> handleBadRequest(MethodArgumentNotValidException e) {
         String errorMessage = e.getBindingResult().getFieldErrors().stream()
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/support/ApiHeaders.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/support/ApiHeaders.java
index 1793e6ad1..5da4b2853 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/support/ApiHeaders.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/support/ApiHeaders.java
@@ -5,6 +5,6 @@ public final class ApiHeaders {
     private ApiHeaders() {
     }
 
-    public static final String USER_ID = "X-USER-ID";
+    public static final String USER_LOGIN_ID = "X-USER-ID";
 }
 
diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java
index 5d142efbf..4c97b13f3 100644
--- a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java
+++ b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java
@@ -11,7 +11,79 @@ public enum ErrorType {
     INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), "일시적인 오류가 발생했습니다."),
     BAD_REQUEST(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "잘못된 요청입니다."),
     NOT_FOUND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "존재하지 않는 요청입니다."),
-    CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 리소스입니다.");
+    CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 리소스입니다."),
+
+    /** Money 도메인 에러 */
+    INVALID_MONEY_VALUE(HttpStatus.BAD_REQUEST, "INVALID_MONEY_VALUE", "금액은 비어있을 수 없습니다."),
+    NEGATIVE_MONEY_VALUE(HttpStatus.BAD_REQUEST, "NEGATIVE_MONEY_VALUE", "금액은 음수일 수 없습니다."),
+
+    /** User 도메인 에러 */
+    INVALID_LOGIN_ID_EMPTY(HttpStatus.BAD_REQUEST, "INVALID_LOGIN_ID_EMPTY", "로그인 ID는 비어있을 수 없습니다."),
+    INVALID_LOGIN_ID_LENGTH(HttpStatus.BAD_REQUEST, "INVALID_LOGIN_ID_LENGTH", "로그인 ID는 10자 이내여야 합니다."),
+    INVALID_LOGIN_ID_FORMAT(HttpStatus.BAD_REQUEST, "INVALID_LOGIN_ID_FORMAT", "로그인 ID는 영문/숫자만 허용됩니다."),
+    INVALID_EMAIL_EMPTY(HttpStatus.BAD_REQUEST, "INVALID_EMAIL_EMPTY", "이메일은 비어있을 수 없습니다."),
+    INVALID_EMAIL_FORMAT(HttpStatus.BAD_REQUEST, "INVALID_EMAIL_FORMAT", "이메일 형식이 올바르지 않습니다."),
+    INVALID_BIRTH_EMPTY(HttpStatus.BAD_REQUEST, "INVALID_BIRTH_EMPTY", "생년월일은 비어있을 수 없습니다."),
+    INVALID_BIRTH_FUTURE(HttpStatus.BAD_REQUEST, "INVALID_BIRTH_FUTURE", "생년월일은 미래일 수 없습니다."),
+    INVALID_GENDER_EMPTY(HttpStatus.BAD_REQUEST, "INVALID_GENDER_EMPTY", "성별은 비어있을 수 없습니다."),
+
+    /** Point 도메인 에러 */
+    INVALID_POINT_USER_EMPTY(HttpStatus.BAD_REQUEST, "INVALID_POINT_USER_EMPTY", "사용자는 비어있을 수 없습니다."),
+
+    /** PointAmount 도메인 에러 */
+    INVALID_POINT_AMOUNT_EMPTY(HttpStatus.BAD_REQUEST, "INVALID_POINT_AMOUNT_EMPTY", "포인트 금액은 비어있을 수 없습니다."),
+    NEGATIVE_POINT_AMOUNT(HttpStatus.BAD_REQUEST, "NEGATIVE_POINT_AMOUNT", "포인트 금액은 음수일 수 없습니다."),
+    INVALID_CHARGE_AMOUNT(HttpStatus.BAD_REQUEST, "INVALID_CHARGE_AMOUNT", "충전 금액은 0보다 커야 합니다."),
+    INVALID_DEDUCT_AMOUNT(HttpStatus.BAD_REQUEST, "INVALID_DEDUCT_AMOUNT", "차감 금액은 0보다 커야 합니다."),
+    INSUFFICIENT_POINT_BALANCE(HttpStatus.BAD_REQUEST, "INSUFFICIENT_POINT_BALANCE", "포인트 잔액이 부족합니다."),
+
+    /** Stock 도메인 에러 */
+    INVALID_STOCK_VALUE(HttpStatus.BAD_REQUEST, "INVALID_STOCK_VALUE", "재고 수량은 비어있을 수 없습니다."),
+    NEGATIVE_STOCK_VALUE(HttpStatus.BAD_REQUEST, "NEGATIVE_STOCK_VALUE", "재고 수량은 음수일 수 없습니다."),
+    INVALID_STOCK_INCREASE_AMOUNT(HttpStatus.BAD_REQUEST, "INVALID_STOCK_INCREASE_AMOUNT", "증가 수량은 0 이상이어야 합니다."),
+    INVALID_STOCK_DECREASE_AMOUNT(HttpStatus.BAD_REQUEST, "INVALID_STOCK_DECREASE_AMOUNT", "감소 수량은 0 이상이어야 합니다."),
+    INSUFFICIENT_STOCK(HttpStatus.BAD_REQUEST, "INSUFFICIENT_STOCK", "재고가 부족합니다."),
+
+    /** Brand 도메인 에러 */
+    INVALID_BRAND_NAME_EMPTY(HttpStatus.BAD_REQUEST, "INVALID_BRAND_NAME_EMPTY", "브랜드명은 비어있을 수 없습니다."),
+    INVALID_BRAND_NAME_LENGTH(HttpStatus.BAD_REQUEST, "INVALID_BRAND_NAME_LENGTH", "브랜드명은 50자 이내여야 합니다."),
+
+    /** Product 도메인 에러 */
+    INVALID_PRODUCT_NAME_EMPTY(HttpStatus.BAD_REQUEST, "INVALID_PRODUCT_NAME_EMPTY", "상품명은 비어있을 수 없습니다."),
+    INVALID_PRODUCT_NAME_LENGTH(HttpStatus.BAD_REQUEST, "INVALID_PRODUCT_NAME_LENGTH", "상품명은 100자 이내여야 합니다."),
+    INVALID_PRODUCT_PRICE_EMPTY(HttpStatus.BAD_REQUEST, "INVALID_PRODUCT_PRICE_EMPTY", "가격은 비어있을 수 없습니다."),
+    INVALID_PRODUCT_STOCK_EMPTY(HttpStatus.BAD_REQUEST, "INVALID_PRODUCT_STOCK_EMPTY", "재고는 비어있을 수 없습니다."),
+    INVALID_PRODUCT_BRAND_EMPTY(HttpStatus.BAD_REQUEST, "INVALID_PRODUCT_BRAND_EMPTY", "브랜드는 비어있을 수 없습니다."),
+
+    /** ProductLike 도메인 에러 */
+    INVALID_PRODUCT_LIKE_USER_EMPTY(HttpStatus.BAD_REQUEST, "INVALID_PRODUCT_LIKE_USER_EMPTY", "사용자는 비어있을 수 없습니다."),
+    INVALID_PRODUCT_LIKE_PRODUCT_EMPTY(HttpStatus.BAD_REQUEST, "INVALID_PRODUCT_LIKE_PRODUCT_EMPTY", "상품은 비어있을 수 없습니다."),
+
+    /** ProductDetail 도메인 에러 */
+    INVALID_PRODUCT_DETAIL_PRODUCT_EMPTY(HttpStatus.BAD_REQUEST, "INVALID_PRODUCT_DETAIL_PRODUCT_EMPTY", "상품 정보는 필수입니다."),
+    INVALID_PRODUCT_DETAIL_BRAND_EMPTY(HttpStatus.BAD_REQUEST, "INVALID_PRODUCT_DETAIL_BRAND_EMPTY", "브랜드 정보는 필수입니다."),
+
+    /** Quantity 도메인 에러 */
+    INVALID_QUANTITY_VALUE(HttpStatus.BAD_REQUEST, "INVALID_QUANTITY_VALUE", "수량은 비어있을 수 없습니다."),
+    INVALID_QUANTITY_RANGE(HttpStatus.BAD_REQUEST, "INVALID_QUANTITY_RANGE", "수량은 0 이상이어야 합니다."),
+
+    /** OrderItem 도메인 에러 */
+    INVALID_ORDER_ITEM_ORDER_EMPTY(HttpStatus.BAD_REQUEST, "INVALID_ORDER_ITEM_ORDER_EMPTY", "주문은 비어있을 수 없습니다."),
+    INVALID_ORDER_ITEM_PRODUCT_EMPTY(HttpStatus.BAD_REQUEST, "INVALID_ORDER_ITEM_PRODUCT_EMPTY", "상품은 비어있을 수 없습니다."),
+    INVALID_ORDER_ITEM_PRODUCT_NAME_EMPTY(HttpStatus.BAD_REQUEST, "INVALID_ORDER_ITEM_PRODUCT_NAME_EMPTY", "상품명은 비어있을 수 없습니다."),
+    INVALID_ORDER_ITEM_PRODUCT_NAME_LENGTH(HttpStatus.BAD_REQUEST, "INVALID_ORDER_ITEM_PRODUCT_NAME_LENGTH", "상품명은 100자 이내여야 합니다."),
+    INVALID_ORDER_ITEM_QUANTITY_EMPTY(HttpStatus.BAD_REQUEST, "INVALID_ORDER_ITEM_QUANTITY_EMPTY", "수량은 비어있을 수 없습니다."),
+
+    /** OrderPrice 도메인 에러 */
+    INVALID_ORDER_PRICE_VALUE(HttpStatus.BAD_REQUEST, "INVALID_ORDER_PRICE_VALUE", "주문 가격은 비어있을 수 없습니다."),
+    NEGATIVE_ORDER_PRICE_VALUE(HttpStatus.BAD_REQUEST, "NEGATIVE_ORDER_PRICE_VALUE", "주문 가격은 음수일 수 없습니다."),
+
+    /** Order 도메인 에러 */
+    INVALID_ORDER_USER_EMPTY(HttpStatus.BAD_REQUEST, "INVALID_ORDER_USER_EMPTY", "사용자는 비어있을 수 없습니다."),
+    INVALID_ORDER_STATUS_EMPTY(HttpStatus.BAD_REQUEST, "INVALID_ORDER_STATUS_EMPTY", "주문 상태는 비어있을 수 없습니다."),
+    INVALID_ORDER_TOTAL_AMOUNT_EMPTY(HttpStatus.BAD_REQUEST, "INVALID_ORDER_TOTAL_AMOUNT_EMPTY", "주문 총액은 비어있을 수 없습니다."),
+    NEGATIVE_ORDER_TOTAL_AMOUNT(HttpStatus.BAD_REQUEST, "NEGATIVE_ORDER_TOTAL_AMOUNT", "주문 총액은 음수일 수 없습니다."),
+    INVALID_ORDER_ORDERED_AT_EMPTY(HttpStatus.BAD_REQUEST, "INVALID_ORDER_ORDERED_AT_EMPTY", "주문 시각은 비어있을 수 없습니다.");
 
     private final HttpStatus status;
     private final String code;

From 105801958d6e27aa791c604a3e0d8cacf778118a Mon Sep 17 00:00:00 2001
From: green <green@mobidoc.us>
Date: Fri, 14 Nov 2025 14:01:07 +0900
Subject: [PATCH 10/21] =?UTF-8?q?fix:=20Value=20Objects=20=EA=B0=9C?=
 =?UTF-8?q?=EC=84=A0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- Stock 재고 관리 로직 개선
- 테스트 코드 보완 (MoneyTest, StockTest, QuantityTest)
---
 .../java/com/loopers/domain/stock/Stock.java  |   2 +-
 .../com/loopers/domain/money/MoneyTest.java   |  36 ++--
 .../loopers/domain/quantity/QuantityTest.java |  75 +++-----
 .../com/loopers/domain/stock/StockTest.java   | 170 +++++++++++-------
 4 files changed, 146 insertions(+), 137 deletions(-)

diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/stock/Stock.java b/apps/commerce-api/src/main/java/com/loopers/domain/stock/Stock.java
index 1cc2815da..bc91b111b 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/stock/Stock.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/stock/Stock.java
@@ -56,4 +56,4 @@ public boolean isAvailable() {
   public Long getValue() {
     return value;
   }
-}
\ No newline at end of file
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/money/MoneyTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/money/MoneyTest.java
index b989a19db..94c3cf8b1 100644
--- a/apps/commerce-api/src/test/java/com/loopers/domain/money/MoneyTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/money/MoneyTest.java
@@ -5,38 +5,19 @@
 import org.junit.jupiter.api.DisplayName;
 import org.junit.jupiter.api.Nested;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
-@DisplayName("Money 도메인 테스트")
+@DisplayName("Money VO 테스트")
 class MoneyTest {
 
   @DisplayName("Money를 생성할 때")
   @Nested
   class Create {
 
-    @DisplayName("올바른 금액으로 생성하면 성공한다")
-    @Test
-    void shouldCreate_whenValid() {
-      Money money = Money.of(10000L);
-
-      assertThat(money).extracting("value").isEqualTo(10000L);
-    }
-
-    @DisplayName("0원으로 생성할 수 있다")
-    @Test
-    void shouldCreate_whenZero() {
-      Money zero = Money.zero();
-
-      assertThat(zero).extracting("value").isEqualTo(0L);
-    }
-  }
-
-  @DisplayName("value 검증")
-  @Nested
-  class ValidateValue {
-
     @DisplayName("null이면 예외가 발생한다")
     @Test
     void shouldThrowException_whenNull() {
@@ -54,9 +35,18 @@ void shouldThrowException_whenNegative() {
           .hasMessage("금액은 음수일 수 없습니다.")
           .extracting("errorType").isEqualTo(ErrorType.NEGATIVE_MONEY_VALUE);
     }
+
+    @DisplayName("0원 이상으로 금액을 생성할 수 있다")
+    @ParameterizedTest
+    @ValueSource(longs = {0L, 10000L, 100000L})
+    void shouldCreate_whenValidValue(Long value) {
+      Money money = Money.of(value);
+      assertThat(money).extracting("value").isEqualTo(value);
+    }
+
   }
 
-  @DisplayName("동등성")
+  @DisplayName("두 객체의 동등성 비교할 때")
   @Nested
   class Equality {
 
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/quantity/QuantityTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/quantity/QuantityTest.java
index 12672ea9f..5de381c64 100644
--- a/apps/commerce-api/src/test/java/com/loopers/domain/quantity/QuantityTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/quantity/QuantityTest.java
@@ -5,6 +5,8 @@
 import org.junit.jupiter.api.DisplayName;
 import org.junit.jupiter.api.Nested;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -15,36 +17,6 @@ class QuantityTest {
   @DisplayName("Quantity를 생성할 때")
   @Nested
   class Create {
-
-    @DisplayName("0 이상의 값으로 생성하면 성공한다")
-    @Test
-    void shouldCreate_whenValidValue() {
-      Quantity quantity = Quantity.of(0);
-
-      assertThat(quantity.getValue()).isEqualTo(0);
-    }
-
-    @DisplayName("1로 생성하면 성공한다")
-    @Test
-    void shouldCreate_whenOne() {
-      Quantity quantity = Quantity.of(1);
-
-      assertThat(quantity.getValue()).isEqualTo(1);
-    }
-
-    @DisplayName("큰 값으로 생성하면 성공한다")
-    @Test
-    void shouldCreate_whenLargeValue() {
-      Quantity quantity = Quantity.of(999);
-
-      assertThat(quantity.getValue()).isEqualTo(999);
-    }
-  }
-
-  @DisplayName("값 검증")
-  @Nested
-  class Validation {
-
     @DisplayName("null이면 예외가 발생한다")
     @Test
     void shouldThrowException_whenNull() {
@@ -62,28 +34,37 @@ void shouldThrowException_whenNegative() {
           .hasMessage("수량은 0 이상이어야 합니다.")
           .extracting("errorType").isEqualTo(ErrorType.INVALID_QUANTITY_RANGE);
     }
+
+    @DisplayName("0 이상의 값으로 생성하면 성공한다")
+    @ParameterizedTest
+    @ValueSource(ints = {0, 1, 10, 100, 999})
+    void shouldCreate_whenValidValue(Integer value) {
+      Quantity quantity = Quantity.of(value);
+
+      assertThat(quantity.getValue()).isEqualTo(value);
+    }
   }
+}
 
-  @DisplayName("동등성 비교")
-  @Nested
-  class Equality {
+@DisplayName("두 객체의 동등성을 비교 할 때")
+@Nested
+class Equality {
 
-    @DisplayName("같은 값이면 동등하다")
-    @Test
-    void shouldBeEqual_whenSameValue() {
-      Quantity quantity1 = Quantity.of(5);
-      Quantity quantity2 = Quantity.of(5);
+  @DisplayName("같은 값이면 동등하다")
+  @Test
+  void shouldBeEqual_whenSameValue() {
+    Quantity quantity1 = Quantity.of(5);
+    Quantity quantity2 = Quantity.of(5);
 
-      assertThat(quantity1).isEqualTo(quantity2);
-    }
+    assertThat(quantity1).isEqualTo(quantity2);
+  }
 
-    @DisplayName("다른 값이면 동등하지 않다")
-    @Test
-    void shouldNotBeEqual_whenDifferentValue() {
-      Quantity quantity1 = Quantity.of(5);
-      Quantity quantity2 = Quantity.of(10);
+  @DisplayName("다른 값이면 동등하지 않다")
+  @Test
+  void shouldNotBeEqual_whenDifferentValue() {
+    Quantity quantity1 = Quantity.of(5);
+    Quantity quantity2 = Quantity.of(10);
 
-      assertThat(quantity1).isNotEqualTo(quantity2);
-    }
+    assertThat(quantity1).isNotEqualTo(quantity2);
   }
 }
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockTest.java
index 732ec207a..338b30aa1 100644
--- a/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/stock/StockTest.java
@@ -2,101 +2,139 @@
 
 import com.loopers.support.error.CoreException;
 import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
+@DisplayName("Stock VO 테스트")
 class StockTest {
 
-  @Test
-  @DisplayName("음수 재고로 Stock을 생성할 수 없다")
-  void createStockWithNegative() {
-    assertThatThrownBy(() -> Stock.of(-10L))
-        .isInstanceOf(CoreException.class)
-        .hasMessage("재고 수량은 음수일 수 없습니다.");
-  }
+  @DisplayName("Stock을 생성할 때")
+  @Nested
+  class Create {
+
+    @DisplayName("음수 재고이면 예외가 발생한다")
+    @Test
+    void shouldThrowException_whenNegative() {
+      assertThatThrownBy(() -> Stock.of(-10L))
+          .isInstanceOf(CoreException.class)
+          .hasMessage("재고 수량은 음수일 수 없습니다.");
+    }
+
+    @DisplayName("0 이상의 재고로 생성할 수 있다")
+    @ParameterizedTest
+    @ValueSource(longs = {0L, 1000L, 10000L})
+    void shouldCreate_whenValidValue(Long value) {
+      Stock zero = Stock.of(value);
+      assertThat(zero).extracting("value").isEqualTo(value);
+    }
 
-  @Test
-  @DisplayName("0 재고로 Stock을 생성할 수 있다")
-  void createZeroStock() {
-    Stock zero = Stock.zero();
 
-    assertThat(zero).extracting("value").isEqualTo(0L);
   }
 
-  @Test
-  @DisplayName("재고를 증가할 수 있다")
-  void increaseStock() {
-    Stock stock = Stock.of(100L);
+  @DisplayName("재고를 증가할 때")
+  @Nested
+  class Increase {
 
-    Stock result = stock.increase(50L);
+    @DisplayName("음수 수량으로 재고를 증가시키면 예외가 발생한다")
+    @Test
+    void shouldThrowException_whenNegativeAmount() {
+      Stock stock = Stock.of(100L);
 
-    assertThat(result).extracting("value").isEqualTo(150L);
-  }
+      assertThatThrownBy(() -> stock.increase(-10L))
+          .isInstanceOf(CoreException.class)
+          .hasMessage("증가 수량은 0 이상이어야 합니다.");
+    }
 
-  @Test
-  @DisplayName("재고를 감소할 수 있다")
-  void decreaseStock() {
-    Stock stock = Stock.of(100L);
+    @DisplayName("0이상의 수량으로 재고를 증가할 수 있다")
+    @ParameterizedTest
+    @ValueSource(longs = {0L, 1000L, 10000L})
+    void shouldIncrease_whenPositiveAmount(Long value) {
+      Stock stock = Stock.of(0L);
 
-    Stock result = stock.decrease(30L);
+      Stock result = stock.increase(value);
+
+      assertThat(result).extracting("value").isEqualTo(value);
+    }
 
-    assertThat(result).extracting("value").isEqualTo(70L);
   }
 
-  @Test
-  @DisplayName("재고가 부족하면 감소할 수 없다")
-  void decreaseInsufficientStock() {
-    Stock stock = Stock.of(50L);
+  @DisplayName("재고를 감소할 때")
+  @Nested
+  class Decrease {
 
-    assertThatThrownBy(() -> stock.decrease(100L))
-        .isInstanceOf(CoreException.class)
-        .hasMessage("재고가 부족합니다.");
-  }
+    @DisplayName("음수 수량으로 재고를 감소시키면 예외가 발생한다")
+    @Test
+    void shouldThrowException_whenNegativeAmount() {
+      Stock stock = Stock.of(100L);
 
-  @Test
-  @DisplayName("음수 수량으로 증가할 수 없다")
-  void increaseWithNegativeAmount() {
-    Stock stock = Stock.of(100L);
+      assertThatThrownBy(() -> stock.decrease(-10L))
+          .isInstanceOf(CoreException.class)
+          .hasMessage("감소 수량은 0 이상이어야 합니다.");
+    }
 
-    assertThatThrownBy(() -> stock.increase(-10L))
-        .isInstanceOf(CoreException.class)
-        .hasMessage("증가 수량은 0 이상이어야 합니다.");
-  }
+    @DisplayName("재고가 부족하면 예외가 발생한다")
+    @Test
+    void shouldThrowException_whenInsufficientStock() {
+      Stock stock = Stock.of(50L);
 
-  @Test
-  @DisplayName("음수 수량으로 감소할 수 없다")
-  void decreaseWithNegativeAmount() {
-    Stock stock = Stock.of(100L);
+      assertThatThrownBy(() -> stock.decrease(51L))
+          .isInstanceOf(CoreException.class)
+          .hasMessage("재고가 부족합니다.");
+    }
 
-    assertThatThrownBy(() -> stock.decrease(-10L))
-        .isInstanceOf(CoreException.class)
-        .hasMessage("감소 수량은 0 이상이어야 합니다.");
-  }
+    @DisplayName("0개 이상의 재고를 감소할 수 있다")
+    @ParameterizedTest
+    @ValueSource(longs = {0L, 1L, 30L, 49L, 50L})
+    void shouldDecrease_whenSufficientStock(Long decreaseAmount) {
+      Stock stock = Stock.of(50L);
 
-  @Test
-  @DisplayName("같은 수량의 Stock은 동등하다")
-  void stockEquality() {
-    Stock stock1 = Stock.of(100L);
-    Stock stock2 = Stock.of(100L);
+      Stock result = stock.decrease(decreaseAmount);
+
+      assertThat(result).extracting("value").isEqualTo(50L - decreaseAmount);
+    }
 
-    assertThat(stock1).isEqualTo(stock2);
   }
 
-  @Test
-  @DisplayName("재고가 0보다 크면 available하다")
-  void isAvailableWithPositiveStock() {
-    Stock stock = Stock.of(1L);
+  @DisplayName("재고 가용성 확인할 때")
+  @Nested
+  class Availability {
+
+    @DisplayName("재고가 0보다 크면 available하다")
+    @Test
+    void shouldBeAvailable_whenPositiveStock() {
+      Stock stock = Stock.of(1L);
+
+      assertThat(stock.isAvailable()).isTrue();
+    }
+
+    @DisplayName("재고가 0이면 available하지 않다")
+    @Test
+    void shouldNotBeAvailable_whenZeroStock() {
+      Stock stock = Stock.zero();
+
+      assertThat(stock.isAvailable()).isFalse();
+    }
 
-    assertThat(stock.isAvailable()).isTrue();
   }
 
-  @Test
-  @DisplayName("재고가 0이면 available하지 않다")
-  void isNotAvailableWithZeroStock() {
-    Stock stock = Stock.zero();
+  @DisplayName("두 객체의 동등성 비교할 때")
+  @Nested
+  class Equality {
+
+    @DisplayName("같은 수량의 Stock은 동등하다")
+    @Test
+    void shouldBeEqual_whenSameValue() {
+      Stock stock1 = Stock.of(100L);
+      Stock stock2 = Stock.of(100L);
+
+      assertThat(stock1).isEqualTo(stock2);
+    }
 
-    assertThat(stock.isAvailable()).isFalse();
   }
-}
\ No newline at end of file
+
+}

From 2637ac66e837059a142d45286ad9b4c992095462 Mon Sep 17 00:00:00 2001
From: green <green@mobidoc.us>
Date: Fri, 14 Nov 2025 14:01:50 +0900
Subject: [PATCH 11/21] =?UTF-8?q?fix:=20Brand=20=EB=8F=84=EB=A9=94?=
 =?UTF-8?q?=EC=9D=B8=20=EA=B0=9C=EC=84=A0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- BrandResponse → BrandResult 네이밍 변경
- Brand 도메인 로직 개선
- BrandFacade, BrandService 개선
- 테스트 코드 보완
---
 .../loopers/application/brand/BrandFacade.java    |  9 ++++++---
 .../{BrandResponse.java => BrandResult.java}      |  6 +++---
 .../main/java/com/loopers/domain/brand/Brand.java |  2 +-
 .../com/loopers/domain/brand/BrandRepository.java |  4 +---
 .../com/loopers/domain/brand/BrandService.java    | 10 +++-------
 .../java/com/loopers/domain/brand/Brands.java     |  4 +++-
 .../application/brand/BrandFacadeTest.java        | 15 ++++++++-------
 7 files changed, 25 insertions(+), 25 deletions(-)
 rename apps/commerce-api/src/main/java/com/loopers/application/brand/{BrandResponse.java => BrandResult.java} (68%)

diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java
index 2aab05e28..4186c20fa 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java
@@ -2,6 +2,8 @@
 
 import com.loopers.domain.brand.Brand;
 import com.loopers.domain.brand.BrandService;
+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;
@@ -13,8 +15,9 @@ public class BrandFacade {
 
   private final BrandService brandService;
 
-  public BrandResponse getBrand(Long brandId) {
-    Brand brand = brandService.getById(brandId);
-    return BrandResponse.from(brand);
+  public BrandResult viewBrand(Long brandId) {
+    Brand brand = brandService.getById(brandId)
+        .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다."));
+    return BrandResult.from(brand);
   }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandResponse.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandResult.java
similarity index 68%
rename from apps/commerce-api/src/main/java/com/loopers/application/brand/BrandResponse.java
rename to apps/commerce-api/src/main/java/com/loopers/application/brand/BrandResult.java
index 634f2d27e..1984fe5db 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandResponse.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandResult.java
@@ -2,14 +2,14 @@
 
 import com.loopers.domain.brand.Brand;
 
-public record BrandResponse(
+public record BrandResult(
     Long brandId,
     String name,
     String description
 ) {
 
-  public static BrandResponse from(Brand brand) {
-    return new BrandResponse(
+  public static BrandResult from(Brand brand) {
+    return new BrandResult(
         brand.getId(),
         brand.getName(),
         brand.getDescription()
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java
index 4ff4d243d..fa8c89bd1 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java
@@ -19,7 +19,7 @@ public class Brand extends BaseEntity {
   @Column(nullable = false, length = MAX_NAME_LENGTH)
   private String name;
 
-  @Column(length = 500)
+  @Column(length = 200)
   private String description;
 
   private Brand(String name, String description) {
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java
index df11b6dca..675d24718 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java
@@ -4,11 +4,9 @@
 import java.util.Optional;
 
 public interface BrandRepository {
-
-
+  
   Optional<Brand> findById(Long id);
 
-
   List<Brand> findByIdIn(List<Long> ids);
 
   Brand save(Brand brand);
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java
index b713cefaa..adce57ab7 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java
@@ -1,25 +1,21 @@
 package com.loopers.domain.brand;
 
-import com.loopers.support.error.CoreException;
-import com.loopers.support.error.ErrorType;
 import lombok.RequiredArgsConstructor;
 import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Transactional;
 
 import java.util.List;
+import java.util.Optional;
 
 
 @Service
 @RequiredArgsConstructor
-@Transactional(readOnly = true)
 public class BrandService {
 
   private final BrandRepository brandRepository;
 
 
-  public Brand getById(Long brandId) {
-    return brandRepository.findById(brandId)
-        .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다."));
+  public Optional<Brand> getById(Long brandId) {
+    return brandRepository.findById(brandId);
   }
 
 
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brands.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brands.java
index bb6aeb66e..f3451026b 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brands.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brands.java
@@ -1,5 +1,7 @@
 package com.loopers.domain.brand;
 
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
 import java.util.ArrayList;
 import java.util.List;
 
@@ -19,7 +21,7 @@ public Brand getBrandById(Long brandId) {
     return values.stream()
         .filter(brand -> brand.isSameId(brandId))
         .findFirst()
-        .orElse(null);
+        .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다."));
   }
 
   public List<Brand> toList() {
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java
index 7e1b417d1..b8885f896 100644
--- a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java
@@ -11,6 +11,8 @@
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
 
+import java.util.Optional;
+
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.mockito.BDDMockito.given;
@@ -27,15 +29,15 @@ class BrandFacadeTest {
 
   @Test
   @DisplayName("브랜드 ID로 브랜드 조회 성공")
-  void getBrand_success() {
+  void viewBrand_success() {
     // given
     Long brandId = 1L;
     Brand brand = Brand.of("나이키", "스포츠 브랜드");
 
-    given(brandService.getById(brandId)).willReturn(brand);
+    given(brandService.getById(brandId)).willReturn(Optional.of(brand));
 
     // when
-    BrandResponse result = sut.getBrand(brandId);
+    BrandResult result = sut.viewBrand(brandId);
 
     // then
     assertThat(result)
@@ -45,15 +47,14 @@ void getBrand_success() {
 
   @Test
   @DisplayName("존재하지 않는 브랜드 ID 조회 시 CoreException 발생")
-  void getBrand_notFound() {
+  void viewBrand_notFound() {
     // given
     Long brandId = 999L;
 
-    given(brandService.getById(brandId))
-        .willThrow(new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다."));
+    given(brandService.getById(brandId)).willReturn(Optional.empty());
 
     // when & then
-    assertThatThrownBy(() -> sut.getBrand(brandId))
+    assertThatThrownBy(() -> sut.viewBrand(brandId))
         .isInstanceOf(CoreException.class)
         .hasMessageContaining("브랜드를 찾을 수 없습니다.");
   }

From 0010968a1919ee6277403fa29c6cd906697ded64 Mon Sep 17 00:00:00 2001
From: green <green@mobidoc.us>
Date: Fri, 14 Nov 2025 14:02:36 +0900
Subject: [PATCH 12/21] =?UTF-8?q?fix:=20Product=20=EB=8F=84=EB=A9=94?=
 =?UTF-8?q?=EC=9D=B8=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

구조 변경:
- ProductDetail을 application 레이어로 이동
- domain/product/detail/ 패키지 제거
- ProductDetailResponse → ProductDetailResult 네이밍 변경
- ProductListResponse → ProductDetailListResult 네이밍 변경

도메인 개선:
- Product 도메인 로직 개선
- ProductService 추가
- ProductFacade 개선

테스트:
- ProductServiceTest 추가
- ProductFacadeIntegrationTest 보완
- 불필요한 테스트 제거 (ProductDetailDomainServiceTest, ProductDetailsTest)
---
 .../application/product/ProductDetail.java    | 40 ++++++++++
 .../product/ProductDetailResponse.java        | 42 -----------
 .../application/product/ProductFacade.java    | 44 ++++++-----
 .../product/ProductListResponse.java          | 36 ---------
 .../com/loopers/domain/product/Product.java   |  2 +-
 .../domain/product/ProductService.java        | 29 +++----
 .../domain/product/detail/ProductDetail.java  | 75 -------------------
 .../detail/ProductDetailDomainService.java    | 34 ---------
 .../domain/product/detail/ProductDetails.java | 30 --------
 .../product/ProductFacadeIntegrationTest.java | 74 +++++++++---------
 .../ProductDetailDomainServiceTest.java       | 69 -----------------
 .../domain/product/ProductDetailsTest.java    | 46 ------------
 .../domain/product/ProductServiceTest.java    | 71 ++++++++++++++++++
 13 files changed, 181 insertions(+), 411 deletions(-)
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetail.java
 delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailResponse.java
 delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductListResponse.java
 delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/detail/ProductDetail.java
 delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/detail/ProductDetailDomainService.java
 delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/detail/ProductDetails.java
 delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductDetailDomainServiceTest.java
 delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductDetailsTest.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java

diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetail.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetail.java
new file mode 100644
index 000000000..1e19b89c7
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetail.java
@@ -0,0 +1,40 @@
+package com.loopers.application.product;
+
+import com.loopers.domain.brand.Brand;
+import com.loopers.domain.product.Product;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+
+public record ProductDetail(
+    Long productId,
+    String productName,
+    Long price,
+    String description,
+    Long stock,
+    Long brandId,
+    String brandName,
+    Long likeCount,
+    boolean liked
+) {
+
+  public static ProductDetail of(Product product, Brand brand, boolean liked) {
+    if (product == null) {
+      throw new CoreException(ErrorType.INVALID_PRODUCT_DETAIL_PRODUCT_EMPTY, "상품 정보는 필수입니다.");
+    }
+    if (brand == null) {
+      throw new CoreException(ErrorType.INVALID_PRODUCT_DETAIL_BRAND_EMPTY, "브랜드 정보는 필수입니다.");
+    }
+
+    return new ProductDetail(
+        product.getId(),
+        product.getName(),
+        product.getPriceValue(),
+        product.getDescription(),
+        product.getStockValue(),
+        brand.getId(),
+        brand.getName(),
+        product.getLikeCount(),
+        liked
+    );
+  }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailResponse.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailResponse.java
deleted file mode 100644
index bd122f1cf..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailResponse.java
+++ /dev/null
@@ -1,42 +0,0 @@
-package com.loopers.application.product;
-
-import com.loopers.domain.product.Product;
-import com.loopers.domain.product.detail.ProductDetail;
-
-public record ProductDetailResponse(
-    Long id,
-    String name,
-    Long price,
-    String description,
-    Long stock,
-    BrandSummary brand,
-    Long likeCount,
-    boolean isLiked
-) {
-
-  public static ProductDetailResponse of(Product product, BrandSummary brandSummary, boolean isLiked) {
-    return new ProductDetailResponse(
-        product.getId(),
-        product.getName(),
-        product.getPriceValue(),
-        product.getDescription(),
-        product.getStockValue(),
-        brandSummary,
-        product.getLikeCount(),
-        isLiked
-    );
-  }
-
-  public static ProductDetailResponse from(ProductDetail productDetail) {
-    return new ProductDetailResponse(
-        productDetail.getProductId(),
-        productDetail.getProductName(),
-        productDetail.getPrice(),
-        productDetail.getDescription(),
-        productDetail.getStockValue(),
-        new BrandSummary(productDetail.getBrandId(), productDetail.getBrandName()),
-        productDetail.getLikeCount(),
-        productDetail.isLiked()
-    );
-  }
-}
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
index f10aec869..fbdfc8aa1 100644
--- 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
@@ -4,13 +4,14 @@
 import com.loopers.domain.brand.BrandService;
 import com.loopers.domain.brand.Brands;
 import com.loopers.domain.product.Product;
-import com.loopers.domain.product.detail.ProductDetail;
-import com.loopers.domain.product.detail.ProductDetailDomainService;
-import com.loopers.domain.product.detail.ProductDetails;
 import com.loopers.domain.product.ProductService;
 import com.loopers.domain.product.Products;
 import com.loopers.domain.productlike.ProductLikeService;
 import com.loopers.domain.productlike.ProductLikeStatuses;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import java.util.Map;
+import java.util.stream.Collectors;
 import lombok.RequiredArgsConstructor;
 import org.springframework.data.domain.Page;
 import org.springframework.data.domain.Pageable;
@@ -23,12 +24,9 @@ public class ProductFacade {
   private final ProductService productService;
   private final BrandService brandService;
   private final ProductLikeService productLikeService;
-  private final ProductDetailDomainService productDetailDomainService;
 
-  public Page<ProductListResponse> getProducts(Long brandId, Long userId, Pageable pageable) {
-    Page<Product> productPage = brandId != null
-        ? productService.findByBrandId(brandId, pageable)
-        : productService.findAll(pageable);
+  public Page<ProductDetail> searchProductDetails(Long brandId, Long userId, Pageable pageable) {
+    Page<Product> productPage = productService.findProducts(brandId, pageable);
 
     Products products = Products.from(productPage.getContent());
     Brands brands = Brands.from(brandService.findByIdIn(products.getBrandIds()));
@@ -36,20 +34,26 @@ public Page<ProductListResponse> getProducts(Long brandId, Long userId, Pageable
         ? productLikeService.findLikeStatusByUser(userId, products.getProductIds())
         : ProductLikeStatuses.empty();
 
-    ProductDetails productDetails = productDetailDomainService.create(products, brands, likeStatuses);
-
-    return productPage.map(product ->
-        ProductListResponse.from(productDetails.get(product.getId()))
-    );
+    Map<Long, ProductDetail> resultMap = products.toList().stream()
+        .collect(Collectors.toMap(
+            Product::getId,
+            product -> ProductDetail.of(
+                product,
+                brands.getBrandById(product.getBrandId()),
+                likeStatuses.isLiked(product.getId())
+            )
+        ));
+
+    return productPage.map(product -> resultMap.get(product.getId()));
   }
 
-  public ProductDetailResponse getProduct(Long productId, Long userId) {
-    Product product = productService.getById(productId);
-    Brand brand = brandService.getById(product.getBrandId());
-    boolean isLiked = productLikeService.isLiked(userId, productId);
-
-    ProductDetail productDetail = productDetailDomainService.get(product, brand, isLiked);
+  public ProductDetail viewProductDetail(Long productId, Long userId) {
+    Product product = productService.getById(productId)
+        .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
+    Brand brand = brandService.getById(product.getBrandId())
+        .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다."));
+    boolean isLiked = userId != null && productLikeService.isLiked(userId, productId);
 
-    return ProductDetailResponse.from(productDetail);
+    return ProductDetail.of(product, brand, isLiked);
   }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductListResponse.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductListResponse.java
deleted file mode 100644
index 301bd51ab..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductListResponse.java
+++ /dev/null
@@ -1,36 +0,0 @@
-package com.loopers.application.product;
-
-import com.loopers.domain.product.Product;
-import com.loopers.domain.product.detail.ProductDetail;
-
-public record ProductListResponse(
-    Long id,
-    String name,
-    Long price,
-    BrandSummary brand,
-    Long likeCount,
-    boolean isLiked
-) {
-
-  public static ProductListResponse of(Product product, BrandSummary brandSummary, boolean isLiked) {
-    return new ProductListResponse(
-        product.getId(),
-        product.getName(),
-        product.getPriceValue(),
-        brandSummary,
-        product.getLikeCount(),
-        isLiked
-    );
-  }
-
-  public static ProductListResponse from(ProductDetail productDetail) {
-    return new ProductListResponse(
-        productDetail.getProductId(),
-        productDetail.getProductName(),
-        productDetail.getPrice(),
-        new BrandSummary(productDetail.getBrandId(), productDetail.getBrandName()),
-        productDetail.getLikeCount(),
-        productDetail.isLiked()
-    );
-  }
-}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java
index a079e501e..d0eab37ce 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java
@@ -29,7 +29,7 @@ public class Product extends BaseEntity {
   @AttributeOverride(name = "value", column = @Column(name = "price"))
   private Money price;
 
-  @Column(length = 1000)
+  @Column(length = 200)
   private String description;
 
   @Embedded
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
index 8501a8b27..9d2c5ccba 100644
--- 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
@@ -1,42 +1,33 @@
 package com.loopers.domain.product;
 
-import com.loopers.support.error.CoreException;
-import com.loopers.support.error.ErrorType;
+import jakarta.transaction.Transactional;
+import java.util.List;
+import java.util.Optional;
 import lombok.RequiredArgsConstructor;
 import org.springframework.data.domain.Page;
 import org.springframework.data.domain.Pageable;
 import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Transactional;
-
-import java.util.List;
 
 @Service
 @RequiredArgsConstructor
-@Transactional(readOnly = true)
 public class ProductService {
 
   private final ProductRepository productRepository;
 
-  public Product getById(Long productId) {
-    return productRepository.findById(productId)
-        .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
-  }
-
-  public List<Product> getByIds(List<Long> productIds) {
-    List<Long> distinctIds = productIds.stream().distinct().toList();
-    return productRepository.findAllById(distinctIds);
+  public Optional<Product> getById(Long productId) {
+    return productRepository.findById(productId);
   }
 
+  @Transactional
   public List<Product> getByIdsWithLock(List<Long> productIds) {
     List<Long> distinctIds = productIds.stream().distinct().toList();
     return productRepository.findAllByIdWithLock(distinctIds);
   }
 
-  public Page<Product> findByBrandId(Long brandId, Pageable pageable) {
-    return productRepository.findByBrandId(brandId, pageable);
+  public Page<Product> findProducts(Long brandId, Pageable pageable) {
+    return brandId != null
+        ? productRepository.findByBrandId(brandId, pageable)
+        : productRepository.findAll(pageable);
   }
 
-  public Page<Product> findAll(Pageable pageable) {
-    return productRepository.findAll(pageable);
-  }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/detail/ProductDetail.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/detail/ProductDetail.java
deleted file mode 100644
index 8a530d327..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/domain/product/detail/ProductDetail.java
+++ /dev/null
@@ -1,75 +0,0 @@
-package com.loopers.domain.product.detail;
-
-import com.loopers.domain.brand.Brand;
-import com.loopers.domain.product.Product;
-import com.loopers.support.error.CoreException;
-import com.loopers.support.error.ErrorType;
-import lombok.Getter;
-
-/**
- * Product와 Brand, ProductLike 정보를 조합한 도메인 객체
- */
-public class ProductDetail {
-
-  private final Product product;
-  private final Brand brand;
-  @Getter
-  private final boolean isLiked;
-
-  private ProductDetail(Product product, Brand brand, boolean isLiked) {
-    validateProduct(product);
-    validateBrand(brand);
-
-    this.product = product;
-    this.brand = brand;
-    this.isLiked = isLiked;
-  }
-
-  public static ProductDetail of(Product product, Brand brand, boolean isLiked) {
-    return new ProductDetail(product, brand, isLiked);
-  }
-
-  private void validateProduct(Product product) {
-    if (product == null) {
-      throw new CoreException(ErrorType.INVALID_PRODUCT_DETAIL_PRODUCT_EMPTY, "상품 정보는 필수입니다.");
-    }
-  }
-
-  private void validateBrand(Brand brand) {
-    if (brand == null) {
-      throw new CoreException(ErrorType.INVALID_PRODUCT_DETAIL_BRAND_EMPTY, "브랜드 정보는 필수입니다.");
-    }
-  }
-
-  public Long getProductId() {
-    return product.getId();
-  }
-
-  public String getProductName() {
-    return product.getName();
-  }
-
-  public Long getPrice() {
-    return product.getPriceValue();
-  }
-
-  public String getDescription() {
-    return product.getDescription();
-  }
-
-  public Long getStockValue() {
-    return product.getStockValue();
-  }
-
-  public String getBrandName() {
-    return brand.getName();
-  }
-
-  public Long getBrandId() {
-    return brand.getId();
-  }
-
-  public Long getLikeCount() {
-    return product.getLikeCount();
-  }
-}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/detail/ProductDetailDomainService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/detail/ProductDetailDomainService.java
deleted file mode 100644
index 7f73611c2..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/domain/product/detail/ProductDetailDomainService.java
+++ /dev/null
@@ -1,34 +0,0 @@
-package com.loopers.domain.product.detail;
-
-import com.loopers.domain.brand.Brand;
-import com.loopers.domain.brand.Brands;
-import com.loopers.domain.product.Product;
-import com.loopers.domain.product.Products;
-import com.loopers.domain.productlike.ProductLikeStatuses;
-import org.springframework.stereotype.Component;
-
-import java.util.List;
-
-@Component
-public class ProductDetailDomainService {
-
-  public ProductDetail get(Product product, Brand brand, boolean isLiked) {
-    return ProductDetail.of(product, brand, isLiked);
-  }
-
-  public ProductDetails create(
-      Products products,
-      Brands brands,
-      ProductLikeStatuses likeStatuses
-  ) {
-    List<ProductDetail> details = products.toList().stream()
-        .map(product -> ProductDetail.of(
-            product,
-            brands.getBrandById(product.getBrandId()),
-            likeStatuses.isLiked(product.getId())
-        ))
-        .toList();
-
-    return ProductDetails.from(details);
-  }
-}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/detail/ProductDetails.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/detail/ProductDetails.java
deleted file mode 100644
index 21f2e992d..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/domain/product/detail/ProductDetails.java
+++ /dev/null
@@ -1,30 +0,0 @@
-package com.loopers.domain.product.detail;
-
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Map;
-import java.util.stream.Collectors;
-
-public class ProductDetails {
-
-  private final List<ProductDetail> values;
-  private final Map<Long, ProductDetail> detailMap;
-
-  private ProductDetails(List<ProductDetail> values) {
-    this.values = values;
-    this.detailMap = values.stream()
-        .collect(Collectors.toMap(ProductDetail::getProductId, pd -> pd));
-  }
-
-  public static ProductDetails from(List<ProductDetail> details) {
-    return new ProductDetails(new ArrayList<>(details));
-  }
-
-  public ProductDetail get(Long productId) {
-    return detailMap.get(productId);
-  }
-
-  public List<ProductDetail> toList() {
-    return List.copyOf(values);
-  }
-}
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeIntegrationTest.java
index 3bdb4d9f2..33959ad3b 100644
--- a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeIntegrationTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeIntegrationTest.java
@@ -70,11 +70,11 @@ void getsAllProducts_withoutBrandFilter() {
 
       Pageable pageable = PageRequest.of(0, 20);
 
-      Page<ProductListResponse> result = productFacade.getProducts(null, null, pageable);
+      Page<ProductDetail> result = productFacade.searchProductDetails(null, null, pageable);
 
       assertThat(result.getContent()).hasSize(2);
       assertThat(result.getContent())
-          .extracting("name", "price")
+          .extracting("productName", "price")
           .containsExactlyInAnyOrder(
               tuple("상품1", 10000L),
               tuple("상품2", 20000L)
@@ -99,11 +99,11 @@ void getsProducts_withBrandFilter() {
 
       Pageable pageable = PageRequest.of(0, 20);
 
-      Page<ProductListResponse> result = productFacade.getProducts(brand1.getId(), null, pageable);
+      Page<ProductDetail> result = productFacade.searchProductDetails(brand1.getId(), null, pageable);
 
       assertThat(result.getContent()).hasSize(2);
       assertThat(result.getContent())
-          .extracting("name", "price")
+          .extracting("productName", "price")
           .containsExactlyInAnyOrder(
               tuple("상품1", 10000L),
               tuple("상품2", 20000L)
@@ -128,10 +128,10 @@ void includesLikeStatus_whenUserLikesProduct() {
 
       Pageable pageable = PageRequest.of(0, 20);
 
-      Page<ProductListResponse> result = productFacade.getProducts(null, userId, pageable);
+      Page<ProductDetail> result = productFacade.searchProductDetails(null, userId, pageable);
 
       assertThat(result.getContent())
-          .extracting("name", "isLiked")
+          .extracting("productName", "liked")
           .containsExactlyInAnyOrder(
               tuple("상품1", true),
               tuple("상품2", false)
@@ -148,10 +148,10 @@ void returnsAllLikedFalse_whenUserIdIsNull() {
 
       Pageable pageable = PageRequest.of(0, 20);
 
-      Page<ProductListResponse> result = productFacade.getProducts(null, null, pageable);
+      Page<ProductDetail> result = productFacade.searchProductDetails(null, null, pageable);
 
       assertThat(result.getContent())
-          .extracting("isLiked")
+          .extracting("liked")
           .containsOnly(false);
     }
 
@@ -167,7 +167,7 @@ void supportsPagination() {
 
       Pageable pageable = PageRequest.of(0, 2);
 
-      Page<ProductListResponse> result = productFacade.getProducts(null, null, pageable);
+      Page<ProductDetail> result = productFacade.searchProductDetails(null, null, pageable);
 
       assertThat(result.getContent()).hasSize(2);
       assertThat(result.getTotalElements()).isEqualTo(5);
@@ -179,7 +179,7 @@ void supportsPagination() {
     void returnsEmptyPage_whenNoProducts() {
       Pageable pageable = PageRequest.of(0, 20);
 
-      Page<ProductListResponse> result = productFacade.getProducts(null, null, pageable);
+      Page<ProductDetail> result = productFacade.searchProductDetails(null, null, pageable);
 
       assertThat(result.getContent()).isEmpty();
       assertThat(result.getTotalElements()).isZero();
@@ -191,7 +191,7 @@ void returnsEmptyPage_whenBrandHasNoProducts() {
       Brand brand = brandRepository.save(Brand.of("빈브랜드", "상품없음"));
 
       Pageable pageable = PageRequest.of(0, 20);
-      Page<ProductListResponse> result = productFacade.getProducts(brand.getId(), null, pageable);
+      Page<ProductDetail> result = productFacade.searchProductDetails(brand.getId(), null, pageable);
 
       assertThat(result.getContent()).isEmpty();
     }
@@ -208,7 +208,7 @@ void getsLastPage() {
 
       Pageable pageable = PageRequest.of(2, 2);
 
-      Page<ProductListResponse> result = productFacade.getProducts(null, null, pageable);
+      Page<ProductDetail> result = productFacade.searchProductDetails(null, null, pageable);
 
       assertThat(result.getContent()).hasSize(1);
       assertThat(result.isLast()).isTrue();
@@ -224,7 +224,7 @@ void returnsEmptyPage_whenPageExceedsRange() {
 
       Pageable pageable = PageRequest.of(10, 20);
 
-      Page<ProductListResponse> result = productFacade.getProducts(null, null, pageable);
+      Page<ProductDetail> result = productFacade.searchProductDetails(null, null, pageable);
 
       assertThat(result.getContent()).isEmpty();
       assertThat(result.getTotalElements()).isEqualTo(1);
@@ -247,12 +247,12 @@ void showsCorrectLikeCount_whenMultipleUsersLike() {
       productRepository.save(product);
 
       Pageable pageable = PageRequest.of(0, 20);
-      Page<ProductListResponse> result = productFacade.getProducts(null, 1L, pageable);
+      Page<ProductDetail> result = productFacade.searchProductDetails(null, 1L, pageable);
 
       assertThat(result.getContent())
           .hasSize(1)
           .first()
-          .extracting("likeCount", "isLiked")
+          .extracting("likeCount", "liked")
           .containsExactly(3L, true);
     }
 
@@ -265,12 +265,12 @@ void getsProduct_withZeroLikes() {
       );
 
       Pageable pageable = PageRequest.of(0, 20);
-      Page<ProductListResponse> result = productFacade.getProducts(null, null, pageable);
+      Page<ProductDetail> result = productFacade.searchProductDetails(null, null, pageable);
 
       assertThat(result.getContent())
           .hasSize(1)
           .first()
-          .extracting("likeCount", "isLiked")
+          .extracting("likeCount", "liked")
           .containsExactly(0L, false);
     }
 
@@ -283,12 +283,12 @@ void includesBrandSummary_inResponse() {
       );
 
       Pageable pageable = PageRequest.of(0, 20);
-      Page<ProductListResponse> result = productFacade.getProducts(null, null, pageable);
+      Page<ProductDetail> result = productFacade.searchProductDetails(null, null, pageable);
 
       assertThat(result.getContent())
           .hasSize(1)
           .first()
-          .extracting("brand.id", "brand.name")
+          .extracting("brandId", "brandName")
           .containsExactly(brand.getId(), "나이키");
     }
 
@@ -303,7 +303,7 @@ void returnsEmpty_whenBrandIdDoesNotExist() {
       Long nonExistentBrandId = 999L;
       Pageable pageable = PageRequest.of(0, 20);
 
-      Page<ProductListResponse> result = productFacade.getProducts(nonExistentBrandId, null, pageable);
+      Page<ProductDetail> result = productFacade.searchProductDetails(nonExistentBrandId, null, pageable);
 
       assertThat(result.getContent()).isEmpty();
     }
@@ -321,15 +321,11 @@ void returnsProductWithBrand() {
           Product.of("상품1", Money.of(10000L), "상세설명", Stock.of(100L), brand.getId())
       );
 
-      ProductDetailResponse result = productFacade.getProduct(product.getId(), null);
+      ProductDetail result = productFacade.viewProductDetail(product.getId(), null);
 
       assertThat(result)
-          .extracting("name", "price", "description", "stock")
-          .containsExactly("상품1", 10000L, "상세설명", 100L);
-
-      assertThat(result.brand())
-          .extracting("name")
-          .isEqualTo("브랜드A");
+          .extracting("productName", "price", "description", "stock", "brandName")
+          .containsExactly("상품1", 10000L, "상세설명", 100L, "브랜드A");
     }
 
     @Test
@@ -345,9 +341,9 @@ void returnsIsLikedTrue_whenUserLikesProduct() {
           ProductLike.of(userId, product.getId(), LIKED_AT_2025_10_30)
       );
 
-      ProductDetailResponse result = productFacade.getProduct(product.getId(), userId);
+      ProductDetail result = productFacade.viewProductDetail(product.getId(), userId);
 
-      assertThat(result.isLiked()).isTrue();
+      assertThat(result.liked()).isTrue();
     }
 
     @Test
@@ -360,9 +356,9 @@ void returnsIsLikedFalse_whenUserDoesNotLikeProduct() {
 
       Long userId = 1L;
 
-      ProductDetailResponse result = productFacade.getProduct(product.getId(), userId);
+      ProductDetail result = productFacade.viewProductDetail(product.getId(), userId);
 
-      assertThat(result.isLiked()).isFalse();
+      assertThat(result.liked()).isFalse();
     }
 
     @Test
@@ -371,7 +367,7 @@ void throwsNotFoundException_whenProductDoesNotExist() {
       Long nonExistentProductId = 999L;
 
       assertThatThrownBy(() ->
-          productFacade.getProduct(nonExistentProductId, null)
+          productFacade.viewProductDetail(nonExistentProductId, null)
       )
           .isInstanceOf(CoreException.class)
           .extracting("errorType", "message")
@@ -388,7 +384,7 @@ void throwsNotFoundException_whenBrandDoesNotExist() {
       Long productId = product.getId();
 
       assertThatThrownBy(() ->
-          productFacade.getProduct(productId, null)
+          productFacade.viewProductDetail(productId, null)
       )
           .isInstanceOf(CoreException.class)
           .extracting("errorType", "message")
@@ -416,10 +412,10 @@ void sortsByPriceAscending() {
 
       Pageable pageable = PageRequest.of(0, 20, Sort.by("price").ascending());
 
-      Page<ProductListResponse> result = productFacade.getProducts(null, null, pageable);
+      Page<ProductDetail> result = productFacade.searchProductDetails(null, null, pageable);
 
       assertThat(result.getContent())
-          .extracting("name", "price")
+          .extracting("productName", "price")
           .containsExactly(
               tuple("상품A", 10000L),
               tuple("상품B", 20000L),
@@ -451,10 +447,10 @@ void sortsByLikesDescending() {
 
       Pageable pageable = PageRequest.of(0, 20, Sort.by("likeCount").descending());
 
-      Page<ProductListResponse> result = productFacade.getProducts(null, null, pageable);
+      Page<ProductDetail> result = productFacade.searchProductDetails(null, null, pageable);
 
       assertThat(result.getContent())
-          .extracting("name", "likeCount")
+          .extracting("productName", "likeCount")
           .containsExactly(
               tuple("상품2", 10L),
               tuple("상품1", 5L),
@@ -478,10 +474,10 @@ void sortsByLatest() {
 
       Pageable pageable = PageRequest.of(0, 20, Sort.by("createdAt").descending());
 
-      Page<ProductListResponse> result = productFacade.getProducts(null, null, pageable);
+      Page<ProductDetail> result = productFacade.searchProductDetails(null, null, pageable);
 
       assertThat(result.getContent())
-          .extracting("name", "price")
+          .extracting("productName", "price")
           .containsExactly(
               tuple("상품C", 30000L),
               tuple("상품B", 20000L),
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductDetailDomainServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductDetailDomainServiceTest.java
deleted file mode 100644
index c573cb830..000000000
--- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductDetailDomainServiceTest.java
+++ /dev/null
@@ -1,69 +0,0 @@
-package com.loopers.domain.product;
-
-import com.loopers.domain.brand.Brand;
-import com.loopers.domain.brand.Brands;
-import com.loopers.domain.money.Money;
-import com.loopers.domain.product.detail.ProductDetail;
-import com.loopers.domain.product.detail.ProductDetailDomainService;
-import com.loopers.domain.product.detail.ProductDetails;
-import com.loopers.domain.productlike.ProductLikeStatuses;
-import com.loopers.domain.stock.Stock;
-import org.junit.jupiter.api.BeforeEach;
-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;
-
-@DisplayName("ProductDetailDomainService 테스트")
-class ProductDetailDomainServiceTest {
-
-  private ProductDetailDomainService sut;
-
-  @BeforeEach
-  void setUp() {
-    sut = new ProductDetailDomainService();
-  }
-
-  @DisplayName("단일 ProductDetail 생성")
-  @Nested
-  class GetSingle {
-
-    @Test
-    @DisplayName("Product와 Brand, 좋아요 정보로 ProductDetail 생성 성공")
-    void get() {
-      Product product = Product.of("나이키 에어맥스", Money.of(150000L), "편안한 운동화", Stock.of(100L), 1L);
-      Brand brand = Brand.of("나이키");
-
-      ProductDetail result = sut.get(product, brand, true);
-
-      assertAll(
-          () -> assertThat(result.getProductName()).isEqualTo("나이키 에어맥스"),
-          () -> assertThat(result.getPrice()).isEqualTo(150000L),
-          () -> assertThat(result.getDescription()).isEqualTo("편안한 운동화"),
-          () -> assertThat(result.getBrandName()).isEqualTo("나이키"),
-          () -> assertThat(result.isLiked()).isTrue()
-      );
-    }
-  }
-
-  @DisplayName("ProductDetails 일급컬렉션 생성")
-  @Nested
-  class CreateCollection {
-
-    @Test
-    @DisplayName("빈 상품 리스트로 빈 ProductDetails 생성")
-    void create_withEmptyProducts() {
-      Products products = Products.from(List.of());
-      Brands brands = Brands.from(List.of());
-      ProductLikeStatuses likeStatuses = ProductLikeStatuses.empty();
-
-      ProductDetails result = sut.create(products, brands, likeStatuses);
-
-      assertThat(result.toList()).isEmpty();
-    }
-  }
-}
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductDetailsTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductDetailsTest.java
deleted file mode 100644
index 75a66f3f2..000000000
--- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductDetailsTest.java
+++ /dev/null
@@ -1,46 +0,0 @@
-package com.loopers.domain.product;
-
-import com.loopers.domain.brand.Brand;
-import com.loopers.domain.money.Money;
-import com.loopers.domain.product.detail.ProductDetail;
-import com.loopers.domain.product.detail.ProductDetails;
-import com.loopers.domain.stock.Stock;
-import org.junit.jupiter.api.DisplayName;
-import org.junit.jupiter.api.Test;
-
-import java.util.List;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-@DisplayName("ProductDetails 일급컬렉션 테스트")
-class ProductDetailsTest {
-
-  @Test
-  @DisplayName("ProductDetail 리스트로 일급컬렉션 생성")
-  void from() {
-    Product product = Product.of("상품1", Money.of(1000L), "설명1", Stock.of(10L), 1L);
-    Brand brand = Brand.of("브랜드1", "설명1");
-    ProductDetail detail = ProductDetail.of(product, brand, true);
-    List<ProductDetail> detailList = List.of(detail);
-
-    ProductDetails details = ProductDetails.from(detailList);
-
-    assertThat(details.toList()).hasSize(1);
-  }
-
-  @Test
-  @DisplayName("내부 리스트를 불변 복사본으로 반환")
-  void toList() {
-    Product product = Product.of("상품1", Money.of(1000L), "설명1", Stock.of(10L), 1L);
-    Brand brand = Brand.of("브랜드1", "설명1");
-    ProductDetail detail = ProductDetail.of(product, brand, true);
-    ProductDetails details = ProductDetails.from(List.of(detail));
-
-    List<ProductDetail> list1 = details.toList();
-    List<ProductDetail> list2 = details.toList();
-
-    assertThat(list1)
-        .isNotSameAs(list2)
-        .isEqualTo(list2);
-  }
-}
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java
new file mode 100644
index 000000000..19be13a70
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java
@@ -0,0 +1,71 @@
+package com.loopers.domain.product;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageImpl;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.BDDMockito.then;
+import static org.mockito.Mockito.times;
+
+@ExtendWith(MockitoExtension.class)
+@DisplayName("ProductService 단위 테스트")
+class ProductServiceTest {
+
+  @Mock
+  private ProductRepository productRepository;
+
+  @InjectMocks
+  private ProductService sut;
+
+  @Nested
+  @DisplayName("상품 목록 조회")
+  class FindProducts {
+
+    @Test
+    @DisplayName("brandId가 null이면 전체 상품 목록을 조회한다")
+    void findProducts_withNullBrandId() {
+      // given
+      Pageable pageable = PageRequest.of(0, 10);
+      Page<Product> expectedPage = new PageImpl<>(List.of());
+      given(productRepository.findAll(pageable)).willReturn(expectedPage);
+
+      // when
+      Page<Product> result = sut.findProducts(null, pageable);
+
+      // then
+      assertThat(result).isEqualTo(expectedPage);
+      then(productRepository).should(times(1)).findAll(pageable);
+      then(productRepository).should(times(0)).findByBrandId(null, pageable);
+    }
+
+    @Test
+    @DisplayName("brandId가 있으면 해당 브랜드의 상품 목록을 조회한다")
+    void findProducts_withBrandId() {
+      // given
+      Long brandId = 1L;
+      Pageable pageable = PageRequest.of(0, 10);
+      Page<Product> expectedPage = new PageImpl<>(List.of());
+      given(productRepository.findByBrandId(brandId, pageable)).willReturn(expectedPage);
+
+      // when
+      Page<Product> result = sut.findProducts(brandId, pageable);
+
+      // then
+      assertThat(result).isEqualTo(expectedPage);
+      then(productRepository).should(times(1)).findByBrandId(brandId, pageable);
+      then(productRepository).should(times(0)).findAll(pageable);
+    }
+  }
+}

From 9bdd4a236b61ece3ce47a7bced62008516ca2208 Mon Sep 17 00:00:00 2001
From: green <green@mobidoc.us>
Date: Fri, 14 Nov 2025 14:03:20 +0900
Subject: [PATCH 13/21] =?UTF-8?q?fix:=20ProductLike=20=EB=8F=84=EB=A9=94?=
 =?UTF-8?q?=EC=9D=B8=20=EA=B0=9C=EC=84=A0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- ProductLike 도메인 로직 개선
- ProductLikeService 개선
- LikeFacade 개선
- ProductLikeJpaRepository 개선
- 테스트 코드 보완
---
 .../loopers/application/like/LikeFacade.java    |  8 ++++++--
 .../loopers/domain/productlike/ProductLike.java |  9 +++++++--
 .../domain/productlike/ProductLikeService.java  |  6 ++----
 .../domain/productlike/ProductLikes.java        |  3 ++-
 .../productlike/ProductLikeJpaRepository.java   |  6 ------
 .../application/like/LikeFacadeTest.java        | 10 ++++++----
 .../domain/productlike/ProductLikeTest.java     | 17 +++++++++++++++++
 7 files changed, 40 insertions(+), 19 deletions(-)

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
index 9721f6403..92c3b1233 100644
--- 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
@@ -3,6 +3,8 @@
 import com.loopers.domain.product.Product;
 import com.loopers.domain.product.ProductService;
 import com.loopers.domain.productlike.ProductLikeService;
+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;
@@ -16,7 +18,8 @@ public class LikeFacade {
   private final ProductService productService;
 
   public void likeProduct(Long userId, Long productId) {
-    Product product = productService.getById(productId);
+    Product product = productService.getById(productId)
+        .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
 
     boolean alreadyLiked = productLikeService.isLiked(userId, productId);
     if (alreadyLiked) {
@@ -28,7 +31,8 @@ public void likeProduct(Long userId, Long productId) {
   }
 
   public void unlikeProduct(Long userId, Long productId) {
-    Product product = productService.getById(productId);
+    Product product = productService.getById(productId)
+        .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
 
     boolean liked = productLikeService.isLiked(userId, productId);
     if (!liked) {
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLike.java b/apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLike.java
index 80bfa2955..a61707438 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLike.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLike.java
@@ -41,18 +41,17 @@ public class ProductLike extends BaseEntity {
   private ProductLike(Long userId, Long productId, LocalDateTime likedAt) {
     validateUserId(userId);
     validateProductId(productId);
+    validateLikedAt(likedAt);
 
     this.userId = userId;
     this.productId = productId;
     this.likedAt = likedAt;
   }
 
-
   public static ProductLike of(Long userId, Long productId, LocalDateTime likedAt) {
     return new ProductLike(userId, productId, likedAt);
   }
 
-
   public boolean isLikedBy(Long userId) {
     return this.userId.equals(userId);
   }
@@ -68,4 +67,10 @@ private void validateProductId(Long productId) {
       throw new CoreException(ErrorType.INVALID_PRODUCT_LIKE_PRODUCT_EMPTY);
     }
   }
+
+  private void validateLikedAt(LocalDateTime likedAt) {
+    if (likedAt == null) {
+      throw new CoreException(ErrorType.INVALID_PRODUCT_LIKE_LIKED_AT_EMPTY);
+    }
+  }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLikeService.java b/apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLikeService.java
index eba7d0994..a267989a9 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLikeService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLikeService.java
@@ -1,15 +1,13 @@
 package com.loopers.domain.productlike;
 
+import java.time.LocalDateTime;
+import java.util.List;
 import lombok.RequiredArgsConstructor;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
-import java.time.LocalDateTime;
-import java.util.List;
-
 @Service
 @RequiredArgsConstructor
-@Transactional(readOnly = true)
 public class ProductLikeService {
 
   private final ProductLikeRepository productLikeRepository;
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLikes.java b/apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLikes.java
index cbf21d991..0381d821d 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLikes.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLikes.java
@@ -38,7 +38,8 @@ public ProductLikeStatuses toStatuses(List<Long> allProductIds) {
     Map<Long, Boolean> statusMap = allProductIds.stream()
         .collect(Collectors.toMap(
             productId -> productId,
-            productId -> likedMap.getOrDefault(productId, false)
+            productId -> likedMap.getOrDefault(productId, false),
+            (newValue, existingValue) -> existingValue
         ));
 
     return ProductLikeStatuses.from(statusMap);
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/productlike/ProductLikeJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/productlike/ProductLikeJpaRepository.java
index 479780ac5..afb627865 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/productlike/ProductLikeJpaRepository.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/productlike/ProductLikeJpaRepository.java
@@ -4,17 +4,11 @@
 import org.springframework.data.jpa.repository.JpaRepository;
 
 import java.util.List;
-import java.util.Optional;
 
 public interface ProductLikeJpaRepository extends JpaRepository<ProductLike, Long> {
 
-
-  Optional<ProductLike> findByUserIdAndProductId(Long userId, Long productId);
-
-
   boolean existsByUserIdAndProductId(Long userId, Long productId);
 
-
   List<ProductLike> findByUserIdAndProductIdIn(Long userId, List<Long> productIds);
 
   void deleteByUserIdAndProductId(Long userId, Long productId);
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java
index 8e88c75e0..855ead433 100644
--- a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java
@@ -15,6 +15,8 @@
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
 
+import java.util.Optional;
+
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyLong;
@@ -54,7 +56,7 @@ void likeProduct_success() {
           1L
       );
 
-      given(productService.getById(productId)).willReturn(product);
+      given(productService.getById(productId)).willReturn(Optional.of(product));
       given(productLikeService.isLiked(userId, productId)).willReturn(false);
 
       // when
@@ -79,7 +81,7 @@ void likeProduct_idempotent() {
           1L
       );
 
-      given(productService.getById(productId)).willReturn(product);
+      given(productService.getById(productId)).willReturn(Optional.of(product));
       given(productLikeService.isLiked(userId, productId)).willReturn(true);
 
       // when
@@ -124,7 +126,7 @@ void unlikeProduct_success() {
           1L
       );
 
-      given(productService.getById(productId)).willReturn(product);
+      given(productService.getById(productId)).willReturn(Optional.of(product));
       given(productLikeService.isLiked(userId, productId)).willReturn(true);
 
       // when
@@ -149,7 +151,7 @@ void unlikeProduct_idempotent() {
           1L
       );
 
-      given(productService.getById(productId)).willReturn(product);
+      given(productService.getById(productId)).willReturn(Optional.of(product));
       given(productLikeService.isLiked(userId, productId)).willReturn(false);
 
       // when
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/productlike/ProductLikeTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/productlike/ProductLikeTest.java
index ead588de6..c86d0a653 100644
--- a/apps/commerce-api/src/test/java/com/loopers/domain/productlike/ProductLikeTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/productlike/ProductLikeTest.java
@@ -65,6 +65,23 @@ void shouldThrowException_whenNull() {
     }
   }
 
+  @DisplayName("likedAt 검증")
+  @Nested
+  class ValidateLikedAt {
+
+    @DisplayName("null이면 예외가 발생한다")
+    @Test
+    void shouldThrowException_whenNull() {
+      Long userId = 1L;
+      Long productId = 100L;
+
+      assertThatThrownBy(() -> ProductLike.of(userId, productId, null))
+          .isInstanceOf(CoreException.class)
+          .hasMessage("좋아요 일시는 비어있을 수 없습니다.")
+          .extracting("errorType").isEqualTo(ErrorType.INVALID_PRODUCT_LIKE_LIKED_AT_EMPTY);
+    }
+  }
+
   @DisplayName("비즈니스 로직")
   @Nested
   class BusinessLogic {

From 41aa8a6438c558375eddbd6c7aa0465f39185335 Mon Sep 17 00:00:00 2001
From: green <green@mobidoc.us>
Date: Fri, 14 Nov 2025 14:04:05 +0900
Subject: [PATCH 14/21] =?UTF-8?q?fix:=20Order=20=EB=8F=84=EB=A9=94?=
 =?UTF-8?q?=EC=9D=B8=20=EA=B0=9C=EC=84=A0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Order Aggregate:
- Order 비즈니스 로직 개선
- OrderStatus Enum 개선
- OrderItemFactory 추가
- OrderItemCommand, OrderItems 개선

비즈니스 로직:
- OrderFacade 트랜잭션 처리 개선
- OrderService 개선
- OrderJpaRepository 개선

테스트:
- OrderItem 테스트 위치 이동 (domain/order/orderitem/)
- Facade, Service, Repository 통합 테스트 보완
- 경쟁 조건 테스트 추가
---
 .../application/order/OrderFacade.java        | 31 +++---
 .../application/order/OrderItemFactory.java   | 26 +++++
 .../java/com/loopers/domain/order/Order.java  | 30 ++++--
 .../loopers/domain/order/OrderService.java    | 40 +++++---
 .../com/loopers/domain/order/OrderStatus.java |  2 +-
 .../order/orderitem/OrderItemCommand.java     | 21 +---
 .../domain/order/orderitem/OrderItems.java    | 22 ++---
 .../order/OrderJpaRepository.java             |  1 -
 .../order/OrderFacadeIntegrationTest.java     | 23 +++--
 .../order/OrderFacadeRaceConditionTest.java   | 17 +++-
 .../order/OrderRepositoryIntegrationTest.java | 17 ++--
 .../order/OrderServiceIntegrationTest.java    | 16 ++--
 .../domain/order/OrderServiceTest.java        | 95 +++++++++++++------
 .../com/loopers/domain/order/OrderTest.java   | 86 +++++++++++++----
 .../{ => order}/orderitem/OrderItemTest.java  |  2 +-
 .../{ => order}/orderitem/OrderPriceTest.java |  2 +-
 16 files changed, 263 insertions(+), 168 deletions(-)
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemFactory.java
 rename apps/commerce-api/src/test/java/com/loopers/domain/{ => order}/orderitem/OrderItemTest.java (99%)
 rename apps/commerce-api/src/test/java/com/loopers/domain/{ => order}/orderitem/OrderPriceTest.java (98%)

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
index be4cdd375..f82b01e1a 100644
--- 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
@@ -1,10 +1,10 @@
 package com.loopers.application.order;
 
 import com.loopers.domain.order.Order;
+import com.loopers.domain.order.orderitem.OrderItem;
 import com.loopers.domain.order.orderitem.OrderItemCommand;
 import com.loopers.domain.order.orderitem.OrderItems;
 import com.loopers.domain.order.OrderService;
-import com.loopers.domain.order.orderitem.OrderItem;
 import com.loopers.domain.point.PointService;
 import com.loopers.domain.product.Products;
 import com.loopers.domain.product.ProductService;
@@ -15,13 +15,6 @@
 import java.time.LocalDateTime;
 import java.util.List;
 
-/**
- * OrderFacade (Application Layer)
- * 주문 생성 시 여러 도메인 서비스를 조율
- * - 상품 재고 검증 및 차감
- * - 포인트 잔액 검증 및 차감
- * - 주문 생성
- */
 @Component
 @RequiredArgsConstructor
 public class OrderFacade {
@@ -29,32 +22,30 @@ public class OrderFacade {
   private final OrderService orderService;
   private final ProductService productService;
   private final PointService pointService;
+  private final OrderItemFactory orderItemFactory;
 
   @Transactional
   public Order createOrder(Long userId, List<OrderItemCommand> commands, LocalDateTime orderedAt) {
-    List<OrderItem> requestedOrderItems = commands.stream()
-        .map(cmd -> OrderItem.of(
-            cmd.productId(),
-            cmd.productName(),
-            cmd.quantity(),
-            cmd.orderPrice()
-        ))
+    List<Long> productIds = commands.stream()
+        .map(OrderItemCommand::productId)
         .toList();
 
-    OrderItems orderItems = OrderItems.from(requestedOrderItems);
+    Products products = Products.from(productService.getByIdsWithLock(productIds));
+
+    List<OrderItem> items = commands.stream()
+        .map(cmd -> orderItemFactory.create(cmd, products))
+        .toList();
 
-    Products products = Products.from(productService.getByIdsWithLock(orderItems.getProductIds()));
+    OrderItems orderItems = new OrderItems(items);
 
     orderItems.validateStock(products);
 
     Long totalAmount = orderItems.calculateTotalAmount();
 
-    pointService.checkBalance(userId, totalAmount);
+    pointService.deduct(userId, totalAmount);
 
     orderItems.decreaseStock(products);
 
-    pointService.deduct(userId, totalAmount);
-
     return orderService.create(userId, orderItems.getItems(), orderedAt);
   }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemFactory.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemFactory.java
new file mode 100644
index 000000000..49877287e
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemFactory.java
@@ -0,0 +1,26 @@
+package com.loopers.application.order;
+
+import com.loopers.domain.order.orderitem.OrderItem;
+import com.loopers.domain.order.orderitem.OrderItemCommand;
+import com.loopers.domain.order.orderitem.OrderPrice;
+import com.loopers.domain.product.Product;
+import com.loopers.domain.product.Products;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import org.springframework.stereotype.Component;
+
+@Component
+public class OrderItemFactory {
+
+  public OrderItem create(OrderItemCommand command, Products products) {
+    Product product = products.findById(command.productId())
+        .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
+
+    return OrderItem.of(
+        product.getId(),
+        product.getName(),
+        command.quantity(),
+        OrderPrice.of(product.getPriceValue())
+    );
+  }
+}
\ No newline at end of file
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java
index e5d557538..74037d59e 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java
@@ -62,20 +62,32 @@ public Long getTotalAmountValue() {
     return totalAmount.getValue();
   }
 
-  /**
-   * OrderItem 추가 (양방향 관계 설정)
-   */
+
   public void addItem(OrderItem item) {
     this.items.add(item);
     item.assignOrder(this);
   }
 
-  /**
-   * 주문 상태 변경 (결제 실패 시 PAYMENT_PENDING으로 변경 등)
-   */
-  public void updateStatus(OrderStatus status) {
-    validateStatus(status);
-    this.status = status;
+
+  public void complete() {
+    if (this.status != OrderStatus.PENDING) {
+      throw new CoreException(ErrorType.ORDER_CANNOT_COMPLETE);
+    }
+    this.status = OrderStatus.COMPLETED;
+  }
+
+  public void failPayment() {
+    if (this.status != OrderStatus.PENDING) {
+      throw new CoreException(ErrorType.ORDER_CANNOT_FAIL_PAYMENT);
+    }
+    this.status = OrderStatus.PAYMENT_FAILED;
+  }
+
+  public void retryComplete() {
+    if (this.status != OrderStatus.PAYMENT_FAILED) {
+      throw new CoreException(ErrorType.ORDER_CANNOT_RETRY_COMPLETE);
+    }
+    this.status = OrderStatus.COMPLETED;
   }
 
   private void validateUserId(Long userId) {
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
index 893f7198b..052b49423 100644
--- 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
@@ -5,44 +5,54 @@
 import com.loopers.support.error.ErrorType;
 import lombok.RequiredArgsConstructor;
 import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Transactional;
 
 import java.time.LocalDateTime;
 import java.util.List;
+import java.util.Optional;
 
 @Service
 @RequiredArgsConstructor
-@Transactional(readOnly = true)
 public class OrderService {
 
   private final OrderRepository orderRepository;
 
-  public Order getById(Long orderId) {
-    return orderRepository.findById(orderId)
-        .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다."));
+  public Optional<Order> getById(Long orderId) {
+    return orderRepository.findById(orderId);
   }
 
-  public Order getWithItemsById(Long orderId) {
-    return orderRepository.findWithItemsById(orderId)
-        .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다."));
+  public Optional<Order> getWithItemsById(Long orderId) {
+    return orderRepository.findWithItemsById(orderId);
   }
 
-  @Transactional
   public Order create(Long userId, List<OrderItem> orderItems, LocalDateTime orderedAt) {
     Long totalAmount = calculateTotalAmount(orderItems);
 
-    Order order = Order.of(userId, OrderStatus.PAYMENT_PENDING, totalAmount, orderedAt);
+    Order order = Order.of(userId, OrderStatus.PAYMENT_FAILED, totalAmount, orderedAt);
 
     orderItems.forEach(order::addItem);
 
     return orderRepository.save(order);
   }
 
-  @Transactional
-  public Order updateOrderStatus(Long orderId, OrderStatus newStatus) {
-    Order order = getById(orderId);
-    order.updateStatus(newStatus);
-    return order;
+  public Order completeOrder(Long orderId) {
+    Order order = getById(orderId)
+        .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다."));
+    order.complete();
+    return orderRepository.save(order);
+  }
+
+  public Order markOrderAsPaymentPending(Long orderId) {
+    Order order = getById(orderId)
+        .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다."));
+    order.failPayment();
+    return orderRepository.save(order);
+  }
+
+  public Order retryCompleteOrder(Long orderId) {
+    Order order = getById(orderId)
+        .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다."));
+    order.retryComplete();
+    return orderRepository.save(order);
   }
 
   private Long calculateTotalAmount(List<OrderItem> orderItems) {
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java
index ba556d5cd..220dc321b 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java
@@ -4,7 +4,7 @@
 public enum OrderStatus {
   PENDING("주문 생성 완료"),
   COMPLETED("결제 완료"),
-  PAYMENT_PENDING("결제 재시도 실패");
+  PAYMENT_FAILED("결제 실패");
 
   private final String description;
 
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/orderitem/OrderItemCommand.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/orderitem/OrderItemCommand.java
index a4a10062e..9935a8d70 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/order/orderitem/OrderItemCommand.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/orderitem/OrderItemCommand.java
@@ -4,25 +4,10 @@
 
 public record OrderItemCommand(
     Long productId,
-    String productName,
-    Quantity quantity,
-    OrderPrice orderPrice
+    Quantity quantity
 ) {
 
-  public static OrderItemCommand of(
-      Long productId,
-      String productName,
-      Quantity quantity,
-      OrderPrice orderPrice
-  ) {
-    return new OrderItemCommand(productId, productName, quantity, orderPrice);
-  }
-
-  public Integer getQuantityValue() {
-    return quantity.getValue();
-  }
-
-  public Long getOrderPriceValue() {
-    return orderPrice.getValue();
+  public static OrderItemCommand of(Long productId, Quantity quantity) {
+    return new OrderItemCommand(productId, quantity);
   }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/orderitem/OrderItems.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/orderitem/OrderItems.java
index d29efbb0d..754ab6b0d 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/order/orderitem/OrderItems.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/orderitem/OrderItems.java
@@ -16,39 +16,31 @@ public OrderItems(List<OrderItem> items) {
     this.items = items;
   }
 
-  public static OrderItems from(List<OrderItem> items) {
-    return new OrderItems(items);
-  }
-
-
   public Long calculateTotalAmount() {
     return items.stream()
         .mapToLong(item -> item.getOrderPriceValue() * item.getQuantityValue())
         .sum();
   }
 
-  public List<Long> getProductIds() {
-    return items.stream()
-        .map(OrderItem::getProductId)
-        .toList();
-  }
-
   public void validateStock(Products products) {
     for (OrderItem item : items) {
-      Product product = products.findById(item.getProductId())
-          .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
+      Product product = findById(products, item);
       product.validateStockForOrder(item.getQuantityValue());
     }
   }
 
   public void decreaseStock(Products products) {
     for (OrderItem item : items) {
-      Product product = products.findById(item.getProductId())
-          .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
+      Product product = findById(products, item);
       product.decreaseStock(item.getQuantityValue().longValue());
     }
   }
 
+  private Product findById(Products products, OrderItem item) {
+    return products.findById(item.getProductId())
+        .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
+  }
+
   public List<OrderItem> getItems() {
     return List.copyOf(items);
   }
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
index e5c27a4fd..8a9d1fb6b 100644
--- 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
@@ -22,7 +22,6 @@ public interface OrderJpaRepository extends JpaRepository<Order, Long> {
 
   /**
    * 사용자별 주문 목록 조회 (DTO 프로젝션, SIZE()로 개수만 조회)
-   * HHH000104 경고 없음, 메모리 페이징 발생하지 않음
    */
   @Query("SELECT new com.loopers.domain.order.OrderListDto(" +
       "o.id, o.userId, o.status, o.totalAmount.value, o.orderedAt, SIZE(o.items)) " +
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java
index f8e77c191..1cf507d3c 100644
--- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java
@@ -6,7 +6,6 @@
 import com.loopers.domain.order.Order;
 import com.loopers.domain.order.orderitem.OrderItemCommand;
 import com.loopers.domain.order.OrderStatus;
-import com.loopers.domain.order.orderitem.OrderPrice;
 import com.loopers.domain.point.Point;
 import com.loopers.domain.point.PointRepository;
 import com.loopers.domain.product.Product;
@@ -83,8 +82,8 @@ class CreateOrder {
     void createOrder_success() {
       // given
       List<OrderItemCommand> commands = List.of(
-          OrderItemCommand.of(product1.getId(), "상품1", Quantity.of(2), OrderPrice.of(10000L)),
-          OrderItemCommand.of(product2.getId(), "상품2", Quantity.of(1), OrderPrice.of(30000L))
+          OrderItemCommand.of(product1.getId(), Quantity.of(2)),
+          OrderItemCommand.of(product2.getId(), Quantity.of(1))
       );
 
       // when
@@ -92,7 +91,7 @@ void createOrder_success() {
 
       // then
       assertThat(order.getTotalAmountValue()).isEqualTo(50000L);
-      assertThat(order.getStatus()).isEqualTo(OrderStatus.PAYMENT_PENDING);
+      assertThat(order.getStatus()).isEqualTo(OrderStatus.PAYMENT_FAILED);
 
       // 재고 차감 확인 (실제 DB 조회)
       Product updatedProduct1 = productRepository.findById(product1.getId()).orElseThrow();
@@ -111,7 +110,7 @@ void createOrder_success() {
     void createOrder_insufficientStock() {
       // given
       List<OrderItemCommand> commands = List.of(
-          OrderItemCommand.of(product1.getId(), "상품1", Quantity.of(100), OrderPrice.of(10000L))
+          OrderItemCommand.of(product1.getId(), Quantity.of(100))
       );
 
       // when & then
@@ -138,7 +137,7 @@ void createOrder_zeroStock() {
       );
 
       List<OrderItemCommand> commands = List.of(
-          OrderItemCommand.of(zeroStockProduct.getId(), "재고0상품", Quantity.of(1), OrderPrice.of(10000L))
+          OrderItemCommand.of(zeroStockProduct.getId(), Quantity.of(1))
       );
 
       // when & then
@@ -152,7 +151,7 @@ void createOrder_zeroStock() {
     void createOrder_insufficientPoint() {
       // given
       List<OrderItemCommand> commands = List.of(
-          OrderItemCommand.of(product1.getId(), "상품1", Quantity.of(1), OrderPrice.of(10000L))
+          OrderItemCommand.of(product1.getId(), Quantity.of(1))
       );
 
       // 포인트를 5000으로 설정 (부족)
@@ -164,7 +163,7 @@ void createOrder_insufficientPoint() {
           .isInstanceOf(CoreException.class)
           .hasFieldOrPropertyWithValue("errorType", ErrorType.INSUFFICIENT_POINT_BALANCE);
 
-      // 롤백 확인 - 재고 변경 안 됨
+      // 재고 변경 안 됨 (포인트 차감 전 실패)
       Product unchangedProduct = productRepository.findById(product1.getId()).orElseThrow();
       assertThat(unchangedProduct.getStockValue()).isEqualTo(10L);
 
@@ -178,19 +177,19 @@ void createOrder_insufficientPoint() {
     void createOrder_totalAmountCalculation() {
       // given
       List<OrderItemCommand> commands = List.of(
-          OrderItemCommand.of(product1.getId(), "상품1", Quantity.of(3), OrderPrice.of(5000L)),
-          OrderItemCommand.of(product2.getId(), "상품2", Quantity.of(2), OrderPrice.of(12500L))
+          OrderItemCommand.of(product1.getId(), Quantity.of(3)),
+          OrderItemCommand.of(product2.getId(), Quantity.of(2))
       );
 
       // when
       Order order = orderFacade.createOrder(user.getId(), commands, ORDERED_AT_2025_10_30);
 
       // then
-      assertThat(order.getTotalAmountValue()).isEqualTo(40000L);
+      assertThat(order.getTotalAmountValue()).isEqualTo(90000L);
 
       // 포인트 차감 확인
       Point updatedPoint = pointRepository.findByUserId(user.getId()).orElseThrow();
-      assertThat(updatedPoint.getAmountValue()).isEqualTo(60000L);
+      assertThat(updatedPoint.getAmountValue()).isEqualTo(10000L);
     }
   }
 }
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeRaceConditionTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeRaceConditionTest.java
index 77fbe4735..2fb900cdb 100644
--- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeRaceConditionTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeRaceConditionTest.java
@@ -4,7 +4,6 @@
 import com.loopers.domain.brand.BrandRepository;
 import com.loopers.domain.money.Money;
 import com.loopers.domain.order.orderitem.OrderItemCommand;
-import com.loopers.domain.order.orderitem.OrderPrice;
 import com.loopers.domain.point.Point;
 import com.loopers.domain.point.PointRepository;
 import com.loopers.domain.product.Product;
@@ -15,6 +14,8 @@
 import com.loopers.domain.user.User;
 import com.loopers.domain.user.UserRepository;
 import com.loopers.support.error.CoreException;
+import com.loopers.utils.DatabaseCleanUp;
+import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.DisplayName;
 import org.junit.jupiter.api.Test;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -49,9 +50,17 @@ class OrderFacadeRaceConditionTest {
   @Autowired
   private PointRepository pointRepository;
 
+  @Autowired
+  private DatabaseCleanUp databaseCleanUp;
+
   private static final LocalDateTime ORDERED_AT_2025_10_30 = LocalDateTime.of(2025, 10, 30, 0, 0, 0);
   private static final LocalDate BIRTH_DATE_1990_01_01 = LocalDate.of(1990, 1, 1);
 
+  @AfterEach
+  void tearDown() {
+    databaseCleanUp.truncateAllTables();
+  }
+
   @Test
   @DisplayName("동시에 2개 주문 시 하나만 성공하고 재고가 음수가 되지 않음")
   void concurrentOrders_onlyOneSucceeds() throws InterruptedException {
@@ -72,7 +81,7 @@ void concurrentOrders_onlyOneSucceeds() throws InterruptedException {
     AtomicInteger failureCount = new AtomicInteger(0);
 
     List<OrderItemCommand> commands = List.of(
-        OrderItemCommand.of(product.getId(), "재고1개상품", Quantity.of(1), OrderPrice.of(10000L))
+        OrderItemCommand.of(product.getId(), Quantity.of(1))
     );
 
     try (ExecutorService executorService = Executors.newFixedThreadPool(threadCount)) {
@@ -122,7 +131,7 @@ void concurrentOrders_multipleThreads() throws InterruptedException {
     AtomicInteger failureCount = new AtomicInteger(0);
 
     List<OrderItemCommand> commands = List.of(
-        OrderItemCommand.of(product.getId(), "재고5개상품", Quantity.of(1), OrderPrice.of(5000L))
+        OrderItemCommand.of(product.getId(), Quantity.of(1))
     );
 
     AtomicInteger counter = new AtomicInteger(0);
@@ -166,7 +175,7 @@ void concurrentOrders_stockNeverNegative() throws InterruptedException {
     CountDownLatch latch = new CountDownLatch(threadCount);
 
     List<OrderItemCommand> commands = List.of(
-        OrderItemCommand.of(product.getId(), "재고3개상품", Quantity.of(1), OrderPrice.of(3000L))
+        OrderItemCommand.of(product.getId(), Quantity.of(1))
     );
 
     AtomicInteger counter = new AtomicInteger(100);
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderRepositoryIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderRepositoryIntegrationTest.java
index bb255975b..349453acc 100644
--- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderRepositoryIntegrationTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderRepositoryIntegrationTest.java
@@ -18,6 +18,7 @@
 import java.util.Optional;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.tuple;
 
 @SpringBootTest
 @Transactional
@@ -65,17 +66,11 @@ void shouldReturnDtoPage() {
       // then
       assertThat(result.getContent())
           .hasSize(2)
-          .element(0).extracting("userId", "status", "itemCount")
-          .containsExactly(userId, OrderStatus.COMPLETED, 2);
-      assertThat(result.getContent())
-          .element(0).extracting("totalAmount")
-          .isEqualTo(30000L);
-      assertThat(result.getContent())
-          .element(1).extracting("userId", "status", "itemCount")
-          .containsExactly(userId, OrderStatus.PENDING, 1);
-      assertThat(result.getContent())
-          .element(1).extracting("totalAmount")
-          .isEqualTo(50000L);
+          .extracting("userId", "status", "itemCount", "totalAmount")
+          .containsExactly(
+              tuple(userId, OrderStatus.COMPLETED, 2, 30000L),
+              tuple(userId, OrderStatus.PENDING, 1, 50000L)
+          );
     }
 
     @DisplayName("다른 사용자의 주문은 조회되지 않는다")
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
index 750344a7a..fe79140c4 100644
--- 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
@@ -52,12 +52,12 @@ void createOrder_thenGet_dataMatches() {
 
       // when
       Order savedOrder = orderService.create(userId, orderItems, ORDERED_AT_2025_10_30);
-      Order foundOrder = orderService.getById(savedOrder.getId());
+      Order foundOrder = orderService.getById(savedOrder.getId()).orElseThrow();
 
       // then
       assertThat(foundOrder)
           .extracting("userId", "status", "orderedAt")
-          .containsExactly(userId, OrderStatus.PAYMENT_PENDING, ORDERED_AT_2025_10_30);
+          .containsExactly(userId, OrderStatus.PAYMENT_FAILED, ORDERED_AT_2025_10_30);
       assertThat(foundOrder.getTotalAmountValue()).isEqualTo(50000L);
     }
 
@@ -73,7 +73,7 @@ void createOrder_itemsCascadePersisted() {
 
       // when
       Order savedOrder = orderService.create(userId, orderItems, ORDERED_AT_2025_10_30);
-      Order foundOrder = orderService.getWithItemsById(savedOrder.getId());
+      Order foundOrder = orderService.getWithItemsById(savedOrder.getId()).orElseThrow();
 
       // then
       assertThat(foundOrder.getItems())
@@ -91,19 +91,19 @@ void createOrder_itemsCascadePersisted() {
   class UpdateOrderStatus {
 
     @Test
-    @DisplayName("주문 상태를 PAYMENT_PENDING에서 COMPLETED로 변경하면 변경된 상태가 조회된다")
-    void updateOrderStatus_thenGet_statusChanged() {
+    @DisplayName("주문 상태를 PAYMENT_FAILED에서 COMPLETED로 재시도 완료하면 변경된 상태가 조회된다")
+    void retryCompleteOrder_thenGet_statusChanged() {
       // given
       Long userId = 1L;
       List<OrderItem> orderItems = List.of(
           OrderItem.of(100L, "상품1", Quantity.of(1), OrderPrice.of(10000L))
       );
       Order savedOrder = orderService.create(userId, orderItems, ORDERED_AT_2025_10_30);
-      assertThat(savedOrder.getStatus()).isEqualTo(OrderStatus.PAYMENT_PENDING);
+      assertThat(savedOrder.getStatus()).isEqualTo(OrderStatus.PAYMENT_FAILED);
 
       // when
-      orderService.updateOrderStatus(savedOrder.getId(), OrderStatus.COMPLETED);
-      Order foundOrder = orderService.getById(savedOrder.getId());
+      orderService.retryCompleteOrder(savedOrder.getId());
+      Order foundOrder = orderService.getById(savedOrder.getId()).orElseThrow();
 
       // then
       assertThat(foundOrder.getStatus()).isEqualTo(OrderStatus.COMPLETED);
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java
index 56dd804ff..46933491f 100644
--- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java
@@ -56,8 +56,8 @@ void createOrder_success() {
       );
       LocalDateTime orderedAt = ORDERED_AT_2025_10_30;
 
-      Order savedOrder = Order.of(userId, OrderStatus.PAYMENT_PENDING, 50000L, orderedAt);
-      given(orderRepository.save(any(Order.class))).willReturn(savedOrder);
+      given(orderRepository.save(any(Order.class)))
+          .willAnswer(invocation -> invocation.getArgument(0));
 
       // when
       Order result = sut.create(userId, orderItems, orderedAt);
@@ -66,14 +66,12 @@ void createOrder_success() {
       then(orderRepository).should(times(1)).save(orderCaptor.capture());
       Order capturedOrder = orderCaptor.getValue();
 
-      assertThat(result)
+      assertThat(capturedOrder)
           .extracting("userId", "status", "orderedAt")
-          .containsExactly(userId, OrderStatus.PAYMENT_PENDING, orderedAt);
-      assertThat(result.getTotalAmountValue()).isEqualTo(50000L);
+          .containsExactly(userId, OrderStatus.PAYMENT_FAILED, orderedAt);
+      assertThat(capturedOrder.getTotalAmountValue()).isEqualTo(50000L);
 
-      assertThat(capturedOrder)
-          .extracting("userId", "status")
-          .containsExactly(userId, OrderStatus.PAYMENT_PENDING);
+      assertThat(capturedOrder.getItems()).hasSize(2);
     }
 
     @Test
@@ -85,17 +83,26 @@ void createOrder_bidirectionalRelationship() {
           OrderItem.of(100L, "상품1", Quantity.of(1), OrderPrice.of(10000L))
       );
 
-      Order savedOrder = Order.of(userId, OrderStatus.PAYMENT_PENDING, 10000L, ORDERED_AT_2025_10_30);
-      OrderItem item = OrderItem.of(100L, "상품1", Quantity.of(1), OrderPrice.of(10000L));
-      savedOrder.addItem(item);
-
-      given(orderRepository.save(any(Order.class))).willReturn(savedOrder);
+      given(orderRepository.save(any(Order.class)))
+          .willAnswer(invocation -> invocation.getArgument(0));
 
       // when
-      Order result = sut.create(userId, orderItems, ORDERED_AT_2025_10_30);
+      sut.create(userId, orderItems, ORDERED_AT_2025_10_30);
 
       // then
-      assertThat(result.getItems()).hasSize(1);
+      then(orderRepository).should(times(1)).save(orderCaptor.capture());
+      Order capturedOrder = orderCaptor.getValue();
+
+      assertThat(capturedOrder.getItems()).hasSize(1);
+
+      OrderItem capturedItem = capturedOrder.getItems().get(0);
+      assertThat(capturedItem.getOrder()).isEqualTo(capturedOrder);
+
+      assertThat(capturedItem)
+          .extracting("productId", "productName")
+          .containsExactly(100L, "상품1");
+      assertThat(capturedItem.getQuantityValue()).isEqualTo(1L);
+      assertThat(capturedItem.getOrderPriceValue()).isEqualTo(10000L);
     }
   }
 
@@ -112,27 +119,28 @@ void getById_success() {
       given(orderRepository.findById(orderId)).willReturn(Optional.of(order));
 
       // when
-      Order result = sut.getById(orderId);
+      Optional<Order> result = sut.getById(orderId);
 
       // then
-      assertThat(result)
+      assertThat(result).isPresent();
+      assertThat(result.get())
           .extracting("userId", "status")
           .containsExactly(1L, OrderStatus.COMPLETED);
       then(orderRepository).should(times(1)).findById(orderId);
     }
 
     @Test
-    @DisplayName("주문 ID로 조회 시 해당 주문이 존재하지 않으면 NOT_FOUND 예외가 발생한다")
+    @DisplayName("주문 ID로 조회 시 해당 주문이 존재하지 않으면 Optional.empty()가 반환된다")
     void getById_notFound() {
       // given
       Long orderId = 999L;
       given(orderRepository.findById(orderId)).willReturn(Optional.empty());
 
-      // when & then
-      assertThatThrownBy(() -> sut.getById(orderId))
-          .isInstanceOf(CoreException.class)
-          .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND)
-          .hasMessageContaining("주문을 찾을 수 없습니다.");
+      // when
+      Optional<Order> result = sut.getById(orderId);
+
+      // then
+      assertThat(result).isEmpty();
     }
 
     @Test
@@ -144,32 +152,57 @@ void getWithItemsById_success() {
       given(orderRepository.findWithItemsById(orderId)).willReturn(Optional.of(order));
 
       // when
-      Order result = sut.getWithItemsById(orderId);
+      Optional<Order> result = sut.getWithItemsById(orderId);
 
       // then
-      assertThat(result).isNotNull();
+      assertThat(result).isPresent();
       then(orderRepository).should(times(1)).findWithItemsById(orderId);
     }
   }
 
   @Nested
-  @DisplayName("주문 상태 업데이트")
-  class UpdateOrderStatus {
+  @DisplayName("주문 완료")
+  class CompleteOrder {
+
+    @Test
+    @DisplayName("PENDING 상태의 주문을 완료할 수 있다")
+    void shouldCompleteOrder_whenPending() {
+      // given
+      Long orderId = 1L;
+      Order order = Order.of(1L, OrderStatus.PENDING, 50000L, ORDERED_AT_2025_10_30);
+      given(orderRepository.findById(orderId)).willReturn(Optional.of(order));
+      given(orderRepository.save(order)).willReturn(order);
+
+      // when
+      Order result = sut.completeOrder(orderId);
+
+      // then
+      assertThat(result.getStatus()).isEqualTo(OrderStatus.COMPLETED);
+      then(orderRepository).should(times(1)).findById(orderId);
+      then(orderRepository).should(times(1)).save(order);
+    }
+  }
+
+  @Nested
+  @DisplayName("재시도 후 주문 완료")
+  class RetryCompleteOrder {
 
     @Test
-    @DisplayName("주문 ID와 새로운 상태로 상태를 업데이트하면 주문의 상태가 변경된다")
-    void updateOrderStatus_success() {
+    @DisplayName("PAYMENT_FAILED 상태의 주문을 재시도 완료할 수 있다")
+    void shouldRetryCompleteOrder_whenPaymentFailed() {
       // given
       Long orderId = 1L;
-      Order order = Order.of(1L, OrderStatus.PAYMENT_PENDING, 50000L, ORDERED_AT_2025_10_30);
+      Order order = Order.of(1L, OrderStatus.PAYMENT_FAILED, 50000L, ORDERED_AT_2025_10_30);
       given(orderRepository.findById(orderId)).willReturn(Optional.of(order));
+      given(orderRepository.save(order)).willReturn(order);
 
       // when
-      Order result = sut.updateOrderStatus(orderId, OrderStatus.COMPLETED);
+      Order result = sut.retryCompleteOrder(orderId);
 
       // then
       assertThat(result.getStatus()).isEqualTo(OrderStatus.COMPLETED);
       then(orderRepository).should(times(1)).findById(orderId);
+      then(orderRepository).should(times(1)).save(order);
     }
   }
 }
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java
index 782c7fa3c..3e8987731 100644
--- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java
@@ -137,37 +137,81 @@ void shouldThrowException_whenNull() {
     }
   }
 
-  @DisplayName("비즈니스 로직")
+  @DisplayName("주문 완료")
   @Nested
-  class BusinessLogic {
+  class Complete {
 
-    @DisplayName("주문 상태를 COMPLETED에서 PAYMENT_PENDING으로 변경할 수 있다")
+    @DisplayName("PENDING 상태에서 완료할 수 있다")
     @Test
-    void shouldUpdateStatus() {
-      Long userId = 1L;
-      OrderStatus initialStatus = OrderStatus.COMPLETED;
-      Long totalAmount = 50000L;
-      LocalDateTime orderedAt = ORDERED_AT_2025_10_30;
-      Order order = Order.of(userId, initialStatus, totalAmount, orderedAt);
+    void shouldComplete_whenPending() {
+      Order order = Order.of(1L, OrderStatus.PENDING, 50000L, ORDERED_AT_2025_10_30);
 
-      order.updateStatus(OrderStatus.PAYMENT_PENDING);
+      order.complete();
 
-      assertThat(order).extracting("status").isEqualTo(OrderStatus.PAYMENT_PENDING);
+      assertThat(order).extracting("status").isEqualTo(OrderStatus.COMPLETED);
     }
 
-    @DisplayName("상태 변경 시 null이면 예외가 발생한다")
+    @DisplayName("PENDING이 아닌 상태에서 완료하면 예외가 발생한다")
     @Test
-    void shouldThrowException_whenUpdateStatusWithNull() {
-      Long userId = 1L;
-      OrderStatus initialStatus = OrderStatus.COMPLETED;
-      Long totalAmount = 50000L;
-      LocalDateTime orderedAt = ORDERED_AT_2025_10_30;
-      Order order = Order.of(userId, initialStatus, totalAmount, orderedAt);
+    void shouldThrowException_whenNotPending() {
+      Order order = Order.of(1L, OrderStatus.COMPLETED, 50000L, ORDERED_AT_2025_10_30);
 
-      assertThatThrownBy(() -> order.updateStatus(null))
+      assertThatThrownBy(() -> order.complete())
           .isInstanceOf(CoreException.class)
-          .hasMessage("주문 상태는 비어있을 수 없습니다.")
-          .extracting("errorType").isEqualTo(ErrorType.INVALID_ORDER_STATUS_EMPTY);
+          .hasMessage("PENDING 상태의 주문만 완료할 수 있습니다.")
+          .extracting("errorType").isEqualTo(ErrorType.ORDER_CANNOT_COMPLETE);
+    }
+  }
+
+  @DisplayName("결제 실패 상태 변경")
+  @Nested
+  class FailPayment {
+
+    @DisplayName("PENDING 상태에서 PAYMENT_FAILED로 변경할 수 있다")
+    @Test
+    void shouldFailPayment_whenPending() {
+      Order order = Order.of(1L, OrderStatus.PENDING, 50000L, ORDERED_AT_2025_10_30);
+
+      order.failPayment();
+
+      assertThat(order).extracting("status").isEqualTo(OrderStatus.PAYMENT_FAILED);
+    }
+
+    @DisplayName("PENDING이 아닌 상태에서 변경하면 예외가 발생한다")
+    @Test
+    void shouldThrowException_whenNotPending() {
+      Order order = Order.of(1L, OrderStatus.COMPLETED, 50000L, ORDERED_AT_2025_10_30);
+
+      assertThatThrownBy(order::failPayment)
+          .isInstanceOf(CoreException.class)
+          .hasMessage("PENDING 상태의 주문만 결제 실패 상태로 변경할 수 있습니다.")
+          .extracting("errorType").isEqualTo(ErrorType.ORDER_CANNOT_FAIL_PAYMENT);
+    }
+  }
+
+  @DisplayName("재시도 후 주문 완료")
+  @Nested
+  class RetryComplete {
+
+    @DisplayName("PAYMENT_FAILED 상태에서 완료할 수 있다")
+    @Test
+    void shouldRetryComplete_whenPaymentFailed() {
+      Order order = Order.of(1L, OrderStatus.PAYMENT_FAILED, 50000L, ORDERED_AT_2025_10_30);
+
+      order.retryComplete();
+
+      assertThat(order).extracting("status").isEqualTo(OrderStatus.COMPLETED);
+    }
+
+    @DisplayName("PAYMENT_FAILED가 아닌 상태에서 완료하면 예외가 발생한다")
+    @Test
+    void shouldThrowException_whenNotPaymentFailed() {
+      Order order = Order.of(1L, OrderStatus.PENDING, 50000L, ORDERED_AT_2025_10_30);
+
+      assertThatThrownBy(() -> order.retryComplete())
+          .isInstanceOf(CoreException.class)
+          .hasMessage("PAYMENT_FAILED 상태의 주문만 재시도 완료할 수 있습니다.")
+          .extracting("errorType").isEqualTo(ErrorType.ORDER_CANNOT_RETRY_COMPLETE);
     }
   }
 
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/orderitem/OrderItemTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/orderitem/OrderItemTest.java
similarity index 99%
rename from apps/commerce-api/src/test/java/com/loopers/domain/orderitem/OrderItemTest.java
rename to apps/commerce-api/src/test/java/com/loopers/domain/order/orderitem/OrderItemTest.java
index 357eff5ce..ec2fb61a6 100644
--- a/apps/commerce-api/src/test/java/com/loopers/domain/orderitem/OrderItemTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/orderitem/OrderItemTest.java
@@ -1,4 +1,4 @@
-package com.loopers.domain.orderitem;
+package com.loopers.domain.order.orderitem;
 
 import com.loopers.domain.order.orderitem.OrderItem;
 import com.loopers.domain.order.orderitem.OrderPrice;
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/orderitem/OrderPriceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/orderitem/OrderPriceTest.java
similarity index 98%
rename from apps/commerce-api/src/test/java/com/loopers/domain/orderitem/OrderPriceTest.java
rename to apps/commerce-api/src/test/java/com/loopers/domain/order/orderitem/OrderPriceTest.java
index bbb9c9cf0..9628b13e9 100644
--- a/apps/commerce-api/src/test/java/com/loopers/domain/orderitem/OrderPriceTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/orderitem/OrderPriceTest.java
@@ -1,4 +1,4 @@
-package com.loopers.domain.orderitem;
+package com.loopers.domain.order.orderitem;
 
 import com.loopers.domain.order.orderitem.OrderPrice;
 import com.loopers.support.error.CoreException;

From 8d57a57687b33e8c1d61b5bfedee43d567b9965b Mon Sep 17 00:00:00 2001
From: green <green@mobidoc.us>
Date: Fri, 14 Nov 2025 14:06:30 +0900
Subject: [PATCH 15/21] =?UTF-8?q?fix:=20User=20=EB=8F=84=EB=A9=94=EC=9D=B8?=
 =?UTF-8?q?=20=EA=B0=9C=EC=84=A0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- UserFacade 개선
- UserController 개선
- 테스트 코드 보완
---
 .../main/java/com/loopers/application/user/UserFacade.java  | 2 +-
 .../com/loopers/interfaces/api/user/UserController.java     | 2 +-
 .../src/test/java/com/loopers/domain/user/UserTest.java     | 6 +++---
 3 files changed, 5 insertions(+), 5 deletions(-)

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
index d1e9bf74b..49eb178f7 100644
--- 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
@@ -20,7 +20,7 @@ public UserResult register(String loginId, String email, LocalDate birth, Gender
         return UserResult.from(user);
     }
 
-    public UserResult getUser(String loginId) {
+    public UserResult lookupUserProfile(String loginId) {
         User user = userService.findById(loginId);
         if (user == null) {
             throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.");
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java
index b5615d782..bcfadf9b7 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java
@@ -38,7 +38,7 @@ public ApiResponse<UserDto.UserResponse> register(
     @GetMapping("/{loginId}")
     @Override
     public ApiResponse<UserDto.UserResponse> getUser(@PathVariable String loginId) {
-        UserResult userResult = userFacade.getUser(loginId);
+        UserResult userResult = userFacade.lookupUserProfile(loginId);
         UserDto.UserResponse response = UserDto.UserResponse.from(userResult);
         return ApiResponse.success(response);
     }
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java
index aef7563ca..4fadf9bbb 100644
--- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java
@@ -78,11 +78,11 @@ void shouldThrowException_whenNullOrEmpty(String invalidLoginId) {
         @ValueSource(strings = {
             "abc12345678",    // 11자
             "user@123",       // 특수문자
-            "user-name",      // 하이픈
-            "user.name",      // 점
+            "user-productName",      // 하이픈
+            "user.productName",      // 점
             "user한글",        // 한글
             "홍길동",          // 한글만
-            "user name"       // 공백
+            "user productName"       // 공백
         })
         void shouldThrowException_whenInvalidFormat(String invalidLoginId) {
             LocalDate validBirth = LocalDate.of(1990, 1, 1);

From 684a4afe840fcfd5fb6e26ade84442ed5f275d43 Mon Sep 17 00:00:00 2001
From: green <green@mobidoc.us>
Date: Fri, 14 Nov 2025 14:06:47 +0900
Subject: [PATCH 16/21] =?UTF-8?q?fix:=20Point=20=EB=8F=84=EB=A9=94?=
 =?UTF-8?q?=EC=9D=B8=20=EA=B0=9C=EC=84=A0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- PointFacade 개선
- PointService 개선
- PointController 개선
- E2E 테스트 보완
---
 .../com/loopers/application/point/PointFacade.java |  5 +++--
 .../com/loopers/domain/point/PointService.java     |  9 ---------
 .../interfaces/api/point/PointController.java      |  3 +--
 .../interfaces/api/point/PointApiE2ETest.java      | 14 +++++++-------
 4 files changed, 11 insertions(+), 20 deletions(-)

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
index 8b0eb3162..710318cd4 100644
--- 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
@@ -1,10 +1,10 @@
 package com.loopers.application.point;
 
 import com.loopers.domain.point.PointService;
-import com.loopers.domain.point.Point;
 import java.util.Optional;
 import lombok.RequiredArgsConstructor;
 import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
 
 @Component
 @RequiredArgsConstructor
@@ -12,11 +12,12 @@ public class PointFacade {
 
     private final PointService pointService;
 
-    public Optional<PointResult> findPoint(String loginId) {
+    public Optional<PointResult> getPoint(String loginId) {
       return pointService.findByUserLoginId(loginId)
           .map(PointResult::from);
     }
 
+    @Transactional
     public PointResult charge(String loginId, Long amount) {
       return PointResult.from(pointService.charge(loginId, amount));
     }
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
index 858f40414..14d99ce4b 100644
--- 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
@@ -9,7 +9,6 @@
 
 @Component
 @RequiredArgsConstructor
-@Transactional(readOnly = true)
 public class PointService {
 
     private final PointRepository pointRepository;
@@ -31,14 +30,6 @@ public Point charge(String loginId, Long chargeAmount) {
         return pointRepository.save(point);
     }
 
-    public void checkBalance(Long userId, Long requiredAmount) {
-        Point point = pointRepository.findByUserId(userId)
-            .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "포인트 정보를 찾을 수 없습니다."));
-        if (point.isNotEnough(requiredAmount)) {
-            throw new CoreException(ErrorType.INSUFFICIENT_POINT_BALANCE);
-        }
-    }
-
     @Transactional
     public Point deduct(Long userId, Long deductAmount) {
         Point point = pointRepository.findByUserIdWithLock(userId)
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointController.java
index 8e7518d20..9e86389d3 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointController.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointController.java
@@ -1,7 +1,6 @@
 package com.loopers.interfaces.api.point;
 
 import com.loopers.application.point.PointFacade;
-import com.loopers.application.point.PointResult;
 import com.loopers.interfaces.api.ApiResponse;
 import com.loopers.interfaces.api.point.PointDto.ChargeRequest;
 import com.loopers.interfaces.api.point.PointDto.ChargeResponse;
@@ -26,7 +25,7 @@ public class PointController implements PointApiSpec {
   @Override
   @GetMapping
 public ApiResponse<PointResponse> getPoint(@RequestHeader(ApiHeaders.USER_LOGIN_ID) String loginId) {
-    PointResponse response = pointFacade.findPoint(loginId)
+    PointResponse response = pointFacade.getPoint(loginId)
         .map(PointResponse::from)
         .orElse(null);
     return ApiResponse.success(response);
diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/point/PointApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/point/PointApiE2ETest.java
index 5a5b5b86c..04dcfdf52 100644
--- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/point/PointApiE2ETest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/point/PointApiE2ETest.java
@@ -65,12 +65,12 @@ void tearDown() {
     databaseCleanUp.truncateAllTables();
   }
 
-  @DisplayName("GET /api/v1/points (header: X-USER-ID) ")
+  @DisplayName("GET /api/v1/points (header: X-USER-LOGIN-ID) ")
   @Nested
   class GetPoint {
 
     @Test
-    @DisplayName("`X-USER-ID` 헤더가 없을 경우, `400 Bad Request` 응답을 반환한다.")
+    @DisplayName("`X-USER-LOGIN-ID` 헤더가 없을 경우, `400 Bad Request` 응답을 반환한다.")
     void returnBadRequestException_whenUserIdHeaderIsMissing() {
       ParameterizedTypeReference<ApiResponse<PointResponse>> responseType =
           new ParameterizedTypeReference<>() {
@@ -106,7 +106,7 @@ void returnsPoint_whenPointExists() {
 
       // when
       HttpHeaders httpHeaders = new HttpHeaders();
-      httpHeaders.set("X-USER-ID", userId);
+      httpHeaders.set("X-USER-LOGIN-ID", userId);
 
       ParameterizedTypeReference<ApiResponse<PointResponse>> responseType =
           new ParameterizedTypeReference<>() {
@@ -136,7 +136,7 @@ void returnsNull_whenUserDoesNotExists() {
       String userId = "nonexistent";
       // when
       HttpHeaders httpHeaders = new HttpHeaders();
-      httpHeaders.set("X-USER-ID", userId);
+      httpHeaders.set("X-USER-LOGIN-ID", userId);
 
       ParameterizedTypeReference<ApiResponse<PointResponse>> responseType =
           new ParameterizedTypeReference<>() {
@@ -159,7 +159,7 @@ void returnsNull_whenUserDoesNotExists() {
 
   }
 
-  @DisplayName("PATCH /api/v1/points/charge (header: X-USER-ID, body: ChargeRequest)")
+  @DisplayName("PATCH /api/v1/points/charge (header: X-USER-LOGIN-ID, body: ChargeRequest)")
   @Nested
   class ChargePoint {
 
@@ -182,7 +182,7 @@ void returnsTotalBalance_whenChargingThousand() {
 
       // when
       HttpHeaders httpHeaders = new HttpHeaders();
-      httpHeaders.set("X-USER-ID", userId);
+      httpHeaders.set("X-USER-LOGIN-ID", userId);
 
       ParameterizedTypeReference<ApiResponse<ChargeResponse>> responseType =
           new ParameterizedTypeReference<>() {
@@ -213,7 +213,7 @@ void returnsNotFoundException_whenUserDoesNotExists() {
 
       // when
       HttpHeaders httpHeaders = new HttpHeaders();
-      httpHeaders.set("X-USER-ID", userId);
+      httpHeaders.set("X-USER-LOGIN-ID", userId);
 
       ParameterizedTypeReference<ApiResponse<ChargeResponse>> responseType =
           new ParameterizedTypeReference<>() {

From 8fde71291cbd9d17411fde067583108b50793b94 Mon Sep 17 00:00:00 2001
From: green <green@mobidoc.us>
Date: Fri, 14 Nov 2025 14:07:08 +0900
Subject: [PATCH 17/21] =?UTF-8?q?fix:=20=EA=B3=B5=ED=86=B5=20=EA=B8=B0?=
 =?UTF-8?q?=EB=8A=A5=20=EA=B0=9C=EC=84=A0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- ErrorType 개선
- ApiHeaders 개선
- ExampleService 개선
- E2E 테스트 보완
---
 .../java/com/loopers/domain/example/ExampleService.java     | 2 +-
 .../java/com/loopers/interfaces/api/support/ApiHeaders.java | 2 +-
 .../src/main/java/com/loopers/support/error/ErrorType.java  | 6 +++++-
 .../com/loopers/interfaces/api/ExampleV1ApiE2ETest.java     | 2 +-
 4 files changed, 8 insertions(+), 4 deletions(-)

diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java
index c0e8431e8..f6acaad9f 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java
@@ -15,6 +15,6 @@ public class ExampleService {
     @Transactional(readOnly = true)
     public ExampleModel getExample(Long id) {
         return exampleRepository.find(id)
-            .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + id + "] 예시를 찾을 수 없습니다."));
+            .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[productId = " + id + "] 예시를 찾을 수 없습니다."));
     }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/support/ApiHeaders.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/support/ApiHeaders.java
index 5da4b2853..667b83fa5 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/support/ApiHeaders.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/support/ApiHeaders.java
@@ -5,6 +5,6 @@ public final class ApiHeaders {
     private ApiHeaders() {
     }
 
-    public static final String USER_LOGIN_ID = "X-USER-ID";
+    public static final String USER_LOGIN_ID = "X-USER-LOGIN-ID";
 }
 
diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java
index 4c97b13f3..2668d6a01 100644
--- a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java
+++ b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java
@@ -58,6 +58,7 @@ public enum ErrorType {
     /** ProductLike 도메인 에러 */
     INVALID_PRODUCT_LIKE_USER_EMPTY(HttpStatus.BAD_REQUEST, "INVALID_PRODUCT_LIKE_USER_EMPTY", "사용자는 비어있을 수 없습니다."),
     INVALID_PRODUCT_LIKE_PRODUCT_EMPTY(HttpStatus.BAD_REQUEST, "INVALID_PRODUCT_LIKE_PRODUCT_EMPTY", "상품은 비어있을 수 없습니다."),
+    INVALID_PRODUCT_LIKE_LIKED_AT_EMPTY(HttpStatus.BAD_REQUEST, "INVALID_PRODUCT_LIKE_LIKED_AT_EMPTY", "좋아요 일시는 비어있을 수 없습니다."),
 
     /** ProductDetail 도메인 에러 */
     INVALID_PRODUCT_DETAIL_PRODUCT_EMPTY(HttpStatus.BAD_REQUEST, "INVALID_PRODUCT_DETAIL_PRODUCT_EMPTY", "상품 정보는 필수입니다."),
@@ -83,7 +84,10 @@ public enum ErrorType {
     INVALID_ORDER_STATUS_EMPTY(HttpStatus.BAD_REQUEST, "INVALID_ORDER_STATUS_EMPTY", "주문 상태는 비어있을 수 없습니다."),
     INVALID_ORDER_TOTAL_AMOUNT_EMPTY(HttpStatus.BAD_REQUEST, "INVALID_ORDER_TOTAL_AMOUNT_EMPTY", "주문 총액은 비어있을 수 없습니다."),
     NEGATIVE_ORDER_TOTAL_AMOUNT(HttpStatus.BAD_REQUEST, "NEGATIVE_ORDER_TOTAL_AMOUNT", "주문 총액은 음수일 수 없습니다."),
-    INVALID_ORDER_ORDERED_AT_EMPTY(HttpStatus.BAD_REQUEST, "INVALID_ORDER_ORDERED_AT_EMPTY", "주문 시각은 비어있을 수 없습니다.");
+    INVALID_ORDER_ORDERED_AT_EMPTY(HttpStatus.BAD_REQUEST, "INVALID_ORDER_ORDERED_AT_EMPTY", "주문 시각은 비어있을 수 없습니다."),
+    ORDER_CANNOT_COMPLETE(HttpStatus.BAD_REQUEST, "ORDER_CANNOT_COMPLETE", "PENDING 상태의 주문만 완료할 수 있습니다."),
+    ORDER_CANNOT_FAIL_PAYMENT(HttpStatus.BAD_REQUEST, "ORDER_CANNOT_FAIL_PAYMENT", "PENDING 상태의 주문만 결제 실패 상태로 변경할 수 있습니다."),
+    ORDER_CANNOT_RETRY_COMPLETE(HttpStatus.BAD_REQUEST, "ORDER_CANNOT_RETRY_COMPLETE", "PAYMENT_FAILED 상태의 주문만 재시도 완료할 수 있습니다.");
 
     private final HttpStatus status;
     private final String code;
diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java
index f7ca1f1f2..baedfb8f3 100644
--- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java
@@ -48,7 +48,7 @@ void tearDown() {
         databaseCleanUp.truncateAllTables();
     }
 
-    @DisplayName("GET /api/v1/examples/{id}")
+    @DisplayName("GET /api/v1/examples/{productId}")
     @Nested
     class Get {
         @DisplayName("존재하는 예시 ID를 주면, 해당 예시 정보를 반환한다.")

From 22ca108667d0a3c68ba242087a1fef2e09ad40fc Mon Sep 17 00:00:00 2001
From: green <green@mobidoc.us>
Date: Fri, 14 Nov 2025 14:07:29 +0900
Subject: [PATCH 18/21] =?UTF-8?q?docs:=20=EC=84=A4=EA=B3=84=20=EB=AC=B8?=
 =?UTF-8?q?=EC=84=9C=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- ADR-001 양방향 관계 문서 개선
- 요구사항 문서 업데이트
- 시퀀스 다이어그램 개선
- 클래스 다이어그램 업데이트
- ERD 업데이트
---
 ...er-orderitem-bidirectional-relationship.md |  2 ++
 docs/design/01-requirements.md                |  8 +++----
 docs/design/02-sequence-diagrams.md           |  4 ++--
 docs/design/03-class-diagram.md               | 22 +++++++++----------
 docs/design/04-erd.md                         |  8 +++----
 5 files changed, 23 insertions(+), 21 deletions(-)

diff --git a/docs/adr/ADR-001-order-orderitem-bidirectional-relationship.md b/docs/adr/ADR-001-order-orderitem-bidirectional-relationship.md
index eb5b8af90..157029f7f 100644
--- a/docs/adr/ADR-001-order-orderitem-bidirectional-relationship.md
+++ b/docs/adr/ADR-001-order-orderitem-bidirectional-relationship.md
@@ -57,6 +57,7 @@ Order 도메인이 items 관리 로직 포함:
 ### ✅ 해결 방안
 
 **목록 조회**: DTO 프로젝션 (DB 레벨 페이징)
+
 ```java
 @Query("SELECT new OrderListDto(o.id, o.orderedAt, o.totalAmount, o.status, SIZE(o.items)) " +
        "FROM Order o WHERE o.userId = :userId")
@@ -64,6 +65,7 @@ Page<OrderListDto> findOrderList(Long userId, Pageable pageable);
 ```
 
 **상세 조회**: @EntityGraph (단건은 페이징 불필요)
+
 ```java
 @EntityGraph(attributePaths = "items")
 Optional<Order> findWithItemsById(Long orderId);
diff --git a/docs/design/01-requirements.md b/docs/design/01-requirements.md
index 2338d7cb2..dbe1fe2b5 100644
--- a/docs/design/01-requirements.md
+++ b/docs/design/01-requirements.md
@@ -265,7 +265,7 @@
 - **결제 처리 실패**:
   - 주문은 이미 DB에 저장 완료 (재고/포인트 차감 완료, 트랜잭션 커밋됨)
   - 자동 재시도 3회 (1초, 2초, 4초 간격, 지수 백오프)
-  - 재시도 실패 시 주문 상태를 '결제 대기'로 마킹
+  - 재시도 실패 시 주문 상태를 '결제 실패'로 마킹
   - 500 Internal Server Error 응답
 
 ##### 기능 요구사항
@@ -290,7 +290,7 @@
         7. 외부 시스템 전송 (Mock 가능)
     - **응답 데이터**:
         - 주문 ID, 주문 일시
-        - 주문 상태 (status): PENDING, COMPLETED, PAYMENT_PENDING
+        - 주문 상태 (status): PENDING, COMPLETED, PAYMENT_FAILED
         - 주문 상품 목록: 상품 ID, 상품명, 수량, 가격
         - 총 결제 금액
 
@@ -300,14 +300,14 @@
         - `size`: 페이지당 주문 수 (기본값: 20)
     - **응답 데이터**:
         - 주문 ID, 주문 일시, 총 금액
-        - 주문 상태 (status): PENDING, COMPLETED, PAYMENT_PENDING
+        - 주문 상태 (status): PENDING, COMPLETED, PAYMENT_FAILED
         - 주문 상품 수
         - 페이지네이션 정보: 총 개수, 현재 페이지, 전체 페이지 수, 페이지당 개수
 
 - **주문 상세 조회**: `GET /api/v1/orders/{orderId}`
     - **응답 데이터**:
         - 주문 ID, 주문 일시
-        - 주문 상태 (status): PENDING, COMPLETED, PAYMENT_PENDING
+        - 주문 상태 (status): PENDING, COMPLETED, PAYMENT_FAILED
         - 주문 상품 목록: 상품 ID, 상품명, 수량, 가격
         - 총 결제 금액
 
diff --git a/docs/design/02-sequence-diagrams.md b/docs/design/02-sequence-diagrams.md
index 54c1570ce..a015df0b2 100644
--- a/docs/design/02-sequence-diagrams.md
+++ b/docs/design/02-sequence-diagrams.md
@@ -592,7 +592,7 @@ sequenceDiagram
 **주요 처리 사항**:
 - 트랜잭션 커밋 후 결제 시스템 호출 (트랜잭션 밖)
 - 3회 자동 재시도 (1초, 2초, 4초 간격, 지수 백오프)
-- 재시도 실패 시 주문 상태를 '결제 대기'로 마킹 및 500 에러 응답
+- 재시도 실패 시 주문 상태를 '결제 실패'로 마킹 및 500 에러 응답
 
 ```mermaid
 sequenceDiagram
@@ -627,7 +627,7 @@ sequenceDiagram
     end
 
     Note over OrderFacade: 재시도 실패 (3회 모두 실패)
-    OrderFacade->>Order: updateStatus("결제 대기")
+    OrderFacade->>Order: failPayment()
     OrderFacade->>OrderRepository: save(Order)
 
     Note over OrderFacade: 모니터링 알림 발송
diff --git a/docs/design/03-class-diagram.md b/docs/design/03-class-diagram.md
index bda12f016..0a2c192eb 100644
--- a/docs/design/03-class-diagram.md
+++ b/docs/design/03-class-diagram.md
@@ -140,11 +140,11 @@ classDiagram
 
 ### 2.6 Order (주문)
 - 사용자의 주문 정보
-- `status`: 주문 상태 (OrderStatus Enum: PENDING, COMPLETED, PAYMENT_PENDING)
+- `status`: 주문 상태 (OrderStatus Enum: PENDING, COMPLETED, PAYMENT_FAILED)
 - `totalAmount`: 주문 총액 (OrderItem 금액 합계)
 - `orderedAt`: 주문한 시각
 - **비즈니스 로직**:
-  - `updateStatus()`: 주문 상태 변경 (결제 실패 시 PAYMENT_PENDING으로 변경)
+  - `updateStatus()`: 주문 상태 변경 (결제 실패 시 PAYMENT_FAILED으로 변경)
 - **주문 항목 조회**: OrderItemRepository를 통해 `findByOrderId()`로 조회
 - **상태 다이어그램**: [4. Order 상태 다이어그램](#4-order-상태-다이어그램) 참고
 
@@ -188,11 +188,11 @@ classDiagram
 |------|------|------|
 | **PENDING** | 주문 생성 완료 | 외부 결제 API 호출 전 초기 상태 |
 | **COMPLETED** | 결제 완료 | 외부 결제 API 성공 시 최종 상태 |
-| **PAYMENT_PENDING** | 결제 재시도 실패 | 외부 결제 실패 후 재시도 실패 시 |
+| **PAYMENT_FAILED** | 결제 재시도 실패 | 외부 결제 실패 후 재시도 실패 시 |
 
 **참고**: `FAILED` 상태는 존재하지 않음
 - 이유: 결제 실패 시 트랜잭션 롤백으로 주문 데이터가 DB에 저장되지 않음
-- 재시도 실패한 주문만 `PAYMENT_PENDING` 상태로 DB에 저장됨
+- 재시도 실패한 주문만 `PAYMENT_FAILED` 상태로 DB에 저장됨
 
 ### 4.2 상태 전이 다이어그램
 
@@ -200,9 +200,9 @@ classDiagram
 stateDiagram-v2
     [*] --> PENDING: 주문 생성
     PENDING --> COMPLETED: 외부 결제 성공
-    PENDING --> PAYMENT_PENDING: 외부 결제 재시도 실패
+    PENDING --> PAYMENT_FAILED: 외부 결제 재시도 실패
     COMPLETED --> [*]
-    PAYMENT_PENDING --> [*]
+    PAYMENT_FAILED --> [*]
 
     note right of PENDING
         초기 상태
@@ -214,7 +214,7 @@ stateDiagram-v2
         결제 완료
     end note
 
-    note right of PAYMENT_PENDING
+    note right of PAYMENT_FAILED
         재시도 실패 상태
         수동 처리 필요
     end note
@@ -226,7 +226,7 @@ stateDiagram-v2
 |------|------|------|
 | `[*] → PENDING` | 주문 생성 요청 | 1. 재고 확인 및 차감<br>2. 포인트 차감<br>3. Order, OrderItem 생성 (status: PENDING) |
 | `PENDING → COMPLETED` | 외부 결제 API 성공 | 1. Order.status를 COMPLETED로 업데이트<br>2. 트랜잭션 커밋 |
-| `PENDING → PAYMENT_PENDING` | 외부 결제 재시도 실패 | 1. Order.status를 PAYMENT_PENDING으로 업데이트<br>2. 트랜잭션 커밋 (수동 처리 위해 주문 기록 보존) |
+| `PENDING → PAYMENT_FAILED` | 외부 결제 재시도 실패 | 1. Order.status를 PAYMENT_FAILED으로 업데이트<br>2. 트랜잭션 커밋 (수동 처리 위해 주문 기록 보존) |
 | `PENDING → [트랜잭션 롤백]` | 외부 결제 실패 (재시도 전) | 1. 전체 트랜잭션 롤백<br>2. DB에 주문 데이터 저장 안 됨 |
 
 ### 4.4 주문 처리 흐름도
@@ -253,7 +253,7 @@ flowchart TD
 
     PaymentSuccess -->|실패| Retry{재시도 성공?}
     Retry -->|성공| UpdateCompleted
-    Retry -->|실패| UpdatePending[status → PAYMENT_PENDING]
+    Retry -->|실패| UpdatePending[status → PAYMENT_FAILED]
     UpdatePending --> Commit2[트랜잭션 커밋<br/>수동 처리용 기록 보존]
     Commit2 --> End2([재시도 실패])
 
@@ -270,7 +270,7 @@ flowchart TD
 - **허용 동작**:
   - 외부 결제 API 호출
   - 상태 변경 → COMPLETED (결제 성공)
-  - 상태 변경 → PAYMENT_PENDING (재시도 실패)
+  - 상태 변경 → PAYMENT_FAILED (재시도 실패)
 - **제약 사항**:
   - 사용자에게 주문 상세 조회 시 "결제 처리 중" 표시
   - 이 상태의 주문은 재고/포인트 이미 차감된 상태
@@ -282,7 +282,7 @@ flowchart TD
   - 더 이상 상태 변경 불가 (최종 상태)
   - 취소/환불 기능은 현재 요구사항에 없음
 
-#### PAYMENT_PENDING
+#### PAYMENT_FAILED
 - **허용 동작**:
   - 관리자 수동 처리
   - 주문 조회
diff --git a/docs/design/04-erd.md b/docs/design/04-erd.md
index f41060ce3..14fab8059 100644
--- a/docs/design/04-erd.md
+++ b/docs/design/04-erd.md
@@ -57,7 +57,7 @@ erDiagram
     BRANDS {
         bigint id PK "브랜드 식별자"
         varchar(100) name "브랜드명"
-        text description "브랜드 설명"
+        varchar(200) description "브랜드 설명"
         timestamp created_at
         timestamp updated_at
         timestamp deleted_at
@@ -69,7 +69,7 @@ erDiagram
         bigint id PK "상품 식별자"
         varchar(100) name "상품명"
         bigint price "가격"
-        text description "상품 설명"
+        varchar(200) description "상품 설명"
         int stock "재고 수량"
         bigint like_count "좋아요 수 (비정규화)"
         bigint ref_brand_id "브랜드 참조"
@@ -123,7 +123,7 @@ erDiagram
 
 ### 2.1 USERS
 - 사용자 계정 정보
-- user_id, email 유니크 제약
+- login_id, email 유니크 제약
 
 ### 2.2 POINTS
 - 포인트 잔액
@@ -154,7 +154,7 @@ erDiagram
 - total_amount는 OrderItem 합계
 - **status**: 주문 상태
   - `COMPLETED`: 결제 완료
-  - `PAYMENT_PENDING`: 결제 대기 (결제 처리 실패 시)
+  - `PAYMENT_FAILED`: 결제 실패
 
 ### 2.7 ORDER_ITEMS
 - 주문 상품 상세

From 617d0fc80fa777c5333b42e7a671e6ef0e0ff7c2 Mon Sep 17 00:00:00 2001
From: green <green@mobidoc.us>
Date: Fri, 14 Nov 2025 15:22:24 +0900
Subject: [PATCH 19/21] =?UTF-8?q?fix:=20Example=20=EB=B3=80=EA=B2=BD=20?=
 =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=9B=90=EB=B3=B5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../main/java/com/loopers/domain/example/ExampleService.java    | 2 +-
 .../java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java    | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java
index f6acaad9f..c0e8431e8 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java
@@ -15,6 +15,6 @@ public class ExampleService {
     @Transactional(readOnly = true)
     public ExampleModel getExample(Long id) {
         return exampleRepository.find(id)
-            .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[productId = " + id + "] 예시를 찾을 수 없습니다."));
+            .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + id + "] 예시를 찾을 수 없습니다."));
     }
 }
diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java
index baedfb8f3..f7ca1f1f2 100644
--- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java
@@ -48,7 +48,7 @@ void tearDown() {
         databaseCleanUp.truncateAllTables();
     }
 
-    @DisplayName("GET /api/v1/examples/{productId}")
+    @DisplayName("GET /api/v1/examples/{id}")
     @Nested
     class Get {
         @DisplayName("존재하는 예시 ID를 주면, 해당 예시 정보를 반환한다.")

From f4847263cb08c96226ecd997b58026cc4fd04c41 Mon Sep 17 00:00:00 2001
From: green <green@mobidoc.us>
Date: Fri, 14 Nov 2025 17:04:36 +0900
Subject: [PATCH 20/21] =?UTF-8?q?refactor:=20ProductLikeFacade=20=EB=82=B4?=
 =?UTF-8?q?=20=EB=B6=84=EA=B8=B0=EC=A1=B0=EA=B1=B4=EC=9D=84=20ProductServi?=
 =?UTF-8?q?ce=EB=A1=9C=20=EC=9D=B4=EB=8F=99?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../com/loopers/application/product/ProductFacade.java     | 7 +++----
 .../com/loopers/domain/productlike/ProductLikeService.java | 4 +++-
 2 files changed, 6 insertions(+), 5 deletions(-)

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
index fbdfc8aa1..7a0200caf 100644
--- 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
@@ -30,9 +30,8 @@ public Page<ProductDetail> searchProductDetails(Long brandId, Long userId, Pagea
 
     Products products = Products.from(productPage.getContent());
     Brands brands = Brands.from(brandService.findByIdIn(products.getBrandIds()));
-    ProductLikeStatuses likeStatuses = userId != null
-        ? productLikeService.findLikeStatusByUser(userId, products.getProductIds())
-        : ProductLikeStatuses.empty();
+    ProductLikeStatuses likeStatuses =
+        productLikeService.findLikeStatusByUser(userId, products.getProductIds());
 
     Map<Long, ProductDetail> resultMap = products.toList().stream()
         .collect(Collectors.toMap(
@@ -52,7 +51,7 @@ public ProductDetail viewProductDetail(Long productId, Long userId) {
         .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
     Brand brand = brandService.getById(product.getBrandId())
         .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다."));
-    boolean isLiked = userId != null && productLikeService.isLiked(userId, productId);
+    boolean isLiked = productLikeService.isLiked(userId, productId);
 
     return ProductDetail.of(product, brand, isLiked);
   }
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLikeService.java b/apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLikeService.java
index a267989a9..d6d33c3a2 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLikeService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLikeService.java
@@ -13,6 +13,9 @@ public class ProductLikeService {
   private final ProductLikeRepository productLikeRepository;
 
   public boolean isLiked(Long userId, Long productId) {
+    if (userId == null || productId == null) {
+      return false;
+    }
     return productLikeRepository.existsByUserIdAndProductId(userId, productId);
   }
 
@@ -39,7 +42,6 @@ public void createLike(Long userId, Long productId) {
     productLikeRepository.save(like);
   }
 
-
   @Transactional
   public void deleteLike(Long userId, Long productId) {
     productLikeRepository.deleteByUserIdAndProductId(userId, productId);

From ef12384eec5f2a161ceea692c017e42ba4b51d3d Mon Sep 17 00:00:00 2001
From: green <green@mobidoc.us>
Date: Mon, 17 Nov 2025 10:16:35 +0900
Subject: [PATCH 21/21] =?UTF-8?q?refactor:=20API=20=EB=B0=8F=20=EB=8F=84?=
 =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EA=B0=9C=EC=84=A0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

변경 사항:
- ApiHeaders에서 사용자 로그인 ID를 X-USER-ID로 변경
- PointRepository에서 사용자 로그인 ID 관련 메서드 제거
- OrderPreparation 도메인 추가
- ProductLikeRepository에서 메서드 추가 및 수정
- ProductRepository에서 메서드 추가 및 수정
- ClockConfig 추가

비즈니스 로직:
- 사용자 ID 기반으로 포인트 및 좋아요 기능 개선

테스트:
- 관련 테스트 케이스 추가 및 수정
---
 .../loopers/application/like/LikeFacade.java  |  29 ++-
 .../application/order/OrderFacade.java        |  44 ++--
 .../application/order/OrderItemFactory.java   |  26 ---
 .../application/point/PointFacade.java        |   8 +-
 .../application/product/ProductFacade.java    |  54 +++--
 .../java/com/loopers/config/ClockConfig.java  |  14 ++
 .../java/com/loopers/domain/brand/Brand.java  |   4 +-
 .../loopers/domain/brand/BrandService.java    |   5 +-
 .../java/com/loopers/domain/brand/Brands.java |   6 +-
 .../java/com/loopers/domain/order/Order.java  |   2 +
 .../domain/order/OrderCreateService.java      |  50 +++++
 .../domain/order/OrderPreparation.java        |   9 +
 .../loopers/domain/order/OrderService.java    |   7 +-
 .../domain/order/orderitem/OrderItem.java     |   4 +-
 .../domain/order/orderitem/OrderItems.java    |  18 +-
 .../java/com/loopers/domain/point/Point.java  |   7 +
 .../loopers/domain/point/PointRepository.java |   4 -
 .../loopers/domain/point/PointService.java    |   8 +-
 .../com/loopers/domain/product/Product.java   |   2 +-
 .../domain/product/ProductRepository.java     |   8 +-
 .../domain/product/ProductService.java        |  25 ++-
 .../domain/productlike/ProductLike.java       |   3 +-
 .../productlike/ProductLikeRepository.java    |   4 +-
 .../productlike/ProductLikeService.java       |  28 +--
 .../productlike/ProductLikeStatuses.java      |  28 ---
 .../domain/productlike/ProductLikes.java      |  17 +-
 .../com/loopers/domain/quantity/Quantity.java |   6 +-
 .../java/com/loopers/domain/user/User.java    |  16 +-
 .../com/loopers/domain/user/UserService.java  |   7 +-
 .../point/PointJpaRepository.java             |   7 -
 .../point/PointRepositoryImpl.java            |  10 -
 .../product/ProductJpaRepository.java         |  11 +-
 .../product/ProductRepositoryImpl.java        |  20 +-
 .../productlike/ProductLikeJpaRepository.java |   7 +-
 .../ProductLikeRepositoryImpl.java            |   9 +-
 .../interfaces/api/point/PointApiSpec.java    |   8 +-
 .../interfaces/api/point/PointController.java |   8 +-
 .../interfaces/api/support/ApiHeaders.java    |   2 +-
 .../application/brand/BrandFacadeTest.java    |   7 +-
 .../like/LikeFacadeIntegrationTest.java       | 163 ++++++++++++++
 .../application/like/LikeFacadeTest.java      | 180 ----------------
 .../order/OrderFacadeIntegrationTest.java     |  28 +--
 .../order/OrderFacadeRaceConditionTest.java   | 199 ++++++++++--------
 .../product/ProductFacadeIntegrationTest.java |  55 ++---
 .../brand/BrandServiceIntegrationTest.java    | 139 ++++++++++++
 .../domain/order/OrderCreateServiceTest.java  | 116 ++++++++++
 .../order/OrderRepositoryIntegrationTest.java |  31 +--
 .../order/OrderServiceIntegrationTest.java    | 163 ++++++++++++--
 .../domain/order/OrderServiceTest.java        |  26 ++-
 .../com/loopers/domain/order/OrderTest.java   |  14 +-
 .../domain/order/orderitem/OrderItemTest.java |  22 +-
 .../point/PointServiceIntegrationTest.java    |  18 +-
 .../domain/product/ProductServiceTest.java    |   6 +-
 .../productlike/ProductLikeStatusesTest.java  |  60 ------
 .../domain/productlike/ProductLikesTest.java  |  79 ++++---
 .../loopers/domain/quantity/QuantityTest.java |  40 ++--
 .../user/UserServiceIntegrationTest.java      |  13 +-
 .../com/loopers/domain/user/UserTest.java     |  20 +-
 .../interfaces/api/point/PointApiE2ETest.java |  26 +--
 docs/design/01-requirements.md                |   2 +-
 60 files changed, 1181 insertions(+), 751 deletions(-)
 delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemFactory.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/config/ClockConfig.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderCreateService.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderPreparation.java
 delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLikeStatuses.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java
 delete mode 100644 apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderCreateServiceTest.java
 delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/productlike/ProductLikeStatusesTest.java

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
index 92c3b1233..9d2244263 100644
--- 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
@@ -1,45 +1,42 @@
 package com.loopers.application.like;
 
-import com.loopers.domain.product.Product;
 import com.loopers.domain.product.ProductService;
 import com.loopers.domain.productlike.ProductLikeService;
 import com.loopers.support.error.CoreException;
 import com.loopers.support.error.ErrorType;
 import lombok.RequiredArgsConstructor;
+import org.springframework.dao.DataIntegrityViolationException;
 import org.springframework.stereotype.Component;
 import org.springframework.transaction.annotation.Transactional;
 
 @Component
 @RequiredArgsConstructor
-@Transactional
 public class LikeFacade {
 
   private final ProductLikeService productLikeService;
   private final ProductService productService;
 
+  @Transactional
   public void likeProduct(Long userId, Long productId) {
-    Product product = productService.getById(productId)
+    productService.getById(productId)
         .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
+    try {
+      productLikeService.createLike(userId, productId);
+      productService.increaseLikeCount(productId);
+    } catch (DataIntegrityViolationException e) {
 
-    boolean alreadyLiked = productLikeService.isLiked(userId, productId);
-    if (alreadyLiked) {
-      return;
     }
-
-    productLikeService.createLike(userId, productId);
-    product.increaseLikeCount();
   }
 
+  @Transactional
   public void unlikeProduct(Long userId, Long productId) {
-    Product product = productService.getById(productId)
+    productService.getById(productId)
         .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
 
-    boolean liked = productLikeService.isLiked(userId, productId);
-    if (!liked) {
-      return;
-    }
+    int deleted = productLikeService.deleteLike(userId, productId);
 
-    productLikeService.deleteLike(userId, productId);
-    product.decreaseLikeCount();
+    if (deleted > 0) {
+      productService.decreaseLikeCount(productId);
+    }
   }
 }
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
index f82b01e1a..e8553ee12 100644
--- 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
@@ -1,51 +1,57 @@
 package com.loopers.application.order;
 
 import com.loopers.domain.order.Order;
-import com.loopers.domain.order.orderitem.OrderItem;
-import com.loopers.domain.order.orderitem.OrderItemCommand;
-import com.loopers.domain.order.orderitem.OrderItems;
+import com.loopers.domain.order.OrderCreateService;
+import com.loopers.domain.order.OrderPreparation;
 import com.loopers.domain.order.OrderService;
+import com.loopers.domain.order.orderitem.OrderItemCommand;
 import com.loopers.domain.point.PointService;
-import com.loopers.domain.product.Products;
+import com.loopers.domain.product.Product;
 import com.loopers.domain.product.ProductService;
+import java.util.Objects;
 import lombok.RequiredArgsConstructor;
 import org.springframework.stereotype.Component;
 import org.springframework.transaction.annotation.Transactional;
 
 import java.time.LocalDateTime;
 import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
 
 @Component
 @RequiredArgsConstructor
 public class OrderFacade {
 
   private final OrderService orderService;
+  private final OrderCreateService orderCreateService;
   private final ProductService productService;
   private final PointService pointService;
-  private final OrderItemFactory orderItemFactory;
 
   @Transactional
   public Order createOrder(Long userId, List<OrderItemCommand> commands, LocalDateTime orderedAt) {
-    List<Long> productIds = commands.stream()
-        .map(OrderItemCommand::productId)
-        .toList();
+    Objects.requireNonNull(userId, "유저아이디는 null 일수 없습니다.");
+    Objects.requireNonNull(commands, "상품 주문 정보는 null일 수 없습니다.");
+    Objects.requireNonNull(orderedAt, "주문 요청 시간은 null일 수 없습니다");
 
-    Products products = Products.from(productService.getByIdsWithLock(productIds));
+    Map<Long, Product> productById = getProductMapWithLock(commands);
 
-    List<OrderItem> items = commands.stream()
-        .map(cmd -> orderItemFactory.create(cmd, products))
-        .toList();
+    OrderPreparation result = orderCreateService.prepareOrder(commands, productById);
 
-    OrderItems orderItems = new OrderItems(items);
+    pointService.deduct(userId, result.totalAmount());
 
-    orderItems.validateStock(products);
+    result.orderItems().decreaseStock(productById);
 
-    Long totalAmount = orderItems.calculateTotalAmount();
-
-    pointService.deduct(userId, totalAmount);
+    return orderService.create(userId, result.orderItems().getItems(), orderedAt);
+  }
 
-    orderItems.decreaseStock(products);
+  private Map<Long, Product> getProductMapWithLock(List<OrderItemCommand> commands) {
+    List<Long> productIds = commands.stream()
+        .map(OrderItemCommand::productId)
+        .distinct()
+        .toList();
 
-    return orderService.create(userId, orderItems.getItems(), orderedAt);
+    return productService.findByIdsWithLock(productIds).stream()
+        .collect(Collectors.toMap(Product::getId, Function.identity()));
   }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemFactory.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemFactory.java
deleted file mode 100644
index 49877287e..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemFactory.java
+++ /dev/null
@@ -1,26 +0,0 @@
-package com.loopers.application.order;
-
-import com.loopers.domain.order.orderitem.OrderItem;
-import com.loopers.domain.order.orderitem.OrderItemCommand;
-import com.loopers.domain.order.orderitem.OrderPrice;
-import com.loopers.domain.product.Product;
-import com.loopers.domain.product.Products;
-import com.loopers.support.error.CoreException;
-import com.loopers.support.error.ErrorType;
-import org.springframework.stereotype.Component;
-
-@Component
-public class OrderItemFactory {
-
-  public OrderItem create(OrderItemCommand command, Products products) {
-    Product product = products.findById(command.productId())
-        .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
-
-    return OrderItem.of(
-        product.getId(),
-        product.getName(),
-        command.quantity(),
-        OrderPrice.of(product.getPriceValue())
-    );
-  }
-}
\ No newline at end of file
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
index 710318cd4..3f6bd6940 100644
--- 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
@@ -12,14 +12,14 @@ public class PointFacade {
 
     private final PointService pointService;
 
-    public Optional<PointResult> getPoint(String loginId) {
-      return pointService.findByUserLoginId(loginId)
+    public Optional<PointResult> getPoint(Long userId) {
+      return pointService.findByUserId(userId)
           .map(PointResult::from);
     }
 
     @Transactional
-    public PointResult charge(String loginId, Long amount) {
-      return PointResult.from(pointService.charge(loginId, amount));
+    public PointResult charge(Long userId, Long amount) {
+      return PointResult.from(pointService.charge(userId, amount));
     }
 
 }
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
index 7a0200caf..eaf4c9846 100644
--- 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
@@ -2,15 +2,14 @@
 
 import com.loopers.domain.brand.Brand;
 import com.loopers.domain.brand.BrandService;
-import com.loopers.domain.brand.Brands;
 import com.loopers.domain.product.Product;
 import com.loopers.domain.product.ProductService;
-import com.loopers.domain.product.Products;
 import com.loopers.domain.productlike.ProductLikeService;
-import com.loopers.domain.productlike.ProductLikeStatuses;
 import com.loopers.support.error.CoreException;
 import com.loopers.support.error.ErrorType;
+import java.util.List;
 import java.util.Map;
+import java.util.function.Function;
 import java.util.stream.Collectors;
 import lombok.RequiredArgsConstructor;
 import org.springframework.data.domain.Page;
@@ -27,23 +26,40 @@ public class ProductFacade {
 
   public Page<ProductDetail> searchProductDetails(Long brandId, Long userId, Pageable pageable) {
     Page<Product> productPage = productService.findProducts(brandId, pageable);
+    List<Product> products = productPage.getContent();
 
-    Products products = Products.from(productPage.getContent());
-    Brands brands = Brands.from(brandService.findByIdIn(products.getBrandIds()));
-    ProductLikeStatuses likeStatuses =
-        productLikeService.findLikeStatusByUser(userId, products.getProductIds());
-
-    Map<Long, ProductDetail> resultMap = products.toList().stream()
-        .collect(Collectors.toMap(
-            Product::getId,
-            product -> ProductDetail.of(
-                product,
-                brands.getBrandById(product.getBrandId()),
-                likeStatuses.isLiked(product.getId())
-            )
-        ));
-
-    return productPage.map(product -> resultMap.get(product.getId()));
+    Map<Long, Brand> brandById = getBrandById(products);
+    Map<Long, Boolean> likeStatusByProductId = getLikeStatusByProductId(userId, products);
+
+    return getProductDetails(productPage, brandById, likeStatusByProductId);
+  }
+
+
+  private Map<Long, Brand> getBrandById(List<Product> products) {
+    List<Long> brandIds = products.stream()
+        .map(Product::getBrandId)
+        .distinct()
+        .toList();
+
+    return brandService.findByIdIn(brandIds).stream()
+        .collect(Collectors.toMap(Brand::getId, Function.identity()));
+  }
+
+  private Map<Long, Boolean> getLikeStatusByProductId(Long userId, List<Product> products) {
+    List<Long> productIds = products.stream()
+        .map(Product::getId)
+        .toList();
+
+    return productLikeService.findLikeStatusByProductId(userId, productIds);
+  }
+
+  private Page<ProductDetail> getProductDetails(Page<Product> productPage, Map<Long, Brand> brandById,
+      Map<Long, Boolean> likeStatusByProductId) {
+    return productPage.map(product -> ProductDetail.of(
+        product,
+        brandById.get(product.getBrandId()),
+        likeStatusByProductId.getOrDefault(product.getId(), false)
+    ));
   }
 
   public ProductDetail viewProductDetail(Long productId, Long userId) {
diff --git a/apps/commerce-api/src/main/java/com/loopers/config/ClockConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/ClockConfig.java
new file mode 100644
index 000000000..ca76c1896
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/config/ClockConfig.java
@@ -0,0 +1,14 @@
+package com.loopers.config;
+
+import java.time.Clock;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class ClockConfig {
+
+  @Bean
+  public Clock clock() {
+    return Clock.systemDefaultZone();
+  }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java
index fa8c89bd1..9d1f0dbbc 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java
@@ -9,6 +9,8 @@
 import lombok.AccessLevel;
 import lombok.NoArgsConstructor;
 
+import java.util.Objects;
+
 @Entity
 @Table(name = "brand")
 @NoArgsConstructor(access = AccessLevel.PROTECTED)
@@ -46,7 +48,7 @@ private void validateName(String name) {
   }
 
   public boolean isSameId(Long otherId) {
-    return this.getId().equals(otherId);
+    return Objects.equals(this.getId(), otherId);
   }
 
   public String getName() {
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java
index adce57ab7..8f7385729 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java
@@ -1,13 +1,12 @@
 package com.loopers.domain.brand;
 
 import lombok.RequiredArgsConstructor;
-import org.springframework.stereotype.Service;
+import org.springframework.stereotype.Component;
 
 import java.util.List;
 import java.util.Optional;
 
-
-@Service
+@Component
 @RequiredArgsConstructor
 public class BrandService {
 
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brands.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brands.java
index f3451026b..747b85a19 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brands.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brands.java
@@ -4,6 +4,7 @@
 import com.loopers.support.error.ErrorType;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Objects;
 
 public class Brands {
 
@@ -14,7 +15,8 @@ private Brands(List<Brand> values) {
   }
 
   public static Brands from(List<Brand> brands) {
-    return new Brands(new ArrayList<>(brands));
+    Objects.requireNonNull(brands, "brands는 null일 수 없습니다.");
+    return new Brands(List.copyOf(brands));
   }
 
   public Brand getBrandById(Long brandId) {
@@ -25,6 +27,6 @@ public Brand getBrandById(Long brandId) {
   }
 
   public List<Brand> toList() {
-    return List.copyOf(values);
+    return new ArrayList<>(values);
   }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java
index 74037d59e..14e3116cf 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java
@@ -6,6 +6,7 @@
 import com.loopers.support.error.CoreException;
 import com.loopers.support.error.ErrorType;
 import jakarta.persistence.*;
+import java.util.Objects;
 import lombok.AccessLevel;
 import lombok.Getter;
 import lombok.NoArgsConstructor;
@@ -64,6 +65,7 @@ public Long getTotalAmountValue() {
 
 
   public void addItem(OrderItem item) {
+    Objects.requireNonNull(item, "OrderItem은 null일 수 없습니다.");
     this.items.add(item);
     item.assignOrder(this);
   }
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderCreateService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderCreateService.java
new file mode 100644
index 000000000..3d4b1dff9
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderCreateService.java
@@ -0,0 +1,50 @@
+package com.loopers.domain.order;
+
+import com.loopers.domain.order.orderitem.OrderItem;
+import com.loopers.domain.order.orderitem.OrderItemCommand;
+import com.loopers.domain.order.orderitem.OrderItems;
+import com.loopers.domain.order.orderitem.OrderPrice;
+import com.loopers.domain.product.Product;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+@Component
+public class OrderCreateService {
+
+  public OrderPreparation prepareOrder(List<OrderItemCommand> commands,
+                                          Map<Long, Product> productById) {
+    OrderItems orderItems = createOrderItems(commands, productById);
+
+    orderItems.validateStock(productById);
+
+    Long totalAmount = orderItems.calculateTotalAmount();
+
+    return new OrderPreparation(orderItems, totalAmount);
+  }
+
+  private OrderItems createOrderItems(List<OrderItemCommand> commands,
+                                      Map<Long, Product> productById) {
+    List<OrderItem> items = commands.stream()
+        .map(cmd -> createOrderItem(cmd, productById))
+        .toList();
+
+    return new OrderItems(items);
+  }
+
+  private OrderItem createOrderItem(OrderItemCommand command, Map<Long, Product> productById) {
+    Product product = Optional.ofNullable(productById.get(command.productId()))
+        .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
+
+    return OrderItem.of(
+        product.getId(),
+        product.getName(),
+        command.quantity(),
+        OrderPrice.of(product.getPriceValue())
+    );
+  }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderPreparation.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderPreparation.java
new file mode 100644
index 000000000..847deb5f6
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderPreparation.java
@@ -0,0 +1,9 @@
+package com.loopers.domain.order;
+
+import com.loopers.domain.order.orderitem.OrderItems;
+
+public record OrderPreparation(
+    OrderItems orderItems,
+    Long totalAmount
+) {
+}
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
index 052b49423..089566399 100644
--- 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
@@ -4,13 +4,14 @@
 import com.loopers.support.error.CoreException;
 import com.loopers.support.error.ErrorType;
 import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
 import org.springframework.stereotype.Service;
 
 import java.time.LocalDateTime;
 import java.util.List;
 import java.util.Optional;
 
-@Service
+@Component
 @RequiredArgsConstructor
 public class OrderService {
 
@@ -27,7 +28,7 @@ public Optional<Order> getWithItemsById(Long orderId) {
   public Order create(Long userId, List<OrderItem> orderItems, LocalDateTime orderedAt) {
     Long totalAmount = calculateTotalAmount(orderItems);
 
-    Order order = Order.of(userId, OrderStatus.PAYMENT_FAILED, totalAmount, orderedAt);
+    Order order = Order.of(userId, OrderStatus.PENDING, totalAmount, orderedAt);
 
     orderItems.forEach(order::addItem);
 
@@ -41,7 +42,7 @@ public Order completeOrder(Long orderId) {
     return orderRepository.save(order);
   }
 
-  public Order markOrderAsPaymentPending(Long orderId) {
+  public Order failPaymentOrder(Long orderId) {
     Order order = getById(orderId)
         .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다."));
     order.failPayment();
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/orderitem/OrderItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/orderitem/OrderItem.java
index 82f8ba819..7b2aed119 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/order/orderitem/OrderItem.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/orderitem/OrderItem.java
@@ -6,6 +6,7 @@
 import com.loopers.support.error.CoreException;
 import com.loopers.support.error.ErrorType;
 import jakarta.persistence.*;
+import java.util.Objects;
 import lombok.AccessLevel;
 import lombok.Getter;
 import lombok.NoArgsConstructor;
@@ -66,6 +67,7 @@ public static OrderItem of(Long productId, String productName, Quantity quantity
    * Order.addItem()에서만 호출되어야 함
    */
   public void assignOrder(Order order) {
+    Objects.requireNonNull(order, "Order는 null일 수 없습니다.");
     this.order = order;
   }
 
@@ -73,7 +75,7 @@ public Long getOrderPriceValue() {
     return orderPrice.getValue();
   }
 
-  public Integer getQuantityValue() {
+  public Long getQuantityValue() {
     return quantity.getValue();
   }
 
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/orderitem/OrderItems.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/orderitem/OrderItems.java
index 754ab6b0d..e45c5ae5b 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/order/orderitem/OrderItems.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/orderitem/OrderItems.java
@@ -1,12 +1,12 @@
 package com.loopers.domain.order.orderitem;
 
 import com.loopers.domain.product.Product;
-import com.loopers.domain.product.Products;
 import com.loopers.support.error.CoreException;
 import com.loopers.support.error.ErrorType;
 
 import java.util.List;
-
+import java.util.Map;
+import java.util.Optional;
 
 public class OrderItems {
 
@@ -22,22 +22,22 @@ public Long calculateTotalAmount() {
         .sum();
   }
 
-  public void validateStock(Products products) {
+  public void validateStock(Map<Long, Product> productById) {
     for (OrderItem item : items) {
-      Product product = findById(products, item);
+      Product product = findById(productById, item);
       product.validateStockForOrder(item.getQuantityValue());
     }
   }
 
-  public void decreaseStock(Products products) {
+  public void decreaseStock(Map<Long, Product> productById) {
     for (OrderItem item : items) {
-      Product product = findById(products, item);
-      product.decreaseStock(item.getQuantityValue().longValue());
+      Product product = findById(productById, item);
+      product.decreaseStock(item.getQuantityValue());
     }
   }
 
-  private Product findById(Products products, OrderItem item) {
-    return products.findById(item.getProductId())
+  private Product findById(Map<Long, Product> productById, OrderItem item) {
+    return Optional.ofNullable(productById.get(item.getProductId()))
         .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
   }
 
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java
index fb16a62ad..d387d096d 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java
@@ -22,6 +22,7 @@ protected Point() {
 
   private Point(Long userId, PointAmount amount) {
     validateUserId(userId);
+    validateAmount(amount);
     this.userId = userId;
     this.amount = amount;
   }
@@ -44,6 +45,12 @@ private void validateUserId(Long userId) {
     }
   }
 
+  private void validateAmount(PointAmount amount) {
+    if (amount == null) {
+      throw new CoreException(ErrorType.INVALID_POINT_AMOUNT_EMPTY);
+    }
+  }
+
   public void charge(Long chargeAmount) {
     this.amount = this.amount.add(chargeAmount);
   }
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
index c44517fda..c1c94e28b 100644
--- 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
@@ -9,8 +9,4 @@ public interface PointRepository {
   Optional<Point> findByUserId(Long userId);
 
   Optional<Point> findByUserIdWithLock(Long userId);
-
-  Optional<Point> findByUserLoginId(String loginId);
-
-  Optional<Point> findByUserLoginIdWithLock(String loginId);
 }
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
index 14d99ce4b..84629c6a7 100644
--- 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
@@ -17,13 +17,9 @@ public Optional<Point> findByUserId(Long userId) {
       return pointRepository.findByUserId(userId);
     }
 
-    public Optional<Point> findByUserLoginId(String loginId) {
-      return pointRepository.findByUserLoginId(loginId);
-    }
-
     @Transactional
-    public Point charge(String loginId, Long chargeAmount) {
-        Point point = pointRepository.findByUserLoginIdWithLock(loginId)
+    public Point charge(Long userId, Long chargeAmount) {
+        Point point = pointRepository.findByUserIdWithLock(userId)
             .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND,
                 "포인트 정보를 찾을 수 없습니다."));
         point.charge(chargeAmount);
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java
index d0eab37ce..132dea3ee 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java
@@ -101,7 +101,7 @@ public boolean isSameId(Long id) {
     return id != null && id.equals(getId());
   }
 
-  public void validateStockForOrder(Integer requestedQuantity) {
+  public void validateStockForOrder(Long requestedQuantity) {
     if (isNotAvailable()) {
       throw new CoreException(ErrorType.INSUFFICIENT_STOCK,
           String.format("상품 [%s]의 재고가 부족합니다.", name));
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
index e580b6d96..e9469c32d 100644
--- 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
@@ -14,9 +14,13 @@ public interface ProductRepository {
 
   Optional<Product> findById(Long id);
 
-  List<Product> findAllById(List<Long> ids);
-
   List<Product> findAllByIdWithLock(List<Long> ids);
 
   Product save(Product product);
+
+  Product saveAndFlush(Product product);
+
+  void incrementLikeCount(Long productId);
+
+  void decrementLikeCount(Long productId);
 }
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
index 9d2c5ccba..f2a73564b 100644
--- 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
@@ -1,14 +1,15 @@
 package com.loopers.domain.product;
 
-import jakarta.transaction.Transactional;
 import java.util.List;
+import java.util.Objects;
 import java.util.Optional;
 import lombok.RequiredArgsConstructor;
 import org.springframework.data.domain.Page;
 import org.springframework.data.domain.Pageable;
-import org.springframework.stereotype.Service;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
 
-@Service
+@Component
 @RequiredArgsConstructor
 public class ProductService {
 
@@ -19,7 +20,8 @@ public Optional<Product> getById(Long productId) {
   }
 
   @Transactional
-  public List<Product> getByIdsWithLock(List<Long> productIds) {
+  public List<Product> findByIdsWithLock(List<Long> productIds) {
+    Objects.requireNonNull(productIds, "상품 ID 목록은 null일 수 없습니다");
     List<Long> distinctIds = productIds.stream().distinct().toList();
     return productRepository.findAllByIdWithLock(distinctIds);
   }
@@ -30,4 +32,19 @@ public Page<Product> findProducts(Long brandId, Pageable pageable) {
         : productRepository.findAll(pageable);
   }
 
+  public Product create(Product product) {
+    return productRepository.saveAndFlush(product);
+  }
+
+  @Transactional
+  public void increaseLikeCount(Long productId) {
+    productRepository.incrementLikeCount(productId);
+  }
+
+  @Transactional
+  public void decreaseLikeCount(Long productId) {
+    productRepository.decrementLikeCount(productId);
+
+  }
+
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLike.java b/apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLike.java
index a61707438..b872127c3 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLike.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLike.java
@@ -4,6 +4,7 @@
 import com.loopers.support.error.CoreException;
 import com.loopers.support.error.ErrorType;
 import jakarta.persistence.*;
+import java.util.Objects;
 import lombok.AccessLevel;
 import lombok.Getter;
 import lombok.NoArgsConstructor;
@@ -53,7 +54,7 @@ public static ProductLike of(Long userId, Long productId, LocalDateTime likedAt)
   }
 
   public boolean isLikedBy(Long userId) {
-    return this.userId.equals(userId);
+    return Objects.equals(this.userId, userId);
   }
 
   private void validateUserId(Long userId) {
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLikeRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLikeRepository.java
index a3c2546e5..93751bae3 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLikeRepository.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLikeRepository.java
@@ -10,5 +10,7 @@ public interface ProductLikeRepository {
 
   ProductLike save(ProductLike productLike);
 
-  void deleteByUserIdAndProductId(Long userId, Long productId);
+  ProductLike saveAndFlush(ProductLike productLike);
+
+  int deleteByUserIdAndProductId(Long userId, Long productId);
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLikeService.java b/apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLikeService.java
index d6d33c3a2..cfbc244d0 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLikeService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLikeService.java
@@ -1,16 +1,20 @@
 package com.loopers.domain.productlike;
 
+import java.time.Clock;
 import java.time.LocalDateTime;
 import java.util.List;
+import java.util.Map;
 import lombok.RequiredArgsConstructor;
-import org.springframework.stereotype.Service;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Propagation;
 import org.springframework.transaction.annotation.Transactional;
 
-@Service
+@Component
 @RequiredArgsConstructor
 public class ProductLikeService {
 
   private final ProductLikeRepository productLikeRepository;
+  private final Clock clock;
 
   public boolean isLiked(Long userId, Long productId) {
     if (userId == null || productId == null) {
@@ -20,30 +24,26 @@ public boolean isLiked(Long userId, Long productId) {
   }
 
 
-  public ProductLikeStatuses findLikeStatusByUser(Long userId, List<Long> productIds) {
+  public Map<Long, Boolean> findLikeStatusByProductId(Long userId, List<Long> productIds) {
     if (userId == null || productIds == null || productIds.isEmpty()) {
-      return ProductLikeStatuses.empty();
+      return Map.of();
     }
 
     List<ProductLike> likes = productLikeRepository.findByUserIdAndProductIdIn(userId, productIds);
     ProductLikes productLikes = ProductLikes.from(likes);
 
-    return productLikes.toStatuses(productIds);
+    return productLikes.toLikeStatusByProductId(productIds);
   }
 
 
-  @Transactional
+  @Transactional(propagation = Propagation.REQUIRES_NEW)
   public void createLike(Long userId, Long productId) {
-    if (productLikeRepository.existsByUserIdAndProductId(userId, productId)) {
-      return;
-    }
-
-    ProductLike like = ProductLike.of(userId, productId, LocalDateTime.now());
-    productLikeRepository.save(like);
+      ProductLike like = ProductLike.of(userId, productId, LocalDateTime.now(clock));
+      productLikeRepository.saveAndFlush(like);
   }
 
   @Transactional
-  public void deleteLike(Long userId, Long productId) {
-    productLikeRepository.deleteByUserIdAndProductId(userId, productId);
+  public int deleteLike(Long userId, Long productId) {
+    return productLikeRepository.deleteByUserIdAndProductId(userId, productId);
   }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLikeStatuses.java b/apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLikeStatuses.java
deleted file mode 100644
index c867b9b54..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLikeStatuses.java
+++ /dev/null
@@ -1,28 +0,0 @@
-package com.loopers.domain.productlike;
-
-import java.util.Map;
-
-public class ProductLikeStatuses {
-
-  private final Map<Long, Boolean> values;
-
-  private ProductLikeStatuses(Map<Long, Boolean> values) {
-    this.values = values;
-  }
-
-  public static ProductLikeStatuses from(Map<Long, Boolean> statuses) {
-    return new ProductLikeStatuses(Map.copyOf(statuses));
-  }
-
-  public static ProductLikeStatuses empty() {
-    return new ProductLikeStatuses(Map.of());
-  }
-
-  public boolean isLiked(Long productId) {
-    return values.getOrDefault(productId, false);
-  }
-
-  public Map<Long, Boolean> toMap() {
-    return Map.copyOf(values);
-  }
-}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLikes.java b/apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLikes.java
index 0381d821d..a30a48e83 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLikes.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLikes.java
@@ -23,25 +23,24 @@ public static ProductLikes empty() {
     return new ProductLikes(List.of());
   }
 
-  public Map<Long, Boolean> toLikedMap() {
+  // 중복 productId가 있으면 IllegalStateException 발생 (데이터 무결성 오류)
+  public Map<Long, Boolean> toLikedByProductId() {
     return values.stream()
         .collect(Collectors.toMap(ProductLike::getProductId, like -> true));
   }
 
-  public ProductLikeStatuses toStatuses(List<Long> allProductIds) {
+  public Map<Long, Boolean> toLikeStatusByProductId(List<Long> allProductIds) {
     if (allProductIds == null || allProductIds.isEmpty()) {
-      return ProductLikeStatuses.empty();
+      return Map.of();
     }
 
-    Map<Long, Boolean> likedMap = toLikedMap();
+    Map<Long, Boolean> likedByProductId = toLikedByProductId();
 
-    Map<Long, Boolean> statusMap = allProductIds.stream()
+    // allProductIds에 중복이 있으면 IllegalStateException 발생
+    return allProductIds.stream()
         .collect(Collectors.toMap(
             productId -> productId,
-            productId -> likedMap.getOrDefault(productId, false),
-            (newValue, existingValue) -> existingValue
+            productId -> likedByProductId.getOrDefault(productId, false)
         ));
-
-    return ProductLikeStatuses.from(statusMap);
   }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/quantity/Quantity.java b/apps/commerce-api/src/main/java/com/loopers/domain/quantity/Quantity.java
index ceb52181e..abe73916d 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/quantity/Quantity.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/quantity/Quantity.java
@@ -15,9 +15,9 @@
 @Getter
 public class Quantity {
 
-  private Integer value;
+  private Long value;
 
-  private Quantity(Integer value) {
+  private Quantity(Long value) {
     if (value == null) {
       throw new CoreException(ErrorType.INVALID_QUANTITY_VALUE);
     }
@@ -27,7 +27,7 @@ private Quantity(Integer value) {
     this.value = value;
   }
 
-  public static Quantity of(Integer value) {
+  public static Quantity of(Long value) {
     return new Quantity(value);
   }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java
index e7758661a..defebdf63 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java
@@ -8,12 +8,10 @@
 import jakarta.persistence.Enumerated;
 import jakarta.persistence.EnumType;
 import jakarta.persistence.Table;
+import java.time.LocalDate;
 import java.util.regex.Pattern;
 import lombok.Getter;
 
-import java.time.Clock;
-import java.time.LocalDate;
-
 @Entity
 @Table(name = "users")
 @Getter
@@ -41,13 +39,13 @@ public class User extends BaseEntity {
   protected User() {
   }
 
-  private User(String loginId, String email, LocalDate birth, Gender gender, Clock clock) {
+  private User(String loginId, String email, LocalDate birth, Gender gender, LocalDate currentDate) {
     String normalizedLoginId = loginId != null ? loginId.trim() : null;
     String normalizedEmail = email != null ? email.toLowerCase().trim() : null;
 
     validateLoginId(normalizedLoginId);
     validateEmail(normalizedEmail);
-    validateBirth(birth, LocalDate.now(clock));
+    validateBirth(birth, currentDate);
     validateGender(gender);
 
     this.loginId = normalizedLoginId;
@@ -56,12 +54,8 @@ private User(String loginId, String email, LocalDate birth, Gender gender, Clock
     this.gender = gender;
   }
 
-  public static User of(String loginId, String email, LocalDate birth, Gender gender) {
-    return of(loginId, email, birth, gender, Clock.systemDefaultZone());
-  }
-
-  public static User of(String loginId, String email, LocalDate birth, Gender gender, Clock clock) {
-    return new User(loginId, email, birth, gender, clock);
+  public static User of(String loginId, String email, LocalDate birth, Gender gender, LocalDate currentDate) {
+    return new User(loginId, email, birth, gender, currentDate);
   }
 
   private void validateLoginId(String loginId) {
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
index ff52f14dc..4539aefcf 100644
--- 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
@@ -2,6 +2,7 @@
 
 import com.loopers.support.error.CoreException;
 import com.loopers.support.error.ErrorType;
+import java.time.Clock;
 import lombok.RequiredArgsConstructor;
 import org.springframework.dao.DataIntegrityViolationException;
 import org.springframework.stereotype.Component;
@@ -15,17 +16,19 @@
 public class UserService {
 
   private final UserRepository userRepository;
+  private final Clock clock;
 
   @Transactional
   public User registerUser(String loginId, String email, LocalDate birth, Gender gender) {
     try {
-      return userRepository.save(User.of(loginId, email, birth, gender));
+      return userRepository.save(User.of(loginId, email, birth, gender, LocalDate.now(clock)));
     } catch (DataIntegrityViolationException e) {
       throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 로그인 ID입니다.");
     }
   }
 
   public User findById(String loginId) {
-    return userRepository.findByLoginId(loginId).orElse(null);
+    return userRepository.findByLoginId(loginId)
+        .orElse(null);
   }
 }
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
index 97664d6db..d0120efaf 100644
--- 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
@@ -18,11 +18,4 @@ public interface PointJpaRepository extends JpaRepository<Point, Long> {
   @Query("SELECT p FROM Point p WHERE p.userId = :userId")
   Optional<Point> findByUserIdWithLock(@Param("userId") Long userId);
 
-  @Query("SELECT p FROM Point p JOIN User u ON p.userId = u.id WHERE u.loginId = :loginId")
-  Optional<Point> findByUserLoginId(@Param("loginId") String loginId);
-
-  @Lock(LockModeType.PESSIMISTIC_WRITE)
-  @Query("SELECT p FROM Point p JOIN User u ON p.userId = u.id WHERE u.loginId = :loginId")
-  Optional<Point> findByUserLoginIdWithLock(@Param("loginId") String loginId);
-
 }
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
index c13b4a65d..6f23b5065 100644
--- 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
@@ -28,14 +28,4 @@ public Optional<Point> findByUserIdWithLock(Long userId) {
     return pointJpaRepository.findByUserIdWithLock(userId);
   }
 
-  @Override
-  public Optional<Point> findByUserLoginId(String loginId) {
-    return pointJpaRepository.findByUserLoginId(loginId);
-  }
-
-  @Override
-  public Optional<Point> findByUserLoginIdWithLock(String loginId) {
-    return pointJpaRepository.findByUserLoginIdWithLock(loginId);
-  }
-
 }
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
index 9be61c7c0..72321701e 100644
--- 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
@@ -6,6 +6,7 @@
 import org.springframework.data.domain.Pageable;
 import org.springframework.data.jpa.repository.JpaRepository;
 import org.springframework.data.jpa.repository.Lock;
+import org.springframework.data.jpa.repository.Modifying;
 import org.springframework.data.jpa.repository.Query;
 import org.springframework.data.repository.query.Param;
 
@@ -16,6 +17,14 @@ public interface ProductJpaRepository extends JpaRepository<Product, Long> {
   Page<Product> findByBrandId(Long brandId, Pageable pageable);
 
   @Lock(LockModeType.PESSIMISTIC_WRITE)
-  @Query("SELECT p FROM Product p WHERE p.id IN :ids")
+  @Query("SELECT p FROM Product p WHERE p.id IN :ids ORDER BY p.id ASC")
   List<Product> findAllByIdWithLock(@Param("ids") List<Long> ids);
+
+  @Modifying(clearAutomatically = true, flushAutomatically = true)
+  @Query("UPDATE Product p SET p.likeCount = p.likeCount + 1 WHERE p.id = :productId")
+  int incrementLikeCount(@Param("productId") Long productId);
+
+  @Modifying(clearAutomatically = true, flushAutomatically = true)
+  @Query("UPDATE Product p SET p.likeCount = p.likeCount - 1 WHERE p.id = :productId AND p.likeCount > 0")
+  int decrementLikeCount(@Param("productId") Long productId);
 }
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
index f6a27e335..c188f163e 100644
--- 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
@@ -31,11 +31,6 @@ public Optional<Product> findById(Long id) {
     return jpaRepository.findById(id);
   }
 
-  @Override
-  public List<Product> findAllById(List<Long> ids) {
-    return jpaRepository.findAllById(ids);
-  }
-
   @Override
   public List<Product> findAllByIdWithLock(List<Long> ids) {
     return jpaRepository.findAllByIdWithLock(ids);
@@ -45,4 +40,19 @@ public List<Product> findAllByIdWithLock(List<Long> ids) {
   public Product save(Product product) {
     return jpaRepository.save(product);
   }
+
+  @Override
+  public Product saveAndFlush(Product product) {
+    return jpaRepository.saveAndFlush(product);
+  }
+
+  @Override
+  public void incrementLikeCount(Long productId) {
+    jpaRepository.incrementLikeCount(productId);
+  }
+
+  @Override
+  public void decrementLikeCount(Long productId) {
+    jpaRepository.decrementLikeCount(productId);
+  }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/productlike/ProductLikeJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/productlike/ProductLikeJpaRepository.java
index afb627865..3fa89d307 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/productlike/ProductLikeJpaRepository.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/productlike/ProductLikeJpaRepository.java
@@ -2,6 +2,9 @@
 
 import com.loopers.domain.productlike.ProductLike;
 import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
 
 import java.util.List;
 
@@ -11,5 +14,7 @@ public interface ProductLikeJpaRepository extends JpaRepository<ProductLike, Lon
 
   List<ProductLike> findByUserIdAndProductIdIn(Long userId, List<Long> productIds);
 
-  void deleteByUserIdAndProductId(Long userId, Long productId);
+  @Modifying
+  @Query("DELETE FROM ProductLike pl WHERE pl.userId = :userId AND pl.productId = :productId")
+  int deleteByUserIdAndProductId(@Param("userId") Long userId, @Param("productId") Long productId);
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/productlike/ProductLikeRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/productlike/ProductLikeRepositoryImpl.java
index 46a575529..d68fe84b8 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/productlike/ProductLikeRepositoryImpl.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/productlike/ProductLikeRepositoryImpl.java
@@ -35,7 +35,12 @@ public ProductLike save(ProductLike productLike) {
   }
 
   @Override
-  public void deleteByUserIdAndProductId(Long userId, Long productId) {
-    jpaRepository.deleteByUserIdAndProductId(userId, productId);
+  public ProductLike saveAndFlush(ProductLike productLike) {
+    return jpaRepository.saveAndFlush(productLike);
+  }
+
+  @Override
+  public int deleteByUserIdAndProductId(Long userId, Long productId) {
+    return jpaRepository.deleteByUserIdAndProductId(userId, productId);
   }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointApiSpec.java
index fbab8a00b..cdbf727b9 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointApiSpec.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointApiSpec.java
@@ -19,8 +19,8 @@ public interface PointApiSpec {
         description = "사용자의 포인트를 조회합니다."
     )
     ApiResponse<PointResponse> getPoint(
-        @Parameter(description = "사용자 로그인 ID", required = true)
-        @RequestHeader(ApiHeaders.USER_LOGIN_ID) String loginId
+        @Parameter(description = "사용자 ID", required = true)
+        @RequestHeader(ApiHeaders.USER_ID) Long userId
     );
 
     @Operation(
@@ -28,8 +28,8 @@ ApiResponse<PointResponse> getPoint(
         description = "사용자의 포인트를 충전합니다."
     )
     ApiResponse<ChargeResponse> chargePoint(
-        @Parameter(description = "사용자 로그인 ID", required = true)
-        @RequestHeader(ApiHeaders.USER_LOGIN_ID) String loginId,
+        @Parameter(description = "사용자 ID", required = true)
+        @RequestHeader(ApiHeaders.USER_ID) Long userId,
         @RequestBody(description = "충전 요청", required = true)
         ChargeRequest request
     );
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointController.java
index 9e86389d3..2cd097ea4 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointController.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointController.java
@@ -24,8 +24,8 @@ public class PointController implements PointApiSpec {
 
   @Override
   @GetMapping
-public ApiResponse<PointResponse> getPoint(@RequestHeader(ApiHeaders.USER_LOGIN_ID) String loginId) {
-    PointResponse response = pointFacade.getPoint(loginId)
+public ApiResponse<PointResponse> getPoint(@RequestHeader(ApiHeaders.USER_ID) Long userId) {
+    PointResponse response = pointFacade.getPoint(userId)
         .map(PointResponse::from)
         .orElse(null);
     return ApiResponse.success(response);
@@ -34,11 +34,11 @@ public ApiResponse<PointResponse> getPoint(@RequestHeader(ApiHeaders.USER_LOGIN_
   @Override
   @PatchMapping("/charge")
   public ApiResponse<ChargeResponse> chargePoint(
-      @RequestHeader(ApiHeaders.USER_LOGIN_ID) String loginId,
+      @RequestHeader(ApiHeaders.USER_ID) Long userId,
       @Valid @RequestBody ChargeRequest request
   ) {
     ChargeResponse response = ChargeResponse.from(
-        pointFacade.charge(loginId, request.amount())
+        pointFacade.charge(userId, request.amount())
     );
     return ApiResponse.success(response);
   }
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/support/ApiHeaders.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/support/ApiHeaders.java
index 667b83fa5..1793e6ad1 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/support/ApiHeaders.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/support/ApiHeaders.java
@@ -5,6 +5,6 @@ public final class ApiHeaders {
     private ApiHeaders() {
     }
 
-    public static final String USER_LOGIN_ID = "X-USER-LOGIN-ID";
+    public static final String USER_ID = "X-USER-ID";
 }
 
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java
index b8885f896..2fc4d710f 100644
--- a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java
@@ -3,7 +3,6 @@
 import com.loopers.domain.brand.Brand;
 import com.loopers.domain.brand.BrandService;
 import com.loopers.support.error.CoreException;
-import com.loopers.support.error.ErrorType;
 import org.junit.jupiter.api.DisplayName;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
@@ -22,7 +21,7 @@
 class BrandFacadeTest {
 
   @InjectMocks
-  private BrandFacade sut;
+  private BrandFacade brandFacade;
 
   @Mock
   private BrandService brandService;
@@ -37,7 +36,7 @@ void viewBrand_success() {
     given(brandService.getById(brandId)).willReturn(Optional.of(brand));
 
     // when
-    BrandResult result = sut.viewBrand(brandId);
+    BrandResult result = brandFacade.viewBrand(brandId);
 
     // then
     assertThat(result)
@@ -54,7 +53,7 @@ void viewBrand_notFound() {
     given(brandService.getById(brandId)).willReturn(Optional.empty());
 
     // when & then
-    assertThatThrownBy(() -> sut.viewBrand(brandId))
+    assertThatThrownBy(() -> brandFacade.viewBrand(brandId))
         .isInstanceOf(CoreException.class)
         .hasMessageContaining("브랜드를 찾을 수 없습니다.");
   }
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java
new file mode 100644
index 000000000..124e81a84
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java
@@ -0,0 +1,163 @@
+package com.loopers.application.like;
+
+import com.loopers.domain.brand.Brand;
+import com.loopers.domain.brand.BrandRepository;
+import com.loopers.domain.money.Money;
+import com.loopers.domain.product.Product;
+import com.loopers.domain.product.ProductRepository;
+import com.loopers.domain.productlike.ProductLike;
+import com.loopers.domain.productlike.ProductLikeRepository;
+import com.loopers.domain.stock.Stock;
+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.time.LocalDateTime;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+@SpringBootTest
+@DisplayName("LikeFacade 통합 테스트")
+class LikeFacadeIntegrationTest {
+
+  private static final LocalDateTime LIKED_AT_2025_10_30 = LocalDateTime.of(2025, 10, 30, 0, 0, 0);
+
+  @Autowired
+  private LikeFacade likeFacade;
+
+  @Autowired
+  private ProductRepository productRepository;
+
+  @Autowired
+  private BrandRepository brandRepository;
+
+  @Autowired
+  private ProductLikeRepository productLikeRepository;
+
+  @Autowired
+  private DatabaseCleanUp databaseCleanUp;
+
+  @AfterEach
+  void tearDown() {
+    databaseCleanUp.truncateAllTables();
+  }
+
+  @Nested
+  @DisplayName("좋아요 등록 시")
+  class LikeProduct {
+
+    @Test
+    @DisplayName("좋아요 등록 성공하고 상품의 좋아요 수가 증가한다")
+    void likesProduct_andIncreasesLikeCount() {
+      Brand brand = brandRepository.save(Brand.of("브랜드A", "설명A"));
+      Product product = productRepository.save(
+          Product.of("상품1", Money.of(10000L), "설명1", Stock.of(100L), brand.getId())
+      );
+      Long userId = 1L;
+      Long productId = product.getId();
+
+      likeFacade.likeProduct(userId, productId);
+
+      assertThat(productLikeRepository.existsByUserIdAndProductId(userId, productId)).isTrue();
+      Product updatedProduct = productRepository.findById(productId).orElseThrow();
+      assertThat(updatedProduct.getLikeCount()).isEqualTo(1L);
+    }
+
+    @Test
+    @DisplayName("같은 사용자가 두 번 좋아요 시도해도 1개만 증가 (멱등성)")
+    void ensuresIdempotency_whenDuplicateLikes() {
+      Brand brand = brandRepository.save(Brand.of("브랜드A", "설명A"));
+      Product product = productRepository.save(
+          Product.of("상품1", Money.of(10000L), "설명1", Stock.of(100L), brand.getId())
+      );
+      Long userId = 1L;
+      Long productId = product.getId();
+
+      likeFacade.likeProduct(userId, productId);
+      likeFacade.likeProduct(userId, productId);
+
+      assertThat(productLikeRepository.existsByUserIdAndProductId(userId, productId)).isTrue();
+      Product updatedProduct = productRepository.findById(productId).orElseThrow();
+      assertThat(updatedProduct.getLikeCount()).isEqualTo(1L);
+    }
+
+    @Test
+    @DisplayName("존재하지 않는 상품 좋아요 시 예외 발생")
+    void throwsException_whenProductNotFound() {
+      Long userId = 1L;
+      Long productId = 999999L;
+
+      assertThatThrownBy(() -> likeFacade.likeProduct(userId, productId))
+          .isInstanceOf(CoreException.class)
+          .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND)
+          .hasMessageContaining("상품을 찾을 수 없습니다.");
+    }
+  }
+
+  @Nested
+  @DisplayName("좋아요 취소 시")
+  class UnlikeProduct {
+
+    @Test
+    @DisplayName("좋아요 취소 성공하고 상품의 좋아요 수가 감소한다")
+    void unlikesProduct_andDecreasesLikeCount() {
+      Brand brand = brandRepository.save(Brand.of("브랜드A", "설명A"));
+      Product product = productRepository.save(
+          Product.of("상품1", Money.of(10000L), "설명1", Stock.of(100L), brand.getId())
+      );
+      Long userId = 1L;
+      Long productId = product.getId();
+
+      productLikeRepository.save(ProductLike.of(userId, productId, LIKED_AT_2025_10_30));
+      product.increaseLikeCount();
+      productRepository.save(product);
+
+      likeFacade.unlikeProduct(userId, productId);
+
+      assertThat(productLikeRepository.existsByUserIdAndProductId(userId, productId)).isFalse();
+      Product updatedProduct = productRepository.findById(productId).orElseThrow();
+      assertThat(updatedProduct.getLikeCount()).isZero();
+    }
+
+    @Test
+    @DisplayName("같은 사용자가 두 번 좋아요 취소 시도해도 1개만 감소 (멱등성)")
+    void ensuresIdempotency_whenDuplicateUnlikes() {
+      Brand brand = brandRepository.save(Brand.of("브랜드A", "설명A"));
+      Product product = productRepository.save(
+          Product.of("상품1", Money.of(10000L), "설명1", Stock.of(100L), brand.getId())
+      );
+      Long userId = 1L;
+      Long productId = product.getId();
+
+      productLikeRepository.save(ProductLike.of(userId, productId, LIKED_AT_2025_10_30));
+      product.increaseLikeCount();
+      productRepository.save(product);
+
+      likeFacade.unlikeProduct(userId, productId);
+      likeFacade.unlikeProduct(userId, productId);
+
+      assertThat(productLikeRepository.existsByUserIdAndProductId(userId, productId)).isFalse();
+      Product updatedProduct = productRepository.findById(productId).orElseThrow();
+      assertThat(updatedProduct.getLikeCount()).isZero();
+    }
+
+    @Test
+    @DisplayName("존재하지 않는 상품 좋아요 취소 시 예외 발생")
+    void throwsException_whenProductNotFound() {
+      Long userId = 1L;
+      Long productId = 999999L;
+
+      assertThatThrownBy(() -> likeFacade.unlikeProduct(userId, productId))
+          .isInstanceOf(CoreException.class)
+          .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND)
+          .hasMessageContaining("상품을 찾을 수 없습니다.");
+    }
+  }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java
deleted file mode 100644
index 855ead433..000000000
--- a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java
+++ /dev/null
@@ -1,180 +0,0 @@
-package com.loopers.application.like;
-
-import com.loopers.domain.money.Money;
-import com.loopers.domain.product.Product;
-import com.loopers.domain.product.ProductService;
-import com.loopers.domain.productlike.ProductLikeService;
-import com.loopers.domain.stock.Stock;
-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 org.junit.jupiter.api.extension.ExtendWith;
-import org.mockito.InjectMocks;
-import org.mockito.Mock;
-import org.mockito.junit.jupiter.MockitoExtension;
-
-import java.util.Optional;
-
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.anyLong;
-import static org.mockito.BDDMockito.given;
-import static org.mockito.BDDMockito.then;
-import static org.mockito.Mockito.never;
-import static org.mockito.Mockito.times;
-
-@ExtendWith(MockitoExtension.class)
-@DisplayName("LikeFacade 테스트")
-class LikeFacadeTest {
-
-  @InjectMocks
-  private LikeFacade sut;
-
-  @Mock
-  private ProductLikeService productLikeService;
-
-  @Mock
-  private ProductService productService;
-
-  @Nested
-  @DisplayName("좋아요 등록")
-  class LikeProduct {
-
-    @Test
-    @DisplayName("좋아요 등록 성공")
-    void likeProduct_success() {
-      // given
-      Long userId = 1L;
-      Long productId = 1L;
-      Product product = Product.of(
-          "상품1",
-          Money.of(10000L),
-          "설명",
-          Stock.of(100L),
-          1L
-      );
-
-      given(productService.getById(productId)).willReturn(Optional.of(product));
-      given(productLikeService.isLiked(userId, productId)).willReturn(false);
-
-      // when
-      sut.likeProduct(userId, productId);
-
-      // then
-      then(productLikeService).should(times(1)).createLike(userId, productId);
-      then(productService).should(times(1)).getById(productId);
-    }
-
-    @Test
-    @DisplayName("이미 좋아요한 상품 재등록 시 멱등성 보장 (아무것도 하지 않음)")
-    void likeProduct_idempotent() {
-      // given
-      Long userId = 1L;
-      Long productId = 1L;
-      Product product = Product.of(
-          "상품1",
-          Money.of(10000L),
-          "설명",
-          Stock.of(100L),
-          1L
-      );
-
-      given(productService.getById(productId)).willReturn(Optional.of(product));
-      given(productLikeService.isLiked(userId, productId)).willReturn(true);
-
-      // when
-      sut.likeProduct(userId, productId);
-
-      // then
-      then(productLikeService).should(never()).createLike(userId, productId);
-    }
-
-    @Test
-    @DisplayName("존재하지 않는 상품 좋아요 시 CoreException 발생")
-    void likeProduct_productNotFound() {
-      // given
-      Long userId = 1L;
-      Long productId = 999L;
-
-      given(productService.getById(productId))
-          .willThrow(new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
-
-      // when & then
-      assertThatThrownBy(() -> sut.likeProduct(userId, productId))
-          .isInstanceOf(CoreException.class)
-          .hasMessageContaining("상품을 찾을 수 없습니다.");
-    }
-  }
-
-  @Nested
-  @DisplayName("좋아요 취소")
-  class UnlikeProduct {
-
-    @Test
-    @DisplayName("좋아요 취소 성공")
-    void unlikeProduct_success() {
-      // given
-      Long userId = 1L;
-      Long productId = 1L;
-      Product product = Product.of(
-          "상품1",
-          Money.of(10000L),
-          "설명",
-          Stock.of(100L),
-          1L
-      );
-
-      given(productService.getById(productId)).willReturn(Optional.of(product));
-      given(productLikeService.isLiked(userId, productId)).willReturn(true);
-
-      // when
-      sut.unlikeProduct(userId, productId);
-
-      // then
-      then(productLikeService).should(times(1)).deleteLike(userId, productId);
-      then(productService).should(times(1)).getById(productId);
-    }
-
-    @Test
-    @DisplayName("이미 취소한 좋아요 재취소 시 멱등성 보장 (아무것도 하지 않음)")
-    void unlikeProduct_idempotent() {
-      // given
-      Long userId = 1L;
-      Long productId = 1L;
-      Product product = Product.of(
-          "상품1",
-          Money.of(10000L),
-          "설명",
-          Stock.of(100L),
-          1L
-      );
-
-      given(productService.getById(productId)).willReturn(Optional.of(product));
-      given(productLikeService.isLiked(userId, productId)).willReturn(false);
-
-      // when
-      sut.unlikeProduct(userId, productId);
-
-      // then
-      then(productLikeService).should(never()).deleteLike(userId, productId);
-    }
-
-    @Test
-    @DisplayName("존재하지 않는 상품 좋아요 취소 시 CoreException 발생")
-    void unlikeProduct_productNotFound() {
-      // given
-      Long userId = 1L;
-      Long productId = 999L;
-
-      given(productService.getById(productId))
-          .willThrow(new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
-
-      // when & then
-      assertThatThrownBy(() -> sut.unlikeProduct(userId, productId))
-          .isInstanceOf(CoreException.class)
-          .hasMessageContaining("상품을 찾을 수 없습니다.");
-    }
-  }
-}
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java
index 1cf507d3c..1717b87d4 100644
--- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java
@@ -62,7 +62,7 @@ class OrderFacadeIntegrationTest {
 
   @BeforeEach
   void setUp() {
-    user = userRepository.save(User.of("testuser", "test@example.com", BIRTH_DATE_1990_01_01, Gender.MALE));
+    user = userRepository.save(User.of("testuser", "test@example.com", BIRTH_DATE_1990_01_01, Gender.MALE, LocalDate.of(2025, 10, 30)));
     brand = brandRepository.save(Brand.of("테스트브랜드"));
     product1 = productRepository.save(
         Product.of("상품1", Money.of(10000L), "설명1", Stock.of(10L), brand.getId())
@@ -78,12 +78,12 @@ void setUp() {
   class CreateOrder {
 
     @Test
-    @DisplayName("정상 주문 생성 - 재고 차감, 포인트 차감 확인")
+    @DisplayName("유효한 주문 요청이면 주문이 생성되고 재고와 포인트가 차감된다")
     void createOrder_success() {
       // given
       List<OrderItemCommand> commands = List.of(
-          OrderItemCommand.of(product1.getId(), Quantity.of(2)),
-          OrderItemCommand.of(product2.getId(), Quantity.of(1))
+          OrderItemCommand.of(product1.getId(), Quantity.of(2L)),
+          OrderItemCommand.of(product2.getId(), Quantity.of(1L))
       );
 
       // when
@@ -91,7 +91,7 @@ void createOrder_success() {
 
       // then
       assertThat(order.getTotalAmountValue()).isEqualTo(50000L);
-      assertThat(order.getStatus()).isEqualTo(OrderStatus.PAYMENT_FAILED);
+      assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING);
 
       // 재고 차감 확인 (실제 DB 조회)
       Product updatedProduct1 = productRepository.findById(product1.getId()).orElseThrow();
@@ -106,11 +106,11 @@ void createOrder_success() {
     }
 
     @Test
-    @DisplayName("재고 부족 시 예외 발생 - 롤백 확인")
+    @DisplayName("재고가 부족하면 예외가 발생하고 트랜잭션이 롤백된다")
     void createOrder_insufficientStock() {
       // given
       List<OrderItemCommand> commands = List.of(
-          OrderItemCommand.of(product1.getId(), Quantity.of(100))
+          OrderItemCommand.of(product1.getId(), Quantity.of(100L))
       );
 
       // when & then
@@ -129,7 +129,7 @@ void createOrder_insufficientStock() {
     }
 
     @Test
-    @DisplayName("재고 0인 상품 - 예외 발생")
+    @DisplayName("재고가 0인 상품을 주문하면 예외가 발생한다")
     void createOrder_zeroStock() {
       // given
       Product zeroStockProduct = productRepository.save(
@@ -137,7 +137,7 @@ void createOrder_zeroStock() {
       );
 
       List<OrderItemCommand> commands = List.of(
-          OrderItemCommand.of(zeroStockProduct.getId(), Quantity.of(1))
+          OrderItemCommand.of(zeroStockProduct.getId(), Quantity.of(1L))
       );
 
       // when & then
@@ -147,11 +147,11 @@ void createOrder_zeroStock() {
     }
 
     @Test
-    @DisplayName("포인트 부족 시 예외 발생 - 롤백 확인")
+    @DisplayName("포인트가 부족하면 예외가 발생하고 트랜잭션이 롤백된다")
     void createOrder_insufficientPoint() {
       // given
       List<OrderItemCommand> commands = List.of(
-          OrderItemCommand.of(product1.getId(), Quantity.of(1))
+          OrderItemCommand.of(product1.getId(), Quantity.of(1L))
       );
 
       // 포인트를 5000으로 설정 (부족)
@@ -173,12 +173,12 @@ void createOrder_insufficientPoint() {
     }
 
     @Test
-    @DisplayName("총액 계산 정확성 검증")
+    @DisplayName("주문 생성 시 총액이 정확하게 계산되고 포인트가 차감된다")
     void createOrder_totalAmountCalculation() {
       // given
       List<OrderItemCommand> commands = List.of(
-          OrderItemCommand.of(product1.getId(), Quantity.of(3)),
-          OrderItemCommand.of(product2.getId(), Quantity.of(2))
+          OrderItemCommand.of(product1.getId(), Quantity.of(3L)),
+          OrderItemCommand.of(product2.getId(), Quantity.of(2L))
       );
 
       // when
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeRaceConditionTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeRaceConditionTest.java
index 2fb900cdb..4a00fe1b1 100644
--- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeRaceConditionTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeRaceConditionTest.java
@@ -23,10 +23,9 @@
 
 import java.time.LocalDate;
 import java.time.LocalDateTime;
+import java.util.ArrayList;
 import java.util.List;
-import java.util.concurrent.CountDownLatch;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
+import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.atomic.AtomicInteger;
 
 import static org.assertj.core.api.Assertions.assertThat;
@@ -63,143 +62,173 @@ void tearDown() {
 
   @Test
   @DisplayName("동시에 2개 주문 시 하나만 성공하고 재고가 음수가 되지 않음")
-  void concurrentOrders_onlyOneSucceeds() throws InterruptedException {
+  void concurrentOrders_onlyOneSucceeds() {
     Brand brand = brandRepository.save(Brand.of("테스트브랜드"));
     Product product = productRepository.save(
         Product.of("재고1개상품", Money.of(10000L), "재고 1개 상품", Stock.of(1L), brand.getId())
     );
 
     int random = (int) (System.nanoTime() % 10000);
-    User user1 = userRepository.save(User.of("u1" + random, "u1" + random + "@t.com", BIRTH_DATE_1990_01_01, Gender.MALE));
-    User user2 = userRepository.save(User.of("u2" + random, "u2" + random + "@t.com", BIRTH_DATE_1990_01_01, Gender.FEMALE));
+    User user1 = userRepository.save(User.of("u1" + random, "u1" + random + "@t.com", BIRTH_DATE_1990_01_01, Gender.MALE, LocalDate.of(2025, 10, 30)));
+    User user2 = userRepository.save(User.of("u2" + random, "u2" + random + "@t.com", BIRTH_DATE_1990_01_01, Gender.FEMALE, LocalDate.of(2025, 10, 30)));
     pointRepository.save(Point.of(user1.getId(), 50000L));
     pointRepository.save(Point.of(user2.getId(), 50000L));
 
-    int threadCount = 2;
-    CountDownLatch latch = new CountDownLatch(threadCount);
-    AtomicInteger successCount = new AtomicInteger(0);
-    AtomicInteger failureCount = new AtomicInteger(0);
-
     List<OrderItemCommand> commands = List.of(
-        OrderItemCommand.of(product.getId(), Quantity.of(1))
+        OrderItemCommand.of(product.getId(), Quantity.of(1L))
     );
 
-    try (ExecutorService executorService = Executors.newFixedThreadPool(threadCount)) {
-      executorService.submit(() -> {
-        try {
-          orderFacade.createOrder(user1.getId(), commands, ORDERED_AT_2025_10_30);
-          successCount.incrementAndGet();
-        } catch (CoreException e) {
-          failureCount.incrementAndGet();
-        } finally {
-          latch.countDown();
-        }
-      });
+    List<CompletableFuture<Void>> futures = new ArrayList<>();
 
-      executorService.submit(() -> {
-        try {
-          orderFacade.createOrder(user2.getId(), commands, ORDERED_AT_2025_10_30);
-          successCount.incrementAndGet();
-        } catch (CoreException e) {
-          failureCount.incrementAndGet();
-        } finally {
-          latch.countDown();
-        }
-      });
+    futures.add(CompletableFuture.runAsync(() -> {
+      try {
+        orderFacade.createOrder(user1.getId(), commands, ORDERED_AT_2025_10_30);
+      } catch (CoreException ignored) {
+        // 재고 부족 시 예외 발생 예상
+      }
+    }));
 
-      latch.await();
-    }
+    futures.add(CompletableFuture.runAsync(() -> {
+      try {
+        orderFacade.createOrder(user2.getId(), commands, ORDERED_AT_2025_10_30);
+      } catch (CoreException ignored) {
+        // 재고 부족 시 예외 발생 예상
+      }
+    }));
 
-    assertThat(successCount.get()).isEqualTo(1);
-    assertThat(failureCount.get()).isEqualTo(1);
+    CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
 
     Product updatedProduct = productRepository.findById(product.getId()).orElseThrow();
-    assertThat(updatedProduct.getStockValue()).isZero();
+    assertThat(updatedProduct).extracting("stockValue").isEqualTo(0L);
   }
 
   @Test
   @DisplayName("동시에 10개 주문 시 재고 5개 상품은 5개만 성공")
-  void concurrentOrders_multipleThreads() throws InterruptedException {
+  void concurrentOrders_multipleThreads() {
     Brand brand = brandRepository.save(Brand.of("테스트브랜드"));
     Product product = productRepository.save(
         Product.of("재고5개상품", Money.of(5000L), "재고 5개 상품", Stock.of(5L), brand.getId())
     );
 
     int threadCount = 10;
-    CountDownLatch latch = new CountDownLatch(threadCount);
-    AtomicInteger successCount = new AtomicInteger(0);
-    AtomicInteger failureCount = new AtomicInteger(0);
 
     List<OrderItemCommand> commands = List.of(
-        OrderItemCommand.of(product.getId(), Quantity.of(1))
+        OrderItemCommand.of(product.getId(), Quantity.of(1L))
     );
 
     AtomicInteger counter = new AtomicInteger(0);
-    try (ExecutorService executorService = Executors.newFixedThreadPool(threadCount)) {
-      for (int i = 0; i < threadCount; i++) {
-        executorService.submit(() -> {
-          try {
-            int id = counter.getAndIncrement();
-            String loginId = "u" + id;
-            User user = userRepository.save(User.of(loginId, loginId + "@t.com", BIRTH_DATE_1990_01_01, Gender.MALE));
-            pointRepository.save(Point.of(user.getId(), 50000L));
-            orderFacade.createOrder(user.getId(), commands, ORDERED_AT_2025_10_30);
-            successCount.incrementAndGet();
-          } catch (CoreException e) {
-            failureCount.incrementAndGet();
-          } finally {
-            latch.countDown();
-          }
-        });
-      }
+    List<CompletableFuture<Void>> futures = new ArrayList<>();
 
-      latch.await();
+    for (int i = 0; i < threadCount; i++) {
+      futures.add(CompletableFuture.runAsync(() -> {
+        try {
+          int id = counter.getAndIncrement();
+          String loginId = "u" + id;
+          User user = userRepository.save(User.of(loginId, loginId + "@t.com", BIRTH_DATE_1990_01_01, Gender.MALE, LocalDate.of(2025, 10, 30)));
+          pointRepository.save(Point.of(user.getId(), 50000L));
+          orderFacade.createOrder(user.getId(), commands, ORDERED_AT_2025_10_30);
+        } catch (CoreException ignored) {
+          // 재고 부족 시 예외 발생 예상
+        }
+      }));
     }
 
-    assertThat(successCount.get()).isEqualTo(5);
-    assertThat(failureCount.get()).isEqualTo(5);
+    CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
 
     Product updatedProduct = productRepository.findById(product.getId()).orElseThrow();
-    assertThat(updatedProduct.getStockValue()).isZero();
+    assertThat(updatedProduct).extracting("stockValue").isEqualTo(0L);
   }
 
   @Test
   @DisplayName("동시 주문 시 재고가 음수가 되지 않음")
-  void concurrentOrders_stockNeverNegative() throws InterruptedException {
+  void concurrentOrders_stockNeverNegative() {
     Brand brand = brandRepository.save(Brand.of("테스트브랜드"));
     Product product = productRepository.save(
         Product.of("재고3개상품", Money.of(3000L), "재고 3개 상품", Stock.of(3L), brand.getId())
     );
 
     int threadCount = 5;
-    CountDownLatch latch = new CountDownLatch(threadCount);
 
     List<OrderItemCommand> commands = List.of(
-        OrderItemCommand.of(product.getId(), Quantity.of(1))
+        OrderItemCommand.of(product.getId(), Quantity.of(1L))
     );
 
     AtomicInteger counter = new AtomicInteger(100);
-    try (ExecutorService executorService = Executors.newFixedThreadPool(threadCount)) {
-      for (int i = 0; i < threadCount; i++) {
-        executorService.submit(() -> {
-          try {
-            int id = counter.getAndIncrement();
-            String loginId = "u" + id;
-            User user = userRepository.save(User.of(loginId, loginId + "@t.com", BIRTH_DATE_1990_01_01, Gender.MALE));
-            pointRepository.save(Point.of(user.getId(), 50000L));
-            orderFacade.createOrder(user.getId(), commands, ORDERED_AT_2025_10_30);
-          } catch (CoreException ignored) {
-            // 재고 부족 시 예외 발생 예상
-          } finally {
-            latch.countDown();
-          }
-        });
-      }
+    List<CompletableFuture<Void>> futures = new ArrayList<>();
 
-      latch.await();
+    for (int i = 0; i < threadCount; i++) {
+      futures.add(CompletableFuture.runAsync(() -> {
+        try {
+          int id = counter.getAndIncrement();
+          String loginId = "u" + id;
+          User user = userRepository.save(User.of(loginId, loginId + "@t.com", BIRTH_DATE_1990_01_01, Gender.MALE, LocalDate.of(2025, 10, 30)));
+          pointRepository.save(Point.of(user.getId(), 50000L));
+          orderFacade.createOrder(user.getId(), commands, ORDERED_AT_2025_10_30);
+        } catch (CoreException ignored) {
+          // 재고 부족 시 예외 발생 예상
+        }
+      }));
     }
 
+    CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
+
     Product finalProduct = productRepository.findById(product.getId()).orElseThrow();
-    assertThat(finalProduct.getStockValue()).isGreaterThanOrEqualTo(0L);
+    assertThat(finalProduct).extracting("stockValue").matches(value -> (Long) value >= 0L);
+  }
+
+  @Test
+  @DisplayName("여러 상품 역순 동시 주문 시 데드락이 발생하지 않음 (ORDER BY 검증)")
+  void concurrentOrders_multipleProducts_noDeadlock() {
+    Brand brand = brandRepository.save(Brand.of("테스트브랜드"));
+    Product product1 = productRepository.save(
+        Product.of("상품1", Money.of(1000L), "상품 1", Stock.of(10L), brand.getId())
+    );
+    Product product2 = productRepository.save(
+        Product.of("상품2", Money.of(2000L), "상품 2", Stock.of(10L), brand.getId())
+    );
+    Product product3 = productRepository.save(
+        Product.of("상품3", Money.of(3000L), "상품 3", Stock.of(10L), brand.getId())
+    );
+
+    int random1 = (int) (System.nanoTime() % 10000);
+    int random2 = random1 + 1;
+    User user1 = userRepository.save(User.of("u1" + random1, "u1" + random1 + "@t.com", BIRTH_DATE_1990_01_01, Gender.MALE, LocalDate.of(2025, 10, 30)));
+    User user2 = userRepository.save(User.of("u2" + random2, "u2" + random2 + "@t.com", BIRTH_DATE_1990_01_01, Gender.FEMALE, LocalDate.of(2025, 10, 30)));
+    pointRepository.save(Point.of(user1.getId(), 100000L));
+    pointRepository.save(Point.of(user2.getId(), 100000L));
+
+    List<OrderItemCommand> commands1 = List.of(
+        OrderItemCommand.of(product1.getId(), Quantity.of(1L)),
+        OrderItemCommand.of(product2.getId(), Quantity.of(1L)),
+        OrderItemCommand.of(product3.getId(), Quantity.of(1L))
+    );
+
+    List<OrderItemCommand> commands2 = List.of(
+        OrderItemCommand.of(product3.getId(), Quantity.of(1L)),
+        OrderItemCommand.of(product2.getId(), Quantity.of(1L)),
+        OrderItemCommand.of(product1.getId(), Quantity.of(1L))
+    );
+
+    List<CompletableFuture<Void>> futures = new ArrayList<>();
+
+    futures.add(CompletableFuture.runAsync(() -> {
+      orderFacade.createOrder(user1.getId(), commands1, ORDERED_AT_2025_10_30);
+    }));
+
+    futures.add(CompletableFuture.runAsync(() -> {
+      orderFacade.createOrder(user2.getId(), commands2, ORDERED_AT_2025_10_30);
+    }));
+
+    CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
+
+    List<Product> finalProducts = List.of(
+        productRepository.findById(product1.getId()).orElseThrow(),
+        productRepository.findById(product2.getId()).orElseThrow(),
+        productRepository.findById(product3.getId()).orElseThrow()
+    );
+
+    assertThat(finalProducts)
+        .extracting("stockValue")
+        .containsExactly(8L, 8L, 8L);
   }
 }
\ No newline at end of file
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeIntegrationTest.java
index 33959ad3b..8a0c744ec 100644
--- a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeIntegrationTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeIntegrationTest.java
@@ -1,5 +1,6 @@
 package com.loopers.application.product;
 
+import com.loopers.application.like.LikeFacade;
 import com.loopers.domain.brand.Brand;
 import com.loopers.domain.brand.BrandRepository;
 import com.loopers.domain.money.Money;
@@ -27,6 +28,7 @@
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.assertj.core.api.Assertions.tuple;
+import static org.junit.jupiter.api.Assertions.assertAll;
 
 @SpringBootTest
 class ProductFacadeIntegrationTest {
@@ -36,6 +38,9 @@ class ProductFacadeIntegrationTest {
   @Autowired
   private ProductFacade productFacade;
 
+  @Autowired
+  private LikeFacade likeFacade;
+
   @Autowired
   private BrandRepository brandRepository;
 
@@ -58,7 +63,7 @@ void tearDown() {
   class GetProducts {
 
     @Test
-    @DisplayName("브랜드 필터 없이 전체 상품을 조회한다")
+    @DisplayName("브랜드 필터 없이 조회하면 전체 상품이 반환된다")
     void getsAllProducts_withoutBrandFilter() {
       Brand brand = brandRepository.save(Brand.of("브랜드A", "설명A"));
       productRepository.save(
@@ -72,8 +77,8 @@ void getsAllProducts_withoutBrandFilter() {
 
       Page<ProductDetail> result = productFacade.searchProductDetails(null, null, pageable);
 
-      assertThat(result.getContent()).hasSize(2);
       assertThat(result.getContent())
+          .hasSize(2)
           .extracting("productName", "price")
           .containsExactlyInAnyOrder(
               tuple("상품1", 10000L),
@@ -82,7 +87,7 @@ void getsAllProducts_withoutBrandFilter() {
     }
 
     @Test
-    @DisplayName("특정 브랜드로 필터링하여 상품을 조회한다")
+    @DisplayName("특정 브랜드로 필터링하면 해당 브랜드의 상품만 반환된다")
     void getsProducts_withBrandFilter() {
       Brand brand1 = brandRepository.save(Brand.of("브랜드A", "설명A"));
       Brand brand2 = brandRepository.save(Brand.of("브랜드B", "설명B"));
@@ -101,8 +106,8 @@ void getsProducts_withBrandFilter() {
 
       Page<ProductDetail> result = productFacade.searchProductDetails(brand1.getId(), null, pageable);
 
-      assertThat(result.getContent()).hasSize(2);
       assertThat(result.getContent())
+          .hasSize(2)
           .extracting("productName", "price")
           .containsExactlyInAnyOrder(
               tuple("상품1", 10000L),
@@ -156,7 +161,7 @@ void returnsAllLikedFalse_whenUserIdIsNull() {
     }
 
     @Test
-    @DisplayName("페이지네이션이 정상 동작한다")
+    @DisplayName("페이지네이션을 적용하면 페이지 크기만큼만 상품이 반환된다")
     void supportsPagination() {
       Brand brand = brandRepository.save(Brand.of("브랜드A", "설명A"));
       for (int i = 1; i <= 5; i++) {
@@ -169,9 +174,11 @@ void supportsPagination() {
 
       Page<ProductDetail> result = productFacade.searchProductDetails(null, null, pageable);
 
-      assertThat(result.getContent()).hasSize(2);
-      assertThat(result.getTotalElements()).isEqualTo(5);
-      assertThat(result.getTotalPages()).isEqualTo(3);
+      assertAll(
+          () -> assertThat(result.getContent()).hasSize(2),
+          () -> assertThat(result.getTotalElements()).isEqualTo(5L),
+          () -> assertThat(result.getTotalPages()).isEqualTo(3)
+      );
     }
 
     @Test
@@ -197,7 +204,7 @@ void returnsEmptyPage_whenBrandHasNoProducts() {
     }
 
     @Test
-    @DisplayName("마지막 페이지를 정상 조회한다")
+    @DisplayName("마지막 페이지를 조회하면 남은 상품만 반환된다")
     void getsLastPage() {
       Brand brand = brandRepository.save(Brand.of("브랜드A", "설명A"));
       for (int i = 1; i <= 5; i++) {
@@ -239,12 +246,8 @@ void showsCorrectLikeCount_whenMultipleUsersLike() {
       );
 
       for (long userId = 1L; userId <= 3L; userId++) {
-        productLikeRepository.save(
-            ProductLike.of(userId, product.getId(), LIKED_AT_2025_10_30)
-        );
-        product.increaseLikeCount(1);
+        likeFacade.likeProduct(userId, product.getId());
       }
-      productRepository.save(product);
 
       Pageable pageable = PageRequest.of(0, 20);
       Page<ProductDetail> result = productFacade.searchProductDetails(null, 1L, pageable);
@@ -314,7 +317,7 @@ void returnsEmpty_whenBrandIdDoesNotExist() {
   class GetProduct {
 
     @Test
-    @DisplayName("정상적으로 상품 정보와 브랜드 정보를 반환한다")
+    @DisplayName("상품을 조회하면 상품 정보와 브랜드 정보가 반환된다")
     void returnsProductWithBrand() {
       Brand brand = brandRepository.save(Brand.of("브랜드A", "설명A"));
       Product product = productRepository.save(
@@ -397,7 +400,7 @@ void throwsNotFoundException_whenBrandDoesNotExist() {
   class SortProducts {
 
     @Test
-    @DisplayName("가격 오름차순 정렬이 정상 동작한다")
+    @DisplayName("가격 오름차순으로 정렬하면 저렴한 순서로 상품이 반환된다")
     void sortsByPriceAscending() {
       Brand brand = brandRepository.save(Brand.of("브랜드A", "설명A"));
       productRepository.save(
@@ -424,7 +427,7 @@ void sortsByPriceAscending() {
     }
 
     @Test
-    @DisplayName("좋아요순 정렬이 정상 동작한다")
+    @DisplayName("좋아요순으로 정렬하면 좋아요가 많은 순서로 상품이 반환된다")
     void sortsByLikesDescending() {
       Brand brand = brandRepository.save(Brand.of("브랜드A", "설명A"));
       Product product1 = productRepository.save(
@@ -437,13 +440,17 @@ void sortsByLikesDescending() {
           Product.of("상품3", Money.of(30000L), "설명3", Stock.of(100L), brand.getId())
       );
 
-      product1.increaseLikeCount(5);
-      product2.increaseLikeCount(10);
-      product3.increaseLikeCount(2);
+      for (long userId = 1L; userId <= 5L; userId++) {
+        likeFacade.likeProduct(userId, product1.getId());
+      }
+
+      for (long userId = 1L; userId <= 10L; userId++) {
+        likeFacade.likeProduct(userId, product2.getId());
+      }
 
-      productRepository.save(product1);
-      productRepository.save(product2);
-      productRepository.save(product3);
+      for (long userId = 1L; userId <= 2L; userId++) {
+        likeFacade.likeProduct(userId, product3.getId());
+      }
 
       Pageable pageable = PageRequest.of(0, 20, Sort.by("likeCount").descending());
 
@@ -459,7 +466,7 @@ void sortsByLikesDescending() {
     }
 
     @Test
-    @DisplayName("최신순 정렬이 정상 동작한다")
+    @DisplayName("최신순으로 정렬하면 최근 생성된 순서로 상품이 반환된다")
     void sortsByLatest() {
       Brand brand = brandRepository.save(Brand.of("브랜드A", "설명A"));
       productRepository.save(
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java
new file mode 100644
index 000000000..762446305
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java
@@ -0,0 +1,139 @@
+package com.loopers.domain.brand;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.loopers.utils.DatabaseCleanUp;
+import java.util.List;
+import java.util.Optional;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+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;
+
+@SpringBootTest
+class BrandServiceIntegrationTest {
+
+  @Autowired
+  private BrandRepository brandRepository;
+
+  @Autowired
+  private DatabaseCleanUp databaseCleanUp;
+
+  private BrandService brandService;
+
+  @BeforeEach
+  void setUp() {
+    brandService = new BrandService(brandRepository);
+  }
+
+  @AfterEach
+  void tearDown() {
+    databaseCleanUp.truncateAllTables();
+  }
+
+  @Nested
+  @DisplayName("브랜드 단건 조회 시")
+  class GetById {
+
+    @Test
+    @DisplayName("해당 ID의 브랜드가 존재하면 브랜드가 반환된다")
+    void returnsBrand_whenBrandExists() {
+      // given
+      Brand brand = brandRepository.save(Brand.of("나이키", "스포츠 브랜드"));
+      Long brandId = brand.getId();
+
+      // when
+      Optional<Brand> result = brandService.getById(brandId);
+
+      // then
+      assertThat(result).isPresent();
+      assertThat(result.get())
+          .extracting("name", "description")
+          .containsExactly("나이키", "스포츠 브랜드");
+    }
+
+    @Test
+    @DisplayName("해당 ID의 브랜드가 존재하지 않으면 빈 Optional이 반환된다")
+    void returnsEmpty_whenBrandDoesNotExist() {
+      // given
+      Long nonExistentBrandId = 999L;
+
+      // when
+      Optional<Brand> result = brandService.getById(nonExistentBrandId);
+
+      // then
+      assertThat(result).isEmpty();
+    }
+  }
+
+  @Nested
+  @DisplayName("브랜드 다건 조회 시")
+  class FindByIdIn {
+
+    @Test
+    @DisplayName("모든 ID가 존재하면 해당 브랜드들이 모두 반환된다")
+    void returnsBrands_whenBrandsExist() {
+      // given
+      Brand brand1 = brandRepository.save(Brand.of("나이키", "스포츠 브랜드"));
+      Brand brand2 = brandRepository.save(Brand.of("아디다스", "스포츠 브랜드"));
+      Brand brand3 = brandRepository.save(Brand.of("퓨마", "스포츠 브랜드"));
+
+      List<Long> brandIds = List.of(brand1.getId(), brand2.getId(), brand3.getId());
+
+      // when
+      List<Brand> result = brandService.findByIdIn(brandIds);
+
+      // then
+      assertThat(result)
+          .hasSize(3)
+          .extracting("name")
+          .containsExactlyInAnyOrder("나이키", "아디다스", "퓨마");
+    }
+
+    @Test
+    @DisplayName("일부 ID만 존재하면 존재하는 브랜드만 반환된다")
+    void returnsExistingBrandsOnly_whenSomeBrandsDoNotExist() {
+      // given
+      Brand brand1 = brandRepository.save(Brand.of("나이키", "스포츠 브랜드"));
+      List<Long> brandIds = List.of(brand1.getId(), 999L, 998L);
+
+      // when
+      List<Brand> result = brandService.findByIdIn(brandIds);
+
+      // then
+      assertThat(result)
+          .hasSize(1)
+          .extracting("name")
+          .containsExactly("나이키");
+    }
+
+    @Test
+    @DisplayName("모든 ID가 존재하지 않으면 빈 리스트가 반환된다")
+    void returnsEmptyList_whenNoBrandsExist() {
+      // given
+      List<Long> nonExistentBrandIds = List.of(999L, 998L, 997L);
+
+      // when
+      List<Brand> result = brandService.findByIdIn(nonExistentBrandIds);
+
+      // then
+      assertThat(result).isEmpty();
+    }
+
+    @Test
+    @DisplayName("빈 ID 목록으로 조회하면 빈 리스트가 반환된다")
+    void returnsEmptyList_whenEmptyIdList() {
+      // given
+      List<Long> emptyBrandIds = List.of();
+
+      // when
+      List<Brand> result = brandService.findByIdIn(emptyBrandIds);
+
+      // then
+      assertThat(result).isEmpty();
+    }
+  }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderCreateServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderCreateServiceTest.java
new file mode 100644
index 000000000..e3d9692ed
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderCreateServiceTest.java
@@ -0,0 +1,116 @@
+package com.loopers.domain.order;
+
+import com.loopers.domain.brand.Brand;
+import com.loopers.domain.brand.BrandRepository;
+import com.loopers.domain.money.Money;
+import com.loopers.domain.order.orderitem.OrderItemCommand;
+import com.loopers.domain.product.Product;
+import com.loopers.domain.product.ProductRepository;
+import com.loopers.domain.quantity.Quantity;
+import com.loopers.domain.stock.Stock;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import org.junit.jupiter.api.BeforeEach;
+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.transaction.annotation.Transactional;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+@SpringBootTest
+@Transactional
+@DisplayName("OrderCreateService 통합 테스트")
+class OrderCreateServiceTest {
+
+  @Autowired
+  private OrderCreateService orderCreateService;
+
+  @Autowired
+  private ProductRepository productRepository;
+
+  @Autowired
+  private BrandRepository brandRepository;
+
+  private Brand brand;
+  private Product product1;
+  private Product product2;
+
+  @BeforeEach
+  void setUp() {
+    brand = brandRepository.save(Brand.of("테스트브랜드"));
+    product1 = productRepository.save(
+        Product.of("상품1", Money.of(10000L), "설명1", Stock.of(100L), brand.getId())
+    );
+    product2 = productRepository.save(
+        Product.of("상품2", Money.of(30000L), "설명2", Stock.of(50L), brand.getId())
+    );
+  }
+
+  @Nested
+  @DisplayName("주문 준비")
+  class PrepareOrder {
+
+    @Test
+    @DisplayName("정상적으로 주문을 준비한다")
+    void prepareOrder_success() {
+      Map<Long, Product> productMap = Map.of(
+          product1.getId(), product1,
+          product2.getId(), product2
+      );
+
+      List<OrderItemCommand> commands = List.of(
+          OrderItemCommand.of(product1.getId(), Quantity.of(10L)),
+          OrderItemCommand.of(product2.getId(), Quantity.of(5L))
+      );
+
+      OrderPreparation result = orderCreateService.prepareOrder(commands, productMap);
+
+      assertThat(result.orderItems()).isNotNull();
+      assertThat(result.totalAmount()).isEqualTo(250000L);
+      assertThat(product1.getStockValue()).isEqualTo(100L);
+      assertThat(product2.getStockValue()).isEqualTo(50L);
+    }
+
+    @Test
+    @DisplayName("상품을 찾을 수 없으면 예외 발생")
+    void prepareOrder_productNotFound() {
+      Map<Long, Product> productMap = Map.of();
+
+      List<OrderItemCommand> commands = List.of(
+          OrderItemCommand.of(999L, Quantity.of(10L))
+      );
+
+      assertThatThrownBy(() -> orderCreateService.prepareOrder(commands, productMap))
+          .isInstanceOf(CoreException.class)
+          .hasMessage("상품을 찾을 수 없습니다.")
+          .extracting(ex -> ((CoreException) ex).getErrorType())
+          .isEqualTo(ErrorType.NOT_FOUND);
+    }
+
+    @Test
+    @DisplayName("재고가 부족하면 예외 발생")
+    void prepareOrder_insufficientStock() {
+      Product lowStockProduct = productRepository.save(
+          Product.of("재고부족상품", Money.of(10000L), "설명", Stock.of(5L), brand.getId())
+      );
+
+      Map<Long, Product> productMap = Map.of(lowStockProduct.getId(), lowStockProduct);
+
+      List<OrderItemCommand> commands = List.of(
+          OrderItemCommand.of(lowStockProduct.getId(), Quantity.of(10L))
+      );
+
+      assertThatThrownBy(() -> orderCreateService.prepareOrder(commands, productMap))
+          .isInstanceOf(CoreException.class)
+          .extracting(ex -> ((CoreException) ex).getErrorType())
+          .isEqualTo(ErrorType.INSUFFICIENT_STOCK);
+    }
+  }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderRepositoryIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderRepositoryIntegrationTest.java
index 349453acc..ed9a31f84 100644
--- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderRepositoryIntegrationTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderRepositoryIntegrationTest.java
@@ -19,6 +19,7 @@
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.tuple;
+import static org.junit.jupiter.api.Assertions.assertAll;
 
 @SpringBootTest
 @Transactional
@@ -42,7 +43,7 @@ void tearDown() {
   @Nested
   class FindOrderList {
 
-    @DisplayName("사용자별 주문 목록을 페이징 조회하면 DTO로 반환된다")
+    @DisplayName("사용자 ID로 주문을 페이지네이션 조회하면 DTO 목록이 반환된다")
     @Test
     void shouldReturnDtoPage() {
       // given
@@ -50,12 +51,12 @@ void shouldReturnDtoPage() {
       LocalDateTime orderedAt = ORDERED_AT_2025_10_30;
 
       Order order1 = Order.of(userId, OrderStatus.COMPLETED, 30000L, orderedAt);
-      order1.addItem(OrderItem.of(100L, "상품1", Quantity.of(1), OrderPrice.of(10000L)));
-      order1.addItem(OrderItem.of(200L, "상품2", Quantity.of(2), OrderPrice.of(10000L)));
+      order1.addItem(OrderItem.of(100L, "상품1", Quantity.of(1L), OrderPrice.of(10000L)));
+      order1.addItem(OrderItem.of(200L, "상품2", Quantity.of(2L), OrderPrice.of(10000L)));
       orderRepository.save(order1);
 
       Order order2 = Order.of(userId, OrderStatus.PENDING, 50000L, orderedAt.plusDays(1));
-      order2.addItem(OrderItem.of(300L, "상품3", Quantity.of(5), OrderPrice.of(10000L)));
+      order2.addItem(OrderItem.of(300L, "상품3", Quantity.of(5L), OrderPrice.of(10000L)));
       orderRepository.save(order2);
 
       PageRequest pageRequest = PageRequest.of(0, 10);
@@ -82,11 +83,11 @@ void shouldFilterByUserId() {
       LocalDateTime orderedAt = ORDERED_AT_2025_10_30;
 
       Order order1 = Order.of(userId1, OrderStatus.COMPLETED, 10000L, orderedAt);
-      order1.addItem(OrderItem.of(100L, "상품1", Quantity.of(1), OrderPrice.of(10000L)));
+      order1.addItem(OrderItem.of(100L, "상품1", Quantity.of(1L), OrderPrice.of(10000L)));
       orderRepository.save(order1);
 
       Order order2 = Order.of(userId2, OrderStatus.PENDING, 20000L, orderedAt);
-      order2.addItem(OrderItem.of(200L, "상품2", Quantity.of(2), OrderPrice.of(10000L)));
+      order2.addItem(OrderItem.of(200L, "상품2", Quantity.of(2L), OrderPrice.of(10000L)));
       orderRepository.save(order2);
 
       PageRequest pageRequest = PageRequest.of(0, 10);
@@ -110,7 +111,7 @@ void shouldPaginateCorrectly() {
 
       for (int i = 0; i < 15; i++) {
         Order order = Order.of(userId, OrderStatus.COMPLETED, 10000L, orderedAt.plusDays(i));
-        order.addItem(OrderItem.of(100L + i, "상품" + i, Quantity.of(1), OrderPrice.of(10000L)));
+        order.addItem(OrderItem.of(100L + i, "상품" + i, Quantity.of(1L), OrderPrice.of(10000L)));
         orderRepository.save(order);
       }
 
@@ -120,9 +121,11 @@ void shouldPaginateCorrectly() {
       Page<OrderListDto> result = orderRepository.findOrderList(userId, pageRequest);
 
       // then
-      assertThat(result.getTotalElements()).isEqualTo(15);
-      assertThat(result.getTotalPages()).isEqualTo(2);
-      assertThat(result.getContent()).hasSize(5);
+      assertAll(
+          () -> assertThat(result.getContent()).hasSize(5),
+          () -> assertThat(result.getTotalElements()).isEqualTo(15L),
+          () -> assertThat(result.getTotalPages()).isEqualTo(2)
+      );
     }
   }
 
@@ -130,7 +133,7 @@ void shouldPaginateCorrectly() {
   @Nested
   class FindWithItemsById {
 
-    @DisplayName("OrderItem과 함께 조회된다 (N+1 방지)")
+    @DisplayName("OrderItem을 포함하여 조회하면 함께 조회된다 (N+1 방지)")
     @Test
     void shouldFetchWithItems() {
       // given
@@ -138,8 +141,8 @@ void shouldFetchWithItems() {
       LocalDateTime orderedAt = ORDERED_AT_2025_10_30;
 
       Order order = Order.of(userId, OrderStatus.COMPLETED, 30000L, orderedAt);
-      order.addItem(OrderItem.of(100L, "상품1", Quantity.of(1), OrderPrice.of(10000L)));
-      order.addItem(OrderItem.of(200L, "상품2", Quantity.of(2), OrderPrice.of(10000L)));
+      order.addItem(OrderItem.of(100L, "상품1", Quantity.of(1L), OrderPrice.of(10000L)));
+      order.addItem(OrderItem.of(200L, "상품2", Quantity.of(2L), OrderPrice.of(10000L)));
       Order savedOrder = orderRepository.save(order);
 
       // when
@@ -153,7 +156,7 @@ void shouldFetchWithItems() {
           .containsExactly("상품1", "상품2");
     }
 
-    @DisplayName("존재하지 않는 ID로 조회하면 empty를 반환한다")
+    @DisplayName("존재하지 않는 ID로 조회하면 빈 Optional이 반환된다")
     @Test
     void shouldReturnEmpty_whenNotFound() {
       // given
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
index fe79140c4..b087a7fbb 100644
--- 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
@@ -14,7 +14,11 @@
 import java.time.LocalDateTime;
 import java.util.List;
 
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 @SpringBootTest
 @DisplayName("OrderService 통합 테스트")
@@ -23,9 +27,6 @@ class OrderServiceIntegrationTest {
   @Autowired
   private OrderService orderService;
 
-  @Autowired
-  private OrderRepository orderRepository;
-
   @Autowired
   private DatabaseCleanUp databaseCleanUp;
 
@@ -46,8 +47,8 @@ void createOrder_thenGet_dataMatches() {
       // given
       Long userId = 1L;
       List<OrderItem> orderItems = List.of(
-          OrderItem.of(100L, "상품1", Quantity.of(2), OrderPrice.of(10000L)),
-          OrderItem.of(200L, "상품2", Quantity.of(1), OrderPrice.of(30000L))
+          OrderItem.of(100L, "상품1", Quantity.of(2L), OrderPrice.of(10000L)),
+          OrderItem.of(200L, "상품2", Quantity.of(1L), OrderPrice.of(30000L))
       );
 
       // when
@@ -57,18 +58,18 @@ void createOrder_thenGet_dataMatches() {
       // then
       assertThat(foundOrder)
           .extracting("userId", "status", "orderedAt")
-          .containsExactly(userId, OrderStatus.PAYMENT_FAILED, ORDERED_AT_2025_10_30);
+          .containsExactly(userId, OrderStatus.PENDING, ORDERED_AT_2025_10_30);
       assertThat(foundOrder.getTotalAmountValue()).isEqualTo(50000L);
     }
 
     @Test
-    @DisplayName("주문 생성 시 OrderItem이 cascade로 함께 저장되어 조회된다")
+    @DisplayName("주문 생성 시 OrderItem이 함께 저장되고 조회된다")
     void createOrder_itemsCascadePersisted() {
       // given
       Long userId = 1L;
       List<OrderItem> orderItems = List.of(
-          OrderItem.of(100L, "상품1", Quantity.of(1), OrderPrice.of(10000L)),
-          OrderItem.of(200L, "상품2", Quantity.of(2), OrderPrice.of(20000L))
+          OrderItem.of(100L, "상품1", Quantity.of(1L), OrderPrice.of(10000L)),
+          OrderItem.of(200L, "상품2", Quantity.of(2L), OrderPrice.of(20000L))
       );
 
       // when
@@ -87,26 +88,156 @@ void createOrder_itemsCascadePersisted() {
   }
 
   @Nested
-  @DisplayName("주문 상태 변경")
-  class UpdateOrderStatus {
+  @DisplayName("주문 완료 (PENDING → COMPLETED)")
+  class CompleteOrder {
+
+    @Test
+    @DisplayName("COMPLETED 주문을 완료 시도하면 예외가 발생한다")
+    void shouldThrowException_whenCompletedOrder() {
+      // given
+      Long userId = 1L;
+      List<OrderItem> orderItems = List.of(
+          OrderItem.of(100L, "상품1", Quantity.of(1L), OrderPrice.of(10000L))
+      );
+      Order savedOrder = orderService.create(userId, orderItems, ORDERED_AT_2025_10_30);
+      orderService.completeOrder(savedOrder.getId());
+
+      // when & then
+      assertThatThrownBy(() -> orderService.completeOrder(savedOrder.getId()))
+          .isInstanceOf(CoreException.class)
+          .hasMessage("PENDING 상태의 주문만 완료할 수 있습니다.");
+    }
+
+    @Test
+    @DisplayName("PAYMENT_FAILED 주문을 완료 시도하면 예외가 발생한다")
+    void shouldThrowException_whenPaymentFailedOrder() {
+      // given
+      Long userId = 1L;
+      List<OrderItem> orderItems = List.of(
+          OrderItem.of(100L, "상품1", Quantity.of(1L), OrderPrice.of(10000L))
+      );
+      Order savedOrder = orderService.create(userId, orderItems, ORDERED_AT_2025_10_30);
+      orderService.failPaymentOrder(savedOrder.getId());
+
+      // when & then
+      assertThatThrownBy(() -> orderService.completeOrder(savedOrder.getId()))
+          .isInstanceOf(CoreException.class)
+          .hasMessage("PENDING 상태의 주문만 완료할 수 있습니다.");
+    }
+
+    @Test
+    @DisplayName("PENDING 주문을 완료하면 COMPLETED 상태로 변경된다")
+    void shouldChangeToCompleted_whenPendingOrder() {
+      // given
+      Long userId = 1L;
+      List<OrderItem> orderItems = List.of(
+          OrderItem.of(100L, "상품1", Quantity.of(1L), OrderPrice.of(10000L))
+      );
+      Order savedOrder = orderService.create(userId, orderItems, ORDERED_AT_2025_10_30);
+
+      // when
+      orderService.completeOrder(savedOrder.getId());
+      Order foundOrder = orderService.getById(savedOrder.getId()).orElseThrow();
+
+      // then
+      assertThat(foundOrder).extracting("status").isEqualTo(OrderStatus.COMPLETED);
+    }
+  }
+
+  @Nested
+  @DisplayName("결제 실패 (PENDING → PAYMENT_FAILED)")
+  class FailPayment {
+
+    @Test
+    @DisplayName("COMPLETED 주문을 결제 실패 처리하면 예외가 발생한다")
+    void shouldThrowException_whenCompletedOrder() {
+      // given
+      Long userId = 1L;
+      List<OrderItem> orderItems = List.of(
+          OrderItem.of(100L, "상품1", Quantity.of(1L), OrderPrice.of(10000L))
+      );
+      Order savedOrder = orderService.create(userId, orderItems, ORDERED_AT_2025_10_30);
+      orderService.completeOrder(savedOrder.getId());
+
+      // when & then
+      assertThatThrownBy(() -> orderService.failPaymentOrder(savedOrder.getId()))
+          .isInstanceOf(CoreException.class)
+          .hasMessage("PENDING 상태의 주문만 결제 실패 상태로 변경할 수 있습니다.");
+    }
+
+    @Test
+    @DisplayName("PENDING 주문의 결제가 실패하면 PAYMENT_FAILED 상태로 변경된다")
+    void shouldChangeToPaymentFailed_whenPendingOrder() {
+      // given
+      Long userId = 1L;
+      List<OrderItem> orderItems = List.of(
+          OrderItem.of(100L, "상품1", Quantity.of(1L), OrderPrice.of(10000L))
+      );
+      Order savedOrder = orderService.create(userId, orderItems, ORDERED_AT_2025_10_30);
+
+      // when
+      orderService.failPaymentOrder(savedOrder.getId());
+      Order foundOrder = orderService.getById(savedOrder.getId()).orElseThrow();
+
+      // then
+      assertThat(foundOrder).extracting("status").isEqualTo(OrderStatus.PAYMENT_FAILED);
+    }
+  }
+
+  @Nested
+  @DisplayName("재시도 완료 (PAYMENT_FAILED → COMPLETED)")
+  class RetryComplete {
+
+    @Test
+    @DisplayName("PENDING 주문을 재시도 완료하면 예외가 발생한다")
+    void shouldThrowException_whenPendingOrder() {
+      // given
+      Long userId = 1L;
+      List<OrderItem> orderItems = List.of(
+          OrderItem.of(100L, "상품1", Quantity.of(1L), OrderPrice.of(10000L))
+      );
+      Order savedOrder = orderService.create(userId, orderItems, ORDERED_AT_2025_10_30);
+
+      // when & then
+      assertThatThrownBy(() -> orderService.retryCompleteOrder(savedOrder.getId()))
+          .isInstanceOf(CoreException.class)
+          .hasMessage("PAYMENT_FAILED 상태의 주문만 재시도 완료할 수 있습니다.");
+    }
+
+    @Test
+    @DisplayName("COMPLETED 주문을 재시도 완료하면 예외가 발생한다")
+    void shouldThrowException_whenCompletedOrder() {
+      // given
+      Long userId = 1L;
+      List<OrderItem> orderItems = List.of(
+          OrderItem.of(100L, "상품1", Quantity.of(1L), OrderPrice.of(10000L))
+      );
+      Order savedOrder = orderService.create(userId, orderItems, ORDERED_AT_2025_10_30);
+      orderService.completeOrder(savedOrder.getId());
+
+      // when & then
+      assertThatThrownBy(() -> orderService.retryCompleteOrder(savedOrder.getId()))
+          .isInstanceOf(CoreException.class)
+          .hasMessage("PAYMENT_FAILED 상태의 주문만 재시도 완료할 수 있습니다.");
+    }
 
     @Test
-    @DisplayName("주문 상태를 PAYMENT_FAILED에서 COMPLETED로 재시도 완료하면 변경된 상태가 조회된다")
-    void retryCompleteOrder_thenGet_statusChanged() {
+    @DisplayName("PAYMENT_FAILED 주문을 재시도 완료하면 COMPLETED 상태로 변경된다")
+    void shouldChangeToCompleted_whenPaymentFailedOrder() {
       // given
       Long userId = 1L;
       List<OrderItem> orderItems = List.of(
-          OrderItem.of(100L, "상품1", Quantity.of(1), OrderPrice.of(10000L))
+          OrderItem.of(100L, "상품1", Quantity.of(1L), OrderPrice.of(10000L))
       );
       Order savedOrder = orderService.create(userId, orderItems, ORDERED_AT_2025_10_30);
-      assertThat(savedOrder.getStatus()).isEqualTo(OrderStatus.PAYMENT_FAILED);
+      orderService.failPaymentOrder(savedOrder.getId());
 
       // when
       orderService.retryCompleteOrder(savedOrder.getId());
       Order foundOrder = orderService.getById(savedOrder.getId()).orElseThrow();
 
       // then
-      assertThat(foundOrder.getStatus()).isEqualTo(OrderStatus.COMPLETED);
+      assertThat(foundOrder).extracting("status").isEqualTo(OrderStatus.COMPLETED);
     }
   }
 }
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java
index 46933491f..5257226a8 100644
--- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java
@@ -3,8 +3,6 @@
 import com.loopers.domain.order.orderitem.OrderItem;
 import com.loopers.domain.order.orderitem.OrderPrice;
 import com.loopers.domain.quantity.Quantity;
-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;
@@ -34,7 +32,7 @@ class OrderServiceTest {
   private OrderRepository orderRepository;
 
   @InjectMocks
-  private OrderService sut;
+  private OrderService orderService;
 
   @Captor
   private ArgumentCaptor<Order> orderCaptor;
@@ -51,8 +49,8 @@ void createOrder_success() {
       // given
       Long userId = 1L;
       List<OrderItem> orderItems = List.of(
-          OrderItem.of(100L, "상품1", Quantity.of(2), OrderPrice.of(10000L)),
-          OrderItem.of(200L, "상품2", Quantity.of(1), OrderPrice.of(30000L))
+          OrderItem.of(100L, "상품1", Quantity.of(2L), OrderPrice.of(10000L)),
+          OrderItem.of(200L, "상품2", Quantity.of(1L), OrderPrice.of(30000L))
       );
       LocalDateTime orderedAt = ORDERED_AT_2025_10_30;
 
@@ -60,7 +58,7 @@ void createOrder_success() {
           .willAnswer(invocation -> invocation.getArgument(0));
 
       // when
-      Order result = sut.create(userId, orderItems, orderedAt);
+      orderService.create(userId, orderItems, orderedAt);
 
       // then
       then(orderRepository).should(times(1)).save(orderCaptor.capture());
@@ -68,7 +66,7 @@ void createOrder_success() {
 
       assertThat(capturedOrder)
           .extracting("userId", "status", "orderedAt")
-          .containsExactly(userId, OrderStatus.PAYMENT_FAILED, orderedAt);
+          .containsExactly(userId, OrderStatus.PENDING, orderedAt);
       assertThat(capturedOrder.getTotalAmountValue()).isEqualTo(50000L);
 
       assertThat(capturedOrder.getItems()).hasSize(2);
@@ -80,14 +78,14 @@ void createOrder_bidirectionalRelationship() {
       // given
       Long userId = 1L;
       List<OrderItem> orderItems = List.of(
-          OrderItem.of(100L, "상품1", Quantity.of(1), OrderPrice.of(10000L))
+          OrderItem.of(100L, "상품1", Quantity.of(1L), OrderPrice.of(10000L))
       );
 
       given(orderRepository.save(any(Order.class)))
           .willAnswer(invocation -> invocation.getArgument(0));
 
       // when
-      sut.create(userId, orderItems, ORDERED_AT_2025_10_30);
+      orderService.create(userId, orderItems, ORDERED_AT_2025_10_30);
 
       // then
       then(orderRepository).should(times(1)).save(orderCaptor.capture());
@@ -119,7 +117,7 @@ void getById_success() {
       given(orderRepository.findById(orderId)).willReturn(Optional.of(order));
 
       // when
-      Optional<Order> result = sut.getById(orderId);
+      Optional<Order> result = orderService.getById(orderId);
 
       // then
       assertThat(result).isPresent();
@@ -137,7 +135,7 @@ void getById_notFound() {
       given(orderRepository.findById(orderId)).willReturn(Optional.empty());
 
       // when
-      Optional<Order> result = sut.getById(orderId);
+      Optional<Order> result = orderService.getById(orderId);
 
       // then
       assertThat(result).isEmpty();
@@ -152,7 +150,7 @@ void getWithItemsById_success() {
       given(orderRepository.findWithItemsById(orderId)).willReturn(Optional.of(order));
 
       // when
-      Optional<Order> result = sut.getWithItemsById(orderId);
+      Optional<Order> result = orderService.getWithItemsById(orderId);
 
       // then
       assertThat(result).isPresent();
@@ -174,7 +172,7 @@ void shouldCompleteOrder_whenPending() {
       given(orderRepository.save(order)).willReturn(order);
 
       // when
-      Order result = sut.completeOrder(orderId);
+      Order result = orderService.completeOrder(orderId);
 
       // then
       assertThat(result.getStatus()).isEqualTo(OrderStatus.COMPLETED);
@@ -197,7 +195,7 @@ void shouldRetryCompleteOrder_whenPaymentFailed() {
       given(orderRepository.save(order)).willReturn(order);
 
       // when
-      Order result = sut.retryCompleteOrder(orderId);
+      Order result = orderService.retryCompleteOrder(orderId);
 
       // then
       assertThat(result.getStatus()).isEqualTo(OrderStatus.COMPLETED);
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java
index 3e8987731..c84b34193 100644
--- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java
@@ -115,7 +115,7 @@ void shouldCreate_whenZero() {
 
       Order order = Order.of(userId, status, totalAmount, orderedAt);
 
-      assertThat(order.getTotalAmountValue()).isEqualTo(0L);
+      assertThat(order.getTotalAmountValue()).isZero();
     }
   }
 
@@ -156,7 +156,7 @@ void shouldComplete_whenPending() {
     void shouldThrowException_whenNotPending() {
       Order order = Order.of(1L, OrderStatus.COMPLETED, 50000L, ORDERED_AT_2025_10_30);
 
-      assertThatThrownBy(() -> order.complete())
+      assertThatThrownBy(order::complete)
           .isInstanceOf(CoreException.class)
           .hasMessage("PENDING 상태의 주문만 완료할 수 있습니다.")
           .extracting("errorType").isEqualTo(ErrorType.ORDER_CANNOT_COMPLETE);
@@ -208,7 +208,7 @@ void shouldRetryComplete_whenPaymentFailed() {
     void shouldThrowException_whenNotPaymentFailed() {
       Order order = Order.of(1L, OrderStatus.PENDING, 50000L, ORDERED_AT_2025_10_30);
 
-      assertThatThrownBy(() -> order.retryComplete())
+      assertThatThrownBy(order::retryComplete)
           .isInstanceOf(CoreException.class)
           .hasMessage("PAYMENT_FAILED 상태의 주문만 재시도 완료할 수 있습니다.")
           .extracting("errorType").isEqualTo(ErrorType.ORDER_CANNOT_RETRY_COMPLETE);
@@ -224,7 +224,7 @@ class OrderWithOrderItem {
     void shouldAddItem() {
       // given
       Order order = Order.of(1L, OrderStatus.PENDING, 0L, ORDERED_AT_2025_10_30);
-      OrderItem item = OrderItem.of(100L, "테스트 상품", Quantity.of(2), OrderPrice.of(10000L));
+      OrderItem item = OrderItem.of(100L, "테스트 상품", Quantity.of(2L), OrderPrice.of(10000L));
 
       // when
       order.addItem(item);
@@ -239,8 +239,8 @@ void shouldAddItem() {
     void shouldAddMultipleItems() {
       // given
       Order order = Order.of(1L, OrderStatus.PENDING, 0L, ORDERED_AT_2025_10_30);
-      OrderItem item1 = OrderItem.of(100L, "상품1", Quantity.of(1), OrderPrice.of(10000L));
-      OrderItem item2 = OrderItem.of(200L, "상품2", Quantity.of(2), OrderPrice.of(20000L));
+      OrderItem item1 = OrderItem.of(100L, "상품1", Quantity.of(1L), OrderPrice.of(10000L));
+      OrderItem item2 = OrderItem.of(200L, "상품2", Quantity.of(2L), OrderPrice.of(20000L));
 
       // when
       order.addItem(item1);
@@ -256,7 +256,7 @@ void shouldAddMultipleItems() {
     void shouldSetBidirectionalRelationship() {
       // given
       Order order = Order.of(1L, OrderStatus.PENDING, 0L, ORDERED_AT_2025_10_30);
-      OrderItem item = OrderItem.of(100L, "테스트 상품", Quantity.of(2), OrderPrice.of(10000L));
+      OrderItem item = OrderItem.of(100L, "테스트 상품", Quantity.of(2L), OrderPrice.of(10000L));
 
       // when
       order.addItem(item);
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/orderitem/OrderItemTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/orderitem/OrderItemTest.java
index ec2fb61a6..2b3e0a32e 100644
--- a/apps/commerce-api/src/test/java/com/loopers/domain/order/orderitem/OrderItemTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/orderitem/OrderItemTest.java
@@ -1,7 +1,8 @@
 package com.loopers.domain.order.orderitem;
 
-import com.loopers.domain.order.orderitem.OrderItem;
-import com.loopers.domain.order.orderitem.OrderPrice;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
 import com.loopers.domain.quantity.Quantity;
 import com.loopers.support.error.CoreException;
 import com.loopers.support.error.ErrorType;
@@ -9,9 +10,6 @@
 import org.junit.jupiter.api.Nested;
 import org.junit.jupiter.api.Test;
 
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
-
 @DisplayName("OrderItem 도메인 테스트")
 class OrderItemTest {
 
@@ -25,7 +23,7 @@ void shouldCreate_whenValid() {
       // given
       Long productId = 100L;
       String productName = "테스트 상품";
-      Quantity quantity = Quantity.of(3);
+      Quantity quantity = Quantity.of(3L);
       OrderPrice orderPrice = OrderPrice.of(10000L);
 
       // when
@@ -48,7 +46,7 @@ class ValidateProductId {
     void shouldThrowException_whenNull() {
       // given
       String productName = "테스트 상품";
-      Quantity quantity = Quantity.of(1);
+      Quantity quantity = Quantity.of(1L);
       OrderPrice orderPrice = OrderPrice.of(10000L);
 
       // when & then
@@ -68,7 +66,7 @@ class ValidateProductName {
     void shouldThrowException_whenNull() {
       // given
       Long productId = 100L;
-      Quantity quantity = Quantity.of(1);
+      Quantity quantity = Quantity.of(1L);
       OrderPrice orderPrice = OrderPrice.of(10000L);
 
       // when & then
@@ -83,7 +81,7 @@ void shouldThrowException_whenNull() {
     void shouldThrowException_whenBlank() {
       // given
       Long productId = 100L;
-      Quantity quantity = Quantity.of(1);
+      Quantity quantity = Quantity.of(1L);
       OrderPrice orderPrice = OrderPrice.of(10000L);
 
       // when & then
@@ -99,7 +97,7 @@ void shouldThrowException_whenTooLong() {
       // given
       Long productId = 100L;
       String productName = "a".repeat(101);
-      Quantity quantity = Quantity.of(1);
+      Quantity quantity = Quantity.of(1L);
       OrderPrice orderPrice = OrderPrice.of(10000L);
 
       // when & then
@@ -140,7 +138,7 @@ void shouldThrowException_whenNull() {
       // given
       Long productId = 100L;
       String productName = "테스트 상품";
-      Quantity quantity = Quantity.of(1);
+      Quantity quantity = Quantity.of(1L);
 
       // when & then
       assertThatThrownBy(() -> OrderItem.of(productId, productName, quantity, null))
@@ -160,7 +158,7 @@ void shouldGetOrderPriceValue() {
       // given
       Long productId = 100L;
       String productName = "테스트 상품";
-      Quantity quantity = Quantity.of(1);
+      Quantity quantity = Quantity.of(1L);
       OrderPrice orderPrice = OrderPrice.of(20000L);
       OrderItem orderItem = OrderItem.of(productId, productName, quantity, orderPrice);
 
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
index 8589e4fbc..5e04cd434 100644
--- 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
@@ -51,7 +51,7 @@ void tearDown() {
   class Get {
 
     @Test
-    @DisplayName("해당 ID의 회원이 존재할 경우, 보유 포인트가 반환된다")
+    @DisplayName("회원이 존재하면 해당 회원의 포인트가 반환된다")
     void returnsPoint_whenUserExists() {
       // given
       String userId = "testuser";
@@ -59,7 +59,7 @@ void returnsPoint_whenUserExists() {
       LocalDate birth = LocalDate.of(1990, 1, 1);
       Gender gender = Gender.MALE;
 
-      User user = User.of(userId, email, birth, gender, TEST_CLOCK);
+      User user = User.of(userId, email, birth, gender, LocalDate.now(TEST_CLOCK));
       User savedUser = userRepository.save(user);
 
       Point point = Point.zero(savedUser.getId());
@@ -75,7 +75,7 @@ void returnsPoint_whenUserExists() {
     }
 
     @Test
-    @DisplayName("해당 ID의 회원이 존재하지 않을 경우, empty Optional이 반환된다")
+    @DisplayName("회원이 존재하지 않으면 빈 Optional이 반환된다")
     void returnsEmpty_whenUserDoesNotExist() {
       // given
       Long nonExistentUserId = 999999L;
@@ -90,7 +90,7 @@ void returnsEmpty_whenUserDoesNotExist() {
   class Charge {
 
     @Test
-    @DisplayName("존재하는 유저 ID로 충전하면 성공한다")
+    @DisplayName("존재하는 회원 ID로 충전하면 포인트가 증가한다")
     void succeeds_whenUserExists() {
       // given
       String loginId = "testuser";
@@ -98,7 +98,7 @@ void succeeds_whenUserExists() {
       LocalDate birth = LocalDate.of(1990, 1, 1);
       Gender gender = Gender.MALE;
 
-      User user = User.of(loginId, email, birth, gender, TEST_CLOCK);
+      User user = User.of(loginId, email, birth, gender, LocalDate.now(TEST_CLOCK));
       User savedUser = userRepository.save(user);
 
       Point point = Point.of(savedUser.getId(), 1000L);
@@ -107,7 +107,7 @@ void succeeds_whenUserExists() {
       Long chargeAmount = 500L;
 
       // when
-      Point result = pointService.charge(loginId, chargeAmount);
+      Point result = pointService.charge(savedUser.getId(), chargeAmount);
 
       // then
       assertThat(result)
@@ -116,15 +116,15 @@ void succeeds_whenUserExists() {
     }
 
     @Test
-    @DisplayName("존재하지 않는 유저 ID로 충전하면 실패한다")
+    @DisplayName("존재하지 않는 회원 ID로 충전하면 예외가 발생한다")
     void fails_whenUserDoesNotExist() {
       // given
-      String nonExistentLoginId = "nonexistent";
+      Long nonExistentUserId = 999999L;
       Long chargeAmount = 500L;
 
       // when & then
       assertThatThrownBy(() ->
-          pointService.charge(nonExistentLoginId, chargeAmount)
+          pointService.charge(nonExistentUserId, chargeAmount)
       )
           .isInstanceOf(CoreException.class)
           .extracting("errorType", "message")
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java
index 19be13a70..765f7a86a 100644
--- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java
@@ -27,7 +27,7 @@ class ProductServiceTest {
   private ProductRepository productRepository;
 
   @InjectMocks
-  private ProductService sut;
+  private ProductService productService;
 
   @Nested
   @DisplayName("상품 목록 조회")
@@ -42,7 +42,7 @@ void findProducts_withNullBrandId() {
       given(productRepository.findAll(pageable)).willReturn(expectedPage);
 
       // when
-      Page<Product> result = sut.findProducts(null, pageable);
+      Page<Product> result = productService.findProducts(null, pageable);
 
       // then
       assertThat(result).isEqualTo(expectedPage);
@@ -60,7 +60,7 @@ void findProducts_withBrandId() {
       given(productRepository.findByBrandId(brandId, pageable)).willReturn(expectedPage);
 
       // when
-      Page<Product> result = sut.findProducts(brandId, pageable);
+      Page<Product> result = productService.findProducts(brandId, pageable);
 
       // then
       assertThat(result).isEqualTo(expectedPage);
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/productlike/ProductLikeStatusesTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/productlike/ProductLikeStatusesTest.java
deleted file mode 100644
index 85e828638..000000000
--- a/apps/commerce-api/src/test/java/com/loopers/domain/productlike/ProductLikeStatusesTest.java
+++ /dev/null
@@ -1,60 +0,0 @@
-package com.loopers.domain.productlike;
-
-import org.junit.jupiter.api.DisplayName;
-import org.junit.jupiter.api.Test;
-
-import java.util.Map;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-@DisplayName("ProductLikeStatuses 일급컬렉션 테스트")
-class ProductLikeStatusesTest {
-
-  @Test
-  @DisplayName("Map으로 일급컬렉션을 생성한다")
-  void from() {
-    Map<Long, Boolean> statusMap = Map.of(1L, true, 2L, false);
-
-    ProductLikeStatuses statuses = ProductLikeStatuses.from(statusMap);
-
-    assertThat(statuses.toMap()).hasSize(2);
-  }
-
-  @Test
-  @DisplayName("빈 일급컬렉션을 생성한다")
-  void empty() {
-    ProductLikeStatuses statuses = ProductLikeStatuses.empty();
-
-    assertThat(statuses.toMap()).isEmpty();
-  }
-
-  @Test
-  @DisplayName("특정 상품의 좋아요 여부를 확인한다")
-  void isLiked() {
-    Map<Long, Boolean> statusMap = Map.of(1L, true, 2L, false);
-    ProductLikeStatuses statuses = ProductLikeStatuses.from(statusMap);
-
-    assertThat(statuses.isLiked(1L)).isTrue();
-    assertThat(statuses.isLiked(2L)).isFalse();
-  }
-
-  @Test
-  @DisplayName("존재하지 않는 상품 ID는 false를 반환한다")
-  void isLiked_whenProductNotExists() {
-    ProductLikeStatuses statuses = ProductLikeStatuses.from(Map.of(1L, true));
-
-    assertThat(statuses.isLiked(999L)).isFalse();
-  }
-
-  @Test
-  @DisplayName("내부 Map을 불변 복사본으로 반환한다")
-  void toMap() {
-    Map<Long, Boolean> statusMap = Map.of(1L, true);
-    ProductLikeStatuses statuses = ProductLikeStatuses.from(statusMap);
-
-    Map<Long, Boolean> map1 = statuses.toMap();
-    Map<Long, Boolean> map2 = statuses.toMap();
-
-    assertThat(map1).isEqualTo(map2);
-  }
-}
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/productlike/ProductLikesTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/productlike/ProductLikesTest.java
index c2a45e635..d26e59003 100644
--- a/apps/commerce-api/src/test/java/com/loopers/domain/productlike/ProductLikesTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/productlike/ProductLikesTest.java
@@ -59,22 +59,22 @@ void empty() {
   }
 
   @Nested
-  @DisplayName("toLikedMap 변환")
-  class ToLikedMap {
+  @DisplayName("toLikedByProductId 변환")
+  class ToLikedByProductId {
 
     @Test
     @DisplayName("빈 리스트를 빈 Map으로 변환")
-    void toLikedMap_empty() {
+    void toLikedByProductId_empty() {
       ProductLikes productLikes = ProductLikes.empty();
 
-      Map<Long, Boolean> result = productLikes.toLikedMap();
+      Map<Long, Boolean> result = productLikes.toLikedByProductId();
 
       assertThat(result).isEmpty();
     }
 
     @Test
     @DisplayName("ProductLike 리스트를 좋아요 Map으로 변환 (모두 true)")
-    void toLikedMap_success() {
+    void toLikedByProductId_success() {
       List<ProductLike> likes = List.of(
           ProductLike.of(1L, 100L, LIKED_AT_2025_10_30),
           ProductLike.of(1L, 200L, LIKED_AT_2025_10_30),
@@ -82,7 +82,7 @@ void toLikedMap_success() {
       );
       ProductLikes productLikes = ProductLikes.from(likes);
 
-      Map<Long, Boolean> result = productLikes.toLikedMap();
+      Map<Long, Boolean> result = productLikes.toLikedByProductId();
 
       assertThat(result)
           .hasSize(3)
@@ -93,34 +93,34 @@ void toLikedMap_success() {
   }
 
   @Nested
-  @DisplayName("toStatuses 변환")
-  class ToStatuses {
+  @DisplayName("toLikeStatusByProductId 변환")
+  class ToLikeStatusByProductId {
 
     @Test
-    @DisplayName("null productIds로 호출 시 빈 ProductLikeStatuses 반환")
-    void toStatuses_nullProductIds() {
+    @DisplayName("null productIds로 호출 시 빈 Map 반환")
+    void toLikeStatusByProductId_nullProductIds() {
       ProductLikes productLikes = ProductLikes.empty();
 
-      ProductLikeStatuses result = productLikes.toStatuses(null);
+      Map<Long, Boolean> result = productLikes.toLikeStatusByProductId(null);
 
-      assertThat(result).isNotNull();
+      assertThat(result).isEmpty();
     }
 
     @Test
-    @DisplayName("빈 productIds로 호출 시 빈 ProductLikeStatuses 반환")
-    void toStatuses_emptyProductIds() {
+    @DisplayName("빈 productIds로 호출 시 빈 Map 반환")
+    void toLikeStatusByProductId_emptyProductIds() {
       ProductLikes productLikes = ProductLikes.from(List.of(
           ProductLike.of(1L, 100L, LIKED_AT_2025_10_30)
       ));
 
-      ProductLikeStatuses result = productLikes.toStatuses(List.of());
+      Map<Long, Boolean> result = productLikes.toLikeStatusByProductId(List.of());
 
-      assertThat(result).isNotNull();
+      assertThat(result).isEmpty();
     }
 
     @Test
     @DisplayName("좋아요한 상품과 안 한 상품 혼합 상태로 변환")
-    void toStatuses_mixed() {
+    void toLikeStatusByProductId_mixed() {
       List<ProductLike> likes = List.of(
           ProductLike.of(1L, 100L, LIKED_AT_2025_10_30),
           ProductLike.of(1L, 300L, LIKED_AT_2025_10_30)
@@ -128,30 +128,34 @@ void toStatuses_mixed() {
       ProductLikes productLikes = ProductLikes.from(likes);
       List<Long> allProductIds = List.of(100L, 200L, 300L, 400L);
 
-      ProductLikeStatuses result = productLikes.toStatuses(allProductIds);
+      Map<Long, Boolean> result = productLikes.toLikeStatusByProductId(allProductIds);
 
-      assertThat(result.isLiked(100L)).isTrue();
-      assertThat(result.isLiked(200L)).isFalse();
-      assertThat(result.isLiked(300L)).isTrue();
-      assertThat(result.isLiked(400L)).isFalse();
+      assertThat(result)
+          .hasSize(4)
+          .containsEntry(100L, true)
+          .containsEntry(200L, false)
+          .containsEntry(300L, true)
+          .containsEntry(400L, false);
     }
 
     @Test
     @DisplayName("모든 상품을 좋아요하지 않은 경우")
-    void toStatuses_allNotLiked() {
+    void toLikeStatusByProductId_allNotLiked() {
       ProductLikes productLikes = ProductLikes.empty();
       List<Long> allProductIds = List.of(100L, 200L, 300L);
 
-      ProductLikeStatuses result = productLikes.toStatuses(allProductIds);
+      Map<Long, Boolean> result = productLikes.toLikeStatusByProductId(allProductIds);
 
-      assertThat(result.isLiked(100L)).isFalse();
-      assertThat(result.isLiked(200L)).isFalse();
-      assertThat(result.isLiked(300L)).isFalse();
+      assertThat(result)
+          .hasSize(3)
+          .containsEntry(100L, false)
+          .containsEntry(200L, false)
+          .containsEntry(300L, false);
     }
 
     @Test
     @DisplayName("모든 상품을 좋아요한 경우")
-    void toStatuses_allLiked() {
+    void toLikeStatusByProductId_allLiked() {
       List<ProductLike> likes = List.of(
           ProductLike.of(1L, 100L, LIKED_AT_2025_10_30),
           ProductLike.of(1L, 200L, LIKED_AT_2025_10_30),
@@ -160,25 +164,30 @@ void toStatuses_allLiked() {
       ProductLikes productLikes = ProductLikes.from(likes);
       List<Long> allProductIds = List.of(100L, 200L, 300L);
 
-      ProductLikeStatuses result = productLikes.toStatuses(allProductIds);
+      Map<Long, Boolean> result = productLikes.toLikeStatusByProductId(allProductIds);
 
-      assertThat(result.isLiked(100L)).isTrue();
-      assertThat(result.isLiked(200L)).isTrue();
-      assertThat(result.isLiked(300L)).isTrue();
+      assertThat(result)
+          .hasSize(3)
+          .containsEntry(100L, true)
+          .containsEntry(200L, true)
+          .containsEntry(300L, true);
     }
 
     @Test
     @DisplayName("좋아요 목록에 없는 상품 ID 조회 시 false 반환")
-    void toStatuses_productNotInList() {
+    void toLikeStatusByProductId_productNotInList() {
       List<ProductLike> likes = List.of(
           ProductLike.of(1L, 100L, LIKED_AT_2025_10_30)
       );
       ProductLikes productLikes = ProductLikes.from(likes);
       List<Long> allProductIds = List.of(100L, 999L);
 
-      ProductLikeStatuses result = productLikes.toStatuses(allProductIds);
+      Map<Long, Boolean> result = productLikes.toLikeStatusByProductId(allProductIds);
 
-      assertThat(result.isLiked(999L)).isFalse();
+      assertThat(result)
+          .hasSize(2)
+          .containsEntry(100L, true)
+          .containsEntry(999L, false);
     }
   }
 }
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/quantity/QuantityTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/quantity/QuantityTest.java
index 5de381c64..342d12a3b 100644
--- a/apps/commerce-api/src/test/java/com/loopers/domain/quantity/QuantityTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/quantity/QuantityTest.java
@@ -29,7 +29,7 @@ void shouldThrowException_whenNull() {
     @DisplayName("음수이면 예외가 발생한다")
     @Test
     void shouldThrowException_whenNegative() {
-      assertThatThrownBy(() -> Quantity.of(-1))
+      assertThatThrownBy(() -> Quantity.of(-1L))
           .isInstanceOf(CoreException.class)
           .hasMessage("수량은 0 이상이어야 합니다.")
           .extracting("errorType").isEqualTo(ErrorType.INVALID_QUANTITY_RANGE);
@@ -37,34 +37,34 @@ void shouldThrowException_whenNegative() {
 
     @DisplayName("0 이상의 값으로 생성하면 성공한다")
     @ParameterizedTest
-    @ValueSource(ints = {0, 1, 10, 100, 999})
-    void shouldCreate_whenValidValue(Integer value) {
+    @ValueSource(longs = {0, 1, 10, 100, 999})
+    void shouldCreate_whenValidValue(Long value) {
       Quantity quantity = Quantity.of(value);
 
       assertThat(quantity.getValue()).isEqualTo(value);
     }
   }
-}
 
-@DisplayName("두 객체의 동등성을 비교 할 때")
-@Nested
-class Equality {
+  @DisplayName("두 객체의 동등성을 비교 할 때")
+  @Nested
+  class Equality {
 
-  @DisplayName("같은 값이면 동등하다")
-  @Test
-  void shouldBeEqual_whenSameValue() {
-    Quantity quantity1 = Quantity.of(5);
-    Quantity quantity2 = Quantity.of(5);
+    @DisplayName("같은 값이면 동등하다")
+    @Test
+    void shouldBeEqual_whenSameValue() {
+      Quantity quantity1 = Quantity.of(5L);
+      Quantity quantity2 = Quantity.of(5L);
 
-    assertThat(quantity1).isEqualTo(quantity2);
-  }
+      assertThat(quantity1).isEqualTo(quantity2);
+    }
 
-  @DisplayName("다른 값이면 동등하지 않다")
-  @Test
-  void shouldNotBeEqual_whenDifferentValue() {
-    Quantity quantity1 = Quantity.of(5);
-    Quantity quantity2 = Quantity.of(10);
+    @DisplayName("다른 값이면 동등하지 않다")
+    @Test
+    void shouldNotBeEqual_whenDifferentValue() {
+      Quantity quantity1 = Quantity.of(5L);
+      Quantity quantity2 = Quantity.of(10L);
 
-    assertThat(quantity1).isNotEqualTo(quantity2);
+      assertThat(quantity1).isNotEqualTo(quantity2);
+    }
   }
 }
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
index 34130901e..404b4bc13 100644
--- 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
@@ -12,6 +12,7 @@
 import com.loopers.support.error.CoreException;
 import com.loopers.support.error.ErrorType;
 import com.loopers.utils.DatabaseCleanUp;
+import java.time.Clock;
 import java.time.LocalDate;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
@@ -34,7 +35,7 @@ class UserServiceIntegrationTest {
 
   @BeforeEach
   void setUp() {
-    userService = new UserService(userRepository);
+    userService = new UserService(userRepository, Clock.systemDefaultZone());
   }
 
   @AfterEach
@@ -47,11 +48,11 @@ void tearDown() {
   class RegisterUser {
 
     @Test
-    @DisplayName("User 저장이 수행된다")
+    @DisplayName("회원가입을 요청하면 User가 저장된다")
     void saveUser_whenRegisterUser() {
       // given
       UserRepository spyRepository = spy(userRepository);
-      UserService spyService = new UserService(spyRepository);
+      UserService spyService = new UserService(spyRepository, Clock.systemDefaultZone());
 
       String loginId = "testuser";
       String email = "test@example.com";
@@ -71,7 +72,7 @@ void saveUser_whenRegisterUser() {
     }
 
     @Test
-    @DisplayName("이미 가입된 ID로 회원가입 시도 시 실패한다")
+    @DisplayName("이미 가입된 ID로 회원가입하면 예외가 발생한다")
     void throwsException_whenDuplicateLoginId() {
       // given
       String loginId = "existuser";
@@ -115,7 +116,7 @@ void returnsUser_whenValidInfoIsProvided() {
   class Get {
 
     @Test
-    @DisplayName("해당 ID의 회원이 존재할 경우, 회원 정보가 반환된다")
+    @DisplayName("회원이 존재하면 회원 정보가 반환된다")
     void returnsUser_whenUserExists() {
       // given
       String loginId = "testuser";
@@ -134,7 +135,7 @@ void returnsUser_whenUserExists() {
     }
 
     @Test
-    @DisplayName("해당 ID의 회원이 존재하지 않을 경우, null이 반환된다")
+    @DisplayName("회원이 존재하지 않으면 null이 반환된다")
     void returnsNull_whenUserDoesNotExist() {
       // given
       String nonExistentLoginId = "nonexistent";
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java
index 4fadf9bbb..01d6703b1 100644
--- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java
@@ -37,7 +37,7 @@ void shouldCreate_whenValid() {
             LocalDate birth = LocalDate.of(1990, 1, 15);
             Gender gender = MALE;
 
-            User result = User.of(loginId, email, birth, gender, TEST_CLOCK);
+            User result = User.of(loginId, email, birth, gender, LocalDate.now(TEST_CLOCK));
 
             assertThat(result)
                 .extracting("loginId", "email", "birth", "gender")
@@ -55,7 +55,7 @@ void shouldCreate_whenValidLoginId() {
             String validLoginId = "user123";
             LocalDate validBirth = LocalDate.of(1990, 1, 1);
 
-            User result = User.of(validLoginId, "test@example.com", validBirth, MALE, TEST_CLOCK);
+            User result = User.of(validLoginId, "test@example.com", validBirth, MALE, LocalDate.now(TEST_CLOCK));
 
             assertThat(result.getLoginId()).isEqualTo(validLoginId);
         }
@@ -67,7 +67,7 @@ void shouldThrowException_whenNullOrEmpty(String invalidLoginId) {
             LocalDate validBirth = LocalDate.of(1990, 1, 1);
 
             assertThatThrownBy(() ->
-                User.of(invalidLoginId, "test@example.com", validBirth, MALE, TEST_CLOCK)
+                User.of(invalidLoginId, "test@example.com", validBirth, MALE, LocalDate.now(TEST_CLOCK))
             )
                 .isInstanceOf(CoreException.class)
                 .hasMessage("로그인 ID는 비어있을 수 없습니다.");
@@ -88,7 +88,7 @@ void shouldThrowException_whenInvalidFormat(String invalidLoginId) {
             LocalDate validBirth = LocalDate.of(1990, 1, 1);
 
             assertThatThrownBy(() ->
-                User.of(invalidLoginId, "test@example.com", validBirth, MALE, TEST_CLOCK)
+                User.of(invalidLoginId, "test@example.com", validBirth, MALE, LocalDate.now(TEST_CLOCK))
             )
                 .isInstanceOf(CoreException.class);
         }
@@ -104,7 +104,7 @@ void shouldCreate_whenValidEmail() {
             String validEmail = "user@example.com";
             LocalDate validBirth = LocalDate.of(1990, 1, 1);
 
-            User result = User.of("user123", validEmail, validBirth, MALE, TEST_CLOCK);
+            User result = User.of("user123", validEmail, validBirth, MALE, LocalDate.now(TEST_CLOCK));
 
             assertThat(result.getEmail()).isEqualTo(validEmail);
         }
@@ -116,7 +116,7 @@ void shouldThrowException_whenNullOrEmpty(String invalidEmail) {
             LocalDate validBirth = LocalDate.of(1990, 1, 1);
 
             assertThatThrownBy(() ->
-                User.of("user123", invalidEmail, validBirth, MALE, TEST_CLOCK)
+                User.of("user123", invalidEmail, validBirth, MALE, LocalDate.now(TEST_CLOCK))
             )
                 .isInstanceOf(CoreException.class)
                 .hasMessage("이메일은 비어있을 수 없습니다.");
@@ -137,7 +137,7 @@ void shouldThrowException_whenInvalidFormat(String invalidEmail) {
             LocalDate validBirth = LocalDate.of(1990, 1, 1);
 
             assertThatThrownBy(() ->
-                User.of("user123", invalidEmail, validBirth, MALE, TEST_CLOCK)
+                User.of("user123", invalidEmail, validBirth, MALE, LocalDate.now(TEST_CLOCK))
             )
                 .isInstanceOf(CoreException.class);
         }
@@ -152,7 +152,7 @@ class ValidateBirth {
         void shouldCreate_whenValidBirth() {
             LocalDate validBirth = LocalDate.of(1990, 1, 15);
 
-            User result = User.of("user123", "test@example.com", validBirth, MALE, TEST_CLOCK);
+            User result = User.of("user123", "test@example.com", validBirth, MALE, LocalDate.now(TEST_CLOCK));
 
             assertThat(result.getBirth()).isEqualTo(validBirth);
         }
@@ -161,7 +161,7 @@ void shouldCreate_whenValidBirth() {
         @Test
         void shouldThrowException_whenNull() {
             assertThatThrownBy(() ->
-                User.of("user123", "test@example.com", null, MALE, TEST_CLOCK)
+                User.of("user123", "test@example.com", null, MALE, LocalDate.now(TEST_CLOCK))
             )
                 .isInstanceOf(CoreException.class)
                 .hasMessage("생년월일은 비어있을 수 없습니다.");
@@ -173,7 +173,7 @@ void shouldThrowException_whenFutureDate() {
             LocalDate futureDate = LocalDate.now(TEST_CLOCK).plusDays(1);
 
             assertThatThrownBy(() ->
-                User.of("user123", "test@example.com", futureDate, MALE, TEST_CLOCK)
+                User.of("user123", "test@example.com", futureDate, MALE, LocalDate.now(TEST_CLOCK))
             )
                 .isInstanceOf(CoreException.class)
                 .hasMessage("생년월일은 미래일 수 없습니다.");
diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/point/PointApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/point/PointApiE2ETest.java
index 04dcfdf52..054eae9b6 100644
--- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/point/PointApiE2ETest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/point/PointApiE2ETest.java
@@ -65,12 +65,12 @@ void tearDown() {
     databaseCleanUp.truncateAllTables();
   }
 
-  @DisplayName("GET /api/v1/points (header: X-USER-LOGIN-ID) ")
+  @DisplayName("GET /api/v1/points (header: X-USER-ID) ")
   @Nested
   class GetPoint {
 
     @Test
-    @DisplayName("`X-USER-LOGIN-ID` 헤더가 없을 경우, `400 Bad Request` 응답을 반환한다.")
+    @DisplayName("`X-USER-ID` 헤더가 없을 경우, `400 Bad Request` 응답을 반환한다.")
     void returnBadRequestException_whenUserIdHeaderIsMissing() {
       ParameterizedTypeReference<ApiResponse<PointResponse>> responseType =
           new ParameterizedTypeReference<>() {
@@ -93,12 +93,12 @@ void returnBadRequestException_whenUserIdHeaderIsMissing() {
     @DisplayName("포인트 조회에 성공할 경우, 보유 포인트를 응답으로 반환한다.")
     void returnsPoint_whenPointExists() {
       //given
-      String userId = "testuser";
+      String loginId = "testuser";
       String email = "test@example.com";
       LocalDate birth = LocalDate.of(1990, 1, 1);
       Gender gender = Gender.MALE;
 
-      User user = User.of(userId, email, birth, gender, TEST_CLOCK);
+      User user = User.of(loginId, email, birth, gender, LocalDate.now(TEST_CLOCK));
       User savedUser = userJpaRepository.save(user);
 
       Point point = Point.of(savedUser.getId(), 5L);
@@ -106,7 +106,7 @@ void returnsPoint_whenPointExists() {
 
       // when
       HttpHeaders httpHeaders = new HttpHeaders();
-      httpHeaders.set("X-USER-LOGIN-ID", userId);
+      httpHeaders.set("X-USER-ID", String.valueOf(savedUser.getId()));
 
       ParameterizedTypeReference<ApiResponse<PointResponse>> responseType =
           new ParameterizedTypeReference<>() {
@@ -133,10 +133,10 @@ void returnsPoint_whenPointExists() {
     @DisplayName("해당 ID의 회원이 존재하지 않을 경우, null을 반환한다.")
     void returnsNull_whenUserDoesNotExists() {
       //given
-      String userId = "nonexistent";
+      Long userId = 999999L;
       // when
       HttpHeaders httpHeaders = new HttpHeaders();
-      httpHeaders.set("X-USER-LOGIN-ID", userId);
+      httpHeaders.set("X-USER-ID", String.valueOf(userId));
 
       ParameterizedTypeReference<ApiResponse<PointResponse>> responseType =
           new ParameterizedTypeReference<>() {
@@ -159,7 +159,7 @@ void returnsNull_whenUserDoesNotExists() {
 
   }
 
-  @DisplayName("PATCH /api/v1/points/charge (header: X-USER-LOGIN-ID, body: ChargeRequest)")
+  @DisplayName("PATCH /api/v1/points/charge (header: X-USER-ID, body: ChargeRequest)")
   @Nested
   class ChargePoint {
 
@@ -167,12 +167,12 @@ class ChargePoint {
     @DisplayName("존재하는 유저가 1000원을 충전할 경우, 충전된 보유 총량을 응답으로 반환한다")
     void returnsTotalBalance_whenChargingThousand() {
       // given
-      String userId = "testuser";
+      String loginId = "testuser";
       String email = "test@example.com";
       LocalDate birth = LocalDate.of(1990, 1, 1);
       Gender gender = Gender.MALE;
 
-      User user = User.of(userId, email, birth, gender, TEST_CLOCK);
+      User user = User.of(loginId, email, birth, gender, LocalDate.now(TEST_CLOCK));
       User savedUser = userJpaRepository.save(user);
 
       Point point = Point.zero(savedUser.getId());
@@ -182,7 +182,7 @@ void returnsTotalBalance_whenChargingThousand() {
 
       // when
       HttpHeaders httpHeaders = new HttpHeaders();
-      httpHeaders.set("X-USER-LOGIN-ID", userId);
+      httpHeaders.set("X-USER-ID", String.valueOf(savedUser.getId()));
 
       ParameterizedTypeReference<ApiResponse<ChargeResponse>> responseType =
           new ParameterizedTypeReference<>() {
@@ -208,12 +208,12 @@ void returnsTotalBalance_whenChargingThousand() {
     @DisplayName("존재하지 않는 유저로 요청할 경우, 404 NOT FOUND 응답을 반환한다.")
     void returnsNotFoundException_whenUserDoesNotExists() {
       // given
-      String userId = "doesnotexist";
+      Long userId = 999999L;
       ChargeRequest chargeRequest = new ChargeRequest(1000L);
 
       // when
       HttpHeaders httpHeaders = new HttpHeaders();
-      httpHeaders.set("X-USER-LOGIN-ID", userId);
+      httpHeaders.set("X-USER-ID", String.valueOf(userId));
 
       ParameterizedTypeReference<ApiResponse<ChargeResponse>> responseType =
           new ParameterizedTypeReference<>() {
diff --git a/docs/design/01-requirements.md b/docs/design/01-requirements.md
index dbe1fe2b5..94fa67659 100644
--- a/docs/design/01-requirements.md
+++ b/docs/design/01-requirements.md
@@ -21,7 +21,7 @@
 - **제외**: 회원가입, 포인트 충전 (1주차 구현 완료)
 
 ### 0.2 인증/인가
-- 모든 API는 별도의 인증없이 X-USER-LOGIN-ID 헤더로 사용자 식별
+- 모든 API는 별도의 인증없이 X-USER-ID 헤더로 사용자 식별
 - 토큰이 없으면 비회원 사용자
 - 회원가입 API는 1주차 완료 (설계 범위 제외)
 
