From dc3a1907d274dda6869d784d050ac1e93375e495 Mon Sep 17 00:00:00 2001
From: minor7295 <44902090+minor7295@users.noreply.github.com>
Date: Thu, 13 Nov 2025 21:07:33 +0900
Subject: [PATCH 1/7] Feature/refactor user (#7)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* refactor: 사용자 및 포인트에 대한 단위 테스트 수정

* refactor: point도메인을 user 도메인으로 통합

* refactor: user 도메인 관련 통합테스트 로직 수정

* refactor: user 도메인 관련 서비스 로직 구현

* refactor: user 도메인 관련 E2E 테스트 로직 수정

* refactor: user 도메인 관련 API 구현
---
 .../pointwallet/PointWalletFacade.java        |  83 +++++++++++++
 .../application/signup/SignUpFacade.java      |  41 +++++--
 .../application/userinfo/UserInfoFacade.java  |  69 +++++++++++
 .../java/com/loopers/domain/point/Point.java  |  84 -------------
 .../loopers/domain/point/PointRepository.java |  30 -----
 .../loopers/domain/point/PointService.java    |  68 -----------
 .../java/com/loopers/domain/user/Point.java   |  98 +++++++++++++++
 .../java/com/loopers/domain/user/User.java    |  41 ++++++-
 .../com/loopers/domain/user/UserService.java  |  16 +--
 .../point/PointJpaRepository.java             |  33 -----
 .../point/PointRepositoryImpl.java            |  39 ------
 .../PointWalletV1Controller.java}             |  37 +++---
 .../PointWalletV1Dto.java}                    |  19 +--
 .../api/signup/SignUpV1Controller.java        |  19 +--
 .../api/userinfo/UserInfoV1Controller.java    |  20 +--
 .../api/userinfo/UserInfoV1Dto.java           |  19 +--
 .../PointWalletFacadeIntegrationTest.java     | 115 ++++++++++++++++++
 .../signup/SignUpFacadeIntegrationTest.java}  |  65 ++--------
 .../UserInfoFacadeIntegrationTest.java        |  80 ++++++++++++
 .../point/PointServiceIntegrationTest.java    |  94 --------------
 .../com/loopers/domain/point/PointTest.java   |  37 ------
 .../com/loopers/domain/user/UserTest.java     |   6 +-
 .../loopers/domain/user/UserTestFixture.java  |   2 +
 ...Test.java => PointWalletV1ApiE2ETest.java} |  28 ++---
 .../interfaces/api/UserInfoV1ApiE2ETest.java  |   2 +-
 25 files changed, 595 insertions(+), 550 deletions(-)
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/pointwallet/PointWalletFacade.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/userinfo/UserInfoFacade.java
 delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java
 delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/point/PointRepository.java
 delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/Point.java
 delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointJpaRepository.java
 delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointRepositoryImpl.java
 rename apps/commerce-api/src/main/java/com/loopers/interfaces/api/{point/PointsV1Controller.java => pointwallet/PointWalletV1Controller.java} (54%)
 rename apps/commerce-api/src/main/java/com/loopers/interfaces/api/{point/PointsV1Dto.java => pointwallet/PointWalletV1Dto.java} (61%)
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/pointwallet/PointWalletFacadeIntegrationTest.java
 rename apps/commerce-api/src/test/java/com/loopers/{domain/user/UserServiceIntegrationTest.java => application/signup/SignUpFacadeIntegrationTest.java} (60%)
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/userinfo/UserInfoFacadeIntegrationTest.java
 delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java
 delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/point/PointTest.java
 rename apps/commerce-api/src/test/java/com/loopers/interfaces/api/{PointsV1ApiE2ETest.java => PointWalletV1ApiE2ETest.java} (88%)

diff --git a/apps/commerce-api/src/main/java/com/loopers/application/pointwallet/PointWalletFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/pointwallet/PointWalletFacade.java
new file mode 100644
index 000000000..1e1d171a1
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/pointwallet/PointWalletFacade.java
@@ -0,0 +1,83 @@
+package com.loopers.application.pointwallet;
+
+import com.loopers.domain.user.Point;
+import com.loopers.domain.user.User;
+import com.loopers.domain.user.UserRepository;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import jakarta.transaction.Transactional;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+/**
+ * 포인트 지갑 파사드.
+ * <p>
+ * 포인트 조회 및 충전 유즈케이스를 처리하는 애플리케이션 서비스입니다.
+ * 트랜잭션 경계를 관리하여 데이터 일관성을 보장합니다.
+ * </p>
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@RequiredArgsConstructor
+@Component
+public class PointWalletFacade {
+    private final UserRepository userRepository;
+
+    /**
+     * 사용자의 포인트를 조회합니다.
+     *
+     * @param userId 조회할 사용자 ID
+     * @return 포인트 정보
+     * @throws CoreException 사용자를 찾을 수 없는 경우
+     */
+    public PointsInfo getPoints(String userId) {
+        User user = userRepository.findByUserId(userId);
+        if (user == null) {
+            throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.");
+        }
+        return PointsInfo.from(user);
+    }
+
+    /**
+     * 사용자의 포인트를 충전합니다.
+     * <p>
+     * 트랜잭션 내에서 실행되어 데이터 일관성을 보장합니다.
+     * </p>
+     *
+     * @param userId 충전할 사용자 ID
+     * @param amount 충전할 포인트 금액
+     * @return 충전된 포인트 정보
+     * @throws CoreException 사용자를 찾을 수 없거나 충전 금액이 유효하지 않은 경우
+     */
+    @Transactional
+    public PointsInfo chargePoint(String userId, Long amount) {
+        User user = userRepository.findByUserId(userId);
+        if (user == null) {
+            throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.");
+        }
+        Point point = Point.of(amount);
+        user.receivePoint(point);
+        User savedUser = userRepository.save(user);
+        return PointsInfo.from(savedUser);
+    }
+
+    /**
+     * 포인트 정보를 담는 레코드.
+     *
+     * @param userId 사용자 ID
+     * @param balance 포인트 잔액
+     */
+    public record PointsInfo(String userId, Long balance) {
+        /**
+         * User 엔티티로부터 PointsInfo를 생성합니다.
+         *
+         * @param user 사용자 엔티티
+         * @return 생성된 PointsInfo
+         */
+        public static PointsInfo from(User user) {
+            return new PointsInfo(user.getUserId(), user.getPoint().getValue());
+        }
+    }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/signup/SignUpFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/signup/SignUpFacade.java
index 2b5ada30c..9ede52abb 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/signup/SignUpFacade.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/signup/SignUpFacade.java
@@ -1,18 +1,22 @@
 package com.loopers.application.signup;
 
-import com.loopers.domain.point.PointService;
 import com.loopers.domain.user.Gender;
+import com.loopers.domain.user.Point;
 import com.loopers.domain.user.User;
 import com.loopers.domain.user.UserService;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
 import jakarta.transaction.Transactional;
 import lombok.RequiredArgsConstructor;
 import org.springframework.stereotype.Component;
 
+import java.util.Locale;
+
 /**
  * 회원가입 파사드.
  * <p>
- * 회원가입 시 사용자 생성과 포인트 초기화를 조율하는 
- * 애플리케이션 서비스입니다.
+ * 회원가입 시 사용자 생성을 처리하는 애플리케이션 서비스입니다.
+ * 사용자 생성 시 포인트는 자동으로 0으로 초기화됩니다.
  * 트랜잭션 경계를 관리하여 데이터 일관성을 보장합니다.
  * </p>
  *
@@ -24,26 +28,41 @@
 public class SignUpFacade {
     private final UserService userService;
 
-    private final PointService pointService;
-
     /**
      * 회원가입을 처리합니다.
      * <p>
-     * 사용자를 생성하고 초기 포인트(0)를 부여합니다.
+     * 사용자를 생성하며, 포인트는 자동으로 0으로 초기화됩니다.
      * 전체 과정이 하나의 트랜잭션으로 처리됩니다.
      * </p>
      *
      * @param userId 사용자 ID
      * @param email 이메일 주소
      * @param birthDateStr 생년월일 (yyyy-MM-dd)
-     * @param gender 성별
+     * @param genderStr 성별 문자열 (MALE 또는 FEMALE)
      * @return 생성된 사용자 정보
-     * @throws com.loopers.support.error.CoreException 유효성 검증 실패 또는 중복 ID 존재 시
+     * @throws CoreException gender 값이 유효하지 않거나, 유효성 검증 실패 또는 중복 ID 존재 시
      */
     @Transactional
-    public SignUpInfo signUp(String userId, String email, String birthDateStr, Gender gender) {
-        User user = userService.create(userId, email, birthDateStr, gender);
-        pointService.create(user, 0L);
+    public SignUpInfo signUp(String userId, String email, String birthDateStr, String genderStr) {
+        Gender gender = parseGender(genderStr);
+        Point point = Point.of(0L);
+        User user = userService.create(userId, email, birthDateStr, gender, point);
         return SignUpInfo.from(user);
     }
+
+    /**
+     * 성별 문자열을 Gender enum으로 변환합니다.
+     *
+     * @param genderStr 성별 문자열
+     * @return Gender enum
+     * @throws CoreException gender 값이 유효하지 않은 경우
+     */
+    private Gender parseGender(String genderStr) {
+        try {
+            String genderValue = genderStr.trim().toUpperCase(Locale.ROOT);
+            return Gender.valueOf(genderValue);
+        } catch (IllegalArgumentException e) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "gender 값이 올바르지 않습니다.");
+        }
+    }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/userinfo/UserInfoFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/userinfo/UserInfoFacade.java
new file mode 100644
index 000000000..ceae04eef
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/userinfo/UserInfoFacade.java
@@ -0,0 +1,69 @@
+package com.loopers.application.userinfo;
+
+import com.loopers.domain.user.User;
+import com.loopers.domain.user.UserRepository;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+/**
+ * 사용자 정보 조회 파사드.
+ * <p>
+ * 사용자 정보 조회 유즈케이스를 처리하는 애플리케이션 서비스입니다.
+ * </p>
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@RequiredArgsConstructor
+@Component
+public class UserInfoFacade {
+    private final UserRepository userRepository;
+
+    /**
+     * 사용자 ID로 사용자 정보를 조회합니다.
+     *
+     * @param userId 조회할 사용자 ID
+     * @return 조회된 사용자 정보
+     * @throws CoreException 사용자를 찾을 수 없는 경우
+     */
+    public UserInfo getUserInfo(String userId) {
+        User user = userRepository.findByUserId(userId);
+        if (user == null) {
+            throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.");
+        }
+        return UserInfo.from(user);
+    }
+
+    /**
+     * 사용자 정보를 담는 레코드.
+     *
+     * @param userId 사용자 ID
+     * @param email 이메일 주소
+     * @param birthDate 생년월일
+     * @param gender 성별
+     */
+    public record UserInfo(
+        String userId,
+        String email,
+        java.time.LocalDate birthDate,
+        com.loopers.domain.user.Gender gender
+    ) {
+        /**
+         * User 엔티티로부터 UserInfo를 생성합니다.
+         *
+         * @param user 사용자 엔티티
+         * @return 생성된 UserInfo
+         */
+        public static UserInfo from(User user) {
+            return new UserInfo(
+                user.getUserId(),
+                user.getEmail(),
+                user.getBirthDate(),
+                user.getGender()
+            );
+        }
+    }
+}
+
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
deleted file mode 100644
index 663d4080f..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java
+++ /dev/null
@@ -1,84 +0,0 @@
-package com.loopers.domain.point;
-
-import com.loopers.domain.BaseEntity;
-import com.loopers.domain.user.User;
-import com.loopers.support.error.CoreException;
-import com.loopers.support.error.ErrorType;
-import jakarta.persistence.*;
-import lombok.AccessLevel;
-import lombok.Getter;
-import lombok.NoArgsConstructor;
-
-/**
- * 포인트 도메인 엔티티.
- * <p>
- * 사용자의 포인트 잔액을 관리하며, 포인트 충전 기능을 제공합니다.
- * User와 일대일 관계를 맺고 있습니다.
- * </p>
- *
- * @author Loopers
- * @version 1.0
- */
-@Entity
-@Table(name = "point")
-@NoArgsConstructor(access = AccessLevel.PROTECTED)
-@Getter
-public class Point extends BaseEntity {
-    @OneToOne(fetch = FetchType.LAZY, optional = false)
-    @JoinColumn(
-        name = "user_id",
-        referencedColumnName = "id",
-        foreignKey = @ForeignKey(value = ConstraintMode.NO_CONSTRAINT)
-    )
-    private User user;
-
-    @Column(name = "balance", nullable = false)
-    private Long balance;
-
-    /**
-     * Point 인스턴스를 생성합니다.
-     *
-     * @param user 포인트 소유자
-     * @param balance 초기 잔액 (null인 경우 0으로 초기화)
-     */
-    public Point(User user, Long balance) {
-        this.user = user;
-        this.balance = balance != null ? balance : 0L;
-    }
-
-    /**
-     * Point 인스턴스를 생성하는 정적 팩토리 메서드.
-     *
-     * @param user 포인트 소유자
-     * @param balance 초기 잔액
-     * @return 생성된 Point 인스턴스
-     */
-    public static Point of(User user, Long balance) {
-        return new Point(user, balance);
-    }
-
-    /**
-     * 포인트를 충전합니다.
-     *
-     * @param amount 충전할 포인트 금액 (0보다 커야 함)
-     * @throws CoreException amount가 null이거나 0 이하일 경우
-     */
-    public void charge(Long amount) {
-        validateChargeAmount(amount);
-        this.balance += amount;
-    }
-
-    /**
-     * 충전 금액의 유효성을 검증합니다.
-     *
-     * @param amount 검증할 충전 금액
-     * @throws CoreException amount가 null이거나 0 이하일 경우
-     */
-    private void validateChargeAmount(Long amount) {
-        if (amount == null || amount <= 0) {
-            throw new CoreException(ErrorType.BAD_REQUEST, "포인트는 0보다 큰 값이어야 합니다.");
-        }
-    }
-}
-
-
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointRepository.java
deleted file mode 100644
index cbf2c3d08..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointRepository.java
+++ /dev/null
@@ -1,30 +0,0 @@
-package com.loopers.domain.point;
-
-/**
- * Point 엔티티에 대한 저장소 인터페이스.
- * <p>
- * 포인트 정보의 영속성 계층과의 상호작용을 정의합니다.
- * </p>
- *
- * @author Loopers
- * @version 1.0
- */
-public interface PointRepository {
-    /**
-     * 포인트를 저장합니다.
-     *
-     * @param point 저장할 포인트
-     * @return 저장된 포인트
-     */
-    Point save(Point point);
-    
-    /**
-     * 사용자 ID로 포인트를 조회합니다.
-     *
-     * @param userId 조회할 사용자 ID
-     * @return 조회된 포인트, 없으면 null
-     */
-    Point findByUserId(String userId);
-}
-
-
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
deleted file mode 100644
index 5d03e73ef..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java
+++ /dev/null
@@ -1,68 +0,0 @@
-package com.loopers.domain.point;
-
-import com.loopers.domain.user.User;
-import com.loopers.support.error.CoreException;
-import com.loopers.support.error.ErrorType;
-import lombok.RequiredArgsConstructor;
-import org.springframework.stereotype.Component;
-import org.springframework.transaction.annotation.Transactional;
-
-/**
- * 포인트 도메인 서비스.
- * <p>
- * 포인트 생성, 조회, 충전 등의 도메인 로직을 처리합니다.
- * </p>
- *
- * @author Loopers
- * @version 1.0
- */
-@RequiredArgsConstructor
-@Component
-public class PointService {
-    private final PointRepository pointRepository;
-
-    /**
-     * 새로운 포인트를 생성합니다.
-     *
-     * @param user 포인트 소유자
-     * @param balance 초기 잔액
-     * @return 생성된 포인트
-     */
-    public Point create(User user, Long balance) {
-        Point point = Point.of(user, balance);
-        return pointRepository.save(point);
-    }
-
-    /**
-     * 사용자 ID로 포인트를 조회합니다.
-     *
-     * @param userId 조회할 사용자 ID
-     * @return 조회된 포인트, 없으면 null
-     */
-    public Point findByUserId(String userId) {
-        return pointRepository.findByUserId(userId);
-    }
-
-    /**
-     * 사용자의 포인트를 충전합니다.
-     * <p>
-     * 트랜잭션 내에서 실행되어 데이터 일관성을 보장합니다.
-     * </p>
-     *
-     * @param userId 사용자 ID
-     * @param amount 충전할 금액 (0보다 커야 함)
-     * @return 충전된 포인트
-     * @throws CoreException 포인트를 찾을 수 없거나 충전 금액이 유효하지 않을 경우
-     */
-    @Transactional
-    public Point charge(String userId, Long amount) {
-        Point point = pointRepository.findByUserId(userId);
-        if (point == null) {
-            throw new CoreException(ErrorType.NOT_FOUND, "포인트를 찾을 수 없습니다.");
-        }
-        point.charge(amount);
-        return pointRepository.save(point);
-    }
-}
-
-
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/Point.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/Point.java
new file mode 100644
index 000000000..5754b0dad
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/Point.java
@@ -0,0 +1,98 @@
+package com.loopers.domain.user;
+
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import jakarta.persistence.Column;
+import jakarta.persistence.Embeddable;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+
+/**
+ * 포인트 Value Object.
+ * <p>
+ * 사용자의 포인트 잔액을 나타내는 불변 값 객체입니다.
+ * 값으로 식별되며 불변성을 가집니다.
+ * </p>
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Embeddable
+@Getter
+@EqualsAndHashCode
+public class Point {
+    @Column(name = "balance", nullable = false)
+    private Long value;
+
+    protected Point() {
+        this.value = 0L; // JPA를 위한 기본 생성자
+    }
+
+    /**
+     * Point 인스턴스를 생성합니다.
+     *
+     * @param value 포인트 값 (0 이상이어야 함)
+     * @throws CoreException value가 null이거나 음수일 경우
+     */
+    public Point(Long value) {
+        validateValue(value);
+        this.value = value;
+    }
+
+    /**
+     * Point 인스턴스를 생성하는 정적 팩토리 메서드.
+     *
+     * @param value 포인트 값
+     * @return 생성된 Point 인스턴스
+     * @throws CoreException value가 null이거나 음수일 경우
+     */
+    public static Point of(Long value) {
+        return new Point(value);
+    }
+
+    /**
+     * 포인트를 더한 새로운 Point 인스턴스를 반환합니다.
+     *
+     * @param other 더할 포인트
+     * @return 새로운 Point 인스턴스
+     * @throws CoreException other가 null일 경우
+     */
+    public Point add(Point other) {
+        if (other == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "포인트는 null일 수 없습니다.");
+        }
+        return new Point(this.value + other.value);
+    }
+
+    /**
+     * 포인트를 뺀 새로운 Point 인스턴스를 반환합니다.
+     * 포인트는 감소만 가능하며 음수가 되지 않도록 도메인 레벨에서 검증합니다.
+     *
+     * @param other 뺄 포인트
+     * @return 새로운 Point 인스턴스
+     * @throws CoreException other가 null이거나 잔액이 부족할 경우
+     */
+    public Point subtract(Point other) {
+        if (other == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "포인트는 null일 수 없습니다.");
+        }
+        if (this.value < other.value) {
+            throw new CoreException(ErrorType.BAD_REQUEST,
+                String.format("포인트가 부족합니다. (현재 잔액: %d, 요청 금액: %d)", this.value, other.value));
+        }
+        return new Point(this.value - other.value);
+    }
+
+    /**
+     * 포인트 값의 유효성을 검증합니다.
+     *
+     * @param value 검증할 포인트 값
+     * @throws CoreException value가 null이거나 음수일 경우
+     */
+    private void validateValue(Long value) {
+        if (value == null || value < 0) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "포인트는 0 이상이어야 합니다.");
+        }
+    }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java
index f547389c5..96dadff67 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java
@@ -4,6 +4,7 @@
 import com.loopers.support.error.CoreException;
 import com.loopers.support.error.ErrorType;
 import jakarta.persistence.Column;
+import jakarta.persistence.Embedded;
 import jakarta.persistence.Entity;
 import jakarta.persistence.Table;
 import jakarta.persistence.Enumerated;
