From a43ba604bbff436da0a82c6d636398b76eed3ca4 Mon Sep 17 00:00:00 2001
From: Yim Yoobin <yoobinbkk@naver.com>
Date: Fri, 31 Oct 2025 12:42:25 +0900
Subject: [PATCH 01/19] =?UTF-8?q?Round=201=20:=20TDD=20=EC=8B=A4=EC=8A=B5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .codeguide/loopers-1-week.md                  |  21 ++
 .../loopers/application/user/UserFacade.java  |  56 ++++
 .../loopers/application/user/UserInfo.java    |  42 +++
 .../java/com/loopers/domain/user/User.java    |  73 +++++
 .../loopers/domain/user/UserRepository.java   |   8 +
 .../com/loopers/domain/user/UserService.java  |  21 ++
 .../user/UserJpaRepository.java               |  10 +
 .../user/UserRepositoryImpl.java              |  25 ++
 .../interfaces/api/user/UserApiSpec.java      |  52 +++
 .../interfaces/api/user/UserController.java   |  53 +++
 .../loopers/interfaces/api/user/UserDto.java  |  36 +++
 .../com/loopers/support/config/WebConfig.java |  19 ++
 .../support/error/CoreExceptionUtil.java      |  33 ++
 .../interceptor/AccessControlInterceptor.java |  31 ++
 .../com/loopers/domain/user/UserE2ETest.java  | 198 ++++++++++++
 .../domain/user/UserIntegrationTest.java      | 164 ++++++++++
 .../com/loopers/domain/user/UserTest.java     | 304 ++++++++++++++++++
 17 files changed, 1146 insertions(+)
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/User.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserApiSpec.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserDto.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/support/config/WebConfig.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/support/error/CoreExceptionUtil.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/support/interceptor/AccessControlInterceptor.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/UserE2ETest.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/UserIntegrationTest.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java

diff --git a/.codeguide/loopers-1-week.md b/.codeguide/loopers-1-week.md
index a8ace53e5..5eb0868b9 100644
--- a/.codeguide/loopers-1-week.md
+++ b/.codeguide/loopers-1-week.md
@@ -43,3 +43,24 @@
 
 - [ ]  포인트 조회에 성공할 경우, 보유 포인트를 응답으로 반환한다.
 - [ ]  `X-USER-ID` 헤더가 없을 경우, `400 Bad Request` 응답을 반환한다.
