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, 이메일, 생년월일, 성별)를 관리하며, + * 각 필드에 대한 유효성 검증을 수행합니다. + *

+ * + *

검증 규칙

+ * + * + * @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 zcmd3ObyQYwx9v*^NOucj(9%dqcPi2#NH-E<(cPsIQi7yN2#6rvCEX=TNGKxRAl$Y6 zednC-+;RT9_l|KL!y&xFv-f^tt-0o$YlmyxRwTfs#YLe|1j85DJA>jDrQA zaFj=k!#`+lnu;>0_xE2@ekm_;vILjlVZ7xmmh0d49(u{RDF*%`=p@ z-{srEjOTll97hNB_Z}RkAYvIDrjWu5CQ|?Ux+AQG;Ga+1u4z>L^Hoq1Ca%!mp9~_7 zlbR1fekX|7oRoFupRZ)p(1YpzekK#9{KiH0pP_g!k~{rtP-g%CaZof9DR$ltY7+%} z77h+PoBp)<$cxAo8NPUK)OS*mWXa7dVB-osewC0A{5g&#+j+LZivsxprQ+AOo)U_P zh47&cUi83itO)vTveP8q5f#@&OEn{ zW%Fy5Pc`-k=PP||j?7r%-}%g`&UTT-Psw%Rc|&5kHaD;^Z)c2OrsP+;yPC2fEVNV< z#VNO=SqWcGa$}YbHhApkY2XVjSvflqbKK_;$48f@ZgX|&{vMQD$f&nh@ptx=MzUbc zFB-l|fn;-IIwY(u8s5^$*V9<4u9#+Itir&1kx>S_ua6i^Fg1=|mW%Q(>2FqLQx9`~AFHvRnF zghBD|60cGP)0uCsQL?UodU1sutyC{Lyo`PsYa&=V!7PX8;#R8cyx2$k&c6F$1t|t) z_nL?|%-%g5W&f#+;}Y90&@LhSmT6oZrQA)EtkGLCJ)kK}Ew#55EN)B@{df24SskH^HwWi0>6YBWdy1=g`0^Z(FQ%W-ZZz4vk&)vs=t~@ zU+3Q)N==6=)aGaRO-oA|C$o0p*Q)A>NIv>ahv1SUn zO|%>hZQTF4vNWs8P6a*>s(ikV$(aRIte(%vrO6Uryg=jT=3ZP}+|&tT9qJ|EMdQ16qo_eG`tL9T zcgUT>TvdqzrA%YhwhC5zM*rThQn?&#-A@G+Lq8r0{BySd?;ZUAC(-2p*FoE^%`|w^ z>6P4XpY=VDzW2##Yb@xw;u{G$9Nr7)4Sw3@QM?UNqqlW+DVgu2N6pL_HAE1{EAQ+Idw=RHfMA3kUj#ydWEuywjKz=5rGI;XPti}FSROZJg8{kgY+0nK}p#`pWv zRNVK2Dk>_b9XKi_ypP|&t=Mk_KF^c!4iiv>aV1!1en`#dtwW>?-rjzglbieQfpN{F zA?j1sn{IAyXT;G9>tAcTrKgW}R$S*>E-W2XS65SuK4PV(r;q>QHXz=~81NiR+B=LG zS4d9sUtylyDW&AsL`fk*OO7VneV-$%3hC(F-0O|0(zUMNLptUj4!)7L-+1S?(tqj{ zBu2R^8)OUNa98@DOrkiS=l;2`Pu|Fg{>l1GcvKYroe8ayc%S< z!PMd&0%BrfJeFO=K2$RHa^7Lb#P4W=>9UA$g+w)~-rd3#%G=og{Ut0iGEkqhV<=m3 zetsV9>C>me!oqJJ{~Gsli+g&wY3BH~hQig=^~X##znHi<{D|2jn@Q#GnG!iMFy2LE zJVV?RVoe9n{k7)NcUnX5jc++leYl{Nr`EjpYhtU1yU}#9>qXx~4J=ei z*52OS+l$Y@z_7Wy8>GRym`X}9@!nV~Jz3KC44zYL?$@u#2Je$?_3-d)iQQ-UL?jnd zs+o@8B-6<#MZ+Ll?NlaB)6=x=?d|%XoG#tYkRb5&^`(vfIbXe!-rAeY&y=82_H^TZ zQgX6clOI}=gtusax_FNe3v<}9##ar-D?y`J01?7;DJUqq(Wc2T}&*4L-AW=Gpg$dEs4_OW0QSU8Pq$WIP76wS@A-1q(Sm|nsw z$yp&PEQ`qdd1-{JA+lokHC$^)M2R-?D&pn&Ik4%sKw~`%GG1nB-!)z$4=p02p?$!Y)_p=AuBe4 zM@;2kcBQQa&&TV_E)UKc`e;u|*z4DqVlLeZseQU(-kZc*<$1t0+s1>xr&xzAu20fe zM?}$C-F&9a8Bq|Ua}BGmW|h&DE?{W)Kd*k%s+%1C%)#mj#ay?;YsWG95iyDGJIRLk zdyX4VKENh^A5F)MbU~=LY)` zu&t_5=<@f-WsAIw`zCDh`|61dJlX=4#WKUH6I*BIBGkH4UG}j$hC^|f2m%1p_Ql7> z5~--FZgn#H&b@n^zEDV@>1r=VI`ehTu}=qo58I+3P>=$EiYZS}!HHv{?E6EH9P+4k z3&T4J9vGSWW<8r5zA8;~)d@E1xy@)wDkb!CCu}y@In+fCb43avJh!xem*ci*T{P^b ziSP)~!%A+s)^_kpw@;!}AwxM!!tyZcEfI;#Djw{dl+4Xd7fTx(i=M0x2ZMJo=u95f4ax{|eYadMzJb6#c z`K8%pqRBuflRmloTrbfjUE^_)j0hhI>ZE{%mSX_(TG}g&@$@936XxV<2xPB0r&T?l z*~@ec!JO;`BXiOVQJsXC*bn01{AZSo#lmnjLkJrC8?&)vg?c+WA;076R<|3wRVNPV z`uLZ0%yKkhFtrI&unT)}x(4+6X7urRsWaIR!gxwA=#<&{uXJ*v+KlCmrG?%gLF2vMN*wbdI>f^7M%K4 zTr0O{ZX!J6W3{nuxx==MJ=&9S?yRG?NjiB|OYHYhx$K_b@pEfN_87Oo1Qz0Z#3lRZ zQySD(5bVS#0gMP-b(nZBA4z#*?)&#vJsQrj9$U`P>gLC8VlNL<6Y$zy$f~4`Y_WY4 z%1^c!5~sN{@7A|7v>tYXlZ(XPR>;nJ1yk8TfQ5%vL1TZ z&f$0Xv-WciWwC`qYjgIEKl#&&n%pE;BmV3p=B&%%W4{{{0x&?;Tq@SS^HSY7v9MV?tqg zH6CWAQ92p*B{iK{N@?#NhQFj(U?FGQXdbk@IB>m8=arN}GhUhg7>>;$oeYe)wVdjF zg_rk5$(6LzQ!il=5hhkvoTH;7uRROclT)F9?orp83LW-x9nt-pOc@(g07Z4Zwn+i@Ers9z*-=q=1O2Tsz~Lg#GqT1{UfXGxHTb zKF#XJ;NS}=v2iuRJ*JU2&Wu+=LaqNDB??5v$CEFvtXQm$zVp(F7Ja;m`A)ll9!2`y z%Gx@FL+||??~{G61WmYX2odA^Q=fzNug})2S5PQMpF^hG>0%dAIQaN&kiPs25}L+~ z3@}hg3ZT4Uh=~$*-@Ybsx+(jH{rTFr`r6g}1P%?h0Kjg#XFlG5Zn|9rJ@*}-d3d0s zDjlZ+7=2GkP^LCEpmp$f*d!R!Xc#y(lYbt-V2X zaaeoJ#6X1R_3PJ*0EKw*4w(Vq>OS9~q@=`eve;v^Uh@v)z0^6r=Vtr%^iN{&cxjCx zg4=2a?d{|k9}N&NVab)X#7SND`}9euuD-s^s)rImsxY9`n88X@la$Yd;_7fN9+yc& zFO?9w>N6E;eQ8dkz+C$Q_7_;SS1SGdm3N1jg|RxKVOp-up#W8J%BpPWtTZ#{|P_EBqBnojZa^RA%Z&ljhu zr{31Guh`3{Hh-~fs&YBsi1w9ljp&`d`M3zw5<8bBhnBZ#4q&81-Smq}kP3XnUsQ#> zZJxOA;URi_urUB^GfZn-&TRLuN5+mz_{eXmNKe-Sd`FxGhi(s+osA#!oh%FV%wL9_9UTmbnn)EmQhHV(Fn(f#VROB zK|(@;Z0h(zwdCYv@%7YL4hwCNhpC!bIWk>Dh6}v&t~(OquMV1&;|Za_ZNM+h5jKro58xRr2y#Tlp27 zSEDYJ)j(ZUOt_5De;mInEsgf%_jK^nS?^aRSLhWLJ3a6OaXY?*TH=uDOG^kbq%)kc zuF|~U@}y@U8GePk)FsEf7(B+$p_?9?a-gjU(R_Fk>PmAdr*C!P5W25ixq|*+`eXCj zRQcAhDmR*r>g4$2()}r$3?@#_DDLrlfPafBYciD{4C;uO4tXf(T9j&zk56C zR7^56GY@x1?hIwhVmCwxkY@jqea@t2O4L;l7zxRG(nzG}QE(J{`@1c_Tf`ZLMQH|X zq>`&Kf=DqWzy|n$I!x>BZL_HlR+AsB@H(RDLXqsUIoFJA8A!|mXsf7iGgTpV;foWDw{Zdr9 zb)U@r`wS*J9EN(bxM-&p1WCiK+S7LZb++Ob#WKGfeoi)aI2;^3koG1hUYj$S3wc)2 zGuOhznV-@81(MMwAVpIManH_}6!cXk9@0xBJj66`DD%SC;bauqj99uU9%@bj4FSJtZIqrK}c)BRbJ z42+D7NE$Wq+PYvOpW?^6pV{5>rG;r!-%@rbJp&vIP*n%t{D|TDq z$Dj+h4tr!?u$9DU0gp&qo&v$aXgUuAsqM6Y{!qF)Qfcc~Sjb`c(ROCN%6-Rrrp}!# zj#+)7kfE8hs0)_#k$y+A<2l)6O|9XY_^fz z*&NFWr_o{jK#892GfhQbiq~YC*%9m#8x1%nhfQ$ZHsZPU`r_v}Ln;DW_AiwWFi>hq zJZJz>yee)#!7Jd(m>=j*&nP)zUNV$?o9BK4Cx|KT^XAD$%u(9=w1M(Vt zmvZf6=@{^7;3-mA{%5XpjPv(o83tS?u)12r$jC@wz1U8L-=@DUUEFgM*psHupTkR- zqger)AQ%B4MG#VzKv-BxCB~5R?sCy!A|FQD2aDIVYasCEU9nL$ z>G^^0d8p8=7ny1z=X{~^)$qv3R*sUofKT5%@a|PQW=bcMWJ*|4BbyOfbwB%ZTPdy7 zn-6G_TKM;uPZpD0*y#0C|D z?gvU04TGqeWbX;gxq2P{e0vCi!}VnUf&RUF-(fCt&(Ovitn_`leTKikyKE*z+dg|=`qRyMY~goFKQ zBCSH#J=j@UH3++gM@A4}aP8W)x!hOx7g9;dq`dRb?jjWtE`oCWc{yn}tB&`7N9O0V zLaF+_@%$_>Fc1UaTug@V8I6$B%v>tjjW%=MZ|*CX5Fm*F)5IJ1N+18y#izR&<*cGc zyq8&%joc_m4l=7h1~X+9V9Jeme@ze}1t5%wXJTTqHeG=Sv)>6qQmAq5ldZng`q5Es zukFt)2w1&x^(wE^jBtEn;sS6lxo8Ib^4|GRkTqn$7GF`o_UKptntbf$gd`Va4Pk>l z1CR)5*8I`I_~;nK8)7P|(5fnA2q5J!*4Nkj)@P-eV4nfOi_nYR-Q9CQ3EsJL2ktD^ zSp`SRTM5piCYeV+!4O2PORO`auh~LVfweaW;3({KDh&IV$LI7}cKxTk2Blohuy*q` z*xXiDRxU0s8ct81JONhGWufgN5UFgRZarN$1)>-DytW@h*+s7_8zE%Yw_g};x~ zM8C#w%+x%_gSqN1p_qjnVElcrr>95o{_OZTrT6i!e{?j#Icbo<#Z}oHk$>ZZZE)v9 zC<$!6fY+oA%!PX0?vnmj80cVQwo~hlcrJZ@h3$~cR9;;j{SCv9gVeoj4bERGi#t$7 zu*|2ia$$EfCFIm7{*4&6AQJj$>*t!EpRYkz#M?27Wm0_a)ACcMpnQ1bs0;u8lY~Oy z;o*}F-nVYhBI+wO4zhHQMo>)bT*fs)6dbyy01l9S#|udp_vT;0Pyu0`bOpvO*7KqlEfxnvR8w|5&_y9t|k%7qyRfP?0B7- z*&OZ)&OQRQ=D&ZJEzJBYxl;4u3gt{?%3}ZvbX`)C5gqM@mlyhM->-#vwt8WtY@S3{;of#A2uN=eYw)%E7@J+WWE zeu0;lH%as{yNNswQyU8T{Xj~B%RvGH&d$y?1E6Z-Q%4_%DI=vKz)508#@J7@KB<3y z1P7{WMMXvAKEnJFEi&DF$Pc!Q+5Xzp=Kg*ja*64L*RnEQYP~b;D-JNNNyshO{(X$; z7qVgwyzDa=#G;yE3yZ9Bf4CGc%tpm!WDv2iu>2hq4IVCv^zU<2tYKagIchDp5qa6b zfL3yeBLC1^R3^?LM(EqOZ;#k$VW(V#q4Nik8S2~*;16=WPt9v9`Z8^Np@~#d&C`aM zeSL?Zu)VGAGAx9@zyDv+9OK&m%ziNRfV5ur;LA-Q2~1B9w_IFZGd&&D$Sv?e4C?;h z^rxkX(*rnUrcs$7_H_4wSoLcHV7EUM_8=FTIXFZzhq)7iCe)U)Pko5#OrLM-+HytPQn(Yez>2H#2j7Rh9lxwHs&~#TmTQ629F%JrzFv zn;9Yz8^9;OKV8dgv^ehR>hgQ_iVC*TA-hcczr>|g{u!VbkPr%mUua|`-q(8;=}BG70*OPk@YRX)vlDJEqbfg8*~lp=vmh`*91Y!m ztn&$w&bG5Zb+Gw0F98D0TauHnI<7p*;9q!pN49;rebSh-ZSuf)s54+TH0cAdPtgN( z?faYavP++1-W%82dv$Eiax{QmS1~?7edgId@vz$HCQ+yD>=6z4Cqg(zn z3mAI`rK-N-GJ_<81cPrrCl5e97_1jmV29cid`XjAbM|_%u zx8Z}qEzoD1o14+H6`}(|L$Qq&XD!*ESF{k^e`i5*63K{V(8Y9Sin&a2Vs^0ZPK_y& zWgR~BG+c76_t2XIMAc@PUrG`lQiNTDeEK{kB?6Lr3k=vALZIcdFSgUmf6Ap$2wVNP z8_U(J82kJC)HfZeL4M;gZ9@4_#Q~x5Bd{f!klbl?-L-|_2CbzyeLG-1Tijf_5td8H$F6w3W`YF|fT5I&*V#v#EBYp-1R?^cw+Bio9rLsf@>E zIF}4!zRqHqp4s!hKS&O3FO;1>3W%}9xP1M3aH7vm7m&iF0I>k-TK`s$M?^#fbrW*i z#fB9Nsi{T7!q?@_L; ztsy0)w)W`hppvH0cClF#%Y4$!4ho;fHJPG9`VlO3MeW;t=j)JEyhwOapFe*-+-xQK ztKdSy1weQRN*2kbOX|%bS4qPW0Bh9XMI#%6FRi46Q&e2Mb+nT49g?n>n~WMMq}CiH zsRP}JgyH4hWTc2K4zc$0IXkl1T^+-tyXoksbgc!HQy4S)#Z|>^^@}SN6Zd7IE=O=u zZ>kUx+`|u`;2MsW>2O5|aw@7X^nYIJ-nc<8#yj$rRQJYM%DaoWxGl$fzmCj@gI>OT z$pe|*YqJ>}`7NNaOMTCM)KYH}+>!hvFq@XIk!C6auoMu1>&7?ny8USfBzp$an{R`! zirMwQphYYHfLP#Exs63j(8Z6mDgJkZXv5_K03b`^Ch70Q5)eGc*Xb4Ls$~VJ`Cz zw%6=TAx7cCO_i4NU}9k{yv^`w0deBUS^>d5VPRoXUS48IZb=g*!V zr@;p~cdUd_qFc9aIex370Wzcsbd13g6G_J}mC?=$_U{h$h3gdhf`mJ9(|?)I2Y*dE zUl;D3WsJ4Zwu8(Y7!iRB$}}|@%k8!| z(t(#LBagmD-3G}T{vZ(=9)u3?0&{g%WSIv9J&BVwemF$9-#cTGpn*JV1I;@Ys46CF z9&GJaO z8dRhF>q{s+j5C=bh+}`R~ByY!Ldn6Sc-=3uMa{hZCEH46% z|DGDh43!M;P20iC`@oo#!Xh`M2$aHJDs|skF1!6&w5+X7t|4V~(TKUH?!BSQ>)*#- z#N}Q#*Kc{=sW5tN#AWlaI*pq-`p0~0sN?bO&myQ1*5H7yKuI4K6JrfLI42L!^AbMc z&R9m9de4L9$cuMn`GwRSL2W`@7C_K#wGv6b0nW~g4CPl_dkYSX%ThOm*WrAqSA}%8 zJO=(O7!eT52y&$K8-2uKj~_7;yhmjF7u4+@@l>7F(wB)@g+2-UaNo|Pc8b^+WZ=N( zOQ`7>Ezscz3Jv`mwl?Yyi0SEjg_zhE>1@n>W<<16`BKcf1a$s>vlQnxH|E5K33eoR zhD9t80GX-LMHQzIpPU>H3F1vw7CKb!tw=@4yr=TlidY`%!0{?r+qVG+6CG@VIu06t z=!cHnwOK<_n+VLsnQg3?1nJ+VtG^bdKz@h)h^9 zRieSLz>iYqog66$@t5LlbE`+Wl2CKVaMsPewM}Knd%|hwf?BJh4sfUh^ z4(Qkbkc!NJdO_-ASULjR7cb4jN&1MHoGN|! zhW_9(k4MaqJ`$JCx}uC!B-lqQpSEhd2%8_*-6n7C7HHFBT6 zM@EaIM71`(CNcQD^yP1lwlQnamlq*O^8_Y;5+3%b0P8vdmTal@hAzj%7R{Sq zJIeQ73sCp!^j5?N^HtT_pPy>J{Gj32EFK?R;~=7N_2R{gX5t>ZrXbcW7pBk~Co1ep zza2~+bZ-0;VmFev)gHUMUb`W4nRpI}DXe<$n8&d@ZS1zLAExFvMjv3^YX)?lDDS68D#FE+Cr=Ctw zoHhZ=L45^m8jwAa`RWg7(CL-YEtfW2&sc}tj>KUssfeWEj)N)S*UNS~W)bc+H!1Nt9rNsMF0<#gX0g+sBq z07q`6khU*dQj(KtZNNmc$ixK&VS?71 z0gq!kg!3^RVJ$y7&vt-`;E=r@3Z$0t@kDMXZ7`|vZ3i(t?d{ylT;Ovq^OXt!!Or?tagpHn*16)le^f9{qe? za=V9Q%xY2)rnbJvN^a})smfm%bfeX+>EUr2^^*IO9+XpThQqxV;6`x%1=*#*q!?Z* zzKEo2PELFfr@a!;G#bhKoGe*Od6eCh4|hwua~F!mNlLb9zDsKF@CmSTYsE3yF%g^U zQJB|wFri)CTxFNi&s=uJzY7?Aeq$pR02S;sbH~F3D%zGW&W(LO$3Iyb4qq^cJ!_h- ze9(i~0QCe@fJNm8ng!9b6653B^!Pi(+v2E0AlsA^(B0f@$I7zLy*xG+g=?P7(IjKV zUL4R|Y^~j9{OW?hKEZ`VCkj|5)bYgT1Fty3zl%vzoyEWE5x);U6bWI12LnRauNyQC-C6O1{oJz~jFKJ&9=ebzuBac4)$$5Z^E~&sT`@=gd zy5MeN&9BQZ0{sf437Uuu4=sN3Z`v9H26KeC;8pgMBh1PNFQ+Jt_sJh^&KBrVyjfLSE*vfre~GiCDPi&dPK4u>q3T8Hx(@P7NpPw-5(iL)8#c0uyfr zdT53VYHlN03S=k$>#B*HI)-3btUEjY1&O=Up`^Y;PbgnDln|+=s-FC&?TBS0qM~|5 zyHWhPySoKo@e8D%;sx_2SR|Q*gs2b*OTl}kZ|6LlzP8aA|0iB1&TG@V29Fx4YXj?v zuNy@^bN)?5gYKun8f0|8Rh&FWdTDX7slT6aqQqnctSr3o=du4yYI(W-g3XTHVZ5l> z9|N1m_spw7r4{OB-22&Zzf5QkS-K=gt6rCU$hz((^tE-nxuvVkA%>`N6In+7=hJv&w+0Lm}m z8+k1{@GFi(avlu%Ne5zW^(l#0LqHFbVFFK2(3>}JJP1U=#E`dCtu6&DxC>BP4U@Z* z&&vJ{uqv%pJxNpawH1rx@_I z$0sB(-MU3L>wWN=?+2{*X6=t$kZ#m{>IL4_(g`{F@nYdc7I@xKdo9nXcJC>r1NL>Z z>DnEpeqLF|jf%-VSH_0xLj~j?DEQ}Zb`$LQPW%aa6*3HI&6=bwH4s)a8%FcuWx@(= ziAM7PeGFXNLQ`5^x z4ujYucC;AR{R9lnRbZIt?d_fW@gx5UzW3vw!%Jlug>v|cZsKHIX~Z$czdB?Zcu=)H zhtu{z7U1CGE<~oj(_Bkm3RQcn(Y6sS<7Z4jgl>Ns);+KM4|7(v&pz>Y+4&IEM_Z{t zc1}(?@SPy0Bq*rOzty=T%m?C2#;4{%fu?f^r{y_-ZA1_PFBUbQB>?@scKq{=^D`p8 z_o%~wGdMIt75*?@*bsAr7Y;m72!ow3Ucla;~<+r5TxqBaeJi)dTSyn%{r z1~=)Uxj2O^XMqPQuTuRQ)@&izHMOUM*cdwu=vVTN3=$dg+;P6$CRmV5H#CPG(%@`X7aaFkwRiBtGJ;z*=DE^>&BPS6sVQJe_pTb!Z$M90IF8mY`0u}(6201e`CR|+HcoU9FpiQB}pr%9J1TYyfF2}1+#vg6(zLt$H z03{tEk`QbCAv?-IUyOs^#W2`C!X`W?lnMWg);Fp=vA<2^AYt?4d(lAZ8*-=UxAqj$ zB8JVo%3l)GeZt%3;}G^0CisqZ(ag?F_iO$mq2+-a!L!`y#EGAYmuv8z)XL7MUBVJN z5@_?x$G1`;aDd`eN|qaR*&=`iqJWO#Tp5GJnBbAUJwfyapbQ}>2Q@|5^%?Ndc5 zKLzJuRq#HYmKM~UzYUcx9Wbb8)UAjl?I`rR4`($ZKw|;yV#Z^xYajopWj^Qg9X$=# zRi-4x;>>p{JmRy3=8Q7zBj8X?5XMcudEF?e2 znJSW5pOYJ>r>BUw63M>EDTckg1&q;mIIy-~YXsBM(l$Y3FoOfr)!(lGESAs7Zy|&> zff190U9W@?UNS5+#XupM8+-x3tSUoRR#rj>2Q?a?1g#UHw3aC3VP&B-U|&Lel2Xp6 zo49JTQPk_e5O4FxHn#aani4Ce`SPlMn^RJ3L;FP>mrGF$CM?qK>mn?8))MK&m6)bx zp>~YjOe2pIk4{DOy_QWV98pd9iMP*^Y+MNiOR^I#mb_2l=R3tG)u6CEEU!5=&C-Zz zzRD4(K0Fb~dsFzLu3N5f?F^wov&GN^i}cBd_5;-y6he$6*8)qoXH@V>JZ1hon-t7FjKksg?ZWw*{yMNLf&A{{q?0fzw<*R?p-tuMl4a~51 z4X|9+x?hy`wZf$9)3YG0hvbF*girU-&s9#X>?(W+E)5S4mqJqz_{E^}kl3OnMIU-$ z+#WqzjEq+K4;T83B`~wyA3l802k!D-y@%il_!FkE8NHgo!eszzgFc|BNd&FV%*;%J z<~)VqZjf;FL1BvPSpxFF^OT8B?$ILw6K!V*;yt2I*L$J5%GSM~PSKxtLDfLSBI>h6 zvPo&Yu}u`O0V`g@yUKW=Kcs(AcIOnRKOPdcuDDWG=XXtH< z`G{E#cTP6~pG9XD3!RKxmqZ^SmS5V&9Y{r9JBF#yPn8M=GLuuBn&UV74P6BivhjQC zWokpM0uuL1X7BkL_@&kLZX+F^`*x+^$Y(V#TTj`LHXi$h zw%U1P@YMI7yIMsS`*B^26QXv2>a!A=+V*dL{Y?uG&EWG;dq;97d!Rr);Q`74B=MH% z_xc>pV3nz!K~HOTVD#sE?UR$6otCW7}JSzyNDYsL5L zQy@LWCL|>2B#@UK4#*_$Hm=^JYTKFY=9CE2K7TP*ZFdrA(u$HPV>6MZ>|Xw@6GYJw z(U#7+t8Z0Z+1#-wCuu#U)y0X1rOHHRcwA;I8rPSNrD8hBOUy|cx=JmeWRSlgKCvkw z+ip~KSF6oDu;R4V6`{IsK3z9zsG7R>(e}lVudYN4-Y=u6x~~b1YZF?s$vZ39`XW7+ z9Iw-oeOnB4wc|gVOFp*B>?}AL$p%%Z zFrI+;F$E-_0UV_hVBYlItIvFYs3(_{aFd10tp*=(l^QpXBpDBMOAaP-$5{FN;&Vv4 zFyyS!e^~x*Chw+xbwlJ)ePg0=RVQ^VmsRHNvo$GqfxLiZjN7GQkxWH(=?Q6I7Dru9$&c&kUMCt**QXoBHyl9I6vuNIy5d4qcX) zmf9Omw^b`a-m3&z4iOe~wRgiLz2xkGFdb#Ir!w@O{mgZ&&OYny>@#evigyPX$)1qy_8DnrBwLKzpE3NhgLa81 zojmh+3T#=g9zjSKTa8YeOrB9?R>J8y~V$;Lg13Rs%?TJ^@( z(@ALAKtZ~foApiQOrd#P*70k~t3T_A`$g|7ep2Lg?kRXx1(phE7^jThde=3>m%1P}*f}p+H`u*Yv;@#ol0i;RsVwxMWABY2#r$rH$FEUWVF4VcFT0 z&d$Gv8Iz-W^JY{#`|G{>q;SNq#N#q21xzuP@A)4glVX`zpR8d$`?u*azZvfBx#u-( zapcmZW-G>;L&2CN=_~Qgb3?o#1>s&p1j4eiuB3^$VS!~vzs&psvg@9?yPF+u&QtT4 z`Wfn1yMD(4F$x2iF*yZ=D^UGH0fxwCi0TVnzNWCWh|0Oug2&x7W7J7@s#N^w=kNtk zXKsPllGklnZTINW^$zRM=IT3R1(Jbv#A6R7WM+qqv_+a??mbZZ^{}9z*L34&R1r6p zox0fbgK)|LLWM*zgb?cfq3qoY7$D#JA{(YBurx)2=u1dOMkC zX1~kk_SmpB_tJ_%V8iXJDz|TUfRdxbOSg+k7MQ8(S4rX4asHz@y$Rfsf-;fz&#JvoDiuuceg*R_G?rPB5^XsW0&!CY z@e1jfc5-s^612kvg}OtL(K`qXtd|;3xA9EW3$Rw$*x1yMrzUw+$~+UPax8Ss^AhlD z-@_|5=AEj%HDflvj+iDegt*Qcbx!){K9>+7D8pNO^CwGP9expu4ioC_m{^Hp90>`D zL&!7W!^c4yXb+E$=0P1kTzX})36r;@jt|Y;e_P=N-nzu4q!b9&frV-TCVvd)M!~&M z!Wu@m*Z;h;MXQSU3RorE5wqZrA7Q{2Kl(kL;4DKc1?d0u&z~}nJ$n$?F`?m#cQ#0y zchTSDH91R@L->U(9~WJ`8wRS1mkVsfbQ>)EBU(5u6E2z^1o)JH`-exdk5}zmT>wrF zihC^RQYQhG0O&cS@j5&vCZw@Z68PCjL5Fdv%RFmlOvpXgtb~}3=&S2BSjQ4IsrjD9Qwaj1E7KhvFOaxnAMW}KzSzvbS8!Lm0Nx$ZMgW( z2B;}H_;wRmIz$Bz=)0P9yYyXCb#Wojc_h!ZMQs!%WofrWXu&N9R2PZhO?(Q*<5ou} zVao$F{$J7_*mNjFE6&Q!CJ>!ONr6UF>UrP<0tp$c0?0qjU}rh9&S8KUVg`O-eXu)@ zzc)tu!)d)r;Z-*zjYUQl4*jaqWUbJfhU8qNyYmFxB}ev}ilpH|1eZkP38;DefGUhG zVNN^tNU|L1vYfY&N!BjQal1=$D|OD~sIvkkF7Ca{h$3WO1Ez-fX)ko4PxqnA_mbM!DP}3yx|t?bwpJ|o(v>ke{f+S zjiFvzEkJ#Qe){xjTZB`ga7m=-;dVe3ap|LjP+tQv%ishZ%8lN@l?IKlonY!(OR5}Y zY!@lgm@*HE78Yh^h3c+=cSe;Hg?ijbHUKXau8TnQpwEuAcM>7ZX*g4pmyQMtK`5;OZ?f@LOevkE@jDI{m9DB zu1Uy=>}HnqXXqe#DFDG3oMOlqZIy9iIz8O1UoCc#g1E&YbHM>cY(6d2|Gc|kqspCp zj3xzM6Oy7`dN((T_Y4riJoLSS5tQvt>Drf1vCg*(tV4~BjS>F}ILVu4XBolqB@La- z!L-Ai$eSBRRY0slqInv;z6o|h)x#xR-=EsuWRUR6r`Ly6ZXi-x z<#ogd273aJa2;7I3kwV40n2wp*0O(yH7G;cJ2^~mK4Op|@oWF7Q%;QWr*v4hYa=TG zi~vD0f2LC+-llut!XmYUrV!7f59a6S`}H17JUpdS8Kxo+7jckJ5K9fP)4_3ZWKgF> zz|)w42^KMB(L#SgmxUXcmI0Or0PI0LM@X+$y8AK>{v}cJot1%3@Iz{7YPM`x^Eu5n zM1zcJKHn1T^5jWKaxzUKmocu5jZJ)f{9sdn3n$Nhq+OR0HJ(WC9Jqn8el{M`XzZc1 zw7EQhH=&ClVF>RgVf_RK zxy@RdU;s0Ezr8ae;e_7)G5p1rL zlRw<~Z}YqD{F$36v9ECd8={SEn7{lX@?TvD&_RX* zSDoI~H6%5{T=IcccJ{+d@WJBJ5~5jwd-lh8 zaq9HSZP<86UsEq2j#hOLfXTri3U6e3^g<;Unze&E7`MT)SwJ96{sjo9Sg7!jx7mQB z_z zDwGU~XC2P-yx-?}KhOLAaoyK-li%+<9LGNPUVE*z4?w9ir#e20JotaJ;aNnFm{&+_ z>sA&#-wy6YB0d3SRz}{y%Uj8w_Q;VV;1oG`$#dXXg;<(ok>Wia(V$C%#HyD1`gTmr z>AR=Dw56^=e?5JwyuIiF|5vwjKrgZvU+Q@-O)A#~GOTl*8dir;$8k}Lc)t)bQQh{# z|Ee+GbPS1!iP2zHy2QAyWoP%p8sH_WAiMw*7lHdA|DQoG$xP%lp2QIBZ5&pwrf9CA zQJw=OJ+N_~%GBSwYbZ6qK zuVK&uNi36hmowO1JI;M#1Xkw{y3l^k%T=S`GA~&F)S`pR6%k^Oi z*MM+`BB;4Vo_A!P8k{@bp!E&F@^iS(%T-vy-crfU$e4(@B8B==GLQL)|YtibgR zQaYfPNRZ(DHg1-i*Do%P+oIx9Va?W^V)Y&W^E?CiQgkzBUOpJkIc3{-)qpQtHln46 zJWS(L@Ha3ILa@B^BQ$$e}C(H)nIzLn~McGKIk;;dIU&zGJiR#%#oA z2gX$2?@`3X@XmfKap*iWQkjTmW9Ei1R+~kZlUob%`);8m_fAtDNbaaKK$W#tt|n=p z**5yq<=4!Pp&*k17Osr}3LAX){YkzH>MPHp)_HBDJp|xQy($pEy^Ac}-sQKEG)#)6 z&t7Y+Q>|J~-&@O+pZCun?(HLo7fN-Fs|Vw8R(>#nJ`|c3;*oVN2oml8{zeFY^JwL7 zB5+lt1g5Zp2?ikiT~}$g$Kq$v6*|GJs;G3w{BQX=pFrGmJ%2vuw(|J)?b}KEq@@-9 zV(Y`K78oLgpjkfjY_`7+nwl`6XQ0k9p>urtUC~B%c6J$1{wn@_e@cut@aFu1E2LIb z+*RffN?QfUtq!g&WToKkjE~9}6iOTsN9%72pt1@hRcqha8&-Km_m#_jwwx||%K2By zf|XvwDSP|Ovjfa~Ok*O0Z0T9@4V9u_vf4G%*)yO1^bgT~W`pzi*S&nl7^jx_ROu8e z7}jk$-S^nm+B)Uz>sQ2p><1)F0g+<=5%d3#f6t_-ZJ=9*CRknT$lllK zj~KtrD>kw_U%KH@kRd$r#$O%AjFF8i&>!giGn~%6Kt zkk5X{zb{@_U?@H1M{!$&?)NI$Sl7A{0VxkOd59XEclN)}T=<{QWOK$Eknx>2y$b6d6uY)WEAlZXH zj)Cl3n=8G?GatT5*XD~$NJzy=xT|VA)HU!~1Mh&e{gy`QWO_*{}s?8VwGEk~8mbUA?To`rk;Jal_S?ae8{1m4}B8lsGhNacKLyO!veeE*nCx zN=Ia3l7-92;*|z2Y$h`;jS3gWn_zQKjDsG+qHOnq2WR$m;gb5w&mFLpd44s2ZUF>2 zBo3;`Ypuw?X#X6*AjFh=@7})DWbsEJNcZ&i*1{DcRe5ZDGbnmpV=TRuCZ5u_TcAq3 zhPI-^FSZ7au%7=M;;wySccvJGs4h4>TCRd*(JMY_^H)E3=2*F#y7Wu9-AJra zLWV-rgl*5RT=Z7-KL?UH$qhh=X`Y=1!8hyKZP0M0pR(P5j5A5{>SL&c^vun(e*c-N zTK>yINGK59NJvT5qxy^aAtEi!^vq*M|H{_ZGh#6~e-C^(P#~679>_;QDmzGQr{E`M z^EU0Er+cnmSzGdO&HXHSrC(NUpT2?i1uaQkit4qJW=xHzf zB@g6iLl@;G{tKV$>zF7A;?q-X$KvVbC5twBdlFd{E~&3!zVPTX++$yOZ8B;W^xoFAmD zt#bDI~EK`{%QfI#*bZ9?EDhPT%VY;GLlUy?mJ?l`y_T8hSqs;}IVh_do zE27G%l(46P;3{X)YwZ2w{G$Ah?q~NK(X`pTe1<&9iOpaDjVAC)|F>}RhG;+pK@CO} zQFt(I#dbQCA;-o06xJhXP1%MMrV$bRz*L0jY`%O6SrMHaj3s(29?DhX%dJbZRm)S_ zA#@TDe=l9#DlKiaboIwTmGVH0;m-48k#b{%OhHTsr>Fg(J{{=D_%D$;$E-*Z3C=o} zEh!2rD=Tlf=dTG&Xhc-n#vd+AAR!?5`;+&2#n_qHKd9(Ec<7KHoEDM4$)d&dGI4iz z+AH1YX1hny_@}7ztD*E>Lxe&bcHe>|10I?GNScTU4eNEd`1M}--bfNIQ(<<@8_H-+ zxvKW3RbS33_B+ zIKl_fJJP^K!eZy_@{-q*ZW=#1k|C5b2#Uw~j9P2DwaFIyYArDgIN9{2J6~X>!62d` z4DJ}&`a$s!DZd#lojHLoBpVuqw(F;tJOpbX%xH)|QV-mI@I`m|_JbF!Vg+kXFfOUi zNGPr+lt1*lhQ59cL(KcxxvTA@eNcquy+Ou3rX~Hy@H{|HC9+3g2S9D-#L|)y0i)xG zJK$GYTU)y#`x2rVBl@D**=tx__~B%)WB!!3=AQRZQ36^dD>@)Bkf%ZN+<7xlI9vO; zxrtNY{!TIWR8x~~JFnSNl^ zu1QJ`ET^I6r60?Kpd%oj+;vf5)Pf9*U@$;bYyd2AIPt<-f`L>Oh{86&98p4kN?^5t z(3Sh&`G?$ZV>NLYyH= zlM4P{nxgEBIF&rt+E9WEO_bjxf3N!cn-88rQ(qn?-$&U`s8Ii(2{{GaD9I_Prmile zq*Ou2|0wh8@x5n8V^21(-Q*{uG|mw+9>UeyopF;ty}OXNnb<9Wb3l9$Tq@_qkR4Pj z+S@d+hVhRKY}%vst+?uxT)^2)FBqZiptCr3tPqMbSTr%ppL#i%(6QMp`1e|$-sa9D zVqL$k9DjgYd}sW5Iax)vkII>K-83h?g{4#4@*D$=-Yz98J6OGnW1V7S zvQ3>BJ_)}k_W?fkU^dfXXnQEHoCpo8&N`CC5h8DGX&`z2Wq9yM{nN%^Q=X^<%g-^b%UCG4|o9;y_x6 z;c=!EkNZ)a^}_9WK|TiJLCJ6*)P2X58&2_f?w1G+spJaQ>pvRwor~Mp|a(M&SLRJl2{hMvdP|fCo=mNY?8`6PEi%uRs12 z()Tjf;JP2jma+aW1L$}vH#^=t?l$)5+}P@Qc{LDe^4G9r^haE;d}9CLK#k)$@ig{^ z7itt;Nv0bws8_Rplb85c#8T{j^8I`bU9irmdHR7GhP*c)HiKHHclS-sV*h2o&8~;? zX14LEfRIF*j|7B5BTxg3(SZQgR9GKpayCcNnb2~w+!7eiccbYn9nU#^QieX(C{bPC zIpS*9>lZKRgrjId*Ddigm$!qr3UQi2_SHxrM?~R8NJ=k_#+tao9@%sD!$rX4HNc*3 z{HR*y2hbH@(I0oV;%o%}9$pZ?=Ds~1n3G(+QT45g;CazyiCUH-Lrzt1de?86l^(IZ z2NNK7di2D)v#%ygJH8L~9C5}aWgX6|kJ$+pD$Db+$zO|fsV1_+xr-vl<>%FhwGw>| zj9?5jyvZ=}uK=C+r`IQhVi~#(U#Zcu9p?-`@keg6-Fn_HF3ngSK*G6+?2uUIJU3C? zSvr|l`&NWsYujW-OHKxXi?u?v3iuIp4xOY_IgM9eftj@@WE=k6Y;De8xCjYsjyB>L1i zF(O7TOKhI3Q1KHfJyCQh_Fq~Jqe7hGJ_xVKWLSB5Ly^13zx;cYFh7Cs6?p&ksBCtf z+GOltT~M$vQ51PavqGwu!9Vu)8(uDe5JdQcrjaUQmgR@E7@>Ca47UpdD>m>Nzk|Ek zc67dYy~%KSnP)s)cOTE8*y815&mwiV{dD>KEod$;LjK%O*aMIT0`i0vy{E(KdwVLr zs=5nU>&(pG87^}@5&F=cDn0%M>*$>~gSYQ~Ekb+r*N*`KeCr74>6bX1d81$|7FzFf zdz$57r~5M_JAa6sgy7u2dGvwZo#12lpS|)HQasb&q$bP;&q={3&B&y@uPP_r-raV4 zO&FkU-ZjkYt5KTGWqsPThw*nL??lmWiba4y*0{q4X%Y>1JD+H&SyvrOvwQIXslm^piNVY~asA81R-K0)CY}e{CLI zljUzGZD``7zbD($t8aY&HeJT4n_BfA-QO3_8<>I{K$z)BR3?pR8k~JObYDK|S*2gv zDf4Y`{d$Tkz7&+c0>jydr1u!toXIvC-FuGnL0=(Nr~GnOwx1dN&=zSHjtX;yMa9N; zrfpgkut&h1Y7!MBaoV9m4^E2TyJ+dd?KBdSWNnX`+#R+&UL|1!sk>*Kn}} zmKw8=kWf=|^Kdq;P-76=Gx#1AgGdnv=&BHn{G+*B{*k^O&JB-@hZ8|OdQUD+HGb4D zJ3C`%zJExF;E@l43s|va+~*Ia+la zlL#vzrl5tvJjuw6KtZ#NMsEkhHJ7(B&N<}Bllo%nx9bt%AvKNH;{_7_fzBPkIh}O&sp0*g5WH_{(qS^Y$ z`9m{I7F)MF_hm?-gD`#+u6KvEv`!HI>d)zE8$8hyfb?Fzetm;5-Zm&*iUd?P`Bb*t z;uVF=xpO}~7Bl1l)L|jL4dCfSY2!M=Ocn{8?W8L}5y#US# zTu0OxM4Sgeq!5;IORlBUD5OQuYy@j#2!ft3uni!YB;f=YwI0U;z{RfkP}Qt zQ2jjvN{NmDkOpQzlL5)embw0~5L^n*j%c8=f(TI~9sC56(!YUSXk}L0N{QO}R`VGM z8cYbL1kDOjvw6$nBpUSM?OS#cjqo=>7d!37VLoyn_?bTqlZv-@Wv}cn;>{0wPT*EC zjSTb@N$=89ivpDtNsowS5>R9r>sl(Jk%2)3GVh@TM|bi^pe^B#f*0szxzM?JR`lo* zO43>Y2gFn#cc1K{qM53vWQxivo3~gB?HL#eH$aljx^``<@vJz9v(^Fa)sOIe>frPR zrM<)JQ}3Q#d0ga)%B*=BP}oV#R@e#rG{zmRS;Je;8yh8CPzfA8Sw6mc?Tz(2?@O(t z?-Y=H2mkw%A8?q*j$-39BhkIRxurYDELBQC*}I}h39yyf5-bZOTUL>N12J0#*W3ry z(P|?v4Yu>(XcPNjXhAc-Jx{H)+2t_4Ie?q}iIn!5CsMi21nMW63()t<&VOM~@)(z> zq!WrzIcpyQ3nATY`-)3S>XDMX0Y(Kc|JZ@iC>TBvX&7u2@8V^h!kXK0<+l_VG<7CJ z2%oB#FYnZyxhsZ&5){=qIfyWsVjp@bvHn*ZxpD4g^;O~JBk`PGKa2nq``XC^RHE3~ zWYWR{+k%n;LklES8_~OU{kz~!u@BV+$m7H(88bTqhjk2*-nA8-JWN3>qGN>m@qmdz z2E{!HrUo2M3`{|d-a9wAx-veJ<~D#Tbl(L) zL?JT_zIvW3sRfBD5S057K%!9T$q_MWZ;8D>8U#~Dtx3c66b?M64H1xdzkK-umg8zm z_3)Rdo(tVZ4*^-6G>Ql!pcJIvyy2jbkQO&8Bk)c9^TpEi5Gw>>7l@wP47(G>J@6{Q zAy6UdP-j!95Lc#>d(uEwl1l!enUG%&*u{>S>L`IEiCsJEEoP4Af%`2ox{BDlkK388 z-GvJN*!Bmih!#Yy-^h(~-C={1uDgT*BB`atCinsKR#jPlkqZc^7Y`b)l7vbB$klss}doh7*;u6#u#lj9amvzqpnWR)Gej_LH%YR*FH&OaD;KyNeDtmsGs z>PUgtp9>W@lRY_C!NGCS)K?>RxR7F|ygD2i8MiGAmW2(C=XXyi$GAMG?Oi*SRrSMT zseg0bA(O45QYHEH>`&_aKTyu#Z> z4R{|Bf2wF@iF2DjY_`c_Ab2}$OAwtWwP|G!p@gDgb>@(?bG@k{R}gj3+|FrSl?)u< z65Bts{=82jg>ljy$&H;5DQlW?GoMXqrpU`)$37RNeS*l0;M)!MfTjBQL2MAFocu8z z9%I~SmXbXyi;iREMxCI7a-U*{LG#o3^4vz(I3-j**yWvqDy(qy-2qz6&XD+KTn%~&Hv75zIe;YUNILjld$@a^?6;~ZUR^DRw zi*EfF>w;a%0y+DFU1KLXB=HDYKJl6a@m$fYq3>fI*v9DAK=GZ;u7$51ylH;N{dD@f zk>&3NpCT0BFm2>6teotlb@_wM;$YH@(%L9!>;>swE#}x47*<)8m@)4({PSoJWtkXc z2=EJ@c;@c*d%_G5MooJ=QJo8--5f#s&+ynRa~+}-We;(q7FG%G1eg=jCRUF6Gg70cwwl`0t;* zf=f&th=1C*)7We#xHF4izSQc%@+mDs&4|oo2NyXOjszEuqb5HJ=BZ^LEy)bICxct} z+~SmNjFU1fKGMP^Ib!+z*CIxrOrUzQ1)F3jTXXe`rY0*&U!M&--2(DG7}ce^J|K;h zF&4dWGH+P8F%F&)&x?ddua-vBn0$Y3dNMhq?=cN^;mb~Z@Yyq+*9}ZMM3gQ>jhkGX9e*ORft5-K?Djy4YKW_tn(%(8R&Z~6JWx!5*+Xy$ z(1?C^(xh|Sv|*f@@%qm}lSvkrY}8q|v|UI&FI#`V`HF8x@77g|@iK#+;J+7?l%##` zRq!<{K1IyRRw_h?6uFM5Idp95fY5?r~(j;1g1A|$3%|jZ1d*DefZadCLYbh*!Z4D@yrpXi34FNM=ZZp1 z;$ZC%&eHYuJNpkW%)GvZr97;i-v>iw_b^KgoN+o{X6yj5nlF#0(AO>iZcP!7hg!4zf8A zf;fyi&;b%@6eQn_%twPJ5vx?emx`xEOZuTM1N>MebxVK$ z2yul?HE+(J)TWc=K@Ya35KR@7GRO)F?Ayg4j7WV*yAu^#9U4~!K)FaQM4De(zcU`T zD(C>JGll0@5F?KtE^IJlQ&Ke=MG_rxWgvDF0xsq%keMHY-@ZlQ+eY94;cKzwb3%01 za~V%Gx5s)*H-iWbjVmqDH*s$uXH@DL7w zXiOYu$aTVDpO^wv9WF-o;s@-B;QOR|hyio{D5(YE{`m7#E2&!NkI~AS{bJSGFea4b z{_r)7V%=c2NBFj0ULIlZpq1bJ4QKX9LITGftsoG#V9`ka zf_NQYyS4^6r`FQ5=-zUS4un<`?c=jhIhA@3!|NHOU%!Ed6T|@?jq_${>|Wd@v5rLT zJ^x9}pMUt^fAjK8x?NXOpbR2CYJ>wuur^RzN5UEG#gofF)7>KTduW)~2cuUkvBtI0 zQ{y+3f(}2VaUt+pFygxh1SNVPf|UuA0uBG{0ofJ6wpx<)7-I{OFE0-QqY zF7sIx%EQU0ch>*gqb5^bXV}2V!0uPW1(0eHF_u`hBAUbp5~gQjrZ(Dy=-dz&Ycd0; ztIN22&rFBG;&*L~BRRanD}~W%W@hG!gZJRzz4gK7N00t|%C7GS66|AhW#l_Pm|X_Y zv|}Lzv+*vT(Ffnr(gPieCIdJ4b%U_ zLzI-L#O@(pE~dcE7&Simc61{nK~_PMK}O-*xuJEwDLN8=S8oUox1if+W>~+$Gn30C5z)Fj=D>MZ`}#bop|5 zbw?uJKV|%c=s}1SP?K-b97p0GIa4mwUMs{L`+QyN~j}UfCWTnO@J`-n*&93#5iK zxn3&oyBj@!=okQw`)}cPvkrYCEE51q_%Ae?83;#WWj?MJD|v&vS~bF(*Xk&(iqZ_Y zecXc%Pg4cy1R6JF*r|d6VprRjHz})eT#;hfskbB~NnabkVpSo=&Zd8VTwNxC4NDkZ zFW<~NlTA@}(gxwPIHc3TFj5a~_No zNG5FE7{E%_R{dCrnlO?jHuexIkOV(x(+SmQFfQ!iZ>O1lfX~5A2668JJpk8^yHgd8 zLO}6%!Ix{K2=AmAxj!niT34^8n_Y$WG6z0Rl*DJ8f#?z83?d~DxlKk3>a?ns!Cgfx zkW-bUhYW!h>(=G#o0gAFZ(F{7iN7ca;AB=%~#5332dWm zS7moSBAa+_FZ~FYm~WnqNC1X|)>8pBX2%VJSDfynN}IV3{6j*3fT)HId{-$JdU`)F z9TAK>db^KH+`K^1-p_Vldx(Ioecr~N!ZHPgmcr9tik*!eU+n`zj+mX@o7y}pgAJrX=7bw#H zT&O#9!l)!uvo`O8vn#)8Xd08g*3mIK>%$cAR^ffe%-N)(U%MmxoSeUs*~#q~>Kl)- zF!d*ZgwqlCQe-PY1sYA(s%R#@>**1cks-z;xUH-rC2{eLF6-wx{?soic3h1PxomWJ zze&^0NKv;&!KSFvPJXW|lIBONzuTJpIyewPUoWg4~2dk8U;&iR=2kw?dHrJ zid(u8H9Z#)`AG!FA88sPQ;U=~ZhUaT`r!j=sh6v&8DG^5jTPI9!866TVWaJkleD`2Z-p{u zsuR11hkjo_)_KHC^UwwVglL|R2@mV~H*x44K2`hm{Rhn+59XvJDl^@J0dXZHp9ABZ z8+`&Ygcomu{^^BpD#*{lDn1K#3lQE1HC%`7p^OiH!G}};R|yskcE}{!d}N0x(0Xfp zM!z0sw?_p};w_c%V+CAme#75HXHAXN&Q)gLU$F`^yrr^Z^e8McQ+HDuPP{9dA-c-C z?Lu&DfbH=~bE!umekPTh?`Cf~lPxZ-PAx5@9enU9e5S}F!!R`Br!+O(Kt=@-dI`zW zVZ%XiJ<@?kI-r%LbVoU$&3+Zd?c5xVWAG#z9^G>6V_@R$suzmF^*s?#++f2Xqr>|q zF-Rd4`DW9o8{BanUXFF#dKVYlGb&B>X3eW4Gt*Mt@DEi7te3iD8Q5c*Z@PY{;|aaY zD}QdY)}J>_wM9}n?C@Gd=Td~H_byB71(xMMr z>h?Fz`pxI3th>9Jy=dOqzA2b@%{7m_XD)H`k+Ic;48w`ssXFI};`K{1EJI)KTUEKs zUbs!UkNVKLBG zb8PoqP%AY5Qzz3dz@S3r^7IMV`Ar*Xe?q-PD59_?!ZC&1rFF?9k<~^Jrsaj`C6|64 z18vXN(FeaQXh6PDQP**SVgIP_vo3xjQ!a@TNn0K8qqVJ^`FeYWGWqgAh~sf z_2l<8pF^qCPDV5|yXQ5VC4c$^1eqwLR%p36a!b6uAYYBSeVZ_HiTQp-f97CrVxsM**mOlGM->_psfKg>A z{X3&<-#v!{k{5tvSlivb4g@bGo+`onS?L48aj$`gi^8cZzuc-gK3{L~%`ViGVF|2i z-zmk0{qXXMHb z@4*luISryUVdsj(WO(NpUkXXNA)H6-V&$2s^!EDOzHFz&wVC zWp<~?UkKERYc&)hZ-C8%OhPwDsUK3S{ej1!Y?Tj0iHl%L7od**M|1%J4Fmd)jkD@$AlUi?=|^cxqdj`PB?2+Dd(76SK6sF7&Hwh zlgOl`JVrTmAaxlyV3w-uEzPlg;%OC>0$sWfdPqSBo+=S-!J(bhshFBrEs*{$#Wl5x z%#*~iSL)q=%P%)q8hrQg>DlZ57`?xW1VI&bo`{?rI~bS#`T6;JNr{+Bp$zR1PL)QO z;K;(Ypfr%~myupvU|PqyjIj~MZr@vxf#KC=H&(}Wt&KeJv?f=9;!ZNt@I`rgBFiC? zuP*dZPHGa|=3mmbw~7B*NQ#IwYSX3z^Za0J$Nm6@lwx+=SbIq0DzTp?R^qU!#7L^k zsPi!%&KJ2o?3r);d$n)K)ryJ=;%YIZ+V|nZP2?b_Ar@gMf^m@NC-WpOQfVb`4E*ri zwr$ro#-}t5!d)M3x|}TVUjBQrRHnV^>eW&~=rLP=*qnDecdoO41it&ZzxfA1pM?p& zDy{^Be26A-yHiiK$rp8T4#LDjG5$H55!v_30@E2bLxnti>`mD!B~+G)Ob4n(dJr+4gx zrwS*%*GcJ@i#Y=M{ys=gC))robTpekv!H+#)?sRBHc?TIgwdm5dpm(l00}$rZw%v3 z%ohXY2dCL;P@Ay6mZomSINST~RZWDdmp}_3pj5+IBJ^VacA=>b3iM{qrgjs z5Y#26hDPCBDqkyx3KhQWTNznN_A&%=q+%lO=$Hv3dkAZ4$E8U@NMEPtS`H}|BKTp< z5MC%5EkL}I1L)SyouVS*Y3k_Yd zK_966ApYa=DooDO&qOyBIU_W_J|O1+4XGn}acb(INtE`8qn-@CFmZ`W$q1t7}X9O5ZSrY?99nhw1(lG zmQiX1Vd?7KyPt~D?FiK^M#D7+oDHpa>tcBgotON4VoHvtGm!^CKKh(cfK<6RU;v>j=%*+l zsE}Tvoa?eo*C>*cpimMqKav+Ka!zMDvqP(bv@)gpaHq3m7 zT!INWUYycpIZRmdf#Wza{6h}66_Yw|fQvDy9USrnf_M;FV>Yo6pL+u95QHwt>$h2D zc^d}r1apxYHsFAxP6u(y^?Tn=vRvZQ{(;~`5Zr))P*b20Z^`32-foxd6@*`r>0Ah1 zd(0m>y->_lEMi?nB_vmA>R68Xg1+}W7_QVCP@)4GbLPWv==}JhK$|kUKEW*?H*=7d zX+_?xN(P=2bSTms02H6y;IYidmxo{FZo_g=yp zu2q@Qo5QOi5gN)wxc88G3_Eu3CKpS5+(Gi36VpzUv-*!X?EC-A7tM|gG_A?5Dw*DP zha-jIoBNsv9-jcQ5zYoW3}~egyqOF)&D;;L4DWIhtRWzZZ&tU_3W;g6Ibok#pE^YY zoZw_4bV9)MBjDz?>sXR85HN&i!WJOFrFE{)Eq?0!)|ALYN-3T(nWdYILyoZ(s4!UoU-O4*wO=ViN{1U%Z!+0H#0=Aj}iXauD-P$;hAudtf&z zM1eon_waff5RYZgkLd#F^n;stk=Lqy1@athsx|*F3S)xJ0=gWK|(^3SP)*g;RamVvdOWS%-(tb{%ESjK5}#eY6i0HG?@q%4!QO^lo+GgS|%yn-bX(aG^@V9d*i|d2FW!y-Yy+4 zV7IU0$de_J6bCa(vr%vqA%CQ8Yj4+4W>2B`RYNQhcK0kJ$#NMj4|W8h_dA~Cm%w*{Os`bu6B@2?8xb(JE~vp=E_SL ziApQnv3~^P{#KKuokTeJ!VP0lnvz8sh=6N_-U#^d!cl{S1A*gC=q~&yAOKCpt#}%( zHTciznvOS223ClIwEi#Au9v+1X^y3{f|04Yr9<*Z0$o8iy)5XPK2(?7IR&LYFfU0y z59!`sY_Q^Bf4bE~Aqg}KTLAR12qE{q^8F9ck+8=1|I<#ULuNIPd_X0O%w8jA;Akez zWoewmX!*Q^XM+!S9llBSK%Og~G3S<`BdR%9C?-0$tcezSxrHKc*Y|`%VgMdE6x*V_ zE|jfO_pH(kMIHL|^d|LOhC#wf3qLP}3LU@ib=JQTUTZ9Xg+V)>HVWVtMO0U!S+iyh z5PwqC80(4zMDil2lE$Iu*^%PkYM%S02JMZWjYl67vta-M^X`VifQM=0-s?tB+Wz*@ zdOktVaaeuz@nf6q>;#WLa8vsGPFh@LLgG(WcZuIR8@)6O-X~uOW-s9Niw)Kou(y*y zvj?VNg7*wQ4+RQdR-w~7*v+Q?2srTM!>ll z4u%O@@RGm@u(z9Dyco*nHNxphoPxoOF4}L*>BLBi^u3-tu(F#?c=nLF>&&RcH6nPa zvGg!^iY({&(zG;DGCRmGA_~TX^fk?Lo zi37nwz>Om~NPI5ZTYQ7UlpoKc_@{ffYs0gsi)q5 zcR>P0+sKXooAKLSH3sE6;jhA?#w+3*Zz>sw0_>#`M^EfBKU%}wJ6r`T!6U=)gy4T_ zguE3AAWW=q(-48vqi|n&6|p$-MUZA46zD%VPF%zMkZ+4^DB)}Nqs%Z@&x7Jqrbsu{ zs(C2rJ6{j8tMzEzEz?_btl9SAvMXRh?tjVI!jv~rz{-soM^mwo@} zQcii}W2YL19TTrF=oJ@9zh@U{yf@-!e^g(r=MZE6zV6-z24?Bh$oQMX8?|5mxN&Fa z%lVg#jEuMM49$2dy%L%C+-vtL{8hr(e|(OxR%?xd^ORcDq-1*|L>R zsAW~KM9|AVirCreQQyR8-CJtp)NDUmlDlVg+j{0}=QgKw@36O9Ub-ux<$gL}|3NM7 z#eUn3zjz(ri=pbjMn>yFKE{@9{(zXxXHMB@IS2Eap|HQ#cG?xIr`AelaA0}qaZf;S zh7o)(_=rykjA6oz_%i~IN7J~MQysi1c#t#r9dCGtN?~H6^zQ^W^BZqWPlSEr5RMe@ zv=Fi7WzEemSd!ZB_M|4AnRS6#+hK8Xm;l1I`$&gL|dqI5T7XzlX1WW5YOFmX#X zt@D=BHa~QZN%XThn_U9$A{3baUR-?jhKqfxA~gKb*r~RX0b|a>Nky$glJul;Y;cC{ zvGSvn4W=*ryPoQs%7##_(({v}X*J))U+*jFc_ksDZ!}dcN$1G+<70^SNXAPo)wj1y zJxys1Pc?Q#`IsfKQzvWsh#u73!NwR{{Lmm`5OTe0B9Fk<3^mGqYkOl)V_IX2Y`Ktw zFouL|Rp4cMc}kvY7u z%a}~r)pC;ZltrFjbG*~Ru+BG*`o0b9X-4}hn%}wZe2qSx4Z31*%*`-W{}>zB$GR;A zgn`ovyo+Bgm;Gw2ShGWd%>xm(#?J3rM3Z4{Hm@M9xhkbr$|EXaZZiYP$Q>$9cDBkD zT08DCOBd6|+iT0z3)xJ|$Q`aV5TP~qk9O2#FFN()NQI=lEo%s+CXKWotFKxnMX>9w z9cF4$7cQKk5Hdf}Idx}zGg4&|jltnr<-*q|T@`C>RA*rYgTiI3bt-TJto&0JVEv~M@?yF|^g5j?8Ue@p) z>FYXS4vAMkrepTNdeNEsfS8QSGI-}SSo+sAQfq+3TFY>|j=d2oq+^R3_%oJzgLJf^h1&=ApIiB8 z)5dqfvDY4EMm{;*cbNaW^mHWLKl7#hGpX3}07)LhBNo0%TB+7sey#XliO!6LvLtC&V7# zX4|%C0DD82|5uGFHU!4bW;wtA)5n5uR4?hK3gdwehizB@AD?;PYon5qn4@2aZl5*t zbW`o1M6n~7e-AdY8;#epmC~bQeQ6{@kyMhzxd?MB;(G$(b@3TCwzh=K>iKu3`*>nc z96%5fr&GJpqfsXOdb06gNl`=a~FY17xiUAp{0utziMM-~_;v z!dlbIcKw`q(FRpC5q?kJu3DtV|5iP6obwA2n$l``Efvp}C1ERz&+&N#^OTL8VpDzi4 zd9f|}*)rUW#H5SDsx^L_OhW7WJPoznUOX#8KUqZYn|t>K!XEd>teOqLkFK?4^iijm z_M&t<)fyj&Gu8?;C_*tIn!5@d8{krv^xR#vIQG-YV(QwwA`ab(V7zJovj{5%SaTr5 z(5_mA5qk_l!5OkM6YtjIrsh5+xR5cUq^v{!e+e_;h){wsot8=^PD^82nD4J&=gHhg zhf-cb1M{BAwoEB0;YjxS%|m+5CH8gB#~nX@SK>4A^jcz4oI3BDaLMC(!lmsmLG&kf zkR+`K(zXu@DY&ZndF(Mp)}dXwFuoZLh?WDW)Med1YR>CscMePJ{R}4_qR{^M`Vvs! z2D|X^a1NcR-F@}#dx#4s4$DtxtE{oXu|^zSZ+7U%^_5kBY&@ss+-CxC3S)W%A?qc& z4sdK>o+#eEhMKxG{%Mq&T2iFw!6?%7NCnd9#!kkW_Ib^IDEs+pcZ0z*Khc9wE1(M~ zfgS(x^*#Meju$T@g;eVe{_;mB5M_`pZeWK0`ls-3bZ@Y;WHGd)u-8$x{H*Vbh|jo; ze8?<_W}L(j&~P3;J^yp_LA4GUG{-S#U@zFPqxHn(Y7FKB1oy$69$hN_20ScxPA=jT zL=)40TrUv&K`gQ7Fk=3A;uL0O47Zf*(sFiqjGP6g4>c#`LX4PB9d;!qC0tx$##c6q zDQ=M|pIRTCumI1KIv})#pjVL?+u7MkK@eM_t#Otf)l^e+-+k^=xNr)Va@yi&1ZQFi zh6TJI8*y|w^y;I(eNa}A4ha4f!VtH_t9cn2)!@8-iPU{}9W9;^m)miD9a*?jEC;kn zBKl2;g^q`ZN2-{%cF2ne1}$f$=N}v82_y(X({-SQh9)K=5)v;)ri#_n3?E|(5!st= zD#Bvo!><8pxNBZ=_%omP^t%cWp@tUy4S1AYreqmEAj{ao%57w)m|{fT_7mTrc!-b- zx)mJUv<{CwPxdo5*5RKnWg3D}w_&4Hhew$3>oynQrun3x?{)^3l_kqe(R^guq_Duq z7w_#eqnrPbNJxG#EerG!FsWJURzcka)Fc=w!-yZ*#CBV`sj5YMLV-@IB19(F@eTKPOE%-5w-YH+;W9Iqglt{>dQcX={f`b-1f6~IuMvH zy9=6R6MT^Y+)5QPZ3p5HJd+WeGy8GNiFw>7{>)$B{oU1+z7(zP;iJOS65&Gk8yl#p zn>PH<1L%M4w0#6HJoAPEd?@J~F(*F2+?cN3n(@B!;=0C*C_T@ORfi~>7#cQp??JRe z8u|Un*Ddo;e)CO~HXbW~aYZ&LIG!b%&uvXK-@3TIOV?jS%o1-82!lL5!P)_3tf2iv zxvHvaLwiCiq9{kA+Mm`L6m_E;o}M|dZ=cT`yPCG2tXhX#Twmnw|Ji!x{g|Tja4gse z&j^DuV|OdSvSV1{c>ewSDpPleRbZ3I4e?zL=+Up!5piH`WR04|GyaZOOBv1gTDIJt zhe$!>OTWC)1Zf!6Boa@ibC89mpIZMt&tzr}5c5i*i3e`eQR5uqh-)8329)jI-6z zH?RMsMq#Uo*V-RSJ`XOX^BGxLl8fJ5t`dlnjNe;?W5pZ-^VzvcYcgaux5$8o0;~|y z+=j_9;o@SBixt`)L{R2E`*dL2{FYZAcXO-0!b5Mmjr4w678o#k1M1)*6~J}A;@8{5 zgv3CMQV;N7R&(mlqDC0T@Eivg-oM*YQJi3&UEQbYy~f1#E+xLSiU2Z;Gxr zd(-thyD{g0)>=_kCR?>ENp|&x(D+$Pe*YX@m&;ebBais_AkVVux{0M1CjsE`^sTlh z6uJ(n6!KiQ0jtdjquuxdiaZ-smjSpU#9tw9dVxWM5 z<5F08Teph{)N(r<$7VE-n+ev-WR12z%qp4EopJB2=*SEzQW3tw3pxilRQo;-9ldo< z|MtV?(OW~uGB4CM#psyEsE+nsT42B5$ht#JjxB8CiPn;hO3l`enSot4#r(2ahH2*c zcNS(1GYm5`csx&CUY5NR%Ut*N?$zV>p#Zl0o1x5b)KFZ+?`+bwqrzUEvz9c7fX>hws8;_BEtQ^D#q zl8lF^mYd~SBTk=)IU1C2bgEHsT63jhHuMb_iMnu*E%LnDnatun^QQV#?&T+G&GsMK zZnMrj+Naj=X?I~)ZoXsEVJh~BT_+sFBT==~@FJN6L|3Ke^I{O?OjKKbpO2DdqR!?!&r|K~0CrVg>7(G>C{jl!QTg zuy(xf==JdF+jqXYouzzWy0yq9-)Zv^tCFg^&I)u#h}AKHbAgpVM|^G&`}n~8BICBj zEq1oHxa(1;R}s>5bZs`d{J~5{LZ%ESa^9-EYhB}W=%aIMkaJ4z7TVsxT_;>xV@Gy+ z9EzE)h@rZC4K-3Zaq|MOgtFerM%|-U}Z0rV-4;c)hH6{d%)ZxhyK9CP zT(LN_J%zSw*H^utv=t}eB(x4`#B<~~lMj{u9?TKj+CFZZA=CNRy68hq?(<`>(rdYe z_37j9P;Bry{nvb57-O7OugzicoC9j?1r1cuJtbSZQe1C!ha9+N`XeZ~p?0lMG2J^& zm9!5`cbYct`%U-x$E&?UvWZPW-_M>+iWvRLtQWRi^;p#Qw@##CN@0@*o0dCMt}W91 zX@a8>J8FbsB-hCe4nGX%glry<=?V^T7JY~f)p#rU`C@p)?r5JF@s?w8Y_ElEYY3l-o7z@bd!pZgBpXPFsIH#H6gv= zJGTP*R7BivkFjo3ak84PP#2q0JkHuNyQ=MX_9HXpYd_O;acbx>Ac?65mv{>G6hDk! zY)ZTJ&lgQlwZ1I6!sz_kZJn5hkDOQGUzKbHPKoAc2QlS>)XETGPJb_6XTxYjDImQWaGHVt0g)MqishHpXaQ&eJ`)@P~dW5)LXR)fk zX8!e!tYgtlvF@Wm@1O8%IR8~*ixMy5vk~ih&+@cbdOPb>8S}edmeGgpx2zxkX5c7m zL=8{fiX{NxV5Ttk?%j(;eR}QlMSZb_cw;n=>VZ@X+Miq3 z*TNzkw(6)} z_p)Z2$7N6D|CYKKeoR}Csy-xq+uAh80QG6@})ZLn<)Ws+=2UD9m~Xe6Z}eUQ29khk2v= zZn-TNZR;L$)pHjW)JJYiaT{!qEkTnX8ztCCO|8SQ{(yzy)%CYN^+7(7hCa?gxao(P zj&e%=YGY@WJ{i*;!8jP2@@wuUg$7AS>D!ITK%7pl~~XgxXUsD5K%5DQ~&zM0<4X+&c9cB=hBH z??Kt|>!#_1kyN65$NEra(+EF~r`wNw<`IAQ*yK^^E1rT4$9I@i_oMSJO7RvhU=3NP z^ipm-TKvo7uK%kf{%UsotDL_BcG`BPNy*l;KkSq1I~JdQ6-~5o{uZ#hnfa-n-K>jc z`wM60aG}p31nCo*`9ot?aC(mzY;BShHWINa%{pB|q7Z6}iLl;X=X=50GnZ zOC?e$q8`3S=SH1+BoOE`ez~;zq zdQK`dz;^6m&(yYSJ!+t!H`s9TZoiG+$;2&BIUlQZ1mBw7mmy)o63lfW!kcAsJkWALH#YlbCaM(7wU%p%TRQBH>=Pqme7ROr>%w5d;{dWJa=C1p%sdJ6vKt(A6 z0wOhxfQYCRlp#jBVuCCgVHq(vh+6hWhNu-8Au78+F|-yA!nmvBtpj#jK#J)k17YoMyl~jQn7^93IRvz ziC4qEm`cm2Ifc#pRSl>{kK$%esz~Ygx^>S_vAEJB?&C5Veszujuc-n=czhhl5B9^92(XIpXlhbsj^x47X4I}Sf=rEs`hf#iQ~an>io$Y!Lj zzNt(gkS)LNZfa4wK66!KhoB;}ZEdlaFJ*T`KvaSP-$~uDYEP8ij^F36;GD77 zO@Fg9&^=-Al*#fN)v*ds4+?2lGoZ6$p8?>=svvr$Qn+uNfE=8M#K%pqzwt;TyAQD- z&8GM_WQ=0O$nXTloVE=Eh7Oqo)fQ3z85Xtlc027%cAt=A`5NMckZ0%2j}1+LIBgp~ z^68G+&;*dtyTgziC9%z*Pb76n0D1*FconW?r4@lMQ=l0I-(F$HiG`XIh(; zXs}xyj}|Yg^8Q`V^;Hq2v8b;@5IS)vQK7Orh+?^gALo7F=~7JoYMJ?3{P*s^oLPza zz#7zV(c7xWCng?mX3Y0In_tn?s8jY2ZIjD8YIYwrM8htu%hxSV_V%>wxVvXGOF29> z++JoIWs`6%XIEDkrS%ETCi}O9@QWj(h`7u`euO6CYaA<_$a4c3C8n>TLBz@D3{7C@ ziD>xcy|df$eukZ>EgT{1>FwVD6wKL$#96(@=#ORWKOhFVJB)<zk{&J1I)5BwR&MqX>>rKHOJUjtVp#C#fkLN4C9_3tBURHLZz4@J@gg3G;RF@kY ze@xNn?q_~0#T*kl7Ni~U8jWN%=BT#tZS6C2XIjr~kJxD+E8ZHAHNxX3H1it_7(>zo zjY&K&cR3v%ok8fuBF8RwHS?Q6XD9vGMj0M!xkXFPtlrdOI9$BPI&WQtx^#JI zs4_3}WVzU^+rzw5<&Dxx$5Pp(Sa&n}9b_}95EcuEGT1AU6}poNAg`b;i~cx51Sq0m zm})cI4}N%6u&xCAOb8yRV?z^90~+k~-XWisttu<4o)acyVMIfG#=)%^>SjJ!i(vSjh#u z*PO0_3l(_!lfSzbx$3er^O4k)RIz+?p}LG;kcC5iG4F~)TJNwXU<3)l^Fxb`0k3mvNjm(4j|NArNKJT*R*AEKHuyk7E(eIcczVy!1O2?G-8B-eGslo$c*k zAa#qEJ6q1mrQ(6o?8;lcW&ZYt!Q$T5PGEw~KGec3XmACR(2gu5nO6kLhryEj_(!m$ z$At9u_wQ~hkTRRZ6VHsA2o^fjIs2c$UQZoZ+n2Lj6-H#Sd+>KY89gS zfZ`+`#W45r5E&1ii-W#2Ody@Z^WQz%gG9ew2~1CD;Sb8{pR|#DtZSs}6XpIOOPn&j zr^XAbVgCJfui z$jXWXH(Klbg04&_O%`J{`nHT5YxoU$iYwoPk|G?nes1pGQq2%g2mp!7gE&hds3ZX@ zo{J`CBo}1g!6uiD7D}|Bxuu2ZnTgn(2Xs@YtsUNjE1?z71qr*$}*(P+o|GL3r*>KVv$! z!N2e`?sWnLsTl-_*Wqvf1pY4Zz851XNFP$3_i0By)BSAh5%jdVCt$Y z^0l$wP4q+hk?(io;GSeO21M%cr}b#~o>!*7#nzosO(44>Lj4ioY)g?Gej%~0;KcNi z2M!&qJa(!@4&I6Z;<}>%D~a!WI;J5&%!L-3j8!3%rM3l9mvBU;D4{0T1Pr{MX)^v{ zsmZwC5?D|ZcUHFo=!3=vuRt~Plq9ap()L|sRefR|?F*IG?AEKtc)g@c^wT{hx@ zAO8w|cp<1^V(*3z{>}TBV3x+^iM^EsAE43#ThsgcESMj~ZsXHiNtJ)+BcP%*4SK>k zsI&(5aouNgxPnPs!x8%b=Bhp!*Y+kmJ+G>+r0?q&Ui=b+m~|xKyS=S=Ru?>rkU!BM zb+NM!c4pL0)x$I#(%OCi#I8k*EyFAx_^^4!|j?w+l?E=Gn6WirSoYW zN)X>Ve=bSD#L<<=)_{^b_}<1}$?XFuWq(Ps$PUjlDmZ{?Qij@ib0`5W1gI0h3=o=z zIsr;)`W?|Xe=;}!^Gjd|C2`@HKGn{L?z@l$-9E!55PBn3lksBrKUq4|M&m?9(~J)p zU;T1xt{T^Zv1Xb;`xhs)Ixmz?K4Tm{diN{vNMMpk+d*DKU`%AmI~ltOKSW;tmtT55 YHA^C;Lwn_zj0AaETi7$IHt&x8Cjr@a%m4rY literal 0 HcmV?d00001 From 89723cb20aa77985967ef75015d26fdc077da34e Mon Sep 17 00:00:00 2001 From: minor7295 <44902090+minor7295@users.noreply.github.com> Date: Sun, 16 Nov 2025 23:03:59 +0900 Subject: [PATCH 04/12] =?UTF-8?q?[volume-3]=20=EB=8F=84=EB=A9=94=EC=9D=B8?= =?UTF-8?q?=20=EB=AA=A8=EB=8D=B8=EB=A7=81=20(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Feature/refactor user (#7) * refactor: 사용자 및 포인트에 대한 단위 테스트 수정 * refactor: point도메인을 user 도메인으로 통합 * refactor: user 도메인 관련 통합테스트 로직 수정 * refactor: user 도메인 관련 서비스 로직 구현 * refactor: user 도메인 관련 E2E 테스트 로직 수정 * refactor: user 도메인 관련 API 구현 * Feature/brand product (#8) * test: product 도메인 단위 테스트 추가 * feat: product, brand 도메인 구현 * Feature/like (#9) * feat: like 도메인 구현 * test: like에 대한 단위 테스트 코드 추가 * feat: like에 대한 서비스 로직 추가 * Feature/order (#10) * Feature/like (#9) * feat: like 도메인 구현 * test: like에 대한 단위 테스트 코드 추가 * feat: like에 대한 서비스 로직 추가 * test: order 도메인에 대한 단위 테스트 추가 * feat: order 도메인 구현 * test: 주문에 대한 통합 테스트 코드 추가 * feat: 주문에 대한 서비스 로직 구현 * Feature/product detail (#11) * test: 상품 정보에 대한 도메인 서비스 테스트 코드 추가 * feat: 상품 정보 도메인 서비스 구현 * feat: 상품 정보 조회 서비스 로직 구현 * feat: 테이블명에 `이 포함되는 경우에도 DatabaseCleanUp로직에서 오류 발생하지 않도록 수정 (#12) * refactor: 설계 문서 업데이트 및 구현 내용 반영 (#13) --- .docs/design/01-requirements.md | 34 +- .docs/design/02-sequence-diagrams.md | 227 ++++++----- .docs/design/03-class-diagram.md | 385 ++++++++++++++---- .docs/design/04-erd.md | 23 +- .../catalog/CatalogProductFacade.java | 81 ++++ .../application/catalog/ProductInfo.java | 12 + .../application/catalog/ProductInfoList.java | 46 +++ .../loopers/application/like/LikeFacade.java | 180 ++++++++ .../pointwallet/PointWalletFacade.java | 83 ++++ .../application/purchasing/OrderInfo.java | 46 +++ .../purchasing/OrderItemCommand.java | 22 + .../application/purchasing/OrderItemInfo.java | 34 ++ .../purchasing/PurchasingFacade.java | 202 +++++++++ .../application/signup/SignUpFacade.java | 41 +- .../application/userinfo/UserInfoFacade.java | 69 ++++ .../java/com/loopers/domain/brand/Brand.java | 47 +++ .../loopers/domain/brand/BrandRepository.java | 31 ++ .../java/com/loopers/domain/like/Like.java | 53 +++ .../loopers/domain/like/LikeRepository.java | 57 +++ .../java/com/loopers/domain/order/Order.java | 131 ++++++ .../com/loopers/domain/order/OrderItem.java | 56 +++ .../loopers/domain/order/OrderRepository.java | 39 ++ .../com/loopers/domain/order/OrderStatus.java | 15 + .../java/com/loopers/domain/point/Point.java | 84 ---- .../loopers/domain/point/PointRepository.java | 30 -- .../loopers/domain/point/PointService.java | 68 ---- .../com/loopers/domain/product/Product.java | 160 ++++++++ .../loopers/domain/product/ProductDetail.java | 53 +++ .../domain/product/ProductDetailService.java | 53 +++ .../domain/product/ProductRepository.java | 51 +++ .../java/com/loopers/domain/user/Point.java | 98 +++++ .../java/com/loopers/domain/user/User.java | 41 +- .../com/loopers/domain/user/UserService.java | 16 +- .../brand/BrandJpaRepository.java | 11 + .../brand/BrandRepositoryImpl.java | 28 ++ .../like/LikeJpaRepository.java | 60 +++ .../like/LikeRepositoryImpl.java | 45 ++ .../order/OrderJpaRepository.java | 15 + .../order/OrderRepositoryImpl.java | 36 ++ .../point/PointJpaRepository.java | 33 -- .../point/PointRepositoryImpl.java | 39 -- .../product/ProductJpaRepository.java | 43 ++ .../product/ProductRepositoryImpl.java | 77 ++++ .../PointWalletV1Controller.java} | 37 +- .../PointWalletV1Dto.java} | 19 +- .../api/signup/SignUpV1Controller.java | 19 +- .../api/userinfo/UserInfoV1Controller.java | 20 +- .../api/userinfo/UserInfoV1Dto.java | 19 +- .../application/like/LikeFacadeTest.java | 156 +++++++ .../PointWalletFacadeIntegrationTest.java | 115 ++++++ .../purchasing/PurchasingFacadeTest.java | 242 +++++++++++ .../signup/SignUpFacadeIntegrationTest.java} | 65 +-- .../UserInfoFacadeIntegrationTest.java | 80 ++++ .../com/loopers/domain/order/OrderTest.java | 92 +++++ .../domain/order/OrderTestFixture.java | 39 ++ .../point/PointServiceIntegrationTest.java | 94 ----- .../com/loopers/domain/point/PointTest.java | 37 -- .../product/ProductDetailServiceTest.java | 100 +++++ .../loopers/domain/product/ProductTest.java | 110 +++++ .../domain/product/ProductTestFixture.java | 33 ++ .../com/loopers/domain/user/UserTest.java | 6 +- .../loopers/domain/user/UserTestFixture.java | 2 + ...Test.java => PointWalletV1ApiE2ETest.java} | 28 +- .../interfaces/api/UserInfoV1ApiE2ETest.java | 2 +- .../com/loopers/utils/DatabaseCleanUp.java | 7 +- 65 files changed, 3519 insertions(+), 758 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogProductFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/catalog/ProductInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/catalog/ProductInfoList.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/pointwallet/PointWalletFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/purchasing/OrderInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/purchasing/OrderItemCommand.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/purchasing/OrderItemInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/purchasing/PurchasingFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/userinfo/UserInfoFacade.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/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/order/Order.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/point/PointRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDetail.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDetailService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/Point.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/like/LikeJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointJpaRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java rename apps/commerce-api/src/main/java/com/loopers/interfaces/api/{point/PointsV1Controller.java => pointwallet/PointWalletV1Controller.java} (54%) rename apps/commerce-api/src/main/java/com/loopers/interfaces/api/{point/PointsV1Dto.java => pointwallet/PointWalletV1Dto.java} (61%) create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/pointwallet/PointWalletFacadeIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeTest.java rename apps/commerce-api/src/test/java/com/loopers/{domain/user/UserServiceIntegrationTest.java => application/signup/SignUpFacadeIntegrationTest.java} (60%) create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/userinfo/UserInfoFacadeIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTestFixture.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/point/PointTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductDetailServiceTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTestFixture.java rename apps/commerce-api/src/test/java/com/loopers/interfaces/api/{PointsV1ApiE2ETest.java => PointWalletV1ApiE2ETest.java} (88%) diff --git a/.docs/design/01-requirements.md b/.docs/design/01-requirements.md index ddca484ea..bd42c9782 100644 --- a/.docs/design/01-requirements.md +++ b/.docs/design/01-requirements.md @@ -56,7 +56,7 @@ | **액터** | User | | **사전조건** | • 요청 시 상품 ID가 파라미터로 전달된다.
• (선택) 사용자가 로그인된 상태이다.| | **기본 시나리오** | 1) 사용자는 상품 목록이나 링크를 통해 상세 페이지에 접근할 수 있다.
2) 시스템은 상품명, 가격, 재고, 좋아요 수, 브랜드 정보를 반환한다. | -| **예외 시나리오** | • 상품 ID가 존재하지 않거나 삭제된 상품 혹은 인 경우 404 반환한다.
• 상품이 비공개 상태일 경우 403 반환한다.
• 상품 ID 형식이 잘못된 경우 400 반환한다. | +| **예외 시나리오** | • 상품 ID가 존재하지 않거나 삭제된 상품인 경우 "상품을 찾을 수 없습니다" 메시지 반환
• 상품이 비공개 상태일 경우 "접근할 수 없는 상품입니다" 메시지 반환
• 상품 ID 형식이 잘못된 경우 "잘못된 요청입니다" 메시지 반환 | | **후조건** | 상품 상세 정보가 표시된다. | --- @@ -66,10 +66,10 @@ |------|------| | **액터** | User | | **사전조건** | • 사용자는 로그인 상태이다. | -| **기본 시나리오** | 1) 사용자가 상세/목록 페이지에서 좋아요 버튼을 클릭한다.
2) 시스템은 해당 사용자의 좋아요 등록/취소를 처리한다.
4) 현재 상태(liked=true/false)와 총 좋아요 수를 반환한다. | -| **예외 시나리오** | • 중복 요청 시 동일 결과 반환(멱등 보장)
• 데이터 충돌 시 요청 재시도 처리 | +| **기본 시나리오** | 1) 사용자가 상세/목록 페이지에서 좋아요 버튼을 클릭한다.
2) 시스템은 해당 사용자의 좋아요 등록/취소를 처리한다.
3) 현재 좋아요 상태(좋아요 함/안 함)와 총 좋아요 수를 반환한다. | +| **예외 시나리오** | • 이미 좋아요한 상품에 다시 좋아요 요청 시 현재 상태 유지 (중복 방지)
• 좋아요하지 않은 상품에 취소 요청 시 현재 상태 유지 (중복 방지)
• 시스템 오류 시 요청 재시도 가능 | | **후조건** | 좋아요 상태가 변경되고, 상품 목록/상세 정보에 반영된다. | -| **비고** | (user_id, product_id) UNIQUE 제약으로 중복 방지 | +| **비고** | 동일 사용자는 동일 상품에 대해 하나의 좋아요만 등록 가능 (중복 방지) | --- @@ -78,17 +78,17 @@ |------|------| | **액터** | User | | **사전조건** | 상품이 존재하고 재고 및 포인트가 충분해야 함 | -| **기본 시나리오** | 1) 사용자가 여러 상품을 선택해 주문을 요청한다.
2) OrderService는 ProductService에 재고 확인을 요청한다.
3) OrderService는 PointService에 결제 금액만큼 포인트 차감을 요청한다.
4) 모든 검증이 통과되면 주문 정보를 생성한다.
5) 주문 정보를 외부 시스템으로 전송한다.
6) 주문 생성 결과(주문번호, 결제금액, 잔여 포인트)를 반환한다. | -| **예외 시나리오** | • 포인트 부족 → “결제 실패” 응답 및 충전 안내
• 재고 부족 → “주문 불가 상품” 메시지 반환
• 외부 전송 실패 → 주문은 저장되지만 상태를 “보류(PENDING)”로 표시 | +| **기본 시나리오** | 1) 사용자가 여러 상품을 선택해 주문을 요청한다.
2) 시스템은 주문할 상품의 재고가 충분한지 확인한다.
3) 시스템은 사용자의 포인트 잔액이 주문 금액보다 충분한지 확인한다.
4) 모든 검증이 통과되면 주문 정보를 생성하고 재고를 차감한다.
5) 주문 금액만큼 포인트를 차감하고 주문 상태를 "완료"로 변경한다.
6) 주문 생성 결과(주문번호, 결제금액, 잔여 포인트)를 사용자에게 반환한다. | +| **예외 시나리오** | • 포인트 부족 → "결제 실패" 응답 및 포인트 충전 안내 메시지 표시
• 재고 부족 → "주문 불가 상품" 메시지 반환 및 재고 부족 안내
• 시스템 오류 → 주문은 저장되지만 상태를 "처리 중"으로 표시하여 나중에 재처리 가능 | | **후조건** | 포인트와 재고가 차감되고, 주문 내역이 생성된다. | -| **비고** | UC-04-1~3을 포함하며, 트랜잭션으로 처리해야 함. | +| **비고** | UC-04-1~3을 포함하며, 모든 단계가 성공해야 주문이 완료되고, 중간에 실패하면 모든 변경사항이 취소됨. | #### UC-04-1 포인트 결제 처리 | 항목 | 내용 | |------|------| | **액터** | System | | **기능 요약** | 사용자의 포인트 잔액을 검증 후 주문 금액만큼 차감한다. | -| **예외 시나리오** | 잔액 부족 시 “Insufficient Points” 오류 반환 | +| **예외 시나리오** | 포인트 잔액이 주문 금액보다 부족한 경우 "포인트 부족" 오류 메시지 반환 | | **후조건** | 사용자의 포인트 잔액이 감소한다. | #### UC-04-2 재고 차감 처리 @@ -104,8 +104,8 @@ |------|------| | **액터** | System / External Service | | **기능 요약** | 생성된 주문을 외부 시스템으로 전송한다. | -| **예외 시나리오** | 외부 API 응답 지연 시 재시도 또는 비동기 큐에 저장 | -| **후조건** | 주문 상태가 “전송 완료”로 변경된다. | +| **예외 시나리오** | 외부 시스템 응답 지연 또는 오류 시 자동 재시도 또는 나중에 처리할 수 있도록 대기 목록에 저장 | +| **후조건** | 주문 정보가 외부 시스템에 전송되고 주문 상태가 "전송 완료"로 변경된다. | --- @@ -126,7 +126,7 @@ | **액터** | User | | **사전조건** | 주문이 존재하고 해당 사용자의 주문이어야 함 | | **기본 시나리오** | 1) 사용자는 목록이나 링크를 통해 상세 페이지에 접근할 수 있다.
2) 시스템은 해당 주문의 상세 정보(주문번호, 상품 목록, 총액, 상태 등)를 반환한다. | -| **예외 시나리오** | • 주문이 존재하지 않을 경우 404 반환
• 다른 사용자의 주문을 조회하려는 경우 403 반환
• 주문 ID 형식이 잘못된 경우 400 반환 | +| **예외 시나리오** | • 주문이 존재하지 않을 경우 "주문을 찾을 수 없습니다" 메시지 반환
• 다른 사용자의 주문을 조회하려는 경우 "접근할 수 없는 주문입니다" 메시지 반환
• 주문 ID 형식이 잘못된 경우 "잘못된 요청입니다" 메시지 반환 | | **후조건** | 주문 상세 정보가 화면에 표시된다. | --- @@ -134,9 +134,9 @@ ## ⚙️ 5. 비기능 요구사항 | 항목 | 내용 | |------|------| -| **식별 방식** | 모든 API는 `X-USER-ID` 헤더로 사용자 식별 | -| **동시성 제어** | 포인트 차감 및 재고 차감은 트랜잭션 단위로 동작 | -| **멱등성 보장** | 좋아요, 주문 요청은 멱등하게 처리되어야 함 | -| **성능 요구사항** | 상품 목록 조회는 페이지네이션(page/size) 적용 | -| **확장성 고려** | 좋아요 데이터를 추천/랭킹 기능으로 확장 가능 | -| **일관성 보장** | 외부 시스템 연동 실패 시 재시도 로직 또는 보류 상태 유지 | +| **식별 방식** | 모든 요청은 사용자 ID를 통해 사용자를 식별 | +| **데이터 일관성** | 주문 처리 시 포인트 차감과 재고 차감은 함께 성공하거나 함께 취소됨 (부분 성공 방지) | +| **중복 방지** | 좋아요, 주문 요청은 동일한 요청을 여러 번 보내도 같은 결과가 나오도록 처리 | +| **성능 요구사항** | 상품 목록 조회 시 한 번에 보여줄 수 있는 상품 수를 제한하여 빠른 응답 제공 | +| **확장성 고려** | 좋아요 데이터를 활용하여 상품 추천이나 인기 랭킹 기능으로 확장 가능 | +| **오류 처리** | 외부 시스템 연동 실패 시 자동 재시도 또는 나중에 처리할 수 있도록 보류 상태로 관리 | diff --git a/.docs/design/02-sequence-diagrams.md b/.docs/design/02-sequence-diagrams.md index ebc9b746d..d829685a5 100644 --- a/.docs/design/02-sequence-diagrams.md +++ b/.docs/design/02-sequence-diagrams.md @@ -5,159 +5,170 @@ --- ## 🎯 개요 -이 문서는 **UC-03 (좋아요)** 와 **UC-04 (주문)** 의 핵심 시나리오를 시퀀스 다이어그램으로 시각화한다. -핵심 비즈니스 로직 흐름만 표현하여 가독성을 높였다. +이 문서는 **UC-03 (좋아요)** 와 **UC-04 (주문)** 의 핵심 시나리오를 비즈니스 관점에서 시각화한다. +기술적인 세부사항보다는 **사용자와 시스템 간의 상호작용 흐름**을 중심으로 설명하여, 비개발자도 쉽게 이해할 수 있도록 작성되었다. --- ## ❤️ UC-03 상품 좋아요 등록/취소 -> **멱등성 보장**: 이미 좋아요한 상태에서 다시 좋아요 요청 시, 추가 작업 없이 현재 상태(`liked: true`)를 반환하여 멱등성을 보장합니다. - ### 1️⃣ 좋아요 등록 +**시나리오**: 사용자가 상품에 좋아요를 누르는 과정 + ```mermaid sequenceDiagram autonumber - actor User - participant Controller - participant Facade - participant ProductDisplay - - User->>Controller: POST /api/v1/like/products/{productId} - Controller->>Facade: likeProduct(userId, productId) - Facade->>ProductDisplay: findById(productId) - ProductDisplay-->>Facade: ProductDisplay - - alt 이미 좋아요 함 - ProductDisplay-->>Facade: likeCount - Facade-->>Controller: {liked: true, likeCount} - else 좋아요 없음 - Facade->>Facade: create Like(userId, productId) - Facade->>ProductDisplay: like(Like) - ProductDisplay->>ProductDisplay: likes.add(Like) - ProductDisplay-->>Facade: likeCount - Facade-->>Controller: {liked: true, likeCount} + actor 사용자 + participant 웹사이트 + participant 좋아요시스템 + participant 상품정보 + + 사용자->>웹사이트: 좋아요 버튼 클릭 + 웹사이트->>좋아요시스템: 좋아요 요청 전송 + + alt 이미 좋아요한 상품인 경우 + 좋아요시스템->>좋아요시스템: 이미 좋아요 상태 확인 + 좋아요시스템-->>웹사이트: 좋아요 상태 유지 (변경 없음) + else 처음 좋아요하는 경우 + 좋아요시스템->>상품정보: 상품 존재 여부 확인 + 상품정보-->>좋아요시스템: 상품 정보 반환 + 좋아요시스템->>좋아요시스템: 좋아요 기록 저장 + 좋아요시스템->>상품정보: 좋아요 수 업데이트 + 상품정보-->>좋아요시스템: 업데이트 완료 + 좋아요시스템-->>웹사이트: 좋아요 등록 완료 end - Controller-->>User: 응답 + + 웹사이트-->>사용자: 좋아요 상태 표시 업데이트 ``` +**설명**: +- 사용자가 좋아요 버튼을 클릭하면, 시스템은 먼저 해당 사용자가 이미 좋아요를 눌렀는지 확인합니다. +- 이미 좋아요한 경우: 추가 작업 없이 현재 상태를 유지합니다 (중복 방지). +- 처음 좋아요하는 경우: 좋아요 기록을 저장하고 상품의 좋아요 수를 증가시킵니다. + ### 2️⃣ 좋아요 취소 -> **멱등성 보장**: 좋아요하지 않은 상태에서 취소 요청 시, 추가 작업 없이 현재 상태(`liked: false`)를 반환하여 멱등성을 보장합니다. +**시나리오**: 사용자가 좋아요를 취소하는 과정 ```mermaid sequenceDiagram autonumber - actor User - participant Controller - participant Facade - participant ProductDisplay - - User->>Controller: DELETE /api/v1/like/products/{productId} - Controller->>Facade: unlikeProduct(userId, productId) - Facade->>ProductDisplay: findById(productId) - ProductDisplay-->>Facade: ProductDisplay - - alt 좋아요 없음 - ProductDisplay-->>Facade: likeCount - Facade-->>Controller: {liked: false, likeCount} - else 이미 좋아요 함 - Facade->>Facade: find Like(userId, productId) - Facade->>ProductDisplay: unlike(Like) - ProductDisplay->>ProductDisplay: likes.remove(Like) - ProductDisplay-->>Facade: likeCount - Facade-->>Controller: {liked: false, likeCount} + actor 사용자 + participant 웹사이트 + participant 좋아요시스템 + participant 상품정보 + + 사용자->>웹사이트: 좋아요 취소 버튼 클릭 + 웹사이트->>좋아요시스템: 좋아요 취소 요청 전송 + + alt 좋아요하지 않은 상품인 경우 + 좋아요시스템->>좋아요시스템: 좋아요 기록 없음 확인 + 좋아요시스템-->>웹사이트: 좋아요 없음 상태 유지 (변경 없음) + else 이미 좋아요한 경우 + 좋아요시스템->>상품정보: 상품 존재 여부 확인 + 상품정보-->>좋아요시스템: 상품 정보 반환 + 좋아요시스템->>좋아요시스템: 좋아요 기록 삭제 + 좋아요시스템->>상품정보: 좋아요 수 감소 + 상품정보-->>좋아요시스템: 업데이트 완료 + 좋아요시스템-->>웹사이트: 좋아요 취소 완료 end - Controller-->>User: 응답 + + 웹사이트-->>사용자: 좋아요 상태 표시 업데이트 ``` +**설명**: +- 사용자가 좋아요 취소 버튼을 클릭하면, 시스템은 해당 사용자가 좋아요를 눌렀는지 확인합니다. +- 좋아요하지 않은 경우: 추가 작업 없이 현재 상태를 유지합니다 (중복 방지). +- 좋아요한 경우: 좋아요 기록을 삭제하고 상품의 좋아요 수를 감소시킵니다. + --- ## 🛒 UC-04 주문 생성 -### 1️⃣ 주문 생성 기본 흐름 +### 1️⃣ 주문 생성 기본 흐름 (성공 케이스) + +**시나리오**: 사용자가 여러 상품을 선택하여 주문을 생성하는 과정 ```mermaid sequenceDiagram autonumber - actor User - participant Controller - participant Facade - participant ProductOrder - participant Point - participant Order - - User->>Controller: POST /api/v1/orders - Controller->>Facade: createOrder(userId, items, totalAmount) + actor 사용자 + participant 쇼핑몰 + participant 주문시스템 + participant 상품재고 + participant 포인트계정 + participant 주문내역 + + 사용자->>쇼핑몰: 주문하기 버튼 클릭 + 쇼핑몰->>주문시스템: 주문 요청 전송 (상품 목록, 수량) - Note over Facade: 트랜잭션 시작 + Note over 주문시스템: 주문 처리 시작 (모든 작업이 성공하거나 모두 취소됨) - Facade->>ProductOrder: checkStock(quantity) - ProductOrder-->>Facade: stock available + 주문시스템->>상품재고: 주문할 상품의 재고 확인 + 상품재고-->>주문시스템: 재고 충분함 - Facade->>Point: checkBalance(totalAmount) - Point-->>Facade: balance sufficient + 주문시스템->>주문시스템: 주문 총액 계산 - Facade->>ProductOrder: decreaseStock(quantity) - ProductOrder-->>Facade: success + 주문시스템->>포인트계정: 포인트 잔액 확인 + 포인트계정-->>주문시스템: 포인트 충분함 - Facade->>Point: deduct(totalAmount) - Point-->>Facade: success + 주문시스템->>상품재고: 주문 수량만큼 재고 차감 + 상품재고-->>주문시스템: 재고 차감 완료 - Facade->>Order: create(List~ProductOrder~, Point) - Order-->>Facade: Order (status: PENDING) + 주문시스템->>포인트계정: 주문 금액만큼 포인트 차감 + 포인트계정-->>주문시스템: 포인트 차감 완료 - Note over Facade: 트랜잭션 커밋 - Facade-->>Controller: Order - Controller-->>User: 주문 접수 완료 + 주문시스템->>주문내역: 주문 정보 저장 (상태: 완료) + 주문내역-->>주문시스템: 주문 저장 완료 + + Note over 주문시스템: 모든 작업 성공적으로 완료 + + 주문시스템-->>쇼핑몰: 주문 완료 (주문번호, 결제금액, 잔여 포인트) + 쇼핑몰-->>사용자: 주문 완료 화면 표시 ``` -### 2️⃣ 외부 전송 성공 +**설명**: +1. 사용자가 주문하기 버튼을 클릭하면, 주문 시스템이 주문 처리를 시작합니다. +2. **재고 확인**: 주문할 상품의 재고가 충분한지 확인합니다. +3. **포인트 확인**: 사용자의 포인트 잔액이 주문 금액보다 충분한지 확인합니다. +4. **재고 차감**: 재고가 충분하면 주문 수량만큼 재고를 차감합니다. +5. **포인트 차감**: 포인트가 충분하면 주문 금액만큼 포인트를 차감합니다. +6. **주문 저장**: 주문 정보를 저장하고 상태를 "완료"로 설정합니다. +7. **결과 반환**: 주문 번호, 결제 금액, 잔여 포인트를 사용자에게 반환합니다. -```mermaid -sequenceDiagram - autonumber - participant Facade - participant ExternalService - participant Order +**중요**: 모든 단계가 성공해야 주문이 완료됩니다. 중간에 실패하면 모든 변경사항이 취소됩니다 (예: 재고 차감 후 포인트 부족 시 재고도 원복). - Note over Facade: 주문 생성 완료 - - Facade->>ExternalService: sendOrder(order) - ExternalService-->>Facade: success - - Facade->>Order: complete() - Order->>Order: status = COMPLETED - - Facade-->>User: 주문 완료 -``` +### 2️⃣ 주문 실패 시나리오 -### 3️⃣ 외부 전송 실패 (PENDING 상태 유지) +**시나리오**: 재고 부족 또는 포인트 부족으로 주문이 실패하는 경우 ```mermaid sequenceDiagram autonumber - participant Facade - participant ExternalService - participant Order - - Note over Facade: 주문 생성 완료 - - Facade->>ExternalService: sendOrder(order) - ExternalService-->>Facade: error - - Note over Facade: 주문은 저장되지만 PENDING 상태 유지 - - Facade->>Order: 상태 유지 - Order->>Order: status = PENDING - - Note over Facade: 재시도 로직 또는 비동기 큐에 저장 - - Facade-->>User: 주문 접수 완료 (외부 전송 보류) + actor 사용자 + participant 쇼핑몰 + participant 주문시스템 + participant 상품재고 + participant 포인트계정 + + 사용자->>쇼핑몰: 주문하기 버튼 클릭 + 쇼핑몰->>주문시스템: 주문 요청 전송 + + alt 재고 부족인 경우 + 주문시스템->>상품재고: 주문할 상품의 재고 확인 + 상품재고-->>주문시스템: 재고 부족 (요청 수량 > 현재 재고) + 주문시스템-->>쇼핑몰: 주문 실패 (재고 부족) + 쇼핑몰-->>사용자: 재고 부족 안내 메시지 표시 + else 포인트 부족인 경우 + 주문시스템->>상품재고: 재고 확인 (성공) + 주문시스템->>포인트계정: 포인트 잔액 확인 + 포인트계정-->>주문시스템: 포인트 부족 (주문 금액 > 잔액) + 주문시스템-->>쇼핑몰: 주문 실패 (포인트 부족) + 쇼핑몰-->>사용자: 포인트 부족 안내 및 충전 안내 메시지 표시 + end ``` -### 💬 예외 시나리오 -- **포인트 부족**: Point.deduct()에서 예외 발생 → 트랜잭션 롤백 -- **재고 부족**: ProductOrder.decreaseStock()에서 예외 발생 → 트랜잭션 롤백 -- **외부 전송 실패**: 주문은 저장되지만 상태를 PENDING으로 유지하여 재시도 가능 +**설명**: +- **재고 부족**: 주문하려는 상품의 재고가 부족하면 주문이 실패합니다. 이 경우 아무것도 차감되지 않습니다. +- **포인트 부족**: 포인트 잔액이 주문 금액보다 부족하면 주문이 실패합니다. 재고는 이미 확인했지만, 포인트 부족으로 인해 주문이 취소되므로 재고도 차감되지 않습니다. diff --git a/.docs/design/03-class-diagram.md b/.docs/design/03-class-diagram.md index 2eaeac428..a087ad43e 100644 --- a/.docs/design/03-class-diagram.md +++ b/.docs/design/03-class-diagram.md @@ -5,60 +5,162 @@ ## 🎯 개요 본 문서는 도메인별 행위에 맞춰 설계된 클래스 다이어그램을 정의한다. +각 도메인을 독립적인 Aggregate로 분리하여 일관성 경계를 명확히 한다. ### 설계 원칙 - **도메인 중심 설계 (DDD)**: 각 도메인이 자신의 책임과 행위를 명확히 가진다 -- **도메인 분리**: 동일한 테이블이라도 행위와 책임이 다르면 별도 도메인으로 분리한다 - - 예: `product` 테이블이 하나라도 `ProductDisplay`(표시/조회)와 `ProductOrder`(주문)로 분리 +- **Aggregate 분리**: 각 도메인을 독립적인 Aggregate로 분리하여 일관성 경계를 명확히 한다 + - 예: User, Product, Order, Like, Brand를 각각 독립적인 Aggregate로 구성 - **행위 중심**: 데이터 구조가 아닌 도메인의 행위와 책임을 우선한다 +- **Value Object 활용**: 불변 값 객체(OrderItem, Point)를 활용하여 도메인 로직을 캡슐화한다 --- -## 🧩 ProductDisplay 도메인 클래스 구조 +## 📦 Aggregate 구분 다이어그램 (전체 개요) + +```mermaid +graph TB + subgraph "User Aggregate" + User[User
Aggregate Root] + Point[Point
Value Object] + User -->|embeds| Point + end + + subgraph "Product Aggregate" + Product[Product
Aggregate Root] + end + + subgraph "Order Aggregate" + Order[Order
Aggregate Root] + OrderItem[OrderItem
Value Object] + OrderStatus[OrderStatus
Enum] + Order -->|contains| OrderItem + Order -->|uses| OrderStatus + end + + subgraph "Brand Aggregate" + Brand[Brand
Aggregate Root] + end + + subgraph "Like Aggregate" + Like[Like
Aggregate Root] + end + + Order -.->|references by ID| User + Order -.->|references by ID| Product + Product -.->|references by ID| Brand + Like -.->|references by ID| User + Like -.->|references by ID| Product + + style User fill:#e1f5ff + style Product fill:#e1f5ff + style Order fill:#e1f5ff + style Brand fill:#e1f5ff + style Like fill:#e1f5ff + style Point fill:#fff4e1 + style OrderItem fill:#fff4e1 + style OrderStatus fill:#f0f0f0 +``` + +**Aggregate 경계 설명**: +- 각 Aggregate는 독립적인 일관성 경계를 가집니다. +- Aggregate 간 참조는 ID를 통해서만 이루어집니다 (직접 참조 금지). +- 하나의 트랜잭션은 하나의 Aggregate만 수정해야 합니다. +- 여러 Aggregate 간 협력이 필요한 경우 Application Service (Facade)에서 조율합니다. + +--- + +## 👤 User Aggregate + +### 클래스 다이어그램 ```mermaid classDiagram - %% 엔티티 (BaseEntity 상속) - class Brand { + class User { +Long id - +String name + +String userId + +String email + +LocalDate birthDate + +Gender gender + +Point point + +receivePoint(Point) + +deductPoint(Point) + } + + class Point { + +Long value + +add(Point) Point + +subtract(Point) Point + } + + class Gender { + <> + MALE + FEMALE } - class ProductDisplay { + User "1" *-- "1" Point : embeds + User "1" --> "1" Gender : uses +``` + +### 클래스 설명 + +| 클래스 | 타입 | 책임 | +|---------|------|------| +| **User** | Aggregate Root | 사용자 정보 관리 및 포인트 차감/충전 처리 | +| **Point** | Value Object | 포인트 값과 연산 로직을 캡슐화 (User에 Embedded) | +| **Gender** | Enum | 사용자 성별 정보 | + +### 주요 특징 +- `Point`는 `User`에 Embedded된 Value Object로, User의 생명주기와 함께 관리됩니다. +- User는 자신의 포인트를 직접 관리합니다 (`receivePoint()`, `deductPoint()`). +- User 생성 시 Point가 함께 초기화됩니다. + +--- + +## 🛍 Product Aggregate + +### 클래스 다이어그램 + +```mermaid +classDiagram + + class Product { +Long id +String name - +int price - +Brand brand - +Set~Like~ likes - +like(Like) - +unlike(Like) - +getLikeCount() int + +Integer price + +Integer stock + +Long brandId + +decreaseStock(quantity) + +increaseStock(quantity) } - class Like { + class Brand { +Long id - +Long userId - +Long productId + +String name } - %% 관계 설정 - Brand "1" --> "*" ProductDisplay : has - ProductDisplay "1" --> "*" Like : has + Product "*" ..> "1" Brand : references by ID ``` ---- +### 클래스 설명 + +| 클래스 | 타입 | 책임 | +|---------|------|------| +| **Product** | Aggregate Root | 상품 정보 및 재고 관리 (재고 차감/증가 처리) | +| **Brand** | 외부 Aggregate | 브랜드 정보 (Product가 ID로만 참조) | -## 📦 클래스 간 역할 설명 +### 주요 특징 +- Product는 Brand를 ID로만 참조하며, Brand는 독립적인 Aggregate입니다. +- 재고 관리 로직을 Product 내부에서 처리합니다 (`decreaseStock()`, `increaseStock()`). +- 주문 처리 시 재고 차감/증가가 발생합니다. -| 클래스 | 책임 | 도메인 분리 이유 | -|---------|------|-----------------| -| **Brand** | 브랜드 메타 정보 보유 | 브랜드 정보는 표시와 주문 모두에서 공통으로 사용되지만, 독립적인 도메인으로 관리 | -| **ProductDisplay** | 상품 표시/조회 관련 행위 관리 | 상품의 **표시와 조회**에 집중 (좋아요, 정렬, 목록 조회) | -| **Like** | 상품을 좋아요 표시한 사용자 정보 보유 | 행위의 주체는 상품이지만, 정보를 가지는 주체로서 도메인 분리 | --- -## 🧩 Order 도메인 클래스 구조 +## 📦 Order Aggregate + +### 클래스 다이어그램 ```mermaid classDiagram @@ -67,29 +169,17 @@ classDiagram +Long id +Long userId +OrderStatus status - +int totalAmount - +JSON items - +getTotalAmount() - +create(List~ProductOrder~, Point) + +Integer totalAmount + +List~OrderItem~ items +complete() - +cancel(Point) + +cancel() } - class ProductOrder { - +Long id + class OrderItem { + +Long productId +String name - +int price - +int stock - +decreaseStock(quantity) - +increaseStock(quantity) - } - - class Point { - +Long id - +User user - +Long balance - +deduct(amount) - +refund(amount) + +Integer price + +Integer quantity } class OrderStatus { @@ -99,25 +189,160 @@ classDiagram CANCELED } - Order "1" --> "many" ProductOrder : contains - Order --> Point : usesForPayment + class User { + +Long id + +String userId + } + + class Product { + +Long id + +String name + } + + Order "1" --> "*" OrderItem : contains + Order "1" --> "1" OrderStatus : uses + Order "1" ..> "1" User : references by ID + OrderItem "1" ..> "1" Product : references by ID +``` + +### 클래스 설명 + +| 클래스 | 타입 | 책임 | +|---------|------|------| +| **Order** | Aggregate Root | 주문의 상태, 총액, 주문 아이템 관리 및 상태 전이 처리 | +| **OrderItem** | Value Object | 주문 시점의 상품 정보 스냅샷 (JSON으로 저장) | +| **OrderStatus** | Enum | 주문의 생명주기 상태 표현 | +| **User** | 외부 Aggregate | 주문자 정보 (Order가 ID로만 참조) | +| **Product** | 외부 Aggregate | 주문된 상품 정보 (OrderItem이 ID로만 참조) | + +### 주요 특징 +- `OrderItem`은 JSON으로 저장되는 Value Object입니다. +- Order는 User ID와 Product ID를 참조하지만, 실제 Entity를 참조하지 않습니다. +- Order 상태 전이는 Order 내부에서 관리됩니다 (`complete()`, `cancel()`). +- 총액 계산은 Order 생성 시 자동으로 수행됩니다. + +### 상태 전이 다이어그램 + +```mermaid +stateDiagram-v2 + [*] --> PENDING: 주문 생성 + + PENDING --> COMPLETED: 주문 완료\n(포인트 차감, 재고 차감 완료) + PENDING --> CANCELED: 주문 취소\n(포인트 환불, 재고 복구) + + COMPLETED --> [*] + CANCELED --> [*] + + note right of PENDING + 초기 상태 + 주문 생성 직후 상태 + end note + + note right of COMPLETED + 최종 완료 상태 + 모든 처리가 완료된 상태 + end note + + note right of CANCELED + 취소 상태 + PENDING 상태에서만 취소 가능 + end note ``` --- -## 📦 Order 도메인 클래스 간 역할 설명 +## 🏷 Brand Aggregate + +### 클래스 다이어그램 + +```mermaid +classDiagram + + class Brand { + +Long id + +String name + +String description + } +``` + +### 클래스 설명 + +| 클래스 | 타입 | 책임 | +|---------|------|------| +| **Brand** | Aggregate Root | 브랜드 정보 관리 | + +### 주요 특징 +- Brand는 독립적인 Aggregate입니다. +- Product가 Brand를 참조하지만, Brand는 Product를 알지 못합니다. +- 단순한 정보 관리만 수행합니다. + +--- + +## ❤️ Like Aggregate + +### 클래스 다이어그램 + +```mermaid +classDiagram + + class Like { + +Long id + +Long userId + +Long productId + } + + class LikeRepository { + <> + +save(Like) + +findByUserIdAndProductId(userId, productId) + +delete(Like) + +findAllByUserId(userId) + +countByProductIds(productIds) + } + + class LikeFacade { + +addLike(userId, productId) + +removeLike(userId, productId) + +getLikedProducts(userId) + } + + class User { + +Long id + +String userId + } -| 클래스 | 책임 | 도메인 분리 이유 | -|---------|------|-----------------| -| **Order** | 주문의 상태, 총액, 주문 아이템 관리 | 주문 생성, 완료, 취소 등 주문 생명주기 관리. `items`는 `{productId, name, price, quantity}`로 관리 | -| **ProductOrder** | 주문 시 상품 정보 및 재고 관리 | 주문 시점의 상품 정보 보관 및 주문 처리 중 재고 차감/복구 관리 (ProductDisplay와 분리) | -| **Point** | 포인트 잔액 관리 및 결제 처리 | 주문 시 포인트 차감, 취소 시 환불 처리 | -| **User** | 주문자 정보 | 주문과 사용자의 관계 표현 | -| **OrderStatus** | 주문 상태 관리 | 주문의 생명주기 상태 표현 | + class Product { + +Long id + +String name + } + + LikeFacade --> LikeRepository : uses + LikeFacade --> User : references + LikeFacade --> Product : references + LikeRepository ..> Like : manages + Like "1" ..> "1" User : references by ID + Like "1" ..> "1" Product : references by ID +``` + +### 클래스 설명 + +| 클래스 | 타입 | 책임 | +|---------|------|------| +| **Like** | Aggregate Root | 사용자와 상품 간의 좋아요 관계를 나타내는 엔티티 | +| **LikeRepository** | Repository Interface | 좋아요 정보의 저장, 조회, 삭제를 담당하는 저장소 인터페이스 | +| **LikeFacade** | Application Service | 좋아요 추가, 삭제, 목록 조회를 처리하는 애플리케이션 서비스 | +| **User** | 외부 Aggregate | 좋아요를 누른 사용자 정보 (Like가 ID로만 참조) | +| **Product** | 외부 Aggregate | 좋아요 대상이 되는 상품 정보 (Like가 ID로만 참조) | + +### 주요 특징 +- Like는 User와 Product 간의 관계를 나타내는 독립적인 Aggregate입니다. +- User ID와 Product ID만 참조하며, 실제 Entity를 참조하지 않습니다. +- 좋아요 관계의 생명주기를 독립적으로 관리합니다. +- LikeFacade에서 중복 좋아요/취소 요청 시 현재 상태를 반환하여 멱등성을 보장합니다. --- -## 🧭 상태 및 상수 클래스 +## 🧭 상수 및 Enum 클래스 ```mermaid classDiagram @@ -134,31 +359,49 @@ classDiagram COMPLETED CANCELED } + + class Gender { + <> + MALE + FEMALE + } ``` +### Enum 설명 + +| Enum | 사용 위치 | 설명 | +|------|-----------|------| +| **SortType** | 상품 목록 조회 | 상품 정렬 기준 (최신순, 가격 오름차순, 좋아요 내림차순) | +| **OrderStatus** | Order Aggregate | 주문 상태 (대기, 완료, 취소) | +| **Gender** | User Aggregate | 사용자 성별 (남성, 여성) | + --- ## 🔁 설계 의도 요약 | 설계 포인트 | 선택 근거 | |--------------|-------------| -| **도메인 중심 (DDD)** | Entity가 스스로 상태를 관리하도록 설계 (ex. ProductDisplay.likeBy(), ProductOrder.increaseStock(), Order.complete()) | -| **도메인 분리** | 동일 테이블이라도 행위와 책임이 다르면 별도 도메인으로 분리. ProductOrder는 주문 처리에 필요한 상품 정보와 재고 관리를 담당하며, ProductDisplay와 분리하여 주문 로직의 독립성 보장 | -| **멱등성 보장** | ProductDisplay의 likedUserIds를 Set으로 관리하여 중복 방지, Order 상태 전이는 멱등하게 처리 | +| **도메인 중심 (DDD)** | Entity가 스스로 상태를 관리하도록 설계 (ex. Product.decreaseStock(), Order.complete(), User.deductPoint()) | +| **Aggregate 분리** | User, Product, Order, Like, Brand를 독립적인 Aggregate로 분리하여 각각의 일관성 경계를 명확히 함 | +| **멱등성 보장** | LikeFacade에서 중복 좋아요/취소 요청 시 현재 상태를 반환하여 멱등성 보장 | | **Enum 사용** | SortType, OrderStatus 등 도메인별 상수는 Enum으로 명확히 정의 | +| **Value Object 활용** | OrderItem, Point를 Value Object로 설계하여 불변성과 도메인 로직 캡슐화 | +| **ID 참조 원칙** | Aggregate 간 참조는 ID를 통해서만 이루어지며, 직접 Entity 참조를 금지하여 결합도 감소 | --- -## 💡 도메인 분리 상세 설명 +## 📝 Aggregate 간 협력 + +여러 Aggregate 간 협력이 필요한 경우 Application Service (Facade)에서 조율합니다. + +### 예시: 주문 생성 시 협력 + +- **PurchasingFacade**: Order, User, Product Aggregate 간의 협력을 조정 + - 주문 완료: Order.complete() + User.deductPoint() + Product.decreaseStock() + - 주문 취소: Order.cancel() + User.receivePoint() + Product.increaseStock() -### ProductDisplay vs ProductOrder -동일한 `product` 테이블을 사용하더라도, 행위와 책임에 따라 별도 도메인으로 분리: +### 예시: 좋아요 처리 시 협력 -| 구분 | ProductDisplay | ProductOrder | -|------|----------------|--------------| -| **책임** | 상품 표시, 조회, 좋아요 | 주문 처리 시 상품 정보 및 재고 관리 | -| **주요 행위** | `like()`, `unlike()` | `decreaseStock()`, `increaseStock()` (주문 처리 중 재고 관리) | -| **관심사** | 사용자에게 상품을 보여주는 것 | 주문 생성/취소 시 상품 정보와 재고를 처리하는 것 | -| **변경 빈도** | 상품 정보, 좋아요 수 | 주문 생성/취소 시 재고 변경 | -| **데이터 특성** | 실시간 상품 정보 (조회용) | 주문 처리 중 상품 정보 및 재고 상태 (주문용) | -| **생명주기** | 상품이 존재하는 동안 지속 | 주문 생성 시 생성, 주문 완료/취소 시 처리 | +- **LikeFacade**: Like, User, Product Aggregate 간의 협력을 조정 + - 좋아요 추가: Like 생성 + User/Product 존재 확인 + - 좋아요 취소: Like 삭제 + User/Product 존재 확인 diff --git a/.docs/design/04-erd.md b/.docs/design/04-erd.md index cf43fdfb8..e3b7b6b05 100644 --- a/.docs/design/04-erd.md +++ b/.docs/design/04-erd.md @@ -62,18 +62,8 @@ erDiagram datetime deleted_at } - POINT { - bigint id PK - bigint ref_user_id FK - bigint balance - datetime created_at - datetime updated_at - datetime deleted_at - } - USERS ||--o{ LIKES : "좋아요" USERS ||--o{ ORDERS : "주문" - USERS ||--o{ POINT : "포인트" BRANDS ||--o{ PRODUCTS : "브랜드 상품" PRODUCTS ||--o{ LIKES : "좋아요 대상" @@ -84,7 +74,18 @@ erDiagram ## ⚙️ 제약조건 | 테이블 | 제약조건 | 설명 | |---------|-----------|------| -| **LIKES** | (user_id, product_id) UNIQUE | 동일 사용자-상품 중복 방지 | +| **LIKES** | (ref_user_id, ref_product_id) UNIQUE | 동일 사용자-상품 중복 방지 | | **ORDERS** | status IN ('PENDING', 'COMPLETED', 'CANCELED') | 주문 상태는 OrderStatus enum 값만 허용 | | **ORDERS** | items JSON 형식: [{productId, name, price, quantity}] | 주문 아이템은 JSON 배열로 저장 | +| **USERS** | user_id UNIQUE | 사용자 ID는 고유해야 함 | +| **PRODUCTS** | stock >= 0 | 재고는 0 이상이어야 함 | +| **PRODUCTS** | price >= 0 | 가격은 0 이상이어야 함 | + +--- + +## 📝 데이터베이스 설계 참고사항 + +### Aggregate 경계 +- 각 테이블은 하나의 Aggregate Root에 해당합니다. +- Aggregate 간 참조는 외래키를 통한 ID 참조로만 이루어집니다. --- diff --git a/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogProductFacade.java new file mode 100644 index 000000000..53d7ca98c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogProductFacade.java @@ -0,0 +1,81 @@ +package com.loopers.application.catalog; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.like.LikeRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductDetail; +import com.loopers.domain.product.ProductDetailService; +import com.loopers.domain.product.ProductRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 상품 조회 파사드. + *

+ * 상품 목록 조회 및 상품 정보 조회 유즈케이스를 처리하는 애플리케이션 서비스입니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@RequiredArgsConstructor +@Component +public class CatalogProductFacade { + private final ProductRepository productRepository; + private final BrandRepository brandRepository; + private final LikeRepository likeRepository; + private final ProductDetailService productDetailService; + + /** + * 상품 목록을 조회합니다. + * + * @param brandId 브랜드 ID (선택) + * @param sort 정렬 기준 (latest, price_asc, likes_desc) + * @param page 페이지 번호 (0부터 시작) + * @param size 페이지당 상품 수 + * @return 상품 목록 조회 결과 + */ + public ProductInfoList getProducts(Long brandId, String sort, int page, int size) { + long totalCount = productRepository.countAll(brandId); + List products = productRepository.findAll(brandId, sort, page, size); + List productsInfo = products.stream() + .map(product -> getProduct(product.getId())) + .toList(); + return new ProductInfoList(productsInfo, totalCount, page, size); + } + + /** + * 상품 정보를 조회합니다. + * + * @param productId 상품 ID + * @return 상품 정보와 좋아요 수 + * @throws CoreException 상품을 찾을 수 없는 경우 + */ + public ProductInfo getProduct(Long productId) { + Product product = productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); + + // 브랜드 조회 + Brand brand = brandRepository.findById(product.getBrandId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); + + // 좋아요 수 조회 + Map likesCountMap = likeRepository.countByProductIds(List.of(productId)); + Long likesCount = likesCountMap.getOrDefault(productId, 0L); + + // 도메인 서비스를 통해 ProductDetail 생성 (도메인 객체 협력) + ProductDetail productDetail = productDetailService.combineProductAndBrand(product, brand, likesCount); + + return new ProductInfo(productDetail); + } + +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/catalog/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/catalog/ProductInfo.java new file mode 100644 index 000000000..6a22a5f21 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/catalog/ProductInfo.java @@ -0,0 +1,12 @@ +package com.loopers.application.catalog; + +import com.loopers.domain.product.ProductDetail; + +/** + * 상품 상세 정보를 담는 레코드. + * + * @param productDetail 상품 상세 정보 (Product + Brand + 좋아요 수) + */ +public record ProductInfo(ProductDetail productDetail) { +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/catalog/ProductInfoList.java b/apps/commerce-api/src/main/java/com/loopers/application/catalog/ProductInfoList.java new file mode 100644 index 000000000..23f1a9345 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/catalog/ProductInfoList.java @@ -0,0 +1,46 @@ +package com.loopers.application.catalog; + +import java.util.List; + +/** + * 상품 목록 조회 결과. + * + * @param products 상품 목록 (좋아요 수 포함) + * @param totalCount 전체 상품 수 + * @param page 현재 페이지 번호 + * @param size 페이지당 상품 수 + */ +public record ProductInfoList( + List products, + long totalCount, + int page, + int size +) { + /** + * 전체 페이지 수를 계산합니다. + * + * @return 전체 페이지 수 + */ + public int getTotalPages() { + return size > 0 ? (int) Math.ceil((double) totalCount / size) : 0; + } + + /** + * 다음 페이지가 있는지 확인합니다. + * + * @return 다음 페이지 존재 여부 + */ + public boolean hasNext() { + return (page + 1) * size < totalCount; + } + + /** + * 이전 페이지가 있는지 확인합니다. + * + * @return 이전 페이지 존재 여부 + */ + public boolean hasPrevious() { + return page > 0; + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java new file mode 100644 index 000000000..c97814f0f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -0,0 +1,180 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * 좋아요 관리 파사드. + *

+ * 좋아요 추가, 삭제, 목록 조회 유즈케이스를 처리하는 애플리케이션 서비스입니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@RequiredArgsConstructor +@Component +public class LikeFacade { + private final LikeRepository likeRepository; + private final UserRepository userRepository; + private final ProductRepository productRepository; + + /** + * 상품에 좋아요를 추가합니다. + *

+ * 멱등성을 보장합니다. 이미 좋아요가 존재하는 경우 아무 작업도 수행하지 않습니다. + *

+ * + * @param userId 사용자 ID (String) + * @param productId 상품 ID + * @throws CoreException 사용자 또는 상품을 찾을 수 없는 경우 + */ + @Transactional + public void addLike(String userId, Long productId) { + User user = loadUser(userId); + loadProduct(productId); + + Optional existingLike = likeRepository.findByUserIdAndProductId(user.getId(), productId); + if (existingLike.isPresent()) { + return; + } + + Like like = Like.of(user.getId(), productId); + likeRepository.save(like); + } + + /** + * 상품의 좋아요를 취소합니다. + *

+ * 멱등성을 보장합니다. 좋아요가 존재하지 않는 경우 아무 작업도 수행하지 않습니다. + *

+ * + * @param userId 사용자 ID (String) + * @param productId 상품 ID + * @throws CoreException 사용자 또는 상품을 찾을 수 없는 경우 + */ + @Transactional + public void removeLike(String userId, Long productId) { + User user = loadUser(userId); + loadProduct(productId); + + Optional like = likeRepository.findByUserIdAndProductId(user.getId(), productId); + if (like.isEmpty()) { + return; + } + + likeRepository.delete(like.get()); + } + + /** + * 사용자가 좋아요한 상품 목록을 조회합니다. + * + * @param userId 사용자 ID (String) + * @return 좋아요한 상품 목록 + * @throws CoreException 사용자를 찾을 수 없는 경우 + */ + public List getLikedProducts(String userId) { + User user = loadUser(userId); + + // 사용자의 좋아요 목록 조회 + List likes = likeRepository.findAllByUserId(user.getId()); + + if (likes.isEmpty()) { + return List.of(); + } + + // 상품 ID 목록 추출 + List productIds = likes.stream() + .map(Like::getProductId) + .toList(); + + // 상품 정보 조회 + List products = productIds.stream() + .map(productId -> productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", productId)))) + .toList(); + + // 좋아요 수 집계 + Map likesCountMap = likeRepository.countByProductIds(productIds); + + // 좋아요 목록을 상품 정보와 좋아요 수와 함께 변환 + return likes.stream() + .map(like -> { + Product product = products.stream() + .filter(p -> p.getId().equals(like.getProductId())) + .findFirst() + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", like.getProductId()))); + Long likesCount = likesCountMap.getOrDefault(like.getProductId(), 0L); + return LikedProduct.from(product, like, likesCount); + }) + .toList(); + } + + private User loadUser(String userId) { + User user = userRepository.findByUserId(userId); + if (user == null) { + throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다."); + } + return user; + } + + private Product loadProduct(Long productId) { + return productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", productId))); + } + + /** + * 좋아요한 상품 정보. + * + * @param productId 상품 ID + * @param name 상품 이름 + * @param price 상품 가격 + * @param stock 상품 재고 + * @param brandId 브랜드 ID + * @param likesCount 좋아요 수 + */ + public record LikedProduct( + Long productId, + String name, + Integer price, + Integer stock, + Long brandId, + Long likesCount + ) { + /** + * Product와 Like로부터 LikedProduct를 생성합니다. + * + * @param product 상품 엔티티 + * @param like 좋아요 엔티티 + * @param likesCount 좋아요 수 + * @return 생성된 LikedProduct + */ + public static LikedProduct from(Product product, Like like, Long likesCount) { + return new LikedProduct( + product.getId(), + product.getName(), + product.getPrice(), + product.getStock(), + product.getBrandId(), + likesCount + ); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/pointwallet/PointWalletFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/pointwallet/PointWalletFacade.java new file mode 100644 index 000000000..1e1d171a1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/pointwallet/PointWalletFacade.java @@ -0,0 +1,83 @@ +package com.loopers.application.pointwallet; + +import com.loopers.domain.user.Point; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * 포인트 지갑 파사드. + *

+ * 포인트 조회 및 충전 유즈케이스를 처리하는 애플리케이션 서비스입니다. + * 트랜잭션 경계를 관리하여 데이터 일관성을 보장합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@RequiredArgsConstructor +@Component +public class PointWalletFacade { + private final UserRepository userRepository; + + /** + * 사용자의 포인트를 조회합니다. + * + * @param userId 조회할 사용자 ID + * @return 포인트 정보 + * @throws CoreException 사용자를 찾을 수 없는 경우 + */ + public PointsInfo getPoints(String userId) { + User user = userRepository.findByUserId(userId); + if (user == null) { + throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다."); + } + return PointsInfo.from(user); + } + + /** + * 사용자의 포인트를 충전합니다. + *

+ * 트랜잭션 내에서 실행되어 데이터 일관성을 보장합니다. + *

+ * + * @param userId 충전할 사용자 ID + * @param amount 충전할 포인트 금액 + * @return 충전된 포인트 정보 + * @throws CoreException 사용자를 찾을 수 없거나 충전 금액이 유효하지 않은 경우 + */ + @Transactional + public PointsInfo chargePoint(String userId, Long amount) { + User user = userRepository.findByUserId(userId); + if (user == null) { + throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다."); + } + Point point = Point.of(amount); + user.receivePoint(point); + User savedUser = userRepository.save(user); + return PointsInfo.from(savedUser); + } + + /** + * 포인트 정보를 담는 레코드. + * + * @param userId 사용자 ID + * @param balance 포인트 잔액 + */ + public record PointsInfo(String userId, Long balance) { + /** + * User 엔티티로부터 PointsInfo를 생성합니다. + * + * @param user 사용자 엔티티 + * @return 생성된 PointsInfo + */ + public static PointsInfo from(User user) { + return new PointsInfo(user.getUserId(), user.getPoint().getValue()); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/OrderInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/OrderInfo.java new file mode 100644 index 000000000..48e9bdb58 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/OrderInfo.java @@ -0,0 +1,46 @@ +package com.loopers.application.purchasing; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderStatus; + +import java.util.List; + +/** + * 주문 정보를 담는 레코드. + * + * @param orderId 주문 ID + * @param userId 사용자 ID + * @param totalAmount 총 주문 금액 + * @param status 주문 상태 + * @param items 주문 아이템 목록 + */ +public record OrderInfo( + Long orderId, + Long userId, + Integer totalAmount, + OrderStatus status, + List items +) { + /** + * Order 엔티티로부터 OrderInfo를 생성합니다. + * + * @param order 주문 엔티티 + * @return 생성된 OrderInfo + */ + public static OrderInfo from(Order order) { + List itemInfos = order.getItems() == null + ? List.of() + : order.getItems().stream() + .map(OrderItemInfo::from) + .toList(); + + return new OrderInfo( + order.getId(), + order.getUserId(), + order.getTotalAmount(), + order.getStatus(), + itemInfos + ); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/OrderItemCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/OrderItemCommand.java new file mode 100644 index 000000000..b93d31215 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/OrderItemCommand.java @@ -0,0 +1,22 @@ +package com.loopers.application.purchasing; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +/** + * 주문 생성 요청 아이템 명령. + * + * @param productId 상품 ID + * @param quantity 수량 + */ +public record OrderItemCommand(Long productId, Integer quantity) { + public OrderItemCommand { + if (productId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 ID는 필수입니다."); + } + if (quantity == null || quantity <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 수량은 1개 이상이어야 합니다."); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/OrderItemInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/OrderItemInfo.java new file mode 100644 index 000000000..dffb90549 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/OrderItemInfo.java @@ -0,0 +1,34 @@ +package com.loopers.application.purchasing; + +import com.loopers.domain.order.OrderItem; + +/** + * 주문 아이템 정보를 담는 레코드. + * + * @param productId 상품 ID + * @param name 상품 이름 + * @param price 상품 가격 + * @param quantity 수량 + */ +public record OrderItemInfo( + Long productId, + String name, + Integer price, + Integer quantity +) { + /** + * OrderItem으로부터 OrderItemInfo를 생성합니다. + * + * @param item 주문 아이템 + * @return 생성된 OrderItemInfo + */ + public static OrderItemInfo from(OrderItem item) { + return new OrderItemInfo( + item.getProductId(), + item.getName(), + item.getPrice(), + item.getQuantity() + ); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PurchasingFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PurchasingFacade.java new file mode 100644 index 000000000..4bc808613 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PurchasingFacade.java @@ -0,0 +1,202 @@ +package com.loopers.application.purchasing; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; +import com.loopers.domain.order.OrderRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.user.Point; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 구매 파사드. + *

+ * 주문 생성과 결제(포인트 차감), 재고 조정, 외부 연동을 조율한다. + *

+ */ +@RequiredArgsConstructor +@Component +public class PurchasingFacade { + + private final UserRepository userRepository; + private final ProductRepository productRepository; + private final OrderRepository orderRepository; + + /** + * 주문을 생성한다. + *

+ * 1. 사용자 조회 및 존재 여부 검증
+ * 2. 상품 재고 검증 및 차감
+ * 3. 사용자 포인트 검증 및 차감
+ * 4. 주문 저장 및 외부 시스템 알림 + *

+ * + * @param userId 사용자 식별자 (로그인 ID) + * @param commands 주문 상품 정보 + * @return 생성된 주문 정보 + */ + @Transactional + public OrderInfo createOrder(String userId, List commands) { + if (commands == null || commands.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 아이템은 1개 이상이어야 합니다."); + } + + User user = loadUser(userId); + + Set productIds = new HashSet<>(); + List products = new ArrayList<>(); + List orderItems = new ArrayList<>(); + + for (OrderItemCommand command : commands) { + if (!productIds.add(command.productId())) { + throw new CoreException(ErrorType.BAD_REQUEST, + String.format("상품이 중복되었습니다. (상품 ID: %d)", command.productId())); + } + + Product product = productRepository.findById(command.productId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", command.productId()))); + products.add(product); + + orderItems.add(OrderItem.of( + product.getId(), + product.getName(), + product.getPrice(), + command.quantity() + )); + } + + Order order = Order.of(user.getId(), orderItems); + + decreaseStocksForOrderItems(order.getItems(), products); + deductUserPoint(user, order.getTotalAmount()); + order.complete(); + + products.forEach(productRepository::save); + userRepository.save(user); + + Order savedOrder = orderRepository.save(order); + + return OrderInfo.from(savedOrder); + } + + /** + * 주문을 취소하고 포인트를 환불하며 재고를 원복한다. + * + * @param order 주문 엔티티 + * @param user 사용자 엔티티 + */ + @Transactional + public void cancelOrder(Order order, User user) { + if (order == null || user == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "취소할 주문과 사용자 정보는 필수입니다."); + } + + List products = order.getItems().stream() + .map(item -> productRepository.findById(item.getProductId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", item.getProductId())))) + .toList(); + + order.cancel(); + increaseStocksForOrderItems(order.getItems(), products); + user.receivePoint(Point.of((long) order.getTotalAmount())); + + products.forEach(productRepository::save); + userRepository.save(user); + orderRepository.save(order); + } + + /** + * 사용자 ID로 주문 목록을 조회한다. + * + * @param userId 사용자 식별자 (로그인 ID) + * @return 주문 목록 + */ + @Transactional + public List getOrders(String userId) { + User user = loadUser(userId); + List orders = orderRepository.findAllByUserId(user.getId()); + return orders.stream() + .map(OrderInfo::from) + .toList(); + } + + /** + * 주문 ID로 단일 주문을 조회한다. + * + * @param userId 사용자 식별자 (로그인 ID) + * @param orderId 주문 ID + * @return 주문 정보 + */ + @Transactional + public OrderInfo getOrder(String userId, Long orderId) { + User user = loadUser(userId); + Order order = orderRepository.findById(orderId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.")); + + if (!order.getUserId().equals(user.getId())) { + throw new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다."); + } + + return OrderInfo.from(order); + } + + private void decreaseStocksForOrderItems(List items, List products) { + Map productMap = products.stream() + .collect(Collectors.toMap(Product::getId, product -> product)); + + for (OrderItem item : items) { + Product product = productMap.get(item.getProductId()); + if (product == null) { + throw new CoreException(ErrorType.NOT_FOUND, + String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", item.getProductId())); + } + product.decreaseStock(item.getQuantity()); + } + } + + private void increaseStocksForOrderItems(List items, List products) { + Map productMap = products.stream() + .collect(Collectors.toMap(Product::getId, product -> product)); + + for (OrderItem item : items) { + Product product = productMap.get(item.getProductId()); + if (product == null) { + throw new CoreException(ErrorType.NOT_FOUND, + String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", item.getProductId())); + } + product.increaseStock(item.getQuantity()); + } + } + + private void deductUserPoint(User user, Integer totalAmount) { + if (Objects.requireNonNullElse(totalAmount, 0) <= 0) { + return; + } + user.deductPoint(Point.of(totalAmount.longValue())); + } + + private User loadUser(String userId) { + User user = userRepository.findByUserId(userId); + if (user == null) { + throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다."); + } + return user; + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/signup/SignUpFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/signup/SignUpFacade.java index 2b5ada30c..9ede52abb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/signup/SignUpFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/signup/SignUpFacade.java @@ -1,18 +1,22 @@ package com.loopers.application.signup; -import com.loopers.domain.point.PointService; import com.loopers.domain.user.Gender; +import com.loopers.domain.user.Point; import com.loopers.domain.user.User; import com.loopers.domain.user.UserService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import java.util.Locale; + /** * 회원가입 파사드. *

- * 회원가입 시 사용자 생성과 포인트 초기화를 조율하는 - * 애플리케이션 서비스입니다. + * 회원가입 시 사용자 생성을 처리하는 애플리케이션 서비스입니다. + * 사용자 생성 시 포인트는 자동으로 0으로 초기화됩니다. * 트랜잭션 경계를 관리하여 데이터 일관성을 보장합니다. *

* @@ -24,26 +28,41 @@ public class SignUpFacade { private final UserService userService; - private final PointService pointService; - /** * 회원가입을 처리합니다. *

- * 사용자를 생성하고 초기 포인트(0)를 부여합니다. + * 사용자를 생성하며, 포인트는 자동으로 0으로 초기화됩니다. * 전체 과정이 하나의 트랜잭션으로 처리됩니다. *

* * @param userId 사용자 ID * @param email 이메일 주소 * @param birthDateStr 생년월일 (yyyy-MM-dd) - * @param gender 성별 + * @param genderStr 성별 문자열 (MALE 또는 FEMALE) * @return 생성된 사용자 정보 - * @throws com.loopers.support.error.CoreException 유효성 검증 실패 또는 중복 ID 존재 시 + * @throws CoreException gender 값이 유효하지 않거나, 유효성 검증 실패 또는 중복 ID 존재 시 */ @Transactional - public SignUpInfo signUp(String userId, String email, String birthDateStr, Gender gender) { - User user = userService.create(userId, email, birthDateStr, gender); - pointService.create(user, 0L); + public SignUpInfo signUp(String userId, String email, String birthDateStr, String genderStr) { + Gender gender = parseGender(genderStr); + Point point = Point.of(0L); + User user = userService.create(userId, email, birthDateStr, gender, point); return SignUpInfo.from(user); } + + /** + * 성별 문자열을 Gender enum으로 변환합니다. + * + * @param genderStr 성별 문자열 + * @return Gender enum + * @throws CoreException gender 값이 유효하지 않은 경우 + */ + private Gender parseGender(String genderStr) { + try { + String genderValue = genderStr.trim().toUpperCase(Locale.ROOT); + return Gender.valueOf(genderValue); + } catch (IllegalArgumentException e) { + throw new CoreException(ErrorType.BAD_REQUEST, "gender 값이 올바르지 않습니다."); + } + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/userinfo/UserInfoFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/userinfo/UserInfoFacade.java new file mode 100644 index 000000000..ceae04eef --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/userinfo/UserInfoFacade.java @@ -0,0 +1,69 @@ +package com.loopers.application.userinfo; + +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * 사용자 정보 조회 파사드. + *

+ * 사용자 정보 조회 유즈케이스를 처리하는 애플리케이션 서비스입니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@RequiredArgsConstructor +@Component +public class UserInfoFacade { + private final UserRepository userRepository; + + /** + * 사용자 ID로 사용자 정보를 조회합니다. + * + * @param userId 조회할 사용자 ID + * @return 조회된 사용자 정보 + * @throws CoreException 사용자를 찾을 수 없는 경우 + */ + public UserInfo getUserInfo(String userId) { + User user = userRepository.findByUserId(userId); + if (user == null) { + throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다."); + } + return UserInfo.from(user); + } + + /** + * 사용자 정보를 담는 레코드. + * + * @param userId 사용자 ID + * @param email 이메일 주소 + * @param birthDate 생년월일 + * @param gender 성별 + */ + public record UserInfo( + String userId, + String email, + java.time.LocalDate birthDate, + com.loopers.domain.user.Gender gender + ) { + /** + * User 엔티티로부터 UserInfo를 생성합니다. + * + * @param user 사용자 엔티티 + * @return 생성된 UserInfo + */ + public static UserInfo from(User user) { + return new UserInfo( + user.getUserId(), + user.getEmail(), + user.getBirthDate(), + user.getGender() + ); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java new file mode 100644 index 000000000..611b27a2c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java @@ -0,0 +1,47 @@ +package com.loopers.domain.brand; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 브랜드 도메인 엔티티. + *

+ * 브랜드의 기본 정보(이름)를 관리합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Entity +@Table(name = "brand") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class Brand extends BaseEntity { + @Column(name = "name") + private String name; + + /** + * Brand 인스턴스를 생성합니다. + * + * @param name 브랜드 이름 + */ + public Brand(String name) { + this.name = name; + } + + /** + * Brand 인스턴스를 생성하는 정적 팩토리 메서드. + * + * @param name 브랜드 이름 + * @return 생성된 Brand 인스턴스 + */ + public static Brand of(String name) { + return new Brand(name); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java new file mode 100644 index 000000000..df55a0780 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java @@ -0,0 +1,31 @@ +package com.loopers.domain.brand; + +import java.util.Optional; + +/** + * Brand 엔티티에 대한 저장소 인터페이스. + *

+ * 브랜드 정보의 영속성 계층과의 상호작용을 정의합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public interface BrandRepository { + /** + * 브랜드를 저장합니다. + * + * @param brand 저장할 브랜드 + * @return 저장된 브랜드 + */ + Brand save(Brand brand); + + /** + * 브랜드 ID로 브랜드를 조회합니다. + * + * @param brandId 조회할 브랜드 ID + * @return 조회된 브랜드를 담은 Optional + */ + Optional findById(Long brandId); +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java new file mode 100644 index 000000000..326de17ed --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java @@ -0,0 +1,53 @@ +package com.loopers.domain.like; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 좋아요 도메인 엔티티. + *

+ * 사용자와 상품 간의 좋아요 관계를 관리합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Entity +@Table(name = "`like`") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class Like extends BaseEntity { + @Column(name = "ref_user_id", nullable = false) + private Long userId; + + @Column(name = "ref_product_id", nullable = false) + private Long productId; + + /** + * Like 인스턴스를 생성합니다. + * + * @param userId 사용자 ID + * @param productId 상품 ID + */ + public Like(Long userId, Long productId) { + this.userId = userId; + this.productId = productId; + } + + /** + * Like 인스턴스를 생성하는 정적 팩토리 메서드. + * + * @param userId 사용자 ID + * @param productId 상품 ID + * @return 생성된 Like 인스턴스 + */ + public static Like of(Long userId, Long productId) { + return new Like(userId, productId); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java new file mode 100644 index 000000000..fbc2976c1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java @@ -0,0 +1,57 @@ +package com.loopers.domain.like; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Like 엔티티에 대한 저장소 인터페이스. + *

+ * 좋아요 정보의 영속성 계층과의 상호작용을 정의합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public interface LikeRepository { + /** + * 좋아요를 저장합니다. + * + * @param like 저장할 좋아요 + * @return 저장된 좋아요 + */ + Like save(Like like); + + /** + * 사용자 ID와 상품 ID로 좋아요를 조회합니다. + * + * @param userId 사용자 ID + * @param productId 상품 ID + * @return 조회된 좋아요를 담은 Optional + */ + Optional findByUserIdAndProductId(Long userId, Long productId); + + /** + * 좋아요를 삭제합니다. + * + * @param like 삭제할 좋아요 + */ + void delete(Like like); + + /** + * 사용자 ID로 좋아요한 상품 목록을 조회합니다. + * + * @param userId 사용자 ID + * @return 좋아요 목록 + */ + List findAllByUserId(Long userId); + + /** + * 상품별 좋아요 수를 집계합니다. + * + * @param productIds 상품 ID 목록 + * @return 상품 ID를 키로, 좋아요 수를 값으로 하는 Map + */ + Map countByProductIds(List productIds); +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java new file mode 100644 index 000000000..4c0a59214 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -0,0 +1,131 @@ +package com.loopers.domain.order; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.util.List; + +/** + * 주문 도메인 엔티티. + *

+ * 주문의 상태, 총액, 주문 아이템을 관리합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Entity +@Table(name = "`order`") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class Order extends BaseEntity { + @Column(name = "ref_user_id", nullable = false) + private Long userId; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private OrderStatus status; + + @Column(name = "total_amount", nullable = false) + private Integer totalAmount; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "items", nullable = false, columnDefinition = "json") + private List items; + + /** + * Order 인스턴스를 생성합니다. + * + * @param userId 사용자 ID + * @param items 주문 아이템 목록 + * @throws CoreException items가 null이거나 비어있을 경우 + */ + public Order(Long userId, List items) { + validateUserId(userId); + validateItems(items); + this.userId = userId; + this.items = items; + this.totalAmount = calculateTotalAmount(items); + this.status = OrderStatus.PENDING; + } + + /** + * Order 인스턴스를 생성하는 정적 팩토리 메서드. + * + * @param userId 사용자 ID + * @param items 주문 아이템 목록 + * @return 생성된 Order 인스턴스 + */ + public static Order of(Long userId, List items) { + return new Order(userId, items); + } + + /** + * 주문 아이템 목록으로부터 총액을 계산합니다. + * + * @param items 주문 아이템 목록 + * @return 계산된 총액 + */ + private static Integer calculateTotalAmount(List items) { + return items.stream() + .mapToInt(item -> item.getPrice() * item.getQuantity()) + .sum(); + } + + /** + * 사용자 ID의 유효성을 검증합니다. + * + * @param userId 검증할 사용자 ID + * @throws CoreException userId가 null일 경우 + */ + private void validateUserId(Long userId) { + if (userId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "사용자 ID는 필수입니다."); + } + } + + /** + * 주문 아이템 목록의 유효성을 검증합니다. + * + * @param items 검증할 주문 아이템 목록 + * @throws CoreException items가 null이거나 비어있을 경우 + */ + private void validateItems(List items) { + if (items == null || items.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 아이템은 필수이며 최소 1개 이상이어야 합니다."); + } + } + + /** + * 주문을 완료 상태로 변경합니다. + * 상태 변경만 수행하며, 포인트 차감은 도메인 서비스에서 처리합니다. + */ + public void complete() { + if (this.status != OrderStatus.PENDING) { + throw new CoreException(ErrorType.BAD_REQUEST, + String.format("완료할 수 없는 주문 상태입니다. (현재 상태: %s)", this.status)); + } + this.status = OrderStatus.COMPLETED; + } + + /** + * 주문을 취소 상태로 변경합니다. + * 상태 변경만 수행하며, 포인트 환불은 도메인 서비스에서 처리합니다. + * PENDING 상태의 주문만 취소할 수 있습니다. + */ + public void cancel() { + if (this.status != OrderStatus.PENDING) { + throw new CoreException(ErrorType.BAD_REQUEST, + String.format("취소할 수 없는 주문 상태입니다. (현재 상태: %s)", this.status)); + } + this.status = OrderStatus.CANCELED; + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java new file mode 100644 index 000000000..8e18bc60d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java @@ -0,0 +1,56 @@ +package com.loopers.domain.order; + +import jakarta.persistence.Embeddable; +import lombok.EqualsAndHashCode; +import lombok.Getter; + +/** + * 주문 아이템 Value Object. + *

+ * 주문에 포함된 상품 정보와 수량을 나타냅니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Getter +@EqualsAndHashCode +@Embeddable +public class OrderItem { + private Long productId; + private String name; + private Integer price; + private Integer quantity; + + protected OrderItem() { + } + + /** + * OrderItem 인스턴스를 생성합니다. + * + * @param productId 상품 ID + * @param name 상품 이름 + * @param price 상품 가격 + * @param quantity 수량 + */ + public OrderItem(Long productId, String name, Integer price, Integer quantity) { + this.productId = productId; + this.name = name; + this.price = price; + this.quantity = quantity; + } + + /** + * OrderItem 인스턴스를 생성하는 정적 팩토리 메서드. + * + * @param productId 상품 ID + * @param name 상품 이름 + * @param price 상품 가격 + * @param quantity 수량 + * @return 생성된 OrderItem 인스턴스 + */ + public static OrderItem of(Long productId, String name, Integer price, Integer quantity) { + return new OrderItem(productId, name, price, quantity); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java new file mode 100644 index 000000000..a6f9870dc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java @@ -0,0 +1,39 @@ +package com.loopers.domain.order; + +import java.util.List; +import java.util.Optional; + +/** + * 주문 저장소 인터페이스. + *

+ * Order 엔티티의 영속성 계층 접근을 추상화합니다. + *

+ */ +public interface OrderRepository { + + /** + * 주문을 저장합니다. + * + * @param order 저장할 주문 + * @return 저장된 주문 + */ + Order save(Order order); + + /** + * 주문 ID로 주문을 조회합니다. + * + * @param orderId 조회할 주문 ID + * @return 조회된 주문 + */ + Optional findById(Long orderId); + + /** + * 사용자 ID로 주문 목록을 조회합니다. + * + * @param userId 사용자 ID + * @return 해당 사용자의 주문 목록 + */ + List findAllByUserId(Long userId); +} + + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java new file mode 100644 index 000000000..59fa340ce --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java @@ -0,0 +1,15 @@ +package com.loopers.domain.order; + +/** + * 주문 상태 enum. + * + * @author Loopers + * @version 1.0 + */ +public enum OrderStatus { + PENDING, + COMPLETED, + CANCELED +} + + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java deleted file mode 100644 index 663d4080f..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java +++ /dev/null @@ -1,84 +0,0 @@ -package com.loopers.domain.point; - -import com.loopers.domain.BaseEntity; -import com.loopers.domain.user.User; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.*; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; - -/** - * 포인트 도메인 엔티티. - *

- * 사용자의 포인트 잔액을 관리하며, 포인트 충전 기능을 제공합니다. - * 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 deleted file mode 100644 index cbf2c3d08..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointRepository.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.loopers.domain.point; - -/** - * Point 엔티티에 대한 저장소 인터페이스. - *

- * 포인트 정보의 영속성 계층과의 상호작용을 정의합니다. - *

- * - * @author Loopers - * @version 1.0 - */ -public interface PointRepository { - /** - * 포인트를 저장합니다. - * - * @param point 저장할 포인트 - * @return 저장된 포인트 - */ - Point save(Point point); - - /** - * 사용자 ID로 포인트를 조회합니다. - * - * @param userId 조회할 사용자 ID - * @return 조회된 포인트, 없으면 null - */ - Point findByUserId(String userId); -} - - diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java deleted file mode 100644 index 5d03e73ef..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.loopers.domain.point; - -import com.loopers.domain.user.User; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -/** - * 포인트 도메인 서비스. - *

- * 포인트 생성, 조회, 충전 등의 도메인 로직을 처리합니다. - *

- * - * @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/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java new file mode 100644 index 000000000..52315a26e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -0,0 +1,160 @@ +package com.loopers.domain.product; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 상품 도메인 엔티티. + *

+ * 상품의 기본 정보(이름, 가격, 재고, 브랜드)를 관리하며, + * 주문 시 재고 차감 기능을 제공합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Entity +@Table(name = "product") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class Product extends BaseEntity { + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "price", nullable = false) + private Integer price; + + @Column(name = "stock", nullable = false) + private Integer stock; + + @Column(name = "ref_brand_id", nullable = false) + private Long brandId; + + /** + * Product 인스턴스를 생성합니다. + * + * @param name 상품 이름 (필수) + * @param price 상품 가격 (필수, 0 이상) + * @param stock 상품 재고 (필수, 0 이상) + * @param brandId 브랜드 ID (필수) + * @throws CoreException 유효성 검증 실패 시 + */ + public Product(String name, Integer price, Integer stock, Long brandId) { + validateName(name); + validatePrice(price); + validateStock(stock); + validateBrandId(brandId); + this.name = name; + this.price = price; + this.stock = stock; + this.brandId = brandId; + } + + /** + * Product 인스턴스를 생성하는 정적 팩토리 메서드. + * + * @param name 상품 이름 + * @param price 상품 가격 + * @param stock 상품 재고 + * @param brandId 브랜드 ID + * @return 생성된 Product 인스턴스 + * @throws CoreException 유효성 검증 실패 시 + */ + public static Product of(String name, Integer price, Integer stock, Long brandId) { + return new Product(name, price, stock, brandId); + } + + /** + * 재고를 차감합니다. + * 재고는 감소만 가능하며 음수가 되지 않도록 도메인 레벨에서 검증합니다. + * + * @param quantity 차감할 수량 (0보다 커야 함) + * @throws CoreException quantity가 null, 0 이하이거나 재고가 부족할 경우 + */ + public void decreaseStock(Integer quantity) { + validateQuantity(quantity); + if (this.stock < quantity) { + throw new CoreException(ErrorType.BAD_REQUEST, + String.format("재고가 부족합니다. (현재 재고: %d, 요청 수량: %d)", this.stock, quantity)); + } + this.stock -= quantity; + } + + /** + * 재고를 증가시킵니다. + * 주문 취소 시 재고를 원복하는 데 사용됩니다. + * + * @param quantity 증가시킬 수량 (0보다 커야 함) + * @throws CoreException quantity가 null이거나 0 이하일 경우 + */ + public void increaseStock(Integer quantity) { + validateQuantity(quantity); + this.stock += quantity; + } + + /** + * 상품 이름의 유효성을 검증합니다. + * + * @param name 검증할 상품 이름 + * @throws CoreException name이 null이거나 공백일 경우 + */ + private void validateName(String name) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 이름은 필수입니다."); + } + } + + /** + * 상품 가격의 유효성을 검증합니다. + * + * @param price 검증할 상품 가격 + * @throws CoreException price가 null이거나 0 미만일 경우 + */ + private void validatePrice(Integer price) { + if (price == null || price < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 가격은 0 이상이어야 합니다."); + } + } + + /** + * 상품 재고의 유효성을 검증합니다. + * + * @param stock 검증할 상품 재고 + * @throws CoreException stock이 null이거나 0 미만일 경우 + */ + private void validateStock(Integer stock) { + if (stock == null || stock < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 재고는 0 이상이어야 합니다."); + } + } + + /** + * 브랜드 ID의 유효성을 검증합니다. + * + * @param brandId 검증할 브랜드 ID + * @throws CoreException brandId가 null일 경우 + */ + private void validateBrandId(Long brandId) { + if (brandId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 ID는 필수입니다."); + } + } + + /** + * 수량의 유효성을 검증합니다. + * + * @param quantity 검증할 수량 + * @throws CoreException quantity가 null이거나 0 이하일 경우 + */ + private void validateQuantity(Integer quantity) { + if (quantity == null || quantity <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "수량은 0보다 커야 합니다."); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDetail.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDetail.java new file mode 100644 index 000000000..190b4a00d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDetail.java @@ -0,0 +1,53 @@ +package com.loopers.domain.product; + +import lombok.EqualsAndHashCode; +import lombok.Getter; + +/** + * 상품 상세 정보 Value Object. + *

+ * 상품 상세 조회 시 Product, Brand 정보, 좋아요 수를 조합한 결과를 나타냅니다. + * 값으로 식별되며 불변성을 가집니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Getter +@EqualsAndHashCode +public class ProductDetail { + private final Long id; + private final String name; + private final Integer price; + private final Integer stock; + private final Long brandId; + private final String brandName; + private final Long likesCount; + + private ProductDetail(Long id, String name, Integer price, Integer stock, Long brandId, String brandName, Long likesCount) { + this.id = id; + this.name = name; + this.price = price; + this.stock = stock; + this.brandId = brandId; + this.brandName = brandName; + this.likesCount = likesCount; + } + + /** + * ProductDetail 인스턴스를 생성하는 정적 팩토리 메서드. + * + * @param id 상품 ID + * @param name 상품 이름 + * @param price 상품 가격 + * @param stock 상품 재고 + * @param brandId 브랜드 ID + * @param brandName 브랜드 이름 + * @param likesCount 좋아요 수 + * @return 생성된 ProductDetail 인스턴스 + */ + public static ProductDetail of(Long id, String name, Integer price, Integer stock, Long brandId, String brandName, Long likesCount) { + return new ProductDetail(id, name, price, stock, brandId, brandName, likesCount); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDetailService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDetailService.java new file mode 100644 index 000000000..ef2614040 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDetailService.java @@ -0,0 +1,53 @@ +package com.loopers.domain.product; + +import com.loopers.domain.brand.Brand; +import org.springframework.stereotype.Component; + +/** + * 상품 상세 정보 조합 도메인 서비스. + *

+ * 상품 상세 조회 시 Product와 Brand 정보, 좋아요 수를 조합하는 도메인 로직을 처리합니다. + * 도메인 간 협력 로직(Product + Brand + Like)을 담당합니다. + *

+ *

+ * 상태가 없고, 도메인 객체의 협력 중심으로 설계되었습니다. + * Repository 의존성 없이 도메인 객체(Product, Brand, likesCount)를 파라미터로 받아 처리합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Component +public class ProductDetailService { + + /** + * 상품과 브랜드 정보, 좋아요 수를 조합하여 상품 상세 정보를 생성합니다. + * + * @param product 상품 엔티티 + * @param brand 브랜드 엔티티 + * @param likesCount 좋아요 수 + * @return 조합된 상품 상세 정보 + */ + public ProductDetail combineProductAndBrand(Product product, Brand brand, Long likesCount) { + if (product == null) { + throw new IllegalArgumentException("상품은 null일 수 없습니다."); + } + if (brand == null) { + throw new IllegalArgumentException("브랜드 정보는 필수입니다."); + } + if (likesCount == null) { + throw new IllegalArgumentException("좋아요 수는 null일 수 없습니다."); + } + + return ProductDetail.of( + product.getId(), + product.getName(), + product.getPrice(), + product.getStock(), + product.getBrandId(), + brand.getName(), + likesCount + ); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java new file mode 100644 index 000000000..ca4837bd9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -0,0 +1,51 @@ +package com.loopers.domain.product; + +import java.util.List; +import java.util.Optional; + +/** + * Product 엔티티에 대한 저장소 인터페이스. + *

+ * 상품 정보의 영속성 계층과의 상호작용을 정의합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public interface ProductRepository { + /** + * 상품을 저장합니다. + * + * @param product 저장할 상품 + * @return 저장된 상품 + */ + Product save(Product product); + + /** + * 상품 ID로 상품을 조회합니다. + * + * @param productId 조회할 상품 ID + * @return 조회된 상품을 담은 Optional + */ + Optional findById(Long productId); + + /** + * 상품 목록을 조회합니다. + * + * @param brandId 브랜드 ID (null이면 전체 조회) + * @param sort 정렬 기준 (latest, price_asc, likes_desc) + * @param page 페이지 번호 (0부터 시작) + * @param size 페이지당 상품 수 + * @return 상품 목록 + */ + List findAll(Long brandId, String sort, int page, int size); + + /** + * 상품 목록의 총 개수를 조회합니다. + * + * @param brandId 브랜드 ID (null이면 전체 조회) + * @return 상품 총 개수 + */ + long countAll(Long brandId); +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/Point.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/Point.java new file mode 100644 index 000000000..5754b0dad --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/Point.java @@ -0,0 +1,98 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.EqualsAndHashCode; +import lombok.Getter; + +/** + * 포인트 Value Object. + *

+ * 사용자의 포인트 잔액을 나타내는 불변 값 객체입니다. + * 값으로 식별되며 불변성을 가집니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Embeddable +@Getter +@EqualsAndHashCode +public class Point { + @Column(name = "balance", nullable = false) + private Long value; + + protected Point() { + this.value = 0L; // JPA를 위한 기본 생성자 + } + + /** + * Point 인스턴스를 생성합니다. + * + * @param value 포인트 값 (0 이상이어야 함) + * @throws CoreException value가 null이거나 음수일 경우 + */ + public Point(Long value) { + validateValue(value); + this.value = value; + } + + /** + * Point 인스턴스를 생성하는 정적 팩토리 메서드. + * + * @param value 포인트 값 + * @return 생성된 Point 인스턴스 + * @throws CoreException value가 null이거나 음수일 경우 + */ + public static Point of(Long value) { + return new Point(value); + } + + /** + * 포인트를 더한 새로운 Point 인스턴스를 반환합니다. + * + * @param other 더할 포인트 + * @return 새로운 Point 인스턴스 + * @throws CoreException other가 null일 경우 + */ + public Point add(Point other) { + if (other == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "포인트는 null일 수 없습니다."); + } + return new Point(this.value + other.value); + } + + /** + * 포인트를 뺀 새로운 Point 인스턴스를 반환합니다. + * 포인트는 감소만 가능하며 음수가 되지 않도록 도메인 레벨에서 검증합니다. + * + * @param other 뺄 포인트 + * @return 새로운 Point 인스턴스 + * @throws CoreException other가 null이거나 잔액이 부족할 경우 + */ + public Point subtract(Point other) { + if (other == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "포인트는 null일 수 없습니다."); + } + if (this.value < other.value) { + throw new CoreException(ErrorType.BAD_REQUEST, + String.format("포인트가 부족합니다. (현재 잔액: %d, 요청 금액: %d)", this.value, other.value)); + } + return new Point(this.value - other.value); + } + + /** + * 포인트 값의 유효성을 검증합니다. + * + * @param value 검증할 포인트 값 + * @throws CoreException value가 null이거나 음수일 경우 + */ + private void validateValue(Long value) { + if (value == null || value < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "포인트는 0 이상이어야 합니다."); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java index f547389c5..96dadff67 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java @@ -4,6 +4,7 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import jakarta.persistence.Column; +import jakarta.persistence.Embedded; import jakarta.persistence.Entity; import jakarta.persistence.Table; import jakarta.persistence.Enumerated; @@ -49,6 +50,9 @@ public class User extends BaseEntity { @Enumerated(EnumType.STRING) private Gender gender; + @Embedded + private Point point; + private static final Pattern USER_ID_PATTERN = Pattern.compile("^[a-zA-Z0-9]{1,10}$"); /** * 사용자 ID의 유효성을 검증합니다. @@ -107,7 +111,7 @@ private static void validateBirthDate(String birthDate) { * @param gender 성별 * @throws CoreException userId, email, birthDate가 유효하지 않을 경우 */ - public User (String userId, String email, String birthDateStr, Gender gender) { + public User (String userId, String email, String birthDateStr, Gender gender, Point point) { validateUserId(userId); validateEmail(email); validateBirthDate(birthDateStr); @@ -116,6 +120,7 @@ public User (String userId, String email, String birthDateStr, Gender gender) { this.email = email; this.birthDate = LocalDate.parse(birthDateStr); this.gender = gender; + this.point = point; } /** * User 인스턴스를 생성하는 정적 팩토리 메서드. @@ -127,8 +132,38 @@ public User (String userId, String email, String birthDateStr, Gender gender) { * @return 생성된 User 인스턴스 * @throws CoreException 유효성 검증 실패 시 */ - public static User of(String userId, String email, String birthDate, Gender gender) { - return new User(userId, email, birthDate, gender); + public static User of(String userId, String email, String birthDate, Gender gender, Point point) { + return new User(userId, email, birthDate, gender, point); + } + + /** + * 포인트를 반환합니다. + * + * @return 포인트 Value Object + */ + public Point getPoint() { + return this.point; + } + + /** + * 포인트를 받습니다 (충전/환불). + * + * @param point 받을 포인트 + * @throws CoreException point가 null일 경우 + */ + public void receivePoint(Point point) { + this.point = this.point.add(point); + } + + /** + * 포인트를 차감합니다. + * 포인트는 감소만 가능하며 음수가 되지 않도록 도메인 레벨에서 검증합니다. + * + * @param point 차감할 포인트 + * @throws CoreException point가 null이거나 잔액이 부족할 경우 + */ + public void deductPoint(Point point) { + this.point = this.point.subtract(point); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java index 8b66f855c..6621a8b62 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -9,7 +9,8 @@ /** * 사용자 도메인 서비스. *

- * 사용자 생성 및 조회 등의 도메인 로직을 처리합니다. + * 사용자 생성 등의 도메인 로직을 처리합니다. + * Repository에 의존하며 데이터 무결성 제약 조건을 처리합니다. *

* * @author Loopers @@ -30,8 +31,8 @@ public class UserService { * @return 생성된 사용자 * @throws CoreException 중복된 사용자 ID가 존재하거나 유효성 검증 실패 시 */ - public User create(String userId, String email, String birthDateStr, Gender gender) { - User user = User.of(userId, email, birthDateStr, gender); + public User create(String userId, String email, String birthDateStr, Gender gender, Point point) { + User user = User.of(userId, email, birthDateStr, gender, point); try { return userRepository.save(user); } catch (DataIntegrityViolationException e) { @@ -42,13 +43,4 @@ public User create(String userId, String email, String birthDateStr, Gender gend } } - /** - * 사용자 ID로 사용자를 조회합니다. - * - * @param userId 조회할 사용자 ID - * @return 조회된 사용자, 없으면 null - */ - public User findByUserId(String userId) { - return userRepository.findByUserId(userId); - } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java new file mode 100644 index 000000000..1d28a2376 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.Brand; +import org.springframework.data.jpa.repository.JpaRepository; + +/** + * Brand 엔티티를 위한 Spring Data JPA 리포지토리. + */ +public interface BrandJpaRepository extends JpaRepository { +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java new file mode 100644 index 000000000..7e616b95b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java @@ -0,0 +1,28 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +/** + * BrandRepository의 JPA 구현체. + */ +@RequiredArgsConstructor +@Repository +public class BrandRepositoryImpl implements BrandRepository { + private final BrandJpaRepository brandJpaRepository; + + @Override + public Brand save(Brand brand) { + return brandJpaRepository.save(brand); + } + + @Override + public Optional findById(Long brandId) { + return brandJpaRepository.findById(brandId); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java new file mode 100644 index 000000000..3c1c3399a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java @@ -0,0 +1,60 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.Like; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * Like 엔티티를 위한 Spring Data JPA 리포지토리. + */ +public interface LikeJpaRepository extends JpaRepository { + /** + * 사용자 ID와 상품 ID로 좋아요를 조회합니다. + * + * @param userId 사용자 ID + * @param productId 상품 ID + * @return 조회된 좋아요를 담은 Optional + */ + Optional findByUserIdAndProductId(Long userId, Long productId); + + /** + * 사용자 ID로 좋아요 목록을 조회합니다. + * + * @param userId 사용자 ID + * @return 좋아요 목록 + */ + List findAllByUserId(Long userId); + + /** + * 상품별 좋아요 수를 집계합니다. + * + * @param productIds 상품 ID 목록 + * @return 상품 ID와 좋아요 수의 쌍 목록 + */ + @Query("SELECT l.productId, COUNT(l) FROM Like l WHERE l.productId IN :productIds GROUP BY l.productId") + List countByProductIds(@Param("productIds") List productIds); + + /** + * 상품별 좋아요 수를 Map으로 변환합니다. + * + * @param productIds 상품 ID 목록 + * @return 상품 ID를 키로, 좋아요 수를 값으로 하는 Map + */ + default Map countByProductIdsAsMap(List productIds) { + if (productIds == null || productIds.isEmpty()) { + return Map.of(); + } + return countByProductIds(productIds).stream() + .collect(Collectors.toMap( + row -> (Long) row[0], + row -> ((Number) row[1]).longValue() + )); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java new file mode 100644 index 000000000..bd169e7b2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java @@ -0,0 +1,45 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * LikeRepository의 JPA 구현체. + */ +@RequiredArgsConstructor +@Repository +public class LikeRepositoryImpl implements LikeRepository { + private final LikeJpaRepository likeJpaRepository; + + @Override + public Like save(Like like) { + return likeJpaRepository.save(like); + } + + @Override + public Optional findByUserIdAndProductId(Long userId, Long productId) { + return likeJpaRepository.findByUserIdAndProductId(userId, productId); + } + + @Override + public void delete(Like like) { + likeJpaRepository.delete(like); + } + + @Override + public List findAllByUserId(Long userId) { + return likeJpaRepository.findAllByUserId(userId); + } + + @Override + public Map countByProductIds(List productIds) { + return likeJpaRepository.countByProductIdsAsMap(productIds); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java new file mode 100644 index 000000000..02808e69c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -0,0 +1,15 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.Order; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +/** + * Order JPA Repository. + */ +public interface OrderJpaRepository extends JpaRepository { + List findAllByUserId(Long userId); +} + + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java new file mode 100644 index 000000000..9440a7aa8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -0,0 +1,36 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +/** + * OrderRepository의 JPA 구현체. + */ +@RequiredArgsConstructor +@Repository +public class OrderRepositoryImpl implements OrderRepository { + + private final OrderJpaRepository orderJpaRepository; + + @Override + public Order save(Order order) { + return orderJpaRepository.save(order); + } + + @Override + public Optional findById(Long orderId) { + return orderJpaRepository.findById(orderId); + } + + @Override + public List findAllByUserId(Long userId) { + return orderJpaRepository.findAllByUserId(userId); + } +} + + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointJpaRepository.java deleted file mode 100644 index b32add174..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointJpaRepository.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.loopers.infrastructure.point; - -import com.loopers.domain.point.Point; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -import java.util.Optional; - -/** - * Point 엔티티를 위한 Spring Data JPA 리포지토리. - *

- * 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 deleted file mode 100644 index f85e31482..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointRepositoryImpl.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.loopers.infrastructure.point; - -import com.loopers.domain.point.Point; -import com.loopers.domain.point.PointRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -/** - * PointRepository의 JPA 구현체. - *

- * 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/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java new file mode 100644 index 000000000..fe294dad8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -0,0 +1,43 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.Product; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +/** + * Product 엔티티를 위한 Spring Data JPA 리포지토리. + *

+ * JpaRepository를 확장하여 기본 CRUD 기능을 제공합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public interface ProductJpaRepository extends JpaRepository { + /** + * 브랜드 ID로 상품을 조회합니다. + * + * @param brandId 브랜드 ID + * @param pageable 페이징 정보 + * @return 상품 페이지 + */ + Page findByBrandId(Long brandId, Pageable pageable); + + /** + * 전체 상품을 조회합니다. + * + * @param pageable 페이징 정보 + * @return 상품 페이지 + */ + Page findAll(Pageable pageable); + + /** + * 브랜드 ID로 상품 개수를 조회합니다. + * + * @param brandId 브랜드 ID + * @return 상품 개수 + */ + long countByBrandId(Long brandId); +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java new file mode 100644 index 000000000..f578a7d3a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -0,0 +1,77 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +/** + * ProductRepository의 JPA 구현체. + *

+ * Spring Data JPA를 활용하여 Product 엔티티의 + * 영속성 작업을 처리합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@RequiredArgsConstructor +@Component +public class ProductRepositoryImpl implements ProductRepository { + private final ProductJpaRepository productJpaRepository; + + /** + * {@inheritDoc} + */ + @Override + public Product save(Product product) { + return productJpaRepository.save(product); + } + + /** + * {@inheritDoc} + */ + @Override + public Optional findById(Long productId) { + return productJpaRepository.findById(productId); + } + + /** + * {@inheritDoc} + */ + @Override + public List findAll(Long brandId, String sort, int page, int size) { + Pageable pageable = createPageable(sort, page, size); + Page productPage = brandId != null + ? productJpaRepository.findByBrandId(brandId, pageable) + : productJpaRepository.findAll(pageable); + return productPage.getContent(); + } + + /** + * {@inheritDoc} + */ + @Override + public long countAll(Long brandId) { + return brandId != null + ? productJpaRepository.countByBrandId(brandId) + : productJpaRepository.count(); + } + + private Pageable createPageable(String sort, int page, int size) { + Sort sortObj = switch (sort != null ? sort : "latest") { + case "price_asc" -> Sort.by(Sort.Direction.ASC, "price"); + case "likes_desc" -> Sort.by(Sort.Direction.DESC, "id"); // 좋아요 수는 별도 처리 필요 + default -> Sort.by(Sort.Direction.DESC, "createdAt"); + }; + return PageRequest.of(page, size, sortObj); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointsV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/pointwallet/PointWalletV1Controller.java similarity index 54% rename from apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointsV1Controller.java rename to apps/commerce-api/src/main/java/com/loopers/interfaces/api/pointwallet/PointWalletV1Controller.java index c313fbb7d..4efc043ca 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointsV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/pointwallet/PointWalletV1Controller.java @@ -1,10 +1,7 @@ -package com.loopers.interfaces.api.point; +package com.loopers.interfaces.api.pointwallet; -import com.loopers.domain.point.Point; -import com.loopers.domain.point.PointService; +import com.loopers.application.pointwallet.PointWalletFacade; import com.loopers.interfaces.api.ApiResponse; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; @@ -15,9 +12,9 @@ import org.springframework.web.bind.annotation.RestController; /** - * 포인트 API v1 컨트롤러. + * 포인트 관리 API v1 컨트롤러. *

- * 사용자의 포인트 조회 및 충전 기능을 제공합니다. + * 사용자의 포인트 조회 및 충전 유즈케이스를 처리합니다. *

* * @author Loopers @@ -26,27 +23,23 @@ @RequiredArgsConstructor @RestController @RequestMapping("/api/v1") -public class PointsV1Controller { +public class PointWalletV1Controller { - private final PointService pointService; + private final PointWalletFacade pointWalletFacade; /** * 현재 사용자의 포인트를 조회합니다. * * @param userId X-USER-ID 헤더로 전달된 사용자 ID * @return 포인트 정보를 담은 API 응답 - * @throws CoreException 포인트를 찾을 수 없는 경우 + * @throws CoreException 사용자를 찾을 수 없는 경우 */ @GetMapping("/me/points") - public ApiResponse getMyPoints( + 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)); + PointWalletFacade.PointsInfo pointsInfo = pointWalletFacade.getPoints(userId); + return ApiResponse.success(PointWalletV1Dto.PointsResponse.from(pointsInfo)); } /** @@ -55,15 +48,15 @@ public ApiResponse getMyPoints( * @param userId X-USER-ID 헤더로 전달된 사용자 ID * @param request 충전 요청 데이터 (amount) * @return 충전된 포인트 정보를 담은 API 응답 - * @throws CoreException 포인트를 찾을 수 없거나 충전 금액이 유효하지 않은 경우 + * @throws CoreException 사용자를 찾을 수 없거나 충전 금액이 유효하지 않은 경우 */ @PostMapping("/me/points/charge") - public ApiResponse chargePoints( + public ApiResponse chargePoints( @RequestHeader("X-USER-ID") String userId, - @Valid @RequestBody PointsV1Dto.ChargeRequest request + @Valid @RequestBody PointWalletV1Dto.ChargeRequest request ) { - Point point = pointService.charge(userId, request.amount()); - return ApiResponse.success(PointsV1Dto.PointsResponse.from(point)); + PointWalletFacade.PointsInfo pointsInfo = pointWalletFacade.chargePoint(userId, request.amount()); + return ApiResponse.success(PointWalletV1Dto.PointsResponse.from(pointsInfo)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointsV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/pointwallet/PointWalletV1Dto.java similarity index 61% rename from apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointsV1Dto.java rename to apps/commerce-api/src/main/java/com/loopers/interfaces/api/pointwallet/PointWalletV1Dto.java index 3107b17a0..461605598 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointsV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/pointwallet/PointWalletV1Dto.java @@ -1,16 +1,16 @@ -package com.loopers.interfaces.api.point; +package com.loopers.interfaces.api.pointwallet; -import com.loopers.domain.point.Point; +import com.loopers.application.pointwallet.PointWalletFacade; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; /** - * 포인트 API v1의 데이터 전송 객체(DTO) 컨테이너. + * 포인트 관리 API v1의 데이터 전송 객체(DTO) 컨테이너. * * @author Loopers * @version 1.0 */ -public class PointsV1Dto { +public class PointWalletV1Dto { /** * 포인트 정보 응답 데이터. * @@ -19,13 +19,13 @@ public class PointsV1Dto { */ public record PointsResponse(String userId, Long balance) { /** - * Point 엔티티로부터 PointsResponse를 생성합니다. + * PointsInfo로부터 PointsResponse를 생성합니다. * - * @param point 포인트 엔티티 + * @param pointsInfo 포인트 정보 * @return 생성된 응답 객체 */ - public static PointsResponse from(Point point) { - return new PointsResponse(point.getUser().getUserId(), point.getBalance()); + public static PointsResponse from(PointWalletFacade.PointsInfo pointsInfo) { + return new PointsResponse(pointsInfo.userId(), pointsInfo.balance()); } } @@ -39,4 +39,5 @@ public record ChargeRequest( @Positive(message = "포인트는 0보다 큰 값이어야 합니다.") Long amount ) {} -} \ No newline at end of file +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/signup/SignUpV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/signup/SignUpV1Controller.java index 13f72103f..3ce1a8c6e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/signup/SignUpV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/signup/SignUpV1Controller.java @@ -2,13 +2,9 @@ import com.loopers.application.signup.SignUpFacade; import com.loopers.application.signup.SignUpInfo; -import com.loopers.domain.user.Gender; import com.loopers.interfaces.api.ApiResponse; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import java.util.Locale; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -41,15 +37,12 @@ public class SignUpV1Controller { public ApiResponse signUp( @Valid @RequestBody SignUpV1Dto.SignUpRequest request ) { - Gender gender; - try { - String genderValue = request.gender().trim().toUpperCase(Locale.ROOT); - gender = Gender.valueOf(genderValue); - } catch (IllegalArgumentException e) { - throw new CoreException(ErrorType.BAD_REQUEST, "gender 값이 올바르지 않습니다."); - } - - SignUpInfo info = signUpFacade.signUp(request.userId(), request.email(), request.birthDate(), gender); + SignUpInfo info = signUpFacade.signUp( + request.userId(), + request.email(), + request.birthDate(), + request.gender() + ); SignUpV1Dto.SignupResponse response = SignUpV1Dto.SignupResponse.from(info); return ApiResponse.success(response); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/userinfo/UserInfoV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/userinfo/UserInfoV1Controller.java index 36b858d05..a125747e0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/userinfo/UserInfoV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/userinfo/UserInfoV1Controller.java @@ -1,10 +1,7 @@ package com.loopers.interfaces.api.userinfo; -import com.loopers.domain.user.User; -import com.loopers.domain.user.UserService; +import com.loopers.application.userinfo.UserInfoFacade; import com.loopers.interfaces.api.ApiResponse; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestHeader; @@ -12,9 +9,9 @@ import org.springframework.web.bind.annotation.RestController; /** - * 사용자 정보 API v1 컨트롤러. + * 사용자 정보 조회 API v1 컨트롤러. *

- * 인증된 사용자의 정보 조회 기능을 제공합니다. + * 인증된 사용자의 정보 조회 유즈케이스를 처리합니다. *

* * @author Loopers @@ -25,7 +22,7 @@ @RequestMapping("/api/v1") public class UserInfoV1Controller { - private final UserService userService; + private final UserInfoFacade userInfoFacade; /** * 현재 사용자의 정보를 조회합니다. @@ -38,13 +35,8 @@ public class UserInfoV1Controller { public ApiResponse getMyInfo( @RequestHeader("X-USER-ID") String userId ) { - User user = userService.findByUserId(userId); - if (user == null) { - throw new CoreException(ErrorType.NOT_FOUND, null); - } - - return ApiResponse.success(UserInfoV1Dto.UserInfoResponse.from(user)); + UserInfoFacade.UserInfo userInfo = userInfoFacade.getUserInfo(userId); + return ApiResponse.success(UserInfoV1Dto.UserInfoResponse.from(userInfo)); } } - diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/userinfo/UserInfoV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/userinfo/UserInfoV1Dto.java index 369f684a4..7a8987aca 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/userinfo/UserInfoV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/userinfo/UserInfoV1Dto.java @@ -1,9 +1,9 @@ package com.loopers.interfaces.api.userinfo; -import com.loopers.domain.user.User; +import com.loopers.application.userinfo.UserInfoFacade; /** - * 사용자 정보 API v1의 데이터 전송 객체(DTO) 컨테이너. + * 사용자 정보 조회 API v1의 데이터 전송 객체(DTO) 컨테이너. * * @author Loopers * @version 1.0 @@ -24,18 +24,19 @@ public record UserInfoResponse( String gender ) { /** - * User 엔티티로부터 UserInfoResponse를 생성합니다. + * UserInfo로부터 UserInfoResponse를 생성합니다. * - * @param user 사용자 엔티티 + * @param userInfo 사용자 정보 * @return 생성된 응답 객체 */ - public static UserInfoResponse from(User user) { + public static UserInfoResponse from(UserInfoFacade.UserInfo userInfo) { return new UserInfoResponse( - user.getUserId(), - user.getEmail(), - user.getBirthDate().toString(), - user.getGender() != null ? user.getGender().name() : null + userInfo.userId(), + userInfo.email(), + userInfo.birthDate().toString(), + userInfo.gender() != null ? userInfo.gender().name() : null ); } } } + diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java new file mode 100644 index 000000000..86f7d91a0 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java @@ -0,0 +1,156 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.never; + +@DisplayName("LikeFacade 좋아요 등록/취소/중복 방지 흐름 검증") +class LikeFacadeTest { + + private LikeFacade likeFacade; + private UserRepository userRepository; + private ProductRepository productRepository; + private LikeRepository likeRepository; + + private static final String DEFAULT_USER_ID = "testuser"; + private static final Long DEFAULT_USER_INTERNAL_ID = 1L; + private static final Long DEFAULT_PRODUCT_ID = 1L; + + @BeforeEach + void setUp() { + userRepository = mock(UserRepository.class); + productRepository = mock(ProductRepository.class); + likeRepository = mock(LikeRepository.class); + + likeFacade = new LikeFacade( + likeRepository, + userRepository, + productRepository + ); + } + + @Test + @DisplayName("좋아요를 등록할 수 있다") + void addLike_success() { + // arrange + setupMocks(DEFAULT_USER_ID, DEFAULT_USER_INTERNAL_ID, DEFAULT_PRODUCT_ID); + when(likeRepository.findByUserIdAndProductId(DEFAULT_USER_INTERNAL_ID, DEFAULT_PRODUCT_ID)) + .thenReturn(Optional.empty()); + + // act + likeFacade.addLike(DEFAULT_USER_ID, DEFAULT_PRODUCT_ID); + + // assert + verify(likeRepository).save(any(Like.class)); + } + + @Test + @DisplayName("좋아요를 취소할 수 있다") + void removeLike_success() { + // arrange + setupMocks(DEFAULT_USER_ID, DEFAULT_USER_INTERNAL_ID, DEFAULT_PRODUCT_ID); + Like like = Like.of(DEFAULT_USER_INTERNAL_ID, DEFAULT_PRODUCT_ID); + when(likeRepository.findByUserIdAndProductId(DEFAULT_USER_INTERNAL_ID, DEFAULT_PRODUCT_ID)) + .thenReturn(Optional.of(like)); + + // act + likeFacade.removeLike(DEFAULT_USER_ID, DEFAULT_PRODUCT_ID); + + // assert + verify(likeRepository).delete(like); + } + + @Test + @DisplayName("좋아요는 중복 등록되지 않는다.") + void addLike_isIdempotent() { + // arrange + setupMocks(DEFAULT_USER_ID, DEFAULT_USER_INTERNAL_ID, DEFAULT_PRODUCT_ID); + when(likeRepository.findByUserIdAndProductId(DEFAULT_USER_INTERNAL_ID, DEFAULT_PRODUCT_ID)) + .thenReturn(Optional.of(Like.of(DEFAULT_USER_INTERNAL_ID, DEFAULT_PRODUCT_ID))); + + // act + likeFacade.addLike(DEFAULT_USER_ID, DEFAULT_PRODUCT_ID); + + // assert - save는 한 번만 호출되어야 함 (중복 방지) + verify(likeRepository, never()).save(any(Like.class)); + } + + @Test + @DisplayName("좋아요는 중복 취소되지 않는다.") + void removeLike_isIdempotent() { + // arrange + setupMocks(DEFAULT_USER_ID, DEFAULT_USER_INTERNAL_ID, DEFAULT_PRODUCT_ID); + when(likeRepository.findByUserIdAndProductId(DEFAULT_USER_INTERNAL_ID, DEFAULT_PRODUCT_ID)) + .thenReturn(Optional.empty()); // 좋아요 없음 + + // act - 좋아요가 없는 상태에서 취소 시도 + likeFacade.removeLike(DEFAULT_USER_ID, DEFAULT_PRODUCT_ID); + + // assert - 예외가 발생하지 않아야 함 (멱등성 보장) + verify(likeRepository).findByUserIdAndProductId(DEFAULT_USER_INTERNAL_ID, DEFAULT_PRODUCT_ID); + verify(likeRepository, never()).delete(any(Like.class)); + } + + @Test + @DisplayName("사용자를 찾을 수 없으면 예외를 던진다") + void addLike_userNotFound() { + // arrange + String unknownUserId = "unknown"; + when(userRepository.findByUserId(unknownUserId)).thenReturn(null); + + // act & assert + assertThatThrownBy(() -> likeFacade.addLike(unknownUserId, DEFAULT_PRODUCT_ID)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); + } + + @Test + @DisplayName("상품을 찾을 수 없으면 예외를 던진다") + void addLike_productNotFound() { + // arrange + setupMockUser(DEFAULT_USER_ID, DEFAULT_USER_INTERNAL_ID); + Long nonExistentProductId = 999L; + when(productRepository.findById(nonExistentProductId)).thenReturn(Optional.empty()); + + // act & assert + assertThatThrownBy(() -> likeFacade.addLike(DEFAULT_USER_ID, nonExistentProductId)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); + } + + // Helper methods for test setup + + private void setupMocks(String userId, Long userInternalId, Long productId) { + setupMockUser(userId, userInternalId); + setupMockProduct(productId); + } + + private void setupMockUser(String userId, Long userInternalId) { + User mockUser = mock(User.class); + when(mockUser.getId()).thenReturn(userInternalId); + when(userRepository.findByUserId(userId)).thenReturn(mockUser); + } + + private void setupMockProduct(Long productId) { + Product mockProduct = mock(Product.class); + when(productRepository.findById(productId)).thenReturn(Optional.of(mockProduct)); + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/application/pointwallet/PointWalletFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/pointwallet/PointWalletFacadeIntegrationTest.java new file mode 100644 index 000000000..cebc13975 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/pointwallet/PointWalletFacadeIntegrationTest.java @@ -0,0 +1,115 @@ +package com.loopers.application.pointwallet; + +import com.loopers.application.signup.SignUpFacade; +import com.loopers.domain.user.Gender; +import com.loopers.domain.user.UserTestFixture; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +@DisplayName("PointWalletFacade 통합 테스트") +class PointWalletFacadeIntegrationTest { + @Autowired + private PointWalletFacade pointWalletFacade; + + @Autowired + private SignUpFacade signUpFacade; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("포인트 조회에 관한 통합 테스트") + @Nested + class PointInfo { + @DisplayName("해당 ID 의 회원이 존재할 경우, 보유 포인트가 반환된다.") + @ParameterizedTest + @EnumSource(Gender.class) + void returnsPoints_whenUserExists(Gender gender) { + // arrange + String userId = UserTestFixture.ValidUser.USER_ID; + String email = UserTestFixture.ValidUser.EMAIL; + String birthDate = UserTestFixture.ValidUser.BIRTH_DATE; + signUpFacade.signUp(userId, email, birthDate, gender.name()); + + // act + PointWalletFacade.PointsInfo pointsInfo = pointWalletFacade.getPoints(userId); + + // assert + assertAll( + () -> assertThat(pointsInfo).isNotNull(), + () -> assertThat(pointsInfo.userId()).isEqualTo(userId), + () -> assertThat(pointsInfo.balance()).isEqualTo(0L) + ); + } + + @DisplayName("해당 ID 의 회원이 존재하지 않을 경우, 예외가 발생한다.") + @Test + void throwsException_whenUserDoesNotExist() { + // arrange + String userId = "unknown"; + + // act & assert + assertThatThrownBy(() -> pointWalletFacade.getPoints(userId)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); + } + } + + @DisplayName("포인트 충전에 관한 통합 테스트") + @Nested + class PointCharge { + @DisplayName("포인트를 충전할 수 있다.") + @ParameterizedTest + @EnumSource(Gender.class) + void chargesPoints_success(Gender gender) { + // arrange + String userId = UserTestFixture.ValidUser.USER_ID; + String email = UserTestFixture.ValidUser.EMAIL; + String birthDate = UserTestFixture.ValidUser.BIRTH_DATE; + signUpFacade.signUp(userId, email, birthDate, gender.name()); + Long chargeAmount = 10_000L; + + // act + PointWalletFacade.PointsInfo pointsInfo = pointWalletFacade.chargePoint(userId, chargeAmount); + + // assert + assertAll( + () -> assertThat(pointsInfo).isNotNull(), + () -> assertThat(pointsInfo.userId()).isEqualTo(userId), + () -> assertThat(pointsInfo.balance()).isEqualTo(chargeAmount) + ); + } + + @DisplayName("사용자가 존재하지 않으면 포인트 충전 시 예외가 발생한다.") + @Test + void throwsException_whenUserDoesNotExist() { + // arrange + String userId = "unknown"; + Long chargeAmount = 10_000L; + + // act & assert + assertThatThrownBy(() -> pointWalletFacade.chargePoint(userId, chargeAmount)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); + } + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeTest.java new file mode 100644 index 000000000..0809b50cc --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeTest.java @@ -0,0 +1,242 @@ +package com.loopers.application.purchasing; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.user.Gender; +import com.loopers.domain.user.Point; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest +@DisplayName("PurchasingFacade 주문 스펙 검증") +class PurchasingFacadeTest { + + @Autowired + private PurchasingFacade purchasingFacade; + + @Autowired + private UserRepository userRepository; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private BrandRepository brandRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + // Helper methods for test fixtures + private User createAndSaveUser(String userId, String email, long point) { + User user = User.of(userId, email, "1990-01-01", Gender.MALE, Point.of(point)); + return userRepository.save(user); + } + + private Brand createAndSaveBrand(String brandName) { + Brand brand = Brand.of(brandName); + return brandRepository.save(brand); + } + + private Product createAndSaveProduct(String productName, int price, int stock, Long brandId) { + Product product = Product.of(productName, price, stock, brandId); + return productRepository.save(product); + } + + @Test + @DisplayName("주문 생성 시 재고 차감, 포인트 차감, 주문 완료, 외부 전송을 수행한다") + void createOrder_successFlow() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + Brand brand = createAndSaveBrand("브랜드"); + Product product1 = createAndSaveProduct("상품1", 10_000, 10, brand.getId()); + Product product2 = createAndSaveProduct("상품2", 5_000, 5, brand.getId()); + + List commands = List.of( + new OrderItemCommand(product1.getId(), 2), + new OrderItemCommand(product2.getId(), 1) + ); + + // act + OrderInfo orderInfo = purchasingFacade.createOrder(user.getUserId(), commands); + + // assert + assertThat(orderInfo.status()).isEqualTo(OrderStatus.COMPLETED); + + // 재고 차감 확인 + Product savedProduct1 = productRepository.findById(product1.getId()).orElseThrow(); + Product savedProduct2 = productRepository.findById(product2.getId()).orElseThrow(); + assertThat(savedProduct1.getStock()).isEqualTo(8); // 10 - 2 + assertThat(savedProduct2.getStock()).isEqualTo(4); // 5 - 1 + + // 포인트 차감 확인 + User savedUser = userRepository.findByUserId(user.getUserId()); + assertThat(savedUser.getPoint().getValue()).isEqualTo(25_000L); // 50_000 - (10_000 * 2 + 5_000 * 1) + } + + @Test + @DisplayName("주문 아이템이 비어 있으면 예외를 던진다") + void createOrder_emptyItems_throwsException() { + // arrange + String userId = "user"; + List emptyCommands = List.of(); + + // act & assert + assertThatThrownBy(() -> purchasingFacade.createOrder(userId, emptyCommands)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + + @Test + @DisplayName("사용자를 찾을 수 없으면 예외를 던진다") + void createOrder_userNotFound() { + // arrange + String unknownUserId = "unknown"; + List commands = List.of( + new OrderItemCommand(1L, 1) + ); + + // act & assert + assertThatThrownBy(() -> purchasingFacade.createOrder(unknownUserId, commands)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); + } + + @Test + @DisplayName("상품 재고가 부족하면 예외를 던지고 포인트는 차감되지 않는다") + void createOrder_stockNotEnough() { + // arrange + User user = createAndSaveUser("testuser2", "test2@example.com", 50_000L); + final String userId = user.getUserId(); + + Brand brand = createAndSaveBrand("브랜드2"); + Product product = createAndSaveProduct("상품", 10_000, 1, brand.getId()); + final Long productId = product.getId(); + + List commands = List.of( + new OrderItemCommand(productId, 2) + ); + + // act & assert + assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + + // 포인트가 차감되지 않았는지 확인 + User savedUser = userRepository.findByUserId(userId); + assertThat(savedUser.getPoint().getValue()).isEqualTo(50_000L); + } + + @Test + @DisplayName("동일 상품을 중복 주문하면 예외를 던진다") + void createOrder_duplicateProducts_throwsException() { + // arrange + User user = createAndSaveUser("testuser3", "test3@example.com", 50_000L); + final String userId = user.getUserId(); + + Brand brand = createAndSaveBrand("브랜드3"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + final Long productId = product.getId(); + + List commands = List.of( + new OrderItemCommand(productId, 1), + new OrderItemCommand(productId, 2) + ); + + // act & assert + assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + + // 주문이 저장되지 않았는지 확인 + List orders = purchasingFacade.getOrders(userId); + assertThat(orders).isEmpty(); + } + + @Test + @DisplayName("사용자의 주문 목록을 조회한다") + void getOrders_returnsUserOrders() { + // arrange + User user = createAndSaveUser("testuser4", "test4@example.com", 50_000L); + Brand brand = createAndSaveBrand("브랜드4"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + + List commands = List.of( + new OrderItemCommand(product.getId(), 1) + ); + purchasingFacade.createOrder(user.getUserId(), commands); + + // act + List orders = purchasingFacade.getOrders(user.getUserId()); + + // assert + assertThat(orders).hasSize(1); + assertThat(orders.get(0).status()).isEqualTo(OrderStatus.COMPLETED); + } + + @Test + @DisplayName("사용자의 단일 주문을 조회한다") + void getOrder_returnsSingleOrder() { + // arrange + User user = createAndSaveUser("testuser5", "test5@example.com", 50_000L); + Brand brand = createAndSaveBrand("브랜드5"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + + List commands = List.of( + new OrderItemCommand(product.getId(), 1) + ); + OrderInfo createdOrder = purchasingFacade.createOrder(user.getUserId(), commands); + + // act + OrderInfo found = purchasingFacade.getOrder(user.getUserId(), createdOrder.orderId()); + + // assert + assertThat(found.orderId()).isEqualTo(createdOrder.orderId()); + assertThat(found.status()).isEqualTo(OrderStatus.COMPLETED); + } + + @Test + @DisplayName("다른 사용자의 주문은 조회할 수 없다") + void getOrder_withDifferentUser_throwsException() { + // arrange + User user1 = createAndSaveUser("user1", "user1@example.com", 50_000L); + User user2 = createAndSaveUser("user2", "user2@example.com", 50_000L); + final String user1Id = user1.getUserId(); + final String user2Id = user2.getUserId(); + + Brand brand = createAndSaveBrand("브랜드6"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + + List commands = List.of( + new OrderItemCommand(product.getId(), 1) + ); + OrderInfo user1Order = purchasingFacade.createOrder(user1Id, commands); + final Long orderId = user1Order.orderId(); + + // act & assert + assertThatThrownBy(() -> purchasingFacade.getOrder(user2Id, orderId)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); + } + +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/signup/SignUpFacadeIntegrationTest.java similarity index 60% rename from apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java rename to apps/commerce-api/src/test/java/com/loopers/application/signup/SignUpFacadeIntegrationTest.java index 82d76bad8..c827cae1d 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/signup/SignUpFacadeIntegrationTest.java @@ -1,7 +1,8 @@ -package com.loopers.domain.user; +package com.loopers.application.signup; -import com.loopers.application.signup.SignUpFacade; -import com.loopers.application.signup.SignUpInfo; +import com.loopers.domain.user.Gender; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserTestFixture; import com.loopers.infrastructure.user.UserJpaRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; @@ -9,7 +10,6 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; import org.mockito.Mockito; @@ -17,8 +17,6 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; -import java.time.LocalDate; - import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -27,13 +25,11 @@ import static org.mockito.Mockito.verify; @SpringBootTest -public class UserServiceIntegrationTest { +@DisplayName("SignUpFacade 통합 테스트") +class SignUpFacadeIntegrationTest { @Autowired private SignUpFacade signUpFacade; - @Autowired - private UserService userService; - @MockitoSpyBean private UserJpaRepository userJpaRepository; @@ -51,7 +47,7 @@ class SignUp { @DisplayName("회원가입시 User 저장이 수행된다.") @ParameterizedTest @EnumSource(Gender.class) - void returnsExampleInfo_whenValidIdIsProvided(Gender gender) { + void returnsSignUpInfo_whenValidIdIsProvided(Gender gender) { // arrange String userId = UserTestFixture.ValidUser.USER_ID; String email = UserTestFixture.ValidUser.EMAIL; @@ -59,7 +55,7 @@ void returnsExampleInfo_whenValidIdIsProvided(Gender gender) { Mockito.reset(userJpaRepository); // act - SignUpInfo signUpInfo = signUpFacade.signUp(userId, email, birthDate, gender); + SignUpInfo signUpInfo = signUpFacade.signUp(userId, email, birthDate, gender.name()); // assert assertAll( @@ -77,55 +73,16 @@ void fails_whenDuplicateUserIdExists(Gender gender) { String userId = UserTestFixture.ValidUser.USER_ID; String email = UserTestFixture.ValidUser.EMAIL; String birthDate = UserTestFixture.ValidUser.BIRTH_DATE; - signUpFacade.signUp(userId, email, birthDate, gender); + signUpFacade.signUp(userId, email, birthDate, gender.name()); // act CoreException result = assertThrows(CoreException.class, () -> - signUpFacade.signUp(userId, email, birthDate, gender) + signUpFacade.signUp(userId, email, birthDate, gender.name()) ); // assert assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT); } } - - @DisplayName("회원 조회에 관한 통합 테스트") - @Nested - class UserInfo { - @DisplayName("해당 ID 의 회원이 존재할 경우, 회원 정보가 반환된다.") - @ParameterizedTest - @EnumSource(Gender.class) - void returnsUser_whenUserExists(Gender gender) { - // arrange - String userId = UserTestFixture.ValidUser.USER_ID; - String email = UserTestFixture.ValidUser.EMAIL; - String birthDate = UserTestFixture.ValidUser.BIRTH_DATE; - signUpFacade.signUp(userId, email, birthDate, gender); - - // act - User found = userService.findByUserId(userId); - - // assert - assertAll( - () -> assertThat(found).isNotNull(), - () -> assertThat(found.getUserId()).isEqualTo(userId), - () -> assertThat(found.getEmail()).isEqualTo(email), - () -> assertThat(found.getBirthDate()).isEqualTo(LocalDate.parse(birthDate)), - () -> assertThat(found.getGender()).isEqualTo(gender) - ); - } - - @DisplayName("해당 ID 의 회원이 존재하지 않을 경우, null 이 반환된다.") - @Test - void returnsNull_whenUserDoesNotExist() { - // arrange - String userId = "unknown"; - - // act - User found = userService.findByUserId(userId); - - // assert - assertThat(found).isNull(); - } - } } + diff --git a/apps/commerce-api/src/test/java/com/loopers/application/userinfo/UserInfoFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/userinfo/UserInfoFacadeIntegrationTest.java new file mode 100644 index 000000000..78efa5e30 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/userinfo/UserInfoFacadeIntegrationTest.java @@ -0,0 +1,80 @@ +package com.loopers.application.userinfo; + +import com.loopers.application.signup.SignUpFacade; +import com.loopers.domain.user.Gender; +import com.loopers.domain.user.UserTestFixture; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +@DisplayName("UserInfoFacade 통합 테스트") +class UserInfoFacadeIntegrationTest { + @Autowired + private UserInfoFacade userInfoFacade; + + @Autowired + private SignUpFacade signUpFacade; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("회원 조회에 관한 통합 테스트") + @Nested + class UserInfo { + @DisplayName("해당 ID 의 회원이 존재할 경우, 회원 정보가 반환된다.") + @ParameterizedTest + @EnumSource(Gender.class) + void returnsUserInfo_whenUserExists(Gender gender) { + // arrange + String userId = UserTestFixture.ValidUser.USER_ID; + String email = UserTestFixture.ValidUser.EMAIL; + String birthDate = UserTestFixture.ValidUser.BIRTH_DATE; + signUpFacade.signUp(userId, email, birthDate, gender.name()); + + // act + UserInfoFacade.UserInfo userInfo = userInfoFacade.getUserInfo(userId); + + // assert + assertAll( + () -> assertThat(userInfo).isNotNull(), + () -> assertThat(userInfo.userId()).isEqualTo(userId), + () -> assertThat(userInfo.email()).isEqualTo(email), + () -> assertThat(userInfo.birthDate()).isEqualTo(LocalDate.parse(birthDate)), + () -> assertThat(userInfo.gender()).isEqualTo(gender) + ); + } + + @DisplayName("해당 ID 의 회원이 존재하지 않을 경우, 예외가 발생한다.") + @Test + void throwsException_whenUserDoesNotExist() { + // arrange + String userId = "unknown"; + + // act & assert + assertThatThrownBy(() -> userInfoFacade.getUserInfo(userId)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); + } + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java new file mode 100644 index 000000000..b3e8db17b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java @@ -0,0 +1,92 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class OrderTest { + + @DisplayName("정상 주문 / 예외 주문 흐름을 모두 검증한다.") + @Nested + class OrderFlow { + @DisplayName("정상 주문 흐름: 주문이 정상적으로 생성되고 총액이 올바르게 계산된다.") + @Test + void normalOrderFlow() { + // arrange + Long userId = OrderTestFixture.ValidOrder.USER_ID; + List items = OrderTestFixture.ValidOrderItem.createMultipleItems(); + // 상품 1: 10000 * 1 = 10000 + // 상품 2: 5000 * 2 = 10000 + // 총액: 20000 + + // act + Order order = Order.of(userId, items); + + // assert + assertThat(order.getUserId()).isEqualTo(userId); + assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING); + assertThat(order.getTotalAmount()).isEqualTo(20000); + assertThat(order.getItems()).hasSize(2); + assertThat(order.getItems()).containsExactlyElementsOf(items); + } + + @DisplayName("예외 주문 흐름: 주문 아이템이 null이면 예외가 발생한다.") + @Test + void exceptionOrderFlow_nullItems() { + // arrange + Long userId = OrderTestFixture.ValidOrder.USER_ID; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + Order.of(userId, null); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(result.getMessage()).contains("주문 아이템은 필수이며 최소 1개 이상이어야 합니다"); + } + + @DisplayName("예외 주문 흐름: 주문 아이템이 비어있으면 예외가 발생한다.") + @Test + void exceptionOrderFlow_emptyItems() { + // arrange + Long userId = OrderTestFixture.ValidOrder.USER_ID; + List emptyItems = List.of(); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + Order.of(userId, emptyItems); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(result.getMessage()).contains("주문 아이템은 필수이며 최소 1개 이상이어야 합니다"); + } + + @DisplayName("예외 주문 흐름: 사용자 ID가 null이면 예외가 발생한다.") + @Test + void exceptionOrderFlow_nullUserId() { + // arrange + List items = OrderTestFixture.ValidOrderItem.createMultipleItems(); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + Order.of(null, items); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(result.getMessage()).contains("사용자 ID는 필수입니다"); + } + } + +} + + diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTestFixture.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTestFixture.java new file mode 100644 index 000000000..2f11dedf1 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTestFixture.java @@ -0,0 +1,39 @@ +package com.loopers.domain.order; + +import java.util.List; + +/** + * 테스트용 고정 데이터 (Fixture) 클래스 + * 모든 Order 관련 테스트에서 사용하는 공통 데이터를 관리 + */ +public class OrderTestFixture { + + // 기본 유효한 테스트 데이터 + public static final class ValidOrder { + public static final Long USER_ID = 1L; + public static final Integer TOTAL_AMOUNT = 20000; + } + + // 기본 유효한 주문 아이템 데이터 + public static final class ValidOrderItem { + public static final Long PRODUCT_ID_1 = 1L; + public static final String NAME_1 = "테스트 상품 1"; + public static final Integer PRICE_1 = 10000; + public static final Integer QUANTITY_1 = 1; + + public static final Long PRODUCT_ID_2 = 2L; + public static final String NAME_2 = "테스트 상품 2"; + public static final Integer PRICE_2 = 5000; + public static final Integer QUANTITY_2 = 2; + + public static List createMultipleItems() { + return List.of( + OrderItem.of(PRODUCT_ID_1, NAME_1, PRICE_1, QUANTITY_1), + OrderItem.of(PRODUCT_ID_2, NAME_2, PRICE_2, QUANTITY_2) + ); + } + } + +} + + diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java deleted file mode 100644 index a77d11ab2..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java +++ /dev/null @@ -1,94 +0,0 @@ -package com.loopers.domain.point; - -import com.loopers.application.signup.SignUpFacade; -import com.loopers.domain.user.Gender; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertThrows; - -@SpringBootTest -public class PointServiceIntegrationTest { - @Autowired - private PointService pointService; - - @Autowired - private SignUpFacade signUpFacade; - - @Autowired - private DatabaseCleanUp databaseCleanUp; - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @DisplayName("포인트 조회에 관한 통합 테스트") - @Nested - class PointsLookup { - @DisplayName("해당 ID 의 회원이 존재할 경우, 보유 포인트가 반환된다.") - @Test - void returnsPoints_whenUserExists() { - // arrange - String userId = "testuser"; - String email = "test@example.com"; - String birthDate = "1990-01-01"; - Gender gender = Gender.MALE; - Long balance = 0L; - signUpFacade.signUp(userId, email, birthDate, gender); - - // act - Point point = pointService.findByUserId(userId); - - // assert - assertAll( - () -> assertThat(point).isNotNull(), - () -> assertThat(point.getUser().getUserId()).isEqualTo(userId), - () -> assertThat(point.getBalance()).isEqualTo(balance) - ); - } - - @DisplayName("해당 ID 의 회원이 존재하지 않을 경우, null 이 반환된다.") - @Test - void returnsNull_whenUserDoesNotExist() { - // arrange - String userId = "unknown"; - - // act - Point found = pointService.findByUserId(userId); - - // assert - assertThat(found).isNull(); - } - } - - @DisplayName("포인트 충전에 관한 통합 테스트") - @Nested - class PointCharge { - @DisplayName("존재하지 않는 유저 ID 로 충전을 시도한 경우, 실패한다.") - @Test - void fails_whenUserDoesNotExist() { - // arrange - String userId = "unknown"; - Long chargeAmount = 1000L; - - // act - CoreException result = assertThrows(CoreException.class, () -> - pointService.charge(userId, chargeAmount) - ); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); - } - } -} - diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointTest.java deleted file mode 100644 index 38b7f7c80..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointTest.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.loopers.domain.point; - -import com.loopers.domain.user.User; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.Mockito.mock; - -public class PointTest { - - @DisplayName("Point 도메인의 금액 검증에 관한 단위 테스트") - @Nested - class BalanceValidation { - @DisplayName("0 이하의 정수로 포인트를 충전 시 실패한다.") - @ParameterizedTest - @ValueSource(longs = {0L, -1L, -100L}) - void throwsBadRequest_whenChargingWithNonPositiveAmount(long nonPositiveAmount) { - // arrange - User user = mock(User.class); - Point point = Point.of(user, 0L); - - // act - CoreException result = assertThrows(CoreException.class, () -> { - point.charge(nonPositiveAmount); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductDetailServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductDetailServiceTest.java new file mode 100644 index 000000000..588a2ac09 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductDetailServiceTest.java @@ -0,0 +1,100 @@ +package com.loopers.domain.product; + +import com.loopers.domain.brand.Brand; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class ProductDetailServiceTest { + + private final ProductDetailService productDetailService = new ProductDetailService(); + + @DisplayName("상품 상세 조회 시 Product + Brand + 좋아요 수 정보 조합은 도메인 서비스에서 처리했다") + @Test + void combineProductAndBrand() { + // arrange + Brand brand = Brand.of(ProductTestFixture.ValidBrand.NAME); + Product product = Product.of( + ProductTestFixture.ValidProduct.NAME, + ProductTestFixture.ValidProduct.PRICE, + ProductTestFixture.ValidProduct.STOCK, + brand.getId() + ); + Long likesCount = 10L; + + // act + ProductDetail productDetail = productDetailService.combineProductAndBrand(product, brand, likesCount); + + // assert + assertThat(productDetail.getId()).isEqualTo(product.getId()); + assertThat(productDetail.getName()).isEqualTo(product.getName()); + assertThat(productDetail.getPrice()).isEqualTo(product.getPrice()); + assertThat(productDetail.getStock()).isEqualTo(product.getStock()); + assertThat(productDetail.getBrandId()).isEqualTo(brand.getId()); + assertThat(productDetail.getBrandName()).isEqualTo(brand.getName()); + assertThat(productDetail.getLikesCount()).isEqualTo(likesCount); + } + + @DisplayName("상품이 null이면 예외가 발생한다.") + @Test + void throwsException_whenProductIsNull() { + // arrange + Brand brand = Brand.of(ProductTestFixture.ValidBrand.NAME); + Long likesCount = 0L; + + // act + IllegalArgumentException result = assertThrows(IllegalArgumentException.class, () -> { + productDetailService.combineProductAndBrand(null, brand, likesCount); + }); + + // assert + assertThat(result.getMessage()).contains("상품은 null일 수 없습니다"); + } + + @DisplayName("브랜드가 null이면 예외가 발생한다.") + @Test + void throwsException_whenBrandIsNull() { + // arrange + Brand brand = Brand.of(ProductTestFixture.ValidBrand.NAME); + Product product = Product.of( + ProductTestFixture.ValidProduct.NAME, + ProductTestFixture.ValidProduct.PRICE, + ProductTestFixture.ValidProduct.STOCK, + brand.getId() + ); + Long likesCount = 0L; + + // act + IllegalArgumentException result = assertThrows(IllegalArgumentException.class, () -> { + productDetailService.combineProductAndBrand(product, null, likesCount); + }); + + // assert + assertThat(result.getMessage()).contains("브랜드 정보는 필수입니다"); + } + + @DisplayName("좋아요 수가 null이면 예외가 발생한다.") + @Test + void throwsException_whenLikesCountIsNull() { + // arrange + Brand brand = Brand.of(ProductTestFixture.ValidBrand.NAME); + Product product = Product.of( + ProductTestFixture.ValidProduct.NAME, + ProductTestFixture.ValidProduct.PRICE, + ProductTestFixture.ValidProduct.STOCK, + brand.getId() + ); + + // act + IllegalArgumentException result = assertThrows(IllegalArgumentException.class, () -> { + productDetailService.combineProductAndBrand(product, brand, null); + }); + + // assert + assertThat(result.getMessage()).contains("좋아요 수는 null일 수 없습니다"); + } + +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java new file mode 100644 index 000000000..9cfe0294e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java @@ -0,0 +1,110 @@ +package com.loopers.domain.product; + +import com.loopers.domain.brand.Brand; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class ProductTest { + + @DisplayName("상품 정보 객체는 브랜드 정보를 포함한다.") + @Test + void productContainsBrandId() { + // arrange + Brand brand = Brand.of(ProductTestFixture.ValidBrand.NAME); + Long brandId = brand.getId(); + + // act + Product product = Product.of(ProductTestFixture.ValidProduct.NAME, ProductTestFixture.ValidProduct.PRICE, ProductTestFixture.ValidProduct.STOCK, brandId); + + // assert + assertThat(product.getBrandId()).isNotNull(); + assertThat(product.getBrandId()).isEqualTo(brandId); + } + + @DisplayName("상품은 재고를 가지고 있고, 주문 시 차감할 수 있어야 한다.") + @Test + void canDecreaseStock_whenOrdering() { + // arrange + Brand brand = Brand.of(ProductTestFixture.ValidBrand.NAME); + Product product = Product.of(ProductTestFixture.ValidProduct.NAME, ProductTestFixture.ValidProduct.PRICE, 100, brand.getId()); + Integer orderQuantity = 5; + Integer initialStock = product.getStock(); + + // act + product.decreaseStock(orderQuantity); + + // assert + assertThat(product.getStock()).isEqualTo(initialStock - orderQuantity); + } + + @DisplayName("재고 감소 시 음수 수량을 전달하면 예외가 발생한다.") + @Test + void throwsException_whenDecreasingStockWithNegativeQuantity() { + // arrange + Brand brand = Brand.of(ProductTestFixture.ValidBrand.NAME); + Product product = Product.of(ProductTestFixture.ValidProduct.NAME, ProductTestFixture.ValidProduct.PRICE, 100, brand.getId()); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + product.decreaseStock(-1); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("주문 취소 시 재고를 증가시킬 수 있다.") + @Test + void canIncreaseStock_whenCancelingOrder() { + // arrange + Brand brand = Brand.of(ProductTestFixture.ValidBrand.NAME); + Product product = Product.of(ProductTestFixture.ValidProduct.NAME, ProductTestFixture.ValidProduct.PRICE, 100, brand.getId()); + Integer increaseQuantity = 5; + Integer initialStock = product.getStock(); + + // act + product.increaseStock(increaseQuantity); + + // assert + assertThat(product.getStock()).isEqualTo(initialStock + increaseQuantity); + } + + @DisplayName("재고 증가 시 음수 수량을 전달하면 예외가 발생한다.") + @Test + void throwsException_whenIncreasingStockWithNegativeQuantity() { + // arrange + Brand brand = Brand.of(ProductTestFixture.ValidBrand.NAME); + Product product = Product.of(ProductTestFixture.ValidProduct.NAME, ProductTestFixture.ValidProduct.PRICE, 100, brand.getId()); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + product.increaseStock(-1); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("재고 부족 예외 흐름은 도메인 레벨에서 처리되며, 재고가 음수가 되지 않도록 방지한다.") + @Test + void preventsNegativeStock_atDomainLevel() { + // arrange + Brand brand = Brand.of(ProductTestFixture.ValidBrand.NAME); + Product product = Product.of(ProductTestFixture.ValidProduct.NAME, ProductTestFixture.ValidProduct.PRICE, 10, brand.getId()); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + product.decreaseStock(11); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(result.getMessage()).contains("재고가 부족합니다"); + assertThat(product.getStock()).isEqualTo(10); // 재고가 변경되지 않았음을 확인 + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTestFixture.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTestFixture.java new file mode 100644 index 000000000..43da5f290 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTestFixture.java @@ -0,0 +1,33 @@ +package com.loopers.domain.product; + +/** + * 테스트용 고정 데이터 (Fixture) 클래스 + * 모든 Product 관련 테스트에서 사용하는 공통 데이터를 관리 + */ +public class ProductTestFixture { + + // 기본 유효한 테스트 데이터 + public static final class ValidProduct { + public static final String NAME = "테스트 상품"; + public static final Integer PRICE = 10000; + public static final Integer STOCK = 100; + } + + // 유효하지 않은 테스트 데이터 + public static final class InvalidProduct { + public static final String NAME = ""; + public static final Integer PRICE = -1; + public static final Integer STOCK = -1; + } + + // 기본 유효한 브랜드 데이터 + public static final class ValidBrand { + public static final String NAME = "테스트 브랜드"; + } + + // 유효하지 않은 브랜드 데이터 + public static final class InvalidBrand { + public static final String NAME = ""; + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java index 3fc3950d4..b1490b09c 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java @@ -22,7 +22,7 @@ void throwsBadRequestException_whenIdFormatIsInvalid(Gender gender) { String userId = UserTestFixture.InvalidUser.USER_ID; // act CoreException result = assertThrows(CoreException.class, () -> { - User.of(userId, UserTestFixture.ValidUser.EMAIL, UserTestFixture.ValidUser.BIRTH_DATE, gender); + User.of(userId, UserTestFixture.ValidUser.EMAIL, UserTestFixture.ValidUser.BIRTH_DATE, gender, UserTestFixture.ValidUser.POINT); }); // assert assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); @@ -36,7 +36,7 @@ void throwsBadRequestException_whenEmailFormatIsInvalid(Gender gender) { String email = UserTestFixture.InvalidUser.EMAIL; // act CoreException result = assertThrows(CoreException.class, () -> { - User.of(UserTestFixture.ValidUser.USER_ID, email, UserTestFixture.ValidUser.BIRTH_DATE, gender); + User.of(UserTestFixture.ValidUser.USER_ID, email, UserTestFixture.ValidUser.BIRTH_DATE, gender, UserTestFixture.ValidUser.POINT); }); // assert assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); @@ -50,7 +50,7 @@ void throwsBadRequestException_whenBirthDateIsInvalid(Gender gender) { String birthDateStr = UserTestFixture.InvalidUser.BIRTH_DATE; // act CoreException result = assertThrows(CoreException.class, () -> { - User.of(UserTestFixture.ValidUser.USER_ID, UserTestFixture.ValidUser.EMAIL, birthDateStr, gender); + User.of(UserTestFixture.ValidUser.USER_ID, UserTestFixture.ValidUser.EMAIL, birthDateStr, gender, UserTestFixture.ValidUser.POINT); }); // assert assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTestFixture.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTestFixture.java index a36637064..9babd2fb1 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTestFixture.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTestFixture.java @@ -11,6 +11,7 @@ public static final class ValidUser { public static final String USER_ID = "testuser"; public static final String EMAIL = "test@example.com"; public static final String BIRTH_DATE = "1990-01-01"; + public static final Point POINT = Point.of(0L); } // 유효하지 않은 테스트 데이터 @@ -18,5 +19,6 @@ public static final class InvalidUser { public static final String USER_ID = "한글"; public static final String EMAIL = "test"; public static final String BIRTH_DATE = "2024.1.1"; + public static final Point POINT = Point.of(0L); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointsV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointWalletV1ApiE2ETest.java similarity index 88% rename from apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointsV1ApiE2ETest.java rename to apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointWalletV1ApiE2ETest.java index 3621bbd60..c2629fb78 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointsV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointWalletV1ApiE2ETest.java @@ -3,7 +3,7 @@ import com.loopers.application.signup.SignUpFacade; import com.loopers.domain.user.Gender; import com.loopers.domain.user.UserTestFixture; -import com.loopers.interfaces.api.point.PointsV1Dto; +import com.loopers.interfaces.api.pointwallet.PointWalletV1Dto; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; @@ -26,7 +26,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -public class PointsV1ApiE2ETest { +public class PointWalletV1ApiE2ETest { private static final String ENDPOINT_POINTS = "/api/v1/me/points"; @@ -35,7 +35,7 @@ public class PointsV1ApiE2ETest { private final DatabaseCleanUp databaseCleanUp; @Autowired - public PointsV1ApiE2ETest( + public PointWalletV1ApiE2ETest( TestRestTemplate testRestTemplate, SignUpFacade signUpFacade, DatabaseCleanUp databaseCleanUp @@ -61,16 +61,16 @@ void returnsPoints_whenUserExists(Gender gender) { String userId = UserTestFixture.ValidUser.USER_ID; String email = UserTestFixture.ValidUser.EMAIL; String birthDate = UserTestFixture.ValidUser.BIRTH_DATE; - signUpFacade.signUp(userId, email, birthDate, gender); + signUpFacade.signUp(userId, email, birthDate, gender.name()); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); HttpEntity httpEntity = new HttpEntity<>(headers); // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; headers.add("X-USER-ID", userId); - ResponseEntity> response = + ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_POINTS, HttpMethod.GET, httpEntity, responseType); // assert @@ -145,19 +145,19 @@ void returnsChargedBalance_whenUserExists(Gender gender) { String userId = UserTestFixture.ValidUser.USER_ID; String email = UserTestFixture.ValidUser.EMAIL; String birthDate = UserTestFixture.ValidUser.BIRTH_DATE; - signUpFacade.signUp(userId, email, birthDate, gender); + signUpFacade.signUp(userId, email, birthDate, gender.name()); Long chargeAmount = 1000L; - PointsV1Dto.ChargeRequest requestBody = new PointsV1Dto.ChargeRequest(chargeAmount); + PointWalletV1Dto.ChargeRequest requestBody = new PointWalletV1Dto.ChargeRequest(chargeAmount); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); headers.add("X-USER-ID", userId); - HttpEntity httpEntity = new HttpEntity<>(requestBody, headers); + HttpEntity httpEntity = new HttpEntity<>(requestBody, headers); // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_CHARGE, HttpMethod.POST, httpEntity, responseType); // assert @@ -177,12 +177,12 @@ void returns404_whenUserDoesNotExist() { // arrange String userId = "unknown"; Long chargeAmount = 1000L; - PointsV1Dto.ChargeRequest requestBody = new PointsV1Dto.ChargeRequest(chargeAmount); + PointWalletV1Dto.ChargeRequest requestBody = new PointWalletV1Dto.ChargeRequest(chargeAmount); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); headers.add("X-USER-ID", userId); - HttpEntity httpEntity = new HttpEntity<>(requestBody, headers); + HttpEntity httpEntity = new HttpEntity<>(requestBody, headers); // act ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; @@ -198,4 +198,4 @@ void returns404_whenUserDoesNotExist() { ); } } -} \ No newline at end of file +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserInfoV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserInfoV1ApiE2ETest.java index a78f616cd..f9d33a421 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserInfoV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserInfoV1ApiE2ETest.java @@ -61,7 +61,7 @@ void returnsUserInfo_whenUserExists(Gender gender) { String userId = UserTestFixture.ValidUser.USER_ID; String email = UserTestFixture.ValidUser.EMAIL; String birthDate = UserTestFixture.ValidUser.BIRTH_DATE; - signUpFacade.signUp(userId, email, birthDate, gender); + signUpFacade.signUp(userId, email, birthDate, gender.name()); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); diff --git a/modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java b/modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java index 85c43ace9..14251dad8 100644 --- a/modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java +++ b/modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java @@ -33,7 +33,12 @@ public void truncateAllTables() { entityManager.createNativeQuery("SET FOREIGN_KEY_CHECKS = 0").executeUpdate(); for (String table : tableNames) { - entityManager.createNativeQuery("TRUNCATE TABLE `" + table + "`").executeUpdate(); + String tableName = table; + // 테이블 이름에 이미 백틱이 있으면 생략 + if (!tableName.startsWith("`") && !tableName.endsWith("`")) { + tableName = "`" + tableName + "`"; + } + entityManager.createNativeQuery("TRUNCATE TABLE " + tableName).executeUpdate(); } entityManager.createNativeQuery("SET FOREIGN_KEY_CHECKS = 1").executeUpdate(); From 197dda27d474cdb45f865bbcbb4042a878fcbd38 Mon Sep 17 00:00:00 2001 From: minor7295 <44902090+minor7295@users.noreply.github.com> Date: Mon, 24 Nov 2025 12:19:04 +0900 Subject: [PATCH 05/12] =?UTF-8?q?[volume-4]=20API=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95=20=EB=B0=8F=20=ED=8A=B8=EB=9E=9C=EC=9E=AD?= =?UTF-8?q?=EC=85=98=EC=9D=84=20=ED=99=9C=EC=9A=A9=ED=95=9C=20=EB=8F=99?= =?UTF-8?q?=EC=8B=9C=EC=84=B1=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0=20?= =?UTF-8?q?(#99)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add GitHub Actions workflow for PR Agent * Feature/misc api (#15) * refactor: product 도메인의 likeCount 집계 방식을 배치 방식으로 변경 * refactor: CatalogProductFacade에서 발생하는 n+1 쿼리 수정 * refactor: SignUpFacade의 NPE 가능성 제거 * refactor: Brand 도메인 name 필드의 입력 검증 로직 추가 * refactor: Order도메인 내의 OrderItem을 불변 객체로 설정 * feat: 브랜드 정보 조회 API 추가 * feat: 상품 조회 API 추가 * feat: 좋아요 수 집계 로직 추가 * feat: 좋아요 API 추가 * feat: 구매 API 추가 * Feature/concurrency like (#16) * test: 좋아요 동시성 테스트 로직 추가 * feat: like에 unique constraint 적용해서 동시성 이슈 발생하지 않도록 함 * Feature/coupon (#17) * test: 쿠폰 관련 테스트 코드 추가 * feat: coupon 구현 * Feature/concurrency purchasing (#18) * test: 주문 동시성 테스트 로직 추가 * test: 주문 흐름의 원자성을 검증하는 테스트 코드 추가 * feat: 비관적 락 적용하여 주문 동시성 이슈 발생하지 않도록 함 * refactor: deadlock 문제 수정 --------- Co-authored-by: simplify-len --- .github/workflows/main.yml | 13 + apps/commerce-api/build.gradle.kts | 3 + .../com/loopers/CommerceApiApplication.java | 2 + .../catalog/CatalogBrandFacade.java | 55 +++ .../catalog/CatalogProductFacade.java | 46 ++- .../loopers/application/like/LikeFacade.java | 101 +++-- .../purchasing/OrderItemCommand.java | 14 +- .../purchasing/PurchasingFacade.java | 246 ++++++++++-- .../scheduler/LikeCountSyncScheduler.java | 98 +++++ .../application/signup/SignUpFacade.java | 8 +- .../batch/LikeCountSyncBatchConfig.java | 171 +++++++++ .../java/com/loopers/domain/brand/Brand.java | 18 +- .../loopers/domain/brand/BrandRepository.java | 12 + .../com/loopers/domain/coupon/Coupon.java | 137 +++++++ .../domain/coupon/CouponRepository.java | 39 ++ .../com/loopers/domain/coupon/CouponType.java | 23 ++ .../com/loopers/domain/coupon/UserCoupon.java | 134 +++++++ .../domain/coupon/UserCouponRepository.java | 52 +++ .../discount/CouponDiscountStrategy.java | 23 ++ .../CouponDiscountStrategyFactory.java | 53 +++ .../discount/FixedAmountDiscountStrategy.java | 32 ++ .../discount/PercentageDiscountStrategy.java | 32 ++ .../java/com/loopers/domain/like/Like.java | 11 +- .../loopers/domain/like/LikeRepository.java | 10 + .../java/com/loopers/domain/order/Order.java | 38 +- .../com/loopers/domain/order/OrderItem.java | 1 - .../com/loopers/domain/product/Product.java | 20 + .../loopers/domain/product/ProductDetail.java | 38 ++ .../domain/product/ProductRepository.java | 51 +++ .../loopers/domain/user/UserRepository.java | 19 + .../brand/BrandRepositoryImpl.java | 6 + .../coupon/CouponJpaRepository.java | 26 ++ .../coupon/CouponRepositoryImpl.java | 49 +++ .../coupon/UserCouponJpaRepository.java | 61 +++ .../coupon/UserCouponRepositoryImpl.java | 49 +++ .../like/LikeJpaRepository.java | 11 + .../like/LikeRepositoryImpl.java | 9 + .../product/ProductJpaRepository.java | 48 +++ .../product/ProductRepositoryImpl.java | 38 +- .../user/UserJpaRepository.java | 35 ++ .../user/UserRepositoryImpl.java | 8 + .../api/catalog/BrandV1Controller.java | 39 ++ .../interfaces/api/catalog/BrandV1Dto.java | 30 ++ .../api/catalog/ProductV1Controller.java | 62 +++ .../interfaces/api/catalog/ProductV1Dto.java | 95 +++++ .../interfaces/api/like/LikeV1Controller.java | 76 ++++ .../interfaces/api/like/LikeV1Dto.java | 73 ++++ .../purchasing/PurchasingV1Controller.java | 75 ++++ .../api/purchasing/PurchasingV1Dto.java | 122 ++++++ .../src/main/resources/application.yml | 5 + .../like/LikeFacadeConcurrencyTest.java | 336 ++++++++++++++++ .../application/like/LikeFacadeTest.java | 101 +++++ .../PurchasingFacadeConcurrencyTest.java | 358 ++++++++++++++++++ .../purchasing/PurchasingFacadeTest.java | 340 ++++++++++++++++- 54 files changed, 3471 insertions(+), 81 deletions(-) create mode 100644 .github/workflows/main.yml create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogBrandFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/scheduler/LikeCountSyncScheduler.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/config/batch/LikeCountSyncBatchConfig.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/Coupon.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponType.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/UserCoupon.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/UserCouponRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/discount/CouponDiscountStrategy.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/discount/CouponDiscountStrategyFactory.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/discount/FixedAmountDiscountStrategy.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/discount/PercentageDiscountStrategy.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/UserCouponJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/UserCouponRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/BrandV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/BrandV1Dto.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/ProductV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/ProductV1Dto.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeConcurrencyTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeConcurrencyTest.java diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 000000000..1f80db6bf --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,13 @@ +name: PR Agent +on: + pull_request: + types: [opened, synchronize] +jobs: + pr_agent_job: + runs-on: ubuntu-latest + steps: + - name: PR Agent action step + uses: Codium-ai/pr-agent@main + env: + OPENAI_KEY: ${{ secrets.OPENAI_KEY }} + GITHUB_TOKEN: ${{ secrets.G_TOKEN }} diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 03ce68f02..969c6a1f0 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -11,6 +11,9 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${project.properties["springDocOpenApiVersion"]}") + // batch + implementation("org.springframework.boot:spring-boot-starter-batch") + // querydsl annotationProcessor("com.querydsl:querydsl-apt::jakarta") annotationProcessor("jakarta.persistence:jakarta.persistence-api") diff --git a/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java b/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java index 9027b51bf..0b4b1cde4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java +++ b/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java @@ -4,10 +4,12 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.scheduling.annotation.EnableScheduling; import java.util.TimeZone; @ConfigurationPropertiesScan @SpringBootApplication +@EnableScheduling public class CommerceApiApplication { @PostConstruct diff --git a/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogBrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogBrandFacade.java new file mode 100644 index 000000000..4cdc2f177 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogBrandFacade.java @@ -0,0 +1,55 @@ +package com.loopers.application.catalog; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * 브랜드 조회 파사드. + *

+ * 브랜드 정보 조회 유즈케이스를 처리하는 애플리케이션 서비스입니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@RequiredArgsConstructor +@Component +public class CatalogBrandFacade { + private final BrandRepository brandRepository; + + /** + * 브랜드 정보를 조회합니다. + * + * @param brandId 브랜드 ID + * @return 브랜드 정보 + * @throws CoreException 브랜드를 찾을 수 없는 경우 + */ + public BrandInfo getBrand(Long brandId) { + Brand brand = brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); + return BrandInfo.from(brand); + } + + /** + * 브랜드 정보를 담는 레코드. + * + * @param id 브랜드 ID + * @param name 브랜드 이름 + */ + public record BrandInfo(Long id, String name) { + /** + * Brand 엔티티로부터 BrandInfo를 생성합니다. + * + * @param brand 브랜드 엔티티 + * @return 생성된 BrandInfo + */ + public static BrandInfo from(Brand brand) { + return new BrandInfo(brand.getId(), brand.getName()); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogProductFacade.java index 53d7ca98c..3cba60deb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogProductFacade.java @@ -2,17 +2,14 @@ import com.loopers.domain.brand.Brand; import com.loopers.domain.brand.BrandRepository; -import com.loopers.domain.like.LikeRepository; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductDetail; -import com.loopers.domain.product.ProductDetailService; import com.loopers.domain.product.ProductRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; -import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -31,11 +28,12 @@ public class CatalogProductFacade { private final ProductRepository productRepository; private final BrandRepository brandRepository; - private final LikeRepository likeRepository; - private final ProductDetailService productDetailService; /** * 상품 목록을 조회합니다. + *

+ * 배치 조회를 통해 N+1 쿼리 문제를 해결합니다. + *

* * @param brandId 브랜드 ID (선택) * @param sort 정렬 기준 (latest, price_asc, likes_desc) @@ -46,9 +44,36 @@ public class CatalogProductFacade { public ProductInfoList getProducts(Long brandId, String sort, int page, int size) { long totalCount = productRepository.countAll(brandId); List products = productRepository.findAll(brandId, sort, page, size); + + if (products.isEmpty()) { + return new ProductInfoList(List.of(), totalCount, page, size); + } + + // ✅ 배치 조회로 N+1 쿼리 문제 해결 + // 브랜드 ID 수집 + List brandIds = products.stream() + .map(Product::getBrandId) + .distinct() + .toList(); + + // 브랜드 배치 조회 및 Map으로 변환 (O(1) 조회를 위해) + Map brandMap = brandRepository.findAllById(brandIds).stream() + .collect(Collectors.toMap(Brand::getId, brand -> brand)); + + // 상품 정보 변환 (이미 조회한 Product 재사용) List productsInfo = products.stream() - .map(product -> getProduct(product.getId())) + .map(product -> { + Brand brand = brandMap.get(product.getBrandId()); + if (brand == null) { + throw new CoreException(ErrorType.NOT_FOUND, + String.format("브랜드를 찾을 수 없습니다. (브랜드 ID: %d)", product.getBrandId())); + } + // ✅ Product.likeCount 필드 사용 (비동기 집계된 값) + ProductDetail productDetail = ProductDetail.from(product, brand.getName(), product.getLikeCount()); + return new ProductInfo(productDetail); + }) .toList(); + return new ProductInfoList(productsInfo, totalCount, page, size); } @@ -67,12 +92,11 @@ public ProductInfo getProduct(Long productId) { Brand brand = brandRepository.findById(product.getBrandId()) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); - // 좋아요 수 조회 - Map likesCountMap = likeRepository.countByProductIds(List.of(productId)); - Long likesCount = likesCountMap.getOrDefault(productId, 0L); + // ✅ Product.likeCount 필드 사용 (비동기 집계된 값) + Long likesCount = product.getLikeCount(); - // 도메인 서비스를 통해 ProductDetail 생성 (도메인 객체 협력) - ProductDetail productDetail = productDetailService.combineProductAndBrand(product, brand, likesCount); + // ProductDetail 생성 (Aggregate 경계 준수: Brand 엔티티 대신 brandName만 전달) + ProductDetail productDetail = ProductDetail.from(product, brand.getName(), likesCount); return new ProductInfo(productDetail); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java index c97814f0f..3c016a492 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -8,13 +8,14 @@ import com.loopers.domain.user.UserRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; -import jakarta.transaction.Transactional; +import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.stream.Collectors; /** * 좋아요 관리 파사드. @@ -37,23 +38,52 @@ public class LikeFacade { *

* 멱등성을 보장합니다. 이미 좋아요가 존재하는 경우 아무 작업도 수행하지 않습니다. *

+ *

+ * 동시성 제어 전략: + *

    + *
  • UNIQUE 제약조건 사용: 데이터베이스 레벨에서 중복 삽입을 물리적으로 방지
  • + *
  • 애플리케이션 레벨 한계: 애플리케이션 레벨로는 race condition을 완전히 방지할 수 없음
  • + *
  • 예외 처리: UNIQUE 제약조건 위반 시 DataIntegrityViolationException 처리하여 멱등성 보장
  • + *
+ *

+ *

+ * DBA 설득 근거 (유니크 인덱스 사용): + *

    + *
  • 트래픽 패턴: 좋아요는 고 QPS write-heavy 테이블이 아니며, 전체 서비스에서 차지하는 비중이 낮음
  • + *
  • 애플리케이션 레벨 한계: 동일 시점 동시 요청 시 select 시점엔 중복 없음 → insert 2번 발생 가능
  • + *
  • 데이터 무결성: DB만이 강한 무결성(Strong Consistency)을 제공할 수 있음
  • + *
  • 비즈니스 데이터 보호: 중복 좋아요로 인한 비즈니스 데이터 오염 방지
  • + *
+ *

* * @param userId 사용자 ID (String) * @param productId 상품 ID * @throws CoreException 사용자 또는 상품을 찾을 수 없는 경우 */ - @Transactional public void addLike(String userId, Long productId) { User user = loadUser(userId); loadProduct(productId); + // 먼저 일반 조회로 중복 체크 (대부분의 경우 빠르게 처리) + // ⚠️ 주의: 애플리케이션 레벨 체크만으로는 race condition을 완전히 방지할 수 없음 + // 동시에 두 요청이 들어오면 둘 다 "없음"으로 판단 → 둘 다 저장 시도 가능 Optional existingLike = likeRepository.findByUserIdAndProductId(user.getId(), productId); if (existingLike.isPresent()) { return; } + // 저장 시도 (동시성 상황에서는 UNIQUE 제약조건 위반 예외 발생 가능) + // ✅ UNIQUE 제약조건이 최종 보호: DB 레벨에서 중복 삽입을 물리적으로 방지 + // @Transactional이 없어도 save() 호출 시 자동 트랜잭션으로 예외를 catch할 수 있음 Like like = Like.of(user.getId(), productId); - likeRepository.save(like); + try { + likeRepository.save(like); + } catch (org.springframework.dao.DataIntegrityViolationException e) { + // UNIQUE 제약조건 위반 = 이미 저장됨 (멱등성 보장) + // 동시에 여러 요청이 들어와서 모두 "없음"으로 판단하고 저장을 시도할 때, + // 첫 번째만 성공하고 나머지는 UNIQUE 제약조건 위반 예외 발생 + // 이미 좋아요가 존재하는 경우이므로 정상 처리로 간주 + } } /** @@ -66,7 +96,6 @@ public void addLike(String userId, Long productId) { * @param productId 상품 ID * @throws CoreException 사용자 또는 상품을 찾을 수 없는 경우 */ - @Transactional public void removeLike(String userId, Long productId) { User user = loadUser(userId); loadProduct(productId); @@ -76,16 +105,33 @@ public void removeLike(String userId, Long productId) { return; } - likeRepository.delete(like.get()); + try { + likeRepository.delete(like.get()); + } catch (Exception e) { + // 동시성 상황에서 이미 삭제된 경우 등 예외 발생 가능 + // 멱등성 보장: 이미 삭제된 경우 정상 처리로 간주 + } } /** * 사용자가 좋아요한 상품 목록을 조회합니다. + *

+ * 상품 정보 조회를 병렬로 처리하여 성능을 최적화합니다. + *

+ *

+ * 좋아요 수 조회 전략: + *

    + *
  • 비동기 집계: Product.likeCount 필드 사용 (스케줄러로 주기적 동기화)
  • + *
  • Eventually Consistent: 약간의 지연 허용 (최대 5초)
  • + *
  • 성능 최적화: COUNT(*) 쿼리 없이 컬럼만 읽으면 됨
  • + *
+ *

* * @param userId 사용자 ID (String) * @return 좋아요한 상품 목록 * @throws CoreException 사용자를 찾을 수 없는 경우 */ + @Transactional(readOnly = true) public List getLikedProducts(String userId) { User user = loadUser(userId); @@ -101,26 +147,26 @@ public List getLikedProducts(String userId) { .map(Like::getProductId) .toList(); - // 상품 정보 조회 - List products = productIds.stream() - .map(productId -> productRepository.findById(productId) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, - String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", productId)))) - .toList(); + // ✅ 배치 조회로 N+1 쿼리 문제 해결 + Map productMap = productRepository.findAllById(productIds).stream() + .collect(Collectors.toMap(Product::getId, product -> product)); - // 좋아요 수 집계 - Map likesCountMap = likeRepository.countByProductIds(productIds); + // 요청한 상품 ID와 조회된 상품 수가 일치하는지 확인 + if (productMap.size() != productIds.size()) { + throw new CoreException(ErrorType.NOT_FOUND, "일부 상품을 찾을 수 없습니다."); + } // 좋아요 목록을 상품 정보와 좋아요 수와 함께 변환 + // ✅ Product.likeCount 필드 사용 (비동기 집계된 값) return likes.stream() .map(like -> { - Product product = products.stream() - .filter(p -> p.getId().equals(like.getProductId())) - .findFirst() - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, - String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", like.getProductId()))); - Long likesCount = likesCountMap.getOrDefault(like.getProductId(), 0L); - return LikedProduct.from(product, like, likesCount); + Product product = productMap.get(like.getProductId()); + if (product == null) { + throw new CoreException(ErrorType.NOT_FOUND, + String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", like.getProductId())); + } + // Product 엔티티의 likeCount 필드를 내부에서 사용 + return LikedProduct.from(product); }) .toList(); } @@ -158,21 +204,26 @@ public record LikedProduct( Long likesCount ) { /** - * Product와 Like로부터 LikedProduct를 생성합니다. + * Product로부터 LikedProduct를 생성합니다. + *

+ * Product.likeCount 필드를 사용하여 좋아요 수를 가져옵니다. + *

* * @param product 상품 엔티티 - * @param like 좋아요 엔티티 - * @param likesCount 좋아요 수 * @return 생성된 LikedProduct + * @throws IllegalArgumentException product가 null인 경우 */ - public static LikedProduct from(Product product, Like like, Long likesCount) { + public static LikedProduct from(Product product) { + if (product == null) { + throw new IllegalArgumentException("상품은 null일 수 없습니다."); + } return new LikedProduct( product.getId(), product.getName(), product.getPrice(), product.getStock(), product.getBrandId(), - likesCount + product.getLikeCount() // ✅ Product.likeCount 필드 사용 (비동기 집계된 값) ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/OrderItemCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/OrderItemCommand.java index b93d31215..903595a6c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/OrderItemCommand.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/OrderItemCommand.java @@ -8,8 +8,9 @@ * * @param productId 상품 ID * @param quantity 수량 + * @param couponCode 쿠폰 코드 (선택) */ -public record OrderItemCommand(Long productId, Integer quantity) { +public record OrderItemCommand(Long productId, Integer quantity, String couponCode) { public OrderItemCommand { if (productId == null) { throw new CoreException(ErrorType.BAD_REQUEST, "상품 ID는 필수입니다."); @@ -18,5 +19,16 @@ public record OrderItemCommand(Long productId, Integer quantity) { throw new CoreException(ErrorType.BAD_REQUEST, "상품 수량은 1개 이상이어야 합니다."); } } + + /** + * 쿠폰 코드 없이 OrderItemCommand를 생성합니다. + * + * @param productId 상품 ID + * @param quantity 수량 + * @return 생성된 OrderItemCommand + */ + public static OrderItemCommand of(Long productId, Integer quantity) { + return new OrderItemCommand(productId, quantity, null); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PurchasingFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PurchasingFacade.java index 4bc808613..82e55c406 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PurchasingFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PurchasingFacade.java @@ -1,8 +1,14 @@ package com.loopers.application.purchasing; +import com.loopers.domain.coupon.Coupon; +import com.loopers.domain.coupon.CouponRepository; +import com.loopers.domain.coupon.UserCoupon; +import com.loopers.domain.coupon.UserCouponRepository; +import com.loopers.domain.coupon.discount.CouponDiscountStrategyFactory; import com.loopers.domain.order.Order; import com.loopers.domain.order.OrderItem; import com.loopers.domain.order.OrderRepository; +import org.springframework.orm.ObjectOptimisticLockingFailureException; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductRepository; import com.loopers.domain.user.Point; @@ -10,16 +16,14 @@ import com.loopers.domain.user.UserRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; -import jakarta.transaction.Transactional; +import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import java.util.ArrayList; -import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.Set; import java.util.stream.Collectors; /** @@ -35,6 +39,9 @@ public class PurchasingFacade { private final UserRepository userRepository; private final ProductRepository productRepository; private final OrderRepository orderRepository; + private final CouponRepository couponRepository; + private final UserCouponRepository userCouponRepository; + private final CouponDiscountStrategyFactory couponDiscountStrategyFactory; /** * 주문을 생성한다. @@ -42,7 +49,34 @@ public class PurchasingFacade { * 1. 사용자 조회 및 존재 여부 검증
* 2. 상품 재고 검증 및 차감
* 3. 사용자 포인트 검증 및 차감
- * 4. 주문 저장 및 외부 시스템 알림 + * 4. 주문 저장 + *

+ *

+ * 동시성 제어 전략: + *

    + *
  • PESSIMISTIC_WRITE 사용 근거: Lost Update 방지 및 데이터 일관성 보장
  • + *
  • 포인트 차감: 동시 주문 시 포인트 중복 차감 방지 (금전적 손실 방지)
  • + *
  • 재고 차감: 동시 주문 시 재고 음수 방지 및 정확한 차감 보장 (재고 oversell 방지)
  • + *
  • Lock 범위 최소화: PK/UNIQUE 인덱스 기반 조회로 Lock 범위 최소화
  • + *
+ *

+ *

+ * DBA 설득 근거 (비관적 락 사용): + *

    + *
  • 제한적 사용: 전역이 아닌 금전적 손실 위험이 있는 특정 도메인에만 사용
  • + *
  • 트랜잭션 최소화: 트랜잭션 내부에 외부 I/O 없음, lock holding time 매우 짧음 (몇 ms)
  • + *
  • Lock 범위 최소화: PK/UNIQUE 인덱스 기반 조회로 해당 행만 락 (Record Lock)
  • + *
  • 애플리케이션 레벨 한계: 애플리케이션 레벨로는 race condition을 완전히 방지할 수 없어서 DB 차원의 strong consistency 필요
  • + *
  • 낙관적 락 기본 전략: 쿠폰 사용은 낙관적 락 사용 (Hot Spot 대응)
  • + *
+ *

+ *

+ * Lock 생명주기: + *

    + *
  1. SELECT ... FOR UPDATE 실행 시 락 획득
  2. + *
  3. 트랜잭션 내에서 락 유지 (외부 I/O 없음, 매우 짧은 시간)
  4. + *
  5. 트랜잭션 커밋/롤백 시 락 자동 해제
  6. + *
*

* * @param userId 사용자 식별자 (로그인 ID) @@ -51,25 +85,52 @@ public class PurchasingFacade { */ @Transactional public OrderInfo createOrder(String userId, List commands) { + if (userId == null || userId.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "사용자 ID는 필수입니다."); + } if (commands == null || commands.isEmpty()) { throw new CoreException(ErrorType.BAD_REQUEST, "주문 아이템은 1개 이상이어야 합니다."); } - User user = loadUser(userId); + // 비관적 락을 사용하여 사용자 조회 (포인트 차감 시 동시성 제어) + // - userId는 UNIQUE 인덱스가 있어 Lock 범위 최소화 (Record Lock만 적용) + // - Lost Update 방지: 동시 주문 시 포인트 중복 차감 방지 (금전적 손실 방지) + // - 트랜잭션 내부에 외부 I/O 없음, lock holding time 매우 짧음 + User user = loadUserForUpdate(userId); - Set productIds = new HashSet<>(); - List products = new ArrayList<>(); - List orderItems = new ArrayList<>(); + // ✅ Deadlock 방지: 상품 ID를 정렬하여 일관된 락 획득 순서 보장 + // 여러 상품을 주문할 때, 항상 동일한 순서로 락을 획득하여 deadlock 방지 + List sortedProductIds = commands.stream() + .map(OrderItemCommand::productId) + .distinct() + .sorted() + .toList(); - for (OrderItemCommand command : commands) { - if (!productIds.add(command.productId())) { - throw new CoreException(ErrorType.BAD_REQUEST, - String.format("상품이 중복되었습니다. (상품 ID: %d)", command.productId())); - } + // 중복 상품 검증 + if (sortedProductIds.size() != commands.size()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품이 중복되었습니다."); + } + + // 정렬된 순서대로 상품 락 획득 (Deadlock 방지) + Map productMap = new java.util.HashMap<>(); - Product product = productRepository.findById(command.productId()) + for (Long productId : sortedProductIds) { + // 비관적 락을 사용하여 상품 조회 (재고 차감 시 동시성 제어) + // - id는 PK 인덱스가 있어 Lock 범위 최소화 (Record Lock만 적용) + // - Lost Update 방지: 동시 주문 시 재고 음수 방지 및 정확한 차감 보장 (재고 oversell 방지) + // - 트랜잭션 내부에 외부 I/O 없음, lock holding time 매우 짧음 + // - ✅ 정렬된 순서로 락 획득하여 deadlock 방지 + Product product = productRepository.findByIdForUpdate(productId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, - String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", command.productId()))); + String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", productId))); + productMap.put(productId, product); + } + + // OrderItem 생성 + List products = new ArrayList<>(); + List orderItems = new ArrayList<>(); + for (OrderItemCommand command : commands) { + Product product = productMap.get(command.productId()); products.add(product); orderItems.add(OrderItem.of( @@ -80,7 +141,14 @@ public OrderInfo createOrder(String userId, List commands) { )); } - Order order = Order.of(user.getId(), orderItems); + // 쿠폰 처리 (있는 경우) + String couponCode = extractCouponCode(commands); + Integer discountAmount = 0; + if (couponCode != null && !couponCode.isBlank()) { + discountAmount = applyCoupon(user.getId(), couponCode, calculateSubtotal(orderItems)); + } + + Order order = Order.of(user.getId(), orderItems, couponCode, discountAmount); decreaseStocksForOrderItems(order.getItems(), products); deductUserPoint(user, order.getTotalAmount()); @@ -96,6 +164,13 @@ public OrderInfo createOrder(String userId, List commands) { /** * 주문을 취소하고 포인트를 환불하며 재고를 원복한다. + *

+ * 동시성 제어: + *

    + *
  • 비관적 락 사용: 재고 원복 시 동시성 제어를 위해 findByIdForUpdate 사용
  • + *
  • Deadlock 방지: 상품 ID를 정렬하여 일관된 락 획득 순서 보장
  • + *
+ *

* * @param order 주문 엔티티 * @param user 사용자 엔티티 @@ -106,18 +181,41 @@ public void cancelOrder(Order order, User user) { throw new CoreException(ErrorType.BAD_REQUEST, "취소할 주문과 사용자 정보는 필수입니다."); } - List products = order.getItems().stream() - .map(item -> productRepository.findById(item.getProductId()) + // ✅ Deadlock 방지: User 락을 먼저 획득하여 createOrder와 동일한 락 획득 순서 보장 + // createOrder: User 락 → Product 락 (정렬됨) + // cancelOrder: User 락 → Product 락 (정렬됨) - 동일한 순서로 락 획득 + User lockedUser = userRepository.findByUserIdForUpdate(user.getUserId()); + if (lockedUser == null) { + throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다."); + } + + // ✅ Deadlock 방지: 상품 ID를 정렬하여 일관된 락 획득 순서 보장 + List sortedProductIds = order.getItems().stream() + .map(OrderItem::getProductId) + .distinct() + .sorted() + .toList(); + + // 정렬된 순서대로 상품 락 획득 (Deadlock 방지) + Map productMap = new java.util.HashMap<>(); + for (Long productId : sortedProductIds) { + Product product = productRepository.findByIdForUpdate(productId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, - String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", item.getProductId())))) + String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", productId))); + productMap.put(productId, product); + } + + // OrderItem 순서대로 Product 리스트 생성 + List products = order.getItems().stream() + .map(item -> productMap.get(item.getProductId())) .toList(); order.cancel(); increaseStocksForOrderItems(order.getItems(), products); - user.receivePoint(Point.of((long) order.getTotalAmount())); + lockedUser.receivePoint(Point.of((long) order.getTotalAmount())); products.forEach(productRepository::save); - userRepository.save(user); + userRepository.save(lockedUser); orderRepository.save(order); } @@ -127,7 +225,7 @@ public void cancelOrder(Order order, User user) { * @param userId 사용자 식별자 (로그인 ID) * @return 주문 목록 */ - @Transactional + @Transactional(readOnly = true) public List getOrders(String userId) { User user = loadUser(userId); List orders = orderRepository.findAllByUserId(user.getId()); @@ -143,7 +241,7 @@ public List getOrders(String userId) { * @param orderId 주문 ID * @return 주문 정보 */ - @Transactional + @Transactional(readOnly = true) public OrderInfo getOrder(String userId, Long orderId) { User user = loadUser(userId); Order order = orderRepository.findById(orderId) @@ -198,5 +296,107 @@ private User loadUser(String userId) { } return user; } + + /** + * 비관적 락을 사용하여 사용자를 조회합니다. + *

+ * 포인트 차감 등 동시성 제어가 필요한 경우 사용합니다. + *

+ *

+ * 전제 조건: userId는 상위 계층에서 이미 null/blank 검증이 완료되어야 합니다. + *

+ * + * @param userId 사용자 ID (null이 아니고 비어있지 않아야 함) + * @return 조회된 사용자 + * @throws CoreException 사용자를 찾을 수 없는 경우 + */ + private User loadUserForUpdate(String userId) { + User user = userRepository.findByUserIdForUpdate(userId); + if (user == null) { + throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다."); + } + return user; + } + + /** + * 주문 명령에서 쿠폰 코드를 추출합니다. + * + * @param commands 주문 명령 목록 + * @return 쿠폰 코드 (없으면 null) + */ + private String extractCouponCode(List commands) { + return commands.stream() + .filter(cmd -> cmd.couponCode() != null && !cmd.couponCode().isBlank()) + .map(OrderItemCommand::couponCode) + .findFirst() + .orElse(null); + } + + /** + * 쿠폰을 적용하여 할인 금액을 계산하고 쿠폰을 사용 처리합니다. + *

+ * 동시성 제어 전략: + *

    + *
  • OPTIMISTIC_LOCK 사용 근거: 쿠폰 중복 사용 방지, Hot Spot 대응
  • + *
  • @Version 필드: UserCoupon 엔티티의 version 필드를 통해 자동으로 낙관적 락 적용
  • + *
  • 동시 사용 시: 한 명만 성공하고 나머지는 OptimisticLockException 발생
  • + *
  • 사용 목적: 동일 쿠폰으로 여러 기기에서 동시 주문해도 한 번만 사용되도록 보장
  • + *
+ *

+ * + * @param userId 사용자 ID + * @param couponCode 쿠폰 코드 + * @param subtotal 주문 소계 금액 + * @return 할인 금액 + * @throws CoreException 쿠폰을 찾을 수 없거나 사용 불가능한 경우, 동시 사용으로 인한 충돌 시 + */ + private Integer applyCoupon(Long userId, String couponCode, Integer subtotal) { + // 쿠폰 존재 여부 확인 + Coupon coupon = couponRepository.findByCode(couponCode) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + String.format("쿠폰을 찾을 수 없습니다. (쿠폰 코드: %s)", couponCode))); + + // 낙관적 락을 사용하여 사용자 쿠폰 조회 (동시성 제어) + // @Version 필드가 있어 자동으로 낙관적 락이 적용됨 + UserCoupon userCoupon = userCouponRepository.findByUserIdAndCouponCodeForUpdate(userId, couponCode) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + String.format("사용자가 소유한 쿠폰을 찾을 수 없습니다. (쿠폰 코드: %s)", couponCode))); + + // 쿠폰 사용 가능 여부 확인 + if (!userCoupon.isAvailable()) { + throw new CoreException(ErrorType.BAD_REQUEST, + String.format("이미 사용된 쿠폰입니다. (쿠폰 코드: %s)", couponCode)); + } + + // 쿠폰 사용 처리 + userCoupon.use(); + + // 할인 금액 계산 (전략 패턴 사용) + Integer discountAmount = coupon.calculateDiscountAmount(subtotal, couponDiscountStrategyFactory); + + try { + // 사용자 쿠폰 저장 (version 체크 자동 수행) + // 다른 트랜잭션이 먼저 수정했다면 OptimisticLockException 발생 + userCouponRepository.save(userCoupon); + } catch (ObjectOptimisticLockingFailureException e) { + // 낙관적 락 충돌: 다른 트랜잭션이 먼저 쿠폰을 사용함 + throw new CoreException(ErrorType.CONFLICT, + String.format("쿠폰이 이미 사용되었습니다. (쿠폰 코드: %s)", couponCode)); + } + + return discountAmount; + } + + /** + * 주문 아이템 목록으로부터 소계 금액을 계산합니다. + * + * @param orderItems 주문 아이템 목록 + * @return 계산된 소계 금액 + */ + private Integer calculateSubtotal(List orderItems) { + return orderItems.stream() + .mapToInt(item -> item.getPrice() * item.getQuantity()) + .sum(); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/scheduler/LikeCountSyncScheduler.java b/apps/commerce-api/src/main/java/com/loopers/application/scheduler/LikeCountSyncScheduler.java new file mode 100644 index 000000000..4621b4fef --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/scheduler/LikeCountSyncScheduler.java @@ -0,0 +1,98 @@ +package com.loopers.application.scheduler; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.batch.core.repository.JobRestartException; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 좋아요 수 동기화 스케줄러. + *

+ * 주기적으로 Spring Batch Job을 실행하여 Like 테이블의 COUNT(*) 결과를 Product.likeCount 필드에 동기화합니다. + *

+ *

+ * 동작 원리: + *

    + *
  1. 주기적으로 실행 (기본: 5초마다)
  2. + *
  3. Spring Batch Job 실행
  4. + *
  5. Reader: 모든 상품 ID 조회
  6. + *
  7. Processor: 각 상품의 좋아요 수 집계 (Like 테이블 COUNT(*))
  8. + *
  9. Writer: Product 테이블의 likeCount 필드 업데이트
  10. + *
+ *

+ *

+ * 설계 근거: + *

    + *
  • Spring Batch 사용: 대량 처리, 청크 단위 처리, 재시작 가능
  • + *
  • Eventually Consistent: 좋아요 수는 약간의 지연 허용 가능
  • + *
  • 성능 최적화: 조회 시 COUNT(*) 대신 컬럼만 읽으면 됨
  • + *
  • 쓰기 경합 최소화: Like 테이블은 Insert-only로 쓰기 경합 없음
  • + *
  • 확장성: Redis 없이도 대규모 트래픽 처리 가능
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@RequiredArgsConstructor +@Component +public class LikeCountSyncScheduler { + + private final JobLauncher jobLauncher; + private final Job likeCountSyncJob; + + /** + * 좋아요 수를 동기화합니다. + *

+ * 5초마다 실행되어 Spring Batch Job을 통해 Like 테이블의 집계 결과를 Product.likeCount에 반영합니다. + *

+ *

+ * Spring Batch 장점: + *

    + *
  • 청크 단위 처리: 100개씩 묶어서 처리하여 성능 최적화
  • + *
  • 트랜잭션 관리: 청크 단위로 커밋하여 안정성 보장
  • + *
  • 재시작 가능: Job 실패 시 재시작 가능
  • + *
  • 모니터링: Spring Batch 메타데이터로 실행 이력 추적
  • + *
+ *

+ *

+ * 주기적 실행 전략: + *

    + *
  • 타임스탬프 기반 JobParameters: 매 실행마다 타임스탬프를 추가하여 새로운 JobInstance 생성
  • + *
  • 5초마다 실행: 스케줄러가 5초마다 Job을 실행하여 좋아요 수를 최신화
  • + *
+ *

+ */ + @Scheduled(fixedDelay = 5000) // 5초마다 실행 + public void syncLikeCounts() { + try { + log.debug("좋아요 수 동기화 배치 Job 시작"); + + // 타임스탬프를 JobParameters에 추가하여 매번 새로운 JobInstance 생성 + // Spring Batch는 동일한 JobParameters를 가진 JobInstance를 재실행하지 않으므로, + // 타임스탬프를 추가하여 매 실행마다 새로운 JobInstance를 생성합니다. + JobParameters jobParameters = new JobParametersBuilder() + .addString("jobName", "likeCountSync") + .addLong("timestamp", System.currentTimeMillis()) + .toJobParameters(); + + // Spring Batch Job 실행 + JobExecution jobExecution = jobLauncher.run(likeCountSyncJob, jobParameters); + + log.debug("좋아요 수 동기화 배치 Job 완료: status={}", jobExecution.getStatus()); + + } catch (JobRestartException e) { + log.error("좋아요 수 동기화 배치 Job 재시작 실패", e); + } catch (Exception e) { + log.error("좋아요 수 동기화 배치 Job 실행 중 오류 발생", e); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/signup/SignUpFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/signup/SignUpFacade.java index 9ede52abb..293505b15 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/signup/SignUpFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/signup/SignUpFacade.java @@ -52,12 +52,18 @@ public SignUpInfo signUp(String userId, String email, String birthDateStr, Strin /** * 성별 문자열을 Gender enum으로 변환합니다. + *

+ * 도메인 진입점에서 방어 로직을 제공하여 NPE를 방지합니다. + *

* * @param genderStr 성별 문자열 * @return Gender enum - * @throws CoreException gender 값이 유효하지 않은 경우 + * @throws CoreException gender 값이 null이거나 유효하지 않은 경우 */ private Gender parseGender(String genderStr) { + if (genderStr == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "gender 값이 올바르지 않습니다."); + } try { String genderValue = genderStr.trim().toUpperCase(Locale.ROOT); return Gender.valueOf(genderValue); diff --git a/apps/commerce-api/src/main/java/com/loopers/config/batch/LikeCountSyncBatchConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/batch/LikeCountSyncBatchConfig.java new file mode 100644 index 000000000..016ca8c0f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/batch/LikeCountSyncBatchConfig.java @@ -0,0 +1,171 @@ +package com.loopers.config.batch; + +import com.loopers.domain.like.LikeRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.ItemWriter; +import org.springframework.batch.item.support.ListItemReader; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +import java.util.List; +import java.util.Map; + +/** + * 좋아요 수 동기화 배치 Job Configuration. + *

+ * Spring Batch를 사용하여 Like 테이블의 COUNT(*) 결과를 Product.likeCount 필드에 동기화합니다. + *

+ *

+ * 배치 구조: + *

    + *
  1. Reader: 모든 상품 ID 조회
  2. + *
  3. Processor: 각 상품의 좋아요 수 집계 (Like 테이블 COUNT(*))
  4. + *
  5. Writer: Product.likeCount 필드 업데이트
  6. + *
+ *

+ *

+ * 설계 근거: + *

    + *
  • 대량 처리: Spring Batch의 청크 단위 처리로 성능 최적화
  • + *
  • 트랜잭션 관리: 청크 단위로 커밋하여 안정성 보장
  • + *
  • 재시작 가능: Job 실패 시 재시작 가능
  • + *
  • 모니터링: Spring Batch 메타데이터로 실행 이력 추적
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@RequiredArgsConstructor +@Configuration +public class LikeCountSyncBatchConfig { + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final ProductRepository productRepository; + private final LikeRepository likeRepository; + + private static final int CHUNK_SIZE = 100; // 청크 크기: 100개씩 처리 + + /** + * 좋아요 수 동기화 Job을 생성합니다. + * + * @return 좋아요 수 동기화 Job + */ + @Bean + public Job likeCountSyncJob() { + return new JobBuilder("likeCountSyncJob", jobRepository) + .start(likeCountSyncStep()) + .build(); + } + + /** + * 좋아요 수 동기화 Step을 생성합니다. + *

+ * allowStartIfComplete(true) 설정: + *

    + *
  • 주기적 실행: 스케줄러에서 주기적으로 실행할 수 있도록 완료된 Step도 재실행 가능
  • + *
  • 고정된 JobParameters: 고정된 JobParameters를 사용하므로 완료된 JobInstance도 재실행 필요
  • + *
+ *

+ * + * @return 좋아요 수 동기화 Step + */ + @Bean + public Step likeCountSyncStep() { + return new StepBuilder("likeCountSyncStep", jobRepository) + .chunk(CHUNK_SIZE, transactionManager) + .reader(productIdReader()) + .processor(productLikeCountProcessor()) + .writer(productLikeCountWriter()) + .allowStartIfComplete(true) // ✅ 완료된 Step도 재실행 가능 (스케줄러에서 주기적 실행) + .build(); + } + + /** + * 모든 상품 ID를 읽어오는 Reader를 생성합니다. + *

+ * @StepScope 사용 이유: + *

    + *
  • 최신 데이터 보장: 매 Step 실행 시마다 Reader가 새로 생성되어 최신 상품 ID 목록 조회
  • + *
  • 신규 상품 포함: 애플리케이션 기동 이후 생성된 상품도 배치 Job 처리 대상에 포함
  • + *
  • 싱글톤 스코프 문제 해결: @Bean 기본 스코프(싱글톤)로 인한 스냅샷 고정 문제 방지
  • + *
+ *

+ *

+ * 동작 원리: + *

    + *
  • @StepScope는 Step 실행 시마다 Bean을 새로 생성
  • + *
  • 매번 productRepository.findAllProductIds()를 호출하여 최신 상품 ID 목록 조회
  • + *
  • 스케줄러가 주기적으로 Job을 실행해도 항상 최신 상품 목록 기준으로 동기화
  • + *
+ *

+ * + * @return 상품 ID Reader + */ + @Bean + @StepScope + public ItemReader productIdReader() { + List productIds = productRepository.findAllProductIds(); + log.debug("좋아요 수 동기화 대상 상품 수: {}", productIds.size()); + return new ListItemReader<>(productIds); + } + + /** + * 상품 ID로부터 좋아요 수를 집계하는 Processor를 생성합니다. + * + * @return 상품 좋아요 수 Processor + */ + @Bean + public ItemProcessor productLikeCountProcessor() { + return productId -> { + // Like 테이블에서 해당 상품의 좋아요 수 집계 + Map likeCountMap = likeRepository.countByProductIds(List.of(productId)); + Long likeCount = likeCountMap.getOrDefault(productId, 0L); + return new ProductLikeCount(productId, likeCount); + }; + } + + /** + * Product.likeCount 필드를 업데이트하는 Writer를 생성합니다. + * + * @return 상품 좋아요 수 Writer + */ + @Bean + public ItemWriter productLikeCountWriter() { + return items -> { + for (ProductLikeCount item : items) { + try { + productRepository.updateLikeCount(item.productId(), item.likeCount()); + } catch (Exception e) { + log.warn("상품 좋아요 수 업데이트 실패: productId={}, likeCount={}, error={}", + item.productId(), item.likeCount(), e.getMessage()); + // 개별 실패는 로그만 남기고 계속 진행 + } + } + }; + } + + /** + * 상품 ID와 좋아요 수를 담는 레코드. + * + * @param productId 상품 ID + * @param likeCount 좋아요 수 + */ + public record ProductLikeCount(Long productId, Long likeCount) { + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java index 611b27a2c..b0b1f55c3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java @@ -1,6 +1,8 @@ package com.loopers.domain.brand; import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Table; @@ -22,18 +24,32 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter public class Brand extends BaseEntity { - @Column(name = "name") + @Column(name = "name", nullable = false) private String name; /** * Brand 인스턴스를 생성합니다. * * @param name 브랜드 이름 + * @throws CoreException name이 null이거나 공백일 경우 */ public Brand(String name) { + validateName(name); this.name = name; } + /** + * 브랜드 이름의 유효성을 검증합니다. + * + * @param name 검증할 브랜드 이름 + * @throws CoreException name이 null이거나 공백일 경우 + */ + private void validateName(String name) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 이름은 필수입니다."); + } + } + /** * Brand 인스턴스를 생성하는 정적 팩토리 메서드. * diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java index df55a0780..03d6db682 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java @@ -1,5 +1,6 @@ package com.loopers.domain.brand; +import java.util.List; import java.util.Optional; /** @@ -27,5 +28,16 @@ public interface BrandRepository { * @return 조회된 브랜드를 담은 Optional */ Optional findById(Long brandId); + + /** + * 브랜드 ID 목록으로 브랜드 목록을 조회합니다. + *

+ * 배치 조회를 통해 N+1 쿼리 문제를 해결합니다. + *

+ * + * @param brandIds 조회할 브랜드 ID 목록 + * @return 조회된 브랜드 목록 + */ + List findAllById(List brandIds); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/Coupon.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/Coupon.java new file mode 100644 index 000000000..b02c07333 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/Coupon.java @@ -0,0 +1,137 @@ +package com.loopers.domain.coupon; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.coupon.discount.CouponDiscountStrategy; +import com.loopers.domain.coupon.discount.CouponDiscountStrategyFactory; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 쿠폰 도메인 엔티티. + *

+ * 쿠폰의 기본 정보(코드, 타입, 할인 금액/비율)를 관리합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Entity +@Table(name = "coupon") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class Coupon extends BaseEntity { + @Column(name = "code", unique = true, nullable = false, length = 50) + private String code; + + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false) + private CouponType type; + + @Column(name = "discount_value", nullable = false) + private Integer discountValue; + + /** + * Coupon 인스턴스를 생성합니다. + * + * @param code 쿠폰 코드 (필수, 최대 50자) + * @param type 쿠폰 타입 (필수) + * @param discountValue 할인 값 (필수, 0 초과) + * - FIXED_AMOUNT: 할인 금액 + * - PERCENTAGE: 할인 비율 (0-100) + * @throws CoreException 유효성 검증 실패 시 + */ + public Coupon(String code, CouponType type, Integer discountValue) { + validateCode(code); + validateType(type); + validateDiscountValue(type, discountValue); + this.code = code; + this.type = type; + this.discountValue = discountValue; + } + + /** + * Coupon 인스턴스를 생성하는 정적 팩토리 메서드. + * + * @param code 쿠폰 코드 + * @param type 쿠폰 타입 + * @param discountValue 할인 값 + * @return 생성된 Coupon 인스턴스 + * @throws CoreException 유효성 검증 실패 시 + */ + public static Coupon of(String code, CouponType type, Integer discountValue) { + return new Coupon(code, type, discountValue); + } + + /** + * 쿠폰 코드의 유효성을 검증합니다. + * + * @param code 검증할 쿠폰 코드 + * @throws CoreException code가 null, 공백이거나 50자를 초과할 경우 + */ + private void validateCode(String code) { + if (code == null || code.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "쿠폰 코드는 필수입니다."); + } + if (code.length() > 50) { + throw new CoreException(ErrorType.BAD_REQUEST, "쿠폰 코드는 50자를 초과할 수 없습니다."); + } + } + + /** + * 쿠폰 타입의 유효성을 검증합니다. + * + * @param type 검증할 쿠폰 타입 + * @throws CoreException type이 null일 경우 + */ + private void validateType(CouponType type) { + if (type == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "쿠폰 타입은 필수입니다."); + } + } + + /** + * 할인 값의 유효성을 검증합니다. + * + * @param type 쿠폰 타입 + * @param discountValue 검증할 할인 값 + * @throws CoreException discountValue가 null이거나 유효하지 않을 경우 + */ + private void validateDiscountValue(CouponType type, Integer discountValue) { + if (discountValue == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "할인 값은 필수입니다."); + } + if (discountValue <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "할인 값은 0보다 커야 합니다."); + } + if (type == CouponType.PERCENTAGE && discountValue > 100) { + throw new CoreException(ErrorType.BAD_REQUEST, "정률 쿠폰의 할인 비율은 100을 초과할 수 없습니다."); + } + } + + /** + * 주문 금액에 쿠폰을 적용하여 할인 금액을 계산합니다. + *

+ * 전략 패턴을 사용하여 쿠폰 타입별 할인 계산 로직을 분리합니다. + * 새로운 쿠폰 타입이 추가되어도 기존 코드를 수정하지 않고 확장할 수 있습니다. + *

+ * + * @param orderAmount 주문 금액 + * @param strategyFactory 할인 계산 전략 팩토리 + * @return 할인 금액 + * @throws CoreException orderAmount가 null이거나 0 이하일 경우 + */ + public Integer calculateDiscountAmount(Integer orderAmount, CouponDiscountStrategyFactory strategyFactory) { + if (orderAmount == null || orderAmount <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 금액은 0보다 커야 합니다."); + } + + // 전략 패턴을 사용하여 쿠폰 타입별 할인 계산 + CouponDiscountStrategy strategy = strategyFactory.getStrategy(this.type); + return strategy.calculateDiscountAmount(orderAmount, this.discountValue); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponRepository.java new file mode 100644 index 000000000..6ffe52a9e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponRepository.java @@ -0,0 +1,39 @@ +package com.loopers.domain.coupon; + +import java.util.Optional; + +/** + * Coupon 엔티티에 대한 저장소 인터페이스. + *

+ * 쿠폰 정보의 영속성 계층과의 상호작용을 정의합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public interface CouponRepository { + /** + * 쿠폰을 저장합니다. + * + * @param coupon 저장할 쿠폰 + * @return 저장된 쿠폰 + */ + Coupon save(Coupon coupon); + + /** + * 쿠폰 코드로 쿠폰을 조회합니다. + * + * @param code 쿠폰 코드 + * @return 조회된 쿠폰을 담은 Optional + */ + Optional findByCode(String code); + + /** + * 쿠폰 ID로 쿠폰을 조회합니다. + * + * @param couponId 쿠폰 ID + * @return 조회된 쿠폰을 담은 Optional + */ + Optional findById(Long couponId); +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponType.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponType.java new file mode 100644 index 000000000..183cb31c4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponType.java @@ -0,0 +1,23 @@ +package com.loopers.domain.coupon; + +/** + * 쿠폰 타입. + *

+ * 정액 쿠폰과 정률 쿠폰을 구분합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public enum CouponType { + /** + * 정액 쿠폰: 고정 금액 할인 + */ + FIXED_AMOUNT, + + /** + * 정률 쿠폰: 비율 할인 + */ + PERCENTAGE +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/UserCoupon.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/UserCoupon.java new file mode 100644 index 000000000..3f2e07322 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/UserCoupon.java @@ -0,0 +1,134 @@ +package com.loopers.domain.coupon; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 사용자 쿠폰 도메인 엔티티. + *

+ * 사용자가 소유한 쿠폰과 사용 여부를 관리합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Entity +@Table(name = "user_coupon", uniqueConstraints = { + @UniqueConstraint(name = "uk_user_coupon_user_coupon", columnNames = {"ref_user_id", "ref_coupon_id"}) +}) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class UserCoupon extends BaseEntity { + @Column(name = "ref_user_id", nullable = false) + private Long userId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "ref_coupon_id", nullable = false) + private Coupon coupon; + + @Column(name = "is_used", nullable = false) + private Boolean isUsed; + + @Version + @Column(name = "version", nullable = false) + private Long version; + + /** + * UserCoupon 인스턴스를 생성합니다. + * + * @param userId 사용자 ID (필수) + * @param coupon 쿠폰 (필수) + * @throws CoreException 유효성 검증 실패 시 + */ + public UserCoupon(Long userId, Coupon coupon) { + validateUserId(userId); + validateCoupon(coupon); + this.userId = userId; + this.coupon = coupon; + this.isUsed = false; + } + + /** + * UserCoupon 인스턴스를 생성하는 정적 팩토리 메서드. + * + * @param userId 사용자 ID + * @param coupon 쿠폰 + * @return 생성된 UserCoupon 인스턴스 + * @throws CoreException 유효성 검증 실패 시 + */ + public static UserCoupon of(Long userId, Coupon coupon) { + return new UserCoupon(userId, coupon); + } + + /** + * 사용자 ID의 유효성을 검증합니다. + * + * @param userId 검증할 사용자 ID + * @throws CoreException userId가 null일 경우 + */ + private void validateUserId(Long userId) { + if (userId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "사용자 ID는 필수입니다."); + } + } + + /** + * 쿠폰의 유효성을 검증합니다. + * + * @param coupon 검증할 쿠폰 + * @throws CoreException coupon이 null일 경우 + */ + private void validateCoupon(Coupon coupon) { + if (coupon == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "쿠폰은 필수입니다."); + } + } + + /** + * 쿠폰을 사용합니다. + *

+ * 이미 사용된 쿠폰은 다시 사용할 수 없습니다. + *

+ * + * @throws CoreException 이미 사용된 쿠폰일 경우 + */ + public void use() { + if (this.isUsed) { + throw new CoreException(ErrorType.BAD_REQUEST, "이미 사용된 쿠폰입니다."); + } + this.isUsed = true; + } + + /** + * 쿠폰이 사용 가능한지 확인합니다. + * + * @return 사용 가능하면 true, 아니면 false + */ + public boolean isAvailable() { + return !this.isUsed; + } + + /** + * 쿠폰 코드를 반환합니다. + * + * @return 쿠폰 코드 + */ + public String getCouponCode() { + return coupon.getCode(); + } + + /** + * 쿠폰을 반환합니다. + * + * @return 쿠폰 + */ + public Coupon getCoupon() { + return coupon; + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/UserCouponRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/UserCouponRepository.java new file mode 100644 index 000000000..0bfd69db7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/UserCouponRepository.java @@ -0,0 +1,52 @@ +package com.loopers.domain.coupon; + +import java.util.Optional; + +/** + * UserCoupon 엔티티에 대한 저장소 인터페이스. + *

+ * 사용자 쿠폰 정보의 영속성 계층과의 상호작용을 정의합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public interface UserCouponRepository { + /** + * 사용자 쿠폰을 저장합니다. + * + * @param userCoupon 저장할 사용자 쿠폰 + * @return 저장된 사용자 쿠폰 + */ + UserCoupon save(UserCoupon userCoupon); + + /** + * 사용자 ID와 쿠폰 코드로 사용자 쿠폰을 조회합니다. + * + * @param userId 사용자 ID + * @param couponCode 쿠폰 코드 + * @return 조회된 사용자 쿠폰을 담은 Optional + */ + Optional findByUserIdAndCouponCode(Long userId, String couponCode); + + /** + * 사용자 ID와 쿠폰 코드로 사용자 쿠폰을 조회합니다. (낙관적 락) + *

+ * 동시성 제어가 필요한 경우 사용합니다. (예: 쿠폰 사용) + *

+ *

+ * Lock 전략: + *

    + *
  • OPTIMISTIC_LOCK: @Version 필드를 통한 낙관적 락 사용
  • + *
  • 동시 사용 시: 한 명만 성공하고 나머지는 OptimisticLockException 발생
  • + *
  • 사용 목적: 쿠폰 사용 시 Lost Update 방지, Hot Spot 대응
  • + *
+ *

+ * + * @param userId 사용자 ID + * @param couponCode 쿠폰 코드 + * @return 조회된 사용자 쿠폰을 담은 Optional + */ + Optional findByUserIdAndCouponCodeForUpdate(Long userId, String couponCode); +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/discount/CouponDiscountStrategy.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/discount/CouponDiscountStrategy.java new file mode 100644 index 000000000..883ab15bd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/discount/CouponDiscountStrategy.java @@ -0,0 +1,23 @@ +package com.loopers.domain.coupon.discount; + +/** + * 쿠폰 할인 계산 전략 인터페이스. + *

+ * 전략 패턴을 사용하여 쿠폰 타입별 할인 계산 로직을 분리합니다. + * 새로운 쿠폰 타입이 추가되어도 기존 코드를 수정하지 않고 확장할 수 있습니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public interface CouponDiscountStrategy { + /** + * 주문 금액에 쿠폰을 적용하여 할인 금액을 계산합니다. + * + * @param orderAmount 주문 금액 + * @param discountValue 할인 값 (쿠폰 타입에 따라 의미가 다름) + * @return 할인 금액 + */ + Integer calculateDiscountAmount(Integer orderAmount, Integer discountValue); +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/discount/CouponDiscountStrategyFactory.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/discount/CouponDiscountStrategyFactory.java new file mode 100644 index 000000000..36bc98a7f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/discount/CouponDiscountStrategyFactory.java @@ -0,0 +1,53 @@ +package com.loopers.domain.coupon.discount; + +import com.loopers.domain.coupon.CouponType; +import org.springframework.stereotype.Component; + +import java.util.Map; + +/** + * 쿠폰 할인 계산 전략 팩토리. + *

+ * 쿠폰 타입에 따라 적절한 할인 계산 전략을 반환합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Component +public class CouponDiscountStrategyFactory { + private final Map strategyMap; + + /** + * CouponDiscountStrategyFactory를 생성합니다. + * + * @param fixedAmountStrategy 정액 쿠폰 전략 + * @param percentageStrategy 정률 쿠폰 전략 + */ + public CouponDiscountStrategyFactory( + FixedAmountDiscountStrategy fixedAmountStrategy, + PercentageDiscountStrategy percentageStrategy + ) { + this.strategyMap = Map.of( + CouponType.FIXED_AMOUNT, fixedAmountStrategy, + CouponType.PERCENTAGE, percentageStrategy + ); + } + + /** + * 쿠폰 타입에 해당하는 할인 계산 전략을 반환합니다. + * + * @param type 쿠폰 타입 + * @return 할인 계산 전략 + * @throws IllegalArgumentException 지원하지 않는 쿠폰 타입인 경우 + */ + public CouponDiscountStrategy getStrategy(CouponType type) { + CouponDiscountStrategy strategy = strategyMap.get(type); + if (strategy == null) { + throw new IllegalArgumentException( + String.format("지원하지 않는 쿠폰 타입입니다. (타입: %s)", type)); + } + return strategy; + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/discount/FixedAmountDiscountStrategy.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/discount/FixedAmountDiscountStrategy.java new file mode 100644 index 000000000..f7a25a11a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/discount/FixedAmountDiscountStrategy.java @@ -0,0 +1,32 @@ +package com.loopers.domain.coupon.discount; + +import org.springframework.stereotype.Component; + +/** + * 정액 쿠폰 할인 계산 전략. + *

+ * 고정 금액을 할인하며, 할인 금액이 주문 금액을 초과하지 않도록 보장합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Component +public class FixedAmountDiscountStrategy implements CouponDiscountStrategy { + /** + * {@inheritDoc} + *

+ * 정액 쿠폰: 할인 금액이 주문 금액을 초과하지 않도록 제한합니다. + *

+ * + * @param orderAmount 주문 금액 + * @param discountValue 할인 금액 + * @return 할인 금액 (주문 금액을 초과하지 않음) + */ + @Override + public Integer calculateDiscountAmount(Integer orderAmount, Integer discountValue) { + // 할인 금액이 주문 금액을 초과하지 않도록 + return Math.min(discountValue, orderAmount); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/discount/PercentageDiscountStrategy.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/discount/PercentageDiscountStrategy.java new file mode 100644 index 000000000..c8b0a87e8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/discount/PercentageDiscountStrategy.java @@ -0,0 +1,32 @@ +package com.loopers.domain.coupon.discount; + +import org.springframework.stereotype.Component; + +/** + * 정률 쿠폰 할인 계산 전략. + *

+ * 주문 금액의 일정 비율을 할인합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Component +public class PercentageDiscountStrategy implements CouponDiscountStrategy { + /** + * {@inheritDoc} + *

+ * 정률 쿠폰: 주문 금액의 할인 비율만큼 할인합니다. + *

+ * + * @param orderAmount 주문 금액 + * @param discountValue 할인 비율 (0-100) + * @return 할인 금액 + */ + @Override + public Integer calculateDiscountAmount(Integer orderAmount, Integer discountValue) { + // 주문 금액의 할인 비율만큼 할인 + return (int) Math.round(orderAmount * discountValue / 100.0); + } +} + 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 index 326de17ed..d6d78bd1b 100644 --- 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 @@ -4,6 +4,7 @@ import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -18,7 +19,15 @@ * @version 1.0 */ @Entity -@Table(name = "`like`") +@Table( + name = "`like`", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_like_user_product", + columnNames = {"ref_user_id", "ref_product_id"} + ) + } +) @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter public class Like extends BaseEntity { 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 index fbc2976c1..96b09f43e 100644 --- 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 @@ -53,5 +53,15 @@ public interface LikeRepository { * @return 상품 ID를 키로, 좋아요 수를 값으로 하는 Map */ Map countByProductIds(List productIds); + + /** + * 모든 상품의 좋아요 수를 집계합니다. + *

+ * 비동기 집계 스케줄러에서 사용됩니다. + *

+ * + * @return 상품 ID를 키로, 좋아요 수를 값으로 하는 Map + */ + Map countAllByProductIds(); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java index 4c0a59214..78b5e1049 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -36,6 +36,12 @@ public class Order extends BaseEntity { @Column(name = "total_amount", nullable = false) private Integer totalAmount; + @Column(name = "coupon_code", length = 50) + private String couponCode; + + @Column(name = "discount_amount") + private Integer discountAmount; + @JdbcTypeCode(SqlTypes.JSON) @Column(name = "items", nullable = false, columnDefinition = "json") private List items; @@ -45,14 +51,21 @@ public class Order extends BaseEntity { * * @param userId 사용자 ID * @param items 주문 아이템 목록 + * @param couponCode 쿠폰 코드 (선택) + * @param discountAmount 할인 금액 (선택) * @throws CoreException items가 null이거나 비어있을 경우 */ - public Order(Long userId, List items) { + public Order(Long userId, List items, String couponCode, Integer discountAmount) { validateUserId(userId); validateItems(items); this.userId = userId; - this.items = items; - this.totalAmount = calculateTotalAmount(items); + // ✅ 방어적 복사로 불변 리스트 생성 (총액과 아이템의 일관성 보장) + List immutableItems = List.copyOf(items); + this.items = immutableItems; + Integer subtotal = calculateTotalAmount(immutableItems); + this.discountAmount = discountAmount != null ? discountAmount : 0; + this.totalAmount = Math.max(0, subtotal - this.discountAmount); + this.couponCode = couponCode; this.status = OrderStatus.PENDING; } @@ -64,7 +77,20 @@ public Order(Long userId, List items) { * @return 생성된 Order 인스턴스 */ public static Order of(Long userId, List items) { - return new Order(userId, items); + return new Order(userId, items, null, null); + } + + /** + * Order 인스턴스를 생성하는 정적 팩토리 메서드 (쿠폰 포함). + * + * @param userId 사용자 ID + * @param items 주문 아이템 목록 + * @param couponCode 쿠폰 코드 + * @param discountAmount 할인 금액 + * @return 생성된 Order 인스턴스 + */ + public static Order of(Long userId, List items, String couponCode, Integer discountAmount) { + return new Order(userId, items, couponCode, discountAmount); } /** @@ -118,10 +144,10 @@ public void complete() { /** * 주문을 취소 상태로 변경합니다. * 상태 변경만 수행하며, 포인트 환불은 도메인 서비스에서 처리합니다. - * PENDING 상태의 주문만 취소할 수 있습니다. + * PENDING 또는 COMPLETED 상태의 주문만 취소할 수 있습니다. */ public void cancel() { - if (this.status != OrderStatus.PENDING) { + if (this.status != OrderStatus.PENDING && this.status != OrderStatus.COMPLETED) { throw new CoreException(ErrorType.BAD_REQUEST, String.format("취소할 수 없는 주문 상태입니다. (현재 상태: %s)", this.status)); } 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 index 8e18bc60d..0b38e00b9 100644 --- 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 @@ -15,7 +15,6 @@ */ @Getter @EqualsAndHashCode -@Embeddable public class OrderItem { private Long productId; private String name; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java index 52315a26e..8e56d4e20 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -35,6 +35,9 @@ public class Product extends BaseEntity { @Column(name = "ref_brand_id", nullable = false) private Long brandId; + @Column(name = "like_count", nullable = false) + private Long likeCount; + /** * Product 인스턴스를 생성합니다. * @@ -53,6 +56,7 @@ public Product(String name, Integer price, Integer stock, Long brandId) { this.price = price; this.stock = stock; this.brandId = brandId; + this.likeCount = 0L; } /** @@ -156,5 +160,21 @@ private void validateQuantity(Integer quantity) { throw new CoreException(ErrorType.BAD_REQUEST, "수량은 0보다 커야 합니다."); } } + + /** + * 좋아요 수를 업데이트합니다. + *

+ * 비동기 집계 스케줄러에서 사용됩니다. + *

+ * + * @param likeCount 업데이트할 좋아요 수 (0 이상) + * @throws CoreException likeCount가 null이거나 음수일 경우 + */ + public void updateLikeCount(Long likeCount) { + if (likeCount == null || likeCount < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "좋아요 수는 0 이상이어야 합니다."); + } + this.likeCount = likeCount; + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDetail.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDetail.java index 190b4a00d..24a7e3c4c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDetail.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDetail.java @@ -49,5 +49,43 @@ private ProductDetail(Long id, String name, Integer price, Integer stock, Long b public static ProductDetail of(Long id, String name, Integer price, Integer stock, Long brandId, String brandName, Long likesCount) { return new ProductDetail(id, name, price, stock, brandId, brandName, likesCount); } + + /** + * Product, 브랜드 이름, 좋아요 수로부터 ProductDetail을 생성하는 정적 팩토리 메서드. + *

+ * 상품 상세 조회 시 Product와 브랜드 이름, 좋아요 수를 조합하여 ProductDetail을 생성합니다. + *

+ *

+ * Aggregate 경계 준수: Brand Aggregate 엔티티 대신 필요한 값(brandName)만 전달하여 + * Aggregate 간 직접 참조를 피합니다. + *

+ * + * @param product 상품 엔티티 + * @param brandName 브랜드 이름 + * @param likesCount 좋아요 수 + * @return 생성된 ProductDetail 인스턴스 + * @throws IllegalArgumentException product, brandName, likesCount가 null인 경우 + */ + public static ProductDetail from(Product product, String brandName, Long likesCount) { + if (product == null) { + throw new IllegalArgumentException("상품은 null일 수 없습니다."); + } + if (brandName == null) { + throw new IllegalArgumentException("브랜드 이름은 필수입니다."); + } + if (likesCount == null) { + throw new IllegalArgumentException("좋아요 수는 null일 수 없습니다."); + } + + return ProductDetail.of( + product.getId(), + product.getName(), + product.getPrice(), + product.getStock(), + product.getBrandId(), + brandName, + likesCount + ); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java index ca4837bd9..425ce977c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -29,6 +29,36 @@ public interface ProductRepository { */ Optional findById(Long productId); + /** + * 상품 ID로 상품을 조회합니다. (비관적 락) + *

+ * 동시성 제어가 필요한 경우 사용합니다. (예: 재고 차감) + *

+ *

+ * Lock 전략: + *

    + *
  • PESSIMISTIC_WRITE: SELECT ... FOR UPDATE 사용
  • + *
  • Lock 범위: PK(id) 기반 조회로 해당 행만 락 (최소화)
  • + *
  • 사용 목적: 재고 차감 시 Lost Update 방지
  • + *
+ *

+ * + * @param productId 조회할 상품 ID + * @return 조회된 상품을 담은 Optional + */ + Optional findByIdForUpdate(Long productId); + + /** + * 상품 ID 목록으로 상품 목록을 조회합니다. + *

+ * 배치 조회를 통해 N+1 쿼리 문제를 해결합니다. + *

+ * + * @param productIds 조회할 상품 ID 목록 + * @return 조회된 상품 목록 + */ + List findAllById(List productIds); + /** * 상품 목록을 조회합니다. * @@ -47,5 +77,26 @@ public interface ProductRepository { * @return 상품 총 개수 */ long countAll(Long brandId); + + /** + * 상품의 좋아요 수를 업데이트합니다. + *

+ * 비동기 집계 스케줄러에서 사용됩니다. + *

+ * + * @param productId 상품 ID + * @param likeCount 업데이트할 좋아요 수 + */ + void updateLikeCount(Long productId, Long likeCount); + + /** + * 모든 상품 ID를 조회합니다. + *

+ * Spring Batch Reader에서 사용됩니다. + *

+ * + * @return 모든 상품 ID 목록 + */ + List findAllProductIds(); } 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 e2fffb58f..09d47afe2 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 @@ -25,4 +25,23 @@ public interface UserRepository { * @return 조회된 사용자, 없으면 null */ User findByUserId(String userId); + + /** + * 사용자 ID로 사용자를 조회합니다. (비관적 락) + *

+ * 동시성 제어가 필요한 경우 사용합니다. (예: 포인트 차감) + *

+ *

+ * Lock 전략: + *

    + *
  • PESSIMISTIC_WRITE: SELECT ... FOR UPDATE 사용
  • + *
  • Lock 범위: UNIQUE(userId) 인덱스 기반 조회로 해당 행만 락 (최소화)
  • + *
  • 사용 목적: 포인트 차감 시 Lost Update 방지
  • + *
+ *

+ * + * @param userId 조회할 사용자 ID + * @return 조회된 사용자, 없으면 null + */ + User findByUserIdForUpdate(String userId); } 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 index 7e616b95b..79f186e9a 100644 --- 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 @@ -5,6 +5,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; /** @@ -24,5 +25,10 @@ public Brand save(Brand brand) { public Optional findById(Long brandId) { return brandJpaRepository.findById(brandId); } + + @Override + public List findAllById(List brandIds) { + return brandJpaRepository.findAllById(brandIds); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java new file mode 100644 index 000000000..bd4eb6ee9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java @@ -0,0 +1,26 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.Coupon; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +/** + * Coupon 엔티티를 위한 Spring Data JPA 리포지토리. + *

+ * JpaRepository를 확장하여 기본 CRUD 기능을 제공합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public interface CouponJpaRepository extends JpaRepository { + /** + * 쿠폰 코드로 쿠폰을 조회합니다. + * + * @param code 쿠폰 코드 + * @return 조회된 쿠폰을 담은 Optional + */ + Optional findByCode(String code); +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java new file mode 100644 index 000000000..3f4af5fdb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java @@ -0,0 +1,49 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.Coupon; +import com.loopers.domain.coupon.CouponRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +/** + * CouponRepository의 JPA 구현체. + *

+ * Spring Data JPA를 활용하여 Coupon 엔티티의 + * 영속성 작업을 처리합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@RequiredArgsConstructor +@Component +public class CouponRepositoryImpl implements CouponRepository { + private final CouponJpaRepository couponJpaRepository; + + /** + * {@inheritDoc} + */ + @Override + public Coupon save(Coupon coupon) { + return couponJpaRepository.save(coupon); + } + + /** + * {@inheritDoc} + */ + @Override + public Optional findByCode(String code) { + return couponJpaRepository.findByCode(code); + } + + /** + * {@inheritDoc} + */ + @Override + public Optional findById(Long couponId) { + return couponJpaRepository.findById(couponId); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/UserCouponJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/UserCouponJpaRepository.java new file mode 100644 index 000000000..710f74a74 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/UserCouponJpaRepository.java @@ -0,0 +1,61 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.UserCoupon; +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; + +/** + * UserCoupon 엔티티를 위한 Spring Data JPA 리포지토리. + *

+ * JpaRepository를 확장하여 기본 CRUD 기능을 제공합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public interface UserCouponJpaRepository extends JpaRepository { + /** + * 사용자 ID와 쿠폰 코드로 사용자 쿠폰을 조회합니다. + * + * @param userId 사용자 ID + * @param couponCode 쿠폰 코드 + * @return 조회된 사용자 쿠폰을 담은 Optional + */ + @Query("SELECT uc FROM UserCoupon uc JOIN uc.coupon c WHERE uc.userId = :userId AND c.code = :couponCode") + Optional findByUserIdAndCouponCode(@Param("userId") Long userId, @Param("couponCode") String couponCode); + + /** + * 사용자 ID와 쿠폰 코드로 사용자 쿠폰을 조회합니다. + *

+ * Optimistic Lock을 사용하여 동시성 제어를 보장합니다. + * UserCoupon 엔티티의 @Version 필드를 통해 자동으로 낙관적 락이 적용됩니다. + *

+ *

+ * Lock 전략: + *

    + *
  • OPTIMISTIC_LOCK 선택 근거: 쿠폰 사용 시 Lost Update 방지, Hot Spot 대응
  • + *
  • @Version 필드: 엔티티에 version 필드가 있어 자동으로 낙관적 락 적용
  • + *
  • 동시 사용 시: 한 명만 성공하고 나머지는 OptimisticLockException 발생
  • + *
+ *

+ *

+ * 동작 원리: + *

    + *
  1. 일반 조회로 UserCoupon 엔티티 로드 (version 포함)
  2. + *
  3. 쿠폰 사용 처리 (isUsed = true)
  4. + *
  5. 저장 시 version 체크 → 다른 트랜잭션이 먼저 수정했다면 OptimisticLockException 발생
  6. + *
  7. 예외 발생 시 쿠폰 사용 실패 처리
  8. + *
+ *

+ * + * @param userId 사용자 ID + * @param couponCode 쿠폰 코드 + * @return 조회된 사용자 쿠폰을 담은 Optional + */ + @Query("SELECT uc FROM UserCoupon uc JOIN uc.coupon c WHERE uc.userId = :userId AND c.code = :couponCode") + Optional findByUserIdAndCouponCodeForUpdate(@Param("userId") Long userId, @Param("couponCode") String couponCode); +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/UserCouponRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/UserCouponRepositoryImpl.java new file mode 100644 index 000000000..8daaf5567 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/UserCouponRepositoryImpl.java @@ -0,0 +1,49 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.UserCoupon; +import com.loopers.domain.coupon.UserCouponRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +/** + * UserCouponRepository의 JPA 구현체. + *

+ * Spring Data JPA를 활용하여 UserCoupon 엔티티의 + * 영속성 작업을 처리합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@RequiredArgsConstructor +@Component +public class UserCouponRepositoryImpl implements UserCouponRepository { + private final UserCouponJpaRepository userCouponJpaRepository; + + /** + * {@inheritDoc} + */ + @Override + public UserCoupon save(UserCoupon userCoupon) { + return userCouponJpaRepository.save(userCoupon); + } + + /** + * {@inheritDoc} + */ + @Override + public Optional findByUserIdAndCouponCode(Long userId, String couponCode) { + return userCouponJpaRepository.findByUserIdAndCouponCode(userId, couponCode); + } + + /** + * {@inheritDoc} + */ + @Override + public Optional findByUserIdAndCouponCodeForUpdate(Long userId, String couponCode) { + return userCouponJpaRepository.findByUserIdAndCouponCodeForUpdate(userId, couponCode); + } +} + 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 index 3c1c3399a..61d335de8 100644 --- 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 @@ -40,6 +40,17 @@ public interface LikeJpaRepository extends JpaRepository { @Query("SELECT l.productId, COUNT(l) FROM Like l WHERE l.productId IN :productIds GROUP BY l.productId") List countByProductIds(@Param("productIds") List productIds); + /** + * 모든 상품의 좋아요 수를 집계합니다. + *

+ * 비동기 집계 스케줄러에서 사용됩니다. + *

+ * + * @return 상품 ID와 좋아요 수의 쌍 목록 + */ + @Query("SELECT l.productId, COUNT(l) FROM Like l GROUP BY l.productId") + List countAllByProductIds(); + /** * 상품별 좋아요 수를 Map으로 변환합니다. * 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 index bd169e7b2..113d524f8 100644 --- 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 @@ -41,5 +41,14 @@ public List findAllByUserId(Long userId) { public Map countByProductIds(List productIds) { return likeJpaRepository.countByProductIdsAsMap(productIds); } + + @Override + public Map countAllByProductIds() { + return likeJpaRepository.countAllByProductIds().stream() + .collect(java.util.stream.Collectors.toMap( + row -> (Long) row[0], + row -> ((Number) row[1]).longValue() + )); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java index fe294dad8..d9913a180 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -1,9 +1,16 @@ package com.loopers.infrastructure.product; import com.loopers.domain.product.Product; +import jakarta.persistence.LockModeType; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; /** * Product 엔티티를 위한 Spring Data JPA 리포지토리. @@ -39,5 +46,46 @@ public interface ProductJpaRepository extends JpaRepository { * @return 상품 개수 */ long countByBrandId(Long brandId); + + /** + * 상품 ID로 상품을 조회합니다. (비관적 락) + *

+ * SELECT ... FOR UPDATE를 사용하여 동시성 제어를 보장합니다. + *

+ *

+ * Lock 전략: + *

    + *
  • PESSIMISTIC_WRITE 선택 근거: 재고 차감 시 Lost Update 방지
  • + *
  • Lock 범위 최소화: PK(id) 기반 조회로 해당 행만 락
  • + *
  • 인덱스 활용: PK는 자동으로 인덱스가 생성되어 Lock 범위 최소화
  • + *
+ *

+ *

+ * 동작 원리: + *

    + *
  1. SELECT ... FOR UPDATE 실행 → 해당 행에 배타적 락 설정
  2. + *
  3. 다른 트랜잭션의 쓰기/FOR UPDATE는 차단 (일반 읽기는 가능)
  4. + *
  5. 재고 차감 후 트랜잭션 커밋 → 락 해제
  6. + *
  7. 대기 중이던 트랜잭션이 최신 값을 읽어 처리
  8. + *
+ *

+ * + * @param productId 조회할 상품 ID + * @return 조회된 상품을 담은 Optional + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT p FROM Product p WHERE p.id = :productId") + Optional findByIdForUpdate(@Param("productId") Long productId); + + /** + * 모든 상품 ID를 조회합니다. + *

+ * 비동기 집계 스케줄러에서 사용됩니다. + *

+ * + * @return 모든 상품 ID 목록 + */ + @Query("SELECT p.id FROM Product p") + List findAllProductIds(); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java index f578a7d3a..29cc890f2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -43,6 +43,22 @@ public Optional findById(Long productId) { return productJpaRepository.findById(productId); } + /** + * {@inheritDoc} + */ + @Override + public Optional findByIdForUpdate(Long productId) { + return productJpaRepository.findByIdForUpdate(productId); + } + + /** + * {@inheritDoc} + */ + @Override + public List findAllById(List productIds) { + return productJpaRepository.findAllById(productIds); + } + /** * {@inheritDoc} */ @@ -65,10 +81,30 @@ public long countAll(Long brandId) { : productJpaRepository.count(); } + /** + * {@inheritDoc} + */ + @Override + public void updateLikeCount(Long productId, Long likeCount) { + Product product = productJpaRepository.findById(productId) + .orElseThrow(() -> new IllegalArgumentException( + String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", productId))); + product.updateLikeCount(likeCount); + productJpaRepository.save(product); + } + + /** + * {@inheritDoc} + */ + @Override + public List findAllProductIds() { + return productJpaRepository.findAllProductIds(); + } + private Pageable createPageable(String sort, int page, int size) { Sort sortObj = switch (sort != null ? sort : "latest") { case "price_asc" -> Sort.by(Sort.Direction.ASC, "price"); - case "likes_desc" -> Sort.by(Sort.Direction.DESC, "id"); // 좋아요 수는 별도 처리 필요 + case "likes_desc" -> Sort.by(Sort.Direction.DESC, "likeCount"); // ✅ Product.likeCount 필드로 정렬 default -> Sort.by(Sort.Direction.DESC, "createdAt"); }; return PageRequest.of(page, size, sortObj); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java index 905189891..2de935469 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java @@ -1,7 +1,12 @@ package com.loopers.infrastructure.user; import com.loopers.domain.user.User; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + import java.util.Optional; /** @@ -22,4 +27,34 @@ public interface UserJpaRepository extends JpaRepository { * @return 조회된 사용자를 담은 Optional */ Optional findByUserId(String userId); + + /** + * 사용자 ID로 사용자를 조회합니다. (비관적 락) + *

+ * SELECT ... FOR UPDATE를 사용하여 동시성 제어를 보장합니다. + *

+ *

+ * Lock 전략: + *

    + *
  • PESSIMISTIC_WRITE 선택 근거: 포인트 차감 시 Lost Update 방지
  • + *
  • Lock 범위 최소화: UNIQUE(userId) 인덱스 기반 조회로 해당 행만 락
  • + *
  • 인덱스 활용: UNIQUE 제약조건으로 인덱스가 자동 생성되어 Lock 범위 최소화
  • + *
+ *

+ *

+ * 동작 원리: + *

    + *
  1. SELECT ... FOR UPDATE 실행 → 해당 행에 배타적 락 설정
  2. + *
  3. 다른 트랜잭션의 쓰기/FOR UPDATE는 차단 (일반 읽기는 가능)
  4. + *
  5. 포인트 차감 후 트랜잭션 커밋 → 락 해제
  6. + *
  7. 대기 중이던 트랜잭션이 최신 값을 읽어 처리
  8. + *
+ *

+ * + * @param userId 조회할 사용자 ID + * @return 조회된 사용자를 담은 Optional + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT u FROM User u WHERE u.userId = :userId") + Optional findByUserIdForUpdate(@Param("userId") 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 index 25d6ead87..62d2512cf 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 @@ -35,4 +35,12 @@ public User save(User user) { public User findByUserId(String userId) { return userJpaRepository.findByUserId(userId).orElse(null); } + + /** + * {@inheritDoc} + */ + @Override + public User findByUserIdForUpdate(String userId) { + return userJpaRepository.findByUserIdForUpdate(userId).orElse(null); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/BrandV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/BrandV1Controller.java new file mode 100644 index 000000000..a56cc1c63 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/BrandV1Controller.java @@ -0,0 +1,39 @@ +package com.loopers.interfaces.api.catalog; + +import com.loopers.application.catalog.CatalogBrandFacade; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 브랜드 조회 API v1 컨트롤러. + *

+ * 브랜드 정보 조회 유즈케이스를 처리합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/brands") +public class BrandV1Controller { + + private final CatalogBrandFacade catalogBrandFacade; + + /** + * 브랜드 정보를 조회합니다. + * + * @param brandId 브랜드 ID + * @return 브랜드 정보를 담은 API 응답 + */ + @GetMapping("/{brandId}") + public ApiResponse getBrand(@PathVariable Long brandId) { + CatalogBrandFacade.BrandInfo brandInfo = catalogBrandFacade.getBrand(brandId); + return ApiResponse.success(BrandV1Dto.BrandResponse.from(brandInfo)); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/BrandV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/BrandV1Dto.java new file mode 100644 index 000000000..2bc497615 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/BrandV1Dto.java @@ -0,0 +1,30 @@ +package com.loopers.interfaces.api.catalog; + +import com.loopers.application.catalog.CatalogBrandFacade; + +/** + * 브랜드 조회 API v1의 데이터 전송 객체(DTO) 컨테이너. + * + * @author Loopers + * @version 1.0 + */ +public class BrandV1Dto { + /** + * 브랜드 정보 응답 데이터. + * + * @param brandId 브랜드 ID + * @param name 브랜드 이름 + */ + public record BrandResponse(Long brandId, String name) { + /** + * BrandInfo로부터 BrandResponse를 생성합니다. + * + * @param brandInfo 브랜드 정보 + * @return 생성된 응답 객체 + */ + public static BrandResponse from(CatalogBrandFacade.BrandInfo brandInfo) { + return new BrandResponse(brandInfo.id(), brandInfo.name()); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/ProductV1Controller.java new file mode 100644 index 000000000..4dc38d439 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/ProductV1Controller.java @@ -0,0 +1,62 @@ +package com.loopers.interfaces.api.catalog; + +import com.loopers.application.catalog.CatalogProductFacade; +import com.loopers.application.catalog.ProductInfo; +import com.loopers.application.catalog.ProductInfoList; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 상품 조회 API v1 컨트롤러. + *

+ * 상품 목록 조회 및 상품 정보 조회 유즈케이스를 처리합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/products") +public class ProductV1Controller { + + private final CatalogProductFacade catalogProductFacade; + + /** + * 상품 목록을 조회합니다. + * + * @param brandId 브랜드 ID (선택) + * @param sort 정렬 기준 (latest, price_asc, likes_desc) + * @param page 페이지 번호 (기본값 0) + * @param size 페이지당 상품 수 (기본값 20) + * @return 상품 목록을 담은 API 응답 + */ + @GetMapping + public ApiResponse getProducts( + @RequestParam(required = false) Long brandId, + @RequestParam(required = false, defaultValue = "latest") String sort, + @RequestParam(required = false, defaultValue = "0") int page, + @RequestParam(required = false, defaultValue = "20") int size + ) { + ProductInfoList result = catalogProductFacade.getProducts(brandId, sort, page, size); + return ApiResponse.success(ProductV1Dto.ProductsResponse.from(result)); + } + + /** + * 상품 정보를 조회합니다. + * + * @param productId 상품 ID + * @return 상품 정보를 담은 API 응답 + */ + @GetMapping("/{productId}") + public ApiResponse getProduct(@PathVariable Long productId) { + ProductInfo productInfo = catalogProductFacade.getProduct(productId); + return ApiResponse.success(ProductV1Dto.ProductResponse.from(productInfo)); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/ProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/ProductV1Dto.java new file mode 100644 index 000000000..3661d9c9e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/ProductV1Dto.java @@ -0,0 +1,95 @@ +package com.loopers.interfaces.api.catalog; + +import com.loopers.application.catalog.ProductInfo; +import com.loopers.application.catalog.ProductInfoList; + +import java.util.List; + +/** + * 상품 조회 API v1의 데이터 전송 객체(DTO) 컨테이너. + * + * @author Loopers + * @version 1.0 + */ +public class ProductV1Dto { + /** + * 상품 정보 응답 데이터. + * + * @param productId 상품 ID + * @param name 상품 이름 + * @param price 상품 가격 + * @param stock 상품 재고 + * @param brandId 브랜드 ID + * @param likesCount 좋아요 수 + */ + public record ProductResponse( + Long productId, + String name, + Integer price, + Integer stock, + Long brandId, + Long likesCount + ) { + /** + * ProductInfo로부터 ProductResponse를 생성합니다. + * + * @param productInfo 상품 상세 정보 + * @return 생성된 응답 객체 + */ + public static ProductResponse from(ProductInfo productInfo) { + var detail = productInfo.productDetail(); + return new ProductResponse( + detail.getId(), + detail.getName(), + detail.getPrice(), + detail.getStock(), + detail.getBrandId(), + detail.getLikesCount() + ); + } + } + + /** + * 상품 목록 응답 데이터. + * + * @param products 상품 목록 + * @param totalCount 전체 상품 수 + * @param page 현재 페이지 번호 + * @param size 페이지당 상품 수 + * @param totalPages 전체 페이지 수 + * @param hasNext 다음 페이지 존재 여부 + * @param hasPrevious 이전 페이지 존재 여부 + */ + public record ProductsResponse( + List products, + long totalCount, + int page, + int size, + int totalPages, + boolean hasNext, + boolean hasPrevious + ) { + /** + * ProductInfoList로부터 ProductsResponse를 생성합니다. + * + * @param result 상품 목록 조회 결과 + * @return 생성된 응답 객체 + */ + public static ProductsResponse from(ProductInfoList result) { + List productResponses = result.products().stream() + .map(ProductResponse::from) + .toList(); + + return new ProductsResponse( + productResponses, + result.totalCount(), + result.page(), + result.size(), + result.getTotalPages(), + result.hasNext(), + result.hasPrevious() + ); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java new file mode 100644 index 000000000..2935b424d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java @@ -0,0 +1,76 @@ +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.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 좋아요 API v1 컨트롤러. + *

+ * 상품 좋아요 추가, 삭제, 목록 조회 유즈케이스를 처리합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/like/products") +public class LikeV1Controller { + + private final LikeFacade likeFacade; + + /** + * 상품에 좋아요를 추가합니다. + * + * @param userId X-USER-ID 헤더로 전달된 사용자 ID + * @param productId 상품 ID + * @return 성공 응답 + */ + @PostMapping("/{productId}") + public ApiResponse addLike( + @RequestHeader("X-USER-ID") String userId, + @PathVariable Long productId + ) { + likeFacade.addLike(userId, productId); + return ApiResponse.success(null); + } + + /** + * 상품의 좋아요를 취소합니다. + * + * @param userId X-USER-ID 헤더로 전달된 사용자 ID + * @param productId 상품 ID + * @return 성공 응답 + */ + @DeleteMapping("/{productId}") + public ApiResponse removeLike( + @RequestHeader("X-USER-ID") String userId, + @PathVariable Long productId + ) { + likeFacade.removeLike(userId, productId); + return ApiResponse.success(null); + } + + /** + * 사용자가 좋아요한 상품 목록을 조회합니다. + * + * @param userId X-USER-ID 헤더로 전달된 사용자 ID + * @return 좋아요한 상품 목록을 담은 API 응답 + */ + @GetMapping + public ApiResponse getLikedProducts( + @RequestHeader("X-USER-ID") String userId + ) { + var likedProducts = likeFacade.getLikedProducts(userId); + return ApiResponse.success(LikeV1Dto.LikedProductsResponse.from(likedProducts)); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java new file mode 100644 index 000000000..1fc6f20f0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java @@ -0,0 +1,73 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.like.LikeFacade; + +import java.util.List; + +/** + * 좋아요 API v1의 데이터 전송 객체(DTO) 컨테이너. + * + * @author Loopers + * @version 1.0 + */ +public class LikeV1Dto { + /** + * 좋아요한 상품 목록 응답 데이터. + * + * @param products 좋아요한 상품 목록 + */ + public record LikedProductsResponse( + List products + ) { + /** + * LikeFacade.LikedProduct 목록으로부터 LikedProductsResponse를 생성합니다. + * + * @param likedProducts 좋아요한 상품 목록 + * @return 생성된 응답 객체 + */ + public static LikedProductsResponse from(List likedProducts) { + return new LikedProductsResponse( + likedProducts.stream() + .map(LikedProductResponse::from) + .toList() + ); + } + } + + /** + * 좋아요한 상품 정보 응답 데이터. + * + * @param productId 상품 ID + * @param name 상품 이름 + * @param price 상품 가격 + * @param stock 상품 재고 + * @param brandId 브랜드 ID + * @param likesCount 좋아요 수 + */ + public record LikedProductResponse( + Long productId, + String name, + Integer price, + Integer stock, + Long brandId, + Long likesCount + ) { + /** + * LikeFacade.LikedProduct로부터 LikedProductResponse를 생성합니다. + * + * @param likedProduct 좋아요한 상품 정보 + * @return 생성된 응답 객체 + */ + public static LikedProductResponse from(LikeFacade.LikedProduct likedProduct) { + return new LikedProductResponse( + likedProduct.productId(), + likedProduct.name(), + likedProduct.price(), + likedProduct.stock(), + likedProduct.brandId(), + likedProduct.likesCount() + ); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Controller.java new file mode 100644 index 000000000..744ca1b15 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Controller.java @@ -0,0 +1,75 @@ +package com.loopers.interfaces.api.purchasing; + +import com.loopers.application.purchasing.OrderInfo; +import com.loopers.application.purchasing.PurchasingFacade; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 주문 API v1 컨트롤러. + */ +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/orders") +public class PurchasingV1Controller { + + private final PurchasingFacade purchasingFacade; + + /** + * 주문을 생성한다. + * + * @param userId X-USER-ID 헤더 + * @param request 주문 생성 요청 + * @return 생성된 주문 정보 + */ + @PostMapping + public ApiResponse createOrder( + @RequestHeader("X-USER-ID") String userId, + @Valid @RequestBody PurchasingV1Dto.CreateRequest request + ) { + OrderInfo orderInfo = purchasingFacade.createOrder(userId, request.toCommands()); + return ApiResponse.success(PurchasingV1Dto.OrderResponse.from(orderInfo)); + } + + /** + * 현재 사용자의 주문 목록을 조회한다. + * + * @param userId X-USER-ID 헤더 + * @return 주문 목록 + */ + @GetMapping + public ApiResponse getOrders( + @RequestHeader("X-USER-ID") String userId + ) { + List orderInfos = purchasingFacade.getOrders(userId); + return ApiResponse.success(PurchasingV1Dto.OrdersResponse.from(orderInfos)); + } + + /** + * 현재 사용자의 단일 주문을 조회한다. + * + * @param userId X-USER-ID 헤더 + * @param orderId 주문 ID + * @return 주문 상세 정보 + */ + @GetMapping("/{orderId}") + public ApiResponse getOrder( + @RequestHeader("X-USER-ID") String userId, + @PathVariable Long orderId + ) { + OrderInfo orderInfo = purchasingFacade.getOrder(userId, orderId); + return ApiResponse.success(PurchasingV1Dto.OrderResponse.from(orderInfo)); + } +} + + diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Dto.java new file mode 100644 index 000000000..ce278fc49 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Dto.java @@ -0,0 +1,122 @@ +package com.loopers.interfaces.api.purchasing; + +import com.loopers.application.purchasing.OrderInfo; +import com.loopers.application.purchasing.OrderItemCommand; +import com.loopers.application.purchasing.OrderItemInfo; +import com.loopers.domain.order.OrderStatus; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; + +import java.util.List; + +public final class PurchasingV1Dto { + + private PurchasingV1Dto() { + } + + /** + * 주문 생성 요청 DTO. + */ + public record CreateRequest( + @NotEmpty(message = "주문 상품은 1개 이상이어야 합니다.") + List<@Valid ItemRequest> items + ) { + public List toCommands() { + return items.stream() + .map(item -> OrderItemCommand.of(item.productId(), item.quantity())) + .toList(); + } + } + + /** + * 주문 생성 요청 아이템 DTO. + */ + public record ItemRequest( + @NotNull(message = "상품 ID는 필수입니다.") + Long productId, + + @NotNull(message = "상품 수량은 필수입니다.") + @Min(value = 1, message = "상품 수량은 1개 이상이어야 합니다.") + Integer quantity + ) { + } + + /** + * 주문 응답 DTO. + */ + public record OrderResponse( + Long orderId, + Long userId, + Integer totalAmount, + OrderStatus status, + List items + ) { + /** + * OrderInfo로부터 OrderResponse를 생성합니다. + * + * @param orderInfo 주문 정보 + * @return 생성된 응답 객체 + */ + public static OrderResponse from(OrderInfo orderInfo) { + List itemResponses = orderInfo.items().stream() + .map(OrderItemResponse::from) + .toList(); + + return new OrderResponse( + orderInfo.orderId(), + orderInfo.userId(), + orderInfo.totalAmount(), + orderInfo.status(), + itemResponses + ); + } + } + + /** + * 주문 아이템 응답 DTO. + */ + public record OrderItemResponse( + Long productId, + String name, + Integer price, + Integer quantity + ) { + /** + * OrderItemInfo로부터 OrderItemResponse를 생성합니다. + * + * @param itemInfo 주문 아이템 정보 + * @return 생성된 응답 객체 + */ + public static OrderItemResponse from(OrderItemInfo itemInfo) { + return new OrderItemResponse( + itemInfo.productId(), + itemInfo.name(), + itemInfo.price(), + itemInfo.quantity() + ); + } + } + + /** + * 주문 목록 응답 DTO. + */ + public record OrdersResponse(List orders) { + /** + * OrderInfo 목록으로부터 OrdersResponse를 생성합니다. + * + * @param orderInfos 주문 정보 목록 + * @return 생성된 응답 객체 + */ + public static OrdersResponse from(List orderInfos) { + return new OrdersResponse( + orderInfos.stream() + .map(OrderResponse::from) + .toList() + ); + } + } +} + + diff --git a/apps/commerce-api/src/main/resources/application.yml b/apps/commerce-api/src/main/resources/application.yml index 484c070d0..0f9239776 100644 --- a/apps/commerce-api/src/main/resources/application.yml +++ b/apps/commerce-api/src/main/resources/application.yml @@ -23,6 +23,11 @@ spring: - redis.yml - logging.yml - monitoring.yml + batch: + jdbc: + initialize-schema: always # Spring Batch 메타데이터 테이블 자동 생성 (임시: production 배포 전 EDA로 교체 예정) + job: + enabled: false # 스케줄러에서 수동 실행하므로 자동 실행 비활성화 springdoc: use-fqn: true diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeConcurrencyTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeConcurrencyTest.java new file mode 100644 index 000000000..1e7b42394 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeConcurrencyTest.java @@ -0,0 +1,336 @@ +package com.loopers.application.like; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.like.LikeRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.user.Gender; +import com.loopers.domain.user.Point; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * LikeFacade 동시성 테스트 + *

+ * 여러 스레드에서 동시에 좋아요 요청을 보내도 데이터 일관성이 유지되는지 검증합니다. + *

+ */ +@SpringBootTest +@Import(MySqlTestContainersConfig.class) +@DisplayName("LikeFacade 동시성 테스트") +class LikeFacadeConcurrencyTest { + + @Autowired + private LikeFacade likeFacade; + + @Autowired + private UserRepository userRepository; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private BrandRepository brandRepository; + + @Autowired + private LikeRepository likeRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private User createAndSaveUser(String userId, String email, long point) { + User user = User.of(userId, email, "1990-01-01", Gender.MALE, Point.of(point)); + return userRepository.save(user); + } + + private Brand createAndSaveBrand(String brandName) { + Brand brand = Brand.of(brandName); + return brandRepository.save(brand); + } + + private Product createAndSaveProduct(String productName, int price, int stock, Long brandId) { + Product product = Product.of(productName, price, stock, brandId); + return productRepository.save(product); + } + + /** + * 상품들의 좋아요 수를 동기화합니다. + *

+ * 테스트에서 비동기 스케줄러를 기다리지 않고 직접 like count를 업데이트하기 위해 사용합니다. + *

+ * + * @param productIds 동기화할 상품 ID 목록 + */ + private void syncLikeCounts(List productIds) { + Map likeCountMap = likeRepository.countByProductIds(productIds); + for (Long productId : productIds) { + Long likeCount = likeCountMap.getOrDefault(productId, 0L); + productRepository.updateLikeCount(productId, likeCount); + } + } + + @Test + @DisplayName("동일한 상품에 대해 여러명이 좋아요를 요청해도, 상품의 좋아요 개수가 정상 반영되어야 한다") + void concurrencyTest_likeShouldBeProperlyCounted() throws InterruptedException { + // arrange + Brand brand = createAndSaveBrand("테스트 브랜드"); + Product product = createAndSaveProduct("테스트 상품", 10_000, 100, brand.getId()); + Long productId = product.getId(); + + int userCount = 10; + List users = new ArrayList<>(); + for (int i = 0; i < userCount; i++) { + users.add(createAndSaveUser("user" + i, "user" + i + "@example.com", 0L)); + } + + ExecutorService executorService = Executors.newFixedThreadPool(userCount); + CountDownLatch latch = new CountDownLatch(userCount); + AtomicInteger successCount = new AtomicInteger(0); + List exceptions = new ArrayList<>(); + + // act + for (User user : users) { + executorService.submit(() -> { + try { + likeFacade.addLike(user.getUserId(), productId); + successCount.incrementAndGet(); + } catch (Exception e) { + synchronized (exceptions) { + exceptions.add(e); + } + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executorService.shutdown(); + + // assert + long actualLikesCount = likeRepository.countByProductIds(List.of(productId)) + .getOrDefault(productId, 0L); + + assertThat(actualLikesCount).isEqualTo(userCount); + assertThat(successCount.get()).isEqualTo(userCount); + assertThat(exceptions).isEmpty(); + } + + @Test + @DisplayName("동일한 사용자가 동시에 여러번 좋아요를 요청해도, 정상적으로 카운트되어야 한다") + void concurrencyTest_sameUserMultipleRequests_shouldBeCountedCorrectly() throws InterruptedException { + // arrange + Brand brand = createAndSaveBrand("테스트 브랜드"); + Product product = createAndSaveProduct("테스트 상품", 10_000, 100, brand.getId()); + Long productId = product.getId(); + User user = createAndSaveUser("testuser", "test@example.com", 0L); + String userId = user.getUserId(); + + int concurrentRequestCount = 10; + ExecutorService executorService = Executors.newFixedThreadPool(concurrentRequestCount); + CountDownLatch latch = new CountDownLatch(concurrentRequestCount); + AtomicInteger successCount = new AtomicInteger(0); + List exceptions = new ArrayList<>(); + + // act + for (int i = 0; i < concurrentRequestCount; i++) { + executorService.submit(() -> { + try { + likeFacade.addLike(userId, productId); + successCount.incrementAndGet(); + } catch (Exception e) { + synchronized (exceptions) { + exceptions.add(e); + } + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executorService.shutdown(); + + // assert + // UNIQUE 제약조건으로 인해 정확히 1개의 좋아요만 저장되어야 함 + long actualLikesCount = likeRepository.countByProductIds(List.of(productId)) + .getOrDefault(productId, 0L); + + assertThat(actualLikesCount).isEqualTo(1L); + // 애플리케이션 레벨 체크 또는 데이터베이스 UNIQUE 제약조건으로 인해 + // 모든 요청이 성공하거나 일부는 예외가 발생할 수 있지만, + // 최종적으로는 1개의 좋아요만 저장되어야 함 + assertThat(successCount.get() + exceptions.size()).isEqualTo(concurrentRequestCount); + } + + @Test + @DisplayName("@Transactional(readOnly = true)와 UNIQUE 제약조건은 서로 다른 목적을 가진다") + void concurrencyTest_transactionReadOnlyAndUniqueConstraintServeDifferentPurposes() throws InterruptedException { + // 이 테스트는 @Transactional(readOnly = true)와 UNIQUE 제약조건의 차이를 보여줍니다. + // + // UNIQUE 제약조건: + // - 목적: 데이터 무결성 보장 (중복 데이터 방지) + // - 예시: LikeFacade.addLike()에서 동일 사용자가 동일 상품에 중복 좋아요 방지 + // - 작동: 데이터베이스 레벨에서 물리적으로 중복 삽입 방지 + // + // @Transactional(readOnly = true): + // - 목적: 여러 쿼리 간의 논리적 일관성 보장 + // - 예시: LikeFacade.getLikedProducts()에서 좋아요 목록과 집계 결과의 일관성 + // - 작동: 모든 쿼리가 동일한 트랜잭션 내에서 실행되어 일관된 스냅샷을 봄 + // + // REPEATABLE READ 격리 수준에서: + // - 트랜잭션이 없으면: 각 쿼리가 독립적으로 실행되며, 각 쿼리는 자체 스냅샷을 봄 + // - 트랜잭션이 있으면: 모든 쿼리가 동일한 트랜잭션 시작 시점의 스냅샷을 봄 + // + // 실제 문제 시나리오: + // 1. 좋아요 목록 조회 (쿼리 1) - 시점 T1의 스냅샷 + // 2. 다른 트랜잭션이 좋아요 추가 (커밋) + // 3. 좋아요 수 집계 (쿼리 2) - 시점 T2의 스냅샷 (T1과 다를 수 있음) + // + // 트랜잭션이 없으면: + // - 쿼리 1과 쿼리 2가 서로 다른 시점의 스냅샷을 볼 수 있음 + // - 좋아요 목록에는 상품1이 1개로 보이지만, 집계 결과는 2개일 수 있음 + // + // 트랜잭션이 있으면: + // - 모든 쿼리가 동일한 시점의 스냅샷을 봄 + // - 좋아요 목록과 집계 결과가 일관됨 + // + // 왜 테스트가 통과하는가? + // - REPEATABLE READ에서는 각 쿼리가 자체적으로 일관된 스냅샷을 봄 + // - 쿼리 실행 시간이 매우 짧아서 다른 트랜잭션이 정확히 중간에 개입할 확률이 낮음 + // - 하지만 여러 쿼리 간의 논리적 일관성을 보장하려면 트랜잭션이 필요함 + + // arrange + Brand brand = createAndSaveBrand("테스트 브랜드"); + Product product1 = createAndSaveProduct("상품1", 10_000, 100, brand.getId()); + Product product2 = createAndSaveProduct("상품2", 20_000, 100, brand.getId()); + + User user1 = createAndSaveUser("user1", "user1@example.com", 0L); + User user2 = createAndSaveUser("user2", "user2@example.com", 0L); + String userId1 = user1.getUserId(); + String userId2 = user2.getUserId(); + + // user1이 상품1, 상품2에 좋아요를 이미 누른 상태 + likeFacade.addLike(userId1, product1.getId()); + likeFacade.addLike(userId1, product2.getId()); + + ExecutorService executorService = Executors.newFixedThreadPool(20); + CountDownLatch latch = new CountDownLatch(20); + List> allResults = new ArrayList<>(); + + // act + // 여러 스레드에서 동시에 조회를 수행 + for (int i = 0; i < 10; i++) { + executorService.submit(() -> { + try { + List result = likeFacade.getLikedProducts(userId1); + synchronized (allResults) { + allResults.add(result); + } + } catch (Exception e) { + e.printStackTrace(); + } finally { + latch.countDown(); + } + }); + } + + // 다른 스레드들이 조회 중간에 좋아요를 추가/삭제 + for (int i = 0; i < 10; i++) { + final int index = i; + executorService.submit(() -> { + try { + // 조회가 시작된 후 실행되도록 약간의 지연 + Thread.sleep(1 + index); + if (index % 2 == 0) { + // user2가 상품1에 좋아요 추가 + try { + likeFacade.addLike(userId2, product1.getId()); + } catch (Exception e) { + // 이미 좋아요가 있으면 무시 + } + } else { + // user2가 상품2에 좋아요 추가 + try { + likeFacade.addLike(userId2, product2.getId()); + } catch (Exception e) { + // 이미 좋아요가 있으면 무시 + } + } + } catch (Exception e) { + e.printStackTrace(); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + executorService.shutdown(); + + // 좋아요 수 동기화 (비동기 스케줄러를 기다리지 않고 직접 업데이트) + syncLikeCounts(List.of(product1.getId(), product2.getId())); + + // assert + // @Transactional(readOnly = true)가 있으면: + // - 모든 조회가 동일한 트랜잭션 내에서 실행되어 일관된 스냅샷을 봄 + // - 각 조회 결과 내에서 좋아요 목록과 집계 결과가 일관됨 + + // @Transactional(readOnly = true)가 없으면: + // - 각 쿼리가 독립적으로 실행되어 서로 다른 시점의 데이터를 볼 수 있음 + // - 하지만 REPEATABLE READ에서는 각 쿼리가 자체 스냅샷을 보므로 + // 실제로는 문제가 드물 수 있음 + + // 검증: 모든 조회 결과가 정상적으로 반환되었는지 확인 + assertThat(allResults).hasSize(10); + + // 각 조회 결과가 올바른 형식인지 확인 + // 참고: allResults는 동기화 이전에 조회된 결과이므로 likesCount가 0일 수 있습니다. + // 이 테스트는 @Transactional(readOnly = true)의 일관성 보장을 검증하는 것이 목적이므로, + // 동시성 테스트 중 조회된 결과의 상품 ID 일관성만 확인합니다. + for (List result : allResults) { + // user1의 좋아요 목록에는 상품1, 상품2가 포함되어야 함 + List resultProductIds = result.stream() + .map(LikeFacade.LikedProduct::productId) + .sorted() + .toList(); + assertThat(resultProductIds).contains(product1.getId(), product2.getId()); + } + + // 최종 상태 확인 (동기화 후) + List finalResult = likeFacade.getLikedProducts(userId1); + List finalProductIds = finalResult.stream() + .map(LikeFacade.LikedProduct::productId) + .sorted() + .toList(); + assertThat(finalProductIds).containsExactlyInAnyOrder(product1.getId(), product2.getId()); + + // 동기화 후에는 정확한 좋아요 수가 반영되어야 함 + for (LikeFacade.LikedProduct likedProduct : finalResult) { + assertThat(likedProduct.likesCount()).isGreaterThan(0); + } + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java index 86f7d91a0..d64ba6cbb 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java @@ -12,8 +12,10 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +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.Mockito.mock; @@ -135,6 +137,93 @@ void addLike_productNotFound() { .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); } + @Test + @DisplayName("좋아요한 상품 목록을 조회할 수 있다") + void getLikedProducts_success() { + // arrange + setupMockUser(DEFAULT_USER_ID, DEFAULT_USER_INTERNAL_ID); + + Long productId1 = 1L; + Long productId2 = 2L; + + Like like1 = Like.of(DEFAULT_USER_INTERNAL_ID, productId1); + Like like2 = Like.of(DEFAULT_USER_INTERNAL_ID, productId2); + List likes = List.of(like1, like2); + + // ✅ Product.likeCount 필드 사용 (비동기 집계된 값) + Product product1 = createMockProduct(productId1, "상품1", 10000, 10, 1L, 5L); + Product product2 = createMockProduct(productId2, "상품2", 20000, 20, 1L, 3L); + + when(likeRepository.findAllByUserId(DEFAULT_USER_INTERNAL_ID)).thenReturn(likes); + // ✅ findAllById를 사용하므로 findAllById를 mock해야 함 + when(productRepository.findAllById(List.of(productId1, productId2))) + .thenReturn(List.of(product1, product2)); + + // act + List result = likeFacade.getLikedProducts(DEFAULT_USER_ID); + + // assert + assertThat(result).hasSize(2); + assertThat(result).extracting(LikeFacade.LikedProduct::productId) + .containsExactlyInAnyOrder(productId1, productId2); + assertThat(result).extracting(LikeFacade.LikedProduct::likesCount) + .containsExactlyInAnyOrder(5L, 3L); + } + + @Test + @DisplayName("좋아요한 상품이 없으면 빈 목록을 반환한다") + void getLikedProducts_emptyList() { + // arrange + setupMockUser(DEFAULT_USER_ID, DEFAULT_USER_INTERNAL_ID); + when(likeRepository.findAllByUserId(DEFAULT_USER_INTERNAL_ID)).thenReturn(List.of()); + + // act + List result = likeFacade.getLikedProducts(DEFAULT_USER_ID); + + // assert + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("좋아요한 상품 목록 조회 시 상품을 찾을 수 없으면 예외를 던진다") + void getLikedProducts_productNotFound() { + // arrange + setupMockUser(DEFAULT_USER_ID, DEFAULT_USER_INTERNAL_ID); + + Long productId1 = 1L; + Long nonExistentProductId = 999L; + + Like like1 = Like.of(DEFAULT_USER_INTERNAL_ID, productId1); + Like like2 = Like.of(DEFAULT_USER_INTERNAL_ID, nonExistentProductId); + List likes = List.of(like1, like2); + + Product product1 = createMockProduct(productId1, "상품1", 10000, 10, 1L, 5L); + + when(likeRepository.findAllByUserId(DEFAULT_USER_INTERNAL_ID)).thenReturn(likes); + // ✅ findAllById를 사용하므로 findAllById를 mock해야 함 + // nonExistentProductId가 포함되지 않아서 예외가 발생해야 함 + when(productRepository.findAllById(List.of(productId1, nonExistentProductId))) + .thenReturn(List.of(product1)); // product1만 반환 (nonExistentProductId는 없음) + + // act & assert + assertThatThrownBy(() -> likeFacade.getLikedProducts(DEFAULT_USER_ID)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); + } + + @Test + @DisplayName("좋아요한 상품 목록 조회 시 사용자를 찾을 수 없으면 예외를 던진다") + void getLikedProducts_userNotFound() { + // arrange + String unknownUserId = "unknown"; + when(userRepository.findByUserId(unknownUserId)).thenReturn(null); + + // act & assert + assertThatThrownBy(() -> likeFacade.getLikedProducts(unknownUserId)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); + } + // Helper methods for test setup private void setupMocks(String userId, Long userInternalId, Long productId) { @@ -152,5 +241,17 @@ private void setupMockProduct(Long productId) { Product mockProduct = mock(Product.class); when(productRepository.findById(productId)).thenReturn(Optional.of(mockProduct)); } + + private Product createMockProduct(Long productId, String name, Integer price, Integer stock, Long brandId, Long likeCount) { + Product product = mock(Product.class); + when(product.getId()).thenReturn(productId); + when(product.getName()).thenReturn(name); + when(product.getPrice()).thenReturn(price); + when(product.getStock()).thenReturn(stock); + when(product.getBrandId()).thenReturn(brandId); + // ✅ Product.likeCount 필드 mock 설정 (비동기 집계된 값) + when(product.getLikeCount()).thenReturn(likeCount); + return product; + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeConcurrencyTest.java b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeConcurrencyTest.java new file mode 100644 index 000000000..3220b41c0 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeConcurrencyTest.java @@ -0,0 +1,358 @@ +package com.loopers.application.purchasing; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.coupon.Coupon; +import com.loopers.domain.coupon.CouponRepository; +import com.loopers.domain.coupon.CouponType; +import com.loopers.domain.coupon.UserCoupon; +import com.loopers.domain.coupon.UserCouponRepository; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.user.Gender; +import com.loopers.domain.user.Point; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * PurchasingFacade 동시성 테스트 + *

+ * 여러 스레드에서 동시에 주문 요청을 보내도 데이터 일관성이 유지되는지 검증합니다. + * - 포인트 차감의 정확성 + * - 재고 차감의 정확성 + * - 쿠폰 사용의 중복 방지 (예시) + *

+ */ +@SpringBootTest +@Import(MySqlTestContainersConfig.class) +@DisplayName("PurchasingFacade 동시성 테스트") +class PurchasingFacadeConcurrencyTest { + + @Autowired + private PurchasingFacade purchasingFacade; + + @Autowired + private UserRepository userRepository; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private BrandRepository brandRepository; + + @Autowired + private OrderRepository orderRepository; + + @Autowired + private CouponRepository couponRepository; + + @Autowired + private UserCouponRepository userCouponRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private User createAndSaveUser(String userId, String email, long point) { + User user = User.of(userId, email, "1990-01-01", Gender.MALE, Point.of(point)); + return userRepository.save(user); + } + + private Brand createAndSaveBrand(String brandName) { + Brand brand = Brand.of(brandName); + return brandRepository.save(brand); + } + + private Product createAndSaveProduct(String productName, int price, int stock, Long brandId) { + Product product = Product.of(productName, price, stock, brandId); + return productRepository.save(product); + } + + private Coupon createAndSaveCoupon(String code, CouponType type, Integer discountValue) { + Coupon coupon = Coupon.of(code, type, discountValue); + return couponRepository.save(coupon); + } + + private UserCoupon createAndSaveUserCoupon(Long userId, Coupon coupon) { + UserCoupon userCoupon = UserCoupon.of(userId, coupon); + return userCouponRepository.save(userCoupon); + } + + @Test + @DisplayName("동일한 유저가 서로 다른 주문을 동시에 수행해도, 포인트가 정상적으로 차감되어야 한다") + void concurrencyTest_pointShouldProperlyDecreaseWhenOrderCreated() throws InterruptedException { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 100_000L); + String userId = user.getUserId(); + Brand brand = createAndSaveBrand("테스트 브랜드"); + + int orderCount = 5; + List products = new ArrayList<>(); + for (int i = 0; i < orderCount; i++) { + products.add(createAndSaveProduct("상품" + i, 10_000, 100, brand.getId())); + } + + ExecutorService executorService = Executors.newFixedThreadPool(orderCount); + CountDownLatch latch = new CountDownLatch(orderCount); + AtomicInteger successCount = new AtomicInteger(0); + List exceptions = new ArrayList<>(); + + // act + for (int i = 0; i < orderCount; i++) { + final int index = i; + executorService.submit(() -> { + try { + List commands = List.of( + OrderItemCommand.of(products.get(index).getId(), 1) + ); + purchasingFacade.createOrder(userId, commands); + successCount.incrementAndGet(); + } catch (Exception e) { + synchronized (exceptions) { + exceptions.add(e); + } + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executorService.shutdown(); + + // assert + User savedUser = userRepository.findByUserId(userId); + long expectedRemainingPoint = 100_000L - (10_000L * orderCount); + + assertThat(successCount.get()).isEqualTo(orderCount); + assertThat(exceptions).isEmpty(); + assertThat(savedUser.getPoint().getValue()).isEqualTo(expectedRemainingPoint); + } + + @Test + @DisplayName("동일한 상품에 대해 여러 주문이 동시에 요청되어도, 재고가 정상적으로 차감되어야 한다") + void concurrencyTest_stockShouldBeProperlyDecreasedWhenOrdersCreated() throws InterruptedException { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 1_000_000L); + String userId = user.getUserId(); + Brand brand = createAndSaveBrand("테스트 브랜드"); + Product product = createAndSaveProduct("테스트 상품", 10_000, 100, brand.getId()); + Long productId = product.getId(); + + int orderCount = 10; + int quantityPerOrder = 5; + + ExecutorService executorService = Executors.newFixedThreadPool(orderCount); + CountDownLatch latch = new CountDownLatch(orderCount); + AtomicInteger successCount = new AtomicInteger(0); + List exceptions = new ArrayList<>(); + + // act + for (int i = 0; i < orderCount; i++) { + executorService.submit(() -> { + try { + List commands = List.of( + OrderItemCommand.of(productId, quantityPerOrder) + ); + purchasingFacade.createOrder(userId, commands); + successCount.incrementAndGet(); + } catch (Exception e) { + synchronized (exceptions) { + exceptions.add(e); + } + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executorService.shutdown(); + + // assert + Product savedProduct = productRepository.findById(productId).orElseThrow(); + int expectedStock = 100 - (successCount.get() * quantityPerOrder); + + assertThat(savedProduct.getStock()).isEqualTo(expectedStock); + assertThat(successCount.get() + exceptions.size()).isEqualTo(orderCount); + } + + @Test + @DisplayName("동일한 쿠폰으로 여러 기기에서 동시에 주문해도, 쿠폰은 단 한번만 사용되어야 한다") + void concurrencyTest_couponShouldBeUsedOnlyOnceWhenOrdersCreated() throws InterruptedException { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 100_000L); + String userId = user.getUserId(); + Brand brand = createAndSaveBrand("테스트 브랜드"); + Product product = createAndSaveProduct("테스트 상품", 10_000, 100, brand.getId()); + + // 정액 쿠폰 생성 (5,000원 할인) + Coupon coupon = createAndSaveCoupon("COUPON001", CouponType.FIXED_AMOUNT, 5_000); + String couponCode = coupon.getCode(); + + // 사용자에게 쿠폰 지급 + UserCoupon userCoupon = createAndSaveUserCoupon(user.getId(), coupon); + + int concurrentRequestCount = 10; // 요구사항: 10개 스레드 + + ExecutorService executorService = Executors.newFixedThreadPool(concurrentRequestCount); + CountDownLatch latch = new CountDownLatch(concurrentRequestCount); + AtomicInteger successCount = new AtomicInteger(0); + List exceptions = new ArrayList<>(); + + // act + for (int i = 0; i < concurrentRequestCount; i++) { + executorService.submit(() -> { + try { + List commands = List.of( + new OrderItemCommand(product.getId(), 1, couponCode) + ); + purchasingFacade.createOrder(userId, commands); + successCount.incrementAndGet(); + } catch (Exception e) { + synchronized (exceptions) { + exceptions.add(e); + } + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executorService.shutdown(); + + // assert + // 쿠폰은 정확히 1번만 사용되어야 함 + UserCoupon savedUserCoupon = userCouponRepository.findByUserIdAndCouponCode(user.getId(), couponCode) + .orElseThrow(); + assertThat(savedUserCoupon.isAvailable()).isFalse(); // 사용됨 + assertThat(savedUserCoupon.getIsUsed()).isTrue(); + + // 성공한 주문은 1개만 있어야 함 (나머지는 쿠폰 중복 사용으로 실패) + assertThat(successCount.get()).isEqualTo(1); + assertThat(exceptions).hasSize(concurrentRequestCount - 1); + + // 성공한 주문의 할인 금액이 적용되었는지 확인 + List orders = orderRepository.findAllByUserId(user.getId()); + assertThat(orders).hasSize(1); + Order order = orders.get(0); + assertThat(order.getCouponCode()).isEqualTo(couponCode); + assertThat(order.getDiscountAmount()).isEqualTo(5_000); + assertThat(order.getTotalAmount()).isEqualTo(5_000); // 10,000 - 5,000 = 5,000 + } + + @Test + @DisplayName("주문 취소 중 다른 스레드가 재고를 변경해도, 재고 원복이 정확하게 이루어져야 한다") + void concurrencyTest_cancelOrderShouldRestoreStockAccuratelyDuringConcurrentStockChanges() throws InterruptedException { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 1_000_000L); + String userId = user.getUserId(); + Brand brand = createAndSaveBrand("테스트 브랜드"); + Product product = createAndSaveProduct("테스트 상품", 10_000, 100, brand.getId()); + Long productId = product.getId(); + + // 주문 생성 (재고 5개 차감) + int orderQuantity = 5; + List commands = List.of( + OrderItemCommand.of(productId, orderQuantity) + ); + OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands); + Long orderId = orderInfo.orderId(); + + // 주문 취소 전 재고 확인 (100 - 5 = 95) + Product productBeforeCancel = productRepository.findById(productId).orElseThrow(); + int stockBeforeCancel = productBeforeCancel.getStock(); + assertThat(stockBeforeCancel).isEqualTo(95); + + // 주문 조회 + Order order = orderRepository.findById(orderId).orElseThrow(); + + ExecutorService executorService = Executors.newFixedThreadPool(3); + CountDownLatch latch = new CountDownLatch(3); + AtomicInteger cancelSuccess = new AtomicInteger(0); + AtomicInteger orderSuccess = new AtomicInteger(0); + List exceptions = new ArrayList<>(); + + // act + // 스레드 1: 주문 취소 (재고 원복) + executorService.submit(() -> { + try { + purchasingFacade.cancelOrder(order, user); + cancelSuccess.incrementAndGet(); + } catch (Exception e) { + synchronized (exceptions) { + exceptions.add(e); + } + } finally { + latch.countDown(); + } + }); + + // 스레드 2, 3: 취소 중간에 다른 주문 생성 (재고 추가 차감) + for (int i = 0; i < 2; i++) { + executorService.submit(() -> { + try { + Thread.sleep(10); // 취소가 시작된 후 실행되도록 약간의 지연 + List otherCommands = List.of( + OrderItemCommand.of(productId, 3) + ); + purchasingFacade.createOrder(userId, otherCommands); + orderSuccess.incrementAndGet(); + } catch (Exception e) { + synchronized (exceptions) { + exceptions.add(e); + } + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + executorService.shutdown(); + + // assert + // findByIdForUpdate로 인해 비관적 락이 적용되어 재고 원복이 정확하게 이루어져야 함 + Product finalProduct = productRepository.findById(productId).orElseThrow(); + int finalStock = finalProduct.getStock(); + + // 시나리오: + // 1. 초기 재고: 100 + // 2. 첫 주문: 95 (100 - 5) + // 3. 주문 취소: 100 (95 + 5) - 비관적 락으로 정확한 재고 조회 후 원복 + // 4. 다른 주문 2개: 각각 3개씩 차감 + // - 취소와 동시에 실행되면 락 대기 후 순차 처리 + // - 최종 재고: 100 - 3 - 3 = 94 (취소로 5개 원복 후 2개 주문으로 6개 차감) + + assertThat(cancelSuccess.get()).isEqualTo(1); + // 취소가 성공했고, 비관적 락으로 인해 정확한 재고가 원복되었는지 확인 + // 취소로 5개가 원복되고, 다른 주문 2개로 6개가 차감되므로: 95 + 5 - 6 = 94 + int expectedStock = stockBeforeCancel + orderQuantity - (orderSuccess.get() * 3); + assertThat(finalStock).isEqualTo(expectedStock); + + // 예외가 발생하지 않았는지 확인 + assertThat(exceptions).isEmpty(); + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeTest.java index 0809b50cc..6464e9552 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeTest.java @@ -2,6 +2,12 @@ import com.loopers.domain.brand.Brand; import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.coupon.Coupon; +import com.loopers.domain.coupon.CouponRepository; +import com.loopers.domain.coupon.CouponType; +import com.loopers.domain.coupon.UserCoupon; +import com.loopers.domain.coupon.UserCouponRepository; +import com.loopers.domain.order.OrderRepository; import com.loopers.domain.order.OrderStatus; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductRepository; @@ -38,6 +44,15 @@ class PurchasingFacadeTest { @Autowired private BrandRepository brandRepository; + + @Autowired + private OrderRepository orderRepository; + + @Autowired + private CouponRepository couponRepository; + + @Autowired + private UserCouponRepository userCouponRepository; @Autowired private DatabaseCleanUp databaseCleanUp; @@ -63,6 +78,34 @@ private Product createAndSaveProduct(String productName, int price, int stock, L return productRepository.save(product); } + /** + * 쿠폰을 생성하고 저장합니다. + * + * @param code 쿠폰 코드 + * @param type 쿠폰 타입 + * @param discountValue 할인 값 + * @return 저장된 쿠폰 + */ + private Coupon createAndSaveCoupon(String code, CouponType type, Integer discountValue) { + Coupon coupon = Coupon.of(code, type, discountValue); + return couponRepository.save(coupon); + } + + /** + * 사용자 쿠폰을 생성하고 저장합니다. + *

+ * 쿠폰은 이미 저장된 상태여야 합니다. + *

+ * + * @param userId 사용자 ID + * @param coupon 저장된 쿠폰 + * @return 저장된 사용자 쿠폰 + */ + private UserCoupon createAndSaveUserCoupon(Long userId, Coupon coupon) { + UserCoupon userCoupon = UserCoupon.of(userId, coupon); + return userCouponRepository.save(userCoupon); + } + @Test @DisplayName("주문 생성 시 재고 차감, 포인트 차감, 주문 완료, 외부 전송을 수행한다") void createOrder_successFlow() { @@ -73,8 +116,8 @@ void createOrder_successFlow() { Product product2 = createAndSaveProduct("상품2", 5_000, 5, brand.getId()); List commands = List.of( - new OrderItemCommand(product1.getId(), 2), - new OrderItemCommand(product2.getId(), 1) + OrderItemCommand.of(product1.getId(), 2), + OrderItemCommand.of(product2.getId(), 1) ); // act @@ -113,7 +156,7 @@ void createOrder_userNotFound() { // arrange String unknownUserId = "unknown"; List commands = List.of( - new OrderItemCommand(1L, 1) + OrderItemCommand.of(1L, 1) ); // act & assert @@ -132,9 +175,40 @@ void createOrder_stockNotEnough() { Brand brand = createAndSaveBrand("브랜드2"); Product product = createAndSaveProduct("상품", 10_000, 1, brand.getId()); final Long productId = product.getId(); + final int initialStock = product.getStock(); + + List commands = List.of( + OrderItemCommand.of(productId, 2) + ); + + // act & assert + assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + + // 롤백 확인: 포인트가 차감되지 않았는지 확인 + User savedUser = userRepository.findByUserId(userId); + assertThat(savedUser.getPoint().getValue()).isEqualTo(50_000L); + + // 롤백 확인: 재고가 변경되지 않았는지 확인 + Product savedProduct = productRepository.findById(productId).orElseThrow(); + assertThat(savedProduct.getStock()).isEqualTo(initialStock); + } + + @Test + @DisplayName("상품 재고가 0이면 예외를 던지고 포인트는 차감되지 않는다") + void createOrder_stockZero() { + // arrange + User user = createAndSaveUser("testuser2", "test2@example.com", 50_000L); + final String userId = user.getUserId(); + + Brand brand = createAndSaveBrand("브랜드2"); + Product product = createAndSaveProduct("상품", 10_000, 0, brand.getId()); + final Long productId = product.getId(); + final int initialStock = product.getStock(); List commands = List.of( - new OrderItemCommand(productId, 2) + OrderItemCommand.of(productId, 1) ); // act & assert @@ -142,9 +216,43 @@ void createOrder_stockNotEnough() { .isInstanceOf(CoreException.class) .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); - // 포인트가 차감되지 않았는지 확인 + // 롤백 확인: 포인트가 차감되지 않았는지 확인 User savedUser = userRepository.findByUserId(userId); assertThat(savedUser.getPoint().getValue()).isEqualTo(50_000L); + + // 롤백 확인: 재고가 변경되지 않았는지 확인 + Product savedProduct = productRepository.findById(productId).orElseThrow(); + assertThat(savedProduct.getStock()).isEqualTo(initialStock); + } + + @Test + @DisplayName("유저의 포인트 잔액이 부족하면 예외를 던지고 재고는 차감되지 않는다") + void createOrder_pointNotEnough() { + // arrange + User user = createAndSaveUser("testuser2", "test2@example.com", 5_000L); + final String userId = user.getUserId(); + + Brand brand = createAndSaveBrand("브랜드2"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + final Long productId = product.getId(); + final int initialStock = product.getStock(); + + List commands = List.of( + OrderItemCommand.of(productId, 1) + ); + + // act & assert + assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + + // 롤백 확인: 포인트가 차감되지 않았는지 확인 + User savedUser = userRepository.findByUserId(userId); + assertThat(savedUser.getPoint().getValue()).isEqualTo(5_000L); + + // 롤백 확인: 재고가 변경되지 않았는지 확인 + Product savedProduct = productRepository.findById(productId).orElseThrow(); + assertThat(savedProduct.getStock()).isEqualTo(initialStock); } @Test @@ -159,8 +267,8 @@ void createOrder_duplicateProducts_throwsException() { final Long productId = product.getId(); List commands = List.of( - new OrderItemCommand(productId, 1), - new OrderItemCommand(productId, 2) + OrderItemCommand.of(productId, 1), + OrderItemCommand.of(productId, 2) ); // act & assert @@ -182,7 +290,7 @@ void getOrders_returnsUserOrders() { Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); List commands = List.of( - new OrderItemCommand(product.getId(), 1) + OrderItemCommand.of(product.getId(), 1) ); purchasingFacade.createOrder(user.getUserId(), commands); @@ -203,7 +311,7 @@ void getOrder_returnsSingleOrder() { Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); List commands = List.of( - new OrderItemCommand(product.getId(), 1) + OrderItemCommand.of(product.getId(), 1) ); OrderInfo createdOrder = purchasingFacade.createOrder(user.getUserId(), commands); @@ -228,7 +336,7 @@ void getOrder_withDifferentUser_throwsException() { Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); List commands = List.of( - new OrderItemCommand(product.getId(), 1) + OrderItemCommand.of(product.getId(), 1) ); OrderInfo user1Order = purchasingFacade.createOrder(user1Id, commands); final Long orderId = user1Order.orderId(); @@ -239,4 +347,216 @@ void getOrder_withDifferentUser_throwsException() { .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); } + @Test + @DisplayName("주문 전체 흐름에 대해 원자성이 보장되어야 한다 - 실패 시 모든 작업이 롤백된다") + void createOrder_atomicityGuaranteed() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + final String userId = user.getUserId(); + final long initialPoint = user.getPoint().getValue(); + + Brand brand = createAndSaveBrand("브랜드"); + Product product1 = createAndSaveProduct("상품1", 10_000, 5, brand.getId()); + Product product2 = createAndSaveProduct("상품2", 20_000, 3, brand.getId()); + final Long product1Id = product1.getId(); + final Long product2Id = product2.getId(); + final int initialStock1 = product1.getStock(); + final int initialStock2 = product2.getStock(); + + // product2의 재고가 부족한 상황 (3개 재고인데 5개 주문) + List commands = List.of( + OrderItemCommand.of(product1Id, 2), + OrderItemCommand.of(product2Id, 5) // 재고 부족 + ); + + // act & assert + assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + + // 롤백 확인: 포인트가 차감되지 않았는지 확인 + User savedUser = userRepository.findByUserId(userId); + assertThat(savedUser.getPoint().getValue()).isEqualTo(initialPoint); + + // 롤백 확인: 모든 상품의 재고가 변경되지 않았는지 확인 + Product savedProduct1 = productRepository.findById(product1Id).orElseThrow(); + Product savedProduct2 = productRepository.findById(product2Id).orElseThrow(); + assertThat(savedProduct1.getStock()).isEqualTo(initialStock1); + assertThat(savedProduct2.getStock()).isEqualTo(initialStock2); + + // 롤백 확인: 주문이 저장되지 않았는지 확인 + List orders = purchasingFacade.getOrders(userId); + assertThat(orders).isEmpty(); + } + + @Test + @DisplayName("주문 성공 시, 모든 처리는 정상 반영되어야 한다 - 재고, 포인트, 주문 모두 반영") + void createOrder_success_allOperationsReflected() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 100_000L); + final String userId = user.getUserId(); + final long initialPoint = user.getPoint().getValue(); + + Brand brand = createAndSaveBrand("브랜드"); + Product product1 = createAndSaveProduct("상품1", 10_000, 10, brand.getId()); + Product product2 = createAndSaveProduct("상품2", 15_000, 5, brand.getId()); + final Long product1Id = product1.getId(); + final Long product2Id = product2.getId(); + final int initialStock1 = product1.getStock(); + final int initialStock2 = product2.getStock(); + + List commands = List.of( + OrderItemCommand.of(product1Id, 3), + OrderItemCommand.of(product2Id, 2) + ); + final int totalAmount = (10_000 * 3) + (15_000 * 2); + + // act + OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands); + + // assert + // 주문이 정상적으로 생성되었는지 확인 + assertThat(orderInfo.status()).isEqualTo(OrderStatus.COMPLETED); + assertThat(orderInfo.items()).hasSize(2); + + // 재고가 정상적으로 차감되었는지 확인 + Product savedProduct1 = productRepository.findById(product1Id).orElseThrow(); + Product savedProduct2 = productRepository.findById(product2Id).orElseThrow(); + assertThat(savedProduct1.getStock()).isEqualTo(initialStock1 - 3); + assertThat(savedProduct2.getStock()).isEqualTo(initialStock2 - 2); + + // 포인트가 정상적으로 차감되었는지 확인 + User savedUser = userRepository.findByUserId(userId); + assertThat(savedUser.getPoint().getValue()).isEqualTo(initialPoint - totalAmount); + + // 주문이 저장되었는지 확인 + List orders = purchasingFacade.getOrders(userId); + assertThat(orders).hasSize(1); + assertThat(orders.get(0).orderId()).isEqualTo(orderInfo.orderId()); + } + + @Test + @DisplayName("정액 쿠폰을 적용하여 주문할 수 있다") + void createOrder_withFixedAmountCoupon_success() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + String userId = user.getUserId(); + Brand brand = createAndSaveBrand("브랜드"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + + Coupon coupon = createAndSaveCoupon("FIXED5000", CouponType.FIXED_AMOUNT, 5_000); + createAndSaveUserCoupon(user.getId(), coupon); + + List commands = List.of( + new OrderItemCommand(product.getId(), 1, "FIXED5000") + ); + + // act + OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands); + + // assert + assertThat(orderInfo.status()).isEqualTo(OrderStatus.COMPLETED); + assertThat(orderInfo.totalAmount()).isEqualTo(5_000); // 10,000 - 5,000 = 5,000 + + // 쿠폰이 사용되었는지 확인 + UserCoupon savedUserCoupon = userCouponRepository.findByUserIdAndCouponCode(user.getId(), "FIXED5000") + .orElseThrow(); + assertThat(savedUserCoupon.getIsUsed()).isTrue(); + } + + @Test + @DisplayName("정률 쿠폰을 적용하여 주문할 수 있다") + void createOrder_withPercentageCoupon_success() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + String userId = user.getUserId(); + Brand brand = createAndSaveBrand("브랜드"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + + Coupon coupon = createAndSaveCoupon("PERCENT20", CouponType.PERCENTAGE, 20); + createAndSaveUserCoupon(user.getId(), coupon); + + List commands = List.of( + new OrderItemCommand(product.getId(), 1, "PERCENT20") + ); + + // act + OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands); + + // assert + assertThat(orderInfo.status()).isEqualTo(OrderStatus.COMPLETED); + assertThat(orderInfo.totalAmount()).isEqualTo(8_000); // 10,000 - (10,000 * 20%) = 8,000 + + // 쿠폰이 사용되었는지 확인 + UserCoupon savedUserCoupon = userCouponRepository.findByUserIdAndCouponCode(user.getId(), "PERCENT20") + .orElseThrow(); + assertThat(savedUserCoupon.getIsUsed()).isTrue(); + } + + @Test + @DisplayName("존재하지 않는 쿠폰으로 주문하면 실패한다") + void createOrder_withNonExistentCoupon_shouldFail() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + String userId = user.getUserId(); + Brand brand = createAndSaveBrand("브랜드"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + + List commands = List.of( + new OrderItemCommand(product.getId(), 1, "NON_EXISTENT") + ); + + // act & assert + assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); + } + + @Test + @DisplayName("사용자가 소유하지 않은 쿠폰으로 주문하면 실패한다") + void createOrder_withCouponNotOwnedByUser_shouldFail() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + String userId = user.getUserId(); + Brand brand = createAndSaveBrand("브랜드"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + + Coupon coupon = Coupon.of("COUPON001", CouponType.FIXED_AMOUNT, 5_000); + couponRepository.save(coupon); + // 사용자에게 쿠폰을 지급하지 않음 + + List commands = List.of( + new OrderItemCommand(product.getId(), 1, "COUPON001") + ); + + // act & assert + assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); + } + + @Test + @DisplayName("이미 사용된 쿠폰으로 주문하면 실패한다") + void createOrder_withUsedCoupon_shouldFail() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + String userId = user.getUserId(); + Brand brand = createAndSaveBrand("브랜드"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + + Coupon coupon = createAndSaveCoupon("USED_COUPON", CouponType.FIXED_AMOUNT, 5_000); + UserCoupon userCoupon = createAndSaveUserCoupon(user.getId(), coupon); + userCoupon.use(); // 이미 사용 처리 + userCouponRepository.save(userCoupon); + + List commands = List.of( + new OrderItemCommand(product.getId(), 1, "USED_COUPON") + ); + + // act & assert + assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + } From 5054a5436a9428d39e20d6ec080c0a4b09f3b5b2 Mon Sep 17 00:00:00 2001 From: minor7295 <44902090+minor7295@users.noreply.github.com> Date: Tue, 2 Dec 2025 12:42:42 +0900 Subject: [PATCH 06/12] =?UTF-8?q?[volume-5]=20=EC=9D=B8=EB=8D=B1=EC=8A=A4?= =?UTF-8?q?=EC=99=80=20=EC=BA=90=EC=8B=B1=EC=9D=84=20=ED=86=B5=ED=95=9C=20?= =?UTF-8?q?=EC=9D=BD=EA=B8=B0=20=EC=84=B1=EB=8A=A5=20=EC=B5=9C=EC=A0=81?= =?UTF-8?q?=ED=99=94=20(#125)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Feature/catalog caching (#20) * feat: 상품 조회 결과를 캐시로 관리하는 서비스 로직 추가 * feat: 상품 조회하는 서비스 로직에 Cache Aside 패턴 적용 * Feature/cache eviction (#21) * feature: 상품 좋아요 수에 로컬캐시 적용 (#21) * test: LikeFacade 테스트 코드 수정 * feat: Product 엔티티에 인덱스 추가 (#22) --- .../catalog/CatalogProductFacade.java | 45 ++- .../catalog/ProductCacheService.java | 272 ++++++++++++++++++ .../loopers/application/like/LikeFacade.java | 8 + .../batch/LikeCountSyncBatchConfig.java | 30 +- .../com/loopers/domain/product/Product.java | 18 +- .../application/like/LikeFacadeTest.java | 28 +- 6 files changed, 384 insertions(+), 17 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/catalog/ProductCacheService.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogProductFacade.java index 3cba60deb..1ebf5c394 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogProductFacade.java @@ -28,10 +28,12 @@ public class CatalogProductFacade { private final ProductRepository productRepository; private final BrandRepository brandRepository; + private final ProductCacheService productCacheService; /** * 상품 목록을 조회합니다. *

+ * Redis 캐시를 확인하고, 캐시에 없으면 DB에서 조회한 후 캐시에 저장합니다. * 배치 조회를 통해 N+1 쿼리 문제를 해결합니다. *

* @@ -42,11 +44,24 @@ public class CatalogProductFacade { * @return 상품 목록 조회 결과 */ public ProductInfoList getProducts(Long brandId, String sort, int page, int size) { + // sort 기본값 처리 (컨트롤러와 동일하게 "latest" 사용) + String normalizedSort = (sort != null && !sort.isBlank()) ? sort : "latest"; + + // 캐시에서 조회 시도 + ProductInfoList cachedResult = productCacheService.getCachedProductList(brandId, normalizedSort, page, size); + if (cachedResult != null) { + return cachedResult; + } + + // 캐시에 없으면 DB에서 조회 long totalCount = productRepository.countAll(brandId); - List products = productRepository.findAll(brandId, sort, page, size); + List products = productRepository.findAll(brandId, normalizedSort, page, size); if (products.isEmpty()) { - return new ProductInfoList(List.of(), totalCount, page, size); + ProductInfoList emptyResult = new ProductInfoList(List.of(), totalCount, page, size); + // 캐시 저장 + productCacheService.cacheProductList(brandId, normalizedSort, page, size, emptyResult); + return emptyResult; } // ✅ 배치 조회로 N+1 쿼리 문제 해결 @@ -74,17 +89,33 @@ public ProductInfoList getProducts(Long brandId, String sort, int page, int size }) .toList(); - return new ProductInfoList(productsInfo, totalCount, page, size); + ProductInfoList result = new ProductInfoList(productsInfo, totalCount, page, size); + + // 캐시 저장 + productCacheService.cacheProductList(brandId, normalizedSort, page, size, result); + + // 로컬 캐시의 좋아요 수 델타 적용 (DB 조회 결과에도 델타 반영) + return productCacheService.applyLikeCountDelta(result); } /** * 상품 정보를 조회합니다. + *

+ * Redis 캐시를 먼저 확인하고, 캐시에 없으면 DB에서 조회한 후 캐시에 저장합니다. + *

* * @param productId 상품 ID * @return 상품 정보와 좋아요 수 * @throws CoreException 상품을 찾을 수 없는 경우 */ public ProductInfo getProduct(Long productId) { + // 캐시에서 조회 시도 + ProductInfo cachedResult = productCacheService.getCachedProduct(productId); + if (cachedResult != null) { + return cachedResult; + } + + // 캐시에 없으면 DB에서 조회 Product product = productRepository.findById(productId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); @@ -98,7 +129,13 @@ public ProductInfo getProduct(Long productId) { // ProductDetail 생성 (Aggregate 경계 준수: Brand 엔티티 대신 brandName만 전달) ProductDetail productDetail = ProductDetail.from(product, brand.getName(), likesCount); - return new ProductInfo(productDetail); + ProductInfo result = new ProductInfo(productDetail); + + // 캐시에 저장 + productCacheService.cacheProduct(productId, result); + + // 로컬 캐시의 좋아요 수 델타 적용 (DB 조회 결과에도 델타 반영) + return productCacheService.applyLikeCountDelta(result); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/catalog/ProductCacheService.java b/apps/commerce-api/src/main/java/com/loopers/application/catalog/ProductCacheService.java new file mode 100644 index 000000000..8428efa50 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/catalog/ProductCacheService.java @@ -0,0 +1,272 @@ +package com.loopers.application.catalog; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.product.ProductDetail; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +/** + * 상품 조회 결과를 Redis에 캐시하는 서비스. + *

+ * 상품 목록 조회와 상품 상세 조회 결과를 캐시하여 성능을 향상시킵니다. + *

+ *

+ * 캐시 전략: + *

    + *
  • 상품 목록: 첫 3페이지만 캐시하여 메모리 사용량 최적화
  • + *
  • 상품 상세: 모든 상품 상세 정보 캐시
  • + *
+ *

+ * + * @author Loopers + */ +@Service +@RequiredArgsConstructor +public class ProductCacheService { + + private static final String CACHE_KEY_PREFIX_LIST = "product:list:"; + private static final String CACHE_KEY_PREFIX_DETAIL = "product:detail:"; + private static final Duration CACHE_TTL = Duration.ofMinutes(1); // 1분 TTL + + private final RedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + /** + * 로컬 캐시: 상품별 좋아요 수 델타 (productId -> likeCount delta) + *

+ * 좋아요 추가/취소 시 델타를 저장하고, 캐시 조회 시 델타를 적용하여 반환합니다. + * 배치 집계 후에는 초기화됩니다. + *

+ */ + private final ConcurrentHashMap likeCountDeltaCache = new ConcurrentHashMap<>(); + + /** + * 상품 목록 조회 결과를 캐시에서 조회합니다. + *

+ * 페이지 번호와 관계없이 캐시를 확인하고, 캐시에 있으면 반환합니다. + * 캐시에 없으면 null을 반환하여 DB 조회를 유도합니다. + *

+ *

+ * 로컬 캐시의 좋아요 수 델타를 적용하여 반환합니다. + *

+ * + * @param brandId 브랜드 ID (null이면 전체) + * @param sort 정렬 기준 + * @param page 페이지 번호 + * @param size 페이지당 상품 수 + * @return 캐시된 상품 목록 (없으면 null) + */ + public ProductInfoList getCachedProductList(Long brandId, String sort, int page, int size) { + try { + String key = buildListCacheKey(brandId, sort, page, size); + String cachedValue = redisTemplate.opsForValue().get(key); + + if (cachedValue == null) { + return null; + } + + ProductInfoList cachedList = objectMapper.readValue(cachedValue, new TypeReference() {}); + + // 로컬 캐시의 좋아요 수 델타 적용 + return applyLikeCountDelta(cachedList); + } catch (Exception e) { + return null; + } + } + + /** + * 상품 목록 조회 결과를 캐시에 저장합니다. + *

+ * 첫 3페이지인 경우에만 캐시에 저장합니다. + *

+ * + * @param brandId 브랜드 ID (null이면 전체) + * @param sort 정렬 기준 + * @param page 페이지 번호 + * @param size 페이지당 상품 수 + * @param productInfoList 캐시할 상품 목록 + */ + public void cacheProductList(Long brandId, String sort, int page, int size, ProductInfoList productInfoList) { + // 3페이지까지만 캐시 저장 + if (page > 2) { + return; + } + + try { + String key = buildListCacheKey(brandId, sort, page, size); + String value = objectMapper.writeValueAsString(productInfoList); + redisTemplate.opsForValue().set(key, value, CACHE_TTL); + } catch (Exception e) { + // 캐시 저장 실패는 무시 (DB 조회로 폴백 가능) + } + } + + /** + * 상품 상세 조회 결과를 캐시에서 조회합니다. + *

+ * 로컬 캐시의 좋아요 수 델타를 적용하여 반환합니다. + *

+ * + * @param productId 상품 ID + * @return 캐시된 상품 정보 (없으면 null) + */ + public ProductInfo getCachedProduct(Long productId) { + try { + String key = buildDetailCacheKey(productId); + String cachedValue = redisTemplate.opsForValue().get(key); + + if (cachedValue == null) { + return null; + } + + ProductInfo cachedInfo = objectMapper.readValue(cachedValue, new TypeReference() {}); + + // 로컬 캐시의 좋아요 수 델타 적용 + return applyLikeCountDelta(cachedInfo); + } catch (Exception e) { + return null; + } + } + + /** + * 상품 상세 조회 결과를 캐시에 저장합니다. + * + * @param productId 상품 ID + * @param productInfo 캐시할 상품 정보 + */ + public void cacheProduct(Long productId, ProductInfo productInfo) { + try { + String key = buildDetailCacheKey(productId); + String value = objectMapper.writeValueAsString(productInfo); + redisTemplate.opsForValue().set(key, value, CACHE_TTL); + } catch (Exception e) { + // 캐시 저장 실패는 무시 (DB 조회로 폴백 가능) + } + } + + /** + * 상품 목록 캐시 키를 생성합니다. + * + * @param brandId 브랜드 ID (null이면 "all") + * @param sort 정렬 기준 + * @param page 페이지 번호 + * @param size 페이지당 상품 수 + * @return 캐시 키 + */ + private String buildListCacheKey(Long brandId, String sort, int page, int size) { + String brandPart = brandId != null ? "brand:" + brandId : "brand:all"; + // sort가 null이면 기본값 "latest" 사용 (컨트롤러와 동일한 기본값) + String sortValue = sort != null ? sort : "latest"; + return String.format("%s%s:sort:%s:page:%d:size:%d", + CACHE_KEY_PREFIX_LIST, brandPart, sortValue, page, size); + } + + /** + * 상품 상세 캐시 키를 생성합니다. + * + * @param productId 상품 ID + * @return 캐시 키 + */ + private String buildDetailCacheKey(Long productId) { + return CACHE_KEY_PREFIX_DETAIL + productId; + } + + /** + * 좋아요 수 델타를 증가시킵니다. + *

+ * 좋아요 추가 시 호출됩니다. + *

+ * + * @param productId 상품 ID + */ + public void incrementLikeCountDelta(Long productId) { + likeCountDeltaCache.merge(productId, 1L, Long::sum); + } + + /** + * 좋아요 수 델타를 감소시킵니다. + *

+ * 좋아요 취소 시 호출됩니다. + *

+ * + * @param productId 상품 ID + */ + public void decrementLikeCountDelta(Long productId) { + likeCountDeltaCache.merge(productId, -1L, Long::sum); + } + + /** + * 모든 좋아요 수 델타를 초기화합니다. + *

+ * 배치 집계 후 호출됩니다. + *

+ */ + public void clearAllLikeCountDelta() { + likeCountDeltaCache.clear(); + } + + /** + * 상품 목록에 좋아요 수 델타를 적용합니다. + *

+ * DB에서 직접 조회한 결과에도 델타를 적용하기 위해 public으로 제공합니다. + *

+ * + * @param productInfoList 상품 목록 + * @return 델타가 적용된 상품 목록 + */ + public ProductInfoList applyLikeCountDelta(ProductInfoList productInfoList) { + if (likeCountDeltaCache.isEmpty()) { + return productInfoList; + } + + List updatedProducts = productInfoList.products().stream() + .map(this::applyLikeCountDelta) + .collect(Collectors.toList()); + + return new ProductInfoList( + updatedProducts, + productInfoList.totalCount(), + productInfoList.page(), + productInfoList.size() + ); + } + + /** + * 상품 정보에 좋아요 수 델타를 적용합니다. + *

+ * DB에서 직접 조회한 결과에도 델타를 적용하기 위해 public으로 제공합니다. + *

+ * + * @param productInfo 상품 정보 + * @return 델타가 적용된 상품 정보 + */ + public ProductInfo applyLikeCountDelta(ProductInfo productInfo) { + Long delta = likeCountDeltaCache.get(productInfo.productDetail().getId()); + if (delta == null || delta == 0) { + return productInfo; + } + + ProductDetail originalDetail = productInfo.productDetail(); + Long updatedLikesCount = originalDetail.getLikesCount() + delta; + + ProductDetail updatedDetail = ProductDetail.of( + originalDetail.getId(), + originalDetail.getName(), + originalDetail.getPrice(), + originalDetail.getStock(), + originalDetail.getBrandId(), + originalDetail.getBrandName(), + updatedLikesCount + ); + + return new ProductInfo(updatedDetail); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java index 3c016a492..de21d46b5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -1,5 +1,6 @@ package com.loopers.application.like; +import com.loopers.application.catalog.ProductCacheService; import com.loopers.domain.like.Like; import com.loopers.domain.like.LikeRepository; import com.loopers.domain.product.Product; @@ -32,6 +33,7 @@ public class LikeFacade { private final LikeRepository likeRepository; private final UserRepository userRepository; private final ProductRepository productRepository; + private final ProductCacheService productCacheService; /** * 상품에 좋아요를 추가합니다. @@ -78,11 +80,14 @@ public void addLike(String userId, Long productId) { Like like = Like.of(user.getId(), productId); try { likeRepository.save(like); + // 좋아요 추가 성공 시 로컬 캐시의 델타 증가 + productCacheService.incrementLikeCountDelta(productId); } catch (org.springframework.dao.DataIntegrityViolationException e) { // UNIQUE 제약조건 위반 = 이미 저장됨 (멱등성 보장) // 동시에 여러 요청이 들어와서 모두 "없음"으로 판단하고 저장을 시도할 때, // 첫 번째만 성공하고 나머지는 UNIQUE 제약조건 위반 예외 발생 // 이미 좋아요가 존재하는 경우이므로 정상 처리로 간주 + // 로컬 캐시는 업데이트하지 않음 (이미 좋아요가 존재하므로) } } @@ -107,9 +112,12 @@ public void removeLike(String userId, Long productId) { try { likeRepository.delete(like.get()); + // 좋아요 취소 성공 시 로컬 캐시의 델타 감소 + productCacheService.decrementLikeCountDelta(productId); } catch (Exception e) { // 동시성 상황에서 이미 삭제된 경우 등 예외 발생 가능 // 멱등성 보장: 이미 삭제된 경우 정상 처리로 간주 + // 로컬 캐시는 업데이트하지 않음 (이미 삭제되었으므로) } } diff --git a/apps/commerce-api/src/main/java/com/loopers/config/batch/LikeCountSyncBatchConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/batch/LikeCountSyncBatchConfig.java index 016ca8c0f..6bf72da5c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/config/batch/LikeCountSyncBatchConfig.java +++ b/apps/commerce-api/src/main/java/com/loopers/config/batch/LikeCountSyncBatchConfig.java @@ -1,12 +1,15 @@ package com.loopers.config.batch; +import com.loopers.application.catalog.ProductCacheService; import com.loopers.domain.like.LikeRepository; -import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.ExitStatus; import org.springframework.batch.core.Job; import org.springframework.batch.core.Step; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.core.StepExecutionListener; import org.springframework.batch.core.configuration.annotation.StepScope; import org.springframework.batch.core.job.builder.JobBuilder; import org.springframework.batch.core.repository.JobRepository; @@ -57,6 +60,7 @@ public class LikeCountSyncBatchConfig { private final PlatformTransactionManager transactionManager; private final ProductRepository productRepository; private final LikeRepository likeRepository; + private final ProductCacheService productCacheService; private static final int CHUNK_SIZE = 100; // 청크 크기: 100개씩 처리 @@ -91,6 +95,7 @@ public Step likeCountSyncStep() { .reader(productIdReader()) .processor(productLikeCountProcessor()) .writer(productLikeCountWriter()) + .listener(likeCountSyncStepListener()) .allowStartIfComplete(true) // ✅ 완료된 Step도 재실행 가능 (스케줄러에서 주기적 실행) .build(); } @@ -159,6 +164,29 @@ public ItemWriter productLikeCountWriter() { }; } + /** + * 좋아요 수 동기화 Step 완료 후 로컬 캐시를 초기화하는 Listener를 생성합니다. + *

+ * 배치 집계가 완료되면 정확한 값으로 DB가 업데이트되므로, + * 로컬 캐시의 델타를 초기화하여 다음 배치까지의 델타만 추적합니다. + *

+ * + * @return StepExecutionListener + */ + @Bean + public StepExecutionListener likeCountSyncStepListener() { + return new StepExecutionListener() { + @Override + public ExitStatus afterStep(StepExecution stepExecution) { + // 배치 집계 완료 후 모든 로컬 캐시 델타 초기화 + // 배치가 정확한 값으로 DB를 업데이트했으므로, 델타는 0부터 다시 시작 + productCacheService.clearAllLikeCountDelta(); + log.debug("좋아요 수 동기화 배치 완료: 로컬 캐시 델타 초기화"); + return stepExecution.getExitStatus(); + } + }; + } + /** * 상품 ID와 좋아요 수를 담는 레코드. * diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java index 8e56d4e20..83363f71c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -19,7 +19,23 @@ * @version 1.0 */ @Entity -@Table(name = "product") +@Table( + name = "product", + indexes = { + // 브랜드 필터 + 좋아요 순 정렬 최적화 (복합 인덱스: ref_brand_id, like_count) + @Index(name = "idx_product_brand_likes", columnList = "ref_brand_id,like_count"), + // 브랜드 필터 + 최신순 정렬 최적화 (복합 인덱스: ref_brand_id, created_at) + @Index(name = "idx_product_brand_created", columnList = "ref_brand_id,created_at"), + // 브랜드 필터 + 가격순 정렬 최적화 (복합 인덱스: ref_brand_id, price) + @Index(name = "idx_product_brand_price", columnList = "ref_brand_id,price"), + // 전체 조회 + 좋아요 순 정렬 최적화 + @Index(name = "idx_product_likes", columnList = "like_count"), + // 전체 조회 + 가격순 정렬 최적화 + @Index(name = "idx_product_price", columnList = "price"), + // 전체 조회 + 최신순 정렬 최적화 + @Index(name = "idx_product_created", columnList = "created_at") + } +) @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter public class Product extends BaseEntity { diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java index d64ba6cbb..148fe1968 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java @@ -1,5 +1,6 @@ package com.loopers.application.like; +import com.loopers.application.catalog.ProductCacheService; import com.loopers.domain.like.Like; import com.loopers.domain.like.LikeRepository; import com.loopers.domain.product.Product; @@ -11,6 +12,9 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; import java.util.List; import java.util.Optional; @@ -26,10 +30,20 @@ @DisplayName("LikeFacade 좋아요 등록/취소/중복 방지 흐름 검증") class LikeFacadeTest { - private LikeFacade likeFacade; + @Mock + private LikeRepository likeRepository; + + @Mock private UserRepository userRepository; + + @Mock private ProductRepository productRepository; - private LikeRepository likeRepository; + + @Mock + private ProductCacheService productCacheService; + + @InjectMocks + private LikeFacade likeFacade; private static final String DEFAULT_USER_ID = "testuser"; private static final Long DEFAULT_USER_INTERNAL_ID = 1L; @@ -37,15 +51,7 @@ class LikeFacadeTest { @BeforeEach void setUp() { - userRepository = mock(UserRepository.class); - productRepository = mock(ProductRepository.class); - likeRepository = mock(LikeRepository.class); - - likeFacade = new LikeFacade( - likeRepository, - userRepository, - productRepository - ); + MockitoAnnotations.openMocks(this); } @Test From d928734990302244685660c7ca9f9d081d3072ec Mon Sep 17 00:00:00 2001 From: minor7295 <44902090+minor7295@users.noreply.github.com> Date: Sun, 7 Dec 2025 06:19:23 +0900 Subject: [PATCH 07/12] =?UTF-8?q?[volume-6]=20=EC=99=B8=EB=B6=80=20?= =?UTF-8?q?=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EC=9E=A5=EC=95=A0=20=EB=B0=8F=20?= =?UTF-8?q?=EC=A7=80=EC=97=B0=20=EB=8C=80=EC=9D=91=20(#152)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: PG 모듈 추가 (#24) * Feature/pg client (#27) * test: PG 호출 테스트 코드 추가 * feat: PG 호출 로직 구현 * test: PG CircuitBreaker 테스트 코드 추가 * feat: CircuitBreaker 로직 구현 --- apps/commerce-api/build.gradle.kts | 12 + .../com/loopers/CommerceApiApplication.java | 2 + .../purchasing/PaymentFailureHandler.java | 107 +++ .../purchasing/PaymentRecoveryScheduler.java | 115 +++ .../purchasing/PaymentRecoveryService.java | 66 ++ .../purchasing/PaymentRequest.java | 20 + .../purchasing/PaymentRequestBuilder.java | 167 ++++ .../purchasing/PurchasingFacade.java | 413 ++++++++-- .../config/Resilience4jRetryConfig.java | 129 ++++ .../order/OrderCancellationService.java | 106 +++ .../loopers/domain/order/OrderRepository.java | 8 + .../domain/order/OrderStatusUpdater.java | 100 +++ .../order/PaymentFailureClassifier.java | 74 ++ .../domain/order/PaymentFailureType.java | 25 + .../loopers/domain/order/PaymentResult.java | 55 ++ .../loopers/domain/user/UserRepository.java | 8 + .../order/OrderJpaRepository.java | 2 + .../order/OrderRepositoryImpl.java | 5 + .../paymentgateway/DelayProvider.java | 24 + .../paymentgateway/PaymentGatewayAdapter.java | 136 ++++ .../paymentgateway/PaymentGatewayClient.java | 85 +++ .../paymentgateway/PaymentGatewayDto.java | 106 +++ .../paymentgateway/PaymentGatewayMetrics.java | 86 +++ .../PaymentGatewaySchedulerClient.java | 70 ++ .../paymentgateway/ThreadDelayProvider.java | 21 + .../user/UserRepositoryImpl.java | 8 + .../loopers/interfaces/api/ApiResponse.java | 4 + .../purchasing/PurchasingV1Controller.java | 40 +- .../api/purchasing/PurchasingV1Dto.java | 14 +- .../src/main/resources/application.yml | 102 +++ .../PurchasingFacadeCircuitBreakerTest.java | 711 ++++++++++++++++++ .../PurchasingFacadeConcurrencyTest.java | 10 +- .../PurchasingFacadePaymentCallbackTest.java | 307 ++++++++ .../PurchasingFacadePaymentGatewayTest.java | 280 +++++++ .../purchasing/PurchasingFacadeTest.java | 84 ++- .../PaymentGatewayClientTest.java | 277 +++++++ .../api/PurchasingV1ApiE2ETest.java | 535 +++++++++++++ .../testutil/CircuitBreakerTestUtil.java | 152 ++++ apps/pg-simulator/README.md | 42 ++ apps/pg-simulator/build.gradle.kts | 40 + .../com/loopers/PaymentGatewayApplication.kt | 24 + .../loopers/application/payment/OrderInfo.kt | 14 + .../payment/PaymentApplicationService.kt | 88 +++ .../application/payment/PaymentCommand.kt | 22 + .../application/payment/TransactionInfo.kt | 39 + .../com/loopers/config/web/WebMvcConfig.kt | 13 + .../com/loopers/domain/payment/CardType.kt | 7 + .../com/loopers/domain/payment/Payment.kt | 87 +++ .../loopers/domain/payment/PaymentEvent.kt | 28 + .../domain/payment/PaymentEventPublisher.kt | 6 + .../loopers/domain/payment/PaymentRelay.kt | 7 + .../domain/payment/PaymentRepository.kt | 8 + .../domain/payment/TransactionKeyGenerator.kt | 20 + .../domain/payment/TransactionStatus.kt | 7 + .../com/loopers/domain/user/UserInfo.kt | 8 + .../payment/PaymentCoreEventPublisher.kt | 19 + .../payment/PaymentCoreRelay.kt | 21 + .../payment/PaymentCoreRepository.kt | 32 + .../payment/PaymentJpaRepository.kt | 9 + .../interfaces/api/ApiControllerAdvice.kt | 119 +++ .../com/loopers/interfaces/api/ApiResponse.kt | 32 + .../UserInfoArgumentResolver.kt | 32 + .../interfaces/api/payment/PaymentApi.kt | 60 ++ .../interfaces/api/payment/PaymentDto.kt | 136 ++++ .../event/payment/PaymentEventListener.kt | 28 + .../loopers/support/error/CoreException.kt | 6 + .../com/loopers/support/error/ErrorType.kt | 11 + .../src/main/resources/application.yml | 77 ++ http/pg-simulator/payments.http | 20 + settings.gradle.kts | 1 + 70 files changed, 5443 insertions(+), 86 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentFailureHandler.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRecoveryScheduler.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRecoveryService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRequest.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRequestBuilder.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/config/Resilience4jRetryConfig.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderCancellationService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatusUpdater.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/PaymentFailureClassifier.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/PaymentFailureType.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/PaymentResult.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/DelayProvider.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayAdapter.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayClient.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayDto.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayMetrics.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewaySchedulerClient.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/ThreadDelayProvider.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeCircuitBreakerTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadePaymentCallbackTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadePaymentGatewayTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayClientTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/PurchasingV1ApiE2ETest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/testutil/CircuitBreakerTestUtil.java create mode 100644 apps/pg-simulator/README.md create mode 100644 apps/pg-simulator/build.gradle.kts create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/PaymentGatewayApplication.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/OrderInfo.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/PaymentApplicationService.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/PaymentCommand.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/TransactionInfo.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/config/web/WebMvcConfig.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/CardType.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/Payment.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentEvent.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentEventPublisher.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentRelay.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentRepository.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/TransactionKeyGenerator.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/TransactionStatus.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/domain/user/UserInfo.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreEventPublisher.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreRelay.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreRepository.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentJpaRepository.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiControllerAdvice.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiResponse.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/argumentresolver/UserInfoArgumentResolver.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentApi.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentDto.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/event/payment/PaymentEventListener.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/support/error/CoreException.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/support/error/ErrorType.kt create mode 100644 apps/pg-simulator/src/main/resources/application.yml create mode 100644 http/pg-simulator/payments.http diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 969c6a1f0..83be16c09 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -10,6 +10,18 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${project.properties["springDocOpenApiVersion"]}") + + // feign client + implementation("org.springframework.cloud:spring-cloud-starter-openfeign") + + // resilience4j + implementation("io.github.resilience4j:resilience4j-spring-boot3") + implementation("io.github.resilience4j:resilience4j-core") // IntervalFunction을 위한 core 모듈 + implementation("io.github.resilience4j:resilience4j-circuitbreaker") + implementation("io.github.resilience4j:resilience4j-retry") + implementation("io.github.resilience4j:resilience4j-timelimiter") + implementation("io.github.resilience4j:resilience4j-bulkhead") // Bulkheads 패턴 구현 + implementation("org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j") // batch implementation("org.springframework.boot:spring-boot-starter-batch") diff --git a/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java b/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java index 0b4b1cde4..659d8ccdb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java +++ b/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java @@ -4,12 +4,14 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.cloud.openfeign.EnableFeignClients; import org.springframework.scheduling.annotation.EnableScheduling; import java.util.TimeZone; @ConfigurationPropertiesScan @SpringBootApplication @EnableScheduling +@EnableFeignClients public class CommerceApiApplication { @PostConstruct diff --git a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentFailureHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentFailureHandler.java new file mode 100644 index 000000000..52f240e13 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentFailureHandler.java @@ -0,0 +1,107 @@ +package com.loopers.application.purchasing; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderRepository; +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.order.OrderCancellationService; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** + * 결제 실패 처리 서비스. + *

+ * 결제 실패 시 주문 취소 및 리소스 원복을 처리합니다. + *

+ *

+ * 트랜잭션 전략: + *

    + *
  • REQUIRES_NEW: 별도 트랜잭션으로 실행하여 외부 시스템 호출과 독립적으로 처리
  • + *
  • 결제 실패 처리 중 오류가 발생해도 기존 주문 생성 트랜잭션에 영향을 주지 않음
  • + *
  • Self-invocation 문제 해결: 별도 서비스로 분리하여 Spring AOP 프록시가 정상적으로 적용되도록 함
  • + *
+ *

+ *

+ * 주의사항: + *

    + *
  • 주문이 이미 취소되었거나 존재하지 않는 경우 로그만 기록합니다.
  • + *
  • 결제 실패 처리 중 오류 발생 시에도 로그만 기록합니다.
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PaymentFailureHandler { + + private final UserRepository userRepository; + private final OrderRepository orderRepository; + private final OrderCancellationService orderCancellationService; + + /** + * 결제 실패 시 주문 취소 및 리소스 원복을 처리합니다. + *

+ * 결제 요청이 실패한 경우, 이미 생성된 주문을 취소하고 + * 차감된 포인트를 환불하며 재고를 원복합니다. + *

+ *

+ * 처리 내용: + *

    + *
  • 주문 상태를 CANCELED로 변경
  • + *
  • 차감된 포인트 환불
  • + *
  • 차감된 재고 원복
  • + *
+ *

+ * + * @param userId 사용자 ID (로그인 ID) + * @param orderId 주문 ID + * @param errorCode 오류 코드 + * @param errorMessage 오류 메시지 + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void handle(String userId, Long orderId, String errorCode, String errorMessage) { + try { + // 사용자 조회 + User user = userRepository.findByUserId(userId); + + if (user == null) { + log.warn("결제 실패 처리 시 사용자를 찾을 수 없습니다. (userId: {}, orderId: {})", userId, orderId); + return; + } + + // 주문 조회 + Order order = orderRepository.findById(orderId) + .orElse(null); + + if (order == null) { + log.warn("결제 실패 처리 시 주문을 찾을 수 없습니다. (orderId: {})", orderId); + return; + } + + // 이미 취소된 주문인 경우 처리하지 않음 + if (order.getStatus() == OrderStatus.CANCELED) { + log.info("이미 취소된 주문입니다. 결제 실패 처리를 건너뜁니다. (orderId: {})", orderId); + return; + } + + // 주문 취소 및 리소스 원복 + orderCancellationService.cancel(order, user); + + log.info("결제 실패로 인한 주문 취소 완료. (orderId: {}, errorCode: {}, errorMessage: {})", + orderId, errorCode, errorMessage); + } catch (Exception e) { + // 결제 실패 처리 중 오류 발생 시에도 로그만 기록 + // 이미 주문은 생성되어 있으므로, 나중에 수동으로 처리할 수 있도록 로그 기록 + log.error("결제 실패 처리 중 오류 발생. (orderId: {}, errorCode: {})", + orderId, errorCode, e); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRecoveryScheduler.java b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRecoveryScheduler.java new file mode 100644 index 000000000..6502a9cce --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRecoveryScheduler.java @@ -0,0 +1,115 @@ +package com.loopers.application.purchasing; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderRepository; +import com.loopers.domain.order.OrderStatus; +import com.loopers.infrastructure.user.UserJpaRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * 결제 상태 복구 스케줄러. + *

+ * 콜백이 오지 않은 PENDING 상태의 주문들을 주기적으로 조회하여 + * PG 시스템의 결제 상태 확인 API를 통해 상태를 복구합니다. + *

+ *

+ * 동작 원리: + *

    + *
  1. 주기적으로 실행 (기본: 1분마다)
  2. + *
  3. PENDING 상태인 주문들을 조회
  4. + *
  5. 각 주문에 대해 PG 결제 상태 확인 API 호출
  6. + *
  7. 결제 상태에 따라 주문 상태 업데이트
  8. + *
+ *

+ *

+ * 설계 근거: + *

    + *
  • 주기적 복구: 콜백이 오지 않아도 자동으로 상태 복구
  • + *
  • Eventually Consistent: 약간의 지연 허용 가능
  • + *
  • 안전한 처리: 각 주문별로 독립적으로 처리하여 실패 시에도 다른 주문에 영향 없음
  • + *
  • 성능 고려: 배치로 처리하여 PG 시스템 부하 최소화
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@RequiredArgsConstructor +@Component +public class PaymentRecoveryScheduler { + + private final OrderRepository orderRepository; + private final UserJpaRepository userJpaRepository; + private final PurchasingFacade purchasingFacade; + + /** + * PENDING 상태인 주문들의 결제 상태를 복구합니다. + *

+ * 1분마다 실행되어 PENDING 상태인 주문들을 조회하고, + * 각 주문에 대해 PG 결제 상태 확인 API를 호출하여 상태를 복구합니다. + *

+ *

+ * 처리 전략: + *

    + *
  • 배치 처리: 한 번에 여러 주문 처리
  • + *
  • 독립적 처리: 각 주문별로 독립적으로 처리하여 실패 시에도 다른 주문에 영향 없음
  • + *
  • 안전한 예외 처리: 개별 주문 처리 실패 시에도 계속 진행
  • + *
+ *

+ */ + @Scheduled(fixedDelay = 60000) // 1분마다 실행 + public void recoverPendingOrders() { + try { + log.debug("결제 상태 복구 스케줄러 시작"); + + // PENDING 상태인 주문들 조회 + List pendingOrders = orderRepository.findAllByStatus(OrderStatus.PENDING); + + if (pendingOrders.isEmpty()) { + log.debug("복구할 PENDING 상태 주문이 없습니다."); + return; + } + + log.info("PENDING 상태 주문 {}건에 대한 결제 상태 복구 시작", pendingOrders.size()); + + int successCount = 0; + int failureCount = 0; + + // 각 주문에 대해 결제 상태 확인 및 복구 + for (Order order : pendingOrders) { + try { + // Order의 userId는 User의 id (Long)이므로 User를 조회하여 userId (String)를 가져옴 + var userOptional = userJpaRepository.findById(order.getUserId()); + if (userOptional.isEmpty()) { + log.warn("주문의 사용자를 찾을 수 없습니다. 복구를 건너뜁니다. (orderId: {}, userId: {})", + order.getId(), order.getUserId()); + failureCount++; + continue; + } + + String userId = userOptional.get().getUserId(); + + // 결제 상태 확인 및 복구 + purchasingFacade.recoverOrderStatusByPaymentCheck(userId, order.getId()); + successCount++; + } catch (Exception e) { + // 개별 주문 처리 실패 시에도 계속 진행 + log.error("주문 상태 복구 중 오류 발생. (orderId: {})", order.getId(), e); + failureCount++; + } + } + + log.info("결제 상태 복구 완료. 성공: {}건, 실패: {}건", successCount, failureCount); + + } catch (Exception e) { + log.error("결제 상태 복구 스케줄러 실행 중 오류 발생", e); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRecoveryService.java b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRecoveryService.java new file mode 100644 index 000000000..6c6ff0646 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRecoveryService.java @@ -0,0 +1,66 @@ +package com.loopers.application.purchasing; + +import com.loopers.domain.order.OrderStatusUpdater; +import com.loopers.infrastructure.paymentgateway.DelayProvider; +import com.loopers.infrastructure.paymentgateway.PaymentGatewayAdapter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.Duration; + +/** + * 결제 복구 서비스. + *

+ * 타임아웃 발생 후 결제 상태를 확인하여 주문 상태를 복구합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PaymentRecoveryService { + + private final PaymentGatewayAdapter paymentGatewayAdapter; + private final OrderStatusUpdater orderStatusUpdater; + private final DelayProvider delayProvider; + + /** + * 타임아웃 발생 후 결제 상태를 확인하여 주문 상태를 복구합니다. + *

+ * 타임아웃은 요청이 전송되었을 수 있으므로, 실제 결제 상태를 확인하여 + * 결제가 성공했다면 주문을 완료하고, 실패했다면 주문을 취소합니다. + *

+ * + * @param userId 사용자 ID + * @param orderId 주문 ID + */ + public void recoverAfterTimeout(String userId, Long orderId) { + try { + // 잠시 대기 후 상태 확인 (PG 처리 시간 고려) + // 타임아웃이 발생했지만 요청은 전송되었을 수 있으므로, + // PG 시스템이 처리할 시간을 주기 위해 짧은 대기 + delayProvider.delay(Duration.ofSeconds(1)); + + // PG에서 주문별 결제 정보 조회 + var status = paymentGatewayAdapter.getPaymentStatus(userId, String.valueOf(orderId)); + + // 별도 트랜잭션으로 상태 업데이트 + boolean updated = orderStatusUpdater.updateByPaymentStatus(orderId, status, null, null); + + if (!updated) { + log.warn("타임아웃 후 상태 확인 실패. 주문 상태 업데이트에 실패했습니다. 나중에 스케줄러로 복구됩니다. (orderId: {})", orderId); + } + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("타임아웃 후 상태 확인 중 인터럽트 발생. (orderId: {})", orderId); + } catch (Exception e) { + // 기타 오류: 나중에 스케줄러로 복구 가능 + log.error("타임아웃 후 상태 확인 중 오류 발생. 나중에 스케줄러로 복구됩니다. (orderId: {})", orderId, e); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRequest.java b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRequest.java new file mode 100644 index 000000000..4188fd2e6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRequest.java @@ -0,0 +1,20 @@ +package com.loopers.application.purchasing; + +import com.loopers.infrastructure.paymentgateway.PaymentGatewayDto; + +/** + * 결제 요청 도메인 모델. + * + * @author Loopers + * @version 1.0 + */ +public record PaymentRequest( + String userId, + String orderId, + PaymentGatewayDto.CardType cardType, + String cardNo, + Long amount, + String callbackUrl +) { +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRequestBuilder.java b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRequestBuilder.java new file mode 100644 index 000000000..21b7869d0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRequestBuilder.java @@ -0,0 +1,167 @@ +package com.loopers.application.purchasing; + +import com.loopers.infrastructure.paymentgateway.PaymentGatewayDto; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * 결제 요청 빌더. + *

+ * 결제 요청 도메인 모델을 생성합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Component +@RequiredArgsConstructor +public class PaymentRequestBuilder { + + @Value("${payment.callback.base-url}") + private String callbackBaseUrl; + + /** + * 결제 요청을 생성합니다. + * + * @param userId 사용자 ID + * @param orderId 주문 ID + * @param cardType 카드 타입 문자열 + * @param cardNo 카드 번호 + * @param amount 결제 금액 + * @return 결제 요청 도메인 모델 + * @throws CoreException 잘못된 카드 타입인 경우 + */ + public PaymentRequest build(String userId, Long orderId, String cardType, String cardNo, Integer amount) { + // 카드 번호 유효성 검증 + validateCardNo(cardNo); + + // 주문 ID를 6자리 이상 문자열로 변환 (pg-simulator 검증 요구사항) + String orderIdString = formatOrderId(orderId); + return new PaymentRequest( + userId, + orderIdString, + parseCardType(cardType), + cardNo, + amount.longValue(), + generateCallbackUrl(orderId) + ); + } + + /** + * 카드 타입 문자열을 CardType enum으로 변환합니다. + * + * @param cardType 카드 타입 문자열 + * @return CardType enum + * @throws CoreException 잘못된 카드 타입인 경우 + */ + private PaymentGatewayDto.CardType parseCardType(String cardType) { + try { + return PaymentGatewayDto.CardType.valueOf(cardType.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new CoreException(ErrorType.BAD_REQUEST, + String.format("잘못된 카드 타입입니다. (cardType: %s)", cardType)); + } + } + + /** + * 콜백 URL을 생성합니다. + *

+ * 환경변수 {@code payment.callback.base-url}을 사용하여 프로덕션 환경에 적합한 URL을 생성합니다. + *

+ * + * @param orderId 주문 ID + * @return 콜백 URL + */ + private String generateCallbackUrl(Long orderId) { + return String.format("%s/api/v1/orders/%d/callback", callbackBaseUrl, orderId); + } + + /** + * 카드 번호 유효성 검증을 수행합니다. + *

+ * 다음 사항들을 검증합니다: + *

    + *
  • null/empty 체크
  • + *
  • 공백/하이픈 제거 및 정규화
  • + *
  • 길이 검증 (13-19자리)
  • + *
  • 숫자만 포함하는지 검증
  • + *
  • Luhn 알고리즘 체크섬 검증
  • + *
+ *

+ * + * @param cardNo 카드 번호 + * @throws CoreException 유효하지 않은 카드 번호인 경우 + */ + private void validateCardNo(String cardNo) { + if (cardNo == null || cardNo.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "카드 번호는 필수입니다."); + } + + // 공백/하이픈 제거 및 정규화 + String normalized = cardNo.replaceAll("[\\s-]", ""); + + // 길이 검증 (13-19자리) + if (normalized.length() < 13 || normalized.length() > 19) { + throw new CoreException(ErrorType.BAD_REQUEST, + String.format("유효하지 않은 카드 번호 길이입니다. (길이: %d, 요구사항: 13-19자리)", normalized.length())); + } + + // 숫자만 포함하는지 검증 + if (!normalized.matches("\\d+")) { + throw new CoreException(ErrorType.BAD_REQUEST, "카드 번호는 숫자만 포함해야 합니다."); + } + + // Luhn 알고리즘 체크섬 검증 + if (!isValidLuhn(normalized)) { + throw new CoreException(ErrorType.BAD_REQUEST, "유효하지 않은 카드 번호입니다. (Luhn 알고리즘 검증 실패)"); + } + } + + /** + * Luhn 알고리즘을 사용하여 카드 번호의 체크섬을 검증합니다. + *

+ * Luhn 알고리즘은 신용카드 번호의 유효성을 검증하는 표준 알고리즘입니다. + *

+ * + * @param cardNo 정규화된 카드 번호 (숫자만 포함) + * @return 유효한 경우 true, 그렇지 않으면 false + */ + private boolean isValidLuhn(String cardNo) { + int sum = 0; + boolean alternate = false; + + // 오른쪽에서 왼쪽으로 순회 + for (int i = cardNo.length() - 1; i >= 0; i--) { + int digit = Character.getNumericValue(cardNo.charAt(i)); + + if (alternate) { + digit *= 2; + if (digit > 9) { + digit = (digit % 10) + 1; + } + } + + sum += digit; + alternate = !alternate; + } + + return (sum % 10) == 0; + } + + /** + * 주문 ID를 6자리 이상 문자열로 변환합니다. + *

+ * pg-simulator의 검증 요구사항에 맞추기 위해 최소 6자리로 패딩합니다. + *

+ * + * @param orderId 주문 ID (Long) + * @return 6자리 이상의 주문 ID 문자열 + */ + public String formatOrderId(Long orderId) { + return String.format("%06d", orderId); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PurchasingFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PurchasingFacade.java index 82e55c406..0a2b1b208 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PurchasingFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PurchasingFacade.java @@ -8,16 +8,28 @@ import com.loopers.domain.order.Order; import com.loopers.domain.order.OrderItem; import com.loopers.domain.order.OrderRepository; +import com.loopers.domain.order.OrderStatus; import org.springframework.orm.ObjectOptimisticLockingFailureException; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductRepository; import com.loopers.domain.user.Point; import com.loopers.domain.user.User; import com.loopers.domain.user.UserRepository; +import com.loopers.infrastructure.paymentgateway.PaymentGatewaySchedulerClient; +import com.loopers.infrastructure.paymentgateway.PaymentGatewayDto; +import com.loopers.infrastructure.paymentgateway.PaymentGatewayAdapter; +import com.loopers.domain.order.PaymentFailureClassifier; +import com.loopers.domain.order.PaymentFailureType; +import com.loopers.domain.order.OrderStatusUpdater; +import com.loopers.domain.order.OrderCancellationService; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; +import feign.FeignException; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import java.util.ArrayList; @@ -32,6 +44,7 @@ * 주문 생성과 결제(포인트 차감), 재고 조정, 외부 연동을 조율한다. *

*/ +@Slf4j @RequiredArgsConstructor @Component public class PurchasingFacade { @@ -42,6 +55,14 @@ public class PurchasingFacade { private final CouponRepository couponRepository; private final UserCouponRepository userCouponRepository; private final CouponDiscountStrategyFactory couponDiscountStrategyFactory; + private final PaymentGatewaySchedulerClient paymentGatewaySchedulerClient; // 스케줄러용 (Retry 적용) + private final PaymentRequestBuilder paymentRequestBuilder; + private final PaymentGatewayAdapter paymentGatewayAdapter; + private final PaymentFailureClassifier paymentFailureClassifier; + private final PaymentRecoveryService paymentRecoveryService; + private final OrderCancellationService orderCancellationService; + private final OrderStatusUpdater orderStatusUpdater; + private final PaymentFailureHandler paymentFailureHandler; /** * 주문을 생성한다. @@ -49,7 +70,8 @@ public class PurchasingFacade { * 1. 사용자 조회 및 존재 여부 검증
* 2. 상품 재고 검증 및 차감
* 3. 사용자 포인트 검증 및 차감
- * 4. 주문 저장 + * 4. 주문 저장
+ * 5. PG 결제 요청 (비동기) *

*

* 동시성 제어 전략: @@ -81,10 +103,12 @@ public class PurchasingFacade { * * @param userId 사용자 식별자 (로그인 ID) * @param commands 주문 상품 정보 + * @param cardType 카드 타입 (SAMSUNG, KB, HYUNDAI) + * @param cardNo 카드 번호 (xxxx-xxxx-xxxx-xxxx 형식) * @return 생성된 주문 정보 */ @Transactional - public OrderInfo createOrder(String userId, List commands) { + public OrderInfo createOrder(String userId, List commands, String cardType, String cardNo) { if (userId == null || userId.isBlank()) { throw new CoreException(ErrorType.BAD_REQUEST, "사용자 ID는 필수입니다."); } @@ -149,15 +173,49 @@ public OrderInfo createOrder(String userId, List commands) { } Order order = Order.of(user.getId(), orderItems, couponCode, discountAmount); + // 주문은 PENDING 상태로 생성됨 (Order 생성자에서 기본값으로 설정) + // 결제 성공 후에만 COMPLETED로 변경됨 decreaseStocksForOrderItems(order.getItems(), products); deductUserPoint(user, order.getTotalAmount()); - order.complete(); + // 주문은 PENDING 상태로 유지 (결제 요청 중 상태) + // 결제 성공 시 콜백이나 상태 확인 API를 통해 COMPLETED로 변경됨 products.forEach(productRepository::save); userRepository.save(user); Order savedOrder = orderRepository.save(order); + // 주문은 PENDING 상태로 저장됨 + + // PG 결제 요청을 트랜잭션 커밋 후에 실행하여 DB 커넥션 풀 고갈 방지 + // 트랜잭션 내에서 외부 HTTP 호출을 하면 PG 지연/타임아웃 시 DB 커넥션이 오래 유지되어 커넥션 풀 고갈 위험 + Long orderId = savedOrder.getId(); + Integer totalAmount = savedOrder.getTotalAmount(); + TransactionSynchronizationManager.registerSynchronization( + new TransactionSynchronization() { + @Override + public void afterCommit() { + // 트랜잭션 커밋 후 PG 호출 (DB 커넥션 해제 후 실행) + try { + String transactionKey = requestPaymentToGateway(userId, orderId, cardType, cardNo, totalAmount); + if (transactionKey != null) { + // 결제 성공: 별도 트랜잭션에서 주문 상태를 COMPLETED로 변경 + updateOrderStatusToCompleted(orderId, transactionKey); + log.info("PG 결제 요청 완료. (orderId: {}, transactionKey: {})", orderId, transactionKey); + } else { + // PG 요청 실패: 외부 시스템 장애로 간주 + // 주문은 PENDING 상태로 유지되어 나중에 상태 확인 API나 콜백으로 복구 가능 + log.info("PG 결제 요청 실패. 주문은 PENDING 상태로 유지됩니다. (orderId: {})", orderId); + } + } catch (Exception e) { + // PG 요청 중 예외 발생 시에도 주문은 이미 저장되어 있으므로 유지 + // 외부 시스템 장애는 내부 시스템에 영향을 주지 않도록 함 + log.error("PG 결제 요청 중 예외 발생. 주문은 PENDING 상태로 유지됩니다. (orderId: {})", + orderId, e); + } + } + } + ); return OrderInfo.from(savedOrder); } @@ -175,48 +233,18 @@ public OrderInfo createOrder(String userId, List commands) { * @param order 주문 엔티티 * @param user 사용자 엔티티 */ + /** + * 주문을 취소하고 포인트를 환불하며 재고를 원복한다. + *

+ * OrderCancellationService를 사용하여 처리합니다. + *

+ * + * @param order 주문 엔티티 + * @param user 사용자 엔티티 + */ @Transactional public void cancelOrder(Order order, User user) { - if (order == null || user == null) { - throw new CoreException(ErrorType.BAD_REQUEST, "취소할 주문과 사용자 정보는 필수입니다."); - } - - // ✅ Deadlock 방지: User 락을 먼저 획득하여 createOrder와 동일한 락 획득 순서 보장 - // createOrder: User 락 → Product 락 (정렬됨) - // cancelOrder: User 락 → Product 락 (정렬됨) - 동일한 순서로 락 획득 - User lockedUser = userRepository.findByUserIdForUpdate(user.getUserId()); - if (lockedUser == null) { - throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다."); - } - - // ✅ Deadlock 방지: 상품 ID를 정렬하여 일관된 락 획득 순서 보장 - List sortedProductIds = order.getItems().stream() - .map(OrderItem::getProductId) - .distinct() - .sorted() - .toList(); - - // 정렬된 순서대로 상품 락 획득 (Deadlock 방지) - Map productMap = new java.util.HashMap<>(); - for (Long productId : sortedProductIds) { - Product product = productRepository.findByIdForUpdate(productId) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, - String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", productId))); - productMap.put(productId, product); - } - - // OrderItem 순서대로 Product 리스트 생성 - List products = order.getItems().stream() - .map(item -> productMap.get(item.getProductId())) - .toList(); - - order.cancel(); - increaseStocksForOrderItems(order.getItems(), products); - lockedUser.receivePoint(Point.of((long) order.getTotalAmount())); - - products.forEach(productRepository::save); - userRepository.save(lockedUser); - orderRepository.save(order); + orderCancellationService.cancel(order, user); } /** @@ -268,19 +296,6 @@ private void decreaseStocksForOrderItems(List items, List pr } } - private void increaseStocksForOrderItems(List items, List products) { - Map productMap = products.stream() - .collect(Collectors.toMap(Product::getId, product -> product)); - - for (OrderItem item : items) { - Product product = productMap.get(item.getProductId()); - if (product == null) { - throw new CoreException(ErrorType.NOT_FOUND, - String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", item.getProductId())); - } - product.increaseStock(item.getQuantity()); - } - } private void deductUserPoint(User user, Integer totalAmount) { if (Objects.requireNonNullElse(totalAmount, 0) <= 0) { @@ -398,5 +413,291 @@ private Integer calculateSubtotal(List orderItems) { .mapToInt(item -> item.getPrice() * item.getQuantity()) .sum(); } + + /** + * 주문 상태를 COMPLETED로 업데이트합니다. + *

+ * 트랜잭션 커밋 후 별도 트랜잭션에서 실행되어 주문 상태를 업데이트합니다. + *

+ * + * @param orderId 주문 ID + * @param transactionKey 트랜잭션 키 + */ + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.REQUIRES_NEW) + public void updateOrderStatusToCompleted(Long orderId, String transactionKey) { + Order order = orderRepository.findById(orderId).orElse(null); + if (order == null) { + log.warn("주문 상태 업데이트 시 주문을 찾을 수 없습니다. (orderId: {})", orderId); + return; + } + + if (order.getStatus() == OrderStatus.COMPLETED) { + log.info("이미 완료된 주문입니다. 상태 업데이트를 건너뜁니다. (orderId: {})", orderId); + return; + } + + order.complete(); + orderRepository.save(order); + log.info("주문 상태를 COMPLETED로 업데이트했습니다. (orderId: {}, transactionKey: {})", orderId, transactionKey); + } + + /** + * PG 결제 게이트웨이에 결제 요청을 전송합니다. + *

+ * 트랜잭션 커밋 후 실행되어 DB 커넥션 풀 고갈을 방지합니다. + * 실패 시에도 주문은 이미 저장되어 있으므로, 로그만 기록합니다. + *

+ * + * @param userId 사용자 ID + * @param orderId 주문 ID + * @param cardType 카드 타입 + * @param cardNo 카드 번호 + * @param amount 결제 금액 + * @return transactionKey (성공 시), null (실패 시) + */ + private String requestPaymentToGateway(String userId, Long orderId, String cardType, String cardNo, Integer amount) { + try { + // 결제 요청 생성 + PaymentRequest request = paymentRequestBuilder.build(userId, orderId, cardType, cardNo, amount); + + // PG 결제 요청 전송 + var result = paymentGatewayAdapter.requestPayment(request); + + // 결과 처리 + return result.handle( + success -> { + log.info("PG 결제 요청 성공. (orderId: {}, transactionKey: {})", + orderId, success.transactionKey()); + return success.transactionKey(); + }, + failure -> { + PaymentFailureType failureType = paymentFailureClassifier.classify(failure.errorCode()); + + if (failureType == PaymentFailureType.BUSINESS_FAILURE) { + // 비즈니스 실패: 주문 취소 (별도 트랜잭션으로 처리) + paymentFailureHandler.handle(userId, orderId, failure.errorCode(), failure.message()); + } else if (failure.isTimeout()) { + // 타임아웃: 상태 확인 후 복구 + log.info("타임아웃 발생. PG 결제 상태 확인 API를 호출하여 실제 결제 상태를 확인합니다. (orderId: {})", orderId); + paymentRecoveryService.recoverAfterTimeout(userId, orderId); + } else { + // 외부 시스템 장애: 주문은 PENDING 상태로 유지 + log.info("외부 시스템 장애로 인한 실패. 주문은 PENDING 상태로 유지됩니다. (orderId: {}, errorCode: {})", + orderId, failure.errorCode()); + } + return null; + } + ); + } catch (CoreException e) { + // 잘못된 카드 타입 등 검증 오류 + log.warn("결제 요청 생성 실패. (orderId: {}, error: {})", orderId, e.getMessage()); + return null; + } catch (Exception e) { + // 기타 예외 처리 + log.error("PG 결제 요청 중 예상치 못한 오류 발생. (orderId: {})", orderId, e); + log.info("예상치 못한 오류 발생. 주문은 PENDING 상태로 유지됩니다. (orderId: {})", orderId); + return null; + } + } + + + /** + * PG 결제 콜백을 처리합니다. + *

+ * PG 시스템에서 결제 처리 완료 후 콜백으로 전송된 결제 결과를 받아 + * 주문 상태를 업데이트합니다. + *

+ *

+ * 보안 및 정합성 강화: + *

    + *
  • 콜백 정보를 직접 신뢰하지 않고 PG 조회 API로 교차 검증
  • + *
  • 불일치 시 PG 원장을 우선시하여 처리
  • + *
  • 콜백 정보와 PG 조회 결과가 일치하는지 검증
  • + *
+ *

+ *

+ * 처리 내용: + *

    + *
  • 결제 성공 (SUCCESS): 주문 상태를 COMPLETED로 변경
  • + *
  • 결제 실패 (FAILED): 주문 상태를 CANCELED로 변경하고 리소스 원복
  • + *
  • 결제 대기 (PENDING): 상태 유지 (추가 처리 없음)
  • + *
+ *

+ * + * @param orderId 주문 ID + * @param callbackRequest 콜백 요청 정보 + */ + @Transactional + public void handlePaymentCallback(Long orderId, PaymentGatewayDto.CallbackRequest callbackRequest) { + try { + // 주문 조회 + Order order = orderRepository.findById(orderId) + .orElse(null); + + if (order == null) { + log.warn("콜백 처리 시 주문을 찾을 수 없습니다. (orderId: {}, transactionKey: {})", + orderId, callbackRequest.transactionKey()); + return; + } + + // 이미 완료되거나 취소된 주문인 경우 처리하지 않음 + if (order.getStatus() == OrderStatus.COMPLETED) { + log.info("이미 완료된 주문입니다. 콜백 처리를 건너뜁니다. (orderId: {}, transactionKey: {})", + orderId, callbackRequest.transactionKey()); + return; + } + + if (order.getStatus() == OrderStatus.CANCELED) { + log.info("이미 취소된 주문입니다. 콜백 처리를 건너뜁니다. (orderId: {}, transactionKey: {})", + orderId, callbackRequest.transactionKey()); + return; + } + + // 콜백 정보와 PG 원장 교차 검증 + // 보안 및 정합성을 위해 PG 조회 API로 실제 결제 상태 확인 + PaymentGatewayDto.TransactionStatus verifiedStatus = verifyCallbackWithPgInquiry( + order.getUserId(), orderId, callbackRequest); + + // OrderStatusUpdater를 사용하여 상태 업데이트 + boolean updated = orderStatusUpdater.updateByPaymentStatus( + orderId, + verifiedStatus, + callbackRequest.transactionKey(), + callbackRequest.reason() + ); + + if (updated) { + log.info("PG 결제 콜백 처리 완료 (PG 원장 검증 완료). (orderId: {}, transactionKey: {}, status: {})", + orderId, callbackRequest.transactionKey(), verifiedStatus); + } else { + log.warn("PG 결제 콜백 처리 실패. 주문 상태 업데이트에 실패했습니다. (orderId: {}, transactionKey: {}, status: {})", + orderId, callbackRequest.transactionKey(), verifiedStatus); + } + } catch (Exception e) { + log.error("콜백 처리 중 오류 발생. (orderId: {}, transactionKey: {})", + orderId, callbackRequest.transactionKey(), e); + throw e; // 콜백 실패는 재시도 가능하도록 예외를 다시 던짐 + } + } + + /** + * 콜백 정보를 PG 조회 API로 교차 검증합니다. + *

+ * 보안 및 정합성을 위해 콜백 정보를 직접 신뢰하지 않고, + * PG 원장(조회 API)을 기준으로 검증합니다. + *

+ *

+ * 검증 전략: + *

    + *
  • PG 조회 API로 실제 결제 상태 확인
  • + *
  • 콜백 정보와 PG 조회 결과 비교
  • + *
  • 불일치 시 PG 원장을 우선시하여 처리
  • + *
  • PG 조회 실패 시 콜백 정보를 사용하되 경고 로그 기록
  • + *
+ *

+ * + * @param userId 사용자 ID (Long) + * @param orderId 주문 ID + * @param callbackRequest 콜백 요청 정보 + * @return 검증된 결제 상태 (PG 원장 기준) + */ + private PaymentGatewayDto.TransactionStatus verifyCallbackWithPgInquiry( + Long userId, Long orderId, PaymentGatewayDto.CallbackRequest callbackRequest) { + + try { + // User의 userId (String)를 가져오기 위해 User 조회 + User user = userRepository.findById(userId); + if (user == null) { + log.warn("콜백 검증 시 사용자를 찾을 수 없습니다. 콜백 정보를 사용합니다. (orderId: {}, userId: {})", + orderId, userId); + return callbackRequest.status(); // 사용자를 찾을 수 없으면 콜백 정보 사용 + } + + String userIdString = user.getUserId(); + + // PG에서 주문별 결제 정보 조회 (스케줄러 전용 클라이언트 사용 - Retry 적용) + // 주문 ID를 6자리 이상 문자열로 변환 (pg-simulator 검증 요구사항) + String orderIdString = paymentRequestBuilder.formatOrderId(orderId); + PaymentGatewayDto.ApiResponse response = + paymentGatewaySchedulerClient.getTransactionsByOrder(userIdString, orderIdString); + + if (response == null || response.meta() == null + || response.meta().result() != PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS + || response.data() == null || response.data().transactions() == null + || response.data().transactions().isEmpty()) { + // PG 조회 실패: 콜백 정보를 사용하되 경고 로그 기록 + log.warn("콜백 검증 시 PG 조회 API 호출 실패. 콜백 정보를 사용합니다. (orderId: {}, transactionKey: {})", + orderId, callbackRequest.transactionKey()); + return callbackRequest.status(); + } + + // 가장 최근 트랜잭션의 상태 확인 (PG 원장 기준) + PaymentGatewayDto.TransactionResponse latestTransaction = + response.data().transactions().get(response.data().transactions().size() - 1); + + PaymentGatewayDto.TransactionStatus pgStatus = latestTransaction.status(); + PaymentGatewayDto.TransactionStatus callbackStatus = callbackRequest.status(); + + // 콜백 정보와 PG 조회 결과 비교 + if (pgStatus != callbackStatus) { + // 불일치 시 PG 원장을 우선시하여 처리 + log.warn("콜백 정보와 PG 원장이 불일치합니다. PG 원장을 우선시하여 처리합니다. " + + "(orderId: {}, transactionKey: {}, 콜백 상태: {}, PG 원장 상태: {})", + orderId, callbackRequest.transactionKey(), callbackStatus, pgStatus); + return pgStatus; // PG 원장 기준으로 처리 + } + + // 일치하는 경우: 정상 처리 + log.debug("콜백 정보와 PG 원장이 일치합니다. (orderId: {}, transactionKey: {}, 상태: {})", + orderId, callbackRequest.transactionKey(), pgStatus); + return pgStatus; + + } catch (FeignException e) { + // PG 조회 API 호출 실패: 콜백 정보를 사용하되 경고 로그 기록 + log.warn("콜백 검증 시 PG 조회 API 호출 중 Feign 예외 발생. 콜백 정보를 사용합니다. " + + "(orderId: {}, transactionKey: {}, status: {})", + orderId, callbackRequest.transactionKey(), e.status(), e); + return callbackRequest.status(); + } catch (Exception e) { + // 기타 예외: 콜백 정보를 사용하되 경고 로그 기록 + log.warn("콜백 검증 시 예상치 못한 오류 발생. 콜백 정보를 사용합니다. " + + "(orderId: {}, transactionKey: {})", + orderId, callbackRequest.transactionKey(), e); + return callbackRequest.status(); + } + } + + /** + * 결제 상태 확인 API를 통해 주문 상태를 복구합니다. + *

+ * 콜백이 오지 않았거나 타임아웃된 경우, PG 시스템의 결제 상태 확인 API를 호출하여 + * 실제 결제 상태를 확인하고 주문 상태를 업데이트합니다. + *

+ * + * @param userId 사용자 ID (String - PG API 요구사항) + * @param orderId 주문 ID + */ + @Transactional + public void recoverOrderStatusByPaymentCheck(String userId, Long orderId) { + try { + // PG에서 결제 상태 조회 + // 주문 ID를 6자리 이상 문자열로 변환 (pg-simulator 검증 요구사항) + String orderIdString = paymentRequestBuilder.formatOrderId(orderId); + PaymentGatewayDto.TransactionStatus status = + paymentGatewayAdapter.getPaymentStatus(userId, orderIdString); + + // OrderStatusUpdater를 사용하여 상태 업데이트 + boolean updated = orderStatusUpdater.updateByPaymentStatus(orderId, status, null, null); + + if (!updated) { + log.warn("상태 복구 실패. 주문 상태 업데이트에 실패했습니다. (orderId: {})", orderId); + } + + } catch (Exception e) { + log.error("상태 복구 중 오류 발생. (orderId: {})", orderId, e); + // 기타 오류도 로그만 기록 + } + } + } diff --git a/apps/commerce-api/src/main/java/com/loopers/config/Resilience4jRetryConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/Resilience4jRetryConfig.java new file mode 100644 index 000000000..90923e2f3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/Resilience4jRetryConfig.java @@ -0,0 +1,129 @@ +package com.loopers.config; + +import io.github.resilience4j.retry.RetryConfig; +import io.github.resilience4j.retry.RetryRegistry; +import io.github.resilience4j.core.IntervalFunction; +import lombok.extern.slf4j.Slf4j; +import feign.FeignException; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Duration; +import java.util.concurrent.TimeoutException; +import java.net.SocketTimeoutException; + +/** + * Resilience4j Retry 설정 커스터마이징. + *

+ * 실무 권장 패턴에 따라 메서드별로 다른 Retry 정책을 적용합니다: + *

+ *

+ * Retry 정책: + *

    + *
  • 결제 요청 API (requestPayment): Retry 없음 (유저 요청 경로 - 빠른 실패)
  • + *
  • 조회 API (getTransactionsByOrder, getTransaction): Exponential Backoff 적용 (스케줄러 - 안전)
  • + *
+ *

+ *

+ * Exponential Backoff 전략 (조회 API용): + *

    + *
  • 초기 대기 시간: 500ms
  • + *
  • 배수(multiplier): 2 (각 재시도마다 2배씩 증가)
  • + *
  • 최대 대기 시간: 5초 (너무 길어지지 않도록 제한)
  • + *
  • 랜덤 jitter: 활성화 (thundering herd 문제 방지)
  • + *
+ *

+ *

+ * 재시도 시퀀스 예시 (조회 API): + *

    + *
  1. 1차 시도: 즉시 실행
  2. + *
  3. 2차 시도: 500ms 후 (500ms * 2^0)
  4. + *
  5. 3차 시도: 1000ms 후 (500ms * 2^1)
  6. + *
+ *

+ *

+ * 설계 근거: + *

    + *
  • 유저 요청 경로: 긴 Retry는 스레드 점유 비용이 크므로 Retry 없이 빠르게 실패
  • + *
  • 스케줄러 경로: 비동기/배치 기반이므로 Retry가 안전하게 적용 가능 (Nice-to-Have 요구사항 충족)
  • + *
  • Eventually Consistent: 실패 시 주문은 PENDING 상태로 유지되어 스케줄러에서 복구
  • + *
  • 일시적 오류 복구: 네트워크 일시적 오류나 PG 서버 일시적 장애 시 자동 복구
  • + *
+ *

+ * + * @author Loopers + * @version 2.0 + */ +@Slf4j +@Configuration +public class Resilience4jRetryConfig { + + /** + * PaymentGatewayClient용 Retry 설정을 커스터마이징합니다. + *

+ * Exponential Backoff 전략을 적용하여 재시도 간격을 점진적으로 증가시킵니다. + *

+ * + * @return RetryRegistry (커스터마이징된 설정이 적용됨) + */ + @Bean + public RetryRegistry retryRegistry() { + RetryRegistry retryRegistry = io.github.resilience4j.retry.RetryRegistry.ofDefaults(); + // Exponential Backoff 설정 + // - 초기 대기 시간: 500ms + // - 배수: 2 (각 재시도마다 2배씩 증가) + // - 최대 대기 시간: 5초 + // - 랜덤 jitter: 활성화 (thundering herd 문제 방지) + IntervalFunction intervalFunction = IntervalFunction + .ofExponentialRandomBackoff( + Duration.ofMillis(500), // 초기 대기 시간 + 2.0, // 배수 (exponential multiplier) + Duration.ofSeconds(5) // 최대 대기 시간 + ); + + // RetryConfig 커스터마이징 + RetryConfig retryConfig = RetryConfig.custom() + .maxAttempts(3) // 최대 재시도 횟수 (초기 시도 포함) + .intervalFunction(intervalFunction) // Exponential Backoff 적용 + .retryOnException(throwable -> { + // 일시적 오류만 재시도: 5xx 서버 오류, 타임아웃, 네트워크 오류 + if (throwable instanceof FeignException feignException) { + int status = feignException.status(); + // 5xx 서버 오류만 재시도 + if (status >= 500 && status < 600) { + log.debug("재시도 대상 예외: FeignException (status: {})", status); + return true; + } + return false; + } + if (throwable instanceof SocketTimeoutException || + throwable instanceof TimeoutException) { + log.debug("재시도 대상 예외: {}", throwable.getClass().getSimpleName()); + return true; + } + return false; + }) + // ignoreExceptions는 사용하지 않음 + // retryOnException에서 5xx만 재시도하고 4xx는 제외하므로, + // 별도로 ignoreExceptions를 설정할 필요가 없음 + .build(); + + // 결제 요청 API: 유저 요청 경로에서 사용되므로 Retry 비활성화 (빠른 실패) + // 실패 시 주문은 PENDING 상태로 유지되어 스케줄러에서 복구됨 + RetryConfig noRetryConfig = RetryConfig.custom() + .maxAttempts(1) // 재시도 없음 (초기 시도만) + .build(); + retryRegistry.addConfiguration("paymentGatewayClient", noRetryConfig); + + // 스케줄러 전용 클라이언트: 비동기/배치 기반으로 Retry 적용 + // Exponential Backoff 적용하여 일시적 오류 자동 복구 + retryRegistry.addConfiguration("paymentGatewaySchedulerClient", retryConfig); + + log.info("Resilience4j Retry 설정 완료:"); + log.info(" - 결제 요청 API (paymentGatewayClient): Retry 없음 (유저 요청 경로 - 빠른 실패)"); + log.info(" - 조회 API (paymentGatewaySchedulerClient): Exponential Backoff 적용 (스케줄러 - 비동기/배치 기반 Retry)"); + + return retryRegistry; + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderCancellationService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderCancellationService.java new file mode 100644 index 000000000..643644749 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderCancellationService.java @@ -0,0 +1,106 @@ +package com.loopers.domain.order; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.user.Point; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 주문 취소 도메인 서비스. + *

+ * 주문 취소 및 리소스 원복을 처리합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class OrderCancellationService { + + private final OrderRepository orderRepository; + private final UserRepository userRepository; + private final ProductRepository productRepository; + + /** + * 주문을 취소하고 포인트를 환불하며 재고를 원복합니다. + *

+ * 동시성 제어: + *

    + *
  • 비관적 락 사용: 재고 원복 시 동시성 제어를 위해 findByIdForUpdate 사용
  • + *
  • Deadlock 방지: 상품 ID를 정렬하여 일관된 락 획득 순서 보장
  • + *
+ *

+ * + * @param order 주문 엔티티 + * @param user 사용자 엔티티 + */ + @Transactional + public void cancel(Order order, User user) { + if (order == null || user == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "취소할 주문과 사용자 정보는 필수입니다."); + } + + // ✅ Deadlock 방지: User 락을 먼저 획득하여 createOrder와 동일한 락 획득 순서 보장 + User lockedUser = userRepository.findByUserIdForUpdate(user.getUserId()); + if (lockedUser == null) { + throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다."); + } + + // ✅ Deadlock 방지: 상품 ID를 정렬하여 일관된 락 획득 순서 보장 + List sortedProductIds = order.getItems().stream() + .map(OrderItem::getProductId) + .distinct() + .sorted() + .toList(); + + // 정렬된 순서대로 상품 락 획득 (Deadlock 방지) + Map productMap = new HashMap<>(); + for (Long productId : sortedProductIds) { + Product product = productRepository.findByIdForUpdate(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", productId))); + productMap.put(productId, product); + } + + // OrderItem 순서대로 Product 리스트 생성 + List products = order.getItems().stream() + .map(item -> productMap.get(item.getProductId())) + .toList(); + + order.cancel(); + increaseStocksForOrderItems(order.getItems(), products); + lockedUser.receivePoint(Point.of((long) order.getTotalAmount())); + + products.forEach(productRepository::save); + userRepository.save(lockedUser); + orderRepository.save(order); + } + + private void increaseStocksForOrderItems(List items, List products) { + Map productMap = products.stream() + .collect(java.util.stream.Collectors.toMap(Product::getId, product -> product)); + + for (OrderItem item : items) { + Product product = productMap.get(item.getProductId()); + if (product == null) { + throw new CoreException(ErrorType.NOT_FOUND, + String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", item.getProductId())); + } + product.increaseStock(item.getQuantity()); + } + } +} + 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 index a6f9870dc..10b80fc16 100644 --- 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 @@ -34,6 +34,14 @@ public interface OrderRepository { * @return 해당 사용자의 주문 목록 */ List findAllByUserId(Long userId); + + /** + * 주문 상태로 주문 목록을 조회합니다. + * + * @param status 주문 상태 + * @return 해당 상태의 주문 목록 + */ + List findAllByStatus(OrderStatus status); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatusUpdater.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatusUpdater.java new file mode 100644 index 000000000..6e9abcc1f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatusUpdater.java @@ -0,0 +1,100 @@ +package com.loopers.domain.order; + +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import com.loopers.infrastructure.paymentgateway.PaymentGatewayDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** + * 주문 상태 업데이트 도메인 서비스. + *

+ * 결제 상태에 따라 주문 상태를 업데이트합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class OrderStatusUpdater { + + private final OrderRepository orderRepository; + private final UserRepository userRepository; + private final OrderCancellationService orderCancellationService; + + /** + * 결제 상태에 따라 주문 상태를 업데이트합니다. + *

+ * 별도 트랜잭션으로 실행하여 외부 시스템 호출과 독립적으로 처리합니다. + *

+ * + * @param orderId 주문 ID + * @param status 결제 상태 + * @param transactionKey 트랜잭션 키 + * @param reason 실패 사유 (실패 시) + * @return 업데이트 성공 여부 (true: 성공, false: 실패) + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public boolean updateByPaymentStatus( + Long orderId, + PaymentGatewayDto.TransactionStatus status, + String transactionKey, + String reason + ) { + try { + Order order = orderRepository.findById(orderId) + .orElse(null); + + if (order == null) { + log.warn("주문 상태 업데이트 시 주문을 찾을 수 없습니다. (orderId: {})", orderId); + return false; + } + + // 이미 완료되거나 취소된 주문인 경우 처리하지 않음 (정상적인 경우이므로 true 반환) + if (order.getStatus() == OrderStatus.COMPLETED) { + log.info("이미 완료된 주문입니다. 상태 업데이트를 건너뜁니다. (orderId: {})", orderId); + return true; + } + + if (order.getStatus() == OrderStatus.CANCELED) { + log.info("이미 취소된 주문입니다. 상태 업데이트를 건너뜁니다. (orderId: {})", orderId); + return true; + } + + if (status == PaymentGatewayDto.TransactionStatus.SUCCESS) { + // 결제 성공: 주문 완료 + order.complete(); + orderRepository.save(order); + log.info("결제 상태 확인 결과, 주문 상태를 COMPLETED로 업데이트했습니다. (orderId: {}, transactionKey: {})", + orderId, transactionKey); + return true; + } else if (status == PaymentGatewayDto.TransactionStatus.FAILED) { + // 결제 실패: 주문 취소 및 리소스 원복 + User user = userRepository.findById(order.getUserId()); + if (user == null) { + log.warn("주문 상태 업데이트 시 사용자를 찾을 수 없습니다. (orderId: {}, userId: {})", + orderId, order.getUserId()); + return false; + } + orderCancellationService.cancel(order, user); + log.info("결제 상태 확인 결과, 주문 상태를 CANCELED로 업데이트했습니다. (orderId: {}, transactionKey: {}, reason: {})", + orderId, transactionKey, reason); + return true; + } else { + // PENDING 상태: 아직 처리 중 (정상적인 경우이므로 true 반환) + log.info("결제 상태 확인 결과, 아직 처리 중입니다. 주문은 PENDING 상태로 유지됩니다. (orderId: {}, transactionKey: {})", + orderId, transactionKey); + return true; + } + } catch (Exception e) { + log.error("주문 상태 업데이트 중 오류 발생. (orderId: {})", orderId, e); + return false; + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/PaymentFailureClassifier.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/PaymentFailureClassifier.java new file mode 100644 index 000000000..804b9c123 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/PaymentFailureClassifier.java @@ -0,0 +1,74 @@ +package com.loopers.domain.order; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Set; + +/** + * 결제 실패 분류 도메인 서비스. + *

+ * 결제 실패를 비즈니스 실패와 외부 시스템 장애로 분류합니다. + *

+ *

+ * 비즈니스 실패 예시: + *

    + *
  • 카드 한도 초과 (LIMIT_EXCEEDED)
  • + *
  • 잘못된 카드 번호 (INVALID_CARD)
  • + *
  • 카드 오류 (CARD_ERROR)
  • + *
  • 잔액 부족 (INSUFFICIENT_FUNDS)
  • + *
+ *

+ *

+ * 외부 시스템 장애 예시: + *

    + *
  • CircuitBreaker Open (CIRCUIT_BREAKER_OPEN)
  • + *
  • 서버 오류 (5xx)
  • + *
  • 타임아웃
  • + *
  • 네트워크 오류
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Component +@RequiredArgsConstructor +public class PaymentFailureClassifier { + + private static final Set BUSINESS_FAILURE_CODES = Set.of( + "LIMIT_EXCEEDED", + "INVALID_CARD", + "CARD_ERROR", + "INSUFFICIENT_FUNDS", + "PAYMENT_FAILED" + ); + + private static final String CIRCUIT_BREAKER_OPEN = "CIRCUIT_BREAKER_OPEN"; + + /** + * 오류 코드를 기반으로 결제 실패 유형을 분류합니다. + * + * @param errorCode 오류 코드 + * @return 결제 실패 유형 + */ + public PaymentFailureType classify(String errorCode) { + if (errorCode == null) { + return PaymentFailureType.EXTERNAL_SYSTEM_FAILURE; + } + + // CircuitBreaker Open 상태는 명시적으로 외부 시스템 장애로 간주 + if (CIRCUIT_BREAKER_OPEN.equals(errorCode)) { + return PaymentFailureType.EXTERNAL_SYSTEM_FAILURE; + } + + // 명확한 비즈니스 실패 오류 코드만 취소 처리 + boolean isBusinessFailure = BUSINESS_FAILURE_CODES.stream() + .anyMatch(errorCode::contains); + + return isBusinessFailure + ? PaymentFailureType.BUSINESS_FAILURE + : PaymentFailureType.EXTERNAL_SYSTEM_FAILURE; + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/PaymentFailureType.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/PaymentFailureType.java new file mode 100644 index 000000000..2cc7f03af --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/PaymentFailureType.java @@ -0,0 +1,25 @@ +package com.loopers.domain.order; + +/** + * 결제 실패 유형. + *

+ * 결제 실패를 비즈니스 실패와 외부 시스템 장애로 구분합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public enum PaymentFailureType { + /** + * 비즈니스 실패: 주문 취소 필요 + * 예: 카드 한도 초과, 잘못된 카드 번호 등 + */ + BUSINESS_FAILURE, + + /** + * 외부 시스템 장애: 주문 PENDING 상태 유지 + * 예: CircuitBreaker Open, 서버 오류, 타임아웃 등 + */ + EXTERNAL_SYSTEM_FAILURE +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/PaymentResult.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/PaymentResult.java new file mode 100644 index 000000000..f1c953c70 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/PaymentResult.java @@ -0,0 +1,55 @@ +package com.loopers.domain.order; + +import java.util.function.Function; + +/** + * 결제 결과 도메인 모델. + *

+ * 결제 요청의 성공/실패 결과를 표현합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public sealed interface PaymentResult { + + /** + * 성공 결과. + */ + record Success(String transactionKey) implements PaymentResult { + } + + /** + * 실패 결과. + */ + record Failure( + String errorCode, + String message, + boolean isTimeout, + boolean isServerError, + boolean isClientError + ) implements PaymentResult { + } + + /** + * 결과에 따라 처리합니다. + * + * @param successHandler 성공 시 처리 함수 + * @param failureHandler 실패 시 처리 함수 + * @param 반환 타입 + * @return 처리 결과 + */ + default T handle( + Function successHandler, + Function failureHandler + ) { + if (this instanceof Success success) { + return successHandler.apply(success); + } else if (this instanceof Failure failure) { + return failureHandler.apply(failure); + } else { + throw new IllegalStateException("Unknown PaymentResult type: " + this.getClass()); + } + } +} + 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 09d47afe2..88ac6434c 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 @@ -44,4 +44,12 @@ public interface UserRepository { * @return 조회된 사용자, 없으면 null */ User findByUserIdForUpdate(String userId); + + /** + * 사용자 ID (PK)로 사용자를 조회합니다. + * + * @param id 사용자 ID (PK) + * @return 조회된 사용자, 없으면 null + */ + User findById(Long id); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java index 02808e69c..0c91bd190 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -10,6 +10,8 @@ */ public interface OrderJpaRepository extends JpaRepository { List findAllByUserId(Long userId); + + List findAllByStatus(com.loopers.domain.order.OrderStatus status); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java index 9440a7aa8..763d6e927 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -31,6 +31,11 @@ public Optional findById(Long orderId) { public List findAllByUserId(Long userId) { return orderJpaRepository.findAllByUserId(userId); } + + @Override + public List findAllByStatus(com.loopers.domain.order.OrderStatus status) { + return orderJpaRepository.findAllByStatus(status); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/DelayProvider.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/DelayProvider.java new file mode 100644 index 000000000..11cc69f71 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/DelayProvider.java @@ -0,0 +1,24 @@ +package com.loopers.infrastructure.paymentgateway; + +import java.time.Duration; + +/** + * 지연 제공자 인터페이스. + *

+ * 테스트 가능성을 위해 Thread.sleep을 추상화합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +public interface DelayProvider { + + /** + * 지정된 시간만큼 대기합니다. + * + * @param duration 대기 시간 + * @throws InterruptedException 인터럽트 발생 시 + */ + void delay(Duration duration) throws InterruptedException; +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayAdapter.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayAdapter.java new file mode 100644 index 000000000..2084f2139 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayAdapter.java @@ -0,0 +1,136 @@ +package com.loopers.infrastructure.paymentgateway; + +import com.loopers.application.purchasing.PaymentRequest; +import com.loopers.domain.order.PaymentResult; +import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * 결제 게이트웨이 어댑터. + *

+ * 인프라 관심사(FeignClient 호출, 예외 처리)를 도메인 모델로 변환합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PaymentGatewayAdapter { + + private final PaymentGatewayClient paymentGatewayClient; + private final PaymentGatewaySchedulerClient paymentGatewaySchedulerClient; + private final PaymentGatewayMetrics metrics; + + /** + * 결제 요청을 전송합니다. + * + * @param request 결제 요청 + * @return 결제 결과 (성공 또는 실패) + */ + @CircuitBreaker(name = "pgCircuit", fallbackMethod = "fallback") + public PaymentResult requestPayment(PaymentRequest request) { + PaymentGatewayDto.PaymentRequest dtoRequest = toDto(request); + PaymentGatewayDto.ApiResponse response = + paymentGatewayClient.requestPayment(request.userId(), dtoRequest); + + return toDomainResult(response, request.orderId()); + } + + /** + * Circuit Breaker fallback 메서드. + * + * @param request 결제 요청 + * @param t 발생한 예외 + * @return 결제 대기 상태의 실패 결과 + */ + public PaymentResult fallback(PaymentRequest request, Throwable t) { + log.warn("Circuit Breaker fallback 호출됨. (orderId: {}, exception: {})", + request.orderId(), t.getClass().getSimpleName(), t); + metrics.recordFallback("paymentGatewayClient"); + return new PaymentResult.Failure( + "CIRCUIT_BREAKER_OPEN", + "결제 대기 상태", + false, + false, + false + ); + } + + /** + * Circuit Breaker fallback 메서드 (결제 상태 조회). + * + * @param userId 사용자 ID + * @param orderId 주문 ID + * @param t 발생한 예외 + * @return PENDING 상태 반환 + */ + public PaymentGatewayDto.TransactionStatus getPaymentStatusFallback(String userId, String orderId, Throwable t) { + log.warn("Circuit Breaker fallback 호출됨 (결제 상태 조회). (orderId: {}, exception: {})", + orderId, t.getClass().getSimpleName(), t); + metrics.recordFallback("paymentGatewaySchedulerClient"); + return PaymentGatewayDto.TransactionStatus.PENDING; + } + + /** + * 결제 상태를 조회합니다. + * + * @param userId 사용자 ID + * @param orderId 주문 ID + * @return 결제 상태 (SUCCESS, FAILED, PENDING) + */ + @CircuitBreaker(name = "pgCircuit", fallbackMethod = "getPaymentStatusFallback") + public PaymentGatewayDto.TransactionStatus getPaymentStatus(String userId, String orderId) { + PaymentGatewayDto.ApiResponse response = + paymentGatewaySchedulerClient.getTransactionsByOrder(userId, orderId); + + if (response == null || response.meta() == null + || response.meta().result() != PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS + || response.data() == null || response.data().transactions() == null + || response.data().transactions().isEmpty()) { + return PaymentGatewayDto.TransactionStatus.PENDING; + } + + // 가장 최근 트랜잭션의 상태 반환 + PaymentGatewayDto.TransactionResponse latestTransaction = + response.data().transactions().get(response.data().transactions().size() - 1); + return latestTransaction.status(); + } + + private PaymentGatewayDto.PaymentRequest toDto(PaymentRequest request) { + return new PaymentGatewayDto.PaymentRequest( + request.orderId(), + request.cardType(), + request.cardNo(), + request.amount(), + request.callbackUrl() + ); + } + + private PaymentResult toDomainResult( + PaymentGatewayDto.ApiResponse response, + String orderId + ) { + if (response != null && response.meta() != null + && response.meta().result() == PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS + && response.data() != null) { + String transactionKey = response.data().transactionKey(); + log.info("PG 결제 요청 성공. (orderId: {}, transactionKey: {})", orderId, transactionKey); + metrics.recordSuccess("paymentGatewayClient"); + return new PaymentResult.Success(transactionKey); + } else { + String errorCode = response != null && response.meta() != null + ? response.meta().errorCode() : "UNKNOWN"; + String message = response != null && response.meta() != null + ? response.meta().message() : "응답이 null입니다."; + log.warn("PG 결제 요청 실패. (orderId: {}, errorCode: {}, message: {})", + orderId, errorCode, message); + return new PaymentResult.Failure(errorCode, message, false, false, false); + } + } + +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayClient.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayClient.java new file mode 100644 index 000000000..9357ec6d3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayClient.java @@ -0,0 +1,85 @@ +package com.loopers.infrastructure.paymentgateway; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; + +/** + * PG 결제 게이트웨이 FeignClient. + *

+ * CircuitBreaker, Bulkhead가 적용되어 있습니다. + *

+ *

+ * Bulkhead 패턴: + *

    + *
  • 동시 호출 최대 20개로 제한 (Building Resilient Distributed Systems: 격벽 패턴)
  • + *
  • PG 호출 실패가 다른 API에 영향을 주지 않도록 격리
  • + *
+ *

+ *

+ * Retry 정책: + *

    + *
  • 결제 요청 API (requestPayment): 5xx 서버 오류만 재시도, 4xx 클라이언트 오류는 재시도하지 않음
  • + *
  • 조회 API (getTransactionsByOrder, getTransaction): Exponential Backoff 적용 (스케줄러 - 비동기/배치 기반 Retry)
  • + *
+ *

+ *

+ * 설계 근거: + *

    + *
  • 5xx 서버 오류: 일시적 오류이므로 재시도하여 복구 가능
  • + *
  • 4xx 클라이언트 오류: 비즈니스 로직 오류이므로 재시도해도 성공하지 않음
  • + *
  • Eventually Consistent: 실패 시 주문은 PENDING 상태로 유지되어 스케줄러에서 복구
  • + *
+ *

+ */ +@FeignClient( + name = "paymentGatewayClient", + url = "${payment-gateway.url}", + path = "/api/v1/payments" +) +public interface PaymentGatewayClient { + + /** + * 결제 요청. + * + * @param userId 사용자 ID (X-USER-ID 헤더) + * @param request 결제 요청 정보 + * @return 결제 응답 + */ + @PostMapping + PaymentGatewayDto.ApiResponse requestPayment( + @RequestHeader("X-USER-ID") String userId, + @RequestBody PaymentGatewayDto.PaymentRequest request + ); + + /** + * 결제 정보 확인 (트랜잭션 키로 조회). + * + * @param userId 사용자 ID (X-USER-ID 헤더) + * @param transactionKey 트랜잭션 키 + * @return 결제 상세 정보 + */ + @GetMapping("/{transactionKey}") + PaymentGatewayDto.ApiResponse getTransaction( + @RequestHeader("X-USER-ID") String userId, + @PathVariable("transactionKey") String transactionKey + ); + + /** + * 주문에 엮인 결제 정보 조회. + * + * @param userId 사용자 ID (X-USER-ID 헤더) + * @param orderId 주문 ID + * @return 주문별 결제 목록 + */ + @GetMapping + PaymentGatewayDto.ApiResponse getTransactionsByOrder( + @RequestHeader("X-USER-ID") String userId, + @RequestParam("orderId") String orderId + ); +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayDto.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayDto.java new file mode 100644 index 000000000..812fbed96 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayDto.java @@ -0,0 +1,106 @@ +package com.loopers.infrastructure.paymentgateway; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * PG 결제 게이트웨이 DTO. + */ +public class PaymentGatewayDto { + + /** + * PG 결제 요청 DTO. + */ + public record PaymentRequest( + @JsonProperty("orderId") String orderId, + @JsonProperty("cardType") CardType cardType, + @JsonProperty("cardNo") String cardNo, + @JsonProperty("amount") Long amount, + @JsonProperty("callbackUrl") String callbackUrl + ) { + } + + /** + * PG 결제 응답 DTO. + */ + public record TransactionResponse( + @JsonProperty("transactionKey") String transactionKey, + @JsonProperty("status") TransactionStatus status, + @JsonProperty("reason") String reason + ) { + } + + /** + * PG 결제 상세 응답 DTO. + */ + public record TransactionDetailResponse( + @JsonProperty("transactionKey") String transactionKey, + @JsonProperty("orderId") String orderId, + @JsonProperty("cardType") CardType cardType, + @JsonProperty("cardNo") String cardNo, + @JsonProperty("amount") Long amount, + @JsonProperty("status") TransactionStatus status, + @JsonProperty("reason") String reason + ) { + } + + /** + * PG 주문별 결제 목록 응답 DTO. + */ + public record OrderResponse( + @JsonProperty("orderId") String orderId, + @JsonProperty("transactions") java.util.List transactions + ) { + } + + /** + * 카드 타입. + */ + public enum CardType { + SAMSUNG, + KB, + HYUNDAI + } + + /** + * 거래 상태. + */ + public enum TransactionStatus { + PENDING, + SUCCESS, + FAILED + } + + /** + * PG 콜백 요청 DTO (PG에서 보내는 TransactionInfo). + */ + public record CallbackRequest( + @JsonProperty("transactionKey") String transactionKey, + @JsonProperty("orderId") String orderId, + @JsonProperty("cardType") CardType cardType, + @JsonProperty("cardNo") String cardNo, + @JsonProperty("amount") Long amount, + @JsonProperty("status") TransactionStatus status, + @JsonProperty("reason") String reason + ) { + } + + /** + * PG API 응답 래퍼. + */ + public record ApiResponse( + @JsonProperty("meta") Metadata meta, + @JsonProperty("data") T data + ) { + public record Metadata( + @JsonProperty("result") Result result, + @JsonProperty("errorCode") String errorCode, + @JsonProperty("message") String message + ) { + public enum Result { + SUCCESS, + FAIL + } + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayMetrics.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayMetrics.java new file mode 100644 index 000000000..57c97df10 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayMetrics.java @@ -0,0 +1,86 @@ +package com.loopers.infrastructure.paymentgateway; + +import io.micrometer.core.instrument.MeterRegistry; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * 결제 게이트웨이 메트릭. + *

+ * PG 서버 오류, 타임아웃, Fallback 등의 이벤트를 Prometheus 메트릭으로 기록합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@Component +@RequiredArgsConstructor +public class PaymentGatewayMetrics { + + private final MeterRegistry meterRegistry; + + /** + * PG 서버 오류(5xx) 발생 횟수를 기록합니다. + * + * @param clientName 클라이언트 이름 (paymentGatewayClient, paymentGatewaySchedulerClient) + * @param status HTTP 상태 코드 + */ + public void recordServerError(String clientName, int status) { + meterRegistry.counter( + "payment.gateway.server.error", + "client", clientName, + "status", String.valueOf(status) + ).increment(); + } + + /** + * PG 타임아웃 발생 횟수를 기록합니다. + * + * @param clientName 클라이언트 이름 + */ + public void recordTimeout(String clientName) { + meterRegistry.counter( + "payment.gateway.timeout", + "client", clientName + ).increment(); + } + + /** + * PG 클라이언트 오류(4xx) 발생 횟수를 기록합니다. + * + * @param clientName 클라이언트 이름 + * @param status HTTP 상태 코드 + */ + public void recordClientError(String clientName, int status) { + meterRegistry.counter( + "payment.gateway.client.error", + "client", clientName, + "status", String.valueOf(status) + ).increment(); + } + + /** + * Fallback 호출 횟수를 기록합니다. + * + * @param clientName 클라이언트 이름 + */ + public void recordFallback(String clientName) { + meterRegistry.counter( + "payment.gateway.fallback", + "client", clientName + ).increment(); + } + + /** + * PG 결제 요청 성공 횟수를 기록합니다. + * + * @param clientName 클라이언트 이름 + */ + public void recordSuccess(String clientName) { + meterRegistry.counter( + "payment.gateway.request.success", + "client", clientName + ).increment(); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewaySchedulerClient.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewaySchedulerClient.java new file mode 100644 index 000000000..44d693912 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewaySchedulerClient.java @@ -0,0 +1,70 @@ +package com.loopers.infrastructure.paymentgateway; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; + +/** + * PG 결제 게이트웨이 FeignClient (스케줄러 전용). + *

+ * 스케줄러에서 사용하는 조회 API에 Retry를 적용합니다. + *

+ *

+ * Retry 정책: + *

    + *
  • Exponential Backoff 적용: 초기 500ms → 1000ms (최대 5초)
  • + *
  • 최대 재시도 횟수: 3회 (초기 시도 포함)
  • + *
  • 재시도 대상: 5xx 서버 오류, 타임아웃, 네트워크 오류
  • + *
+ *

+ *

+ * 설계 근거: + *

    + *
  • 비동기/배치 기반: 스케줄러는 배치 작업이므로 Retry가 안전하게 적용 가능
  • + *
  • 일시적 오류 복구: 네트워크 일시적 오류나 PG 서버 일시적 장애 시 자동 복구
  • + *
  • 유저 요청 스레드 점유 없음: 스케줄러 스레드에서 실행되므로 유저 경험에 영향 없음
  • + *
+ *

+ */ +@FeignClient( + name = "paymentGatewaySchedulerClient", + url = "${payment-gateway.url}", + path = "/api/v1/payments" +) +public interface PaymentGatewaySchedulerClient { + + /** + * 결제 정보 확인 (트랜잭션 키로 조회). + *

+ * 스케줄러에서 사용하며, Retry가 적용됩니다. + *

+ * + * @param userId 사용자 ID (X-USER-ID 헤더) + * @param transactionKey 트랜잭션 키 + * @return 결제 상세 정보 + */ + @GetMapping("/{transactionKey}") + PaymentGatewayDto.ApiResponse getTransaction( + @RequestHeader("X-USER-ID") String userId, + @PathVariable("transactionKey") String transactionKey + ); + + /** + * 주문에 엮인 결제 정보 조회. + *

+ * 스케줄러에서 사용하며, Retry가 적용됩니다. + *

+ * + * @param userId 사용자 ID (X-USER-ID 헤더) + * @param orderId 주문 ID + * @return 주문별 결제 목록 + */ + @GetMapping + PaymentGatewayDto.ApiResponse getTransactionsByOrder( + @RequestHeader("X-USER-ID") String userId, + @RequestParam("orderId") String orderId + ); +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/ThreadDelayProvider.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/ThreadDelayProvider.java new file mode 100644 index 000000000..803a6f304 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/ThreadDelayProvider.java @@ -0,0 +1,21 @@ +package com.loopers.infrastructure.paymentgateway; + +import org.springframework.stereotype.Component; + +import java.time.Duration; + +/** + * Thread.sleep을 사용하는 DelayProvider 구현체. + * + * @author Loopers + * @version 1.0 + */ +@Component +public class ThreadDelayProvider implements DelayProvider { + + @Override + public void delay(Duration duration) throws InterruptedException { + Thread.sleep(duration.toMillis()); + } +} + 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 62d2512cf..defb715e9 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 @@ -43,4 +43,12 @@ public User findByUserId(String userId) { public User findByUserIdForUpdate(String userId) { return userJpaRepository.findByUserIdForUpdate(userId).orElse(null); } + + /** + * {@inheritDoc} + */ + @Override + public User findById(Long id) { + return userJpaRepository.findById(id).orElse(null); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiResponse.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiResponse.java index 33b77b529..0eba8be22 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiResponse.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiResponse.java @@ -19,6 +19,10 @@ public static ApiResponse success() { return new ApiResponse<>(Metadata.success(), null); } + public static ApiResponse successVoid() { + return new ApiResponse<>(Metadata.success(), null); + } + public static ApiResponse success(T data) { return new ApiResponse<>(Metadata.success(), data); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Controller.java index 744ca1b15..fcea74ac2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Controller.java @@ -2,6 +2,7 @@ import com.loopers.application.purchasing.OrderInfo; import com.loopers.application.purchasing.PurchasingFacade; +import com.loopers.infrastructure.paymentgateway.PaymentGatewayDto; import com.loopers.interfaces.api.ApiResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -37,7 +38,12 @@ public ApiResponse createOrder( @RequestHeader("X-USER-ID") String userId, @Valid @RequestBody PurchasingV1Dto.CreateRequest request ) { - OrderInfo orderInfo = purchasingFacade.createOrder(userId, request.toCommands()); + OrderInfo orderInfo = purchasingFacade.createOrder( + userId, + request.toCommands(), + request.payment().cardType(), + request.payment().cardNo() + ); return ApiResponse.success(PurchasingV1Dto.OrderResponse.from(orderInfo)); } @@ -70,6 +76,38 @@ public ApiResponse getOrder( OrderInfo orderInfo = purchasingFacade.getOrder(userId, orderId); return ApiResponse.success(PurchasingV1Dto.OrderResponse.from(orderInfo)); } + + /** + * PG 결제 콜백을 처리합니다. + * + * @param orderId 주문 ID + * @param callbackRequest 콜백 요청 정보 + * @return 성공 응답 + */ + @PostMapping("/{orderId}/callback") + public ApiResponse handlePaymentCallback( + @PathVariable Long orderId, + @RequestBody PaymentGatewayDto.CallbackRequest callbackRequest + ) { + purchasingFacade.handlePaymentCallback(orderId, callbackRequest); + return ApiResponse.successVoid(); + } + + /** + * 결제 상태 확인 API를 통해 주문 상태를 복구합니다. + * + * @param userId X-USER-ID 헤더 + * @param orderId 주문 ID + * @return 성공 응답 + */ + @PostMapping("/{orderId}/recover") + public ApiResponse recoverOrderStatus( + @RequestHeader("X-USER-ID") String userId, + @PathVariable Long orderId + ) { + purchasingFacade.recoverOrderStatusByPaymentCheck(userId, orderId); + return ApiResponse.successVoid(); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Dto.java index ce278fc49..e1307ca42 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Dto.java @@ -21,7 +21,8 @@ private PurchasingV1Dto() { */ public record CreateRequest( @NotEmpty(message = "주문 상품은 1개 이상이어야 합니다.") - List<@Valid ItemRequest> items + List<@Valid ItemRequest> items, + @Valid PaymentRequest payment ) { public List toCommands() { return items.stream() @@ -30,6 +31,17 @@ public List toCommands() { } } + /** + * 결제 정보 요청 DTO. + */ + public record PaymentRequest( + @NotNull(message = "카드 타입은 필수입니다.") + String cardType, + @NotNull(message = "카드 번호는 필수입니다.") + String cardNo + ) { + } + /** * 주문 생성 요청 아이템 DTO. */ diff --git a/apps/commerce-api/src/main/resources/application.yml b/apps/commerce-api/src/main/resources/application.yml index 0f9239776..f8971a2f0 100644 --- a/apps/commerce-api/src/main/resources/application.yml +++ b/apps/commerce-api/src/main/resources/application.yml @@ -29,6 +29,108 @@ spring: job: enabled: false # 스케줄러에서 수동 실행하므로 자동 실행 비활성화 +payment-gateway: + url: http://localhost:8082 + +payment: + callback: + base-url: ${PAYMENT_CALLBACK_BASE_URL:http://localhost:8080} + +feign: + client: + config: + default: + connectTimeout: 2000 # 연결 타임아웃 (2초) + readTimeout: 6000 # 읽기 타임아웃 (6초) - PG 처리 지연 1s~5s 고려 + paymentGatewayClient: + connectTimeout: 2000 # 연결 타임아웃 (2초) + readTimeout: 6000 # 읽기 타임아웃 (6초) - PG 처리 지연 1s~5s 고려 + loggerLevel: full # 로깅 레벨 (디버깅용) + paymentGatewaySchedulerClient: + connectTimeout: 2000 # 연결 타임아웃 (2초) + readTimeout: 6000 # 읽기 타임아웃 (6초) - PG 처리 지연 1s~5s 고려 + loggerLevel: full # 로깅 레벨 (디버깅용) + circuitbreaker: + enabled: false # FeignClient 자동 Circuit Breaker 비활성화 (어댑터 레벨에서 pgCircuit 사용) + resilience4j: + enabled: true # Resilience4j 활성화 + +resilience4j: + circuitbreaker: + configs: + default: + registerHealthIndicator: true + slidingWindowSize: 20 # 슬라이딩 윈도우 크기 (Building Resilient Distributed Systems 권장: 20~50) + minimumNumberOfCalls: 1 # 최소 호출 횟수 (첫 호출부터 통계 수집하여 메트릭 즉시 노출) + permittedNumberOfCallsInHalfOpenState: 3 # Half-Open 상태에서 허용되는 호출 수 + automaticTransitionFromOpenToHalfOpenEnabled: true # 자동으로 Half-Open으로 전환 + waitDurationInOpenState: 10s # Open 상태 유지 시간 (10초 후 Half-Open으로 전환) + failureRateThreshold: 50 # 실패율 임계값 (50% 이상 실패 시 Open) + slowCallRateThreshold: 50 # 느린 호출 비율 임계값 (50% 이상 느리면 Open) - Release It! 권장: 50~70% + slowCallDurationThreshold: ${RESILIENCE4J_CIRCUITBREAKER_SCHEDULER_SLOW_CALL_DURATION_THRESHOLD:2s} # 느린 호출 기준 시간 (2초 이상) - Building Resilient Distributed Systems 권장: 2s (환경 변수로 동적 설정 가능) + recordExceptions: + - feign.FeignException + - feign.FeignException$InternalServerError + - feign.FeignException$ServiceUnavailable + - feign.FeignException$GatewayTimeout + - feign.FeignException$BadGateway + - java.net.SocketTimeoutException + - java.util.concurrent.TimeoutException + ignoreExceptions: [] # 모든 예외를 기록 (무시할 예외 없음) + instances: + pgCircuit: + baseConfig: default + slidingWindowSize: 20 # Building Resilient Distributed Systems 권장: 20 (과제 권장값) + minimumNumberOfCalls: 1 # 첫 호출부터 통계 수집하여 메트릭 즉시 노출 + waitDurationInOpenState: 10s + failureRateThreshold: 50 + slowCallRateThreshold: 50 # 느린 호출 비율 임계값 (50% 이상 느리면 Open) - Release It! 권장: 50~70% + slowCallDurationThreshold: ${RESILIENCE4J_CIRCUITBREAKER_PAYMENT_GATEWAY_SLOW_CALL_DURATION_THRESHOLD:2s} # 느린 호출 기준 시간 (2초 이상) - Building Resilient Distributed Systems 권장: 2s (환경 변수로 동적 설정 가능) + retry: + configs: + default: + maxAttempts: 3 # 최대 재시도 횟수 (초기 시도 포함) + waitDuration: 500ms # 재시도 대기 시간 (기본값, paymentGatewayClient는 Java Config에서 Exponential Backoff 적용) + retryExceptions: + # 일시적 오류만 재시도: 5xx 서버 오류, 타임아웃, 네트워크 오류 + - feign.FeignException$InternalServerError # 500 에러 + - feign.FeignException$ServiceUnavailable # 503 에러 + - feign.FeignException$GatewayTimeout # 504 에러 + - java.net.SocketTimeoutException + - java.util.concurrent.TimeoutException + ignoreExceptions: + # 클라이언트 오류(4xx)는 재시도하지 않음: 비즈니스 로직 오류이므로 재시도해도 성공하지 않음 + - feign.FeignException$BadRequest # 400 에러 + - feign.FeignException$Unauthorized # 401 에러 + - feign.FeignException$Forbidden # 403 에러 + - feign.FeignException$NotFound # 404 에러 + timelimiter: + configs: + default: + timeoutDuration: 6s # 타임아웃 시간 (Feign readTimeout과 동일) + cancelRunningFuture: true # 실행 중인 Future 취소 + instances: + paymentGatewayClient: + baseConfig: default + timeoutDuration: 6s + paymentGatewaySchedulerClient: + baseConfig: default + timeoutDuration: 6s + bulkhead: + configs: + default: + maxConcurrentCalls: 20 # 동시 호출 최대 수 (Building Resilient Distributed Systems: 격벽 패턴) + maxWaitDuration: 5s # 대기 시간 (5초 초과 시 BulkheadFullException 발생) + instances: + paymentGatewayClient: + baseConfig: default + maxConcurrentCalls: 20 # PG 호출용 전용 격벽: 동시 호출 최대 20개로 제한 + maxWaitDuration: 5s + paymentGatewaySchedulerClient: + baseConfig: default + maxConcurrentCalls: 10 # 스케줄러용 격벽: 동시 호출 최대 10개로 제한 (배치 작업이므로 더 보수적) + maxWaitDuration: 5s + springdoc: use-fqn: true swagger-ui: diff --git a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeCircuitBreakerTest.java b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeCircuitBreakerTest.java new file mode 100644 index 000000000..72b6052a8 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeCircuitBreakerTest.java @@ -0,0 +1,711 @@ +package com.loopers.application.purchasing; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderRepository; +import com.loopers.infrastructure.order.OrderJpaRepository; +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.user.Gender; +import com.loopers.domain.user.Point; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import com.loopers.infrastructure.paymentgateway.PaymentGatewayClient; +import com.loopers.infrastructure.paymentgateway.PaymentGatewayDto; +import com.loopers.utils.DatabaseCleanUp; +import io.github.resilience4j.circuitbreaker.CircuitBreaker; +import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import feign.FeignException; +import feign.Request; + +import java.util.Collections; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +/** + * PurchasingFacade 서킷 브레이커 테스트. + *

+ * 서킷 브레이커의 동작을 검증합니다. + * - CLOSED → OPEN 전환 (실패율 임계값 초과) + * - OPEN → HALF_OPEN 전환 (일정 시간 후) + * - HALF_OPEN → CLOSED 전환 (성공 시) + * - HALF_OPEN → OPEN 전환 (실패 시) + * - 서킷 브레이커 OPEN 상태에서 Fallback 동작 + *

+ */ +@SpringBootTest +@ActiveProfiles("test") +@DisplayName("PurchasingFacade 서킷 브레이커 테스트") +class PurchasingFacadeCircuitBreakerTest { + + @Autowired + private PurchasingFacade purchasingFacade; + + @Autowired + private UserRepository userRepository; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private BrandRepository brandRepository; + + @Autowired + private OrderRepository orderRepository; + + @Autowired + private OrderJpaRepository orderJpaRepository; + + @MockitoBean + private PaymentGatewayClient paymentGatewayClient; + + @Autowired + private CircuitBreakerRegistry circuitBreakerRegistry; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + // 서킷 브레이커 상태 초기화 + if (circuitBreakerRegistry != null) { + circuitBreakerRegistry.getAllCircuitBreakers().forEach(cb -> cb.reset()); + } + } + + private User createAndSaveUser(String userId, String email, long point) { + User user = User.of(userId, email, "1990-01-01", Gender.MALE, Point.of(point)); + return userRepository.save(user); + } + + private Brand createAndSaveBrand(String brandName) { + Brand brand = Brand.of(brandName); + return brandRepository.save(brand); + } + + private Product createAndSaveProduct(String productName, int price, int stock, Long brandId) { + Product product = Product.of(productName, price, stock, brandId); + return productRepository.save(product); + } + + @Test + @DisplayName("PG 연속 실패 시 서킷 브레이커가 CLOSED에서 OPEN으로 전환된다") + void createOrder_consecutiveFailures_circuitBreakerOpens() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + Brand brand = createAndSaveBrand("브랜드"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + + List commands = List.of( + OrderItemCommand.of(product.getId(), 1) + ); + + // PG 연속 실패 시뮬레이션 + when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class))) + .thenThrow(new FeignException.ServiceUnavailable( + "Service unavailable", + Request.create(Request.HttpMethod.POST, "/api/v1/payments", Collections.emptyMap(), null, null, null), + null, + Collections.emptyMap() + )); + + // CircuitBreaker를 리셋하여 초기 상태로 만듦 + if (circuitBreakerRegistry != null) { + CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit"); + if (circuitBreaker != null) { + circuitBreaker.reset(); + } + } + + // act + // 서킷 브레이커 설정: minimumNumberOfCalls=1, failureRateThreshold=50% + // 첫 번째 호출이 실패하면 100% 실패율이 되어 임계값 50%를 초과하므로 OPEN 상태로 전환됨 + // 따라서 첫 번째 호출만 실제로 PaymentGatewayClient를 호출하고, + // 이후 호출들은 서킷 브레이커가 OPEN 상태이므로 fallback이 호출됨 + int numberOfCalls = 5; + for (int i = 0; i < numberOfCalls; i++) { + purchasingFacade.createOrder( + user.getUserId(), + commands, + "SAMSUNG", + "4111-1111-1111-1111" + ); + } + + // assert + // 재시도 정책에 따라 5xx 에러는 최대 3번까지 재시도됨 (maxAttempts: 3) + // 첫 번째 createOrder 호출에서 재시도가 일어나면서 최대 3번 호출될 수 있음 + // Circuit Breaker가 OPEN 상태가 되면 이후 호출들은 fallback이 호출되어 실제 호출되지 않음 + verify(paymentGatewayClient, atMost(3)) + .requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)); + + // 서킷 브레이커 상태 확인 + if (circuitBreakerRegistry != null) { + CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit"); + if (circuitBreaker != null) { + // 연속 실패 후에는 OPEN 상태여야 함 + // 첫 번째 호출이 실패하면 100% 실패율이 되어 임계값 50%를 초과하므로 OPEN 상태로 전환됨 + assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.OPEN); + } + } + } + + @Test + @DisplayName("서킷 브레이커가 OPEN 상태일 때 Fallback이 동작한다") + void createOrder_circuitBreakerOpen_fallbackExecuted() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + Brand brand = createAndSaveBrand("브랜드"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + + List commands = List.of( + OrderItemCommand.of(product.getId(), 1) + ); + + // 서킷 브레이커를 OPEN 상태로 만듦 + if (circuitBreakerRegistry != null) { + CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit"); + if (circuitBreaker != null) { + circuitBreaker.transitionToOpenState(); + } + } + + // act + purchasingFacade.createOrder( + user.getUserId(), + commands, + "SAMSUNG", + "4111-1111-1111-1111" + ); + + // assert + // 서킷 브레이커가 OPEN 상태일 때는 PG API가 호출되지 않아야 함 (Fallback 동작) + verify(paymentGatewayClient, never()) + .requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)); + } + + @Test + @DisplayName("서킷 브레이커가 HALF_OPEN 상태에서 성공 시 CLOSED로 전환된다") + void createOrder_circuitBreakerHalfOpen_success_transitionsToClosed() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + Brand brand = createAndSaveBrand("브랜드"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + + List commands = List.of( + OrderItemCommand.of(product.getId(), 1) + ); + + // 서킷 브레이커를 HALF_OPEN 상태로 만듦 + // 서킷 브레이커는 CLOSED → OPEN → HALF_OPEN 순서로만 전환 가능 + if (circuitBreakerRegistry != null) { + CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit"); + if (circuitBreaker != null) { + // 먼저 OPEN 상태로 전환 + circuitBreaker.transitionToOpenState(); + // 그 다음 HALF_OPEN 상태로 전환 + circuitBreaker.transitionToHalfOpenState(); + } + } + + // PG 성공 응답 + PaymentGatewayDto.ApiResponse successResponse = + new PaymentGatewayDto.ApiResponse<>( + new PaymentGatewayDto.ApiResponse.Metadata( + PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS, + null, + null + ), + new PaymentGatewayDto.TransactionResponse( + "TXN123456", + PaymentGatewayDto.TransactionStatus.SUCCESS, + null + ) + ); + when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class))) + .thenReturn(successResponse); + + // act + purchasingFacade.createOrder( + user.getUserId(), + commands, + "SAMSUNG", + "4111-1111-1111-1111" + ); + + // assert + // 서킷 브레이커 상태가 CLOSED로 전환되었는지 확인 + if (circuitBreakerRegistry != null) { + CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit"); + if (circuitBreaker != null) { + // 성공 시 CLOSED로 전환될 수 있음 + assertThat(circuitBreaker.getState()).isIn( + CircuitBreaker.State.CLOSED, + CircuitBreaker.State.HALF_OPEN + ); + } + } + } + + @Test + @DisplayName("서킷 브레이커가 HALF_OPEN 상태에서 실패 시 OPEN으로 전환된다") + void createOrder_circuitBreakerHalfOpen_failure_transitionsToOpen() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + Brand brand = createAndSaveBrand("브랜드"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + + List commands = List.of( + OrderItemCommand.of(product.getId(), 1) + ); + + // 서킷 브레이커를 HALF_OPEN 상태로 만듦 + // 서킷 브레이커는 CLOSED → OPEN → HALF_OPEN 순서로만 전환 가능 + if (circuitBreakerRegistry != null) { + CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit"); + if (circuitBreaker != null) { + // 먼저 OPEN 상태로 전환 + circuitBreaker.transitionToOpenState(); + // 그 다음 HALF_OPEN 상태로 전환 + circuitBreaker.transitionToHalfOpenState(); + } + } + + // PG 실패 응답 + when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class))) + .thenThrow(new FeignException.ServiceUnavailable( + "Service unavailable", + Request.create(Request.HttpMethod.POST, "/api/v1/payments", Collections.emptyMap(), null, null, null), + null, + Collections.emptyMap() + )); + + // act + OrderInfo orderInfo = purchasingFacade.createOrder( + user.getUserId(), + commands, + "SAMSUNG", + "4111-1111-1111-1111" + ); + + // assert + assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING); + + // 서킷 브레이커 상태가 OPEN으로 전환되었는지 확인 + if (circuitBreakerRegistry != null) { + CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit"); + if (circuitBreaker != null) { + // HALF_OPEN 상태에서 실패 시 OPEN으로 전환되어야 함 + assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.OPEN); + } + } + } + + @Test + @DisplayName("서킷 브레이커가 OPEN 상태일 때도 내부 시스템은 정상적으로 응답한다") + void createOrder_circuitBreakerOpen_internalSystemRespondsNormally() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + Brand brand = createAndSaveBrand("브랜드"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + + List commands = List.of( + OrderItemCommand.of(product.getId(), 1) + ); + + // 서킷 브레이커를 OPEN 상태로 만듦 + if (circuitBreakerRegistry != null) { + CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit"); + if (circuitBreaker != null) { + circuitBreaker.transitionToOpenState(); + } + } + + // act + OrderInfo orderInfo = purchasingFacade.createOrder( + user.getUserId(), + commands, + "SAMSUNG", + "4111-1111-1111-1111" + ); + + // assert + // 내부 시스템은 정상적으로 응답해야 함 (예외가 발생하지 않아야 함) + assertThat(orderInfo).isNotNull(); + assertThat(orderInfo.orderId()).isNotNull(); + assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING); + + // 재고와 포인트는 정상적으로 차감되어야 함 + Product savedProduct = productRepository.findById(product.getId()).orElseThrow(); + assertThat(savedProduct.getStock()).isEqualTo(9); + + User savedUser = userRepository.findByUserId(user.getUserId()); + assertThat(savedUser.getPoint().getValue()).isEqualTo(40_000L); + } + + @Test + @DisplayName("Fallback 응답의 CIRCUIT_BREAKER_OPEN 에러 코드가 올바르게 처리되어 주문이 PENDING 상태로 유지된다") + void createOrder_fallbackResponseWithCircuitBreakerOpen_orderRemainsPending() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + Brand brand = createAndSaveBrand("브랜드"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + + List commands = List.of( + OrderItemCommand.of(product.getId(), 1) + ); + + // 서킷 브레이커를 OPEN 상태로 만들어 Fallback이 호출되도록 함 + if (circuitBreakerRegistry != null) { + CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit"); + if (circuitBreaker != null) { + circuitBreaker.transitionToOpenState(); + } + } + + // Fallback이 CIRCUIT_BREAKER_OPEN 에러 코드를 반환하도록 Mock 설정 + // (실제로는 PaymentGatewayClientFallback이 호출되지만, 테스트를 위해 명시적으로 설정) + PaymentGatewayDto.ApiResponse fallbackResponse = + new PaymentGatewayDto.ApiResponse<>( + new PaymentGatewayDto.ApiResponse.Metadata( + PaymentGatewayDto.ApiResponse.Metadata.Result.FAIL, + "CIRCUIT_BREAKER_OPEN", + "PG 서비스가 일시적으로 사용할 수 없습니다. 잠시 후 다시 시도해주세요." + ), + null + ); + when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class))) + .thenReturn(fallbackResponse); + + // act + OrderInfo orderInfo = purchasingFacade.createOrder( + user.getUserId(), + commands, + "SAMSUNG", + "4111-1111-1111-1111" + ); + + // assert + // CIRCUIT_BREAKER_OPEN은 외부 시스템 장애로 간주되어 주문이 PENDING 상태로 유지되어야 함 + assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING); + + // 주문이 저장되었는지 확인 + Order savedOrder = orderRepository.findById(orderInfo.orderId()).orElseThrow(); + assertThat(savedOrder.getStatus()).isEqualTo(OrderStatus.PENDING); + + // 비즈니스 실패 처리(주문 취소)가 호출되지 않았는지 확인 + // 주문이 CANCELED 상태가 아니어야 함 + assertThat(savedOrder.getStatus()).isNotEqualTo(OrderStatus.CANCELED); + } + + @Test + @DisplayName("Retry 실패 후 CircuitBreaker가 OPEN 상태가 되어 Fallback이 호출된다") + void createOrder_retryFailure_circuitBreakerOpens_fallbackExecuted() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 100_000L); + Brand brand = createAndSaveBrand("브랜드"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + + List commands = List.of( + OrderItemCommand.of(product.getId(), 1) + ); + + // 모든 재시도가 실패하도록 설정 (5xx 서버 오류) + when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class))) + .thenThrow(new FeignException.InternalServerError( + "Internal Server Error", + Request.create(Request.HttpMethod.POST, "/api/v1/payments", Collections.emptyMap(), null, null, null), + null, + Collections.emptyMap() + )); + + // CircuitBreaker를 리셋하여 초기 상태로 만듦 + if (circuitBreakerRegistry != null) { + CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit"); + if (circuitBreaker != null) { + circuitBreaker.reset(); + } + } + + // act + // 서킷 브레이커 설정: minimumNumberOfCalls=1, failureRateThreshold=50% + // 첫 번째 호출이 실패하면 100% 실패율이 되어 임계값 50%를 초과하므로 OPEN 상태로 전환됨 + // 따라서 첫 번째 호출만 실제로 PaymentGatewayClient를 호출하고 (재시도 포함하여 3번), + // 이후 호출들은 서킷 브레이커가 OPEN 상태이므로 fallback이 호출되어 실제 호출되지 않음 + int numberOfCalls = 6; // 여러 번 호출하여 서킷 브레이커 동작 확인 + for (int i = 0; i < numberOfCalls; i++) { + purchasingFacade.createOrder( + user.getUserId(), + commands, + "SAMSUNG", + "4111-1111-1111-1111" + ); + } + + // assert + // 재시도 정책에 따라 5xx 에러는 최대 3번까지 재시도됨 (maxAttempts: 3) + // 첫 번째 createOrder 호출에서 재시도가 일어나면서 최대 3번 호출될 수 있음 + // Circuit Breaker가 OPEN 상태가 되면 이후 호출들은 fallback이 호출되어 실제 호출되지 않음 + verify(paymentGatewayClient, atMost(3)) + .requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)); + + // CircuitBreaker 상태 확인 + if (circuitBreakerRegistry != null) { + CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit"); + if (circuitBreaker != null) { + // 실패율이 임계값을 초과했으므로 OPEN 상태로 전환되어야 함 + // 첫 번째 호출이 실패하면 100% 실패율이 되어 임계값 50%를 초과하므로 OPEN 상태로 전환됨 + assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.OPEN); + } + } + + // 모든 주문이 PENDING 상태로 생성되었는지 확인 + // Circuit Breaker가 언제 OPEN 상태로 전환될지 정확히 예측하기 어려우므로, + // 최소 1개 이상의 주문이 생성되었는지 확인 + List orders = orderJpaRepository.findAll(); + assertThat(orders.size()).isGreaterThanOrEqualTo(1); + orders.forEach(order -> { + assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING); + }); + } + + @Test + @DisplayName("Retry 실패 후 Fallback이 호출되고 CIRCUIT_BREAKER_OPEN 응답이 올바르게 처리된다") + void createOrder_retryFailure_fallbackCalled_circuitBreakerOpenHandled() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + Brand brand = createAndSaveBrand("브랜드"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + + List commands = List.of( + OrderItemCommand.of(product.getId(), 1) + ); + + // CircuitBreaker를 OPEN 상태로 만들어 Fallback이 호출되도록 함 + if (circuitBreakerRegistry != null) { + CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit"); + if (circuitBreaker != null) { + circuitBreaker.transitionToOpenState(); + } + } + + // Fallback이 CIRCUIT_BREAKER_OPEN 에러 코드를 반환하도록 설정 + // 실제로는 PaymentGatewayClientFallback이 호출되지만, 테스트를 위해 Mock으로 시뮬레이션 + PaymentGatewayDto.ApiResponse fallbackResponse = + new PaymentGatewayDto.ApiResponse<>( + new PaymentGatewayDto.ApiResponse.Metadata( + PaymentGatewayDto.ApiResponse.Metadata.Result.FAIL, + "CIRCUIT_BREAKER_OPEN", + "PG 서비스가 일시적으로 사용할 수 없습니다. 잠시 후 다시 시도해주세요." + ), + null + ); + when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class))) + .thenReturn(fallbackResponse); + + // act + OrderInfo orderInfo = purchasingFacade.createOrder( + user.getUserId(), + commands, + "SAMSUNG", + "4111-1111-1111-1111" + ); + + // assert + // 1. Fallback 응답이 올바르게 처리되어 주문이 PENDING 상태로 유지되어야 함 + assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING); + + // 2. 주문이 저장되었는지 확인 + Order savedOrder = orderRepository.findById(orderInfo.orderId()).orElseThrow(); + assertThat(savedOrder.getStatus()).isEqualTo(OrderStatus.PENDING); + + // 3. CIRCUIT_BREAKER_OPEN은 외부 시스템 장애로 간주되므로 주문 취소가 발생하지 않아야 함 + assertThat(savedOrder.getStatus()).isNotEqualTo(OrderStatus.CANCELED); + + // 4. 재고와 포인트는 정상적으로 차감되어야 함 (주문은 생성되었지만 결제는 PENDING) + Product savedProduct = productRepository.findById(product.getId()).orElseThrow(); + assertThat(savedProduct.getStock()).isEqualTo(9); + + User savedUser = userRepository.findByUserId(user.getUserId()); + assertThat(savedUser.getPoint().getValue()).isEqualTo(40_000L); + } + + @Test + @DisplayName("Fallback 응답 처리 로직: CIRCUIT_BREAKER_OPEN 에러 코드는 외부 시스템 장애로 간주되어 주문이 PENDING 상태로 유지된다") + void createOrder_fallbackResponse_circuitBreakerOpenErrorCode_orderRemainsPending() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + Brand brand = createAndSaveBrand("브랜드"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + + List commands = List.of( + OrderItemCommand.of(product.getId(), 1) + ); + + // CircuitBreaker를 OPEN 상태로 만들어 Fallback이 호출되도록 함 + if (circuitBreakerRegistry != null) { + CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit"); + if (circuitBreaker != null) { + circuitBreaker.transitionToOpenState(); + } + } + + // Fallback 응답 시뮬레이션: CIRCUIT_BREAKER_OPEN 에러 코드 반환 + PaymentGatewayDto.ApiResponse fallbackResponse = + new PaymentGatewayDto.ApiResponse<>( + new PaymentGatewayDto.ApiResponse.Metadata( + PaymentGatewayDto.ApiResponse.Metadata.Result.FAIL, + "CIRCUIT_BREAKER_OPEN", // Fallback이 반환하는 에러 코드 + "PG 서비스가 일시적으로 사용할 수 없습니다. 잠시 후 다시 시도해주세요." + ), + null + ); + when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class))) + .thenReturn(fallbackResponse); + + // act + OrderInfo orderInfo = purchasingFacade.createOrder( + user.getUserId(), + commands, + "SAMSUNG", + "4111-1111-1111-1111" + ); + + // assert + // 1. Fallback 응답의 CIRCUIT_BREAKER_OPEN 에러 코드가 올바르게 처리되어야 함 + assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING); + + // 2. 주문이 저장되었는지 확인 + Order savedOrder = orderRepository.findById(orderInfo.orderId()).orElseThrow(); + assertThat(savedOrder.getStatus()).isEqualTo(OrderStatus.PENDING); + + // 3. CIRCUIT_BREAKER_OPEN은 비즈니스 실패가 아니므로 주문 취소가 발생하지 않아야 함 + // PurchasingFacade의 isBusinessFailure() 메서드는 CIRCUIT_BREAKER_OPEN을 false로 반환해야 함 + assertThat(savedOrder.getStatus()).isNotEqualTo(OrderStatus.CANCELED); + + // 4. 외부 시스템 장애로 인한 실패이므로 주문은 PENDING 상태로 유지되어 나중에 복구 가능해야 함 + // (상태 확인 API나 콜백을 통해 나중에 상태를 업데이트할 수 있어야 함) + } + + @Test + @DisplayName("Retry가 모두 실패한 후 CircuitBreaker가 OPEN 상태가 되면 Fallback이 호출되어 주문이 PENDING 상태로 유지된다") + void createOrder_retryExhausted_circuitBreakerOpens_fallbackCalled_orderPending() { + // arrange + // 6번의 주문 생성 + fallback 테스트 1번 = 총 7번의 주문 생성 + // 각 주문마다 10,000 포인트가 필요하므로 최소 70,000 포인트 필요 + // 여유를 두고 100,000 포인트로 설정 + User user = createAndSaveUser("testuser", "test@example.com", 100_000L); + Brand brand = createAndSaveBrand("브랜드"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + + List commands = List.of( + OrderItemCommand.of(product.getId(), 1) + ); + + // 모든 재시도가 실패하도록 설정 (5xx 서버 오류) + when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class))) + .thenThrow(new FeignException.InternalServerError( + "Internal Server Error", + Request.create(Request.HttpMethod.POST, "/api/v1/payments", Collections.emptyMap(), null, null, null), + null, + Collections.emptyMap() + )); + + // CircuitBreaker를 리셋하여 초기 상태로 만듦 + if (circuitBreakerRegistry != null) { + CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit"); + if (circuitBreaker != null) { + circuitBreaker.reset(); + } + } + + // act + // 서킷 브레이커 설정: minimumNumberOfCalls=1, failureRateThreshold=50% + // 첫 번째 호출이 실패하면 100% 실패율이 되어 임계값 50%를 초과하므로 OPEN 상태로 전환됨 + // 따라서 첫 번째 호출만 실제로 PaymentGatewayClient를 호출하고, + // 이후 호출들은 서킷 브레이커가 OPEN 상태이므로 fallback이 호출되어 실제 호출되지 않음 + int numberOfCalls = 6; // 여러 번 호출하여 서킷 브레이커 동작 확인 + + for (int i = 0; i < numberOfCalls; i++) { + purchasingFacade.createOrder( + user.getUserId(), + commands, + "SAMSUNG", + "4111-1111-1111-1111" + ); + } + + // CircuitBreaker 상태 확인 + CircuitBreaker circuitBreaker = null; + if (circuitBreakerRegistry != null) { + circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit"); + } + + // assert + // 1. 재시도 정책에 따라 5xx 에러는 최대 3번까지 재시도됨 (maxAttempts: 3) + // 첫 번째 createOrder 호출에서 재시도가 일어나면서 최대 3번 호출될 수 있음 + // Circuit Breaker가 OPEN 상태가 되면 이후 호출들은 fallback이 호출되어 실제 호출되지 않음 + verify(paymentGatewayClient, atMost(3)) + .requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)); + + // 2. CircuitBreaker가 OPEN 상태로 전환되었는지 확인 + if (circuitBreaker != null) { + // 실패율이 임계값을 초과했으므로 OPEN 상태로 전환되어야 함 + assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.OPEN); + } + + // 3. CircuitBreaker가 OPEN 상태가 되면 다음 호출에서 Fallback이 호출되어야 함 + // Fallback 응답 시뮬레이션 + PaymentGatewayDto.ApiResponse fallbackResponse = + new PaymentGatewayDto.ApiResponse<>( + new PaymentGatewayDto.ApiResponse.Metadata( + PaymentGatewayDto.ApiResponse.Metadata.Result.FAIL, + "CIRCUIT_BREAKER_OPEN", + "PG 서비스가 일시적으로 사용할 수 없습니다. 잠시 후 다시 시도해주세요." + ), + null + ); + when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class))) + .thenReturn(fallbackResponse); + + // CircuitBreaker를 강제로 OPEN 상태로 만듦 (Fallback 호출 보장) + if (circuitBreaker != null) { + circuitBreaker.transitionToOpenState(); + } + + // Fallback이 호출되는 시나리오 테스트 + OrderInfo fallbackOrderInfo = purchasingFacade.createOrder( + user.getUserId(), + commands, + "SAMSUNG", + "4111-1111-1111-1111" + ); + + // 4. Fallback 응답이 올바르게 처리되어 주문이 PENDING 상태로 유지되어야 함 + assertThat(fallbackOrderInfo.status()).isEqualTo(OrderStatus.PENDING); + + // 5. 모든 주문이 PENDING 상태로 생성되었는지 확인 + List orders = orderJpaRepository.findAll(); + assertThat(orders.size()).isGreaterThanOrEqualTo(numberOfCalls + 1); // numberOfCalls + fallback 테스트 1번 + orders.forEach(order -> { + assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING); + assertThat(order.getStatus()).isNotEqualTo(OrderStatus.CANCELED); + }); + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeConcurrencyTest.java b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeConcurrencyTest.java index 3220b41c0..bdfda839d 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeConcurrencyTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeConcurrencyTest.java @@ -128,7 +128,7 @@ void concurrencyTest_pointShouldProperlyDecreaseWhenOrderCreated() throws Interr List commands = List.of( OrderItemCommand.of(products.get(index).getId(), 1) ); - purchasingFacade.createOrder(userId, commands); + purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111"); successCount.incrementAndGet(); } catch (Exception e) { synchronized (exceptions) { @@ -176,7 +176,7 @@ void concurrencyTest_stockShouldBeProperlyDecreasedWhenOrdersCreated() throws In List commands = List.of( OrderItemCommand.of(productId, quantityPerOrder) ); - purchasingFacade.createOrder(userId, commands); + purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111"); successCount.incrementAndGet(); } catch (Exception e) { synchronized (exceptions) { @@ -228,7 +228,7 @@ void concurrencyTest_couponShouldBeUsedOnlyOnceWhenOrdersCreated() throws Interr List commands = List.of( new OrderItemCommand(product.getId(), 1, couponCode) ); - purchasingFacade.createOrder(userId, commands); + purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111"); successCount.incrementAndGet(); } catch (Exception e) { synchronized (exceptions) { @@ -277,7 +277,7 @@ void concurrencyTest_cancelOrderShouldRestoreStockAccuratelyDuringConcurrentStoc List commands = List.of( OrderItemCommand.of(productId, orderQuantity) ); - OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands); + OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111"); Long orderId = orderInfo.orderId(); // 주문 취소 전 재고 확인 (100 - 5 = 95) @@ -317,7 +317,7 @@ void concurrencyTest_cancelOrderShouldRestoreStockAccuratelyDuringConcurrentStoc List otherCommands = List.of( OrderItemCommand.of(productId, 3) ); - purchasingFacade.createOrder(userId, otherCommands); + purchasingFacade.createOrder(userId, otherCommands, "SAMSUNG", "4111-1111-1111-1111"); orderSuccess.incrementAndGet(); } catch (Exception e) { synchronized (exceptions) { diff --git a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadePaymentCallbackTest.java b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadePaymentCallbackTest.java new file mode 100644 index 000000000..24195fab4 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadePaymentCallbackTest.java @@ -0,0 +1,307 @@ +package com.loopers.application.purchasing; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderRepository; +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.user.Gender; +import com.loopers.domain.user.Point; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import com.loopers.infrastructure.paymentgateway.PaymentGatewayClient; +import com.loopers.infrastructure.paymentgateway.PaymentGatewayDto; +import com.loopers.infrastructure.paymentgateway.PaymentGatewaySchedulerClient; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * PurchasingFacade 결제 콜백 및 상태 확인 테스트. + *

+ * PG 결제 콜백 처리 및 상태 확인 API를 통한 복구 로직을 검증합니다. + * - 콜백 수신 시 주문 상태 업데이트 + * - 콜백 미수신 시 상태 확인 API로 복구 + * - 타임아웃 후 상태 확인 API로 복구 + *

+ */ +@SpringBootTest +@ActiveProfiles("test") +@DisplayName("PurchasingFacade 결제 콜백 및 상태 확인 테스트") +class PurchasingFacadePaymentCallbackTest { + + @Autowired + private PurchasingFacade purchasingFacade; + + @Autowired + private UserRepository userRepository; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private BrandRepository brandRepository; + + @Autowired + private OrderRepository orderRepository; + + @MockitoBean + private PaymentGatewayClient paymentGatewayClient; + + @MockitoBean + private PaymentGatewaySchedulerClient paymentGatewaySchedulerClient; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private User createAndSaveUser(String userId, String email, long point) { + User user = User.of(userId, email, "1990-01-01", Gender.MALE, Point.of(point)); + return userRepository.save(user); + } + + private Brand createAndSaveBrand(String brandName) { + Brand brand = Brand.of(brandName); + return brandRepository.save(brand); + } + + private Product createAndSaveProduct(String productName, int price, int stock, Long brandId) { + Product product = Product.of(productName, price, stock, brandId); + return productRepository.save(product); + } + + @Test + @DisplayName("PG 결제 성공 콜백을 수신하면 주문 상태가 COMPLETED로 변경된다") + void handlePaymentCallback_successCallback_orderStatusUpdatedToCompleted() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + Brand brand = createAndSaveBrand("브랜드"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + + List commands = List.of( + OrderItemCommand.of(product.getId(), 1) + ); + + // PG 결제 요청 성공 (트랜잭션 키 반환) + PaymentGatewayDto.ApiResponse paymentResponse = + new PaymentGatewayDto.ApiResponse<>( + new PaymentGatewayDto.ApiResponse.Metadata( + PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS, + null, + null + ), + new PaymentGatewayDto.TransactionResponse( + "TXN123456", + PaymentGatewayDto.TransactionStatus.PENDING, + null + ) + ); + when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class))) + .thenReturn(paymentResponse); + + OrderInfo orderInfo = purchasingFacade.createOrder( + user.getUserId(), + commands, + "SAMSUNG", + "4111-1111-1111-1111" // 유효한 Luhn 알고리즘 통과 카드 번호 + ); + Long orderId = orderInfo.orderId(); + + // 콜백 검증을 위한 PG 조회 API Mock (SUCCESS 상태 반환) + PaymentGatewayDto.ApiResponse pgInquiryResponse = + new PaymentGatewayDto.ApiResponse<>( + new PaymentGatewayDto.ApiResponse.Metadata( + PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS, + null, + null + ), + new PaymentGatewayDto.OrderResponse( + String.format("%06d", orderId), + List.of( + new PaymentGatewayDto.TransactionResponse( + "TXN123456", + PaymentGatewayDto.TransactionStatus.SUCCESS, + null + ) + ) + ) + ); + when(paymentGatewaySchedulerClient.getTransactionsByOrder(eq(user.getUserId()), eq(String.format("%06d", orderId)))) + .thenReturn(pgInquiryResponse); + + // act + PaymentGatewayDto.CallbackRequest callbackRequest = new PaymentGatewayDto.CallbackRequest( + "TXN123456", + String.format("%06d", orderId), + PaymentGatewayDto.CardType.SAMSUNG, + "4111-1111-1111-1111", + 10_000L, + PaymentGatewayDto.TransactionStatus.SUCCESS, + null + ); + purchasingFacade.handlePaymentCallback(orderId, callbackRequest); + + // assert + Order savedOrder = orderRepository.findById(orderId).orElseThrow(); + assertThat(savedOrder.getStatus()).isEqualTo(OrderStatus.COMPLETED); + } + + @Test + @DisplayName("PG 결제 실패 콜백을 수신하면 주문 상태가 CANCELED로 변경된다") + void handlePaymentCallback_failureCallback_orderStatusUpdatedToCanceled() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + Brand brand = createAndSaveBrand("브랜드"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + + List commands = List.of( + OrderItemCommand.of(product.getId(), 1) + ); + + // PG 결제 요청 성공 (트랜잭션 키 반환) + PaymentGatewayDto.ApiResponse paymentResponse = + new PaymentGatewayDto.ApiResponse<>( + new PaymentGatewayDto.ApiResponse.Metadata( + PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS, + null, + null + ), + new PaymentGatewayDto.TransactionResponse( + "TXN123456", + PaymentGatewayDto.TransactionStatus.PENDING, + null + ) + ); + when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class))) + .thenReturn(paymentResponse); + + OrderInfo orderInfo = purchasingFacade.createOrder( + user.getUserId(), + commands, + "SAMSUNG", + "4111-1111-1111-1111" // 유효한 Luhn 알고리즘 통과 카드 번호 + ); + Long orderId = orderInfo.orderId(); + + // 콜백 검증을 위한 PG 조회 API Mock (FAILED 상태 반환) + PaymentGatewayDto.ApiResponse pgInquiryResponse = + new PaymentGatewayDto.ApiResponse<>( + new PaymentGatewayDto.ApiResponse.Metadata( + PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS, + null, + null + ), + new PaymentGatewayDto.OrderResponse( + String.format("%06d", orderId), + List.of( + new PaymentGatewayDto.TransactionResponse( + "TXN123456", + PaymentGatewayDto.TransactionStatus.FAILED, + "카드 한도 초과" + ) + ) + ) + ); + when(paymentGatewaySchedulerClient.getTransactionsByOrder(eq(user.getUserId()), eq(String.format("%06d", orderId)))) + .thenReturn(pgInquiryResponse); + + // act + PaymentGatewayDto.CallbackRequest callbackRequest = new PaymentGatewayDto.CallbackRequest( + "TXN123456", + String.format("%06d", orderId), + PaymentGatewayDto.CardType.SAMSUNG, + "4111-1111-1111-1111", + 10_000L, + PaymentGatewayDto.TransactionStatus.FAILED, + "카드 한도 초과" + ); + purchasingFacade.handlePaymentCallback(orderId, callbackRequest); + + // assert + Order savedOrder = orderRepository.findById(orderId).orElseThrow(); + assertThat(savedOrder.getStatus()).isEqualTo(OrderStatus.CANCELED); + + // 재고와 포인트가 원복되었는지 확인 + Product savedProduct = productRepository.findById(product.getId()).orElseThrow(); + assertThat(savedProduct.getStock()).isEqualTo(10); + + User savedUser = userRepository.findByUserId(user.getUserId()); + assertThat(savedUser.getPoint().getValue()).isEqualTo(50_000L); + } + + @Test + @DisplayName("타임아웃 후 상태 확인 API로 주문 상태를 복구할 수 있다") + void recoverOrderStatus_afterTimeout_statusRecoveredByStatusCheck() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + Brand brand = createAndSaveBrand("브랜드"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + + List commands = List.of( + OrderItemCommand.of(product.getId(), 1) + ); + + // PG 결제 요청 타임아웃 + String transactionKey = "TXN123456"; + doThrow(new RuntimeException(new java.net.SocketTimeoutException("Request timeout"))) + .when(paymentGatewayClient).requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)); + + OrderInfo orderInfo = purchasingFacade.createOrder( + user.getUserId(), + commands, + "SAMSUNG", + "4111-1111-1111-1111" // 유효한 Luhn 알고리즘 통과 카드 번호 + ); + Long orderId = orderInfo.orderId(); + + // 상태 확인 API 응답 (결제 성공) - 주문 ID로 조회 + PaymentGatewayDto.ApiResponse orderResponse = + new PaymentGatewayDto.ApiResponse<>( + new PaymentGatewayDto.ApiResponse.Metadata( + PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS, + null, + null + ), + new PaymentGatewayDto.OrderResponse( + String.valueOf(orderId), + List.of( + new PaymentGatewayDto.TransactionResponse( + transactionKey, + PaymentGatewayDto.TransactionStatus.SUCCESS, + null + ) + ) + ) + ); + when(paymentGatewaySchedulerClient.getTransactionsByOrder(eq(user.getUserId()), eq(String.format("%06d", orderId)))) + .thenReturn(orderResponse); + + // act + purchasingFacade.recoverOrderStatusByPaymentCheck(user.getUserId(), orderId); + + // assert + Order savedOrder = orderRepository.findById(orderId).orElseThrow(); + assertThat(savedOrder.getStatus()).isEqualTo(OrderStatus.COMPLETED); + } + +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadePaymentGatewayTest.java b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadePaymentGatewayTest.java new file mode 100644 index 000000000..6a3f9f837 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadePaymentGatewayTest.java @@ -0,0 +1,280 @@ +package com.loopers.application.purchasing; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderRepository; +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.user.Gender; +import com.loopers.domain.user.Point; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import com.loopers.infrastructure.paymentgateway.PaymentGatewayClient; +import com.loopers.infrastructure.paymentgateway.PaymentGatewayDto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import feign.FeignException; +import feign.Request; +import org.springframework.test.context.ActiveProfiles; + +import java.net.SocketTimeoutException; +import java.util.Collections; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +/** + * PurchasingFacade PG 연동 테스트. + *

+ * PG 결제 게이트웨이와의 연동에서 발생할 수 있는 다양한 시나리오를 검증합니다. + * - PG 연동 실패 시 주문 처리 + * - 타임아웃 발생 시 주문 상태 + * - 서킷 브레이커 동작 + * - 재시도 정책 동작 + *

+ */ +@SpringBootTest +@ActiveProfiles("test") +@DisplayName("PurchasingFacade PG 연동 테스트") +class PurchasingFacadePaymentGatewayTest { + + @Autowired + private PurchasingFacade purchasingFacade; + + @Autowired + private UserRepository userRepository; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private BrandRepository brandRepository; + + @Autowired + private OrderRepository orderRepository; + + @MockitoBean + private PaymentGatewayClient paymentGatewayClient; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + reset(paymentGatewayClient); // Mock 초기화 + } + + private User createAndSaveUser(String userId, String email, long point) { + User user = User.of(userId, email, "1990-01-01", Gender.MALE, Point.of(point)); + return userRepository.save(user); + } + + private Brand createAndSaveBrand(String brandName) { + Brand brand = Brand.of(brandName); + return brandRepository.save(brand); + } + + private Product createAndSaveProduct(String productName, int price, int stock, Long brandId) { + Product product = Product.of(productName, price, stock, brandId); + return productRepository.save(product); + } + + @Test + @DisplayName("PG 결제 요청이 타임아웃되어도 주문은 생성되고 PENDING 상태로 유지된다") + void createOrder_paymentGatewayTimeout_orderCreatedWithPendingStatus() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + Brand brand = createAndSaveBrand("브랜드"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + + List commands = List.of( + OrderItemCommand.of(product.getId(), 1) + ); + + // PG 결제 요청이 타임아웃 발생 + doThrow(new RuntimeException(new SocketTimeoutException("Request timeout"))) + .when(paymentGatewayClient).requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)); + + // act + OrderInfo orderInfo = purchasingFacade.createOrder( + user.getUserId(), + commands, + "SAMSUNG", + "4111-1111-1111-1111" // 유효한 Luhn 알고리즘 통과 카드 번호 + ); + + // assert + assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING); + + // 재고는 차감되었는지 확인 + Product savedProduct = productRepository.findById(product.getId()).orElseThrow(); + assertThat(savedProduct.getStock()).isEqualTo(9); + + // 포인트는 차감되었는지 확인 + User savedUser = userRepository.findByUserId(user.getUserId()); + assertThat(savedUser.getPoint().getValue()).isEqualTo(40_000L); + + // 주문이 저장되었는지 확인 + Order savedOrder = orderRepository.findById(orderInfo.orderId()).orElseThrow(); + assertThat(savedOrder.getStatus()).isEqualTo(OrderStatus.PENDING); + } + + @Test + @DisplayName("PG 결제 요청이 실패해도 주문은 생성되고 PENDING 상태로 유지된다") + void createOrder_paymentGatewayFailure_orderCreatedWithPendingStatus() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + Brand brand = createAndSaveBrand("브랜드"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + + List commands = List.of( + OrderItemCommand.of(product.getId(), 1) + ); + + // PG 결제 요청이 실패 (외부 시스템 장애 - 주문은 PENDING 상태로 유지) + PaymentGatewayDto.ApiResponse failureResponse = + new PaymentGatewayDto.ApiResponse<>( + new PaymentGatewayDto.ApiResponse.Metadata( + PaymentGatewayDto.ApiResponse.Metadata.Result.FAIL, + "INTERNAL_SERVER_ERROR", // 외부 시스템 장애로 분류되어 주문이 PENDING 상태로 유지됨 + "서버 오류가 발생했습니다" + ), + null + ); + when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class))) + .thenReturn(failureResponse); + + // act + OrderInfo orderInfo = purchasingFacade.createOrder( + user.getUserId(), + commands, + "SAMSUNG", + "4111-1111-1111-1111" // 유효한 Luhn 알고리즘 통과 카드 번호 + ); + + // assert + assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING); + + // 주문이 저장되었는지 확인 + Order savedOrder = orderRepository.findById(orderInfo.orderId()).orElseThrow(); + assertThat(savedOrder.getStatus()).isEqualTo(OrderStatus.PENDING); + } + + @Test + @DisplayName("PG 서버가 500 에러를 반환해도 주문은 생성되고 PENDING 상태로 유지된다") + void createOrder_paymentGatewayServerError_orderCreatedWithPendingStatus() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + Brand brand = createAndSaveBrand("브랜드"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + + List commands = List.of( + OrderItemCommand.of(product.getId(), 1) + ); + + // PG 서버가 500 에러 반환 + when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class))) + .thenThrow(new FeignException.InternalServerError( + "Internal Server Error", + Request.create(Request.HttpMethod.POST, "/api/v1/payments", Collections.emptyMap(), null, null, null), + null, + Collections.emptyMap() + )); + + // act + OrderInfo orderInfo = purchasingFacade.createOrder( + user.getUserId(), + commands, + "SAMSUNG", + "4111-1111-1111-1111" // 유효한 Luhn 알고리즘 통과 카드 번호 + ); + + // assert + assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING); + + // 주문이 저장되었는지 확인 + Order savedOrder = orderRepository.findById(orderInfo.orderId()).orElseThrow(); + assertThat(savedOrder.getStatus()).isEqualTo(OrderStatus.PENDING); + } + + @Test + @DisplayName("PG 연결이 실패해도 주문은 생성되고 PENDING 상태로 유지된다") + void createOrder_paymentGatewayConnectionFailure_orderCreatedWithPendingStatus() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + Brand brand = createAndSaveBrand("브랜드"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + + List commands = List.of( + OrderItemCommand.of(product.getId(), 1) + ); + + // PG 연결 실패 + when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class))) + .thenThrow(new FeignException.ServiceUnavailable( + "Service unavailable", + Request.create(Request.HttpMethod.POST, "/api/v1/payments", Collections.emptyMap(), null, null, null), + null, + Collections.emptyMap() + )); + + // act + OrderInfo orderInfo = purchasingFacade.createOrder( + user.getUserId(), + commands, + "SAMSUNG", + "4111-1111-1111-1111" // 유효한 Luhn 알고리즘 통과 카드 번호 + ); + + // assert + assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING); + + // 주문이 저장되었는지 확인 + Order savedOrder = orderRepository.findById(orderInfo.orderId()).orElseThrow(); + assertThat(savedOrder.getStatus()).isEqualTo(OrderStatus.PENDING); + } + + @Test + @DisplayName("PG 결제 요청이 타임아웃되어도 내부 시스템은 정상적으로 응답한다") + void createOrder_paymentGatewayTimeout_internalSystemRespondsNormally() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + Brand brand = createAndSaveBrand("브랜드"); + Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); + + List commands = List.of( + OrderItemCommand.of(product.getId(), 1) + ); + + // PG 결제 요청이 타임아웃 발생 + doThrow(new RuntimeException(new SocketTimeoutException("Request timeout"))) + .when(paymentGatewayClient).requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)); + + // act + OrderInfo orderInfo = purchasingFacade.createOrder( + user.getUserId(), + commands, + "SAMSUNG", + "4111-1111-1111-1111" // 유효한 Luhn 알고리즘 통과 카드 번호 + ); + + // assert + // 내부 시스템은 정상적으로 응답해야 함 (예외가 발생하지 않아야 함) + assertThat(orderInfo).isNotNull(); + assertThat(orderInfo.orderId()).isNotNull(); + assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING); + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeTest.java index 6464e9552..e9b01e55e 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeTest.java @@ -15,17 +15,25 @@ import com.loopers.domain.user.Point; import com.loopers.domain.user.User; import com.loopers.domain.user.UserRepository; +import com.loopers.infrastructure.paymentgateway.PaymentGatewayClient; +import com.loopers.infrastructure.paymentgateway.PaymentGatewayDto; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import java.util.List; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -56,6 +64,30 @@ class PurchasingFacadeTest { @Autowired private DatabaseCleanUp databaseCleanUp; + + @MockitoBean + private PaymentGatewayClient paymentGatewayClient; + + @BeforeEach + void setUp() { + // 기본적으로 모든 테스트에서 결제 성공 응답을 반환하도록 설정 + // 개별 테스트에서 필요시 재설정 가능 + PaymentGatewayDto.ApiResponse successResponse = + new PaymentGatewayDto.ApiResponse<>( + new PaymentGatewayDto.ApiResponse.Metadata( + PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS, + null, + null + ), + new PaymentGatewayDto.TransactionResponse( + "TXN123456", + PaymentGatewayDto.TransactionStatus.SUCCESS, + null + ) + ); + when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class))) + .thenReturn(successResponse); + } @AfterEach void tearDown() { @@ -121,10 +153,11 @@ void createOrder_successFlow() { ); // act - OrderInfo orderInfo = purchasingFacade.createOrder(user.getUserId(), commands); + OrderInfo orderInfo = purchasingFacade.createOrder(user.getUserId(), commands, "SAMSUNG", "4111-1111-1111-1111"); // assert - assertThat(orderInfo.status()).isEqualTo(OrderStatus.COMPLETED); + // createOrder는 주문을 PENDING 상태로 생성하고, PG 결제 요청은 afterCommit 콜백에서 비동기로 실행됨 + assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING); // 재고 차감 확인 Product savedProduct1 = productRepository.findById(product1.getId()).orElseThrow(); @@ -145,7 +178,7 @@ void createOrder_emptyItems_throwsException() { List emptyCommands = List.of(); // act & assert - assertThatThrownBy(() -> purchasingFacade.createOrder(userId, emptyCommands)) + assertThatThrownBy(() -> purchasingFacade.createOrder(userId, emptyCommands, "SAMSUNG", "4111-1111-1111-1111")) .isInstanceOf(CoreException.class) .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); } @@ -160,7 +193,7 @@ void createOrder_userNotFound() { ); // act & assert - assertThatThrownBy(() -> purchasingFacade.createOrder(unknownUserId, commands)) + assertThatThrownBy(() -> purchasingFacade.createOrder(unknownUserId, commands, "SAMSUNG", "4111-1111-1111-1111")) .isInstanceOf(CoreException.class) .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); } @@ -182,7 +215,7 @@ void createOrder_stockNotEnough() { ); // act & assert - assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands)) + assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111")) .isInstanceOf(CoreException.class) .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); @@ -212,7 +245,7 @@ void createOrder_stockZero() { ); // act & assert - assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands)) + assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111")) .isInstanceOf(CoreException.class) .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); @@ -242,7 +275,7 @@ void createOrder_pointNotEnough() { ); // act & assert - assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands)) + assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111")) .isInstanceOf(CoreException.class) .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); @@ -272,7 +305,7 @@ void createOrder_duplicateProducts_throwsException() { ); // act & assert - assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands)) + assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111")) .isInstanceOf(CoreException.class) .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); @@ -292,14 +325,15 @@ void getOrders_returnsUserOrders() { List commands = List.of( OrderItemCommand.of(product.getId(), 1) ); - purchasingFacade.createOrder(user.getUserId(), commands); + purchasingFacade.createOrder(user.getUserId(), commands, "SAMSUNG", "4111-1111-1111-1111"); // act List orders = purchasingFacade.getOrders(user.getUserId()); // assert assertThat(orders).hasSize(1); - assertThat(orders.get(0).status()).isEqualTo(OrderStatus.COMPLETED); + // createOrder는 주문을 PENDING 상태로 생성하고, PG 결제 요청은 afterCommit 콜백에서 비동기로 실행됨 + assertThat(orders.get(0).status()).isEqualTo(OrderStatus.PENDING); } @Test @@ -313,14 +347,15 @@ void getOrder_returnsSingleOrder() { List commands = List.of( OrderItemCommand.of(product.getId(), 1) ); - OrderInfo createdOrder = purchasingFacade.createOrder(user.getUserId(), commands); + OrderInfo createdOrder = purchasingFacade.createOrder(user.getUserId(), commands, "SAMSUNG", "4111-1111-1111-1111"); // act OrderInfo found = purchasingFacade.getOrder(user.getUserId(), createdOrder.orderId()); // assert assertThat(found.orderId()).isEqualTo(createdOrder.orderId()); - assertThat(found.status()).isEqualTo(OrderStatus.COMPLETED); + // createOrder는 주문을 PENDING 상태로 생성하고, PG 결제 요청은 afterCommit 콜백에서 비동기로 실행됨 + assertThat(found.status()).isEqualTo(OrderStatus.PENDING); } @Test @@ -338,7 +373,7 @@ void getOrder_withDifferentUser_throwsException() { List commands = List.of( OrderItemCommand.of(product.getId(), 1) ); - OrderInfo user1Order = purchasingFacade.createOrder(user1Id, commands); + OrderInfo user1Order = purchasingFacade.createOrder(user1Id, commands, "SAMSUNG", "4111-1111-1111-1111"); final Long orderId = user1Order.orderId(); // act & assert @@ -370,7 +405,7 @@ void createOrder_atomicityGuaranteed() { ); // act & assert - assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands)) + assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111")) .isInstanceOf(CoreException.class) .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); @@ -412,11 +447,12 @@ void createOrder_success_allOperationsReflected() { final int totalAmount = (10_000 * 3) + (15_000 * 2); // act - OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands); + OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111"); // assert // 주문이 정상적으로 생성되었는지 확인 - assertThat(orderInfo.status()).isEqualTo(OrderStatus.COMPLETED); + // createOrder는 주문을 PENDING 상태로 생성하고, PG 결제 요청은 afterCommit 콜백에서 비동기로 실행됨 + assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING); assertThat(orderInfo.items()).hasSize(2); // 재고가 정상적으로 차감되었는지 확인 @@ -452,10 +488,11 @@ void createOrder_withFixedAmountCoupon_success() { ); // act - OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands); + OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111"); // assert - assertThat(orderInfo.status()).isEqualTo(OrderStatus.COMPLETED); + // createOrder는 주문을 PENDING 상태로 생성하고, PG 결제 요청은 afterCommit 콜백에서 비동기로 실행됨 + assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING); assertThat(orderInfo.totalAmount()).isEqualTo(5_000); // 10,000 - 5,000 = 5,000 // 쿠폰이 사용되었는지 확인 @@ -481,10 +518,11 @@ void createOrder_withPercentageCoupon_success() { ); // act - OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands); + OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111"); // assert - assertThat(orderInfo.status()).isEqualTo(OrderStatus.COMPLETED); + // createOrder는 주문을 PENDING 상태로 생성하고, PG 결제 요청은 afterCommit 콜백에서 비동기로 실행됨 + assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING); assertThat(orderInfo.totalAmount()).isEqualTo(8_000); // 10,000 - (10,000 * 20%) = 8,000 // 쿠폰이 사용되었는지 확인 @@ -507,7 +545,7 @@ void createOrder_withNonExistentCoupon_shouldFail() { ); // act & assert - assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands)) + assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111")) .isInstanceOf(CoreException.class) .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); } @@ -530,7 +568,7 @@ void createOrder_withCouponNotOwnedByUser_shouldFail() { ); // act & assert - assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands)) + assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111")) .isInstanceOf(CoreException.class) .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); } @@ -554,7 +592,7 @@ void createOrder_withUsedCoupon_shouldFail() { ); // act & assert - assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands)) + assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111")) .isInstanceOf(CoreException.class) .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); } diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayClientTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayClientTest.java new file mode 100644 index 000000000..89b3eef61 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayClientTest.java @@ -0,0 +1,277 @@ +package com.loopers.infrastructure.paymentgateway; + +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import feign.FeignException; +import feign.Request; +import org.springframework.test.context.ActiveProfiles; + +import java.net.SocketTimeoutException; +import java.util.Collections; + +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.anyString; +import static org.mockito.Mockito.*; + +/** + * PaymentGatewayClient 타임아웃 및 실패 처리 테스트. + *

+ * 외부 PG 시스템과의 통신에서 발생할 수 있는 다양한 장애 시나리오를 검증합니다. + * - 타임아웃 처리 + * - 네트워크 오류 처리 + * - 서버 오류 처리 + *

+ */ +@SpringBootTest +@ActiveProfiles("test") +@DisplayName("PaymentGatewayClient 타임아웃 및 실패 처리 테스트") +class PaymentGatewayClientTest { + + @MockitoBean + private PaymentGatewayClient paymentGatewayClient; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + reset(paymentGatewayClient); + } + + @Test + @DisplayName("PG 결제 요청 시 타임아웃이 발생하면 적절한 예외가 발생한다") + void requestPayment_timeout_throwsException() { + // arrange + String userId = "testuser"; + PaymentGatewayDto.PaymentRequest request = new PaymentGatewayDto.PaymentRequest( + "ORDER001", + PaymentGatewayDto.CardType.SAMSUNG, + "4111-1111-1111-1111", + 10_000L, + "http://localhost:8080/api/v1/orders/1/callback" + ); + + // Mock 서버에서 타임아웃 예외 발생 + SocketTimeoutException timeoutException = new SocketTimeoutException("Request timeout"); + doThrow(new RuntimeException(timeoutException)) + .when(paymentGatewayClient).requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)); + + // act & assert + assertThatThrownBy(() -> paymentGatewayClient.requestPayment(userId, request)) + .isInstanceOf(RuntimeException.class) + .hasCauseInstanceOf(SocketTimeoutException.class) + .hasMessageContaining("Request timeout"); + } + + @Test + @DisplayName("PG 결제 요청 시 연결 타임아웃이 발생하면 적절한 예외가 발생한다") + void requestPayment_connectionTimeout_throwsException() { + // arrange + String userId = "testuser"; + PaymentGatewayDto.PaymentRequest request = new PaymentGatewayDto.PaymentRequest( + "ORDER001", + PaymentGatewayDto.CardType.SAMSUNG, + "4111-1111-1111-1111", + 10_000L, + "http://localhost:8080/api/v1/orders/1/callback" + ); + + // Mock 서버에서 연결 실패 예외 발생 + SocketTimeoutException timeoutException = new SocketTimeoutException("Connection timeout"); + doThrow(new RuntimeException(timeoutException)) + .when(paymentGatewayClient).requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)); + + // act & assert + assertThatThrownBy(() -> paymentGatewayClient.requestPayment(userId, request)) + .isInstanceOf(RuntimeException.class) + .hasCauseInstanceOf(SocketTimeoutException.class) + .hasMessageContaining("Connection timeout"); + } + + @Test + @DisplayName("PG 결제 요청 시 읽기 타임아웃이 발생하면 적절한 예외가 발생한다") + void requestPayment_readTimeout_throwsException() { + // arrange + String userId = "testuser"; + PaymentGatewayDto.PaymentRequest request = new PaymentGatewayDto.PaymentRequest( + "ORDER001", + PaymentGatewayDto.CardType.SAMSUNG, + "4111-1111-1111-1111", + 10_000L, + "http://localhost:8080/api/v1/orders/1/callback" + ); + + // Mock 서버에서 읽기 타임아웃 예외 발생 + SocketTimeoutException timeoutException = new SocketTimeoutException("Read timed out"); + doThrow(new RuntimeException(timeoutException)) + .when(paymentGatewayClient).requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)); + + // act & assert + assertThatThrownBy(() -> paymentGatewayClient.requestPayment(userId, request)) + .isInstanceOf(RuntimeException.class) + .hasCauseInstanceOf(SocketTimeoutException.class) + .hasMessageContaining("Read timed out"); + } + + @Test + @DisplayName("PG 결제 상태 확인 API 호출 시 타임아웃이 발생하면 적절한 예외가 발생한다") + void getTransaction_timeout_throwsException() { + // arrange + String userId = "testuser"; + String transactionKey = "TXN123456"; + + // Mock 서버에서 타임아웃 예외 발생 + SocketTimeoutException timeoutException = new SocketTimeoutException("Request timeout"); + doThrow(new RuntimeException(timeoutException)) + .when(paymentGatewayClient).getTransaction(anyString(), anyString()); + + // act & assert + assertThatThrownBy(() -> paymentGatewayClient.getTransaction(userId, transactionKey)) + .isInstanceOf(RuntimeException.class) + .hasCauseInstanceOf(SocketTimeoutException.class) + .hasMessageContaining("Request timeout"); + } + + @Test + @DisplayName("PG 서버가 500 에러를 반환하면 적절한 예외가 발생한다") + void requestPayment_serverError_throwsException() { + // arrange + String userId = "testuser"; + PaymentGatewayDto.PaymentRequest request = new PaymentGatewayDto.PaymentRequest( + "ORDER001", + PaymentGatewayDto.CardType.SAMSUNG, + "4111-1111-1111-1111", + 10_000L, + "http://localhost:8080/api/v1/orders/1/callback" + ); + + // Mock 서버에서 500 에러 반환 + when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class))) + .thenThrow(new FeignException.InternalServerError( + "Internal Server Error", + Request.create(Request.HttpMethod.POST, "/api/v1/payments", Collections.emptyMap(), null, null, null), + null, + Collections.emptyMap() + )); + + // act & assert + assertThatThrownBy(() -> paymentGatewayClient.requestPayment(userId, request)) + .isInstanceOf(FeignException.class) + .matches(e -> ((FeignException) e).status() == 500); + } + + @Test + @DisplayName("PG 서버가 400 에러를 반환하면 적절한 예외가 발생한다") + void requestPayment_badRequest_throwsException() { + // arrange + String userId = "testuser"; + PaymentGatewayDto.PaymentRequest request = new PaymentGatewayDto.PaymentRequest( + "ORDER001", + PaymentGatewayDto.CardType.SAMSUNG, + "INVALID_CARD", // 잘못된 카드 번호 + 10_000L, + "http://localhost:8080/api/v1/orders/1/callback" + ); + + // Mock 서버에서 400 에러 반환 + when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class))) + .thenThrow(new FeignException.BadRequest( + "Bad Request", + Request.create(Request.HttpMethod.POST, "/api/v1/payments", Collections.emptyMap(), null, null, null), + null, + Collections.emptyMap() + )); + + // act & assert + assertThatThrownBy(() -> paymentGatewayClient.requestPayment(userId, request)) + .isInstanceOf(FeignException.class) + .matches(e -> ((FeignException) e).status() == 400); + } + + @Test + @DisplayName("PG 결제 요청이 성공하면 정상적인 응답을 받는다") + void requestPayment_success_returnsResponse() { + // arrange + String userId = "testuser"; + PaymentGatewayDto.PaymentRequest request = new PaymentGatewayDto.PaymentRequest( + "ORDER001", + PaymentGatewayDto.CardType.SAMSUNG, + "4111-1111-1111-1111", + 10_000L, + "http://localhost:8080/api/v1/orders/1/callback" + ); + + // Mock 서버에서 성공 응답 반환 + PaymentGatewayDto.ApiResponse successResponse = + new PaymentGatewayDto.ApiResponse<>( + new PaymentGatewayDto.ApiResponse.Metadata( + PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS, + null, + null + ), + new PaymentGatewayDto.TransactionResponse( + "TXN123456", + PaymentGatewayDto.TransactionStatus.PENDING, + null + ) + ); + when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class))) + .thenReturn(successResponse); + + // act + PaymentGatewayDto.ApiResponse response = + paymentGatewayClient.requestPayment(userId, request); + + // assert + assertThat(response.meta().result()).isEqualTo(PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS); + assertThat(response.data()).isNotNull(); + assertThat(response.data().transactionKey()).isNotNull(); + } + + @Test + @DisplayName("PG 결제 상태 확인 API가 성공하면 정상적인 응답을 받는다") + void getTransaction_success_returnsResponse() { + // arrange + String userId = "testuser"; + String transactionKey = "TXN123456"; + + // Mock 서버에서 성공 응답 반환 + PaymentGatewayDto.ApiResponse successResponse = + new PaymentGatewayDto.ApiResponse<>( + new PaymentGatewayDto.ApiResponse.Metadata( + PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS, + null, + null + ), + new PaymentGatewayDto.TransactionDetailResponse( + transactionKey, + "ORDER001", + PaymentGatewayDto.CardType.SAMSUNG, + "4111-1111-1111-1111", + 10_000L, + PaymentGatewayDto.TransactionStatus.SUCCESS, + null + ) + ); + when(paymentGatewayClient.getTransaction(anyString(), anyString())) + .thenReturn(successResponse); + + // act + PaymentGatewayDto.ApiResponse response = + paymentGatewayClient.getTransaction(userId, transactionKey); + + // assert + assertThat(response.meta().result()).isEqualTo(PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS); + assertThat(response.data()).isNotNull(); + assertThat(response.data().transactionKey()).isEqualTo(transactionKey); + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PurchasingV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PurchasingV1ApiE2ETest.java new file mode 100644 index 000000000..46e6f964a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PurchasingV1ApiE2ETest.java @@ -0,0 +1,535 @@ +package com.loopers.interfaces.api; + +import com.loopers.application.pointwallet.PointWalletFacade; +import com.loopers.application.signup.SignUpFacade; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.user.Gender; +import com.loopers.domain.user.UserTestFixture; +import com.loopers.infrastructure.paymentgateway.PaymentGatewayClient; +import com.loopers.infrastructure.paymentgateway.PaymentGatewayDto; +import com.loopers.infrastructure.paymentgateway.PaymentGatewaySchedulerClient; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.purchasing.PurchasingV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import io.github.resilience4j.circuitbreaker.CircuitBreaker; +import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry; +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.test.context.bean.override.mockito.MockitoBean; +import org.springframework.boot.test.web.client.TestRestTemplate; +import feign.FeignException; +import feign.Request; + +import java.net.SocketTimeoutException; +import java.util.Collections; +import org.springframework.core.ParameterizedTypeReference; +import static org.mockito.Mockito.doThrow; +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 org.springframework.test.context.ActiveProfiles; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * PurchasingV1Api E2E 테스트. + *

+ * PG 연동 관련 E2E 시나리오를 검증합니다. + * - PG 타임아웃 시나리오 + * - PG 실패 시나리오 + * - 서킷 브레이커 동작 + *

+ */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +@DisplayName("PurchasingV1Api E2E 테스트") +public class PurchasingV1ApiE2ETest { + + private static final String ENDPOINT_ORDERS = "/api/v1/orders"; + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private SignUpFacade signUpFacade; + + @Autowired + private PointWalletFacade pointWalletFacade; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private BrandRepository brandRepository; + + @MockitoBean + private PaymentGatewayClient paymentGatewayClient; + + @MockitoBean + private PaymentGatewaySchedulerClient paymentGatewaySchedulerClient; + + @Autowired + private CircuitBreakerRegistry circuitBreakerRegistry; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + reset(paymentGatewayClient, paymentGatewaySchedulerClient); + // 서킷 브레이커 상태 초기화 + if (circuitBreakerRegistry != null) { + circuitBreakerRegistry.getAllCircuitBreakers().forEach(CircuitBreaker::reset); + } + } + + // 테스트 데이터 준비 헬퍼 메서드 + private HttpEntity createOrderRequest(Long productId) { + String userId = UserTestFixture.ValidUser.USER_ID; + String email = UserTestFixture.ValidUser.EMAIL; + String birthDate = UserTestFixture.ValidUser.BIRTH_DATE; + signUpFacade.signUp(userId, email, birthDate, Gender.MALE.name()); + pointWalletFacade.chargePoint(userId, 500_000L); + + Brand brand = Brand.of("테스트 브랜드"); + Brand savedBrand = brandRepository.save(brand); + Product product = Product.of("테스트 상품", 10_000, 10, savedBrand.getId()); + Product savedProduct = productRepository.save(product); + + PurchasingV1Dto.CreateRequest requestBody = new PurchasingV1Dto.CreateRequest( + List.of( + new PurchasingV1Dto.ItemRequest(savedProduct.getId(), 1) + ), + new PurchasingV1Dto.PaymentRequest("SAMSUNG", "4111-1111-1111-1111") + ); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.add("X-USER-ID", userId); + return new HttpEntity<>(requestBody, headers); + } + + @DisplayName("POST /api/v1/orders") + @Nested + class CreateOrder { + @DisplayName("외부 시스템(PG) 장애 시에도 항상 200 응답을 반환한다") + @Nested + class ExternalSystemFailureIsolation { + @DisplayName("PG 타임아웃 시에도 200 응답을 반환한다") + @Test + void returns200_whenPaymentGatewayTimeout() { + // arrange + HttpEntity httpEntity = createOrderRequest(null); + doThrow(new RuntimeException(new SocketTimeoutException("Request timeout"))) + .when(paymentGatewayClient).requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_ORDERS, HttpMethod.POST, httpEntity, responseType); + + // assert - 외부 시스템 장애 격리 원칙: 항상 200 응답 + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS), + () -> assertThat(response.getBody().data().status()).isEqualTo(OrderStatus.PENDING) + ); + } + + @DisplayName("PG 서버 500 에러 시에도 200 응답을 반환한다") + @Test + void returns200_whenPaymentGatewayServerError() { + // arrange + HttpEntity httpEntity = createOrderRequest(null); + + // 서킷 브레이커를 리셋하여 CLOSED 상태로 시작 + if (circuitBreakerRegistry != null) { + CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit"); + if (circuitBreaker != null) { + circuitBreaker.reset(); + } + } + + when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class))) + .thenThrow(new FeignException.InternalServerError( + "Internal Server Error", + Request.create(Request.HttpMethod.POST, "/api/v1/payments", Collections.emptyMap(), null, null, null), + null, + Collections.emptyMap() + )); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_ORDERS, HttpMethod.POST, httpEntity, responseType); + + // assert - 외부 시스템 장애 격리 원칙: 항상 200 응답 + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS), + () -> assertThat(response.getBody().data().status()).isEqualTo(OrderStatus.PENDING) + ); + } + + @DisplayName("PG 실패 응답 시에도 200 응답을 반환한다") + @Test + void returns200_whenPaymentGatewayFailure() { + // arrange + HttpEntity httpEntity = createOrderRequest(null); + + PaymentGatewayDto.ApiResponse failureResponse = + new PaymentGatewayDto.ApiResponse<>( + new PaymentGatewayDto.ApiResponse.Metadata( + PaymentGatewayDto.ApiResponse.Metadata.Result.FAIL, + "INTERNAL_SERVER_ERROR", + "PG 서버 내부 오류가 발생했습니다" + ), + null + ); + when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class))) + .thenReturn(failureResponse); + + // act + ParameterizedTypeReference> responseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_ORDERS, HttpMethod.POST, httpEntity, responseType); + + // assert - 외부 시스템 장애 격리 원칙: 항상 200 응답 + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS), + () -> assertThat(response.getBody().data().status()).isEqualTo(OrderStatus.PENDING) + ); + } + } + + } + + @DisplayName("POST /api/v1/orders/{orderId}/callback") + @Nested + class HandlePaymentCallback { + private static final String ENDPOINT_CALLBACK = "/api/v1/orders/{orderId}/callback"; + + @DisplayName("PG 결제 성공 콜백을 수신하면 주문 상태가 COMPLETED로 변경되고 200 응답을 반환한다") + @Test + void returns200_whenPaymentCallbackSuccess() { + // arrange + String userId = UserTestFixture.ValidUser.USER_ID; + String email = UserTestFixture.ValidUser.EMAIL; + String birthDate = UserTestFixture.ValidUser.BIRTH_DATE; + signUpFacade.signUp(userId, email, birthDate, Gender.MALE.name()); + pointWalletFacade.chargePoint(userId, 500_000L); + + Brand brand = Brand.of("테스트 브랜드"); + Brand savedBrand = brandRepository.save(brand); + Product product = Product.of("테스트 상품", 10_000, 10, savedBrand.getId()); + Product savedProduct = productRepository.save(product); + + // 주문 생성 (PENDING 상태) + PurchasingV1Dto.CreateRequest createRequest = new PurchasingV1Dto.CreateRequest( + List.of( + new PurchasingV1Dto.ItemRequest(savedProduct.getId(), 1) + ), + new PurchasingV1Dto.PaymentRequest("SAMSUNG", "4111-1111-1111-1111") + ); + + HttpHeaders createHeaders = new HttpHeaders(); + createHeaders.setContentType(MediaType.APPLICATION_JSON); + createHeaders.add("X-USER-ID", userId); + HttpEntity createHttpEntity = new HttpEntity<>(createRequest, createHeaders); + + // PG 결제 요청 성공 Mock + when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class))) + .thenReturn(new PaymentGatewayDto.ApiResponse<>( + new PaymentGatewayDto.ApiResponse.Metadata( + PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS, + null, + null + ), + new PaymentGatewayDto.TransactionResponse( + "TXN123456", + PaymentGatewayDto.TransactionStatus.SUCCESS, + null + ) + )); + + ParameterizedTypeReference> createResponseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> createResponse = + testRestTemplate.exchange(ENDPOINT_ORDERS, HttpMethod.POST, createHttpEntity, createResponseType); + + Long orderId = createResponse.getBody().data().orderId(); + String transactionKey = "TXN123456"; + + // 콜백 검증을 위한 PG 조회 API Mock (SUCCESS 상태 반환) + PaymentGatewayDto.ApiResponse pgInquiryResponse = + new PaymentGatewayDto.ApiResponse<>( + new PaymentGatewayDto.ApiResponse.Metadata( + PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS, + null, + null + ), + new PaymentGatewayDto.OrderResponse( + String.format("%06d", orderId), + List.of( + new PaymentGatewayDto.TransactionResponse( + transactionKey, + PaymentGatewayDto.TransactionStatus.SUCCESS, + null + ) + ) + ) + ); + when(paymentGatewaySchedulerClient.getTransactionsByOrder(eq(userId), eq(String.format("%06d", orderId)))) + .thenReturn(pgInquiryResponse); + + // 콜백 요청 생성 + PaymentGatewayDto.CallbackRequest callbackRequest = new PaymentGatewayDto.CallbackRequest( + transactionKey, + String.format("%06d", orderId), + PaymentGatewayDto.CardType.SAMSUNG, + "4111-1111-1111-1111", + 10_000L, + PaymentGatewayDto.TransactionStatus.SUCCESS, + null + ); + + HttpHeaders callbackHeaders = new HttpHeaders(); + callbackHeaders.setContentType(MediaType.APPLICATION_JSON); + HttpEntity callbackHttpEntity = new HttpEntity<>(callbackRequest, callbackHeaders); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_CALLBACK, + HttpMethod.POST, + callbackHttpEntity, + new ParameterizedTypeReference>() {}, + orderId + ); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS) + ); + } + + @DisplayName("PG 결제 실패 콜백을 수신하면 주문 상태가 CANCELED로 변경되고 200 응답을 반환한다") + @Test + void returns200_whenPaymentCallbackFailure() { + // arrange + String userId = UserTestFixture.ValidUser.USER_ID; + String email = UserTestFixture.ValidUser.EMAIL; + String birthDate = UserTestFixture.ValidUser.BIRTH_DATE; + signUpFacade.signUp(userId, email, birthDate, Gender.MALE.name()); + pointWalletFacade.chargePoint(userId, 500_000L); + + Brand brand = Brand.of("테스트 브랜드"); + Brand savedBrand = brandRepository.save(brand); + Product product = Product.of("테스트 상품", 10_000, 10, savedBrand.getId()); + Product savedProduct = productRepository.save(product); + + // 주문 생성 (PENDING 상태) + PurchasingV1Dto.CreateRequest createRequest = new PurchasingV1Dto.CreateRequest( + List.of( + new PurchasingV1Dto.ItemRequest(savedProduct.getId(), 1) + ), + new PurchasingV1Dto.PaymentRequest("SAMSUNG", "4111-1111-1111-1111") + ); + + HttpHeaders createHeaders = new HttpHeaders(); + createHeaders.setContentType(MediaType.APPLICATION_JSON); + createHeaders.add("X-USER-ID", userId); + HttpEntity createHttpEntity = new HttpEntity<>(createRequest, createHeaders); + + // PG 결제 요청 성공 Mock + when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class))) + .thenReturn(new PaymentGatewayDto.ApiResponse<>( + new PaymentGatewayDto.ApiResponse.Metadata( + PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS, + null, + null + ), + new PaymentGatewayDto.TransactionResponse( + "TXN123456", + PaymentGatewayDto.TransactionStatus.SUCCESS, + null + ) + )); + + ParameterizedTypeReference> createResponseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> createResponse = + testRestTemplate.exchange(ENDPOINT_ORDERS, HttpMethod.POST, createHttpEntity, createResponseType); + + Long orderId = createResponse.getBody().data().orderId(); + String transactionKey = "TXN123456"; + + // 콜백 검증을 위한 PG 조회 API Mock (FAILED 상태 반환) + PaymentGatewayDto.ApiResponse pgInquiryResponse = + new PaymentGatewayDto.ApiResponse<>( + new PaymentGatewayDto.ApiResponse.Metadata( + PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS, + null, + null + ), + new PaymentGatewayDto.OrderResponse( + String.format("%06d", orderId), + List.of( + new PaymentGatewayDto.TransactionResponse( + transactionKey, + PaymentGatewayDto.TransactionStatus.FAILED, + "카드 한도 초과" + ) + ) + ) + ); + when(paymentGatewaySchedulerClient.getTransactionsByOrder(eq(userId), eq(String.format("%06d", orderId)))) + .thenReturn(pgInquiryResponse); + + // 콜백 요청 생성 (FAILED 상태) + PaymentGatewayDto.CallbackRequest callbackRequest = new PaymentGatewayDto.CallbackRequest( + transactionKey, + String.format("%06d", orderId), + PaymentGatewayDto.CardType.SAMSUNG, + "4111-1111-1111-1111", + 10_000L, + PaymentGatewayDto.TransactionStatus.FAILED, + "카드 한도 초과" + ); + + HttpHeaders callbackHeaders = new HttpHeaders(); + callbackHeaders.setContentType(MediaType.APPLICATION_JSON); + HttpEntity callbackHttpEntity = new HttpEntity<>(callbackRequest, callbackHeaders); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_CALLBACK, + HttpMethod.POST, + callbackHttpEntity, + new ParameterizedTypeReference>() {}, + orderId + ); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS) + ); + } + } + + @DisplayName("POST /api/v1/orders/{orderId}/recover") + @Nested + class RecoverOrderStatus { + private static final String ENDPOINT_RECOVER = "/api/v1/orders/{orderId}/recover"; + + @DisplayName("수동으로 주문 상태를 복구할 수 있다") + @Test + void returns200_whenOrderStatusRecovered() { + // arrange + String userId = UserTestFixture.ValidUser.USER_ID; + String email = UserTestFixture.ValidUser.EMAIL; + String birthDate = UserTestFixture.ValidUser.BIRTH_DATE; + signUpFacade.signUp(userId, email, birthDate, Gender.MALE.name()); + pointWalletFacade.chargePoint(userId, 500_000L); + + Brand brand = Brand.of("테스트 브랜드"); + Brand savedBrand = brandRepository.save(brand); + Product product = Product.of("테스트 상품", 10_000, 10, savedBrand.getId()); + Product savedProduct = productRepository.save(product); + + // 주문 생성 (타임아웃으로 인해 PENDING 상태 유지) + PurchasingV1Dto.CreateRequest createRequest = new PurchasingV1Dto.CreateRequest( + List.of( + new PurchasingV1Dto.ItemRequest(savedProduct.getId(), 1) + ), + new PurchasingV1Dto.PaymentRequest("SAMSUNG", "4111-1111-1111-1111") + ); + + HttpHeaders createHeaders = new HttpHeaders(); + createHeaders.setContentType(MediaType.APPLICATION_JSON); + createHeaders.add("X-USER-ID", userId); + HttpEntity createHttpEntity = new HttpEntity<>(createRequest, createHeaders); + + // PG 결제 요청 타임아웃 Mock (주문은 PENDING 상태로 유지) + doThrow(new RuntimeException(new SocketTimeoutException("Request timeout"))) + .when(paymentGatewayClient).requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)); + + ParameterizedTypeReference> createResponseType = + new ParameterizedTypeReference<>() {}; + ResponseEntity> createResponse = + testRestTemplate.exchange(ENDPOINT_ORDERS, HttpMethod.POST, createHttpEntity, createResponseType); + + Long orderId = createResponse.getBody().data().orderId(); + String transactionKey = "TXN123456"; + + // 상태 복구를 위한 PG 조회 API Mock (SUCCESS 상태 반환) + PaymentGatewayDto.ApiResponse pgInquiryResponse = + new PaymentGatewayDto.ApiResponse<>( + new PaymentGatewayDto.ApiResponse.Metadata( + PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS, + null, + null + ), + new PaymentGatewayDto.OrderResponse( + String.format("%06d", orderId), + List.of( + new PaymentGatewayDto.TransactionResponse( + transactionKey, + PaymentGatewayDto.TransactionStatus.SUCCESS, + null + ) + ) + ) + ); + when(paymentGatewaySchedulerClient.getTransactionsByOrder(eq(userId), eq(String.format("%06d", orderId)))) + .thenReturn(pgInquiryResponse); + + HttpHeaders recoverHeaders = new HttpHeaders(); + recoverHeaders.add("X-USER-ID", userId); + HttpEntity recoverHttpEntity = new HttpEntity<>(recoverHeaders); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_RECOVER, + HttpMethod.POST, + recoverHttpEntity, + new ParameterizedTypeReference>() {}, + orderId + ); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS) + ); + } + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/testutil/CircuitBreakerTestUtil.java b/apps/commerce-api/src/test/java/com/loopers/testutil/CircuitBreakerTestUtil.java new file mode 100644 index 000000000..96ce26e96 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/testutil/CircuitBreakerTestUtil.java @@ -0,0 +1,152 @@ +package com.loopers.testutil; + +import io.github.resilience4j.circuitbreaker.CircuitBreaker; +import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Circuit Breaker 테스트 유틸리티. + *

+ * Circuit Breaker를 특정 상태로 만들거나, 실패를 유발하여 Circuit Breaker를 열리게 하는 유틸리티 메서드를 제공합니다. + *

+ */ +@Component +public class CircuitBreakerTestUtil { + + private static final Logger log = LoggerFactory.getLogger(CircuitBreakerTestUtil.class); + private final CircuitBreakerRegistry circuitBreakerRegistry; + + @Autowired + public CircuitBreakerTestUtil(CircuitBreakerRegistry circuitBreakerRegistry) { + this.circuitBreakerRegistry = circuitBreakerRegistry; + } + + /** + * Circuit Breaker를 OPEN 상태로 전환합니다. + * + * @param circuitBreakerName Circuit Breaker 이름 (예: "paymentGatewayClient") + */ + public void openCircuitBreaker(String circuitBreakerName) { + CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(circuitBreakerName); + if (circuitBreaker != null) { + circuitBreaker.transitionToOpenState(); + log.info("Circuit Breaker '{}'를 OPEN 상태로 전환했습니다.", circuitBreakerName); + } else { + log.warn("Circuit Breaker '{}'를 찾을 수 없습니다.", circuitBreakerName); + } + } + + /** + * Circuit Breaker를 HALF_OPEN 상태로 전환합니다. + * + * @param circuitBreakerName Circuit Breaker 이름 + */ + public void halfOpenCircuitBreaker(String circuitBreakerName) { + CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(circuitBreakerName); + if (circuitBreaker != null) { + circuitBreaker.transitionToHalfOpenState(); + log.info("Circuit Breaker '{}'를 HALF_OPEN 상태로 전환했습니다.", circuitBreakerName); + } else { + log.warn("Circuit Breaker '{}'를 찾을 수 없습니다.", circuitBreakerName); + } + } + + /** + * Circuit Breaker를 CLOSED 상태로 리셋합니다. + * + * @param circuitBreakerName Circuit Breaker 이름 + */ + public void resetCircuitBreaker(String circuitBreakerName) { + CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(circuitBreakerName); + if (circuitBreaker != null) { + circuitBreaker.reset(); + log.info("Circuit Breaker '{}'를 리셋했습니다.", circuitBreakerName); + } else { + log.warn("Circuit Breaker '{}'를 찾을 수 없습니다.", circuitBreakerName); + } + } + + /** + * Circuit Breaker의 현재 상태를 반환합니다. + * + * @param circuitBreakerName Circuit Breaker 이름 + * @return Circuit Breaker 상태 (CLOSED, OPEN, HALF_OPEN) + */ + public CircuitBreaker.State getCircuitBreakerState(String circuitBreakerName) { + CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(circuitBreakerName); + if (circuitBreaker != null) { + return circuitBreaker.getState(); + } + return null; + } + + /** + * 실패를 유발하여 Circuit Breaker를 OPEN 상태로 만듭니다. + *

+ * 이 메서드는 실패 임계값을 초과하도록 여러 번 실패를 유발합니다. + *

+ * + * @param circuitBreakerName Circuit Breaker 이름 + * @param failureFunction 실패를 유발하는 함수 (예: PG API 호출) + * @param minFailures 최소 실패 횟수 (실패율 임계값을 초과하기 위해 필요한 실패 횟수) + */ + public void triggerCircuitBreakerOpen(String circuitBreakerName, Runnable failureFunction, int minFailures) { + log.info("Circuit Breaker '{}'를 OPEN 상태로 만들기 위해 {}번의 실패를 유발합니다.", circuitBreakerName, minFailures); + + // Circuit Breaker 리셋 + resetCircuitBreaker(circuitBreakerName); + + // 실패 유발 + AtomicInteger failureCount = new AtomicInteger(0); + for (int i = 0; i < minFailures; i++) { + try { + failureFunction.run(); + } catch (Exception e) { + failureCount.incrementAndGet(); + log.debug("실패 {}번 발생: {}", failureCount.get(), e.getMessage()); + } + } + + log.info("총 {}번의 실패를 유발했습니다. Circuit Breaker 상태: {}", + failureCount.get(), getCircuitBreakerState(circuitBreakerName)); + } + + /** + * Circuit Breaker가 OPEN 상태인지 확인합니다. + * + * @param circuitBreakerName Circuit Breaker 이름 + * @return OPEN 상태이면 true + */ + public boolean isCircuitBreakerOpen(String circuitBreakerName) { + CircuitBreaker.State state = getCircuitBreakerState(circuitBreakerName); + return state == CircuitBreaker.State.OPEN; + } + + /** + * Circuit Breaker가 HALF_OPEN 상태인지 확인합니다. + * + * @param circuitBreakerName Circuit Breaker 이름 + * @return HALF_OPEN 상태이면 true + */ + public boolean isCircuitBreakerHalfOpen(String circuitBreakerName) { + CircuitBreaker.State state = getCircuitBreakerState(circuitBreakerName); + return state == CircuitBreaker.State.HALF_OPEN; + } + + /** + * Circuit Breaker가 CLOSED 상태인지 확인합니다. + * + * @param circuitBreakerName Circuit Breaker 이름 + * @return CLOSED 상태이면 true + */ + public boolean isCircuitBreakerClosed(String circuitBreakerName) { + CircuitBreaker.State state = getCircuitBreakerState(circuitBreakerName); + return state == CircuitBreaker.State.CLOSED; + } +} + diff --git a/apps/pg-simulator/README.md b/apps/pg-simulator/README.md new file mode 100644 index 000000000..118642638 --- /dev/null +++ b/apps/pg-simulator/README.md @@ -0,0 +1,42 @@ +## PG-Simulator (PaymentGateway) + +### Description +Loopback BE 과정을 위해 PaymentGateway 를 시뮬레이션하는 App Module 입니다. +`local` 프로필로 실행 권장하며, 커머스 서비스와의 동시 실행을 위해 서버 포트가 조정되어 있습니다. +- server port : 8082 +- actuator port : 8083 + +### Getting Started +부트 서버를 아래 명령어 혹은 `intelliJ` 통해 실행해주세요. +```shell +./gradlew :apps:pg-simulator:bootRun +``` + +API 는 아래와 같이 주어지니, 커머스 서비스와 동시에 실행시킨 후 진행해주시면 됩니다. +- 결제 요청 API +- 결제 정보 확인 `by transactionKey` +- 결제 정보 목록 조회 `by orderId` + +```http request +### 결제 요청 +POST {{pg-simulator}}/api/v1/payments +X-USER-ID: 135135 +Content-Type: application/json + +{ + "orderId": "1351039135", + "cardType": "SAMSUNG", + "cardNo": "1234-5678-9814-1451", + "amount" : "5000", + "callbackUrl": "http://localhost:8080/api/v1/examples/callback" +} + +### 결제 정보 확인 +GET {{pg-simulator}}/api/v1/payments/20250816:TR:9577c5 +X-USER-ID: 135135 + +### 주문에 엮인 결제 정보 조회 +GET {{pg-simulator}}/api/v1/payments?orderId=1351039135 +X-USER-ID: 135135 + +``` \ No newline at end of file diff --git a/apps/pg-simulator/build.gradle.kts b/apps/pg-simulator/build.gradle.kts new file mode 100644 index 000000000..653d549da --- /dev/null +++ b/apps/pg-simulator/build.gradle.kts @@ -0,0 +1,40 @@ +plugins { + val kotlinVersion = "2.0.20" + + id("org.jetbrains.kotlin.jvm") version(kotlinVersion) + id("org.jetbrains.kotlin.kapt") version(kotlinVersion) + id("org.jetbrains.kotlin.plugin.spring") version(kotlinVersion) + id("org.jetbrains.kotlin.plugin.jpa") version(kotlinVersion) +} + +kotlin { + compilerOptions { + jvmToolchain(21) + freeCompilerArgs.addAll("-Xjsr305=strict") + } +} + +dependencies { + // add-ons + implementation(project(":modules:jpa")) + implementation(project(":modules:redis")) + implementation(project(":supports:jackson")) + implementation(project(":supports:logging")) + implementation(project(":supports:monitoring")) + + // kotlin + implementation("org.jetbrains.kotlin:kotlin-reflect") + implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") + + // web + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${project.properties["springDocOpenApiVersion"]}") + + // querydsl + kapt("com.querydsl:querydsl-apt::jakarta") + + // test-fixtures + testImplementation(testFixtures(project(":modules:jpa"))) + testImplementation(testFixtures(project(":modules:redis"))) +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/PaymentGatewayApplication.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/PaymentGatewayApplication.kt new file mode 100644 index 000000000..05595d135 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/PaymentGatewayApplication.kt @@ -0,0 +1,24 @@ +package com.loopers + +import jakarta.annotation.PostConstruct +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.context.properties.ConfigurationPropertiesScan +import org.springframework.boot.runApplication +import org.springframework.scheduling.annotation.EnableAsync +import java.util.TimeZone + +@ConfigurationPropertiesScan +@EnableAsync +@SpringBootApplication +class PaymentGatewayApplication { + + @PostConstruct + fun started() { + // set timezone + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")) + } +} + +fun main(args: Array) { + runApplication(*args) +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/OrderInfo.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/OrderInfo.kt new file mode 100644 index 000000000..7e04d1ce0 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/OrderInfo.kt @@ -0,0 +1,14 @@ +package com.loopers.application.payment + +/** + * 결제 주문 정보 + * + * 결제는 주문에 대한 다수 트랜잭션으로 구성됩니다. + * + * @property orderId 주문 정보 + * @property transactions 주문에 엮인 트랜잭션 목록 + */ +data class OrderInfo( + val orderId: String, + val transactions: List, +) diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/PaymentApplicationService.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/PaymentApplicationService.kt new file mode 100644 index 000000000..9a5ebdc5d --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/PaymentApplicationService.kt @@ -0,0 +1,88 @@ +package com.loopers.application.payment + +import com.loopers.domain.payment.Payment +import com.loopers.domain.payment.PaymentEvent +import com.loopers.domain.payment.PaymentEventPublisher +import com.loopers.domain.payment.PaymentRelay +import com.loopers.domain.payment.PaymentRepository +import com.loopers.domain.payment.TransactionKeyGenerator +import com.loopers.domain.user.UserInfo +import com.loopers.support.error.CoreException +import com.loopers.support.error.ErrorType +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional + +@Component +class PaymentApplicationService( + private val paymentRepository: PaymentRepository, + private val paymentEventPublisher: PaymentEventPublisher, + private val paymentRelay: PaymentRelay, + private val transactionKeyGenerator: TransactionKeyGenerator, +) { + companion object { + private val RATE_LIMIT_EXCEEDED = (1..20) + private val RATE_INVALID_CARD = (21..30) + } + + @Transactional + fun createTransaction(command: PaymentCommand.CreateTransaction): TransactionInfo { + command.validate() + + val transactionKey = transactionKeyGenerator.generate() + val payment = paymentRepository.save( + Payment( + transactionKey = transactionKey, + userId = command.userId, + orderId = command.orderId, + cardType = command.cardType, + cardNo = command.cardNo, + amount = command.amount, + callbackUrl = command.callbackUrl, + ), + ) + + paymentEventPublisher.publish(PaymentEvent.PaymentCreated.from(payment = payment)) + + return TransactionInfo.from(payment) + } + + @Transactional(readOnly = true) + fun getTransactionDetailInfo(userInfo: UserInfo, transactionKey: String): TransactionInfo { + val payment = paymentRepository.findByTransactionKey(userId = userInfo.userId, transactionKey = transactionKey) + ?: throw CoreException(ErrorType.NOT_FOUND, "(transactionKey: $transactionKey) 결제건이 존재하지 않습니다.") + return TransactionInfo.from(payment) + } + + @Transactional(readOnly = true) + fun findTransactionsByOrderId(userInfo: UserInfo, orderId: String): OrderInfo { + val payments = paymentRepository.findByOrderId(userId = userInfo.userId, orderId = orderId) + if (payments.isEmpty()) { + throw CoreException(ErrorType.NOT_FOUND, "(orderId: $orderId) 에 해당하는 결제건이 존재하지 않습니다.") + } + + return OrderInfo( + orderId = orderId, + transactions = payments.map { TransactionInfo.from(it) }, + ) + } + + @Transactional + fun handle(transactionKey: String) { + val payment = paymentRepository.findByTransactionKey(transactionKey) + ?: throw CoreException(ErrorType.NOT_FOUND, "(transactionKey: $transactionKey) 결제건이 존재하지 않습니다.") + + val rate = (1..100).random() + when (rate) { + in RATE_LIMIT_EXCEEDED -> payment.limitExceeded() + in RATE_INVALID_CARD -> payment.invalidCard() + else -> payment.approve() + } + paymentEventPublisher.publish(event = PaymentEvent.PaymentHandled.from(payment)) + } + + fun notifyTransactionResult(transactionKey: String) { + val payment = paymentRepository.findByTransactionKey(transactionKey) + ?: throw CoreException(ErrorType.NOT_FOUND, "(transactionKey: $transactionKey) 결제건이 존재하지 않습니다.") + paymentRelay.notify(callbackUrl = payment.callbackUrl, transactionInfo = TransactionInfo.from(payment)) + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/PaymentCommand.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/PaymentCommand.kt new file mode 100644 index 000000000..01d8ae440 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/PaymentCommand.kt @@ -0,0 +1,22 @@ +package com.loopers.application.payment + +import com.loopers.domain.payment.CardType +import com.loopers.support.error.CoreException +import com.loopers.support.error.ErrorType + +object PaymentCommand { + data class CreateTransaction( + val userId: String, + val orderId: String, + val cardType: CardType, + val cardNo: String, + val amount: Long, + val callbackUrl: String, + ) { + fun validate() { + if (amount <= 0L) { + throw CoreException(ErrorType.BAD_REQUEST, "요청 금액은 0 보다 큰 정수여야 합니다.") + } + } + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/TransactionInfo.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/TransactionInfo.kt new file mode 100644 index 000000000..5c21e51af --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/TransactionInfo.kt @@ -0,0 +1,39 @@ +package com.loopers.application.payment + +import com.loopers.domain.payment.CardType +import com.loopers.domain.payment.Payment +import com.loopers.domain.payment.TransactionStatus + +/** + * 트랜잭션 정보 + * + * @property transactionKey 트랜잭션 KEY + * @property orderId 주문 ID + * @property cardType 카드 종류 + * @property cardNo 카드 번호 + * @property amount 금액 + * @property status 처리 상태 + * @property reason 처리 사유 + */ +data class TransactionInfo( + val transactionKey: String, + val orderId: String, + val cardType: CardType, + val cardNo: String, + val amount: Long, + val status: TransactionStatus, + val reason: String?, +) { + companion object { + fun from(payment: Payment): TransactionInfo = + TransactionInfo( + transactionKey = payment.transactionKey, + orderId = payment.orderId, + cardType = payment.cardType, + cardNo = payment.cardNo, + amount = payment.amount, + status = payment.status, + reason = payment.reason, + ) + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/config/web/WebMvcConfig.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/config/web/WebMvcConfig.kt new file mode 100644 index 000000000..8aec9dc82 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/config/web/WebMvcConfig.kt @@ -0,0 +1,13 @@ +package com.loopers.config.web + +import com.loopers.interfaces.api.argumentresolver.UserInfoArgumentResolver +import org.springframework.context.annotation.Configuration +import org.springframework.web.method.support.HandlerMethodArgumentResolver +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer + +@Configuration +class WebMvcConfig : WebMvcConfigurer { + override fun addArgumentResolvers(resolvers: MutableList) { + resolvers.add(UserInfoArgumentResolver()) + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/CardType.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/CardType.kt new file mode 100644 index 000000000..55008a95d --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/CardType.kt @@ -0,0 +1,7 @@ +package com.loopers.domain.payment + +enum class CardType { + SAMSUNG, + KB, + HYUNDAI, +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/Payment.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/Payment.kt new file mode 100644 index 000000000..cfc2386c1 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/Payment.kt @@ -0,0 +1,87 @@ +package com.loopers.domain.payment + +import com.loopers.support.error.CoreException +import com.loopers.support.error.ErrorType +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.Id +import jakarta.persistence.Index +import jakarta.persistence.Table +import java.time.LocalDateTime + +@Entity +@Table( + name = "payments", + indexes = [ + Index(name = "idx_user_transaction", columnList = "user_id, transaction_key"), + Index(name = "idx_user_order", columnList = "user_id, order_id"), + Index(name = "idx_unique_user_order_transaction", columnList = "user_id, order_id, transaction_key", unique = true), + ] +) +class Payment( + @Id + @Column(name = "transaction_key", nullable = false, unique = true) + val transactionKey: String, + + @Column(name = "user_id", nullable = false) + val userId: String, + + @Column(name = "order_id", nullable = false) + val orderId: String, + + @Enumerated(EnumType.STRING) + @Column(name = "card_type", nullable = false) + val cardType: CardType, + + @Column(name = "card_no", nullable = false) + val cardNo: String, + + @Column(name = "amount", nullable = false) + val amount: Long, + + @Column(name = "callback_url", nullable = false) + val callbackUrl: String, +) { + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + var status: TransactionStatus = TransactionStatus.PENDING + private set + + @Column(name = "reason", nullable = true) + var reason: String? = null + private set + + @Column(name = "created_at", nullable = false) + var createdAt: LocalDateTime = LocalDateTime.now() + private set + + @Column(name = "updated_at", nullable = false) + var updatedAt: LocalDateTime = LocalDateTime.now() + private set + + fun approve() { + if (status != TransactionStatus.PENDING) { + throw CoreException(ErrorType.INTERNAL_ERROR, "결제승인은 대기상태에서만 가능합니다.") + } + status = TransactionStatus.SUCCESS + reason = "정상 승인되었습니다." + } + + fun invalidCard() { + if (status != TransactionStatus.PENDING) { + throw CoreException(ErrorType.INTERNAL_ERROR, "결제처리는 대기상태에서만 가능합니다.") + } + status = TransactionStatus.FAILED + reason = "잘못된 카드입니다. 다른 카드를 선택해주세요." + } + + fun limitExceeded() { + if (status != TransactionStatus.PENDING) { + throw CoreException(ErrorType.INTERNAL_ERROR, "한도초과 처리는 대기상태에서만 가능합니다.") + } + status = TransactionStatus.FAILED + reason = "한도초과입니다. 다른 카드를 선택해주세요." + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentEvent.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentEvent.kt new file mode 100644 index 000000000..8e495b2e3 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentEvent.kt @@ -0,0 +1,28 @@ +package com.loopers.domain.payment + +object PaymentEvent { + data class PaymentCreated( + val transactionKey: String, + ) { + companion object { + fun from(payment: Payment): PaymentCreated = PaymentCreated(transactionKey = payment.transactionKey) + } + } + + data class PaymentHandled( + val transactionKey: String, + val status: TransactionStatus, + val reason: String?, + val callbackUrl: String, + ) { + companion object { + fun from(payment: Payment): PaymentHandled = + PaymentHandled( + transactionKey = payment.transactionKey, + status = payment.status, + reason = payment.reason, + callbackUrl = payment.callbackUrl, + ) + } + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentEventPublisher.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentEventPublisher.kt new file mode 100644 index 000000000..251c68319 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentEventPublisher.kt @@ -0,0 +1,6 @@ +package com.loopers.domain.payment + +interface PaymentEventPublisher { + fun publish(event: PaymentEvent.PaymentCreated) + fun publish(event: PaymentEvent.PaymentHandled) +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentRelay.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentRelay.kt new file mode 100644 index 000000000..e622899b2 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentRelay.kt @@ -0,0 +1,7 @@ +package com.loopers.domain.payment + +import com.loopers.application.payment.TransactionInfo + +interface PaymentRelay { + fun notify(callbackUrl: String, transactionInfo: TransactionInfo) +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentRepository.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentRepository.kt new file mode 100644 index 000000000..c1173c0aa --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentRepository.kt @@ -0,0 +1,8 @@ +package com.loopers.domain.payment + +interface PaymentRepository { + fun save(payment: Payment): Payment + fun findByTransactionKey(transactionKey: String): Payment? + fun findByTransactionKey(userId: String, transactionKey: String): Payment? + fun findByOrderId(userId: String, orderId: String): List +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/TransactionKeyGenerator.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/TransactionKeyGenerator.kt new file mode 100644 index 000000000..c8703a763 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/TransactionKeyGenerator.kt @@ -0,0 +1,20 @@ +package com.loopers.domain.payment + +import org.springframework.stereotype.Component +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.UUID + +@Component +class TransactionKeyGenerator { + companion object { + private const val KEY_TRANSACTION = "TR" + private val DATETIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd") + } + + fun generate(): String { + val now = LocalDateTime.now() + val uuid = UUID.randomUUID().toString().replace("-", "").substring(0, 6) + return "${DATETIME_FORMATTER.format(now)}:$KEY_TRANSACTION:$uuid" + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/TransactionStatus.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/TransactionStatus.kt new file mode 100644 index 000000000..0c94bcfb9 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/TransactionStatus.kt @@ -0,0 +1,7 @@ +package com.loopers.domain.payment + +enum class TransactionStatus { + PENDING, + SUCCESS, + FAILED +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/user/UserInfo.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/user/UserInfo.kt new file mode 100644 index 000000000..c51e660a9 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/user/UserInfo.kt @@ -0,0 +1,8 @@ +package com.loopers.domain.user + +/** + * user 정보 + * + * @param userId 유저 식별자 + */ +data class UserInfo(val userId: String) diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreEventPublisher.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreEventPublisher.kt new file mode 100644 index 000000000..715516360 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreEventPublisher.kt @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.payment + +import com.loopers.domain.payment.PaymentEvent +import com.loopers.domain.payment.PaymentEventPublisher +import org.springframework.context.ApplicationEventPublisher +import org.springframework.stereotype.Component + +@Component +class PaymentCoreEventPublisher( + private val applicationEventPublisher: ApplicationEventPublisher, +) : PaymentEventPublisher { + override fun publish(event: PaymentEvent.PaymentCreated) { + applicationEventPublisher.publishEvent(event) + } + + override fun publish(event: PaymentEvent.PaymentHandled) { + applicationEventPublisher.publishEvent(event) + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreRelay.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreRelay.kt new file mode 100644 index 000000000..ffd643c0f --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreRelay.kt @@ -0,0 +1,21 @@ +package com.loopers.infrastructure.payment + +import com.loopers.application.payment.TransactionInfo +import com.loopers.domain.payment.PaymentRelay +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import org.springframework.web.client.RestTemplate + +@Component +class PaymentCoreRelay : PaymentRelay { + companion object { + private val logger = LoggerFactory.getLogger(PaymentCoreRelay::class.java) + private val restTemplate = RestTemplate() + } + + override fun notify(callbackUrl: String, transactionInfo: TransactionInfo) { + runCatching { + restTemplate.postForEntity(callbackUrl, transactionInfo, Any::class.java) + }.onFailure { e -> logger.error("콜백 호출을 실패했습니다. {}", e.message, e) } + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreRepository.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreRepository.kt new file mode 100644 index 000000000..cf521c47d --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreRepository.kt @@ -0,0 +1,32 @@ +package com.loopers.infrastructure.payment + +import com.loopers.domain.payment.Payment +import com.loopers.domain.payment.PaymentRepository +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import kotlin.jvm.optionals.getOrNull + +@Component +class PaymentCoreRepository( + private val paymentJpaRepository: PaymentJpaRepository, +) : PaymentRepository { + @Transactional + override fun save(payment: Payment): Payment { + return paymentJpaRepository.save(payment) + } + + @Transactional(readOnly = true) + override fun findByTransactionKey(transactionKey: String): Payment? { + return paymentJpaRepository.findById(transactionKey).getOrNull() + } + + @Transactional(readOnly = true) + override fun findByTransactionKey(userId: String, transactionKey: String): Payment? { + return paymentJpaRepository.findByUserIdAndTransactionKey(userId, transactionKey) + } + + override fun findByOrderId(userId: String, orderId: String): List { + return paymentJpaRepository.findByUserIdAndOrderId(userId, orderId) + .sortedByDescending { it.updatedAt } + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentJpaRepository.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentJpaRepository.kt new file mode 100644 index 000000000..a5ea32822 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentJpaRepository.kt @@ -0,0 +1,9 @@ +package com.loopers.infrastructure.payment + +import com.loopers.domain.payment.Payment +import org.springframework.data.jpa.repository.JpaRepository + +interface PaymentJpaRepository : JpaRepository { + fun findByUserIdAndTransactionKey(userId: String, transactionKey: String): Payment? + fun findByUserIdAndOrderId(userId: String, orderId: String): List +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiControllerAdvice.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiControllerAdvice.kt new file mode 100644 index 000000000..434a229e2 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiControllerAdvice.kt @@ -0,0 +1,119 @@ +package com.loopers.interfaces.api + +import com.fasterxml.jackson.databind.JsonMappingException +import com.fasterxml.jackson.databind.exc.InvalidFormatException +import com.fasterxml.jackson.databind.exc.MismatchedInputException +import com.loopers.support.error.CoreException +import com.loopers.support.error.ErrorType +import org.slf4j.LoggerFactory +import org.springframework.http.ResponseEntity +import org.springframework.http.converter.HttpMessageNotReadableException +import org.springframework.web.bind.MissingServletRequestParameterException +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException +import org.springframework.web.server.ServerWebInputException +import org.springframework.web.servlet.resource.NoResourceFoundException +import kotlin.collections.joinToString +import kotlin.jvm.java +import kotlin.text.isNotEmpty +import kotlin.text.toRegex + +@RestControllerAdvice +class ApiControllerAdvice { + private val log = LoggerFactory.getLogger(ApiControllerAdvice::class.java) + + @ExceptionHandler + fun handle(e: CoreException): ResponseEntity> { + log.warn("CoreException : {}", e.customMessage ?: e.message, e) + return failureResponse(errorType = e.errorType, errorMessage = e.customMessage) + } + + @ExceptionHandler + fun handleBadRequest(e: MethodArgumentTypeMismatchException): ResponseEntity> { + val name = e.name + val type = e.requiredType?.simpleName ?: "unknown" + val value = e.value ?: "null" + val message = "요청 파라미터 '$name' (타입: $type)의 값 '$value'이(가) 잘못되었습니다." + return failureResponse(errorType = ErrorType.BAD_REQUEST, errorMessage = message) + } + + @ExceptionHandler + fun handleBadRequest(e: MissingServletRequestParameterException): ResponseEntity> { + val name = e.parameterName + val type = e.parameterType + val message = "필수 요청 파라미터 '$name' (타입: $type)가 누락되었습니다." + return failureResponse(errorType = ErrorType.BAD_REQUEST, errorMessage = message) + } + + @ExceptionHandler + fun handleBadRequest(e: HttpMessageNotReadableException): ResponseEntity> { + val errorMessage = when (val rootCause = e.rootCause) { + is InvalidFormatException -> { + val fieldName = rootCause.path.joinToString(".") { it.fieldName ?: "?" } + + val valueIndicationMessage = when { + rootCause.targetType.isEnum -> { + val enumClass = rootCause.targetType + val enumValues = enumClass.enumConstants.joinToString(", ") { it.toString() } + "사용 가능한 값 : [$enumValues]" + } + + else -> "" + } + + val expectedType = rootCause.targetType.simpleName + val value = rootCause.value + + "필드 '$fieldName'의 값 '$value'이(가) 예상 타입($expectedType)과 일치하지 않습니다. $valueIndicationMessage" + } + + is MismatchedInputException -> { + val fieldPath = rootCause.path.joinToString(".") { it.fieldName ?: "?" } + "필수 필드 '$fieldPath'이(가) 누락되었습니다." + } + + is JsonMappingException -> { + val fieldPath = rootCause.path.joinToString(".") { it.fieldName ?: "?" } + "필드 '$fieldPath'에서 JSON 매핑 오류가 발생했습니다: ${rootCause.originalMessage}" + } + + else -> "요청 본문을 처리하는 중 오류가 발생했습니다. JSON 메세지 규격을 확인해주세요." + } + + return failureResponse(errorType = ErrorType.BAD_REQUEST, errorMessage = errorMessage) + } + + @ExceptionHandler + fun handleBadRequest(e: ServerWebInputException): ResponseEntity> { + fun extractMissingParameter(message: String): String { + val regex = "'(.+?)'".toRegex() + return regex.find(message)?.groupValues?.get(1) ?: "" + } + + val missingParams = extractMissingParameter(e.reason ?: "") + return if (missingParams.isNotEmpty()) { + failureResponse(errorType = ErrorType.BAD_REQUEST, errorMessage = "필수 요청 값 \'$missingParams\'가 누락되었습니다.") + } else { + failureResponse(errorType = ErrorType.BAD_REQUEST) + } + } + + @ExceptionHandler + fun handleNotFound(e: NoResourceFoundException): ResponseEntity> { + return failureResponse(errorType = ErrorType.NOT_FOUND) + } + + @ExceptionHandler + fun handle(e: Throwable): ResponseEntity> { + log.error("Exception : {}", e.message, e) + val errorType = ErrorType.INTERNAL_ERROR + return failureResponse(errorType = errorType) + } + + private fun failureResponse(errorType: ErrorType, errorMessage: String? = null): ResponseEntity> = + ResponseEntity( + ApiResponse.fail(errorCode = errorType.code, errorMessage = errorMessage ?: errorType.message), + errorType.status, + ) +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiResponse.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiResponse.kt new file mode 100644 index 000000000..f5c38ab5e --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiResponse.kt @@ -0,0 +1,32 @@ +package com.loopers.interfaces.api + +data class ApiResponse( + val meta: Metadata, + val data: T?, +) { + data class Metadata( + val result: Result, + val errorCode: String?, + val message: String?, + ) { + enum class Result { SUCCESS, FAIL } + + companion object { + fun success() = Metadata(Result.SUCCESS, null, null) + + fun fail(errorCode: String, errorMessage: String) = Metadata(Result.FAIL, errorCode, errorMessage) + } + } + + companion object { + fun success(): ApiResponse = ApiResponse(Metadata.success(), null) + + fun success(data: T? = null) = ApiResponse(Metadata.success(), data) + + fun fail(errorCode: String, errorMessage: String): ApiResponse = + ApiResponse( + meta = Metadata.fail(errorCode = errorCode, errorMessage = errorMessage), + data = null, + ) + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/argumentresolver/UserInfoArgumentResolver.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/argumentresolver/UserInfoArgumentResolver.kt new file mode 100644 index 000000000..9ef6c25da --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/argumentresolver/UserInfoArgumentResolver.kt @@ -0,0 +1,32 @@ +package com.loopers.interfaces.api.argumentresolver + +import com.loopers.domain.user.UserInfo +import com.loopers.support.error.CoreException +import com.loopers.support.error.ErrorType +import org.springframework.core.MethodParameter +import org.springframework.web.bind.support.WebDataBinderFactory +import org.springframework.web.context.request.NativeWebRequest +import org.springframework.web.method.support.HandlerMethodArgumentResolver +import org.springframework.web.method.support.ModelAndViewContainer + +class UserInfoArgumentResolver: HandlerMethodArgumentResolver { + companion object { + private const val KEY_USER_ID = "X-USER-ID" + } + + override fun supportsParameter(parameter: MethodParameter): Boolean { + return UserInfo::class.java.isAssignableFrom(parameter.parameterType) + } + + override fun resolveArgument( + parameter: MethodParameter, + mavContainer: ModelAndViewContainer?, + webRequest: NativeWebRequest, + binderFactory: WebDataBinderFactory?, + ): UserInfo { + val userId = webRequest.getHeader(KEY_USER_ID) + ?: throw CoreException(ErrorType.BAD_REQUEST, "유저 ID 헤더는 필수입니다.") + + return UserInfo(userId) + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentApi.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentApi.kt new file mode 100644 index 000000000..22d5cbe38 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentApi.kt @@ -0,0 +1,60 @@ +package com.loopers.interfaces.api.payment + +import com.loopers.application.payment.PaymentApplicationService +import com.loopers.interfaces.api.ApiResponse +import com.loopers.domain.user.UserInfo +import com.loopers.support.error.CoreException +import com.loopers.support.error.ErrorType +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v1/payments") +class PaymentApi( + private val paymentApplicationService: PaymentApplicationService, +) { + @PostMapping + fun request( + userInfo: UserInfo, + @RequestBody request: PaymentDto.PaymentRequest, + ): ApiResponse { + request.validate() + + // 100ms ~ 500ms 지연 + Thread.sleep((100..500L).random()) + + // 40% 확률로 요청 실패 + if ((1..100).random() <= 40) { + throw CoreException(ErrorType.INTERNAL_ERROR, "현재 서버가 불안정합니다. 잠시 후 다시 시도해주세요.") + } + + return paymentApplicationService.createTransaction(request.toCommand(userInfo.userId)) + .let { PaymentDto.TransactionResponse.from(it) } + .let { ApiResponse.success(it) } + } + + @GetMapping("/{transactionKey}") + fun getTransaction( + userInfo: UserInfo, + @PathVariable("transactionKey") transactionKey: String, + ): ApiResponse { + return paymentApplicationService.getTransactionDetailInfo(userInfo, transactionKey) + .let { PaymentDto.TransactionDetailResponse.from(it) } + .let { ApiResponse.success(it) } + } + + @GetMapping + fun getTransactionsByOrder( + userInfo: UserInfo, + @RequestParam("orderId", required = false) orderId: String, + ): ApiResponse { + return paymentApplicationService.findTransactionsByOrderId(userInfo, orderId) + .let { PaymentDto.OrderResponse.from(it) } + .let { ApiResponse.success(it) } + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentDto.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentDto.kt new file mode 100644 index 000000000..52a00b156 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentDto.kt @@ -0,0 +1,136 @@ +package com.loopers.interfaces.api.payment + +import com.loopers.application.payment.OrderInfo +import com.loopers.application.payment.PaymentCommand +import com.loopers.application.payment.TransactionInfo +import com.loopers.domain.payment.CardType +import com.loopers.domain.payment.TransactionStatus +import com.loopers.support.error.CoreException +import com.loopers.support.error.ErrorType + +object PaymentDto { + data class PaymentRequest( + val orderId: String, + val cardType: CardTypeDto, + val cardNo: String, + val amount: Long, + val callbackUrl: String, + ) { + companion object { + private val REGEX_CARD_NO = Regex("^\\d{4}-\\d{4}-\\d{4}-\\d{4}$") + private const val PREFIX_CALLBACK_URL = "http://localhost:8080" + } + + fun validate() { + if (orderId.isBlank() || orderId.length < 6) { + throw CoreException(ErrorType.BAD_REQUEST, "주문 ID는 6자리 이상 문자열이어야 합니다.") + } + if (!REGEX_CARD_NO.matches(cardNo)) { + throw CoreException(ErrorType.BAD_REQUEST, "카드 번호는 xxxx-xxxx-xxxx-xxxx 형식이어야 합니다.") + } + if (amount <= 0) { + throw CoreException(ErrorType.BAD_REQUEST, "결제금액은 양의 정수여야 합니다.") + } + if (!callbackUrl.startsWith(PREFIX_CALLBACK_URL)) { + throw CoreException(ErrorType.BAD_REQUEST, "콜백 URL 은 $PREFIX_CALLBACK_URL 로 시작해야 합니다.") + } + } + + fun toCommand(userId: String): PaymentCommand.CreateTransaction = + PaymentCommand.CreateTransaction( + userId = userId, + orderId = orderId, + cardType = cardType.toCardType(), + cardNo = cardNo, + amount = amount, + callbackUrl = callbackUrl, + ) + } + + data class TransactionDetailResponse( + val transactionKey: String, + val orderId: String, + val cardType: CardTypeDto, + val cardNo: String, + val amount: Long, + val status: TransactionStatusResponse, + val reason: String?, + ) { + companion object { + fun from(transactionInfo: TransactionInfo): TransactionDetailResponse = + TransactionDetailResponse( + transactionKey = transactionInfo.transactionKey, + orderId = transactionInfo.orderId, + cardType = CardTypeDto.from(transactionInfo.cardType), + cardNo = transactionInfo.cardNo, + amount = transactionInfo.amount, + status = TransactionStatusResponse.from(transactionInfo.status), + reason = transactionInfo.reason, + ) + } + } + + data class TransactionResponse( + val transactionKey: String, + val status: TransactionStatusResponse, + val reason: String?, + ) { + companion object { + fun from(transactionInfo: TransactionInfo): TransactionResponse = + TransactionResponse( + transactionKey = transactionInfo.transactionKey, + status = TransactionStatusResponse.from(transactionInfo.status), + reason = transactionInfo.reason, + ) + } + } + + data class OrderResponse( + val orderId: String, + val transactions: List, + ) { + companion object { + fun from(orderInfo: OrderInfo): OrderResponse = + OrderResponse( + orderId = orderInfo.orderId, + transactions = orderInfo.transactions.map { TransactionResponse.from(it) }, + ) + } + } + + enum class CardTypeDto { + SAMSUNG, + KB, + HYUNDAI, + ; + + fun toCardType(): CardType = when (this) { + SAMSUNG -> CardType.SAMSUNG + KB -> CardType.KB + HYUNDAI -> CardType.HYUNDAI + } + + companion object { + fun from(cardType: CardType) = when (cardType) { + CardType.SAMSUNG -> SAMSUNG + CardType.KB -> KB + CardType.HYUNDAI -> HYUNDAI + } + } + } + + enum class TransactionStatusResponse { + PENDING, + SUCCESS, + FAILED, + ; + + companion object { + fun from(transactionStatus: TransactionStatus) = when (transactionStatus) { + TransactionStatus.PENDING -> PENDING + TransactionStatus.SUCCESS -> SUCCESS + TransactionStatus.FAILED -> FAILED + } + } + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/event/payment/PaymentEventListener.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/event/payment/PaymentEventListener.kt new file mode 100644 index 000000000..241322890 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/event/payment/PaymentEventListener.kt @@ -0,0 +1,28 @@ +package com.loopers.interfaces.event.payment + +import com.loopers.application.payment.PaymentApplicationService +import com.loopers.domain.payment.PaymentEvent +import org.springframework.scheduling.annotation.Async +import org.springframework.stereotype.Component +import org.springframework.transaction.event.TransactionPhase +import org.springframework.transaction.event.TransactionalEventListener + +@Component +class PaymentEventListener( + private val paymentApplicationService: PaymentApplicationService, +) { + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + fun handle(event: PaymentEvent.PaymentCreated) { + val thresholdMillis = (1000L..5000L).random() + Thread.sleep(thresholdMillis) + + paymentApplicationService.handle(event.transactionKey) + } + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + fun handle(event: PaymentEvent.PaymentHandled) { + paymentApplicationService.notifyTransactionResult(transactionKey = event.transactionKey) + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/support/error/CoreException.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/support/error/CoreException.kt new file mode 100644 index 000000000..120f7fc5f --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/support/error/CoreException.kt @@ -0,0 +1,6 @@ +package com.loopers.support.error + +class CoreException( + val errorType: ErrorType, + val customMessage: String? = null, +) : RuntimeException(customMessage ?: errorType.message) diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/support/error/ErrorType.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/support/error/ErrorType.kt new file mode 100644 index 000000000..e0799a5ea --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/support/error/ErrorType.kt @@ -0,0 +1,11 @@ +package com.loopers.support.error + +import org.springframework.http.HttpStatus + +enum class ErrorType(val status: HttpStatus, val code: String, val message: String) { + /** 범용 에러 */ + INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.reasonPhrase, "일시적인 오류가 발생했습니다."), + BAD_REQUEST(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.reasonPhrase, "잘못된 요청입니다."), + NOT_FOUND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.reasonPhrase, "존재하지 않는 요청입니다."), + CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.reasonPhrase, "이미 존재하는 리소스입니다."), +} diff --git a/apps/pg-simulator/src/main/resources/application.yml b/apps/pg-simulator/src/main/resources/application.yml new file mode 100644 index 000000000..addf0e29c --- /dev/null +++ b/apps/pg-simulator/src/main/resources/application.yml @@ -0,0 +1,77 @@ +server: + shutdown: graceful + tomcat: + threads: + max: 200 # 최대 워커 스레드 수 (default : 200) + min-spare: 10 # 최소 유지 스레드 수 (default : 10) + connection-timeout: 1m # 연결 타임아웃 (ms) (default : 60000ms = 1m) + max-connections: 8192 # 최대 동시 연결 수 (default : 8192) + accept-count: 100 # 대기 큐 크기 (default : 100) + keep-alive-timeout: 60s # 60s + max-http-request-header-size: 8KB + +spring: + main: + web-application-type: servlet + application: + name: commerce-api + profiles: + active: local + config: + import: + - jpa.yml + - redis.yml + - logging.yml + - monitoring.yml + +datasource: + mysql-jpa: + main: + jdbc-url: jdbc:mysql://localhost:3306/paymentgateway + +springdoc: + use-fqn: true + swagger-ui: + path: /swagger-ui.html + +--- +spring: + config: + activate: + on-profile: local, test + +server: + port: 8082 + +management: + server: + port: 8083 + +--- +spring: + config: + activate: + on-profile: dev + +server: + port: 8082 + +management: + server: + port: 8083 + +--- +spring: + config: + activate: + on-profile: qa + +--- +spring: + config: + activate: + on-profile: prd + +springdoc: + api-docs: + enabled: false \ No newline at end of file diff --git a/http/pg-simulator/payments.http b/http/pg-simulator/payments.http new file mode 100644 index 000000000..096dd2f0a --- /dev/null +++ b/http/pg-simulator/payments.http @@ -0,0 +1,20 @@ +### 결제 요청 +POST {{pg-simulator}}/api/v1/payments +X-USER-ID: 135135 +Content-Type: application/json + +{ + "orderId": "1351039135", + "cardType": "SAMSUNG", + "cardNo": "1234-5678-9814-1451", + "amount" : "5000", + "callbackUrl": "http://localhost:8080/api/v1/examples/callback" +} + +### 결제 정보 확인 +GET {{pg-simulator}}/api/v1/payments/20250816:TR:9577c5 +X-USER-ID: 135135 + +### 주문에 엮인 결제 정보 조회 +GET {{pg-simulator}}/api/v1/payments?orderId=1351039135 +X-USER-ID: 135135 diff --git a/settings.gradle.kts b/settings.gradle.kts index c99fb6360..161a1ba24 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -2,6 +2,7 @@ rootProject.name = "loopers-java-spring-template" include( ":apps:commerce-api", + ":apps:pg-simulator", ":apps:commerce-streamer", ":modules:jpa", ":modules:redis", From fb769b27dd0674e4c6ee4c0a963dc05f89945579 Mon Sep 17 00:00:00 2001 From: minor7295 <44902090+minor7295@users.noreply.github.com> Date: Sun, 14 Dec 2025 23:08:35 +0900 Subject: [PATCH 08/12] [volume-7] Decoupling with Event (#171) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Feature/refactor purchasing (#30) * feat: PG 모듈 추가 (#24) * Feature/pg client (#27) * test: PG 호출 테스트 코드 추가 * feat: PG 호출 로직 구현 * test: PG CircuitBreaker 테스트 코드 추가 * feat: CircuitBreaker 로직 구현 * test: payment 도메인 단위 테스트 추가 * feat: payment 도메인 구현 * test: PaymentService 도메인 서비스 테스트 코드 추가 * feat: PaymentService 도메인 서비스 구현 * refactor: payment 도메인의 책임이지만 order 도메인에 있던 로직들 이동 * test: Order 도메인 서비스 로직 테스트 코드 작성 * refactor: Order 도메인의 도메인 서비스 로직 재구성 * refactor: purchasing facade에서 처리하고 있던 내용 중 도메인 레이어로 위임할 수 있는 내용들 재배치 * refactor: DIP 원칙에 맞춰 PG 로직에서 infra 레이어가 domain 레이어를 의존하도록 재구성 * refactor: payment 관련 스케줄링 로직들은 infra 레이어로 이동 * refactor: PurchasingFacade가 repository를 직접 사용하지 않도록 도메인 서비스 로직 재구성 * refactor: PuhrchasingFacade가 도메인 서비스를 조합해서 사용하는 역할만 담당하도록 재구성 * refactor: 재구성한 도메인 서비스 로직에 맞춰 테스트 코드 재구성 * refactor: 주문 결제시 포인트 또는 카드만을 사용하여 결제 할 수 있도록 수정 * refactor: 포인트 또는 카드를 사용하여 결제할 수 있도록 테스트 코드 재구성 * refactor: 다른 application레이어와 동일하게 command의 위치를 domain에서 application으로 이동 * refactor: Order도메인 서비스에 대한 테스트 코드 중 application 레이어에 더 적합한 부분 분리 * refactor: Order 도메인 서비스에 있던 내용 중 어플리케이션 서비스에 해당하는 부분들은 application 레이어로 이동시킴 * refactor: infrastructure 레이어에 domain레이어와 달리 payment와 paymentgateway가 구분되어있어 통일함 * chore: 중복되는 로그 정리, 불필요하게 높은 level인 로그는 debug 로그로 전환 * Feature/refactor application (#31) * refactor: application 레이어를 도메인별 어플리케이션 서비스와 어플리케이션의 조합인 facade로 분리하여 구성 * refactor: application가 도메인별 어플리케이션 서비스와 파사드로 구분된 것에 맞춰 테스트 코드 수정 * refactor: scheduler를 infrastructure 레이어로 이동 * refactor: 도메인의 캡슐화가 부족한 부분 개선 * refactor: 캐시 사용할 때 dip 적용하여 application레이어가 infrastructure 레이어를 참조하지 않도록 수정 (#32) * Feature/event (#34) * feat: 도메인별 event 추가 * feat: DIP 적용하여 event publisher 구현 * refactor: 좋아요 수 집계를 스케줄 기반 처리하는 것에서 이벤트 기반 처리하는 것으로 변경 * feat: 쿠폰 이벤트 처리하는 로직 추가 * feat: order 도메인의 이벤트 처리 로직 추가 * feat: payment 도메인의 이벤트 처리 로직 추가 * feat: point 도메인의 이벤트 처리 로직 추가 * feat: product 도메인의 이벤트 처리 로직 추가 * 도메인이벤트 * refactor: HeartFacade에서 좋아요 처리시 Product 어플리케이션 서비스를 직접호출하지 않고 이벤트 사용하는 방식으로 재구성 * refactor: PurchasingFacade에서 주문 처리시 어플리케이션 서비스를 직접호출하지 않고 이벤트 사용하는 방식으로 재구성 * test: eventhandler에 대한 테스트 추가 * refactor: event handler 테스트에 맞춰 코드 수정 * feat: 데이터 플랫폼으로 주문 데이터 전송하는 로직 추가 * feat: event 및 command 재구성 (#35) * feat: event 및 command 재구성 (#35) event * code rabbit --- .../com/loopers/CommerceApiApplication.java | 2 + .../BrandService.java} | 39 +- ...gProductFacade.java => CatalogFacade.java} | 23 +- .../coupon/ApplyCouponCommand.java | 30 + .../coupon/CouponEventHandler.java | 116 +++ .../application/coupon/CouponService.java | 112 +++ .../HeartFacade.java} | 117 ++-- .../loopers/application/like/LikeService.java | 87 +++ .../application/order/CreateOrderCommand.java | 42 ++ .../application/order/OrderEventHandler.java | 148 ++++ .../application/order/OrderService.java | 334 +++++++++ .../payment/PaymentEventHandler.java | 234 +++++++ .../payment/PaymentRequestCommand.java | 73 ++ .../application/payment/PaymentService.java | 482 +++++++++++++ .../pointwallet/PointWalletFacade.java | 83 --- .../product/DeductStockCommand.java | 27 + .../ProductCacheService.java | 101 +-- .../product/ProductEventHandler.java | 214 ++++++ .../application/product/ProductService.java | 148 ++++ .../purchasing/PaymentFailureHandler.java | 107 --- .../purchasing/PaymentRecoveryService.java | 66 -- .../purchasing/PaymentRequest.java | 20 - .../purchasing/PaymentRequestBuilder.java | 167 ----- .../purchasing/PurchasingFacade.java | 626 +++++++---------- .../scheduler/LikeCountSyncScheduler.java | 98 --- .../application/signup/SignUpFacade.java | 74 -- .../application/signup/SignUpInfo.java | 38 - .../application/user/DeductPointCommand.java | 22 + .../application/user/PointEventHandler.java | 147 ++++ .../loopers/application/user/UserService.java | 191 +++++ .../application/userinfo/UserInfoFacade.java | 69 -- .../config/Resilience4jRetryConfig.java | 6 +- .../batch/LikeCountSyncBatchConfig.java | 199 ------ .../loopers/domain/coupon/CouponEvent.java | 124 ++++ .../domain/coupon/CouponEventPublisher.java | 29 + .../domain/coupon/UserCouponRepository.java | 9 + .../com/loopers/domain/like/LikeEvent.java | 94 +++ .../domain/like/LikeEventPublisher.java | 29 + .../java/com/loopers/domain/order/Order.java | 47 ++ .../order/OrderCancellationService.java | 106 --- .../com/loopers/domain/order/OrderEvent.java | 227 ++++++ .../domain/order/OrderEventPublisher.java | 35 + .../domain/order/OrderStatusUpdater.java | 100 --- .../order/PaymentFailureClassifier.java | 74 -- .../domain/order/PaymentFailureType.java | 25 - .../com/loopers/domain/payment/CardType.java | 11 + .../com/loopers/domain/payment/Payment.java | 374 ++++++++++ .../loopers/domain/payment/PaymentEvent.java | 266 +++++++ .../domain/payment/PaymentEventPublisher.java | 35 + .../domain/payment/PaymentFailureType.java | 80 +++ .../domain/payment/PaymentGateway.java | 35 + .../domain/payment/PaymentRepository.java | 54 ++ .../domain/payment/PaymentRequest.java | 54 ++ .../domain/payment/PaymentRequestResult.java | 30 + .../{order => payment}/PaymentResult.java | 2 +- .../loopers/domain/payment/PaymentStatus.java | 29 + .../com/loopers/domain/product/Product.java | 34 +- .../com/loopers/domain/user/PointEvent.java | 114 +++ .../domain/user/PointEventPublisher.java | 29 + .../java/com/loopers/domain/user/User.java | 9 + .../loopers/domain/user/UserRepository.java | 19 + .../com/loopers/domain/user/UserService.java | 46 -- .../coupon/CouponEventPublisherImpl.java | 35 + .../coupon/UserCouponRepositoryImpl.java | 8 + .../like/LikeEventPublisherImpl.java | 35 + .../order/OrderEventPublisherImpl.java | 39 ++ .../order/OrderJpaRepository.java | 3 +- .../order/OrderRepositoryImpl.java | 3 +- .../DelayProvider.java | 3 +- .../payment/PaymentEventPublisherImpl.java | 39 ++ .../PaymentGatewayClient.java | 3 +- .../PaymentGatewayDto.java | 3 +- .../PaymentGatewayImpl.java} | 66 +- .../PaymentGatewayMetrics.java | 3 +- .../PaymentGatewaySchedulerClient.java | 3 +- .../payment/PaymentJpaRepository.java | 20 + .../payment/PaymentRepositoryImpl.java | 46 ++ .../ThreadDelayProvider.java | 3 +- .../scheduler}/PaymentRecoveryScheduler.java | 10 +- .../user/PointEventPublisherImpl.java | 35 + .../user/UserJpaRepository.java | 21 + .../user/UserRepositoryImpl.java | 8 + .../api/catalog/BrandV1Controller.java | 6 +- .../interfaces/api/catalog/BrandV1Dto.java | 4 +- .../api/catalog/ProductV1Controller.java | 8 +- .../interfaces/api/like/LikeV1Controller.java | 10 +- .../interfaces/api/like/LikeV1Dto.java | 6 +- .../pointwallet/PointWalletV1Controller.java | 8 +- .../api/pointwallet/PointWalletV1Dto.java | 4 +- .../purchasing/PurchasingV1Controller.java | 3 +- .../api/purchasing/PurchasingV1Dto.java | 7 +- .../api/signup/SignUpV1Controller.java | 36 +- .../interfaces/api/signup/SignUpV1Dto.java | 18 +- .../api/userinfo/UserInfoV1Controller.java | 11 +- .../api/userinfo/UserInfoV1Dto.java | 16 +- .../event/coupon/CouponEventListener.java | 84 +++ .../event/data/DataEventListener.java | 81 +++ .../event/order/OrderEventListener.java | 92 +++ .../event/payment/PaymentEventListener.java | 76 ++ .../event/product/ProductEventListener.java | 131 ++++ .../event/user/PointEventListener.java | 73 ++ .../coupon/CouponEventHandlerTest.java | 301 ++++++++ .../HeartFacadeConcurrencyTest.java} | 32 +- .../HeartFacadeTest.java} | 119 ++-- .../product/ProductEventHandlerTest.java | 115 +++ .../PurchasingFacadeCircuitBreakerTest.java | 253 +------ .../PurchasingFacadeConcurrencyTest.java | 358 ---------- .../PurchasingFacadePaymentCallbackTest.java | 14 +- .../PurchasingFacadePaymentGatewayTest.java | 13 +- .../purchasing/PurchasingFacadeTest.java | 175 ++--- .../signup/SignUpFacadeIntegrationTest.java | 88 --- .../user/PointEventHandlerTest.java | 175 +++++ .../UserServiceIntegrationTest.java} | 86 ++- .../UserInfoFacadeIntegrationTest.java | 80 --- .../domain/coupon/CouponServiceTest.java | 198 ++++++ .../domain/order/OrderServiceTest.java | 473 +++++++++++++ .../domain/payment/PaymentServiceTest.java | 662 ++++++++++++++++++ .../loopers/domain/payment/PaymentTest.java | 306 ++++++++ .../domain/payment/PaymentTestFixture.java | 30 + .../domain/product/ProductServiceTest.java | 94 +++ .../loopers/domain/user/UserServiceTest.java | 165 +++++ .../PaymentGatewayClientTest.java | 3 +- .../api/PointWalletV1ApiE2ETest.java | 13 +- .../api/PurchasingV1ApiE2ETest.java | 39 +- .../interfaces/api/UserInfoV1ApiE2ETest.java | 11 +- modules/redis/build.gradle.kts | 1 + .../main/java/com/loopers/cache/CacheKey.java | 41 ++ .../cache/CacheSerializationException.java | 15 + .../java/com/loopers/cache/CacheTemplate.java | 55 ++ .../com/loopers/cache/RedisCacheTemplate.java | 95 +++ .../com/loopers/cache/SimpleCacheKey.java | 34 + .../com/loopers/cache/NoOpCacheTemplate.java | 37 + 132 files changed, 8649 insertions(+), 2854 deletions(-) rename apps/commerce-api/src/main/java/com/loopers/application/{catalog/CatalogBrandFacade.java => brand/BrandService.java} (57%) rename apps/commerce-api/src/main/java/com/loopers/application/catalog/{CatalogProductFacade.java => CatalogFacade.java} (85%) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/coupon/ApplyCouponCommand.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponEventHandler.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponService.java rename apps/commerce-api/src/main/java/com/loopers/application/{like/LikeFacade.java => heart/HeartFacade.java} (61%) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/CreateOrderCommand.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderEventHandler.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentEventHandler.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentRequestCommand.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentService.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/pointwallet/PointWalletFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/DeductStockCommand.java rename apps/commerce-api/src/main/java/com/loopers/application/{catalog => product}/ProductCacheService.java (75%) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductEventHandler.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentFailureHandler.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRecoveryService.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRequest.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRequestBuilder.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/scheduler/LikeCountSyncScheduler.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/signup/SignUpFacade.java delete 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/application/user/DeductPointCommand.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/user/PointEventHandler.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/user/UserService.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/userinfo/UserInfoFacade.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/config/batch/LikeCountSyncBatchConfig.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponEvent.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponEventPublisher.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/LikeEvent.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/LikeEventPublisher.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderCancellationService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderEvent.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderEventPublisher.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatusUpdater.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/PaymentFailureClassifier.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/PaymentFailureType.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/payment/CardType.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/PaymentEvent.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentEventPublisher.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentFailureType.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentGateway.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRequest.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRequestResult.java rename apps/commerce-api/src/main/java/com/loopers/domain/{order => payment}/PaymentResult.java (97%) 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/user/PointEvent.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/PointEventPublisher.java delete 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/coupon/CouponEventPublisherImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeEventPublisherImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderEventPublisherImpl.java rename apps/commerce-api/src/main/java/com/loopers/infrastructure/{paymentgateway => payment}/DelayProvider.java (89%) create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentEventPublisherImpl.java rename apps/commerce-api/src/main/java/com/loopers/infrastructure/{paymentgateway => payment}/PaymentGatewayClient.java (98%) rename apps/commerce-api/src/main/java/com/loopers/infrastructure/{paymentgateway => payment}/PaymentGatewayDto.java (98%) rename apps/commerce-api/src/main/java/com/loopers/infrastructure/{paymentgateway/PaymentGatewayAdapter.java => payment/PaymentGatewayImpl.java} (66%) rename apps/commerce-api/src/main/java/com/loopers/infrastructure/{paymentgateway => payment}/PaymentGatewayMetrics.java (97%) rename apps/commerce-api/src/main/java/com/loopers/infrastructure/{paymentgateway => payment}/PaymentGatewaySchedulerClient.java (97%) create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java rename apps/commerce-api/src/main/java/com/loopers/infrastructure/{paymentgateway => payment}/ThreadDelayProvider.java (88%) rename apps/commerce-api/src/main/java/com/loopers/{application/purchasing => infrastructure/scheduler}/PaymentRecoveryScheduler.java (92%) create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/user/PointEventPublisherImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/event/coupon/CouponEventListener.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/event/data/DataEventListener.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/event/order/OrderEventListener.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/event/payment/PaymentEventListener.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/event/product/ProductEventListener.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/event/user/PointEventListener.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponEventHandlerTest.java rename apps/commerce-api/src/test/java/com/loopers/application/{like/LikeFacadeConcurrencyTest.java => heart/HeartFacadeConcurrencyTest.java} (93%) rename apps/commerce-api/src/test/java/com/loopers/application/{like/LikeFacadeTest.java => heart/HeartFacadeTest.java} (61%) create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/product/ProductEventHandlerTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeConcurrencyTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/application/signup/SignUpFacadeIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/user/PointEventHandlerTest.java rename apps/commerce-api/src/test/java/com/loopers/application/{pointwallet/PointWalletFacadeIntegrationTest.java => user/UserServiceIntegrationTest.java} (53%) delete mode 100644 apps/commerce-api/src/test/java/com/loopers/application/userinfo/UserInfoFacadeIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponServiceTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentServiceTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentTestFixture.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java rename apps/commerce-api/src/test/java/com/loopers/infrastructure/{paymentgateway => payment}/PaymentGatewayClientTest.java (99%) create mode 100644 modules/redis/src/main/java/com/loopers/cache/CacheKey.java create mode 100644 modules/redis/src/main/java/com/loopers/cache/CacheSerializationException.java create mode 100644 modules/redis/src/main/java/com/loopers/cache/CacheTemplate.java create mode 100644 modules/redis/src/main/java/com/loopers/cache/RedisCacheTemplate.java create mode 100644 modules/redis/src/main/java/com/loopers/cache/SimpleCacheKey.java create mode 100644 modules/redis/src/testFixtures/java/com/loopers/cache/NoOpCacheTemplate.java diff --git a/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java b/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java index 659d8ccdb..a15cdca7a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java +++ b/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java @@ -5,12 +5,14 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableScheduling; import java.util.TimeZone; @ConfigurationPropertiesScan @SpringBootApplication @EnableScheduling +@EnableAsync @EnableFeignClients public class CommerceApiApplication { diff --git a/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogBrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java similarity index 57% rename from apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogBrandFacade.java rename to apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java index 4cdc2f177..94f1a63b8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogBrandFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java @@ -1,4 +1,4 @@ -package com.loopers.application.catalog; +package com.loopers.application.brand; import com.loopers.domain.brand.Brand; import com.loopers.domain.brand.BrandRepository; @@ -6,6 +6,9 @@ import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; /** * 브랜드 조회 파사드. @@ -18,9 +21,36 @@ */ @RequiredArgsConstructor @Component -public class CatalogBrandFacade { +public class BrandService { private final BrandRepository brandRepository; + /** + * 브랜드 ID로 브랜드를 조회합니다. + * + * @param brandId 브랜드 ID + * @return 조회된 브랜드 + * @throws CoreException 브랜드를 찾을 수 없는 경우 + */ + @Transactional(readOnly = true) + public Brand getBrand(Long brandId) { + return brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); + } + + /** + * 브랜드 ID 목록으로 브랜드 목록을 조회합니다. + *

+ * 배치 조회를 통해 N+1 쿼리 문제를 해결합니다. + *

+ * + * @param brandIds 조회할 브랜드 ID 목록 + * @return 조회된 브랜드 목록 + */ + @Transactional(readOnly = true) + public List getBrands(List brandIds) { + return brandRepository.findAllById(brandIds); + } + /** * 브랜드 정보를 조회합니다. * @@ -28,9 +58,8 @@ public class CatalogBrandFacade { * @return 브랜드 정보 * @throws CoreException 브랜드를 찾을 수 없는 경우 */ - public BrandInfo getBrand(Long brandId) { - Brand brand = brandRepository.findById(brandId) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); + public BrandInfo getBrandInfo(Long brandId) { + Brand brand = getBrand(brandId); return BrandInfo.from(brand); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogFacade.java similarity index 85% rename from apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogProductFacade.java rename to apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogFacade.java index 1ebf5c394..f46e74301 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogFacade.java @@ -1,10 +1,11 @@ package com.loopers.application.catalog; +import com.loopers.application.brand.BrandService; +import com.loopers.application.product.ProductCacheService; +import com.loopers.application.product.ProductService; import com.loopers.domain.brand.Brand; -import com.loopers.domain.brand.BrandRepository; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductDetail; -import com.loopers.domain.product.ProductRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; @@ -25,9 +26,9 @@ */ @RequiredArgsConstructor @Component -public class CatalogProductFacade { - private final ProductRepository productRepository; - private final BrandRepository brandRepository; +public class CatalogFacade { + private final BrandService brandService; + private final ProductService productService; private final ProductCacheService productCacheService; /** @@ -54,8 +55,8 @@ public ProductInfoList getProducts(Long brandId, String sort, int page, int size } // 캐시에 없으면 DB에서 조회 - long totalCount = productRepository.countAll(brandId); - List products = productRepository.findAll(brandId, normalizedSort, page, size); + long totalCount = productService.countAll(brandId); + List products = productService.findAll(brandId, normalizedSort, page, size); if (products.isEmpty()) { ProductInfoList emptyResult = new ProductInfoList(List.of(), totalCount, page, size); @@ -72,7 +73,7 @@ public ProductInfoList getProducts(Long brandId, String sort, int page, int size .toList(); // 브랜드 배치 조회 및 Map으로 변환 (O(1) 조회를 위해) - Map brandMap = brandRepository.findAllById(brandIds).stream() + Map brandMap = brandService.getBrands(brandIds).stream() .collect(Collectors.toMap(Brand::getId, brand -> brand)); // 상품 정보 변환 (이미 조회한 Product 재사용) @@ -116,12 +117,10 @@ public ProductInfo getProduct(Long productId) { } // 캐시에 없으면 DB에서 조회 - Product product = productRepository.findById(productId) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); + Product product = productService.getProduct(productId); // 브랜드 조회 - Brand brand = brandRepository.findById(product.getBrandId()) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); + Brand brand = brandService.getBrand(product.getBrandId()); // ✅ Product.likeCount 필드 사용 (비동기 집계된 값) Long likesCount = product.getLikeCount(); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/ApplyCouponCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/ApplyCouponCommand.java new file mode 100644 index 000000000..9ebb38172 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/ApplyCouponCommand.java @@ -0,0 +1,30 @@ +package com.loopers.application.coupon; + +/** + * 쿠폰 적용 명령. + *

+ * 쿠폰 적용을 위한 명령 객체입니다. + *

+ * + * @param userId 사용자 ID + * @param couponCode 쿠폰 코드 + * @param subtotal 주문 소계 금액 + */ +public record ApplyCouponCommand( + Long userId, + String couponCode, + Integer subtotal +) { + public ApplyCouponCommand { + if (userId == null) { + throw new IllegalArgumentException("userId는 필수입니다."); + } + if (couponCode == null || couponCode.isBlank()) { + throw new IllegalArgumentException("couponCode는 필수입니다."); + } + if (subtotal == null || subtotal < 0) { + throw new IllegalArgumentException("subtotal은 0 이상이어야 합니다."); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponEventHandler.java new file mode 100644 index 000000000..92f6b8ba3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponEventHandler.java @@ -0,0 +1,116 @@ +package com.loopers.application.coupon; + +import com.loopers.domain.coupon.CouponEvent; +import com.loopers.domain.coupon.CouponEventPublisher; +import com.loopers.domain.order.OrderEvent; +import com.loopers.support.error.CoreException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * 쿠폰 이벤트 핸들러. + *

+ * 주문 생성 이벤트를 받아 쿠폰 사용 처리를 수행하는 애플리케이션 로직을 처리합니다. + *

+ *

+ * DDD/EDA 관점: + *

    + *
  • 책임 분리: CouponService는 쿠폰 도메인 비즈니스 로직, CouponEventHandler는 이벤트 처리 로직
  • + *
  • 이벤트 핸들러: 이벤트를 받아서 처리하는 역할을 명확히 나타냄
  • + *
  • 도메인 경계 준수: 쿠폰 도메인은 쿠폰 적용 이벤트만 발행하고, 주문 도메인은 자신의 상태를 관리
  • + *
+ *

+ * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CouponEventHandler { + + private final CouponService couponService; + private final CouponEventPublisher couponEventPublisher; + + /** + * 주문 생성 이벤트를 처리하여 쿠폰을 사용하고 쿠폰 적용 이벤트를 발행합니다. + *

+ * 쿠폰 코드가 있는 경우에만 쿠폰 사용 처리를 수행합니다. + * 쿠폰 적용 후 CouponApplied 이벤트를 발행하여 주문 도메인이 자신의 상태를 업데이트하도록 합니다. + *

+ * + * @param event 주문 생성 이벤트 + */ + @Transactional + public void handleOrderCreated(OrderEvent.OrderCreated event) { + // 쿠폰 코드가 없는 경우 처리하지 않음 + if (event.couponCode() == null || event.couponCode().isBlank()) { + log.debug("쿠폰 코드가 없어 쿠폰 사용 처리를 건너뜁니다. (orderId: {})", event.orderId()); + return; + } + + try { + // ✅ OrderEvent.OrderCreated를 구독하여 쿠폰 적용 Command 실행 + // 쿠폰 사용 처리 (쿠폰 사용 마킹 및 할인 금액 계산) + Integer discountAmount = couponService.applyCoupon( + new ApplyCouponCommand( + event.userId(), + event.couponCode(), + event.subtotal() + ) + ); + + // ✅ 도메인 이벤트 발행: 쿠폰이 적용되었음 (과거 사실) + // 주문 도메인이 이 이벤트를 구독하여 자신의 상태를 업데이트함 + couponEventPublisher.publish(CouponEvent.CouponApplied.of( + event.orderId(), + event.userId(), + event.couponCode(), + discountAmount + )); + + log.info("쿠폰 사용 처리 완료. (orderId: {}, couponCode: {}, discountAmount: {})", + event.orderId(), event.couponCode(), discountAmount); + } catch (CoreException e) { + // 비즈니스 예외 발생 시 실패 이벤트 발행 + String failureReason = e.getMessage() != null ? e.getMessage() : "쿠폰 적용 실패"; + log.error("쿠폰 적용 실패. (orderId: {}, couponCode: {})", + event.orderId(), event.couponCode(), e); + couponEventPublisher.publish(CouponEvent.CouponApplicationFailed.of( + event.orderId(), + event.userId(), + event.couponCode(), + failureReason + )); + throw e; + } catch (ObjectOptimisticLockingFailureException e) { + // 낙관적 락 충돌: 다른 트랜잭션이 먼저 쿠폰을 사용함 + String failureReason = "쿠폰이 이미 사용되었습니다. (동시성 충돌)"; + log.warn("쿠폰 사용 중 낙관적 락 충돌 발생. (orderId: {}, couponCode: {})", + event.orderId(), event.couponCode()); + couponEventPublisher.publish(CouponEvent.CouponApplicationFailed.of( + event.orderId(), + event.userId(), + event.couponCode(), + failureReason + )); + throw e; + } catch (Exception e) { + // 예상치 못한 오류 발생 시 실패 이벤트 발행 + String failureReason = e.getMessage() != null ? e.getMessage() : "쿠폰 적용 처리 중 오류 발생"; + log.error("쿠폰 적용 처리 중 오류 발생. (orderId: {}, couponCode: {})", + event.orderId(), event.couponCode(), e); + couponEventPublisher.publish(CouponEvent.CouponApplicationFailed.of( + event.orderId(), + event.userId(), + event.couponCode(), + failureReason + )); + throw e; + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponService.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponService.java new file mode 100644 index 000000000..9a83d9df7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponService.java @@ -0,0 +1,112 @@ +package com.loopers.application.coupon; + +import com.loopers.domain.coupon.Coupon; +import com.loopers.domain.coupon.CouponRepository; +import com.loopers.domain.coupon.UserCoupon; +import com.loopers.domain.coupon.UserCouponRepository; +import com.loopers.domain.coupon.discount.CouponDiscountStrategyFactory; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * 쿠폰 애플리케이션 서비스. + *

+ * 쿠폰 조회, 사용 등의 애플리케이션 로직을 처리합니다. + * Repository에 의존하며 트랜잭션 관리 및 동시성 제어를 담당합니다. + *

+ * + * @author Loopers + * @version 1.0 + */ +@RequiredArgsConstructor +@Component +public class CouponService { + private final CouponRepository couponRepository; + private final UserCouponRepository userCouponRepository; + private final CouponDiscountStrategyFactory couponDiscountStrategyFactory; + + /** + * 쿠폰을 적용하여 할인 금액을 계산하고 쿠폰을 사용 처리합니다. + *

+ * 동시성 제어 전략: + *

    + *
  • OPTIMISTIC_LOCK 사용 근거: 쿠폰 중복 사용 방지, Hot Spot 대응
  • + *
  • @Version 필드: UserCoupon 엔티티의 version 필드를 통해 자동으로 낙관적 락 적용
  • + *
  • 동시 사용 시: 한 명만 성공하고 나머지는 OptimisticLockException 발생
  • + *
  • 사용 목적: 동일 쿠폰으로 여러 기기에서 동시 주문해도 한 번만 사용되도록 보장
  • + *
+ *

+ * + * @param command 쿠폰 적용 명령 + * @return 할인 금액 + * @throws CoreException 쿠폰을 찾을 수 없거나 사용 불가능한 경우, 동시 사용으로 인한 충돌 시 + */ + @Transactional + public Integer applyCoupon(ApplyCouponCommand command) { + return applyCoupon(command.userId(), command.couponCode(), command.subtotal()); + } + + /** + * 쿠폰을 적용하여 할인 금액을 계산하고 쿠폰을 사용 처리합니다. + *

+ * 동시성 제어 전략: + *

    + *
  • OPTIMISTIC_LOCK 사용 근거: 쿠폰 중복 사용 방지, Hot Spot 대응
  • + *
  • @Version 필드: UserCoupon 엔티티의 version 필드를 통해 자동으로 낙관적 락 적용
  • + *
  • 동시 사용 시: 한 명만 성공하고 나머지는 OptimisticLockException 발생
  • + *
  • 사용 목적: 동일 쿠폰으로 여러 기기에서 동시 주문해도 한 번만 사용되도록 보장
  • + *
+ *

+ * + * @param userId 사용자 ID + * @param couponCode 쿠폰 코드 + * @param subtotal 주문 소계 금액 + * @return 할인 금액 + * @throws CoreException 쿠폰을 찾을 수 없거나 사용 불가능한 경우, 동시 사용으로 인한 충돌 시 + */ + @Transactional + private Integer applyCoupon(Long userId, String couponCode, Integer subtotal) { + // 쿠폰 존재 여부 확인 + Coupon coupon = couponRepository.findByCode(couponCode) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + String.format("쿠폰을 찾을 수 없습니다. (쿠폰 코드: %s)", couponCode))); + + // 낙관적 락을 사용하여 사용자 쿠폰 조회 (동시성 제어) + // @Version 필드가 있어 자동으로 낙관적 락이 적용됨 + UserCoupon userCoupon = userCouponRepository.findByUserIdAndCouponCodeForUpdate(userId, couponCode) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + String.format("사용자가 소유한 쿠폰을 찾을 수 없습니다. (쿠폰 코드: %s)", couponCode))); + + // 쿠폰 사용 가능 여부 확인 + if (!userCoupon.isAvailable()) { + throw new CoreException(ErrorType.BAD_REQUEST, + String.format("이미 사용된 쿠폰입니다. (쿠폰 코드: %s)", couponCode)); + } + + // 쿠폰 사용 처리 + userCoupon.use(); + + // 할인 금액 계산 (전략 패턴 사용) + Integer discountAmount = coupon.calculateDiscountAmount(subtotal, couponDiscountStrategyFactory); + + try { + // 사용자 쿠폰 저장 (version 체크 자동 수행) + // 다른 트랜잭션이 먼저 수정했다면 OptimisticLockException 발생 + userCouponRepository.save(userCoupon); + // ✅ flush()를 명시적으로 호출하여 Optimistic Lock 체크를 즉시 수행 + // flush() 없이는 트랜잭션 커밋 시점에 체크되므로, 여러 트랜잭션이 동시에 성공할 수 있음 + userCouponRepository.flush(); + } catch (ObjectOptimisticLockingFailureException e) { + // 낙관적 락 충돌: 다른 트랜잭션이 먼저 쿠폰을 사용함 + throw new CoreException(ErrorType.CONFLICT, + String.format("쿠폰이 이미 사용되었습니다. (쿠폰 코드: %s)", couponCode)); + } + + return discountAmount; + } +} + 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/heart/HeartFacade.java similarity index 61% rename from apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java rename to apps/commerce-api/src/main/java/com/loopers/application/heart/HeartFacade.java index de21d46b5..cce249563 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/heart/HeartFacade.java @@ -1,14 +1,15 @@ -package com.loopers.application.like; +package com.loopers.application.heart; -import com.loopers.application.catalog.ProductCacheService; +import com.loopers.application.like.LikeService; +import com.loopers.application.product.ProductService; +import com.loopers.application.user.UserService; import com.loopers.domain.like.Like; -import com.loopers.domain.like.LikeRepository; import com.loopers.domain.product.Product; -import com.loopers.domain.product.ProductRepository; import com.loopers.domain.user.User; -import com.loopers.domain.user.UserRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -23,17 +24,24 @@ *

* 좋아요 추가, 삭제, 목록 조회 유즈케이스를 처리하는 애플리케이션 서비스입니다. *

+ *

+ * EDA 원칙 준수: + *

    + *
  • 이벤트 기반: Like 도메인 이벤트만 발행하고, 다른 애그리거트를 직접 수정하지 않음
  • + *
  • 느슨한 결합: Product, User 애그리거트와의 직접적인 의존성 최소화
  • + *
  • 책임 분리: 좋아요 도메인만 관리하고, 상품 좋아요 수 집계는 이벤트 핸들러에서 처리
  • + *
+ *

* * @author Loopers * @version 1.0 */ @RequiredArgsConstructor @Component -public class LikeFacade { - private final LikeRepository likeRepository; - private final UserRepository userRepository; - private final ProductRepository productRepository; - private final ProductCacheService productCacheService; +public class HeartFacade { + private final LikeService likeService; + private final UserService userService; // String userId를 Long id로 변환하는 데만 사용 + private final ProductService productService; // getLikedProducts 조회용으로만 사용 /** * 상품에 좋아요를 추가합니다. @@ -57,37 +65,44 @@ public class LikeFacade { *
  • 비즈니스 데이터 보호: 중복 좋아요로 인한 비즈니스 데이터 오염 방지
  • * *

    + *

    + * EDA 원칙: + *

      + *
    • 이벤트 기반: LikeService.save()가 LikeEvent.LikeAdded 이벤트를 발행
    • + *
    • 느슨한 결합: Product 애그리거트를 직접 조회/수정하지 않음. 이벤트 핸들러가 상품 좋아요 수를 업데이트
    • + *
    • 책임 분리: 좋아요 도메인만 관리하고, 상품 좋아요 수 집계는 ProductEventHandler에서 처리
    • + *
    + *

    * * @param userId 사용자 ID (String) * @param productId 상품 ID - * @throws CoreException 사용자 또는 상품을 찾을 수 없는 경우 + * @throws CoreException 사용자를 찾을 수 없는 경우 */ public void addLike(String userId, Long productId) { + // ✅ EDA 원칙: UserService는 String userId를 Long id로 변환하는 데만 사용 + // Product 존재 여부 검증은 제거 (이벤트 핸들러에서 처리하거나, 외래키 제약조건으로 보장) User user = loadUser(userId); - loadProduct(productId); // 먼저 일반 조회로 중복 체크 (대부분의 경우 빠르게 처리) // ⚠️ 주의: 애플리케이션 레벨 체크만으로는 race condition을 완전히 방지할 수 없음 // 동시에 두 요청이 들어오면 둘 다 "없음"으로 판단 → 둘 다 저장 시도 가능 - Optional existingLike = likeRepository.findByUserIdAndProductId(user.getId(), productId); + Optional existingLike = likeService.getLike(user.getId(), productId); if (existingLike.isPresent()) { return; } // 저장 시도 (동시성 상황에서는 UNIQUE 제약조건 위반 예외 발생 가능) // ✅ UNIQUE 제약조건이 최종 보호: DB 레벨에서 중복 삽입을 물리적으로 방지 - // @Transactional이 없어도 save() 호출 시 자동 트랜잭션으로 예외를 catch할 수 있음 + // ✅ LikeService.save()가 LikeEvent.LikeAdded 이벤트를 발행 + // ✅ ProductEventHandler가 이벤트를 구독하여 상품 좋아요 수 및 캐시 업데이트 Like like = Like.of(user.getId(), productId); try { - likeRepository.save(like); - // 좋아요 추가 성공 시 로컬 캐시의 델타 증가 - productCacheService.incrementLikeCountDelta(productId); + likeService.save(like); } catch (org.springframework.dao.DataIntegrityViolationException e) { // UNIQUE 제약조건 위반 = 이미 저장됨 (멱등성 보장) // 동시에 여러 요청이 들어와서 모두 "없음"으로 판단하고 저장을 시도할 때, // 첫 번째만 성공하고 나머지는 UNIQUE 제약조건 위반 예외 발생 // 이미 좋아요가 존재하는 경우이므로 정상 처리로 간주 - // 로컬 캐시는 업데이트하지 않음 (이미 좋아요가 존재하므로) } } @@ -96,28 +111,36 @@ public void addLike(String userId, Long productId) { *

    * 멱등성을 보장합니다. 좋아요가 존재하지 않는 경우 아무 작업도 수행하지 않습니다. *

    + *

    + * EDA 원칙: + *

      + *
    • 이벤트 기반: LikeService.delete()가 LikeEvent.LikeRemoved 이벤트를 발행
    • + *
    • 느슨한 결합: Product 애그리거트를 직접 조회/수정하지 않음. 이벤트 핸들러가 상품 좋아요 수를 업데이트
    • + *
    • 책임 분리: 좋아요 도메인만 관리하고, 상품 좋아요 수 집계는 ProductEventHandler에서 처리
    • + *
    + *

    * * @param userId 사용자 ID (String) * @param productId 상품 ID - * @throws CoreException 사용자 또는 상품을 찾을 수 없는 경우 + * @throws CoreException 사용자를 찾을 수 없는 경우 */ public void removeLike(String userId, Long productId) { + // ✅ EDA 원칙: UserService는 String userId를 Long id로 변환하는 데만 사용 + // Product 존재 여부 검증은 제거 (이벤트 핸들러에서 처리하거나, 외래키 제약조건으로 보장) User user = loadUser(userId); - loadProduct(productId); - Optional like = likeRepository.findByUserIdAndProductId(user.getId(), productId); + Optional like = likeService.getLike(user.getId(), productId); if (like.isEmpty()) { return; } try { - likeRepository.delete(like.get()); - // 좋아요 취소 성공 시 로컬 캐시의 델타 감소 - productCacheService.decrementLikeCountDelta(productId); - } catch (Exception e) { + // ✅ LikeService.delete()가 LikeEvent.LikeRemoved 이벤트를 발행 + // ✅ ProductEventHandler가 이벤트를 구독하여 상품 좋아요 수 및 캐시 업데이트 + likeService.delete(like.get()); + } catch (EmptyResultDataAccessException | ObjectOptimisticLockingFailureException e) { // 동시성 상황에서 이미 삭제된 경우 등 예외 발생 가능 // 멱등성 보장: 이미 삭제된 경우 정상 처리로 간주 - // 로컬 캐시는 업데이트하지 않음 (이미 삭제되었으므로) } } @@ -129,11 +152,18 @@ public void removeLike(String userId, Long productId) { *

    * 좋아요 수 조회 전략: *

      - *
    • 비동기 집계: Product.likeCount 필드 사용 (스케줄러로 주기적 동기화)
    • - *
    • Eventually Consistent: 약간의 지연 허용 (최대 5초)
    • + *
    • 이벤트 기반 집계: Product.likeCount 필드 사용 (LikeEvent로 실시간 업데이트)
    • + *
    • Strong Consistency: 이벤트 기반으로 실시간 반영
    • *
    • 성능 최적화: COUNT(*) 쿼리 없이 컬럼만 읽으면 됨
    • *
    *

    + *

    + * EDA 원칙: + *

      + *
    • 조회 특성: 조회 쿼리는 이벤트로 처리하기 어려우므로 ProductService 의존 허용
    • + *
    • 최소 의존: 조회용으로만 사용하며, 수정 작업은 수행하지 않음
    • + *
    + *

    * * @param userId 사용자 ID (String) * @return 좋아요한 상품 목록 @@ -144,7 +174,7 @@ public List getLikedProducts(String userId) { User user = loadUser(userId); // 사용자의 좋아요 목록 조회 - List likes = likeRepository.findAllByUserId(user.getId()); + List likes = likeService.getLikesByUserId(user.getId()); if (likes.isEmpty()) { return List.of(); @@ -156,7 +186,8 @@ public List getLikedProducts(String userId) { .toList(); // ✅ 배치 조회로 N+1 쿼리 문제 해결 - Map productMap = productRepository.findAllById(productIds).stream() + // ⚠️ 조회 특성상 ProductService 의존은 허용 (이벤트로 처리하기 어려움) + Map productMap = productService.getProducts(productIds).stream() .collect(Collectors.toMap(Product::getId, product -> product)); // 요청한 상품 ID와 조회된 상품 수가 일치하는지 확인 @@ -165,7 +196,7 @@ public List getLikedProducts(String userId) { } // 좋아요 목록을 상품 정보와 좋아요 수와 함께 변환 - // ✅ Product.likeCount 필드 사용 (비동기 집계된 값) + // ✅ Product.likeCount 필드 사용 (이벤트 기반 실시간 집계된 값) return likes.stream() .map(like -> { Product product = productMap.get(like.getProductId()); @@ -179,18 +210,18 @@ public List getLikedProducts(String userId) { .toList(); } + /** + * String userId를 Long id로 변환합니다. + *

    + * EDA 원칙에 따라 최소한의 UserService 의존만 사용합니다. + *

    + * + * @param userId 사용자 ID (String) + * @return 사용자 엔티티 + * @throws CoreException 사용자를 찾을 수 없는 경우 + */ private User loadUser(String userId) { - User user = userRepository.findByUserId(userId); - if (user == null) { - throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다."); - } - return user; - } - - private Product loadProduct(Long productId) { - return productRepository.findById(productId) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, - String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", productId))); + return userService.getUser(userId); } /** @@ -231,7 +262,7 @@ public static LikedProduct from(Product product) { product.getPrice(), product.getStock(), product.getBrandId(), - product.getLikeCount() // ✅ Product.likeCount 필드 사용 (비동기 집계된 값) + product.getLikeCount() // ✅ Product.likeCount 필드 사용 (이벤트 기반 실시간 집계된 값) ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java new file mode 100644 index 000000000..a7c9874e6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java @@ -0,0 +1,87 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeEvent; +import com.loopers.domain.like.LikeEventPublisher; +import com.loopers.domain.like.LikeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +/** + * 좋아요 애플리케이션 서비스. + *

    + * 좋아요 조회, 저장, 삭제 등의 애플리케이션 로직을 처리합니다. + * Repository에 의존하며 트랜잭션 관리를 담당합니다. + *

    + * + * @author Loopers + * @version 1.0 + */ +@RequiredArgsConstructor +@Component +public class LikeService { + private final LikeRepository likeRepository; + private final LikeEventPublisher likeEventPublisher; + + /** + * 사용자 ID와 상품 ID로 좋아요를 조회합니다. + * + * @param userId 사용자 ID + * @param productId 상품 ID + * @return 조회된 좋아요를 담은 Optional + */ + @Transactional(readOnly = true) + public Optional getLike(Long userId, Long productId) { + return likeRepository.findByUserIdAndProductId(userId, productId); + } + + /** + * 좋아요를 저장합니다. + *

    + * 저장 성공 시 좋아요 추가 이벤트를 발행합니다. + *

    + * + * @param like 저장할 좋아요 + * @return 저장된 좋아요 + */ + @Transactional + public Like save(Like like) { + Like savedLike = likeRepository.save(like); + + // ✅ 도메인 이벤트 발행: 좋아요가 추가되었음 (과거 사실) + likeEventPublisher.publish(LikeEvent.LikeAdded.from(savedLike)); + + return savedLike; + } + + /** + * 좋아요를 삭제합니다. + *

    + * 삭제 전에 좋아요 취소 이벤트를 발행합니다. + *

    + * + * @param like 삭제할 좋아요 + */ + @Transactional + public void delete(Like like) { + // ✅ 도메인 이벤트 발행: 좋아요가 취소되었음 (과거 사실) + likeEventPublisher.publish(LikeEvent.LikeRemoved.from(like)); + + likeRepository.delete(like); + } + + /** + * 사용자 ID로 좋아요한 상품 목록을 조회합니다. + * + * @param userId 사용자 ID + * @return 좋아요 목록 + */ + @Transactional(readOnly = true) + public List getLikesByUserId(Long userId) { + return likeRepository.findAllByUserId(userId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/CreateOrderCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/order/CreateOrderCommand.java new file mode 100644 index 000000000..4bfb8e408 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/CreateOrderCommand.java @@ -0,0 +1,42 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.OrderItem; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.util.List; + +/** + * 주문 생성 명령. + *

    + * 주문 생성을 위한 명령 객체입니다. + *

    + * + * @param userId 사용자 ID + * @param items 주문 아이템 목록 + * @param couponCode 쿠폰 코드 (선택) + * @param subtotal 주문 소계 (쿠폰 할인 전 금액) + * @param usedPointAmount 사용할 포인트 금액 + */ +public record CreateOrderCommand( + Long userId, + List items, + String couponCode, + Integer subtotal, + Long usedPointAmount +) { + public CreateOrderCommand { + if (userId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "사용자 ID는 필수입니다."); + } + if (items == null || items.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 아이템은 1개 이상이어야 합니다."); + } + if (subtotal == null || subtotal < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 소계는 0 이상이어야 합니다."); + } + if (usedPointAmount == null || usedPointAmount < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "사용 포인트 금액은 0 이상이어야 합니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderEventHandler.java new file mode 100644 index 000000000..10b18bd7e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderEventHandler.java @@ -0,0 +1,148 @@ +package com.loopers.application.order; + +import com.loopers.domain.coupon.CouponEvent; +import com.loopers.domain.order.Order; +import com.loopers.domain.payment.PaymentEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * 주문 이벤트 핸들러. + *

    + * 결제 완료/실패 이벤트와 쿠폰 적용 이벤트를 받아 주문 상태를 업데이트하는 애플리케이션 로직을 처리합니다. + *

    + *

    + * DDD/EDA 관점: + *

      + *
    • 책임 분리: OrderService는 주문 도메인 비즈니스 로직, OrderEventHandler는 이벤트 처리 로직
    • + *
    • 이벤트 핸들러: 이벤트를 받아서 처리하는 역할을 명확히 나타냄
    • + *
    • 도메인 경계 준수: 주문 도메인은 자신의 상태만 관리하며, 다른 도메인의 이벤트를 구독하여 반응
    • + *
    • 느슨한 결합: UserService나 PurchasingFacade를 직접 참조하지 않고, 이벤트만 발행
    • + *
    + *

    + * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class OrderEventHandler { + + private final OrderService orderService; + + /** + * 결제 완료 이벤트를 처리하여 주문 상태를 COMPLETED로 업데이트합니다. + *

    + * 트랜잭션 전략: + *

      + *
    • AFTER_COMMIT: 원래 트랜잭션이 이미 커밋되었으므로 자동으로 새 트랜잭션이 생성됨
    • + *
    + *

    + * + * @param event 결제 완료 이벤트 + */ + @Transactional + public void handlePaymentCompleted(PaymentEvent.PaymentCompleted event) { + Order order = orderService.getOrder(event.orderId()).orElse(null); + if (order == null) { + log.warn("결제 완료 이벤트 처리 시 주문을 찾을 수 없습니다. (orderId: {})", event.orderId()); + return; + } + + // 이미 완료된 주문인 경우 처리하지 않음 + if (order.isCompleted()) { + log.debug("이미 완료된 주문입니다. 상태 업데이트를 건너뜁니다. (orderId: {})", event.orderId()); + return; + } + + // 이미 취소된 주문인 경우 처리하지 않음 (race condition 방지) + // 예: 결제 타임아웃으로 인해 주문이 취소되었지만, 이후 PG 상태 확인에서 SUCCESS가 반환된 경우 + if (order.isCanceled()) { + log.warn("이미 취소된 주문입니다. 결제 완료 처리를 건너뜁니다. (orderId: {}, transactionKey: {})", + event.orderId(), event.transactionKey()); + return; + } + + // 주문 완료 처리 + orderService.completeOrder(event.orderId()); + log.info("결제 완료로 인한 주문 상태 업데이트 완료. (orderId: {}, transactionKey: {})", + event.orderId(), event.transactionKey()); + } + + /** + * 결제 실패 이벤트를 처리하여 주문을 취소합니다. + *

    + * 주문 상태만 CANCELED로 변경하고 OrderCanceled 이벤트를 발행합니다. + * 리소스 원복(재고, 포인트)은 OrderCanceled 이벤트를 구독하는 별도 핸들러에서 처리합니다. + *

    + *

    + * 트랜잭션 전략: + *

      + *
    • AFTER_COMMIT: 원래 트랜잭션이 이미 커밋되었으므로 자동으로 새 트랜잭션이 생성됨
    • + *
    + *

    + *

    + * DDD/EDA 관점: + *

      + *
    • 도메인 경계 준수: 주문 도메인이 자신의 상태를 관리하며, 결제 실패 이벤트를 구독하여 반응
    • + *
    • 느슨한 결합: 리소스 원복은 별도 이벤트 핸들러에서 처리하여 도메인 간 결합 제거
    • + *
    + *

    + * + * @param event 결제 실패 이벤트 + */ + @Transactional + public void handlePaymentFailed(PaymentEvent.PaymentFailed event) { + Order order = orderService.getOrder(event.orderId()).orElse(null); + if (order == null) { + log.warn("결제 실패 이벤트 처리 시 주문을 찾을 수 없습니다. (orderId: {})", event.orderId()); + return; + } + + // 이미 완료된 주문인 경우 처리하지 않음 (race condition 방지) + if (order.isCompleted()) { + log.warn("이미 완료된 주문입니다. 결제 실패 처리를 건너뜁니다. (orderId: {})", event.orderId()); + return; + } + + // 이미 취소된 주문인 경우 처리하지 않음 + if (order.isCanceled()) { + log.debug("이미 취소된 주문입니다. 상태 업데이트를 건너뜁니다. (orderId: {})", event.orderId()); + return; + } + + // 주문 취소 (OrderCanceled 이벤트 발행 포함) + // PaymentFailed 이벤트에 포함된 refundPointAmount 사용 + orderService.cancelOrder(event.orderId(), event.reason(), event.refundPointAmount()); + log.info("결제 실패로 인한 주문 취소 완료. (orderId: {}, reason: {}, refundPointAmount: {})", + event.orderId(), event.reason(), event.refundPointAmount()); + } + + + /** + * 쿠폰 적용 이벤트를 처리하여 주문에 할인 금액을 적용합니다. + *

    + * 쿠폰 도메인에서 쿠폰이 적용되었다는 이벤트를 받아 주문 도메인이 자신의 상태를 업데이트합니다. + *

    + * + * @param event 쿠폰 적용 이벤트 + */ + @Transactional + public void handleCouponApplied(CouponEvent.CouponApplied event) { + try { + // 주문에 할인 금액 적용 (totalAmount 업데이트) + orderService.applyCouponDiscount(event.orderId(), event.discountAmount()); + + log.info("쿠폰 할인 금액이 주문에 적용되었습니다. (orderId: {}, couponCode: {}, discountAmount: {})", + event.orderId(), event.couponCode(), event.discountAmount()); + } catch (Exception e) { + // 주문 업데이트 실패는 로그만 기록 (쿠폰은 이미 적용되었으므로) + log.error("쿠폰 적용 이벤트 처리 중 오류 발생. (orderId: {}, couponCode: {})", + event.orderId(), event.couponCode(), e); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java new file mode 100644 index 000000000..eb71438ab --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java @@ -0,0 +1,334 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderEvent; +import com.loopers.domain.order.OrderEventPublisher; +import com.loopers.domain.order.OrderItem; +import com.loopers.domain.order.OrderRepository; +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.payment.PaymentStatus; +import com.loopers.domain.product.Product; +import com.loopers.domain.user.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; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * 주문 애플리케이션 서비스. + *

    + * 주문의 기본 CRUD 및 상태 변경을 담당하는 애플리케이션 서비스입니다. + * Repository에 의존하며 트랜잭션 관리를 담당합니다. + *

    + * + * @author Loopers + * @version 1.0 + */ +@Component +@RequiredArgsConstructor +public class OrderService { + + private final OrderRepository orderRepository; + private final OrderEventPublisher orderEventPublisher; + + /** + * 주문을 저장합니다. + * + * @param order 저장할 주문 + * @return 저장된 주문 + */ + @Transactional + public Order save(Order order) { + return orderRepository.save(order); + } + + /** + * 주문 ID로 주문을 조회합니다. + * + * @param orderId 주문 ID + * @return 조회된 주문 + * @throws CoreException 주문을 찾을 수 없는 경우 + */ + @Transactional(readOnly = true) + public Order getById(Long orderId) { + return orderRepository.findById(orderId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.")); + } + + /** + * 주문 ID로 주문을 조회합니다 (Optional 반환). + * + * @param orderId 주문 ID + * @return 조회된 주문 (없으면 Optional.empty()) + */ + @Transactional(readOnly = true) + public Optional getOrder(Long orderId) { + return orderRepository.findById(orderId); + } + + /** + * 사용자 ID로 주문 목록을 조회합니다. + * + * @param userId 사용자 ID + * @return 해당 사용자의 주문 목록 + */ + @Transactional(readOnly = true) + public List getOrdersByUserId(Long userId) { + return orderRepository.findAllByUserId(userId); + } + + /** + * 주문 상태로 주문 목록을 조회합니다. + * + * @param status 주문 상태 + * @return 해당 상태의 주문 목록 + */ + @Transactional(readOnly = true) + public List getOrdersByStatus(OrderStatus status) { + return orderRepository.findAllByStatus(status); + } + + /** + * 주문을 생성합니다. + * + * @param userId 사용자 ID + * @param items 주문 아이템 목록 + * @param couponCode 쿠폰 코드 (선택) + * @param discountAmount 할인 금액 (선택) + * @return 생성된 주문 + */ + @Transactional + public Order create(Long userId, List items, String couponCode, Integer discountAmount) { + Order order = Order.of(userId, items, couponCode, discountAmount); + return orderRepository.save(order); + } + + /** + * 주문을 생성합니다 (쿠폰 없음). + * + * @param userId 사용자 ID + * @param items 주문 아이템 목록 + * @return 생성된 주문 + */ + @Transactional + public Order create(Long userId, List items) { + Order order = Order.of(userId, items); + Order savedOrder = orderRepository.save(order); + + // 소계 계산 + Integer subtotal = calculateSubtotal(items); + + // ✅ 도메인 이벤트 발행: 주문이 생성되었음 (과거 사실) + orderEventPublisher.publish(OrderEvent.OrderCreated.from(savedOrder, subtotal, 0L)); + + return savedOrder; + } + + /** + * 주문을 생성합니다 (쿠폰 코드와 소계 포함). + *

    + * 주문 생성 후 OrderCreated 이벤트를 발행합니다. + *

    + * + * @param command 주문 생성 명령 + * @return 생성된 주문 + */ + @Transactional + public Order create(CreateOrderCommand command) { + // 쿠폰이 있어도 discountAmount는 0으로 설정 (CouponEventHandler가 이벤트를 받아 쿠폰 적용) + Order order = Order.of(command.userId(), command.items(), command.couponCode(), 0); + Order savedOrder = orderRepository.save(order); + + // ✅ 도메인 이벤트 발행: 주문이 생성되었음 (과거 사실) + orderEventPublisher.publish(OrderEvent.OrderCreated.from(savedOrder, command.subtotal(), command.usedPointAmount())); + + return savedOrder; + } + + /** + * 주문에 쿠폰 할인 금액을 적용합니다. + *

    + * 이벤트 핸들러에서 쿠폰 적용 후 호출됩니다. + *

    + * + * @param orderId 주문 ID + * @param discountAmount 할인 금액 + * @return 업데이트된 주문 + * @throws CoreException 주문을 찾을 수 없거나 할인을 적용할 수 없는 상태인 경우 + */ + @Transactional + public Order applyCouponDiscount(Long orderId, Integer discountAmount) { + Order order = getById(orderId); + order.applyDiscount(discountAmount); + return orderRepository.save(order); + } + + /** + * 주문 아이템 목록으로부터 소계 금액을 계산합니다. + * + * @param orderItems 주문 아이템 목록 + * @return 계산된 소계 금액 + */ + private Integer calculateSubtotal(List orderItems) { + return orderItems.stream() + .mapToInt(item -> item.getPrice() * item.getQuantity()) + .sum(); + } + + /** + * 주문을 완료 상태로 변경합니다. + *

    + * 주문 완료 후 OrderCompleted 이벤트를 발행합니다. + *

    + * + * @param orderId 주문 ID + * @return 완료된 주문 + * @throws CoreException 주문을 찾을 수 없는 경우 + */ + @Transactional + public Order completeOrder(Long orderId) { + Order order = getById(orderId); + order.complete(); + Order savedOrder = orderRepository.save(order); + + // ✅ 도메인 이벤트 발행: 주문이 완료되었음 (과거 사실) + orderEventPublisher.publish(OrderEvent.OrderCompleted.from(savedOrder)); + + return savedOrder; + } + + /** + * 주문을 취소 상태로 변경합니다. + *

    + * 주문 취소 후 OrderCanceled 이벤트를 발행합니다. + * 리소스 원복(재고, 포인트)은 별도 이벤트 핸들러에서 처리합니다. + *

    + * + * @param orderId 주문 ID + * @param reason 취소 사유 + * @param refundPointAmount 환불할 포인트 금액 + * @return 취소된 주문 + * @throws CoreException 주문을 찾을 수 없는 경우 + */ + @Transactional + public Order cancelOrder(Long orderId, String reason, Long refundPointAmount) { + Order order = getById(orderId); + order.cancel(); + Order savedOrder = orderRepository.save(order); + + // ✅ 도메인 이벤트 발행: 주문이 취소되었음 (과거 사실) + orderEventPublisher.publish(OrderEvent.OrderCanceled.from(savedOrder, reason, refundPointAmount)); + + return savedOrder; + } + + /** + * 주문을 취소 상태로 변경하고 재고를 원복하며 포인트를 환불합니다. + *

    + * 도메인 로직만 처리합니다. 사용자 조회, 상품 조회, Payment 조회는 애플리케이션 레이어에서 처리합니다. + *

    + * + * @param order 주문 엔티티 + * @param products 주문 아이템에 해당하는 상품 목록 (락이 이미 획득된 상태) + * @param user 사용자 엔티티 (락이 이미 획득된 상태) + * @param refundPointAmount 환불할 포인트 금액 + * @throws CoreException 주문 또는 사용자 정보가 null인 경우 + */ + @Transactional + public void cancelOrder(Order order, List products, User user, Long refundPointAmount) { + if (order == null || user == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "취소할 주문과 사용자 정보는 필수입니다."); + } + + // 주문 취소 + order.cancel(); + + // 재고 원복 + increaseStocksForOrderItems(order.getItems(), products); + + // 포인트 환불 + if (refundPointAmount > 0) { + user.receivePoint(Point.of(refundPointAmount)); + } + + orderRepository.save(order); + } + + /** + * 결제 상태에 따라 주문 상태를 업데이트합니다. + *

    + * 주문 상태 변경 후 해당 이벤트(OrderCompleted 또는 OrderCanceled)를 발행합니다. + *

    + *

    + * 도메인 로직만 처리합니다. 사용자 조회, 트랜잭션 관리, 로깅은 애플리케이션 레이어에서 처리합니다. + *

    + * + * @param order 주문 엔티티 + * @param paymentStatus 결제 상태 + * @param reason 취소 사유 (FAILED인 경우 필수, 그 외 null 가능) + * @param refundPointAmount 환불할 포인트 금액 (FAILED인 경우 필수, 그 외 null 가능) + * @throws CoreException 주문이 null이거나 이미 완료/취소된 경우 + */ + @Transactional + public void updateStatusByPaymentResult(Order order, PaymentStatus paymentStatus, String reason, Long refundPointAmount) { + if (order == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 정보는 필수입니다."); + } + + // 이미 완료되거나 취소된 주문인 경우 처리하지 않음 + if (order.isCompleted() || order.isCanceled()) { + return; + } + + if (paymentStatus == PaymentStatus.SUCCESS) { + // 결제 성공: 주문 완료 + order.complete(); + Order savedOrder = orderRepository.save(order); + + // ✅ 도메인 이벤트 발행: 주문이 완료되었음 (과거 사실) + orderEventPublisher.publish(OrderEvent.OrderCompleted.from(savedOrder)); + } else if (paymentStatus == PaymentStatus.FAILED) { + // 결제 실패: 주문 취소 (재고 원복 및 포인트 환불은 이벤트 핸들러에서 처리) + if (reason == null || reason.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "결제 실패 시 취소 사유는 필수입니다."); + } + if (refundPointAmount == null || refundPointAmount < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "환불할 포인트 금액은 0 이상이어야 합니다."); + } + + order.cancel(); + Order savedOrder = orderRepository.save(order); + + // ✅ 도메인 이벤트 발행: 주문이 취소되었음 (과거 사실) + orderEventPublisher.publish(OrderEvent.OrderCanceled.from(savedOrder, reason, refundPointAmount)); + } + // PENDING 상태: 상태 유지 (아무 작업도 하지 않음) + } + + /** + * 주문 아이템에 대해 재고를 증가시킵니다. + * + * @param items 주문 아이템 목록 + * @param products 상품 목록 + */ + private void increaseStocksForOrderItems(List items, List products) { + Map productMap = products.stream() + .collect(java.util.stream.Collectors.toMap(Product::getId, product -> product)); + + for (OrderItem item : items) { + Product product = productMap.get(item.getProductId()); + if (product == null) { + throw new CoreException(ErrorType.NOT_FOUND, + String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", item.getProductId())); + } + product.increaseStock(item.getQuantity()); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentEventHandler.java new file mode 100644 index 000000000..dc8b250a1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentEventHandler.java @@ -0,0 +1,234 @@ +package com.loopers.application.payment; + +import com.loopers.domain.coupon.CouponEvent; +import com.loopers.domain.payment.CardType; +import com.loopers.domain.payment.Payment; +import com.loopers.domain.payment.PaymentEvent; +import com.loopers.domain.payment.PaymentGateway; +import com.loopers.domain.payment.PaymentRequest; +import com.loopers.domain.payment.PaymentRequestResult; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.time.LocalDateTime; + +/** + * 결제 이벤트 핸들러. + *

    + * 결제 요청 이벤트를 받아 Payment 생성 및 PG 결제 요청 처리를 수행하는 애플리케이션 로직을 처리합니다. + *

    + *

    + * DDD/EDA 관점: + *

      + *
    • 책임 분리: PaymentService는 결제 도메인 비즈니스 로직, PaymentEventHandler는 이벤트 처리 로직
    • + *
    • 이벤트 핸들러: 이벤트를 받아서 처리하는 역할을 명확히 나타냄
    • + *
    + *

    + * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PaymentEventHandler { + + private final PaymentService paymentService; + private final PaymentGateway paymentGateway; + + /** + * 결제 요청 이벤트를 처리하여 Payment를 생성하고 PG 결제를 요청합니다. + *

    + * 결제 금액이 0인 경우 PG 요청 없이 바로 완료 처리합니다. + *

    + * + * @param event 결제 요청 이벤트 + */ + @Transactional + public void handlePaymentRequested(PaymentEvent.PaymentRequested event) { + try { + // Payment 생성 + CardType cardTypeEnum = (event.cardType() != null && !event.cardType().isBlank()) + ? convertCardType(event.cardType()) + : null; + + Payment payment = paymentService.create( + event.orderId(), + event.userEntityId(), + event.totalAmount(), + event.usedPointAmount(), + cardTypeEnum, + event.cardNo(), + LocalDateTime.now() + ); + + // 결제 금액이 0인 경우 (포인트+쿠폰으로 전액 결제) + Long paidAmount = event.totalAmount() - event.usedPointAmount(); + if (paidAmount.equals(0L)) { + // PG 요청 없이 바로 완료 (PaymentCompleted 이벤트 발행) + paymentService.toSuccess(payment.getId(), LocalDateTime.now(), null); + log.info("포인트+쿠폰으로 전액 결제 완료. (orderId: {})", event.orderId()); + return; + } + + // PG 결제가 필요한 경우 + if (event.cardType() == null || event.cardType().isBlank() || + event.cardNo() == null || event.cardNo().isBlank()) { + log.error("카드 정보가 없어 PG 결제를 진행할 수 없습니다. (orderId: {})", event.orderId()); + throw new CoreException( + ErrorType.BAD_REQUEST, + "포인트와 쿠폰만으로 결제할 수 없습니다. 카드 정보를 입력해주세요."); + } + + // PG 결제 요청 (트랜잭션 커밋 후 별도 트랜잭션에서 처리) + TransactionSynchronizationManager.registerSynchronization( + new TransactionSynchronization() { + @Override + public void afterCommit() { + try { + // 쿠폰 할인이 적용된 후의 최신 Payment 정보 조회 + // 쿠폰 할인이 적용되면 Payment.applyCouponDiscount에서 paidAmount가 재계산됨 + Payment payment = paymentService.getPaymentByOrderId(event.orderId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + String.format("주문 ID에 해당하는 결제를 찾을 수 없습니다. (orderId: %d)", event.orderId()))); + + // 이미 완료된 결제는 PG 요청 불필요 + if (payment.isCompleted()) { + log.info("결제가 이미 완료되어 PG 요청을 건너뜁니다. (orderId: {})", event.orderId()); + return; + } + + // 최신 paidAmount 사용 (쿠폰 할인 적용 후 금액) + Long paidAmount = payment.getPaidAmount(); + + // paidAmount가 0이면 PG 요청 불필요 (이미 완료 처리됨) + if (paidAmount == 0L) { + log.info("결제 금액이 0이어서 PG 요청을 건너뜁니다. (orderId: {})", event.orderId()); + return; + } + + // ✅ PaymentEvent.PaymentRequested를 구독하여 결제 요청 Command 실행 + String callbackUrl = generateCallbackUrl(event.orderId()); + // ✅ RequestPaymentCommand (PaymentRequestCommand) 생성 및 실행 + PaymentRequestCommand command = new PaymentRequestCommand( + event.userId(), + event.orderId(), + event.cardType(), + event.cardNo(), + paidAmount, + callbackUrl + ); + // 도메인 계층으로 변환하여 PG 결제 요청 + PaymentRequest paymentRequest = command.toPaymentRequest(); + PaymentRequestResult result = paymentGateway.requestPayment(paymentRequest); + + if (result instanceof PaymentRequestResult.Success success) { + // 결제 성공: PaymentService.toSuccess가 PaymentCompleted 이벤트를 발행하고, + // OrderEventHandler가 이를 받아 주문 상태를 COMPLETED로 변경 + paymentService.getPaymentByOrderId(event.orderId()).ifPresent(p -> { + if (p.isPending()) { + paymentService.toSuccess(p.getId(), LocalDateTime.now(), success.transactionKey()); + } + }); + log.info("PG 결제 요청 성공. (orderId: {}, transactionKey: {})", + event.orderId(), success.transactionKey()); + } else if (result instanceof PaymentRequestResult.Failure failure) { + // PG 요청 실패: PaymentService.toFailed가 PaymentFailed 이벤트를 발행하고, + // OrderEventHandler가 이를 받아 주문 취소 처리 + paymentService.getPaymentByOrderId(event.orderId()).ifPresent(p -> { + if (p.isPending()) { + paymentService.toFailed(p.getId(), failure.message(), + LocalDateTime.now(), null); + } + }); + log.warn("PG 결제 요청 실패. (orderId: {}, errorCode: {}, message: {})", + event.orderId(), failure.errorCode(), failure.message()); + } + } catch (Exception e) { + log.error("PG 결제 요청 중 예외 발생. 주문은 PENDING 상태로 유지됩니다. (orderId: {})", + event.orderId(), e); + } + } + } + ); + + log.info("결제 요청 처리 완료. (orderId: {}, totalAmount: {}, usedPointAmount: {})", + event.orderId(), event.totalAmount(), event.usedPointAmount()); + } catch (Exception e) { + log.error("결제 요청 처리 중 오류 발생. (orderId: {})", event.orderId(), e); + throw e; + } + } + + /** + * 쿠폰 적용 이벤트를 처리하여 결제 금액을 업데이트합니다. + *

    + * 쿠폰 할인이 적용된 후 Order의 totalAmount가 업데이트되면, + * Payment의 totalAmount도 동기화하기 위해 호출됩니다. + *

    + *

    + * EDA 원칙: + *

      + *
    • 이벤트 구독: CouponEvent.CouponApplied 이벤트를 구독하여 결제 도메인 상태 업데이트
    • + *
    • 책임 분리: CouponEventHandler는 쿠폰 도메인만 관리하고, 결제 동기화는 이 핸들러에서 처리
    • + *
    + *

    + * + * @param event 쿠폰 적용 이벤트 + */ + @Transactional + public void handleCouponApplied(CouponEvent.CouponApplied event) { + try { + // 결제 금액에 쿠폰 할인 적용 + paymentService.applyCouponDiscount(event.orderId(), event.discountAmount()); + + log.info("쿠폰 할인 금액이 결제에 적용되었습니다. (orderId: {}, couponCode: {}, discountAmount: {})", + event.orderId(), event.couponCode(), event.discountAmount()); + } catch (CoreException e) { + // 결제를 찾을 수 없는 경우는 로그만 기록 (정상적인 경우일 수 있음) + if (e.getErrorType() == ErrorType.NOT_FOUND) { + log.debug("쿠폰 적용 시 결제를 찾을 수 없습니다. (orderId: {}, couponCode: {})", + event.orderId(), event.couponCode()); + } else { + log.error("쿠폰 적용 이벤트 처리 중 오류 발생. (orderId: {}, couponCode: {})", + event.orderId(), event.couponCode(), e); + } + } catch (Exception e) { + log.error("쿠폰 적용 이벤트 처리 중 예상치 못한 오류 발생. (orderId: {}, couponCode: {})", + event.orderId(), event.couponCode(), e); + } + } + + /** + * 카드 타입 문자열을 CardType enum으로 변환합니다. + * + * @param cardType 카드 타입 문자열 + * @return CardType enum + */ + private CardType convertCardType(String cardType) { + try { + return CardType.valueOf(cardType.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new CoreException( + ErrorType.BAD_REQUEST, + String.format("잘못된 카드 타입입니다. (cardType: %s)", cardType)); + } + } + + /** + * 콜백 URL을 생성합니다. + * + * @param orderId 주문 ID + * @return 콜백 URL + */ + private String generateCallbackUrl(Long orderId) { + return String.format("/api/v1/payments/callback?orderId=%d", orderId); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentRequestCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentRequestCommand.java new file mode 100644 index 000000000..83c71557e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentRequestCommand.java @@ -0,0 +1,73 @@ +package com.loopers.application.payment; + +import com.loopers.domain.payment.PaymentRequest; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +/** + * 결제 요청 명령. + *

    + * PG 결제 요청에 필요한 정보를 담는 명령 모델입니다. + *

    + * + * @author Loopers + * @version 1.0 + */ +public record PaymentRequestCommand( + String userId, + Long orderId, + String cardType, + String cardNo, + Long amount, + String callbackUrl +) { + public PaymentRequestCommand { + if (userId == null || userId.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "userId는 필수입니다."); + } + if (orderId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "orderId는 필수입니다."); + } + if (cardType == null || cardType.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "cardType은 필수입니다."); + } + if (cardNo == null || cardNo.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "cardNo는 필수입니다."); + } + if (amount == null || amount <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "amount는 0보다 커야 합니다."); + } + if (callbackUrl == null || callbackUrl.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "callbackUrl은 필수입니다."); + } + } + + /** + * 도메인 계층의 PaymentRequest로 변환합니다. + *

    + * 애플리케이션 계층의 Command를 도메인 계층의 Value Object로 변환하여 + * 도메인 서비스에 전달합니다. + *

    + * + * @return 도메인 계층의 PaymentRequest + */ + public PaymentRequest toPaymentRequest() { + return new PaymentRequest( + userId, + orderId, + cardType, + cardNo, + amount, + callbackUrl + ); + } + + @Override + public String toString() { + String maskedCardNo = cardNo != null && cardNo.length() > 4 + ? "****" + cardNo.substring(cardNo.length() - 4) + : "****"; + return "PaymentRequestCommand[userId=%s, orderId=%d, cardType=%s, cardNo=%s, amount=%d, callbackUrl=%s]" + .formatted(userId, orderId, cardType, maskedCardNo, amount, callbackUrl); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentService.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentService.java new file mode 100644 index 000000000..5802d44a3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentService.java @@ -0,0 +1,482 @@ +package com.loopers.application.payment; + +import com.loopers.domain.payment.*; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +/** + * 결제 애플리케이션 서비스. + *

    + * 결제의 생성, 조회, 상태 변경 및 PG 연동을 담당하는 애플리케이션 서비스입니다. + * 도메인 로직은 Payment 엔티티에 위임하며, Service는 조회/저장, 트랜잭션 관리 및 PG 연동을 담당합니다. + *

    + * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PaymentService { + + private final PaymentRepository paymentRepository; + private final PaymentGateway paymentGateway; + private final PaymentEventPublisher paymentEventPublisher; + + @Value("${payment.callback.base-url}") + private String callbackBaseUrl; + + /** + * 카드 결제를 생성합니다. + * + * @param orderId 주문 ID + * @param userId 사용자 ID + * @param cardType 카드 타입 + * @param cardNo 카드 번호 + * @param amount 결제 금액 + * @param requestedAt PG 요청 시각 + * @return 생성된 Payment + */ + @Transactional + public Payment create( + Long orderId, + Long userId, + CardType cardType, + String cardNo, + Long amount, + LocalDateTime requestedAt + ) { + Payment payment = Payment.of(orderId, userId, cardType, cardNo, amount, requestedAt); + return paymentRepository.save(payment); + } + + /** + * 포인트 결제를 생성합니다. + * + * @param orderId 주문 ID + * @param userId 사용자 ID + * @param totalAmount 총 결제 금액 + * @param usedPoint 사용 포인트 + * @param requestedAt PG 요청 시각 + * @return 생성된 Payment + */ + @Transactional + public Payment create( + Long orderId, + Long userId, + Long totalAmount, + Long usedPoint, + LocalDateTime requestedAt + ) { + Payment payment = Payment.of(orderId, userId, totalAmount, usedPoint, requestedAt); + return paymentRepository.save(payment); + } + + /** + * 포인트와 카드 혼합 결제를 생성합니다. + *

    + * 포인트와 쿠폰 할인을 적용한 후 남은 금액을 카드로 결제하는 경우 사용합니다. + *

    + * + * @param orderId 주문 ID + * @param userId 사용자 ID + * @param totalAmount 총 결제 금액 + * @param usedPoint 사용 포인트 + * @param cardType 카드 타입 (paidAmount > 0일 때만 필수) + * @param cardNo 카드 번호 (paidAmount > 0일 때만 필수) + * @param requestedAt PG 요청 시각 + * @return 생성된 Payment + */ + @Transactional + public Payment create( + Long orderId, + Long userId, + Long totalAmount, + Long usedPoint, + CardType cardType, + String cardNo, + LocalDateTime requestedAt + ) { + Payment payment = Payment.of(orderId, userId, totalAmount, usedPoint, cardType, cardNo, requestedAt); + return paymentRepository.save(payment); + } + + /** + * 결제를 SUCCESS 상태로 전이합니다. + *

    + * 멱등성 보장: 이미 SUCCESS 상태인 경우 아무 작업도 하지 않습니다. + * 결제 완료 후 PaymentCompleted 이벤트를 발행합니다. + *

    + * + * @param paymentId 결제 ID + * @param completedAt PG 완료 시각 + * @param transactionKey 트랜잭션 키 (null 가능) + * @throws CoreException 결제를 찾을 수 없는 경우 + */ + @Transactional + public void toSuccess(Long paymentId, LocalDateTime completedAt, String transactionKey) { + Payment payment = getPayment(paymentId); + + // 이미 SUCCESS 상태인 경우 이벤트 발행하지 않음 (멱등성) + if (payment.isCompleted()) { + return; + } + + payment.toSuccess(completedAt); // Entity에 위임 + Payment savedPayment = paymentRepository.save(payment); + + // ✅ 도메인 이벤트 발행: 결제가 완료되었음 (과거 사실) + paymentEventPublisher.publish(PaymentEvent.PaymentCompleted.from(savedPayment, transactionKey)); + } + + /** + * 결제를 FAILED 상태로 전이합니다. + *

    + * 멱등성 보장: 이미 FAILED 상태인 경우 아무 작업도 하지 않습니다. + * 결제 실패 후 PaymentFailed 이벤트를 발행합니다. + *

    + * + * @param paymentId 결제 ID + * @param failureReason 실패 사유 + * @param completedAt PG 완료 시각 + * @param transactionKey 트랜잭션 키 (null 가능) + * @throws CoreException 결제를 찾을 수 없는 경우 + */ + @Transactional + public void toFailed(Long paymentId, String failureReason, LocalDateTime completedAt, String transactionKey) { + Payment payment = getPayment(paymentId); + + // 이미 FAILED 상태인 경우 이벤트 발행하지 않음 (멱등성) + if (payment.getStatus() == PaymentStatus.FAILED) { + return; + } + + payment.toFailed(failureReason, completedAt); // Entity에 위임 + Payment savedPayment = paymentRepository.save(payment); + + // ✅ 도메인 이벤트 발행: 결제가 실패했음 (과거 사실) + paymentEventPublisher.publish(PaymentEvent.PaymentFailed.from(savedPayment, failureReason, transactionKey)); + } + + /** + * 결제 ID로 결제를 조회합니다. + * + * @param paymentId 결제 ID + * @return 조회된 Payment + * @throws CoreException 결제를 찾을 수 없는 경우 + */ + @Transactional(readOnly = true) + public Payment getPayment(Long paymentId) { + return paymentRepository.findById(paymentId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "결제를 찾을 수 없습니다.")); + } + + /** + * 주문 ID로 결제를 조회합니다. + * + * @param orderId 주문 ID + * @return 조회된 Payment (없으면 Optional.empty()) + */ + @Transactional(readOnly = true) + public Optional getPaymentByOrderId(Long orderId) { + return paymentRepository.findByOrderId(orderId); + } + + /** + * 사용자 ID로 결제 목록을 조회합니다. + * + * @param userId 사용자 ID + * @return 해당 사용자의 결제 목록 + */ + @Transactional(readOnly = true) + public List getPaymentsByUserId(Long userId) { + return paymentRepository.findAllByUserId(userId); + } + + /** + * 결제 상태로 결제 목록을 조회합니다. + * + * @param status 결제 상태 + * @return 해당 상태의 결제 목록 + */ + @Transactional(readOnly = true) + public List getPaymentsByStatus(PaymentStatus status) { + return paymentRepository.findAllByStatus(status); + } + + /** + * PG 결제 요청을 생성하고 전송합니다. + *

    + * 결제를 생성하고 PG에 결제 요청을 전송합니다. + *

    + * + * @param orderId 주문 ID + * @param userId 사용자 ID (String - User.userId) + * @param userEntityId 사용자 엔티티 ID (Long - User.id) + * @param cardType 카드 타입 + * @param cardNo 카드 번호 + * @param amount 결제 금액 + * @return 결제 요청 결과 + */ + @Transactional + public PaymentRequestResult requestPayment( + Long orderId, + String userId, + Long userEntityId, + String cardType, + String cardNo, + Long amount + ) { + // 1. 카드 번호 유효성 검증 + validateCardNo(cardNo); + + // 2. 결제 생성 (User 엔티티의 id 사용) + Payment payment = create( + orderId, + userEntityId, + convertCardType(cardType), + cardNo, + amount, + LocalDateTime.now() + ); + + // 3. 결제 요청 명령 생성 (애플리케이션 계층) + String callbackUrl = generateCallbackUrl(orderId); + PaymentRequestCommand command = new PaymentRequestCommand( + userId, + orderId, + cardType, + cardNo, + amount, + callbackUrl + ); + + // 4. 도메인 계층으로 변환하여 PG 결제 요청 전송 + PaymentRequest paymentRequest = command.toPaymentRequest(); + PaymentRequestResult result = paymentGateway.requestPayment(paymentRequest); + + // 5. 결과 처리 + if (result instanceof PaymentRequestResult.Success success) { + log.info("PG 결제 요청 성공. (orderId: {}, transactionKey: {})", orderId, success.transactionKey()); + return result; + } else if (result instanceof PaymentRequestResult.Failure failure) { + // 실패 분류 + PaymentFailureType failureType = PaymentFailureType.classify(failure.errorCode()); + if (failureType == PaymentFailureType.BUSINESS_FAILURE) { + // 비즈니스 실패: 결제 상태를 FAILED로 변경 + toFailed(payment.getId(), failure.message(), LocalDateTime.now(), null); + } + // 외부 시스템 장애는 PENDING 상태 유지 + log.warn("PG 결제 요청 실패. (orderId: {}, errorCode: {}, message: {})", + orderId, failure.errorCode(), failure.message()); + return result; + } + + throw new IllegalStateException("알 수 없는 결제 결과 타입: " + result.getClass().getName()); + } + + /** + * 결제 상태를 조회합니다. + * + * @param userId 사용자 ID + * @param orderId 주문 ID + * @return 결제 상태 + */ + @Transactional(readOnly = true) + public PaymentStatus getPaymentStatus(String userId, Long orderId) { + return paymentGateway.getPaymentStatus(userId, orderId); + } + + /** + * PG 콜백을 처리합니다. + * + * @param orderId 주문 ID + * @param transactionKey 트랜잭션 키 + * @param status 결제 상태 + * @param reason 실패 사유 (실패 시) + */ + @Transactional + public void handleCallback(Long orderId, String transactionKey, PaymentStatus status, String reason) { + Optional paymentOpt = getPaymentByOrderId(orderId); + if (paymentOpt.isEmpty()) { + log.warn("콜백 처리 시 결제를 찾을 수 없습니다. (orderId: {})", orderId); + return; + } + + Payment payment = paymentOpt.get(); + + if (status == PaymentStatus.SUCCESS) { + toSuccess(payment.getId(), LocalDateTime.now(), transactionKey); + log.info("결제 콜백 처리 완료: SUCCESS. (orderId: {}, transactionKey: {})", orderId, transactionKey); + } else if (status == PaymentStatus.FAILED) { + toFailed(payment.getId(), reason != null ? reason : "결제 실패", LocalDateTime.now(), transactionKey); + log.warn("결제 콜백 처리 완료: FAILED. (orderId: {}, transactionKey: {}, reason: {})", + orderId, transactionKey, reason); + } else { + // PENDING 상태: 상태 유지 + log.debug("결제 콜백 처리: PENDING 상태 유지. (orderId: {}, transactionKey: {})", orderId, transactionKey); + } + } + + /** + * 타임아웃 후 결제 상태를 복구합니다. + *

    + * 타임아웃 발생 후 실제 결제 상태를 확인하여 결제 상태를 업데이트합니다. + *

    + * + * @param userId 사용자 ID + * @param orderId 주문 ID + * @param delayDuration 대기 시간 (PG 처리 시간 고려) + */ + public void recoverAfterTimeout(String userId, Long orderId, Duration delayDuration) { + try { + // 잠시 대기 후 상태 확인 (PG 처리 시간 고려) + if (delayDuration != null && !delayDuration.isZero()) { + Thread.sleep(delayDuration.toMillis()); + } + + // 결제 상태 조회 + PaymentStatus status = getPaymentStatus(userId, orderId); + Optional paymentOpt = getPaymentByOrderId(orderId); + + if (paymentOpt.isEmpty()) { + log.warn("복구 시 결제를 찾을 수 없습니다. (orderId: {})", orderId); + return; + } + + Payment payment = paymentOpt.get(); + + if (status == PaymentStatus.SUCCESS) { + toSuccess(payment.getId(), LocalDateTime.now(), null); + log.info("타임아웃 후 상태 확인 완료: SUCCESS. (orderId: {})", orderId); + } else if (status == PaymentStatus.FAILED) { + toFailed(payment.getId(), "타임아웃 후 상태 확인 실패", LocalDateTime.now(), null); + log.warn("타임아웃 후 상태 확인 완료: FAILED. (orderId: {})", orderId); + } else { + // PENDING 상태: 상태 유지 + log.debug("타임아웃 후 상태 확인: PENDING 상태 유지. (orderId: {})", orderId); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn("타임아웃 후 상태 확인 중 인터럽트 발생. (orderId: {})", orderId); + } catch (Exception e) { + log.error("타임아웃 후 상태 확인 중 오류 발생. (orderId: {})", orderId, e); + } + } + + /** + * 타임아웃 후 결제 상태를 복구합니다 (기본 대기 시간: 1초). + * + * @param userId 사용자 ID + * @param orderId 주문 ID + */ + public void recoverAfterTimeout(String userId, Long orderId) { + recoverAfterTimeout(userId, orderId, Duration.ofSeconds(1)); + } + + /** + * 쿠폰 할인 금액을 적용하여 결제 금액을 업데이트합니다. + *

    + * 쿠폰 할인이 적용된 후 Order의 totalAmount가 업데이트되면, + * Payment의 totalAmount도 동기화하기 위해 호출됩니다. + *

    + * + * @param orderId 주문 ID + * @param discountAmount 할인 금액 + * @throws CoreException 결제를 찾을 수 없거나 할인 금액이 유효하지 않은 경우 + */ + @Transactional + public void applyCouponDiscount(Long orderId, Integer discountAmount) { + Payment payment = getPaymentByOrderId(orderId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + String.format("주문 ID에 해당하는 결제를 찾을 수 없습니다. (orderId: %d)", orderId))); + + payment.applyCouponDiscount(discountAmount); + paymentRepository.save(payment); + } + + // 내부 헬퍼 메서드들 + + private CardType convertCardType(String cardType) { + try { + return CardType.valueOf(cardType.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new CoreException(ErrorType.BAD_REQUEST, + String.format("잘못된 카드 타입입니다. (cardType: %s)", cardType)); + } + } + + private String generateCallbackUrl(Long orderId) { + return String.format("%s/api/v1/orders/%d/callback", callbackBaseUrl, orderId); + } + + /** + * 카드 번호 유효성 검증을 수행합니다. + * + * @param cardNo 카드 번호 + * @throws CoreException 유효하지 않은 카드 번호인 경우 + */ + private void validateCardNo(String cardNo) { + if (cardNo == null || cardNo.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "카드 번호는 필수입니다."); + } + + // 공백/하이픈 제거 및 정규화 + String normalized = cardNo.replaceAll("[\\s-]", ""); + + // 길이 검증 (13-19자리) + if (normalized.length() < 13 || normalized.length() > 19) { + throw new CoreException(ErrorType.BAD_REQUEST, + String.format("유효하지 않은 카드 번호 길이입니다. (길이: %d, 요구사항: 13-19자리)", normalized.length())); + } + + // 숫자만 포함하는지 검증 + if (!normalized.matches("\\d+")) { + throw new CoreException(ErrorType.BAD_REQUEST, "카드 번호는 숫자만 포함해야 합니다."); + } + + // Luhn 알고리즘 체크섬 검증 + if (!isValidLuhn(normalized)) { + throw new CoreException(ErrorType.BAD_REQUEST, "유효하지 않은 카드 번호입니다. (Luhn 알고리즘 검증 실패)"); + } + } + + /** + * Luhn 알고리즘을 사용하여 카드 번호의 체크섬을 검증합니다. + * + * @param cardNo 정규화된 카드 번호 (숫자만 포함) + * @return 유효한 경우 true, 그렇지 않으면 false + */ + private boolean isValidLuhn(String cardNo) { + int sum = 0; + boolean alternate = false; + + // 오른쪽에서 왼쪽으로 순회 + for (int i = cardNo.length() - 1; i >= 0; i--) { + int digit = Character.getNumericValue(cardNo.charAt(i)); + + if (alternate) { + digit *= 2; + if (digit > 9) { + digit = (digit % 10) + 1; + } + } + + sum += digit; + alternate = !alternate; + } + + return (sum % 10) == 0; + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/pointwallet/PointWalletFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/pointwallet/PointWalletFacade.java deleted file mode 100644 index 1e1d171a1..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/pointwallet/PointWalletFacade.java +++ /dev/null @@ -1,83 +0,0 @@ -package com.loopers.application.pointwallet; - -import com.loopers.domain.user.Point; -import com.loopers.domain.user.User; -import com.loopers.domain.user.UserRepository; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.transaction.Transactional; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -/** - * 포인트 지갑 파사드. - *

    - * 포인트 조회 및 충전 유즈케이스를 처리하는 애플리케이션 서비스입니다. - * 트랜잭션 경계를 관리하여 데이터 일관성을 보장합니다. - *

    - * - * @author Loopers - * @version 1.0 - */ -@RequiredArgsConstructor -@Component -public class PointWalletFacade { - private final UserRepository userRepository; - - /** - * 사용자의 포인트를 조회합니다. - * - * @param userId 조회할 사용자 ID - * @return 포인트 정보 - * @throws CoreException 사용자를 찾을 수 없는 경우 - */ - public PointsInfo getPoints(String userId) { - User user = userRepository.findByUserId(userId); - if (user == null) { - throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다."); - } - return PointsInfo.from(user); - } - - /** - * 사용자의 포인트를 충전합니다. - *

    - * 트랜잭션 내에서 실행되어 데이터 일관성을 보장합니다. - *

    - * - * @param userId 충전할 사용자 ID - * @param amount 충전할 포인트 금액 - * @return 충전된 포인트 정보 - * @throws CoreException 사용자를 찾을 수 없거나 충전 금액이 유효하지 않은 경우 - */ - @Transactional - public PointsInfo chargePoint(String userId, Long amount) { - User user = userRepository.findByUserId(userId); - if (user == null) { - throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다."); - } - Point point = Point.of(amount); - user.receivePoint(point); - User savedUser = userRepository.save(user); - return PointsInfo.from(savedUser); - } - - /** - * 포인트 정보를 담는 레코드. - * - * @param userId 사용자 ID - * @param balance 포인트 잔액 - */ - public record PointsInfo(String userId, Long balance) { - /** - * User 엔티티로부터 PointsInfo를 생성합니다. - * - * @param user 사용자 엔티티 - * @return 생성된 PointsInfo - */ - public static PointsInfo from(User user) { - return new PointsInfo(user.getUserId(), user.getPoint().getValue()); - } - } -} - diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/DeductStockCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/product/DeductStockCommand.java new file mode 100644 index 000000000..343292349 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/DeductStockCommand.java @@ -0,0 +1,27 @@ +package com.loopers.application.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +/** + * 재고 차감 명령. + *

    + * 재고 차감을 위한 명령 객체입니다. + *

    + * + * @param productId 상품 ID + * @param quantity 차감할 수량 + */ +public record DeductStockCommand( + Long productId, + Integer quantity +) { + public DeductStockCommand { + if (productId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 ID는 필수입니다."); + } + if (quantity == null || quantity <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "차감 수량은 1 이상이어야 합니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/catalog/ProductCacheService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheService.java similarity index 75% rename from apps/commerce-api/src/main/java/com/loopers/application/catalog/ProductCacheService.java rename to apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheService.java index 8428efa50..f2e6b5bfe 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/catalog/ProductCacheService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheService.java @@ -1,19 +1,23 @@ -package com.loopers.application.catalog; +package com.loopers.application.product; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.catalog.ProductInfo; +import com.loopers.application.catalog.ProductInfoList; +import com.loopers.cache.CacheKey; +import com.loopers.cache.CacheTemplate; +import com.loopers.cache.SimpleCacheKey; import com.loopers.domain.product.ProductDetail; import lombok.RequiredArgsConstructor; -import org.springframework.data.redis.core.RedisTemplate; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import java.time.Duration; import java.util.List; +import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; /** - * 상품 조회 결과를 Redis에 캐시하는 서비스. + * 상품 조회 결과를 캐시하는 서비스. *

    * 상품 목록 조회와 상품 상세 조회 결과를 캐시하여 성능을 향상시킵니다. *

    @@ -27,6 +31,7 @@ * * @author Loopers */ +@Slf4j @Service @RequiredArgsConstructor public class ProductCacheService { @@ -35,8 +40,7 @@ public class ProductCacheService { private static final String CACHE_KEY_PREFIX_DETAIL = "product:detail:"; private static final Duration CACHE_TTL = Duration.ofMinutes(1); // 1분 TTL - private final RedisTemplate redisTemplate; - private final ObjectMapper objectMapper; + private final CacheTemplate cacheTemplate; /** * 로컬 캐시: 상품별 좋아요 수 델타 (productId -> likeCount delta) @@ -64,21 +68,20 @@ public class ProductCacheService { * @return 캐시된 상품 목록 (없으면 null) */ public ProductInfoList getCachedProductList(Long brandId, String sort, int page, int size) { - try { - String key = buildListCacheKey(brandId, sort, page, size); - String cachedValue = redisTemplate.opsForValue().get(key); - - if (cachedValue == null) { - return null; - } - - ProductInfoList cachedList = objectMapper.readValue(cachedValue, new TypeReference() {}); - - // 로컬 캐시의 좋아요 수 델타 적용 - return applyLikeCountDelta(cachedList); - } catch (Exception e) { + String cacheKey = buildListCacheKey(brandId, sort, page, size); + CacheKey key = SimpleCacheKey.of( + cacheKey, + CACHE_TTL, + ProductInfoList.class + ); + + Optional cached = cacheTemplate.get(key); + if (cached.isEmpty()) { return null; } + + // 로컬 캐시의 좋아요 수 델타 적용 + return applyLikeCountDelta(cached.get()); } /** @@ -98,14 +101,15 @@ public void cacheProductList(Long brandId, String sort, int page, int size, Prod if (page > 2) { return; } - - try { - String key = buildListCacheKey(brandId, sort, page, size); - String value = objectMapper.writeValueAsString(productInfoList); - redisTemplate.opsForValue().set(key, value, CACHE_TTL); - } catch (Exception e) { - // 캐시 저장 실패는 무시 (DB 조회로 폴백 가능) - } + + String cacheKey = buildListCacheKey(brandId, sort, page, size); + CacheKey key = SimpleCacheKey.of( + cacheKey, + CACHE_TTL, + ProductInfoList.class + ); + + cacheTemplate.put(key, productInfoList); } /** @@ -118,21 +122,20 @@ public void cacheProductList(Long brandId, String sort, int page, int size, Prod * @return 캐시된 상품 정보 (없으면 null) */ public ProductInfo getCachedProduct(Long productId) { - try { - String key = buildDetailCacheKey(productId); - String cachedValue = redisTemplate.opsForValue().get(key); - - if (cachedValue == null) { - return null; - } - - ProductInfo cachedInfo = objectMapper.readValue(cachedValue, new TypeReference() {}); - - // 로컬 캐시의 좋아요 수 델타 적용 - return applyLikeCountDelta(cachedInfo); - } catch (Exception e) { + String cacheKey = buildDetailCacheKey(productId); + CacheKey key = SimpleCacheKey.of( + cacheKey, + CACHE_TTL, + ProductInfo.class + ); + + Optional cached = cacheTemplate.get(key); + if (cached.isEmpty()) { return null; } + + // 로컬 캐시의 좋아요 수 델타 적용 + return applyLikeCountDelta(cached.get()); } /** @@ -142,13 +145,14 @@ public ProductInfo getCachedProduct(Long productId) { * @param productInfo 캐시할 상품 정보 */ public void cacheProduct(Long productId, ProductInfo productInfo) { - try { - String key = buildDetailCacheKey(productId); - String value = objectMapper.writeValueAsString(productInfo); - redisTemplate.opsForValue().set(key, value, CACHE_TTL); - } catch (Exception e) { - // 캐시 저장 실패는 무시 (DB 조회로 폴백 가능) - } + String cacheKey = buildDetailCacheKey(productId); + CacheKey key = SimpleCacheKey.of( + cacheKey, + CACHE_TTL, + ProductInfo.class + ); + + cacheTemplate.put(key, productInfo); } /** @@ -162,7 +166,6 @@ public void cacheProduct(Long productId, ProductInfo productInfo) { */ private String buildListCacheKey(Long brandId, String sort, int page, int size) { String brandPart = brandId != null ? "brand:" + brandId : "brand:all"; - // sort가 null이면 기본값 "latest" 사용 (컨트롤러와 동일한 기본값) String sortValue = sort != null ? sort : "latest"; return String.format("%s%s:sort:%s:page:%d:size:%d", CACHE_KEY_PREFIX_LIST, brandPart, sortValue, page, size); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductEventHandler.java new file mode 100644 index 000000000..3cba362e3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductEventHandler.java @@ -0,0 +1,214 @@ +package com.loopers.application.product; + +import com.loopers.domain.like.LikeEvent; +import com.loopers.domain.order.OrderEvent; +import com.loopers.domain.product.Product; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 상품 이벤트 핸들러. + *

    + * 좋아요 추가/취소 이벤트와 주문 생성/취소 이벤트를 받아 상품의 좋아요 수 및 재고를 업데이트하는 애플리케이션 로직을 처리합니다. + *

    + *

    + * DDD/EDA 관점: + *

      + *
    • 책임 분리: ProductService는 상품 도메인 비즈니스 로직, ProductEventHandler는 이벤트 처리 로직
    • + *
    • 이벤트 핸들러: 이벤트를 받아서 처리하는 역할을 명확히 나타냄
    • + *
    • 도메인 경계 준수: 상품 도메인은 자신의 상태만 관리하며, 주문 생성/취소 이벤트를 구독하여 재고 관리
    • + *
    • EDA 원칙: LikeEvent를 구독하여 상품 좋아요 수 및 캐시를 업데이트
    • + *
    + *

    + * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ProductEventHandler { + + private final ProductService productService; + private final ProductCacheService productCacheService; + + /** + * 좋아요 추가 이벤트를 처리하여 상품의 좋아요 수를 증가시킵니다. + *

    + * EDA 원칙: + *

      + *
    • 이벤트 구독: LikeEvent.LikeAdded 이벤트를 구독하여 상품 도메인 상태 업데이트
    • + *
    • 책임 분리: HeartFacade는 이 핸들러의 존재를 모르며, 이벤트만 발행
    • + *
    + *

    + * + * @param event 좋아요 추가 이벤트 + */ + @Transactional + public void handleLikeAdded(LikeEvent.LikeAdded event) { + log.debug("좋아요 추가 이벤트 처리: productId={}, userId={}", + event.productId(), event.userId()); + + // ✅ 이벤트 기반 실시간 집계: Product.likeCount 직접 증가 + productService.incrementLikeCount(event.productId()); + + // ✅ 캐시 델타 업데이트: 좋아요 추가 시 로컬 캐시의 델타 증가 + productCacheService.incrementLikeCountDelta(event.productId()); + + log.debug("좋아요 수 증가 완료: productId={}", event.productId()); + } + + /** + * 좋아요 취소 이벤트를 처리하여 상품의 좋아요 수를 감소시킵니다. + *

    + * EDA 원칙: + *

      + *
    • 이벤트 구독: LikeEvent.LikeRemoved 이벤트를 구독하여 상품 도메인 상태 업데이트
    • + *
    • 책임 분리: HeartFacade는 이 핸들러의 존재를 모르며, 이벤트만 발행
    • + *
    + *

    + * + * @param event 좋아요 취소 이벤트 + */ + @Transactional + public void handleLikeRemoved(LikeEvent.LikeRemoved event) { + log.debug("좋아요 취소 이벤트 처리: productId={}, userId={}", + event.productId(), event.userId()); + + // ✅ 이벤트 기반 실시간 집계: Product.likeCount 직접 감소 + productService.decrementLikeCount(event.productId()); + + // ✅ 캐시 델타 업데이트: 좋아요 취소 시 로컬 캐시의 델타 감소 + productCacheService.decrementLikeCountDelta(event.productId()); + + log.debug("좋아요 수 감소 완료: productId={}", event.productId()); + } + + /** + * 주문 생성 이벤트를 처리하여 재고를 차감합니다. + *

    + * 동시성 제어: + *

      + *
    • 비관적 락 사용: 재고 차감 시 동시성 제어를 위해 findByIdForUpdate 사용
    • + *
    • Deadlock 방지: 상품 ID를 정렬하여 일관된 락 획득 순서 보장
    • + *
    + *

    + * + * @param event 주문 생성 이벤트 + */ + @Transactional + public void handleOrderCreated(OrderEvent.OrderCreated event) { + if (event.orderItems() == null || event.orderItems().isEmpty()) { + log.debug("주문 아이템이 없어 재고 차감을 건너뜁니다. (orderId: {})", event.orderId()); + return; + } + + try { + // ✅ Deadlock 방지: 상품 ID를 정렬하여 일관된 락 획득 순서 보장 + List sortedProductIds = event.orderItems().stream() + .map(OrderEvent.OrderCreated.OrderItemInfo::productId) + .distinct() + .sorted() + .toList(); + + // 정렬된 순서대로 상품 락 획득 (Deadlock 방지) + Map productMap = new HashMap<>(); + for (Long productId : sortedProductIds) { + try { + Product product = productService.getProductForUpdate(productId); + productMap.put(productId, product); + } catch (Exception e) { + log.warn("상품 락 획득 실패. (orderId: {}, productId: {})", event.orderId(), productId); + // 상품이 없으면 해당 아이템은 건너뜀 + } + } + + // ✅ OrderEvent.OrderCreated를 구독하여 재고 차감 Command 실행 + for (OrderEvent.OrderCreated.OrderItemInfo itemInfo : event.orderItems()) { + Product product = productMap.get(itemInfo.productId()); + if (product == null) { + continue; + } + + // ✅ DeductStockCommand 생성 및 실행 + DeductStockCommand command = new DeductStockCommand(itemInfo.productId(), itemInfo.quantity()); + product.decreaseStock(command.quantity()); + } + + // 저장 + productService.saveAll(productMap.values().stream().toList()); + + log.info("주문 생성으로 인한 재고 차감 완료. (orderId: {})", event.orderId()); + } catch (Exception e) { + log.error("주문 생성 이벤트 처리 중 오류 발생. (orderId: {})", event.orderId(), e); + throw e; + } + } + + /** + * 주문 취소 이벤트를 처리하여 재고를 원복합니다. + *

    + * 동시성 제어: + *

      + *
    • 비관적 락 사용: 재고 원복 시 동시성 제어를 위해 findByIdForUpdate 사용
    • + *
    • Deadlock 방지: 상품 ID를 정렬하여 일관된 락 획득 순서 보장
    • + *
    + *

    + * + * @param event 주문 취소 이벤트 + */ + @Transactional + public void handleOrderCanceled(OrderEvent.OrderCanceled event) { + if (event.orderItems() == null || event.orderItems().isEmpty()) { + log.debug("주문 아이템이 없어 재고 원복을 건너뜁니다. (orderId: {})", event.orderId()); + return; + } + + try { + // ✅ Deadlock 방지: 상품 ID를 정렬하여 일관된 락 획득 순서 보장 + List sortedProductIds = event.orderItems().stream() + .map(OrderEvent.OrderCanceled.OrderItemInfo::productId) + .distinct() + .sorted() + .toList(); + + // 정렬된 순서대로 상품 락 획득 (Deadlock 방지) + Map productMap = new HashMap<>(); + for (Long productId : sortedProductIds) { + try { + Product product = productService.getProductForUpdate(productId); + productMap.put(productId, product); + } catch (Exception e) { + log.warn("상품 락 획득 실패. (orderId: {}, productId: {})", event.orderId(), productId); + // 상품이 없으면 해당 아이템은 건너뜀 + } + } + + // 재고 원복 + for (OrderEvent.OrderCanceled.OrderItemInfo itemInfo : event.orderItems()) { + Product product = productMap.get(itemInfo.productId()); + if (product == null) { + log.warn("상품을 찾을 수 없습니다. (orderId: {}, productId: {})", + event.orderId(), itemInfo.productId()); + continue; + } + product.increaseStock(itemInfo.quantity()); + } + + // 저장 + productService.saveAll(productMap.values().stream().toList()); + + log.info("주문 취소로 인한 재고 원복 완료. (orderId: {})", event.orderId()); + } catch (Exception e) { + log.error("주문 취소 이벤트 처리 중 오류 발생. (orderId: {})", event.orderId(), e); + throw e; + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java new file mode 100644 index 000000000..36889a38f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java @@ -0,0 +1,148 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * 상품 애플리케이션 서비스. + *

    + * 상품 조회, 저장 등의 애플리케이션 로직을 처리합니다. + * Repository에 의존하며 트랜잭션 관리를 담당합니다. + *

    + * + * @author Loopers + * @version 1.0 + */ +@RequiredArgsConstructor +@Component +public class ProductService { + private final ProductRepository productRepository; + + /** + * 상품 ID로 상품을 조회합니다. + * + * @param productId 조회할 상품 ID + * @return 조회된 상품 + * @throws CoreException 상품을 찾을 수 없는 경우 + */ + @Transactional(readOnly = true) + public Product getProduct(Long productId) { + return productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", productId))); + } + + /** + * 상품 ID 목록으로 상품 목록을 조회합니다. + *

    + * 배치 조회를 통해 N+1 쿼리 문제를 해결합니다. + *

    + * + * @param productIds 조회할 상품 ID 목록 + * @return 조회된 상품 목록 + */ + @Transactional(readOnly = true) + public List getProducts(List productIds) { + return productRepository.findAllById(productIds); + } + + /** + * 상품 ID로 상품을 조회합니다. (비관적 락) + *

    + * 동시성 제어가 필요한 경우 사용합니다. (예: 재고 차감) + *

    + * + * @param productId 조회할 상품 ID + * @return 조회된 상품 + * @throws CoreException 상품을 찾을 수 없는 경우 + */ + @Transactional + public Product getProductForUpdate(Long productId) { + return productRepository.findByIdForUpdate(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", productId))); + } + + /** + * 상품 목록을 저장합니다. + * + * @param products 저장할 상품 목록 + */ + @Transactional + public void saveAll(List products) { + products.forEach(productRepository::save); + } + + /** + * 상품 목록을 조회합니다. + * + * @param brandId 브랜드 ID (null이면 전체 조회) + * @param sort 정렬 기준 (latest, price_asc, likes_desc) + * @param page 페이지 번호 (0부터 시작) + * @param size 페이지당 상품 수 + * @return 상품 목록 + */ + @Transactional(readOnly = true) + public List findAll(Long brandId, String sort, int page, int size) { + return productRepository.findAll(brandId, sort, page, size); + } + + /** + * 상품 목록의 총 개수를 조회합니다. + * + * @param brandId 브랜드 ID (null이면 전체 조회) + * @return 상품 총 개수 + */ + @Transactional(readOnly = true) + public long countAll(Long brandId) { + return productRepository.countAll(brandId); + } + + /** + * 상품의 좋아요 수를 증가시킵니다. + *

    + * 이벤트 기반 집계에서 사용됩니다. + *

    + *

    + * 동시성 제어: 비관적 락을 사용하지 않습니다. 좋아요 수는 정확도보다 성능이 중요하며, + * 약간의 오차는 허용 가능합니다. 필요시 나중에 비관적 락을 추가할 수 있습니다. + *

    + * + * @param productId 상품 ID + * @throws CoreException 상품을 찾을 수 없는 경우 + */ + @Transactional + public void incrementLikeCount(Long productId) { + Product product = getProduct(productId); + product.incrementLikeCount(); + productRepository.save(product); + } + + /** + * 상품의 좋아요 수를 감소시킵니다. + *

    + * 이벤트 기반 집계에서 사용됩니다. + *

    + *

    + * 동시성 제어: 비관적 락을 사용하지 않습니다. 좋아요 수는 정확도보다 성능이 중요하며, + * 약간의 오차는 허용 가능합니다. 필요시 나중에 비관적 락을 추가할 수 있습니다. + *

    + * + * @param productId 상품 ID + * @throws CoreException 상품을 찾을 수 없는 경우 + */ + @Transactional + public void decrementLikeCount(Long productId) { + Product product = getProduct(productId); + product.decrementLikeCount(); + productRepository.save(product); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentFailureHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentFailureHandler.java deleted file mode 100644 index 52f240e13..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentFailureHandler.java +++ /dev/null @@ -1,107 +0,0 @@ -package com.loopers.application.purchasing; - -import com.loopers.domain.order.Order; -import com.loopers.domain.order.OrderRepository; -import com.loopers.domain.order.OrderStatus; -import com.loopers.domain.order.OrderCancellationService; -import com.loopers.domain.user.User; -import com.loopers.domain.user.UserRepository; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; - -/** - * 결제 실패 처리 서비스. - *

    - * 결제 실패 시 주문 취소 및 리소스 원복을 처리합니다. - *

    - *

    - * 트랜잭션 전략: - *

      - *
    • REQUIRES_NEW: 별도 트랜잭션으로 실행하여 외부 시스템 호출과 독립적으로 처리
    • - *
    • 결제 실패 처리 중 오류가 발생해도 기존 주문 생성 트랜잭션에 영향을 주지 않음
    • - *
    • Self-invocation 문제 해결: 별도 서비스로 분리하여 Spring AOP 프록시가 정상적으로 적용되도록 함
    • - *
    - *

    - *

    - * 주의사항: - *

      - *
    • 주문이 이미 취소되었거나 존재하지 않는 경우 로그만 기록합니다.
    • - *
    • 결제 실패 처리 중 오류 발생 시에도 로그만 기록합니다.
    • - *
    - *

    - * - * @author Loopers - * @version 1.0 - */ -@Slf4j -@Component -@RequiredArgsConstructor -public class PaymentFailureHandler { - - private final UserRepository userRepository; - private final OrderRepository orderRepository; - private final OrderCancellationService orderCancellationService; - - /** - * 결제 실패 시 주문 취소 및 리소스 원복을 처리합니다. - *

    - * 결제 요청이 실패한 경우, 이미 생성된 주문을 취소하고 - * 차감된 포인트를 환불하며 재고를 원복합니다. - *

    - *

    - * 처리 내용: - *

      - *
    • 주문 상태를 CANCELED로 변경
    • - *
    • 차감된 포인트 환불
    • - *
    • 차감된 재고 원복
    • - *
    - *

    - * - * @param userId 사용자 ID (로그인 ID) - * @param orderId 주문 ID - * @param errorCode 오류 코드 - * @param errorMessage 오류 메시지 - */ - @Transactional(propagation = Propagation.REQUIRES_NEW) - public void handle(String userId, Long orderId, String errorCode, String errorMessage) { - try { - // 사용자 조회 - User user = userRepository.findByUserId(userId); - - if (user == null) { - log.warn("결제 실패 처리 시 사용자를 찾을 수 없습니다. (userId: {}, orderId: {})", userId, orderId); - return; - } - - // 주문 조회 - Order order = orderRepository.findById(orderId) - .orElse(null); - - if (order == null) { - log.warn("결제 실패 처리 시 주문을 찾을 수 없습니다. (orderId: {})", orderId); - return; - } - - // 이미 취소된 주문인 경우 처리하지 않음 - if (order.getStatus() == OrderStatus.CANCELED) { - log.info("이미 취소된 주문입니다. 결제 실패 처리를 건너뜁니다. (orderId: {})", orderId); - return; - } - - // 주문 취소 및 리소스 원복 - orderCancellationService.cancel(order, user); - - log.info("결제 실패로 인한 주문 취소 완료. (orderId: {}, errorCode: {}, errorMessage: {})", - orderId, errorCode, errorMessage); - } catch (Exception e) { - // 결제 실패 처리 중 오류 발생 시에도 로그만 기록 - // 이미 주문은 생성되어 있으므로, 나중에 수동으로 처리할 수 있도록 로그 기록 - log.error("결제 실패 처리 중 오류 발생. (orderId: {}, errorCode: {})", - orderId, errorCode, e); - } - } -} - diff --git a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRecoveryService.java b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRecoveryService.java deleted file mode 100644 index 6c6ff0646..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRecoveryService.java +++ /dev/null @@ -1,66 +0,0 @@ -package com.loopers.application.purchasing; - -import com.loopers.domain.order.OrderStatusUpdater; -import com.loopers.infrastructure.paymentgateway.DelayProvider; -import com.loopers.infrastructure.paymentgateway.PaymentGatewayAdapter; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -import java.time.Duration; - -/** - * 결제 복구 서비스. - *

    - * 타임아웃 발생 후 결제 상태를 확인하여 주문 상태를 복구합니다. - *

    - * - * @author Loopers - * @version 1.0 - */ -@Slf4j -@Component -@RequiredArgsConstructor -public class PaymentRecoveryService { - - private final PaymentGatewayAdapter paymentGatewayAdapter; - private final OrderStatusUpdater orderStatusUpdater; - private final DelayProvider delayProvider; - - /** - * 타임아웃 발생 후 결제 상태를 확인하여 주문 상태를 복구합니다. - *

    - * 타임아웃은 요청이 전송되었을 수 있으므로, 실제 결제 상태를 확인하여 - * 결제가 성공했다면 주문을 완료하고, 실패했다면 주문을 취소합니다. - *

    - * - * @param userId 사용자 ID - * @param orderId 주문 ID - */ - public void recoverAfterTimeout(String userId, Long orderId) { - try { - // 잠시 대기 후 상태 확인 (PG 처리 시간 고려) - // 타임아웃이 발생했지만 요청은 전송되었을 수 있으므로, - // PG 시스템이 처리할 시간을 주기 위해 짧은 대기 - delayProvider.delay(Duration.ofSeconds(1)); - - // PG에서 주문별 결제 정보 조회 - var status = paymentGatewayAdapter.getPaymentStatus(userId, String.valueOf(orderId)); - - // 별도 트랜잭션으로 상태 업데이트 - boolean updated = orderStatusUpdater.updateByPaymentStatus(orderId, status, null, null); - - if (!updated) { - log.warn("타임아웃 후 상태 확인 실패. 주문 상태 업데이트에 실패했습니다. 나중에 스케줄러로 복구됩니다. (orderId: {})", orderId); - } - - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - log.warn("타임아웃 후 상태 확인 중 인터럽트 발생. (orderId: {})", orderId); - } catch (Exception e) { - // 기타 오류: 나중에 스케줄러로 복구 가능 - log.error("타임아웃 후 상태 확인 중 오류 발생. 나중에 스케줄러로 복구됩니다. (orderId: {})", orderId, e); - } - } -} - diff --git a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRequest.java b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRequest.java deleted file mode 100644 index 4188fd2e6..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRequest.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.loopers.application.purchasing; - -import com.loopers.infrastructure.paymentgateway.PaymentGatewayDto; - -/** - * 결제 요청 도메인 모델. - * - * @author Loopers - * @version 1.0 - */ -public record PaymentRequest( - String userId, - String orderId, - PaymentGatewayDto.CardType cardType, - String cardNo, - Long amount, - String callbackUrl -) { -} - diff --git a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRequestBuilder.java b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRequestBuilder.java deleted file mode 100644 index 21b7869d0..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRequestBuilder.java +++ /dev/null @@ -1,167 +0,0 @@ -package com.loopers.application.purchasing; - -import com.loopers.infrastructure.paymentgateway.PaymentGatewayDto; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; - -/** - * 결제 요청 빌더. - *

    - * 결제 요청 도메인 모델을 생성합니다. - *

    - * - * @author Loopers - * @version 1.0 - */ -@Component -@RequiredArgsConstructor -public class PaymentRequestBuilder { - - @Value("${payment.callback.base-url}") - private String callbackBaseUrl; - - /** - * 결제 요청을 생성합니다. - * - * @param userId 사용자 ID - * @param orderId 주문 ID - * @param cardType 카드 타입 문자열 - * @param cardNo 카드 번호 - * @param amount 결제 금액 - * @return 결제 요청 도메인 모델 - * @throws CoreException 잘못된 카드 타입인 경우 - */ - public PaymentRequest build(String userId, Long orderId, String cardType, String cardNo, Integer amount) { - // 카드 번호 유효성 검증 - validateCardNo(cardNo); - - // 주문 ID를 6자리 이상 문자열로 변환 (pg-simulator 검증 요구사항) - String orderIdString = formatOrderId(orderId); - return new PaymentRequest( - userId, - orderIdString, - parseCardType(cardType), - cardNo, - amount.longValue(), - generateCallbackUrl(orderId) - ); - } - - /** - * 카드 타입 문자열을 CardType enum으로 변환합니다. - * - * @param cardType 카드 타입 문자열 - * @return CardType enum - * @throws CoreException 잘못된 카드 타입인 경우 - */ - private PaymentGatewayDto.CardType parseCardType(String cardType) { - try { - return PaymentGatewayDto.CardType.valueOf(cardType.toUpperCase()); - } catch (IllegalArgumentException e) { - throw new CoreException(ErrorType.BAD_REQUEST, - String.format("잘못된 카드 타입입니다. (cardType: %s)", cardType)); - } - } - - /** - * 콜백 URL을 생성합니다. - *

    - * 환경변수 {@code payment.callback.base-url}을 사용하여 프로덕션 환경에 적합한 URL을 생성합니다. - *

    - * - * @param orderId 주문 ID - * @return 콜백 URL - */ - private String generateCallbackUrl(Long orderId) { - return String.format("%s/api/v1/orders/%d/callback", callbackBaseUrl, orderId); - } - - /** - * 카드 번호 유효성 검증을 수행합니다. - *

    - * 다음 사항들을 검증합니다: - *

      - *
    • null/empty 체크
    • - *
    • 공백/하이픈 제거 및 정규화
    • - *
    • 길이 검증 (13-19자리)
    • - *
    • 숫자만 포함하는지 검증
    • - *
    • Luhn 알고리즘 체크섬 검증
    • - *
    - *

    - * - * @param cardNo 카드 번호 - * @throws CoreException 유효하지 않은 카드 번호인 경우 - */ - private void validateCardNo(String cardNo) { - if (cardNo == null || cardNo.isEmpty()) { - throw new CoreException(ErrorType.BAD_REQUEST, "카드 번호는 필수입니다."); - } - - // 공백/하이픈 제거 및 정규화 - String normalized = cardNo.replaceAll("[\\s-]", ""); - - // 길이 검증 (13-19자리) - if (normalized.length() < 13 || normalized.length() > 19) { - throw new CoreException(ErrorType.BAD_REQUEST, - String.format("유효하지 않은 카드 번호 길이입니다. (길이: %d, 요구사항: 13-19자리)", normalized.length())); - } - - // 숫자만 포함하는지 검증 - if (!normalized.matches("\\d+")) { - throw new CoreException(ErrorType.BAD_REQUEST, "카드 번호는 숫자만 포함해야 합니다."); - } - - // Luhn 알고리즘 체크섬 검증 - if (!isValidLuhn(normalized)) { - throw new CoreException(ErrorType.BAD_REQUEST, "유효하지 않은 카드 번호입니다. (Luhn 알고리즘 검증 실패)"); - } - } - - /** - * Luhn 알고리즘을 사용하여 카드 번호의 체크섬을 검증합니다. - *

    - * Luhn 알고리즘은 신용카드 번호의 유효성을 검증하는 표준 알고리즘입니다. - *

    - * - * @param cardNo 정규화된 카드 번호 (숫자만 포함) - * @return 유효한 경우 true, 그렇지 않으면 false - */ - private boolean isValidLuhn(String cardNo) { - int sum = 0; - boolean alternate = false; - - // 오른쪽에서 왼쪽으로 순회 - for (int i = cardNo.length() - 1; i >= 0; i--) { - int digit = Character.getNumericValue(cardNo.charAt(i)); - - if (alternate) { - digit *= 2; - if (digit > 9) { - digit = (digit % 10) + 1; - } - } - - sum += digit; - alternate = !alternate; - } - - return (sum % 10) == 0; - } - - /** - * 주문 ID를 6자리 이상 문자열로 변환합니다. - *

    - * pg-simulator의 검증 요구사항에 맞추기 위해 최소 6자리로 패딩합니다. - *

    - * - * @param orderId 주문 ID (Long) - * @return 6자리 이상의 주문 ID 문자열 - */ - public String formatOrderId(Long orderId) { - return String.format("%06d", orderId); - } -} - diff --git a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PurchasingFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PurchasingFacade.java index 0a2b1b208..bf76c3280 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PurchasingFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PurchasingFacade.java @@ -1,33 +1,24 @@ package com.loopers.application.purchasing; -import com.loopers.domain.coupon.Coupon; -import com.loopers.domain.coupon.CouponRepository; -import com.loopers.domain.coupon.UserCoupon; -import com.loopers.domain.coupon.UserCouponRepository; -import com.loopers.domain.coupon.discount.CouponDiscountStrategyFactory; import com.loopers.domain.order.Order; import com.loopers.domain.order.OrderItem; -import com.loopers.domain.order.OrderRepository; -import com.loopers.domain.order.OrderStatus; -import org.springframework.orm.ObjectOptimisticLockingFailureException; +import com.loopers.application.order.CreateOrderCommand; +import com.loopers.application.order.OrderService; import com.loopers.domain.product.Product; -import com.loopers.domain.product.ProductRepository; -import com.loopers.domain.user.Point; +import com.loopers.application.product.ProductService; import com.loopers.domain.user.User; -import com.loopers.domain.user.UserRepository; -import com.loopers.infrastructure.paymentgateway.PaymentGatewaySchedulerClient; -import com.loopers.infrastructure.paymentgateway.PaymentGatewayDto; -import com.loopers.infrastructure.paymentgateway.PaymentGatewayAdapter; -import com.loopers.domain.order.PaymentFailureClassifier; -import com.loopers.domain.order.PaymentFailureType; -import com.loopers.domain.order.OrderStatusUpdater; -import com.loopers.domain.order.OrderCancellationService; +import com.loopers.application.user.UserService; +import com.loopers.infrastructure.payment.PaymentGatewayDto; +import com.loopers.domain.payment.PaymentEvent; +import com.loopers.domain.payment.PaymentEventPublisher; +import com.loopers.application.payment.PaymentService; +import com.loopers.domain.payment.Payment; +import com.loopers.domain.payment.PaymentStatus; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import feign.FeignException; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; -import org.springframework.transaction.support.TransactionSynchronization; -import org.springframework.transaction.support.TransactionSynchronizationManager; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -36,79 +27,72 @@ import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.stream.Collectors; /** * 구매 파사드. *

    - * 주문 생성과 결제(포인트 차감), 재고 조정, 외부 연동을 조율한다. + * 주문 생성과 결제(포인트 차감), 재고 조정, 외부 연동을 조율하는 애플리케이션 서비스입니다. + * 여러 도메인 서비스를 조합하여 구매 유즈케이스를 처리합니다. *

    + *

    + * EDA 원칙 준수: + *

      + *
    • 이벤트 기반: 도메인 이벤트만 발행하고, 다른 애그리거트를 직접 수정하지 않음
    • + *
    • 느슨한 결합: Product, User, Payment 애그리거트와의 직접적인 의존성 최소화
    • + *
    • 책임 분리: 주문 도메인만 관리하고, 재고/포인트/결제 처리는 이벤트 핸들러에서 처리
    • + *
    + *

    + * + * @author Loopers + * @version 1.0 */ @Slf4j @RequiredArgsConstructor @Component public class PurchasingFacade { - private final UserRepository userRepository; - private final ProductRepository productRepository; - private final OrderRepository orderRepository; - private final CouponRepository couponRepository; - private final UserCouponRepository userCouponRepository; - private final CouponDiscountStrategyFactory couponDiscountStrategyFactory; - private final PaymentGatewaySchedulerClient paymentGatewaySchedulerClient; // 스케줄러용 (Retry 적용) - private final PaymentRequestBuilder paymentRequestBuilder; - private final PaymentGatewayAdapter paymentGatewayAdapter; - private final PaymentFailureClassifier paymentFailureClassifier; - private final PaymentRecoveryService paymentRecoveryService; - private final OrderCancellationService orderCancellationService; - private final OrderStatusUpdater orderStatusUpdater; - private final PaymentFailureHandler paymentFailureHandler; + private final UserService userService; // String userId를 Long id로 변환하는 데만 사용 + private final ProductService productService; // 상품 조회용으로만 사용 (재고 검증은 이벤트 핸들러에서) + private final OrderService orderService; + private final PaymentService paymentService; // Payment 조회용으로만 사용 + private final PaymentEventPublisher paymentEventPublisher; // PaymentEvent 발행용 /** * 주문을 생성한다. *

    * 1. 사용자 조회 및 존재 여부 검증
    - * 2. 상품 재고 검증 및 차감
    - * 3. 사용자 포인트 검증 및 차감
    - * 4. 주문 저장
    - * 5. PG 결제 요청 (비동기) + * 2. 상품 조회 (재고 검증은 이벤트 핸들러에서 처리)
    + * 3. 쿠폰 할인 적용
    + * 4. 주문 저장 및 OrderEvent.OrderCreated 이벤트 발행
    + * 5. 결제 요청 시 PaymentEvent.PaymentRequested 이벤트 발행
    *

    *

    - * 동시성 제어 전략: + * 결제 방식: *

      - *
    • PESSIMISTIC_WRITE 사용 근거: Lost Update 방지 및 데이터 일관성 보장
    • - *
    • 포인트 차감: 동시 주문 시 포인트 중복 차감 방지 (금전적 손실 방지)
    • - *
    • 재고 차감: 동시 주문 시 재고 음수 방지 및 정확한 차감 보장 (재고 oversell 방지)
    • - *
    • Lock 범위 최소화: PK/UNIQUE 인덱스 기반 조회로 Lock 범위 최소화
    • + *
    • 포인트+쿠폰 전액 결제: paidAmount == 0이면 PG 요청 없이 바로 완료
    • + *
    • 혼합 결제: 포인트 일부 사용 + PG 결제 나머지 금액
    • + *
    • 카드만 결제: 포인트 사용 없이 카드로 전체 금액 결제
    • *
    *

    *

    - * DBA 설득 근거 (비관적 락 사용): + * EDA 원칙: *

      - *
    • 제한적 사용: 전역이 아닌 금전적 손실 위험이 있는 특정 도메인에만 사용
    • - *
    • 트랜잭션 최소화: 트랜잭션 내부에 외부 I/O 없음, lock holding time 매우 짧음 (몇 ms)
    • - *
    • Lock 범위 최소화: PK/UNIQUE 인덱스 기반 조회로 해당 행만 락 (Record Lock)
    • - *
    • 애플리케이션 레벨 한계: 애플리케이션 레벨로는 race condition을 완전히 방지할 수 없어서 DB 차원의 strong consistency 필요
    • - *
    • 낙관적 락 기본 전략: 쿠폰 사용은 낙관적 락 사용 (Hot Spot 대응)
    • + *
    • 이벤트 기반: 재고 차감은 OrderEvent.OrderCreated를 구독하는 ProductEventHandler에서 처리
    • + *
    • 이벤트 기반: 포인트 차감은 OrderEvent.OrderCreated를 구독하는 PointEventHandler에서 처리
    • + *
    • 이벤트 기반: Payment 생성 및 PG 결제는 PaymentEvent.PaymentRequested를 구독하는 PaymentEventHandler에서 처리
    • + *
    • 느슨한 결합: Product, User, Payment 애그리거트를 직접 수정하지 않고 이벤트만 발행
    • *
    *

    - *

    - * Lock 생명주기: - *

      - *
    1. SELECT ... FOR UPDATE 실행 시 락 획득
    2. - *
    3. 트랜잭션 내에서 락 유지 (외부 I/O 없음, 매우 짧은 시간)
    4. - *
    5. 트랜잭션 커밋/롤백 시 락 자동 해제
    6. - *
    - *

    * * @param userId 사용자 식별자 (로그인 ID) * @param commands 주문 상품 정보 - * @param cardType 카드 타입 (SAMSUNG, KB, HYUNDAI) - * @param cardNo 카드 번호 (xxxx-xxxx-xxxx-xxxx 형식) + * @param usedPoint 포인트 사용량 (선택, 기본값: 0) + * @param cardType 카드 타입 (paidAmount > 0일 때만 필수) + * @param cardNo 카드 번호 (paidAmount > 0일 때만 필수) * @return 생성된 주문 정보 */ @Transactional - public OrderInfo createOrder(String userId, List commands, String cardType, String cardNo) { + public OrderInfo createOrder(String userId, List commands, Long usedPoint, String cardType, String cardNo) { if (userId == null || userId.isBlank()) { throw new CoreException(ErrorType.BAD_REQUEST, "사용자 ID는 필수입니다."); } @@ -116,14 +100,11 @@ public OrderInfo createOrder(String userId, List commands, Str throw new CoreException(ErrorType.BAD_REQUEST, "주문 아이템은 1개 이상이어야 합니다."); } - // 비관적 락을 사용하여 사용자 조회 (포인트 차감 시 동시성 제어) - // - userId는 UNIQUE 인덱스가 있어 Lock 범위 최소화 (Record Lock만 적용) - // - Lost Update 방지: 동시 주문 시 포인트 중복 차감 방지 (금전적 손실 방지) - // - 트랜잭션 내부에 외부 I/O 없음, lock holding time 매우 짧음 - User user = loadUserForUpdate(userId); + // ✅ EDA 원칙: UserService는 String userId를 Long id로 변환하는 데만 사용 + // 포인트 검증은 PointEventHandler에서 처리 + User user = userService.getUser(userId); - // ✅ Deadlock 방지: 상품 ID를 정렬하여 일관된 락 획득 순서 보장 - // 여러 상품을 주문할 때, 항상 동일한 순서로 락을 획득하여 deadlock 방지 + // ✅ EDA 원칙: ProductService는 상품 조회만 (재고 검증은 ProductEventHandler에서 처리) List sortedProductIds = commands.stream() .map(OrderItemCommand::productId) .distinct() @@ -135,28 +116,26 @@ public OrderInfo createOrder(String userId, List commands, Str throw new CoreException(ErrorType.BAD_REQUEST, "상품이 중복되었습니다."); } - // 정렬된 순서대로 상품 락 획득 (Deadlock 방지) + // 상품 조회 (재고 검증은 이벤트 핸들러에서 처리) Map productMap = new java.util.HashMap<>(); - for (Long productId : sortedProductIds) { - // 비관적 락을 사용하여 상품 조회 (재고 차감 시 동시성 제어) - // - id는 PK 인덱스가 있어 Lock 범위 최소화 (Record Lock만 적용) - // - Lost Update 방지: 동시 주문 시 재고 음수 방지 및 정확한 차감 보장 (재고 oversell 방지) - // - 트랜잭션 내부에 외부 I/O 없음, lock holding time 매우 짧음 - // - ✅ 정렬된 순서로 락 획득하여 deadlock 방지 - Product product = productRepository.findByIdForUpdate(productId) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, - String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", productId))); + Product product = productService.getProduct(productId); productMap.put(productId, product); } - // OrderItem 생성 - List products = new ArrayList<>(); + // OrderItem 생성 및 재고 사전 검증 List orderItems = new ArrayList<>(); for (OrderItemCommand command : commands) { Product product = productMap.get(command.productId()); - products.add(product); - + + // ✅ 재고 사전 검증 (읽기 전용 조회이므로 EDA 원칙 위반 아님) + // 재고 차감은 여전히 ProductEventHandler에서 처리 + int currentStock = product.getStock(); + if (currentStock < command.quantity()) { + throw new CoreException(ErrorType.BAD_REQUEST, + String.format("재고가 부족합니다. (현재 재고: %d, 요청 수량: %d)", currentStock, command.quantity())); + } + orderItems.add(OrderItem.of( product.getId(), product.getName(), @@ -165,57 +144,67 @@ public OrderInfo createOrder(String userId, List commands, Str )); } - // 쿠폰 처리 (있는 경우) + // 쿠폰 코드 추출 String couponCode = extractCouponCode(commands); - Integer discountAmount = 0; - if (couponCode != null && !couponCode.isBlank()) { - discountAmount = applyCoupon(user.getId(), couponCode, calculateSubtotal(orderItems)); - } + Integer subtotal = calculateSubtotal(orderItems); + + // 포인트 사용량 + Long usedPointAmount = Objects.requireNonNullElse(usedPoint, 0L); + + // ✅ CreateOrderCommand 생성 + CreateOrderCommand createOrderCommand = new CreateOrderCommand( + user.getId(), + orderItems, + couponCode, + subtotal, + usedPointAmount + ); - Order order = Order.of(user.getId(), orderItems, couponCode, discountAmount); - // 주문은 PENDING 상태로 생성됨 (Order 생성자에서 기본값으로 설정) - // 결제 성공 후에만 COMPLETED로 변경됨 - - decreaseStocksForOrderItems(order.getItems(), products); - deductUserPoint(user, order.getTotalAmount()); - // 주문은 PENDING 상태로 유지 (결제 요청 중 상태) - // 결제 성공 시 콜백이나 상태 확인 API를 통해 COMPLETED로 변경됨 - - products.forEach(productRepository::save); - userRepository.save(user); - - Order savedOrder = orderRepository.save(order); - // 주문은 PENDING 상태로 저장됨 - - // PG 결제 요청을 트랜잭션 커밋 후에 실행하여 DB 커넥션 풀 고갈 방지 - // 트랜잭션 내에서 외부 HTTP 호출을 하면 PG 지연/타임아웃 시 DB 커넥션이 오래 유지되어 커넥션 풀 고갈 위험 - Long orderId = savedOrder.getId(); - Integer totalAmount = savedOrder.getTotalAmount(); - TransactionSynchronizationManager.registerSynchronization( - new TransactionSynchronization() { - @Override - public void afterCommit() { - // 트랜잭션 커밋 후 PG 호출 (DB 커넥션 해제 후 실행) - try { - String transactionKey = requestPaymentToGateway(userId, orderId, cardType, cardNo, totalAmount); - if (transactionKey != null) { - // 결제 성공: 별도 트랜잭션에서 주문 상태를 COMPLETED로 변경 - updateOrderStatusToCompleted(orderId, transactionKey); - log.info("PG 결제 요청 완료. (orderId: {}, transactionKey: {})", orderId, transactionKey); - } else { - // PG 요청 실패: 외부 시스템 장애로 간주 - // 주문은 PENDING 상태로 유지되어 나중에 상태 확인 API나 콜백으로 복구 가능 - log.info("PG 결제 요청 실패. 주문은 PENDING 상태로 유지됩니다. (orderId: {})", orderId); - } - } catch (Exception e) { - // PG 요청 중 예외 발생 시에도 주문은 이미 저장되어 있으므로 유지 - // 외부 시스템 장애는 내부 시스템에 영향을 주지 않도록 함 - log.error("PG 결제 요청 중 예외 발생. 주문은 PENDING 상태로 유지됩니다. (orderId: {})", - orderId, e); - } - } + // ✅ OrderService.create() 호출 → OrderEvent.OrderCreated 이벤트 발행 + // ✅ ProductEventHandler가 OrderEvent.OrderCreated를 구독하여 재고 차감 처리 + // ✅ CouponEventHandler가 OrderEvent.OrderCreated를 구독하여 쿠폰 적용 처리 + // ✅ PointEventHandler가 OrderEvent.OrderCreated를 구독하여 포인트 차감 처리 + Order savedOrder = orderService.create(createOrderCommand); + + // PG 결제 금액 계산 + // 주의: 쿠폰 할인은 비동기로 적용되므로, PaymentEvent.PaymentRequested 발행 시점에는 할인 전 금액(subtotal)을 사용 + // 쿠폰 할인이 적용된 후에는 OrderEventHandler가 주문의 totalAmount를 업데이트함 + Long totalAmount = subtotal.longValue(); // 쿠폰 할인 전 금액 사용 + Long paidAmount = totalAmount - usedPointAmount; + + // ✅ 결제 요청 시 PaymentEvent.PaymentRequested 이벤트 발행 + // ✅ PaymentEventHandler가 PaymentEvent.PaymentRequested를 구독하여 Payment 생성 및 PG 결제 요청 처리 + if (paidAmount.equals(0L)) { + // 포인트+쿠폰으로 전액 결제 완료된 경우 + // PaymentEventHandler가 Payment를 생성하고 바로 완료 처리 + paymentEventPublisher.publish(PaymentEvent.PaymentRequested.of( + savedOrder.getId(), + userId, + user.getId(), + totalAmount, + usedPointAmount, + null, + null + )); + log.debug("포인트+쿠폰으로 전액 결제 요청. (orderId: {})", savedOrder.getId()); + } else { + // PG 결제가 필요한 경우 + if (cardType == null || cardType.isBlank() || cardNo == null || cardNo.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, + "포인트와 쿠폰만으로 결제할 수 없습니다. 카드 정보를 입력해주세요."); } - ); + + paymentEventPublisher.publish(PaymentEvent.PaymentRequested.of( + savedOrder.getId(), + userId, + user.getId(), + totalAmount, + usedPointAmount, + cardType, + cardNo + )); + log.debug("PG 결제 요청. (orderId: {})", savedOrder.getId()); + } return OrderInfo.from(savedOrder); } @@ -223,28 +212,35 @@ public void afterCommit() { /** * 주문을 취소하고 포인트를 환불하며 재고를 원복한다. *

    - * 동시성 제어: + * EDA 원칙: *

      - *
    • 비관적 락 사용: 재고 원복 시 동시성 제어를 위해 findByIdForUpdate 사용
    • - *
    • Deadlock 방지: 상품 ID를 정렬하여 일관된 락 획득 순서 보장
    • + *
    • 이벤트 기반: OrderService.cancelOrder()가 OrderEvent.OrderCanceled 이벤트를 발행
    • + *
    • 이벤트 기반: 재고 원복은 OrderEvent.OrderCanceled를 구독하는 ProductEventHandler에서 처리
    • + *
    • 이벤트 기반: 포인트 환불은 OrderEvent.OrderCanceled를 구독하는 PointEventHandler에서 처리
    • + *
    • 느슨한 결합: Product, User 애그리거트를 직접 수정하지 않고 이벤트만 발행
    • *
    *

    * * @param order 주문 엔티티 * @param user 사용자 엔티티 */ - /** - * 주문을 취소하고 포인트를 환불하며 재고를 원복한다. - *

    - * OrderCancellationService를 사용하여 처리합니다. - *

    - * - * @param order 주문 엔티티 - * @param user 사용자 엔티티 - */ @Transactional public void cancelOrder(Order order, User user) { - orderCancellationService.cancel(order, user); + if (order == null || user == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "취소할 주문과 사용자 정보는 필수입니다."); + } + + // 실제로 사용된 포인트만 환불 (Payment에서 확인) + Long refundPointAmount = paymentService.getPaymentByOrderId(order.getId()) + .map(Payment::getUsedPoint) + .orElse(0L); + + // ✅ OrderService.cancelOrder() 호출 → OrderEvent.OrderCanceled 이벤트 발행 + // ✅ ProductEventHandler가 OrderEvent.OrderCanceled를 구독하여 재고 원복 처리 + // ✅ PointEventHandler가 OrderEvent.OrderCanceled를 구독하여 포인트 환불 처리 + orderService.cancelOrder(order.getId(), "사용자 요청", refundPointAmount); + + log.info("주문 취소 처리 완료. (orderId: {}, refundPointAmount: {})", order.getId(), refundPointAmount); } /** @@ -255,8 +251,8 @@ public void cancelOrder(Order order, User user) { */ @Transactional(readOnly = true) public List getOrders(String userId) { - User user = loadUser(userId); - List orders = orderRepository.findAllByUserId(user.getId()); + User user = userService.getUser(userId); + List orders = orderService.getOrdersByUserId(user.getId()); return orders.stream() .map(OrderInfo::from) .toList(); @@ -271,9 +267,8 @@ public List getOrders(String userId) { */ @Transactional(readOnly = true) public OrderInfo getOrder(String userId, Long orderId) { - User user = loadUser(userId); - Order order = orderRepository.findById(orderId) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.")); + User user = userService.getUser(userId); + Order order = orderService.getById(orderId); if (!order.getUserId().equals(user.getId())) { throw new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다."); @@ -282,57 +277,8 @@ public OrderInfo getOrder(String userId, Long orderId) { return OrderInfo.from(order); } - private void decreaseStocksForOrderItems(List items, List products) { - Map productMap = products.stream() - .collect(Collectors.toMap(Product::getId, product -> product)); - - for (OrderItem item : items) { - Product product = productMap.get(item.getProductId()); - if (product == null) { - throw new CoreException(ErrorType.NOT_FOUND, - String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", item.getProductId())); - } - product.decreaseStock(item.getQuantity()); - } - } - private void deductUserPoint(User user, Integer totalAmount) { - if (Objects.requireNonNullElse(totalAmount, 0) <= 0) { - return; - } - user.deductPoint(Point.of(totalAmount.longValue())); - } - - private User loadUser(String userId) { - User user = userRepository.findByUserId(userId); - if (user == null) { - throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다."); - } - return user; - } - - /** - * 비관적 락을 사용하여 사용자를 조회합니다. - *

    - * 포인트 차감 등 동시성 제어가 필요한 경우 사용합니다. - *

    - *

    - * 전제 조건: userId는 상위 계층에서 이미 null/blank 검증이 완료되어야 합니다. - *

    - * - * @param userId 사용자 ID (null이 아니고 비어있지 않아야 함) - * @return 조회된 사용자 - * @throws CoreException 사용자를 찾을 수 없는 경우 - */ - private User loadUserForUpdate(String userId) { - User user = userRepository.findByUserIdForUpdate(userId); - if (user == null) { - throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다."); - } - return user; - } - /** * 주문 명령에서 쿠폰 코드를 추출합니다. * @@ -347,60 +293,6 @@ private String extractCouponCode(List commands) { .orElse(null); } - /** - * 쿠폰을 적용하여 할인 금액을 계산하고 쿠폰을 사용 처리합니다. - *

    - * 동시성 제어 전략: - *

      - *
    • OPTIMISTIC_LOCK 사용 근거: 쿠폰 중복 사용 방지, Hot Spot 대응
    • - *
    • @Version 필드: UserCoupon 엔티티의 version 필드를 통해 자동으로 낙관적 락 적용
    • - *
    • 동시 사용 시: 한 명만 성공하고 나머지는 OptimisticLockException 발생
    • - *
    • 사용 목적: 동일 쿠폰으로 여러 기기에서 동시 주문해도 한 번만 사용되도록 보장
    • - *
    - *

    - * - * @param userId 사용자 ID - * @param couponCode 쿠폰 코드 - * @param subtotal 주문 소계 금액 - * @return 할인 금액 - * @throws CoreException 쿠폰을 찾을 수 없거나 사용 불가능한 경우, 동시 사용으로 인한 충돌 시 - */ - private Integer applyCoupon(Long userId, String couponCode, Integer subtotal) { - // 쿠폰 존재 여부 확인 - Coupon coupon = couponRepository.findByCode(couponCode) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, - String.format("쿠폰을 찾을 수 없습니다. (쿠폰 코드: %s)", couponCode))); - - // 낙관적 락을 사용하여 사용자 쿠폰 조회 (동시성 제어) - // @Version 필드가 있어 자동으로 낙관적 락이 적용됨 - UserCoupon userCoupon = userCouponRepository.findByUserIdAndCouponCodeForUpdate(userId, couponCode) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, - String.format("사용자가 소유한 쿠폰을 찾을 수 없습니다. (쿠폰 코드: %s)", couponCode))); - - // 쿠폰 사용 가능 여부 확인 - if (!userCoupon.isAvailable()) { - throw new CoreException(ErrorType.BAD_REQUEST, - String.format("이미 사용된 쿠폰입니다. (쿠폰 코드: %s)", couponCode)); - } - - // 쿠폰 사용 처리 - userCoupon.use(); - - // 할인 금액 계산 (전략 패턴 사용) - Integer discountAmount = coupon.calculateDiscountAmount(subtotal, couponDiscountStrategyFactory); - - try { - // 사용자 쿠폰 저장 (version 체크 자동 수행) - // 다른 트랜잭션이 먼저 수정했다면 OptimisticLockException 발생 - userCouponRepository.save(userCoupon); - } catch (ObjectOptimisticLockingFailureException e) { - // 낙관적 락 충돌: 다른 트랜잭션이 먼저 쿠폰을 사용함 - throw new CoreException(ErrorType.CONFLICT, - String.format("쿠폰이 이미 사용되었습니다. (쿠폰 코드: %s)", couponCode)); - } - - return discountAmount; - } /** * 주문 아이템 목록으로부터 소계 금액을 계산합니다. @@ -414,93 +306,112 @@ private Integer calculateSubtotal(List orderItems) { .sum(); } + /** - * 주문 상태를 COMPLETED로 업데이트합니다. - *

    - * 트랜잭션 커밋 후 별도 트랜잭션에서 실행되어 주문 상태를 업데이트합니다. - *

    + * PaymentGatewayDto.TransactionStatus를 PaymentStatus 도메인 모델로 변환합니다. * - * @param orderId 주문 ID - * @param transactionKey 트랜잭션 키 + * @param transactionStatus 인프라 계층의 TransactionStatus + * @return 도메인 모델의 PaymentStatus */ - @Transactional(propagation = org.springframework.transaction.annotation.Propagation.REQUIRES_NEW) - public void updateOrderStatusToCompleted(Long orderId, String transactionKey) { - Order order = orderRepository.findById(orderId).orElse(null); - if (order == null) { - log.warn("주문 상태 업데이트 시 주문을 찾을 수 없습니다. (orderId: {})", orderId); - return; - } - - if (order.getStatus() == OrderStatus.COMPLETED) { - log.info("이미 완료된 주문입니다. 상태 업데이트를 건너뜁니다. (orderId: {})", orderId); - return; - } - - order.complete(); - orderRepository.save(order); - log.info("주문 상태를 COMPLETED로 업데이트했습니다. (orderId: {}, transactionKey: {})", orderId, transactionKey); + private PaymentStatus convertToPaymentStatus( + PaymentGatewayDto.TransactionStatus transactionStatus + ) { + return switch (transactionStatus) { + case SUCCESS -> PaymentStatus.SUCCESS; + case FAILED -> PaymentStatus.FAILED; + case PENDING -> PaymentStatus.PENDING; + }; } /** - * PG 결제 게이트웨이에 결제 요청을 전송합니다. + * PaymentStatus 도메인 모델을 PaymentGatewayDto.TransactionStatus로 변환합니다. + * + * @param paymentStatus 도메인 모델의 PaymentStatus + * @return 인프라 계층의 TransactionStatus + */ + private PaymentGatewayDto.TransactionStatus convertToInfraStatus(PaymentStatus paymentStatus) { + return switch (paymentStatus) { + case SUCCESS -> PaymentGatewayDto.TransactionStatus.SUCCESS; + case FAILED -> PaymentGatewayDto.TransactionStatus.FAILED; + case PENDING -> PaymentGatewayDto.TransactionStatus.PENDING; + }; + } + + /** + * 결제 상태에 따라 주문 상태를 업데이트합니다. *

    - * 트랜잭션 커밋 후 실행되어 DB 커넥션 풀 고갈을 방지합니다. - * 실패 시에도 주문은 이미 저장되어 있으므로, 로그만 기록합니다. + * 별도 트랜잭션으로 실행하여 외부 시스템 호출과 독립적으로 처리합니다. *

    * - * @param userId 사용자 ID * @param orderId 주문 ID - * @param cardType 카드 타입 - * @param cardNo 카드 번호 - * @param amount 결제 금액 - * @return transactionKey (성공 시), null (실패 시) + * @param paymentStatus 결제 상태 (도메인 모델) + * @param transactionKey 트랜잭션 키 + * @param reason 실패 사유 (실패 시) + * @return 업데이트 성공 여부 (true: 성공, false: 실패) */ - private String requestPaymentToGateway(String userId, Long orderId, String cardType, String cardNo, Integer amount) { + @Transactional(propagation = Propagation.REQUIRES_NEW) + public boolean updateOrderStatusByPaymentResult( + Long orderId, + PaymentStatus paymentStatus, + String transactionKey, + String reason + ) { try { - // 결제 요청 생성 - PaymentRequest request = paymentRequestBuilder.build(userId, orderId, cardType, cardNo, amount); + Order order = orderService.getOrder(orderId).orElse(null); - // PG 결제 요청 전송 - var result = paymentGatewayAdapter.requestPayment(request); + if (order == null) { + log.warn("주문 상태 업데이트 시 주문을 찾을 수 없습니다. (orderId: {})", orderId); + return false; + } - // 결과 처리 - return result.handle( - success -> { - log.info("PG 결제 요청 성공. (orderId: {}, transactionKey: {})", - orderId, success.transactionKey()); - return success.transactionKey(); - }, - failure -> { - PaymentFailureType failureType = paymentFailureClassifier.classify(failure.errorCode()); - - if (failureType == PaymentFailureType.BUSINESS_FAILURE) { - // 비즈니스 실패: 주문 취소 (별도 트랜잭션으로 처리) - paymentFailureHandler.handle(userId, orderId, failure.errorCode(), failure.message()); - } else if (failure.isTimeout()) { - // 타임아웃: 상태 확인 후 복구 - log.info("타임아웃 발생. PG 결제 상태 확인 API를 호출하여 실제 결제 상태를 확인합니다. (orderId: {})", orderId); - paymentRecoveryService.recoverAfterTimeout(userId, orderId); - } else { - // 외부 시스템 장애: 주문은 PENDING 상태로 유지 - log.info("외부 시스템 장애로 인한 실패. 주문은 PENDING 상태로 유지됩니다. (orderId: {}, errorCode: {})", - orderId, failure.errorCode()); - } - return null; - } - ); - } catch (CoreException e) { - // 잘못된 카드 타입 등 검증 오류 - log.warn("결제 요청 생성 실패. (orderId: {}, error: {})", orderId, e.getMessage()); - return null; + // 이미 완료되거나 취소된 주문인 경우 처리하지 않음 (정상적인 경우이므로 true 반환) + if (order.isCompleted()) { + log.debug("이미 완료된 주문입니다. 상태 업데이트를 건너뜁니다. (orderId: {})", orderId); + return true; + } + + if (order.isCanceled()) { + log.debug("이미 취소된 주문입니다. 상태 업데이트를 건너뜁니다. (orderId: {})", orderId); + return true; + } + + if (paymentStatus == PaymentStatus.SUCCESS) { + // 결제 성공: 주문 완료 + orderService.updateStatusByPaymentResult(order, paymentStatus, null, null); + log.info("결제 상태 확인 결과, 주문 상태를 COMPLETED로 업데이트했습니다. (orderId: {}, transactionKey: {})", + orderId, transactionKey); + return true; + } else if (paymentStatus == PaymentStatus.FAILED) { + // 결제 실패: 주문 취소 및 리소스 원복 + // 실제로 사용된 포인트만 환불 (Payment에서 확인) + Long refundPointAmount = paymentService.getPaymentByOrderId(order.getId()) + .map(Payment::getUsedPoint) + .orElse(0L); + + // 취소 사유 설정 (reason이 없으면 기본값 사용) + String cancelReason = (reason != null && !reason.isBlank()) + ? reason + : "결제 실패"; + + // 주문 취소 처리 (이벤트 발행 포함) + orderService.updateStatusByPaymentResult(order, paymentStatus, cancelReason, refundPointAmount); + log.info("결제 상태 확인 결과, 주문 상태를 CANCELED로 업데이트했습니다. (orderId: {}, transactionKey: {}, reason: {}, refundPointAmount: {})", + orderId, transactionKey, cancelReason, refundPointAmount); + return true; + } else { + // PENDING 상태: 아직 처리 중 (정상적인 경우이므로 true 반환) + log.debug("결제 상태 확인 결과, 아직 처리 중입니다. 주문은 PENDING 상태로 유지됩니다. (orderId: {}, transactionKey: {})", + orderId, transactionKey); + return true; + } } catch (Exception e) { - // 기타 예외 처리 - log.error("PG 결제 요청 중 예상치 못한 오류 발생. (orderId: {})", orderId, e); - log.info("예상치 못한 오류 발생. 주문은 PENDING 상태로 유지됩니다. (orderId: {})", orderId); - return null; + log.error("주문 상태 업데이트 중 오류 발생. (orderId: {})", orderId, e); + return false; } } + /** * PG 결제 콜백을 처리합니다. *

    @@ -531,24 +442,24 @@ private String requestPaymentToGateway(String userId, Long orderId, String cardT public void handlePaymentCallback(Long orderId, PaymentGatewayDto.CallbackRequest callbackRequest) { try { // 주문 조회 - Order order = orderRepository.findById(orderId) - .orElse(null); - - if (order == null) { + Order order; + try { + order = orderService.getById(orderId); + } catch (CoreException e) { log.warn("콜백 처리 시 주문을 찾을 수 없습니다. (orderId: {}, transactionKey: {})", orderId, callbackRequest.transactionKey()); return; } // 이미 완료되거나 취소된 주문인 경우 처리하지 않음 - if (order.getStatus() == OrderStatus.COMPLETED) { - log.info("이미 완료된 주문입니다. 콜백 처리를 건너뜁니다. (orderId: {}, transactionKey: {})", + if (order.isCompleted()) { + log.debug("이미 완료된 주문입니다. 콜백 처리를 건너뜁니다. (orderId: {}, transactionKey: {})", orderId, callbackRequest.transactionKey()); return; } - if (order.getStatus() == OrderStatus.CANCELED) { - log.info("이미 취소된 주문입니다. 콜백 처리를 건너뜁니다. (orderId: {}, transactionKey: {})", + if (order.isCanceled()) { + log.debug("이미 취소된 주문입니다. 콜백 처리를 건너뜁니다. (orderId: {}, transactionKey: {})", orderId, callbackRequest.transactionKey()); return; } @@ -558,10 +469,19 @@ public void handlePaymentCallback(Long orderId, PaymentGatewayDto.CallbackReques PaymentGatewayDto.TransactionStatus verifiedStatus = verifyCallbackWithPgInquiry( order.getUserId(), orderId, callbackRequest); - // OrderStatusUpdater를 사용하여 상태 업데이트 - boolean updated = orderStatusUpdater.updateByPaymentStatus( + // PaymentService를 통한 콜백 처리 (도메인 모델로 변환) + PaymentStatus paymentStatus = convertToPaymentStatus(verifiedStatus); + paymentService.handleCallback( + orderId, + callbackRequest.transactionKey(), + paymentStatus, + callbackRequest.reason() + ); + + // 주문 상태 업데이트 처리 + boolean updated = updateOrderStatusByPaymentResult( orderId, - verifiedStatus, + paymentStatus, callbackRequest.transactionKey(), callbackRequest.reason() ); @@ -606,8 +526,10 @@ private PaymentGatewayDto.TransactionStatus verifyCallbackWithPgInquiry( try { // User의 userId (String)를 가져오기 위해 User 조회 - User user = userRepository.findById(userId); - if (user == null) { + User user; + try { + user = userService.getUserById(userId); + } catch (CoreException e) { log.warn("콜백 검증 시 사용자를 찾을 수 없습니다. 콜백 정보를 사용합니다. (orderId: {}, userId: {})", orderId, userId); return callbackRequest.status(); // 사용자를 찾을 수 없으면 콜백 정보 사용 @@ -615,27 +537,11 @@ private PaymentGatewayDto.TransactionStatus verifyCallbackWithPgInquiry( String userIdString = user.getUserId(); - // PG에서 주문별 결제 정보 조회 (스케줄러 전용 클라이언트 사용 - Retry 적용) - // 주문 ID를 6자리 이상 문자열로 변환 (pg-simulator 검증 요구사항) - String orderIdString = paymentRequestBuilder.formatOrderId(orderId); - PaymentGatewayDto.ApiResponse response = - paymentGatewaySchedulerClient.getTransactionsByOrder(userIdString, orderIdString); - - if (response == null || response.meta() == null - || response.meta().result() != PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS - || response.data() == null || response.data().transactions() == null - || response.data().transactions().isEmpty()) { - // PG 조회 실패: 콜백 정보를 사용하되 경고 로그 기록 - log.warn("콜백 검증 시 PG 조회 API 호출 실패. 콜백 정보를 사용합니다. (orderId: {}, transactionKey: {})", - orderId, callbackRequest.transactionKey()); - return callbackRequest.status(); - } - - // 가장 최근 트랜잭션의 상태 확인 (PG 원장 기준) - PaymentGatewayDto.TransactionResponse latestTransaction = - response.data().transactions().get(response.data().transactions().size() - 1); + // PaymentService를 통한 결제 상태 조회 (PG 원장 기준) + PaymentStatus paymentStatus = paymentService.getPaymentStatus(userIdString, orderId); - PaymentGatewayDto.TransactionStatus pgStatus = latestTransaction.status(); + // 도메인 모델을 인프라 DTO로 변환 (검증 로직에서 사용) + PaymentGatewayDto.TransactionStatus pgStatus = convertToInfraStatus(paymentStatus); PaymentGatewayDto.TransactionStatus callbackStatus = callbackRequest.status(); // 콜백 정보와 PG 조회 결과 비교 @@ -680,14 +586,14 @@ private PaymentGatewayDto.TransactionStatus verifyCallbackWithPgInquiry( @Transactional public void recoverOrderStatusByPaymentCheck(String userId, Long orderId) { try { - // PG에서 결제 상태 조회 - // 주문 ID를 6자리 이상 문자열로 변환 (pg-simulator 검증 요구사항) - String orderIdString = paymentRequestBuilder.formatOrderId(orderId); - PaymentGatewayDto.TransactionStatus status = - paymentGatewayAdapter.getPaymentStatus(userId, orderIdString); + // PaymentService를 통한 타임아웃 복구 + paymentService.recoverAfterTimeout(userId, orderId); + + // 결제 상태 조회 + PaymentStatus paymentStatus = paymentService.getPaymentStatus(userId, orderId); - // OrderStatusUpdater를 사용하여 상태 업데이트 - boolean updated = orderStatusUpdater.updateByPaymentStatus(orderId, status, null, null); + // 주문 상태 업데이트 처리 + boolean updated = updateOrderStatusByPaymentResult(orderId, paymentStatus, null, null); if (!updated) { log.warn("상태 복구 실패. 주문 상태 업데이트에 실패했습니다. (orderId: {})", orderId); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/scheduler/LikeCountSyncScheduler.java b/apps/commerce-api/src/main/java/com/loopers/application/scheduler/LikeCountSyncScheduler.java deleted file mode 100644 index 4621b4fef..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/scheduler/LikeCountSyncScheduler.java +++ /dev/null @@ -1,98 +0,0 @@ -package com.loopers.application.scheduler; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.batch.core.Job; -import org.springframework.batch.core.JobExecution; -import org.springframework.batch.core.JobParameters; -import org.springframework.batch.core.JobParametersBuilder; -import org.springframework.batch.core.launch.JobLauncher; -import org.springframework.batch.core.repository.JobRestartException; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; - -/** - * 좋아요 수 동기화 스케줄러. - *

    - * 주기적으로 Spring Batch Job을 실행하여 Like 테이블의 COUNT(*) 결과를 Product.likeCount 필드에 동기화합니다. - *

    - *

    - * 동작 원리: - *

      - *
    1. 주기적으로 실행 (기본: 5초마다)
    2. - *
    3. Spring Batch Job 실행
    4. - *
    5. Reader: 모든 상품 ID 조회
    6. - *
    7. Processor: 각 상품의 좋아요 수 집계 (Like 테이블 COUNT(*))
    8. - *
    9. Writer: Product 테이블의 likeCount 필드 업데이트
    10. - *
    - *

    - *

    - * 설계 근거: - *

      - *
    • Spring Batch 사용: 대량 처리, 청크 단위 처리, 재시작 가능
    • - *
    • Eventually Consistent: 좋아요 수는 약간의 지연 허용 가능
    • - *
    • 성능 최적화: 조회 시 COUNT(*) 대신 컬럼만 읽으면 됨
    • - *
    • 쓰기 경합 최소화: Like 테이블은 Insert-only로 쓰기 경합 없음
    • - *
    • 확장성: Redis 없이도 대규모 트래픽 처리 가능
    • - *
    - *

    - * - * @author Loopers - * @version 1.0 - */ -@Slf4j -@RequiredArgsConstructor -@Component -public class LikeCountSyncScheduler { - - private final JobLauncher jobLauncher; - private final Job likeCountSyncJob; - - /** - * 좋아요 수를 동기화합니다. - *

    - * 5초마다 실행되어 Spring Batch Job을 통해 Like 테이블의 집계 결과를 Product.likeCount에 반영합니다. - *

    - *

    - * Spring Batch 장점: - *

      - *
    • 청크 단위 처리: 100개씩 묶어서 처리하여 성능 최적화
    • - *
    • 트랜잭션 관리: 청크 단위로 커밋하여 안정성 보장
    • - *
    • 재시작 가능: Job 실패 시 재시작 가능
    • - *
    • 모니터링: Spring Batch 메타데이터로 실행 이력 추적
    • - *
    - *

    - *

    - * 주기적 실행 전략: - *

      - *
    • 타임스탬프 기반 JobParameters: 매 실행마다 타임스탬프를 추가하여 새로운 JobInstance 생성
    • - *
    • 5초마다 실행: 스케줄러가 5초마다 Job을 실행하여 좋아요 수를 최신화
    • - *
    - *

    - */ - @Scheduled(fixedDelay = 5000) // 5초마다 실행 - public void syncLikeCounts() { - try { - log.debug("좋아요 수 동기화 배치 Job 시작"); - - // 타임스탬프를 JobParameters에 추가하여 매번 새로운 JobInstance 생성 - // Spring Batch는 동일한 JobParameters를 가진 JobInstance를 재실행하지 않으므로, - // 타임스탬프를 추가하여 매 실행마다 새로운 JobInstance를 생성합니다. - JobParameters jobParameters = new JobParametersBuilder() - .addString("jobName", "likeCountSync") - .addLong("timestamp", System.currentTimeMillis()) - .toJobParameters(); - - // Spring Batch Job 실행 - JobExecution jobExecution = jobLauncher.run(likeCountSyncJob, jobParameters); - - log.debug("좋아요 수 동기화 배치 Job 완료: status={}", jobExecution.getStatus()); - - } catch (JobRestartException e) { - log.error("좋아요 수 동기화 배치 Job 재시작 실패", e); - } catch (Exception e) { - log.error("좋아요 수 동기화 배치 Job 실행 중 오류 발생", e); - } - } -} - 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 deleted file mode 100644 index 293505b15..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/signup/SignUpFacade.java +++ /dev/null @@ -1,74 +0,0 @@ -package com.loopers.application.signup; - -import com.loopers.domain.user.Gender; -import com.loopers.domain.user.Point; -import com.loopers.domain.user.User; -import com.loopers.domain.user.UserService; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.transaction.Transactional; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.util.Locale; - -/** - * 회원가입 파사드. - *

    - * 회원가입 시 사용자 생성을 처리하는 애플리케이션 서비스입니다. - * 사용자 생성 시 포인트는 자동으로 0으로 초기화됩니다. - * 트랜잭션 경계를 관리하여 데이터 일관성을 보장합니다. - *

    - * - * @author Loopers - * @version 1.0 - */ -@RequiredArgsConstructor -@Component -public class SignUpFacade { - private final UserService userService; - - /** - * 회원가입을 처리합니다. - *

    - * 사용자를 생성하며, 포인트는 자동으로 0으로 초기화됩니다. - * 전체 과정이 하나의 트랜잭션으로 처리됩니다. - *

    - * - * @param userId 사용자 ID - * @param email 이메일 주소 - * @param birthDateStr 생년월일 (yyyy-MM-dd) - * @param genderStr 성별 문자열 (MALE 또는 FEMALE) - * @return 생성된 사용자 정보 - * @throws CoreException gender 값이 유효하지 않거나, 유효성 검증 실패 또는 중복 ID 존재 시 - */ - @Transactional - public SignUpInfo signUp(String userId, String email, String birthDateStr, String genderStr) { - Gender gender = parseGender(genderStr); - Point point = Point.of(0L); - User user = userService.create(userId, email, birthDateStr, gender, point); - return SignUpInfo.from(user); - } - - /** - * 성별 문자열을 Gender enum으로 변환합니다. - *

    - * 도메인 진입점에서 방어 로직을 제공하여 NPE를 방지합니다. - *

    - * - * @param genderStr 성별 문자열 - * @return Gender enum - * @throws CoreException gender 값이 null이거나 유효하지 않은 경우 - */ - private Gender parseGender(String genderStr) { - if (genderStr == null) { - throw new CoreException(ErrorType.BAD_REQUEST, "gender 값이 올바르지 않습니다."); - } - try { - String genderValue = genderStr.trim().toUpperCase(Locale.ROOT); - return Gender.valueOf(genderValue); - } catch (IllegalArgumentException e) { - throw new CoreException(ErrorType.BAD_REQUEST, "gender 값이 올바르지 않습니다."); - } - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/signup/SignUpInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/signup/SignUpInfo.java deleted file mode 100644 index c84caf7a3..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/signup/SignUpInfo.java +++ /dev/null @@ -1,38 +0,0 @@ -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/application/user/DeductPointCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/user/DeductPointCommand.java new file mode 100644 index 000000000..f18b91b03 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/DeductPointCommand.java @@ -0,0 +1,22 @@ +package com.loopers.application.user; + +/** + * 포인트 차감 명령. + *

    + * 포인트 차감을 위한 명령 객체입니다. + *

    + * + * @param userId 사용자 ID + * @param usedPointAmount 사용할 포인트 금액 + */ +public record DeductPointCommand( + Long userId, + Long usedPointAmount +) { + public DeductPointCommand { + if (userId == null) { + throw new IllegalArgumentException("userId는 필수입니다."); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/PointEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/user/PointEventHandler.java new file mode 100644 index 000000000..73af4d9d4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/PointEventHandler.java @@ -0,0 +1,147 @@ +package com.loopers.application.user; + +import com.loopers.domain.order.OrderEvent; +import com.loopers.domain.user.Point; +import com.loopers.domain.user.PointEvent; +import com.loopers.domain.user.PointEventPublisher; +import com.loopers.domain.user.User; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * 포인트 이벤트 핸들러. + *

    + * 주문 생성 이벤트를 받아 포인트 사용 처리를 수행하고, 주문 취소 이벤트를 받아 포인트 환불 처리를 수행하는 애플리케이션 로직을 처리합니다. + *

    + *

    + * DDD/EDA 관점: + *

      + *
    • 책임 분리: UserService는 사용자 도메인 비즈니스 로직, PointEventHandler는 이벤트 처리 로직
    • + *
    • 이벤트 핸들러: 이벤트를 받아서 처리하는 역할을 명확히 나타냄
    • + *
    • 느슨한 결합: PurchasingFacade는 UserService를 직접 참조하지 않고 이벤트로 처리
    • + *
    + *

    + * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PointEventHandler { + + private final UserService userService; + private final PointEventPublisher pointEventPublisher; + + /** + * 주문 생성 이벤트를 처리하여 포인트를 차감합니다. + *

    + * OrderEvent.OrderCreated를 구독하여 포인트 차감 Command를 실행합니다. + *

    + * + * @param event 주문 생성 이벤트 + */ + @Transactional + public void handleOrderCreated(OrderEvent.OrderCreated event) { + // 포인트 사용량이 없는 경우 처리하지 않음 + if (event.usedPointAmount() == null || event.usedPointAmount() == 0) { + log.debug("포인트 사용량이 없어 포인트 차감 처리를 건너뜁니다. (orderId: {})", event.orderId()); + return; + } + + try { + // 사용자 조회 (비관적 락 사용) + // OrderEvent.OrderCreated의 userId는 Long 타입 (User.id - PK) + User user = userService.getUserByIdForUpdate(event.userId()); + + // 포인트 잔액 검증 + Long userPointBalance = user.getPointValue(); + if (userPointBalance < event.usedPointAmount()) { + String failureReason = String.format("포인트가 부족합니다. (현재 잔액: %d, 사용 요청 금액: %d)", + userPointBalance, event.usedPointAmount()); + log.error("포인트가 부족합니다. (orderId: {}, userId: {}, 현재 잔액: {}, 사용 요청 금액: {})", + event.orderId(), event.userId(), userPointBalance, event.usedPointAmount()); + + // 포인트 사용 실패 이벤트 발행 + pointEventPublisher.publish(PointEvent.PointUsedFailed.of( + event.orderId(), + event.userId(), + event.usedPointAmount(), + failureReason + )); + + throw new CoreException(ErrorType.BAD_REQUEST, failureReason); + } + + // ✅ OrderEvent.OrderCreated를 구독하여 포인트 차감 Command 실행 + DeductPointCommand command = new DeductPointCommand(event.userId(), event.usedPointAmount()); + user.deductPoint(Point.of(command.usedPointAmount())); + userService.save(user); + + log.info("포인트 차감 처리 완료. (orderId: {}, userId: {}, usedPointAmount: {})", + event.orderId(), event.userId(), event.usedPointAmount()); + } catch (CoreException e) { + // CoreException은 이미 이벤트가 발행되었거나 처리되었으므로 그대로 던짐 + throw e; + } catch (Exception e) { + // 예상치 못한 오류 발생 시 실패 이벤트 발행 + String failureReason = e.getMessage() != null ? e.getMessage() : "포인트 차감 처리 중 오류 발생"; + log.error("포인트 차감 처리 중 오류 발생. (orderId: {}, userId: {}, usedPointAmount: {})", + event.orderId(), event.userId(), event.usedPointAmount(), e); + + pointEventPublisher.publish(PointEvent.PointUsedFailed.of( + event.orderId(), + event.userId(), + event.usedPointAmount(), + failureReason + )); + + throw e; + } + } + + /** + * 주문 취소 이벤트를 처리하여 포인트를 환불합니다. + *

    + * 환불할 포인트 금액이 0보다 큰 경우에만 포인트 환불 처리를 수행합니다. + *

    + *

    + * 동시성 제어: + *

      + *
    • 비관적 락 사용: 포인트 환불 시 동시성 제어를 위해 getUserForUpdate 사용
    • + *
    + *

    + * + * @param event 주문 취소 이벤트 + */ + @Transactional + public void handleOrderCanceled(OrderEvent.OrderCanceled event) { + // 환불할 포인트 금액이 없는 경우 처리하지 않음 + if (event.refundPointAmount() == null || event.refundPointAmount() == 0) { + log.debug("환불할 포인트 금액이 없어 포인트 환불 처리를 건너뜁니다. (orderId: {})", event.orderId()); + return; + } + + try { + // ✅ 동시성 제어: 포인트 환불 시 Lost Update 방지를 위해 비관적 락 사용 + // OrderEvent.OrderCanceled의 userId는 Long 타입 (User.id - PK) + User user = userService.getUserByIdForUpdate(event.userId()); + + // 포인트 환불 + user.receivePoint(Point.of(event.refundPointAmount())); + userService.save(user); + + log.info("주문 취소로 인한 포인트 환불 완료. (orderId: {}, userId: {}, refundPointAmount: {})", + event.orderId(), event.userId(), event.refundPointAmount()); + } catch (Exception e) { + log.error("포인트 환불 처리 중 오류 발생. (orderId: {}, userId: {}, refundPointAmount: {})", + event.orderId(), event.userId(), event.refundPointAmount(), e); + throw e; + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserService.java new file mode 100644 index 000000000..d7fb5c2f1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserService.java @@ -0,0 +1,191 @@ +package com.loopers.application.user; + +import com.loopers.domain.user.Gender; +import com.loopers.domain.user.Point; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * 사용자 애플리케이션 서비스. + *

    + * 사용자 생성, 조회, 포인트 관리 등의 애플리케이션 로직을 처리합니다. + * Repository에 의존하며 트랜잭션 관리 및 데이터 무결성 제약 조건을 처리합니다. + *

    + * + * @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, Point point) { + User user = User.of(userId, email, birthDateStr, gender, point); + 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 조회된 사용자 + * @throws CoreException 사용자를 찾을 수 없는 경우 + */ + @Transactional(readOnly = true) + public User getUser(String userId) { + User user = userRepository.findByUserId(userId); + if (user == null) { + throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다."); + } + return user; + } + + /** + * 사용자 ID로 사용자를 조회합니다. (비관적 락) + *

    + * 포인트 차감 등 동시성 제어가 필요한 경우 사용합니다. + *

    + * + * @param userId 조회할 사용자 ID + * @return 조회된 사용자 + * @throws CoreException 사용자를 찾을 수 없는 경우 + */ + @Transactional + public User getUserForUpdate(String userId) { + User user = userRepository.findByUserIdForUpdate(userId); + if (user == null) { + throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다."); + } + return user; + } + + /** + * 사용자 ID (PK)로 사용자를 조회합니다. + * + * @param id 사용자 ID (PK) + * @return 조회된 사용자 + * @throws CoreException 사용자를 찾을 수 없는 경우 + */ + @Transactional(readOnly = true) + public User getUserById(Long id) { + User user = userRepository.findById(id); + if (user == null) { + throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다."); + } + return user; + } + + /** + * 사용자 ID (PK)로 사용자를 조회합니다. (비관적 락) + *

    + * 포인트 차감 등 동시성 제어가 필요한 경우 사용합니다. + *

    + * + * @param id 사용자 ID (PK) + * @return 조회된 사용자 + * @throws CoreException 사용자를 찾을 수 없는 경우 + */ + @Transactional + public User getUserByIdForUpdate(Long id) { + User user = userRepository.findByIdForUpdate(id); + if (user == null) { + throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다."); + } + return user; + } + + /** + * 사용자를 저장합니다. + * + * @param user 저장할 사용자 + * @return 저장된 사용자 + */ + @Transactional + public User save(User user) { + return userRepository.save(user); + } + + /** + * 사용자의 포인트를 조회합니다. + * + * @param userId 조회할 사용자 ID + * @return 포인트 정보 + * @throws CoreException 사용자를 찾을 수 없는 경우 + */ + @Transactional(readOnly = true) + public PointsInfo getPoints(String userId) { + User user = getUser(userId); + return PointsInfo.from(user); + } + + /** + * 사용자의 포인트를 충전합니다. + *

    + * 트랜잭션 내에서 실행되어 데이터 일관성을 보장합니다. + * 비관적 락(PESSIMISTIC_WRITE)을 사용하여 동시 충전 요청 시 Lost Update를 방지합니다. + *

    + *

    + * 동시성 제어: + *

      + *
    • 비관적 락 사용: SELECT ... FOR UPDATE로 해당 사용자 행에 배타적 락 설정
    • + *
    • Lost Update 방지: 동시 충전 요청이 들어와도 순차적으로 처리되어 모든 충전이 반영됨
    • + *
    • Lock 범위 최소화: UNIQUE(userId) 인덱스 기반 조회로 해당 행만 락
    • + *
    + *

    + * + * @param userId 충전할 사용자 ID + * @param amount 충전할 포인트 금액 + * @return 충전된 포인트 정보 + * @throws CoreException 사용자를 찾을 수 없거나 충전 금액이 유효하지 않은 경우 + */ + @Transactional + public PointsInfo chargePoint(String userId, Long amount) { + User user = getUserForUpdate(userId); + Point point = Point.of(amount); + user.receivePoint(point); + User savedUser = save(user); + return PointsInfo.from(savedUser); + } + + /** + * 포인트 정보를 담는 레코드. + * + * @param userId 사용자 ID + * @param balance 포인트 잔액 + */ + public record PointsInfo(String userId, Long balance) { + /** + * User 엔티티로부터 PointsInfo를 생성합니다. + * + * @param user 사용자 엔티티 + * @return 생성된 PointsInfo + */ + public static PointsInfo from(User user) { + return new PointsInfo(user.getUserId(), user.getPointValue()); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/userinfo/UserInfoFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/userinfo/UserInfoFacade.java deleted file mode 100644 index ceae04eef..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/userinfo/UserInfoFacade.java +++ /dev/null @@ -1,69 +0,0 @@ -package com.loopers.application.userinfo; - -import com.loopers.domain.user.User; -import com.loopers.domain.user.UserRepository; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -/** - * 사용자 정보 조회 파사드. - *

    - * 사용자 정보 조회 유즈케이스를 처리하는 애플리케이션 서비스입니다. - *

    - * - * @author Loopers - * @version 1.0 - */ -@RequiredArgsConstructor -@Component -public class UserInfoFacade { - private final UserRepository userRepository; - - /** - * 사용자 ID로 사용자 정보를 조회합니다. - * - * @param userId 조회할 사용자 ID - * @return 조회된 사용자 정보 - * @throws CoreException 사용자를 찾을 수 없는 경우 - */ - public UserInfo getUserInfo(String userId) { - User user = userRepository.findByUserId(userId); - if (user == null) { - throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다."); - } - return UserInfo.from(user); - } - - /** - * 사용자 정보를 담는 레코드. - * - * @param userId 사용자 ID - * @param email 이메일 주소 - * @param birthDate 생년월일 - * @param gender 성별 - */ - public record UserInfo( - String userId, - String email, - java.time.LocalDate birthDate, - com.loopers.domain.user.Gender gender - ) { - /** - * User 엔티티로부터 UserInfo를 생성합니다. - * - * @param user 사용자 엔티티 - * @return 생성된 UserInfo - */ - public static UserInfo from(User user) { - return new UserInfo( - user.getUserId(), - user.getEmail(), - user.getBirthDate(), - user.getGender() - ); - } - } -} - diff --git a/apps/commerce-api/src/main/java/com/loopers/config/Resilience4jRetryConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/Resilience4jRetryConfig.java index 90923e2f3..ee554579f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/config/Resilience4jRetryConfig.java +++ b/apps/commerce-api/src/main/java/com/loopers/config/Resilience4jRetryConfig.java @@ -119,9 +119,9 @@ public RetryRegistry retryRegistry() { // Exponential Backoff 적용하여 일시적 오류 자동 복구 retryRegistry.addConfiguration("paymentGatewaySchedulerClient", retryConfig); - log.info("Resilience4j Retry 설정 완료:"); - log.info(" - 결제 요청 API (paymentGatewayClient): Retry 없음 (유저 요청 경로 - 빠른 실패)"); - log.info(" - 조회 API (paymentGatewaySchedulerClient): Exponential Backoff 적용 (스케줄러 - 비동기/배치 기반 Retry)"); + log.debug("Resilience4j Retry 설정 완료:"); + log.debug(" - 결제 요청 API (paymentGatewayClient): Retry 없음 (유저 요청 경로 - 빠른 실패)"); + log.debug(" - 조회 API (paymentGatewaySchedulerClient): Exponential Backoff 적용 (스케줄러 - 비동기/배치 기반 Retry)"); return retryRegistry; } diff --git a/apps/commerce-api/src/main/java/com/loopers/config/batch/LikeCountSyncBatchConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/batch/LikeCountSyncBatchConfig.java deleted file mode 100644 index 6bf72da5c..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/config/batch/LikeCountSyncBatchConfig.java +++ /dev/null @@ -1,199 +0,0 @@ -package com.loopers.config.batch; - -import com.loopers.application.catalog.ProductCacheService; -import com.loopers.domain.like.LikeRepository; -import com.loopers.domain.product.ProductRepository; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.batch.core.ExitStatus; -import org.springframework.batch.core.Job; -import org.springframework.batch.core.Step; -import org.springframework.batch.core.StepExecution; -import org.springframework.batch.core.StepExecutionListener; -import org.springframework.batch.core.configuration.annotation.StepScope; -import org.springframework.batch.core.job.builder.JobBuilder; -import org.springframework.batch.core.repository.JobRepository; -import org.springframework.batch.core.step.builder.StepBuilder; -import org.springframework.batch.item.ItemProcessor; -import org.springframework.batch.item.ItemReader; -import org.springframework.batch.item.ItemWriter; -import org.springframework.batch.item.support.ListItemReader; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.transaction.PlatformTransactionManager; - -import java.util.List; -import java.util.Map; - -/** - * 좋아요 수 동기화 배치 Job Configuration. - *

    - * Spring Batch를 사용하여 Like 테이블의 COUNT(*) 결과를 Product.likeCount 필드에 동기화합니다. - *

    - *

    - * 배치 구조: - *

      - *
    1. Reader: 모든 상품 ID 조회
    2. - *
    3. Processor: 각 상품의 좋아요 수 집계 (Like 테이블 COUNT(*))
    4. - *
    5. Writer: Product.likeCount 필드 업데이트
    6. - *
    - *

    - *

    - * 설계 근거: - *

      - *
    • 대량 처리: Spring Batch의 청크 단위 처리로 성능 최적화
    • - *
    • 트랜잭션 관리: 청크 단위로 커밋하여 안정성 보장
    • - *
    • 재시작 가능: Job 실패 시 재시작 가능
    • - *
    • 모니터링: Spring Batch 메타데이터로 실행 이력 추적
    • - *
    - *

    - * - * @author Loopers - * @version 1.0 - */ -@Slf4j -@RequiredArgsConstructor -@Configuration -public class LikeCountSyncBatchConfig { - - private final JobRepository jobRepository; - private final PlatformTransactionManager transactionManager; - private final ProductRepository productRepository; - private final LikeRepository likeRepository; - private final ProductCacheService productCacheService; - - private static final int CHUNK_SIZE = 100; // 청크 크기: 100개씩 처리 - - /** - * 좋아요 수 동기화 Job을 생성합니다. - * - * @return 좋아요 수 동기화 Job - */ - @Bean - public Job likeCountSyncJob() { - return new JobBuilder("likeCountSyncJob", jobRepository) - .start(likeCountSyncStep()) - .build(); - } - - /** - * 좋아요 수 동기화 Step을 생성합니다. - *

    - * allowStartIfComplete(true) 설정: - *

      - *
    • 주기적 실행: 스케줄러에서 주기적으로 실행할 수 있도록 완료된 Step도 재실행 가능
    • - *
    • 고정된 JobParameters: 고정된 JobParameters를 사용하므로 완료된 JobInstance도 재실행 필요
    • - *
    - *

    - * - * @return 좋아요 수 동기화 Step - */ - @Bean - public Step likeCountSyncStep() { - return new StepBuilder("likeCountSyncStep", jobRepository) - .chunk(CHUNK_SIZE, transactionManager) - .reader(productIdReader()) - .processor(productLikeCountProcessor()) - .writer(productLikeCountWriter()) - .listener(likeCountSyncStepListener()) - .allowStartIfComplete(true) // ✅ 완료된 Step도 재실행 가능 (스케줄러에서 주기적 실행) - .build(); - } - - /** - * 모든 상품 ID를 읽어오는 Reader를 생성합니다. - *

    - * @StepScope 사용 이유: - *

      - *
    • 최신 데이터 보장: 매 Step 실행 시마다 Reader가 새로 생성되어 최신 상품 ID 목록 조회
    • - *
    • 신규 상품 포함: 애플리케이션 기동 이후 생성된 상품도 배치 Job 처리 대상에 포함
    • - *
    • 싱글톤 스코프 문제 해결: @Bean 기본 스코프(싱글톤)로 인한 스냅샷 고정 문제 방지
    • - *
    - *

    - *

    - * 동작 원리: - *

      - *
    • @StepScope는 Step 실행 시마다 Bean을 새로 생성
    • - *
    • 매번 productRepository.findAllProductIds()를 호출하여 최신 상품 ID 목록 조회
    • - *
    • 스케줄러가 주기적으로 Job을 실행해도 항상 최신 상품 목록 기준으로 동기화
    • - *
    - *

    - * - * @return 상품 ID Reader - */ - @Bean - @StepScope - public ItemReader productIdReader() { - List productIds = productRepository.findAllProductIds(); - log.debug("좋아요 수 동기화 대상 상품 수: {}", productIds.size()); - return new ListItemReader<>(productIds); - } - - /** - * 상품 ID로부터 좋아요 수를 집계하는 Processor를 생성합니다. - * - * @return 상품 좋아요 수 Processor - */ - @Bean - public ItemProcessor productLikeCountProcessor() { - return productId -> { - // Like 테이블에서 해당 상품의 좋아요 수 집계 - Map likeCountMap = likeRepository.countByProductIds(List.of(productId)); - Long likeCount = likeCountMap.getOrDefault(productId, 0L); - return new ProductLikeCount(productId, likeCount); - }; - } - - /** - * Product.likeCount 필드를 업데이트하는 Writer를 생성합니다. - * - * @return 상품 좋아요 수 Writer - */ - @Bean - public ItemWriter productLikeCountWriter() { - return items -> { - for (ProductLikeCount item : items) { - try { - productRepository.updateLikeCount(item.productId(), item.likeCount()); - } catch (Exception e) { - log.warn("상품 좋아요 수 업데이트 실패: productId={}, likeCount={}, error={}", - item.productId(), item.likeCount(), e.getMessage()); - // 개별 실패는 로그만 남기고 계속 진행 - } - } - }; - } - - /** - * 좋아요 수 동기화 Step 완료 후 로컬 캐시를 초기화하는 Listener를 생성합니다. - *

    - * 배치 집계가 완료되면 정확한 값으로 DB가 업데이트되므로, - * 로컬 캐시의 델타를 초기화하여 다음 배치까지의 델타만 추적합니다. - *

    - * - * @return StepExecutionListener - */ - @Bean - public StepExecutionListener likeCountSyncStepListener() { - return new StepExecutionListener() { - @Override - public ExitStatus afterStep(StepExecution stepExecution) { - // 배치 집계 완료 후 모든 로컬 캐시 델타 초기화 - // 배치가 정확한 값으로 DB를 업데이트했으므로, 델타는 0부터 다시 시작 - productCacheService.clearAllLikeCountDelta(); - log.debug("좋아요 수 동기화 배치 완료: 로컬 캐시 델타 초기화"); - return stepExecution.getExitStatus(); - } - }; - } - - /** - * 상품 ID와 좋아요 수를 담는 레코드. - * - * @param productId 상품 ID - * @param likeCount 좋아요 수 - */ - public record ProductLikeCount(Long productId, Long likeCount) { - } -} - diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponEvent.java new file mode 100644 index 000000000..10f8d6c9a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponEvent.java @@ -0,0 +1,124 @@ +package com.loopers.domain.coupon; + +import java.time.LocalDateTime; + +/** + * 쿠폰 도메인 이벤트. + *

    + * 쿠폰 도메인의 중요한 상태 변화를 나타내는 이벤트들입니다. + *

    + * + * @author Loopers + * @version 1.0 + */ +public class CouponEvent { + + /** + * 쿠폰 적용 이벤트. + *

    + * 쿠폰이 주문에 적용되었을 때 발행되는 이벤트입니다. + *

    + * + * @param orderId 주문 ID + * @param userId 사용자 ID (Long - User.id) + * @param couponCode 쿠폰 코드 + * @param discountAmount 할인 금액 + * @param appliedAt 쿠폰 적용 시각 + */ + public record CouponApplied( + Long orderId, + Long userId, + String couponCode, + Integer discountAmount, + LocalDateTime appliedAt + ) { + public CouponApplied { + if (orderId == null) { + throw new IllegalArgumentException("orderId는 필수입니다."); + } + if (userId == null) { + throw new IllegalArgumentException("userId는 필수입니다."); + } + if (couponCode == null || couponCode.isBlank()) { + throw new IllegalArgumentException("couponCode는 필수입니다."); + } + if (discountAmount == null || discountAmount < 0) { + throw new IllegalArgumentException("discountAmount는 0 이상이어야 합니다."); + } + } + + /** + * 쿠폰 적용 정보로부터 CouponApplied 이벤트를 생성합니다. + * + * @param orderId 주문 ID + * @param userId 사용자 ID + * @param couponCode 쿠폰 코드 + * @param discountAmount 할인 금액 + * @return CouponApplied 이벤트 + */ + public static CouponApplied of(Long orderId, Long userId, String couponCode, Integer discountAmount) { + return new CouponApplied( + orderId, + userId, + couponCode, + discountAmount, + LocalDateTime.now() + ); + } + } + + /** + * 쿠폰 적용 실패 이벤트. + *

    + * 쿠폰 적용에 실패했을 때 발행되는 이벤트입니다. + *

    + * + * @param orderId 주문 ID + * @param userId 사용자 ID (Long - User.id) + * @param couponCode 쿠폰 코드 + * @param failureReason 실패 사유 + * @param failedAt 실패 시각 + */ + public record CouponApplicationFailed( + Long orderId, + Long userId, + String couponCode, + String failureReason, + LocalDateTime failedAt + ) { + public CouponApplicationFailed { + if (orderId == null) { + throw new IllegalArgumentException("orderId는 필수입니다."); + } + if (userId == null) { + throw new IllegalArgumentException("userId는 필수입니다."); + } + if (couponCode == null || couponCode.isBlank()) { + throw new IllegalArgumentException("couponCode는 필수입니다."); + } + if (failureReason == null || failureReason.isBlank()) { + throw new IllegalArgumentException("failureReason는 필수입니다."); + } + } + + /** + * 쿠폰 적용 실패 정보로부터 CouponApplicationFailed 이벤트를 생성합니다. + * + * @param orderId 주문 ID + * @param userId 사용자 ID + * @param couponCode 쿠폰 코드 + * @param failureReason 실패 사유 + * @return CouponApplicationFailed 이벤트 + */ + public static CouponApplicationFailed of(Long orderId, Long userId, String couponCode, String failureReason) { + return new CouponApplicationFailed( + orderId, + userId, + couponCode, + failureReason, + LocalDateTime.now() + ); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponEventPublisher.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponEventPublisher.java new file mode 100644 index 000000000..8269b35e4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponEventPublisher.java @@ -0,0 +1,29 @@ +package com.loopers.domain.coupon; + +/** + * 쿠폰 도메인 이벤트 발행 인터페이스. + *

    + * DIP를 준수하여 도메인 레이어에서 이벤트 발행 인터페이스를 정의합니다. + * 구현은 인프라 레이어에서 제공됩니다. + *

    + * + * @author Loopers + * @version 1.0 + */ +public interface CouponEventPublisher { + + /** + * 쿠폰 적용 이벤트를 발행합니다. + * + * @param event 쿠폰 적용 이벤트 + */ + void publish(CouponEvent.CouponApplied event); + + /** + * 쿠폰 적용 실패 이벤트를 발행합니다. + * + * @param event 쿠폰 적용 실패 이벤트 + */ + void publish(CouponEvent.CouponApplicationFailed event); +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/UserCouponRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/UserCouponRepository.java index 0bfd69db7..6a06032ff 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/UserCouponRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/UserCouponRepository.java @@ -48,5 +48,14 @@ public interface UserCouponRepository { * @return 조회된 사용자 쿠폰을 담은 Optional */ Optional findByUserIdAndCouponCodeForUpdate(Long userId, String couponCode); + + /** + * 영속성 컨텍스트의 변경사항을 데이터베이스에 즉시 반영합니다. + *

    + * Optimistic Lock을 사용하는 경우, save() 후 flush()를 호출하여 + * version 체크를 즉시 수행하도록 합니다. + *

    + */ + void flush(); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeEvent.java new file mode 100644 index 000000000..36778dab5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeEvent.java @@ -0,0 +1,94 @@ +package com.loopers.domain.like; + +import java.time.LocalDateTime; + +/** + * 좋아요 도메인 이벤트. + *

    + * 좋아요 도메인의 중요한 상태 변화를 나타내는 이벤트들입니다. + *

    + * + * @author Loopers + * @version 1.0 + */ +public class LikeEvent { + + /** + * 좋아요 추가 이벤트. + *

    + * 좋아요가 추가되었을 때 발행되는 이벤트입니다. + *

    + * + * @param userId 사용자 ID (Long - User.id) + * @param productId 상품 ID + * @param occurredAt 이벤트 발생 시각 + */ + public record LikeAdded( + Long userId, + Long productId, + LocalDateTime occurredAt + ) { + public LikeAdded { + if (userId == null) { + throw new IllegalArgumentException("userId는 필수입니다."); + } + if (productId == null) { + throw new IllegalArgumentException("productId는 필수입니다."); + } + } + + /** + * Like 엔티티로부터 LikeAdded 이벤트를 생성합니다. + * + * @param like 좋아요 엔티티 + * @return LikeAdded 이벤트 + */ + public static LikeAdded from(Like like) { + return new LikeAdded( + like.getUserId(), + like.getProductId(), + LocalDateTime.now() + ); + } + } + + /** + * 좋아요 취소 이벤트. + *

    + * 좋아요가 취소되었을 때 발행되는 이벤트입니다. + *

    + * + * @param userId 사용자 ID (Long - User.id) + * @param productId 상품 ID + * @param occurredAt 이벤트 발생 시각 + */ + public record LikeRemoved( + Long userId, + Long productId, + LocalDateTime occurredAt + ) { + public LikeRemoved { + if (userId == null) { + throw new IllegalArgumentException("userId는 필수입니다."); + } + if (productId == null) { + throw new IllegalArgumentException("productId는 필수입니다."); + } + } + + /** + * Like 엔티티로부터 LikeRemoved 이벤트를 생성합니다. + * + * @param like 좋아요 엔티티 + * @return LikeRemoved 이벤트 + */ + public static LikeRemoved from(Like like) { + return new LikeRemoved( + like.getUserId(), + like.getProductId(), + LocalDateTime.now() + ); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeEventPublisher.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeEventPublisher.java new file mode 100644 index 000000000..cc9e6bdf8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeEventPublisher.java @@ -0,0 +1,29 @@ +package com.loopers.domain.like; + +/** + * 좋아요 도메인 이벤트 발행 인터페이스. + *

    + * DIP를 준수하여 도메인 레이어에서 이벤트 발행 인터페이스를 정의합니다. + * 구현은 인프라 레이어에서 제공됩니다. + *

    + * + * @author Loopers + * @version 1.0 + */ +public interface LikeEventPublisher { + + /** + * 좋아요 추가 이벤트를 발행합니다. + * + * @param event 좋아요 추가 이벤트 + */ + void publish(LikeEvent.LikeAdded event); + + /** + * 좋아요 취소 이벤트를 발행합니다. + * + * @param event 좋아요 취소 이벤트 + */ + void publish(LikeEvent.LikeRemoved event); +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java index 78b5e1049..87aaa96d5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -153,5 +153,52 @@ public void cancel() { } this.status = OrderStatus.CANCELED; } + + /** + * 주문이 완료 상태인지 확인합니다. + * + * @return 완료 상태이면 true, 아니면 false + */ + public boolean isCompleted() { + return this.status == OrderStatus.COMPLETED; + } + + /** + * 주문이 취소 상태인지 확인합니다. + * + * @return 취소 상태이면 true, 아니면 false + */ + public boolean isCanceled() { + return this.status == OrderStatus.CANCELED; + } + + /** + * 주문이 대기 상태인지 확인합니다. + * + * @return 대기 상태이면 true, 아니면 false + */ + public boolean isPending() { + return this.status == OrderStatus.PENDING; + } + + /** + * 주문에 할인 금액을 적용합니다. + * PENDING 상태의 주문에만 할인 적용이 가능합니다. + * + * @param discountAmount 적용할 할인 금액 + * @throws CoreException PENDING 상태가 아니거나 할인 금액이 유효하지 않을 경우 + */ + public void applyDiscount(Integer discountAmount) { + if (this.status != OrderStatus.PENDING) { + throw new CoreException(ErrorType.BAD_REQUEST, + String.format("할인을 적용할 수 없는 주문 상태입니다. (현재 상태: %s)", this.status)); + } + if (discountAmount == null || discountAmount < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "할인 금액은 0 이상이어야 합니다."); + } + this.discountAmount = discountAmount; + Integer subtotal = calculateTotalAmount(this.items); + this.totalAmount = Math.max(0, subtotal - this.discountAmount); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderCancellationService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderCancellationService.java deleted file mode 100644 index 643644749..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderCancellationService.java +++ /dev/null @@ -1,106 +0,0 @@ -package com.loopers.domain.order; - -import com.loopers.domain.product.Product; -import com.loopers.domain.product.ProductRepository; -import com.loopers.domain.user.Point; -import com.loopers.domain.user.User; -import com.loopers.domain.user.UserRepository; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -/** - * 주문 취소 도메인 서비스. - *

    - * 주문 취소 및 리소스 원복을 처리합니다. - *

    - * - * @author Loopers - * @version 1.0 - */ -@Slf4j -@Component -@RequiredArgsConstructor -public class OrderCancellationService { - - private final OrderRepository orderRepository; - private final UserRepository userRepository; - private final ProductRepository productRepository; - - /** - * 주문을 취소하고 포인트를 환불하며 재고를 원복합니다. - *

    - * 동시성 제어: - *

      - *
    • 비관적 락 사용: 재고 원복 시 동시성 제어를 위해 findByIdForUpdate 사용
    • - *
    • Deadlock 방지: 상품 ID를 정렬하여 일관된 락 획득 순서 보장
    • - *
    - *

    - * - * @param order 주문 엔티티 - * @param user 사용자 엔티티 - */ - @Transactional - public void cancel(Order order, User user) { - if (order == null || user == null) { - throw new CoreException(ErrorType.BAD_REQUEST, "취소할 주문과 사용자 정보는 필수입니다."); - } - - // ✅ Deadlock 방지: User 락을 먼저 획득하여 createOrder와 동일한 락 획득 순서 보장 - User lockedUser = userRepository.findByUserIdForUpdate(user.getUserId()); - if (lockedUser == null) { - throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다."); - } - - // ✅ Deadlock 방지: 상품 ID를 정렬하여 일관된 락 획득 순서 보장 - List sortedProductIds = order.getItems().stream() - .map(OrderItem::getProductId) - .distinct() - .sorted() - .toList(); - - // 정렬된 순서대로 상품 락 획득 (Deadlock 방지) - Map productMap = new HashMap<>(); - for (Long productId : sortedProductIds) { - Product product = productRepository.findByIdForUpdate(productId) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, - String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", productId))); - productMap.put(productId, product); - } - - // OrderItem 순서대로 Product 리스트 생성 - List products = order.getItems().stream() - .map(item -> productMap.get(item.getProductId())) - .toList(); - - order.cancel(); - increaseStocksForOrderItems(order.getItems(), products); - lockedUser.receivePoint(Point.of((long) order.getTotalAmount())); - - products.forEach(productRepository::save); - userRepository.save(lockedUser); - orderRepository.save(order); - } - - private void increaseStocksForOrderItems(List items, List products) { - Map productMap = products.stream() - .collect(java.util.stream.Collectors.toMap(Product::getId, product -> product)); - - for (OrderItem item : items) { - Product product = productMap.get(item.getProductId()); - if (product == null) { - throw new CoreException(ErrorType.NOT_FOUND, - String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", item.getProductId())); - } - product.increaseStock(item.getQuantity()); - } - } -} - diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderEvent.java new file mode 100644 index 000000000..313671be7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderEvent.java @@ -0,0 +1,227 @@ +package com.loopers.domain.order; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 주문 도메인 이벤트. + *

    + * 주문 도메인의 중요한 상태 변화를 나타내는 이벤트들입니다. + *

    + */ +public class OrderEvent { + + /** + * 주문 생성 이벤트. + *

    + * 주문이 생성되었을 때 발행되는 이벤트입니다. + *

    + * + * @param orderId 주문 ID + * @param userId 사용자 ID (Long - User.id) + * @param couponCode 쿠폰 코드 (null 가능) + * @param subtotal 주문 소계 (쿠폰 할인 전 금액) + * @param usedPointAmount 사용할 포인트 금액 + * @param orderItems 주문 아이템 목록 (재고 차감용) + * @param createdAt 이벤트 발생 시각 + */ + public record OrderCreated( + Long orderId, + Long userId, + String couponCode, + Integer subtotal, + Long usedPointAmount, + List orderItems, + LocalDateTime createdAt + ) { + /** + * 주문 아이템 정보 (재고 차감용). + * + * @param productId 상품 ID + * @param quantity 수량 + */ + public record OrderItemInfo( + Long productId, + Integer quantity + ) { + public OrderItemInfo { + if (productId == null) { + throw new IllegalArgumentException("productId는 필수입니다."); + } + if (quantity == null || quantity <= 0) { + throw new IllegalArgumentException("quantity는 1 이상이어야 합니다."); + } + } + } + + public OrderCreated { + if (orderId == null) { + throw new IllegalArgumentException("orderId는 필수입니다."); + } + if (userId == null) { + throw new IllegalArgumentException("userId는 필수입니다."); + } + if (subtotal == null || subtotal < 0) { + throw new IllegalArgumentException("subtotal은 0 이상이어야 합니다."); + } + if (usedPointAmount == null || usedPointAmount < 0) { + throw new IllegalArgumentException("usedPointAmount는 0 이상이어야 합니다."); + } + if (orderItems == null) { + throw new IllegalArgumentException("orderItems는 필수입니다."); + } + } + + /** + * Order 엔티티로부터 OrderCreated 이벤트를 생성합니다. + * + * @param order 주문 엔티티 + * @param subtotal 주문 소계 (쿠폰 할인 전 금액) + * @param usedPointAmount 사용할 포인트 금액 + * @return OrderCreated 이벤트 + */ + public static OrderCreated from(Order order, Integer subtotal, Long usedPointAmount) { + List orderItemInfos = order.getItems().stream() + .map(item -> new OrderItemInfo(item.getProductId(), item.getQuantity())) + .toList(); + + return new OrderCreated( + order.getId(), + order.getUserId(), + order.getCouponCode(), + subtotal, + usedPointAmount, + orderItemInfos, + LocalDateTime.now() + ); + } + } + + /** + * 주문 완료 이벤트. + *

    + * 주문이 완료되었을 때 발행되는 이벤트입니다. + *

    + * + * @param orderId 주문 ID + * @param userId 사용자 ID (Long - User.id) + * @param totalAmount 주문 총액 + * @param completedAt 주문 완료 시각 + */ + public record OrderCompleted( + Long orderId, + Long userId, + Long totalAmount, + LocalDateTime completedAt + ) { + public OrderCompleted { + if (orderId == null) { + throw new IllegalArgumentException("orderId는 필수입니다."); + } + if (userId == null) { + throw new IllegalArgumentException("userId는 필수입니다."); + } + if (totalAmount == null || totalAmount < 0) { + throw new IllegalArgumentException("totalAmount는 0 이상이어야 합니다."); + } + } + + /** + * Order 엔티티로부터 OrderCompleted 이벤트를 생성합니다. + * + * @param order 주문 엔티티 + * @return OrderCompleted 이벤트 + */ + public static OrderCompleted from(Order order) { + return new OrderCompleted( + order.getId(), + order.getUserId(), + order.getTotalAmount().longValue(), + LocalDateTime.now() + ); + } + } + + /** + * 주문 취소 이벤트. + *

    + * 주문이 취소되었을 때 발행되는 이벤트입니다. + *

    + * + * @param orderId 주문 ID + * @param userId 사용자 ID (Long - User.id) + * @param reason 취소 사유 + * @param orderItems 주문 아이템 목록 (재고 원복용) + * @param refundPointAmount 환불할 포인트 금액 + * @param canceledAt 주문 취소 시각 + */ + public record OrderCanceled( + Long orderId, + Long userId, + String reason, + List orderItems, + Long refundPointAmount, + LocalDateTime canceledAt + ) { + /** + * 주문 아이템 정보 (재고 원복용). + * + * @param productId 상품 ID + * @param quantity 수량 + */ + public record OrderItemInfo( + Long productId, + Integer quantity + ) { + public OrderItemInfo { + if (productId == null) { + throw new IllegalArgumentException("productId는 필수입니다."); + } + if (quantity == null || quantity <= 0) { + throw new IllegalArgumentException("quantity는 1 이상이어야 합니다."); + } + } + } + + public OrderCanceled { + if (orderId == null) { + throw new IllegalArgumentException("orderId는 필수입니다."); + } + if (userId == null) { + throw new IllegalArgumentException("userId는 필수입니다."); + } + if (reason == null || reason.isBlank()) { + throw new IllegalArgumentException("reason은 필수입니다."); + } + if (orderItems == null) { + throw new IllegalArgumentException("orderItems는 필수입니다."); + } + if (refundPointAmount == null || refundPointAmount < 0) { + throw new IllegalArgumentException("refundPointAmount는 0 이상이어야 합니다."); + } + } + + /** + * Order 엔티티와 환불 포인트 금액으로부터 OrderCanceled 이벤트를 생성합니다. + * + * @param order 주문 엔티티 + * @param reason 취소 사유 + * @param refundPointAmount 환불할 포인트 금액 + * @return OrderCanceled 이벤트 + */ + public static OrderCanceled from(Order order, String reason, Long refundPointAmount) { + List orderItemInfos = order.getItems().stream() + .map(item -> new OrderItemInfo(item.getProductId(), item.getQuantity())) + .toList(); + + return new OrderCanceled( + order.getId(), + order.getUserId(), + reason, + orderItemInfos, + refundPointAmount, + LocalDateTime.now() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderEventPublisher.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderEventPublisher.java new file mode 100644 index 000000000..5be0e2027 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderEventPublisher.java @@ -0,0 +1,35 @@ +package com.loopers.domain.order; + +/** + * 주문 도메인 이벤트 발행 인터페이스. + *

    + * DIP를 준수하여 도메인 레이어에서 이벤트 발행 인터페이스를 정의합니다. + * 구현은 인프라 레이어에서 제공됩니다. + *

    + * + * @author Loopers + * @version 1.0 + */ +public interface OrderEventPublisher { + + /** + * 주문 생성 이벤트를 발행합니다. + * + * @param event 주문 생성 이벤트 + */ + void publish(OrderEvent.OrderCreated event); + + /** + * 주문 완료 이벤트를 발행합니다. + * + * @param event 주문 완료 이벤트 + */ + void publish(OrderEvent.OrderCompleted event); + + /** + * 주문 취소 이벤트를 발행합니다. + * + * @param event 주문 취소 이벤트 + */ + void publish(OrderEvent.OrderCanceled event); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatusUpdater.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatusUpdater.java deleted file mode 100644 index 6e9abcc1f..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatusUpdater.java +++ /dev/null @@ -1,100 +0,0 @@ -package com.loopers.domain.order; - -import com.loopers.domain.user.User; -import com.loopers.domain.user.UserRepository; -import com.loopers.infrastructure.paymentgateway.PaymentGatewayDto; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; - -/** - * 주문 상태 업데이트 도메인 서비스. - *

    - * 결제 상태에 따라 주문 상태를 업데이트합니다. - *

    - * - * @author Loopers - * @version 1.0 - */ -@Slf4j -@Component -@RequiredArgsConstructor -public class OrderStatusUpdater { - - private final OrderRepository orderRepository; - private final UserRepository userRepository; - private final OrderCancellationService orderCancellationService; - - /** - * 결제 상태에 따라 주문 상태를 업데이트합니다. - *

    - * 별도 트랜잭션으로 실행하여 외부 시스템 호출과 독립적으로 처리합니다. - *

    - * - * @param orderId 주문 ID - * @param status 결제 상태 - * @param transactionKey 트랜잭션 키 - * @param reason 실패 사유 (실패 시) - * @return 업데이트 성공 여부 (true: 성공, false: 실패) - */ - @Transactional(propagation = Propagation.REQUIRES_NEW) - public boolean updateByPaymentStatus( - Long orderId, - PaymentGatewayDto.TransactionStatus status, - String transactionKey, - String reason - ) { - try { - Order order = orderRepository.findById(orderId) - .orElse(null); - - if (order == null) { - log.warn("주문 상태 업데이트 시 주문을 찾을 수 없습니다. (orderId: {})", orderId); - return false; - } - - // 이미 완료되거나 취소된 주문인 경우 처리하지 않음 (정상적인 경우이므로 true 반환) - if (order.getStatus() == OrderStatus.COMPLETED) { - log.info("이미 완료된 주문입니다. 상태 업데이트를 건너뜁니다. (orderId: {})", orderId); - return true; - } - - if (order.getStatus() == OrderStatus.CANCELED) { - log.info("이미 취소된 주문입니다. 상태 업데이트를 건너뜁니다. (orderId: {})", orderId); - return true; - } - - if (status == PaymentGatewayDto.TransactionStatus.SUCCESS) { - // 결제 성공: 주문 완료 - order.complete(); - orderRepository.save(order); - log.info("결제 상태 확인 결과, 주문 상태를 COMPLETED로 업데이트했습니다. (orderId: {}, transactionKey: {})", - orderId, transactionKey); - return true; - } else if (status == PaymentGatewayDto.TransactionStatus.FAILED) { - // 결제 실패: 주문 취소 및 리소스 원복 - User user = userRepository.findById(order.getUserId()); - if (user == null) { - log.warn("주문 상태 업데이트 시 사용자를 찾을 수 없습니다. (orderId: {}, userId: {})", - orderId, order.getUserId()); - return false; - } - orderCancellationService.cancel(order, user); - log.info("결제 상태 확인 결과, 주문 상태를 CANCELED로 업데이트했습니다. (orderId: {}, transactionKey: {}, reason: {})", - orderId, transactionKey, reason); - return true; - } else { - // PENDING 상태: 아직 처리 중 (정상적인 경우이므로 true 반환) - log.info("결제 상태 확인 결과, 아직 처리 중입니다. 주문은 PENDING 상태로 유지됩니다. (orderId: {}, transactionKey: {})", - orderId, transactionKey); - return true; - } - } catch (Exception e) { - log.error("주문 상태 업데이트 중 오류 발생. (orderId: {})", orderId, e); - return false; - } - } -} - diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/PaymentFailureClassifier.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/PaymentFailureClassifier.java deleted file mode 100644 index 804b9c123..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/PaymentFailureClassifier.java +++ /dev/null @@ -1,74 +0,0 @@ -package com.loopers.domain.order; - -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.util.Set; - -/** - * 결제 실패 분류 도메인 서비스. - *

    - * 결제 실패를 비즈니스 실패와 외부 시스템 장애로 분류합니다. - *

    - *

    - * 비즈니스 실패 예시: - *

      - *
    • 카드 한도 초과 (LIMIT_EXCEEDED)
    • - *
    • 잘못된 카드 번호 (INVALID_CARD)
    • - *
    • 카드 오류 (CARD_ERROR)
    • - *
    • 잔액 부족 (INSUFFICIENT_FUNDS)
    • - *
    - *

    - *

    - * 외부 시스템 장애 예시: - *

      - *
    • CircuitBreaker Open (CIRCUIT_BREAKER_OPEN)
    • - *
    • 서버 오류 (5xx)
    • - *
    • 타임아웃
    • - *
    • 네트워크 오류
    • - *
    - *

    - * - * @author Loopers - * @version 1.0 - */ -@Component -@RequiredArgsConstructor -public class PaymentFailureClassifier { - - private static final Set BUSINESS_FAILURE_CODES = Set.of( - "LIMIT_EXCEEDED", - "INVALID_CARD", - "CARD_ERROR", - "INSUFFICIENT_FUNDS", - "PAYMENT_FAILED" - ); - - private static final String CIRCUIT_BREAKER_OPEN = "CIRCUIT_BREAKER_OPEN"; - - /** - * 오류 코드를 기반으로 결제 실패 유형을 분류합니다. - * - * @param errorCode 오류 코드 - * @return 결제 실패 유형 - */ - public PaymentFailureType classify(String errorCode) { - if (errorCode == null) { - return PaymentFailureType.EXTERNAL_SYSTEM_FAILURE; - } - - // CircuitBreaker Open 상태는 명시적으로 외부 시스템 장애로 간주 - if (CIRCUIT_BREAKER_OPEN.equals(errorCode)) { - return PaymentFailureType.EXTERNAL_SYSTEM_FAILURE; - } - - // 명확한 비즈니스 실패 오류 코드만 취소 처리 - boolean isBusinessFailure = BUSINESS_FAILURE_CODES.stream() - .anyMatch(errorCode::contains); - - return isBusinessFailure - ? PaymentFailureType.BUSINESS_FAILURE - : PaymentFailureType.EXTERNAL_SYSTEM_FAILURE; - } -} - diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/PaymentFailureType.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/PaymentFailureType.java deleted file mode 100644 index 2cc7f03af..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/PaymentFailureType.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.loopers.domain.order; - -/** - * 결제 실패 유형. - *

    - * 결제 실패를 비즈니스 실패와 외부 시스템 장애로 구분합니다. - *

    - * - * @author Loopers - * @version 1.0 - */ -public enum PaymentFailureType { - /** - * 비즈니스 실패: 주문 취소 필요 - * 예: 카드 한도 초과, 잘못된 카드 번호 등 - */ - BUSINESS_FAILURE, - - /** - * 외부 시스템 장애: 주문 PENDING 상태 유지 - * 예: CircuitBreaker Open, 서버 오류, 타임아웃 등 - */ - EXTERNAL_SYSTEM_FAILURE -} - diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/CardType.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/CardType.java new file mode 100644 index 000000000..ea3ab7d43 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/CardType.java @@ -0,0 +1,11 @@ +package com.loopers.domain.payment; + +/** + * 카드 타입. + */ +public enum CardType { + SAMSUNG, + KB, + HYUNDAI +} + 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..4a2c4c9e8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java @@ -0,0 +1,374 @@ +package com.loopers.domain.payment; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 결제 도메인 엔티티. + *

    + * 결제의 상태, 금액, 포인트 사용 정보를 관리합니다. + *

    + * + * @author Loopers + * @version 1.0 + */ +@Entity +@Table( + name = "payment", + uniqueConstraints = { + @UniqueConstraint(name = "uk_payment_order_id", columnNames = "ref_order_id") + }, + indexes = { + @Index(name = "idx_payment_order_id", columnList = "ref_order_id"), + @Index(name = "idx_payment_user_id", columnList = "ref_user_id"), + @Index(name = "idx_payment_status", columnList = "status") + } +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class Payment extends BaseEntity { + + @Column(name = "ref_order_id", nullable = false) + private Long orderId; + + @Column(name = "ref_user_id", nullable = false) + private Long userId; + + @Column(name = "total_amount", nullable = false) + private Long totalAmount; + + @Column(name = "used_point", nullable = false) + private Long usedPoint; + + @Column(name = "paid_amount", nullable = false) + private Long paidAmount; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private PaymentStatus status; + + @Enumerated(EnumType.STRING) + @Column(name = "card_type") + private CardType cardType; + + @Column(name = "card_no") + private String cardNo; + + @Column(name = "failure_reason", length = 500) + private String failureReason; + + @Column(name = "pg_requested_at", nullable = false) + private LocalDateTime pgRequestedAt; + + @Column(name = "pg_completed_at") + private LocalDateTime pgCompletedAt; + + /** + * 카드 결제용 Payment를 생성합니다. + * + * @param orderId 주문 ID + * @param userId 사용자 ID + * @param cardType 카드 타입 + * @param cardNo 카드 번호 + * @param amount 결제 금액 + * @param requestedAt PG 요청 시각 + * @return 생성된 Payment 인스턴스 + * @throws CoreException 유효성 검증 실패 시 + */ + public static Payment of( + Long orderId, + Long userId, + CardType cardType, + String cardNo, + Long amount, + LocalDateTime requestedAt + ) { + validateOrderId(orderId); + validateUserId(userId); + validateCardType(cardType); + validateCardNo(cardNo); + validateAmount(amount); + validateRequestedAt(requestedAt); + + Payment payment = new Payment(); + payment.orderId = orderId; + payment.userId = userId; + payment.totalAmount = amount; + payment.usedPoint = 0L; + payment.paidAmount = amount; + payment.status = PaymentStatus.PENDING; + payment.cardType = cardType; + payment.cardNo = cardNo; + payment.pgRequestedAt = requestedAt; + + return payment; + } + + /** + * 포인트 결제용 Payment를 생성합니다. + * + * @param orderId 주문 ID + * @param userId 사용자 ID + * @param totalAmount 총 결제 금액 + * @param usedPoint 사용 포인트 + * @param requestedAt PG 요청 시각 + * @return 생성된 Payment 인스턴스 + * @throws CoreException 유효성 검증 실패 시 + */ + public static Payment of( + Long orderId, + Long userId, + Long totalAmount, + Long usedPoint, + LocalDateTime requestedAt + ) { + return of(orderId, userId, totalAmount, usedPoint, null, null, requestedAt); + } + + /** + * 포인트와 카드 혼합 결제용 Payment를 생성합니다. + *

    + * 포인트와 쿠폰 할인을 적용한 후 남은 금액을 카드로 결제하는 경우 사용합니다. + *

    + * + * @param orderId 주문 ID + * @param userId 사용자 ID + * @param totalAmount 총 결제 금액 + * @param usedPoint 사용 포인트 + * @param cardType 카드 타입 (paidAmount > 0일 때만 필수) + * @param cardNo 카드 번호 (paidAmount > 0일 때만 필수) + * @param requestedAt PG 요청 시각 + * @return 생성된 Payment 인스턴스 + * @throws CoreException 유효성 검증 실패 시 + */ + public static Payment of( + Long orderId, + Long userId, + Long totalAmount, + Long usedPoint, + CardType cardType, + String cardNo, + LocalDateTime requestedAt + ) { + validateOrderId(orderId); + validateUserId(userId); + validateAmount(totalAmount); + validateUsedPoint(usedPoint); + validateRequestedAt(requestedAt); + + Long paidAmount = totalAmount - usedPoint; + validatePaidAmount(paidAmount); + + // paidAmount > 0이면 카드 정보 필수 + if (paidAmount > 0) { + if (cardType == null || cardNo == null || cardNo.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, + "포인트와 쿠폰만으로 결제할 수 없습니다. 카드 정보를 입력해주세요."); + } + } + + Payment payment = new Payment(); + payment.orderId = orderId; + payment.userId = userId; + payment.totalAmount = totalAmount; + payment.usedPoint = usedPoint; + payment.paidAmount = paidAmount; + payment.status = (paidAmount == 0L) ? PaymentStatus.SUCCESS : PaymentStatus.PENDING; + payment.cardType = cardType; // paidAmount > 0일 때만 설정 + payment.cardNo = cardNo; // paidAmount > 0일 때만 설정 + payment.pgRequestedAt = requestedAt; + + return payment; + } + + /** + * 결제를 SUCCESS 상태로 전이합니다. + *

    + * 멱등성 보장: 이미 SUCCESS 상태인 경우 아무 작업도 하지 않습니다. + *

    + * + * @param completedAt PG 완료 시각 + * @throws CoreException PENDING 상태가 아닌 경우 (SUCCESS는 제외) + */ + public void toSuccess(LocalDateTime completedAt) { + if (status == PaymentStatus.SUCCESS) { + // 멱등성: 이미 성공 상태면 아무 작업도 하지 않음 + return; + } + if (status != PaymentStatus.PENDING) { + throw new CoreException(ErrorType.BAD_REQUEST, "결제 대기 상태에서만 성공으로 전이할 수 있습니다."); + } + this.status = PaymentStatus.SUCCESS; + this.pgCompletedAt = completedAt; + } + + /** + * 결제를 FAILED 상태로 전이합니다. + *

    + * 멱등성 보장: 이미 FAILED 상태인 경우 아무 작업도 하지 않습니다. + *

    + * + * @param failureReason 실패 사유 + * @param completedAt PG 완료 시각 + * @throws CoreException PENDING 상태가 아닌 경우 (FAILED는 제외) + */ + public void toFailed(String failureReason, LocalDateTime completedAt) { + if (status == PaymentStatus.FAILED) { + // 멱등성: 이미 실패 상태면 아무 작업도 하지 않음 + return; + } + if (status != PaymentStatus.PENDING) { + throw new CoreException(ErrorType.BAD_REQUEST, "결제 대기 상태에서만 실패로 전이할 수 있습니다."); + } + this.status = PaymentStatus.FAILED; + this.failureReason = failureReason; + this.pgCompletedAt = completedAt; + } + + /** + * 결제가 완료되었는지 확인합니다. + * + * @return 완료 여부 + */ + public boolean isCompleted() { + return status.isCompleted(); + } + + /** + * 결제가 대기 상태인지 확인합니다. + * + * @return 대기 상태이면 true, 아니면 false + */ + public boolean isPending() { + return status == PaymentStatus.PENDING; + } + + /** + * 결제가 성공 상태인지 확인합니다. + * + * @return 성공 상태이면 true, 아니면 false + */ + public boolean isSuccess() { + return status.isSuccess(); + } + + /** + * 결제가 실패 상태인지 확인합니다. + * + * @return 실패 상태이면 true, 아니면 false + */ + public boolean isFailed() { + return status == PaymentStatus.FAILED; + } + + /** + * 쿠폰 할인 금액을 적용하여 결제 금액을 업데이트합니다. + *

    + * 쿠폰 할인이 적용된 후 Order의 totalAmount가 업데이트되면, + * Payment의 totalAmount도 동기화하기 위해 호출됩니다. + *

    + *

    + * 주의사항: + *

      + *
    • 이미 완료된 결제에는 할인을 적용할 수 없습니다.
    • + *
    • 할인 금액이 totalAmount를 초과할 수 없습니다.
    • + *
    • paidAmount는 자동으로 재계산됩니다.
    • + *
    + *

    + * + * @param discountAmount 할인 금액 + * @throws CoreException 결제가 완료되었거나 할인 금액이 유효하지 않은 경우 + */ + public void applyCouponDiscount(Integer discountAmount) { + if (discountAmount == null || discountAmount < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "할인 금액은 0 이상이어야 합니다."); + } + + // 이미 완료된 결제에는 할인을 적용할 수 없음 + if (isCompleted()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이미 완료된 결제에는 할인을 적용할 수 없습니다."); + } + + // 할인 금액이 totalAmount를 초과할 수 없음 + if (discountAmount > totalAmount) { + throw new CoreException(ErrorType.BAD_REQUEST, + String.format("할인 금액(%d)이 결제 금액(%d)을 초과할 수 없습니다.", discountAmount, totalAmount)); + } + + // totalAmount에서 할인 금액 차감 + Long newTotalAmount = totalAmount - discountAmount; + if (newTotalAmount < 0) { + newTotalAmount = 0L; + } + + this.totalAmount = newTotalAmount; + + // paidAmount 재계산 (totalAmount - usedPoint) + Long newPaidAmount = newTotalAmount - usedPoint; + if (newPaidAmount < 0) { + newPaidAmount = 0L; + } + this.paidAmount = newPaidAmount; + + // paidAmount가 0이 되면 자동으로 완료 처리 + if (newPaidAmount == 0L && status == PaymentStatus.PENDING) { + this.status = PaymentStatus.SUCCESS; + } + } + + private static void validateOrderId(Long orderId) { + if (orderId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 ID는 필수입니다."); + } + } + + private static void validateUserId(Long userId) { + if (userId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "사용자 ID는 필수입니다."); + } + } + + private static void validateCardType(CardType cardType) { + if (cardType == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "카드 타입은 필수입니다."); + } + } + + private static void validateCardNo(String cardNo) { + if (cardNo == null || cardNo.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "카드 번호는 필수입니다."); + } + } + + private static void validateAmount(Long amount) { + if (amount == null || amount <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "결제 금액은 0보다 커야 합니다."); + } + } + + private static void validateUsedPoint(Long usedPoint) { + if (usedPoint == null || usedPoint < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "사용 포인트는 0 이상이어야 합니다."); + } + } + + private static void validatePaidAmount(Long paidAmount) { + if (paidAmount < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "포인트와 쿠폰 할인의 합이 주문 금액을 초과합니다."); + } + } + + private static void validateRequestedAt(LocalDateTime requestedAt) { + if (requestedAt == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "PG 요청 시각은 필수입니다."); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentEvent.java new file mode 100644 index 000000000..01e58027a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentEvent.java @@ -0,0 +1,266 @@ +package com.loopers.domain.payment; + +import java.time.LocalDateTime; + +/** + * 결제 도메인 이벤트. + *

    + * 결제 도메인의 중요한 상태 변화를 나타내는 이벤트들입니다. + *

    + */ +public class PaymentEvent { + + /** + * 결제 완료 이벤트. + *

    + * 결제가 성공적으로 완료되었을 때 발행되는 이벤트입니다. + *

    + * + * @param orderId 주문 ID + * @param paymentId 결제 ID + * @param transactionKey 트랜잭션 키 (null 가능 - PG 응답 전에는 없을 수 있음) + * @param completedAt 결제 완료 시각 + */ + public record PaymentCompleted( + Long orderId, + Long paymentId, + String transactionKey, + LocalDateTime completedAt + ) { + public PaymentCompleted { + if (orderId == null) { + throw new IllegalArgumentException("orderId는 필수입니다."); + } + if (paymentId == null) { + throw new IllegalArgumentException("paymentId는 필수입니다."); + } + } + + /** + * Payment 엔티티와 transactionKey로부터 PaymentCompleted 이벤트를 생성합니다. + * + * @param payment 결제 엔티티 + * @param transactionKey 트랜잭션 키 (null 가능) + * @return PaymentCompleted 이벤트 + */ + public static PaymentCompleted from(Payment payment, String transactionKey) { + return new PaymentCompleted( + payment.getOrderId(), + payment.getId(), + transactionKey, + payment.getPgCompletedAt() != null ? payment.getPgCompletedAt() : LocalDateTime.now() + ); + } + } + + /** + * 결제 실패 이벤트. + *

    + * 결제가 실패했을 때 발행되는 이벤트입니다. + *

    + * + * @param orderId 주문 ID + * @param paymentId 결제 ID + * @param transactionKey 트랜잭션 키 (null 가능) + * @param reason 실패 사유 + * @param refundPointAmount 환불할 포인트 금액 + * @param failedAt 결제 실패 시각 + */ + public record PaymentFailed( + Long orderId, + Long paymentId, + String transactionKey, + String reason, + Long refundPointAmount, + LocalDateTime failedAt + ) { + public PaymentFailed { + if (orderId == null) { + throw new IllegalArgumentException("orderId는 필수입니다."); + } + if (paymentId == null) { + throw new IllegalArgumentException("paymentId는 필수입니다."); + } + if (reason == null || reason.isBlank()) { + throw new IllegalArgumentException("reason은 필수입니다."); + } + if (refundPointAmount == null || refundPointAmount < 0) { + throw new IllegalArgumentException("refundPointAmount는 0 이상이어야 합니다."); + } + } + + /** + * Payment 엔티티와 transactionKey로부터 PaymentFailed 이벤트를 생성합니다. + * + * @param payment 결제 엔티티 + * @param reason 실패 사유 + * @param transactionKey 트랜잭션 키 (null 가능) + * @return PaymentFailed 이벤트 + */ + public static PaymentFailed from(Payment payment, String reason, String transactionKey) { + return new PaymentFailed( + payment.getOrderId(), + payment.getId(), + transactionKey, + reason, + payment.getUsedPoint(), + payment.getPgCompletedAt() != null ? payment.getPgCompletedAt() : LocalDateTime.now() + ); + } + } + + /** + * 결제 요청 이벤트. + *

    + * 주문에 대한 결제를 요청할 때 발행되는 이벤트입니다. + *

    + * + * @param orderId 주문 ID + * @param userId 사용자 ID (String - User.userId, PG 요청용) + * @param userEntityId 사용자 엔티티 ID (Long - User.id, Payment 엔티티용) + * @param totalAmount 주문 총액 + * @param usedPointAmount 사용된 포인트 금액 + * @param cardType 카드 타입 (null 가능) + * @param cardNo 카드 번호 (null 가능) + * @param occurredAt 이벤트 발생 시각 + */ + public record PaymentRequested( + Long orderId, + String userId, + Long userEntityId, + Long totalAmount, + Long usedPointAmount, + String cardType, + String cardNo, + LocalDateTime occurredAt + ) { + public PaymentRequested { + if (orderId == null) { + throw new IllegalArgumentException("orderId는 필수입니다."); + } + if (userId == null || userId.isBlank()) { + throw new IllegalArgumentException("userId는 필수입니다."); + } + if (userEntityId == null) { + throw new IllegalArgumentException("userEntityId는 필수입니다."); + } + if (totalAmount == null || totalAmount < 0) { + throw new IllegalArgumentException("totalAmount는 0 이상이어야 합니다."); + } + if (usedPointAmount == null || usedPointAmount < 0) { + throw new IllegalArgumentException("usedPointAmount는 0 이상이어야 합니다."); + } + } + + /** + * 결제 요청 이벤트를 생성합니다. + * + * @param orderId 주문 ID + * @param userId 사용자 ID (String - User.userId) + * @param userEntityId 사용자 엔티티 ID (Long - User.id) + * @param totalAmount 주문 총액 + * @param usedPointAmount 사용된 포인트 금액 + * @param cardType 카드 타입 (null 가능) + * @param cardNo 카드 번호 (null 가능) + * @return PaymentRequested 이벤트 + */ + public static PaymentRequested of( + Long orderId, + String userId, + Long userEntityId, + Long totalAmount, + Long usedPointAmount, + String cardType, + String cardNo + ) { + return new PaymentRequested( + orderId, + userId, + userEntityId, + totalAmount, + usedPointAmount, + cardType, + cardNo, + LocalDateTime.now() + ); + } + + /** + * 마스킹된 카드 번호를 반환합니다. + *

    + * PII 보호를 위해 카드 번호의 중간 부분을 마스킹합니다. + * 마지막 4자리만 표시하고 나머지는 *로 마스킹합니다. + * 예: "4111-1234-5678-9010" -> "****-****-****-9010" + * "4111123456789010" -> "************9010" + *

    + * + * @return 마스킹된 카드 번호 (cardNo가 null이거나 비어있으면 null 반환) + */ + public String maskedCardNo() { + if (cardNo == null || cardNo.isBlank()) { + return null; + } + + // 숫자만 추출 + String digitsOnly = cardNo.replaceAll("[^0-9]", ""); + + if (digitsOnly.length() < 4) { + // 카드 번호가 너무 짧으면 전체 마스킹 + return "****"; + } + + // 마지막 4자리 추출 + String lastFour = digitsOnly.substring(digitsOnly.length() - 4); + + // 원본에 하이픈이 있었다면 하이픈 패턴 유지 + if (cardNo.contains("-")) { + // 하이픈으로 구분된 각 부분 처리 + String[] parts = cardNo.split("-"); + StringBuilder result = new StringBuilder(); + + for (int i = 0; i < parts.length; i++) { + if (i > 0) { + result.append("-"); + } + + String part = parts[i].replaceAll("[^0-9]", ""); + if (i == parts.length - 1 && part.length() >= 4) { + // 마지막 부분은 마지막 4자리만 표시 + result.append("*".repeat(part.length() - 4)).append(lastFour); + } else { + // 중간 부분은 모두 마스킹 + result.append("*".repeat(part.length())); + } + } + return result.toString(); + } else { + // 하이픈이 없으면 마스킹된 부분과 마지막 4자리만 반환 + int maskedLength = digitsOnly.length() - 4; + return "*".repeat(maskedLength) + lastFour; + } + } + + /** + * 로깅 및 이벤트 저장 시 사용할 수 있도록 마스킹된 정보를 포함한 문자열을 반환합니다. + *

    + * PII 보호를 위해 cardNo는 마스킹된 버전으로 출력됩니다. + *

    + * + * @return 마스킹된 정보를 포함한 문자열 표현 + */ + @Override + public String toString() { + return String.format( + "PaymentRequested[orderId=%d, userId='%s', userEntityId=%d, totalAmount=%d, usedPointAmount=%d, cardType='%s', cardNo='%s', occurredAt=%s]", + orderId, + userId, + userEntityId, + totalAmount, + usedPointAmount, + cardType, + maskedCardNo(), + occurredAt + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentEventPublisher.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentEventPublisher.java new file mode 100644 index 000000000..f8f6e2687 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentEventPublisher.java @@ -0,0 +1,35 @@ +package com.loopers.domain.payment; + +/** + * 결제 도메인 이벤트 발행 인터페이스. + *

    + * DIP를 준수하여 도메인 레이어에서 이벤트 발행 인터페이스를 정의합니다. + * 구현은 인프라 레이어에서 제공됩니다. + *

    + * + * @author Loopers + * @version 1.0 + */ +public interface PaymentEventPublisher { + + /** + * 결제 완료 이벤트를 발행합니다. + * + * @param event 결제 완료 이벤트 + */ + void publish(PaymentEvent.PaymentCompleted event); + + /** + * 결제 실패 이벤트를 발행합니다. + * + * @param event 결제 실패 이벤트 + */ + void publish(PaymentEvent.PaymentFailed event); + + /** + * 결제 요청 이벤트를 발행합니다. + * + * @param event 결제 요청 이벤트 + */ + void publish(PaymentEvent.PaymentRequested event); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentFailureType.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentFailureType.java new file mode 100644 index 000000000..4a41411aa --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentFailureType.java @@ -0,0 +1,80 @@ +package com.loopers.domain.payment; + +import java.util.Set; + +/** + * 결제 실패 유형. + *

    + * 결제 실패를 비즈니스 실패와 외부 시스템 장애로 구분합니다. + *

    + * + * @author Loopers + * @version 1.0 + */ +public enum PaymentFailureType { + /** + * 비즈니스 실패: 주문 취소 필요 + * 예: 카드 한도 초과, 잘못된 카드 번호 등 + */ + BUSINESS_FAILURE, + + /** + * 외부 시스템 장애: 주문 PENDING 상태 유지 + * 예: CircuitBreaker Open, 서버 오류, 타임아웃 등 + */ + EXTERNAL_SYSTEM_FAILURE; + + private static final Set BUSINESS_FAILURE_CODES = Set.of( + "LIMIT_EXCEEDED", + "INVALID_CARD", + "CARD_ERROR", + "INSUFFICIENT_FUNDS", + "PAYMENT_FAILED" + ); + + private static final String CIRCUIT_BREAKER_OPEN = "CIRCUIT_BREAKER_OPEN"; + + /** + * 오류 코드를 기반으로 결제 실패 유형을 분류합니다. + *

    + * 비즈니스 실패 예시: + *

      + *
    • 카드 한도 초과 (LIMIT_EXCEEDED)
    • + *
    • 잘못된 카드 번호 (INVALID_CARD)
    • + *
    • 카드 오류 (CARD_ERROR)
    • + *
    • 잔액 부족 (INSUFFICIENT_FUNDS)
    • + *
    + *

    + *

    + * 외부 시스템 장애 예시: + *

      + *
    • CircuitBreaker Open (CIRCUIT_BREAKER_OPEN)
    • + *
    • 서버 오류 (5xx)
    • + *
    • 타임아웃
    • + *
    • 네트워크 오류
    • + *
    + *

    + * + * @param errorCode 오류 코드 + * @return 결제 실패 유형 + */ + public static PaymentFailureType classify(String errorCode) { + if (errorCode == null) { + return EXTERNAL_SYSTEM_FAILURE; + } + + // CircuitBreaker Open 상태는 명시적으로 외부 시스템 장애로 간주 + if (CIRCUIT_BREAKER_OPEN.equals(errorCode)) { + return EXTERNAL_SYSTEM_FAILURE; + } + + // 명확한 비즈니스 실패 오류 코드만 취소 처리 + boolean isBusinessFailure = BUSINESS_FAILURE_CODES.stream() + .anyMatch(errorCode::contains); + + return isBusinessFailure + ? BUSINESS_FAILURE + : EXTERNAL_SYSTEM_FAILURE; + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentGateway.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentGateway.java new file mode 100644 index 000000000..a2d42ca88 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentGateway.java @@ -0,0 +1,35 @@ +package com.loopers.domain.payment; + +/** + * 결제 게이트웨이 인터페이스. + *

    + * 도메인 계층에 정의하여 DIP를 준수합니다. + * 인프라 계층이 이 인터페이스를 구현합니다. + *

    + * + * @author Loopers + * @version 1.0 + */ +public interface PaymentGateway { + /** + * PG 결제 요청을 전송합니다. + * + * @param request 결제 요청 값 객체 + * @return 결제 요청 결과 + */ + PaymentRequestResult requestPayment(PaymentRequest request); + + /** + * 결제 상태를 조회합니다. + *

    + * 주의: userId는 PG 시스템이 요구하는 사용자 식별자(String)입니다. + * 도메인 모델의 User.id(Long)와는 다른 값입니다. + *

    + * + * @param userId 사용자 ID (PG 시스템이 요구하는 String 형식의 사용자 식별자) + * @param orderId 주문 ID + * @return 결제 상태 + */ + PaymentStatus getPaymentStatus(String userId, Long orderId); +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java new file mode 100644 index 000000000..7d3a73868 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java @@ -0,0 +1,54 @@ +package com.loopers.domain.payment; + +import java.util.List; +import java.util.Optional; + +/** + * 결제 저장소 인터페이스. + *

    + * Payment 엔티티의 영속성 계층 접근을 추상화합니다. + *

    + */ +public interface PaymentRepository { + + /** + * 결제를 저장합니다. + * + * @param payment 저장할 결제 + * @return 저장된 결제 + */ + Payment save(Payment payment); + + /** + * 결제 ID로 결제를 조회합니다. + * + * @param paymentId 조회할 결제 ID + * @return 조회된 결제 + */ + Optional findById(Long paymentId); + + /** + * 주문 ID로 결제를 조회합니다. + * + * @param orderId 주문 ID + * @return 조회된 결제 + */ + Optional findByOrderId(Long orderId); + + /** + * 사용자 ID로 결제 목록을 조회합니다. + * + * @param userId 사용자 ID + * @return 해당 사용자의 결제 목록 + */ + List findAllByUserId(Long userId); + + /** + * 결제 상태로 결제 목록을 조회합니다. + * + * @param status 결제 상태 + * @return 해당 상태의 결제 목록 + */ + List findAllByStatus(PaymentStatus status); +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRequest.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRequest.java new file mode 100644 index 000000000..4aa51dc7e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRequest.java @@ -0,0 +1,54 @@ +package com.loopers.domain.payment; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +/** + * 결제 요청 값 객체. + *

    + * PG 결제 요청에 필요한 정보를 담는 도메인 값 객체입니다. + * 도메인 계층에 위치하여 DIP를 준수합니다. + *

    + * + * @author Loopers + * @version 1.0 + */ +public record PaymentRequest( + String userId, + Long orderId, + String cardType, + String cardNo, + Long amount, + String callbackUrl +) { + public PaymentRequest { + if (userId == null || userId.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "userId는 필수입니다."); + } + if (orderId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "orderId는 필수입니다."); + } + if (cardType == null || cardType.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "cardType은 필수입니다."); + } + if (cardNo == null || cardNo.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "cardNo는 필수입니다."); + } + if (amount == null || amount <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "amount는 0보다 커야 합니다."); + } + if (callbackUrl == null || callbackUrl.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "callbackUrl은 필수입니다."); + } + } + + @Override + public String toString() { + String maskedCardNo = cardNo != null && cardNo.length() > 4 + ? "****" + cardNo.substring(cardNo.length() - 4) + : "****"; + return "PaymentRequest[userId=%s, orderId=%d, cardType=%s, cardNo=%s, amount=%d, callbackUrl=%s]" + .formatted(userId, orderId, cardType, maskedCardNo, amount, callbackUrl); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRequestResult.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRequestResult.java new file mode 100644 index 000000000..9c62dee8c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRequestResult.java @@ -0,0 +1,30 @@ +package com.loopers.domain.payment; + +/** + * 결제 요청 결과. + *

    + * PG 결제 요청의 결과를 나타내는 도메인 모델입니다. + *

    + * + * @author Loopers + * @version 1.0 + */ +public sealed interface PaymentRequestResult { + /** + * 결제 요청 성공. + * + * @param transactionKey 트랜잭션 키 + */ + record Success(String transactionKey) implements PaymentRequestResult {} + + /** + * 결제 요청 실패. + * + * @param errorCode 오류 코드 + * @param message 오류 메시지 + * @param isTimeout 타임아웃 여부 + * @param isRetryable 재시도 가능 여부 + */ + record Failure(String errorCode, String message, boolean isTimeout, boolean isRetryable) implements PaymentRequestResult {} +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/PaymentResult.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentResult.java similarity index 97% rename from apps/commerce-api/src/main/java/com/loopers/domain/order/PaymentResult.java rename to apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentResult.java index f1c953c70..792362f02 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/PaymentResult.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentResult.java @@ -1,4 +1,4 @@ -package com.loopers.domain.order; +package com.loopers.domain.payment; import java.util.function.Function; 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..7335929c3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentStatus.java @@ -0,0 +1,29 @@ +package com.loopers.domain.payment; + +/** + * 결제 상태. + */ +public enum PaymentStatus { + PENDING, + SUCCESS, + FAILED; + + /** + * 결제가 완료되었는지 확인합니다. + * + * @return 완료 여부 (SUCCESS 또는 FAILED) + */ + public boolean isCompleted() { + return this == SUCCESS || this == FAILED; + } + + /** + * 결제가 성공했는지 확인합니다. + * + * @return 성공 여부 + */ + public boolean isSuccess() { + return this == SUCCESS; + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java index 83363f71c..3ad8c8e7c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -177,10 +177,42 @@ private void validateQuantity(Integer quantity) { } } + /** + * 좋아요 수를 증가시킵니다. + *

    + * 이벤트 기반 집계에서 사용됩니다. + *

    + * + * @throws CoreException 좋아요 수가 음수가 되는 경우 + */ + public void incrementLikeCount() { + if (this.likeCount < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "좋아요 수는 음수가 될 수 없습니다."); + } + this.likeCount++; + } + + /** + * 좋아요 수를 감소시킵니다. + *

    + * 이벤트 기반 집계에서 사용됩니다. + *

    + *

    + * 멱등성 보장: 좋아요 수가 0인 경우에도 예외를 던지지 않고 그대로 유지합니다. + * 이는 동시성 상황에서 이미 삭제된 좋아요에 대한 이벤트가 중복 처리될 수 있기 때문입니다. + *

    + */ + public void decrementLikeCount() { + if (this.likeCount > 0) { + this.likeCount--; + } + // likeCount가 0인 경우는 이미 삭제된 상태이므로 그대로 유지 (멱등성 보장) + } + /** * 좋아요 수를 업데이트합니다. *

    - * 비동기 집계 스케줄러에서 사용됩니다. + * 배치 집계나 초기화 시 사용됩니다. *

    * * @param likeCount 업데이트할 좋아요 수 (0 이상) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/PointEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/PointEvent.java new file mode 100644 index 000000000..8bba3b8a1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/PointEvent.java @@ -0,0 +1,114 @@ +package com.loopers.domain.user; + +import java.time.LocalDateTime; + +/** + * 포인트 도메인 이벤트. + *

    + * 포인트 도메인의 중요한 상태 변화를 나타내는 이벤트들입니다. + *

    + */ +public class PointEvent { + + /** + * 포인트 사용 이벤트. + *

    + * 주문에서 포인트를 사용할 때 발행되는 이벤트입니다. + *

    + * + * @param orderId 주문 ID + * @param userId 사용자 ID (Long - User.id) + * @param usedPointAmount 사용할 포인트 금액 + * @param occurredAt 이벤트 발생 시각 + */ + public record PointUsed( + Long orderId, + Long userId, + Long usedPointAmount, + LocalDateTime occurredAt + ) { + public PointUsed { + if (orderId == null) { + throw new IllegalArgumentException("orderId는 필수입니다."); + } + if (userId == null) { + throw new IllegalArgumentException("userId는 필수입니다."); + } + if (usedPointAmount == null || usedPointAmount < 0) { + throw new IllegalArgumentException("usedPointAmount는 0 이상이어야 합니다."); + } + } + + /** + * OrderCreated 이벤트로부터 PointUsed 이벤트를 생성합니다. + * + * @param orderId 주문 ID + * @param userId 사용자 ID + * @param usedPointAmount 사용할 포인트 금액 + * @return PointUsed 이벤트 + */ + public static PointUsed of(Long orderId, Long userId, Long usedPointAmount) { + return new PointUsed( + orderId, + userId, + usedPointAmount, + LocalDateTime.now() + ); + } + } + + /** + * 포인트 사용 실패 이벤트. + *

    + * 포인트 사용에 실패했을 때 발행되는 이벤트입니다. + *

    + * + * @param orderId 주문 ID + * @param userId 사용자 ID (Long - User.id) + * @param usedPointAmount 사용 요청 포인트 금액 + * @param failureReason 실패 사유 + * @param failedAt 실패 시각 + */ + public record PointUsedFailed( + Long orderId, + Long userId, + Long usedPointAmount, + String failureReason, + LocalDateTime failedAt + ) { + public PointUsedFailed { + if (orderId == null) { + throw new IllegalArgumentException("orderId는 필수입니다."); + } + if (userId == null) { + throw new IllegalArgumentException("userId는 필수입니다."); + } + if (usedPointAmount == null || usedPointAmount < 0) { + throw new IllegalArgumentException("usedPointAmount는 0 이상이어야 합니다."); + } + if (failureReason == null || failureReason.isBlank()) { + throw new IllegalArgumentException("failureReason는 필수입니다."); + } + } + + /** + * 포인트 사용 실패 정보로부터 PointUsedFailed 이벤트를 생성합니다. + * + * @param orderId 주문 ID + * @param userId 사용자 ID + * @param usedPointAmount 사용 요청 포인트 금액 + * @param failureReason 실패 사유 + * @return PointUsedFailed 이벤트 + */ + public static PointUsedFailed of(Long orderId, Long userId, Long usedPointAmount, String failureReason) { + return new PointUsedFailed( + orderId, + userId, + usedPointAmount, + failureReason, + LocalDateTime.now() + ); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/PointEventPublisher.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/PointEventPublisher.java new file mode 100644 index 000000000..8b01a7bca --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/PointEventPublisher.java @@ -0,0 +1,29 @@ +package com.loopers.domain.user; + +/** + * 포인트 도메인 이벤트 발행 인터페이스. + *

    + * DIP를 준수하여 도메인 레이어에서 이벤트 발행 인터페이스를 정의합니다. + * 구현은 인프라 레이어에서 제공됩니다. + *

    + * + * @author Loopers + * @version 1.0 + */ +public interface PointEventPublisher { + + /** + * 포인트 사용 이벤트를 발행합니다. + * + * @param event 포인트 사용 이벤트 + */ + void publish(PointEvent.PointUsed event); + + /** + * 포인트 사용 실패 이벤트를 발행합니다. + * + * @param event 포인트 사용 실패 이벤트 + */ + void publish(PointEvent.PointUsedFailed event); +} + 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 96dadff67..b3ed48869 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 @@ -166,4 +166,13 @@ public void deductPoint(Point point) { this.point = this.point.subtract(point); } + /** + * 포인트 값을 반환합니다. + * + * @return 포인트 값 + */ + public Long getPointValue() { + return this.point.getValue(); + } + } 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 88ac6434c..1a057041f 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 @@ -52,4 +52,23 @@ public interface UserRepository { * @return 조회된 사용자, 없으면 null */ User findById(Long id); + + /** + * 사용자 ID (PK)로 사용자를 조회합니다. (비관적 락) + *

    + * 동시성 제어가 필요한 경우 사용합니다. (예: 포인트 차감) + *

    + *

    + * Lock 전략: + *

      + *
    • PESSIMISTIC_WRITE: SELECT ... FOR UPDATE 사용
    • + *
    • Lock 범위: PK(id) 기반 조회로 해당 행만 락 (최소화)
    • + *
    • 사용 목적: 포인트 차감 시 Lost Update 방지
    • + *
    + *

    + * + * @param id 사용자 ID (PK) + * @return 조회된 사용자, 없으면 null + */ + User findByIdForUpdate(Long 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 deleted file mode 100644 index 6621a8b62..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ /dev/null @@ -1,46 +0,0 @@ -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; - -/** - * 사용자 도메인 서비스. - *

    - * 사용자 생성 등의 도메인 로직을 처리합니다. - * Repository에 의존하며 데이터 무결성 제약 조건을 처리합니다. - *

    - * - * @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, Point point) { - User user = User.of(userId, email, birthDateStr, gender, point); - 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, "데이터 무결성 제약 조건 위반"); - } - } - -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponEventPublisherImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponEventPublisherImpl.java new file mode 100644 index 000000000..7e7c4c8c0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponEventPublisherImpl.java @@ -0,0 +1,35 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.CouponEvent; +import com.loopers.domain.coupon.CouponEventPublisher; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +/** + * CouponEventPublisher 인터페이스의 구현체. + *

    + * Spring ApplicationEventPublisher를 사용하여 쿠폰 이벤트를 발행합니다. + * DIP를 준수하여 도메인 인터페이스를 구현합니다. + *

    + * + * @author Loopers + * @version 1.0 + */ +@Component +@RequiredArgsConstructor +public class CouponEventPublisherImpl implements CouponEventPublisher { + + private final ApplicationEventPublisher applicationEventPublisher; + + @Override + public void publish(CouponEvent.CouponApplied event) { + applicationEventPublisher.publishEvent(event); + } + + @Override + public void publish(CouponEvent.CouponApplicationFailed event) { + applicationEventPublisher.publishEvent(event); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/UserCouponRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/UserCouponRepositoryImpl.java index 8daaf5567..314eb130d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/UserCouponRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/UserCouponRepositoryImpl.java @@ -45,5 +45,13 @@ public Optional findByUserIdAndCouponCode(Long userId, String coupon public Optional findByUserIdAndCouponCodeForUpdate(Long userId, String couponCode) { return userCouponJpaRepository.findByUserIdAndCouponCodeForUpdate(userId, couponCode); } + + /** + * {@inheritDoc} + */ + @Override + public void flush() { + userCouponJpaRepository.flush(); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeEventPublisherImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeEventPublisherImpl.java new file mode 100644 index 000000000..ad27ee294 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeEventPublisherImpl.java @@ -0,0 +1,35 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.LikeEvent; +import com.loopers.domain.like.LikeEventPublisher; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +/** + * LikeEventPublisher 인터페이스의 구현체. + *

    + * Spring ApplicationEventPublisher를 사용하여 좋아요 이벤트를 발행합니다. + * DIP를 준수하여 도메인 인터페이스를 구현합니다. + *

    + * + * @author Loopers + * @version 1.0 + */ +@Component +@RequiredArgsConstructor +public class LikeEventPublisherImpl implements LikeEventPublisher { + + private final ApplicationEventPublisher applicationEventPublisher; + + @Override + public void publish(LikeEvent.LikeAdded event) { + applicationEventPublisher.publishEvent(event); + } + + @Override + public void publish(LikeEvent.LikeRemoved event) { + applicationEventPublisher.publishEvent(event); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderEventPublisherImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderEventPublisherImpl.java new file mode 100644 index 000000000..526c4dbb8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderEventPublisherImpl.java @@ -0,0 +1,39 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderEvent; +import com.loopers.domain.order.OrderEventPublisher; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +/** + * OrderEventPublisher 인터페이스의 구현체. + *

    + * Spring ApplicationEventPublisher를 사용하여 주문 이벤트를 발행합니다. + * DIP를 준수하여 도메인 인터페이스를 구현합니다. + *

    + * + * @author Loopers + * @version 1.0 + */ +@Component +@RequiredArgsConstructor +public class OrderEventPublisherImpl implements OrderEventPublisher { + + private final ApplicationEventPublisher applicationEventPublisher; + + @Override + public void publish(OrderEvent.OrderCreated event) { + applicationEventPublisher.publishEvent(event); + } + + @Override + public void publish(OrderEvent.OrderCompleted event) { + applicationEventPublisher.publishEvent(event); + } + + @Override + public void publish(OrderEvent.OrderCanceled event) { + applicationEventPublisher.publishEvent(event); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java index 0c91bd190..2e435b981 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -1,6 +1,7 @@ package com.loopers.infrastructure.order; import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderStatus; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; @@ -11,7 +12,7 @@ public interface OrderJpaRepository extends JpaRepository { List findAllByUserId(Long userId); - List findAllByStatus(com.loopers.domain.order.OrderStatus status); + List findAllByStatus(OrderStatus status); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java index 763d6e927..e6158698f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -2,6 +2,7 @@ import com.loopers.domain.order.Order; import com.loopers.domain.order.OrderRepository; +import com.loopers.domain.order.OrderStatus; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; @@ -33,7 +34,7 @@ public List findAllByUserId(Long userId) { } @Override - public List findAllByStatus(com.loopers.domain.order.OrderStatus status) { + public List findAllByStatus(OrderStatus status) { return orderJpaRepository.findAllByStatus(status); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/DelayProvider.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/DelayProvider.java similarity index 89% rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/DelayProvider.java rename to apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/DelayProvider.java index 11cc69f71..22fabd259 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/DelayProvider.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/DelayProvider.java @@ -1,4 +1,4 @@ -package com.loopers.infrastructure.paymentgateway; +package com.loopers.infrastructure.payment; import java.time.Duration; @@ -21,4 +21,3 @@ public interface DelayProvider { */ void delay(Duration duration) throws InterruptedException; } - diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentEventPublisherImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentEventPublisherImpl.java new file mode 100644 index 000000000..dfdfca597 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentEventPublisherImpl.java @@ -0,0 +1,39 @@ +package com.loopers.infrastructure.payment; + +import com.loopers.domain.payment.PaymentEvent; +import com.loopers.domain.payment.PaymentEventPublisher; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +/** + * PaymentEventPublisher 인터페이스의 구현체. + *

    + * Spring ApplicationEventPublisher를 사용하여 결제 이벤트를 발행합니다. + * DIP를 준수하여 도메인 인터페이스를 구현합니다. + *

    + * + * @author Loopers + * @version 1.0 + */ +@Component +@RequiredArgsConstructor +public class PaymentEventPublisherImpl implements PaymentEventPublisher { + + private final ApplicationEventPublisher applicationEventPublisher; + + @Override + public void publish(PaymentEvent.PaymentCompleted event) { + applicationEventPublisher.publishEvent(event); + } + + @Override + public void publish(PaymentEvent.PaymentFailed event) { + applicationEventPublisher.publishEvent(event); + } + + @Override + public void publish(PaymentEvent.PaymentRequested event) { + applicationEventPublisher.publishEvent(event); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayClient.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewayClient.java similarity index 98% rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayClient.java rename to apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewayClient.java index 9357ec6d3..cf6e1b2d6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayClient.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewayClient.java @@ -1,4 +1,4 @@ -package com.loopers.infrastructure.paymentgateway; +package com.loopers.infrastructure.payment; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.GetMapping; @@ -82,4 +82,3 @@ PaymentGatewayDto.ApiResponse getTransactionsBy @RequestParam("orderId") String orderId ); } - diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayDto.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewayDto.java similarity index 98% rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayDto.java rename to apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewayDto.java index 812fbed96..4ca22424f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayDto.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewayDto.java @@ -1,4 +1,4 @@ -package com.loopers.infrastructure.paymentgateway; +package com.loopers.infrastructure.payment; import com.fasterxml.jackson.annotation.JsonProperty; @@ -103,4 +103,3 @@ public enum Result { } } } - diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayAdapter.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewayImpl.java similarity index 66% rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayAdapter.java rename to apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewayImpl.java index 2084f2139..8eddd36da 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayAdapter.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewayImpl.java @@ -1,15 +1,18 @@ -package com.loopers.infrastructure.paymentgateway; +package com.loopers.infrastructure.payment; -import com.loopers.application.purchasing.PaymentRequest; -import com.loopers.domain.order.PaymentResult; +import com.loopers.domain.payment.PaymentGateway; +import com.loopers.domain.payment.PaymentRequest; +import com.loopers.domain.payment.PaymentRequestResult; +import com.loopers.domain.payment.PaymentStatus; import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; /** - * 결제 게이트웨이 어댑터. + * PaymentGateway 인터페이스의 구현체. *

    + * 도메인 계층의 PaymentGateway 인터페이스를 구현합니다. * 인프라 관심사(FeignClient 호출, 예외 처리)를 도메인 모델로 변환합니다. *

    * @@ -19,20 +22,21 @@ @Slf4j @Component @RequiredArgsConstructor -public class PaymentGatewayAdapter { +public class PaymentGatewayImpl implements PaymentGateway { private final PaymentGatewayClient paymentGatewayClient; private final PaymentGatewaySchedulerClient paymentGatewaySchedulerClient; private final PaymentGatewayMetrics metrics; /** - * 결제 요청을 전송합니다. + * PG 결제 요청을 전송합니다. * - * @param request 결제 요청 - * @return 결제 결과 (성공 또는 실패) + * @param request 결제 요청 값 객체 + * @return 결제 요청 결과 */ + @Override @CircuitBreaker(name = "pgCircuit", fallbackMethod = "fallback") - public PaymentResult requestPayment(PaymentRequest request) { + public PaymentRequestResult requestPayment(PaymentRequest request) { PaymentGatewayDto.PaymentRequest dtoRequest = toDto(request); PaymentGatewayDto.ApiResponse response = paymentGatewayClient.requestPayment(request.userId(), dtoRequest); @@ -43,19 +47,18 @@ public PaymentResult requestPayment(PaymentRequest request) { /** * Circuit Breaker fallback 메서드. * - * @param request 결제 요청 + * @param request 결제 요청 값 객체 * @param t 발생한 예외 * @return 결제 대기 상태의 실패 결과 */ - public PaymentResult fallback(PaymentRequest request, Throwable t) { + public PaymentRequestResult fallback(PaymentRequest request, Throwable t) { log.warn("Circuit Breaker fallback 호출됨. (orderId: {}, exception: {})", request.orderId(), t.getClass().getSimpleName(), t); metrics.recordFallback("paymentGatewayClient"); - return new PaymentResult.Failure( + return new PaymentRequestResult.Failure( "CIRCUIT_BREAKER_OPEN", "결제 대기 상태", false, - false, false ); } @@ -68,11 +71,11 @@ public PaymentResult fallback(PaymentRequest request, Throwable t) { * @param t 발생한 예외 * @return PENDING 상태 반환 */ - public PaymentGatewayDto.TransactionStatus getPaymentStatusFallback(String userId, String orderId, Throwable t) { + public PaymentStatus getPaymentStatusFallback(String userId, Long orderId, Throwable t) { log.warn("Circuit Breaker fallback 호출됨 (결제 상태 조회). (orderId: {}, exception: {})", orderId, t.getClass().getSimpleName(), t); metrics.recordFallback("paymentGatewaySchedulerClient"); - return PaymentGatewayDto.TransactionStatus.PENDING; + return PaymentStatus.PENDING; } /** @@ -82,45 +85,47 @@ public PaymentGatewayDto.TransactionStatus getPaymentStatusFallback(String userI * @param orderId 주문 ID * @return 결제 상태 (SUCCESS, FAILED, PENDING) */ + @Override @CircuitBreaker(name = "pgCircuit", fallbackMethod = "getPaymentStatusFallback") - public PaymentGatewayDto.TransactionStatus getPaymentStatus(String userId, String orderId) { + public PaymentStatus getPaymentStatus(String userId, Long orderId) { + // 주문 ID를 6자리 이상 문자열로 변환 (pg-simulator 검증 요구사항) + String orderIdString = String.format("%06d", orderId); PaymentGatewayDto.ApiResponse response = - paymentGatewaySchedulerClient.getTransactionsByOrder(userId, orderId); + paymentGatewaySchedulerClient.getTransactionsByOrder(userId, orderIdString); if (response == null || response.meta() == null || response.meta().result() != PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS || response.data() == null || response.data().transactions() == null || response.data().transactions().isEmpty()) { - return PaymentGatewayDto.TransactionStatus.PENDING; + return PaymentStatus.PENDING; } // 가장 최근 트랜잭션의 상태 반환 PaymentGatewayDto.TransactionResponse latestTransaction = response.data().transactions().get(response.data().transactions().size() - 1); - return latestTransaction.status(); + return convertToPaymentStatus(latestTransaction.status()); } private PaymentGatewayDto.PaymentRequest toDto(PaymentRequest request) { return new PaymentGatewayDto.PaymentRequest( - request.orderId(), - request.cardType(), + String.format("%06d", request.orderId()), // 주문 ID를 6자리 이상 문자열로 변환 + PaymentGatewayDto.CardType.valueOf(request.cardType().toUpperCase()), request.cardNo(), request.amount(), request.callbackUrl() ); } - private PaymentResult toDomainResult( + private PaymentRequestResult toDomainResult( PaymentGatewayDto.ApiResponse response, - String orderId + Long orderId ) { if (response != null && response.meta() != null && response.meta().result() == PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS && response.data() != null) { String transactionKey = response.data().transactionKey(); - log.info("PG 결제 요청 성공. (orderId: {}, transactionKey: {})", orderId, transactionKey); metrics.recordSuccess("paymentGatewayClient"); - return new PaymentResult.Success(transactionKey); + return new PaymentRequestResult.Success(transactionKey); } else { String errorCode = response != null && response.meta() != null ? response.meta().errorCode() : "UNKNOWN"; @@ -128,9 +133,16 @@ private PaymentResult toDomainResult( ? response.meta().message() : "응답이 null입니다."; log.warn("PG 결제 요청 실패. (orderId: {}, errorCode: {}, message: {})", orderId, errorCode, message); - return new PaymentResult.Failure(errorCode, message, false, false, false); + return new PaymentRequestResult.Failure(errorCode, message, false, false); } } + private PaymentStatus convertToPaymentStatus(PaymentGatewayDto.TransactionStatus status) { + return switch (status) { + case SUCCESS -> PaymentStatus.SUCCESS; + case FAILED -> PaymentStatus.FAILED; + case PENDING -> PaymentStatus.PENDING; + }; + } + } - diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayMetrics.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewayMetrics.java similarity index 97% rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayMetrics.java rename to apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewayMetrics.java index 57c97df10..72bc0b96b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayMetrics.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewayMetrics.java @@ -1,4 +1,4 @@ -package com.loopers.infrastructure.paymentgateway; +package com.loopers.infrastructure.payment; import io.micrometer.core.instrument.MeterRegistry; import lombok.RequiredArgsConstructor; @@ -83,4 +83,3 @@ public void recordSuccess(String clientName) { ).increment(); } } - diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewaySchedulerClient.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewaySchedulerClient.java similarity index 97% rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewaySchedulerClient.java rename to apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewaySchedulerClient.java index 44d693912..01451ab62 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewaySchedulerClient.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentGatewaySchedulerClient.java @@ -1,4 +1,4 @@ -package com.loopers.infrastructure.paymentgateway; +package com.loopers.infrastructure.payment; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.GetMapping; @@ -67,4 +67,3 @@ PaymentGatewayDto.ApiResponse getTransactionsBy @RequestParam("orderId") String orderId ); } - diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java new file mode 100644 index 000000000..a34757237 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java @@ -0,0 +1,20 @@ +package com.loopers.infrastructure.payment; + +import com.loopers.domain.payment.Payment; +import com.loopers.domain.payment.PaymentStatus; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +/** + * Payment JPA Repository. + */ +public interface PaymentJpaRepository extends JpaRepository { + Optional findByOrderId(Long orderId); + + List findAllByUserId(Long userId); + + List findAllByStatus(PaymentStatus status); +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java new file mode 100644 index 000000000..9b38ff60a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java @@ -0,0 +1,46 @@ +package com.loopers.infrastructure.payment; + +import com.loopers.domain.payment.Payment; +import com.loopers.domain.payment.PaymentRepository; +import com.loopers.domain.payment.PaymentStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +/** + * Payment Repository 구현체. + */ +@Repository +@RequiredArgsConstructor +public class PaymentRepositoryImpl implements PaymentRepository { + + private final PaymentJpaRepository paymentJpaRepository; + + @Override + public Payment save(Payment payment) { + return paymentJpaRepository.save(payment); + } + + @Override + public Optional findById(Long paymentId) { + return paymentJpaRepository.findById(paymentId); + } + + @Override + public Optional findByOrderId(Long orderId) { + return paymentJpaRepository.findByOrderId(orderId); + } + + @Override + public List findAllByUserId(Long userId) { + return paymentJpaRepository.findAllByUserId(userId); + } + + @Override + public List findAllByStatus(PaymentStatus status) { + return paymentJpaRepository.findAllByStatus(status); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/ThreadDelayProvider.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/ThreadDelayProvider.java similarity index 88% rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/ThreadDelayProvider.java rename to apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/ThreadDelayProvider.java index 803a6f304..d31ef49d9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/ThreadDelayProvider.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/ThreadDelayProvider.java @@ -1,4 +1,4 @@ -package com.loopers.infrastructure.paymentgateway; +package com.loopers.infrastructure.payment; import org.springframework.stereotype.Component; @@ -18,4 +18,3 @@ public void delay(Duration duration) throws InterruptedException { Thread.sleep(duration.toMillis()); } } - diff --git a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRecoveryScheduler.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/PaymentRecoveryScheduler.java similarity index 92% rename from apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRecoveryScheduler.java rename to apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/PaymentRecoveryScheduler.java index 6502a9cce..048e91755 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRecoveryScheduler.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/PaymentRecoveryScheduler.java @@ -1,5 +1,6 @@ -package com.loopers.application.purchasing; +package com.loopers.infrastructure.scheduler; +import com.loopers.application.purchasing.PurchasingFacade; import com.loopers.domain.order.Order; import com.loopers.domain.order.OrderRepository; import com.loopers.domain.order.OrderStatus; @@ -35,6 +36,13 @@ *
  • 성능 고려: 배치로 처리하여 PG 시스템 부하 최소화
  • * *

    + *

    + * 레이어 위치 근거: + *

      + *
    • 스케줄링은 기술적 관심사이므로 Infrastructure Layer에 위치
    • + *
    • 비즈니스 로직은 Application Layer의 PurchasingFacade에 위임
    • + *
    + *

    * * @author Loopers * @version 1.0 diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/PointEventPublisherImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/PointEventPublisherImpl.java new file mode 100644 index 000000000..5bed86f2e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/PointEventPublisherImpl.java @@ -0,0 +1,35 @@ +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.PointEvent; +import com.loopers.domain.user.PointEventPublisher; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +/** + * PointEventPublisher 인터페이스의 구현체. + *

    + * Spring ApplicationEventPublisher를 사용하여 포인트 이벤트를 발행합니다. + * DIP를 준수하여 도메인 인터페이스를 구현합니다. + *

    + * + * @author Loopers + * @version 1.0 + */ +@Component +@RequiredArgsConstructor +public class PointEventPublisherImpl implements PointEventPublisher { + + private final ApplicationEventPublisher applicationEventPublisher; + + @Override + public void publish(PointEvent.PointUsed event) { + applicationEventPublisher.publishEvent(event); + } + + @Override + public void publish(PointEvent.PointUsedFailed event) { + applicationEventPublisher.publishEvent(event); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java index 2de935469..a69a17925 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java @@ -57,4 +57,25 @@ public interface UserJpaRepository extends JpaRepository { @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("SELECT u FROM User u WHERE u.userId = :userId") Optional findByUserIdForUpdate(@Param("userId") String userId); + + /** + * 사용자 ID (PK)로 사용자를 조회합니다. (비관적 락) + *

    + * SELECT ... FOR UPDATE를 사용하여 동시성 제어를 보장합니다. + *

    + *

    + * Lock 전략: + *

      + *
    • PESSIMISTIC_WRITE 선택 근거: 포인트 차감 시 Lost Update 방지
    • + *
    • Lock 범위 최소화: PK(id) 기반 조회로 해당 행만 락
    • + *
    • 인덱스 활용: PK는 자동으로 인덱스가 생성되어 Lock 범위 최소화
    • + *
    + *

    + * + * @param id 사용자 ID (PK) + * @return 조회된 사용자를 담은 Optional + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT u FROM User u WHERE u.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); } 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 defb715e9..8beff4c6d 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 @@ -51,4 +51,12 @@ public User findByUserIdForUpdate(String userId) { public User findById(Long id) { return userJpaRepository.findById(id).orElse(null); } + + /** + * {@inheritDoc} + */ + @Override + public User findByIdForUpdate(Long id) { + return userJpaRepository.findByIdForUpdate(id).orElse(null); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/BrandV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/BrandV1Controller.java index a56cc1c63..e62516e29 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/BrandV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/BrandV1Controller.java @@ -1,6 +1,6 @@ package com.loopers.interfaces.api.catalog; -import com.loopers.application.catalog.CatalogBrandFacade; +import com.loopers.application.brand.BrandService; import com.loopers.interfaces.api.ApiResponse; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; @@ -22,7 +22,7 @@ @RequestMapping("/api/v1/brands") public class BrandV1Controller { - private final CatalogBrandFacade catalogBrandFacade; + private final BrandService brandService; /** * 브랜드 정보를 조회합니다. @@ -32,7 +32,7 @@ public class BrandV1Controller { */ @GetMapping("/{brandId}") public ApiResponse getBrand(@PathVariable Long brandId) { - CatalogBrandFacade.BrandInfo brandInfo = catalogBrandFacade.getBrand(brandId); + BrandService.BrandInfo brandInfo = brandService.getBrandInfo(brandId); return ApiResponse.success(BrandV1Dto.BrandResponse.from(brandInfo)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/BrandV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/BrandV1Dto.java index 2bc497615..53e8fa13a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/BrandV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/BrandV1Dto.java @@ -1,6 +1,6 @@ package com.loopers.interfaces.api.catalog; -import com.loopers.application.catalog.CatalogBrandFacade; +import com.loopers.application.brand.BrandService; /** * 브랜드 조회 API v1의 데이터 전송 객체(DTO) 컨테이너. @@ -22,7 +22,7 @@ public record BrandResponse(Long brandId, String name) { * @param brandInfo 브랜드 정보 * @return 생성된 응답 객체 */ - public static BrandResponse from(CatalogBrandFacade.BrandInfo brandInfo) { + public static BrandResponse from(BrandService.BrandInfo brandInfo) { return new BrandResponse(brandInfo.id(), brandInfo.name()); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/ProductV1Controller.java index 4dc38d439..c275b3b7d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/ProductV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/ProductV1Controller.java @@ -1,6 +1,6 @@ package com.loopers.interfaces.api.catalog; -import com.loopers.application.catalog.CatalogProductFacade; +import com.loopers.application.catalog.CatalogFacade; import com.loopers.application.catalog.ProductInfo; import com.loopers.application.catalog.ProductInfoList; import com.loopers.interfaces.api.ApiResponse; @@ -25,7 +25,7 @@ @RequestMapping("/api/v1/products") public class ProductV1Controller { - private final CatalogProductFacade catalogProductFacade; + private final CatalogFacade catalogFacade; /** * 상품 목록을 조회합니다. @@ -43,7 +43,7 @@ public ApiResponse getProducts( @RequestParam(required = false, defaultValue = "0") int page, @RequestParam(required = false, defaultValue = "20") int size ) { - ProductInfoList result = catalogProductFacade.getProducts(brandId, sort, page, size); + ProductInfoList result = catalogFacade.getProducts(brandId, sort, page, size); return ApiResponse.success(ProductV1Dto.ProductsResponse.from(result)); } @@ -55,7 +55,7 @@ public ApiResponse getProducts( */ @GetMapping("/{productId}") public ApiResponse getProduct(@PathVariable Long productId) { - ProductInfo productInfo = catalogProductFacade.getProduct(productId); + ProductInfo productInfo = catalogFacade.getProduct(productId); return ApiResponse.success(ProductV1Dto.ProductResponse.from(productInfo)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java index 2935b424d..640c909e3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java @@ -1,6 +1,6 @@ package com.loopers.interfaces.api.like; -import com.loopers.application.like.LikeFacade; +import com.loopers.application.heart.HeartFacade; import com.loopers.interfaces.api.ApiResponse; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.DeleteMapping; @@ -25,7 +25,7 @@ @RequestMapping("/api/v1/like/products") public class LikeV1Controller { - private final LikeFacade likeFacade; + private final HeartFacade heartFacade; /** * 상품에 좋아요를 추가합니다. @@ -39,7 +39,7 @@ public ApiResponse addLike( @RequestHeader("X-USER-ID") String userId, @PathVariable Long productId ) { - likeFacade.addLike(userId, productId); + heartFacade.addLike(userId, productId); return ApiResponse.success(null); } @@ -55,7 +55,7 @@ public ApiResponse removeLike( @RequestHeader("X-USER-ID") String userId, @PathVariable Long productId ) { - likeFacade.removeLike(userId, productId); + heartFacade.removeLike(userId, productId); return ApiResponse.success(null); } @@ -69,7 +69,7 @@ public ApiResponse removeLike( public ApiResponse getLikedProducts( @RequestHeader("X-USER-ID") String userId ) { - var likedProducts = likeFacade.getLikedProducts(userId); + var likedProducts = heartFacade.getLikedProducts(userId); return ApiResponse.success(LikeV1Dto.LikedProductsResponse.from(likedProducts)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java index 1fc6f20f0..e154c036b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java @@ -1,6 +1,6 @@ package com.loopers.interfaces.api.like; -import com.loopers.application.like.LikeFacade; +import com.loopers.application.heart.HeartFacade; import java.util.List; @@ -25,7 +25,7 @@ public record LikedProductsResponse( * @param likedProducts 좋아요한 상품 목록 * @return 생성된 응답 객체 */ - public static LikedProductsResponse from(List likedProducts) { + public static LikedProductsResponse from(List likedProducts) { return new LikedProductsResponse( likedProducts.stream() .map(LikedProductResponse::from) @@ -58,7 +58,7 @@ public record LikedProductResponse( * @param likedProduct 좋아요한 상품 정보 * @return 생성된 응답 객체 */ - public static LikedProductResponse from(LikeFacade.LikedProduct likedProduct) { + public static LikedProductResponse from(HeartFacade.LikedProduct likedProduct) { return new LikedProductResponse( likedProduct.productId(), likedProduct.name(), diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/pointwallet/PointWalletV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/pointwallet/PointWalletV1Controller.java index 4efc043ca..e0ffa6f26 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/pointwallet/PointWalletV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/pointwallet/PointWalletV1Controller.java @@ -1,6 +1,6 @@ package com.loopers.interfaces.api.pointwallet; -import com.loopers.application.pointwallet.PointWalletFacade; +import com.loopers.application.user.UserService; import com.loopers.interfaces.api.ApiResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -25,7 +25,7 @@ @RequestMapping("/api/v1") public class PointWalletV1Controller { - private final PointWalletFacade pointWalletFacade; + private final UserService userService; /** * 현재 사용자의 포인트를 조회합니다. @@ -38,7 +38,7 @@ public class PointWalletV1Controller { public ApiResponse getMyPoints( @RequestHeader("X-USER-ID") String userId ) { - PointWalletFacade.PointsInfo pointsInfo = pointWalletFacade.getPoints(userId); + UserService.PointsInfo pointsInfo = userService.getPoints(userId); return ApiResponse.success(PointWalletV1Dto.PointsResponse.from(pointsInfo)); } @@ -55,7 +55,7 @@ public ApiResponse chargePoints( @RequestHeader("X-USER-ID") String userId, @Valid @RequestBody PointWalletV1Dto.ChargeRequest request ) { - PointWalletFacade.PointsInfo pointsInfo = pointWalletFacade.chargePoint(userId, request.amount()); + UserService.PointsInfo pointsInfo = userService.chargePoint(userId, request.amount()); return ApiResponse.success(PointWalletV1Dto.PointsResponse.from(pointsInfo)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/pointwallet/PointWalletV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/pointwallet/PointWalletV1Dto.java index 461605598..c6e7c97b6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/pointwallet/PointWalletV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/pointwallet/PointWalletV1Dto.java @@ -1,6 +1,6 @@ package com.loopers.interfaces.api.pointwallet; -import com.loopers.application.pointwallet.PointWalletFacade; +import com.loopers.application.user.UserService; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; @@ -24,7 +24,7 @@ public record PointsResponse(String userId, Long balance) { * @param pointsInfo 포인트 정보 * @return 생성된 응답 객체 */ - public static PointsResponse from(PointWalletFacade.PointsInfo pointsInfo) { + public static PointsResponse from(UserService.PointsInfo pointsInfo) { return new PointsResponse(pointsInfo.userId(), pointsInfo.balance()); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Controller.java index fcea74ac2..937a9cf20 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Controller.java @@ -2,7 +2,7 @@ import com.loopers.application.purchasing.OrderInfo; import com.loopers.application.purchasing.PurchasingFacade; -import com.loopers.infrastructure.paymentgateway.PaymentGatewayDto; +import com.loopers.infrastructure.payment.PaymentGatewayDto; import com.loopers.interfaces.api.ApiResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -41,6 +41,7 @@ public ApiResponse createOrder( OrderInfo orderInfo = purchasingFacade.createOrder( userId, request.toCommands(), + request.payment().usedPoint(), request.payment().cardType(), request.payment().cardNo() ); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Dto.java index e1307ca42..f2e552d09 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Dto.java @@ -35,10 +35,9 @@ public List toCommands() { * 결제 정보 요청 DTO. */ public record PaymentRequest( - @NotNull(message = "카드 타입은 필수입니다.") - String cardType, - @NotNull(message = "카드 번호는 필수입니다.") - String cardNo + Long usedPoint, // 포인트 사용량 (선택, 기본값: 0) + String cardType, // 카드 타입 (paidAmount > 0일 때만 필수) + String cardNo // 카드 번호 (paidAmount > 0일 때만 필수) ) { } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/signup/SignUpV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/signup/SignUpV1Controller.java index 3ce1a8c6e..4945b507d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/signup/SignUpV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/signup/SignUpV1Controller.java @@ -1,8 +1,12 @@ package com.loopers.interfaces.api.signup; -import com.loopers.application.signup.SignUpFacade; -import com.loopers.application.signup.SignUpInfo; +import com.loopers.application.user.UserService; +import com.loopers.domain.user.Gender; +import com.loopers.domain.user.Point; +import com.loopers.domain.user.User; 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.PostMapping; @@ -24,7 +28,7 @@ @RequestMapping("/api/v1/signup") public class SignUpV1Controller { - private final SignUpFacade signUpFacade; + private final UserService userService; /** * 회원가입을 처리합니다. @@ -37,13 +41,33 @@ public class SignUpV1Controller { public ApiResponse signUp( @Valid @RequestBody SignUpV1Dto.SignUpRequest request ) { - SignUpInfo info = signUpFacade.signUp( + Gender gender = parseGender(request.gender()); + User user = userService.create( request.userId(), request.email(), request.birthDate(), - request.gender() + gender, + Point.of(0L) ); - SignUpV1Dto.SignupResponse response = SignUpV1Dto.SignupResponse.from(info); + SignUpV1Dto.SignupResponse response = SignUpV1Dto.SignupResponse.from(user); return ApiResponse.success(response); } + + /** + * 성별 문자열을 Gender enum으로 변환합니다. + * + * @param genderStr 성별 문자열 (MALE 또는 FEMALE) + * @return Gender enum + * @throws CoreException 유효하지 않은 성별 값인 경우 + */ + private Gender parseGender(String genderStr) { + try { + return Gender.valueOf(genderStr.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new CoreException( + ErrorType.BAD_REQUEST, + String.format("유효하지 않은 성별입니다. (gender: %s)", genderStr) + ); + } + } } 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 index afcfeae33..caa4cc492 100644 --- 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 @@ -1,6 +1,6 @@ package com.loopers.interfaces.api.signup; -import com.loopers.application.signup.SignUpInfo; +import com.loopers.domain.user.User; import jakarta.validation.constraints.NotBlank; /** @@ -36,18 +36,18 @@ public record SignUpRequest( */ public record SignupResponse(Long id, String userId, String email, String birthDate, String gender) { /** - * SignUpInfo로부터 SignupResponse를 생성합니다. + * User 엔티티로부터 SignupResponse를 생성합니다. * - * @param info 회원가입 정보 + * @param user 사용자 엔티티 * @return 생성된 응답 객체 */ - public static SignupResponse from(SignUpInfo info) { + public static SignupResponse from(User user) { return new SignupResponse( - info.id(), - info.userId(), - info.email(), - info.birthDate().toString(), - info.gender() != null ? info.gender().name() : null + user.getId(), + user.getUserId(), + user.getEmail(), + user.getBirthDate().toString(), + user.getGender() != null ? user.getGender().name() : null ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/userinfo/UserInfoV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/userinfo/UserInfoV1Controller.java index a125747e0..4b716b745 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/userinfo/UserInfoV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/userinfo/UserInfoV1Controller.java @@ -1,6 +1,7 @@ package com.loopers.interfaces.api.userinfo; -import com.loopers.application.userinfo.UserInfoFacade; +import com.loopers.application.user.UserService; +import com.loopers.domain.user.User; import com.loopers.interfaces.api.ApiResponse; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; @@ -22,21 +23,21 @@ @RequestMapping("/api/v1") public class UserInfoV1Controller { - private final UserInfoFacade userInfoFacade; + private final UserService userService; /** * 현재 사용자의 정보를 조회합니다. * * @param userId X-USER-ID 헤더로 전달된 사용자 ID * @return 사용자 정보를 담은 API 응답 - * @throws CoreException 사용자를 찾을 수 없는 경우 + * @throws com.loopers.support.error.CoreException 사용자를 찾을 수 없는 경우 */ @GetMapping("/me") public ApiResponse getMyInfo( @RequestHeader("X-USER-ID") String userId ) { - UserInfoFacade.UserInfo userInfo = userInfoFacade.getUserInfo(userId); - return ApiResponse.success(UserInfoV1Dto.UserInfoResponse.from(userInfo)); + User user = userService.getUser(userId); + 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 index 7a8987aca..d79ce9b6a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/userinfo/UserInfoV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/userinfo/UserInfoV1Dto.java @@ -1,6 +1,6 @@ package com.loopers.interfaces.api.userinfo; -import com.loopers.application.userinfo.UserInfoFacade; +import com.loopers.domain.user.User; /** * 사용자 정보 조회 API v1의 데이터 전송 객체(DTO) 컨테이너. @@ -24,17 +24,17 @@ public record UserInfoResponse( String gender ) { /** - * UserInfo로부터 UserInfoResponse를 생성합니다. + * User 엔티티로부터 UserInfoResponse를 생성합니다. * - * @param userInfo 사용자 정보 + * @param user 사용자 엔티티 * @return 생성된 응답 객체 */ - public static UserInfoResponse from(UserInfoFacade.UserInfo userInfo) { + public static UserInfoResponse from(User user) { return new UserInfoResponse( - userInfo.userId(), - userInfo.email(), - userInfo.birthDate().toString(), - userInfo.gender() != null ? userInfo.gender().name() : null + user.getUserId(), + user.getEmail(), + user.getBirthDate().toString(), + user.getGender() != null ? user.getGender().name() : null ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/event/coupon/CouponEventListener.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/event/coupon/CouponEventListener.java new file mode 100644 index 000000000..afd36ab0b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/event/coupon/CouponEventListener.java @@ -0,0 +1,84 @@ +package com.loopers.interfaces.event.coupon; + +import com.loopers.application.coupon.CouponEventHandler; +import com.loopers.domain.coupon.CouponEvent; +import com.loopers.domain.coupon.CouponEventPublisher; +import com.loopers.domain.order.OrderEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 쿠폰 이벤트 리스너. + *

    + * 주문 생성 이벤트를 받아서 쿠폰 사용 처리를 수행하는 인터페이스 레이어의 어댑터입니다. + *

    + *

    + * 레이어 역할: + *

      + *
    • 인터페이스 레이어: 외부 이벤트(도메인 이벤트)를 받아서 애플리케이션 핸들러를 호출하는 어댑터
    • + *
    • 비즈니스 로직 없음: 단순히 이벤트를 받아서 애플리케이션 핸들러를 호출하는 역할만 수행
    • + *
    + *

    + * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CouponEventListener { + + private final CouponEventHandler couponEventHandler; + private final CouponEventPublisher couponEventPublisher; + + /** + * 주문 생성 이벤트를 처리합니다. + *

    + * 트랜잭션 커밋 후 비동기로 실행되어 쿠폰 사용 처리를 수행합니다. + *

    + * + * @param event 주문 생성 이벤트 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleOrderCreated(OrderEvent.OrderCreated event) { + try { + couponEventHandler.handleOrderCreated(event); + } catch (Exception e) { + // ✅ 도메인 이벤트 발행: 쿠폰 적용이 실패했음 (과거 사실) + // 이벤트 핸들러에서 예외가 발생했으므로 실패 이벤트를 발행 + + // Optimistic Locking 실패는 정상적인 동시성 제어 결과이므로 별도 처리 + String failureReason; + if (e instanceof ObjectOptimisticLockingFailureException || + e instanceof OptimisticLockingFailureException) { + failureReason = "쿠폰이 이미 사용되었습니다. (동시성 충돌)"; + } else { + failureReason = e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName(); + } + + couponEventPublisher.publish(CouponEvent.CouponApplicationFailed.of( + event.orderId(), + event.userId(), + event.couponCode(), + failureReason + )); + + // Optimistic Locking 실패는 정상적인 동시성 제어 결과이므로 WARN 레벨로 로깅 + if (e instanceof ObjectOptimisticLockingFailureException || + e instanceof OptimisticLockingFailureException) { + log.warn("쿠폰 사용 중 낙관적 락 충돌 발생. (orderId: {}, couponCode: {})", + event.orderId(), event.couponCode()); + } else { + log.error("주문 생성 이벤트 처리 중 오류 발생. (orderId: {})", event.orderId(), e); + } + // 이벤트 처리 실패는 다른 리스너에 영향을 주지 않도록 예외를 삼킴 + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/event/data/DataEventListener.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/event/data/DataEventListener.java new file mode 100644 index 000000000..1cb371d46 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/event/data/DataEventListener.java @@ -0,0 +1,81 @@ +package com.loopers.interfaces.event.data; + +import com.loopers.domain.order.OrderEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 데이터 플랫폼 전송 이벤트 리스너. + *

    + * 주문 완료/취소 이벤트를 받아 데이터 플랫폼에 전송합니다. + *

    + *

    + * 트랜잭션 전략: + *

      + *
    • AFTER_COMMIT: 주문 트랜잭션이 커밋된 후에 실행되어 데이터 일관성 보장
    • + *
    • @Async: 비동기로 실행하여 주문 처리 성능에 영향을 주지 않음
    • + *
    + *

    + *

    + * 주의사항: + *

      + *
    • 데이터 플랫폼 전송 실패는 로그만 기록 (주문 처리에는 영향 없음)
    • + *
    • 재시도는 외부 시스템(메시지 큐 등)에서 처리하거나 별도 스케줄러로 처리
    • + *
    + *

    + * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class DataEventListener { + + // TODO: 데이터 플랫폼 전송 클라이언트 주입 + // private final DataPlatformClient dataPlatformClient; + + /** + * 주문 완료 이벤트를 처리하여 데이터 플랫폼에 전송합니다. + * + * @param event 주문 완료 이벤트 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleOrderCompleted(OrderEvent.OrderCompleted event) { + try { + // TODO: 데이터 플랫폼에 주문 완료 데이터 전송 + // dataPlatformClient.sendOrderCompleted(event); + + log.info("주문 완료 데이터 플랫폼 전송 완료. (orderId: {}, userId: {}, totalAmount: {})", + event.orderId(), event.userId(), event.totalAmount()); + } catch (Exception e) { + // 데이터 플랫폼 전송 실패는 로그만 기록 + log.error("주문 완료 데이터 플랫폼 전송 중 오류 발생. (orderId: {})", event.orderId(), e); + } + } + + /** + * 주문 취소 이벤트를 처리하여 데이터 플랫폼에 전송합니다. + * + * @param event 주문 취소 이벤트 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleOrderCanceled(OrderEvent.OrderCanceled event) { + try { + // TODO: 데이터 플랫폼에 주문 취소 데이터 전송 + // dataPlatformClient.sendOrderCanceled(event); + + log.info("주문 취소 데이터 플랫폼 전송 완료. (orderId: {}, userId: {}, reason: {})", + event.orderId(), event.userId(), event.reason()); + } catch (Exception e) { + // 데이터 플랫폼 전송 실패는 로그만 기록 + log.error("주문 취소 데이터 플랫폼 전송 중 오류 발생. (orderId: {})", event.orderId(), e); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/event/order/OrderEventListener.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/event/order/OrderEventListener.java new file mode 100644 index 000000000..ec3001f3c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/event/order/OrderEventListener.java @@ -0,0 +1,92 @@ +package com.loopers.interfaces.event.order; + +import com.loopers.application.order.OrderEventHandler; +import com.loopers.domain.coupon.CouponEvent; +import com.loopers.domain.payment.PaymentEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 주문 이벤트 리스너. + *

    + * 결제 완료/실패 이벤트와 쿠폰 적용 이벤트를 받아서 주문 상태를 업데이트하는 인터페이스 레이어의 어댑터입니다. + *

    + *

    + * 레이어 역할: + *

      + *
    • 인터페이스 레이어: 외부 이벤트(도메인 이벤트)를 받아서 애플리케이션 핸들러를 호출하는 어댑터
    • + *
    • 비즈니스 로직 없음: 단순히 이벤트를 받아서 애플리케이션 핸들러를 호출하는 역할만 수행
    • + *
    + *

    + * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class OrderEventListener { + + private final OrderEventHandler orderEventHandler; + + /** + * 결제 완료 이벤트를 처리합니다. + *

    + * 트랜잭션 커밋 후 실행되어 주문 상태를 COMPLETED로 업데이트합니다. + *

    + * + * @param event 결제 완료 이벤트 + */ + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handlePaymentCompleted(PaymentEvent.PaymentCompleted event) { + try { + orderEventHandler.handlePaymentCompleted(event); + } catch (Exception e) { + log.error("결제 완료 이벤트 처리 중 오류 발생. (orderId: {})", event.orderId(), e); + // 이벤트 처리 실패는 다른 리스너에 영향을 주지 않도록 예외를 삼킴 + } + } + + /** + * 쿠폰 적용 이벤트를 처리합니다. + *

    + * 트랜잭션 커밋 후 비동기로 실행되어 주문에 할인 금액을 적용합니다. + *

    + * + * @param event 쿠폰 적용 이벤트 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleCouponApplied(CouponEvent.CouponApplied event) { + try { + orderEventHandler.handleCouponApplied(event); + } catch (Exception e) { + log.error("쿠폰 적용 이벤트 처리 중 오류 발생. (orderId: {}, couponCode: {})", + event.orderId(), event.couponCode(), e); + // 이벤트 처리 실패는 다른 리스너에 영향을 주지 않도록 예외를 삼킴 + } + } + + /** + * 결제 실패 이벤트를 처리합니다. + *

    + * 트랜잭션 커밋 후 비동기로 실행되어 주문 취소 처리를 수행합니다. + *

    + * + * @param event 결제 실패 이벤트 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handlePaymentFailed(PaymentEvent.PaymentFailed event) { + try { + orderEventHandler.handlePaymentFailed(event); + } catch (Exception e) { + log.error("결제 실패 이벤트 처리 중 오류 발생. (orderId: {})", event.orderId(), e); + // 이벤트 처리 실패는 다른 리스너에 영향을 주지 않도록 예외를 삼킴 + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/event/payment/PaymentEventListener.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/event/payment/PaymentEventListener.java new file mode 100644 index 000000000..a3d6f6202 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/event/payment/PaymentEventListener.java @@ -0,0 +1,76 @@ +package com.loopers.interfaces.event.payment; + +import com.loopers.application.payment.PaymentEventHandler; +import com.loopers.domain.coupon.CouponEvent; +import com.loopers.domain.payment.PaymentEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 결제 이벤트 리스너. + *

    + * 결제 요청 이벤트를 받아서 Payment 생성 및 PG 결제 요청 처리를 수행하는 인터페이스 레이어의 어댑터입니다. + *

    + *

    + * 레이어 역할: + *

      + *
    • 인터페이스 레이어: 외부 이벤트(도메인 이벤트)를 받아서 애플리케이션 핸들러를 호출하는 어댑터
    • + *
    • 비즈니스 로직 없음: 단순히 이벤트를 받아서 애플리케이션 핸들러를 호출하는 역할만 수행
    • + *
    + *

    + * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PaymentEventListener { + + private final PaymentEventHandler paymentEventHandler; + + /** + * 결제 요청 이벤트를 처리합니다. + *

    + * 트랜잭션 커밋 후 비동기로 실행되어 Payment 생성 및 PG 결제 요청 처리를 수행합니다. + *

    + * + * @param event 결제 요청 이벤트 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handlePaymentRequested(PaymentEvent.PaymentRequested event) { + try { + paymentEventHandler.handlePaymentRequested(event); + } catch (Exception e) { + log.error("결제 요청 이벤트 처리 중 오류 발생. (orderId: {})", event.orderId(), e); + // 이벤트 처리 실패는 다른 리스너에 영향을 주지 않도록 예외를 삼킴 + } + } + + /** + * 쿠폰 적용 이벤트를 처리합니다. + *

    + * 트랜잭션 커밋 후 비동기로 실행되어 결제 금액에 쿠폰 할인을 적용합니다. + *

    + * + * @param event 쿠폰 적용 이벤트 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleCouponApplied(CouponEvent.CouponApplied event) { + try { + paymentEventHandler.handleCouponApplied(event); + } catch (Exception e) { + log.error("쿠폰 적용 이벤트 처리 중 오류 발생. (orderId: {}, couponCode: {})", + event.orderId(), event.couponCode(), e); + // 이벤트 처리 실패는 다른 리스너에 영향을 주지 않도록 예외를 삼킴 + } + } + +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/event/product/ProductEventListener.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/event/product/ProductEventListener.java new file mode 100644 index 000000000..d38dc84e5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/event/product/ProductEventListener.java @@ -0,0 +1,131 @@ +package com.loopers.interfaces.event.product; + +import com.loopers.application.product.ProductEventHandler; +import com.loopers.domain.like.LikeEvent; +import com.loopers.domain.order.OrderEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 상품 이벤트 리스너. + *

    + * 좋아요 추가/취소 이벤트와 주문 생성/취소 이벤트를 받아서 상품의 좋아요 수 및 재고를 업데이트하는 인터페이스 레이어의 어댑터입니다. + *

    + *

    + * 레이어 역할: + *

      + *
    • 인터페이스 레이어: 외부 이벤트(도메인 이벤트)를 받아서 애플리케이션 핸들러를 호출하는 어댑터
    • + *
    • 비즈니스 로직 없음: 단순히 이벤트를 받아서 애플리케이션 핸들러를 호출하는 역할만 수행
    • + *
    + *

    + *

    + * EDA 원칙: + *

      + *
    • 느슨한 결합: HeartFacade는 이 리스너의 존재를 모름
    • + *
    • 비동기 처리: @Async로 집계 처리를 비동기로 실행
    • + *
    • 이벤트 기반: 좋아요 추가/취소 이벤트를 구독하여 상품의 좋아요 수 업데이트
    • + *
    + *

    + *

    + * 집계 전략: + *

      + *
    • 이벤트 기반 실시간 집계: 좋아요 추가/취소 시 즉시 Product.likeCount 업데이트
    • + *
    • Strong Consistency: 이벤트 기반으로 실시간 반영
    • + *
    + *

    + * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ProductEventListener { + + private final ProductEventHandler productEventHandler; + + /** + * 좋아요 추가 이벤트를 처리합니다. + *

    + * 트랜잭션 커밋 후 비동기로 실행되어 상품의 좋아요 수를 증가시킵니다. + *

    + * + * @param event 좋아요 추가 이벤트 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(LikeEvent.LikeAdded event) { + try { + productEventHandler.handleLikeAdded(event); + } catch (Exception e) { + log.error("좋아요 추가 이벤트 처리 중 오류 발생: productId={}, userId={}", + event.productId(), event.userId(), e); + // 이벤트 처리 실패는 다른 리스너에 영향을 주지 않도록 예외를 삼킴 + } + } + + /** + * 좋아요 취소 이벤트를 처리합니다. + *

    + * 트랜잭션 커밋 후 비동기로 실행되어 상품의 좋아요 수를 감소시킵니다. + *

    + * + * @param event 좋아요 취소 이벤트 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(LikeEvent.LikeRemoved event) { + try { + productEventHandler.handleLikeRemoved(event); + } catch (Exception e) { + log.error("좋아요 취소 이벤트 처리 중 오류 발생: productId={}, userId={}", + event.productId(), event.userId(), e); + // 이벤트 처리 실패는 다른 리스너에 영향을 주지 않도록 예외를 삼킴 + } + } + + /** + * 주문 생성 이벤트를 처리합니다. + *

    + * 주문 생성과 같은 트랜잭션 내에서 동기적으로 실행되어 재고를 차감합니다. + * 재고 차감은 민감한 영역이므로 하나의 트랜잭션으로 실행되어야 합니다. + *

    + * + * @param event 주문 생성 이벤트 + */ + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void handleOrderCreated(OrderEvent.OrderCreated event) { + try { + productEventHandler.handleOrderCreated(event); + } catch (Exception e) { + log.error("주문 생성 이벤트 처리 중 오류 발생. (orderId: {})", event.orderId(), e); + // 재고 차감 실패 시 주문 생성도 롤백되어야 하므로 예외를 다시 던짐 + throw e; + } + } + + /** + * 주문 취소 이벤트를 처리합니다. + *

    + * 주문 취소와 같은 트랜잭션 내에서 동기적으로 실행되어 재고를 원복합니다. + * 재고 원복은 민감한 영역이므로 하나의 트랜잭션으로 실행되어야 합니다. + *

    + * + * @param event 주문 취소 이벤트 + */ + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void handleOrderCanceled(OrderEvent.OrderCanceled event) { + try { + productEventHandler.handleOrderCanceled(event); + } catch (Exception e) { + log.error("주문 취소 이벤트 처리 중 오류 발생. (orderId: {})", event.orderId(), e); + // 재고 원복 실패 시 주문 취소도 롤백되어야 하므로 예외를 다시 던짐 + throw e; + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/event/user/PointEventListener.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/event/user/PointEventListener.java new file mode 100644 index 000000000..c221c01b6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/event/user/PointEventListener.java @@ -0,0 +1,73 @@ +package com.loopers.interfaces.event.user; + +import com.loopers.application.user.PointEventHandler; +import com.loopers.domain.order.OrderEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 포인트 이벤트 리스너. + *

    + * 포인트 사용 이벤트와 주문 취소 이벤트를 받아서 포인트 사용/환불 처리를 수행하는 인터페이스 레이어의 어댑터입니다. + *

    + *

    + * 레이어 역할: + *

      + *
    • 인터페이스 레이어: 외부 이벤트(도메인 이벤트)를 받아서 애플리케이션 핸들러를 호출하는 어댑터
    • + *
    • 비즈니스 로직 없음: 단순히 이벤트를 받아서 애플리케이션 핸들러를 호출하는 역할만 수행
    • + *
    + *

    + * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PointEventListener { + + private final PointEventHandler pointEventHandler; + + /** + * 주문 생성 이벤트를 처리합니다. + *

    + * 트랜잭션 커밋 후 비동기로 실행되어 포인트 사용 처리를 수행합니다. + *

    + * + * @param event 주문 생성 이벤트 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleOrderCreated(OrderEvent.OrderCreated event) { + try { + pointEventHandler.handleOrderCreated(event); + } catch (Exception e) { + log.error("주문 생성 이벤트 처리 중 오류 발생. (orderId: {})", event.orderId(), e); + // 이벤트 처리 실패는 다른 리스너에 영향을 주지 않도록 예외를 삼킴 + } + } + + /** + * 주문 취소 이벤트를 처리합니다. + *

    + * 트랜잭션 커밋 후 비동기로 실행되어 포인트 환불 처리를 수행합니다. + *

    + * + * @param event 주문 취소 이벤트 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleOrderCanceled(OrderEvent.OrderCanceled event) { + try { + pointEventHandler.handleOrderCanceled(event); + } catch (Exception e) { + log.error("주문 취소 이벤트 처리 중 오류 발생. (orderId: {})", event.orderId(), e); + // 이벤트 처리 실패는 다른 리스너에 영향을 주지 않도록 예외를 삼킴 + } + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponEventHandlerTest.java b/apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponEventHandlerTest.java new file mode 100644 index 000000000..cc7867966 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/coupon/CouponEventHandlerTest.java @@ -0,0 +1,301 @@ +package com.loopers.application.coupon; + +import com.loopers.domain.coupon.Coupon; +import com.loopers.domain.coupon.CouponEvent; +import com.loopers.domain.coupon.CouponRepository; +import com.loopers.domain.coupon.CouponType; +import com.loopers.domain.coupon.UserCoupon; +import com.loopers.domain.coupon.UserCouponRepository; +import com.loopers.domain.order.OrderEvent; +import com.loopers.domain.user.Gender; +import com.loopers.domain.user.Point; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.event.ApplicationEvents; +import org.springframework.test.context.event.RecordApplicationEvents; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@DisplayName("CouponEventHandler 쿠폰 적용 검증") +@RecordApplicationEvents +class CouponEventHandlerTest { + + @Autowired + private CouponEventHandler couponEventHandler; + + @Autowired + private UserRepository userRepository; + + @Autowired + private CouponRepository couponRepository; + + @Autowired + private UserCouponRepository userCouponRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private ApplicationEvents applicationEvents; + + // ✅ OrderEventListener를 Mocking하여 CouponEventHandlerTest에서 주문 관련 로직이 실행되지 않도록 함 + // CouponEventHandlerTest는 쿠폰 도메인의 책임만 테스트해야 하므로 주문 관련 로직은 제외 + @MockitoBean + private com.loopers.interfaces.event.order.OrderEventListener orderEventListener; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + // Helper methods for test fixtures + private User createAndSaveUser(String userId, String email, long point) { + User user = User.of(userId, email, "1990-01-01", Gender.MALE, Point.of(point)); + return userRepository.save(user); + } + + private Coupon createAndSaveCoupon(String code, CouponType type, Integer discountValue) { + Coupon coupon = Coupon.of(code, type, discountValue); + return couponRepository.save(coupon); + } + + private UserCoupon createAndSaveUserCoupon(Long userId, Coupon coupon) { + UserCoupon userCoupon = UserCoupon.of(userId, coupon); + return userCouponRepository.save(userCoupon); + } + + @Test + @DisplayName("쿠폰 코드가 없으면 처리하지 않는다") + void handleOrderCreated_skips_whenNoCouponCode() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + OrderEvent.OrderCreated event = new OrderEvent.OrderCreated( + 1L, + user.getId(), + null, // 쿠폰 코드 없음 + 10_000, + 0L, + List.of(), + LocalDateTime.now() + ); + + // act + // CouponEventHandler를 직접 호출하여 테스트 + couponEventHandler.handleOrderCreated(event); + + // assert + // 예외 없이 처리되어야 함 + } + + @Test + @DisplayName("정액 쿠폰을 정상적으로 적용할 수 있다") + void handleOrderCreated_appliesFixedAmountCoupon_success() throws InterruptedException { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + Coupon coupon = createAndSaveCoupon("FIXED5000", CouponType.FIXED_AMOUNT, 5_000); + createAndSaveUserCoupon(user.getId(), coupon); + + OrderEvent.OrderCreated event = new OrderEvent.OrderCreated( + 1L, + user.getId(), + "FIXED5000", + 10_000, + 0L, + List.of(), + LocalDateTime.now() + ); + + // act + // CouponEventHandler를 직접 호출하여 테스트 + couponEventHandler.handleOrderCreated(event); + + // assert + // 쿠폰 적용 성공 이벤트가 발행되었는지 확인 + assertThat(applicationEvents.stream(CouponEvent.CouponApplied.class)).hasSize(1); + CouponEvent.CouponApplied appliedEvent = applicationEvents.stream(CouponEvent.CouponApplied.class) + .findFirst() + .orElseThrow(); + assertThat(appliedEvent.orderId()).isEqualTo(1L); + assertThat(appliedEvent.userId()).isEqualTo(user.getId()); + assertThat(appliedEvent.couponCode()).isEqualTo("FIXED5000"); + assertThat(appliedEvent.discountAmount()).isEqualTo(5_000); + + // 쿠폰 적용 실패 이벤트는 발행되지 않아야 함 + assertThat(applicationEvents.stream(CouponEvent.CouponApplicationFailed.class)).isEmpty(); + } + + @Test + @DisplayName("정률 쿠폰을 정상적으로 적용할 수 있다") + void handleOrderCreated_appliesPercentageCoupon_success() throws InterruptedException { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + Coupon coupon = createAndSaveCoupon("PERCENT20", CouponType.PERCENTAGE, 20); + createAndSaveUserCoupon(user.getId(), coupon); + + OrderEvent.OrderCreated event = new OrderEvent.OrderCreated( + 1L, + user.getId(), + "PERCENT20", + 10_000, + 0L, + List.of(), + LocalDateTime.now() + ); + + // act + // CouponEventHandler를 직접 호출하여 테스트 + couponEventHandler.handleOrderCreated(event); + + // assert + // 쿠폰 적용 성공 이벤트가 발행되었는지 확인 + assertThat(applicationEvents.stream(CouponEvent.CouponApplied.class)).hasSize(1); + CouponEvent.CouponApplied appliedEvent = applicationEvents.stream(CouponEvent.CouponApplied.class) + .findFirst() + .orElseThrow(); + assertThat(appliedEvent.orderId()).isEqualTo(1L); + assertThat(appliedEvent.userId()).isEqualTo(user.getId()); + assertThat(appliedEvent.couponCode()).isEqualTo("PERCENT20"); + assertThat(appliedEvent.discountAmount()).isEqualTo(2_000); // 10,000 * 20% = 2,000 + + // 쿠폰 적용 실패 이벤트는 발행되지 않아야 함 + assertThat(applicationEvents.stream(CouponEvent.CouponApplicationFailed.class)).isEmpty(); + + // 쿠폰이 사용되었는지 확인 + UserCoupon savedUserCoupon = userCouponRepository.findByUserIdAndCouponCode(user.getId(), "PERCENT20") + .orElseThrow(); + assertThat(savedUserCoupon.getIsUsed()).isTrue(); + } + + @Test + @DisplayName("존재하지 않는 쿠폰 코드로 주문하면 쿠폰 적용 실패 이벤트가 발행된다") + void handleOrderCreated_publishesFailedEvent_whenCouponNotFound() throws InterruptedException { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + OrderEvent.OrderCreated event = new OrderEvent.OrderCreated( + 1L, + user.getId(), + "NON_EXISTENT", + 10_000, + 0L, + List.of(), + LocalDateTime.now() + ); + + // act + // CouponEventHandler를 직접 호출하여 테스트 + try { + couponEventHandler.handleOrderCreated(event); + } catch (Exception e) { + // 예외는 예상된 동작 + } + + // assert + // 쿠폰 적용 실패 이벤트가 발행되었는지 확인 + assertThat(applicationEvents.stream(CouponEvent.CouponApplicationFailed.class)).hasSize(1); + CouponEvent.CouponApplicationFailed failedEvent = applicationEvents.stream(CouponEvent.CouponApplicationFailed.class) + .findFirst() + .orElseThrow(); + assertThat(failedEvent.orderId()).isEqualTo(1L); + assertThat(failedEvent.userId()).isEqualTo(user.getId()); + assertThat(failedEvent.couponCode()).isEqualTo("NON_EXISTENT"); + assertThat(failedEvent.failureReason()).contains("쿠폰을 찾을 수 없습니다"); + + // 쿠폰 적용 성공 이벤트는 발행되지 않아야 함 + assertThat(applicationEvents.stream(CouponEvent.CouponApplied.class)).isEmpty(); + } + + @Test + @DisplayName("사용자가 소유하지 않은 쿠폰으로 주문하면 쿠폰 적용 실패 이벤트가 발행된다") + void handleOrderCreated_publishesFailedEvent_whenCouponNotOwnedByUser() throws InterruptedException { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + createAndSaveCoupon("COUPON001", CouponType.FIXED_AMOUNT, 5_000); + // 사용자에게 쿠폰을 지급하지 않음 + + OrderEvent.OrderCreated event = new OrderEvent.OrderCreated( + 1L, + user.getId(), + "COUPON001", + 10_000, + 0L, + List.of(), + LocalDateTime.now() + ); + + // act + // CouponEventHandler를 직접 호출하여 테스트 + try { + couponEventHandler.handleOrderCreated(event); + } catch (Exception e) { + // 예외는 예상된 동작 + } + + // assert + // 쿠폰 적용 실패 이벤트가 발행되었는지 확인 + assertThat(applicationEvents.stream(CouponEvent.CouponApplicationFailed.class)).hasSize(1); + CouponEvent.CouponApplicationFailed failedEvent = applicationEvents.stream(CouponEvent.CouponApplicationFailed.class) + .findFirst() + .orElseThrow(); + assertThat(failedEvent.orderId()).isEqualTo(1L); + assertThat(failedEvent.userId()).isEqualTo(user.getId()); + assertThat(failedEvent.couponCode()).isEqualTo("COUPON001"); + assertThat(failedEvent.failureReason()).contains("사용자가 소유한 쿠폰을 찾을 수 없습니다"); + + // 쿠폰 적용 성공 이벤트는 발행되지 않아야 함 + assertThat(applicationEvents.stream(CouponEvent.CouponApplied.class)).isEmpty(); + } + + @Test + @DisplayName("이미 사용된 쿠폰으로 주문하면 쿠폰 적용 실패 이벤트가 발행된다") + void handleOrderCreated_publishesFailedEvent_whenCouponAlreadyUsed() throws InterruptedException { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + Coupon coupon = createAndSaveCoupon("USED_COUPON", CouponType.FIXED_AMOUNT, 5_000); + UserCoupon userCoupon = createAndSaveUserCoupon(user.getId(), coupon); + userCoupon.use(); // 이미 사용 처리 + userCouponRepository.save(userCoupon); + + OrderEvent.OrderCreated event = new OrderEvent.OrderCreated( + 1L, + user.getId(), + "USED_COUPON", + 10_000, + 0L, + List.of(), + LocalDateTime.now() + ); + + // act + // CouponEventHandler를 직접 호출하여 테스트 + try { + couponEventHandler.handleOrderCreated(event); + } catch (Exception e) { + // 예외는 예상된 동작 + } + + // assert + // 쿠폰 적용 실패 이벤트가 발행되었는지 확인 + assertThat(applicationEvents.stream(CouponEvent.CouponApplicationFailed.class)).hasSize(1); + CouponEvent.CouponApplicationFailed failedEvent = applicationEvents.stream(CouponEvent.CouponApplicationFailed.class) + .findFirst() + .orElseThrow(); + assertThat(failedEvent.orderId()).isEqualTo(1L); + assertThat(failedEvent.userId()).isEqualTo(user.getId()); + assertThat(failedEvent.couponCode()).isEqualTo("USED_COUPON"); + assertThat(failedEvent.failureReason()).contains("이미 사용된 쿠폰입니다"); + + // 쿠폰 적용 성공 이벤트는 발행되지 않아야 함 + assertThat(applicationEvents.stream(CouponEvent.CouponApplied.class)).isEmpty(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeConcurrencyTest.java b/apps/commerce-api/src/test/java/com/loopers/application/heart/HeartFacadeConcurrencyTest.java similarity index 93% rename from apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeConcurrencyTest.java rename to apps/commerce-api/src/test/java/com/loopers/application/heart/HeartFacadeConcurrencyTest.java index 1e7b42394..fc9afe984 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeConcurrencyTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/heart/HeartFacadeConcurrencyTest.java @@ -1,4 +1,4 @@ -package com.loopers.application.like; +package com.loopers.application.heart; import com.loopers.domain.brand.Brand; import com.loopers.domain.brand.BrandRepository; @@ -37,10 +37,10 @@ @SpringBootTest @Import(MySqlTestContainersConfig.class) @DisplayName("LikeFacade 동시성 테스트") -class LikeFacadeConcurrencyTest { +class HeartFacadeConcurrencyTest { @Autowired - private LikeFacade likeFacade; + private HeartFacade heartFacade; @Autowired private UserRepository userRepository; @@ -116,7 +116,7 @@ void concurrencyTest_likeShouldBeProperlyCounted() throws InterruptedException { for (User user : users) { executorService.submit(() -> { try { - likeFacade.addLike(user.getUserId(), productId); + heartFacade.addLike(user.getUserId(), productId); successCount.incrementAndGet(); } catch (Exception e) { synchronized (exceptions) { @@ -159,7 +159,7 @@ void concurrencyTest_sameUserMultipleRequests_shouldBeCountedCorrectly() throws for (int i = 0; i < concurrentRequestCount; i++) { executorService.submit(() -> { try { - likeFacade.addLike(userId, productId); + heartFacade.addLike(userId, productId); successCount.incrementAndGet(); } catch (Exception e) { synchronized (exceptions) { @@ -233,19 +233,19 @@ void concurrencyTest_transactionReadOnlyAndUniqueConstraintServeDifferentPurpose String userId2 = user2.getUserId(); // user1이 상품1, 상품2에 좋아요를 이미 누른 상태 - likeFacade.addLike(userId1, product1.getId()); - likeFacade.addLike(userId1, product2.getId()); + heartFacade.addLike(userId1, product1.getId()); + heartFacade.addLike(userId1, product2.getId()); ExecutorService executorService = Executors.newFixedThreadPool(20); CountDownLatch latch = new CountDownLatch(20); - List> allResults = new ArrayList<>(); + List> allResults = new ArrayList<>(); // act // 여러 스레드에서 동시에 조회를 수행 for (int i = 0; i < 10; i++) { executorService.submit(() -> { try { - List result = likeFacade.getLikedProducts(userId1); + List result = heartFacade.getLikedProducts(userId1); synchronized (allResults) { allResults.add(result); } @@ -267,14 +267,14 @@ void concurrencyTest_transactionReadOnlyAndUniqueConstraintServeDifferentPurpose if (index % 2 == 0) { // user2가 상품1에 좋아요 추가 try { - likeFacade.addLike(userId2, product1.getId()); + heartFacade.addLike(userId2, product1.getId()); } catch (Exception e) { // 이미 좋아요가 있으면 무시 } } else { // user2가 상품2에 좋아요 추가 try { - likeFacade.addLike(userId2, product2.getId()); + heartFacade.addLike(userId2, product2.getId()); } catch (Exception e) { // 이미 좋아요가 있으면 무시 } @@ -310,25 +310,25 @@ void concurrencyTest_transactionReadOnlyAndUniqueConstraintServeDifferentPurpose // 참고: allResults는 동기화 이전에 조회된 결과이므로 likesCount가 0일 수 있습니다. // 이 테스트는 @Transactional(readOnly = true)의 일관성 보장을 검증하는 것이 목적이므로, // 동시성 테스트 중 조회된 결과의 상품 ID 일관성만 확인합니다. - for (List result : allResults) { + for (List result : allResults) { // user1의 좋아요 목록에는 상품1, 상품2가 포함되어야 함 List resultProductIds = result.stream() - .map(LikeFacade.LikedProduct::productId) + .map(HeartFacade.LikedProduct::productId) .sorted() .toList(); assertThat(resultProductIds).contains(product1.getId(), product2.getId()); } // 최종 상태 확인 (동기화 후) - List finalResult = likeFacade.getLikedProducts(userId1); + List finalResult = heartFacade.getLikedProducts(userId1); List finalProductIds = finalResult.stream() - .map(LikeFacade.LikedProduct::productId) + .map(HeartFacade.LikedProduct::productId) .sorted() .toList(); assertThat(finalProductIds).containsExactlyInAnyOrder(product1.getId(), product2.getId()); // 동기화 후에는 정확한 좋아요 수가 반영되어야 함 - for (LikeFacade.LikedProduct likedProduct : finalResult) { + for (HeartFacade.LikedProduct likedProduct : finalResult) { assertThat(likedProduct.likesCount()).isGreaterThan(0); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/heart/HeartFacadeTest.java similarity index 61% rename from apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java rename to apps/commerce-api/src/test/java/com/loopers/application/heart/HeartFacadeTest.java index 148fe1968..9634d4c48 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/heart/HeartFacadeTest.java @@ -1,12 +1,11 @@ -package com.loopers.application.like; +package com.loopers.application.heart; -import com.loopers.application.catalog.ProductCacheService; +import com.loopers.application.like.LikeService; +import com.loopers.application.product.ProductService; +import com.loopers.application.user.UserService; import com.loopers.domain.like.Like; -import com.loopers.domain.like.LikeRepository; import com.loopers.domain.product.Product; -import com.loopers.domain.product.ProductRepository; import com.loopers.domain.user.User; -import com.loopers.domain.user.UserRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import org.junit.jupiter.api.BeforeEach; @@ -27,23 +26,20 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.never; -@DisplayName("LikeFacade 좋아요 등록/취소/중복 방지 흐름 검증") -class LikeFacadeTest { +@DisplayName("HeartFacade 좋아요 등록/취소/중복 방지 흐름 검증") +class HeartFacadeTest { @Mock - private LikeRepository likeRepository; + private LikeService likeService; @Mock - private UserRepository userRepository; + private UserService userService; @Mock - private ProductRepository productRepository; - - @Mock - private ProductCacheService productCacheService; + private ProductService productService; // 조회용으로만 사용 @InjectMocks - private LikeFacade likeFacade; + private HeartFacade heartFacade; private static final String DEFAULT_USER_ID = "testuser"; private static final Long DEFAULT_USER_INTERNAL_ID = 1L; @@ -59,14 +55,17 @@ void setUp() { void addLike_success() { // arrange setupMocks(DEFAULT_USER_ID, DEFAULT_USER_INTERNAL_ID, DEFAULT_PRODUCT_ID); - when(likeRepository.findByUserIdAndProductId(DEFAULT_USER_INTERNAL_ID, DEFAULT_PRODUCT_ID)) + when(likeService.getLike(DEFAULT_USER_INTERNAL_ID, DEFAULT_PRODUCT_ID)) .thenReturn(Optional.empty()); // act - likeFacade.addLike(DEFAULT_USER_ID, DEFAULT_PRODUCT_ID); + heartFacade.addLike(DEFAULT_USER_ID, DEFAULT_PRODUCT_ID); // assert - verify(likeRepository).save(any(Like.class)); + // ✅ EDA 원칙: LikeService.save()가 LikeEvent.LikeAdded 이벤트를 발행 + // ✅ ProductEventHandler가 이벤트를 구독하여 상품 좋아요 수 및 캐시 업데이트 + verify(likeService).save(any(Like.class)); + // ProductService는 조회용으로만 사용되므로 검증하지 않음 } @Test @@ -75,14 +74,16 @@ void removeLike_success() { // arrange setupMocks(DEFAULT_USER_ID, DEFAULT_USER_INTERNAL_ID, DEFAULT_PRODUCT_ID); Like like = Like.of(DEFAULT_USER_INTERNAL_ID, DEFAULT_PRODUCT_ID); - when(likeRepository.findByUserIdAndProductId(DEFAULT_USER_INTERNAL_ID, DEFAULT_PRODUCT_ID)) + when(likeService.getLike(DEFAULT_USER_INTERNAL_ID, DEFAULT_PRODUCT_ID)) .thenReturn(Optional.of(like)); // act - likeFacade.removeLike(DEFAULT_USER_ID, DEFAULT_PRODUCT_ID); + heartFacade.removeLike(DEFAULT_USER_ID, DEFAULT_PRODUCT_ID); // assert - verify(likeRepository).delete(like); + // ✅ EDA 원칙: LikeService.delete()가 LikeEvent.LikeRemoved 이벤트를 발행 + // ✅ ProductEventHandler가 이벤트를 구독하여 상품 좋아요 수 및 캐시 업데이트 + verify(likeService).delete(like); } @Test @@ -90,14 +91,14 @@ void removeLike_success() { void addLike_isIdempotent() { // arrange setupMocks(DEFAULT_USER_ID, DEFAULT_USER_INTERNAL_ID, DEFAULT_PRODUCT_ID); - when(likeRepository.findByUserIdAndProductId(DEFAULT_USER_INTERNAL_ID, DEFAULT_PRODUCT_ID)) + when(likeService.getLike(DEFAULT_USER_INTERNAL_ID, DEFAULT_PRODUCT_ID)) .thenReturn(Optional.of(Like.of(DEFAULT_USER_INTERNAL_ID, DEFAULT_PRODUCT_ID))); // act - likeFacade.addLike(DEFAULT_USER_ID, DEFAULT_PRODUCT_ID); + heartFacade.addLike(DEFAULT_USER_ID, DEFAULT_PRODUCT_ID); // assert - save는 한 번만 호출되어야 함 (중복 방지) - verify(likeRepository, never()).save(any(Like.class)); + verify(likeService, never()).save(any(Like.class)); } @Test @@ -105,15 +106,15 @@ void addLike_isIdempotent() { void removeLike_isIdempotent() { // arrange setupMocks(DEFAULT_USER_ID, DEFAULT_USER_INTERNAL_ID, DEFAULT_PRODUCT_ID); - when(likeRepository.findByUserIdAndProductId(DEFAULT_USER_INTERNAL_ID, DEFAULT_PRODUCT_ID)) + when(likeService.getLike(DEFAULT_USER_INTERNAL_ID, DEFAULT_PRODUCT_ID)) .thenReturn(Optional.empty()); // 좋아요 없음 // act - 좋아요가 없는 상태에서 취소 시도 - likeFacade.removeLike(DEFAULT_USER_ID, DEFAULT_PRODUCT_ID); + heartFacade.removeLike(DEFAULT_USER_ID, DEFAULT_PRODUCT_ID); // assert - 예외가 발생하지 않아야 함 (멱등성 보장) - verify(likeRepository).findByUserIdAndProductId(DEFAULT_USER_INTERNAL_ID, DEFAULT_PRODUCT_ID); - verify(likeRepository, never()).delete(any(Like.class)); + verify(likeService).getLike(DEFAULT_USER_INTERNAL_ID, DEFAULT_PRODUCT_ID); + verify(likeService, never()).delete(any(Like.class)); } @Test @@ -121,26 +122,33 @@ void removeLike_isIdempotent() { void addLike_userNotFound() { // arrange String unknownUserId = "unknown"; - when(userRepository.findByUserId(unknownUserId)).thenReturn(null); + when(userService.getUser(unknownUserId)) + .thenThrow(new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.")); // act & assert - assertThatThrownBy(() -> likeFacade.addLike(unknownUserId, DEFAULT_PRODUCT_ID)) + assertThatThrownBy(() -> heartFacade.addLike(unknownUserId, DEFAULT_PRODUCT_ID)) .isInstanceOf(CoreException.class) .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); } @Test - @DisplayName("상품을 찾을 수 없으면 예외를 던진다") - void addLike_productNotFound() { + @DisplayName("좋아요 등록 시 상품 존재 여부 검증은 제거됨 (이벤트 핸들러에서 처리)") + void addLike_productValidationRemoved() { // arrange setupMockUser(DEFAULT_USER_ID, DEFAULT_USER_INTERNAL_ID); - Long nonExistentProductId = 999L; - when(productRepository.findById(nonExistentProductId)).thenReturn(Optional.empty()); + Long productId = 999L; + // ✅ EDA 원칙: Product 존재 여부 검증은 제거됨 + // 이벤트 핸들러에서 처리하거나 외래키 제약조건으로 보장 + when(likeService.getLike(DEFAULT_USER_INTERNAL_ID, productId)) + .thenReturn(Optional.empty()); - // act & assert - assertThatThrownBy(() -> likeFacade.addLike(DEFAULT_USER_ID, nonExistentProductId)) - .isInstanceOf(CoreException.class) - .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); + // act + heartFacade.addLike(DEFAULT_USER_ID, productId); + + // assert + // ProductService.getProduct()는 호출되지 않음 (검증 제거됨) + verify(productService, never()).getProduct(any()); + verify(likeService).save(any(Like.class)); } @Test @@ -160,19 +168,18 @@ void getLikedProducts_success() { Product product1 = createMockProduct(productId1, "상품1", 10000, 10, 1L, 5L); Product product2 = createMockProduct(productId2, "상품2", 20000, 20, 1L, 3L); - when(likeRepository.findAllByUserId(DEFAULT_USER_INTERNAL_ID)).thenReturn(likes); - // ✅ findAllById를 사용하므로 findAllById를 mock해야 함 - when(productRepository.findAllById(List.of(productId1, productId2))) + when(likeService.getLikesByUserId(DEFAULT_USER_INTERNAL_ID)).thenReturn(likes); + when(productService.getProducts(List.of(productId1, productId2))) .thenReturn(List.of(product1, product2)); // act - List result = likeFacade.getLikedProducts(DEFAULT_USER_ID); + List result = heartFacade.getLikedProducts(DEFAULT_USER_ID); // assert assertThat(result).hasSize(2); - assertThat(result).extracting(LikeFacade.LikedProduct::productId) + assertThat(result).extracting(HeartFacade.LikedProduct::productId) .containsExactlyInAnyOrder(productId1, productId2); - assertThat(result).extracting(LikeFacade.LikedProduct::likesCount) + assertThat(result).extracting(HeartFacade.LikedProduct::likesCount) .containsExactlyInAnyOrder(5L, 3L); } @@ -181,10 +188,10 @@ void getLikedProducts_success() { void getLikedProducts_emptyList() { // arrange setupMockUser(DEFAULT_USER_ID, DEFAULT_USER_INTERNAL_ID); - when(likeRepository.findAllByUserId(DEFAULT_USER_INTERNAL_ID)).thenReturn(List.of()); + when(likeService.getLikesByUserId(DEFAULT_USER_INTERNAL_ID)).thenReturn(List.of()); // act - List result = likeFacade.getLikedProducts(DEFAULT_USER_ID); + List result = heartFacade.getLikedProducts(DEFAULT_USER_ID); // assert assertThat(result).isEmpty(); @@ -205,14 +212,13 @@ void getLikedProducts_productNotFound() { Product product1 = createMockProduct(productId1, "상품1", 10000, 10, 1L, 5L); - when(likeRepository.findAllByUserId(DEFAULT_USER_INTERNAL_ID)).thenReturn(likes); - // ✅ findAllById를 사용하므로 findAllById를 mock해야 함 + when(likeService.getLikesByUserId(DEFAULT_USER_INTERNAL_ID)).thenReturn(likes); // nonExistentProductId가 포함되지 않아서 예외가 발생해야 함 - when(productRepository.findAllById(List.of(productId1, nonExistentProductId))) + when(productService.getProducts(List.of(productId1, nonExistentProductId))) .thenReturn(List.of(product1)); // product1만 반환 (nonExistentProductId는 없음) // act & assert - assertThatThrownBy(() -> likeFacade.getLikedProducts(DEFAULT_USER_ID)) + assertThatThrownBy(() -> heartFacade.getLikedProducts(DEFAULT_USER_ID)) .isInstanceOf(CoreException.class) .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); } @@ -222,10 +228,11 @@ void getLikedProducts_productNotFound() { void getLikedProducts_userNotFound() { // arrange String unknownUserId = "unknown"; - when(userRepository.findByUserId(unknownUserId)).thenReturn(null); + when(userService.getUser(unknownUserId)) + .thenThrow(new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.")); // act & assert - assertThatThrownBy(() -> likeFacade.getLikedProducts(unknownUserId)) + assertThatThrownBy(() -> heartFacade.getLikedProducts(unknownUserId)) .isInstanceOf(CoreException.class) .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); } @@ -234,18 +241,14 @@ void getLikedProducts_userNotFound() { private void setupMocks(String userId, Long userInternalId, Long productId) { setupMockUser(userId, userInternalId); - setupMockProduct(productId); + // ✅ EDA 원칙: ProductService는 조회용으로만 사용되므로 mock 설정 불필요 + // Product 존재 여부 검증은 제거됨 } private void setupMockUser(String userId, Long userInternalId) { User mockUser = mock(User.class); when(mockUser.getId()).thenReturn(userInternalId); - when(userRepository.findByUserId(userId)).thenReturn(mockUser); - } - - private void setupMockProduct(Long productId) { - Product mockProduct = mock(Product.class); - when(productRepository.findById(productId)).thenReturn(Optional.of(mockProduct)); + when(userService.getUser(userId)).thenReturn(mockUser); } private Product createMockProduct(Long productId, String name, Integer price, Integer stock, Long brandId, Long likeCount) { diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductEventHandlerTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductEventHandlerTest.java new file mode 100644 index 000000000..db50500ff --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductEventHandlerTest.java @@ -0,0 +1,115 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.order.OrderEvent; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.event.RecordApplicationEvents; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@DisplayName("ProductEventHandler 재고 차감 검증") +@RecordApplicationEvents +class ProductEventHandlerTest { + + @Autowired + private com.loopers.interfaces.event.product.ProductEventListener productEventListener; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private BrandRepository brandRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + // Helper methods for test fixtures + private Brand createAndSaveBrand(String name) { + Brand brand = Brand.of(name); + return brandRepository.save(brand); + } + + private Product createAndSaveProduct(String name, int price, int stock, Long brandId) { + Product product = Product.of(name, price, stock, brandId); + return productRepository.save(product); + } + + @Test + @DisplayName("동일한 상품에 대해 여러 주문이 동시에 요청되어도, 재고가 정상적으로 차감되어야 한다") + void concurrencyTest_stockShouldBeProperlyDecreasedWhenOrdersCreated() throws InterruptedException { + // arrange + Brand brand = createAndSaveBrand("테스트 브랜드"); + Product product = createAndSaveProduct("테스트 상품", 10_000, 100, brand.getId()); + Long productId = product.getId(); + int initialStock = 100; + + int orderCount = 10; + int quantityPerOrder = 5; + + ExecutorService executorService = Executors.newFixedThreadPool(orderCount); + CountDownLatch latch = new CountDownLatch(orderCount); + AtomicInteger successCount = new AtomicInteger(0); + List exceptions = new ArrayList<>(); + + // act + for (int i = 0; i < orderCount; i++) { + final int orderId = i + 1; + executorService.submit(() -> { + try { + OrderEvent.OrderCreated event = new OrderEvent.OrderCreated( + (long) orderId, + 1L, // userId + null, // couponCode + 10_000, // subtotal + 0L, // usedPointAmount + List.of(new OrderEvent.OrderCreated.OrderItemInfo(productId, quantityPerOrder)), + LocalDateTime.now() + ); + // ProductEventListener를 통해 호출하여 실제 운영 환경과 동일한 방식으로 테스트 + // 재고 차감은 BEFORE_COMMIT으로 동기 처리되므로 예외가 발생하면 롤백됨 + productEventListener.handleOrderCreated(event); + successCount.incrementAndGet(); + } catch (Exception e) { + synchronized (exceptions) { + exceptions.add(e); + } + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executorService.shutdown(); + + // assert + // 재고 차감은 동기적으로 처리되므로 즉시 반영됨 + Product savedProduct = productRepository.findById(productId).orElseThrow(); + int expectedStock = initialStock - (successCount.get() * quantityPerOrder); + + assertThat(savedProduct.getStock()).isEqualTo(expectedStock); + assertThat(successCount.get() + exceptions.size()).isEqualTo(orderCount); + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeCircuitBreakerTest.java b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeCircuitBreakerTest.java index 72b6052a8..0503b9ad9 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeCircuitBreakerTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeCircuitBreakerTest.java @@ -12,8 +12,8 @@ import com.loopers.domain.user.Point; import com.loopers.domain.user.User; import com.loopers.domain.user.UserRepository; -import com.loopers.infrastructure.paymentgateway.PaymentGatewayClient; -import com.loopers.infrastructure.paymentgateway.PaymentGatewayDto; +import com.loopers.infrastructure.payment.PaymentGatewayClient; +import com.loopers.infrastructure.payment.PaymentGatewayDto; import com.loopers.utils.DatabaseCleanUp; import io.github.resilience4j.circuitbreaker.CircuitBreaker; import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry; @@ -142,6 +142,7 @@ void createOrder_consecutiveFailures_circuitBreakerOpens() { purchasingFacade.createOrder( user.getUserId(), commands, + null, "SAMSUNG", "4111-1111-1111-1111" ); @@ -189,6 +190,7 @@ void createOrder_circuitBreakerOpen_fallbackExecuted() { purchasingFacade.createOrder( user.getUserId(), commands, + null, "SAMSUNG", "4111-1111-1111-1111" ); @@ -244,6 +246,7 @@ void createOrder_circuitBreakerHalfOpen_success_transitionsToClosed() { purchasingFacade.createOrder( user.getUserId(), commands, + null, "SAMSUNG", "4111-1111-1111-1111" ); @@ -262,60 +265,6 @@ void createOrder_circuitBreakerHalfOpen_success_transitionsToClosed() { } } - @Test - @DisplayName("서킷 브레이커가 HALF_OPEN 상태에서 실패 시 OPEN으로 전환된다") - void createOrder_circuitBreakerHalfOpen_failure_transitionsToOpen() { - // arrange - User user = createAndSaveUser("testuser", "test@example.com", 50_000L); - Brand brand = createAndSaveBrand("브랜드"); - Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); - - List commands = List.of( - OrderItemCommand.of(product.getId(), 1) - ); - - // 서킷 브레이커를 HALF_OPEN 상태로 만듦 - // 서킷 브레이커는 CLOSED → OPEN → HALF_OPEN 순서로만 전환 가능 - if (circuitBreakerRegistry != null) { - CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit"); - if (circuitBreaker != null) { - // 먼저 OPEN 상태로 전환 - circuitBreaker.transitionToOpenState(); - // 그 다음 HALF_OPEN 상태로 전환 - circuitBreaker.transitionToHalfOpenState(); - } - } - - // PG 실패 응답 - when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class))) - .thenThrow(new FeignException.ServiceUnavailable( - "Service unavailable", - Request.create(Request.HttpMethod.POST, "/api/v1/payments", Collections.emptyMap(), null, null, null), - null, - Collections.emptyMap() - )); - - // act - OrderInfo orderInfo = purchasingFacade.createOrder( - user.getUserId(), - commands, - "SAMSUNG", - "4111-1111-1111-1111" - ); - - // assert - assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING); - - // 서킷 브레이커 상태가 OPEN으로 전환되었는지 확인 - if (circuitBreakerRegistry != null) { - CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit"); - if (circuitBreaker != null) { - // HALF_OPEN 상태에서 실패 시 OPEN으로 전환되어야 함 - assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.OPEN); - } - } - } - @Test @DisplayName("서킷 브레이커가 OPEN 상태일 때도 내부 시스템은 정상적으로 응답한다") void createOrder_circuitBreakerOpen_internalSystemRespondsNormally() { @@ -337,9 +286,11 @@ void createOrder_circuitBreakerOpen_internalSystemRespondsNormally() { } // act + // 포인트를 사용하지 않고 카드로만 결제 (Circuit Breaker OPEN 상태에서도 주문은 PENDING 상태로 유지) OrderInfo orderInfo = purchasingFacade.createOrder( user.getUserId(), commands, + null, "SAMSUNG", "4111-1111-1111-1111" ); @@ -350,12 +301,13 @@ void createOrder_circuitBreakerOpen_internalSystemRespondsNormally() { assertThat(orderInfo.orderId()).isNotNull(); assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING); - // 재고와 포인트는 정상적으로 차감되어야 함 + // 재고는 정상적으로 차감되어야 함 Product savedProduct = productRepository.findById(product.getId()).orElseThrow(); assertThat(savedProduct.getStock()).isEqualTo(9); + // 포인트는 사용하지 않았으므로 차감되지 않음 User savedUser = userRepository.findByUserId(user.getUserId()); - assertThat(savedUser.getPoint().getValue()).isEqualTo(40_000L); + assertThat(savedUser.getPoint().getValue()).isEqualTo(50_000L); } @Test @@ -396,6 +348,7 @@ void createOrder_fallbackResponseWithCircuitBreakerOpen_orderRemainsPending() { OrderInfo orderInfo = purchasingFacade.createOrder( user.getUserId(), commands, + null, "SAMSUNG", "4111-1111-1111-1111" ); @@ -413,77 +366,6 @@ void createOrder_fallbackResponseWithCircuitBreakerOpen_orderRemainsPending() { assertThat(savedOrder.getStatus()).isNotEqualTo(OrderStatus.CANCELED); } - @Test - @DisplayName("Retry 실패 후 CircuitBreaker가 OPEN 상태가 되어 Fallback이 호출된다") - void createOrder_retryFailure_circuitBreakerOpens_fallbackExecuted() { - // arrange - User user = createAndSaveUser("testuser", "test@example.com", 100_000L); - Brand brand = createAndSaveBrand("브랜드"); - Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); - - List commands = List.of( - OrderItemCommand.of(product.getId(), 1) - ); - - // 모든 재시도가 실패하도록 설정 (5xx 서버 오류) - when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class))) - .thenThrow(new FeignException.InternalServerError( - "Internal Server Error", - Request.create(Request.HttpMethod.POST, "/api/v1/payments", Collections.emptyMap(), null, null, null), - null, - Collections.emptyMap() - )); - - // CircuitBreaker를 리셋하여 초기 상태로 만듦 - if (circuitBreakerRegistry != null) { - CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit"); - if (circuitBreaker != null) { - circuitBreaker.reset(); - } - } - - // act - // 서킷 브레이커 설정: minimumNumberOfCalls=1, failureRateThreshold=50% - // 첫 번째 호출이 실패하면 100% 실패율이 되어 임계값 50%를 초과하므로 OPEN 상태로 전환됨 - // 따라서 첫 번째 호출만 실제로 PaymentGatewayClient를 호출하고 (재시도 포함하여 3번), - // 이후 호출들은 서킷 브레이커가 OPEN 상태이므로 fallback이 호출되어 실제 호출되지 않음 - int numberOfCalls = 6; // 여러 번 호출하여 서킷 브레이커 동작 확인 - for (int i = 0; i < numberOfCalls; i++) { - purchasingFacade.createOrder( - user.getUserId(), - commands, - "SAMSUNG", - "4111-1111-1111-1111" - ); - } - - // assert - // 재시도 정책에 따라 5xx 에러는 최대 3번까지 재시도됨 (maxAttempts: 3) - // 첫 번째 createOrder 호출에서 재시도가 일어나면서 최대 3번 호출될 수 있음 - // Circuit Breaker가 OPEN 상태가 되면 이후 호출들은 fallback이 호출되어 실제 호출되지 않음 - verify(paymentGatewayClient, atMost(3)) - .requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)); - - // CircuitBreaker 상태 확인 - if (circuitBreakerRegistry != null) { - CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit"); - if (circuitBreaker != null) { - // 실패율이 임계값을 초과했으므로 OPEN 상태로 전환되어야 함 - // 첫 번째 호출이 실패하면 100% 실패율이 되어 임계값 50%를 초과하므로 OPEN 상태로 전환됨 - assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.OPEN); - } - } - - // 모든 주문이 PENDING 상태로 생성되었는지 확인 - // Circuit Breaker가 언제 OPEN 상태로 전환될지 정확히 예측하기 어려우므로, - // 최소 1개 이상의 주문이 생성되었는지 확인 - List orders = orderJpaRepository.findAll(); - assertThat(orders.size()).isGreaterThanOrEqualTo(1); - orders.forEach(order -> { - assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING); - }); - } - @Test @DisplayName("Retry 실패 후 Fallback이 호출되고 CIRCUIT_BREAKER_OPEN 응답이 올바르게 처리된다") void createOrder_retryFailure_fallbackCalled_circuitBreakerOpenHandled() { @@ -519,9 +401,11 @@ void createOrder_retryFailure_fallbackCalled_circuitBreakerOpenHandled() { .thenReturn(fallbackResponse); // act + // 포인트를 사용하지 않고 카드로만 결제 (Circuit Breaker OPEN 상태에서도 주문은 PENDING 상태로 유지) OrderInfo orderInfo = purchasingFacade.createOrder( user.getUserId(), commands, + null, "SAMSUNG", "4111-1111-1111-1111" ); @@ -537,12 +421,13 @@ void createOrder_retryFailure_fallbackCalled_circuitBreakerOpenHandled() { // 3. CIRCUIT_BREAKER_OPEN은 외부 시스템 장애로 간주되므로 주문 취소가 발생하지 않아야 함 assertThat(savedOrder.getStatus()).isNotEqualTo(OrderStatus.CANCELED); - // 4. 재고와 포인트는 정상적으로 차감되어야 함 (주문은 생성되었지만 결제는 PENDING) + // 4. 재고는 정상적으로 차감되어야 함 (주문은 생성되었지만 결제는 PENDING) Product savedProduct = productRepository.findById(product.getId()).orElseThrow(); assertThat(savedProduct.getStock()).isEqualTo(9); + // 포인트는 사용하지 않았으므로 차감되지 않음 User savedUser = userRepository.findByUserId(user.getUserId()); - assertThat(savedUser.getPoint().getValue()).isEqualTo(40_000L); + assertThat(savedUser.getPoint().getValue()).isEqualTo(50_000L); } @Test @@ -582,6 +467,7 @@ void createOrder_fallbackResponse_circuitBreakerOpenErrorCode_orderRemainsPendin OrderInfo orderInfo = purchasingFacade.createOrder( user.getUserId(), commands, + null, "SAMSUNG", "4111-1111-1111-1111" ); @@ -602,110 +488,5 @@ void createOrder_fallbackResponse_circuitBreakerOpenErrorCode_orderRemainsPendin // (상태 확인 API나 콜백을 통해 나중에 상태를 업데이트할 수 있어야 함) } - @Test - @DisplayName("Retry가 모두 실패한 후 CircuitBreaker가 OPEN 상태가 되면 Fallback이 호출되어 주문이 PENDING 상태로 유지된다") - void createOrder_retryExhausted_circuitBreakerOpens_fallbackCalled_orderPending() { - // arrange - // 6번의 주문 생성 + fallback 테스트 1번 = 총 7번의 주문 생성 - // 각 주문마다 10,000 포인트가 필요하므로 최소 70,000 포인트 필요 - // 여유를 두고 100,000 포인트로 설정 - User user = createAndSaveUser("testuser", "test@example.com", 100_000L); - Brand brand = createAndSaveBrand("브랜드"); - Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); - - List commands = List.of( - OrderItemCommand.of(product.getId(), 1) - ); - - // 모든 재시도가 실패하도록 설정 (5xx 서버 오류) - when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class))) - .thenThrow(new FeignException.InternalServerError( - "Internal Server Error", - Request.create(Request.HttpMethod.POST, "/api/v1/payments", Collections.emptyMap(), null, null, null), - null, - Collections.emptyMap() - )); - - // CircuitBreaker를 리셋하여 초기 상태로 만듦 - if (circuitBreakerRegistry != null) { - CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit"); - if (circuitBreaker != null) { - circuitBreaker.reset(); - } - } - - // act - // 서킷 브레이커 설정: minimumNumberOfCalls=1, failureRateThreshold=50% - // 첫 번째 호출이 실패하면 100% 실패율이 되어 임계값 50%를 초과하므로 OPEN 상태로 전환됨 - // 따라서 첫 번째 호출만 실제로 PaymentGatewayClient를 호출하고, - // 이후 호출들은 서킷 브레이커가 OPEN 상태이므로 fallback이 호출되어 실제 호출되지 않음 - int numberOfCalls = 6; // 여러 번 호출하여 서킷 브레이커 동작 확인 - - for (int i = 0; i < numberOfCalls; i++) { - purchasingFacade.createOrder( - user.getUserId(), - commands, - "SAMSUNG", - "4111-1111-1111-1111" - ); - } - - // CircuitBreaker 상태 확인 - CircuitBreaker circuitBreaker = null; - if (circuitBreakerRegistry != null) { - circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit"); - } - - // assert - // 1. 재시도 정책에 따라 5xx 에러는 최대 3번까지 재시도됨 (maxAttempts: 3) - // 첫 번째 createOrder 호출에서 재시도가 일어나면서 최대 3번 호출될 수 있음 - // Circuit Breaker가 OPEN 상태가 되면 이후 호출들은 fallback이 호출되어 실제 호출되지 않음 - verify(paymentGatewayClient, atMost(3)) - .requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)); - - // 2. CircuitBreaker가 OPEN 상태로 전환되었는지 확인 - if (circuitBreaker != null) { - // 실패율이 임계값을 초과했으므로 OPEN 상태로 전환되어야 함 - assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.OPEN); - } - - // 3. CircuitBreaker가 OPEN 상태가 되면 다음 호출에서 Fallback이 호출되어야 함 - // Fallback 응답 시뮬레이션 - PaymentGatewayDto.ApiResponse fallbackResponse = - new PaymentGatewayDto.ApiResponse<>( - new PaymentGatewayDto.ApiResponse.Metadata( - PaymentGatewayDto.ApiResponse.Metadata.Result.FAIL, - "CIRCUIT_BREAKER_OPEN", - "PG 서비스가 일시적으로 사용할 수 없습니다. 잠시 후 다시 시도해주세요." - ), - null - ); - when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class))) - .thenReturn(fallbackResponse); - - // CircuitBreaker를 강제로 OPEN 상태로 만듦 (Fallback 호출 보장) - if (circuitBreaker != null) { - circuitBreaker.transitionToOpenState(); - } - - // Fallback이 호출되는 시나리오 테스트 - OrderInfo fallbackOrderInfo = purchasingFacade.createOrder( - user.getUserId(), - commands, - "SAMSUNG", - "4111-1111-1111-1111" - ); - - // 4. Fallback 응답이 올바르게 처리되어 주문이 PENDING 상태로 유지되어야 함 - assertThat(fallbackOrderInfo.status()).isEqualTo(OrderStatus.PENDING); - - // 5. 모든 주문이 PENDING 상태로 생성되었는지 확인 - List orders = orderJpaRepository.findAll(); - assertThat(orders.size()).isGreaterThanOrEqualTo(numberOfCalls + 1); // numberOfCalls + fallback 테스트 1번 - orders.forEach(order -> { - assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING); - assertThat(order.getStatus()).isNotEqualTo(OrderStatus.CANCELED); - }); - } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeConcurrencyTest.java b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeConcurrencyTest.java deleted file mode 100644 index bdfda839d..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeConcurrencyTest.java +++ /dev/null @@ -1,358 +0,0 @@ -package com.loopers.application.purchasing; - -import com.loopers.domain.brand.Brand; -import com.loopers.domain.brand.BrandRepository; -import com.loopers.domain.coupon.Coupon; -import com.loopers.domain.coupon.CouponRepository; -import com.loopers.domain.coupon.CouponType; -import com.loopers.domain.coupon.UserCoupon; -import com.loopers.domain.coupon.UserCouponRepository; -import com.loopers.domain.order.Order; -import com.loopers.domain.order.OrderRepository; -import com.loopers.domain.product.Product; -import com.loopers.domain.product.ProductRepository; -import com.loopers.domain.user.Gender; -import com.loopers.domain.user.Point; -import com.loopers.domain.user.User; -import com.loopers.domain.user.UserRepository; -import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import com.loopers.testcontainers.MySqlTestContainersConfig; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.context.annotation.Import; - -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.atomic.AtomicInteger; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * PurchasingFacade 동시성 테스트 - *

    - * 여러 스레드에서 동시에 주문 요청을 보내도 데이터 일관성이 유지되는지 검증합니다. - * - 포인트 차감의 정확성 - * - 재고 차감의 정확성 - * - 쿠폰 사용의 중복 방지 (예시) - *

    - */ -@SpringBootTest -@Import(MySqlTestContainersConfig.class) -@DisplayName("PurchasingFacade 동시성 테스트") -class PurchasingFacadeConcurrencyTest { - - @Autowired - private PurchasingFacade purchasingFacade; - - @Autowired - private UserRepository userRepository; - - @Autowired - private ProductRepository productRepository; - - @Autowired - private BrandRepository brandRepository; - - @Autowired - private OrderRepository orderRepository; - - @Autowired - private CouponRepository couponRepository; - - @Autowired - private UserCouponRepository userCouponRepository; - - @Autowired - private DatabaseCleanUp databaseCleanUp; - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - private User createAndSaveUser(String userId, String email, long point) { - User user = User.of(userId, email, "1990-01-01", Gender.MALE, Point.of(point)); - return userRepository.save(user); - } - - private Brand createAndSaveBrand(String brandName) { - Brand brand = Brand.of(brandName); - return brandRepository.save(brand); - } - - private Product createAndSaveProduct(String productName, int price, int stock, Long brandId) { - Product product = Product.of(productName, price, stock, brandId); - return productRepository.save(product); - } - - private Coupon createAndSaveCoupon(String code, CouponType type, Integer discountValue) { - Coupon coupon = Coupon.of(code, type, discountValue); - return couponRepository.save(coupon); - } - - private UserCoupon createAndSaveUserCoupon(Long userId, Coupon coupon) { - UserCoupon userCoupon = UserCoupon.of(userId, coupon); - return userCouponRepository.save(userCoupon); - } - - @Test - @DisplayName("동일한 유저가 서로 다른 주문을 동시에 수행해도, 포인트가 정상적으로 차감되어야 한다") - void concurrencyTest_pointShouldProperlyDecreaseWhenOrderCreated() throws InterruptedException { - // arrange - User user = createAndSaveUser("testuser", "test@example.com", 100_000L); - String userId = user.getUserId(); - Brand brand = createAndSaveBrand("테스트 브랜드"); - - int orderCount = 5; - List products = new ArrayList<>(); - for (int i = 0; i < orderCount; i++) { - products.add(createAndSaveProduct("상품" + i, 10_000, 100, brand.getId())); - } - - ExecutorService executorService = Executors.newFixedThreadPool(orderCount); - CountDownLatch latch = new CountDownLatch(orderCount); - AtomicInteger successCount = new AtomicInteger(0); - List exceptions = new ArrayList<>(); - - // act - for (int i = 0; i < orderCount; i++) { - final int index = i; - executorService.submit(() -> { - try { - List commands = List.of( - OrderItemCommand.of(products.get(index).getId(), 1) - ); - purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111"); - successCount.incrementAndGet(); - } catch (Exception e) { - synchronized (exceptions) { - exceptions.add(e); - } - } finally { - latch.countDown(); - } - }); - } - latch.await(); - executorService.shutdown(); - - // assert - User savedUser = userRepository.findByUserId(userId); - long expectedRemainingPoint = 100_000L - (10_000L * orderCount); - - assertThat(successCount.get()).isEqualTo(orderCount); - assertThat(exceptions).isEmpty(); - assertThat(savedUser.getPoint().getValue()).isEqualTo(expectedRemainingPoint); - } - - @Test - @DisplayName("동일한 상품에 대해 여러 주문이 동시에 요청되어도, 재고가 정상적으로 차감되어야 한다") - void concurrencyTest_stockShouldBeProperlyDecreasedWhenOrdersCreated() throws InterruptedException { - // arrange - User user = createAndSaveUser("testuser", "test@example.com", 1_000_000L); - String userId = user.getUserId(); - Brand brand = createAndSaveBrand("테스트 브랜드"); - Product product = createAndSaveProduct("테스트 상품", 10_000, 100, brand.getId()); - Long productId = product.getId(); - - int orderCount = 10; - int quantityPerOrder = 5; - - ExecutorService executorService = Executors.newFixedThreadPool(orderCount); - CountDownLatch latch = new CountDownLatch(orderCount); - AtomicInteger successCount = new AtomicInteger(0); - List exceptions = new ArrayList<>(); - - // act - for (int i = 0; i < orderCount; i++) { - executorService.submit(() -> { - try { - List commands = List.of( - OrderItemCommand.of(productId, quantityPerOrder) - ); - purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111"); - successCount.incrementAndGet(); - } catch (Exception e) { - synchronized (exceptions) { - exceptions.add(e); - } - } finally { - latch.countDown(); - } - }); - } - latch.await(); - executorService.shutdown(); - - // assert - Product savedProduct = productRepository.findById(productId).orElseThrow(); - int expectedStock = 100 - (successCount.get() * quantityPerOrder); - - assertThat(savedProduct.getStock()).isEqualTo(expectedStock); - assertThat(successCount.get() + exceptions.size()).isEqualTo(orderCount); - } - - @Test - @DisplayName("동일한 쿠폰으로 여러 기기에서 동시에 주문해도, 쿠폰은 단 한번만 사용되어야 한다") - void concurrencyTest_couponShouldBeUsedOnlyOnceWhenOrdersCreated() throws InterruptedException { - // arrange - User user = createAndSaveUser("testuser", "test@example.com", 100_000L); - String userId = user.getUserId(); - Brand brand = createAndSaveBrand("테스트 브랜드"); - Product product = createAndSaveProduct("테스트 상품", 10_000, 100, brand.getId()); - - // 정액 쿠폰 생성 (5,000원 할인) - Coupon coupon = createAndSaveCoupon("COUPON001", CouponType.FIXED_AMOUNT, 5_000); - String couponCode = coupon.getCode(); - - // 사용자에게 쿠폰 지급 - UserCoupon userCoupon = createAndSaveUserCoupon(user.getId(), coupon); - - int concurrentRequestCount = 10; // 요구사항: 10개 스레드 - - ExecutorService executorService = Executors.newFixedThreadPool(concurrentRequestCount); - CountDownLatch latch = new CountDownLatch(concurrentRequestCount); - AtomicInteger successCount = new AtomicInteger(0); - List exceptions = new ArrayList<>(); - - // act - for (int i = 0; i < concurrentRequestCount; i++) { - executorService.submit(() -> { - try { - List commands = List.of( - new OrderItemCommand(product.getId(), 1, couponCode) - ); - purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111"); - successCount.incrementAndGet(); - } catch (Exception e) { - synchronized (exceptions) { - exceptions.add(e); - } - } finally { - latch.countDown(); - } - }); - } - latch.await(); - executorService.shutdown(); - - // assert - // 쿠폰은 정확히 1번만 사용되어야 함 - UserCoupon savedUserCoupon = userCouponRepository.findByUserIdAndCouponCode(user.getId(), couponCode) - .orElseThrow(); - assertThat(savedUserCoupon.isAvailable()).isFalse(); // 사용됨 - assertThat(savedUserCoupon.getIsUsed()).isTrue(); - - // 성공한 주문은 1개만 있어야 함 (나머지는 쿠폰 중복 사용으로 실패) - assertThat(successCount.get()).isEqualTo(1); - assertThat(exceptions).hasSize(concurrentRequestCount - 1); - - // 성공한 주문의 할인 금액이 적용되었는지 확인 - List orders = orderRepository.findAllByUserId(user.getId()); - assertThat(orders).hasSize(1); - Order order = orders.get(0); - assertThat(order.getCouponCode()).isEqualTo(couponCode); - assertThat(order.getDiscountAmount()).isEqualTo(5_000); - assertThat(order.getTotalAmount()).isEqualTo(5_000); // 10,000 - 5,000 = 5,000 - } - - @Test - @DisplayName("주문 취소 중 다른 스레드가 재고를 변경해도, 재고 원복이 정확하게 이루어져야 한다") - void concurrencyTest_cancelOrderShouldRestoreStockAccuratelyDuringConcurrentStockChanges() throws InterruptedException { - // arrange - User user = createAndSaveUser("testuser", "test@example.com", 1_000_000L); - String userId = user.getUserId(); - Brand brand = createAndSaveBrand("테스트 브랜드"); - Product product = createAndSaveProduct("테스트 상품", 10_000, 100, brand.getId()); - Long productId = product.getId(); - - // 주문 생성 (재고 5개 차감) - int orderQuantity = 5; - List commands = List.of( - OrderItemCommand.of(productId, orderQuantity) - ); - OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111"); - Long orderId = orderInfo.orderId(); - - // 주문 취소 전 재고 확인 (100 - 5 = 95) - Product productBeforeCancel = productRepository.findById(productId).orElseThrow(); - int stockBeforeCancel = productBeforeCancel.getStock(); - assertThat(stockBeforeCancel).isEqualTo(95); - - // 주문 조회 - Order order = orderRepository.findById(orderId).orElseThrow(); - - ExecutorService executorService = Executors.newFixedThreadPool(3); - CountDownLatch latch = new CountDownLatch(3); - AtomicInteger cancelSuccess = new AtomicInteger(0); - AtomicInteger orderSuccess = new AtomicInteger(0); - List exceptions = new ArrayList<>(); - - // act - // 스레드 1: 주문 취소 (재고 원복) - executorService.submit(() -> { - try { - purchasingFacade.cancelOrder(order, user); - cancelSuccess.incrementAndGet(); - } catch (Exception e) { - synchronized (exceptions) { - exceptions.add(e); - } - } finally { - latch.countDown(); - } - }); - - // 스레드 2, 3: 취소 중간에 다른 주문 생성 (재고 추가 차감) - for (int i = 0; i < 2; i++) { - executorService.submit(() -> { - try { - Thread.sleep(10); // 취소가 시작된 후 실행되도록 약간의 지연 - List otherCommands = List.of( - OrderItemCommand.of(productId, 3) - ); - purchasingFacade.createOrder(userId, otherCommands, "SAMSUNG", "4111-1111-1111-1111"); - orderSuccess.incrementAndGet(); - } catch (Exception e) { - synchronized (exceptions) { - exceptions.add(e); - } - } finally { - latch.countDown(); - } - }); - } - - latch.await(); - executorService.shutdown(); - - // assert - // findByIdForUpdate로 인해 비관적 락이 적용되어 재고 원복이 정확하게 이루어져야 함 - Product finalProduct = productRepository.findById(productId).orElseThrow(); - int finalStock = finalProduct.getStock(); - - // 시나리오: - // 1. 초기 재고: 100 - // 2. 첫 주문: 95 (100 - 5) - // 3. 주문 취소: 100 (95 + 5) - 비관적 락으로 정확한 재고 조회 후 원복 - // 4. 다른 주문 2개: 각각 3개씩 차감 - // - 취소와 동시에 실행되면 락 대기 후 순차 처리 - // - 최종 재고: 100 - 3 - 3 = 94 (취소로 5개 원복 후 2개 주문으로 6개 차감) - - assertThat(cancelSuccess.get()).isEqualTo(1); - // 취소가 성공했고, 비관적 락으로 인해 정확한 재고가 원복되었는지 확인 - // 취소로 5개가 원복되고, 다른 주문 2개로 6개가 차감되므로: 95 + 5 - 6 = 94 - int expectedStock = stockBeforeCancel + orderQuantity - (orderSuccess.get() * 3); - assertThat(finalStock).isEqualTo(expectedStock); - - // 예외가 발생하지 않았는지 확인 - assertThat(exceptions).isEmpty(); - } -} - diff --git a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadePaymentCallbackTest.java b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadePaymentCallbackTest.java index 24195fab4..22d2bbb1d 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadePaymentCallbackTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadePaymentCallbackTest.java @@ -11,9 +11,9 @@ import com.loopers.domain.user.Point; import com.loopers.domain.user.User; import com.loopers.domain.user.UserRepository; -import com.loopers.infrastructure.paymentgateway.PaymentGatewayClient; -import com.loopers.infrastructure.paymentgateway.PaymentGatewayDto; -import com.loopers.infrastructure.paymentgateway.PaymentGatewaySchedulerClient; +import com.loopers.infrastructure.payment.PaymentGatewayClient; +import com.loopers.infrastructure.payment.PaymentGatewayDto; +import com.loopers.infrastructure.payment.PaymentGatewaySchedulerClient; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; @@ -121,6 +121,7 @@ void handlePaymentCallback_successCallback_orderStatusUpdatedToCompleted() { OrderInfo orderInfo = purchasingFacade.createOrder( user.getUserId(), commands, + null, "SAMSUNG", "4111-1111-1111-1111" // 유효한 Luhn 알고리즘 통과 카드 번호 ); @@ -197,6 +198,7 @@ void handlePaymentCallback_failureCallback_orderStatusUpdatedToCanceled() { OrderInfo orderInfo = purchasingFacade.createOrder( user.getUserId(), commands, + null, "SAMSUNG", "4111-1111-1111-1111" // 유효한 Luhn 알고리즘 통과 카드 번호 ); @@ -268,6 +270,7 @@ void recoverOrderStatus_afterTimeout_statusRecoveredByStatusCheck() { OrderInfo orderInfo = purchasingFacade.createOrder( user.getUserId(), commands, + null, "SAMSUNG", "4111-1111-1111-1111" // 유효한 Luhn 알고리즘 통과 카드 번호 ); @@ -299,8 +302,11 @@ void recoverOrderStatus_afterTimeout_statusRecoveredByStatusCheck() { purchasingFacade.recoverOrderStatusByPaymentCheck(user.getUserId(), orderId); // assert + // ✅ EDA 원칙: 결제 타임아웃으로 인해 주문이 취소된 경우, + // 이후 PG 상태 확인에서 SUCCESS가 반환되더라도 이미 취소된 주문은 복구할 수 없음 + // OrderEventHandler.handlePaymentCompleted에서 취소된 주문을 무시하도록 처리됨 Order savedOrder = orderRepository.findById(orderId).orElseThrow(); - assertThat(savedOrder.getStatus()).isEqualTo(OrderStatus.COMPLETED); + assertThat(savedOrder.getStatus()).isEqualTo(OrderStatus.CANCELED); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadePaymentGatewayTest.java b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadePaymentGatewayTest.java index 6a3f9f837..366a0b91a 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadePaymentGatewayTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadePaymentGatewayTest.java @@ -11,8 +11,8 @@ import com.loopers.domain.user.Point; import com.loopers.domain.user.User; import com.loopers.domain.user.UserRepository; -import com.loopers.infrastructure.paymentgateway.PaymentGatewayClient; -import com.loopers.infrastructure.paymentgateway.PaymentGatewayDto; +import com.loopers.infrastructure.payment.PaymentGatewayClient; +import com.loopers.infrastructure.payment.PaymentGatewayDto; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; @@ -111,6 +111,7 @@ void createOrder_paymentGatewayTimeout_orderCreatedWithPendingStatus() { OrderInfo orderInfo = purchasingFacade.createOrder( user.getUserId(), commands, + null, "SAMSUNG", "4111-1111-1111-1111" // 유효한 Luhn 알고리즘 통과 카드 번호 ); @@ -122,9 +123,9 @@ void createOrder_paymentGatewayTimeout_orderCreatedWithPendingStatus() { Product savedProduct = productRepository.findById(product.getId()).orElseThrow(); assertThat(savedProduct.getStock()).isEqualTo(9); - // 포인트는 차감되었는지 확인 + // 포인트 차감 확인 (usedPoint가 null이므로 포인트 차감 없음) User savedUser = userRepository.findByUserId(user.getUserId()); - assertThat(savedUser.getPoint().getValue()).isEqualTo(40_000L); + assertThat(savedUser.getPoint().getValue()).isEqualTo(50_000L); // 포인트 차감 없음 // 주문이 저장되었는지 확인 Order savedOrder = orderRepository.findById(orderInfo.orderId()).orElseThrow(); @@ -160,6 +161,7 @@ void createOrder_paymentGatewayFailure_orderCreatedWithPendingStatus() { OrderInfo orderInfo = purchasingFacade.createOrder( user.getUserId(), commands, + null, "SAMSUNG", "4111-1111-1111-1111" // 유효한 Luhn 알고리즘 통과 카드 번호 ); @@ -197,6 +199,7 @@ void createOrder_paymentGatewayServerError_orderCreatedWithPendingStatus() { OrderInfo orderInfo = purchasingFacade.createOrder( user.getUserId(), commands, + null, "SAMSUNG", "4111-1111-1111-1111" // 유효한 Luhn 알고리즘 통과 카드 번호 ); @@ -234,6 +237,7 @@ void createOrder_paymentGatewayConnectionFailure_orderCreatedWithPendingStatus() OrderInfo orderInfo = purchasingFacade.createOrder( user.getUserId(), commands, + null, "SAMSUNG", "4111-1111-1111-1111" // 유효한 Luhn 알고리즘 통과 카드 번호 ); @@ -266,6 +270,7 @@ void createOrder_paymentGatewayTimeout_internalSystemRespondsNormally() { OrderInfo orderInfo = purchasingFacade.createOrder( user.getUserId(), commands, + null, "SAMSUNG", "4111-1111-1111-1111" // 유효한 Luhn 알고리즘 통과 카드 번호 ); diff --git a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeTest.java index e9b01e55e..69eb22cb6 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeTest.java @@ -7,7 +7,6 @@ import com.loopers.domain.coupon.CouponType; import com.loopers.domain.coupon.UserCoupon; import com.loopers.domain.coupon.UserCouponRepository; -import com.loopers.domain.order.OrderRepository; import com.loopers.domain.order.OrderStatus; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductRepository; @@ -15,8 +14,8 @@ import com.loopers.domain.user.Point; import com.loopers.domain.user.User; import com.loopers.domain.user.UserRepository; -import com.loopers.infrastructure.paymentgateway.PaymentGatewayClient; -import com.loopers.infrastructure.paymentgateway.PaymentGatewayDto; +import com.loopers.infrastructure.payment.PaymentGatewayClient; +import com.loopers.infrastructure.payment.PaymentGatewayDto; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import com.loopers.utils.DatabaseCleanUp; @@ -53,9 +52,6 @@ class PurchasingFacadeTest { @Autowired private BrandRepository brandRepository; - @Autowired - private OrderRepository orderRepository; - @Autowired private CouponRepository couponRepository; @@ -153,21 +149,23 @@ void createOrder_successFlow() { ); // act - OrderInfo orderInfo = purchasingFacade.createOrder(user.getUserId(), commands, "SAMSUNG", "4111-1111-1111-1111"); + OrderInfo orderInfo = purchasingFacade.createOrder(user.getUserId(), commands, null, "SAMSUNG", "4111-1111-1111-1111"); // assert - // createOrder는 주문을 PENDING 상태로 생성하고, PG 결제 요청은 afterCommit 콜백에서 비동기로 실행됨 + // ✅ EDA 원칙: createOrder는 주문을 PENDING 상태로 생성하고 OrderEvent.OrderCreated 이벤트를 발행 + // ✅ ProductEventHandler가 OrderEvent.OrderCreated를 구독하여 재고 차감 처리 + // ✅ PaymentEventHandler가 PaymentEvent.PaymentRequested를 구독하여 Payment 생성 및 PG 결제 요청 처리 assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING); - // 재고 차감 확인 + // ✅ 이벤트 핸들러가 재고 차감 처리 (통합 테스트이므로 실제 이벤트 핸들러가 실행됨) Product savedProduct1 = productRepository.findById(product1.getId()).orElseThrow(); Product savedProduct2 = productRepository.findById(product2.getId()).orElseThrow(); assertThat(savedProduct1.getStock()).isEqualTo(8); // 10 - 2 assertThat(savedProduct2.getStock()).isEqualTo(4); // 5 - 1 - // 포인트 차감 확인 + // 포인트 차감 확인 (usedPoint가 null이므로 포인트 차감 없음) User savedUser = userRepository.findByUserId(user.getUserId()); - assertThat(savedUser.getPoint().getValue()).isEqualTo(25_000L); // 50_000 - (10_000 * 2 + 5_000 * 1) + assertThat(savedUser.getPoint().getValue()).isEqualTo(50_000L); // 포인트 차감 없음 } @Test @@ -178,7 +176,7 @@ void createOrder_emptyItems_throwsException() { List emptyCommands = List.of(); // act & assert - assertThatThrownBy(() -> purchasingFacade.createOrder(userId, emptyCommands, "SAMSUNG", "4111-1111-1111-1111")) + assertThatThrownBy(() -> purchasingFacade.createOrder(userId, emptyCommands, null, "SAMSUNG", "4111-1111-1111-1111")) .isInstanceOf(CoreException.class) .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); } @@ -193,7 +191,7 @@ void createOrder_userNotFound() { ); // act & assert - assertThatThrownBy(() -> purchasingFacade.createOrder(unknownUserId, commands, "SAMSUNG", "4111-1111-1111-1111")) + assertThatThrownBy(() -> purchasingFacade.createOrder(unknownUserId, commands, null, "SAMSUNG", "4111-1111-1111-1111")) .isInstanceOf(CoreException.class) .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); } @@ -215,7 +213,9 @@ void createOrder_stockNotEnough() { ); // act & assert - assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111")) + // ✅ 재고 부족 사전 검증: PurchasingFacade에서 재고를 확인하여 예외 발생 + // ✅ 재고 차감은 ProductEventHandler에서 처리 + assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, null, "SAMSUNG", "4111-1111-1111-1111")) .isInstanceOf(CoreException.class) .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); @@ -245,7 +245,7 @@ void createOrder_stockZero() { ); // act & assert - assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111")) + assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, null, "SAMSUNG", "4111-1111-1111-1111")) .isInstanceOf(CoreException.class) .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); @@ -259,7 +259,7 @@ void createOrder_stockZero() { } @Test - @DisplayName("유저의 포인트 잔액이 부족하면 예외를 던지고 재고는 차감되지 않는다") + @DisplayName("유저의 포인트 잔액이 부족하면 주문은 생성되지만 포인트 사용 실패 이벤트가 발행된다") void createOrder_pointNotEnough() { // arrange User user = createAndSaveUser("testuser2", "test2@example.com", 5_000L); @@ -274,18 +274,25 @@ void createOrder_pointNotEnough() { OrderItemCommand.of(productId, 1) ); - // act & assert - assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111")) - .isInstanceOf(CoreException.class) - .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + // act + // ✅ EDA 원칙: PurchasingFacade는 포인트 사전 검증을 하지 않음 + // ✅ 포인트 검증 및 차감은 PointEventHandler에서 처리 + // ✅ 포인트 부족 시 PointEventHandler에서 PointEvent.PointUsedFailed 이벤트 발행 + OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands, 10_000L, "SAMSUNG", "4111-1111-1111-1111"); - // 롤백 확인: 포인트가 차감되지 않았는지 확인 - User savedUser = userRepository.findByUserId(userId); - assertThat(savedUser.getPoint().getValue()).isEqualTo(5_000L); + // assert + // 주문은 생성됨 (포인트 검증은 이벤트 핸들러에서 처리) + assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING); + assertThat(orderInfo.orderId()).isNotNull(); - // 롤백 확인: 재고가 변경되지 않았는지 확인 + // ✅ 재고는 차감됨 (ProductEventHandler가 동기적으로 처리) Product savedProduct = productRepository.findById(productId).orElseThrow(); - assertThat(savedProduct.getStock()).isEqualTo(initialStock); + assertThat(savedProduct.getStock()).isEqualTo(initialStock - 1); + + // ✅ 포인트는 차감되지 않음 (포인트 부족으로 실패) + // 주의: 포인트 사용 실패 이벤트 발행 검증은 PointEventHandlerTest에서 수행 + User savedUser = userRepository.findByUserId(userId); + assertThat(savedUser.getPoint().getValue()).isEqualTo(5_000L); } @Test @@ -305,7 +312,7 @@ void createOrder_duplicateProducts_throwsException() { ); // act & assert - assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111")) + assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, null, "SAMSUNG", "4111-1111-1111-1111")) .isInstanceOf(CoreException.class) .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); @@ -325,7 +332,7 @@ void getOrders_returnsUserOrders() { List commands = List.of( OrderItemCommand.of(product.getId(), 1) ); - purchasingFacade.createOrder(user.getUserId(), commands, "SAMSUNG", "4111-1111-1111-1111"); + purchasingFacade.createOrder(user.getUserId(), commands, null, "SAMSUNG", "4111-1111-1111-1111"); // act List orders = purchasingFacade.getOrders(user.getUserId()); @@ -347,7 +354,7 @@ void getOrder_returnsSingleOrder() { List commands = List.of( OrderItemCommand.of(product.getId(), 1) ); - OrderInfo createdOrder = purchasingFacade.createOrder(user.getUserId(), commands, "SAMSUNG", "4111-1111-1111-1111"); + OrderInfo createdOrder = purchasingFacade.createOrder(user.getUserId(), commands, null, "SAMSUNG", "4111-1111-1111-1111"); // act OrderInfo found = purchasingFacade.getOrder(user.getUserId(), createdOrder.orderId()); @@ -373,7 +380,7 @@ void getOrder_withDifferentUser_throwsException() { List commands = List.of( OrderItemCommand.of(product.getId(), 1) ); - OrderInfo user1Order = purchasingFacade.createOrder(user1Id, commands, "SAMSUNG", "4111-1111-1111-1111"); + OrderInfo user1Order = purchasingFacade.createOrder(user1Id, commands, null, "SAMSUNG", "4111-1111-1111-1111"); final Long orderId = user1Order.orderId(); // act & assert @@ -405,7 +412,7 @@ void createOrder_atomicityGuaranteed() { ); // act & assert - assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111")) + assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, null, "SAMSUNG", "4111-1111-1111-1111")) .isInstanceOf(CoreException.class) .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); @@ -444,26 +451,26 @@ void createOrder_success_allOperationsReflected() { OrderItemCommand.of(product1Id, 3), OrderItemCommand.of(product2Id, 2) ); - final int totalAmount = (10_000 * 3) + (15_000 * 2); // act - OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111"); + OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands, null, "SAMSUNG", "4111-1111-1111-1111"); // assert - // 주문이 정상적으로 생성되었는지 확인 - // createOrder는 주문을 PENDING 상태로 생성하고, PG 결제 요청은 afterCommit 콜백에서 비동기로 실행됨 + // ✅ EDA 원칙: createOrder는 주문을 PENDING 상태로 생성하고 OrderEvent.OrderCreated 이벤트를 발행 + // ✅ ProductEventHandler가 OrderEvent.OrderCreated를 구독하여 재고 차감 처리 + // ✅ PaymentEventHandler가 PaymentEvent.PaymentRequested를 구독하여 Payment 생성 및 PG 결제 요청 처리 assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING); assertThat(orderInfo.items()).hasSize(2); - // 재고가 정상적으로 차감되었는지 확인 + // ✅ 이벤트 핸들러가 재고 차감 처리 (통합 테스트이므로 실제 이벤트 핸들러가 실행됨) Product savedProduct1 = productRepository.findById(product1Id).orElseThrow(); Product savedProduct2 = productRepository.findById(product2Id).orElseThrow(); assertThat(savedProduct1.getStock()).isEqualTo(initialStock1 - 3); assertThat(savedProduct2.getStock()).isEqualTo(initialStock2 - 2); - // 포인트가 정상적으로 차감되었는지 확인 + // 포인트 차감 확인 (usedPoint가 null이므로 포인트 차감 없음) User savedUser = userRepository.findByUserId(userId); - assertThat(savedUser.getPoint().getValue()).isEqualTo(initialPoint - totalAmount); + assertThat(savedUser.getPoint().getValue()).isEqualTo(initialPoint); // 포인트 차감 없음 // 주문이 저장되었는지 확인 List orders = purchasingFacade.getOrders(userId); @@ -488,17 +495,14 @@ void createOrder_withFixedAmountCoupon_success() { ); // act - OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111"); + OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands, null, "SAMSUNG", "4111-1111-1111-1111"); // assert - // createOrder는 주문을 PENDING 상태로 생성하고, PG 결제 요청은 afterCommit 콜백에서 비동기로 실행됨 + // ✅ EDA 원칙: PurchasingFacade는 주문을 생성하고 이벤트를 발행하는 책임만 가짐 + // ✅ 쿠폰 할인 적용은 CouponEventHandler와 OrderEventHandler의 책임 assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING); - assertThat(orderInfo.totalAmount()).isEqualTo(5_000); // 10,000 - 5,000 = 5,000 - - // 쿠폰이 사용되었는지 확인 - UserCoupon savedUserCoupon = userCouponRepository.findByUserIdAndCouponCode(user.getId(), "FIXED5000") - .orElseThrow(); - assertThat(savedUserCoupon.getIsUsed()).isTrue(); + assertThat(orderInfo.orderId()).isNotNull(); + // 주의: 쿠폰 할인 적용 및 쿠폰 사용 여부 검증은 CouponEventHandler/OrderEventHandler 테스트에서 수행 } @Test @@ -518,83 +522,18 @@ void createOrder_withPercentageCoupon_success() { ); // act - OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111"); + OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands, null, "SAMSUNG", "4111-1111-1111-1111"); // assert - // createOrder는 주문을 PENDING 상태로 생성하고, PG 결제 요청은 afterCommit 콜백에서 비동기로 실행됨 + // ✅ EDA 원칙: PurchasingFacade는 주문을 생성하고 이벤트를 발행하는 책임만 가짐 + // ✅ 쿠폰 할인 적용은 CouponEventHandler와 OrderEventHandler의 책임 assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING); - assertThat(orderInfo.totalAmount()).isEqualTo(8_000); // 10,000 - (10,000 * 20%) = 8,000 - - // 쿠폰이 사용되었는지 확인 - UserCoupon savedUserCoupon = userCouponRepository.findByUserIdAndCouponCode(user.getId(), "PERCENT20") - .orElseThrow(); - assertThat(savedUserCoupon.getIsUsed()).isTrue(); - } - - @Test - @DisplayName("존재하지 않는 쿠폰으로 주문하면 실패한다") - void createOrder_withNonExistentCoupon_shouldFail() { - // arrange - User user = createAndSaveUser("testuser", "test@example.com", 50_000L); - String userId = user.getUserId(); - Brand brand = createAndSaveBrand("브랜드"); - Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); - - List commands = List.of( - new OrderItemCommand(product.getId(), 1, "NON_EXISTENT") - ); - - // act & assert - assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111")) - .isInstanceOf(CoreException.class) - .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); - } - - @Test - @DisplayName("사용자가 소유하지 않은 쿠폰으로 주문하면 실패한다") - void createOrder_withCouponNotOwnedByUser_shouldFail() { - // arrange - User user = createAndSaveUser("testuser", "test@example.com", 50_000L); - String userId = user.getUserId(); - Brand brand = createAndSaveBrand("브랜드"); - Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); - - Coupon coupon = Coupon.of("COUPON001", CouponType.FIXED_AMOUNT, 5_000); - couponRepository.save(coupon); - // 사용자에게 쿠폰을 지급하지 않음 - - List commands = List.of( - new OrderItemCommand(product.getId(), 1, "COUPON001") - ); - - // act & assert - assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111")) - .isInstanceOf(CoreException.class) - .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); + assertThat(orderInfo.orderId()).isNotNull(); + // 주의: 쿠폰 할인 적용 및 쿠폰 사용 여부 검증은 CouponEventHandler/OrderEventHandler 테스트에서 수행 } - @Test - @DisplayName("이미 사용된 쿠폰으로 주문하면 실패한다") - void createOrder_withUsedCoupon_shouldFail() { - // arrange - User user = createAndSaveUser("testuser", "test@example.com", 50_000L); - String userId = user.getUserId(); - Brand brand = createAndSaveBrand("브랜드"); - Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId()); - - Coupon coupon = createAndSaveCoupon("USED_COUPON", CouponType.FIXED_AMOUNT, 5_000); - UserCoupon userCoupon = createAndSaveUserCoupon(user.getId(), coupon); - userCoupon.use(); // 이미 사용 처리 - userCouponRepository.save(userCoupon); - - List commands = List.of( - new OrderItemCommand(product.getId(), 1, "USED_COUPON") - ); - - // act & assert - assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111")) - .isInstanceOf(CoreException.class) - .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); - } + // 주의: 쿠폰 검증 테스트는 CouponEventHandler 테스트로 이동해야 함 + // 쿠폰 검증(존재 여부, 소유 여부, 사용 가능 여부)은 CouponEventHandler에서 비동기로 처리되므로, + // PurchasingFacade에서는 검증할 수 없음 (이벤트 핸들러의 책임) } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/signup/SignUpFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/signup/SignUpFacadeIntegrationTest.java deleted file mode 100644 index c827cae1d..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/application/signup/SignUpFacadeIntegrationTest.java +++ /dev/null @@ -1,88 +0,0 @@ -package com.loopers.application.signup; - -import com.loopers.domain.user.Gender; -import com.loopers.domain.user.User; -import com.loopers.domain.user.UserTestFixture; -import com.loopers.infrastructure.user.UserJpaRepository; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -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.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 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 -@DisplayName("SignUpFacade 통합 테스트") -class SignUpFacadeIntegrationTest { - @Autowired - private SignUpFacade signUpFacade; - - @MockitoSpyBean - private UserJpaRepository userJpaRepository; - - @Autowired - private DatabaseCleanUp databaseCleanUp; - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @DisplayName("회원 가입에 관한 통합 테스트") - @Nested - class SignUp { - @DisplayName("회원가입시 User 저장이 수행된다.") - @ParameterizedTest - @EnumSource(Gender.class) - void returnsSignUpInfo_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.name()); - - // 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.name()); - - // act - CoreException result = assertThrows(CoreException.class, () -> - signUpFacade.signUp(userId, email, birthDate, gender.name()) - ); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT); - } - } -} - diff --git a/apps/commerce-api/src/test/java/com/loopers/application/user/PointEventHandlerTest.java b/apps/commerce-api/src/test/java/com/loopers/application/user/PointEventHandlerTest.java new file mode 100644 index 000000000..409e767c3 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/user/PointEventHandlerTest.java @@ -0,0 +1,175 @@ +package com.loopers.application.user; + +import com.loopers.domain.order.OrderEvent; +import com.loopers.domain.user.Gender; +import com.loopers.domain.user.Point; +import com.loopers.domain.user.PointEvent; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.event.ApplicationEvents; +import org.springframework.test.context.event.RecordApplicationEvents; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@DisplayName("PointEventHandler 포인트 사용 검증") +@RecordApplicationEvents +class PointEventHandlerTest { + + @Autowired + private PointEventHandler pointEventHandler; + + @Autowired + private UserRepository userRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private ApplicationEvents applicationEvents; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + // Helper methods for test fixtures + private User createAndSaveUser(String userId, String email, long point) { + User user = User.of(userId, email, "1990-01-01", Gender.MALE, Point.of(point)); + return userRepository.save(user); + } + + @Test + @DisplayName("포인트를 정상적으로 사용할 수 있다") + void handleOrderCreated_success() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + OrderEvent.OrderCreated event = new OrderEvent.OrderCreated( + 1L, + user.getId(), + null, // couponCode + 10_000, // subtotal + 10_000L, // usedPointAmount + List.of(), // orderItems + LocalDateTime.now() + ); + + // act + pointEventHandler.handleOrderCreated(event); + + // assert + // 포인트 사용 실패 이벤트는 발행되지 않아야 함 + assertThat(applicationEvents.stream(PointEvent.PointUsedFailed.class)).isEmpty(); + + // 포인트가 차감되었는지 확인 + User savedUser = Optional.ofNullable(userRepository.findById(user.getId())) + .orElseThrow(() -> new AssertionError("사용자를 찾을 수 없습니다.")); + assertThat(savedUser.getPointValue()).isEqualTo(40_000L); // 50,000 - 10,000 + } + + @Test + @DisplayName("포인트 잔액이 부족하면 포인트 사용 실패 이벤트가 발행된다") + void handleOrderCreated_publishesFailedEvent_whenInsufficientBalance() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 5_000L); + OrderEvent.OrderCreated event = new OrderEvent.OrderCreated( + 1L, + user.getId(), + null, // couponCode + 10_000, // subtotal + 10_000L, // usedPointAmount + List.of(), // orderItems + LocalDateTime.now() + ); + + // act + try { + pointEventHandler.handleOrderCreated(event); + } catch (Exception e) { + // 예외는 예상된 동작 + } + + // assert + // 포인트 사용 실패 이벤트가 발행되었는지 확인 + assertThat(applicationEvents.stream(PointEvent.PointUsedFailed.class)).hasSize(1); + PointEvent.PointUsedFailed failedEvent = applicationEvents.stream(PointEvent.PointUsedFailed.class) + .findFirst() + .orElseThrow(); + assertThat(failedEvent.orderId()).isEqualTo(1L); + assertThat(failedEvent.userId()).isEqualTo(user.getId()); + assertThat(failedEvent.usedPointAmount()).isEqualTo(10_000L); + assertThat(failedEvent.failureReason()).contains("포인트가 부족합니다"); + + // 포인트가 차감되지 않았는지 확인 + User savedUser = Optional.ofNullable(userRepository.findById(user.getId())) + .orElseThrow(() -> new AssertionError("사용자를 찾을 수 없습니다.")); + assertThat(savedUser.getPointValue()).isEqualTo(5_000L); // 변경 없음 + } + + @Test + @DisplayName("포인트 잔액이 정확히 사용 요청 금액과 같으면 정상적으로 사용할 수 있다") + void handleOrderCreated_success_whenBalanceEqualsUsedAmount() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 10_000L); + OrderEvent.OrderCreated event = new OrderEvent.OrderCreated( + 1L, + user.getId(), + null, // couponCode + 10_000, // subtotal + 10_000L, // usedPointAmount + List.of(), // orderItems + LocalDateTime.now() + ); + + // act + pointEventHandler.handleOrderCreated(event); + + // assert + // 포인트 사용 실패 이벤트는 발행되지 않아야 함 + assertThat(applicationEvents.stream(PointEvent.PointUsedFailed.class)).isEmpty(); + + // 포인트가 차감되었는지 확인 + User savedUser = Optional.ofNullable(userRepository.findById(user.getId())) + .orElseThrow(() -> new AssertionError("사용자를 찾을 수 없습니다.")); + assertThat(savedUser.getPointValue()).isEqualTo(0L); // 10,000 - 10,000 + } + + @Test + @DisplayName("포인트 사용량이 0이면 정상적으로 처리된다") + void handleOrderCreated_success_whenUsedAmountIsZero() { + // arrange + User user = createAndSaveUser("testuser", "test@example.com", 50_000L); + OrderEvent.OrderCreated event = new OrderEvent.OrderCreated( + 1L, + user.getId(), + null, // couponCode + 10_000, // subtotal + 0L, // usedPointAmount + List.of(), // orderItems + LocalDateTime.now() + ); + + // act + pointEventHandler.handleOrderCreated(event); + + // assert + // 포인트 사용 실패 이벤트는 발행되지 않아야 함 + assertThat(applicationEvents.stream(PointEvent.PointUsedFailed.class)).isEmpty(); + + // 포인트가 변경되지 않았는지 확인 + User savedUser = Optional.ofNullable(userRepository.findById(user.getId())) + .orElseThrow(() -> new AssertionError("사용자를 찾을 수 없습니다.")); + assertThat(savedUser.getPointValue()).isEqualTo(50_000L); // 변경 없음 + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/application/pointwallet/PointWalletFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/user/UserServiceIntegrationTest.java similarity index 53% rename from apps/commerce-api/src/test/java/com/loopers/application/pointwallet/PointWalletFacadeIntegrationTest.java rename to apps/commerce-api/src/test/java/com/loopers/application/user/UserServiceIntegrationTest.java index cebc13975..db7a1de10 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/pointwallet/PointWalletFacadeIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/user/UserServiceIntegrationTest.java @@ -1,8 +1,10 @@ -package com.loopers.application.pointwallet; +package com.loopers.application.user; -import com.loopers.application.signup.SignUpFacade; import com.loopers.domain.user.Gender; +import com.loopers.domain.user.Point; +import com.loopers.domain.user.User; import com.loopers.domain.user.UserTestFixture; +import com.loopers.infrastructure.user.UserJpaRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import com.loopers.utils.DatabaseCleanUp; @@ -12,21 +14,27 @@ 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 static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; 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 -@DisplayName("PointWalletFacade 통합 테스트") -class PointWalletFacadeIntegrationTest { +@DisplayName("UserService 통합 테스트") +class UserServiceIntegrationTest { @Autowired - private PointWalletFacade pointWalletFacade; + private UserService userService; - @Autowired - private SignUpFacade signUpFacade; + @MockitoSpyBean + private UserJpaRepository userJpaRepository; @Autowired private DatabaseCleanUp databaseCleanUp; @@ -36,6 +44,57 @@ void tearDown() { databaseCleanUp.truncateAllTables(); } + /** + * 테스트용 사용자를 생성합니다. + */ + private void createUser(String userId, String email, String birthDate, Gender gender) { + userService.create(userId, email, birthDate, gender, Point.of(0L)); + } + + @DisplayName("회원 가입에 관한 통합 테스트") + @Nested + class SignUp { + @DisplayName("회원가입시 User 저장이 수행된다.") + @ParameterizedTest + @EnumSource(Gender.class) + void createsUser_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 + User user = userService.create(userId, email, birthDate, gender, Point.of(0L)); + + // assert + assertAll( + () -> assertThat(user).isNotNull(), + () -> assertThat(user.getUserId()).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; + userService.create(userId, email, birthDate, gender, Point.of(0L)); + + // act + CoreException result = assertThrows(CoreException.class, () -> + userService.create(userId, email, birthDate, gender, Point.of(0L)) + ); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + } + @DisplayName("포인트 조회에 관한 통합 테스트") @Nested class PointInfo { @@ -47,10 +106,10 @@ void returnsPoints_whenUserExists(Gender gender) { String userId = UserTestFixture.ValidUser.USER_ID; String email = UserTestFixture.ValidUser.EMAIL; String birthDate = UserTestFixture.ValidUser.BIRTH_DATE; - signUpFacade.signUp(userId, email, birthDate, gender.name()); + createUser(userId, email, birthDate, gender); // act - PointWalletFacade.PointsInfo pointsInfo = pointWalletFacade.getPoints(userId); + UserService.PointsInfo pointsInfo = userService.getPoints(userId); // assert assertAll( @@ -67,7 +126,7 @@ void throwsException_whenUserDoesNotExist() { String userId = "unknown"; // act & assert - assertThatThrownBy(() -> pointWalletFacade.getPoints(userId)) + assertThatThrownBy(() -> userService.getPoints(userId)) .isInstanceOf(CoreException.class) .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); } @@ -84,11 +143,11 @@ void chargesPoints_success(Gender gender) { String userId = UserTestFixture.ValidUser.USER_ID; String email = UserTestFixture.ValidUser.EMAIL; String birthDate = UserTestFixture.ValidUser.BIRTH_DATE; - signUpFacade.signUp(userId, email, birthDate, gender.name()); + createUser(userId, email, birthDate, gender); Long chargeAmount = 10_000L; // act - PointWalletFacade.PointsInfo pointsInfo = pointWalletFacade.chargePoint(userId, chargeAmount); + UserService.PointsInfo pointsInfo = userService.chargePoint(userId, chargeAmount); // assert assertAll( @@ -106,10 +165,9 @@ void throwsException_whenUserDoesNotExist() { Long chargeAmount = 10_000L; // act & assert - assertThatThrownBy(() -> pointWalletFacade.chargePoint(userId, chargeAmount)) + assertThatThrownBy(() -> userService.chargePoint(userId, chargeAmount)) .isInstanceOf(CoreException.class) .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); } } } - diff --git a/apps/commerce-api/src/test/java/com/loopers/application/userinfo/UserInfoFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/userinfo/UserInfoFacadeIntegrationTest.java deleted file mode 100644 index 78efa5e30..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/application/userinfo/UserInfoFacadeIntegrationTest.java +++ /dev/null @@ -1,80 +0,0 @@ -package com.loopers.application.userinfo; - -import com.loopers.application.signup.SignUpFacade; -import com.loopers.domain.user.Gender; -import com.loopers.domain.user.UserTestFixture; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.EnumSource; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; - -import java.time.LocalDate; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.junit.jupiter.api.Assertions.assertAll; - -@SpringBootTest -@DisplayName("UserInfoFacade 통합 테스트") -class UserInfoFacadeIntegrationTest { - @Autowired - private UserInfoFacade userInfoFacade; - - @Autowired - private SignUpFacade signUpFacade; - - @Autowired - private DatabaseCleanUp databaseCleanUp; - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @DisplayName("회원 조회에 관한 통합 테스트") - @Nested - class UserInfo { - @DisplayName("해당 ID 의 회원이 존재할 경우, 회원 정보가 반환된다.") - @ParameterizedTest - @EnumSource(Gender.class) - void returnsUserInfo_whenUserExists(Gender gender) { - // arrange - String userId = UserTestFixture.ValidUser.USER_ID; - String email = UserTestFixture.ValidUser.EMAIL; - String birthDate = UserTestFixture.ValidUser.BIRTH_DATE; - signUpFacade.signUp(userId, email, birthDate, gender.name()); - - // act - UserInfoFacade.UserInfo userInfo = userInfoFacade.getUserInfo(userId); - - // assert - assertAll( - () -> assertThat(userInfo).isNotNull(), - () -> assertThat(userInfo.userId()).isEqualTo(userId), - () -> assertThat(userInfo.email()).isEqualTo(email), - () -> assertThat(userInfo.birthDate()).isEqualTo(LocalDate.parse(birthDate)), - () -> assertThat(userInfo.gender()).isEqualTo(gender) - ); - } - - @DisplayName("해당 ID 의 회원이 존재하지 않을 경우, 예외가 발생한다.") - @Test - void throwsException_whenUserDoesNotExist() { - // arrange - String userId = "unknown"; - - // act & assert - assertThatThrownBy(() -> userInfoFacade.getUserInfo(userId)) - .isInstanceOf(CoreException.class) - .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); - } - } -} - diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponServiceTest.java new file mode 100644 index 000000000..4f1230c27 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponServiceTest.java @@ -0,0 +1,198 @@ +package com.loopers.domain.coupon; + +import com.loopers.application.coupon.ApplyCouponCommand; +import com.loopers.application.coupon.CouponService; +import com.loopers.domain.coupon.discount.CouponDiscountStrategy; +import com.loopers.domain.coupon.discount.CouponDiscountStrategyFactory; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.orm.ObjectOptimisticLockingFailureException; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.*; + +/** + * CouponService 테스트. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("CouponService") +public class CouponServiceTest { + + @Mock + private CouponRepository couponRepository; + + @Mock + private UserCouponRepository userCouponRepository; + + @Mock + private CouponDiscountStrategyFactory couponDiscountStrategyFactory; + + @Mock + private CouponDiscountStrategy couponDiscountStrategy; + + @InjectMocks + private CouponService couponService; + + @DisplayName("쿠폰 적용") + @Nested + class ApplyCoupon { + @DisplayName("쿠폰을 적용하여 할인 금액을 계산하고 쿠폰을 사용 처리할 수 있다.") + @Test + void appliesCouponAndCalculatesDiscount() { + // arrange + Long userId = 1L; + String couponCode = "FIXED5000"; + Integer subtotal = 10_000; + Integer expectedDiscount = 5_000; + + Coupon coupon = Coupon.of(couponCode, CouponType.FIXED_AMOUNT, 5_000); + UserCoupon userCoupon = UserCoupon.of(userId, coupon); + + when(couponRepository.findByCode(couponCode)).thenReturn(Optional.of(coupon)); + when(userCouponRepository.findByUserIdAndCouponCodeForUpdate(userId, couponCode)) + .thenReturn(Optional.of(userCoupon)); + when(couponDiscountStrategyFactory.getStrategy(CouponType.FIXED_AMOUNT)) + .thenReturn(couponDiscountStrategy); + when(couponDiscountStrategy.calculateDiscountAmount(subtotal, 5_000)) + .thenReturn(expectedDiscount); + when(userCouponRepository.save(any(UserCoupon.class))).thenReturn(userCoupon); + + // act + Integer result = couponService.applyCoupon(new ApplyCouponCommand(userId, couponCode, subtotal)); + + // assert + assertThat(result).isEqualTo(expectedDiscount); + assertThat(userCoupon.getIsUsed()).isTrue(); // 쿠폰이 사용되었는지 확인 + verify(couponRepository, times(1)).findByCode(couponCode); + verify(userCouponRepository, times(1)).findByUserIdAndCouponCodeForUpdate(userId, couponCode); + verify(userCouponRepository, times(1)).save(userCoupon); + } + + @DisplayName("쿠폰을 찾을 수 없으면 예외가 발생한다.") + @Test + void throwsException_whenCouponNotFound() { + // arrange + Long userId = 1L; + String couponCode = "NON_EXISTENT"; + Integer subtotal = 10_000; + + when(couponRepository.findByCode(couponCode)).thenReturn(Optional.empty()); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + couponService.applyCoupon(new ApplyCouponCommand(userId, couponCode, subtotal)); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + assertThat(result.getMessage()).contains("쿠폰을 찾을 수 없습니다"); + verify(couponRepository, times(1)).findByCode(couponCode); + verify(userCouponRepository, never()).findByUserIdAndCouponCodeForUpdate(any(), any()); + } + + @DisplayName("사용자가 소유한 쿠폰을 찾을 수 없으면 예외가 발생한다.") + @Test + void throwsException_whenUserCouponNotFound() { + // arrange + Long userId = 1L; + String couponCode = "FIXED5000"; + Integer subtotal = 10_000; + + Coupon coupon = Coupon.of(couponCode, CouponType.FIXED_AMOUNT, 5_000); + + when(couponRepository.findByCode(couponCode)).thenReturn(Optional.of(coupon)); + when(userCouponRepository.findByUserIdAndCouponCodeForUpdate(userId, couponCode)) + .thenReturn(Optional.empty()); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + couponService.applyCoupon(new ApplyCouponCommand(userId, couponCode, subtotal)); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + assertThat(result.getMessage()).contains("사용자가 소유한 쿠폰을 찾을 수 없습니다"); + verify(couponRepository, times(1)).findByCode(couponCode); + verify(userCouponRepository, times(1)).findByUserIdAndCouponCodeForUpdate(userId, couponCode); + } + + @DisplayName("이미 사용된 쿠폰이면 예외가 발생한다.") + @Test + void throwsException_whenCouponAlreadyUsed() { + // arrange + Long userId = 1L; + String couponCode = "USED_COUPON"; + Integer subtotal = 10_000; + + Coupon coupon = Coupon.of(couponCode, CouponType.FIXED_AMOUNT, 5_000); + UserCoupon userCoupon = UserCoupon.of(userId, coupon); + userCoupon.use(); // 이미 사용 처리 + + when(couponRepository.findByCode(couponCode)).thenReturn(Optional.of(coupon)); + when(userCouponRepository.findByUserIdAndCouponCodeForUpdate(userId, couponCode)) + .thenReturn(Optional.of(userCoupon)); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + couponService.applyCoupon(new ApplyCouponCommand(userId, couponCode, subtotal)); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(result.getMessage()).contains("이미 사용된 쿠폰입니다"); + verify(couponRepository, times(1)).findByCode(couponCode); + verify(userCouponRepository, times(1)).findByUserIdAndCouponCodeForUpdate(userId, couponCode); + verify(userCouponRepository, never()).save(any(UserCoupon.class)); + } + + @DisplayName("낙관적 락 충돌 시 예외가 발생한다.") + @Test + void throwsException_whenOptimisticLockConflict() { + // arrange + Long userId = 1L; + String couponCode = "FIXED5000"; + Integer subtotal = 10_000; + + Coupon coupon = Coupon.of(couponCode, CouponType.FIXED_AMOUNT, 5_000); + UserCoupon userCoupon = UserCoupon.of(userId, coupon); + + when(couponRepository.findByCode(couponCode)).thenReturn(Optional.of(coupon)); + when(userCouponRepository.findByUserIdAndCouponCodeForUpdate(userId, couponCode)) + .thenReturn(Optional.of(userCoupon)); + // Coupon.calculateDiscountAmount()가 호출될 때 getStrategy()가 호출되므로 stubbing 필요 + when(couponDiscountStrategyFactory.getStrategy(any(CouponType.class))) + .thenReturn(couponDiscountStrategy); + when(couponDiscountStrategy.calculateDiscountAmount(anyInt(), anyInt())) + .thenReturn(5_000); + when(userCouponRepository.save(any(UserCoupon.class))) + .thenThrow(new ObjectOptimisticLockingFailureException(UserCoupon.class, userCoupon)); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + couponService.applyCoupon(new ApplyCouponCommand(userId, couponCode, subtotal)); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT); + assertThat(result.getMessage()).contains("쿠폰이 이미 사용되었습니다"); + verify(couponRepository, times(1)).findByCode(couponCode); + verify(userCouponRepository, times(1)).findByUserIdAndCouponCodeForUpdate(userId, couponCode); + verify(couponDiscountStrategyFactory, times(1)).getStrategy(CouponType.FIXED_AMOUNT); + verify(couponDiscountStrategy, times(1)).calculateDiscountAmount(subtotal, 5_000); + verify(userCouponRepository, times(1)).save(userCoupon); + } + } +} + 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..beed8dc1d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java @@ -0,0 +1,473 @@ +package com.loopers.domain.order; + +import com.loopers.application.order.OrderService; +import com.loopers.domain.order.OrderEvent; +import com.loopers.domain.payment.PaymentStatus; +import com.loopers.domain.product.Product; +import com.loopers.domain.user.Gender; +import com.loopers.domain.user.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.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * OrderService 테스트. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("OrderService") +public class OrderServiceTest { + + @Mock + private OrderRepository orderRepository; + + @Mock + private OrderEventPublisher orderEventPublisher; + + @InjectMocks + private OrderService orderService; + + @DisplayName("주문 저장") + @Nested + class SaveOrder { + @DisplayName("주문을 저장할 수 있다.") + @Test + void savesOrder() { + // arrange + Order order = Order.of( + OrderTestFixture.ValidOrder.USER_ID, + OrderTestFixture.ValidOrderItem.createMultipleItems() + ); + when(orderRepository.save(any(Order.class))).thenReturn(order); + + // act + Order result = orderService.save(order); + + // assert + assertThat(result).isNotNull(); + verify(orderRepository, times(1)).save(order); + } + } + + @DisplayName("주문 조회") + @Nested + class FindOrder { + @DisplayName("주문 ID로 주문을 조회할 수 있다.") + @Test + void findsById() { + // arrange + Long orderId = 1L; + Order expectedOrder = Order.of( + OrderTestFixture.ValidOrder.USER_ID, + OrderTestFixture.ValidOrderItem.createMultipleItems() + ); + when(orderRepository.findById(orderId)).thenReturn(Optional.of(expectedOrder)); + + // act + Order result = orderService.getById(orderId); + + // assert + assertThat(result).isEqualTo(expectedOrder); + verify(orderRepository, times(1)).findById(orderId); + } + + @DisplayName("주문 ID로 주문을 조회할 수 있다 (Optional 반환).") + @Test + void findsByIdOptional() { + // arrange + Long orderId = 1L; + Order expectedOrder = Order.of( + OrderTestFixture.ValidOrder.USER_ID, + OrderTestFixture.ValidOrderItem.createMultipleItems() + ); + when(orderRepository.findById(orderId)).thenReturn(Optional.of(expectedOrder)); + + // act + Optional result = orderService.getOrder(orderId); + + // assert + assertThat(result).isPresent(); + assertThat(result.get()).isEqualTo(expectedOrder); + verify(orderRepository, times(1)).findById(orderId); + } + + @DisplayName("주문을 찾을 수 없으면 예외가 발생한다.") + @Test + void throwsException_whenOrderNotFound() { + // arrange + Long orderId = 999L; + when(orderRepository.findById(orderId)).thenReturn(Optional.empty()); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + orderService.getById(orderId); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + verify(orderRepository, times(1)).findById(orderId); + } + + @DisplayName("사용자 ID로 주문 목록을 조회할 수 있다.") + @Test + void findsAllByUserId() { + // arrange + Long userId = OrderTestFixture.ValidOrder.USER_ID; + List expectedOrders = List.of( + Order.of(userId, OrderTestFixture.ValidOrderItem.createMultipleItems()) + ); + when(orderRepository.findAllByUserId(userId)).thenReturn(expectedOrders); + + // act + List result = orderService.getOrdersByUserId(userId); + + // assert + assertThat(result).hasSize(1); + assertThat(result).isEqualTo(expectedOrders); + verify(orderRepository, times(1)).findAllByUserId(userId); + } + + @DisplayName("주문 상태로 주문 목록을 조회할 수 있다.") + @Test + void findsAllByStatus() { + // arrange + OrderStatus status = OrderStatus.PENDING; + List expectedOrders = List.of( + Order.of(OrderTestFixture.ValidOrder.USER_ID, OrderTestFixture.ValidOrderItem.createMultipleItems()) + ); + when(orderRepository.findAllByStatus(status)).thenReturn(expectedOrders); + + // act + List result = orderService.getOrdersByStatus(status); + + // assert + assertThat(result).hasSize(1); + assertThat(result).isEqualTo(expectedOrders); + verify(orderRepository, times(1)).findAllByStatus(status); + } + } + + @DisplayName("주문 생성") + @Nested + class CreateOrder { + @DisplayName("주문을 생성할 수 있다 (쿠폰 없음).") + @Test + void createsOrder() { + // arrange + Long userId = OrderTestFixture.ValidOrder.USER_ID; + List items = OrderTestFixture.ValidOrderItem.createMultipleItems(); + Order expectedOrder = Order.of(userId, items); + when(orderRepository.save(any(Order.class))).thenReturn(expectedOrder); + + // act + Order result = orderService.create(userId, items); + + // assert + assertThat(result).isNotNull(); + verify(orderRepository, times(1)).save(any(Order.class)); + } + + @DisplayName("주문을 생성할 수 있다 (쿠폰 포함).") + @Test + void createsOrderWithCoupon() { + // arrange + Long userId = OrderTestFixture.ValidOrder.USER_ID; + List items = OrderTestFixture.ValidOrderItem.createMultipleItems(); + String couponCode = "COUPON123"; + Integer discountAmount = 1000; + Order expectedOrder = Order.of(userId, items, couponCode, discountAmount); + when(orderRepository.save(any(Order.class))).thenReturn(expectedOrder); + + // act + Order result = orderService.create(userId, items, couponCode, discountAmount); + + // assert + assertThat(result).isNotNull(); + verify(orderRepository, times(1)).save(any(Order.class)); + } + } + + @DisplayName("주문 완료") + @Nested + class CompleteOrder { + @DisplayName("주문을 완료 상태로 변경할 수 있다.") + @Test + void completesOrder() { + // arrange + Long orderId = 1L; + Order order = Order.of( + OrderTestFixture.ValidOrder.USER_ID, + OrderTestFixture.ValidOrderItem.createMultipleItems() + ); + when(orderRepository.findById(orderId)).thenReturn(Optional.of(order)); + when(orderRepository.save(any(Order.class))).thenReturn(order); + + // act + Order result = orderService.completeOrder(orderId); + + // assert + assertThat(result).isNotNull(); + verify(orderRepository, times(1)).findById(orderId); + verify(orderRepository, times(1)).save(order); + // 상태 변경 검증은 OrderTest에서 이미 검증했으므로 제거 + } + + @DisplayName("주문을 찾을 수 없으면 예외가 발생한다.") + @Test + void throwsException_whenOrderNotFound() { + // arrange + Long orderId = 999L; + when(orderRepository.findById(orderId)).thenReturn(Optional.empty()); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + orderService.completeOrder(orderId); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + verify(orderRepository, times(1)).findById(orderId); + verify(orderRepository, never()).save(any(Order.class)); + } + } + + @DisplayName("주문 취소") + @Nested + class CancelOrder { + @DisplayName("주문을 취소하고 재고를 원복하며 포인트를 환불할 수 있다.") + @Test + void cancelsOrderAndRecoversResources() { + // arrange + Long userId = OrderTestFixture.ValidOrder.USER_ID; + User user = createUser(userId); + Order order = Order.of(userId, OrderTestFixture.ValidOrderItem.createMultipleItems()); + + List items = order.getItems(); + Product product1 = createProduct(items.get(0).getProductId()); + Product product2 = createProduct(items.get(1).getProductId()); + List products = List.of(product1, product2); + Long refundPointAmount = 5000L; + + when(orderRepository.save(any(Order.class))).thenReturn(order); + + // act + orderService.cancelOrder(order, products, user, refundPointAmount); + + // assert + verify(orderRepository, times(1)).save(order); + verify(product1, times(1)).increaseStock(items.get(0).getQuantity()); + verify(product2, times(1)).increaseStock(items.get(1).getQuantity()); + // 상태 변경 검증은 OrderTest에서 이미 검증했으므로 제거 + } + + @DisplayName("주문이 null이면 예외가 발생한다.") + @Test + void throwsException_whenOrderIsNull() { + // arrange + User user = createUser(OrderTestFixture.ValidOrder.USER_ID); + List products = List.of(); + Long refundPointAmount = 0L; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + orderService.cancelOrder(null, products, user, refundPointAmount); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + verify(orderRepository, never()).save(any(Order.class)); + } + + @DisplayName("사용자가 null이면 예외가 발생한다.") + @Test + void throwsException_whenUserIsNull() { + // arrange + Order order = Order.of( + OrderTestFixture.ValidOrder.USER_ID, + OrderTestFixture.ValidOrderItem.createMultipleItems() + ); + List products = List.of(); + Long refundPointAmount = 0L; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + orderService.cancelOrder(order, products, null, refundPointAmount); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + verify(orderRepository, never()).save(any(Order.class)); + } + + @DisplayName("포인트를 사용하지 않은 주문은 포인트 환불 없이 취소할 수 있다.") + @Test + void cancelsOrderWithoutPointRefund() { + // arrange + Long userId = OrderTestFixture.ValidOrder.USER_ID; + User user = createUser(userId); + Order order = Order.of(userId, OrderTestFixture.ValidOrderItem.createMultipleItems()); + + List items = order.getItems(); + Product product1 = createProduct(items.get(0).getProductId()); + Product product2 = createProduct(items.get(1).getProductId()); + List products = List.of(product1, product2); + Long refundPointAmount = 0L; + + when(orderRepository.save(any(Order.class))).thenReturn(order); + + // act + orderService.cancelOrder(order, products, user, refundPointAmount); + + // assert + verify(orderRepository, times(1)).save(order); + verify(product1, times(1)).increaseStock(items.get(0).getQuantity()); + verify(product2, times(1)).increaseStock(items.get(1).getQuantity()); + } + } + + @DisplayName("결제 결과에 따른 주문 상태 업데이트") + @Nested + class UpdateStatusByPaymentResult { + @DisplayName("결제 성공 시 주문을 완료 상태로 변경할 수 있다.") + @Test + void completesOrder_whenPaymentSuccess() { + // arrange + Order order = Order.of( + OrderTestFixture.ValidOrder.USER_ID, + OrderTestFixture.ValidOrderItem.createMultipleItems() + ); + when(orderRepository.save(any(Order.class))).thenReturn(order); + + // act + orderService.updateStatusByPaymentResult(order, PaymentStatus.SUCCESS, null, null); + + // assert + verify(orderRepository, times(1)).save(order); + verify(orderEventPublisher, times(1)).publish(any(OrderEvent.OrderCompleted.class)); + // 상태 변경 검증은 OrderTest에서 이미 검증했으므로 제거 + } + + @DisplayName("결제 실패 시 주문을 취소 상태로 변경할 수 있다.") + @Test + void cancelsOrder_whenPaymentFailed() { + // arrange + Order order = Order.of( + OrderTestFixture.ValidOrder.USER_ID, + OrderTestFixture.ValidOrderItem.createMultipleItems() + ); + when(orderRepository.save(any(Order.class))).thenReturn(order); + String reason = "결제 실패"; + Long refundPointAmount = 0L; + + // act + orderService.updateStatusByPaymentResult(order, PaymentStatus.FAILED, reason, refundPointAmount); + + // assert + verify(orderRepository, times(1)).save(order); + verify(orderEventPublisher, times(1)).publish(any(OrderEvent.OrderCanceled.class)); + // 상태 변경 검증은 OrderTest에서 이미 검증했으므로 제거 + } + + @DisplayName("결제 대기 상태면 주문 상태를 유지한다.") + @Test + void maintainsOrderStatus_whenPaymentPending() { + // arrange + Order order = Order.of( + OrderTestFixture.ValidOrder.USER_ID, + OrderTestFixture.ValidOrderItem.createMultipleItems() + ); + + // act + orderService.updateStatusByPaymentResult(order, PaymentStatus.PENDING, null, null); + + // assert + verify(orderRepository, never()).save(any(Order.class)); + verify(orderEventPublisher, never()).publish(any(OrderEvent.OrderCompleted.class)); + verify(orderEventPublisher, never()).publish(any(OrderEvent.OrderCanceled.class)); + } + + @DisplayName("이미 완료된 주문은 처리하지 않는다.") + @Test + void skipsProcessing_whenOrderAlreadyCompleted() { + // arrange + Order order = Order.of( + OrderTestFixture.ValidOrder.USER_ID, + OrderTestFixture.ValidOrderItem.createMultipleItems() + ); + order.complete(); // 이미 완료 상태 + + // act + orderService.updateStatusByPaymentResult(order, PaymentStatus.SUCCESS, null, null); + + // assert + verify(orderRepository, never()).save(any(Order.class)); + verify(orderEventPublisher, never()).publish(any(OrderEvent.OrderCompleted.class)); + verify(orderEventPublisher, never()).publish(any(OrderEvent.OrderCanceled.class)); + } + + @DisplayName("이미 취소된 주문은 처리하지 않는다.") + @Test + void skipsProcessing_whenOrderAlreadyCanceled() { + // arrange + Order order = Order.of( + OrderTestFixture.ValidOrder.USER_ID, + OrderTestFixture.ValidOrderItem.createMultipleItems() + ); + order.cancel(); // 이미 취소 상태 + + // act + orderService.updateStatusByPaymentResult(order, PaymentStatus.FAILED, "결제 실패", 0L); + + // assert + verify(orderRepository, never()).save(any(Order.class)); + verify(orderEventPublisher, never()).publish(any(OrderEvent.OrderCompleted.class)); + verify(orderEventPublisher, never()).publish(any(OrderEvent.OrderCanceled.class)); + } + + @DisplayName("주문이 null이면 예외가 발생한다.") + @Test + void throwsException_whenOrderIsNull() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + orderService.updateStatusByPaymentResult(null, PaymentStatus.SUCCESS, null, null); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + verify(orderRepository, never()).save(any(Order.class)); + } + } + + private User createUser(Long userId) { + return User.of( + String.valueOf(userId), + "test@example.com", + "1990-01-01", + Gender.MALE, + Point.of(0L) + ); + } + + private Product createProduct(Long productId) { + // Mock을 사용하여 ID 설정 + Product mockedProduct = mock(Product.class); + when(mockedProduct.getId()).thenReturn(productId); + doNothing().when(mockedProduct).increaseStock(any(Integer.class)); + return mockedProduct; + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentServiceTest.java new file mode 100644 index 000000000..4ab528833 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentServiceTest.java @@ -0,0 +1,662 @@ +package com.loopers.domain.payment; + +import com.loopers.application.payment.PaymentService; +import com.loopers.domain.payment.PaymentRequest; +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 org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * PaymentService 테스트. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("PaymentService") +public class PaymentServiceTest { + + @Mock + private PaymentRepository paymentRepository; + + @Mock + private PaymentGateway paymentGateway; + + @Mock + private PaymentEventPublisher paymentEventPublisher; + + @InjectMocks + private PaymentService paymentService; + + @BeforeEach + void setUp() { + // @Value 어노테이션 필드 설정 + ReflectionTestUtils.setField(paymentService, "callbackBaseUrl", "http://localhost:8080"); + } + + @DisplayName("결제 생성") + @Nested + class CreatePayment { + @DisplayName("카드 결제를 생성할 수 있다.") + @Test + void createsCardPayment() { + // arrange + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + Long userId = PaymentTestFixture.ValidPayment.USER_ID; + CardType cardType = PaymentTestFixture.ValidPayment.CARD_TYPE; + String cardNo = PaymentTestFixture.ValidPayment.CARD_NO; + Long amount = PaymentTestFixture.ValidPayment.AMOUNT; + LocalDateTime requestedAt = PaymentTestFixture.ValidPayment.REQUESTED_AT; + + Payment expectedPayment = Payment.of(orderId, userId, cardType, cardNo, amount, requestedAt); + when(paymentRepository.save(any(Payment.class))).thenReturn(expectedPayment); + + // act + Payment result = paymentService.create(orderId, userId, cardType, cardNo, amount, requestedAt); + + // assert + assertThat(result).isNotNull(); + verify(paymentRepository, times(1)).save(any(Payment.class)); + } + + @DisplayName("포인트 결제를 생성할 수 있다.") + @Test + void createsPointPayment() { + // arrange + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + Long userId = PaymentTestFixture.ValidPayment.USER_ID; + Long totalAmount = PaymentTestFixture.ValidPayment.AMOUNT; + Long usedPoint = PaymentTestFixture.ValidPayment.FULL_POINT; // 포인트로 전액 결제 + LocalDateTime requestedAt = PaymentTestFixture.ValidPayment.REQUESTED_AT; + + Payment expectedPayment = Payment.of(orderId, userId, totalAmount, usedPoint, requestedAt); + when(paymentRepository.save(any(Payment.class))).thenReturn(expectedPayment); + + // act + Payment result = paymentService.create(orderId, userId, totalAmount, usedPoint, requestedAt); + + // assert + assertThat(result).isNotNull(); + verify(paymentRepository, times(1)).save(any(Payment.class)); + } + } + + @DisplayName("결제 상태 변경") + @Nested + class UpdatePaymentStatus { + @DisplayName("결제를 SUCCESS 상태로 전이할 수 있다.") + @Test + void transitionsToSuccess() { + // arrange + Long paymentId = 1L; + Payment payment = Payment.of( + PaymentTestFixture.ValidPayment.ORDER_ID, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + PaymentTestFixture.ValidPayment.REQUESTED_AT + ); + LocalDateTime completedAt = LocalDateTime.of(2025, 12, 1, 10, 5, 0); + + when(paymentRepository.findById(paymentId)).thenReturn(Optional.of(payment)); + when(paymentRepository.save(any(Payment.class))).thenReturn(payment); + + // act + paymentService.toSuccess(paymentId, completedAt, null); + + // assert + verify(paymentRepository, times(1)).findById(paymentId); + verify(paymentRepository, times(1)).save(payment); + // 상태 변경 검증은 PaymentTest에서 이미 검증했으므로 제거 + } + + @DisplayName("결제를 FAILED 상태로 전이할 수 있다.") + @Test + void transitionsToFailed() { + // arrange + Long paymentId = 1L; + Payment payment = Payment.of( + PaymentTestFixture.ValidPayment.ORDER_ID, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + PaymentTestFixture.ValidPayment.REQUESTED_AT + ); + LocalDateTime completedAt = LocalDateTime.of(2025, 12, 1, 10, 5, 0); + String failureReason = "카드 한도 초과"; + + when(paymentRepository.findById(paymentId)).thenReturn(Optional.of(payment)); + when(paymentRepository.save(any(Payment.class))).thenReturn(payment); + + // act + paymentService.toFailed(paymentId, failureReason, completedAt, null); + + // assert + verify(paymentRepository, times(1)).findById(paymentId); + verify(paymentRepository, times(1)).save(payment); + // 상태 변경 검증은 PaymentTest에서 이미 검증했으므로 제거 + } + + @DisplayName("결제를 찾을 수 없으면 예외가 발생한다.") + @Test + void throwsException_whenPaymentNotFound() { + // arrange + Long paymentId = 999L; + LocalDateTime completedAt = LocalDateTime.now(); + + when(paymentRepository.findById(paymentId)).thenReturn(Optional.empty()); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + paymentService.toSuccess(paymentId, completedAt, null); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + verify(paymentRepository, times(1)).findById(paymentId); + verify(paymentRepository, never()).save(any(Payment.class)); + } + } + + @DisplayName("결제 조회") + @Nested + class FindPayment { + @DisplayName("결제 ID로 결제를 조회할 수 있다.") + @Test + void findsById() { + // arrange + Long paymentId = 1L; + Payment expectedPayment = Payment.of( + PaymentTestFixture.ValidPayment.ORDER_ID, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + PaymentTestFixture.ValidPayment.REQUESTED_AT + ); + + when(paymentRepository.findById(paymentId)).thenReturn(Optional.of(expectedPayment)); + + // act + Payment result = paymentService.getPayment(paymentId); + + // assert + assertThat(result).isEqualTo(expectedPayment); + verify(paymentRepository, times(1)).findById(paymentId); + } + + @DisplayName("주문 ID로 결제를 조회할 수 있다.") + @Test + void findsByOrderId() { + // arrange + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + Payment expectedPayment = Payment.of( + orderId, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + PaymentTestFixture.ValidPayment.REQUESTED_AT + ); + + when(paymentRepository.findByOrderId(orderId)).thenReturn(Optional.of(expectedPayment)); + + // act + Optional result = paymentService.getPaymentByOrderId(orderId); + + // assert + assertThat(result).isPresent(); + assertThat(result.get()).isEqualTo(expectedPayment); + verify(paymentRepository, times(1)).findByOrderId(orderId); + } + + @DisplayName("결제를 찾을 수 없으면 예외가 발생한다.") + @Test + void throwsException_whenPaymentNotFound() { + // arrange + Long paymentId = 999L; + + when(paymentRepository.findById(paymentId)).thenReturn(Optional.empty()); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + paymentService.getPayment(paymentId); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + verify(paymentRepository, times(1)).findById(paymentId); + } + } + + @DisplayName("PG 결제 요청") + @Nested + class RequestPayment { + @DisplayName("PG 결제 요청을 성공적으로 처리할 수 있다.") + @Test + void requestsPaymentSuccessfully() { + // arrange + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + String userId = "user123"; // User.userId (String) + Long userEntityId = PaymentTestFixture.ValidPayment.USER_ID; // User.id (Long) + String cardType = "SAMSUNG"; + String cardNo = PaymentTestFixture.ValidPayment.CARD_NO; + Long amount = PaymentTestFixture.ValidPayment.AMOUNT; + + Payment payment = Payment.of( + orderId, + userEntityId, + CardType.SAMSUNG, + cardNo, + amount, + LocalDateTime.now() + ); + + PaymentRequestResult.Success successResult = new PaymentRequestResult.Success("TXN123456"); + + when(paymentRepository.save(any(Payment.class))).thenReturn(payment); + when(paymentGateway.requestPayment(any(PaymentRequest.class))).thenReturn(successResult); + + // act + PaymentRequestResult result = paymentService.requestPayment(orderId, userId, userEntityId, cardType, cardNo, amount); + + // assert + assertThat(result).isInstanceOf(PaymentRequestResult.Success.class); + assertThat(((PaymentRequestResult.Success) result).transactionKey()).isEqualTo("TXN123456"); + verify(paymentRepository, times(1)).save(any(Payment.class)); + verify(paymentGateway, times(1)).requestPayment(any(PaymentRequest.class)); + } + + @DisplayName("비즈니스 실패 시 결제 상태를 FAILED로 변경한다.") + @Test + void updatesPaymentToFailed_whenBusinessFailure() { + // arrange + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + String userId = "user123"; // User.userId (String) + Long userEntityId = PaymentTestFixture.ValidPayment.USER_ID; // User.id (Long) + String cardType = "SAMSUNG"; + String cardNo = PaymentTestFixture.ValidPayment.CARD_NO; + Long amount = PaymentTestFixture.ValidPayment.AMOUNT; + + Payment payment = Payment.of( + orderId, + userEntityId, + CardType.SAMSUNG, + cardNo, + amount, + LocalDateTime.now() + ); + + PaymentRequestResult.Failure failureResult = new PaymentRequestResult.Failure( + "LIMIT_EXCEEDED", + "카드 한도 초과", + false, + false + ); + + when(paymentRepository.save(any(Payment.class))).thenReturn(payment); + when(paymentGateway.requestPayment(any(PaymentRequest.class))).thenReturn(failureResult); + when(paymentRepository.findById(anyLong())).thenReturn(Optional.of(payment)); + + // act + PaymentRequestResult result = paymentService.requestPayment(orderId, userId, userEntityId, cardType, cardNo, amount); + + // assert + assertThat(result).isInstanceOf(PaymentRequestResult.Failure.class); + verify(paymentRepository, times(2)).save(any(Payment.class)); // 생성 + 실패 상태 변경 + } + + @DisplayName("외부 시스템 장애 시 결제 상태를 PENDING으로 유지한다.") + @Test + void maintainsPendingStatus_whenExternalSystemFailure() { + // arrange + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + String userId = "user123"; // User.userId (String) + Long userEntityId = PaymentTestFixture.ValidPayment.USER_ID; // User.id (Long) + String cardType = "SAMSUNG"; + String cardNo = PaymentTestFixture.ValidPayment.CARD_NO; + Long amount = PaymentTestFixture.ValidPayment.AMOUNT; + + Payment payment = Payment.of( + orderId, + userEntityId, + CardType.SAMSUNG, + cardNo, + amount, + LocalDateTime.now() + ); + + PaymentRequestResult.Failure failureResult = new PaymentRequestResult.Failure( + "CIRCUIT_BREAKER_OPEN", + "결제 대기 상태", + false, + false + ); + + when(paymentRepository.save(any(Payment.class))).thenReturn(payment); + when(paymentGateway.requestPayment(any(PaymentRequest.class))).thenReturn(failureResult); + + // act + PaymentRequestResult result = paymentService.requestPayment(orderId, userId, userEntityId, cardType, cardNo, amount); + + // assert + assertThat(result).isInstanceOf(PaymentRequestResult.Failure.class); + verify(paymentRepository, times(1)).save(any(Payment.class)); // 생성만 + verify(paymentRepository, never()).findById(anyLong()); // 상태 변경 없음 + } + + @DisplayName("잘못된 카드 번호로 인해 예외가 발생한다.") + @Test + void throwsException_whenInvalidCardNo() { + // arrange + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + String userId = "user123"; // User.userId (String) + Long userEntityId = PaymentTestFixture.ValidPayment.USER_ID; // User.id (Long) + String cardType = "SAMSUNG"; + String invalidCardNo = "1234"; // 잘못된 카드 번호 + Long amount = PaymentTestFixture.ValidPayment.AMOUNT; + + // act & assert + CoreException result = assertThrows(CoreException.class, () -> { + paymentService.requestPayment(orderId, userId, userEntityId, cardType, invalidCardNo, amount); + }); + + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + verify(paymentRepository, never()).save(any(Payment.class)); + verify(paymentGateway, never()).requestPayment(any(PaymentRequest.class)); + } + + @DisplayName("잘못된 카드 타입으로 인해 예외가 발생한다.") + @Test + void throwsException_whenInvalidCardType() { + // arrange + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + String userId = "user123"; // User.userId (String) + Long userEntityId = PaymentTestFixture.ValidPayment.USER_ID; // User.id (Long) + String invalidCardType = "INVALID"; + String cardNo = PaymentTestFixture.ValidPayment.CARD_NO; + Long amount = PaymentTestFixture.ValidPayment.AMOUNT; + + // act & assert + CoreException result = assertThrows(CoreException.class, () -> { + paymentService.requestPayment(orderId, userId, userEntityId, invalidCardType, cardNo, amount); + }); + + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + verify(paymentRepository, never()).save(any(Payment.class)); + verify(paymentGateway, never()).requestPayment(any(PaymentRequest.class)); + } + } + + @DisplayName("결제 상태 조회") + @Nested + class GetPaymentStatus { + @DisplayName("결제 상태를 조회할 수 있다.") + @Test + void getsPaymentStatus() { + // arrange + String userId = "user123"; + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + PaymentStatus expectedStatus = PaymentStatus.SUCCESS; + + when(paymentGateway.getPaymentStatus(userId, orderId)).thenReturn(expectedStatus); + + // act + PaymentStatus result = paymentService.getPaymentStatus(userId, orderId); + + // assert + assertThat(result).isEqualTo(expectedStatus); + verify(paymentGateway, times(1)).getPaymentStatus(userId, orderId); + } + } + + @DisplayName("콜백 처리") + @Nested + class HandleCallback { + @DisplayName("SUCCESS 콜백을 처리할 수 있다.") + @Test + void handlesSuccessCallback() { + // arrange + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + String transactionKey = "TXN123456"; + PaymentStatus status = PaymentStatus.SUCCESS; + String reason = null; + + Payment payment = Payment.of( + orderId, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + LocalDateTime.now() + ); + + when(paymentRepository.findByOrderId(orderId)).thenReturn(Optional.of(payment)); + when(paymentRepository.findById(anyLong())).thenReturn(Optional.of(payment)); + when(paymentRepository.save(any(Payment.class))).thenReturn(payment); + + // act + paymentService.handleCallback(orderId, transactionKey, status, reason); + + // assert + verify(paymentRepository, times(1)).findByOrderId(orderId); + verify(paymentRepository, times(1)).findById(anyLong()); + verify(paymentRepository, times(1)).save(any(Payment.class)); + } + + @DisplayName("FAILED 콜백을 처리할 수 있다.") + @Test + void handlesFailedCallback() { + // arrange + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + String transactionKey = "TXN123456"; + PaymentStatus status = PaymentStatus.FAILED; + String reason = "카드 한도 초과"; + + Payment payment = Payment.of( + orderId, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + LocalDateTime.now() + ); + + when(paymentRepository.findByOrderId(orderId)).thenReturn(Optional.of(payment)); + when(paymentRepository.findById(anyLong())).thenReturn(Optional.of(payment)); + when(paymentRepository.save(any(Payment.class))).thenReturn(payment); + + // act + paymentService.handleCallback(orderId, transactionKey, status, reason); + + // assert + verify(paymentRepository, times(1)).findByOrderId(orderId); + verify(paymentRepository, times(1)).findById(anyLong()); + verify(paymentRepository, times(1)).save(any(Payment.class)); + } + + @DisplayName("PENDING 콜백은 상태를 유지한다.") + @Test + void maintainsStatus_whenPendingCallback() { + // arrange + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + String transactionKey = "TXN123456"; + PaymentStatus status = PaymentStatus.PENDING; + String reason = null; + + Payment payment = Payment.of( + orderId, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + LocalDateTime.now() + ); + + when(paymentRepository.findByOrderId(orderId)).thenReturn(Optional.of(payment)); + + // act + paymentService.handleCallback(orderId, transactionKey, status, reason); + + // assert + verify(paymentRepository, times(1)).findByOrderId(orderId); + verify(paymentRepository, never()).findById(anyLong()); + verify(paymentRepository, never()).save(any(Payment.class)); + } + + @DisplayName("결제를 찾을 수 없으면 로그만 기록한다.") + @Test + void logsWarning_whenPaymentNotFound() { + // arrange + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + String transactionKey = "TXN123456"; + PaymentStatus status = PaymentStatus.SUCCESS; + String reason = null; + + when(paymentRepository.findByOrderId(orderId)).thenReturn(Optional.empty()); + + // act + paymentService.handleCallback(orderId, transactionKey, status, reason); + + // assert + verify(paymentRepository, times(1)).findByOrderId(orderId); + verify(paymentRepository, never()).findById(anyLong()); + verify(paymentRepository, never()).save(any(Payment.class)); + } + } + + @DisplayName("타임아웃 복구") + @Nested + class RecoverAfterTimeout { + @DisplayName("SUCCESS 상태로 복구할 수 있다.") + @Test + void recoversToSuccess() { + // arrange + String userId = "user123"; + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + PaymentStatus status = PaymentStatus.SUCCESS; + + Payment payment = Payment.of( + orderId, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + LocalDateTime.now() + ); + + when(paymentGateway.getPaymentStatus(userId, orderId)).thenReturn(status); + when(paymentRepository.findByOrderId(orderId)).thenReturn(Optional.of(payment)); + when(paymentRepository.findById(anyLong())).thenReturn(Optional.of(payment)); + when(paymentRepository.save(any(Payment.class))).thenReturn(payment); + + // act + paymentService.recoverAfterTimeout(userId, orderId); + + // assert + verify(paymentGateway, times(1)).getPaymentStatus(userId, orderId); + verify(paymentRepository, times(1)).findByOrderId(orderId); + verify(paymentRepository, times(1)).findById(anyLong()); + verify(paymentRepository, times(1)).save(any(Payment.class)); + } + + @DisplayName("FAILED 상태로 복구할 수 있다.") + @Test + void recoversToFailed() { + // arrange + String userId = "user123"; + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + PaymentStatus status = PaymentStatus.FAILED; + + Payment payment = Payment.of( + orderId, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + LocalDateTime.now() + ); + + when(paymentGateway.getPaymentStatus(userId, orderId)).thenReturn(status); + when(paymentRepository.findByOrderId(orderId)).thenReturn(Optional.of(payment)); + when(paymentRepository.findById(anyLong())).thenReturn(Optional.of(payment)); + when(paymentRepository.save(any(Payment.class))).thenReturn(payment); + + // act + paymentService.recoverAfterTimeout(userId, orderId); + + // assert + verify(paymentGateway, times(1)).getPaymentStatus(userId, orderId); + verify(paymentRepository, times(1)).findByOrderId(orderId); + verify(paymentRepository, times(1)).findById(anyLong()); + verify(paymentRepository, times(1)).save(any(Payment.class)); + } + + @DisplayName("PENDING 상태는 유지한다.") + @Test + void maintainsPendingStatus() { + // arrange + String userId = "user123"; + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + PaymentStatus status = PaymentStatus.PENDING; + + Payment payment = Payment.of( + orderId, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + LocalDateTime.now() + ); + + when(paymentGateway.getPaymentStatus(userId, orderId)).thenReturn(status); + when(paymentRepository.findByOrderId(orderId)).thenReturn(Optional.of(payment)); + + // act + paymentService.recoverAfterTimeout(userId, orderId); + + // assert + verify(paymentGateway, times(1)).getPaymentStatus(userId, orderId); + verify(paymentRepository, times(1)).findByOrderId(orderId); + verify(paymentRepository, never()).findById(anyLong()); + verify(paymentRepository, never()).save(any(Payment.class)); + } + + @DisplayName("결제를 찾을 수 없으면 로그만 기록한다.") + @Test + void logsWarning_whenPaymentNotFound() { + // arrange + String userId = "user123"; + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + PaymentStatus status = PaymentStatus.SUCCESS; + + when(paymentGateway.getPaymentStatus(userId, orderId)).thenReturn(status); + when(paymentRepository.findByOrderId(orderId)).thenReturn(Optional.empty()); + + // act + paymentService.recoverAfterTimeout(userId, orderId); + + // assert + verify(paymentGateway, times(1)).getPaymentStatus(userId, orderId); + verify(paymentRepository, times(1)).findByOrderId(orderId); + verify(paymentRepository, never()).findById(anyLong()); + verify(paymentRepository, never()).save(any(Payment.class)); + } + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentTest.java new file mode 100644 index 000000000..cc49a6467 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentTest.java @@ -0,0 +1,306 @@ +package com.loopers.domain.payment; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class PaymentTest { + + @DisplayName("필수 입력값 검증") + @Nested + class InputValidation { + @DisplayName("결제 생성 시 주문 ID가 null이면 예외가 발생한다.") + @Test + void throwsException_whenOrderIdIsNull() { + // arrange + Long userId = PaymentTestFixture.ValidPayment.USER_ID; + Long amount = PaymentTestFixture.ValidPayment.AMOUNT; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + Payment.of(null, userId, PaymentTestFixture.ValidPayment.CARD_TYPE, PaymentTestFixture.ValidPayment.CARD_NO, amount, PaymentTestFixture.ValidPayment.REQUESTED_AT); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("결제 생성 시 결제 금액이 0 이하이면 예외가 발생한다.") + @Test + void throwsException_whenAmountIsNotPositive() { + // arrange + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + Long userId = PaymentTestFixture.ValidPayment.USER_ID; + Long invalidAmount = PaymentTestFixture.InvalidPayment.INVALID_AMOUNT; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + Payment.of(orderId, userId, PaymentTestFixture.ValidPayment.CARD_TYPE, PaymentTestFixture.ValidPayment.CARD_NO, invalidAmount, PaymentTestFixture.ValidPayment.REQUESTED_AT); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("상태 검증") + @Nested + class StatusValidation { + @DisplayName("포인트로 전액 결제하면 SUCCESS 상태로 생성된다.") + @Test + void hasSuccessStatus_whenPointCoversTotalAmount() { + // arrange + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + Long userId = PaymentTestFixture.ValidPayment.USER_ID; + Long totalAmount = PaymentTestFixture.ValidPayment.AMOUNT; + Long usedPoint = totalAmount; // 포인트로 전액 결제 + + // act + Payment payment = Payment.of(orderId, userId, totalAmount, usedPoint, PaymentTestFixture.ValidPayment.REQUESTED_AT); + + // assert + assertThat(payment.getStatus()).isEqualTo(PaymentStatus.SUCCESS); + assertThat(payment.getUsedPoint()).isEqualTo(usedPoint); + assertThat(payment.getPaidAmount()).isEqualTo(0L); + } + + @DisplayName("포인트로 결제하지 않으면 PENDING 상태로 생성된다.") + @Test + void hasPendingStatus_whenPointDoesNotCoverTotalAmount() { + // arrange + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + Long userId = PaymentTestFixture.ValidPayment.USER_ID; + Long amount = PaymentTestFixture.ValidPayment.AMOUNT; + + // act + Payment payment = Payment.of( + orderId, + userId, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + amount, + PaymentTestFixture.ValidPayment.REQUESTED_AT + ); + + // assert + assertThat(payment.getStatus()).isEqualTo(PaymentStatus.PENDING); + assertThat(payment.getUsedPoint()).isEqualTo(0L); + assertThat(payment.getPaidAmount()).isEqualTo(amount); + } + + @DisplayName("포인트로 부분 결제하면 PENDING 상태로 생성된다.") + @Test + void hasPendingStatus_whenPointPartiallyCoversTotalAmount() { + // arrange + Long orderId = PaymentTestFixture.ValidPayment.ORDER_ID; + Long userId = PaymentTestFixture.ValidPayment.USER_ID; + Long totalAmount = PaymentTestFixture.ValidPayment.AMOUNT; + Long usedPoint = PaymentTestFixture.ValidPayment.PARTIAL_POINT; // 포인트로 절반 결제 + + // act + Payment payment = Payment.of( + orderId, + userId, + totalAmount, + usedPoint, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.REQUESTED_AT + ); + + // assert + assertThat(payment.getStatus()).isEqualTo(PaymentStatus.PENDING); + assertThat(payment.getUsedPoint()).isEqualTo(usedPoint); + assertThat(payment.getPaidAmount()).isEqualTo(totalAmount - usedPoint); + } + + @DisplayName("결제는 PENDING 상태에서 SUCCESS 상태로 전이할 수 있다.") + @Test + void canTransitionToSuccess_whenPending() { + // arrange + Payment payment = Payment.of( + PaymentTestFixture.ValidPayment.ORDER_ID, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + PaymentTestFixture.ValidPayment.REQUESTED_AT + ); + LocalDateTime completedAt = LocalDateTime.of(2025, 12, 1, 10, 5, 0); + + // act + payment.toSuccess(completedAt); + + // assert + assertThat(payment.getStatus()).isEqualTo(PaymentStatus.SUCCESS); + assertThat(payment.getPgCompletedAt()).isEqualTo(completedAt); + } + + @DisplayName("결제는 PENDING 상태에서 FAILED 상태로 전이할 수 있다.") + @Test + void canTransitionToFailed_whenPending() { + // arrange + Payment payment = Payment.of( + PaymentTestFixture.ValidPayment.ORDER_ID, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + PaymentTestFixture.ValidPayment.REQUESTED_AT + ); + LocalDateTime completedAt = LocalDateTime.of(2025, 12, 1, 10, 5, 0); + String failureReason = "카드 한도 초과"; + + // act + payment.toFailed(failureReason, completedAt); + + // assert + assertThat(payment.getStatus()).isEqualTo(PaymentStatus.FAILED); + assertThat(payment.getFailureReason()).isEqualTo(failureReason); + assertThat(payment.getPgCompletedAt()).isEqualTo(completedAt); + } + + @DisplayName("FAILED 상태에서 SUCCESS로 전이할 수 없다.") + @Test + void throwsException_whenTransitioningToSuccessFromFailed() { + // arrange + Payment payment = Payment.of( + PaymentTestFixture.ValidPayment.ORDER_ID, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + PaymentTestFixture.ValidPayment.REQUESTED_AT + ); + payment.toFailed("실패 사유", LocalDateTime.now()); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + payment.toSuccess(LocalDateTime.now()); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("SUCCESS 상태에서 FAILED로 전이할 수 없다.") + @Test + void throwsException_whenTransitioningToFailedFromSuccess() { + // arrange + Payment payment = Payment.of( + PaymentTestFixture.ValidPayment.ORDER_ID, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + PaymentTestFixture.ValidPayment.REQUESTED_AT + ); + payment.toSuccess(LocalDateTime.now()); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + payment.toFailed("실패 사유", LocalDateTime.now()); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("완료된 결제는 isCompleted가 true를 반환한다.") + @Test + void returnsTrue_whenPaymentIsCompleted() { + // arrange + Payment successPayment = Payment.of( + PaymentTestFixture.ValidPayment.ORDER_ID, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + PaymentTestFixture.ValidPayment.REQUESTED_AT + ); + successPayment.toSuccess(LocalDateTime.now()); + + Payment failedPayment = Payment.of( + PaymentTestFixture.ValidPayment.ORDER_ID, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + PaymentTestFixture.ValidPayment.REQUESTED_AT + ); + failedPayment.toFailed("ERROR", LocalDateTime.now()); + + Payment pendingPayment = Payment.of( + PaymentTestFixture.ValidPayment.ORDER_ID, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + PaymentTestFixture.ValidPayment.REQUESTED_AT + ); + + // assert + assertThat(successPayment.isCompleted()).isTrue(); + assertThat(failedPayment.isCompleted()).isTrue(); + assertThat(pendingPayment.isCompleted()).isFalse(); + } + + @DisplayName("이미 SUCCESS 상태인 결제를 다시 SUCCESS로 전이해도 예외가 발생하지 않는다.") + @Test + void doesNotThrowException_whenTransitioningToSuccessFromSuccess() { + // arrange + Payment payment = Payment.of( + PaymentTestFixture.ValidPayment.ORDER_ID, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + PaymentTestFixture.ValidPayment.REQUESTED_AT + ); + LocalDateTime firstCompletedAt = LocalDateTime.of(2025, 12, 1, 10, 5, 0); + LocalDateTime secondCompletedAt = LocalDateTime.of(2025, 12, 1, 10, 6, 0); + payment.toSuccess(firstCompletedAt); + + // act + payment.toSuccess(secondCompletedAt); // 멱등성: 이미 SUCCESS 상태면 아무 작업도 하지 않음 + + // assert + assertThat(payment.getStatus()).isEqualTo(PaymentStatus.SUCCESS); + assertThat(payment.getPgCompletedAt()).isEqualTo(firstCompletedAt); // 첫 번째 시각 유지 + } + + @DisplayName("이미 FAILED 상태인 결제를 다시 FAILED로 전이해도 예외가 발생하지 않는다.") + @Test + void doesNotThrowException_whenTransitioningToFailedFromFailed() { + // arrange + Payment payment = Payment.of( + PaymentTestFixture.ValidPayment.ORDER_ID, + PaymentTestFixture.ValidPayment.USER_ID, + PaymentTestFixture.ValidPayment.CARD_TYPE, + PaymentTestFixture.ValidPayment.CARD_NO, + PaymentTestFixture.ValidPayment.AMOUNT, + PaymentTestFixture.ValidPayment.REQUESTED_AT + ); + LocalDateTime firstCompletedAt = LocalDateTime.of(2025, 12, 1, 10, 5, 0); + LocalDateTime secondCompletedAt = LocalDateTime.of(2025, 12, 1, 10, 6, 0); + String firstReason = "첫 번째 실패 사유"; + String secondReason = "두 번째 실패 사유"; + payment.toFailed(firstReason, firstCompletedAt); + + // act + payment.toFailed(secondReason, secondCompletedAt); // 멱등성: 이미 FAILED 상태면 아무 작업도 하지 않음 + + // assert + assertThat(payment.getStatus()).isEqualTo(PaymentStatus.FAILED); + assertThat(payment.getFailureReason()).isEqualTo(firstReason); // 첫 번째 사유 유지 + assertThat(payment.getPgCompletedAt()).isEqualTo(firstCompletedAt); // 첫 번째 시각 유지 + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentTestFixture.java b/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentTestFixture.java new file mode 100644 index 000000000..5e781bceb --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentTestFixture.java @@ -0,0 +1,30 @@ +package com.loopers.domain.payment; + +import java.time.LocalDateTime; + +/** + * 테스트용 고정 데이터 (Fixture) 클래스 + * 모든 Payment 관련 테스트에서 사용하는 공통 데이터를 관리 + */ +public class PaymentTestFixture { + + // 기본 유효한 테스트 데이터 + public static final class ValidPayment { + public static final Long ORDER_ID = 1L; + public static final Long USER_ID = 100L; + public static final Long AMOUNT = 50000L; + public static final CardType CARD_TYPE = CardType.SAMSUNG; + public static final String CARD_NO = "4111-1111-1111-1111"; + public static final LocalDateTime REQUESTED_AT = LocalDateTime.of(2025, 12, 1, 10, 0, 0); + public static final String TRANSACTION_KEY = "tx-key-12345"; + public static final Long ZERO_POINT = 0L; + public static final Long FULL_POINT = AMOUNT; // 전액 포인트 + public static final Long PARTIAL_POINT = AMOUNT / 2; // 부분 포인트 + } + + // 유효하지 않은 테스트 데이터 + public static final class InvalidPayment { + public static final Long INVALID_AMOUNT = 0L; + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java new file mode 100644 index 000000000..c87faa10f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java @@ -0,0 +1,94 @@ +package com.loopers.domain.product; + +import com.loopers.application.product.ProductService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * ProductService 테스트. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("ProductService") +public class ProductServiceTest { + + @Mock + private ProductRepository productRepository; + + @InjectMocks + private ProductService productService; + + @DisplayName("상품 조회 (비관적 락)") + @Nested + class FindProductForUpdate { + @DisplayName("상품 ID로 상품을 조회할 수 있다. (비관적 락)") + @Test + void findsProductByIdForUpdate() { + // arrange + Long productId = 1L; + Product expectedProduct = Product.of("상품", 10_000, 10, 1L); + when(productRepository.findByIdForUpdate(productId)).thenReturn(Optional.of(expectedProduct)); + + // act + Product result = productService.getProductForUpdate(productId); + + // assert + assertThat(result).isEqualTo(expectedProduct); + verify(productRepository, times(1)).findByIdForUpdate(productId); + } + + @DisplayName("상품을 찾을 수 없으면 예외가 발생한다.") + @Test + void throwsException_whenProductNotFound() { + // arrange + Long productId = 999L; + when(productRepository.findByIdForUpdate(productId)).thenReturn(Optional.empty()); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + productService.getProductForUpdate(productId); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + assertThat(result.getMessage()).contains("상품을 찾을 수 없습니다"); + verify(productRepository, times(1)).findByIdForUpdate(productId); + } + } + + @DisplayName("상품 저장") + @Nested + class SaveProducts { + @DisplayName("상품 목록을 저장할 수 있다.") + @Test + void savesAllProducts() { + // arrange + Product product1 = Product.of("상품1", 10_000, 10, 1L); + Product product2 = Product.of("상품2", 20_000, 5, 1L); + List products = List.of(product1, product2); + when(productRepository.save(any(Product.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + // act + productService.saveAll(products); + + // assert + verify(productRepository, times(1)).save(product1); + verify(productRepository, times(1)).save(product2); + } + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java new file mode 100644 index 000000000..1bde8daa1 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java @@ -0,0 +1,165 @@ +package com.loopers.domain.user; + +import com.loopers.application.user.UserService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * UserService 테스트. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("UserService") +public class UserServiceTest { + + @Mock + private UserRepository userRepository; + + @InjectMocks + private UserService userService; + + @DisplayName("사용자 조회") + @Nested + class FindUser { + @DisplayName("사용자 ID로 사용자를 조회할 수 있다.") + @Test + void findsUserByUserId() { + // arrange + String userId = "testuser"; + User expectedUser = User.of(userId, "test@example.com", "1990-01-01", Gender.MALE, Point.of(1000L)); + when(userRepository.findByUserId(userId)).thenReturn(expectedUser); + + // act + User result = userService.getUser(userId); + + // assert + assertThat(result).isEqualTo(expectedUser); + verify(userRepository, times(1)).findByUserId(userId); + } + + @DisplayName("사용자를 찾을 수 없으면 예외가 발생한다.") + @Test + void throwsException_whenUserNotFound() { + // arrange + String userId = "unknown"; + when(userRepository.findByUserId(userId)).thenReturn(null); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + userService.getUser(userId); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + assertThat(result.getMessage()).contains("사용자를 찾을 수 없습니다"); + verify(userRepository, times(1)).findByUserId(userId); + } + } + + @DisplayName("사용자 조회 (비관적 락)") + @Nested + class FindUserForUpdate { + @DisplayName("사용자 ID로 사용자를 조회할 수 있다. (비관적 락)") + @Test + void findsUserByUserIdForUpdate() { + // arrange + String userId = "testuser"; + User expectedUser = User.of(userId, "test@example.com", "1990-01-01", Gender.MALE, Point.of(1000L)); + when(userRepository.findByUserIdForUpdate(userId)).thenReturn(expectedUser); + + // act + User result = userService.getUserForUpdate(userId); + + // assert + assertThat(result).isEqualTo(expectedUser); + verify(userRepository, times(1)).findByUserIdForUpdate(userId); + } + + @DisplayName("사용자를 찾을 수 없으면 예외가 발생한다.") + @Test + void throwsException_whenUserNotFound() { + // arrange + String userId = "unknown"; + when(userRepository.findByUserIdForUpdate(userId)).thenReturn(null); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + userService.getUserForUpdate(userId); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + assertThat(result.getMessage()).contains("사용자를 찾을 수 없습니다"); + verify(userRepository, times(1)).findByUserIdForUpdate(userId); + } + } + + @DisplayName("사용자 조회 (ID)") + @Nested + class FindUserById { + @DisplayName("사용자 ID (PK)로 사용자를 조회할 수 있다.") + @Test + void findsUserById() { + // arrange + Long id = 1L; + User expectedUser = User.of("testuser", "test@example.com", "1990-01-01", Gender.MALE, Point.of(1000L)); + when(userRepository.findById(id)).thenReturn(expectedUser); + + // act + User result = userService.getUserById(id); + + // assert + assertThat(result).isEqualTo(expectedUser); + verify(userRepository, times(1)).findById(id); + } + + @DisplayName("사용자를 찾을 수 없으면 예외가 발생한다.") + @Test + void throwsException_whenUserNotFound() { + // arrange + Long id = 999L; + when(userRepository.findById(id)).thenReturn(null); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + userService.getUserById(id); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + assertThat(result.getMessage()).contains("사용자를 찾을 수 없습니다"); + verify(userRepository, times(1)).findById(id); + } + } + + @DisplayName("사용자 저장") + @Nested + class SaveUser { + @DisplayName("사용자를 저장할 수 있다.") + @Test + void savesUser() { + // arrange + User user = User.of("testuser", "test@example.com", "1990-01-01", Gender.MALE, Point.of(1000L)); + when(userRepository.save(any(User.class))).thenReturn(user); + + // act + User result = userService.save(user); + + // assert + assertThat(result).isEqualTo(user); + verify(userRepository, times(1)).save(user); + } + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayClientTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/payment/PaymentGatewayClientTest.java similarity index 99% rename from apps/commerce-api/src/test/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayClientTest.java rename to apps/commerce-api/src/test/java/com/loopers/infrastructure/payment/PaymentGatewayClientTest.java index 89b3eef61..dbfac8411 100644 --- a/apps/commerce-api/src/test/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayClientTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/payment/PaymentGatewayClientTest.java @@ -1,4 +1,4 @@ -package com.loopers.infrastructure.paymentgateway; +package com.loopers.infrastructure.payment; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; @@ -274,4 +274,3 @@ void getTransaction_success_returnsResponse() { assertThat(response.data().transactionKey()).isEqualTo(transactionKey); } } - diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointWalletV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointWalletV1ApiE2ETest.java index c2629fb78..559a26477 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointWalletV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointWalletV1ApiE2ETest.java @@ -1,7 +1,8 @@ package com.loopers.interfaces.api; -import com.loopers.application.signup.SignUpFacade; +import com.loopers.application.user.UserService; import com.loopers.domain.user.Gender; +import com.loopers.domain.user.Point; import com.loopers.domain.user.UserTestFixture; import com.loopers.interfaces.api.pointwallet.PointWalletV1Dto; import com.loopers.utils.DatabaseCleanUp; @@ -31,17 +32,17 @@ public class PointWalletV1ApiE2ETest { private static final String ENDPOINT_POINTS = "/api/v1/me/points"; private final TestRestTemplate testRestTemplate; - private final SignUpFacade signUpFacade; + private final UserService userService; private final DatabaseCleanUp databaseCleanUp; @Autowired public PointWalletV1ApiE2ETest( TestRestTemplate testRestTemplate, - SignUpFacade signUpFacade, + UserService userService, DatabaseCleanUp databaseCleanUp ) { this.testRestTemplate = testRestTemplate; - this.signUpFacade = signUpFacade; + this.userService = userService; this.databaseCleanUp = databaseCleanUp; } @@ -61,7 +62,7 @@ void returnsPoints_whenUserExists(Gender gender) { String userId = UserTestFixture.ValidUser.USER_ID; String email = UserTestFixture.ValidUser.EMAIL; String birthDate = UserTestFixture.ValidUser.BIRTH_DATE; - signUpFacade.signUp(userId, email, birthDate, gender.name()); + userService.create(userId, email, birthDate, gender, Point.of(0L)); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); @@ -145,7 +146,7 @@ void returnsChargedBalance_whenUserExists(Gender gender) { String userId = UserTestFixture.ValidUser.USER_ID; String email = UserTestFixture.ValidUser.EMAIL; String birthDate = UserTestFixture.ValidUser.BIRTH_DATE; - signUpFacade.signUp(userId, email, birthDate, gender.name()); + userService.create(userId, email, birthDate, gender, Point.of(0L)); Long chargeAmount = 1000L; PointWalletV1Dto.ChargeRequest requestBody = new PointWalletV1Dto.ChargeRequest(chargeAmount); diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PurchasingV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PurchasingV1ApiE2ETest.java index 46e6f964a..49c28c163 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PurchasingV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PurchasingV1ApiE2ETest.java @@ -1,7 +1,7 @@ package com.loopers.interfaces.api; -import com.loopers.application.pointwallet.PointWalletFacade; -import com.loopers.application.signup.SignUpFacade; +import com.loopers.application.user.UserService; +import com.loopers.domain.user.Point; import com.loopers.domain.brand.Brand; import com.loopers.domain.brand.BrandRepository; import com.loopers.domain.order.OrderStatus; @@ -9,9 +9,9 @@ import com.loopers.domain.product.ProductRepository; import com.loopers.domain.user.Gender; import com.loopers.domain.user.UserTestFixture; -import com.loopers.infrastructure.paymentgateway.PaymentGatewayClient; -import com.loopers.infrastructure.paymentgateway.PaymentGatewayDto; -import com.loopers.infrastructure.paymentgateway.PaymentGatewaySchedulerClient; +import com.loopers.infrastructure.payment.PaymentGatewayClient; +import com.loopers.infrastructure.payment.PaymentGatewayDto; +import com.loopers.infrastructure.payment.PaymentGatewaySchedulerClient; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.purchasing.PurchasingV1Dto; import com.loopers.utils.DatabaseCleanUp; @@ -69,10 +69,7 @@ public class PurchasingV1ApiE2ETest { private TestRestTemplate testRestTemplate; @Autowired - private SignUpFacade signUpFacade; - - @Autowired - private PointWalletFacade pointWalletFacade; + private UserService userService; @Autowired private ProductRepository productRepository; @@ -107,8 +104,8 @@ private HttpEntity createOrderRequest(Long produc String userId = UserTestFixture.ValidUser.USER_ID; String email = UserTestFixture.ValidUser.EMAIL; String birthDate = UserTestFixture.ValidUser.BIRTH_DATE; - signUpFacade.signUp(userId, email, birthDate, Gender.MALE.name()); - pointWalletFacade.chargePoint(userId, 500_000L); + userService.create(userId, email, birthDate, Gender.MALE, Point.of(0L)); + userService.chargePoint(userId, 500_000L); Brand brand = Brand.of("테스트 브랜드"); Brand savedBrand = brandRepository.save(brand); @@ -119,7 +116,7 @@ private HttpEntity createOrderRequest(Long produc List.of( new PurchasingV1Dto.ItemRequest(savedProduct.getId(), 1) ), - new PurchasingV1Dto.PaymentRequest("SAMSUNG", "4111-1111-1111-1111") + new PurchasingV1Dto.PaymentRequest(null, "SAMSUNG", "4111-1111-1111-1111") ); HttpHeaders headers = new HttpHeaders(); @@ -239,8 +236,8 @@ void returns200_whenPaymentCallbackSuccess() { String userId = UserTestFixture.ValidUser.USER_ID; String email = UserTestFixture.ValidUser.EMAIL; String birthDate = UserTestFixture.ValidUser.BIRTH_DATE; - signUpFacade.signUp(userId, email, birthDate, Gender.MALE.name()); - pointWalletFacade.chargePoint(userId, 500_000L); + userService.create(userId, email, birthDate, Gender.MALE, Point.of(0L)); + userService.chargePoint(userId, 500_000L); Brand brand = Brand.of("테스트 브랜드"); Brand savedBrand = brandRepository.save(brand); @@ -252,7 +249,7 @@ void returns200_whenPaymentCallbackSuccess() { List.of( new PurchasingV1Dto.ItemRequest(savedProduct.getId(), 1) ), - new PurchasingV1Dto.PaymentRequest("SAMSUNG", "4111-1111-1111-1111") + new PurchasingV1Dto.PaymentRequest(null, "SAMSUNG", "4111-1111-1111-1111") ); HttpHeaders createHeaders = new HttpHeaders(); @@ -344,8 +341,8 @@ void returns200_whenPaymentCallbackFailure() { String userId = UserTestFixture.ValidUser.USER_ID; String email = UserTestFixture.ValidUser.EMAIL; String birthDate = UserTestFixture.ValidUser.BIRTH_DATE; - signUpFacade.signUp(userId, email, birthDate, Gender.MALE.name()); - pointWalletFacade.chargePoint(userId, 500_000L); + userService.create(userId, email, birthDate, Gender.MALE, Point.of(0L)); + userService.chargePoint(userId, 500_000L); Brand brand = Brand.of("테스트 브랜드"); Brand savedBrand = brandRepository.save(brand); @@ -357,7 +354,7 @@ void returns200_whenPaymentCallbackFailure() { List.of( new PurchasingV1Dto.ItemRequest(savedProduct.getId(), 1) ), - new PurchasingV1Dto.PaymentRequest("SAMSUNG", "4111-1111-1111-1111") + new PurchasingV1Dto.PaymentRequest(null, "SAMSUNG", "4111-1111-1111-1111") ); HttpHeaders createHeaders = new HttpHeaders(); @@ -455,8 +452,8 @@ void returns200_whenOrderStatusRecovered() { String userId = UserTestFixture.ValidUser.USER_ID; String email = UserTestFixture.ValidUser.EMAIL; String birthDate = UserTestFixture.ValidUser.BIRTH_DATE; - signUpFacade.signUp(userId, email, birthDate, Gender.MALE.name()); - pointWalletFacade.chargePoint(userId, 500_000L); + userService.create(userId, email, birthDate, Gender.MALE, Point.of(0L)); + userService.chargePoint(userId, 500_000L); Brand brand = Brand.of("테스트 브랜드"); Brand savedBrand = brandRepository.save(brand); @@ -468,7 +465,7 @@ void returns200_whenOrderStatusRecovered() { List.of( new PurchasingV1Dto.ItemRequest(savedProduct.getId(), 1) ), - new PurchasingV1Dto.PaymentRequest("SAMSUNG", "4111-1111-1111-1111") + new PurchasingV1Dto.PaymentRequest(null, "SAMSUNG", "4111-1111-1111-1111") ); HttpHeaders createHeaders = new HttpHeaders(); diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserInfoV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserInfoV1ApiE2ETest.java index f9d33a421..6cea33855 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserInfoV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserInfoV1ApiE2ETest.java @@ -1,7 +1,8 @@ package com.loopers.interfaces.api; -import com.loopers.application.signup.SignUpFacade; +import com.loopers.application.user.UserService; import com.loopers.domain.user.Gender; +import com.loopers.domain.user.Point; import com.loopers.domain.user.UserTestFixture; import com.loopers.interfaces.api.userinfo.UserInfoV1Dto; import com.loopers.utils.DatabaseCleanUp; @@ -31,17 +32,17 @@ public class UserInfoV1ApiE2ETest { private static final String ENDPOINT_ME = "/api/v1/me"; private final TestRestTemplate testRestTemplate; - private final SignUpFacade signUpFacade; + private final UserService userService; private final DatabaseCleanUp databaseCleanUp; @Autowired public UserInfoV1ApiE2ETest( TestRestTemplate testRestTemplate, - SignUpFacade signUpFacade, + UserService userService, DatabaseCleanUp databaseCleanUp ) { this.testRestTemplate = testRestTemplate; - this.signUpFacade = signUpFacade; + this.userService = userService; this.databaseCleanUp = databaseCleanUp; } @@ -61,7 +62,7 @@ void returnsUserInfo_whenUserExists(Gender gender) { String userId = UserTestFixture.ValidUser.USER_ID; String email = UserTestFixture.ValidUser.EMAIL; String birthDate = UserTestFixture.ValidUser.BIRTH_DATE; - signUpFacade.signUp(userId, email, birthDate, gender.name()); + userService.create(userId, email, birthDate, gender, Point.of(0L)); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); diff --git a/modules/redis/build.gradle.kts b/modules/redis/build.gradle.kts index 37ad4f6dd..86aa2a8a6 100644 --- a/modules/redis/build.gradle.kts +++ b/modules/redis/build.gradle.kts @@ -5,6 +5,7 @@ plugins { dependencies { api("org.springframework.boot:spring-boot-starter-data-redis") + api("com.fasterxml.jackson.core:jackson-databind") testFixturesImplementation("com.redis:testcontainers-redis") } diff --git a/modules/redis/src/main/java/com/loopers/cache/CacheKey.java b/modules/redis/src/main/java/com/loopers/cache/CacheKey.java new file mode 100644 index 000000000..2de210ea1 --- /dev/null +++ b/modules/redis/src/main/java/com/loopers/cache/CacheKey.java @@ -0,0 +1,41 @@ +package com.loopers.cache; + +import java.time.Duration; + +/** + * 캐시 키 인터페이스. + *

    + * 캐시 키는 해당 인터페이스를 기반으로 구현되어야 합니다. + *

    + * + * @param 캐시 값의 타입 + * @author Loopers + * @version 1.0 + */ +public interface CacheKey { + + /** + * 캐시 키를 반환합니다. + * + * @return 캐시 키 문자열 + */ + String key(); + + /** + * 캐시 TTL (Time To Live)을 반환합니다. + * + * @return TTL + */ + Duration ttl(); + + /** + * 캐시 값의 타입을 반환합니다. + *

    + * 역직렬화 시 사용됩니다. + *

    + * + * @return 타입 + */ + Class type(); +} + diff --git a/modules/redis/src/main/java/com/loopers/cache/CacheSerializationException.java b/modules/redis/src/main/java/com/loopers/cache/CacheSerializationException.java new file mode 100644 index 000000000..db71b635e --- /dev/null +++ b/modules/redis/src/main/java/com/loopers/cache/CacheSerializationException.java @@ -0,0 +1,15 @@ +package com.loopers.cache; + +/** + * 캐시 직렬화/역직렬화 예외. + * + * @author Loopers + * @version 1.0 + */ +public class CacheSerializationException extends RuntimeException { + + public CacheSerializationException(String message, Throwable cause) { + super(message, cause); + } +} + diff --git a/modules/redis/src/main/java/com/loopers/cache/CacheTemplate.java b/modules/redis/src/main/java/com/loopers/cache/CacheTemplate.java new file mode 100644 index 000000000..2757dbe8e --- /dev/null +++ b/modules/redis/src/main/java/com/loopers/cache/CacheTemplate.java @@ -0,0 +1,55 @@ +package com.loopers.cache; + +import java.util.Optional; +import java.util.function.Supplier; + +/** + * 캐시 템플릿 인터페이스. + *

    + * 캐시 조회, 저장, 삭제 등의 기능을 제공합니다. + *

    + * + * @author Loopers + * @version 1.0 + */ +public interface CacheTemplate { + + /** + * 캐시에서 값을 조회합니다. + * + * @param cacheKey 캐시 키 + * @param 캐시 값의 타입 + * @return 캐시 값 (Optional) + */ + Optional get(CacheKey cacheKey); + + /** + * 캐시에 값을 저장합니다. + * + * @param cacheKey 캐시 키 + * @param value 저장할 값 + * @param 캐시 값의 타입 + */ + void put(CacheKey cacheKey, T value); + + /** + * 캐시를 무효화합니다. + * + * @param cacheKey 캐시 키 + */ + void evict(CacheKey cacheKey); + + /** + * 캐시에서 값을 조회하고, 없으면 로더를 실행하여 값을 가져온 후 캐시에 저장합니다. + *

    + * Cache-Aside 패턴을 구현합니다. + *

    + * + * @param cacheKey 캐시 키 + * @param loader 캐시에 값이 없을 때 실행할 로더 + * @param 캐시 값의 타입 + * @return 캐시 값 또는 로더로부터 가져온 값 + */ + T getOrLoad(CacheKey cacheKey, Supplier loader); +} + diff --git a/modules/redis/src/main/java/com/loopers/cache/RedisCacheTemplate.java b/modules/redis/src/main/java/com/loopers/cache/RedisCacheTemplate.java new file mode 100644 index 000000000..0cfeb2bee --- /dev/null +++ b/modules/redis/src/main/java/com/loopers/cache/RedisCacheTemplate.java @@ -0,0 +1,95 @@ +package com.loopers.cache; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import java.util.Optional; +import java.util.function.Supplier; + +/** + * Redis 캐시 템플릿 구현체. + *

    + * Redis를 사용하여 캐시를 구현합니다. + *

    + * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class RedisCacheTemplate implements CacheTemplate { + + private final RedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + @Override + public Optional get(CacheKey cacheKey) { + try { + String json = redisTemplate.opsForValue().get(cacheKey.key()); + if (json == null) { + return Optional.empty(); + } + T value = deserialize(json, cacheKey.type()); + return Optional.ofNullable(value); + } catch (Exception e) { + log.warn("캐시 조회 실패. (key: {})", cacheKey.key(), e); + return Optional.empty(); + } + } + + @Override + public void put(CacheKey cacheKey, T value) { + try { + String json = serialize(value); + redisTemplate.opsForValue().set(cacheKey.key(), json, cacheKey.ttl()); + } catch (Exception e) { + log.warn("캐시 저장 실패. (key: {})", cacheKey.key(), e); + // 캐시 저장 실패는 무시 (DB 조회로 폴백 가능) + } + } + + @Override + public void evict(CacheKey cacheKey) { + try { + redisTemplate.delete(cacheKey.key()); + } catch (Exception e) { + log.warn("캐시 삭제 실패. (key: {})", cacheKey.key(), e); + } + } + + @Override + public T getOrLoad(CacheKey cacheKey, Supplier loader) { + Optional cached = get(cacheKey); + if (cached.isPresent()) { + return cached.get(); + } + + T value = loader.get(); + if (value != null) { + put(cacheKey, value); + } + return value; + } + + private String serialize(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException e) { + throw new CacheSerializationException("캐시 직렬화 실패", e); + } + } + + private T deserialize(String json, Class type) { + try { + return objectMapper.readValue(json, type); + } catch (JsonProcessingException e) { + throw new CacheSerializationException("캐시 역직렬화 실패", e); + } + } +} + diff --git a/modules/redis/src/main/java/com/loopers/cache/SimpleCacheKey.java b/modules/redis/src/main/java/com/loopers/cache/SimpleCacheKey.java new file mode 100644 index 000000000..28e2c81d9 --- /dev/null +++ b/modules/redis/src/main/java/com/loopers/cache/SimpleCacheKey.java @@ -0,0 +1,34 @@ +package com.loopers.cache; + +import java.time.Duration; + +/** + * 간단한 캐시 키 구현체. + *

    + * 기본적인 캐시 키를 생성할 때 사용합니다. + *

    + * + * @param 캐시 값의 타입 + * @author Loopers + * @version 1.0 + */ +public record SimpleCacheKey( + String key, + Duration ttl, + Class type +) implements CacheKey { + + /** + * 캐시 키를 생성합니다. + * + * @param key 캐시 키 문자열 + * @param ttl TTL + * @param type 캐시 값의 타입 + * @param 캐시 값의 타입 + * @return 캐시 키 + */ + public static SimpleCacheKey of(String key, Duration ttl, Class type) { + return new SimpleCacheKey<>(key, ttl, type); + } +} + diff --git a/modules/redis/src/testFixtures/java/com/loopers/cache/NoOpCacheTemplate.java b/modules/redis/src/testFixtures/java/com/loopers/cache/NoOpCacheTemplate.java new file mode 100644 index 000000000..77ca2f29a --- /dev/null +++ b/modules/redis/src/testFixtures/java/com/loopers/cache/NoOpCacheTemplate.java @@ -0,0 +1,37 @@ +package com.loopers.cache; + +import java.util.Optional; +import java.util.function.Supplier; + +/** + * 테스트용 No-Op 캐시 템플릿. + *

    + * 테스트에서 캐시를 사용하지 않을 때 사용합니다. + *

    + * + * @author Loopers + * @version 1.0 + */ +public class NoOpCacheTemplate implements CacheTemplate { + + @Override + public Optional get(CacheKey cacheKey) { + return Optional.empty(); + } + + @Override + public void put(CacheKey cacheKey, T value) { + // No-op + } + + @Override + public void evict(CacheKey cacheKey) { + // No-op + } + + @Override + public T getOrLoad(CacheKey cacheKey, Supplier loader) { + return loader.get(); + } +} + From 3af88f95432a05002621c06a30ebc46beb70b0e7 Mon Sep 17 00:00:00 2001 From: minor7295 <44902090+minor7295@users.noreply.github.com> Date: Sun, 21 Dec 2025 18:45:14 +0900 Subject: [PATCH 09/12] Feature/kafka (#36) (#191) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore:kafka producer 설정 * chore: kafka 토픽 자동 생성 설정 추가 * feat: kafka event publisher, comsumer 추가 * test: 집계 도메인 단위 테스트 코드 추가 * feat: 집계 도메인 domain 레이어 구현 * feat: 집계 도메인 infra 레이어 구현 * chore: kafka 토픽 자동 생성 설정 추가 * chore: kafka 빌드 의존성 추가 * test: 집계 통합 테스트 추가 * feat: 집계 서비스 로직 구현 * test: kafka consumer 테스트 코드 추가 * feat: kafka comsumer 구현 * outbox 패턴 적용위해 기존 kafka 설정 삭제 * test: outboxevent 단위 테스트 추가 * feat: outbox 도메인 구현 * feat: outbox infrastructure repository구현 * metric 오타 수정 * refactor: consumer 관련 로직들은 commerce-streamer 모듈로 이동 * test: outbox 테스트 코드 추가 * test: outbox 구현 * outbox event listener 구현 * feat: 상품 조회 이벤트 추가 * feat: 상품 조회시 이벤트 발행 * chore: kafka 설정 수정 * fix: outbox 처리되지 않는 오류 수정 * chore: 테스트 코드 실행시 kafka 사용할 수 있도록 test container 설정 추가 * test: offset.reset: latest 설정이 제대로 적용되는지 확인하는 테스트 코드 추가 * test: kafka 파티션 키 설정에 대한 테스트 코드 추가 * chore: commerce-api 테스트 환경에서 카프카 사용하도록 설ㄹ정 * test: event id 기준으로 한 번만 publish, consume하는 것을 검증하는 테스트 코드 추가 * chore: 충돌 발생한 테스트 코드 수정 * feat: event id 기준 1회 처리되도록 로직 구현 * test: 버전 기준으로 최신 이벤트만 처리하도록 테스트 코드 수정 * feat: version 기준으로 최신 이벤트만 처리하도록 함 * test: 중복 메시지 재전송 시 한 번만 처리되는지 검증하는 테스트 코드 추가 * feat: kafka 이벤트 publish 할 때 콜백 사용하여 이벤트 유실 방지 * feat: kafka메시지 헤더에 event type 추가 * feat: 버전 조회와 저장 사이의 경쟁 조건 가능성 해결 * feat: 신규 상품 등록시 event 발행에서 발생하는 경합 문제 수정 --- apps/commerce-api/build.gradle.kts | 2 + .../application/catalog/CatalogFacade.java | 11 + .../outbox/OutboxBridgeEventListener.java | 141 ++++++ .../outbox/OutboxEventService.java | 100 ++++ .../loopers/domain/outbox/OutboxEvent.java | 123 +++++ .../domain/outbox/OutboxEventRepository.java | 51 +++ .../loopers/domain/product/ProductEvent.java | 59 +++ .../domain/product/ProductEventPublisher.java | 21 + .../outbox/OutboxEventJpaRepository.java | 40 ++ .../outbox/OutboxEventPublisher.java | 133 ++++++ .../outbox/OutboxEventRepositoryImpl.java | 39 ++ .../product/ProductEventPublisherImpl.java | 36 ++ .../src/main/resources/application.yml | 1 + .../outbox/OutboxBridgeEventListenerTest.java | 163 +++++++ .../outbox/OutboxEventServiceTest.java | 168 +++++++ .../domain/outbox/OutboxEventTest.java | 124 +++++ .../OutboxEventPublisherIntegrationTest.java | 43 ++ .../outbox/OutboxEventPublisherTest.java | 299 ++++++++++++ .../eventhandled/EventHandledService.java | 64 +++ .../metrics/ProductMetricsService.java | 165 +++++++ .../com/loopers/domain/event/LikeEvent.java | 37 ++ .../com/loopers/domain/event/OrderEvent.java | 40 ++ .../loopers/domain/event/ProductEvent.java | 27 ++ .../domain/eventhandled/EventHandled.java | 62 +++ .../eventhandled/EventHandledRepository.java | 40 ++ .../domain/metrics/ProductMetrics.java | 127 ++++++ .../metrics/ProductMetricsRepository.java | 58 +++ .../EventHandledJpaRepository.java | 31 ++ .../EventHandledRepositoryImpl.java | 39 ++ .../metrics/ProductMetricsJpaRepository.java | 40 ++ .../metrics/ProductMetricsRepositoryImpl.java | 49 ++ .../consumer/ProductMetricsConsumer.java | 430 ++++++++++++++++++ .../eventhandled/EventHandledServiceTest.java | 96 ++++ .../metrics/ProductMetricsServiceTest.java | 217 +++++++++ .../domain/metrics/ProductMetricsTest.java | 155 +++++++ ...ProductMetricsConsumerIntegrationTest.java | 116 +++++ .../consumer/ProductMetricsConsumerTest.java | 393 ++++++++++++++++ .../com/loopers/confg/kafka/KafkaConfig.java | 102 ++++- modules/kafka/src/main/resources/kafka.yml | 12 +- .../KafkaTestContainersConfig.java | 35 ++ .../java/com/loopers/utils/KafkaCleanUp.java | 194 ++++++++ 41 files changed, 4075 insertions(+), 8 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxBridgeEventListener.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEventService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductEvent.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductEventPublisher.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEventPublisherImpl.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxBridgeEventListenerTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxEventServiceTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/outbox/OutboxEventTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherTest.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/application/eventhandled/EventHandledService.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsService.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/event/LikeEvent.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/event/OrderEvent.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/event/ProductEvent.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandled.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandledRepository.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledJpaRepository.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledRepositoryImpl.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java create mode 100644 apps/commerce-streamer/src/test/java/com/loopers/application/eventhandled/EventHandledServiceTest.java create mode 100644 apps/commerce-streamer/src/test/java/com/loopers/application/metrics/ProductMetricsServiceTest.java create mode 100644 apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java create mode 100644 apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerIntegrationTest.java create mode 100644 apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java create mode 100644 modules/kafka/src/testFixtures/java/com/loopers/testcontainers/KafkaTestContainersConfig.java create mode 100644 modules/kafka/src/testFixtures/java/com/loopers/utils/KafkaCleanUp.java diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 83be16c09..3ba4f7df5 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -2,6 +2,7 @@ dependencies { // add-ons implementation(project(":modules:jpa")) implementation(project(":modules:redis")) + implementation(project(":modules:kafka")) implementation(project(":supports:jackson")) implementation(project(":supports:logging")) implementation(project(":supports:monitoring")) @@ -34,4 +35,5 @@ dependencies { // test-fixtures testImplementation(testFixtures(project(":modules:jpa"))) testImplementation(testFixtures(project(":modules:redis"))) + testImplementation(testFixtures(project(":modules:kafka"))) } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogFacade.java index f46e74301..c8eed8f67 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogFacade.java @@ -6,10 +6,13 @@ import com.loopers.domain.brand.Brand; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductDetail; +import com.loopers.domain.product.ProductEvent; +import com.loopers.domain.product.ProductEventPublisher; 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; import java.util.Map; @@ -30,6 +33,7 @@ public class CatalogFacade { private final BrandService brandService; private final ProductService productService; private final ProductCacheService productCacheService; + private final ProductEventPublisher productEventPublisher; /** * 상품 목록을 조회합니다. @@ -103,16 +107,20 @@ public ProductInfoList getProducts(Long brandId, String sort, int page, int size * 상품 정보를 조회합니다. *

    * Redis 캐시를 먼저 확인하고, 캐시에 없으면 DB에서 조회한 후 캐시에 저장합니다. + * 상품 조회 시 ProductViewed 이벤트를 발행하여 메트릭 집계에 사용합니다. *

    * * @param productId 상품 ID * @return 상품 정보와 좋아요 수 * @throws CoreException 상품을 찾을 수 없는 경우 */ + @Transactional(readOnly = true) public ProductInfo getProduct(Long productId) { // 캐시에서 조회 시도 ProductInfo cachedResult = productCacheService.getCachedProduct(productId); if (cachedResult != null) { + // 캐시 히트 시에도 조회 수 집계를 위해 이벤트 발행 + productEventPublisher.publish(ProductEvent.ProductViewed.from(productId)); return cachedResult; } @@ -133,6 +141,9 @@ public ProductInfo getProduct(Long productId) { // 캐시에 저장 productCacheService.cacheProduct(productId, result); + // ✅ 상품 조회 이벤트 발행 (메트릭 집계용) + productEventPublisher.publish(ProductEvent.ProductViewed.from(productId)); + // 로컬 캐시의 좋아요 수 델타 적용 (DB 조회 결과에도 델타 반영) return productCacheService.applyLikeCountDelta(result); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxBridgeEventListener.java b/apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxBridgeEventListener.java new file mode 100644 index 000000000..b44cfb43e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxBridgeEventListener.java @@ -0,0 +1,141 @@ +package com.loopers.application.outbox; + +import com.loopers.domain.like.LikeEvent; +import com.loopers.domain.order.OrderEvent; +import com.loopers.domain.product.ProductEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * Outbox Bridge Event Listener. + *

    + * ApplicationEvent를 구독하여 외부 시스템(Kafka)으로 전송해야 하는 이벤트를 + * Transactional Outbox Pattern을 통해 Outbox에 저장합니다. + *

    + *

    + * 표준 패턴: + *

      + *
    • EventPublisher는 ApplicationEvent만 발행 (단일 책임)
    • + *
    • 이 컴포넌트가 ApplicationEvent를 구독하여 Outbox에 저장 (관심사 분리)
    • + *
    • 트랜잭션 커밋 후(AFTER_COMMIT) 처리하여 에러 격리
    • + *
    + *

    + *

    + * 처리 이벤트: + *

      + *
    • LikeEvent: LikeAdded, LikeRemoved → like-events
    • + *
    • OrderEvent: OrderCreated → order-events
    • + *
    • ProductEvent: ProductViewed → product-events
    • + *
    + *

    + * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class OutboxBridgeEventListener { + + private final OutboxEventService outboxEventService; + + /** + * LikeAdded 이벤트를 Outbox에 저장합니다. + * + * @param event LikeAdded 이벤트 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleLikeAdded(LikeEvent.LikeAdded event) { + try { + outboxEventService.saveEvent( + "LikeAdded", + event.productId().toString(), + "Product", + event, + "like-events", + event.productId().toString() + ); + log.debug("LikeAdded 이벤트를 Outbox에 저장: productId={}", event.productId()); + } catch (Exception e) { + log.error("LikeAdded 이벤트 Outbox 저장 실패: productId={}", event.productId(), e); + // 외부 시스템 전송 실패는 내부 처리에 영향 없음 + } + } + + /** + * LikeRemoved 이벤트를 Outbox에 저장합니다. + * + * @param event LikeRemoved 이벤트 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleLikeRemoved(LikeEvent.LikeRemoved event) { + try { + outboxEventService.saveEvent( + "LikeRemoved", + event.productId().toString(), + "Product", + event, + "like-events", + event.productId().toString() + ); + log.debug("LikeRemoved 이벤트를 Outbox에 저장: productId={}", event.productId()); + } catch (Exception e) { + log.error("LikeRemoved 이벤트 Outbox 저장 실패: productId={}", event.productId(), e); + // 외부 시스템 전송 실패는 내부 처리에 영향 없음 + } + } + + /** + * OrderCreated 이벤트를 Outbox에 저장합니다. + * + * @param event OrderCreated 이벤트 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleOrderCreated(OrderEvent.OrderCreated event) { + try { + outboxEventService.saveEvent( + "OrderCreated", + event.orderId().toString(), + "Order", + event, + "order-events", + event.orderId().toString() + ); + log.debug("OrderCreated 이벤트를 Outbox에 저장: orderId={}", event.orderId()); + } catch (Exception e) { + log.error("OrderCreated 이벤트 Outbox 저장 실패: orderId={}", event.orderId(), e); + // 외부 시스템 전송 실패는 내부 처리에 영향 없음 + } + } + + /** + * ProductViewed 이벤트를 Outbox에 저장합니다. + * + * @param event ProductViewed 이벤트 + */ + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleProductViewed(ProductEvent.ProductViewed event) { + try { + outboxEventService.saveEvent( + "ProductViewed", + event.productId().toString(), + "Product", + event, + "product-events", + event.productId().toString() + ); + log.debug("ProductViewed 이벤트를 Outbox에 저장: productId={}", event.productId()); + } catch (Exception e) { + log.error("ProductViewed 이벤트 Outbox 저장 실패: productId={}", event.productId(), e); + // 외부 시스템 전송 실패는 내부 처리에 영향 없음 + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEventService.java b/apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEventService.java new file mode 100644 index 000000000..4c5f54820 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEventService.java @@ -0,0 +1,100 @@ +package com.loopers.application.outbox; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.outbox.OutboxEvent; +import com.loopers.domain.outbox.OutboxEventRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.UUID; + +/** + * Outbox 이벤트 저장 서비스. + *

    + * 도메인 트랜잭션과 같은 트랜잭션에서 Outbox에 이벤트를 저장합니다. + * Application 레이어에 위치하여 비즈니스 로직(이벤트 저장 결정)을 처리합니다. + *

    + * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class OutboxEventService { + + private final OutboxEventRepository outboxEventRepository; + private final ObjectMapper objectMapper; + + /** + * Kafka로 전송할 이벤트를 Outbox에 저장합니다. + *

    + * 도메인 트랜잭션과 같은 트랜잭션에서 실행되어야 합니다. + * 집계 ID별로 순차적인 버전을 자동으로 부여합니다. + *

    + *

    + * 버전 충돌 시 최대 3회까지 재시도합니다. + * 유니크 제약 조건을 통해 경쟁 조건을 감지하고 재시도합니다. + *

    + * + * @param eventType 이벤트 타입 (예: "OrderCreated", "LikeAdded") + * @param aggregateId 집계 ID (예: orderId, productId) + * @param aggregateType 집계 타입 (예: "Order", "Product") + * @param event 이벤트 객체 + * @param topic Kafka 토픽 이름 + * @param partitionKey 파티션 키 + */ + @Transactional + public void saveEvent( + String eventType, + String aggregateId, + String aggregateType, + Object event, + String topic, + String partitionKey + ) { + int maxRetries = 3; + for (int i = 0; i < maxRetries; i++) { + try { + String eventId = UUID.randomUUID().toString(); + String payload = objectMapper.writeValueAsString(event); + + // 집계 ID별 최신 버전 조회 후 +1 + Long latestVersion = outboxEventRepository.findLatestVersionByAggregateId(aggregateId, aggregateType); + Long nextVersion = latestVersion + 1L; + + OutboxEvent outboxEvent = OutboxEvent.builder() + .eventId(eventId) + .eventType(eventType) + .aggregateId(aggregateId) + .aggregateType(aggregateType) + .payload(payload) + .topic(topic) + .partitionKey(partitionKey) + .version(nextVersion) + .build(); + + outboxEventRepository.save(outboxEvent); + log.debug("Outbox 이벤트 저장: eventType={}, aggregateId={}, topic={}, version={}", + eventType, aggregateId, topic, nextVersion); + return; // 성공 + } catch (DataIntegrityViolationException e) { + // 유니크 제약 조건 위반 (버전 충돌) + if (i == maxRetries - 1) { + log.error("Outbox 이벤트 저장 실패 (최대 재시도 횟수 초과): eventType={}, aggregateId={}, retryCount={}", + eventType, aggregateId, i + 1, e); + throw new RuntimeException("Outbox 이벤트 저장 실패: 버전 충돌", e); + } + log.warn("Outbox 이벤트 저장 재시도: eventType={}, aggregateId={}, retryCount={}", + eventType, aggregateId, i + 1); + } catch (Exception e) { + log.error("Outbox 이벤트 저장 실패: eventType={}, aggregateId={}", + eventType, aggregateId, e); + throw new RuntimeException("Outbox 이벤트 저장 실패", e); + } + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java new file mode 100644 index 000000000..973fdd4ca --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java @@ -0,0 +1,123 @@ +package com.loopers.domain.outbox; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * Outbox 이벤트 엔티티. + *

    + * Transactional Outbox Pattern을 구현하기 위한 엔티티입니다. + * 도메인 트랜잭션과 같은 트랜잭션에서 이벤트를 저장하고, + * 별도 프로세스가 이를 읽어 Kafka로 발행합니다. + *

    + * + * @author Loopers + * @version 1.0 + */ +@Entity +@Table(name = "outbox_event", + indexes = { + @Index(name = "idx_status_created", columnList = "status, created_at") + }, + uniqueConstraints = { + @UniqueConstraint( + name = "uk_aggregate_version", + columnNames = {"aggregate_id", "aggregate_type", "version"} + ) + } +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class OutboxEvent { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "event_id", nullable = false, unique = true, length = 255) + private String eventId; + + @Column(name = "event_type", nullable = false, length = 100) + private String eventType; + + @Column(name = "aggregate_id", nullable = false, length = 255) + private String aggregateId; + + @Column(name = "aggregate_type", nullable = false, length = 100) + private String aggregateType; + + @Column(name = "payload", nullable = false, columnDefinition = "TEXT") + private String payload; + + @Column(name = "topic", nullable = false, length = 255) + private String topic; + + @Column(name = "partition_key", length = 255) + private String partitionKey; + + @Column(name = "version") + private Long version; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 50) + private OutboxStatus status; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "published_at") + private LocalDateTime publishedAt; + + @Builder + public OutboxEvent( + String eventId, + String eventType, + String aggregateId, + String aggregateType, + String payload, + String topic, + String partitionKey, + Long version + ) { + this.eventId = eventId; + this.eventType = eventType; + this.aggregateId = aggregateId; + this.aggregateType = aggregateType; + this.payload = payload; + this.topic = topic; + this.partitionKey = partitionKey; + this.version = version; + this.status = OutboxStatus.PENDING; + this.createdAt = LocalDateTime.now(); + } + + /** + * 이벤트를 발행 완료 상태로 변경합니다. + */ + public void markAsPublished() { + this.status = OutboxStatus.PUBLISHED; + this.publishedAt = LocalDateTime.now(); + } + + /** + * 이벤트를 실패 상태로 변경합니다. + */ + public void markAsFailed() { + this.status = OutboxStatus.FAILED; + } + + /** + * Outbox 이벤트 상태. + */ + public enum OutboxStatus { + PENDING, // 발행 대기 중 + PUBLISHED, // 발행 완료 + FAILED // 발행 실패 + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventRepository.java new file mode 100644 index 000000000..fbf574688 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventRepository.java @@ -0,0 +1,51 @@ +package com.loopers.domain.outbox; + +import java.util.List; + +/** + * OutboxEvent 저장소 인터페이스. + *

    + * DIP를 준수하여 도메인 레이어에서 인터페이스를 정의합니다. + *

    + * + * @author Loopers + * @version 1.0 + */ +public interface OutboxEventRepository { + + /** + * Outbox 이벤트를 저장합니다. + * + * @param outboxEvent 저장할 Outbox 이벤트 + * @return 저장된 Outbox 이벤트 + */ + OutboxEvent save(OutboxEvent outboxEvent); + + /** + * 발행 대기 중인 이벤트 목록을 조회합니다. + * + * @param limit 조회할 최대 개수 + * @return 발행 대기 중인 이벤트 목록 + */ + List findPendingEvents(int limit); + + /** + * ID로 Outbox 이벤트를 조회합니다. + * + * @param id Outbox 이벤트 ID + * @return 조회된 Outbox 이벤트 + */ + OutboxEvent findById(Long id); + + /** + * 집계 ID와 집계 타입으로 최신 버전을 조회합니다. + *

    + * 같은 집계에 대한 이벤트의 최신 버전을 조회하여 순차적인 버전 관리를 위해 사용됩니다. + *

    + * + * @param aggregateId 집계 ID (예: productId, orderId) + * @param aggregateType 집계 타입 (예: "Product", "Order") + * @return 최신 버전 (없으면 0L) + */ + Long findLatestVersionByAggregateId(String aggregateId, String aggregateType); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductEvent.java new file mode 100644 index 000000000..054303b09 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductEvent.java @@ -0,0 +1,59 @@ +package com.loopers.domain.product; + +import java.time.LocalDateTime; + +/** + * 상품 도메인 이벤트. + *

    + * 상품 도메인의 중요한 상태 변화를 나타내는 이벤트들입니다. + *

    + * + * @author Loopers + * @version 1.0 + */ +public class ProductEvent { + + /** + * 상품 상세 페이지 조회 이벤트. + *

    + * 상품 상세 페이지가 조회되었을 때 발행되는 이벤트입니다. + * 메트릭 집계를 위해 사용됩니다. + *

    + * + * @param productId 상품 ID + * @param userId 사용자 ID (null 가능 - 비로그인 사용자) + * @param occurredAt 이벤트 발생 시각 + */ + public record ProductViewed( + Long productId, + Long userId, + LocalDateTime occurredAt + ) { + public ProductViewed { + if (productId == null) { + throw new IllegalArgumentException("productId는 필수입니다."); + } + } + + /** + * 상품 ID로부터 ProductViewed 이벤트를 생성합니다. + * + * @param productId 상품 ID + * @return ProductViewed 이벤트 + */ + public static ProductViewed from(Long productId) { + return new ProductViewed(productId, null, LocalDateTime.now()); + } + + /** + * 상품 ID와 사용자 ID로부터 ProductViewed 이벤트를 생성합니다. + * + * @param productId 상품 ID + * @param userId 사용자 ID + * @return ProductViewed 이벤트 + */ + public static ProductViewed from(Long productId, Long userId) { + return new ProductViewed(productId, userId, LocalDateTime.now()); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductEventPublisher.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductEventPublisher.java new file mode 100644 index 000000000..0cc60f495 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductEventPublisher.java @@ -0,0 +1,21 @@ +package com.loopers.domain.product; + +/** + * 상품 도메인 이벤트 발행 인터페이스. + *

    + * DIP를 준수하여 도메인 레이어에서 이벤트 발행 인터페이스를 정의합니다. + * 구현은 인프라 레이어에서 제공됩니다. + *

    + * + * @author Loopers + * @version 1.0 + */ +public interface ProductEventPublisher { + + /** + * 상품 상세 페이지 조회 이벤트를 발행합니다. + * + * @param event 상품 조회 이벤트 + */ + void publish(ProductEvent.ProductViewed event); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventJpaRepository.java new file mode 100644 index 000000000..1703e9e15 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventJpaRepository.java @@ -0,0 +1,40 @@ +package com.loopers.infrastructure.outbox; + +import com.loopers.domain.outbox.OutboxEvent; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +/** + * OutboxEvent JPA Repository. + */ +public interface OutboxEventJpaRepository extends JpaRepository { + + /** + * 발행 대기 중인 이벤트 목록을 생성 시간 순으로 조회합니다. + * + * @param limit 조회할 최대 개수 + * @return 발행 대기 중인 이벤트 목록 + */ + @Query(value = "SELECT * FROM outbox_event e " + + "WHERE e.status = 'PENDING' " + + "ORDER BY e.created_at ASC " + + "LIMIT :limit", nativeQuery = true) + List findPendingEvents(@Param("limit") int limit); + + /** + * 집계 ID와 집계 타입으로 최신 버전을 조회합니다. + * + * @param aggregateId 집계 ID (예: productId, orderId) + * @param aggregateType 집계 타입 (예: "Product", "Order") + * @return 최신 버전 (없으면 0L) + */ + @Query("SELECT COALESCE(MAX(e.version), 0L) FROM OutboxEvent e " + + "WHERE e.aggregateId = :aggregateId AND e.aggregateType = :aggregateType") + Long findLatestVersionByAggregateId( + @Param("aggregateId") String aggregateId, + @Param("aggregateType") String aggregateType + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java new file mode 100644 index 000000000..1cc0500e9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventPublisher.java @@ -0,0 +1,133 @@ +package com.loopers.infrastructure.outbox; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.outbox.OutboxEvent; +import com.loopers.domain.outbox.OutboxEventRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.support.KafkaHeaders; +import org.springframework.kafka.support.SendResult; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * Outbox 이벤트 발행 프로세스. + *

    + * 주기적으로 Outbox에서 발행 대기 중인 이벤트를 읽어 Kafka로 발행합니다. + * Transactional Outbox Pattern의 Polling 프로세스입니다. + *

    + * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class OutboxEventPublisher { + + private static final int BATCH_SIZE = 100; + + private final OutboxEventRepository outboxEventRepository; + private final KafkaTemplate kafkaTemplate; + private final ObjectMapper objectMapper; + + /** + * 발행 대기 중인 Outbox 이벤트를 Kafka로 발행합니다. + *

    + * 1초마다 실행되어 PENDING 상태의 이벤트를 처리합니다. + *

    + */ + @Scheduled(fixedDelay = 1000) // 1초마다 실행 + @Transactional + public void publishPendingEvents() { + try { + List pendingEvents = outboxEventRepository.findPendingEvents(BATCH_SIZE); + + if (pendingEvents.isEmpty()) { + return; + } + + log.debug("Outbox 이벤트 발행 시작: count={}", pendingEvents.size()); + + for (OutboxEvent event : pendingEvents) { + try { + publishEvent(event); + } catch (Exception e) { + log.error("Outbox 이벤트 발행 요청 실패: eventId={}, topic={}", + event.getEventId(), event.getTopic(), e); + event.markAsFailed(); + outboxEventRepository.save(event); + // 개별 이벤트 실패는 계속 진행 + } + } + + log.debug("Outbox 이벤트 발행 완료: count={}", pendingEvents.size()); + } catch (Exception e) { + log.error("Outbox 이벤트 발행 프로세스 실패", e); + // 프로세스 실패는 다음 스케줄에서 재시도 + } + } + + /** + * Outbox 이벤트를 Kafka로 발행합니다. + *

    + * 멱등성 처리를 위해 `eventId`를 Kafka 메시지 헤더에 포함시킵니다. + *

    + * + * @param event 발행할 Outbox 이벤트 + */ + private void publishEvent(OutboxEvent event) { + try { + // JSON 문자열을 Map으로 역직렬화하여 Kafka로 전송 + // KafkaTemplate의 JsonSerializer가 자동으로 직렬화합니다 + Object payload = objectMapper.readValue(event.getPayload(), Object.class); + + // Kafka 메시지 헤더에 eventId, eventType, version 추가 (멱등성 및 버전 비교 처리용) + var messageBuilder = MessageBuilder + .withPayload(payload) + .setHeader(KafkaHeaders.KEY, event.getPartitionKey()) + .setHeader("eventId", event.getEventId()) + .setHeader("eventType", event.getEventType()); + + // version이 있으면 헤더에 추가 + if (event.getVersion() != null) { + messageBuilder.setHeader("version", event.getVersion()); + } + + var message = messageBuilder.build(); + + // Kafka로 비동기 발행 (콜백에서 상태 업데이트) + kafkaTemplate.send(event.getTopic(), message) + .whenComplete((result, ex) -> handleSendResult(event, result, ex)); + } catch (Exception e) { + log.error("Kafka 이벤트 발행 실패: eventId={}, topic={}", + event.getEventId(), event.getTopic(), e); + throw new RuntimeException("Kafka 이벤트 발행 실패", e); + } + } + + /** + * Kafka 전송 결과를 처리합니다. + */ + private void handleSendResult(OutboxEvent event, SendResult result, Throwable ex) { + try { + if (ex != null) { + log.error("Kafka 전송 실패: eventId={}, topic={}", event.getEventId(), event.getTopic(), ex); + event.markAsFailed(); + } else { + log.debug("Outbox 이벤트 Kafka 발행 성공: eventId={}, topic={}", + event.getEventId(), event.getTopic()); + event.markAsPublished(); + } + outboxEventRepository.save(event); + } catch (Exception e) { + log.error("Outbox 이벤트 상태 업데이트 실패: eventId={}, topic={}", + event.getEventId(), event.getTopic(), e); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventRepositoryImpl.java new file mode 100644 index 000000000..2b7d81b3b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxEventRepositoryImpl.java @@ -0,0 +1,39 @@ +package com.loopers.infrastructure.outbox; + +import com.loopers.domain.outbox.OutboxEvent; +import com.loopers.domain.outbox.OutboxEventRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * OutboxEventRepository의 JPA 구현체. + */ +@Component +@RequiredArgsConstructor +public class OutboxEventRepositoryImpl implements OutboxEventRepository { + + private final OutboxEventJpaRepository outboxEventJpaRepository; + + @Override + public OutboxEvent save(OutboxEvent outboxEvent) { + return outboxEventJpaRepository.save(outboxEvent); + } + + @Override + public List findPendingEvents(int limit) { + return outboxEventJpaRepository.findPendingEvents(limit); + } + + @Override + public OutboxEvent findById(Long id) { + return outboxEventJpaRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("OutboxEvent not found: " + id)); + } + + @Override + public Long findLatestVersionByAggregateId(String aggregateId, String aggregateType) { + return outboxEventJpaRepository.findLatestVersionByAggregateId(aggregateId, aggregateType); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEventPublisherImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEventPublisherImpl.java new file mode 100644 index 000000000..7e8cd2640 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEventPublisherImpl.java @@ -0,0 +1,36 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ProductEvent; +import com.loopers.domain.product.ProductEventPublisher; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +/** + * ProductEventPublisher 인터페이스의 구현체. + *

    + * Spring ApplicationEventPublisher를 사용하여 상품 이벤트를 발행합니다. + * DIP를 준수하여 도메인 인터페이스를 구현합니다. + *

    + *

    + * 표준 패턴: + *

      + *
    • ApplicationEvent만 발행 (단일 책임 원칙)
    • + *
    • Kafka 전송은 OutboxBridgeEventListener가 처리 (관심사 분리)
    • + *
    + *

    + * + * @author Loopers + * @version 1.0 + */ +@Component +@RequiredArgsConstructor +public class ProductEventPublisherImpl implements ProductEventPublisher { + + private final ApplicationEventPublisher applicationEventPublisher; + + @Override + public void publish(ProductEvent.ProductViewed event) { + applicationEventPublisher.publishEvent(event); + } +} diff --git a/apps/commerce-api/src/main/resources/application.yml b/apps/commerce-api/src/main/resources/application.yml index f8971a2f0..584ba6335 100644 --- a/apps/commerce-api/src/main/resources/application.yml +++ b/apps/commerce-api/src/main/resources/application.yml @@ -20,6 +20,7 @@ spring: config: import: - jpa.yml + - kafka.yml - redis.yml - logging.yml - monitoring.yml diff --git a/apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxBridgeEventListenerTest.java b/apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxBridgeEventListenerTest.java new file mode 100644 index 000000000..ae9b15fb9 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxBridgeEventListenerTest.java @@ -0,0 +1,163 @@ +package com.loopers.application.outbox; + +import com.loopers.domain.like.LikeEvent; +import com.loopers.domain.order.OrderEvent; +import com.loopers.domain.product.ProductEvent; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * OutboxBridgeEventListener 테스트. + */ +@ExtendWith(MockitoExtension.class) +class OutboxBridgeEventListenerTest { + + @Mock + private OutboxEventService outboxEventService; + + @InjectMocks + private OutboxBridgeEventListener outboxBridgeEventListener; + + @DisplayName("LikeAdded 이벤트를 Outbox에 저장할 수 있다.") + @Test + void canHandleLikeAdded() { + // arrange + Long userId = 100L; + Long productId = 1L; + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); + + // act + outboxBridgeEventListener.handleLikeAdded(event); + + // assert + verify(outboxEventService).saveEvent( + "LikeAdded", + productId.toString(), + "Product", + event, + "like-events", + productId.toString() + ); + } + + @DisplayName("LikeRemoved 이벤트를 Outbox에 저장할 수 있다.") + @Test + void canHandleLikeRemoved() { + // arrange + Long userId = 100L; + Long productId = 1L; + LikeEvent.LikeRemoved event = new LikeEvent.LikeRemoved(userId, productId, LocalDateTime.now()); + + // act + outboxBridgeEventListener.handleLikeRemoved(event); + + // assert + verify(outboxEventService).saveEvent( + "LikeRemoved", + productId.toString(), + "Product", + event, + "like-events", + productId.toString() + ); + } + + @DisplayName("OrderCreated 이벤트를 Outbox에 저장할 수 있다.") + @Test + void canHandleOrderCreated() { + // arrange + Long orderId = 1L; + Long userId = 100L; + List orderItems = List.of( + new OrderEvent.OrderCreated.OrderItemInfo(1L, 3) + ); + OrderEvent.OrderCreated event = new OrderEvent.OrderCreated( + orderId, userId, null, 10000, 0L, orderItems, LocalDateTime.now() + ); + + // act + outboxBridgeEventListener.handleOrderCreated(event); + + // assert + verify(outboxEventService).saveEvent( + "OrderCreated", + orderId.toString(), + "Order", + event, + "order-events", + orderId.toString() + ); + } + + @DisplayName("ProductViewed 이벤트를 Outbox에 저장할 수 있다.") + @Test + void canHandleProductViewed() { + // arrange + Long productId = 1L; + Long userId = 100L; + ProductEvent.ProductViewed event = new ProductEvent.ProductViewed( + productId, userId, LocalDateTime.now() + ); + + // act + outboxBridgeEventListener.handleProductViewed(event); + + // assert + verify(outboxEventService).saveEvent( + "ProductViewed", + productId.toString(), + "Product", + event, + "product-events", + productId.toString() + ); + } + + @DisplayName("Outbox 저장 실패 시에도 예외를 던지지 않는다 (에러 격리).") + @Test + void doesNotThrowException_whenOutboxSaveFails() { + // arrange + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(100L, 1L, LocalDateTime.now()); + doThrow(new RuntimeException("Outbox 저장 실패")) + .when(outboxEventService).saveEvent(anyString(), anyString(), anyString(), + any(), anyString(), anyString()); + + // act & assert - 예외가 발생하지 않아야 함 + outboxBridgeEventListener.handleLikeAdded(event); + + // verify + verify(outboxEventService).saveEvent(anyString(), anyString(), anyString(), + any(), anyString(), anyString()); + } + + @DisplayName("여러 이벤트를 순차적으로 처리할 수 있다.") + @Test + void canHandleMultipleEvents() { + // arrange + LikeEvent.LikeAdded likeAdded = new LikeEvent.LikeAdded(100L, 1L, LocalDateTime.now()); + LikeEvent.LikeRemoved likeRemoved = new LikeEvent.LikeRemoved(100L, 1L, LocalDateTime.now()); + ProductEvent.ProductViewed productViewed = new ProductEvent.ProductViewed( + 1L, 100L, LocalDateTime.now() + ); + + // act + outboxBridgeEventListener.handleLikeAdded(likeAdded); + outboxBridgeEventListener.handleLikeRemoved(likeRemoved); + outboxBridgeEventListener.handleProductViewed(productViewed); + + // assert + verify(outboxEventService, times(3)).saveEvent( + anyString(), anyString(), anyString(), any(), anyString(), anyString() + ); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxEventServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxEventServiceTest.java new file mode 100644 index 000000000..e2ab86a03 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxEventServiceTest.java @@ -0,0 +1,168 @@ +package com.loopers.application.outbox; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.like.LikeEvent; +import com.loopers.domain.outbox.OutboxEvent; +import com.loopers.domain.outbox.OutboxEventRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; + +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.Mockito.*; + +/** + * OutboxEventService 테스트. + */ +@ExtendWith(MockitoExtension.class) +class OutboxEventServiceTest { + + @Mock + private OutboxEventRepository outboxEventRepository; + + @Mock + private ObjectMapper objectMapper; + + @InjectMocks + private OutboxEventService outboxEventService; + + @DisplayName("이벤트를 Outbox에 저장할 수 있다.") + @Test + void canSaveEvent() throws Exception { + // arrange + String eventType = "LikeAdded"; + String aggregateId = "1"; + String aggregateType = "Product"; + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(100L, 1L, LocalDateTime.now()); + String topic = "like-events"; + String partitionKey = "1"; + String payload = "{\"userId\":100,\"productId\":1}"; + + when(objectMapper.writeValueAsString(event)).thenReturn(payload); + when(outboxEventRepository.findLatestVersionByAggregateId(aggregateId, aggregateType)) + .thenReturn(0L); + when(outboxEventRepository.save(any(OutboxEvent.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + outboxEventService.saveEvent(eventType, aggregateId, aggregateType, event, topic, partitionKey); + + // assert + ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class); + verify(outboxEventRepository).save(captor.capture()); + + OutboxEvent savedEvent = captor.getValue(); + assertThat(savedEvent.getEventType()).isEqualTo(eventType); + assertThat(savedEvent.getAggregateId()).isEqualTo(aggregateId); + assertThat(savedEvent.getAggregateType()).isEqualTo(aggregateType); + assertThat(savedEvent.getPayload()).isEqualTo(payload); + assertThat(savedEvent.getTopic()).isEqualTo(topic); + assertThat(savedEvent.getPartitionKey()).isEqualTo(partitionKey); + assertThat(savedEvent.getVersion()).isEqualTo(1L); // 최신 버전(0) + 1 + assertThat(savedEvent.getStatus()).isEqualTo(OutboxEvent.OutboxStatus.PENDING); + assertThat(savedEvent.getEventId()).isNotNull(); + assertThat(savedEvent.getCreatedAt()).isNotNull(); + } + + @DisplayName("이벤트 저장 시 UUID로 고유한 eventId가 생성된다.") + @Test + void generatesUniqueEventId() throws Exception { + // arrange + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(100L, 1L, LocalDateTime.now()); + when(objectMapper.writeValueAsString(event)).thenReturn("{}"); + when(outboxEventRepository.findLatestVersionByAggregateId(anyString(), anyString())) + .thenReturn(0L); + when(outboxEventRepository.save(any(OutboxEvent.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + outboxEventService.saveEvent("LikeAdded", "1", "Product", event, "like-events", "1"); + outboxEventService.saveEvent("LikeAdded", "2", "Product", event, "like-events", "2"); + + // assert + ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class); + verify(outboxEventRepository, times(2)).save(captor.capture()); + + OutboxEvent event1 = captor.getAllValues().get(0); + OutboxEvent event2 = captor.getAllValues().get(1); + assertThat(event1.getEventId()).isNotEqualTo(event2.getEventId()); + } + + @DisplayName("같은 집계 ID에 대해 버전이 순차적으로 증가한다.") + @Test + void incrementsVersionSequentially() throws Exception { + // arrange + String aggregateId = "1"; + String aggregateType = "Product"; + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(100L, 1L, LocalDateTime.now()); + when(objectMapper.writeValueAsString(event)).thenReturn("{}"); + when(outboxEventRepository.findLatestVersionByAggregateId(aggregateId, aggregateType)) + .thenReturn(0L) // 첫 번째 호출: 최신 버전 0 + .thenReturn(1L) // 두 번째 호출: 최신 버전 1 + .thenReturn(2L); // 세 번째 호출: 최신 버전 2 + when(outboxEventRepository.save(any(OutboxEvent.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + outboxEventService.saveEvent("LikeAdded", aggregateId, aggregateType, event, "like-events", aggregateId); + outboxEventService.saveEvent("LikeRemoved", aggregateId, aggregateType, event, "like-events", aggregateId); + outboxEventService.saveEvent("ProductViewed", aggregateId, aggregateType, event, "product-events", aggregateId); + + // assert + ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class); + verify(outboxEventRepository, times(3)).save(captor.capture()); + + OutboxEvent event1 = captor.getAllValues().get(0); + OutboxEvent event2 = captor.getAllValues().get(1); + OutboxEvent event3 = captor.getAllValues().get(2); + + assertThat(event1.getVersion()).isEqualTo(1L); + assertThat(event2.getVersion()).isEqualTo(2L); + assertThat(event3.getVersion()).isEqualTo(3L); + } + + @DisplayName("JSON 직렬화 실패 시 예외를 발생시킨다.") + @Test + void throwsException_whenJsonSerializationFails() throws Exception { + // arrange + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(100L, 1L, LocalDateTime.now()); + when(objectMapper.writeValueAsString(event)) + .thenThrow(new RuntimeException("JSON 직렬화 실패")); + + // act & assert + assertThatThrownBy(() -> + outboxEventService.saveEvent("LikeAdded", "1", "Product", event, "like-events", "1") + ).isInstanceOf(RuntimeException.class) + .hasMessageContaining("Outbox 이벤트 저장 실패"); + + verify(outboxEventRepository, never()).save(any()); + } + + @DisplayName("Repository 저장 실패 시 예외를 발생시킨다.") + @Test + void throwsException_whenRepositorySaveFails() throws Exception { + // arrange + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(100L, 1L, LocalDateTime.now()); + when(objectMapper.writeValueAsString(event)).thenReturn("{}"); + when(outboxEventRepository.findLatestVersionByAggregateId("1", "Product")) + .thenReturn(0L); + when(outboxEventRepository.save(any(OutboxEvent.class))) + .thenThrow(new RuntimeException("DB 저장 실패")); + + // act & assert + assertThatThrownBy(() -> + outboxEventService.saveEvent("LikeAdded", "1", "Product", event, "like-events", "1") + ).isInstanceOf(RuntimeException.class) + .hasMessageContaining("Outbox 이벤트 저장 실패"); + + verify(outboxEventRepository).save(any(OutboxEvent.class)); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/outbox/OutboxEventTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/outbox/OutboxEventTest.java new file mode 100644 index 000000000..190eae400 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/outbox/OutboxEventTest.java @@ -0,0 +1,124 @@ +package com.loopers.domain.outbox; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * OutboxEvent 도메인 테스트. + */ +class OutboxEventTest { + + @DisplayName("OutboxEvent는 필수 필드로 생성되며 초기 상태가 PENDING이다.") + @Test + void createsOutboxEventWithPendingStatus() { + // arrange + String eventId = "event-123"; + String eventType = "OrderCreated"; + String aggregateId = "1"; + String aggregateType = "Order"; + String payload = "{\"orderId\":1}"; + String topic = "order-events"; + String partitionKey = "1"; + + // act + OutboxEvent outboxEvent = OutboxEvent.builder() + .eventId(eventId) + .eventType(eventType) + .aggregateId(aggregateId) + .aggregateType(aggregateType) + .payload(payload) + .topic(topic) + .partitionKey(partitionKey) + .build(); + + // assert + assertThat(outboxEvent.getEventId()).isEqualTo(eventId); + assertThat(outboxEvent.getEventType()).isEqualTo(eventType); + assertThat(outboxEvent.getAggregateId()).isEqualTo(aggregateId); + assertThat(outboxEvent.getAggregateType()).isEqualTo(aggregateType); + assertThat(outboxEvent.getPayload()).isEqualTo(payload); + assertThat(outboxEvent.getTopic()).isEqualTo(topic); + assertThat(outboxEvent.getPartitionKey()).isEqualTo(partitionKey); + assertThat(outboxEvent.getStatus()).isEqualTo(OutboxEvent.OutboxStatus.PENDING); + assertThat(outboxEvent.getCreatedAt()).isNotNull(); + assertThat(outboxEvent.getPublishedAt()).isNull(); + } + + @DisplayName("이벤트를 발행 완료 상태로 변경할 수 있다.") + @Test + void canMarkAsPublished() throws InterruptedException { + // arrange + OutboxEvent outboxEvent = OutboxEvent.builder() + .eventId("event-123") + .eventType("OrderCreated") + .aggregateId("1") + .aggregateType("Order") + .payload("{}") + .topic("order-events") + .partitionKey("1") + .build(); + + LocalDateTime beforePublish = outboxEvent.getCreatedAt(); + Thread.sleep(1); // 시간 차이를 보장하기 위한 작은 지연 + + // act + outboxEvent.markAsPublished(); + + // assert + assertThat(outboxEvent.getStatus()).isEqualTo(OutboxEvent.OutboxStatus.PUBLISHED); + assertThat(outboxEvent.getPublishedAt()).isNotNull(); + assertThat(outboxEvent.getPublishedAt()).isAfter(beforePublish); + } + + @DisplayName("이벤트를 실패 상태로 변경할 수 있다.") + @Test + void canMarkAsFailed() { + // arrange + OutboxEvent outboxEvent = OutboxEvent.builder() + .eventId("event-123") + .eventType("OrderCreated") + .aggregateId("1") + .aggregateType("Order") + .payload("{}") + .topic("order-events") + .partitionKey("1") + .build(); + + // act + outboxEvent.markAsFailed(); + + // assert + assertThat(outboxEvent.getStatus()).isEqualTo(OutboxEvent.OutboxStatus.FAILED); + assertThat(outboxEvent.getPublishedAt()).isNull(); + } + + @DisplayName("발행 완료 후 실패 상태로 변경할 수 있다.") + @Test + void canMarkAsFailedAfterPublished() { + // arrange + OutboxEvent outboxEvent = OutboxEvent.builder() + .eventId("event-123") + .eventType("OrderCreated") + .aggregateId("1") + .aggregateType("Order") + .payload("{}") + .topic("order-events") + .partitionKey("1") + .build(); + + outboxEvent.markAsPublished(); + LocalDateTime publishedAt = outboxEvent.getPublishedAt(); + + // act + outboxEvent.markAsFailed(); + + // assert + assertThat(outboxEvent.getStatus()).isEqualTo(OutboxEvent.OutboxStatus.FAILED); + // markAsFailed는 publishedAt을 변경하지 않음 + assertThat(outboxEvent.getPublishedAt()).isEqualTo(publishedAt); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherIntegrationTest.java new file mode 100644 index 000000000..be6e6a9bb --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherIntegrationTest.java @@ -0,0 +1,43 @@ +package com.loopers.infrastructure.outbox; + +import com.loopers.testcontainers.KafkaTestContainersConfig; +import com.loopers.utils.KafkaCleanUp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +/** + * OutboxEventPublisher 통합 테스트. + *

    + * 실제 Kafka를 사용하여 Outbox 패턴의 이벤트 발행 동작을 검증합니다. + *

    + *

    + * Kafka 컨테이너: + * {@link KafkaTestContainersConfig}가 테스트 실행 시 자동으로 Kafka 컨테이너를 시작합니다. + *

    + */ +@SpringBootTest +@ActiveProfiles("test") +@Import(KafkaTestContainersConfig.class) +class OutboxEventPublisherIntegrationTest { + + @Autowired + private KafkaCleanUp kafkaCleanUp; + + @BeforeEach + void setUp() { + // 테스트 전에 토픽의 모든 메시지 삭제 및 재생성 + kafkaCleanUp.resetAllTestTopics(); + } + + @DisplayName("통합 테스트: Outbox 패턴을 통한 Kafka 이벤트 발행이 정상적으로 동작한다.") + @Test + void integrationTest() { + // TODO: 실제 Kafka를 사용한 통합 테스트 구현 + // 예: OutboxEvent를 저장한 후 OutboxEventPublisher가 Kafka로 발행하는지 확인 + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherTest.java new file mode 100644 index 000000000..e54550433 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/outbox/OutboxEventPublisherTest.java @@ -0,0 +1,299 @@ +package com.loopers.infrastructure.outbox; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.outbox.OutboxEvent; +import com.loopers.domain.outbox.OutboxEventRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.support.KafkaHeaders; +import org.springframework.kafka.support.SendResult; +import org.springframework.messaging.Message; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * OutboxEventPublisher 테스트. + */ +@ExtendWith(MockitoExtension.class) +class OutboxEventPublisherTest { + + @Mock + private OutboxEventRepository outboxEventRepository; + + @Mock + private KafkaTemplate kafkaTemplate; + + @Mock + private ObjectMapper objectMapper; + + @InjectMocks + private OutboxEventPublisher outboxEventPublisher; + + @DisplayName("PENDING 상태의 이벤트를 Kafka로 발행할 수 있다.") + @Test + void canPublishPendingEvents() throws Exception { + // arrange + OutboxEvent event1 = createPendingEvent("event-1", "order-events", "1"); + OutboxEvent event2 = createPendingEvent("event-2", "like-events", "1"); + List pendingEvents = List.of(event1, event2); + + when(outboxEventRepository.findPendingEvents(100)).thenReturn(pendingEvents); + when(objectMapper.readValue(anyString(), eq(Object.class))) + .thenReturn(Map.of("orderId", 1)); + when(kafkaTemplate.send(anyString(), any(Message.class))) + .thenReturn(createSuccessFuture()); + when(outboxEventRepository.save(any(OutboxEvent.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + outboxEventPublisher.publishPendingEvents(); + + // assert + verify(kafkaTemplate, times(2)).send(anyString(), any(Message.class)); + verify(outboxEventRepository, times(2)).save(any(OutboxEvent.class)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class); + verify(outboxEventRepository, times(2)).save(captor.capture()); + + List savedEvents = captor.getAllValues(); + assertThat(savedEvents).allMatch(e -> + e.getStatus() == OutboxEvent.OutboxStatus.PUBLISHED + ); + assertThat(savedEvents).allMatch(e -> + e.getPublishedAt() != null + ); + } + + @DisplayName("PENDING 이벤트가 없으면 아무것도 발행하지 않는다.") + @Test + void doesNothing_whenNoPendingEvents() { + // arrange + when(outboxEventRepository.findPendingEvents(100)).thenReturn(List.of()); + + // act + outboxEventPublisher.publishPendingEvents(); + + // assert + verify(kafkaTemplate, never()).send(anyString(), any(Message.class)); + verify(outboxEventRepository, never()).save(any(OutboxEvent.class)); + } + + @DisplayName("개별 이벤트 발행 실패 시에도 배치 처리를 계속한다.") + @Test + void continuesProcessing_whenIndividualEventFails() throws Exception { + // arrange + OutboxEvent event1 = createPendingEvent("event-1", "order-events", "1"); + OutboxEvent event2 = createPendingEvent("event-2", "like-events", "1"); + List pendingEvents = List.of(event1, event2); + + when(outboxEventRepository.findPendingEvents(100)).thenReturn(pendingEvents); + when(objectMapper.readValue(anyString(), eq(Object.class))) + .thenReturn(Map.of("orderId", 1)); + when(kafkaTemplate.send(eq("order-events"), any(Message.class))) + .thenThrow(new RuntimeException("Kafka 발행 실패")); + when(kafkaTemplate.send(eq("like-events"), any(Message.class))) + .thenReturn(createSuccessFuture()); + when(outboxEventRepository.save(any(OutboxEvent.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + outboxEventPublisher.publishPendingEvents(); + + // assert + verify(kafkaTemplate, times(2)).send(anyString(), any(Message.class)); + verify(outboxEventRepository, times(2)).save(any(OutboxEvent.class)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class); + verify(outboxEventRepository, times(2)).save(captor.capture()); + + List savedEvents = captor.getAllValues(); + // event1은 FAILED, event2는 PUBLISHED + assertThat(savedEvents).anyMatch(e -> + e.getEventId().equals("event-1") && + e.getStatus() == OutboxEvent.OutboxStatus.FAILED + ); + assertThat(savedEvents).anyMatch(e -> + e.getEventId().equals("event-2") && + e.getStatus() == OutboxEvent.OutboxStatus.PUBLISHED + ); + } + + @DisplayName("Kafka 발행 성공 시 이벤트 상태를 PUBLISHED로 변경한다.") + @Test + void marksAsPublished_whenKafkaPublishSucceeds() throws Exception { + // arrange + OutboxEvent event = createPendingEvent("event-1", "order-events", "1"); + List pendingEvents = List.of(event); + + when(outboxEventRepository.findPendingEvents(100)).thenReturn(pendingEvents); + when(objectMapper.readValue(anyString(), eq(Object.class))) + .thenReturn(Map.of("orderId", 1)); + when(kafkaTemplate.send(anyString(), any(Message.class))) + .thenReturn(createSuccessFuture()); + when(outboxEventRepository.save(any(OutboxEvent.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + outboxEventPublisher.publishPendingEvents(); + + // assert + ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class); + verify(outboxEventRepository).save(captor.capture()); + + OutboxEvent savedEvent = captor.getValue(); + assertThat(savedEvent.getStatus()).isEqualTo(OutboxEvent.OutboxStatus.PUBLISHED); + assertThat(savedEvent.getPublishedAt()).isNotNull(); + } + + @DisplayName("Kafka 발행 실패 시 이벤트 상태를 FAILED로 변경한다.") + @Test + void marksAsFailed_whenKafkaPublishFails() throws Exception { + // arrange + OutboxEvent event = createPendingEvent("event-1", "order-events", "1"); + List pendingEvents = List.of(event); + + when(outboxEventRepository.findPendingEvents(100)).thenReturn(pendingEvents); + when(objectMapper.readValue(anyString(), eq(Object.class))) + .thenReturn(Map.of("orderId", 1)); + when(kafkaTemplate.send(anyString(), any(Message.class))) + .thenThrow(new RuntimeException("Kafka 발행 실패")); + when(outboxEventRepository.save(any(OutboxEvent.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + outboxEventPublisher.publishPendingEvents(); + + // assert + ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class); + verify(outboxEventRepository).save(captor.capture()); + + OutboxEvent savedEvent = captor.getValue(); + assertThat(savedEvent.getStatus()).isEqualTo(OutboxEvent.OutboxStatus.FAILED); + assertThat(savedEvent.getPublishedAt()).isNull(); + } + + @DisplayName("JSON 역직렬화 실패 시 이벤트 상태를 FAILED로 변경한다.") + @Test + void marksAsFailed_whenJsonDeserializationFails() throws Exception { + // arrange + OutboxEvent event = createPendingEvent("event-1", "order-events", "1"); + List pendingEvents = List.of(event); + + when(outboxEventRepository.findPendingEvents(100)).thenReturn(pendingEvents); + when(objectMapper.readValue(anyString(), eq(Object.class))) + .thenThrow(new RuntimeException("JSON 역직렬화 실패")); + when(outboxEventRepository.save(any(OutboxEvent.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + outboxEventPublisher.publishPendingEvents(); + + // assert + ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class); + verify(outboxEventRepository).save(captor.capture()); + + OutboxEvent savedEvent = captor.getValue(); + assertThat(savedEvent.getStatus()).isEqualTo(OutboxEvent.OutboxStatus.FAILED); + verify(kafkaTemplate, never()).send(anyString(), any(Message.class)); + } + + @DisplayName("배치 크기만큼 이벤트를 조회한다.") + @Test + void queriesEventsWithBatchSize() { + // arrange + when(outboxEventRepository.findPendingEvents(100)).thenReturn(List.of()); + + // act + outboxEventPublisher.publishPendingEvents(); + + // assert + verify(outboxEventRepository).findPendingEvents(100); + } + + @DisplayName("각 토픽에 적절한 파티션 키를 사용하여 Kafka로 발행한다.") + @Test + void usesCorrectPartitionKeyForEachTopic() throws Exception { + // arrange + OutboxEvent likeEvent = createPendingEvent("event-1", "like-events", "product-123"); + OutboxEvent orderEvent = createPendingEvent("event-2", "order-events", "order-456"); + OutboxEvent productEvent = createPendingEvent("event-3", "product-events", "product-789"); + List pendingEvents = List.of(likeEvent, orderEvent, productEvent); + + when(outboxEventRepository.findPendingEvents(100)).thenReturn(pendingEvents); + when(objectMapper.readValue(anyString(), eq(Object.class))) + .thenReturn(Map.of("productId", 123)); + when(kafkaTemplate.send(anyString(), any(Message.class))) + .thenReturn(createSuccessFuture()); + when(outboxEventRepository.save(any(OutboxEvent.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + outboxEventPublisher.publishPendingEvents(); + + // assert - 각 토픽에 올바른 파티션 키가 전달되는지 검증 + ArgumentCaptor topicCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(Message.class); + + verify(kafkaTemplate, times(3)).send( + topicCaptor.capture(), + messageCaptor.capture() + ); + + List topics = topicCaptor.getAllValues(); + List messages = messageCaptor.getAllValues(); + + // like-events는 productId를 파티션 키로 사용 + int likeIndex = topics.indexOf("like-events"); + assertThat(likeIndex).isNotEqualTo(-1); + assertThat(messages.get(likeIndex).getHeaders().get(KafkaHeaders.KEY)) + .isEqualTo("product-123"); + + // order-events는 orderId를 파티션 키로 사용 + int orderIndex = topics.indexOf("order-events"); + assertThat(orderIndex).isNotEqualTo(-1); + assertThat(messages.get(orderIndex).getHeaders().get(KafkaHeaders.KEY)) + .isEqualTo("order-456"); + + // product-events는 productId를 파티션 키로 사용 + int productIndex = topics.indexOf("product-events"); + assertThat(productIndex).isNotEqualTo(-1); + assertThat(messages.get(productIndex).getHeaders().get(KafkaHeaders.KEY)) + .isEqualTo("product-789"); + } + + /** + * PENDING 상태의 OutboxEvent를 생성합니다. + */ + private OutboxEvent createPendingEvent(String eventId, String topic, String partitionKey) { + return OutboxEvent.builder() + .eventId(eventId) + .eventType("OrderCreated") + .aggregateId("1") + .aggregateType("Order") + .payload("{\"orderId\":1}") + .topic(topic) + .partitionKey(partitionKey) + .build(); + } + + /** + * Kafka 발행 성공을 시뮬레이션하는 CompletableFuture를 생성합니다. + */ + @SuppressWarnings("unchecked") + private CompletableFuture> createSuccessFuture() { + return (CompletableFuture>) (CompletableFuture) + CompletableFuture.completedFuture(null); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/eventhandled/EventHandledService.java b/apps/commerce-streamer/src/main/java/com/loopers/application/eventhandled/EventHandledService.java new file mode 100644 index 000000000..bbb016ee2 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/eventhandled/EventHandledService.java @@ -0,0 +1,64 @@ +package com.loopers.application.eventhandled; + +import com.loopers.domain.eventhandled.EventHandled; +import com.loopers.domain.eventhandled.EventHandledRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 이벤트 처리 기록 서비스. + *

    + * Kafka Consumer에서 이벤트의 멱등성을 보장하기 위한 서비스입니다. + * 이벤트 처리 전 `eventId`가 이미 처리되었는지 확인하고, + * 처리되지 않은 경우에만 처리 기록을 저장합니다. + *

    + * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class EventHandledService { + + private final EventHandledRepository eventHandledRepository; + + /** + * 이벤트가 이미 처리되었는지 확인합니다. + * + * @param eventId 이벤트 ID + * @return 이미 처리된 경우 true, 그렇지 않으면 false + */ + @Transactional(readOnly = true) + public boolean isAlreadyHandled(String eventId) { + return eventHandledRepository.existsByEventId(eventId); + } + + /** + * 이벤트 처리 기록을 저장합니다. + *

    + * UNIQUE 제약조건 위반 시 예외를 발생시킵니다. + * 이는 동시성 상황에서 중복 처리를 방지하기 위한 것입니다. + *

    + * + * @param eventId 이벤트 ID + * @param eventType 이벤트 타입 (예: "LikeAdded", "OrderCreated") + * @param topic Kafka 토픽 이름 + * @throws org.springframework.dao.DataIntegrityViolationException 이미 처리된 이벤트인 경우 + */ + @Transactional + public void markAsHandled(String eventId, String eventType, String topic) { + try { + EventHandled eventHandled = new EventHandled(eventId, eventType, topic); + eventHandledRepository.save(eventHandled); + log.debug("이벤트 처리 기록 저장: eventId={}, eventType={}, topic={}", + eventId, eventType, topic); + } catch (org.springframework.dao.DataIntegrityViolationException e) { + // UNIQUE 제약조건 위반 = 이미 처리됨 (멱등성 보장) + log.warn("이벤트가 이미 처리되었습니다: eventId={}", eventId); + throw e; + } + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsService.java b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsService.java new file mode 100644 index 000000000..98227105d --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/metrics/ProductMetricsService.java @@ -0,0 +1,165 @@ +package com.loopers.application.metrics; + +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.metrics.ProductMetricsRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 상품 메트릭 집계 서비스. + *

    + * Kafka Consumer에서 이벤트를 수취하여 상품 메트릭을 집계합니다. + * 좋아요 수, 판매량, 상세 페이지 조회 수 등을 upsert합니다. + *

    + *

    + * 도메인 분리 근거: + *

      + *
    • 외부 시스템(데이터 플랫폼, 분석 시스템)을 위한 메트릭 집계
    • + *
    • Product 도메인의 핵심 비즈니스 로직과는 분리된 관심사
    • + *
    • Kafka Consumer를 통한 이벤트 기반 집계
    • + *
    + *

    + * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ProductMetricsService { + + private final ProductMetricsRepository productMetricsRepository; + + /** + * 좋아요 수를 증가시킵니다. + *

    + * 이벤트의 버전을 기준으로 최신 이벤트만 반영합니다. + *

    + * + * @param productId 상품 ID + * @param eventVersion 이벤트의 버전 + */ + @Transactional + public void incrementLikeCount(Long productId, Long eventVersion) { + ProductMetrics metrics = findOrCreate(productId); + + // 버전 비교: 이벤트가 최신이 아니면 스킵 + if (!metrics.shouldUpdate(eventVersion)) { + log.debug("오래된 이벤트 스킵: productId={}, eventVersion={}, metricsVersion={}", + productId, eventVersion, metrics.getVersion()); + return; + } + + metrics.incrementLikeCount(); + productMetricsRepository.save(metrics); + log.debug("좋아요 수 증가: productId={}, likeCount={}", productId, metrics.getLikeCount()); + } + + /** + * 좋아요 수를 감소시킵니다. + *

    + * 이벤트의 버전을 기준으로 최신 이벤트만 반영합니다. + *

    + * + * @param productId 상품 ID + * @param eventVersion 이벤트의 버전 + */ + @Transactional + public void decrementLikeCount(Long productId, Long eventVersion) { + ProductMetrics metrics = findOrCreate(productId); + + // 버전 비교: 이벤트가 최신이 아니면 스킵 + if (!metrics.shouldUpdate(eventVersion)) { + log.debug("오래된 이벤트 스킵: productId={}, eventVersion={}, metricsVersion={}", + productId, eventVersion, metrics.getVersion()); + return; + } + + metrics.decrementLikeCount(); + productMetricsRepository.save(metrics); + log.debug("좋아요 수 감소: productId={}, likeCount={}", productId, metrics.getLikeCount()); + } + + /** + * 판매량을 증가시킵니다. + *

    + * 이벤트의 버전을 기준으로 최신 이벤트만 반영합니다. + *

    + * + * @param productId 상품 ID + * @param quantity 판매 수량 + * @param eventVersion 이벤트의 버전 + */ + @Transactional + public void incrementSalesCount(Long productId, Integer quantity, Long eventVersion) { + ProductMetrics metrics = findOrCreate(productId); + + // 버전 비교: 이벤트가 최신이 아니면 스킵 + if (!metrics.shouldUpdate(eventVersion)) { + log.debug("오래된 이벤트 스킵: productId={}, eventVersion={}, metricsVersion={}", + productId, eventVersion, metrics.getVersion()); + return; + } + + metrics.incrementSalesCount(quantity); + productMetricsRepository.save(metrics); + log.debug("판매량 증가: productId={}, quantity={}, salesCount={}", + productId, quantity, metrics.getSalesCount()); + } + + /** + * 상세 페이지 조회 수를 증가시킵니다. + *

    + * 이벤트의 버전을 기준으로 최신 이벤트만 반영합니다. + *

    + * + * @param productId 상품 ID + * @param eventVersion 이벤트의 버전 + */ + @Transactional + public void incrementViewCount(Long productId, Long eventVersion) { + ProductMetrics metrics = findOrCreate(productId); + + // 버전 비교: 이벤트가 최신이 아니면 스킵 + if (!metrics.shouldUpdate(eventVersion)) { + log.debug("오래된 이벤트 스킵: productId={}, eventVersion={}, metricsVersion={}", + productId, eventVersion, metrics.getVersion()); + return; + } + + metrics.incrementViewCount(); + productMetricsRepository.save(metrics); + log.debug("조회 수 증가: productId={}, viewCount={}", productId, metrics.getViewCount()); + } + + /** + * 상품 메트릭을 조회하거나 없으면 생성합니다. + *

    + * 비관적 락을 사용하여 동시성 제어를 보장합니다. + * 신규 생성 시 동시 삽입으로 인한 unique constraint violation을 처리합니다. + *

    + * + * @param productId 상품 ID + * @return ProductMetrics 인스턴스 + */ + private ProductMetrics findOrCreate(Long productId) { + return productMetricsRepository + .findByProductIdForUpdate(productId) + .orElseGet(() -> { + try { + ProductMetrics newMetrics = new ProductMetrics(productId); + return productMetricsRepository.save(newMetrics); + } catch (DataIntegrityViolationException e) { + // 동시 삽입 시 재조회 + log.debug("동시 삽입 감지, 재조회: productId={}", productId); + return productMetricsRepository + .findByProductIdForUpdate(productId) + .orElseThrow(() -> new IllegalStateException( + "ProductMetrics 생성 실패: productId=" + productId)); + } + }); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/event/LikeEvent.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/LikeEvent.java new file mode 100644 index 000000000..f806ffea1 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/LikeEvent.java @@ -0,0 +1,37 @@ +package com.loopers.domain.event; + +import java.time.LocalDateTime; + +/** + * 좋아요 이벤트 DTO. + *

    + * Kafka에서 수신한 좋아요 이벤트를 파싱하기 위한 DTO입니다. + * 주의: 이 클래스는 commerce-api의 LikeEvent와 동일한 구조를 가진 DTO입니다. + * 향후 공유 모듈로 분리하는 것을 고려해야 합니다. + *

    + * + * @author Loopers + * @version 1.0 + */ +public class LikeEvent { + + /** + * 좋아요 추가 이벤트. + */ + public record LikeAdded( + Long userId, + Long productId, + LocalDateTime occurredAt + ) { + } + + /** + * 좋아요 취소 이벤트. + */ + public record LikeRemoved( + Long userId, + Long productId, + LocalDateTime occurredAt + ) { + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/event/OrderEvent.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/OrderEvent.java new file mode 100644 index 000000000..eacbbc19a --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/OrderEvent.java @@ -0,0 +1,40 @@ +package com.loopers.domain.event; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 주문 이벤트 DTO. + *

    + * Kafka에서 수신한 주문 이벤트를 파싱하기 위한 DTO입니다. + * 주의: 이 클래스는 commerce-api의 OrderEvent와 동일한 구조를 가진 DTO입니다. + * 향후 공유 모듈로 분리하는 것을 고려해야 합니다. + *

    + * + * @author Loopers + * @version 1.0 + */ +public class OrderEvent { + + /** + * 주문 생성 이벤트. + */ + public record OrderCreated( + Long orderId, + Long userId, + String couponCode, + Integer subtotal, + Long usedPointAmount, + List orderItems, + LocalDateTime createdAt + ) { + /** + * 주문 아이템 정보. + */ + public record OrderItemInfo( + Long productId, + Integer quantity + ) { + } + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/event/ProductEvent.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/ProductEvent.java new file mode 100644 index 000000000..4bd7f3587 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/ProductEvent.java @@ -0,0 +1,27 @@ +package com.loopers.domain.event; + +import java.time.LocalDateTime; + +/** + * 상품 이벤트 DTO. + *

    + * Kafka에서 수신한 상품 이벤트를 파싱하기 위한 DTO입니다. + * 주의: 이 클래스는 commerce-api의 ProductEvent와 동일한 구조를 가진 DTO입니다. + * 향후 공유 모듈로 분리하는 것을 고려해야 합니다. + *

    + * + * @author Loopers + * @version 1.0 + */ +public class ProductEvent { + + /** + * 상품 상세 페이지 조회 이벤트. + */ + public record ProductViewed( + Long productId, + Long userId, + LocalDateTime occurredAt + ) { + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandled.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandled.java new file mode 100644 index 000000000..b280fb891 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandled.java @@ -0,0 +1,62 @@ +package com.loopers.domain.eventhandled; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 이벤트 처리 기록 엔티티. + *

    + * Kafka Consumer에서 처리한 이벤트의 멱등성을 보장하기 위한 엔티티입니다. + * `eventId`를 Primary Key로 사용하여 중복 처리를 방지합니다. + *

    + *

    + * 멱등성 보장: + *

      + *
    • 동일한 `eventId`를 가진 이벤트는 한 번만 처리됩니다
    • + *
    • UNIQUE 제약조건으로 데이터베이스 레벨에서 중복 방지
    • + *
    • 이벤트 처리 전 `eventId` 존재 여부를 확인하여 중복 처리 방지
    • + *
    + *

    + * + * @author Loopers + * @version 1.0 + */ +@Entity +@Table(name = "event_handled", indexes = { + @Index(name = "idx_handled_at", columnList = "handled_at") +}) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class EventHandled { + + @Id + @Column(name = "event_id", nullable = false, length = 255) + private String eventId; + + @Column(name = "event_type", nullable = false, length = 100) + private String eventType; + + @Column(name = "topic", nullable = false, length = 255) + private String topic; + + @Column(name = "handled_at", nullable = false) + private LocalDateTime handledAt; + + /** + * EventHandled 인스턴스를 생성합니다. + * + * @param eventId 이벤트 ID (UUID) + * @param eventType 이벤트 타입 (예: "LikeAdded", "OrderCreated") + * @param topic Kafka 토픽 이름 + */ + public EventHandled(String eventId, String eventType, String topic) { + this.eventId = eventId; + this.eventType = eventType; + this.topic = topic; + this.handledAt = LocalDateTime.now(); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandledRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandledRepository.java new file mode 100644 index 000000000..536ddbd63 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/eventhandled/EventHandledRepository.java @@ -0,0 +1,40 @@ +package com.loopers.domain.eventhandled; + +import java.util.Optional; + +/** + * EventHandled 엔티티에 대한 저장소 인터페이스. + *

    + * 이벤트 처리 기록의 영속성 계층과의 상호작용을 정의합니다. + * DIP를 준수하여 도메인 레이어에서 인터페이스를 정의합니다. + *

    + * + * @author Loopers + * @version 1.0 + */ +public interface EventHandledRepository { + + /** + * 이벤트 처리 기록을 저장합니다. + * + * @param eventHandled 저장할 이벤트 처리 기록 + * @return 저장된 이벤트 처리 기록 + */ + EventHandled save(EventHandled eventHandled); + + /** + * 이벤트 ID로 처리 기록을 조회합니다. + * + * @param eventId 이벤트 ID + * @return 조회된 처리 기록을 담은 Optional + */ + Optional findByEventId(String eventId); + + /** + * 이벤트 ID가 이미 처리되었는지 확인합니다. + * + * @param eventId 이벤트 ID + * @return 이미 처리된 경우 true, 그렇지 않으면 false + */ + boolean existsByEventId(String eventId); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java new file mode 100644 index 000000000..f552b355c --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java @@ -0,0 +1,127 @@ +package com.loopers.domain.metrics; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 상품 메트릭 집계 엔티티. + *

    + * Kafka Consumer에서 이벤트를 수취하여 집계한 메트릭을 저장합니다. + * 좋아요 수, 판매량, 상세 페이지 조회 수 등을 관리합니다. + *

    + *

    + * 도메인 분리 근거: + *

      + *
    • 외부 시스템(데이터 플랫폼, 분석 시스템)을 위한 메트릭 집계
    • + *
    • Product 도메인의 핵심 비즈니스 로직과는 분리된 관심사
    • + *
    • Kafka Consumer를 통한 이벤트 기반 집계
    • + *
    + *

    + * + * @author Loopers + * @version 1.0 + */ +@Entity +@Table(name = "product_metrics") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class ProductMetrics { + + @Id + @Column(name = "product_id") + private Long productId; + + @Column(name = "like_count", nullable = false) + private Long likeCount; + + @Column(name = "sales_count", nullable = false) + private Long salesCount; + + @Column(name = "view_count", nullable = false) + private Long viewCount; + + @Column(name = "version", nullable = false) + private Long version; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + /** + * ProductMetrics 인스턴스를 생성합니다. + * + * @param productId 상품 ID + */ + public ProductMetrics(Long productId) { + this.productId = productId; + this.likeCount = 0L; + this.salesCount = 0L; + this.viewCount = 0L; + this.version = 0L; + this.updatedAt = LocalDateTime.now(); + } + + /** + * 좋아요 수를 증가시킵니다. + */ + public void incrementLikeCount() { + this.likeCount++; + this.version++; + this.updatedAt = LocalDateTime.now(); + } + + /** + * 좋아요 수를 감소시킵니다. + */ + public void decrementLikeCount() { + if (this.likeCount > 0) { + this.likeCount--; + this.version++; + this.updatedAt = LocalDateTime.now(); + } + } + + /** + * 판매량을 증가시킵니다. + * + * @param quantity 판매 수량 + */ + public void incrementSalesCount(Integer quantity) { + if (quantity != null && quantity > 0) { + this.salesCount += quantity; + this.version++; + this.updatedAt = LocalDateTime.now(); + } + } + + /** + * 상세 페이지 조회 수를 증가시킵니다. + */ + public void incrementViewCount() { + this.viewCount++; + this.version++; + this.updatedAt = LocalDateTime.now(); + } + + /** + * 이벤트의 버전을 기준으로 메트릭을 업데이트해야 하는지 확인합니다. + *

    + * 이벤트의 `version`이 메트릭의 `version`보다 크면 업데이트합니다. + * 이를 통해 오래된 이벤트가 최신 메트릭을 덮어쓰는 것을 방지합니다. + *

    + * + * @param eventVersion 이벤트의 버전 + * @return 업데이트해야 하면 true, 그렇지 않으면 false + */ + public boolean shouldUpdate(Long eventVersion) { + if (eventVersion == null) { + // 이벤트에 버전 정보가 없으면 업데이트 (하위 호환성) + return true; + } + // 이벤트 버전이 메트릭 버전보다 크면 업데이트 + return eventVersion > this.version; + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java new file mode 100644 index 000000000..4ffe5938e --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java @@ -0,0 +1,58 @@ +package com.loopers.domain.metrics; + +import java.util.Optional; + +/** + * ProductMetrics 엔티티에 대한 저장소 인터페이스. + *

    + * 상품 메트릭 집계 데이터의 영속성 계층과의 상호작용을 정의합니다. + * DIP를 준수하여 도메인 레이어에서 인터페이스를 정의합니다. + *

    + *

    + * 도메인 분리 근거: + *

      + *
    • Metric 도메인은 외부 시스템 연동을 위한 별도 관심사
    • + *
    • Product 도메인의 핵심 비즈니스 로직과는 분리
    • + *
    + *

    + * + * @author Loopers + * @version 1.0 + */ +public interface ProductMetricsRepository { + + /** + * 상품 메트릭을 저장합니다. + * + * @param productMetrics 저장할 상품 메트릭 + * @return 저장된 상품 메트릭 + */ + ProductMetrics save(ProductMetrics productMetrics); + + /** + * 상품 ID로 메트릭을 조회합니다. + * + * @param productId 상품 ID + * @return 조회된 메트릭을 담은 Optional + */ + Optional findByProductId(Long productId); + + /** + * 상품 ID로 메트릭을 조회합니다. (비관적 락) + *

    + * Upsert 시 동시성 제어를 위해 사용합니다. + *

    + *

    + * Lock 전략: + *

      + *
    • PESSIMISTIC_WRITE: SELECT ... FOR UPDATE 사용
    • + *
    • Lock 범위: PK(productId) 기반 조회로 해당 행만 락 (최소화)
    • + *
    • 사용 목적: 메트릭 집계 시 Lost Update 방지
    • + *
    + *

    + * + * @param productId 상품 ID + * @return 조회된 메트릭을 담은 Optional + */ + Optional findByProductIdForUpdate(Long productId); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledJpaRepository.java new file mode 100644 index 000000000..f3aefc464 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledJpaRepository.java @@ -0,0 +1,31 @@ +package com.loopers.infrastructure.eventhandled; + +import com.loopers.domain.eventhandled.EventHandled; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +/** + * EventHandled 엔티티에 대한 JPA Repository. + * + * @author Loopers + * @version 1.0 + */ +public interface EventHandledJpaRepository extends JpaRepository { + + /** + * 이벤트 ID로 처리 기록을 조회합니다. + * + * @param eventId 이벤트 ID + * @return 조회된 처리 기록을 담은 Optional + */ + Optional findByEventId(String eventId); + + /** + * 이벤트 ID가 이미 처리되었는지 확인합니다. + * + * @param eventId 이벤트 ID + * @return 이미 처리된 경우 true, 그렇지 않으면 false + */ + boolean existsByEventId(String eventId); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledRepositoryImpl.java new file mode 100644 index 000000000..95dfc6b06 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/eventhandled/EventHandledRepositoryImpl.java @@ -0,0 +1,39 @@ +package com.loopers.infrastructure.eventhandled; + +import com.loopers.domain.eventhandled.EventHandled; +import com.loopers.domain.eventhandled.EventHandledRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +/** + * EventHandledRepository의 구현체. + *

    + * JPA를 사용하여 EventHandled 엔티티의 영속성을 관리합니다. + *

    + * + * @author Loopers + * @version 1.0 + */ +@Repository +@RequiredArgsConstructor +public class EventHandledRepositoryImpl implements EventHandledRepository { + + private final EventHandledJpaRepository jpaRepository; + + @Override + public EventHandled save(EventHandled eventHandled) { + return jpaRepository.save(eventHandled); + } + + @Override + public Optional findByEventId(String eventId) { + return jpaRepository.findByEventId(eventId); + } + + @Override + public boolean existsByEventId(String eventId) { + return jpaRepository.existsByEventId(eventId); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java new file mode 100644 index 000000000..e54cb6aef --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java @@ -0,0 +1,40 @@ +package com.loopers.infrastructure.metrics; + +import com.loopers.domain.metrics.ProductMetrics; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import jakarta.persistence.LockModeType; +import java.util.Optional; + +/** + * ProductMetrics JPA Repository. + *

    + * 상품 메트릭 집계 데이터를 관리합니다. + *

    + */ +public interface ProductMetricsJpaRepository extends JpaRepository { + + /** + * 상품 ID로 메트릭을 조회합니다. + * + * @param productId 상품 ID + * @return 조회된 메트릭을 담은 Optional + */ + Optional findByProductId(Long productId); + + /** + * 상품 ID로 메트릭을 조회합니다. (비관적 락) + *

    + * Upsert 시 동시성 제어를 위해 사용합니다. + *

    + * + * @param productId 상품 ID + * @return 조회된 메트릭을 담은 Optional + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT pm FROM ProductMetrics pm WHERE pm.productId = :productId") + Optional findByProductIdForUpdate(@Param("productId") Long productId); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java new file mode 100644 index 000000000..253da5917 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java @@ -0,0 +1,49 @@ +package com.loopers.infrastructure.metrics; + +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.metrics.ProductMetricsRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +/** + * ProductMetricsRepository의 JPA 구현체. + *

    + * Spring Data JPA를 활용하여 ProductMetrics 엔티티의 + * 영속성 작업을 처리합니다. + *

    + * + * @author Loopers + * @version 1.0 + */ +@Component +@RequiredArgsConstructor +public class ProductMetricsRepositoryImpl implements ProductMetricsRepository { + + private final ProductMetricsJpaRepository productMetricsJpaRepository; + + /** + * {@inheritDoc} + */ + @Override + public ProductMetrics save(ProductMetrics productMetrics) { + return productMetricsJpaRepository.save(productMetrics); + } + + /** + * {@inheritDoc} + */ + @Override + public Optional findByProductId(Long productId) { + return productMetricsJpaRepository.findByProductId(productId); + } + + /** + * {@inheritDoc} + */ + @Override + public Optional findByProductIdForUpdate(Long productId) { + return productMetricsJpaRepository.findByProductIdForUpdate(productId); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java new file mode 100644 index 000000000..2811056d9 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/ProductMetricsConsumer.java @@ -0,0 +1,430 @@ +package com.loopers.interfaces.consumer; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.eventhandled.EventHandledService; +import com.loopers.application.metrics.ProductMetricsService; +import com.loopers.confg.kafka.KafkaConfig; +import com.loopers.domain.event.LikeEvent; +import com.loopers.domain.event.OrderEvent; +import com.loopers.domain.event.ProductEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.common.header.Header; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +/** + * 상품 메트릭 집계 Kafka Consumer. + *

    + * Kafka에서 이벤트를 수취하여 상품 메트릭을 집계합니다. + * 좋아요 수, 판매량, 상세 페이지 조회 수 등을 `product_metrics` 테이블에 upsert합니다. + *

    + *

    + * 처리 이벤트: + *

      + *
    • like-events: LikeAdded, LikeRemoved (좋아요 수 집계)
    • + *
    • order-events: OrderCreated (판매량 집계)
    • + *
    • product-events: ProductViewed (조회 수 집계)
    • + *
    + *

    + *

    + * Manual Ack: + *

      + *
    • 이벤트 처리 성공 후 수동으로 커밋하여 At Most Once 보장
    • + *
    • 에러 발생 시 커밋하지 않아 재처리 가능
    • + *
    + *

    + * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ProductMetricsConsumer { + + private final ProductMetricsService productMetricsService; + private final EventHandledService eventHandledService; + private final ObjectMapper objectMapper; + + private static final String EVENT_ID_HEADER = "eventId"; + private static final String EVENT_TYPE_HEADER = "eventType"; + private static final String VERSION_HEADER = "version"; + + /** + * like-events 토픽을 구독하여 좋아요 수를 집계합니다. + *

    + * 멱등성 처리: + *

      + *
    • Kafka 메시지 헤더에서 `eventId`를 추출
    • + *
    • 이미 처리된 이벤트는 스킵하여 중복 처리 방지
    • + *
    • 처리 후 `event_handled` 테이블에 기록
    • + *
    + *

    + * + * @param records Kafka 메시지 레코드 목록 + * @param acknowledgment 수동 커밋을 위한 Acknowledgment + */ + @KafkaListener( + topics = "like-events", + containerFactory = KafkaConfig.BATCH_LISTENER + ) + public void consumeLikeEvents( + List> records, + Acknowledgment acknowledgment + ) { + try { + for (ConsumerRecord record : records) { + try { + String eventId = extractEventId(record); + if (eventId == null) { + log.warn("eventId가 없는 메시지는 건너뜁니다: offset={}, partition={}", + record.offset(), record.partition()); + continue; + } + + // 멱등성 체크: 이미 처리된 이벤트는 스킵 + if (eventHandledService.isAlreadyHandled(eventId)) { + log.debug("이미 처리된 이벤트 스킵: eventId={}", eventId); + continue; + } + + Object value = record.value(); + String eventType; + + // 버전 추출 (헤더에서) + Long eventVersion = extractVersion(record); + + // Spring Kafka가 자동으로 역직렬화한 경우 + if (value instanceof LikeEvent.LikeAdded) { + LikeEvent.LikeAdded event = (LikeEvent.LikeAdded) value; + productMetricsService.incrementLikeCount( + event.productId(), + eventVersion + ); + eventType = "LikeAdded"; + } else if (value instanceof LikeEvent.LikeRemoved) { + LikeEvent.LikeRemoved event = (LikeEvent.LikeRemoved) value; + productMetricsService.decrementLikeCount( + event.productId(), + eventVersion + ); + eventType = "LikeRemoved"; + } else { + // JSON 문자열인 경우 이벤트 타입 헤더로 구분 + String eventTypeHeader = extractEventType(record); + if ("LikeRemoved".equals(eventTypeHeader)) { + LikeEvent.LikeRemoved event = parseLikeRemovedEvent(value); + productMetricsService.decrementLikeCount( + event.productId(), + eventVersion + ); + eventType = "LikeRemoved"; + } else { + // 기본값은 LikeAdded + LikeEvent.LikeAdded event = parseLikeEvent(value); + productMetricsService.incrementLikeCount( + event.productId(), + eventVersion + ); + eventType = "LikeAdded"; + } + } + + // 이벤트 처리 기록 저장 + eventHandledService.markAsHandled(eventId, eventType, "like-events"); + } catch (org.springframework.dao.DataIntegrityViolationException e) { + // UNIQUE 제약조건 위반 = 동시성 상황에서 이미 처리됨 (정상) + log.debug("동시성 상황에서 이미 처리된 이벤트: offset={}, partition={}", + record.offset(), record.partition()); + } catch (Exception e) { + log.error("좋아요 이벤트 처리 실패: offset={}, partition={}", + record.offset(), record.partition(), e); + // 개별 이벤트 처리 실패는 로그만 기록하고 계속 진행 + } + } + + // 모든 이벤트 처리 완료 후 수동 커밋 + acknowledgment.acknowledge(); + log.debug("좋아요 이벤트 처리 완료: count={}", records.size()); + } catch (Exception e) { + log.error("좋아요 이벤트 배치 처리 실패: count={}", records.size(), e); + // 에러 발생 시 커밋하지 않음 (재처리 가능) + throw e; + } + } + + /** + * order-events 토픽을 구독하여 판매량을 집계합니다. + *

    + * 멱등성 처리: + *

      + *
    • Kafka 메시지 헤더에서 `eventId`를 추출
    • + *
    • 이미 처리된 이벤트는 스킵하여 중복 처리 방지
    • + *
    • 처리 후 `event_handled` 테이블에 기록
    • + *
    + *

    + * + * @param records Kafka 메시지 레코드 목록 + * @param acknowledgment 수동 커밋을 위한 Acknowledgment + */ + @KafkaListener( + topics = "order-events", + containerFactory = KafkaConfig.BATCH_LISTENER + ) + public void consumeOrderEvents( + List> records, + Acknowledgment acknowledgment + ) { + try { + for (ConsumerRecord record : records) { + try { + String eventId = extractEventId(record); + if (eventId == null) { + log.warn("eventId가 없는 메시지는 건너뜁니다: offset={}, partition={}", + record.offset(), record.partition()); + continue; + } + + // 멱등성 체크: 이미 처리된 이벤트는 스킵 + if (eventHandledService.isAlreadyHandled(eventId)) { + log.debug("이미 처리된 이벤트 스킵: eventId={}", eventId); + continue; + } + + Object value = record.value(); + OrderEvent.OrderCreated event = parseOrderCreatedEvent(value); + + // 버전 추출 (헤더에서) + Long eventVersion = extractVersion(record); + + // 주문 아이템별로 판매량 집계 + for (OrderEvent.OrderCreated.OrderItemInfo item : event.orderItems()) { + productMetricsService.incrementSalesCount( + item.productId(), + item.quantity(), + eventVersion + ); + } + + // 이벤트 처리 기록 저장 + eventHandledService.markAsHandled(eventId, "OrderCreated", "order-events"); + } catch (org.springframework.dao.DataIntegrityViolationException e) { + // UNIQUE 제약조건 위반 = 동시성 상황에서 이미 처리됨 (정상) + log.debug("동시성 상황에서 이미 처리된 이벤트: offset={}, partition={}", + record.offset(), record.partition()); + } catch (Exception e) { + log.error("주문 이벤트 처리 실패: offset={}, partition={}", + record.offset(), record.partition(), e); + // 개별 이벤트 처리 실패는 로그만 기록하고 계속 진행 + } + } + + // 모든 이벤트 처리 완료 후 수동 커밋 + acknowledgment.acknowledge(); + log.debug("주문 이벤트 처리 완료: count={}", records.size()); + } catch (Exception e) { + log.error("주문 이벤트 배치 처리 실패: count={}", records.size(), e); + // 에러 발생 시 커밋하지 않음 (재처리 가능) + throw e; + } + } + + /** + * Kafka 메시지 값을 LikeAdded 이벤트로 파싱합니다. + * + * @param value Kafka 메시지 값 + * @return 파싱된 LikeAdded 이벤트 + */ + private LikeEvent.LikeAdded parseLikeEvent(Object value) { + try { + // JSON 문자열인 경우 파싱 + String json = value instanceof String ? (String) value : objectMapper.writeValueAsString(value); + return objectMapper.readValue(json, LikeEvent.LikeAdded.class); + } catch (Exception e) { + throw new RuntimeException("LikeAdded 이벤트 파싱 실패", e); + } + } + + /** + * Kafka 메시지 값을 LikeRemoved 이벤트로 파싱합니다. + * + * @param value Kafka 메시지 값 + * @return 파싱된 LikeRemoved 이벤트 + */ + private LikeEvent.LikeRemoved parseLikeRemovedEvent(Object value) { + try { + // JSON 문자열인 경우 파싱 + String json = value instanceof String ? (String) value : objectMapper.writeValueAsString(value); + return objectMapper.readValue(json, LikeEvent.LikeRemoved.class); + } catch (Exception e) { + throw new RuntimeException("LikeRemoved 이벤트 파싱 실패", e); + } + } + + /** + * product-events 토픽을 구독하여 조회 수를 집계합니다. + *

    + * 멱등성 처리: + *

      + *
    • Kafka 메시지 헤더에서 `eventId`를 추출
    • + *
    • 이미 처리된 이벤트는 스킵하여 중복 처리 방지
    • + *
    • 처리 후 `event_handled` 테이블에 기록
    • + *
    + *

    + * + * @param records Kafka 메시지 레코드 목록 + * @param acknowledgment 수동 커밋을 위한 Acknowledgment + */ + @KafkaListener( + topics = "product-events", + containerFactory = KafkaConfig.BATCH_LISTENER + ) + public void consumeProductEvents( + List> records, + Acknowledgment acknowledgment + ) { + try { + for (ConsumerRecord record : records) { + try { + String eventId = extractEventId(record); + if (eventId == null) { + log.warn("eventId가 없는 메시지는 건너뜁니다: offset={}, partition={}", + record.offset(), record.partition()); + continue; + } + + // 멱등성 체크: 이미 처리된 이벤트는 스킵 + if (eventHandledService.isAlreadyHandled(eventId)) { + log.debug("이미 처리된 이벤트 스킵: eventId={}", eventId); + continue; + } + + Object value = record.value(); + ProductEvent.ProductViewed event = parseProductViewedEvent(value); + + // 버전 추출 (헤더에서) + Long eventVersion = extractVersion(record); + + productMetricsService.incrementViewCount( + event.productId(), + eventVersion + ); + + // 이벤트 처리 기록 저장 + eventHandledService.markAsHandled(eventId, "ProductViewed", "product-events"); + } catch (org.springframework.dao.DataIntegrityViolationException e) { + // UNIQUE 제약조건 위반 = 동시성 상황에서 이미 처리됨 (정상) + log.debug("동시성 상황에서 이미 처리된 이벤트: offset={}, partition={}", + record.offset(), record.partition()); + } catch (Exception e) { + log.error("상품 조회 이벤트 처리 실패: offset={}, partition={}", + record.offset(), record.partition(), e); + // 개별 이벤트 처리 실패는 로그만 기록하고 계속 진행 + } + } + + // 모든 이벤트 처리 완료 후 수동 커밋 + acknowledgment.acknowledge(); + log.debug("상품 조회 이벤트 처리 완료: count={}", records.size()); + } catch (Exception e) { + log.error("상품 조회 이벤트 배치 처리 실패: count={}", records.size(), e); + // 에러 발생 시 커밋하지 않음 (재처리 가능) + throw e; + } + } + + /** + * Kafka 메시지 값을 OrderCreated 이벤트로 파싱합니다. + * + * @param value Kafka 메시지 값 + * @return 파싱된 OrderCreated 이벤트 + */ + private OrderEvent.OrderCreated parseOrderCreatedEvent(Object value) { + try { + if (value instanceof OrderEvent.OrderCreated) { + return (OrderEvent.OrderCreated) value; + } + + // JSON 문자열인 경우 파싱 + String json = value instanceof String ? (String) value : objectMapper.writeValueAsString(value); + return objectMapper.readValue(json, OrderEvent.OrderCreated.class); + } catch (Exception e) { + throw new RuntimeException("OrderCreated 이벤트 파싱 실패", e); + } + } + + /** + * Kafka 메시지 값을 ProductViewed 이벤트로 파싱합니다. + * + * @param value Kafka 메시지 값 + * @return 파싱된 ProductViewed 이벤트 + */ + private ProductEvent.ProductViewed parseProductViewedEvent(Object value) { + try { + if (value instanceof ProductEvent.ProductViewed) { + return (ProductEvent.ProductViewed) value; + } + + // JSON 문자열인 경우 파싱 + String json = value instanceof String ? (String) value : objectMapper.writeValueAsString(value); + return objectMapper.readValue(json, ProductEvent.ProductViewed.class); + } catch (Exception e) { + throw new RuntimeException("ProductViewed 이벤트 파싱 실패", e); + } + } + + /** + * Kafka 메시지 헤더에서 eventId를 추출합니다. + * + * @param record Kafka 메시지 레코드 + * @return eventId (없으면 null) + */ + private String extractEventId(ConsumerRecord record) { + Header header = record.headers().lastHeader(EVENT_ID_HEADER); + if (header != null && header.value() != null) { + return new String(header.value(), StandardCharsets.UTF_8); + } + return null; + } + + /** + * Kafka 메시지 헤더에서 eventType을 추출합니다. + * + * @param record Kafka 메시지 레코드 + * @return eventType (없으면 null) + */ + private String extractEventType(ConsumerRecord record) { + Header header = record.headers().lastHeader(EVENT_TYPE_HEADER); + if (header != null && header.value() != null) { + return new String(header.value(), StandardCharsets.UTF_8); + } + return null; + } + + /** + * Kafka 메시지 헤더에서 version을 추출합니다. + * + * @param record Kafka 메시지 레코드 + * @return version (없으면 null) + */ + private Long extractVersion(ConsumerRecord record) { + Header header = record.headers().lastHeader(VERSION_HEADER); + if (header != null && header.value() != null) { + try { + String versionStr = new String(header.value(), StandardCharsets.UTF_8); + return Long.parseLong(versionStr); + } catch (NumberFormatException e) { + log.warn("버전 헤더 파싱 실패: offset={}, partition={}", + record.offset(), record.partition()); + return null; + } + } + return null; + } +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/application/eventhandled/EventHandledServiceTest.java b/apps/commerce-streamer/src/test/java/com/loopers/application/eventhandled/EventHandledServiceTest.java new file mode 100644 index 000000000..77d7efcd9 --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/application/eventhandled/EventHandledServiceTest.java @@ -0,0 +1,96 @@ +package com.loopers.application.eventhandled; + +import com.loopers.domain.eventhandled.EventHandled; +import com.loopers.domain.eventhandled.EventHandledRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; + +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.Mockito.*; + +/** + * EventHandledService 테스트. + */ +@ExtendWith(MockitoExtension.class) +class EventHandledServiceTest { + + @Mock + private EventHandledRepository eventHandledRepository; + + @InjectMocks + private EventHandledService eventHandledService; + + @DisplayName("처리되지 않은 이벤트는 false를 반환한다.") + @Test + void isAlreadyHandled_returnsFalse_whenNotHandled() { + // arrange + String eventId = "test-event-id"; + when(eventHandledRepository.existsByEventId(eventId)).thenReturn(false); + + // act + boolean result = eventHandledService.isAlreadyHandled(eventId); + + // assert + assertThat(result).isFalse(); + verify(eventHandledRepository).existsByEventId(eventId); + } + + @DisplayName("이미 처리된 이벤트는 true를 반환한다.") + @Test + void isAlreadyHandled_returnsTrue_whenAlreadyHandled() { + // arrange + String eventId = "test-event-id"; + when(eventHandledRepository.existsByEventId(eventId)).thenReturn(true); + + // act + boolean result = eventHandledService.isAlreadyHandled(eventId); + + // assert + assertThat(result).isTrue(); + verify(eventHandledRepository).existsByEventId(eventId); + } + + @DisplayName("처리되지 않은 이벤트는 정상적으로 저장된다.") + @Test + void markAsHandled_savesSuccessfully_whenNotHandled() { + // arrange + String eventId = "test-event-id"; + String eventType = "LikeAdded"; + String topic = "like-events"; + + EventHandled savedEventHandled = new EventHandled(eventId, eventType, topic); + when(eventHandledRepository.save(any(EventHandled.class))).thenReturn(savedEventHandled); + + // act + eventHandledService.markAsHandled(eventId, eventType, topic); + + // assert + verify(eventHandledRepository).save(any(EventHandled.class)); + } + + @DisplayName("이미 처리된 이벤트는 DataIntegrityViolationException을 발생시킨다.") + @Test + void markAsHandled_throwsException_whenAlreadyHandled() { + // arrange + String eventId = "test-event-id"; + String eventType = "LikeAdded"; + String topic = "like-events"; + + when(eventHandledRepository.save(any(EventHandled.class))) + .thenThrow(new DataIntegrityViolationException("UNIQUE constraint violation")); + + // act & assert + assertThatThrownBy(() -> + eventHandledService.markAsHandled(eventId, eventType, topic) + ).isInstanceOf(DataIntegrityViolationException.class); + + verify(eventHandledRepository).save(any(EventHandled.class)); + } +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/application/metrics/ProductMetricsServiceTest.java b/apps/commerce-streamer/src/test/java/com/loopers/application/metrics/ProductMetricsServiceTest.java new file mode 100644 index 000000000..e8064e333 --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/application/metrics/ProductMetricsServiceTest.java @@ -0,0 +1,217 @@ +package com.loopers.application.metrics; + +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.metrics.ProductMetricsRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * ProductMetricsService 테스트. + */ +@ExtendWith(MockitoExtension.class) +class ProductMetricsServiceTest { + + @Mock + private ProductMetricsRepository productMetricsRepository; + + @InjectMocks + private ProductMetricsService productMetricsService; + + @DisplayName("좋아요 수를 증가시킬 수 있다.") + @Test + void canIncrementLikeCount() { + // arrange + Long productId = 1L; + ProductMetrics existingMetrics = new ProductMetrics(productId); + existingMetrics.incrementLikeCount(); // 초기값: 1 + + when(productMetricsRepository.findByProductIdForUpdate(productId)) + .thenReturn(Optional.of(existingMetrics)); + when(productMetricsRepository.save(any(ProductMetrics.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + productMetricsService.incrementLikeCount(productId, existingMetrics.getVersion() + 1L); + + // assert + assertThat(existingMetrics.getLikeCount()).isEqualTo(2L); + verify(productMetricsRepository).findByProductIdForUpdate(productId); + verify(productMetricsRepository).save(existingMetrics); + } + + @DisplayName("좋아요 수를 감소시킬 수 있다.") + @Test + void canDecrementLikeCount() { + // arrange + Long productId = 1L; + ProductMetrics existingMetrics = new ProductMetrics(productId); + existingMetrics.incrementLikeCount(); // 초기값: 1 + + when(productMetricsRepository.findByProductIdForUpdate(productId)) + .thenReturn(Optional.of(existingMetrics)); + when(productMetricsRepository.save(any(ProductMetrics.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + productMetricsService.decrementLikeCount(productId, existingMetrics.getVersion() + 1L); + + // assert + assertThat(existingMetrics.getLikeCount()).isEqualTo(0L); + verify(productMetricsRepository).findByProductIdForUpdate(productId); + verify(productMetricsRepository).save(existingMetrics); + } + + @DisplayName("판매량을 증가시킬 수 있다.") + @Test + void canIncrementSalesCount() { + // arrange + Long productId = 1L; + Integer quantity = 5; + ProductMetrics existingMetrics = new ProductMetrics(productId); + + when(productMetricsRepository.findByProductIdForUpdate(productId)) + .thenReturn(Optional.of(existingMetrics)); + when(productMetricsRepository.save(any(ProductMetrics.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + productMetricsService.incrementSalesCount(productId, quantity, existingMetrics.getVersion() + 1L); + + // assert + assertThat(existingMetrics.getSalesCount()).isEqualTo(5L); + verify(productMetricsRepository).findByProductIdForUpdate(productId); + verify(productMetricsRepository).save(existingMetrics); + } + + @DisplayName("조회 수를 증가시킬 수 있다.") + @Test + void canIncrementViewCount() { + // arrange + Long productId = 1L; + ProductMetrics existingMetrics = new ProductMetrics(productId); + + when(productMetricsRepository.findByProductIdForUpdate(productId)) + .thenReturn(Optional.of(existingMetrics)); + when(productMetricsRepository.save(any(ProductMetrics.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + productMetricsService.incrementViewCount(productId, existingMetrics.getVersion() + 1L); + + // assert + assertThat(existingMetrics.getViewCount()).isEqualTo(1L); + verify(productMetricsRepository).findByProductIdForUpdate(productId); + verify(productMetricsRepository).save(existingMetrics); + } + + @DisplayName("메트릭이 없으면 새로 생성한다.") + @Test + void createsNewMetrics_whenNotExists() { + // arrange + Long productId = 1L; + Long eventVersion = 1L; // 새로 생성된 메트릭의 버전(0)보다 큰 버전 + + when(productMetricsRepository.findByProductIdForUpdate(productId)) + .thenReturn(Optional.empty()); + when(productMetricsRepository.save(any(ProductMetrics.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + productMetricsService.incrementLikeCount(productId, eventVersion); + + // assert + verify(productMetricsRepository).findByProductIdForUpdate(productId); + // findOrCreate에서 1번, incrementLikeCount에서 1번 총 2번 호출됨 + verify(productMetricsRepository, atLeast(1)).save(any(ProductMetrics.class)); + } + + @DisplayName("판매량 증가 시 null이나 0 이하의 수량은 무시된다.") + @Test + void ignoresInvalidQuantity_whenIncrementingSalesCount() { + // arrange + Long productId = 1L; + ProductMetrics existingMetrics = new ProductMetrics(productId); + Long initialSalesCount = existingMetrics.getSalesCount(); + Long initialVersion = existingMetrics.getVersion(); + + when(productMetricsRepository.findByProductIdForUpdate(productId)) + .thenReturn(Optional.of(existingMetrics)); + when(productMetricsRepository.save(any(ProductMetrics.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + productMetricsService.incrementSalesCount(productId, null, existingMetrics.getVersion() + 1L); + productMetricsService.incrementSalesCount(productId, 0, existingMetrics.getVersion() + 1L); + productMetricsService.incrementSalesCount(productId, -1, existingMetrics.getVersion() + 1L); + + // assert + // 유효하지 않은 수량은 무시되므로 값이 변경되지 않음 + assertThat(existingMetrics.getSalesCount()).isEqualTo(initialSalesCount); + assertThat(existingMetrics.getVersion()).isEqualTo(initialVersion); + // save()는 호출되지만 메트릭 값은 변경되지 않음 + verify(productMetricsRepository, times(3)).findByProductIdForUpdate(productId); + verify(productMetricsRepository, times(3)).save(existingMetrics); + } + + @DisplayName("오래된 이벤트는 스킵하여 메트릭을 업데이트하지 않는다.") + @Test + void skipsOldEvent_whenEventIsOlderThanMetrics() { + // arrange + Long productId = 1L; + ProductMetrics existingMetrics = new ProductMetrics(productId); + existingMetrics.incrementLikeCount(); // 초기값: 1, version = 1 + + Long oldEventVersion = existingMetrics.getVersion() - 1L; // 이전 버전 이벤트 + + Long initialLikeCount = existingMetrics.getLikeCount(); + Long initialVersion = existingMetrics.getVersion(); + + when(productMetricsRepository.findByProductIdForUpdate(productId)) + .thenReturn(Optional.of(existingMetrics)); + + // act + productMetricsService.incrementLikeCount(productId, oldEventVersion); + + // assert + // 오래된 이벤트는 스킵되므로 값이 변경되지 않음 + assertThat(existingMetrics.getLikeCount()).isEqualTo(initialLikeCount); + assertThat(existingMetrics.getVersion()).isEqualTo(initialVersion); + verify(productMetricsRepository).findByProductIdForUpdate(productId); + verify(productMetricsRepository, never()).save(any(ProductMetrics.class)); + } + + @DisplayName("최신 이벤트는 메트릭을 업데이트한다.") + @Test + void updatesMetrics_whenEventIsNewerThanMetrics() { + // arrange + Long productId = 1L; + ProductMetrics existingMetrics = new ProductMetrics(productId); + existingMetrics.incrementLikeCount(); // 초기값: 1, version = 1 + + Long newEventVersion = existingMetrics.getVersion() + 1L; // 최신 버전 이벤트 + + when(productMetricsRepository.findByProductIdForUpdate(productId)) + .thenReturn(Optional.of(existingMetrics)); + when(productMetricsRepository.save(any(ProductMetrics.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + productMetricsService.incrementLikeCount(productId, newEventVersion); + + // assert + // 최신 이벤트는 반영됨 + assertThat(existingMetrics.getLikeCount()).isEqualTo(2L); + verify(productMetricsRepository).findByProductIdForUpdate(productId); + verify(productMetricsRepository).save(existingMetrics); + } +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java b/apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java new file mode 100644 index 000000000..6fab02bfb --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java @@ -0,0 +1,155 @@ +package com.loopers.domain.metrics; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +public class ProductMetricsTest { + + @DisplayName("ProductMetrics는 상품 ID로 생성되며 초기값이 0으로 설정된다.") + @Test + void createsProductMetricsWithInitialValues() { + // arrange + Long productId = 1L; + + // act + ProductMetrics metrics = new ProductMetrics(productId); + + // assert + assertThat(metrics.getProductId()).isEqualTo(productId); + assertThat(metrics.getLikeCount()).isEqualTo(0L); + assertThat(metrics.getSalesCount()).isEqualTo(0L); + assertThat(metrics.getViewCount()).isEqualTo(0L); + assertThat(metrics.getVersion()).isEqualTo(0L); + assertThat(metrics.getUpdatedAt()).isNotNull(); + } + + @DisplayName("좋아요 수를 증가시킬 수 있다.") + @Test + void canIncrementLikeCount() throws InterruptedException { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + Long initialLikeCount = metrics.getLikeCount(); + Long initialVersion = metrics.getVersion(); + LocalDateTime initialUpdatedAt = metrics.getUpdatedAt(); + + // act + Thread.sleep(1); // 시간 차이를 보장하기 위한 작은 지연 + metrics.incrementLikeCount(); + + // assert + assertThat(metrics.getLikeCount()).isEqualTo(initialLikeCount + 1); + assertThat(metrics.getVersion()).isEqualTo(initialVersion + 1); + assertThat(metrics.getUpdatedAt()).isAfter(initialUpdatedAt); + } + + @DisplayName("좋아요 수를 감소시킬 수 있다.") + @Test + void canDecrementLikeCount() { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + metrics.incrementLikeCount(); // 먼저 증가시킴 + Long initialLikeCount = metrics.getLikeCount(); + Long initialVersion = metrics.getVersion(); + + // act + metrics.decrementLikeCount(); + + // assert + assertThat(metrics.getLikeCount()).isEqualTo(initialLikeCount - 1); + assertThat(metrics.getVersion()).isEqualTo(initialVersion + 1); + } + + @DisplayName("좋아요 수가 0일 때 감소해도 음수가 되지 않는다 (멱등성 보장).") + @Test + void preventsNegativeLikeCount_whenDecrementingFromZero() { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + assertThat(metrics.getLikeCount()).isEqualTo(0L); + Long initialVersion = metrics.getVersion(); + + // act + metrics.decrementLikeCount(); + + // assert + assertThat(metrics.getLikeCount()).isEqualTo(0L); + assertThat(metrics.getVersion()).isEqualTo(initialVersion); // version도 변경되지 않음 + } + + @DisplayName("판매량을 증가시킬 수 있다.") + @Test + void canIncrementSalesCount() { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + Long initialSalesCount = metrics.getSalesCount(); + Long initialVersion = metrics.getVersion(); + Integer quantity = 5; + + // act + metrics.incrementSalesCount(quantity); + + // assert + assertThat(metrics.getSalesCount()).isEqualTo(initialSalesCount + quantity); + assertThat(metrics.getVersion()).isEqualTo(initialVersion + 1); + } + + @DisplayName("판매량 증가 시 null이나 0 이하의 수량은 무시된다.") + @Test + void ignoresInvalidQuantity_whenIncrementingSalesCount() { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + Long initialSalesCount = metrics.getSalesCount(); + Long initialVersion = metrics.getVersion(); + + // act + metrics.incrementSalesCount(null); + metrics.incrementSalesCount(0); + metrics.incrementSalesCount(-1); + + // assert + assertThat(metrics.getSalesCount()).isEqualTo(initialSalesCount); + assertThat(metrics.getVersion()).isEqualTo(initialVersion); // version도 변경되지 않음 + } + + @DisplayName("상세 페이지 조회 수를 증가시킬 수 있다.") + @Test + void canIncrementViewCount() throws InterruptedException { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + Long initialViewCount = metrics.getViewCount(); + Long initialVersion = metrics.getVersion(); + LocalDateTime initialUpdatedAt = metrics.getUpdatedAt(); + + // act + Thread.sleep(1); // 시간 차이를 보장하기 위한 작은 지연 + metrics.incrementViewCount(); + + // assert + assertThat(metrics.getViewCount()).isEqualTo(initialViewCount + 1); + assertThat(metrics.getVersion()).isEqualTo(initialVersion + 1); + assertThat(metrics.getUpdatedAt()).isAfter(initialUpdatedAt); + } + + @DisplayName("여러 메트릭을 연속으로 업데이트할 수 있다.") + @Test + void canUpdateMultipleMetrics() { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + + // act + metrics.incrementLikeCount(); + metrics.incrementLikeCount(); + metrics.incrementSalesCount(10); + metrics.incrementViewCount(); + metrics.decrementLikeCount(); + + // assert + assertThat(metrics.getLikeCount()).isEqualTo(1L); + assertThat(metrics.getSalesCount()).isEqualTo(10L); + assertThat(metrics.getViewCount()).isEqualTo(1L); + assertThat(metrics.getVersion()).isEqualTo(5L); // 5번 업데이트됨 + } +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerIntegrationTest.java b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerIntegrationTest.java new file mode 100644 index 000000000..c43519195 --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerIntegrationTest.java @@ -0,0 +1,116 @@ +package com.loopers.interfaces.consumer; + +import com.loopers.domain.event.LikeEvent; +import com.loopers.testcontainers.KafkaTestContainersConfig; +import com.loopers.utils.KafkaCleanUp; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.consumer.KafkaConsumer; +import org.apache.kafka.common.TopicPartition; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.kafka.KafkaProperties; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * ProductMetricsConsumer 통합 테스트. + *

    + * 실제 Kafka를 사용하여 이벤트 처리 동작을 검증합니다. + *

    + *

    + * Kafka 컨테이너: + * {@link KafkaTestContainersConfig}가 테스트 실행 시 자동으로 Kafka 컨테이너를 시작합니다. + *

    + */ +@SpringBootTest +@ActiveProfiles("test") +@Import(KafkaTestContainersConfig.class) +class ProductMetricsConsumerIntegrationTest { + + @Autowired + private KafkaCleanUp kafkaCleanUp; + + @Autowired + private KafkaTemplate kafkaTemplate; + + @Autowired + private KafkaProperties kafkaProperties; + + @BeforeEach + void setUp() { + // 테스트 전에 토픽의 모든 메시지 삭제 및 재생성 + kafkaCleanUp.resetAllTestTopics(); + } + + /** + * offset.reset: latest 설정이 제대로 적용되는지 확인하는 테스트. + *

    + * 테스트 목적: + * kafka.yml에 설정된 `offset.reset: latest`가 실제로 동작하는지 검증합니다. + *

    + *

    + * 동작 원리: + * 1. 이전 메시지를 Kafka에 발행 (이 메시지는 나중에 읽히지 않아야 함) + * 2. Consumer Group을 삭제하여 offset 정보 제거 + * 3. 새로운 메시지를 Kafka에 발행 + * 4. 새로운 Consumer Group으로 Consumer를 시작 + * 5. offset.reset: latest 설정으로 인해 Consumer는 최신 메시지(새로운 메시지)부터 읽기 시작해야 함 + *

    + *

    + * 검증 내용: + * - Consumer의 현재 position이 최신 offset(endOffset)과 같거나 가까운지 확인 + * - 이는 Consumer가 이전 메시지를 건너뛰고 최신 메시지부터 읽기 시작했다는 의미 + *

    + */ + @DisplayName("offset.reset: latest 설정이 적용되어 새로운 Consumer Group은 최신 메시지만 읽는다.") + @Test + void offsetResetLatest_shouldOnlyReadLatestMessages() throws Exception { + // 이 메시지는 나중에 Consumer가 읽지 않아야 함 (offset.reset: latest 때문) + String topic = "like-events"; + String partitionKey = "product-1"; + LikeEvent.LikeAdded oldMessage = new LikeEvent.LikeAdded(100L, 1L, LocalDateTime.now()); + kafkaTemplate.send(topic, partitionKey, oldMessage).get(); + + // Consumer Group을 삭제하면 offset 정보가 사라짐 + // 다음에 같은 Consumer Group으로 시작할 때 offset.reset 설정이 적용됨 + String testGroupId = "test-offset-reset-" + System.currentTimeMillis(); + kafkaCleanUp.resetConsumerGroup(testGroupId); + + // 이 메시지는 Consumer가 읽어야 함 (최신 메시지이므로) + LikeEvent.LikeAdded newMessage = new LikeEvent.LikeAdded(200L, 1L, LocalDateTime.now()); + kafkaTemplate.send(topic, partitionKey, newMessage).get(); + + // 프로젝트의 kafka.yml 설정을 사용하여 Consumer 생성 + // 이 설정에는 offset.reset: latest가 포함되어 있음 + Map consumerProps = kafkaProperties.buildConsumerProperties(); + consumerProps.put(ConsumerConfig.GROUP_ID_CONFIG, testGroupId); + + try (KafkaConsumer consumer = new KafkaConsumer<>(consumerProps)) { + // 특정 파티션에 할당 (테스트용) + TopicPartition partition = new TopicPartition(topic, 0); + consumer.assign(Collections.singletonList(partition)); + + // endOffset: 토픽의 마지막 메시지 다음 offset (현재는 2개 메시지가 있으므로 2) + // currentPosition: Consumer가 현재 읽을 위치 (offset.reset: latest면 endOffset과 같아야 함) + Long endOffset = consumer.endOffsets(Collections.singletonList(partition)).get(partition); + long currentPosition = consumer.position(partition); + + // offset.reset: latest 설정이 적용되었다면: + // - currentPosition은 endOffset과 같거나 가까워야 함 + // - 이는 Consumer가 이전 메시지(oldMessage)를 건너뛰고 최신 메시지(newMessage)부터 읽기 시작했다는 의미 + // 예: endOffset=2, currentPosition=2 → 이전 메시지(offset 0)를 건너뛰고 최신 메시지(offset 1)부터 시작 + assertThat(currentPosition) + .isGreaterThanOrEqualTo(endOffset); + } + } +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java new file mode 100644 index 000000000..bf5306797 --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/ProductMetricsConsumerTest.java @@ -0,0 +1,393 @@ +package com.loopers.interfaces.consumer; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.eventhandled.EventHandledService; +import com.loopers.application.metrics.ProductMetricsService; +import com.loopers.domain.event.LikeEvent; +import com.loopers.domain.event.OrderEvent; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.common.header.Headers; +import org.apache.kafka.common.header.internals.RecordHeader; +import org.apache.kafka.common.header.internals.RecordHeaders; +import org.apache.kafka.common.record.TimestampType; + +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.kafka.support.Acknowledgment; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * ProductMetricsConsumer 테스트. + */ +@ExtendWith(MockitoExtension.class) +class ProductMetricsConsumerTest { + + @Mock + private ProductMetricsService productMetricsService; + + @Mock + private EventHandledService eventHandledService; + + @Mock + private ObjectMapper objectMapper; + + @Mock + private Acknowledgment acknowledgment; + + @InjectMocks + private ProductMetricsConsumer productMetricsConsumer; + + @DisplayName("LikeAdded 이벤트를 처리할 수 있다.") + @Test + void canConsumeLikeAddedEvent() { + // arrange + String eventId = "test-event-id"; + Long productId = 1L; + Long userId = 100L; + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); + + Headers headers = new RecordHeaders(); + headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); + headers.add(new RecordHeader("version", "1".getBytes(StandardCharsets.UTF_8))); + + ConsumerRecord record = new ConsumerRecord<>( + "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty() + ); + List> records = List.of(record); + + when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false); + + // act + productMetricsConsumer.consumeLikeEvents(records, acknowledgment); + + // assert + verify(eventHandledService).isAlreadyHandled(eventId); + verify(productMetricsService).incrementLikeCount(eq(productId), eq(1L)); + verify(eventHandledService).markAsHandled(eventId, "LikeAdded", "like-events"); + verify(acknowledgment).acknowledge(); + } + + @DisplayName("LikeRemoved 이벤트를 처리할 수 있다.") + @Test + void canConsumeLikeRemovedEvent() { + // arrange + String eventId = "test-event-id-2"; + Long productId = 1L; + Long userId = 100L; + LikeEvent.LikeRemoved event = new LikeEvent.LikeRemoved(userId, productId, LocalDateTime.now()); + + Headers headers = new RecordHeaders(); + headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); + headers.add(new RecordHeader("version", "2".getBytes(StandardCharsets.UTF_8))); + + ConsumerRecord record = new ConsumerRecord<>( + "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty() + ); + List> records = List.of(record); + + when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false); + + // act + productMetricsConsumer.consumeLikeEvents(records, acknowledgment); + + // assert + verify(eventHandledService).isAlreadyHandled(eventId); + verify(productMetricsService).decrementLikeCount(eq(productId), eq(2L)); + verify(eventHandledService).markAsHandled(eventId, "LikeRemoved", "like-events"); + verify(acknowledgment).acknowledge(); + } + + @DisplayName("OrderCreated 이벤트를 처리할 수 있다.") + @Test + void canConsumeOrderCreatedEvent() { + // arrange + String eventId = "test-event-id-3"; + Long orderId = 1L; + Long userId = 100L; + Long productId1 = 1L; + Long productId2 = 2L; + + List orderItems = List.of( + new OrderEvent.OrderCreated.OrderItemInfo(productId1, 3), + new OrderEvent.OrderCreated.OrderItemInfo(productId2, 2) + ); + + OrderEvent.OrderCreated event = new OrderEvent.OrderCreated( + orderId, userId, null, 10000, 0L, orderItems, LocalDateTime.now() + ); + + Headers headers = new RecordHeaders(); + headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); + headers.add(new RecordHeader("version", "3".getBytes(StandardCharsets.UTF_8))); + + ConsumerRecord record = new ConsumerRecord<>( + "order-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty() + ); + List> records = List.of(record); + + when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false); + + // act + productMetricsConsumer.consumeOrderEvents(records, acknowledgment); + + // assert + verify(eventHandledService).isAlreadyHandled(eventId); + verify(productMetricsService).incrementSalesCount(eq(productId1), eq(3), eq(3L)); + verify(productMetricsService).incrementSalesCount(eq(productId2), eq(2), eq(3L)); + verify(eventHandledService).markAsHandled(eventId, "OrderCreated", "order-events"); + verify(acknowledgment).acknowledge(); + } + + @DisplayName("배치로 여러 이벤트를 처리할 수 있다.") + @Test + void canConsumeMultipleEvents() { + // arrange + String eventId1 = "test-event-id-4"; + String eventId2 = "test-event-id-5"; + Long productId = 1L; + Long userId = 100L; + + LikeEvent.LikeAdded event1 = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); + LikeEvent.LikeRemoved event2 = new LikeEvent.LikeRemoved(userId, productId, LocalDateTime.now()); + + Headers headers1 = new RecordHeaders(); + headers1.add(new RecordHeader("eventId", eventId1.getBytes(StandardCharsets.UTF_8))); + headers1.add(new RecordHeader("version", "4".getBytes(StandardCharsets.UTF_8))); + Headers headers2 = new RecordHeaders(); + headers2.add(new RecordHeader("eventId", eventId2.getBytes(StandardCharsets.UTF_8))); + headers2.add(new RecordHeader("version", "5".getBytes(StandardCharsets.UTF_8))); + + List> records = List.of( + new ConsumerRecord<>("like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event1, headers1, Optional.empty()), + new ConsumerRecord<>("like-events", 0, 1L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event2, headers2, Optional.empty()) + ); + + when(eventHandledService.isAlreadyHandled(eventId1)).thenReturn(false); + when(eventHandledService.isAlreadyHandled(eventId2)).thenReturn(false); + + // act + productMetricsConsumer.consumeLikeEvents(records, acknowledgment); + + // assert + verify(eventHandledService).isAlreadyHandled(eventId1); + verify(eventHandledService).isAlreadyHandled(eventId2); + verify(productMetricsService).incrementLikeCount(eq(productId), eq(4L)); + verify(productMetricsService).decrementLikeCount(eq(productId), eq(5L)); + verify(eventHandledService).markAsHandled(eventId1, "LikeAdded", "like-events"); + verify(eventHandledService).markAsHandled(eventId2, "LikeRemoved", "like-events"); + verify(acknowledgment, times(1)).acknowledge(); + } + + @DisplayName("개별 이벤트 처리 실패 시에도 배치 처리를 계속한다.") + @Test + void continuesProcessing_whenIndividualEventFails() { + // arrange + String eventId1 = "test-event-id-6"; + String eventId2 = "test-event-id-7"; + Long productId = 1L; + Long userId = 100L; + + LikeEvent.LikeAdded validEvent = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); + Object invalidEvent = "invalid-event"; + + Headers headers1 = new RecordHeaders(); + headers1.add(new RecordHeader("eventId", eventId1.getBytes(StandardCharsets.UTF_8))); + headers1.add(new RecordHeader("version", "6".getBytes(StandardCharsets.UTF_8))); + Headers headers2 = new RecordHeaders(); + headers2.add(new RecordHeader("eventId", eventId2.getBytes(StandardCharsets.UTF_8))); + headers2.add(new RecordHeader("version", "7".getBytes(StandardCharsets.UTF_8))); + + when(eventHandledService.isAlreadyHandled(eventId1)).thenReturn(false); + when(eventHandledService.isAlreadyHandled(eventId2)).thenReturn(false); + doThrow(new RuntimeException("처리 실패")) + .when(productMetricsService).incrementLikeCount(any(), anyLong()); + + List> records = List.of( + new ConsumerRecord<>("like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", invalidEvent, headers1, Optional.empty()), + new ConsumerRecord<>("like-events", 0, 1L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", validEvent, headers2, Optional.empty()) + ); + + // act + productMetricsConsumer.consumeLikeEvents(records, acknowledgment); + + // assert + verify(eventHandledService).isAlreadyHandled(eventId1); + verify(eventHandledService).isAlreadyHandled(eventId2); + verify(productMetricsService, atLeastOnce()).incrementLikeCount(any(), anyLong()); + verify(acknowledgment).acknowledge(); + } + + @DisplayName("개별 이벤트 처리 실패 시에도 acknowledgment를 수행한다.") + @Test + void acknowledgesEvenWhenIndividualEventFails() { + // arrange + String eventId = "test-event-id-8"; + Long productId = 1L; + Long userId = 100L; + + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); + + Headers headers = new RecordHeaders(); + headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); + headers.add(new RecordHeader("version", "8".getBytes(StandardCharsets.UTF_8))); + + // 서비스 호출 시 예외 발생 + when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false); + doThrow(new RuntimeException("서비스 처리 실패")) + .when(productMetricsService).incrementLikeCount(eq(productId), anyLong()); + + List> records = List.of( + new ConsumerRecord<>("like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty()) + ); + + // act + productMetricsConsumer.consumeLikeEvents(records, acknowledgment); + + // assert + // 개별 이벤트 실패는 내부 catch 블록에서 처리되고 계속 진행되므로 acknowledgment는 호출됨 + verify(eventHandledService).isAlreadyHandled(eventId); + verify(productMetricsService).incrementLikeCount(eq(productId), anyLong()); + // 예외 발생 시 markAsHandled는 호출되지 않음 + verify(eventHandledService, never()).markAsHandled(any(), any(), any()); + verify(acknowledgment).acknowledge(); + } + + @DisplayName("이미 처리된 이벤트는 스킵한다.") + @Test + void skipsAlreadyHandledEvent() { + // arrange + String eventId = "test-event-id"; + Long productId = 1L; + Long userId = 100L; + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); + + Headers headers = new RecordHeaders(); + headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); + + ConsumerRecord record = new ConsumerRecord<>( + "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty() + ); + List> records = List.of(record); + + when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(true); + + // act + productMetricsConsumer.consumeLikeEvents(records, acknowledgment); + + // assert + verify(eventHandledService).isAlreadyHandled(eventId); + verify(productMetricsService, never()).incrementLikeCount(any(), anyLong()); + verify(eventHandledService, never()).markAsHandled(any(), any(), any()); + verify(acknowledgment).acknowledge(); + } + + @DisplayName("eventId가 없는 메시지는 건너뛴다.") + @Test + void skipsEventWithoutEventId() { + // arrange + Long productId = 1L; + Long userId = 100L; + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); + + ConsumerRecord record = new ConsumerRecord<>( + "like-events", 0, 0L, "key", event + ); + List> records = List.of(record); + + // act + productMetricsConsumer.consumeLikeEvents(records, acknowledgment); + + // assert + verify(eventHandledService, never()).isAlreadyHandled(any()); + verify(productMetricsService, never()).incrementLikeCount(any(), anyLong()); + verify(acknowledgment).acknowledge(); + } + + @DisplayName("동시성 상황에서 DataIntegrityViolationException이 발생하면 정상 처리로 간주한다.") + @Test + void handlesDataIntegrityViolationException() { + // arrange + String eventId = "test-event-id"; + Long productId = 1L; + Long userId = 100L; + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); + + Headers headers = new RecordHeaders(); + headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); + headers.add(new RecordHeader("version", "9".getBytes(StandardCharsets.UTF_8))); + + ConsumerRecord record = new ConsumerRecord<>( + "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty() + ); + List> records = List.of(record); + + when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false); + doThrow(new DataIntegrityViolationException("UNIQUE constraint violation")) + .when(eventHandledService).markAsHandled(eventId, "LikeAdded", "like-events"); + + // act + productMetricsConsumer.consumeLikeEvents(records, acknowledgment); + + // assert + verify(eventHandledService).isAlreadyHandled(eventId); + verify(productMetricsService).incrementLikeCount(eq(productId), anyLong()); + verify(eventHandledService).markAsHandled(eventId, "LikeAdded", "like-events"); + verify(acknowledgment).acknowledge(); + } + + @DisplayName("중복 메시지 재전송 시 한 번만 처리되어 멱등성이 보장된다.") + @Test + void handlesDuplicateMessagesIdempotently() { + // arrange + String eventId = "duplicate-event-id"; + Long productId = 1L; + Long userId = 100L; + Long eventVersion = 1L; + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); + + Headers headers = new RecordHeaders(); + headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); + headers.add(new RecordHeader("version", String.valueOf(eventVersion).getBytes(StandardCharsets.UTF_8))); + + // 동일한 eventId를 가진 메시지 3개 생성 + List> records = List.of( + new ConsumerRecord<>("like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty()), + new ConsumerRecord<>("like-events", 0, 1L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty()), + new ConsumerRecord<>("like-events", 0, 2L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty()) + ); + + // 첫 번째 메시지는 처리되지 않았으므로 false, 나머지는 이미 처리되었으므로 true + when(eventHandledService.isAlreadyHandled(eventId)) + .thenReturn(false) // 첫 번째: 처리됨 + .thenReturn(true) // 두 번째: 이미 처리됨 (스킵) + .thenReturn(true); // 세 번째: 이미 처리됨 (스킵) + + // act + productMetricsConsumer.consumeLikeEvents(records, acknowledgment); + + // assert + // isAlreadyHandled는 3번 호출됨 (각 메시지마다) + verify(eventHandledService, times(3)).isAlreadyHandled(eventId); + + // incrementLikeCount는 한 번만 호출되어야 함 (첫 번째 메시지만 처리) + verify(productMetricsService, times(1)).incrementLikeCount(eq(productId), eq(eventVersion)); + + // markAsHandled는 한 번만 호출되어야 함 (첫 번째 메시지만 처리) + verify(eventHandledService, times(1)).markAsHandled(eventId, "LikeAdded", "like-events"); + + // acknowledgment는 한 번만 호출되어야 함 (배치 처리 완료) + verify(acknowledgment, times(1)).acknowledge(); + } +} diff --git a/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java b/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java index a73842775..33222efb1 100644 --- a/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java +++ b/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java @@ -1,6 +1,7 @@ package com.loopers.confg.kafka; import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.kafka.clients.admin.NewTopic; import org.apache.kafka.clients.consumer.ConsumerConfig; import org.springframework.boot.autoconfigure.kafka.KafkaProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -8,6 +9,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.kafka.annotation.EnableKafka; import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; +import org.springframework.kafka.config.TopicBuilder; import org.springframework.kafka.core.*; import org.springframework.kafka.listener.ContainerProperties; import org.springframework.kafka.support.converter.BatchMessagingMessageConverter; @@ -30,19 +32,19 @@ public class KafkaConfig { public static final int MAX_POLL_INTERVAL_MS = 2 * 60 * 1000; // max poll interval = 2m @Bean - public ProducerFactory producerFactory(KafkaProperties kafkaProperties) { + public ProducerFactory producerFactory(KafkaProperties kafkaProperties) { Map props = new HashMap<>(kafkaProperties.buildProducerProperties()); return new DefaultKafkaProducerFactory<>(props); } @Bean - public ConsumerFactory consumerFactory(KafkaProperties kafkaProperties) { + public ConsumerFactory consumerFactory(KafkaProperties kafkaProperties) { Map props = new HashMap<>(kafkaProperties.buildConsumerProperties()); return new DefaultKafkaConsumerFactory<>(props); } @Bean - public KafkaTemplate kafkaTemplate(ProducerFactory producerFactory) { + public KafkaTemplate kafkaTemplate(ProducerFactory producerFactory) { return new KafkaTemplate<>(producerFactory); } @@ -52,7 +54,7 @@ public ByteArrayJsonMessageConverter jsonMessageConverter(ObjectMapper objectMap } @Bean(name = BATCH_LISTENER) - public ConcurrentKafkaListenerContainerFactory defaultBatchListenerContainerFactory( + public ConcurrentKafkaListenerContainerFactory defaultBatchListenerContainerFactory( KafkaProperties kafkaProperties, ByteArrayJsonMessageConverter converter ) { @@ -64,7 +66,7 @@ public ConcurrentKafkaListenerContainerFactory defaultBatchListe consumerConfig.put(ConsumerConfig.HEARTBEAT_INTERVAL_MS_CONFIG, HEARTBEAT_INTERVAL_MS); consumerConfig.put(ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG, MAX_POLL_INTERVAL_MS); - ConcurrentKafkaListenerContainerFactory factory = new ConcurrentKafkaListenerContainerFactory<>(); + ConcurrentKafkaListenerContainerFactory factory = new ConcurrentKafkaListenerContainerFactory<>(); factory.setConsumerFactory(new DefaultKafkaConsumerFactory<>(consumerConfig)); factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL); // 수동 커밋 factory.setBatchMessageConverter(new BatchMessagingMessageConverter(converter)); @@ -72,4 +74,94 @@ public ConcurrentKafkaListenerContainerFactory defaultBatchListe factory.setBatchListener(true); return factory; } + + /** + * Like 도메인 이벤트 토픽. + *

    + * 파티션 키: productId (상품별 좋아요 수 집계를 위해) + *

    + */ + @Bean + public NewTopic likeEventsTopic() { + return TopicBuilder.name("like-events") + .partitions(3) + .replicas(1) + .config("min.insync.replicas", "1") + .build(); + } + + /** + * Product 도메인 이벤트 토픽. + *

    + * 파티션 키: productId (상품별 재고 관리를 위해) + *

    + */ + @Bean + public NewTopic productEventsTopic() { + return TopicBuilder.name("product-events") + .partitions(3) + .replicas(1) + .config("min.insync.replicas", "1") + .build(); + } + + /** + * Order 도메인 이벤트 토픽. + *

    + * 파티션 키: orderId (주문별 이벤트 순서 보장을 위해) + *

    + */ + @Bean + public NewTopic orderEventsTopic() { + return TopicBuilder.name("order-events") + .partitions(3) + .replicas(1) + .config("min.insync.replicas", "1") + .build(); + } + + /** + * Payment 도메인 이벤트 토픽. + *

    + * 파티션 키: orderId (주문별 결제 처리 순서 보장을 위해) + *

    + */ + @Bean + public NewTopic paymentEventsTopic() { + return TopicBuilder.name("payment-events") + .partitions(3) + .replicas(1) + .config("min.insync.replicas", "1") + .build(); + } + + /** + * Coupon 도메인 이벤트 토픽. + *

    + * 파티션 키: orderId (주문별 쿠폰 할인 적용 순서 보장을 위해) + *

    + */ + @Bean + public NewTopic couponEventsTopic() { + return TopicBuilder.name("coupon-events") + .partitions(3) + .replicas(1) + .config("min.insync.replicas", "1") + .build(); + } + + /** + * User 도메인 이벤트 토픽. + *

    + * 파티션 키: userId (사용자별 포인트 처리 순서 보장을 위해) + *

    + */ + @Bean + public NewTopic userEventsTopic() { + return TopicBuilder.name("user-events") + .partitions(3) + .replicas(1) + .config("min.insync.replicas", "1") + .build(); + } } diff --git a/modules/kafka/src/main/resources/kafka.yml b/modules/kafka/src/main/resources/kafka.yml index 9609dbf85..a2a73417b 100644 --- a/modules/kafka/src/main/resources/kafka.yml +++ b/modules/kafka/src/main/resources/kafka.yml @@ -15,6 +15,10 @@ spring: key-serializer: org.apache.kafka.common.serialization.StringSerializer value-serializer: org.springframework.kafka.support.serializer.JsonSerializer retries: 3 + properties: + acks: all # 모든 리플리카에 쓰기 확인 (At Least Once 보장) + enable.idempotence: true # 중복 방지 (At Least Once 보장) + max.in.flight.requests.per.connection: 5 # idempotence=true일 때 필수 consumer: group-id: loopers-default-consumer key-deserializer: org.apache.kafka.common.serialization.StringDeserializer @@ -29,11 +33,13 @@ spring.config.activate.on-profile: local, test spring: kafka: - bootstrap-servers: localhost:19092 + # Testcontainers를 사용하는 경우 BOOTSTRAP_SERVERS가 자동으로 설정됨 + # 로컬 개발 환경에서는 localhost:19092 사용 + bootstrap-servers: ${BOOTSTRAP_SERVERS:localhost:19092} admin: properties: - bootstrap.servers: kafka:9092 - + bootstrap.servers: ${BOOTSTRAP_SERVERS:localhost:19092} + auto-create: true --- spring.config.activate.on-profile: dev diff --git a/modules/kafka/src/testFixtures/java/com/loopers/testcontainers/KafkaTestContainersConfig.java b/modules/kafka/src/testFixtures/java/com/loopers/testcontainers/KafkaTestContainersConfig.java new file mode 100644 index 000000000..4500d3b0b --- /dev/null +++ b/modules/kafka/src/testFixtures/java/com/loopers/testcontainers/KafkaTestContainersConfig.java @@ -0,0 +1,35 @@ +package com.loopers.testcontainers; + +import org.springframework.context.annotation.Configuration; +import org.testcontainers.kafka.ConfluentKafkaContainer; + +/** + * Kafka Testcontainers 설정. + *

    + * 테스트 실행 시 자동으로 Kafka 컨테이너를 시작하고, + * Spring Boot의 Kafka 설정에 동적으로 포트를 주입합니다. + *

    + *

    + * 동작 방식: + * 1. Kafka 컨테이너를 시작 + * 2. 동적으로 할당된 포트를 System Property로 설정 + * 3. kafka.yml의 ${BOOTSTRAP_SERVERS}가 이 값을 사용 + *

    + */ +@Configuration +public class KafkaTestContainersConfig { + + private static final ConfluentKafkaContainer kafkaContainer; + + static { + // Kafka 컨테이너 생성 및 시작 + // ConfluentKafkaContainer는 confluentinc/cp-kafka 이미지를 사용 + kafkaContainer = new ConfluentKafkaContainer("confluentinc/cp-kafka:7.5.0"); + kafkaContainer.start(); + + // Spring Boot의 Kafka 설정에 동적으로 포트 주입 + // kafka.yml의 ${BOOTSTRAP_SERVERS}가 이 값을 사용 + String bootstrapServers = kafkaContainer.getBootstrapServers(); + System.setProperty("BOOTSTRAP_SERVERS", bootstrapServers); + } +} diff --git a/modules/kafka/src/testFixtures/java/com/loopers/utils/KafkaCleanUp.java b/modules/kafka/src/testFixtures/java/com/loopers/utils/KafkaCleanUp.java new file mode 100644 index 000000000..51207364a --- /dev/null +++ b/modules/kafka/src/testFixtures/java/com/loopers/utils/KafkaCleanUp.java @@ -0,0 +1,194 @@ +package com.loopers.utils; + +import org.apache.kafka.clients.admin.AdminClient; +import org.apache.kafka.clients.admin.AdminClientConfig; +import org.apache.kafka.clients.admin.DeleteConsumerGroupsResult; +import org.apache.kafka.clients.admin.DeleteTopicsResult; +import org.apache.kafka.clients.admin.NewTopic; +import org.springframework.kafka.config.TopicBuilder; +import org.springframework.kafka.core.KafkaAdmin; +import org.springframework.stereotype.Component; + +import java.util.Collections; +import java.util.List; +import java.util.Properties; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +/** + * Kafka 테스트 정리 유틸리티. + *

    + * 테스트 간 Kafka 메시지 격리를 위해 토픽을 삭제하고 재생성합니다. + *

    + *

    + * 사용 방법: + *

      + *
    • 통합 테스트에서 `@BeforeEach` 또는 `@AfterEach`에서 호출하여 테스트 간 격리 보장
    • + *
    • 단위 테스트는 Mock을 사용하므로 불필요
    • + *
    + *

    + *

    + * 주의: + * 프로덕션 환경에서는 사용하지 마세요. 테스트 환경에서만 사용해야 합니다. + *

    + */ +@Component +public class KafkaCleanUp { + + private static final List TEST_TOPICS = List.of( + "like-events", + "order-events", + "product-events", + "payment-events", + "coupon-events", + "user-events" + ); + + private final KafkaAdmin kafkaAdmin; + + public KafkaCleanUp(KafkaAdmin kafkaAdmin) { + this.kafkaAdmin = kafkaAdmin; + } + + /** + * 테스트용 토픽의 모든 메시지를 삭제합니다. + *

    + * 토픽을 삭제하고 재생성하여 모든 메시지를 제거합니다. + *

    + *

    + * 주의: 프로덕션 환경에서는 사용하지 마세요. + *

    + */ + public void deleteAllTestTopics() { + try (AdminClient adminClient = createAdminClient()) { + // 존재하는 토픽만 삭제 + Set existingTopics = adminClient.listTopics() + .names() + .get(5, TimeUnit.SECONDS); + + List topicsToDelete = TEST_TOPICS.stream() + .filter(existingTopics::contains) + .toList(); + + if (topicsToDelete.isEmpty()) { + return; + } + + // 토픽 삭제 (모든 메시지 제거) + DeleteTopicsResult deleteResult = adminClient.deleteTopics(topicsToDelete); + deleteResult.all().get(10, TimeUnit.SECONDS); + + // 토픽 삭제 후 재생성 대기 (Kafka가 토픽 삭제를 완료할 때까지) + Thread.sleep(1000); + } catch (Exception e) { + // 토픽이 없거나 이미 삭제된 경우 무시 + // 테스트 환경에서는 토픽이 없을 수 있음 + } + } + + /** + * 테스트용 토픽을 재생성합니다. + *

    + * 삭제된 토픽을 원래 설정으로 재생성합니다. + *

    + */ + public void recreateTestTopics() { + try (AdminClient adminClient = createAdminClient()) { + for (String topicName : TEST_TOPICS) { + try { + // 토픽이 이미 존재하는지 확인 + adminClient.describeTopics(Collections.singletonList(topicName)) + .allTopicNames() + .get(2, TimeUnit.SECONDS); + // 이미 존재하면 스킵 + continue; + } catch (Exception e) { + // 토픽이 없으면 생성 + } + + // 토픽 생성 + NewTopic newTopic = TopicBuilder.name(topicName) + .partitions(3) + .replicas(1) + .config("min.insync.replicas", "1") + .build(); + + adminClient.createTopics(Collections.singletonList(newTopic)) + .all() + .get(5, TimeUnit.SECONDS); + } + } catch (Exception e) { + // 토픽 생성 실패는 무시 (이미 존재할 수 있음) + } + } + + /** + * 테스트용 토픽을 삭제하고 재생성합니다. + *

    + * 모든 메시지를 제거하고 깨끗한 상태로 시작합니다. + *

    + */ + public void resetAllTestTopics() { + deleteAllTestTopics(); + recreateTestTopics(); + } + + /** + * 모든 Consumer Group을 삭제하여 offset을 리셋합니다. + *

    + * 테스트 간 격리를 위해 사용합니다. + *

    + *

    + * 주의: 모든 Consumer Group을 삭제하므로 프로덕션 환경에서는 사용하지 마세요. + *

    + */ + public void resetAllConsumerGroups() { + try (AdminClient adminClient = createAdminClient()) { + // 모든 Consumer Group 목록 조회 + Set consumerGroups = adminClient.listConsumerGroups() + .all() + .get(5, TimeUnit.SECONDS) + .stream() + .map(group -> group.groupId()) + .collect(java.util.stream.Collectors.toSet()); + + if (consumerGroups.isEmpty()) { + return; + } + + // Consumer Group 삭제 (offset 리셋) + DeleteConsumerGroupsResult deleteResult = adminClient.deleteConsumerGroups(consumerGroups); + deleteResult.all().get(5, TimeUnit.SECONDS); + } catch (Exception e) { + // Consumer Group이 없거나 이미 삭제된 경우 무시 + // 테스트 환경에서는 Consumer Group이 없을 수 있음 + } + } + + /** + * 특정 Consumer Group을 삭제합니다. + * + * @param groupId 삭제할 Consumer Group ID + */ + public void resetConsumerGroup(String groupId) { + try (AdminClient adminClient = createAdminClient()) { + DeleteConsumerGroupsResult deleteResult = adminClient.deleteConsumerGroups( + Collections.singletonList(groupId) + ); + deleteResult.all().get(5, TimeUnit.SECONDS); + } catch (Exception e) { + // Consumer Group이 없거나 이미 삭제된 경우 무시 + } + } + + /** + * AdminClient를 생성합니다. + */ + private AdminClient createAdminClient() { + Properties props = new Properties(); + Object bootstrapServers = kafkaAdmin.getConfigurationProperties() + .getOrDefault(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:19092"); + props.put(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + return AdminClient.create(props); + } +} From f8db897511d25c03118c40dc5e7734db5503c0b8 Mon Sep 17 00:00:00 2001 From: minor7295 <44902090+minor7295@users.noreply.github.com> Date: Mon, 29 Dec 2025 00:35:36 +0900 Subject: [PATCH 10/12] [volume - 9] What is Popularity? (#210) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Feature/ranking (#37) * feat: zset 모듈 추가 zset * test: 랭킹 계산에 대한 테스트 코드 추가 * feat: 랭킹 계산 서비스 구현 * test: 랭킹 이벤트 컨슈머 테스트 로직 추가 * feat: 랭킹 컨슈머 구현 * test: 랭킹 조회 통합 테스트 코드 추가 * feat: 랭킹 조회 서비스 로직 구현 * feat: 랭킹 조회 엔드포인트 추가 * test: 랭킹 정보 포함하여 상품 조회하는 테스트 코드 작성 * feat: 랭킹 포함하여 상품 정보 조회하도록 api 수정 --------- Co-authored-by: 이건영 <> * Feature/ranking event (#38) * feat: zset 모듈에 zunionstore 연산 처리 메소드 추가 * test: 랭킹 집계에 필요한 데이터 수집과 랭킹 계산 로직을 application event 기준으로 분리하도록 테스트 코드 수정 * feat: 랭킹 집계에 필요한 데이터 수집과 랭킹 계산 로직을 application event 기준으로 분리하도록 함 * Feature/ranking exception (#39) * test: 랭킹 조회 실패할 때의 테스트코드 추가 * feat: 랭킹 조회 실패시 전날 혹은 좋아요 순 데이터로 응답하도록 보완 * feat: 랭킹 fallback 전략 구현 * test: 랭킹 fallback 전략에 맞춰 테스트코드 수정 * refactor: 일자 단위 carry over 도입에 따라 unionstore 제거 * chore: 클래스명과 동일하게 파일 이름 변경 * refactor: 랭킹 이벤트 컨슈머에서 멱등성 체크 로직, 에러 처리 로직, 배치 커밋 로직 반복 제거 * refactor: 불필요한 stubbing 제거 * chore: 시간대 설정 추가 --- .../application/catalog/CatalogFacade.java | 25 +- .../application/catalog/ProductInfo.java | 23 +- .../product/ProductCacheService.java | 2 +- .../ranking/RankingKeyGenerator.java | 52 ++ .../application/ranking/RankingService.java | 342 ++++++++++ .../ranking/RankingSnapshotService.java | 103 +++ .../scheduler/RankingSnapshotScheduler.java | 72 +++ .../interfaces/api/catalog/ProductV1Dto.java | 7 +- .../api/ranking/RankingV1Controller.java | 89 +++ .../interfaces/api/ranking/RankingV1Dto.java | 94 +++ .../catalog/CatalogFacadeTest.java | 230 +++++++ .../ranking/RankingServiceTest.java | 607 ++++++++++++++++++ .../loopers/CommerceStreamerApplication.java | 2 + .../ranking/RankingEventHandler.java | 118 ++++ .../ranking/RankingKeyGenerator.java | 35 + .../application/ranking/RankingService.java | 165 +++++ .../scheduler/RankingCarryOverScheduler.java | 71 ++ .../interfaces/consumer/RankingConsumer.java | 389 +++++++++++ .../event/ranking/RankingEventListener.java | 121 ++++ .../ranking/RankingEventHandlerTest.java | 158 +++++ .../ranking/RankingServiceTest.java | 296 +++++++++ .../consumer/RankingConsumerTest.java | 450 +++++++++++++ .../com/loopers/zset/RedisZSetTemplate.java | 215 +++++++ .../main/java/com/loopers/zset/ZSetEntry.java | 12 + 24 files changed, 3667 insertions(+), 11 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingKeyGenerator.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingSnapshotService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/RankingSnapshotScheduler.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/catalog/CatalogFacadeTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingServiceTest.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingEventHandler.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingKeyGenerator.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingService.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/RankingCarryOverScheduler.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/RankingConsumer.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/interfaces/event/ranking/RankingEventListener.java create mode 100644 apps/commerce-streamer/src/test/java/com/loopers/application/ranking/RankingEventHandlerTest.java create mode 100644 apps/commerce-streamer/src/test/java/com/loopers/application/ranking/RankingServiceTest.java create mode 100644 apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/RankingConsumerTest.java create mode 100644 modules/redis/src/main/java/com/loopers/zset/RedisZSetTemplate.java create mode 100644 modules/redis/src/main/java/com/loopers/zset/ZSetEntry.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogFacade.java index c8eed8f67..4ce66ca53 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/catalog/CatalogFacade.java @@ -3,6 +3,7 @@ import com.loopers.application.brand.BrandService; import com.loopers.application.product.ProductCacheService; import com.loopers.application.product.ProductService; +import com.loopers.application.ranking.RankingService; import com.loopers.domain.brand.Brand; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductDetail; @@ -14,6 +15,7 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -34,6 +36,7 @@ public class CatalogFacade { private final ProductService productService; private final ProductCacheService productCacheService; private final ProductEventPublisher productEventPublisher; + private final RankingService rankingService; /** * 상품 목록을 조회합니다. @@ -90,7 +93,7 @@ public ProductInfoList getProducts(Long brandId, String sort, int page, int size } // ✅ Product.likeCount 필드 사용 (비동기 집계된 값) ProductDetail productDetail = ProductDetail.from(product, brand.getName(), product.getLikeCount()); - return new ProductInfo(productDetail); + return ProductInfo.withoutRank(productDetail); }) .toList(); @@ -108,10 +111,11 @@ public ProductInfoList getProducts(Long brandId, String sort, int page, int size *

    * Redis 캐시를 먼저 확인하고, 캐시에 없으면 DB에서 조회한 후 캐시에 저장합니다. * 상품 조회 시 ProductViewed 이벤트를 발행하여 메트릭 집계에 사용합니다. + * 랭킹 정보도 함께 조회하여 반환합니다. *

    * * @param productId 상품 ID - * @return 상품 정보와 좋아요 수 + * @return 상품 정보와 좋아요 수, 랭킹 순위 * @throws CoreException 상품을 찾을 수 없는 경우 */ @Transactional(readOnly = true) @@ -121,7 +125,11 @@ public ProductInfo getProduct(Long productId) { if (cachedResult != null) { // 캐시 히트 시에도 조회 수 집계를 위해 이벤트 발행 productEventPublisher.publish(ProductEvent.ProductViewed.from(productId)); - return cachedResult; + + // 랭킹 정보 조회 (캐시된 결과에 랭킹 정보 추가) + LocalDate today = LocalDate.now(); + Long rank = rankingService.getProductRank(productId, today); + return ProductInfo.withRank(cachedResult.productDetail(), rank); } // 캐시에 없으면 DB에서 조회 @@ -136,16 +144,19 @@ public ProductInfo getProduct(Long productId) { // ProductDetail 생성 (Aggregate 경계 준수: Brand 엔티티 대신 brandName만 전달) ProductDetail productDetail = ProductDetail.from(product, brand.getName(), likesCount); - ProductInfo result = new ProductInfo(productDetail); + // 랭킹 정보 조회 + LocalDate today = LocalDate.now(); + Long rank = rankingService.getProductRank(productId, today); - // 캐시에 저장 - productCacheService.cacheProduct(productId, result); + // 캐시에 저장 (랭킹 정보는 제외하고 저장 - 랭킹은 실시간으로 조회) + productCacheService.cacheProduct(productId, ProductInfo.withoutRank(productDetail)); // ✅ 상품 조회 이벤트 발행 (메트릭 집계용) productEventPublisher.publish(ProductEvent.ProductViewed.from(productId)); // 로컬 캐시의 좋아요 수 델타 적용 (DB 조회 결과에도 델타 반영) - return productCacheService.applyLikeCountDelta(result); + ProductInfo deltaApplied = productCacheService.applyLikeCountDelta(ProductInfo.withoutRank(productDetail)); + return ProductInfo.withRank(deltaApplied.productDetail(), rank); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/catalog/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/catalog/ProductInfo.java index 6a22a5f21..ec634bc0a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/catalog/ProductInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/catalog/ProductInfo.java @@ -6,7 +6,28 @@ * 상품 상세 정보를 담는 레코드. * * @param productDetail 상품 상세 정보 (Product + Brand + 좋아요 수) + * @param rank 랭킹 순위 (1부터 시작, 랭킹에 없으면 null) */ -public record ProductInfo(ProductDetail productDetail) { +public record ProductInfo(ProductDetail productDetail, Long rank) { + /** + * 랭킹 정보 없이 ProductInfo를 생성합니다. + * + * @param productDetail 상품 상세 정보 + * @return ProductInfo (rank는 null) + */ + public static ProductInfo withoutRank(ProductDetail productDetail) { + return new ProductInfo(productDetail, null); + } + + /** + * 랭킹 정보와 함께 ProductInfo를 생성합니다. + * + * @param productDetail 상품 상세 정보 + * @param rank 랭킹 순위 (1부터 시작, 랭킹에 없으면 null) + * @return ProductInfo + */ + public static ProductInfo withRank(ProductDetail productDetail, Long rank) { + return new ProductInfo(productDetail, rank); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheService.java index f2e6b5bfe..32c4f915b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheService.java @@ -269,7 +269,7 @@ public ProductInfo applyLikeCountDelta(ProductInfo productInfo) { updatedLikesCount ); - return new ProductInfo(updatedDetail); + return ProductInfo.withoutRank(updatedDetail); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingKeyGenerator.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingKeyGenerator.java new file mode 100644 index 000000000..f87a52422 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingKeyGenerator.java @@ -0,0 +1,52 @@ +package com.loopers.application.ranking; + +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +/** + * 랭킹 키 생성 유틸리티. + *

    + * Redis ZSET 랭킹 키를 생성합니다. + *

    + * + * @author Loopers + * @version 1.0 + */ +@Component +public class RankingKeyGenerator { + private static final String DAILY_KEY_PREFIX = "ranking:all:"; + private static final String HOURLY_KEY_PREFIX = "ranking:hourly:"; + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHH"); + + /** + * 일간 랭킹 키를 생성합니다. + *

    + * 예: ranking:all:20241215 + *

    + * + * @param date 날짜 + * @return 일간 랭킹 키 + */ + public String generateDailyKey(LocalDate date) { + String dateStr = date.format(DATE_FORMATTER); + return DAILY_KEY_PREFIX + dateStr; + } + + /** + * 시간 단위 랭킹 키를 생성합니다. + *

    + * 예: ranking:hourly:2024121514 + *

    + * + * @param dateTime 날짜 및 시간 + * @return 시간 단위 랭킹 키 + */ + public String generateHourlyKey(LocalDateTime dateTime) { + String dateTimeStr = dateTime.format(DATE_TIME_FORMATTER); + return HOURLY_KEY_PREFIX + dateTimeStr; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java new file mode 100644 index 000000000..df6305b83 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java @@ -0,0 +1,342 @@ +package com.loopers.application.ranking; + +import com.loopers.application.brand.BrandService; +import com.loopers.application.product.ProductService; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductDetail; +import com.loopers.zset.ZSetEntry; +import com.loopers.zset.RedisZSetTemplate; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataAccessException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * 랭킹 조회 서비스. + *

    + * Redis ZSET에서 랭킹을 조회하고 상품 정보를 Aggregation하여 제공합니다. + *

    + *

    + * 설계 원칙: + *

      + *
    • Application 유즈케이스: Ranking은 도메인이 아닌 파생 View로 취급
    • + *
    • 상품 정보 Aggregation: 상품 ID만이 아닌 상품 정보 포함
    • + *
    • 배치 조회: N+1 쿼리 문제 방지
    • + *
    + *

    + * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class RankingService { + private final RedisZSetTemplate zSetTemplate; + private final RankingKeyGenerator keyGenerator; + private final ProductService productService; + private final BrandService brandService; + private final RankingSnapshotService rankingSnapshotService; + + /** + * 랭킹을 조회합니다 (페이징). + *

    + * ZSET에서 상위 N개를 조회하고, 상품 정보를 Aggregation하여 반환합니다. + *

    + *

    + * Graceful Degradation: + *

      + *
    • Redis 장애 시 스냅샷으로 Fallback
    • + *
    • 스냅샷도 없으면 기본 랭킹(좋아요순) 제공 (단순 조회, 계산 아님)
    • + *
    + *

    + * + * @param date 날짜 (yyyyMMdd 형식의 문자열 또는 LocalDate) + * @param page 페이지 번호 (0부터 시작) + * @param size 페이지당 항목 수 + * @return 랭킹 조회 결과 + */ + @Transactional(readOnly = true) + public RankingsResponse getRankings(LocalDate date, int page, int size) { + try { + return getRankingsFromRedis(date, page, size); + } catch (DataAccessException e) { + log.warn("Redis 랭킹 조회 실패, 스냅샷으로 Fallback: date={}, error={}", + date, e.getMessage()); + // 스냅샷으로 Fallback 시도 + Optional snapshot = rankingSnapshotService.getSnapshot(date); + if (snapshot.isPresent()) { + log.info("스냅샷으로 랭킹 제공: date={}, itemCount={}", date, snapshot.get().items().size()); + return snapshot.get(); + } + + // 전날 스냅샷 시도 + Optional yesterdaySnapshot = rankingSnapshotService.getSnapshot(date.minusDays(1)); + if (yesterdaySnapshot.isPresent()) { + log.info("전날 스냅샷으로 랭킹 제공: date={}, itemCount={}", date, yesterdaySnapshot.get().items().size()); + return yesterdaySnapshot.get(); + } + + // 최종 Fallback: 기본 랭킹 (단순 조회, 계산 아님) + log.warn("스냅샷도 없음, 기본 랭킹(좋아요순)으로 Fallback: date={}", date); + return getDefaultRankings(page, size); + } catch (Exception e) { + log.error("랭킹 조회 중 예상치 못한 오류 발생, 기본 랭킹으로 Fallback: date={}", date, e); + return getDefaultRankings(page, size); + } + } + + /** + * Redis에서 랭킹을 조회합니다. + *

    + * 스케줄러에서 스냅샷 저장 시 호출하기 위해 public으로 제공합니다. + *

    + * + * @param date 날짜 + * @param page 페이지 번호 + * @param size 페이지당 항목 수 + * @return 랭킹 조회 결과 + * @throws DataAccessException Redis 접근 실패 시 + */ + public RankingsResponse getRankingsFromRedis(LocalDate date, int page, int size) { + String key = keyGenerator.generateDailyKey(date); + long start = (long) page * size; + long end = start + size - 1; + + // ZSET에서 Top N 조회 + List entries = zSetTemplate.getTopRankings(key, start, end); + + if (entries.isEmpty()) { + return RankingsResponse.empty(page, size); + } + + // 상품 ID 추출 + List productIds = entries.stream() + .map(entry -> Long.parseLong(entry.member())) + .toList(); + + // 상품 정보 배치 조회 + List products = productService.getProducts(productIds); + + // 상품 ID → Product Map 생성 + Map productMap = products.stream() + .collect(Collectors.toMap(Product::getId, product -> product)); + + // 브랜드 ID 수집 + List brandIds = products.stream() + .map(Product::getBrandId) + .distinct() + .toList(); + + // 브랜드 배치 조회 + Map brandMap = brandService.getBrands(brandIds).stream() + .collect(Collectors.toMap(Brand::getId, brand -> brand)); + + // 랭킹 항목 생성 (순위, 점수, 상품 정보 포함) + List rankingItems = new ArrayList<>(); + for (int i = 0; i < entries.size(); i++) { + ZSetEntry entry = entries.get(i); + Long productId = Long.parseLong(entry.member()); + Long rank = start + i + 1; // 1-based 순위 + + Product product = productMap.get(productId); + if (product == null) { + log.warn("랭킹에 포함된 상품을 찾을 수 없습니다: productId={}", productId); + continue; + } + + Brand brand = brandMap.get(product.getBrandId()); + if (brand == null) { + log.warn("상품의 브랜드를 찾을 수 없습니다: productId={}, brandId={}", + productId, product.getBrandId()); + continue; + } + + ProductDetail productDetail = ProductDetail.from( + product, + brand.getName(), + product.getLikeCount() + ); + + rankingItems.add(new RankingItem( + rank, + entry.score(), + productDetail + )); + } + + // 전체 랭킹 개수 조회 (ZSET 크기) + Long totalSize = zSetTemplate.getSize(key); + boolean hasNext = (start + size) < totalSize; + + return new RankingsResponse(rankingItems, page, size, hasNext); + } + + /** + * 기본 랭킹(좋아요순)을 제공합니다. + *

    + * 최종 Fallback으로 사용됩니다. 랭킹을 새로 계산하는 것이 아니라 + * 이미 집계된 좋아요 수를 단순 조회하는 것이므로 DB 부하가 크지 않습니다. + *

    + * + * @param page 페이지 번호 (0부터 시작) + * @param size 페이지당 항목 수 + * @return 랭킹 조회 결과 + */ + private RankingsResponse getDefaultRankings(int page, int size) { + // 좋아요순으로 상품 조회 + List products = productService.findAll(null, "likes_desc", page, size); + long totalCount = productService.countAll(null); + + if (products.isEmpty()) { + return RankingsResponse.empty(page, size); + } + + // 브랜드 ID 수집 + List brandIds = products.stream() + .map(Product::getBrandId) + .distinct() + .toList(); + + // 브랜드 배치 조회 + Map brandMap = brandService.getBrands(brandIds).stream() + .collect(Collectors.toMap(Brand::getId, brand -> brand)); + + // 랭킹 항목 생성 (좋아요 수를 점수로 사용) + List rankingItems = new ArrayList<>(); + long start = (long) page * size; + for (int i = 0; i < products.size(); i++) { + Product product = products.get(i); + Long rank = start + i + 1; // 1-based 순위 + + Brand brand = brandMap.get(product.getBrandId()); + if (brand == null) { + log.warn("상품의 브랜드를 찾을 수 없습니다: productId={}, brandId={}", + product.getId(), product.getBrandId()); + continue; + } + + ProductDetail productDetail = ProductDetail.from( + product, + brand.getName(), + product.getLikeCount() + ); + + // 좋아요 수를 점수로 사용 + double score = product.getLikeCount() != null ? product.getLikeCount().doubleValue() : 0.0; + rankingItems.add(new RankingItem( + rank, + score, + productDetail + )); + } + + boolean hasNext = (start + size) < totalCount; + return new RankingsResponse(rankingItems, page, size, hasNext); + } + + /** + * 특정 상품의 순위를 조회합니다. + *

    + * 상품이 랭킹에 없으면 null을 반환합니다. + *

    + *

    + * Graceful Degradation: + *

      + *
    • Redis 장애 시 전날 랭킹으로 Fallback
    • + *
    • 전날 랭킹도 없으면 null 반환 (기본 랭킹에서는 순위 계산 불가)
    • + *
    + *

    + * + * @param productId 상품 ID + * @param date 날짜 + * @return 순위 (1부터 시작, 없으면 null) + */ + @Transactional(readOnly = true) + public Long getProductRank(Long productId, LocalDate date) { + try { + return getProductRankFromRedis(productId, date); + } catch (DataAccessException e) { + log.warn("Redis 상품 순위 조회 실패, 전날 랭킹으로 Fallback: productId={}, date={}, error={}", + productId, date, e.getMessage()); + // 전날 랭킹으로 Fallback 시도 + try { + LocalDate yesterday = date.minusDays(1); + return getProductRankFromRedis(productId, yesterday); + } catch (DataAccessException fallbackException) { + log.warn("전날 랭킹 조회도 실패: productId={}, date={}, error={}", + productId, date, fallbackException.getMessage()); + // 기본 랭킹에서는 순위 계산이 어려우므로 null 반환 + return null; + } + } catch (Exception e) { + log.error("상품 순위 조회 중 예상치 못한 오류 발생: productId={}, date={}", productId, date, e); + return null; + } + } + + /** + * Redis에서 상품 순위를 조회합니다. + * + * @param productId 상품 ID + * @param date 날짜 + * @return 순위 (1부터 시작, 없으면 null) + * @throws DataAccessException Redis 접근 실패 시 + */ + private Long getProductRankFromRedis(Long productId, LocalDate date) { + String key = keyGenerator.generateDailyKey(date); + Long rank = zSetTemplate.getRank(key, String.valueOf(productId)); + + if (rank == null) { + return null; + } + + // 0-based → 1-based 변환 + return rank + 1; + } + + /** + * 랭킹 조회 결과. + * + * @param items 랭킹 항목 목록 + * @param page 현재 페이지 번호 + * @param size 페이지당 항목 수 + * @param hasNext 다음 페이지 존재 여부 + */ + public record RankingsResponse( + List items, + int page, + int size, + boolean hasNext + ) { + /** + * 빈 랭킹 조회 결과를 생성합니다. + */ + public static RankingsResponse empty(int page, int size) { + return new RankingsResponse(List.of(), page, size, false); + } + } + + /** + * 랭킹 항목 (순위, 점수, 상품 정보). + * + * @param rank 순위 (1부터 시작) + * @param score 점수 + * @param productDetail 상품 상세 정보 + */ + public record RankingItem( + Long rank, + Double score, + ProductDetail productDetail + ) { + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingSnapshotService.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingSnapshotService.java new file mode 100644 index 000000000..c9bd2efab --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingSnapshotService.java @@ -0,0 +1,103 @@ +package com.loopers.application.ranking; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 랭킹 스냅샷 서비스. + *

    + * Redis 장애 시 Fallback으로 사용하기 위한 랭킹 데이터 스냅샷을 인메모리에 저장합니다. + *

    + *

    + * 설계 원칙: + *

      + *
    • 인메모리 캐시: 구현이 간단하고 성능이 우수함
    • + *
    • 메모리 관리: 최근 7일치만 보관하여 메모리 사용량 제한
    • + *
    • 스냅샷 기반 Fallback: DB 실시간 재계산 대신 스냅샷 서빙으로 DB 부하 방지
    • + *
    + *

    + * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Service +public class RankingSnapshotService { + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + private static final int MAX_SNAPSHOTS = 7; // 최근 7일치만 보관 + + private final Map snapshotCache = new ConcurrentHashMap<>(); + + /** + * 랭킹 스냅샷을 저장합니다. + * + * @param date 날짜 + * @param rankings 랭킹 조회 결과 + */ + public void saveSnapshot(LocalDate date, RankingService.RankingsResponse rankings) { + String key = date.format(DATE_FORMATTER); + snapshotCache.put(key, rankings); + log.debug("랭킹 스냅샷 저장: date={}, key={}, itemCount={}", date, key, rankings.items().size()); + + // 오래된 스냅샷 정리 (메모리 관리) + cleanupOldSnapshots(); + } + + /** + * 랭킹 스냅샷을 조회합니다. + * + * @param date 날짜 + * @return 랭킹 조회 결과 (없으면 empty) + */ + public Optional getSnapshot(LocalDate date) { + String key = date.format(DATE_FORMATTER); + RankingService.RankingsResponse snapshot = snapshotCache.get(key); + + if (snapshot != null) { + log.debug("랭킹 스냅샷 조회 성공: date={}, key={}, itemCount={}", date, key, snapshot.items().size()); + return Optional.of(snapshot); + } + + log.debug("랭킹 스냅샷 없음: date={}, key={}", date, key); + return Optional.empty(); + } + + /** + * 오래된 스냅샷을 정리합니다. + *

    + * 최근 7일치만 보관하여 메모리 사용량을 제한합니다. + *

    + */ + private void cleanupOldSnapshots() { + if (snapshotCache.size() <= MAX_SNAPSHOTS) { + return; + } + + // 가장 오래된 스냅샷 제거 + LocalDate today = LocalDate.now(ZoneId.of("UTC")); + LocalDate oldestDate = today.minusDays(MAX_SNAPSHOTS); + + snapshotCache.entrySet().removeIf(entry -> { + try { + LocalDate entryDate = LocalDate.parse(entry.getKey(), DATE_FORMATTER); + boolean shouldRemove = entryDate.isBefore(oldestDate); + if (shouldRemove) { + log.debug("오래된 스냅샷 제거: key={}", entry.getKey()); + } + return shouldRemove; + } catch (Exception e) { + log.warn("스냅샷 키 파싱 실패, 제거: key={}", entry.getKey(), e); + return true; // 파싱 실패한 키는 제거 + } + }); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/RankingSnapshotScheduler.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/RankingSnapshotScheduler.java new file mode 100644 index 000000000..3adefd9be --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/scheduler/RankingSnapshotScheduler.java @@ -0,0 +1,72 @@ +package com.loopers.infrastructure.scheduler; + +import com.loopers.application.ranking.RankingService; +import com.loopers.application.ranking.RankingSnapshotService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.ZoneId; + +/** + * 랭킹 스냅샷 저장 스케줄러. + *

    + * 주기적으로 랭킹 결과를 스냅샷으로 저장하여, Redis 장애 시 Fallback으로 사용할 수 있도록 합니다. + *

    + *

    + * 설계 원칙: + *

      + *
    • 스냅샷 기반 Fallback: DB 실시간 재계산 대신 스냅샷 서빙으로 DB 부하 방지
    • + *
    • 주기적 저장: 1시간마다 최신 랭킹을 스냅샷으로 저장
    • + *
    • 에러 처리: 스냅샷 저장 실패 시에도 다음 스케줄에서 재시도
    • + *
    + *

    + *

    + * 주기 선택 근거: + *

      + *
    • 비용 대비 효과: 1시간 주기가 리소스 사용량이 1/12로 감소하면서도 사용자 체감 차이는 거의 없음
    • + *
    • 랭킹의 성격: 비즈니스 결정이 아닌 조회용 파생 데이터이므로 1시간 전 데이터도 충분히 유용함
    • + *
    • 운영 관점: 스케줄러 실행 빈도가 낮아 모니터링 부담 감소
    • + *
    + *

    + * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class RankingSnapshotScheduler { + + private final RankingService rankingService; + private final RankingSnapshotService rankingSnapshotService; + + /** + * 랭킹 스냅샷을 저장합니다. + *

    + * 1시간마다 실행되어 오늘의 랭킹을 스냅샷으로 저장합니다. + *

    + */ + @Scheduled(fixedRate = 3600000) // 1시간마다 (3600000ms = 1시간) + public void saveRankingSnapshot() { + LocalDate today = LocalDate.now(ZoneId.of("UTC")); + try { + // 상위 100개 랭킹을 스냅샷으로 저장 (대부분의 사용자가 상위 100개 이내만 조회) + // Redis가 정상일 때만 스냅샷 저장 (예외 발생 시 스킵) + RankingService.RankingsResponse rankings = rankingService.getRankingsFromRedis(today, 0, 100); + + rankingSnapshotService.saveSnapshot(today, rankings); + + log.debug("랭킹 스냅샷 저장 완료: date={}, itemCount={}", today, rankings.items().size()); + } catch (org.springframework.dao.DataAccessException e) { + log.warn("Redis 장애로 인한 랭킹 스냅샷 저장 실패: date={}, error={}", today, e.getMessage()); + // Redis 장애 시 스냅샷 저장 스킵 (다음 스케줄에서 재시도) + } catch (Exception e) { + log.warn("랭킹 스냅샷 저장 실패: date={}", today, e); + // 스냅샷 저장 실패는 다음 스케줄에서 재시도 + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/ProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/ProductV1Dto.java index 3661d9c9e..7df592db6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/ProductV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/catalog/ProductV1Dto.java @@ -21,6 +21,7 @@ public class ProductV1Dto { * @param stock 상품 재고 * @param brandId 브랜드 ID * @param likesCount 좋아요 수 + * @param rank 랭킹 순위 (1부터 시작, 랭킹에 없으면 null) */ public record ProductResponse( Long productId, @@ -28,7 +29,8 @@ public record ProductResponse( Integer price, Integer stock, Long brandId, - Long likesCount + Long likesCount, + Long rank ) { /** * ProductInfo로부터 ProductResponse를 생성합니다. @@ -44,7 +46,8 @@ public static ProductResponse from(ProductInfo productInfo) { detail.getPrice(), detail.getStock(), detail.getBrandId(), - detail.getLikesCount() + detail.getLikesCount(), + productInfo.rank() ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java new file mode 100644 index 000000000..ecbae6157 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java @@ -0,0 +1,89 @@ +package com.loopers.interfaces.api.ranking; + +import com.loopers.application.ranking.RankingService; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; + +/** + * 랭킹 조회 API v1 컨트롤러. + *

    + * 랭킹 조회 유즈케이스를 처리합니다. + *

    + * + * @author Loopers + * @version 1.0 + */ +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/rankings") +public class RankingV1Controller { + + private final RankingService rankingService; + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + + /** + * 랭킹을 조회합니다. + *

    + * 날짜별 랭킹을 페이징하여 조회합니다. + *

    + * + * @param date 날짜 (yyyyMMdd 형식, 기본값: 오늘 날짜) + * @param page 페이지 번호 (기본값: 0) + * @param size 페이지당 항목 수 (기본값: 20) + * @return 랭킹 목록을 담은 API 응답 + */ + @GetMapping + public ApiResponse getRankings( + @RequestParam(required = false) String date, + @RequestParam(required = false, defaultValue = "0") int page, + @RequestParam(required = false, defaultValue = "20") int size + ) { + // 날짜 파라미터 검증 및 기본값 처리 + LocalDate targetDate = parseDate(date); + + // 페이징 검증 + if (page < 0) { + page = 0; + } + if (size < 1) { + size = 20; + } + if (size > 100) { + size = 100; // 최대 100개로 제한 + } + + RankingService.RankingsResponse result = rankingService.getRankings(targetDate, page, size); + return ApiResponse.success(RankingV1Dto.RankingsResponse.from(result)); + } + + /** + * 날짜 문자열을 LocalDate로 파싱합니다. + *

    + * 날짜가 없거나 파싱 실패 시 오늘 날짜를 반환합니다. + *

    + * + * @param dateStr 날짜 문자열 (yyyyMMdd 형식) + * @return 파싱된 날짜 (실패 시 오늘 날짜) + */ + private LocalDate parseDate(String dateStr) { + if (dateStr == null || dateStr.isBlank()) { + return LocalDate.now(ZoneId.of("UTC")); + } + + try { + return LocalDate.parse(dateStr, DATE_FORMATTER); + } catch (DateTimeParseException e) { + // 파싱 실패 시 오늘 날짜 반환 (UTC 기준) + return LocalDate.now(ZoneId.of("UTC")); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java new file mode 100644 index 000000000..45ac64ab0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Dto.java @@ -0,0 +1,94 @@ +package com.loopers.interfaces.api.ranking; + +import com.loopers.application.ranking.RankingService; +import com.loopers.domain.product.ProductDetail; + +import java.util.List; + +/** + * 랭킹 조회 API v1의 데이터 전송 객체(DTO) 컨테이너. + * + * @author Loopers + * @version 1.0 + */ +public class RankingV1Dto { + /** + * 랭킹 항목 응답 데이터. + * + * @param rank 순위 (1부터 시작) + * @param score 점수 + * @param productId 상품 ID + * @param name 상품 이름 + * @param price 상품 가격 + * @param stock 상품 재고 + * @param brandId 브랜드 ID + * @param brandName 브랜드 이름 + * @param likesCount 좋아요 수 + */ + public record RankingItemResponse( + Long rank, + Double score, + Long productId, + String name, + Integer price, + Integer stock, + Long brandId, + String brandName, + Long likesCount + ) { + /** + * RankingService.RankingItem으로부터 RankingItemResponse를 생성합니다. + * + * @param item 랭킹 항목 + * @return 생성된 응답 객체 + */ + public static RankingItemResponse from(RankingService.RankingItem item) { + ProductDetail detail = item.productDetail(); + return new RankingItemResponse( + item.rank(), + item.score(), + detail.getId(), + detail.getName(), + detail.getPrice(), + detail.getStock(), + detail.getBrandId(), + detail.getBrandName(), + detail.getLikesCount() + ); + } + } + + /** + * 랭킹 목록 응답 데이터. + * + * @param items 랭킹 항목 목록 + * @param page 현재 페이지 번호 + * @param size 페이지당 항목 수 + * @param hasNext 다음 페이지 존재 여부 + */ + public record RankingsResponse( + List items, + int page, + int size, + boolean hasNext + ) { + /** + * RankingService.RankingsResponse로부터 RankingsResponse를 생성합니다. + * + * @param response 랭킹 조회 결과 + * @return 생성된 응답 객체 + */ + public static RankingsResponse from(RankingService.RankingsResponse response) { + List items = response.items().stream() + .map(RankingItemResponse::from) + .toList(); + + return new RankingsResponse( + items, + response.page(), + response.size(), + response.hasNext() + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/catalog/CatalogFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/catalog/CatalogFacadeTest.java new file mode 100644 index 000000000..bb78c71b6 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/catalog/CatalogFacadeTest.java @@ -0,0 +1,230 @@ +package com.loopers.application.catalog; + +import com.loopers.application.brand.BrandService; +import com.loopers.application.product.ProductCacheService; +import com.loopers.application.product.ProductService; +import com.loopers.application.ranking.RankingService; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductDetail; +import com.loopers.domain.product.ProductEvent; +import com.loopers.domain.product.ProductEventPublisher; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.lang.reflect.Field; +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * CatalogFacade 테스트. + *

    + * 상품 조회 시 랭킹 정보가 포함되는지 검증합니다. + * 캐시 히트/미스의 세부 로직은 ProductCacheService 테스트에서 검증합니다. + *

    + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("CatalogFacade 상품 조회 랭킹 정보 포함 테스트") +class CatalogFacadeTest { + + @Mock + private BrandService brandService; + + @Mock + private ProductService productService; + + @Mock + private ProductCacheService productCacheService; + + @Mock + private ProductEventPublisher productEventPublisher; + + @Mock + private RankingService rankingService; + + @InjectMocks + private CatalogFacade catalogFacade; + + private static final Long PRODUCT_ID = 1L; + private static final Long BRAND_ID = 10L; + private static final String BRAND_NAME = "테스트 브랜드"; + private static final String PRODUCT_NAME = "테스트 상품"; + private static final Integer PRODUCT_PRICE = 10000; + private static final Integer PRODUCT_STOCK = 10; + private static final Long LIKES_COUNT = 5L; + + /** + * Product에 ID를 설정합니다 (리플렉션 사용). + */ + private void setId(Product product, Long id) { + try { + Field idField = product.getClass().getSuperclass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(product, id); + } catch (Exception e) { + throw new RuntimeException("Failed to set Product ID", e); + } + } + + /** + * Brand에 ID를 설정합니다 (리플렉션 사용). + */ + private void setId(Brand brand, Long id) { + try { + Field idField = brand.getClass().getSuperclass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(brand, id); + } catch (Exception e) { + throw new RuntimeException("Failed to set Brand ID", e); + } + } + + @Test + @DisplayName("캐시 히트 시 랭킹 정보가 포함된다") + void getProduct_withCacheHit_includesRanking() { + // arrange + ProductDetail cachedProductDetail = ProductDetail.of( + PRODUCT_ID, + PRODUCT_NAME, + PRODUCT_PRICE, + PRODUCT_STOCK, + BRAND_ID, + BRAND_NAME, + LIKES_COUNT + ); + ProductInfo cachedProductInfo = ProductInfo.withoutRank(cachedProductDetail); + Long expectedRank = 3L; + + when(productCacheService.getCachedProduct(PRODUCT_ID)) + .thenReturn(cachedProductInfo); + when(rankingService.getProductRank(eq(PRODUCT_ID), any(LocalDate.class))) + .thenReturn(expectedRank); + + // act + ProductInfo result = catalogFacade.getProduct(PRODUCT_ID); + + // assert + assertThat(result.rank()).isEqualTo(expectedRank); + verify(rankingService).getProductRank(eq(PRODUCT_ID), any(LocalDate.class)); + verify(productEventPublisher).publish(any(ProductEvent.ProductViewed.class)); + verify(productService, never()).getProduct(any()); + } + + @Test + @DisplayName("캐시 히트 시 랭킹에 없는 상품은 null을 반환한다") + void getProduct_withCacheHit_noRanking_returnsNull() { + // arrange + ProductDetail cachedProductDetail = ProductDetail.of( + PRODUCT_ID, + PRODUCT_NAME, + PRODUCT_PRICE, + PRODUCT_STOCK, + BRAND_ID, + BRAND_NAME, + LIKES_COUNT + ); + ProductInfo cachedProductInfo = ProductInfo.withoutRank(cachedProductDetail); + + when(productCacheService.getCachedProduct(PRODUCT_ID)) + .thenReturn(cachedProductInfo); + when(rankingService.getProductRank(eq(PRODUCT_ID), any(LocalDate.class))) + .thenReturn(null); + + // act + ProductInfo result = catalogFacade.getProduct(PRODUCT_ID); + + // assert + assertThat(result.rank()).isNull(); + verify(rankingService).getProductRank(eq(PRODUCT_ID), any(LocalDate.class)); + } + + @Test + @DisplayName("캐시 미스 시 랭킹 정보가 포함된다") + void getProduct_withCacheMiss_includesRanking() { + // arrange + Product product = Product.of(PRODUCT_NAME, PRODUCT_PRICE, PRODUCT_STOCK, BRAND_ID); + setId(product, PRODUCT_ID); + + // Product.likeCount 설정 (리플렉션 사용) + try { + Field likeCountField = Product.class.getDeclaredField("likeCount"); + likeCountField.setAccessible(true); + likeCountField.set(product, LIKES_COUNT); + } catch (Exception e) { + throw new RuntimeException("Failed to set Product likeCount", e); + } + + Brand brand = Brand.of(BRAND_NAME); + setId(brand, BRAND_ID); + + Long expectedRank = 5L; + + when(productCacheService.getCachedProduct(PRODUCT_ID)) + .thenReturn(null); + when(productService.getProduct(PRODUCT_ID)) + .thenReturn(product); + when(brandService.getBrand(BRAND_ID)) + .thenReturn(brand); + when(rankingService.getProductRank(eq(PRODUCT_ID), any(LocalDate.class))) + .thenReturn(expectedRank); + when(productCacheService.applyLikeCountDelta(any(ProductInfo.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + ProductInfo result = catalogFacade.getProduct(PRODUCT_ID); + + // assert + assertThat(result.rank()).isEqualTo(expectedRank); + verify(rankingService).getProductRank(eq(PRODUCT_ID), any(LocalDate.class)); + verify(productEventPublisher).publish(any(ProductEvent.ProductViewed.class)); + verify(productService).getProduct(PRODUCT_ID); + verify(productCacheService).cacheProduct(eq(PRODUCT_ID), any(ProductInfo.class)); + } + + @Test + @DisplayName("캐시 미스 시 랭킹에 없는 상품은 null을 반환한다") + void getProduct_withCacheMiss_noRanking_returnsNull() { + // arrange + Product product = Product.of(PRODUCT_NAME, PRODUCT_PRICE, PRODUCT_STOCK, BRAND_ID); + setId(product, PRODUCT_ID); + + // Product.likeCount 설정 (리플렉션 사용) + try { + Field likeCountField = Product.class.getDeclaredField("likeCount"); + likeCountField.setAccessible(true); + likeCountField.set(product, LIKES_COUNT); + } catch (Exception e) { + throw new RuntimeException("Failed to set Product likeCount", e); + } + + Brand brand = Brand.of(BRAND_NAME); + setId(brand, BRAND_ID); + + when(productCacheService.getCachedProduct(PRODUCT_ID)) + .thenReturn(null); + when(productService.getProduct(PRODUCT_ID)) + .thenReturn(product); + when(brandService.getBrand(BRAND_ID)) + .thenReturn(brand); + when(rankingService.getProductRank(eq(PRODUCT_ID), any(LocalDate.class))) + .thenReturn(null); + when(productCacheService.applyLikeCountDelta(any(ProductInfo.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // act + ProductInfo result = catalogFacade.getProduct(PRODUCT_ID); + + // assert + assertThat(result.rank()).isNull(); + verify(rankingService).getProductRank(eq(PRODUCT_ID), any(LocalDate.class)); + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingServiceTest.java new file mode 100644 index 000000000..5bd82f939 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/ranking/RankingServiceTest.java @@ -0,0 +1,607 @@ +package com.loopers.application.ranking; + +import com.loopers.application.brand.BrandService; +import com.loopers.application.product.ProductService; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductDetail; +import com.loopers.zset.RedisZSetTemplate; +import com.loopers.zset.ZSetEntry; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.lang.reflect.Field; +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * RankingService 테스트. + */ +@ExtendWith(MockitoExtension.class) +class RankingServiceTest { + + @Mock + private RedisZSetTemplate zSetTemplate; + + @Mock + private RankingKeyGenerator keyGenerator; + + @Mock + private ProductService productService; + + @Mock + private BrandService brandService; + + @Mock + private RankingSnapshotService rankingSnapshotService; + + @InjectMocks + private RankingService rankingService; + + /** + * Product에 ID를 설정합니다 (리플렉션 사용). + */ + private void setId(Product product, Long id) { + try { + Field idField = product.getClass().getSuperclass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(product, id); + } catch (Exception e) { + throw new RuntimeException("Failed to set Product ID", e); + } + } + + /** + * Brand에 ID를 설정합니다 (리플렉션 사용). + */ + private void setId(Brand brand, Long id) { + try { + Field idField = brand.getClass().getSuperclass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(brand, id); + } catch (Exception e) { + throw new RuntimeException("Failed to set Brand ID", e); + } + } + + @DisplayName("랭킹을 조회할 수 있다.") + @Test + void canGetRankings() { + // arrange + LocalDate date = LocalDate.of(2024, 12, 15); + int page = 0; + int size = 20; + String key = "ranking:all:20241215"; + + Long productId1 = 1L; + Long productId2 = 2L; + Long brandId1 = 10L; + Long brandId2 = 20L; + + List entries = List.of( + new ZSetEntry(String.valueOf(productId1), 100.5), + new ZSetEntry(String.valueOf(productId2), 90.3) + ); + + Product product1 = Product.of("상품1", 10000, 10, brandId1); + Product product2 = Product.of("상품2", 20000, 5, brandId2); + Brand brand1 = Brand.of("브랜드1"); + Brand brand2 = Brand.of("브랜드2"); + + // ID 설정 + setId(product1, productId1); + setId(product2, productId2); + setId(brand1, brandId1); + setId(brand2, brandId2); + when(keyGenerator.generateDailyKey(date)).thenReturn(key); + when(zSetTemplate.getTopRankings(key, 0L, 19L)).thenReturn(entries); + when(zSetTemplate.getSize(key)).thenReturn(50L); + when(productService.getProducts(List.of(productId1, productId2))) + .thenReturn(List.of(product1, product2)); + when(brandService.getBrands(List.of(brandId1, brandId2))) + .thenReturn(List.of(brand1, brand2)); + + // act + RankingService.RankingsResponse result = rankingService.getRankings(date, page, size); + + // assert + assertThat(result.items()).hasSize(2); + assertThat(result.page()).isEqualTo(page); + assertThat(result.size()).isEqualTo(size); + assertThat(result.hasNext()).isTrue(); + + RankingService.RankingItem item1 = result.items().get(0); + assertThat(item1.rank()).isEqualTo(1L); + assertThat(item1.score()).isEqualTo(100.5); + assertThat(item1.productDetail().getId()).isEqualTo(productId1); + assertThat(item1.productDetail().getName()).isEqualTo("상품1"); + + RankingService.RankingItem item2 = result.items().get(1); + assertThat(item2.rank()).isEqualTo(2L); + assertThat(item2.score()).isEqualTo(90.3); + assertThat(item2.productDetail().getId()).isEqualTo(productId2); + assertThat(item2.productDetail().getName()).isEqualTo("상품2"); + } + + @DisplayName("빈 랭킹을 조회할 수 있다.") + @Test + void canGetEmptyRankings() { + // arrange + LocalDate date = LocalDate.of(2024, 12, 15); + int page = 0; + int size = 20; + String key = "ranking:all:20241215"; + + when(keyGenerator.generateDailyKey(date)).thenReturn(key); + when(zSetTemplate.getTopRankings(key, 0L, 19L)).thenReturn(List.of()); + + // act + RankingService.RankingsResponse result = rankingService.getRankings(date, page, size); + + // assert + assertThat(result.items()).isEmpty(); + assertThat(result.page()).isEqualTo(page); + assertThat(result.size()).isEqualTo(size); + assertThat(result.hasNext()).isFalse(); + verify(zSetTemplate, never()).getSize(anyString()); + } + + @DisplayName("페이징이 정상적으로 동작한다.") + @Test + void canGetRankingsWithPaging() { + // arrange + LocalDate date = LocalDate.of(2024, 12, 15); + int page = 2; + int size = 10; + String key = "ranking:all:20241215"; + + Long productId = 1L; + Long brandId = 10L; + + List entries = List.of( + new ZSetEntry(String.valueOf(productId), 100.0) + ); + + Product product = Product.of("상품", 10000, 10, brandId); + Brand brand = Brand.of("브랜드"); + + // ID 설정 + setId(product, productId); + setId(brand, brandId); + + when(keyGenerator.generateDailyKey(date)).thenReturn(key); + when(zSetTemplate.getTopRankings(key, 20L, 29L)).thenReturn(entries); + when(zSetTemplate.getSize(key)).thenReturn(31L); // 31 > 20 + 10이므로 다음 페이지 있음 + when(productService.getProducts(List.of(productId))).thenReturn(List.of(product)); + when(brandService.getBrands(List.of(brandId))).thenReturn(List.of(brand)); + + // act + RankingService.RankingsResponse result = rankingService.getRankings(date, page, size); + + // assert + assertThat(result.items()).hasSize(1); + assertThat(result.page()).isEqualTo(page); + assertThat(result.size()).isEqualTo(size); + assertThat(result.hasNext()).isTrue(); // 31 > 20 + 10 + + RankingService.RankingItem item = result.items().get(0); + assertThat(item.rank()).isEqualTo(21L); // start(20) + i(0) + 1 + } + + @DisplayName("랭킹에 포함된 상품이 DB에 없으면 스킵한다.") + @Test + void skipsProduct_whenProductNotFound() { + // arrange + LocalDate date = LocalDate.of(2024, 12, 15); + int page = 0; + int size = 20; + String key = "ranking:all:20241215"; + + Long productId1 = 1L; + Long productId2 = 999L; // 존재하지 않는 상품 + + List entries = List.of( + new ZSetEntry(String.valueOf(productId1), 100.0), + new ZSetEntry(String.valueOf(productId2), 90.0) + ); + + Product product1 = Product.of("상품1", 10000, 10, 10L); + Brand brand1 = Brand.of("브랜드1"); + + // ID 설정 + setId(product1, productId1); + setId(brand1, 10L); + + when(keyGenerator.generateDailyKey(date)).thenReturn(key); + when(zSetTemplate.getTopRankings(key, 0L, 19L)).thenReturn(entries); + when(zSetTemplate.getSize(key)).thenReturn(2L); + when(productService.getProducts(List.of(productId1, productId2))) + .thenReturn(List.of(product1)); // productId2는 없음 + when(brandService.getBrands(List.of(10L))).thenReturn(List.of(brand1)); + + // act + RankingService.RankingsResponse result = rankingService.getRankings(date, page, size); + + // assert + assertThat(result.items()).hasSize(1); // productId2는 스킵됨 + assertThat(result.items().get(0).productDetail().getId()).isEqualTo(productId1); + } + + @DisplayName("상품의 브랜드가 없으면 스킵한다.") + @Test + void skipsProduct_whenBrandNotFound() { + // arrange + LocalDate date = LocalDate.of(2024, 12, 15); + int page = 0; + int size = 20; + String key = "ranking:all:20241215"; + + Long productId1 = 1L; + Long productId2 = 2L; + Long brandId1 = 10L; + Long brandId2 = 999L; // 존재하지 않는 브랜드 + + List entries = List.of( + new ZSetEntry(String.valueOf(productId1), 100.0), + new ZSetEntry(String.valueOf(productId2), 90.0) + ); + + Product product1 = Product.of("상품1", 10000, 10, brandId1); + Product product2 = Product.of("상품2", 20000, 5, brandId2); + Brand brand1 = Brand.of("브랜드1"); + + // ID 설정 + setId(product1, productId1); + setId(product2, productId2); + setId(brand1, brandId1); + + when(keyGenerator.generateDailyKey(date)).thenReturn(key); + when(zSetTemplate.getTopRankings(key, 0L, 19L)).thenReturn(entries); + when(zSetTemplate.getSize(key)).thenReturn(2L); + when(productService.getProducts(List.of(productId1, productId2))) + .thenReturn(List.of(product1, product2)); + when(brandService.getBrands(List.of(brandId1, brandId2))) + .thenReturn(List.of(brand1)); // brandId2는 없음 + + // act + RankingService.RankingsResponse result = rankingService.getRankings(date, page, size); + + // assert + assertThat(result.items()).hasSize(1); // productId2는 브랜드가 없어서 스킵됨 + assertThat(result.items().get(0).productDetail().getId()).isEqualTo(productId1); + } + + @DisplayName("다음 페이지가 없을 때 hasNext가 false이다.") + @Test + void hasNextIsFalse_whenNoMorePages() { + // arrange + LocalDate date = LocalDate.of(2024, 12, 15); + int page = 0; + int size = 20; + String key = "ranking:all:20241215"; + + Long productId = 1L; + Long brandId = 10L; + + List entries = List.of( + new ZSetEntry(String.valueOf(productId), 100.0) + ); + + Product product = Product.of("상품", 10000, 10, brandId); + Brand brand = Brand.of("브랜드"); + + // ID 설정 + setId(product, productId); + setId(brand, brandId); + + when(keyGenerator.generateDailyKey(date)).thenReturn(key); + when(zSetTemplate.getTopRankings(key, 0L, 19L)).thenReturn(entries); + when(zSetTemplate.getSize(key)).thenReturn(1L); // 전체 크기가 1이므로 다음 페이지 없음 + when(productService.getProducts(List.of(productId))).thenReturn(List.of(product)); + when(brandService.getBrands(List.of(brandId))).thenReturn(List.of(brand)); + + // act + RankingService.RankingsResponse result = rankingService.getRankings(date, page, size); + + // assert + assertThat(result.hasNext()).isFalse(); // 1 <= 0 + 20 + } + + @DisplayName("특정 상품의 순위를 조회할 수 있다.") + @Test + void canGetProductRank() { + // arrange + Long productId = 1L; + LocalDate date = LocalDate.of(2024, 12, 15); + String key = "ranking:all:20241215"; + Long rank = 5L; // 0-based + + when(keyGenerator.generateDailyKey(date)).thenReturn(key); + when(zSetTemplate.getRank(key, String.valueOf(productId))).thenReturn(rank); + + // act + Long result = rankingService.getProductRank(productId, date); + + // assert + assertThat(result).isEqualTo(6L); // 1-based (5 + 1) + verify(keyGenerator).generateDailyKey(date); + verify(zSetTemplate).getRank(key, String.valueOf(productId)); + } + + @DisplayName("랭킹에 없는 상품의 순위는 null이다.") + @Test + void returnsNull_whenProductNotInRanking() { + // arrange + Long productId = 999L; + LocalDate date = LocalDate.of(2024, 12, 15); + String key = "ranking:all:20241215"; + + when(keyGenerator.generateDailyKey(date)).thenReturn(key); + when(zSetTemplate.getRank(key, String.valueOf(productId))).thenReturn(null); + + // act + Long result = rankingService.getProductRank(productId, date); + + // assert + assertThat(result).isNull(); + verify(keyGenerator).generateDailyKey(date); + verify(zSetTemplate).getRank(key, String.valueOf(productId)); + } + + @DisplayName("같은 브랜드의 여러 상품이 랭킹에 포함될 수 있다.") + @Test + void canHandleMultipleProductsFromSameBrand() { + // arrange + LocalDate date = LocalDate.of(2024, 12, 15); + int page = 0; + int size = 20; + String key = "ranking:all:20241215"; + + Long productId1 = 1L; + Long productId2 = 2L; + Long brandId = 10L; // 같은 브랜드 + + List entries = List.of( + new ZSetEntry(String.valueOf(productId1), 100.0), + new ZSetEntry(String.valueOf(productId2), 90.0) + ); + + Product product1 = Product.of("상품1", 10000, 10, brandId); + Product product2 = Product.of("상품2", 20000, 5, brandId); + Brand brand = Brand.of("브랜드"); + + // ID 설정 + setId(product1, productId1); + setId(product2, productId2); + setId(brand, brandId); + + when(keyGenerator.generateDailyKey(date)).thenReturn(key); + when(zSetTemplate.getTopRankings(key, 0L, 19L)).thenReturn(entries); + when(zSetTemplate.getSize(key)).thenReturn(2L); + when(productService.getProducts(List.of(productId1, productId2))) + .thenReturn(List.of(product1, product2)); + when(brandService.getBrands(List.of(brandId))) // 중복 제거되어 한 번만 조회 + .thenReturn(List.of(brand)); + + // act + RankingService.RankingsResponse result = rankingService.getRankings(date, page, size); + + // assert + assertThat(result.items()).hasSize(2); + assertThat(result.items().get(0).productDetail().getBrandId()).isEqualTo(brandId); + assertThat(result.items().get(1).productDetail().getBrandId()).isEqualTo(brandId); + // 브랜드는 한 번만 조회됨 (중복 제거) + verify(brandService).getBrands(List.of(brandId)); + } + + @DisplayName("Redis 장애 시 스냅샷으로 Fallback한다.") + @Test + void fallbackToSnapshot_whenRedisFails() { + // arrange + LocalDate date = LocalDate.of(2024, 12, 15); + int page = 0; + int size = 20; + String todayKey = "ranking:all:20241215"; + + Long productId = 1L; + Long brandId = 10L; + + Product product = Product.of("상품", 10000, 10, brandId); + Brand brand = Brand.of("브랜드"); + + // ID 설정 + setId(product, productId); + setId(brand, brandId); + + RankingService.RankingItem rankingItem = new RankingService.RankingItem( + 1L, 100.0, + ProductDetail.from(product, brand.getName(), product.getLikeCount()) + ); + RankingService.RankingsResponse snapshot = new RankingService.RankingsResponse( + List.of(rankingItem), page, size, false + ); + + when(keyGenerator.generateDailyKey(date)).thenReturn(todayKey); + + // 오늘 랭킹 조회 시 예외 발생 + when(zSetTemplate.getTopRankings(todayKey, 0L, 19L)) + .thenThrow(new org.springframework.dao.DataAccessException("Redis connection failed") {}); + + // 스냅샷 조회 성공 + when(rankingSnapshotService.getSnapshot(date)).thenReturn(java.util.Optional.of(snapshot)); + + // act + RankingService.RankingsResponse result = rankingService.getRankings(date, page, size); + + // assert + assertThat(result.items()).hasSize(1); + assertThat(result.items().get(0).productDetail().getId()).isEqualTo(productId); + verify(zSetTemplate).getTopRankings(todayKey, 0L, 19L); + verify(rankingSnapshotService).getSnapshot(date); + verify(rankingSnapshotService, never()).getSnapshot(date.minusDays(1)); + } + + @DisplayName("Redis 장애 시 스냅샷이 없으면 전날 스냅샷으로 Fallback한다.") + @Test + void fallbackToYesterdaySnapshot_whenSnapshotNotAvailable() { + // arrange + LocalDate date = LocalDate.of(2024, 12, 15); + LocalDate yesterday = date.minusDays(1); + int page = 0; + int size = 20; + String todayKey = "ranking:all:20241215"; + + Long productId = 1L; + Long brandId = 10L; + + Product product = Product.of("상품", 10000, 10, brandId); + Brand brand = Brand.of("브랜드"); + + // ID 설정 + setId(product, productId); + setId(brand, brandId); + + RankingService.RankingItem rankingItem = new RankingService.RankingItem( + 1L, 100.0, + ProductDetail.from(product, brand.getName(), product.getLikeCount()) + ); + RankingService.RankingsResponse yesterdaySnapshot = new RankingService.RankingsResponse( + List.of(rankingItem), page, size, false + ); + + when(keyGenerator.generateDailyKey(date)).thenReturn(todayKey); + + // 오늘 랭킹 조회 시 예외 발생 + when(zSetTemplate.getTopRankings(todayKey, 0L, 19L)) + .thenThrow(new org.springframework.dao.DataAccessException("Redis connection failed") {}); + + // 오늘 스냅샷 없음, 전날 스냅샷 있음 + when(rankingSnapshotService.getSnapshot(date)).thenReturn(java.util.Optional.empty()); + when(rankingSnapshotService.getSnapshot(yesterday)).thenReturn(java.util.Optional.of(yesterdaySnapshot)); + + // act + RankingService.RankingsResponse result = rankingService.getRankings(date, page, size); + + // assert + assertThat(result.items()).hasSize(1); + assertThat(result.items().get(0).productDetail().getId()).isEqualTo(productId); + verify(zSetTemplate).getTopRankings(todayKey, 0L, 19L); + verify(rankingSnapshotService).getSnapshot(date); + verify(rankingSnapshotService).getSnapshot(yesterday); + } + + @DisplayName("Redis 장애 시 스냅샷도 없으면 기본 랭킹(좋아요순)으로 Fallback한다.") + @Test + void fallbackToDefaultRanking_whenSnapshotNotAvailable() { + // arrange + LocalDate date = LocalDate.of(2024, 12, 15); + LocalDate yesterday = date.minusDays(1); + int page = 0; + int size = 20; + String todayKey = "ranking:all:20241215"; + + Long productId = 1L; + Long brandId = 10L; + + Product product = Product.of("상품", 10000, 10, brandId); + Brand brand = Brand.of("브랜드"); + + // ID 설정 + setId(product, productId); + setId(brand, brandId); + + when(keyGenerator.generateDailyKey(date)).thenReturn(todayKey); + + // 오늘 랭킹 조회 시 예외 발생 + when(zSetTemplate.getTopRankings(todayKey, 0L, 19L)) + .thenThrow(new org.springframework.dao.DataAccessException("Redis connection failed") {}); + + // 스냅샷도 없음 + when(rankingSnapshotService.getSnapshot(date)).thenReturn(java.util.Optional.empty()); + when(rankingSnapshotService.getSnapshot(yesterday)).thenReturn(java.util.Optional.empty()); + + // 기본 랭킹(좋아요순) 조회 + when(productService.findAll(null, "likes_desc", page, size)).thenReturn(List.of(product)); + when(productService.countAll(null)).thenReturn(1L); + when(brandService.getBrands(List.of(brandId))).thenReturn(List.of(brand)); + + // act + RankingService.RankingsResponse result = rankingService.getRankings(date, page, size); + + // assert + assertThat(result.items()).hasSize(1); + assertThat(result.items().get(0).productDetail().getId()).isEqualTo(productId); + assertThat(result.items().get(0).score()).isEqualTo(product.getLikeCount().doubleValue()); + verify(rankingSnapshotService).getSnapshot(date); + verify(rankingSnapshotService).getSnapshot(yesterday); + verify(productService).findAll(null, "likes_desc", page, size); + } + + @DisplayName("Redis 장애 시 상품 순위 조회도 전날 랭킹으로 Fallback한다.") + @Test + void fallbackToYesterdayRanking_whenGetProductRankFails() { + // arrange + Long productId = 1L; + LocalDate date = LocalDate.of(2024, 12, 15); + LocalDate yesterday = date.minusDays(1); + String todayKey = "ranking:all:20241215"; + String yesterdayKey = "ranking:all:20241214"; + Long rank = 5L; // 0-based + + when(keyGenerator.generateDailyKey(date)).thenReturn(todayKey); + when(keyGenerator.generateDailyKey(yesterday)).thenReturn(yesterdayKey); + + // 오늘 랭킹 조회 시 예외 발생 + when(zSetTemplate.getRank(todayKey, String.valueOf(productId))) + .thenThrow(new org.springframework.dao.DataAccessException("Redis connection failed") {}); + + // 전날 랭킹 조회 성공 + when(zSetTemplate.getRank(yesterdayKey, String.valueOf(productId))).thenReturn(rank); + + // act + Long result = rankingService.getProductRank(productId, date); + + // assert + assertThat(result).isEqualTo(6L); // 1-based (5 + 1) + verify(zSetTemplate).getRank(todayKey, String.valueOf(productId)); + verify(zSetTemplate).getRank(yesterdayKey, String.valueOf(productId)); + } + + @DisplayName("Redis 장애 시 상품 순위 조회도 전날 랭킹이 없으면 null을 반환한다.") + @Test + void returnsNull_whenRedisAndYesterdayRankingFail() { + // arrange + Long productId = 1L; + LocalDate date = LocalDate.of(2024, 12, 15); + LocalDate yesterday = date.minusDays(1); + String todayKey = "ranking:all:20241215"; + String yesterdayKey = "ranking:all:20241214"; + + when(keyGenerator.generateDailyKey(date)).thenReturn(todayKey); + when(keyGenerator.generateDailyKey(yesterday)).thenReturn(yesterdayKey); + + // 오늘 랭킹 조회 시 예외 발생 + when(zSetTemplate.getRank(todayKey, String.valueOf(productId))) + .thenThrow(new org.springframework.dao.DataAccessException("Redis connection failed") {}); + + // 전날 랭킹 조회도 예외 발생 + when(zSetTemplate.getRank(yesterdayKey, String.valueOf(productId))) + .thenThrow(new org.springframework.dao.DataAccessException("Redis connection failed") {}); + + // act + Long result = rankingService.getProductRank(productId, date); + + // assert + assertThat(result).isNull(); + verify(zSetTemplate).getRank(todayKey, String.valueOf(productId)); + verify(zSetTemplate).getRank(yesterdayKey, String.valueOf(productId)); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java b/apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java index ea4b4d15a..eb986acdd 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java @@ -4,11 +4,13 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.scheduling.annotation.EnableScheduling; import java.util.TimeZone; @ConfigurationPropertiesScan @SpringBootApplication +@EnableScheduling public class CommerceStreamerApplication { @PostConstruct public void started() { diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingEventHandler.java b/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingEventHandler.java new file mode 100644 index 000000000..49c078580 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingEventHandler.java @@ -0,0 +1,118 @@ +package com.loopers.application.ranking; + +import com.loopers.domain.event.LikeEvent; +import com.loopers.domain.event.OrderEvent; +import com.loopers.domain.event.ProductEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.ZoneId; + +/** + * 랭킹 이벤트 핸들러. + *

    + * 좋아요 추가/취소, 주문 생성, 상품 조회 이벤트를 받아 랭킹 점수를 집계하는 애플리케이션 로직을 처리합니다. + *

    + *

    + * DDD/EDA 관점: + *

      + *
    • 책임 분리: RankingService는 랭킹 점수 계산/적재, RankingEventHandler는 이벤트 처리 로직
    • + *
    • 이벤트 핸들러: 이벤트를 받아서 처리하는 역할을 명확히 나타냄
    • + *
    • 도메인 경계 준수: 랭킹은 파생 View로 취급하며, 도메인 이벤트를 구독하여 집계
    • + *
    + *

    + * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class RankingEventHandler { + + private final RankingService rankingService; + + /** + * 좋아요 추가 이벤트를 처리하여 랭킹 점수를 추가합니다. + * + * @param event 좋아요 추가 이벤트 + */ + public void handleLikeAdded(LikeEvent.LikeAdded event) { + log.debug("좋아요 추가 이벤트 처리: productId={}, userId={}", + event.productId(), event.userId()); + + LocalDate date = LocalDate.now(ZoneId.of("UTC")); + rankingService.addLikeScore(event.productId(), date, true); + + log.debug("좋아요 점수 추가 완료: productId={}", event.productId()); + } + + /** + * 좋아요 취소 이벤트를 처리하여 랭킹 점수를 차감합니다. + * + * @param event 좋아요 취소 이벤트 + */ + public void handleLikeRemoved(LikeEvent.LikeRemoved event) { + log.debug("좋아요 취소 이벤트 처리: productId={}, userId={}", + event.productId(), event.userId()); + + LocalDate date = LocalDate.now(ZoneId.of("UTC")); + rankingService.addLikeScore(event.productId(), date, false); + + log.debug("좋아요 점수 차감 완료: productId={}", event.productId()); + } + + /** + * 주문 생성 이벤트를 처리하여 랭킹 점수를 추가합니다. + *

    + * 주문 금액 계산: + *

      + *
    • OrderEvent.OrderCreated에는 개별 상품 가격 정보가 없음
    • + *
    • subtotal을 totalQuantity로 나눠서 평균 단가를 구하고, 각 아이템의 quantity를 곱함
    • + *
    • 향후 개선: 주문 이벤트에 개별 상품 가격 정보 추가
    • + *
    + *

    + * + * @param event 주문 생성 이벤트 + */ + public void handleOrderCreated(OrderEvent.OrderCreated event) { + log.debug("주문 생성 이벤트 처리: orderId={}", event.orderId()); + + LocalDate date = LocalDate.now(ZoneId.of("UTC")); + + // 주문 아이템별로 점수 집계 + // 주의: OrderEvent.OrderCreated에는 개별 상품 가격 정보가 없으므로 + // subtotal을 totalQuantity로 나눠서 평균 단가를 구하고, 각 아이템의 quantity를 곱함 + int totalQuantity = event.orderItems().stream() + .mapToInt(OrderEvent.OrderCreated.OrderItemInfo::quantity) + .sum(); + + if (totalQuantity > 0 && event.subtotal() != null) { + double averagePrice = (double) event.subtotal() / totalQuantity; + + for (OrderEvent.OrderCreated.OrderItemInfo item : event.orderItems()) { + double orderAmount = averagePrice * item.quantity(); + rankingService.addOrderScore(item.productId(), date, orderAmount); + } + } + + log.debug("주문 점수 추가 완료: orderId={}", event.orderId()); + } + + /** + * 상품 조회 이벤트를 처리하여 랭킹 점수를 추가합니다. + * + * @param event 상품 조회 이벤트 + */ + public void handleProductViewed(ProductEvent.ProductViewed event) { + log.debug("상품 조회 이벤트 처리: productId={}", event.productId()); + + LocalDate date = LocalDate.now(ZoneId.of("UTC")); + rankingService.addViewScore(event.productId(), date); + + log.debug("조회 점수 추가 완료: productId={}", event.productId()); + } +} + diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingKeyGenerator.java b/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingKeyGenerator.java new file mode 100644 index 000000000..583b8b7d7 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingKeyGenerator.java @@ -0,0 +1,35 @@ +package com.loopers.application.ranking; + +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +/** + * 랭킹 키 생성 유틸리티. + *

    + * Redis ZSET 랭킹 키를 생성합니다. + *

    + * + * @author Loopers + * @version 1.0 + */ +@Component +public class RankingKeyGenerator { + private static final String DAILY_KEY_PREFIX = "ranking:all:"; + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + + /** + * 일간 랭킹 키를 생성합니다. + *

    + * 예: ranking:all:20241215 + *

    + * + * @param date 날짜 + * @return 일간 랭킹 키 + */ + public String generateDailyKey(LocalDate date) { + String dateStr = date.format(DATE_FORMATTER); + return DAILY_KEY_PREFIX + dateStr; + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingService.java b/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingService.java new file mode 100644 index 000000000..f88096f5e --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/ranking/RankingService.java @@ -0,0 +1,165 @@ +package com.loopers.application.ranking; + +import com.loopers.zset.RedisZSetTemplate; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.time.LocalDate; +import java.util.Map; + +/** + * 랭킹 점수 계산 및 ZSET 적재 서비스. + *

    + * Kafka Consumer에서 이벤트를 수취하여 Redis ZSET에 랭킹 점수를 적재합니다. + *

    + *

    + * 설계 원칙: + *

      + *
    • Application 유즈케이스: Ranking은 도메인이 아닌 파생 View로 취급
    • + *
    • Eventually Consistent: 일시적인 지연/중복 허용
    • + *
    • CQRS Read Model: Write Side(도메인) → Kafka → Read Side(Application) → Redis ZSET
    • + *
    • 단순성: ZSetTemplate을 직접 사용하여 불필요한 추상화 제거
    • + *
    + *

    + *

    + * 점수 계산 공식: + *

      + *
    • 조회: Weight = 0.1, Score = 1
    • + *
    • 좋아요: Weight = 0.2, Score = 1
    • + *
    • 주문: Weight = 0.6, Score = price * amount (정규화: log(1 + amount))
    • + *
    + *

    + * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class RankingService { + private static final double VIEW_WEIGHT = 0.1; + private static final double LIKE_WEIGHT = 0.2; + private static final double ORDER_WEIGHT = 0.6; + private static final Duration TTL = Duration.ofDays(2); + + private final RedisZSetTemplate zSetTemplate; + private final RankingKeyGenerator keyGenerator; + + /** + * 조회 이벤트 점수를 ZSET에 추가합니다. + * + * @param productId 상품 ID + * @param date 날짜 + */ + public void addViewScore(Long productId, LocalDate date) { + String key = keyGenerator.generateDailyKey(date); + double score = VIEW_WEIGHT; + incrementScore(key, productId, score); + log.debug("조회 점수 추가: productId={}, date={}, score={}", productId, date, score); + } + + /** + * 좋아요 이벤트 점수를 ZSET에 추가/차감합니다. + * + * @param productId 상품 ID + * @param date 날짜 + * @param isAdded 좋아요 추가 여부 (true: 추가, false: 취소) + */ + public void addLikeScore(Long productId, LocalDate date, boolean isAdded) { + String key = keyGenerator.generateDailyKey(date); + double score = isAdded ? LIKE_WEIGHT : -LIKE_WEIGHT; + incrementScore(key, productId, score); + log.debug("좋아요 점수 {}: productId={}, date={}, score={}", + isAdded ? "추가" : "차감", productId, date, score); + } + + /** + * 주문 이벤트 점수를 ZSET에 추가합니다. + *

    + * 주문 금액을 기반으로 점수를 계산합니다. + * 정규화를 위해 log(1 + orderAmount)를 사용합니다. + *

    + * + * @param productId 상품 ID + * @param date 날짜 + * @param orderAmount 주문 금액 (price * quantity) + */ + public void addOrderScore(Long productId, LocalDate date, double orderAmount) { + String key = keyGenerator.generateDailyKey(date); + // 정규화: log(1 + orderAmount) 사용하여 큰 금액 차이를 완화 + double score = Math.log1p(orderAmount) * ORDER_WEIGHT; + incrementScore(key, productId, score); + log.debug("주문 점수 추가: productId={}, date={}, orderAmount={}, score={}", + productId, date, orderAmount, score); + } + + /** + * 배치로 점수를 적재합니다. + *

    + * 같은 배치 내에서 같은 상품의 여러 이벤트를 메모리에서 집계한 후 한 번에 적재합니다. + *

    + * + * @param scoreMap 상품 ID별 점수 맵 + * @param date 날짜 + */ + public void addScoresBatch(Map scoreMap, LocalDate date) { + if (scoreMap.isEmpty()) { + return; + } + + String key = keyGenerator.generateDailyKey(date); + for (Map.Entry entry : scoreMap.entrySet()) { + zSetTemplate.incrementScore(key, String.valueOf(entry.getKey()), entry.getValue()); + } + + // TTL 설정 (최초 1회만) + zSetTemplate.setTtlIfNotExists(key, TTL); + + log.debug("배치 점수 적재 완료: date={}, count={}", date, scoreMap.size()); + } + + /** + * Score Carry-Over: 오늘의 랭킹을 내일 랭킹에 일부 반영합니다. + *

    + * 콜드 스타트 문제를 완화하기 위해 오늘의 랭킹을 가중치를 적용하여 내일 랭킹에 반영합니다. + * 예: 오늘 랭킹의 10%를 내일 랭킹에 반영 + *

    + * + * @param today 오늘 날짜 + * @param tomorrow 내일 날짜 + * @param carryOverWeight Carry-Over 가중치 (예: 0.1 = 10%) + * @return 반영된 멤버 수 + */ + public Long carryOverScore(LocalDate today, LocalDate tomorrow, double carryOverWeight) { + String todayKey = keyGenerator.generateDailyKey(today); + String tomorrowKey = keyGenerator.generateDailyKey(tomorrow); + + // 오늘 랭킹을 가중치를 적용하여 내일 랭킹에 합산 + Long result = zSetTemplate.unionStoreWithWeight(tomorrowKey, todayKey, carryOverWeight); + + // TTL 설정 + zSetTemplate.setTtlIfNotExists(tomorrowKey, TTL); + + log.info("Score Carry-Over 완료: today={}, tomorrow={}, weight={}, memberCount={}", + today, tomorrow, carryOverWeight, result); + return result; + } + + /** + * ZSET에 점수를 증가시킵니다. + *

    + * 점수 계산 후 ZSetTemplate을 통해 Redis에 적재합니다. + *

    + * + * @param key ZSET 키 + * @param productId 상품 ID + * @param score 증가시킬 점수 + */ + private void incrementScore(String key, Long productId, double score) { + zSetTemplate.incrementScore(key, String.valueOf(productId), score); + // TTL 설정 (최초 1회만) + zSetTemplate.setTtlIfNotExists(key, TTL); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/RankingCarryOverScheduler.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/RankingCarryOverScheduler.java new file mode 100644 index 000000000..c23a29d4c --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/scheduler/RankingCarryOverScheduler.java @@ -0,0 +1,71 @@ +package com.loopers.infrastructure.scheduler; + +import com.loopers.application.ranking.RankingService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.ZoneId; + +/** + * 랭킹 Score Carry-Over 스케줄러. + *

    + * 매일 자정에 전날 랭킹을 오늘 랭킹에 일부 반영하여 콜드 스타트 문제를 완화합니다. + *

    + *

    + * 설계 원칙: + *

      + *
    • 콜드 스타트 완화: 매일 자정에 랭킹이 0점에서 시작하는 문제를 완화
    • + *
    • 가중치 적용: 전날 랭킹의 일부(예: 10%)만 반영하여 신선도 유지
    • + *
    • 에러 처리: Carry-Over 실패 시에도 다음 스케줄에서 재시도
    • + *
    + *

    + *

    + * 실행 시점: + *

      + *
    • 매일 자정(00:00:00)에 실행
    • + *
    • 전날(어제) 랭킹을 오늘 랭킹에 반영
    • + *
    + *

    + * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class RankingCarryOverScheduler { + + private static final double DEFAULT_CARRY_OVER_WEIGHT = 0.1; // 10% + + private final RankingService rankingService; + + /** + * 전날 랭킹을 오늘 랭킹에 일부 반영합니다. + *

    + * 매일 자정에 실행되어 어제 랭킹의 일부를 오늘 랭킹에 반영합니다. + *

    + */ + @Scheduled(cron = "0 0 0 * * ?") // 매일 자정 (00:00:00) + public void carryOverScore() { + LocalDate today = LocalDate.now(ZoneId.of("UTC")); + LocalDate yesterday = today.minusDays(1); + + try { + Long memberCount = rankingService.carryOverScore(yesterday, today, DEFAULT_CARRY_OVER_WEIGHT); + + log.info("랭킹 Score Carry-Over 완료: yesterday={}, today={}, weight={}, memberCount={}", + yesterday, today, DEFAULT_CARRY_OVER_WEIGHT, memberCount); + } catch (org.springframework.dao.DataAccessException e) { + log.warn("Redis 장애로 인한 랭킹 Score Carry-Over 실패: yesterday={}, today={}, error={}", + yesterday, today, e.getMessage()); + // Redis 장애 시 Carry-Over 스킵 (다음 스케줄에서 재시도) + } catch (Exception e) { + log.warn("랭킹 Score Carry-Over 실패: yesterday={}, today={}", yesterday, today, e); + // Carry-Over 실패는 다음 스케줄에서 재시도 + } + } +} + diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/RankingConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/RankingConsumer.java new file mode 100644 index 000000000..8c19d687d --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/RankingConsumer.java @@ -0,0 +1,389 @@ +package com.loopers.interfaces.consumer; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.eventhandled.EventHandledService; +import com.loopers.confg.kafka.KafkaConfig; +import com.loopers.domain.event.LikeEvent; +import com.loopers.domain.event.OrderEvent; +import com.loopers.domain.event.ProductEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.common.header.Header; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +/** + * 랭킹 집계 Kafka Consumer. + *

    + * Kafka에서 이벤트를 수취하여 Spring ApplicationEvent로 발행합니다. + * 조회, 좋아요, 주문 이벤트를 기반으로 실시간 랭킹을 구축합니다. + *

    + *

    + * 처리 이벤트: + *

      + *
    • like-events: LikeAdded, LikeRemoved (좋아요 점수 집계)
    • + *
    • order-events: OrderCreated (주문 점수 집계)
    • + *
    • product-events: ProductViewed (조회 점수 집계)
    • + *
    + *

    + *

    + * Manual Ack: + *

      + *
    • 이벤트 처리 성공 후 수동으로 커밋하여 At Most Once 보장
    • + *
    • 에러 발생 시 커밋하지 않아 재처리 가능
    • + *
    + *

    + *

    + * 설계 원칙: + *

      + *
    • 관심사 분리: Consumer는 Kafka 메시지 수신/파싱만 담당, 비즈니스 로직은 EventHandler에서 처리
    • + *
    • 이벤트 핸들러 패턴: Kafka Event → Spring ApplicationEvent → RankingEventListener → RankingEventHandler
    • + *
    • Eventually Consistent: 일시적인 지연/중복 허용
    • + *
    • CQRS Read Model: Write Side(도메인) → Kafka → Read Side(Application) → Redis ZSET
    • + *
    + *

    + * + * @author Loopers + * @version 2.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class RankingConsumer { + + private final ApplicationEventPublisher applicationEventPublisher; + private final EventHandledService eventHandledService; + private final ObjectMapper objectMapper; + + private static final String EVENT_ID_HEADER = "eventId"; + private static final String EVENT_TYPE_HEADER = "eventType"; + private static final String VERSION_HEADER = "version"; + + /** + * 개별 레코드 처리 로직을 정의하는 함수형 인터페이스. + */ + @FunctionalInterface + private interface RecordProcessor { + /** + * 개별 레코드를 처리합니다. + * + * @param record Kafka 메시지 레코드 + * @param eventId 이벤트 ID + * @return 처리된 이벤트 타입과 토픽 이름을 담은 EventProcessResult + * @throws Exception 처리 중 발생한 예외 + */ + EventProcessResult process(ConsumerRecord record, String eventId) throws Exception; + } + + /** + * 이벤트 처리 결과를 담는 레코드. + */ + private record EventProcessResult(String eventType, String topicName) { + } + + /** + * 공통 배치 처리 로직을 실행합니다. + *

    + * 멱등성 체크, 에러 처리, 배치 커밋 등의 공통 로직을 처리합니다. + *

    + * + * @param records Kafka 메시지 레코드 목록 + * @param acknowledgment 수동 커밋을 위한 Acknowledgment + * @param topicName 토픽 이름 (로깅 및 이벤트 기록용) + * @param processor 개별 레코드 처리 로직 + */ + private void processBatch( + List> records, + Acknowledgment acknowledgment, + String topicName, + RecordProcessor processor + ) { + try { + for (ConsumerRecord record : records) { + try { + String eventId = extractEventId(record); + if (eventId == null) { + log.warn("eventId가 없는 메시지는 건너뜁니다: offset={}, partition={}", + record.offset(), record.partition()); + continue; + } + + // 멱등성 체크: 이미 처리된 이벤트는 스킵 + if (eventHandledService.isAlreadyHandled(eventId)) { + log.debug("이미 처리된 이벤트 스킵: eventId={}", eventId); + continue; + } + + // 개별 레코드 처리 + EventProcessResult result = processor.process(record, eventId); + + // 이벤트 처리 기록 저장 + eventHandledService.markAsHandled(eventId, result.eventType(), result.topicName()); + } catch (org.springframework.dao.DataIntegrityViolationException e) { + // UNIQUE 제약조건 위반 = 동시성 상황에서 이미 처리됨 (정상) + log.debug("동시성 상황에서 이미 처리된 이벤트: offset={}, partition={}", + record.offset(), record.partition()); + } catch (Exception e) { + log.error("이벤트 처리 실패: topic={}, offset={}, partition={}", + topicName, record.offset(), record.partition(), e); + // 개별 이벤트 처리 실패는 로그만 기록하고 계속 진행 + } + } + + // 모든 이벤트 처리 완료 후 수동 커밋 + acknowledgment.acknowledge(); + log.debug("이벤트 처리 완료: topic={}, count={}", topicName, records.size()); + } catch (Exception e) { + log.error("배치 처리 실패: topic={}, count={}", topicName, records.size(), e); + // 에러 발생 시 커밋하지 않음 (재처리 가능) + throw e; + } + } + + /** + * like-events 토픽을 구독하여 좋아요 점수를 집계합니다. + *

    + * 멱등성 처리: + *

      + *
    • Kafka 메시지 헤더에서 `eventId`를 추출
    • + *
    • 이미 처리된 이벤트는 스킵하여 중복 처리 방지
    • + *
    • 처리 후 `event_handled` 테이블에 기록
    • + *
    + *

    + * + * @param records Kafka 메시지 레코드 목록 + * @param acknowledgment 수동 커밋을 위한 Acknowledgment + */ + @KafkaListener( + topics = "like-events", + containerFactory = KafkaConfig.BATCH_LISTENER + ) + public void consumeLikeEvents( + List> records, + Acknowledgment acknowledgment + ) { + processBatch(records, acknowledgment, "like-events", (record, eventId) -> { + Object value = record.value(); + String eventType; + + // Spring Kafka가 자동으로 역직렬화한 경우 + if (value instanceof LikeEvent.LikeAdded) { + LikeEvent.LikeAdded event = (LikeEvent.LikeAdded) value; + applicationEventPublisher.publishEvent(event); + eventType = "LikeAdded"; + } else if (value instanceof LikeEvent.LikeRemoved) { + LikeEvent.LikeRemoved event = (LikeEvent.LikeRemoved) value; + applicationEventPublisher.publishEvent(event); + eventType = "LikeRemoved"; + } else { + // JSON 문자열인 경우 이벤트 타입 헤더로 구분 + String eventTypeHeader = extractEventType(record); + if ("LikeRemoved".equals(eventTypeHeader)) { + LikeEvent.LikeRemoved event = parseLikeRemovedEvent(value); + applicationEventPublisher.publishEvent(event); + eventType = "LikeRemoved"; + } else { + // 기본값은 LikeAdded + LikeEvent.LikeAdded event = parseLikeEvent(value); + applicationEventPublisher.publishEvent(event); + eventType = "LikeAdded"; + } + } + + return new EventProcessResult(eventType, "like-events"); + }); + } + + /** + * order-events 토픽을 구독하여 주문 점수를 집계합니다. + *

    + * 멱등성 처리: + *

      + *
    • Kafka 메시지 헤더에서 `eventId`를 추출
    • + *
    • 이미 처리된 이벤트는 스킵하여 중복 처리 방지
    • + *
    • 처리 후 `event_handled` 테이블에 기록
    • + *
    + *

    + * + * @param records Kafka 메시지 레코드 목록 + * @param acknowledgment 수동 커밋을 위한 Acknowledgment + */ + @KafkaListener( + topics = "order-events", + containerFactory = KafkaConfig.BATCH_LISTENER + ) + public void consumeOrderEvents( + List> records, + Acknowledgment acknowledgment + ) { + processBatch(records, acknowledgment, "order-events", (record, eventId) -> { + Object value = record.value(); + OrderEvent.OrderCreated event = parseOrderCreatedEvent(value); + + // Spring ApplicationEvent 발행 (애플리케이션 내부 이벤트) + applicationEventPublisher.publishEvent(event); + + return new EventProcessResult("OrderCreated", "order-events"); + }); + } + + /** + * product-events 토픽을 구독하여 조회 점수를 집계합니다. + *

    + * 멱등성 처리: + *

      + *
    • Kafka 메시지 헤더에서 `eventId`를 추출
    • + *
    • 이미 처리된 이벤트는 스킵하여 중복 처리 방지
    • + *
    • 처리 후 `event_handled` 테이블에 기록
    • + *
    + *

    + * + * @param records Kafka 메시지 레코드 목록 + * @param acknowledgment 수동 커밋을 위한 Acknowledgment + */ + @KafkaListener( + topics = "product-events", + containerFactory = KafkaConfig.BATCH_LISTENER + ) + public void consumeProductEvents( + List> records, + Acknowledgment acknowledgment + ) { + processBatch(records, acknowledgment, "product-events", (record, eventId) -> { + Object value = record.value(); + ProductEvent.ProductViewed event = parseProductViewedEvent(value); + + // Spring ApplicationEvent 발행 (애플리케이션 내부 이벤트) + applicationEventPublisher.publishEvent(event); + + return new EventProcessResult("ProductViewed", "product-events"); + }); + } + + /** + * Kafka 메시지 값을 LikeAdded 이벤트로 파싱합니다. + * + * @param value Kafka 메시지 값 + * @return 파싱된 LikeAdded 이벤트 + */ + private LikeEvent.LikeAdded parseLikeEvent(Object value) { + try { + // JSON 문자열인 경우 파싱 + String json = value instanceof String ? (String) value : objectMapper.writeValueAsString(value); + return objectMapper.readValue(json, LikeEvent.LikeAdded.class); + } catch (Exception e) { + throw new RuntimeException("LikeAdded 이벤트 파싱 실패", e); + } + } + + /** + * Kafka 메시지 값을 LikeRemoved 이벤트로 파싱합니다. + * + * @param value Kafka 메시지 값 + * @return 파싱된 LikeRemoved 이벤트 + */ + private LikeEvent.LikeRemoved parseLikeRemovedEvent(Object value) { + try { + // JSON 문자열인 경우 파싱 + String json = value instanceof String ? (String) value : objectMapper.writeValueAsString(value); + return objectMapper.readValue(json, LikeEvent.LikeRemoved.class); + } catch (Exception e) { + throw new RuntimeException("LikeRemoved 이벤트 파싱 실패", e); + } + } + + /** + * Kafka 메시지 값을 OrderCreated 이벤트로 파싱합니다. + * + * @param value Kafka 메시지 값 + * @return 파싱된 OrderCreated 이벤트 + */ + private OrderEvent.OrderCreated parseOrderCreatedEvent(Object value) { + try { + if (value instanceof OrderEvent.OrderCreated) { + return (OrderEvent.OrderCreated) value; + } + + // JSON 문자열인 경우 파싱 + String json = value instanceof String ? (String) value : objectMapper.writeValueAsString(value); + return objectMapper.readValue(json, OrderEvent.OrderCreated.class); + } catch (Exception e) { + throw new RuntimeException("OrderCreated 이벤트 파싱 실패", e); + } + } + + /** + * Kafka 메시지 값을 ProductViewed 이벤트로 파싱합니다. + * + * @param value Kafka 메시지 값 + * @return 파싱된 ProductViewed 이벤트 + */ + private ProductEvent.ProductViewed parseProductViewedEvent(Object value) { + try { + if (value instanceof ProductEvent.ProductViewed) { + return (ProductEvent.ProductViewed) value; + } + + // JSON 문자열인 경우 파싱 + String json = value instanceof String ? (String) value : objectMapper.writeValueAsString(value); + return objectMapper.readValue(json, ProductEvent.ProductViewed.class); + } catch (Exception e) { + throw new RuntimeException("ProductViewed 이벤트 파싱 실패", e); + } + } + + /** + * Kafka 메시지 헤더에서 eventId를 추출합니다. + * + * @param record Kafka 메시지 레코드 + * @return eventId (없으면 null) + */ + private String extractEventId(ConsumerRecord record) { + Header header = record.headers().lastHeader(EVENT_ID_HEADER); + if (header != null && header.value() != null) { + return new String(header.value(), StandardCharsets.UTF_8); + } + return null; + } + + /** + * Kafka 메시지 헤더에서 eventType을 추출합니다. + * + * @param record Kafka 메시지 레코드 + * @return eventType (없으면 null) + */ + private String extractEventType(ConsumerRecord record) { + Header header = record.headers().lastHeader(EVENT_TYPE_HEADER); + if (header != null && header.value() != null) { + return new String(header.value(), StandardCharsets.UTF_8); + } + return null; + } + + /** + * Kafka 메시지 헤더에서 version을 추출합니다. + * + * @param record Kafka 메시지 레코드 + * @return version (없으면 null) + */ + private Long extractVersion(ConsumerRecord record) { + Header header = record.headers().lastHeader(VERSION_HEADER); + if (header != null && header.value() != null) { + try { + String versionStr = new String(header.value(), StandardCharsets.UTF_8); + return Long.parseLong(versionStr); + } catch (NumberFormatException e) { + log.warn("버전 헤더 파싱 실패: offset={}, partition={}", + record.offset(), record.partition()); + return null; + } + } + return null; + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/event/ranking/RankingEventListener.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/event/ranking/RankingEventListener.java new file mode 100644 index 000000000..b72cc4a48 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/event/ranking/RankingEventListener.java @@ -0,0 +1,121 @@ +package com.loopers.interfaces.event.ranking; + +import com.loopers.application.ranking.RankingEventHandler; +import com.loopers.domain.event.LikeEvent; +import com.loopers.domain.event.OrderEvent; +import com.loopers.domain.event.ProductEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +/** + * 랭킹 이벤트 리스너. + *

    + * 좋아요 추가/취소, 주문 생성, 상품 조회 이벤트를 받아서 랭킹 점수를 집계하는 인터페이스 레이어의 어댑터입니다. + *

    + *

    + * 레이어 역할: + *

      + *
    • 인터페이스 레이어: 외부 이벤트(도메인 이벤트)를 받아서 애플리케이션 핸들러를 호출하는 어댑터
    • + *
    • 비즈니스 로직 없음: 단순히 이벤트를 받아서 애플리케이션 핸들러를 호출하는 역할만 수행
    • + *
    + *

    + *

    + * EDA 원칙: + *

      + *
    • 비동기 처리: @Async로 집계 처리를 비동기로 실행하여 Kafka Consumer의 성능에 영향 없음
    • + *
    • 이벤트 기반: 좋아요, 주문, 조회 이벤트를 구독하여 랭킹 점수 집계
    • + *
    + *

    + * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class RankingEventListener { + + private final RankingEventHandler rankingEventHandler; + + /** + * 좋아요 추가 이벤트를 처리합니다. + *

    + * 비동기로 실행되어 랭킹 점수를 집계합니다. + *

    + * + * @param event 좋아요 추가 이벤트 + */ + @Async + @EventListener + public void handleLikeAdded(LikeEvent.LikeAdded event) { + try { + rankingEventHandler.handleLikeAdded(event); + } catch (Exception e) { + log.error("좋아요 추가 이벤트 처리 중 오류 발생: productId={}, userId={}", + event.productId(), event.userId(), e); + // 이벤트 처리 실패는 다른 리스너에 영향을 주지 않도록 예외를 삼킴 + } + } + + /** + * 좋아요 취소 이벤트를 처리합니다. + *

    + * 비동기로 실행되어 랭킹 점수를 차감합니다. + *

    + * + * @param event 좋아요 취소 이벤트 + */ + @Async + @EventListener + public void handleLikeRemoved(LikeEvent.LikeRemoved event) { + try { + rankingEventHandler.handleLikeRemoved(event); + } catch (Exception e) { + log.error("좋아요 취소 이벤트 처리 중 오류 발생: productId={}, userId={}", + event.productId(), event.userId(), e); + // 이벤트 처리 실패는 다른 리스너에 영향을 주지 않도록 예외를 삼킴 + } + } + + /** + * 주문 생성 이벤트를 처리합니다. + *

    + * 비동기로 실행되어 랭킹 점수를 집계합니다. + *

    + * + * @param event 주문 생성 이벤트 + */ + @Async + @EventListener + public void handleOrderCreated(OrderEvent.OrderCreated event) { + try { + rankingEventHandler.handleOrderCreated(event); + } catch (Exception e) { + log.error("주문 생성 이벤트 처리 중 오류 발생: orderId={}", event.orderId(), e); + // 이벤트 처리 실패는 다른 리스너에 영향을 주지 않도록 예외를 삼킴 + } + } + + /** + * 상품 조회 이벤트를 처리합니다. + *

    + * 비동기로 실행되어 랭킹 점수를 집계합니다. + *

    + * + * @param event 상품 조회 이벤트 + */ + @Async + @EventListener + public void handleProductViewed(ProductEvent.ProductViewed event) { + try { + rankingEventHandler.handleProductViewed(event); + } catch (Exception e) { + log.error("상품 조회 이벤트 처리 중 오류 발생: productId={}", event.productId(), e); + // 이벤트 처리 실패는 다른 리스너에 영향을 주지 않도록 예외를 삼킴 + } + } +} + diff --git a/apps/commerce-streamer/src/test/java/com/loopers/application/ranking/RankingEventHandlerTest.java b/apps/commerce-streamer/src/test/java/com/loopers/application/ranking/RankingEventHandlerTest.java new file mode 100644 index 000000000..a32182b98 --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/application/ranking/RankingEventHandlerTest.java @@ -0,0 +1,158 @@ +package com.loopers.application.ranking; + +import com.loopers.domain.event.LikeEvent; +import com.loopers.domain.event.OrderEvent; +import com.loopers.domain.event.ProductEvent; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * RankingEventHandler 테스트. + */ +@ExtendWith(MockitoExtension.class) +class RankingEventHandlerTest { + + @Mock + private RankingService rankingService; + + @InjectMocks + private RankingEventHandler rankingEventHandler; + + @DisplayName("좋아요 추가 이벤트를 처리할 수 있다.") + @Test + void canHandleLikeAdded() { + // arrange + Long productId = 1L; + Long userId = 100L; + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); + + // act + rankingEventHandler.handleLikeAdded(event); + + // assert + verify(rankingService).addLikeScore(eq(productId), any(LocalDate.class), eq(true)); + } + + @DisplayName("좋아요 취소 이벤트를 처리할 수 있다.") + @Test + void canHandleLikeRemoved() { + // arrange + Long productId = 1L; + Long userId = 100L; + LikeEvent.LikeRemoved event = new LikeEvent.LikeRemoved(userId, productId, LocalDateTime.now()); + + // act + rankingEventHandler.handleLikeRemoved(event); + + // assert + verify(rankingService).addLikeScore(eq(productId), any(LocalDate.class), eq(false)); + } + + @DisplayName("주문 생성 이벤트를 처리할 수 있다.") + @Test + void canHandleOrderCreated() { + // arrange + Long orderId = 1L; + Long userId = 100L; + OrderEvent.OrderCreated.OrderItemInfo item1 = + new OrderEvent.OrderCreated.OrderItemInfo(1L, 2); + OrderEvent.OrderCreated.OrderItemInfo item2 = + new OrderEvent.OrderCreated.OrderItemInfo(2L, 3); + OrderEvent.OrderCreated event = new OrderEvent.OrderCreated( + orderId, + userId, + null, // couponCode + 10000, // subtotal + null, // usedPointAmount + List.of(item1, item2), + LocalDateTime.now() + ); + + // act + rankingEventHandler.handleOrderCreated(event); + + // assert + // totalQuantity = 2 + 3 = 5 + // averagePrice = 10000 / 5 = 2000 + // item1: 2000 * 2 = 4000 + // item2: 2000 * 3 = 6000 + verify(rankingService).addOrderScore(eq(1L), any(LocalDate.class), eq(4000.0)); + verify(rankingService).addOrderScore(eq(2L), any(LocalDate.class), eq(6000.0)); + } + + @DisplayName("주문 아이템이 없으면 점수를 추가하지 않는다.") + @Test + void doesNothing_whenOrderItemsIsEmpty() { + // arrange + Long orderId = 1L; + Long userId = 100L; + OrderEvent.OrderCreated event = new OrderEvent.OrderCreated( + orderId, + userId, + null, // couponCode + 10000, // subtotal + null, // usedPointAmount + List.of(), + LocalDateTime.now() + ); + + // act + rankingEventHandler.handleOrderCreated(event); + + // assert + verify(rankingService, never()).addOrderScore(any(), any(), anyDouble()); + } + + @DisplayName("주문 subtotal이 null이면 점수를 추가하지 않는다.") + @Test + void doesNothing_whenSubtotalIsNull() { + // arrange + Long orderId = 1L; + Long userId = 100L; + OrderEvent.OrderCreated.OrderItemInfo item = + new OrderEvent.OrderCreated.OrderItemInfo(1L, 2); + OrderEvent.OrderCreated event = new OrderEvent.OrderCreated( + orderId, + userId, + null, // couponCode + null, // subtotal + null, // usedPointAmount + List.of(item), + LocalDateTime.now() + ); + + // act + rankingEventHandler.handleOrderCreated(event); + + // assert + verify(rankingService, never()).addOrderScore(any(), any(), anyDouble()); + } + + @DisplayName("상품 조회 이벤트를 처리할 수 있다.") + @Test + void canHandleProductViewed() { + // arrange + Long productId = 1L; + Long userId = 100L; + ProductEvent.ProductViewed event = new ProductEvent.ProductViewed(productId, userId, LocalDateTime.now()); + + // act + rankingEventHandler.handleProductViewed(event); + + // assert + verify(rankingService).addViewScore(eq(productId), any(LocalDate.class)); + } +} + diff --git a/apps/commerce-streamer/src/test/java/com/loopers/application/ranking/RankingServiceTest.java b/apps/commerce-streamer/src/test/java/com/loopers/application/ranking/RankingServiceTest.java new file mode 100644 index 000000000..ed3e67e23 --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/application/ranking/RankingServiceTest.java @@ -0,0 +1,296 @@ +package com.loopers.application.ranking; + +import com.loopers.zset.RedisZSetTemplate; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Duration; +import java.time.LocalDate; +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * RankingService 테스트. + */ +@ExtendWith(MockitoExtension.class) +class RankingServiceTest { + + @Mock + private RedisZSetTemplate zSetTemplate; + + @Mock + private RankingKeyGenerator keyGenerator; + + @InjectMocks + private RankingService rankingService; + + @DisplayName("조회 점수를 ZSET에 추가할 수 있다.") + @Test + void canAddViewScore() { + // arrange + Long productId = 1L; + LocalDate date = LocalDate.of(2024, 12, 15); + String expectedKey = "ranking:all:20241215"; + double expectedScore = 0.1; // VIEW_WEIGHT + + when(keyGenerator.generateDailyKey(date)).thenReturn(expectedKey); + + // act + rankingService.addViewScore(productId, date); + + // assert + verify(keyGenerator).generateDailyKey(date); + verify(zSetTemplate).incrementScore(eq(expectedKey), eq(String.valueOf(productId)), eq(expectedScore)); + verify(zSetTemplate).setTtlIfNotExists(eq(expectedKey), eq(Duration.ofDays(2))); + } + + @DisplayName("좋아요 추가 시 점수를 ZSET에 추가할 수 있다.") + @Test + void canAddLikeScore_whenAdded() { + // arrange + Long productId = 1L; + LocalDate date = LocalDate.of(2024, 12, 15); + String expectedKey = "ranking:all:20241215"; + double expectedScore = 0.2; // LIKE_WEIGHT + boolean isAdded = true; + + when(keyGenerator.generateDailyKey(date)).thenReturn(expectedKey); + + // act + rankingService.addLikeScore(productId, date, isAdded); + + // assert + verify(keyGenerator).generateDailyKey(date); + verify(zSetTemplate).incrementScore(eq(expectedKey), eq(String.valueOf(productId)), eq(expectedScore)); + verify(zSetTemplate).setTtlIfNotExists(eq(expectedKey), eq(Duration.ofDays(2))); + } + + @DisplayName("좋아요 취소 시 점수를 ZSET에서 차감할 수 있다.") + @Test + void canSubtractLikeScore_whenRemoved() { + // arrange + Long productId = 1L; + LocalDate date = LocalDate.of(2024, 12, 15); + String expectedKey = "ranking:all:20241215"; + double expectedScore = -0.2; // -LIKE_WEIGHT + boolean isAdded = false; + + when(keyGenerator.generateDailyKey(date)).thenReturn(expectedKey); + + // act + rankingService.addLikeScore(productId, date, isAdded); + + // assert + verify(keyGenerator).generateDailyKey(date); + verify(zSetTemplate).incrementScore(eq(expectedKey), eq(String.valueOf(productId)), eq(expectedScore)); + verify(zSetTemplate).setTtlIfNotExists(eq(expectedKey), eq(Duration.ofDays(2))); + } + + @DisplayName("주문 점수를 ZSET에 추가할 수 있다.") + @Test + void canAddOrderScore() { + // arrange + Long productId = 1L; + LocalDate date = LocalDate.of(2024, 12, 15); + String expectedKey = "ranking:all:20241215"; + double orderAmount = 10000.0; + // 정규화: log(1 + orderAmount) * ORDER_WEIGHT + // log(1 + 10000) ≈ 9.2103, 9.2103 * 0.6 ≈ 5.526 + double expectedScore = Math.log1p(orderAmount) * 0.6; // ORDER_WEIGHT = 0.6 + + when(keyGenerator.generateDailyKey(date)).thenReturn(expectedKey); + + // act + rankingService.addOrderScore(productId, date, orderAmount); + + // assert + verify(keyGenerator).generateDailyKey(date); + verify(zSetTemplate).incrementScore(eq(expectedKey), eq(String.valueOf(productId)), eq(expectedScore)); + verify(zSetTemplate).setTtlIfNotExists(eq(expectedKey), eq(Duration.ofDays(2))); + } + + @DisplayName("주문 금액이 0일 때도 정상적으로 처리된다.") + @Test + void canAddOrderScore_whenOrderAmountIsZero() { + // arrange + Long productId = 1L; + LocalDate date = LocalDate.of(2024, 12, 15); + String expectedKey = "ranking:all:20241215"; + double orderAmount = 0.0; + double expectedScore = Math.log1p(orderAmount) * 0.6; // log(1) * 0.6 = 0 + + when(keyGenerator.generateDailyKey(date)).thenReturn(expectedKey); + + // act + rankingService.addOrderScore(productId, date, orderAmount); + + // assert + verify(keyGenerator).generateDailyKey(date); + verify(zSetTemplate).incrementScore(eq(expectedKey), eq(String.valueOf(productId)), eq(expectedScore)); + verify(zSetTemplate).setTtlIfNotExists(eq(expectedKey), eq(Duration.ofDays(2))); + } + + @DisplayName("배치로 여러 상품의 점수를 한 번에 적재할 수 있다.") + @Test + void canAddScoresBatch() { + // arrange + LocalDate date = LocalDate.of(2024, 12, 15); + String expectedKey = "ranking:all:20241215"; + + Map scoreMap = new HashMap<>(); + scoreMap.put(1L, 10.5); + scoreMap.put(2L, 20.3); + scoreMap.put(3L, 15.7); + + when(keyGenerator.generateDailyKey(date)).thenReturn(expectedKey); + + // act + rankingService.addScoresBatch(scoreMap, date); + + // assert + verify(keyGenerator).generateDailyKey(date); + + // 각 상품에 대해 incrementScore 호출 확인 + verify(zSetTemplate).incrementScore(eq(expectedKey), eq("1"), eq(10.5)); + verify(zSetTemplate).incrementScore(eq(expectedKey), eq("2"), eq(20.3)); + verify(zSetTemplate).incrementScore(eq(expectedKey), eq("3"), eq(15.7)); + + // TTL 설정은 한 번만 호출 + verify(zSetTemplate, times(1)).setTtlIfNotExists(eq(expectedKey), eq(Duration.ofDays(2))); + } + + @DisplayName("빈 맵을 배치로 적재할 때는 아무 작업도 수행하지 않는다.") + @Test + void doesNothing_whenBatchIsEmpty() { + // arrange + LocalDate date = LocalDate.of(2024, 12, 15); + Map emptyScoreMap = new HashMap<>(); + + // act + rankingService.addScoresBatch(emptyScoreMap, date); + + // assert + verify(keyGenerator, never()).generateDailyKey(any()); + verify(zSetTemplate, never()).incrementScore(anyString(), anyString(), anyDouble()); + verify(zSetTemplate, never()).setTtlIfNotExists(anyString(), any(Duration.class)); + } + + @DisplayName("여러 날짜에 대해 독립적으로 점수를 추가할 수 있다.") + @Test + void canAddScoresForDifferentDates() { + // arrange + Long productId = 1L; + LocalDate date1 = LocalDate.of(2024, 12, 15); + LocalDate date2 = LocalDate.of(2024, 12, 16); + String key1 = "ranking:all:20241215"; + String key2 = "ranking:all:20241216"; + + when(keyGenerator.generateDailyKey(date1)).thenReturn(key1); + when(keyGenerator.generateDailyKey(date2)).thenReturn(key2); + + // act + rankingService.addViewScore(productId, date1); + rankingService.addViewScore(productId, date2); + + // assert + verify(keyGenerator).generateDailyKey(date1); + verify(keyGenerator).generateDailyKey(date2); + verify(zSetTemplate).incrementScore(eq(key1), eq(String.valueOf(productId)), eq(0.1)); + verify(zSetTemplate).incrementScore(eq(key2), eq(String.valueOf(productId)), eq(0.1)); + verify(zSetTemplate).setTtlIfNotExists(eq(key1), eq(Duration.ofDays(2))); + verify(zSetTemplate).setTtlIfNotExists(eq(key2), eq(Duration.ofDays(2))); + } + + @DisplayName("같은 상품에 여러 이벤트를 추가하면 점수가 누적된다.") + @Test + void accumulatesScoresForSameProduct() { + // arrange + Long productId = 1L; + LocalDate date = LocalDate.of(2024, 12, 15); + String expectedKey = "ranking:all:20241215"; + + when(keyGenerator.generateDailyKey(date)).thenReturn(expectedKey); + + // act + rankingService.addViewScore(productId, date); // +0.1 + rankingService.addLikeScore(productId, date, true); // +0.2 + rankingService.addOrderScore(productId, date, 1000.0); // +log(1001) * 0.6 + + // assert + verify(keyGenerator, times(3)).generateDailyKey(date); + + // 각 이벤트별로 incrementScore 호출 확인 + verify(zSetTemplate).incrementScore(eq(expectedKey), eq(String.valueOf(productId)), eq(0.1)); + verify(zSetTemplate).incrementScore(eq(expectedKey), eq(String.valueOf(productId)), eq(0.2)); + + ArgumentCaptor scoreCaptor = ArgumentCaptor.forClass(Double.class); + verify(zSetTemplate, times(3)).incrementScore(eq(expectedKey), eq(String.valueOf(productId)), scoreCaptor.capture()); + + // 주문 점수 계산 확인 + double orderScore = scoreCaptor.getAllValues().get(2); + double expectedOrderScore = Math.log1p(1000.0) * 0.6; + assertThat(orderScore).isCloseTo(expectedOrderScore, org.assertj.core.data.Offset.offset(0.001)); + + // TTL 설정은 각 호출마다 수행됨 (incrementScore 내부에서 호출) + verify(zSetTemplate, times(3)).setTtlIfNotExists(eq(expectedKey), eq(Duration.ofDays(2))); + } + + @DisplayName("Score Carry-Over로 오늘 랭킹을 내일 랭킹에 반영할 수 있다.") + @Test + void canCarryOverScore() { + // arrange + LocalDate today = LocalDate.of(2024, 12, 15); + LocalDate tomorrow = LocalDate.of(2024, 12, 16); + String todayKey = "ranking:all:20241215"; + String tomorrowKey = "ranking:all:20241216"; + double carryOverWeight = 0.1; // 10% + + when(keyGenerator.generateDailyKey(today)).thenReturn(todayKey); + when(keyGenerator.generateDailyKey(tomorrow)).thenReturn(tomorrowKey); + when(zSetTemplate.unionStoreWithWeight(eq(tomorrowKey), eq(todayKey), eq(carryOverWeight))) + .thenReturn(50L); + + // act + Long result = rankingService.carryOverScore(today, tomorrow, carryOverWeight); + + // assert + assertThat(result).isEqualTo(50L); + verify(keyGenerator).generateDailyKey(today); + verify(keyGenerator).generateDailyKey(tomorrow); + verify(zSetTemplate).unionStoreWithWeight(eq(tomorrowKey), eq(todayKey), eq(carryOverWeight)); + verify(zSetTemplate).setTtlIfNotExists(eq(tomorrowKey), eq(Duration.ofDays(2))); + } + + @DisplayName("Score Carry-Over 가중치가 0일 때도 정상적으로 처리된다.") + @Test + void canCarryOverScore_withZeroWeight() { + // arrange + LocalDate today = LocalDate.of(2024, 12, 15); + LocalDate tomorrow = LocalDate.of(2024, 12, 16); + String todayKey = "ranking:all:20241215"; + String tomorrowKey = "ranking:all:20241216"; + double carryOverWeight = 0.0; + + when(keyGenerator.generateDailyKey(today)).thenReturn(todayKey); + when(keyGenerator.generateDailyKey(tomorrow)).thenReturn(tomorrowKey); + when(zSetTemplate.unionStoreWithWeight(eq(tomorrowKey), eq(todayKey), eq(carryOverWeight))) + .thenReturn(0L); + + // act + Long result = rankingService.carryOverScore(today, tomorrow, carryOverWeight); + + // assert + assertThat(result).isEqualTo(0L); + verify(zSetTemplate).unionStoreWithWeight(eq(tomorrowKey), eq(todayKey), eq(carryOverWeight)); + } +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/RankingConsumerTest.java b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/RankingConsumerTest.java new file mode 100644 index 000000000..67485df03 --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/interfaces/consumer/RankingConsumerTest.java @@ -0,0 +1,450 @@ +package com.loopers.interfaces.consumer; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.eventhandled.EventHandledService; +import com.loopers.domain.event.LikeEvent; +import com.loopers.domain.event.OrderEvent; +import com.loopers.domain.event.ProductEvent; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.common.header.Headers; +import org.apache.kafka.common.header.internals.RecordHeader; +import org.apache.kafka.common.header.internals.RecordHeaders; +import org.apache.kafka.common.record.TimestampType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.kafka.support.Acknowledgment; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * RankingConsumer 테스트. + */ +@ExtendWith(MockitoExtension.class) +class RankingConsumerTest { + + @Mock + private ApplicationEventPublisher applicationEventPublisher; + + @Mock + private EventHandledService eventHandledService; + + @Mock + private ObjectMapper objectMapper; + + @Mock + private Acknowledgment acknowledgment; + + @InjectMocks + private RankingConsumer rankingConsumer; + + @DisplayName("LikeAdded 이벤트를 처리할 수 있다.") + @Test + void canConsumeLikeAddedEvent() { + // arrange + String eventId = "test-event-id"; + Long productId = 1L; + Long userId = 100L; + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); + + Headers headers = new RecordHeaders(); + headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); + + ConsumerRecord record = new ConsumerRecord<>( + "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty() + ); + List> records = List.of(record); + + when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false); + + // act + rankingConsumer.consumeLikeEvents(records, acknowledgment); + + // assert + verify(eventHandledService).isAlreadyHandled(eventId); + verify(applicationEventPublisher).publishEvent(any(LikeEvent.LikeAdded.class)); + verify(eventHandledService).markAsHandled(eventId, "LikeAdded", "like-events"); + verify(acknowledgment).acknowledge(); + } + + @DisplayName("LikeRemoved 이벤트를 처리할 수 있다.") + @Test + void canConsumeLikeRemovedEvent() { + // arrange + String eventId = "test-event-id-2"; + Long productId = 1L; + Long userId = 100L; + LikeEvent.LikeRemoved event = new LikeEvent.LikeRemoved(userId, productId, LocalDateTime.now()); + + Headers headers = new RecordHeaders(); + headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); + headers.add(new RecordHeader("eventType", "LikeRemoved".getBytes(StandardCharsets.UTF_8))); + + ConsumerRecord record = new ConsumerRecord<>( + "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty() + ); + List> records = List.of(record); + + when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false); + + // act + rankingConsumer.consumeLikeEvents(records, acknowledgment); + + // assert + verify(eventHandledService).isAlreadyHandled(eventId); + verify(applicationEventPublisher).publishEvent(any(LikeEvent.LikeRemoved.class)); + verify(eventHandledService).markAsHandled(eventId, "LikeRemoved", "like-events"); + verify(acknowledgment).acknowledge(); + } + + @DisplayName("OrderCreated 이벤트를 처리할 수 있다.") + @Test + void canConsumeOrderCreatedEvent() { + // arrange + String eventId = "test-event-id-3"; + Long orderId = 1L; + Long userId = 100L; + Long productId1 = 1L; + Long productId2 = 2L; + + List orderItems = List.of( + new OrderEvent.OrderCreated.OrderItemInfo(productId1, 3), + new OrderEvent.OrderCreated.OrderItemInfo(productId2, 2) + ); + + OrderEvent.OrderCreated event = new OrderEvent.OrderCreated( + orderId, userId, null, 10000, 0L, orderItems, LocalDateTime.now() + ); + + Headers headers = new RecordHeaders(); + headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); + + ConsumerRecord record = new ConsumerRecord<>( + "order-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty() + ); + List> records = List.of(record); + + when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false); + + // act + rankingConsumer.consumeOrderEvents(records, acknowledgment); + + // assert + verify(eventHandledService).isAlreadyHandled(eventId); + verify(applicationEventPublisher).publishEvent(any(OrderEvent.OrderCreated.class)); + verify(eventHandledService).markAsHandled(eventId, "OrderCreated", "order-events"); + verify(acknowledgment).acknowledge(); + } + + @DisplayName("ProductViewed 이벤트를 처리할 수 있다.") + @Test + void canConsumeProductViewedEvent() { + // arrange + String eventId = "test-event-id-4"; + Long productId = 1L; + Long userId = 100L; + ProductEvent.ProductViewed event = new ProductEvent.ProductViewed(productId, userId, LocalDateTime.now()); + + Headers headers = new RecordHeaders(); + headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); + + ConsumerRecord record = new ConsumerRecord<>( + "product-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty() + ); + List> records = List.of(record); + + when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false); + + // act + rankingConsumer.consumeProductEvents(records, acknowledgment); + + // assert + verify(eventHandledService).isAlreadyHandled(eventId); + verify(applicationEventPublisher).publishEvent(any(ProductEvent.ProductViewed.class)); + verify(eventHandledService).markAsHandled(eventId, "ProductViewed", "product-events"); + verify(acknowledgment).acknowledge(); + } + + @DisplayName("배치로 여러 이벤트를 처리할 수 있다.") + @Test + void canConsumeMultipleEvents() { + // arrange + String eventId1 = "test-event-id-5"; + String eventId2 = "test-event-id-6"; + Long productId = 1L; + Long userId = 100L; + + LikeEvent.LikeAdded event1 = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); + ProductEvent.ProductViewed event2 = new ProductEvent.ProductViewed(productId, userId, LocalDateTime.now()); + + Headers headers1 = new RecordHeaders(); + headers1.add(new RecordHeader("eventId", eventId1.getBytes(StandardCharsets.UTF_8))); + Headers headers2 = new RecordHeaders(); + headers2.add(new RecordHeader("eventId", eventId2.getBytes(StandardCharsets.UTF_8))); + + List> records = List.of( + new ConsumerRecord<>("like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event1, headers1, Optional.empty()), + new ConsumerRecord<>("product-events", 0, 1L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event2, headers2, Optional.empty()) + ); + + when(eventHandledService.isAlreadyHandled(eventId1)).thenReturn(false); + when(eventHandledService.isAlreadyHandled(eventId2)).thenReturn(false); + + // act + rankingConsumer.consumeLikeEvents(List.of(records.get(0)), acknowledgment); + rankingConsumer.consumeProductEvents(List.of(records.get(1)), acknowledgment); + + // assert + verify(eventHandledService).isAlreadyHandled(eventId1); + verify(eventHandledService).isAlreadyHandled(eventId2); + verify(applicationEventPublisher).publishEvent(any(LikeEvent.LikeAdded.class)); + verify(applicationEventPublisher).publishEvent(any(ProductEvent.ProductViewed.class)); + verify(eventHandledService).markAsHandled(eventId1, "LikeAdded", "like-events"); + verify(eventHandledService).markAsHandled(eventId2, "ProductViewed", "product-events"); + verify(acknowledgment, times(2)).acknowledge(); + } + + @DisplayName("이미 처리된 이벤트는 스킵한다.") + @Test + void skipsAlreadyHandledEvent() { + // arrange + String eventId = "test-event-id"; + Long productId = 1L; + Long userId = 100L; + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); + + Headers headers = new RecordHeaders(); + headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); + + ConsumerRecord record = new ConsumerRecord<>( + "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty() + ); + List> records = List.of(record); + + when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(true); + + // act + rankingConsumer.consumeLikeEvents(records, acknowledgment); + + // assert + verify(eventHandledService).isAlreadyHandled(eventId); + verify(applicationEventPublisher, never()).publishEvent(any()); + verify(eventHandledService, never()).markAsHandled(any(), any(), any()); + verify(acknowledgment).acknowledge(); + } + + @DisplayName("eventId가 없는 메시지는 건너뛴다.") + @Test + void skipsEventWithoutEventId() { + // arrange + Long productId = 1L; + Long userId = 100L; + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); + + ConsumerRecord record = new ConsumerRecord<>( + "like-events", 0, 0L, "key", event + ); + List> records = List.of(record); + + // act + rankingConsumer.consumeLikeEvents(records, acknowledgment); + + // assert + verify(eventHandledService, never()).isAlreadyHandled(any()); + verify(applicationEventPublisher, never()).publishEvent(any()); + verify(acknowledgment).acknowledge(); + } + + @DisplayName("개별 이벤트 처리 실패 시에도 배치 처리를 계속한다.") + @Test + void continuesProcessing_whenIndividualEventFails() { + // arrange + String eventId1 = "test-event-id-7"; + String eventId2 = "test-event-id-8"; + Long productId = 1L; + Long userId = 100L; + + LikeEvent.LikeAdded validEvent = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); + Object invalidEvent = "invalid-event"; + + Headers headers1 = new RecordHeaders(); + headers1.add(new RecordHeader("eventId", eventId1.getBytes(StandardCharsets.UTF_8))); + Headers headers2 = new RecordHeaders(); + headers2.add(new RecordHeader("eventId", eventId2.getBytes(StandardCharsets.UTF_8))); + + when(eventHandledService.isAlreadyHandled(eventId1)).thenReturn(false); + when(eventHandledService.isAlreadyHandled(eventId2)).thenReturn(false); + + List> records = List.of( + new ConsumerRecord<>("like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", invalidEvent, headers1, Optional.empty()), + new ConsumerRecord<>("like-events", 0, 1L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", validEvent, headers2, Optional.empty()) + ); + + // act + rankingConsumer.consumeLikeEvents(records, acknowledgment); + + // assert + verify(eventHandledService).isAlreadyHandled(eventId1); + verify(eventHandledService).isAlreadyHandled(eventId2); + // 첫 번째 이벤트는 파싱 실패로 publishEvent가 호출되지 않음 + // 두 번째 이벤트는 정상적으로 publishEvent가 호출됨 + verify(applicationEventPublisher, times(1)).publishEvent(any(LikeEvent.LikeAdded.class)); + verify(acknowledgment).acknowledge(); + } + + @DisplayName("동시성 상황에서 DataIntegrityViolationException이 발생하면 정상 처리로 간주한다.") + @Test + void handlesDataIntegrityViolationException() { + // arrange + String eventId = "test-event-id"; + Long productId = 1L; + Long userId = 100L; + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); + + Headers headers = new RecordHeaders(); + headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); + + ConsumerRecord record = new ConsumerRecord<>( + "like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty() + ); + List> records = List.of(record); + + when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false); + doThrow(new DataIntegrityViolationException("UNIQUE constraint violation")) + .when(eventHandledService).markAsHandled(eventId, "LikeAdded", "like-events"); + + // act + rankingConsumer.consumeLikeEvents(records, acknowledgment); + + // assert + verify(eventHandledService).isAlreadyHandled(eventId); + verify(applicationEventPublisher).publishEvent(any(LikeEvent.LikeAdded.class)); + verify(eventHandledService).markAsHandled(eventId, "LikeAdded", "like-events"); + verify(acknowledgment).acknowledge(); + } + + @DisplayName("주문 이벤트에서 totalQuantity가 0이면 점수를 추가하지 않는다.") + @Test + void doesNotAddScore_whenTotalQuantityIsZero() { + // arrange + String eventId = "test-event-id-9"; + Long orderId = 1L; + Long userId = 100L; + + List orderItems = List.of( + new OrderEvent.OrderCreated.OrderItemInfo(1L, 0) + ); + + OrderEvent.OrderCreated event = new OrderEvent.OrderCreated( + orderId, userId, null, 0, 0L, orderItems, LocalDateTime.now() + ); + + Headers headers = new RecordHeaders(); + headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); + + ConsumerRecord record = new ConsumerRecord<>( + "order-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty() + ); + List> records = List.of(record); + + when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false); + + // act + rankingConsumer.consumeOrderEvents(records, acknowledgment); + + // assert + verify(eventHandledService).isAlreadyHandled(eventId); + verify(applicationEventPublisher).publishEvent(any(OrderEvent.OrderCreated.class)); + verify(eventHandledService).markAsHandled(eventId, "OrderCreated", "order-events"); + verify(acknowledgment).acknowledge(); + } + + @DisplayName("주문 이벤트에서 subtotal이 null이면 점수를 추가하지 않는다.") + @Test + void doesNotAddScore_whenSubtotalIsNull() { + // arrange + String eventId = "test-event-id-10"; + Long orderId = 1L; + Long userId = 100L; + + List orderItems = List.of( + new OrderEvent.OrderCreated.OrderItemInfo(1L, 3) + ); + + OrderEvent.OrderCreated event = new OrderEvent.OrderCreated( + orderId, userId, null, null, 0L, orderItems, LocalDateTime.now() + ); + + Headers headers = new RecordHeaders(); + headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); + + ConsumerRecord record = new ConsumerRecord<>( + "order-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty() + ); + List> records = List.of(record); + + when(eventHandledService.isAlreadyHandled(eventId)).thenReturn(false); + + // act + rankingConsumer.consumeOrderEvents(records, acknowledgment); + + // assert + verify(eventHandledService).isAlreadyHandled(eventId); + verify(applicationEventPublisher).publishEvent(any(OrderEvent.OrderCreated.class)); + verify(eventHandledService).markAsHandled(eventId, "OrderCreated", "order-events"); + verify(acknowledgment).acknowledge(); + } + + @DisplayName("중복 메시지 재전송 시 한 번만 처리되어 멱등성이 보장된다.") + @Test + void handlesDuplicateMessagesIdempotently() { + // arrange + String eventId = "duplicate-event-id"; + Long productId = 1L; + Long userId = 100L; + LikeEvent.LikeAdded event = new LikeEvent.LikeAdded(userId, productId, LocalDateTime.now()); + + Headers headers = new RecordHeaders(); + headers.add(new RecordHeader("eventId", eventId.getBytes(StandardCharsets.UTF_8))); + + // 동일한 eventId를 가진 메시지 3개 생성 + List> records = List.of( + new ConsumerRecord<>("like-events", 0, 0L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty()), + new ConsumerRecord<>("like-events", 0, 1L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty()), + new ConsumerRecord<>("like-events", 0, 2L, 0L, TimestampType.CREATE_TIME, 0, 0, "key", event, headers, Optional.empty()) + ); + + // 첫 번째 메시지는 처리되지 않았으므로 false, 나머지는 이미 처리되었으므로 true + when(eventHandledService.isAlreadyHandled(eventId)) + .thenReturn(false) // 첫 번째: 처리됨 + .thenReturn(true) // 두 번째: 이미 처리됨 (스킵) + .thenReturn(true); // 세 번째: 이미 처리됨 (스킵) + + // act + rankingConsumer.consumeLikeEvents(records, acknowledgment); + + // assert + // isAlreadyHandled는 3번 호출됨 (각 메시지마다) + verify(eventHandledService, times(3)).isAlreadyHandled(eventId); + + // publishEvent는 한 번만 호출되어야 함 (첫 번째 메시지만 처리) + verify(applicationEventPublisher, times(1)).publishEvent(any(LikeEvent.LikeAdded.class)); + + // markAsHandled는 한 번만 호출되어야 함 (첫 번째 메시지만 처리) + verify(eventHandledService, times(1)).markAsHandled(eventId, "LikeAdded", "like-events"); + + // acknowledgment는 한 번만 호출되어야 함 (배치 처리 완료) + verify(acknowledgment, times(1)).acknowledge(); + } +} diff --git a/modules/redis/src/main/java/com/loopers/zset/RedisZSetTemplate.java b/modules/redis/src/main/java/com/loopers/zset/RedisZSetTemplate.java new file mode 100644 index 000000000..0b81e46a7 --- /dev/null +++ b/modules/redis/src/main/java/com/loopers/zset/RedisZSetTemplate.java @@ -0,0 +1,215 @@ +package com.loopers.zset; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +/** + * Redis ZSET 템플릿. + *

    + * Redis Sorted Set (ZSET) 조작 기능을 제공합니다. + * ZSET은 Redis 전용 데이터 구조이므로 인터페이스 분리 없이 클래스로 직접 제공합니다. + *

    + * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class RedisZSetTemplate { + + private final RedisTemplate redisTemplate; + + /** + * ZSET에 점수를 증가시킵니다. + *

    + * ZINCRBY는 원자적 연산이므로 동시성 문제가 없습니다. + *

    + * + * @param key ZSET 키 + * @param member 멤버 (예: 상품 ID) + * @param score 증가시킬 점수 + */ + public void incrementScore(String key, String member, double score) { + try { + redisTemplate.opsForZSet().incrementScore(key, member, score); + } catch (Exception e) { + log.warn("ZSET 점수 증가 실패: key={}, member={}, score={}", key, member, score, e); + // Redis 연결 실패 시 로그만 기록하고 계속 진행 + } + } + + /** + * ZSET의 TTL을 설정합니다. + *

    + * 이미 TTL이 설정되어 있으면 설정하지 않습니다. + *

    + * + * @param key ZSET 키 + * @param ttl TTL (Duration) + */ + public void setTtlIfNotExists(String key, Duration ttl) { + try { + Long currentTtl = redisTemplate.getExpire(key); + if (currentTtl == null || currentTtl == -1) { + // TTL이 없거나 -1(만료 시간 없음)인 경우에만 설정 + redisTemplate.expire(key, ttl); + } + } catch (Exception e) { + log.warn("ZSET TTL 설정 실패: key={}", key, e); + } + } + + /** + * 특정 멤버의 순위를 조회합니다. + *

    + * 점수가 높은 순서대로 정렬된 순위를 반환합니다 (0부터 시작). + * 멤버가 없으면 null을 반환합니다. + *

    + * + * @param key ZSET 키 + * @param member 멤버 + * @return 순위 (0부터 시작, 없으면 null) + * @throws org.springframework.dao.DataAccessException Redis 접근 실패 시 + */ + public Long getRank(String key, String member) { + return redisTemplate.opsForZSet().reverseRank(key, member); + } + + /** + * ZSET에서 상위 N개 멤버를 조회합니다. + *

    + * 점수가 높은 순서대로 정렬된 멤버와 점수를 반환합니다. + *

    + * + * @param key ZSET 키 + * @param start 시작 인덱스 (0부터 시작) + * @param end 종료 인덱스 (포함) + * @return 멤버와 점수 쌍의 리스트 + * @throws org.springframework.dao.DataAccessException Redis 접근 실패 시 + */ + public List getTopRankings(String key, long start, long end) { + Set> tuples = redisTemplate.opsForZSet() + .reverseRangeWithScores(key, start, end); + + if (tuples == null) { + return List.of(); + } + + List entries = new ArrayList<>(); + for (ZSetOperations.TypedTuple tuple : tuples) { + entries.add(new ZSetEntry(tuple.getValue(), tuple.getScore())); + } + return entries; + } + + /** + * ZSET의 크기를 조회합니다. + *

    + * ZSET에 포함된 멤버의 총 개수를 반환합니다. + *

    + * + * @param key ZSET 키 + * @return ZSET 크기 (없으면 0) + * @throws org.springframework.dao.DataAccessException Redis 접근 실패 시 + */ + public Long getSize(String key) { + Long size = redisTemplate.opsForZSet().size(key); + return size != null ? size : 0L; + } + + /** + * 여러 ZSET을 합쳐서 새로운 ZSET을 생성합니다. + *

    + * ZUNIONSTORE 명령어를 사용하여 여러 소스 ZSET의 점수를 합산합니다. + * 같은 멤버가 여러 ZSET에 있으면 점수가 합산됩니다. + *

    + *

    + * 사용 사례: + *

      + *
    • 시간 단위 랭킹을 일간 랭킹으로 집계
    • + *
    • Score Carry-Over: 오늘 랭킹을 내일 랭킹에 일부 반영
    • + *
    + *

    + * + * @param destination 목적지 ZSET 키 + * @param sourceKeys 소스 ZSET 키 목록 + * @return 합쳐진 ZSET의 멤버 수 + */ + public Long unionStore(String destination, List sourceKeys) { + try { + if (sourceKeys.isEmpty()) { + log.warn("소스 키가 비어있습니다: destination={}", destination); + return 0L; + } + + Long result = redisTemplate.opsForZSet().unionAndStore( + sourceKeys.get(0), + sourceKeys.subList(1, sourceKeys.size()), + destination + ); + return result != null ? result : 0L; + } catch (Exception e) { + log.warn("ZSET 합치기 실패: destination={}, sourceKeys={}", destination, sourceKeys, e); + return 0L; + } + } + + /** + * 단일 ZSET을 가중치를 적용하여 목적지 ZSET에 합산합니다. + *

    + * 소스 ZSET의 점수에 가중치를 곱한 후 목적지 ZSET에 합산합니다. + * 목적지 ZSET이 이미 존재하면 기존 점수에 합산됩니다. + *

    + *

    + * 사용 사례: + *

      + *
    • Score Carry-Over: 오늘 랭킹을 0.1 배율로 내일 랭킹에 반영
    • + *
    + *

    + * + * @param destination 목적지 ZSET 키 + * @param sourceKey 소스 ZSET 키 + * @param weight 가중치 (예: 0.1 = 10%) + * @return 합쳐진 ZSET의 멤버 수 + */ + public Long unionStoreWithWeight(String destination, String sourceKey, double weight) { + try { + // ZUNIONSTORE를 사용하여 가중치 적용 + // destination과 sourceKey를 합치되, sourceKey에만 가중치 적용 + // 이를 위해 임시 키를 사용하거나 직접 구현 + + // 방법: sourceKey의 모든 멤버를 읽어서 가중치를 적용한 후 destination에 추가 + Set> sourceMembers = redisTemplate.opsForZSet() + .rangeWithScores(sourceKey, 0, -1); + + if (sourceMembers == null || sourceMembers.isEmpty()) { + return 0L; + } + + // 가중치를 적용하여 destination에 추가 + for (ZSetOperations.TypedTuple tuple : sourceMembers) { + String member = tuple.getValue(); + Double originalScore = tuple.getScore(); + if (member != null && originalScore != null) { + double weightedScore = originalScore * weight; + redisTemplate.opsForZSet().incrementScore(destination, member, weightedScore); + } + } + + return (long) sourceMembers.size(); + } catch (Exception e) { + log.warn("ZSET 가중치 합치기 실패: destination={}, sourceKey={}, weight={}", + destination, sourceKey, weight, e); + return 0L; + } + } +} diff --git a/modules/redis/src/main/java/com/loopers/zset/ZSetEntry.java b/modules/redis/src/main/java/com/loopers/zset/ZSetEntry.java new file mode 100644 index 000000000..0c9642503 --- /dev/null +++ b/modules/redis/src/main/java/com/loopers/zset/ZSetEntry.java @@ -0,0 +1,12 @@ +package com.loopers.zset; + +/** + * ZSET 엔트리 (멤버와 점수 쌍). + * + * @param member 멤버 + * @param score 점수 + * @author Loopers + * @version 1.0 + */ +public record ZSetEntry(String member, Double score) { +} From d4cecccb34b505a85fbe617806b98527a10e29d4 Mon Sep 17 00:00:00 2001 From: minor7295 <44902090+minor7295@users.noreply.github.com> Date: Fri, 2 Jan 2026 03:03:57 +0900 Subject: [PATCH 11/12] Feature/batch (#40) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: batch 처리 모듇 분리 * feat: batch 모듈에 ProductMetrics 도메인 추가 * feat: ProudctMetrics의 Repository 추가 * test: Product Metrics 배치 작업에 대한 테스트 코드 추가 * feat: ProductMetrics 배치 작업 구현 * test: Product Rank에 대한 테스트 코드 추가 * feat: Product Rank 도메인 구현 * feat: Product Rank Repository 추가 * test: Product Rank 배치에 대한 테스트 코드 추가 * feat: Product Rank 배치 작업 추가 * feat: 일간, 주간, 월간 랭킹을 제공하는 api 추가 * refractor: 랭킹 집계 로직을 여러 step으로 분리함 * chore: db 초기화 로직에서 발생하는 오류 수정 * test: 랭킹 집계의 각 step에 대한 테스트 코드 추가 --- apps/commerce-api/build.gradle.kts | 3 - .../application/ranking/RankingService.java | 182 ++++++++++++ .../com/loopers/domain/rank/ProductRank.java | 119 ++++++++ .../domain/rank/ProductRankRepository.java | 39 +++ .../rank/ProductRankRepositoryImpl.java | 63 +++++ .../api/ranking/RankingV1Controller.java | 39 ++- .../src/main/resources/application.yml | 5 - apps/commerce-batch/build.gradle.kts | 22 ++ .../java/com/loopers/BatchApplication.java | 34 +++ .../domain/metrics/ProductMetrics.java | 134 +++++++++ .../metrics/ProductMetricsRepository.java | 86 ++++++ .../com/loopers/domain/rank/ProductRank.java | 166 +++++++++++ .../domain/rank/ProductRankRepository.java | 59 ++++ .../loopers/domain/rank/ProductRankScore.java | 141 ++++++++++ .../rank/ProductRankScoreRepository.java | 68 +++++ .../metrics/ProductMetricsItemProcessor.java | 45 +++ .../metrics/ProductMetricsItemReader.java | 111 ++++++++ .../metrics/ProductMetricsItemWriter.java | 58 ++++ .../metrics/ProductMetricsJobConfig.java | 148 ++++++++++ .../rank/ProductRankAggregationProcessor.java | 74 +++++ .../rank/ProductRankAggregationReader.java | 123 ++++++++ .../rank/ProductRankCalculationProcessor.java | 87 ++++++ .../rank/ProductRankCalculationReader.java | 72 +++++ .../rank/ProductRankCalculationWriter.java | 82 ++++++ .../batch/rank/ProductRankJobConfig.java | 257 +++++++++++++++++ .../ProductRankScoreAggregationWriter.java | 170 +++++++++++ .../metrics/ProductMetricsJpaRepository.java | 58 ++++ .../metrics/ProductMetricsRepositoryImpl.java | 73 +++++ .../rank/ProductRankRepositoryImpl.java | 95 +++++++ .../rank/ProductRankScoreRepositoryImpl.java | 100 +++++++ .../src/main/resources/application.yml | 43 +++ .../domain/metrics/ProductMetricsTest.java | 217 +++++++++++++++ .../loopers/domain/rank/ProductRankTest.java | 235 ++++++++++++++++ .../ProductMetricsItemProcessorTest.java | 87 ++++++ .../metrics/ProductMetricsItemReaderTest.java | 134 +++++++++ .../metrics/ProductMetricsItemWriterTest.java | 118 ++++++++ .../ProductRankAggregationProcessorTest.java | 121 ++++++++ .../ProductRankAggregationReaderTest.java | 152 ++++++++++ .../ProductRankCalculationProcessorTest.java | 263 ++++++++++++++++++ ...ProductRankScoreAggregationWriterTest.java | 251 +++++++++++++++++ .../com/loopers/utils/DatabaseCleanUp.java | 18 +- settings.gradle.kts | 1 + 42 files changed, 4342 insertions(+), 11 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRank.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRankRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/ProductRankRepositoryImpl.java create mode 100644 apps/commerce-batch/build.gradle.kts create mode 100644 apps/commerce-batch/src/main/java/com/loopers/BatchApplication.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRank.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScore.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScoreRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemProcessor.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemReader.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemWriter.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsJobConfig.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationProcessor.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReader.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessor.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationReader.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationWriter.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankJobConfig.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriter.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankRepositoryImpl.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankScoreRepositoryImpl.java create mode 100644 apps/commerce-batch/src/main/resources/application.yml create mode 100644 apps/commerce-batch/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/domain/rank/ProductRankTest.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemProcessorTest.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemReaderTest.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemWriterTest.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationProcessorTest.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReaderTest.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessorTest.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriterTest.java diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 3ba4f7df5..f4d3b583a 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -24,9 +24,6 @@ dependencies { implementation("io.github.resilience4j:resilience4j-bulkhead") // Bulkheads 패턴 구현 implementation("org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j") - // batch - implementation("org.springframework.boot:spring-boot-starter-batch") - // querydsl annotationProcessor("com.querydsl:querydsl-apt::jakarta") annotationProcessor("jakarta.persistence:jakarta.persistence-api") diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java index df6305b83..d4b0d38d2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java @@ -46,10 +46,49 @@ public class RankingService { private final ProductService productService; private final BrandService brandService; private final RankingSnapshotService rankingSnapshotService; + private final com.loopers.domain.rank.ProductRankRepository productRankRepository; /** * 랭킹을 조회합니다 (페이징). *

    + * 기간별(일간/주간/월간) 랭킹을 조회합니다. + *

    + *

    + * 기간별 조회 방식: + *

      + *
    • DAILY: Redis ZSET에서 조회 (기존 방식)
    • + *
    • WEEKLY: Materialized View에서 조회
    • + *
    • MONTHLY: Materialized View에서 조회
    • + *
    + *

    + *

    + * Graceful Degradation (DAILY만 적용): + *

      + *
    • Redis 장애 시 스냅샷으로 Fallback
    • + *
    • 스냅샷도 없으면 기본 랭킹(좋아요순) 제공 (단순 조회, 계산 아님)
    • + *
    + *

    + * + * @param date 날짜 (yyyyMMdd 형식의 문자열 또는 LocalDate) + * @param periodType 기간 타입 (DAILY, WEEKLY, MONTHLY) + * @param page 페이지 번호 (0부터 시작) + * @param size 페이지당 항목 수 + * @return 랭킹 조회 결과 + */ + @Transactional(readOnly = true) + public RankingsResponse getRankings(LocalDate date, PeriodType periodType, int page, int size) { + if (periodType == PeriodType.DAILY) { + // 일간 랭킹: 기존 Redis 방식 + return getRankings(date, page, size); + } else { + // 주간/월간 랭킹: Materialized View에서 조회 + return getRankingsFromMaterializedView(date, periodType, page, size); + } + } + + /** + * 랭킹을 조회합니다 (페이징) - 일간 랭킹 전용. + *

    * ZSET에서 상위 N개를 조회하고, 상품 정보를 Aggregation하여 반환합니다. *

    *

    @@ -304,6 +343,149 @@ private Long getProductRankFromRedis(Long productId, LocalDate date) { return rank + 1; } + /** + * Materialized View에서 주간/월간 랭킹을 조회합니다. + *

    + * Materialized View에 저장된 TOP 100 랭킹을 조회하고, 상품 정보를 Aggregation하여 반환합니다. + *

    + * + * @param date 기준 날짜 + * @param periodType 기간 타입 (WEEKLY 또는 MONTHLY) + * @param page 페이지 번호 (0부터 시작) + * @param size 페이지당 항목 수 + * @return 랭킹 조회 결과 + */ + private RankingsResponse getRankingsFromMaterializedView( + LocalDate date, + PeriodType periodType, + int page, + int size + ) { + // 기간 시작일 계산 + LocalDate periodStartDate; + if (periodType == PeriodType.WEEKLY) { + // 주간: 해당 주의 월요일 + periodStartDate = date.with(java.time.DayOfWeek.MONDAY); + } else { + // 월간: 해당 월의 1일 + periodStartDate = date.with(java.time.temporal.TemporalAdjusters.firstDayOfMonth()); + } + + // Materialized View에서 랭킹 조회 + com.loopers.domain.rank.ProductRank.PeriodType rankPeriodType = + periodType == PeriodType.WEEKLY + ? com.loopers.domain.rank.ProductRank.PeriodType.WEEKLY + : com.loopers.domain.rank.ProductRank.PeriodType.MONTHLY; + + List ranks = productRankRepository.findByPeriod( + rankPeriodType, periodStartDate, 100 + ); + + if (ranks.isEmpty()) { + return RankingsResponse.empty(page, size); + } + + // 페이징 처리 + long start = (long) page * size; + long end = Math.min(start + size, ranks.size()); + + if (start >= ranks.size()) { + return RankingsResponse.empty(page, size); + } + + List pagedRanks = ranks.subList((int) start, (int) end); + + // 상품 ID 추출 + List productIds = pagedRanks.stream() + .map(com.loopers.domain.rank.ProductRank::getProductId) + .toList(); + + // 상품 정보 배치 조회 + List products = productService.getProducts(productIds); + + // 상품 ID → Product Map 생성 + Map productMap = products.stream() + .collect(Collectors.toMap(Product::getId, product -> product)); + + // 브랜드 ID 수집 + List brandIds = products.stream() + .map(Product::getBrandId) + .distinct() + .toList(); + + // 브랜드 배치 조회 + Map brandMap = brandService.getBrands(brandIds).stream() + .collect(Collectors.toMap(Brand::getId, brand -> brand)); + + // 랭킹 항목 생성 + List rankingItems = new ArrayList<>(); + for (com.loopers.domain.rank.ProductRank rank : pagedRanks) { + Long productId = rank.getProductId(); + Product product = productMap.get(productId); + + if (product == null) { + log.warn("랭킹에 포함된 상품을 찾을 수 없습니다: productId={}", productId); + continue; + } + + Brand brand = brandMap.get(product.getBrandId()); + if (brand == null) { + log.warn("상품의 브랜드를 찾을 수 없습니다: productId={}, brandId={}", + productId, product.getBrandId()); + continue; + } + + ProductDetail productDetail = ProductDetail.from( + product, + brand.getName(), + rank.getLikeCount() + ); + + // 종합 점수 계산 (Materialized View에는 저장되지 않으므로 계산) + double score = calculateScore(rank.getLikeCount(), rank.getSalesCount(), rank.getViewCount()); + + rankingItems.add(new RankingItem( + rank.getRank().longValue(), + score, + productDetail + )); + } + + boolean hasNext = end < ranks.size(); + return new RankingsResponse(rankingItems, page, size, hasNext); + } + + /** + * 종합 점수를 계산합니다. + *

    + * 가중치: + *

      + *
    • 좋아요: 0.3
    • + *
    • 판매량: 0.5
    • + *
    • 조회수: 0.2
    • + *
    + *

    + * + * @param likeCount 좋아요 수 + * @param salesCount 판매량 + * @param viewCount 조회 수 + * @return 종합 점수 + */ + private double calculateScore(Long likeCount, Long salesCount, Long viewCount) { + return (likeCount != null ? likeCount : 0L) * 0.3 + + (salesCount != null ? salesCount : 0L) * 0.5 + + (viewCount != null ? viewCount : 0L) * 0.2; + } + + /** + * 기간 타입 열거형. + */ + public enum PeriodType { + DAILY, // 일간 + WEEKLY, // 주간 + MONTHLY // 월간 + } + /** * 랭킹 조회 결과. * diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRank.java b/apps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRank.java new file mode 100644 index 000000000..30abae5d3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRank.java @@ -0,0 +1,119 @@ +package com.loopers.domain.rank; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 상품 랭킹 Materialized View 엔티티. + *

    + * 주간/월간 TOP 100 랭킹을 저장하는 조회 전용 테이블입니다. + *

    + *

    + * Materialized View 설계: + *

      + *
    • 주간 랭킹: `mv_product_rank_weekly` (period_type = WEEKLY)
    • + *
    • 월간 랭킹: `mv_product_rank_monthly` (period_type = MONTHLY)
    • + *
    • TOP 100만 저장하여 조회 성능 최적화
    • + *
    + *

    + *

    + * 인덱스 전략: + *

      + *
    • 복합 인덱스: (period_type, period_start_date, rank) - 기간별 랭킹 조회 최적화
    • + *
    • 복합 인덱스: (period_type, period_start_date, product_id) - 특정 상품 랭킹 조회 최적화
    • + *
    + *

    + * + * @author Loopers + * @version 1.0 + */ +@Entity +@Table( + name = "mv_product_rank", + indexes = { + @Index(name = "idx_period_type_start_date_rank", columnList = "period_type, period_start_date, rank"), + @Index(name = "idx_period_type_start_date_product_id", columnList = "period_type, period_start_date, product_id") + } +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class ProductRank { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + /** + * 기간 타입 (WEEKLY: 주간, MONTHLY: 월간) + */ + @Enumerated(EnumType.STRING) + @Column(name = "period_type", nullable = false, length = 20) + private PeriodType periodType; + + /** + * 기간 시작일 + *
      + *
    • 주간: 해당 주의 월요일 (ISO 8601 기준)
    • + *
    • 월간: 해당 월의 1일
    • + *
    + */ + @Column(name = "period_start_date", nullable = false) + private LocalDate periodStartDate; + + /** + * 상품 ID + */ + @Column(name = "product_id", nullable = false) + private Long productId; + + /** + * 랭킹 (1-100) + */ + @Column(name = "rank", nullable = false) + private Integer rank; + + /** + * 좋아요 수 + */ + @Column(name = "like_count", nullable = false) + private Long likeCount; + + /** + * 판매량 + */ + @Column(name = "sales_count", nullable = false) + private Long salesCount; + + /** + * 조회 수 + */ + @Column(name = "view_count", nullable = false) + private Long viewCount; + + /** + * 생성 시각 + */ + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + /** + * 수정 시각 + */ + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + /** + * 기간 타입 열거형. + */ + public enum PeriodType { + WEEKLY, // 주간 + MONTHLY // 월간 + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRankRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRankRepository.java new file mode 100644 index 000000000..92b1529e7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRankRepository.java @@ -0,0 +1,39 @@ +package com.loopers.domain.rank; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +/** + * ProductRank 도메인 Repository 인터페이스. + *

    + * Materialized View에 저장된 상품 랭킹 데이터를 조회합니다. + *

    + */ +public interface ProductRankRepository { + + /** + * 특정 기간의 랭킹 데이터를 조회합니다. + * + * @param periodType 기간 타입 + * @param periodStartDate 기간 시작일 + * @param limit 조회할 랭킹 수 (기본: 100) + * @return 랭킹 리스트 (rank 오름차순) + */ + List findByPeriod(ProductRank.PeriodType periodType, LocalDate periodStartDate, int limit); + + /** + * 특정 기간의 특정 상품 랭킹을 조회합니다. + * + * @param periodType 기간 타입 + * @param periodStartDate 기간 시작일 + * @param productId 상품 ID + * @return 랭킹 정보 (없으면 Optional.empty()) + */ + Optional findByPeriodAndProductId( + ProductRank.PeriodType periodType, + LocalDate periodStartDate, + Long productId + ); +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/ProductRankRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/ProductRankRepositoryImpl.java new file mode 100644 index 000000000..046c6a035 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/ProductRankRepositoryImpl.java @@ -0,0 +1,63 @@ +package com.loopers.infrastructure.rank; + +import com.loopers.domain.rank.ProductRank; +import com.loopers.domain.rank.ProductRankRepository; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +/** + * ProductRank Repository 구현체. + *

    + * Materialized View에 저장된 상품 랭킹 데이터를 조회합니다. + *

    + */ +@Slf4j +@Repository +public class ProductRankRepositoryImpl implements ProductRankRepository { + + @PersistenceContext + private EntityManager entityManager; + + @Override + public List findByPeriod(ProductRank.PeriodType periodType, LocalDate periodStartDate, int limit) { + String jpql = "SELECT pr FROM ProductRank pr " + + "WHERE pr.periodType = :periodType AND pr.periodStartDate = :periodStartDate " + + "ORDER BY pr.rank ASC"; + + return entityManager.createQuery(jpql, ProductRank.class) + .setParameter("periodType", periodType) + .setParameter("periodStartDate", periodStartDate) + .setMaxResults(limit) + .getResultList(); + } + + @Override + public Optional findByPeriodAndProductId( + ProductRank.PeriodType periodType, + LocalDate periodStartDate, + Long productId + ) { + String jpql = "SELECT pr FROM ProductRank pr " + + "WHERE pr.periodType = :periodType " + + "AND pr.periodStartDate = :periodStartDate " + + "AND pr.productId = :productId"; + + try { + ProductRank rank = entityManager.createQuery(jpql, ProductRank.class) + .setParameter("periodType", periodType) + .setParameter("periodStartDate", periodStartDate) + .setParameter("productId", productId) + .getSingleResult(); + return Optional.of(rank); + } catch (jakarta.persistence.NoResultException e) { + return Optional.empty(); + } + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java index ecbae6157..2a34d7f21 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ranking/RankingV1Controller.java @@ -33,10 +33,19 @@ public class RankingV1Controller { /** * 랭킹을 조회합니다. *

    - * 날짜별 랭킹을 페이징하여 조회합니다. + * 기간별(일간/주간/월간) 랭킹을 페이징하여 조회합니다. + *

    + *

    + * 기간 타입: + *

      + *
    • DAILY: 일간 랭킹 (Redis ZSET에서 조회)
    • + *
    • WEEKLY: 주간 랭킹 (Materialized View에서 조회)
    • + *
    • MONTHLY: 월간 랭킹 (Materialized View에서 조회)
    • + *
    *

    * * @param date 날짜 (yyyyMMdd 형식, 기본값: 오늘 날짜) + * @param period 기간 타입 (DAILY, WEEKLY, MONTHLY, 기본값: DAILY) * @param page 페이지 번호 (기본값: 0) * @param size 페이지당 항목 수 (기본값: 20) * @return 랭킹 목록을 담은 API 응답 @@ -44,12 +53,16 @@ public class RankingV1Controller { @GetMapping public ApiResponse getRankings( @RequestParam(required = false) String date, + @RequestParam(required = false, defaultValue = "DAILY") String period, @RequestParam(required = false, defaultValue = "0") int page, @RequestParam(required = false, defaultValue = "20") int size ) { // 날짜 파라미터 검증 및 기본값 처리 LocalDate targetDate = parseDate(date); + // 기간 타입 파싱 및 검증 + RankingService.PeriodType periodType = parsePeriodType(period); + // 페이징 검증 if (page < 0) { page = 0; @@ -61,7 +74,7 @@ public ApiResponse getRankings( size = 100; // 최대 100개로 제한 } - RankingService.RankingsResponse result = rankingService.getRankings(targetDate, page, size); + RankingService.RankingsResponse result = rankingService.getRankings(targetDate, periodType, page, size); return ApiResponse.success(RankingV1Dto.RankingsResponse.from(result)); } @@ -86,4 +99,26 @@ private LocalDate parseDate(String dateStr) { return LocalDate.now(ZoneId.of("UTC")); } } + + /** + * 기간 타입 문자열을 PeriodType으로 파싱합니다. + *

    + * 파싱 실패 시 DAILY를 반환합니다. + *

    + * + * @param periodStr 기간 타입 문자열 (DAILY, WEEKLY, MONTHLY) + * @return 파싱된 기간 타입 (실패 시 DAILY) + */ + private RankingService.PeriodType parsePeriodType(String periodStr) { + if (periodStr == null || periodStr.isBlank()) { + return RankingService.PeriodType.DAILY; + } + + try { + return RankingService.PeriodType.valueOf(periodStr.toUpperCase()); + } catch (IllegalArgumentException e) { + // 파싱 실패 시 DAILY 반환 + return RankingService.PeriodType.DAILY; + } + } } diff --git a/apps/commerce-api/src/main/resources/application.yml b/apps/commerce-api/src/main/resources/application.yml index 584ba6335..0856b8d81 100644 --- a/apps/commerce-api/src/main/resources/application.yml +++ b/apps/commerce-api/src/main/resources/application.yml @@ -24,11 +24,6 @@ spring: - redis.yml - logging.yml - monitoring.yml - batch: - jdbc: - initialize-schema: always # Spring Batch 메타데이터 테이블 자동 생성 (임시: production 배포 전 EDA로 교체 예정) - job: - enabled: false # 스케줄러에서 수동 실행하므로 자동 실행 비활성화 payment-gateway: url: http://localhost:8082 diff --git a/apps/commerce-batch/build.gradle.kts b/apps/commerce-batch/build.gradle.kts new file mode 100644 index 000000000..1d691a669 --- /dev/null +++ b/apps/commerce-batch/build.gradle.kts @@ -0,0 +1,22 @@ +dependencies { + // add-ons + implementation(project(":modules:jpa")) + implementation(project(":modules:redis")) + implementation(project(":supports:jackson")) + implementation(project(":supports:logging")) + implementation(project(":supports:monitoring")) + + // batch + implementation("org.springframework.boot:spring-boot-starter-batch") + testImplementation("org.springframework.batch:spring-batch-test") + + // querydsl (필요시) + annotationProcessor("com.querydsl:querydsl-apt::jakarta") + annotationProcessor("jakarta.persistence:jakarta.persistence-api") + annotationProcessor("jakarta.annotation:jakarta.annotation-api") + + // test-fixtures + testImplementation(testFixtures(project(":modules:jpa"))) + testImplementation(testFixtures(project(":modules:redis"))) +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/BatchApplication.java b/apps/commerce-batch/src/main/java/com/loopers/BatchApplication.java new file mode 100644 index 000000000..76619b777 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/BatchApplication.java @@ -0,0 +1,34 @@ +package com.loopers; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +/** + * Spring Batch 애플리케이션 메인 클래스. + *

    + * 대량 데이터 집계 및 배치 처리를 위한 독립 실행형 애플리케이션입니다. + *

    + *

    + * 실행 방법: + *

    + * java -jar commerce-batch.jar \
    + *   --spring.batch.job.names=productMetricsAggregationJob \
    + *   targetDate=20241215
    + * 
    + *

    + * + * @author Loopers + * @version 1.0 + */ +@SpringBootApplication(scanBasePackages = "com.loopers") +@EnableJpaRepositories(basePackages = "com.loopers.infrastructure") +@EntityScan(basePackages = "com.loopers.domain") +public class BatchApplication { + + public static void main(String[] args) { + System.exit(SpringApplication.exit(SpringApplication.run(BatchApplication.class, args))); + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java new file mode 100644 index 000000000..953aae115 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetrics.java @@ -0,0 +1,134 @@ +package com.loopers.domain.metrics; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 상품 메트릭 집계 엔티티. + *

    + * Spring Batch에서 집계 및 조회를 위한 메트릭 엔티티입니다. + *

    + *

    + * 도메인 분리 근거: + *

      + *
    • 외부 시스템(데이터 플랫폼, 분석 시스템)을 위한 메트릭 집계
    • + *
    • Product 도메인의 핵심 비즈니스 로직과는 분리된 관심사
    • + *
    • Spring Batch를 통한 대량 데이터 처리
    • + *
    + *

    + *

    + * 모듈별 독립성: + *

      + *
    • commerce-batch 전용 엔티티 (독립적 진화 가능)
    • + *
    • commerce-streamer와는 별도로 관리되어 모듈별 커스터마이징 가능
    • + *
    + *

    + * + * @author Loopers + * @version 1.0 + */ +@Entity +@Table(name = "product_metrics") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class ProductMetrics { + + @Id + @Column(name = "product_id") + private Long productId; + + @Column(name = "like_count", nullable = false) + private Long likeCount; + + @Column(name = "sales_count", nullable = false) + private Long salesCount; + + @Column(name = "view_count", nullable = false) + private Long viewCount; + + @Column(name = "version", nullable = false) + private Long version; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + /** + * ProductMetrics 인스턴스를 생성합니다. + * + * @param productId 상품 ID + */ + public ProductMetrics(Long productId) { + this.productId = productId; + this.likeCount = 0L; + this.salesCount = 0L; + this.viewCount = 0L; + this.version = 0L; + this.updatedAt = LocalDateTime.now(); + } + + /** + * 좋아요 수를 증가시킵니다. + */ + public void incrementLikeCount() { + this.likeCount++; + this.version++; + this.updatedAt = LocalDateTime.now(); + } + + /** + * 좋아요 수를 감소시킵니다. + */ + public void decrementLikeCount() { + if (this.likeCount > 0) { + this.likeCount--; + this.version++; + this.updatedAt = LocalDateTime.now(); + } + } + + /** + * 판매량을 증가시킵니다. + * + * @param quantity 판매 수량 + */ + public void incrementSalesCount(Integer quantity) { + if (quantity != null && quantity > 0) { + this.salesCount += quantity; + this.version++; + this.updatedAt = LocalDateTime.now(); + } + } + + /** + * 상세 페이지 조회 수를 증가시킵니다. + */ + public void incrementViewCount() { + this.viewCount++; + this.version++; + this.updatedAt = LocalDateTime.now(); + } + + /** + * 이벤트의 버전을 기준으로 메트릭을 업데이트해야 하는지 확인합니다. + *

    + * 이벤트의 `version`이 메트릭의 `version`보다 크면 업데이트합니다. + * 이를 통해 오래된 이벤트가 최신 메트릭을 덮어쓰는 것을 방지합니다. + *

    + * + * @param eventVersion 이벤트의 버전 + * @return 업데이트해야 하면 true, 그렇지 않으면 false + */ + public boolean shouldUpdate(Long eventVersion) { + if (eventVersion == null) { + // 이벤트에 버전 정보가 없으면 업데이트 (하위 호환성) + return true; + } + // 이벤트 버전이 메트릭 버전보다 크면 업데이트 + return eventVersion > this.version; + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java new file mode 100644 index 000000000..aa831ba5a --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java @@ -0,0 +1,86 @@ +package com.loopers.domain.metrics; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.time.LocalDateTime; +import java.util.Optional; + +/** + * ProductMetrics 엔티티에 대한 저장소 인터페이스. + *

    + * 상품 메트릭 집계 데이터의 영속성 계층과의 상호작용을 정의합니다. + * DIP를 준수하여 도메인 레이어에서 인터페이스를 정의합니다. + *

    + *

    + * 도메인 분리 근거: + *

      + *
    • Metric 도메인은 외부 시스템 연동을 위한 별도 관심사
    • + *
    • Product 도메인의 핵심 비즈니스 로직과는 분리
    • + *
    + *

    + *

    + * 배치 전용 메서드: + *

      + *
    • Spring Batch에서 날짜 기반 조회를 위한 메서드 포함
    • + *
    • 대량 데이터 처리를 위한 페이징 조회 지원
    • + *
    + *

    + * + * @author Loopers + * @version 1.0 + */ +public interface ProductMetricsRepository { + + /** + * 상품 메트릭을 저장합니다. + * + * @param productMetrics 저장할 상품 메트릭 + * @return 저장된 상품 메트릭 + */ + ProductMetrics save(ProductMetrics productMetrics); + + /** + * 상품 ID로 메트릭을 조회합니다. + * + * @param productId 상품 ID + * @return 조회된 메트릭을 담은 Optional + */ + Optional findByProductId(Long productId); + + /** + * 특정 날짜에 업데이트된 메트릭을 페이징하여 조회합니다. + *

    + * Spring Batch의 JpaPagingItemReader에서 사용됩니다. + * updated_at 필드를 기준으로 해당 날짜의 데이터만 조회합니다. + *

    + * + * @param startDateTime 조회 시작 시각 (해당 날짜의 00:00:00) + * @param endDateTime 조회 종료 시각 (해당 날짜의 23:59:59.999999999) + * @param pageable 페이징 정보 + * @return 조회된 메트릭 페이지 + */ + Page findByUpdatedAtBetween( + LocalDateTime startDateTime, + LocalDateTime endDateTime, + Pageable pageable + ); + + /** + * Spring Batch의 RepositoryItemReader에서 사용하기 위한 JPA Repository를 반환합니다. + *

    + * RepositoryItemReader는 PagingAndSortingRepository를 직접 요구하므로, + * 기술적 제약으로 인해 JPA Repository에 대한 접근을 제공합니다. + *

    + *

    + * 주의: 이 메서드는 Spring Batch의 기술적 요구사항으로 인해 제공됩니다. + * 일반적인 비즈니스 로직에서는 이 메서드를 사용하지 않고, + * 위의 도메인 메서드들을 사용해야 합니다. + *

    + * + * @return PagingAndSortingRepository를 구현한 JPA Repository + */ + @SuppressWarnings("rawtypes") + org.springframework.data.repository.PagingAndSortingRepository getJpaRepository(); +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRank.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRank.java new file mode 100644 index 000000000..576eb158d --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRank.java @@ -0,0 +1,166 @@ +package com.loopers.domain.rank; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 상품 랭킹 Materialized View 엔티티. + *

    + * 주간/월간 TOP 100 랭킹을 저장하는 조회 전용 테이블입니다. + *

    + *

    + * Materialized View 설계: + *

      + *
    • 주간 랭킹: `mv_product_rank_weekly` (period_type = WEEKLY)
    • + *
    • 월간 랭킹: `mv_product_rank_monthly` (period_type = MONTHLY)
    • + *
    • TOP 100만 저장하여 조회 성능 최적화
    • + *
    + *

    + *

    + * 인덱스 전략: + *

      + *
    • 복합 인덱스: (period_type, period_start_date, rank) - 기간별 랭킹 조회 최적화
    • + *
    • 복합 인덱스: (period_type, period_start_date, product_id) - 특정 상품 랭킹 조회 최적화
    • + *
    + *

    + * + * @author Loopers + * @version 1.0 + */ +@Entity +@Table( + name = "mv_product_rank", + indexes = { + @Index(name = "idx_period_type_start_date_rank", columnList = "period_type, period_start_date, rank"), + @Index(name = "idx_period_type_start_date_product_id", columnList = "period_type, period_start_date, product_id") + } +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class ProductRank { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + /** + * 기간 타입 (WEEKLY: 주간, MONTHLY: 월간) + */ + @Enumerated(EnumType.STRING) + @Column(name = "period_type", nullable = false, length = 20) + private PeriodType periodType; + + /** + * 기간 시작일 + *
      + *
    • 주간: 해당 주의 월요일 (ISO 8601 기준)
    • + *
    • 월간: 해당 월의 1일
    • + *
    + */ + @Column(name = "period_start_date", nullable = false) + private LocalDate periodStartDate; + + /** + * 상품 ID + */ + @Column(name = "product_id", nullable = false) + private Long productId; + + /** + * 랭킹 (1-100) + */ + @Column(name = "rank", nullable = false) + private Integer rank; + + /** + * 좋아요 수 + */ + @Column(name = "like_count", nullable = false) + private Long likeCount; + + /** + * 판매량 + */ + @Column(name = "sales_count", nullable = false) + private Long salesCount; + + /** + * 조회 수 + */ + @Column(name = "view_count", nullable = false) + private Long viewCount; + + /** + * 생성 시각 + */ + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + /** + * 수정 시각 + */ + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + /** + * ProductRank 인스턴스를 생성합니다. + * + * @param periodType 기간 타입 (WEEKLY 또는 MONTHLY) + * @param periodStartDate 기간 시작일 + * @param productId 상품 ID + * @param rank 랭킹 (1-100) + * @param likeCount 좋아요 수 + * @param salesCount 판매량 + * @param viewCount 조회 수 + */ + public ProductRank( + PeriodType periodType, + LocalDate periodStartDate, + Long productId, + Integer rank, + Long likeCount, + Long salesCount, + Long viewCount + ) { + this.periodType = periodType; + this.periodStartDate = periodStartDate; + this.productId = productId; + this.rank = rank; + this.likeCount = likeCount; + this.salesCount = salesCount; + this.viewCount = viewCount; + this.createdAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } + + /** + * 랭킹 정보를 업데이트합니다. + * + * @param rank 새로운 랭킹 + * @param likeCount 좋아요 수 + * @param salesCount 판매량 + * @param viewCount 조회 수 + */ + public void updateRank(Integer rank, Long likeCount, Long salesCount, Long viewCount) { + this.rank = rank; + this.likeCount = likeCount; + this.salesCount = salesCount; + this.viewCount = viewCount; + this.updatedAt = LocalDateTime.now(); + } + + /** + * 기간 타입 열거형. + */ + public enum PeriodType { + WEEKLY, // 주간 + MONTHLY // 월간 + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankRepository.java new file mode 100644 index 000000000..f30679126 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankRepository.java @@ -0,0 +1,59 @@ +package com.loopers.domain.rank; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +/** + * ProductRank 도메인 Repository 인터페이스. + *

    + * Materialized View에 저장된 상품 랭킹 데이터를 조회합니다. + *

    + */ +public interface ProductRankRepository { + + /** + * 특정 기간의 랭킹 데이터를 저장합니다. + *

    + * 기존 데이터가 있으면 삭제 후 새로 저장합니다 (UPSERT 방식). + *

    + * + * @param periodType 기간 타입 + * @param periodStartDate 기간 시작일 + * @param ranks 저장할 랭킹 리스트 (TOP 100) + */ + void saveRanks(ProductRank.PeriodType periodType, LocalDate periodStartDate, List ranks); + + /** + * 특정 기간의 랭킹 데이터를 조회합니다. + * + * @param periodType 기간 타입 + * @param periodStartDate 기간 시작일 + * @param limit 조회할 랭킹 수 (기본: 100) + * @return 랭킹 리스트 (rank 오름차순) + */ + List findByPeriod(ProductRank.PeriodType periodType, LocalDate periodStartDate, int limit); + + /** + * 특정 기간의 특정 상품 랭킹을 조회합니다. + * + * @param periodType 기간 타입 + * @param periodStartDate 기간 시작일 + * @param productId 상품 ID + * @return 랭킹 정보 (없으면 Optional.empty()) + */ + Optional findByPeriodAndProductId( + ProductRank.PeriodType periodType, + LocalDate periodStartDate, + Long productId + ); + + /** + * 특정 기간의 기존 랭킹 데이터를 삭제합니다. + * + * @param periodType 기간 타입 + * @param periodStartDate 기간 시작일 + */ + void deleteByPeriod(ProductRank.PeriodType periodType, LocalDate periodStartDate); +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScore.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScore.java new file mode 100644 index 000000000..97653efd6 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScore.java @@ -0,0 +1,141 @@ +package com.loopers.domain.rank; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 상품 랭킹 점수 집계 임시 엔티티. + *

    + * Step 1 (집계 로직 계산)에서 사용하는 임시 테이블입니다. + * product_id별로 점수를 집계하여 저장하며, 랭킹 번호는 저장하지 않습니다. + *

    + *

    + * 사용 목적: + *

      + *
    • Step 1에서 모든 ProductMetrics를 읽어서 product_id별로 점수 집계
    • + *
    • Step 2에서 전체 데이터를 읽어서 TOP 100 선정 및 랭킹 번호 부여
    • + *
    + *

    + *

    + * 인덱스 전략: + *

      + *
    • product_id에 유니크 인덱스: 같은 product_id는 하나의 레코드만 존재 (UPSERT 방식)
    • + *
    • score에 인덱스: Step 2에서 정렬 시 성능 최적화
    • + *
    + *

    + * + * @author Loopers + * @version 1.0 + */ +@Entity +@Table( + name = "tmp_product_rank_score", + indexes = { + @Index(name = "idx_product_id", columnList = "product_id", unique = true), + @Index(name = "idx_score", columnList = "score") + } +) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class ProductRankScore { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + /** + * 상품 ID + */ + @Column(name = "product_id", nullable = false, unique = true) + private Long productId; + + /** + * 좋아요 수 (집계된 값) + */ + @Column(name = "like_count", nullable = false) + private Long likeCount; + + /** + * 판매량 (집계된 값) + */ + @Column(name = "sales_count", nullable = false) + private Long salesCount; + + /** + * 조회 수 (집계된 값) + */ + @Column(name = "view_count", nullable = false) + private Long viewCount; + + /** + * 종합 점수 + *

    + * 가중치: + *

      + *
    • 좋아요: 0.3
    • + *
    • 판매량: 0.5
    • + *
    • 조회수: 0.2
    • + *
    + *

    + */ + @Column(name = "score", nullable = false) + private Double score; + + /** + * 메트릭 값을 설정합니다. + *

    + * Repository에서만 사용하는 내부 메서드입니다. + *

    + */ + public void setMetrics(Long likeCount, Long salesCount, Long viewCount, Double score) { + this.likeCount = likeCount; + this.salesCount = salesCount; + this.viewCount = viewCount; + this.score = score; + this.updatedAt = LocalDateTime.now(); + } + + /** + * 생성 시각 + */ + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + /** + * 수정 시각 + */ + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + /** + * ProductRankScore 인스턴스를 생성합니다. + * + * @param productId 상품 ID + * @param likeCount 좋아요 수 + * @param salesCount 판매량 + * @param viewCount 조회 수 + * @param score 종합 점수 + */ + public ProductRankScore( + Long productId, + Long likeCount, + Long salesCount, + Long viewCount, + Double score + ) { + this.productId = productId; + this.likeCount = likeCount; + this.salesCount = salesCount; + this.viewCount = viewCount; + this.score = score; + this.createdAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } + +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScoreRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScoreRepository.java new file mode 100644 index 000000000..149357a81 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScoreRepository.java @@ -0,0 +1,68 @@ +package com.loopers.domain.rank; + +import java.util.List; +import java.util.Optional; + +/** + * ProductRankScore 도메인 Repository 인터페이스. + *

    + * Step 1과 Step 2 간 데이터 전달을 위한 임시 테이블을 관리합니다. + *

    + */ +public interface ProductRankScoreRepository { + + /** + * ProductRankScore를 저장합니다. + *

    + * 같은 product_id가 이미 존재하면 업데이트, 없으면 생성합니다 (UPSERT 방식). + *

    + * + * @param score 저장할 ProductRankScore + */ + void save(ProductRankScore score); + + /** + * 여러 ProductRankScore를 저장합니다. + *

    + * 같은 product_id가 이미 존재하면 업데이트, 없으면 생성합니다 (UPSERT 방식). + *

    + * + * @param scores 저장할 ProductRankScore 리스트 + */ + void saveAll(List scores); + + /** + * product_id로 ProductRankScore를 조회합니다. + * + * @param productId 상품 ID + * @return ProductRankScore (없으면 Optional.empty()) + */ + Optional findByProductId(Long productId); + + /** + * 모든 ProductRankScore를 점수 내림차순으로 조회합니다. + *

    + * Step 2에서 TOP 100 선정을 위해 사용합니다. + *

    + * + * @param limit 조회할 최대 개수 (기본: 전체) + * @return ProductRankScore 리스트 (점수 내림차순) + */ + List findAllOrderByScoreDesc(int limit); + + /** + * 모든 ProductRankScore를 조회합니다. + * + * @return ProductRankScore 리스트 + */ + List findAll(); + + /** + * 모든 ProductRankScore를 삭제합니다. + *

    + * Step 2 완료 후 임시 테이블을 정리하기 위해 사용합니다. + *

    + */ + void deleteAll(); +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemProcessor.java new file mode 100644 index 000000000..7d23b370a --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemProcessor.java @@ -0,0 +1,45 @@ +package com.loopers.infrastructure.batch.metrics; + +import com.loopers.domain.metrics.ProductMetrics; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.stereotype.Component; + +/** + * ProductMetrics를 처리하는 Spring Batch ItemProcessor. + *

    + * 현재는 데이터를 그대로 전달하지만, 향후 집계 로직을 추가할 수 있습니다. + *

    + *

    + * 구현 의도: + *

      + *
    • Reader와 Writer 사이의 변환/필터링 로직을 위한 확장 포인트 제공
    • + *
    • 향후 주간/월간 집계를 위한 데이터 변환 로직 추가 가능
    • + *
    • 비즈니스 로직 검증 및 필터링 수행 가능
    • + *
    + *

    + * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +public class ProductMetricsItemProcessor implements ItemProcessor { + + /** + * ProductMetrics를 처리합니다. + *

    + * 현재는 데이터를 그대로 전달하지만, 필요시 변환/필터링 로직을 추가할 수 있습니다. + *

    + * + * @param item 처리할 ProductMetrics + * @return 처리된 ProductMetrics (null 반환 시 해당 항목은 Writer로 전달되지 않음) + */ + @Override + public ProductMetrics process(ProductMetrics item) throws Exception { + // 현재는 데이터를 그대로 전달 + // 향후 집계 로직이나 데이터 변환이 필요하면 여기에 추가 + return item; + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemReader.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemReader.java new file mode 100644 index 000000000..b7f420b87 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemReader.java @@ -0,0 +1,111 @@ +package com.loopers.infrastructure.batch.metrics; + +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.metrics.ProductMetricsRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.data.RepositoryItemReader; +import org.springframework.batch.item.data.builder.RepositoryItemReaderBuilder; +import org.springframework.data.domain.Sort; +import org.springframework.data.repository.PagingAndSortingRepository; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.HashMap; +import java.util.Map; + +/** + * ProductMetrics를 읽기 위한 Spring Batch ItemReader Factory. + *

    + * Chunk-Oriented Processing을 위해 JPA Repository 기반 Reader를 생성합니다. + * 특정 날짜의 product_metrics 데이터를 페이징하여 읽습니다. + *

    + *

    + * 구현 의도: + *

      + *
    • 대량 데이터를 메모리 효율적으로 처리하기 위해 페이징 방식 사용
    • + *
    • 날짜 파라미터를 받아 해당 날짜의 데이터만 조회
    • + *
    • product_id 기준 정렬로 일관된 읽기 순서 보장
    • + *
    + *

    + *

    + * DIP 준수: + *

      + *
    • 도메인 레이어의 ProductMetricsRepository 인터페이스를 사용
    • + *
    • Spring Batch의 기술적 제약으로 인해 getJpaRepository()를 통해 JPA Repository 접근
    • + *
    + *

    + * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ProductMetricsItemReader { + + private final ProductMetricsRepository productMetricsRepository; + + /** + * ProductMetrics를 읽는 ItemReader를 생성합니다. + *

    + * Job 파라미터에서 날짜를 받아 해당 날짜의 데이터만 조회합니다. + *

    + * + * @param targetDate 조회할 날짜 (yyyyMMdd 형식) + * @return RepositoryItemReader 인스턴스 + */ + public RepositoryItemReader createReader(String targetDate) { + // 날짜 파라미터 파싱 + LocalDate date = parseDate(targetDate); + LocalDateTime startDateTime = date.atStartOfDay(); + LocalDateTime endDateTime = date.atTime(LocalTime.MAX); + + log.info("ProductMetrics Reader 초기화: targetDate={}, startDateTime={}, endDateTime={}", + date, startDateTime, endDateTime); + + // 정렬 기준 설정 (product_id 기준 오름차순) + Map sorts = new HashMap<>(); + sorts.put("productId", Sort.Direction.ASC); + + // Spring Batch의 RepositoryItemReader는 PagingAndSortingRepository를 직접 요구하므로 + // 기술적 제약으로 인해 getJpaRepository()를 통해 접근 + PagingAndSortingRepository jpaRepository = + productMetricsRepository.getJpaRepository(); + + return new RepositoryItemReaderBuilder() + .name("productMetricsReader") + .repository(jpaRepository) + .methodName("findByUpdatedAtBetween") + .arguments(startDateTime, endDateTime) + .pageSize(100) // Chunk 크기와 동일하게 설정 + .sorts(sorts) + .build(); + } + + /** + * 날짜 문자열을 LocalDate로 파싱합니다. + *

    + * yyyyMMdd 형식의 문자열을 파싱하며, 파싱 실패 시 오늘 날짜를 반환합니다. + *

    + * + * @param dateStr 날짜 문자열 (yyyyMMdd 형식) + * @return 파싱된 날짜 + */ + private LocalDate parseDate(String dateStr) { + if (dateStr == null || dateStr.isEmpty()) { + log.warn("날짜 파라미터가 없어 오늘 날짜를 사용합니다."); + return LocalDate.now(); + } + + try { + return LocalDate.parse(dateStr, java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd")); + } catch (Exception e) { + log.warn("날짜 파싱 실패: {}, 오늘 날짜를 사용합니다.", dateStr, e); + return LocalDate.now(); + } + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemWriter.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemWriter.java new file mode 100644 index 000000000..89364f52e --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemWriter.java @@ -0,0 +1,58 @@ +package com.loopers.infrastructure.batch.metrics; + +import com.loopers.domain.metrics.ProductMetrics; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * ProductMetrics를 처리하는 Spring Batch ItemWriter. + *

    + * 현재는 로깅만 수행하지만, 향후 Materialized View에 저장하는 로직을 추가할 수 있습니다. + *

    + *

    + * 구현 의도: + *

      + *
    • Chunk 단위로 데이터를 처리하여 대량 데이터 처리 성능 최적화
    • + *
    • 향후 주간/월간 랭킹을 위한 Materialized View 저장 로직 추가 예정
    • + *
    • 트랜잭션 단위는 Chunk 단위로 관리
    • + *
    + *

    + * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +public class ProductMetricsItemWriter implements ItemWriter { + + /** + * ProductMetrics Chunk를 처리합니다. + *

    + * 현재는 로깅만 수행하며, 향후 Materialized View에 저장하는 로직을 추가할 예정입니다. + *

    + * + * @param chunk 처리할 ProductMetrics Chunk + * @throws Exception 처리 중 오류 발생 시 + */ + @Override + public void write(Chunk chunk) throws Exception { + List items = chunk.getItems(); + + log.info("ProductMetrics Chunk 처리 시작: itemCount={}", items.size()); + + // 현재는 로깅만 수행 + // 향후 주간/월간 랭킹을 위한 Materialized View 저장 로직 추가 예정 + for (ProductMetrics item : items) { + log.debug("ProductMetrics 처리: productId={}, likeCount={}, salesCount={}, viewCount={}, updatedAt={}", + item.getProductId(), item.getLikeCount(), item.getSalesCount(), + item.getViewCount(), item.getUpdatedAt()); + } + + log.info("ProductMetrics Chunk 처리 완료: itemCount={}", items.size()); + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsJobConfig.java new file mode 100644 index 000000000..1c874b3b7 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/metrics/ProductMetricsJobConfig.java @@ -0,0 +1,148 @@ +package com.loopers.infrastructure.batch.metrics; + +import com.loopers.domain.metrics.ProductMetrics; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.ItemWriter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +/** + * ProductMetrics 집계를 위한 Spring Batch Job Configuration. + *

    + * Chunk-Oriented Processing 방식을 사용하여 대량의 product_metrics 데이터를 처리합니다. + *

    + *

    + * 구현 의도: + *

      + *
    • Chunk-Oriented Processing: 대량 데이터를 메모리 효율적으로 처리
    • + *
    • Job 파라미터 기반 실행: 날짜를 파라미터로 받아 특정 날짜의 데이터만 처리
    • + *
    • 확장성: 향후 주간/월간 집계를 위한 구조 준비
    • + *
    • 재시작 가능: 실패 시 이전 Chunk부터 재시작 가능
    • + *
    + *

    + *

    + * Chunk 크기 선택 근거: + *

      + *
    • 100개: 메모리 사용량과 성능의 균형
    • + *
    • 너무 작으면: 트랜잭션 오버헤드 증가
    • + *
    • 너무 크면: 메모리 사용량 증가 및 롤백 범위 확대
    • + *
    + *

    + * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Configuration +@RequiredArgsConstructor +public class ProductMetricsJobConfig { + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final ProductMetricsItemReader productMetricsItemReader; + private final ProductMetricsItemProcessor productMetricsItemProcessor; + private final ProductMetricsItemWriter productMetricsItemWriter; + + /** + * ProductMetrics 집계 Job을 생성합니다. + *

    + * Job 파라미터: + *

      + *
    • targetDate: 처리할 날짜 (yyyyMMdd 형식, 예: "20241215")
    • + *
    + *

    + *

    + * 실행 예시: + *

    +     * java -jar commerce-batch.jar --spring.batch.job.names=productMetricsAggregationJob targetDate=20241215
    +     * 
    + *

    + * + * @return ProductMetrics 집계 Job + */ + @Bean + public Job productMetricsAggregationJob(Step productMetricsAggregationStep) { + return new JobBuilder("productMetricsAggregationJob", jobRepository) + .start(productMetricsAggregationStep) + .build(); + } + + /** + * ProductMetrics 집계 Step을 생성합니다. + *

    + * Chunk-Oriented Processing을 사용하여: + *

      + *
    1. Reader: 특정 날짜의 product_metrics를 페이징하여 읽기
    2. + *
    3. Processor: 데이터 변환/필터링 (현재는 pass-through)
    4. + *
    5. Writer: 집계 결과 처리 (현재는 로깅, 향후 MV 저장)
    6. + *
    + *

    + * + * @param productMetricsReader ProductMetrics Reader (StepScope Bean) + * @param productMetricsProcessor ProductMetrics Processor + * @param productMetricsWriter ProductMetrics Writer + * @return ProductMetrics 집계 Step + */ + @Bean + public Step productMetricsAggregationStep( + ItemReader productMetricsReader, + ItemProcessor productMetricsProcessor, + ItemWriter productMetricsWriter + ) { + return new StepBuilder("productMetricsAggregationStep", jobRepository) + .chunk(100, transactionManager) // Chunk 크기: 100 + .reader(productMetricsReader) // StepScope Bean은 Step 실행 시점에 자동 주입됨 + .processor(productMetricsProcessor) + .writer(productMetricsWriter) + .build(); + } + + /** + * ProductMetrics Reader를 생성합니다. + *

    + * StepScope로 선언된 Bean이므로 Step 실행 시점에 Job 파라미터를 받아 생성됩니다. + *

    + * + * @param targetDate 조회할 날짜 (Job 파라미터에서 주입) + * @return ProductMetrics Reader (StepScope로 선언되어 Step 실행 시 생성) + */ + @Bean + @StepScope + public ItemReader productMetricsReader( + @Value("#{jobParameters['targetDate']}") String targetDate + ) { + return productMetricsItemReader.createReader(targetDate); + } + + /** + * ProductMetrics Processor를 주입받습니다. + * + * @return ProductMetrics Processor + */ + @Bean + public ItemProcessor productMetricsProcessor() { + return productMetricsItemProcessor; + } + + /** + * ProductMetrics Writer를 주입받습니다. + * + * @return ProductMetrics Writer + */ + @Bean + public ItemWriter productMetricsWriter() { + return productMetricsItemWriter; + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationProcessor.java new file mode 100644 index 000000000..2cf591cef --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationProcessor.java @@ -0,0 +1,74 @@ +package com.loopers.infrastructure.batch.rank; + +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.rank.ProductRank; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.temporal.TemporalAdjusters; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +/** + * ProductRank 집계를 위한 Processor. + *

    + * 기간 정보를 관리하고 Writer에서 사용할 수 있도록 제공합니다. + * 실제 집계는 Writer에서 Chunk 단위로 수행됩니다. + *

    + * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +public class ProductRankAggregationProcessor { + + private ProductRank.PeriodType periodType; + private LocalDate periodStartDate; + + /** + * 기간 정보를 설정합니다. + *

    + * Job 파라미터에서 주입받아 설정합니다. + *

    + * + * @param periodType 기간 타입 (WEEKLY 또는 MONTHLY) + * @param targetDate 기준 날짜 + */ + public void setPeriod(ProductRank.PeriodType periodType, LocalDate targetDate) { + this.periodType = periodType; + + if (periodType == ProductRank.PeriodType.WEEKLY) { + // 주간 시작일: 해당 주의 월요일 + this.periodStartDate = targetDate.with(java.time.DayOfWeek.MONDAY); + } else if (periodType == ProductRank.PeriodType.MONTHLY) { + // 월간 시작일: 해당 월의 1일 + this.periodStartDate = targetDate.with(TemporalAdjusters.firstDayOfMonth()); + } + } + + /** + * 기간 타입을 반환합니다. + * + * @return 기간 타입 + */ + public ProductRank.PeriodType getPeriodType() { + return periodType; + } + + /** + * 기간 시작일을 반환합니다. + * + * @return 기간 시작일 + */ + public LocalDate getPeriodStartDate() { + return periodStartDate; + } + +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReader.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReader.java new file mode 100644 index 000000000..449cb18d2 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReader.java @@ -0,0 +1,123 @@ +package com.loopers.infrastructure.batch.rank; + +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.metrics.ProductMetricsRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.data.RepositoryItemReader; +import org.springframework.batch.item.data.builder.RepositoryItemReaderBuilder; +import org.springframework.data.domain.Sort; +import org.springframework.data.repository.PagingAndSortingRepository; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.temporal.TemporalAdjusters; +import java.util.HashMap; +import java.util.Map; + +/** + * ProductRank 집계를 위한 Spring Batch ItemReader Factory. + *

    + * 주간/월간 집계를 위해 특정 기간의 모든 ProductMetrics를 읽습니다. + *

    + *

    + * 구현 의도: + *

      + *
    • 주간 집계: 해당 주의 월요일부터 일요일까지의 데이터 조회
    • + *
    • 월간 집계: 해당 월의 1일부터 마지막 일까지의 데이터 조회
    • + *
    • 대량 데이터를 메모리 효율적으로 처리하기 위해 페이징 방식 사용
    • + *
    + *

    + * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ProductRankAggregationReader { + + private final ProductMetricsRepository productMetricsRepository; + + /** + * 주간 집계를 위한 Reader를 생성합니다. + *

    + * 해당 주의 월요일부터 일요일까지의 ProductMetrics를 조회합니다. + *

    + * + * @param targetDate 기준 날짜 (해당 주의 어느 날짜든 가능) + * @return RepositoryItemReader 인스턴스 + */ + public RepositoryItemReader createWeeklyReader(LocalDate targetDate) { + // 주간 시작일 계산 (월요일) + LocalDate weekStart = targetDate.with(java.time.DayOfWeek.MONDAY); + LocalDateTime startDateTime = weekStart.atStartOfDay(); + + // 주간 종료일 계산 (다음 주 월요일 00:00:00) + LocalDate weekEnd = weekStart.plusWeeks(1); + LocalDateTime endDateTime = weekEnd.atStartOfDay(); + + log.info("ProductRank 주간 Reader 초기화: targetDate={}, weekStart={}, weekEnd={}", + targetDate, weekStart, weekEnd); + + return createReader(startDateTime, endDateTime, "weeklyReader"); + } + + /** + * 월간 집계를 위한 Reader를 생성합니다. + *

    + * 해당 월의 1일부터 마지막 일까지의 ProductMetrics를 조회합니다. + *

    + * + * @param targetDate 기준 날짜 (해당 월의 어느 날짜든 가능) + * @return RepositoryItemReader 인스턴스 + */ + public RepositoryItemReader createMonthlyReader(LocalDate targetDate) { + // 월간 시작일 계산 (1일) + LocalDate monthStart = targetDate.with(TemporalAdjusters.firstDayOfMonth()); + LocalDateTime startDateTime = monthStart.atStartOfDay(); + + // 월간 종료일 계산 (다음 달 1일 00:00:00) + LocalDate monthEnd = targetDate.with(TemporalAdjusters.firstDayOfNextMonth()); + LocalDateTime endDateTime = monthEnd.atStartOfDay(); + + log.info("ProductRank 월간 Reader 초기화: targetDate={}, monthStart={}, monthEnd={}", + targetDate, monthStart, monthEnd); + + return createReader(startDateTime, endDateTime, "monthlyReader"); + } + + /** + * ProductMetrics를 읽는 ItemReader를 생성합니다. + * + * @param startDateTime 조회 시작 시각 + * @param endDateTime 조회 종료 시각 + * @param readerName Reader 이름 + * @return RepositoryItemReader 인스턴스 + */ + private RepositoryItemReader createReader( + LocalDateTime startDateTime, + LocalDateTime endDateTime, + String readerName + ) { + // 정렬 기준 설정 (product_id 기준 오름차순) + Map sorts = new HashMap<>(); + sorts.put("productId", Sort.Direction.ASC); + + // Spring Batch의 RepositoryItemReader는 PagingAndSortingRepository를 직접 요구하므로 + // 기술적 제약으로 인해 getJpaRepository()를 통해 접근 + PagingAndSortingRepository jpaRepository = + productMetricsRepository.getJpaRepository(); + + return new RepositoryItemReaderBuilder() + .name(readerName) + .repository(jpaRepository) + .methodName("findByUpdatedAtBetween") + .arguments(startDateTime, endDateTime) + .pageSize(100) // Chunk 크기와 동일하게 설정 + .sorts(sorts) + .build(); + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessor.java new file mode 100644 index 000000000..cafcbc4cc --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessor.java @@ -0,0 +1,87 @@ +package com.loopers.infrastructure.batch.rank; + +import com.loopers.domain.rank.ProductRank; +import com.loopers.domain.rank.ProductRankScore; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; + +/** + * ProductRankScore를 ProductRank로 변환하는 Processor. + *

    + * Step 2 (랭킹 로직 실행 Step)에서 사용합니다. + * ProductRankScore를 읽어서 랭킹 번호를 부여하고 ProductRank로 변환합니다. + *

    + *

    + * 구현 의도: + *

      + *
    • ProductRankScore에 랭킹 번호 부여 (1부터 시작)
    • + *
    • TOP 100만 선정 (나머지는 null 반환하여 필터링)
    • + *
    • ProductRank로 변환
    • + *
    + *

    + * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ProductRankCalculationProcessor implements ItemProcessor { + + private final ProductRankAggregationProcessor productRankAggregationProcessor; + private final ThreadLocal currentRank = ThreadLocal.withInitial(() -> 0); + private static final int TOP_RANK_LIMIT = 100; + + /** + * ProductRankScore를 ProductRank로 변환합니다. + *

    + * 랭킹 번호를 부여하고, TOP 100에 포함되는 경우에만 ProductRank를 반환합니다. + *

    + * + * @param score ProductRankScore + * @return ProductRank (TOP 100에 포함되는 경우), null (그 외) + * @throws Exception 처리 중 오류 발생 시 + */ + @Override + public ProductRank process(ProductRankScore score) throws Exception { + int rank = currentRank.get() + 1; + currentRank.set(rank); + + // TOP 100에 포함되지 않으면 null 반환 (필터링) + if (rank > TOP_RANK_LIMIT) { + return null; + } + + // 기간 정보 가져오기 + ProductRank.PeriodType periodType = productRankAggregationProcessor.getPeriodType(); + LocalDate periodStartDate = productRankAggregationProcessor.getPeriodStartDate(); + + if (periodType == null || periodStartDate == null) { + log.error("기간 정보가 설정되지 않았습니다. 건너뜁니다."); + return null; + } + + // ProductRank 생성 (랭킹 번호 부여) + ProductRank productRank = new ProductRank( + periodType, + periodStartDate, + score.getProductId(), + rank, // 랭킹 번호 (1부터 시작) + score.getLikeCount(), + score.getSalesCount(), + score.getViewCount() + ); + + // Step 완료 후 ThreadLocal 정리 (마지막 항목 처리 시) + if (rank == TOP_RANK_LIMIT) { + currentRank.remove(); + } + + return productRank; + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationReader.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationReader.java new file mode 100644 index 000000000..4b997f66c --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationReader.java @@ -0,0 +1,72 @@ +package com.loopers.infrastructure.batch.rank; + +import com.loopers.domain.rank.ProductRankScore; +import com.loopers.domain.rank.ProductRankScoreRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.NonTransientResourceException; +import org.springframework.batch.item.ParseException; +import org.springframework.batch.item.UnexpectedInputException; +import org.springframework.stereotype.Component; + +import java.util.Iterator; +import java.util.List; + +/** + * ProductRankScore를 읽는 Reader. + *

    + * Step 2 (랭킹 로직 실행 Step)에서 사용합니다. + * ProductRankScore 테이블에서 점수 내림차순으로 모든 데이터를 읽습니다. + *

    + *

    + * 구현 의도: + *

      + *
    • Step 1에서 집계된 모든 ProductRankScore를 읽기
    • + *
    • 점수 내림차순으로 정렬된 데이터를 제공
    • + *
    • TOP 100 선정을 위해 전체 데이터를 읽어야 함
    • + *
    + *

    + * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ProductRankCalculationReader implements ItemReader { + + private final ProductRankScoreRepository productRankScoreRepository; + private Iterator scoreIterator; + private boolean initialized = false; + + /** + * ProductRankScore를 읽습니다. + *

    + * 첫 호출 시 모든 데이터를 조회하고, 이후 Iterator를 통해 하나씩 반환합니다. + *

    + * + * @return ProductRankScore (더 이상 없으면 null) + * @throws UnexpectedInputException 예상치 못한 입력 오류 + * @throws ParseException 파싱 오류 + * @throws NonTransientResourceException 일시적이지 않은 리소스 오류 + */ + @Override + public ProductRankScore read() throws Exception, UnexpectedInputException, ParseException, NonTransientResourceException { + if (!initialized) { + // 첫 호출 시 모든 데이터를 점수 내림차순으로 조회 + List scores = productRankScoreRepository.findAllOrderByScoreDesc(0); + this.scoreIterator = scores.iterator(); + this.initialized = true; + + log.info("ProductRankScore 조회 완료: totalCount={}", scores.size()); + } + + if (scoreIterator.hasNext()) { + return scoreIterator.next(); + } + + return null; // 더 이상 읽을 데이터가 없음 + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationWriter.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationWriter.java new file mode 100644 index 000000000..71fd8ea5c --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationWriter.java @@ -0,0 +1,82 @@ +package com.loopers.infrastructure.batch.rank; + +import com.loopers.domain.rank.ProductRank; +import com.loopers.domain.rank.ProductRankRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.util.List; +import java.util.stream.Collectors; + +/** + * ProductRank를 Materialized View에 저장하는 Writer. + *

    + * Step 2 (랭킹 로직 실행 Step)에서 사용합니다. + * 랭킹 번호가 부여된 ProductRank를 Materialized View에 저장합니다. + *

    + *

    + * 구현 의도: + *

      + *
    • Chunk 단위로 받은 ProductRank를 수집하고 저장
    • + *
    • 각 Chunk마다 전체 ProductRank를 저장 (saveRanks가 delete + insert를 수행)
    • + *
    • 기존 데이터 삭제 후 새 데이터 저장 (delete + insert 방식)
    • + *
    + *

    + * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ProductRankCalculationWriter implements ItemWriter { + + private final ProductRankRepository productRankRepository; + private final ProductRankAggregationProcessor productRankAggregationProcessor; + private final List allRanks = new java.util.ArrayList<>(); + + /** + * ProductRank Chunk를 수집하고 저장합니다. + *

    + * 모든 Chunk를 메모리에 모아두고, 각 Chunk마다 전체를 저장합니다. + * saveRanks가 delete + insert를 수행하므로, 각 Chunk마다 전체를 저장해도 문제없습니다. + *

    + * + * @param chunk 처리할 ProductRank Chunk + * @throws Exception 처리 중 오류 발생 시 + */ + @Override + public void write(Chunk chunk) throws Exception { + List items = chunk.getItems() + .stream() + .filter(item -> item != null) // null 필터링 (TOP 100에 포함되지 않은 항목) + .collect(Collectors.toList()); + + if (items.isEmpty()) { + return; + } + + // 기간 정보 가져오기 + ProductRank.PeriodType periodType = productRankAggregationProcessor.getPeriodType(); + LocalDate periodStartDate = productRankAggregationProcessor.getPeriodStartDate(); + + if (periodType == null || periodStartDate == null) { + log.error("기간 정보가 설정되지 않았습니다. 건너뜁니다."); + return; + } + + // 모든 Chunk를 수집 + allRanks.addAll(items); + log.debug("ProductRank Chunk 수집: count={}, total={}", items.size(), allRanks.size()); + + // 각 Chunk마다 전체를 저장 (saveRanks가 delete + insert를 수행하므로 문제없음) + log.info("ProductRank 저장: periodType={}, periodStartDate={}, total={}", + periodType, periodStartDate, allRanks.size()); + productRankRepository.saveRanks(periodType, periodStartDate, allRanks); + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankJobConfig.java new file mode 100644 index 000000000..a8c06a0e5 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankJobConfig.java @@ -0,0 +1,257 @@ +package com.loopers.infrastructure.batch.rank; + +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.rank.ProductRank; +import com.loopers.domain.rank.ProductRankScore; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.ItemWriter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; + +/** + * ProductRank 집계를 위한 Spring Batch Job Configuration. + *

    + * 주간/월간 TOP 100 랭킹을 Materialized View에 저장합니다. + *

    + *

    + * 구현 의도: + *

      + *
    • Step 1 (집계 로직 계산): 모든 ProductMetrics를 읽어서 product_id별로 점수 집계
    • + *
    • Step 2 (랭킹 로직 실행): 집계된 전체 데이터를 기반으로 TOP 100 선정 및 랭킹 번호 부여
    • + *
    • Chunk-Oriented Processing: 대량 데이터를 메모리 효율적으로 처리
    • + *
    • Materialized View 저장: 조회 성능 최적화를 위한 TOP 100 랭킹 저장
    • + *
    + *

    + *

    + * Job 파라미터: + *

      + *
    • periodType: 기간 타입 (WEEKLY 또는 MONTHLY)
    • + *
    • targetDate: 기준 날짜 (yyyyMMdd 형식, 예: "20241215")
    • + *
    + *

    + *

    + * 실행 예시: + *

    + * // 주간 집계
    + * java -jar commerce-batch.jar \
    + *   --spring.batch.job.names=productRankAggregationJob \
    + *   periodType=WEEKLY targetDate=20241215
    + *
    + * // 월간 집계
    + * java -jar commerce-batch.jar \
    + *   --spring.batch.job.names=productRankAggregationJob \
    + *   periodType=MONTHLY targetDate=20241215
    + * 
    + *

    + * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Configuration +@RequiredArgsConstructor +public class ProductRankJobConfig { + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final ProductRankAggregationReader productRankAggregationReader; + private final ProductRankAggregationProcessor productRankAggregationProcessor; + private final ProductRankScoreAggregationWriter productRankScoreAggregationWriter; + private final ProductRankCalculationReader productRankCalculationReader; + private final ProductRankCalculationProcessor productRankCalculationProcessor; + private final ProductRankCalculationWriter productRankCalculationWriter; + + /** + * ProductRank 집계 Job을 생성합니다. + *

    + * 2-Step 구조: + *

      + *
    1. Step 1: 집계 로직 계산 (점수 집계)
    2. + *
    3. Step 2: 랭킹 로직 실행 (TOP 100 선정 및 랭킹 번호 부여)
    4. + *
    + *

    + * + * @param scoreAggregationStep Step 1: 집계 로직 계산 Step + * @param rankingCalculationStep Step 2: 랭킹 로직 실행 Step + * @return ProductRank 집계 Job + */ + @Bean + public Job productRankAggregationJob( + Step scoreAggregationStep, + Step rankingCalculationStep + ) { + return new JobBuilder("productRankAggregationJob", jobRepository) + .start(scoreAggregationStep) // Step 1 먼저 실행 + .next(rankingCalculationStep) // Step 1 완료 후 Step 2 실행 + .build(); + } + + /** + * Step 1: 집계 로직 계산 Step을 생성합니다. + *

    + * 모든 ProductMetrics를 읽어서 product_id별로 점수 집계하여 임시 테이블에 저장합니다. + *

    + *

    + * Chunk-Oriented Processing을 사용하여: + *

      + *
    1. Reader: 특정 기간의 product_metrics를 페이징하여 읽기
    2. + *
    3. Processor: Pass-through (필터링 필요 시 추가 가능)
    4. + *
    5. Writer: product_id별로 점수 집계하여 ProductRankScore 테이블에 저장
    6. + *
    + *

    + * + * @param productRankReader ProductRank Reader (StepScope Bean) + * @param productRankScoreWriter ProductRankScore Writer + * @return 집계 로직 계산 Step + */ + @Bean + public Step scoreAggregationStep( + ItemReader productRankReader, + ItemWriter productRankScoreWriter + ) { + return new StepBuilder("scoreAggregationStep", jobRepository) + .chunk(100, transactionManager) // Chunk 크기: 100 + .reader(productRankReader) + .processor(item -> item) // Pass-through + .writer(productRankScoreWriter) + .build(); + } + + /** + * Step 2: 랭킹 로직 실행 Step을 생성합니다. + *

    + * 집계된 전체 데이터를 기반으로 TOP 100 선정 및 랭킹 번호 부여하여 Materialized View에 저장합니다. + *

    + *

    + * Chunk-Oriented Processing을 사용하여: + *

      + *
    1. Reader: ProductRankScore 테이블에서 모든 데이터를 점수 내림차순으로 읽기
    2. + *
    3. Processor: TOP 100 선정 및 랭킹 번호 부여
    4. + *
    5. Writer: ProductRank를 수집하고 저장
    6. + *
    + *

    + * + * @param productRankScoreReader ProductRankScore Reader + * @param productRankCalculationProcessor ProductRank 계산 Processor + * @param productRankCalculationWriter ProductRank 계산 Writer + * @return 랭킹 로직 실행 Step + */ + @Bean + public Step rankingCalculationStep( + ItemReader productRankScoreReader, + ItemProcessor productRankCalculationProcessor, + ItemWriter productRankCalculationWriter + ) { + return new StepBuilder("rankingCalculationStep", jobRepository) + .chunk(100, transactionManager) // Chunk 크기: 100 + .reader(productRankScoreReader) + .processor(productRankCalculationProcessor) + .writer(productRankCalculationWriter) + .build(); + } + + /** + * ProductRank Reader를 생성합니다. + *

    + * StepScope로 선언된 Bean이므로 Step 실행 시점에 Job 파라미터를 받아 생성됩니다. + *

    + * + * @param periodType 기간 타입 (Job 파라미터에서 주입) + * @param targetDate 기준 날짜 (Job 파라미터에서 주입) + * @return ProductRank Reader (StepScope로 선언되어 Step 실행 시 생성) + */ + @Bean + @StepScope + public ItemReader productRankReader( + @Value("#{jobParameters['periodType']}") String periodType, + @Value("#{jobParameters['targetDate']}") String targetDate + ) { + LocalDate date = parseDate(targetDate); + ProductRank.PeriodType period = ProductRank.PeriodType.valueOf(periodType.toUpperCase()); + + // Processor에 기간 정보 설정 + productRankAggregationProcessor.setPeriod(period, date); + + if (period == ProductRank.PeriodType.WEEKLY) { + return productRankAggregationReader.createWeeklyReader(date); + } else { + return productRankAggregationReader.createMonthlyReader(date); + } + } + + /** + * Step 1용 ProductRankScore Writer를 주입받습니다. + * + * @return ProductRankScore Writer + */ + @Bean + public ItemWriter productRankScoreWriter() { + return productRankScoreAggregationWriter; + } + + /** + * Step 2용 ProductRankScore Reader를 주입받습니다. + * + * @return ProductRankScore Reader + */ + @Bean + public ItemReader productRankScoreReader() { + return productRankCalculationReader; + } + + /** + * Step 2용 ProductRank 계산 Processor를 주입받습니다. + * + * @return ProductRank 계산 Processor + */ + @Bean + public ItemProcessor productRankCalculationProcessor() { + return productRankCalculationProcessor; + } + + /** + * Step 2용 ProductRank 계산 Writer를 주입받습니다. + * + * @return ProductRank 계산 Writer + */ + @Bean + public ItemWriter productRankCalculationWriter() { + return productRankCalculationWriter; + } + + /** + * 날짜 문자열을 LocalDate로 파싱합니다. + * + * @param dateStr 날짜 문자열 (yyyyMMdd 형식) + * @return 파싱된 날짜 + */ + private LocalDate parseDate(String dateStr) { + if (dateStr == null || dateStr.isEmpty()) { + log.warn("날짜 파라미터가 없어 오늘 날짜를 사용합니다."); + return LocalDate.now(); + } + + try { + return LocalDate.parse(dateStr, DateTimeFormatter.ofPattern("yyyyMMdd")); + } catch (Exception e) { + log.warn("날짜 파싱 실패: {}, 오늘 날짜를 사용합니다.", dateStr, e); + return LocalDate.now(); + } + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriter.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriter.java new file mode 100644 index 000000000..f1e3d6404 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriter.java @@ -0,0 +1,170 @@ +package com.loopers.infrastructure.batch.rank; + +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.rank.ProductRankScore; +import com.loopers.domain.rank.ProductRankScoreRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * ProductRankScore 집계를 위한 Writer. + *

    + * Step 1 (집계 로직 계산 Step)에서 사용합니다. + * Chunk 단위로 받은 ProductMetrics를 product_id별로 집계하여 점수를 계산하고, + * ProductRankScore 임시 테이블에 저장합니다. + *

    + *

    + * 구현 의도: + *

      + *
    • Chunk 단위로 받은 ProductMetrics를 product_id별로 집계
    • + *
    • 점수 계산 (가중치: 좋아요 0.3, 판매량 0.5, 조회수 0.2)
    • + *
    • ProductRankScore 테이블에 저장 (랭킹 번호 없이)
    • + *
    • 같은 product_id가 여러 Chunk에 걸쳐 있을 경우 UPSERT 방식으로 누적
    • + *
    + *

    + * + * @author Loopers + * @version 1.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ProductRankScoreAggregationWriter implements ItemWriter { + + private final ProductRankScoreRepository productRankScoreRepository; + + /** + * ProductMetrics Chunk를 집계하여 ProductRankScore 테이블에 저장합니다. + *

    + * Chunk 단위로 받은 ProductMetrics를 product_id별로 집계하여 점수를 계산하고 저장합니다. + * 같은 product_id가 여러 Chunk에 걸쳐 있을 경우, 기존 데이터를 조회하여 누적한 후 저장합니다. + *

    + * + * @param chunk 처리할 ProductMetrics Chunk + * @throws Exception 처리 중 오류 발생 시 + */ + @Override + public void write(Chunk chunk) throws Exception { + List items = chunk.getItems(); + + if (items.isEmpty()) { + log.warn("ProductMetrics Chunk가 비어있습니다."); + return; + } + + log.debug("ProductRankScore Chunk 처리 시작: itemCount={}", items.size()); + + // 같은 product_id를 가진 메트릭을 합산 (Chunk 내에서) + Map chunkAggregatedMap = items.stream() + .collect(Collectors.groupingBy( + ProductMetrics::getProductId, + Collectors.reducing( + new AggregatedMetrics(0L, 0L, 0L), + metrics -> new AggregatedMetrics( + metrics.getLikeCount(), + metrics.getSalesCount(), + metrics.getViewCount() + ), + (a, b) -> new AggregatedMetrics( + a.getLikeCount() + b.getLikeCount(), + a.getSalesCount() + b.getSalesCount(), + a.getViewCount() + b.getViewCount() + ) + ) + )); + + // 기존 데이터와 누적하여 ProductRankScore 생성 + List scores = chunkAggregatedMap.entrySet().stream() + .map(entry -> { + Long productId = entry.getKey(); + AggregatedMetrics chunkAggregated = entry.getValue(); + + // 기존 데이터 조회 + java.util.Optional existing = productRankScoreRepository.findByProductId(productId); + + // 기존 데이터와 누적 + Long totalLikeCount = chunkAggregated.getLikeCount(); + Long totalSalesCount = chunkAggregated.getSalesCount(); + Long totalViewCount = chunkAggregated.getViewCount(); + + if (existing.isPresent()) { + ProductRankScore existingScore = existing.get(); + totalLikeCount += existingScore.getLikeCount(); + totalSalesCount += existingScore.getSalesCount(); + totalViewCount += existingScore.getViewCount(); + } + + // 점수 계산 (가중치: 좋아요 0.3, 판매량 0.5, 조회수 0.2) + double score = calculateScore(totalLikeCount, totalSalesCount, totalViewCount); + + return new ProductRankScore( + productId, + totalLikeCount, + totalSalesCount, + totalViewCount, + score + ); + }) + .collect(Collectors.toList()); + + // 저장 (기존 데이터가 있으면 덮어쓰기) + productRankScoreRepository.saveAll(scores); + + log.debug("ProductRankScore 저장 완료: count={}", scores.size()); + } + + /** + * 종합 점수를 계산합니다. + *

    + * 가중치: + *

      + *
    • 좋아요: 0.3
    • + *
    • 판매량: 0.5
    • + *
    • 조회수: 0.2
    • + *
    + *

    + * + * @param likeCount 좋아요 수 + * @param salesCount 판매량 + * @param viewCount 조회 수 + * @return 종합 점수 + */ + private double calculateScore(Long likeCount, Long salesCount, Long viewCount) { + return likeCount * 0.3 + salesCount * 0.5 + viewCount * 0.2; + } + + /** + * 집계된 메트릭을 담는 내부 클래스. + */ + private static class AggregatedMetrics { + private final Long likeCount; + private final Long salesCount; + private final Long viewCount; + + public AggregatedMetrics(Long likeCount, Long salesCount, Long viewCount) { + this.likeCount = likeCount; + this.salesCount = salesCount; + this.viewCount = viewCount; + } + + public Long getLikeCount() { + return likeCount; + } + + public Long getSalesCount() { + return salesCount; + } + + public Long getViewCount() { + return viewCount; + } + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java new file mode 100644 index 000000000..e76dd736f --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java @@ -0,0 +1,58 @@ +package com.loopers.infrastructure.metrics; + +import com.loopers.domain.metrics.ProductMetrics; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.Optional; + +/** + * ProductMetrics JPA Repository. + *

    + * 상품 메트릭 집계 데이터를 관리합니다. + * commerce-batch 전용 Repository입니다. + *

    + *

    + * 모듈별 독립성: + *

      + *
    • commerce-batch의 필요에 맞게 커스터마이징된 Repository
    • + *
    • Spring Batch에서 날짜 기반 조회에 최적화
    • + *
    + *

    + */ +public interface ProductMetricsJpaRepository extends JpaRepository { + + /** + * 상품 ID로 메트릭을 조회합니다. + * + * @param productId 상품 ID + * @return 조회된 메트릭을 담은 Optional + */ + Optional findByProductId(Long productId); + + /** + * 특정 날짜에 업데이트된 메트릭을 페이징하여 조회합니다. + *

    + * Spring Batch의 JpaPagingItemReader에서 사용됩니다. + * updated_at 필드를 기준으로 해당 날짜의 데이터만 조회합니다. + *

    + * + * @param startDateTime 조회 시작 시각 (해당 날짜의 00:00:00) + * @param endDateTime 조회 종료 시각 (해당 날짜의 23:59:59.999999999) + * @param pageable 페이징 정보 + * @return 조회된 메트릭 페이지 + */ + @Query("SELECT pm FROM ProductMetrics pm " + + "WHERE pm.updatedAt >= :startDateTime AND pm.updatedAt < :endDateTime " + + "ORDER BY pm.productId") + Page findByUpdatedAtBetween( + @Param("startDateTime") LocalDateTime startDateTime, + @Param("endDateTime") LocalDateTime endDateTime, + Pageable pageable + ); +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java new file mode 100644 index 000000000..70b775e30 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java @@ -0,0 +1,73 @@ +package com.loopers.infrastructure.metrics; + +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.metrics.ProductMetricsRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.Optional; + +/** + * ProductMetricsRepository의 JPA 구현체. + *

    + * Spring Data JPA를 활용하여 ProductMetrics 엔티티의 + * 영속성 작업을 처리합니다. + *

    + *

    + * 배치 전용 구현: + *

      + *
    • Spring Batch에서 날짜 기반 조회에 최적화
    • + *
    • 대량 데이터 처리를 위한 페이징 조회 지원
    • + *
    + *

    + * + * @author Loopers + * @version 1.0 + */ +@Component +@RequiredArgsConstructor +public class ProductMetricsRepositoryImpl implements ProductMetricsRepository { + + private final ProductMetricsJpaRepository productMetricsJpaRepository; + + /** + * {@inheritDoc} + */ + @Override + public ProductMetrics save(ProductMetrics productMetrics) { + return productMetricsJpaRepository.save(productMetrics); + } + + /** + * {@inheritDoc} + */ + @Override + public Optional findByProductId(Long productId) { + return productMetricsJpaRepository.findByProductId(productId); + } + + /** + * {@inheritDoc} + */ + @Override + public Page findByUpdatedAtBetween( + LocalDateTime startDateTime, + LocalDateTime endDateTime, + Pageable pageable + ) { + return productMetricsJpaRepository.findByUpdatedAtBetween(startDateTime, endDateTime, pageable); + } + + /** + * {@inheritDoc} + */ + @Override + @SuppressWarnings("rawtypes") + public org.springframework.data.repository.PagingAndSortingRepository getJpaRepository() { + return productMetricsJpaRepository; + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankRepositoryImpl.java new file mode 100644 index 000000000..d50aa8991 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankRepositoryImpl.java @@ -0,0 +1,95 @@ +package com.loopers.infrastructure.rank; + +import com.loopers.domain.rank.ProductRank; +import com.loopers.domain.rank.ProductRankRepository; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.Query; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +/** + * ProductRank Repository 구현체. + *

    + * Materialized View에 저장된 상품 랭킹 데이터를 관리합니다. + *

    + */ +@Slf4j +@Repository +public class ProductRankRepositoryImpl implements ProductRankRepository { + + @PersistenceContext + private EntityManager entityManager; + + @Override + @Transactional + public void saveRanks(ProductRank.PeriodType periodType, LocalDate periodStartDate, List ranks) { + // 기존 데이터 삭제 + deleteByPeriod(periodType, periodStartDate); + + // 새 데이터 저장 + for (ProductRank rank : ranks) { + entityManager.persist(rank); + } + + log.info("ProductRank 저장 완료: periodType={}, periodStartDate={}, count={}", + periodType, periodStartDate, ranks.size()); + } + + @Override + public List findByPeriod(ProductRank.PeriodType periodType, LocalDate periodStartDate, int limit) { + String jpql = "SELECT pr FROM ProductRank pr " + + "WHERE pr.periodType = :periodType AND pr.periodStartDate = :periodStartDate " + + "ORDER BY pr.rank ASC"; + + return entityManager.createQuery(jpql, ProductRank.class) + .setParameter("periodType", periodType) + .setParameter("periodStartDate", periodStartDate) + .setMaxResults(limit) + .getResultList(); + } + + @Override + public Optional findByPeriodAndProductId( + ProductRank.PeriodType periodType, + LocalDate periodStartDate, + Long productId + ) { + String jpql = "SELECT pr FROM ProductRank pr " + + "WHERE pr.periodType = :periodType " + + "AND pr.periodStartDate = :periodStartDate " + + "AND pr.productId = :productId"; + + try { + ProductRank rank = entityManager.createQuery(jpql, ProductRank.class) + .setParameter("periodType", periodType) + .setParameter("periodStartDate", periodStartDate) + .setParameter("productId", productId) + .getSingleResult(); + return Optional.of(rank); + } catch (jakarta.persistence.NoResultException e) { + return Optional.empty(); + } + } + + @Override + @Transactional + public void deleteByPeriod(ProductRank.PeriodType periodType, LocalDate periodStartDate) { + String jpql = "DELETE FROM ProductRank pr " + + "WHERE pr.periodType = :periodType AND pr.periodStartDate = :periodStartDate"; + + int deletedCount = entityManager.createQuery(jpql) + .setParameter("periodType", periodType) + .setParameter("periodStartDate", periodStartDate) + .executeUpdate(); + + log.debug("ProductRank 삭제 완료: periodType={}, periodStartDate={}, deletedCount={}", + periodType, periodStartDate, deletedCount); + } +} + diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankScoreRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankScoreRepositoryImpl.java new file mode 100644 index 000000000..b210d9ce2 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankScoreRepositoryImpl.java @@ -0,0 +1,100 @@ +package com.loopers.infrastructure.rank; + +import com.loopers.domain.rank.ProductRankScore; +import com.loopers.domain.rank.ProductRankScoreRepository; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +/** + * ProductRankScore Repository 구현체. + *

    + * Step 1과 Step 2 간 데이터 전달을 위한 임시 테이블을 관리합니다. + *

    + */ +@Slf4j +@Repository +public class ProductRankScoreRepositoryImpl implements ProductRankScoreRepository { + + @PersistenceContext + private EntityManager entityManager; + + @Override + @Transactional + public void save(ProductRankScore score) { + Optional existing = findByProductId(score.getProductId()); + + if (existing.isPresent()) { + // 기존 레코드가 있으면 덮어쓰기 (Writer에서 이미 누적된 값을 전달받음) + ProductRankScore existingScore = existing.get(); + existingScore.setMetrics( + score.getLikeCount(), + score.getSalesCount(), + score.getViewCount(), + score.getScore() + ); + entityManager.merge(existingScore); + log.debug("ProductRankScore 업데이트: productId={}", score.getProductId()); + } else { + // 없으면 새로 생성 + entityManager.persist(score); + log.debug("ProductRankScore 생성: productId={}", score.getProductId()); + } + } + + @Override + @Transactional + public void saveAll(List scores) { + for (ProductRankScore score : scores) { + save(score); + } + log.info("ProductRankScore 일괄 저장 완료: count={}", scores.size()); + } + + @Override + public Optional findByProductId(Long productId) { + String jpql = "SELECT prs FROM ProductRankScore prs WHERE prs.productId = :productId"; + + try { + ProductRankScore score = entityManager.createQuery(jpql, ProductRankScore.class) + .setParameter("productId", productId) + .getSingleResult(); + return Optional.of(score); + } catch (jakarta.persistence.NoResultException e) { + return Optional.empty(); + } + } + + @Override + public List findAllOrderByScoreDesc(int limit) { + String jpql = "SELECT prs FROM ProductRankScore prs ORDER BY prs.score DESC"; + + jakarta.persistence.TypedQuery query = + entityManager.createQuery(jpql, ProductRankScore.class); + if (limit > 0) { + query.setMaxResults(limit); + } + + return query.getResultList(); + } + + @Override + public List findAll() { + String jpql = "SELECT prs FROM ProductRankScore prs"; + return entityManager.createQuery(jpql, ProductRankScore.class).getResultList(); + } + + @Override + @Transactional + public void deleteAll() { + String jpql = "DELETE FROM ProductRankScore"; + int deletedCount = entityManager.createQuery(jpql).executeUpdate(); + log.info("ProductRankScore 전체 삭제 완료: deletedCount={}", deletedCount); + } +} + diff --git a/apps/commerce-batch/src/main/resources/application.yml b/apps/commerce-batch/src/main/resources/application.yml new file mode 100644 index 000000000..8c66d71dc --- /dev/null +++ b/apps/commerce-batch/src/main/resources/application.yml @@ -0,0 +1,43 @@ +spring: + main: + web-application-type: none # 배치 전용이므로 웹 서버 불필요 + application: + name: commerce-batch + profiles: + active: local + config: + import: + - jpa.yml + - redis.yml + - logging.yml + - monitoring.yml + batch: + jdbc: + initialize-schema: always # Spring Batch 메타데이터 테이블 자동 생성 + job: + enabled: false # 명령줄에서 수동 실행하므로 자동 실행 비활성화 + +--- +spring: + config: + activate: + on-profile: local, test + +--- +spring: + config: + activate: + on-profile: dev + +--- +spring: + config: + activate: + on-profile: qa + +--- +spring: + config: + activate: + on-profile: prd + diff --git a/apps/commerce-batch/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java b/apps/commerce-batch/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java new file mode 100644 index 000000000..133932ae4 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java @@ -0,0 +1,217 @@ +package com.loopers.domain.metrics; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * ProductMetrics 도메인 엔티티 테스트. + *

    + * commerce-batch 모듈의 ProductMetrics 엔티티에 대한 단위 테스트입니다. + *

    + */ +class ProductMetricsTest { + + @DisplayName("ProductMetrics는 상품 ID로 생성되며 초기값이 0으로 설정된다") + @Test + void createsProductMetricsWithInitialValues() { + // arrange + Long productId = 1L; + + // act + ProductMetrics metrics = new ProductMetrics(productId); + + // assert + assertThat(metrics.getProductId()).isEqualTo(productId); + assertThat(metrics.getLikeCount()).isEqualTo(0L); + assertThat(metrics.getSalesCount()).isEqualTo(0L); + assertThat(metrics.getViewCount()).isEqualTo(0L); + assertThat(metrics.getVersion()).isEqualTo(0L); + assertThat(metrics.getUpdatedAt()).isNotNull(); + } + + @DisplayName("좋아요 수를 증가시킬 수 있다") + @Test + void canIncrementLikeCount() throws InterruptedException { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + Long initialLikeCount = metrics.getLikeCount(); + Long initialVersion = metrics.getVersion(); + LocalDateTime initialUpdatedAt = metrics.getUpdatedAt(); + + // act + Thread.sleep(1); // 시간 차이를 보장하기 위한 작은 지연 + metrics.incrementLikeCount(); + + // assert + assertThat(metrics.getLikeCount()).isEqualTo(initialLikeCount + 1); + assertThat(metrics.getVersion()).isEqualTo(initialVersion + 1); + assertThat(metrics.getUpdatedAt()).isAfter(initialUpdatedAt); + } + + @DisplayName("좋아요 수를 감소시킬 수 있다") + @Test + void canDecrementLikeCount() { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + metrics.incrementLikeCount(); // 먼저 증가시킴 + Long initialLikeCount = metrics.getLikeCount(); + Long initialVersion = metrics.getVersion(); + + // act + metrics.decrementLikeCount(); + + // assert + assertThat(metrics.getLikeCount()).isEqualTo(initialLikeCount - 1); + assertThat(metrics.getVersion()).isEqualTo(initialVersion + 1); + } + + @DisplayName("좋아요 수가 0일 때 감소해도 음수가 되지 않는다 (멱등성 보장)") + @Test + void preventsNegativeLikeCount_whenDecrementingFromZero() { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + assertThat(metrics.getLikeCount()).isEqualTo(0L); + Long initialVersion = metrics.getVersion(); + + // act + metrics.decrementLikeCount(); + + // assert + assertThat(metrics.getLikeCount()).isEqualTo(0L); + assertThat(metrics.getVersion()).isEqualTo(initialVersion); // version도 변경되지 않음 + } + + @DisplayName("판매량을 증가시킬 수 있다") + @Test + void canIncrementSalesCount() { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + Long initialSalesCount = metrics.getSalesCount(); + Long initialVersion = metrics.getVersion(); + Integer quantity = 5; + + // act + metrics.incrementSalesCount(quantity); + + // assert + assertThat(metrics.getSalesCount()).isEqualTo(initialSalesCount + quantity); + assertThat(metrics.getVersion()).isEqualTo(initialVersion + 1); + } + + @DisplayName("판매량 증가 시 null이나 0 이하의 수량은 무시된다") + @Test + void ignoresInvalidQuantity_whenIncrementingSalesCount() { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + Long initialSalesCount = metrics.getSalesCount(); + Long initialVersion = metrics.getVersion(); + + // act + metrics.incrementSalesCount(null); + metrics.incrementSalesCount(0); + metrics.incrementSalesCount(-1); + + // assert + assertThat(metrics.getSalesCount()).isEqualTo(initialSalesCount); + assertThat(metrics.getVersion()).isEqualTo(initialVersion); // version도 변경되지 않음 + } + + @DisplayName("상세 페이지 조회 수를 증가시킬 수 있다") + @Test + void canIncrementViewCount() throws InterruptedException { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + Long initialViewCount = metrics.getViewCount(); + Long initialVersion = metrics.getVersion(); + LocalDateTime initialUpdatedAt = metrics.getUpdatedAt(); + + // act + Thread.sleep(1); // 시간 차이를 보장하기 위한 작은 지연 + metrics.incrementViewCount(); + + // assert + assertThat(metrics.getViewCount()).isEqualTo(initialViewCount + 1); + assertThat(metrics.getVersion()).isEqualTo(initialVersion + 1); + assertThat(metrics.getUpdatedAt()).isAfter(initialUpdatedAt); + } + + @DisplayName("여러 메트릭을 연속으로 업데이트할 수 있다") + @Test + void canUpdateMultipleMetrics() { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + + // act + metrics.incrementLikeCount(); + metrics.incrementLikeCount(); + metrics.incrementSalesCount(10); + metrics.incrementViewCount(); + metrics.decrementLikeCount(); + + // assert + assertThat(metrics.getLikeCount()).isEqualTo(1L); + assertThat(metrics.getSalesCount()).isEqualTo(10L); + assertThat(metrics.getViewCount()).isEqualTo(1L); + assertThat(metrics.getVersion()).isEqualTo(5L); // 5번 업데이트됨 + } + + @DisplayName("이벤트 버전이 메트릭 버전보다 크면 업데이트해야 한다고 판단한다") + @Test + void shouldUpdate_whenEventVersionIsGreater() { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + metrics.incrementLikeCount(); // version = 1 + Long eventVersion = 2L; + + // act + boolean result = metrics.shouldUpdate(eventVersion); + + // assert + assertThat(result).isTrue(); + } + + @DisplayName("이벤트 버전이 메트릭 버전보다 작거나 같으면 업데이트하지 않아야 한다고 판단한다") + @Test + void shouldNotUpdate_whenEventVersionIsLessOrEqual() { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + metrics.incrementLikeCount(); // version = 1 + metrics.incrementLikeCount(); // version = 2 + + // act & assert + assertThat(metrics.shouldUpdate(1L)).isFalse(); // 이벤트 버전이 더 작음 + assertThat(metrics.shouldUpdate(2L)).isFalse(); // 이벤트 버전이 같음 + } + + @DisplayName("이벤트 버전이 null이면 업데이트해야 한다고 판단한다 (하위 호환성)") + @Test + void shouldUpdate_whenEventVersionIsNull() { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + metrics.incrementLikeCount(); // version = 1 + + // act + boolean result = metrics.shouldUpdate(null); + + // assert + assertThat(result).isTrue(); // 하위 호환성을 위해 null이면 업데이트 + } + + @DisplayName("초기 버전(0)인 메트릭은 모든 이벤트 버전에 대해 업데이트해야 한다고 판단한다") + @Test + void shouldUpdate_whenMetricsVersionIsZero() { + // arrange + ProductMetrics metrics = new ProductMetrics(1L); + assertThat(metrics.getVersion()).isEqualTo(0L); + + // act & assert + assertThat(metrics.shouldUpdate(0L)).isFalse(); // 같으면 업데이트 안 함 + assertThat(metrics.shouldUpdate(1L)).isTrue(); // 더 크면 업데이트 + assertThat(metrics.shouldUpdate(100L)).isTrue(); // 더 크면 업데이트 + } +} + diff --git a/apps/commerce-batch/src/test/java/com/loopers/domain/rank/ProductRankTest.java b/apps/commerce-batch/src/test/java/com/loopers/domain/rank/ProductRankTest.java new file mode 100644 index 000000000..72d0c592f --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/domain/rank/ProductRankTest.java @@ -0,0 +1,235 @@ +package com.loopers.domain.rank; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * ProductRank 도메인 엔티티 테스트. + *

    + * commerce-batch 모듈의 ProductRank 엔티티에 대한 단위 테스트입니다. + *

    + */ +class ProductRankTest { + + @DisplayName("ProductRank는 모든 필수 정보로 생성된다") + @Test + void createsProductRankWithAllFields() { + // arrange + ProductRank.PeriodType periodType = ProductRank.PeriodType.WEEKLY; + LocalDate periodStartDate = LocalDate.of(2024, 12, 9); // 월요일 + Long productId = 1L; + Integer rank = 1; + Long likeCount = 100L; + Long salesCount = 500L; + Long viewCount = 1000L; + + // act + ProductRank productRank = new ProductRank( + periodType, + periodStartDate, + productId, + rank, + likeCount, + salesCount, + viewCount + ); + + // assert + assertThat(productRank.getPeriodType()).isEqualTo(periodType); + assertThat(productRank.getPeriodStartDate()).isEqualTo(periodStartDate); + assertThat(productRank.getProductId()).isEqualTo(productId); + assertThat(productRank.getRank()).isEqualTo(rank); + assertThat(productRank.getLikeCount()).isEqualTo(likeCount); + assertThat(productRank.getSalesCount()).isEqualTo(salesCount); + assertThat(productRank.getViewCount()).isEqualTo(viewCount); + assertThat(productRank.getCreatedAt()).isNotNull(); + assertThat(productRank.getUpdatedAt()).isNotNull(); + } + + @DisplayName("ProductRank 생성 시 createdAt과 updatedAt이 현재 시간으로 설정된다") + @Test + void setsCreatedAtAndUpdatedAtOnCreation() throws InterruptedException { + // arrange + LocalDateTime beforeCreation = LocalDateTime.now(); + Thread.sleep(1); + + // act + ProductRank productRank = new ProductRank( + ProductRank.PeriodType.WEEKLY, + LocalDate.of(2024, 12, 9), + 1L, + 1, + 100L, + 500L, + 1000L + ); + + Thread.sleep(1); + LocalDateTime afterCreation = LocalDateTime.now(); + + // assert + assertThat(productRank.getCreatedAt()) + .isAfter(beforeCreation) + .isBefore(afterCreation); + assertThat(productRank.getUpdatedAt()) + .isAfter(beforeCreation) + .isBefore(afterCreation); + } + + @DisplayName("주간 랭킹을 생성할 수 있다") + @Test + void createsWeeklyRank() { + // arrange + LocalDate weekStart = LocalDate.of(2024, 12, 9); // 월요일 + + // act + ProductRank weeklyRank = new ProductRank( + ProductRank.PeriodType.WEEKLY, + weekStart, + 1L, + 1, + 100L, + 500L, + 1000L + ); + + // assert + assertThat(weeklyRank.getPeriodType()).isEqualTo(ProductRank.PeriodType.WEEKLY); + assertThat(weeklyRank.getPeriodStartDate()).isEqualTo(weekStart); + } + + @DisplayName("월간 랭킹을 생성할 수 있다") + @Test + void createsMonthlyRank() { + // arrange + LocalDate monthStart = LocalDate.of(2024, 12, 1); // 월의 1일 + + // act + ProductRank monthlyRank = new ProductRank( + ProductRank.PeriodType.MONTHLY, + monthStart, + 1L, + 1, + 100L, + 500L, + 1000L + ); + + // assert + assertThat(monthlyRank.getPeriodType()).isEqualTo(ProductRank.PeriodType.MONTHLY); + assertThat(monthlyRank.getPeriodStartDate()).isEqualTo(monthStart); + } + + @DisplayName("랭킹 정보를 업데이트할 수 있다") + @Test + void canUpdateRank() throws InterruptedException { + // arrange + ProductRank productRank = new ProductRank( + ProductRank.PeriodType.WEEKLY, + LocalDate.of(2024, 12, 9), + 1L, + 1, + 100L, + 500L, + 1000L + ); + Integer newRank = 2; + Long newLikeCount = 200L; + Long newSalesCount = 600L; + Long newViewCount = 1100L; + LocalDateTime initialUpdatedAt = productRank.getUpdatedAt(); + + // act + Thread.sleep(1); // 시간 차이를 보장하기 위한 작은 지연 + productRank.updateRank(newRank, newLikeCount, newSalesCount, newViewCount); + + // assert + assertThat(productRank.getRank()).isEqualTo(newRank); + assertThat(productRank.getLikeCount()).isEqualTo(newLikeCount); + assertThat(productRank.getSalesCount()).isEqualTo(newSalesCount); + assertThat(productRank.getViewCount()).isEqualTo(newViewCount); + assertThat(productRank.getUpdatedAt()).isAfter(initialUpdatedAt); + } + + @DisplayName("랭킹 업데이트 시 updatedAt이 갱신된다") + @Test + void updatesUpdatedAtWhenRankIsUpdated() throws InterruptedException { + // arrange + ProductRank productRank = new ProductRank( + ProductRank.PeriodType.WEEKLY, + LocalDate.of(2024, 12, 9), + 1L, + 1, + 100L, + 500L, + 1000L + ); + LocalDateTime initialUpdatedAt = productRank.getUpdatedAt(); + + // act + Thread.sleep(1); + productRank.updateRank(2, 200L, 600L, 1100L); + + // assert + assertThat(productRank.getUpdatedAt()).isAfter(initialUpdatedAt); + } + + @DisplayName("PeriodType enum이 올바르게 정의되어 있다") + @Test + void periodTypeEnumIsCorrectlyDefined() { + // assert + assertThat(ProductRank.PeriodType.WEEKLY).isNotNull(); + assertThat(ProductRank.PeriodType.MONTHLY).isNotNull(); + assertThat(ProductRank.PeriodType.values()).hasSize(2); + } + + @DisplayName("TOP 100 랭킹을 생성할 수 있다") + @Test + void createsTop100Rank() { + // arrange + Integer topRank = 100; + + // act + ProductRank top100Rank = new ProductRank( + ProductRank.PeriodType.WEEKLY, + LocalDate.of(2024, 12, 9), + 100L, + topRank, + 1L, + 1L, + 1L + ); + + // assert + assertThat(top100Rank.getRank()).isEqualTo(topRank); + assertThat(top100Rank.getRank()).isLessThanOrEqualTo(100); + } + + @DisplayName("랭킹 1위를 생성할 수 있다") + @Test + void createsFirstRank() { + // arrange + Integer firstRank = 1; + + // act + ProductRank firstPlaceRank = new ProductRank( + ProductRank.PeriodType.WEEKLY, + LocalDate.of(2024, 12, 9), + 1L, + firstRank, + 1000L, + 5000L, + 10000L + ); + + // assert + assertThat(firstPlaceRank.getRank()).isEqualTo(firstRank); + assertThat(firstPlaceRank.getRank()).isGreaterThanOrEqualTo(1); + } +} + diff --git a/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemProcessorTest.java b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemProcessorTest.java new file mode 100644 index 000000000..23869009a --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemProcessorTest.java @@ -0,0 +1,87 @@ +package com.loopers.infrastructure.batch.metrics; + +import com.loopers.domain.metrics.ProductMetrics; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * ProductMetricsItemProcessor 테스트. + */ +class ProductMetricsItemProcessorTest { + + private final ProductMetricsItemProcessor processor = new ProductMetricsItemProcessor(); + + @DisplayName("ProductMetrics를 그대로 전달한다 (pass-through)") + @Test + void processesItem_andReturnsSameItem() throws Exception { + // arrange + ProductMetrics item = new ProductMetrics(1L); + item.incrementLikeCount(); + item.incrementSalesCount(10); + item.incrementViewCount(); + + // act + ProductMetrics result = processor.process(item); + + // assert + assertThat(result).isSameAs(item); // 동일한 객체 반환 + assertThat(result.getProductId()).isEqualTo(1L); + assertThat(result.getLikeCount()).isEqualTo(1L); + assertThat(result.getSalesCount()).isEqualTo(10L); + assertThat(result.getViewCount()).isEqualTo(1L); + } + + @DisplayName("null이 아닌 모든 ProductMetrics를 처리한다") + @Test + void processesNonNullItem() throws Exception { + // arrange + ProductMetrics item = new ProductMetrics(100L); + + // act + ProductMetrics result = processor.process(item); + + // assert + assertThat(result).isNotNull(); + assertThat(result).isSameAs(item); + } + + @DisplayName("여러 번 처리해도 동일한 결과를 반환한다") + @Test + void processesItemMultipleTimes_returnsSameResult() throws Exception { + // arrange + ProductMetrics item = new ProductMetrics(1L); + item.incrementLikeCount(); + + // act + ProductMetrics result1 = processor.process(item); + ProductMetrics result2 = processor.process(item); + ProductMetrics result3 = processor.process(item); + + // assert + assertThat(result1).isSameAs(item); + assertThat(result2).isSameAs(item); + assertThat(result3).isSameAs(item); + } + + @DisplayName("초기값을 가진 ProductMetrics도 처리한다") + @Test + void processesItemWithInitialValues() throws Exception { + // arrange + ProductMetrics item = new ProductMetrics(1L); + // 초기값: 모든 카운트가 0 + + // act + ProductMetrics result = processor.process(item); + + // assert + assertThat(result).isSameAs(item); + assertThat(result.getLikeCount()).isEqualTo(0L); + assertThat(result.getSalesCount()).isEqualTo(0L); + assertThat(result.getViewCount()).isEqualTo(0L); + } +} + diff --git a/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemReaderTest.java b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemReaderTest.java new file mode 100644 index 000000000..4a3a75f93 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemReaderTest.java @@ -0,0 +1,134 @@ +package com.loopers.infrastructure.batch.metrics; + +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.metrics.ProductMetricsRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.batch.item.data.RepositoryItemReader; +import org.springframework.data.repository.PagingAndSortingRepository; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +/** + * ProductMetricsItemReader 테스트. + */ +@ExtendWith(MockitoExtension.class) +class ProductMetricsItemReaderTest { + + @Mock + private ProductMetricsRepository productMetricsRepository; + + @Mock + private PagingAndSortingRepository jpaRepository; + + @DisplayName("올바른 날짜 형식으로 Reader를 생성할 수 있다") + @Test + void createsReader_withValidDate() { + // arrange + String targetDate = "20241215"; + when(productMetricsRepository.getJpaRepository()).thenReturn(jpaRepository); + + ProductMetricsItemReader reader = new ProductMetricsItemReader(productMetricsRepository); + + // act + RepositoryItemReader itemReader = reader.createReader(targetDate); + + // assert + assertThat(itemReader).isNotNull(); + assertThat(itemReader.getName()).isEqualTo("productMetricsReader"); + } + + @DisplayName("날짜 파라미터가 null이면 오늘 날짜를 사용하여 Reader를 생성한다") + @Test + void createsReader_withNullDate_usesToday() { + // arrange + when(productMetricsRepository.getJpaRepository()).thenReturn(jpaRepository); + + ProductMetricsItemReader reader = new ProductMetricsItemReader(productMetricsRepository); + + // act + RepositoryItemReader itemReader = reader.createReader(null); + + // assert + assertThat(itemReader).isNotNull(); + } + + @DisplayName("날짜 파라미터가 빈 문자열이면 오늘 날짜를 사용하여 Reader를 생성한다") + @Test + void createsReader_withEmptyDate_usesToday() { + // arrange + when(productMetricsRepository.getJpaRepository()).thenReturn(jpaRepository); + + ProductMetricsItemReader reader = new ProductMetricsItemReader(productMetricsRepository); + + // act + RepositoryItemReader itemReader = reader.createReader(""); + + // assert + assertThat(itemReader).isNotNull(); + } + + @DisplayName("잘못된 날짜 형식이면 오늘 날짜를 사용하여 Reader를 생성한다") + @Test + void createsReader_withInvalidDate_usesToday() { + // arrange + when(productMetricsRepository.getJpaRepository()).thenReturn(jpaRepository); + + ProductMetricsItemReader reader = new ProductMetricsItemReader(productMetricsRepository); + + // act + RepositoryItemReader itemReader = reader.createReader("invalid-date"); + + // assert + assertThat(itemReader).isNotNull(); + } + + @DisplayName("날짜 파라미터를 올바르게 파싱하여 날짜 범위를 설정한다") + @Test + void parsesDateCorrectly_andSetsDateTimeRange() { + // arrange + String targetDate = "20241215"; + LocalDate expectedDate = LocalDate.of(2024, 12, 15); + LocalDateTime expectedStart = expectedDate.atStartOfDay(); + LocalDateTime expectedEnd = expectedDate.atTime(LocalTime.MAX); + + when(productMetricsRepository.getJpaRepository()).thenReturn(jpaRepository); + + ProductMetricsItemReader reader = new ProductMetricsItemReader(productMetricsRepository); + + // act + RepositoryItemReader itemReader = reader.createReader(targetDate); + + // assert + assertThat(itemReader).isNotNull(); + // 날짜 파싱이 올바르게 되었는지 확인 (Reader 내부에서 사용되므로 간접적으로 검증) + // 실제 날짜 범위는 Repository 호출 시 사용되므로, Reader가 정상 생성되었으면 성공 + } + + @DisplayName("JPA Repository를 통해 Reader를 생성한다") + @Test + void createsReader_usingJpaRepository() { + // arrange + String targetDate = "20241215"; + when(productMetricsRepository.getJpaRepository()).thenReturn(jpaRepository); + + ProductMetricsItemReader reader = new ProductMetricsItemReader(productMetricsRepository); + + // act + RepositoryItemReader itemReader = reader.createReader(targetDate); + + // assert + assertThat(itemReader).isNotNull(); + // getJpaRepository()가 호출되었는지 확인 + // (실제로는 RepositoryItemReader 내부에서 사용되므로 간접적으로 검증) + } +} + diff --git a/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemWriterTest.java b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemWriterTest.java new file mode 100644 index 000000000..d0613096e --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/metrics/ProductMetricsItemWriterTest.java @@ -0,0 +1,118 @@ +package com.loopers.infrastructure.batch.metrics; + +import com.loopers.domain.metrics.ProductMetrics; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.item.Chunk; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * ProductMetricsItemWriter 테스트. + */ +class ProductMetricsItemWriterTest { + + private final ProductMetricsItemWriter writer = new ProductMetricsItemWriter(); + + @DisplayName("Chunk를 정상적으로 처리할 수 있다") + @Test + void writesChunk_successfully() throws Exception { + // arrange + List items = createProductMetricsList(3); + Chunk chunk = new Chunk<>(items); + + // act & assert + assertThatCode(() -> writer.write(chunk)) + .doesNotThrowAnyException(); + } + + @DisplayName("빈 Chunk도 처리할 수 있다") + @Test + void writesEmptyChunk_successfully() throws Exception { + // arrange + Chunk chunk = new Chunk<>(new ArrayList<>()); + + // act & assert + assertThatCode(() -> writer.write(chunk)) + .doesNotThrowAnyException(); + } + + @DisplayName("큰 Chunk도 처리할 수 있다") + @Test + void writesLargeChunk_successfully() throws Exception { + // arrange + List items = createProductMetricsList(100); // Chunk 크기와 동일 + Chunk chunk = new Chunk<>(items); + + // act & assert + assertThatCode(() -> writer.write(chunk)) + .doesNotThrowAnyException(); + } + + @DisplayName("다양한 메트릭 값을 가진 Chunk를 처리할 수 있다") + @Test + void writesChunk_withVariousMetrics() throws Exception { + // arrange + List items = new ArrayList<>(); + + ProductMetrics metrics1 = new ProductMetrics(1L); + metrics1.incrementLikeCount(); + items.add(metrics1); + + ProductMetrics metrics2 = new ProductMetrics(2L); + metrics2.incrementSalesCount(100); + items.add(metrics2); + + ProductMetrics metrics3 = new ProductMetrics(3L); + metrics3.incrementViewCount(); + metrics3.incrementViewCount(); + items.add(metrics3); + + Chunk chunk = new Chunk<>(items); + + // act & assert + assertThatCode(() -> writer.write(chunk)) + .doesNotThrowAnyException(); + } + + @DisplayName("Chunk의 모든 항목을 처리한다") + @Test + void writesChunk_processesAllItems() throws Exception { + // arrange + int itemCount = 10; + List items = createProductMetricsList(itemCount); + Chunk chunk = new Chunk<>(items); + + // act + writer.write(chunk); + + // assert + // 현재는 로깅만 수행하므로 예외가 발생하지 않으면 성공 + // 향후 Materialized View 저장 로직 추가 시 추가 검증 필요 + assertThatCode(() -> writer.write(chunk)) + .doesNotThrowAnyException(); + } + + /** + * 테스트용 ProductMetrics 리스트를 생성합니다. + * + * @param count 생성할 항목 수 + * @return ProductMetrics 리스트 + */ + private List createProductMetricsList(int count) { + List items = new ArrayList<>(); + for (long i = 1; i <= count; i++) { + ProductMetrics metrics = new ProductMetrics(i); + metrics.incrementLikeCount(); + metrics.incrementSalesCount((int) i); + metrics.incrementViewCount(); + items.add(metrics); + } + return items; + } +} + diff --git a/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationProcessorTest.java b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationProcessorTest.java new file mode 100644 index 000000000..a87ec4585 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationProcessorTest.java @@ -0,0 +1,121 @@ +package com.loopers.infrastructure.batch.rank; + +import com.loopers.domain.rank.ProductRank; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * ProductRankAggregationProcessor 테스트. + */ +class ProductRankAggregationProcessorTest { + + private final ProductRankAggregationProcessor processor = new ProductRankAggregationProcessor(); + + @DisplayName("주간 기간 정보를 설정할 수 있다") + @Test + void setsWeeklyPeriod() { + // arrange + LocalDate targetDate = LocalDate.of(2024, 12, 15); // 일요일 + ProductRank.PeriodType periodType = ProductRank.PeriodType.WEEKLY; + + // act + processor.setPeriod(periodType, targetDate); + + // assert + assertThat(processor.getPeriodType()).isEqualTo(periodType); + assertThat(processor.getPeriodStartDate()).isEqualTo(LocalDate.of(2024, 12, 9)); // 월요일 + } + + @DisplayName("월간 기간 정보를 설정할 수 있다") + @Test + void setsMonthlyPeriod() { + // arrange + LocalDate targetDate = LocalDate.of(2024, 12, 15); + ProductRank.PeriodType periodType = ProductRank.PeriodType.MONTHLY; + + // act + processor.setPeriod(periodType, targetDate); + + // assert + assertThat(processor.getPeriodType()).isEqualTo(periodType); + assertThat(processor.getPeriodStartDate()).isEqualTo(LocalDate.of(2024, 12, 1)); // 월의 1일 + } + + @DisplayName("주간 기간 설정 시 해당 주의 월요일을 시작일로 계산한다") + @Test + void calculatesWeekStartAsMonday_whenSettingWeeklyPeriod() { + // arrange + ProductRank.PeriodType periodType = ProductRank.PeriodType.WEEKLY; + + // 월요일 + LocalDate monday = LocalDate.of(2024, 12, 9); + // 수요일 + LocalDate wednesday = LocalDate.of(2024, 12, 11); + // 일요일 + LocalDate sunday = LocalDate.of(2024, 12, 15); + + // act & assert + processor.setPeriod(periodType, monday); + assertThat(processor.getPeriodStartDate()).isEqualTo(monday); + + processor.setPeriod(periodType, wednesday); + assertThat(processor.getPeriodStartDate()).isEqualTo(monday); + + processor.setPeriod(periodType, sunday); + assertThat(processor.getPeriodStartDate()).isEqualTo(monday); + } + + @DisplayName("월간 기간 설정 시 해당 월의 1일을 시작일로 계산한다") + @Test + void calculatesMonthStartAsFirstDay_whenSettingMonthlyPeriod() { + // arrange + ProductRank.PeriodType periodType = ProductRank.PeriodType.MONTHLY; + LocalDate expectedStart = LocalDate.of(2024, 12, 1); + + // 1일 + LocalDate firstDay = LocalDate.of(2024, 12, 1); + // 15일 + LocalDate midDay = LocalDate.of(2024, 12, 15); + // 마지막 일 + LocalDate lastDay = LocalDate.of(2024, 12, 31); + + // act & assert + processor.setPeriod(periodType, firstDay); + assertThat(processor.getPeriodStartDate()).isEqualTo(expectedStart); + + processor.setPeriod(periodType, midDay); + assertThat(processor.getPeriodStartDate()).isEqualTo(expectedStart); + + processor.setPeriod(periodType, lastDay); + assertThat(processor.getPeriodStartDate()).isEqualTo(expectedStart); + } + + @DisplayName("기간 정보를 여러 번 설정할 수 있다") + @Test + void canSetPeriodMultipleTimes() { + // arrange + LocalDate firstDate = LocalDate.of(2024, 12, 15); + LocalDate secondDate = LocalDate.of(2024, 11, 20); + + // act + processor.setPeriod(ProductRank.PeriodType.WEEKLY, firstDate); + ProductRank.PeriodType firstType = processor.getPeriodType(); + LocalDate firstStart = processor.getPeriodStartDate(); + + processor.setPeriod(ProductRank.PeriodType.MONTHLY, secondDate); + ProductRank.PeriodType secondType = processor.getPeriodType(); + LocalDate secondStart = processor.getPeriodStartDate(); + + // assert + assertThat(firstType).isEqualTo(ProductRank.PeriodType.WEEKLY); + assertThat(firstStart).isEqualTo(LocalDate.of(2024, 12, 9)); + + assertThat(secondType).isEqualTo(ProductRank.PeriodType.MONTHLY); + assertThat(secondStart).isEqualTo(LocalDate.of(2024, 11, 1)); + } +} + diff --git a/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReaderTest.java b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReaderTest.java new file mode 100644 index 000000000..50e225b7e --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReaderTest.java @@ -0,0 +1,152 @@ +package com.loopers.infrastructure.batch.rank; + +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.metrics.ProductMetricsRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.batch.item.data.RepositoryItemReader; +import org.springframework.data.repository.PagingAndSortingRepository; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +/** + * ProductRankAggregationReader 테스트. + */ +@ExtendWith(MockitoExtension.class) +class ProductRankAggregationReaderTest { + + @Mock + private ProductMetricsRepository productMetricsRepository; + + @Mock + private PagingAndSortingRepository jpaRepository; + + @DisplayName("주간 Reader를 생성할 수 있다") + @Test + void createsWeeklyReader() { + // arrange + LocalDate targetDate = LocalDate.of(2024, 12, 15); // 일요일 + when(productMetricsRepository.getJpaRepository()).thenReturn(jpaRepository); + + ProductRankAggregationReader reader = new ProductRankAggregationReader(productMetricsRepository); + + // act + RepositoryItemReader itemReader = reader.createWeeklyReader(targetDate); + + // assert + assertThat(itemReader).isNotNull(); + assertThat(itemReader.getName()).isEqualTo("weeklyReader"); + } + + @DisplayName("주간 Reader는 해당 주의 월요일부터 다음 주 월요일까지의 데이터를 조회한다") + @Test + void weeklyReaderQueriesFromMondayToNextMonday() { + // arrange + LocalDate targetDate = LocalDate.of(2024, 12, 15); // 일요일 + when(productMetricsRepository.getJpaRepository()).thenReturn(jpaRepository); + + ProductRankAggregationReader reader = new ProductRankAggregationReader(productMetricsRepository); + + // act + RepositoryItemReader itemReader = reader.createWeeklyReader(targetDate); + + // assert + assertThat(itemReader).isNotNull(); + // 주간 시작일은 해당 주의 월요일이어야 함 + // 2024-12-15(일) -> 2024-12-09(월)이 시작일 + } + + @DisplayName("월간 Reader를 생성할 수 있다") + @Test + void createsMonthlyReader() { + // arrange + LocalDate targetDate = LocalDate.of(2024, 12, 15); + when(productMetricsRepository.getJpaRepository()).thenReturn(jpaRepository); + + ProductRankAggregationReader reader = new ProductRankAggregationReader(productMetricsRepository); + + // act + RepositoryItemReader itemReader = reader.createMonthlyReader(targetDate); + + // assert + assertThat(itemReader).isNotNull(); + assertThat(itemReader.getName()).isEqualTo("monthlyReader"); + } + + @DisplayName("월간 Reader는 해당 월의 1일부터 다음 달 1일까지의 데이터를 조회한다") + @Test + void monthlyReaderQueriesFromFirstDayToNextMonth() { + // arrange + LocalDate targetDate = LocalDate.of(2024, 12, 15); + when(productMetricsRepository.getJpaRepository()).thenReturn(jpaRepository); + + ProductRankAggregationReader reader = new ProductRankAggregationReader(productMetricsRepository); + + // act + RepositoryItemReader itemReader = reader.createMonthlyReader(targetDate); + + // assert + assertThat(itemReader).isNotNull(); + // 월간 시작일은 해당 월의 1일이어야 함 + // 2024-12-15 -> 2024-12-01이 시작일 + } + + @DisplayName("주간 Reader는 주의 어느 날짜든 올바른 주간 범위를 계산한다") + @Test + void weeklyReaderCalculatesCorrectWeekRange_forAnyDayInWeek() { + // arrange + when(productMetricsRepository.getJpaRepository()).thenReturn(jpaRepository); + ProductRankAggregationReader reader = new ProductRankAggregationReader(productMetricsRepository); + + // 월요일 + LocalDate monday = LocalDate.of(2024, 12, 9); + // 수요일 + LocalDate wednesday = LocalDate.of(2024, 12, 11); + // 일요일 + LocalDate sunday = LocalDate.of(2024, 12, 15); + + // act + RepositoryItemReader mondayReader = reader.createWeeklyReader(monday); + RepositoryItemReader wednesdayReader = reader.createWeeklyReader(wednesday); + RepositoryItemReader sundayReader = reader.createWeeklyReader(sunday); + + // assert + assertThat(mondayReader).isNotNull(); + assertThat(wednesdayReader).isNotNull(); + assertThat(sundayReader).isNotNull(); + // 모두 같은 주의 월요일부터 시작해야 함 + } + + @DisplayName("월간 Reader는 월의 어느 날짜든 올바른 월간 범위를 계산한다") + @Test + void monthlyReaderCalculatesCorrectMonthRange_forAnyDayInMonth() { + // arrange + when(productMetricsRepository.getJpaRepository()).thenReturn(jpaRepository); + ProductRankAggregationReader reader = new ProductRankAggregationReader(productMetricsRepository); + + // 1일 + LocalDate firstDay = LocalDate.of(2024, 12, 1); + // 15일 + LocalDate midDay = LocalDate.of(2024, 12, 15); + // 마지막 일 + LocalDate lastDay = LocalDate.of(2024, 12, 31); + + // act + RepositoryItemReader firstDayReader = reader.createMonthlyReader(firstDay); + RepositoryItemReader midDayReader = reader.createMonthlyReader(midDay); + RepositoryItemReader lastDayReader = reader.createMonthlyReader(lastDay); + + // assert + assertThat(firstDayReader).isNotNull(); + assertThat(midDayReader).isNotNull(); + assertThat(lastDayReader).isNotNull(); + // 모두 같은 월의 1일부터 시작해야 함 + } +} + diff --git a/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessorTest.java b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessorTest.java new file mode 100644 index 000000000..cf55ad54d --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessorTest.java @@ -0,0 +1,263 @@ +package com.loopers.infrastructure.batch.rank; + +import com.loopers.domain.rank.ProductRank; +import com.loopers.domain.rank.ProductRankScore; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +/** + * ProductRankCalculationProcessor 테스트. + */ +@ExtendWith(MockitoExtension.class) +class ProductRankCalculationProcessorTest { + + @Mock + private ProductRankAggregationProcessor productRankAggregationProcessor; + + private ProductRankCalculationProcessor processor; + + @BeforeEach + void setUp() { + processor = new ProductRankCalculationProcessor(productRankAggregationProcessor); + } + + @DisplayName("랭킹 번호를 1부터 순차적으로 부여한다") + @Test + void assignsRankSequentially() throws Exception { + // arrange + ProductRank.PeriodType periodType = ProductRank.PeriodType.WEEKLY; + LocalDate periodStartDate = LocalDate.of(2024, 12, 9); + + when(productRankAggregationProcessor.getPeriodType()).thenReturn(periodType); + when(productRankAggregationProcessor.getPeriodStartDate()).thenReturn(periodStartDate); + + ProductRankScore score1 = createProductRankScore(1L, 10L, 20L, 5L); + ProductRankScore score2 = createProductRankScore(2L, 15L, 25L, 8L); + ProductRankScore score3 = createProductRankScore(3L, 8L, 15L, 3L); + + // act + ProductRank rank1 = processor.process(score1); + ProductRank rank2 = processor.process(score2); + ProductRank rank3 = processor.process(score3); + + // assert + assertThat(rank1).isNotNull(); + assertThat(rank1.getRank()).isEqualTo(1); + assertThat(rank1.getProductId()).isEqualTo(1L); + + assertThat(rank2).isNotNull(); + assertThat(rank2.getRank()).isEqualTo(2); + assertThat(rank2.getProductId()).isEqualTo(2L); + + assertThat(rank3).isNotNull(); + assertThat(rank3.getRank()).isEqualTo(3); + assertThat(rank3.getProductId()).isEqualTo(3L); + } + + @DisplayName("TOP 100에 포함되는 경우 ProductRank를 반환한다") + @Test + void returnsProductRankForTop100() throws Exception { + // arrange + ProductRank.PeriodType periodType = ProductRank.PeriodType.WEEKLY; + LocalDate periodStartDate = LocalDate.of(2024, 12, 9); + + when(productRankAggregationProcessor.getPeriodType()).thenReturn(periodType); + when(productRankAggregationProcessor.getPeriodStartDate()).thenReturn(periodStartDate); + + ProductRankScore score = createProductRankScore(1L, 10L, 20L, 5L); + + // act + ProductRank result = processor.process(score); + + // assert + assertThat(result).isNotNull(); + assertThat(result.getRank()).isEqualTo(1); + assertThat(result.getProductId()).isEqualTo(1L); + assertThat(result.getPeriodType()).isEqualTo(periodType); + assertThat(result.getPeriodStartDate()).isEqualTo(periodStartDate); + assertThat(result.getLikeCount()).isEqualTo(10L); + assertThat(result.getSalesCount()).isEqualTo(20L); + assertThat(result.getViewCount()).isEqualTo(5L); + } + + @DisplayName("100번째 처리 후 ThreadLocal이 정리된다") + @Test + void cleansUpThreadLocalAfter100th() throws Exception { + // arrange + ProductRank.PeriodType periodType = ProductRank.PeriodType.WEEKLY; + LocalDate periodStartDate = LocalDate.of(2024, 12, 9); + + when(productRankAggregationProcessor.getPeriodType()).thenReturn(periodType); + when(productRankAggregationProcessor.getPeriodStartDate()).thenReturn(periodStartDate); + + // 99개까지 처리 + for (int i = 1; i <= 99; i++) { + ProductRankScore score = createProductRankScore((long) i, 10L, 20L, 5L); + ProductRank result = processor.process(score); + assertThat(result).isNotNull(); + assertThat(result.getRank()).isEqualTo(i); + } + + // 100번째 처리 (이 시점에서 rank=100이 되고, rank == TOP_RANK_LIMIT이므로 remove() 호출됨) + ProductRankScore score100 = createProductRankScore(100L, 10L, 20L, 5L); + ProductRank rank100 = processor.process(score100); + + // assert + assertThat(rank100).isNotNull(); + assertThat(rank100.getRank()).isEqualTo(100); + + // 100번째 처리 후 remove()가 호출되어 ThreadLocal이 정리됨 + // 실제 배치에서는 100번째 이후는 처리되지 않으므로, + // 101번째를 처리하면 currentRank가 0으로 초기화되어 rank=1이 됨 + // 이는 실제 배치 동작과는 다르지만, ThreadLocal 정리 동작을 검증하기 위한 테스트 + ProductRankScore score101 = createProductRankScore(101L, 10L, 20L, 5L); + ProductRank result = processor.process(score101); + + // remove() 후이므로 currentRank가 0으로 초기화되어 rank=1이 되고, + // rank <= 100이므로 ProductRank가 반환됨 + assertThat(result).isNotNull(); + assertThat(result.getRank()).isEqualTo(1); // remove() 후 다시 1부터 시작 + } + + @DisplayName("정확히 100번째는 ProductRank를 반환한다") + @Test + void returnsProductRankFor100th() throws Exception { + // arrange + ProductRank.PeriodType periodType = ProductRank.PeriodType.WEEKLY; + LocalDate periodStartDate = LocalDate.of(2024, 12, 9); + + when(productRankAggregationProcessor.getPeriodType()).thenReturn(periodType); + when(productRankAggregationProcessor.getPeriodStartDate()).thenReturn(periodStartDate); + + // 99개까지 처리 + for (int i = 1; i <= 99; i++) { + ProductRankScore score = createProductRankScore((long) i, 10L, 20L, 5L); + processor.process(score); + } + + // 100번째 처리 + ProductRankScore score100 = createProductRankScore(100L, 10L, 20L, 5L); + + // act + ProductRank result = processor.process(score100); + + // assert + assertThat(result).isNotNull(); + assertThat(result.getRank()).isEqualTo(100); + assertThat(result.getProductId()).isEqualTo(100L); + } + + @DisplayName("기간 정보가 설정되지 않으면 null을 반환한다") + @Test + void returnsNullWhenPeriodNotSet() throws Exception { + // arrange + when(productRankAggregationProcessor.getPeriodType()).thenReturn(null); + when(productRankAggregationProcessor.getPeriodStartDate()).thenReturn(null); + + ProductRankScore score = createProductRankScore(1L, 10L, 20L, 5L); + + // act + ProductRank result = processor.process(score); + + // assert + assertThat(result).isNull(); + } + + @DisplayName("기간 시작일이 설정되지 않으면 null을 반환한다") + @Test + void returnsNullWhenPeriodStartDateNotSet() throws Exception { + // arrange + when(productRankAggregationProcessor.getPeriodType()).thenReturn(ProductRank.PeriodType.WEEKLY); + when(productRankAggregationProcessor.getPeriodStartDate()).thenReturn(null); + + ProductRankScore score = createProductRankScore(1L, 10L, 20L, 5L); + + // act + ProductRank result = processor.process(score); + + // assert + assertThat(result).isNull(); + } + + @DisplayName("주간 기간 정보로 ProductRank를 생성한다") + @Test + void createsProductRankWithWeeklyPeriod() throws Exception { + // arrange + ProductRank.PeriodType periodType = ProductRank.PeriodType.WEEKLY; + LocalDate periodStartDate = LocalDate.of(2024, 12, 9); + + when(productRankAggregationProcessor.getPeriodType()).thenReturn(periodType); + when(productRankAggregationProcessor.getPeriodStartDate()).thenReturn(periodStartDate); + + ProductRankScore score = createProductRankScore(1L, 10L, 20L, 5L); + + // act + ProductRank result = processor.process(score); + + // assert + assertThat(result).isNotNull(); + assertThat(result.getPeriodType()).isEqualTo(ProductRank.PeriodType.WEEKLY); + assertThat(result.getPeriodStartDate()).isEqualTo(periodStartDate); + } + + @DisplayName("월간 기간 정보로 ProductRank를 생성한다") + @Test + void createsProductRankWithMonthlyPeriod() throws Exception { + // arrange + ProductRank.PeriodType periodType = ProductRank.PeriodType.MONTHLY; + LocalDate periodStartDate = LocalDate.of(2024, 12, 1); + + when(productRankAggregationProcessor.getPeriodType()).thenReturn(periodType); + when(productRankAggregationProcessor.getPeriodStartDate()).thenReturn(periodStartDate); + + ProductRankScore score = createProductRankScore(1L, 10L, 20L, 5L); + + // act + ProductRank result = processor.process(score); + + // assert + assertThat(result).isNotNull(); + assertThat(result.getPeriodType()).isEqualTo(ProductRank.PeriodType.MONTHLY); + assertThat(result.getPeriodStartDate()).isEqualTo(periodStartDate); + } + + @DisplayName("ProductRankScore의 메트릭 값을 ProductRank에 전달한다") + @Test + void transfersMetricsFromScoreToRank() throws Exception { + // arrange + ProductRank.PeriodType periodType = ProductRank.PeriodType.WEEKLY; + LocalDate periodStartDate = LocalDate.of(2024, 12, 9); + + when(productRankAggregationProcessor.getPeriodType()).thenReturn(periodType); + when(productRankAggregationProcessor.getPeriodStartDate()).thenReturn(periodStartDate); + + ProductRankScore score = createProductRankScore(1L, 100L, 200L, 50L); + + // act + ProductRank result = processor.process(score); + + // assert + assertThat(result).isNotNull(); + assertThat(result.getLikeCount()).isEqualTo(100L); + assertThat(result.getSalesCount()).isEqualTo(200L); + assertThat(result.getViewCount()).isEqualTo(50L); + } + + /** + * 테스트용 ProductRankScore를 생성합니다. + */ + private ProductRankScore createProductRankScore(Long productId, Long likeCount, Long salesCount, Long viewCount) { + double score = likeCount * 0.3 + salesCount * 0.5 + viewCount * 0.2; + return new ProductRankScore(productId, likeCount, salesCount, viewCount, score); + } +} + diff --git a/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriterTest.java b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriterTest.java new file mode 100644 index 000000000..5aab9868a --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriterTest.java @@ -0,0 +1,251 @@ +package com.loopers.infrastructure.batch.rank; + +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.rank.ProductRankScore; +import com.loopers.domain.rank.ProductRankScoreRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.batch.item.Chunk; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.*; + +/** + * ProductRankScoreAggregationWriter 테스트. + */ +@ExtendWith(MockitoExtension.class) +class ProductRankScoreAggregationWriterTest { + + @Mock + private ProductRankScoreRepository productRankScoreRepository; + + @InjectMocks + private ProductRankScoreAggregationWriter writer; + + @DisplayName("Chunk 내에서 같은 product_id를 가진 메트릭을 집계한다") + @Test + void aggregatesMetricsByProductId() throws Exception { + // arrange + List items = new ArrayList<>(); + + // 같은 product_id를 가진 메트릭 2개 + ProductMetrics metrics1 = new ProductMetrics(1L); + metrics1.incrementLikeCount(); + metrics1.incrementSalesCount(10); + metrics1.incrementViewCount(); + items.add(metrics1); + + ProductMetrics metrics2 = new ProductMetrics(1L); + metrics2.incrementLikeCount(); + metrics2.incrementSalesCount(20); + metrics2.incrementViewCount(); + items.add(metrics2); + + // 다른 product_id + ProductMetrics metrics3 = new ProductMetrics(2L); + metrics3.incrementLikeCount(); + items.add(metrics3); + + Chunk chunk = new Chunk<>(items); + + when(productRankScoreRepository.findByProductId(anyLong())).thenReturn(Optional.empty()); + doNothing().when(productRankScoreRepository).saveAll(anyList()); + + // act + writer.write(chunk); + + // assert + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(productRankScoreRepository, times(1)).saveAll(captor.capture()); + + List savedScores = captor.getValue(); + assertThat(savedScores).hasSize(2); + + // product_id=1: 좋아요 2, 판매량 30, 조회수 2 + ProductRankScore score1 = savedScores.stream() + .filter(s -> s.getProductId().equals(1L)) + .findFirst() + .orElseThrow(); + assertThat(score1.getLikeCount()).isEqualTo(2L); + assertThat(score1.getSalesCount()).isEqualTo(30L); + assertThat(score1.getViewCount()).isEqualTo(2L); + + // product_id=2: 좋아요 1, 판매량 0, 조회수 0 + ProductRankScore score2 = savedScores.stream() + .filter(s -> s.getProductId().equals(2L)) + .findFirst() + .orElseThrow(); + assertThat(score2.getLikeCount()).isEqualTo(1L); + assertThat(score2.getSalesCount()).isEqualTo(0L); + assertThat(score2.getViewCount()).isEqualTo(0L); + } + + @DisplayName("점수를 올바른 가중치로 계산한다") + @Test + void calculatesScoreWithCorrectWeights() throws Exception { + // arrange + List items = new ArrayList<>(); + + ProductMetrics metrics = new ProductMetrics(1L); + metrics.incrementLikeCount(); // 1 + metrics.incrementSalesCount(10); // 10 + metrics.incrementViewCount(); // 1 + items.add(metrics); + + Chunk chunk = new Chunk<>(items); + + when(productRankScoreRepository.findByProductId(anyLong())).thenReturn(Optional.empty()); + doNothing().when(productRankScoreRepository).saveAll(anyList()); + + // act + writer.write(chunk); + + // assert + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(productRankScoreRepository, times(1)).saveAll(captor.capture()); + + ProductRankScore savedScore = captor.getValue().get(0); + // 점수 = 1 * 0.3 + 10 * 0.5 + 1 * 0.2 = 0.3 + 5.0 + 0.2 = 5.5 + assertThat(savedScore.getScore()).isEqualTo(5.5); + } + + @DisplayName("기존 데이터가 있으면 누적하여 저장한다") + @Test + void accumulatesWithExistingData() throws Exception { + // arrange + List items = new ArrayList<>(); + + ProductMetrics metrics = new ProductMetrics(1L); + metrics.incrementLikeCount(); + metrics.incrementSalesCount(10); + metrics.incrementViewCount(); + items.add(metrics); + + Chunk chunk = new Chunk<>(items); + + // 기존 데이터: 좋아요 5, 판매량 20, 조회수 3 + ProductRankScore existingScore = new ProductRankScore(1L, 5L, 20L, 3L, 12.1); + when(productRankScoreRepository.findByProductId(1L)).thenReturn(Optional.of(existingScore)); + doNothing().when(productRankScoreRepository).saveAll(anyList()); + + // act + writer.write(chunk); + + // assert + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(productRankScoreRepository, times(1)).saveAll(captor.capture()); + + ProductRankScore savedScore = captor.getValue().get(0); + // 누적: 좋아요 5+1=6, 판매량 20+10=30, 조회수 3+1=4 + assertThat(savedScore.getLikeCount()).isEqualTo(6L); + assertThat(savedScore.getSalesCount()).isEqualTo(30L); + assertThat(savedScore.getViewCount()).isEqualTo(4L); + // 점수 = 6 * 0.3 + 30 * 0.5 + 4 * 0.2 = 1.8 + 15.0 + 0.8 = 17.6 + assertThat(savedScore.getScore()).isEqualTo(17.6); + } + + @DisplayName("빈 Chunk는 처리하지 않는다") + @Test + void skipsEmptyChunk() throws Exception { + // arrange + Chunk chunk = new Chunk<>(new ArrayList<>()); + + // act + writer.write(chunk); + + // assert + verify(productRankScoreRepository, never()).findByProductId(anyLong()); + verify(productRankScoreRepository, never()).saveAll(anyList()); + } + + @DisplayName("여러 product_id를 가진 Chunk를 처리한다") + @Test + void processesMultipleProductIds() throws Exception { + // arrange + List items = new ArrayList<>(); + + for (long i = 1; i <= 5; i++) { + ProductMetrics metrics = new ProductMetrics(i); + metrics.incrementLikeCount(); + metrics.incrementSalesCount((int) i); + metrics.incrementViewCount(); + items.add(metrics); + } + + Chunk chunk = new Chunk<>(items); + + when(productRankScoreRepository.findByProductId(anyLong())).thenReturn(Optional.empty()); + doNothing().when(productRankScoreRepository).saveAll(anyList()); + + // act + writer.write(chunk); + + // assert + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(productRankScoreRepository, times(1)).saveAll(captor.capture()); + + List savedScores = captor.getValue(); + assertThat(savedScores).hasSize(5); + + // 각 product_id별로 저장되었는지 확인 + for (long i = 1; i <= 5; i++) { + long productId = i; + ProductRankScore score = savedScores.stream() + .filter(s -> s.getProductId().equals(productId)) + .findFirst() + .orElseThrow(); + assertThat(score.getProductId()).isEqualTo(productId); + assertThat(score.getLikeCount()).isEqualTo(1L); + assertThat(score.getSalesCount()).isEqualTo(productId); + assertThat(score.getViewCount()).isEqualTo(1L); + } + } + + @DisplayName("기존 데이터가 없으면 새로 생성한다") + @Test + void createsNewScoreWhenNoExistingData() throws Exception { + // arrange + List items = new ArrayList<>(); + + ProductMetrics metrics = new ProductMetrics(1L); + metrics.incrementLikeCount(); + metrics.incrementSalesCount(10); + metrics.incrementViewCount(); + items.add(metrics); + + Chunk chunk = new Chunk<>(items); + + when(productRankScoreRepository.findByProductId(1L)).thenReturn(Optional.empty()); + doNothing().when(productRankScoreRepository).saveAll(anyList()); + + // act + writer.write(chunk); + + // assert + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(productRankScoreRepository, times(1)).saveAll(captor.capture()); + + ProductRankScore savedScore = captor.getValue().get(0); + assertThat(savedScore.getProductId()).isEqualTo(1L); + assertThat(savedScore.getLikeCount()).isEqualTo(1L); + assertThat(savedScore.getSalesCount()).isEqualTo(10L); + assertThat(savedScore.getViewCount()).isEqualTo(1L); + } +} + diff --git a/modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java b/modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java index 14251dad8..8648e9c8f 100644 --- a/modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java +++ b/modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java @@ -38,7 +38,23 @@ public void truncateAllTables() { if (!tableName.startsWith("`") && !tableName.endsWith("`")) { tableName = "`" + tableName + "`"; } - entityManager.createNativeQuery("TRUNCATE TABLE " + tableName).executeUpdate(); + + // 테이블이 존재하는지 확인 후 TRUNCATE 수행 + try { + // 테이블 존재 여부 확인 + String checkTableSql = "SELECT COUNT(*) FROM information_schema.tables " + + "WHERE table_schema = DATABASE() AND table_name = ?"; + Long count = ((Number) entityManager.createNativeQuery(checkTableSql) + .setParameter(1, table.replace("`", "")) + .getSingleResult()).longValue(); + + if (count > 0) { + entityManager.createNativeQuery("TRUNCATE TABLE " + tableName).executeUpdate(); + } + } catch (Exception e) { + // 테이블이 없거나 오류가 발생하면 무시하고 계속 진행 + // 로그는 남기지 않음 (테스트 환경에서 정상적인 상황일 수 있음) + } } entityManager.createNativeQuery("SET FOREIGN_KEY_CHECKS = 1").executeUpdate(); diff --git a/settings.gradle.kts b/settings.gradle.kts index 161a1ba24..eeb4fbb90 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -2,6 +2,7 @@ rootProject.name = "loopers-java-spring-template" include( ":apps:commerce-api", + ":apps:commerce-batch", ":apps:pg-simulator", ":apps:commerce-streamer", ":modules:jpa", From 37a09a98144f5921436366d397497cba998a268e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=84=80=E1=85=A5=E1=86=AB=E1=84=8B?= =?UTF-8?q?=E1=85=A7=E1=86=BC?= <> Date: Fri, 2 Jan 2026 20:30:07 +0900 Subject: [PATCH 12/12] =?UTF-8?q?cdoerabbit=20=ED=94=BC=EB=93=9C=EB=B0=B1?= =?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 * 트랜젝션 어노테이션 추가 * 랭킹 대상 항목이 100개 미만일 때의 배치 에외 처리 * @StepScope를 적용하여 Step 실행마다 새 인스턴스를 생성 * 랭크 계산 후 싱글톤 인스턴스 내의 필드 초기화하여 데이터 오염 및 메모리 누수 문제 방지 * 배치 실행 파라미터에서 발생할 수 있는 null pointer exeception 수정 * n+1 쿼리 개선 --- .../application/ranking/RankingService.java | 6 +- .../com/loopers/domain/rank/ProductRank.java | 5 +- .../rank/ProductRankRepositoryImpl.java | 2 + .../metrics/ProductMetricsRepository.java | 14 +++- .../com/loopers/domain/rank/ProductRank.java | 5 +- .../rank/ProductRankScoreRepository.java | 12 ++++ .../rank/ProductRankAggregationReader.java | 69 ++++++++++++++++--- .../rank/ProductRankCalculationProcessor.java | 12 ++-- .../rank/ProductRankCalculationReader.java | 2 + .../rank/ProductRankCalculationWriter.java | 2 + .../batch/rank/ProductRankJobConfig.java | 4 ++ .../ProductRankScoreAggregationWriter.java | 22 ++++-- .../metrics/ProductMetricsJpaRepository.java | 13 +++- .../metrics/ProductMetricsRepositoryImpl.java | 1 - .../rank/ProductRankScoreRepositoryImpl.java | 13 ++++ .../ProductRankAggregationReaderTest.java | 60 ++++++++++------ .../ProductRankCalculationProcessorTest.java | 27 ++------ ...ProductRankScoreAggregationWriterTest.java | 16 ++--- 18 files changed, 198 insertions(+), 87 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java index d4b0d38d2..b6ebf5fc5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/ranking/RankingService.java @@ -417,8 +417,10 @@ private RankingsResponse getRankingsFromMaterializedView( Map brandMap = brandService.getBrands(brandIds).stream() .collect(Collectors.toMap(Brand::getId, brand -> brand)); - // 랭킹 항목 생성 + // 랭킹 항목 생성 (순위 재계산: 누락된 항목 제외 후 연속 순위 부여) List rankingItems = new ArrayList<>(); + long currentRank = start + 1; // 1-based 순위 (페이지 시작 순위) + for (com.loopers.domain.rank.ProductRank rank : pagedRanks) { Long productId = rank.getProductId(); Product product = productMap.get(productId); @@ -445,7 +447,7 @@ private RankingsResponse getRankingsFromMaterializedView( double score = calculateScore(rank.getLikeCount(), rank.getSalesCount(), rank.getViewCount()); rankingItems.add(new RankingItem( - rank.getRank().longValue(), + currentRank++, // 연속 순위 부여 score, productDetail )); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRank.java b/apps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRank.java index 30abae5d3..22dfd22c9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRank.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/rank/ProductRank.java @@ -16,8 +16,9 @@ *

    * Materialized View 설계: *

      - *
    • 주간 랭킹: `mv_product_rank_weekly` (period_type = WEEKLY)
    • - *
    • 월간 랭킹: `mv_product_rank_monthly` (period_type = MONTHLY)
    • + *
    • 테이블: `mv_product_rank` (단일 테이블)
    • + *
    • 주간 랭킹: period_type = WEEKLY
    • + *
    • 월간 랭킹: period_type = MONTHLY
    • *
    • TOP 100만 저장하여 조회 성능 최적화
    • *
    *

    diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/ProductRankRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/ProductRankRepositoryImpl.java index 046c6a035..d995ff486 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/ProductRankRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/ProductRankRepositoryImpl.java @@ -6,6 +6,7 @@ import jakarta.persistence.PersistenceContext; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; import java.util.List; @@ -19,6 +20,7 @@ */ @Slf4j @Repository +@Transactional(readOnly = true) public class ProductRankRepositoryImpl implements ProductRankRepository { @PersistenceContext diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java index aa831ba5a..4df1e6311 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/metrics/ProductMetricsRepository.java @@ -54,9 +54,18 @@ public interface ProductMetricsRepository { * Spring Batch의 JpaPagingItemReader에서 사용됩니다. * updated_at 필드를 기준으로 해당 날짜의 데이터만 조회합니다. *

    + *

    + * 주의: 쿼리는 {@code updatedAt >= :startDateTime AND updatedAt < :endDateTime} 조건을 사용하므로, + * endDateTime은 exclusive end입니다. 예를 들어, 2024-12-15의 데이터를 조회하려면: + *

      + *
    • startDateTime: 2024-12-15 00:00:00
    • + *
    • endDateTime: 2024-12-16 00:00:00 (다음 날 00:00:00)
    • + *
    + * 또는 {@code date.atTime(LocalTime.MAX)}를 사용할 수도 있습니다. + *

    * - * @param startDateTime 조회 시작 시각 (해당 날짜의 00:00:00) - * @param endDateTime 조회 종료 시각 (해당 날짜의 23:59:59.999999999) + * @param startDateTime 조회 시작 시각 (해당 날짜의 00:00:00, inclusive) + * @param endDateTime 조회 종료 시각 (다음 날 00:00:00 또는 해당 날짜의 23:59:59.999999999, exclusive) * @param pageable 페이징 정보 * @return 조회된 메트릭 페이지 */ @@ -80,7 +89,6 @@ Page findByUpdatedAtBetween( * * @return PagingAndSortingRepository를 구현한 JPA Repository */ - @SuppressWarnings("rawtypes") org.springframework.data.repository.PagingAndSortingRepository getJpaRepository(); } diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRank.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRank.java index 576eb158d..42a261c97 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRank.java +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRank.java @@ -16,8 +16,9 @@ *

    * Materialized View 설계: *

      - *
    • 주간 랭킹: `mv_product_rank_weekly` (period_type = WEEKLY)
    • - *
    • 월간 랭킹: `mv_product_rank_monthly` (period_type = MONTHLY)
    • + *
    • 테이블: `mv_product_rank` (단일 테이블)
    • + *
    • 주간 랭킹: period_type = WEEKLY
    • + *
    • 월간 랭킹: period_type = MONTHLY
    • *
    • TOP 100만 저장하여 조회 성능 최적화
    • *
    *

    diff --git a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScoreRepository.java b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScoreRepository.java index 149357a81..efb09527d 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScoreRepository.java +++ b/apps/commerce-batch/src/main/java/com/loopers/domain/rank/ProductRankScoreRepository.java @@ -2,6 +2,7 @@ import java.util.List; import java.util.Optional; +import java.util.Set; /** * ProductRankScore 도메인 Repository 인터페이스. @@ -39,6 +40,17 @@ public interface ProductRankScoreRepository { */ Optional findByProductId(Long productId); + /** + * 여러 product_id로 ProductRankScore를 일괄 조회합니다. + *

    + * N+1 쿼리 문제를 방지하기 위해 사용합니다. + *

    + * + * @param productIds 상품 ID 집합 + * @return ProductRankScore 리스트 + */ + List findAllByProductIdIn(Set productIds); + /** * 모든 ProductRankScore를 점수 내림차순으로 조회합니다. *

    diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReader.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReader.java index 449cb18d2..3f58bc891 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReader.java +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReader.java @@ -50,6 +50,24 @@ public class ProductRankAggregationReader { * @return RepositoryItemReader 인스턴스 */ public RepositoryItemReader createWeeklyReader(LocalDate targetDate) { + DateRange weekRange = calculateWeeklyRange(targetDate); + + log.info("ProductRank 주간 Reader 초기화: targetDate={}, weekStart={}, weekEnd={}", + targetDate, weekRange.startDate(), weekRange.endDate()); + + return createReader(weekRange.startDateTime(), weekRange.endDateTime(), "weeklyReader"); + } + + /** + * 주간 범위를 계산합니다. + *

    + * 테스트 가능성을 위해 별도 메서드로 분리했습니다. + *

    + * + * @param targetDate 기준 날짜 (해당 주의 어느 날짜든 가능) + * @return 주간 범위 (시작일, 종료일) + */ + DateRange calculateWeeklyRange(LocalDate targetDate) { // 주간 시작일 계산 (월요일) LocalDate weekStart = targetDate.with(java.time.DayOfWeek.MONDAY); LocalDateTime startDateTime = weekStart.atStartOfDay(); @@ -57,11 +75,8 @@ public RepositoryItemReader createWeeklyReader(LocalDate targetD // 주간 종료일 계산 (다음 주 월요일 00:00:00) LocalDate weekEnd = weekStart.plusWeeks(1); LocalDateTime endDateTime = weekEnd.atStartOfDay(); - - log.info("ProductRank 주간 Reader 초기화: targetDate={}, weekStart={}, weekEnd={}", - targetDate, weekStart, weekEnd); - - return createReader(startDateTime, endDateTime, "weeklyReader"); + + return new DateRange(weekStart, weekEnd, startDateTime, endDateTime); } /** @@ -74,6 +89,24 @@ public RepositoryItemReader createWeeklyReader(LocalDate targetD * @return RepositoryItemReader 인스턴스 */ public RepositoryItemReader createMonthlyReader(LocalDate targetDate) { + DateRange monthRange = calculateMonthlyRange(targetDate); + + log.info("ProductRank 월간 Reader 초기화: targetDate={}, monthStart={}, monthEnd={}", + targetDate, monthRange.startDate(), monthRange.endDate()); + + return createReader(monthRange.startDateTime(), monthRange.endDateTime(), "monthlyReader"); + } + + /** + * 월간 범위를 계산합니다. + *

    + * 테스트 가능성을 위해 별도 메서드로 분리했습니다. + *

    + * + * @param targetDate 기준 날짜 (해당 월의 어느 날짜든 가능) + * @return 월간 범위 (시작일, 종료일) + */ + DateRange calculateMonthlyRange(LocalDate targetDate) { // 월간 시작일 계산 (1일) LocalDate monthStart = targetDate.with(TemporalAdjusters.firstDayOfMonth()); LocalDateTime startDateTime = monthStart.atStartOfDay(); @@ -81,11 +114,8 @@ public RepositoryItemReader createMonthlyReader(LocalDate target // 월간 종료일 계산 (다음 달 1일 00:00:00) LocalDate monthEnd = targetDate.with(TemporalAdjusters.firstDayOfNextMonth()); LocalDateTime endDateTime = monthEnd.atStartOfDay(); - - log.info("ProductRank 월간 Reader 초기화: targetDate={}, monthStart={}, monthEnd={}", - targetDate, monthStart, monthEnd); - - return createReader(startDateTime, endDateTime, "monthlyReader"); + + return new DateRange(monthStart, monthEnd, startDateTime, endDateTime); } /** @@ -119,5 +149,24 @@ private RepositoryItemReader createReader( .sorts(sorts) .build(); } + + /** + * 날짜 범위를 담는 레코드. + *

    + * 테스트 가능성을 위해 내부 클래스로 정의했습니다. + *

    + * + * @param startDate 시작일 + * @param endDate 종료일 (exclusive) + * @param startDateTime 시작 시각 + * @param endDateTime 종료 시각 (exclusive) + */ + record DateRange( + LocalDate startDate, + LocalDate endDate, + LocalDateTime startDateTime, + LocalDateTime endDateTime + ) { + } } diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessor.java index cafcbc4cc..159138dae 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessor.java +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessor.java @@ -4,6 +4,7 @@ import com.loopers.domain.rank.ProductRankScore; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.configuration.annotation.StepScope; import org.springframework.batch.item.ItemProcessor; import org.springframework.stereotype.Component; @@ -29,11 +30,12 @@ */ @Slf4j @Component +@StepScope @RequiredArgsConstructor public class ProductRankCalculationProcessor implements ItemProcessor { private final ProductRankAggregationProcessor productRankAggregationProcessor; - private final ThreadLocal currentRank = ThreadLocal.withInitial(() -> 0); + private int currentRank = 0; private static final int TOP_RANK_LIMIT = 100; /** @@ -48,8 +50,7 @@ public class ProductRankCalculationProcessor implements ItemProcessor TOP_RANK_LIMIT) { @@ -76,11 +77,6 @@ public ProductRank process(ProductRankScore score) throws Exception { score.getViewCount() ); - // Step 완료 후 ThreadLocal 정리 (마지막 항목 처리 시) - if (rank == TOP_RANK_LIMIT) { - currentRank.remove(); - } - return productRank; } } diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationReader.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationReader.java index 4b997f66c..679d1d823 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationReader.java +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationReader.java @@ -4,6 +4,7 @@ import com.loopers.domain.rank.ProductRankScoreRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.configuration.annotation.StepScope; import org.springframework.batch.item.ItemReader; import org.springframework.batch.item.NonTransientResourceException; import org.springframework.batch.item.ParseException; @@ -33,6 +34,7 @@ */ @Slf4j @Component +@StepScope @RequiredArgsConstructor public class ProductRankCalculationReader implements ItemReader { diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationWriter.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationWriter.java index 71fd8ea5c..40530a10a 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationWriter.java +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationWriter.java @@ -4,6 +4,7 @@ import com.loopers.domain.rank.ProductRankRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.configuration.annotation.StepScope; import org.springframework.batch.item.Chunk; import org.springframework.batch.item.ItemWriter; import org.springframework.stereotype.Component; @@ -32,6 +33,7 @@ */ @Slf4j @Component +@StepScope @RequiredArgsConstructor public class ProductRankCalculationWriter implements ItemWriter { diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankJobConfig.java index a8c06a0e5..875bd3519 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankJobConfig.java +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankJobConfig.java @@ -181,6 +181,10 @@ public ItemReader productRankReader( @Value("#{jobParameters['periodType']}") String periodType, @Value("#{jobParameters['targetDate']}") String targetDate ) { + if (periodType == null || periodType.isEmpty()) { + throw new IllegalArgumentException("periodType 파라미터는 필수입니다. (WEEKLY 또는 MONTHLY)"); + } + LocalDate date = parseDate(targetDate); ProductRank.PeriodType period = ProductRank.PeriodType.valueOf(periodType.toUpperCase()); diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriter.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriter.java index f1e3d6404..59a8da624 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriter.java +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriter.java @@ -11,6 +11,8 @@ import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.function.Function; import java.util.stream.Collectors; /** @@ -80,25 +82,31 @@ public void write(Chunk chunk) throws Exception { ) )); + // Chunk 내 모든 productId를 한 번에 조회 + Set productIds = chunkAggregatedMap.keySet(); + Map existingScores = productRankScoreRepository + .findAllByProductIdIn(productIds) + .stream() + .collect(Collectors.toMap(ProductRankScore::getProductId, Function.identity())); + // 기존 데이터와 누적하여 ProductRankScore 생성 List scores = chunkAggregatedMap.entrySet().stream() .map(entry -> { Long productId = entry.getKey(); AggregatedMetrics chunkAggregated = entry.getValue(); - // 기존 데이터 조회 - java.util.Optional existing = productRankScoreRepository.findByProductId(productId); + // 기존 데이터 조회 (일괄 조회 결과에서) + ProductRankScore existing = existingScores.get(productId); // 기존 데이터와 누적 Long totalLikeCount = chunkAggregated.getLikeCount(); Long totalSalesCount = chunkAggregated.getSalesCount(); Long totalViewCount = chunkAggregated.getViewCount(); - if (existing.isPresent()) { - ProductRankScore existingScore = existing.get(); - totalLikeCount += existingScore.getLikeCount(); - totalSalesCount += existingScore.getSalesCount(); - totalViewCount += existingScore.getViewCount(); + if (existing != null) { + totalLikeCount += existing.getLikeCount(); + totalSalesCount += existing.getSalesCount(); + totalViewCount += existing.getViewCount(); } // 점수 계산 (가중치: 좋아요 0.3, 판매량 0.5, 조회수 0.2) diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java index e76dd736f..ff10e63c2 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java @@ -40,9 +40,18 @@ public interface ProductMetricsJpaRepository extends JpaRepository + *

    + * 주의: 쿼리는 {@code updatedAt >= :startDateTime AND updatedAt < :endDateTime} 조건을 사용하므로, + * endDateTime은 exclusive end입니다. 예를 들어, 2024-12-15의 데이터를 조회하려면: + *

      + *
    • startDateTime: 2024-12-15 00:00:00
    • + *
    • endDateTime: 2024-12-16 00:00:00 (다음 날 00:00:00)
    • + *
    + * 또는 {@code date.atTime(LocalTime.MAX)}를 사용할 수도 있습니다. + *

    * - * @param startDateTime 조회 시작 시각 (해당 날짜의 00:00:00) - * @param endDateTime 조회 종료 시각 (해당 날짜의 23:59:59.999999999) + * @param startDateTime 조회 시작 시각 (해당 날짜의 00:00:00, inclusive) + * @param endDateTime 조회 종료 시각 (다음 날 00:00:00 또는 해당 날짜의 23:59:59.999999999, exclusive) * @param pageable 페이징 정보 * @return 조회된 메트릭 페이지 */ diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java index 70b775e30..51d974de5 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsRepositoryImpl.java @@ -65,7 +65,6 @@ public Page findByUpdatedAtBetween( * {@inheritDoc} */ @Override - @SuppressWarnings("rawtypes") public org.springframework.data.repository.PagingAndSortingRepository getJpaRepository() { return productMetricsJpaRepository; } diff --git a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankScoreRepositoryImpl.java b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankScoreRepositoryImpl.java index b210d9ce2..3037e8f99 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankScoreRepositoryImpl.java +++ b/apps/commerce-batch/src/main/java/com/loopers/infrastructure/rank/ProductRankScoreRepositoryImpl.java @@ -10,6 +10,7 @@ import java.util.List; import java.util.Optional; +import java.util.Set; /** * ProductRankScore Repository 구현체. @@ -70,6 +71,18 @@ public Optional findByProductId(Long productId) { } } + @Override + public List findAllByProductIdIn(Set productIds) { + if (productIds == null || productIds.isEmpty()) { + return List.of(); + } + + String jpql = "SELECT prs FROM ProductRankScore prs WHERE prs.productId IN :productIds"; + return entityManager.createQuery(jpql, ProductRankScore.class) + .setParameter("productIds", productIds) + .getResultList(); + } + @Override public List findAllOrderByScoreDesc(int limit) { String jpql = "SELECT prs FROM ProductRankScore prs ORDER BY prs.score DESC"; diff --git a/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReaderTest.java b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReaderTest.java index 50e225b7e..286108683 100644 --- a/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReaderTest.java +++ b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankAggregationReaderTest.java @@ -49,17 +49,18 @@ void createsWeeklyReader() { void weeklyReaderQueriesFromMondayToNextMonday() { // arrange LocalDate targetDate = LocalDate.of(2024, 12, 15); // 일요일 - when(productMetricsRepository.getJpaRepository()).thenReturn(jpaRepository); ProductRankAggregationReader reader = new ProductRankAggregationReader(productMetricsRepository); // act - RepositoryItemReader itemReader = reader.createWeeklyReader(targetDate); + ProductRankAggregationReader.DateRange range = reader.calculateWeeklyRange(targetDate); // assert - assertThat(itemReader).isNotNull(); - // 주간 시작일은 해당 주의 월요일이어야 함 // 2024-12-15(일) -> 2024-12-09(월)이 시작일 + assertThat(range.startDate()).isEqualTo(LocalDate.of(2024, 12, 9)); // 월요일 + assertThat(range.endDate()).isEqualTo(LocalDate.of(2024, 12, 16)); // 다음 주 월요일 + assertThat(range.startDateTime()).isEqualTo(LocalDate.of(2024, 12, 9).atStartOfDay()); + assertThat(range.endDateTime()).isEqualTo(LocalDate.of(2024, 12, 16).atStartOfDay()); } @DisplayName("월간 Reader를 생성할 수 있다") @@ -84,24 +85,24 @@ void createsMonthlyReader() { void monthlyReaderQueriesFromFirstDayToNextMonth() { // arrange LocalDate targetDate = LocalDate.of(2024, 12, 15); - when(productMetricsRepository.getJpaRepository()).thenReturn(jpaRepository); ProductRankAggregationReader reader = new ProductRankAggregationReader(productMetricsRepository); // act - RepositoryItemReader itemReader = reader.createMonthlyReader(targetDate); + ProductRankAggregationReader.DateRange range = reader.calculateMonthlyRange(targetDate); // assert - assertThat(itemReader).isNotNull(); - // 월간 시작일은 해당 월의 1일이어야 함 // 2024-12-15 -> 2024-12-01이 시작일 + assertThat(range.startDate()).isEqualTo(LocalDate.of(2024, 12, 1)); // 1일 + assertThat(range.endDate()).isEqualTo(LocalDate.of(2025, 1, 1)); // 다음 달 1일 + assertThat(range.startDateTime()).isEqualTo(LocalDate.of(2024, 12, 1).atStartOfDay()); + assertThat(range.endDateTime()).isEqualTo(LocalDate.of(2025, 1, 1).atStartOfDay()); } @DisplayName("주간 Reader는 주의 어느 날짜든 올바른 주간 범위를 계산한다") @Test void weeklyReaderCalculatesCorrectWeekRange_forAnyDayInWeek() { // arrange - when(productMetricsRepository.getJpaRepository()).thenReturn(jpaRepository); ProductRankAggregationReader reader = new ProductRankAggregationReader(productMetricsRepository); // 월요일 @@ -112,22 +113,29 @@ void weeklyReaderCalculatesCorrectWeekRange_forAnyDayInWeek() { LocalDate sunday = LocalDate.of(2024, 12, 15); // act - RepositoryItemReader mondayReader = reader.createWeeklyReader(monday); - RepositoryItemReader wednesdayReader = reader.createWeeklyReader(wednesday); - RepositoryItemReader sundayReader = reader.createWeeklyReader(sunday); + ProductRankAggregationReader.DateRange mondayRange = reader.calculateWeeklyRange(monday); + ProductRankAggregationReader.DateRange wednesdayRange = reader.calculateWeeklyRange(wednesday); + ProductRankAggregationReader.DateRange sundayRange = reader.calculateWeeklyRange(sunday); // assert - assertThat(mondayReader).isNotNull(); - assertThat(wednesdayReader).isNotNull(); - assertThat(sundayReader).isNotNull(); // 모두 같은 주의 월요일부터 시작해야 함 + LocalDate expectedStart = LocalDate.of(2024, 12, 9); // 월요일 + LocalDate expectedEnd = LocalDate.of(2024, 12, 16); // 다음 주 월요일 + + assertThat(mondayRange.startDate()).isEqualTo(expectedStart); + assertThat(mondayRange.endDate()).isEqualTo(expectedEnd); + + assertThat(wednesdayRange.startDate()).isEqualTo(expectedStart); + assertThat(wednesdayRange.endDate()).isEqualTo(expectedEnd); + + assertThat(sundayRange.startDate()).isEqualTo(expectedStart); + assertThat(sundayRange.endDate()).isEqualTo(expectedEnd); } @DisplayName("월간 Reader는 월의 어느 날짜든 올바른 월간 범위를 계산한다") @Test void monthlyReaderCalculatesCorrectMonthRange_forAnyDayInMonth() { // arrange - when(productMetricsRepository.getJpaRepository()).thenReturn(jpaRepository); ProductRankAggregationReader reader = new ProductRankAggregationReader(productMetricsRepository); // 1일 @@ -138,15 +146,23 @@ void monthlyReaderCalculatesCorrectMonthRange_forAnyDayInMonth() { LocalDate lastDay = LocalDate.of(2024, 12, 31); // act - RepositoryItemReader firstDayReader = reader.createMonthlyReader(firstDay); - RepositoryItemReader midDayReader = reader.createMonthlyReader(midDay); - RepositoryItemReader lastDayReader = reader.createMonthlyReader(lastDay); + ProductRankAggregationReader.DateRange firstDayRange = reader.calculateMonthlyRange(firstDay); + ProductRankAggregationReader.DateRange midDayRange = reader.calculateMonthlyRange(midDay); + ProductRankAggregationReader.DateRange lastDayRange = reader.calculateMonthlyRange(lastDay); // assert - assertThat(firstDayReader).isNotNull(); - assertThat(midDayReader).isNotNull(); - assertThat(lastDayReader).isNotNull(); // 모두 같은 월의 1일부터 시작해야 함 + LocalDate expectedStart = LocalDate.of(2024, 12, 1); // 1일 + LocalDate expectedEnd = LocalDate.of(2025, 1, 1); // 다음 달 1일 + + assertThat(firstDayRange.startDate()).isEqualTo(expectedStart); + assertThat(firstDayRange.endDate()).isEqualTo(expectedEnd); + + assertThat(midDayRange.startDate()).isEqualTo(expectedStart); + assertThat(midDayRange.endDate()).isEqualTo(expectedEnd); + + assertThat(lastDayRange.startDate()).isEqualTo(expectedStart); + assertThat(lastDayRange.endDate()).isEqualTo(expectedEnd); } } diff --git a/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessorTest.java b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessorTest.java index cf55ad54d..2bd675457 100644 --- a/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessorTest.java +++ b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankCalculationProcessorTest.java @@ -89,9 +89,9 @@ void returnsProductRankForTop100() throws Exception { assertThat(result.getViewCount()).isEqualTo(5L); } - @DisplayName("100번째 처리 후 ThreadLocal이 정리된다") + @DisplayName("101번째 이후는 null을 반환한다 (TOP 100 초과)") @Test - void cleansUpThreadLocalAfter100th() throws Exception { + void returnsNullAfter100th() throws Exception { // arrange ProductRank.PeriodType periodType = ProductRank.PeriodType.WEEKLY; LocalDate periodStartDate = LocalDate.of(2024, 12, 9); @@ -99,33 +99,20 @@ void cleansUpThreadLocalAfter100th() throws Exception { when(productRankAggregationProcessor.getPeriodType()).thenReturn(periodType); when(productRankAggregationProcessor.getPeriodStartDate()).thenReturn(periodStartDate); - // 99개까지 처리 - for (int i = 1; i <= 99; i++) { + // 100개까지 처리 + for (int i = 1; i <= 100; i++) { ProductRankScore score = createProductRankScore((long) i, 10L, 20L, 5L); ProductRank result = processor.process(score); assertThat(result).isNotNull(); assertThat(result.getRank()).isEqualTo(i); } - // 100번째 처리 (이 시점에서 rank=100이 되고, rank == TOP_RANK_LIMIT이므로 remove() 호출됨) - ProductRankScore score100 = createProductRankScore(100L, 10L, 20L, 5L); - ProductRank rank100 = processor.process(score100); - - // assert - assertThat(rank100).isNotNull(); - assertThat(rank100.getRank()).isEqualTo(100); - - // 100번째 처리 후 remove()가 호출되어 ThreadLocal이 정리됨 - // 실제 배치에서는 100번째 이후는 처리되지 않으므로, - // 101번째를 처리하면 currentRank가 0으로 초기화되어 rank=1이 됨 - // 이는 실제 배치 동작과는 다르지만, ThreadLocal 정리 동작을 검증하기 위한 테스트 + // 101번째 처리 (rank > TOP_RANK_LIMIT이므로 null 반환) ProductRankScore score101 = createProductRankScore(101L, 10L, 20L, 5L); ProductRank result = processor.process(score101); - // remove() 후이므로 currentRank가 0으로 초기화되어 rank=1이 되고, - // rank <= 100이므로 ProductRank가 반환됨 - assertThat(result).isNotNull(); - assertThat(result.getRank()).isEqualTo(1); // remove() 후 다시 1부터 시작 + // assert + assertThat(result).isNull(); // TOP 100 초과이므로 null 반환 } @DisplayName("정확히 100번째는 ProductRank를 반환한다") diff --git a/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriterTest.java b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriterTest.java index 5aab9868a..626d3ee2f 100644 --- a/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriterTest.java +++ b/apps/commerce-batch/src/test/java/com/loopers/infrastructure/batch/rank/ProductRankScoreAggregationWriterTest.java @@ -14,11 +14,11 @@ import java.util.ArrayList; import java.util.List; -import java.util.Optional; +import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.anyList; -import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anySet; import static org.mockito.Mockito.*; /** @@ -59,7 +59,7 @@ void aggregatesMetricsByProductId() throws Exception { Chunk chunk = new Chunk<>(items); - when(productRankScoreRepository.findByProductId(anyLong())).thenReturn(Optional.empty()); + when(productRankScoreRepository.findAllByProductIdIn(anySet())).thenReturn(List.of()); doNothing().when(productRankScoreRepository).saveAll(anyList()); // act @@ -106,7 +106,7 @@ void calculatesScoreWithCorrectWeights() throws Exception { Chunk chunk = new Chunk<>(items); - when(productRankScoreRepository.findByProductId(anyLong())).thenReturn(Optional.empty()); + when(productRankScoreRepository.findAllByProductIdIn(anySet())).thenReturn(List.of()); doNothing().when(productRankScoreRepository).saveAll(anyList()); // act @@ -138,7 +138,7 @@ void accumulatesWithExistingData() throws Exception { // 기존 데이터: 좋아요 5, 판매량 20, 조회수 3 ProductRankScore existingScore = new ProductRankScore(1L, 5L, 20L, 3L, 12.1); - when(productRankScoreRepository.findByProductId(1L)).thenReturn(Optional.of(existingScore)); + when(productRankScoreRepository.findAllByProductIdIn(anySet())).thenReturn(List.of(existingScore)); doNothing().when(productRankScoreRepository).saveAll(anyList()); // act @@ -168,7 +168,7 @@ void skipsEmptyChunk() throws Exception { writer.write(chunk); // assert - verify(productRankScoreRepository, never()).findByProductId(anyLong()); + verify(productRankScoreRepository, never()).findAllByProductIdIn(anySet()); verify(productRankScoreRepository, never()).saveAll(anyList()); } @@ -188,7 +188,7 @@ void processesMultipleProductIds() throws Exception { Chunk chunk = new Chunk<>(items); - when(productRankScoreRepository.findByProductId(anyLong())).thenReturn(Optional.empty()); + when(productRankScoreRepository.findAllByProductIdIn(anySet())).thenReturn(List.of()); doNothing().when(productRankScoreRepository).saveAll(anyList()); // act @@ -230,7 +230,7 @@ void createsNewScoreWhenNoExistingData() throws Exception { Chunk chunk = new Chunk<>(items); - when(productRankScoreRepository.findByProductId(1L)).thenReturn(Optional.empty()); + when(productRankScoreRepository.findAllByProductIdIn(anySet())).thenReturn(List.of()); doNothing().when(productRankScoreRepository).saveAll(anyList()); // act