From 823e679e12db9b1c7d8298dff3c3f119d3cf5d58 Mon Sep 17 00:00:00 2001
From: green <green@mobidoc.us>
Date: Wed, 19 Nov 2025 20:52:46 +0900
Subject: [PATCH 01/14] =?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?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

ErrorType:
- 도메인별 에러 코드 추가 및 정리
- API 응답용 에러 메시지 개선

IntegrationTestSupport:
- 통합 테스트 공통 인프라 추가
- 트랜잭션 관리 및 DB 초기화 지원
---
 .../main/java/com/loopers/support/error/ErrorType.java    | 1 +
 .../com/loopers/support/test/IntegrationTestSupport.java  | 8 ++++++++
 2 files changed, 9 insertions(+)
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/support/test/IntegrationTestSupport.java

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 2668d6a01..6a3cc3f37 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,6 +11,7 @@ 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(), "존재하지 않는 요청입니다."),
+    FORBIDDEN(HttpStatus.FORBIDDEN, HttpStatus.FORBIDDEN.getReasonPhrase(), "권한이 없습니다."),
     CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 리소스입니다."),
 
     /** Money 도메인 에러 */
diff --git a/apps/commerce-api/src/test/java/com/loopers/support/test/IntegrationTestSupport.java b/apps/commerce-api/src/test/java/com/loopers/support/test/IntegrationTestSupport.java
new file mode 100644
index 000000000..595d9dd04
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/support/test/IntegrationTestSupport.java
@@ -0,0 +1,8 @@
+package com.loopers.support.test;
+
+import org.springframework.boot.test.context.SpringBootTest;
+
+@SpringBootTest
+public abstract class IntegrationTestSupport {
+
+}

From f6386981be1ef919a454d1f26ef005526256900f Mon Sep 17 00:00:00 2001
From: green <green@mobidoc.us>
Date: Wed, 19 Nov 2025 20:52:57 +0900
Subject: [PATCH 02/14] =?UTF-8?q?feat:=20Brand=20API=20=EC=B6=94=EA=B0=80?=
 =?UTF-8?q?=20=EB=B0=8F=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=A6=AC=ED=8C=A9?=
 =?UTF-8?q?=ED=86=A0=EB=A7=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

도메인 개선:
- Brands 일급 컬렉션 제거 (단순 조회는 List 사용)
- BrandFacade에서 직접 List<Brand> 처리

API 계층:
- BrandApiSpec: OpenAPI 문서화 (브랜드 목록 조회)
- BrandController: REST API 엔드포인트 구현
- BrandDto: API 요청/응답 DTO

E2E 테스트:
- BrandApiE2ETest: 브랜드 목록 조회 E2E 시나리오
- 삭제된 브랜드 필터링 검증
---
 .../application/brand/BrandFacade.java        |  2 +-
 .../java/com/loopers/domain/brand/Brands.java | 32 -------
 .../interfaces/api/brand/BrandApiSpec.java    | 21 +++++
 .../interfaces/api/brand/BrandController.java | 26 ++++++
 .../interfaces/api/brand/BrandDto.java        | 21 +++++
 .../application/brand/BrandFacadeTest.java    | 12 +--
 .../brand/BrandServiceIntegrationTest.java    |  5 +-
 .../com/loopers/domain/brand/BrandsTest.java  | 38 --------
 .../interfaces/api/brand/BrandApiE2ETest.java | 92 +++++++++++++++++++
 9 files changed, 169 insertions(+), 80 deletions(-)
 delete 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/interfaces/api/brand/BrandApiSpec.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandDto.java
 delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandsTest.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandApiE2ETest.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