+
+### 포인트 충전
+
+**🧱 단위 테스트**
+
+- [ ]  0 이하의 정수로 포인트를 충전 시 실패한다.
+
+**🔗 통합 테스트**
+
+- [ ]  존재하지 않는 유저 ID 로 충전을 시도한 경우, 실패한다.
+
+**🌐 E2E 테스트**
+
+- [ ]  존재하는 유저가 1000원을 충전할 경우, 충전된 보유 총량을 응답으로 반환한다.
+- [ ]  존재하지 않는 유저로 요청할 경우, `404 Not Found` 응답을 반환한다.
+
+## ✅ Checklist
+
+- [ ]  테스트는 모두 통과해야 하며, `@Test` 기반으로 명시적으로 작성
+- [ ]  각 테스트는 테스트 명/설명/입력/예상 결과가 분명해야 함
+- [ ]  E2E 테스트는 실제 HTTP 요청을 통해 흐름을 검증할 것
\ No newline at end of file
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java
new file mode 100644
index 000000000..79c9b59b9
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java
@@ -0,0 +1,56 @@
+package com.loopers.application.user;
+
+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 lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Optional;
+
+@RequiredArgsConstructor
+@Component
+public class UserFacade {
+    private final UserService userService;
+
+    @Transactional
+    public UserInfo saveUser(UserInfo userInfo) {
+        Optional<User> foundUser = userService.findUserById(userInfo.loginId());
+        if(foundUser.isPresent()) {
+            throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 회원을 다시 저장 못합니다.");
+        }
+
+        User user = User.builder()
+                .loginId(userInfo.loginId())
+                .email(userInfo.email())
+                .birthday(userInfo.birthday())
+                .gender(userInfo.gender())
+                .point(userInfo.point())
+                .build();
+        Optional<User> optSavedUser = userService.saveUser(user);
+        return UserInfo.from(optSavedUser);
+    }
+
+    @Transactional(readOnly = true)
+    public UserInfo getUser(String loginId){
+        Optional<User> optUser = userService.findUserById(loginId);
+        return UserInfo.from(optUser);
+    }
+
+    @Transactional(readOnly = true)
+    public Integer getUserPoint(String loginId){
+        User user = userService.findUserById(loginId)
+                .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[loginId = " + loginId + "] User를 찾을 수 없습니다."));
+        return user.getPoint();
+    }
+
+    @Transactional
+    public Integer addUserPoint(String loginId, Integer userPoint) {      User user = userService.findUserById(loginId)
+                .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[loginId = " + loginId + "] point를 충전할 User를 찾을 수 없습니다."));
+        user.addPoint(userPoint);
+        Optional<User> savedUser = userService.saveUser(user);
+        return savedUser.get().getPoint();
+    }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java
new file mode 100644
index 000000000..497cb0a59
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java
@@ -0,0 +1,42 @@
+package com.loopers.application.user;
+
+import com.loopers.domain.user.User;
+import com.loopers.interfaces.api.user.UserDto;
+import lombok.Builder;
+
+import java.util.Optional;
+
+@Builder
+public record UserInfo(
+        String loginId
+        , String email
+        , String birthday
+        , String gender
+        , Integer point
+) {
+    public static UserInfo from(Optional<User> optUser) {
+        if(optUser.isPresent()) {
+            User user = optUser.get();
+            return UserInfo.builder()
+                    .loginId(user.getLoginId())
+                    .email(user.getEmail())
+                    .birthday(user.getBirthday())
+                    .gender(user.getGender())
+                    .point(user.getPoint())
+                    .build();
+        }
+
+        return null;
+    }
+
+    public static UserInfo from(UserDto.CreateUserRequest cur) {
+        return UserInfo.builder()
+                .loginId(cur.loginId())
+                .email(cur.email())
+                .birthday(cur.birthday())
+                .gender(cur.gender())
+                .point(cur.point())
+                .build();
+    }
+
+}
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
new file mode 100644
index 000000000..1ba1edbc8
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java
@@ -0,0 +1,73 @@
+package com.loopers.domain.user;
+
+import com.loopers.domain.BaseEntity;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Table;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import static com.loopers.support.error.CoreExceptionUtil.*;
+
+@Entity
+@Table(name = "user")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Getter
+@Builder(toBuilder = true)
+public class User extends BaseEntity {
+
+    private String loginId;
+    private String email;
+    private String birthday;
+    private String gender;
+    private Integer point;
+
+    private User(
+            String loginId
+            , String email
+            , String birthday
+            , String gender
+            , Integer point
+    ) {
+        this.loginId = loginId;
+        this.email = email;
+        this.birthday = birthday;
+        this.gender = gender;
+        this.point = point;
+        this.guard();
+    }
+
+    @Override
+    protected void guard() {
+        // id : 영문, 숫자가 각각 최소 한 개씩 포함된 10자 이내 문자열 검사
+        validateNullOrBlank(loginId, "ID가 비어있을 수 없습니다.");
+        validatePattern(loginId, "^(?=.*[a-zA-Z])(?=.*[0-9])[a-zA-Z0-9]{1,10}$", "ID에 영문과 숫자만 10자 이내로 꼭 포함되어 있어야 합니다.");
+
+        // email : `xx@yy.zz` 형식에 맞는지 검사
+        validateNullOrBlank(email, "email이 비어있을 수 없습니다.");
+        validatePattern(email, "^[a-zA-Z0-9]+@[a-zA-Z0-9]+\\.[a-zA-Z]{2,6}$", "email이 `xx@yy.zz` 형식에 맞아야 합니다.");
+
+        // birthday : `yyyy-MM-dd` 형식에 맞는지 검사
+        validateNullOrBlank(birthday, "birthday가 비어있을 수 없습니다.");
+        validatePattern(birthday, "^\\d{4}-\\d{2}-\\d{2}$", "birthday가 `yyyy-MM-dd` 형식에 맞아야 합니다.");
+
+        // gender : `M` or `F` 형식에 맞는지 검사
+        validateNullOrBlank(gender, "gender가 비어있을 수 없습니다.");
+        validatePattern(gender, "^[FM]{1}$", "gender가 `M` or `F` 형식에 맞아야 합니다.");
+
+        // point : Null 이 되면 안 됩니다.
+        validateObjectNull(point, "point가 Null 이 되면 안 됩니다.");
+        if(point < 0) throw new CoreException(ErrorType.BAD_REQUEST, "point는 음수가 될 수 없습니다.");
+    }
+
+    public Integer addPoint(Integer point) {
+        // 충전하는 포인트가 음수이면 BAD REQUEST CoreException 을 발생
+        if(point < 0) throw new CoreException(ErrorType.BAD_REQUEST, "충전할 point는 음수가 될 수 없습니다.");
+        this.point += point;
+        return this.point;
+    }
+
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java
new file mode 100644
index 000000000..5a01502cb
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java
@@ -0,0 +1,8 @@
+package com.loopers.domain.user;
+
+import java.util.Optional;
+
+public interface UserRepository {
+    Optional<User> save(User user);
+    Optional<User> findByLoginId(String id);
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java
new file mode 100644
index 000000000..dfb2f6035
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java
@@ -0,0 +1,21 @@
+package com.loopers.domain.user;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import java.util.Optional;
+
+@RequiredArgsConstructor
+@Component
+public class UserService {
+
+    private final UserRepository userRepository;
+
+    public Optional<User> saveUser(User user) {
+        return userRepository.save(user);
+    }
+
+    public Optional<User> findUserById(String id) {
+        return userRepository.findByLoginId(id);
+    }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java
new file mode 100644
index 000000000..0f7f5a05b
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java
@@ -0,0 +1,10 @@
+package com.loopers.infrastructure.user;
+
+import com.loopers.domain.user.User;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.Optional;
+
+public interface UserJpaRepository extends JpaRepository<User,Long> {
+    Optional<User> findByLoginId(String loginId);
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java
new file mode 100644
index 000000000..359cc1020
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java
@@ -0,0 +1,25 @@
+package com.loopers.infrastructure.user;
+
+import com.loopers.domain.user.User;
+import com.loopers.domain.user.UserRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import java.util.Optional;
+
+@RequiredArgsConstructor
+@Component
+public class UserRepositoryImpl implements UserRepository {
+    private final UserJpaRepository userJpaRepository;
+
+    @Override
+    public Optional<User> save(User user) {
+        userJpaRepository.save(user);
+        return Optional.of(user);
+    }
+
+    @Override
+    public Optional<User> findByLoginId(String id) {
+        return userJpaRepository.findByLoginId(id);
+    }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserApiSpec.java
new file mode 100644
index 000000000..a6d789db9
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserApiSpec.java
@@ -0,0 +1,52 @@
+package com.loopers.interfaces.api.user;
+
+import com.loopers.interfaces.api.ApiResponse;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.enums.ParameterIn;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.tags.Tag;
+
+@Tag(name = "User API", description = "회원 관련 API 입니다.")
+public interface UserApiSpec {
+
+    @Operation(
+            summary = "회원가입"
+            , description = "회원 정보를 저장합니다."
+    )
+    ApiResponse<UserDto.UserResponse> saveUser(
+            @Schema(name = "회원 정보", description = "회원 정보 request DTO")
+            UserDto.CreateUserRequest createUserRequest
+    );
+
+    @Operation(
+            summary = "회원 조회"
+            , description = "로그인 ID 로 회원 조회합니다."
+    )
+    ApiResponse<UserDto.UserResponse> getUser(
+            @Schema(name = "로그인 ID", description = "회원 조회할 로그인 ID")
+            String loginId
+    );
+
+    @Operation(
+            summary = "포인트 조회"
+            , description = "회원의 포인트 조회합니다."
+    )
+    ApiResponse<Integer> getUserPoint(
+            @Schema(name = "로그인 ID", description = "포인트 조회할 회원의 로그인 ID")
+            String loginId
+    );
+
+    @Operation(
+            summary = "포인트 충전"
+            , description = "회원의 포인트 충전합니다."
+    )
+    ApiResponse<Integer> addUserPoint(
+            @Schema(name = "로그인 ID", description = "포인트 충전할 회원의 로그인 ID")
+            @Parameter(name = "loginId", in = ParameterIn.PATH)
+            String loginId,
+            @Schema(name = "충전할 포인트", description = "포인트 충전할 회원의 로그인 ID")
+            @Parameter(name = "userPoint", in = ParameterIn.QUERY)
+            Integer userPoint
+    );
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java
new file mode 100644
index 000000000..0483d603e
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java
@@ -0,0 +1,53 @@
+package com.loopers.interfaces.api.user;
+
+import com.loopers.application.user.UserFacade;
+import com.loopers.application.user.UserInfo;
+import com.loopers.interfaces.api.ApiResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/api/user")
+public class UserController implements UserApiSpec {
+
+    private final UserFacade userFacade;
+
+    @PostMapping("/saveUser")
+    public ApiResponse<UserDto.UserResponse> saveUser(
+            @RequestBody UserDto.CreateUserRequest cur
+    ) {
+        UserInfo userInfo = userFacade.saveUser(UserInfo.from(cur));
+        UserDto.UserResponse userResponse = UserDto.UserResponse.from(userInfo);
+        return ApiResponse.success(userResponse);
+    }
+
+    @GetMapping("/{loginId}")
+    @Override
+    public ApiResponse<UserDto.UserResponse> getUser(
+        @PathVariable String loginId
+    ) {
+        UserInfo userInfo = userFacade.getUser(loginId);
+        UserDto.UserResponse userResponse = UserDto.UserResponse.from(userInfo);
+        return ApiResponse.success(userResponse);
+    }
+
+    @GetMapping("/{loginId}/getUserPoint")
+    @Override
+    public ApiResponse<Integer> getUserPoint(
+            @PathVariable String loginId
+    ) {
+        Integer userPoint = userFacade.getUserPoint(loginId);
+        return ApiResponse.success(userPoint);
+    }
+
+    @PostMapping("/{loginId}/addUserPoint")
+    @Override
+    public ApiResponse<Integer> addUserPoint(
+            @PathVariable String loginId
+            , @RequestParam Integer userPoint
+    ) {
+        Integer userTotalPoint = userFacade.addUserPoint(loginId, userPoint);
+        return ApiResponse.success(userTotalPoint);
+    }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserDto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserDto.java
new file mode 100644
index 000000000..7dae2a4f9
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserDto.java
@@ -0,0 +1,36 @@
+package com.loopers.interfaces.api.user;
+
+import com.loopers.application.user.UserInfo;
+import lombok.Builder;
+
+public class UserDto {
+
+    @Builder
+    public record CreateUserRequest(
+            String loginId
+            , String email
+            , String birthday
+            , String gender
+            , Integer point
+    ) {}
+
+    @Builder
+    public record UserResponse(
+            String loginId
+            , String email
+            , String birthday
+            , String gender
+            , Integer point
+    ) {
+        public static UserResponse from(UserInfo userInfo) {
+            return UserResponse.builder()
+                    .loginId(userInfo.loginId())
+                    .email(userInfo.email())
+                    .birthday(userInfo.birthday())
+                    .gender(userInfo.gender())
+                    .point(userInfo.point())
+                    .build();
+        }
+    }
+
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/support/config/WebConfig.java b/apps/commerce-api/src/main/java/com/loopers/support/config/WebConfig.java
new file mode 100644
index 000000000..a758f90c9
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/support/config/WebConfig.java
@@ -0,0 +1,19 @@
+package com.loopers.support.config;
+
+import com.loopers.support.interceptor.AccessControlInterceptor;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+@Configuration
+public class WebConfig implements WebMvcConfigurer {
+
+    @Autowired
+    private AccessControlInterceptor accessControlInterceptor;
+
+    @Override
+    public void addInterceptors(InterceptorRegistry registry) {
+        registry.addInterceptor(accessControlInterceptor);
+    }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/CoreExceptionUtil.java b/apps/commerce-api/src/main/java/com/loopers/support/error/CoreExceptionUtil.java
new file mode 100644
index 000000000..39a239394
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/support/error/CoreExceptionUtil.java
@@ -0,0 +1,33 @@
+package com.loopers.support.error;
+
+import java.util.regex.Pattern;
+
+public class CoreExceptionUtil {
+    /**
+     * 문자열이 NULL, Blank 이면 throw Bad Request CoreException
+     */
+    public static void validateNullOrBlank(String param, String message) {
+        if (param == null || param.isBlank()) {
+            throw new CoreException(ErrorType.BAD_REQUEST, message);
+        }
+    }
+
+    /**
+     * 객체가 NULL 이면 throw Bad Request CoreException
+     */
+    public static void validateObjectNull(Object param, String message) {
+        if (param == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, message);
+        }
+    }
+
+    /**
+     * 문자열이 pattern 에 맞지 않으면 throw Bad Request CoreException
+     */
+    public static void validatePattern(String param, String patternString, String message) {
+        Pattern pattern = Pattern.compile(patternString);
+        if(!pattern.matcher(param).matches()) {
+            throw new CoreException(ErrorType.BAD_REQUEST, message);
+        }
+    }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/support/interceptor/AccessControlInterceptor.java b/apps/commerce-api/src/main/java/com/loopers/support/interceptor/AccessControlInterceptor.java
new file mode 100644
index 000000000..2c2b5c3a7
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/support/interceptor/AccessControlInterceptor.java
@@ -0,0 +1,31 @@
+package com.loopers.support.interceptor;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.stereotype.Component;
+import org.springframework.util.AntPathMatcher;
+import org.springframework.web.servlet.HandlerInterceptor;
+
+import static com.loopers.support.error.CoreExceptionUtil.validateNullOrBlank;
+
+@Component
+public class AccessControlInterceptor implements HandlerInterceptor {
+
+    private final AntPathMatcher pathMatcher = new AntPathMatcher();
+
+    public boolean preHandle(
+            HttpServletRequest request
+            , HttpServletResponse response
+            , Object handler
+    ) throws Exception {
+
+        // 포인트 조회 시 헤더에 X-USER-ID 가 없으면 BAD REQUEST
+        String requestPath = request.getRequestURI().substring(request.getContextPath().length());
+        if(pathMatcher.match("/api/user/*/getUserPoint", requestPath)) {
+            String userIdHeader = request.getHeader("X-USER-ID");
+            validateNullOrBlank(userIdHeader, "포인트 조회 시 헤더에 X-USER-ID 가 필요합니다.");
+        }
+
+        return true;
+    }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserE2ETest.java
new file mode 100644
index 000000000..5836ec6e5
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserE2ETest.java
@@ -0,0 +1,198 @@
+package com.loopers.domain.user;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.loopers.application.user.UserFacade;
+import com.loopers.application.user.UserInfo;
+import com.loopers.interfaces.api.user.UserDto;
+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.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+
+import static org.hamcrest.Matchers.endsWith;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@DisplayName("User E2E 테스트")
+@SpringBootTest
+@AutoConfigureMockMvc
+public class UserE2ETest {
+
+    @Autowired
+    private MockMvc mockMvc;
+
+    @Autowired
+    private DatabaseCleanUp databaseCleanUp;
+
+    @Autowired
+    private UserFacade userFacade;
+
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    @AfterEach
+    void tearDown() {databaseCleanUp.truncateAllTables();}
+
+    final String validLoginId = "bobby34";
+    final String validEmail = "bobby34@naver.com";
+    final String validBirthday = "1994-04-08";
+    final String validGender = "M";
+    final int validPoint = 0;
+
+    @DisplayName("User 엔티티 생성")
+    @Nested
+    class CreateUserTest {
+
+        @DisplayName("성공 케이스 : 회원 가입이 성공해서 생성된 유저 정보를 응답으로 반환")
+        @Test
+        void saveUser_returnUserResponse_Success() throws Exception {
+            // arrange
+            UserDto.CreateUserRequest createUserRequest = UserDto.CreateUserRequest.builder()
+                    .loginId(validLoginId)
+                    .email(validEmail)
+                    .birthday(validBirthday)
+                    .gender(validGender)
+                    .point(validPoint)
+                    .build();
+            String userJson = objectMapper.writeValueAsString(createUserRequest);
+
+            // act
+            mockMvc.perform(post("/api/user/saveUser")
+                    .contentType(MediaType.APPLICATION_JSON)
+                    .content(userJson))
+
+            // assert
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.data").exists())
+                    .andExpect(jsonPath("$.data.loginId").value(validLoginId))
+                    .andExpect(jsonPath("$.data.email").value(validEmail))
+                    .andExpect(jsonPath("$.data.birthday").value(validBirthday))
+                    .andExpect(jsonPath("$.data.gender").value(validGender))
+                    .andExpect(jsonPath("$.data.point").value(validPoint));
+        }
+
+        @DisplayName("실패 케이스 : 회원 가입 시 성별 없을 경우 `400 Bad Request` 응답 반환")
+        @Test
+        void saveUser_noGender_BadRequest() throws Exception {
+            // arrange
+            UserDto.CreateUserRequest createUserRequest = UserDto.CreateUserRequest.builder()
+                    .loginId(validLoginId)
+                    .email(validEmail)
+                    .birthday(validBirthday)
+                    .point(validPoint)
+                    .build();
+            String userJson = objectMapper.writeValueAsString(createUserRequest);
+
+            // act
+            mockMvc.perform(post("/api/user/saveUser")
+                            .contentType(MediaType.APPLICATION_JSON)
+                            .content(userJson))
+
+                    // assert
+                    .andExpect(status().isBadRequest())
+                    .andExpect(jsonPath("$.data").doesNotExist())
+                    .andExpect(jsonPath("$.meta.result").value("FAIL"))
+                    .andExpect(jsonPath("$.meta.errorCode").value("Bad Request"))
+                    .andExpect(jsonPath("$.meta.message").value("gender가 비어있을 수 없습니다."));
+        }
+    }
+
+    @DisplayName("User 포인트 조회")
+    @Nested
+    class GetUserTest {
+
+        @DisplayName("성공 케이스 : 포인트 조회에 성공할 경우, 보유 포인트를 응답으로 반환")
+        @Test
+        void getUserPoint_withValidFields_Success() throws Exception {
+            // arrange
+            UserDto.CreateUserRequest cur = UserDto.CreateUserRequest.builder()
+                    .loginId(validLoginId)
+                    .email(validEmail)
+                    .birthday(validBirthday)
+                    .gender(validGender)
+                    .point(validPoint)
+                    .build();
+
+            userFacade.saveUser(UserInfo.from(cur));
+
+            // act
+            mockMvc.perform(get("/api/user/{loginId}/getUserPoint", validLoginId)
+                            .header("X-USER-ID",  validLoginId)
+                            .contentType(MediaType.APPLICATION_JSON))
+
+            // assert
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.data").isNumber())
+                    .andExpect(jsonPath("$.data").value(validPoint));
+        }
+
+        @DisplayName("실패 케이스 : `X-USER-ID` 헤더가 없을 경우, `400 Bad Request` 응답을 반환")
+        @Test
+        void getUserPoint_XUserIdHeaderNotExists_BadRequest() throws Exception {
+            // arrange
+
+            // act
+            mockMvc.perform(get("/api/user/{loginId}/getUserPoint", validLoginId)
+                            .contentType(MediaType.APPLICATION_JSON))
+
+            // assert
+                    .andExpect(status().isBadRequest())
+                    .andExpect(jsonPath("$.meta.message").value("포인트 조회 시 헤더에 X-USER-ID 가 필요합니다."));
+        }
+    }
+
+    @DisplayName("User 포인트 충전")
+    @Nested
+    class AddUserTest {
+
+        @DisplayName("성공 케이스 : 존재하는 User가 1000원을 충전할 경우, 충전된 보유 총량을 응답으로 반환")
+        @Test
+        void addUserPoint_add1000_getTotalPoints() throws Exception {
+            // arrange
+            Integer requestPoint = 1000;
+
+            UserDto.CreateUserRequest cur = UserDto.CreateUserRequest.builder()
+                    .loginId(validLoginId)
+                    .email(validEmail)
+                    .birthday(validBirthday)
+                    .gender(validGender)
+                    .point(validPoint)
+                    .build();
+
+            userFacade.saveUser(UserInfo.from(cur));
+
+            // act
+            mockMvc.perform(post("/api/user/{loginId}/addUserPoint", validLoginId)
+                            .param("userPoint", requestPoint.toString())
+                            .contentType(MediaType.APPLICATION_JSON))
+
+                    // assert
+                    .andExpect(status().isOk())
+                    .andExpect(jsonPath("$.data").isNumber())
+                    .andExpect(jsonPath("$.data").value(requestPoint));
+        }
+
+        @DisplayName("실패 케이스 : 존재하지 않는 User로 요청할 경우, `404 Not Found` 응답을 반환")
+        @Test
+        void addPoint_withNoUser_NotFound() throws Exception {
+            // arrange
+            Integer requestPoint = 1000;
+
+            // act
+            mockMvc.perform(post("/api/user/{loginId}/addUserPoint", validLoginId)
+                            .param("userPoint", requestPoint.toString())
+                            .contentType(MediaType.APPLICATION_JSON))
+
+                    // assert
+                    .andExpect(status().isNotFound())
+                    .andExpect(jsonPath("$.meta.message").value(endsWith("point를 충전할 User를 찾을 수 없습니다.")));
+        }
+    }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserIntegrationTest.java
new file mode 100644
index 000000000..742fe801a
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserIntegrationTest.java
@@ -0,0 +1,164 @@
+package com.loopers.domain.user;
+
+import com.loopers.application.user.UserFacade;
+import com.loopers.application.user.UserInfo;
+import com.loopers.interfaces.api.user.UserDto;
+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.mockito.Mockito;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+@DisplayName("User 통합 테스트")
+@SpringBootTest
+public class UserIntegrationTest {
+
+    @Autowired
+    private UserFacade userFacade;
+
+    @Autowired
+    private UserService userService;
+
+    @MockitoSpyBean
+    private UserRepository userRepository;
+
+    @Autowired
+    private DatabaseCleanUp databaseCleanUp;
+
+    @AfterEach
+    void tearDown() {databaseCleanUp.truncateAllTables();}
+
+    final String validLoginId = "bobby34";
+    final String validEmail = "bobby34@naver.com";
+    final String validBirthday = "1994-04-08";
+    final String validGender = "M";
+    final Integer validPoint = 0;
+
+    @DisplayName("User 엔티티 생성")
+    @Nested
+    class CreateUserTest {
+
+        @DisplayName("성공 케이스 : User 저장에 성공하는지 확인")
+        @Test
+        void saveUser_withValidFields_Success() {
+            // arrange
+            UserDto.CreateUserRequest cur = UserDto.CreateUserRequest.builder()
+                    .loginId(validLoginId)
+                    .email(validEmail)
+                    .birthday(validBirthday)
+                    .gender(validGender)
+                    .point(validPoint)
+                    .build();
+
+            // act
+            userFacade.saveUser(UserInfo.from(cur));
+
+            // assert
+            Mockito.verify(userRepository, Mockito.times(1))
+                .save(Mockito.argThat(user -> 
+                    user.getLoginId().equals(validLoginId)
+                    && user.getEmail().equals(validEmail)
+                    && user.getBirthday().equals(validBirthday)
+                    && user.getGender().equals(validGender)
+                    && user.getPoint().equals(validPoint)
+                )
+            );
+        }
+
+        @DisplayName("실패 케이스 : 이미 저장된 User 를 다시 저장하면 실패")
+        @Test
+        void saveUser_spyUserRepository_Success() {
+            // arrange
+            UserDto.CreateUserRequest cur = UserDto.CreateUserRequest.builder()
+                    .loginId(validLoginId)
+                    .email(validEmail)
+                    .birthday(validBirthday)
+                    .gender(validGender)
+                    .point(validPoint)
+                    .build();
+
+            // act
+            userFacade.saveUser(UserInfo.from(cur));
+            CoreException result = assertThrows(CoreException.class,
+                () -> userFacade.saveUser(UserInfo.from(cur)));
+
+            // assert
+            assertEquals(ErrorType.CONFLICT, result.getErrorType());
+        }
+
+    }
+
+    @DisplayName("User 엔티티 조회")
+    @Nested
+    class GetUserTest {
+
+        @DisplayName("성공 케이스 : ID 의 User가 존재할 경우, 회원 정보 반환")
+        @Test
+        void getUser_idExists_Success() {
+            // arrange
+            UserDto.CreateUserRequest cur = UserDto.CreateUserRequest.builder()
+                    .loginId(validLoginId)
+                    .email(validEmail)
+                    .birthday(validBirthday)
+                    .gender(validGender)
+                    .point(validPoint)
+                    .build();
+
+            userFacade.saveUser(UserInfo.from(cur));
+
+            // act
+            UserInfo userInfo = userFacade.getUser(validLoginId);
+
+            // assert
+            assertNotNull(userInfo);
+            assertAll(
+                    () -> assertEquals(userInfo.loginId(), validLoginId)
+                    , () -> assertEquals(userInfo.email(), validEmail)
+                    , () -> assertEquals(userInfo.birthday(), validBirthday)
+                    , () -> assertEquals(userInfo.gender(), validGender)
+                    , () -> assertEquals(userInfo.point(), validPoint)
+            );
+        }
+
+        @DisplayName("실패 케이스 : ID 의 User가 존재하지 않을 경우, Null 반환")
+        @Test
+        void getUser_idNotExists_getNull() {
+            // arrange
+
+            // act
+            UserInfo userInfo = userFacade.getUser(validLoginId);
+
+            // assert
+            assertNull(userInfo);
+        }
+    }
+
+    @DisplayName("point 충전 테스트")
+    @Nested
+    class PointTest {
+
+        @DisplayName("실패 케이스 : 존재하지 않는 User ID 로 충전을 시도한 경우, 실패")
+        @Test
+        void addPoint_withNoUser_NotFound() {
+            // arrange
+            Integer requestPoint = 1000;
+
+            // act
+            CoreException result = assertThrows(CoreException.class,
+                    () -> userFacade.addUserPoint(validLoginId,  requestPoint)
+            );
+
+            // assert
+            assertEquals(ErrorType.NOT_FOUND, result.getErrorType());
+            assertTrue(result.getCustomMessage().endsWith("point를 충전할 User를 찾을 수 없습니다."));
+        }
+    }
+}
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
new file mode 100644
index 000000000..e3c5b6d5b
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java
@@ -0,0 +1,304 @@
+package com.loopers.domain.user;
+
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+@DisplayName("User 테스트")
+public class UserTest {
+
+    @DisplayName("User 엔티티 생성")
+    @Nested
+    class CreateUserTest {
+
+        final String validLoginId = "bobby34";
+        final String validEmail = "bobby34@naver.com";
+        final String validBirthday = "1994-04-08";
+        final String validGender = "M";
+        final Integer validPoint = 0;
+
+        @DisplayName("성공 케이스 : 필드가 모두 형식에 맞으면 User 객체 생성 성공")
+        @Test
+        void createUser_withValidFields_Success() {
+            // arrange
+
+            // act
+            User user = User.builder()
+                    .loginId(validLoginId)
+                    .email(validEmail)
+                    .birthday(validBirthday)
+                    .gender(validGender)
+                    .point(validPoint)
+                    .build();
+
+            // assert
+            assertNotNull(user);
+            assertAll(
+                    () -> assertEquals(user.getLoginId(), validLoginId)
+                    , () -> assertEquals(user.getEmail(), validEmail)
+                    , () -> assertEquals(user.getBirthday(), validBirthday)
+                    ,  () -> assertEquals(user.getGender(), validGender)
+                    ,  () -> assertEquals(user.getPoint(), validPoint)
+            );
+        }
+
+        @DisplayName("User ID 형식 검사: 영문, 숫자만 있는 10자 이내 문자열")
+        @Nested
+        class IdTest {
+
+            @DisplayName("실패 케이스 : User ID가 영문, 숫자 이외 문자열이면 User 객체 생성 실패")
+            @Test
+            void createUser_withNoEngAndNumId_BadRequest() {
+                // arrange
+                String noEngAndNumId = "안녕하세요";
+
+                // act
+                CoreException result = assertThrows(CoreException.class,
+                        () -> User.builder()
+                            .loginId(noEngAndNumId)
+                            .email(validEmail)
+                            .birthday(validBirthday)
+                            .gender(validGender)
+                            .build()
+                );
+                
+                // assert
+                assertEquals(ErrorType.BAD_REQUEST, result.getErrorType());
+                assertEquals("ID에 영문과 숫자만 10자 이내로 꼭 포함되어 있어야 합니다.", result.getCustomMessage());
+            }
+
+            @DisplayName("실패 케이스 : User ID에 영문이 없으면 User 객체 생성 실패")
+            @Test
+            void createUser_withNoEngId_BadRequest() {
+                // arrange
+                String noEngId = "34";
+
+                // act
+                CoreException result = assertThrows(CoreException.class,
+                        () -> User.builder()
+                            .loginId(noEngId)
+                            .email(validEmail)
+                            .birthday(validBirthday)
+                            .gender(validGender)
+                            .build()
+                );
+
+                // assert
+                assertEquals(ErrorType.BAD_REQUEST, result.getErrorType());
+                assertEquals("ID에 영문과 숫자만 10자 이내로 꼭 포함되어 있어야 합니다.", result.getCustomMessage());
+            }
+
+            @DisplayName("실패 케이스 : User ID에 숫자가 없으면 User 객체 생성 실패")
+            @Test
+            void createUser_withNoNumId_BadRequest() {
+                // arrange
+                String noNumId = "bobby";
+
+                // act
+                CoreException result = assertThrows(CoreException.class,
+                        () -> User.builder()
+                            .loginId(noNumId)
+                            .email(validEmail)
+                            .birthday(validBirthday)
+                            .gender(validGender)
+                            .build()
+                );
+
+                // assert
+                assertEquals(ErrorType.BAD_REQUEST, result.getErrorType());
+                assertEquals("ID에 영문과 숫자만 10자 이내로 꼭 포함되어 있어야 합니다.", result.getCustomMessage());
+            }
+
+            @DisplayName("실패 케이스 : User ID가 10자를 넘어가면 User 객체 생성 실패")
+            @Test
+            void createUser_withIdOverTenChars_BadRequest() {
+                // arrange
+                String idOverTenChars = "bobby343434";
+
+                // act
+                CoreException result = assertThrows(CoreException.class,
+                        () -> User.builder()
+                            .loginId(idOverTenChars)
+                            .email(validEmail)
+                            .birthday(validBirthday)
+                            .gender(validGender)
+                            .build()
+                );
+
+                // assert
+                assertEquals(ErrorType.BAD_REQUEST, result.getErrorType());
+                assertEquals("ID에 영문과 숫자만 10자 이내로 꼭 포함되어 있어야 합니다.", result.getCustomMessage());
+            }
+        }
+
+        @DisplayName("User email 형식 검사 : `xx@yy.zz`")
+        @Nested
+        class EmailTest {
+
+            @DisplayName("실패 케이스 : User email에 `@` 이 없으면 User 객체 생성 실패")
+            @Test
+            void createUser_withNoAtSignEmail_BadRequest() {
+                // arrange
+                String email = "bobby34naver.com";
+
+                // act
+                CoreException result = assertThrows(CoreException.class,
+                        () -> User.builder()
+                            .loginId(validLoginId)
+                            .email(email)
+                            .birthday(validBirthday)
+                            .gender(validGender)
+                            .build()
+                );
+
+                // assert
+                assertEquals(ErrorType.BAD_REQUEST, result.getErrorType());
+                assertEquals("email이 `xx@yy.zz` 형식에 맞아야 합니다.", result.getCustomMessage());
+            }
+
+            @DisplayName("실패 케이스 : User email에 `@` 이 두 개 있으면 User 객체 생성 실패")
+            @Test
+            void createUser_withTwoAtSignEmail_BadRequest() {
+                // arrange
+                String email = "bobby34@@naver.com";
+
+                // act
+                CoreException result = assertThrows(CoreException.class,
+                        () -> User.builder()
+                            .loginId(validLoginId)
+                            .email(email)
+                            .birthday(validBirthday)
+                            .gender(validGender)
+                            .build()
+                );
+
+                // assert
+                assertEquals(ErrorType.BAD_REQUEST, result.getErrorType());
+                assertEquals("email이 `xx@yy.zz` 형식에 맞아야 합니다.", result.getCustomMessage());
+            }
+
+            @DisplayName("실패 케이스 : User email에 `.` 이 없으면 User 객체 생성 실패")
+            @Test
+            void createUser_withNoDotEmail_BadRequest() {
+                // arrange
+                String email = "bobby34@navercom";
+
+                // act
+                CoreException result = assertThrows(CoreException.class,
+                        () -> User.builder()
+                            .loginId(validLoginId)
+                            .email(email)
+                            .birthday(validBirthday)
+                            .gender(validGender)
+                            .build()
+                );
+
+                // assert
+                assertEquals(ErrorType.BAD_REQUEST, result.getErrorType());
+                assertEquals("email이 `xx@yy.zz` 형식에 맞아야 합니다.", result.getCustomMessage());
+            }
+
+            @DisplayName("실패 케이스 : User email에 `.` 이 두 개 있으면 User 객체 생성 실패")
+            @Test
+            void createUser_withTwoDotEmail_BadRequest() {
+                // arrange
+                String email = "bobby34@naver..com";
+
+                // act
+                CoreException result = assertThrows(CoreException.class,
+                        () -> User.builder()
+                            .loginId(validLoginId)
+                            .email(email)
+                            .birthday(validBirthday)
+                            .gender(validGender)
+                            .build()
+                );
+
+                // assert
+                assertEquals(ErrorType.BAD_REQUEST, result.getErrorType());
+                assertEquals("email이 `xx@yy.zz` 형식에 맞아야 합니다.", result.getCustomMessage());
+            }
+        }
+
+        @DisplayName("User birthday 형식 검사 : `yyyy-MM-dd`")
+        @Nested
+        class BirthdayTest {
+
+            @DisplayName("실패 케이스 : User birthday가 `yyyyMMdd` 형식이면 User 객체 생성 실패")
+            @Test
+            void createUser_withNoHyphenBirthday_BadRequest() {
+                // arrange
+                String noHyphenBirthday = "19940408";
+
+                // act
+                CoreException result = assertThrows(CoreException.class,
+                        () -> User.builder()
+                            .loginId(validLoginId)
+                            .email(validEmail)
+                            .birthday(noHyphenBirthday)
+                            .gender(validGender)
+                            .build()
+                );
+
+                // assert
+                assertEquals(ErrorType.BAD_REQUEST, result.getErrorType());
+                assertEquals("birthday가 `yyyy-MM-dd` 형식에 맞아야 합니다.", result.getCustomMessage());
+            }
+
+            @DisplayName("실패 케이스 : User birthday가 `yyyy-MM-d` 형식이면 User 객체 생성 실패")
+            @Test
+            void createUser_withNoOneDayBirthdayFormat_BadRequest() {
+                // arrange
+                String birthday = "1994-04-1";
+
+                // act
+                CoreException result = assertThrows(CoreException.class,
+                        () -> User.builder()
+                            .loginId(validLoginId)
+                            .email(validEmail)
+                            .birthday(birthday)
+                            .gender(validGender)
+                            .build()
+                );
+
+                // assert
+                assertEquals(ErrorType.BAD_REQUEST, result.getErrorType());
+                assertEquals("birthday가 `yyyy-MM-dd` 형식에 맞아야 합니다.", result.getCustomMessage());
+            }
+        }
+
+        @DisplayName("point 충전 테스트")
+        @Nested
+        class PointTest {
+
+            @DisplayName("실패 케이스 : 0 이하의 정수로 포인트를 충전 시 실패")
+            @Test
+            void addPoint_inputBelowZero_BadRequest() {
+                // arrange
+                User user = User.builder()
+                        .loginId(validLoginId)
+                        .email(validEmail)
+                        .birthday(validBirthday)
+                        .gender(validGender)
+                        .point(validPoint)
+                        .build();
+
+                Integer requestPoint = -10;
+
+                // act
+                CoreException result = assertThrows(CoreException.class,
+                        () -> user.addPoint(requestPoint)
+                );
+
+                // assert
+                assertEquals(ErrorType.BAD_REQUEST, result.getErrorType());
+                assertEquals("충전할 point는 음수가 될 수 없습니다.", result.getCustomMessage());
+            }
+        }
+
+    }
+}

From 1183db5ecf70bf4cfb574e6f39a9abdfd09d0f89 Mon Sep 17 00:00:00 2001
From: Yim Yoobin <yoobinbkk@naver.com>
Date: Fri, 31 Oct 2025 20:13:41 +0900
Subject: [PATCH 02/19] =?UTF-8?q?getUserPoint=20=EC=9D=98=20X-USER-ID=20?=
 =?UTF-8?q?=EA=B0=90=EC=A7=80=20=EB=B0=A9=EB=B2=95=20=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../interfaces/api/user/UserApiSpec.java      |  8 ++++-
 .../interfaces/api/user/UserController.java   |  5 +++
 .../com/loopers/support/config/WebConfig.java | 19 ------------
 .../interceptor/AccessControlInterceptor.java | 31 -------------------
 4 files changed, 12 insertions(+), 51 deletions(-)
 delete mode 100644 apps/commerce-api/src/main/java/com/loopers/support/config/WebConfig.java
 delete mode 100644 apps/commerce-api/src/main/java/com/loopers/support/interceptor/AccessControlInterceptor.java

diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserApiSpec.java
index a6d789db9..4d96c092e 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserApiSpec.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserApiSpec.java
@@ -34,7 +34,13 @@ ApiResponse<UserDto.UserResponse> getUser(
     )
     ApiResponse<Integer> getUserPoint(
             @Schema(name = "로그인 ID", description = "포인트 조회할 회원의 로그인 ID")
-            String loginId
+            String loginId,
+            @Parameter(
+                    name = "X-USER-ID"
+                    , in = ParameterIn.HEADER
+                    , description = "요청자 사용자 ID 헤더"
+            )
+            String xUserId
     );
 
     @Operation(
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java
index 0483d603e..663b87537 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java
@@ -3,9 +3,12 @@
 import com.loopers.application.user.UserFacade;
 import com.loopers.application.user.UserInfo;
 import com.loopers.interfaces.api.ApiResponse;
+import com.loopers.support.error.CoreExceptionUtil;
 import lombok.RequiredArgsConstructor;
 import org.springframework.web.bind.annotation.*;
 
+import static com.loopers.support.error.CoreExceptionUtil.*;
+
 @RequiredArgsConstructor
 @RestController
 @RequestMapping("/api/user")
@@ -36,7 +39,9 @@ public ApiResponse<UserDto.UserResponse> getUser(
     @Override
     public ApiResponse<Integer> getUserPoint(
             @PathVariable String loginId
+            , @RequestHeader(value = "X-USER-ID", required = false) String xUserId
     ) {
+        validateNullOrBlank(xUserId, "포인트 조회 시 헤더에 X-USER-ID 가 필요합니다.");
         Integer userPoint = userFacade.getUserPoint(loginId);
         return ApiResponse.success(userPoint);
     }
diff --git a/apps/commerce-api/src/main/java/com/loopers/support/config/WebConfig.java b/apps/commerce-api/src/main/java/com/loopers/support/config/WebConfig.java
deleted file mode 100644
index a758f90c9..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/support/config/WebConfig.java
+++ /dev/null
@@ -1,19 +0,0 @@
-package com.loopers.support.config;
-
-import com.loopers.support.interceptor.AccessControlInterceptor;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.context.annotation.Configuration;
-import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
-import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
-
-@Configuration
-public class WebConfig implements WebMvcConfigurer {
-
-    @Autowired
-    private AccessControlInterceptor accessControlInterceptor;
-
-    @Override
-    public void addInterceptors(InterceptorRegistry registry) {
-        registry.addInterceptor(accessControlInterceptor);
-    }
-}
diff --git a/apps/commerce-api/src/main/java/com/loopers/support/interceptor/AccessControlInterceptor.java b/apps/commerce-api/src/main/java/com/loopers/support/interceptor/AccessControlInterceptor.java
deleted file mode 100644
index 2c2b5c3a7..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/support/interceptor/AccessControlInterceptor.java
+++ /dev/null
@@ -1,31 +0,0 @@
-package com.loopers.support.interceptor;
-
-import jakarta.servlet.http.HttpServletRequest;
-import jakarta.servlet.http.HttpServletResponse;
-import org.springframework.stereotype.Component;
-import org.springframework.util.AntPathMatcher;
-import org.springframework.web.servlet.HandlerInterceptor;
-
-import static com.loopers.support.error.CoreExceptionUtil.validateNullOrBlank;
-
-@Component
-public class AccessControlInterceptor implements HandlerInterceptor {
-
-    private final AntPathMatcher pathMatcher = new AntPathMatcher();
-
-    public boolean preHandle(
-            HttpServletRequest request
-            , HttpServletResponse response
-            , Object handler
-    ) throws Exception {
-
-        // 포인트 조회 시 헤더에 X-USER-ID 가 없으면 BAD REQUEST
-        String requestPath = request.getRequestURI().substring(request.getContextPath().length());
-        if(pathMatcher.match("/api/user/*/getUserPoint", requestPath)) {
-            String userIdHeader = request.getHeader("X-USER-ID");
-            validateNullOrBlank(userIdHeader, "포인트 조회 시 헤더에 X-USER-ID 가 필요합니다.");
-        }
-
-        return true;
-    }
-}

From e1da8e10332599373c46d4a7185f46fdb4e265df Mon Sep 17 00:00:00 2001
From: Yim Yoobin <yoobinbkk@naver.com>
Date: Fri, 31 Oct 2025 20:14:15 +0900
Subject: [PATCH 03/19] =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=200=20?=
 =?UTF-8?q?=EC=9D=B4=ED=95=98=20=EC=B6=A9=EC=A0=84=20=EB=AA=BB=ED=95=98?=
 =?UTF-8?q?=EA=B2=8C=20=ED=95=98=EB=8A=94=20=EB=A1=9C=EC=A7=81=EC=9C=BC?=
 =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../java/com/loopers/domain/user/User.java    |  2 +-
 .../com/loopers/domain/user/UserTest.java     | 26 ++++++++++++++++++-
 2 files changed, 26 insertions(+), 2 deletions(-)

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 1ba1edbc8..28ccfeb9a 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
@@ -65,7 +65,7 @@ protected void guard() {
 
     public Integer addPoint(Integer point) {
         // 충전하는 포인트가 음수이면 BAD REQUEST CoreException 을 발생
-        if(point < 0) throw new CoreException(ErrorType.BAD_REQUEST, "충전할 point는 음수가 될 수 없습니다.");
+        if(point <= 0) throw new CoreException(ErrorType.BAD_REQUEST, "충전할 point는 0 이하가 될 수 없습니다.");
         this.point += point;
         return this.point;
     }
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 e3c5b6d5b..ecfa0dfcd 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
@@ -277,6 +277,30 @@ class PointTest {
 
             @DisplayName("실패 케이스 : 0 이하의 정수로 포인트를 충전 시 실패")
             @Test
+            void addPoint_inputZero_BadRequest() {
+                // arrange
+                User user = User.builder()
+                        .loginId(validLoginId)
+                        .email(validEmail)
+                        .birthday(validBirthday)
+                        .gender(validGender)
+                        .point(validPoint)
+                        .build();
+
+                Integer requestPoint = 0;
+
+                // act
+                CoreException result = assertThrows(CoreException.class,
+                        () -> user.addPoint(requestPoint)
+                );
+
+                // assert
+                assertEquals(ErrorType.BAD_REQUEST, result.getErrorType());
+                assertEquals("충전할 point는 0 이하가 될 수 없습니다.", result.getCustomMessage());
+            }
+
+            @DisplayName("실패 케이스 : 음수로 포인트를 충전 시 실패")
+            @Test
             void addPoint_inputBelowZero_BadRequest() {
                 // arrange
                 User user = User.builder()
@@ -296,7 +320,7 @@ void addPoint_inputBelowZero_BadRequest() {
 
                 // assert
                 assertEquals(ErrorType.BAD_REQUEST, result.getErrorType());
-                assertEquals("충전할 point는 음수가 될 수 없습니다.", result.getCustomMessage());
+                assertEquals("충전할 point는 0 이하가 될 수 없습니다.", result.getCustomMessage());
             }
         }
 

From c3da3ca3f054dac675373ef45475c4e59710040e Mon Sep 17 00:00:00 2001
From: Yim Yoobin <yoobinbkk@naver.com>
Date: Fri, 31 Oct 2025 20:38:43 +0900
Subject: [PATCH 04/19] =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../loopers/application/user/UserFacade.java  |  3 ++-
 .../interfaces/api/user/UserController.java   |  3 +--
 .../domain/user/UserIntegrationTest.java      | 19 +++++++------------
 .../com/loopers/domain/user/UserTest.java     |  4 ++--
 4 files changed, 12 insertions(+), 17 deletions(-)

diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java
index 79c9b59b9..70011d90c 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java
@@ -47,7 +47,8 @@ public Integer getUserPoint(String loginId){
     }
 
     @Transactional
-    public Integer addUserPoint(String loginId, Integer userPoint) {      User user = userService.findUserById(loginId)
+    public Integer addUserPoint(String loginId, Integer userPoint) {
+        User user = userService.findUserById(loginId)
                 .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[loginId = " + loginId + "] point를 충전할 User를 찾을 수 없습니다."));
         user.addPoint(userPoint);
         Optional<User> savedUser = userService.saveUser(user);
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java
index 663b87537..e502fcace 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java
@@ -3,11 +3,10 @@
 import com.loopers.application.user.UserFacade;
 import com.loopers.application.user.UserInfo;
 import com.loopers.interfaces.api.ApiResponse;
-import com.loopers.support.error.CoreExceptionUtil;
 import lombok.RequiredArgsConstructor;
 import org.springframework.web.bind.annotation.*;
 
-import static com.loopers.support.error.CoreExceptionUtil.*;
+import static com.loopers.support.error.CoreExceptionUtil.validateNullOrBlank;
 
 @RequiredArgsConstructor
 @RestController
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserIntegrationTest.java
index 742fe801a..f84aadd5c 100644
--- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserIntegrationTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserIntegrationTest.java
@@ -2,7 +2,6 @@
 
 import com.loopers.application.user.UserFacade;
 import com.loopers.application.user.UserInfo;
-import com.loopers.interfaces.api.user.UserDto;
 import com.loopers.support.error.CoreException;
 import com.loopers.support.error.ErrorType;
 import com.loopers.utils.DatabaseCleanUp;
@@ -24,9 +23,6 @@ public class UserIntegrationTest {
     @Autowired
     private UserFacade userFacade;
 
-    @Autowired
-    private UserService userService;
-
     @MockitoSpyBean
     private UserRepository userRepository;
 
@@ -50,7 +46,7 @@ class CreateUserTest {
         @Test
         void saveUser_withValidFields_Success() {
             // arrange
-            UserDto.CreateUserRequest cur = UserDto.CreateUserRequest.builder()
+            UserInfo userinfo = UserInfo.builder()
                     .loginId(validLoginId)
                     .email(validEmail)
                     .birthday(validBirthday)
@@ -59,7 +55,7 @@ void saveUser_withValidFields_Success() {
                     .build();
 
             // act
-            userFacade.saveUser(UserInfo.from(cur));
+            userFacade.saveUser(userinfo);
 
             // assert
             Mockito.verify(userRepository, Mockito.times(1))
@@ -77,18 +73,18 @@ void saveUser_withValidFields_Success() {
         @Test
         void saveUser_spyUserRepository_Success() {
             // arrange
-            UserDto.CreateUserRequest cur = UserDto.CreateUserRequest.builder()
+            UserInfo userinfo = UserInfo.builder()
                     .loginId(validLoginId)
                     .email(validEmail)
                     .birthday(validBirthday)
                     .gender(validGender)
                     .point(validPoint)
                     .build();
+            userFacade.saveUser(userinfo);
 
             // act
-            userFacade.saveUser(UserInfo.from(cur));
             CoreException result = assertThrows(CoreException.class,
-                () -> userFacade.saveUser(UserInfo.from(cur)));
+                () -> userFacade.saveUser(userinfo));
 
             // assert
             assertEquals(ErrorType.CONFLICT, result.getErrorType());
@@ -104,15 +100,14 @@ class GetUserTest {
         @Test
         void getUser_idExists_Success() {
             // arrange
-            UserDto.CreateUserRequest cur = UserDto.CreateUserRequest.builder()
+            UserInfo userinfo = UserInfo.builder()
                     .loginId(validLoginId)
                     .email(validEmail)
                     .birthday(validBirthday)
                     .gender(validGender)
                     .point(validPoint)
                     .build();
-
-            userFacade.saveUser(UserInfo.from(cur));
+            userFacade.saveUser(userinfo);
 
             // act
             UserInfo userInfo = userFacade.getUser(validLoginId);
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 ecfa0dfcd..a9222b0b0 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
@@ -41,8 +41,8 @@ void createUser_withValidFields_Success() {
                     () -> assertEquals(user.getLoginId(), validLoginId)
                     , () -> assertEquals(user.getEmail(), validEmail)
                     , () -> assertEquals(user.getBirthday(), validBirthday)
-                    ,  () -> assertEquals(user.getGender(), validGender)
-                    ,  () -> assertEquals(user.getPoint(), validPoint)
+                    , () -> assertEquals(user.getGender(), validGender)
+                    , () -> assertEquals(user.getPoint(), validPoint)
             );
         }
 

From 9df952f60e7170514405ed9c9d2e21a2e34a508c Mon Sep 17 00:00:00 2001
From: Yim Yoobin <yoobinbkk@naver.com>
Date: Fri, 7 Nov 2025 09:51:05 +0900
Subject: [PATCH 05/19] =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=812?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../main/java/com/loopers/domain/user/UserRepository.java | 2 +-
 .../loopers/infrastructure/user/UserRepositoryImpl.java   | 8 ++++----
 .../main/java/com/loopers/support/error/ErrorType.java    | 2 +-
 3 files changed, 6 insertions(+), 6 deletions(-)

diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java
index 5a01502cb..9c401b4bf 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java
@@ -4,5 +4,5 @@
 
 public interface UserRepository {
     Optional<User> save(User user);
-    Optional<User> findByLoginId(String id);
+    Optional<User> findByLoginId(String loginId);
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java
index 359cc1020..6a8dd1b90 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java
@@ -14,12 +14,12 @@ public class UserRepositoryImpl implements UserRepository {
 
     @Override
     public Optional<User> save(User user) {
-        userJpaRepository.save(user);
-        return Optional.of(user);
+        User savedUser = userJpaRepository.save(user);
+        return Optional.of(savedUser);
     }
 
     @Override
-    public Optional<User> findByLoginId(String id) {
-        return userJpaRepository.findByLoginId(id);
+    public Optional<User> findByLoginId(String loginId) {
+        return userJpaRepository.findByLoginId(loginId);
     }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java
index 5d142efbf..d2571f71b 100644
--- a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java
+++ b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java
@@ -10,7 +10,7 @@ public enum ErrorType {
     /** 범용 에러 */
     INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), "일시적인 오류가 발생했습니다."),
     BAD_REQUEST(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "잘못된 요청입니다."),
-    NOT_FOUND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "존재하지 않는 요청입니다."),
+    NOT_FOUND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "존재하지 않는 리소스입니다."),
     CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 리소스입니다.");
 
     private final HttpStatus status;

From f7f3cc2436d401d27cffe1760d3bb1089b838004 Mon Sep 17 00:00:00 2001
From: Yim Yoobin <yoobinbkk@naver.com>
Date: Fri, 7 Nov 2025 09:52:17 +0900
Subject: [PATCH 06/19] =?UTF-8?q?[Round=202]=201.=20=EC=9A=94=EA=B5=AC?=
 =?UTF-8?q?=EC=82=AC=ED=95=AD=20=EC=A0=95=EB=A6=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

 - 요구사항 대상: 상품 조회, 상품 좋아요, 주문/결제 및 주문 조회
 - 유저 스토리: 유저가 서비스를 사용하는 간단한 시나리오
 - 기능 흐름: 백엔드에서 처리되는 경로를 명시, 예외처리도 표시
 - 유비쿼터스 언어: 도메인 용어 정리
---
 .../doc/week2/01-requirements/requirements.md | 128 ++++++++++++++++++
 1 file changed, 128 insertions(+)
 create mode 100644 apps/commerce-api/src/doc/week2/01-requirements/requirements.md

diff --git a/apps/commerce-api/src/doc/week2/01-requirements/requirements.md b/apps/commerce-api/src/doc/week2/01-requirements/requirements.md
new file mode 100644
index 000000000..acef88f63
--- /dev/null
+++ b/apps/commerce-api/src/doc/week2/01-requirements/requirements.md
@@ -0,0 +1,128 @@
+# ✍️ 요구사항 정리
+
+## 📋요구사항 대상
+> - 상품 목록 / 상품 상세 / 브랜드 조회
+> - 상품 좋아요 등록 / 취소 / 조회 (멱등 동작)
+> - 주문 생성 및 결제 흐름 (재고 차감, 포인트 차감, 외부 시스템 연동)
+
+---
+
+## 📋유저 스토리
+
+### <상품 조회>
+> - 상품 목록 페이지에 진입 시 상품 정보 목록을 조회한다.
+> - 상품 상세 페이지에 진입 시 상품 상세 정보를 조회한다.
+> - 브랜드 페이지에 진입 시 브랜드 정보를 조회한다.
+
+### <상품 좋아요>
+상품 목록, 상세 페이지에서...
+> - 상품 좋아요 등록하고 취소할 수 있어야 한다.
+> - "좋아요" 표시한 상품 목록 조회하기
+
+### <주문과 결제>
+주문하기 버튼을 클릭 시...
+> - 상품을 주문한다.
+>  - 결제도 같이 이루어진다.
+>  - 주문한 상품의 재고가 차감된다.
+>  - 주문에 사용된 포인트 차감된다. 포인트로만 결제하게 만든다.
+>  - 외부 시스템(eg. PG)과 연동된다. (지금은 mock 로만 구현)
+>  - 여러 상품을 한 번에 주문, 결제할 수 있어야 한다.
+
+마이페이지 > 주문배송조회 페이지에서...
+> - 유저의 주문 목록 조회한다.
+> - 유저의 단일 주문 상세를 조회한다.
+
+---
+
+## 📋기능 흐름
+
+### <상품 조회>
+**상품 목록 조회**
+> 1. 상품 목록 조회 함수 호출
+>   1. 상품 도메인 엔티티를 조회하기
+>   2. 페이지 번호와 상품 개수 조건을 적용하여 조회하기 (페이징)
+>   3. 파라미터에 따라 최신, 최저가, 좋아요 최대수 순으로 정렬하게 하기
+>   4. 브랜드ID가 있으면 그 브랜만 필터링해서 조회하기
+> 2. 200 응답, 상품 목록용 DTO 를 Response 에 담아 보내기
+
+**상품 상세 조회**
+> 1. 상품 상세 조회 함수 호출
+>   - 상품ID에 맞는 상품 도메인 엔티티를 조회하기
+> 2. 200 응답, 상품 상세용 DTO 를 Response 에 담아 보내기
+
+**브랜드 조회**
+> 1. 브랜드 정보 조회 함수 호출
+>   - 브랜드ID의 브랜드 도메인 엔티티를 조회하기
+> 2. 200 응답, 브랜드용 DTO 를 Response 에 담아 보내기
+
+### <상품 좋아요>
+**상품 좋아요 등록**
+> 1. 상품 좋아요 등록 함수 호출
+>   - X-USER-ID 의 유저ID와 상품ID 로 상품 좋아요 객체 생성 후 저장하기
+>   - userId 와 targetId(상품, 브랜드, 셀러 등등) Composite key 를 활용하여 두 번 등록되지 않게 하기
+>   - 그러나 Exception 을 일으키는 것은 아니고 save() 함수로 덮어쓰게 해서 멱등성 확보하기
+> 2. 응답 보내기
+>   - 만약 등록에 성공하면 201 으로 응답하기
+>   - 만약 중복으로 인해 등록에 실패하면 204 로 응답하기
+
+**상품 좋아요 취소**
+> 1. 상품 좋아요 취소 함수 호출
+>   - X-USER-ID 의 유저ID와 상품ID로 상품 좋아요 데이터 삭제하기
+> 2. 응답 보내기
+>   - 만약 삭제에 성공하면 200 로 응답하기
+>   - 만약 이미 삭제되었다면 등록에 실패하면 204 로 응답하기
+>     - 데이터가 있는지 확인하는 JPA 함수 활용해야 할 것 같다.
+
+**"좋아요" 표시한 상품 목록 조회**
+> 1. "좋아요" 표시한 상품 목록 조회 함수 호출
+>   1. X-USER-ID 의 유저ID로 상품 엔티티 목록을 가져오기
+>   2. 페이지 번호와 상품 개수 조건을 적용하여 조회하기 (페이징)
+>   3. 파라미터에 따라 최신, 최저가, 좋아요 최대수 순으로 정렬하게 하기
+>   4. 브랜드ID가 있으면 그 브랜만 필터링해서 조회하기
+> 2. 200 응답, 상품 목록용 DTO 를 Response 에 담아 보내기
+
+### <주문과 결제>
+**상품 주문**
+> 1. 주문 함수 호출
+>   1. X-USER-ID 의 유저ID, 상품ID, 개수로 주문 저장하기
+>      1. 주문 객체 생성 후 저장하기
+>      2. 주문 상품 객체도 생성 후 저장하기
+>   2. 주문한 상품의 재고를 차감하기
+>      - `만약 재고가 없으면 "재고 없음" 메세지와 함께 400 응답을 보내기`
+>   3. 총 결제 금액을 계산한 뒤 유저의 보유 포인트 액수와 비교하기
+>      - 만약 충분하면 포인트 차감
+>      - `결제 실패 시 재고를 다시 채우고 주문 객체의 주문상태를 "결제 실패" 로 변경하고 400 응답을 보내기`
+>   4. 외부 시스템 전송하기 (Mock 처리)
+> 2. 200 응답 보내기
+
+**유저의 주문 목록 조회**
+> 1. 주문 목록 조회 함수 호출
+>   - X-USER-ID 의 유저ID 을 조건으로 주문 객체 리스트를 가져오기
+> 2. 200 응답, 주문 목록용 DTO 를 Response 에 담아 보내기
+
+**유저의 주문 상세 조회**
+> 1. 주문 상세 조회 함수 호출
+>   - X-USER-ID 의 유저ID 을 조건으로 주문 객체를 가져오기
+> 2. 200 응답, 주문 목록용 DTO 를 Response 에 담아 보내기
+
+---
+
+## 📋참고해야 할 조건/예외사항
+> 1. "상품 좋아요"와 "상품 주문/결제"는 로그인해야 만 사용할 수 있다. (X-USER-ID 가 필요함)
+> 2. 목록 조회의 경우, API 의 응답이 빈 객체다. 단일 조회는 Exception 을 일으킨다.
+
+---
+
+## 💬유비쿼터스 언어
+### 도메인 용어 정리
+```
+상품 → Product
+브랜드 → Brand
+좋아요 → Like
+주문 → Order
+주문 상품 -> OrderItem
+결제 -> Payment
+재고 -> Stock
+포인트 -> Point
+회원 -> User
+```
\ No newline at end of file

From 5e1a8eb17786f6183b92e9650ee8a47e2fb3775a Mon Sep 17 00:00:00 2001
From: Yim Yoobin <yoobinbkk@naver.com>
Date: Fri, 7 Nov 2025 09:54:23 +0900
Subject: [PATCH 07/19] =?UTF-8?q?[Round=202]=202.=20API=20=EC=8B=9C?=
 =?UTF-8?q?=ED=80=80=EC=8A=A4=20=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8?=
 =?UTF-8?q?=EB=9E=A8=20(=EC=83=81=ED=92=88=20=EB=AA=A9=EB=A1=9D=20&=20?=
 =?UTF-8?q?=EC=83=81=ED=92=88=20=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../week2/02-sequence-diagrams/productAPI.md  | 63 +++++++++++++++++++
 1 file changed, 63 insertions(+)
 create mode 100644 apps/commerce-api/src/doc/week2/02-sequence-diagrams/productAPI.md

diff --git a/apps/commerce-api/src/doc/week2/02-sequence-diagrams/productAPI.md b/apps/commerce-api/src/doc/week2/02-sequence-diagrams/productAPI.md
new file mode 100644
index 000000000..9cbb9f53a
--- /dev/null
+++ b/apps/commerce-api/src/doc/week2/02-sequence-diagrams/productAPI.md
@@ -0,0 +1,63 @@
+---
+title: Product API Sequence Diagram
+---
+sequenceDiagram
+autonumber
+actor Client
+participant C as ProductController
+participant F as ProductFacade
+participant S as ProductService
+participant R as ProductRepository
+
+    %% 상품 목록 조회
+    Client->>+C: GET /api/v1/products (ProductDto.SearchRequest req)
+    Note left of C: Product 목록 조회 API 호출 (검색 조건 포함)
+    Note left of C: Query Parameters<br/>--------------------<br/>"categoryId":123 or null<br/>"brandId": 123 or null,<br/>"sort": "latest",<br/>"pageIndex": 0,<br/>"pageSize": 20<br/>---------------------
+
+    C->>+F: getProductList(ProductInfo info)
+    F->>+S: getProductList(Product product)
+
+    S->>+R: findProductsCustom(Predicate predicate, Pageable pageable)
+
+    Note right of S: QueryDSL 의 Predicate(BooleanBuilder) 를 활용하여<br/>상품 카테고리와 브랜드 조건으로 List<Product> 를 가져오게 한다.
+    Note right of S: Pageable 객체를 활용해서 페이지 관련 데이터를 받아 Response data 에 담는다.
+    
+    R-->>-S: Optional<List<Product>>
+
+    alt Product 목록이 있는 경우
+        S->>S: List<Product> 객체 반환
+    else Product 목록이 없는 경우
+        S->>S: List<Product> empty 객체 반환
+    end
+    
+    S->>S: List<Product> -> List<ProductInfo>
+    S-->>-F: List<ProductInfo>
+    F-->>-C: List<ProductInfo>
+
+    C-->>-Client: 200 OK (List<ProductDto.InfoResponse> JSON 응답)
+    Note left of C: "meta": { "result": "SUCCESS", "errorCode": null, "message": null },<br/>"data": {<br/>"content": [<br/>{<br/>"id": 10,<br/>"brand": { "id": 1, "name": "Loopers" },<br/>"name": "에센셜 코튼 티셔츠",<br/>"description": "부드러운 촉감의 데일리 티셔츠",<br/>"status": "ACTIVE",<br/>"price": 19900,<br/>"isVisible": true,<br/> "isSellable": true,<br/>"stockQuantity": 42,<br/>"likesCount": 128<br/>},<br/>{<br/>"id": 11,<br/>"brand": { "id": 1, "name": "Loopers" },<br/>"name": "라이트 웨이트 조거 팬츠",<br/>"description": "가벼운 착용감의 조거 팬츠",<br/>"status": "ACTIVE",<br/>"price": 39900,<br/>"isVisible": true,<br/>"isSellable": true,<br/>"stockQuantity": 17,<br/>"likesCount": 64<br/>}<br/>],<br/>"page": {<br/>"page": 0,<br/>"size": 20,<br/>"totalElements": 312,<br/>"totalPages": 16,<br/>"sort": "latest"<br/>}
+
+    %% 상품 상세 조회
+    Client->>+C: GET /api/v1/products/{productId}
+    Note left of C: Product 상세 조회 API 호출
+    Note left of C: Path Variable<br/>--------------------<br/>"productId":123<br/>---------------------
+
+    C->>+F: getProductDetail(Long productId)
+    F->>+S: getProductDetail(Long productId)
+    S->>+R: findById(Long productId)
+    
+    R-->>-S: Optional<Product>
+
+    rect rgb(20,20,0)
+        alt Product가 존재하지 않는 경우
+            S-->>Client: NullPointerException(message)
+            Note left of C: "meta": { "result": "FAIL",<br/>"errorCode": NOT_FOUND,<br/>"message": "상품을 찾지 못했습니다." },<br/>"data": {}
+        end
+    end
+    
+    S->>S: Product -> ProductDetailInfo
+    S-->>-F: ProductDetailInfo
+    F-->>-C: ProductDetailInfo
+
+    C-->>-Client: 200 OK (ProductDto.DetailResponse JSON 응답)
+    Note left of C: "meta": { "result": "SUCCESS", "errorCode": null, "message": null },<br/>"data": {<br/> "id": 10,<br/>"brand": {<br/>"id": 1,<br/>"name": "Loopers",<br/>"status": "ACTIVE"<br/>},<br/>"name": "에센셜 코튼 티셔츠",<br/>"description": "부드러운 촉감의 데일리 티셔츠",<br/>"status": "ACTIVE",<br/>"price": 19900,<br/>"isVisible": true,<br/>"isSellable": true,<br/>"stockQuantity": 42,<br/>"likesCount": 128,<br/>"liked": false<br/>}

From e752a7d5983c3c2da887c58847defdde06925fa9 Mon Sep 17 00:00:00 2001
From: Yim Yoobin <yoobinbkk@naver.com>
Date: Fri, 7 Nov 2025 09:55:42 +0900
Subject: [PATCH 08/19] =?UTF-8?q?[Round=202]=202.=20API=20=EC=8B=9C?=
 =?UTF-8?q?=ED=80=80=EC=8A=A4=20=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8?=
 =?UTF-8?q?=EB=9E=A8=20(=EB=B8=8C=EB=9E=9C=EB=93=9C=20=EC=A1=B0=ED=9A=8C)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../week2/02-sequence-diagrams/brandAPI.md    | 34 +++++++++++++++++++
 1 file changed, 34 insertions(+)
 create mode 100644 apps/commerce-api/src/doc/week2/02-sequence-diagrams/brandAPI.md

diff --git a/apps/commerce-api/src/doc/week2/02-sequence-diagrams/brandAPI.md b/apps/commerce-api/src/doc/week2/02-sequence-diagrams/brandAPI.md
new file mode 100644
index 000000000..7e66f0fce
--- /dev/null
+++ b/apps/commerce-api/src/doc/week2/02-sequence-diagrams/brandAPI.md
@@ -0,0 +1,34 @@
+---
+title: Brand API Sequence Diagram
+---
+sequenceDiagram
+    autonumber
+    actor Client
+    participant C as BrandController
+    participant F as BrandFacade
+    participant S as BrandService
+    participant R as BrandRepository
+
+    Client->>+C: GET /api/v1/brands/{brandId}
+    Note left of C: Brand 상세 조회 API 호출 (brandId)
+    Note left of C: Path Variable<br/>--------------------<br/>"brandId":123<br/>---------------------
+
+    C->>+F: getBrandDetail(Long brandId)
+    F->>+S: getBrandDetail(Long brandId)
+
+    S->>+R: findById(Long brandId)
+    R-->>-S: Optional<Brand>
+
+    rect rgb(20,20,0)
+        alt Brand가 존재하지 않는 경우
+            S-->>Client: NullPointerException(message)
+            Note left of C: "meta": { "result": "FAIL",<br/>"errorCode": NOT_FOUND,<br/>"message": "브랜드를 찾지 못했습니다." },<br/>"data": {}
+        end
+    end    
+
+    S->>S: Brand -> BrandDetailInfo
+    S-->>-F: BrandDetailInfo
+    F-->>-C: BrandDetailInfo
+
+    C-->>-Client: 200 OK (BrandDto.DetailResponse JSON 응답)
+    Note left of C: "meta": { "result": "SUCCESS", "errorCode": null, "message": null },<br/>"data": {<br/>"id": 1,<br/>"name": "Loopers",<br/>"description": "일상을 더 편하게 만드는 루퍼스",<br/>"status": "ACTIVE",<br/>"isVisible": true,<br/>"isSellable": true<br/>}<br/>
\ No newline at end of file

From b334862801f753e52070a214a695735a65a53962 Mon Sep 17 00:00:00 2001
From: Yim Yoobin <yoobinbkk@naver.com>
Date: Fri, 7 Nov 2025 09:56:51 +0900
Subject: [PATCH 09/19] =?UTF-8?q?[Round=202]=202.=20API=20=EC=8B=9C?=
 =?UTF-8?q?=ED=80=80=EC=8A=A4=20=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8?=
 =?UTF-8?q?=EB=9E=A8=20(=EC=83=81=ED=92=88=20=EC=A2=8B=EC=95=84=EC=9A=94?=
 =?UTF-8?q?=20=EB=93=B1=EB=A1=9D/=EC=B7=A8=EC=86=8C)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../doc/week2/02-sequence-diagrams/likeAPI.md | 44 +++++++++++++++++++
 1 file changed, 44 insertions(+)
 create mode 100644 apps/commerce-api/src/doc/week2/02-sequence-diagrams/likeAPI.md

diff --git a/apps/commerce-api/src/doc/week2/02-sequence-diagrams/likeAPI.md b/apps/commerce-api/src/doc/week2/02-sequence-diagrams/likeAPI.md
new file mode 100644
index 000000000..f63e46114
--- /dev/null
+++ b/apps/commerce-api/src/doc/week2/02-sequence-diagrams/likeAPI.md
@@ -0,0 +1,44 @@
+---
+title: Like API Sequence Diagram
+---
+sequenceDiagram
+    autonumber
+    actor Client
+    participant C as LikeController
+    participant F as LikeFacade
+    participant S as LikeService
+    participant R as LikeRepository
+
+    %% --- 상품 좋아요 등록 로직 시작 ---
+    Client->>+C: POST /api/v1/like/products/{productId} (Header: X-USER-ID)
+    Note left of C: Product 좋아요 등록 API 호출 (유저ID, 상품ID)
+    Note left of C: Path Variable: "productId":123<br/>Header:"userId":123
+
+    C->>+F: addProductLike(String userId, Long productId)
+    F->>+S: addProductLike(String userId, Long productId)
+
+    S->>+R: save(Like likeEntity)
+    Note right of S: 중복 등록 시 덮어씀으로 데이터 무결점 확보, 멱등성 실천
+    
+    S-->>-F:
+    F-->>-C:
+    
+    C-->>-Client: 200 OK
+    Note left of C: "meta": { "result": "SUCCESS", "errorCode": null, "message": null }
+
+    %% --- 상품 좋아요 취소 로직 시작 ---
+    Client->>+C: DELETE /api/v1/like/products/{productId} (Header: X-USER-ID)
+    Note left of C: Product 좋아요 취소 API 호출 (유저ID, 상품ID)
+    Note left of C: Path Variable: "productId":123<br/>Header:"userId":123
+
+    C->>+F: deleteProductLike(String userId, Long productId)
+    F->>+S: deleteProductLike(String userId, Long productId)
+
+    S->>+R: deleteByUserIdAndTargetId(String userId, Long productId)
+    Note right of S: 회원과 상품 ID에 해당하는 Like 만 삭제함으로<br/>중복 삭제 시 아무런 일도 일어나지 않음, 멱등성 실천
+
+    S-->>-F:
+    F-->>-C:
+    
+    C-->>-Client: 200 OK
+    Note left of C: "meta": { "result": "SUCCESS", "errorCode": null, "message": null }
\ No newline at end of file

From 2e04f58a0a25555e05399b5c697bffdb2fa0c5d4 Mon Sep 17 00:00:00 2001
From: Yim Yoobin <yoobinbkk@naver.com>
Date: Fri, 7 Nov 2025 09:57:29 +0900
Subject: [PATCH 10/19] =?UTF-8?q?[Round=202]=202.=20API=20=EC=8B=9C?=
 =?UTF-8?q?=ED=80=80=EC=8A=A4=20=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8?=
 =?UTF-8?q?=EB=9E=A8=20(=EC=A2=8B=EC=95=84=ED=95=98=EB=8A=94=20=EC=83=81?=
 =?UTF-8?q?=ED=92=88=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../02-sequence-diagrams/likeProductAPI.md    | 30 +++++++++++++++++++
 1 file changed, 30 insertions(+)
 create mode 100644 apps/commerce-api/src/doc/week2/02-sequence-diagrams/likeProductAPI.md

diff --git a/apps/commerce-api/src/doc/week2/02-sequence-diagrams/likeProductAPI.md b/apps/commerce-api/src/doc/week2/02-sequence-diagrams/likeProductAPI.md
new file mode 100644
index 000000000..3d78991bb
--- /dev/null
+++ b/apps/commerce-api/src/doc/week2/02-sequence-diagrams/likeProductAPI.md
@@ -0,0 +1,30 @@
+---
+title: LikeProduct API Sequence Diagram
+---
+sequenceDiagram
+    autonumber
+    actor Client
+    participant C as LikeController
+    participant F as LikeFacade
+    participant S as LikeService
+    participant R as LikeRepository
+
+    %% --- "좋아요" 상품 목록 가져오기 로직 시작 ---
+    Client->>+C: GET /api/v1/like/products (Header: X-USER-ID, ProductDto.SearchConditionRequest req)
+    Note left of C: "좋아요" 상품 목록 조회 API 호출 (유저ID, 검색 조건 포함)
+    Note left of C: Query Parameters<br/>--------------------<br/>"categoryId":123 or null<br/>"brandId": 123 or null,<br/>"sort": "latest",<br/>"pageIndex": 0,<br/>"pageSize": 20<br/>---------------------
+
+    C->>+F: getLikedProducts(String userId, ProductInfo info)
+    F->>+S: getLikedProducts(String userId, ProductInfo info)
+
+    S->>+R: findLikedProductsByConditions(<br/>Long userId,<br/>LikeSearchCondition condition,<br/> Pageable pageable)<br/>)
+    Note right of S: QueryDSL 의 JPAQueryFactory 를 통해 동적 쿼리 활용<br/>상품 필터 및 페이징 조건을 적용하여 조회
+
+    R-->>-S: List<Product> products
+    
+    S->>S: List<Product> -> List<ProductInfo>
+    S-->>-F: List<ProductInfo>
+    F-->>-C: List<ProductInfo>
+
+    C-->>-Client: 200 OK (List<ProductDto.InfoResponse> JSON 응답)
+    Note left of C: "meta": { "result": "SUCCESS", "errorCode": null, "message": null },<br/>"data": {<br/>"content": [<br/>{<br/>"id": 10,<br/>"brand": { "id": 1, "name": "Loopers" },<br/>"name": "에센셜 코튼 티셔츠",<br/>"description": "부드러운 촉감의 데일리 티셔츠",<br/>"status": "ACTIVE",<br/>"price": 19900,<br/>"isVisible": true,<br/> "isSellable": true,<br/>"stockQuantity": 42,<br/>"likesCount": 128<br/>},<br/>{<br/>"id": 11,<br/>"brand": { "id": 1, "name": "Loopers" },<br/>"name": "라이트 웨이트 조거 팬츠",<br/>"description": "가벼운 착용감의 조거 팬츠",<br/>"status": "ACTIVE",<br/>"price": 39900,<br/>"isVisible": true,<br/>"isSellable": true,<br/>"stockQuantity": 17,<br/>"likesCount": 64<br/>}<br/>],<br/>"page": {<br/>"page": 0,<br/>"size": 20,<br/>"totalElements": 312,<br/>"totalPages": 16,<br/>"sort": "latest"<br/>}

From 612f8480917955dcba74131448835252f0a97213 Mon Sep 17 00:00:00 2001
From: Yim Yoobin <yoobinbkk@naver.com>
Date: Fri, 7 Nov 2025 10:03:44 +0900
Subject: [PATCH 11/19] =?UTF-8?q?[Round=202]=203.=20=ED=81=B4=EB=9E=98?=
 =?UTF-8?q?=EC=8A=A4=20=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

클래스 목록
 - 회원(User)
 - 포인트(Point)
 - 상품(Product)
 - 상품 카테고리(ProductCategory)
 - 재고(Stock)
 - 브랜드(Brand)
 - 좋아요(Like)
 - 주문(Order)
 - 주문상품(OrderItem)
 - 결제(Payment)
 - 페이지 객체(PageDto)
---
 .../week2/03-class-diagram/class-diagram.md   | 105 ++++++++++++++++++
 1 file changed, 105 insertions(+)
 create mode 100644 apps/commerce-api/src/doc/week2/03-class-diagram/class-diagram.md

diff --git a/apps/commerce-api/src/doc/week2/03-class-diagram/class-diagram.md b/apps/commerce-api/src/doc/week2/03-class-diagram/class-diagram.md
new file mode 100644
index 000000000..cddaad364
--- /dev/null
+++ b/apps/commerce-api/src/doc/week2/03-class-diagram/class-diagram.md
@@ -0,0 +1,105 @@
+---
+title: E-Commerce Class Diagram
+---
+classDiagram
+    class User {
+        Long id
+        String loginId
+        String email
+        String birthday
+        Enum gender
+        Point point
+    }
+    class Point {
+        Long id
+        User user
+        Long amount
+        Enum grade
+    }
+    class Product {
+        Long id
+        ProductCategory productCategory
+        Stock stock;
+        Brand brand
+        String name
+        String description
+        Enum status
+        Long price
+        Boolean isVisible
+        Boolean isSellable
+    }
+    class ProductCategory {
+        Long id
+        Long parentId
+        List<Product> products
+        String name
+        String description
+        Boolean isVisible
+        Boolean isSellable
+    }
+    class Stock {
+        Long id
+        Product product
+        Long quantity
+    }
+    class Brand {
+        Long id
+        List<Product> products
+        String name
+        String description
+        Enum status
+        Boolean isVisible
+        Boolean isSellable
+    }
+    class Like {
+        Long id
+        User user
+        Product product
+    }
+    class Order {
+        Long id
+        User user
+        List<OrderItem> orderItems
+        Enum status
+        Long finalAmount
+        Long totalPrice
+        Long discountAmount
+        Long shippingFee
+    }
+    class OrderItem {
+        Long id
+        Order order
+        Product product
+        Integer quantity
+        Long unitPrice
+        Long totalAmount
+    }
+    class Payment {
+        Long id
+        Order order
+        String pgTransactionId
+        Enum method
+        Long amount
+        Enum status
+    }
+    class PageDto {
+        List<T> elements
+        Integer index
+        Integer number
+        Integer size
+        Integer totalElements
+        Integer totalPages
+        String sort
+    }
+
+    User <-- Point
+    User <-- Like
+    User <-- Order
+    Product <-- Like
+    Product <-- Stock
+    Product <-- OrderItem
+    Brand <-- Product
+    ProductCategory <-- Product
+    Order <-- OrderItem
+    Order <-- Payment
+    
\ No newline at end of file

From 6013fb766e22ec14401beaaff90e16f8f29e9beb Mon Sep 17 00:00:00 2001
From: Yim Yoobin <yoobinbkk@naver.com>
Date: Fri, 7 Nov 2025 10:10:00 +0900
Subject: [PATCH 12/19] [Round 2] 4. ERD
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

엔티티 목록
 - 회원(user)
 - 포인트(point)
 - 상품(product)
 - 상품 카테고리(product_category)
 - 재고(stock)
 - 브랜드(brand)
 - 좋아요(like)
 - 주문(order)
 - 주문상품(order_item)
 - 결제(payment)
---
 apps/commerce-api/src/doc/week2/04-erd/erd.md | 90 +++++++++++++++++++
 1 file changed, 90 insertions(+)
 create mode 100644 apps/commerce-api/src/doc/week2/04-erd/erd.md

diff --git a/apps/commerce-api/src/doc/week2/04-erd/erd.md b/apps/commerce-api/src/doc/week2/04-erd/erd.md
new file mode 100644
index 000000000..100c7813a
--- /dev/null
+++ b/apps/commerce-api/src/doc/week2/04-erd/erd.md
@@ -0,0 +1,90 @@
+---
+title: E-Commerce ERD
+---
+erDiagram
+    user {
+        bigint id PK
+        varchar login_id UK
+        varchar email UK
+        varchar birthday
+        enum gender
+    }
+    point {
+        bigint id PK
+        bigint user_id FK
+        bigint amount
+        enum grade
+    }
+    product {
+        bigint id PK
+        bigint brand_id FK
+        bigint stock_id FK
+        varchar name
+        varchar description
+        enum status
+        bigint price
+        boolean is_visible
+        boolean is_sellable
+    }
+    product_category {
+        bigint id PK
+        bigint parent_id
+        varchar name
+        varchar description
+        boolean is_visible
+        boolean is_sellable
+    }
+    stock {
+        bigint id PK
+        bigint product_id FK
+        bigint quantity
+    }
+    brand {
+        bigint id PK
+        varchar name
+        varchar description
+        Enum status
+        boolean is_visible
+        boolean is_sellable
+    }
+    like {
+        bigint id PK
+        bigint user_id FK
+        bigint product_id FK
+    }
+    order {
+        bigint id PK
+        bigint user_id FK
+        enum status
+        bigint final_amount
+        bigint total_price
+        bigint discount_amount
+        bigint shipping_fee
+    }
+    order_item {
+        bigint id PK
+        bigint order_id FK
+        bigint product_id FK
+        int quantity
+        bigint unit_price
+        bigint total_amount
+    }
+    payment {
+        bigint id PK
+        bigint order_id FK
+        varchar pg_transaction_id
+        enum method
+        bigint amount
+        enum status
+    }
+
+    user ||--|| point : ""
+    user ||--o{ like : ""
+    user ||--o{ order : ""
+    product ||--o{ like : ""
+    product ||--|| stock : ""
+    product ||--o{ order_item : ""
+    brand ||--o{ product : ""
+    product_category ||--o{ product : ""
+    order ||--|{ order_item : ""
+    order ||--|| payment : ""
\ No newline at end of file

From e376888f33967141aa8d09f0cda56faeb1277314 Mon Sep 17 00:00:00 2001
From: Yim Yoobin <yoobinbkk@naver.com>
Date: Fri, 14 Nov 2025 01:29:59 +0900
Subject: [PATCH 13/19] =?UTF-8?q?User,=20Point=20=EB=A6=AC=ED=8C=A9?=
 =?UTF-8?q?=ED=86=A0=EB=A7=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../application/point/PointFacade.java        |  34 +++
 .../loopers/application/point/PointInfo.java  |  24 +++
 .../loopers/application/user/UserFacade.java  |  68 ++++--
 .../loopers/application/user/UserInfo.java    |  30 ++-
 .../java/com/loopers/domain/point/Point.java  |  67 ++++++
 .../loopers/domain/point/PointRepository.java |  10 +
 .../loopers/domain/point/PointService.java    |  24 +++
 .../java/com/loopers/domain/user/Gender.java  |   5 +
 .../java/com/loopers/domain/user/User.java    |  71 ++++---
 .../com/loopers/domain/user/UserService.java  |   7 +-
 .../point/PointJpaRepository.java             |  10 +
 .../point/PointRepositoryImpl.java            |  27 +++
 .../user/UserRepositoryImpl.java              |   1 +
 .../interfaces/api/point/PointApiSpec.java    |  43 ++++
 .../interfaces/api/point/PointController.java |  34 +++
 .../interfaces/api/point/PointDto.java        |  25 +++
 .../interfaces/api/user/UserApiSpec.java      |  24 ---
 .../interfaces/api/user/UserController.java   |  33 +--
 .../loopers/interfaces/api/user/UserDto.java  |   8 +-
 .../com/loopers/support/config/WebConfig.java |  20 ++
 .../support/error/CoreExceptionUtil.java      |  33 ---
 .../interceptor/AccessControlInterceptor.java |  33 +++
 .../domain/point/PointIntegrationTest.java    |  63 ++++++
 .../com/loopers/domain/point/PointTest.java   |  70 ++++++
 .../com/loopers/domain/user/UserE2ETest.java  | 198 -----------------
 .../domain/user/UserIntegrationTest.java      |  69 ++----
 .../com/loopers/domain/user/UserTest.java     |  88 ++------
 .../loopers/interfaces/api/PointE2ETest.java  | 200 ++++++++++++++++++
 .../loopers/interfaces/api/UserE2ETest.java   | 109 ++++++++++
 29 files changed, 955 insertions(+), 473 deletions(-)
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/point/PointInfo.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/point/PointRepository.java
 create 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/Gender.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointJpaRepository.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointRepositoryImpl.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointApiSpec.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointController.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointDto.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/support/config/WebConfig.java
 delete mode 100644 apps/commerce-api/src/main/java/com/loopers/support/error/CoreExceptionUtil.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/support/interceptor/AccessControlInterceptor.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/point/PointIntegrationTest.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/point/PointTest.java
 delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/UserE2ETest.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointE2ETest.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserE2ETest.java

diff --git a/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java
new file mode 100644
index 000000000..7c2131ede
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java
@@ -0,0 +1,34 @@
+package com.loopers.application.point;
+
+import com.loopers.domain.point.Point;
+import com.loopers.domain.point.PointService;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import java.math.BigDecimal;
+
+@RequiredArgsConstructor
+@Component
+public class PointFacade {
+
+    private final PointService pointService;
+
+    // 포인트 조회
+    public PointInfo getPoint(String loginId){
+        Point point = pointService.findByUserLoginId(loginId)
+                .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[loginId = " + loginId + "] Point를 찾을 수 없습니다."));
+        return PointInfo.from(point);
+    }
+
+    // 포인트 충전
+    public PointInfo charge(String loginId, BigDecimal chargeAmount) {
+        Point point = pointService.findByUserLoginId(loginId)
+                .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[loginId = " + loginId + "] 포인트를 충전할 Point 객체를 찾을 수 없습니다."));
+        point.charge(chargeAmount);
+        Point savedPoint = pointService.savePoint(point)
+                .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[loginId = " + loginId + "] 포인트를 충전 후 Point 객체를 찾을 수 없습니다."));
+        return PointInfo.from(savedPoint);
+    }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/point/PointInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/point/PointInfo.java
new file mode 100644
index 000000000..303444b62
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/point/PointInfo.java
@@ -0,0 +1,24 @@
+package com.loopers.application.point;
+
+import com.loopers.domain.point.Point;
+import com.loopers.interfaces.api.point.PointDto;
+import lombok.Builder;
+
+import java.math.BigDecimal;
+
+@Builder
+public record PointInfo(
+        BigDecimal amount
+) {
+    public static PointInfo from(Point point) {
+        return PointInfo.builder()
+                .amount(point.getAmount())
+                .build();
+    }
+
+    public static PointInfo from(PointDto.PointRequest pointRequest) {
+        return PointInfo.builder()
+                .amount(pointRequest.amount())
+                .build();
+    }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java
index 70011d90c..400384475 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java
@@ -1,5 +1,7 @@
 package com.loopers.application.user;
 
+import com.loopers.domain.point.Point;
+import com.loopers.domain.point.PointService;
 import com.loopers.domain.user.User;
 import com.loopers.domain.user.UserService;
 import com.loopers.support.error.CoreException;
@@ -8,50 +10,70 @@
 import org.springframework.stereotype.Component;
 import org.springframework.transaction.annotation.Transactional;
 
+import java.math.BigDecimal;
 import java.util.Optional;
 
 @RequiredArgsConstructor
 @Component
 public class UserFacade {
+
     private final UserService userService;
+    private final PointService pointService;
 
+    /**
+     * 회원가입 (User 저장하기)
+     * @param userInfo
+     * @return
+     */
     @Transactional
     public UserInfo saveUser(UserInfo userInfo) {
-        Optional<User> foundUser = userService.findUserById(userInfo.loginId());
-        if(foundUser.isPresent()) {
+
+        // 이미 존재하는 회원 객체가 있는지 확인
+        Optional<User> foundUser = userService.findUserByLoginId(userInfo.loginId());
+        if(foundUser.isPresent()){
             throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 회원을 다시 저장 못합니다.");
         }
 
+        // 이미 존재하는 회원 포인트 객체가 있는지 확인
+        Optional<Point> foundPoint = pointService.findByUserLoginId(userInfo.loginId());
+        if(foundPoint.isPresent()){
+            throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 회원의 포인트를 다시 저장 못합니다.");
+        }
+
+        // 포인트 객체 생성 (User 객체는 NULL)
+        Point point = Point.builder()
+                .user(null)
+                .amount(BigDecimal.valueOf(0))
+                .build();
+
+        // 회원 객체 생성 (point 참조)
         User user = User.builder()
                 .loginId(userInfo.loginId())
                 .email(userInfo.email())
                 .birthday(userInfo.birthday())
                 .gender(userInfo.gender())
-                .point(userInfo.point())
+                .point(point)
                 .build();
-        Optional<User> optSavedUser = userService.saveUser(user);
-        return UserInfo.from(optSavedUser);
-    }
 
-    @Transactional(readOnly = true)
-    public UserInfo getUser(String loginId){
-        Optional<User> optUser = userService.findUserById(loginId);
-        return UserInfo.from(optUser);
-    }
+        // 회원 저장 (Cascade로 Point도 함께 저장됨)
+        User savedUser = userService.saveUser(user)
+                .orElseThrow(() -> new CoreException(ErrorType.INTERNAL_ERROR, "User 를 저장하지 못했습니다. (참조된 포인트 객체와 함께 저장)"));
 
-    @Transactional(readOnly = true)
-    public Integer getUserPoint(String loginId){
-        User user = userService.findUserById(loginId)
-                .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[loginId = " + loginId + "] User를 찾을 수 없습니다."));
-        return user.getPoint();
+        // Point의 user 참조 설정 (영속성 컨텍스트가 자동으로 UPDATE)
+        point.setUser(savedUser);
+
+        return UserInfo.from(savedUser);
     }
 
-    @Transactional
-    public Integer addUserPoint(String loginId, Integer userPoint) {
-        User user = userService.findUserById(loginId)
-                .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[loginId = " + loginId + "] point를 충전할 User를 찾을 수 없습니다."));
-        user.addPoint(userPoint);
-        Optional<User> savedUser = userService.saveUser(user);
-        return savedUser.get().getPoint();
+    /**
+     * 회원 조회 (User 정보 가져오기)
+     * @param loginId
+     * @return
+     */
+    @Transactional(readOnly = true)
+    public UserInfo getUser(String loginId){
+        User user = userService.findUserByLoginId(loginId)
+                .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[loginId = " + loginId + "] User 를 찾지 못했습니다."));
+        return UserInfo.from(user);
     }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java
index 497cb0a59..65af7c44a 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java
@@ -1,32 +1,27 @@
 package com.loopers.application.user;
 
+import com.loopers.domain.point.Point;
+import com.loopers.domain.user.Gender;
 import com.loopers.domain.user.User;
 import com.loopers.interfaces.api.user.UserDto;
 import lombok.Builder;
 
-import java.util.Optional;
-
 @Builder
 public record UserInfo(
         String loginId
         , String email
         , String birthday
-        , String gender
-        , Integer point
+        , Gender gender
+        , Point point
 ) {
-    public static UserInfo from(Optional<User> optUser) {
-        if(optUser.isPresent()) {
-            User user = optUser.get();
-            return UserInfo.builder()
-                    .loginId(user.getLoginId())
-                    .email(user.getEmail())
-                    .birthday(user.getBirthday())
-                    .gender(user.getGender())
-                    .point(user.getPoint())
-                    .build();
-        }
-
-        return null;
+    public static UserInfo from(User user) {
+        return UserInfo.builder()
+                .loginId(user.getLoginId())
+                .email(user.getEmail())
+                .birthday(user.getBirthday())
+                .gender(user.getGender())
+                .point(user.getPoint())
+                .build();
     }
 
     public static UserInfo from(UserDto.CreateUserRequest cur) {
@@ -35,7 +30,6 @@ public static UserInfo from(UserDto.CreateUserRequest cur) {
                 .email(cur.email())
                 .birthday(cur.birthday())
                 .gender(cur.gender())
-                .point(cur.point())
                 .build();
     }
 
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
new file mode 100644
index 000000000..04e08630b
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java
@@ -0,0 +1,67 @@
+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.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.math.BigDecimal;
+
+@Entity
+@Table(name = "point")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Getter
+public class Point extends BaseEntity {
+
+    private BigDecimal amount;
+
+    @OneToOne(fetch = FetchType.LAZY)
+    @JoinColumn(name = "user_id")
+    private User user;
+
+    @Builder
+    private Point(
+            BigDecimal amount
+            , User user
+    ) {
+        this.amount = amount;
+        this.user = user;
+        guard();
+    }
+
+    // User 필드를 세팅
+    public void setUser(User user) {
+        if(this.user != null) return;
+        this.user = user;
+    }
+
+    // 유효성 검사
+    @Override
+    protected void guard() {
+        // amount : Null 혹은 음수인지 검사
+        if(amount == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "Point : amount가 Null 이 되면 안 됩니다.");
+        } else if(amount.compareTo(BigDecimal.ZERO) < 0) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "Point : amount는 음수가 될 수 없습니다.");
+        }
+
+        // user : Null 인지 검사
+//        if(user == null) {
+//            throw new CoreException(ErrorType.BAD_REQUEST, "Point : user가 Null 이 되면 안 됩니다.");
+//        }
+    }
+
+    public BigDecimal charge(BigDecimal amount) {
+        // 충전하는 포인트가 음수이면 BAD REQUEST CoreException 을 발생
+        if(amount.compareTo(BigDecimal.ZERO) <= 0) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "충전할 point는 0 이하가 될 수 없습니다.");
+        }
+        this.amount = this.amount.add(amount);
+        return this.amount;
+    }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointRepository.java
new file mode 100644
index 000000000..9f420aa22
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointRepository.java
@@ -0,0 +1,10 @@
+package com.loopers.domain.point;
+
+import com.loopers.domain.user.User;
+
+import java.util.Optional;
+
+public interface PointRepository {
+    Optional<Point> findByUser_loginId(String loginId);
+    Optional<Point> save(Point point);
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java
new file mode 100644
index 000000000..2d53242bf
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java
@@ -0,0 +1,24 @@
+package com.loopers.domain.point;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Optional;
+
+@RequiredArgsConstructor
+@Component
+public class PointService {
+
+    private final PointRepository pointRepository;
+
+    @Transactional(readOnly = true)
+    public Optional<Point> findByUserLoginId(String loginId) {
+        return pointRepository.findByUser_loginId(loginId);
+    }
+
+    @Transactional
+    public Optional<Point> savePoint(Point point) {
+        return pointRepository.save(point);
+    }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/Gender.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/Gender.java
new file mode 100644
index 000000000..3d90a80df
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/Gender.java
@@ -0,0 +1,5 @@
+package com.loopers.domain.user;
+
+public enum Gender {
+    MALE,FEMALE,OTHER;
+}
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 28ccfeb9a..3a93aa7a4 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java
@@ -1,36 +1,36 @@
 package com.loopers.domain.user;
 
 import com.loopers.domain.BaseEntity;
+import com.loopers.domain.point.Point;
 import com.loopers.support.error.CoreException;
 import com.loopers.support.error.ErrorType;
-import jakarta.persistence.Entity;
-import jakarta.persistence.Table;
+import jakarta.persistence.*;
 import lombok.AccessLevel;
 import lombok.Builder;
 import lombok.Getter;
 import lombok.NoArgsConstructor;
 
-import static com.loopers.support.error.CoreExceptionUtil.*;
-
 @Entity
 @Table(name = "user")
 @NoArgsConstructor(access = AccessLevel.PROTECTED)
 @Getter
-@Builder(toBuilder = true)
 public class User extends BaseEntity {
 
     private String loginId;
     private String email;
     private String birthday;
-    private String gender;
-    private Integer point;
+    private Gender gender;
+
+    @OneToOne(mappedBy = "user", cascade = CascadeType.PERSIST)
+    private Point point;
 
+    @Builder
     private User(
             String loginId
             , String email
             , String birthday
-            , String gender
-            , Integer point
+            , Gender gender
+            , Point point
     ) {
         this.loginId = loginId;
         this.email = email;
@@ -40,34 +40,49 @@ private User(
         this.guard();
     }
 
+    // 포인트 필드를 세팅 (초기화 위해 있음)
+    public void setPoint(Point point) {
+        if(this.point != null) {
+            throw new CoreException(ErrorType.CONFLICT, "User : Point 가 이미 존재합니다.");
+        }
+        this.point = point;
+    }
+
+    // 유효성 검사
     @Override
     protected void guard() {
         // id : 영문, 숫자가 각각 최소 한 개씩 포함된 10자 이내 문자열 검사
-        validateNullOrBlank(loginId, "ID가 비어있을 수 없습니다.");
-        validatePattern(loginId, "^(?=.*[a-zA-Z])(?=.*[0-9])[a-zA-Z0-9]{1,10}$", "ID에 영문과 숫자만 10자 이내로 꼭 포함되어 있어야 합니다.");
+        if(loginId == null || loginId.isBlank()) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "User : ID가 비어있을 수 없습니다.");
+        } else if(!loginId.matches("^(?=.*[a-zA-Z])(?=.*[0-9])[a-zA-Z0-9]{1,10}$")) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "User : ID가 비어있을 수 없습니다.ID에 영문과 숫자만 10자 이내로 꼭 포함되어 있어야 합니다.");
+        }
 
         // email : `xx@yy.zz` 형식에 맞는지 검사
-        validateNullOrBlank(email, "email이 비어있을 수 없습니다.");
-        validatePattern(email, "^[a-zA-Z0-9]+@[a-zA-Z0-9]+\\.[a-zA-Z]{2,6}$", "email이 `xx@yy.zz` 형식에 맞아야 합니다.");
+        if(email == null || email.isBlank()) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "User : email이 비어있을 수 없습니다.");
+        } else if(!email.matches("^[a-zA-Z0-9]+@[a-zA-Z0-9]+\\.[a-zA-Z]{2,6}$")) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "User : ID가 비어있을 수 없습니다.email이 `xx@yy.zz` 형식에 맞아야 합니다.");
+        }
 
         // birthday : `yyyy-MM-dd` 형식에 맞는지 검사
-        validateNullOrBlank(birthday, "birthday가 비어있을 수 없습니다.");
-        validatePattern(birthday, "^\\d{4}-\\d{2}-\\d{2}$", "birthday가 `yyyy-MM-dd` 형식에 맞아야 합니다.");
-
-        // gender : `M` or `F` 형식에 맞는지 검사
-        validateNullOrBlank(gender, "gender가 비어있을 수 없습니다.");
-        validatePattern(gender, "^[FM]{1}$", "gender가 `M` or `F` 형식에 맞아야 합니다.");
+        if(birthday == null || birthday.isBlank()) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "User : birthday가 비어있을 수 없습니다.");
+        } else if(!birthday.matches("^\\d{4}-\\d{2}-\\d{2}$")) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "User : ID가 비어있을 수 없습니다.birthday가 `yyyy-MM-dd` 형식에 맞아야 합니다.");
+        }
 
-        // point : Null 이 되면 안 됩니다.
-        validateObjectNull(point, "point가 Null 이 되면 안 됩니다.");
-        if(point < 0) throw new CoreException(ErrorType.BAD_REQUEST, "point는 음수가 될 수 없습니다.");
-    }
+        // gender : NULL 이면 안 됨
+        if(gender == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "User : gender가 NULL일 수 없습니다.");
+        }
+        //throw new CoreException(ErrorType.BAD_REQUEST, "gender가 `MALE`, `FEMALE`, `OTHER` 형식에 맞아야 합니다.");
+        //this.gender = Gender.valueOf(gender.toUpperCase());
 
-    public Integer addPoint(Integer point) {
-        // 충전하는 포인트가 음수이면 BAD REQUEST CoreException 을 발생
-        if(point <= 0) throw new CoreException(ErrorType.BAD_REQUEST, "충전할 point는 0 이하가 될 수 없습니다.");
-        this.point += point;
-        return this.point;
+        // point : NULL 이면 안 됨
+//        if(point == null) {
+//            throw new CoreException(ErrorType.BAD_REQUEST, "User : 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 dfb2f6035..5df001b08 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java
@@ -2,6 +2,7 @@
 
 import lombok.RequiredArgsConstructor;
 import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
 
 import java.util.Optional;
 
@@ -11,11 +12,13 @@ public class UserService {
 
     private final UserRepository userRepository;
 
+    @Transactional
     public Optional<User> saveUser(User user) {
         return userRepository.save(user);
     }
 
-    public Optional<User> findUserById(String id) {
-        return userRepository.findByLoginId(id);
+    @Transactional(readOnly = true)
+    public Optional<User> findUserByLoginId(String loginId) {
+        return userRepository.findByLoginId(loginId);
     }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointJpaRepository.java
new file mode 100644
index 000000000..36faba058
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointJpaRepository.java
@@ -0,0 +1,10 @@
+package com.loopers.infrastructure.point;
+
+import com.loopers.domain.point.Point;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.Optional;
+
+public interface PointJpaRepository extends JpaRepository<Point, Integer> {
+    Optional<Point> findByUser_loginId(String loginId);
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointRepositoryImpl.java
new file mode 100644
index 000000000..81b0c56e2
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointRepositoryImpl.java
@@ -0,0 +1,27 @@
+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;
+
+import java.util.Optional;
+
+@RequiredArgsConstructor
+@Component
+public class PointRepositoryImpl implements PointRepository {
+
+    private final PointJpaRepository pointJpaRepository;
+
+
+    @Override
+    public Optional<Point> findByUser_loginId(String loginId) {
+        return pointJpaRepository.findByUser_loginId(loginId);
+    }
+
+    @Override
+    public Optional<Point> save(Point point) {
+        Point savedPoint = pointJpaRepository.save(point);
+        return Optional.of(savedPoint);
+    }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java
index 6a8dd1b90..e0fe6c743 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java
@@ -10,6 +10,7 @@
 @RequiredArgsConstructor
 @Component
 public class UserRepositoryImpl implements UserRepository {
+
     private final UserJpaRepository userJpaRepository;
 
     @Override
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointApiSpec.java
new file mode 100644
index 000000000..82f5adb32
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointApiSpec.java
@@ -0,0 +1,43 @@
+package com.loopers.interfaces.api.point;
+
+import com.loopers.interfaces.api.ApiResponse;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.enums.ParameterIn;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.tags.Tag;
+
+import java.math.BigDecimal;
+
+@Tag(name = "Point API", description = "포인트 관련 API 입니다.")
+public interface PointApiSpec {
+
+    @Operation(
+            summary = "포인트 조회"
+            , description = "회원의 포인트 조회합니다."
+    )
+    ApiResponse<PointDto.PointResponse> getPoint(
+            @Parameter(
+                    name = "X-USER-ID"
+                    , in = ParameterIn.HEADER
+                    , description = "요청자 사용자 ID 헤더"
+            )
+            String xUserId
+    );
+
+    @Operation(
+            summary = "포인트 충전"
+            , description = "회원의 포인트 충전합니다."
+    )
+    ApiResponse<PointDto.PointResponse> charge(
+            @Parameter(
+                    name = "X-USER-ID"
+                    , in = ParameterIn.HEADER
+                    , description = "요청자 사용자 ID 헤더"
+            )
+            String xUserId,
+
+            @Schema(name = "충전할 포인트", description = "충전할 포인트 액수")
+            PointDto.PointRequest pointRequest
+    );
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointController.java
new file mode 100644
index 000000000..47dab044e
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointController.java
@@ -0,0 +1,34 @@
+package com.loopers.interfaces.api.point;
+
+import com.loopers.application.point.PointFacade;
+import com.loopers.application.point.PointInfo;
+import com.loopers.interfaces.api.ApiResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/api/v1/points")
+public class PointController implements PointApiSpec {
+
+    private final PointFacade  pointFacade;
+
+    @GetMapping("/")
+    @Override
+    public ApiResponse<PointDto.PointResponse> getPoint(
+            @RequestHeader(value = "X-USER-ID") String xUserId
+    ) {
+        PointInfo pointInfo = pointFacade.getPoint(xUserId);
+        return ApiResponse.success(PointDto.PointResponse.from(pointInfo));
+    }
+
+    @PostMapping("/charge")
+    @Override
+    public ApiResponse<PointDto.PointResponse> charge(
+            @RequestHeader(value = "X-USER-ID") String xUserId
+            , @RequestBody PointDto.PointRequest pointRequest
+    ) {
+        PointInfo pointInfo = pointFacade.charge(xUserId, pointRequest.amount());
+        return ApiResponse.success(PointDto.PointResponse.from(pointInfo));
+    }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointDto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointDto.java
new file mode 100644
index 000000000..2ae34b82e
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointDto.java
@@ -0,0 +1,25 @@
+package com.loopers.interfaces.api.point;
+
+import com.loopers.application.point.PointInfo;
+import lombok.Builder;
+
+import java.math.BigDecimal;
+
+public class PointDto {
+
+    @Builder
+    public record PointRequest(
+           BigDecimal amount
+    ) {}
+
+    @Builder
+    public record PointResponse(
+            BigDecimal amount
+    ) {
+        public static PointResponse from(PointInfo pointInfo) {
+            return PointResponse.builder()
+                    .amount(pointInfo.amount())
+                    .build();
+        }
+    }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserApiSpec.java
index 4d96c092e..82197bf23 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserApiSpec.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserApiSpec.java
@@ -24,17 +24,6 @@ ApiResponse<UserDto.UserResponse> saveUser(
             , description = "로그인 ID 로 회원 조회합니다."
     )
     ApiResponse<UserDto.UserResponse> getUser(
-            @Schema(name = "로그인 ID", description = "회원 조회할 로그인 ID")
-            String loginId
-    );
-
-    @Operation(
-            summary = "포인트 조회"
-            , description = "회원의 포인트 조회합니다."
-    )
-    ApiResponse<Integer> getUserPoint(
-            @Schema(name = "로그인 ID", description = "포인트 조회할 회원의 로그인 ID")
-            String loginId,
             @Parameter(
                     name = "X-USER-ID"
                     , in = ParameterIn.HEADER
@@ -42,17 +31,4 @@ ApiResponse<Integer> getUserPoint(
             )
             String xUserId
     );
-
-    @Operation(
-            summary = "포인트 충전"
-            , description = "회원의 포인트 충전합니다."
-    )
-    ApiResponse<Integer> addUserPoint(
-            @Schema(name = "로그인 ID", description = "포인트 충전할 회원의 로그인 ID")
-            @Parameter(name = "loginId", in = ParameterIn.PATH)
-            String loginId,
-            @Schema(name = "충전할 포인트", description = "포인트 충전할 회원의 로그인 ID")
-            @Parameter(name = "userPoint", in = ParameterIn.QUERY)
-            Integer userPoint
-    );
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java
index e502fcace..e66246162 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java
@@ -6,16 +6,14 @@
 import lombok.RequiredArgsConstructor;
 import org.springframework.web.bind.annotation.*;
 
-import static com.loopers.support.error.CoreExceptionUtil.validateNullOrBlank;
-
 @RequiredArgsConstructor
 @RestController
-@RequestMapping("/api/user")
+@RequestMapping("/api/v1/users")
 public class UserController implements UserApiSpec {
 
     private final UserFacade userFacade;
 
-    @PostMapping("/saveUser")
+    @PostMapping("/")
     public ApiResponse<UserDto.UserResponse> saveUser(
             @RequestBody UserDto.CreateUserRequest cur
     ) {
@@ -24,34 +22,13 @@ public ApiResponse<UserDto.UserResponse> saveUser(
         return ApiResponse.success(userResponse);
     }
 
-    @GetMapping("/{loginId}")
+    @GetMapping("/me")
     @Override
     public ApiResponse<UserDto.UserResponse> getUser(
-        @PathVariable String loginId
+            @RequestHeader(value = "X-USER-ID") String xUserId
     ) {
-        UserInfo userInfo = userFacade.getUser(loginId);
+        UserInfo userInfo = userFacade.getUser(xUserId);
         UserDto.UserResponse userResponse = UserDto.UserResponse.from(userInfo);
         return ApiResponse.success(userResponse);
     }
-
-    @GetMapping("/{loginId}/getUserPoint")
-    @Override
-    public ApiResponse<Integer> getUserPoint(
-            @PathVariable String loginId
-            , @RequestHeader(value = "X-USER-ID", required = false) String xUserId
-    ) {
-        validateNullOrBlank(xUserId, "포인트 조회 시 헤더에 X-USER-ID 가 필요합니다.");
-        Integer userPoint = userFacade.getUserPoint(loginId);
-        return ApiResponse.success(userPoint);
-    }
-
-    @PostMapping("/{loginId}/addUserPoint")
-    @Override
-    public ApiResponse<Integer> addUserPoint(
-            @PathVariable String loginId
-            , @RequestParam Integer userPoint
-    ) {
-        Integer userTotalPoint = userFacade.addUserPoint(loginId, userPoint);
-        return ApiResponse.success(userTotalPoint);
-    }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserDto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserDto.java
index 7dae2a4f9..c43ca7054 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserDto.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserDto.java
@@ -1,6 +1,7 @@
 package com.loopers.interfaces.api.user;
 
 import com.loopers.application.user.UserInfo;
+import com.loopers.domain.user.Gender;
 import lombok.Builder;
 
 public class UserDto {
@@ -10,8 +11,7 @@ public record CreateUserRequest(
             String loginId
             , String email
             , String birthday
-            , String gender
-            , Integer point
+            , Gender gender
     ) {}
 
     @Builder
@@ -19,8 +19,7 @@ public record UserResponse(
             String loginId
             , String email
             , String birthday
-            , String gender
-            , Integer point
+            , Gender gender
     ) {
         public static UserResponse from(UserInfo userInfo) {
             return UserResponse.builder()
@@ -28,7 +27,6 @@ public static UserResponse from(UserInfo userInfo) {
                     .email(userInfo.email())
                     .birthday(userInfo.birthday())
                     .gender(userInfo.gender())
-                    .point(userInfo.point())
                     .build();
         }
     }
diff --git a/apps/commerce-api/src/main/java/com/loopers/support/config/WebConfig.java b/apps/commerce-api/src/main/java/com/loopers/support/config/WebConfig.java
new file mode 100644
index 000000000..76b601dfb
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/support/config/WebConfig.java
@@ -0,0 +1,20 @@
+package com.loopers.support.config;
+
+import com.loopers.support.interceptor.AccessControlInterceptor;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+@Configuration
+public class WebConfig implements WebMvcConfigurer {
+
+    @Autowired
+    private AccessControlInterceptor accessControlInterceptor;
+
+    @Override
+    public void addInterceptors(InterceptorRegistry registry) {
+        registry.addInterceptor(accessControlInterceptor)
+                .excludePathPatterns("/api/v1/users/");
+    }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/CoreExceptionUtil.java b/apps/commerce-api/src/main/java/com/loopers/support/error/CoreExceptionUtil.java
deleted file mode 100644
index 39a239394..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/support/error/CoreExceptionUtil.java
+++ /dev/null
@@ -1,33 +0,0 @@
-package com.loopers.support.error;
-
-import java.util.regex.Pattern;
-
-public class CoreExceptionUtil {
-    /**
-     * 문자열이 NULL, Blank 이면 throw Bad Request CoreException
-     */
-    public static void validateNullOrBlank(String param, String message) {
-        if (param == null || param.isBlank()) {
-            throw new CoreException(ErrorType.BAD_REQUEST, message);
-        }
-    }
-
-    /**
-     * 객체가 NULL 이면 throw Bad Request CoreException
-     */
-    public static void validateObjectNull(Object param, String message) {
-        if (param == null) {
-            throw new CoreException(ErrorType.BAD_REQUEST, message);
-        }
-    }
-
-    /**
-     * 문자열이 pattern 에 맞지 않으면 throw Bad Request CoreException
-     */
-    public static void validatePattern(String param, String patternString, String message) {
-        Pattern pattern = Pattern.compile(patternString);
-        if(!pattern.matcher(param).matches()) {
-            throw new CoreException(ErrorType.BAD_REQUEST, message);
-        }
-    }
-}
diff --git a/apps/commerce-api/src/main/java/com/loopers/support/interceptor/AccessControlInterceptor.java b/apps/commerce-api/src/main/java/com/loopers/support/interceptor/AccessControlInterceptor.java
new file mode 100644
index 000000000..b962cd579
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/support/interceptor/AccessControlInterceptor.java
@@ -0,0 +1,33 @@
+package com.loopers.support.interceptor;
+
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.stereotype.Component;
+import org.springframework.util.AntPathMatcher;
+import org.springframework.web.servlet.HandlerInterceptor;
+
+@Component
+public class AccessControlInterceptor implements HandlerInterceptor {
+
+    // private final AntPathMatcher pathMatcher = new AntPathMatcher();
+    private static final String ENDPOINT = "/api/v1";
+
+    public boolean preHandle(
+            HttpServletRequest request
+            , HttpServletResponse response
+            , Object handler
+    ) throws Exception {
+
+        // 헤더에 X-USER-ID 가 없으면 BAD REQUEST
+        String requestPath = request.getRequestURI().substring(request.getContextPath().length());
+        String userIdHeader = request.getHeader("X-USER-ID");
+
+        if(userIdHeader == null || userIdHeader.isBlank()) {
+            throw new CoreException(ErrorType.BAD_REQUEST, requestPath + " API 요청에 X-USER-ID 가 꼭 필요합니다.");
+        }
+
+        return true;
+    }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointIntegrationTest.java
new file mode 100644
index 000000000..afc3d11e7
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointIntegrationTest.java
@@ -0,0 +1,63 @@
+package com.loopers.domain.point;
+
+import com.loopers.application.point.PointFacade;
+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 java.math.BigDecimal;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+@DisplayName("Point 통합 테스트")
+@SpringBootTest
+public class PointIntegrationTest {
+
+    @Autowired
+    private PointFacade pointFacade;
+
+    @Autowired
+    private DatabaseCleanUp databaseCleanUp;
+
+    @AfterEach
+    void tearDown() {databaseCleanUp.truncateAllTables();}
+
+    final String validLoginId = "bobby34";
+    final String validEmail = "bobby34@naver.com";
+    final String validBirthday = "1994-04-08";
+    final Gender validGender = Gender.MALE;
+    final BigDecimal validPoint = BigDecimal.valueOf(0);
+
+    @DisplayName("Point 충전 테스트")
+    @Nested
+    class PointTest {
+
+        @DisplayName("실패 케이스 : 존재하지 않는 User ID 로 충전을 시도한 경우, 실패")
+        @Test
+        void addPoint_withNoUser_NotFound() {
+            // arrange
+            BigDecimal requestPoint = BigDecimal.valueOf(1000);
+
+            // act
+            CoreException result = assertThrows(CoreException.class,
+                    () -> pointFacade.charge(validLoginId,  requestPoint)
+            );
+
+            System.out.println("--- CoreException Details ---");
+            System.out.println("ErrorType: " + result.getErrorType());
+            System.out.println("CustomMessage: " + result.getCustomMessage());
+            System.out.println("------------------------");
+
+            // assert
+            assertEquals(ErrorType.NOT_FOUND, result.getErrorType());
+            assertTrue(result.getCustomMessage().endsWith("포인트를 충전할 Point 객체를 찾을 수 없습니다."));
+        }
+    }
+}
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
new file mode 100644
index 000000000..df2f96748
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointTest.java
@@ -0,0 +1,70 @@
+package com.loopers.domain.point;
+
+import com.loopers.domain.user.Gender;
+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.math.BigDecimal;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+@DisplayName("Point 테스트")
+public class PointTest {
+
+    @DisplayName("point 충전 테스트")
+    @Nested
+    class AddPointTest {
+
+        final String validLoginId = "bobby34";
+        final String validEmail = "bobby34@naver.com";
+        final String validBirthday = "1994-04-08";
+        final Gender validGender = Gender.MALE;
+        final BigDecimal validPoint = BigDecimal.valueOf(0);
+
+        @DisplayName("실패 케이스 : 0 이하의 정수로 포인트를 충전 시 실패")
+        @Test
+        void charge_inputZero_BadRequest() {
+            // arrange
+            Point point = Point.builder()
+                    .user(null)
+                    .amount(validPoint)
+                    .build();
+
+            BigDecimal requestPoint = BigDecimal.valueOf(0);
+
+            // act
+            CoreException result = assertThrows(CoreException.class,
+                    () -> point.charge(requestPoint)
+            );
+
+            // assert
+            assertEquals(ErrorType.BAD_REQUEST, result.getErrorType());
+            assertEquals("충전할 point는 0 이하가 될 수 없습니다.", result.getCustomMessage());
+        }
+
+        @DisplayName("실패 케이스 : 음수로 포인트를 충전 시 실패")
+        @Test
+        void charge_inputBelowZero_BadRequest() {
+            // arrange
+            Point point = Point.builder()
+                    .user(null)
+                    .amount(validPoint)
+                    .build();
+
+            BigDecimal requestPoint = BigDecimal.valueOf(-10);
+
+            // act
+            CoreException result = assertThrows(CoreException.class,
+                    () -> point.charge(requestPoint)
+            );
+
+            // assert
+            assertEquals(ErrorType.BAD_REQUEST, result.getErrorType());
+            assertEquals("충전할 point는 0 이하가 될 수 없습니다.", result.getCustomMessage());
+        }
+    }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserE2ETest.java
deleted file mode 100644
index 5836ec6e5..000000000
--- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserE2ETest.java
+++ /dev/null
@@ -1,198 +0,0 @@
-package com.loopers.domain.user;
-
-import com.fasterxml.jackson.databind.ObjectMapper;
-import com.loopers.application.user.UserFacade;
-import com.loopers.application.user.UserInfo;
-import com.loopers.interfaces.api.user.UserDto;
-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.autoconfigure.web.servlet.AutoConfigureMockMvc;
-import org.springframework.boot.test.context.SpringBootTest;
-import org.springframework.http.MediaType;
-import org.springframework.test.web.servlet.MockMvc;
-
-import static org.hamcrest.Matchers.endsWith;
-import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
-import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
-import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
-import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
-
-@DisplayName("User E2E 테스트")
-@SpringBootTest
-@AutoConfigureMockMvc
-public class UserE2ETest {
-
-    @Autowired
-    private MockMvc mockMvc;
-
-    @Autowired
-    private DatabaseCleanUp databaseCleanUp;
-
-    @Autowired
-    private UserFacade userFacade;
-
-    private final ObjectMapper objectMapper = new ObjectMapper();
-
-    @AfterEach
-    void tearDown() {databaseCleanUp.truncateAllTables();}
-
-    final String validLoginId = "bobby34";
-    final String validEmail = "bobby34@naver.com";
-    final String validBirthday = "1994-04-08";
-    final String validGender = "M";
-    final int validPoint = 0;
-
-    @DisplayName("User 엔티티 생성")
-    @Nested
-    class CreateUserTest {
-
-        @DisplayName("성공 케이스 : 회원 가입이 성공해서 생성된 유저 정보를 응답으로 반환")
-        @Test
-        void saveUser_returnUserResponse_Success() throws Exception {
-            // arrange
-            UserDto.CreateUserRequest createUserRequest = UserDto.CreateUserRequest.builder()
-                    .loginId(validLoginId)
-                    .email(validEmail)
-                    .birthday(validBirthday)
-                    .gender(validGender)
-                    .point(validPoint)
-                    .build();
-            String userJson = objectMapper.writeValueAsString(createUserRequest);
-
-            // act
-            mockMvc.perform(post("/api/user/saveUser")
-                    .contentType(MediaType.APPLICATION_JSON)
-                    .content(userJson))
-
-            // assert
-                    .andExpect(status().isOk())
-                    .andExpect(jsonPath("$.data").exists())
-                    .andExpect(jsonPath("$.data.loginId").value(validLoginId))
-                    .andExpect(jsonPath("$.data.email").value(validEmail))
-                    .andExpect(jsonPath("$.data.birthday").value(validBirthday))
-                    .andExpect(jsonPath("$.data.gender").value(validGender))
-                    .andExpect(jsonPath("$.data.point").value(validPoint));
-        }
-
-        @DisplayName("실패 케이스 : 회원 가입 시 성별 없을 경우 `400 Bad Request` 응답 반환")
-        @Test
-        void saveUser_noGender_BadRequest() throws Exception {
-            // arrange
-            UserDto.CreateUserRequest createUserRequest = UserDto.CreateUserRequest.builder()
-                    .loginId(validLoginId)
-                    .email(validEmail)
-                    .birthday(validBirthday)
-                    .point(validPoint)
-                    .build();
-            String userJson = objectMapper.writeValueAsString(createUserRequest);
-
-            // act
-            mockMvc.perform(post("/api/user/saveUser")
-                            .contentType(MediaType.APPLICATION_JSON)
-                            .content(userJson))
-
-                    // assert
-                    .andExpect(status().isBadRequest())
-                    .andExpect(jsonPath("$.data").doesNotExist())
-                    .andExpect(jsonPath("$.meta.result").value("FAIL"))
-                    .andExpect(jsonPath("$.meta.errorCode").value("Bad Request"))
-                    .andExpect(jsonPath("$.meta.message").value("gender가 비어있을 수 없습니다."));
-        }
-    }
-
-    @DisplayName("User 포인트 조회")
-    @Nested
-    class GetUserTest {
-
-        @DisplayName("성공 케이스 : 포인트 조회에 성공할 경우, 보유 포인트를 응답으로 반환")
-        @Test
-        void getUserPoint_withValidFields_Success() throws Exception {
-            // arrange
-            UserDto.CreateUserRequest cur = UserDto.CreateUserRequest.builder()
-                    .loginId(validLoginId)
-                    .email(validEmail)
-                    .birthday(validBirthday)
-                    .gender(validGender)
-                    .point(validPoint)
-                    .build();
-
-            userFacade.saveUser(UserInfo.from(cur));
-
-            // act
-            mockMvc.perform(get("/api/user/{loginId}/getUserPoint", validLoginId)
-                            .header("X-USER-ID",  validLoginId)
-                            .contentType(MediaType.APPLICATION_JSON))
-
-            // assert
-                    .andExpect(status().isOk())
-                    .andExpect(jsonPath("$.data").isNumber())
-                    .andExpect(jsonPath("$.data").value(validPoint));
-        }
-
-        @DisplayName("실패 케이스 : `X-USER-ID` 헤더가 없을 경우, `400 Bad Request` 응답을 반환")
-        @Test
-        void getUserPoint_XUserIdHeaderNotExists_BadRequest() throws Exception {
-            // arrange
-
-            // act
-            mockMvc.perform(get("/api/user/{loginId}/getUserPoint", validLoginId)
-                            .contentType(MediaType.APPLICATION_JSON))
-
-            // assert
-                    .andExpect(status().isBadRequest())
-                    .andExpect(jsonPath("$.meta.message").value("포인트 조회 시 헤더에 X-USER-ID 가 필요합니다."));
-        }
-    }
-
-    @DisplayName("User 포인트 충전")
-    @Nested
-    class AddUserTest {
-
-        @DisplayName("성공 케이스 : 존재하는 User가 1000원을 충전할 경우, 충전된 보유 총량을 응답으로 반환")
-        @Test
-        void addUserPoint_add1000_getTotalPoints() throws Exception {
-            // arrange
-            Integer requestPoint = 1000;
-
-            UserDto.CreateUserRequest cur = UserDto.CreateUserRequest.builder()
-                    .loginId(validLoginId)
-                    .email(validEmail)
-                    .birthday(validBirthday)
-                    .gender(validGender)
-                    .point(validPoint)
-                    .build();
-
-            userFacade.saveUser(UserInfo.from(cur));
-
-            // act
-            mockMvc.perform(post("/api/user/{loginId}/addUserPoint", validLoginId)
-                            .param("userPoint", requestPoint.toString())
-                            .contentType(MediaType.APPLICATION_JSON))
-
-                    // assert
-                    .andExpect(status().isOk())
-                    .andExpect(jsonPath("$.data").isNumber())
-                    .andExpect(jsonPath("$.data").value(requestPoint));
-        }
-
-        @DisplayName("실패 케이스 : 존재하지 않는 User로 요청할 경우, `404 Not Found` 응답을 반환")
-        @Test
-        void addPoint_withNoUser_NotFound() throws Exception {
-            // arrange
-            Integer requestPoint = 1000;
-
-            // act
-            mockMvc.perform(post("/api/user/{loginId}/addUserPoint", validLoginId)
-                            .param("userPoint", requestPoint.toString())
-                            .contentType(MediaType.APPLICATION_JSON))
-
-                    // assert
-                    .andExpect(status().isNotFound())
-                    .andExpect(jsonPath("$.meta.message").value(endsWith("point를 충전할 User를 찾을 수 없습니다.")));
-        }
-    }
-}
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserIntegrationTest.java
index f84aadd5c..9130b38f6 100644
--- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserIntegrationTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserIntegrationTest.java
@@ -9,10 +9,10 @@
 import org.junit.jupiter.api.DisplayName;
 import org.junit.jupiter.api.Nested;
 import org.junit.jupiter.api.Test;
-import org.mockito.Mockito;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.boot.test.context.SpringBootTest;
-import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
+
+import java.math.BigDecimal;
 
 import static org.junit.jupiter.api.Assertions.*;
 
@@ -23,8 +23,8 @@ public class UserIntegrationTest {
     @Autowired
     private UserFacade userFacade;
 
-    @MockitoSpyBean
-    private UserRepository userRepository;
+    @Autowired
+    private UserService userService;
 
     @Autowired
     private DatabaseCleanUp databaseCleanUp;
@@ -35,8 +35,8 @@ public class UserIntegrationTest {
     final String validLoginId = "bobby34";
     final String validEmail = "bobby34@naver.com";
     final String validBirthday = "1994-04-08";
-    final String validGender = "M";
-    final Integer validPoint = 0;
+    final Gender validGender = Gender.MALE;
+    final BigDecimal validPoint = BigDecimal.valueOf(0);
 
     @DisplayName("User 엔티티 생성")
     @Nested
@@ -51,22 +51,18 @@ void saveUser_withValidFields_Success() {
                     .email(validEmail)
                     .birthday(validBirthday)
                     .gender(validGender)
-                    .point(validPoint)
                     .build();
 
             // act
-            userFacade.saveUser(userinfo);
+            UserInfo userInfo = userFacade.saveUser(userinfo);
 
             // assert
-            Mockito.verify(userRepository, Mockito.times(1))
-                .save(Mockito.argThat(user -> 
-                    user.getLoginId().equals(validLoginId)
-                    && user.getEmail().equals(validEmail)
-                    && user.getBirthday().equals(validBirthday)
-                    && user.getGender().equals(validGender)
-                    && user.getPoint().equals(validPoint)
-                )
-            );
+            // 저장한 User 의 내용 일치되는지 확인
+            assertEquals(validLoginId, userInfo.loginId());
+            assertEquals(validEmail, userInfo.email());
+            assertEquals(validBirthday, userInfo.birthday());
+            assertEquals(validGender, userInfo.gender());
+            assertTrue(validPoint.compareTo(userInfo.point().getAmount()) == 0);
         }
 
         @DisplayName("실패 케이스 : 이미 저장된 User 를 다시 저장하면 실패")
@@ -78,16 +74,17 @@ void saveUser_spyUserRepository_Success() {
                     .email(validEmail)
                     .birthday(validBirthday)
                     .gender(validGender)
-                    .point(validPoint)
                     .build();
+
             userFacade.saveUser(userinfo);
 
             // act
-            CoreException result = assertThrows(CoreException.class,
-                () -> userFacade.saveUser(userinfo));
+            CoreException result = assertThrows(CoreException.class
+                    , () -> userFacade.saveUser(userinfo));
 
             // assert
             assertEquals(ErrorType.CONFLICT, result.getErrorType());
+            assertEquals("이미 존재하는 회원을 다시 저장 못합니다.", result.getCustomMessage());
         }
 
     }
@@ -105,12 +102,9 @@ void getUser_idExists_Success() {
                     .email(validEmail)
                     .birthday(validBirthday)
                     .gender(validGender)
-                    .point(validPoint)
                     .build();
-            userFacade.saveUser(userinfo);
-
+            UserInfo userInfo = userFacade.saveUser(userinfo);
             // act
-            UserInfo userInfo = userFacade.getUser(validLoginId);
 
             // assert
             assertNotNull(userInfo);
@@ -119,7 +113,6 @@ void getUser_idExists_Success() {
                     , () -> assertEquals(userInfo.email(), validEmail)
                     , () -> assertEquals(userInfo.birthday(), validBirthday)
                     , () -> assertEquals(userInfo.gender(), validGender)
-                    , () -> assertEquals(userInfo.point(), validPoint)
             );
         }
 
@@ -129,31 +122,13 @@ void getUser_idNotExists_getNull() {
             // arrange
 
             // act
-            UserInfo userInfo = userFacade.getUser(validLoginId);
-
-            // assert
-            assertNull(userInfo);
-        }
-    }
-
-    @DisplayName("point 충전 테스트")
-    @Nested
-    class PointTest {
-
-        @DisplayName("실패 케이스 : 존재하지 않는 User ID 로 충전을 시도한 경우, 실패")
-        @Test
-        void addPoint_withNoUser_NotFound() {
-            // arrange
-            Integer requestPoint = 1000;
-
-            // act
-            CoreException result = assertThrows(CoreException.class,
-                    () -> userFacade.addUserPoint(validLoginId,  requestPoint)
-            );
+            CoreException result = assertThrows(CoreException.class
+                    , () -> userFacade.getUser(validLoginId));
 
             // assert
             assertEquals(ErrorType.NOT_FOUND, result.getErrorType());
-            assertTrue(result.getCustomMessage().endsWith("point를 충전할 User를 찾을 수 없습니다."));
+            assertTrue(result.getCustomMessage().endsWith("User 를 찾지 못했습니다."));
         }
     }
+
 }
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 a9222b0b0..09ec0c4a6 100644
--- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java
@@ -1,11 +1,14 @@
 package com.loopers.domain.user;
 
+import com.loopers.domain.point.Point;
 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.math.BigDecimal;
+
 import static org.junit.jupiter.api.Assertions.*;
 
 @DisplayName("User 테스트")
@@ -18,13 +21,17 @@ class CreateUserTest {
         final String validLoginId = "bobby34";
         final String validEmail = "bobby34@naver.com";
         final String validBirthday = "1994-04-08";
-        final String validGender = "M";
-        final Integer validPoint = 0;
+        final Gender validGender = Gender.MALE;
+        final BigDecimal validPoint = BigDecimal.valueOf(0);
 
         @DisplayName("성공 케이스 : 필드가 모두 형식에 맞으면 User 객체 생성 성공")
         @Test
         void createUser_withValidFields_Success() {
             // arrange
+            Point point = Point.builder()
+                    .user(null)
+                    .amount(validPoint)
+                    .build();
 
             // act
             User user = User.builder()
@@ -32,7 +39,7 @@ void createUser_withValidFields_Success() {
                     .email(validEmail)
                     .birthday(validBirthday)
                     .gender(validGender)
-                    .point(validPoint)
+                    .point(point)
                     .build();
 
             // assert
@@ -42,7 +49,7 @@ void createUser_withValidFields_Success() {
                     , () -> assertEquals(user.getEmail(), validEmail)
                     , () -> assertEquals(user.getBirthday(), validBirthday)
                     , () -> assertEquals(user.getGender(), validGender)
-                    , () -> assertEquals(user.getPoint(), validPoint)
+                    , () -> assertEquals(user.getPoint().getAmount(), validPoint)
             );
         }
 
@@ -68,7 +75,7 @@ void createUser_withNoEngAndNumId_BadRequest() {
                 
                 // assert
                 assertEquals(ErrorType.BAD_REQUEST, result.getErrorType());
-                assertEquals("ID에 영문과 숫자만 10자 이내로 꼭 포함되어 있어야 합니다.", result.getCustomMessage());
+                assertEquals("User : ID가 비어있을 수 없습니다.ID에 영문과 숫자만 10자 이내로 꼭 포함되어 있어야 합니다.", result.getCustomMessage());
             }
 
             @DisplayName("실패 케이스 : User ID에 영문이 없으면 User 객체 생성 실패")
@@ -89,7 +96,7 @@ void createUser_withNoEngId_BadRequest() {
 
                 // assert
                 assertEquals(ErrorType.BAD_REQUEST, result.getErrorType());
-                assertEquals("ID에 영문과 숫자만 10자 이내로 꼭 포함되어 있어야 합니다.", result.getCustomMessage());
+                assertEquals("User : ID가 비어있을 수 없습니다.ID에 영문과 숫자만 10자 이내로 꼭 포함되어 있어야 합니다.", result.getCustomMessage());
             }
 
             @DisplayName("실패 케이스 : User ID에 숫자가 없으면 User 객체 생성 실패")
@@ -110,7 +117,7 @@ void createUser_withNoNumId_BadRequest() {
 
                 // assert
                 assertEquals(ErrorType.BAD_REQUEST, result.getErrorType());
-                assertEquals("ID에 영문과 숫자만 10자 이내로 꼭 포함되어 있어야 합니다.", result.getCustomMessage());
+                assertEquals("User : ID가 비어있을 수 없습니다.ID에 영문과 숫자만 10자 이내로 꼭 포함되어 있어야 합니다.", result.getCustomMessage());
             }
 
             @DisplayName("실패 케이스 : User ID가 10자를 넘어가면 User 객체 생성 실패")
@@ -131,7 +138,7 @@ void createUser_withIdOverTenChars_BadRequest() {
 
                 // assert
                 assertEquals(ErrorType.BAD_REQUEST, result.getErrorType());
-                assertEquals("ID에 영문과 숫자만 10자 이내로 꼭 포함되어 있어야 합니다.", result.getCustomMessage());
+                assertEquals("User : ID가 비어있을 수 없습니다.ID에 영문과 숫자만 10자 이내로 꼭 포함되어 있어야 합니다.", result.getCustomMessage());
             }
         }
 
@@ -157,7 +164,7 @@ void createUser_withNoAtSignEmail_BadRequest() {
 
                 // assert
                 assertEquals(ErrorType.BAD_REQUEST, result.getErrorType());
-                assertEquals("email이 `xx@yy.zz` 형식에 맞아야 합니다.", result.getCustomMessage());
+                assertEquals("User : ID가 비어있을 수 없습니다.email이 `xx@yy.zz` 형식에 맞아야 합니다.", result.getCustomMessage());
             }
 
             @DisplayName("실패 케이스 : User email에 `@` 이 두 개 있으면 User 객체 생성 실패")
@@ -178,7 +185,7 @@ void createUser_withTwoAtSignEmail_BadRequest() {
 
                 // assert
                 assertEquals(ErrorType.BAD_REQUEST, result.getErrorType());
-                assertEquals("email이 `xx@yy.zz` 형식에 맞아야 합니다.", result.getCustomMessage());
+                assertEquals("User : ID가 비어있을 수 없습니다.email이 `xx@yy.zz` 형식에 맞아야 합니다.", result.getCustomMessage());
             }
 
             @DisplayName("실패 케이스 : User email에 `.` 이 없으면 User 객체 생성 실패")
@@ -199,7 +206,7 @@ void createUser_withNoDotEmail_BadRequest() {
 
                 // assert
                 assertEquals(ErrorType.BAD_REQUEST, result.getErrorType());
-                assertEquals("email이 `xx@yy.zz` 형식에 맞아야 합니다.", result.getCustomMessage());
+                assertEquals("User : ID가 비어있을 수 없습니다.email이 `xx@yy.zz` 형식에 맞아야 합니다.", result.getCustomMessage());
             }
 
             @DisplayName("실패 케이스 : User email에 `.` 이 두 개 있으면 User 객체 생성 실패")
@@ -220,7 +227,7 @@ void createUser_withTwoDotEmail_BadRequest() {
 
                 // assert
                 assertEquals(ErrorType.BAD_REQUEST, result.getErrorType());
-                assertEquals("email이 `xx@yy.zz` 형식에 맞아야 합니다.", result.getCustomMessage());
+                assertEquals("User : ID가 비어있을 수 없습니다.email이 `xx@yy.zz` 형식에 맞아야 합니다.", result.getCustomMessage());
             }
         }
 
@@ -246,7 +253,7 @@ void createUser_withNoHyphenBirthday_BadRequest() {
 
                 // assert
                 assertEquals(ErrorType.BAD_REQUEST, result.getErrorType());
-                assertEquals("birthday가 `yyyy-MM-dd` 형식에 맞아야 합니다.", result.getCustomMessage());
+                assertEquals("User : ID가 비어있을 수 없습니다.birthday가 `yyyy-MM-dd` 형식에 맞아야 합니다.", result.getCustomMessage());
             }
 
             @DisplayName("실패 케이스 : User birthday가 `yyyy-MM-d` 형식이면 User 객체 생성 실패")
@@ -267,60 +274,7 @@ void createUser_withNoOneDayBirthdayFormat_BadRequest() {
 
                 // assert
                 assertEquals(ErrorType.BAD_REQUEST, result.getErrorType());
-                assertEquals("birthday가 `yyyy-MM-dd` 형식에 맞아야 합니다.", result.getCustomMessage());
-            }
-        }
-
-        @DisplayName("point 충전 테스트")
-        @Nested
-        class PointTest {
-
-            @DisplayName("실패 케이스 : 0 이하의 정수로 포인트를 충전 시 실패")
-            @Test
-            void addPoint_inputZero_BadRequest() {
-                // arrange
-                User user = User.builder()
-                        .loginId(validLoginId)
-                        .email(validEmail)
-                        .birthday(validBirthday)
-                        .gender(validGender)
-                        .point(validPoint)
-                        .build();
-
-                Integer requestPoint = 0;
-
-                // act
-                CoreException result = assertThrows(CoreException.class,
-                        () -> user.addPoint(requestPoint)
-                );
-
-                // assert
-                assertEquals(ErrorType.BAD_REQUEST, result.getErrorType());
-                assertEquals("충전할 point는 0 이하가 될 수 없습니다.", result.getCustomMessage());
-            }
-
-            @DisplayName("실패 케이스 : 음수로 포인트를 충전 시 실패")
-            @Test
-            void addPoint_inputBelowZero_BadRequest() {
-                // arrange
-                User user = User.builder()
-                        .loginId(validLoginId)
-                        .email(validEmail)
-                        .birthday(validBirthday)
-                        .gender(validGender)
-                        .point(validPoint)
-                        .build();
-
-                Integer requestPoint = -10;
-
-                // act
-                CoreException result = assertThrows(CoreException.class,
-                        () -> user.addPoint(requestPoint)
-                );
-
-                // assert
-                assertEquals(ErrorType.BAD_REQUEST, result.getErrorType());
-                assertEquals("충전할 point는 0 이하가 될 수 없습니다.", result.getCustomMessage());
+                assertEquals("User : ID가 비어있을 수 없습니다.birthday가 `yyyy-MM-dd` 형식에 맞아야 합니다.", result.getCustomMessage());
             }
         }
 
diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointE2ETest.java
new file mode 100644
index 000000000..e5fad7f43
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointE2ETest.java
@@ -0,0 +1,200 @@
+package com.loopers.interfaces.api;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.loopers.application.user.UserFacade;
+import com.loopers.application.user.UserInfo;
+import com.loopers.domain.point.Point;
+import com.loopers.domain.user.Gender;
+import com.loopers.domain.user.User;
+import com.loopers.domain.user.UserRepository;
+import com.loopers.interfaces.api.point.PointDto;
+import com.loopers.interfaces.api.user.UserDto;
+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.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.core.ParameterizedTypeReference;
+import org.springframework.http.*;
+import org.springframework.test.web.servlet.MockMvc;
+
+import java.math.BigDecimal;
+
+import static org.hamcrest.Matchers.endsWith;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@DisplayName("Point E2E 테스트")
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+public class PointE2ETest {
+
+    private static final String ENDPOINT = "/api/v1/points";
+
+    private final TestRestTemplate testRestTemplate;
+    private final UserFacade userFacade;
+    private final DatabaseCleanUp databaseCleanUp;
+
+    @Autowired
+    public PointE2ETest(
+            TestRestTemplate restTemplate
+            , UserFacade userFacade
+            , DatabaseCleanUp databaseCleanUp
+    ) {
+        this.testRestTemplate = restTemplate;
+        this.userFacade = userFacade;
+        this.databaseCleanUp = databaseCleanUp;
+    }
+
+    @AfterEach
+    void tearDown() {databaseCleanUp.truncateAllTables();}
+
+    final String validLoginId = "bobby34";
+    final String validEmail = "bobby34@naver.com";
+    final String validBirthday = "1994-04-08";
+    final Gender validGender = Gender.MALE;
+    final BigDecimal validPoint = BigDecimal.valueOf(0);
+
+    @DisplayName("포인트 조회")
+    @Nested
+    class GetUserTest {
+
+        @DisplayName("성공 케이스 : 포인트 조회에 성공할 경우, 보유 포인트를 응답으로 반환")
+        @Test
+        void getUserPoint_withValidFields_Success() {
+            // arrange
+
+            // 먼저 User 저장하기
+            UserInfo userInfo = UserInfo.builder()
+                    .loginId(validLoginId)
+                    .email(validEmail)
+                    .birthday(validBirthday)
+                    .gender(validGender)
+                    .build();
+
+            userFacade.saveUser(userInfo);
+
+            String requestUrl = ENDPOINT + "/";
+            HttpHeaders headers = new HttpHeaders();
+            headers.add("X-USER-ID", validLoginId);
+            ParameterizedTypeReference<ApiResponse<PointDto.PointResponse>> responseType = new ParameterizedTypeReference<>() {};
+
+            // act
+            ResponseEntity<ApiResponse<PointDto.PointResponse>> response =
+                    testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(headers), responseType);
+
+            System.out.println("--- Response Details ---");
+            System.out.println("Status Code: " + response.getStatusCode());
+            System.out.println("Headers: " + response.getHeaders());
+            System.out.println("Body: " + response.getBody());
+            System.out.println("------------------------");
+
+            // assert
+            assertTrue(response.getStatusCode().is2xxSuccessful());
+            assertNotNull(response.getBody());
+            assertTrue(validPoint.compareTo(response.getBody().data().amount()) == 0);
+        }
+
+        @DisplayName("실패 케이스 : `X-USER-ID` 헤더가 없을 경우, `400 Bad Request` 응답을 반환")
+        @Test
+        void getUserPoint_XUserIdHeaderNotExists_BadRequest() {
+            // arrange
+            String requestUrl = ENDPOINT + "/";
+            ParameterizedTypeReference<ApiResponse<PointDto.PointResponse>> responseType = new ParameterizedTypeReference<>() {};
+
+            // act
+            ResponseEntity<ApiResponse<PointDto.PointResponse>> response =
+                    testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType);
+
+            System.out.println("--- Response Details ---");
+            System.out.println("Status Code: " + response.getStatusCode());
+            System.out.println("Headers: " + response.getHeaders());
+            System.out.println("Body: " + response.getBody());
+            System.out.println("------------------------");
+
+            // assert
+            assertTrue(response.getStatusCode().is4xxClientError());
+            assertNotNull(response.getBody());
+            assertEquals(requestUrl +  " API 요청에 X-USER-ID 가 꼭 필요합니다.", response.getBody().meta().message());
+        }
+    }
+
+    @DisplayName("User 포인트 충전")
+    @Nested
+    class AddUserTest {
+
+        @DisplayName("성공 케이스 : 존재하는 User가 1000원을 충전할 경우, 충전된 보유 총량을 응답으로 반환")
+        @Test
+        void addUserPoint_add1000_getTotalPoints() {
+            // arrange
+            BigDecimal requestPoint = BigDecimal.valueOf(1000);
+            PointDto.PointRequest pointRequest = PointDto.PointRequest.builder()
+                    .amount(requestPoint).build();
+
+            UserDto.CreateUserRequest cur = UserDto.CreateUserRequest.builder()
+                    .loginId(validLoginId)
+                    .email(validEmail)
+                    .birthday(validBirthday)
+                    .gender(validGender)
+                    .build();
+
+            userFacade.saveUser(UserInfo.from(cur));
+
+            String requestUrl = ENDPOINT + "/charge";
+            HttpHeaders headers = new HttpHeaders();
+            headers.add("X-USER-ID", validLoginId);
+            ParameterizedTypeReference<ApiResponse<PointDto.PointResponse>> responseType = new ParameterizedTypeReference<>() {};
+
+            // act
+            ResponseEntity<ApiResponse<PointDto.PointResponse>> response =
+                    testRestTemplate.exchange(requestUrl, HttpMethod.POST, new HttpEntity<>(pointRequest, headers), responseType);
+
+            System.out.println("--- Response Details ---");
+            System.out.println("Status Code: " + response.getStatusCode());
+            System.out.println("Headers: " + response.getHeaders());
+            System.out.println("Body: " + response.getBody());
+            System.out.println("------------------------");
+
+            assertTrue(response.getStatusCode().is2xxSuccessful());
+            assertNotNull(response.getBody());
+            assertTrue(requestPoint.compareTo(response.getBody().data().amount()) == 0);
+        }
+
+        @DisplayName("실패 케이스 : 존재하지 않는 User로 요청할 경우, `404 Not Found` 응답을 반환")
+        @Test
+        void addPoint_withNoUser_BadRequest() {
+            // arrange
+            BigDecimal requestPoint = BigDecimal.valueOf(1000);
+            PointDto.PointRequest pointRequest = PointDto.PointRequest.builder()
+                    .amount(requestPoint).build();
+
+            // act
+            String requestUrl = ENDPOINT + "/charge";
+            HttpHeaders headers = new HttpHeaders();
+            headers.add("X-USER-ID", validLoginId);
+            ParameterizedTypeReference<ApiResponse<PointDto.PointResponse>> responseType = new ParameterizedTypeReference<>() {};
+
+            // act
+            ResponseEntity<ApiResponse<PointDto.PointResponse>> response =
+                    testRestTemplate.exchange(requestUrl, HttpMethod.POST, new HttpEntity<>(pointRequest, headers), responseType);
+
+            System.out.println("--- Response Details ---");
+            System.out.println("Status Code: " + response.getStatusCode());
+            System.out.println("Headers: " + response.getHeaders());
+            System.out.println("Body: " + response.getBody());
+            System.out.println("------------------------");
+
+            // assert
+            assertTrue(response.getStatusCode().is4xxClientError());
+            assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode());
+            assertNotNull(response.getBody());
+            assertTrue(response.getBody().meta().message().endsWith("포인트를 충전할 Point 객체를 찾을 수 없습니다."));
+        }
+    }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserE2ETest.java
new file mode 100644
index 000000000..b5c4f70cf
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserE2ETest.java
@@ -0,0 +1,109 @@
+package com.loopers.interfaces.api;
+
+import com.loopers.domain.user.Gender;
+import com.loopers.interfaces.api.user.UserDto;
+import com.loopers.utils.DatabaseCleanUp;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.core.ParameterizedTypeReference;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.ResponseEntity;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+@DisplayName("User E2E 테스트")
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+public class UserE2ETest {
+
+    private static final String ENDPOINT = "/api/v1/users";
+
+    private final TestRestTemplate testRestTemplate;
+    private final DatabaseCleanUp databaseCleanUp;
+
+    @Autowired
+    public UserE2ETest(
+            TestRestTemplate restTemplate
+            , DatabaseCleanUp databaseCleanUp
+    ) {
+        this.testRestTemplate = restTemplate;
+        this.databaseCleanUp = databaseCleanUp;
+    }
+
+    @AfterEach
+    void tearDown() {databaseCleanUp.truncateAllTables();}
+
+    final String validLoginId = "bobby34";
+    final String validEmail = "bobby34@naver.com";
+    final String validBirthday = "1994-04-08";
+    final Gender validGender = Gender.MALE;
+
+    @DisplayName("User 엔티티 생성")
+    @Nested
+    class CreateUserTest {
+
+        @DisplayName("성공 케이스 : 회원 가입이 성공해서 생성된 유저 정보를 응답으로 반환")
+        @Test
+        void saveUser_returnUserResponse_Success() {
+            // arrange
+            UserDto.CreateUserRequest request = UserDto.CreateUserRequest.builder()
+                    .loginId(validLoginId)
+                    .email(validEmail)
+                    .birthday(validBirthday)
+                    .gender(validGender)
+                    .build();
+
+            String requestUrl = ENDPOINT + "/";
+            HttpEntity<UserDto.CreateUserRequest> requestEntity = new HttpEntity<>(request);
+            ParameterizedTypeReference<ApiResponse<UserDto.UserResponse>> responseType = new ParameterizedTypeReference<>() {};
+
+            // act
+            ResponseEntity<ApiResponse<UserDto.UserResponse>> response =
+                    testRestTemplate.exchange(requestUrl, HttpMethod.POST, requestEntity, responseType);
+
+            // assert
+            assertTrue(response.getStatusCode().is2xxSuccessful());
+            assertNotNull(response.getBody());
+            assertAll(
+                    () -> assertEquals(validLoginId, response.getBody().data().loginId())
+                    , () -> assertEquals(validEmail, response.getBody().data().email())
+                    , () -> assertEquals(validBirthday, response.getBody().data().birthday())
+                    , () -> assertEquals(validGender, response.getBody().data().gender())
+            );
+        }
+
+        @DisplayName("실패 케이스 : 회원 가입 시 성별 없을 경우 `400 Bad Request` 응답 반환")
+        @Test
+        void saveUser_noGender_BadRequest() {
+            // arrange
+            UserDto.CreateUserRequest request = UserDto.CreateUserRequest.builder()
+                    .loginId(validLoginId)
+                    .email(validEmail)
+                    .birthday(validBirthday)
+                    .build();
+
+            String requestUrl = ENDPOINT + "/";
+            HttpEntity<UserDto.CreateUserRequest> requestEntity = new HttpEntity<>(request);
+            ParameterizedTypeReference<ApiResponse<UserDto.UserResponse>> responseType = new ParameterizedTypeReference<>() {};
+
+            // act
+            ResponseEntity<ApiResponse<UserDto.UserResponse>> response =
+                    testRestTemplate.exchange(requestUrl, HttpMethod.POST, requestEntity, responseType, request);
+
+            // assert
+            assertTrue(response.getStatusCode().is4xxClientError());
+            assertNotNull(response.getBody());
+            assertAll(
+                    () -> assertEquals(ApiResponse.Metadata.Result.FAIL, response.getBody().meta().result())
+                    , () -> assertEquals("Bad Request", response.getBody().meta().errorCode())
+                    , () -> assertEquals("User : gender가 NULL일 수 없습니다.", response.getBody().meta().message())
+            );
+        }
+    }
+
+}

From 2a1f1a4435a250181d7d735af7cbac419f7c92bb Mon Sep 17 00:00:00 2001
From: Yim Yoobin <yoobinbkk@naver.com>
Date: Fri, 14 Nov 2025 01:30:40 +0900
Subject: [PATCH 14/19] =?UTF-8?q?2=EC=A3=BC=EC=B0=A8=20=EC=9A=94=EA=B5=AC?=
 =?UTF-8?q?=EC=82=AC=ED=95=AD=20=EB=AC=B8=EC=84=9C=20=EB=A6=AC=ED=8C=A9?=
 =?UTF-8?q?=ED=86=A0=EB=A7=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../week2/02-sequence-diagrams/brandAPI.md    |   2 +-
 .../week2/02-sequence-diagrams/orderAPI.md    |  49 ++++++
 .../week2/02-sequence-diagrams/productAPI.md  |  31 ++--
 .../week2/03-class-diagram/class-diagram.md   | 157 ++++++++++--------
 apps/commerce-api/src/doc/week2/04-erd/erd.md |   9 -
 5 files changed, 156 insertions(+), 92 deletions(-)
 create mode 100644 apps/commerce-api/src/doc/week2/02-sequence-diagrams/orderAPI.md

diff --git a/apps/commerce-api/src/doc/week2/02-sequence-diagrams/brandAPI.md b/apps/commerce-api/src/doc/week2/02-sequence-diagrams/brandAPI.md
index 7e66f0fce..86ff465d3 100644
--- a/apps/commerce-api/src/doc/week2/02-sequence-diagrams/brandAPI.md
+++ b/apps/commerce-api/src/doc/week2/02-sequence-diagrams/brandAPI.md
@@ -20,7 +20,7 @@ sequenceDiagram
     R-->>-S: Optional<Brand>
 
     rect rgb(20,20,0)
-        alt Brand가 존재하지 않는 경우
+        opt Brand가 존재하지 않는 경우
             S-->>Client: NullPointerException(message)
             Note left of C: "meta": { "result": "FAIL",<br/>"errorCode": NOT_FOUND,<br/>"message": "브랜드를 찾지 못했습니다." },<br/>"data": {}
         end
diff --git a/apps/commerce-api/src/doc/week2/02-sequence-diagrams/orderAPI.md b/apps/commerce-api/src/doc/week2/02-sequence-diagrams/orderAPI.md
new file mode 100644
index 000000000..61e5aac1e
--- /dev/null
+++ b/apps/commerce-api/src/doc/week2/02-sequence-diagrams/orderAPI.md
@@ -0,0 +1,49 @@
+sequenceDiagram
+    autonumber
+    actor Client
+    participant C as OrderController
+    participant F as OrderFacade
+    participant S as OrderService
+    participant R as Repository
+
+    Client->>+C: POST /api/v1/orders (X-USER-ID, OrderDto.OrderRequest)
+    Note left of C: 주문 요청 (유저ID, 상품ID, 개수)
+    Note left of C: JSON<br/>"items": [<br/>{ "productId": 1, "quantity": 2 },<br/>{ "productId": 3, "quantity": 1 }<br/>]<br/>
+
+    C->>+F: createOrderAndPayment(String userId, OrderInfo orderInfo)
+    F->>+S: createOrder(String userId, Order order)
+    S->>+R: orderRepository.saveOrder(Order order)
+    Note right of S: 1. 주문 객체 및 주문 상품 객체 저장
+    R-->>-S: Order savedOrder
+
+    %% 2. 재고 차감 로직 시작
+
+    rect rgb(20,20,0)
+        opt 재고 부족하면 (Stock Check)
+            S->>+R: checkAndDeductStock(Long productId, Long quantity)
+            Note right of S: 재고 확인 및 차감 트랜잭션 시작
+            R-->>-S: false
+            S-->>-F: throw CoreException(ErrorType.BAD_REQUEST)
+            F-->>-C: throw CoreException(ErrorType.BAD_REQUEST)
+            C-->>-Client: 400 Bad Request ("재고 없음")
+        end
+    end
+
+    %% 3. 결제 (포인트 차감) 로직 시작
+
+    S->>+R: getPoint(String userId)
+    Note right of S: 회원의 포인트 확인
+    R-->>-S: Point point
+    Note right of S: 결제 금액과 포인트 비교
+
+    rect rgb(20,20,0)
+        opt 포인트 부족하면 (Long point)
+            S-->>F: throw CoreException(ErrorType.BAD_REQUEST)
+            F-->>C: throw CoreException(ErrorType.BAD_REQUEST)
+            C-->>Client: 400 Bad Request ("재고 없음")
+        end
+    end
+    
+    S->>+R: calculateAndDeductPoints(userId, totalAmount)
+    Note right of S: 총 결제 금액 계산 및 포인트 차감 시도
+    R-->>-S: isPaymentSuccessful
\ No newline at end of file
diff --git a/apps/commerce-api/src/doc/week2/02-sequence-diagrams/productAPI.md b/apps/commerce-api/src/doc/week2/02-sequence-diagrams/productAPI.md
index 9cbb9f53a..1a643eff6 100644
--- a/apps/commerce-api/src/doc/week2/02-sequence-diagrams/productAPI.md
+++ b/apps/commerce-api/src/doc/week2/02-sequence-diagrams/productAPI.md
@@ -2,12 +2,12 @@
 title: Product API Sequence Diagram
 ---
 sequenceDiagram
-autonumber
-actor Client
-participant C as ProductController
-participant F as ProductFacade
-participant S as ProductService
-participant R as ProductRepository
+    autonumber
+    actor Client
+    participant C as ProductController
+    participant F as ProductFacade
+    participant S as ProductService
+    participant R as ProductRepository
 
     %% 상품 목록 조회
     Client->>+C: GET /api/v1/products (ProductDto.SearchRequest req)
@@ -19,15 +19,16 @@ participant R as ProductRepository
 
     S->>+R: findProductsCustom(Predicate predicate, Pageable pageable)
 
-    Note right of S: QueryDSL 의 Predicate(BooleanBuilder) 를 활용하여<br/>상품 카테고리와 브랜드 조건으로 List<Product> 를 가져오게 한다.
-    Note right of S: Pageable 객체를 활용해서 페이지 관련 데이터를 받아 Response data 에 담는다.
+    Note right of S: QueryDSL 의 Predicate(BooleanBuilder) 를 활용하여<br/>상품 카테고리와 브랜드 조건으로 List<Product> 를 가져오게 한다.<br/><br/>booleanBuilder.and(qProduct.categoryId.eq(req.getCategoryId()))<br/>booleanBuilder.and(qProduct.brand.name.containsIgnoreCase(req.getBrandName()))
+    Note right of S: Pageable 객체를 활용해서 페이지 관련 데이터를 받아 Response data 에 담는다.<br/><br/>Pageable pageable = PageRequest.of(<br/>            req.getPageIndex(),<br/>            req.getPageSize(),<br/>            Sort.by(Sort.Direction.DESC, req.getSortBy())<br/>);
     
-    R-->>-S: Optional<List<Product>>
+    R-->>-S: Optional<List<Product>> products
 
-    alt Product 목록이 있는 경우
-        S->>S: List<Product> 객체 반환
-    else Product 목록이 없는 경우
-        S->>S: List<Product> empty 객체 반환
+    alt products.isPresent == true
+        S->>S: return List<Product> products
+    else products.isPresent == false
+        S->>S: return Collections.emptyList()
+        Note right of S: 비어있는 리스트를 반환
     end
     
     S->>S: List<Product> -> List<ProductInfo>
@@ -46,10 +47,10 @@ participant R as ProductRepository
     F->>+S: getProductDetail(Long productId)
     S->>+R: findById(Long productId)
     
-    R-->>-S: Optional<Product>
+    R-->>-S: Optional<Product> product
 
     rect rgb(20,20,0)
-        alt Product가 존재하지 않는 경우
+        opt products.isPresent == false
             S-->>Client: NullPointerException(message)
             Note left of C: "meta": { "result": "FAIL",<br/>"errorCode": NOT_FOUND,<br/>"message": "상품을 찾지 못했습니다." },<br/>"data": {}
         end
diff --git a/apps/commerce-api/src/doc/week2/03-class-diagram/class-diagram.md b/apps/commerce-api/src/doc/week2/03-class-diagram/class-diagram.md
index cddaad364..8d394a181 100644
--- a/apps/commerce-api/src/doc/week2/03-class-diagram/class-diagram.md
+++ b/apps/commerce-api/src/doc/week2/03-class-diagram/class-diagram.md
@@ -3,93 +3,116 @@ title: E-Commerce Class Diagram
 ---
 classDiagram
     class User {
-        Long id
-        String loginId
-        String email
-        String birthday
-        Enum gender
-        Point point
+        -Long id
+        -String loginId
+        -String email
+        -String birthday
+        -Enum gender
+        -Point point
+        -ZonedDateTime createdAt
+        -ZonedDateTime updatedAt
     }
     class Point {
-        Long id
-        User user
-        Long amount
-        Enum grade
+        -Long id
+        -User user
+        -BigDecimal amount
+        -Enum grade
+        -ZonedDateTime createdAt
+        -ZonedDateTime updatedAt
+        +charge()
     }
     class Product {
-        Long id
-        ProductCategory productCategory
-        Stock stock;
-        Brand brand
-        String name
-        String description
-        Enum status
-        Long price
-        Boolean isVisible
-        Boolean isSellable
+        -Long id
+        -ProductCategory productCategory
+        -Stock stock
+        -Brand brand
+        -String name
+        -String description
+        -Enum status
+        -BigDecimal price
+        -Boolean isVisible
+        -Boolean isSellable
+        -ZonedDateTime createdAt
+        -ZonedDateTime updatedAt
     }
     class ProductCategory {
-        Long id
-        Long parentId
-        List<Product> products
-        String name
-        String description
-        Boolean isVisible
-        Boolean isSellable
+        -Long id
+        -Long parentId
+        -List<Product> products
+        -String name
+        -String description
+        -Boolean isVisible
+        -Boolean isSellable
+        -ZonedDateTime createdAt
+        -ZonedDateTime updatedAt
     }
     class Stock {
-        Long id
-        Product product
-        Long quantity
+        -Long id
+        -Product product
+        -Long quantity
+        -ZonedDateTime createdAt
+        -ZonedDateTime updatedAt
     }
     class Brand {
-        Long id
-        List<Product> products
-        String name
-        String description
-        Enum status
-        Boolean isVisible
-        Boolean isSellable
+        -Long id
+        -List<Product> products
+        -String name
+        -String description
+        -Enum status
+        -Boolean isVisible
+        -Boolean isSellable
+        -ZonedDateTime createdAt
+        -ZonedDateTime updatedAt
     }
     class Like {
-        Long id
-        User user
-        Product product
+        -Long id
+        -User user
+        -Product product
+        -ZonedDateTime createdAt
+        -ZonedDateTime updatedAt
     }
     class Order {
-        Long id
-        User user
-        List<OrderItem> orderItems
-        Enum status
-        Long finalAmount
-        Long totalPrice
-        Long discountAmount
-        Long shippingFee
+        -Long id
+        -User user
+        -List<OrderItem> orderItems
+        -Enum status
+        -BigDecimal finalAmount
+        -BigDecimal totalPrice
+        -BigDecimal discountAmount
+        -BigDecimal shippingFee
+        -ZonedDateTime createdAt
+        -ZonedDateTime updatedAt
     }
     class OrderItem {
-        Long id
-        Order order
-        Product product
-        Integer quantity
-        Long unitPrice
-        Long totalAmount
+        -Long id
+        -Order order
+        -Product product
+        -Integer quantity
+        -BigDecimal unitPrice
+        -BigDecimal totalAmount
+        -ZonedDateTime createdAt
+        -ZonedDateTime updatedAt
     }
     class Payment {
-        Long id
-        Order order
-        String pgTransactionId
-        Enum method
-        Long amount
-        Enum status
+        -Long id
+        -Order order
+        -String pgTransactionId
+        -Enum method
+        -BigDecimal amount
+        -Enum status
+        -ZonedDateTime createdAt
+        -ZonedDateTime updatedAt
     }
     class PageDto {
-        List<T> elements
-        Integer index
-        Integer number
-        Integer size
-        Integer totalElements
-        Integer totalPages
-        String sort
+        -List<T> elements
+        -Integer index
+        -Integer number
+        -Integer size
+        -Integer totalElements
+        -Integer totalPages
+        -String sort
+        -ZonedDateTime createdAt
+        -ZonedDateTime updatedAt
     }
 
     User <-- Point
diff --git a/apps/commerce-api/src/doc/week2/04-erd/erd.md b/apps/commerce-api/src/doc/week2/04-erd/erd.md
index 100c7813a..8d03b9030 100644
--- a/apps/commerce-api/src/doc/week2/04-erd/erd.md
+++ b/apps/commerce-api/src/doc/week2/04-erd/erd.md
@@ -18,7 +18,6 @@ erDiagram
     product {
         bigint id PK
         bigint brand_id FK
-        bigint stock_id FK
         varchar name
         varchar description
         enum status
@@ -26,14 +25,6 @@ erDiagram
         boolean is_visible
         boolean is_sellable
     }
-    product_category {
-        bigint id PK
-        bigint parent_id
-        varchar name
-        varchar description
-        boolean is_visible
-        boolean is_sellable
-    }
     stock {
         bigint id PK
         bigint product_id FK

From 511e4b7a7d86533eb9f79b628cecf5e2c990aaba Mon Sep 17 00:00:00 2001
From: Yim Yoobin <yoobinbkk@naver.com>
Date: Fri, 14 Nov 2025 17:26:08 +0900
Subject: [PATCH 15/19] =?UTF-8?q?[Round=203]=20=EB=AC=B8=EC=84=9C=20?=
 =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../doc/week2/02-sequence-diagrams/likeAPI.md |   4 +-
 .../src/doc/week3/03-class-diagram.md         | 119 ++++++++++++++++++
 apps/commerce-api/src/doc/week3/04-erd.md     |  90 +++++++++++++
 3 files changed, 211 insertions(+), 2 deletions(-)
 create mode 100644 apps/commerce-api/src/doc/week3/03-class-diagram.md
 create mode 100644 apps/commerce-api/src/doc/week3/04-erd.md

diff --git a/apps/commerce-api/src/doc/week2/02-sequence-diagrams/likeAPI.md b/apps/commerce-api/src/doc/week2/02-sequence-diagrams/likeAPI.md
index f63e46114..13dc6f8ab 100644
--- a/apps/commerce-api/src/doc/week2/02-sequence-diagrams/likeAPI.md
+++ b/apps/commerce-api/src/doc/week2/02-sequence-diagrams/likeAPI.md
@@ -14,8 +14,8 @@ sequenceDiagram
     Note left of C: Product 좋아요 등록 API 호출 (유저ID, 상품ID)
     Note left of C: Path Variable: "productId":123<br/>Header:"userId":123
 
-    C->>+F: addProductLike(String userId, Long productId)
-    F->>+S: addProductLike(String userId, Long productId)
+    C->>+F: saveProductLike(String userId, Long productId)
+    F->>+S: saveProductLike(String userId, Long productId)
 
     S->>+R: save(Like likeEntity)
     Note right of S: 중복 등록 시 덮어씀으로 데이터 무결점 확보, 멱등성 실천
diff --git a/apps/commerce-api/src/doc/week3/03-class-diagram.md b/apps/commerce-api/src/doc/week3/03-class-diagram.md
new file mode 100644
index 000000000..a25c41354
--- /dev/null
+++ b/apps/commerce-api/src/doc/week3/03-class-diagram.md
@@ -0,0 +1,119 @@
+---
+title: E-Commerce Class Diagram
+---
+classDiagram
+    class User {
+        -Long id
+        -String loginId
+        -String email
+        -String birthday
+        -Enum gender
+        -Point point
+        -ZonedDateTime createdAt
+        -ZonedDateTime updatedAt
+    }
+    class Point {
+        -Long id
+        -BigDecimal amount
+        -Enum grade
+        -ZonedDateTime createdAt
+        -ZonedDateTime updatedAt
+        -User user
+        +charge(BigDecimal pointAmount)
+        +deduct(BigDecimal pointAmount)
+    }
+    class Product {
+        -Long id
+        -String name
+        -String description
+        -BigDecimal price
+        -Long likeCount
+        -Boolean isVisible
+        -Boolean isSellable
+        -Enum status
+        -Stock stock
+        -Brand brand
+        -ZonedDateTime createdAt
+        -ZonedDateTime updatedAt
+    }
+    class Stock {
+        -Long id
+        -Long quantity
+        -Product product
+        -ZonedDateTime createdAt
+        -ZonedDateTime updatedAt
+        +deduct(Long quantity)
+    }
+    class Brand {
+        -Long id
+        -String name
+        -String description
+        -Enum status
+        -Boolean isVisible
+        -Boolean isSellable
+        -List<Product> products
+        -ZonedDateTime createdAt
+        -ZonedDateTime updatedAt
+    }
+    class Like {
+        -Long id
+        -Long userId
+        -Long targetId
+        -Enum targetType
+        -ZonedDateTime createdAt
+        -ZonedDateTime updatedAt
+    }
+    class Order {
+        -Long id
+        -BigDecimal finalAmount
+        -BigDecimal totalPrice
+        -BigDecimal discountAmount
+        -BigDecimal shippingFee
+        -Enum orderStatus
+        -User user
+        -List<OrderItem> orderItems
+        -ZonedDateTime createdAt
+        -ZonedDateTime updatedAt
+    }
+    class OrderItem {
+        -Long id
+        -Integer quantity
+        -BigDecimal unitPrice
+        -BigDecimal totalAmount
+        -Order order
+        -Product product
+        -ZonedDateTime createdAt
+        -ZonedDateTime updatedAt
+    }
+    class Payment {
+        -Long id
+        -String pgTransactionId
+        -Enum method
+        -BigDecimal amount
+        -Enum paymentStatus
+        -Order order
+        -ZonedDateTime createdAt
+        -ZonedDateTime updatedAt
+    }
+    class PageDto {
+        -List<T> elements
+        -Integer index
+        -Integer number
+        -Integer size
+        -Integer totalElements
+        -Integer totalPages
+        -String sort
+        -ZonedDateTime createdAt
+        -ZonedDateTime updatedAt
+    }
+
+    User <-- Point
+    User <-- Like
+    User <-- Order
+    Product <-- Like
+    Product <-- Stock
+    Product <-- OrderItem
+    Brand <-- Product
+    Order <-- OrderItem
+    Order <-- Payment
+    
\ No newline at end of file
diff --git a/apps/commerce-api/src/doc/week3/04-erd.md b/apps/commerce-api/src/doc/week3/04-erd.md
new file mode 100644
index 000000000..5e087cb82
--- /dev/null
+++ b/apps/commerce-api/src/doc/week3/04-erd.md
@@ -0,0 +1,90 @@
+---
+title: E-Commerce ERD
+---
+erDiagram
+    user {
+        bigint id PK
+        varchar login_id UK
+        varchar email UK
+        varchar birthday
+        enum gender
+    }
+    point {
+        bigint id PK
+        bigint user_id FK
+        bigint amount
+        enum grade
+    }
+    product {
+        bigint id PK
+        bigint brand_id FK
+        varchar name
+        varchar description
+        enum status
+        bigint price
+        boolean is_visible
+        boolean is_sellable
+    }
+    product_category {
+        bigint id PK
+        bigint parent_id
+        varchar name
+        varchar description
+        boolean is_visible
+        boolean is_sellable
+    }
+    stock {
+        bigint id PK
+        bigint product_id FK
+        bigint quantity
+    }
+    brand {
+        bigint id PK
+        varchar name
+        varchar description
+        Enum status
+        boolean is_visible
+        boolean is_sellable
+    }
+    like {
+        bigint id PK
+        bigint user_id FK
+        bigint target_id
+        Enum targetType
+    }
+    order {
+        bigint id PK
+        bigint user_id FK
+        enum status
+        bigint final_amount
+        bigint total_price
+        bigint discount_amount
+        bigint shipping_fee
+    }
+    order_item {
+        bigint id PK
+        bigint order_id FK
+        bigint product_id FK
+        int quantity
+        bigint unit_price
+        bigint total_amount
+    }
+    payment {
+        bigint id PK
+        bigint order_id FK
+        varchar pg_transaction_id
+        enum method
+        bigint amount
+        enum status
+    }
+
+    user ||--|| point : ""
+    user ||--o{ like : ""
+    user ||--o{ order : ""
+    product ||--o{ like : ""
+    product ||--|| stock : ""
+    product ||--o{ order_item : ""
+    brand ||--o{ product : ""
+    product_category ||--o{ product : ""
+    order ||--|{ order_item : ""
+    order ||--|| payment : ""
\ No newline at end of file

From 57a0656d454efb18b4d98a30e28ddb500668e479 Mon Sep 17 00:00:00 2001
From: Yim Yoobin <yoobinbkk@naver.com>
Date: Fri, 14 Nov 2025 17:27:17 +0900
Subject: [PATCH 16/19] =?UTF-8?q?[Round=203]=20User=20=EC=99=80=20Point=20?=
 =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../loopers/application/user/UserFacade.java  | 30 +++---
 .../java/com/loopers/domain/point/Point.java  | 18 ++++
 .../loopers/domain/point/PointService.java    |  3 -
 .../java/com/loopers/domain/user/User.java    | 21 ++--
 .../com/loopers/domain/user/UserService.java  |  3 -
 .../com/loopers/domain/point/PointTest.java   | 96 ++++++++++++++++++-
 6 files changed, 145 insertions(+), 26 deletions(-)

diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java
index 400384475..1b9c0cd1b 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java
@@ -11,6 +11,7 @@
 import org.springframework.transaction.annotation.Transactional;
 
 import java.math.BigDecimal;
+import java.util.ArrayList;
 import java.util.Optional;
 
 @RequiredArgsConstructor
@@ -40,27 +41,32 @@ public UserInfo saveUser(UserInfo userInfo) {
             throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 회원의 포인트를 다시 저장 못합니다.");
         }
 
-        // 포인트 객체 생성 (User 객체는 NULL)
-        Point point = Point.builder()
-                .user(null)
-                .amount(BigDecimal.valueOf(0))
-                .build();
-
-        // 회원 객체 생성 (point 참조)
+        // 회원 객체 생성 (point는 null, 빈 likes 리스트 초기화)
         User user = User.builder()
                 .loginId(userInfo.loginId())
                 .email(userInfo.email())
                 .birthday(userInfo.birthday())
                 .gender(userInfo.gender())
-                .point(point)
+                .point(null)  // Point는 나중에 설정
+                .likes(new ArrayList<>())
                 .build();
 
-        // 회원 저장 (Cascade로 Point도 함께 저장됨)
+        // 회원 저장 (Point 없이 먼저 저장)
         User savedUser = userService.saveUser(user)
-                .orElseThrow(() -> new CoreException(ErrorType.INTERNAL_ERROR, "User 를 저장하지 못했습니다. (참조된 포인트 객체와 함께 저장)"));
+                .orElseThrow(() -> new CoreException(ErrorType.INTERNAL_ERROR, "User 를 저장하지 못했습니다."));
+
+        // 포인트 객체 생성 (저장된 User 참조)
+        Point point = Point.builder()
+                .user(savedUser)  // 저장된 User 참조
+                .amount(BigDecimal.valueOf(0))
+                .build();
+
+        // Point 저장
+        Point savedPoint = pointService.savePoint(point)
+                .orElseThrow(() -> new CoreException(ErrorType.INTERNAL_ERROR, "Point 를 저장하지 못했습니다."));
 
-        // Point의 user 참조 설정 (영속성 컨텍스트가 자동으로 UPDATE)
-        point.setUser(savedUser);
+        // User에 Point 설정 (양방향 관계 완성)
+        savedUser.setPoint(savedPoint);
 
         return UserInfo.from(savedUser);
     }
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java
index 04e08630b..4b0fd055a 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java
@@ -64,4 +64,22 @@ public BigDecimal charge(BigDecimal amount) {
         this.amount = this.amount.add(amount);
         return this.amount;
     }
+
+    public BigDecimal deduct(BigDecimal amount) {
+        // 차감하는 포인트가 음수이면 BAD REQUEST CoreException 을 발생
+        if(amount.compareTo(BigDecimal.ZERO) <= 0) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "차감할 point는 0 이하가 될 수 없습니다.");
+        }
+        
+        // 포인트가 부족하면 BAD REQUEST CoreException 을 발생
+        if(this.amount.compareTo(amount) < 0) {
+            throw new CoreException(
+                ErrorType.BAD_REQUEST, 
+                "포인트가 부족합니다. (현재 포인트: " + this.amount + ", 요청 금액: " + amount + ")"
+            );
+        }
+        
+        this.amount = this.amount.subtract(amount);
+        return this.amount;
+    }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java
index 2d53242bf..f431f7d6b 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java
@@ -2,7 +2,6 @@
 
 import lombok.RequiredArgsConstructor;
 import org.springframework.stereotype.Component;
-import org.springframework.transaction.annotation.Transactional;
 
 import java.util.Optional;
 
@@ -12,12 +11,10 @@ public class PointService {
 
     private final PointRepository pointRepository;
 
-    @Transactional(readOnly = true)
     public Optional<Point> findByUserLoginId(String loginId) {
         return pointRepository.findByUser_loginId(loginId);
     }
 
-    @Transactional
     public Optional<Point> savePoint(Point point) {
         return pointRepository.save(point);
     }
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 3a93aa7a4..d9bfe461c 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java
@@ -1,6 +1,7 @@
 package com.loopers.domain.user;
 
 import com.loopers.domain.BaseEntity;
+import com.loopers.domain.like.Like;
 import com.loopers.domain.point.Point;
 import com.loopers.support.error.CoreException;
 import com.loopers.support.error.ErrorType;
@@ -10,6 +11,8 @@
 import lombok.Getter;
 import lombok.NoArgsConstructor;
 
+import java.util.List;
+
 @Entity
 @Table(name = "user")
 @NoArgsConstructor(access = AccessLevel.PROTECTED)
@@ -19,24 +22,30 @@ public class User extends BaseEntity {
     private String loginId;
     private String email;
     private String birthday;
+    @Enumerated(EnumType.STRING)
     private Gender gender;
 
-    @OneToOne(mappedBy = "user", cascade = CascadeType.PERSIST)
+    @OneToOne(mappedBy = "user")
     private Point point;
 
+    @OneToMany(mappedBy = "user")
+    private List<Like> likes;
+
     @Builder
     private User(
-            String loginId
-            , String email
-            , String birthday
-            , Gender gender
-            , Point point
+        String loginId
+        , String email
+        , String birthday
+        , Gender gender
+        , Point point
+        , List<Like> likes
     ) {
         this.loginId = loginId;
         this.email = email;
         this.birthday = birthday;
         this.gender = gender;
         this.point = point;
+        this.likes = likes;
         this.guard();
     }
 
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 5df001b08..ad132b437 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java
@@ -2,7 +2,6 @@
 
 import lombok.RequiredArgsConstructor;
 import org.springframework.stereotype.Component;
-import org.springframework.transaction.annotation.Transactional;
 
 import java.util.Optional;
 
@@ -12,12 +11,10 @@ public class UserService {
 
     private final UserRepository userRepository;
 
-    @Transactional
     public Optional<User> saveUser(User user) {
         return userRepository.save(user);
     }
 
-    @Transactional(readOnly = true)
     public Optional<User> findUserByLoginId(String loginId) {
         return userRepository.findByLoginId(loginId);
     }
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointTest.java
index df2f96748..42ee4ebcb 100644
--- a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointTest.java
@@ -9,8 +9,7 @@
 
 import java.math.BigDecimal;
 
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.*;
 
 @DisplayName("Point 테스트")
 public class PointTest {
@@ -67,4 +66,97 @@ void charge_inputBelowZero_BadRequest() {
             assertEquals("충전할 point는 0 이하가 될 수 없습니다.", result.getCustomMessage());
         }
     }
+
+    @DisplayName("point 차감 테스트")
+    @Nested
+    class DeductPointTest {
+
+        final String validLoginId = "bobby34";
+        final String validEmail = "bobby34@naver.com";
+        final String validBirthday = "1994-04-08";
+        final Gender validGender = Gender.MALE;
+        final BigDecimal validPoint = BigDecimal.valueOf(10000);
+
+        @DisplayName("성공 케이스 : 정상적인 포인트 차감")
+        @Test
+        void deduct_validAmount_Success() {
+            // arrange
+            Point point = Point.builder()
+                    .user(null)
+                    .amount(validPoint)
+                    .build();
+
+            BigDecimal deductAmount = BigDecimal.valueOf(5000);
+
+            // act
+            BigDecimal result = point.deduct(deductAmount);
+
+            // assert
+            assertEquals(BigDecimal.valueOf(5000), result);
+            assertEquals(BigDecimal.valueOf(5000), point.getAmount());
+        }
+
+        @DisplayName("실패 케이스 : 0 이하의 정수로 포인트를 차감 시 실패")
+        @Test
+        void deduct_inputZero_BadRequest() {
+            // arrange
+            Point point = Point.builder()
+                    .user(null)
+                    .amount(validPoint)
+                    .build();
+
+            BigDecimal requestPoint = BigDecimal.valueOf(0);
+
+            // act
+            CoreException result = assertThrows(CoreException.class,
+                    () -> point.deduct(requestPoint)
+            );
+
+            // assert
+            assertEquals(ErrorType.BAD_REQUEST, result.getErrorType());
+            assertEquals("차감할 point는 0 이하가 될 수 없습니다.", result.getCustomMessage());
+        }
+
+        @DisplayName("실패 케이스 : 음수로 포인트를 차감 시 실패")
+        @Test
+        void deduct_inputBelowZero_BadRequest() {
+            // arrange
+            Point point = Point.builder()
+                    .user(null)
+                    .amount(validPoint)
+                    .build();
+
+            BigDecimal requestPoint = BigDecimal.valueOf(-10);
+
+            // act
+            CoreException result = assertThrows(CoreException.class,
+                    () -> point.deduct(requestPoint)
+            );
+
+            // assert
+            assertEquals(ErrorType.BAD_REQUEST, result.getErrorType());
+            assertEquals("차감할 point는 0 이하가 될 수 없습니다.", result.getCustomMessage());
+        }
+
+        @DisplayName("실패 케이스 : 포인트가 부족한 경우")
+        @Test
+        void deduct_insufficientPoint_BadRequest() {
+            // arrange
+            Point point = Point.builder()
+                    .user(null)
+                    .amount(validPoint)
+                    .build();
+
+            BigDecimal requestPoint = BigDecimal.valueOf(20000); // 현재 포인트보다 많은 금액
+
+            // act
+            CoreException result = assertThrows(CoreException.class,
+                    () -> point.deduct(requestPoint)
+            );
+
+            // assert
+            assertEquals(ErrorType.BAD_REQUEST, result.getErrorType());
+            assertTrue(result.getCustomMessage().contains("포인트가 부족합니다"));
+        }
+    }
 }

From 653b2fbeae792ca991a02b8026d999fd9b99dd16 Mon Sep 17 00:00:00 2001
From: Yim Yoobin <yoobinbkk@naver.com>
Date: Fri, 14 Nov 2025 17:28:17 +0900
Subject: [PATCH 17/19] =?UTF-8?q?[Round=203]=20Product=20=EC=99=80=20Brand?=
 =?UTF-8?q?=20API=20=EA=B0=9C=EB=B0=9C=20=EB=B0=98=EC=98=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../application/brand/BrandFacade.java        |  24 ++
 .../loopers/application/brand/BrandInfo.java  |  27 ++
 .../application/product/ProductFacade.java    |  61 +++
 .../application/product/ProductInfo.java      |  52 +++
 .../java/com/loopers/domain/brand/Brand.java  |  82 ++++
 .../loopers/domain/brand/BrandRepository.java |   8 +
 .../loopers/domain/brand/BrandService.java    |  25 ++
 .../com/loopers/domain/brand/BrandStatus.java |   5 +
 .../com/loopers/domain/product/Product.java   | 117 ++++++
 .../domain/product/ProductCondition.java      |  14 +
 .../domain/product/ProductRepository.java     |  17 +
 .../domain/product/ProductService.java        |  74 ++++
 .../loopers/domain/product/ProductStatus.java |   5 +
 .../brand/BrandJpaRepository.java             |   7 +
 .../brand/BrandRepositoryImpl.java            |  21 +
 .../product/ProductJpaRepository.java         |   7 +
 .../product/ProductQueryRepository.java       | 128 +++++++
 .../product/ProductRepositoryImpl.java        |  50 +++
 .../interfaces/api/brand/BrandApiSpec.java    |  26 ++
 .../interfaces/api/brand/BrandController.java |  24 ++
 .../interfaces/api/brand/BrandDto.java        |  30 ++
 .../api/product/ProductApiSpec.java           |  37 ++
 .../api/product/ProductController.java        |  52 +++
 .../interfaces/api/product/ProductDto.java    |  87 +++++
 .../product/ProductFacadeIntegrationTest.java | 208 ++++++++++
 .../loopers/domain/product/ProductTest.java   | 358 ++++++++++++++++++
 26 files changed, 1546 insertions(+)
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandStatus.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductCondition.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductStatus.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/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductQueryRepository.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandApiSpec.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandDto.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductApiSpec.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductDto.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeIntegrationTest.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java

diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java
new file mode 100644
index 000000000..feb68ec01
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java
@@ -0,0 +1,24 @@
+package com.loopers.application.brand;
+
+import com.loopers.domain.brand.Brand;
+import com.loopers.domain.brand.BrandService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+@RequiredArgsConstructor
+@Component
+public class BrandFacade {
+
+    private final BrandService brandService;
+
+    /**
+     * 브랜드 상세 조회
+     */
+    @Transactional(readOnly = true)
+    public BrandInfo getBrand(Long brandId) {
+        Brand brand = brandService.findById(brandId);
+        return BrandInfo.from(brand);
+    }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java
new file mode 100644
index 000000000..def720eae
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java
@@ -0,0 +1,27 @@
+package com.loopers.application.brand;
+
+import com.loopers.domain.brand.Brand;
+import com.loopers.domain.brand.BrandStatus;
+import lombok.Builder;
+
+@Builder
+public record BrandInfo(
+        Long id,
+        String name,
+        String description,
+        BrandStatus status,
+        Boolean isVisible,
+        Boolean isSellable
+) {
+    public static BrandInfo from(Brand brand) {
+        return BrandInfo.builder()
+                .id(brand.getId())
+                .name(brand.getName())
+                .description(brand.getDescription())
+                .status(brand.getStatus())
+                .isVisible(brand.getIsVisible())
+                .isSellable(brand.getIsSellable())
+                .build();
+    }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
new file mode 100644
index 000000000..d53ee5faa
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
@@ -0,0 +1,61 @@
+package com.loopers.application.product;
+
+import com.loopers.domain.product.Product;
+import com.loopers.domain.product.ProductCondition;
+import com.loopers.domain.product.ProductService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+@RequiredArgsConstructor
+@Component
+public class ProductFacade {
+
+    private final ProductService productService;
+
+    /**
+     * 상품 목록 조회
+     */
+    @Transactional(readOnly = true)
+    public Page<ProductInfo> getProducts(ProductCondition condition, Pageable pageable) {
+        Page<Product> products = productService.findProducts(condition, pageable);
+        return products.map(ProductInfo::from);
+    }
+
+    /**
+     * 상품 상세 조회 (Product + Brand 조합)
+     * 도메인 서비스에서 Product + Brand 조합 로직을 처리하도록 위임
+     */
+    @Transactional(readOnly = true)
+    public ProductInfo getProduct(Long productId) {
+        ProductService.ProductWithBrand productWithBrand = productService.getProductDetailWithBrand(productId);
+        return ProductInfo.from(productWithBrand);
+    }
+
+    /**
+     * 상품 저장
+     */
+    @Transactional
+    public ProductInfo saveProduct(ProductInfo productInfo) {
+        
+        Product product = Product.builder()
+                .name(productInfo.name())
+                .description(productInfo.description())
+                .price(productInfo.price())
+                .likeCount(productInfo.likeCount())
+                .status(productInfo.status())
+                .isVisible(productInfo.isVisible())
+                .isSellable(productInfo.isSellable())
+                .build();
+
+        Product savedProduct = productService.saveProduct(product)
+                .orElseThrow(() -> new com.loopers.support.error.CoreException(
+                        com.loopers.support.error.ErrorType.INTERNAL_ERROR,
+                        "Product 저장에 실패했습니다."
+                ));
+        return ProductInfo.from(savedProduct);
+    }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java
new file mode 100644
index 000000000..2ccd03781
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java
@@ -0,0 +1,52 @@
+package com.loopers.application.product;
+
+import com.loopers.application.brand.BrandInfo;
+import com.loopers.domain.product.Product;
+import com.loopers.domain.product.ProductService;
+import com.loopers.domain.product.ProductStatus;
+import lombok.Builder;
+
+import java.math.BigDecimal;
+
+@Builder
+public record ProductInfo(
+        Long id
+        , String name
+        , String description
+        , BigDecimal price
+        , Long likeCount
+        , ProductStatus status
+        , Boolean isVisible
+        , Boolean isSellable
+        , BrandInfo brand
+) {
+    public static ProductInfo from(Product product) {
+        return ProductInfo.builder()
+                .id(product.getId())
+                .name(product.getName())
+                .description(product.getDescription())
+                .price(product.getPrice())
+                .likeCount(product.getLikeCount())
+                .status(product.getStatus())
+                .isVisible(product.getIsVisible())
+                .isSellable(product.getIsSellable())
+                .brand(product.getBrand() != null ? BrandInfo.from(product.getBrand()) : null)
+                .build();
+    }
+
+    public static ProductInfo from(ProductService.ProductWithBrand productWithBrand) {
+        Product product = productWithBrand.getProduct();
+        return ProductInfo.builder()
+                .id(product.getId())
+                .name(product.getName())
+                .description(product.getDescription())
+                .price(product.getPrice())
+                .likeCount(product.getLikeCount())
+                .status(product.getStatus())
+                .isVisible(product.getIsVisible())
+                .isSellable(product.getIsSellable())
+                .brand(BrandInfo.from(productWithBrand.getBrand()))
+                .build();
+    }
+}
+
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..a239a66ef
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java
@@ -0,0 +1,82 @@
+package com.loopers.domain.brand;
+
+import com.loopers.domain.BaseEntity;
+import com.loopers.domain.product.Product;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+@Entity
+@Table(name = "brand")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Getter
+public class Brand extends BaseEntity {
+
+    private String name;
+    private String description;
+    @Enumerated(EnumType.STRING)
+    private BrandStatus status;
+    private Boolean isVisible;
+    private Boolean isSellable;
+
+    @OneToMany(mappedBy = "brand")
+    List<Product> products;
+
+    @Builder
+    private Brand(
+        String name
+        , String description
+        , BrandStatus status
+        , Boolean isVisible
+        , Boolean isSellable
+        , List<Product> products
+    ) {
+        this.name = name;
+        this.description = description;
+        this.status = status;
+        this.isVisible = isVisible;
+        this.isSellable = isSellable;
+        this.products = products;
+    }
+
+    // 상품 목록 필드를 세팅 (초기화 위해 있음)
+    public void setPoint(List<Product> products) {
+        if(this.products != null) {
+            throw new CoreException(ErrorType.CONFLICT, "Brand : List<Product> 가 이미 존재합니다.");
+        }
+        this.products = products;
+    }
+
+    // 유효성 검사
+    @Override
+    protected void guard() {
+        // name 유효성 검사
+        if(name == null || name.isBlank()) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "Brand : name이 비어있을 수 없습니다.");
+        }
+
+        // description nullable
+
+        // status 유효성 검사
+        if(status == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "Brand : status 가 비어있을 수 없습니다.");
+        }
+
+        // isVisible 유효성 검사
+        if(isVisible == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "Brand : isVisible 가 비어있을 수 없습니다.");
+        }
+
+        // isSellable 유효성 검사
+        if(isSellable == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "Brand : isSellable 가 비어있을 수 없습니다.");
+        }
+    }
+
+}
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..362156bb2
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java
@@ -0,0 +1,8 @@
+package com.loopers.domain.brand;
+
+import java.util.Optional;
+
+public interface BrandRepository {
+    Optional<Brand> findById(Long brandId);
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java
new file mode 100644
index 000000000..d9222ac16
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java
@@ -0,0 +1,25 @@
+package com.loopers.domain.brand;
+
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+@Component
+@RequiredArgsConstructor
+public class BrandService {
+
+    private final BrandRepository brandRepository;
+
+    /**
+     * 브랜드 조회
+     */
+    public Brand findById(Long brandId) {
+        return brandRepository.findById(brandId)
+                .orElseThrow(() -> new CoreException(
+                        ErrorType.NOT_FOUND,
+                        "[brandId = " + brandId + "] Brand를 찾을 수 없습니다."
+                ));
+    }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandStatus.java
new file mode 100644
index 000000000..37742570c
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandStatus.java
@@ -0,0 +1,5 @@
+package com.loopers.domain.brand;
+
+public enum BrandStatus {
+    ON_SALE, SOLD_OUT, DISCONTINUED, HIDDEN
+}
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..bbcfb7c2e
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java
@@ -0,0 +1,117 @@
+package com.loopers.domain.product;
+
+import com.loopers.domain.BaseEntity;
+import com.loopers.domain.brand.Brand;
+import com.loopers.domain.stock.Stock;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.math.BigDecimal;
+
+@Entity
+@Table(name = "product")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Getter
+public class Product extends BaseEntity {
+
+    private String name;
+    private String description;
+    private BigDecimal price;
+    private Long likeCount;
+    @Enumerated(EnumType.STRING)
+    private ProductStatus status;
+    private Boolean isVisible;
+    private Boolean isSellable;
+
+    @ManyToOne(fetch = FetchType.LAZY)
+    @JoinColumn(name = "brand_id")
+    private Brand brand;
+
+    @OneToOne(mappedBy = "product")
+    private Stock stock;
+
+    @Builder
+    private Product(
+        String name
+        , String description
+        , BigDecimal price
+        , Long likeCount
+        , ProductStatus status
+        , Boolean isVisible
+        , Boolean isSellable
+        , Brand brand
+        , Stock stock
+    ) {
+        this.name = name;
+        this.description = description;
+        this.price = price;
+        this.likeCount = likeCount;
+        this.status = status;
+        this.isVisible = isVisible;
+        this.isSellable = isSellable;
+        this.brand = brand;
+        this.stock = stock;
+        guard();
+    }
+
+    // 브랜드 필드를 세팅 (초기화 위해 있음)
+    public void setBrand(Brand brand) {
+        if(this.brand != null) {
+            throw new CoreException(ErrorType.CONFLICT, "Product : Brand 가 이미 존재합니다.");
+        }
+        this.brand = brand;
+    }
+
+    // 재고 필드를 세팅 (초기화 위해 있음)
+    public void setStock(Stock stock) {
+        if(this.stock != null) {
+            throw new CoreException(ErrorType.CONFLICT, "Product : Stock 가 이미 존재합니다.");
+        }
+        this.stock = stock;
+    }
+
+    // 유효성 검사
+    @Override
+    protected void guard() {
+        // name 유효성 검사
+        if(name == null || name.isBlank()) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "Product : name이 비어있을 수 없습니다.");
+        }
+
+        // description nullable
+
+        // price 유효성 검사
+        if(price == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "Product : price 가 비어있을 수 없습니다.");
+        } else if(price.compareTo(BigDecimal.ZERO) < 0) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "Product : price 는 음수가 될 수 없습니다.");
+        }
+
+        // likeCount 유효성 검사
+        if(likeCount == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "Product : likeCount 가 비어있을 수 없습니다.");
+        } else if(likeCount < 0) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "Product : likeCount 는 음수가 될 수 없습니다.");
+        }
+
+        // status 유효성 검사
+        if(status == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "Product : status 가 비어있을 수 없습니다.");
+        }
+
+        // isVisible 유효성 검사
+        if(isVisible == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "Product : isVisible 가 비어있을 수 없습니다.");
+        }
+
+        // isSellable 유효성 검사
+        if(isSellable == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "Product : isSellable 가 비어있을 수 없습니다.");
+        }
+    }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductCondition.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductCondition.java
new file mode 100644
index 000000000..ded85af92
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductCondition.java
@@ -0,0 +1,14 @@
+package com.loopers.domain.product;
+
+import java.math.BigDecimal;
+import java.time.ZonedDateTime;
+
+import lombok.Builder;
+
+@Builder
+public record ProductCondition(
+    BigDecimal price
+    , Long likeCount
+    , ZonedDateTime createdAt
+    , String sort
+) {}
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..2a8d02edc
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java
@@ -0,0 +1,17 @@
+package com.loopers.domain.product;
+
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+
+import java.util.Optional;
+
+public interface ProductRepository {
+
+    Optional<Product> save(Product product);
+    Optional<Product> findById(Long productId);
+    Optional<Product> findByIdWithBrand(Long productId);
+    Page<Product> findProducts(ProductCondition condition, Pageable pageable);
+    void incrementLikeCount(Long productId);
+    void decrementLikeCount(Long productId);
+    
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java
new file mode 100644
index 000000000..f4c3f2213
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java
@@ -0,0 +1,74 @@
+package com.loopers.domain.product;
+
+import com.loopers.domain.brand.Brand;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Component;
+
+import java.util.Optional;
+
+@Component
+@RequiredArgsConstructor
+public class ProductService {
+
+    private final ProductRepository productRepository;
+
+    public Optional<Product> saveProduct(Product product) {
+        return productRepository.save(product);
+    }
+
+    public Optional<Product> findById(Long productId) {
+        return productRepository.findById(productId);
+    }
+
+    public Page<Product> findProducts(ProductCondition condition, Pageable pageable) {
+        return productRepository.findProducts(condition, pageable);
+    }
+
+    /**
+     * 상품과 브랜드 정보를 조합하여 조회
+     * 도메인 서비스에서 Product + Brand 조합 로직 처리
+     * Repository에서 fetch join을 통해 Brand를 함께 조회
+     */
+    public ProductWithBrand getProductDetailWithBrand(Long productId) {
+        Product product = productRepository.findByIdWithBrand(productId)
+                .orElseThrow(() -> new CoreException(
+                        ErrorType.NOT_FOUND,
+                        "[productId = " + productId + "] Product를 찾을 수 없습니다."
+                ));
+
+        Brand brand = product.getBrand();
+        if (brand == null) {
+            throw new CoreException(
+                    ErrorType.NOT_FOUND,
+                    "[productId = " + productId + "] Product에 Brand 정보가 없습니다."
+            );
+        }
+
+        return new ProductWithBrand(product, brand);
+    }
+
+    /**
+     * Product와 Brand를 조합한 도메인 객체
+     */
+    public static class ProductWithBrand {
+        private final Product product;
+        private final Brand brand;
+
+        public ProductWithBrand(Product product, Brand brand) {
+            this.product = product;
+            this.brand = brand;
+        }
+
+        public Product getProduct() {
+            return product;
+        }
+
+        public Brand getBrand() {
+            return brand;
+        }
+    }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductStatus.java
new file mode 100644
index 000000000..a908987b2
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductStatus.java
@@ -0,0 +1,5 @@
+package com.loopers.domain.product;
+
+public enum ProductStatus {
+    ON_SALE, SOLD_OUT, DISCONTINUED, HIDDEN
+}
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..63698df49
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java
@@ -0,0 +1,7 @@
+package com.loopers.infrastructure.brand;
+
+import com.loopers.domain.brand.Brand;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+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..5964c6d06
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java
@@ -0,0 +1,21 @@
+package com.loopers.infrastructure.brand;
+
+import com.loopers.domain.brand.Brand;
+import com.loopers.domain.brand.BrandRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import java.util.Optional;
+
+@RequiredArgsConstructor
+@Component
+public class BrandRepositoryImpl implements BrandRepository {
+
+    private final BrandJpaRepository brandJpaRepository;
+
+    @Override
+    public Optional<Brand> findById(Long brandId) {
+        return brandJpaRepository.findById(brandId);
+    }
+}
+
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..0bcb4c2f2
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java
@@ -0,0 +1,7 @@
+package com.loopers.infrastructure.product;
+
+import com.loopers.domain.product.Product;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface ProductJpaRepository extends JpaRepository<Product, Long> {}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductQueryRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductQueryRepository.java
new file mode 100644
index 000000000..a4c04a9a3
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductQueryRepository.java
@@ -0,0 +1,128 @@
+package com.loopers.infrastructure.product;
+
+import com.loopers.domain.product.Product;
+import com.loopers.domain.product.ProductCondition;
+import com.loopers.domain.product.QProduct;
+import com.querydsl.core.BooleanBuilder;
+import com.querydsl.core.types.OrderSpecifier;
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import jakarta.persistence.EntityManager;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageImpl;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+@RequiredArgsConstructor
+@Component
+public class ProductQueryRepository {
+
+    private final JPAQueryFactory queryFactory;
+    private final EntityManager entityManager;
+    private final QProduct product = QProduct.product;
+
+    public Page<Product> findProducts(ProductCondition condition, Pageable pageable) {
+    
+    // 1. where 조건 생성 (간결하게)
+    BooleanBuilder whereCondition = new BooleanBuilder();
+    
+    // if (condition.price() != null) {
+    //     whereCondition.and(product.price.eq(condition.price()));
+    // }
+    // if (condition.likeCount() != null) {
+    //     whereCondition.and(product.likeCount.goe(condition.likeCount()));
+    // }
+    // if (condition.createdAt() != null) {
+    //     whereCondition.and(product.createdAt.goe(condition.createdAt()));
+    // }
+    
+    // 2. 정렬 조건
+    final Map<String, OrderSpecifier<?>> ORDER_BY_MAP = Map.of(
+        "latest", product.createdAt.desc(),
+        "price_asc", product.price.asc(),
+        "likes_desc", product.likeCount.desc()
+    );
+    
+    OrderSpecifier<?> orderSpecifier = ORDER_BY_MAP.getOrDefault(
+        condition.sort(),
+        ORDER_BY_MAP.get("latest")
+    );
+    
+    // 3. 데이터 조회
+    List<Product> products = queryFactory
+            .selectFrom(product)
+            .where(whereCondition)
+            .orderBy(orderSpecifier)
+            .offset(pageable.getOffset())
+            .limit(pageable.getPageSize())
+            .fetch();
+    
+    // 4. 전체 개수 조회
+    Long total = queryFactory
+            .select(product.count())
+            .from(product)
+            .where(whereCondition)
+            .fetchOne();
+    
+    return new PageImpl<>(products, pageable, total != null ? total : 0L);
+}
+
+    /**
+     * 상품 좋아요 수 증가 (동시성 안전)
+     * 원자적으로 like_count를 1 증가시킴
+     * 
+     * @param productId 상품 ID
+     */
+    public void incrementLikeCount(Long productId) {
+        String sql = """
+            UPDATE product 
+            SET like_count = like_count + 1 
+            WHERE id = :productId
+            """;
+
+        entityManager.createNativeQuery(sql)
+                .setParameter("productId", productId)
+                .executeUpdate();
+    }
+
+    /**
+     * 상품 좋아요 수 감소 (동시성 안전)
+     * 원자적으로 like_count를 1 감소시킴 (0 미만으로는 감소하지 않음)
+     * 
+     * @param productId 상품 ID
+     */
+    public void decrementLikeCount(Long productId) {
+        String sql = """
+            UPDATE product 
+            SET like_count = like_count - 1 
+            WHERE id = :productId 
+            AND like_count > 0
+            """;
+
+        entityManager.createNativeQuery(sql)
+                .setParameter("productId", productId)
+                .executeUpdate();
+    }
+
+    /**
+     * 상품 ID로 상품과 브랜드 정보를 함께 조회 (fetch join)
+     * 도메인 서비스에서 Product + Brand 조합 로직을 위해 사용
+     * 
+     * @param productId 상품 ID
+     * @return Product (Brand 포함)
+     */
+    public Optional<Product> findByIdWithBrand(Long productId) {
+        Product result = queryFactory
+                .selectFrom(product)
+                .leftJoin(product.brand).fetchJoin()
+                .where(product.id.eq(productId))
+                .fetchOne();
+        return Optional.ofNullable(result);
+    }
+
+}
+
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..56e74c041
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java
@@ -0,0 +1,50 @@
+package com.loopers.infrastructure.product;
+
+import com.loopers.domain.product.Product;
+import com.loopers.domain.product.ProductCondition;
+import com.loopers.domain.product.ProductRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Component;
+
+import java.util.Optional;
+
+@RequiredArgsConstructor
+@Component
+public class ProductRepositoryImpl implements ProductRepository {
+
+    private final ProductJpaRepository productJpaRepository;
+    private final ProductQueryRepository productQueryRepository;
+
+    @Override
+    public Optional<Product> save(Product product) {
+        Product savedProduct = productJpaRepository.save(product);
+        return Optional.of(savedProduct);
+    }
+
+    @Override
+    public Optional<Product> findById(Long productId) {
+        return productJpaRepository.findById(productId);
+    }
+
+    @Override
+    public Optional<Product> findByIdWithBrand(Long productId) {
+        return productQueryRepository.findByIdWithBrand(productId);
+    }
+
+    @Override
+    public Page<Product> findProducts(ProductCondition condition, Pageable pageable) {
+        return productQueryRepository.findProducts(condition, pageable);
+    }
+
+    @Override
+    public void incrementLikeCount(Long productId) {
+        productQueryRepository.incrementLikeCount(productId);
+    }
+
+    @Override
+    public void decrementLikeCount(Long productId) {
+        productQueryRepository.decrementLikeCount(productId);
+    }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandApiSpec.java
new file mode 100644
index 000000000..a1a29ad2a
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandApiSpec.java
@@ -0,0 +1,26 @@
+package com.loopers.interfaces.api.brand;
+
+import com.loopers.interfaces.api.ApiResponse;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.enums.ParameterIn;
+import io.swagger.v3.oas.annotations.tags.Tag;
+
+@Tag(name = "Brand API", description = "브랜드 관련 API 입니다.")
+public interface BrandApiSpec {
+
+    @Operation(
+            summary = "브랜드 정보 조회"
+            , description = "브랜드 ID로 브랜드 상세 정보를 조회합니다."
+    )
+    ApiResponse<BrandDto.BrandResponse> getBrand(
+            @Parameter(
+                    name = "brandId"
+                    , in = ParameterIn.PATH
+                    , description = "브랜드 ID"
+                    , required = true
+            )
+            Long brandId
+    );
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java
new file mode 100644
index 000000000..9371f06ae
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java
@@ -0,0 +1,24 @@
+package com.loopers.interfaces.api.brand;
+
+import com.loopers.application.brand.BrandFacade;
+import com.loopers.interfaces.api.ApiResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/api/v1/brands")
+public class BrandController implements BrandApiSpec {
+
+    private final BrandFacade brandFacade;
+
+    @GetMapping("/{brandId}")
+    @Override
+    public ApiResponse<BrandDto.BrandResponse> getBrand(
+            @PathVariable Long brandId
+    ) {
+        var brandInfo = brandFacade.getBrand(brandId);
+        return ApiResponse.success(BrandDto.BrandResponse.from(brandInfo));
+    }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandDto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandDto.java
new file mode 100644
index 000000000..0d46f2481
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandDto.java
@@ -0,0 +1,30 @@
+package com.loopers.interfaces.api.brand;
+
+import com.loopers.application.brand.BrandInfo;
+import com.loopers.domain.brand.BrandStatus;
+import lombok.Builder;
+
+public class BrandDto {
+
+    @Builder
+    public record BrandResponse(
+            Long id,
+            String name,
+            String description,
+            BrandStatus status,
+            Boolean isVisible,
+            Boolean isSellable
+    ) {
+        public static BrandResponse from(BrandInfo brandInfo) {
+            return BrandResponse.builder()
+                    .id(brandInfo.id())
+                    .name(brandInfo.name())
+                    .description(brandInfo.description())
+                    .status(brandInfo.status())
+                    .isVisible(brandInfo.isVisible())
+                    .isSellable(brandInfo.isSellable())
+                    .build();
+        }
+    }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductApiSpec.java
new file mode 100644
index 000000000..4a10383ca
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductApiSpec.java
@@ -0,0 +1,37 @@
+package com.loopers.interfaces.api.product;
+
+import com.loopers.interfaces.api.ApiResponse;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.enums.ParameterIn;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.data.domain.Page;
+
+@Tag(name = "Product API", description = "상품 관련 API 입니다.")
+public interface ProductApiSpec {
+
+    @Operation(
+            summary = "상품 목록 조회"
+            , description = "상품 목록을 페이징하여 조회합니다. 검색 조건과 정렬 옵션을 지원합니다."
+    )
+    ApiResponse<Page<ProductDto.ProductResponse>> getProducts(
+            @Schema(name = "검색 조건", description = "상품 검색 조건 및 페이징 정보")
+            ProductDto.SearchRequest request
+    );
+
+    @Operation(
+            summary = "상품 상세 조회"
+            , description = "상품 ID로 상품 상세 정보를 조회합니다."
+    )
+    ApiResponse<ProductDto.ProductResponse> getProduct(
+            @Parameter(
+                    name = "productId"
+                    , in = ParameterIn.PATH
+                    , description = "상품 ID"
+                    , required = true
+            )
+            Long productId
+    );
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java
new file mode 100644
index 000000000..f1a8531d2
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java
@@ -0,0 +1,52 @@
+package com.loopers.interfaces.api.product;
+
+import com.loopers.application.product.ProductFacade;
+import com.loopers.interfaces.api.ApiResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.web.bind.annotation.*;
+import com.loopers.domain.product.ProductCondition;
+
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/api/v1/products")
+public class ProductController implements ProductApiSpec {
+
+    private final ProductFacade productFacade;
+
+    @GetMapping
+    @Override
+    public ApiResponse<Page<ProductDto.ProductResponse>> getProducts(
+            @ModelAttribute ProductDto.SearchRequest request
+    ) {
+        // DTO → Domain 변환
+        ProductCondition condition = request.toCondition();
+        
+        // PageRequest 생성
+        Pageable pageable = PageRequest.of(
+                request.page() != null ? request.page() : 0,
+                request.size() != null ? request.size() : 20
+        );
+        
+        // Facade 호출 및 변환
+        var productInfoPage = productFacade.getProducts(condition, pageable);
+
+        // PageResponse로 변환
+        return ApiResponse.success(productInfoPage.map(ProductDto.ProductResponse::from));
+    }
+
+    @GetMapping("/{productId}")
+    @Override
+    public ApiResponse<ProductDto.ProductResponse> getProduct(
+            @PathVariable Long productId
+    ) {
+        // Facade 호출 (단일 객체)
+        var productInfo = productFacade.getProduct(productId);
+        
+        // Info → DTO 변환 후 반환
+        return ApiResponse.success(ProductDto.ProductResponse.from(productInfo));
+    }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductDto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductDto.java
new file mode 100644
index 000000000..c16c8e40f
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductDto.java
@@ -0,0 +1,87 @@
+package com.loopers.interfaces.api.product;
+
+import com.loopers.application.product.ProductInfo;
+import com.loopers.domain.product.ProductCondition;
+import com.loopers.domain.product.ProductStatus;
+import com.loopers.interfaces.api.brand.BrandDto;
+import lombok.Builder;
+import org.springframework.data.domain.Page;
+
+import java.math.BigDecimal;
+import java.time.ZonedDateTime;
+import java.util.List;
+
+public class ProductDto {
+
+    @Builder
+    public record SearchRequest(
+            // 검색 조건
+            BigDecimal price,
+            Long likeCount,
+            ZonedDateTime createdAt,
+            
+            // 페이징
+            Integer page,
+            Integer size,
+            String sort
+    ) {
+        public ProductCondition toCondition() {
+            return new ProductCondition(
+                    price,
+                    likeCount,
+                    createdAt,
+                    sort != null ? sort : "latest"
+            );
+        }
+    }
+
+    @Builder
+    public record ProductResponse(
+            Long id,
+            String name,
+            String description,
+            BigDecimal price,
+            Long likeCount,
+            ProductStatus status,
+            Boolean isVisible,
+            Boolean isSellable,
+            BrandDto.BrandResponse brand
+    ) {
+        public static ProductResponse from(ProductInfo productInfo) {
+            return ProductResponse.builder()
+                    .id(productInfo.id())
+                    .name(productInfo.name())
+                    .description(productInfo.description())
+                    .price(productInfo.price())
+                    .likeCount(productInfo.likeCount())
+                    .status(productInfo.status())
+                    .isVisible(productInfo.isVisible())
+                    .isSellable(productInfo.isSellable())
+                    .brand(productInfo.brand() != null ? BrandDto.BrandResponse.from(productInfo.brand()) : null)
+                    .build();
+        }
+    }
+
+    @Builder
+    public record PageResponse<T>(
+            List<T> content,
+            Integer page,
+            Integer size,
+            Long totalElements,
+            Integer totalPages,
+            Boolean isFirst,
+            Boolean isLast
+    ) {
+        public static <T> PageResponse<T> from(Page<T> page) {
+            return PageResponse.<T>builder()
+                    .content(page.getContent())
+                    .page(page.getNumber())
+                    .size(page.getSize())
+                    .totalElements(page.getTotalElements())
+                    .totalPages(page.getTotalPages())
+                    .isFirst(page.isFirst())
+                    .isLast(page.isLast())
+                    .build();
+        }
+    }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeIntegrationTest.java
new file mode 100644
index 000000000..78908339c
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeIntegrationTest.java
@@ -0,0 +1,208 @@
+package com.loopers.application.product;
+
+import com.loopers.domain.product.Product;
+import com.loopers.domain.product.ProductCondition;
+import com.loopers.domain.product.ProductStatus;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import com.loopers.utils.DatabaseCleanUp;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+
+import java.math.BigDecimal;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.*;
+
+@DisplayName("ProductFacade 통합 테스트")
+@SpringBootTest
+class ProductFacadeIntegrationTest {
+
+    @Autowired
+    private ProductFacade productFacade;
+
+    @Autowired
+    private DatabaseCleanUp databaseCleanUp;
+
+    @AfterEach
+    void tearDown() {
+        databaseCleanUp.truncateAllTables();
+    }
+
+    @DisplayName("getProducts 메서드")
+    @Nested
+    class GetProductsTest {
+
+        @DisplayName("성공 케이스: sort가 'latest'이면 생성일시 내림차순으로 정렬된 상품 목록을 반환한다")
+        @Test
+        void getProducts_withSortLatest_returnsProductsSortedByCreatedAtDesc() throws InterruptedException {
+            // arrange
+            ProductInfo product1 = createAndSaveProduct("상품1", BigDecimal.valueOf(10000L), 10L);
+            Thread.sleep(10); // 생성 시간 차이를 위해
+            ProductInfo product2 = createAndSaveProduct("상품2", BigDecimal.valueOf(20000L), 20L);
+            Thread.sleep(10);
+            ProductInfo product3 = createAndSaveProduct("상품3", BigDecimal.valueOf(30000L), 30L);
+
+            Pageable pageable = PageRequest.of(0, 10);
+            ProductCondition condition = ProductCondition.builder()
+                    .sort("latest")
+                    .build();
+
+            // act
+            Page<ProductInfo> result = productFacade.getProducts(condition, pageable);
+
+            // assert
+            assertAll(
+                    () -> assertThat(result.getContent()).hasSize(3),
+                    () -> assertThat(result.getContent().get(0).name()).isEqualTo("상품3"), // 최신순
+                    () -> assertThat(result.getContent().get(1).name()).isEqualTo("상품2"),
+                    () -> assertThat(result.getContent().get(2).name()).isEqualTo("상품1"),
+                    () -> assertThat(result.getTotalElements()).isEqualTo(3L)
+            );
+        }
+
+        @DisplayName("성공 케이스: sort가 'price_asc'이면 가격 오름차순으로 정렬된 상품 목록을 반환한다")
+        @Test
+        void getProducts_withSortPriceAsc_returnsProductsSortedByPriceAsc() {
+            // arrange
+            ProductInfo product1 = createAndSaveProduct("상품1", BigDecimal.valueOf(30000L), 10L);
+            ProductInfo product2 = createAndSaveProduct("상품2", BigDecimal.valueOf(10000L), 20L);
+            ProductInfo product3 = createAndSaveProduct("상품3", BigDecimal.valueOf(20000L), 30L);
+
+            Pageable pageable = PageRequest.of(0, 10);
+            ProductCondition condition = ProductCondition.builder()
+                    .sort("price_asc")
+                    .build();
+
+            // act
+            Page<ProductInfo> result = productFacade.getProducts(condition, pageable);
+
+            // assert
+            assertAll(
+                    () -> assertThat(result.getContent()).hasSize(3),
+                    () -> assertThat(result.getContent().get(0).price()).isEqualByComparingTo(BigDecimal.valueOf(10000L)),
+                    () -> assertThat(result.getContent().get(1).price()).isEqualByComparingTo(BigDecimal.valueOf(20000L)),
+                    () -> assertThat(result.getContent().get(2).price()).isEqualByComparingTo(BigDecimal.valueOf(30000L))
+            );
+        }
+
+        @DisplayName("성공 케이스: sort가 'likes_desc'이면 좋아요 수 내림차순으로 정렬된 상품 목록을 반환한다")
+        @Test
+        void getProducts_withSortLikesDesc_returnsProductsSortedByLikesDesc() {
+            // arrange
+            ProductInfo product1 = createAndSaveProduct("상품1", BigDecimal.valueOf(10000L), 10L);
+            ProductInfo product2 = createAndSaveProduct("상품2", BigDecimal.valueOf(20000L), 30L);
+            ProductInfo product3 = createAndSaveProduct("상품3", BigDecimal.valueOf(30000L), 20L);
+
+            Pageable pageable = PageRequest.of(0, 10);
+            ProductCondition condition = ProductCondition.builder()
+                    .sort("likes_desc")
+                    .build();
+
+            // act
+            Page<ProductInfo> result = productFacade.getProducts(condition, pageable);
+
+            // assert
+            assertAll(
+                    () -> assertThat(result.getContent()).hasSize(3),
+                    () -> assertThat(result.getContent().get(0).likeCount()).isEqualTo(30L),
+                    () -> assertThat(result.getContent().get(1).likeCount()).isEqualTo(20L),
+                    () -> assertThat(result.getContent().get(2).likeCount()).isEqualTo(10L)
+            );
+        }
+
+        @DisplayName("성공 케이스: 페이징이 제대로 동작한다")
+        @Test
+        void getProducts_withPaging_returnsPagedResults() {
+            // arrange
+            for (int i = 1; i <= 5; i++) {
+                createAndSaveProduct("상품" + i, BigDecimal.valueOf(10000L * i), 10L);
+            }
+
+            Pageable pageable = PageRequest.of(1, 2); // 두 번째 페이지, 페이지당 2개
+            ProductCondition condition = ProductCondition.builder()
+                    .sort("latest")
+                    .build();
+
+            // act
+            Page<ProductInfo> result = productFacade.getProducts(condition, pageable);
+
+            // assert
+            assertAll(
+                    () -> assertThat(result.getContent()).hasSize(2),
+                    () -> assertThat(result.getNumber()).isEqualTo(1),
+                    () -> assertThat(result.getSize()).isEqualTo(2),
+                    () -> assertThat(result.getTotalElements()).isEqualTo(5L),
+                    () -> assertThat(result.getTotalPages()).isEqualTo(3)
+            );
+        }
+    }
+
+    @DisplayName("getProduct 메서드")
+    @Nested
+    class GetProductTest {
+
+        @DisplayName("성공 케이스: 존재하는 Product ID로 조회하면 해당 ProductInfo를 반환한다")
+        @Test
+        void getProduct_withValidId_returnsProductInfo() {
+            // arrange
+            ProductInfo productInfo = createAndSaveProduct("테스트 상품", BigDecimal.valueOf(10000L), 10L);
+            Long productId = productInfo.id();
+
+            // act
+            ProductInfo result = productFacade.getProduct(productId);
+
+            // assert
+            assertAll(
+                    () -> assertThat(result).isNotNull(),
+                    () -> assertThat(result.id()).isEqualTo(productId),
+                    () -> assertThat(result.name()).isEqualTo("테스트 상품"),
+                    () -> assertThat(result.price()).isEqualByComparingTo(BigDecimal.valueOf(10000L)),
+                    () -> assertThat(result.likeCount()).isEqualTo(10L),
+                    () -> assertThat(result.status()).isEqualTo(ProductStatus.ON_SALE),
+                    () -> assertThat(result.isVisible()).isTrue(),
+                    () -> assertThat(result.isSellable()).isTrue()
+            );
+        }
+
+        @DisplayName("실패 케이스: 존재하지 않는 Product ID로 조회하면 NOT_FOUND 예외가 발생한다")
+        @Test
+        void getProduct_withInvalidId_throwsNotFoundException() {
+            // arrange
+            Long invalidId = 999L;
+
+            // act & assert
+            CoreException exception = assertThrows(CoreException.class, () -> {
+                productFacade.getProduct(invalidId);
+            });
+
+            assertAll(
+                    () -> assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND),
+                    () -> assertThat(exception.getCustomMessage()).contains("[productId = " + invalidId + "] Product를 찾을 수 없습니다.")
+            );
+        }
+    }
+
+    // 테스트 헬퍼 메서드
+    private ProductInfo createAndSaveProduct(String name, BigDecimal price, Long likeCount) {
+        Product product = Product.builder()
+                .name(name)
+                .description("테스트 설명")
+                .price(price)
+                .likeCount(likeCount)
+                .status(ProductStatus.ON_SALE)
+                .isVisible(true)
+                .isSellable(true)
+                .build();
+
+        return productFacade.saveProduct(ProductInfo.from(product));
+    }
+}
+
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..08bcce113
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java
@@ -0,0 +1,358 @@
+package com.loopers.domain.product;
+
+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.math.BigDecimal;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+@DisplayName("Product 테스트")
+public class ProductTest {
+
+    @DisplayName("Product 엔티티 생성")
+    @Nested
+    class CreateProductTest {
+
+        final String validName = "테스트 상품";
+        final String validDescription = "테스트 설명";
+        final BigDecimal validPrice = BigDecimal.valueOf(10000L);
+        final Long validLikeCount = 0L;
+        final ProductStatus validStatus = ProductStatus.ON_SALE;
+        final Boolean validIsVisible = true;
+        final Boolean validIsSellable = true;
+
+        @DisplayName("성공 케이스: 필드가 모두 유효하면 Product 객체 생성 성공")
+        @Test
+        void createProduct_withValidFields_Success() {
+            // arrange & act
+            Product product = Product.builder()
+                    .name(validName)
+                    .description(validDescription)
+                    .price(validPrice)
+                    .likeCount(validLikeCount)
+                    .status(validStatus)
+                    .isVisible(validIsVisible)
+                    .isSellable(validIsSellable)
+                    .build();
+
+            // assert
+            assertNotNull(product);
+            assertAll(
+                    () -> assertEquals(product.getName(), validName),
+                    () -> assertEquals(product.getDescription(), validDescription),
+                    () -> assertEquals(product.getPrice(), validPrice),
+                    () -> assertEquals(product.getLikeCount(), validLikeCount),
+                    () -> assertEquals(product.getStatus(), validStatus),
+                    () -> assertEquals(product.getIsVisible(), validIsVisible),
+                    () -> assertEquals(product.getIsSellable(), validIsSellable)
+            );
+        }
+
+        @DisplayName("Product name 유효성 검사")
+        @Nested
+        class NameTest {
+
+            @DisplayName("실패 케이스: name이 null이면 Product 객체 생성 실패")
+            @Test
+            void createProduct_withNullName_BadRequest() {
+                // arrange & act
+                CoreException result = assertThrows(CoreException.class,
+                        () -> Product.builder()
+                                .name(null)
+                                .description(validDescription)
+                                .price(validPrice)
+                                .likeCount(validLikeCount)
+                                .status(validStatus)
+                                .isVisible(validIsVisible)
+                                .isSellable(validIsSellable)
+                                .build()
+                );
+
+                // assert
+                assertEquals(ErrorType.BAD_REQUEST, result.getErrorType());
+                assertEquals("Product : name이 비어있을 수 없습니다.", result.getCustomMessage());
+            }
+
+            @DisplayName("실패 케이스: name이 빈 문자열이면 Product 객체 생성 실패")
+            @Test
+            void createProduct_withBlankName_BadRequest() {
+                // arrange & act
+                CoreException result = assertThrows(CoreException.class,
+                        () -> Product.builder()
+                                .name("")
+                                .description(validDescription)
+                                .price(validPrice)
+                                .likeCount(validLikeCount)
+                                .status(validStatus)
+                                .isVisible(validIsVisible)
+                                .isSellable(validIsSellable)
+                                .build()
+                );
+
+                // assert
+                assertEquals(ErrorType.BAD_REQUEST, result.getErrorType());
+                assertEquals("Product : name이 비어있을 수 없습니다.", result.getCustomMessage());
+            }
+
+            @DisplayName("실패 케이스: name이 공백만 있으면 Product 객체 생성 실패")
+            @Test
+            void createProduct_withWhitespaceName_BadRequest() {
+                // arrange & act
+                CoreException result = assertThrows(CoreException.class,
+                        () -> Product.builder()
+                                .name("   ")
+                                .description(validDescription)
+                                .price(validPrice)
+                                .likeCount(validLikeCount)
+                                .status(validStatus)
+                                .isVisible(validIsVisible)
+                                .isSellable(validIsSellable)
+                                .build()
+                );
+
+                // assert
+                assertEquals(ErrorType.BAD_REQUEST, result.getErrorType());
+                assertEquals("Product : name이 비어있을 수 없습니다.", result.getCustomMessage());
+            }
+        }
+
+        @DisplayName("Product price 유효성 검사")
+        @Nested
+        class PriceTest {
+
+            @DisplayName("실패 케이스: price가 null이면 Product 객체 생성 실패")
+            @Test
+            void createProduct_withNullPrice_BadRequest() {
+                // arrange & act
+                CoreException result = assertThrows(CoreException.class,
+                        () -> Product.builder()
+                                .name(validName)
+                                .description(validDescription)
+                                .price(null)
+                                .likeCount(validLikeCount)
+                                .status(validStatus)
+                                .isVisible(validIsVisible)
+                                .isSellable(validIsSellable)
+                                .build()
+                );
+
+                // assert
+                assertEquals(ErrorType.BAD_REQUEST, result.getErrorType());
+                assertEquals("Product : price 가 비어있을 수 없습니다.", result.getCustomMessage());
+            }
+
+            @DisplayName("실패 케이스: price가 음수이면 Product 객체 생성 실패")
+            @Test
+            void createProduct_withNegativePrice_BadRequest() {
+                // arrange & act
+                CoreException result = assertThrows(CoreException.class,
+                        () -> Product.builder()
+                                .name(validName)
+                                .description(validDescription)
+                                .price(BigDecimal.valueOf(-1000L))
+                                .likeCount(validLikeCount)
+                                .status(validStatus)
+                                .isVisible(validIsVisible)
+                                .isSellable(validIsSellable)
+                                .build()
+                );
+
+                // assert
+                assertEquals(ErrorType.BAD_REQUEST, result.getErrorType());
+                assertEquals("Product : price 는 음수가 될 수 없습니다.", result.getCustomMessage());
+            }
+
+            @DisplayName("성공 케이스: price가 0이면 Product 객체 생성 성공")
+            @Test
+            void createProduct_withZeroPrice_Success() {
+                // arrange & act
+                Product product = Product.builder()
+                        .name(validName)
+                        .description(validDescription)
+                        .price(BigDecimal.ZERO)
+                        .likeCount(validLikeCount)
+                        .status(validStatus)
+                        .isVisible(validIsVisible)
+                        .isSellable(validIsSellable)
+                        .build();
+
+                // assert
+                assertNotNull(product);
+                assertEquals(BigDecimal.ZERO, product.getPrice());
+            }
+        }
+
+        @DisplayName("Product likeCount 유효성 검사")
+        @Nested
+        class LikeCountTest {
+
+            @DisplayName("실패 케이스: likeCount가 null이면 Product 객체 생성 실패")
+            @Test
+            void createProduct_withNullLikeCount_BadRequest() {
+                // arrange & act
+                CoreException result = assertThrows(CoreException.class,
+                        () -> Product.builder()
+                                .name(validName)
+                                .description(validDescription)
+                                .price(validPrice)
+                                .likeCount(null)
+                                .status(validStatus)
+                                .isVisible(validIsVisible)
+                                .isSellable(validIsSellable)
+                                .build()
+                );
+
+                // assert
+                assertEquals(ErrorType.BAD_REQUEST, result.getErrorType());
+                assertEquals("Product : likeCount 가 비어있을 수 없습니다.", result.getCustomMessage());
+            }
+
+            @DisplayName("실패 케이스: likeCount가 음수이면 Product 객체 생성 실패")
+            @Test
+            void createProduct_withNegativeLikeCount_BadRequest() {
+                // arrange & act
+                CoreException result = assertThrows(CoreException.class,
+                        () -> Product.builder()
+                                .name(validName)
+                                .description(validDescription)
+                                .price(validPrice)
+                                .likeCount(-1L)
+                                .status(validStatus)
+                                .isVisible(validIsVisible)
+                                .isSellable(validIsSellable)
+                                .build()
+                );
+
+                // assert
+                assertEquals(ErrorType.BAD_REQUEST, result.getErrorType());
+                assertEquals("Product : likeCount 는 음수가 될 수 없습니다.", result.getCustomMessage());
+            }
+
+            @DisplayName("성공 케이스: likeCount가 0이면 Product 객체 생성 성공")
+            @Test
+            void createProduct_withZeroLikeCount_Success() {
+                // arrange & act
+                Product product = Product.builder()
+                        .name(validName)
+                        .description(validDescription)
+                        .price(validPrice)
+                        .likeCount(0L)
+                        .status(validStatus)
+                        .isVisible(validIsVisible)
+                        .isSellable(validIsSellable)
+                        .build();
+
+                // assert
+                assertNotNull(product);
+                assertEquals(0L, product.getLikeCount());
+            }
+        }
+
+        @DisplayName("Product status 유효성 검사")
+        @Nested
+        class StatusTest {
+
+            @DisplayName("실패 케이스: status가 null이면 Product 객체 생성 실패")
+            @Test
+            void createProduct_withNullStatus_BadRequest() {
+                // arrange & act
+                CoreException result = assertThrows(CoreException.class,
+                        () -> Product.builder()
+                                .name(validName)
+                                .description(validDescription)
+                                .price(validPrice)
+                                .likeCount(validLikeCount)
+                                .status(null)
+                                .isVisible(validIsVisible)
+                                .isSellable(validIsSellable)
+                                .build()
+                );
+
+                // assert
+                assertEquals(ErrorType.BAD_REQUEST, result.getErrorType());
+                assertEquals("Product : status 가 비어있을 수 없습니다.", result.getCustomMessage());
+            }
+        }
+
+        @DisplayName("Product isVisible 유효성 검사")
+        @Nested
+        class IsVisibleTest {
+
+            @DisplayName("실패 케이스: isVisible이 null이면 Product 객체 생성 실패")
+            @Test
+            void createProduct_withNullIsVisible_BadRequest() {
+                // arrange & act
+                CoreException result = assertThrows(CoreException.class,
+                        () -> Product.builder()
+                                .name(validName)
+                                .description(validDescription)
+                                .price(validPrice)
+                                .likeCount(validLikeCount)
+                                .status(validStatus)
+                                .isVisible(null)
+                                .isSellable(validIsSellable)
+                                .build()
+                );
+
+                // assert
+                assertEquals(ErrorType.BAD_REQUEST, result.getErrorType());
+                assertEquals("Product : isVisible 가 비어있을 수 없습니다.", result.getCustomMessage());
+            }
+        }
+
+        @DisplayName("Product isSellable 유효성 검사")
+        @Nested
+        class IsSellableTest {
+
+            @DisplayName("실패 케이스: isSellable이 null이면 Product 객체 생성 실패")
+            @Test
+            void createProduct_withNullIsSellable_BadRequest() {
+                // arrange & act
+                CoreException result = assertThrows(CoreException.class,
+                        () -> Product.builder()
+                                .name(validName)
+                                .description(validDescription)
+                                .price(validPrice)
+                                .likeCount(validLikeCount)
+                                .status(validStatus)
+                                .isVisible(validIsVisible)
+                                .isSellable(null)
+                                .build()
+                );
+
+                // assert
+                assertEquals(ErrorType.BAD_REQUEST, result.getErrorType());
+                assertEquals("Product : isSellable 가 비어있을 수 없습니다.", result.getCustomMessage());
+            }
+        }
+
+        @DisplayName("Product description은 nullable")
+        @Nested
+        class DescriptionTest {
+
+            @DisplayName("성공 케이스: description이 null이어도 Product 객체 생성 성공")
+            @Test
+            void createProduct_withNullDescription_Success() {
+                // arrange & act
+                Product product = Product.builder()
+                        .name(validName)
+                        .description(null)
+                        .price(validPrice)
+                        .likeCount(validLikeCount)
+                        .status(validStatus)
+                        .isVisible(validIsVisible)
+                        .isSellable(validIsSellable)
+                        .build();
+
+                // assert
+                assertNotNull(product);
+                assertNull(product.getDescription());
+            }
+        }
+    }
+}
+

From 5651d7517315c773eb4a5e01dfb1b4557bc6fca4 Mon Sep 17 00:00:00 2001
From: Yim Yoobin <yoobinbkk@naver.com>
Date: Fri, 14 Nov 2025 17:28:48 +0900
Subject: [PATCH 18/19] =?UTF-8?q?[Round=203]=20=EC=A2=8B=EC=95=84=EC=9A=94?=
 =?UTF-8?q?=20Like=20API=20=EA=B0=9C=EB=B0=9C=20=EB=B0=98=EC=98=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../loopers/application/like/LikeFacade.java  |  47 ++++
 .../loopers/application/like/LikeInfo.java    |  23 ++
 .../java/com/loopers/domain/like/Like.java    |  73 +++++
 .../loopers/domain/like/LikeRepository.java   |  17 ++
 .../com/loopers/domain/like/LikeService.java  |  50 ++++
 .../loopers/domain/like/LikeTargetType.java   |   6 +
 .../like/LikeJpaRepository.java               |  13 +
 .../like/LikeQueryRepository.java             |  60 ++++
 .../like/LikeRepositoryImpl.java              |  39 +++
 .../interfaces/api/like/LikeApiSpec.java      |  50 ++++
 .../interfaces/api/like/LikeController.java   |  35 +++
 .../loopers/interfaces/api/like/LikeDto.java  |   7 +
 .../like/LikeFacadeIntegrationTest.java       | 256 ++++++++++++++++++
 .../com/loopers/domain/like/LikeTest.java     | 138 ++++++++++
 14 files changed, 814 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/application/like/LikeInfo.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/like/LikeService.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/LikeTargetType.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/LikeQueryRepository.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeApiSpec.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeController.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeDto.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.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..8bef282f2
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java
@@ -0,0 +1,47 @@
+package com.loopers.application.like;
+
+import com.loopers.domain.like.LikeService;
+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 lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+@RequiredArgsConstructor
+@Component
+public class LikeFacade {
+
+    private final LikeService likeService;
+    private final UserService userService;
+
+    /**
+     * 상품 좋아요 등록
+     */
+    @Transactional
+    public void saveProductLike(String loginId, Long productId) {
+        User user = userService.findUserByLoginId(loginId)
+                .orElseThrow(() -> new CoreException(
+                        ErrorType.NOT_FOUND,
+                        "[loginId = " + loginId + "] User를 찾을 수 없습니다."
+                ));
+
+        likeService.saveProductLike(user, productId);
+    }
+
+    /**
+     * 상품 좋아요 취소
+     */
+    @Transactional
+    public void deleteProductLike(String loginId, Long productId) {
+        User user = userService.findUserByLoginId(loginId)
+                .orElseThrow(() -> new CoreException(
+                        ErrorType.NOT_FOUND,
+                        "[loginId = " + loginId + "] User를 찾을 수 없습니다."
+                ));
+
+        likeService.deleteProductLike(user, productId);
+    }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java
new file mode 100644
index 000000000..5cab07650
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java
@@ -0,0 +1,23 @@
+package com.loopers.application.like;
+
+import com.loopers.domain.like.Like;
+import com.loopers.domain.like.LikeTargetType;
+import lombok.Builder;
+
+@Builder
+public record LikeInfo(
+        Long id
+        , Long userId
+        , Long likeTargetId
+        , LikeTargetType likeTargetType
+) {
+    public static LikeInfo from(Like like) {
+        return LikeInfo.builder()
+                .id(like.getId())
+                .userId(like.getUser().getId())
+                .likeTargetId(like.getLikeTargetId())
+                .likeTargetType(like.getLikeTargetType())
+                .build();
+    }
+}
+
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..31513f34f
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java
@@ -0,0 +1,73 @@
+package com.loopers.domain.like;
+
+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.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@Table(
+    name = "user_like",
+    // 💡 UNIQUE 인덱스를 @Index로 정의하는 것이 더 명확하고 유연할 수 있습니다.
+    indexes = {
+        @Index(
+            name = "uk_user_like_target", 
+            columnList = "user_id, like_target_id, like_target_type", 
+            unique = true // 명시적으로 UNIQUE 지정
+        )
+    }
+)
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Getter
+public class Like extends BaseEntity {
+
+    @ManyToOne(fetch = FetchType.LAZY)
+    @JoinColumn(name = "user_id")
+    private User user;
+
+    @Column(name = "like_target_id")
+    private Long likeTargetId;
+
+    @Enumerated(EnumType.STRING)
+    @Column(name = "like_target_type")
+    private LikeTargetType likeTargetType;
+
+    @Builder
+    private Like(
+        User user
+        , Long likeTargetId
+        , LikeTargetType likeTargetType
+    ) {
+        this.user = user;
+        this.likeTargetId = likeTargetId;
+        this.likeTargetType = likeTargetType;
+        guard();
+    }
+
+    // 유효성 검사
+    @Override
+    protected void guard() {
+        // user 유효성 검사
+        if(user == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "Like : user가 비어있을 수 없습니다.");
+        }
+
+        // likeTargetId 유효성 검사
+        if(likeTargetId == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "Like : likeTargetId가 비어있을 수 없습니다.");
+        } else if(likeTargetId <= 0) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "Like : likeTargetId는 양수여야 합니다.");
+        }
+
+        // likeTargetType 유효성 검사
+        if(likeTargetType == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "Like : likeTargetType이 비어있을 수 없습니다.");
+        }
+    }
+}
+
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..d3870a120
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java
@@ -0,0 +1,17 @@
+package com.loopers.domain.like;
+
+import java.util.Optional;
+
+public interface LikeRepository {
+
+    Optional<Like> save(Like like);
+    long deleteByUserIdAndLikeTargetId(Long userId, Long likeTargetId, LikeTargetType likeTargetType);
+    Optional<Like> findByUserIdAndLikeTargetId(Long userId, Long likeTargetId, LikeTargetType likeTargetType);
+    
+    /**
+     * 좋아요 등록 (동시성 안전)
+     * INSERT ... ON DUPLICATE KEY UPDATE를 사용하여 원자적으로 처리
+     */
+    long insertOrIgnore(Long userId, Long likeTargetId, LikeTargetType likeTargetType);
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java
new file mode 100644
index 000000000..abd5baaaf
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java
@@ -0,0 +1,50 @@
+package com.loopers.domain.like;
+
+import com.loopers.domain.product.ProductRepository;
+import com.loopers.domain.user.User;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+@Component
+@RequiredArgsConstructor
+public class LikeService {
+
+    private final LikeRepository likeRepository;
+    private final ProductRepository productRepository;
+
+    /**
+     * 상품 좋아요 등록
+     * 멱등성: 중복 저장하면 이전 것을 덮어씀
+     * 동시성 안전: INSERT ... ON DUPLICATE KEY UPDATE를 사용하여 원자적으로 처리
+     */
+    public void saveProductLike(User user, Long productId) {
+        // INSERT ... ON DUPLICATE KEY UPDATE 실행
+        // 반환값: 1 = 새로 생성됨, 2 = 이미 존재하여 UPDATE만 됨
+        long affectedRows = likeRepository.insertOrIgnore(user.getId(), productId, LikeTargetType.PRODUCT);
+
+        // 디버깅: 실제 반환값 확인
+        System.out.println("insertOrIgnore 반환값: " + affectedRows);
+        
+        // 실제로 새로 생성된 경우에만 likeCount 증가
+        if (affectedRows == 1) {
+            productRepository.incrementLikeCount(productId);
+        }
+    }
+
+    /**
+     * 상품 좋아요 취소
+     * 멱등성: Like가 없어도 예외 없이 처리
+     */
+    public void deleteProductLike(User user, Long productId) {
+        // 삭제 실행 (영향받은 행 수 반환)
+        long deletedRows = likeRepository.deleteByUserIdAndLikeTargetId(
+                user.getId(), productId, LikeTargetType.PRODUCT
+        );
+        
+        // 실제로 삭제된 경우에만 likeCount 감소
+        if (deletedRows > 0) {
+            productRepository.decrementLikeCount(productId);
+        }
+    }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeTargetType.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeTargetType.java
new file mode 100644
index 000000000..6a4ff61eb
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeTargetType.java
@@ -0,0 +1,6 @@
+package com.loopers.domain.like;
+
+public enum LikeTargetType {
+    PRODUCT
+}
+
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..76bae4567
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java
@@ -0,0 +1,13 @@
+package com.loopers.infrastructure.like;
+
+import com.loopers.domain.like.Like;
+import com.loopers.domain.like.LikeTargetType;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.Optional;
+
+public interface LikeJpaRepository extends JpaRepository<Like, Long> {
+    Optional<Like> findByUser_IdAndLikeTargetIdAndLikeTargetType(Long userId, Long likeTargetId, LikeTargetType likeTargetType);
+    void deleteByUser_IdAndLikeTargetIdAndLikeTargetType(Long userId, Long likeTargetId, LikeTargetType likeTargetType);
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeQueryRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeQueryRepository.java
new file mode 100644
index 000000000..7040f8751
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeQueryRepository.java
@@ -0,0 +1,60 @@
+package com.loopers.infrastructure.like;
+
+import jakarta.persistence.EntityManager;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+@RequiredArgsConstructor
+@Component
+public class LikeQueryRepository {
+
+    private final EntityManager entityManager;
+
+    /**
+     * 좋아요 등록 (동시성 안전)
+     * INSERT ... ON DUPLICATE KEY UPDATE를 사용하여 원자적으로 처리
+     * 
+     * @param userId 사용자 ID
+     * @param likeTargetId 좋아요 대상 ID
+     * @param likeTargetType 좋아요 대상 타입
+     * @return 영향받은 행 수 (1: INSERT 또는 UPDATE 성공, 2: UPDATE 성공)
+     */
+    public long insertOrIgnore(Long userId, Long likeTargetId, String likeTargetType) {
+
+        String sql = """
+            INSERT INTO user_like (user_id, like_target_id, like_target_type, created_at, updated_at)
+            VALUES (:userId, :likeTargetId, :likeTargetType, NOW(), NOW())
+            ON DUPLICATE KEY UPDATE updated_at = NOW()
+            """;
+
+        return entityManager.createNativeQuery(sql)
+                .setParameter("userId", userId)
+                .setParameter("likeTargetId", likeTargetId)
+                .setParameter("likeTargetType", likeTargetType)
+                .executeUpdate();
+    }
+
+    /**
+     * 좋아요 삭제 (동시성 안전)
+     * 
+     * @param userId 사용자 ID
+     * @param likeTargetId 좋아요 대상 ID
+     * @param likeTargetType 좋아요 대상 타입
+     * @return 영향받은 행 수 (0: 삭제할 데이터 없음, 1: 삭제 성공)
+     */
+    public long deleteByUserIdAndLikeTargetId(Long userId, Long likeTargetId, String likeTargetType) {
+        String sql = """
+            DELETE FROM user_like 
+            WHERE user_id = :userId 
+            AND like_target_id = :likeTargetId 
+            AND like_target_type = :likeTargetType
+            """;
+
+        return entityManager.createNativeQuery(sql)
+                .setParameter("userId", userId)
+                .setParameter("likeTargetId", likeTargetId)
+                .setParameter("likeTargetType", likeTargetType)
+                .executeUpdate();
+    }
+}
+
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..a386f9025
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java
@@ -0,0 +1,39 @@
+package com.loopers.infrastructure.like;
+
+import com.loopers.domain.like.Like;
+import com.loopers.domain.like.LikeRepository;
+import com.loopers.domain.like.LikeTargetType;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import java.util.Optional;
+
+@RequiredArgsConstructor
+@Component
+public class LikeRepositoryImpl implements LikeRepository {
+
+    private final LikeJpaRepository likeJpaRepository;
+    private final LikeQueryRepository likeQueryRepository;
+
+    @Override
+    public Optional<Like> save(Like like) {
+        Like savedLike = likeJpaRepository.save(like);
+        return Optional.of(savedLike);
+    }
+
+    @Override
+    public long deleteByUserIdAndLikeTargetId(Long userId, Long likeTargetId, LikeTargetType likeTargetType) {
+        return likeQueryRepository.deleteByUserIdAndLikeTargetId(userId, likeTargetId, likeTargetType.name());
+    }
+
+    @Override
+    public Optional<Like> findByUserIdAndLikeTargetId(Long userId, Long likeTargetId, LikeTargetType likeTargetType) {
+        return likeJpaRepository.findByUser_IdAndLikeTargetIdAndLikeTargetType(userId, likeTargetId, likeTargetType);
+    }
+
+    @Override
+    public long insertOrIgnore(Long userId, Long likeTargetId, LikeTargetType likeTargetType) {
+        return likeQueryRepository.insertOrIgnore(userId, likeTargetId, likeTargetType.name());
+    }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeApiSpec.java
new file mode 100644
index 000000000..6c0e4d238
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeApiSpec.java
@@ -0,0 +1,50 @@
+package com.loopers.interfaces.api.like;
+
+import com.loopers.interfaces.api.ApiResponse;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.enums.ParameterIn;
+import io.swagger.v3.oas.annotations.tags.Tag;
+
+@Tag(name = "Like API", description = "좋아요 관련 API 입니다.")
+public interface LikeApiSpec {
+
+    @Operation(
+            summary = "상품 좋아요 등록"
+            , description = "상품에 좋아요를 등록합니다."
+    )
+    ApiResponse<Object> saveProductLike(
+            @Parameter(
+                    name = "X-USER-ID"
+                    , in = ParameterIn.HEADER
+                    , description = "요청자 사용자 ID 헤더"
+            )
+            String xUserId,
+            @Parameter(
+                    name = "productId"
+                    , in = ParameterIn.PATH
+                    , description = "상품 ID"
+            )
+            Long productId
+    );
+
+    @Operation(
+            summary = "상품 좋아요 취소"
+            , description = "상품 좋아요를 취소합니다."
+    )
+    ApiResponse<Object> deleteProductLike(
+            @Parameter(
+                    name = "X-USER-ID"
+                    , in = ParameterIn.HEADER
+                    , description = "요청자 사용자 ID 헤더"
+            )
+            String xUserId,
+            @Parameter(
+                    name = "productId"
+                    , in = ParameterIn.PATH
+                    , description = "상품 ID"
+            )
+            Long productId
+    );
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeController.java
new file mode 100644
index 000000000..48f27a708
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeController.java
@@ -0,0 +1,35 @@
+package com.loopers.interfaces.api.like;
+
+import com.loopers.application.like.LikeFacade;
+import com.loopers.interfaces.api.ApiResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/api/v1/like")
+public class LikeController implements LikeApiSpec {
+
+    private final LikeFacade likeFacade;
+
+    @PostMapping("/products/{productId}")
+    @Override
+    public ApiResponse<Object> saveProductLike(
+            @RequestHeader(value = "X-USER-ID") String xUserId
+            , @PathVariable Long productId
+    ) {
+        likeFacade.saveProductLike(xUserId, productId);
+        return ApiResponse.success();
+    }
+
+    @DeleteMapping("/products/{productId}")
+    @Override
+    public ApiResponse<Object> deleteProductLike(
+            @RequestHeader(value = "X-USER-ID") String xUserId
+            , @PathVariable Long productId
+    ) {
+        likeFacade.deleteProductLike(xUserId, productId);
+        return ApiResponse.success();
+    }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeDto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeDto.java
new file mode 100644
index 000000000..39d895a32
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeDto.java
@@ -0,0 +1,7 @@
+package com.loopers.interfaces.api.like;
+
+public class LikeDto {
+    // 현재는 메타 정보만 반환하므로 별도의 DTO 불필요
+    // 향후 확장 시 사용 가능
+}
+
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java
new file mode 100644
index 000000000..78ddb4cef
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java
@@ -0,0 +1,256 @@
+package com.loopers.application.like;
+
+import com.loopers.application.product.ProductFacade;
+import com.loopers.application.product.ProductInfo;
+import com.loopers.application.user.UserFacade;
+import com.loopers.application.user.UserInfo;
+import com.loopers.domain.like.Like;
+import com.loopers.domain.like.LikeTargetType;
+import com.loopers.domain.product.Product;
+import com.loopers.domain.product.ProductRepository;
+import com.loopers.domain.product.ProductStatus;
+import com.loopers.domain.user.Gender;
+import com.loopers.domain.user.User;
+import com.loopers.domain.user.UserService;
+import com.loopers.infrastructure.like.LikeJpaRepository;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import com.loopers.utils.DatabaseCleanUp;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+
+import java.math.BigDecimal;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.*;
+
+@DisplayName("LikeFacade 통합 테스트")
+@SpringBootTest
+class LikeFacadeIntegrationTest {
+
+    @Autowired
+    private LikeFacade likeFacade;
+
+    @Autowired
+    private UserFacade userFacade;
+
+    @Autowired
+    private UserService userService;
+
+    @Autowired
+    private LikeJpaRepository likeJpaRepository;
+
+    @Autowired
+    private ProductFacade productFacade;
+
+    @Autowired
+    private ProductRepository productRepository;
+
+    @Autowired
+    private DatabaseCleanUp databaseCleanUp;
+
+    @AfterEach
+    void tearDown() {
+        databaseCleanUp.truncateAllTables();
+    }
+
+    final String validLoginId = "bobby34";
+    final String validEmail = "bobby34@naver.com";
+    final String validBirthday = "1994-04-08";
+    final Gender validGender = Gender.MALE;
+
+    @DisplayName("saveProductLike 메서드")
+    @Nested
+    class saveProductLikeTest {
+
+        @DisplayName("성공 케이스: 유효한 사용자와 상품 ID로 좋아요 등록 시 성공")
+        @Test
+        void saveProductLike_withValidUserAndProduct_Success() {
+            // arrange
+            createAndSaveUser(validLoginId);
+            User user = userService.findUserByLoginId(validLoginId).orElseThrow();
+            Long userId = user.getId();
+            ProductInfo productInfo = createAndSaveProduct("테스트 상품", BigDecimal.valueOf(10000L), 0L);
+            Long productId = productInfo.id();
+
+            // act
+            likeFacade.saveProductLike(validLoginId, productId);
+
+            // assert
+            Optional<Like> savedLike = likeJpaRepository.findByUser_IdAndLikeTargetIdAndLikeTargetType(
+                    userId, productId, LikeTargetType.PRODUCT
+            );
+            assertTrue(savedLike.isPresent());
+            assertEquals(userId, savedLike.get().getUser().getId());
+            assertEquals(productId, savedLike.get().getLikeTargetId());
+            assertEquals(LikeTargetType.PRODUCT, savedLike.get().getLikeTargetType());
+            
+            // Product.likeCount가 1 증가했는지 확인
+            Product product = productRepository.findById(productId).orElseThrow();
+            assertEquals(1L, product.getLikeCount(), "좋아요 등록 시 likeCount가 1 증가해야 합니다.");
+        }
+
+        @DisplayName("멱등성 테스트: 동일한 좋아요를 중복 등록해도 예외 없이 처리")
+        @Test
+        void saveProductLike_duplicateLike_Idempotent() {
+            // arrange
+            createAndSaveUser(validLoginId);
+            User user = userService.findUserByLoginId(validLoginId).orElseThrow();
+            Long userId = user.getId();
+            ProductInfo productInfo = createAndSaveProduct("테스트 상품", BigDecimal.valueOf(10000L), 0L);
+            Long productId = productInfo.id();
+
+            // act
+            likeFacade.saveProductLike(validLoginId, productId);
+            Product productAfterFirst = productRepository.findById(productId).orElseThrow();
+            Long likeCountAfterFirst = productAfterFirst.getLikeCount();
+            
+            likeFacade.saveProductLike(validLoginId, productId); // 중복 등록
+
+            // assert
+            Optional<Like> savedLike = likeJpaRepository.findByUser_IdAndLikeTargetIdAndLikeTargetType(
+                    userId, productId, LikeTargetType.PRODUCT
+            );
+            assertTrue(savedLike.isPresent());
+            // 멱등성: 중복 등록해도 하나만 존재
+            long count = likeJpaRepository.findAll().stream()
+                    .filter(like -> like.getUser().getId().equals(userId)
+                            && like.getLikeTargetId().equals(productId)
+                            && like.getLikeTargetType() == LikeTargetType.PRODUCT)
+                    .count();
+            assertThat(count).isEqualTo(1);
+            
+            // 멱등성: 중복 등록 시 likeCount가 증가하지 않아야 함
+            Product productAfterSecond = productRepository.findById(productId).orElseThrow();
+            assertEquals(likeCountAfterFirst, productAfterSecond.getLikeCount(), 
+                    "중복 등록 시 likeCount가 증가하지 않아야 합니다.");
+        }
+
+        @DisplayName("실패 케이스: 존재하지 않는 사용자로 좋아요 등록 시 NOT_FOUND 예외 발생")
+        @Test
+        void saveProductLike_withNonExistentUser_NotFound() {
+            // arrange
+            String nonExistentLoginId = "nonexistent";
+            Long productId = 1L;
+
+            // act & assert
+            CoreException exception = assertThrows(CoreException.class, () -> {
+                likeFacade.saveProductLike(nonExistentLoginId, productId);
+            });
+
+            assertEquals(ErrorType.NOT_FOUND, exception.getErrorType());
+            assertTrue(exception.getCustomMessage().contains("[loginId = " + nonExistentLoginId + "] User를 찾을 수 없습니다."));
+        }
+    }
+
+    @DisplayName("deleteProductLike 메서드")
+    @Nested
+    class DeleteProductLikeTest {
+
+        @DisplayName("성공 케이스: 존재하는 좋아요를 취소하면 삭제됨")
+        @Test
+        void deleteProductLike_withExistingLike_Success() {
+            // arrange
+            createAndSaveUser(validLoginId);
+            User user = userService.findUserByLoginId(validLoginId).orElseThrow();
+            Long userId = user.getId();
+            ProductInfo productInfo = createAndSaveProduct("테스트 상품", BigDecimal.valueOf(10000L), 0L);
+            Long productId = productInfo.id();
+            likeFacade.saveProductLike(validLoginId, productId);
+            
+            Product productBeforeDelete = productRepository.findById(productId).orElseThrow();
+            Long likeCountBeforeDelete = productBeforeDelete.getLikeCount();
+
+            // act
+            likeFacade.deleteProductLike(validLoginId, productId);
+
+            // assert
+            Optional<Like> deletedLike = likeJpaRepository.findByUser_IdAndLikeTargetIdAndLikeTargetType(
+                    userId, productId, LikeTargetType.PRODUCT
+            );
+            assertFalse(deletedLike.isPresent());
+            
+            // Product.likeCount가 1 감소했는지 확인
+            Product productAfterDelete = productRepository.findById(productId).orElseThrow();
+            assertEquals(likeCountBeforeDelete - 1, productAfterDelete.getLikeCount(), 
+                    "좋아요 삭제 시 likeCount가 1 감소해야 합니다.");
+        }
+
+        @DisplayName("멱등성 테스트: 존재하지 않는 좋아요를 취소해도 예외 없이 처리")
+        @Test
+        void deleteProductLike_withNonExistentLike_Idempotent() {
+            // arrange
+            createAndSaveUser(validLoginId);
+            User user = userService.findUserByLoginId(validLoginId).orElseThrow();
+            Long userId = user.getId();
+            ProductInfo productInfo = createAndSaveProduct("테스트 상품", BigDecimal.valueOf(10000L), 5L);
+            Long productId = productInfo.id();
+            
+            Product productBeforeDelete = productRepository.findById(productId).orElseThrow();
+            Long likeCountBeforeDelete = productBeforeDelete.getLikeCount();
+
+            // act & assert - 예외 없이 처리되어야 함
+            assertDoesNotThrow(() -> {
+                likeFacade.deleteProductLike(validLoginId, productId);
+            });
+
+            // assert
+            Optional<Like> deletedLike = likeJpaRepository.findByUser_IdAndLikeTargetIdAndLikeTargetType(
+                    userId, productId, LikeTargetType.PRODUCT
+            );
+            assertFalse(deletedLike.isPresent());
+            
+            // 멱등성: 존재하지 않는 좋아요 삭제 시 likeCount가 변하지 않아야 함
+            Product productAfterDelete = productRepository.findById(productId).orElseThrow();
+            assertEquals(likeCountBeforeDelete, productAfterDelete.getLikeCount(), 
+                    "존재하지 않는 좋아요 삭제 시 likeCount가 변하지 않아야 합니다.");
+        }
+
+        @DisplayName("실패 케이스: 존재하지 않는 사용자로 좋아요 취소 시 NOT_FOUND 예외 발생")
+        @Test
+        void deleteProductLike_withNonExistentUser_NotFound() {
+            // arrange
+            String nonExistentLoginId = "nonexistent";
+            Long productId = 1L;
+
+            // act & assert
+            CoreException exception = assertThrows(CoreException.class, () -> {
+                likeFacade.deleteProductLike(nonExistentLoginId, productId);
+            });
+
+            assertEquals(ErrorType.NOT_FOUND, exception.getErrorType());
+            assertTrue(exception.getCustomMessage().contains("[loginId = " + nonExistentLoginId + "] User를 찾을 수 없습니다."));
+        }
+    }
+
+    // 테스트 헬퍼 메서드
+    private UserInfo createAndSaveUser(String loginId) {
+        UserInfo userInfo = UserInfo.builder()
+                .loginId(loginId)
+                .email(validEmail)
+                .birthday(validBirthday)
+                .gender(validGender)
+                .build();
+        return userFacade.saveUser(userInfo);
+    }
+
+    private ProductInfo createAndSaveProduct(String name, BigDecimal price, Long likeCount) {
+        Product product = Product.builder()
+                .name(name)
+                .description("테스트 설명")
+                .price(price)
+                .likeCount(likeCount)
+                .status(ProductStatus.ON_SALE)
+                .isVisible(true)
+                .isSellable(true)
+                .build();
+
+        return productFacade.saveProduct(ProductInfo.from(product));
+    }
+}
+
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java
new file mode 100644
index 000000000..96d7b244b
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java
@@ -0,0 +1,138 @@
+package com.loopers.domain.like;
+
+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.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+@DisplayName("Like 테스트")
+public class LikeTest {
+
+    @DisplayName("Like 엔티티 생성")
+    @Nested
+    class CreateLikeTest {
+
+        final Long validLikeTargetId = 1L;
+        final LikeTargetType validLikeTargetType = LikeTargetType.PRODUCT;
+
+        @DisplayName("성공 케이스: 필드가 모두 유효하면 Like 객체 생성 성공")
+        @Test
+        void createLike_withValidFields_Success() {
+            // arrange
+            User user = User.builder()
+                    .loginId("test123")
+                    .email("test@test.com")
+                    .birthday("1990-01-01")
+                    .gender(com.loopers.domain.user.Gender.MALE)
+                    .build();
+
+            // act
+            Like like = Like.builder()
+                    .user(user)
+                    .likeTargetId(validLikeTargetId)
+                    .likeTargetType(validLikeTargetType)
+                    .build();
+
+            // assert
+            assertNotNull(like);
+            assertAll(
+                    () -> assertNotNull(like.getUser()),
+                    () -> assertEquals(validLikeTargetId, like.getLikeTargetId()),
+                    () -> assertEquals(validLikeTargetType, like.getLikeTargetType())
+            );
+        }
+
+        @DisplayName("실패 케이스: user가 null이면 BAD_REQUEST 예외 발생")
+        @Test
+        void createLike_withNullUser_BadRequest() {
+            // act & assert
+            CoreException result = assertThrows(CoreException.class, () -> {
+                Like.builder()
+                        .user(null)
+                        .likeTargetId(validLikeTargetId)
+                        .likeTargetType(validLikeTargetType)
+                        .build();
+            });
+
+            assertEquals(ErrorType.BAD_REQUEST, result.getErrorType());
+            assertEquals("Like : user가 비어있을 수 없습니다.", result.getCustomMessage());
+        }
+
+        @DisplayName("실패 케이스: likeTargetId가 null이면 BAD_REQUEST 예외 발생")
+        @Test
+        void createLike_withNullLikeTargetId_BadRequest() {
+            // arrange
+            User user = User.builder()
+                    .loginId("test123")
+                    .email("test@test.com")
+                    .birthday("1990-01-01")
+                    .gender(com.loopers.domain.user.Gender.MALE)
+                    .build();
+
+            // act & assert
+            CoreException result = assertThrows(CoreException.class, () -> {
+                Like.builder()
+                        .user(user)
+                        .likeTargetId(null)
+                        .likeTargetType(validLikeTargetType)
+                        .build();
+            });
+
+            assertEquals(ErrorType.BAD_REQUEST, result.getErrorType());
+            assertEquals("Like : likeTargetId가 비어있을 수 없습니다.", result.getCustomMessage());
+        }
+
+        @DisplayName("실패 케이스: likeTargetId가 0 이하면 BAD_REQUEST 예외 발생")
+        @Test
+        void createLike_withInvalidLikeTargetId_BadRequest() {
+            // arrange
+            User user = User.builder()
+                    .loginId("test123")
+                    .email("test@test.com")
+                    .birthday("1990-01-01")
+                    .gender(com.loopers.domain.user.Gender.MALE)
+                    .build();
+
+            // act & assert
+            CoreException result = assertThrows(CoreException.class, () -> {
+                Like.builder()
+                        .user(user)
+                        .likeTargetId(0L)
+                        .likeTargetType(validLikeTargetType)
+                        .build();
+            });
+
+            assertEquals(ErrorType.BAD_REQUEST, result.getErrorType());
+            assertEquals("Like : likeTargetId는 양수여야 합니다.", result.getCustomMessage());
+        }
+
+        @DisplayName("실패 케이스: likeTargetType이 null이면 BAD_REQUEST 예외 발생")
+        @Test
+        void createLike_withNullLikeTargetType_BadRequest() {
+            // arrange
+            User user = User.builder()
+                    .loginId("test123")
+                    .email("test@test.com")
+                    .birthday("1990-01-01")
+                    .gender(com.loopers.domain.user.Gender.MALE)
+                    .build();
+
+            // act & assert
+            CoreException result = assertThrows(CoreException.class, () -> {
+                Like.builder()
+                        .user(user)
+                        .likeTargetId(validLikeTargetId)
+                        .likeTargetType(null)
+                        .build();
+            });
+
+            assertEquals(ErrorType.BAD_REQUEST, result.getErrorType());
+            assertEquals("Like : likeTargetType이 비어있을 수 없습니다.", result.getCustomMessage());
+        }
+    }
+}
+

From cd9d6976d45f53ffa411b880ac89d087a08ee2fe Mon Sep 17 00:00:00 2001
From: Yim Yoobin <yoobinbkk@naver.com>
Date: Fri, 14 Nov 2025 17:29:06 +0900
Subject: [PATCH 19/19] =?UTF-8?q?[Round=203]=20Order=20=EA=B0=9C=EB=B0=9C?=
 =?UTF-8?q?=20=EB=B0=98=EC=98=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../application/order/OrderFacade.java        |  61 ++++
 .../loopers/application/order/OrderInfo.java  |  58 ++++
 .../java/com/loopers/domain/order/Order.java  | 128 ++++++++
 .../com/loopers/domain/order/OrderItem.java   |  81 ++++++
 .../loopers/domain/order/OrderRepository.java |  13 +
 .../loopers/domain/order/OrderService.java    | 169 +++++++++++
 .../com/loopers/domain/order/OrderStatus.java |   6 +
 .../com/loopers/domain/payment/Payment.java   | 153 ++++++++++
 .../loopers/domain/payment/PaymentMethod.java |   6 +
 .../loopers/domain/payment/PaymentStatus.java |   6 +
 .../java/com/loopers/domain/stock/Stock.java  |  53 ++++
 .../loopers/domain/stock/StockRepository.java |  10 +
 .../loopers/domain/stock/StockService.java    |  57 ++++
 .../order/OrderJpaRepository.java             |  12 +
 .../order/OrderRepositoryImpl.java            |  34 +++
 .../stock/StockJpaRepository.java             |  11 +
 .../stock/StockQueryRepository.java           |  36 +++
 .../stock/StockRepositoryImpl.java            |  34 +++
 .../interfaces/api/order/OrderApiSpec.java    |  56 ++++
 .../interfaces/api/order/OrderController.java |  55 ++++
 .../interfaces/api/order/OrderDto.java        |  70 +++++
 .../domain/order/OrderServiceTest.java        | 273 ++++++++++++++++++
 22 files changed, 1382 insertions(+)
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.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/OrderService.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentMethod.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentStatus.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/stock/Stock.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/stock/StockRepository.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/stock/StockService.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/stock/StockJpaRepository.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockQueryRepository.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockRepositoryImpl.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderApiSpec.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderDto.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java

diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
new file mode 100644
index 000000000..74a6588c4
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
@@ -0,0 +1,61 @@
+package com.loopers.application.order;
+
+import com.loopers.domain.order.Order;
+import com.loopers.domain.order.OrderService;
+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 lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+@RequiredArgsConstructor
+@Component
+public class OrderFacade {
+
+    private final OrderService orderService;
+    private final UserService userService;
+
+    /**
+     * 주문 생성
+     */
+    @Transactional
+    public OrderInfo createOrder(String loginId, List<OrderService.OrderItemRequest> orderItemRequests) {
+        User user = userService.findUserByLoginId(loginId)
+                .orElseThrow(() -> new CoreException(
+                        ErrorType.NOT_FOUND,
+                        "[loginId = " + loginId + "] User를 찾을 수 없습니다."
+                ));
+
+        Order order = orderService.createOrder(user, orderItemRequests);
+        return OrderInfo.from(order);
+    }
+
+    /**
+     * 단일 주문 상세 조회
+     */
+    public OrderInfo getOrder(Long orderId) {
+        Order order = orderService.findOrderById(orderId);
+        return OrderInfo.from(order);
+    }
+
+    /**
+     * 유저의 주문 목록 조회
+     */
+    public List<OrderInfo> getOrders(String loginId) {
+        User user = userService.findUserByLoginId(loginId)
+                .orElseThrow(() -> new CoreException(
+                        ErrorType.NOT_FOUND,
+                        "[loginId = " + loginId + "] User를 찾을 수 없습니다."
+                ));
+
+        List<Order> orders = orderService.findOrdersByUser(user);
+        return orders.stream()
+                .map(OrderInfo::from)
+                .toList();
+    }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java
new file mode 100644
index 000000000..c732e7c10
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java
@@ -0,0 +1,58 @@
+package com.loopers.application.order;
+
+import com.loopers.domain.order.Order;
+import com.loopers.domain.order.OrderItem;
+import com.loopers.domain.order.OrderStatus;
+import lombok.Builder;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+@Builder
+public record OrderInfo(
+        Long id,
+        BigDecimal finalAmount,
+        BigDecimal totalPrice,
+        BigDecimal discountAmount,
+        BigDecimal shippingFee,
+        OrderStatus orderStatus,
+        String userLoginId,
+        List<OrderItemInfo> orderItems
+) {
+    public static OrderInfo from(Order order) {
+        return OrderInfo.builder()
+                .id(order.getId())
+                .finalAmount(order.getFinalAmount())
+                .totalPrice(order.getTotalPrice())
+                .discountAmount(order.getDiscountAmount())
+                .shippingFee(order.getShippingFee())
+                .orderStatus(order.getOrderStatus())
+                .userLoginId(order.getUser().getLoginId())
+                .orderItems(order.getOrderItems().stream()
+                        .map(OrderItemInfo::from)
+                        .toList())
+                .build();
+    }
+
+    @Builder
+    public record OrderItemInfo(
+            Long id,
+            Integer quantity,
+            BigDecimal unitPrice,
+            BigDecimal totalAmount,
+            Long productId,
+            String productName
+    ) {
+        public static OrderItemInfo from(OrderItem orderItem) {
+            return OrderItemInfo.builder()
+                    .id(orderItem.getId())
+                    .quantity(orderItem.getQuantity())
+                    .unitPrice(orderItem.getUnitPrice())
+                    .totalAmount(orderItem.getTotalAmount())
+                    .productId(orderItem.getProduct().getId())
+                    .productName(orderItem.getProduct().getName())
+                    .build();
+        }
+    }
+}
+
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..227fc8485
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java
@@ -0,0 +1,128 @@
+package com.loopers.domain.order;
+
+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.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.List;
+
+@Entity
+@Table(name = "orders")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Getter
+public class Order extends BaseEntity {
+
+    private BigDecimal finalAmount;
+    private BigDecimal totalPrice;
+    private BigDecimal discountAmount;
+    private BigDecimal shippingFee;
+
+    @Enumerated(EnumType.STRING)
+    private OrderStatus orderStatus;
+
+    @ManyToOne(fetch = FetchType.LAZY)
+    @JoinColumn(name = "user_id")
+    private User user;
+
+    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
+    private List<OrderItem> orderItems = new ArrayList<>();
+
+    @Builder
+    private Order(
+            BigDecimal finalAmount,
+            BigDecimal totalPrice,
+            BigDecimal discountAmount,
+            BigDecimal shippingFee,
+            OrderStatus orderStatus,
+            User user,
+            List<OrderItem> orderItems
+    ) {
+        this.finalAmount = finalAmount;
+        this.totalPrice = totalPrice;
+        this.discountAmount = discountAmount;
+        this.shippingFee = shippingFee;
+        this.orderStatus = orderStatus;
+        this.user = user;
+        if (orderItems != null) {
+            this.orderItems = orderItems;
+            orderItems.forEach(item -> item.setOrder(this));
+        }
+        guard();
+    }
+
+    public void setUser(User user) {
+        if (this.user != null) {
+            throw new CoreException(ErrorType.CONFLICT, "Order : User가 이미 존재합니다.");
+        }
+        this.user = user;
+    }
+
+    public void addOrderItem(OrderItem orderItem) {
+        this.orderItems.add(orderItem);
+        orderItem.setOrder(this);
+    }
+
+    public void confirm() {
+        if (this.orderStatus != OrderStatus.PENDING) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "Order : PENDING 상태의 주문만 확인할 수 있습니다.");
+        }
+        this.orderStatus = OrderStatus.CONFIRMED;
+    }
+
+    /**
+     * Payment에서 계산된 최종 금액을 설정
+     */
+    public void setFinalAmount(BigDecimal finalAmount) {
+        if (finalAmount == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "Order : finalAmount가 비어있을 수 없습니다.");
+        }
+        if (finalAmount.compareTo(BigDecimal.ZERO) < 0) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "Order : finalAmount는 음수가 될 수 없습니다.");
+        }
+        this.finalAmount = finalAmount;
+    }
+
+    @Override
+    protected void guard() {
+        if (finalAmount == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "Order : finalAmount가 비어있을 수 없습니다.");
+        } else if (finalAmount.compareTo(BigDecimal.ZERO) < 0) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "Order : finalAmount는 음수가 될 수 없습니다.");
+        }
+
+        if (totalPrice == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "Order : totalPrice가 비어있을 수 없습니다.");
+        } else if (totalPrice.compareTo(BigDecimal.ZERO) < 0) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "Order : totalPrice는 음수가 될 수 없습니다.");
+        }
+
+        if (discountAmount == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "Order : discountAmount가 비어있을 수 없습니다.");
+        } else if (discountAmount.compareTo(BigDecimal.ZERO) < 0) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "Order : discountAmount는 음수가 될 수 없습니다.");
+        }
+
+        if (shippingFee == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "Order : shippingFee가 비어있을 수 없습니다.");
+        } else if (shippingFee.compareTo(BigDecimal.ZERO) < 0) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "Order : shippingFee는 음수가 될 수 없습니다.");
+        }
+
+        if (orderStatus == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "Order : orderStatus가 비어있을 수 없습니다.");
+        }
+
+        if (orderItems == null || orderItems.isEmpty()) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "Order : orderItems가 비어있을 수 없습니다.");
+        }
+    }
+}
+
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..ffeca6734
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java
@@ -0,0 +1,81 @@
+package com.loopers.domain.order;
+
+import com.loopers.domain.BaseEntity;
+import com.loopers.domain.product.Product;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.math.BigDecimal;
+
+@Entity
+@Table(name = "order_item")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Getter
+public class OrderItem extends BaseEntity {
+
+    private Integer quantity;
+    private BigDecimal unitPrice;
+    private BigDecimal totalAmount;
+
+    @ManyToOne(fetch = FetchType.LAZY)
+    @JoinColumn(name = "order_id")
+    private Order order;
+
+    @ManyToOne(fetch = FetchType.LAZY)
+    @JoinColumn(name = "product_id")
+    private Product product;
+
+    @Builder
+    private OrderItem(
+            Integer quantity,
+            BigDecimal unitPrice,
+            BigDecimal totalAmount,
+            Order order,
+            Product product
+    ) {
+        this.quantity = quantity;
+        this.unitPrice = unitPrice;
+        this.totalAmount = totalAmount;
+        this.order = order;
+        this.product = product;
+        guard();
+    }
+
+    public void setOrder(Order order) {
+        if (this.order != null && !this.order.equals(order)) {
+            throw new CoreException(ErrorType.CONFLICT, "OrderItem : Order가 이미 존재합니다.");
+        }
+        this.order = order;
+    }
+
+    @Override
+    protected void guard() {
+        if (quantity == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "OrderItem : quantity가 비어있을 수 없습니다.");
+        } else if (quantity <= 0) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "OrderItem : quantity는 0보다 커야 합니다.");
+        }
+
+        if (unitPrice == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "OrderItem : unitPrice가 비어있을 수 없습니다.");
+        } else if (unitPrice.compareTo(BigDecimal.ZERO) < 0) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "OrderItem : unitPrice는 음수가 될 수 없습니다.");
+        }
+
+        if (totalAmount == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "OrderItem : totalAmount가 비어있을 수 없습니다.");
+        } else if (totalAmount.compareTo(BigDecimal.ZERO) < 0) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "OrderItem : totalAmount는 음수가 될 수 없습니다.");
+        }
+
+        if (product == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "OrderItem : product가 비어있을 수 없습니다.");
+        }
+    }
+}
+
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..e568a22b5
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java
@@ -0,0 +1,13 @@
+package com.loopers.domain.order;
+
+import com.loopers.domain.user.User;
+
+import java.util.List;
+import java.util.Optional;
+
+public interface OrderRepository {
+    Optional<Order> save(Order order);
+    Optional<Order> findById(Long id);
+    List<Order> findByUser(User user);
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java
new file mode 100644
index 000000000..5481d0938
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java
@@ -0,0 +1,169 @@
+package com.loopers.domain.order;
+
+import com.loopers.domain.payment.Payment;
+import com.loopers.domain.payment.PaymentMethod;
+import com.loopers.domain.point.Point;
+import com.loopers.domain.point.PointService;
+import com.loopers.domain.product.Product;
+import com.loopers.domain.product.ProductRepository;
+import com.loopers.domain.stock.StockService;
+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 java.math.BigDecimal;
+import java.util.List;
+
+@Component
+@RequiredArgsConstructor
+public class OrderService {
+
+    private final OrderRepository orderRepository;
+    private final ProductRepository productRepository;
+    private final StockService stockService;
+    private final PointService pointService;
+
+    /**
+     * 주문 생성 및 처리
+     * 1. 주문 아이템 생성 및 검증
+     * 2. 주문 생성 (PENDING 상태)
+     * 3. 재고 차감
+     * 4. Payment 생성 및 계산
+     * 5. 포인트 차감 (Payment를 통해)
+     * 6. 주문 저장 및 상태 변경
+     */
+    public Order createOrder(User user, List<OrderItemRequest> orderItemRequests) {
+        // 1. 주문 아이템 생성 및 검증
+        List<OrderItem> orderItems = orderItemRequests.stream()
+                .map(request -> {
+                    Product product = productRepository.findById(request.getProductId())
+                            .orElseThrow(() -> new CoreException(
+                                    ErrorType.NOT_FOUND,
+                                    "[productId = " + request.getProductId() + "] Product를 찾을 수 없습니다."
+                            ));
+
+                    // 상품 판매 가능 여부 확인
+                    if (!product.getIsSellable()) {
+                        throw new CoreException(
+                                ErrorType.BAD_REQUEST,
+                                "[productId = " + request.getProductId() + "] 판매 불가능한 상품입니다."
+                        );
+                    }
+
+                    BigDecimal unitPrice = product.getPrice();
+                    BigDecimal totalAmount = unitPrice.multiply(BigDecimal.valueOf(request.getQuantity()));
+
+                    return OrderItem.builder()
+                            .quantity(request.getQuantity())
+                            .unitPrice(unitPrice)
+                            .totalAmount(totalAmount)
+                            .product(product)
+                            .build();
+                })
+                .toList();
+
+        // 2. 주문 금액 계산
+        BigDecimal totalPrice = orderItems.stream()
+                .map(OrderItem::getTotalAmount)
+                .reduce(BigDecimal.ZERO, BigDecimal::add);
+
+        BigDecimal discountAmount = BigDecimal.ZERO; // 할인 로직은 추후 구현
+        BigDecimal shippingFee = BigDecimal.ZERO; // 배송비 로직은 추후 구현
+
+        // 3. 주문 생성 (PENDING 상태, finalAmount는 임시로 0 설정)
+        Order order = Order.builder()
+                .finalAmount(BigDecimal.ZERO) // Payment에서 계산된 값으로 업데이트됨
+                .totalPrice(totalPrice)
+                .discountAmount(discountAmount)
+                .shippingFee(shippingFee)
+                .orderStatus(OrderStatus.PENDING)
+                .user(user)
+                .orderItems(orderItems)
+                .build();
+
+        // 4. 재고 차감 (재고 부족 시 예외 발생)
+        for (OrderItem orderItem : orderItems) {
+            stockService.decreaseQuantity(
+                    orderItem.getProduct().getId(),
+                    Long.valueOf(orderItem.getQuantity())
+            );
+        }
+
+        // 5. Payment 생성 및 계산
+        Payment payment = Payment.create(
+                order,
+                totalPrice,
+                discountAmount,
+                shippingFee,
+                PaymentMethod.POINT
+        );
+
+        // Payment에서 계산된 최종 금액을 Order에 적용
+        BigDecimal finalAmount = payment.getAmount();
+        order.setFinalAmount(finalAmount);
+
+        // 6. 포인트 조회
+        Point point = pointService.findByUserLoginId(user.getLoginId())
+                .orElseThrow(() -> new CoreException(
+                        ErrorType.NOT_FOUND,
+                        "[loginId = " + user.getLoginId() + "] Point를 찾을 수 없습니다."
+                ));
+
+        // 7. Payment를 통해 포인트 차감 처리 (포인트 부족 시 예외 발생)
+        payment.processWithPoint(point);
+
+        // 8. 주문 저장
+        Order savedOrder = orderRepository.save(order)
+                .orElseThrow(() -> new CoreException(
+                        ErrorType.INTERNAL_ERROR,
+                        "주문 저장에 실패했습니다."
+                ));
+
+        // 9. 주문 상태를 CONFIRMED로 변경
+        savedOrder.confirm();
+        
+        return savedOrder;
+    }
+
+    /**
+     * 주문 조회
+     */
+    public Order findOrderById(Long orderId) {
+        return orderRepository.findById(orderId)
+                .orElseThrow(() -> new CoreException(
+                        ErrorType.NOT_FOUND,
+                        "[orderId = " + orderId + "] Order를 찾을 수 없습니다."
+                ));
+    }
+
+    /**
+     * 유저의 주문 목록 조회
+     */
+    public List<Order> findOrdersByUser(User user) {
+        return orderRepository.findByUser(user);
+    }
+
+    /**
+     * 주문 아이템 요청 DTO
+     */
+    public static class OrderItemRequest {
+        private Long productId;
+        private Integer quantity;
+
+        public OrderItemRequest(Long productId, Integer quantity) {
+            this.productId = productId;
+            this.quantity = quantity;
+        }
+
+        public Long getProductId() {
+            return productId;
+        }
+
+        public Integer getQuantity() {
+            return quantity;
+        }
+    }
+}
+
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..709ed4f4e
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java
@@ -0,0 +1,6 @@
+package com.loopers.domain.order;
+
+public enum OrderStatus {
+    PENDING, CONFIRMED, SHIPPING, DELIVERED, CANCELLED
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java
new file mode 100644
index 000000000..6947db7e4
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java
@@ -0,0 +1,153 @@
+package com.loopers.domain.payment;
+
+import com.loopers.domain.BaseEntity;
+import com.loopers.domain.order.Order;
+import com.loopers.domain.point.Point;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.math.BigDecimal;
+
+@Entity
+@Table(name = "payment")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Getter
+public class Payment extends BaseEntity {
+
+    private String pgTransactionId;
+    
+    @Enumerated(EnumType.STRING)
+    private PaymentMethod method;
+    
+    private BigDecimal amount;
+    
+    @Enumerated(EnumType.STRING)
+    private PaymentStatus paymentStatus;
+
+    @OneToOne(fetch = FetchType.LAZY)
+    @JoinColumn(name = "order_id")
+    private Order order;
+
+    @Builder
+    private Payment(
+            String pgTransactionId,
+            PaymentMethod method,
+            BigDecimal amount,
+            PaymentStatus paymentStatus,
+            Order order
+    ) {
+        this.pgTransactionId = pgTransactionId;
+        this.method = method;
+        this.amount = amount;
+        this.paymentStatus = paymentStatus;
+        this.order = order;
+        guard();
+    }
+
+    public void setOrder(Order order) {
+        if (this.order != null) {
+            throw new CoreException(ErrorType.CONFLICT, "Payment : Order가 이미 존재합니다.");
+        }
+        this.order = order;
+    }
+
+    /**
+     * 결제 금액 계산
+     * totalPrice - discountAmount + shippingFee = finalAmount
+     */
+    public static BigDecimal calculateFinalAmount(
+            BigDecimal totalPrice,
+            BigDecimal discountAmount,
+            BigDecimal shippingFee
+    ) {
+        if (totalPrice == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "Payment : totalPrice가 비어있을 수 없습니다.");
+        }
+        if (discountAmount == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "Payment : discountAmount가 비어있을 수 없습니다.");
+        }
+        if (shippingFee == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "Payment : shippingFee가 비어있을 수 없습니다.");
+        }
+
+        BigDecimal finalAmount = totalPrice.subtract(discountAmount).add(shippingFee);
+        
+        if (finalAmount.compareTo(BigDecimal.ZERO) < 0) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "Payment : 최종 결제 금액은 음수가 될 수 없습니다.");
+        }
+        
+        return finalAmount;
+    }
+
+    /**
+     * Payment 생성 및 계산
+     */
+    public static Payment create(
+            Order order,
+            BigDecimal totalPrice,
+            BigDecimal discountAmount,
+            BigDecimal shippingFee,
+            PaymentMethod method
+    ) {
+        BigDecimal finalAmount = calculateFinalAmount(totalPrice, discountAmount, shippingFee);
+        
+        return Payment.builder()
+                .method(method)
+                .amount(finalAmount)
+                .paymentStatus(PaymentStatus.PENDING)
+                .order(order)
+                .build();
+    }
+
+    /**
+     * 포인트 결제 처리
+     * 포인트 차감 및 결제 상태 변경
+     */
+    public void processWithPoint(Point point) {
+        if (this.paymentStatus != PaymentStatus.PENDING) {
+            throw new CoreException(
+                    ErrorType.BAD_REQUEST,
+                    "Payment : PENDING 상태의 결제만 처리할 수 있습니다."
+            );
+        }
+
+        if (this.method != PaymentMethod.POINT) {
+            throw new CoreException(
+                    ErrorType.BAD_REQUEST,
+                    "Payment : 포인트 결제 방식이 아닙니다."
+            );
+        }
+
+        // 포인트 차감 (포인트 부족 시 예외 발생)
+        point.deduct(this.amount);
+
+        // 결제 상태를 COMPLETED로 변경
+        this.paymentStatus = PaymentStatus.COMPLETED;
+    }
+
+    @Override
+    protected void guard() {
+        if (method == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "Payment : method가 비어있을 수 없습니다.");
+        }
+
+        if (amount == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "Payment : amount가 비어있을 수 없습니다.");
+        } else if (amount.compareTo(BigDecimal.ZERO) < 0) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "Payment : amount는 음수가 될 수 없습니다.");
+        }
+
+        if (paymentStatus == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "Payment : paymentStatus가 비어있을 수 없습니다.");
+        }
+
+        // order는 생성 시점에는 null일 수 있음 (Payment.create()에서 설정)
+        // guard에서는 검증하지 않음
+    }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentMethod.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentMethod.java
new file mode 100644
index 000000000..c83555809
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentMethod.java
@@ -0,0 +1,6 @@
+package com.loopers.domain.payment;
+
+public enum PaymentMethod {
+    POINT, CARD, BANK_TRANSFER
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentStatus.java
new file mode 100644
index 000000000..a81f0c68b
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentStatus.java
@@ -0,0 +1,6 @@
+package com.loopers.domain.payment;
+
+public enum PaymentStatus {
+    PENDING, COMPLETED, FAILED, CANCELLED, REFUNDED
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/stock/Stock.java b/apps/commerce-api/src/main/java/com/loopers/domain/stock/Stock.java
new file mode 100644
index 000000000..f7c4e4e0f
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/stock/Stock.java
@@ -0,0 +1,53 @@
+package com.loopers.domain.stock;
+
+import com.loopers.domain.BaseEntity;
+import com.loopers.domain.product.Product;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Entity
+@Table(name = "stock")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Getter
+public class Stock extends BaseEntity {
+
+    private Long quantity;
+
+    @OneToOne(fetch = FetchType.LAZY)
+    @JoinColumn(name = "product_id")
+    Product product;
+
+    @Builder
+    private Stock(
+        Long quantity
+        , Product product
+    ) {
+        this.quantity = quantity;
+        this.product = product;
+    }
+
+    // 상품 필드를 세팅 (초기화 위해 있음)
+    public void setProduct(Product product) {
+        if(this.product != null) {
+            throw new CoreException(ErrorType.CONFLICT, "Stock : Product 가 이미 존재합니다.");
+        }
+        this.product = product;
+    }
+
+    // 유효성 검사
+    @Override
+    protected void guard() {
+        // quantity 유효성 검사
+        if(quantity == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "Stock : quantity 가 비어있을 수 없습니다.");
+        } else if(quantity < 0L) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "Stock : quantity 는 음수가 될 수 없습니다.");
+        }
+    }
+
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockRepository.java
new file mode 100644
index 000000000..c43b0a1da
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockRepository.java
@@ -0,0 +1,10 @@
+package com.loopers.domain.stock;
+
+import java.util.Optional;
+
+public interface StockRepository {
+    Optional<Stock> findByProductId(Long productId);
+    Optional<Stock> save(Stock stock);
+    boolean decreaseQuantity(Long productId, Long decreaseQuantity);
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockService.java b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockService.java
new file mode 100644
index 000000000..76196feb5
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockService.java
@@ -0,0 +1,57 @@
+package com.loopers.domain.stock;
+
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import java.util.Optional;
+
+@RequiredArgsConstructor
+@Component
+public class StockService {
+
+    private final StockRepository stockRepository;
+
+    public Optional<Stock> findByProductId(Long productId) {
+        return stockRepository.findByProductId(productId);
+    }
+
+    public Optional<Stock> saveStock(Stock stock) {
+        return stockRepository.save(stock);
+    }
+
+    /**
+     * 재고 감소 (동시성 안전)
+     * @param productId 상품 ID
+     * @param decreaseQuantity 감소할 수량
+     * @throws CoreException 감소량이 0 이하이거나, 재고가 부족한 경우
+     */
+    public void decreaseQuantity(Long productId, Long decreaseQuantity) {
+        // 감소량 유효성 검사
+        if (decreaseQuantity == null || decreaseQuantity <= 0L) {
+            throw new CoreException(
+                    ErrorType.BAD_REQUEST,
+                    "재고 감소량은 0보다 커야 합니다."
+            );
+        }
+
+        // QueryDSL로 동시성 안전하게 업데이트
+        boolean success = stockRepository.decreaseQuantity(productId, decreaseQuantity);
+        
+        if (!success) {
+            // 재고 조회하여 상세 메시지 제공
+            Stock stock = stockRepository.findByProductId(productId)
+                    .orElseThrow(() -> new CoreException(
+                            ErrorType.NOT_FOUND,
+                            "[productId = " + productId + "] Stock을 찾을 수 없습니다."
+                    ));
+            
+            throw new CoreException(
+                    ErrorType.BAD_REQUEST,
+                    "재고가 부족합니다. (현재 재고: " + stock.getQuantity() + ", 요청 수량: " + decreaseQuantity + ")"
+            );
+        }
+    }
+}
+
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..07a1bf7de
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java
@@ -0,0 +1,12 @@
+package com.loopers.infrastructure.order;
+
+import com.loopers.domain.order.Order;
+import com.loopers.domain.user.User;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+
+public interface OrderJpaRepository extends JpaRepository<Order, Long> {
+    List<Order> findByUser(User user);
+}
+
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..8a8ac7d57
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java
@@ -0,0 +1,34 @@
+package com.loopers.infrastructure.order;
+
+import com.loopers.domain.order.Order;
+import com.loopers.domain.order.OrderRepository;
+import com.loopers.domain.user.User;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+import java.util.Optional;
+
+@RequiredArgsConstructor
+@Component
+public class OrderRepositoryImpl implements OrderRepository {
+
+    private final OrderJpaRepository orderJpaRepository;
+
+    @Override
+    public Optional<Order> save(Order order) {
+        Order savedOrder = orderJpaRepository.save(order);
+        return Optional.of(savedOrder);
+    }
+
+    @Override
+    public Optional<Order> findById(Long id) {
+        return orderJpaRepository.findById(id);
+    }
+
+    @Override
+    public List<Order> findByUser(User user) {
+        return orderJpaRepository.findByUser(user);
+    }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockJpaRepository.java
new file mode 100644
index 000000000..5cbc4a495
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockJpaRepository.java
@@ -0,0 +1,11 @@
+package com.loopers.infrastructure.stock;
+
+import com.loopers.domain.stock.Stock;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.Optional;
+
+public interface StockJpaRepository extends JpaRepository<Stock, Long> {
+    Optional<Stock> findByProductId(Long productId);
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockQueryRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockQueryRepository.java
new file mode 100644
index 000000000..e1bcde8d2
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockQueryRepository.java
@@ -0,0 +1,36 @@
+package com.loopers.infrastructure.stock;
+
+import com.querydsl.jpa.impl.JPAQueryFactory;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import static com.loopers.domain.stock.QStock.stock;
+
+@RequiredArgsConstructor
+@Component
+public class StockQueryRepository {
+
+    private final JPAQueryFactory queryFactory;
+
+    /**
+     * 재고 감소 (동시성 안전)
+     * @param productId 상품 ID
+     * @param decreaseQuantity 감소할 수량
+     * @return 업데이트된 행 수 (1이면 성공, 0이면 재고 부족)
+     */
+    public long decreaseQuantity(Long productId, Long decreaseQuantity) {
+        if (decreaseQuantity == null || decreaseQuantity <= 0L) {
+            return 0L;
+        }
+
+        return queryFactory
+                .update(stock)
+                .set(stock.quantity, stock.quantity.subtract(decreaseQuantity))
+                .where(
+                        stock.product.id.eq(productId)
+                                .and(stock.quantity.goe(decreaseQuantity))  // 재고가 충분한 경우만
+                )
+                .execute();
+    }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockRepositoryImpl.java
new file mode 100644
index 000000000..154242a66
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockRepositoryImpl.java
@@ -0,0 +1,34 @@
+package com.loopers.infrastructure.stock;
+
+import com.loopers.domain.stock.Stock;
+import com.loopers.domain.stock.StockRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import java.util.Optional;
+
+@RequiredArgsConstructor
+@Component
+public class StockRepositoryImpl implements StockRepository {
+
+    private final StockJpaRepository stockJpaRepository;
+    private final StockQueryRepository stockQueryRepository;
+
+    @Override
+    public Optional<Stock> findByProductId(Long productId) {
+        return stockJpaRepository.findByProductId(productId);
+    }
+
+    @Override
+    public Optional<Stock> save(Stock stock) {
+        Stock savedStock = stockJpaRepository.save(stock);
+        return Optional.of(savedStock);
+    }
+
+    @Override
+    public boolean decreaseQuantity(Long productId, Long decreaseQuantity) {
+        long updatedRows = stockQueryRepository.decreaseQuantity(productId, decreaseQuantity);
+        return updatedRows > 0L;
+    }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderApiSpec.java
new file mode 100644
index 000000000..4069dc2f8
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderApiSpec.java
@@ -0,0 +1,56 @@
+package com.loopers.interfaces.api.order;
+
+import com.loopers.interfaces.api.ApiResponse;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.enums.ParameterIn;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.tags.Tag;
+
+import java.util.List;
+
+@Tag(name = "Order API", description = "주문 관련 API 입니다.")
+public interface OrderApiSpec {
+
+    @Operation(
+            summary = "주문 요청",
+            description = "상품 주문을 생성합니다."
+    )
+    ApiResponse<OrderDto.OrderResponse> createOrder(
+            @Parameter(
+                    name = "X-USER-ID",
+                    in = ParameterIn.HEADER,
+                    description = "요청자 사용자 ID 헤더"
+            )
+            String xUserId,
+            @Schema(name = "주문 요청", description = "주문 요청 DTO")
+            OrderDto.CreateOrderRequest request
+    );
+
+    @Operation(
+            summary = "유저의 주문 목록 조회",
+            description = "로그인 ID로 주문 목록을 조회합니다."
+    )
+    ApiResponse<List<OrderDto.OrderResponse>> getOrders(
+            @Parameter(
+                    name = "X-USER-ID",
+                    in = ParameterIn.HEADER,
+                    description = "요청자 사용자 ID 헤더"
+            )
+            String xUserId
+    );
+
+    @Operation(
+            summary = "단일 주문 상세 조회",
+            description = "주문 ID로 주문 상세 정보를 조회합니다."
+    )
+    ApiResponse<OrderDto.OrderResponse> getOrder(
+            @Parameter(
+                    name = "orderId",
+                    in = ParameterIn.PATH,
+                    description = "주문 ID"
+            )
+            Long orderId
+    );
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java
new file mode 100644
index 000000000..d6c3b418b
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java
@@ -0,0 +1,55 @@
+package com.loopers.interfaces.api.order;
+
+import com.loopers.application.order.OrderFacade;
+import com.loopers.application.order.OrderInfo;
+import com.loopers.domain.order.OrderService;
+import com.loopers.interfaces.api.ApiResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/api/v1/orders")
+public class OrderController implements OrderApiSpec {
+
+    private final OrderFacade orderFacade;
+
+    @PostMapping("/")
+    @Override
+    public ApiResponse<OrderDto.OrderResponse> createOrder(
+            @RequestHeader(value = "X-USER-ID") String xUserId,
+            @RequestBody OrderDto.CreateOrderRequest request
+    ) {
+        // DTO를 도메인 서비스의 OrderItemRequest로 변환
+        List<OrderService.OrderItemRequest> orderItemRequests = request.items().stream()
+                .map(item -> new OrderService.OrderItemRequest(item.productId(), item.quantity()))
+                .toList();
+
+        OrderInfo orderInfo = orderFacade.createOrder(xUserId, orderItemRequests);
+        return ApiResponse.success(OrderDto.OrderResponse.from(orderInfo));
+    }
+
+    @GetMapping("/")
+    @Override
+    public ApiResponse<List<OrderDto.OrderResponse>> getOrders(
+            @RequestHeader(value = "X-USER-ID") String xUserId
+    ) {
+        List<OrderInfo> orderInfos = orderFacade.getOrders(xUserId);
+        List<OrderDto.OrderResponse> responses = orderInfos.stream()
+                .map(OrderDto.OrderResponse::from)
+                .toList();
+        return ApiResponse.success(responses);
+    }
+
+    @GetMapping("/{orderId}")
+    @Override
+    public ApiResponse<OrderDto.OrderResponse> getOrder(
+            @PathVariable Long orderId
+    ) {
+        OrderInfo orderInfo = orderFacade.getOrder(orderId);
+        return ApiResponse.success(OrderDto.OrderResponse.from(orderInfo));
+    }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderDto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderDto.java
new file mode 100644
index 000000000..08079cd2b
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderDto.java
@@ -0,0 +1,70 @@
+package com.loopers.interfaces.api.order;
+
+import com.loopers.application.order.OrderInfo;
+import lombok.Builder;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+public class OrderDto {
+
+    @Builder
+    public record CreateOrderRequest(
+            List<OrderItemRequest> items
+    ) {
+        @Builder
+        public record OrderItemRequest(
+                Long productId,
+                Integer quantity
+        ) {}
+    }
+
+    @Builder
+    public record OrderResponse(
+            Long id,
+            BigDecimal finalAmount,
+            BigDecimal totalPrice,
+            BigDecimal discountAmount,
+            BigDecimal shippingFee,
+            String orderStatus,
+            String userLoginId,
+            List<OrderItemResponse> orderItems
+    ) {
+        public static OrderResponse from(OrderInfo orderInfo) {
+            return OrderResponse.builder()
+                    .id(orderInfo.id())
+                    .finalAmount(orderInfo.finalAmount())
+                    .totalPrice(orderInfo.totalPrice())
+                    .discountAmount(orderInfo.discountAmount())
+                    .shippingFee(orderInfo.shippingFee())
+                    .orderStatus(orderInfo.orderStatus().name())
+                    .userLoginId(orderInfo.userLoginId())
+                    .orderItems(orderInfo.orderItems().stream()
+                            .map(OrderItemResponse::from)
+                            .toList())
+                    .build();
+        }
+    }
+
+    @Builder
+    public record OrderItemResponse(
+            Long id,
+            Integer quantity,
+            BigDecimal unitPrice,
+            BigDecimal totalAmount,
+            Long productId,
+            String productName
+    ) {
+        public static OrderItemResponse from(OrderInfo.OrderItemInfo orderItemInfo) {
+            return OrderItemResponse.builder()
+                    .id(orderItemInfo.id())
+                    .quantity(orderItemInfo.quantity())
+                    .unitPrice(orderItemInfo.unitPrice())
+                    .totalAmount(orderItemInfo.totalAmount())
+                    .productId(orderItemInfo.productId())
+                    .productName(orderItemInfo.productName())
+                    .build();
+        }
+    }
+}
+
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java
new file mode 100644
index 000000000..cdf60c891
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java
@@ -0,0 +1,273 @@
+package com.loopers.domain.order;
+
+import com.loopers.domain.brand.Brand;
+import com.loopers.domain.brand.BrandStatus;
+import com.loopers.domain.point.Point;
+import com.loopers.domain.point.PointService;
+import com.loopers.domain.product.Product;
+import com.loopers.domain.product.ProductRepository;
+import com.loopers.domain.product.ProductStatus;
+import com.loopers.domain.stock.Stock;
+import com.loopers.domain.stock.StockService;
+import com.loopers.domain.user.Gender;
+import com.loopers.domain.user.User;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.math.BigDecimal;
+import java.util.List;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+@DisplayName("OrderService 테스트")
+class OrderServiceTest {
+
+    @Mock
+    private OrderRepository orderRepository;
+
+    @Mock
+    private ProductRepository productRepository;
+
+    @Mock
+    private StockService stockService;
+
+    @Mock
+    private PointService pointService;
+
+    @InjectMocks
+    private OrderService orderService;
+
+    private User user;
+    private Product product;
+    private Stock stock;
+    private Point point;
+
+    @BeforeEach
+    void setUp() {
+        user = User.builder()
+                .loginId("testuser1")
+                .email("test@test.com")
+                .birthday("1990-01-01")
+                .gender(Gender.MALE)
+                .build();
+
+        Brand brand = Brand.builder()
+                .name("Test Brand")
+                .description("Test Description")
+                .status(BrandStatus.ON_SALE)
+                .isVisible(true)
+                .isSellable(true)
+                .build();
+
+        stock = Stock.builder()
+                .quantity(100L)
+                .product(null)
+                .build();
+
+        product = Product.builder()
+                .name("Test Product")
+                .description("Test Description")
+                .price(BigDecimal.valueOf(10000))
+                .likeCount(0L)
+                .status(ProductStatus.ON_SALE)
+                .isVisible(true)
+                .isSellable(true)
+                .brand(brand)
+                .stock(null)
+                .build();
+
+        stock.setProduct(product);
+        product.setStock(stock);
+
+        point = Point.builder()
+                .amount(BigDecimal.valueOf(50000))
+                .user(user)
+                .build();
+
+        user.setPoint(point);
+        point.setUser(user);
+    }
+
+    @DisplayName("정상 주문 흐름")
+    @Nested
+    class NormalOrderFlow {
+
+        @Test
+        @DisplayName("성공: 정상적인 주문 생성")
+        void createOrder_success() {
+            // given
+            List<OrderService.OrderItemRequest> requests = List.of(
+                    new OrderService.OrderItemRequest(product.getId(), 2)
+            );
+
+            when(productRepository.findById(anyLong())).thenReturn(Optional.of(product));
+            doNothing().when(stockService).decreaseQuantity(anyLong(), anyLong());
+            when(pointService.findByUserLoginId(any())).thenReturn(Optional.of(point));
+            when(orderRepository.save(any(Order.class))).thenAnswer(invocation -> {
+                Order order = invocation.getArgument(0);
+                return Optional.of(order);
+            });
+
+            // when
+            Order result = orderService.createOrder(user, requests);
+
+            // then
+            assertThat(result).isNotNull();
+            assertThat(result.getOrderStatus()).isEqualTo(OrderStatus.CONFIRMED);
+            assertThat(result.getOrderItems()).hasSize(1);
+            assertThat(result.getFinalAmount()).isEqualByComparingTo(BigDecimal.valueOf(20000));
+
+            verify(stockService, times(1)).decreaseQuantity(anyLong(), eq(2L));
+            verify(pointService, times(1)).findByUserLoginId(user.getLoginId());
+            verify(orderRepository, times(1)).save(any(Order.class));
+        }
+    }
+
+    @DisplayName("예외 주문 흐름")
+    @Nested
+    class ExceptionOrderFlow {
+
+        @Test
+        @DisplayName("실패: 상품을 찾을 수 없음")
+        void createOrder_productNotFound() {
+            // given
+            List<OrderService.OrderItemRequest> requests = List.of(
+                    new OrderService.OrderItemRequest(999L, 2)
+            );
+
+            when(productRepository.findById(999L)).thenReturn(Optional.empty());
+
+            // when & then
+            assertThatThrownBy(() -> orderService.createOrder(user, requests))
+                    .isInstanceOf(CoreException.class)
+                    .satisfies(exception -> {
+                        CoreException coreException = (CoreException) exception;
+                        assertThat(coreException.getErrorType()).isEqualTo(ErrorType.NOT_FOUND);
+                    });
+
+            verify(stockService, never()).decreaseQuantity(anyLong(), anyLong());
+            verify(pointService, never()).findByUserLoginId(any());
+        }
+
+        @Test
+        @DisplayName("실패: 판매 불가능한 상품")
+        void createOrder_productNotSellable() {
+            // given
+            Product unsellableProduct = Product.builder()
+                    .name("Unsellable Product")
+                    .description("Test")
+                    .price(BigDecimal.valueOf(10000))
+                    .likeCount(0L)
+                    .status(ProductStatus.ON_SALE)
+                    .isVisible(true)
+                    .isSellable(false) // 판매 불가
+                    .build();
+
+            List<OrderService.OrderItemRequest> requests = List.of(
+                    new OrderService.OrderItemRequest(unsellableProduct.getId(), 2)
+            );
+
+            when(productRepository.findById(anyLong())).thenReturn(Optional.of(unsellableProduct));
+
+            // when & then
+            assertThatThrownBy(() -> orderService.createOrder(user, requests))
+                    .isInstanceOf(CoreException.class)
+                    .satisfies(exception -> {
+                        CoreException coreException = (CoreException) exception;
+                        assertThat(coreException.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
+                    });
+
+            verify(stockService, never()).decreaseQuantity(anyLong(), anyLong());
+        }
+
+        @Test
+        @DisplayName("실패: 재고 부족")
+        void createOrder_insufficientStock() {
+            // given
+            List<OrderService.OrderItemRequest> requests = List.of(
+                    new OrderService.OrderItemRequest(product.getId(), 200) // 재고보다 많은 수량
+            );
+
+            when(productRepository.findById(anyLong())).thenReturn(Optional.of(product));
+            doThrow(new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다."))
+                    .when(stockService).decreaseQuantity(anyLong(), anyLong());
+
+            // when & then
+            assertThatThrownBy(() -> orderService.createOrder(user, requests))
+                    .isInstanceOf(CoreException.class)
+                    .satisfies(exception -> {
+                        CoreException coreException = (CoreException) exception;
+                        assertThat(coreException.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
+                    });
+
+            verify(pointService, never()).findByUserLoginId(any());
+            verify(orderRepository, never()).save(any(Order.class));
+        }
+
+        @Test
+        @DisplayName("실패: 포인트 부족")
+        void createOrder_insufficientPoint() {
+            // given
+            Point lowPoint = Point.builder()
+                    .amount(BigDecimal.valueOf(1000)) // 주문 금액보다 적은 포인트
+                    .user(user)
+                    .build();
+
+            List<OrderService.OrderItemRequest> requests = List.of(
+                    new OrderService.OrderItemRequest(product.getId(), 2) // 20000원 주문
+            );
+
+            when(productRepository.findById(anyLong())).thenReturn(Optional.of(product));
+            doNothing().when(stockService).decreaseQuantity(anyLong(), anyLong());
+            when(pointService.findByUserLoginId(any())).thenReturn(Optional.of(lowPoint));
+
+            // when & then
+            assertThatThrownBy(() -> orderService.createOrder(user, requests))
+                    .isInstanceOf(CoreException.class)
+                    .satisfies(exception -> {
+                        CoreException coreException = (CoreException) exception;
+                        assertThat(coreException.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
+                    });
+
+            verify(orderRepository, never()).save(any(Order.class));
+        }
+
+        @Test
+        @DisplayName("실패: 포인트를 찾을 수 없음")
+        void createOrder_pointNotFound() {
+            // given
+            List<OrderService.OrderItemRequest> requests = List.of(
+                    new OrderService.OrderItemRequest(product.getId(), 2)
+            );
+
+            when(productRepository.findById(anyLong())).thenReturn(Optional.of(product));
+            doNothing().when(stockService).decreaseQuantity(anyLong(), anyLong());
+            when(pointService.findByUserLoginId(any())).thenReturn(Optional.empty());
+
+            // when & then
+            assertThatThrownBy(() -> orderService.createOrder(user, requests))
+                    .isInstanceOf(CoreException.class)
+                    .satisfies(exception -> {
+                        CoreException coreException = (CoreException) exception;
+                        assertThat(coreException.getErrorType()).isEqualTo(ErrorType.NOT_FOUND);
+                    });
+
+            verify(orderRepository, never()).save(any(Order.class));
+        }
+    }
+}
+
