From 794d2361646133a126662b2d775a84ae4150d599 Mon Sep 17 00:00:00 2001
From: minor7295 <44902090+minor7295@users.noreply.github.com>
Date: Mon, 3 Nov 2025 00:45:40 +0900
Subject: [PATCH 01/12] =?UTF-8?q?[volume-1]=20=ED=9A=8C=EC=9B=90=EA=B0=80?=
=?UTF-8?q?=EC=9E=85,=20=EB=82=B4=20=EC=A0=95=EB=B3=B4=20=EC=A1=B0?=
=?UTF-8?q?=ED=9A=8C,=20=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=A1=B0=ED=9A=8C,?=
=?UTF-8?q?=20=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=B6=A9=EC=A0=84=20=EA=B8=B0?=
=?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84=20(#22)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* Feature/user (#1)
* test: User 단위테스트 추가
* feat: User 도메인 구현
* test: 회원 가입 통합테스트 추가
* feat: 회원가입 서비스 로직 구현
* test: 회원가입 E2E 테스트 추가
* feat: 회원가입 API 구현
* test: gender필드를 저장할 수 있도록 테스트 코드 수정
* refactor: User도메인에 성별 필드 추가
* test: 회원 정보 조회 통합 테스트 작성
* feat: 회원 정보 조회 서비스 로직 구현
* test: 회원 정보 조회 E2E 테스트 작성
* feat: 회원 정보 조회 API 추가
* Feature/point (#2)
* test: 회원가입 관련 테스트 코드가 SignUpFacade를 참조하도록 수정
* refactor: 회원가입을 처리하는 SignUpFacade 구현
* test: 포인트 조회 통합테스트 추가
* feat: 포인트 조회 서비스 로직 구현
* test: 포인트 조회 E2E 테스트 코드 추가
* feat: 포인트 조회 API 로직 추가
* test: 포인트 충전 단위 테스트 추가
* feat: 포인트 충전 도메인 로직 추가
* test: 포인트 충전 테스트 코드 추가
* feat: 포인트 충전 서비스 로직 추가
* test: 포인트 충전 E2E 테스트 코드 추가
* feat: 포인트 충전 API 추가
* docs: 회원가입, 내 정보 조회, 포인트 조회, 포인트 충전 기능 관련 docstring 추가 (#3)
---
.../application/signup/SignUpFacade.java | 49 +++++
.../application/signup/SignUpInfo.java | 38 ++++
.../java/com/loopers/domain/point/Point.java | 84 ++++++++
.../loopers/domain/point/PointRepository.java | 30 +++
.../loopers/domain/point/PointService.java | 68 ++++++
.../java/com/loopers/domain/user/Gender.java | 14 ++
.../java/com/loopers/domain/user/User.java | 134 ++++++++++++
.../loopers/domain/user/UserRepository.java | 28 +++
.../com/loopers/domain/user/UserService.java | 54 +++++
.../point/PointJpaRepository.java | 33 +++
.../point/PointRepositoryImpl.java | 39 ++++
.../user/UserJpaRepository.java | 25 +++
.../user/UserRepositoryImpl.java | 38 ++++
.../interfaces/api/ApiControllerAdvice.java | 133 ++++++++++--
.../api/point/PointsV1Controller.java | 69 ++++++
.../interfaces/api/point/PointsV1Dto.java | 42 ++++
.../api/signup/SignUpV1Controller.java | 56 +++++
.../interfaces/api/signup/SignUpV1Dto.java | 54 +++++
.../api/userinfo/UserInfoV1Controller.java | 50 +++++
.../api/userinfo/UserInfoV1Dto.java | 41 ++++
.../point/PointServiceIntegrationTest.java | 94 ++++++++
.../com/loopers/domain/point/PointTest.java | 37 ++++
.../user/UserServiceIntegrationTest.java | 131 ++++++++++++
.../com/loopers/domain/user/UserTest.java | 59 +++++
.../loopers/domain/user/UserTestFixture.java | 22 ++
.../interfaces/api/PointsV1ApiE2ETest.java | 201 ++++++++++++++++++
.../interfaces/api/SignUpV1ApiE2ETest.java | 111 ++++++++++
.../interfaces/api/UserInfoV1ApiE2ETest.java | 135 ++++++++++++
28 files changed, 1857 insertions(+), 12 deletions(-)
create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/signup/SignUpFacade.java
create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/signup/SignUpInfo.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/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/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/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/point/PointsV1Controller.java
create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointsV1Dto.java
create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/signup/SignUpV1Controller.java
create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/signup/SignUpV1Dto.java
create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/userinfo/UserInfoV1Controller.java
create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/userinfo/UserInfoV1Dto.java
create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java
create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/point/PointTest.java
create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java
create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java
create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/UserTestFixture.java
create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointsV1ApiE2ETest.java
create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/SignUpV1ApiE2ETest.java
create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserInfoV1ApiE2ETest.java
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/signup/SignUpFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/signup/SignUpFacade.java
new file mode 100644
index 000000000..2b5ada30c
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/signup/SignUpFacade.java
@@ -0,0 +1,49 @@
+package com.loopers.application.signup;
+
+import com.loopers.domain.point.PointService;
+import com.loopers.domain.user.Gender;
+import com.loopers.domain.user.User;
+import com.loopers.domain.user.UserService;
+import jakarta.transaction.Transactional;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+/**
+ * 회원가입 파사드.
+ *
+ * 회원가입 시 사용자 생성과 포인트 초기화를 조율하는
+ * 애플리케이션 서비스입니다.
+ * 트랜잭션 경계를 관리하여 데이터 일관성을 보장합니다.
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@RequiredArgsConstructor
+@Component
+public class SignUpFacade {
+ private final UserService userService;
+
+ private final PointService pointService;
+
+ /**
+ * 회원가입을 처리합니다.
+ *
+ * 사용자를 생성하고 초기 포인트(0)를 부여합니다.
+ * 전체 과정이 하나의 트랜잭션으로 처리됩니다.
+ *
+ *
+ * @param userId 사용자 ID
+ * @param email 이메일 주소
+ * @param birthDateStr 생년월일 (yyyy-MM-dd)
+ * @param gender 성별
+ * @return 생성된 사용자 정보
+ * @throws com.loopers.support.error.CoreException 유효성 검증 실패 또는 중복 ID 존재 시
+ */
+ @Transactional
+ public SignUpInfo signUp(String userId, String email, String birthDateStr, Gender gender) {
+ User user = userService.create(userId, email, birthDateStr, gender);
+ pointService.create(user, 0L);
+ return SignUpInfo.from(user);
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/signup/SignUpInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/signup/SignUpInfo.java
new file mode 100644
index 000000000..c84caf7a3
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/signup/SignUpInfo.java
@@ -0,0 +1,38 @@
+package com.loopers.application.signup;
+
+import com.loopers.domain.user.Gender;
+import com.loopers.domain.user.User;
+
+import java.time.LocalDate;
+
+/**
+ * 회원가입 결과 정보를 담는 레코드.
+ *
+ * User 도메인 엔티티로부터 생성된 불변 데이터 전송 객체입니다.
+ *
+ *
+ * @param id 사용자 엔티티 ID
+ * @param userId 사용자 ID
+ * @param email 이메일 주소
+ * @param birthDate 생년월일
+ * @param gender 성별
+ * @author Loopers
+ * @version 1.0
+ */
+public record SignUpInfo(Long id, String userId, String email, LocalDate birthDate, Gender gender) {
+ /**
+ * User 엔티티로부터 SignUpInfo를 생성합니다.
+ *
+ * @param user 변환할 사용자 엔티티
+ * @return 생성된 SignUpInfo
+ */
+ public static SignUpInfo from(User user) {
+ return new SignUpInfo(
+ user.getId(),
+ user.getUserId(),
+ user.getEmail(),
+ user.getBirthDate(),
+ user.getGender()
+ );
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java
new file mode 100644
index 000000000..663d4080f
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java
@@ -0,0 +1,84 @@
+package com.loopers.domain.point;
+
+import com.loopers.domain.BaseEntity;
+import com.loopers.domain.user.User;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import jakarta.persistence.*;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/**
+ * 포인트 도메인 엔티티.
+ *
+ * 사용자의 포인트 잔액을 관리하며, 포인트 충전 기능을 제공합니다.
+ * User와 일대일 관계를 맺고 있습니다.
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Entity
+@Table(name = "point")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Getter
+public class Point extends BaseEntity {
+ @OneToOne(fetch = FetchType.LAZY, optional = false)
+ @JoinColumn(
+ name = "user_id",
+ referencedColumnName = "id",
+ foreignKey = @ForeignKey(value = ConstraintMode.NO_CONSTRAINT)
+ )
+ private User user;
+
+ @Column(name = "balance", nullable = false)
+ private Long balance;
+
+ /**
+ * Point 인스턴스를 생성합니다.
+ *
+ * @param user 포인트 소유자
+ * @param balance 초기 잔액 (null인 경우 0으로 초기화)
+ */
+ public Point(User user, Long balance) {
+ this.user = user;
+ this.balance = balance != null ? balance : 0L;
+ }
+
+ /**
+ * Point 인스턴스를 생성하는 정적 팩토리 메서드.
+ *
+ * @param user 포인트 소유자
+ * @param balance 초기 잔액
+ * @return 생성된 Point 인스턴스
+ */
+ public static Point of(User user, Long balance) {
+ return new Point(user, balance);
+ }
+
+ /**
+ * 포인트를 충전합니다.
+ *
+ * @param amount 충전할 포인트 금액 (0보다 커야 함)
+ * @throws CoreException amount가 null이거나 0 이하일 경우
+ */
+ public void charge(Long amount) {
+ validateChargeAmount(amount);
+ this.balance += amount;
+ }
+
+ /**
+ * 충전 금액의 유효성을 검증합니다.
+ *
+ * @param amount 검증할 충전 금액
+ * @throws CoreException amount가 null이거나 0 이하일 경우
+ */
+ private void validateChargeAmount(Long amount) {
+ if (amount == null || amount <= 0) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "포인트는 0보다 큰 값이어야 합니다.");
+ }
+ }
+}
+
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointRepository.java
new file mode 100644
index 000000000..cbf2c3d08
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointRepository.java
@@ -0,0 +1,30 @@
+package com.loopers.domain.point;
+
+/**
+ * Point 엔티티에 대한 저장소 인터페이스.
+ *
+ * 포인트 정보의 영속성 계층과의 상호작용을 정의합니다.
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+public interface PointRepository {
+ /**
+ * 포인트를 저장합니다.
+ *
+ * @param point 저장할 포인트
+ * @return 저장된 포인트
+ */
+ Point save(Point point);
+
+ /**
+ * 사용자 ID로 포인트를 조회합니다.
+ *
+ * @param userId 조회할 사용자 ID
+ * @return 조회된 포인트, 없으면 null
+ */
+ Point findByUserId(String userId);
+}
+
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java
new file mode 100644
index 000000000..5d03e73ef
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java
@@ -0,0 +1,68 @@
+package com.loopers.domain.point;
+
+import com.loopers.domain.user.User;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * 포인트 도메인 서비스.
+ *
+ * 포인트 생성, 조회, 충전 등의 도메인 로직을 처리합니다.
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@RequiredArgsConstructor
+@Component
+public class PointService {
+ private final PointRepository pointRepository;
+
+ /**
+ * 새로운 포인트를 생성합니다.
+ *
+ * @param user 포인트 소유자
+ * @param balance 초기 잔액
+ * @return 생성된 포인트
+ */
+ public Point create(User user, Long balance) {
+ Point point = Point.of(user, balance);
+ return pointRepository.save(point);
+ }
+
+ /**
+ * 사용자 ID로 포인트를 조회합니다.
+ *
+ * @param userId 조회할 사용자 ID
+ * @return 조회된 포인트, 없으면 null
+ */
+ public Point findByUserId(String userId) {
+ return pointRepository.findByUserId(userId);
+ }
+
+ /**
+ * 사용자의 포인트를 충전합니다.
+ *
+ * 트랜잭션 내에서 실행되어 데이터 일관성을 보장합니다.
+ *
+ *
+ * @param userId 사용자 ID
+ * @param amount 충전할 금액 (0보다 커야 함)
+ * @return 충전된 포인트
+ * @throws CoreException 포인트를 찾을 수 없거나 충전 금액이 유효하지 않을 경우
+ */
+ @Transactional
+ public Point charge(String userId, Long amount) {
+ Point point = pointRepository.findByUserId(userId);
+ if (point == null) {
+ throw new CoreException(ErrorType.NOT_FOUND, "포인트를 찾을 수 없습니다.");
+ }
+ point.charge(amount);
+ return pointRepository.save(point);
+ }
+}
+
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/Gender.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/Gender.java
new file mode 100644
index 000000000..7616a497f
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/Gender.java
@@ -0,0 +1,14 @@
+package com.loopers.domain.user;
+
+/**
+ * 사용자의 성별을 나타내는 열거형.
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+public enum Gender {
+ MALE,
+ FEMALE
+}
+
+
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..f547389c5
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java
@@ -0,0 +1,134 @@
+package com.loopers.domain.user;
+
+import com.loopers.domain.BaseEntity;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Table;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.EnumType;
+import lombok.Getter;
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.regex.Pattern;
+
+/**
+ * 사용자 도메인 엔티티.
+ *
+ * 사용자의 기본 정보(ID, 이메일, 생년월일, 성별)를 관리하며,
+ * 각 필드에 대한 유효성 검증을 수행합니다.
+ *
+ *
+ * 검증 규칙
+ *
+ * - userId: 영문 및 숫자 조합, 최대 10자
+ * - email: 유효한 이메일 형식
+ * - birthDate: yyyy-MM-dd 형식
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Entity
+@Table(name = "user")
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@Getter
+public class User extends BaseEntity {
+ @Column(name = "user_id", unique = true, nullable = false, length = 10)
+ private String userId;
+
+ private String email;
+
+ private LocalDate birthDate;
+
+ @Enumerated(EnumType.STRING)
+ private Gender gender;
+
+ private static final Pattern USER_ID_PATTERN = Pattern.compile("^[a-zA-Z0-9]{1,10}$");
+ /**
+ * 사용자 ID의 유효성을 검증합니다.
+ *
+ * @param userId 검증할 사용자 ID
+ * @throws CoreException userId가 null, 공백이거나 형식에 맞지 않을 경우
+ */
+ private void validateUserId(String userId) {
+ if (userId == null || userId.isBlank()) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "ID는 필수입니다.");
+ }
+ if (!USER_ID_PATTERN.matcher(userId).matches()) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "ID는 영문 및 숫자 10자 이내여야 합니다.");
+ }
+ }
+
+ private static final Pattern EMAIL_PATTERN = Pattern.compile("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$");
+ /**
+ * 이메일의 유효성을 검증합니다.
+ *
+ * @param email 검증할 이메일 주소
+ * @throws CoreException email이 null, 공백이거나 형식에 맞지 않을 경우
+ */
+ private void validateEmail(String email) {
+ if (email == null || email.isBlank()) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "이메일은 필수입니다.");
+ }
+ if (!EMAIL_PATTERN.matcher(email).matches()) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "이메일 형식이 올바르지 않습니다.");
+ }
+ }
+
+ private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+ /**
+ * 생년월일의 유효성을 검증합니다.
+ *
+ * @param birthDate 검증할 생년월일 문자열
+ * @throws CoreException birthDate가 null, 공백이거나 yyyy-MM-dd 형식이 아닐 경우
+ */
+ private static void validateBirthDate(String birthDate) {
+ if (birthDate == null || birthDate.isBlank()) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 필수입니다.");
+ }
+ try {
+ LocalDate.parse(birthDate, DATE_FORMATTER);
+ } catch (DateTimeParseException e) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 yyyy-MM-dd 형식이어야 합니다.");
+ }
+ }
+ /**
+ * 사용자를 생성합니다.
+ *
+ * @param userId 사용자 ID (영문 및 숫자, 최대 10자)
+ * @param email 이메일 주소
+ * @param birthDateStr 생년월일 (yyyy-MM-dd 형식)
+ * @param gender 성별
+ * @throws CoreException userId, email, birthDate가 유효하지 않을 경우
+ */
+ public User (String userId, String email, String birthDateStr, Gender gender) {
+ validateUserId(userId);
+ validateEmail(email);
+ validateBirthDate(birthDateStr);
+
+ this.userId = userId;
+ this.email = email;
+ this.birthDate = LocalDate.parse(birthDateStr);
+ this.gender = gender;
+ }
+ /**
+ * User 인스턴스를 생성하는 정적 팩토리 메서드.
+ *
+ * @param userId 사용자 ID
+ * @param email 이메일 주소
+ * @param birthDate 생년월일 문자열
+ * @param gender 성별
+ * @return 생성된 User 인스턴스
+ * @throws CoreException 유효성 검증 실패 시
+ */
+ public static User of(String userId, String email, String birthDate, Gender gender) {
+ return new User(userId, email, birthDate, gender);
+ }
+
+}
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..e2fffb58f
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java
@@ -0,0 +1,28 @@
+package com.loopers.domain.user;
+
+/**
+ * User 엔티티에 대한 저장소 인터페이스.
+ *
+ * 사용자 정보의 영속성 계층과의 상호작용을 정의합니다.
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+public interface UserRepository {
+ /**
+ * 사용자를 저장합니다.
+ *
+ * @param user 저장할 사용자
+ * @return 저장된 사용자
+ */
+ User save(User user);
+
+ /**
+ * 사용자 ID로 사용자를 조회합니다.
+ *
+ * @param userId 조회할 사용자 ID
+ * @return 조회된 사용자, 없으면 null
+ */
+ User findByUserId(String userId);
+}
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..8b66f855c
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java
@@ -0,0 +1,54 @@
+package com.loopers.domain.user;
+
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import lombok.RequiredArgsConstructor;
+import org.springframework.dao.DataIntegrityViolationException;
+import org.springframework.stereotype.Component;
+
+/**
+ * 사용자 도메인 서비스.
+ *
+ * 사용자 생성 및 조회 등의 도메인 로직을 처리합니다.
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@RequiredArgsConstructor
+@Component
+public class UserService {
+ private final UserRepository userRepository;
+
+ /**
+ * 새로운 사용자를 생성합니다.
+ *
+ * @param userId 사용자 ID
+ * @param email 이메일 주소
+ * @param birthDateStr 생년월일 (yyyy-MM-dd)
+ * @param gender 성별
+ * @return 생성된 사용자
+ * @throws CoreException 중복된 사용자 ID가 존재하거나 유효성 검증 실패 시
+ */
+ public User create(String userId, String email, String birthDateStr, Gender gender) {
+ User user = User.of(userId, email, birthDateStr, gender);
+ try {
+ return userRepository.save(user);
+ } catch (DataIntegrityViolationException e) {
+ if (e.getMessage() != null && e.getMessage().contains("user_id")) {
+ throw new CoreException(ErrorType.CONFLICT, "이미 가입된 ID입니다: " + userId);
+ }
+ throw new CoreException(ErrorType.CONFLICT, "데이터 무결성 제약 조건 위반");
+ }
+ }
+
+ /**
+ * 사용자 ID로 사용자를 조회합니다.
+ *
+ * @param userId 조회할 사용자 ID
+ * @return 조회된 사용자, 없으면 null
+ */
+ public User findByUserId(String userId) {
+ return userRepository.findByUserId(userId);
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointJpaRepository.java
new file mode 100644
index 000000000..b32add174
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointJpaRepository.java
@@ -0,0 +1,33 @@
+package com.loopers.infrastructure.point;
+
+import com.loopers.domain.point.Point;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import java.util.Optional;
+
+/**
+ * Point 엔티티를 위한 Spring Data JPA 리포지토리.
+ *
+ * JpaRepository를 확장하여 기본 CRUD 기능과
+ * 사용자 ID 기반 조회 기능을 제공합니다.
+ * N+1 문제 방지를 위해 Fetch Join을 사용합니다.
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+public interface PointJpaRepository extends JpaRepository {
+ /**
+ * 사용자 ID로 포인트를 조회합니다.
+ *
+ * JOIN FETCH를 사용하여 연관된 User 엔티티를 함께 로드합니다.
+ *
+ *
+ * @param userId 조회할 사용자 ID
+ * @return 조회된 포인트를 담은 Optional
+ */
+ @Query("SELECT p FROM Point p JOIN FETCH p.user WHERE p.user.userId = :userId")
+ Optional findByUserId(@Param("userId") String userId);
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointRepositoryImpl.java
new file mode 100644
index 000000000..f85e31482
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointRepositoryImpl.java
@@ -0,0 +1,39 @@
+package com.loopers.infrastructure.point;
+
+import com.loopers.domain.point.Point;
+import com.loopers.domain.point.PointRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+/**
+ * PointRepository의 JPA 구현체.
+ *
+ * Spring Data JPA를 활용하여 Point 엔티티의
+ * 영속성 작업을 처리합니다.
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@RequiredArgsConstructor
+@Component
+public class PointRepositoryImpl implements PointRepository {
+ private final PointJpaRepository pointJpaRepository;
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public Point save(Point point) {
+ return pointJpaRepository.save(point);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public Point findByUserId(String userId) {
+ return pointJpaRepository.findByUserId(userId).orElse(null);
+ }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java
new file mode 100644
index 000000000..905189891
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java
@@ -0,0 +1,25 @@
+package com.loopers.infrastructure.user;
+
+import com.loopers.domain.user.User;
+import org.springframework.data.jpa.repository.JpaRepository;
+import java.util.Optional;
+
+/**
+ * User 엔티티를 위한 Spring Data JPA 리포지토리.
+ *
+ * JpaRepository를 확장하여 기본 CRUD 기능과
+ * 사용자 ID 기반 조회 기능을 제공합니다.
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+public interface UserJpaRepository extends JpaRepository {
+ /**
+ * 사용자 ID로 사용자를 조회합니다.
+ *
+ * @param userId 조회할 사용자 ID
+ * @return 조회된 사용자를 담은 Optional
+ */
+ Optional findByUserId(String userId);
+}
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..25d6ead87
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java
@@ -0,0 +1,38 @@
+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;
+
+/**
+ * UserRepository의 JPA 구현체.
+ *
+ * Spring Data JPA를 활용하여 User 엔티티의
+ * 영속성 작업을 처리합니다.
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@RequiredArgsConstructor
+@Component
+public class UserRepositoryImpl implements UserRepository {
+ private final UserJpaRepository userJpaRepository;
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public User save(User user) {
+ return userJpaRepository.save(user);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public User findByUserId(String userId) {
+ return userJpaRepository.findByUserId(userId).orElse(null);
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java
index 20b2809c8..7f2948f2a 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java
@@ -9,6 +9,8 @@
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MissingServletRequestParameterException;
+import org.springframework.web.bind.MissingRequestHeaderException;
+import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
@@ -19,16 +21,47 @@
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
-
+import java.util.stream.Stream;
+
+/**
+ * 전역 API 예외 처리 핸들러.
+ *
+ * 애플리케이션 전역에서 발생하는 예외를 가로채어
+ * 일관된 형식의 에러 응답을 생성합니다.
+ *
+ *
+ * 처리하는 예외 유형
+ *
+ * - CoreException: 도메인 비즈니스 로직 예외
+ * - Validation 예외: 요청 데이터 검증 실패
+ * - HTTP 메시지 변환 예외: JSON 파싱 오류
+ * - 기타 예상치 못한 예외
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
@RestControllerAdvice
@Slf4j
public class ApiControllerAdvice {
+ /**
+ * CoreException을 처리합니다.
+ *
+ * @param e 발생한 CoreException
+ * @return 에러 응답
+ */
@ExceptionHandler
public ResponseEntity> handle(CoreException e) {
log.warn("CoreException : {}", e.getCustomMessage() != null ? e.getCustomMessage() : e.getMessage(), e);
return failureResponse(e.getErrorType(), e.getCustomMessage());
}
+ /**
+ * 요청 파라미터 타입 불일치 예외를 처리합니다.
+ *
+ * @param e 발생한 MethodArgumentTypeMismatchException
+ * @return BAD_REQUEST 에러 응답
+ */
@ExceptionHandler
public ResponseEntity> handleBadRequest(MethodArgumentTypeMismatchException e) {
String name = e.getName();
@@ -38,6 +71,12 @@ public ResponseEntity> handleBadRequest(MethodArgumentTypeMismatc
return failureResponse(ErrorType.BAD_REQUEST, message);
}
+ /**
+ * 필수 요청 파라미터 누락 예외를 처리합니다.
+ *
+ * @param e 발생한 MissingServletRequestParameterException
+ * @return BAD_REQUEST 에러 응답
+ */
@ExceptionHandler
public ResponseEntity> handleBadRequest(MissingServletRequestParameterException e) {
String name = e.getParameterName();
@@ -46,6 +85,45 @@ public ResponseEntity> handleBadRequest(MissingServletRequestPara
return failureResponse(ErrorType.BAD_REQUEST, message);
}
+ /**
+ * 필수 요청 헤더 누락 예외를 처리합니다.
+ *
+ * @param e 발생한 MissingRequestHeaderException
+ * @return BAD_REQUEST 에러 응답
+ */
+ @ExceptionHandler
+ public ResponseEntity> handleBadRequest(MissingRequestHeaderException e) {
+ String name = e.getHeaderName();
+ String message = String.format("필수 요청 헤더 '%s'가 누락되었습니다.", name);
+ return failureResponse(ErrorType.BAD_REQUEST, message);
+ }
+
+ /**
+ * 요청 데이터 유효성 검증 실패 예외를 처리합니다.
+ *
+ * @param e 발생한 MethodArgumentNotValidException
+ * @return BAD_REQUEST 에러 응답 (검증 실패 필드 정보 포함)
+ */
+ @ExceptionHandler
+ public ResponseEntity> handleBadRequest(MethodArgumentNotValidException e) {
+ String message = Stream.concat(
+ e.getBindingResult().getFieldErrors().stream()
+ .map(err -> String.format("필드 '%s' %s", err.getField(), err.getDefaultMessage())),
+ e.getBindingResult().getGlobalErrors().stream()
+ .map(err -> String.format("객체 '%s' %s", err.getObjectName(), err.getDefaultMessage()))
+ )
+ .filter(str -> str != null && !str.isBlank())
+ .collect(Collectors.joining(", "));
+ return failureResponse(ErrorType.BAD_REQUEST, message.isBlank() ? null : message);
+ }
+
+ /**
+ * HTTP 메시지 읽기 실패 예외를 처리합니다.
+ * JSON 파싱 오류, 타입 불일치 등을 처리합니다.
+ *
+ * @param e 발생한 HttpMessageNotReadableException
+ * @return BAD_REQUEST 에러 응답
+ */
@ExceptionHandler
public ResponseEntity> handleBadRequest(HttpMessageNotReadableException e) {
String errorMessage;
@@ -53,15 +131,15 @@ public ResponseEntity> handleBadRequest(HttpMessageNotReadableExc
if (rootCause instanceof InvalidFormatException invalidFormat) {
String fieldName = invalidFormat.getPath().stream()
- .map(ref -> ref.getFieldName() != null ? ref.getFieldName() : "?")
- .collect(Collectors.joining("."));
+ .map(ref -> ref.getFieldName() != null ? ref.getFieldName() : "?")
+ .collect(Collectors.joining("."));
String valueIndicationMessage = "";
if (invalidFormat.getTargetType().isEnum()) {
Class> enumClass = invalidFormat.getTargetType();
String enumValues = Arrays.stream(enumClass.getEnumConstants())
- .map(Object::toString)
- .collect(Collectors.joining(", "));
+ .map(Object::toString)
+ .collect(Collectors.joining(", "));
valueIndicationMessage = "사용 가능한 값 : [" + enumValues + "]";
}
@@ -69,20 +147,20 @@ public ResponseEntity> handleBadRequest(HttpMessageNotReadableExc
Object value = invalidFormat.getValue();
errorMessage = String.format("필드 '%s'의 값 '%s'이(가) 예상 타입(%s)과 일치하지 않습니다. %s",
- fieldName, value, expectedType, valueIndicationMessage);
+ fieldName, value, expectedType, valueIndicationMessage);
} else if (rootCause instanceof MismatchedInputException mismatchedInput) {
String fieldPath = mismatchedInput.getPath().stream()
- .map(ref -> ref.getFieldName() != null ? ref.getFieldName() : "?")
- .collect(Collectors.joining("."));
+ .map(ref -> ref.getFieldName() != null ? ref.getFieldName() : "?")
+ .collect(Collectors.joining("."));
errorMessage = String.format("필수 필드 '%s'이(가) 누락되었습니다.", fieldPath);
} else if (rootCause instanceof JsonMappingException jsonMapping) {
String fieldPath = jsonMapping.getPath().stream()
- .map(ref -> ref.getFieldName() != null ? ref.getFieldName() : "?")
- .collect(Collectors.joining("."));
+ .map(ref -> ref.getFieldName() != null ? ref.getFieldName() : "?")
+ .collect(Collectors.joining("."));
errorMessage = String.format("필드 '%s'에서 JSON 매핑 오류가 발생했습니다: %s",
- fieldPath, jsonMapping.getOriginalMessage());
+ fieldPath, jsonMapping.getOriginalMessage());
} else {
errorMessage = "요청 본문을 처리하는 중 오류가 발생했습니다. JSON 메세지 규격을 확인해주세요.";
@@ -91,6 +169,12 @@ public ResponseEntity> handleBadRequest(HttpMessageNotReadableExc
return failureResponse(ErrorType.BAD_REQUEST, errorMessage);
}
+ /**
+ * 서버 웹 입력 예외를 처리합니다.
+ *
+ * @param e 발생한 ServerWebInputException
+ * @return BAD_REQUEST 에러 응답
+ */
@ExceptionHandler
public ResponseEntity> handleBadRequest(ServerWebInputException e) {
String missingParams = extractMissingParameter(e.getReason() != null ? e.getReason() : "");
@@ -102,25 +186,50 @@ public ResponseEntity> handleBadRequest(ServerWebInputException e
}
}
+ /**
+ * 리소스를 찾을 수 없는 예외를 처리합니다.
+ *
+ * @param e 발생한 NoResourceFoundException
+ * @return NOT_FOUND 에러 응답
+ */
@ExceptionHandler
public ResponseEntity> handleNotFound(NoResourceFoundException e) {
return failureResponse(ErrorType.NOT_FOUND, null);
}
+ /**
+ * 예상치 못한 모든 예외를 처리합니다.
+ *
+ * @param e 발생한 Throwable
+ * @return INTERNAL_ERROR 에러 응답
+ */
@ExceptionHandler
public ResponseEntity> handle(Throwable e) {
log.error("Exception : {}", e.getMessage(), e);
return failureResponse(ErrorType.INTERNAL_ERROR, null);
}
+ /**
+ * 에러 메시지에서 누락된 파라미터명을 추출합니다.
+ *
+ * @param message 에러 메시지
+ * @return 추출된 파라미터명
+ */
private String extractMissingParameter(String message) {
Pattern pattern = Pattern.compile("'(.+?)'");
Matcher matcher = pattern.matcher(message);
return matcher.find() ? matcher.group(1) : "";
}
+ /**
+ * 에러 타입과 메시지를 기반으로 실패 응답을 생성합니다.
+ *
+ * @param errorType 에러 타입
+ * @param errorMessage 에러 메시지
+ * @return 에러 응답
+ */
private ResponseEntity> failureResponse(ErrorType errorType, String errorMessage) {
return ResponseEntity.status(errorType.getStatus())
- .body(ApiResponse.fail(errorType.getCode(), errorMessage != null ? errorMessage : errorType.getMessage()));
+ .body(ApiResponse.fail(errorType.getCode(), errorMessage != null ? errorMessage : errorType.getMessage()));
}
}
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointsV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointsV1Controller.java
new file mode 100644
index 000000000..c313fbb7d
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointsV1Controller.java
@@ -0,0 +1,69 @@
+package com.loopers.interfaces.api.point;
+
+import com.loopers.domain.point.Point;
+import com.loopers.domain.point.PointService;
+import com.loopers.interfaces.api.ApiResponse;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 포인트 API v1 컨트롤러.
+ *
+ * 사용자의 포인트 조회 및 충전 기능을 제공합니다.
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/api/v1")
+public class PointsV1Controller {
+
+ private final PointService pointService;
+
+ /**
+ * 현재 사용자의 포인트를 조회합니다.
+ *
+ * @param userId X-USER-ID 헤더로 전달된 사용자 ID
+ * @return 포인트 정보를 담은 API 응답
+ * @throws CoreException 포인트를 찾을 수 없는 경우
+ */
+ @GetMapping("/me/points")
+ public ApiResponse getMyPoints(
+ @RequestHeader("X-USER-ID") String userId
+ ) {
+ Point point = pointService.findByUserId(userId);
+ if (point == null) {
+ throw new CoreException(ErrorType.NOT_FOUND, null);
+ }
+
+ return ApiResponse.success(PointsV1Dto.PointsResponse.from(point));
+ }
+
+ /**
+ * 현재 사용자의 포인트를 충전합니다.
+ *
+ * @param userId X-USER-ID 헤더로 전달된 사용자 ID
+ * @param request 충전 요청 데이터 (amount)
+ * @return 충전된 포인트 정보를 담은 API 응답
+ * @throws CoreException 포인트를 찾을 수 없거나 충전 금액이 유효하지 않은 경우
+ */
+ @PostMapping("/me/points/charge")
+ public ApiResponse chargePoints(
+ @RequestHeader("X-USER-ID") String userId,
+ @Valid @RequestBody PointsV1Dto.ChargeRequest request
+ ) {
+ Point point = pointService.charge(userId, request.amount());
+ return ApiResponse.success(PointsV1Dto.PointsResponse.from(point));
+ }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointsV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointsV1Dto.java
new file mode 100644
index 000000000..3107b17a0
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointsV1Dto.java
@@ -0,0 +1,42 @@
+package com.loopers.interfaces.api.point;
+
+import com.loopers.domain.point.Point;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Positive;
+
+/**
+ * 포인트 API v1의 데이터 전송 객체(DTO) 컨테이너.
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+public class PointsV1Dto {
+ /**
+ * 포인트 정보 응답 데이터.
+ *
+ * @param userId 사용자 ID
+ * @param balance 포인트 잔액
+ */
+ public record PointsResponse(String userId, Long balance) {
+ /**
+ * Point 엔티티로부터 PointsResponse를 생성합니다.
+ *
+ * @param point 포인트 엔티티
+ * @return 생성된 응답 객체
+ */
+ public static PointsResponse from(Point point) {
+ return new PointsResponse(point.getUser().getUserId(), point.getBalance());
+ }
+ }
+
+ /**
+ * 포인트 충전 요청 데이터.
+ *
+ * @param amount 충전할 포인트 금액 (필수, 0보다 커야 함)
+ */
+ public record ChargeRequest(
+ @NotNull(message = "포인트는 필수입니다.")
+ @Positive(message = "포인트는 0보다 큰 값이어야 합니다.")
+ Long amount
+ ) {}
+}
\ No newline at end of file
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/signup/SignUpV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/signup/SignUpV1Controller.java
new file mode 100644
index 000000000..13f72103f
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/signup/SignUpV1Controller.java
@@ -0,0 +1,56 @@
+package com.loopers.interfaces.api.signup;
+
+import com.loopers.application.signup.SignUpFacade;
+import com.loopers.application.signup.SignUpInfo;
+import com.loopers.domain.user.Gender;
+import com.loopers.interfaces.api.ApiResponse;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import java.util.Locale;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 회원가입 API v1 컨트롤러.
+ *
+ * 사용자 회원가입 요청을 처리하는 REST API를 제공합니다.
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/api/v1/signup")
+public class SignUpV1Controller {
+
+ private final SignUpFacade signUpFacade;
+
+ /**
+ * 회원가입을 처리합니다.
+ *
+ * @param request 회원가입 요청 데이터 (userId, email, birthDate, gender)
+ * @return 생성된 사용자 정보를 담은 API 응답
+ * @throws CoreException gender 값이 유효하지 않거나, 유효성 검증 실패 또는 중복 ID 존재 시
+ */
+ @PostMapping
+ public ApiResponse signUp(
+ @Valid @RequestBody SignUpV1Dto.SignUpRequest request
+ ) {
+ Gender gender;
+ try {
+ String genderValue = request.gender().trim().toUpperCase(Locale.ROOT);
+ gender = Gender.valueOf(genderValue);
+ } catch (IllegalArgumentException e) {
+ throw new CoreException(ErrorType.BAD_REQUEST, "gender 값이 올바르지 않습니다.");
+ }
+
+ SignUpInfo info = signUpFacade.signUp(request.userId(), request.email(), request.birthDate(), gender);
+ SignUpV1Dto.SignupResponse response = SignUpV1Dto.SignupResponse.from(info);
+ return ApiResponse.success(response);
+ }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/signup/SignUpV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/signup/SignUpV1Dto.java
new file mode 100644
index 000000000..afcfeae33
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/signup/SignUpV1Dto.java
@@ -0,0 +1,54 @@
+package com.loopers.interfaces.api.signup;
+
+import com.loopers.application.signup.SignUpInfo;
+import jakarta.validation.constraints.NotBlank;
+
+/**
+ * 회원가입 API v1의 데이터 전송 객체(DTO) 컨테이너.
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+public class SignUpV1Dto {
+ /**
+ * 회원가입 요청 데이터.
+ *
+ * @param userId 사용자 ID (필수)
+ * @param email 이메일 주소 (필수)
+ * @param birthDate 생년월일 (필수, yyyy-MM-dd)
+ * @param gender 성별 (필수, MALE 또는 FEMALE)
+ */
+ public record SignUpRequest(
+ @NotBlank String userId,
+ @NotBlank String email,
+ @NotBlank String birthDate,
+ @NotBlank String gender
+ ) {}
+
+ /**
+ * 회원가입 응답 데이터.
+ *
+ * @param id 사용자 엔티티 ID
+ * @param userId 사용자 ID
+ * @param email 이메일 주소
+ * @param birthDate 생년월일
+ * @param gender 성별
+ */
+ public record SignupResponse(Long id, String userId, String email, String birthDate, String gender) {
+ /**
+ * SignUpInfo로부터 SignupResponse를 생성합니다.
+ *
+ * @param info 회원가입 정보
+ * @return 생성된 응답 객체
+ */
+ public static SignupResponse from(SignUpInfo info) {
+ return new SignupResponse(
+ info.id(),
+ info.userId(),
+ info.email(),
+ info.birthDate().toString(),
+ info.gender() != null ? info.gender().name() : null
+ );
+ }
+ }
+}
\ No newline at end of file
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/userinfo/UserInfoV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/userinfo/UserInfoV1Controller.java
new file mode 100644
index 000000000..36b858d05
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/userinfo/UserInfoV1Controller.java
@@ -0,0 +1,50 @@
+package com.loopers.interfaces.api.userinfo;
+
+import com.loopers.domain.user.User;
+import com.loopers.domain.user.UserService;
+import com.loopers.interfaces.api.ApiResponse;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 사용자 정보 API v1 컨트롤러.
+ *
+ * 인증된 사용자의 정보 조회 기능을 제공합니다.
+ *
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/api/v1")
+public class UserInfoV1Controller {
+
+ private final UserService userService;
+
+ /**
+ * 현재 사용자의 정보를 조회합니다.
+ *
+ * @param userId X-USER-ID 헤더로 전달된 사용자 ID
+ * @return 사용자 정보를 담은 API 응답
+ * @throws CoreException 사용자를 찾을 수 없는 경우
+ */
+ @GetMapping("/me")
+ public ApiResponse getMyInfo(
+ @RequestHeader("X-USER-ID") String userId
+ ) {
+ User user = userService.findByUserId(userId);
+ if (user == null) {
+ throw new CoreException(ErrorType.NOT_FOUND, null);
+ }
+
+ return ApiResponse.success(UserInfoV1Dto.UserInfoResponse.from(user));
+ }
+}
+
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/userinfo/UserInfoV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/userinfo/UserInfoV1Dto.java
new file mode 100644
index 000000000..369f684a4
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/userinfo/UserInfoV1Dto.java
@@ -0,0 +1,41 @@
+package com.loopers.interfaces.api.userinfo;
+
+import com.loopers.domain.user.User;
+
+/**
+ * 사용자 정보 API v1의 데이터 전송 객체(DTO) 컨테이너.
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+public class UserInfoV1Dto {
+ /**
+ * 사용자 정보 응답 데이터.
+ *
+ * @param userId 사용자 ID
+ * @param email 이메일 주소
+ * @param birthDate 생년월일 (문자열)
+ * @param gender 성별
+ */
+ public record UserInfoResponse(
+ String userId,
+ String email,
+ String birthDate,
+ String gender
+ ) {
+ /**
+ * User 엔티티로부터 UserInfoResponse를 생성합니다.
+ *
+ * @param user 사용자 엔티티
+ * @return 생성된 응답 객체
+ */
+ public static UserInfoResponse from(User user) {
+ return new UserInfoResponse(
+ user.getUserId(),
+ user.getEmail(),
+ user.getBirthDate().toString(),
+ user.getGender() != null ? user.getGender().name() : null
+ );
+ }
+ }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java
new file mode 100644
index 000000000..a77d11ab2
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java
@@ -0,0 +1,94 @@
+package com.loopers.domain.point;
+
+import com.loopers.application.signup.SignUpFacade;
+import com.loopers.domain.user.Gender;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import com.loopers.utils.DatabaseCleanUp;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+@SpringBootTest
+public class PointServiceIntegrationTest {
+ @Autowired
+ private PointService pointService;
+
+ @Autowired
+ private SignUpFacade signUpFacade;
+
+ @Autowired
+ private DatabaseCleanUp databaseCleanUp;
+
+ @AfterEach
+ void tearDown() {
+ databaseCleanUp.truncateAllTables();
+ }
+
+ @DisplayName("포인트 조회에 관한 통합 테스트")
+ @Nested
+ class PointsLookup {
+ @DisplayName("해당 ID 의 회원이 존재할 경우, 보유 포인트가 반환된다.")
+ @Test
+ void returnsPoints_whenUserExists() {
+ // arrange
+ String userId = "testuser";
+ String email = "test@example.com";
+ String birthDate = "1990-01-01";
+ Gender gender = Gender.MALE;
+ Long balance = 0L;
+ signUpFacade.signUp(userId, email, birthDate, gender);
+
+ // act
+ Point point = pointService.findByUserId(userId);
+
+ // assert
+ assertAll(
+ () -> assertThat(point).isNotNull(),
+ () -> assertThat(point.getUser().getUserId()).isEqualTo(userId),
+ () -> assertThat(point.getBalance()).isEqualTo(balance)
+ );
+ }
+
+ @DisplayName("해당 ID 의 회원이 존재하지 않을 경우, null 이 반환된다.")
+ @Test
+ void returnsNull_whenUserDoesNotExist() {
+ // arrange
+ String userId = "unknown";
+
+ // act
+ Point found = pointService.findByUserId(userId);
+
+ // assert
+ assertThat(found).isNull();
+ }
+ }
+
+ @DisplayName("포인트 충전에 관한 통합 테스트")
+ @Nested
+ class PointCharge {
+ @DisplayName("존재하지 않는 유저 ID 로 충전을 시도한 경우, 실패한다.")
+ @Test
+ void fails_whenUserDoesNotExist() {
+ // arrange
+ String userId = "unknown";
+ Long chargeAmount = 1000L;
+
+ // act
+ CoreException result = assertThrows(CoreException.class, () ->
+ pointService.charge(userId, chargeAmount)
+ );
+
+ // assert
+ assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND);
+ }
+ }
+}
+
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointTest.java
new file mode 100644
index 000000000..38b7f7c80
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointTest.java
@@ -0,0 +1,37 @@
+package com.loopers.domain.point;
+
+import com.loopers.domain.user.User;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.mock;
+
+public class PointTest {
+
+ @DisplayName("Point 도메인의 금액 검증에 관한 단위 테스트")
+ @Nested
+ class BalanceValidation {
+ @DisplayName("0 이하의 정수로 포인트를 충전 시 실패한다.")
+ @ParameterizedTest
+ @ValueSource(longs = {0L, -1L, -100L})
+ void throwsBadRequest_whenChargingWithNonPositiveAmount(long nonPositiveAmount) {
+ // arrange
+ User user = mock(User.class);
+ Point point = Point.of(user, 0L);
+
+ // act
+ CoreException result = assertThrows(CoreException.class, () -> {
+ point.charge(nonPositiveAmount);
+ });
+
+ // assert
+ assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
+ }
+ }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java
new file mode 100644
index 000000000..82d76bad8
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java
@@ -0,0 +1,131 @@
+package com.loopers.domain.user;
+
+import com.loopers.application.signup.SignUpFacade;
+import com.loopers.application.signup.SignUpInfo;
+import com.loopers.infrastructure.user.UserJpaRepository;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import com.loopers.utils.DatabaseCleanUp;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+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.time.LocalDate;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+@SpringBootTest
+public class UserServiceIntegrationTest {
+ @Autowired
+ private SignUpFacade signUpFacade;
+
+ @Autowired
+ private UserService userService;
+
+ @MockitoSpyBean
+ private UserJpaRepository userJpaRepository;
+
+ @Autowired
+ private DatabaseCleanUp databaseCleanUp;
+
+ @AfterEach
+ void tearDown() {
+ databaseCleanUp.truncateAllTables();
+ }
+
+ @DisplayName("회원 가입에 관한 통합 테스트")
+ @Nested
+ class SignUp {
+ @DisplayName("회원가입시 User 저장이 수행된다.")
+ @ParameterizedTest
+ @EnumSource(Gender.class)
+ void returnsExampleInfo_whenValidIdIsProvided(Gender gender) {
+ // arrange
+ String userId = UserTestFixture.ValidUser.USER_ID;
+ String email = UserTestFixture.ValidUser.EMAIL;
+ String birthDate = UserTestFixture.ValidUser.BIRTH_DATE;
+ Mockito.reset(userJpaRepository);
+
+ // act
+ SignUpInfo signUpInfo = signUpFacade.signUp(userId, email, birthDate, gender);
+
+ // assert
+ assertAll(
+ () -> assertThat(signUpInfo).isNotNull(),
+ () -> assertThat(signUpInfo.userId()).isEqualTo(userId),
+ () -> verify(userJpaRepository, times(1)).save(any(User.class))
+ );
+ }
+
+ @DisplayName("이미 가입된 ID로 회원가입 시도 시, 실패한다.")
+ @ParameterizedTest
+ @EnumSource(Gender.class)
+ void fails_whenDuplicateUserIdExists(Gender gender) {
+ // arrange
+ String userId = UserTestFixture.ValidUser.USER_ID;
+ String email = UserTestFixture.ValidUser.EMAIL;
+ String birthDate = UserTestFixture.ValidUser.BIRTH_DATE;
+ signUpFacade.signUp(userId, email, birthDate, gender);
+
+ // act
+ CoreException result = assertThrows(CoreException.class, () ->
+ signUpFacade.signUp(userId, email, birthDate, gender)
+ );
+
+ // assert
+ assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT);
+ }
+ }
+
+ @DisplayName("회원 조회에 관한 통합 테스트")
+ @Nested
+ class UserInfo {
+ @DisplayName("해당 ID 의 회원이 존재할 경우, 회원 정보가 반환된다.")
+ @ParameterizedTest
+ @EnumSource(Gender.class)
+ void returnsUser_whenUserExists(Gender gender) {
+ // arrange
+ String userId = UserTestFixture.ValidUser.USER_ID;
+ String email = UserTestFixture.ValidUser.EMAIL;
+ String birthDate = UserTestFixture.ValidUser.BIRTH_DATE;
+ signUpFacade.signUp(userId, email, birthDate, gender);
+
+ // act
+ User found = userService.findByUserId(userId);
+
+ // assert
+ assertAll(
+ () -> assertThat(found).isNotNull(),
+ () -> assertThat(found.getUserId()).isEqualTo(userId),
+ () -> assertThat(found.getEmail()).isEqualTo(email),
+ () -> assertThat(found.getBirthDate()).isEqualTo(LocalDate.parse(birthDate)),
+ () -> assertThat(found.getGender()).isEqualTo(gender)
+ );
+ }
+
+ @DisplayName("해당 ID 의 회원이 존재하지 않을 경우, null 이 반환된다.")
+ @Test
+ void returnsNull_whenUserDoesNotExist() {
+ // arrange
+ String userId = "unknown";
+
+ // act
+ User found = userService.findByUserId(userId);
+
+ // assert
+ assertThat(found).isNull();
+ }
+ }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java
new file mode 100644
index 000000000..3fc3950d4
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java
@@ -0,0 +1,59 @@
+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.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+public class UserTest {
+ @DisplayName("User 도메인의 생성에 관한 단위 테스트")
+ @Nested
+ class Create {
+ @DisplayName("ID 가 `영문 및 숫자 10자 이내` 형식에 맞지 않으면, User 객체 생성에 실패한다.")
+ @ParameterizedTest
+ @EnumSource(Gender.class)
+ void throwsBadRequestException_whenIdFormatIsInvalid(Gender gender) {
+ // arrange
+ String userId = UserTestFixture.InvalidUser.USER_ID;
+ // act
+ CoreException result = assertThrows(CoreException.class, () -> {
+ User.of(userId, UserTestFixture.ValidUser.EMAIL, UserTestFixture.ValidUser.BIRTH_DATE, gender);
+ });
+ // assert
+ assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
+ }
+
+ @DisplayName("이메일이 `xx@yy.zz` 형식에 맞지 않으면, User 객체 생성에 실패한다.")
+ @ParameterizedTest
+ @EnumSource(Gender.class)
+ void throwsBadRequestException_whenEmailFormatIsInvalid(Gender gender) {
+ // arrange
+ String email = UserTestFixture.InvalidUser.EMAIL;
+ // act
+ CoreException result = assertThrows(CoreException.class, () -> {
+ User.of(UserTestFixture.ValidUser.USER_ID, email, UserTestFixture.ValidUser.BIRTH_DATE, gender);
+ });
+ // assert
+ assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
+ }
+
+ @DisplayName("생년월일이 `yyyy-MM-dd` 형식에 맞지 않으면, User 객체 생성에 실패한다.")
+ @ParameterizedTest
+ @EnumSource(Gender.class)
+ void throwsBadRequestException_whenBirthDateIsInvalid(Gender gender) {
+ // arrange
+ String birthDateStr = UserTestFixture.InvalidUser.BIRTH_DATE;
+ // act
+ CoreException result = assertThrows(CoreException.class, () -> {
+ User.of(UserTestFixture.ValidUser.USER_ID, UserTestFixture.ValidUser.EMAIL, birthDateStr, gender);
+ });
+ // assert
+ assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
+ }
+ }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTestFixture.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTestFixture.java
new file mode 100644
index 000000000..a36637064
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTestFixture.java
@@ -0,0 +1,22 @@
+package com.loopers.domain.user;
+
+/**
+ * 테스트용 고정 데이터 (Fixture) 클래스
+ * 모든 User 관련 테스트에서 사용하는 공통 데이터를 관리
+ */
+public class UserTestFixture {
+
+ // 기본 유효한 테스트 데이터
+ public static final class ValidUser {
+ public static final String USER_ID = "testuser";
+ public static final String EMAIL = "test@example.com";
+ public static final String BIRTH_DATE = "1990-01-01";
+ }
+
+ // 유효하지 않은 테스트 데이터
+ public static final class InvalidUser {
+ public static final String USER_ID = "한글";
+ public static final String EMAIL = "test";
+ public static final String BIRTH_DATE = "2024.1.1";
+ }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointsV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointsV1ApiE2ETest.java
new file mode 100644
index 000000000..3621bbd60
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointsV1ApiE2ETest.java
@@ -0,0 +1,201 @@
+package com.loopers.interfaces.api;
+
+import com.loopers.application.signup.SignUpFacade;
+import com.loopers.domain.user.Gender;
+import com.loopers.domain.user.UserTestFixture;
+import com.loopers.interfaces.api.point.PointsV1Dto;
+import com.loopers.utils.DatabaseCleanUp;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.core.ParameterizedTypeReference;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+public class PointsV1ApiE2ETest {
+
+ private static final String ENDPOINT_POINTS = "/api/v1/me/points";
+
+ private final TestRestTemplate testRestTemplate;
+ private final SignUpFacade signUpFacade;
+ private final DatabaseCleanUp databaseCleanUp;
+
+ @Autowired
+ public PointsV1ApiE2ETest(
+ TestRestTemplate testRestTemplate,
+ SignUpFacade signUpFacade,
+ DatabaseCleanUp databaseCleanUp
+ ) {
+ this.testRestTemplate = testRestTemplate;
+ this.signUpFacade = signUpFacade;
+ this.databaseCleanUp = databaseCleanUp;
+ }
+
+ @AfterEach
+ void tearDown() {
+ databaseCleanUp.truncateAllTables();
+ }
+
+ @DisplayName("GET /api/v1/me/points")
+ @Nested
+ class GetMyPoints {
+ @DisplayName("포인트 조회에 성공할 경우, 보유 포인트를 응답으로 반환한다.")
+ @ParameterizedTest
+ @EnumSource(Gender.class)
+ void returnsPoints_whenUserExists(Gender gender) {
+ // arrange
+ String userId = UserTestFixture.ValidUser.USER_ID;
+ String email = UserTestFixture.ValidUser.EMAIL;
+ String birthDate = UserTestFixture.ValidUser.BIRTH_DATE;
+ signUpFacade.signUp(userId, email, birthDate, gender);
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_JSON);
+ HttpEntity httpEntity = new HttpEntity<>(headers);
+
+ // act
+ ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {};
+ headers.add("X-USER-ID", userId);
+ ResponseEntity> response =
+ testRestTemplate.exchange(ENDPOINT_POINTS, HttpMethod.GET, httpEntity, responseType);
+
+ // assert
+ assertAll(
+ () -> assertTrue(response.getStatusCode().is2xxSuccessful()),
+ () -> assertThat(response.getBody()).isNotNull(),
+ () -> assertThat(response.getBody().data()).isNotNull(),
+ () -> assertThat(response.getBody().data().userId()).isEqualTo(userId),
+ () -> assertThat(response.getBody().data().balance()).isEqualTo(0L),
+ () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS)
+ );
+ }
+
+ @DisplayName("존재하지 않는 ID 로 조회할 경우, `404 Not Found` 응답을 반환한다.")
+ @Test
+ void returns404_whenUserDoesNotExist() {
+ // arrange
+ String userId = "unknown";
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_JSON);
+ HttpEntity httpEntity = new HttpEntity<>(headers);
+
+ // act
+ ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {};
+ headers.add("X-USER-ID", userId);
+ ResponseEntity> response =
+ testRestTemplate.exchange(ENDPOINT_POINTS, HttpMethod.GET, httpEntity, responseType);
+
+ // assert
+ assertAll(
+ () -> assertThat(response.getStatusCode().value()).isEqualTo(404),
+ () -> assertThat(response.getBody()).isNotNull(),
+ () -> assertThat(response.getBody().meta()).isNotNull(),
+ () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL)
+ );
+ }
+
+ @DisplayName("`X-USER-ID` 헤더가 없을 경우, `400 Bad Request` 응답을 반환한다.")
+ @Test
+ void returns400_whenHeaderMissing() {
+ // arrange
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_JSON);
+ HttpEntity httpEntity = new HttpEntity<>(headers);
+
+ // act
+ ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {};
+ ResponseEntity> response =
+ testRestTemplate.exchange(ENDPOINT_POINTS, HttpMethod.GET, httpEntity, responseType);
+
+ // assert
+ assertAll(
+ () -> assertThat(response.getStatusCode().value()).isEqualTo(400),
+ () -> assertThat(response.getBody()).isNotNull(),
+ () -> assertThat(response.getBody().meta()).isNotNull(),
+ () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL)
+ );
+ }
+ }
+
+ @DisplayName("POST /api/v1/me/points/charge")
+ @Nested
+ class ChargePoints {
+ private static final String ENDPOINT_CHARGE = "/api/v1/me/points/charge";
+
+ @DisplayName("존재하는 유저가 1000원을 충전할 경우, 충전된 보유 총량을 응답으로 반환한다.")
+ @ParameterizedTest
+ @EnumSource(Gender.class)
+ void returnsChargedBalance_whenUserExists(Gender gender) {
+ // arrange
+ String userId = UserTestFixture.ValidUser.USER_ID;
+ String email = UserTestFixture.ValidUser.EMAIL;
+ String birthDate = UserTestFixture.ValidUser.BIRTH_DATE;
+ signUpFacade.signUp(userId, email, birthDate, gender);
+
+ Long chargeAmount = 1000L;
+ PointsV1Dto.ChargeRequest requestBody = new PointsV1Dto.ChargeRequest(chargeAmount);
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_JSON);
+ headers.add("X-USER-ID", userId);
+ HttpEntity httpEntity = new HttpEntity<>(requestBody, headers);
+
+ // act
+ ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {};
+ ResponseEntity> response =
+ testRestTemplate.exchange(ENDPOINT_CHARGE, HttpMethod.POST, httpEntity, responseType);
+
+ // assert
+ assertAll(
+ () -> assertTrue(response.getStatusCode().is2xxSuccessful()),
+ () -> assertThat(response.getBody()).isNotNull(),
+ () -> assertThat(response.getBody().data()).isNotNull(),
+ () -> assertThat(response.getBody().data().userId()).isEqualTo(userId),
+ () -> assertThat(response.getBody().data().balance()).isEqualTo(chargeAmount),
+ () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS)
+ );
+ }
+
+ @DisplayName("존재하지 않는 유저로 요청할 경우, `404 Not Found` 응답을 반환한다.")
+ @Test
+ void returns404_whenUserDoesNotExist() {
+ // arrange
+ String userId = "unknown";
+ Long chargeAmount = 1000L;
+ PointsV1Dto.ChargeRequest requestBody = new PointsV1Dto.ChargeRequest(chargeAmount);
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_JSON);
+ headers.add("X-USER-ID", userId);
+ HttpEntity httpEntity = new HttpEntity<>(requestBody, headers);
+
+ // act
+ ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {};
+ ResponseEntity> response =
+ testRestTemplate.exchange(ENDPOINT_CHARGE, HttpMethod.POST, httpEntity, responseType);
+
+ // assert
+ assertAll(
+ () -> assertThat(response.getStatusCode().value()).isEqualTo(404),
+ () -> assertThat(response.getBody()).isNotNull(),
+ () -> assertThat(response.getBody().meta()).isNotNull(),
+ () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL)
+ );
+ }
+ }
+}
\ No newline at end of file
diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/SignUpV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/SignUpV1ApiE2ETest.java
new file mode 100644
index 000000000..900e94328
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/SignUpV1ApiE2ETest.java
@@ -0,0 +1,111 @@
+package com.loopers.interfaces.api;
+
+import com.loopers.infrastructure.user.UserJpaRepository;
+import com.loopers.domain.user.Gender;
+import com.loopers.interfaces.api.signup.SignUpV1Dto;
+import com.loopers.domain.user.UserTestFixture;
+import com.loopers.utils.DatabaseCleanUp;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.core.ParameterizedTypeReference;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+public class SignUpV1ApiE2ETest {
+
+ private static final String ENDPOINT_SIGNUP = "/api/v1/signup";
+
+ private final TestRestTemplate testRestTemplate;
+ private final UserJpaRepository userJpaRepository;
+ private final DatabaseCleanUp databaseCleanUp;
+
+ @Autowired
+ public SignUpV1ApiE2ETest(
+ TestRestTemplate testRestTemplate,
+ UserJpaRepository userJpaRepository,
+ DatabaseCleanUp databaseCleanUp
+ ) {
+ this.testRestTemplate = testRestTemplate;
+ this.userJpaRepository = userJpaRepository;
+ this.databaseCleanUp = databaseCleanUp;
+ }
+
+ @AfterEach
+ void tearDown() {
+ databaseCleanUp.truncateAllTables();
+ }
+
+ @DisplayName("POST /api/v1/signup")
+ @Nested
+ class SignUp {
+ @DisplayName("회원 가입이 성공할 경우, 생성된 유저 정보를 응답으로 반환한다.")
+ @ParameterizedTest
+ @EnumSource(Gender.class)
+ void returnsUserInfo_whenSignUpSucceeds(Gender gender) {
+ // arrange
+ SignUpV1Dto.SignUpRequest requestBody = new SignUpV1Dto.SignUpRequest(
+ UserTestFixture.ValidUser.USER_ID,
+ UserTestFixture.ValidUser.EMAIL,
+ UserTestFixture.ValidUser.BIRTH_DATE,
+ gender.name()
+ );
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_JSON);
+ HttpEntity httpEntity = new HttpEntity<>(requestBody, headers);
+
+ // act
+ ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {};
+ ResponseEntity> response =
+ testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, httpEntity, responseType);
+
+ // assert
+ assertAll(
+ () -> assertTrue(response.getStatusCode().is2xxSuccessful()),
+ () -> assertThat(userJpaRepository.count()).isEqualTo(1L)
+ );
+ }
+
+ @DisplayName("회원 가입 시에 성별이 없을 경우, `400 Bad Request` 응답을 반환한다.")
+ @Test
+ void returns400_whenSignUpWithNoGender() {
+ // arrange
+ SignUpV1Dto.SignUpRequest requestBody = new SignUpV1Dto.SignUpRequest(
+ UserTestFixture.ValidUser.USER_ID,
+ UserTestFixture.ValidUser.EMAIL,
+ UserTestFixture.ValidUser.BIRTH_DATE,
+ null // gender missing
+ );
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_JSON);
+ HttpEntity httpEntity = new HttpEntity<>(requestBody, headers);
+
+ // act
+ ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {};
+ ResponseEntity> response =
+ testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, httpEntity, responseType);
+
+ // assert
+ assertAll(
+ () -> assertThat(response.getStatusCode().value()).isEqualTo(400),
+ () -> assertThat(userJpaRepository.count()).isEqualTo(0L)
+ );
+ }
+ }
+}
diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserInfoV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserInfoV1ApiE2ETest.java
new file mode 100644
index 000000000..a78f616cd
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserInfoV1ApiE2ETest.java
@@ -0,0 +1,135 @@
+package com.loopers.interfaces.api;
+
+import com.loopers.application.signup.SignUpFacade;
+import com.loopers.domain.user.Gender;
+import com.loopers.domain.user.UserTestFixture;
+import com.loopers.interfaces.api.userinfo.UserInfoV1Dto;
+import com.loopers.utils.DatabaseCleanUp;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.core.ParameterizedTypeReference;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+public class UserInfoV1ApiE2ETest {
+
+ private static final String ENDPOINT_ME = "/api/v1/me";
+
+ private final TestRestTemplate testRestTemplate;
+ private final SignUpFacade signUpFacade;
+ private final DatabaseCleanUp databaseCleanUp;
+
+ @Autowired
+ public UserInfoV1ApiE2ETest(
+ TestRestTemplate testRestTemplate,
+ SignUpFacade signUpFacade,
+ DatabaseCleanUp databaseCleanUp
+ ) {
+ this.testRestTemplate = testRestTemplate;
+ this.signUpFacade = signUpFacade;
+ this.databaseCleanUp = databaseCleanUp;
+ }
+
+ @AfterEach
+ void tearDown() {
+ databaseCleanUp.truncateAllTables();
+ }
+
+ @DisplayName("GET /api/v1/me")
+ @Nested
+ class GetUserInfo {
+ @DisplayName("내 정보 조회에 성공할 경우, 해당하는 유저 정보를 응답으로 반환한다.")
+ @ParameterizedTest
+ @EnumSource(Gender.class)
+ void returnsUserInfo_whenUserExists(Gender gender) {
+ // arrange
+ String userId = UserTestFixture.ValidUser.USER_ID;
+ String email = UserTestFixture.ValidUser.EMAIL;
+ String birthDate = UserTestFixture.ValidUser.BIRTH_DATE;
+ signUpFacade.signUp(userId, email, birthDate, gender);
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_JSON);
+ HttpEntity httpEntity = new HttpEntity<>(headers);
+
+ // act
+ ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {};
+ headers.add("X-USER-ID", userId);
+ ResponseEntity> response =
+ testRestTemplate.exchange(ENDPOINT_ME, HttpMethod.GET, httpEntity, responseType);
+
+ // assert
+ assertAll(
+ () -> assertTrue(response.getStatusCode().is2xxSuccessful()),
+ () -> assertThat(response.getBody()).isNotNull(),
+ () -> assertThat(response.getBody().data()).isNotNull(),
+ () -> assertThat(response.getBody().data().userId()).isEqualTo(userId),
+ () -> assertThat(response.getBody().data().email()).isEqualTo(email),
+ () -> assertThat(response.getBody().data().birthDate()).isEqualTo(birthDate),
+ () -> assertThat(response.getBody().data().gender()).isEqualTo(gender.name())
+ );
+ }
+
+ @DisplayName("존재하지 않는 ID 로 조회할 경우, `404 Not Found` 응답을 반환한다.")
+ @Test
+ void returns404_whenUserDoesNotExist() {
+ // arrange
+ String userId = "unknown";
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_JSON);
+ HttpEntity httpEntity = new HttpEntity<>(headers);
+
+ // act
+ ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {};
+ headers.add("X-USER-ID", userId);
+ ResponseEntity> response =
+ testRestTemplate.exchange(ENDPOINT_ME, HttpMethod.GET, httpEntity, responseType);
+
+ // assert
+ assertAll(
+ () -> assertThat(response.getStatusCode().value()).isEqualTo(404),
+ () -> assertThat(response.getBody()).isNotNull(),
+ () -> assertThat(response.getBody().meta()).isNotNull(),
+ () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL)
+ );
+ }
+
+ @DisplayName("`X-USER-ID` 헤더가 없을 경우, `400 Bad Request` 응답을 반환한다.")
+ @Test
+ void returns400_whenHeaderMissing() {
+ // arrange
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_JSON);
+ HttpEntity httpEntity = new HttpEntity<>(headers);
+
+ // act
+ ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {};
+ ResponseEntity> response =
+ testRestTemplate.exchange(ENDPOINT_ME, HttpMethod.GET, httpEntity, responseType);
+
+ // assert
+ assertAll(
+ () -> assertThat(response.getStatusCode().value()).isEqualTo(400),
+ () -> assertThat(response.getBody()).isNotNull(),
+ () -> assertThat(response.getBody().meta()).isNotNull(),
+ () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL)
+ );
+ }
+ }
+}
\ No newline at end of file
From ef70fe539f2f2a5a55d009346d97af0d4e0b5412 Mon Sep 17 00:00:00 2001
From: minor7295 <44902090+minor7295@users.noreply.github.com>
Date: Fri, 7 Nov 2025 02:51:33 +0900
Subject: [PATCH 02/12] Base pr round1 (#50)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* Feature/user (#1)
* test: User 단위테스트 추가
* feat: User 도메인 구현
* test: 회원 가입 통합테스트 추가
* feat: 회원가입 서비스 로직 구현
* test: 회원가입 E2E 테스트 추가
* feat: 회원가입 API 구현
* test: gender필드를 저장할 수 있도록 테스트 코드 수정
* refactor: User도메인에 성별 필드 추가
* test: 회원 정보 조회 통합 테스트 작성
* feat: 회원 정보 조회 서비스 로직 구현
* test: 회원 정보 조회 E2E 테스트 작성
* feat: 회원 정보 조회 API 추가
* Feature/point (#2)
* test: 회원가입 관련 테스트 코드가 SignUpFacade를 참조하도록 수정
* refactor: 회원가입을 처리하는 SignUpFacade 구현
* test: 포인트 조회 통합테스트 추가
* feat: 포인트 조회 서비스 로직 구현
* test: 포인트 조회 E2E 테스트 코드 추가
* feat: 포인트 조회 API 로직 추가
* test: 포인트 충전 단위 테스트 추가
* feat: 포인트 충전 도메인 로직 추가
* test: 포인트 충전 테스트 코드 추가
* feat: 포인트 충전 서비스 로직 추가
* test: 포인트 충전 E2E 테스트 코드 추가
* feat: 포인트 충전 API 추가
* docs: 회원가입, 내 정보 조회, 포인트 조회, 포인트 충전 기능 관련 docstring 추가 (#3)
From 97a403c9798f46dae4c9dd11e2bf2c01f1433238 Mon Sep 17 00:00:00 2001
From: minor7295 <44902090+minor7295@users.noreply.github.com>
Date: Mon, 10 Nov 2025 22:20:59 +0900
Subject: [PATCH 03/12] Feature/software design (#6) (#51)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* [volume-1] 회원가입, 내 정보 조회, 포인트 조회, 포인트 충전 기능 구현 (#4)
* Feature/user (#1)
* test: User 단위테스트 추가
* feat: User 도메인 구현
* test: 회원 가입 통합테스트 추가
* feat: 회원가입 서비스 로직 구현
* test: 회원가입 E2E 테스트 추가
* feat: 회원가입 API 구현
* test: gender필드를 저장할 수 있도록 테스트 코드 수정
* refactor: User도메인에 성별 필드 추가
* test: 회원 정보 조회 통합 테스트 작성
* feat: 회원 정보 조회 서비스 로직 구현
* test: 회원 정보 조회 E2E 테스트 작성
* feat: 회원 정보 조회 API 추가
* Feature/point (#2)
* test: 회원가입 관련 테스트 코드가 SignUpFacade를 참조하도록 수정
* refactor: 회원가입을 처리하는 SignUpFacade 구현
* test: 포인트 조회 통합테스트 추가
* feat: 포인트 조회 서비스 로직 구현
* test: 포인트 조회 E2E 테스트 코드 추가
* feat: 포인트 조회 API 로직 추가
* test: 포인트 충전 단위 테스트 추가
* feat: 포인트 충전 도메인 로직 추가
* test: 포인트 충전 테스트 코드 추가
* feat: 포인트 충전 서비스 로직 추가
* test: 포인트 충전 E2E 테스트 코드 추가
* feat: 포인트 충전 API 추가
* docs: 회원가입, 내 정보 조회, 포인트 조회, 포인트 충전 기능 관련 docstring 추가 (#3)
* docs: 설계 문서 추가
---
.docs/design/01-requirements.md | 142 +++++++++++++++++++++++
.docs/design/02-sequence-diagrams.md | 163 ++++++++++++++++++++++++++
.docs/design/03-class-diagram.md | 164 +++++++++++++++++++++++++++
.docs/design/04-erd.md | 90 +++++++++++++++
.docs/design/use case diagram.png | Bin 0 -> 46609 bytes
5 files changed, 559 insertions(+)
create mode 100644 .docs/design/01-requirements.md
create mode 100644 .docs/design/02-sequence-diagrams.md
create mode 100644 .docs/design/03-class-diagram.md
create mode 100644 .docs/design/04-erd.md
create mode 100644 .docs/design/use case diagram.png
diff --git a/.docs/design/01-requirements.md b/.docs/design/01-requirements.md
new file mode 100644
index 000000000..ddca484ea
--- /dev/null
+++ b/.docs/design/01-requirements.md
@@ -0,0 +1,142 @@
+# 01-requirements.md
+> 루프팩 감성 이커머스 – 요구사항 및 유스케이스 명세서
+
+---
+
+## 📘 1. 개요
+루프팩 이커머스는 여러 브랜드의 상품을 한 번에 주문하고, 좋아요와 포인트 결제를 통해 감성적 쇼핑 경험을 제공하는 플랫폼이다. 본 문서는 사용자 중심 시나리오 기반으로 **기능 요구사항**과 **유스케이스**를 정의하며, 이후 UML(시퀀스, 클래스, ERD)으로 확장된다.
+
+---
+
+## 👥 2. 주요 액터
+| 액터 | 설명 |
+|------|------|
+| **User (사용자)** | 상품 탐색, 좋아요, 주문 등 주요 행위를 수행하는 주체 |
+| **System (이커머스 시스템)** | 포인트 및 재고를 관리하고 주문 정보를 처리 |
+| **External Service (외부 연동 시스템)** | 주문 정보를 전달받는 외부 서비스(Mock 처리 가능) |
+
+---
+
+## 🧾 3. 유스케이스 목록
+| UC ID | 유스케이스명 | 주요 액터 | 설명 | 관계 |
+|------|---------------|----------|------|------|
+| UC-01 | 상품 목록 조회 | User | 브랜드/정렬 기준별 상품 목록을 조회한다 | - |
+| UC-02 | 상품 상세 조회 | User | 특정 상품의 상세 정보를 확인한다 | includes(UC-01) |
+| UC-03 | 상품 좋아요 등록/취소 | User | 상품에 좋아요를 누르거나 취소한다 | - |
+| UC-04 | 주문 생성 | User | 여러 상품을 선택해 포인트로 결제하고 주문을 생성한다 | include(UC-02) |
+| UC-04-1 | 포인트 결제 처리 | System | 주문 생성 중 포인트 차감을 수행한다 | include(UC-04) |
+| UC-04-2 | 재고 차감 처리 | System | 주문 생성 중 상품 재고를 차감한다 | include(UC-04) |
+| UC-04-3 | 외부 주문 전송 | System/External | 주문 생성 후 외부 시스템에 전송한다 | include(UC-04) |
+| UC-05 | 주문 내역 조회 | User | 사용자의 주문 이력을 확인한다 | - |
+| UC-06 | 단일 주문 상세 조회 | User | 특정 주문의 상세 정보를 확인한다 | include(UC-05) |
+
+---
+
+## 🎛️ 3-1. 유스케이스 다이어그램
+
+
+---
+
+## 🧩 4. 유스케이스 명세
+
+### 🛍 UC-01 상품 목록 조회
+| 구분 | 내용 |
+|------|------|
+| **액터** | User |
+| **기본 시나리오** | 1) 사용자가 상품 목록 페이지에 접근한다.
2) 정렬(latest/price_asc/likes_desc)과 필터(brandId)를 선택한다.
3) 시스템은 조건에 맞는 상품 목록을 반환한다.
4) 각 상품의 좋아요 수를 함께 표시한다. |
+| **대안 시나리오** | 4a. 사용자가 로그인 된 경우 본인이 좋아요 했는지를 함께 표시한다.
+| **예외 시나리오** | • 등록된 상품이 없는 경우 빈 배열 반환한다.
• 정렬 기준이 유효하지 않으면 기본(latest)으로 조회한다 |
+| **후조건** | 상품 목록이 사용자 화면에 표시된다. |
+
+---
+
+### 📄 UC-02 상품 상세 조회
+| 구분 | 내용 |
+|------|------|
+| **액터** | User |
+| **사전조건** | • 요청 시 상품 ID가 파라미터로 전달된다.
• (선택) 사용자가 로그인된 상태이다.|
+| **기본 시나리오** | 1) 사용자는 상품 목록이나 링크를 통해 상세 페이지에 접근할 수 있다.
2) 시스템은 상품명, 가격, 재고, 좋아요 수, 브랜드 정보를 반환한다. |
+| **예외 시나리오** | • 상품 ID가 존재하지 않거나 삭제된 상품 혹은 인 경우 404 반환한다.
• 상품이 비공개 상태일 경우 403 반환한다.
• 상품 ID 형식이 잘못된 경우 400 반환한다. |
+| **후조건** | 상품 상세 정보가 표시된다. |
+
+---
+
+### ❤️ UC-03 상품 좋아요 등록/취소
+| 구분 | 내용 |
+|------|------|
+| **액터** | User |
+| **사전조건** | • 사용자는 로그인 상태이다. |
+| **기본 시나리오** | 1) 사용자가 상세/목록 페이지에서 좋아요 버튼을 클릭한다.
2) 시스템은 해당 사용자의 좋아요 등록/취소를 처리한다.
4) 현재 상태(liked=true/false)와 총 좋아요 수를 반환한다. |
+| **예외 시나리오** | • 중복 요청 시 동일 결과 반환(멱등 보장)
• 데이터 충돌 시 요청 재시도 처리 |
+| **후조건** | 좋아요 상태가 변경되고, 상품 목록/상세 정보에 반영된다. |
+| **비고** | (user_id, product_id) UNIQUE 제약으로 중복 방지 |
+
+---
+
+### 🛒 UC-04 주문 생성
+| 구분 | 내용 |
+|------|------|
+| **액터** | User |
+| **사전조건** | 상품이 존재하고 재고 및 포인트가 충분해야 함 |
+| **기본 시나리오** | 1) 사용자가 여러 상품을 선택해 주문을 요청한다.
2) OrderService는 ProductService에 재고 확인을 요청한다.
3) OrderService는 PointService에 결제 금액만큼 포인트 차감을 요청한다.
4) 모든 검증이 통과되면 주문 정보를 생성한다.
5) 주문 정보를 외부 시스템으로 전송한다.
6) 주문 생성 결과(주문번호, 결제금액, 잔여 포인트)를 반환한다. |
+| **예외 시나리오** | • 포인트 부족 → “결제 실패” 응답 및 충전 안내
• 재고 부족 → “주문 불가 상품” 메시지 반환
• 외부 전송 실패 → 주문은 저장되지만 상태를 “보류(PENDING)”로 표시 |
+| **후조건** | 포인트와 재고가 차감되고, 주문 내역이 생성된다. |
+| **비고** | UC-04-1~3을 포함하며, 트랜잭션으로 처리해야 함. |
+
+#### UC-04-1 포인트 결제 처리
+| 항목 | 내용 |
+|------|------|
+| **액터** | System |
+| **기능 요약** | 사용자의 포인트 잔액을 검증 후 주문 금액만큼 차감한다. |
+| **예외 시나리오** | 잔액 부족 시 “Insufficient Points” 오류 반환 |
+| **후조건** | 사용자의 포인트 잔액이 감소한다. |
+
+#### UC-04-2 재고 차감 처리
+| 항목 | 내용 |
+|------|------|
+| **액터** | System |
+| **기능 요약** | 주문한 각 상품의 재고를 차감한다. |
+| **예외 시나리오** | 재고 부족 시 해당 상품 주문 불가 처리 |
+| **후조건** | 재고 수량이 감소한다. |
+
+#### UC-04-3 외부 주문 전송
+| 항목 | 내용 |
+|------|------|
+| **액터** | System / External Service |
+| **기능 요약** | 생성된 주문을 외부 시스템으로 전송한다. |
+| **예외 시나리오** | 외부 API 응답 지연 시 재시도 또는 비동기 큐에 저장 |
+| **후조건** | 주문 상태가 “전송 완료”로 변경된다. |
+
+---
+
+### 📦 UC-05 주문 내역 조회
+| 구분 | 내용 |
+|------|------|
+| **액터** | User |
+| **사전조건** | 주문 내역이 존재 |
+| **기본 시나리오** | 1) 사용자가 주문 내역 페이지를 연다.
2) 시스템은 유저의 모든 주문 목록을 반환한다. |
+| **예외 시나리오** | • 주문 내역이 없을 경우 빈 배열 반환 |
+| **후조건** | 주문 목록이 화면에 표시된다. |
+
+---
+
+#### UC-06 단일 주문 상세 조회
+| 구분 | 내용 |
+|------|------|
+| **액터** | User |
+| **사전조건** | 주문이 존재하고 해당 사용자의 주문이어야 함 |
+| **기본 시나리오** | 1) 사용자는 목록이나 링크를 통해 상세 페이지에 접근할 수 있다.
2) 시스템은 해당 주문의 상세 정보(주문번호, 상품 목록, 총액, 상태 등)를 반환한다. |
+| **예외 시나리오** | • 주문이 존재하지 않을 경우 404 반환
• 다른 사용자의 주문을 조회하려는 경우 403 반환
• 주문 ID 형식이 잘못된 경우 400 반환 |
+| **후조건** | 주문 상세 정보가 화면에 표시된다. |
+
+---
+
+## ⚙️ 5. 비기능 요구사항
+| 항목 | 내용 |
+|------|------|
+| **식별 방식** | 모든 API는 `X-USER-ID` 헤더로 사용자 식별 |
+| **동시성 제어** | 포인트 차감 및 재고 차감은 트랜잭션 단위로 동작 |
+| **멱등성 보장** | 좋아요, 주문 요청은 멱등하게 처리되어야 함 |
+| **성능 요구사항** | 상품 목록 조회는 페이지네이션(page/size) 적용 |
+| **확장성 고려** | 좋아요 데이터를 추천/랭킹 기능으로 확장 가능 |
+| **일관성 보장** | 외부 시스템 연동 실패 시 재시도 로직 또는 보류 상태 유지 |
diff --git a/.docs/design/02-sequence-diagrams.md b/.docs/design/02-sequence-diagrams.md
new file mode 100644
index 000000000..ebc9b746d
--- /dev/null
+++ b/.docs/design/02-sequence-diagrams.md
@@ -0,0 +1,163 @@
+# 02-sequence-diagrams.md
+> 루프팩 감성 이커머스 – 시퀀스 다이어그램 명세서
+> (도메인별 행위와 책임 중심 설계)
+
+---
+
+## 🎯 개요
+이 문서는 **UC-03 (좋아요)** 와 **UC-04 (주문)** 의 핵심 시나리오를 시퀀스 다이어그램으로 시각화한다.
+핵심 비즈니스 로직 흐름만 표현하여 가독성을 높였다.
+
+---
+
+## ❤️ UC-03 상품 좋아요 등록/취소
+
+> **멱등성 보장**: 이미 좋아요한 상태에서 다시 좋아요 요청 시, 추가 작업 없이 현재 상태(`liked: true`)를 반환하여 멱등성을 보장합니다.
+
+### 1️⃣ 좋아요 등록
+
+```mermaid
+sequenceDiagram
+ autonumber
+ actor User
+ participant Controller
+ participant Facade
+ participant ProductDisplay
+
+ User->>Controller: POST /api/v1/like/products/{productId}
+ Controller->>Facade: likeProduct(userId, productId)
+ Facade->>ProductDisplay: findById(productId)
+ ProductDisplay-->>Facade: ProductDisplay
+
+ alt 이미 좋아요 함
+ ProductDisplay-->>Facade: likeCount
+ Facade-->>Controller: {liked: true, likeCount}
+ else 좋아요 없음
+ Facade->>Facade: create Like(userId, productId)
+ Facade->>ProductDisplay: like(Like)
+ ProductDisplay->>ProductDisplay: likes.add(Like)
+ ProductDisplay-->>Facade: likeCount
+ Facade-->>Controller: {liked: true, likeCount}
+ end
+ Controller-->>User: 응답
+```
+
+### 2️⃣ 좋아요 취소
+
+> **멱등성 보장**: 좋아요하지 않은 상태에서 취소 요청 시, 추가 작업 없이 현재 상태(`liked: false`)를 반환하여 멱등성을 보장합니다.
+
+```mermaid
+sequenceDiagram
+ autonumber
+ actor User
+ participant Controller
+ participant Facade
+ participant ProductDisplay
+
+ User->>Controller: DELETE /api/v1/like/products/{productId}
+ Controller->>Facade: unlikeProduct(userId, productId)
+ Facade->>ProductDisplay: findById(productId)
+ ProductDisplay-->>Facade: ProductDisplay
+
+ alt 좋아요 없음
+ ProductDisplay-->>Facade: likeCount
+ Facade-->>Controller: {liked: false, likeCount}
+ else 이미 좋아요 함
+ Facade->>Facade: find Like(userId, productId)
+ Facade->>ProductDisplay: unlike(Like)
+ ProductDisplay->>ProductDisplay: likes.remove(Like)
+ ProductDisplay-->>Facade: likeCount
+ Facade-->>Controller: {liked: false, likeCount}
+ end
+ Controller-->>User: 응답
+```
+
+---
+
+## 🛒 UC-04 주문 생성
+
+### 1️⃣ 주문 생성 기본 흐름
+
+```mermaid
+sequenceDiagram
+ autonumber
+ actor User
+ participant Controller
+ participant Facade
+ participant ProductOrder
+ participant Point
+ participant Order
+
+ User->>Controller: POST /api/v1/orders
+ Controller->>Facade: createOrder(userId, items, totalAmount)
+
+ Note over Facade: 트랜잭션 시작
+
+ Facade->>ProductOrder: checkStock(quantity)
+ ProductOrder-->>Facade: stock available
+
+ Facade->>Point: checkBalance(totalAmount)
+ Point-->>Facade: balance sufficient
+
+ Facade->>ProductOrder: decreaseStock(quantity)
+ ProductOrder-->>Facade: success
+
+ Facade->>Point: deduct(totalAmount)
+ Point-->>Facade: success
+
+ Facade->>Order: create(List~ProductOrder~, Point)
+ Order-->>Facade: Order (status: PENDING)
+
+ Note over Facade: 트랜잭션 커밋
+ Facade-->>Controller: Order
+ Controller-->>User: 주문 접수 완료
+```
+
+### 2️⃣ 외부 전송 성공
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant Facade
+ participant ExternalService
+ participant Order
+
+ Note over Facade: 주문 생성 완료
+
+ Facade->>ExternalService: sendOrder(order)
+ ExternalService-->>Facade: success
+
+ Facade->>Order: complete()
+ Order->>Order: status = COMPLETED
+
+ Facade-->>User: 주문 완료
+```
+
+### 3️⃣ 외부 전송 실패 (PENDING 상태 유지)
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant Facade
+ participant ExternalService
+ participant Order
+
+ Note over Facade: 주문 생성 완료
+
+ Facade->>ExternalService: sendOrder(order)
+ ExternalService-->>Facade: error
+
+ Note over Facade: 주문은 저장되지만 PENDING 상태 유지
+
+ Facade->>Order: 상태 유지
+ Order->>Order: status = PENDING
+
+ Note over Facade: 재시도 로직 또는 비동기 큐에 저장
+
+ Facade-->>User: 주문 접수 완료 (외부 전송 보류)
+```
+
+### 💬 예외 시나리오
+- **포인트 부족**: Point.deduct()에서 예외 발생 → 트랜잭션 롤백
+- **재고 부족**: ProductOrder.decreaseStock()에서 예외 발생 → 트랜잭션 롤백
+- **외부 전송 실패**: 주문은 저장되지만 상태를 PENDING으로 유지하여 재시도 가능
diff --git a/.docs/design/03-class-diagram.md b/.docs/design/03-class-diagram.md
new file mode 100644
index 000000000..2eaeac428
--- /dev/null
+++ b/.docs/design/03-class-diagram.md
@@ -0,0 +1,164 @@
+# 03-class-diagram.md
+> 루프팩 감성 이커머스 – 클래스 다이어그램 명세서
+
+---
+
+## 🎯 개요
+본 문서는 도메인별 행위에 맞춰 설계된 클래스 다이어그램을 정의한다.
+
+### 설계 원칙
+- **도메인 중심 설계 (DDD)**: 각 도메인이 자신의 책임과 행위를 명확히 가진다
+- **도메인 분리**: 동일한 테이블이라도 행위와 책임이 다르면 별도 도메인으로 분리한다
+ - 예: `product` 테이블이 하나라도 `ProductDisplay`(표시/조회)와 `ProductOrder`(주문)로 분리
+- **행위 중심**: 데이터 구조가 아닌 도메인의 행위와 책임을 우선한다
+
+---
+
+## 🧩 ProductDisplay 도메인 클래스 구조
+
+```mermaid
+classDiagram
+
+ %% 엔티티 (BaseEntity 상속)
+ class Brand {
+ +Long id
+ +String name
+ }
+
+ class ProductDisplay {
+ +Long id
+ +String name
+ +int price
+ +Brand brand
+ +Set~Like~ likes
+ +like(Like)
+ +unlike(Like)
+ +getLikeCount() int
+ }
+
+ class Like {
+ +Long id
+ +Long userId
+ +Long productId
+ }
+
+ %% 관계 설정
+ Brand "1" --> "*" ProductDisplay : has
+ ProductDisplay "1" --> "*" Like : has
+```
+
+---
+
+## 📦 클래스 간 역할 설명
+
+| 클래스 | 책임 | 도메인 분리 이유 |
+|---------|------|-----------------|
+| **Brand** | 브랜드 메타 정보 보유 | 브랜드 정보는 표시와 주문 모두에서 공통으로 사용되지만, 독립적인 도메인으로 관리 |
+| **ProductDisplay** | 상품 표시/조회 관련 행위 관리 | 상품의 **표시와 조회**에 집중 (좋아요, 정렬, 목록 조회) |
+| **Like** | 상품을 좋아요 표시한 사용자 정보 보유 | 행위의 주체는 상품이지만, 정보를 가지는 주체로서 도메인 분리 |
+---
+
+## 🧩 Order 도메인 클래스 구조
+
+```mermaid
+classDiagram
+
+ class Order {
+ +Long id
+ +Long userId
+ +OrderStatus status
+ +int totalAmount
+ +JSON items
+ +getTotalAmount()
+ +create(List~ProductOrder~, Point)
+ +complete()
+ +cancel(Point)
+ }
+
+ class ProductOrder {
+ +Long id
+ +String name
+ +int price
+ +int stock
+ +decreaseStock(quantity)
+ +increaseStock(quantity)
+ }
+
+ class Point {
+ +Long id
+ +User user
+ +Long balance
+ +deduct(amount)
+ +refund(amount)
+ }
+
+ class OrderStatus {
+ <>
+ PENDING
+ COMPLETED
+ CANCELED
+ }
+
+ Order "1" --> "many" ProductOrder : contains
+ Order --> Point : usesForPayment
+```
+
+---
+
+## 📦 Order 도메인 클래스 간 역할 설명
+
+| 클래스 | 책임 | 도메인 분리 이유 |
+|---------|------|-----------------|
+| **Order** | 주문의 상태, 총액, 주문 아이템 관리 | 주문 생성, 완료, 취소 등 주문 생명주기 관리. `items`는 `{productId, name, price, quantity}`로 관리 |
+| **ProductOrder** | 주문 시 상품 정보 및 재고 관리 | 주문 시점의 상품 정보 보관 및 주문 처리 중 재고 차감/복구 관리 (ProductDisplay와 분리) |
+| **Point** | 포인트 잔액 관리 및 결제 처리 | 주문 시 포인트 차감, 취소 시 환불 처리 |
+| **User** | 주문자 정보 | 주문과 사용자의 관계 표현 |
+| **OrderStatus** | 주문 상태 관리 | 주문의 생명주기 상태 표현 |
+
+---
+
+## 🧭 상태 및 상수 클래스
+
+```mermaid
+classDiagram
+ class SortType {
+ <>
+ LATEST
+ PRICE_ASC
+ LIKES_DESC
+ }
+
+ class OrderStatus {
+ <>
+ PENDING
+ COMPLETED
+ CANCELED
+ }
+```
+
+---
+
+## 🔁 설계 의도 요약
+
+| 설계 포인트 | 선택 근거 |
+|--------------|-------------|
+| **도메인 중심 (DDD)** | Entity가 스스로 상태를 관리하도록 설계 (ex. ProductDisplay.likeBy(), ProductOrder.increaseStock(), Order.complete()) |
+| **도메인 분리** | 동일 테이블이라도 행위와 책임이 다르면 별도 도메인으로 분리. ProductOrder는 주문 처리에 필요한 상품 정보와 재고 관리를 담당하며, ProductDisplay와 분리하여 주문 로직의 독립성 보장 |
+| **멱등성 보장** | ProductDisplay의 likedUserIds를 Set으로 관리하여 중복 방지, Order 상태 전이는 멱등하게 처리 |
+| **Enum 사용** | SortType, OrderStatus 등 도메인별 상수는 Enum으로 명확히 정의 |
+
+---
+
+## 💡 도메인 분리 상세 설명
+
+### ProductDisplay vs ProductOrder
+동일한 `product` 테이블을 사용하더라도, 행위와 책임에 따라 별도 도메인으로 분리:
+
+| 구분 | ProductDisplay | ProductOrder |
+|------|----------------|--------------|
+| **책임** | 상품 표시, 조회, 좋아요 | 주문 처리 시 상품 정보 및 재고 관리 |
+| **주요 행위** | `like()`, `unlike()` | `decreaseStock()`, `increaseStock()` (주문 처리 중 재고 관리) |
+| **관심사** | 사용자에게 상품을 보여주는 것 | 주문 생성/취소 시 상품 정보와 재고를 처리하는 것 |
+| **변경 빈도** | 상품 정보, 좋아요 수 | 주문 생성/취소 시 재고 변경 |
+| **데이터 특성** | 실시간 상품 정보 (조회용) | 주문 처리 중 상품 정보 및 재고 상태 (주문용) |
+| **생명주기** | 상품이 존재하는 동안 지속 | 주문 생성 시 생성, 주문 완료/취소 시 처리 |
diff --git a/.docs/design/04-erd.md b/.docs/design/04-erd.md
new file mode 100644
index 000000000..cf43fdfb8
--- /dev/null
+++ b/.docs/design/04-erd.md
@@ -0,0 +1,90 @@
+# 04-erd.md
+> 루프팩 감성 이커머스 – ERD(Entity Relationship Diagram)
+
+---
+
+## 🎯 개요
+본 문서는 클래스 다이어그램을 관계형 데이터베이스 구조로 변환한 ERD를 정의한다.
+
+---
+
+## 🧱 ERD
+
+```mermaid
+erDiagram
+ USERS {
+ bigint id PK
+ varchar user_id
+ varchar email
+ date birthDate
+ datetime created_at
+ datetime updated_at
+ datetime deleted_at
+ }
+
+ BRANDS {
+ bigint id PK
+ varchar name
+ varchar description
+ datetime created_at
+ datetime updated_at
+ datetime deleted_at
+ }
+
+ PRODUCTS {
+ bigint id PK
+ varchar name
+ int price
+ int stock
+ bigint ref_brand_id FK
+ datetime created_at
+ datetime updated_at
+ datetime deleted_at
+ }
+
+ LIKES {
+ bigint id PK
+ bigint ref_user_id FK
+ bigint ref_product_id FK
+ datetime created_at
+ datetime updated_at
+ datetime deleted_at
+ }
+
+ ORDERS {
+ bigint id PK
+ bigint ref_user_id FK
+ int total_amount
+ json items "주문 아이템 배열: [{productId, name, price, quantity}]"
+ varchar status "OrderStatus enum 값 (PENDING, COMPLETED, CANCELED)"
+ datetime created_at
+ datetime updated_at
+ datetime deleted_at
+ }
+
+ POINT {
+ bigint id PK
+ bigint ref_user_id FK
+ bigint balance
+ datetime created_at
+ datetime updated_at
+ datetime deleted_at
+ }
+
+ USERS ||--o{ LIKES : "좋아요"
+ USERS ||--o{ ORDERS : "주문"
+ USERS ||--o{ POINT : "포인트"
+
+ BRANDS ||--o{ PRODUCTS : "브랜드 상품"
+ PRODUCTS ||--o{ LIKES : "좋아요 대상"
+```
+
+---
+
+## ⚙️ 제약조건
+| 테이블 | 제약조건 | 설명 |
+|---------|-----------|------|
+| **LIKES** | (user_id, product_id) UNIQUE | 동일 사용자-상품 중복 방지 |
+| **ORDERS** | status IN ('PENDING', 'COMPLETED', 'CANCELED') | 주문 상태는 OrderStatus enum 값만 허용 |
+| **ORDERS** | items JSON 형식: [{productId, name, price, quantity}] | 주문 아이템은 JSON 배열로 저장 |
+---
diff --git a/.docs/design/use case diagram.png b/.docs/design/use case diagram.png
new file mode 100644
index 0000000000000000000000000000000000000000..bc2c3f4f758551196f646f96a357aede3e81f757
GIT binary patch
literal 46609
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