index 4186c20fa..528901a29 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
@@ -15,7 +15,7 @@ public class BrandFacade {
 
   private final BrandService brandService;
 
-  public BrandResult viewBrand(Long brandId) {
+  public BrandResult retrieveBrand(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/domain/brand/Brands.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brands.java
deleted file mode 100644
index 747b85a19..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brands.java
+++ /dev/null
@@ -1,32 +0,0 @@
-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;
-import java.util.Objects;
-
-public class Brands {
-
-  private final List<Brand> values;
-
-  private Brands(List<Brand> values) {
-    this.values = values;
-  }
-
-  public static Brands from(List<Brand> brands) {
-    Objects.requireNonNull(brands, "brands는 null일 수 없습니다.");
-    return new Brands(List.copyOf(brands));
-  }
-
-  public Brand getBrandById(Long brandId) {
-    return values.stream()
-        .filter(brand -> brand.isSameId(brandId))
-        .findFirst()
-        .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다."));
-  }
-
-  public List<Brand> toList() {
-    return new ArrayList<>(values);
-  }
-}
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandApiSpec.java
new file mode 100644
index 000000000..4470bc2ee
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandApiSpec.java
@@ -0,0 +1,21 @@
+package com.loopers.interfaces.api.brand;
+
+import com.loopers.interfaces.api.ApiResponse;
+import com.loopers.interfaces.api.brand.BrandDto.BrandViewResponse;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.PathVariable;
+
+@Tag(name = "Brand API", description = "브랜드 API 입니다.")
+public interface BrandApiSpec {
+
+  @Operation(
+      summary = "브랜드 조회",
+      description = "브랜드 ID로 브랜드 정보를 조회합니다."
+  )
+  ApiResponse<BrandViewResponse> retrieveBrand(
+      @Parameter(description = "브랜드 ID", required = true)
+      @PathVariable Long brandId
+  );
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java
new file mode 100644
index 000000000..f07daeb2c
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java
@@ -0,0 +1,26 @@
+package com.loopers.interfaces.api.brand;
+
+import com.loopers.application.brand.BrandFacade;
+import com.loopers.application.brand.BrandResult;
+import com.loopers.interfaces.api.ApiResponse;
+import com.loopers.interfaces.api.brand.BrandDto.BrandViewResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/api/v1/brands")
+public class BrandController implements BrandApiSpec {
+
+  private final BrandFacade brandFacade;
+
+  @Override
+  @GetMapping("/{brandId}")
+  public ApiResponse<BrandViewResponse> retrieveBrand(@PathVariable Long brandId) {
+    BrandResult result = brandFacade.retrieveBrand(brandId);
+    return ApiResponse.success(BrandViewResponse.from(result));
+  }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandDto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandDto.java
new file mode 100644
index 000000000..c813129b8
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandDto.java
@@ -0,0 +1,21 @@
+package com.loopers.interfaces.api.brand;
+
+import com.loopers.application.brand.BrandResult;
+
+public class BrandDto {
+
+  public record BrandViewResponse(
+      Long brandId,
+      String name,
+      String description
+  ) {
+
+    public static BrandViewResponse from(BrandResult result) {
+      return new BrandViewResponse(
+          result.brandId(),
+          result.name(),
+          result.description()
+      );
+    }
+  }
+}
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 2fc4d710f..cbf778ed0 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
@@ -27,8 +27,8 @@ class BrandFacadeTest {
   private BrandService brandService;
 
   @Test
-  @DisplayName("브랜드 ID로 브랜드 조회 성공")
-  void viewBrand_success() {
+  @DisplayName("브랜드 ID로 브랜드를 조회한다")
+  void retrieveBrand_success() {
     // given
     Long brandId = 1L;
     Brand brand = Brand.of("나이키", "스포츠 브랜드");
@@ -36,7 +36,7 @@ void viewBrand_success() {
     given(brandService.getById(brandId)).willReturn(Optional.of(brand));
 
     // when
-    BrandResult result = brandFacade.viewBrand(brandId);
+    BrandResult result = brandFacade.retrieveBrand(brandId);
 
     // then
     assertThat(result)
@@ -45,15 +45,15 @@ void viewBrand_success() {
   }
 
   @Test
-  @DisplayName("존재하지 않는 브랜드 ID 조회 시 CoreException 발생")
-  void viewBrand_notFound() {
+  @DisplayName("존재하지 않는 브랜드 ID를 조회하면 CoreException이 발생한다")
+  void retrieveBrand_notFound() {
     // given
     Long brandId = 999L;
 
     given(brandService.getById(brandId)).willReturn(Optional.empty());
 
     // when & then
-    assertThatThrownBy(() -> brandFacade.viewBrand(brandId))
+    assertThatThrownBy(() -> brandFacade.retrieveBrand(brandId))
         .isInstanceOf(CoreException.class)
         .hasMessageContaining("브랜드를 찾을 수 없습니다.");
   }
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
index 762446305..a1d598beb 100644
--- 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
@@ -2,6 +2,7 @@
 
 import static org.assertj.core.api.Assertions.assertThat;
 
+import com.loopers.support.test.IntegrationTestSupport;
 import com.loopers.utils.DatabaseCleanUp;
 import java.util.List;
 import java.util.Optional;
@@ -11,10 +12,8 @@
 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 {
+class BrandServiceIntegrationTest extends IntegrationTestSupport {
 
   @Autowired
   private BrandRepository brandRepository;
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
deleted file mode 100644
index cbc6a529d..000000000
--- a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandsTest.java
+++ /dev/null
@@ -1,38 +0,0 @@
-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);
-  }
-}
diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandApiE2ETest.java
new file mode 100644
index 000000000..dd8fb853f
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandApiE2ETest.java
@@ -0,0 +1,92 @@
+package com.loopers.interfaces.api.brand;
+
+import com.loopers.domain.brand.Brand;
+import com.loopers.infrastructure.brand.BrandJpaRepository;
+import com.loopers.interfaces.api.ApiResponse;
+import com.loopers.interfaces.api.brand.BrandDto.BrandViewResponse;
+import com.loopers.utils.DatabaseCleanUp;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.core.ParameterizedTypeReference;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertAll;
+
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+class BrandApiE2ETest {
+
+  private static final ParameterizedTypeReference<ApiResponse<BrandViewResponse>> BRAND_RESPONSE_TYPE = new ParameterizedTypeReference<>() {
+  };
+
+  private final TestRestTemplate testRestTemplate;
+  private final BrandJpaRepository brandJpaRepository;
+  private final DatabaseCleanUp databaseCleanUp;
+
+  @Autowired
+  public BrandApiE2ETest(
+      TestRestTemplate testRestTemplate,
+      BrandJpaRepository brandJpaRepository,
+      DatabaseCleanUp databaseCleanUp)
+   {
+    this.testRestTemplate = testRestTemplate;
+    this.brandJpaRepository = brandJpaRepository;
+    this.databaseCleanUp = databaseCleanUp;
+  }
+
+  @AfterEach
+  void tearDown() {
+    databaseCleanUp.truncateAllTables();
+  }
+
+  @DisplayName("GET /api/v1/brands/{brandId}")
+  @Nested
+  class GetBrand {
+
+    @DisplayName("브랜드가 존재하면 브랜드 정보를 반환한다")
+    @Test
+    void returnsBrandInfo_whenBrandExists() {
+      Brand brand = Brand.of("나이키", "스포츠 브랜드");
+      Brand savedBrand = brandJpaRepository.save(brand);
+
+      ResponseEntity<ApiResponse<BrandViewResponse>> response =
+          testRestTemplate.exchange(
+              "/api/v1/brands/" + savedBrand.getId(),
+              HttpMethod.GET,
+              null,
+              BRAND_RESPONSE_TYPE
+          );
+
+      assertAll(
+          () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK),
+          () -> assertThat(response.getBody()).isNotNull(),
+          () -> assertThat(response.getBody().data())
+              .extracting("brandId", "name", "description")
+              .containsExactly(savedBrand.getId(), "나이키", "스포츠 브랜드")
+      );
+    }
+
+    @DisplayName("브랜드가 존재하지 않으면 404를 반환한다")
+    @Test
+    void returnsNotFound_whenBrandDoesNotExist() {
+      long nonExistentBrandId = 999L;
+
+      ResponseEntity<ApiResponse<BrandViewResponse>> response =
+          testRestTemplate.exchange(
+              "/api/v1/brands/" + nonExistentBrandId,
+              HttpMethod.GET,
+              null,
+              BRAND_RESPONSE_TYPE
+          );
+
+      assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
+    }
+  }
+}

From 3fb8e4c97a9d3d1e065c55338c8908de8dac593d Mon Sep 17 00:00:00 2001
From: green <green@mobidoc.us>
Date: Wed, 19 Nov 2025 20:54:49 +0900
Subject: [PATCH 03/14] =?UTF-8?q?feat:=20Product=20API=20=EC=B6=94?=
 =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=A6=AC?=
 =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

도메인 개선:
- Products 일급 컬렉션 제거
- Repository에 정렬 기능 추가 (최신순, 가격순)
- 페이징 처리 지원

API 계층:
- ProductApiSpec: 상품 목록/상세 조회 API 문서화
- ProductController: REST API 엔드포인트 구현
- ProductDto: 상품 응답 DTO (좋아요 상태 포함)
- ProductSortType: 정렬 타입 Enum (LATEST, PRICE)
- ProductSortTypeConverter: Query Parameter 변환

E2E 테스트:
- ProductApiE2ETest: 상품 목록/상세 조회 E2E 시나리오
- 정렬, 페이징, 좋아요 상태 검증
---
 .../application/product/ProductFacade.java    |   2 +-
 .../domain/product/ProductRepository.java     |   2 +
 .../domain/product/ProductService.java        |   6 +
 .../com/loopers/domain/product/Products.java  |  41 ---
 .../product/ProductJpaRepository.java         |   2 +
 .../product/ProductRepositoryImpl.java        |   5 +
 .../api/product/ProductApiSpec.java           |  48 +++
 .../api/product/ProductController.java        |  55 ++++
 .../interfaces/api/product/ProductDto.java    |  98 ++++++
 .../api/product/ProductSortType.java          |  35 ++
 .../api/product/ProductSortTypeConverter.java |  13 +
 .../product/ProductFacadeIntegrationTest.java |  23 +-
 .../loopers/domain/product/ProductsTest.java  |  55 ----
 .../api/product/ProductApiE2ETest.java        | 310 ++++++++++++++++++
 14 files changed, 586 insertions(+), 109 deletions(-)
 delete 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/interfaces/api/product/ProductApiSpec.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductDto.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductSortType.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductSortTypeConverter.java
 delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductsTest.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiE2ETest.java

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 eaf4c9846..b26246b5e 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
@@ -62,7 +62,7 @@ private Page<ProductDetail> getProductDetails(Page<Product> productPage, Map<Lon
     ));
   }
 
-  public ProductDetail viewProductDetail(Long productId, Long userId) {
+  public ProductDetail retrieveProductDetail(Long productId, Long userId) {
     Product product = productService.getById(productId)
         .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
     Brand brand = brandService.getById(product.getBrandId())
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 e9469c32d..6d6ee3422 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
@@ -16,6 +16,8 @@ public interface ProductRepository {
 
   List<Product> findAllByIdWithLock(List<Long> ids);
 
+  List<Product> findByIdIn(List<Long> ids);
+
   Product save(Product product);
 
   Product saveAndFlush(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
index f2a73564b..4874f92ba 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
@@ -26,6 +26,12 @@ public List<Product> findByIdsWithLock(List<Long> productIds) {
     return productRepository.findAllByIdWithLock(distinctIds);
   }
 
+  public List<Product> findByIdIn(List<Long> productIds) {
+    Objects.requireNonNull(productIds, "상품 ID 목록은 null일 수 없습니다");
+    List<Long> distinctIds = productIds.stream().distinct().toList();
+    return productRepository.findByIdIn(distinctIds);
+  }
+
   public Page<Product> findProducts(Long brandId, Pageable pageable) {
     return brandId != null
         ? productRepository.findByBrandId(brandId, 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
deleted file mode 100644
index 9cda7addc..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Products.java
+++ /dev/null
@@ -1,41 +0,0 @@
-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/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java
index 72321701e..ba123a877 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
@@ -20,6 +20,8 @@ public interface ProductJpaRepository extends JpaRepository<Product, Long> {
   @Query("SELECT p FROM Product p WHERE p.id IN :ids ORDER BY p.id ASC")
   List<Product> findAllByIdWithLock(@Param("ids") List<Long> ids);
 
+  List<Product> findByIdIn(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);
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 c188f163e..3c12245ee 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
@@ -36,6 +36,11 @@ public List<Product> findAllByIdWithLock(List<Long> ids) {
     return jpaRepository.findAllByIdWithLock(ids);
   }
 
+  @Override
+  public List<Product> findByIdIn(List<Long> ids) {
+    return jpaRepository.findByIdIn(ids);
+  }
+
   @Override
   public Product save(Product product) {
     return jpaRepository.save(product);
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductApiSpec.java
new file mode 100644
index 000000000..20f6dd97f
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductApiSpec.java
@@ -0,0 +1,48 @@
+package com.loopers.interfaces.api.product;
+
+import com.loopers.interfaces.api.ApiResponse;
+import com.loopers.interfaces.api.product.ProductDto.ProductListResponse;
+import com.loopers.interfaces.api.product.ProductDto.ProductResponse;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestParam;
+
+@Tag(name = "Product API", description = "상품 API 입니다.")
+public interface ProductApiSpec {
+
+  @Operation(
+      summary = "상품 목록 조회",
+      description = "상품 목록을 조회합니다. 브랜드 필터링, 정렬, 페이지네이션을 지원합니다."
+  )
+  ApiResponse<ProductListResponse> searchProductDetails(
+      @Parameter(description = "사용자 ID (선택, 비회원 가능)", required = false)
+      @RequestHeader(value = "X-USER-ID", required = false) Long userId,
+
+      @Parameter(description = "브랜드 ID (선택, 특정 브랜드 필터링)", required = false)
+      @RequestParam(required = false) Long brandId,
+
+      @Parameter(description = "정렬 기준 (latest, price_asc, likes_desc)", required = false)
+      @RequestParam(defaultValue = "latest") ProductSortType sort,
+
+      @Parameter(description = "페이지 번호 (0부터 시작)", required = false)
+      @RequestParam(defaultValue = "0") int page,
+
+      @Parameter(description = "페이지당 상품 수", required = false)
+      @RequestParam(defaultValue = "20") int size
+  );
+
+  @Operation(
+      summary = "상품 상세 조회",
+      description = "상품 ID로 상품 상세 정보를 조회합니다."
+  )
+  ApiResponse<ProductResponse> retrieveProductDetail(
+      @Parameter(description = "사용자 ID (선택, 비회원 가능)", required = false)
+      @RequestHeader(value = "X-USER-ID", required = false) Long userId,
+
+      @Parameter(description = "상품 ID", required = true)
+      @PathVariable Long productId
+  );
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java
new file mode 100644
index 000000000..6a83fbb25
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java
@@ -0,0 +1,55 @@
+package com.loopers.interfaces.api.product;
+
+import com.loopers.application.product.ProductDetail;
+import com.loopers.application.product.ProductFacade;
+import com.loopers.interfaces.api.ApiResponse;
+import com.loopers.interfaces.api.product.ProductDto.ProductListResponse;
+import com.loopers.interfaces.api.product.ProductDto.ProductResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/api/v1/products")
+public class ProductController implements ProductApiSpec {
+
+  private final ProductFacade productFacade;
+
+  @Override
+  @GetMapping
+  public ApiResponse<ProductListResponse> searchProductDetails(
+      @RequestHeader(value = "X-USER-ID", required = false) Long userId,
+      @RequestParam(required = false) Long brandId,
+      @RequestParam(defaultValue = "lastest") ProductSortType sort,
+      @RequestParam(defaultValue = "0") int page,
+      @RequestParam(defaultValue = "20") int size
+  ) {
+    Pageable pageable = PageRequest.of(page, size, sort.toSort());
+
+    Page<ProductDetail> productPage = productFacade.searchProductDetails(brandId, userId,
+        pageable);
+    ProductListResponse response = ProductListResponse.from(productPage);
+
+    return ApiResponse.success(response);
+  }
+
+  @Override
+  @GetMapping("/{productId}")
+  public ApiResponse<ProductResponse> retrieveProductDetail(
+      @RequestHeader(value = "X-USER-ID", required = false) Long userId,
+      @PathVariable Long productId
+  ) {
+    ProductDetail detail = productFacade.retrieveProductDetail(productId, userId);
+    ProductResponse response = ProductResponse.from(detail);
+
+    return ApiResponse.success(response);
+  }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductDto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductDto.java
new file mode 100644
index 000000000..26c3817bd
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductDto.java
@@ -0,0 +1,98 @@
+package com.loopers.interfaces.api.product;
+
+import com.loopers.application.product.ProductDetail;
+import java.util.List;
+import org.springframework.data.domain.Page;
+
+public class ProductDto {
+
+  public record ProductListResponse(
+      List<ProductListItemResponse> products,
+      PageInfo pageInfo
+  ) {
+
+    public static ProductListResponse from(Page<ProductDetail> page) {
+      List<ProductListItemResponse> products = page.getContent().stream()
+          .map(ProductListItemResponse::from)
+          .toList();
+
+      PageInfo pageInfo = PageInfo.from(page);
+
+      return new ProductListResponse(products, pageInfo);
+    }
+  }
+
+  public record ProductListItemResponse(
+      Long productId,
+      String name,
+      Long price,
+      BrandInfo brand,
+      Long likeCount,
+      boolean isLiked
+  ) {
+
+    public static ProductListItemResponse from(ProductDetail detail) {
+      return new ProductListItemResponse(
+          detail.productId(),
+          detail.productName(),
+          detail.price(),
+          new BrandInfo(detail.brandId(), detail.brandName()),
+          detail.likeCount(),
+          detail.liked()
+      );
+    }
+  }
+
+  public record ProductResponse(
+      Long productId,
+      String name,
+      Long price,
+      String description,
+      Long stock,
+      BrandInfo brand,
+      Long likeCount,
+      boolean isLiked
+  ) {
+
+    public static ProductResponse from(ProductDetail detail) {
+      return new ProductResponse(
+          detail.productId(),
+          detail.productName(),
+          detail.price(),
+          detail.description(),
+          detail.stock(),
+          new BrandInfo(detail.brandId(), detail.brandName()),
+          detail.likeCount(),
+          detail.liked()
+      );
+    }
+  }
+
+  public record BrandInfo(
+      Long brandId,
+      String name
+  ) {
+
+  }
+
+  public record PageInfo(
+      long totalElements,
+      int currentPage,
+      int totalPages,
+      int pageSize,
+      String sort
+  ) {
+
+    public static PageInfo from(Page<?> page) {
+      String sortInfo = page.getSort().toString();
+
+      return new PageInfo(
+          page.getTotalElements(),
+          page.getNumber(),
+          page.getTotalPages(),
+          page.getSize(),
+          sortInfo
+      );
+    }
+  }
+}
\ No newline at end of file
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductSortType.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductSortType.java
new file mode 100644
index 000000000..226ffec0d
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductSortType.java
@@ -0,0 +1,35 @@
+package com.loopers.interfaces.api.product;
+
+import java.util.Arrays;
+import org.springframework.data.domain.Sort;
+
+public enum ProductSortType {
+  LATEST("latest", Sort.Direction.DESC, "id"),
+  PRICE_ASC("price_asc", Sort.Direction.ASC, "price.value"),
+  LIKES_DESC("likes_desc", Sort.Direction.DESC, "likeCount");
+
+  private final String parameterValue;
+  private final Sort.Direction direction;
+  private final String property;
+
+  ProductSortType(String parameterValue, Sort.Direction direction, String property) {
+    this.parameterValue = parameterValue;
+    this.direction = direction;
+    this.property = property;
+  }
+
+  public Sort toSort() {
+    return Sort.by(direction, property);
+  }
+
+  public static ProductSortType from(String parameterValue) {
+    if (parameterValue == null) {
+      return LATEST;
+    }
+
+    return Arrays.stream(values())
+        .filter(type -> type.parameterValue.equalsIgnoreCase(parameterValue))
+        .findFirst()
+        .orElse(LATEST);
+  }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductSortTypeConverter.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductSortTypeConverter.java
new file mode 100644
index 000000000..5aa643874
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductSortTypeConverter.java
@@ -0,0 +1,13 @@
+package com.loopers.interfaces.api.product;
+
+import org.springframework.core.convert.converter.Converter;
+import org.springframework.stereotype.Component;
+
+@Component
+public class ProductSortTypeConverter implements Converter<String, ProductSortType> {
+
+  @Override
+  public ProductSortType convert(String source) {
+    return ProductSortType.from(source);
+  }
+}
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 8a0c744ec..652e291eb 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.support.test.IntegrationTestSupport;
 import com.loopers.application.like.LikeFacade;
 import com.loopers.domain.brand.Brand;
 import com.loopers.domain.brand.BrandRepository;
@@ -17,7 +18,6 @@
 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;
@@ -30,8 +30,7 @@
 import static org.assertj.core.api.Assertions.tuple;
 import static org.junit.jupiter.api.Assertions.assertAll;
 
-@SpringBootTest
-class ProductFacadeIntegrationTest {
+class ProductFacadeIntegrationTest extends IntegrationTestSupport {
 
   private static final LocalDateTime LIKED_AT_2025_10_30 = LocalDateTime.of(2025, 10, 30, 0, 0, 0);
 
@@ -246,7 +245,7 @@ void showsCorrectLikeCount_whenMultipleUsersLike() {
       );
 
       for (long userId = 1L; userId <= 3L; userId++) {
-        likeFacade.likeProduct(userId, product.getId());
+        likeFacade.registerProductLike(userId, product.getId());
       }
 
       Pageable pageable = PageRequest.of(0, 20);
@@ -324,7 +323,7 @@ void returnsProductWithBrand() {
           Product.of("상품1", Money.of(10000L), "상세설명", Stock.of(100L), brand.getId())
       );
 
-      ProductDetail result = productFacade.viewProductDetail(product.getId(), null);
+      ProductDetail result = productFacade.retrieveProductDetail(product.getId(), null);
 
       assertThat(result)
           .extracting("productName", "price", "description", "stock", "brandName")
@@ -344,7 +343,7 @@ void returnsIsLikedTrue_whenUserLikesProduct() {
           ProductLike.of(userId, product.getId(), LIKED_AT_2025_10_30)
       );
 
-      ProductDetail result = productFacade.viewProductDetail(product.getId(), userId);
+      ProductDetail result = productFacade.retrieveProductDetail(product.getId(), userId);
 
       assertThat(result.liked()).isTrue();
     }
@@ -359,7 +358,7 @@ void returnsIsLikedFalse_whenUserDoesNotLikeProduct() {
 
       Long userId = 1L;
 
-      ProductDetail result = productFacade.viewProductDetail(product.getId(), userId);
+      ProductDetail result = productFacade.retrieveProductDetail(product.getId(), userId);
 
       assertThat(result.liked()).isFalse();
     }
@@ -370,7 +369,7 @@ void throwsNotFoundException_whenProductDoesNotExist() {
       Long nonExistentProductId = 999L;
 
       assertThatThrownBy(() ->
-          productFacade.viewProductDetail(nonExistentProductId, null)
+          productFacade.retrieveProductDetail(nonExistentProductId, null)
       )
           .isInstanceOf(CoreException.class)
           .extracting("errorType", "message")
@@ -387,7 +386,7 @@ void throwsNotFoundException_whenBrandDoesNotExist() {
       Long productId = product.getId();
 
       assertThatThrownBy(() ->
-          productFacade.viewProductDetail(productId, null)
+          productFacade.retrieveProductDetail(productId, null)
       )
           .isInstanceOf(CoreException.class)
           .extracting("errorType", "message")
@@ -441,15 +440,15 @@ void sortsByLikesDescending() {
       );
 
       for (long userId = 1L; userId <= 5L; userId++) {
-        likeFacade.likeProduct(userId, product1.getId());
+        likeFacade.registerProductLike(userId, product1.getId());
       }
 
       for (long userId = 1L; userId <= 10L; userId++) {
-        likeFacade.likeProduct(userId, product2.getId());
+        likeFacade.registerProductLike(userId, product2.getId());
       }
 
       for (long userId = 1L; userId <= 2L; userId++) {
-        likeFacade.likeProduct(userId, product3.getId());
+        likeFacade.registerProductLike(userId, product3.getId());
       }
 
       Pageable pageable = PageRequest.of(0, 20, Sort.by("likeCount").descending());
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
deleted file mode 100644
index 28568d400..000000000
--- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductsTest.java
+++ /dev/null
@@ -1,55 +0,0 @@
-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);
-  }
-}
diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiE2ETest.java
new file mode 100644
index 000000000..e5adae309
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiE2ETest.java
@@ -0,0 +1,310 @@
+package com.loopers.interfaces.api.product;
+
+import com.loopers.domain.brand.Brand;
+import com.loopers.domain.money.Money;
+import com.loopers.domain.product.Product;
+import com.loopers.domain.productlike.ProductLike;
+import com.loopers.domain.stock.Stock;
+import com.loopers.domain.user.Gender;
+import com.loopers.domain.user.User;
+import com.loopers.infrastructure.brand.BrandJpaRepository;
+import com.loopers.infrastructure.product.ProductJpaRepository;
+import com.loopers.infrastructure.productlike.ProductLikeJpaRepository;
+import com.loopers.infrastructure.user.UserJpaRepository;
+import com.loopers.interfaces.api.ApiResponse;
+import com.loopers.interfaces.api.product.ProductDto.ProductListItemResponse;
+import com.loopers.interfaces.api.product.ProductDto.ProductListResponse;
+import com.loopers.interfaces.api.product.ProductDto.ProductResponse;
+import com.loopers.utils.DatabaseCleanUp;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.List;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.core.ParameterizedTypeReference;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.util.UriComponentsBuilder;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertAll;
+
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+class ProductApiE2ETest {
+
+  private static final String BASE_URL = "/api/v1/products";
+  private static final LocalDate FIXED_DATE = LocalDate.of(2024, 1, 1);
+  private static final LocalDateTime FIXED_DATE_TIME = LocalDateTime.of(2024, 1, 1, 0, 0);
+  private static final ParameterizedTypeReference<ApiResponse<ProductListResponse>> PRODUCT_LIST_RESPONSE_TYPE =
+      new ParameterizedTypeReference<>() {
+      };
+  private static final ParameterizedTypeReference<ApiResponse<ProductResponse>> PRODUCT_RESPONSE_TYPE =
+      new ParameterizedTypeReference<>() {
+      };
+
+  private final TestRestTemplate testRestTemplate;
+  private final ProductJpaRepository productJpaRepository;
+  private final BrandJpaRepository brandJpaRepository;
+  private final UserJpaRepository userJpaRepository;
+  private final ProductLikeJpaRepository productLikeJpaRepository;
+  private final DatabaseCleanUp databaseCleanUp;
+
+  @Autowired
+  ProductApiE2ETest(
+      TestRestTemplate testRestTemplate,
+      ProductJpaRepository productJpaRepository,
+      BrandJpaRepository brandJpaRepository,
+      UserJpaRepository userJpaRepository,
+      ProductLikeJpaRepository productLikeJpaRepository,
+      DatabaseCleanUp databaseCleanUp
+  ) {
+    this.testRestTemplate = testRestTemplate;
+    this.productJpaRepository = productJpaRepository;
+    this.brandJpaRepository = brandJpaRepository;
+    this.userJpaRepository = userJpaRepository;
+    this.productLikeJpaRepository = productLikeJpaRepository;
+    this.databaseCleanUp = databaseCleanUp;
+  }
+
+  @AfterEach
+  void tearDown() {
+    databaseCleanUp.truncateAllTables();
+  }
+
+  @Nested
+  @DisplayName("GET /api/v1/products")
+  class GetProducts {
+
+    @Test
+    @DisplayName("기본 정렬(latest)로 상품 목록과 페이징 정보를 반환한다")
+    void returnsProductsSortedByLatest_whenSortIsDefault() {
+      Brand brand = saveBrand("나이키");
+      Product first = saveProduct("슬리퍼", 12000L, 10L, brand.getId(), 0);
+      Product second = saveProduct("샌들", 15000L, 5L, brand.getId(), 0);
+
+      ResponseEntity<ApiResponse<ProductListResponse>> response =
+          testRestTemplate.exchange(
+              UriComponentsBuilder.fromUriString(BASE_URL)
+                  .queryParam("page", 0)
+                  .queryParam("size", 20)
+                  .build()
+                  .toUri(),
+              HttpMethod.GET,
+              HttpEntity.EMPTY,
+              PRODUCT_LIST_RESPONSE_TYPE
+          );
+
+      ApiResponse<ProductListResponse> responseBody = response.getBody();
+
+      assertAll(
+          () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK),
+          () -> assertThat(responseBody).isNotNull(),
+          () -> {
+            ProductListResponse data = responseBody.data();
+            assertThat(data).isNotNull();
+            List<ProductListItemResponse> products = data.products();
+            assertThat(products)
+                .hasSize(2)
+                .element(0).extracting(ProductListItemResponse::productId)
+                .isEqualTo(second.getId());
+            assertThat(products)
+                .element(1).extracting(ProductListItemResponse::productId)
+                .isEqualTo(first.getId());
+          }
+      );
+    }
+
+    @Test
+    @DisplayName("브랜드 필터 + sort=price_asc 조합으로 좋아요 상태를 포함해 반환한다")
+    void returnsFilteredProductsWithLikeStatus_whenBrandAndPriceSortProvided() {
+      Brand nike = saveBrand("나이키");
+      Brand adidas = saveBrand("아디다스");
+      Product nikeProduct = saveProduct("에어맥스", 200000L, 50L, nike.getId(), 5);
+      saveProduct("울트라부스트", 150000L, 20L, adidas.getId(), 0);
+      User user = saveUser("tester1");
+      productLikeJpaRepository.save(ProductLike.of(user.getId(), nikeProduct.getId(), FIXED_DATE_TIME));
+
+      HttpHeaders headers = new HttpHeaders();
+      headers.set("X-USER-ID", String.valueOf(user.getId()));
+
+      ResponseEntity<ApiResponse<ProductListResponse>> response =
+          testRestTemplate.exchange(
+              UriComponentsBuilder.fromUriString(BASE_URL)
+                  .queryParam("brandId", nike.getId())
+                  .queryParam("sort", "price_asc")
+                  .queryParam("page", 0)
+                  .queryParam("size", 10)
+                  .build()
+                  .toUri(),
+              HttpMethod.GET,
+              new HttpEntity<>(null, headers),
+              PRODUCT_LIST_RESPONSE_TYPE
+          );
+
+      ApiResponse<ProductListResponse> responseBody = response.getBody();
+
+      assertAll(
+          () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK),
+          () -> assertThat(responseBody).isNotNull(),
+          () -> {
+            ProductListResponse data = responseBody.data();
+            assertThat(data).isNotNull();
+            List<ProductListItemResponse> products = data.products();
+            assertThat(products)
+                .hasSize(1)
+                .element(0)
+                .extracting("productId", "likeCount", "isLiked")
+                .containsExactly(nikeProduct.getId(), 5L, true);
+          }
+      );
+    }
+
+    @Test
+    @DisplayName("sort=price_asc이면 가격 오름차순으로 정렬한다")
+    void returnsProductsSortedByPriceAscending_whenSortIsPriceAsc() {
+      Brand brand = saveBrand("가격브랜드");
+      Product cheap = saveProduct("슬림핏", 10000L, 10L, brand.getId(), 0);
+      Product pricey = saveProduct("프리미엄", 50000L, 8L, brand.getId(), 0);
+
+      ResponseEntity<ApiResponse<ProductListResponse>> response =
+          testRestTemplate.exchange(
+              UriComponentsBuilder.fromUriString(BASE_URL)
+                  .queryParam("sort", "price_asc")
+                  .queryParam("size", 10)
+                  .build()
+                  .toUri(),
+              HttpMethod.GET,
+              HttpEntity.EMPTY,
+              PRODUCT_LIST_RESPONSE_TYPE
+          );
+
+      ApiResponse<ProductListResponse> responseBody = response.getBody();
+      assertAll(
+          () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK),
+          () -> assertThat(responseBody).isNotNull(),
+          () -> {
+            ProductListResponse data = responseBody.data();
+            assertThat(data).isNotNull();
+            List<ProductListItemResponse> products = data.products();
+            assertThat(products).extracting(ProductListItemResponse::productId)
+                .containsExactly(cheap.getId(), pricey.getId());
+          }
+      );
+    }
+
+    @Test
+    @DisplayName("sort=likes_desc이면 좋아요 내림차순으로 정렬한다")
+    void returnsProductsSortedByLikesDescending_whenSortIsLikesDesc() {
+      Brand brand = saveBrand("좋아요브랜드");
+      Product popular = saveProduct("인기상품", 20000L, 10L, brand.getId(), 10);
+      Product normal = saveProduct("일반상품", 18000L, 10L, brand.getId(), 2);
+
+      ResponseEntity<ApiResponse<ProductListResponse>> response =
+          testRestTemplate.exchange(
+              UriComponentsBuilder.fromUriString(BASE_URL)
+                  .queryParam("sort", "likes_desc")
+                  .queryParam("size", 10)
+                  .build()
+                  .toUri(),
+              HttpMethod.GET,
+              HttpEntity.EMPTY,
+              PRODUCT_LIST_RESPONSE_TYPE
+          );
+
+      ApiResponse<ProductListResponse> responseBody = response.getBody();
+      assertAll(
+          () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK),
+          () -> assertThat(responseBody).isNotNull(),
+          () -> {
+            ProductListResponse data = responseBody.data();
+            assertThat(data).isNotNull();
+            List<ProductListItemResponse> products = data.products();
+            assertThat(products).extracting(ProductListItemResponse::productId)
+                .containsExactly(popular.getId(), normal.getId());
+          }
+      );
+    }
+  }
+
+  @Nested
+  @DisplayName("GET /api/v1/products/{productId}")
+  class GetProductDetail {
+
+    @Test
+    @DisplayName("존재하는 상품이면 상세 정보를 반환한다")
+    void returnsProductDetail_whenProductExists() {
+      Brand brand = saveBrand("루프");
+      Product product = saveProduct("루프 티셔츠", 35000L, 30L, brand.getId(), 2);
+      User user = saveUser("tester2");
+      productLikeJpaRepository.save(ProductLike.of(user.getId(), product.getId(), FIXED_DATE_TIME));
+
+      HttpHeaders headers = new HttpHeaders();
+      headers.set("X-USER-ID", String.valueOf(user.getId()));
+
+      ResponseEntity<ApiResponse<ProductResponse>> response =
+          testRestTemplate.exchange(
+              BASE_URL + "/" + product.getId(),
+              HttpMethod.GET,
+              new HttpEntity<>(null, headers),
+              PRODUCT_RESPONSE_TYPE
+          );
+
+      assertAll(
+          () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK),
+          () -> {
+            ApiResponse<ProductResponse> responseBody = response.getBody();
+            assertThat(responseBody).isNotNull();
+            ProductResponse productResponse = responseBody.data();
+            assertThat(productResponse).isNotNull();
+            assertThat(productResponse)
+                .extracting(
+                    ProductResponse::productId,
+                    ProductResponse::name,
+                    productBrand -> productBrand.brand().brandId(),
+                    ProductResponse::isLiked,
+                    ProductResponse::likeCount
+                )
+                .containsExactly(product.getId(), "루프 티셔츠", brand.getId(), true, 2L);
+          }
+      );
+    }
+
+    @Test
+    @DisplayName("상품이 존재하지 않으면 404를 반환한다")
+    void returnsNotFound_whenProductDoesNotExist() {
+      ResponseEntity<ApiResponse<ProductResponse>> response =
+          testRestTemplate.exchange(
+              BASE_URL + "/999999",
+              HttpMethod.GET,
+              HttpEntity.EMPTY,
+              PRODUCT_RESPONSE_TYPE
+          );
+
+      assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
+    }
+  }
+
+  private Brand saveBrand(String name) {
+    return brandJpaRepository.save(Brand.of(name, name + " 설명"));
+  }
+
+  private Product saveProduct(String name, long price, long stock, Long brandId, long likeCount) {
+    Product product = Product.of(name, Money.of(price), name + " 상세", Stock.of(stock), brandId);
+    if (likeCount > 0) {
+      product.increaseLikeCount((int) likeCount);
+    }
+    return productJpaRepository.save(product);
+  }
+
+  private User saveUser(String loginId) {
+    return userJpaRepository.save(User.of(loginId, loginId + "@loopers.dev", FIXED_DATE.minusYears(20), Gender.MALE, FIXED_DATE));
+  }
+}

From 080857e6fb0e1b3fbf6cd989a342439616d98012 Mon Sep 17 00:00:00 2001
From: green <green@mobidoc.us>
Date: Wed, 19 Nov 2025 20:55:07 +0900
Subject: [PATCH 04/14] =?UTF-8?q?feat:=20Like=20API=20=EC=B6=94=EA=B0=80?=
 =?UTF-8?q?=20=EB=B0=8F=20Query=20Repository=20=EB=B6=84=EB=A6=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

도메인 개선:
- LikeQueryRepository: 조회 전용 Repository 분리 (CQRS 패턴)
- LikedProduct VO: 좋아요한 상품 정보 표현 (Product + 좋아요 시각)
- LikeSortType Enum: 정렬 타입 (최신순, 좋아요순)
- ProductLikes 일급 컬렉션 제거

조회 최적화:
- Join Fetch로 N+1 문제 해결
- 정렬 기능 Repository 계층에서 처리
- 페이징 지원

API 계층:
- LikeApiSpec: 좋아요 토글/목록 조회 API 문서화
- LikeController: REST API 엔드포인트 구현
- LikeDto: 좋아요 응답 DTO
- LikeSortTypeConverter: Query Parameter 변환

E2E 테스트:
- LikeApiE2ETest: 좋아요 토글/목록 조회 E2E 시나리오
- 정렬, 페이징 검증
---
 .../loopers/application/like/LikeFacade.java  |  13 +-
 .../productlike/LikeQueryRepository.java      |  15 +
 .../domain/productlike/LikeSortType.java      |  31 ++
 .../domain/productlike/LikedProduct.java      |  33 ++
 .../productlike/ProductLikeService.java       |  32 +-
 .../domain/productlike/ProductLikes.java      |  46 ---
 .../productlike/LikeQueryJpaRepository.java   |  85 +++++
 .../productlike/LikeQueryRepositoryImpl.java  |  35 ++
 .../productlike/ProductLikeJpaRepository.java |   6 +
 .../ProductLikeRepositoryImpl.java            |   3 +-
 .../interfaces/api/like/LikeApiSpec.java      |  44 +++
 .../interfaces/api/like/LikeController.java   |  62 ++++
 .../loopers/interfaces/api/like/LikeDto.java  |  69 ++++
 .../api/like/LikeSortTypeConverter.java       |  14 +
 .../like/LikeFacadeIntegrationTest.java       | 204 +++++++++--
 .../domain/productlike/LikedProductTest.java  |  87 +++++
 .../ProductLikeServiceIntegrationTest.java    | 190 ++++++++++
 .../domain/productlike/ProductLikeTest.java   | 114 ------
 .../domain/productlike/ProductLikesTest.java  | 193 -----------
 .../interfaces/api/like/LikeApiE2ETest.java   | 327 ++++++++++++++++++
 20 files changed, 1222 insertions(+), 381 deletions(-)
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/productlike/LikeQueryRepository.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/productlike/LikeSortType.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/productlike/LikedProduct.java
 delete 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/LikeQueryJpaRepository.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/productlike/LikeQueryRepositoryImpl.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeApiSpec.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeController.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeDto.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeSortTypeConverter.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/productlike/LikedProductTest.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/productlike/ProductLikeServiceIntegrationTest.java
 delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/productlike/ProductLikeTest.java
 delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/productlike/ProductLikesTest.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiE2ETest.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 9d2244263..05a19a0a0 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,11 +1,15 @@
 package com.loopers.application.like;
 
 import com.loopers.domain.product.ProductService;
+import com.loopers.domain.productlike.LikeSortType;
+import com.loopers.domain.productlike.LikedProduct;
 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.data.domain.Page;
+import org.springframework.data.domain.Pageable;
 import org.springframework.stereotype.Component;
 import org.springframework.transaction.annotation.Transactional;
 
@@ -17,7 +21,7 @@ public class LikeFacade {
   private final ProductService productService;
 
   @Transactional
-  public void likeProduct(Long userId, Long productId) {
+  public void registerProductLike(Long userId, Long productId) {
     productService.getById(productId)
         .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
     try {
@@ -29,7 +33,7 @@ public void likeProduct(Long userId, Long productId) {
   }
 
   @Transactional
-  public void unlikeProduct(Long userId, Long productId) {
+  public void cancelProductLike(Long userId, Long productId) {
     productService.getById(productId)
         .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
 
@@ -39,4 +43,9 @@ public void unlikeProduct(Long userId, Long productId) {
       productService.decreaseLikeCount(productId);
     }
   }
+
+  public Page<LikedProduct> retrieveLikedProducts(Long userId, LikeSortType sortType,
+      Pageable pageable) {
+    return productLikeService.findLikedProducts(userId, sortType, pageable);
+  }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/productlike/LikeQueryRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/productlike/LikeQueryRepository.java
new file mode 100644
index 000000000..72af878ae
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/productlike/LikeQueryRepository.java
@@ -0,0 +1,15 @@
+package com.loopers.domain.productlike;
+
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+
+public interface LikeQueryRepository {
+
+  Page<LikedProduct> findByUserIdOrderByLatest(Long userId, Pageable pageable);
+
+  Page<LikedProduct> findByUserIdOrderByProductName(Long userId, Pageable pageable);
+
+  Page<LikedProduct> findByUserIdOrderByPriceAsc(Long userId, Pageable pageable);
+
+  Page<LikedProduct> findByUserIdOrderByPriceDesc(Long userId, Pageable pageable);
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/productlike/LikeSortType.java b/apps/commerce-api/src/main/java/com/loopers/domain/productlike/LikeSortType.java
new file mode 100644
index 000000000..cc49af40f
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/productlike/LikeSortType.java
@@ -0,0 +1,31 @@
+package com.loopers.domain.productlike;
+
+import java.util.Arrays;
+
+public enum LikeSortType {
+  LATEST("latest"),
+  PRODUCT_NAME("product_name"),
+  PRICE_ASC("price_asc"),
+  PRICE_DESC("price_desc");
+
+  private final String parameterValue;
+
+  LikeSortType(String parameterValue) {
+    this.parameterValue = parameterValue;
+  }
+
+  public String parameterValue() {
+    return parameterValue;
+  }
+
+  public static LikeSortType from(String parameterValue) {
+    if (parameterValue == null) {
+      return LATEST;
+    }
+
+    return Arrays.stream(values())
+        .filter(type -> type.parameterValue.equalsIgnoreCase(parameterValue))
+        .findFirst()
+        .orElse(LATEST);
+  }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/productlike/LikedProduct.java b/apps/commerce-api/src/main/java/com/loopers/domain/productlike/LikedProduct.java
new file mode 100644
index 000000000..47df89303
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/productlike/LikedProduct.java
@@ -0,0 +1,33 @@
+package com.loopers.domain.productlike;
+
+import com.loopers.domain.brand.Brand;
+import com.loopers.domain.product.Product;
+import java.time.LocalDateTime;
+import java.util.Objects;
+
+public record LikedProduct(
+    Long productId,
+    String productName,
+    Long price,
+    Long likeCount,
+    Long brandId,
+    String brandName,
+    LocalDateTime likedAt
+) {
+
+  public static LikedProduct of(ProductLike productLike, Product product, Brand brand) {
+    Objects.requireNonNull(productLike, "ProductLike는 null일 수 없습니다.");
+    Objects.requireNonNull(product, "Product는 null일 수 없습니다.");
+    Objects.requireNonNull(brand, "Brand는 null일 수 없습니다.");
+
+    return new LikedProduct(
+        product.getId(),
+        product.getName(),
+        product.getPriceValue(),
+        product.getLikeCount(),
+        brand.getId(),
+        brand.getName(),
+        productLike.getLikedAt()
+    );
+  }
+}
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 cfbc244d0..2859035f0 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
@@ -4,7 +4,10 @@
 import java.time.LocalDateTime;
 import java.util.List;
 import java.util.Map;
+import java.util.stream.Collectors;
 import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
 import org.springframework.stereotype.Component;
 import org.springframework.transaction.annotation.Propagation;
 import org.springframework.transaction.annotation.Transactional;
@@ -14,6 +17,7 @@
 public class ProductLikeService {
 
   private final ProductLikeRepository productLikeRepository;
+  private final LikeQueryRepository likeQueryRepository;
   private final Clock clock;
 
   public boolean isLiked(Long userId, Long productId) {
@@ -30,20 +34,40 @@ public Map<Long, Boolean> findLikeStatusByProductId(Long userId, List<Long> prod
     }
 
     List<ProductLike> likes = productLikeRepository.findByUserIdAndProductIdIn(userId, productIds);
-    ProductLikes productLikes = ProductLikes.from(likes);
+    if (likes.isEmpty()) {
+      return Map.of();
+    }
+
+    Map<Long, Boolean> likedByProductId = likes.stream()
+        .collect(Collectors.toMap(ProductLike::getProductId, like -> true));
 
-    return productLikes.toLikeStatusByProductId(productIds);
+    // allProductIds에 중복이 있으면 IllegalStateException 발생
+    return productIds.stream()
+        .collect(Collectors.toMap(
+            productId -> productId,
+            productId -> likedByProductId.getOrDefault(productId, false)
+        ));
   }
 
 
   @Transactional(propagation = Propagation.REQUIRES_NEW)
   public void createLike(Long userId, Long productId) {
-      ProductLike like = ProductLike.of(userId, productId, LocalDateTime.now(clock));
-      productLikeRepository.saveAndFlush(like);
+    ProductLike like = ProductLike.of(userId, productId, LocalDateTime.now(clock));
+    productLikeRepository.saveAndFlush(like);
   }
 
   @Transactional
   public int deleteLike(Long userId, Long productId) {
     return productLikeRepository.deleteByUserIdAndProductId(userId, productId);
   }
+
+  public Page<LikedProduct> findLikedProducts(Long userId, LikeSortType sortType,
+      Pageable pageable) {
+    return switch (sortType) {
+      case LATEST -> likeQueryRepository.findByUserIdOrderByLatest(userId, pageable);
+      case PRODUCT_NAME -> likeQueryRepository.findByUserIdOrderByProductName(userId, pageable);
+      case PRICE_ASC -> likeQueryRepository.findByUserIdOrderByPriceAsc(userId, pageable);
+      case PRICE_DESC -> likeQueryRepository.findByUserIdOrderByPriceDesc(userId, pageable);
+    };
+  }
 }
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
deleted file mode 100644
index a30a48e83..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/domain/productlike/ProductLikes.java
+++ /dev/null
@@ -1,46 +0,0 @@
-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());
-  }
-
-  // 중복 productId가 있으면 IllegalStateException 발생 (데이터 무결성 오류)
-  public Map<Long, Boolean> toLikedByProductId() {
-    return values.stream()
-        .collect(Collectors.toMap(ProductLike::getProductId, like -> true));
-  }
-
-  public Map<Long, Boolean> toLikeStatusByProductId(List<Long> allProductIds) {
-    if (allProductIds == null || allProductIds.isEmpty()) {
-      return Map.of();
-    }
-
-    Map<Long, Boolean> likedByProductId = toLikedByProductId();
-
-    // allProductIds에 중복이 있으면 IllegalStateException 발생
-    return allProductIds.stream()
-        .collect(Collectors.toMap(
-            productId -> productId,
-            productId -> likedByProductId.getOrDefault(productId, false)
-        ));
-  }
-}
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/productlike/LikeQueryJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/productlike/LikeQueryJpaRepository.java
new file mode 100644
index 000000000..ccb0449e7
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/productlike/LikeQueryJpaRepository.java
@@ -0,0 +1,85 @@
+package com.loopers.infrastructure.productlike;
+
+import com.loopers.domain.productlike.LikedProduct;
+import com.loopers.domain.productlike.ProductLike;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+public interface LikeQueryJpaRepository extends JpaRepository<ProductLike, Long> {
+
+  @Query("""
+      SELECT new com.loopers.domain.productlike.LikedProduct(
+        p.id,
+        p.name,
+        p.price.value,
+        p.likeCount,
+        b.id,
+        b.name,
+        pl.likedAt
+      )
+      FROM ProductLike pl
+      LEFT JOIN Product p ON p.id = pl.productId
+      LEFT JOIN Brand b ON b.id = p.brandId
+      WHERE pl.userId = :userId
+      ORDER BY pl.likedAt DESC
+      """)
+  Page<LikedProduct> findByUserIdOrderByLatest(@Param("userId") Long userId, Pageable pageable);
+
+  @Query("""
+      SELECT new com.loopers.domain.productlike.LikedProduct(
+        p.id,
+        p.name,
+        p.price.value,
+        p.likeCount,
+        b.id,
+        b.name,
+        pl.likedAt
+      )
+      FROM ProductLike pl
+      LEFT JOIN Product p ON p.id = pl.productId
+      LEFT JOIN Brand b ON b.id = p.brandId
+      WHERE pl.userId = :userId
+      ORDER BY p.name ASC
+      """)
+  Page<LikedProduct> findByUserIdOrderByProductName(@Param("userId") Long userId,
+      Pageable pageable);
+
+  @Query("""
+      SELECT new com.loopers.domain.productlike.LikedProduct(
+        p.id,
+        p.name,
+        p.price.value,
+        p.likeCount,
+        b.id,
+        b.name,
+        pl.likedAt
+      )
+      FROM ProductLike pl
+      LEFT JOIN Product p ON p.id = pl.productId
+      LEFT JOIN Brand b ON b.id = p.brandId
+      WHERE pl.userId = :userId
+      ORDER BY p.price.value ASC
+      """)
+  Page<LikedProduct> findByUserIdOrderByPriceAsc(@Param("userId") Long userId, Pageable pageable);
+
+  @Query("""
+      SELECT new com.loopers.domain.productlike.LikedProduct(
+        p.id,
+        p.name,
+        p.price.value,
+        p.likeCount,
+        b.id,
+        b.name,
+        pl.likedAt
+      )
+      FROM ProductLike pl
+      LEFT JOIN Product p ON p.id = pl.productId
+      LEFT JOIN Brand b ON b.id = p.brandId
+      WHERE pl.userId = :userId
+      ORDER BY p.price.value DESC
+      """)
+  Page<LikedProduct> findByUserIdOrderByPriceDesc(@Param("userId") Long userId, Pageable pageable);
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/productlike/LikeQueryRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/productlike/LikeQueryRepositoryImpl.java
new file mode 100644
index 000000000..de2178a90
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/productlike/LikeQueryRepositoryImpl.java
@@ -0,0 +1,35 @@
+package com.loopers.infrastructure.productlike;
+
+import com.loopers.domain.productlike.LikeQueryRepository;
+import com.loopers.domain.productlike.LikedProduct;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Repository;
+
+@Repository
+@RequiredArgsConstructor
+public class LikeQueryRepositoryImpl implements LikeQueryRepository {
+
+  private final LikeQueryJpaRepository jpaRepository;
+
+  @Override
+  public Page<LikedProduct> findByUserIdOrderByLatest(Long userId, Pageable pageable) {
+    return jpaRepository.findByUserIdOrderByLatest(userId, pageable);
+  }
+
+  @Override
+  public Page<LikedProduct> findByUserIdOrderByProductName(Long userId, Pageable pageable) {
+    return jpaRepository.findByUserIdOrderByProductName(userId, pageable);
+  }
+
+  @Override
+  public Page<LikedProduct> findByUserIdOrderByPriceAsc(Long userId, Pageable pageable) {
+    return jpaRepository.findByUserIdOrderByPriceAsc(userId, pageable);
+  }
+
+  @Override
+  public Page<LikedProduct> findByUserIdOrderByPriceDesc(Long userId, Pageable pageable) {
+    return jpaRepository.findByUserIdOrderByPriceDesc(userId, pageable);
+  }
+}
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 3fa89d307..ddbb76cc8 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
@@ -1,6 +1,8 @@
 package com.loopers.infrastructure.productlike;
 
 import com.loopers.domain.productlike.ProductLike;
+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.Modifying;
 import org.springframework.data.jpa.repository.Query;
@@ -17,4 +19,8 @@ public interface ProductLikeJpaRepository extends JpaRepository<ProductLike, Lon
   @Modifying
   @Query("DELETE FROM ProductLike pl WHERE pl.userId = :userId AND pl.productId = :productId")
   int deleteByUserIdAndProductId(@Param("userId") Long userId, @Param("productId") Long productId);
+
+  @Query("SELECT pl FROM ProductLike pl WHERE pl.userId = :userId ORDER BY pl.likedAt DESC")
+  Page<ProductLike> findByUserIdOrderByLikedAtDesc(@Param("userId") Long userId, Pageable pageable);
+
 }
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 d68fe84b8..73ebe9661 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
@@ -2,11 +2,10 @@
 
 import com.loopers.domain.productlike.ProductLike;
 import com.loopers.domain.productlike.ProductLikeRepository;
+import java.util.List;
 import lombok.RequiredArgsConstructor;
 import org.springframework.stereotype.Repository;
 
-import java.util.List;
-
 @Repository
 @RequiredArgsConstructor
 public class ProductLikeRepositoryImpl implements ProductLikeRepository {
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeApiSpec.java
new file mode 100644
index 000000000..f57572052
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeApiSpec.java
@@ -0,0 +1,44 @@
+package com.loopers.interfaces.api.like;
+
+import com.loopers.domain.productlike.LikeSortType;
+import com.loopers.interfaces.api.ApiResponse;
+import com.loopers.interfaces.api.like.LikeDto.LikedProductListResponse;
+import com.loopers.interfaces.api.support.ApiHeaders;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestParam;
+
+@Tag(name = "Like API", description = "상품 좋아요 API 입니다.")
+public interface LikeApiSpec {
+
+  @Operation(summary = "상품 좋아요 등록", description = "상품에 좋아요를 등록합니다.")
+  ApiResponse<Object> registerProductLike(
+      @Parameter(description = "사용자 ID", required = true)
+      @RequestHeader(ApiHeaders.USER_ID) Long userId,
+      @Parameter(description = "상품 ID", required = true)
+      @PathVariable Long productId
+  );
+
+  @Operation(summary = "상품 좋아요 취소", description = "상품 좋아요를 취소합니다.")
+  ApiResponse<Object> cancelProductLike(
+      @Parameter(description = "사용자 ID", required = true)
+      @RequestHeader(ApiHeaders.USER_ID) Long userId,
+      @Parameter(description = "상품 ID", required = true)
+      @PathVariable Long productId
+  );
+
+  @Operation(summary = "좋아요 상품 목록 조회", description = "사용자가 좋아요한 상품 목록을 페이지 단위로 조회합니다.")
+  ApiResponse<LikedProductListResponse> retrieveLikedProducts(
+      @Parameter(description = "사용자 ID", required = true)
+      @RequestHeader(ApiHeaders.USER_ID) Long userId,
+      @Parameter(description = "정렬 (latest, product_name, price_asc, price_desc)")
+      @RequestParam(defaultValue = "lastest") LikeSortType sort,
+      @Parameter(description = "페이지 번호 (0부터 시작)")
+      @RequestParam(defaultValue = "0") int page,
+      @Parameter(description = "페이지 크기")
+      @RequestParam(defaultValue = "20") int size
+  );
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeController.java
new file mode 100644
index 000000000..b32b7c657
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeController.java
@@ -0,0 +1,62 @@
+package com.loopers.interfaces.api.like;
+
+import com.loopers.application.like.LikeFacade;
+import com.loopers.domain.productlike.LikeSortType;
+import com.loopers.domain.productlike.LikedProduct;
+import com.loopers.interfaces.api.ApiResponse;
+import com.loopers.interfaces.api.like.LikeDto.LikedProductListResponse;
+import com.loopers.interfaces.api.support.ApiHeaders;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/api/v1/like/products")
+@RequiredArgsConstructor
+public class LikeController implements LikeApiSpec {
+
+  private final LikeFacade likeFacade;
+
+  @Override
+  @PostMapping("/{productId}")
+  public ApiResponse<Object> registerProductLike(
+      @RequestHeader(ApiHeaders.USER_ID) Long userId,
+      @PathVariable Long productId
+  ) {
+    likeFacade.registerProductLike(userId, productId);
+    return ApiResponse.success();
+  }
+
+  @Override
+  @DeleteMapping("/{productId}")
+  public ApiResponse<Object> cancelProductLike(
+      @RequestHeader(ApiHeaders.USER_ID) Long userId,
+      @PathVariable Long productId
+  ) {
+    likeFacade.cancelProductLike(userId, productId);
+    return ApiResponse.success();
+  }
+
+  @Override
+  @GetMapping
+  public ApiResponse<LikedProductListResponse> retrieveLikedProducts(
+      @RequestHeader(ApiHeaders.USER_ID) Long userId,
+      @RequestParam(defaultValue = "latest") LikeSortType sort,
+      @RequestParam(defaultValue = "0") int page,
+      @RequestParam(defaultValue = "20") int size
+  ) {
+    Pageable pageable = PageRequest.of(page, size);
+    Page<LikedProduct> likedProducts = likeFacade.retrieveLikedProducts(userId, sort, pageable);
+    LikedProductListResponse response = LikedProductListResponse.from(likedProducts);
+    return ApiResponse.success(response);
+  }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeDto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeDto.java
new file mode 100644
index 000000000..1b237a18d
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeDto.java
@@ -0,0 +1,69 @@
+package com.loopers.interfaces.api.like;
+
+import com.loopers.domain.productlike.LikedProduct;
+import java.util.List;
+import org.springframework.data.domain.Page;
+
+public class LikeDto {
+
+  private LikeDto() {
+  }
+
+  public record LikedProductListResponse(
+      List<LikedProductResponse> products,
+      PageInfo pageInfo
+  ) {
+
+    public static LikedProductListResponse from(Page<LikedProduct> page) {
+      List<LikedProductResponse> products = page.getContent().stream()
+          .map(LikedProductResponse::from)
+          .toList();
+
+      return new LikedProductListResponse(products, PageInfo.from(page));
+    }
+  }
+
+  public record LikedProductResponse(
+      Long productId,
+      String productName,
+      Long price,
+      Long likeCount,
+      BrandInfo brand
+  ) {
+
+    public static LikedProductResponse from(LikedProduct likedProduct) {
+      return new LikedProductResponse(
+          likedProduct.productId(),
+          likedProduct.productName(),
+          likedProduct.price(),
+          likedProduct.likeCount(),
+          new BrandInfo(likedProduct.brandId(), likedProduct.brandName())
+      );
+    }
+  }
+
+  public record BrandInfo(
+      Long brandId,
+      String name
+  ) {
+  }
+
+  public record PageInfo(
+      long totalElements,
+      int currentPage,
+      int totalPages,
+      int pageSize,
+      String sort
+  ) {
+
+    public static PageInfo from(Page<?> page) {
+      return new PageInfo(
+          page.getTotalElements(),
+          page.getNumber(),
+          page.getTotalPages(),
+          page.getSize(),
+          page.getSort().toString()
+      );
+    }
+  }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeSortTypeConverter.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeSortTypeConverter.java
new file mode 100644
index 000000000..611bd8844
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeSortTypeConverter.java
@@ -0,0 +1,14 @@
+package com.loopers.interfaces.api.like;
+
+import com.loopers.domain.productlike.LikeSortType;
+import org.springframework.core.convert.converter.Converter;
+import org.springframework.stereotype.Component;
+
+@Component
+public class LikeSortTypeConverter implements Converter<String, LikeSortType> {
+
+  @Override
+  public LikeSortType convert(String source) {
+    return LikeSortType.from(source);
+  }
+}
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
index 124e81a84..f74a8f52a 100644
--- 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
@@ -1,31 +1,34 @@
 package com.loopers.application.like;
 
+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 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.LikeSortType;
+import com.loopers.domain.productlike.LikedProduct;
 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.support.test.IntegrationTestSupport;
 import com.loopers.utils.DatabaseCleanUp;
+import java.time.LocalDateTime;
 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 java.time.LocalDateTime;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
-
-@SpringBootTest
 @DisplayName("LikeFacade 통합 테스트")
-class LikeFacadeIntegrationTest {
+class LikeFacadeIntegrationTest extends IntegrationTestSupport {
 
   private static final LocalDateTime LIKED_AT_2025_10_30 = LocalDateTime.of(2025, 10, 30, 0, 0, 0);
 
@@ -63,7 +66,7 @@ void likesProduct_andIncreasesLikeCount() {
       Long userId = 1L;
       Long productId = product.getId();
 
-      likeFacade.likeProduct(userId, productId);
+      likeFacade.registerProductLike(userId, productId);
 
       assertThat(productLikeRepository.existsByUserIdAndProductId(userId, productId)).isTrue();
       Product updatedProduct = productRepository.findById(productId).orElseThrow();
@@ -71,7 +74,7 @@ void likesProduct_andIncreasesLikeCount() {
     }
 
     @Test
-    @DisplayName("같은 사용자가 두 번 좋아요 시도해도 1개만 증가 (멱등성)")
+    @DisplayName("같은 사용자가 두 번 좋아요 시도해도 1개만 증가한다. (멱등성)")
     void ensuresIdempotency_whenDuplicateLikes() {
       Brand brand = brandRepository.save(Brand.of("브랜드A", "설명A"));
       Product product = productRepository.save(
@@ -80,8 +83,8 @@ void ensuresIdempotency_whenDuplicateLikes() {
       Long userId = 1L;
       Long productId = product.getId();
 
-      likeFacade.likeProduct(userId, productId);
-      likeFacade.likeProduct(userId, productId);
+      likeFacade.registerProductLike(userId, productId);
+      likeFacade.registerProductLike(userId, productId);
 
       assertThat(productLikeRepository.existsByUserIdAndProductId(userId, productId)).isTrue();
       Product updatedProduct = productRepository.findById(productId).orElseThrow();
@@ -89,12 +92,12 @@ void ensuresIdempotency_whenDuplicateLikes() {
     }
 
     @Test
-    @DisplayName("존재하지 않는 상품 좋아요 시 예외 발생")
+    @DisplayName("존재하지 않는 상품 좋아요 시 예외 발생한다")
     void throwsException_whenProductNotFound() {
       Long userId = 1L;
       Long productId = 999999L;
 
-      assertThatThrownBy(() -> likeFacade.likeProduct(userId, productId))
+      assertThatThrownBy(() -> likeFacade.registerProductLike(userId, productId))
           .isInstanceOf(CoreException.class)
           .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND)
           .hasMessageContaining("상품을 찾을 수 없습니다.");
@@ -119,7 +122,7 @@ void unlikesProduct_andDecreasesLikeCount() {
       product.increaseLikeCount();
       productRepository.save(product);
 
-      likeFacade.unlikeProduct(userId, productId);
+      likeFacade.cancelProductLike(userId, productId);
 
       assertThat(productLikeRepository.existsByUserIdAndProductId(userId, productId)).isFalse();
       Product updatedProduct = productRepository.findById(productId).orElseThrow();
@@ -127,7 +130,7 @@ void unlikesProduct_andDecreasesLikeCount() {
     }
 
     @Test
-    @DisplayName("같은 사용자가 두 번 좋아요 취소 시도해도 1개만 감소 (멱등성)")
+    @DisplayName("같은 사용자가 두 번 좋아요 취소 시도해도 1개만 감소한다 (멱등성)")
     void ensuresIdempotency_whenDuplicateUnlikes() {
       Brand brand = brandRepository.save(Brand.of("브랜드A", "설명A"));
       Product product = productRepository.save(
@@ -140,8 +143,8 @@ void ensuresIdempotency_whenDuplicateUnlikes() {
       product.increaseLikeCount();
       productRepository.save(product);
 
-      likeFacade.unlikeProduct(userId, productId);
-      likeFacade.unlikeProduct(userId, productId);
+      likeFacade.cancelProductLike(userId, productId);
+      likeFacade.cancelProductLike(userId, productId);
 
       assertThat(productLikeRepository.existsByUserIdAndProductId(userId, productId)).isFalse();
       Product updatedProduct = productRepository.findById(productId).orElseThrow();
@@ -149,15 +152,176 @@ void ensuresIdempotency_whenDuplicateUnlikes() {
     }
 
     @Test
-    @DisplayName("존재하지 않는 상품 좋아요 취소 시 예외 발생")
+    @DisplayName("존재하지 않는 상품 좋아요 취소 시 예외가 발생한다")
     void throwsException_whenProductNotFound() {
       Long userId = 1L;
       Long productId = 999999L;
 
-      assertThatThrownBy(() -> likeFacade.unlikeProduct(userId, productId))
+      assertThatThrownBy(() -> likeFacade.cancelProductLike(userId, productId))
           .isInstanceOf(CoreException.class)
           .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND)
           .hasMessageContaining("상품을 찾을 수 없습니다.");
     }
   }
+
+  @Nested
+  @DisplayName("좋아요 목록 조회 시")
+  class RetrieveLikedProducts {
+
+    @Test
+    @DisplayName("LATEST 정렬 - 최근 좋아요 순으로 조회한다")
+    void retrievesLikedProducts_sortedByLatest() {
+      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(50L), brand.getId()));
+
+      Long userId = 1L;
+      productLikeRepository.save(
+          ProductLike.of(userId, product1.getId(), LIKED_AT_2025_10_30));
+      productLikeRepository.save(ProductLike.of(userId, product2.getId(),
+          LIKED_AT_2025_10_30.plusSeconds(10)));
+
+      Page<LikedProduct> result = likeFacade.retrieveLikedProducts(userId, LikeSortType.LATEST,
+          PageRequest.of(0, 10));
+
+      assertThat(result.getContent())
+          .hasSize(2)
+          .extracting("productName", "likedAt")
+          .containsExactly(
+              tuple("상품2", LIKED_AT_2025_10_30.plusSeconds(10)),
+              tuple("상품1", LIKED_AT_2025_10_30)
+          );
+    }
+
+    @Test
+    @DisplayName("PRODUCT_NAME 정렬 - 상품명 오름차순으로 조회한다")
+    void retrievesLikedProducts_sortedByProductName() {
+      Brand brand = brandRepository.save(Brand.of("브랜드A", "설명A"));
+      Product charlie = productRepository.save(
+          Product.of("Charlie", Money.of(30000L), "설명C", Stock.of(30L), brand.getId()));
+      Product alpha = productRepository.save(
+          Product.of("Alpha", Money.of(10000L), "설명A", Stock.of(100L), brand.getId()));
+      Product beta = productRepository.save(
+          Product.of("Beta", Money.of(20000L), "설명B", Stock.of(50L), brand.getId()));
+
+      Long userId = 1L;
+      productLikeRepository.save(
+          ProductLike.of(userId, charlie.getId(), LIKED_AT_2025_10_30));
+      productLikeRepository.save(
+          ProductLike.of(userId, alpha.getId(), LIKED_AT_2025_10_30.plusSeconds(1)));
+      productLikeRepository.save(
+          ProductLike.of(userId, beta.getId(), LIKED_AT_2025_10_30.plusSeconds(2)));
+
+      Page<LikedProduct> result = likeFacade.retrieveLikedProducts(userId,
+          LikeSortType.PRODUCT_NAME, PageRequest.of(0, 10));
+
+      assertThat(result.getContent())
+          .hasSize(3)
+          .extracting("productName")
+          .containsExactly("Alpha", "Beta", "Charlie");
+    }
+
+    @Test
+    @DisplayName("PRICE_ASC 정렬 - 가격 오름차순으로 조회한다")
+    void retrievesLikedProducts_sortedByPriceAsc() {
+      Brand brand = brandRepository.save(Brand.of("브랜드A", "설명A"));
+      Product expensive = productRepository.save(
+          Product.of("비싼상품", Money.of(50000L), "설명", Stock.of(10L), brand.getId()));
+      Product cheap = productRepository.save(
+          Product.of("저렴한상품", Money.of(10000L), "설명", Stock.of(100L), brand.getId()));
+      Product medium = productRepository.save(
+          Product.of("중간상품", Money.of(30000L), "설명", Stock.of(50L), brand.getId()));
+
+      Long userId = 1L;
+      productLikeRepository.save(
+          ProductLike.of(userId, expensive.getId(), LIKED_AT_2025_10_30));
+      productLikeRepository.save(
+          ProductLike.of(userId, cheap.getId(), LIKED_AT_2025_10_30.plusSeconds(1)));
+      productLikeRepository.save(
+          ProductLike.of(userId, medium.getId(), LIKED_AT_2025_10_30.plusSeconds(2)));
+
+      Page<LikedProduct> result = likeFacade.retrieveLikedProducts(userId,
+          LikeSortType.PRICE_ASC, PageRequest.of(0, 10));
+
+      assertThat(result.getContent())
+          .hasSize(3)
+          .extracting("productName", "price")
+          .containsExactly(
+              tuple("저렴한상품", 10000L),
+              tuple("중간상품", 30000L),
+              tuple("비싼상품", 50000L)
+          );
+    }
+
+    @Test
+    @DisplayName("PRICE_DESC 정렬 - 가격 내림차순으로 조회한다")
+    void retrievesLikedProducts_sortedByPriceDesc() {
+      Brand brand = brandRepository.save(Brand.of("브랜드A", "설명A"));
+      Product expensive = productRepository.save(
+          Product.of("비싼상품", Money.of(50000L), "설명", Stock.of(10L), brand.getId()));
+      Product cheap = productRepository.save(
+          Product.of("저렴한상품", Money.of(10000L), "설명", Stock.of(100L), brand.getId()));
+      Product medium = productRepository.save(
+          Product.of("중간상품", Money.of(30000L), "설명", Stock.of(50L), brand.getId()));
+
+      Long userId = 1L;
+      productLikeRepository.save(
+          ProductLike.of(userId, expensive.getId(), LIKED_AT_2025_10_30));
+      productLikeRepository.save(
+          ProductLike.of(userId, cheap.getId(), LIKED_AT_2025_10_30.plusSeconds(1)));
+      productLikeRepository.save(
+          ProductLike.of(userId, medium.getId(), LIKED_AT_2025_10_30.plusSeconds(2)));
+
+      Page<LikedProduct> result = likeFacade.retrieveLikedProducts(userId,
+          LikeSortType.PRICE_DESC, PageRequest.of(0, 10));
+
+      assertThat(result.getContent())
+          .hasSize(3)
+          .extracting("productName", "price")
+          .containsExactly(
+              tuple("비싼상품", 50000L),
+              tuple("중간상품", 30000L),
+              tuple("저렴한상품", 10000L)
+          );
+    }
+
+    @Test
+    @DisplayName("좋아요한 상품이 없으면 빈 페이지를 반환한다")
+    void returnsEmptyPage_whenNoLikedProducts() {
+      Long userId = 1L;
+
+      Page<LikedProduct> result = likeFacade.retrieveLikedProducts(userId, LikeSortType.LATEST,
+          PageRequest.of(0, 10));
+
+      assertThat(result.getContent()).isEmpty();
+      assertThat(result.getTotalElements()).isZero();
+    }
+
+    @Test
+    @DisplayName("브랜드 정보가 포함된다")
+    void includesBrandInformation() {
+      Brand brandA = brandRepository.save(Brand.of("브랜드A", "설명A"));
+      Brand brandB = brandRepository.save(Brand.of("브랜드B", "설명B"));
+      Product product1 = productRepository.save(
+          Product.of("상품1", Money.of(10000L), "설명1", Stock.of(100L), brandA.getId()));
+      Product product2 = productRepository.save(
+          Product.of("상품2", Money.of(20000L), "설명2", Stock.of(50L), brandB.getId()));
+
+      Long userId = 1L;
+      productLikeRepository.save(
+          ProductLike.of(userId, product1.getId(), LIKED_AT_2025_10_30));
+      productLikeRepository.save(
+          ProductLike.of(userId, product2.getId(), LIKED_AT_2025_10_30.plusSeconds(1)));
+
+      Page<LikedProduct> result = likeFacade.retrieveLikedProducts(userId, LikeSortType.LATEST,
+          PageRequest.of(0, 10));
+
+      assertThat(result.getContent())
+          .hasSize(2)
+          .extracting("brandName")
+          .containsExactly("브랜드B", "브랜드A");
+    }
+  }
 }
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/productlike/LikedProductTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/productlike/LikedProductTest.java
new file mode 100644
index 000000000..4c36598c5
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/productlike/LikedProductTest.java
@@ -0,0 +1,87 @@
+package com.loopers.domain.productlike;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.loopers.domain.brand.Brand;
+import com.loopers.domain.money.Money;
+import com.loopers.domain.product.Product;
+import com.loopers.domain.stock.Stock;
+import java.time.LocalDateTime;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+@DisplayName("LikedProduct DTO 테스트")
+class LikedProductTest {
+
+  private static final LocalDateTime LIKED_AT_2025_10_30 = LocalDateTime.of(2025, 10, 30, 0, 0, 0);
+
+  @DisplayName("LikedProduct.of()로 생성할 때")
+  @Nested
+  class Create {
+
+    @Test
+    @DisplayName("ProductLike, Product, Brand를 조합하여 LikedProduct를 생성한다")
+    void shouldCreate_whenAllParametersAreValid() {
+      ProductLike productLike = ProductLike.of(1L, 10L, LIKED_AT_2025_10_30);
+      Product product = Product.of("상품명", Money.of(50000L), "설명", Stock.of(100L), 5L);
+      Brand brand = Brand.of("브랜드명", "브랜드 설명");
+
+      LikedProduct result = LikedProduct.of(productLike, product, brand);
+
+      assertThat(result)
+          .extracting("productId", "productName", "price", "brandId", "brandName", "likedAt")
+          .containsExactly(product.getId(), "상품명", 50000L, brand.getId(), "브랜드명",
+              LIKED_AT_2025_10_30);
+    }
+  }
+
+  @DisplayName("ProductLike 검증")
+  @Nested
+  class ValidateProductLike {
+
+    @Test
+    @DisplayName("ProductLike가 null이면 예외가 발생한다")
+    void shouldThrowException_whenProductLikeIsNull() {
+      Product product = Product.of("상품명", Money.of(50000L), "설명", Stock.of(100L), 5L);
+      Brand brand = Brand.of("브랜드명", "브랜드 설명");
+
+      assertThatThrownBy(() -> LikedProduct.of(null, product, brand))
+          .isInstanceOf(NullPointerException.class)
+          .hasMessage("ProductLike는 null일 수 없습니다.");
+    }
+  }
+
+  @DisplayName("Product 검증")
+  @Nested
+  class ValidateProduct {
+
+    @Test
+    @DisplayName("Product가 null이면 예외가 발생한다")
+    void shouldThrowException_whenProductIsNull() {
+      ProductLike productLike = ProductLike.of(1L, 10L, LIKED_AT_2025_10_30);
+      Brand brand = Brand.of("브랜드명", "브랜드 설명");
+
+      assertThatThrownBy(() -> LikedProduct.of(productLike, null, brand))
+          .isInstanceOf(NullPointerException.class)
+          .hasMessage("Product는 null일 수 없습니다.");
+    }
+  }
+
+  @DisplayName("Brand 검증")
+  @Nested
+  class ValidateBrand {
+
+    @Test
+    @DisplayName("Brand가 null이면 예외가 발생한다")
+    void shouldThrowException_whenBrandIsNull() {
+      ProductLike productLike = ProductLike.of(1L, 10L, LIKED_AT_2025_10_30);
+      Product product = Product.of("상품명", Money.of(50000L), "설명", Stock.of(100L), 5L);
+
+      assertThatThrownBy(() -> LikedProduct.of(productLike, product, null))
+          .isInstanceOf(NullPointerException.class)
+          .hasMessage("Brand는 null일 수 없습니다.");
+    }
+  }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/productlike/ProductLikeServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/productlike/ProductLikeServiceIntegrationTest.java
new file mode 100644
index 000000000..580a3e7e1
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/productlike/ProductLikeServiceIntegrationTest.java
@@ -0,0 +1,190 @@
+package com.loopers.domain.productlike;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.loopers.domain.money.Money;
+import com.loopers.domain.product.Product;
+import com.loopers.domain.product.ProductRepository;
+import com.loopers.domain.stock.Stock;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.test.IntegrationTestSupport;
+import com.loopers.utils.DatabaseCleanUp;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.List;
+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.mockito.Mockito;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+
+@DisplayName("ProductLikeService 통합 테스트")
+class ProductLikeServiceIntegrationTest extends IntegrationTestSupport {
+
+  private static final Clock FIXED_CLOCK = Clock.fixed(Instant.parse("2025-10-30T00:00:00Z"), ZoneId.systemDefault());
+  private static final LocalDateTime FIXED_LIKED_AT = LocalDateTime.ofInstant(FIXED_CLOCK.instant(), FIXED_CLOCK.getZone());
+
+  @Autowired
+  private ProductLikeService productLikeService;
+
+  @Autowired
+  private ProductRepository productRepository;
+
+  @Autowired
+  private ProductLikeRepository productLikeRepository;
+
+  @Autowired
+  private DatabaseCleanUp databaseCleanUp;
+
+  @MockitoBean
+  private Clock clock;
+
+  @BeforeEach
+  void setUpClock() {
+    Mockito.when(clock.instant()).thenReturn(FIXED_CLOCK.instant());
+    Mockito.when(clock.getZone()).thenReturn(FIXED_CLOCK.getZone());
+  }
+
+  @AfterEach
+  void tearDown() {
+    databaseCleanUp.truncateAllTables();
+  }
+
+  private Product saveProduct(String name, long price, Long brandId) {
+    return productRepository.save(
+        Product.of(name, Money.of(price), name + " 설명", Stock.of(10L), brandId)
+    );
+  }
+
+  @Nested
+  @DisplayName("좋아요 생성")
+  class CreateLike {
+
+    @Test
+    @DisplayName("유효한 사용자와 상품이면 좋아요가 생성된다.")
+    void createsLike_whenValid() {
+      long brandId = 1L;
+      Product product = saveProduct("상품1", 10000L, brandId);
+
+      productLikeService.createLike(brandId, product.getId());
+
+      List<ProductLike> likes = productLikeRepository.findByUserIdAndProductIdIn(brandId, List.of(brandId));
+      assertThat(likes).hasSize(1)
+          .element(0)
+          .extracting(ProductLike::getUserId, ProductLike::getProductId, ProductLike::getLikedAt)
+          .containsExactly(1L, product.getId(), FIXED_LIKED_AT);
+    }
+
+    @Test
+    @DisplayName("userId가 null이면 예외가 발생한다")
+    void throwsWhenUserIdNull() {
+      assertThatThrownBy(() -> productLikeService.createLike(null, 1L))
+          .isInstanceOf(CoreException.class)
+          .hasMessage("사용자는 비어있을 수 없습니다.");
+    }
+
+    @Test
+    @DisplayName("productId가 null이면 예외가 발생한다")
+    void throwsWhenProductIdNull() {
+      assertThatThrownBy(() -> productLikeService.createLike(1L, null))
+          .isInstanceOf(CoreException.class)
+          .hasMessage("상품은 비어있을 수 없습니다.");
+    }
+  }
+
+  @Nested
+  @DisplayName("좋아요 상태 조회")
+  class FindLikeStatus {
+
+    @Test
+    @DisplayName("사용자가 특정 상품을 좋아요 했으면 true를 반환한다")
+    void returnsTrueWhenLiked() {
+      long brandId = 1L;
+      Product product = saveProduct("상품1", 10000L, brandId);
+      productLikeRepository.save(ProductLike.of(1L, product.getId(), FIXED_LIKED_AT));
+
+      boolean result = productLikeService.isLiked(1L, product.getId());
+
+      assertThat(result).isTrue();
+    }
+
+    @Test
+    @DisplayName("좋아요하지 않았다면 false를 반환한다")
+    void returnsFalseWhenNotLiked() {
+      long brandId = 1L;
+      Product product = saveProduct("상품1", 10000L, brandId);
+
+      boolean result = productLikeService.isLiked(1L, product.getId());
+
+      assertThat(result).isFalse();
+    }
+  }
+
+  @Nested
+  @DisplayName("좋아요 목록 조회")
+  class FindLikedProducts {
+
+    @Test
+    @DisplayName("사용자가 좋아요한 상품 목록을 LATEST 순으로 조회한다")
+    void returnsLikedProducts_sortedByLatest() {
+      long brandId = 1L;
+      Product product1 = saveProduct("상품1", 10000L, brandId);
+      Product product2 = saveProduct("상품2", 20000L, brandId);
+      productLikeRepository.save(ProductLike.of(1L, product1.getId(), FIXED_LIKED_AT));
+      productLikeRepository.save(
+          ProductLike.of(1L, product2.getId(), FIXED_LIKED_AT.plusSeconds(10)));
+
+      Page<LikedProduct> result = productLikeService.findLikedProducts(1L, LikeSortType.LATEST,
+          PageRequest.of(0, 10));
+
+      assertThat(result.getContent())
+          .hasSize(2)
+          .extracting(LikedProduct::productId, LikedProduct::likedAt)
+          .containsExactly(
+              org.assertj.core.groups.Tuple.tuple(product2.getId(),
+                  FIXED_LIKED_AT.plusSeconds(10)),
+              org.assertj.core.groups.Tuple.tuple(product1.getId(), FIXED_LIKED_AT)
+          );
+    }
+
+    @Test
+    @DisplayName("좋아요한 상품이 없으면 빈 페이지를 반환한다")
+    void returnsEmptyPage_whenNoLikes() {
+      Page<LikedProduct> result = productLikeService.findLikedProducts(1L, LikeSortType.LATEST,
+          PageRequest.of(0, 10));
+
+      assertThat(result.getContent()).isEmpty();
+      assertThat(result.getTotalElements()).isZero();
+    }
+
+    @Test
+    @DisplayName("페이지네이션이 동작한다")
+    void supportsPagination() {
+      long brandId = 1L;
+      for (int i = 1; i <= 5; i++) {
+        Product product = saveProduct("상품" + i, 10000L * i, brandId);
+        productLikeRepository.save(
+            ProductLike.of(1L, product.getId(), FIXED_LIKED_AT.plusSeconds(i)));
+      }
+
+      Page<LikedProduct> firstPage = productLikeService.findLikedProducts(1L,
+          LikeSortType.LATEST, PageRequest.of(0, 2));
+      Page<LikedProduct> secondPage = productLikeService.findLikedProducts(1L,
+          LikeSortType.LATEST, PageRequest.of(1, 2));
+
+      assertThat(firstPage.getContent()).hasSize(2);
+      assertThat(firstPage.getTotalElements()).isEqualTo(5);
+      assertThat(secondPage.getContent()).hasSize(2);
+      assertThat(secondPage.getTotalElements()).isEqualTo(5);
+    }
+  }
+
+}
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
deleted file mode 100644
index c86d0a653..000000000
--- a/apps/commerce-api/src/test/java/com/loopers/domain/productlike/ProductLikeTest.java
+++ /dev/null
@@ -1,114 +0,0 @@
-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("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 {
-
-    @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
deleted file mode 100644
index d26e59003..000000000
--- a/apps/commerce-api/src/test/java/com/loopers/domain/productlike/ProductLikesTest.java
+++ /dev/null
@@ -1,193 +0,0 @@
-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("toLikedByProductId 변환")
-  class ToLikedByProductId {
-
-    @Test
-    @DisplayName("빈 리스트를 빈 Map으로 변환")
-    void toLikedByProductId_empty() {
-      ProductLikes productLikes = ProductLikes.empty();
-
-      Map<Long, Boolean> result = productLikes.toLikedByProductId();
-
-      assertThat(result).isEmpty();
-    }
-
-    @Test
-    @DisplayName("ProductLike 리스트를 좋아요 Map으로 변환 (모두 true)")
-    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),
-          ProductLike.of(1L, 300L, LIKED_AT_2025_10_30)
-      );
-      ProductLikes productLikes = ProductLikes.from(likes);
-
-      Map<Long, Boolean> result = productLikes.toLikedByProductId();
-
-      assertThat(result)
-          .hasSize(3)
-          .containsEntry(100L, true)
-          .containsEntry(200L, true)
-          .containsEntry(300L, true);
-    }
-  }
-
-  @Nested
-  @DisplayName("toLikeStatusByProductId 변환")
-  class ToLikeStatusByProductId {
-
-    @Test
-    @DisplayName("null productIds로 호출 시 빈 Map 반환")
-    void toLikeStatusByProductId_nullProductIds() {
-      ProductLikes productLikes = ProductLikes.empty();
-
-      Map<Long, Boolean> result = productLikes.toLikeStatusByProductId(null);
-
-      assertThat(result).isEmpty();
-    }
-
-    @Test
-    @DisplayName("빈 productIds로 호출 시 빈 Map 반환")
-    void toLikeStatusByProductId_emptyProductIds() {
-      ProductLikes productLikes = ProductLikes.from(List.of(
-          ProductLike.of(1L, 100L, LIKED_AT_2025_10_30)
-      ));
-
-      Map<Long, Boolean> result = productLikes.toLikeStatusByProductId(List.of());
-
-      assertThat(result).isEmpty();
-    }
-
-    @Test
-    @DisplayName("좋아요한 상품과 안 한 상품 혼합 상태로 변환")
-    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)
-      );
-      ProductLikes productLikes = ProductLikes.from(likes);
-      List<Long> allProductIds = List.of(100L, 200L, 300L, 400L);
-
-      Map<Long, Boolean> result = productLikes.toLikeStatusByProductId(allProductIds);
-
-      assertThat(result)
-          .hasSize(4)
-          .containsEntry(100L, true)
-          .containsEntry(200L, false)
-          .containsEntry(300L, true)
-          .containsEntry(400L, false);
-    }
-
-    @Test
-    @DisplayName("모든 상품을 좋아요하지 않은 경우")
-    void toLikeStatusByProductId_allNotLiked() {
-      ProductLikes productLikes = ProductLikes.empty();
-      List<Long> allProductIds = List.of(100L, 200L, 300L);
-
-      Map<Long, Boolean> result = productLikes.toLikeStatusByProductId(allProductIds);
-
-      assertThat(result)
-          .hasSize(3)
-          .containsEntry(100L, false)
-          .containsEntry(200L, false)
-          .containsEntry(300L, false);
-    }
-
-    @Test
-    @DisplayName("모든 상품을 좋아요한 경우")
-    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),
-          ProductLike.of(1L, 300L, LIKED_AT_2025_10_30)
-      );
-      ProductLikes productLikes = ProductLikes.from(likes);
-      List<Long> allProductIds = List.of(100L, 200L, 300L);
-
-      Map<Long, Boolean> result = productLikes.toLikeStatusByProductId(allProductIds);
-
-      assertThat(result)
-          .hasSize(3)
-          .containsEntry(100L, true)
-          .containsEntry(200L, true)
-          .containsEntry(300L, true);
-    }
-
-    @Test
-    @DisplayName("좋아요 목록에 없는 상품 ID 조회 시 false 반환")
-    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);
-
-      Map<Long, Boolean> result = productLikes.toLikeStatusByProductId(allProductIds);
-
-      assertThat(result)
-          .hasSize(2)
-          .containsEntry(100L, true)
-          .containsEntry(999L, false);
-    }
-  }
-}
diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiE2ETest.java
new file mode 100644
index 000000000..0d4e116e2
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiE2ETest.java
@@ -0,0 +1,327 @@
+package com.loopers.interfaces.api.like;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertAll;
+
+import com.loopers.domain.brand.Brand;
+import com.loopers.domain.money.Money;
+import com.loopers.domain.product.Product;
+import com.loopers.domain.productlike.ProductLike;
+import com.loopers.domain.stock.Stock;
+import com.loopers.infrastructure.brand.BrandJpaRepository;
+import com.loopers.infrastructure.product.ProductJpaRepository;
+import com.loopers.infrastructure.productlike.ProductLikeJpaRepository;
+import com.loopers.interfaces.api.ApiResponse;
+import com.loopers.interfaces.api.like.LikeDto.LikedProductListResponse;
+import com.loopers.interfaces.api.like.LikeDto.LikedProductResponse;
+import com.loopers.interfaces.api.support.ApiHeaders;
+import com.loopers.utils.DatabaseCleanUp;
+import java.time.LocalDateTime;
+import java.util.List;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.core.ParameterizedTypeReference;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+class LikeApiE2ETest {
+
+  private static final String BASE_URL = "/api/v1/like/products";
+
+  private final TestRestTemplate restTemplate;
+  private final BrandJpaRepository brandJpaRepository;
+  private final ProductJpaRepository productJpaRepository;
+  private final ProductLikeJpaRepository productLikeJpaRepository;
+  private final DatabaseCleanUp databaseCleanUp;
+
+  @Autowired
+  LikeApiE2ETest(
+      TestRestTemplate restTemplate,
+      BrandJpaRepository brandJpaRepository,
+      ProductJpaRepository productJpaRepository,
+      ProductLikeJpaRepository productLikeJpaRepository,
+      DatabaseCleanUp databaseCleanUp
+  ) {
+    this.restTemplate = restTemplate;
+    this.brandJpaRepository = brandJpaRepository;
+    this.productJpaRepository = productJpaRepository;
+    this.productLikeJpaRepository = productLikeJpaRepository;
+    this.databaseCleanUp = databaseCleanUp;
+  }
+
+  @AfterEach
+  void tearDown() {
+    databaseCleanUp.truncateAllTables();
+  }
+
+  @Nested
+  @DisplayName("POST /api/v1/like/products/{productId}")
+  class LikeProduct {
+
+    @Test
+    @DisplayName("상품 좋아요 등록에 성공하면 200과 함께 좋아요 수가 증가한다")
+    void likesProductSuccessfully() {
+      Brand brand = brandJpaRepository.save(Brand.of("브랜드A", "설명"));
+      Product product = productJpaRepository.save(
+          Product.of("상품1", Money.of(10000L), "설명", Stock.of(10L), brand.getId())
+      );
+
+      ResponseEntity<ApiResponse<Object>> response =
+          restTemplate.exchange(
+              BASE_URL + "/" + product.getId(),
+              HttpMethod.POST,
+              new HttpEntity<>(null, userHeaders(1L)),
+              new ParameterizedTypeReference<>() {
+              }
+          );
+
+      Product updated = productJpaRepository.findById(product.getId()).orElseThrow();
+
+      assertAll(
+          () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK),
+          () -> assertThat(productLikeJpaRepository.existsByUserIdAndProductId(1L, product.getId())).isTrue(),
+          () -> assertThat(updated.getLikeCount()).isEqualTo(1L)
+      );
+    }
+
+    @Test
+    @DisplayName("같은 사용자가 두 번 좋아요 요청해도 1개만 반영된다")
+    void ensuresIdempotencyOnDuplicateRequests() {
+      Brand brand = brandJpaRepository.save(Brand.of("브랜드A", "설명"));
+      Product product = productJpaRepository.save(
+          Product.of("상품1", Money.of(10000L), "설명", Stock.of(10L), brand.getId())
+      );
+
+      HttpEntity<Void> request = new HttpEntity<>(null, userHeaders(1L));
+      ParameterizedTypeReference<ApiResponse<Object>> responseType = new ParameterizedTypeReference<>() {
+      };
+
+      restTemplate.exchange(BASE_URL + "/" + product.getId(), HttpMethod.POST, request, responseType);
+      restTemplate.exchange(BASE_URL + "/" + product.getId(), HttpMethod.POST, request, responseType);
+
+      Product updated = productJpaRepository.findById(product.getId()).orElseThrow();
+      assertThat(updated.getLikeCount()).isEqualTo(1L);
+    }
+
+    @Test
+    @DisplayName("존재하지 않는 상품이면 404를 반환한다")
+    void returnsNotFoundWhenProductMissing() {
+      ResponseEntity<ApiResponse<Object>> response =
+          restTemplate.exchange(
+              BASE_URL + "/999999",
+              HttpMethod.POST,
+              new HttpEntity<>(null, userHeaders(1L)),
+              new ParameterizedTypeReference<>() {
+              }
+          );
+
+      assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
+    }
+  }
+
+  @Nested
+  @DisplayName("DELETE /api/v1/like/products/{productId}")
+  class UnlikeProduct {
+
+    @Test
+    @DisplayName("좋아요 취소에 성공하면 좋아요 수가 감소한다")
+    void unlikesProductSuccessfully() {
+      Brand brand = brandJpaRepository.save(Brand.of("브랜드A", "설명"));
+      Product product = productJpaRepository.save(
+          Product.of("상품1", Money.of(10000L), "설명", Stock.of(10L), brand.getId())
+      );
+
+      HttpEntity<Void> request = new HttpEntity<>(null, userHeaders(1L));
+      ParameterizedTypeReference<ApiResponse<Object>> responseType = new ParameterizedTypeReference<>() {
+      };
+      restTemplate.exchange(BASE_URL + "/" + product.getId(), HttpMethod.POST, request, responseType);
+
+      ResponseEntity<ApiResponse<Object>> response = restTemplate.exchange(
+          BASE_URL + "/" + product.getId(),
+          HttpMethod.DELETE,
+          request,
+          responseType
+      );
+
+      Product updated = productJpaRepository.findById(product.getId()).orElseThrow();
+      assertAll(
+          () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK),
+          () -> assertThat(productLikeJpaRepository.existsByUserIdAndProductId(1L, product.getId())).isFalse(),
+          () -> assertThat(updated.getLikeCount()).isZero()
+      );
+    }
+
+    @Test
+    @DisplayName("이미 취소된 좋아요를 다시 취소해도 200 응답한다")
+    void ensuresIdempotentUnlikes() {
+      Brand brand = brandJpaRepository.save(Brand.of("브랜드A", "설명"));
+      Product product = productJpaRepository.save(
+          Product.of("상품1", Money.of(10000L), "설명", Stock.of(10L), brand.getId())
+      );
+
+      HttpEntity<Void> request = new HttpEntity<>(null, userHeaders(1L));
+      ParameterizedTypeReference<ApiResponse<Object>> responseType = new ParameterizedTypeReference<>() {
+      };
+
+      restTemplate.exchange(BASE_URL + "/" + product.getId(), HttpMethod.POST, request, responseType);
+      restTemplate.exchange(BASE_URL + "/" + product.getId(), HttpMethod.DELETE, request, responseType);
+      ResponseEntity<ApiResponse<Object>> response = restTemplate.exchange(
+          BASE_URL + "/" + product.getId(),
+          HttpMethod.DELETE,
+          request,
+          responseType
+      );
+
+      Product updated = productJpaRepository.findById(product.getId()).orElseThrow();
+      assertAll(
+          () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK),
+          () -> assertThat(productLikeJpaRepository.existsByUserIdAndProductId(1L, product.getId())).isFalse(),
+          () -> assertThat(updated.getLikeCount()).isZero()
+      );
+    }
+  }
+
+  @Nested
+  @DisplayName("GET /api/v1/like/products")
+  class GetLikedProducts {
+
+    @Test
+    @DisplayName("기본 정렬(latest)로 좋아요 상품 목록을 반환한다")
+    void returnsLikedProductsSortedByLatest() {
+      Long userId = 99L;
+      Brand brand = brandJpaRepository.save(Brand.of("나이키", "스포츠 브랜드"));
+      Product first = saveProductWithLikes("에어포스", 12000L, brand.getId(), 3L);
+      Product second = saveProductWithLikes("조던", 10000L, brand.getId(), 2L);
+      Product third = saveProductWithLikes("덩크", 9000L, brand.getId(), 1L);
+
+      productLikeJpaRepository.save(ProductLike.of(userId, first.getId(), LocalDateTime.of(2025, 1, 3, 0, 0)));
+      productLikeJpaRepository.save(ProductLike.of(userId, second.getId(), LocalDateTime.of(2025, 1, 2, 0, 0)));
+      productLikeJpaRepository.save(ProductLike.of(userId, third.getId(), LocalDateTime.of(2025, 1, 1, 0, 0)));
+
+      ResponseEntity<ApiResponse<LikedProductListResponse>> response =
+          restTemplate.exchange(
+              BASE_URL,
+              HttpMethod.GET,
+              new HttpEntity<>(null, userHeaders(userId)),
+              new ParameterizedTypeReference<>() {
+              }
+          );
+
+      assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+      List<LikedProductResponse> products = response.getBody().data().products();
+      assertThat(products)
+          .extracting(LikedProductResponse::productName)
+          .containsExactly("에어포스", "조던", "덩크");
+    }
+
+    @Test
+    @DisplayName("sort=product_name이면 상품명을 기준으로 정렬된다")
+    void returnsProductsSortedByName() {
+      Long userId = 77L;
+      Brand brand = brandJpaRepository.save(Brand.of("나이키", "스포츠 브랜드"));
+      Product first = saveProductWithLikes("조던", 20000L, brand.getId(), 5L);
+      Product second = saveProductWithLikes("덩크", 15000L, brand.getId(), 6L);
+      Product third = saveProductWithLikes("에어맥스", 5000L, brand.getId(), 2L);
+
+      productLikeJpaRepository.save(ProductLike.of(userId, first.getId(), LocalDateTime.now()));
+      productLikeJpaRepository.save(ProductLike.of(userId, second.getId(), LocalDateTime.now()));
+      productLikeJpaRepository.save(ProductLike.of(userId, third.getId(), LocalDateTime.now()));
+
+      ResponseEntity<ApiResponse<LikedProductListResponse>> response =
+          restTemplate.exchange(
+              BASE_URL + "?sort=product_name",
+              HttpMethod.GET,
+              new HttpEntity<>(null, userHeaders(userId)),
+              new ParameterizedTypeReference<>() {
+              }
+          );
+
+      assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+      List<LikedProductResponse> products = response.getBody().data().products();
+      assertThat(products)
+          .extracting(LikedProductResponse::productName)
+          .containsExactly("덩크", "에어맥스", "조던");
+    }
+
+    @Test
+    @DisplayName("sort=price_desc이면 가격이 높은 순으로 정렬된다")
+    void returnsProductsSortedByPriceDesc() {
+      Long userId = 55L;
+      Brand brand = brandJpaRepository.save(Brand.of("나이키", "스포츠 브랜드"));
+      Product cheap = saveProductWithLikes("페가수스", 10000L, brand.getId(), 1L);
+      Product mid = saveProductWithLikes("코르테즈", 50000L, brand.getId(), 1L);
+      Product expensive = saveProductWithLikes("에어조던", 100000L, brand.getId(), 1L);
+
+      productLikeJpaRepository.save(ProductLike.of(userId, cheap.getId(), LocalDateTime.now()));
+      productLikeJpaRepository.save(ProductLike.of(userId, mid.getId(), LocalDateTime.now()));
+      productLikeJpaRepository.save(ProductLike.of(userId, expensive.getId(), LocalDateTime.now()));
+
+      ResponseEntity<ApiResponse<LikedProductListResponse>> response =
+          restTemplate.exchange(
+              BASE_URL + "?sort=price_desc",
+              HttpMethod.GET,
+              new HttpEntity<>(null, userHeaders(userId)),
+              new ParameterizedTypeReference<>() {
+              }
+          );
+
+      assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+      List<LikedProductResponse> products = response.getBody().data().products();
+      assertThat(products)
+          .extracting(LikedProductResponse::productName)
+          .containsExactly("에어조던", "코르테즈", "페가수스");
+    }
+
+    @Test
+    @DisplayName("sort=price_asc이면 가격이 낮은 순으로 정렬된다")
+    void returnsProductsSortedByPriceAsc() {
+      Long userId = 66L;
+      Brand brand = brandJpaRepository.save(Brand.of("나이키", "스포츠 브랜드"));
+      Product cheap = saveProductWithLikes("페가수스", 10000L, brand.getId(), 1L);
+      Product mid = saveProductWithLikes("코르테즈", 50000L, brand.getId(), 1L);
+      Product expensive = saveProductWithLikes("에어조던", 100000L, brand.getId(), 1L);
+
+      productLikeJpaRepository.save(ProductLike.of(userId, cheap.getId(), LocalDateTime.now()));
+      productLikeJpaRepository.save(ProductLike.of(userId, mid.getId(), LocalDateTime.now()));
+      productLikeJpaRepository.save(ProductLike.of(userId, expensive.getId(), LocalDateTime.now()));
+
+      ResponseEntity<ApiResponse<LikedProductListResponse>> response =
+          restTemplate.exchange(
+              BASE_URL + "?sort=price_asc",
+              HttpMethod.GET,
+              new HttpEntity<>(null, userHeaders(userId)),
+              new ParameterizedTypeReference<>() {
+              }
+          );
+
+      assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+      List<LikedProductResponse> products = response.getBody().data().products();
+      assertThat(products)
+          .extracting(LikedProductResponse::productName)
+          .containsExactly("페가수스", "코르테즈", "에어조던");
+    }
+  }
+
+  private Product saveProductWithLikes(String name, long price, Long brandId, long likeCount) {
+    Product product = Product.of(name, Money.of(price), name + " 설명", Stock.of(10L), brandId);
+    if (likeCount > 0) {
+      product.increaseLikeCount((int) likeCount);
+    }
+    return productJpaRepository.save(product);
+  }
+
+  private HttpHeaders userHeaders(Long userId) {
+    HttpHeaders headers = new HttpHeaders();
+    headers.set(ApiHeaders.USER_ID, String.valueOf(userId));
+    return headers;
+  }
+}

From 8bb78dc8747808158d9bec20a46f8a0fce1c3cb7 Mon Sep 17 00:00:00 2001
From: green <green@mobidoc.us>
Date: Wed, 19 Nov 2025 20:55:16 +0900
Subject: [PATCH 05/14] =?UTF-8?q?feat:=20Order=20API=20=EC=B6=94=EA=B0=80?=
 =?UTF-8?q?=20=EB=B0=8F=20=EB=8F=84=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

도메인 개선:
- OrderCreateService: 주문 생성 로직 강화
- OrderService: 주문 조회 기능 개선
- Repository: 사용자별 주문 조회 쿼리 최적화

API 계층:
- OrderApiSpec: 주문 생성/조회 API 문서화
- OrderController: REST API 엔드포인트 구현
- OrderDto: 주문 요청/응답 DTO

E2E 테스트:
- OrderApiE2ETest: 주문 생성/조회 E2E 시나리오
- 트랜잭션 롤백 검증
---
 .../application/order/OrderFacade.java        |  33 +-
 .../domain/order/OrderCreateService.java      |   3 +
 .../loopers/domain/order/OrderService.java    |  11 +
 .../order/OrderJpaRepository.java             |   4 +-
 .../interfaces/api/order/OrderApiSpec.java    |  59 ++++
 .../interfaces/api/order/OrderController.java |  80 +++++
 .../interfaces/api/order/OrderDto.java        | 123 +++++++
 .../order/OrderFacadeIntegrationTest.java     | 153 ++++----
 .../domain/order/OrderCreateServiceTest.java  |  18 +-
 .../order/OrderRepositoryIntegrationTest.java |  30 +-
 .../order/OrderServiceIntegrationTest.java    |   6 +-
 .../domain/order/OrderServiceTest.java        |  20 +-
 .../interfaces/api/order/OrderApiE2ETest.java | 334 ++++++++++++++++++
 13 files changed, 750 insertions(+), 124 deletions(-)
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderApiSpec.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderDto.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.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
index e8553ee12..92df454dc 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
@@ -2,14 +2,20 @@
 
 import com.loopers.domain.order.Order;
 import com.loopers.domain.order.OrderCreateService;
+import com.loopers.domain.order.OrderListDto;
 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.Product;
 import com.loopers.domain.product.ProductService;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import java.time.Clock;
 import java.util.Objects;
 import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
 import org.springframework.stereotype.Component;
 import org.springframework.transaction.annotation.Transactional;
 
@@ -27,14 +33,14 @@ public class OrderFacade {
   private final OrderCreateService orderCreateService;
   private final ProductService productService;
   private final PointService pointService;
+  private final Clock clock;
+
 
   @Transactional
-  public Order createOrder(Long userId, List<OrderItemCommand> commands, LocalDateTime orderedAt) {
-    Objects.requireNonNull(userId, "유저아이디는 null 일수 없습니다.");
-    Objects.requireNonNull(commands, "상품 주문 정보는 null일 수 없습니다.");
-    Objects.requireNonNull(orderedAt, "주문 요청 시간은 null일 수 없습니다");
+  public Order createOrder(Long userId, List<OrderItemCommand> commands) {
+    LocalDateTime orderedAt = LocalDateTime.now(clock);
 
-    Map<Long, Product> productById = getProductMapWithLock(commands);
+    Map<Long, Product> productById = getProductByIdWithLocks(commands);
 
     OrderPreparation result = orderCreateService.prepareOrder(commands, productById);
 
@@ -45,7 +51,22 @@ public Order createOrder(Long userId, List<OrderItemCommand> commands, LocalDate
     return orderService.create(userId, result.orderItems().getItems(), orderedAt);
   }
 
-  private Map<Long, Product> getProductMapWithLock(List<OrderItemCommand> commands) {
+  public Page<OrderListDto> retrieveOrders(Long userId, Pageable pageable) {
+    return orderService.findOrders(userId, pageable);
+  }
+
+  public Order retrieveOrderDetail(Long userId, Long orderId) {
+    Order order = orderService.getWithItemsById(orderId)
+        .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다."));
+
+    if (!Objects.equals(order.getUserId(), userId)) {
+      throw new CoreException(ErrorType.FORBIDDEN, "본인의 주문만 조회할 수 있습니다.");
+    }
+
+    return order;
+  }
+
+  private Map<Long, Product> getProductByIdWithLocks(List<OrderItemCommand> commands) {
     List<Long> productIds = commands.stream()
         .map(OrderItemCommand::productId)
         .distinct()
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
index 3d4b1dff9..23560ee16 100644
--- 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
@@ -7,6 +7,7 @@
 import com.loopers.domain.product.Product;
 import com.loopers.support.error.CoreException;
 import com.loopers.support.error.ErrorType;
+import java.util.Objects;
 import org.springframework.stereotype.Component;
 
 import java.util.List;
@@ -18,6 +19,8 @@ public class OrderCreateService {
 
   public OrderPreparation prepareOrder(List<OrderItemCommand> commands,
                                           Map<Long, Product> productById) {
+
+    Objects.requireNonNull(commands, "상품 주문 정보는 null일 수 없습니다.");
     OrderItems orderItems = createOrderItems(commands, productById);
 
     orderItems.validateStock(productById);
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 089566399..d6b162161 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,11 +4,14 @@
 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.Component;
 import org.springframework.stereotype.Service;
 
 import java.time.LocalDateTime;
 import java.util.List;
+import java.util.Objects;
 import java.util.Optional;
 
 @Component
@@ -22,9 +25,17 @@ public Optional<Order> getById(Long orderId) {
   }
 
   public Optional<Order> getWithItemsById(Long orderId) {
+    Objects.requireNonNull(orderId, "주문 ID는 null일 수 없습니다.");
+
     return orderRepository.findWithItemsById(orderId);
   }
 
+  public Page<OrderListDto> findOrders(Long userId, Pageable pageable) {
+    Objects.requireNonNull(userId, "사용자 ID는 null일 수 없습니다.");
+    Objects.requireNonNull(pageable, "Pageable은 null일 수 없습니다.");
+    return orderRepository.findOrderList(userId, pageable);
+  }
+
   public Order create(Long userId, List<OrderItem> orderItems, LocalDateTime orderedAt) {
     Long totalAmount = calculateTotalAmount(orderItems);
 
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 8a9d1fb6b..58e99a201 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
@@ -21,10 +21,10 @@ public interface OrderJpaRepository extends JpaRepository<Order, Long> {
   Optional<Order> findWithItemsById(@Param("id") Long id);
 
   /**
-   * 사용자별 주문 목록 조회 (DTO 프로젝션, SIZE()로 개수만 조회)
+   * 사용자별 주문 목록 조회 (DTO 프로젝션, SIZE()로 개수만 조회, 최신순 정렬)
    */
   @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")
+      "FROM Order o WHERE o.userId = :userId ORDER BY o.orderedAt DESC, o.id DESC")
   Page<OrderListDto> findOrderListByUserId(@Param("userId") Long userId, Pageable pageable);
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderApiSpec.java
new file mode 100644
index 000000000..3ba2236d6
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderApiSpec.java
@@ -0,0 +1,59 @@
+package com.loopers.interfaces.api.order;
+
+import com.loopers.interfaces.api.ApiResponse;
+import com.loopers.interfaces.api.order.OrderDto.OrderCreateRequest;
+import com.loopers.interfaces.api.order.OrderDto.OrderDetailResponse;
+import com.loopers.interfaces.api.order.OrderDto.OrderListResponse;
+import com.loopers.interfaces.api.support.ApiHeaders;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.parameters.RequestBody;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestParam;
+
+@Tag(name = "Order API", description = "주문 API입니다.")
+public interface OrderApiSpec {
+
+  @Operation(
+      summary = "주문 생성",
+      description = "여러 상품을 한 번에 주문하고 포인트로 결제합니다."
+  )
+  ResponseEntity<ApiResponse<OrderDetailResponse>> createOrder(
+      @Parameter(description = "사용자 ID", required = true)
+      @RequestHeader(ApiHeaders.USER_ID) Long userId,
+
+      @RequestBody(description = "주문 생성 요청", required = true)
+      OrderCreateRequest request
+  );
+
+  @Operation(
+      summary = "주문 목록 조회",
+      description = "사용자의 주문 목록을 조회합니다. 페이지네이션을 지원합니다."
+  )
+  ApiResponse<OrderListResponse> retrieveOrders(
+      @Parameter(description = "사용자 ID", required = true)
+      @RequestHeader("X-USER-ID") Long userId,
+
+      @Parameter(description = "페이지 번호 (0부터 시작)", required = false)
+      @RequestParam(defaultValue = "0") int page,
+
+      @Parameter(description = "페이지당 주문 수", required = false)
+      @RequestParam(defaultValue = "20") int size
+  );
+
+  @Operation(
+      summary = "주문 상세 조회",
+      description = "주문 ID로 주문 상세 정보를 조회합니다. 본인의 주문만 조회할 수 있습니다."
+  )
+  ApiResponse<OrderDetailResponse> retrieveOrderDetail(
+      @Parameter(description = "사용자 ID", required = true)
+      @RequestHeader("X-USER-ID") Long userId,
+
+      @Parameter(description = "주문 ID", required = true)
+      @PathVariable Long orderId
+  );
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java
new file mode 100644
index 000000000..9f79d5c22
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java
@@ -0,0 +1,80 @@
+package com.loopers.interfaces.api.order;
+
+import com.loopers.application.order.OrderFacade;
+import com.loopers.domain.order.Order;
+import com.loopers.domain.order.OrderListDto;
+import com.loopers.domain.order.orderitem.OrderItemCommand;
+import com.loopers.domain.quantity.Quantity;
+import com.loopers.interfaces.api.ApiResponse;
+import com.loopers.interfaces.api.order.OrderDto.OrderCreateRequest;
+import com.loopers.interfaces.api.order.OrderDto.OrderDetailResponse;
+import com.loopers.interfaces.api.order.OrderDto.OrderListResponse;
+import com.loopers.interfaces.api.support.ApiHeaders;
+import jakarta.validation.Valid;
+import java.util.List;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/api/v1/orders")
+public class OrderController implements OrderApiSpec {
+
+  private final OrderFacade orderFacade;
+
+  @Override
+  @PostMapping
+  public ResponseEntity<ApiResponse<OrderDetailResponse>> createOrder(
+      @RequestHeader(ApiHeaders.USER_ID) Long userId,
+      @Valid @RequestBody OrderCreateRequest request
+  ) {
+    List<OrderItemCommand> commands = request.items().stream()
+        .map(item -> OrderItemCommand.of(item.productId(), Quantity.of(item.quantity())))
+        .toList();
+
+    Order order = orderFacade.createOrder(userId, commands);
+    OrderDetailResponse response = OrderDetailResponse.from(order);
+
+    return ResponseEntity.status(HttpStatus.CREATED)
+        .body(ApiResponse.success(response));
+  }
+
+  @Override
+  @GetMapping
+  public ApiResponse<OrderListResponse> retrieveOrders(
+      @RequestHeader("X-USER-ID") Long userId,
+      @RequestParam(defaultValue = "0") int page,
+      @RequestParam(defaultValue = "20") int size
+  ) {
+    Pageable pageable = PageRequest.of(page, size);
+
+    Page<OrderListDto> orderPage = orderFacade.retrieveOrders(userId, pageable);
+    OrderListResponse response = OrderListResponse.from(orderPage);
+
+    return ApiResponse.success(response);
+  }
+
+  @Override
+  @GetMapping("/{orderId}")
+  public ApiResponse<OrderDetailResponse> retrieveOrderDetail(
+      @RequestHeader("X-USER-ID") Long userId,
+      @PathVariable Long orderId
+  ) {
+    Order order = orderFacade.retrieveOrderDetail(userId, orderId);
+    OrderDetailResponse response = OrderDetailResponse.from(order);
+
+    return ApiResponse.success(response);
+  }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderDto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderDto.java
new file mode 100644
index 000000000..f9d793c7f
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderDto.java
@@ -0,0 +1,123 @@
+package com.loopers.interfaces.api.order;
+
+import com.loopers.domain.order.Order;
+import com.loopers.domain.order.OrderListDto;
+import com.loopers.domain.order.OrderStatus;
+import com.loopers.domain.order.orderitem.OrderItem;
+import jakarta.validation.constraints.NotEmpty;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Positive;
+import java.time.LocalDateTime;
+import java.util.List;
+import org.springframework.data.domain.Page;
+
+public class OrderDto {
+
+  public record OrderCreateRequest(
+      @NotNull(message = "주문 항목은 null일 수 없습니다.")
+      @NotEmpty(message = "주문 항목은 비어있을 수 없습니다.")
+      List<OrderItemRequest> items
+  ) {
+  }
+
+  public record OrderItemRequest(
+      @Positive(message = "상품 ID는 양수여야 합니다.")
+      Long productId,
+
+      @Positive(message = "수량은 양수여야 합니다.")
+      Long quantity
+  ) {
+  }
+
+  public record OrderListResponse(
+      List<OrderListItemResponse> orders,
+      PageInfo pageInfo
+  ) {
+
+    public static OrderListResponse from(Page<OrderListDto> page) {
+      List<OrderListItemResponse> orders = page.getContent().stream()
+          .map(OrderListItemResponse::from)
+          .toList();
+
+      PageInfo pageInfo = PageInfo.from(page);
+
+      return new OrderListResponse(orders, pageInfo);
+    }
+  }
+
+  public record OrderListItemResponse(
+      Long orderId,
+      LocalDateTime orderedAt,
+      Long totalAmount,
+      OrderStatus status,
+      Integer itemCount
+  ) {
+
+    public static OrderListItemResponse from(OrderListDto dto) {
+      return new OrderListItemResponse(
+          dto.id(),
+          dto.orderedAt(),
+          dto.totalAmount(),
+          dto.status(),
+          dto.itemCount()
+      );
+    }
+  }
+
+  public record OrderDetailResponse(
+      Long orderId,
+      LocalDateTime orderedAt,
+      OrderStatus status,
+      List<OrderItemResponse> items,
+      Long totalAmount
+  ) {
+
+    public static OrderDetailResponse from(Order order) {
+      List<OrderItemResponse> items = order.getItems().stream()
+          .map(OrderItemResponse::from)
+          .toList();
+
+      return new OrderDetailResponse(
+          order.getId(),
+          order.getOrderedAt(),
+          order.getStatus(),
+          items,
+          order.getTotalAmountValue()
+      );
+    }
+  }
+
+  public record OrderItemResponse(
+      Long productId,
+      String productName,
+      Long quantity,
+      Long price
+  ) {
+
+    public static OrderItemResponse from(OrderItem item) {
+      return new OrderItemResponse(
+          item.getProductId(),
+          item.getProductName(),
+          item.getQuantityValue(),
+          item.getOrderPriceValue()
+      );
+    }
+  }
+
+  public record PageInfo(
+      long totalElements,
+      int currentPage,
+      int totalPages,
+      int pageSize
+  ) {
+
+    public static PageInfo from(Page<?> page) {
+      return new PageInfo(
+          page.getTotalElements(),
+          page.getNumber(),
+          page.getTotalPages(),
+          page.getSize()
+      );
+    }
+  }
+}
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 1717b87d4..a02bbfcc6 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
@@ -1,7 +1,6 @@
 package com.loopers.application.order;
 
-import com.loopers.domain.brand.Brand;
-import com.loopers.domain.brand.BrandRepository;
+import com.loopers.support.test.IntegrationTestSupport;
 import com.loopers.domain.money.Money;
 import com.loopers.domain.order.Order;
 import com.loopers.domain.order.orderitem.OrderItemCommand;
@@ -17,13 +16,13 @@
 import com.loopers.domain.user.UserRepository;
 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.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;
@@ -31,82 +30,87 @@
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.junit.jupiter.api.Assertions.assertAll;
 
-@SpringBootTest
-@Transactional
 @DisplayName("OrderFacade 통합 테스트")
-class OrderFacadeIntegrationTest {
+class OrderFacadeIntegrationTest extends IntegrationTestSupport {
 
+  private static final LocalDate BIRTH_DATE_1990_01_01 = LocalDate.of(1990, 1, 1);
   @Autowired
   private OrderFacade orderFacade;
-
   @Autowired
   private ProductRepository productRepository;
-
-  @Autowired
-  private BrandRepository brandRepository;
-
   @Autowired
   private UserRepository userRepository;
-
   @Autowired
   private PointRepository pointRepository;
+  @Autowired
+  private DatabaseCleanUp databaseCleanUp;
 
   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, 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())
+        Product.of("상품1", Money.of(10000L), "설명1", Stock.of(10L), 1L)
     );
     product2 = productRepository.save(
-        Product.of("상품2", Money.of(30000L), "설명2", Stock.of(5L), brand.getId())
+        Product.of("상품2", Money.of(30000L), "설명2", Stock.of(5L), 1L)
     );
     pointRepository.save(Point.of(user.getId(), 100000L));
   }
 
+  @AfterEach
+  void tearDown() {
+    databaseCleanUp.truncateAllTables();
+  }
+
   @Nested
   @DisplayName("주문 생성")
   class CreateOrder {
 
     @Test
-    @DisplayName("유효한 주문 요청이면 주문이 생성되고 재고와 포인트가 차감된다")
-    void createOrder_success() {
+    @DisplayName("재고가 0인 상품을 주문하면 예외가 발생한다")
+    void createOrder_zeroStock() {
       // given
-      List<OrderItemCommand> commands = List.of(
-          OrderItemCommand.of(product1.getId(), Quantity.of(2L)),
-          OrderItemCommand.of(product2.getId(), Quantity.of(1L))
+      Product zeroStockProduct = productRepository.save(
+          Product.of("재고0상품", Money.of(10000L), "설명", Stock.of(0L), 1L)
       );
 
-      // when
-      Order order = orderFacade.createOrder(user.getId(), commands, ORDERED_AT_2025_10_30);
+      List<OrderItemCommand> commands = List.of(
+          OrderItemCommand.of(zeroStockProduct.getId(), Quantity.of(1L))
+      );
 
-      // then
-      assertThat(order.getTotalAmountValue()).isEqualTo(50000L);
-      assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING);
+      // when & then
+      assertThatThrownBy(() -> orderFacade.createOrder(user.getId(), commands))
+          .isInstanceOf(CoreException.class)
+          .hasFieldOrPropertyWithValue("errorType", ErrorType.INSUFFICIENT_STOCK);
+    }
 
-      // 재고 차감 확인 (실제 DB 조회)
-      Product updatedProduct1 = productRepository.findById(product1.getId()).orElseThrow();
-      assertThat(updatedProduct1.getStockValue()).isEqualTo(8L);
+    @Test
+    @DisplayName("포인트가 부족하면 예외가 발생한다")
+    void createOrder_insufficientPoint() {
+      // given
+      List<OrderItemCommand> commands = List.of(
+          OrderItemCommand.of(product1.getId(), Quantity.of(1L))
+      );
 
-      Product updatedProduct2 = productRepository.findById(product2.getId()).orElseThrow();
-      assertThat(updatedProduct2.getStockValue()).isEqualTo(4L);
+      Point point = pointRepository.findByUserId(user.getId()).orElseThrow();
+      point.deduct(95000L);
+      pointRepository.save(point);
 
-      // 포인트 차감 확인
-      Point updatedPoint = pointRepository.findByUserId(user.getId()).orElseThrow();
-      assertThat(updatedPoint.getAmountValue()).isEqualTo(50000L);
+      // when & then
+      assertThatThrownBy(() -> orderFacade.createOrder(user.getId(), commands))
+          .isInstanceOf(CoreException.class)
+          .hasFieldOrPropertyWithValue("errorType", ErrorType.INSUFFICIENT_POINT_BALANCE);
     }
 
+
     @Test
-    @DisplayName("재고가 부족하면 예외가 발생하고 트랜잭션이 롤백된다")
+    @DisplayName("재고가 부족하면 예외가 발생한다")
     void createOrder_insufficientStock() {
       // given
       List<OrderItemCommand> commands = List.of(
@@ -114,82 +118,85 @@ void createOrder_insufficientStock() {
       );
 
       // when & then
-      assertThatThrownBy(() -> orderFacade.createOrder(user.getId(), commands, ORDERED_AT_2025_10_30))
+      assertThatThrownBy(() -> orderFacade.createOrder(user.getId(), commands))
           .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() {
+    @DisplayName("재고가 부족하면 트랜잭션이 롤백된다")
+    void createOrder_rollbackWhenStockInsufficient() {
       // 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(), Quantity.of(1L))
+          OrderItemCommand.of(product1.getId(), Quantity.of(100L))
       );
 
       // when & then
-      assertThatThrownBy(() -> orderFacade.createOrder(user.getId(), commands, ORDERED_AT_2025_10_30))
+      assertThatThrownBy(() -> orderFacade.createOrder(user.getId(), commands))
           .isInstanceOf(CoreException.class)
           .hasFieldOrPropertyWithValue("errorType", ErrorType.INSUFFICIENT_STOCK);
+
+      Product unchangedProduct = productRepository.findById(product1.getId()).orElseThrow();
+      Point unchangedPoint = pointRepository.findByUserId(user.getId()).orElseThrow();
+
+      assertThat(unchangedProduct.getStockValue()).isEqualTo(10L);
+      assertThat(unchangedPoint.getAmountValue()).isEqualTo(100000L);
     }
 
     @Test
-    @DisplayName("포인트가 부족하면 예외가 발생하고 트랜잭션이 롤백된다")
-    void createOrder_insufficientPoint() {
+    @DisplayName("포인트가 부족하면 트랜잭션이 롤백된다")
+    void createOrder_rollbackWhenPointInsufficient() {
       // given
       List<OrderItemCommand> commands = List.of(
           OrderItemCommand.of(product1.getId(), Quantity.of(1L))
       );
 
-      // 포인트를 5000으로 설정 (부족)
       Point point = pointRepository.findByUserId(user.getId()).orElseThrow();
       point.deduct(95000L);
+      pointRepository.save(point);
 
       // when & then
-      assertThatThrownBy(() -> orderFacade.createOrder(user.getId(), commands, ORDERED_AT_2025_10_30))
+      assertThatThrownBy(() -> orderFacade.createOrder(user.getId(), commands))
           .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(unchangedProduct.getStockValue()).isEqualTo(10L);
       assertThat(unchangedPoint.getAmountValue()).isEqualTo(5000L);
     }
 
+
     @Test
-    @DisplayName("주문 생성 시 총액이 정확하게 계산되고 포인트가 차감된다")
-    void createOrder_totalAmountCalculation() {
+    @DisplayName("유효한 주문 요청이면 주문이 생성되고 재고와 포인트가 차감된다")
+    void createOrder_success() {
       // given
       List<OrderItemCommand> commands = List.of(
-          OrderItemCommand.of(product1.getId(), Quantity.of(3L)),
-          OrderItemCommand.of(product2.getId(), Quantity.of(2L))
+          OrderItemCommand.of(product1.getId(), Quantity.of(2L)),
+          OrderItemCommand.of(product2.getId(), Quantity.of(1L))
       );
 
       // when
-      Order order = orderFacade.createOrder(user.getId(), commands, ORDERED_AT_2025_10_30);
+      Order order = orderFacade.createOrder(user.getId(), commands);
 
       // then
-      assertThat(order.getTotalAmountValue()).isEqualTo(90000L);
-
-      // 포인트 차감 확인
+      Product updatedProduct1 = productRepository.findById(product1.getId()).orElseThrow();
+      Product updatedProduct2 = productRepository.findById(product2.getId()).orElseThrow();
       Point updatedPoint = pointRepository.findByUserId(user.getId()).orElseThrow();
-      assertThat(updatedPoint.getAmountValue()).isEqualTo(10000L);
+
+      assertAll(
+          () -> assertThat(order)
+              .extracting("totalAmountValue", "status")
+              .containsExactly(50000L, OrderStatus.PENDING),
+          () -> assertThat(updatedProduct1.getStockValue()).isEqualTo(8L),
+          () -> assertThat(updatedProduct2.getStockValue()).isEqualTo(4L),
+          () -> assertThat(updatedPoint.getAmountValue()).isEqualTo(50000L)
+      );
+
+
     }
+
   }
 }
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
index e3d9692ed..c7e55b45e 100644
--- 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
@@ -1,5 +1,8 @@
 package com.loopers.domain.order;
 
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
 import com.loopers.domain.brand.Brand;
 import com.loopers.domain.brand.BrandRepository;
 import com.loopers.domain.money.Money;
@@ -10,24 +13,17 @@
 import com.loopers.domain.stock.Stock;
 import com.loopers.support.error.CoreException;
 import com.loopers.support.error.ErrorType;
+import com.loopers.support.test.IntegrationTestSupport;
+import java.util.List;
+import java.util.Map;
 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 {
+class OrderCreateServiceTest extends IntegrationTestSupport {
 
   @Autowired
   private OrderCreateService orderCreateService;
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 ed9a31f84..02424cba2 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
@@ -1,30 +1,26 @@
 package com.loopers.domain.order;
 
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.tuple;
+import static org.junit.jupiter.api.Assertions.assertAll;
+
+import com.loopers.support.test.IntegrationTestSupport;
 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 java.time.LocalDateTime;
+import java.util.Optional;
 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;
-import static org.assertj.core.api.Assertions.tuple;
-import static org.junit.jupiter.api.Assertions.assertAll;
-
-@SpringBootTest
-@Transactional
 @DisplayName("OrderRepository 통합 테스트")
-class OrderRepositoryIntegrationTest {
+class OrderRepositoryIntegrationTest extends IntegrationTestSupport {
 
   private static final LocalDateTime ORDERED_AT_2025_10_30 = LocalDateTime.of(2025, 10, 30, 0, 0, 0);
 
@@ -53,11 +49,11 @@ void shouldReturnDtoPage() {
       Order order1 = Order.of(userId, OrderStatus.COMPLETED, 30000L, orderedAt);
       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 savedOrder1 = orderRepository.save(order1);
 
       Order order2 = Order.of(userId, OrderStatus.PENDING, 50000L, orderedAt.plusDays(1));
       order2.addItem(OrderItem.of(300L, "상품3", Quantity.of(5L), OrderPrice.of(10000L)));
-      orderRepository.save(order2);
+      Order savedOrder2 = orderRepository.save(order2);
 
       PageRequest pageRequest = PageRequest.of(0, 10);
 
@@ -67,10 +63,10 @@ void shouldReturnDtoPage() {
       // then
       assertThat(result.getContent())
           .hasSize(2)
-          .extracting("userId", "status", "itemCount", "totalAmount")
+          .extracting("id", "userId", "status", "itemCount", "totalAmount")
           .containsExactly(
-              tuple(userId, OrderStatus.COMPLETED, 2, 30000L),
-              tuple(userId, OrderStatus.PENDING, 1, 50000L)
+              tuple(savedOrder2.getId(), userId, OrderStatus.PENDING, 1, 50000L),
+              tuple(savedOrder1.getId(), userId, OrderStatus.COMPLETED, 2, 30000L)
           );
     }
 
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 b087a7fbb..1664e69c4 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
@@ -1,5 +1,6 @@
 package com.loopers.domain.order;
 
+import com.loopers.support.test.IntegrationTestSupport;
 import com.loopers.domain.order.orderitem.OrderItem;
 import com.loopers.domain.order.orderitem.OrderPrice;
 import com.loopers.domain.quantity.Quantity;
@@ -9,20 +10,17 @@
 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 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 통합 테스트")
-class OrderServiceIntegrationTest {
+class OrderServiceIntegrationTest extends IntegrationTestSupport {
 
   @Autowired
   private OrderService orderService;
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 5257226a8..8e261bb09 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
@@ -1,8 +1,17 @@
 package com.loopers.domain.order;
 
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.BDDMockito.then;
+import static org.mockito.Mockito.times;
+
 import com.loopers.domain.order.orderitem.OrderItem;
 import com.loopers.domain.order.orderitem.OrderPrice;
 import com.loopers.domain.quantity.Quantity;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Optional;
 import org.junit.jupiter.api.DisplayName;
 import org.junit.jupiter.api.Nested;
 import org.junit.jupiter.api.Test;
@@ -13,17 +22,6 @@
 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 {
diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java
new file mode 100644
index 000000000..4137b6618
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java
@@ -0,0 +1,334 @@
+package com.loopers.interfaces.api.order;
+
+import com.loopers.domain.brand.Brand;
+import com.loopers.domain.money.Money;
+import com.loopers.domain.order.Order;
+import com.loopers.domain.order.OrderStatus;
+import com.loopers.domain.order.orderitem.OrderItem;
+import com.loopers.domain.order.orderitem.OrderPrice;
+import com.loopers.domain.quantity.Quantity;
+import com.loopers.domain.point.Point;
+import com.loopers.domain.point.PointAmount;
+import com.loopers.domain.product.Product;
+import com.loopers.domain.stock.Stock;
+import com.loopers.domain.user.Gender;
+import com.loopers.domain.user.User;
+import com.loopers.infrastructure.brand.BrandJpaRepository;
+import com.loopers.infrastructure.order.OrderJpaRepository;
+import com.loopers.infrastructure.point.PointJpaRepository;
+import com.loopers.infrastructure.product.ProductJpaRepository;
+import com.loopers.infrastructure.user.UserJpaRepository;
+import com.loopers.interfaces.api.ApiResponse;
+import com.loopers.interfaces.api.order.OrderDto.OrderDetailResponse;
+import com.loopers.interfaces.api.order.OrderDto.OrderListResponse;
+import com.loopers.utils.DatabaseCleanUp;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.core.ParameterizedTypeReference;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.util.UriComponentsBuilder;
+
+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(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+class OrderApiE2ETest {
+
+  private static final String BASE_URL = "/api/v1/orders";
+  private static final LocalDate BIRTH_DATE_1990_01_01 = LocalDate.of(1990, 1, 1);
+  private static final LocalDateTime FIRST_ORDER_AT_2025_10_30_10_00 = LocalDateTime.of(2025, 10, 30, 10, 0, 0);
+  private static final LocalDateTime SECOND_ORDER_AT_2025_10_30_11_00 = LocalDateTime.of(2025, 10, 30, 11, 0, 0);
+  private static final ParameterizedTypeReference<ApiResponse<OrderListResponse>> ORDER_LIST_RESPONSE_TYPE =
+      new ParameterizedTypeReference<>() {
+      };
+  private static final ParameterizedTypeReference<ApiResponse<OrderDetailResponse>> ORDER_DETAIL_RESPONSE_TYPE =
+      new ParameterizedTypeReference<>() {
+      };
+
+  private final TestRestTemplate testRestTemplate;
+  private final OrderJpaRepository orderJpaRepository;
+  private final UserJpaRepository userJpaRepository;
+  private final BrandJpaRepository brandJpaRepository;
+  private final ProductJpaRepository productJpaRepository;
+  private final DatabaseCleanUp databaseCleanUp;
+
+  @Autowired
+  OrderApiE2ETest(
+      TestRestTemplate testRestTemplate,
+      OrderJpaRepository orderJpaRepository,
+      UserJpaRepository userJpaRepository,
+      BrandJpaRepository brandJpaRepository,
+      ProductJpaRepository productJpaRepository,
+      DatabaseCleanUp databaseCleanUp
+  ) {
+    this.testRestTemplate = testRestTemplate;
+    this.orderJpaRepository = orderJpaRepository;
+    this.userJpaRepository = userJpaRepository;
+    this.brandJpaRepository = brandJpaRepository;
+    this.productJpaRepository = productJpaRepository;
+    this.databaseCleanUp = databaseCleanUp;
+  }
+
+  @AfterEach
+  void tearDown() {
+    databaseCleanUp.truncateAllTables();
+  }
+
+  @Nested
+  @DisplayName("GET /api/v1/orders")
+  class GetOrders {
+
+    @Test
+    @DisplayName("사용자의 주문 목록과 페이징 정보를 반환한다")
+    void returnsOrdersWithPagination_whenValidUserId() {
+      User user = saveUser("user123", "user@example.com");
+      Brand brand = saveBrand("나이키");
+      Product product = saveProduct("운동화", 30000L, 50L, brand.getId());
+
+      Order first = saveOrder(user.getId(), OrderStatus.PENDING, 30000L, FIRST_ORDER_AT_2025_10_30_10_00);
+      addOrderItem(first, product.getId(), "운동화", 1L, 30000L);
+      orderJpaRepository.save(first);
+
+      Order second = saveOrder(user.getId(), OrderStatus.COMPLETED, 50000L, SECOND_ORDER_AT_2025_10_30_11_00);
+      addOrderItem(second, product.getId(), "운동화", 2L, 25000L);
+      orderJpaRepository.save(second);
+
+      HttpHeaders headers = new HttpHeaders();
+      headers.set("X-USER-ID", user.getId().toString());
+
+      ResponseEntity<ApiResponse<OrderListResponse>> response =
+          testRestTemplate.exchange(
+              UriComponentsBuilder.fromUriString(BASE_URL)
+                  .queryParam("page", 0)
+                  .queryParam("size", 20)
+                  .build()
+                  .toUri(),
+              HttpMethod.GET,
+              new HttpEntity<>(headers),
+              ORDER_LIST_RESPONSE_TYPE
+          );
+
+      assertAll(
+          () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK),
+          () -> assertThat(response.getBody()).isNotNull(),
+          () -> assertThat(response.getBody().data().orders())
+              .hasSize(2)
+              .element(0).extracting("orderId", "totalAmount", "status", "itemCount")
+              .containsExactly(second.getId(), 50000L, OrderStatus.COMPLETED, 1),
+          () -> assertThat(response.getBody().data().orders())
+              .element(1).extracting("orderId", "totalAmount", "status", "itemCount")
+              .containsExactly(first.getId(), 30000L, OrderStatus.PENDING, 1)
+      );
+    }
+
+    @Test
+    @DisplayName("페이지네이션이 올바르게 동작한다")
+    void returnsPaginatedOrders_whenPageSizeIsSet() {
+      User user = saveUser("user123", "user@example.com");
+      Brand brand = saveBrand("나이키");
+      Product product = saveProduct("운동화", 30000L, 50L, brand.getId());
+
+      for (int i = 0; i < 3; i++) {
+        Order order = saveOrder(user.getId(), OrderStatus.PENDING, 30000L, FIRST_ORDER_AT_2025_10_30_10_00);
+        addOrderItem(order, product.getId(), "운동화", 1L, 30000L);
+        orderJpaRepository.save(order);
+      }
+
+      HttpHeaders headers = new HttpHeaders();
+      headers.set("X-USER-ID", user.getId().toString());
+
+      ResponseEntity<ApiResponse<OrderListResponse>> response =
+          testRestTemplate.exchange(
+              UriComponentsBuilder.fromUriString(BASE_URL)
+                  .queryParam("page", 0)
+                  .queryParam("size", 2)
+                  .build()
+                  .toUri(),
+              HttpMethod.GET,
+              new HttpEntity<>(headers),
+              ORDER_LIST_RESPONSE_TYPE
+          );
+
+      assertAll(
+          () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK),
+          () -> assertThat(response.getBody()).isNotNull(),
+          () -> assertThat(response.getBody().data().orders()).hasSize(2),
+          () -> assertThat(response.getBody().data().pageInfo())
+              .extracting("totalElements", "currentPage", "totalPages", "pageSize")
+              .containsExactly(3L, 0, 2, 2)
+      );
+    }
+
+    @Test
+    @DisplayName("다른 사용자의 주문은 조회되지 않는다")
+    void returnsOnlyOwnOrders_whenMultipleUsersExist() {
+      User user1 = saveUser("user1", "user1@example.com");
+      User user2 = saveUser("user2", "user2@example.com");
+      Brand brand = saveBrand("나이키");
+      Product product = saveProduct("운동화", 30000L, 50L, brand.getId());
+
+      Order order1 = saveOrder(user1.getId(), OrderStatus.PENDING, 30000L, FIRST_ORDER_AT_2025_10_30_10_00);
+      addOrderItem(order1, product.getId(), "운동화", 1L, 30000L);
+      orderJpaRepository.save(order1);
+
+      Order order2 = saveOrder(user2.getId(), OrderStatus.PENDING, 50000L, FIRST_ORDER_AT_2025_10_30_10_00);
+      addOrderItem(order2, product.getId(), "운동화", 2L, 25000L);
+      orderJpaRepository.save(order2);
+
+      HttpHeaders headers = new HttpHeaders();
+      headers.set("X-USER-ID", user1.getId().toString());
+
+      ResponseEntity<ApiResponse<OrderListResponse>> response =
+          testRestTemplate.exchange(
+              UriComponentsBuilder.fromUriString(BASE_URL)
+                  .queryParam("page", 0)
+                  .queryParam("size", 20)
+                  .build()
+                  .toUri(),
+              HttpMethod.GET,
+              new HttpEntity<>(headers),
+              ORDER_LIST_RESPONSE_TYPE
+          );
+
+      assertAll(
+          () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK),
+          () -> assertThat(response.getBody()).isNotNull(),
+          () -> assertThat(response.getBody().data().orders())
+              .hasSize(1)
+              .element(0).extracting("orderId")
+              .isEqualTo(order1.getId())
+      );
+    }
+  }
+
+  @Nested
+  @DisplayName("GET /api/v1/orders/{orderId}")
+  class GetOrderDetail {
+
+    @Test
+    @DisplayName("주문 상세 정보를 반환한다")
+    void returnsOrderDetail_whenValidOrderId() {
+      User user = saveUser("user123", "user@example.com");
+      Brand brand = saveBrand("나이키");
+      Product product1 = saveProduct("운동화", 30000L, 50L, brand.getId());
+      Product product2 = saveProduct("슬리퍼", 20000L, 30L, brand.getId());
+
+      Order order = saveOrder(user.getId(), OrderStatus.PENDING, 50000L, FIRST_ORDER_AT_2025_10_30_10_00);
+      addOrderItem(order, product1.getId(), "운동화", 1L, 30000L);
+      addOrderItem(order, product2.getId(), "슬리퍼", 1L, 20000L);
+      orderJpaRepository.save(order);
+
+      HttpHeaders headers = new HttpHeaders();
+      headers.set("X-USER-ID", user.getId().toString());
+
+      ResponseEntity<ApiResponse<OrderDetailResponse>> response =
+          testRestTemplate.exchange(
+              BASE_URL + "/" + order.getId(),
+              HttpMethod.GET,
+              new HttpEntity<>(headers),
+              ORDER_DETAIL_RESPONSE_TYPE
+          );
+
+
+      assertAll(
+          () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK),
+          () -> assertThat(response.getBody()).isNotNull(),
+          () -> assertThat(response.getBody().data())
+              .extracting("orderId", "status", "totalAmount")
+              .containsExactly(order.getId(), OrderStatus.PENDING, 50000L),
+          () -> assertThat(response.getBody().data().items())
+              .hasSize(2)
+              .extracting("productId", "productName", "quantity", "price")
+              .containsExactlyInAnyOrder(
+                  tuple(product1.getId(), "운동화", 1L, 30000L),
+                  tuple(product2.getId(), "슬리퍼", 1L, 20000L)
+              )
+      );
+    }
+
+    @Test
+    @DisplayName("타인의 주문은 조회할 수 없다 (403 Forbidden)")
+    void returnsForbidden_whenAccessingOthersOrder() {
+      User user1 = saveUser("user1", "user1@example.com");
+      User user2 = saveUser("user2", "user2@example.com");
+      Brand brand = saveBrand("나이키");
+      Product product = saveProduct("운동화", 30000L, 50L, brand.getId());
+
+      Order order = saveOrder(user1.getId(), OrderStatus.PENDING, 30000L, FIRST_ORDER_AT_2025_10_30_10_00);
+      addOrderItem(order, product.getId(), "운동화", 1L, 30000L);
+      orderJpaRepository.save(order);
+
+      HttpHeaders headers = new HttpHeaders();
+      headers.set("X-USER-ID", user2.getId().toString());
+
+      ResponseEntity<ApiResponse<OrderDetailResponse>> response =
+          testRestTemplate.exchange(
+              BASE_URL + "/" + order.getId(),
+              HttpMethod.GET,
+              new HttpEntity<>(headers),
+              ORDER_DETAIL_RESPONSE_TYPE
+          );
+
+      assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
+    }
+
+    @Test
+    @DisplayName("존재하지 않는 주문은 404 Not Found를 반환한다")
+    void returnsNotFound_whenOrderNotExists() {
+      User user = saveUser("user123", "user@example.com");
+
+      HttpHeaders headers = new HttpHeaders();
+      headers.set("X-USER-ID", user.getId().toString());
+
+      ResponseEntity<ApiResponse<OrderDetailResponse>> response =
+          testRestTemplate.exchange(
+              BASE_URL + "/999999",
+              HttpMethod.GET,
+              new HttpEntity<>(headers),
+              ORDER_DETAIL_RESPONSE_TYPE
+          );
+
+      assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
+    }
+  }
+
+  private User saveUser(String loginId, String email) {
+    LocalDate currentDate = LocalDate.of(2025, 10, 30);
+    User user = User.of(loginId, email, BIRTH_DATE_1990_01_01, Gender.MALE, currentDate);
+    return userJpaRepository.save(user);
+  }
+
+  private Brand saveBrand(String name) {
+    Brand brand = Brand.of(name, name + " 브랜드");
+    return brandJpaRepository.save(brand);
+  }
+
+  private Product saveProduct(String name, Long price, Long stock, Long brandId) {
+    Product product = Product.of(name, Money.of(price), name + " 설명", Stock.of(stock), brandId);
+    return productJpaRepository.save(product);
+  }
+
+  private Order saveOrder(Long userId, OrderStatus status, Long totalAmount, LocalDateTime orderedAt) {
+    Order order = Order.of(userId, status, totalAmount, orderedAt);
+    return orderJpaRepository.save(order);
+  }
+
+  private void addOrderItem(Order order, Long productId, String productName, Long quantity,
+      Long price) {
+    OrderItem item = OrderItem.of(productId, productName, Quantity.of(quantity),
+        OrderPrice.of(price));
+    order.addItem(item);
+  }
+}

From 99a249e34f610c93222d416d77fecda6abf1d5f6 Mon Sep 17 00:00:00 2001
From: green <green@mobidoc.us>
Date: Wed, 19 Nov 2025 20:55:25 +0900
Subject: [PATCH 06/14] =?UTF-8?q?refactor:=20User/Point=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

Point 도메인:
- Point 엔티티 개선
- PointService 비즈니스 로직 강화

User 도메인:
- UserFacade 개선
- UserApiSpec 문서화 보완

테스트:
- 통합 테스트 개선
- E2E 테스트 보완
---
 .../java/com/loopers/application/user/UserFacade.java |  2 +-
 .../src/main/java/com/loopers/domain/point/Point.java | 11 -----------
 .../java/com/loopers/domain/point/PointService.java   |  2 ++
 .../com/loopers/interfaces/api/user/UserApiSpec.java  |  2 +-
 .../loopers/interfaces/api/user/UserController.java   |  4 ++--
 .../domain/point/PointServiceIntegrationTest.java     |  5 ++---
 .../domain/user/UserServiceIntegrationTest.java       |  6 +++---
 .../loopers/interfaces/api/point/PointApiE2ETest.java | 10 +++++-----
 .../loopers/interfaces/api/user/UserApiE2ETest.java   |  8 ++++----
 9 files changed, 20 insertions(+), 30 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 49eb178f7..42ca0d2a9 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 lookupUserProfile(String loginId) {
+    public UserResult retrieveUserProfile(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/domain/point/Point.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java
index d387d096d..9dccb5e53 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
@@ -59,17 +59,6 @@ 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.getValue();
   }
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 84629c6a7..31bb73f4e 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.Objects;
 import java.util.Optional;
 import lombok.RequiredArgsConstructor;
 import org.springframework.stereotype.Component;
@@ -28,6 +29,7 @@ public Point charge(Long userId, Long chargeAmount) {
 
     @Transactional
     public Point deduct(Long userId, Long deductAmount) {
+      Objects.requireNonNull(userId, "유저아이디는 null 일수 없습니다.");
         Point point = pointRepository.findByUserIdWithLock(userId)
             .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND,
                 "포인트 정보를 찾을 수 없습니다."));
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 34ab3524b..8080c4103 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
@@ -21,7 +21,7 @@ ApiResponse<UserDto.UserResponse> register(
         summary = "사용자 조회",
         description = "로그인 ID로 사용자 정보를 조회합니다."
     )
-    ApiResponse<UserDto.UserResponse> getUser(
+    ApiResponse<UserDto.UserResponse> retrieveUserProfile(
         @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 bcfadf9b7..d641aac0b 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
@@ -37,8 +37,8 @@ public ApiResponse<UserDto.UserResponse> register(
 
     @GetMapping("/{loginId}")
     @Override
-    public ApiResponse<UserDto.UserResponse> getUser(@PathVariable String loginId) {
-        UserResult userResult = userFacade.lookupUserProfile(loginId);
+    public ApiResponse<UserDto.UserResponse> retrieveUserProfile(@PathVariable String loginId) {
+        UserResult userResult = userFacade.retrieveUserProfile(loginId);
         UserDto.UserResponse response = UserDto.UserResponse.from(userResult);
         return ApiResponse.success(response);
     }
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 5e04cd434..9458508e9 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
@@ -1,5 +1,6 @@
 package com.loopers.domain.point;
 
+import com.loopers.support.test.IntegrationTestSupport;
 import com.loopers.domain.user.Gender;
 import com.loopers.domain.user.User;
 import com.loopers.domain.user.UserRepository;
@@ -11,7 +12,6 @@
 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.Clock;
 import java.time.Instant;
@@ -21,8 +21,7 @@
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
-@SpringBootTest
-class PointServiceIntegrationTest {
+class PointServiceIntegrationTest extends IntegrationTestSupport {
 
   private static final Clock TEST_CLOCK = Clock.fixed(
       Instant.parse("2025-10-30T00:00:00Z"),
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 404b4bc13..e5653a6c0 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
@@ -9,6 +9,7 @@
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 
+import com.loopers.support.test.IntegrationTestSupport;
 import com.loopers.support.error.CoreException;
 import com.loopers.support.error.ErrorType;
 import com.loopers.utils.DatabaseCleanUp;
@@ -20,10 +21,9 @@
 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 UserServiceIntegrationTest {
+@DisplayName("UserService 통합 테스트")
+class UserServiceIntegrationTest extends IntegrationTestSupport {
 
   @Autowired
   private UserRepository userRepository;
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 054eae9b6..89a8da7c8 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
@@ -70,7 +70,7 @@ void tearDown() {
   class GetPoint {
 
     @Test
-    @DisplayName("`X-USER-ID` 헤더가 없을 경우, `400 Bad Request` 응답을 반환한다.")
+    @DisplayName("`X-USER-ID` 헤더가 없으면 `400 Bad Request` 응답을 반환한다")
     void returnBadRequestException_whenUserIdHeaderIsMissing() {
       ParameterizedTypeReference<ApiResponse<PointResponse>> responseType =
           new ParameterizedTypeReference<>() {
@@ -90,7 +90,7 @@ void returnBadRequestException_whenUserIdHeaderIsMissing() {
     }
 
     @Test
-    @DisplayName("포인트 조회에 성공할 경우, 보유 포인트를 응답으로 반환한다.")
+    @DisplayName("포인트 조회에 성공하면 보유 포인트를 응답으로 반환한다")
     void returnsPoint_whenPointExists() {
       //given
       String loginId = "testuser";
@@ -130,7 +130,7 @@ void returnsPoint_whenPointExists() {
 
 
     @Test
-    @DisplayName("해당 ID의 회원이 존재하지 않을 경우, null을 반환한다.")
+    @DisplayName("해당 ID의 회원이 존재하지 않으면 null을 반환한다")
     void returnsNull_whenUserDoesNotExists() {
       //given
       Long userId = 999999L;
@@ -164,7 +164,7 @@ void returnsNull_whenUserDoesNotExists() {
   class ChargePoint {
 
     @Test
-    @DisplayName("존재하는 유저가 1000원을 충전할 경우, 충전된 보유 총량을 응답으로 반환한다")
+    @DisplayName("존재하는 유저가 1000원을 충전하면 충전된 보유 총량을 응답으로 반환한다")
     void returnsTotalBalance_whenChargingThousand() {
       // given
       String loginId = "testuser";
@@ -205,7 +205,7 @@ void returnsTotalBalance_whenChargingThousand() {
     }
 
     @Test
-    @DisplayName("존재하지 않는 유저로 요청할 경우, 404 NOT FOUND 응답을 반환한다.")
+    @DisplayName("존재하지 않는 유저로 요청하면 404 NOT FOUND 응답을 반환한다")
     void returnsNotFoundException_whenUserDoesNotExists() {
       // given
       Long userId = 999999L;
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 a5daba7a8..108a5adc8 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
@@ -52,7 +52,7 @@ void tearDown() {
   @Nested
   class Register {
 
-    @DisplayName("회원 가입이 성공할 경우, 생성된 유저 정보를 응답으로 반환한다.")
+    @DisplayName("회원 가입에 성공하면 생성된 유저 정보를 응답으로 반환한다")
     @Test
     void returnsCreatedUserInfo_whenRegistrationIsSuccessful() {
       // given
@@ -77,7 +77,7 @@ void returnsCreatedUserInfo_whenRegistrationIsSuccessful() {
       );
     }
 
-    @DisplayName("회원 가입 시에 성별이 없을 경우, 400 Bad Request 응답을 반환한다.")
+    @DisplayName("성별이 없으면 400 Bad Request 응답을 반환한다")
     @Test
     void throwsBadRequest_whenGenderIsMissing() {
       // given
@@ -99,7 +99,7 @@ void throwsBadRequest_whenGenderIsMissing() {
   @Nested
   class GetUser {
 
-    @DisplayName("내 정보 조회에 성공할 경우, 해당하는 유저 정보를 응답으로 반환한다.")
+    @DisplayName("내 정보 조회에 성공하면 해당 유저 정보를 응답으로 반환한다")
     @Test
     void returnsUserInfo_whenUserExists() {
       // given
@@ -126,7 +126,7 @@ void returnsUserInfo_whenUserExists() {
       );
     }
 
-    @DisplayName("존재하지 않는 ID로 조회할 경우, 404 Not Found 응답을 반환한다.")
+    @DisplayName("존재하지 않는 ID로 조회하면 404 Not Found 응답을 반환한다")
     @Test
     void returnsNotFound_whenUserDoesNotExist() {
       // given

From 3f9a487f95648a94a216a44941b35a1a361afb6e Mon Sep 17 00:00:00 2001
From: green <green@mobidoc.us>
Date: Wed, 19 Nov 2025 20:55:33 +0900
Subject: [PATCH 07/14] =?UTF-8?q?test:=20=EB=8F=99=EC=8B=9C=EC=84=B1=20?=
 =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

LikeFacadeRaceConditionTest:
- 동시 좋아요 토글 시 카운트 정확성 검증
- ExecutorService 기반 멀티스레드 테스트

OrderFacadeRaceConditionTest:
- 동시 주문 시 재고 음수 방지 검증
- 비관적 락 동작 확인
---
 .../like/LikeFacadeRaceConditionTest.java     | 139 +++++++
 .../order/OrderFacadeRaceConditionTest.java   | 367 ++++++++++--------
 2 files changed, 339 insertions(+), 167 deletions(-)
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeRaceConditionTest.java

diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeRaceConditionTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeRaceConditionTest.java
new file mode 100644
index 000000000..2936c5354
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeRaceConditionTest.java
@@ -0,0 +1,139 @@
+package com.loopers.application.like;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+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.test.IntegrationTestSupport;
+import com.loopers.utils.DatabaseCleanUp;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+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;
+
+@DisplayName("LikeFacade 동시성 테스트")
+public class LikeFacadeRaceConditionTest extends IntegrationTestSupport {
+
+  @Autowired
+  private LikeFacade likeFacade;
+
+  @Autowired
+  private ProductRepository productRepository;
+
+  @Autowired
+  private ProductLikeRepository productLikeRepository;
+
+  @Autowired
+  private DatabaseCleanUp databaseCleanUp;
+
+  @AfterEach
+  void tearDown() {
+    databaseCleanUp.truncateAllTables();
+  }
+
+  @Nested
+  @DisplayName("좋아요 추가 동시성")
+  class ConcurrentLike {
+
+    @Test
+    @DisplayName("10명이 동시에 같은 상품에 좋아요 시 카운트가 정확히 10으로 증가한다")
+    void countsCorrectly() {
+      // given
+      Product product = productRepository.save(
+          Product.of("상품1", Money.of(10000L), "설명1", Stock.of(100L), 1L)
+      );
+
+      // when
+      List<CompletableFuture<Void>> futures = new ArrayList<>();
+      for (int i = 1; i <= 10; i++) {
+        Long userId = (long) i;
+        futures.add(asyncExecute(() -> likeFacade.registerProductLike(userId, product.getId())));
+      }
+
+      CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
+
+      // then
+      Product updatedProduct = productRepository.findById(product.getId()).orElseThrow();
+      long likeCount = updatedProduct.getLikeCount();
+      assertThat(likeCount).isEqualTo(10L);
+    }
+
+    @Test
+    @DisplayName("같은 유저가 동시에 5번 좋아요 시 중복 방지되어 1개만 등록된다")
+    void preventsDuplicate() {
+      // given
+      Product product = productRepository.save(
+          Product.of("상품1", Money.of(10000L), "설명1", Stock.of(100L), 1L)
+      );
+      Long userId = 1L;
+
+      // when
+      List<CompletableFuture<Void>> futures = new ArrayList<>();
+      for (int i = 0; i < 5; i++) {
+        futures.add(asyncExecute(() -> likeFacade.registerProductLike(userId, product.getId())));
+      }
+
+      CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
+
+      // then
+      Product updatedProduct = productRepository.findById(product.getId()).orElseThrow();
+      long likeCount = updatedProduct.getLikeCount();
+      assertThat(likeCount).isEqualTo(1L);
+
+      List<ProductLike> likes = productLikeRepository.findByUserIdAndProductIdIn(userId, List.of(product.getId()));
+      assertThat(likes).hasSize(1);
+    }
+
+  }
+
+  @Nested
+  @DisplayName("좋아요 취소 동시성")
+  class ConcurrentUnlike {
+
+    @Test
+    @DisplayName("10명이 동시에 좋아요 취소 시 카운트가 정확히 0으로 감소한다")
+    void countsCorrectly() {
+      // given
+      Product product = productRepository.save(
+          Product.of("상품1", Money.of(10000L), "설명1", Stock.of(100L), 1L)
+      );
+
+      for (int i = 1; i <= 10; i++) {
+        Long userId = (long) i;
+        likeFacade.registerProductLike(userId, product.getId());
+      }
+
+      // when
+      List<CompletableFuture<Void>> futures = new ArrayList<>();
+      for (int i = 1; i <= 10; i++) {
+        Long userId = (long) i;
+        futures.add(asyncExecute(() -> likeFacade.cancelProductLike(userId, product.getId())));
+      }
+
+      CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
+
+      // then
+      Product updatedProduct = productRepository.findById(product.getId()).orElseThrow();
+      long likeCount = updatedProduct.getLikeCount();
+      assertThat(likeCount).isZero();
+    }
+  }
+
+  private CompletableFuture<Void> asyncExecute(Runnable task) {
+    return CompletableFuture.runAsync(() -> {
+      try {
+        task.run();
+      } catch (Exception e) {
+        // 동시성 테스트에서는 예외 무시
+      }
+    });
+  }
+}
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 4a00fe1b1..e25d89012 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
@@ -1,7 +1,7 @@
 package com.loopers.application.order;
 
-import com.loopers.domain.brand.Brand;
-import com.loopers.domain.brand.BrandRepository;
+import static org.assertj.core.api.Assertions.assertThat;
+
 import com.loopers.domain.money.Money;
 import com.loopers.domain.order.orderitem.OrderItemCommand;
 import com.loopers.domain.point.Point;
@@ -13,26 +13,22 @@
 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.test.IntegrationTestSupport;
 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;
-import org.springframework.boot.test.context.SpringBootTest;
-
 import java.time.LocalDate;
 import java.time.LocalDateTime;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.atomic.AtomicInteger;
+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 static org.assertj.core.api.Assertions.assertThat;
-
-@SpringBootTest
 @DisplayName("OrderFacade 동시성 테스트")
-class OrderFacadeRaceConditionTest {
+class OrderFacadeRaceConditionTest extends IntegrationTestSupport {
 
   @Autowired
   private OrderFacade orderFacade;
@@ -40,9 +36,6 @@ class OrderFacadeRaceConditionTest {
   @Autowired
   private ProductRepository productRepository;
 
-  @Autowired
-  private BrandRepository brandRepository;
-
   @Autowired
   private UserRepository userRepository;
 
@@ -54,181 +47,221 @@ class OrderFacadeRaceConditionTest {
 
   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);
+  private static final LocalDate JOINED_AT_2025_10_30 = LocalDate.of(2025, 10, 30);
+
+  private static final long POINT_BALANCE_50_000 = 50000L;
+  private static final long BRAND_ID = 1L;
 
   @AfterEach
   void tearDown() {
     databaseCleanUp.truncateAllTables();
   }
 
-  @Test
-  @DisplayName("동시에 2개 주문 시 하나만 성공하고 재고가 음수가 되지 않음")
-  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, 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));
-
-    List<OrderItemCommand> commands = List.of(
-        OrderItemCommand.of(product.getId(), Quantity.of(1L))
-    );
-
-    List<CompletableFuture<Void>> futures = new ArrayList<>();
+  @DisplayName("재고 동시성 테스트")
+  @Nested
+  class StockConcurrency {
 
-    futures.add(CompletableFuture.runAsync(() -> {
-      try {
-        orderFacade.createOrder(user1.getId(), commands, ORDERED_AT_2025_10_30);
-      } catch (CoreException ignored) {
-        // 재고 부족 시 예외 발생 예상
-      }
-    }));
+    @Test
+    @DisplayName("재고 1개 상품에 2명이 동시 주문 시 1개만 성공하고 재고가 0이 된다")
+    void concurrentOrders_onlyOneSucceeds() {
+      // given
+      Product product = productRepository.save(
+          Product.of("재고1개상품", Money.of(10000L), "재고 1개 상품", Stock.of(1L), BRAND_ID)
+      );
 
-    futures.add(CompletableFuture.runAsync(() -> {
-      try {
-        orderFacade.createOrder(user2.getId(), commands, ORDERED_AT_2025_10_30);
-      } catch (CoreException ignored) {
-        // 재고 부족 시 예외 발생 예상
-      }
-    }));
+      User user1 = createUserWithPoint("user1", "user1@test.com", Gender.MALE, POINT_BALANCE_50_000);
+      User user2 = createUserWithPoint("user2", "user2@test.com", Gender.FEMALE, POINT_BALANCE_50_000);
 
-    CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
+      List<OrderItemCommand> commands = List.of(
+          OrderItemCommand.of(product.getId(), Quantity.of(1L))
+      );
 
-    Product updatedProduct = productRepository.findById(product.getId()).orElseThrow();
-    assertThat(updatedProduct).extracting("stockValue").isEqualTo(0L);
-  }
-
-  @Test
-  @DisplayName("동시에 10개 주문 시 재고 5개 상품은 5개만 성공")
-  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())
-    );
+      // when
+      List<CompletableFuture<Void>> futures = new ArrayList<>();
+      futures.add(asyncExecute(() -> orderFacade.createOrder(user1.getId(), commands)));
+      futures.add(asyncExecute(() -> orderFacade.createOrder(user2.getId(), commands)));
 
-    int threadCount = 10;
+      CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
 
-    List<OrderItemCommand> commands = List.of(
-        OrderItemCommand.of(product.getId(), Quantity.of(1L))
-    );
-
-    AtomicInteger counter = new AtomicInteger(0);
-    List<CompletableFuture<Void>> futures = new ArrayList<>();
-
-    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) {
-          // 재고 부족 시 예외 발생 예상
-        }
-      }));
+      // then
+      Product updatedProduct = productRepository.findById(product.getId()).orElseThrow();
+      long remainingStock = updatedProduct.getStockValue();
+      assertThat(remainingStock).isZero();
     }
 
-    CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
+    @Test
+    @DisplayName("재고 5개 상품에 10명이 동시 주문 시 5개만 성공하고 재고가 0이 된다")
+    void concurrentOrders_multipleThreads() {
+      // given
+      Product product = productRepository.save(
+          Product.of("재고5개상품", Money.of(5000L), "재고 5개 상품", Stock.of(5L), BRAND_ID)
+      );
+
+      List<OrderItemCommand> commands = List.of(
+          OrderItemCommand.of(product.getId(), Quantity.of(1L))
+      );
+
+      // when
+      AtomicInteger userIdCounter = new AtomicInteger(10);
+      List<CompletableFuture<Void>> futures = new ArrayList<>();
+
+      for (int i = 0; i < 10; i++) {
+        futures.add(asyncExecute(() -> {
+          int userId = userIdCounter.getAndIncrement();
+          String loginId = "user" + userId;
+          User user = createUserWithPoint(loginId, loginId + "@test.com", Gender.MALE, POINT_BALANCE_50_000);
+          orderFacade.createOrder(user.getId(), commands);
+        }));
+      }
 
-    Product updatedProduct = productRepository.findById(product.getId()).orElseThrow();
-    assertThat(updatedProduct).extracting("stockValue").isEqualTo(0L);
-  }
+      CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
 
-  @Test
-  @DisplayName("동시 주문 시 재고가 음수가 되지 않음")
-  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())
-    );
+      // then
+      Product updatedProduct = productRepository.findById(product.getId()).orElseThrow();
+      long remainingStock = updatedProduct.getStockValue();
+      assertThat(remainingStock).isZero();
+    }
 
-    int threadCount = 5;
+    @Test
+    @DisplayName("재고 3개 상품에 5명이 동시 주문 시 재고가 음수가 되지 않는다")
+    void concurrentOrders_stockNeverNegative() {
+      // given
+      Product product = productRepository.save(
+          Product.of("재고3개상품", Money.of(3000L), "재고 3개 상품", Stock.of(3L), BRAND_ID)
+      );
+
+      List<OrderItemCommand> commands = List.of(
+          OrderItemCommand.of(product.getId(), Quantity.of(1L))
+      );
+
+      // when
+      AtomicInteger userIdCounter = new AtomicInteger(20);
+      List<CompletableFuture<Void>> futures = new ArrayList<>();
+
+      for (int i = 0; i < 5; i++) {
+        futures.add(asyncExecute(() -> {
+          int userId = userIdCounter.getAndIncrement();
+          String loginId = "user" + userId;
+          User user = createUserWithPoint(loginId, loginId + "@test.com", Gender.MALE, POINT_BALANCE_50_000);
+          orderFacade.createOrder(user.getId(), commands);
+        }));
+      }
 
-    List<OrderItemCommand> commands = List.of(
-        OrderItemCommand.of(product.getId(), Quantity.of(1L))
-    );
+      CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
 
-    AtomicInteger counter = new AtomicInteger(100);
-    List<CompletableFuture<Void>> futures = new ArrayList<>();
-
-    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) {
-          // 재고 부족 시 예외 발생 예상
-        }
-      }));
+      // then
+      Product finalProduct = productRepository.findById(product.getId()).orElseThrow();
+      long remainingStock = finalProduct.getStockValue();
+      assertThat(remainingStock).isGreaterThanOrEqualTo(0L);
     }
-
-    CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
-
-    Product finalProduct = productRepository.findById(product.getId()).orElseThrow();
-    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);
-    }));
+  @DisplayName("포인트 동시성 테스트")
+  @Nested
+  class PointConcurrency {
+
+    @Test
+    @DisplayName("동일한 유저가 서로 다른 상품 2개를 동시 주문 시 포인트가 정확히 차감된다")
+    void concurrentOrders_sameUser_differentProducts() {
+      // given
+      Product product1 = productRepository.save(
+          Product.of("상품1", Money.of(10000L), "상품 1", Stock.of(10L), BRAND_ID)
+      );
+      Product product2 = productRepository.save(
+          Product.of("상품2", Money.of(20000L), "상품 2", Stock.of(10L), BRAND_ID)
+      );
+
+      User user = createUserWithPoint("user30", "user30@test.com", Gender.MALE, POINT_BALANCE_50_000);
+
+      List<OrderItemCommand> commands1 = List.of(
+          OrderItemCommand.of(product1.getId(), Quantity.of(1L))
+      );
+      List<OrderItemCommand> commands2 = List.of(
+          OrderItemCommand.of(product2.getId(), Quantity.of(1L))
+      );
+
+      // when
+      List<CompletableFuture<Void>> futures = new ArrayList<>();
+      futures.add(asyncExecute(() -> orderFacade.createOrder(user.getId(), commands1)));
+      futures.add(asyncExecute(() -> orderFacade.createOrder(user.getId(), commands2)));
+
+      CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
+
+      // then
+      Point finalPoint = pointRepository.findByUserId(user.getId()).orElseThrow();
+      long remainingBalance = finalPoint.getAmountValue();
+      assertThat(remainingBalance).isEqualTo(20000L);
+    }
+  }
 
-    futures.add(CompletableFuture.runAsync(() -> {
-      orderFacade.createOrder(user2.getId(), commands2, ORDERED_AT_2025_10_30);
-    }));
+  @DisplayName("데드락 방지 테스트")
+  @Nested
+  class DeadlockPrevention {
+
+    @Test
+    @DisplayName("여러 상품 역순 동시 주문 시 데드락이 발생하지 않는다 (ORDER BY 검증)")
+    void concurrentOrders_multipleProducts_noDeadlock() {
+      // given
+      Product product1 = productRepository.save(
+          Product.of("상품1", Money.of(1000L), "상품 1", Stock.of(10L), BRAND_ID)
+      );
+      Product product2 = productRepository.save(
+          Product.of("상품2", Money.of(2000L), "상품 2", Stock.of(10L), BRAND_ID)
+      );
+      Product product3 = productRepository.save(
+          Product.of("상품3", Money.of(3000L), "상품 3", Stock.of(10L), BRAND_ID)
+      );
+
+      User user1 = createUserWithPoint("user40", "user40@test.com", Gender.MALE, POINT_BALANCE_50_000);
+      User user2 = createUserWithPoint("user41", "user41@test.com", Gender.FEMALE, POINT_BALANCE_50_000);
+
+      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))
+      );
+
+      // when
+      List<CompletableFuture<Void>> futures = new ArrayList<>();
+      futures.add(asyncExecute(() -> orderFacade.createOrder(user1.getId(), commands1)));
+      futures.add(asyncExecute(() -> orderFacade.createOrder(user2.getId(), commands2)));
+
+      CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
+
+      // then
+      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);
+    }
+  }
 
-    CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
+  private CompletableFuture<Void> asyncExecute(Runnable task) {
+    return CompletableFuture.runAsync(() -> {
+      try {
+        task.run();
+      } catch (Exception e) {
+        // 동시성 테스트에서는 예외 무시
+      }
+    });
+  }
 
-    List<Product> finalProducts = List.of(
-        productRepository.findById(product1.getId()).orElseThrow(),
-        productRepository.findById(product2.getId()).orElseThrow(),
-        productRepository.findById(product3.getId()).orElseThrow()
+  private User createUserWithPoint(String loginId, String email, Gender gender, Long pointBalance) {
+    User user = userRepository.save(
+        User.of(loginId, email, BIRTH_DATE_1990_01_01, gender, JOINED_AT_2025_10_30)
     );
-
-    assertThat(finalProducts)
-        .extracting("stockValue")
-        .containsExactly(8L, 8L, 8L);
+    pointRepository.save(Point.of(user.getId(), pointBalance));
+    return user;
   }
-}
\ No newline at end of file
+}

From 8634fbdb464f467a7445df3a3c52a5299ee7c70d Mon Sep 17 00:00:00 2001
From: green <green@mobidoc.us>
Date: Fri, 21 Nov 2025 10:30:17 +0900
Subject: [PATCH 08/14] =?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?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

API Support:
- PageInfo: 페이지네이션 응답 공통 DTO 추가

Test Support:
- IntegrationTestSupport: 통합 테스트 공통 인프라 개선
---
 .../interfaces/api/support/PageInfo.java      | 36 +++++++++++++++++++
 .../support/test/IntegrationTestSupport.java  | 10 ++++++
 2 files changed, 46 insertions(+)
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/support/PageInfo.java

diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/support/PageInfo.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/support/PageInfo.java
new file mode 100644
index 000000000..0cd6be15a
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/support/PageInfo.java
@@ -0,0 +1,36 @@
+package com.loopers.interfaces.api.support;
+
+import java.util.Objects;
+import org.springframework.data.domain.Page;
+
+public record PageInfo(
+    long totalElements,
+    int currentPage,
+    int totalPages,
+    int pageSize,
+    String sort
+) {
+
+  public static PageInfo from(Page<?> page) {
+    Objects.requireNonNull(page, "page는 null일 수 없습니다.");
+    return new PageInfo(
+        page.getTotalElements(),
+        page.getNumber(),
+        page.getTotalPages(),
+        page.getSize(),
+        page.getSort().toString()
+    );
+  }
+
+  public static PageInfo from(Page<?> page, String sortDescription) {
+    Objects.requireNonNull(page, "page는 null일 수 없습니다.");
+    Objects.requireNonNull(sortDescription, "sortDescription은 null일 수 없습니다.");
+    return new PageInfo(
+        page.getTotalElements(),
+        page.getNumber(),
+        page.getTotalPages(),
+        page.getSize(),
+        sortDescription
+    );
+  }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/support/test/IntegrationTestSupport.java b/apps/commerce-api/src/test/java/com/loopers/support/test/IntegrationTestSupport.java
index 595d9dd04..6612ef70d 100644
--- a/apps/commerce-api/src/test/java/com/loopers/support/test/IntegrationTestSupport.java
+++ b/apps/commerce-api/src/test/java/com/loopers/support/test/IntegrationTestSupport.java
@@ -1,8 +1,18 @@
 package com.loopers.support.test;
 
+import com.loopers.utils.DatabaseCleanUp;
+import org.junit.jupiter.api.AfterEach;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.boot.test.context.SpringBootTest;
 
 @SpringBootTest
 public abstract class IntegrationTestSupport {
 
+  @Autowired
+  private DatabaseCleanUp databaseCleanUp;
+
+  @AfterEach
+  void tearDown() {
+    databaseCleanUp.truncateAllTables();
+  }
 }

From 0be7f01cffe0c18684e957674cce497506b16c16 Mon Sep 17 00:00:00 2001
From: green <green@mobidoc.us>
Date: Fri, 21 Nov 2025 10:38:27 +0900
Subject: [PATCH 09/14] =?UTF-8?q?refactor:=20Product=20=EB=8F=84=EB=A9=94?=
 =?UTF-8?q?=EC=9D=B8=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20API=20=EB=A6=AC?=
 =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Application Layer:
- ProductFacade: @Transactional(readOnly = true) 추가

Domain Layer:
- ProductService: 미사용 메서드 제거 (findByIdIn)

API Layer:
- ProductController: ApiHeaders 상수 사용, "lastest" 오타 수정
- ProductApiSpec: API 문서 개선
- ProductDto: DTO 구조 개선

Test:
- ProductFacadeIntegrationTest: 통합 테스트 개선
- ProductApiE2ETest: E2E 테스트 개선
---
 .../application/product/ProductFacade.java    |  3 ++
 .../domain/product/ProductService.java        |  6 ----
 .../product/ProductJpaRepository.java         |  3 +-
 .../api/product/ProductApiSpec.java           |  7 +++--
 .../api/product/ProductController.java        |  9 +++---
 .../interfaces/api/product/ProductDto.java    | 31 ++++++-------------
 .../product/ProductFacadeIntegrationTest.java | 10 ------
 .../api/product/ProductApiE2ETest.java        |  5 +--
 8 files changed, 26 insertions(+), 48 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 b26246b5e..1548efc5f 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
@@ -15,6 +15,7 @@
 import org.springframework.data.domain.Page;
 import org.springframework.data.domain.Pageable;
 import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
 
 @Component
 @RequiredArgsConstructor
@@ -24,6 +25,7 @@ public class ProductFacade {
   private final BrandService brandService;
   private final ProductLikeService productLikeService;
 
+  @Transactional(readOnly = true)
   public Page<ProductDetail> searchProductDetails(Long brandId, Long userId, Pageable pageable) {
     Page<Product> productPage = productService.findProducts(brandId, pageable);
     List<Product> products = productPage.getContent();
@@ -62,6 +64,7 @@ private Page<ProductDetail> getProductDetails(Page<Product> productPage, Map<Lon
     ));
   }
 
+  @Transactional(readOnly = true)
   public ProductDetail retrieveProductDetail(Long productId, Long userId) {
     Product product = productService.getById(productId)
         .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
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 4874f92ba..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
@@ -26,12 +26,6 @@ public List<Product> findByIdsWithLock(List<Long> productIds) {
     return productRepository.findAllByIdWithLock(distinctIds);
   }
 
-  public List<Product> findByIdIn(List<Long> productIds) {
-    Objects.requireNonNull(productIds, "상품 ID 목록은 null일 수 없습니다");
-    List<Long> distinctIds = productIds.stream().distinct().toList();
-    return productRepository.findByIdIn(distinctIds);
-  }
-
   public Page<Product> findProducts(Long brandId, Pageable pageable) {
     return brandId != null
         ? productRepository.findByBrandId(brandId, pageable)
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 ba123a877..7de0aa1be 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
@@ -20,7 +20,8 @@ public interface ProductJpaRepository extends JpaRepository<Product, Long> {
   @Query("SELECT p FROM Product p WHERE p.id IN :ids ORDER BY p.id ASC")
   List<Product> findAllByIdWithLock(@Param("ids") List<Long> ids);
 
-  List<Product> findByIdIn(List<Long> ids);
+  @Query("SELECT p FROM Product p WHERE p.id IN :ids ORDER BY p.id ASC")
+  List<Product> findByIdIn(@Param("ids") List<Long> ids);
 
   @Modifying(clearAutomatically = true, flushAutomatically = true)
   @Query("UPDATE Product p SET p.likeCount = p.likeCount + 1 WHERE p.id = :productId")
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductApiSpec.java
index 20f6dd97f..38b551495 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductApiSpec.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductApiSpec.java
@@ -3,6 +3,7 @@
 import com.loopers.interfaces.api.ApiResponse;
 import com.loopers.interfaces.api.product.ProductDto.ProductListResponse;
 import com.loopers.interfaces.api.product.ProductDto.ProductResponse;
+import com.loopers.interfaces.api.support.ApiHeaders;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.Parameter;
 import io.swagger.v3.oas.annotations.tags.Tag;
@@ -19,7 +20,7 @@ public interface ProductApiSpec {
   )
   ApiResponse<ProductListResponse> searchProductDetails(
       @Parameter(description = "사용자 ID (선택, 비회원 가능)", required = false)
-      @RequestHeader(value = "X-USER-ID", required = false) Long userId,
+      @RequestHeader(value = ApiHeaders.USER_ID, required = false) Long userId,
 
       @Parameter(description = "브랜드 ID (선택, 특정 브랜드 필터링)", required = false)
       @RequestParam(required = false) Long brandId,
@@ -40,9 +41,9 @@ ApiResponse<ProductListResponse> searchProductDetails(
   )
   ApiResponse<ProductResponse> retrieveProductDetail(
       @Parameter(description = "사용자 ID (선택, 비회원 가능)", required = false)
-      @RequestHeader(value = "X-USER-ID", required = false) Long userId,
+      @RequestHeader(value = ApiHeaders.USER_ID, required = false) Long userId,
 
       @Parameter(description = "상품 ID", required = true)
-      @PathVariable Long productId
+      @PathVariable("productId") Long productId
   );
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java
index 6a83fbb25..aaf4963dc 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java
@@ -5,6 +5,7 @@
 import com.loopers.interfaces.api.ApiResponse;
 import com.loopers.interfaces.api.product.ProductDto.ProductListResponse;
 import com.loopers.interfaces.api.product.ProductDto.ProductResponse;
+import com.loopers.interfaces.api.support.ApiHeaders;
 import lombok.RequiredArgsConstructor;
 import org.springframework.data.domain.Page;
 import org.springframework.data.domain.PageRequest;
@@ -26,9 +27,9 @@ public class ProductController implements ProductApiSpec {
   @Override
   @GetMapping
   public ApiResponse<ProductListResponse> searchProductDetails(
-      @RequestHeader(value = "X-USER-ID", required = false) Long userId,
+      @RequestHeader(value = ApiHeaders.USER_ID, required = false) Long userId,
       @RequestParam(required = false) Long brandId,
-      @RequestParam(defaultValue = "lastest") ProductSortType sort,
+      @RequestParam(defaultValue = "latest") ProductSortType sort,
       @RequestParam(defaultValue = "0") int page,
       @RequestParam(defaultValue = "20") int size
   ) {
@@ -44,8 +45,8 @@ public ApiResponse<ProductListResponse> searchProductDetails(
   @Override
   @GetMapping("/{productId}")
   public ApiResponse<ProductResponse> retrieveProductDetail(
-      @RequestHeader(value = "X-USER-ID", required = false) Long userId,
-      @PathVariable Long productId
+      @RequestHeader(value = ApiHeaders.USER_ID, required = false) Long userId,
+      @PathVariable("productId") Long productId
   ) {
     ProductDetail detail = productFacade.retrieveProductDetail(productId, userId);
     ProductResponse response = ProductResponse.from(detail);
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductDto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductDto.java
index 26c3817bd..c5b953bb5 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductDto.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductDto.java
@@ -1,17 +1,23 @@
 package com.loopers.interfaces.api.product;
 
 import com.loopers.application.product.ProductDetail;
+import com.loopers.interfaces.api.support.PageInfo;
 import java.util.List;
+import java.util.Objects;
 import org.springframework.data.domain.Page;
 
 public class ProductDto {
 
+  private ProductDto() {
+  }
+
   public record ProductListResponse(
       List<ProductListItemResponse> products,
       PageInfo pageInfo
   ) {
 
     public static ProductListResponse from(Page<ProductDetail> page) {
+      Objects.requireNonNull(page, "page는 null일 수 없습니다.");
       List<ProductListItemResponse> products = page.getContent().stream()
           .map(ProductListItemResponse::from)
           .toList();
@@ -32,6 +38,7 @@ public record ProductListItemResponse(
   ) {
 
     public static ProductListItemResponse from(ProductDetail detail) {
+      Objects.requireNonNull(detail, "detail은 null일 수 없습니다.");
       return new ProductListItemResponse(
           detail.productId(),
           detail.productName(),
@@ -55,6 +62,7 @@ public record ProductResponse(
   ) {
 
     public static ProductResponse from(ProductDetail detail) {
+      Objects.requireNonNull(detail, "detail은 null일 수 없습니다.");
       return new ProductResponse(
           detail.productId(),
           detail.productName(),
@@ -74,25 +82,4 @@ public record BrandInfo(
   ) {
 
   }
-
-  public record PageInfo(
-      long totalElements,
-      int currentPage,
-      int totalPages,
-      int pageSize,
-      String sort
-  ) {
-
-    public static PageInfo from(Page<?> page) {
-      String sortInfo = page.getSort().toString();
-
-      return new PageInfo(
-          page.getTotalElements(),
-          page.getNumber(),
-          page.getTotalPages(),
-          page.getSize(),
-          sortInfo
-      );
-    }
-  }
-}
\ 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 652e291eb..6bc83b2e2 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
@@ -12,8 +12,6 @@
 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;
@@ -49,14 +47,6 @@ class ProductFacadeIntegrationTest extends IntegrationTestSupport {
   @Autowired
   private ProductLikeRepository productLikeRepository;
 
-  @Autowired
-  private DatabaseCleanUp databaseCleanUp;
-
-  @AfterEach
-  void tearDown() {
-    databaseCleanUp.truncateAllTables();
-  }
-
   @Nested
   @DisplayName("상품 목록 조회 시")
   class GetProducts {
diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiE2ETest.java
index e5adae309..1188afe6d 100644
--- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiE2ETest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiE2ETest.java
@@ -15,6 +15,7 @@
 import com.loopers.interfaces.api.product.ProductDto.ProductListItemResponse;
 import com.loopers.interfaces.api.product.ProductDto.ProductListResponse;
 import com.loopers.interfaces.api.product.ProductDto.ProductResponse;
+import com.loopers.interfaces.api.support.ApiHeaders;
 import com.loopers.utils.DatabaseCleanUp;
 import java.time.LocalDate;
 import java.time.LocalDateTime;
@@ -133,7 +134,7 @@ void returnsFilteredProductsWithLikeStatus_whenBrandAndPriceSortProvided() {
       productLikeJpaRepository.save(ProductLike.of(user.getId(), nikeProduct.getId(), FIXED_DATE_TIME));
 
       HttpHeaders headers = new HttpHeaders();
-      headers.set("X-USER-ID", String.valueOf(user.getId()));
+      headers.set(ApiHeaders.USER_ID, String.valueOf(user.getId()));
 
       ResponseEntity<ApiResponse<ProductListResponse>> response =
           testRestTemplate.exchange(
@@ -247,7 +248,7 @@ void returnsProductDetail_whenProductExists() {
       productLikeJpaRepository.save(ProductLike.of(user.getId(), product.getId(), FIXED_DATE_TIME));
 
       HttpHeaders headers = new HttpHeaders();
-      headers.set("X-USER-ID", String.valueOf(user.getId()));
+      headers.set(ApiHeaders.USER_ID, String.valueOf(user.getId()));
 
       ResponseEntity<ApiResponse<ProductResponse>> response =
           testRestTemplate.exchange(

From 3f54d8349853dacf17992fe3a53e73129e9befaf Mon Sep 17 00:00:00 2001
From: green <green@mobidoc.us>
Date: Fri, 21 Nov 2025 10:42:25 +0900
Subject: [PATCH 10/14] =?UTF-8?q?refactor:=20Like=20=EB=8F=84=EB=A9=94?=
 =?UTF-8?q?=EC=9D=B8=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20API=20=EB=A6=AC?=
 =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Application Layer:
- LikeFacade: @Transactional(readOnly = true) 추가, 트랜잭션 동작 주석 추가

Domain Layer:
- ProductLikeService: Objects.requireNonNull로 null 검증 강화, 중복 productId 제거 로직 추가

API Layer:
- LikeController: @PathVariable 명시적 이름 지정, sort 파라미터 전달
- LikeDto: Response에 sort 파라미터 추가

Test:
- LikeFacadeIntegrationTest: 통합 테스트 개선
- LikeFacadeRaceConditionTest: 동시성 테스트 개선
- ProductLikeServiceIntegrationTest: 도메인 테스트 개선
---
 .../loopers/application/like/LikeFacade.java  |  6 +++-
 .../domain/productlike/LikeSortType.java      | 16 ++++++---
 .../productlike/ProductLikeService.java       | 20 ++++++-----
 .../productlike/ProductLikeJpaRepository.java |  5 ---
 .../interfaces/api/like/LikeApiSpec.java      |  6 ++--
 .../interfaces/api/like/LikeController.java   |  6 ++--
 .../loopers/interfaces/api/like/LikeDto.java  | 34 ++++++++-----------
 .../like/LikeFacadeIntegrationTest.java       | 10 ------
 .../like/LikeFacadeRaceConditionTest.java     | 29 ++++++++--------
 .../ProductLikeServiceIntegrationTest.java    | 10 ------
 10 files changed, 63 insertions(+), 79 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 05a19a0a0..a4006d615 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
@@ -24,11 +24,14 @@ public class LikeFacade {
   public void registerProductLike(Long userId, Long productId) {
     productService.getById(productId)
         .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
+
+    // createLike는 독립 트랜잭션(REQUIRES_NEW)에서 실행
+    // UNIQUE 제약 위반 시 createLike 트랜잭션만 롤백되고 외부 트랜잭션에는 영향 없음
     try {
       productLikeService.createLike(userId, productId);
       productService.increaseLikeCount(productId);
     } catch (DataIntegrityViolationException e) {
-
+      // 중복 좋아요 시도 무시 (멱등성 보장)
     }
   }
 
@@ -44,6 +47,7 @@ public void cancelProductLike(Long userId, Long productId) {
     }
   }
 
+  @Transactional(readOnly = true)
   public Page<LikedProduct> retrieveLikedProducts(Long userId, LikeSortType sortType,
       Pageable pageable) {
     return productLikeService.findLikedProducts(userId, sortType, pageable);
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/productlike/LikeSortType.java b/apps/commerce-api/src/main/java/com/loopers/domain/productlike/LikeSortType.java
index cc49af40f..7758af0fd 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/productlike/LikeSortType.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/productlike/LikeSortType.java
@@ -3,21 +3,27 @@
 import java.util.Arrays;
 
 public enum LikeSortType {
-  LATEST("latest"),
-  PRODUCT_NAME("product_name"),
-  PRICE_ASC("price_asc"),
-  PRICE_DESC("price_desc");
+  LATEST("latest", "likedAt: DESC"),
+  PRODUCT_NAME("product_name", "productName: ASC"),
+  PRICE_ASC("price_asc", "price: ASC"),
+  PRICE_DESC("price_desc", "price: DESC");
 
   private final String parameterValue;
+  private final String sortDescription;
 
-  LikeSortType(String parameterValue) {
+  LikeSortType(String parameterValue, String sortDescription) {
     this.parameterValue = parameterValue;
+    this.sortDescription = sortDescription;
   }
 
   public String parameterValue() {
     return parameterValue;
   }
 
+  public String sortDescription() {
+    return sortDescription;
+  }
+
   public static LikeSortType from(String parameterValue) {
     if (parameterValue == null) {
       return LATEST;
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 2859035f0..145c5ec46 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
@@ -4,6 +4,7 @@
 import java.time.LocalDateTime;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.stream.Collectors;
 import lombok.RequiredArgsConstructor;
 import org.springframework.data.domain.Page;
@@ -29,20 +30,19 @@ public boolean isLiked(Long userId, Long productId) {
 
 
   public Map<Long, Boolean> findLikeStatusByProductId(Long userId, List<Long> productIds) {
-    if (userId == null || productIds == null || productIds.isEmpty()) {
-      return Map.of();
-    }
+    Objects.requireNonNull(userId, "userId는 null일 수 없습니다.");
+    Objects.requireNonNull(productIds, "productIds는 null일 수 없습니다.");
 
-    List<ProductLike> likes = productLikeRepository.findByUserIdAndProductIdIn(userId, productIds);
-    if (likes.isEmpty()) {
-      return Map.of();
-    }
+    List<Long> distinctProductIds = productIds.stream()
+        .distinct()
+        .toList();
+
+    List<ProductLike> likes = productLikeRepository.findByUserIdAndProductIdIn(userId, distinctProductIds);
 
     Map<Long, Boolean> likedByProductId = likes.stream()
         .collect(Collectors.toMap(ProductLike::getProductId, like -> true));
 
-    // allProductIds에 중복이 있으면 IllegalStateException 발생
-    return productIds.stream()
+    return distinctProductIds.stream()
         .collect(Collectors.toMap(
             productId -> productId,
             productId -> likedByProductId.getOrDefault(productId, false)
@@ -63,6 +63,8 @@ public int deleteLike(Long userId, Long productId) {
 
   public Page<LikedProduct> findLikedProducts(Long userId, LikeSortType sortType,
       Pageable pageable) {
+    Objects.requireNonNull(sortType, "sortType은 null일 수 없습니다.");
+
     return switch (sortType) {
       case LATEST -> likeQueryRepository.findByUserIdOrderByLatest(userId, pageable);
       case PRODUCT_NAME -> likeQueryRepository.findByUserIdOrderByProductName(userId, pageable);
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 ddbb76cc8..670b5ab98 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
@@ -1,8 +1,6 @@
 package com.loopers.infrastructure.productlike;
 
 import com.loopers.domain.productlike.ProductLike;
-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.Modifying;
 import org.springframework.data.jpa.repository.Query;
@@ -20,7 +18,4 @@ public interface ProductLikeJpaRepository extends JpaRepository<ProductLike, Lon
   @Query("DELETE FROM ProductLike pl WHERE pl.userId = :userId AND pl.productId = :productId")
   int deleteByUserIdAndProductId(@Param("userId") Long userId, @Param("productId") Long productId);
 
-  @Query("SELECT pl FROM ProductLike pl WHERE pl.userId = :userId ORDER BY pl.likedAt DESC")
-  Page<ProductLike> findByUserIdOrderByLikedAtDesc(@Param("userId") Long userId, Pageable pageable);
-
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeApiSpec.java
index f57572052..4ed1e07f6 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeApiSpec.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeApiSpec.java
@@ -19,7 +19,7 @@ ApiResponse<Object> registerProductLike(
       @Parameter(description = "사용자 ID", required = true)
       @RequestHeader(ApiHeaders.USER_ID) Long userId,
       @Parameter(description = "상품 ID", required = true)
-      @PathVariable Long productId
+      @PathVariable("productId") Long productId
   );
 
   @Operation(summary = "상품 좋아요 취소", description = "상품 좋아요를 취소합니다.")
@@ -27,7 +27,7 @@ ApiResponse<Object> cancelProductLike(
       @Parameter(description = "사용자 ID", required = true)
       @RequestHeader(ApiHeaders.USER_ID) Long userId,
       @Parameter(description = "상품 ID", required = true)
-      @PathVariable Long productId
+      @PathVariable("productId") Long productId
   );
 
   @Operation(summary = "좋아요 상품 목록 조회", description = "사용자가 좋아요한 상품 목록을 페이지 단위로 조회합니다.")
@@ -35,7 +35,7 @@ ApiResponse<LikedProductListResponse> retrieveLikedProducts(
       @Parameter(description = "사용자 ID", required = true)
       @RequestHeader(ApiHeaders.USER_ID) Long userId,
       @Parameter(description = "정렬 (latest, product_name, price_asc, price_desc)")
-      @RequestParam(defaultValue = "lastest") LikeSortType sort,
+      @RequestParam(defaultValue = "latest") LikeSortType sort,
       @Parameter(description = "페이지 번호 (0부터 시작)")
       @RequestParam(defaultValue = "0") int page,
       @Parameter(description = "페이지 크기")
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeController.java
index b32b7c657..dc2de0347 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeController.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeController.java
@@ -30,7 +30,7 @@ public class LikeController implements LikeApiSpec {
   @PostMapping("/{productId}")
   public ApiResponse<Object> registerProductLike(
       @RequestHeader(ApiHeaders.USER_ID) Long userId,
-      @PathVariable Long productId
+      @PathVariable("productId") Long productId
   ) {
     likeFacade.registerProductLike(userId, productId);
     return ApiResponse.success();
@@ -40,7 +40,7 @@ public ApiResponse<Object> registerProductLike(
   @DeleteMapping("/{productId}")
   public ApiResponse<Object> cancelProductLike(
       @RequestHeader(ApiHeaders.USER_ID) Long userId,
-      @PathVariable Long productId
+      @PathVariable("productId") Long productId
   ) {
     likeFacade.cancelProductLike(userId, productId);
     return ApiResponse.success();
@@ -56,7 +56,7 @@ public ApiResponse<LikedProductListResponse> retrieveLikedProducts(
   ) {
     Pageable pageable = PageRequest.of(page, size);
     Page<LikedProduct> likedProducts = likeFacade.retrieveLikedProducts(userId, sort, pageable);
-    LikedProductListResponse response = LikedProductListResponse.from(likedProducts);
+    LikedProductListResponse response = LikedProductListResponse.from(likedProducts, sort);
     return ApiResponse.success(response);
   }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeDto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeDto.java
index 1b237a18d..e5a3213f9 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeDto.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeDto.java
@@ -1,7 +1,10 @@
 package com.loopers.interfaces.api.like;
 
+import com.loopers.domain.productlike.LikeSortType;
 import com.loopers.domain.productlike.LikedProduct;
+import com.loopers.interfaces.api.support.PageInfo;
 import java.util.List;
+import java.util.Objects;
 import org.springframework.data.domain.Page;
 
 public class LikeDto {
@@ -15,12 +18,23 @@ public record LikedProductListResponse(
   ) {
 
     public static LikedProductListResponse from(Page<LikedProduct> page) {
+      Objects.requireNonNull(page, "page는 null일 수 없습니다.");
       List<LikedProductResponse> products = page.getContent().stream()
           .map(LikedProductResponse::from)
           .toList();
 
       return new LikedProductListResponse(products, PageInfo.from(page));
     }
+
+    public static LikedProductListResponse from(Page<LikedProduct> page, LikeSortType sortType) {
+      Objects.requireNonNull(page, "page는 null일 수 없습니다.");
+      Objects.requireNonNull(sortType, "sortType은 null일 수 없습니다.");
+      List<LikedProductResponse> products = page.getContent().stream()
+          .map(LikedProductResponse::from)
+          .toList();
+
+      return new LikedProductListResponse(products, PageInfo.from(page, sortType.sortDescription()));
+    }
   }
 
   public record LikedProductResponse(
@@ -32,6 +46,7 @@ public record LikedProductResponse(
   ) {
 
     public static LikedProductResponse from(LikedProduct likedProduct) {
+      Objects.requireNonNull(likedProduct, "likedProduct는 null일 수 없습니다.");
       return new LikedProductResponse(
           likedProduct.productId(),
           likedProduct.productName(),
@@ -47,23 +62,4 @@ public record BrandInfo(
       String name
   ) {
   }
-
-  public record PageInfo(
-      long totalElements,
-      int currentPage,
-      int totalPages,
-      int pageSize,
-      String sort
-  ) {
-
-    public static PageInfo from(Page<?> page) {
-      return new PageInfo(
-          page.getTotalElements(),
-          page.getNumber(),
-          page.getTotalPages(),
-          page.getSize(),
-          page.getSort().toString()
-      );
-    }
-  }
 }
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
index f74a8f52a..92a785ea1 100644
--- 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
@@ -17,9 +17,7 @@
 import com.loopers.support.error.CoreException;
 import com.loopers.support.error.ErrorType;
 import com.loopers.support.test.IntegrationTestSupport;
-import com.loopers.utils.DatabaseCleanUp;
 import java.time.LocalDateTime;
-import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.DisplayName;
 import org.junit.jupiter.api.Nested;
 import org.junit.jupiter.api.Test;
@@ -44,14 +42,6 @@ class LikeFacadeIntegrationTest extends IntegrationTestSupport {
   @Autowired
   private ProductLikeRepository productLikeRepository;
 
-  @Autowired
-  private DatabaseCleanUp databaseCleanUp;
-
-  @AfterEach
-  void tearDown() {
-    databaseCleanUp.truncateAllTables();
-  }
-
   @Nested
   @DisplayName("좋아요 등록 시")
   class LikeProduct {
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeRaceConditionTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeRaceConditionTest.java
index 2936c5354..aa170dbc4 100644
--- a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeRaceConditionTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeRaceConditionTest.java
@@ -9,19 +9,22 @@
 import com.loopers.domain.productlike.ProductLikeRepository;
 import com.loopers.domain.stock.Stock;
 import com.loopers.support.test.IntegrationTestSupport;
-import com.loopers.utils.DatabaseCleanUp;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.concurrent.CompletableFuture;
-import org.junit.jupiter.api.AfterEach;
+import java.util.concurrent.TimeUnit;
 import org.junit.jupiter.api.DisplayName;
 import org.junit.jupiter.api.Nested;
 import org.junit.jupiter.api.Test;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
 
 @DisplayName("LikeFacade 동시성 테스트")
 public class LikeFacadeRaceConditionTest extends IntegrationTestSupport {
 
+  private static final Logger log = LoggerFactory.getLogger(LikeFacadeRaceConditionTest.class);
+
   @Autowired
   private LikeFacade likeFacade;
 
@@ -31,14 +34,6 @@ public class LikeFacadeRaceConditionTest extends IntegrationTestSupport {
   @Autowired
   private ProductLikeRepository productLikeRepository;
 
-  @Autowired
-  private DatabaseCleanUp databaseCleanUp;
-
-  @AfterEach
-  void tearDown() {
-    databaseCleanUp.truncateAllTables();
-  }
-
   @Nested
   @DisplayName("좋아요 추가 동시성")
   class ConcurrentLike {
@@ -58,7 +53,9 @@ void countsCorrectly() {
         futures.add(asyncExecute(() -> likeFacade.registerProductLike(userId, product.getId())));
       }
 
-      CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
+      CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
+          .orTimeout(10, TimeUnit.SECONDS)
+          .join();
 
       // then
       Product updatedProduct = productRepository.findById(product.getId()).orElseThrow();
@@ -81,7 +78,9 @@ void preventsDuplicate() {
         futures.add(asyncExecute(() -> likeFacade.registerProductLike(userId, product.getId())));
       }
 
-      CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
+      CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
+          .orTimeout(10, TimeUnit.SECONDS)
+          .join();
 
       // then
       Product updatedProduct = productRepository.findById(product.getId()).orElseThrow();
@@ -118,7 +117,9 @@ void countsCorrectly() {
         futures.add(asyncExecute(() -> likeFacade.cancelProductLike(userId, product.getId())));
       }
 
-      CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
+      CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
+          .orTimeout(10, TimeUnit.SECONDS)
+          .join();
 
       // then
       Product updatedProduct = productRepository.findById(product.getId()).orElseThrow();
@@ -132,7 +133,7 @@ private CompletableFuture<Void> asyncExecute(Runnable task) {
       try {
         task.run();
       } catch (Exception e) {
-        // 동시성 테스트에서는 예외 무시
+        log.debug("동시성 테스트 중 예외 발생 (예상됨): {}", e.getMessage());
       }
     });
   }
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/productlike/ProductLikeServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/productlike/ProductLikeServiceIntegrationTest.java
index 580a3e7e1..2722b3e68 100644
--- a/apps/commerce-api/src/test/java/com/loopers/domain/productlike/ProductLikeServiceIntegrationTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/productlike/ProductLikeServiceIntegrationTest.java
@@ -9,13 +9,11 @@
 import com.loopers.domain.stock.Stock;
 import com.loopers.support.error.CoreException;
 import com.loopers.support.test.IntegrationTestSupport;
-import com.loopers.utils.DatabaseCleanUp;
 import java.time.Clock;
 import java.time.Instant;
 import java.time.LocalDateTime;
 import java.time.ZoneId;
 import java.util.List;
-import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.DisplayName;
 import org.junit.jupiter.api.Nested;
@@ -41,9 +39,6 @@ class ProductLikeServiceIntegrationTest extends IntegrationTestSupport {
   @Autowired
   private ProductLikeRepository productLikeRepository;
 
-  @Autowired
-  private DatabaseCleanUp databaseCleanUp;
-
   @MockitoBean
   private Clock clock;
 
@@ -53,11 +48,6 @@ void setUpClock() {
     Mockito.when(clock.getZone()).thenReturn(FIXED_CLOCK.getZone());
   }
 
-  @AfterEach
-  void tearDown() {
-    databaseCleanUp.truncateAllTables();
-  }
-
   private Product saveProduct(String name, long price, Long brandId) {
     return productRepository.save(
         Product.of(name, Money.of(price), name + " 설명", Stock.of(10L), brandId)

From 8e9d67f56bf92b0dd2c6d62d5f9b03f4bb6ac639 Mon Sep 17 00:00:00 2001
From: green <green@mobidoc.us>
Date: Fri, 21 Nov 2025 10:45:05 +0900
Subject: [PATCH 11/14] =?UTF-8?q?refactor:=20Order=20=EB=8F=84=EB=A9=94?=
 =?UTF-8?q?=EC=9D=B8=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20API=20=EB=A6=AC?=
 =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Application Layer:
- OrderFacade: @Transactional(readOnly = true) 추가

Infrastructure Layer:
- OrderRepositoryImpl: 기본 정렬 추가 (id DESC), unsorted 시 기본 정렬 적용

API Layer:
- OrderController: ApiHeaders 상수 사용, @PathVariable 명시적 이름 지정
- OrderApiSpec: API 문서 개선
- OrderDto: DTO 구조 개선

Test:
- OrderFacadeIntegrationTest: 통합 테스트 개선
- OrderFacadeRaceConditionTest: 동시성 테스트 개선
- OrderRepositoryIntegrationTest: Repository 테스트 개선
- OrderServiceIntegrationTest: 도메인 테스트 개선
- OrderApiE2ETest: E2E 테스트 개선
---
 .../application/order/OrderFacade.java        |  2 +
 .../order/OrderJpaRepository.java             |  5 ++-
 .../order/OrderRepositoryImpl.java            | 11 ++++++
 .../interfaces/api/order/OrderApiSpec.java    |  6 +--
 .../interfaces/api/order/OrderController.java |  6 +--
 .../interfaces/api/order/OrderDto.java        | 26 +++++--------
 .../order/OrderFacadeIntegrationTest.java     |  9 -----
 .../order/OrderFacadeRaceConditionTest.java   | 37 +++++++++++--------
 .../order/OrderRepositoryIntegrationTest.java | 10 -----
 .../order/OrderServiceIntegrationTest.java    | 10 -----
 .../interfaces/api/order/OrderApiE2ETest.java | 32 +++++++---------
 11 files changed, 66 insertions(+), 88 deletions(-)

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 92df454dc..5ec86c0b0 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
@@ -51,10 +51,12 @@ public Order createOrder(Long userId, List<OrderItemCommand> commands) {
     return orderService.create(userId, result.orderItems().getItems(), orderedAt);
   }
 
+  @Transactional(readOnly = true)
   public Page<OrderListDto> retrieveOrders(Long userId, Pageable pageable) {
     return orderService.findOrders(userId, pageable);
   }
 
+  @Transactional(readOnly = true)
   public Order retrieveOrderDetail(Long userId, Long orderId) {
     Order order = orderService.getWithItemsById(orderId)
         .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다."));
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 58e99a201..2c8cc4093 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
@@ -21,10 +21,11 @@ public interface OrderJpaRepository extends JpaRepository<Order, Long> {
   Optional<Order> findWithItemsById(@Param("id") Long id);
 
   /**
-   * 사용자별 주문 목록 조회 (DTO 프로젝션, SIZE()로 개수만 조회, 최신순 정렬)
+   * 사용자별 주문 목록 조회 (DTO 프로젝션, SIZE()로 개수만 조회)
+   * 정렬은 Pageable로 제어
    */
   @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 ORDER BY o.orderedAt DESC, o.id DESC")
+      "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
index 0984ab6a1..88f6f9865 100644
--- 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
@@ -5,7 +5,9 @@
 import com.loopers.domain.order.OrderRepository;
 import lombok.RequiredArgsConstructor;
 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 org.springframework.stereotype.Repository;
 
 import java.util.Optional;
@@ -14,6 +16,8 @@
 @RequiredArgsConstructor
 public class OrderRepositoryImpl implements OrderRepository {
 
+  private static final Sort DEFAULT_ORDER_SORT = Sort.by(Sort.Direction.DESC, "id");
+
   private final OrderJpaRepository jpaRepository;
 
   @Override
@@ -28,6 +32,13 @@ public Optional<Order> findWithItemsById(Long id) {
 
   @Override
   public Page<OrderListDto> findOrderList(Long userId, Pageable pageable) {
+    if (pageable.getSort().isUnsorted()) {
+      pageable = PageRequest.of(
+          pageable.getPageNumber(),
+          pageable.getPageSize(),
+          DEFAULT_ORDER_SORT
+      );
+    }
     return jpaRepository.findOrderListByUserId(userId, pageable);
   }
 
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderApiSpec.java
index 3ba2236d6..317b89cf6 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderApiSpec.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderApiSpec.java
@@ -36,7 +36,7 @@ ResponseEntity<ApiResponse<OrderDetailResponse>> createOrder(
   )
   ApiResponse<OrderListResponse> retrieveOrders(
       @Parameter(description = "사용자 ID", required = true)
-      @RequestHeader("X-USER-ID") Long userId,
+      @RequestHeader(ApiHeaders.USER_ID) Long userId,
 
       @Parameter(description = "페이지 번호 (0부터 시작)", required = false)
       @RequestParam(defaultValue = "0") int page,
@@ -51,9 +51,9 @@ ApiResponse<OrderListResponse> retrieveOrders(
   )
   ApiResponse<OrderDetailResponse> retrieveOrderDetail(
       @Parameter(description = "사용자 ID", required = true)
-      @RequestHeader("X-USER-ID") Long userId,
+      @RequestHeader(ApiHeaders.USER_ID) Long userId,
 
       @Parameter(description = "주문 ID", required = true)
-      @PathVariable Long orderId
+      @PathVariable("orderId") Long orderId
   );
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java
index 9f79d5c22..3f0faff45 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java
@@ -54,7 +54,7 @@ public ResponseEntity<ApiResponse<OrderDetailResponse>> createOrder(
   @Override
   @GetMapping
   public ApiResponse<OrderListResponse> retrieveOrders(
-      @RequestHeader("X-USER-ID") Long userId,
+      @RequestHeader(ApiHeaders.USER_ID) Long userId,
       @RequestParam(defaultValue = "0") int page,
       @RequestParam(defaultValue = "20") int size
   ) {
@@ -69,8 +69,8 @@ public ApiResponse<OrderListResponse> retrieveOrders(
   @Override
   @GetMapping("/{orderId}")
   public ApiResponse<OrderDetailResponse> retrieveOrderDetail(
-      @RequestHeader("X-USER-ID") Long userId,
-      @PathVariable Long orderId
+      @RequestHeader(ApiHeaders.USER_ID) Long userId,
+      @PathVariable("orderId") Long orderId
   ) {
     Order order = orderFacade.retrieveOrderDetail(userId, orderId);
     OrderDetailResponse response = OrderDetailResponse.from(order);
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderDto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderDto.java
index f9d793c7f..70e4bf5df 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderDto.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderDto.java
@@ -4,15 +4,20 @@
 import com.loopers.domain.order.OrderListDto;
 import com.loopers.domain.order.OrderStatus;
 import com.loopers.domain.order.orderitem.OrderItem;
+import com.loopers.interfaces.api.support.PageInfo;
 import jakarta.validation.constraints.NotEmpty;
 import jakarta.validation.constraints.NotNull;
 import jakarta.validation.constraints.Positive;
 import java.time.LocalDateTime;
 import java.util.List;
+import java.util.Objects;
 import org.springframework.data.domain.Page;
 
 public class OrderDto {
 
+  private OrderDto() {
+  }
+
   public record OrderCreateRequest(
       @NotNull(message = "주문 항목은 null일 수 없습니다.")
       @NotEmpty(message = "주문 항목은 비어있을 수 없습니다.")
@@ -35,6 +40,7 @@ public record OrderListResponse(
   ) {
 
     public static OrderListResponse from(Page<OrderListDto> page) {
+      Objects.requireNonNull(page, "page는 null일 수 없습니다.");
       List<OrderListItemResponse> orders = page.getContent().stream()
           .map(OrderListItemResponse::from)
           .toList();
@@ -54,6 +60,7 @@ public record OrderListItemResponse(
   ) {
 
     public static OrderListItemResponse from(OrderListDto dto) {
+      Objects.requireNonNull(dto, "dto는 null일 수 없습니다.");
       return new OrderListItemResponse(
           dto.id(),
           dto.orderedAt(),
@@ -73,6 +80,7 @@ public record OrderDetailResponse(
   ) {
 
     public static OrderDetailResponse from(Order order) {
+      Objects.requireNonNull(order, "order는 null일 수 없습니다.");
       List<OrderItemResponse> items = order.getItems().stream()
           .map(OrderItemResponse::from)
           .toList();
@@ -95,6 +103,7 @@ public record OrderItemResponse(
   ) {
 
     public static OrderItemResponse from(OrderItem item) {
+      Objects.requireNonNull(item, "item은 null일 수 없습니다.");
       return new OrderItemResponse(
           item.getProductId(),
           item.getProductName(),
@@ -103,21 +112,4 @@ public static OrderItemResponse from(OrderItem item) {
       );
     }
   }
-
-  public record PageInfo(
-      long totalElements,
-      int currentPage,
-      int totalPages,
-      int pageSize
-  ) {
-
-    public static PageInfo from(Page<?> page) {
-      return new PageInfo(
-          page.getTotalElements(),
-          page.getNumber(),
-          page.getTotalPages(),
-          page.getSize()
-      );
-    }
-  }
 }
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 a02bbfcc6..886bd84ae 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
@@ -16,8 +16,6 @@
 import com.loopers.domain.user.UserRepository;
 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.BeforeEach;
 import org.junit.jupiter.api.DisplayName;
 import org.junit.jupiter.api.Nested;
@@ -44,8 +42,6 @@ class OrderFacadeIntegrationTest extends IntegrationTestSupport {
   private UserRepository userRepository;
   @Autowired
   private PointRepository pointRepository;
-  @Autowired
-  private DatabaseCleanUp databaseCleanUp;
 
   private User user;
   private Product product1;
@@ -63,11 +59,6 @@ void setUp() {
     pointRepository.save(Point.of(user.getId(), 100000L));
   }
 
-  @AfterEach
-  void tearDown() {
-    databaseCleanUp.truncateAllTables();
-  }
-
   @Nested
   @DisplayName("주문 생성")
   class CreateOrder {
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 e25d89012..6b9843b64 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
@@ -14,22 +14,25 @@
 import com.loopers.domain.user.User;
 import com.loopers.domain.user.UserRepository;
 import com.loopers.support.test.IntegrationTestSupport;
-import com.loopers.utils.DatabaseCleanUp;
 import java.time.LocalDate;
 import java.time.LocalDateTime;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
-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.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
 
 @DisplayName("OrderFacade 동시성 테스트")
 class OrderFacadeRaceConditionTest extends IntegrationTestSupport {
 
+  private static final Logger log = LoggerFactory.getLogger(OrderFacadeRaceConditionTest.class);
+
   @Autowired
   private OrderFacade orderFacade;
 
@@ -42,9 +45,6 @@ class OrderFacadeRaceConditionTest extends IntegrationTestSupport {
   @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);
   private static final LocalDate JOINED_AT_2025_10_30 = LocalDate.of(2025, 10, 30);
@@ -52,11 +52,6 @@ class OrderFacadeRaceConditionTest extends IntegrationTestSupport {
   private static final long POINT_BALANCE_50_000 = 50000L;
   private static final long BRAND_ID = 1L;
 
-  @AfterEach
-  void tearDown() {
-    databaseCleanUp.truncateAllTables();
-  }
-
   @DisplayName("재고 동시성 테스트")
   @Nested
   class StockConcurrency {
@@ -81,7 +76,9 @@ void concurrentOrders_onlyOneSucceeds() {
       futures.add(asyncExecute(() -> orderFacade.createOrder(user1.getId(), commands)));
       futures.add(asyncExecute(() -> orderFacade.createOrder(user2.getId(), commands)));
 
-      CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
+      CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
+          .orTimeout(10, TimeUnit.SECONDS)
+          .join();
 
       // then
       Product updatedProduct = productRepository.findById(product.getId()).orElseThrow();
@@ -114,7 +111,9 @@ void concurrentOrders_multipleThreads() {
         }));
       }
 
-      CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
+      CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
+          .orTimeout(10, TimeUnit.SECONDS)
+          .join();
 
       // then
       Product updatedProduct = productRepository.findById(product.getId()).orElseThrow();
@@ -147,7 +146,9 @@ void concurrentOrders_stockNeverNegative() {
         }));
       }
 
-      CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
+      CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
+          .orTimeout(10, TimeUnit.SECONDS)
+          .join();
 
       // then
       Product finalProduct = productRepository.findById(product.getId()).orElseThrow();
@@ -185,7 +186,9 @@ void concurrentOrders_sameUser_differentProducts() {
       futures.add(asyncExecute(() -> orderFacade.createOrder(user.getId(), commands1)));
       futures.add(asyncExecute(() -> orderFacade.createOrder(user.getId(), commands2)));
 
-      CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
+      CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
+          .orTimeout(10, TimeUnit.SECONDS)
+          .join();
 
       // then
       Point finalPoint = pointRepository.findByUserId(user.getId()).orElseThrow();
@@ -232,7 +235,9 @@ void concurrentOrders_multipleProducts_noDeadlock() {
       futures.add(asyncExecute(() -> orderFacade.createOrder(user1.getId(), commands1)));
       futures.add(asyncExecute(() -> orderFacade.createOrder(user2.getId(), commands2)));
 
-      CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
+      CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
+          .orTimeout(10, TimeUnit.SECONDS)
+          .join();
 
       // then
       List<Product> finalProducts = List.of(
@@ -252,7 +257,7 @@ private CompletableFuture<Void> asyncExecute(Runnable task) {
       try {
         task.run();
       } catch (Exception e) {
-        // 동시성 테스트에서는 예외 무시
+        log.debug("동시성 테스트 중 예외 발생 (예상됨): {}", e.getMessage());
       }
     });
   }
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 02424cba2..00b52008c 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
@@ -8,10 +8,8 @@
 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 java.time.LocalDateTime;
 import java.util.Optional;
-import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.DisplayName;
 import org.junit.jupiter.api.Nested;
 import org.junit.jupiter.api.Test;
@@ -27,14 +25,6 @@ class OrderRepositoryIntegrationTest extends IntegrationTestSupport {
   @Autowired
   private OrderRepository orderRepository;
 
-  @Autowired
-  private DatabaseCleanUp databaseCleanUp;
-
-  @AfterEach
-  void tearDown() {
-    databaseCleanUp.truncateAllTables();
-  }
-
   @DisplayName("주문 목록 조회 (@Query + DTO)")
   @Nested
   class FindOrderList {
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 1664e69c4..d5bf6738e 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
@@ -4,8 +4,6 @@
 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;
@@ -25,16 +23,8 @@ class OrderServiceIntegrationTest extends IntegrationTestSupport {
   @Autowired
   private OrderService orderService;
 
-  @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 {
diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java
index 4137b6618..25fe3821b 100644
--- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java
@@ -21,6 +21,7 @@
 import com.loopers.interfaces.api.ApiResponse;
 import com.loopers.interfaces.api.order.OrderDto.OrderDetailResponse;
 import com.loopers.interfaces.api.order.OrderDto.OrderListResponse;
+import com.loopers.interfaces.api.support.ApiHeaders;
 import com.loopers.utils.DatabaseCleanUp;
 import java.time.LocalDate;
 import java.time.LocalDateTime;
@@ -97,16 +98,16 @@ void returnsOrdersWithPagination_whenValidUserId() {
       Brand brand = saveBrand("나이키");
       Product product = saveProduct("운동화", 30000L, 50L, brand.getId());
 
-      Order first = saveOrder(user.getId(), OrderStatus.PENDING, 30000L, FIRST_ORDER_AT_2025_10_30_10_00);
+      Order first = Order.of(user.getId(), OrderStatus.PENDING, 30000L, FIRST_ORDER_AT_2025_10_30_10_00);
       addOrderItem(first, product.getId(), "운동화", 1L, 30000L);
       orderJpaRepository.save(first);
 
-      Order second = saveOrder(user.getId(), OrderStatus.COMPLETED, 50000L, SECOND_ORDER_AT_2025_10_30_11_00);
+      Order second = Order.of(user.getId(), OrderStatus.COMPLETED, 50000L, SECOND_ORDER_AT_2025_10_30_11_00);
       addOrderItem(second, product.getId(), "운동화", 2L, 25000L);
       orderJpaRepository.save(second);
 
       HttpHeaders headers = new HttpHeaders();
-      headers.set("X-USER-ID", user.getId().toString());
+      headers.set(ApiHeaders.USER_ID, user.getId().toString());
 
       ResponseEntity<ApiResponse<OrderListResponse>> response =
           testRestTemplate.exchange(
@@ -141,13 +142,13 @@ void returnsPaginatedOrders_whenPageSizeIsSet() {
       Product product = saveProduct("운동화", 30000L, 50L, brand.getId());
 
       for (int i = 0; i < 3; i++) {
-        Order order = saveOrder(user.getId(), OrderStatus.PENDING, 30000L, FIRST_ORDER_AT_2025_10_30_10_00);
+        Order order = Order.of(user.getId(), OrderStatus.PENDING, 30000L, FIRST_ORDER_AT_2025_10_30_10_00);
         addOrderItem(order, product.getId(), "운동화", 1L, 30000L);
         orderJpaRepository.save(order);
       }
 
       HttpHeaders headers = new HttpHeaders();
-      headers.set("X-USER-ID", user.getId().toString());
+      headers.set(ApiHeaders.USER_ID, user.getId().toString());
 
       ResponseEntity<ApiResponse<OrderListResponse>> response =
           testRestTemplate.exchange(
@@ -179,16 +180,16 @@ void returnsOnlyOwnOrders_whenMultipleUsersExist() {
       Brand brand = saveBrand("나이키");
       Product product = saveProduct("운동화", 30000L, 50L, brand.getId());
 
-      Order order1 = saveOrder(user1.getId(), OrderStatus.PENDING, 30000L, FIRST_ORDER_AT_2025_10_30_10_00);
+      Order order1 = Order.of(user1.getId(), OrderStatus.PENDING, 30000L, FIRST_ORDER_AT_2025_10_30_10_00);
       addOrderItem(order1, product.getId(), "운동화", 1L, 30000L);
       orderJpaRepository.save(order1);
 
-      Order order2 = saveOrder(user2.getId(), OrderStatus.PENDING, 50000L, FIRST_ORDER_AT_2025_10_30_10_00);
+      Order order2 = Order.of(user2.getId(), OrderStatus.PENDING, 50000L, FIRST_ORDER_AT_2025_10_30_10_00);
       addOrderItem(order2, product.getId(), "운동화", 2L, 25000L);
       orderJpaRepository.save(order2);
 
       HttpHeaders headers = new HttpHeaders();
-      headers.set("X-USER-ID", user1.getId().toString());
+      headers.set(ApiHeaders.USER_ID, user1.getId().toString());
 
       ResponseEntity<ApiResponse<OrderListResponse>> response =
           testRestTemplate.exchange(
@@ -225,13 +226,13 @@ void returnsOrderDetail_whenValidOrderId() {
       Product product1 = saveProduct("운동화", 30000L, 50L, brand.getId());
       Product product2 = saveProduct("슬리퍼", 20000L, 30L, brand.getId());
 
-      Order order = saveOrder(user.getId(), OrderStatus.PENDING, 50000L, FIRST_ORDER_AT_2025_10_30_10_00);
+      Order order = Order.of(user.getId(), OrderStatus.PENDING, 50000L, FIRST_ORDER_AT_2025_10_30_10_00);
       addOrderItem(order, product1.getId(), "운동화", 1L, 30000L);
       addOrderItem(order, product2.getId(), "슬리퍼", 1L, 20000L);
       orderJpaRepository.save(order);
 
       HttpHeaders headers = new HttpHeaders();
-      headers.set("X-USER-ID", user.getId().toString());
+      headers.set(ApiHeaders.USER_ID, user.getId().toString());
 
       ResponseEntity<ApiResponse<OrderDetailResponse>> response =
           testRestTemplate.exchange(
@@ -266,12 +267,12 @@ void returnsForbidden_whenAccessingOthersOrder() {
       Brand brand = saveBrand("나이키");
       Product product = saveProduct("운동화", 30000L, 50L, brand.getId());
 
-      Order order = saveOrder(user1.getId(), OrderStatus.PENDING, 30000L, FIRST_ORDER_AT_2025_10_30_10_00);
+      Order order = Order.of(user1.getId(), OrderStatus.PENDING, 30000L, FIRST_ORDER_AT_2025_10_30_10_00);
       addOrderItem(order, product.getId(), "운동화", 1L, 30000L);
       orderJpaRepository.save(order);
 
       HttpHeaders headers = new HttpHeaders();
-      headers.set("X-USER-ID", user2.getId().toString());
+      headers.set(ApiHeaders.USER_ID, user2.getId().toString());
 
       ResponseEntity<ApiResponse<OrderDetailResponse>> response =
           testRestTemplate.exchange(
@@ -290,7 +291,7 @@ void returnsNotFound_whenOrderNotExists() {
       User user = saveUser("user123", "user@example.com");
 
       HttpHeaders headers = new HttpHeaders();
-      headers.set("X-USER-ID", user.getId().toString());
+      headers.set(ApiHeaders.USER_ID, user.getId().toString());
 
       ResponseEntity<ApiResponse<OrderDetailResponse>> response =
           testRestTemplate.exchange(
@@ -320,11 +321,6 @@ private Product saveProduct(String name, Long price, Long stock, Long brandId) {
     return productJpaRepository.save(product);
   }
 
-  private Order saveOrder(Long userId, OrderStatus status, Long totalAmount, LocalDateTime orderedAt) {
-    Order order = Order.of(userId, status, totalAmount, orderedAt);
-    return orderJpaRepository.save(order);
-  }
-
   private void addOrderItem(Order order, Long productId, String productName, Long quantity,
       Long price) {
     OrderItem item = OrderItem.of(productId, productName, Quantity.of(quantity),

From ef87296efd2eb6160c236b53de22550b63e9b8df Mon Sep 17 00:00:00 2001
From: green <green@mobidoc.us>
Date: Fri, 21 Nov 2025 10:49:18 +0900
Subject: [PATCH 12/14] =?UTF-8?q?refactor:=20Brand=20API=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

API Layer:
- BrandController: @PathVariable 명시적 이름 지정
- BrandApiSpec: API 문서 개선
- BrandDto: DTO 구조 개선

Test:
- BrandServiceIntegrationTest: 통합 테스트 개선
---
 .../com/loopers/interfaces/api/brand/BrandApiSpec.java |  2 +-
 .../loopers/interfaces/api/brand/BrandController.java  |  2 +-
 .../com/loopers/interfaces/api/brand/BrandDto.java     |  6 ++++++
 .../domain/brand/BrandServiceIntegrationTest.java      | 10 ----------
 4 files changed, 8 insertions(+), 12 deletions(-)

diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandApiSpec.java
index 4470bc2ee..cd8e51699 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandApiSpec.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandApiSpec.java
@@ -16,6 +16,6 @@ public interface BrandApiSpec {
   )
   ApiResponse<BrandViewResponse> retrieveBrand(
       @Parameter(description = "브랜드 ID", required = true)
-      @PathVariable Long brandId
+      @PathVariable("brandId") Long brandId
   );
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java
index f07daeb2c..c4e429d45 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java
@@ -19,7 +19,7 @@ public class BrandController implements BrandApiSpec {
 
   @Override
   @GetMapping("/{brandId}")
-  public ApiResponse<BrandViewResponse> retrieveBrand(@PathVariable Long brandId) {
+  public ApiResponse<BrandViewResponse> retrieveBrand(@PathVariable("brandId") Long brandId) {
     BrandResult result = brandFacade.retrieveBrand(brandId);
     return ApiResponse.success(BrandViewResponse.from(result));
   }
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandDto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandDto.java
index c813129b8..6dd86effe 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandDto.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandDto.java
@@ -2,8 +2,13 @@
 
 import com.loopers.application.brand.BrandResult;
 
+import java.util.Objects;
+
 public class BrandDto {
 
+  private BrandDto() {
+  }
+
   public record BrandViewResponse(
       Long brandId,
       String name,
@@ -11,6 +16,7 @@ public record BrandViewResponse(
   ) {
 
     public static BrandViewResponse from(BrandResult result) {
+      Objects.requireNonNull(result, "result는 null일 수 없습니다.");
       return new BrandViewResponse(
           result.brandId(),
           result.name(),
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
index a1d598beb..c9063dd8e 100644
--- 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
@@ -3,10 +3,8 @@
 import static org.assertj.core.api.Assertions.assertThat;
 
 import com.loopers.support.test.IntegrationTestSupport;
-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;
@@ -18,9 +16,6 @@ class BrandServiceIntegrationTest extends IntegrationTestSupport {
   @Autowired
   private BrandRepository brandRepository;
 
-  @Autowired
-  private DatabaseCleanUp databaseCleanUp;
-
   private BrandService brandService;
 
   @BeforeEach
@@ -28,11 +23,6 @@ void setUp() {
     brandService = new BrandService(brandRepository);
   }
 
-  @AfterEach
-  void tearDown() {
-    databaseCleanUp.truncateAllTables();
-  }
-
   @Nested
   @DisplayName("브랜드 단건 조회 시")
   class GetById {

From f57dc315960f0122094d7459980e7e4491e2cdc3 Mon Sep 17 00:00:00 2001
From: green <green@mobidoc.us>
Date: Fri, 21 Nov 2025 10:50:46 +0900
Subject: [PATCH 13/14] =?UTF-8?q?refactor:=20User/Point=20=EB=8F=84?=
 =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20API=20?=
 =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Application Layer:
- UserFacade: @Transactional, @Transactional(readOnly = true) 추가
- PointFacade: @Transactional(readOnly = true) 추가

API Layer:
- UserController: @PathVariable 명시적 이름 지정
- UserDto: DTO 구조 개선
- PointDto: DTO 구조 개선

Test:
- UserServiceIntegrationTest: 통합 테스트 개선
- PointServiceIntegrationTest: 통합 테스트 개선
- PointApiE2ETest: E2E 테스트 개선
---
 .../com/loopers/application/point/PointFacade.java     |  1 +
 .../java/com/loopers/application/user/UserFacade.java  |  3 +++
 .../com/loopers/interfaces/api/point/PointDto.java     |  7 +++++++
 .../loopers/interfaces/api/user/UserController.java    |  2 +-
 .../java/com/loopers/interfaces/api/user/UserDto.java  |  6 ++++++
 .../domain/point/PointServiceIntegrationTest.java      | 10 ----------
 .../domain/user/UserServiceIntegrationTest.java        | 10 ----------
 .../loopers/interfaces/api/point/PointApiE2ETest.java  |  9 +++++----
 8 files changed, 23 insertions(+), 25 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 3f6bd6940..3e755bf32 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,6 +12,7 @@ public class PointFacade {
 
     private final PointService pointService;
 
+    @Transactional(readOnly = true)
     public Optional<PointResult> getPoint(Long userId) {
       return pointService.findByUserId(userId)
           .map(PointResult::from);
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 42ca0d2a9..07bb5be71 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
@@ -7,6 +7,7 @@
 import com.loopers.support.error.ErrorType;
 import lombok.RequiredArgsConstructor;
 import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
 
 import java.time.LocalDate;
 
@@ -15,11 +16,13 @@
 public class UserFacade {
     private final UserService userService;
 
+    @Transactional
     public UserResult register(String loginId, String email, LocalDate birth, Gender gender) {
         User user = userService.registerUser(loginId, email, birth, gender);
         return UserResult.from(user);
     }
 
+    @Transactional(readOnly = true)
     public UserResult retrieveUserProfile(String loginId) {
         User user = userService.findById(loginId);
         if (user == null) {
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 15bac5a54..aaa12108e 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
@@ -3,14 +3,20 @@
 import com.loopers.application.point.PointResult;
 import jakarta.validation.constraints.Positive;
 
+import java.util.Objects;
+
 public class PointDto {
 
+  private PointDto() {
+  }
+
   public record PointResponse(
       Long userId,
       Long balance
   ) {
 
     public static PointResponse from(PointResult pointResult) {
+      Objects.requireNonNull(pointResult, "pointResult는 null일 수 없습니다.");
       return new PointResponse(
           pointResult.userId(),
           pointResult.amount()
@@ -29,6 +35,7 @@ public record ChargeResponse(
   ) {
 
     public static ChargeResponse from(PointResult pointResult) {
+      Objects.requireNonNull(pointResult, "pointResult는 null일 수 없습니다.");
       return new ChargeResponse(
           pointResult.userId(),
           pointResult.amount()
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 d641aac0b..84c1302e8 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
@@ -37,7 +37,7 @@ public ApiResponse<UserDto.UserResponse> register(
 
     @GetMapping("/{loginId}")
     @Override
-    public ApiResponse<UserDto.UserResponse> retrieveUserProfile(@PathVariable String loginId) {
+    public ApiResponse<UserDto.UserResponse> retrieveUserProfile(@PathVariable("loginId") String loginId) {
         UserResult userResult = userFacade.retrieveUserProfile(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 be1c2e3e6..b39ff12ad 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
@@ -9,8 +9,13 @@
 import jakarta.validation.constraints.Past;
 import jakarta.validation.constraints.Pattern;
 import java.time.LocalDate;
+import java.util.Objects;
 
 public class UserDto {
+
+    private UserDto() {
+    }
+
     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";
@@ -41,6 +46,7 @@ public record UserResponse(
         Gender gender
     ) {
         public static UserResponse from(UserResult result) {
+            Objects.requireNonNull(result, "result는 null일 수 없습니다.");
             return new UserResponse(
                 result.loginId(),
                 result.email(),
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 9458508e9..f53c71b23 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
@@ -6,8 +6,6 @@
 import com.loopers.domain.user.UserRepository;
 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;
@@ -34,17 +32,9 @@ class PointServiceIntegrationTest extends IntegrationTestSupport {
   @Autowired
   private UserRepository userRepository;
 
-  @Autowired
-  private DatabaseCleanUp databaseCleanUp;
-
   @Autowired
   private PointService pointService;
 
-  @AfterEach
-  void tearDown() {
-    databaseCleanUp.truncateAllTables();
-  }
-
   @Nested
   @DisplayName("포인트 조회 시")
   class Get {
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 e5653a6c0..86c211fde 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,10 +12,8 @@
 import com.loopers.support.test.IntegrationTestSupport;
 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;
 import org.junit.jupiter.api.DisplayName;
 import org.junit.jupiter.api.Nested;
@@ -28,9 +26,6 @@ class UserServiceIntegrationTest extends IntegrationTestSupport {
   @Autowired
   private UserRepository userRepository;
 
-  @Autowired
-  private DatabaseCleanUp databaseCleanUp;
-
   private UserService userService;
 
   @BeforeEach
@@ -38,11 +33,6 @@ void setUp() {
     userService = new UserService(userRepository, Clock.systemDefaultZone());
   }
 
-  @AfterEach
-  void tearDown() {
-    databaseCleanUp.truncateAllTables();
-  }
-
   @Nested
   @DisplayName("회원가입 시")
   class RegisterUser {
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 89a8da7c8..981fe26ab 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
@@ -6,6 +6,7 @@
 import com.loopers.interfaces.api.point.PointDto.ChargeRequest;
 import com.loopers.interfaces.api.point.PointDto.ChargeResponse;
 import com.loopers.interfaces.api.point.PointDto.PointResponse;
+import com.loopers.interfaces.api.support.ApiHeaders;
 import com.loopers.utils.DatabaseCleanUp;
 import com.loopers.infrastructure.point.PointJpaRepository;
 import com.loopers.infrastructure.user.UserJpaRepository;
@@ -106,7 +107,7 @@ void returnsPoint_whenPointExists() {
 
       // when
       HttpHeaders httpHeaders = new HttpHeaders();
-      httpHeaders.set("X-USER-ID", String.valueOf(savedUser.getId()));
+      httpHeaders.set(ApiHeaders.USER_ID, String.valueOf(savedUser.getId()));
 
       ParameterizedTypeReference<ApiResponse<PointResponse>> responseType =
           new ParameterizedTypeReference<>() {
@@ -136,7 +137,7 @@ void returnsNull_whenUserDoesNotExists() {
       Long userId = 999999L;
       // when
       HttpHeaders httpHeaders = new HttpHeaders();
-      httpHeaders.set("X-USER-ID", String.valueOf(userId));
+      httpHeaders.set(ApiHeaders.USER_ID, String.valueOf(userId));
 
       ParameterizedTypeReference<ApiResponse<PointResponse>> responseType =
           new ParameterizedTypeReference<>() {
@@ -182,7 +183,7 @@ void returnsTotalBalance_whenChargingThousand() {
 
       // when
       HttpHeaders httpHeaders = new HttpHeaders();
-      httpHeaders.set("X-USER-ID", String.valueOf(savedUser.getId()));
+      httpHeaders.set(ApiHeaders.USER_ID, String.valueOf(savedUser.getId()));
 
       ParameterizedTypeReference<ApiResponse<ChargeResponse>> responseType =
           new ParameterizedTypeReference<>() {
@@ -213,7 +214,7 @@ void returnsNotFoundException_whenUserDoesNotExists() {
 
       // when
       HttpHeaders httpHeaders = new HttpHeaders();
-      httpHeaders.set("X-USER-ID", String.valueOf(userId));
+      httpHeaders.set(ApiHeaders.USER_ID, String.valueOf(userId));
 
       ParameterizedTypeReference<ApiResponse<ChargeResponse>> responseType =
           new ParameterizedTypeReference<>() {

From 492a1173c3e62f0ee09ea57134ee725546bdcde7 Mon Sep 17 00:00:00 2001
From: green <green@mobidoc.us>
Date: Sat, 22 Nov 2025 18:21:42 +0900
Subject: [PATCH 14/14] =?UTF-8?q?refactor:=20Like=20=EB=8F=84=EB=A9=94?=
 =?UTF-8?q?=EC=9D=B8=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20Order=20=ED=85=8C?=
 =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Like 멱등성 처리 방식 변경:
- REQUIRES_NEW + try-catch → INSERT IGNORE + boolean 반환
- 독립 트랜잭션 제거로 커넥션 풀 부담 감소
- 미사용 isLikedBy() 메서드 제거

ProductLikeService:
- createLike 반환타입 void → boolean
- findLikeStatusByProductId userId null 허용 (비회원)

OrderApiE2ETest:
- save() → saveAndFlush()로 즉시 영속화
---
 .../loopers/application/like/LikeFacade.java  | 12 +++------
 .../domain/productlike/ProductLike.java       |  5 ----
 .../productlike/ProductLikeRepository.java    |  2 +-
 .../productlike/ProductLikeService.java       | 11 +++-----
 .../productlike/ProductLikeJpaRepository.java |  9 +++++++
 .../ProductLikeRepositoryImpl.java            | 11 +++++---
 .../interfaces/api/order/OrderApiE2ETest.java | 26 +++++++++----------
 7 files changed, 39 insertions(+), 37 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 a4006d615..5cc913b2e 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
@@ -7,7 +7,6 @@
 import com.loopers.support.error.CoreException;
 import com.loopers.support.error.ErrorType;
 import lombok.RequiredArgsConstructor;
-import org.springframework.dao.DataIntegrityViolationException;
 import org.springframework.data.domain.Page;
 import org.springframework.data.domain.Pageable;
 import org.springframework.stereotype.Component;
@@ -25,14 +24,11 @@ public void registerProductLike(Long userId, Long productId) {
     productService.getById(productId)
         .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
 
-    // createLike는 독립 트랜잭션(REQUIRES_NEW)에서 실행
-    // UNIQUE 제약 위반 시 createLike 트랜잭션만 롤백되고 외부 트랜잭션에는 영향 없음
-    try {
-      productLikeService.createLike(userId, productId);
-      productService.increaseLikeCount(productId);
-    } catch (DataIntegrityViolationException e) {
-      // 중복 좋아요 시도 무시 (멱등성 보장)
+    boolean result = productLikeService.createLike(userId, productId);
+    if (!result) {
+      return;
     }
+    productService.increaseLikeCount(productId);
   }
 
   @Transactional
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 b872127c3..d49ffd566 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,7 +4,6 @@
 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,10 +52,6 @@ public static ProductLike of(Long userId, Long productId, LocalDateTime likedAt)
     return new ProductLike(userId, productId, likedAt);
   }
 
-  public boolean isLikedBy(Long userId) {
-    return Objects.equals(this.userId, userId);
-  }
-
   private void validateUserId(Long userId) {
     if (userId == null) {
       throw new CoreException(ErrorType.INVALID_PRODUCT_LIKE_USER_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
index 93751bae3..d366afdaa 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,7 +10,7 @@ public interface ProductLikeRepository {
 
   ProductLike save(ProductLike productLike);
 
-  ProductLike saveAndFlush(ProductLike productLike);
+  boolean 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 145c5ec46..d151be3c2 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
@@ -10,7 +10,6 @@
 import org.springframework.data.domain.Page;
 import org.springframework.data.domain.Pageable;
 import org.springframework.stereotype.Component;
-import org.springframework.transaction.annotation.Propagation;
 import org.springframework.transaction.annotation.Transactional;
 
 @Component
@@ -28,9 +27,8 @@ public boolean isLiked(Long userId, Long productId) {
     return productLikeRepository.existsByUserIdAndProductId(userId, productId);
   }
 
-
+  //userId는 null일수 있음 (비회원)
   public Map<Long, Boolean> findLikeStatusByProductId(Long userId, List<Long> productIds) {
-    Objects.requireNonNull(userId, "userId는 null일 수 없습니다.");
     Objects.requireNonNull(productIds, "productIds는 null일 수 없습니다.");
 
     List<Long> distinctProductIds = productIds.stream()
@@ -49,11 +47,10 @@ public Map<Long, Boolean> findLikeStatusByProductId(Long userId, List<Long> prod
         ));
   }
 
-
-  @Transactional(propagation = Propagation.REQUIRES_NEW)
-  public void createLike(Long userId, Long productId) {
+  @Transactional
+  public boolean createLike(Long userId, Long productId) {
     ProductLike like = ProductLike.of(userId, productId, LocalDateTime.now(clock));
-    productLikeRepository.saveAndFlush(like);
+    return productLikeRepository.saveAndFlush(like);
   }
 
   @Transactional
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 670b5ab98..7cbf5dfe0 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
@@ -6,12 +6,21 @@
 import org.springframework.data.jpa.repository.Query;
 import org.springframework.data.repository.query.Param;
 
+import java.time.LocalDateTime;
 import java.util.List;
 
 public interface ProductLikeJpaRepository extends JpaRepository<ProductLike, Long> {
 
   boolean existsByUserIdAndProductId(Long userId, Long productId);
 
+  @Modifying
+  @Query(nativeQuery = true,
+      value = "INSERT IGNORE INTO product_like (ref_user_id, ref_product_id, liked_at, created_at, updated_at) " +
+              "VALUES (:userId, :productId, :likedAt, NOW(), NOW())")
+  int insertIgnore(@Param("userId") Long userId,
+                   @Param("productId") Long productId,
+                   @Param("likedAt") LocalDateTime likedAt);
+
   List<ProductLike> findByUserIdAndProductIdIn(Long userId, List<Long> productIds);
 
   @Modifying
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 73ebe9661..4dc69ea7e 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
@@ -30,12 +30,17 @@ public List<ProductLike> findByUserIdAndProductIdIn(Long userId, List<Long> prod
 
   @Override
   public ProductLike save(ProductLike productLike) {
-    return jpaRepository.save(productLike);
+    return jpaRepository.saveAndFlush(productLike);
   }
 
   @Override
-  public ProductLike saveAndFlush(ProductLike productLike) {
-    return jpaRepository.saveAndFlush(productLike);
+  public boolean saveAndFlush(ProductLike productLike) {
+    int result = jpaRepository.insertIgnore(
+        productLike.getUserId(),
+        productLike.getProductId(),
+        productLike.getLikedAt()
+    );
+    return result > 0;
   }
 
   @Override
diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java
index 25fe3821b..b8dc5698e 100644
--- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java
@@ -100,11 +100,11 @@ void returnsOrdersWithPagination_whenValidUserId() {
 
       Order first = Order.of(user.getId(), OrderStatus.PENDING, 30000L, FIRST_ORDER_AT_2025_10_30_10_00);
       addOrderItem(first, product.getId(), "운동화", 1L, 30000L);
-      orderJpaRepository.save(first);
+      Order savedFirst = orderJpaRepository.saveAndFlush(first);
 
       Order second = Order.of(user.getId(), OrderStatus.COMPLETED, 50000L, SECOND_ORDER_AT_2025_10_30_11_00);
       addOrderItem(second, product.getId(), "운동화", 2L, 25000L);
-      orderJpaRepository.save(second);
+      Order savedSecond = orderJpaRepository.saveAndFlush(second);
 
       HttpHeaders headers = new HttpHeaders();
       headers.set(ApiHeaders.USER_ID, user.getId().toString());
@@ -127,10 +127,10 @@ void returnsOrdersWithPagination_whenValidUserId() {
           () -> assertThat(response.getBody().data().orders())
               .hasSize(2)
               .element(0).extracting("orderId", "totalAmount", "status", "itemCount")
-              .containsExactly(second.getId(), 50000L, OrderStatus.COMPLETED, 1),
+              .containsExactly(savedSecond.getId(), 50000L, OrderStatus.COMPLETED, 1),
           () -> assertThat(response.getBody().data().orders())
               .element(1).extracting("orderId", "totalAmount", "status", "itemCount")
-              .containsExactly(first.getId(), 30000L, OrderStatus.PENDING, 1)
+              .containsExactly(savedFirst.getId(), 30000L, OrderStatus.PENDING, 1)
       );
     }
 
@@ -144,7 +144,7 @@ void returnsPaginatedOrders_whenPageSizeIsSet() {
       for (int i = 0; i < 3; i++) {
         Order order = Order.of(user.getId(), OrderStatus.PENDING, 30000L, FIRST_ORDER_AT_2025_10_30_10_00);
         addOrderItem(order, product.getId(), "운동화", 1L, 30000L);
-        orderJpaRepository.save(order);
+        orderJpaRepository.saveAndFlush(order);
       }
 
       HttpHeaders headers = new HttpHeaders();
@@ -182,11 +182,11 @@ void returnsOnlyOwnOrders_whenMultipleUsersExist() {
 
       Order order1 = Order.of(user1.getId(), OrderStatus.PENDING, 30000L, FIRST_ORDER_AT_2025_10_30_10_00);
       addOrderItem(order1, product.getId(), "운동화", 1L, 30000L);
-      orderJpaRepository.save(order1);
+      Order savedOrder1 = orderJpaRepository.saveAndFlush(order1);
 
       Order order2 = Order.of(user2.getId(), OrderStatus.PENDING, 50000L, FIRST_ORDER_AT_2025_10_30_10_00);
       addOrderItem(order2, product.getId(), "운동화", 2L, 25000L);
-      orderJpaRepository.save(order2);
+      orderJpaRepository.saveAndFlush(order2);
 
       HttpHeaders headers = new HttpHeaders();
       headers.set(ApiHeaders.USER_ID, user1.getId().toString());
@@ -209,7 +209,7 @@ void returnsOnlyOwnOrders_whenMultipleUsersExist() {
           () -> assertThat(response.getBody().data().orders())
               .hasSize(1)
               .element(0).extracting("orderId")
-              .isEqualTo(order1.getId())
+              .isEqualTo(savedOrder1.getId())
       );
     }
   }
@@ -229,14 +229,14 @@ void returnsOrderDetail_whenValidOrderId() {
       Order order = Order.of(user.getId(), OrderStatus.PENDING, 50000L, FIRST_ORDER_AT_2025_10_30_10_00);
       addOrderItem(order, product1.getId(), "운동화", 1L, 30000L);
       addOrderItem(order, product2.getId(), "슬리퍼", 1L, 20000L);
-      orderJpaRepository.save(order);
+      Order savedOrder = orderJpaRepository.saveAndFlush(order);
 
       HttpHeaders headers = new HttpHeaders();
       headers.set(ApiHeaders.USER_ID, user.getId().toString());
 
       ResponseEntity<ApiResponse<OrderDetailResponse>> response =
           testRestTemplate.exchange(
-              BASE_URL + "/" + order.getId(),
+              BASE_URL + "/" + savedOrder.getId(),
               HttpMethod.GET,
               new HttpEntity<>(headers),
               ORDER_DETAIL_RESPONSE_TYPE
@@ -248,7 +248,7 @@ void returnsOrderDetail_whenValidOrderId() {
           () -> assertThat(response.getBody()).isNotNull(),
           () -> assertThat(response.getBody().data())
               .extracting("orderId", "status", "totalAmount")
-              .containsExactly(order.getId(), OrderStatus.PENDING, 50000L),
+              .containsExactly(savedOrder.getId(), OrderStatus.PENDING, 50000L),
           () -> assertThat(response.getBody().data().items())
               .hasSize(2)
               .extracting("productId", "productName", "quantity", "price")
@@ -269,14 +269,14 @@ void returnsForbidden_whenAccessingOthersOrder() {
 
       Order order = Order.of(user1.getId(), OrderStatus.PENDING, 30000L, FIRST_ORDER_AT_2025_10_30_10_00);
       addOrderItem(order, product.getId(), "운동화", 1L, 30000L);
-      orderJpaRepository.save(order);
+      Order savedOrder = orderJpaRepository.saveAndFlush(order);
 
       HttpHeaders headers = new HttpHeaders();
       headers.set(ApiHeaders.USER_ID, user2.getId().toString());
 
       ResponseEntity<ApiResponse<OrderDetailResponse>> response =
           testRestTemplate.exchange(
-              BASE_URL + "/" + order.getId(),
+              BASE_URL + "/" + savedOrder.getId(),
               HttpMethod.GET,
               new HttpEntity<>(headers),
               ORDER_DETAIL_RESPONSE_TYPE