@@ -49,6 +50,9 @@ public class User extends BaseEntity {
     @Enumerated(EnumType.STRING)
     private Gender gender;
 
+    @Embedded
+    private Point point;
+
     private static final Pattern USER_ID_PATTERN = Pattern.compile("^[a-zA-Z0-9]{1,10}$");
     /**
      * 사용자 ID의 유효성을 검증합니다.
@@ -107,7 +111,7 @@ private static void validateBirthDate(String birthDate) {
      * @param gender 성별
      * @throws CoreException userId, email, birthDate가 유효하지 않을 경우
      */
-    public User (String userId, String email, String birthDateStr, Gender gender) {
+    public User (String userId, String email, String birthDateStr, Gender gender, Point point) {
         validateUserId(userId);
         validateEmail(email);
         validateBirthDate(birthDateStr);
@@ -116,6 +120,7 @@ public User (String userId, String email, String birthDateStr, Gender gender) {
         this.email = email;
         this.birthDate = LocalDate.parse(birthDateStr);
         this.gender = gender;
+        this.point = point;
     }
     /**
      * User 인스턴스를 생성하는 정적 팩토리 메서드.
@@ -127,8 +132,38 @@ public User (String userId, String email, String birthDateStr, Gender gender) {
      * @return 생성된 User 인스턴스
      * @throws CoreException 유효성 검증 실패 시
      */
-    public static User of(String userId, String email, String birthDate, Gender gender) {
-        return new User(userId, email, birthDate, gender);
+    public static User of(String userId, String email, String birthDate, Gender gender, Point point) {
+        return new User(userId, email, birthDate, gender, point);
+    }
+
+    /**
+     * 포인트를 반환합니다.
+     *
+     * @return 포인트 Value Object
+     */
+    public Point getPoint() {
+        return this.point;
+    }
+
+    /**
+     * 포인트를 받습니다 (충전/환불).
+     *
+     * @param point 받을 포인트
+     * @throws CoreException point가 null일 경우
+     */
+    public void receivePoint(Point point) {
+        this.point = this.point.add(point);
+    }
+
+    /**
+     * 포인트를 차감합니다.
+     * 포인트는 감소만 가능하며 음수가 되지 않도록 도메인 레벨에서 검증합니다.
+     *
+     * @param point 차감할 포인트
+     * @throws CoreException point가 null이거나 잔액이 부족할 경우
+     */
+    public void deductPoint(Point point) {
+        this.point = this.point.subtract(point);
     }
 
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java
index 8b66f855c..6621a8b62 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java
@@ -9,7 +9,8 @@
 /**
  * 사용자 도메인 서비스.
  * <p>
- * 사용자 생성 및 조회 등의 도메인 로직을 처리합니다.
+ * 사용자 생성 등의 도메인 로직을 처리합니다.
+ * Repository에 의존하며 데이터 무결성 제약 조건을 처리합니다.
  * </p>
  *
  * @author Loopers
@@ -30,8 +31,8 @@ public class UserService {
      * @return 생성된 사용자
      * @throws CoreException 중복된 사용자 ID가 존재하거나 유효성 검증 실패 시
      */
-    public User create(String userId, String email, String birthDateStr, Gender gender) {
-        User user = User.of(userId, email, birthDateStr, gender);
+    public User create(String userId, String email, String birthDateStr, Gender gender, Point point) {
+        User user = User.of(userId, email, birthDateStr, gender, point);
         try {
             return userRepository.save(user);
         } catch (DataIntegrityViolationException e) {
@@ -42,13 +43,4 @@ public User create(String userId, String email, String birthDateStr, Gender gend
         }
     }
 
-    /**
-     * 사용자 ID로 사용자를 조회합니다.
-     *
-     * @param userId 조회할 사용자 ID
-     * @return 조회된 사용자, 없으면 null
-     */
-    public User findByUserId(String userId) {
-        return userRepository.findByUserId(userId);
-    }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointJpaRepository.java
deleted file mode 100644
index b32add174..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointJpaRepository.java
+++ /dev/null
@@ -1,33 +0,0 @@
-package com.loopers.infrastructure.point;
-
-import com.loopers.domain.point.Point;
-import org.springframework.data.jpa.repository.JpaRepository;
-import org.springframework.data.jpa.repository.Query;
-import org.springframework.data.repository.query.Param;
-
-import java.util.Optional;
-
-/**
- * Point 엔티티를 위한 Spring Data JPA 리포지토리.
- * <p>
- * JpaRepository를 확장하여 기본 CRUD 기능과 
- * 사용자 ID 기반 조회 기능을 제공합니다.
- * N+1 문제 방지를 위해 Fetch Join을 사용합니다.
- * </p>
- *
- * @author Loopers
- * @version 1.0
- */
-public interface PointJpaRepository extends JpaRepository<Point, Long> {
-    /**
-     * 사용자 ID로 포인트를 조회합니다.
-     * <p>
-     * JOIN FETCH를 사용하여 연관된 User 엔티티를 함께 로드합니다.
-     * </p>
-     *
-     * @param userId 조회할 사용자 ID
-     * @return 조회된 포인트를 담은 Optional
-     */
-    @Query("SELECT p FROM Point p JOIN FETCH p.user WHERE p.user.userId = :userId")
-    Optional<Point> findByUserId(@Param("userId") String userId);
-}
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointRepositoryImpl.java
deleted file mode 100644
index f85e31482..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointRepositoryImpl.java
+++ /dev/null
@@ -1,39 +0,0 @@
-package com.loopers.infrastructure.point;
-
-import com.loopers.domain.point.Point;
-import com.loopers.domain.point.PointRepository;
-import lombok.RequiredArgsConstructor;
-import org.springframework.stereotype.Component;
-
-/**
- * PointRepository의 JPA 구현체.
- * <p>
- * Spring Data JPA를 활용하여 Point 엔티티의 
- * 영속성 작업을 처리합니다.
- * </p>
- *
- * @author Loopers
- * @version 1.0
- */
-@RequiredArgsConstructor
-@Component
-public class PointRepositoryImpl implements PointRepository {
-    private final PointJpaRepository pointJpaRepository;
-
-    /**
-     * {@inheritDoc}
-     */
-    @Override
-    public Point save(Point point) {
-        return pointJpaRepository.save(point);
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    @Override
-    public Point findByUserId(String userId) {
-        return pointJpaRepository.findByUserId(userId).orElse(null);
-    }
-}
-
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointsV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/pointwallet/PointWalletV1Controller.java
similarity index 54%
rename from apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointsV1Controller.java
rename to apps/commerce-api/src/main/java/com/loopers/interfaces/api/pointwallet/PointWalletV1Controller.java
index c313fbb7d..4efc043ca 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointsV1Controller.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/pointwallet/PointWalletV1Controller.java
@@ -1,10 +1,7 @@
-package com.loopers.interfaces.api.point;
+package com.loopers.interfaces.api.pointwallet;
 
-import com.loopers.domain.point.Point;
-import com.loopers.domain.point.PointService;
+import com.loopers.application.pointwallet.PointWalletFacade;
 import com.loopers.interfaces.api.ApiResponse;
-import com.loopers.support.error.CoreException;
-import com.loopers.support.error.ErrorType;
 import jakarta.validation.Valid;
 import lombok.RequiredArgsConstructor;
 import org.springframework.web.bind.annotation.GetMapping;
@@ -15,9 +12,9 @@
 import org.springframework.web.bind.annotation.RestController;
 
 /**
- * 포인트 API v1 컨트롤러.
+ * 포인트 관리 API v1 컨트롤러.
  * <p>
- * 사용자의 포인트 조회 및 충전 기능을 제공합니다.
+ * 사용자의 포인트 조회 및 충전 유즈케이스를 처리합니다.
  * </p>
  *
  * @author Loopers
@@ -26,27 +23,23 @@
 @RequiredArgsConstructor
 @RestController
 @RequestMapping("/api/v1")
-public class PointsV1Controller {
+public class PointWalletV1Controller {
 
-    private final PointService pointService;
+    private final PointWalletFacade pointWalletFacade;
 
     /**
      * 현재 사용자의 포인트를 조회합니다.
      *
      * @param userId X-USER-ID 헤더로 전달된 사용자 ID
      * @return 포인트 정보를 담은 API 응답
-     * @throws CoreException 포인트를 찾을 수 없는 경우
+     * @throws CoreException 사용자를 찾을 수 없는 경우
      */
     @GetMapping("/me/points")
-    public ApiResponse<PointsV1Dto.PointsResponse> getMyPoints(
+    public ApiResponse<PointWalletV1Dto.PointsResponse> getMyPoints(
         @RequestHeader("X-USER-ID") String userId
     ) {
-        Point point = pointService.findByUserId(userId);
-        if (point == null) {
-            throw new CoreException(ErrorType.NOT_FOUND, null);
-        }
-
-        return ApiResponse.success(PointsV1Dto.PointsResponse.from(point));
+        PointWalletFacade.PointsInfo pointsInfo = pointWalletFacade.getPoints(userId);
+        return ApiResponse.success(PointWalletV1Dto.PointsResponse.from(pointsInfo));
     }
 
     /**
@@ -55,15 +48,15 @@ public ApiResponse<PointsV1Dto.PointsResponse> getMyPoints(
      * @param userId X-USER-ID 헤더로 전달된 사용자 ID
      * @param request 충전 요청 데이터 (amount)
      * @return 충전된 포인트 정보를 담은 API 응답
-     * @throws CoreException 포인트를 찾을 수 없거나 충전 금액이 유효하지 않은 경우
+     * @throws CoreException 사용자를 찾을 수 없거나 충전 금액이 유효하지 않은 경우
      */
     @PostMapping("/me/points/charge")
-    public ApiResponse<PointsV1Dto.PointsResponse> chargePoints(
+    public ApiResponse<PointWalletV1Dto.PointsResponse> chargePoints(
         @RequestHeader("X-USER-ID") String userId,
-        @Valid @RequestBody PointsV1Dto.ChargeRequest request
+        @Valid @RequestBody PointWalletV1Dto.ChargeRequest request
     ) {
-        Point point = pointService.charge(userId, request.amount());
-        return ApiResponse.success(PointsV1Dto.PointsResponse.from(point));
+        PointWalletFacade.PointsInfo pointsInfo = pointWalletFacade.chargePoint(userId, request.amount());
+        return ApiResponse.success(PointWalletV1Dto.PointsResponse.from(pointsInfo));
     }
 }
 
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointsV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/pointwallet/PointWalletV1Dto.java
similarity index 61%
rename from apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointsV1Dto.java
rename to apps/commerce-api/src/main/java/com/loopers/interfaces/api/pointwallet/PointWalletV1Dto.java
index 3107b17a0..461605598 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointsV1Dto.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/pointwallet/PointWalletV1Dto.java
@@ -1,16 +1,16 @@
-package com.loopers.interfaces.api.point;
+package com.loopers.interfaces.api.pointwallet;
 
-import com.loopers.domain.point.Point;
+import com.loopers.application.pointwallet.PointWalletFacade;
 import jakarta.validation.constraints.NotNull;
 import jakarta.validation.constraints.Positive;
 
 /**
- * 포인트 API v1의 데이터 전송 객체(DTO) 컨테이너.
+ * 포인트 관리 API v1의 데이터 전송 객체(DTO) 컨테이너.
  *
  * @author Loopers
  * @version 1.0
  */
-public class PointsV1Dto {
+public class PointWalletV1Dto {
     /**
      * 포인트 정보 응답 데이터.
      *
@@ -19,13 +19,13 @@ public class PointsV1Dto {
      */
     public record PointsResponse(String userId, Long balance) {
         /**
-         * Point 엔티티로부터 PointsResponse를 생성합니다.
+         * PointsInfo로부터 PointsResponse를 생성합니다.
          *
-         * @param point 포인트 엔티티
+         * @param pointsInfo 포인트 정보
          * @return 생성된 응답 객체
          */
-        public static PointsResponse from(Point point) {
-            return new PointsResponse(point.getUser().getUserId(), point.getBalance());
+        public static PointsResponse from(PointWalletFacade.PointsInfo pointsInfo) {
+            return new PointsResponse(pointsInfo.userId(), pointsInfo.balance());
         }
     }
 
@@ -39,4 +39,5 @@ public record ChargeRequest(
         @Positive(message = "포인트는 0보다 큰 값이어야 합니다.")
         Long amount
     ) {}
-}
\ No newline at end of file
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/signup/SignUpV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/signup/SignUpV1Controller.java
index 13f72103f..3ce1a8c6e 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/signup/SignUpV1Controller.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/signup/SignUpV1Controller.java
@@ -2,13 +2,9 @@
 
 import com.loopers.application.signup.SignUpFacade;
 import com.loopers.application.signup.SignUpInfo;
-import com.loopers.domain.user.Gender;
 import com.loopers.interfaces.api.ApiResponse;
-import com.loopers.support.error.CoreException;
-import com.loopers.support.error.ErrorType;
 import jakarta.validation.Valid;
 import lombok.RequiredArgsConstructor;
-import java.util.Locale;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestMapping;
@@ -41,15 +37,12 @@ public class SignUpV1Controller {
     public ApiResponse<SignUpV1Dto.SignupResponse> signUp(
         @Valid @RequestBody SignUpV1Dto.SignUpRequest request
     ) {
-        Gender gender;
-        try {
-            String genderValue = request.gender().trim().toUpperCase(Locale.ROOT);
-            gender = Gender.valueOf(genderValue);
-        } catch (IllegalArgumentException e) {
-            throw new CoreException(ErrorType.BAD_REQUEST, "gender 값이 올바르지 않습니다.");
-        }
-
-        SignUpInfo info = signUpFacade.signUp(request.userId(), request.email(), request.birthDate(), gender);
+        SignUpInfo info = signUpFacade.signUp(
+            request.userId(),
+            request.email(),
+            request.birthDate(),
+            request.gender()
+        );
         SignUpV1Dto.SignupResponse response = SignUpV1Dto.SignupResponse.from(info);
         return ApiResponse.success(response);
     }
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/userinfo/UserInfoV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/userinfo/UserInfoV1Controller.java
index 36b858d05..a125747e0 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/userinfo/UserInfoV1Controller.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/userinfo/UserInfoV1Controller.java
@@ -1,10 +1,7 @@
 package com.loopers.interfaces.api.userinfo;
 
-import com.loopers.domain.user.User;
-import com.loopers.domain.user.UserService;
+import com.loopers.application.userinfo.UserInfoFacade;
 import com.loopers.interfaces.api.ApiResponse;
-import com.loopers.support.error.CoreException;
-import com.loopers.support.error.ErrorType;
 import lombok.RequiredArgsConstructor;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.RequestHeader;
@@ -12,9 +9,9 @@
 import org.springframework.web.bind.annotation.RestController;
 
 /**
- * 사용자 정보 API v1 컨트롤러.
+ * 사용자 정보 조회 API v1 컨트롤러.
  * <p>
- * 인증된 사용자의 정보 조회 기능을 제공합니다.
+ * 인증된 사용자의 정보 조회 유즈케이스를 처리합니다.
  * </p>
  *
  * @author Loopers
@@ -25,7 +22,7 @@
 @RequestMapping("/api/v1")
 public class UserInfoV1Controller {
 
-    private final UserService userService;
+    private final UserInfoFacade userInfoFacade;
 
     /**
      * 현재 사용자의 정보를 조회합니다.
@@ -38,13 +35,8 @@ public class UserInfoV1Controller {
     public ApiResponse<UserInfoV1Dto.UserInfoResponse> getMyInfo(
         @RequestHeader("X-USER-ID") String userId
     ) {
-        User user = userService.findByUserId(userId);
-        if (user == null) {
-            throw new CoreException(ErrorType.NOT_FOUND, null);
-        }
-
-        return ApiResponse.success(UserInfoV1Dto.UserInfoResponse.from(user));
+        UserInfoFacade.UserInfo userInfo = userInfoFacade.getUserInfo(userId);
+        return ApiResponse.success(UserInfoV1Dto.UserInfoResponse.from(userInfo));
     }
 }
 
-
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/userinfo/UserInfoV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/userinfo/UserInfoV1Dto.java
index 369f684a4..7a8987aca 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/userinfo/UserInfoV1Dto.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/userinfo/UserInfoV1Dto.java
@@ -1,9 +1,9 @@
 package com.loopers.interfaces.api.userinfo;
 
-import com.loopers.domain.user.User;
+import com.loopers.application.userinfo.UserInfoFacade;
 
 /**
- * 사용자 정보 API v1의 데이터 전송 객체(DTO) 컨테이너.
+ * 사용자 정보 조회 API v1의 데이터 전송 객체(DTO) 컨테이너.
  *
  * @author Loopers
  * @version 1.0
@@ -24,18 +24,19 @@ public record UserInfoResponse(
         String gender
     ) {
         /**
-         * User 엔티티로부터 UserInfoResponse를 생성합니다.
+         * UserInfo로부터 UserInfoResponse를 생성합니다.
          *
-         * @param user 사용자 엔티티
+         * @param userInfo 사용자 정보
          * @return 생성된 응답 객체
          */
-        public static UserInfoResponse from(User user) {
+        public static UserInfoResponse from(UserInfoFacade.UserInfo userInfo) {
             return new UserInfoResponse(
-                user.getUserId(),
-                user.getEmail(),
-                user.getBirthDate().toString(),
-                user.getGender() != null ? user.getGender().name() : null
+                userInfo.userId(),
+                userInfo.email(),
+                userInfo.birthDate().toString(),
+                userInfo.gender() != null ? userInfo.gender().name() : null
             );
         }
     }
 }
+
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/pointwallet/PointWalletFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/pointwallet/PointWalletFacadeIntegrationTest.java
new file mode 100644
index 000000000..cebc13975
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/application/pointwallet/PointWalletFacadeIntegrationTest.java
@@ -0,0 +1,115 @@
+package com.loopers.application.pointwallet;
+
+import com.loopers.application.signup.SignUpFacade;
+import com.loopers.domain.user.Gender;
+import com.loopers.domain.user.UserTestFixture;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import com.loopers.utils.DatabaseCleanUp;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.junit.jupiter.api.Assertions.assertAll;
+
+@SpringBootTest
+@DisplayName("PointWalletFacade 통합 테스트")
+class PointWalletFacadeIntegrationTest {
+    @Autowired
+    private PointWalletFacade pointWalletFacade;
+
+    @Autowired
+    private SignUpFacade signUpFacade;
+
+    @Autowired
+    private DatabaseCleanUp databaseCleanUp;
+
+    @AfterEach
+    void tearDown() {
+        databaseCleanUp.truncateAllTables();
+    }
+
+    @DisplayName("포인트 조회에 관한 통합 테스트")
+    @Nested
+    class PointInfo {
+        @DisplayName("해당 ID 의 회원이 존재할 경우, 보유 포인트가 반환된다.")
+        @ParameterizedTest
+        @EnumSource(Gender.class)
+        void returnsPoints_whenUserExists(Gender gender) {
+            // arrange
+            String userId = UserTestFixture.ValidUser.USER_ID;
+            String email = UserTestFixture.ValidUser.EMAIL;
+            String birthDate = UserTestFixture.ValidUser.BIRTH_DATE;
+            signUpFacade.signUp(userId, email, birthDate, gender.name());
+
+            // act
+            PointWalletFacade.PointsInfo pointsInfo = pointWalletFacade.getPoints(userId);
+
+            // assert
+            assertAll(
+                () -> assertThat(pointsInfo).isNotNull(),
+                () -> assertThat(pointsInfo.userId()).isEqualTo(userId),
+                () -> assertThat(pointsInfo.balance()).isEqualTo(0L)
+            );
+        }
+
+        @DisplayName("해당 ID 의 회원이 존재하지 않을 경우, 예외가 발생한다.")
+        @Test
+        void throwsException_whenUserDoesNotExist() {
+            // arrange
+            String userId = "unknown";
+
+            // act & assert
+            assertThatThrownBy(() -> pointWalletFacade.getPoints(userId))
+                .isInstanceOf(CoreException.class)
+                .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND);
+        }
+    }
+
+    @DisplayName("포인트 충전에 관한 통합 테스트")
+    @Nested
+    class PointCharge {
+        @DisplayName("포인트를 충전할 수 있다.")
+        @ParameterizedTest
+        @EnumSource(Gender.class)
+        void chargesPoints_success(Gender gender) {
+            // arrange
+            String userId = UserTestFixture.ValidUser.USER_ID;
+            String email = UserTestFixture.ValidUser.EMAIL;
+            String birthDate = UserTestFixture.ValidUser.BIRTH_DATE;
+            signUpFacade.signUp(userId, email, birthDate, gender.name());
+            Long chargeAmount = 10_000L;
+
+            // act
+            PointWalletFacade.PointsInfo pointsInfo = pointWalletFacade.chargePoint(userId, chargeAmount);
+
+            // assert
+            assertAll(
+                () -> assertThat(pointsInfo).isNotNull(),
+                () -> assertThat(pointsInfo.userId()).isEqualTo(userId),
+                () -> assertThat(pointsInfo.balance()).isEqualTo(chargeAmount)
+            );
+        }
+
+        @DisplayName("사용자가 존재하지 않으면 포인트 충전 시 예외가 발생한다.")
+        @Test
+        void throwsException_whenUserDoesNotExist() {
+            // arrange
+            String userId = "unknown";
+            Long chargeAmount = 10_000L;
+
+            // act & assert
+            assertThatThrownBy(() -> pointWalletFacade.chargePoint(userId, chargeAmount))
+                .isInstanceOf(CoreException.class)
+                .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND);
+        }
+    }
+}
+
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/signup/SignUpFacadeIntegrationTest.java
similarity index 60%
rename from apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java
rename to apps/commerce-api/src/test/java/com/loopers/application/signup/SignUpFacadeIntegrationTest.java
index 82d76bad8..c827cae1d 100644
--- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/application/signup/SignUpFacadeIntegrationTest.java
@@ -1,7 +1,8 @@
-package com.loopers.domain.user;
+package com.loopers.application.signup;
 
-import com.loopers.application.signup.SignUpFacade;
-import com.loopers.application.signup.SignUpInfo;
+import com.loopers.domain.user.Gender;
+import com.loopers.domain.user.User;
+import com.loopers.domain.user.UserTestFixture;
 import com.loopers.infrastructure.user.UserJpaRepository;
 import com.loopers.support.error.CoreException;
 import com.loopers.support.error.ErrorType;
@@ -9,7 +10,6 @@
 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.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.EnumSource;
 import org.mockito.Mockito;
@@ -17,8 +17,6 @@
 import org.springframework.boot.test.context.SpringBootTest;
 import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
 
-import java.time.LocalDate;
-
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.junit.jupiter.api.Assertions.assertAll;
 import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -27,13 +25,11 @@
 import static org.mockito.Mockito.verify;
 
 @SpringBootTest
-public class UserServiceIntegrationTest {
+@DisplayName("SignUpFacade 통합 테스트")
+class SignUpFacadeIntegrationTest {
     @Autowired
     private SignUpFacade signUpFacade;
 
-    @Autowired
-    private UserService userService;
-
     @MockitoSpyBean
     private UserJpaRepository userJpaRepository;
 
@@ -51,7 +47,7 @@ class SignUp {
         @DisplayName("회원가입시 User 저장이 수행된다.")
         @ParameterizedTest
         @EnumSource(Gender.class)
-        void returnsExampleInfo_whenValidIdIsProvided(Gender gender) {
+        void returnsSignUpInfo_whenValidIdIsProvided(Gender gender) {
             // arrange
             String userId = UserTestFixture.ValidUser.USER_ID;
             String email = UserTestFixture.ValidUser.EMAIL;
@@ -59,7 +55,7 @@ void returnsExampleInfo_whenValidIdIsProvided(Gender gender) {
             Mockito.reset(userJpaRepository);
 
             // act
-            SignUpInfo signUpInfo = signUpFacade.signUp(userId, email, birthDate, gender);
+            SignUpInfo signUpInfo = signUpFacade.signUp(userId, email, birthDate, gender.name());
 
             // assert
             assertAll(
@@ -77,55 +73,16 @@ void fails_whenDuplicateUserIdExists(Gender gender) {
             String userId = UserTestFixture.ValidUser.USER_ID;
             String email = UserTestFixture.ValidUser.EMAIL;
             String birthDate = UserTestFixture.ValidUser.BIRTH_DATE;
-            signUpFacade.signUp(userId, email, birthDate, gender);
+            signUpFacade.signUp(userId, email, birthDate, gender.name());
 
             // act
             CoreException result = assertThrows(CoreException.class, () ->
-                signUpFacade.signUp(userId, email, birthDate, gender)
+                signUpFacade.signUp(userId, email, birthDate, gender.name())
             );
 
             // assert
             assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT);
         }
     }
-
-    @DisplayName("회원 조회에 관한 통합 테스트")
-    @Nested
-    class UserInfo {
-        @DisplayName("해당 ID 의 회원이 존재할 경우, 회원 정보가 반환된다.")
-        @ParameterizedTest
-        @EnumSource(Gender.class)
-        void returnsUser_whenUserExists(Gender gender) {
-            // arrange
-            String userId = UserTestFixture.ValidUser.USER_ID;
-            String email = UserTestFixture.ValidUser.EMAIL;
-            String birthDate = UserTestFixture.ValidUser.BIRTH_DATE;
-            signUpFacade.signUp(userId, email, birthDate, gender);
-
-            // act
-            User found = userService.findByUserId(userId);
-
-            // assert
-            assertAll(
-                () -> assertThat(found).isNotNull(),
-                () -> assertThat(found.getUserId()).isEqualTo(userId),
-                () -> assertThat(found.getEmail()).isEqualTo(email),
-                () -> assertThat(found.getBirthDate()).isEqualTo(LocalDate.parse(birthDate)),
-                () -> assertThat(found.getGender()).isEqualTo(gender)
-            );
-        }
-
-        @DisplayName("해당 ID 의 회원이 존재하지 않을 경우, null 이 반환된다.")
-        @Test
-        void returnsNull_whenUserDoesNotExist() {
-            // arrange
-            String userId = "unknown";
-
-            // act
-            User found = userService.findByUserId(userId);
-
-            // assert
-            assertThat(found).isNull();
-        }
-    }
 }
+
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/userinfo/UserInfoFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/userinfo/UserInfoFacadeIntegrationTest.java
new file mode 100644
index 000000000..78efa5e30
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/application/userinfo/UserInfoFacadeIntegrationTest.java
@@ -0,0 +1,80 @@
+package com.loopers.application.userinfo;
+
+import com.loopers.application.signup.SignUpFacade;
+import com.loopers.domain.user.Gender;
+import com.loopers.domain.user.UserTestFixture;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import com.loopers.utils.DatabaseCleanUp;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+
+import java.time.LocalDate;
+
+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
+@DisplayName("UserInfoFacade 통합 테스트")
+class UserInfoFacadeIntegrationTest {
+    @Autowired
+    private UserInfoFacade userInfoFacade;
+
+    @Autowired
+    private SignUpFacade signUpFacade;
+
+    @Autowired
+    private DatabaseCleanUp databaseCleanUp;
+
+    @AfterEach
+    void tearDown() {
+        databaseCleanUp.truncateAllTables();
+    }
+
+    @DisplayName("회원 조회에 관한 통합 테스트")
+    @Nested
+    class UserInfo {
+        @DisplayName("해당 ID 의 회원이 존재할 경우, 회원 정보가 반환된다.")
+        @ParameterizedTest
+        @EnumSource(Gender.class)
+        void returnsUserInfo_whenUserExists(Gender gender) {
+            // arrange
+            String userId = UserTestFixture.ValidUser.USER_ID;
+            String email = UserTestFixture.ValidUser.EMAIL;
+            String birthDate = UserTestFixture.ValidUser.BIRTH_DATE;
+            signUpFacade.signUp(userId, email, birthDate, gender.name());
+
+            // act
+            UserInfoFacade.UserInfo userInfo = userInfoFacade.getUserInfo(userId);
+
+            // assert
+            assertAll(
+                () -> assertThat(userInfo).isNotNull(),
+                () -> assertThat(userInfo.userId()).isEqualTo(userId),
+                () -> assertThat(userInfo.email()).isEqualTo(email),
+                () -> assertThat(userInfo.birthDate()).isEqualTo(LocalDate.parse(birthDate)),
+                () -> assertThat(userInfo.gender()).isEqualTo(gender)
+            );
+        }
+
+        @DisplayName("해당 ID 의 회원이 존재하지 않을 경우, 예외가 발생한다.")
+        @Test
+        void throwsException_whenUserDoesNotExist() {
+            // arrange
+            String userId = "unknown";
+
+            // act & assert
+            assertThatThrownBy(() -> userInfoFacade.getUserInfo(userId))
+                .isInstanceOf(CoreException.class)
+                .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND);
+        }
+    }
+}
+
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
deleted file mode 100644
index a77d11ab2..000000000
--- a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java
+++ /dev/null
@@ -1,94 +0,0 @@
-package com.loopers.domain.point;
-
-import com.loopers.application.signup.SignUpFacade;
-import com.loopers.domain.user.Gender;
-import com.loopers.support.error.CoreException;
-import com.loopers.support.error.ErrorType;
-import com.loopers.utils.DatabaseCleanUp;
-import org.junit.jupiter.api.AfterEach;
-import org.junit.jupiter.api.DisplayName;
-import org.junit.jupiter.api.Nested;
-import org.junit.jupiter.api.Test;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.boot.test.context.SpringBootTest;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.junit.jupiter.api.Assertions.assertAll;
-import static org.junit.jupiter.api.Assertions.assertThrows;
-
-@SpringBootTest
-public class PointServiceIntegrationTest {
-    @Autowired
-    private PointService pointService;
-
-    @Autowired
-    private SignUpFacade signUpFacade;
-
-    @Autowired
-    private DatabaseCleanUp databaseCleanUp;
-
-    @AfterEach
-    void tearDown() {
-        databaseCleanUp.truncateAllTables();
-    }
-
-    @DisplayName("포인트 조회에 관한 통합 테스트")
-    @Nested
-    class PointsLookup {
-        @DisplayName("해당 ID 의 회원이 존재할 경우, 보유 포인트가 반환된다.")
-        @Test
-        void returnsPoints_whenUserExists() {
-            // arrange
-            String userId = "testuser";
-            String email = "test@example.com";
-            String birthDate = "1990-01-01";
-            Gender gender = Gender.MALE;
-            Long balance = 0L;
-            signUpFacade.signUp(userId, email, birthDate, gender);
-
-            // act
-            Point point = pointService.findByUserId(userId);
-
-            // assert
-            assertAll(
-                () -> assertThat(point).isNotNull(),
-                () -> assertThat(point.getUser().getUserId()).isEqualTo(userId),
-                () -> assertThat(point.getBalance()).isEqualTo(balance)
-            );
-        }
-
-        @DisplayName("해당 ID 의 회원이 존재하지 않을 경우, null 이 반환된다.")
-        @Test
-        void returnsNull_whenUserDoesNotExist() {
-            // arrange
-            String userId = "unknown";
-
-            // act
-            Point found = pointService.findByUserId(userId);
-
-            // assert
-            assertThat(found).isNull();
-        }
-    }
-
-    @DisplayName("포인트 충전에 관한 통합 테스트")
-    @Nested
-    class PointCharge {
-        @DisplayName("존재하지 않는 유저 ID 로 충전을 시도한 경우, 실패한다.")
-        @Test
-        void fails_whenUserDoesNotExist() {
-            // arrange
-            String userId = "unknown";
-            Long chargeAmount = 1000L;
-
-            // act
-            CoreException result = assertThrows(CoreException.class, () ->
-                pointService.charge(userId, chargeAmount)
-            );
-
-            // assert
-            assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND);
-        }
-    }
-}
-
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointTest.java
deleted file mode 100644
index 38b7f7c80..000000000
--- a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointTest.java
+++ /dev/null
@@ -1,37 +0,0 @@
-package com.loopers.domain.point;
-
-import com.loopers.domain.user.User;
-import com.loopers.support.error.CoreException;
-import com.loopers.support.error.ErrorType;
-import org.junit.jupiter.api.DisplayName;
-import org.junit.jupiter.api.Nested;
-import org.junit.jupiter.params.ParameterizedTest;
-import org.junit.jupiter.params.provider.ValueSource;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.junit.jupiter.api.Assertions.assertThrows;
-import static org.mockito.Mockito.mock;
-
-public class PointTest {
-
-    @DisplayName("Point 도메인의 금액 검증에 관한 단위 테스트")
-    @Nested
-    class BalanceValidation {
-        @DisplayName("0 이하의 정수로 포인트를 충전 시 실패한다.")
-        @ParameterizedTest
-        @ValueSource(longs = {0L, -1L, -100L})
-        void throwsBadRequest_whenChargingWithNonPositiveAmount(long nonPositiveAmount) {
-            // arrange
-            User user = mock(User.class);
-            Point point = Point.of(user, 0L);
-
-            // act
-            CoreException result = assertThrows(CoreException.class, () -> {
-                point.charge(nonPositiveAmount);
-            });
-
-            // assert
-            assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
-        }
-    }
-}
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java
index 3fc3950d4..b1490b09c 100644
--- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java
@@ -22,7 +22,7 @@ void throwsBadRequestException_whenIdFormatIsInvalid(Gender gender) {
             String userId = UserTestFixture.InvalidUser.USER_ID;
             // act
             CoreException result = assertThrows(CoreException.class, () -> {
-                User.of(userId, UserTestFixture.ValidUser.EMAIL, UserTestFixture.ValidUser.BIRTH_DATE, gender);
+                User.of(userId, UserTestFixture.ValidUser.EMAIL, UserTestFixture.ValidUser.BIRTH_DATE, gender, UserTestFixture.ValidUser.POINT);
             });
             // assert
             assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
@@ -36,7 +36,7 @@ void throwsBadRequestException_whenEmailFormatIsInvalid(Gender gender) {
             String email = UserTestFixture.InvalidUser.EMAIL;
             // act
             CoreException result = assertThrows(CoreException.class, () -> {
-                User.of(UserTestFixture.ValidUser.USER_ID, email, UserTestFixture.ValidUser.BIRTH_DATE, gender);
+                User.of(UserTestFixture.ValidUser.USER_ID, email, UserTestFixture.ValidUser.BIRTH_DATE, gender, UserTestFixture.ValidUser.POINT);
             });
             // assert
             assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
@@ -50,7 +50,7 @@ void throwsBadRequestException_whenBirthDateIsInvalid(Gender gender) {
             String birthDateStr = UserTestFixture.InvalidUser.BIRTH_DATE;
             // act
             CoreException result = assertThrows(CoreException.class, () -> {
-                User.of(UserTestFixture.ValidUser.USER_ID, UserTestFixture.ValidUser.EMAIL, birthDateStr, gender);
+                User.of(UserTestFixture.ValidUser.USER_ID, UserTestFixture.ValidUser.EMAIL, birthDateStr, gender, UserTestFixture.ValidUser.POINT);
             });
             // assert
             assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTestFixture.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTestFixture.java
index a36637064..9babd2fb1 100644
--- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTestFixture.java
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTestFixture.java
@@ -11,6 +11,7 @@ public static final class ValidUser {
         public static final String USER_ID = "testuser";
         public static final String EMAIL = "test@example.com";
         public static final String BIRTH_DATE = "1990-01-01";
+        public static final Point POINT = Point.of(0L);
     }
 
     // 유효하지 않은 테스트 데이터
@@ -18,5 +19,6 @@ public static final class InvalidUser {
         public static final String USER_ID = "한글";
         public static final String EMAIL = "test";
         public static final String BIRTH_DATE = "2024.1.1";
+        public static final Point POINT = Point.of(0L);
     }
 }
diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointsV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointWalletV1ApiE2ETest.java
similarity index 88%
rename from apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointsV1ApiE2ETest.java
rename to apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointWalletV1ApiE2ETest.java
index 3621bbd60..c2629fb78 100644
--- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointsV1ApiE2ETest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointWalletV1ApiE2ETest.java
@@ -3,7 +3,7 @@
 import com.loopers.application.signup.SignUpFacade;
 import com.loopers.domain.user.Gender;
 import com.loopers.domain.user.UserTestFixture;
-import com.loopers.interfaces.api.point.PointsV1Dto;
+import com.loopers.interfaces.api.pointwallet.PointWalletV1Dto;
 import com.loopers.utils.DatabaseCleanUp;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.DisplayName;
@@ -26,7 +26,7 @@
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
-public class PointsV1ApiE2ETest {
+public class PointWalletV1ApiE2ETest {
 
     private static final String ENDPOINT_POINTS = "/api/v1/me/points";
 
@@ -35,7 +35,7 @@ public class PointsV1ApiE2ETest {
     private final DatabaseCleanUp databaseCleanUp;
 
     @Autowired
-    public PointsV1ApiE2ETest(
+    public PointWalletV1ApiE2ETest(
         TestRestTemplate testRestTemplate,
         SignUpFacade signUpFacade,
         DatabaseCleanUp databaseCleanUp
@@ -61,16 +61,16 @@ void returnsPoints_whenUserExists(Gender gender) {
             String userId = UserTestFixture.ValidUser.USER_ID;
             String email = UserTestFixture.ValidUser.EMAIL;
             String birthDate = UserTestFixture.ValidUser.BIRTH_DATE;
-            signUpFacade.signUp(userId, email, birthDate, gender);
+            signUpFacade.signUp(userId, email, birthDate, gender.name());
 
             HttpHeaders headers = new HttpHeaders();
             headers.setContentType(MediaType.APPLICATION_JSON);
             HttpEntity<Void> httpEntity = new HttpEntity<>(headers);
 
             // act
-            ParameterizedTypeReference<ApiResponse<PointsV1Dto.PointsResponse>> responseType = new ParameterizedTypeReference<>() {};
+            ParameterizedTypeReference<ApiResponse<PointWalletV1Dto.PointsResponse>> responseType = new ParameterizedTypeReference<>() {};
             headers.add("X-USER-ID", userId);
-            ResponseEntity<ApiResponse<PointsV1Dto.PointsResponse>> response =
+            ResponseEntity<ApiResponse<PointWalletV1Dto.PointsResponse>> response =
                 testRestTemplate.exchange(ENDPOINT_POINTS, HttpMethod.GET, httpEntity, responseType);
 
             // assert
@@ -145,19 +145,19 @@ void returnsChargedBalance_whenUserExists(Gender gender) {
             String userId = UserTestFixture.ValidUser.USER_ID;
             String email = UserTestFixture.ValidUser.EMAIL;
             String birthDate = UserTestFixture.ValidUser.BIRTH_DATE;
-            signUpFacade.signUp(userId, email, birthDate, gender);
+            signUpFacade.signUp(userId, email, birthDate, gender.name());
 
             Long chargeAmount = 1000L;
-            PointsV1Dto.ChargeRequest requestBody = new PointsV1Dto.ChargeRequest(chargeAmount);
+            PointWalletV1Dto.ChargeRequest requestBody = new PointWalletV1Dto.ChargeRequest(chargeAmount);
 
             HttpHeaders headers = new HttpHeaders();
             headers.setContentType(MediaType.APPLICATION_JSON);
             headers.add("X-USER-ID", userId);
-            HttpEntity<PointsV1Dto.ChargeRequest> httpEntity = new HttpEntity<>(requestBody, headers);
+            HttpEntity<PointWalletV1Dto.ChargeRequest> httpEntity = new HttpEntity<>(requestBody, headers);
 
             // act
-            ParameterizedTypeReference<ApiResponse<PointsV1Dto.PointsResponse>> responseType = new ParameterizedTypeReference<>() {};
-            ResponseEntity<ApiResponse<PointsV1Dto.PointsResponse>> response =
+            ParameterizedTypeReference<ApiResponse<PointWalletV1Dto.PointsResponse>> responseType = new ParameterizedTypeReference<>() {};
+            ResponseEntity<ApiResponse<PointWalletV1Dto.PointsResponse>> response =
                 testRestTemplate.exchange(ENDPOINT_CHARGE, HttpMethod.POST, httpEntity, responseType);
 
             // assert
@@ -177,12 +177,12 @@ void returns404_whenUserDoesNotExist() {
             // arrange
             String userId = "unknown";
             Long chargeAmount = 1000L;
-            PointsV1Dto.ChargeRequest requestBody = new PointsV1Dto.ChargeRequest(chargeAmount);
+            PointWalletV1Dto.ChargeRequest requestBody = new PointWalletV1Dto.ChargeRequest(chargeAmount);
 
             HttpHeaders headers = new HttpHeaders();
             headers.setContentType(MediaType.APPLICATION_JSON);
             headers.add("X-USER-ID", userId);
-            HttpEntity<PointsV1Dto.ChargeRequest> httpEntity = new HttpEntity<>(requestBody, headers);
+            HttpEntity<PointWalletV1Dto.ChargeRequest> httpEntity = new HttpEntity<>(requestBody, headers);
 
             // act
             ParameterizedTypeReference<ApiResponse<Object>> responseType = new ParameterizedTypeReference<>() {};
@@ -198,4 +198,4 @@ void returns404_whenUserDoesNotExist() {
             );
         }
     }
-}
\ No newline at end of file
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserInfoV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserInfoV1ApiE2ETest.java
index a78f616cd..f9d33a421 100644
--- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserInfoV1ApiE2ETest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserInfoV1ApiE2ETest.java
@@ -61,7 +61,7 @@ void returnsUserInfo_whenUserExists(Gender gender) {
             String userId = UserTestFixture.ValidUser.USER_ID;
             String email = UserTestFixture.ValidUser.EMAIL;
             String birthDate = UserTestFixture.ValidUser.BIRTH_DATE;
-            signUpFacade.signUp(userId, email, birthDate, gender);
+            signUpFacade.signUp(userId, email, birthDate, gender.name());
 
             HttpHeaders headers = new HttpHeaders();
             headers.setContentType(MediaType.APPLICATION_JSON);

From fa9d6a1f070c90c0f2a8007ffdca26cdfc7270ed Mon Sep 17 00:00:00 2001
From: minor7295 <44902090+minor7295@users.noreply.github.com>
Date: Thu, 13 Nov 2025 21:38:12 +0900
Subject: [PATCH 2/7] Feature/brand product (#8)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* test: product 도메인 단위 테스트 추가

* feat: product, brand 도메인 구현
---
 .../java/com/loopers/domain/brand/Brand.java  |  47 +++++
 .../com/loopers/domain/product/Product.java   | 160 ++++++++++++++++++
 .../loopers/domain/product/ProductTest.java   | 110 ++++++++++++
 .../domain/product/ProductTestFixture.java    |  33 ++++
 4 files changed, 350 insertions(+)
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTestFixture.java

diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java
new file mode 100644
index 000000000..611b27a2c
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java
@@ -0,0 +1,47 @@
+package com.loopers.domain.brand;
+
+import com.loopers.domain.BaseEntity;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Table;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/**
+ * 브랜드 도메인 엔티티.
+ * <p>
+ * 브랜드의 기본 정보(이름)를 관리합니다.
+ * </p>
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Entity
+@Table(name = "brand")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Getter
+public class Brand extends BaseEntity {
+    @Column(name = "name")
+    private String name;
+
+    /**
+     * Brand 인스턴스를 생성합니다.
+     *
+     * @param name 브랜드 이름
+     */
+    public Brand(String name) {
+        this.name = name;
+    }
+
+    /**
+     * Brand 인스턴스를 생성하는 정적 팩토리 메서드.
+     *
+     * @param name 브랜드 이름
+     * @return 생성된 Brand 인스턴스
+     */
+    public static Brand of(String name) {
+        return new Brand(name);
+    }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java
new file mode 100644
index 000000000..52315a26e
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java
@@ -0,0 +1,160 @@
+package com.loopers.domain.product;
+
+import com.loopers.domain.BaseEntity;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/**
+ * 상품 도메인 엔티티.
+ * <p>
+ * 상품의 기본 정보(이름, 가격, 재고, 브랜드)를 관리하며,
+ * 주문 시 재고 차감 기능을 제공합니다.
+ * </p>
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Entity
+@Table(name = "product")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Getter
+public class Product extends BaseEntity {
+    @Column(name = "name", nullable = false)
+    private String name;
+
+    @Column(name = "price", nullable = false)
+    private Integer price;
+
+    @Column(name = "stock", nullable = false)
+    private Integer stock;
+
+    @Column(name = "ref_brand_id", nullable = false)
+    private Long brandId;
+
+    /**
+     * Product 인스턴스를 생성합니다.
+     *
+     * @param name 상품 이름 (필수)
+     * @param price 상품 가격 (필수, 0 이상)
+     * @param stock 상품 재고 (필수, 0 이상)
+     * @param brandId 브랜드 ID (필수)
+     * @throws CoreException 유효성 검증 실패 시
+     */
+    public Product(String name, Integer price, Integer stock, Long brandId) {
+        validateName(name);
+        validatePrice(price);
+        validateStock(stock);
+        validateBrandId(brandId);
+        this.name = name;
+        this.price = price;
+        this.stock = stock;
+        this.brandId = brandId;
+    }
+
+    /**
+     * Product 인스턴스를 생성하는 정적 팩토리 메서드.
+     *
+     * @param name 상품 이름
+     * @param price 상품 가격
+     * @param stock 상품 재고
+     * @param brandId 브랜드 ID
+     * @return 생성된 Product 인스턴스
+     * @throws CoreException 유효성 검증 실패 시
+     */
+    public static Product of(String name, Integer price, Integer stock, Long brandId) {
+        return new Product(name, price, stock, brandId);
+    }
+
+    /**
+     * 재고를 차감합니다.
+     * 재고는 감소만 가능하며 음수가 되지 않도록 도메인 레벨에서 검증합니다.
+     *
+     * @param quantity 차감할 수량 (0보다 커야 함)
+     * @throws CoreException quantity가 null, 0 이하이거나 재고가 부족할 경우
+     */
+    public void decreaseStock(Integer quantity) {
+        validateQuantity(quantity);
+        if (this.stock < quantity) {
+            throw new CoreException(ErrorType.BAD_REQUEST, 
+                String.format("재고가 부족합니다. (현재 재고: %d, 요청 수량: %d)", this.stock, quantity));
+        }
+        this.stock -= quantity;
+    }
+
+    /**
+     * 재고를 증가시킵니다.
+     * 주문 취소 시 재고를 원복하는 데 사용됩니다.
+     *
+     * @param quantity 증가시킬 수량 (0보다 커야 함)
+     * @throws CoreException quantity가 null이거나 0 이하일 경우
+     */
+    public void increaseStock(Integer quantity) {
+        validateQuantity(quantity);
+        this.stock += quantity;
+    }
+
+    /**
+     * 상품 이름의 유효성을 검증합니다.
+     *
+     * @param name 검증할 상품 이름
+     * @throws CoreException name이 null이거나 공백일 경우
+     */
+    private void validateName(String name) {
+        if (name == null || name.isBlank()) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "상품 이름은 필수입니다.");
+        }
+    }
+
+    /**
+     * 상품 가격의 유효성을 검증합니다.
+     *
+     * @param price 검증할 상품 가격
+     * @throws CoreException price가 null이거나 0 미만일 경우
+     */
+    private void validatePrice(Integer price) {
+        if (price == null || price < 0) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "상품 가격은 0 이상이어야 합니다.");
+        }
+    }
+
+    /**
+     * 상품 재고의 유효성을 검증합니다.
+     *
+     * @param stock 검증할 상품 재고
+     * @throws CoreException stock이 null이거나 0 미만일 경우
+     */
+    private void validateStock(Integer stock) {
+        if (stock == null || stock < 0) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "상품 재고는 0 이상이어야 합니다.");
+        }
+    }
+
+    /**
+     * 브랜드 ID의 유효성을 검증합니다.
+     *
+     * @param brandId 검증할 브랜드 ID
+     * @throws CoreException brandId가 null일 경우
+     */
+    private void validateBrandId(Long brandId) {
+        if (brandId == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 ID는 필수입니다.");
+        }
+    }
+
+    /**
+     * 수량의 유효성을 검증합니다.
+     *
+     * @param quantity 검증할 수량
+     * @throws CoreException quantity가 null이거나 0 이하일 경우
+     */
+    private void validateQuantity(Integer quantity) {
+        if (quantity == null || quantity <= 0) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "수량은 0보다 커야 합니다.");
+        }
+    }
+}
+
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java
new file mode 100644
index 000000000..9cfe0294e
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java
@@ -0,0 +1,110 @@
+package com.loopers.domain.product;
+
+import com.loopers.domain.brand.Brand;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+public class ProductTest {
+
+    @DisplayName("상품 정보 객체는 브랜드 정보를 포함한다.")
+    @Test
+    void productContainsBrandId() {
+        // arrange
+        Brand brand = Brand.of(ProductTestFixture.ValidBrand.NAME);
+        Long brandId = brand.getId();
+
+        // act
+        Product product = Product.of(ProductTestFixture.ValidProduct.NAME, ProductTestFixture.ValidProduct.PRICE, ProductTestFixture.ValidProduct.STOCK, brandId);
+
+        // assert
+        assertThat(product.getBrandId()).isNotNull();
+        assertThat(product.getBrandId()).isEqualTo(brandId);
+    }
+
+    @DisplayName("상품은 재고를 가지고 있고, 주문 시 차감할 수 있어야 한다.")
+    @Test
+    void canDecreaseStock_whenOrdering() {
+        // arrange
+        Brand brand = Brand.of(ProductTestFixture.ValidBrand.NAME);
+        Product product = Product.of(ProductTestFixture.ValidProduct.NAME, ProductTestFixture.ValidProduct.PRICE, 100, brand.getId());
+        Integer orderQuantity = 5;
+        Integer initialStock = product.getStock();
+
+        // act
+        product.decreaseStock(orderQuantity);
+
+        // assert
+        assertThat(product.getStock()).isEqualTo(initialStock - orderQuantity);
+    }
+
+    @DisplayName("재고 감소 시 음수 수량을 전달하면 예외가 발생한다.")
+    @Test
+    void throwsException_whenDecreasingStockWithNegativeQuantity() {
+        // arrange
+        Brand brand = Brand.of(ProductTestFixture.ValidBrand.NAME);
+        Product product = Product.of(ProductTestFixture.ValidProduct.NAME, ProductTestFixture.ValidProduct.PRICE, 100, brand.getId());
+
+        // act
+        CoreException result = assertThrows(CoreException.class, () -> {
+            product.decreaseStock(-1);
+        });
+
+        // assert
+        assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
+    }
+
+    @DisplayName("주문 취소 시 재고를 증가시킬 수 있다.")
+    @Test
+    void canIncreaseStock_whenCancelingOrder() {
+        // arrange
+        Brand brand = Brand.of(ProductTestFixture.ValidBrand.NAME);
+        Product product = Product.of(ProductTestFixture.ValidProduct.NAME, ProductTestFixture.ValidProduct.PRICE, 100, brand.getId());
+        Integer increaseQuantity = 5;
+        Integer initialStock = product.getStock();
+
+        // act
+        product.increaseStock(increaseQuantity);
+
+        // assert
+        assertThat(product.getStock()).isEqualTo(initialStock + increaseQuantity);
+    }
+
+    @DisplayName("재고 증가 시 음수 수량을 전달하면 예외가 발생한다.")
+    @Test
+    void throwsException_whenIncreasingStockWithNegativeQuantity() {
+        // arrange
+        Brand brand = Brand.of(ProductTestFixture.ValidBrand.NAME);
+        Product product = Product.of(ProductTestFixture.ValidProduct.NAME, ProductTestFixture.ValidProduct.PRICE, 100, brand.getId());
+
+        // act
+        CoreException result = assertThrows(CoreException.class, () -> {
+            product.increaseStock(-1);
+        });
+
+        // assert
+        assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
+    }
+
+    @DisplayName("재고 부족 예외 흐름은 도메인 레벨에서 처리되며, 재고가 음수가 되지 않도록 방지한다.")
+    @Test
+    void preventsNegativeStock_atDomainLevel() {
+        // arrange
+        Brand brand = Brand.of(ProductTestFixture.ValidBrand.NAME);
+        Product product = Product.of(ProductTestFixture.ValidProduct.NAME, ProductTestFixture.ValidProduct.PRICE, 10, brand.getId());
+
+        // act
+        CoreException result = assertThrows(CoreException.class, () -> {
+            product.decreaseStock(11);
+        });
+
+        // assert
+        assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
+        assertThat(result.getMessage()).contains("재고가 부족합니다");
+        assertThat(product.getStock()).isEqualTo(10); // 재고가 변경되지 않았음을 확인
+    }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTestFixture.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTestFixture.java
new file mode 100644
index 000000000..43da5f290
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTestFixture.java
@@ -0,0 +1,33 @@
+package com.loopers.domain.product;
+
+/**
+ * 테스트용 고정 데이터 (Fixture) 클래스
+ * 모든 Product 관련 테스트에서 사용하는 공통 데이터를 관리
+ */
+public class ProductTestFixture {
+
+    // 기본 유효한 테스트 데이터
+    public static final class ValidProduct {
+        public static final String NAME = "테스트 상품";
+        public static final Integer PRICE = 10000;
+        public static final Integer STOCK = 100;
+    }
+
+    // 유효하지 않은 테스트 데이터
+    public static final class InvalidProduct {
+        public static final String NAME = "";
+        public static final Integer PRICE = -1;
+        public static final Integer STOCK = -1;
+    }
+
+    // 기본 유효한 브랜드 데이터
+    public static final class ValidBrand {
+        public static final String NAME = "테스트 브랜드";
+    }
+
+    // 유효하지 않은 브랜드 데이터
+    public static final class InvalidBrand {
+        public static final String NAME = "";
+    }
+}
+

From eda8e78d226b1c04304197c39906a4ad01dcb14d Mon Sep 17 00:00:00 2001
From: minor7295 <44902090+minor7295@users.noreply.github.com>
Date: Thu, 13 Nov 2025 22:47:31 +0900
Subject: [PATCH 3/7] Feature/like (#9)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* feat: like 도메인 구현

* test: like에 대한 단위 테스트 코드 추가

* feat: like에 대한 서비스 로직 추가
---
 .../loopers/application/like/LikeFacade.java  | 180 ++++++++++++++++++
 .../java/com/loopers/domain/like/Like.java    |  53 ++++++
 .../loopers/domain/like/LikeRepository.java   |  57 ++++++
 .../domain/product/ProductRepository.java     |  51 +++++
 .../like/LikeJpaRepository.java               |  60 ++++++
 .../like/LikeRepositoryImpl.java              |  45 +++++
 .../application/like/LikeFacadeTest.java      | 157 +++++++++++++++
 7 files changed, 603 insertions(+)
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java

diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java
new file mode 100644
index 000000000..c97814f0f
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java
@@ -0,0 +1,180 @@
+package com.loopers.application.like;
+
+import com.loopers.domain.like.Like;
+import com.loopers.domain.like.LikeRepository;
+import com.loopers.domain.product.Product;
+import com.loopers.domain.product.ProductRepository;
+import com.loopers.domain.user.User;
+import com.loopers.domain.user.UserRepository;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import jakarta.transaction.Transactional;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * 좋아요 관리 파사드.
+ * <p>
+ * 좋아요 추가, 삭제, 목록 조회 유즈케이스를 처리하는 애플리케이션 서비스입니다.
+ * </p>
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@RequiredArgsConstructor
+@Component
+public class LikeFacade {
+    private final LikeRepository likeRepository;
+    private final UserRepository userRepository;
+    private final ProductRepository productRepository;
+
+    /**
+     * 상품에 좋아요를 추가합니다.
+     * <p>
+     * 멱등성을 보장합니다. 이미 좋아요가 존재하는 경우 아무 작업도 수행하지 않습니다.
+     * </p>
+     *
+     * @param userId 사용자 ID (String)
+     * @param productId 상품 ID
+     * @throws CoreException 사용자 또는 상품을 찾을 수 없는 경우
+     */
+    @Transactional
+    public void addLike(String userId, Long productId) {
+        User user = loadUser(userId);
+        loadProduct(productId);
+
+        Optional<Like> existingLike = likeRepository.findByUserIdAndProductId(user.getId(), productId);
+        if (existingLike.isPresent()) {
+            return;
+        }
+
+        Like like = Like.of(user.getId(), productId);
+        likeRepository.save(like);
+    }
+
+    /**
+     * 상품의 좋아요를 취소합니다.
+     * <p>
+     * 멱등성을 보장합니다. 좋아요가 존재하지 않는 경우 아무 작업도 수행하지 않습니다.
+     * </p>
+     *
+     * @param userId 사용자 ID (String)
+     * @param productId 상품 ID
+     * @throws CoreException 사용자 또는 상품을 찾을 수 없는 경우
+     */
+    @Transactional
+    public void removeLike(String userId, Long productId) {
+        User user = loadUser(userId);
+        loadProduct(productId);
+
+        Optional<Like> like = likeRepository.findByUserIdAndProductId(user.getId(), productId);
+        if (like.isEmpty()) {
+            return;
+        }
+
+        likeRepository.delete(like.get());
+    }
+
+    /**
+     * 사용자가 좋아요한 상품 목록을 조회합니다.
+     *
+     * @param userId 사용자 ID (String)
+     * @return 좋아요한 상품 목록
+     * @throws CoreException 사용자를 찾을 수 없는 경우
+     */
+    public List<LikedProduct> getLikedProducts(String userId) {
+        User user = loadUser(userId);
+
+        // 사용자의 좋아요 목록 조회
+        List<Like> likes = likeRepository.findAllByUserId(user.getId());
+
+        if (likes.isEmpty()) {
+            return List.of();
+        }
+
+        // 상품 ID 목록 추출
+        List<Long> productIds = likes.stream()
+            .map(Like::getProductId)
+            .toList();
+
+        // 상품 정보 조회
+        List<Product> products = productIds.stream()
+            .map(productId -> productRepository.findById(productId)
+                .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND,
+                    String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", productId))))
+            .toList();
+
+        // 좋아요 수 집계
+        Map<Long, Long> likesCountMap = likeRepository.countByProductIds(productIds);
+
+        // 좋아요 목록을 상품 정보와 좋아요 수와 함께 변환
+        return likes.stream()
+            .map(like -> {
+                Product product = products.stream()
+                    .filter(p -> p.getId().equals(like.getProductId()))
+                    .findFirst()
+                    .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND,
+                        String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", like.getProductId())));
+                Long likesCount = likesCountMap.getOrDefault(like.getProductId(), 0L);
+                return LikedProduct.from(product, like, likesCount);
+            })
+            .toList();
+    }
+
+    private User loadUser(String userId) {
+        User user = userRepository.findByUserId(userId);
+        if (user == null) {
+            throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.");
+        }
+        return user;
+    }
+
+    private Product loadProduct(Long productId) {
+        return productRepository.findById(productId)
+            .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND,
+                String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", productId)));
+    }
+
+    /**
+     * 좋아요한 상품 정보.
+     *
+     * @param productId 상품 ID
+     * @param name 상품 이름
+     * @param price 상품 가격
+     * @param stock 상품 재고
+     * @param brandId 브랜드 ID
+     * @param likesCount 좋아요 수
+     */
+    public record LikedProduct(
+        Long productId,
+        String name,
+        Integer price,
+        Integer stock,
+        Long brandId,
+        Long likesCount
+    ) {
+        /**
+         * Product와 Like로부터 LikedProduct를 생성합니다.
+         *
+         * @param product 상품 엔티티
+         * @param like 좋아요 엔티티
+         * @param likesCount 좋아요 수
+         * @return 생성된 LikedProduct
+         */
+        public static LikedProduct from(Product product, Like like, Long likesCount) {
+            return new LikedProduct(
+                product.getId(),
+                product.getName(),
+                product.getPrice(),
+                product.getStock(),
+                product.getBrandId(),
+                likesCount
+            );
+        }
+    }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java
new file mode 100644
index 000000000..326de17ed
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java
@@ -0,0 +1,53 @@
+package com.loopers.domain.like;
+
+import com.loopers.domain.BaseEntity;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Table;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/**
+ * 좋아요 도메인 엔티티.
+ * <p>
+ * 사용자와 상품 간의 좋아요 관계를 관리합니다.
+ * </p>
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Entity
+@Table(name = "`like`")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Getter
+public class Like extends BaseEntity {
+    @Column(name = "ref_user_id", nullable = false)
+    private Long userId;
+
+    @Column(name = "ref_product_id", nullable = false)
+    private Long productId;
+
+    /**
+     * Like 인스턴스를 생성합니다.
+     *
+     * @param userId 사용자 ID
+     * @param productId 상품 ID
+     */
+    public Like(Long userId, Long productId) {
+        this.userId = userId;
+        this.productId = productId;
+    }
+
+    /**
+     * Like 인스턴스를 생성하는 정적 팩토리 메서드.
+     *
+     * @param userId 사용자 ID
+     * @param productId 상품 ID
+     * @return 생성된 Like 인스턴스
+     */
+    public static Like of(Long userId, Long productId) {
+        return new Like(userId, productId);
+    }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java
new file mode 100644
index 000000000..fbc2976c1
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java
@@ -0,0 +1,57 @@
+package com.loopers.domain.like;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * Like 엔티티에 대한 저장소 인터페이스.
+ * <p>
+ * 좋아요 정보의 영속성 계층과의 상호작용을 정의합니다.
+ * </p>
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+public interface LikeRepository {
+    /**
+     * 좋아요를 저장합니다.
+     *
+     * @param like 저장할 좋아요
+     * @return 저장된 좋아요
+     */
+    Like save(Like like);
+
+    /**
+     * 사용자 ID와 상품 ID로 좋아요를 조회합니다.
+     *
+     * @param userId 사용자 ID
+     * @param productId 상품 ID
+     * @return 조회된 좋아요를 담은 Optional
+     */
+    Optional<Like> findByUserIdAndProductId(Long userId, Long productId);
+
+    /**
+     * 좋아요를 삭제합니다.
+     *
+     * @param like 삭제할 좋아요
+     */
+    void delete(Like like);
+
+    /**
+     * 사용자 ID로 좋아요한 상품 목록을 조회합니다.
+     *
+     * @param userId 사용자 ID
+     * @return 좋아요 목록
+     */
+    List<Like> findAllByUserId(Long userId);
+
+    /**
+     * 상품별 좋아요 수를 집계합니다.
+     *
+     * @param productIds 상품 ID 목록
+     * @return 상품 ID를 키로, 좋아요 수를 값으로 하는 Map
+     */
+    Map<Long, Long> countByProductIds(List<Long> productIds);
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java
new file mode 100644
index 000000000..ca4837bd9
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java
@@ -0,0 +1,51 @@
+package com.loopers.domain.product;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Product 엔티티에 대한 저장소 인터페이스.
+ * <p>
+ * 상품 정보의 영속성 계층과의 상호작용을 정의합니다.
+ * </p>
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+public interface ProductRepository {
+    /**
+     * 상품을 저장합니다.
+     *
+     * @param product 저장할 상품
+     * @return 저장된 상품
+     */
+    Product save(Product product);
+    
+    /**
+     * 상품 ID로 상품을 조회합니다.
+     *
+     * @param productId 조회할 상품 ID
+     * @return 조회된 상품을 담은 Optional
+     */
+    Optional<Product> findById(Long productId);
+
+    /**
+     * 상품 목록을 조회합니다.
+     *
+     * @param brandId 브랜드 ID (null이면 전체 조회)
+     * @param sort 정렬 기준 (latest, price_asc, likes_desc)
+     * @param page 페이지 번호 (0부터 시작)
+     * @param size 페이지당 상품 수
+     * @return 상품 목록
+     */
+    List<Product> findAll(Long brandId, String sort, int page, int size);
+
+    /**
+     * 상품 목록의 총 개수를 조회합니다.
+     *
+     * @param brandId 브랜드 ID (null이면 전체 조회)
+     * @return 상품 총 개수
+     */
+    long countAll(Long brandId);
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java
new file mode 100644
index 000000000..3c1c3399a
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java
@@ -0,0 +1,60 @@
+package com.loopers.infrastructure.like;
+
+import com.loopers.domain.like.Like;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+/**
+ * Like 엔티티를 위한 Spring Data JPA 리포지토리.
+ */
+public interface LikeJpaRepository extends JpaRepository<Like, Long> {
+    /**
+     * 사용자 ID와 상품 ID로 좋아요를 조회합니다.
+     *
+     * @param userId 사용자 ID
+     * @param productId 상품 ID
+     * @return 조회된 좋아요를 담은 Optional
+     */
+    Optional<Like> findByUserIdAndProductId(Long userId, Long productId);
+
+    /**
+     * 사용자 ID로 좋아요 목록을 조회합니다.
+     *
+     * @param userId 사용자 ID
+     * @return 좋아요 목록
+     */
+    List<Like> findAllByUserId(Long userId);
+
+    /**
+     * 상품별 좋아요 수를 집계합니다.
+     *
+     * @param productIds 상품 ID 목록
+     * @return 상품 ID와 좋아요 수의 쌍 목록
+     */
+    @Query("SELECT l.productId, COUNT(l) FROM Like l WHERE l.productId IN :productIds GROUP BY l.productId")
+    List<Object[]> countByProductIds(@Param("productIds") List<Long> productIds);
+
+    /**
+     * 상품별 좋아요 수를 Map으로 변환합니다.
+     *
+     * @param productIds 상품 ID 목록
+     * @return 상품 ID를 키로, 좋아요 수를 값으로 하는 Map
+     */
+    default Map<Long, Long> countByProductIdsAsMap(List<Long> productIds) {
+        if (productIds == null || productIds.isEmpty()) {
+            return Map.of();
+        }
+        return countByProductIds(productIds).stream()
+            .collect(Collectors.toMap(
+                row -> (Long) row[0],
+                row -> ((Number) row[1]).longValue()
+            ));
+    }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java
new file mode 100644
index 000000000..bd169e7b2
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java
@@ -0,0 +1,45 @@
+package com.loopers.infrastructure.like;
+
+import com.loopers.domain.like.Like;
+import com.loopers.domain.like.LikeRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * LikeRepository의 JPA 구현체.
+ */
+@RequiredArgsConstructor
+@Repository
+public class LikeRepositoryImpl implements LikeRepository {
+    private final LikeJpaRepository likeJpaRepository;
+
+    @Override
+    public Like save(Like like) {
+        return likeJpaRepository.save(like);
+    }
+
+    @Override
+    public Optional<Like> findByUserIdAndProductId(Long userId, Long productId) {
+        return likeJpaRepository.findByUserIdAndProductId(userId, productId);
+    }
+
+    @Override
+    public void delete(Like like) {
+        likeJpaRepository.delete(like);
+    }
+
+    @Override
+    public List<Like> findAllByUserId(Long userId) {
+        return likeJpaRepository.findAllByUserId(userId);
+    }
+
+    @Override
+    public Map<Long, Long> countByProductIds(List<Long> productIds) {
+        return likeJpaRepository.countByProductIdsAsMap(productIds);
+    }
+}
+
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java
new file mode 100644
index 000000000..2dcb355a1
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java
@@ -0,0 +1,157 @@
+package com.loopers.application.like;
+
+import com.loopers.domain.like.Like;
+import com.loopers.domain.like.LikeRepository;
+import com.loopers.domain.product.Product;
+import com.loopers.domain.product.ProductRepository;
+import com.loopers.domain.user.User;
+import com.loopers.domain.user.UserRepository;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.never;
+
+@DisplayName("LikeFacade 좋아요 등록/취소/중복 방지 흐름 검증")
+class LikeFacadeTest {
+
+    private LikeFacade likeFacade;
+    private UserRepository userRepository;
+    private ProductRepository productRepository;
+    private LikeRepository likeRepository;
+
+    private static final String DEFAULT_USER_ID = "testuser";
+    private static final Long DEFAULT_USER_INTERNAL_ID = 1L;
+    private static final Long DEFAULT_PRODUCT_ID = 1L;
+
+    @BeforeEach
+    void setUp() {
+        userRepository = mock(UserRepository.class);
+        productRepository = mock(ProductRepository.class);
+        likeRepository = mock(LikeRepository.class);
+
+        likeFacade = new LikeFacade(
+            likeRepository,
+            userRepository,
+            productRepository
+        );
+    }
+
+    @Test
+    @DisplayName("좋아요를 등록할 수 있다")
+    void addLike_success() {
+        // arrange
+        setupMocks(DEFAULT_USER_ID, DEFAULT_USER_INTERNAL_ID, DEFAULT_PRODUCT_ID);
+        when(likeRepository.findByUserIdAndProductId(DEFAULT_USER_INTERNAL_ID, DEFAULT_PRODUCT_ID))
+            .thenReturn(Optional.empty());
+
+        // act
+        likeFacade.addLike(DEFAULT_USER_ID, DEFAULT_PRODUCT_ID);
+
+        // assert
+        verify(likeRepository).save(any(Like.class));
+    }
+
+    @Test
+    @DisplayName("좋아요를 취소할 수 있다")
+    void removeLike_success() {
+        // arrange
+        setupMocks(DEFAULT_USER_ID, DEFAULT_USER_INTERNAL_ID, DEFAULT_PRODUCT_ID);
+        Like like = Like.of(DEFAULT_USER_INTERNAL_ID, DEFAULT_PRODUCT_ID);
+        when(likeRepository.findByUserIdAndProductId(DEFAULT_USER_INTERNAL_ID, DEFAULT_PRODUCT_ID))
+            .thenReturn(Optional.of(like));
+
+        // act
+        likeFacade.removeLike(DEFAULT_USER_ID, DEFAULT_PRODUCT_ID);
+
+        // assert
+        verify(likeRepository).delete(like);
+    }
+
+    @Test
+    @DisplayName("좋아요는 중복 등록되지 않는다.")
+    void addLike_isIdempotent() {
+        // arrange
+        setupMocks(DEFAULT_USER_ID, DEFAULT_USER_INTERNAL_ID, DEFAULT_PRODUCT_ID);
+        when(likeRepository.findByUserIdAndProductId(DEFAULT_USER_INTERNAL_ID, DEFAULT_PRODUCT_ID))
+            .thenReturn(Optional.of(Like.of(DEFAULT_USER_INTERNAL_ID, DEFAULT_PRODUCT_ID)));
+
+        // act
+        likeFacade.addLike(DEFAULT_USER_ID, DEFAULT_PRODUCT_ID);
+
+        // assert - save는 한 번만 호출되어야 함 (중복 방지)
+        verify(likeRepository, never()).save(any(Like.class));
+    }
+
+    @Test
+    @DisplayName("좋아요는 중복 취소되지 않는다.")
+    void removeLike_isIdempotent() {
+        // arrange
+        setupMocks(DEFAULT_USER_ID, DEFAULT_USER_INTERNAL_ID, DEFAULT_PRODUCT_ID);
+        when(likeRepository.findByUserIdAndProductId(DEFAULT_USER_INTERNAL_ID, DEFAULT_PRODUCT_ID))
+            .thenReturn(Optional.empty()); // 좋아요 없음
+
+        // act - 좋아요가 없는 상태에서 취소 시도
+        likeFacade.removeLike(DEFAULT_USER_ID, DEFAULT_PRODUCT_ID);
+
+        // assert - 예외가 발생하지 않아야 함 (멱등성 보장)
+        verify(likeRepository).findByUserIdAndProductId(DEFAULT_USER_INTERNAL_ID, DEFAULT_PRODUCT_ID);
+        verify(likeRepository, never()).delete(any(Like.class));
+    }
+
+    @Test
+    @DisplayName("사용자를 찾을 수 없으면 예외를 던진다")
+    void addLike_userNotFound() {
+        // arrange
+        String unknownUserId = "unknown";
+        when(userRepository.findByUserId(unknownUserId)).thenReturn(null);
+
+        // act & assert
+        assertThatThrownBy(() -> likeFacade.addLike(unknownUserId, DEFAULT_PRODUCT_ID))
+            .isInstanceOf(CoreException.class)
+            .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND);
+    }
+
+    @Test
+    @DisplayName("상품을 찾을 수 없으면 예외를 던진다")
+    void addLike_productNotFound() {
+        // arrange
+        setupMockUser(DEFAULT_USER_ID, DEFAULT_USER_INTERNAL_ID);
+        Long nonExistentProductId = 999L;
+        when(productRepository.findById(nonExistentProductId)).thenReturn(Optional.empty());
+
+        // act & assert
+        assertThatThrownBy(() -> likeFacade.addLike(DEFAULT_USER_ID, nonExistentProductId))
+            .isInstanceOf(CoreException.class)
+            .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND);
+    }
+
+    // Helper methods for test setup
+
+    private void setupMocks(String userId, Long userInternalId, Long productId) {
+        setupMockUser(userId, userInternalId);
+        setupMockProduct(productId);
+    }
+
+    private void setupMockUser(String userId, Long userInternalId) {
+        User mockUser = mock(User.class);
+        when(mockUser.getId()).thenReturn(userInternalId);
+        when(userRepository.findByUserId(userId)).thenReturn(mockUser);
+    }
+
+    private void setupMockProduct(Long productId) {
+        Product mockProduct = mock(Product.class);
+        when(productRepository.findById(productId)).thenReturn(Optional.of(mockProduct));
+    }
+}
+

From d66d9fd398ba015b21ac52d3bf09f64d91813747 Mon Sep 17 00:00:00 2001
From: minor7295 <44902090+minor7295@users.noreply.github.com>
Date: Fri, 14 Nov 2025 00:05:30 +0900
Subject: [PATCH 4/7] Feature/order (#10)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* Feature/like (#9)

* feat: like 도메인 구현

* test: like에 대한 단위 테스트 코드 추가

* feat: like에 대한 서비스 로직 추가

* test: order 도메인에 대한 단위 테스트 추가

* feat: order 도메인 구현

* test: 주문에 대한 통합 테스트 코드 추가

* feat: 주문에 대한 서비스 로직 구현
---
 .../application/purchasing/OrderInfo.java     |  46 ++++
 .../purchasing/OrderItemCommand.java          |  22 ++
 .../application/purchasing/OrderItemInfo.java |  34 +++
 .../purchasing/PurchasingFacade.java          | 202 +++++++++++++++
 .../java/com/loopers/domain/order/Order.java  | 131 ++++++++++
 .../com/loopers/domain/order/OrderItem.java   |  56 ++++
 .../loopers/domain/order/OrderRepository.java |  39 +++
 .../com/loopers/domain/order/OrderStatus.java |  15 ++
 .../order/OrderJpaRepository.java             |  15 ++
 .../order/OrderRepositoryImpl.java            |  36 +++
 .../product/ProductJpaRepository.java         |  43 ++++
 .../product/ProductRepositoryImpl.java        |  77 ++++++
 .../application/like/LikeFacadeTest.java      |   1 -
 .../purchasing/PurchasingFacadeTest.java      | 242 ++++++++++++++++++
 .../com/loopers/domain/order/OrderTest.java   |  92 +++++++
 .../domain/order/OrderTestFixture.java        |  39 +++
 16 files changed, 1089 insertions(+), 1 deletion(-)
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/purchasing/OrderInfo.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/purchasing/OrderItemCommand.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/purchasing/OrderItemInfo.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/purchasing/PurchasingFacade.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeTest.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTestFixture.java

diff --git a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/OrderInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/OrderInfo.java
new file mode 100644
index 000000000..48e9bdb58
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/OrderInfo.java
@@ -0,0 +1,46 @@
+package com.loopers.application.purchasing;
+
+import com.loopers.domain.order.Order;
+import com.loopers.domain.order.OrderStatus;
+
+import java.util.List;
+
+/**
+ * 주문 정보를 담는 레코드.
+ *
+ * @param orderId 주문 ID
+ * @param userId 사용자 ID
+ * @param totalAmount 총 주문 금액
+ * @param status 주문 상태
+ * @param items 주문 아이템 목록
+ */
+public record OrderInfo(
+    Long orderId,
+    Long userId,
+    Integer totalAmount,
+    OrderStatus status,
+    List<OrderItemInfo> items
+) {
+    /**
+     * Order 엔티티로부터 OrderInfo를 생성합니다.
+     *
+     * @param order 주문 엔티티
+     * @return 생성된 OrderInfo
+     */
+    public static OrderInfo from(Order order) {
+        List<OrderItemInfo> itemInfos = order.getItems() == null
+            ? List.of()
+            : order.getItems().stream()
+                .map(OrderItemInfo::from)
+                .toList();
+
+        return new OrderInfo(
+            order.getId(),
+            order.getUserId(),
+            order.getTotalAmount(),
+            order.getStatus(),
+            itemInfos
+        );
+    }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/OrderItemCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/OrderItemCommand.java
new file mode 100644
index 000000000..b93d31215
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/OrderItemCommand.java
@@ -0,0 +1,22 @@
+package com.loopers.application.purchasing;
+
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+
+/**
+ * 주문 생성 요청 아이템 명령.
+ *
+ * @param productId 상품 ID
+ * @param quantity 수량
+ */
+public record OrderItemCommand(Long productId, Integer quantity) {
+    public OrderItemCommand {
+        if (productId == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "상품 ID는 필수입니다.");
+        }
+        if (quantity == null || quantity <= 0) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "상품 수량은 1개 이상이어야 합니다.");
+        }
+    }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/OrderItemInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/OrderItemInfo.java
new file mode 100644
index 000000000..dffb90549
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/OrderItemInfo.java
@@ -0,0 +1,34 @@
+package com.loopers.application.purchasing;
+
+import com.loopers.domain.order.OrderItem;
+
+/**
+ * 주문 아이템 정보를 담는 레코드.
+ *
+ * @param productId 상품 ID
+ * @param name 상품 이름
+ * @param price 상품 가격
+ * @param quantity 수량
+ */
+public record OrderItemInfo(
+    Long productId,
+    String name,
+    Integer price,
+    Integer quantity
+) {
+    /**
+     * OrderItem으로부터 OrderItemInfo를 생성합니다.
+     *
+     * @param item 주문 아이템
+     * @return 생성된 OrderItemInfo
+     */
+    public static OrderItemInfo from(OrderItem item) {
+        return new OrderItemInfo(
+            item.getProductId(),
+            item.getName(),
+            item.getPrice(),
+            item.getQuantity()
+        );
+    }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PurchasingFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PurchasingFacade.java
new file mode 100644
index 000000000..4bc808613
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PurchasingFacade.java
@@ -0,0 +1,202 @@
+package com.loopers.application.purchasing;
+
+import com.loopers.domain.order.Order;
+import com.loopers.domain.order.OrderItem;
+import com.loopers.domain.order.OrderRepository;
+import com.loopers.domain.product.Product;
+import com.loopers.domain.product.ProductRepository;
+import com.loopers.domain.user.Point;
+import com.loopers.domain.user.User;
+import com.loopers.domain.user.UserRepository;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import jakarta.transaction.Transactional;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * 구매 파사드.
+ * <p>
+ * 주문 생성과 결제(포인트 차감), 재고 조정, 외부 연동을 조율한다.
+ * </p>
+ */
+@RequiredArgsConstructor
+@Component
+public class PurchasingFacade {
+
+    private final UserRepository userRepository;
+    private final ProductRepository productRepository;
+    private final OrderRepository orderRepository;
+
+    /**
+     * 주문을 생성한다.
+     * <p>
+     * 1. 사용자 조회 및 존재 여부 검증<br>
+     * 2. 상품 재고 검증 및 차감<br>
+     * 3. 사용자 포인트 검증 및 차감<br>
+     * 4. 주문 저장 및 외부 시스템 알림
+     * </p>
+     *
+     * @param userId 사용자 식별자 (로그인 ID)
+     * @param commands 주문 상품 정보
+     * @return 생성된 주문 정보
+     */
+    @Transactional
+    public OrderInfo createOrder(String userId, List<OrderItemCommand> commands) {
+        if (commands == null || commands.isEmpty()) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "주문 아이템은 1개 이상이어야 합니다.");
+        }
+
+        User user = loadUser(userId);
+
+        Set<Long> productIds = new HashSet<>();
+        List<Product> products = new ArrayList<>();
+        List<OrderItem> orderItems = new ArrayList<>();
+
+        for (OrderItemCommand command : commands) {
+            if (!productIds.add(command.productId())) {
+                throw new CoreException(ErrorType.BAD_REQUEST,
+                    String.format("상품이 중복되었습니다. (상품 ID: %d)", command.productId()));
+            }
+
+            Product product = productRepository.findById(command.productId())
+                .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND,
+                    String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", command.productId())));
+            products.add(product);
+
+            orderItems.add(OrderItem.of(
+                product.getId(),
+                product.getName(),
+                product.getPrice(),
+                command.quantity()
+            ));
+        }
+
+        Order order = Order.of(user.getId(), orderItems);
+
+        decreaseStocksForOrderItems(order.getItems(), products);
+        deductUserPoint(user, order.getTotalAmount());
+        order.complete();
+
+        products.forEach(productRepository::save);
+        userRepository.save(user);
+
+        Order savedOrder = orderRepository.save(order);
+
+        return OrderInfo.from(savedOrder);
+    }
+
+    /**
+     * 주문을 취소하고 포인트를 환불하며 재고를 원복한다.
+     *
+     * @param order 주문 엔티티
+     * @param user 사용자 엔티티
+     */
+    @Transactional
+    public void cancelOrder(Order order, User user) {
+        if (order == null || user == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "취소할 주문과 사용자 정보는 필수입니다.");
+        }
+
+        List<Product> products = order.getItems().stream()
+            .map(item -> productRepository.findById(item.getProductId())
+                .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND,
+                    String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", item.getProductId()))))
+            .toList();
+
+        order.cancel();
+        increaseStocksForOrderItems(order.getItems(), products);
+        user.receivePoint(Point.of((long) order.getTotalAmount()));
+
+        products.forEach(productRepository::save);
+        userRepository.save(user);
+        orderRepository.save(order);
+    }
+
+    /**
+     * 사용자 ID로 주문 목록을 조회한다.
+     *
+     * @param userId 사용자 식별자 (로그인 ID)
+     * @return 주문 목록
+     */
+    @Transactional
+    public List<OrderInfo> getOrders(String userId) {
+        User user = loadUser(userId);
+        List<Order> orders = orderRepository.findAllByUserId(user.getId());
+        return orders.stream()
+            .map(OrderInfo::from)
+            .toList();
+    }
+
+    /**
+     * 주문 ID로 단일 주문을 조회한다.
+     *
+     * @param userId 사용자 식별자 (로그인 ID)
+     * @param orderId 주문 ID
+     * @return 주문 정보
+     */
+    @Transactional
+    public OrderInfo getOrder(String userId, Long orderId) {
+        User user = loadUser(userId);
+        Order order = orderRepository.findById(orderId)
+            .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다."));
+
+        if (!order.getUserId().equals(user.getId())) {
+            throw new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.");
+        }
+
+        return OrderInfo.from(order);
+    }
+
+    private void decreaseStocksForOrderItems(List<OrderItem> items, List<Product> products) {
+        Map<Long, Product> productMap = products.stream()
+            .collect(Collectors.toMap(Product::getId, product -> product));
+
+        for (OrderItem item : items) {
+            Product product = productMap.get(item.getProductId());
+            if (product == null) {
+                throw new CoreException(ErrorType.NOT_FOUND,
+                    String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", item.getProductId()));
+            }
+            product.decreaseStock(item.getQuantity());
+        }
+    }
+
+    private void increaseStocksForOrderItems(List<OrderItem> items, List<Product> products) {
+        Map<Long, Product> productMap = products.stream()
+            .collect(Collectors.toMap(Product::getId, product -> product));
+
+        for (OrderItem item : items) {
+            Product product = productMap.get(item.getProductId());
+            if (product == null) {
+                throw new CoreException(ErrorType.NOT_FOUND,
+                    String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", item.getProductId()));
+            }
+            product.increaseStock(item.getQuantity());
+        }
+    }
+
+    private void deductUserPoint(User user, Integer totalAmount) {
+        if (Objects.requireNonNullElse(totalAmount, 0) <= 0) {
+            return;
+        }
+        user.deductPoint(Point.of(totalAmount.longValue()));
+    }
+
+    private User loadUser(String userId) {
+        User user = userRepository.findByUserId(userId);
+        if (user == null) {
+            throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.");
+        }
+        return user;
+    }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java
new file mode 100644
index 000000000..4c0a59214
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java
@@ -0,0 +1,131 @@
+package com.loopers.domain.order;
+
+import com.loopers.domain.BaseEntity;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import org.hibernate.annotations.JdbcTypeCode;
+import org.hibernate.type.SqlTypes;
+
+import java.util.List;
+
+/**
+ * 주문 도메인 엔티티.
+ * <p>
+ * 주문의 상태, 총액, 주문 아이템을 관리합니다.
+ * </p>
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Entity
+@Table(name = "`order`")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Getter
+public class Order extends BaseEntity {
+    @Column(name = "ref_user_id", nullable = false)
+    private Long userId;
+
+    @Enumerated(EnumType.STRING)
+    @Column(name = "status", nullable = false)
+    private OrderStatus status;
+
+    @Column(name = "total_amount", nullable = false)
+    private Integer totalAmount;
+
+    @JdbcTypeCode(SqlTypes.JSON)
+    @Column(name = "items", nullable = false, columnDefinition = "json")
+    private List<OrderItem> items;
+
+    /**
+     * Order 인스턴스를 생성합니다.
+     *
+     * @param userId 사용자 ID
+     * @param items 주문 아이템 목록
+     * @throws CoreException items가 null이거나 비어있을 경우
+     */
+    public Order(Long userId, List<OrderItem> items) {
+        validateUserId(userId);
+        validateItems(items);
+        this.userId = userId;
+        this.items = items;
+        this.totalAmount = calculateTotalAmount(items);
+        this.status = OrderStatus.PENDING;
+    }
+
+    /**
+     * Order 인스턴스를 생성하는 정적 팩토리 메서드.
+     *
+     * @param userId 사용자 ID
+     * @param items 주문 아이템 목록
+     * @return 생성된 Order 인스턴스
+     */
+    public static Order of(Long userId, List<OrderItem> items) {
+        return new Order(userId, items);
+    }
+
+    /**
+     * 주문 아이템 목록으로부터 총액을 계산합니다.
+     *
+     * @param items 주문 아이템 목록
+     * @return 계산된 총액
+     */
+    private static Integer calculateTotalAmount(List<OrderItem> items) {
+        return items.stream()
+            .mapToInt(item -> item.getPrice() * item.getQuantity())
+            .sum();
+    }
+
+    /**
+     * 사용자 ID의 유효성을 검증합니다.
+     *
+     * @param userId 검증할 사용자 ID
+     * @throws CoreException userId가 null일 경우
+     */
+    private void validateUserId(Long userId) {
+        if (userId == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "사용자 ID는 필수입니다.");
+        }
+    }
+
+    /**
+     * 주문 아이템 목록의 유효성을 검증합니다.
+     *
+     * @param items 검증할 주문 아이템 목록
+     * @throws CoreException items가 null이거나 비어있을 경우
+     */
+    private void validateItems(List<OrderItem> items) {
+        if (items == null || items.isEmpty()) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "주문 아이템은 필수이며 최소 1개 이상이어야 합니다.");
+        }
+    }
+
+    /**
+     * 주문을 완료 상태로 변경합니다.
+     * 상태 변경만 수행하며, 포인트 차감은 도메인 서비스에서 처리합니다.
+     */
+    public void complete() {
+        if (this.status != OrderStatus.PENDING) {
+            throw new CoreException(ErrorType.BAD_REQUEST,
+                String.format("완료할 수 없는 주문 상태입니다. (현재 상태: %s)", this.status));
+        }
+        this.status = OrderStatus.COMPLETED;
+    }
+
+    /**
+     * 주문을 취소 상태로 변경합니다.
+     * 상태 변경만 수행하며, 포인트 환불은 도메인 서비스에서 처리합니다.
+     * PENDING 상태의 주문만 취소할 수 있습니다.
+     */
+    public void cancel() {
+        if (this.status != OrderStatus.PENDING) {
+            throw new CoreException(ErrorType.BAD_REQUEST,
+                String.format("취소할 수 없는 주문 상태입니다. (현재 상태: %s)", this.status));
+        }
+        this.status = OrderStatus.CANCELED;
+    }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java
new file mode 100644
index 000000000..8e18bc60d
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java
@@ -0,0 +1,56 @@
+package com.loopers.domain.order;
+
+import jakarta.persistence.Embeddable;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+
+/**
+ * 주문 아이템 Value Object.
+ * <p>
+ * 주문에 포함된 상품 정보와 수량을 나타냅니다.
+ * </p>
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Getter
+@EqualsAndHashCode
+@Embeddable
+public class OrderItem {
+    private Long productId;
+    private String name;
+    private Integer price;
+    private Integer quantity;
+
+    protected OrderItem() {
+    }
+
+    /**
+     * OrderItem 인스턴스를 생성합니다.
+     *
+     * @param productId 상품 ID
+     * @param name 상품 이름
+     * @param price 상품 가격
+     * @param quantity 수량
+     */
+    public OrderItem(Long productId, String name, Integer price, Integer quantity) {
+        this.productId = productId;
+        this.name = name;
+        this.price = price;
+        this.quantity = quantity;
+    }
+
+    /**
+     * OrderItem 인스턴스를 생성하는 정적 팩토리 메서드.
+     *
+     * @param productId 상품 ID
+     * @param name 상품 이름
+     * @param price 상품 가격
+     * @param quantity 수량
+     * @return 생성된 OrderItem 인스턴스
+     */
+    public static OrderItem of(Long productId, String name, Integer price, Integer quantity) {
+        return new OrderItem(productId, name, price, quantity);
+    }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java
new file mode 100644
index 000000000..a6f9870dc
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java
@@ -0,0 +1,39 @@
+package com.loopers.domain.order;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * 주문 저장소 인터페이스.
+ * <p>
+ * Order 엔티티의 영속성 계층 접근을 추상화합니다.
+ * </p>
+ */
+public interface OrderRepository {
+
+    /**
+     * 주문을 저장합니다.
+     *
+     * @param order 저장할 주문
+     * @return 저장된 주문
+     */
+    Order save(Order order);
+
+    /**
+     * 주문 ID로 주문을 조회합니다.
+     *
+     * @param orderId 조회할 주문 ID
+     * @return 조회된 주문
+     */
+    Optional<Order> findById(Long orderId);
+
+    /**
+     * 사용자 ID로 주문 목록을 조회합니다.
+     *
+     * @param userId 사용자 ID
+     * @return 해당 사용자의 주문 목록
+     */
+    List<Order> findAllByUserId(Long userId);
+}
+
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java
new file mode 100644
index 000000000..59fa340ce
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java
@@ -0,0 +1,15 @@
+package com.loopers.domain.order;
+
+/**
+ * 주문 상태 enum.
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+public enum OrderStatus {
+    PENDING,
+    COMPLETED,
+    CANCELED
+}
+
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java
new file mode 100644
index 000000000..02808e69c
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java
@@ -0,0 +1,15 @@
+package com.loopers.infrastructure.order;
+
+import com.loopers.domain.order.Order;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+
+/**
+ * Order JPA Repository.
+ */
+public interface OrderJpaRepository extends JpaRepository<Order, Long> {
+    List<Order> findAllByUserId(Long userId);
+}
+
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java
new file mode 100644
index 000000000..9440a7aa8
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java
@@ -0,0 +1,36 @@
+package com.loopers.infrastructure.order;
+
+import com.loopers.domain.order.Order;
+import com.loopers.domain.order.OrderRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * OrderRepository의 JPA 구현체.
+ */
+@RequiredArgsConstructor
+@Repository
+public class OrderRepositoryImpl implements OrderRepository {
+
+    private final OrderJpaRepository orderJpaRepository;
+
+    @Override
+    public Order save(Order order) {
+        return orderJpaRepository.save(order);
+    }
+
+    @Override
+    public Optional<Order> findById(Long orderId) {
+        return orderJpaRepository.findById(orderId);
+    }
+
+    @Override
+    public List<Order> findAllByUserId(Long userId) {
+        return orderJpaRepository.findAllByUserId(userId);
+    }
+}
+
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java
new file mode 100644
index 000000000..fe294dad8
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java
@@ -0,0 +1,43 @@
+package com.loopers.infrastructure.product;
+
+import com.loopers.domain.product.Product;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+/**
+ * Product 엔티티를 위한 Spring Data JPA 리포지토리.
+ * <p>
+ * JpaRepository를 확장하여 기본 CRUD 기능을 제공합니다.
+ * </p>
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+public interface ProductJpaRepository extends JpaRepository<Product, Long> {
+    /**
+     * 브랜드 ID로 상품을 조회합니다.
+     *
+     * @param brandId 브랜드 ID
+     * @param pageable 페이징 정보
+     * @return 상품 페이지
+     */
+    Page<Product> findByBrandId(Long brandId, Pageable pageable);
+
+    /**
+     * 전체 상품을 조회합니다.
+     *
+     * @param pageable 페이징 정보
+     * @return 상품 페이지
+     */
+    Page<Product> findAll(Pageable pageable);
+
+    /**
+     * 브랜드 ID로 상품 개수를 조회합니다.
+     *
+     * @param brandId 브랜드 ID
+     * @return 상품 개수
+     */
+    long countByBrandId(Long brandId);
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java
new file mode 100644
index 000000000..f578a7d3a
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java
@@ -0,0 +1,77 @@
+package com.loopers.infrastructure.product;
+
+import com.loopers.domain.product.Product;
+import com.loopers.domain.product.ProductRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Sort;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * ProductRepository의 JPA 구현체.
+ * <p>
+ * Spring Data JPA를 활용하여 Product 엔티티의 
+ * 영속성 작업을 처리합니다.
+ * </p>
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@RequiredArgsConstructor
+@Component
+public class ProductRepositoryImpl implements ProductRepository {
+    private final ProductJpaRepository productJpaRepository;
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public Product save(Product product) {
+        return productJpaRepository.save(product);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public Optional<Product> findById(Long productId) {
+        return productJpaRepository.findById(productId);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public List<Product> findAll(Long brandId, String sort, int page, int size) {
+        Pageable pageable = createPageable(sort, page, size);
+        Page<Product> productPage = brandId != null
+            ? productJpaRepository.findByBrandId(brandId, pageable)
+            : productJpaRepository.findAll(pageable);
+        return productPage.getContent();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public long countAll(Long brandId) {
+        return brandId != null
+            ? productJpaRepository.countByBrandId(brandId)
+            : productJpaRepository.count();
+    }
+
+    private Pageable createPageable(String sort, int page, int size) {
+        Sort sortObj = switch (sort != null ? sort : "latest") {
+            case "price_asc" -> Sort.by(Sort.Direction.ASC, "price");
+            case "likes_desc" -> Sort.by(Sort.Direction.DESC, "id"); // 좋아요 수는 별도 처리 필요
+            default -> Sort.by(Sort.Direction.DESC, "createdAt");
+        };
+        return PageRequest.of(page, size, sortObj);
+    }
+}
+
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java
index 2dcb355a1..86f7d91a0 100644
--- a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java
@@ -19,7 +19,6 @@
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.never;
 
 @DisplayName("LikeFacade 좋아요 등록/취소/중복 방지 흐름 검증")
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeTest.java
new file mode 100644
index 000000000..0809b50cc
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeTest.java
@@ -0,0 +1,242 @@
+package com.loopers.application.purchasing;
+
+import com.loopers.domain.brand.Brand;
+import com.loopers.domain.brand.BrandRepository;
+import com.loopers.domain.order.OrderStatus;
+import com.loopers.domain.product.Product;
+import com.loopers.domain.product.ProductRepository;
+import com.loopers.domain.user.Gender;
+import com.loopers.domain.user.Point;
+import com.loopers.domain.user.User;
+import com.loopers.domain.user.UserRepository;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import 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.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+@SpringBootTest
+@DisplayName("PurchasingFacade 주문 스펙 검증")
+class PurchasingFacadeTest {
+
+    @Autowired
+    private PurchasingFacade purchasingFacade;
+    
+    @Autowired
+    private UserRepository userRepository;
+    
+    @Autowired
+    private ProductRepository productRepository;
+    
+    @Autowired
+    private BrandRepository brandRepository;
+    
+    @Autowired
+    private DatabaseCleanUp databaseCleanUp;
+
+    @AfterEach
+    void tearDown() {
+        databaseCleanUp.truncateAllTables();
+    }
+
+    // Helper methods for test fixtures
+    private User createAndSaveUser(String userId, String email, long point) {
+        User user = User.of(userId, email, "1990-01-01", Gender.MALE, Point.of(point));
+        return userRepository.save(user);
+    }
+
+    private Brand createAndSaveBrand(String brandName) {
+        Brand brand = Brand.of(brandName);
+        return brandRepository.save(brand);
+    }
+
+    private Product createAndSaveProduct(String productName, int price, int stock, Long brandId) {
+        Product product = Product.of(productName, price, stock, brandId);
+        return productRepository.save(product);
+    }
+
+    @Test
+    @DisplayName("주문 생성 시 재고 차감, 포인트 차감, 주문 완료, 외부 전송을 수행한다")
+    void createOrder_successFlow() {
+        // arrange
+        User user = createAndSaveUser("testuser", "test@example.com", 50_000L);
+        Brand brand = createAndSaveBrand("브랜드");
+        Product product1 = createAndSaveProduct("상품1", 10_000, 10, brand.getId());
+        Product product2 = createAndSaveProduct("상품2", 5_000, 5, brand.getId());
+
+        List<OrderItemCommand> commands = List.of(
+            new OrderItemCommand(product1.getId(), 2),
+            new OrderItemCommand(product2.getId(), 1)
+        );
+
+        // act
+        OrderInfo orderInfo = purchasingFacade.createOrder(user.getUserId(), commands);
+
+        // assert
+        assertThat(orderInfo.status()).isEqualTo(OrderStatus.COMPLETED);
+        
+        // 재고 차감 확인
+        Product savedProduct1 = productRepository.findById(product1.getId()).orElseThrow();
+        Product savedProduct2 = productRepository.findById(product2.getId()).orElseThrow();
+        assertThat(savedProduct1.getStock()).isEqualTo(8); // 10 - 2
+        assertThat(savedProduct2.getStock()).isEqualTo(4); // 5 - 1
+        
+        // 포인트 차감 확인
+        User savedUser = userRepository.findByUserId(user.getUserId());
+        assertThat(savedUser.getPoint().getValue()).isEqualTo(25_000L); // 50_000 - (10_000 * 2 + 5_000 * 1)
+    }
+
+    @Test
+    @DisplayName("주문 아이템이 비어 있으면 예외를 던진다")
+    void createOrder_emptyItems_throwsException() {
+        // arrange
+        String userId = "user";
+        List<OrderItemCommand> emptyCommands = List.of();
+
+        // act & assert
+        assertThatThrownBy(() -> purchasingFacade.createOrder(userId, emptyCommands))
+            .isInstanceOf(CoreException.class)
+            .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST);
+    }
+
+    @Test
+    @DisplayName("사용자를 찾을 수 없으면 예외를 던진다")
+    void createOrder_userNotFound() {
+        // arrange
+        String unknownUserId = "unknown";
+        List<OrderItemCommand> commands = List.of(
+            new OrderItemCommand(1L, 1)
+        );
+
+        // act & assert
+        assertThatThrownBy(() -> purchasingFacade.createOrder(unknownUserId, commands))
+            .isInstanceOf(CoreException.class)
+            .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND);
+    }
+
+    @Test
+    @DisplayName("상품 재고가 부족하면 예외를 던지고 포인트는 차감되지 않는다")
+    void createOrder_stockNotEnough() {
+        // arrange
+        User user = createAndSaveUser("testuser2", "test2@example.com", 50_000L);
+        final String userId = user.getUserId();
+
+        Brand brand = createAndSaveBrand("브랜드2");
+        Product product = createAndSaveProduct("상품", 10_000, 1, brand.getId());
+        final Long productId = product.getId();
+
+        List<OrderItemCommand> commands = List.of(
+            new OrderItemCommand(productId, 2)
+        );
+
+        // act & assert
+        assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands))
+            .isInstanceOf(CoreException.class)
+            .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST);
+
+        // 포인트가 차감되지 않았는지 확인
+        User savedUser = userRepository.findByUserId(userId);
+        assertThat(savedUser.getPoint().getValue()).isEqualTo(50_000L);
+    }
+
+    @Test
+    @DisplayName("동일 상품을 중복 주문하면 예외를 던진다")
+    void createOrder_duplicateProducts_throwsException() {
+        // arrange
+        User user = createAndSaveUser("testuser3", "test3@example.com", 50_000L);
+        final String userId = user.getUserId();
+
+        Brand brand = createAndSaveBrand("브랜드3");
+        Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId());
+        final Long productId = product.getId();
+
+        List<OrderItemCommand> commands = List.of(
+            new OrderItemCommand(productId, 1),
+            new OrderItemCommand(productId, 2)
+        );
+
+        // act & assert
+        assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands))
+            .isInstanceOf(CoreException.class)
+            .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST);
+
+        // 주문이 저장되지 않았는지 확인
+        List<OrderInfo> orders = purchasingFacade.getOrders(userId);
+        assertThat(orders).isEmpty();
+    }
+
+    @Test
+    @DisplayName("사용자의 주문 목록을 조회한다")
+    void getOrders_returnsUserOrders() {
+        // arrange
+        User user = createAndSaveUser("testuser4", "test4@example.com", 50_000L);
+        Brand brand = createAndSaveBrand("브랜드4");
+        Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId());
+
+        List<OrderItemCommand> commands = List.of(
+            new OrderItemCommand(product.getId(), 1)
+        );
+        purchasingFacade.createOrder(user.getUserId(), commands);
+
+        // act
+        List<OrderInfo> orders = purchasingFacade.getOrders(user.getUserId());
+
+        // assert
+        assertThat(orders).hasSize(1);
+        assertThat(orders.get(0).status()).isEqualTo(OrderStatus.COMPLETED);
+    }
+
+    @Test
+    @DisplayName("사용자의 단일 주문을 조회한다")
+    void getOrder_returnsSingleOrder() {
+        // arrange
+        User user = createAndSaveUser("testuser5", "test5@example.com", 50_000L);
+        Brand brand = createAndSaveBrand("브랜드5");
+        Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId());
+
+        List<OrderItemCommand> commands = List.of(
+            new OrderItemCommand(product.getId(), 1)
+        );
+        OrderInfo createdOrder = purchasingFacade.createOrder(user.getUserId(), commands);
+
+        // act
+        OrderInfo found = purchasingFacade.getOrder(user.getUserId(), createdOrder.orderId());
+
+        // assert
+        assertThat(found.orderId()).isEqualTo(createdOrder.orderId());
+        assertThat(found.status()).isEqualTo(OrderStatus.COMPLETED);
+    }
+
+    @Test
+    @DisplayName("다른 사용자의 주문은 조회할 수 없다")
+    void getOrder_withDifferentUser_throwsException() {
+        // arrange
+        User user1 = createAndSaveUser("user1", "user1@example.com", 50_000L);
+        User user2 = createAndSaveUser("user2", "user2@example.com", 50_000L);
+        final String user1Id = user1.getUserId();
+        final String user2Id = user2.getUserId();
+
+        Brand brand = createAndSaveBrand("브랜드6");
+        Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId());
+
+        List<OrderItemCommand> commands = List.of(
+            new OrderItemCommand(product.getId(), 1)
+        );
+        OrderInfo user1Order = purchasingFacade.createOrder(user1Id, commands);
+        final Long orderId = user1Order.orderId();
+
+        // act & assert
+        assertThatThrownBy(() -> purchasingFacade.getOrder(user2Id, orderId))
+            .isInstanceOf(CoreException.class)
+            .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND);
+    }
+
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java
new file mode 100644
index 000000000..b3e8db17b
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java
@@ -0,0 +1,92 @@
+package com.loopers.domain.order;
+
+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.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+public class OrderTest {
+
+    @DisplayName("정상 주문 / 예외 주문 흐름을 모두 검증한다.")
+    @Nested
+    class OrderFlow {
+        @DisplayName("정상 주문 흐름: 주문이 정상적으로 생성되고 총액이 올바르게 계산된다.")
+        @Test
+        void normalOrderFlow() {
+            // arrange
+            Long userId = OrderTestFixture.ValidOrder.USER_ID;
+            List<OrderItem> items = OrderTestFixture.ValidOrderItem.createMultipleItems();
+            // 상품 1: 10000 * 1 = 10000
+            // 상품 2: 5000 * 2 = 10000
+            // 총액: 20000
+
+            // act
+            Order order = Order.of(userId, items);
+
+            // assert
+            assertThat(order.getUserId()).isEqualTo(userId);
+            assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING);
+            assertThat(order.getTotalAmount()).isEqualTo(20000);
+            assertThat(order.getItems()).hasSize(2);
+            assertThat(order.getItems()).containsExactlyElementsOf(items);
+        }
+
+        @DisplayName("예외 주문 흐름: 주문 아이템이 null이면 예외가 발생한다.")
+        @Test
+        void exceptionOrderFlow_nullItems() {
+            // arrange
+            Long userId = OrderTestFixture.ValidOrder.USER_ID;
+
+            // act
+            CoreException result = assertThrows(CoreException.class, () -> {
+                Order.of(userId, null);
+            });
+
+            // assert
+            assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
+            assertThat(result.getMessage()).contains("주문 아이템은 필수이며 최소 1개 이상이어야 합니다");
+        }
+
+        @DisplayName("예외 주문 흐름: 주문 아이템이 비어있으면 예외가 발생한다.")
+        @Test
+        void exceptionOrderFlow_emptyItems() {
+            // arrange
+            Long userId = OrderTestFixture.ValidOrder.USER_ID;
+            List<OrderItem> emptyItems = List.of();
+
+            // act
+            CoreException result = assertThrows(CoreException.class, () -> {
+                Order.of(userId, emptyItems);
+            });
+
+            // assert
+            assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
+            assertThat(result.getMessage()).contains("주문 아이템은 필수이며 최소 1개 이상이어야 합니다");
+        }
+
+        @DisplayName("예외 주문 흐름: 사용자 ID가 null이면 예외가 발생한다.")
+        @Test
+        void exceptionOrderFlow_nullUserId() {
+            // arrange
+            List<OrderItem> items = OrderTestFixture.ValidOrderItem.createMultipleItems();
+
+            // act
+            CoreException result = assertThrows(CoreException.class, () -> {
+                Order.of(null, items);
+            });
+
+            // assert
+            assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
+            assertThat(result.getMessage()).contains("사용자 ID는 필수입니다");
+        }
+    }
+
+}
+
+
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTestFixture.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTestFixture.java
new file mode 100644
index 000000000..2f11dedf1
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTestFixture.java
@@ -0,0 +1,39 @@
+package com.loopers.domain.order;
+
+import java.util.List;
+
+/**
+ * 테스트용 고정 데이터 (Fixture) 클래스
+ * 모든 Order 관련 테스트에서 사용하는 공통 데이터를 관리
+ */
+public class OrderTestFixture {
+
+    // 기본 유효한 테스트 데이터
+    public static final class ValidOrder {
+        public static final Long USER_ID = 1L;
+        public static final Integer TOTAL_AMOUNT = 20000;
+    }
+
+    // 기본 유효한 주문 아이템 데이터
+    public static final class ValidOrderItem {
+        public static final Long PRODUCT_ID_1 = 1L;
+        public static final String NAME_1 = "테스트 상품 1";
+        public static final Integer PRICE_1 = 10000;
+        public static final Integer QUANTITY_1 = 1;
+
+        public static final Long PRODUCT_ID_2 = 2L;
+        public static final String NAME_2 = "테스트 상품 2";
+        public static final Integer PRICE_2 = 5000;
+        public static final Integer QUANTITY_2 = 2;
+
+        public static List<OrderItem> createMultipleItems() {
+            return List.of(
+                OrderItem.of(PRODUCT_ID_1, NAME_1, PRICE_1, QUANTITY_1),
+                OrderItem.of(PRODUCT_ID_2, NAME_2, PRICE_2, QUANTITY_2)
+            );
+        }
+    }
+
+}
+
+

From eb9a7dafa170debb02724d05817149f8df2950c3 Mon Sep 17 00:00:00 2001
From: minor7295 <44902090+minor7295@users.noreply.github.com>
Date: Fri, 14 Nov 2025 01:19:10 +0900
Subject: [PATCH 5/7] Feature/product detail (#11)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* test: 상품 정보에 대한 도메인 서비스 테스트 코드 추가

* feat: 상품 정보 도메인 서비스 구현

* feat: 상품 정보 조회 서비스 로직 구현
---
 .../catalog/CatalogProductFacade.java         |  81 ++++++++++++++
 .../application/catalog/ProductInfo.java      |  12 +++
 .../application/catalog/ProductInfoList.java  |  46 ++++++++
 .../loopers/domain/brand/BrandRepository.java |  31 ++++++
 .../loopers/domain/product/ProductDetail.java |  53 ++++++++++
 .../domain/product/ProductDetailService.java  |  53 ++++++++++
 .../brand/BrandJpaRepository.java             |  11 ++
 .../brand/BrandRepositoryImpl.java            |  28 +++++
 .../product/ProductDetailServiceTest.java     | 100 ++++++++++++++++++
 9 files changed, 415 insertions(+)
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogProductFacade.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/catalog/ProductInfo.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/catalog/ProductInfoList.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDetail.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDetailService.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductDetailServiceTest.java

diff --git a/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogProductFacade.java
new file mode 100644
index 000000000..53d7ca98c
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogProductFacade.java
@@ -0,0 +1,81 @@
+package com.loopers.application.catalog;
+
+import com.loopers.domain.brand.Brand;
+import com.loopers.domain.brand.BrandRepository;
+import com.loopers.domain.like.LikeRepository;
+import com.loopers.domain.product.Product;
+import com.loopers.domain.product.ProductDetail;
+import com.loopers.domain.product.ProductDetailService;
+import com.loopers.domain.product.ProductRepository;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * 상품 조회 파사드.
+ * <p>
+ * 상품 목록 조회 및 상품 정보 조회 유즈케이스를 처리하는 애플리케이션 서비스입니다.
+ * </p>
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@RequiredArgsConstructor
+@Component
+public class CatalogProductFacade {
+    private final ProductRepository productRepository;
+    private final BrandRepository brandRepository;
+    private final LikeRepository likeRepository;
+    private final ProductDetailService productDetailService;
+
+    /**
+     * 상품 목록을 조회합니다.
+     *
+     * @param brandId 브랜드 ID (선택)
+     * @param sort 정렬 기준 (latest, price_asc, likes_desc)
+     * @param page 페이지 번호 (0부터 시작)
+     * @param size 페이지당 상품 수
+     * @return 상품 목록 조회 결과
+     */
+    public ProductInfoList getProducts(Long brandId, String sort, int page, int size) {
+        long totalCount = productRepository.countAll(brandId);
+        List<Product> products = productRepository.findAll(brandId, sort, page, size);
+        List<ProductInfo> productsInfo = products.stream()
+            .map(product -> getProduct(product.getId()))
+            .toList();
+        return new ProductInfoList(productsInfo, totalCount, page, size);
+    }
+
+    /**
+     * 상품 정보를 조회합니다.
+     *
+     * @param productId 상품 ID
+     * @return 상품 정보와 좋아요 수
+     * @throws CoreException 상품을 찾을 수 없는 경우
+     */
+    public ProductInfo getProduct(Long productId) {
+        Product product = productRepository.findById(productId)
+            .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
+        
+        // 브랜드 조회
+        Brand brand = brandRepository.findById(product.getBrandId())
+            .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다."));
+        
+        // 좋아요 수 조회
+        Map<Long, Long> likesCountMap = likeRepository.countByProductIds(List.of(productId));
+        Long likesCount = likesCountMap.getOrDefault(productId, 0L);
+        
+        // 도메인 서비스를 통해 ProductDetail 생성 (도메인 객체 협력)
+        ProductDetail productDetail = productDetailService.combineProductAndBrand(product, brand, likesCount);
+        
+        return new ProductInfo(productDetail);
+    }
+
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/catalog/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/catalog/ProductInfo.java
new file mode 100644
index 000000000..6a22a5f21
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/catalog/ProductInfo.java
@@ -0,0 +1,12 @@
+package com.loopers.application.catalog;
+
+import com.loopers.domain.product.ProductDetail;
+
+/**
+ * 상품 상세 정보를 담는 레코드.
+ *
+ * @param productDetail 상품 상세 정보 (Product + Brand + 좋아요 수)
+ */
+public record ProductInfo(ProductDetail productDetail) {
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/catalog/ProductInfoList.java b/apps/commerce-api/src/main/java/com/loopers/application/catalog/ProductInfoList.java
new file mode 100644
index 000000000..23f1a9345
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/catalog/ProductInfoList.java
@@ -0,0 +1,46 @@
+package com.loopers.application.catalog;
+
+import java.util.List;
+
+/**
+ * 상품 목록 조회 결과.
+ *
+ * @param products 상품 목록 (좋아요 수 포함)
+ * @param totalCount 전체 상품 수
+ * @param page 현재 페이지 번호
+ * @param size 페이지당 상품 수
+ */
+public record ProductInfoList(
+    List<ProductInfo> products,
+    long totalCount,
+    int page,
+    int size
+) {
+    /**
+     * 전체 페이지 수를 계산합니다.
+     *
+     * @return 전체 페이지 수
+     */
+    public int getTotalPages() {
+        return size > 0 ? (int) Math.ceil((double) totalCount / size) : 0;
+    }
+
+    /**
+     * 다음 페이지가 있는지 확인합니다.
+     *
+     * @return 다음 페이지 존재 여부
+     */
+    public boolean hasNext() {
+        return (page + 1) * size < totalCount;
+    }
+
+    /**
+     * 이전 페이지가 있는지 확인합니다.
+     *
+     * @return 이전 페이지 존재 여부
+     */
+    public boolean hasPrevious() {
+        return page > 0;
+    }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java
new file mode 100644
index 000000000..df55a0780
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java
@@ -0,0 +1,31 @@
+package com.loopers.domain.brand;
+
+import java.util.Optional;
+
+/**
+ * Brand 엔티티에 대한 저장소 인터페이스.
+ * <p>
+ * 브랜드 정보의 영속성 계층과의 상호작용을 정의합니다.
+ * </p>
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+public interface BrandRepository {
+    /**
+     * 브랜드를 저장합니다.
+     *
+     * @param brand 저장할 브랜드
+     * @return 저장된 브랜드
+     */
+    Brand save(Brand brand);
+    
+    /**
+     * 브랜드 ID로 브랜드를 조회합니다.
+     *
+     * @param brandId 조회할 브랜드 ID
+     * @return 조회된 브랜드를 담은 Optional
+     */
+    Optional<Brand> findById(Long brandId);
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDetail.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDetail.java
new file mode 100644
index 000000000..190b4a00d
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDetail.java
@@ -0,0 +1,53 @@
+package com.loopers.domain.product;
+
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+
+/**
+ * 상품 상세 정보 Value Object.
+ * <p>
+ * 상품 상세 조회 시 Product, Brand 정보, 좋아요 수를 조합한 결과를 나타냅니다.
+ * 값으로 식별되며 불변성을 가집니다.
+ * </p>
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Getter
+@EqualsAndHashCode
+public class ProductDetail {
+    private final Long id;
+    private final String name;
+    private final Integer price;
+    private final Integer stock;
+    private final Long brandId;
+    private final String brandName;
+    private final Long likesCount;
+
+    private ProductDetail(Long id, String name, Integer price, Integer stock, Long brandId, String brandName, Long likesCount) {
+        this.id = id;
+        this.name = name;
+        this.price = price;
+        this.stock = stock;
+        this.brandId = brandId;
+        this.brandName = brandName;
+        this.likesCount = likesCount;
+    }
+
+    /**
+     * ProductDetail 인스턴스를 생성하는 정적 팩토리 메서드.
+     *
+     * @param id 상품 ID
+     * @param name 상품 이름
+     * @param price 상품 가격
+     * @param stock 상품 재고
+     * @param brandId 브랜드 ID
+     * @param brandName 브랜드 이름
+     * @param likesCount 좋아요 수
+     * @return 생성된 ProductDetail 인스턴스
+     */
+    public static ProductDetail of(Long id, String name, Integer price, Integer stock, Long brandId, String brandName, Long likesCount) {
+        return new ProductDetail(id, name, price, stock, brandId, brandName, likesCount);
+    }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDetailService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDetailService.java
new file mode 100644
index 000000000..ef2614040
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDetailService.java
@@ -0,0 +1,53 @@
+package com.loopers.domain.product;
+
+import com.loopers.domain.brand.Brand;
+import org.springframework.stereotype.Component;
+
+/**
+ * 상품 상세 정보 조합 도메인 서비스.
+ * <p>
+ * 상품 상세 조회 시 Product와 Brand 정보, 좋아요 수를 조합하는 도메인 로직을 처리합니다.
+ * 도메인 간 협력 로직(Product + Brand + Like)을 담당합니다.
+ * </p>
+ * <p>
+ * 상태가 없고, 도메인 객체의 협력 중심으로 설계되었습니다.
+ * Repository 의존성 없이 도메인 객체(Product, Brand, likesCount)를 파라미터로 받아 처리합니다.
+ * </p>
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Component
+public class ProductDetailService {
+
+    /**
+     * 상품과 브랜드 정보, 좋아요 수를 조합하여 상품 상세 정보를 생성합니다.
+     *
+     * @param product 상품 엔티티
+     * @param brand 브랜드 엔티티
+     * @param likesCount 좋아요 수
+     * @return 조합된 상품 상세 정보
+     */
+    public ProductDetail combineProductAndBrand(Product product, Brand brand, Long likesCount) {
+        if (product == null) {
+            throw new IllegalArgumentException("상품은 null일 수 없습니다.");
+        }
+        if (brand == null) {
+            throw new IllegalArgumentException("브랜드 정보는 필수입니다.");
+        }
+        if (likesCount == null) {
+            throw new IllegalArgumentException("좋아요 수는 null일 수 없습니다.");
+        }
+
+        return ProductDetail.of(
+            product.getId(),
+            product.getName(),
+            product.getPrice(),
+            product.getStock(),
+            product.getBrandId(),
+            brand.getName(),
+            likesCount
+        );
+    }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java
new file mode 100644
index 000000000..1d28a2376
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java
@@ -0,0 +1,11 @@
+package com.loopers.infrastructure.brand;
+
+import com.loopers.domain.brand.Brand;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+/**
+ * Brand 엔티티를 위한 Spring Data JPA 리포지토리.
+ */
+public interface BrandJpaRepository extends JpaRepository<Brand, Long> {
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java
new file mode 100644
index 000000000..7e616b95b
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java
@@ -0,0 +1,28 @@
+package com.loopers.infrastructure.brand;
+
+import com.loopers.domain.brand.Brand;
+import com.loopers.domain.brand.BrandRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Repository;
+
+import java.util.Optional;
+
+/**
+ * BrandRepository의 JPA 구현체.
+ */
+@RequiredArgsConstructor
+@Repository
+public class BrandRepositoryImpl implements BrandRepository {
+    private final BrandJpaRepository brandJpaRepository;
+
+    @Override
+    public Brand save(Brand brand) {
+        return brandJpaRepository.save(brand);
+    }
+
+    @Override
+    public Optional<Brand> findById(Long brandId) {
+        return brandJpaRepository.findById(brandId);
+    }
+}
+
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductDetailServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductDetailServiceTest.java
new file mode 100644
index 000000000..588a2ac09
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductDetailServiceTest.java
@@ -0,0 +1,100 @@
+package com.loopers.domain.product;
+
+import com.loopers.domain.brand.Brand;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+public class ProductDetailServiceTest {
+
+    private final ProductDetailService productDetailService = new ProductDetailService();
+
+    @DisplayName("상품 상세 조회 시 Product + Brand + 좋아요 수 정보 조합은 도메인 서비스에서 처리했다")
+    @Test
+    void combineProductAndBrand() {
+        // arrange
+        Brand brand = Brand.of(ProductTestFixture.ValidBrand.NAME);
+        Product product = Product.of(
+            ProductTestFixture.ValidProduct.NAME,
+            ProductTestFixture.ValidProduct.PRICE,
+            ProductTestFixture.ValidProduct.STOCK,
+            brand.getId()
+        );
+        Long likesCount = 10L;
+
+        // act
+        ProductDetail productDetail = productDetailService.combineProductAndBrand(product, brand, likesCount);
+
+        // assert
+        assertThat(productDetail.getId()).isEqualTo(product.getId());
+        assertThat(productDetail.getName()).isEqualTo(product.getName());
+        assertThat(productDetail.getPrice()).isEqualTo(product.getPrice());
+        assertThat(productDetail.getStock()).isEqualTo(product.getStock());
+        assertThat(productDetail.getBrandId()).isEqualTo(brand.getId());
+        assertThat(productDetail.getBrandName()).isEqualTo(brand.getName());
+        assertThat(productDetail.getLikesCount()).isEqualTo(likesCount);
+    }
+
+    @DisplayName("상품이 null이면 예외가 발생한다.")
+    @Test
+    void throwsException_whenProductIsNull() {
+        // arrange
+        Brand brand = Brand.of(ProductTestFixture.ValidBrand.NAME);
+        Long likesCount = 0L;
+
+        // act
+        IllegalArgumentException result = assertThrows(IllegalArgumentException.class, () -> {
+            productDetailService.combineProductAndBrand(null, brand, likesCount);
+        });
+
+        // assert
+        assertThat(result.getMessage()).contains("상품은 null일 수 없습니다");
+    }
+
+    @DisplayName("브랜드가 null이면 예외가 발생한다.")
+    @Test
+    void throwsException_whenBrandIsNull() {
+        // arrange
+        Brand brand = Brand.of(ProductTestFixture.ValidBrand.NAME);
+        Product product = Product.of(
+            ProductTestFixture.ValidProduct.NAME,
+            ProductTestFixture.ValidProduct.PRICE,
+            ProductTestFixture.ValidProduct.STOCK,
+            brand.getId()
+        );
+        Long likesCount = 0L;
+
+        // act
+        IllegalArgumentException result = assertThrows(IllegalArgumentException.class, () -> {
+            productDetailService.combineProductAndBrand(product, null, likesCount);
+        });
+
+        // assert
+        assertThat(result.getMessage()).contains("브랜드 정보는 필수입니다");
+    }
+
+    @DisplayName("좋아요 수가 null이면 예외가 발생한다.")
+    @Test
+    void throwsException_whenLikesCountIsNull() {
+        // arrange
+        Brand brand = Brand.of(ProductTestFixture.ValidBrand.NAME);
+        Product product = Product.of(
+            ProductTestFixture.ValidProduct.NAME,
+            ProductTestFixture.ValidProduct.PRICE,
+            ProductTestFixture.ValidProduct.STOCK,
+            brand.getId()
+        );
+
+        // act
+        IllegalArgumentException result = assertThrows(IllegalArgumentException.class, () -> {
+            productDetailService.combineProductAndBrand(product, brand, null);
+        });
+
+        // assert
+        assertThat(result.getMessage()).contains("좋아요 수는 null일 수 없습니다");
+    }
+
+}
+

From c4e3ca795055458a41e446b8fd1eef09f398645c Mon Sep 17 00:00:00 2001
From: minor7295 <44902090+minor7295@users.noreply.github.com>
Date: Fri, 14 Nov 2025 01:23:43 +0900
Subject: [PATCH 6/7] =?UTF-8?q?feat:=20=ED=85=8C=EC=9D=B4=EB=B8=94?=
 =?UTF-8?q?=EB=AA=85=EC=97=90=20`=EC=9D=B4=20=ED=8F=AC=ED=95=A8=EB=90=98?=
 =?UTF-8?q?=EB=8A=94=20=EA=B2=BD=EC=9A=B0=EC=97=90=EB=8F=84=20DatabaseClea?=
 =?UTF-8?q?nUp=EB=A1=9C=EC=A7=81=EC=97=90=EC=84=9C=20=EC=98=A4=EB=A5=98=20?=
 =?UTF-8?q?=EB=B0=9C=EC=83=9D=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8F=84?=
 =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95=20(#12)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../java/com/loopers/utils/DatabaseCleanUp.java            | 7 ++++++-
 1 file changed, 6 insertions(+), 1 deletion(-)

diff --git a/modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java b/modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java
index 85c43ace9..14251dad8 100644
--- a/modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java
+++ b/modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java
@@ -33,7 +33,12 @@ public void truncateAllTables() {
         entityManager.createNativeQuery("SET FOREIGN_KEY_CHECKS = 0").executeUpdate();
 
         for (String table : tableNames) {
-            entityManager.createNativeQuery("TRUNCATE TABLE `" + table + "`").executeUpdate();
+            String tableName = table;
+            // 테이블 이름에 이미 백틱이 있으면 생략
+            if (!tableName.startsWith("`") && !tableName.endsWith("`")) {
+                tableName = "`" + tableName + "`";
+            }
+            entityManager.createNativeQuery("TRUNCATE TABLE " + tableName).executeUpdate();
         }
 
         entityManager.createNativeQuery("SET FOREIGN_KEY_CHECKS = 1").executeUpdate();

From d405f6d51c21628521219a309ec1e9fc3b88ab28 Mon Sep 17 00:00:00 2001
From: minor7295 <44902090+minor7295@users.noreply.github.com>
Date: Fri, 14 Nov 2025 02:42:29 +0900
Subject: [PATCH 7/7] =?UTF-8?q?refactor:=20=EC=84=A4=EA=B3=84=20=EB=AC=B8?=
 =?UTF-8?q?=EC=84=9C=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EB=B0=8F=20?=
 =?UTF-8?q?=EA=B5=AC=ED=98=84=20=EB=82=B4=EC=9A=A9=20=EB=B0=98=EC=98=81=20?=
 =?UTF-8?q?(#13)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .docs/design/01-requirements.md      |  34 +--
 .docs/design/02-sequence-diagrams.md | 227 ++++++++--------
 .docs/design/03-class-diagram.md     | 385 ++++++++++++++++++++++-----
 .docs/design/04-erd.md               |  23 +-
 4 files changed, 462 insertions(+), 207 deletions(-)

diff --git a/.docs/design/01-requirements.md b/.docs/design/01-requirements.md
index ddca484ea..bd42c9782 100644
--- a/.docs/design/01-requirements.md
+++ b/.docs/design/01-requirements.md
@@ -56,7 +56,7 @@
 | **액터** | User |
 | **사전조건** | • 요청 시 상품 ID가 파라미터로 전달된다.<br> • (선택) 사용자가 로그인된 상태이다.|
 | **기본 시나리오** | 1) 사용자는 상품 목록이나 링크를 통해 상세 페이지에 접근할 수 있다.<br> 2) 시스템은 상품명, 가격, 재고, 좋아요 수, 브랜드 정보를 반환한다. |
-| **예외 시나리오** | • 상품 ID가 존재하지 않거나 삭제된 상품 혹은 인 경우 404 반환한다. <br> • 상품이 비공개 상태일 경우 403 반환한다.<br> • 상품 ID 형식이 잘못된 경우 400 반환한다. |
+| **예외 시나리오** | • 상품 ID가 존재하지 않거나 삭제된 상품인 경우 "상품을 찾을 수 없습니다" 메시지 반환<br> • 상품이 비공개 상태일 경우 "접근할 수 없는 상품입니다" 메시지 반환<br> • 상품 ID 형식이 잘못된 경우 "잘못된 요청입니다" 메시지 반환 |
 | **후조건** | 상품 상세 정보가 표시된다. |
 
 ---
@@ -66,10 +66,10 @@
 |------|------|
 | **액터** | User |
 | **사전조건** | • 사용자는 로그인 상태이다. |
-| **기본 시나리오** | 1) 사용자가 상세/목록 페이지에서 좋아요 버튼을 클릭한다.<br> 2) 시스템은 해당 사용자의 좋아요 등록/취소를 처리한다.<br> 4) 현재 상태(liked=true/false)와 총 좋아요 수를 반환한다. |
-| **예외 시나리오** | • 중복 요청 시 동일 결과 반환(멱등 보장)<br> • 데이터 충돌 시 요청 재시도 처리 |
+| **기본 시나리오** | 1) 사용자가 상세/목록 페이지에서 좋아요 버튼을 클릭한다.<br> 2) 시스템은 해당 사용자의 좋아요 등록/취소를 처리한다.<br> 3) 현재 좋아요 상태(좋아요 함/안 함)와 총 좋아요 수를 반환한다. |
+| **예외 시나리오** | • 이미 좋아요한 상품에 다시 좋아요 요청 시 현재 상태 유지 (중복 방지)<br> • 좋아요하지 않은 상품에 취소 요청 시 현재 상태 유지 (중복 방지)<br> • 시스템 오류 시 요청 재시도 가능 |
 | **후조건** | 좋아요 상태가 변경되고, 상품 목록/상세 정보에 반영된다. |
-| **비고** | (user_id, product_id) UNIQUE 제약으로 중복 방지 |
+| **비고** | 동일 사용자는 동일 상품에 대해 하나의 좋아요만 등록 가능 (중복 방지) |
 
 ---
 
@@ -78,17 +78,17 @@
 |------|------|
 | **액터** | User |
 | **사전조건** | 상품이 존재하고 재고 및 포인트가 충분해야 함 |
-| **기본 시나리오** | 1) 사용자가 여러 상품을 선택해 주문을 요청한다.<br> 2) OrderService는 ProductService에 재고 확인을 요청한다.<br> 3) OrderService는 PointService에 결제 금액만큼 포인트 차감을 요청한다.<br> 4) 모든 검증이 통과되면 주문 정보를 생성한다.<br> 5) 주문 정보를 외부 시스템으로 전송한다.<br> 6) 주문 생성 결과(주문번호, 결제금액, 잔여 포인트)를 반환한다. |
-| **예외 시나리오** | • 포인트 부족 → “결제 실패” 응답 및 충전 안내<br> • 재고 부족 → “주문 불가 상품” 메시지 반환<br> • 외부 전송 실패 → 주문은 저장되지만 상태를 “보류(PENDING)”로 표시 |
+| **기본 시나리오** | 1) 사용자가 여러 상품을 선택해 주문을 요청한다.<br> 2) 시스템은 주문할 상품의 재고가 충분한지 확인한다.<br> 3) 시스템은 사용자의 포인트 잔액이 주문 금액보다 충분한지 확인한다.<br> 4) 모든 검증이 통과되면 주문 정보를 생성하고 재고를 차감한다.<br> 5) 주문 금액만큼 포인트를 차감하고 주문 상태를 "완료"로 변경한다.<br> 6) 주문 생성 결과(주문번호, 결제금액, 잔여 포인트)를 사용자에게 반환한다. |
+| **예외 시나리오** | • 포인트 부족 → "결제 실패" 응답 및 포인트 충전 안내 메시지 표시<br> • 재고 부족 → "주문 불가 상품" 메시지 반환 및 재고 부족 안내<br> • 시스템 오류 → 주문은 저장되지만 상태를 "처리 중"으로 표시하여 나중에 재처리 가능 |
 | **후조건** | 포인트와 재고가 차감되고, 주문 내역이 생성된다. |
-| **비고** | UC-04-1~3을 포함하며, 트랜잭션으로 처리해야 함. |
+| **비고** | UC-04-1~3을 포함하며, 모든 단계가 성공해야 주문이 완료되고, 중간에 실패하면 모든 변경사항이 취소됨. |
 
 #### UC-04-1 포인트 결제 처리
 | 항목 | 내용 |
 |------|------|
 | **액터** | System |
 | **기능 요약** | 사용자의 포인트 잔액을 검증 후 주문 금액만큼 차감한다. |
-| **예외 시나리오** | 잔액 부족 시 “Insufficient Points” 오류 반환 |
+| **예외 시나리오** | 포인트 잔액이 주문 금액보다 부족한 경우 "포인트 부족" 오류 메시지 반환 |
 | **후조건** | 사용자의 포인트 잔액이 감소한다. |
 
 #### UC-04-2 재고 차감 처리
@@ -104,8 +104,8 @@
 |------|------|
 | **액터** | System / External Service |
 | **기능 요약** | 생성된 주문을 외부 시스템으로 전송한다. |
-| **예외 시나리오** | 외부 API 응답 지연 시 재시도 또는 비동기 큐에 저장 |
-| **후조건** | 주문 상태가 “전송 완료”로 변경된다. |
+| **예외 시나리오** | 외부 시스템 응답 지연 또는 오류 시 자동 재시도 또는 나중에 처리할 수 있도록 대기 목록에 저장 |
+| **후조건** | 주문 정보가 외부 시스템에 전송되고 주문 상태가 "전송 완료"로 변경된다. |
 
 ---
 
@@ -126,7 +126,7 @@
 | **액터** | User |
 | **사전조건** | 주문이 존재하고 해당 사용자의 주문이어야 함 |
 | **기본 시나리오** | 1) 사용자는 목록이나 링크를 통해 상세 페이지에 접근할 수 있다.<br> 2) 시스템은 해당 주문의 상세 정보(주문번호, 상품 목록, 총액, 상태 등)를 반환한다. |
-| **예외 시나리오** | • 주문이 존재하지 않을 경우 404 반환<br> • 다른 사용자의 주문을 조회하려는 경우 403 반환<br> • 주문 ID 형식이 잘못된 경우 400 반환 |
+| **예외 시나리오** | • 주문이 존재하지 않을 경우 "주문을 찾을 수 없습니다" 메시지 반환<br> • 다른 사용자의 주문을 조회하려는 경우 "접근할 수 없는 주문입니다" 메시지 반환<br> • 주문 ID 형식이 잘못된 경우 "잘못된 요청입니다" 메시지 반환 |
 | **후조건** | 주문 상세 정보가 화면에 표시된다. |
 
 ---
@@ -134,9 +134,9 @@
 ## ⚙️ 5. 비기능 요구사항
 | 항목 | 내용 |
 |------|------|
-| **식별 방식** | 모든 API는 `X-USER-ID` 헤더로 사용자 식별 |
-| **동시성 제어** | 포인트 차감 및 재고 차감은 트랜잭션 단위로 동작 |
-| **멱등성 보장** | 좋아요, 주문 요청은 멱등하게 처리되어야 함 |
-| **성능 요구사항** | 상품 목록 조회는 페이지네이션(page/size) 적용 |
-| **확장성 고려** | 좋아요 데이터를 추천/랭킹 기능으로 확장 가능 |
-| **일관성 보장** | 외부 시스템 연동 실패 시 재시도 로직 또는 보류 상태 유지 |
+| **식별 방식** | 모든 요청은 사용자 ID를 통해 사용자를 식별 |
+| **데이터 일관성** | 주문 처리 시 포인트 차감과 재고 차감은 함께 성공하거나 함께 취소됨 (부분 성공 방지) |
+| **중복 방지** | 좋아요, 주문 요청은 동일한 요청을 여러 번 보내도 같은 결과가 나오도록 처리 |
+| **성능 요구사항** | 상품 목록 조회 시 한 번에 보여줄 수 있는 상품 수를 제한하여 빠른 응답 제공 |
+| **확장성 고려** | 좋아요 데이터를 활용하여 상품 추천이나 인기 랭킹 기능으로 확장 가능 |
+| **오류 처리** | 외부 시스템 연동 실패 시 자동 재시도 또는 나중에 처리할 수 있도록 보류 상태로 관리 |
diff --git a/.docs/design/02-sequence-diagrams.md b/.docs/design/02-sequence-diagrams.md
index ebc9b746d..d829685a5 100644
--- a/.docs/design/02-sequence-diagrams.md
+++ b/.docs/design/02-sequence-diagrams.md
@@ -5,159 +5,170 @@
 ---
 
 ## 🎯 개요
-이 문서는 **UC-03 (좋아요)** 와 **UC-04 (주문)** 의 핵심 시나리오를 시퀀스 다이어그램으로 시각화한다.  
-핵심 비즈니스 로직 흐름만 표현하여 가독성을 높였다.
+이 문서는 **UC-03 (좋아요)** 와 **UC-04 (주문)** 의 핵심 시나리오를 비즈니스 관점에서 시각화한다.  
+기술적인 세부사항보다는 **사용자와 시스템 간의 상호작용 흐름**을 중심으로 설명하여, 비개발자도 쉽게 이해할 수 있도록 작성되었다.
 
 ---
 
 ## ❤️ UC-03 상품 좋아요 등록/취소
 
-> **멱등성 보장**: 이미 좋아요한 상태에서 다시 좋아요 요청 시, 추가 작업 없이 현재 상태(`liked: true`)를 반환하여 멱등성을 보장합니다.
-
 ### 1️⃣ 좋아요 등록
 
+**시나리오**: 사용자가 상품에 좋아요를 누르는 과정
+
 ```mermaid
 sequenceDiagram
     autonumber
-    actor User
-    participant Controller
-    participant Facade
-    participant ProductDisplay
-
-    User->>Controller: POST /api/v1/like/products/{productId}
-    Controller->>Facade: likeProduct(userId, productId)
-    Facade->>ProductDisplay: findById(productId)
-    ProductDisplay-->>Facade: ProductDisplay
-    
-    alt 이미 좋아요 함
-        ProductDisplay-->>Facade: likeCount
-        Facade-->>Controller: {liked: true, likeCount}
-    else 좋아요 없음
-        Facade->>Facade: create Like(userId, productId)
-        Facade->>ProductDisplay: like(Like)
-        ProductDisplay->>ProductDisplay: likes.add(Like)
-        ProductDisplay-->>Facade: likeCount
-        Facade-->>Controller: {liked: true, likeCount}
+    actor 사용자
+    participant 웹사이트
+    participant 좋아요시스템
+    participant 상품정보
+
+    사용자->>웹사이트: 좋아요 버튼 클릭
+    웹사이트->>좋아요시스템: 좋아요 요청 전송
+    
+    alt 이미 좋아요한 상품인 경우
+        좋아요시스템->>좋아요시스템: 이미 좋아요 상태 확인
+        좋아요시스템-->>웹사이트: 좋아요 상태 유지 (변경 없음)
+    else 처음 좋아요하는 경우
+        좋아요시스템->>상품정보: 상품 존재 여부 확인
+        상품정보-->>좋아요시스템: 상품 정보 반환
+        좋아요시스템->>좋아요시스템: 좋아요 기록 저장
+        좋아요시스템->>상품정보: 좋아요 수 업데이트
+        상품정보-->>좋아요시스템: 업데이트 완료
+        좋아요시스템-->>웹사이트: 좋아요 등록 완료
     end
-    Controller-->>User: 응답
+    
+    웹사이트-->>사용자: 좋아요 상태 표시 업데이트
 ```
 
+**설명**:
+- 사용자가 좋아요 버튼을 클릭하면, 시스템은 먼저 해당 사용자가 이미 좋아요를 눌렀는지 확인합니다.
+- 이미 좋아요한 경우: 추가 작업 없이 현재 상태를 유지합니다 (중복 방지).
+- 처음 좋아요하는 경우: 좋아요 기록을 저장하고 상품의 좋아요 수를 증가시킵니다.
+
 ### 2️⃣ 좋아요 취소
 
-> **멱등성 보장**: 좋아요하지 않은 상태에서 취소 요청 시, 추가 작업 없이 현재 상태(`liked: false`)를 반환하여 멱등성을 보장합니다.
+**시나리오**: 사용자가 좋아요를 취소하는 과정
 
 ```mermaid
 sequenceDiagram
     autonumber
-    actor User
-    participant Controller
-    participant Facade
-    participant ProductDisplay
-
-    User->>Controller: DELETE /api/v1/like/products/{productId}
-    Controller->>Facade: unlikeProduct(userId, productId)
-    Facade->>ProductDisplay: findById(productId)
-    ProductDisplay-->>Facade: ProductDisplay
-    
-    alt 좋아요 없음
-        ProductDisplay-->>Facade: likeCount
-        Facade-->>Controller: {liked: false, likeCount}
-    else 이미 좋아요 함
-        Facade->>Facade: find Like(userId, productId)
-        Facade->>ProductDisplay: unlike(Like)
-        ProductDisplay->>ProductDisplay: likes.remove(Like)
-        ProductDisplay-->>Facade: likeCount
-        Facade-->>Controller: {liked: false, likeCount}
+    actor 사용자
+    participant 웹사이트
+    participant 좋아요시스템
+    participant 상품정보
+
+    사용자->>웹사이트: 좋아요 취소 버튼 클릭
+    웹사이트->>좋아요시스템: 좋아요 취소 요청 전송
+    
+    alt 좋아요하지 않은 상품인 경우
+        좋아요시스템->>좋아요시스템: 좋아요 기록 없음 확인
+        좋아요시스템-->>웹사이트: 좋아요 없음 상태 유지 (변경 없음)
+    else 이미 좋아요한 경우
+        좋아요시스템->>상품정보: 상품 존재 여부 확인
+        상품정보-->>좋아요시스템: 상품 정보 반환
+        좋아요시스템->>좋아요시스템: 좋아요 기록 삭제
+        좋아요시스템->>상품정보: 좋아요 수 감소
+        상품정보-->>좋아요시스템: 업데이트 완료
+        좋아요시스템-->>웹사이트: 좋아요 취소 완료
     end
-    Controller-->>User: 응답
+    
+    웹사이트-->>사용자: 좋아요 상태 표시 업데이트
 ```
 
+**설명**:
+- 사용자가 좋아요 취소 버튼을 클릭하면, 시스템은 해당 사용자가 좋아요를 눌렀는지 확인합니다.
+- 좋아요하지 않은 경우: 추가 작업 없이 현재 상태를 유지합니다 (중복 방지).
+- 좋아요한 경우: 좋아요 기록을 삭제하고 상품의 좋아요 수를 감소시킵니다.
+
 ---
 
 ## 🛒 UC-04 주문 생성
 
-### 1️⃣ 주문 생성 기본 흐름
+### 1️⃣ 주문 생성 기본 흐름 (성공 케이스)
+
+**시나리오**: 사용자가 여러 상품을 선택하여 주문을 생성하는 과정
 
 ```mermaid
 sequenceDiagram
     autonumber
-    actor User
-    participant Controller
-    participant Facade
-    participant ProductOrder
-    participant Point
-    participant Order
-
-    User->>Controller: POST /api/v1/orders
-    Controller->>Facade: createOrder(userId, items, totalAmount)
+    actor 사용자
+    participant 쇼핑몰
+    participant 주문시스템
+    participant 상품재고
+    participant 포인트계정
+    participant 주문내역
+
+    사용자->>쇼핑몰: 주문하기 버튼 클릭
+    쇼핑몰->>주문시스템: 주문 요청 전송 (상품 목록, 수량)
     
-    Note over Facade: 트랜잭션 시작
+    Note over 주문시스템: 주문 처리 시작 (모든 작업이 성공하거나 모두 취소됨)
     
-    Facade->>ProductOrder: checkStock(quantity)
-    ProductOrder-->>Facade: stock available
+    주문시스템->>상품재고: 주문할 상품의 재고 확인
+    상품재고-->>주문시스템: 재고 충분함
     
-    Facade->>Point: checkBalance(totalAmount)
-    Point-->>Facade: balance sufficient
+    주문시스템->>주문시스템: 주문 총액 계산
     
-    Facade->>ProductOrder: decreaseStock(quantity)
-    ProductOrder-->>Facade: success
+    주문시스템->>포인트계정: 포인트 잔액 확인
+    포인트계정-->>주문시스템: 포인트 충분함
     
-    Facade->>Point: deduct(totalAmount)
-    Point-->>Facade: success
+    주문시스템->>상품재고: 주문 수량만큼 재고 차감
+    상품재고-->>주문시스템: 재고 차감 완료
     
-    Facade->>Order: create(List~ProductOrder~, Point)
-    Order-->>Facade: Order (status: PENDING)
+    주문시스템->>포인트계정: 주문 금액만큼 포인트 차감
+    포인트계정-->>주문시스템: 포인트 차감 완료
     
-    Note over Facade: 트랜잭션 커밋
-    Facade-->>Controller: Order
-    Controller-->>User: 주문 접수 완료
+    주문시스템->>주문내역: 주문 정보 저장 (상태: 완료)
+    주문내역-->>주문시스템: 주문 저장 완료
+    
+    Note over 주문시스템: 모든 작업 성공적으로 완료
+    
+    주문시스템-->>쇼핑몰: 주문 완료 (주문번호, 결제금액, 잔여 포인트)
+    쇼핑몰-->>사용자: 주문 완료 화면 표시
 ```
 
-### 2️⃣ 외부 전송 성공
+**설명**:
+1. 사용자가 주문하기 버튼을 클릭하면, 주문 시스템이 주문 처리를 시작합니다.
+2. **재고 확인**: 주문할 상품의 재고가 충분한지 확인합니다.
+3. **포인트 확인**: 사용자의 포인트 잔액이 주문 금액보다 충분한지 확인합니다.
+4. **재고 차감**: 재고가 충분하면 주문 수량만큼 재고를 차감합니다.
+5. **포인트 차감**: 포인트가 충분하면 주문 금액만큼 포인트를 차감합니다.
+6. **주문 저장**: 주문 정보를 저장하고 상태를 "완료"로 설정합니다.
+7. **결과 반환**: 주문 번호, 결제 금액, 잔여 포인트를 사용자에게 반환합니다.
 
-```mermaid
-sequenceDiagram
-    autonumber
-    participant Facade
-    participant ExternalService
-    participant Order
+**중요**: 모든 단계가 성공해야 주문이 완료됩니다. 중간에 실패하면 모든 변경사항이 취소됩니다 (예: 재고 차감 후 포인트 부족 시 재고도 원복).
 
-    Note over Facade: 주문 생성 완료
-    
-    Facade->>ExternalService: sendOrder(order)
-    ExternalService-->>Facade: success
-    
-    Facade->>Order: complete()
-    Order->>Order: status = COMPLETED
-    
-    Facade-->>User: 주문 완료
-```
+### 2️⃣ 주문 실패 시나리오
 
-### 3️⃣ 외부 전송 실패 (PENDING 상태 유지)
+**시나리오**: 재고 부족 또는 포인트 부족으로 주문이 실패하는 경우
 
 ```mermaid
 sequenceDiagram
     autonumber
-    participant Facade
-    participant ExternalService
-    participant Order
-
-    Note over Facade: 주문 생성 완료
-    
-    Facade->>ExternalService: sendOrder(order)
-    ExternalService-->>Facade: error
-    
-    Note over Facade: 주문은 저장되지만 PENDING 상태 유지
-    
-    Facade->>Order: 상태 유지
-    Order->>Order: status = PENDING
-    
-    Note over Facade: 재시도 로직 또는 비동기 큐에 저장
-    
-    Facade-->>User: 주문 접수 완료 (외부 전송 보류)
+    actor 사용자
+    participant 쇼핑몰
+    participant 주문시스템
+    participant 상품재고
+    participant 포인트계정
+
+    사용자->>쇼핑몰: 주문하기 버튼 클릭
+    쇼핑몰->>주문시스템: 주문 요청 전송
+    
+    alt 재고 부족인 경우
+        주문시스템->>상품재고: 주문할 상품의 재고 확인
+        상품재고-->>주문시스템: 재고 부족 (요청 수량 > 현재 재고)
+        주문시스템-->>쇼핑몰: 주문 실패 (재고 부족)
+        쇼핑몰-->>사용자: 재고 부족 안내 메시지 표시
+    else 포인트 부족인 경우
+        주문시스템->>상품재고: 재고 확인 (성공)
+        주문시스템->>포인트계정: 포인트 잔액 확인
+        포인트계정-->>주문시스템: 포인트 부족 (주문 금액 > 잔액)
+        주문시스템-->>쇼핑몰: 주문 실패 (포인트 부족)
+        쇼핑몰-->>사용자: 포인트 부족 안내 및 충전 안내 메시지 표시
+    end
 ```
 
-### 💬 예외 시나리오
-- **포인트 부족**: Point.deduct()에서 예외 발생 → 트랜잭션 롤백
-- **재고 부족**: ProductOrder.decreaseStock()에서 예외 발생 → 트랜잭션 롤백
-- **외부 전송 실패**: 주문은 저장되지만 상태를 PENDING으로 유지하여 재시도 가능
+**설명**:
+- **재고 부족**: 주문하려는 상품의 재고가 부족하면 주문이 실패합니다. 이 경우 아무것도 차감되지 않습니다.
+- **포인트 부족**: 포인트 잔액이 주문 금액보다 부족하면 주문이 실패합니다. 재고는 이미 확인했지만, 포인트 부족으로 인해 주문이 취소되므로 재고도 차감되지 않습니다.
diff --git a/.docs/design/03-class-diagram.md b/.docs/design/03-class-diagram.md
index 2eaeac428..a087ad43e 100644
--- a/.docs/design/03-class-diagram.md
+++ b/.docs/design/03-class-diagram.md
@@ -5,60 +5,162 @@
 
 ## 🎯 개요
 본 문서는 도메인별 행위에 맞춰 설계된 클래스 다이어그램을 정의한다.  
+각 도메인을 독립적인 Aggregate로 분리하여 일관성 경계를 명확히 한다.
 
 ### 설계 원칙
 - **도메인 중심 설계 (DDD)**: 각 도메인이 자신의 책임과 행위를 명확히 가진다
-- **도메인 분리**: 동일한 테이블이라도 행위와 책임이 다르면 별도 도메인으로 분리한다
-  - 예: `product` 테이블이 하나라도 `ProductDisplay`(표시/조회)와 `ProductOrder`(주문)로 분리
+- **Aggregate 분리**: 각 도메인을 독립적인 Aggregate로 분리하여 일관성 경계를 명확히 한다
+  - 예: User, Product, Order, Like, Brand를 각각 독립적인 Aggregate로 구성
 - **행위 중심**: 데이터 구조가 아닌 도메인의 행위와 책임을 우선한다
+- **Value Object 활용**: 불변 값 객체(OrderItem, Point)를 활용하여 도메인 로직을 캡슐화한다
 
 ---
 
-## 🧩 ProductDisplay 도메인 클래스 구조
+## 📦 Aggregate 구분 다이어그램 (전체 개요)
+
+```mermaid
+graph TB
+    subgraph "User Aggregate"
+        User[User<br/>Aggregate Root]
+        Point[Point<br/>Value Object]
+        User -->|embeds| Point
+    end
+
+    subgraph "Product Aggregate"
+        Product[Product<br/>Aggregate Root]
+    end
+
+    subgraph "Order Aggregate"
+        Order[Order<br/>Aggregate Root]
+        OrderItem[OrderItem<br/>Value Object]
+        OrderStatus[OrderStatus<br/>Enum]
+        Order -->|contains| OrderItem
+        Order -->|uses| OrderStatus
+    end
+
+    subgraph "Brand Aggregate"
+        Brand[Brand<br/>Aggregate Root]
+    end
+
+    subgraph "Like Aggregate"
+        Like[Like<br/>Aggregate Root]
+    end
+
+    Order -.->|references by ID| User
+    Order -.->|references by ID| Product
+    Product -.->|references by ID| Brand
+    Like -.->|references by ID| User
+    Like -.->|references by ID| Product
+
+    style User fill:#e1f5ff
+    style Product fill:#e1f5ff
+    style Order fill:#e1f5ff
+    style Brand fill:#e1f5ff
+    style Like fill:#e1f5ff
+    style Point fill:#fff4e1
+    style OrderItem fill:#fff4e1
+    style OrderStatus fill:#f0f0f0
+```
+
+**Aggregate 경계 설명**:
+- 각 Aggregate는 독립적인 일관성 경계를 가집니다.
+- Aggregate 간 참조는 ID를 통해서만 이루어집니다 (직접 참조 금지).
+- 하나의 트랜잭션은 하나의 Aggregate만 수정해야 합니다.
+- 여러 Aggregate 간 협력이 필요한 경우 Application Service (Facade)에서 조율합니다.
+
+---
+
+## 👤 User Aggregate
+
+### 클래스 다이어그램
 
 ```mermaid
 classDiagram
 
-    %% 엔티티 (BaseEntity 상속)
-    class Brand {
+    class User {
         +Long id
-        +String name
+        +String userId
+        +String email
+        +LocalDate birthDate
+        +Gender gender
+        +Point point
+        +receivePoint(Point)
+        +deductPoint(Point)
+    }
+
+    class Point {
+        +Long value
+        +add(Point) Point
+        +subtract(Point) Point
+    }
+
+    class Gender {
+        <<enumeration>>
+        MALE
+        FEMALE
     }
 
-    class ProductDisplay {
+    User "1" *-- "1" Point : embeds
+    User "1" --> "1" Gender : uses
+```
+
+### 클래스 설명
+
+| 클래스 | 타입 | 책임 |
+|---------|------|------|
+| **User** | Aggregate Root | 사용자 정보 관리 및 포인트 차감/충전 처리 |
+| **Point** | Value Object | 포인트 값과 연산 로직을 캡슐화 (User에 Embedded) |
+| **Gender** | Enum | 사용자 성별 정보 |
+
+### 주요 특징
+- `Point`는 `User`에 Embedded된 Value Object로, User의 생명주기와 함께 관리됩니다.
+- User는 자신의 포인트를 직접 관리합니다 (`receivePoint()`, `deductPoint()`).
+- User 생성 시 Point가 함께 초기화됩니다.
+
+---
+
+## 🛍 Product Aggregate
+
+### 클래스 다이어그램
+
+```mermaid
+classDiagram
+
+    class Product {
         +Long id
         +String name
-        +int price
-        +Brand brand
-        +Set~Like~ likes
-        +like(Like)
-        +unlike(Like)
-        +getLikeCount() int
+        +Integer price
+        +Integer stock
+        +Long brandId
+        +decreaseStock(quantity)
+        +increaseStock(quantity)
     }
 
-    class Like {
+    class Brand {
         +Long id
-        +Long userId
-        +Long productId
+        +String name
     }
 
-    %% 관계 설정
-    Brand "1" --> "*" ProductDisplay : has
-    ProductDisplay "1" --> "*" Like : has
+    Product "*" ..> "1" Brand : references by ID
 ```
 
----
+### 클래스 설명
+
+| 클래스 | 타입 | 책임 |
+|---------|------|------|
+| **Product** | Aggregate Root | 상품 정보 및 재고 관리 (재고 차감/증가 처리) |
+| **Brand** | 외부 Aggregate | 브랜드 정보 (Product가 ID로만 참조) |
 
-## 📦 클래스 간 역할 설명
+### 주요 특징
+- Product는 Brand를 ID로만 참조하며, Brand는 독립적인 Aggregate입니다.
+- 재고 관리 로직을 Product 내부에서 처리합니다 (`decreaseStock()`, `increaseStock()`).
+- 주문 처리 시 재고 차감/증가가 발생합니다.
 
-| 클래스 | 책임 | 도메인 분리 이유 |
-|---------|------|-----------------|
-| **Brand** | 브랜드 메타 정보 보유 | 브랜드 정보는 표시와 주문 모두에서 공통으로 사용되지만, 독립적인 도메인으로 관리 |
-| **ProductDisplay** | 상품 표시/조회 관련 행위 관리 | 상품의 **표시와 조회**에 집중 (좋아요, 정렬, 목록 조회) |
-| **Like** | 상품을 좋아요 표시한 사용자 정보 보유 | 행위의 주체는 상품이지만, 정보를 가지는 주체로서 도메인 분리 |
 ---
 
-## 🧩 Order 도메인 클래스 구조
+## 📦 Order Aggregate
+
+### 클래스 다이어그램
 
 ```mermaid
 classDiagram
@@ -67,29 +169,17 @@ classDiagram
         +Long id
         +Long userId
         +OrderStatus status
-        +int totalAmount
-        +JSON items
-        +getTotalAmount()
-        +create(List~ProductOrder~, Point)
+        +Integer totalAmount
+        +List~OrderItem~ items
         +complete()
-        +cancel(Point)
+        +cancel()
     }
 
-    class ProductOrder {
-        +Long id
+    class OrderItem {
+        +Long productId
         +String name
-        +int price
-        +int stock
-        +decreaseStock(quantity)
-        +increaseStock(quantity)
-    }
-
-    class Point {
-        +Long id
-        +User user
-        +Long balance
-        +deduct(amount)
-        +refund(amount)
+        +Integer price
+        +Integer quantity
     }
 
     class OrderStatus {
@@ -99,25 +189,160 @@ classDiagram
         CANCELED
     }
 
-    Order "1" --> "many" ProductOrder : contains
-    Order --> Point : usesForPayment
+    class User {
+        +Long id
+        +String userId
+    }
+
+    class Product {
+        +Long id
+        +String name
+    }
+
+    Order "1" --> "*" OrderItem : contains
+    Order "1" --> "1" OrderStatus : uses
+    Order "1" ..> "1" User : references by ID
+    OrderItem "1" ..> "1" Product : references by ID
+```
+
+### 클래스 설명
+
+| 클래스 | 타입 | 책임 |
+|---------|------|------|
+| **Order** | Aggregate Root | 주문의 상태, 총액, 주문 아이템 관리 및 상태 전이 처리 |
+| **OrderItem** | Value Object | 주문 시점의 상품 정보 스냅샷 (JSON으로 저장) |
+| **OrderStatus** | Enum | 주문의 생명주기 상태 표현 |
+| **User** | 외부 Aggregate | 주문자 정보 (Order가 ID로만 참조) |
+| **Product** | 외부 Aggregate | 주문된 상품 정보 (OrderItem이 ID로만 참조) |
+
+### 주요 특징
+- `OrderItem`은 JSON으로 저장되는 Value Object입니다.
+- Order는 User ID와 Product ID를 참조하지만, 실제 Entity를 참조하지 않습니다.
+- Order 상태 전이는 Order 내부에서 관리됩니다 (`complete()`, `cancel()`).
+- 총액 계산은 Order 생성 시 자동으로 수행됩니다.
+
+### 상태 전이 다이어그램
+
+```mermaid
+stateDiagram-v2
+    [*] --> PENDING: 주문 생성
+    
+    PENDING --> COMPLETED: 주문 완료\n(포인트 차감, 재고 차감 완료)
+    PENDING --> CANCELED: 주문 취소\n(포인트 환불, 재고 복구)
+    
+    COMPLETED --> [*]
+    CANCELED --> [*]
+    
+    note right of PENDING
+        초기 상태
+        주문 생성 직후 상태
+    end note
+    
+    note right of COMPLETED
+        최종 완료 상태
+        모든 처리가 완료된 상태
+    end note
+    
+    note right of CANCELED
+        취소 상태
+        PENDING 상태에서만 취소 가능
+    end note
 ```
 
 ---
 
-## 📦 Order 도메인 클래스 간 역할 설명
+## 🏷 Brand Aggregate
+
+### 클래스 다이어그램
+
+```mermaid
+classDiagram
+
+    class Brand {
+        +Long id
+        +String name
+        +String description
+    }
+```
+
+### 클래스 설명
+
+| 클래스 | 타입 | 책임 |
+|---------|------|------|
+| **Brand** | Aggregate Root | 브랜드 정보 관리 |
+
+### 주요 특징
+- Brand는 독립적인 Aggregate입니다.
+- Product가 Brand를 참조하지만, Brand는 Product를 알지 못합니다.
+- 단순한 정보 관리만 수행합니다.
+
+---
+
+## ❤️ Like Aggregate
+
+### 클래스 다이어그램
+
+```mermaid
+classDiagram
+
+    class Like {
+        +Long id
+        +Long userId
+        +Long productId
+    }
+
+    class LikeRepository {
+        <<interface>>
+        +save(Like)
+        +findByUserIdAndProductId(userId, productId)
+        +delete(Like)
+        +findAllByUserId(userId)
+        +countByProductIds(productIds)
+    }
+
+    class LikeFacade {
+        +addLike(userId, productId)
+        +removeLike(userId, productId)
+        +getLikedProducts(userId)
+    }
+
+    class User {
+        +Long id
+        +String userId
+    }
 
-| 클래스 | 책임 | 도메인 분리 이유 |
-|---------|------|-----------------|
-| **Order** | 주문의 상태, 총액, 주문 아이템 관리 | 주문 생성, 완료, 취소 등 주문 생명주기 관리. `items`는 `{productId, name, price, quantity}`로 관리 |
-| **ProductOrder** | 주문 시 상품 정보 및 재고 관리 | 주문 시점의 상품 정보 보관 및 주문 처리 중 재고 차감/복구 관리 (ProductDisplay와 분리) |
-| **Point** | 포인트 잔액 관리 및 결제 처리 | 주문 시 포인트 차감, 취소 시 환불 처리 |
-| **User** | 주문자 정보 | 주문과 사용자의 관계 표현 |
-| **OrderStatus** | 주문 상태 관리 | 주문의 생명주기 상태 표현 |
+    class Product {
+        +Long id
+        +String name
+    }
+
+    LikeFacade --> LikeRepository : uses
+    LikeFacade --> User : references
+    LikeFacade --> Product : references
+    LikeRepository ..> Like : manages
+    Like "1" ..> "1" User : references by ID
+    Like "1" ..> "1" Product : references by ID
+```
+
+### 클래스 설명
+
+| 클래스 | 타입 | 책임 |
+|---------|------|------|
+| **Like** | Aggregate Root | 사용자와 상품 간의 좋아요 관계를 나타내는 엔티티 |
+| **LikeRepository** | Repository Interface | 좋아요 정보의 저장, 조회, 삭제를 담당하는 저장소 인터페이스 |
+| **LikeFacade** | Application Service | 좋아요 추가, 삭제, 목록 조회를 처리하는 애플리케이션 서비스 |
+| **User** | 외부 Aggregate | 좋아요를 누른 사용자 정보 (Like가 ID로만 참조) |
+| **Product** | 외부 Aggregate | 좋아요 대상이 되는 상품 정보 (Like가 ID로만 참조) |
+
+### 주요 특징
+- Like는 User와 Product 간의 관계를 나타내는 독립적인 Aggregate입니다.
+- User ID와 Product ID만 참조하며, 실제 Entity를 참조하지 않습니다.
+- 좋아요 관계의 생명주기를 독립적으로 관리합니다.
+- LikeFacade에서 중복 좋아요/취소 요청 시 현재 상태를 반환하여 멱등성을 보장합니다.
 
 ---
 
-## 🧭 상태 및 상수 클래스
+## 🧭 상수 및 Enum 클래스
 
 ```mermaid
 classDiagram
@@ -134,31 +359,49 @@ classDiagram
         COMPLETED
         CANCELED
     }
+
+    class Gender {
+        <<enumeration>>
+        MALE
+        FEMALE
+    }
 ```
 
+### Enum 설명
+
+| Enum | 사용 위치 | 설명 |
+|------|-----------|------|
+| **SortType** | 상품 목록 조회 | 상품 정렬 기준 (최신순, 가격 오름차순, 좋아요 내림차순) |
+| **OrderStatus** | Order Aggregate | 주문 상태 (대기, 완료, 취소) |
+| **Gender** | User Aggregate | 사용자 성별 (남성, 여성) |
+
 ---
 
 ## 🔁 설계 의도 요약
 
 | 설계 포인트 | 선택 근거 |
 |--------------|-------------|
-| **도메인 중심 (DDD)** | Entity가 스스로 상태를 관리하도록 설계 (ex. ProductDisplay.likeBy(), ProductOrder.increaseStock(), Order.complete()) |
-| **도메인 분리** | 동일 테이블이라도 행위와 책임이 다르면 별도 도메인으로 분리. ProductOrder는 주문 처리에 필요한 상품 정보와 재고 관리를 담당하며, ProductDisplay와 분리하여 주문 로직의 독립성 보장 |
-| **멱등성 보장** | ProductDisplay의 likedUserIds를 Set으로 관리하여 중복 방지, Order 상태 전이는 멱등하게 처리 |
+| **도메인 중심 (DDD)** | Entity가 스스로 상태를 관리하도록 설계 (ex. Product.decreaseStock(), Order.complete(), User.deductPoint()) |
+| **Aggregate 분리** | User, Product, Order, Like, Brand를 독립적인 Aggregate로 분리하여 각각의 일관성 경계를 명확히 함 |
+| **멱등성 보장** | LikeFacade에서 중복 좋아요/취소 요청 시 현재 상태를 반환하여 멱등성 보장 |
 | **Enum 사용** | SortType, OrderStatus 등 도메인별 상수는 Enum으로 명확히 정의 |
+| **Value Object 활용** | OrderItem, Point를 Value Object로 설계하여 불변성과 도메인 로직 캡슐화 |
+| **ID 참조 원칙** | Aggregate 간 참조는 ID를 통해서만 이루어지며, 직접 Entity 참조를 금지하여 결합도 감소 |
 
 ---
 
-## 💡 도메인 분리 상세 설명
+## 📝 Aggregate 간 협력
+
+여러 Aggregate 간 협력이 필요한 경우 Application Service (Facade)에서 조율합니다.
+
+### 예시: 주문 생성 시 협력
+
+- **PurchasingFacade**: Order, User, Product Aggregate 간의 협력을 조정
+  - 주문 완료: Order.complete() + User.deductPoint() + Product.decreaseStock()
+  - 주문 취소: Order.cancel() + User.receivePoint() + Product.increaseStock()
 
-### ProductDisplay vs ProductOrder
-동일한 `product` 테이블을 사용하더라도, 행위와 책임에 따라 별도 도메인으로 분리:
+### 예시: 좋아요 처리 시 협력
 
-| 구분 | ProductDisplay | ProductOrder |
-|------|----------------|--------------|
-| **책임** | 상품 표시, 조회, 좋아요 | 주문 처리 시 상품 정보 및 재고 관리 |
-| **주요 행위** | `like()`, `unlike()` | `decreaseStock()`, `increaseStock()` (주문 처리 중 재고 관리) |
-| **관심사** | 사용자에게 상품을 보여주는 것 | 주문 생성/취소 시 상품 정보와 재고를 처리하는 것 |
-| **변경 빈도** | 상품 정보, 좋아요 수 | 주문 생성/취소 시 재고 변경 |
-| **데이터 특성** | 실시간 상품 정보 (조회용) | 주문 처리 중 상품 정보 및 재고 상태 (주문용) |
-| **생명주기** | 상품이 존재하는 동안 지속 | 주문 생성 시 생성, 주문 완료/취소 시 처리 |
+- **LikeFacade**: Like, User, Product Aggregate 간의 협력을 조정
+  - 좋아요 추가: Like 생성 + User/Product 존재 확인
+  - 좋아요 취소: Like 삭제 + User/Product 존재 확인
diff --git a/.docs/design/04-erd.md b/.docs/design/04-erd.md
index cf43fdfb8..e3b7b6b05 100644
--- a/.docs/design/04-erd.md
+++ b/.docs/design/04-erd.md
@@ -62,18 +62,8 @@ erDiagram
         datetime deleted_at
     }
 
-    POINT {
-        bigint id PK
-        bigint ref_user_id FK
-        bigint balance
-        datetime created_at
-        datetime updated_at
-        datetime deleted_at
-    }
-
     USERS ||--o{ LIKES : "좋아요"
     USERS ||--o{ ORDERS : "주문"
-    USERS ||--o{ POINT : "포인트"
 
     BRANDS ||--o{ PRODUCTS : "브랜드 상품"
     PRODUCTS ||--o{ LIKES : "좋아요 대상"
@@ -84,7 +74,18 @@ erDiagram
 ## ⚙️ 제약조건
 | 테이블 | 제약조건 | 설명 |
 |---------|-----------|------|
-| **LIKES** | (user_id, product_id) UNIQUE | 동일 사용자-상품 중복 방지 |
+| **LIKES** | (ref_user_id, ref_product_id) UNIQUE | 동일 사용자-상품 중복 방지 |
 | **ORDERS** | status IN ('PENDING', 'COMPLETED', 'CANCELED') | 주문 상태는 OrderStatus enum 값만 허용 |
 | **ORDERS** | items JSON 형식: [{productId, name, price, quantity}] | 주문 아이템은 JSON 배열로 저장 |
+| **USERS** | user_id UNIQUE | 사용자 ID는 고유해야 함 |
+| **PRODUCTS** | stock >= 0 | 재고는 0 이상이어야 함 |
+| **PRODUCTS** | price >= 0 | 가격은 0 이상이어야 함 |
+
+---
+
+## 📝 데이터베이스 설계 참고사항
+
+### Aggregate 경계
+- 각 테이블은 하나의 Aggregate Root에 해당합니다.
+- Aggregate 간 참조는 외래키를 통한 ID 참조로만 이루어집니다.
 ---
