From d73dd4217049553967a7f36e01a28b6ce1f5b9b6 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Mon, 9 Feb 2026 18:11:32 +0900 Subject: [PATCH 001/108] =?UTF-8?q?refactor:=20=ED=8C=A8=EC=8A=A4=EC=9B=8C?= =?UTF-8?q?=EB=93=9C=20=ED=98=95=EC=8B=9D=20=EA=B2=80=EC=A6=9D=20=EC=9C=84?= =?UTF-8?q?=EC=B9=98=20VO=20=EC=83=9D=EC=84=B1=EC=9E=90=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .sdkmanrc | 1 + .../loopers/domain/AuthenticationService.java | 2 +- .../java/com/loopers/domain/Password.java | 27 ++- .../PasswordEncoder.java | 2 +- .../java/com/loopers/domain/UserService.java | 2 +- .../BCryptPasswordEncoderImpl.java | 2 + .../interfaces/api/user/UserV1Controller.java | 6 +- .../domain/AuthenticationServiceTest.java | 3 +- .../loopers/domain/FakePasswordEncoder.java | 24 +++ .../loopers/domain/FakeUserRepository.java | 28 +++ .../java/com/loopers/domain/PasswordTest.java | 24 ++- .../com/loopers/domain/UserModelTest.java | 2 +- .../loopers/domain/UserServiceFakeTest.java | 153 +++++++++++++++ .../domain/UserServiceIntegrationTest.java | 16 +- .../loopers/domain/UserServiceMockTest.java | 180 ++++++++++++++++++ 15 files changed, 439 insertions(+), 33 deletions(-) create mode 100644 .sdkmanrc rename apps/commerce-api/src/main/java/com/loopers/{infrastructure => domain}/PasswordEncoder.java (79%) create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/FakePasswordEncoder.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/FakeUserRepository.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/UserServiceFakeTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/UserServiceMockTest.java diff --git a/.sdkmanrc b/.sdkmanrc new file mode 100644 index 000000000..006ec937c --- /dev/null +++ b/.sdkmanrc @@ -0,0 +1 @@ +java=21.0.7-tem diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/AuthenticationService.java b/apps/commerce-api/src/main/java/com/loopers/domain/AuthenticationService.java index bf148ae14..5bef35004 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/AuthenticationService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/AuthenticationService.java @@ -1,6 +1,6 @@ package com.loopers.domain; -import com.loopers.infrastructure.PasswordEncoder; +// PasswordEncoder는 이제 같은 domain 패키지에 있으므로 import 불필요 import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/Password.java b/apps/commerce-api/src/main/java/com/loopers/domain/Password.java index 17d4ec1db..11c0e4101 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/Password.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/Password.java @@ -9,7 +9,6 @@ @Embeddable @EqualsAndHashCode public class Password { - // 특수문자 범위를 ~!@#$%^&*()_+=- 로 확장했습니다. private static final Pattern PASSWORD_PATTERN = Pattern.compile("^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[~!@#$%^&*()_+=-])[A-Za-z\\d~!@#$%^&*()_+=-]{8,16}$"); @@ -17,9 +16,19 @@ public class Password { protected Password() {} - public Password(String value) { - validate(value); - this.value = value; + private Password(String value) { + this. value = value; + } + + public static Password of(String rawPassword) { + validateFormat(rawPassword); + return new Password(rawPassword); + } + + public static Password of(String rawPassword, BirthDate birthDate) { + validateFormat(rawPassword); + validateNotContainBirthday(rawPassword, birthDate); + return new Password(rawPassword); } private Password(String value, boolean skipValidation) { @@ -30,20 +39,24 @@ public static Password fromEncoded(String encodedValue) { return new Password(encodedValue, true); } - private void validate(String value) { + private static void validateFormat(String value) { if (value == null || !PASSWORD_PATTERN.matcher(value).matches()) { throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 8~16자의 영문 대소문자, 숫자, 특수문자 조합이어야 합니다."); } } - public void validateNotContainBirthday(BirthDate birthDate) { + private static void validateNotContainBirthday(String rawPassword, BirthDate birthDate) { String birthDateString = birthDate.toDateString(); - if (this.value.contains(birthDateString)) { + if (rawPassword.contains(birthDateString)) { throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 비밀번호 내에 포함될 수 없습니다."); } } + public void validateNotContainBirthday(BirthDate birthDate) { + validateNotContainBirthday(this.value, birthDate); + } + public void validateNotSameAs(Password other) { if (this.equals(other)) { throw new CoreException(ErrorType.BAD_REQUEST, "현재 사용 중인 비밀번호는 사용할 수 없습니다."); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/PasswordEncoder.java b/apps/commerce-api/src/main/java/com/loopers/domain/PasswordEncoder.java similarity index 79% rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/PasswordEncoder.java rename to apps/commerce-api/src/main/java/com/loopers/domain/PasswordEncoder.java index fbe0ff626..249ec2a76 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/PasswordEncoder.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/PasswordEncoder.java @@ -1,4 +1,4 @@ -package com.loopers.infrastructure; +package com.loopers.domain; public interface PasswordEncoder { String encode(String rawPassword); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/UserService.java index 874c1f87a..f5160d4f9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/UserService.java @@ -1,6 +1,6 @@ package com.loopers.domain; -import com.loopers.infrastructure.PasswordEncoder; +// PasswordEncoder는 이제 같은 domain 패키지에 있으므로 import 불필요 import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import jakarta.transaction.Transactional; diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/BCryptPasswordEncoderImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/BCryptPasswordEncoderImpl.java index 6545f1868..b9e0b850c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/BCryptPasswordEncoderImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/BCryptPasswordEncoderImpl.java @@ -3,6 +3,8 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Component; +import com.loopers.domain.PasswordEncoder; + @Component public class BCryptPasswordEncoderImpl implements PasswordEncoder { diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java index 2f7155150..f596a2b37 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java @@ -27,7 +27,7 @@ public ApiResponse signup( @Valid @RequestBody UserV1Dto.SignupRequest request ) { LoginId loginId = new LoginId(request.loginId()); - Password password = new Password(request.password()); + Password password = Password.of(request.password()); Name name = new Name(request.name()); BirthDate birthDate = new BirthDate(LocalDate.parse(request.birthDate(), BIRTH_DATE_FORMATTER)); Email email = new Email(request.email()); @@ -60,8 +60,8 @@ public ApiResponse changePassword( ) { UserModel authenticatedUser = authenticationService.authenticate(loginId, currentPasswordValue); - Password currentPassword = new Password(request.currentPassword()); - Password newPassword = new Password(request.newPassword()); + Password currentPassword = Password.of(request.currentPassword()); + Password newPassword = Password.of(request.newPassword()); userService.changePassword(authenticatedUser.getLoginId(), currentPassword, newPassword); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/AuthenticationServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/AuthenticationServiceTest.java index 1cf99940c..85fa09a96 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/AuthenticationServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/AuthenticationServiceTest.java @@ -26,7 +26,8 @@ class AuthenticationServiceTest { private UserRepository userRepository; @Mock - private com.loopers.infrastructure.PasswordEncoder passwordEncoder; + private PasswordEncoder passwordEncoder; + // STEP2: infrastructure import가 사라졌다. 같은 domain 패키지. @InjectMocks private AuthenticationService authenticationService; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/FakePasswordEncoder.java b/apps/commerce-api/src/test/java/com/loopers/domain/FakePasswordEncoder.java new file mode 100644 index 000000000..29da58ab0 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/FakePasswordEncoder.java @@ -0,0 +1,24 @@ +package com.loopers.domain; + +// ============================================================ +// STEP 3: Fake 구현체 +// +// Mock과 달리, 단순한 로직을 직접 구현한 테스트용 객체. +// when-then 지시 없이 스스로 동작한다. +// +// 핵심: PasswordEncoder 인터페이스가 domain에 있기 때문에 +// 이 Fake도 infrastructure import 없이 작성 가능하다. +// ============================================================ + +public class FakePasswordEncoder implements PasswordEncoder { + + @Override + public String encode(String rawPassword) { + return "ENCODED_" + rawPassword; + } + + @Override + public boolean matches(String rawPassword, String encodedPassword) { + return encodedPassword.equals("ENCODED_" + rawPassword); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/FakeUserRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/FakeUserRepository.java new file mode 100644 index 000000000..9df4b2bd2 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/FakeUserRepository.java @@ -0,0 +1,28 @@ +package com.loopers.domain; + +// ============================================================ +// STEP 3: Fake 구현체 +// +// UserRepository 인터페이스는 원래부터 domain에 있었으므로 +// 이 Fake는 처음부터 infrastructure import 없이 작성 가능했다. +// ============================================================ + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +public class FakeUserRepository implements UserRepository { + + private final Map store = new HashMap<>(); + + @Override + public UserModel save(UserModel userModel) { + store.put(userModel.getLoginId().getValue(), userModel); + return userModel; + } + + @Override + public Optional find(LoginId loginId) { + return Optional.ofNullable(store.get(loginId.getValue())); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/PasswordTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/PasswordTest.java index 50d2220e8..f3e5c9070 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/PasswordTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/PasswordTest.java @@ -5,12 +5,20 @@ import com.loopers.support.error.CoreException; import java.time.LocalDate; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; class PasswordTest { + private BirthDate defaultBirthDate; + + @BeforeEach + void setUp() { + defaultBirthDate = new BirthDate(LocalDate.of(1990, 1, 15)); + } + @DisplayName("비밀번호 객체를 생성할 때, ") @Nested class Create { @@ -19,13 +27,13 @@ class Create { @Test void createPassword_whenValidFormat() { // 대문자(V), 소문자(alid), 숫자(123), 특수문자(!@#) 모두 포함 - assertDoesNotThrow(() -> new Password("Valid123!@#")); + assertDoesNotThrow(() -> Password.of("Valid123!@#", defaultBirthDate)); } @DisplayName("규칙에 어긋나는 형식이면 예외가 발생한다.") @Test void createPassword_whenInvalidFormat() { - assertThatThrownBy(() -> new Password("invalid")) + assertThatThrownBy(() -> Password.of("invalid", defaultBirthDate)) .isInstanceOf(CoreException.class); } } @@ -37,12 +45,8 @@ class Validation { @DisplayName("비밀번호에 생년월일(yyyyMMdd)이 포함되어 있으면 예외가 발생한다.") @Test void validateNotContainBirthday_fail() { - // arrange: 정규식을 통과하기 위해 대문자 'P' 추가 - Password password = new Password("Pw19900115!"); - BirthDate birthDate = new BirthDate(LocalDate.of(1990, 1, 15)); - - // act & assert - assertThatThrownBy(() -> password.validateNotContainBirthday(birthDate)) + // act & assert: Password.of 생성 시점에 생년월일 포함 검증이 수행됨 + assertThatThrownBy(() -> Password.of("Pw19900115!", defaultBirthDate)) .isInstanceOf(CoreException.class); } @@ -50,8 +54,8 @@ void validateNotContainBirthday_fail() { @Test void validateNotSameAs_fail() { // arrange - Password currentPassword = new Password("Current123!"); - Password newPassword = new Password("Current123!"); + Password currentPassword = Password.of("Current123!", defaultBirthDate); + Password newPassword = Password.of("Current123!", defaultBirthDate); // act & assert assertThatThrownBy(() -> newPassword.validateNotSameAs(currentPassword)) diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/UserModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/UserModelTest.java index 6082a9efa..37df7ae23 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/UserModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/UserModelTest.java @@ -22,7 +22,7 @@ class UserModelTest { @BeforeEach void setUp() { validLoginId = new LoginId("testuser123"); - validPassword = new Password("Test1234!@#"); + validPassword = Password.of("Test1234!@#"); validName = new Name("홍길동"); validBirthDate = new BirthDate(LocalDate.of(1990, 1, 15)); validEmail = new Email("test@example.com"); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceFakeTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceFakeTest.java new file mode 100644 index 000000000..a169a658c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceFakeTest.java @@ -0,0 +1,153 @@ +package com.loopers.domain; + +// ============================================================ +// STEP 3: After — Fake 기반 단위 테스트 +// +// STEP 1의 UserServiceMockTest와 동일한 시나리오를 테스트하되, +// Mock 대신 Fake를 사용한다. +// +// 비교 포인트: +// 1. infrastructure import 없음 — 도메인 안에서 테스트가 완결됨 +// 2. when-then 0줄 — Fake가 알아서 동작 +// 3. 실제 저장된 값을 직접 검증 가능 — verify(save) 대신 find()로 확인 +// +// Mockito import도 없다. 순수 JUnit만 사용. +// ============================================================ + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.loopers.support.error.CoreException; +import java.time.LocalDate; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("STEP3: UserService Fake 기반 단위 테스트 (After)") +class UserServiceFakeTest { + + // Mockito 없음. 순수 Java 객체로 조립. + private FakeUserRepository userRepository; + private FakePasswordEncoder passwordEncoder; + private UserService userService; + + private LoginId validLoginId; + private Password validPassword; + private Name validName; + private BirthDate validBirthDate; + private Email validEmail; + + @BeforeEach + void setUp() { + userRepository = new FakeUserRepository(); + passwordEncoder = new FakePasswordEncoder(); + userService = new UserService(userRepository, passwordEncoder); + + validLoginId = new LoginId("testuser1"); + validPassword = Password.of("Test1234!@#"); + validName = new Name("홍길동"); + validBirthDate = new BirthDate(LocalDate.of(1990, 1, 15)); + validEmail = new Email("test@example.com"); + } + + @DisplayName("회원가입") + @Nested + class Signup { + + @Test + @DisplayName("성공 — when-then 0줄, 암호화 결과를 직접 검증") + void signup_성공() { + // arrange — when-then 없음 + + // act + UserModel result = userService.signup( + validLoginId, validPassword, validName, validBirthDate, validEmail + ); + + // assert + assertThat(result).isNotNull(); + assertThat(result.getLoginId()).isEqualTo(validLoginId); + + // Fake라서 암호화 결과가 예측 가능하다. + // Mock에서는 내가 지시한 "$2a$10$encodedHash"가 나왔지만, + // Fake에서는 실제 로직(ENCODED_ + 원본)이 동작한 결과가 나온다. + assertThat(result.getPassword().getValue()).isEqualTo("ENCODED_Test1234!@#"); + } + + @Test + @DisplayName("중복 아이디면 예외 — Fake에 이미 데이터가 있으므로 자연스럽게 감지") + void signup_중복아이디_예외() { + // arrange — 먼저 가입 + userService.signup(validLoginId, validPassword, validName, validBirthDate, validEmail); + // FakeUserRepository에 데이터가 저장된 상태. + // Mock이면 when(find).thenReturn(Optional.of(...))를 써야 했음. + + // act & assert — 같은 ID로 다시 가입 + assertThatThrownBy(() -> + userService.signup( + validLoginId, + Password.of("Other123!@#"), + validName, validBirthDate, validEmail + ) + ).isInstanceOf(CoreException.class) + .hasMessageContaining("이미 존재하는 아이디입니다."); + } + } + + @DisplayName("비밀번호 변경") + @Nested + class ChangePassword { + + @Test + @DisplayName("성공 — when-then 0줄, 변경된 비밀번호를 직접 검증") + void changePassword_성공() { + // arrange — 실제 흐름처럼 먼저 가입 + userService.signup(validLoginId, validPassword, validName, validBirthDate, validEmail); + // Mock이면 여기서 Mock 설정 5줄이 필요했음. + // Fake는 signup이 실제로 저장하므로 추가 설정 불필요. + + // act + Password newPassword = Password.of("NewPass123!@"); + userService.changePassword(validLoginId, validPassword, newPassword); + + // assert — 실제 저장된 값을 직접 확인 + UserModel updated = userRepository.find(validLoginId).orElseThrow(); + assertThat(updated.getPassword().getValue()).isEqualTo("ENCODED_NewPass123!@"); + // Mock에서는 verify(save).save(any())로 "호출됐는가?"만 확인했음. + // Fake에서는 "어떤 값으로 바뀌었는가?"를 직접 검증한다. + } + + @Test + @DisplayName("현재 비밀번호 불일치면 예외 — Fake가 실제로 매칭 실패") + void changePassword_현재비밀번호_불일치() { + // arrange + userService.signup(validLoginId, validPassword, validName, validBirthDate, validEmail); + + // act & assert + Password wrongCurrent = Password.of("Wrong123!@#"); + Password newPassword = Password.of("NewPass123!@"); + + assertThatThrownBy(() -> + userService.changePassword(validLoginId, wrongCurrent, newPassword) + ).isInstanceOf(CoreException.class) + .hasMessageContaining("현재 비밀번호가 일치하지 않습니다."); + // Mock이면 when(matches).thenReturn(false)를 써야 했음. + // Fake는 실제로 "ENCODED_Wrong123!@#" ≠ "ENCODED_Test1234!@#"이므로 자연스럽게 실패. + } + + @Test + @DisplayName("새 비밀번호가 현재와 같으면 예외") + void changePassword_새비밀번호가_현재와_같으면_예외() { + // arrange + userService.signup(validLoginId, validPassword, validName, validBirthDate, validEmail); + + // act & assert + assertThatThrownBy(() -> + userService.changePassword(validLoginId, validPassword, validPassword) + ).isInstanceOf(CoreException.class) + .hasMessageContaining("현재 사용 중인 비밀번호는 사용할 수 없습니다."); + // Fake가 실제로 매칭: "ENCODED_Test1234!@#" == "ENCODED_Test1234!@#" → true → 예외 + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceIntegrationTest.java index 670673094..31ca5af49 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceIntegrationTest.java @@ -4,7 +4,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertAll; -import com.loopers.infrastructure.PasswordEncoder; +// PasswordEncoder는 이제 domain 패키지 — import 불필요 import com.loopers.infrastructure.UserJpaRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; @@ -43,7 +43,7 @@ public class UserServiceIntegrationTest { void setUp() { validLoginId = new LoginId("testuser123"); rawPassword = "Test1234!@#"; - validPassword = new Password(rawPassword); + validPassword = Password.of(rawPassword); validName = new Name("홍길동"); validBirthDate = new BirthDate(LocalDate.of(1990, 1, 15)); validEmail = new Email("test@example.com"); @@ -149,7 +149,7 @@ class ChangePassword { void changePassword_whenValidPasswords() { // arrange userService.signup(validLoginId, validPassword, validName, validBirthDate, validEmail); - Password newPassword = new Password("NewPass123!@"); + Password newPassword = Password.of("NewPass123!@"); // act userService.changePassword(validLoginId, validPassword, newPassword); @@ -169,8 +169,8 @@ void changePassword_whenValidPasswords() { void changePassword_whenCurrentPasswordNotMatch() { // arrange userService.signup(validLoginId, validPassword, validName, validBirthDate, validEmail); - Password wrongPassword = new Password("Wrong123!@#"); - Password newPassword = new Password("NewPass123!@"); + Password wrongPassword = Password.of("Wrong123!@#"); + Password newPassword = Password.of("NewPass123!@"); // act & assert assertThatThrownBy(() -> userService.changePassword(validLoginId, wrongPassword, newPassword)) @@ -183,7 +183,7 @@ void changePassword_whenCurrentPasswordNotMatch() { void changePassword_whenNewPasswordSameAsCurrent() { // arrange userService.signup(validLoginId, validPassword, validName, validBirthDate, validEmail); - Password samePassword = new Password("Test1234!@#"); + Password samePassword = Password.of("Test1234!@#"); // act & assert assertThatThrownBy(() -> userService.changePassword(validLoginId, validPassword, samePassword)) @@ -196,7 +196,7 @@ void changePassword_whenNewPasswordSameAsCurrent() { void changePassword_whenNewPasswordContainsBirthDate() { // arrange userService.signup(validLoginId, validPassword, validName, validBirthDate, validEmail); - Password newPasswordWithBirthDate = new Password("Pw19900115!"); + Password newPasswordWithBirthDate = Password.of("Pw19900115!"); // act & assert assertThatThrownBy(() -> userService.changePassword(validLoginId, validPassword, newPasswordWithBirthDate)) @@ -209,7 +209,7 @@ void changePassword_whenNewPasswordContainsBirthDate() { void changePassword_whenUserNotFound() { // arrange LoginId invalidLoginId = new LoginId("invalid123"); - Password newPassword = new Password("NewPass123!@"); + Password newPassword = Password.of("NewPass123!@"); // act & assert assertThatThrownBy(() -> userService.changePassword(invalidLoginId, validPassword, newPassword)) diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceMockTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceMockTest.java new file mode 100644 index 000000000..b691c36c3 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceMockTest.java @@ -0,0 +1,180 @@ +package com.loopers.domain; + +// ============================================================ +// STEP 1: Before — Mock 기반 단위 테스트 +// +// 이 테스트는 현재 구조의 "불편함"을 기록하기 위해 작성되었다. +// 주목할 점: +// 1. com.loopers.infrastructure.PasswordEncoder를 import하고 있다 (도메인 테스트인데) +// 2. when-then Mock 설정이 테스트마다 3~5줄씩 필요하다 +// 3. verify(save)로만 검증 가능 — "어떤 값으로 바뀌었는가?"는 확인 불가 +// +// 이 테스트는 STEP 2 리팩토링 후에도 유지되며, +// STEP 3의 Fake 기반 테스트와 비교 대상이 된다. +// ============================================================ + +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.verify; +import static org.mockito.Mockito.when; + +// STEP2 이후: import 불필요 — PasswordEncoder가 같은 domain 패키지로 이동됨 +// (STEP1에서는 여기에 com.loopers.infrastructure.PasswordEncoder import가 있었음) + +import com.loopers.support.error.CoreException; +import java.time.LocalDate; +import java.util.Optional; +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; + +@DisplayName("STEP1: UserService Mock 기반 단위 테스트 (Before)") +@ExtendWith(MockitoExtension.class) +class UserServiceMockTest { + + @Mock + private UserRepository userRepository; + + @Mock + private PasswordEncoder passwordEncoder; + // [불편함 1] 이 Mock의 타입이 com.loopers.infrastructure.PasswordEncoder이다. + + @InjectMocks + private UserService userService; + + private LoginId validLoginId; + private Password validPassword; + private Name validName; + private BirthDate validBirthDate; + private Email validEmail; + + @BeforeEach + void setUp() { + validLoginId = new LoginId("testuser1"); + validPassword = Password.of("Test1234!@#"); + validName = new Name("홍길동"); + validBirthDate = new BirthDate(LocalDate.of(1990, 1, 15)); + validEmail = new Email("test@example.com"); + } + + @DisplayName("회원가입") + @Nested + class Signup { + + @Test + @DisplayName("성공 — Mock 설정 3줄 필요") + void signup_성공() { + // arrange — Mock 설정 3줄 + when(userRepository.find(any(LoginId.class))).thenReturn(Optional.empty()); + when(passwordEncoder.encode("Test1234!@#")).thenReturn("$2a$10$encodedHash"); + // ↑ [불편함 2] "Test1234!@#"을 넘기면 이 값을 반환해라. + // 이건 내가 테스트하고 싶은 것이 아니다. 암호화 결과를 지시하는 것. + when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + // ↑ [불편함 2] save가 받은 객체를 그대로 반환해라. 이것도 노이즈. + + // act + UserModel result = userService.signup( + validLoginId, validPassword, validName, validBirthDate, validEmail + ); + + // assert + assertThat(result).isNotNull(); + assertThat(result.getLoginId()).isEqualTo(validLoginId); + assertThat(result.getPassword().getValue()).isEqualTo("$2a$10$encodedHash"); + // ↑ 이 값은 내가 Mock에게 "반환하라"고 지시한 값. 실제 암호화 결과가 아님. + } + + @Test + @DisplayName("중복 아이디면 예외") + void signup_중복아이디_예외() { + // arrange + UserModel existingUser = createTestUser("$2a$10$existingHash"); + when(userRepository.find(any(LoginId.class))).thenReturn(Optional.of(existingUser)); + + // act & assert + assertThatThrownBy(() -> + userService.signup(validLoginId, validPassword, validName, validBirthDate, validEmail) + ).isInstanceOf(CoreException.class) + .hasMessageContaining("이미 존재하는 아이디입니다."); + } + } + + @DisplayName("비밀번호 변경") + @Nested + class ChangePassword { + + @Test + @DisplayName("성공 — Mock 설정 5줄 필요 (가장 극적인 불편함)") + void changePassword_성공() { + // arrange — Mock 설정 5줄 (!!) + UserModel existingUser = createTestUser("$2a$10$encodedOldHash"); + + when(userRepository.find(any(LoginId.class))) + .thenReturn(Optional.of(existingUser)); + when(passwordEncoder.matches("Test1234!@#", "$2a$10$encodedOldHash")) + .thenReturn(true); + // ↑ [불편함 2] "현재 비밀번호가 맞다고 해줘" — Mock에게 연기 지시 + when(passwordEncoder.matches("NewPass123!@", "$2a$10$encodedOldHash")) + .thenReturn(false); + // ↑ [불편함 2] "새 비밀번호는 현재와 다르다고 해줘" — 또 연기 지시 + when(passwordEncoder.encode("NewPass123!@")) + .thenReturn("$2a$10$encodedNewHash"); + // ↑ [불편함 2] "새 비밀번호를 암호화하면 이 값을 반환해줘" + when(userRepository.save(any())) + .thenAnswer(inv -> inv.getArgument(0)); + + // act + userService.changePassword( + validLoginId, + Password.of("Test1234!@#"), + Password.of("NewPass123!@") + ); + + // assert + verify(userRepository).save(any()); + // ↑ [불편함 3] save가 "호출되었는가?"만 확인 가능. + // "어떤 비밀번호로 바뀌었는가?"는 알 수 없다. + // Mock이라 실제로 저장되지 않았기 때문. + } + + @Test + @DisplayName("현재 비밀번호 불일치면 예외") + void changePassword_현재비밀번호_불일치() { + // arrange + UserModel existingUser = createTestUser("$2a$10$encodedOldHash"); + + when(userRepository.find(any(LoginId.class))) + .thenReturn(Optional.of(existingUser)); + when(passwordEncoder.matches("Wrong123!@#", "$2a$10$encodedOldHash")) + .thenReturn(false); + + // act & assert + assertThatThrownBy(() -> + userService.changePassword( + validLoginId, + Password.of("Wrong123!@#"), + Password.of("NewPass123!@") + ) + ).isInstanceOf(CoreException.class) + .hasMessageContaining("현재 비밀번호가 일치하지 않습니다."); + } + } + + // --- 헬퍼 --- + + private UserModel createTestUser(String encodedPassword) { + return new UserModel( + validLoginId, + Password.fromEncoded(encodedPassword), + validName, + validBirthDate, + validEmail + ); + } +} From 585d382d733b2c74ff13d91f1c8897146c61e3a0 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Tue, 10 Feb 2026 00:06:16 +0900 Subject: [PATCH 002/108] =?UTF-8?q?refactor:=20Password=E2=86=92EncryptedP?= =?UTF-8?q?assword=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20=EB=B0=8F=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=EA=B3=84=EC=B8=B5=20Command/Info?= =?UTF-8?q?=20DTO=20=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Password VO를 EncryptedPassword로 변경하여 암호화된 비밀번호임을 명확히 표현 - 서비스 계층에 SignupCommand, ChangePasswordCommand, UserInfo DTO 도입 - 컨트롤러에서 엔티티(UserModel) 직접 노출 제거 - Example 관련 코드 삭제 Co-Authored-By: Claude Opus 4.6 --- .../application/example/ExampleFacade.java | 17 --- .../application/example/ExampleInfo.java | 13 --- .../loopers/domain/AuthenticationService.java | 3 +- .../loopers/domain/ChangePasswordCommand.java | 8 ++ .../{Password.java => EncryptedPassword.java} | 44 +++---- .../main/java/com/loopers/domain/Name.java | 5 +- .../com/loopers/domain/SignupCommand.java | 10 ++ .../java/com/loopers/domain/UserInfo.java | 19 +++ .../java/com/loopers/domain/UserModel.java | 18 +-- .../java/com/loopers/domain/UserService.java | 48 +++----- .../loopers/domain/example/ExampleModel.java | 44 ------- .../domain/example/ExampleRepository.java | 7 -- .../domain/example/ExampleService.java | 20 ---- .../example/ExampleJpaRepository.java | 6 - .../example/ExampleRepositoryImpl.java | 19 --- .../api/example/ExampleV1ApiSpec.java | 19 --- .../api/example/ExampleV1Controller.java | 28 ----- .../interfaces/api/example/ExampleV1Dto.java | 15 --- .../interfaces/api/user/UserV1Controller.java | 39 +++---- .../interfaces/api/user/UserV1Dto.java | 29 +++-- .../domain/AuthenticationServiceTest.java | 3 +- ...rdTest.java => EncryptedPasswordTest.java} | 33 +++--- .../java/com/loopers/domain/NameTest.java | 26 +---- .../com/loopers/domain/UserModelTest.java | 60 +++++++++- .../loopers/domain/UserServiceFakeTest.java | 104 ++++++----------- .../domain/UserServiceIntegrationTest.java | 108 +++++++++--------- .../loopers/domain/UserServiceMockTest.java | 91 +++++---------- 27 files changed, 318 insertions(+), 518 deletions(-) delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ChangePasswordCommand.java rename apps/commerce-api/src/main/java/com/loopers/domain/{Password.java => EncryptedPassword.java} (60%) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/SignupCommand.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/UserInfo.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java rename apps/commerce-api/src/test/java/com/loopers/domain/{PasswordTest.java => EncryptedPasswordTest.java} (53%) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java deleted file mode 100644 index 552a9ad62..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.loopers.application.example; - -import com.loopers.domain.example.ExampleModel; -import com.loopers.domain.example.ExampleService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -@RequiredArgsConstructor -@Component -public class ExampleFacade { - private final ExampleService exampleService; - - public ExampleInfo getExample(Long id) { - ExampleModel example = exampleService.getExample(id); - return ExampleInfo.from(example); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java deleted file mode 100644 index 877aba96c..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.loopers.application.example; - -import com.loopers.domain.example.ExampleModel; - -public record ExampleInfo(Long id, String name, String description) { - public static ExampleInfo from(ExampleModel model) { - return new ExampleInfo( - model.getId(), - model.getName(), - model.getDescription() - ); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/AuthenticationService.java b/apps/commerce-api/src/main/java/com/loopers/domain/AuthenticationService.java index 5bef35004..e972352c4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/AuthenticationService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/AuthenticationService.java @@ -1,6 +1,5 @@ package com.loopers.domain; -// PasswordEncoder는 이제 같은 domain 패키지에 있으므로 import 불필요 import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; @@ -19,7 +18,7 @@ public UserModel authenticate(String loginIdValue, String rawPassword) { UserModel user = userRepository.find(loginId) .orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED, "로그인 ID 또는 비밀번호가 일치하지 않습니다.")); - if (!passwordEncoder.matches(rawPassword, user.getPassword().getValue())) { + if (!user.getPassword().matches(rawPassword, passwordEncoder)) { throw new CoreException(ErrorType.UNAUTHORIZED, "로그인 ID 또는 비밀번호가 일치하지 않습니다."); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ChangePasswordCommand.java b/apps/commerce-api/src/main/java/com/loopers/domain/ChangePasswordCommand.java new file mode 100644 index 000000000..6fae67700 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/ChangePasswordCommand.java @@ -0,0 +1,8 @@ +package com.loopers.domain; + +public record ChangePasswordCommand( + LoginId loginId, + String rawCurrentPassword, + String rawNewPassword +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/Password.java b/apps/commerce-api/src/main/java/com/loopers/domain/EncryptedPassword.java similarity index 60% rename from apps/commerce-api/src/main/java/com/loopers/domain/Password.java rename to apps/commerce-api/src/main/java/com/loopers/domain/EncryptedPassword.java index 11c0e4101..bc1178a03 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/Password.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/EncryptedPassword.java @@ -8,35 +8,39 @@ @Embeddable @EqualsAndHashCode -public class Password { +public class EncryptedPassword { private static final Pattern PASSWORD_PATTERN = Pattern.compile("^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[~!@#$%^&*()_+=-])[A-Za-z\\d~!@#$%^&*()_+=-]{8,16}$"); private String value; - protected Password() {} + protected EncryptedPassword() {} - private Password(String value) { - this. value = value; + private EncryptedPassword(String encryptedValue) { + this.value = encryptedValue; } - public static Password of(String rawPassword) { + public static EncryptedPassword of(String rawPassword, PasswordEncoder encoder) { validateFormat(rawPassword); - return new Password(rawPassword); + return new EncryptedPassword(encoder.encode(rawPassword)); } - public static Password of(String rawPassword, BirthDate birthDate) { + public static EncryptedPassword of(String rawPassword, PasswordEncoder encoder, BirthDate birthDate) { validateFormat(rawPassword); validateNotContainBirthday(rawPassword, birthDate); - return new Password(rawPassword); + return new EncryptedPassword(encoder.encode(rawPassword)); } - private Password(String value, boolean skipValidation) { - this.value = value; + public static EncryptedPassword fromEncoded(String encodedValue) { + return new EncryptedPassword(encodedValue); } - public static Password fromEncoded(String encodedValue) { - return new Password(encodedValue, true); + public boolean matches(String rawPassword, PasswordEncoder encoder) { + return encoder.matches(rawPassword, this.value); + } + + public String getValue() { + return value; } private static void validateFormat(String value) { @@ -52,18 +56,4 @@ private static void validateNotContainBirthday(String rawPassword, BirthDate bir throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 비밀번호 내에 포함될 수 없습니다."); } } - - public void validateNotContainBirthday(BirthDate birthDate) { - validateNotContainBirthday(this.value, birthDate); - } - - public void validateNotSameAs(Password other) { - if (this.equals(other)) { - throw new CoreException(ErrorType.BAD_REQUEST, "현재 사용 중인 비밀번호는 사용할 수 없습니다."); - } - } - - public String getValue() { - return value; - } -} +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/Name.java b/apps/commerce-api/src/main/java/com/loopers/domain/Name.java index e7495e8c7..ac36ce2d7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/Name.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/Name.java @@ -28,8 +28,7 @@ private void validate(String name) { } } - public String getMaskedName() { - if (name == null || name.isEmpty()) return name; - return name.substring(0, name.length() - 1) + "*"; + public String getValue() { + return name; } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/SignupCommand.java b/apps/commerce-api/src/main/java/com/loopers/domain/SignupCommand.java new file mode 100644 index 000000000..3a90908e7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/SignupCommand.java @@ -0,0 +1,10 @@ +package com.loopers.domain; + +public record SignupCommand( + String loginId, + String rawPassword, + String name, + String birthDate, + String email +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/UserInfo.java b/apps/commerce-api/src/main/java/com/loopers/domain/UserInfo.java new file mode 100644 index 000000000..45ec00a05 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/UserInfo.java @@ -0,0 +1,19 @@ +package com.loopers.domain; + +public record UserInfo( + Long id, + String loginId, + String name, + String birthDate, + String email +) { + public static UserInfo from(UserModel model) { + return new UserInfo( + model.getId(), + model.getLoginId().getValue(), + model.getName().getValue(), + model.getBirthDate().toDateString(), + model.getEmail().getMail() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/UserModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/UserModel.java index 4c76f23d5..22f07ea21 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/UserModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/UserModel.java @@ -8,7 +8,6 @@ import jakarta.persistence.Entity; import jakarta.persistence.Table; import lombok.AccessLevel; -import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @@ -24,7 +23,7 @@ public class UserModel extends BaseEntity { @Embedded @AttributeOverride(name = "value", column = @Column(name = "password")) - private Password password; + private EncryptedPassword password; @Embedded @AttributeOverride(name = "name", column = @Column(name = "name")) @@ -38,7 +37,7 @@ public class UserModel extends BaseEntity { @AttributeOverride(name = "mail", column = @Column(name = "email")) private Email email; - public UserModel(LoginId loginId, Password password, Name name, BirthDate birthDate, Email email) { + public UserModel(LoginId loginId, EncryptedPassword password, Name name, BirthDate birthDate, Email email) { validate(loginId, password, name, birthDate, email); this.loginId = loginId; this.password = password; @@ -47,7 +46,7 @@ public UserModel(LoginId loginId, Password password, Name name, BirthDate birthD this.email = email; } - private void validate(LoginId loginId, Password password, Name name, BirthDate birthDate, Email email) { + private void validate(LoginId loginId, EncryptedPassword password, Name name, BirthDate birthDate, Email email) { validateNotNull(loginId, "로그인 ID"); validateNotNull(password, "비밀번호"); validateNotNull(name, "이름"); @@ -60,8 +59,13 @@ private void validateNotNull(Object value, String fieldName) { } } - public void changePassword(Password currentPassword, Password newPassword) { - // 검증은 UserService에서 수행 - this.password = newPassword; + public void changePassword(String rawCurrentPassword, String rawNewPassword, PasswordEncoder encoder) { + if (!this.password.matches(rawCurrentPassword, encoder)) { + throw new CoreException(ErrorType.BAD_REQUEST, "현재 비밀번호가 일치하지 않습니다."); + } + if (this.password.matches(rawNewPassword, encoder)) { + throw new CoreException(ErrorType.BAD_REQUEST, "현재 사용 중인 비밀번호는 사용할 수 없습니다."); + } + this.password = EncryptedPassword.of(rawNewPassword, encoder, this.birthDate); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/UserService.java index f5160d4f9..f3e51b450 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/UserService.java @@ -1,62 +1,50 @@ package com.loopers.domain; -// PasswordEncoder는 이제 같은 domain 패키지에 있으므로 import 불필요 import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import jakarta.transaction.Transactional; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @Service @RequiredArgsConstructor public class UserService { + private static final DateTimeFormatter BIRTH_DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; @Transactional - public UserModel signup(LoginId loginId, Password password, Name name, BirthDate birthDate, Email email) { + public UserInfo signup(SignupCommand command) { + LoginId loginId = new LoginId(command.loginId()); + Name name = new Name(command.name()); + BirthDate birthDate = new BirthDate(LocalDate.parse(command.birthDate(), BIRTH_DATE_FORMATTER)); + Email email = new Email(command.email()); if(userRepository.find(loginId).isPresent()) { throw new CoreException(ErrorType.BAD_REQUEST,"이미 존재하는 아이디입니다."); } - password.validateNotContainBirthday(birthDate); - - String encodedPasswordValue = passwordEncoder.encode(password.getValue()); - Password encryptedPassword = Password.fromEncoded(encodedPasswordValue); - - UserModel userModel = new UserModel(loginId,encryptedPassword,name,birthDate,email); + EncryptedPassword password = EncryptedPassword.of(command.rawPassword(), passwordEncoder, birthDate); + UserModel userModel = new UserModel(loginId, password, name, birthDate, email); - return userRepository.save(userModel); + return UserInfo.from(userRepository.save(userModel)); } - public UserModel getMyInfo(LoginId loginId) { - return userRepository.find(loginId) + public UserInfo getMyInfo(LoginId loginId) { + UserModel user = userRepository.find(loginId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.")); + return UserInfo.from(user); } @Transactional - public void changePassword(LoginId loginId, Password currentPassword, Password newPassword) { - UserModel user = userRepository.find(loginId) + public void changePassword(ChangePasswordCommand command) { + UserModel user = userRepository.find(command.loginId()) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.")); - // 현재 비밀번호 검증 - if (!passwordEncoder.matches(currentPassword.getValue(), user.getPassword().getValue())) { - throw new CoreException(ErrorType.BAD_REQUEST, "현재 비밀번호가 일치하지 않습니다."); - } - - // 새 비밀번호 검증 - if (passwordEncoder.matches(newPassword.getValue(), user.getPassword().getValue())) { - throw new CoreException(ErrorType.BAD_REQUEST, "현재 사용 중인 비밀번호는 사용할 수 없습니다."); - } - - newPassword.validateNotContainBirthday(user.getBirthDate()); - - // 새 비밀번호 암호화 및 저장 - String encodedNewPassword = passwordEncoder.encode(newPassword.getValue()); - Password encryptedNewPassword = Password.fromEncoded(encodedNewPassword); - - user.changePassword(Password.fromEncoded(user.getPassword().getValue()), encryptedNewPassword); + user.changePassword(command.rawCurrentPassword(), command.rawNewPassword(), passwordEncoder); userRepository.save(user); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java deleted file mode 100644 index c588c4a8a..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.domain.BaseEntity; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; - -@Entity -@Table(name = "example") -public class ExampleModel extends BaseEntity { - - private String name; - private String description; - - protected ExampleModel() {} - - public ExampleModel(String name, String description) { - if (name == null || name.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "이름은 비어있을 수 없습니다."); - } - if (description == null || description.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "설명은 비어있을 수 없습니다."); - } - - this.name = name; - this.description = description; - } - - public String getName() { - return name; - } - - public String getDescription() { - return description; - } - - public void update(String newDescription) { - if (newDescription == null || newDescription.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "설명은 비어있을 수 없습니다."); - } - this.description = newDescription; - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java deleted file mode 100644 index 3625e5662..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.loopers.domain.example; - -import java.util.Optional; - -public interface ExampleRepository { - Optional find(Long id); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java deleted file mode 100644 index c0e8431e8..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -@RequiredArgsConstructor -@Component -public class ExampleService { - - private final ExampleRepository exampleRepository; - - @Transactional(readOnly = true) - public ExampleModel getExample(Long id) { - return exampleRepository.find(id) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + id + "] 예시를 찾을 수 없습니다.")); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java deleted file mode 100644 index ce6d3ead0..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.loopers.infrastructure.example; - -import com.loopers.domain.example.ExampleModel; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface ExampleJpaRepository extends JpaRepository {} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java deleted file mode 100644 index 37f2272f0..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.loopers.infrastructure.example; - -import com.loopers.domain.example.ExampleModel; -import com.loopers.domain.example.ExampleRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.util.Optional; - -@RequiredArgsConstructor -@Component -public class ExampleRepositoryImpl implements ExampleRepository { - private final ExampleJpaRepository exampleJpaRepository; - - @Override - public Optional find(Long id) { - return exampleJpaRepository.findById(id); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java deleted file mode 100644 index 219e3101e..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.loopers.interfaces.api.example; - -import com.loopers.interfaces.api.ApiResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.tags.Tag; - -@Tag(name = "Example V1 API", description = "Loopers 예시 API 입니다.") -public interface ExampleV1ApiSpec { - - @Operation( - summary = "예시 조회", - description = "ID로 예시를 조회합니다." - ) - ApiResponse getExample( - @Schema(name = "예시 ID", description = "조회할 예시의 ID") - Long exampleId - ); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java deleted file mode 100644 index 917376016..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.loopers.interfaces.api.example; - -import com.loopers.application.example.ExampleFacade; -import com.loopers.application.example.ExampleInfo; -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; - -@RequiredArgsConstructor -@RestController -@RequestMapping("/api/v1/examples") -public class ExampleV1Controller implements ExampleV1ApiSpec { - - private final ExampleFacade exampleFacade; - - @GetMapping("/{exampleId}") - @Override - public ApiResponse getExample( - @PathVariable(value = "exampleId") Long exampleId - ) { - ExampleInfo info = exampleFacade.getExample(exampleId); - ExampleV1Dto.ExampleResponse response = ExampleV1Dto.ExampleResponse.from(info); - return ApiResponse.success(response); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java deleted file mode 100644 index 4ecf0eea5..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.loopers.interfaces.api.example; - -import com.loopers.application.example.ExampleInfo; - -public class ExampleV1Dto { - public record ExampleResponse(Long id, String name, String description) { - public static ExampleResponse from(ExampleInfo info) { - return new ExampleResponse( - info.id(), - info.name(), - info.description() - ); - } - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java index f596a2b37..b4eb4be8d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java @@ -1,20 +1,21 @@ package com.loopers.interfaces.api.user; -import com.loopers.domain.*; +import com.loopers.domain.AuthenticationService; +import com.loopers.domain.ChangePasswordCommand; +import com.loopers.domain.SignupCommand; +import com.loopers.domain.UserInfo; +import com.loopers.domain.UserModel; +import com.loopers.domain.UserService; import com.loopers.interfaces.api.ApiResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; -import java.time.LocalDate; -import java.time.format.DateTimeFormatter; - @RequiredArgsConstructor @RestController @RequestMapping("/api/v1/users") public class UserV1Controller implements UserV1ApiSpec { - private static final DateTimeFormatter BIRTH_DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; @@ -26,16 +27,12 @@ public class UserV1Controller implements UserV1ApiSpec { public ApiResponse signup( @Valid @RequestBody UserV1Dto.SignupRequest request ) { - LoginId loginId = new LoginId(request.loginId()); - Password password = Password.of(request.password()); - Name name = new Name(request.name()); - BirthDate birthDate = new BirthDate(LocalDate.parse(request.birthDate(), BIRTH_DATE_FORMATTER)); - Email email = new Email(request.email()); - - UserModel userModel = userService.signup(loginId, password, name, birthDate, email); - UserV1Dto.SignupResponse response = UserV1Dto.SignupResponse.from(userModel); + SignupCommand command = new SignupCommand( + request.loginId(), request.password(), request.name(), request.birthDate(), request.email() + ); + UserInfo userInfo = userService.signup(command); - return ApiResponse.success(response); + return ApiResponse.success(UserV1Dto.SignupResponse.from(userInfo)); } @GetMapping("/me") @@ -45,10 +42,9 @@ public ApiResponse getMyInfo( @RequestHeader(HEADER_LOGIN_PW) String password ) { UserModel authenticatedUser = authenticationService.authenticate(loginId, password); - UserModel userInfo = userService.getMyInfo(authenticatedUser.getLoginId()); - UserV1Dto.MyInfoResponse response = UserV1Dto.MyInfoResponse.from(userInfo); + UserInfo userInfo = userService.getMyInfo(authenticatedUser.getLoginId()); - return ApiResponse.success(response); + return ApiResponse.success(UserV1Dto.MyInfoResponse.from(userInfo)); } @PatchMapping("/password") @@ -59,11 +55,10 @@ public ApiResponse changePassword( @Valid @RequestBody UserV1Dto.ChangePasswordRequest request ) { UserModel authenticatedUser = authenticationService.authenticate(loginId, currentPasswordValue); - - Password currentPassword = Password.of(request.currentPassword()); - Password newPassword = Password.of(request.newPassword()); - - userService.changePassword(authenticatedUser.getLoginId(), currentPassword, newPassword); + ChangePasswordCommand command = new ChangePasswordCommand( + authenticatedUser.getLoginId(), request.currentPassword(), request.newPassword() + ); + userService.changePassword(command); return ApiResponse.success(UserV1Dto.ChangePasswordResponse.success()); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java index 7daae4809..675197763 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java @@ -1,6 +1,6 @@ package com.loopers.interfaces.api.user; -import com.loopers.domain.UserModel; +import com.loopers.domain.UserInfo; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; @@ -42,13 +42,13 @@ public record SignupResponse( String birthDate, String email ) { - public static SignupResponse from(UserModel model) { + public static SignupResponse from(UserInfo info) { return new SignupResponse( - model.getId(), - model.getLoginId().getValue(), - model.getName().getMaskedName(), - model.getBirthDate().toDateString(), - model.getEmail().getMail() + info.id(), + info.loginId(), + maskName(info.name()), + info.birthDate(), + info.email() ); } } @@ -59,12 +59,12 @@ public record MyInfoResponse( String birthDate, String email ) { - public static MyInfoResponse from(UserModel model) { + public static MyInfoResponse from(UserInfo info) { return new MyInfoResponse( - model.getLoginId().getValue(), - model.getName().getMaskedName(), - model.getBirthDate().toDateString(), - model.getEmail().getMail() + info.loginId(), + maskName(info.name()), + info.birthDate(), + info.email() ); } } @@ -89,4 +89,9 @@ public static ChangePasswordResponse success() { return new ChangePasswordResponse("비밀번호가 성공적으로 변경되었습니다."); } } + + private static String maskName(String name) { + if (name == null || name.isEmpty()) return name; + return name.substring(0, name.length() - 1) + "*"; + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/AuthenticationServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/AuthenticationServiceTest.java index 85fa09a96..56a1c298b 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/AuthenticationServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/AuthenticationServiceTest.java @@ -27,7 +27,6 @@ class AuthenticationServiceTest { @Mock private PasswordEncoder passwordEncoder; - // STEP2: infrastructure import가 사라졌다. 같은 domain 패키지. @InjectMocks private AuthenticationService authenticationService; @@ -45,7 +44,7 @@ void setUp() { testUser = new UserModel( new LoginId(validLoginId), - Password.fromEncoded(encodedPassword), + EncryptedPassword.fromEncoded(encodedPassword), new Name("홍길동"), new BirthDate(LocalDate.of(1990, 1, 15)), new Email("test@example.com") diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/PasswordTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/EncryptedPasswordTest.java similarity index 53% rename from apps/commerce-api/src/test/java/com/loopers/domain/PasswordTest.java rename to apps/commerce-api/src/test/java/com/loopers/domain/EncryptedPasswordTest.java index f3e5c9070..b5cedb551 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/PasswordTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/EncryptedPasswordTest.java @@ -1,5 +1,6 @@ package com.loopers.domain; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; @@ -10,30 +11,36 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -class PasswordTest { +class EncryptedPasswordTest { private BirthDate defaultBirthDate; + private PasswordEncoder noOpEncoder; @BeforeEach void setUp() { defaultBirthDate = new BirthDate(LocalDate.of(1990, 1, 15)); + noOpEncoder = new PasswordEncoder() { + @Override + public String encode(String rawPassword) { return rawPassword; } + @Override + public boolean matches(String rawPassword, String encodedPassword) { return rawPassword.equals(encodedPassword); } + }; } - @DisplayName("비밀번호 객체를 생성할 때, ") + @DisplayName("암호화된 비밀번호 객체를 생성할 때, ") @Nested class Create { @DisplayName("8~16자의 영문 대소문자, 숫자, 특수문자가 모두 포함되면 정상 생성된다.") @Test void createPassword_whenValidFormat() { - // 대문자(V), 소문자(alid), 숫자(123), 특수문자(!@#) 모두 포함 - assertDoesNotThrow(() -> Password.of("Valid123!@#", defaultBirthDate)); + assertDoesNotThrow(() -> EncryptedPassword.of("Valid123!@#", noOpEncoder)); } @DisplayName("규칙에 어긋나는 형식이면 예외가 발생한다.") @Test void createPassword_whenInvalidFormat() { - assertThatThrownBy(() -> Password.of("invalid", defaultBirthDate)) + assertThatThrownBy(() -> EncryptedPassword.of("invalid", noOpEncoder)) .isInstanceOf(CoreException.class); } } @@ -45,21 +52,17 @@ class Validation { @DisplayName("비밀번호에 생년월일(yyyyMMdd)이 포함되어 있으면 예외가 발생한다.") @Test void validateNotContainBirthday_fail() { - // act & assert: Password.of 생성 시점에 생년월일 포함 검증이 수행됨 - assertThatThrownBy(() -> Password.of("Pw19900115!", defaultBirthDate)) + assertThatThrownBy(() -> EncryptedPassword.of("Pw19900115!", noOpEncoder, defaultBirthDate)) .isInstanceOf(CoreException.class); } - @DisplayName("수정하려는 비밀번호가 기존 비밀번호와 동일하면 예외가 발생한다.") + @DisplayName("matches()로 원시 비밀번호와 암호화된 비밀번호를 비교할 수 있다.") @Test - void validateNotSameAs_fail() { - // arrange - Password currentPassword = Password.of("Current123!", defaultBirthDate); - Password newPassword = Password.of("Current123!", defaultBirthDate); + void matches_shouldCompareRawWithEncoded() { + EncryptedPassword password = EncryptedPassword.of("Valid123!@#", noOpEncoder); - // act & assert - assertThatThrownBy(() -> newPassword.validateNotSameAs(currentPassword)) - .isInstanceOf(CoreException.class); + assertThat(password.matches("Valid123!@#", noOpEncoder)).isTrue(); + assertThat(password.matches("Wrong123!@#", noOpEncoder)).isFalse(); } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/NameTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/NameTest.java index eeb4796e3..85b684f6c 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/NameTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/NameTest.java @@ -8,7 +8,6 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.ValueSource; class NameTest { @@ -26,6 +25,7 @@ void createName_whenValidNameProvided(String validNameValue) { // assert assertThat(name).isNotNull(); + assertThat(name.getValue()).isEqualTo(validNameValue); } @DisplayName("이름이 null이면 예외가 발생한다.") @@ -58,29 +58,7 @@ void createName_whenNameIsTooLong() { String longName = "가나다라마바사아자차카"; // 11자 assertThatThrownBy(() -> new Name(longName)) - .isInstanceOf(CoreException.class);} - } - - @DisplayName("이름을 마스킹할 때, ") - @Nested - class Masking { - - @DisplayName("이름의 마지막 글자가 '*'로 치환된다.") - @ParameterizedTest - @CsvSource({ - "홍길, 홍*", - "홍길동, 홍길*", - "가나다라마, 가나다라*" - }) - void getMaskedName_shouldMaskLastCharacter(String original, String expected) { - // arrange - Name name = new Name(original); - - // act - String maskedName = name.getMaskedName(); - - // assert - assertThat(maskedName).isEqualTo(expected); + .isInstanceOf(CoreException.class); } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/UserModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/UserModelTest.java index 37df7ae23..85832ad67 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/UserModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/UserModelTest.java @@ -14,15 +14,17 @@ class UserModelTest { private LoginId validLoginId; - private Password validPassword; + private EncryptedPassword validPassword; private Name validName; private BirthDate validBirthDate; private Email validEmail; + private FakePasswordEncoder encoder; @BeforeEach void setUp() { + encoder = new FakePasswordEncoder(); validLoginId = new LoginId("testuser123"); - validPassword = Password.of("Test1234!@#"); + validPassword = EncryptedPassword.of("Test1234!@#", encoder); validName = new Name("홍길동"); validBirthDate = new BirthDate(LocalDate.of(1990, 1, 15)); validEmail = new Email("test@example.com"); @@ -83,4 +85,58 @@ void createUserModel_whenEmailIsNull() { .isInstanceOf(CoreException.class); } } + + @DisplayName("비밀번호를 변경할 때, ") + @Nested + class ChangePassword { + + @DisplayName("올바른 현재 비밀번호와 유효한 새 비밀번호가 주어지면 비밀번호가 변경된다.") + @Test + void changePassword_success() { + // arrange + UserModel user = new UserModel(validLoginId, validPassword, validName, validBirthDate, validEmail); + + // act + user.changePassword("Test1234!@#", "NewPass123!@", encoder); + + // assert + assertThat(user.getPassword().getValue()).isEqualTo("ENCODED_NewPass123!@"); + } + + @DisplayName("현재 비밀번호가 일치하지 않으면 예외가 발생한다.") + @Test + void changePassword_whenCurrentPasswordNotMatch() { + // arrange + UserModel user = new UserModel(validLoginId, validPassword, validName, validBirthDate, validEmail); + + // act & assert + assertThatThrownBy(() -> user.changePassword("Wrong123!@#", "NewPass123!@", encoder)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("현재 비밀번호가 일치하지 않습니다."); + } + + @DisplayName("새 비밀번호가 현재 비밀번호와 동일하면 예외가 발생한다.") + @Test + void changePassword_whenNewPasswordSameAsCurrent() { + // arrange + UserModel user = new UserModel(validLoginId, validPassword, validName, validBirthDate, validEmail); + + // act & assert + assertThatThrownBy(() -> user.changePassword("Test1234!@#", "Test1234!@#", encoder)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("현재 사용 중인 비밀번호는 사용할 수 없습니다."); + } + + @DisplayName("새 비밀번호에 생년월일이 포함되면 예외가 발생한다.") + @Test + void changePassword_whenNewPasswordContainsBirthDate() { + // arrange + UserModel user = new UserModel(validLoginId, validPassword, validName, validBirthDate, validEmail); + + // act & assert + assertThatThrownBy(() -> user.changePassword("Test1234!@#", "Pw19900115!", encoder)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("생년월일은 비밀번호 내에 포함될 수 없습니다."); + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceFakeTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceFakeTest.java index a169a658c..f1e933ad5 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceFakeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceFakeTest.java @@ -1,24 +1,9 @@ package com.loopers.domain; -// ============================================================ -// STEP 3: After — Fake 기반 단위 테스트 -// -// STEP 1의 UserServiceMockTest와 동일한 시나리오를 테스트하되, -// Mock 대신 Fake를 사용한다. -// -// 비교 포인트: -// 1. infrastructure import 없음 — 도메인 안에서 테스트가 완결됨 -// 2. when-then 0줄 — Fake가 알아서 동작 -// 3. 실제 저장된 값을 직접 검증 가능 — verify(save) 대신 find()로 확인 -// -// Mockito import도 없다. 순수 JUnit만 사용. -// ============================================================ - import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.loopers.support.error.CoreException; -import java.time.LocalDate; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -27,16 +12,15 @@ @DisplayName("STEP3: UserService Fake 기반 단위 테스트 (After)") class UserServiceFakeTest { - // Mockito 없음. 순수 Java 객체로 조립. private FakeUserRepository userRepository; private FakePasswordEncoder passwordEncoder; private UserService userService; - private LoginId validLoginId; - private Password validPassword; - private Name validName; - private BirthDate validBirthDate; - private Email validEmail; + private String loginId; + private String rawPassword; + private String name; + private String birthDate; + private String email; @BeforeEach void setUp() { @@ -44,11 +28,15 @@ void setUp() { passwordEncoder = new FakePasswordEncoder(); userService = new UserService(userRepository, passwordEncoder); - validLoginId = new LoginId("testuser1"); - validPassword = Password.of("Test1234!@#"); - validName = new Name("홍길동"); - validBirthDate = new BirthDate(LocalDate.of(1990, 1, 15)); - validEmail = new Email("test@example.com"); + loginId = "testuser1"; + rawPassword = "Test1234!@#"; + name = "홍길동"; + birthDate = "19900115"; + email = "test@example.com"; + } + + private SignupCommand signupCommand() { + return new SignupCommand(loginId, rawPassword, name, birthDate, email); } @DisplayName("회원가입") @@ -58,38 +46,28 @@ class Signup { @Test @DisplayName("성공 — when-then 0줄, 암호화 결과를 직접 검증") void signup_성공() { - // arrange — when-then 없음 - // act - UserModel result = userService.signup( - validLoginId, validPassword, validName, validBirthDate, validEmail - ); + UserInfo result = userService.signup(signupCommand()); // assert assertThat(result).isNotNull(); - assertThat(result.getLoginId()).isEqualTo(validLoginId); + assertThat(result.loginId()).isEqualTo(loginId); - // Fake라서 암호화 결과가 예측 가능하다. - // Mock에서는 내가 지시한 "$2a$10$encodedHash"가 나왔지만, - // Fake에서는 실제 로직(ENCODED_ + 원본)이 동작한 결과가 나온다. - assertThat(result.getPassword().getValue()).isEqualTo("ENCODED_Test1234!@#"); + // 암호화 검증은 repository를 통해 직접 확인 + UserModel saved = userRepository.find(new LoginId(loginId)).orElseThrow(); + assertThat(saved.getPassword().getValue()).isEqualTo("ENCODED_Test1234!@#"); } @Test - @DisplayName("중복 아이디면 예외 — Fake에 이미 데이터가 있으므로 자연스럽게 감지") + @DisplayName("중복 아이디면 예외") void signup_중복아이디_예외() { - // arrange — 먼저 가입 - userService.signup(validLoginId, validPassword, validName, validBirthDate, validEmail); - // FakeUserRepository에 데이터가 저장된 상태. - // Mock이면 when(find).thenReturn(Optional.of(...))를 써야 했음. + // arrange + userService.signup(signupCommand()); - // act & assert — 같은 ID로 다시 가입 + // act & assert + SignupCommand duplicateCommand = new SignupCommand(loginId, "Other123!@#", name, birthDate, email); assertThatThrownBy(() -> - userService.signup( - validLoginId, - Password.of("Other123!@#"), - validName, validBirthDate, validEmail - ) + userService.signup(duplicateCommand) ).isInstanceOf(CoreException.class) .hasMessageContaining("이미 존재하는 아이디입니다."); } @@ -102,52 +80,42 @@ class ChangePassword { @Test @DisplayName("성공 — when-then 0줄, 변경된 비밀번호를 직접 검증") void changePassword_성공() { - // arrange — 실제 흐름처럼 먼저 가입 - userService.signup(validLoginId, validPassword, validName, validBirthDate, validEmail); - // Mock이면 여기서 Mock 설정 5줄이 필요했음. - // Fake는 signup이 실제로 저장하므로 추가 설정 불필요. + // arrange + userService.signup(signupCommand()); // act - Password newPassword = Password.of("NewPass123!@"); - userService.changePassword(validLoginId, validPassword, newPassword); + LoginId loginIdVo = new LoginId(loginId); + userService.changePassword(new ChangePasswordCommand(loginIdVo, rawPassword, "NewPass123!@")); - // assert — 실제 저장된 값을 직접 확인 - UserModel updated = userRepository.find(validLoginId).orElseThrow(); + // assert + UserModel updated = userRepository.find(loginIdVo).orElseThrow(); assertThat(updated.getPassword().getValue()).isEqualTo("ENCODED_NewPass123!@"); - // Mock에서는 verify(save).save(any())로 "호출됐는가?"만 확인했음. - // Fake에서는 "어떤 값으로 바뀌었는가?"를 직접 검증한다. } @Test - @DisplayName("현재 비밀번호 불일치면 예외 — Fake가 실제로 매칭 실패") + @DisplayName("현재 비밀번호 불일치면 예외") void changePassword_현재비밀번호_불일치() { // arrange - userService.signup(validLoginId, validPassword, validName, validBirthDate, validEmail); + userService.signup(signupCommand()); // act & assert - Password wrongCurrent = Password.of("Wrong123!@#"); - Password newPassword = Password.of("NewPass123!@"); - assertThatThrownBy(() -> - userService.changePassword(validLoginId, wrongCurrent, newPassword) + userService.changePassword(new ChangePasswordCommand(new LoginId(loginId), "Wrong123!@#", "NewPass123!@")) ).isInstanceOf(CoreException.class) .hasMessageContaining("현재 비밀번호가 일치하지 않습니다."); - // Mock이면 when(matches).thenReturn(false)를 써야 했음. - // Fake는 실제로 "ENCODED_Wrong123!@#" ≠ "ENCODED_Test1234!@#"이므로 자연스럽게 실패. } @Test @DisplayName("새 비밀번호가 현재와 같으면 예외") void changePassword_새비밀번호가_현재와_같으면_예외() { // arrange - userService.signup(validLoginId, validPassword, validName, validBirthDate, validEmail); + userService.signup(signupCommand()); // act & assert assertThatThrownBy(() -> - userService.changePassword(validLoginId, validPassword, validPassword) + userService.changePassword(new ChangePasswordCommand(new LoginId(loginId), rawPassword, rawPassword)) ).isInstanceOf(CoreException.class) .hasMessageContaining("현재 사용 중인 비밀번호는 사용할 수 없습니다."); - // Fake가 실제로 매칭: "ENCODED_Test1234!@#" == "ENCODED_Test1234!@#" → true → 예외 } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceIntegrationTest.java index 31ca5af49..88d43564c 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceIntegrationTest.java @@ -4,12 +4,10 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertAll; -// PasswordEncoder는 이제 domain 패키지 — import 불필요 import com.loopers.infrastructure.UserJpaRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import com.loopers.utils.DatabaseCleanUp; -import java.time.LocalDate; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -32,21 +30,19 @@ public class UserServiceIntegrationTest { @Autowired private DatabaseCleanUp databaseCleanUp; - private LoginId validLoginId; - private Password validPassword; + private String loginId; private String rawPassword; - private Name validName; - private BirthDate validBirthDate; - private Email validEmail; + private String name; + private String birthDate; + private String email; @BeforeEach void setUp() { - validLoginId = new LoginId("testuser123"); + loginId = "testuser123"; rawPassword = "Test1234!@#"; - validPassword = Password.of(rawPassword); - validName = new Name("홍길동"); - validBirthDate = new BirthDate(LocalDate.of(1990, 1, 15)); - validEmail = new Email("test@example.com"); + name = "홍길동"; + birthDate = "19900115"; + email = "test@example.com"; } @AfterEach @@ -54,6 +50,10 @@ void tearDown() { databaseCleanUp.truncateAllTables(); } + private SignupCommand signupCommand() { + return new SignupCommand(loginId, rawPassword, name, birthDate, email); + } + @DisplayName("유저가 회원가입할 때") @Nested class SingUp{ @@ -61,15 +61,15 @@ class SingUp{ @Test void signup_whenAllInfoProvided() { // act - UserModel result = userService.signup(validLoginId,validPassword,validName,validBirthDate,validEmail); + UserInfo result = userService.signup(signupCommand()); // assert assertAll( () -> assertThat(result).isNotNull(), - () -> assertThat(result.getLoginId()).isEqualTo(validLoginId), - () -> assertThat(result.getName()).isEqualTo(validName), - () -> assertThat(result.getBirthDate()).isEqualTo(validBirthDate), - () -> assertThat(result.getEmail()).isEqualTo(validEmail) + () -> assertThat(result.loginId()).isEqualTo(loginId), + () -> assertThat(result.name()).isEqualTo(name), + () -> assertThat(result.birthDate()).isEqualTo(birthDate), + () -> assertThat(result.email()).isEqualTo(email) ); } @@ -77,14 +77,15 @@ void signup_whenAllInfoProvided() { @Test void signup_should_encrypt_password() { // act - UserModel result = userService.signup(validLoginId,validPassword,validName,validBirthDate,validEmail); + UserInfo result = userService.signup(signupCommand()); // assert - String savedPassword = result.getPassword().getValue(); + UserModel savedUser = userJpaRepository.findById(result.id()).orElseThrow(); + String savedPassword = savedUser.getPassword().getValue(); assertAll( - () -> assertThat(savedPassword).isNotEqualTo(rawPassword), // 평문과 다름 - () -> assertThat(savedPassword).startsWith("$2a$"), // BCrypt 포맷 - () -> assertThat(passwordEncoder.matches(rawPassword, savedPassword)).isTrue() // 평문과 매칭됨 + () -> assertThat(savedPassword).isNotEqualTo(rawPassword), + () -> assertThat(savedPassword).startsWith("$2a$"), + () -> assertThat(passwordEncoder.matches(rawPassword, savedPassword)).isTrue() ); } @@ -92,15 +93,15 @@ void signup_should_encrypt_password() { @Test void signup_should_save_encrypted_password_to_database() { // act - UserModel result = userService.signup(validLoginId,validPassword,validName,validBirthDate,validEmail); + UserInfo result = userService.signup(signupCommand()); // assert - UserModel savedUser = userJpaRepository.findById(result.getId()).orElseThrow(); + UserModel savedUser = userJpaRepository.findById(result.id()).orElseThrow(); String savedPassword = savedUser.getPassword().getValue(); assertAll( - () -> assertThat(savedPassword).isNotEqualTo(rawPassword), // 평문과 다름 - () -> assertThat(savedPassword).startsWith("$2a$"), // BCrypt 포맷 - () -> assertThat(passwordEncoder.matches(rawPassword, savedPassword)).isTrue() // 평문과 매칭됨 + () -> assertThat(savedPassword).isNotEqualTo(rawPassword), + () -> assertThat(savedPassword).startsWith("$2a$"), + () -> assertThat(passwordEncoder.matches(rawPassword, savedPassword)).isTrue() ); } } @@ -112,18 +113,19 @@ class GetMyInfo { @Test void getMyInfo_whenValidLoginId() { // arrange - userService.signup(validLoginId, validPassword, validName, validBirthDate, validEmail); + userService.signup(signupCommand()); // act - UserModel result = userService.getMyInfo(validLoginId); + LoginId loginIdVo = new LoginId(loginId); + UserInfo result = userService.getMyInfo(loginIdVo); // assert assertAll( () -> assertThat(result).isNotNull(), - () -> assertThat(result.getLoginId()).isEqualTo(validLoginId), - () -> assertThat(result.getName()).isEqualTo(validName), - () -> assertThat(result.getBirthDate()).isEqualTo(validBirthDate), - () -> assertThat(result.getEmail()).isEqualTo(validEmail) + () -> assertThat(result.loginId()).isEqualTo(loginId), + () -> assertThat(result.name()).isEqualTo(name), + () -> assertThat(result.birthDate()).isEqualTo(birthDate), + () -> assertThat(result.email()).isEqualTo(email) ); } @@ -148,19 +150,21 @@ class ChangePassword { @Test void changePassword_whenValidPasswords() { // arrange - userService.signup(validLoginId, validPassword, validName, validBirthDate, validEmail); - Password newPassword = Password.of("NewPass123!@"); + userService.signup(signupCommand()); + String newRawPassword = "NewPass123!@"; // act - userService.changePassword(validLoginId, validPassword, newPassword); + LoginId loginIdVo = new LoginId(loginId); + userService.changePassword(new ChangePasswordCommand(loginIdVo, rawPassword, newRawPassword)); // assert - UserModel updatedUser = userService.getMyInfo(validLoginId); - String savedPassword = updatedUser.getPassword().getValue(); + UserInfo updatedUser = userService.getMyInfo(loginIdVo); + UserModel savedUser = userJpaRepository.findById(updatedUser.id()).orElseThrow(); + String savedPassword = savedUser.getPassword().getValue(); assertAll( - () -> assertThat(savedPassword).isNotEqualTo(rawPassword), // 이전 평문과 다름 - () -> assertThat(savedPassword).isNotEqualTo(newPassword.getValue()), // 새 평문과도 다름 (암호화됨) - () -> assertThat(passwordEncoder.matches(newPassword.getValue(), savedPassword)).isTrue() // 새 비밀번호와 매칭됨 + () -> assertThat(savedPassword).isNotEqualTo(rawPassword), + () -> assertThat(savedPassword).isNotEqualTo(newRawPassword), + () -> assertThat(passwordEncoder.matches(newRawPassword, savedPassword)).isTrue() ); } @@ -168,12 +172,11 @@ void changePassword_whenValidPasswords() { @Test void changePassword_whenCurrentPasswordNotMatch() { // arrange - userService.signup(validLoginId, validPassword, validName, validBirthDate, validEmail); - Password wrongPassword = Password.of("Wrong123!@#"); - Password newPassword = Password.of("NewPass123!@"); + userService.signup(signupCommand()); // act & assert - assertThatThrownBy(() -> userService.changePassword(validLoginId, wrongPassword, newPassword)) + LoginId loginIdVo = new LoginId(loginId); + assertThatThrownBy(() -> userService.changePassword(new ChangePasswordCommand(loginIdVo, "Wrong123!@#", "NewPass123!@"))) .isInstanceOf(CoreException.class) .hasMessageContaining("현재 비밀번호가 일치하지 않습니다."); } @@ -182,11 +185,11 @@ void changePassword_whenCurrentPasswordNotMatch() { @Test void changePassword_whenNewPasswordSameAsCurrent() { // arrange - userService.signup(validLoginId, validPassword, validName, validBirthDate, validEmail); - Password samePassword = Password.of("Test1234!@#"); + userService.signup(signupCommand()); // act & assert - assertThatThrownBy(() -> userService.changePassword(validLoginId, validPassword, samePassword)) + LoginId loginIdVo = new LoginId(loginId); + assertThatThrownBy(() -> userService.changePassword(new ChangePasswordCommand(loginIdVo, rawPassword, rawPassword))) .isInstanceOf(CoreException.class) .hasMessageContaining("현재 사용 중인 비밀번호는 사용할 수 없습니다."); } @@ -195,11 +198,11 @@ void changePassword_whenNewPasswordSameAsCurrent() { @Test void changePassword_whenNewPasswordContainsBirthDate() { // arrange - userService.signup(validLoginId, validPassword, validName, validBirthDate, validEmail); - Password newPasswordWithBirthDate = Password.of("Pw19900115!"); + userService.signup(signupCommand()); // act & assert - assertThatThrownBy(() -> userService.changePassword(validLoginId, validPassword, newPasswordWithBirthDate)) + LoginId loginIdVo = new LoginId(loginId); + assertThatThrownBy(() -> userService.changePassword(new ChangePasswordCommand(loginIdVo, rawPassword, "Pw19900115!"))) .isInstanceOf(CoreException.class) .hasMessageContaining("생년월일은 비밀번호 내에 포함될 수 없습니다."); } @@ -209,10 +212,9 @@ void changePassword_whenNewPasswordContainsBirthDate() { void changePassword_whenUserNotFound() { // arrange LoginId invalidLoginId = new LoginId("invalid123"); - Password newPassword = Password.of("NewPass123!@"); // act & assert - assertThatThrownBy(() -> userService.changePassword(invalidLoginId, validPassword, newPassword)) + assertThatThrownBy(() -> userService.changePassword(new ChangePasswordCommand(invalidLoginId, rawPassword, "NewPass123!@"))) .isInstanceOf(CoreException.class) .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND) .hasMessageContaining("사용자를 찾을 수 없습니다."); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceMockTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceMockTest.java index b691c36c3..191893a79 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceMockTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceMockTest.java @@ -1,29 +1,12 @@ package com.loopers.domain; -// ============================================================ -// STEP 1: Before — Mock 기반 단위 테스트 -// -// 이 테스트는 현재 구조의 "불편함"을 기록하기 위해 작성되었다. -// 주목할 점: -// 1. com.loopers.infrastructure.PasswordEncoder를 import하고 있다 (도메인 테스트인데) -// 2. when-then Mock 설정이 테스트마다 3~5줄씩 필요하다 -// 3. verify(save)로만 검증 가능 — "어떤 값으로 바뀌었는가?"는 확인 불가 -// -// 이 테스트는 STEP 2 리팩토링 후에도 유지되며, -// STEP 3의 Fake 기반 테스트와 비교 대상이 된다. -// ============================================================ - 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.verify; import static org.mockito.Mockito.when; -// STEP2 이후: import 불필요 — PasswordEncoder가 같은 domain 패키지로 이동됨 -// (STEP1에서는 여기에 com.loopers.infrastructure.PasswordEncoder import가 있었음) - import com.loopers.support.error.CoreException; -import java.time.LocalDate; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -43,24 +26,27 @@ class UserServiceMockTest { @Mock private PasswordEncoder passwordEncoder; - // [불편함 1] 이 Mock의 타입이 com.loopers.infrastructure.PasswordEncoder이다. @InjectMocks private UserService userService; - private LoginId validLoginId; - private Password validPassword; - private Name validName; - private BirthDate validBirthDate; - private Email validEmail; + private String loginId; + private String rawPassword; + private String name; + private String birthDate; + private String email; @BeforeEach void setUp() { - validLoginId = new LoginId("testuser1"); - validPassword = Password.of("Test1234!@#"); - validName = new Name("홍길동"); - validBirthDate = new BirthDate(LocalDate.of(1990, 1, 15)); - validEmail = new Email("test@example.com"); + loginId = "testuser1"; + rawPassword = "Test1234!@#"; + name = "홍길동"; + birthDate = "19900115"; + email = "test@example.com"; + } + + private SignupCommand signupCommand() { + return new SignupCommand(loginId, rawPassword, name, birthDate, email); } @DisplayName("회원가입") @@ -68,26 +54,19 @@ void setUp() { class Signup { @Test - @DisplayName("성공 — Mock 설정 3줄 필요") + @DisplayName("성공") void signup_성공() { - // arrange — Mock 설정 3줄 + // arrange when(userRepository.find(any(LoginId.class))).thenReturn(Optional.empty()); when(passwordEncoder.encode("Test1234!@#")).thenReturn("$2a$10$encodedHash"); - // ↑ [불편함 2] "Test1234!@#"을 넘기면 이 값을 반환해라. - // 이건 내가 테스트하고 싶은 것이 아니다. 암호화 결과를 지시하는 것. when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); - // ↑ [불편함 2] save가 받은 객체를 그대로 반환해라. 이것도 노이즈. // act - UserModel result = userService.signup( - validLoginId, validPassword, validName, validBirthDate, validEmail - ); + UserInfo result = userService.signup(signupCommand()); // assert assertThat(result).isNotNull(); - assertThat(result.getLoginId()).isEqualTo(validLoginId); - assertThat(result.getPassword().getValue()).isEqualTo("$2a$10$encodedHash"); - // ↑ 이 값은 내가 Mock에게 "반환하라"고 지시한 값. 실제 암호화 결과가 아님. + assertThat(result.loginId()).isEqualTo(loginId); } @Test @@ -99,7 +78,7 @@ class Signup { // act & assert assertThatThrownBy(() -> - userService.signup(validLoginId, validPassword, validName, validBirthDate, validEmail) + userService.signup(signupCommand()) ).isInstanceOf(CoreException.class) .hasMessageContaining("이미 존재하는 아이디입니다."); } @@ -110,37 +89,27 @@ class Signup { class ChangePassword { @Test - @DisplayName("성공 — Mock 설정 5줄 필요 (가장 극적인 불편함)") + @DisplayName("성공") void changePassword_성공() { - // arrange — Mock 설정 5줄 (!!) + // arrange UserModel existingUser = createTestUser("$2a$10$encodedOldHash"); when(userRepository.find(any(LoginId.class))) .thenReturn(Optional.of(existingUser)); when(passwordEncoder.matches("Test1234!@#", "$2a$10$encodedOldHash")) .thenReturn(true); - // ↑ [불편함 2] "현재 비밀번호가 맞다고 해줘" — Mock에게 연기 지시 when(passwordEncoder.matches("NewPass123!@", "$2a$10$encodedOldHash")) .thenReturn(false); - // ↑ [불편함 2] "새 비밀번호는 현재와 다르다고 해줘" — 또 연기 지시 when(passwordEncoder.encode("NewPass123!@")) .thenReturn("$2a$10$encodedNewHash"); - // ↑ [불편함 2] "새 비밀번호를 암호화하면 이 값을 반환해줘" when(userRepository.save(any())) .thenAnswer(inv -> inv.getArgument(0)); // act - userService.changePassword( - validLoginId, - Password.of("Test1234!@#"), - Password.of("NewPass123!@") - ); + userService.changePassword(new ChangePasswordCommand(new LoginId(loginId), "Test1234!@#", "NewPass123!@")); // assert verify(userRepository).save(any()); - // ↑ [불편함 3] save가 "호출되었는가?"만 확인 가능. - // "어떤 비밀번호로 바뀌었는가?"는 알 수 없다. - // Mock이라 실제로 저장되지 않았기 때문. } @Test @@ -156,11 +125,7 @@ class ChangePassword { // act & assert assertThatThrownBy(() -> - userService.changePassword( - validLoginId, - Password.of("Wrong123!@#"), - Password.of("NewPass123!@") - ) + userService.changePassword(new ChangePasswordCommand(new LoginId(loginId), "Wrong123!@#", "NewPass123!@")) ).isInstanceOf(CoreException.class) .hasMessageContaining("현재 비밀번호가 일치하지 않습니다."); } @@ -170,11 +135,11 @@ class ChangePassword { private UserModel createTestUser(String encodedPassword) { return new UserModel( - validLoginId, - Password.fromEncoded(encodedPassword), - validName, - validBirthDate, - validEmail + new LoginId(loginId), + EncryptedPassword.fromEncoded(encodedPassword), + new Name(name), + new BirthDate(java.time.LocalDate.of(1990, 1, 15)), + new Email(email) ); } } From df15ab84981cc0c16bdf6480e07097f60c4ff3dc Mon Sep 17 00:00:00 2001 From: plan11plan Date: Tue, 10 Feb 2026 00:11:35 +0900 Subject: [PATCH 003/108] =?UTF-8?q?chore:=20example=20=EC=98=88=EC=A0=9C?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/example/ExampleModelTest.java | 65 ---------- .../ExampleServiceIntegrationTest.java | 72 ----------- .../interfaces/api/ExampleV1ApiE2ETest.java | 114 ------------------ 3 files changed, 251 deletions(-) delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java deleted file mode 100644 index 44ca7576e..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertThrows; - -class ExampleModelTest { - @DisplayName("예시 모델을 생성할 때, ") - @Nested - class Create { - @DisplayName("제목과 설명이 모두 주어지면, 정상적으로 생성된다.") - @Test - void createsExampleModel_whenNameAndDescriptionAreProvided() { - // arrange - String name = "제목"; - String description = "설명"; - - // act - ExampleModel exampleModel = new ExampleModel(name, description); - - // assert - assertAll( - () -> assertThat(exampleModel.getId()).isNotNull(), - () -> assertThat(exampleModel.getName()).isEqualTo(name), - () -> assertThat(exampleModel.getDescription()).isEqualTo(description) - ); - } - - @DisplayName("제목이 빈칸으로만 이루어져 있으면, BAD_REQUEST 예외가 발생한다.") - @Test - void throwsBadRequestException_whenTitleIsBlank() { - // arrange - String name = " "; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - new ExampleModel(name, "설명"); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - @DisplayName("설명이 비어있으면, BAD_REQUEST 예외가 발생한다.") - @Test - void throwsBadRequestException_whenDescriptionIsEmpty() { - // arrange - String description = ""; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - new ExampleModel("제목", description); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java deleted file mode 100644 index bbd5fdbe1..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.infrastructure.example.ExampleJpaRepository; -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 -class ExampleServiceIntegrationTest { - @Autowired - private ExampleService exampleService; - - @Autowired - private ExampleJpaRepository exampleJpaRepository; - - @Autowired - private DatabaseCleanUp databaseCleanUp; - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @DisplayName("예시를 조회할 때,") - @Nested - class Get { - @DisplayName("존재하는 예시 ID를 주면, 해당 예시 정보를 반환한다.") - @Test - void returnsExampleInfo_whenValidIdIsProvided() { - // arrange - ExampleModel exampleModel = exampleJpaRepository.save( - new ExampleModel("예시 제목", "예시 설명") - ); - - // act - ExampleModel result = exampleService.getExample(exampleModel.getId()); - - // assert - assertAll( - () -> assertThat(result).isNotNull(), - () -> assertThat(result.getId()).isEqualTo(exampleModel.getId()), - () -> assertThat(result.getName()).isEqualTo(exampleModel.getName()), - () -> assertThat(result.getDescription()).isEqualTo(exampleModel.getDescription()) - ); - } - - @DisplayName("존재하지 않는 예시 ID를 주면, NOT_FOUND 예외가 발생한다.") - @Test - void throwsException_whenInvalidIdIsProvided() { - // arrange - Long invalidId = 999L; // Assuming this ID does not exist - - // act - CoreException exception = assertThrows(CoreException.class, () -> { - exampleService.getExample(invalidId); - }); - - // assert - assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java deleted file mode 100644 index 1bb3dba65..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java +++ /dev/null @@ -1,114 +0,0 @@ -package com.loopers.interfaces.api; - -import com.loopers.domain.example.ExampleModel; -import com.loopers.infrastructure.example.ExampleJpaRepository; -import com.loopers.interfaces.api.example.ExampleV1Dto; -import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.web.client.TestRestTemplate; -import org.springframework.core.ParameterizedTypeReference; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpMethod; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; - -import java.util.function.Function; - -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) -class ExampleV1ApiE2ETest { - - private static final Function ENDPOINT_GET = id -> "/api/v1/examples/" + id; - - private final TestRestTemplate testRestTemplate; - private final ExampleJpaRepository exampleJpaRepository; - private final DatabaseCleanUp databaseCleanUp; - - @Autowired - public ExampleV1ApiE2ETest( - TestRestTemplate testRestTemplate, - ExampleJpaRepository exampleJpaRepository, - DatabaseCleanUp databaseCleanUp - ) { - this.testRestTemplate = testRestTemplate; - this.exampleJpaRepository = exampleJpaRepository; - this.databaseCleanUp = databaseCleanUp; - } - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @DisplayName("GET /api/v1/examples/{id}") - @Nested - class Get { - @DisplayName("존재하는 예시 ID를 주면, 해당 예시 정보를 반환한다.") - @Test - void returnsExampleInfo_whenValidIdIsProvided() { - // arrange - ExampleModel exampleModel = exampleJpaRepository.save( - new ExampleModel("예시 제목", "예시 설명") - ); - String requestUrl = ENDPOINT_GET.apply(exampleModel.getId()); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is2xxSuccessful()), - () -> assertThat(response.getBody().data().id()).isEqualTo(exampleModel.getId()), - () -> assertThat(response.getBody().data().name()).isEqualTo(exampleModel.getName()), - () -> assertThat(response.getBody().data().description()).isEqualTo(exampleModel.getDescription()) - ); - } - - @DisplayName("숫자가 아닌 ID 로 요청하면, 400 BAD_REQUEST 응답을 받는다.") - @Test - void throwsBadRequest_whenIdIsNotProvided() { - // arrange - String requestUrl = "/api/v1/examples/나나"; - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is4xxClientError()), - () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST) - ); - } - - @DisplayName("존재하지 않는 예시 ID를 주면, 404 NOT_FOUND 응답을 받는다.") - @Test - void throwsException_whenInvalidIdIsProvided() { - // arrange - Long invalidId = -1L; - String requestUrl = ENDPOINT_GET.apply(invalidId); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is4xxClientError()), - () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) - ); - } - } -} From 8d4b6d2aff3a71a4bb95165728b840bc5c464893 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Tue, 10 Feb 2026 03:15:34 +0900 Subject: [PATCH 004/108] =?UTF-8?q?refactor:=20=EC=83=9D=EB=85=84=EC=9B=94?= =?UTF-8?q?=EC=9D=BC-=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=20=EA=B5=90?= =?UTF-8?q?=EC=B0=A8=20=EA=B2=80=EC=A6=9D=EC=9D=84=20UserModel=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=EC=9E=90=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EncryptedPassword.of() 오버로드 제거 → 하나만 유지 (형식 검증 + 암호화) - UserModel 생성자에서 rawPassword + encoder를 받아 birthDate 교차 검증 수행 - 생성/변경 두 경로 모두 UserModel이 검증 → 일관성 확보 - 패스워드 설계 결정 문서 추가 Co-Authored-By: Claude Opus 4.6 --- .../com/loopers/domain/EncryptedPassword.java | 13 --- .../java/com/loopers/domain/UserModel.java | 29 +++-- .../java/com/loopers/domain/UserService.java | 3 +- .../loopers/domain/EncryptedPasswordTest.java | 10 -- .../com/loopers/domain/UserModelTest.java | 34 +++--- .../loopers/domain/UserServiceMockTest.java | 7 +- docs/round2/password-design-decisions.md | 106 ++++++++++++++++++ 7 files changed, 151 insertions(+), 51 deletions(-) create mode 100644 docs/round2/password-design-decisions.md diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/EncryptedPassword.java b/apps/commerce-api/src/main/java/com/loopers/domain/EncryptedPassword.java index bc1178a03..c991ab9ef 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/EncryptedPassword.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/EncryptedPassword.java @@ -25,12 +25,6 @@ public static EncryptedPassword of(String rawPassword, PasswordEncoder encoder) return new EncryptedPassword(encoder.encode(rawPassword)); } - public static EncryptedPassword of(String rawPassword, PasswordEncoder encoder, BirthDate birthDate) { - validateFormat(rawPassword); - validateNotContainBirthday(rawPassword, birthDate); - return new EncryptedPassword(encoder.encode(rawPassword)); - } - public static EncryptedPassword fromEncoded(String encodedValue) { return new EncryptedPassword(encodedValue); } @@ -49,11 +43,4 @@ private static void validateFormat(String value) { } } - private static void validateNotContainBirthday(String rawPassword, BirthDate birthDate) { - String birthDateString = birthDate.toDateString(); - - if (rawPassword.contains(birthDateString)) { - throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 비밀번호 내에 포함될 수 없습니다."); - } - } } \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/UserModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/UserModel.java index 22f07ea21..587173175 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/UserModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/UserModel.java @@ -37,25 +37,29 @@ public class UserModel extends BaseEntity { @AttributeOverride(name = "mail", column = @Column(name = "email")) private Email email; - public UserModel(LoginId loginId, EncryptedPassword password, Name name, BirthDate birthDate, Email email) { - validate(loginId, password, name, birthDate, email); + public UserModel(LoginId loginId, String rawPassword, PasswordEncoder encoder, Name name, BirthDate birthDate, Email email) { + validateNotNull(loginId, "로그인 ID"); + validateNotNull(name, "이름"); + validateNotNull(birthDate, "생년월일"); + validateNotNull(email, "이메일"); + validateBirthDateNotInPassword(rawPassword, birthDate); + this.loginId = loginId; - this.password = password; + this.password = EncryptedPassword.of(rawPassword, encoder); this.name = name; this.birthDate = birthDate; this.email = email; } - private void validate(LoginId loginId, EncryptedPassword password, Name name, BirthDate birthDate, Email email) { - validateNotNull(loginId, "로그인 ID"); - validateNotNull(password, "비밀번호"); - validateNotNull(name, "이름"); - validateNotNull(birthDate, "생년월일"); - validateNotNull(email, "이메일"); - } private void validateNotNull(Object value, String fieldName) { if (value == null) { - throw new CoreException(ErrorType.BAD_REQUEST,fieldName + "은(는) 필수 입력값입니다."); + throw new CoreException(ErrorType.BAD_REQUEST, fieldName + "은(는) 필수 입력값입니다."); + } + } + + private void validateBirthDateNotInPassword(String rawPassword, BirthDate birthDate) { + if (rawPassword != null && rawPassword.contains(birthDate.toDateString())) { + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 비밀번호 내에 포함될 수 없습니다."); } } @@ -66,6 +70,7 @@ public void changePassword(String rawCurrentPassword, String rawNewPassword, Pas if (this.password.matches(rawNewPassword, encoder)) { throw new CoreException(ErrorType.BAD_REQUEST, "현재 사용 중인 비밀번호는 사용할 수 없습니다."); } - this.password = EncryptedPassword.of(rawNewPassword, encoder, this.birthDate); + validateBirthDateNotInPassword(rawNewPassword, this.birthDate); + this.password = EncryptedPassword.of(rawNewPassword, encoder); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/UserService.java index f3e51b450..02c9dd182 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/UserService.java @@ -27,8 +27,7 @@ public UserInfo signup(SignupCommand command) { throw new CoreException(ErrorType.BAD_REQUEST,"이미 존재하는 아이디입니다."); } - EncryptedPassword password = EncryptedPassword.of(command.rawPassword(), passwordEncoder, birthDate); - UserModel userModel = new UserModel(loginId, password, name, birthDate, email); + UserModel userModel = new UserModel(loginId, command.rawPassword(), passwordEncoder, name, birthDate, email); return UserInfo.from(userRepository.save(userModel)); } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/EncryptedPasswordTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/EncryptedPasswordTest.java index b5cedb551..77973e045 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/EncryptedPasswordTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/EncryptedPasswordTest.java @@ -5,7 +5,6 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import com.loopers.support.error.CoreException; -import java.time.LocalDate; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -13,12 +12,10 @@ class EncryptedPasswordTest { - private BirthDate defaultBirthDate; private PasswordEncoder noOpEncoder; @BeforeEach void setUp() { - defaultBirthDate = new BirthDate(LocalDate.of(1990, 1, 15)); noOpEncoder = new PasswordEncoder() { @Override public String encode(String rawPassword) { return rawPassword; } @@ -49,13 +46,6 @@ void createPassword_whenInvalidFormat() { @Nested class Validation { - @DisplayName("비밀번호에 생년월일(yyyyMMdd)이 포함되어 있으면 예외가 발생한다.") - @Test - void validateNotContainBirthday_fail() { - assertThatThrownBy(() -> EncryptedPassword.of("Pw19900115!", noOpEncoder, defaultBirthDate)) - .isInstanceOf(CoreException.class); - } - @DisplayName("matches()로 원시 비밀번호와 암호화된 비밀번호를 비교할 수 있다.") @Test void matches_shouldCompareRawWithEncoded() { diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/UserModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/UserModelTest.java index 85832ad67..70f0273e0 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/UserModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/UserModelTest.java @@ -14,7 +14,7 @@ class UserModelTest { private LoginId validLoginId; - private EncryptedPassword validPassword; + private String validRawPassword; private Name validName; private BirthDate validBirthDate; private Email validEmail; @@ -24,7 +24,7 @@ class UserModelTest { void setUp() { encoder = new FakePasswordEncoder(); validLoginId = new LoginId("testuser123"); - validPassword = EncryptedPassword.of("Test1234!@#", encoder); + validRawPassword = "Test1234!@#"; validName = new Name("홍길동"); validBirthDate = new BirthDate(LocalDate.of(1990, 1, 15)); validEmail = new Email("test@example.com"); @@ -38,12 +38,12 @@ class Create { @Test void createUserModel_whenAllDataProvided() { // act - UserModel user = new UserModel(validLoginId, validPassword, validName, validBirthDate, validEmail); + UserModel user = new UserModel(validLoginId, validRawPassword, encoder, validName, validBirthDate, validEmail); // assert assertAll( () -> assertThat(user.getLoginId()).isEqualTo(validLoginId), - () -> assertThat(user.getPassword()).isEqualTo(validPassword), + () -> assertThat(user.getPassword().getValue()).isEqualTo("ENCODED_Test1234!@#"), () -> assertThat(user.getName()).isEqualTo(validName), () -> assertThat(user.getBirthDate()).isEqualTo(validBirthDate), () -> assertThat(user.getEmail()).isEqualTo(validEmail) @@ -53,37 +53,45 @@ void createUserModel_whenAllDataProvided() { @DisplayName("로그인 ID가 누락되면 예외가 발생한다.") @Test void createUserModel_whenLoginIdIsNull() { - assertThatThrownBy(() -> new UserModel(null, validPassword, validName, validBirthDate, validEmail)) + assertThatThrownBy(() -> new UserModel(null, validRawPassword, encoder, validName, validBirthDate, validEmail)) .isInstanceOf(CoreException.class); } @DisplayName("비밀번호가 누락되면 예외가 발생한다.") @Test void createUserModel_whenPasswordIsNull() { - assertThatThrownBy(() -> new UserModel(validLoginId, null, validName, validBirthDate, validEmail)) + assertThatThrownBy(() -> new UserModel(validLoginId, null, encoder, validName, validBirthDate, validEmail)) .isInstanceOf(CoreException.class); } @DisplayName("이름이 누락되면 예외가 발생한다.") @Test void createUserModel_whenNameIsNull() { - assertThatThrownBy(() -> new UserModel(validLoginId, validPassword, null, validBirthDate, validEmail)) + assertThatThrownBy(() -> new UserModel(validLoginId, validRawPassword, encoder, null, validBirthDate, validEmail)) .isInstanceOf(CoreException.class); } @DisplayName("생년월일이 누락되면 예외가 발생한다.") @Test void createUserModel_whenBirthDateIsNull() { - assertThatThrownBy(() -> new UserModel(validLoginId, validPassword, validName, null, validEmail)) + assertThatThrownBy(() -> new UserModel(validLoginId, validRawPassword, encoder, validName, null, validEmail)) .isInstanceOf(CoreException.class); } @DisplayName("이메일이 누락되면 예외가 발생한다.") @Test void createUserModel_whenEmailIsNull() { - assertThatThrownBy(() -> new UserModel(validLoginId, validPassword, validName, validBirthDate, null)) + assertThatThrownBy(() -> new UserModel(validLoginId, validRawPassword, encoder, validName, validBirthDate, null)) .isInstanceOf(CoreException.class); } + + @DisplayName("비밀번호에 생년월일이 포함되면 예외가 발생한다.") + @Test + void createUserModel_whenPasswordContainsBirthDate() { + assertThatThrownBy(() -> new UserModel(validLoginId, "Pw19900115!", encoder, validName, validBirthDate, validEmail)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("생년월일은 비밀번호 내에 포함될 수 없습니다."); + } } @DisplayName("비밀번호를 변경할 때, ") @@ -94,7 +102,7 @@ class ChangePassword { @Test void changePassword_success() { // arrange - UserModel user = new UserModel(validLoginId, validPassword, validName, validBirthDate, validEmail); + UserModel user = new UserModel(validLoginId, validRawPassword, encoder, validName, validBirthDate, validEmail); // act user.changePassword("Test1234!@#", "NewPass123!@", encoder); @@ -107,7 +115,7 @@ void changePassword_success() { @Test void changePassword_whenCurrentPasswordNotMatch() { // arrange - UserModel user = new UserModel(validLoginId, validPassword, validName, validBirthDate, validEmail); + UserModel user = new UserModel(validLoginId, validRawPassword, encoder, validName, validBirthDate, validEmail); // act & assert assertThatThrownBy(() -> user.changePassword("Wrong123!@#", "NewPass123!@", encoder)) @@ -119,7 +127,7 @@ void changePassword_whenCurrentPasswordNotMatch() { @Test void changePassword_whenNewPasswordSameAsCurrent() { // arrange - UserModel user = new UserModel(validLoginId, validPassword, validName, validBirthDate, validEmail); + UserModel user = new UserModel(validLoginId, validRawPassword, encoder, validName, validBirthDate, validEmail); // act & assert assertThatThrownBy(() -> user.changePassword("Test1234!@#", "Test1234!@#", encoder)) @@ -131,7 +139,7 @@ void changePassword_whenNewPasswordSameAsCurrent() { @Test void changePassword_whenNewPasswordContainsBirthDate() { // arrange - UserModel user = new UserModel(validLoginId, validPassword, validName, validBirthDate, validEmail); + UserModel user = new UserModel(validLoginId, validRawPassword, encoder, validName, validBirthDate, validEmail); // act & assert assertThatThrownBy(() -> user.changePassword("Test1234!@#", "Pw19900115!", encoder)) diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceMockTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceMockTest.java index 191893a79..da47f1a35 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceMockTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceMockTest.java @@ -134,9 +134,14 @@ class ChangePassword { // --- 헬퍼 --- private UserModel createTestUser(String encodedPassword) { + PasswordEncoder fixedEncoder = new PasswordEncoder() { + @Override public String encode(String rawPassword) { return encodedPassword; } + @Override public boolean matches(String rawPassword, String encoded) { return false; } + }; return new UserModel( new LoginId(loginId), - EncryptedPassword.fromEncoded(encodedPassword), + rawPassword, + fixedEncoder, new Name(name), new BirthDate(java.time.LocalDate.of(1990, 1, 15)), new Email(email) diff --git a/docs/round2/password-design-decisions.md b/docs/round2/password-design-decisions.md new file mode 100644 index 000000000..3deda8e83 --- /dev/null +++ b/docs/round2/password-design-decisions.md @@ -0,0 +1,106 @@ +# 패스워드 설계 결정 기록 + +## 1. Password → EncryptedPassword 리네이밍 + +### 결정 +`Password` VO를 `EncryptedPassword`로 변경 + +### 이유 +- Password라는 이름은 원문(raw)인지 암호화된 값인지 모호함 +- 이 객체는 **항상 암호화된 값만** 보관하므로, 이름이 그 사실을 표현해야 함 +- `EncryptedPassword`라는 이름 자체가 "원문이 들어올 수 없다"는 불변식을 전달 + +--- + +## 2. PasswordEncoder를 도메인 인터페이스로 분리 (DIP) + +### 결정 +`PasswordEncoder` 인터페이스를 `com.loopers.domain` 패키지에 정의하고, 구현체(`BCryptPasswordEncoderImpl`)는 `infrastructure`에 배치 + +### 이유 +- 도메인 객체가 암호화 기능을 사용하되, 구체적 알고리즘(BCrypt)에는 의존하지 않아야 함 +- 테스트 시 `FakePasswordEncoder`로 대체 가능 → 단위 테스트에서 Spring 컨텍스트 불필요 +- DIP(의존성 역전 원칙) 적용: 도메인이 인터페이스를 소유하고, 인프라가 구현 + +--- + +## 3. EncryptedPassword에 PasswordEncoder를 생성자 주입하지 않는 이유 + +### 결정 +`PasswordEncoder`를 `EncryptedPassword`의 필드가 아닌 **메서드 파라미터**로 전달 + +### 이유 +- EncryptedPassword는 `@Embeddable` (JPA 값 객체) +- JPA가 DB에서 로딩할 때 `protected EncryptedPassword() {}`로 생성 → PasswordEncoder 주입 불가 +- DB에서 로딩된 객체에 encoder가 null이면 `matches()` 호출 시 NPE +- 값 객체는 인프라 의존성(상태)을 갖지 않는 것이 원칙 +- 메서드 파라미터는 **행위 협력**이지 **상태 의존**이 아님 → VO 불변성 유지 + +--- + +## 4. EncryptedPassword.of()를 하나로 통일 + +### 변경 전 +```java +of(String rawPassword, PasswordEncoder encoder) // birthDate 검증 없음 +of(String rawPassword, PasswordEncoder encoder, BirthDate birthDate) // birthDate 검증 있음 +``` + +### 변경 후 +```java +of(String rawPassword, PasswordEncoder encoder) // 형식 검증 + 암호화만 +``` + +### 이유 +- "비밀번호에 생년월일 포함 불가"는 **cross-field validation** (password + birthDate 두 값 필요) +- EncryptedPassword는 비밀번호 하나만 표현하는 값 객체 → 다른 값 객체(BirthDate)를 알 필요 없음 +- 테스트를 위해 오버로드를 만드는 것은 설계 신호: **더 작은 단위로 쪼개지 못한 것** +- EncryptedPassword의 책임: 형식 검증 + 암호화 (자기 자신의 관심사만) + +--- + +## 5. 생년월일-비밀번호 교차 검증을 UserModel 생성자로 이동 + +### 변경 전 +- 생성 시: `EncryptedPassword.of(raw, encoder, birthDate)` — VO에서 검증 +- 변경 시: `UserModel.changePassword()` — 엔티티에서 검증 +- **같은 규칙인데 검증 위치가 달랐음** + +### 변경 후 +- 생성 시: `UserModel` 생성자에서 검증 +- 변경 시: `UserModel.changePassword()`에서 검증 +- **두 경로 모두 UserModel이 검증 → 일관성 확보** + +### UserModel 생성자 +```java +public UserModel(LoginId loginId, String rawPassword, PasswordEncoder encoder, + Name name, BirthDate birthDate, Email email) { + // null 검증 + validateBirthDateNotInPassword(rawPassword, birthDate); + this.password = EncryptedPassword.of(rawPassword, encoder); + // ... +} +``` + +### 이유 +- UserModel은 password와 birthDate **두 값을 모두 아는 aggregate** +- cross-field 검증은 두 값을 모두 아는 곳이 담당해야 함 +- `changePassword()`가 이미 같은 패턴(rawPassword + encoder를 받아 내부 검증)을 사용 → 생성자도 동일하게 맞추면 일관성 완성 +- **불변식 보장**: birthDate 포함 비밀번호를 가진 UserModel은 존재 자체가 불가능 + +### DDD 관점에서 문제 없는 이유 +- 생성자가 받는 `PasswordEncoder`는 `com.loopers.domain`의 **도메인 인터페이스** +- 인프라 구현체(BCrypt)가 아닌 추상에 의존 → DIP 적용 상태 +- `changePassword()`도 이미 같은 방식으로 encoder를 받고 있으므로 새로운 의존이 아님 + +--- + +## 최종 책임 분배 + +| 검증/연산 | 위치 | 이유 | +|-----------|------|------| +| 비밀번호 형식 검증 (8~16자, 대소문자, 숫자, 특수문자) | `EncryptedPassword.of()` | 비밀번호 자체의 관심사 | +| 비밀번호에 생년월일 포함 불가 | `UserModel` 생성자 / `changePassword()` | cross-field validation, aggregate가 담당 | +| 암호화 (encode) | `EncryptedPassword.of()` | 형식 검증과 암호화는 원자적으로 수행 | +| 매칭 (matches) | `EncryptedPassword.matches()` | 자기 자신의 값과 비교 | +| 동일 비밀번호 거부 | `UserModel.changePassword()` | 변경 시 비즈니스 규칙 | From 32f687c0900e277115ccf9701f875d8bc8af5ba7 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Thu, 12 Feb 2026 02:38:56 +0900 Subject: [PATCH 005/108] =?UTF-8?q?docs:=20=EA=B8=B0=EB=8A=A5=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20=EC=82=AC=ED=95=AD=20=EB=B0=8F=20=EC=9C=A0=EB=B9=84?= =?UTF-8?q?=EC=BF=BC=ED=84=B0=EC=8A=A4=20=EC=96=B8=EC=96=B4=20=EC=A0=95?= =?UTF-8?q?=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +- docs/design/01-requirements.md | 795 +++++++++++++++++++++++ docs/round2/password-design-decisions.md | 106 --- 3 files changed, 797 insertions(+), 107 deletions(-) create mode 100644 docs/design/01-requirements.md delete mode 100644 docs/round2/password-design-decisions.md diff --git a/.gitignore b/.gitignore index 88ce09aad..69f7b39d6 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,5 @@ out/ .claude/ ### Documentation ### -docs/ +docs/* +!docs/design/ diff --git a/docs/design/01-requirements.md b/docs/design/01-requirements.md new file mode 100644 index 000000000..4457ef3ab --- /dev/null +++ b/docs/design/01-requirements.md @@ -0,0 +1,795 @@ + +--- + +## 0. 요구사항 분석 과정에서 던진 질문들 + +요구사항을 명확히 하기 위해 개발자(나)가 던진 질문과 결정 내용을 정리합니다. + +### 장바구니 (Cart) + +| # | 질문 | 결정 | +|---|------|------| +| 1 | 장바구니 상품의 유효성 체크를 언제 할 것인가? (조회 시 실시간 vs 이벤트로 즉시 정리) | **조회 시 실시간 체크** — 장바구니 테이블은 단순 유지, 조회 시 상품/브랜드 상태를 확인하여 필터링 | +| 2 | 장바구니 담기 시 재고를 확인하는가? | **담을 때는 확인 안 함** — 주문 시점에만 확인. 장바구니는 "보관함" 성격 | +| 3 | 장바구니 수량 상한을 두는가? | **최대 100종류, 상품당 최대 99개** — 페이지네이션 적용 | +| 4 | 장바구니에 수량 '변경' 기능이 필요한가? (담기/제거만 vs 수량 직접 변경) | **수량 직접 변경 API 추가** — PUT /api/v1/carts/{cartItemId}로 원하는 수량 직접 지정 | +| 5 | 장바구니에 표시할 가격은 어떤 시점 기준인가? | **항상 현재 가격** — 장바구니에 가격 저장 안 함. 주문 시점에 스냅샷으로 확정 | +| 6 | 수량 변경 API에서 수량 0으로 변경하면 삭제 처리할 것인가? | **수량 0은 불가, 최소 1** — 제거는 DELETE API로만 가능. 역할 명확히 분리 | +| 7 | 장바구니 담기 시 quantity 기본값은? | **필수값 (기본값 없음)** — 클라이언트가 명시적으로 전달 | +| 8 | 장바구니에서 바로 주문으로 전환하는 기능이 필요한가? | **클라이언트 레벨 전환** — Cart→Order 서버 의존성 없이, 클라이언트가 장바구니 조회 후 기존 주문 API에 직접 요청 | + +### 브랜드 관심/좋아요 + +| # | 질문 | 결정 | +|---|------|------| +| 9 | 브랜드 좋아요(관심) 기능을 추가할 것인가? | **제외** — 처음엔 추가하려 했으나, 분석 과정에서 현 단계에서는 불필요하다고 판단하여 제외 | + +### 삭제 전략 + +| # | 질문 | 결정 | +|---|------|------| +| 10 | 브랜드/상품 삭제 방식은? (Hard Delete vs Soft Delete) | **Soft Delete** — deleted_at 컬럼. 복구 가능성, 조회 시 필터링 방식과 일관성 | +| 11 | 브랜드 삭제 시 연관 데이터(장바구니, 좋아요) 처리는? | **조회 시 필터링** — 브랜드 soft delete → 상품 soft delete → 장바구니/좋아요는 조회 시 필터링. 트랜잭션 비대화 방지 | + +### 좋아요 (ProductLike) + +| # | 질문 | 결정 | +|---|------|------| +| 12 | 좋아요 수(카운트)를 어떻게 관리할 것인가? | **조회 시 COUNT 쿼리** — 별도 likeCount 컬럼 없이, likes 테이블 COUNT로 산출. 정합성 100% | +| 13 | 좋아요 API를 토글 방식으로 할 것인가, POST/DELETE 분리로 할 것인가? | **POST/DELETE 분리** — 미션 API 스펙 준수, RESTful 원칙 부합 | +| 14 | 이미 좋아요한 상품에 다시 POST 요청 시 어떻게 처리할 것인가? (멱등성) | **409 Conflict 반환** — 중복 등록은 명시적 오류 | + +### 상품 (Product) + +| # | 질문 | 결정 | +|---|------|------| +| 15 | 상품 재고(stock)를 어떻게 관리할 것인가? | **Product에 stock 필드 포함** — 별도 Stock 도메인 분리 없이 단순하게 관리 | + +### 문서 작성 + +| # | 질문 | 결정 | +|---|------|------| +| 16 | 설계 문서 저장 위치는? (.claude/ vs docs/design/) | **docs/design/** — 과제 제출 요구사항 기준 | +| 17 | 유비쿼터스 언어(도메인 용어집)를 포함할 것인가? | **포함** — 리뷰어에게 좋은 인상 + 도메인 소통 기준 | +| 18 | 설계 결정 근거를 포함할 것인가? | **포함** — "왜 이렇게 판단했는가"가 드러나는 문서 | + +--- +--- + +## 1. 도메인 용어집 (Ubiquitous Language) + +| 한글 | 영문 | 설명 | +|------|------|------| +| 회원 | User (Member) | 서비스에 가입한 사용자. 1주차에 구현 완료 (본 설계 범위 제외) | +| 브랜드 | Brand | 상품을 판매하는 브랜드. Admin이 등록/관리 | +| 상품 | Product | 브랜드에 속한 판매 상품. 재고(stock) 포함 | +| 재고 | stock | 상품의 현재 판매 가능 수량. Product의 필드로 관리 | +| 품절 | Sold Out | 상품 재고(stock)가 0인 상태 | +| 좋아요 | ProductLike | 회원이 상품에 대해 표현하는 선호. 회원당 상품당 1개. 별도 테이블로 관리 | +| 장바구니 | Cart | 회원이 구매 전 상품을 담아두는 보관함 | +| 장바구니 항목 | CartItem | 장바구니에 담긴 개별 상품과 수량 | +| 주문 | Order | 회원이 상품을 구매하기 위한 요청 | +| 주문 항목 | OrderItem | 주문에 포함된 개별 상품의 스냅샷 (주문 시점 가격/이름 등) | +| 스냅샷 | Snapshot | 주문 시점의 상품 정보를 복사하여 저장하는 것 | +| Soft Delete | - | deleted_at 컬럼으로 논리 삭제. 물리적으로는 데이터 유지 | +| Admin | Admin | LDAP 인증 기반 사내 관리자 | + +--- + +## 2. 설계 범위 + +### 포함 도메인 +- 브랜드 (Brand) +- 상품 (Product) +- 좋아요 (ProductLike) +- 장바구니 (Cart) -- **추가 기능** +- 주문 (Order) + +### 제외 도메인 +- 회원 (User): 1주차에 구현 완료 +- 결제 (Payment): 추후 개발 예정 +- 쿠폰 (Coupon): 추후 개발 예정 + +--- + +## 3. 액터 및 API 식별 규칙 + +### 액터 + +| 액터 | 설명 | 식별 방식 | +|------|------|----------| +| 비회원 (Guest) | 로그인하지 않은 사용자 | 헤더 없음 | +| 회원 (User) | 로그인한 사용자 | `X-Loopers-LoginId`, `X-Loopers-LoginPw` | +| 관리자 (Admin) | 사내 관리자 | `X-Loopers-Ldap: loopers.admin` | + +### API Prefix 규칙 +- 대고객 API: `/api/v1` +- 어드민 API: `/api-admin/v1` + +### 인증/인가 +- 인증/인가 로직은 구현하지 않음 (주요 스코프 아님) +- 헤더 기반으로 사용자를 식별만 함 +- 회원은 타 회원의 정보에 직접 접근할 수 없음 + +--- + +## 4. 도메인별 요구사항 + +--- + +### 4.1 브랜드 (Brand) + +#### 유저 스토리 + +> **비회원으로서**, 브랜드 정보를 조회할 수 있다. 로그인 없이도 브랜드를 탐색하고 싶다. +> +> **관리자로서**, 브랜드를 등록/수정/삭제하여 서비스에 입점할 브랜드를 관리할 수 있다. + +#### 기능 목록 + +| 기능 | 액터 | Method | URI | 인증 | +|------|------|--------|-----|------| +| 브랜드 정보 조회 | 비회원/회원 | GET | `/api/v1/brands/{brandId}` | X | +| 브랜드 목록 조회 | Admin | GET | `/api-admin/v1/brands?page=0&size=20` | LDAP | +| 브랜드 상세 조회 | Admin | GET | `/api-admin/v1/brands/{brandId}` | LDAP | +| 브랜드 등록 | Admin | POST | `/api-admin/v1/brands` | LDAP | +| 브랜드 정보 수정 | Admin | PUT | `/api-admin/v1/brands/{brandId}` | LDAP | +| 브랜드 삭제 | Admin | DELETE | `/api-admin/v1/brands/{brandId}` | LDAP | + +> **고객 vs Admin 응답 차이**: 고객에게는 브랜드명, 설명 등 기본 정보만 제공. +> Admin에게는 등록일, 수정일, 삭제 여부, 소속 상품 수 등 관리 정보도 추가로 제공. + +#### 유스케이스 흐름 + +**UC-B01: 브랜드 정보 조회 (비회원)** + +``` +[유저 스토리] +- 비회원이 특정 브랜드의 정보를 확인할 수 있다. + +[기능 흐름] +1. 비회원이 brandId로 브랜드 정보를 요청한다 +2. 해당 브랜드가 존재하는지 확인한다 +3. 브랜드 기본 정보를 반환한다 + +[예외] +- brandId에 해당하는 브랜드가 없으면 404 반환 +- soft delete된 브랜드는 조회 불가 (404 반환) +``` + +**UC-B02: 브랜드 등록 (Admin)** + +``` +[유저 스토리] +- Admin이 새로운 브랜드를 서비스에 등록할 수 있다. + +[기능 흐름] +1. Admin이 브랜드 정보(이름 등)를 입력한다 +2. 동일한 브랜드명이 이미 존재하는지 확인한다 +3. 브랜드를 저장한다 +4. 생성된 브랜드 정보를 반환한다 + +[예외] +- 이미 존재하는 브랜드명이면 등록 실패 (409 Conflict) + +[조건] +- 브랜드명은 필수값이며 중복 불가 +``` + +**UC-B03: 브랜드 정보 수정 (Admin)** + +``` +[유저 스토리] +- Admin이 브랜드의 정보를 수정할 수 있다. + +[기능 흐름] +1. Admin이 brandId와 수정할 정보를 요청한다 +2. 해당 브랜드가 존재하는지 확인한다 +3. 브랜드 정보를 업데이트한다 + +[예외] +- brandId에 해당하는 브랜드가 없거나 삭제된 경우 404 반환 +- 수정하려는 브랜드명이 다른 브랜드와 중복되면 409 Conflict +``` + +**UC-B04: 브랜드 삭제 (Admin)** + +``` +[유저 스토리] +- Admin이 브랜드를 삭제하면, 해당 브랜드의 상품들도 함께 삭제된다. + +[기능 흐름] +1. Admin이 brandId로 삭제를 요청한다 +2. 해당 브랜드가 존재하는지 확인한다 +3. 해당 브랜드를 soft delete 한다 +4. 해당 브랜드의 모든 상품도 soft delete 한다 + +[예외] +- brandId에 해당하는 브랜드가 없으면 404 반환 +- 이미 삭제된 브랜드이면 404 반환 + +[후속 동작] +- 장바구니/좋아요는 즉시 삭제하지 않음 +- 조회 시점에 상품/브랜드의 deleted_at을 체크하여 필터링 +``` + +> **연쇄 처리 범위**: +> ``` +> 브랜드 soft delete +> └→ 해당 브랜드의 상품 전체 soft delete +> └→ 장바구니 항목: 조회 시 필터링 +> └→ 좋아요: 조회 시 필터링 +> ``` + +--- + +### 4.2 상품 (Product) + +#### 유저 스토리 + +> **비회원으로서**, 상품 목록을 둘러보고 상세 정보를 확인할 수 있다. +> +> **관리자로서**, 상품을 등록/수정/삭제하여 판매 상품을 관리할 수 있다. +> 상품 등록 시 재고(stock)를 설정하고, 수정 시 재고를 변경할 수 있다. + +#### 기능 목록 + +| 기능 | 액터 | Method | URI | 인증 | +|------|------|--------|-----|------| +| 상품 목록 조회 | 비회원/회원 | GET | `/api/v1/products` | X | +| 상품 정보 조회 | 비회원/회원 | GET | `/api/v1/products/{productId}` | X | +| 상품 목록 조회 | Admin | GET | `/api-admin/v1/products?page=0&size=20&brandId={brandId}` | LDAP | +| 상품 상세 조회 | Admin | GET | `/api-admin/v1/products/{productId}` | LDAP | +| 상품 등록 | Admin | POST | `/api-admin/v1/products` | LDAP | +| 상품 정보 수정 | Admin | PUT | `/api-admin/v1/products/{productId}` | LDAP | +| 상품 삭제 | Admin | DELETE | `/api-admin/v1/products/{productId}` | LDAP | + +#### 상품 목록 조회 쿼리 파라미터 + +| 파라미터 | 설명 | 기본값 | +|----------|------|--------| +| `brandId` | 특정 브랜드 상품 필터링 | - (선택) | +| `sort` | 정렬 기준: `latest` / `price_asc` / `likes_desc` | `latest` | +| `page` | 페이지 번호 | 0 | +| `size` | 페이지당 상품 수 | 20 | + +> `sort`는 `latest` 필수, `price_asc` / `likes_desc`는 선택 구현. +> `likes_desc` 정렬 시 좋아요 수는 likes 테이블 COUNT로 산출. + +#### 유스케이스 흐름 + +**UC-P01: 상품 목록 조회 (비회원)** + +``` +[유저 스토리] +- 비회원이 상품 목록을 둘러볼 수 있다. +- 브랜드별 필터링, 정렬, 페이지네이션을 지원한다. + +[기능 흐름] +1. 비회원이 상품 목록을 요청한다 (선택: brandId, sort, page, size) +2. soft delete된 상품/브랜드를 제외한다 +3. 정렬 조건에 맞게 정렬한다 +4. 페이지네이션하여 상품 목록을 반환한다 +5. 각 상품의 좋아요 수를 likes 테이블 COUNT로 함께 반환한다 + +[대안 흐름] +- brandId가 없으면 전체 상품 조회 +- sort가 없으면 latest(최신순) 기본 적용 + +[조건] +- soft delete된 상품은 목록에서 제외 +- soft delete된 브랜드의 상품도 목록에서 제외 +``` + +**UC-P02: 상품 정보 조회 (비회원)** + +``` +[유저 스토리] +- 비회원이 특정 상품의 상세 정보를 확인할 수 있다. + +[기능 흐름] +1. 비회원이 productId로 상품 정보를 요청한다 +2. 해당 상품이 존재하는지 확인한다 +3. 상품 정보와 함께 좋아요 수(COUNT)를 반환한다 + +[예외] +- productId에 해당하는 상품이 없거나 삭제된 경우 404 반환 + +[조건] +- 좋아요 수는 likes 테이블에서 해당 상품의 COUNT 쿼리로 산출 +``` + +**UC-P03: 상품 등록 (Admin)** + +``` +[유저 스토리] +- Admin이 특정 브랜드에 새 상품을 등록할 수 있다. + +[기능 흐름] +1. Admin이 상품 정보를 입력한다 (brandId, 상품명, 가격, 재고 등) +2. brandId에 해당하는 브랜드가 존재하는지 확인한다 +3. 상품을 저장한다 +4. 생성된 상품 정보를 반환한다 + +[예외] +- brandId에 해당하는 브랜드가 없거나 삭제된 경우 등록 실패 + +[조건] +- 상품의 브랜드는 반드시 이미 등록된(삭제되지 않은) 브랜드여야 함 +- 재고(stock)는 상품 등록 시 초기값 설정 (0 이상) +``` + +**UC-P04: 상품 정보 수정 (Admin)** + +``` +[유저 스토리] +- Admin이 상품의 정보(이름, 가격, 재고 등)를 수정할 수 있다. +- 단, 상품이 속한 브랜드는 변경할 수 없다. + +[기능 흐름] +1. Admin이 productId와 수정할 정보를 요청한다 +2. 해당 상품이 존재하는지 확인한다 +3. 상품 정보를 업데이트한다 + +[예외] +- productId에 해당하는 상품이 없거나 삭제된 경우 404 반환 + +[조건] +- 상품의 브랜드(brandId)는 수정할 수 없음 +- 재고(stock) 수정 가능 +``` + +**UC-P05: 상품 삭제 (Admin)** + +``` +[유저 스토리] +- Admin이 상품을 삭제할 수 있다. + +[기능 흐름] +1. Admin이 productId로 삭제를 요청한다 +2. 해당 상품이 존재하는지 확인한다 +3. 해당 상품을 soft delete 한다 + +[예외] +- productId에 해당하는 상품이 없거나 이미 삭제된 경우 404 반환 + +[후속 동작] +- 장바구니/좋아요는 즉시 삭제하지 않음 +- 조회 시점에 필터링으로 처리 +``` + +--- + +### 4.3 좋아요 (ProductLike) + +#### 유저 스토리 + +> **회원으로서**, 마음에 드는 상품에 좋아요를 눌러 선호를 표현하고, 나중에 다시 찾아볼 수 있다. +> 이미 좋아요한 상품은 취소할 수 있다. + +#### 기능 목록 + +| 기능 | 액터 | Method | URI | 인증 | +|------|------|--------|-----|------| +| 상품 좋아요 등록 | 회원 | POST | `/api/v1/products/{productId}/likes` | O | +| 상품 좋아요 취소 | 회원 | DELETE | `/api/v1/products/{productId}/likes` | O | +| 내가 좋아요한 상품 목록 조회 | 회원 | GET | `/api/v1/users/{userId}/likes` | O | + +> **좋아요 수 관리**: 별도 likeCount 컬럼 없이, 조회 시 likes 테이블 COUNT 쿼리로 산출. +> 상품 조회/목록 응답에 좋아요 수가 포함됨. + +#### 유스케이스 흐름 + +**UC-L01: 상품 좋아요 등록** + +``` +[유저 스토리] +- 회원이 마음에 드는 상품에 좋아요를 누를 수 있다. +- 회원당 하나의 상품에 좋아요는 1번만 가능하다. + +[기능 흐름] +1. 회원이 productId로 좋아요를 요청한다 +2. 해당 상품이 존재하는지 확인한다 (삭제된 상품 불가) +3. 이미 좋아요한 상태인지 확인한다 +4. 좋아요를 저장한다 (likes 테이블에 userId + productId) + +[예외] +- productId에 해당하는 상품이 없거나 삭제된 경우 404 반환 +- 이미 좋아요한 상품이면 409 Conflict 반환 + +[조건] +- 로그인한 회원만 가능 +- 회원당 상품당 1개만 저장 (유니크 제약) +``` + +**UC-L02: 상품 좋아요 취소** + +``` +[유저 스토리] +- 회원이 좋아요한 상품의 좋아요를 취소할 수 있다. + +[기능 흐름] +1. 회원이 productId로 좋아요 취소를 요청한다 +2. 해당 좋아요 기록이 존재하는지 확인한다 +3. 좋아요 기록을 삭제한다 + +[예외] +- 좋아요하지 않은 상품의 취소 요청 시 404 반환 + +[조건] +- 로그인한 회원만 가능 +- 본인의 좋아요만 취소 가능 +``` + +**UC-L03: 내가 좋아요한 상품 목록 조회** + +``` +[유저 스토리] +- 회원이 자신이 좋아요 누른 상품 목록을 확인할 수 있다. + +[기능 흐름] +1. 회원이 자신의 좋아요 목록을 요청한다 +2. likes 테이블에서 해당 회원의 좋아요 목록을 조회한다 +3. 상품/브랜드가 삭제되지 않은 항목만 필터링한다 +4. 상품 정보와 함께 반환한다 + +[조건] +- 로그인한 회원만 가능 +- soft delete된 상품/브랜드는 목록에서 제외 (조회 시 필터링) +- 본인의 좋아요 목록만 조회 가능 (타 유저 접근 불가) +``` + +--- + +### 4.4 장바구니 (Cart) -- 추가 기능 + +#### 유저 스토리 + +> **회원으로서**, 구매하고 싶은 상품을 장바구니에 담아두고 나중에 한 번에 확인할 수 있다. +> 담은 상품의 수량을 변경하거나 제거할 수 있다. +> 장바구니에 품절 상품이 있으면 품절 상태로 보여주고, 삭제된 상품/브랜드는 자동으로 걸러진다. + +#### 기능 목록 + +| 기능 | 액터 | Method | URI | 인증 | +|------|------|--------|-----|------| +| 장바구니에 상품 담기 | 회원 | POST | `/api/v1/carts` | O | +| 장바구니 목록 조회 | 회원 | GET | `/api/v1/carts?page=0&size=20` | O | +| 장바구니 수량 변경 | 회원 | PUT | `/api/v1/carts/{cartItemId}` | O | +| 장바구니 항목 제거 | 회원 | DELETE | `/api/v1/carts/{cartItemId}` | O | + +#### 유스케이스 흐름 + +**UC-C01: 장바구니에 상품 담기** + +``` +[유저 스토리] +- 회원이 상품을 장바구니에 담을 수 있다. +- 같은 상품을 다시 담으면 수량이 합산된다. + +[기능 흐름] +1. 회원이 productId와 quantity(필수)로 담기를 요청한다 +2. 해당 상품이 존재하는지 확인한다 (삭제된 상품 불가) +3. 장바구니에 같은 상품이 이미 있는지 확인한다 +4-a. 없으면: 새 CartItem을 저장한다 +4-b. 있으면: 기존 수량에 요청 수량을 합산한다 + +[예외] +- productId에 해당하는 상품이 없거나 삭제된 경우 실패 +- 합산 후 수량이 99를 초과하면 실패 +- 장바구니에 이미 100종류가 담겨있으면 신규 상품 담기 실패 + +[조건] +- quantity는 필수값 (기본값 없음), 1 이상 +- 담을 때 재고는 확인하지 않음 (주문 시점에 확인) +- 가격은 저장하지 않음 (조회 시 현재 가격 사용) +- 로그인한 회원만 가능 +``` + +**UC-C02: 장바구니 목록 조회** + +``` +[유저 스토리] +- 회원이 자신의 장바구니를 확인할 수 있다. +- 품절 상품은 품절로 표시되고, 삭제된 상품은 자동으로 걸러진다. + +[기능 흐름] +1. 회원이 장바구니 목록을 요청한다 (page, size) +2. 해당 회원의 장바구니 항목을 조회한다 +3. 각 항목의 상품/브랜드가 삭제되었는지 확인한다 +4. 삭제된 상품/브랜드의 항목은 목록에서 제외한다 +5. 품절(stock=0) 상품은 품절 상태를 표시한다 +6. 상품의 현재 정보(이름, 가격, 재고 상태)와 함께 반환한다 + +[조건] +- 가격은 항상 현재 상품 가격 기준 (장바구니에 가격 저장 안 함) +- 페이지네이션 적용 (장바구니 최대 100종류) +- 본인의 장바구니만 조회 가능 +- 로그인한 회원만 가능 +``` + +**UC-C03: 장바구니 수량 변경** + +``` +[유저 스토리] +- 회원이 장바구니에 담긴 상품의 수량을 변경할 수 있다. + +[기능 흐름] +1. 회원이 cartItemId와 변경할 quantity를 요청한다 +2. 해당 장바구니 항목이 존재하는지 확인한다 +3. 본인의 장바구니 항목인지 확인한다 +4. 수량을 업데이트한다 + +[예외] +- cartItemId에 해당하는 항목이 없으면 404 반환 +- 수량이 1 미만이면 실패 (최소 1) +- 수량이 99 초과이면 실패 (최대 99) + +[조건] +- 수량 0으로 변경 불가 → 제거는 DELETE API로만 가능 +- 본인의 장바구니 항목만 수정 가능 +- 로그인한 회원만 가능 +``` + +**UC-C04: 장바구니 항목 제거** + +``` +[유저 스토리] +- 회원이 장바구니에서 상품을 제거할 수 있다. + +[기능 흐름] +1. 회원이 cartItemId로 제거를 요청한다 +2. 해당 장바구니 항목이 존재하는지 확인한다 +3. 본인의 장바구니 항목인지 확인한다 +4. 해당 항목을 삭제한다 + +[예외] +- cartItemId에 해당하는 항목이 없으면 404 반환 + +[조건] +- 본인의 장바구니 항목만 제거 가능 +- 로그인한 회원만 가능 +``` + +#### 장바구니 → 주문 관계 + +장바구니와 주문은 **서버 도메인 간 독립적**이다. + +``` +[클라이언트 레벨 전환 흐름] +1. 클라이언트가 GET /api/v1/carts 로 장바구니 조회 +2. 사용자가 주문할 상품을 선택 +3. 클라이언트가 POST /api/v1/orders 에 items를 직접 조립하여 요청 +4. 주문 성공 후 클라이언트가 DELETE /api/v1/carts/{cartItemId} 로 정리 +``` + +- 서버에서 Cart 도메인과 Order 도메인은 서로 참조하지 않음 +- 별도의 "장바구니에서 주문" API 없음 + +--- + +### 4.5 주문 (Order) + +#### 유저 스토리 + +> **회원으로서**, 여러 상품을 한 번에 주문할 수 있다. +> 주문 시 상품 재고가 확인되고 차감된다. +> 주문 후에도 당시 상품 정보(가격, 이름 등)를 확인할 수 있다. +> +> **관리자로서**, 전체 주문 내역을 조회할 수 있다. + +#### 기능 목록 + +| 기능 | 액터 | Method | URI | 인증 | +|------|------|--------|-----|------| +| 주문 요청 | 회원 | POST | `/api/v1/orders` | O | +| 주문 목록 조회 | 회원 | GET | `/api/v1/orders?startAt={date}&endAt={date}` | O | +| 주문 상세 조회 | 회원 | GET | `/api/v1/orders/{orderId}` | O | +| 주문 목록 조회 | Admin | GET | `/api-admin/v1/orders?page=0&size=20` | LDAP | +| 주문 상세 조회 | Admin | GET | `/api-admin/v1/orders/{orderId}` | LDAP | + +#### 주문 요청 본문 예시 + +```json +{ + "items": [ + { "productId": 1, "quantity": 2 }, + { "productId": 3, "quantity": 1 } + ] +} +``` + +#### 유스케이스 흐름 + +**UC-O01: 주문 요청** + +``` +[유저 스토리] +- 회원이 여러 상품을 한 번에 주문할 수 있다. +- 주문 시 재고가 확인되고 차감된다. +- 주문 정보에는 당시 상품 정보가 스냅샷으로 저장된다. + +[기능 흐름] +1. 회원이 상품 목록(productId, quantity)으로 주문을 요청한다 +2. 각 상품이 존재하는지 확인한다 (삭제된 상품 불가) +3. 각 상품의 재고가 충분한지 확인한다 +4. 재고를 차감한다 (원자적 처리) +5. 주문 시점의 상품 정보를 스냅샷으로 저장한다 (상품명, 가격, 브랜드명 등) +6. 주문을 생성한다 + +[예외] +- 상품이 존재하지 않거나 삭제된 경우 주문 실패 +- 재고가 부족한 상품이 하나라도 있으면 주문 전체 실패 +- items가 비어있으면 주문 실패 + +[조건] +- 로그인한 회원만 가능 +- 재고 확인과 차감은 원자적으로 처리되어야 함 +- 동시성 이슈는 추후 해결 (비관적 락 또는 낙관적 락) +``` + +> **스냅샷의 이유**: 주문 이후 상품 가격이 변경되거나 상품/브랜드가 삭제되어도, +> 주문 내역에는 주문 당시 정보가 그대로 남아야 한다. + +**UC-O02: 주문 목록 조회 (회원)** + +``` +[유저 스토리] +- 회원이 특정 기간의 자신의 주문 내역을 확인할 수 있다. + +[기능 흐름] +1. 회원이 기간(startAt, endAt)을 지정하여 주문 목록을 요청한다 +2. 해당 기간 내 본인의 주문 목록을 반환한다 + +[조건] +- 본인의 주문만 조회 가능 +- startAt, endAt은 필수값 (기간 지정 필수) +``` + +**UC-O03: 주문 상세 조회 (회원)** + +``` +[유저 스토리] +- 회원이 특정 주문의 상세 내역을 확인할 수 있다. +- 주문 당시의 상품 정보(스냅샷)가 표시된다. + +[기능 흐름] +1. 회원이 orderId로 주문 상세를 요청한다 +2. 해당 주문이 존재하는지 확인한다 +3. 본인의 주문인지 확인한다 +4. 주문 정보와 스냅샷된 상품 정보를 반환한다 + +[예외] +- orderId에 해당하는 주문이 없으면 404 반환 +- 본인의 주문이 아니면 접근 불가 + +[조건] +- 본인의 주문만 조회 가능 +- 상품 정보는 스냅샷 기준 (현재 상품 상태와 무관) +``` + +--- + +## 5. 설계 결정 사항 + +이 프로젝트에서 내린 주요 설계 결정과 그 근거를 정리합니다. + +### 5.1 삭제 전략: Soft Delete + +**결정**: 브랜드/상품 삭제 시 `deleted_at` 컬럼을 사용한 논리 삭제 + +**근거**: +- 주문에 스냅샷이 남지만, 관리자가 삭제된 브랜드/상품 이력을 확인할 필요가 있을 수 있음 +- 실수로 삭제한 경우 복구 가능성을 열어둠 +- 장바구니/좋아요 등 연관 데이터를 즉시 삭제하지 않고 **조회 시 필터링**으로 처리하여 트랜잭션 범위를 줄임 + +**트레이드오프**: +- 모든 조회 쿼리에 `deleted_at IS NULL` 조건이 추가됨 +- 데이터가 물리적으로 남아있어 스토리지를 차지함 + +### 5.2 브랜드 삭제 연쇄 처리: 조회 시 필터링 + +**결정**: 브랜드 삭제 시 상품은 함께 soft delete 하되, 장바구니/좋아요는 **조회 시점에 필터링** + +**근거**: +- 브랜드 삭제 트랜잭션이 비대해지는 것을 방지 +- 장바구니/좋아요까지 한 트랜잭션에서 삭제하면 도메인 간 결합도가 높아짐 +- Soft Delete + 조회 시 필터링 방식이 일관된 접근 + +### 5.3 좋아요 수 관리: 조회 시 COUNT 쿼리 + +**결정**: Product에 별도 likeCount 컬럼을 두지 않고, 조회 시 likes 테이블 COUNT 쿼리로 산출 + +**근거**: +- 데이터 정합성 100% (카운트 불일치 문제 없음) +- 구현 단순 (등록/취소 시 카운트 업데이트 로직 불필요) +- 동시성 이슈 없음 + +**트레이드오프**: +- 상품 목록 조회 시 JOIN + COUNT 쿼리 비용 발생 +- 대량 데이터 시 성능 이슈 가능 → 필요 시 likeCount 캐싱 컬럼 도입 고려 + +### 5.4 좋아요 API: POST/DELETE 분리 + +**결정**: 좋아요 등록(POST)과 취소(DELETE)를 별도 API로 분리. 토글 방식 아님. + +**근거**: +- 미션 요구사항 API 스펙 준수 +- RESTful 원칙에 부합 (리소스 생성 = POST, 삭제 = DELETE) +- 클라이언트가 현재 상태를 명확히 알고 적절한 API를 호출 + +**멱등성 처리**: +- 이미 좋아요한 상품에 POST → 409 Conflict +- 좋아요하지 않은 상품에 DELETE → 404 Not Found + +### 5.5 장바구니 가격: 현재 가격 기준 + +**결정**: 장바구니에 가격을 저장하지 않고, 조회 시 항상 현재 상품 가격을 사용 + +**근거**: +- 장바구니는 "보관함" 성격. 가격이 확정되는 시점은 주문 시점 +- 장바구니에 가격을 저장하면 가격 변동 시 동기화 문제 발생 +- 주문에서 스냅샷으로 가격이 확정되므로, 장바구니까지 가격을 관리할 필요 없음 + +### 5.6 장바구니 담기 시 재고 미확인 + +**결정**: 장바구니에 담을 때는 재고를 확인하지 않고, 주문 시점에만 확인 + +**근거**: +- 장바구니는 "위시리스트"에 가까운 성격 +- 담을 때 재고를 확인해도, 주문 시점까지 재고가 변동될 수 있어 의미가 제한적 +- 구현 복잡도를 낮추면서 주문 시점 검증으로 데이터 정합성 보장 + +### 5.7 장바구니와 주문: 서버 도메인 간 독립 + +**결정**: 장바구니에서 주문으로의 전환은 클라이언트 레벨에서 처리. 서버에서 Cart와 Order 도메인은 서로 참조하지 않음 + +**근거**: +- 도메인 간 결합도를 0으로 유지 +- 별도의 전환 API 없이 기존 주문 API 재사용 +- 장바구니 기능이 변경되어도 주문에 영향 없음, 반대도 마찬가지 + +### 5.8 장바구니 제약 조건 + +| 제약 | 값 | 근거 | +|------|-----|------| +| 상품당 최대 수량 | 99개 | 비정상 요청 방어 | +| 장바구니 최대 종류 | 100종 | 합리적 상한 + 페이지네이션 적용 | +| 최소 수량 | 1개 | 수량 0은 불가. 제거는 DELETE API로 명확히 분리 | +| quantity | 필수값 | 기본값 없음. 클라이언트가 명시적으로 전달 | + +### 5.9 상품 재고: Product 필드로 관리 + +**결정**: 재고(stock)를 Product 엔티티의 필드로 관리. 별도 Stock 도메인 분리 없음. + +**근거**: +- 현재 요구사항에서 입고/출고 이력 관리가 필요하지 않음 +- 상품 등록/수정 시 재고 설정, 주문 시 차감만 하면 충분 +- 별도 도메인 분리는 오버 엔지니어링 + +--- + +## 6. 잠재 리스크 + +| 리스크 | 설명 | 대응 방안 | +|--------|------|----------| +| 조회 시 필터링 비용 | Soft Delete + 조회 필터링으로 장바구니/좋아요 조회 시 JOIN과 조건이 증가 | 인덱스 전략으로 대응. 필요시 배치로 고아 데이터 정리 | +| 좋아요 COUNT 쿼리 비용 | 상품 조회 시 매번 likes COUNT 쿼리 발생 | 인덱스 활용. 성능 이슈 시 likeCount 캐싱 컬럼 도입 | +| 주문 시 재고 동시성 | 동시 주문 시 재고 차감의 정합성 문제 | 비관적 락 또는 낙관적 락으로 대응 (추후 동시성 처리 단계에서 해결) | +| 스냅샷 데이터 증가 | 주문마다 상품 정보를 복사하므로 데이터량 증가 | 주문 이력 조회 시 필요한 최소 정보만 스냅샷 | +| 대량 브랜드 삭제 | 상품이 많은 브랜드 삭제 시 soft delete 대상이 다수 | 배치 처리 또는 비동기 처리 고려 | + +--- + +## 7. Checklist + +- [x] 상품/브랜드/좋아요/주문 도메인이 모두 포함되어 있는가? +- [x] 기능 요구사항이 유저 중심으로 정리되어 있는가? +- [x] 추가 기능(장바구니)이 기존 요구사항과 일관되게 정의되어 있는가? +- [x] 각 유스케이스에 Main / Alternate / Exception Flow가 포함되어 있는가? +- [x] 유스케이스 흐름이 구체적인 번호 순서로 작성되어 있는가? +- [x] 예외/조건이 명시되어 있는가? (로그인 여부, 삭제 상태, 수량 제한 등) +- [x] 좋아요 수 반영 방식이 명시되어 있는가? +- [x] 상품 재고(stock) 관리 방식이 명시되어 있는가? +- [x] 설계 결정 근거가 명시되어 있는가? +- [x] 도메인 용어집(유비쿼터스 언어)이 정의되어 있는가? diff --git a/docs/round2/password-design-decisions.md b/docs/round2/password-design-decisions.md deleted file mode 100644 index 3deda8e83..000000000 --- a/docs/round2/password-design-decisions.md +++ /dev/null @@ -1,106 +0,0 @@ -# 패스워드 설계 결정 기록 - -## 1. Password → EncryptedPassword 리네이밍 - -### 결정 -`Password` VO를 `EncryptedPassword`로 변경 - -### 이유 -- Password라는 이름은 원문(raw)인지 암호화된 값인지 모호함 -- 이 객체는 **항상 암호화된 값만** 보관하므로, 이름이 그 사실을 표현해야 함 -- `EncryptedPassword`라는 이름 자체가 "원문이 들어올 수 없다"는 불변식을 전달 - ---- - -## 2. PasswordEncoder를 도메인 인터페이스로 분리 (DIP) - -### 결정 -`PasswordEncoder` 인터페이스를 `com.loopers.domain` 패키지에 정의하고, 구현체(`BCryptPasswordEncoderImpl`)는 `infrastructure`에 배치 - -### 이유 -- 도메인 객체가 암호화 기능을 사용하되, 구체적 알고리즘(BCrypt)에는 의존하지 않아야 함 -- 테스트 시 `FakePasswordEncoder`로 대체 가능 → 단위 테스트에서 Spring 컨텍스트 불필요 -- DIP(의존성 역전 원칙) 적용: 도메인이 인터페이스를 소유하고, 인프라가 구현 - ---- - -## 3. EncryptedPassword에 PasswordEncoder를 생성자 주입하지 않는 이유 - -### 결정 -`PasswordEncoder`를 `EncryptedPassword`의 필드가 아닌 **메서드 파라미터**로 전달 - -### 이유 -- EncryptedPassword는 `@Embeddable` (JPA 값 객체) -- JPA가 DB에서 로딩할 때 `protected EncryptedPassword() {}`로 생성 → PasswordEncoder 주입 불가 -- DB에서 로딩된 객체에 encoder가 null이면 `matches()` 호출 시 NPE -- 값 객체는 인프라 의존성(상태)을 갖지 않는 것이 원칙 -- 메서드 파라미터는 **행위 협력**이지 **상태 의존**이 아님 → VO 불변성 유지 - ---- - -## 4. EncryptedPassword.of()를 하나로 통일 - -### 변경 전 -```java -of(String rawPassword, PasswordEncoder encoder) // birthDate 검증 없음 -of(String rawPassword, PasswordEncoder encoder, BirthDate birthDate) // birthDate 검증 있음 -``` - -### 변경 후 -```java -of(String rawPassword, PasswordEncoder encoder) // 형식 검증 + 암호화만 -``` - -### 이유 -- "비밀번호에 생년월일 포함 불가"는 **cross-field validation** (password + birthDate 두 값 필요) -- EncryptedPassword는 비밀번호 하나만 표현하는 값 객체 → 다른 값 객체(BirthDate)를 알 필요 없음 -- 테스트를 위해 오버로드를 만드는 것은 설계 신호: **더 작은 단위로 쪼개지 못한 것** -- EncryptedPassword의 책임: 형식 검증 + 암호화 (자기 자신의 관심사만) - ---- - -## 5. 생년월일-비밀번호 교차 검증을 UserModel 생성자로 이동 - -### 변경 전 -- 생성 시: `EncryptedPassword.of(raw, encoder, birthDate)` — VO에서 검증 -- 변경 시: `UserModel.changePassword()` — 엔티티에서 검증 -- **같은 규칙인데 검증 위치가 달랐음** - -### 변경 후 -- 생성 시: `UserModel` 생성자에서 검증 -- 변경 시: `UserModel.changePassword()`에서 검증 -- **두 경로 모두 UserModel이 검증 → 일관성 확보** - -### UserModel 생성자 -```java -public UserModel(LoginId loginId, String rawPassword, PasswordEncoder encoder, - Name name, BirthDate birthDate, Email email) { - // null 검증 - validateBirthDateNotInPassword(rawPassword, birthDate); - this.password = EncryptedPassword.of(rawPassword, encoder); - // ... -} -``` - -### 이유 -- UserModel은 password와 birthDate **두 값을 모두 아는 aggregate** -- cross-field 검증은 두 값을 모두 아는 곳이 담당해야 함 -- `changePassword()`가 이미 같은 패턴(rawPassword + encoder를 받아 내부 검증)을 사용 → 생성자도 동일하게 맞추면 일관성 완성 -- **불변식 보장**: birthDate 포함 비밀번호를 가진 UserModel은 존재 자체가 불가능 - -### DDD 관점에서 문제 없는 이유 -- 생성자가 받는 `PasswordEncoder`는 `com.loopers.domain`의 **도메인 인터페이스** -- 인프라 구현체(BCrypt)가 아닌 추상에 의존 → DIP 적용 상태 -- `changePassword()`도 이미 같은 방식으로 encoder를 받고 있으므로 새로운 의존이 아님 - ---- - -## 최종 책임 분배 - -| 검증/연산 | 위치 | 이유 | -|-----------|------|------| -| 비밀번호 형식 검증 (8~16자, 대소문자, 숫자, 특수문자) | `EncryptedPassword.of()` | 비밀번호 자체의 관심사 | -| 비밀번호에 생년월일 포함 불가 | `UserModel` 생성자 / `changePassword()` | cross-field validation, aggregate가 담당 | -| 암호화 (encode) | `EncryptedPassword.of()` | 형식 검증과 암호화는 원자적으로 수행 | -| 매칭 (matches) | `EncryptedPassword.matches()` | 자기 자신의 값과 비교 | -| 동일 비밀번호 거부 | `UserModel.changePassword()` | 변경 시 비즈니스 규칙 | From 99a3bc4f6ebddfce1de4ca94ab7d7c6f78fa235a Mon Sep 17 00:00:00 2001 From: plan11plan Date: Fri, 13 Feb 2026 14:40:32 +0900 Subject: [PATCH 006/108] =?UTF-8?q?docs:=20=EC=A2=8B=EC=95=84=EC=9A=94=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EB=AA=A9=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/design/01-requirements.md | 62 +++++++++++++--------------------- 1 file changed, 24 insertions(+), 38 deletions(-) diff --git a/docs/design/01-requirements.md b/docs/design/01-requirements.md index 4457ef3ab..50ce4c43d 100644 --- a/docs/design/01-requirements.md +++ b/docs/design/01-requirements.md @@ -36,8 +36,8 @@ | # | 질문 | 결정 | |---|------|------| | 12 | 좋아요 수(카운트)를 어떻게 관리할 것인가? | **조회 시 COUNT 쿼리** — 별도 likeCount 컬럼 없이, likes 테이블 COUNT로 산출. 정합성 100% | -| 13 | 좋아요 API를 토글 방식으로 할 것인가, POST/DELETE 분리로 할 것인가? | **POST/DELETE 분리** — 미션 API 스펙 준수, RESTful 원칙 부합 | -| 14 | 이미 좋아요한 상품에 다시 POST 요청 시 어떻게 처리할 것인가? (멱등성) | **409 Conflict 반환** — 중복 등록은 명시적 오류 | +| 13 | 좋아요 API를 토글 방식으로 할 것인가, POST/DELETE 분리로 할 것인가? | **엔드포인트 분리 + 내부 토글** — POST/DELETE 엔드포인트는 미션 스펙 유지, 내부적으로는 같은 toggleLike 호출 | +| 14 | 이미 좋아요한 상품에 다시 POST 요청 시 어떻게 처리할 것인가? (멱등성) | **토글 처리** — 이미 좋아요 상태면 취소, 좋아요하지 않은 상태면 등록. 409/404 없음 | ### 상품 (Product) @@ -377,51 +377,36 @@ > **좋아요 수 관리**: 별도 likeCount 컬럼 없이, 조회 시 likes 테이블 COUNT 쿼리로 산출. > 상품 조회/목록 응답에 좋아요 수가 포함됨. +> +> **토글 방식**: POST/DELETE 엔드포인트는 분리하되, 내부적으로 같은 토글 로직(toggleLike)을 호출합니다. #### 유스케이스 흐름 -**UC-L01: 상품 좋아요 등록** +**UC-L01: 상품 좋아요 토글 (등록/취소)** ``` [유저 스토리] -- 회원이 마음에 드는 상품에 좋아요를 누를 수 있다. -- 회원당 하나의 상품에 좋아요는 1번만 가능하다. +- 회원이 마음에 드는 상품에 좋아요를 누르면 등록되고, 다시 요청하면 취소된다. [기능 흐름] -1. 회원이 productId로 좋아요를 요청한다 +1. 회원이 productId로 좋아요를 요청한다 (POST 또는 DELETE) 2. 해당 상품이 존재하는지 확인한다 (삭제된 상품 불가) -3. 이미 좋아요한 상태인지 확인한다 -4. 좋아요를 저장한다 (likes 테이블에 userId + productId) +3. 좋아요 존재 여부를 확인한다 +4-a. 좋아요가 없으면: 좋아요를 저장한다 (등록) +4-b. 좋아요가 있으면: 좋아요를 삭제한다 (취소) [예외] - productId에 해당하는 상품이 없거나 삭제된 경우 404 반환 -- 이미 좋아요한 상품이면 409 Conflict 반환 [조건] - 로그인한 회원만 가능 - 회원당 상품당 1개만 저장 (유니크 제약) +- POST/DELETE 모두 같은 Facade 메서드(toggleLike)를 호출 +- 이미 좋아요한 상품에 POST → 좋아요 취소 (409 없음) +- 좋아요하지 않은 상품에 DELETE → 좋아요 등록 (404 없음) ``` -**UC-L02: 상품 좋아요 취소** - -``` -[유저 스토리] -- 회원이 좋아요한 상품의 좋아요를 취소할 수 있다. - -[기능 흐름] -1. 회원이 productId로 좋아요 취소를 요청한다 -2. 해당 좋아요 기록이 존재하는지 확인한다 -3. 좋아요 기록을 삭제한다 - -[예외] -- 좋아요하지 않은 상품의 취소 요청 시 404 반환 - -[조건] -- 로그인한 회원만 가능 -- 본인의 좋아요만 취소 가능 -``` - -**UC-L03: 내가 좋아요한 상품 목록 조회** +**UC-L02: 내가 좋아요한 상품 목록 조회** ``` [유저 스토리] @@ -709,18 +694,19 @@ - 상품 목록 조회 시 JOIN + COUNT 쿼리 비용 발생 - 대량 데이터 시 성능 이슈 가능 → 필요 시 likeCount 캐싱 컬럼 도입 고려 -### 5.4 좋아요 API: POST/DELETE 분리 +### 5.4 좋아요 API: 엔드포인트 분리 + 내부 토글 -**결정**: 좋아요 등록(POST)과 취소(DELETE)를 별도 API로 분리. 토글 방식 아님. +**결정**: POST/DELETE 엔드포인트는 미션 스펙대로 분리하되, 내부적으로 같은 Facade 메서드(toggleLike)를 호출 **근거**: -- 미션 요구사항 API 스펙 준수 -- RESTful 원칙에 부합 (리소스 생성 = POST, 삭제 = DELETE) -- 클라이언트가 현재 상태를 명확히 알고 적절한 API를 호출 - -**멱등성 처리**: -- 이미 좋아요한 상품에 POST → 409 Conflict -- 좋아요하지 않은 상품에 DELETE → 404 Not Found +- 미션 요구사항 API 스펙 준수 (POST/DELETE 엔드포인트 유지) +- 내부 로직 단순화 (등록/취소 분기 없이 토글 1개 메서드) +- 상품 검증은 항상 수행 (삭제된 상품에 대한 좋아요 조작 방지) + +**토글 동작**: +- 좋아요가 없는 상태에서 요청 → 좋아요 등록 +- 좋아요가 있는 상태에서 요청 → 좋아요 취소 +- 409 Conflict, 404 Not Found(좋아요 미등록) 없음 ### 5.5 장바구니 가격: 현재 가격 기준 From 2ff98b33ad184cd03eb1269e0b958acf62b1c785 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Fri, 13 Feb 2026 14:42:22 +0900 Subject: [PATCH 007/108] =?UTF-8?q?docs:=20=EC=8B=9C=ED=80=80=EC=8A=A4,?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=EB=8B=A4=EC=9D=B4=EC=96=B4?= =?UTF-8?q?=EA=B7=B8=EB=9E=A8=20=EB=B0=8F=20erd=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/design/02-sequence-diagrams.md | 301 ++++++++++++++++++++++++++++ docs/design/03-class-diagram.md | 230 +++++++++++++++++++++ docs/design/04-erd.md | 222 ++++++++++++++++++++ 3 files changed, 753 insertions(+) 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 diff --git a/docs/design/02-sequence-diagrams.md b/docs/design/02-sequence-diagrams.md new file mode 100644 index 000000000..021c1dd04 --- /dev/null +++ b/docs/design/02-sequence-diagrams.md @@ -0,0 +1,301 @@ +# 02. 시퀀스 다이어그램 + +> 핵심 유스케이스의 객체 간 협력 구조를 시퀀스 다이어그램으로 표현합니다. +> 책임 분리, 호출 순서, 트랜잭션 경계를 검증하는 것이 목적입니다. + +--- + +## 다이어그램 작성 원칙 + +1. **User/Admin 제거**: 회원/비회원/관리자 구분은 중요하지만, 다이어그램 복잡성을 늘리므로 제거 +2. **구체적 메서드 시그니처 제거**: "주문 요청", "상품 검증" 같이 의도를 전달하는 수준으로 표현 +3. **Loop 제거**: 비즈니스 규칙상 N개 처리가 당연한 경우 Loop 표기 생략 +4. **Facade 선택적 사용**: 여러 도메인 조율이 필요한 유스케이스만 Facade 사용. 단일 도메인은 Controller → Service 직행 + +--- + +## 다이어그램 선정 기준 + +시퀀스 다이어그램은 **협력이 복잡하고 책임 분리가 중요한 흐름**에서 가치가 있습니다. +단순 CRUD(브랜드 등록, 상품 수정 등)는 협력 구조가 자명하므로 생략했습니다. + +| 선정 | 유스케이스 | 선정 이유 | +|------|----------|----------| +| O | 주문 요청 | Product + Order 도메인 협력. 재고 확인→차감→스냅샷→주문 생성 | +| O | 장바구니 담기 | Product + Cart 도메인 협력. 조건 분기 (신규 vs 기존 상품 합산) | +| O | 장바구니 조회 | Product + Cart 도메인 협력. 필터링 로직 (soft delete + 품절 표시) | +| O | 좋아요 등록/취소 | Product + Like 도메인 협력. 상품 검증 + 토글 (존재 여부에 따라 등록/취소) | +| O | 브랜드 삭제 | Brand + Product 도메인 협력. 연쇄 처리 (브랜드→상품 soft delete) | +| X | 브랜드 등록/수정 | 단일 도메인 CRUD. 시퀀스 가치 낮음 | +| X | 상품 목록 조회 | 단일 도메인 조회. 정렬/페이지네이션은 쿼리 레벨 | +| X | 주문 조회 | 단일 도메인 조회. 스냅샷 반환만 | + +--- + +## 1. 주문 요청 + +### 왜 이 다이어그램이 필요한가 + +주문은 이 서비스에서 가장 복잡한 흐름입니다. +**Product 도메인 (상품 검증 + 재고 차감) + Order 도메인 (주문 생성 + 스냅샷)**을 조율해야 하므로 Facade가 필요합니다. +이 다이어그램으로 **트랜잭션 경계**와 **도메인 간 협력 구조**를 검증합니다. + +### 다이어그램 + +```mermaid +sequenceDiagram + participant OC as OrderController + participant OF as OrderFacade + participant PS as ProductService + participant OS as OrderService + participant OR as OrderRepository + + Note left of OC: POST /api/v1/orders + OC->>OF: 주문 요청 + OF->>PS: 상품 조회 및 검증 + PS-->>OF: 상품 + + Note over PS: 상품 예외 처리
(없음, 삭제됨, 재고 부족) + + OF->>PS: 재고 차감 + PS-->>OF: 완료 + + OF->>OS: 주문 생성 (스냅샷 포함) + OS->>OR: 주문 저장 + OR-->>OS: 주문 + OS-->>OF: 주문 + OF-->>OC: 주문 생성 완료 +``` + +### 이 다이어그램에서 봐야 할 포인트 + +- **OrderFacade가 ProductService와 OrderService를 조율**합니다. 두 도메인 서비스는 서로를 모릅니다. +- 상품 검증, 재고 차감은 **ProductService의 책임**. OrderService는 주문 생성만 책임집니다. +- OrderItem 스냅샷은 주문 생성 시점에 Product 정보를 복사하여 저장합니다. +- 트랜잭션은 **OrderFacade에서 관리**. 전체 흐름이 하나의 트랜잭션으로 묶입니다. + +--- + +## 2. 장바구니 담기 + +### 왜 이 다이어그램이 필요한가 + +장바구니 담기는 **Product 검증 + Cart 저장/합산**을 조율해야 하므로 Facade가 필요합니다. +**같은 상품이 이미 있으면 수량을 합산**하는 조건 분기가 있습니다. +이 다이어그램으로 **두 갈래 흐름**과 **도메인 간 협력**을 검증합니다. + +### 다이어그램 + +```mermaid +sequenceDiagram + participant CC as CartController + participant CF as CartFacade + participant PS as ProductService + participant CS as CartService + + Note left of CC: POST /api/v1/carts + CC->>CF: 장바구니 담기 요청 + CF->>PS: 상품 검증 + PS-->>CF: 검증 완료 + + Note over PS: 상품 예외 처리
(없음, 삭제됨) + + CF->>CS: 기존 장바구니 항목 조회 + CS-->>CF: CartItem 또는 null + + alt 이미 있는 경우 + Note over CF: 수량 합산 (최대 99 검증) + CF->>CS: 수량 업데이트 + else 없는 경우 + Note over CF: 종류 상한 체크 (최대 100) + CF->>CS: 신규 항목 생성 + end + + CS-->>CF: 완료 + CF-->>CC: 장바구니 담기 완료 +``` + +### 이 다이어그램에서 봐야 할 포인트 + +- **CartFacade가 ProductService와 CartService를 조율**합니다. +- 상품 검증은 ProductService, 장바구니 저장/합산은 CartService 책임입니다. +- **두 갈래 분기**: 이미 담긴 상품이면 수량 합산, 아니면 신규 생성. +- 재고는 확인하지 않습니다 (주문 시점에 확인). + +--- + +## 3. 장바구니 조회 + +### 왜 이 다이어그램이 필요한가 + +장바구니 조회는 **Cart 목록 + Product 현재 상태(가격, 재고, 삭제 여부)**를 조합해야 하므로 Facade가 필요합니다. +**삭제된 상품/브랜드 필터링 + 품절 상태 표시**라는 비즈니스 로직이 있습니다. +이 다이어그램으로 **조회 시 필터링의 책임 위치**를 검증합니다. + +### 다이어그램 + +```mermaid +sequenceDiagram + participant CC as CartController + participant CF as CartFacade + participant CS as CartService + participant PS as ProductService + + Note left of CC: GET /api/v1/carts + CC->>CF: 장바구니 조회 요청 + CF->>CS: 장바구니 항목 조회 + CS-->>CF: List + + Note over CF: 각 항목에 대해
상품 현재 정보 조회 + + CF->>PS: 상품 정보 조회 (여러 건) + PS-->>CF: List + + Note over CF: 필터링 + 상태 표시
(삭제됨 제외, 품절 표시) + + CF-->>CC: 장바구니 목록 (현재 가격 + 상태) +``` + +### 이 다이어그램에서 봐야 할 포인트 + +- **CartFacade가 CartService와 ProductService를 조율**합니다. +- 장바구니 항목을 먼저 조회한 후, **각 항목의 상품 현재 상태를 확인**합니다. +- 삭제된 상품은 제외, 품절(stock=0)은 상태 표시, 정상은 현재 가격과 함께 반환. +- 가격은 장바구니에 저장된 것이 아니라 **현재 상품 가격**을 사용합니다. + +--- + +## 4. 좋아요 등록/취소 + +### 왜 이 다이어그램이 필요한가 + +좋아요는 **Product 검증 + Like 등록/취소**를 조율해야 하므로 Facade가 필요합니다. +POST/DELETE 엔드포인트는 분리하되, **내부적으로 같은 toggleLike 메서드**를 호출합니다. +이 다이어그램으로 **상품 검증 후 Facade의 토글 분기**를 검증합니다. + +### 다이어그램 + +```mermaid +sequenceDiagram + autonumber + + participant LC as LikeController + participant LF as LikeFacade + participant PS as ProductService + participant LS as LikeService + + Note left of LC: POST /products/{id}/likes
DELETE /products/{id}/likes + + LC->>LF: 좋아요 토글 요청 + + Note over LF: @Transactional + LF->>PS: 상품 검증 + activate PS + PS-->>PS: 상품 예외처리 + PS-->>LF: 검증 완료 + deactivate PS + + LF->>LS: 좋아요 존재 확인 + + alt 좋아요가 존재하지 않을 경우 + LF->>LS: save() + else 이미 좋아요한 경우 + LF->>LS: delete() + end +``` + +### 이 다이어그램에서 봐야 할 포인트 + +- **LikeFacade가 ProductService와 LikeService를 조율**합니다. +- **POST/DELETE 두 엔드포인트 모두 같은 Facade 메서드(toggleLike)를 호출**합니다. +- **상품 검증은 항상 수행**합니다. 삭제된 상품에 대한 좋아요 조작을 방지합니다. +- **분기(exists → save/delete)는 Facade가 담당**합니다. LikeService는 단순 CRUD만 수행합니다. +- 좋아요 수(COUNT)는 이 흐름에서 관리하지 않습니다. 조회 시 COUNT 쿼리로 산출. + +--- + +## 5. 브랜드 삭제 (연쇄 처리) + +### 왜 이 다이어그램이 필요한가 + +브랜드 삭제는 **Brand 삭제 + Product 연쇄 삭제**를 조율해야 하므로 Facade가 필요합니다. +단일 엔티티 삭제가 아니라, **브랜드 → 해당 브랜드의 상품 전체**를 연쇄적으로 soft delete 해야 합니다. +이 다이어그램으로 **연쇄 삭제의 범위와 순서**를 검증합니다. + +### 다이어그램 + +```mermaid +sequenceDiagram + participant BC as BrandAdminController + participant BF as BrandFacade + participant BS as BrandService + participant PS as ProductService + + Note left of BC: DELETE /api-admin/v1/brands/{id} + BC->>BF: 브랜드 삭제 요청 + BF->>BS: 브랜드 조회 및 검증 + BS-->>BF: 검증 완료 + + Note over BS: 브랜드 예외 처리
(없음, 이미 삭제됨) + + BF->>BS: 브랜드 soft delete + BS-->>BF: 완료 + + BF->>PS: 해당 브랜드 상품 전체 삭제 + PS-->>BF: 완료 + + Note over BF: 장바구니/좋아요는
조회 시 필터링 + + BF-->>BC: 삭제 완료 +``` + +### 이 다이어그램에서 봐야 할 포인트 + +- **BrandFacade가 BrandService와 ProductService를 조율**합니다. +- 브랜드 soft delete 후, **같은 트랜잭션 안에서** 해당 브랜드의 상품도 soft delete. +- 장바구니/좋아요는 **이 트랜잭션에서 건드리지 않습니다**. 조회 시 필터링. +- ProductService를 통해 상품 삭제를 처리하므로 도메인 경계가 보존됩니다. + +--- + +## 6. 개선 검토 리스트 + +현재 Facade → Service → Repository 구조로 작성했습니다. +아래는 **시퀀스 다이어그램을 수정하면서 검토하면 좋을 포인트**입니다. + +### 책임 분리 관점 + +| # | 대상 | 현재 | 검토 포인트 | +|---|------|------|-----------| +| 1 | **주문 - 재고 차감** | ProductService가 재고 차감 처리 | `Product.decreaseStock(quantity)` 도메인 메서드로 추출하면 재고 부족 검증까지 엔티티에 캡슐화 가능 | +| 2 | **주문 - 스냅샷 생성** | OrderFacade 또는 OrderService가 스냅샷 조립 | `OrderItem.createSnapshot(product, quantity)` 정적 팩토리 메서드로 분리하면 스냅샷 대상 필드를 OrderItem이 결정 | +| 3 | **장바구니 담기 - 수량 합산** | CartFacade에서 수량 합산 로직 처리 | `CartItem.addQuantity(quantity)` 도메인 메서드로 검증(최대 99)까지 캡슐화 | +| 4 | **장바구니 조회 - 필터링** | CartFacade에서 필터링 | 쿼리 레벨(JOIN + WHERE deleted_at IS NULL)로 필터링하면 N+1 문제 방지 가능. Facade 레벨 vs 쿼리 레벨 선택 | +| 5 | **상품 검증 로직** | ProductService에 여러 검증 메서드 분산 | Product 엔티티에 `isAvailable()`, `hasStock(quantity)` 같은 도메인 메서드로 캡슐화 검토 | + +### 네이밍 관점 + +| # | 현재 | 검토 포인트 | +|---|------|-----------| +| 6 | `OrderFacade` | 주문 생성만 담당. 주문 조회는 Controller → OrderService 직행. 현재는 문제없음 | +| 7 | `CartFacade` | 담기/조회 담당. 수량 변경/삭제는 Controller → CartService 직행. 현재는 문제없음 | +| 8 | `ProductService` | 상품 CRUD + 검증. 기능이 많아지면 ProductQueryService, ProductCommandService 분리 검토 | + +### 구조 관점 + +| # | 대상 | 검토 포인트 | +|---|------|-----------| +| 9 | **트랜잭션 관리 위치** | 현재 Facade에서 @Transactional 관리. 각 도메인 서비스는 트랜잭션 없이 비즈니스 로직만 담당. 이 구조가 깔끔함 | +| 10 | **장바구니 조회 N+1** | 현재 CartItem 조회 후 Product를 개별 조회. IN 쿼리로 한 번에 가져오는 방식 검토 | +| 11 | **Facade 비대화** | 현재 각 Facade는 1~2개 유스케이스만 담당. 기능 추가 시 유스케이스별 Facade 분리 검토 (CreateOrderFacade, CancelOrderFacade 등) | + +--- + +## Checklist + +- [x] 시퀀스 다이어그램이 최소 2개 이상 포함되어 있는가? (5개) +- [x] 시퀀스 다이어그램에서 책임 객체가 드러나는가? +- [x] Facade를 통한 도메인 간 협력 구조가 표현되어 있는가? +- [x] Mermaid 기반으로 작성되었는가? +- [x] 각 다이어그램에 "왜 필요한가"와 "봐야 할 포인트"가 포함되어 있는가? +- [x] 개선 검토 리스트가 포함되어 있는가? diff --git a/docs/design/03-class-diagram.md b/docs/design/03-class-diagram.md new file mode 100644 index 000000000..bf7846b2e --- /dev/null +++ b/docs/design/03-class-diagram.md @@ -0,0 +1,230 @@ +# 03. 클래스 다이어그램 + +> 도메인 객체의 책임, 관계, 의존 방향을 클래스 다이어그램으로 표현합니다. +> 엔티티의 필드/메서드 설계와 도메인 간 결합 구조를 검증하는 것이 목적입니다. + +--- + +## 다이어그램 선정 기준 + +클래스 다이어그램은 **도메인 책임과 의존 방향이 중요한 구조**에서 가치가 있습니다. +단순 DTO나 Controller는 구조가 자명하므로 생략했습니다. + +| 선정 | 대상 | 선정 이유 | +|------|------|----------| +| O | 도메인 엔티티 전체 관계도 | 엔티티 간 관계, 필드 구성, 책임 분배를 한눈에 확인 | +| O | 서비스 레이어 의존 구조 | 서비스 간 의존 방향, 크로스 도메인 호출 확인 | +| X | Controller 클래스 | 요청 라우팅만 담당. 구조 자명 | +| X | DTO / Request / Response | 필드 나열 수준. 클래스 다이어그램 가치 낮음 | +| X | Repository 인터페이스 | Spring Data JPA 표준 구조. 메서드 시그니처는 ERD에서 확인 | + +--- + +## 1. 도메인 엔티티 관계도 + +### 왜 이 다이어그램이 필요한가 + +이 프로젝트에는 5개 핵심 도메인(Brand, Product, ProductLike, Cart, Order)이 있습니다. +엔티티 간 관계와 각 엔티티가 가진 필드를 한눈에 보면서 +**책임이 적절히 분배되었는지**, **의존 방향이 올바른지** 검증합니다. + +### 다이어그램 + +```mermaid +classDiagram + class Brand { + -Long id + -String name + -String description + -LocalDateTime createdAt + -LocalDateTime updatedAt + -LocalDateTime deletedAt + } + + class Product { + -Long id + -Long brandId + -String name + -int price + -int stock + -String description + -LocalDateTime createdAt + -LocalDateTime updatedAt + -LocalDateTime deletedAt + } + + class ProductLike { + -Long id + -Long userId + -Long productId + -LocalDateTime createdAt + } + + class CartItem { + -Long id + -Long userId + -Long productId + -int quantity + -LocalDateTime createdAt + -LocalDateTime updatedAt + } + + class Order { + -Long id + -Long userId + -LocalDateTime createdAt + } + + class OrderItem { + -Long id + -Long orderId + -Long productId + -String productName + -String brandName + -int price + -int quantity + } + + Brand "1" --> "*" Product : 소유 + Product "1" --> "*" ProductLike : 좋아요 대상 + Product "1" --> "*" CartItem : 장바구니 참조 + Order "1" *-- "*" OrderItem : 포함 +``` + +### 이 다이어그램에서 봐야 할 포인트 + +- **Product**가 가장 많은 관계를 맺는 중심 엔티티입니다. ProductLike, CartItem이 Product를 참조합니다. +- **OrderItem**은 Product를 직접 참조하지 않습니다. `productName`, `brandName`, `price`를 **스냅샷으로 복사**합니다. 주문 이후 상품이 변경/삭제되어도 주문 내역은 보존됩니다. +- **CartItem**에는 `price` 필드가 없습니다. 가격은 조회 시 Product의 현재 가격을 사용합니다. +- Brand, Product에 `deletedAt` 필드가 있어 Soft Delete를 지원합니다. ProductLike, CartItem에는 없습니다 (조회 시 필터링). +- 현재 엔티티에 **도메인 메서드가 없습니다**. 모든 로직이 Service에 있는 상태입니다 (개선 검토 리스트 참고). + +--- + +## 2. 서비스 레이어 의존 구조 + +### 왜 이 다이어그램이 필요한가 + +시퀀스 다이어그램(02)에서 서비스가 자기 도메인 외의 Repository를 호출하는 부분이 여러 곳 있었습니다. +이 다이어그램으로 **서비스 간 의존 방향**과 **도메인 경계를 넘는 의존**이 어디서 발생하는지 구조적으로 확인합니다. + +### 다이어그램 + +```mermaid +classDiagram + class BrandService { + +createBrand() + +updateBrand() + +deleteBrand() + +getBrand() + } + + class ProductService { + +createProduct() + +updateProduct() + +deleteProduct() + +getProduct() + +getProducts() + } + + class LikeService { + +addLike() + +removeLike() + +getMyLikes() + } + + class CartService { + +addToCart() + +getCartItems() + +updateQuantity() + +removeCartItem() + } + + class OrderService { + +createOrder() + +getOrders() + +getOrder() + } + + class BrandRepository { + <> + } + class ProductRepository { + <> + } + class LikeRepository { + <> + } + class CartRepository { + <> + } + class OrderRepository { + <> + } + + BrandService --> BrandRepository + BrandService ..> ProductRepository : 브랜드 삭제 시 상품 연쇄 삭제 + + ProductService --> ProductRepository + ProductService ..> BrandRepository : 상품 등록 시 브랜드 존재 확인 + + LikeService --> LikeRepository + LikeService ..> ProductRepository : 좋아요 시 상품 존재 확인 + + CartService --> CartRepository + CartService ..> ProductRepository : 담기 시 상품 확인 + 조회 시 현재 정보 + + OrderService --> OrderRepository + OrderService ..> ProductRepository : 재고 확인 + 차감 + 스냅샷 +``` + +### 이 다이어그램에서 봐야 할 포인트 + +- **실선(→)**: 자기 도메인 Repository 의존. 당연한 의존. +- **점선(..>)**: 다른 도메인 Repository 의존. **크로스 도메인 호출**. 이 부분이 결합도를 높이는 지점입니다. +- **ProductRepository**가 거의 모든 서비스에서 참조됩니다. Product가 시스템의 핵심 엔티티임을 보여줍니다. +- Cart와 Order 사이에는 **의존이 없습니다**. 설계 결정(5.7)에서 합의한 "서버 도메인 간 독립" 원칙이 지켜지고 있습니다. + +--- + +## 3. 개선 검토 리스트 + +현재 Controller → Service → Repository 기본 구조로 작성했습니다. +아래는 **클래스 다이어그램을 수정하면서 검토하면 좋을 포인트**입니다. + +### 도메인 메서드 관점 + +| # | 대상 | 현재 | 검토 포인트 | +|---|------|------|-----------| +| 1 | **Product - 재고 차감** | Service에서 `product.stock -= quantity` 처리 | `Product.decreaseStock(quantity)` 도메인 메서드로 추출하면? 재고 부족 검증까지 Product 내부에서 책임지면 비즈니스 규칙이 엔티티에 캡슐화됨 | +| 2 | **Product - 품절 확인** | Service에서 `product.stock == 0` 체크 | `Product.isSoldOut()` 메서드로 추출하면 "품절"의 정의가 바뀌어도 한 곳만 수정 | +| 3 | **Product / Brand - soft delete** | Service에서 `entity.deletedAt = now()` 직접 설정 | `softDelete()`, `isDeleted()` 메서드로 추출하면 삭제 상태 관련 로직이 엔티티에 응집 | +| 4 | **CartItem - 수량 합산** | Service에서 `cartItem.quantity += quantity` 처리 | `CartItem.addQuantity(quantity)` 메서드로 추출. 최대 99 검증까지 캡슐화 | +| 5 | **CartItem - 수량 변경** | Service에서 `cartItem.quantity = newQuantity` 처리 | `CartItem.updateQuantity(quantity)` 메서드로 추출. 1~99 범위 검증 캡슐화 | +| 6 | **OrderItem - 스냅샷 생성** | Service에서 Product 정보를 꺼내 OrderItem 필드에 매핑 | `OrderItem.createSnapshot(product, quantity)` 정적 팩토리 메서드로 분리하면 스냅샷 대상 필드를 OrderItem이 결정 | + +### 의존 방향 관점 + +| # | 대상 | 현재 | 검토 포인트 | +|---|------|------|-----------| +| 7 | **BrandService → ProductRepository** | BrandService가 ProductRepository를 직접 의존 | ProductService를 거치면 도메인 경계가 명확해짐. 하지만 같은 트랜잭션 안에서 처리해야 하므로 직접 호출이 더 단순할 수 있음. 트레이드오프 판단 필요 | +| 8 | **OrderService → ProductRepository** | OrderService가 재고 확인/차감까지 직접 처리 | ProductService의 `decreaseStock(productId, quantity)` 메서드를 통해 호출하면 재고 관련 책임이 ProductService로 모임. 하지만 트랜잭션 범위 관리가 복잡해질 수 있음 | + +### JPA 관계 매핑 관점 + +| # | 대상 | 현재 | 검토 포인트 | +|---|------|------|-----------| +| 9 | **Product ↔ Brand** | Product에 `brandId` (Long) 저장 | `@ManyToOne`으로 Brand 엔티티 직접 참조하면 JOIN으로 한 번에 조회 가능. 단, 양방향 관계는 결합도를 높이므로 단방향 `@ManyToOne`만 고려 | +| 10 | **Order ↔ OrderItem** | OrderItem에 `orderId` (Long) 저장 | 함께 생성/조회되므로 `@OneToMany` + cascade가 자연스러움. 하지만 Order 목록 조회 시 N+1 주의 | +| 11 | **OrderItem - productId** | OrderItem에 `productId`를 단순 Long으로 저장 | 스냅샷 특성상 FK 제약을 걸면 상품 삭제 시 문제. Long 값으로 저장이 적절. FK 없이 "그때 그 상품"의 참조 용도 | + +--- + +## Checklist + +- [x] 클래스 다이어그램이 최소 2개 이상 포함되어 있는가? (2개) +- [x] 도메인 엔티티의 필드와 관계가 드러나는가? +- [x] 서비스 레이어의 의존 방향이 표현되어 있는가? +- [x] Mermaid 기반으로 작성되었는가? +- [x] 각 다이어그램에 "왜 필요한가"와 "봐야 할 포인트"가 포함되어 있는가? +- [x] 개선 검토 리스트가 포함되어 있는가? diff --git a/docs/design/04-erd.md b/docs/design/04-erd.md new file mode 100644 index 000000000..398f1bbed --- /dev/null +++ b/docs/design/04-erd.md @@ -0,0 +1,222 @@ +# 04. ERD (Entity-Relationship Diagram) + +> 전체 테이블 구조와 관계를 ERD로 표현합니다. +> 영속성 구조, 관계의 주인, 제약 조건을 검증하는 것이 목적입니다. + +--- + +## 다이어그램 선정 기준 + +ERD는 **전체 데이터 구조를 한 장으로** 표현합니다. +테이블 분리, 컬럼 구성, 관계 방향, 제약 조건이 요구사항과 일치하는지 확인합니다. + +--- + +## 1. 전체 ERD + +### 왜 이 다이어그램이 필요한가 + +6개 테이블의 관계와 컬럼 구성을 한눈에 봅니다. +특히 **OrderItem이 Product를 FK로 참조하지 않는 이유**(스냅샷 설계), +**CartItem에 price가 없는 이유**(현재 가격 사용)를 구조적으로 검증합니다. + +### 다이어그램 + +```mermaid +erDiagram + BRANDS { + bigint id PK + varchar name "NOT NULL, UNIQUE" + varchar description + datetime created_at "NOT NULL" + datetime updated_at "NOT NULL" + datetime deleted_at "NULL = 미삭제" + } + + PRODUCTS { + bigint id PK + bigint brand_id FK "NOT NULL" + varchar name "NOT NULL" + int price "NOT NULL, >= 0" + int stock "NOT NULL, >= 0" + varchar description + datetime created_at "NOT NULL" + datetime updated_at "NOT NULL" + datetime deleted_at "NULL = 미삭제" + } + + PRODUCT_LIKES { + bigint id PK + bigint user_id "NOT NULL" + bigint product_id FK "NOT NULL" + datetime created_at "NOT NULL" + } + + CART_ITEMS { + bigint id PK + bigint user_id "NOT NULL" + bigint product_id FK "NOT NULL" + int quantity "NOT NULL, 1~99" + datetime created_at "NOT NULL" + datetime updated_at "NOT NULL" + } + + ORDERS { + bigint id PK + bigint user_id "NOT NULL" + datetime created_at "NOT NULL" + } + + ORDER_ITEMS { + bigint id PK + bigint order_id FK "NOT NULL" + bigint product_id "NOT NULL, FK 아님" + varchar product_name "NOT NULL, 스냅샷" + varchar brand_name "NOT NULL, 스냅샷" + int price "NOT NULL, 스냅샷" + int quantity "NOT NULL" + } + + BRANDS ||--o{ PRODUCTS : "1 브랜드 = N 상품" + PRODUCTS ||--o{ PRODUCT_LIKES : "1 상품 = N 좋아요" + PRODUCTS ||--o{ CART_ITEMS : "1 상품 = N 장바구니" + ORDERS ||--o{ ORDER_ITEMS : "1 주문 = N 주문항목" +``` + +### 이 다이어그램에서 봐야 할 포인트 + +- **ORDER_ITEMS.product_id**는 FK가 **아닙니다**. 스냅샷 설계이므로 상품이 삭제되어도 주문 내역이 유지되어야 합니다. FK 제약을 걸면 상품 soft delete 시 참조 무결성 문제가 생깁니다. +- **CART_ITEMS에 price 컬럼이 없습니다**. 장바구니 조회 시 PRODUCTS 테이블의 현재 가격을 JOIN으로 가져옵니다. +- **PRODUCT_LIKES, CART_ITEMS에 deleted_at이 없습니다**. 상품/브랜드 삭제 시 조회 시점에 PRODUCTS.deleted_at을 체크하여 필터링합니다. +- **ORDERS와 CART_ITEMS 사이에 관계가 없습니다**. 장바구니→주문 전환은 클라이언트 레벨에서 처리합니다. + +--- + +## 2. 테이블 상세 + +### BRANDS + +| 컬럼 | 타입 | 제약 | 설명 | +|------|------|------|------| +| id | BIGINT | PK, AUTO_INCREMENT | 브랜드 ID | +| name | VARCHAR(100) | NOT NULL, UNIQUE | 브랜드명 (중복 불가) | +| description | VARCHAR(500) | | 브랜드 설명 | +| created_at | DATETIME | NOT NULL | 등록일 | +| updated_at | DATETIME | NOT NULL | 수정일 | +| deleted_at | DATETIME | NULL | Soft Delete 시각. NULL이면 미삭제 | + +### PRODUCTS + +| 컬럼 | 타입 | 제약 | 설명 | +|------|------|------|------| +| id | BIGINT | PK, AUTO_INCREMENT | 상품 ID | +| brand_id | BIGINT | FK → BRANDS.id, NOT NULL | 소속 브랜드 | +| name | VARCHAR(200) | NOT NULL | 상품명 | +| price | INT | NOT NULL, >= 0 | 가격 | +| stock | INT | NOT NULL, >= 0 | 재고 수량. 0이면 품절 | +| description | VARCHAR(1000) | | 상품 설명 | +| created_at | DATETIME | NOT NULL | 등록일 | +| updated_at | DATETIME | NOT NULL | 수정일 | +| deleted_at | DATETIME | NULL | Soft Delete 시각 | + +### PRODUCT_LIKES + +| 컬럼 | 타입 | 제약 | 설명 | +|------|------|------|------| +| id | BIGINT | PK, AUTO_INCREMENT | 좋아요 ID | +| user_id | BIGINT | NOT NULL | 회원 ID | +| product_id | BIGINT | FK → PRODUCTS.id, NOT NULL | 상품 ID | +| created_at | DATETIME | NOT NULL | 좋아요 등록일 | + +**유니크 제약**: `UNIQUE(user_id, product_id)` — 회원당 상품당 좋아요 1개 + +### CART_ITEMS + +| 컬럼 | 타입 | 제약 | 설명 | +|------|------|------|------| +| id | BIGINT | PK, AUTO_INCREMENT | 장바구니 항목 ID | +| user_id | BIGINT | NOT NULL | 회원 ID | +| product_id | BIGINT | FK → PRODUCTS.id, NOT NULL | 상품 ID | +| quantity | INT | NOT NULL, 1~99 | 수량 | +| created_at | DATETIME | NOT NULL | 담은 날짜 | +| updated_at | DATETIME | NOT NULL | 수량 변경일 | + +**유니크 제약**: `UNIQUE(user_id, product_id)` — 같은 상품 중복 담기 불가 (수량 합산) + +### ORDERS + +| 컬럼 | 타입 | 제약 | 설명 | +|------|------|------|------| +| id | BIGINT | PK, AUTO_INCREMENT | 주문 ID | +| user_id | BIGINT | NOT NULL | 주문자 ID | +| created_at | DATETIME | NOT NULL | 주문일 | + +### ORDER_ITEMS + +| 컬럼 | 타입 | 제약 | 설명 | +|------|------|------|------| +| id | BIGINT | PK, AUTO_INCREMENT | 주문 항목 ID | +| order_id | BIGINT | FK → ORDERS.id, NOT NULL | 소속 주문 | +| product_id | BIGINT | NOT NULL | 상품 ID **(FK 아님)** | +| product_name | VARCHAR(200) | NOT NULL | 주문 시점 상품명 (스냅샷) | +| brand_name | VARCHAR(100) | NOT NULL | 주문 시점 브랜드명 (스냅샷) | +| price | INT | NOT NULL | 주문 시점 가격 (스냅샷) | +| quantity | INT | NOT NULL | 주문 수량 | + +> **ORDER_ITEMS.product_id가 FK가 아닌 이유**: +> 주문 이후 상품이 삭제되어도 "그때 주문한 상품이 뭐였는지" 참조할 수 있어야 합니다. +> FK 제약이 있으면 상품 삭제(soft delete 포함) 시 참조 무결성 충돌이 발생합니다. + +--- + +## 3. 인덱스 전략 + +| 테이블 | 인덱스 | 용도 | +|--------|--------|------| +| PRODUCTS | `idx_products_brand_id` (brand_id) | 브랜드별 상품 조회, 브랜드 삭제 시 연쇄 처리 | +| PRODUCTS | `idx_products_deleted_at` (deleted_at) | Soft Delete 필터링 조회 | +| PRODUCT_LIKES | `uq_likes_user_product` (user_id, product_id) | 중복 좋아요 방지 (UNIQUE) | +| PRODUCT_LIKES | `idx_likes_user_id` (user_id) | 내가 좋아요한 목록 조회 | +| PRODUCT_LIKES | `idx_likes_product_id` (product_id) | 상품별 좋아요 수 COUNT | +| CART_ITEMS | `uq_cart_user_product` (user_id, product_id) | 같은 상품 중복 방지 (UNIQUE) | +| CART_ITEMS | `idx_cart_user_id` (user_id) | 내 장바구니 조회 | +| ORDERS | `idx_orders_user_created` (user_id, created_at) | 기간별 주문 목록 조회 | +| ORDER_ITEMS | `idx_order_items_order_id` (order_id) | 주문별 항목 조회 | + +--- + +## 4. 개선 검토 리스트 + +### 컬럼 관점 + +| # | 대상 | 현재 | 검토 포인트 | +|---|------|------|-----------| +| 1 | **ORDERS - total_amount** | total_amount 컬럼 없음 | 주문 총액을 매번 ORDER_ITEMS의 `SUM(price * quantity)`로 계산할 것인가? 아니면 ORDERS에 total_amount를 저장할 것인가? 저장하면 조회 성능 향상, 안 하면 정합성 보장 | +| 2 | **ORDER_ITEMS - 추가 스냅샷** | product_name, brand_name, price만 스냅샷 | 상품 이미지 URL, 상품 설명 등 추가 스냅샷 필드가 필요한가? 현재 요구사항에서는 불필요하지만 확장성 관점 | +| 3 | **PRODUCTS - image** | 이미지 관련 컬럼 없음 | 상품 목록/상세에 이미지가 필요하면 image_url 컬럼 추가 필요. 미션 요구사항에 따라 결정 | + +### 관계 관점 + +| # | 대상 | 현재 | 검토 포인트 | +|---|------|------|-----------| +| 4 | **user_id FK 여부** | PRODUCT_LIKES, CART_ITEMS, ORDERS에 user_id는 단순 BIGINT | USERS 테이블에 대한 FK를 걸 것인가? FK를 걸면 참조 무결성이 보장되지만, 모듈 간 결합이 생김 (User는 1주차 도메인) | +| 5 | **CART_ITEMS - product_id FK** | FK → PRODUCTS.id 설정 | 상품이 soft delete되면 FK는 유지되지만, 장바구니 조회 시 deleted_at 체크로 필터링. Hard delete를 하게 되면 FK 제약 문제 발생. 현재 Soft Delete 전략이므로 FK 유지 가능 | + +### 정규화 관점 + +| # | 대상 | 현재 | 검토 포인트 | +|---|------|------|-----------| +| 6 | **ORDER_ITEMS.brand_name** | 브랜드명을 주문 항목마다 중복 저장 | 같은 주문에서 같은 브랜드 상품을 여러 개 주문하면 brand_name이 중복됨. 하지만 스냅샷 특성상 정규화하면 의미가 없음 (브랜드명도 변경될 수 있으므로) | +| 7 | **좋아요 수 비정규화** | likes COUNT 쿼리로 산출 | PRODUCTS에 `like_count` 컬럼을 추가하면 조회 성능 향상. 등록/취소 시 업데이트 필요. 현재는 COUNT 쿼리로 정합성 100% 유지 | + +--- + +## Checklist + +- [x] ERD가 전체 테이블 구조를 포함하는가? (6개 테이블) +- [x] 각 테이블의 컬럼, 타입, 제약 조건이 명시되어 있는가? +- [x] FK 관계와 비FK 관계의 구분이 설명되어 있는가? (ORDER_ITEMS.product_id) +- [x] Mermaid 기반으로 작성되었는가? +- [x] 인덱스 전략이 포함되어 있는가? +- [x] "왜 필요한가"와 "봐야 할 포인트"가 포함되어 있는가? +- [x] 개선 검토 리스트가 포함되어 있는가? From e353777b265d6969e31831629d063d66fe8bfcbc Mon Sep 17 00:00:00 2001 From: plan11plan Date: Sun, 15 Feb 2026 22:58:00 +0900 Subject: [PATCH 008/108] =?UTF-8?q?docs:=20=EA=B8=B0=EB=8A=A5=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=EC=82=AC=ED=95=AD,=ED=81=B4=EB=9E=98=EC=8A=A4?= =?UTF-8?q?=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8,erd=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/design/01-requirements.md | 702 ++++++----------- docs/design/02-sequence-diagrams.md | 191 +---- docs/design/03-class-diagram.md | 326 ++++---- docs/design/04-erd.md | 286 +++---- ...54\240\225_\352\270\260\353\241\235_v3.md" | 745 ++++++++++++++++++ 5 files changed, 1234 insertions(+), 1016 deletions(-) create mode 100644 "docs/design/\353\217\204\353\251\224\354\235\270_\352\264\200\352\263\204_\354\204\244\352\263\204_\354\235\230\354\202\254\352\262\260\354\240\225_\352\270\260\353\241\235_v3.md" diff --git a/docs/design/01-requirements.md b/docs/design/01-requirements.md index 50ce4c43d..6c9a18aa2 100644 --- a/docs/design/01-requirements.md +++ b/docs/design/01-requirements.md @@ -1,71 +1,66 @@ +# 1. 목적 및 범위 ---- +### 프로젝트 목적 +SSENSE와 같은 하이패션 이커머스 플랫폼을 설게한다. -## 0. 요구사항 분석 과정에서 던진 질문들 +고객이 브랜드별 상품을 탐색하고, 마음에 드는 상품에 좋아요를 표시하고, 여러 상품을 한 번에 주문할 수 있는 이커머스 서비스를 구축한다. 어드민은 브랜드와 상품을 관리하고, 주문 현황을 파악한다. -요구사항을 명확히 하기 위해 개발자(나)가 던진 질문과 결정 내용을 정리합니다. -### 장바구니 (Cart) +### 범위 -| # | 질문 | 결정 | -|---|------|------| -| 1 | 장바구니 상품의 유효성 체크를 언제 할 것인가? (조회 시 실시간 vs 이벤트로 즉시 정리) | **조회 시 실시간 체크** — 장바구니 테이블은 단순 유지, 조회 시 상품/브랜드 상태를 확인하여 필터링 | -| 2 | 장바구니 담기 시 재고를 확인하는가? | **담을 때는 확인 안 함** — 주문 시점에만 확인. 장바구니는 "보관함" 성격 | -| 3 | 장바구니 수량 상한을 두는가? | **최대 100종류, 상품당 최대 99개** — 페이지네이션 적용 | -| 4 | 장바구니에 수량 '변경' 기능이 필요한가? (담기/제거만 vs 수량 직접 변경) | **수량 직접 변경 API 추가** — PUT /api/v1/carts/{cartItemId}로 원하는 수량 직접 지정 | -| 5 | 장바구니에 표시할 가격은 어떤 시점 기준인가? | **항상 현재 가격** — 장바구니에 가격 저장 안 함. 주문 시점에 스냅샷으로 확정 | -| 6 | 수량 변경 API에서 수량 0으로 변경하면 삭제 처리할 것인가? | **수량 0은 불가, 최소 1** — 제거는 DELETE API로만 가능. 역할 명확히 분리 | -| 7 | 장바구니 담기 시 quantity 기본값은? | **필수값 (기본값 없음)** — 클라이언트가 명시적으로 전달 | -| 8 | 장바구니에서 바로 주문으로 전환하는 기능이 필요한가? | **클라이언트 레벨 전환** — Cart→Order 서버 의존성 없이, 클라이언트가 장바구니 조회 후 기존 주문 API에 직접 요청 | +본 문서가 다루는 범위는 다음 네 가지 도메인으로 **한정**한다. -### 브랜드 관심/좋아요 +- **브랜드** — 상품을 묶는 그룹 단위. 어드민이 관리하고, 고객이 조회한다. +- **상품** — 고객이 탐색하고 주문하는 대상. 등록시 브랜드가 반드시 존재해야한다. +- **좋아요** — 고객이 상품에 대한 관심을 표현하는 행위. 실제 구매를 하진 않지만 구매 후보를 표현하는 행위다. +- **장바구니** — 고객이 관심 있는 상품을 임시로 모아두는 공간. 주문 전 단계에서 상품을 선택·관리하고 주문으로 이어지기 쉽다. +- **주문** — 고객이 상품을 구매하는 행위. 주문 시점의 상품 정보가 보존된다. -| # | 질문 | 결정 | -|---|------|------| -| 9 | 브랜드 좋아요(관심) 기능을 추가할 것인가? | **제외** — 처음엔 추가하려 했으나, 분석 과정에서 현 단계에서는 불필요하다고 판단하여 제외 | +### 액터 -### 삭제 전략 +- **고객(User)** — 상품을 탐색하고, 좋아요를 누르고, 주문한다. 로그인이 필요한 행위와 필요 없는 행위가 구분된다. +- **어드민(Admin)** — 브랜드와 상품을 등록·수정·삭제하고, 전체 주문 현황을 조회한다. -| # | 질문 | 결정 | -|---|------|------| -| 10 | 브랜드/상품 삭제 방식은? (Hard Delete vs Soft Delete) | **Soft Delete** — deleted_at 컬럼. 복구 가능성, 조회 시 필터링 방식과 일관성 | -| 11 | 브랜드 삭제 시 연관 데이터(장바구니, 좋아요) 처리는? | **조회 시 필터링** — 브랜드 soft delete → 상품 soft delete → 장바구니/좋아요는 조회 시 필터링. 트랜잭션 비대화 방지 | +### 범위 고정 -### 좋아요 (ProductLike) +- 유저(Users) 회원가입, 내 정보 조회, 비밀번호 변경 기능은 **이미 구현 완료**되어 본 문서에서 다루지 않는다. +- 인증/인가의 구체적 구현 방식은 기존 코드베이스의 패턴을 따르며, 본 문서에서는 **인증이 필요한지 여부만** 명시한다. +- 엔티티 필드는 **행동 기반으로 점진적으로 설계**한다. 각 시나리오가 요구하는 행동에 필요한 필드만 추가한다. -| # | 질문 | 결정 | -|---|------|------| -| 12 | 좋아요 수(카운트)를 어떻게 관리할 것인가? | **조회 시 COUNT 쿼리** — 별도 likeCount 컬럼 없이, likes 테이블 COUNT로 산출. 정합성 100% | -| 13 | 좋아요 API를 토글 방식으로 할 것인가, POST/DELETE 분리로 할 것인가? | **엔드포인트 분리 + 내부 토글** — POST/DELETE 엔드포인트는 미션 스펙 유지, 내부적으로는 같은 toggleLike 호출 | -| 14 | 이미 좋아요한 상품에 다시 POST 요청 시 어떻게 처리할 것인가? (멱등성) | **토글 처리** — 이미 좋아요 상태면 취소, 좋아요하지 않은 상태면 등록. 409/404 없음 | +### 범위 제외 사항 -### 상품 (Product) +| 제외 항목 | 사유 | +|---|---| +| 유저(Users) 기능 | 회원가입, 내 정보 조회, 비밀번호 변경은 이미 구현 완료 | +| 결제(Payment) | 향후 별도 단계에서 추가 개발 예정 | +| 쿠폰(Coupon) | 향후 별도 단계에서 추가 개발 예정 | +| 주문 상태 전이 | 결제 기능과 함께 추가. 현재는 주문 생성(ORDERED)만 다룬다 | +| 주문 취소 | 현재 범위에서 주문 취소 기능은 제공하지 않는다 | -| # | 질문 | 결정 | -|---|------|------| -| 15 | 상품 재고(stock)를 어떻게 관리할 것인가? | **Product에 stock 필드 포함** — 별도 Stock 도메인 분리 없이 단순하게 관리 | -### 문서 작성 +# 2. 공통 정의 -| # | 질문 | 결정 | -|---|------|------| -| 16 | 설계 문서 저장 위치는? (.claude/ vs docs/design/) | **docs/design/** — 과제 제출 요구사항 기준 | -| 17 | 유비쿼터스 언어(도메인 용어집)를 포함할 것인가? | **포함** — 리뷰어에게 좋은 인상 + 도메인 소통 기준 | -| 18 | 설계 결정 근거를 포함할 것인가? | **포함** — "왜 이렇게 판단했는가"가 드러나는 문서 | +### 권한 정의 +| 액터 | 설명 | 식별 방식 | +|------|------|----------| +| 비회원 (Guest) | 로그인하지 않은 사용자 | 헤더 없음 | +| 회원 (User) | 로그인한 사용자 | `X-Loopers-LoginId`, `X-Loopers-LoginPw` | +| 관리자 (Admin) | 사내 관리자 | `X-Loopers-Ldap: loopers.admin` | ---- ---- +### API Prefix 규칙 +- 대고객 API: `/api/v1` +- 어드민 API: `/api-admin/v1` -## 1. 도메인 용어집 (Ubiquitous Language) +### 도메인 용어집 (Ubiquitous Language) | 한글 | 영문 | 설명 | |------|------|------| -| 회원 | User (Member) | 서비스에 가입한 사용자. 1주차에 구현 완료 (본 설계 범위 제외) | +| 회원 | User | 서비스에 가입한 사용자. 1주차에 구현 완료 (본 설계 범위 제외) | | 브랜드 | Brand | 상품을 판매하는 브랜드. Admin이 등록/관리 | | 상품 | Product | 브랜드에 속한 판매 상품. 재고(stock) 포함 | | 재고 | stock | 상품의 현재 판매 가능 수량. Product의 필드로 관리 | | 품절 | Sold Out | 상품 재고(stock)가 0인 상태 | -| 좋아요 | ProductLike | 회원이 상품에 대해 표현하는 선호. 회원당 상품당 1개. 별도 테이블로 관리 | +| 좋아요 | Like | 회원이 상품에 대해 표현하는 선호. 회원당 상품당 1개. 별도 테이블로 관리 | | 장바구니 | Cart | 회원이 구매 전 상품을 담아두는 보관함 | | 장바구니 항목 | CartItem | 장바구니에 담긴 개별 상품과 수량 | | 주문 | Order | 회원이 상품을 구매하기 위한 요청 | @@ -74,58 +69,47 @@ | Soft Delete | - | deleted_at 컬럼으로 논리 삭제. 물리적으로는 데이터 유지 | | Admin | Admin | LDAP 인증 기반 사내 관리자 | ---- - -## 2. 설계 범위 - -### 포함 도메인 -- 브랜드 (Brand) -- 상품 (Product) -- 좋아요 (ProductLike) -- 장바구니 (Cart) -- **추가 기능** -- 주문 (Order) - -### 제외 도메인 -- 회원 (User): 1주차에 구현 완료 -- 결제 (Payment): 추후 개발 예정 -- 쿠폰 (Coupon): 추후 개발 예정 - ---- - -## 3. 액터 및 API 식별 규칙 +### 도메인 참조 원칙 -### 액터 - -| 액터 | 설명 | 식별 방식 | -|------|------|----------| -| 비회원 (Guest) | 로그인하지 않은 사용자 | 헤더 없음 | -| 회원 (User) | 로그인한 사용자 | `X-Loopers-LoginId`, `X-Loopers-LoginPw` | -| 관리자 (Admin) | 사내 관리자 | `X-Loopers-Ldap: loopers.admin` | - -### API Prefix 규칙 -- 대고객 API: `/api/v1` -- 어드민 API: `/api-admin/v1` - -### 인증/인가 -- 인증/인가 로직은 구현하지 않음 (주요 스코프 아님) -- 헤더 기반으로 사용자를 식별만 함 -- 회원은 타 회원의 정보에 직접 접근할 수 없음 - ---- - -## 4. 도메인별 요구사항 +- **DB FK 제약 미사용** — 테이블 간 외래키 제약조건을 사용하지 않는다. 무결성은 애플리케이션 레벨에서 보장. + - FK의 문제: 잠금 전파(데드락 위험), 삭제 순서 강제, 테이블 간 결합 +- **DB 유니크 제약 사용** — 테이블 내부 제약은 사용한다 (FK와 성격이 다름). 동시성(더블클릭 등) 시 중복 방지. +- **참조 방식** + - 같은 도메인 (Brand → Product): 객체참조 + FK 없음 (`@ManyToOne` + `ConstraintMode.NO_CONSTRAINT`) + - 다른 도메인 간: ID 참조 (`private Long userId` 등) +- **Aggregate** — 각 도메인은 독립 Aggregate Root. `@OneToMany` 사용하지 않음. Aggregate 규칙은 Service에서 `@Transactional`로 관리. --- -### 4.1 브랜드 (Brand) +# 3. 기능 요구 사항 -#### 유저 스토리 +## 3.1 브랜드 & 상품 -> **비회원으로서**, 브랜드 정보를 조회할 수 있다. 로그인 없이도 브랜드를 탐색하고 싶다. +> **비회원으로서**, 브랜드 정보를 조회하고 상품 목록을 둘러보고 상세 정보를 확인할 수 있다. > -> **관리자로서**, 브랜드를 등록/수정/삭제하여 서비스에 입점할 브랜드를 관리할 수 있다. - -#### 기능 목록 +> **관리자로서**, 브랜드와 상품을 등록/수정/삭제하여 서비스에 입점할 브랜드와 판매 상품을 관리할 수 있다. + +### 예외 및 정책 + +- **삭제 전략: Soft Delete** — `deleted_at` 컬럼으로 논리 삭제. 복구 가능성을 열어두고, 연관 데이터(장바구니/좋아요)는 조회 시 필터링으로 처리하여 트랜잭션 범위를 줄인다. +- **브랜드 삭제 연쇄 처리** — 브랜드 soft delete 시 해당 브랜드의 상품도 전체 soft delete. 장바구니/좋아요는 즉시 삭제하지 않고 조회 시점에 필터링. + ``` + 브랜드 soft delete + └→ 해당 브랜드의 상품 전체 soft delete + └→ 장바구니 항목: 조회 시 필터링 + └→ 좋아요: 조회 시 필터링 + ``` +- **브랜드명 중복 불가** — 동일한 브랜드명이 이미 존재하면 등록/수정 실패 (409 Conflict) +- **상품 재고: Product 필드로 관리** — 별도 Stock 도메인 분리 없이 Product 엔티티의 stock 필드로 관리. 등록/수정 시 재고 설정, 주문 시 차감. +- **고객 vs Admin 응답 차이** — 고객에게는 기본 정보만, Admin에게는 등록일/수정일/삭제 여부 등 관리 정보 추가 제공 +- **soft delete된 브랜드/상품** — 고객 조회 불가 (404 반환) +- **Brand → Product 참조** — 객체참조 + FK 없음. 코드에서 `product.getBrand().getName()` 접근 가능하되, DB에 FK 제약조건은 생성하지 않음. +- **각각 독립 Aggregate Root** — Brand와 Product는 별도 Aggregate Root. 브랜드 삭제 → 상품 soft delete는 Facade에서 조율. +- **Product.likeCount 캐시 필드** — 찜 수 조회 성능을 위해 Product에 likeCount 캐싱. 찜/취소 시 원자적 증감. + +### API + +**브랜드** | 기능 | 액터 | Method | URI | 인증 | |------|------|--------|-----|------| @@ -136,17 +120,35 @@ | 브랜드 정보 수정 | Admin | PUT | `/api-admin/v1/brands/{brandId}` | LDAP | | 브랜드 삭제 | Admin | DELETE | `/api-admin/v1/brands/{brandId}` | LDAP | -> **고객 vs Admin 응답 차이**: 고객에게는 브랜드명, 설명 등 기본 정보만 제공. -> Admin에게는 등록일, 수정일, 삭제 여부, 소속 상품 수 등 관리 정보도 추가로 제공. +**상품** -#### 유스케이스 흐름 +| 기능 | 액터 | Method | URI | 인증 | +|------|------|--------|-----|------| +| 상품 목록 조회 | 비회원/회원 | GET | `/api/v1/products` | X | +| 상품 정보 조회 | 비회원/회원 | GET | `/api/v1/products/{productId}` | X | +| 상품 목록 조회 | Admin | GET | `/api-admin/v1/products?page=0&size=20&brandId={brandId}` | LDAP | +| 상품 상세 조회 | Admin | GET | `/api-admin/v1/products/{productId}` | LDAP | +| 상품 등록 | Admin | POST | `/api-admin/v1/products` | LDAP | +| 상품 정보 수정 | Admin | PUT | `/api-admin/v1/products/{productId}` | LDAP | +| 상품 삭제 | Admin | DELETE | `/api-admin/v1/products/{productId}` | LDAP | + +**상품 목록 조회 쿼리 파라미터** + +| 파라미터 | 설명 | 기본값 | +|----------|------|--------| +| `brandId` | 특정 브랜드 상품 필터링 | - (선택) | +| `sort` | 정렬 기준: `latest` / `price_asc` / `likes_desc` | `latest` | +| `page` | 페이지 번호 | 0 | +| `size` | 페이지당 상품 수 | 20 | + +> `sort`는 `latest` 필수, `price_asc` / `likes_desc`는 선택 구현. +> `likes_desc` 정렬 시 좋아요 수는 Product.likeCount 필드로 정렬. + +### 유즈케이스 **UC-B01: 브랜드 정보 조회 (비회원)** ``` -[유저 스토리] -- 비회원이 특정 브랜드의 정보를 확인할 수 있다. - [기능 흐름] 1. 비회원이 brandId로 브랜드 정보를 요청한다 2. 해당 브랜드가 존재하는지 확인한다 @@ -160,9 +162,6 @@ **UC-B02: 브랜드 등록 (Admin)** ``` -[유저 스토리] -- Admin이 새로운 브랜드를 서비스에 등록할 수 있다. - [기능 흐름] 1. Admin이 브랜드 정보(이름 등)를 입력한다 2. 동일한 브랜드명이 이미 존재하는지 확인한다 @@ -179,9 +178,6 @@ **UC-B03: 브랜드 정보 수정 (Admin)** ``` -[유저 스토리] -- Admin이 브랜드의 정보를 수정할 수 있다. - [기능 흐름] 1. Admin이 brandId와 수정할 정보를 요청한다 2. 해당 브랜드가 존재하는지 확인한다 @@ -195,9 +191,6 @@ **UC-B04: 브랜드 삭제 (Admin)** ``` -[유저 스토리] -- Admin이 브랜드를 삭제하면, 해당 브랜드의 상품들도 함께 삭제된다. - [기능 흐름] 1. Admin이 brandId로 삭제를 요청한다 2. 해당 브랜드가 존재하는지 확인한다 @@ -207,104 +200,38 @@ [예외] - brandId에 해당하는 브랜드가 없으면 404 반환 - 이미 삭제된 브랜드이면 404 반환 - -[후속 동작] -- 장바구니/좋아요는 즉시 삭제하지 않음 -- 조회 시점에 상품/브랜드의 deleted_at을 체크하여 필터링 ``` -> **연쇄 처리 범위**: -> ``` -> 브랜드 soft delete -> └→ 해당 브랜드의 상품 전체 soft delete -> └→ 장바구니 항목: 조회 시 필터링 -> └→ 좋아요: 조회 시 필터링 -> ``` - ---- - -### 4.2 상품 (Product) - -#### 유저 스토리 - -> **비회원으로서**, 상품 목록을 둘러보고 상세 정보를 확인할 수 있다. -> -> **관리자로서**, 상품을 등록/수정/삭제하여 판매 상품을 관리할 수 있다. -> 상품 등록 시 재고(stock)를 설정하고, 수정 시 재고를 변경할 수 있다. - -#### 기능 목록 - -| 기능 | 액터 | Method | URI | 인증 | -|------|------|--------|-----|------| -| 상품 목록 조회 | 비회원/회원 | GET | `/api/v1/products` | X | -| 상품 정보 조회 | 비회원/회원 | GET | `/api/v1/products/{productId}` | X | -| 상품 목록 조회 | Admin | GET | `/api-admin/v1/products?page=0&size=20&brandId={brandId}` | LDAP | -| 상품 상세 조회 | Admin | GET | `/api-admin/v1/products/{productId}` | LDAP | -| 상품 등록 | Admin | POST | `/api-admin/v1/products` | LDAP | -| 상품 정보 수정 | Admin | PUT | `/api-admin/v1/products/{productId}` | LDAP | -| 상품 삭제 | Admin | DELETE | `/api-admin/v1/products/{productId}` | LDAP | - -#### 상품 목록 조회 쿼리 파라미터 - -| 파라미터 | 설명 | 기본값 | -|----------|------|--------| -| `brandId` | 특정 브랜드 상품 필터링 | - (선택) | -| `sort` | 정렬 기준: `latest` / `price_asc` / `likes_desc` | `latest` | -| `page` | 페이지 번호 | 0 | -| `size` | 페이지당 상품 수 | 20 | - -> `sort`는 `latest` 필수, `price_asc` / `likes_desc`는 선택 구현. -> `likes_desc` 정렬 시 좋아요 수는 likes 테이블 COUNT로 산출. - -#### 유스케이스 흐름 - **UC-P01: 상품 목록 조회 (비회원)** ``` -[유저 스토리] -- 비회원이 상품 목록을 둘러볼 수 있다. -- 브랜드별 필터링, 정렬, 페이지네이션을 지원한다. - [기능 흐름] 1. 비회원이 상품 목록을 요청한다 (선택: brandId, sort, page, size) 2. soft delete된 상품/브랜드를 제외한다 3. 정렬 조건에 맞게 정렬한다 4. 페이지네이션하여 상품 목록을 반환한다 -5. 각 상품의 좋아요 수를 likes 테이블 COUNT로 함께 반환한다 +5. 각 상품의 좋아요 수를 Product.likeCount로 함께 반환한다 [대안 흐름] - brandId가 없으면 전체 상품 조회 - sort가 없으면 latest(최신순) 기본 적용 - -[조건] -- soft delete된 상품은 목록에서 제외 -- soft delete된 브랜드의 상품도 목록에서 제외 ``` **UC-P02: 상품 정보 조회 (비회원)** ``` -[유저 스토리] -- 비회원이 특정 상품의 상세 정보를 확인할 수 있다. - [기능 흐름] 1. 비회원이 productId로 상품 정보를 요청한다 2. 해당 상품이 존재하는지 확인한다 -3. 상품 정보와 함께 좋아요 수(COUNT)를 반환한다 +3. 상품 정보와 함께 좋아요 수(Product.likeCount)를 반환한다 [예외] - productId에 해당하는 상품이 없거나 삭제된 경우 404 반환 - -[조건] -- 좋아요 수는 likes 테이블에서 해당 상품의 COUNT 쿼리로 산출 ``` **UC-P03: 상품 등록 (Admin)** ``` -[유저 스토리] -- Admin이 특정 브랜드에 새 상품을 등록할 수 있다. - [기능 흐름] 1. Admin이 상품 정보를 입력한다 (brandId, 상품명, 가격, 재고 등) 2. brandId에 해당하는 브랜드가 존재하는지 확인한다 @@ -322,10 +249,6 @@ **UC-P04: 상품 정보 수정 (Admin)** ``` -[유저 스토리] -- Admin이 상품의 정보(이름, 가격, 재고 등)를 수정할 수 있다. -- 단, 상품이 속한 브랜드는 변경할 수 없다. - [기능 흐름] 1. Admin이 productId와 수정할 정보를 요청한다 2. 해당 상품이 존재하는지 확인한다 @@ -342,9 +265,6 @@ **UC-P05: 상품 삭제 (Admin)** ``` -[유저 스토리] -- Admin이 상품을 삭제할 수 있다. - [기능 흐름] 1. Admin이 productId로 삭제를 요청한다 2. 해당 상품이 존재하는지 확인한다 @@ -352,22 +272,27 @@ [예외] - productId에 해당하는 상품이 없거나 이미 삭제된 경우 404 반환 - -[후속 동작] -- 장바구니/좋아요는 즉시 삭제하지 않음 -- 조회 시점에 필터링으로 처리 ``` --- -### 4.3 좋아요 (ProductLike) - -#### 유저 스토리 +## 3.2 좋아요 > **회원으로서**, 마음에 드는 상품에 좋아요를 눌러 선호를 표현하고, 나중에 다시 찾아볼 수 있다. > 이미 좋아요한 상품은 취소할 수 있다. -#### 기능 목록 +### 예외 및 정책 + +- **좋아요 수: Product.likeCount 캐시** — Like 엔티티가 원본 데이터, Product.likeCount는 조회 성능을 위한 파생값(derived data). 찜/취소 시 원자적 증감. 모든 상품 조회 API에서 서브쿼리 없이 사용. +- **API 방식: 엔드포인트 분리 + 내부 토글** — POST/DELETE 엔드포인트는 미션 스펙대로 분리하되, 내부적으로 같은 Facade 메서드(toggleLike)를 호출. 409/404 없음. +- **회원당 상품당 1개** — userId + productId DB 유니크 제약. 동시성(더블클릭) 시에도 중복 방지. +- **삭제된 상품/브랜드의 좋아요** — 목록 조회 시 필터링으로 제외. +- **상품 검증 항상 수행** — 등록/취소 모두 ProductService로 상품 존재 + 삭제 여부 확인. 삭제된 상품에 대한 좋아요 조작 방지. +- **참조 방식** — 모두 ID 참조 (userId, productId). +- **User 탈퇴 시** — Like 삭제 + Product.likeCount 감소. +- **Product 삭제 시** — Like 삭제. + +### API | 기능 | 액터 | Method | URI | 인증 | |------|------|--------|-----|------| @@ -375,19 +300,11 @@ | 상품 좋아요 취소 | 회원 | DELETE | `/api/v1/products/{productId}/likes` | O | | 내가 좋아요한 상품 목록 조회 | 회원 | GET | `/api/v1/users/{userId}/likes` | O | -> **좋아요 수 관리**: 별도 likeCount 컬럼 없이, 조회 시 likes 테이블 COUNT 쿼리로 산출. -> 상품 조회/목록 응답에 좋아요 수가 포함됨. -> -> **토글 방식**: POST/DELETE 엔드포인트는 분리하되, 내부적으로 같은 토글 로직(toggleLike)을 호출합니다. - -#### 유스케이스 흐름 +### 유즈케이스 **UC-L01: 상품 좋아요 토글 (등록/취소)** ``` -[유저 스토리] -- 회원이 마음에 드는 상품에 좋아요를 누르면 등록되고, 다시 요청하면 취소된다. - [기능 흐름] 1. 회원이 productId로 좋아요를 요청한다 (POST 또는 DELETE) 2. 해당 상품이 존재하는지 확인한다 (삭제된 상품 불가) @@ -409,9 +326,6 @@ **UC-L02: 내가 좋아요한 상품 목록 조회** ``` -[유저 스토리] -- 회원이 자신이 좋아요 누른 상품 목록을 확인할 수 있다. - [기능 흐름] 1. 회원이 자신의 좋아요 목록을 요청한다 2. likes 테이블에서 해당 회원의 좋아요 목록을 조회한다 @@ -426,136 +340,7 @@ --- -### 4.4 장바구니 (Cart) -- 추가 기능 - -#### 유저 스토리 - -> **회원으로서**, 구매하고 싶은 상품을 장바구니에 담아두고 나중에 한 번에 확인할 수 있다. -> 담은 상품의 수량을 변경하거나 제거할 수 있다. -> 장바구니에 품절 상품이 있으면 품절 상태로 보여주고, 삭제된 상품/브랜드는 자동으로 걸러진다. - -#### 기능 목록 - -| 기능 | 액터 | Method | URI | 인증 | -|------|------|--------|-----|------| -| 장바구니에 상품 담기 | 회원 | POST | `/api/v1/carts` | O | -| 장바구니 목록 조회 | 회원 | GET | `/api/v1/carts?page=0&size=20` | O | -| 장바구니 수량 변경 | 회원 | PUT | `/api/v1/carts/{cartItemId}` | O | -| 장바구니 항목 제거 | 회원 | DELETE | `/api/v1/carts/{cartItemId}` | O | - -#### 유스케이스 흐름 - -**UC-C01: 장바구니에 상품 담기** - -``` -[유저 스토리] -- 회원이 상품을 장바구니에 담을 수 있다. -- 같은 상품을 다시 담으면 수량이 합산된다. - -[기능 흐름] -1. 회원이 productId와 quantity(필수)로 담기를 요청한다 -2. 해당 상품이 존재하는지 확인한다 (삭제된 상품 불가) -3. 장바구니에 같은 상품이 이미 있는지 확인한다 -4-a. 없으면: 새 CartItem을 저장한다 -4-b. 있으면: 기존 수량에 요청 수량을 합산한다 - -[예외] -- productId에 해당하는 상품이 없거나 삭제된 경우 실패 -- 합산 후 수량이 99를 초과하면 실패 -- 장바구니에 이미 100종류가 담겨있으면 신규 상품 담기 실패 - -[조건] -- quantity는 필수값 (기본값 없음), 1 이상 -- 담을 때 재고는 확인하지 않음 (주문 시점에 확인) -- 가격은 저장하지 않음 (조회 시 현재 가격 사용) -- 로그인한 회원만 가능 -``` - -**UC-C02: 장바구니 목록 조회** - -``` -[유저 스토리] -- 회원이 자신의 장바구니를 확인할 수 있다. -- 품절 상품은 품절로 표시되고, 삭제된 상품은 자동으로 걸러진다. - -[기능 흐름] -1. 회원이 장바구니 목록을 요청한다 (page, size) -2. 해당 회원의 장바구니 항목을 조회한다 -3. 각 항목의 상품/브랜드가 삭제되었는지 확인한다 -4. 삭제된 상품/브랜드의 항목은 목록에서 제외한다 -5. 품절(stock=0) 상품은 품절 상태를 표시한다 -6. 상품의 현재 정보(이름, 가격, 재고 상태)와 함께 반환한다 - -[조건] -- 가격은 항상 현재 상품 가격 기준 (장바구니에 가격 저장 안 함) -- 페이지네이션 적용 (장바구니 최대 100종류) -- 본인의 장바구니만 조회 가능 -- 로그인한 회원만 가능 -``` - -**UC-C03: 장바구니 수량 변경** - -``` -[유저 스토리] -- 회원이 장바구니에 담긴 상품의 수량을 변경할 수 있다. - -[기능 흐름] -1. 회원이 cartItemId와 변경할 quantity를 요청한다 -2. 해당 장바구니 항목이 존재하는지 확인한다 -3. 본인의 장바구니 항목인지 확인한다 -4. 수량을 업데이트한다 - -[예외] -- cartItemId에 해당하는 항목이 없으면 404 반환 -- 수량이 1 미만이면 실패 (최소 1) -- 수량이 99 초과이면 실패 (최대 99) - -[조건] -- 수량 0으로 변경 불가 → 제거는 DELETE API로만 가능 -- 본인의 장바구니 항목만 수정 가능 -- 로그인한 회원만 가능 -``` - -**UC-C04: 장바구니 항목 제거** - -``` -[유저 스토리] -- 회원이 장바구니에서 상품을 제거할 수 있다. - -[기능 흐름] -1. 회원이 cartItemId로 제거를 요청한다 -2. 해당 장바구니 항목이 존재하는지 확인한다 -3. 본인의 장바구니 항목인지 확인한다 -4. 해당 항목을 삭제한다 - -[예외] -- cartItemId에 해당하는 항목이 없으면 404 반환 - -[조건] -- 본인의 장바구니 항목만 제거 가능 -- 로그인한 회원만 가능 -``` - -#### 장바구니 → 주문 관계 - -장바구니와 주문은 **서버 도메인 간 독립적**이다. - -``` -[클라이언트 레벨 전환 흐름] -1. 클라이언트가 GET /api/v1/carts 로 장바구니 조회 -2. 사용자가 주문할 상품을 선택 -3. 클라이언트가 POST /api/v1/orders 에 items를 직접 조립하여 요청 -4. 주문 성공 후 클라이언트가 DELETE /api/v1/carts/{cartItemId} 로 정리 -``` - -- 서버에서 Cart 도메인과 Order 도메인은 서로 참조하지 않음 -- 별도의 "장바구니에서 주문" API 없음 - ---- - -### 4.5 주문 (Order) - -#### 유저 스토리 +## 3.3 주문 > **회원으로서**, 여러 상품을 한 번에 주문할 수 있다. > 주문 시 상품 재고가 확인되고 차감된다. @@ -563,7 +348,20 @@ > > **관리자로서**, 전체 주문 내역을 조회할 수 있다. -#### 기능 목록 +### 예외 및 정책 + +- **재고 확인 + 차감 원자적 처리** — 재고 확인과 차감은 하나의 트랜잭션 안에서 원자적으로 수행. 일괄 처리 방식(IN 쿼리). +- **스냅샷 저장** — 주문 시점의 상품 정보(상품명, 가격, 브랜드명)를 OrderItem에 복사. 이후 상품이 변경/삭제되어도 주문 내역은 보존. +- **재고 부족 시 주문 전체 실패** — 하나의 상품이라도 재고 부족이면 주문 전체가 롤백. 부분 성공 없음. +- **items 비어있으면 실패** — 주문 항목이 없는 요청은 거부. +- **동시성 이슈** — 추후 비관적 락 또는 낙관적 락으로 해결 예정. +- **가격 변동 검증** — 주문 시점에 클라이언트가 보낸 expectedPrice와 Product의 현재 가격을 비교. 불일치 시 주문 실패 ("가격이 변경되었습니다"). 하이패션 고가 상품의 가격 분쟁 방지. +- **두 가지 주문 경로** — 바로구매(상품 페이지에서 직접)와 장바구니 주문. Order 도메인은 출처를 모르고, Facade가 경로를 조율. 주문 로직은 단일. +- **스냅샷 구조** — OrderItem에 @Embedded ProductSnapshot (productName, brandName, imageUrl 등). productId는 별도 유지 (재구매, 통계용, FK 아님). +- **Order ↔ OrderItem** — ID 참조 (orderId). @OneToMany 미사용. 같은 Aggregate이지만 프로젝트 전체 ID 참조 패턴과 일관성 유지. +- **User 탈퇴 시** — 주문 데이터 DB 유지 (비즈니스 기록). UserSnapshot 불필요 (탈퇴한 유저는 조회 주체가 사라짐). + +### API | 기능 | 액터 | Method | URI | 인증 | |------|------|--------|-----|------| @@ -573,55 +371,47 @@ | 주문 목록 조회 | Admin | GET | `/api-admin/v1/orders?page=0&size=20` | LDAP | | 주문 상세 조회 | Admin | GET | `/api-admin/v1/orders/{orderId}` | LDAP | -#### 주문 요청 본문 예시 +**주문 요청 본문 예시** ```json { "items": [ - { "productId": 1, "quantity": 2 }, - { "productId": 3, "quantity": 1 } + { "productId": 1, "quantity": 2, "expectedPrice": 50000 }, + { "productId": 3, "quantity": 1, "expectedPrice": 120000 } ] } ``` -#### 유스케이스 흐름 +### 유즈케이스 **UC-O01: 주문 요청** ``` -[유저 스토리] -- 회원이 여러 상품을 한 번에 주문할 수 있다. -- 주문 시 재고가 확인되고 차감된다. -- 주문 정보에는 당시 상품 정보가 스냅샷으로 저장된다. - [기능 흐름] -1. 회원이 상품 목록(productId, quantity)으로 주문을 요청한다 +1. 회원이 상품 목록(productId, quantity, expectedPrice)으로 주문을 요청한다 2. 각 상품이 존재하는지 확인한다 (삭제된 상품 불가) -3. 각 상품의 재고가 충분한지 확인한다 -4. 재고를 차감한다 (원자적 처리) -5. 주문 시점의 상품 정보를 스냅샷으로 저장한다 (상품명, 가격, 브랜드명 등) -6. 주문을 생성한다 +3. 각 상품의 expectedPrice와 현재 가격을 비교한다 (불일치 시 실패) +4. 각 상품의 재고가 충분한지 확인한다 +5. 재고를 차감한다 (원자적 처리) +6. 주문 시점의 상품 정보를 스냅샷으로 저장한다 (ProductSnapshot: 상품명, 브랜드명, 이미지 등) +7. 주문을 생성한다 [예외] - 상품이 존재하지 않거나 삭제된 경우 주문 실패 +- expectedPrice와 현재 가격이 불일치하면 주문 실패 - 재고가 부족한 상품이 하나라도 있으면 주문 전체 실패 - items가 비어있으면 주문 실패 [조건] - 로그인한 회원만 가능 +- 바로구매/장바구니 주문 모두 같은 API 사용 (Order 도메인은 출처를 모름) - 재고 확인과 차감은 원자적으로 처리되어야 함 - 동시성 이슈는 추후 해결 (비관적 락 또는 낙관적 락) ``` -> **스냅샷의 이유**: 주문 이후 상품 가격이 변경되거나 상품/브랜드가 삭제되어도, -> 주문 내역에는 주문 당시 정보가 그대로 남아야 한다. - **UC-O02: 주문 목록 조회 (회원)** ``` -[유저 스토리] -- 회원이 특정 기간의 자신의 주문 내역을 확인할 수 있다. - [기능 흐름] 1. 회원이 기간(startAt, endAt)을 지정하여 주문 목록을 요청한다 2. 해당 기간 내 본인의 주문 목록을 반환한다 @@ -634,10 +424,6 @@ **UC-O03: 주문 상세 조회 (회원)** ``` -[유저 스토리] -- 회원이 특정 주문의 상세 내역을 확인할 수 있다. -- 주문 당시의 상품 정보(스냅샷)가 표시된다. - [기능 흐름] 1. 회원이 orderId로 주문 상세를 요청한다 2. 해당 주문이 존재하는지 확인한다 @@ -655,127 +441,123 @@ --- -## 5. 설계 결정 사항 - -이 프로젝트에서 내린 주요 설계 결정과 그 근거를 정리합니다. - -### 5.1 삭제 전략: Soft Delete - -**결정**: 브랜드/상품 삭제 시 `deleted_at` 컬럼을 사용한 논리 삭제 - -**근거**: -- 주문에 스냅샷이 남지만, 관리자가 삭제된 브랜드/상품 이력을 확인할 필요가 있을 수 있음 -- 실수로 삭제한 경우 복구 가능성을 열어둠 -- 장바구니/좋아요 등 연관 데이터를 즉시 삭제하지 않고 **조회 시 필터링**으로 처리하여 트랜잭션 범위를 줄임 - -**트레이드오프**: -- 모든 조회 쿼리에 `deleted_at IS NULL` 조건이 추가됨 -- 데이터가 물리적으로 남아있어 스토리지를 차지함 - -### 5.2 브랜드 삭제 연쇄 처리: 조회 시 필터링 - -**결정**: 브랜드 삭제 시 상품은 함께 soft delete 하되, 장바구니/좋아요는 **조회 시점에 필터링** - -**근거**: -- 브랜드 삭제 트랜잭션이 비대해지는 것을 방지 -- 장바구니/좋아요까지 한 트랜잭션에서 삭제하면 도메인 간 결합도가 높아짐 -- Soft Delete + 조회 시 필터링 방식이 일관된 접근 - -### 5.3 좋아요 수 관리: 조회 시 COUNT 쿼리 - -**결정**: Product에 별도 likeCount 컬럼을 두지 않고, 조회 시 likes 테이블 COUNT 쿼리로 산출 - -**근거**: -- 데이터 정합성 100% (카운트 불일치 문제 없음) -- 구현 단순 (등록/취소 시 카운트 업데이트 로직 불필요) -- 동시성 이슈 없음 +## 3.4 장바구니 -**트레이드오프**: -- 상품 목록 조회 시 JOIN + COUNT 쿼리 비용 발생 -- 대량 데이터 시 성능 이슈 가능 → 필요 시 likeCount 캐싱 컬럼 도입 고려 - -### 5.4 좋아요 API: 엔드포인트 분리 + 내부 토글 - -**결정**: POST/DELETE 엔드포인트는 미션 스펙대로 분리하되, 내부적으로 같은 Facade 메서드(toggleLike)를 호출 - -**근거**: -- 미션 요구사항 API 스펙 준수 (POST/DELETE 엔드포인트 유지) -- 내부 로직 단순화 (등록/취소 분기 없이 토글 1개 메서드) -- 상품 검증은 항상 수행 (삭제된 상품에 대한 좋아요 조작 방지) - -**토글 동작**: -- 좋아요가 없는 상태에서 요청 → 좋아요 등록 -- 좋아요가 있는 상태에서 요청 → 좋아요 취소 -- 409 Conflict, 404 Not Found(좋아요 미등록) 없음 - -### 5.5 장바구니 가격: 현재 가격 기준 - -**결정**: 장바구니에 가격을 저장하지 않고, 조회 시 항상 현재 상품 가격을 사용 +> **회원으로서**, 구매하고 싶은 상품을 장바구니에 담아두고 나중에 한 번에 확인할 수 있다. +> 담은 상품의 수량을 변경하거나 제거할 수 있다. +> 장바구니에 품절 상품이 있으면 품절 상태로, 삭제된 상품은 판매 종료 상태로 보여준다. + +### 예외 및 정책 + +- **Cart 엔티티 미사용** — DB에 Cart 테이블 없음. CartItem만 DB 엔티티. Cart는 코드에서 일급 컬렉션(First-Class Collection)으로 표현하여 "전체 가격 계산", "선택 항목 추출" 등 장바구니 단위 행위를 응집. +- **가격: 현재 가격 기준** — CartItem에 가격을 저장하지 않음 (가격의 원천은 항상 Product). 조회 시 항상 현재 상품 가격 사용. 하이패션 시즌 세일 시 장바구니에 담아둔 상품의 세일 가격이 자동 반영. +- **재고: 담기 시 미확인** — 장바구니에 담을 때 재고는 확인하지 않음. 주문 시점에만 확인. 장바구니는 "보관함" 성격. +- **주문과 독립** — Cart 도메인과 Order 도메인은 서로를 모른다. Facade가 경로를 조율. + ``` + [장바구니 → 주문 흐름] + 장바구니 → CartItem 조회 → OrderItemCommand 변환 → OrderService 호출 → CartItem 삭제 + + [바로구매 흐름] + 상품 페이지 → OrderItemCommand 직접 생성 → OrderService 호출 + ``` +- **품절 상품** — 장바구니에서 자동 제거하지 않음. 품절 표시하고 유저가 직접 제거. 하이패션에서 신중하게 골라 담은 상품이 자동으로 사라지면 UX 저하. +- **삭제된 상품(SoftDelete)** — 판매 종료 표시 + 주문 불가. SoftDelete이므로 상품 데이터가 남아있어 표시 가능. +- **CartItem 유니크 제약** — userId + productId DB 유니크 제약. 동시성(더블클릭) 시에도 중복 CartItem 방지. +- **참조 방식** — ID 참조 (userId, productId). 스냅샷 불필요 (항상 Product에서 현재 정보 조회). +- **User 탈퇴 시** — CartItem 삭제. +- **제약 조건** + + | 제약 | 값 | 근거 | + |------|-----|------| + | 상품당 최대 수량 | 99개 | 비정상 요청 방어 | + | 장바구니 최대 종류 | 100종 | 합리적 상한 + 페이지네이션 적용 | + | 최소 수량 | 1개 | 수량 0은 불가. 제거는 DELETE API로 명확히 분리 | + | quantity | 필수값 | 기본값 없음. 클라이언트가 명시적으로 전달 | + +### API -**근거**: -- 장바구니는 "보관함" 성격. 가격이 확정되는 시점은 주문 시점 -- 장바구니에 가격을 저장하면 가격 변동 시 동기화 문제 발생 -- 주문에서 스냅샷으로 가격이 확정되므로, 장바구니까지 가격을 관리할 필요 없음 +| 기능 | 액터 | Method | URI | 인증 | +|------|------|--------|-----|------| +| 장바구니에 상품 담기 | 회원 | POST | `/api/v1/carts` | O | +| 장바구니 목록 조회 | 회원 | GET | `/api/v1/carts?page=0&size=20` | O | +| 장바구니 수량 변경 | 회원 | PUT | `/api/v1/carts/{cartItemId}` | O | +| 장바구니 항목 제거 | 회원 | DELETE | `/api/v1/carts/{cartItemId}` | O | -### 5.6 장바구니 담기 시 재고 미확인 +### 유즈케이스 -**결정**: 장바구니에 담을 때는 재고를 확인하지 않고, 주문 시점에만 확인 +**UC-C01: 장바구니에 상품 담기** -**근거**: -- 장바구니는 "위시리스트"에 가까운 성격 -- 담을 때 재고를 확인해도, 주문 시점까지 재고가 변동될 수 있어 의미가 제한적 -- 구현 복잡도를 낮추면서 주문 시점 검증으로 데이터 정합성 보장 +``` +[기능 흐름] +1. 회원이 productId와 quantity(필수)로 담기를 요청한다 +2. 해당 상품이 존재하는지 확인한다 (삭제된 상품 불가) +3. 장바구니에 같은 상품이 이미 있는지 확인한다 +4-a. 없으면: 새 CartItem을 저장한다 +4-b. 있으면: 기존 수량에 요청 수량을 합산한다 -### 5.7 장바구니와 주문: 서버 도메인 간 독립 +[예외] +- productId에 해당하는 상품이 없거나 삭제된 경우 실패 +- 합산 후 수량이 99를 초과하면 실패 +- 장바구니에 이미 100종류가 담겨있으면 신규 상품 담기 실패 -**결정**: 장바구니에서 주문으로의 전환은 클라이언트 레벨에서 처리. 서버에서 Cart와 Order 도메인은 서로 참조하지 않음 +[조건] +- quantity는 필수값 (기본값 없음), 1 이상 +- 담을 때 재고는 확인하지 않음 (주문 시점에 확인) +- 가격은 저장하지 않음 (조회 시 현재 가격 사용) +- 로그인한 회원만 가능 +``` -**근거**: -- 도메인 간 결합도를 0으로 유지 -- 별도의 전환 API 없이 기존 주문 API 재사용 -- 장바구니 기능이 변경되어도 주문에 영향 없음, 반대도 마찬가지 +**UC-C02: 장바구니 목록 조회** -### 5.8 장바구니 제약 조건 +``` +[기능 흐름] +1. 회원이 장바구니 목록을 요청한다 (page, size) +2. 해당 회원의 장바구니 항목을 조회한다 +3. 각 항목의 상품/브랜드 상태를 확인한다 +4. 품절(stock=0) 상품은 품절 상태를 표시한다 +5. 삭제된(SoftDelete) 상품은 판매 종료 상태를 표시한다 +6. 상품의 현재 정보(이름, 가격, 재고 상태)와 함께 반환한다 -| 제약 | 값 | 근거 | -|------|-----|------| -| 상품당 최대 수량 | 99개 | 비정상 요청 방어 | -| 장바구니 최대 종류 | 100종 | 합리적 상한 + 페이지네이션 적용 | -| 최소 수량 | 1개 | 수량 0은 불가. 제거는 DELETE API로 명확히 분리 | -| quantity | 필수값 | 기본값 없음. 클라이언트가 명시적으로 전달 | +[조건] +- 가격은 항상 현재 상품 가격 기준 (CartItem에 가격 저장 안 함, 가격의 원천은 Product) +- 페이지네이션 적용 (장바구니 최대 100종류) +- 본인의 장바구니만 조회 가능 +- 로그인한 회원만 가능 +``` -### 5.9 상품 재고: Product 필드로 관리 +**UC-C03: 장바구니 수량 변경** -**결정**: 재고(stock)를 Product 엔티티의 필드로 관리. 별도 Stock 도메인 분리 없음. +``` +[기능 흐름] +1. 회원이 cartItemId와 변경할 quantity를 요청한다 +2. 해당 장바구니 항목이 존재하는지 확인한다 +3. 본인의 장바구니 항목인지 확인한다 +4. 수량을 업데이트한다 -**근거**: -- 현재 요구사항에서 입고/출고 이력 관리가 필요하지 않음 -- 상품 등록/수정 시 재고 설정, 주문 시 차감만 하면 충분 -- 별도 도메인 분리는 오버 엔지니어링 +[예외] +- cartItemId에 해당하는 항목이 없으면 404 반환 +- 수량이 1 미만이면 실패 (최소 1) +- 수량이 99 초과이면 실패 (최대 99) ---- +[조건] +- 수량 0으로 변경 불가 → 제거는 DELETE API로만 가능 +- 본인의 장바구니 항목만 수정 가능 +- 로그인한 회원만 가능 +``` -## 6. 잠재 리스크 +**UC-C04: 장바구니 항목 제거** -| 리스크 | 설명 | 대응 방안 | -|--------|------|----------| -| 조회 시 필터링 비용 | Soft Delete + 조회 필터링으로 장바구니/좋아요 조회 시 JOIN과 조건이 증가 | 인덱스 전략으로 대응. 필요시 배치로 고아 데이터 정리 | -| 좋아요 COUNT 쿼리 비용 | 상품 조회 시 매번 likes COUNT 쿼리 발생 | 인덱스 활용. 성능 이슈 시 likeCount 캐싱 컬럼 도입 | -| 주문 시 재고 동시성 | 동시 주문 시 재고 차감의 정합성 문제 | 비관적 락 또는 낙관적 락으로 대응 (추후 동시성 처리 단계에서 해결) | -| 스냅샷 데이터 증가 | 주문마다 상품 정보를 복사하므로 데이터량 증가 | 주문 이력 조회 시 필요한 최소 정보만 스냅샷 | -| 대량 브랜드 삭제 | 상품이 많은 브랜드 삭제 시 soft delete 대상이 다수 | 배치 처리 또는 비동기 처리 고려 | +``` +[기능 흐름] +1. 회원이 cartItemId로 제거를 요청한다 +2. 해당 장바구니 항목이 존재하는지 확인한다 +3. 본인의 장바구니 항목인지 확인한다 +4. 해당 항목을 삭제한다 ---- +[예외] +- cartItemId에 해당하는 항목이 없으면 404 반환 -## 7. Checklist - -- [x] 상품/브랜드/좋아요/주문 도메인이 모두 포함되어 있는가? -- [x] 기능 요구사항이 유저 중심으로 정리되어 있는가? -- [x] 추가 기능(장바구니)이 기존 요구사항과 일관되게 정의되어 있는가? -- [x] 각 유스케이스에 Main / Alternate / Exception Flow가 포함되어 있는가? -- [x] 유스케이스 흐름이 구체적인 번호 순서로 작성되어 있는가? -- [x] 예외/조건이 명시되어 있는가? (로그인 여부, 삭제 상태, 수량 제한 등) -- [x] 좋아요 수 반영 방식이 명시되어 있는가? -- [x] 상품 재고(stock) 관리 방식이 명시되어 있는가? -- [x] 설계 결정 근거가 명시되어 있는가? -- [x] 도메인 용어집(유비쿼터스 언어)이 정의되어 있는가? +[조건] +- 본인의 장바구니 항목만 제거 가능 +- 로그인한 회원만 가능 +``` diff --git a/docs/design/02-sequence-diagrams.md b/docs/design/02-sequence-diagrams.md index 021c1dd04..5ee661f03 100644 --- a/docs/design/02-sequence-diagrams.md +++ b/docs/design/02-sequence-diagrams.md @@ -1,38 +1,6 @@ -# 02. 시퀀스 다이어그램 -> 핵심 유스케이스의 객체 간 협력 구조를 시퀀스 다이어그램으로 표현합니다. -> 책임 분리, 호출 순서, 트랜잭션 경계를 검증하는 것이 목적입니다. ---- - -## 다이어그램 작성 원칙 - -1. **User/Admin 제거**: 회원/비회원/관리자 구분은 중요하지만, 다이어그램 복잡성을 늘리므로 제거 -2. **구체적 메서드 시그니처 제거**: "주문 요청", "상품 검증" 같이 의도를 전달하는 수준으로 표현 -3. **Loop 제거**: 비즈니스 규칙상 N개 처리가 당연한 경우 Loop 표기 생략 -4. **Facade 선택적 사용**: 여러 도메인 조율이 필요한 유스케이스만 Facade 사용. 단일 도메인은 Controller → Service 직행 - ---- - -## 다이어그램 선정 기준 - -시퀀스 다이어그램은 **협력이 복잡하고 책임 분리가 중요한 흐름**에서 가치가 있습니다. -단순 CRUD(브랜드 등록, 상품 수정 등)는 협력 구조가 자명하므로 생략했습니다. - -| 선정 | 유스케이스 | 선정 이유 | -|------|----------|----------| -| O | 주문 요청 | Product + Order 도메인 협력. 재고 확인→차감→스냅샷→주문 생성 | -| O | 장바구니 담기 | Product + Cart 도메인 협력. 조건 분기 (신규 vs 기존 상품 합산) | -| O | 장바구니 조회 | Product + Cart 도메인 협력. 필터링 로직 (soft delete + 품절 표시) | -| O | 좋아요 등록/취소 | Product + Like 도메인 협력. 상품 검증 + 토글 (존재 여부에 따라 등록/취소) | -| O | 브랜드 삭제 | Brand + Product 도메인 협력. 연쇄 처리 (브랜드→상품 soft delete) | -| X | 브랜드 등록/수정 | 단일 도메인 CRUD. 시퀀스 가치 낮음 | -| X | 상품 목록 조회 | 단일 도메인 조회. 정렬/페이지네이션은 쿼리 레벨 | -| X | 주문 조회 | 단일 도메인 조회. 스냅샷 반환만 | - ---- - -## 1. 주문 요청 +## 주문 요청 ### 왜 이 다이어그램이 필요한가 @@ -67,105 +35,10 @@ sequenceDiagram OF-->>OC: 주문 생성 완료 ``` -### 이 다이어그램에서 봐야 할 포인트 - -- **OrderFacade가 ProductService와 OrderService를 조율**합니다. 두 도메인 서비스는 서로를 모릅니다. -- 상품 검증, 재고 차감은 **ProductService의 책임**. OrderService는 주문 생성만 책임집니다. -- OrderItem 스냅샷은 주문 생성 시점에 Product 정보를 복사하여 저장합니다. -- 트랜잭션은 **OrderFacade에서 관리**. 전체 흐름이 하나의 트랜잭션으로 묶입니다. - ---- - -## 2. 장바구니 담기 - -### 왜 이 다이어그램이 필요한가 - -장바구니 담기는 **Product 검증 + Cart 저장/합산**을 조율해야 하므로 Facade가 필요합니다. -**같은 상품이 이미 있으면 수량을 합산**하는 조건 분기가 있습니다. -이 다이어그램으로 **두 갈래 흐름**과 **도메인 간 협력**을 검증합니다. - -### 다이어그램 - -```mermaid -sequenceDiagram - participant CC as CartController - participant CF as CartFacade - participant PS as ProductService - participant CS as CartService - - Note left of CC: POST /api/v1/carts - CC->>CF: 장바구니 담기 요청 - CF->>PS: 상품 검증 - PS-->>CF: 검증 완료 - - Note over PS: 상품 예외 처리
(없음, 삭제됨) - - CF->>CS: 기존 장바구니 항목 조회 - CS-->>CF: CartItem 또는 null - - alt 이미 있는 경우 - Note over CF: 수량 합산 (최대 99 검증) - CF->>CS: 수량 업데이트 - else 없는 경우 - Note over CF: 종류 상한 체크 (최대 100) - CF->>CS: 신규 항목 생성 - end - - CS-->>CF: 완료 - CF-->>CC: 장바구니 담기 완료 -``` - -### 이 다이어그램에서 봐야 할 포인트 - -- **CartFacade가 ProductService와 CartService를 조율**합니다. -- 상품 검증은 ProductService, 장바구니 저장/합산은 CartService 책임입니다. -- **두 갈래 분기**: 이미 담긴 상품이면 수량 합산, 아니면 신규 생성. -- 재고는 확인하지 않습니다 (주문 시점에 확인). - ---- - -## 3. 장바구니 조회 - -### 왜 이 다이어그램이 필요한가 - -장바구니 조회는 **Cart 목록 + Product 현재 상태(가격, 재고, 삭제 여부)**를 조합해야 하므로 Facade가 필요합니다. -**삭제된 상품/브랜드 필터링 + 품절 상태 표시**라는 비즈니스 로직이 있습니다. -이 다이어그램으로 **조회 시 필터링의 책임 위치**를 검증합니다. - -### 다이어그램 - -```mermaid -sequenceDiagram - participant CC as CartController - participant CF as CartFacade - participant CS as CartService - participant PS as ProductService - - Note left of CC: GET /api/v1/carts - CC->>CF: 장바구니 조회 요청 - CF->>CS: 장바구니 항목 조회 - CS-->>CF: List - - Note over CF: 각 항목에 대해
상품 현재 정보 조회 - - CF->>PS: 상품 정보 조회 (여러 건) - PS-->>CF: List - - Note over CF: 필터링 + 상태 표시
(삭제됨 제외, 품절 표시) - - CF-->>CC: 장바구니 목록 (현재 가격 + 상태) -``` - -### 이 다이어그램에서 봐야 할 포인트 - -- **CartFacade가 CartService와 ProductService를 조율**합니다. -- 장바구니 항목을 먼저 조회한 후, **각 항목의 상품 현재 상태를 확인**합니다. -- 삭제된 상품은 제외, 품절(stock=0)은 상태 표시, 정상은 현재 가격과 함께 반환. -- 가격은 장바구니에 저장된 것이 아니라 **현재 상품 가격**을 사용합니다. --- -## 4. 좋아요 등록/취소 +## 좋아요 등록/취소 ### 왜 이 다이어그램이 필요한가 @@ -204,17 +77,9 @@ sequenceDiagram end ``` -### 이 다이어그램에서 봐야 할 포인트 - -- **LikeFacade가 ProductService와 LikeService를 조율**합니다. -- **POST/DELETE 두 엔드포인트 모두 같은 Facade 메서드(toggleLike)를 호출**합니다. -- **상품 검증은 항상 수행**합니다. 삭제된 상품에 대한 좋아요 조작을 방지합니다. -- **분기(exists → save/delete)는 Facade가 담당**합니다. LikeService는 단순 CRUD만 수행합니다. -- 좋아요 수(COUNT)는 이 흐름에서 관리하지 않습니다. 조회 시 COUNT 쿼리로 산출. - --- -## 5. 브랜드 삭제 (연쇄 처리) +## 브랜드 삭제 (연쇄 처리) ### 왜 이 다이어그램이 필요한가 @@ -249,53 +114,3 @@ sequenceDiagram BF-->>BC: 삭제 완료 ``` -### 이 다이어그램에서 봐야 할 포인트 - -- **BrandFacade가 BrandService와 ProductService를 조율**합니다. -- 브랜드 soft delete 후, **같은 트랜잭션 안에서** 해당 브랜드의 상품도 soft delete. -- 장바구니/좋아요는 **이 트랜잭션에서 건드리지 않습니다**. 조회 시 필터링. -- ProductService를 통해 상품 삭제를 처리하므로 도메인 경계가 보존됩니다. - ---- - -## 6. 개선 검토 리스트 - -현재 Facade → Service → Repository 구조로 작성했습니다. -아래는 **시퀀스 다이어그램을 수정하면서 검토하면 좋을 포인트**입니다. - -### 책임 분리 관점 - -| # | 대상 | 현재 | 검토 포인트 | -|---|------|------|-----------| -| 1 | **주문 - 재고 차감** | ProductService가 재고 차감 처리 | `Product.decreaseStock(quantity)` 도메인 메서드로 추출하면 재고 부족 검증까지 엔티티에 캡슐화 가능 | -| 2 | **주문 - 스냅샷 생성** | OrderFacade 또는 OrderService가 스냅샷 조립 | `OrderItem.createSnapshot(product, quantity)` 정적 팩토리 메서드로 분리하면 스냅샷 대상 필드를 OrderItem이 결정 | -| 3 | **장바구니 담기 - 수량 합산** | CartFacade에서 수량 합산 로직 처리 | `CartItem.addQuantity(quantity)` 도메인 메서드로 검증(최대 99)까지 캡슐화 | -| 4 | **장바구니 조회 - 필터링** | CartFacade에서 필터링 | 쿼리 레벨(JOIN + WHERE deleted_at IS NULL)로 필터링하면 N+1 문제 방지 가능. Facade 레벨 vs 쿼리 레벨 선택 | -| 5 | **상품 검증 로직** | ProductService에 여러 검증 메서드 분산 | Product 엔티티에 `isAvailable()`, `hasStock(quantity)` 같은 도메인 메서드로 캡슐화 검토 | - -### 네이밍 관점 - -| # | 현재 | 검토 포인트 | -|---|------|-----------| -| 6 | `OrderFacade` | 주문 생성만 담당. 주문 조회는 Controller → OrderService 직행. 현재는 문제없음 | -| 7 | `CartFacade` | 담기/조회 담당. 수량 변경/삭제는 Controller → CartService 직행. 현재는 문제없음 | -| 8 | `ProductService` | 상품 CRUD + 검증. 기능이 많아지면 ProductQueryService, ProductCommandService 분리 검토 | - -### 구조 관점 - -| # | 대상 | 검토 포인트 | -|---|------|-----------| -| 9 | **트랜잭션 관리 위치** | 현재 Facade에서 @Transactional 관리. 각 도메인 서비스는 트랜잭션 없이 비즈니스 로직만 담당. 이 구조가 깔끔함 | -| 10 | **장바구니 조회 N+1** | 현재 CartItem 조회 후 Product를 개별 조회. IN 쿼리로 한 번에 가져오는 방식 검토 | -| 11 | **Facade 비대화** | 현재 각 Facade는 1~2개 유스케이스만 담당. 기능 추가 시 유스케이스별 Facade 분리 검토 (CreateOrderFacade, CancelOrderFacade 등) | - ---- - -## Checklist - -- [x] 시퀀스 다이어그램이 최소 2개 이상 포함되어 있는가? (5개) -- [x] 시퀀스 다이어그램에서 책임 객체가 드러나는가? -- [x] Facade를 통한 도메인 간 협력 구조가 표현되어 있는가? -- [x] Mermaid 기반으로 작성되었는가? -- [x] 각 다이어그램에 "왜 필요한가"와 "봐야 할 포인트"가 포함되어 있는가? -- [x] 개선 검토 리스트가 포함되어 있는가? diff --git a/docs/design/03-class-diagram.md b/docs/design/03-class-diagram.md index bf7846b2e..e43b9385f 100644 --- a/docs/design/03-class-diagram.md +++ b/docs/design/03-class-diagram.md @@ -1,230 +1,170 @@ -# 03. 클래스 다이어그램 +# 클래스 다이어그램 -> 도메인 객체의 책임, 관계, 의존 방향을 클래스 다이어그램으로 표현합니다. +> 도메인 엔티티 중심의 클래스 다이어그램. > 엔티티의 필드/메서드 설계와 도메인 간 결합 구조를 검증하는 것이 목적입니다. --- -## 다이어그램 선정 기준 - -클래스 다이어그램은 **도메인 책임과 의존 방향이 중요한 구조**에서 가치가 있습니다. -단순 DTO나 Controller는 구조가 자명하므로 생략했습니다. - -| 선정 | 대상 | 선정 이유 | -|------|------|----------| -| O | 도메인 엔티티 전체 관계도 | 엔티티 간 관계, 필드 구성, 책임 분배를 한눈에 확인 | -| O | 서비스 레이어 의존 구조 | 서비스 간 의존 방향, 크로스 도메인 호출 확인 | -| X | Controller 클래스 | 요청 라우팅만 담당. 구조 자명 | -| X | DTO / Request / Response | 필드 나열 수준. 클래스 다이어그램 가치 낮음 | -| X | Repository 인터페이스 | Spring Data JPA 표준 구조. 메서드 시그니처는 ERD에서 확인 | - ---- - -## 1. 도메인 엔티티 관계도 - -### 왜 이 다이어그램이 필요한가 - -이 프로젝트에는 5개 핵심 도메인(Brand, Product, ProductLike, Cart, Order)이 있습니다. -엔티티 간 관계와 각 엔티티가 가진 필드를 한눈에 보면서 -**책임이 적절히 분배되었는지**, **의존 방향이 올바른지** 검증합니다. - -### 다이어그램 +## 다이어그램 ```mermaid classDiagram + class User { + LoginId loginId + Password password + UserName name + LocalDate birthDate + Email email + +changePassword(Password) void + } + class Brand { - -Long id - -String name - -String description - -LocalDateTime createdAt - -LocalDateTime updatedAt - -LocalDateTime deletedAt + String name + +update(String) void + +softDelete() void + +isDeleted() boolean } class Product { - -Long id - -Long brandId - -String name - -int price - -int stock - -String description - -LocalDateTime createdAt - -LocalDateTime updatedAt - -LocalDateTime deletedAt + Brand brand + String name + Money price + Stock stock + int likeCount + +update(String, Money, Stock) void + +decreaseStock(int) void + +isSoldOut() boolean + +addLikeCount() void + +subtractLikeCount() void + +softDelete() void + +isDeleted() boolean } class ProductLike { - -Long id - -Long userId - -Long productId - -LocalDateTime createdAt + Long userId + Long productId } class CartItem { - -Long id - -Long userId - -Long productId - -int quantity - -LocalDateTime createdAt - -LocalDateTime updatedAt + Long userId + Long productId + Quantity quantity + +addQuantity(int) void + +updateQuantity(int) void } - class Order { - -Long id - -Long userId - -LocalDateTime createdAt + class Cart { + <<일급 컬렉션>> + List~CartItem~ items + +getTotalPrice() int + +getItemCount() int + +selectItems(List~Long~) List~CartItem~ } - class OrderItem { - -Long id - -Long orderId - -Long productId - -String productName - -String brandName - -int price - -int quantity + class Order { + Long userId + Money totalPrice + OrderStatus status } - Brand "1" --> "*" Product : 소유 - Product "1" --> "*" ProductLike : 좋아요 대상 - Product "1" --> "*" CartItem : 장바구니 참조 - Order "1" *-- "*" OrderItem : 포함 + class OrderItem { + Long orderId + Long productId + Money orderPrice + Quantity quantity + ProductSnapshot snapshot + } + + class ProductSnapshot { + <> + String productName + String brandName + String imageUrl + } + + class OrderStatus { + <> + ORDERED + } + + Product "*" --> "1" Brand : 객체참조 (FK 없음) + ProductLike "*" --> "1" User : userId + ProductLike "*" --> "1" Product : productId + CartItem "*" --> "1" User : userId + CartItem "*" --> "1" Product : productId + Cart o-- CartItem : 일급 컬렉션 + Order "*" --> "1" User : userId + OrderItem "*" --> "1" Order : orderId + OrderItem *-- ProductSnapshot : @Embedded + Order --> OrderStatus ``` -### 이 다이어그램에서 봐야 할 포인트 - -- **Product**가 가장 많은 관계를 맺는 중심 엔티티입니다. ProductLike, CartItem이 Product를 참조합니다. -- **OrderItem**은 Product를 직접 참조하지 않습니다. `productName`, `brandName`, `price`를 **스냅샷으로 복사**합니다. 주문 이후 상품이 변경/삭제되어도 주문 내역은 보존됩니다. -- **CartItem**에는 `price` 필드가 없습니다. 가격은 조회 시 Product의 현재 가격을 사용합니다. -- Brand, Product에 `deletedAt` 필드가 있어 Soft Delete를 지원합니다. ProductLike, CartItem에는 없습니다 (조회 시 필터링). -- 현재 엔티티에 **도메인 메서드가 없습니다**. 모든 로직이 Service에 있는 상태입니다 (개선 검토 리스트 참고). - --- -## 2. 서비스 레이어 의존 구조 - -### 왜 이 다이어그램이 필요한가 - -시퀀스 다이어그램(02)에서 서비스가 자기 도메인 외의 Repository를 호출하는 부분이 여러 곳 있었습니다. -이 다이어그램으로 **서비스 간 의존 방향**과 **도메인 경계를 넘는 의존**이 어디서 발생하는지 구조적으로 확인합니다. - -### 다이어그램 - -```mermaid -classDiagram - class BrandService { - +createBrand() - +updateBrand() - +deleteBrand() - +getBrand() - } - - class ProductService { - +createProduct() - +updateProduct() - +deleteProduct() - +getProduct() - +getProducts() - } - - class LikeService { - +addLike() - +removeLike() - +getMyLikes() - } - - class CartService { - +addToCart() - +getCartItems() - +updateQuantity() - +removeCartItem() - } - - class OrderService { - +createOrder() - +getOrders() - +getOrder() - } - - class BrandRepository { - <> - } - class ProductRepository { - <> - } - class LikeRepository { - <> - } - class CartRepository { - <> - } - class OrderRepository { - <> - } - - BrandService --> BrandRepository - BrandService ..> ProductRepository : 브랜드 삭제 시 상품 연쇄 삭제 - - ProductService --> ProductRepository - ProductService ..> BrandRepository : 상품 등록 시 브랜드 존재 확인 - - LikeService --> LikeRepository - LikeService ..> ProductRepository : 좋아요 시 상품 존재 확인 - - CartService --> CartRepository - CartService ..> ProductRepository : 담기 시 상품 확인 + 조회 시 현재 정보 - - OrderService --> OrderRepository - OrderService ..> ProductRepository : 재고 확인 + 차감 + 스냅샷 -``` - -### 이 다이어그램에서 봐야 할 포인트 - -- **실선(→)**: 자기 도메인 Repository 의존. 당연한 의존. -- **점선(..>)**: 다른 도메인 Repository 의존. **크로스 도메인 호출**. 이 부분이 결합도를 높이는 지점입니다. -- **ProductRepository**가 거의 모든 서비스에서 참조됩니다. Product가 시스템의 핵심 엔티티임을 보여줍니다. -- Cart와 Order 사이에는 **의존이 없습니다**. 설계 결정(5.7)에서 합의한 "서버 도메인 간 독립" 원칙이 지켜지고 있습니다. +## Value Object 규칙 + +| VO | 검증/행위 | 비즈니스 규칙 | +|---|---|---| +| LoginId | validate() | 영문 + 숫자만 허용 | +| Password | validate(birthDate) | 8~16자, 영문대소문자+숫자+특수문자, 생년월일 포함 불가 | +| Password | matches(rawPassword) | BCrypt로 암호화된 값과 원문 비교 | +| UserName | validate() | 이름 포맷 검증 | +| UserName | mask() | 마지막 글자를 `*`로 마스킹 | +| Email | validate() | 이메일 포맷 검증 | +| Money | validate() | 0 이상이어야 함 | +| Stock | validate() | 0 이상이어야 함 | +| Stock | deduct(quantity) | 재고 부족 시 CoreException(BAD_REQUEST) | +| Stock | hasEnough(quantity) | 재고가 요청 수량 이상인지 확인 | +| Quantity | validate() | 1 이상 99 이하 | +| Quantity | add(amount) | 수량 합산, 결과가 99를 초과하면 예외 | --- -## 3. 개선 검토 리스트 +## 엔티티별 비즈니스 규칙 + +| 엔티티 | 메서드 | 비즈니스 규칙 | +|---|---|---| +| User | changePassword(Password) | 새 Password VO로 교체 | +| Brand | update(String) | 브랜드명 변경 | +| Brand | softDelete() / isDeleted() | deleted_at 설정. "삭제"의 정의가 바뀌어도 한 곳만 수정 | +| Product | decreaseStock(int) | 재고 부족 시 CoreException(BAD_REQUEST). Stock VO에 위임 | +| Product | isSoldOut() | stock이 0인지 확인. "품절"의 정의를 캡슐화 | +| Product | addLikeCount() / subtractLikeCount() | 찜 등록/취소 시 likeCount 원자적 증감 | +| Product | softDelete() / isDeleted() | deleted_at 설정. Brand와 동일 패턴 | +| CartItem | addQuantity(int) | 이미 담긴 상품 → 수량 합산. 99 초과 시 예외 | +| CartItem | updateQuantity(int) | 수량 변경. 1~99 범위 검증 | +| Cart | getTotalPrice() | 일급 컬렉션. 장바구니 전체 가격 계산 (Product 현재 가격 기준) | +| Cart | selectItems(List) | 선택한 항목만 추출 (장바구니에서 부분 주문 시) | +| OrderItem | createSnapshot(Product, int) | 정적 팩토리. 주문 시점 Product 정보를 ProductSnapshot으로 복사 | -현재 Controller → Service → Repository 기본 구조로 작성했습니다. -아래는 **클래스 다이어그램을 수정하면서 검토하면 좋을 포인트**입니다. - -### 도메인 메서드 관점 - -| # | 대상 | 현재 | 검토 포인트 | -|---|------|------|-----------| -| 1 | **Product - 재고 차감** | Service에서 `product.stock -= quantity` 처리 | `Product.decreaseStock(quantity)` 도메인 메서드로 추출하면? 재고 부족 검증까지 Product 내부에서 책임지면 비즈니스 규칙이 엔티티에 캡슐화됨 | -| 2 | **Product - 품절 확인** | Service에서 `product.stock == 0` 체크 | `Product.isSoldOut()` 메서드로 추출하면 "품절"의 정의가 바뀌어도 한 곳만 수정 | -| 3 | **Product / Brand - soft delete** | Service에서 `entity.deletedAt = now()` 직접 설정 | `softDelete()`, `isDeleted()` 메서드로 추출하면 삭제 상태 관련 로직이 엔티티에 응집 | -| 4 | **CartItem - 수량 합산** | Service에서 `cartItem.quantity += quantity` 처리 | `CartItem.addQuantity(quantity)` 메서드로 추출. 최대 99 검증까지 캡슐화 | -| 5 | **CartItem - 수량 변경** | Service에서 `cartItem.quantity = newQuantity` 처리 | `CartItem.updateQuantity(quantity)` 메서드로 추출. 1~99 범위 검증 캡슐화 | -| 6 | **OrderItem - 스냅샷 생성** | Service에서 Product 정보를 꺼내 OrderItem 필드에 매핑 | `OrderItem.createSnapshot(product, quantity)` 정적 팩토리 메서드로 분리하면 스냅샷 대상 필드를 OrderItem이 결정 | - -### 의존 방향 관점 - -| # | 대상 | 현재 | 검토 포인트 | -|---|------|------|-----------| -| 7 | **BrandService → ProductRepository** | BrandService가 ProductRepository를 직접 의존 | ProductService를 거치면 도메인 경계가 명확해짐. 하지만 같은 트랜잭션 안에서 처리해야 하므로 직접 호출이 더 단순할 수 있음. 트레이드오프 판단 필요 | -| 8 | **OrderService → ProductRepository** | OrderService가 재고 확인/차감까지 직접 처리 | ProductService의 `decreaseStock(productId, quantity)` 메서드를 통해 호출하면 재고 관련 책임이 ProductService로 모임. 하지만 트랜잭션 범위 관리가 복잡해질 수 있음 | +--- -### JPA 관계 매핑 관점 +## 관계 정리 -| # | 대상 | 현재 | 검토 포인트 | -|---|------|------|-----------| -| 9 | **Product ↔ Brand** | Product에 `brandId` (Long) 저장 | `@ManyToOne`으로 Brand 엔티티 직접 참조하면 JOIN으로 한 번에 조회 가능. 단, 양방향 관계는 결합도를 높이므로 단방향 `@ManyToOne`만 고려 | -| 10 | **Order ↔ OrderItem** | OrderItem에 `orderId` (Long) 저장 | 함께 생성/조회되므로 `@OneToMany` + cascade가 자연스러움. 하지만 Order 목록 조회 시 N+1 주의 | -| 11 | **OrderItem - productId** | OrderItem에 `productId`를 단순 Long으로 저장 | 스냅샷 특성상 FK 제약을 걸면 상품 삭제 시 문제. Long 값으로 저장이 적절. FK 없이 "그때 그 상품"의 참조 용도 | +| 관계 | 카디널리티 | 참조 방식 | 설명 | +|---|---|---|---| +| Brand → Product | 1 : N | 객체참조 + FK 없음 | `product.getBrand().getName()` 접근. DB에 FK 제약조건 없음 | +| User → ProductLike | 1 : N | ID 참조 (userId) | 유니크 제약: userId + productId | +| Product → ProductLike | 1 : N | ID 참조 (productId) | ProductLike = 교차 테이블 | +| User → CartItem | 1 : N | ID 참조 (userId) | 유니크 제약: userId + productId | +| Product → CartItem | 1 : N | ID 참조 (productId) | CartItem에 가격 저장 안 함 | +| User → Order | 1 : N | ID 참조 (userId) | UserSnapshot 불필요 | +| Order → OrderItem | 1 : N | ID 참조 (orderId) | @OneToMany 미사용. 같은 Aggregate이지만 ID 참조 | +| OrderItem → ProductSnapshot | 1 : 1 | @Embedded | 주문 시점 상품 정보 스냅샷 | --- -## Checklist - -- [x] 클래스 다이어그램이 최소 2개 이상 포함되어 있는가? (2개) -- [x] 도메인 엔티티의 필드와 관계가 드러나는가? -- [x] 서비스 레이어의 의존 방향이 표현되어 있는가? -- [x] Mermaid 기반으로 작성되었는가? -- [x] 각 다이어그램에 "왜 필요한가"와 "봐야 할 포인트"가 포함되어 있는가? -- [x] 개선 검토 리스트가 포함되어 있는가? +## 설계 결정 + +- **Rich Domain Model**: 비즈니스 로직은 엔티티와 VO 메서드에 포함한다. Facade는 오케스트레이션만 담당한다. +- **FK 미사용**: 모든 테이블 간 FK 제약조건을 사용하지 않는다. 참조 무결성은 애플리케이션 레벨에서 검증한다. + - Brand → Product만 객체참조 (`@ManyToOne` + `ConstraintMode.NO_CONSTRAINT`). 나머지는 ID 참조. +- **각 도메인 독립 Aggregate Root**: Brand, Product, ProductLike, CartItem, Order 각각 독립. `@OneToMany` 사용하지 않음. +- **Cart 엔티티 없음**: CartItem만 DB 엔티티. Cart는 일급 컬렉션으로 코드에서만 표현. User : Cart = 1:1이라 Cart의 고유 식별자(cartId)가 불필요. +- **likeCount 비정규화**: Product에 likeCount 필드로 캐싱. Like 엔티티가 원본 데이터이고 likeCount는 조회 성능을 위한 파생값(derived data). 찜/취소 시 원자적 증감. +- **N:M 관계**: ProductLike, CartItem 교차 테이블로 해소한다. +- **유니크 제약**: ProductLike(`userId + productId`), CartItem(`userId + productId`)에 DB 유니크 제약. 동시성(더블클릭) 시에도 중복 방지. +- **ProductLike, CartItem 물리 삭제**: 이력이 필요 없는 토글/임시 데이터이므로 Soft Delete 대신 물리 삭제. UNIQUE 제약조건과의 충돌을 방지한다. +- **@Embedded ProductSnapshot**: OrderItem에 스냅샷을 `@Embedded`로 분리. 스냅샷 필드가 추가되어도 ProductSnapshot만 수정하면 된다. +- **OrderItem.productId 유지**: FK 아님. 재구매, 통계 분석을 위한 데이터 연결용. 스냅샷(조회)과 역할이 다르다. +- **Order ↔ OrderItem ID 참조**: 같은 Aggregate이지만 `@OneToMany` + Cascade 대신 ID 참조 + Service에서 `@Transactional` 관리. 프로젝트 전체 패턴과 일관성 유지. N+1 위험 없음. diff --git a/docs/design/04-erd.md b/docs/design/04-erd.md index 398f1bbed..ad56a34bf 100644 --- a/docs/design/04-erd.md +++ b/docs/design/04-erd.md @@ -1,222 +1,158 @@ -# 04. ERD (Entity-Relationship Diagram) +# ERD -> 전체 테이블 구조와 관계를 ERD로 표현합니다. -> 영속성 구조, 관계의 주인, 제약 조건을 검증하는 것이 목적입니다. +> FK 제약조건은 사용하지 않는다. 관계선은 논리적 참조 관계를 나타내며, 실제 DB에서는 ID 컬럼으로만 참조한다. --- -## 다이어그램 선정 기준 - -ERD는 **전체 데이터 구조를 한 장으로** 표현합니다. -테이블 분리, 컬럼 구성, 관계 방향, 제약 조건이 요구사항과 일치하는지 확인합니다. - ---- - -## 1. 전체 ERD - -### 왜 이 다이어그램이 필요한가 - -6개 테이블의 관계와 컬럼 구성을 한눈에 봅니다. -특히 **OrderItem이 Product를 FK로 참조하지 않는 이유**(스냅샷 설계), -**CartItem에 price가 없는 이유**(현재 가격 사용)를 구조적으로 검증합니다. - -### 다이어그램 +## 다이어그램 ```mermaid erDiagram - BRANDS { + users { + bigint id PK + varchar login_id UK + varchar password + varchar name + date birth_date + varchar email + timestamp created_at + timestamp updated_at + timestamp deleted_at + } + + brands { bigint id PK - varchar name "NOT NULL, UNIQUE" - varchar description - datetime created_at "NOT NULL" - datetime updated_at "NOT NULL" - datetime deleted_at "NULL = 미삭제" + varchar name UK + timestamp created_at + timestamp updated_at + timestamp deleted_at } - PRODUCTS { + products { bigint id PK - bigint brand_id FK "NOT NULL" - varchar name "NOT NULL" - int price "NOT NULL, >= 0" - int stock "NOT NULL, >= 0" - varchar description - datetime created_at "NOT NULL" - datetime updated_at "NOT NULL" - datetime deleted_at "NULL = 미삭제" + bigint brand_id + varchar name + int price + int stock + int like_count + timestamp created_at + timestamp updated_at + timestamp deleted_at } - PRODUCT_LIKES { + likes { bigint id PK - bigint user_id "NOT NULL" - bigint product_id FK "NOT NULL" - datetime created_at "NOT NULL" + bigint user_id + bigint product_id + timestamp created_at } - CART_ITEMS { + cart_items { bigint id PK - bigint user_id "NOT NULL" - bigint product_id FK "NOT NULL" - int quantity "NOT NULL, 1~99" - datetime created_at "NOT NULL" - datetime updated_at "NOT NULL" + bigint user_id + bigint product_id + int quantity + timestamp created_at + timestamp updated_at } - ORDERS { + orders { bigint id PK - bigint user_id "NOT NULL" - datetime created_at "NOT NULL" + bigint user_id + int total_price + varchar status + timestamp created_at + timestamp updated_at + timestamp deleted_at } - ORDER_ITEMS { + order_items { bigint id PK - bigint order_id FK "NOT NULL" - bigint product_id "NOT NULL, FK 아님" - varchar product_name "NOT NULL, 스냅샷" - varchar brand_name "NOT NULL, 스냅샷" - int price "NOT NULL, 스냅샷" - int quantity "NOT NULL" + bigint order_id + bigint product_id + varchar product_name + varchar brand_name + varchar image_url + int order_price + int quantity + timestamp created_at + timestamp updated_at + timestamp deleted_at } - BRANDS ||--o{ PRODUCTS : "1 브랜드 = N 상품" - PRODUCTS ||--o{ PRODUCT_LIKES : "1 상품 = N 좋아요" - PRODUCTS ||--o{ CART_ITEMS : "1 상품 = N 장바구니" - ORDERS ||--o{ ORDER_ITEMS : "1 주문 = N 주문항목" + brands ||--o{ products : "" + users ||--o{ likes : "" + products ||--o{ likes : "" + users ||--o{ cart_items : "" + products ||--o{ cart_items : "" + users ||--o{ orders : "" + orders ||--|{ order_items : "" ``` -### 이 다이어그램에서 봐야 할 포인트 +--- + +## 제약조건 -- **ORDER_ITEMS.product_id**는 FK가 **아닙니다**. 스냅샷 설계이므로 상품이 삭제되어도 주문 내역이 유지되어야 합니다. FK 제약을 걸면 상품 soft delete 시 참조 무결성 문제가 생깁니다. -- **CART_ITEMS에 price 컬럼이 없습니다**. 장바구니 조회 시 PRODUCTS 테이블의 현재 가격을 JOIN으로 가져옵니다. -- **PRODUCT_LIKES, CART_ITEMS에 deleted_at이 없습니다**. 상품/브랜드 삭제 시 조회 시점에 PRODUCTS.deleted_at을 체크하여 필터링합니다. -- **ORDERS와 CART_ITEMS 사이에 관계가 없습니다**. 장바구니→주문 전환은 클라이언트 레벨에서 처리합니다. +| 테이블 | 제약조건 | 설명 | +|---|---|---| +| users | UNIQUE(login_id) | 로그인 ID 중복 방지 | +| brands | UNIQUE(name) | 브랜드명 중복 방지 (409 Conflict) | +| likes | UNIQUE(user_id, product_id) | 1인 1좋아요 보장. 동시성(더블클릭) 방지 | +| cart_items | UNIQUE(user_id, product_id) | 동일 상품 중복 담기 방지 (수량 합산으로 처리) | --- -## 2. 테이블 상세 - -### BRANDS - -| 컬럼 | 타입 | 제약 | 설명 | -|------|------|------|------| -| id | BIGINT | PK, AUTO_INCREMENT | 브랜드 ID | -| name | VARCHAR(100) | NOT NULL, UNIQUE | 브랜드명 (중복 불가) | -| description | VARCHAR(500) | | 브랜드 설명 | -| created_at | DATETIME | NOT NULL | 등록일 | -| updated_at | DATETIME | NOT NULL | 수정일 | -| deleted_at | DATETIME | NULL | Soft Delete 시각. NULL이면 미삭제 | - -### PRODUCTS - -| 컬럼 | 타입 | 제약 | 설명 | -|------|------|------|------| -| id | BIGINT | PK, AUTO_INCREMENT | 상품 ID | -| brand_id | BIGINT | FK → BRANDS.id, NOT NULL | 소속 브랜드 | -| name | VARCHAR(200) | NOT NULL | 상품명 | -| price | INT | NOT NULL, >= 0 | 가격 | -| stock | INT | NOT NULL, >= 0 | 재고 수량. 0이면 품절 | -| description | VARCHAR(1000) | | 상품 설명 | -| created_at | DATETIME | NOT NULL | 등록일 | -| updated_at | DATETIME | NOT NULL | 수정일 | -| deleted_at | DATETIME | NULL | Soft Delete 시각 | - -### PRODUCT_LIKES - -| 컬럼 | 타입 | 제약 | 설명 | -|------|------|------|------| -| id | BIGINT | PK, AUTO_INCREMENT | 좋아요 ID | -| user_id | BIGINT | NOT NULL | 회원 ID | -| product_id | BIGINT | FK → PRODUCTS.id, NOT NULL | 상품 ID | -| created_at | DATETIME | NOT NULL | 좋아요 등록일 | - -**유니크 제약**: `UNIQUE(user_id, product_id)` — 회원당 상품당 좋아요 1개 - -### CART_ITEMS - -| 컬럼 | 타입 | 제약 | 설명 | -|------|------|------|------| -| id | BIGINT | PK, AUTO_INCREMENT | 장바구니 항목 ID | -| user_id | BIGINT | NOT NULL | 회원 ID | -| product_id | BIGINT | FK → PRODUCTS.id, NOT NULL | 상품 ID | -| quantity | INT | NOT NULL, 1~99 | 수량 | -| created_at | DATETIME | NOT NULL | 담은 날짜 | -| updated_at | DATETIME | NOT NULL | 수량 변경일 | - -**유니크 제약**: `UNIQUE(user_id, product_id)` — 같은 상품 중복 담기 불가 (수량 합산) - -### ORDERS - -| 컬럼 | 타입 | 제약 | 설명 | -|------|------|------|------| -| id | BIGINT | PK, AUTO_INCREMENT | 주문 ID | -| user_id | BIGINT | NOT NULL | 주문자 ID | -| created_at | DATETIME | NOT NULL | 주문일 | - -### ORDER_ITEMS - -| 컬럼 | 타입 | 제약 | 설명 | -|------|------|------|------| -| id | BIGINT | PK, AUTO_INCREMENT | 주문 항목 ID | -| order_id | BIGINT | FK → ORDERS.id, NOT NULL | 소속 주문 | -| product_id | BIGINT | NOT NULL | 상품 ID **(FK 아님)** | -| product_name | VARCHAR(200) | NOT NULL | 주문 시점 상품명 (스냅샷) | -| brand_name | VARCHAR(100) | NOT NULL | 주문 시점 브랜드명 (스냅샷) | -| price | INT | NOT NULL | 주문 시점 가격 (스냅샷) | -| quantity | INT | NOT NULL | 주문 수량 | - -> **ORDER_ITEMS.product_id가 FK가 아닌 이유**: -> 주문 이후 상품이 삭제되어도 "그때 주문한 상품이 뭐였는지" 참조할 수 있어야 합니다. -> FK 제약이 있으면 상품 삭제(soft delete 포함) 시 참조 무결성 충돌이 발생합니다. +## 인덱스 권장 + +| 테이블 | 인덱스 컬럼 | 용도 | +|---|---|---| +| products | brand_id | 브랜드별 상품 필터링, 브랜드 삭제 시 연쇄 soft delete | +| likes | user_id | 유저의 좋아요 목록 조회 | +| cart_items | user_id | 유저의 장바구니 조회 | +| orders | (user_id, created_at) | 유저의 주문 목록 조회 (날짜 범위 필터링) | +| order_items | order_id | 주문의 상세 항목 조회 | --- -## 3. 인덱스 전략 +## 설계 원칙 -| 테이블 | 인덱스 | 용도 | -|--------|--------|------| -| PRODUCTS | `idx_products_brand_id` (brand_id) | 브랜드별 상품 조회, 브랜드 삭제 시 연쇄 처리 | -| PRODUCTS | `idx_products_deleted_at` (deleted_at) | Soft Delete 필터링 조회 | -| PRODUCT_LIKES | `uq_likes_user_product` (user_id, product_id) | 중복 좋아요 방지 (UNIQUE) | -| PRODUCT_LIKES | `idx_likes_user_id` (user_id) | 내가 좋아요한 목록 조회 | -| PRODUCT_LIKES | `idx_likes_product_id` (product_id) | 상품별 좋아요 수 COUNT | -| CART_ITEMS | `uq_cart_user_product` (user_id, product_id) | 같은 상품 중복 방지 (UNIQUE) | -| CART_ITEMS | `idx_cart_user_id` (user_id) | 내 장바구니 조회 | -| ORDERS | `idx_orders_user_created` (user_id, created_at) | 기간별 주문 목록 조회 | -| ORDER_ITEMS | `idx_order_items_order_id` (order_id) | 주문별 항목 조회 | +- **FK 제약조건 미사용** — ID 컬럼으로 논리적 참조만. 참조 무결성은 애플리케이션 레벨에서 검증한다. FK의 문제(잠금 전파, 데드락 위험, 삭제 순서 강제)를 회피한다. +- **Brand → Product만 객체참조** — JPA에서 `@ManyToOne` + `ConstraintMode.NO_CONSTRAINT`로 객체 참조. DB에 FK 제약조건은 생성하지 않는다. 나머지 관계는 모두 ID 참조. +- **Soft Delete** — brands, products, orders, order_items에 deleted_at 컬럼으로 논리 삭제. 물리적으로 데이터를 제거하지 않는다. +- **Soft Delete 예외** — likes, cart_items는 이력이 필요 없는 토글/임시 데이터이므로 물리 삭제(Hard Delete). UNIQUE 제약조건과의 충돌을 방지한다. +- **공통 컬럼** — BaseEntity 공통 컬럼(id, created_at, updated_at, deleted_at) 포함. likes는 created_at만 사용. +- **Enum 저장** — OrderStatus 등 Enum은 VARCHAR로 저장한다. +- **스냅샷 컬럼** — order_items의 product_name, brand_name, image_url, order_price는 주문 시점의 스냅샷. JPA `@Embedded ProductSnapshot`으로 관리. +- **like_count 비정규화** — products에 like_count 필드로 캐싱. 찜/취소 시 원자적 증감. likes 테이블이 원본 데이터. --- -## 4. 개선 검토 리스트 - -### 컬럼 관점 +## 동시성 제어 -| # | 대상 | 현재 | 검토 포인트 | -|---|------|------|-----------| -| 1 | **ORDERS - total_amount** | total_amount 컬럼 없음 | 주문 총액을 매번 ORDER_ITEMS의 `SUM(price * quantity)`로 계산할 것인가? 아니면 ORDERS에 total_amount를 저장할 것인가? 저장하면 조회 성능 향상, 안 하면 정합성 보장 | -| 2 | **ORDER_ITEMS - 추가 스냅샷** | product_name, brand_name, price만 스냅샷 | 상품 이미지 URL, 상품 설명 등 추가 스냅샷 필드가 필요한가? 현재 요구사항에서는 불필요하지만 확장성 관점 | -| 3 | **PRODUCTS - image** | 이미지 관련 컬럼 없음 | 상품 목록/상세에 이미지가 필요하면 image_url 컬럼 추가 필요. 미션 요구사항에 따라 결정 | +| 대상 | 방식 | 이유 | +|---|---|---| +| products.stock | 비관적 락 (추후 확정) | 주문 시 재고 차감. 동시 주문에도 재고가 음수가 되어서는 안 된다 | +| products.like_count | 원자적 UPDATE (`SET like_count = like_count + 1`) | 좋아요 등록/취소 시 카운터 증감. 경합이 심하지 않으므로 비관적 락은 과도함 | +| likes | DB UNIQUE 제약 | 더블클릭 시 중복 INSERT 방지. 비관적/분산 락은 과도함 | +| cart_items | DB UNIQUE 제약 | 더블클릭 시 중복 INSERT 방지. 동일 원리 | -### 관계 관점 +--- -| # | 대상 | 현재 | 검토 포인트 | -|---|------|------|-----------| -| 4 | **user_id FK 여부** | PRODUCT_LIKES, CART_ITEMS, ORDERS에 user_id는 단순 BIGINT | USERS 테이블에 대한 FK를 걸 것인가? FK를 걸면 참조 무결성이 보장되지만, 모듈 간 결합이 생김 (User는 1주차 도메인) | -| 5 | **CART_ITEMS - product_id FK** | FK → PRODUCTS.id 설정 | 상품이 soft delete되면 FK는 유지되지만, 장바구니 조회 시 deleted_at 체크로 필터링. Hard delete를 하게 되면 FK 제약 문제 발생. 현재 Soft Delete 전략이므로 FK 유지 가능 | +## 참조 무결성 검증 (애플리케이션 레벨) -### 정규화 관점 +FK 제약조건이 없으므로 다음을 애플리케이션에서 검증해야 한다: -| # | 대상 | 현재 | 검토 포인트 | -|---|------|------|-----------| -| 6 | **ORDER_ITEMS.brand_name** | 브랜드명을 주문 항목마다 중복 저장 | 같은 주문에서 같은 브랜드 상품을 여러 개 주문하면 brand_name이 중복됨. 하지만 스냅샷 특성상 정규화하면 의미가 없음 (브랜드명도 변경될 수 있으므로) | -| 7 | **좋아요 수 비정규화** | likes COUNT 쿼리로 산출 | PRODUCTS에 `like_count` 컬럼을 추가하면 조회 성능 향상. 등록/취소 시 업데이트 필요. 현재는 COUNT 쿼리로 정합성 100% 유지 | +- **상품 등록 시** — brand_id가 유효한(삭제되지 않은) 브랜드인지 확인 +- **좋아요 토글 시** — product_id가 유효한(삭제되지 않은) 상품인지 확인 +- **장바구니 담기 시** — product_id가 유효한(삭제되지 않은) 상품인지 확인 (재고는 확인하지 않음) +- **주문 생성 시** — 모든 product_id가 유효하고, expectedPrice와 현재 가격이 일치하며, 재고가 충분한지 확인 --- -## Checklist +## order_items.product_id 포함 이유 + +order_items는 스냅샷 데이터(product_name, brand_name, image_url, order_price)를 저장하지만, product_id도 함께 보관한다. 스냅샷은 **조회 편의용**이고, product_id는 **데이터 연결용**으로 역할이 다르다. -- [x] ERD가 전체 테이블 구조를 포함하는가? (6개 테이블) -- [x] 각 테이블의 컬럼, 타입, 제약 조건이 명시되어 있는가? -- [x] FK 관계와 비FK 관계의 구분이 설명되어 있는가? (ORDER_ITEMS.product_id) -- [x] Mermaid 기반으로 작성되었는가? -- [x] 인덱스 전략이 포함되어 있는가? -- [x] "왜 필요한가"와 "봐야 할 포인트"가 포함되어 있는가? -- [x] 개선 검토 리스트가 포함되어 있는가? +- 재구매 기능: "이 상품을 다시 구매" 시 원본 상품으로 이동 +- 통계 분석: "어떤 상품이 얼마나 팔렸나" 집계 시 product_id 기준으로 GROUP BY +- FK가 아님: 상품이 삭제되어도 주문 내역은 스냅샷으로 보존 diff --git "a/docs/design/\353\217\204\353\251\224\354\235\270_\352\264\200\352\263\204_\354\204\244\352\263\204_\354\235\230\354\202\254\352\262\260\354\240\225_\352\270\260\353\241\235_v3.md" "b/docs/design/\353\217\204\353\251\224\354\235\270_\352\264\200\352\263\204_\354\204\244\352\263\204_\354\235\230\354\202\254\352\262\260\354\240\225_\352\270\260\353\241\235_v3.md" new file mode 100644 index 000000000..be248ac3e --- /dev/null +++ "b/docs/design/\353\217\204\353\251\224\354\235\270_\352\264\200\352\263\204_\354\204\244\352\263\204_\354\235\230\354\202\254\352\262\260\354\240\225_\352\270\260\353\241\235_v3.md" @@ -0,0 +1,745 @@ +# 하이패션 이커머스 도메인 관계 설계 — 의사결정 기록 + +> 기술적 사고력이란, Trade-Off를 내 상황에 맞게 실무 관점에서 생각하고 결정하는 것이다. +> 이 글은 하이패션 이커머스 프로젝트의 도메인 관계를 설계하면서 어떤 고민을 했고, 어떤 선택지가 있었고, 왜 그렇게 결정했는지를 기록한 것이다. + +--- + +## 프로젝트 맥락 + +SSENSE와 같은 하이패션 이커머스 플랫폼을 설계하고 있다. 관리자가 브랜드의 상품을 올리는 구조이며, 고가 브랜드를 다루기 때문에 소비자들이 신중하게 구매하고, 장바구니에 상품이 많이 담기지 않는 특성이 있다. 현재 샵이나 카테고리는 없지만 나중에 추가할 수 있다. + +초기 도메인은 Brand, Product, Order, Like, CartItem으로 시작했고, 이 관계들을 하나씩 구체화해나갔다. + +## 최종 도메인 관계 — 먼저 결론부터 + +이 글에서 다루는 모든 고민의 최종 결과다. 왜 이렇게 됐는지는 이후의 Part 1~3에서 하나씩 따라가면 된다. + +```mermaid +graph TB + User["유저 (User)"] + Brand["브랜드 (Brand)"] + Product["상품 (Product)"] + Like["찜 (Like)"] + CartItem["장바구니 (CartItem)"] + Order["주문 (Order)"] + OrderItem["주문상품 (OrderItem)"] + + Brand -->|"객체참조, FK없음"| Product + User --- Like + User --- CartItem + User --- Order + Product --- Like + Product --- CartItem + Product -->|"스냅샷"| OrderItem + Order --- OrderItem +``` + +``` +모든 영역 간 참조는 ID 참조 (Brand → Product만 객체참조 + FK 없음) +Cart ↔ Order 사이에 직접 관계 없음 (Facade가 조율) +삭제 전략은 기본적으로 SoftDelete +DB FK 제약 사용하지 않음, 무결성은 애플리케이션에서 보장 +DB 유니크 제약은 사용 (테이블 내부 제약, FK와 성격이 다름) +``` + +--- + +# Part 1. 주문 흐름 설계 + +> **이 파트에서 다루는 것**: 상품이 장바구니에 담기고, 주문으로 이어지는 흐름을 설계한다. Cart 엔티티가 정말 필요한지, 주문 경로가 2개인 게 맞는지, 품절/삭제/가격 변동을 어떻게 검증할지를 결정한다. + +## 1-1. Cart 엔티티가 필요한가? + +### 상황 + +User와 CartItem을 연결하는 방식을 설계하면서, "현실 세계에 장바구니라는 개념이 있으니까 Cart 엔티티를 만들어야 하는 거 아닌가?"라는 의문이 들었다. + +### 스스로에게 던진 질문들 + +**"장바구니 단위로 어떤 행위를 하고 싶은가?"** — 딱히 없었다. 장바구니라는 현실 개념이 있으니까 고민이 된 것이지, Cart 엔티티가 해야 할 구체적인 행위가 떠오르지 않았다. 이 시점에서 이미 경고 신호였다. + +**"장바구니에서 주문할 때, 전체를 주문하나? 선택한 항목만?"** — 둘 다 지원해야 한다. 그런데 이건 Cart 엔티티 유무와 관계없이 가능하다. 전체 주문은 `WHERE user_id = ?`로, 선택 주문은 `WHERE id IN (?)`로 CartItem을 조회하면 된다. 이건 요청 파라미터의 문제이지 도메인 모델의 문제가 아니다. + +**"한 유저가 여러 장바구니를 가질 수 있나?"** — 없다. 1인 1장바구니다. 이게 결정적이었다. User : Cart가 1:1이면, Cart의 식별자는 사실상 userId와 동일하다. 별도의 cartId가 존재할 이유가 없다. 만약 1:N이었다면 (위시리스트, "나중에 살 것" 등) Cart에 이름이나 타입 같은 고유 속성이 생기니까 엔티티가 정당화되지만, 1:1에서는 그냥 User의 연장선이다. + +### 선택지 + +| | User → CartItem 직접 | User → Cart → CartItem | +|---|---|---| +| 구조 | CartItem에 userId만 | Cart 엔티티 + CartItem | +| Cart 고유 상태 | 없음 | 만료일, 쿠폰, 공유 URL 등 가능 | +| Cart 고유 행위 | 없음 | 잠금, 만료 등 가능 | +| 복잡도 | 낮음 | 중간 | + +Cart 엔티티가 정당화되려면 장바구니 만료일(30일 후 자동 비우기), 장바구니 단위 쿠폰 적용 상태, 장바구니 공유 기능(공유 URL 생성), 장바구니 잠금(결제 진행 중 변경 방지) 같은 것이 필요하다. 현재 이런 요구사항이 없다. + +- **내 상황에서의 판단**: "N개의 상품을 담는다"는 Cart의 고유한 역할이 아니라 CartItem 집합의 성질이다. `SELECT * FROM cart_item WHERE user_id = ?` 한 줄로 "이 유저의 장바구니"가 완성된다. + +### 결정: User → CartItem 직접 + Cart는 일급 컬렉션으로 + +DB 엔티티로서의 Cart는 만들지 않되, 코드에서 **일급 컬렉션(First-Class Collection)**으로 Cart 객체를 둔다. + +```java +public class Cart { + private final List items; + + public Cart(List items) { + this.items = items; + } + + public int getTotalPrice() { ... } + public int getItemCount() { ... } + public List selectItems(List cartItemIds) { ... } +} +``` + +이렇게 하면 "장바구니 전체 가격 계산", "선택한 항목만 추출" 같은 장바구니 단위 행위를 Cart 객체 안에 응집시킬 수 있다. 테이블은 없으니 불필요한 엔티티 없이, 장바구니라는 현실 개념을 코드에서 표현하는 절충안이다. + +> **이 결정에서 얻은 것**: "현실의 개념 = 엔티티"가 아니다. "도메인에서 고유한 상태와 행위가 있는 개념 = 엔티티"다. + +--- + +## 1-2. 주문 경로가 2개인 문제 + +### 상황 + +이 프로젝트에서 가장 깊이 고민한 설계 결정이다. + +주문으로 이어지는 경로가 두 가지다. "상품 페이지에서 바로구매"와 "장바구니에서 주문". 그런데 정말 2가지 경로가 필요한 것인지 의심이 들었다. 주문할 때 Product가 CartItem으로 담기는 구조라면, 결국 모든 주문이 CartItem을 거치는 것 아닌가? + +### 스스로에게 던진 질문들 + +**"주문의 진짜 원천은 뭐지?"** — CartItem은 Product를 "담아둔 포인터"일 뿐이다. 장바구니 주문이든 바로구매든, 결국 주문에 필요한 정보(상품, 가격, 재고)는 Product가 가지고 있다. 그러면 "모든 주문은 결국 Product를 거친다"고 볼 수 있지 않을까? + +**"CartItem에 가격을 저장해야 하나?"** — 하이패션에서 시즌 세일이 핵심 구매 패턴이다. CartItem에 담은 시점 가격을 저장하면, 세일이 시작돼도 장바구니에 정가가 보인다. 유저가 빼고 다시 담아야 세일 가격이 적용되는 최악의 UX가 된다. **가격의 원천(source of truth)은 항상 Product여야 한다.** + +**"그러면 CartItem은 뭐지?"** — CartItem은 `productId + quantity`만 가지는 포인터다. + +### 선택지 + +**접근법 A: 두 개의 독립 경로.** 바로구매는 Product → Order 직접, 장바구니 주문은 CartItem → Order로 별도 로직. + +- 장점: 바로구매 시 CartItem을 만들 필요 없음, 각 경로 독립 최적화 가능 +- 단점: 주문 생성 로직이 두 벌, 쿠폰/할인 추가 시 양쪽 다 수정 필요 +- **내 상황에서의 판단**: 쿠폰/할인 기능을 계획하고 있기 때문에, 주문 로직이 두 벌이면 유지보수 비용이 배로 늘어난다. **탈락.** + +**접근법 B: 모든 주문이 CartItem을 거침.** 바로구매를 눌러도 내부적으로 임시 CartItem 생성 → 주문 → CartItem 삭제. 항상 단일 흐름. + +- 장점: 주문 로직이 하나, 쿠폰/할인 한 곳에서 관리 +- 단점: 바로구매 시 임시 CartItem이 생기는 게 도메인적으로 부자연스러움. CartItem은 "담아두기"라는 의미인데, 바로구매하는 사람은 "담아둔" 적이 없음 +- **내 상황에서의 판단**: 하이패션 특성상 고가 상품이라 바로구매 비율이 높을 수 있다. 매번 임시 CartItem을 만들었다 지우는 건 의미적으로도, 데이터적으로도 낭비다. + +**접근법 C: Cart와 Order가 서로를 모르는 구조.** 핵심 발상의 전환 — OrderItem 입장에서 자기가 장바구니에서 왔는지, 바로구매에서 왔는지는 전혀 중요하지 않다. Order 도메인은 "어떤 상품을, 몇 개, 얼마에"만 알면 된다. + +``` +// 핵심 원칙: OrderService는 출처를 모른다 +OrderService.createOrder(userId, List items, CouponInfo coupon) + +// Facade에서 분기 +바로구매 → OrderItemCommand 직접 생성 → OrderService 호출 +장바구니 → CartItem 조회 → OrderItemCommand 변환 → OrderService 호출 → CartItem 삭제 +``` + +- 장점: 도메인 간 결합도 최소, 주문 로직 단일화, Cart를 나중에 완전히 바꿔도 Order에 영향 없음 +- 단점: Facade 계층을 잘 설계해야 함 +- **내 상황에서의 판단**: Facade 패턴은 현재 프로젝트에서 이미 사용하고 있는 구조여서 추가 비용이 크지 않다. + +**접근법 D: "모든 주문은 Product를 거친다"는 관점.** 접근법 C를 한 단계 더 구체화한다. + +- 바로구매: Product → Order +- 장바구니 주문: CartItem → **Product** → Order + +CartItem은 Product를 가리키는 포인터일 뿐이고, 주문의 진짜 원천은 항상 Product다. 이 관점이 C에서 Facade가 `OrderItemCommand(productId, quantity, price)`를 만들 때 **price를 어디서 가져오느냐**를 확정해준다 — 항상 Product에서 가져온다. + +### 결정: 접근법 C + Product 중심 원칙 + +```mermaid +graph TB + subgraph A["접근법 A: 독립 경로"] + A1["바로구매"] --> A2["주문 로직 #1"] + A3["장바구니"] --> A4["주문 로직 #2"] + end + + subgraph B["접근법 B: 전부 CartItem 경유"] + B1["바로구매"] --> B2["임시 CartItem"] + B3["장바구니"] --> B4["CartItem"] + B2 --> B4 --> B5["주문 로직"] + end + + subgraph C["접근법 C: Facade 조율 ✅"] + C1["바로구매"] --> C3["Facade"] + C2["장바구니"] --> C3 + C3 --> C4["OrderItemCommand"] + C4 --> C5["OrderService"] + end +``` + +"2가지 경로"라는 문제의 해결책은 "경로를 하나로 합치는 것(B)"이 아니라, **"Order 도메인이 경로를 신경 쓰지 않게 만드는 것(C)"**이었다. + +이 결정의 핵심 근거: + +1. **쿠폰/할인 계획이 있다** → 주문 로직이 단일해야 유지보수 가능 (A 탈락) +2. **하이패션 특성상 바로구매 비율이 높을 수 있다** → 임시 CartItem 강제는 비효율 (B 약점) +3. **DDD 관점에서 Cart와 Order는 서로 다른 Bounded Context** → 직접 의존 없는 것이 자연스러움 (C의 강점) +4. **Facade 패턴을 이미 사용 중** → C의 조율 비용이 추가 부담이 아님 +5. **하이패션은 시즌 세일이 핵심 패턴** → 가격의 원천은 항상 Product (D의 보강) + +--- + +## 1-3. 품절, 삭제, 가격 변동 — 주문 시점에 무엇을 검증해야 하는가? + +### 상황 + +1-2에서 "가격의 원천은 항상 Product"라는 원칙을 세웠다. 그런데 이 원칙을 따르면, 장바구니에 담긴 상품의 상태가 바뀌었을 때 어떻게 처리해야 하는지라는 문제가 자연스럽게 따라온다. + +### 스스로에게 던진 질문들 + +#### "CartItem이 가격을 저장하는 이커머스는 어떤 상황인가?" + +이 질문은 "왜 최신 가격이 맞는지"를 반대 사례로 검증하기 위해 던졌다. + +CartItem에 가격을 저장하는 대표적인 사례는 B2B 이커머스(고객별 협상 가격)나 타임세일/플래시딜("지금 담으면 이 가격 보장") 같은 비즈니스 규칙이 있는 경우다. 하이패션에서는 고객별 차등 가격이 없고, "담으면 가격 보장" 정책도 없다. 오히려 시즌 세일 시 장바구니에 담아둔 상품 가격이 자동으로 바뀌어야 자연스러운 UX다. + +#### "장바구니에 담긴 상품이 품절되면?" + +하이패션에서 한정 수량이 많으니 이건 빈번한 시나리오다. + +**선택지:** +1. 품절되면 장바구니에서 자동 제거 +2. 품절 표시하고 유저가 직접 제거 +3. 품절 표시 + 재입고 시 알림 + +**결정: 품절 표시하고 유저가 직접 제거.** 자동 제거는 유저 입장에서 "내가 담아둔 게 사라졌다"는 혼란을 준다. 하이패션에서 비싼 상품을 신중하게 골라 담았는데 자동으로 없어지면 불쾌하다. + +#### "상품이 아예 삭제(SoftDelete)되면?" + +시즌이 끝나면 상품 자체가 내려가는 경우다. + +**선택지:** +1. 판매 종료 표시 + 주문 불가 +2. 장바구니에서 자동 제거 +3. 일정 기간 후 자동 제거 + +**결정: 판매 종료 표시 + 주문 불가.** 품절은 재입고 가능성이 있지만, 삭제된 상품은 돌아오지 않는다. SoftDelete의 가치가 여기서 드러난다 — HardDelete였으면 CartItem이 참조하는 Product가 사라져서 FK 제약 위반이 되지만, SoftDelete이면 Product 데이터는 남아있으니 "판매 종료" 표시가 가능하다. + +#### "장바구니에 품절 상품이 섞여있을 때 주문하면?" + +**선택지:** +1. 품절 상품 포함 시 주문 전체 차단 +2. 품절 상품만 빼고 나머지만 주문 가능 +3. 유저에게 선택하게 + +**결정: 품절 상품 포함 시 주문 전체 차단.** 하이패션에서 유저는 "전체 코디를 맞춰서 사는" 패턴이 강하다. 자켓 + 팬츠를 함께 담았는데 팬츠만 빠진 채로 주문되면 오히려 불만이다. + +#### "품절 차단을 어느 시점에 하는가?" + +**결정: 장바구니 화면에서 미리 차단 + 백엔드 이중 검증.** 프론트에서 주문 버튼을 비활성화하는 건 UX 가이드일 뿐이다. 유저가 장바구니 화면을 보는 시점과 주문 버튼을 누르는 시점 사이에 시간차가 있고, 그 사이에 품절될 수 있다. **프론트는 UX를 위한 가이드이고, 백엔드가 실제 안전장치다.** + +#### "결제 직전에 가격이 바뀌는 동시성 문제는?" + +이게 가장 실무적으로 중요한 문제다. 유저가 장바구니 화면에서 50만원을 보고 주문 버튼을 눌렀는데, 그 사이 관리자가 가격을 변경했다면? + +**결정: 주문 시점에 유저가 본 가격(expectedPrice)과 Product의 현재 가격을 비교해서, 다르면 주문을 막는다.** 하이패션에서 고가 상품이라 가격 차이가 크기 때문에, 일반 이커머스에서 100원 차이는 무시할 수 있지만 여기서 50,000원 차이는 분쟁이 된다. + +#### "가격 변동 시 유저에게 알림을 줘야 하나?" + +**결정: 지금은 아니지만, 설계에 영향을 주지 않게 분리한다.** 알림은 CartItem의 책임이 아니라 알림 도메인의 책임이다. Product 가격이 바뀌면 이벤트를 발행하고, 해당 Product를 CartItem에 담고 있는 유저들에게 알림을 보내는 이벤트 기반 구조로 나중에 추가할 수 있다. 중요한 건 이 기능 때문에 CartItem에 price 필드를 넣을 필요가 없다는 점이다. + +### Facade 검증 흐름 — 세 가지가 한 곳에서 수렴 + +위 질문들의 결론을 종합하면, 주문 생성 직전에 Facade가 해야 할 검증이 하나로 모인다. + +```mermaid +flowchart TB + Start(["주문 요청"]) --> Facade["Facade 검증"] + Facade --> Check1{"상품 삭제?"} + Check1 -->|"삭제됨"| E1["판매 종료된 상품입니다"] + Check1 -->|"정상"| Check2{"품절?"} + Check2 -->|"품절"| E2["품절된 상품입니다"] + Check2 -->|"재고 있음"| Check3{"가격 변동?"} + Check3 -->|"불일치"| E3["가격이 변경되었습니다"] + Check3 -->|"일치"| Pass["검증 통과"] + Pass --> Order(["OrderService.createOrder()"]) +``` + +```java +// Facade에서 주문 생성 전 검증 흐름 +for (OrderItemCommand item : items) { + Product product = productService.findById(item.getProductId()); + + // 1. 상품 삭제 여부 + if (product.isDeleted()) → "판매 종료된 상품입니다" 에러 + + // 2. 품절 여부 + if (product.isSoldOut()) → "품절된 상품입니다" 에러 + + // 3. 가격 변동 여부 + if (product.getPrice() != item.getExpectedPrice()) → "가격이 변경되었습니다" 에러 +} + +// 모든 검증 통과 시에만 주문 생성 +orderService.createOrder(userId, validatedItems); +``` + +상품 삭제, 품절, 가격 변동 — 이 세 가지 검증이 모두 같은 시점(주문 생성 직전)에, 같은 위치(Facade)에서 일어난다. Order 도메인은 이 검증을 몰라도 된다. + +### Part 1에서 세운 원칙 + +> - **가격의 원천은 항상 Product** — CartItem은 가격을 저장하지 않는다 +> - **Cart와 Order는 서로를 모른다** — Facade가 경로를 조율하고, Order 도메인은 출처를 모른다 +> - **Facade가 안전한 주문만 통과시키는 게이트** — 품절/삭제/가격 검증은 Facade에서 수렴 + +--- + +# Part 2. 도메인 경계 설계 + +> **이 파트에서 다루는 것**: Part 1에서 주문 흐름을 설계했으니, 이제 도메인을 어떤 영역으로 나누고, 영역 간에 어떻게 연결하며, Aggregate 경계를 어디에 두는지를 결정한다. + +## 2-1. 도메인 영역은 어떻게 나누는가? + +### 상황 + +도메인 관계가 구체화되면서, "이 도메인들을 어떤 영역(Bounded Context)으로 묶을 것인가?"라는 질문이 자연스럽게 떠올랐다. 처음에는 "상품 영역에 Brand, Product, Like, CartItem이 있고, 주문 영역에 Order가 있다"고 생각했다. + +### 영역 판단 기준의 발전 + +이 고민을 풀면서 **영역을 나누는 기준 자체가 3단계로 발전**했다. 처음에 세운 기준이 반례에 부딪히면서 더 정교한 기준으로 진화한 과정이 핵심이다. + +#### 첫 번째 기준: 생명주기 종속 → 실패 + +"Like의 생명주기가 Product에 종속되니까 상품 영역"이라고 판단했다. Product가 삭제되면 Like도 존재 이유가 없으니까. + +하지만 CartItem도 Product가 삭제되면 "판매 종료"가 되는데, Part 1에서 CartItem은 독자적인 품절/가격 검증 규칙이 있어서 독립 영역이 맞다고 결정했다. **생명주기 종속만으로는 영역을 결정할 수 없었다.** + +#### 두 번째 기준: 독자적 행위 유무 → 필요하지만 불충분 + +Like의 행위를 구체적으로 나열해보니: + +- Like한 상품이 품절/삭제 시 유저 알림 필요 +- 상품의 인기수에 반영 (Product 방향) +- 유저의 찜 리스트 조회 (User 방향) + +분명히 독자적 행위가 있었다. 만약 Like가 Product 하위 도메인이면, "마이페이지 찜 리스트"를 조회할 때 ProductService에 `findLikedProductsByUserId` 같은 메서드가 생긴다. Product 도메인이 User의 찜 행위까지 책임지게 되는 것이다. + +**→ Like도 독립 영역.** + +하지만 이 기준만으로는 "그러면 Like와 CartItem을 하나로 묶을 수 있는가?"에 답할 수 없었다. 둘 다 독자적 행위가 있으니까. + +#### 세 번째 기준: "함께 변경되는가" → 결정적 + +Like와 CartItem을 나란히 비교해봤다. + +| | Like | CartItem | +|---|---|---| +| 구조 | userId + productId | userId + productId + quantity | +| Order와 관계 | 없음 | Facade 통해 주문으로 변환 | +| 변경 빈도 | 낮음 (한번 찜하면 유지) | 높음 (수량 변경, 추가/제거) | + +구조가 비슷하니까 "유저 활동 영역"으로 묶을 수 있지 않을까? 하지만 핵심 차이는 **CartItem은 주문 흐름에 참여하고, Like는 참여하지 않는다**는 것이다. CartItem의 품절/가격 검증 로직이 변경될 때 Like 쪽 코드는 건드릴 필요가 없다. + +"구조가 비슷하다"는 묶는 이유가 안 된다. **"함께 변경되는가"**가 영역을 나누는 진짜 기준이다. + +#### 세 기준의 발전 과정 정리 + +| 기준 | 내용 | 한계 | +|---|---|---| +| ① 생명주기 종속 | "Product 삭제 시 같이 사라지면 같은 영역" | CartItem도 종속인데 독립 영역 → 반례 | +| ② 독자적 행위 유무 | "자기만의 비즈니스 규칙이 있으면 독립" | Like, CartItem 둘 다 있음 → 묶을지 판단 불가 | +| ③ 함께 변경되는가 | "한쪽 로직이 바뀔 때 다른쪽도 바꿔야 하면 같은 영역" | **결정적 기준** | + +기준 ①이 실패해서 ②로, ②가 부족해서 ③으로 발전한 것이지, 처음부터 ③을 알고 있었던 건 아니다. + +### 결정: 6개 독립 영역 + +```mermaid +graph LR + subgraph brand_ctx["브랜드"] + Brand["Brand"] + end + subgraph product_ctx["상품"] + Product["Product"] + end + subgraph like_ctx["찜"] + Like["Like"] + end + subgraph cart_ctx["장바구니"] + CartItem["CartItem"] + Cart["Cart (일급컬렉션)"] + end + subgraph order_ctx["주문"] + Order["Order"] + OrderItem["OrderItem"] + end + subgraph user_ctx["유저"] + User["User"] + end + + brand_ctx -->|"객체참조"| product_ctx + product_ctx ---|"ID 참조"| like_ctx + product_ctx ---|"ID 참조"| cart_ctx + product_ctx ---|"ID 참조"| order_ctx + user_ctx ---|"ID 참조"| like_ctx + user_ctx ---|"ID 참조"| cart_ctx + user_ctx ---|"ID 참조"| order_ctx +``` + +실선 화살표(→)는 같은 도메인 내 객체 참조, 실선(─)은 영역 간 ID 참조다. + +- **브랜드 영역**: Brand +- **상품 영역**: Product +- **찜 영역**: Like +- **장바구니 영역**: CartItem (+ 일급 컬렉션 Cart) +- **주문 영역**: Order, OrderItem +- **유저 영역**: User + +--- + +## 2-2. Brand ↔ Product — 참조 방식, Aggregate 경계, 패키지 구조 + +### 상황 + +Brand 1:N Product 관계에서, Brand가 삭제되면 해당 Product도 전부 판매 중단하기로 결정했고, Product는 반드시 Brand에 속해야 한다. 이 결정들이 참조 방식과 Aggregate 경계에 어떤 영향을 주는지 따져봐야 했다. + +### 스스로에게 던진 질문들 + +#### "JPA에서 참조 방식이 뭐가 있고, 뭘 써야 하나?" + +멘토님이 "JPA에서는 키(인덱스, 유니크키, 외래키)에 대한 기능을 직접적으로 사용하지 않습니다"라고 했는데, 정확히 어떤 방식인지 몰랐다. 알고 보니 세 가지 방식이 있었다. + +**방식 1: 객체 참조 + DB FK 제약.** `@ManyToOne` + `@JoinColumn`으로 DB에 FK 제약조건이 생긴다. + +**방식 2: ID 참조.** `private Long brandId`로 ID만 가진다. Brand 정보가 필요하면 별도로 조회해야 한다. + +**방식 3: 객체 참조 + FK 없음.** `@ManyToOne` + `@JoinColumn(foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT))`로 코드에서는 객체 참조를 쓰지만 DB에는 FK 제약을 안 만든다. + +| | 방식 1 (객체 + FK) | 방식 2 (ID만) | 방식 3 (객체 + FK없음) | +|---|---|---|---| +| 코드 접근 | `product.getBrand()` | `brandService.findById(brandId)` | `product.getBrand()` | +| DB FK 제약 | 있음 | 없음 | 없음 | +| 무결성 보장 | DB가 보장 | 앱이 보장 | 앱이 보장 | +| 결합도 | DB + 코드 둘 다 | 완전 분리 | 코드만 결합 | +| 데드락 위험 | FK 잠금 전파 | 없음 | 없음 | + +- **내 상황에서의 판단**: Brand와 Product는 같은 상품 도메인이고, `product.getBrand().getName()` 같은 접근이 빈번하다. 방식 2(ID만)는 매번 별도 조회가 필요해서 번거롭다. 방식 1(FK)은 데드락 위험과 결합도가 문제다. 방식 3은 코드 편의성과 DB 유연성의 절충안이다. + +#### "데드락 가능성은?" + +FK 제약이 있으면 Product를 INSERT/UPDATE할 때 DB가 Brand 행에 공유 잠금을 건다. 같은 Brand의 상품을 여러 트랜잭션이 동시에 수정하면 데드락이 가능하다. 하이패션에서 시즌 세일 때 한 브랜드의 상품 가격을 일괄 변경하는 상황을 생각하면 현실적 위험이다. FK가 없는 방식 3에서는 이 위험이 사라진다. + +#### "Brand와 Product의 Aggregate 경계는?" + +| | Brand가 Root + Product 하위 | 각각 독립 Root | +|---|---|---| +| Brand 조회 시 | Product 전부 로딩 | Brand만 로딩 | +| Product 수정 시 | Brand Aggregate 잠금 | Product만 잠금 | +| 동시성 | 같은 Brand 상품 동시 수정 어려움 | 자유로움 | +| Brand 삭제 → Product | Aggregate 내부 자동 처리 | Facade에서 조율 필요 | + +- **내 상황에서의 판단**: 하이패션에서 브랜드당 상품이 100개 이상일 수 있다. Brand를 조회할 때마다 Product 100개가 로딩되고, 시즌 세일 때 같은 브랜드의 상품을 동시에 수정하면 충돌이 생긴다. Aggregate 내부 자동 처리라는 편의보다 성능과 동시성이 더 중요하다. + +#### "패키지 구조는?" + +**접근 A: 영역별 완전 분리.** brand/, product/, like/, cart/, order/, user/ 각각 독립 패키지. +**접근 B: 핵심 도메인 그룹핑.** product/ 안에 brand, like 등을 서브패키지로. + +- **내 상황에서의 판단**: 2-1에서 6개 독립 영역으로 결정했다. 패키지 구조가 이 결정을 반영해야 한다. "브랜드를 검색했는데 상품이 없을 수도 있다"는 점을 생각하면, Brand가 Product 하위 패키지에 있으면 Brand 조회가 상품 영역의 하위 기능처럼 보인다. **접근 A 채택.** + +### 결정 + +- **참조 방식**: 방식 3 (객체 참조 + FK 없음) +- **Aggregate 경계**: 각각 독립 Aggregate Root +- **패키지 구조**: 영역별 완전 분리 + +> **이 결정에서 얻은 것**: 참조 방식은 "편하냐"가 아니라 "FK가 주는 제약(데드락, 결합도)을 감수할 수 있느냐"로, Aggregate 경계는 "같은 영역이냐"가 아니라 "같은 트랜잭션에서 다뤄져야 하느냐"로 판단해야 한다. + +--- + +## 2-3. Product ↔ OrderItem — 주문 내역과 스냅샷 + +### 상황 + +CartItem은 "아직 안 산 것"이지만 OrderItem은 "이미 산 것"이다. 이 차이가 설계에 큰 영향을 준다. 핵심 질문은 "주문된 상품이 나중에 삭제되면, 과거 주문 내역은 어떻게 되는가?"였다. + +### 스스로에게 던진 질문들 + +#### "Product가 삭제된 후에도 주문 내역에서 상품 정보를 보여줘야 하나?" + +**결정: 주문 시 스냅샷을 같이 생성한다.** Part 1의 "가격의 원천은 Product" 원칙과 연결된다. 주문 시점에 Product에서 최신 정보를 가져와서 OrderItem에 스냅샷으로 찍는다. + +여기서 **CartItem과 OrderItem의 스냅샷 필요성 차이**가 명확해진다. + +```mermaid +graph LR + subgraph now["CartItem = 지금"] + CI["CartItem"] -->|"항상 최신 조회"| P1["Product\n현재 가격"] + end + + subgraph then["OrderItem = 그때"] + OI["OrderItem"] --- SS["ProductSnapshot\n주문 시점 기록"] + OI -.->|"연결용"| P2["Product"] + end +``` + +| | CartItem | OrderItem | +|---|---|---| +| 성격 | "아직 안 산 것" | "이미 산 것" | +| 필요한 정보 | 항상 최신 (세일 가격 반영) | 주문 시점 기록 보존 | +| 스냅샷 | **불필요** | **필수** | + +같은 Product 정보를 참조하지만 "시간의 관점"이 다르다. CartItem은 "지금"이 중요하고, OrderItem은 "그때"가 중요하다. + +#### "OrderItem에서 productId를 계속 가지고 있어야 하나?" + +스냅샷이 있으면 productId 없이도 주문 내역을 보여줄 수 있다. 하지만 "이 상품을 다시 구매" 기능이나 "어떤 상품이 얼마나 팔렸나" 통계 분석에 productId가 필요하다. + +**결정: productId 유지.** 스냅샷은 조회 편의용이고, productId는 데이터 연결용으로 역할이 다르다. + +#### "스냅샷을 어떤 구조로 저장할 것인가?" + +| | 접근 1: 직접 넣기 | 접근 2: @Embedded | 접근 3: 별도 테이블 | +|---|---|---|---| +| DB | OrderItem 테이블 하나 | OrderItem 테이블 하나 | OrderItem + Snapshot 2개 | +| 코드 | 필드 뒤섞임 | **역할별 분리** | 완전 분리 | +| 스냅샷 필드 추가 시 | OrderItem이 비대해짐 | **ProductSnapshot만 수정** | JOIN 필요 | + +- **내 상황에서의 판단**: 하이패션 상품은 스냅샷할 정보가 많을 수 있다(상품명, 브랜드명, 이미지, 사이즈, 컬러, 소재 등). 접근 1이면 OrderItem 필드가 비대해지고, 접근 3은 항상 같이 생성/조회되는 데이터를 굳이 테이블로 나누는 과도한 분리다. + +### 결정: @Embedded로 분리 + +```java +@Entity +public class OrderItem { + private Long orderId; + private Long productId; // 데이터 연결용 (재구매, 통계) + private int quantity; + private int orderPrice; + + @Embedded + private ProductSnapshot snapshot; // 조회 편의용 +} + +@Embeddable +public class ProductSnapshot { + private String productName; + private String brandName; + private String imageUrl; +} +``` + +### Part 2에서 세운 원칙 + +> - **영역을 나누는 기준은 "함께 변경되는가"** — 생명주기 종속이나 구조 유사성이 아니라, 변경 이유가 같은가 +> - **FK는 편의가 아니라 제약** — 데드락, 결합도, 삭제 순서 문제를 감수할 수 있을 때만 +> - **같은 데이터라도 역할이 다르면 분리** — productId(연결), orderPrice(기록), ProductSnapshot(조회) + +--- + +# Part 3. 나머지 관계 확정 + +> **이 파트에서 다루는 것**: Part 1~2에서 핵심 구조와 원칙을 세웠다. 이제 남은 관계들(User ↔ Order, User ↔ Like, User ↔ CartItem, Product ↔ Like, Product ↔ CartItem, Order ↔ OrderItem)에 같은 원칙을 적용해서 일괄 확정한다. + +## 3-1. User ↔ Order + +### "참조 방식은?" + +다른 영역이므로 ID 참조가 원칙이다. 하지만 "Order에서 User 정보가 자주 필요하면 방식 3(객체 참조)이 편하지 않을까?"라는 의문이 있었다. + +이걸 판단하려면 **"주문 내역을 누가 조회하는가?"**부터 따져야 한다. 유저 본인만 자기 주문을 본다. 관리자 화면은 현재 범위에 없다. 그러면 Order 도메인 로직에서 User 객체가 필요한 순간이 없다. + +- **내 상황에서의 판단**: Brand ↔ Product에서 방식 3을 택한 건 "같은 상품 도메인이고 `product.getBrand().getName()`이 빈번하기 때문"이었는데, User ↔ Order에는 그런 이유가 없다. **ID 참조.** + +### "UserSnapshot이 필요한가?" + +| | ProductSnapshot | UserSnapshot | +|---|---|---| +| 삭제 후 조회 상황 | 유저가 "뭘 샀는지" 봄 | 탈퇴한 유저는 로그인 불가 → 조회 자체가 없음 | +| 스냅샷 필요성 | **필수** | **불필요** | + +- **내 상황에서의 판단**: ProductSnapshot이 필요한 이유는 "상품이 삭제돼도 유저가 주문 내역에서 뭘 샀는지 봐야 하기 때문"이다. UserSnapshot은? 유저 본인이 탈퇴하면 조회 주체 자체가 사라진다. 보여줄 대상이 없다. + +### 결정 + +- 참조 방식: ID 참조 (userId만) +- UserSnapshot: 불필요 +- User 탈퇴 시: 주문 데이터 DB 유지 (비즈니스 기록) +- 주문 취소만 지원 (반품은 나중에 추가) + +--- + +## 3-2. User ↔ Like, User ↔ CartItem + +### User ↔ Like + +Like 도메인 로직에서 `like.getUser().getName()` 같은 호출이 필요한 시점이 없다. **ID 참조.** + +User 탈퇴 시 Like 데이터를 유지할 이유도 없다. 탈퇴한 유저의 찜은 허수 데이터일 뿐이다. 삭제하면 상품의 찜 수(likeCount)도 줄어드는데, 이것이 맞다 — 실제로 활동하는 유저의 찜만 의미 있는 수치다. + +### User ↔ CartItem + 동시성 문제 + +CartItem도 같은 논리로 **ID 참조.** + +여기서 중요한 설계 결정이 나왔다. **"동일 상품을 장바구니에 또 담으면?"** → 수량 +1로 결정했는데, 이건 `userId + productId` 조합이 유일해야 한다는 뜻이다. + +#### "유니크 보장을 어디서 할 것인가?" + +애플리케이션에서만 보장하면 동시성 문제가 생긴다. 같은 상품에 "장바구니 담기" 버튼을 빠르게 두 번 클릭하면, 두 요청이 동시에 "CartItem 없음"을 확인하고 둘 다 INSERT해서 중복 데이터가 생긴다. + +| | 비관적 락 | 분산 락 | DB 유니크 제약 | +|---|---|---|---| +| 경합 빈도 낮을 때 | 과도함 | 과도함 | **적절** | +| 정합성 보장 | 확실 | Redis 정상 시 | **확실** | +| 인프라 추가 | 없음 | Redis 필요 | **없음** | +| 코드 복잡도 | 중간 (잠금 범위 관리) | 높음 (락 타임아웃, 갱신) | **낮음** (예외 처리만) | + +- **내 상황에서의 판단**: 하이패션에서 장바구니 담기의 동시성 경합은 "더블클릭" 수준이지, 쿠팡 타임세일처럼 수만 명이 동시에 담는 상황이 아니다. 경합 빈도는 매우 낮지만, 발생하면 반드시 막아야 한다(중복 CartItem → 주문 시 같은 상품이 OrderItem 2개로 생김). Redis 인프라도 없다. **DB 유니크 제약이 가장 합리적인 비용으로 가장 확실한 보장을 준다.** + +비관적 락이나 분산 락은 "수만 명이 동시에 같은 자원을 경합하는 상황"에서 빛을 발한다. 예를 들어 한정판 스니커즈 100개를 1만 명이 동시에 주문하는 시나리오 — 이건 재고 차감의 동시성 문제이고, 장바구니 중복 방지와는 다른 문제다. + +#### "멘토님이 키를 안 쓴다고 했는데, 유니크 제약도 해당되나?" + +FK와 유니크 제약은 같은 "키"라는 이름이지만 성격이 다르다. + +| | FK (외래키) | 유니크 제약 | +|---|---|---| +| 제약 범위 | **테이블 간** (Product → Brand) | **테이블 내부** (CartItem 자체) | +| 다른 테이블 영향 | 있음 (잠금 전파, 삭제 순서) | **없음** | +| 데드락 위험 | 있음 | **없음** | + +멘토님이 피하려는 건 FK의 **테이블 간 결합**이지, 테이블 내부 제약까지 포함한 게 아니다. + +### 결정 + +| | User ↔ Like | User ↔ CartItem | +|---|---|---| +| 참조 방식 | ID 참조 (userId) | ID 참조 (userId, productId) | +| 유니크 제약 | userId + productId | userId + productId | +| User 탈퇴 시 | 삭제 + likeCount 감소 | 삭제 | + +--- + +## 3-3. Product ↔ Like — likeCount 집계 방식 + +### "인기수를 어떻게 집계할 것인가?" + +상품 상세, 상품 목록, 브랜드별 상품, 검색 결과 — 상품이 나오는 모든 곳에서 찜 수가 표현돼야 한다. + +| 기준 | COUNT (조회 시 집계) | likeCount 캐시 (Product 필드) | +|---|---|---| +| 데이터 정합성 | ✅ 항상 정확 | 관리 필요 (탈퇴/삭제 시 동기화) | +| 조회 성능 | 모든 API에 서브쿼리 | ✅ Product만 읽으면 끝 | +| 영역 간 결합도 | ✅ 완전 독립 | Like → Product 단방향 결합 | +| 동시성 | - | `likeCount + 1` 원자적 처리 | + +처음에는 "영역 결합도"를 중시해서 COUNT 방식을 선호했다. 하지만 결합의 실체를 따져보니 — **원자적 UPDATE 한 줄(`SET likeCount = likeCount + 1`)이 전부**다. 이 정도 결합으로 모든 조회 API가 깔끔해진다면? + +- **내 상황에서의 판단**: 영역 결합은 원칙이고, 조회 편의성은 매일 마주치는 현실이다. 원칙을 어기는 대가가 UPDATE 한 줄이고, 지키는 대가가 모든 상품 조회 API의 서브쿼리라면, **충분히 합리적인 타협**이다. + +### "likeCount가 있으면 Like 엔티티가 필요 없지 않나?" + +| 기능 | likeCount만으로 가능? | Like 엔티티 필요? | +|---|---|---| +| "이 상품 찜 수" 표시 | ✅ | - | +| "내가 이 상품을 찜했나?" | ❌ | ✅ | +| "내 찜 리스트" 조회 | ❌ | ✅ | +| "찜 취소" | ❌ | ✅ | +| "찜한 상품 세일 알림" | ❌ | ✅ | + +**likeCount는 Like 데이터의 파생값(derived data)**이다. Like 엔티티가 원본이고 likeCount는 조회 성능을 위한 캐시다. + +```mermaid +graph TB + subgraph origin["찜 영역 — 원본"] + Like["Like"] + end + + subgraph cache["상품 영역 — 캐시"] + Product["Product.likeCount"] + end + + Like -->|"찜하기: +1 / 취소: -1"| Product + + subgraph need_like["Like 엔티티가 필요한 기능"] + F1["내가 찜했나?"] + F2["내 찜 리스트"] + F3["찜 취소"] + F4["찜한 상품 알림"] + end + + subgraph only_count["likeCount만으로 충분한 기능"] + C1["찜 수 표시"] + end + + Like -.-> need_like + Product -.-> only_count +``` + +### 결정 + +- 참조 방식: ID 참조 (productId만) +- 유니크 제약: DB 유니크 (`userId + productId`) +- 인기수 집계: Product에 likeCount 캐시 (원자적 증감) +- Like 엔티티 유지 (원본 데이터) +- Product 삭제 시: Like 삭제 + +--- + +## 3-4. Product ↔ CartItem + +Part 1-3에서 품절/삭제 시 처리 규칙은 다뤘다. 참조 방식과 스냅샷 여부만 확정하면 된다. + +Part 2-3에서 정리한 대비가 여기서도 적용된다 — **CartItem은 "아직 안 산 것"이라 항상 Product의 최신 정보를 보여줘야 한다.** 하이패션에서 시즌 세일이 시작되면 장바구니에 담아둔 상품의 세일 가격이 바로 반영돼야 한다. + +### 결정 + +- 참조 방식: ID 참조 (productId만) +- 스냅샷: 불필요 (장바구니 조회 시 항상 Product에서 현재 정보 조회) + +--- + +## 3-5. Order ↔ OrderItem — @OneToMany vs ID 참조 + +### 상황 + +같은 주문 영역이고 같은 Aggregate(Order가 Root)다. 같은 Aggregate이면 `@OneToMany + Cascade`가 자연스러운 선택인데, 정말 그래야 하나? + +### Trade-Off + +| | @OneToMany + Cascade | ID 참조 + Service 관리 | +|---|---|---| +| Aggregate 보장 | JPA 자동 (cascade, orphanRemoval) | Service 수동 (@Transactional) | +| 조회 | `order.getOrderItems()` | `orderItemRepository.findByOrderId()` | +| 예측 가능성 | JPA 내부 동작에 의존 | **쿼리가 명시적** | +| N+1 위험 | 있음 (LAZY 로딩 시) | **없음** | +| 프로젝트 일관성 | 이 관계만 다른 패턴 | **전체 ID 참조와 동일** | + +`orderItem.getOrder()`가 필요한 상황을 점검했는데, OrderItem을 조회하는 상황은 항상 "주문 #123의 상품 목록" 같이 **Order를 먼저 알고 있는 상황**이다. 역추적이 필요 없으니 객체 참조의 실질적 이점이 없다. + +- **내 상황에서의 판단**: 세 가지 이유로 ID 참조. 첫째, 프로젝트 전체가 ID 참조 패턴이므로 **일관성**. 한 군데만 `@OneToMany`를 쓰면 코드를 읽을 때 "여기는 왜 이 방식이지?"라는 의문이 생긴다. 둘째, 멘토님의 JPA 암묵적 동작 지양 방침과의 **정합성**. 셋째, `orderItem.getOrder()`가 필요한 상황이 없으므로 **잃는 게 없음**. + +### 결정 + +- 참조 방식: ID 참조 (orderId만) +- Aggregate 규칙: Service에서 관리 (@Transactional로 함께 생성/삭제 보장) +- @OneToMany 사용하지 않음 + +### Part 3에서 도출된 판단 프레임워크 + +> 모든 관계를 따져보니 네 가지 질문으로 참조 방식을 판단할 수 있었다. +> +> 1. **"도메인 로직에서 상대 객체가 필요한가?"** — 필요 없으면 ID 참조 +> 2. **"삭제 시 데이터를 유지해야 하는가?"** — 유지 필요하면 스냅샷 검토 +> 3. **"동시성으로 데이터가 깨질 수 있는가?"** — 유니크 필요하면 DB 제약 +> 4. **"프로젝트 전체와 일관적인가?"** — 일관성이 예측 가능성을 높인다 + +--- + +## 돌아보며 + +도메인 관계를 설계할 때, 가장 먼저 "정답"을 찾으려 했다. 하지만 실제로 중요했던 것은 **내 상황을 정확히 파악하는 것**이었다. + +"하이패션이라 장바구니에 많이 안 담긴다", "바로구매 비율이 높을 수 있다", "쿠폰/할인이 나중에 들어온다", "시즌 세일이 핵심 구매 패턴이다" — 이런 맥락이 선택지의 장단점 무게를 완전히 바꿨다. 같은 접근법이라도 쿠팡 같은 대량 구매 이커머스였다면 다른 결정을 내렸을 것이다. + +그리고 하나의 좋은 질문이 연쇄적으로 다음 질문을 낳았다. "주문 경로가 2개인 게 맞나?"라는 의심이 "가격의 원천은 어디인가?"로, 그리고 "품절/삭제/가격 변동을 어떻게 검증하는가?"로 자연스럽게 이어졌다. 영역을 나누다 보니 "Like는 정말 상품 영역인가?"라는 의문이 생겼고, 참조 방식을 결정하다 보니 "데드락은?"이라는 질문이 Aggregate 경계 결정으로 이어졌다. + +기술적 의사결정은 "가장 좋은 방법"을 고르는 게 아니라, "내 상황에서 가장 합리적인 타협"을 찾는 과정이다. 그리고 그 과정에서 **스스로에게 좋은 질문을 던지는 것**이 생각하는 힘의 핵심이다. From 32c3e60116223e3bafd29e8337678bc92b190b41 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Wed, 18 Feb 2026 13:46:59 +0900 Subject: [PATCH 009/108] =?UTF-8?q?docs:=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=EC=BB=A8=EB=B2=A4=EC=85=98=20claude-skill=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/CLAUDE.md | 97 ++++ .claude/settings.local.json | 56 ++ .claude/skills/commit-convention/SKILL.md | 172 +++++++ .claude/skills/project-convention/SKILL.md | 188 +++++++ .../application/service-layer-convention.md | 299 +++++++++++ .../references/common/dto-convention.md | 207 ++++++++ .../references/common/exception-convention.md | 293 +++++++++++ .../common/exception-migration-guide.md | 170 ++++++ .../references/common/package-convention.md | 281 ++++++++++ .../references/common/test-convention.md | 397 +++++++++++++++ .../references/domain/entity-vo-convention.md | 396 ++++++++++++++ .../infrastructure-convention.md | 482 ++++++++++++++++++ .../references/interfaces/api-convention.md | 448 ++++++++++++++++ .../interfaces/swagger-convention.md | 391 ++++++++++++++ .claude/skills/requirements-analysis/SKILL.md | 77 +++ .gitignore | 3 +- 16 files changed, 3956 insertions(+), 1 deletion(-) create mode 100644 .claude/CLAUDE.md create mode 100644 .claude/settings.local.json create mode 100644 .claude/skills/commit-convention/SKILL.md create mode 100644 .claude/skills/project-convention/SKILL.md create mode 100644 .claude/skills/project-convention/references/application/service-layer-convention.md create mode 100644 .claude/skills/project-convention/references/common/dto-convention.md create mode 100644 .claude/skills/project-convention/references/common/exception-convention.md create mode 100644 .claude/skills/project-convention/references/common/exception-migration-guide.md create mode 100644 .claude/skills/project-convention/references/common/package-convention.md create mode 100644 .claude/skills/project-convention/references/common/test-convention.md create mode 100644 .claude/skills/project-convention/references/domain/entity-vo-convention.md create mode 100644 .claude/skills/project-convention/references/infrastructure/infrastructure-convention.md create mode 100644 .claude/skills/project-convention/references/interfaces/api-convention.md create mode 100644 .claude/skills/project-convention/references/interfaces/swagger-convention.md create mode 100644 .claude/skills/requirements-analysis/SKILL.md diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 000000000..90d05f5b3 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,97 @@ +## 프로젝트 개요 + +이 프로젝트는 Spring Boot 기반의 멀티모듈 Java 프로젝트입니다. TDD(Test-Driven Development) 방식으로 개발하며, 테스트 가능한 구조를 목표로 합니다. + +--- + +## 기술 스택 및 버전 + +### Core +- **Java**: 21 +- **Spring Boot**: 3.4.4 +- **Spring Cloud**: 2024.0.1 +- **Gradle**: Kotlin DSL + +### Framework & Libraries +- **Spring Web**: REST API 개발 +- **Spring Data JPA**: 데이터 접근 계층 +- **Spring Data Redis**: 캐싱 및 세션 관리 +- **QueryDSL**: 타입 세이프한 쿼리 작성 +- **Kafka**: 메시지 브로커 (commerce-streamer) +- **Spring Batch**: 배치 처리 (commerce-batch) + +### Utilities +- **Lombok**: 보일러플레이트 코드 감소 +- **Jackson**: JSON 직렬화/역직렬화 +- **SpringDoc OpenAPI**: API 문서화 (Swagger) + +### Testing +- **JUnit 5 + AssertJ + Mockito**: 테스트 프레임워크 +- **Testcontainers**: 통합 테스트 (MySQL, Redis) + +### Monitoring & Logging +- **Spring Actuator**: 애플리케이션 모니터링 +- **Slack Appender**: 로그 알림 +- **Jacoco**: 코드 커버리지 + +--- + +## 모듈 구조 + +### Apps (실행 가능한 애플리케이션) +``` +apps/ +├── commerce-api # REST API 서버 +├── commerce-streamer # Kafka 스트리밍 처리 +└── commerce-batch # 배치 작업 +``` + +### Modules (도메인 및 인프라 모듈) +``` +modules/ +├── jpa # BaseEntity, QueryDSL/JPA/DataSource Config +├── redis # Redis 설정 및 Repository +└── kafka # Kafka 설정 및 Producer/Consumer +``` + +### Supports (공통 지원 모듈) +``` +supports/ +├── jackson # JSON 직렬화 설정 +├── logging # 로깅 설정 +└── monitoring # 모니터링 설정 +``` + +--- + +## 아키텍처 + +- 계층 우선 패키지: `interfaces → application → domain ← infrastructure` +- 코딩 컨벤션: `.claude/skills/project-convention/` 참조 (코드 작성 시 해당 스킬의 references/ 하위 문서를 반드시 Read 도구로 읽을 것) +- 커밋 규칙: `.claude/skills/commit-convention/` 참조 + +--- + +## 프로젝트 실행 + +### 개발 환경 실행 +```bash +./gradlew :apps:commerce-api:bootRun +``` + +### 테스트 실행 +```bash +# 전체 테스트 +./gradlew test + +# 특정 모듈 테스트 +./gradlew :apps:commerce-api:test + +# 커버리지 리포트 생성 +./gradlew test jacocoTestReport +``` + +### Docker Compose 실행 +```bash +docker compose up -d +``` diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000..d5b2d26af --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,56 @@ +{ + "permissions": { + "allow": [ + "Bash(docker compose:*)", + "Bash(./gradlew:*)", + "Bash(java:*)", + "Bash(/usr/libexec/java_home:*)", + "Bash(docker exec:*)", + "Bash(curl:*)", + "Bash(lsof:*)", + "Bash(JAVA_HOME=/Users/jins/Library/Java/JavaVirtualMachines/temurin-21.0.7/Contents/Home ./gradlew:*)", + "Bash(export JAVA_HOME=/Users/jins/Library/Java/JavaVirtualMachines/temurin-21.0.7/Contents/Home:*)", + "Bash(cat:*)", + "Bash(python3:*)", + "Bash(test:*)", + "Bash(git branch:*)", + "Bash(git checkout:*)", + "Bash(git push:*)", + "Bash(git add:*)", + "Bash(git commit:*)", + "Bash(git ls-tree:*)", + "Bash(git cherry-pick:*)", + "Bash(claude doctor)", + "Bash(npm update:*)", + "Bash(npm uninstall:*)", + "Bash(npm install:*)", + "Bash(sudo rm:*)", + "Bash(sudo npm install:*)", + "WebSearch", + "WebFetch(domain:addyosmani.com)", + "WebFetch(domain:stackoverflow.blog)", + "WebFetch(domain:builtin.com)", + "WebFetch(domain:www.technologyreview.com)", + "WebFetch(domain:claude.com)", + "WebFetch(domain:gorodinski.com)", + "WebFetch(domain:blog.cleancoder.com)", + "WebFetch(domain:xebia.com)", + "WebFetch(domain:developer20.com)", + "WebFetch(domain:github.com)", + "WebFetch(domain:www.domainlanguage.com)", + "WebFetch(domain:softengbook.org)", + "WebFetch(domain:opus.ch)", + "WebFetch(domain:dev.to)", + "WebFetch(domain:en.wikipedia.org)", + "WebFetch(domain:georgearisty.dev)", + "WebFetch(domain:gist.github.com)", + "WebFetch(domain:raw.githubusercontent.com)", + "WebFetch(domain:medium.com)", + "Bash(source:*)", + "Bash(sdk use java:*)", + "Bash(JAVA_HOME=/Users/jins/Library/Java/JavaVirtualMachines/corretto-21.0.4/Contents/Home ./gradlew:*)" + ], + "deny": [], + "ask": [] + } +} diff --git a/.claude/skills/commit-convention/SKILL.md b/.claude/skills/commit-convention/SKILL.md new file mode 100644 index 000000000..41bbb5c83 --- /dev/null +++ b/.claude/skills/commit-convention/SKILL.md @@ -0,0 +1,172 @@ +--- +name: commit +description: 변경된 파일을 분석하여 의미 단위로 커밋을 분리하고 실행. "커밋", "commit", "커밋 해줘", "커밋 남겨줘", "변경사항 정리", "git commit" 시 트리거. +--- + +# Commit Convention + +변경된 파일을 분석하여 의미 있는 단위로 커밋을 분리하고 실행하는 스킬. + +## 실행 절차 + +### Step 1: 변경 사항 분석 + +```bash +git status +git diff --stat +git diff --cached --stat +``` + +변경된 파일 목록과 diff를 확인한다. + +### Step 2: 커밋 단위 분리 + +변경 파일들을 **작업 의미** 기준으로 그룹핑한다. 분리 기준: + +1. **도메인 단위** — 같은 도메인의 Entity, Service, Repository, Controller, DTO는 하나의 커밋 +2. **작업 성격 단위** — 기능 구현 / 리팩토링 / 테스트 / 설정 변경은 분리 +3. **의존 관계** — A 커밋이 B 커밋에 의존하면 A를 먼저 커밋 + +분리 예시: +``` +// 주문 도메인 기능 구현 + 테스트를 작성한 경우 → 2개 커밋 +커밋 1: feat: 주문 생성 기능 구현 + - domain/order/Order.java + - domain/order/OrderService.java + - domain/order/OrderRepository.java + - infrastructure/order/OrderRepositoryImpl.java + - infrastructure/order/OrderJpaRepository.java + - application/order/OrderFacade.java + - interfaces/order/OrderController.java + - interfaces/order/dto/OrderDto.java + +커밋 2: test: 주문 생성 테스트 코드 추가 + - domain/order/OrderTest.java + - domain/order/OrderServiceTest.java +``` + +### Step 3: 커밋 계획을 사용자에게 보여주기 + +**반드시 실행 전에 확인을 받는다.** 아래 형식으로 보여준다: + +``` +📋 커밋 계획 (총 N개) + +1️⃣ feat: 주문 생성 기능 구현 + - domain/order/Order.java (new) + - domain/order/OrderService.java (new) + - application/order/OrderFacade.java (new) + - interfaces/order/OrderController.java (new) + +2️⃣ test: 주문 생성 테스트 코드 추가 + - domain/order/OrderTest.java (new) + - domain/order/OrderServiceTest.java (new) + +3️⃣ refactor: 상품 엔티티 가격 검증 로직 리팩토링 + - domain/product/Product.java (modified) + +이대로 커밋할까요? +``` + +사용자가 수정을 요청하면 (합치기, 쪼개기, 메시지 변경 등) 반영 후 다시 보여준다. + +### Step 4: 커밋 실행 + +사용자가 확인하면 순서대로 실행한다. + +```bash +# 커밋 1 +git add domain/order/Order.java domain/order/OrderService.java ... +git commit -m "feat: 주문 생성 기능 구현" + +# 커밋 2 +git add domain/order/OrderTest.java domain/order/OrderServiceTest.java +git commit -m "test: 주문 생성 테스트 코드 추가" +``` + +실행 후 결과를 보여준다: + +``` +✅ 커밋 완료 (3개) + 1. feat: 주문 생성 기능 구현 (6 files) + 2. test: 주문 생성 테스트 코드 추가 (2 files) + 3. refactor: 상품 엔티티 가격 검증 로직 리팩토링 (1 file) +``` + +--- + +## 커밋 메시지 규칙 + +### 형식 + +``` +{type}: {한글 설명} +``` + +### 커밋 타입 + +| 타입 | 용도 | 예시 | +|------|------|------| +| `feat` | 새 기능 구현 | `feat: 주문 생성 기능 구현` | +| `fix` | 버그 수정 | `fix: 재고 차감 시 음수 허용 버그 수정` | +| `refactor` | 기능 변경 없이 코드 개선 | `refactor: 장바구니 엔티티 리팩토링` | +| `test` | 테스트 추가/수정 | `test: 주문 생성 테스트 코드 추가` | +| `docs` | 문서 추가/수정 | `docs: API 명세서 업데이트` | +| `chore` | 빌드, 설정, 의존성 등 | `chore: QueryDSL 의존성 추가` | + +### 메시지 작성 규칙 + +- **한글**로 작성한다 +- **명확한 동사 + 작업 대상** 구조: `{무엇을} {어떻게 했다}` +- 메시지만 보고 어떤 작업인지 파악 가능해야 한다 +- 마침표 붙이지 않는다 + +좋은 예시: +``` +feat: 브랜드 CRUD API 구현 +feat: 상품 좋아요 등록/취소 기능 구현 +fix: 주문 취소 시 재고 미복원 버그 수정 +refactor: ProductService 조회 로직 분리 +test: 브랜드 생성 유효성 검증 테스트 추가 +docs: README에 프로젝트 실행 방법 추가 +chore: Spring Boot 3.2 버전 업그레이드 +``` + +나쁜 예시: +``` +feat: 수정 ← 무엇을 수정했는지 모름 +feat: Order 관련 작업 ← 어떤 작업인지 모호 +fix: 버그 수정 ← 어떤 버그인지 모름 +refactor: 리팩토링 ← 무엇을 리팩토링했는지 모름 +``` + +--- + +## 커밋 분리 판단 기준 + +### 하나로 합치는 경우 + +- 같은 도메인의 **기능 구현** 관련 파일들 (Entity + Service + Repository + Controller + DTO) +- **하나의 버그 수정**에 관련된 여러 파일 변경 +- **하나의 리팩토링** 목적으로 변경된 파일들 + +### 분리하는 경우 + +- **기능 구현 vs 테스트** — 프로덕션 코드와 테스트 코드는 분리 +- **서로 다른 도메인** — 주문 기능과 상품 기능은 분리 +- **서로 다른 성격** — 기능 구현(feat)과 리팩토링(refactor)은 분리 +- **설정 변경** — build.gradle, application.yml 등은 별도 커밋 (chore) + +### 커밋이 1개만 나오는 경우 + +변경이 단일 작업이면 굳이 쪼개지 않는다. 1개 커밋도 정상이다. + +--- + +## 주의사항 + +- **반드시 커밋 계획을 먼저 보여주고 확인받는다** — 절대 무확인 커밋하지 않는다 +- `git push`는 하지 않는다 — 커밋만 수행한다 +- 이미 staged된 파일이 있으면 먼저 알려주고 처리 방법을 물어본다 +- untracked 파일이 있으면 포함 여부를 물어본다 +- 충돌이나 에러가 발생하면 즉시 중단하고 알린다 diff --git a/.claude/skills/project-convention/SKILL.md b/.claude/skills/project-convention/SKILL.md new file mode 100644 index 000000000..f5b74d1f8 --- /dev/null +++ b/.claude/skills/project-convention/SKILL.md @@ -0,0 +1,188 @@ +--- +name: project-convention +description: Java Spring 계층형 아키텍처 프로젝트 컨벤션. Controller, Facade, Service, Entity, Repository, DTO, VO, ErrorCode, ApiResponse, ApiSpec, Swagger, QueryDSL, BaseEntity, 테스트 작성 시 참조. 패키지 구조, API 설계, Infrastructure, 예외처리, Admin/고객 분리 규칙 포함. +--- + +# Project Convention + +## 아키텍처 전제 + +``` +Interface(Controller) → Application(Facade) → Domain(Entity + Service) ← Infrastructure +``` + +--- + +## Quick Reference + +### 공통 + +**패키지 구조 — 계층 우선 + 도메인 하위** + +``` +com.loopers/ +├── interfaces/ ← Controller, ApiSpec, Request/Response DTO +│ ├── api/ ← 공통 (ApiResponse, ControllerAdvice) +│ └── {domain}/ ← 도메인별 Controller, DTO +├── application/ ← Facade, Command/Query/Info/Result DTO +│ └── {domain}/ +├── domain/ ← Entity, VO, Service, Repository(I/F), ErrorCode +│ └── {domain}/ +├── infrastructure/ ← Repository 구현, JPA, QueryDSL +│ └── {domain}/ +└── support/ ← error, config, util +``` + +**예외 구조** + +``` +CoreException → ErrorCode (interface) + ├── ErrorType (enum) ← 공통 (support/error/) + └── XxxErrorCode (enum) ← 도메인별 (domain/{domain}/) +``` + +**API 응답** + +```java +ApiResponse.success(data) // 성공 +ApiResponse.fail(code, message) // 일반 에러 +ApiResponse.failValidation(code, message, fieldErrors) // Validation 에러 +``` + +**계층별 DTO 네이밍** + +| 계층 | 요청 | 응답 | +|------|------|------| +| **Interface** | `~Request` | `~Response` | +| **Application** | `~Command` / `~Query` | `~Info` (단일) / `~Result` (조합) | +| **Domain** | `~Data` | **Entity** 또는 `~Info` | + +**테스트** + +- **JUnit 5 + AssertJ + Mockito**, `@Nested` 행위별 그룹핑 +- 메서드명: `{action}_{condition}`, 내부: arrange / act / assert +- 테스트 더블: Domain → **Fake**, Application → **Mockito**, E2E → **실제 Bean** +- DB 격리: `@AfterEach` + `DatabaseCleanUp.truncateAllTables()` + +--- + +### Interface 계층 + +**API 설계** + +- **Prefix**: 고객 `/api/v1`, Admin `/api-admin/v1` +- **리소스**: 복수형, 소문자, 케밥케이스 (`/api/v1/products`) +- **HTTP 메서드**: GET 조회, POST 생성, PUT 수정(PATCH 미사용), DELETE 삭제 +- **상태 코드**: 생성 **201**, 나머지 성공 **200**, 에러는 `ErrorCode.getStatus()` +- **페이지네이션**: Offset 기반 (`page=0&size=20`) +- **엔드포인트**: 표준 CRUD / 중첩 리소스(2단계까지) / 소유자 기준 조회 + +**Controller 분리** + +- 고객 `{Domain}Controller` / Admin `Admin{Domain}Controller` +- Facade 공유 가능, Admin 로직 커지면 분리 + +**Swagger (ApiSpec 인터페이스)** + +- Swagger 어노테이션 → `{Domain}V1ApiSpec` / `Admin{Domain}V1ApiSpec` +- Spring MVC 어노테이션 → Controller에만 +- Controller가 ApiSpec을 `implements`, `@Override` 명시 +- 필수: `@Tag`(인터페이스), `@Operation`(메서드), `@Parameter`(파라미터) +- `@Schema`: 모호한 필드에만 추가 + +--- + +### Application 계층 + +- **Facade만 사용** (ApplicationService 없음) +- Facade: 유스케이스 조율, 도메인 간 흐름 조합, DTO 변환 +- @Transactional: **메서드 레벨**, 조회는 `readOnly = true` +- Facade + Domain Service **양쪽 모두** @Transactional (REQUIRED 전파) +- Facade → 타 도메인 **Service 직접 호출** OK, 타 **Facade 호출 금지** + +--- + +### Domain 계층 + +**Entity** + +- 생성: **정적 팩토리 메서드** (`Order.create(...)`) +- 접근: `@NoArgsConstructor(PROTECTED)`, Setter 금지 +- 검증 훅: `guard()` override → `@PrePersist`/`@PreUpdate` 시 호출 +- 로직 배치: 자기 필드로 완결 → Entity, 그 외 → Domain Service + +**Value Object** + +- 생성 기준: 단순 검증만이면 안 만듦. 형식 규칙/행위/복합 규칙이 있을 때만 +- 구현: DB 저장 → `@Embeddable`, 비저장 → `record` +- 전달: **Entity 내부에서 원시값으로부터 생성** (바깥에서 VO 전달 금지) +- 검증: 단일값 → VO, 크로스필드 → Entity, 외부의존 → Domain Service + +--- + +### Infrastructure 계층 + +**Repository 3-클래스 패턴** + +- `domain/{domain}/OrderRepository` — 순수 Java 인터페이스 (Spring 의존 없음) +- `infrastructure/{domain}/OrderJpaRepository` — Spring Data JPA +- `infrastructure/{domain}/OrderRepositoryImpl` — `@Repository`, 어댑터 + +**QueryDSL** + +- RepositoryImpl에 `JPAQueryFactory` 주입하여 직접 작성 +- 메서드 5개 이상이면 `{Domain}QueryRepository`로 분리 + +**DB 규칙** + +- **FK 미사용**: 같은 도메인 → 객체참조(`NO_CONSTRAINT`), 다른 도메인 → ID 참조 +- **@OneToMany 미사용**: ID 참조 + 별도 Repository 조회 +- **유니크 제약 사용**: 동시성 중복 방지 +- **BaseEntity**: `modules/jpa` 제공. id, createdAt, updatedAt, deletedAt, guard(), delete(), restore() + +--- + +## 상세 참조 가이드 + +**중요: 코드를 작성하거나 수정하기 전에, 해당 작업과 관련된 아래 reference 파일을 반드시 Read 도구로 읽어라.** +경로는 이 SKILL.md 파일 기준 상대경로이며, 절대경로로 변환하여 읽는다. + +### 공통 + +**패키지 구조** → `references/common/package-convention.md` +- 새 도메인 패키지 생성, 클래스 배치, 의존 방향, 도메인 간 참조 + +**예외처리 / API 응답** → `references/common/exception-convention.md` +- ErrorCode enum 추가, CoreException throw, ControllerAdvice, ApiResponse, Validation 에러 + +**기존 코드 마이그레이션** → `references/common/exception-migration-guide.md` +- ErrorType → ErrorCode 전환, CoreException/ApiControllerAdvice/ApiResponse 수정 + +**DTO** → `references/common/dto-convention.md` +- DTO 신규 생성, 계층 간 전달 객체, 변환 메서드(toCommand, from), record Inner Class + +**테스트** → `references/common/test-convention.md` +- 테스트 클래스 생성, 네이밍, 단위/통합/E2E 구분, Fake vs Mockito, DB 정리 + +### Interface 계층 + +**API 설계** → `references/interfaces/api-convention.md` +- Controller 생성, URL 설계, HTTP 메서드/상태 코드, Admin/고객 분리, 페이지네이션 + +**Swagger 문서화** → `references/interfaces/swagger-convention.md` +- ApiSpec 인터페이스 생성, @Operation/@Tag/@Parameter, Controller 연결, @Schema + +### Application 계층 + +**서비스 계층** → `references/application/service-layer-convention.md` +- Facade/Domain Service 생성, 책임 배치, @Transactional, 타 도메인 접근, Facade 분리 + +### Domain 계층 + +**Entity / VO** → `references/domain/entity-vo-convention.md` +- Entity 생성, 정적 팩토리, VO 판단/구현, 도메인 로직 배치, 검증 위치 + +### Infrastructure 계층 + +**Infrastructure** → `references/infrastructure/infrastructure-convention.md` +- Repository 생성, QueryDSL, BaseEntity/guard(), FK/유니크, Soft delete 필터링 diff --git a/.claude/skills/project-convention/references/application/service-layer-convention.md b/.claude/skills/project-convention/references/application/service-layer-convention.md new file mode 100644 index 000000000..e713464e4 --- /dev/null +++ b/.claude/skills/project-convention/references/application/service-layer-convention.md @@ -0,0 +1,299 @@ +# 서비스 계층 책임 분리 컨벤션 + +## 목차 + +1. [계층 구조 개요](#1-계층-구조-개요) +2. [Facade — Application 계층](#2-facade--application-계층) +3. [Domain Service — Domain 계층](#3-domain-service--domain-계층) +4. [트랜잭션 규칙](#4-트랜잭션-규칙) +5. [계층 간 호출 규칙](#5-계층-간-호출-규칙) +6. [Facade가 커질 때](#6-facade가-커질-때) +7. [체크리스트](#체크리스트) + +--- + +## 1. 계층 구조 개요 + +``` +Controller + ↓ +Facade (@Transactional) ← Application 계층: 유스케이스 조율 + ├── OrderService (@Transactional) ← Domain 계층: 자기 도메인 비즈니스 로직 + ├── ProductService (@Transactional) ← Domain 계층: 타 도메인 Service 호출 가능 + └── MemberService (@Transactional) +``` + +| 계층 | 클래스 | 역할 | +|------|--------|------| +| **Application** | `{Domain}Facade` | 유스케이스 조율, 도메인 간 흐름 조합, DTO 변환 | +| **Domain** | `{Domain}Service` | 자기 도메인 비즈니스 로직, Repository 접근, Entity 조작 | + +Application 계층에는 **Facade만 둔다**. 별도 ApplicationService 개념을 두지 않는다. + +--- + +## 2. Facade — Application 계층 + +### 역할 + +- **유스케이스 조율**: 여러 Domain Service를 호출하여 하나의 비즈니스 흐름을 완성한다 +- **DTO 변환**: Interface ↔ Application DTO 변환의 중간 지점 +- **도메인 간 데이터 조합**: 여러 도메인의 Info를 Result로 묶어 반환한다 +- **트랜잭션 경계**: 여러 Domain Service 호출의 원자성을 보장한다 + +### Facade에 넣는 것 + +- 여러 Domain Service 조합 흐름 +- Entity → Info 변환, 여러 Info → Result 조합 +- 타 도메인 Entity → Data DTO 변환 후 자기 도메인 Service에 전달 + +### Facade에 넣지 않는 것 + +- 비즈니스 규칙, 검증 로직 → Domain Service 또는 Entity +- Repository 직접 호출 → Domain Service +- Entity 상태 변경 로직 → Entity 메서드 + +### 예시 + +```java +@Service +public class OrderFacade { + + private final OrderService orderService; + private final ProductService productService; + private final MemberService memberService; + + @Transactional + public OrderInfo createOrder(OrderCommand.Create command) { + // 1. 타 도메인에서 필요한 데이터 조회 + MemberInfo member = memberService.getMember(command.memberId()); + List products = productService.getProducts(command.productIds()); + + // 2. 타 도메인 Info → 자기 도메인 Data 변환 + OrderMemberData memberData = OrderMemberData.from(member); + List productData = products.stream() + .map(OrderProductData::from) + .toList(); + + // 3. 자기 도메인 Service에 위임 + Order order = orderService.create(memberData, productData); + + // 4. Entity → Info 변환 후 반환 + return OrderInfo.from(order); + } + + @Transactional(readOnly = true) + public OrderDetailResult getOrderDetail(OrderQuery.Detail query) { + OrderInfo order = orderService.getOrderInfo(query.orderId()); + ProductInfo product = productService.getProduct(order.productId()); + MemberInfo member = memberService.getMember(order.memberId()); + + return new OrderDetailResult(order, product, member); + } +} +``` + +--- + +## 3. Domain Service — Domain 계층 + +### 역할 + +- **자기 도메인 비즈니스 로직**: Entity 생성, 상태 변경, 검증 +- **Repository 접근**: 조회, 저장, 삭제 +- **도메인 규칙 실행**: Entity만으로 완결되지 않는 로직 (Repository 조회 필요, 여러 Entity 조율) + +### Domain Service에 넣는 것 + +- Repository를 통한 Entity 조회/저장 +- Entity 생성 → 저장 흐름 +- Entity 자기 필드만으로 완결되지 않는 비즈니스 규칙 +- 같은 도메인 내 여러 Entity 조율 + +### Domain Service에 넣지 않는 것 + +- 타 도메인 Facade/Service 호출 → Facade에서 조율 +- DTO 변환 (Info → Response 등) → Facade 또는 Interface 계층 +- Entity 자기 필드만으로 완결되는 로직 → Entity 메서드 + +### 예시 + +```java +@Service +public class OrderService { + + private final OrderRepository orderRepository; + + @Transactional + public Order create(OrderMemberData member, List products) { + List lines = products.stream() + .map(p -> OrderLine.create(p.productId(), p.name(), p.price())) + .toList(); + Order order = Order.create(member.memberId(), lines); + return orderRepository.save(order); + } + + @Transactional + public void cancel(Long orderId) { + Order order = getOrder(orderId); + order.cancel(); // Entity에 위임 + } + + @Transactional(readOnly = true) + public Order getOrder(Long orderId) { + return orderRepository.findById(orderId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND)); + } + + @Transactional(readOnly = true) + public OrderInfo getOrderInfo(Long orderId) { + return OrderInfo.from(getOrder(orderId)); + } +} +``` + +--- + +## 4. 트랜잭션 규칙 + +### 메서드 레벨에 @Transactional을 붙인다 + +클래스 레벨이 아닌 **메서드 레벨**에 붙인다. 각 메서드의 트랜잭션 성격을 명시적으로 표현하기 위함이다. + +```java +@Service +public class OrderService { + + @Transactional // 변경 + public Order create(...) { ... } + + @Transactional // 변경 + public void cancel(Long orderId) { ... } + + @Transactional(readOnly = true) // 조회 + public Order getOrder(Long id) { ... } +} +``` + +클래스 레벨에 붙이지 않는 이유: +- 조회 메서드에도 불필요한 flush가 발생한다 +- 메서드별 트랜잭션 성격이 코드에서 보이지 않는다 +- 트랜잭션이 불필요한 메서드까지 포함될 수 있다 + +### 조회는 readOnly = true + +조회 전용 메서드에는 반드시 `@Transactional(readOnly = true)`를 붙인다. + +이점: +- JPA dirty checking flush 생략 → 성능 향상 +- DB 읽기 전용 힌트 → DB 최적화 가능 +- 실수로 변경 로직이 포함되면 예외 발생 → 안전장치 + +### Facade + Domain Service 양쪽 모두 @Transactional + +``` +Facade (@Transactional) ← 트랜잭션 시작 + └── OrderService (@Transactional) ← 기존 트랜잭션에 참여 (REQUIRED 전파) + └── ProductService (@Transactional) ← 기존 트랜잭션에 참여 +``` + +- Spring 기본 전파 옵션은 `REQUIRED` — 기존 트랜잭션이 있으면 참여, 없으면 새로 시작 +- Facade에서 시작한 트랜잭션에 Domain Service들이 참여한다 +- Domain Service를 단독 호출하면 자기가 트랜잭션을 시작한다 +- **어디서 호출하든 트랜잭션이 보장된다** + +### readOnly 전파 주의 + +```java +// ⚠️ 주의: Facade가 readOnly인데 하위가 변경하는 경우 +@Transactional(readOnly = true) // Facade +public OrderDetailResult getOrderDetail(...) { + orderService.updateViewCount(...); // ❌ readOnly 트랜잭션 안에서 변경 시도 → 문제 +} +``` + +Facade의 readOnly가 하위로 전파되므로, 조회 Facade에서 변경 Service를 호출하면 안 된다. + +--- + +## 5. 계층 간 호출 규칙 + +### 허용되는 호출 + +``` +Controller → Facade ✅ +Facade → 자기 도메인 Service ✅ +Facade → 타 도메인 Service ✅ +Domain Service → 자기 도메인 Repository ✅ +Entity → Entity (같은 Aggregate 내부) ✅ +``` + +### 금지되는 호출 + +``` +Facade → 타 Facade ❌ 순환 의존, 트랜잭션 경계 혼란 +Domain Service → 타 도메인 Service ❌ 도메인 간 결합 +Domain Service → Facade ❌ 하위 → 상위 역방향 +Domain Service → Repository (타 도메인) ❌ 도메인 간 결합 +Controller → Domain Service 직접 ❌ Facade 우회 +``` + +### Facade가 타 도메인에 접근하는 방법 + +Facade는 타 도메인의 **Domain Service**를 직접 주입받아 호출한다. + +```java +// ✅ 타 도메인 Service 직접 호출 +@Service +public class OrderFacade { + private final OrderService orderService; // 자기 도메인 + private final ProductService productService; // 타 도메인 + private final MemberService memberService; // 타 도메인 +} + +// ❌ 타 Facade 호출 금지 +@Service +public class OrderFacade { + private final ProductFacade productFacade; // 금지 +} +``` + +--- + +## 6. Facade가 커질 때 + +Facade가 비대해지면 **유스케이스 단위로 분리**한다. ApplicationService 개념을 도입하지 않는다. + +```java +// 처음: 하나의 Facade +OrderFacade + +// 커지면: 유스케이스별 분리 +OrderCommandFacade // 생성, 취소, 변경 +OrderQueryFacade // 조회, 검색, 목록 +``` + +분리 기준: +- Command(변경)와 Query(조회)가 자연스러운 첫 번째 분리 지점 +- 그래도 크면 유스케이스 단위로 더 분리 (OrderCheckoutFacade, OrderReturnFacade 등) + +--- + +## 체크리스트 + +**Facade** +- [ ] Facade에 비즈니스 규칙/검증 로직이 없는가? (Domain Service나 Entity에 있어야 함) +- [ ] Facade에서 Repository를 직접 호출하지 않는가? +- [ ] Facade가 타 Facade를 호출하지 않는가? +- [ ] 타 도메인 접근 시 타 도메인의 Domain Service를 직접 호출하는가? + +**Domain Service** +- [ ] 자기 도메인의 Repository만 접근하는가? +- [ ] 타 도메인 Service나 Repository를 직접 호출하지 않는가? +- [ ] Entity만으로 완결되는 로직이 Entity에 있는가? (Service에 빼앗지 않았는가) + +**트랜잭션** +- [ ] @Transactional이 메서드 레벨에 붙어 있는가? (클래스 레벨 아님) +- [ ] 조회 메서드에 `readOnly = true`가 붙어 있는가? +- [ ] readOnly Facade에서 변경 Service를 호출하지 않는가? +- [ ] Facade와 Domain Service 양쪽 모두 @Transactional이 있는가? diff --git a/.claude/skills/project-convention/references/common/dto-convention.md b/.claude/skills/project-convention/references/common/dto-convention.md new file mode 100644 index 000000000..19899c59b --- /dev/null +++ b/.claude/skills/project-convention/references/common/dto-convention.md @@ -0,0 +1,207 @@ +# DTO 컨벤션 + +## 목차 + +1. [계층별 DTO 네이밍](#계층별-dto-네이밍) +2. [DTO 작성 규칙](#dto-작성-규칙) +3. [파라미터 전달 규칙](#파라미터-전달-규칙) +4. [계층 간 흐름](#계층-간-흐름-요약) +5. [체크리스트](#체크리스트) + +--- + +## 계층별 DTO 네이밍 + +| 계층 | 요청 (입력) | 응답 (출력) | 비고 | +|------|------------|------------|------| +| **Interface** | `~Request` | `~Response` | API 스펙 종속, `@Valid` 부착 | +| **Application** | `~Command` / `~Query` | `~Info` (단일) / `~Result` (조합) | 유스케이스 단위 입출력 | +| **Domain Service** | `~Data` | **Entity** 또는 `~Info` | 외부 도메인 정보 명세. 필요할 때만 생성 | + +- Domain Service는 **Entity를 직접 반환하는 게 기본**. `Info`는 Entity 하나로 표현이 안 될 때만 생성한다. +- Application `~Info`는 유스케이스 결과(단일 도메인), `~Result`는 여러 도메인 Info를 조합할 때 사용한다. +- Domain 계층 자체(Entity, VO)는 DTO를 사용하지 않는다. + +--- + +## DTO 작성 규칙 + +### 1. Inner Class + Record 활용 + +DTO는 **record**로 작성하고, 관련 DTO끼리 **Inner Class**로 그룹핑한다. + +```java +public class ProductDto { + + public record CreateRequest( + @NotBlank String name, + @Positive int price + ) { + public ProductCommand.Create toCommand() { + return new ProductCommand.Create(name, price); + } + } + + public record DetailResponse(Long id, String name, int price) { + public static DetailResponse from(ProductInfo info) { + return new DetailResponse(info.id(), info.name(), info.price()); + } + } +} +``` + +```java +public class ProductCommand { + public record Create(String name, int price) {} + public record Update(Long id, String name, int price) {} +} +``` + +```java +public record ProductInfo(Long id, String name, int price) { + public static ProductInfo from(Product entity) { + return new ProductInfo(entity.getId(), entity.getName(), entity.getPrice()); + } +} +``` + +### 2. 변환 메서드 위치: "아는 쪽"에 둔다 + +의존 방향(상위 → 하위)을 지키며, **변환 대상을 아는 쪽**에 메서드를 배치한다. + +| 변환 | 메서드 위치 | 형태 | 예시 | +|------|-----------|------|------| +| Request → Command | Request | `toCommand()` | `request.toCommand()` | +| Entity → Info | Info | `static from()` | `ProductInfo.from(entity)` | +| Info → Response | Response | `static from()` | `DetailResponse.from(info)` | +| Entity → Data (타 도메인) | Data | `static from()` | `OrderProductData.from(product)` | + +> **금지**: 하위 계층이 상위 계층을 아는 것. Domain이 Application DTO를, Application이 Interface DTO를 알면 안 된다. + +### 3. Domain Service의 Data / 반환 + +```java +// 주문 도메인이 상품 도메인에 요구하는 정보 명세 +public record OrderProductData(Long productId, String name, Money price, Long shopId) { + public static OrderProductData from(Product product) { + return new OrderProductData( + product.getId(), product.getName(), product.getPrice(), product.getShopId() + ); + } +} + +// 기본: Entity 직접 반환 +public Order create(OrderMemberData member, List products) { + return Order.create(member.memberId(), products); +} + +// Entity로 표현 불가능할 때만 Info 생성 +public record StockDeductionInfo(int remainingStock, boolean success) {} +``` + +--- + +## 파라미터 전달 규칙 + +### 1. Application → Domain Service 입력 + +파라미터 개수에 따라 전달 방식을 결정한다. + +| 파라미터 수 | 전달 방식 | 예시 | +|------------|----------|------| +| **1~3개** | 원시 타입 / VO 직접 전달 | `orderService.create(memberId, address, shopId)` | +| **4개 이상** | DTO(`~Data`) 사용 | `orderService.create(orderProductData)` | + +```java +// ✅ 파라미터 3개 이하 → 원시 타입/VO +public Order create(Long memberId, Address address, Long shopId) { ... } + +// ✅ 파라미터 4개 이상 → Data DTO +public Order create(OrderProductData productData) { ... } +``` + +### 2. 절대 금지: Domain Service에 타 도메인 객체 노출 + +Domain Service의 메서드 시그니처에 **타 도메인의 Entity/VO가 직접 나타나면 안 된다.** + +```java +// ❌ 절대 금지 - 주문 도메인이 Product 엔티티를 직접 참조 +public class OrderService { + public Order create(Member member, List products) { ... } +} + +// ✅ Data로 변환하여 전달 - 도메인 간 결합 제거 +public class OrderService { + public Order create(OrderMemberData member, List products) { ... } +} +``` + +> Application 계층(Facade)이 타 도메인 Entity → Data 변환을 책임진다. + +### 3. 타 도메인 출력 조합: Application에서 `~Result` + +여러 도메인의 Info를 합쳐야 할 때, **Application 계층이 `~Result`로 조합**한다. + +```java +public record OrderDetailResult( + OrderInfo order, + ProductInfo product, + MemberInfo member +) {} +``` + +```java +@Service +public class OrderFacade { + + public OrderDetailResult getDetail(OrderDetailQuery query) { + OrderInfo order = orderService.getOrder(query.orderId()); + ProductInfo product = productService.getProduct(order.productId()); + MemberInfo member = memberService.getMember(order.memberId()); + + return new OrderDetailResult(order, product, member); + } +} +``` + +| 상황 | Application 응답 | 사용 시점 | +|------|-----------------|----------| +| 단일 도메인 반환 | `~Info` | 대부분의 경우 | +| 여러 도메인 조합 | `~Result` | 다중 도메인 Info를 합칠 때 | + +### Domain Service 응답 기준 + +| 상황 | Domain Service 응답 | 사용 시점 | +|------|-------------------|----------| +| Entity로 충분 | **Entity 직접 반환** | 대부분의 경우 | +| Entity로 표현 불가 | `~Info` | 복합 결과가 필요할 때 | + +--- + +## 계층 간 흐름 요약 + +``` +Client + → ProductCreateRequest (Interface 입력) + → ProductCreateCommand (Application 입력) + → OrderProductData (Domain 입력 - 필요 시) + ← Entity 또는 ~Info (Domain 출력) + ← ProductInfo (Application 출력 - 단일) + ← OrderDetailResult (Application 출력 - 조합) + ← ProductCreateResponse (Interface 출력) +``` + +--- + +## 체크리스트 + +- [ ] DTO는 record로 작성했는가? +- [ ] 관련 DTO끼리 Inner Class로 그룹핑했는가? +- [ ] 변환 메서드가 "아는 쪽"에 있는가? (의존 방향 위반 없는가?) +- [ ] Interface DTO에만 `@Valid`, `@JsonProperty` 등이 붙어 있는가? +- [ ] Application DTO(Command/Info)에 API 스펙 관련 어노테이션이 없는가? +- [ ] Domain Service가 Application DTO를 참조하지 않는가? +- [ ] Domain Service의 Info는 Entity로 충분하지 않을 때만 만들었는가? +- [ ] Domain Service 파라미터 1~3개는 원시 타입/VO, 4개+는 Data DTO인가? +- [ ] Domain Service 메서드 시그니처에 타 도메인 Entity가 노출되지 않는가? +- [ ] 여러 도메인 Info 조합 시 Application에서 `~Result`로 합치는가? diff --git a/.claude/skills/project-convention/references/common/exception-convention.md b/.claude/skills/project-convention/references/common/exception-convention.md new file mode 100644 index 000000000..346e36856 --- /dev/null +++ b/.claude/skills/project-convention/references/common/exception-convention.md @@ -0,0 +1,293 @@ +# 예외처리 및 API 응답 컨벤션 + +## 목차 + +1. [예외 구조](#1-예외-구조) +2. [에러코드 규칙](#2-에러코드-규칙) +3. [API 응답 포맷](#3-api-응답-포맷) +4. [ControllerAdvice 구조](#4-controlleradvice-구조) +5. [예외 흐름](#5-예외-흐름) +6. [패키지 배치](#6-패키지-배치) +7. [도메인별 ErrorCode 추가 가이드](#7-도메인별-errorcode-추가-가이드) +8. [체크리스트](#체크리스트) + +--- + +## 1. 예외 구조 + +### 클래스 다이어그램 + +``` +CoreException (단일 예외 클래스) + └─ ErrorCode (interface) + ├── ErrorType (enum) ← 공통 에러 + ├── OrderErrorCode (enum) ← 주문 도메인 + ├── ProductErrorCode (enum) ← 상품 도메인 + └── ... ← 필요 시 추가 +``` + +### ErrorCode 인터페이스 + +```java +public interface ErrorCode { + HttpStatus getStatus(); + String getCode(); + String getMessage(); +} +``` + +### ErrorType — 공통 에러 + +```java +@Getter +@RequiredArgsConstructor +public enum ErrorType implements ErrorCode { + INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Internal Server Error", "일시적인 오류가 발생했습니다."), + BAD_REQUEST(HttpStatus.BAD_REQUEST, "Bad Request", "잘못된 요청입니다."), + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "Unauthorized", "인증에 실패했습니다."), + NOT_FOUND(HttpStatus.NOT_FOUND, "Not Found", "존재하지 않는 요청입니다."), + CONFLICT(HttpStatus.CONFLICT, "Conflict", "이미 존재하는 리소스입니다."); + + private final HttpStatus status; + private final String code; + private final String message; +} +``` + +- 공통 에러의 code는 **HttpStatus reason phrase**를 그대로 사용 +- 도메인 에러와 구분: code에 `_`가 포함되면 도메인, 아니면 공통 + +### CoreException + +```java +@Getter +public class CoreException extends RuntimeException { + private final ErrorCode errorCode; + private final String customMessage; + + public CoreException(ErrorCode errorCode) { + this(errorCode, null); + } + + public CoreException(ErrorCode errorCode, String customMessage) { + super(customMessage != null ? customMessage : errorCode.getMessage()); + this.errorCode = errorCode; + this.customMessage = customMessage; + } +} +``` + +### 도메인별 ErrorCode + +```java +@Getter +@RequiredArgsConstructor +public enum OrderErrorCode implements ErrorCode { + ALREADY_CANCELLED(HttpStatus.BAD_REQUEST, "ORDER_001", "이미 취소된 주문입니다."), + STOCK_INSUFFICIENT(HttpStatus.BAD_REQUEST, "ORDER_002", "재고가 부족합니다."); + + private final HttpStatus status; + private final String code; + private final String message; +} +``` + +--- + +## 2. 에러코드 규칙 + +### code 체계 + +| 구분 | 형식 | 예시 | +|------|------|------| +| 공통 | HttpStatus reason phrase | `"Bad Request"`, `"Not Found"` | +| 도메인 | `{DOMAIN}_{NNN}` | `"ORDER_001"`, `"PRODUCT_002"` | + +### 번호 규칙 + +- 001부터 단순 순번 +- enum 선언 순서 = 번호 순서 +- 삭제된 번호는 결번 처리 (재사용 금지) + +### 사용법 + +```java +throw new CoreException(ErrorType.NOT_FOUND); // 공통 +throw new CoreException(OrderErrorCode.ALREADY_CANCELLED); // 도메인 +throw new CoreException(OrderErrorCode.STOCK_INSUFFICIENT, "상품 A 재고 부족"); // 커스텀 메시지 +``` + +--- + +## 3. API 응답 포맷 + +### ApiResponse 구조 + +```java +public record ApiResponse(Metadata meta, T data) { + + public record Metadata(Result result, String errorCode, String message) { + public enum Result { SUCCESS, FAIL } + + public static Metadata success() { + return new Metadata(Result.SUCCESS, null, null); + } + + public static Metadata fail(String errorCode, String errorMessage) { + return new Metadata(Result.FAIL, errorCode, errorMessage); + } + } + + public record FieldError(String field, Object value, String reason) {} + + // 성공 + public static ApiResponse success() { + return new ApiResponse<>(Metadata.success(), null); + } + + public static ApiResponse success(T data) { + return new ApiResponse<>(Metadata.success(), data); + } + + // 실패 — 일반 에러 + public static ApiResponse fail(String errorCode, String errorMessage) { + return new ApiResponse<>(Metadata.fail(errorCode, errorMessage), null); + } + + // 실패 — Validation 에러 (필드별 상세) + public static ApiResponse> failValidation( + String errorCode, String errorMessage, List fieldErrors) { + return new ApiResponse<>(Metadata.fail(errorCode, errorMessage), fieldErrors); + } +} +``` + +### 응답 예시 + +```json +// 성공 (데이터 없음) +{ + "meta": { "result": "SUCCESS", "errorCode": null, "message": null }, + "data": null +} + +// 성공 (데이터 있음) +{ + "meta": { "result": "SUCCESS", "errorCode": null, "message": null }, + "data": { "id": 1, "name": "상품A", "price": 50000 } +} + +// 실패 — 비즈니스 에러 +{ + "meta": { "result": "FAIL", "errorCode": "ORDER_001", "message": "이미 취소된 주문입니다." }, + "data": null +} + +// 실패 — 공통 에러 +{ + "meta": { "result": "FAIL", "errorCode": "Not Found", "message": "존재하지 않는 요청입니다." }, + "data": null +} + +// 실패 — Validation 에러 +{ + "meta": { "result": "FAIL", "errorCode": "Bad Request", "message": "잘못된 요청입니다." }, + "data": [ + { "field": "price", "value": -1000, "reason": "0보다 커야 합니다" }, + { "field": "name", "value": "", "reason": "공백일 수 없습니다" } + ] +} +``` + +--- + +## 4. ControllerAdvice 구조 + +### 처리 우선순위 + +| 예외 타입 | 성격 | 응답 형태 | +|----------|------|----------| +| `CoreException` | 비즈니스/도메인 에러 | `meta` + `data: null` | +| `MethodArgumentNotValidException` | @Valid 검증 실패 | `meta` + `data: FieldError[]` | +| `MethodArgumentTypeMismatchException` | 파라미터 타입 불일치 | `meta` + `data: null` | +| `MissingServletRequestParameterException` | 필수 파라미터 누락 | `meta` + `data: null` | +| `HttpMessageNotReadableException` | JSON 파싱 실패 (세분화) | `meta` + `data: null` | +| `NoResourceFoundException` | 존재하지 않는 리소스 | `meta` + `data: null` | +| `Throwable` | 예상 못한 에러 (최후 방어) | `meta` + `data: null` | + +### 핵심 원칙 + +- `@RestControllerAdvice` **하나**로 모든 예외 일괄 처리 +- `CoreException` 핸들러에서 `ErrorCode` 인터페이스로 공통/도메인 에러 통합 처리 +- `HttpMessageNotReadableException`은 `InvalidFormatException`, `MismatchedInputException`, `JsonMappingException`까지 세분화 +- `Throwable` 최후 방어 핸들러 필수 +- 예외는 발생 지점에서 그대로 전파, Application에서 잡아서 변환하지 않는다 + +--- + +## 5. 예외 흐름 + +``` +Domain에서 throw new CoreException(OrderErrorCode.STOCK_INSUFFICIENT) + → Application 통과 (잡지 않음) + → Controller 통과 + → @RestControllerAdvice에서 catch + → ErrorCode에서 status, code, message 추출 + → ApiResponse.fail(code, message) 반환 + +@Valid 실패 시 + → Controller 진입 전 MethodArgumentNotValidException 발생 + → @RestControllerAdvice에서 catch + → FieldError 목록 추출 + → ApiResponse.failValidation(code, message, fieldErrors) 반환 +``` + +--- + +## 6. 패키지 배치 + +``` +support/ +└── error/ + ├── ErrorCode.java ← 인터페이스 + ├── ErrorType.java ← 공통 에러 enum + └── CoreException.java ← 단일 예외 클래스 + +domain/ +├── order/ +│ └── OrderErrorCode.java ← 도메인 패키지 내 배치 +└── product/ + └── ProductErrorCode.java + +interfaces/ +└── api/ + ├── ApiResponse.java ← 공통 응답 포맷 + └── ApiControllerAdvice.java ← 글로벌 예외 핸들러 +``` + +--- + +## 7. 도메인별 ErrorCode 추가 가이드 + +1. `{Domain}ErrorCode` enum 생성, `ErrorCode` 인터페이스 구현 +2. code는 `{DOMAIN}_{001}`부터 순번 부여 +3. 도메인 패키지 내 배치 +4. 사용: `throw new CoreException({Domain}ErrorCode.XXX)` + +--- + +## 체크리스트 + +**예외 설계** +- [ ] 새 에러가 공통(`ErrorType`)인가 도메인(`XxxErrorCode`)인가 판단했는가? +- [ ] 도메인 에러코드 번호가 기존 순번 다음인가? (결번 재사용 금지) +- [ ] `CoreException`에 `ErrorCode` 인터페이스 구현체를 넘기고 있는가? + +**예외 흐름** +- [ ] Domain 예외를 Application에서 잡아서 변환하고 있지 않은가? (그대로 전파) +- [ ] ControllerAdvice에 `Throwable` 최후 방어 핸들러가 있는가? + +**API 응답** +- [ ] 성공 응답이 `ApiResponse.success()` 또는 `ApiResponse.success(data)`를 사용하는가? +- [ ] Validation 에러는 `failValidation`으로 `FieldError[]`를 반환하는가? +- [ ] 일반 에러는 `fail(code, message)`로 `data: null`을 반환하는가? diff --git a/.claude/skills/project-convention/references/common/exception-migration-guide.md b/.claude/skills/project-convention/references/common/exception-migration-guide.md new file mode 100644 index 000000000..b8c6a12f7 --- /dev/null +++ b/.claude/skills/project-convention/references/common/exception-migration-guide.md @@ -0,0 +1,170 @@ +# 예외처리 마이그레이션 가이드 + +기존 코드에서 수정하거나 추가해야 할 항목 목록. + +--- + +## 1. 신규 생성 + +### `ErrorCode` 인터페이스 +- 위치: `com.loopers.support.error.ErrorCode` + +```java +package com.loopers.support.error; + +import org.springframework.http.HttpStatus; + +public interface ErrorCode { + HttpStatus getStatus(); + String getCode(); + String getMessage(); +} +``` + +### `FieldError` record (ApiResponse 내부 또는 별도) +- Validation 에러 필드별 상세용 + +--- + +## 2. 기존 코드 수정 + +### `ErrorType` — `ErrorCode` 인터페이스 구현 추가 + +```diff +- public enum ErrorType { ++ public enum ErrorType implements ErrorCode { +``` + +변경 없이 `implements ErrorCode`만 추가하면 기존 필드(`status`, `code`, `message`)가 인터페이스를 이미 만족하므로 다른 수정 불필요. + +--- + +### `CoreException` — `ErrorType` → `ErrorCode`로 변경 + +```diff + @Getter + public class CoreException extends RuntimeException { +- private final ErrorType errorType; ++ private final ErrorCode errorCode; + private final String customMessage; + +- public CoreException(ErrorType errorType) { +- this(errorType, null); ++ public CoreException(ErrorCode errorCode) { ++ this(errorCode, null); + } + +- public CoreException(ErrorType errorType, String customMessage) { +- super(customMessage != null ? customMessage : errorType.getMessage()); +- this.errorType = errorType; ++ public CoreException(ErrorCode errorCode, String customMessage) { ++ super(customMessage != null ? customMessage : errorCode.getMessage()); ++ this.errorCode = errorCode; + this.customMessage = customMessage; + } + } +``` + +--- + +### `ApiResponse` — `failValidation` 메서드 추가 + +```diff + public record ApiResponse(Metadata meta, T data) { + ++ public record FieldError(String field, Object value, String reason) {} + + // 기존 메서드 유지 ... + ++ public static ApiResponse> failValidation( ++ String errorCode, String errorMessage, List fieldErrors) { ++ return new ApiResponse<>(Metadata.fail(errorCode, errorMessage), fieldErrors); ++ } + } +``` + +--- + +### `ApiControllerAdvice` — 3곳 수정 + +#### (1) `handle(CoreException e)` — getter 이름 변경 + +```diff + @ExceptionHandler + public ResponseEntity> handle(CoreException e) { + log.warn("CoreException : {}", e.getCustomMessage() != null ? e.getCustomMessage() : e.getMessage(), e); +- return failureResponse(e.getErrorType(), e.getCustomMessage()); ++ return failureResponse(e.getErrorCode(), e.getCustomMessage()); + } +``` + +#### (2) `handleBadRequest(MethodArgumentNotValidException e)` — 필드별 에러 반환 + +```diff + @ExceptionHandler + public ResponseEntity> handleBadRequest(MethodArgumentNotValidException e) { +- FieldError fieldError = e.getBindingResult().getFieldError(); +- String message = fieldError != null ? fieldError.getDefaultMessage() : "잘못된 요청입니다."; +- return failureResponse(ErrorType.BAD_REQUEST, message); ++ List fieldErrors = e.getBindingResult() ++ .getFieldErrors() ++ .stream() ++ .map(error -> new ApiResponse.FieldError( ++ error.getField(), ++ error.getRejectedValue(), ++ error.getDefaultMessage() ++ )) ++ .toList(); ++ ++ return ResponseEntity.badRequest() ++ .body(ApiResponse.failValidation( ++ ErrorType.BAD_REQUEST.getCode(), ++ ErrorType.BAD_REQUEST.getMessage(), ++ fieldErrors ++ )); + } +``` + +#### (3) `failureResponse` — `ErrorType` → `ErrorCode` + +```diff +- private ResponseEntity> failureResponse(ErrorType errorType, String errorMessage) { +- return ResponseEntity.status(errorType.getStatus()) +- .body(ApiResponse.fail(errorType.getCode(), errorMessage != null ? errorMessage : errorType.getMessage())); ++ private ResponseEntity> failureResponse(ErrorCode errorCode, String errorMessage) { ++ return ResponseEntity.status(errorCode.getStatus()) ++ .body(ApiResponse.fail(errorCode.getCode(), errorMessage != null ? errorMessage : errorCode.getMessage())); + } +``` + +--- + +## 3. 도메인별 ErrorCode enum — 필요할 때 추가 + +예시: `com.loopers.domain.order.OrderErrorCode` + +```java +@Getter +@RequiredArgsConstructor +public enum OrderErrorCode implements ErrorCode { + ALREADY_CANCELLED(HttpStatus.BAD_REQUEST, "ORDER_001", "이미 취소된 주문입니다."), + STOCK_INSUFFICIENT(HttpStatus.BAD_REQUEST, "ORDER_002", "재고가 부족합니다."); + + private final HttpStatus status; + private final String code; + private final String message; +} +``` + +--- + +## 변경 영향 범위 + +| 파일 | 변경 유형 | 영향도 | +|------|----------|--------| +| `ErrorCode.java` | **신규** | 없음 | +| `ErrorType.java` | `implements ErrorCode` 추가 | 없음 (기존 호환) | +| `CoreException.java` | 필드 타입 변경 | **기존 `getErrorType()` 호출부 전체** | +| `ApiResponse.java` | `FieldError`, `failValidation` 추가 | 없음 (기존 호환) | +| `ApiControllerAdvice.java` | 3곳 수정 | 해당 파일만 | +| 기존 `throw new CoreException(ErrorType.XXX)` | **변경 불필요** | `ErrorType`이 `ErrorCode` 구현하므로 호환 | diff --git a/.claude/skills/project-convention/references/common/package-convention.md b/.claude/skills/project-convention/references/common/package-convention.md new file mode 100644 index 000000000..ffb5b56e5 --- /dev/null +++ b/.claude/skills/project-convention/references/common/package-convention.md @@ -0,0 +1,281 @@ +# 패키지 구조 컨벤션 + +## 목차 + +1. [전체 구조](#1-전체-구조) +2. [계층별 패키지 상세](#2-계층별-패키지-상세) +3. [공통 패키지 상세](#3-공통-패키지-상세) +4. [계층별 클래스 배치 규칙](#4-계층별-클래스-배치-규칙) +5. [의존 방향 규칙](#5-의존-방향-규칙) +6. [새 도메인 추가 가이드](#6-새-도메인-추가-가이드) +7. [체크리스트](#체크리스트) + +--- + +## 1. 전체 구조 + +**계층 우선 + 도메인 하위** 방식을 사용한다. 최상위는 계층(interfaces/application/domain/infrastructure)으로 나누고, 각 계층 안에서 도메인별로 분리한다. + +``` +com.loopers/ +│ +├── interfaces/ ← Interface 계층 +│ ├── api/ ← 공통 (ApiResponse, ControllerAdvice) +│ ├── order/ ← 주문 Controller, Request/Response DTO +│ ├── product/ ← 상품 Controller, Request/Response DTO +│ └── like/ ← 좋아요 Controller, Request/Response DTO +│ +├── application/ ← Application 계층 +│ ├── order/ ← 주문 Facade, Command/Query/Info/Result DTO +│ ├── product/ +│ └── like/ +│ +├── domain/ ← Domain 계층 +│ ├── order/ ← 주문 Entity, VO, Service, Repository(I/F), ErrorCode +│ ├── product/ +│ └── like/ +│ +├── infrastructure/ ← Infrastructure 계층 +│ ├── order/ ← 주문 Repository 구현, JPA +│ ├── product/ +│ └── like/ +│ +└── support/ ← 공통 지원 (에러, 설정, 유틸) + ├── error/ + ├── config/ + └── util/ +``` + +왜 계층 우선인가: +- 계층 간 의존 방향이 패키지 레벨에서 시각적으로 명확하다 +- 같은 계층의 클래스를 한 곳에서 파악할 수 있다 (모든 Controller가 interfaces/ 아래) +- 계층별 공통 패턴이나 Base 클래스를 자연스럽게 배치할 수 있다 + +--- + +## 2. 계층별 패키지 상세 + +### interfaces/ — 풀 구조 (대규모 도메인) + +``` +interfaces/ +├── api/ ← 공통 +│ ├── ApiResponse.java +│ └── ApiControllerAdvice.java +│ +└── order/ ← 도메인별 + ├── OrderController.java ← 고객용 Controller + ├── AdminOrderController.java ← Admin용 Controller + └── dto/ + ├── OrderDto.java ← Inner Class: CreateRequest, DetailResponse ... + └── AdminOrderDto.java ← Admin용 Request/Response +``` + +### application/ — 풀 구조 + +``` +application/ +└── order/ + ├── OrderFacade.java + └── dto/ + ├── OrderCommand.java ← Inner Class: Create, Update ... + ├── OrderQuery.java ← Inner Class: Detail, Search ... + ├── OrderInfo.java ← 단일 도메인 응답 + └── OrderDetailResult.java ← 다중 도메인 조합 응답 (필요 시) +``` + +### domain/ — 풀 구조 + +``` +domain/ +└── order/ + ├── Order.java ← Entity (Aggregate Root) + ├── OrderLine.java ← Entity (하위) + ├── OrderStatus.java ← enum / VO + ├── OrderService.java ← Domain Service + ├── OrderRepository.java ← Repository 인터페이스 + ├── OrderErrorCode.java ← 도메인 에러코드 + └── dto/ ← 도메인 DTO (필요 시) + ├── OrderProductData.java ← 타 도메인 정보 명세 + └── OrderMemberData.java +``` + +### infrastructure/ — 풀 구조 + +``` +infrastructure/ +└── order/ + ├── OrderRepositoryImpl.java ← Repository 구현체 + └── OrderJpaRepository.java ← Spring Data JPA 인터페이스 +``` + +### 간소 구조 (소규모 도메인) + +빈 계층 패키지는 만들지 않는다. 필요해지면 그때 추가한다. + +``` +interfaces/ +└── wishlist/ + ├── WishlistController.java + └── dto/ + └── WishlistDto.java + +application/ +└── wishlist/ + ├── WishlistFacade.java + └── dto/ + └── WishlistInfo.java + +domain/ +└── wishlist/ + ├── Wishlist.java + ├── WishlistService.java + └── WishlistRepository.java +``` + +infrastructure가 JpaRepository 하나뿐이면 domain 패키지에 인터페이스만 두고 Spring Data JPA가 자동 구현하도록 한다. + +--- + +## 3. 공통 패키지 상세 + +### 공통 인터페이스 + +도메인에 속하지 않는 API 레벨 공통 클래스. + +``` +interfaces/ +└── api/ + ├── ApiResponse.java ← 공통 응답 포맷 + └── ApiControllerAdvice.java ← 글로벌 예외 핸들러 +``` + +### support + +도메인 로직이 아닌 **기술 지원** 클래스. + +``` +support/ +├── error/ +│ ├── ErrorCode.java ← 인터페이스 +│ ├── ErrorType.java ← 공통 에러 enum +│ └── CoreException.java ← 단일 예외 클래스 +├── config/ +│ ├── WebMvcConfig.java +│ ├── SecurityConfig.java +│ └── JpaConfig.java +└── util/ ← 필요 시만 생성 + └── DateUtils.java +``` + +support에 넣으면 안 되는 것: +- 도메인 로직이 포함된 클래스 → 해당 도메인 패키지로 +- 특정 도메인에만 쓰이는 유틸 → 해당 도메인 패키지로 + +--- + +## 4. 계층별 클래스 배치 규칙 + +### interfaces/{domain}/ + +| 클래스 | 네이밍 | 예시 | +|--------|--------|------| +| Controller (고객) | `{Domain}Controller` | `OrderController` | +| Controller (Admin) | `Admin{Domain}Controller` | `AdminOrderController` | +| Request DTO | `{Domain}Dto.{Action}Request` | `OrderDto.CreateRequest` | +| Response DTO | `{Domain}Dto.{Action}Response` | `OrderDto.DetailResponse` | +| Admin DTO | `Admin{Domain}Dto.{Action}Response` | `AdminOrderDto.DetailResponse` | + +### application/{domain}/ + +| 클래스 | 네이밍 | 예시 | +|--------|--------|------| +| Facade | `{Domain}Facade` | `OrderFacade` | +| Command DTO | `{Domain}Command.{Action}` | `OrderCommand.Create` | +| Query DTO | `{Domain}Query.{Action}` | `OrderQuery.Search` | +| Info DTO | `{Domain}Info` | `OrderInfo` | +| Result DTO | `{Domain}{Detail}Result` | `OrderDetailResult` | + +### domain/{domain}/ + +| 클래스 | 네이밍 | 예시 | +|--------|--------|------| +| Entity | `{Domain}` | `Order` | +| Domain Service | `{Domain}Service` | `OrderService` | +| Repository (인터페이스) | `{Domain}Repository` | `OrderRepository` | +| ErrorCode | `{Domain}ErrorCode` | `OrderErrorCode` | +| VO / enum | 의미에 맞게 | `OrderStatus`, `Money` | +| Data DTO | `{Target}Data` | `OrderProductData` | + +### infrastructure/{domain}/ + +| 클래스 | 네이밍 | 예시 | +|--------|--------|------| +| Repository 구현체 | `{Domain}RepositoryImpl` | `OrderRepositoryImpl` | +| JPA Repository | `{Domain}JpaRepository` | `OrderJpaRepository` | +| 외부 API 클라이언트 | `{External}Client` | `PaymentClient` | + +--- + +## 5. 의존 방향 규칙 + +``` +interfaces → application → domain ← infrastructure +``` + +- **interfaces**는 application을 알 수 있다. domain을 직접 참조하지 않는다. +- **application**은 domain을 알 수 있다. interfaces를 알면 안 된다. +- **domain**은 아무 계층도 알지 못한다. 순수 Java로만 구성한다. +- **infrastructure**는 domain을 알 수 있다 (Repository 인터페이스 구현). + +### 도메인 간 의존 + +- 도메인 간 **Entity 직접 참조 금지** +- Application 계층(Facade)에서 타 도메인의 Domain Service를 호출하여 조합한다 +- 필요 시 Domain의 Data DTO로 정보를 전달한다 + +```java +// ✅ Application에서 타 도메인 Service 호출 +// application/order/OrderFacade.java +@Service +public class OrderFacade { + private final ProductService productService; // domain/product/ + private final OrderService orderService; // domain/order/ +} + +// ❌ Domain에서 타 도메인 직접 참조 +// domain/order/OrderService.java +public class OrderService { + private final ProductRepository productRepository; // 금지 +} +``` + +--- + +## 6. 새 도메인 추가 가이드 + +1. `domain/{domain}/` 패키지부터 시작 (Entity, Repository 인터페이스) +2. API가 필요하면 `interfaces/{domain}/`, `application/{domain}/` 추가 +3. 커스텀 Repository 구현이 필요하면 `infrastructure/{domain}/` 추가 +4. 도메인 에러가 필요하면 `domain/{domain}/{Domain}ErrorCode.java` 추가 +5. 빈 계층은 만들지 않는다 — 필요할 때 추가 + +--- + +## 체크리스트 + +**구조** +- [ ] 최상위가 계층(interfaces/application/domain/infrastructure)으로 나뉘어 있는가? +- [ ] 각 계층 안에서 도메인별로 패키지가 분리되어 있는가? +- [ ] 빈 계층 패키지가 없는가? (불필요한 빈 폴더 금지) +- [ ] 공통 클래스가 interfaces/api/ 또는 support/ 아래에 있는가? + +**의존 방향** +- [ ] interfaces → application → domain ← infrastructure 방향을 지키는가? +- [ ] domain 패키지에 Spring 의존성(`@Service`, `@Transactional` 등)이 없는가? +- [ ] 도메인 간 Entity 직접 참조가 없는가? + +**네이밍** +- [ ] Controller, Facade, Service, Repository 네이밍이 규칙을 따르는가? +- [ ] DTO가 해당 계층의 dto/ 패키지에 있는가? +- [ ] ErrorCode가 domain/{domain}/ 패키지에 있는가? diff --git a/.claude/skills/project-convention/references/common/test-convention.md b/.claude/skills/project-convention/references/common/test-convention.md new file mode 100644 index 000000000..660a233e4 --- /dev/null +++ b/.claude/skills/project-convention/references/common/test-convention.md @@ -0,0 +1,397 @@ +# 테스트 컨벤션 + +## 목차 + +1. [프레임워크 및 도구](#1-프레임워크-및-도구) +2. [테스트 피라미드 — 계층별 전략](#2-테스트-피라미드--계층별-전략) +3. [테스트 클래스 구조](#3-테스트-클래스-구조) +4. [네이밍 규칙](#4-네이밍-규칙) +5. [테스트 더블 전략](#5-테스트-더블-전략) +6. [테스트 패키지 배치](#6-테스트-패키지-배치) +7. [DB 정리 전략](#7-db-정리-전략) +8. [체크리스트](#체크리스트) + +--- + +## 1. 프레임워크 및 도구 + +| 도구 | 용도 | +|------|------| +| JUnit 5 | 테스트 프레임워크 | +| AssertJ | 가독성 높은 검증 (assertThat, assertThatThrownBy) | +| Mockito | 테스트 더블 (mock, stub, verify) | +| @SpringBootTest | 통합 테스트, E2E | +| TestRestTemplate | E2E HTTP 요청 | +| DatabaseCleanUp | 테스트 간 DB 격리 | + +--- + +## 2. 테스트 피라미드 — 계층별 전략 + +### 단위 테스트 (Unit Test) + +| 항목 | 내용 | +|------|------| +| 대상 | Entity, VO, Domain Service | +| 환경 | **Spring 없이 순수 JVM** | +| 테스트 더블 | **Fake 우선**, 필요 시 Mockito | +| 속도 | 빠름 (ms 단위) | +| 비중 | 가장 많이 작성 | + +```java +class NameTest { + @Test + void createName_whenValidNameProvided() { + Name name = new Name("홍길동"); + assertThat(name.getValue()).isEqualTo("홍길동"); + } +} +``` + +### 통합 테스트 (Integration Test) + +| 항목 | 내용 | +|------|------| +| 대상 | Service, Facade (여러 컴포넌트 연결 상태) | +| 환경 | `@SpringBootTest`, 실제 Bean, Test DB | +| 테스트 더블 | **실제 Bean 사용** (DB 포함) | +| 속도 | 보통 | +| 비중 | 핵심 비즈니스 흐름 위주 | + +```java +@SpringBootTest +class UserServiceIntegrationTest { + @Autowired UserService userService; + @Autowired DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { databaseCleanUp.truncateAllTables(); } +} +``` + +### E2E 테스트 (End-to-End Test) + +| 항목 | 내용 | +|------|------| +| 대상 | Controller → Service → DB 전체 | +| 환경 | `@SpringBootTest(webEnvironment = RANDOM_PORT)` | +| 도구 | `TestRestTemplate` | +| 속도 | 느림 | +| 비중 | 주요 시나리오만 선별 | + +```java +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class UserV1ApiE2ETest { + @Autowired TestRestTemplate testRestTemplate; + @Autowired DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { databaseCleanUp.truncateAllTables(); } +} +``` + +--- + +## 3. 테스트 클래스 구조 + +### @Nested로 행위별 그룹핑 + +테스트 클래스 내부를 `@Nested`로 행위(기능) 단위로 그룹핑한다. 부모 `@DisplayName`에 행위를, 자식에 조건+결과를 작성한다. + +```java +class UserModelTest { + + // 공통 픽스처는 @BeforeEach에서 초기화 + @BeforeEach + void setUp() { + encoder = new FakePasswordEncoder(); + validLoginId = new LoginId("testuser123"); + // ... + } + + @DisplayName("유저 모델을 생성할 때, ") + @Nested + class Create { + + @DisplayName("모든 필드가 주어지면, 정상적으로 생성된다.") + @Test + void createUserModel_whenAllDataProvided() { + // act + UserModel user = new UserModel(...); + + // assert + assertAll( + () -> assertThat(user.getLoginId()).isEqualTo(validLoginId), + () -> assertThat(user.getName()).isEqualTo(validName) + ); + } + + @DisplayName("로그인 ID가 누락되면 예외가 발생한다.") + @Test + void createUserModel_whenLoginIdIsNull() { + assertThatThrownBy(() -> new UserModel(null, ...)) + .isInstanceOf(CoreException.class); + } + } + + @DisplayName("비밀번호를 변경할 때, ") + @Nested + class ChangePassword { + // ... + } +} +``` + +### 테스트 메서드 내부 구조: arrange / act / assert + +주석으로 세 섹션을 구분한다. 단, arrange가 없으면 생략 가능. + +```java +@Test +void createOrder_whenAllDataProvided() { + // arrange + Long memberId = 1L; + int price = 50000; + + // act + Order order = Order.create(memberId, price); + + // assert + assertThat(order.getStatus()).isEqualTo(OrderStatus.CREATED); +} +``` + +예외 검증처럼 한 줄로 끝나면 주석 없이 작성해도 된다. + +```java +@Test +void createName_whenNameIsNull() { + assertThatThrownBy(() -> new Name(null)) + .isInstanceOf(CoreException.class); +} +``` + +### 검증: assertAll로 다중 검증 묶기 + +여러 필드를 한번에 검증할 때 `assertAll`을 사용한다. 첫 번째 실패에서 멈추지 않고 모든 검증 결과를 보여준다. + +```java +assertAll( + () -> assertThat(result.loginId()).isEqualTo(loginId), + () -> assertThat(result.name()).isEqualTo(name), + () -> assertThat(result.email()).isEqualTo(email) +); +``` + +### @ParameterizedTest로 경계값 테스트 + +같은 로직에 여러 입력을 테스트할 때 사용한다. + +```java +@DisplayName("2자 이상 10자 이하의 이름이 주어지면, 정상적으로 생성된다.") +@ParameterizedTest +@ValueSource(strings = {"홍길", "홍길동", "가나다라마바사아자차"}) +void createName_whenValidNameProvided(String validNameValue) { + Name name = new Name(validNameValue); + assertThat(name.getValue()).isEqualTo(validNameValue); +} +``` + +--- + +## 4. 네이밍 규칙 + +### 테스트 클래스명 + +| 테스트 유형 | 클래스명 패턴 | 예시 | +|-----------|-----------|------| +| 단위 (Entity/VO) | `{클래스명}Test` | `NameTest`, `OrderTest` | +| 단위 (Domain Service) | `{클래스명}Test` | `OrderServiceTest` | +| 통합 | `{클래스명}IntegrationTest` | `UserServiceIntegrationTest` | +| E2E | `{API명}E2ETest` | `UserV1ApiE2ETest` | + +### 테스트 메서드명 + +**영문 camelCase** + `@DisplayName` 한글 조합. + +패턴: `{action}_{condition}` + +```java +// @DisplayName이 의도를 전달, 메서드명은 식별용 +@DisplayName("로그인 ID가 누락되면 예외가 발생한다.") +@Test +void createUserModel_whenLoginIdIsNull() { ... } + +@DisplayName("올바른 현재 비밀번호와 유효한 새 비밀번호가 주어지면 비밀번호가 변경된다.") +@Test +void changePassword_success() { ... } +``` + +조건이 없는 성공 케이스는 `_{condition}` 대신 `_success` 또는 `_whenAllDataProvided`를 사용한다. + +### @DisplayName 규칙 + +| 위치 | 형식 | 예시 | +|------|------|------| +| `@Nested` 클래스 | `"{행위}할 때, "` | `"유저 모델을 생성할 때, "` | +| `@Test` 메서드 | `"{조건}이면, {결과}한다."` | `"로그인 ID가 누락되면 예외가 발생한다."` | + +부모 + 자식을 이어 읽으면 자연스러운 한국어 문장이 된다: +> "유저 모델을 생성할 때, 로그인 ID가 누락되면 예외가 발생한다." + +--- + +## 5. 테스트 더블 전략 + +### 계층별 테스트 더블 선택 + +| 테스트 대상 | 더블 전략 | 이유 | +|-----------|---------|------| +| **Entity, VO** | 더블 불필요 (순수 로직) | 외부 의존 없음 | +| **Domain Service** | **Fake 우선** | 실제 동작과 유사, 상태 검증 가능 | +| **Application Facade** | **Mockito mock()** | 여러 Service 조합, Fake 비용 큼 | +| **통합 / E2E** | **실제 Bean** | 연동 검증이 목적 | + +### Fake — Domain 단위 테스트의 기본 + +```java +// 인터페이스를 구현하는 가짜 객체 — 실제처럼 동작 +public class FakePasswordEncoder implements PasswordEncoder { + + @Override + public String encode(String rawPassword) { + return "ENCODED_" + rawPassword; + } + + @Override + public boolean matches(String rawPassword, String encodedPassword) { + return encodedPassword.equals("ENCODED_" + rawPassword); + } +} +``` + +Fake를 사용하는 이유: +- encode()와 matches()가 **실제처럼 연동**된다 — mock은 각각 별도로 stub해야 함 +- **상태 기반 검증**이 가능하다 — "ENCODED_Test1234!@#"이 실제로 저장되었는지 확인 +- 테스트가 **구현 세부사항에 결합하지 않는다** — mock은 어떤 메서드가 호출되는지에 결합 + +### Mockito — Application 계층 테스트 + +```java +@ExtendWith(MockitoExtension.class) +class OrderFacadeTest { + + @Mock OrderService orderService; + @Mock ProductService productService; + @InjectMocks OrderFacade orderFacade; + + @Test + void createOrder_success() { + // arrange (stub) + when(productService.getProduct(1L)).thenReturn(productInfo); + when(orderService.create(any())).thenReturn(order); + + // act + OrderDetailResult result = orderFacade.createOrder(command); + + // assert + assertThat(result).isNotNull(); + verify(orderService).create(any()); + } +} +``` + +### 테스트 더블 선택 판단 플로우 + +``` +테스트 대상이 외부 의존이 있는가? + ├── NO → 더블 불필요 (Entity, VO) + └── YES → 의존이 인터페이스로 분리되어 있는가? + ├── YES → Fake를 만들 가치가 있는가? + │ ├── 상태 연동이 중요 → Fake + │ └── 단순 위임 → Mockito mock() + └── NO → Mockito mock() +``` + +### Fake 배치 + +Fake 클래스는 테스트 소스 내에 배치한다. + +``` +src/test/java/com/loopers/ +├── domain/ +│ ├── UserModelTest.java +│ └── FakePasswordEncoder.java ← 테스트 소스에 배치 +└── utils/ + └── DatabaseCleanUp.java +``` + +--- + +## 6. 테스트 패키지 배치 + +테스트 클래스는 **프로덕션 코드와 동일한 패키지 구조**를 따른다. + +``` +src/test/java/com/loopers/ +├── domain/ +│ ├── order/ +│ │ ├── OrderTest.java ← Entity 단위 +│ │ └── OrderServiceTest.java ← Domain Service 단위 +│ ├── product/ +│ │ └── ProductTest.java +│ └── member/ +│ └── ... +├── application/ +│ └── order/ +│ └── OrderFacadeTest.java ← Application mock 테스트 +├── interfaces/ +│ └── order/ +│ └── OrderV1ApiE2ETest.java ← E2E +└── utils/ + ├── DatabaseCleanUp.java + └── FakePasswordEncoder.java ← 공통 Fake +``` + +--- + +## 7. DB 정리 전략 + +통합/E2E 테스트에서 테스트 간 격리를 위해 `@AfterEach`에서 DB를 정리한다. + +```java +@AfterEach +void tearDown() { + databaseCleanUp.truncateAllTables(); +} +``` + +왜 `truncate`인가: +- `@Transactional` 롤백은 `RANDOM_PORT` E2E에서 동작하지 않는다 (별도 스레드) +- `deleteAll()`은 외래키 순서를 관리해야 하고 느리다 +- `truncate`는 빠르고 auto_increment도 초기화된다 + +--- + +## 체크리스트 + +**구조** +- [ ] 행위별로 `@Nested`로 그룹핑했는가? +- [ ] `@DisplayName`이 부모+자식 이어 읽으면 자연스러운 문장인가? +- [ ] 메서드 내부가 arrange / act / assert 순서인가? +- [ ] 다중 검증 시 `assertAll`을 사용했는가? +- [ ] 경계값 테스트에 `@ParameterizedTest`를 활용했는가? + +**네이밍** +- [ ] 테스트 클래스명이 `{클래스}Test` / `IntegrationTest` / `E2ETest` 패턴인가? +- [ ] 메서드명이 `{action}_{condition}` 패턴 영문 camelCase인가? +- [ ] `@DisplayName`이 한글로 의도를 명확히 전달하는가? + +**테스트 더블** +- [ ] Domain 단위 테스트에서 Fake를 우선 사용했는가? +- [ ] Application 테스트에서 Mockito mock()을 사용했는가? +- [ ] 통합/E2E에서 실제 Bean을 사용했는가? +- [ ] Fake가 테스트 소스에 배치되어 있는가? + +**DB 격리** +- [ ] 통합/E2E 테스트에 `@AfterEach` + `truncateAllTables()`가 있는가? diff --git a/.claude/skills/project-convention/references/domain/entity-vo-convention.md b/.claude/skills/project-convention/references/domain/entity-vo-convention.md new file mode 100644 index 000000000..091cc1fac --- /dev/null +++ b/.claude/skills/project-convention/references/domain/entity-vo-convention.md @@ -0,0 +1,396 @@ +# 엔티티 / VO 설계 컨벤션 + +## 목차 + +1. [Entity 작성 규칙](#1-entity-작성-규칙) +2. [VO 설계 규칙](#2-vo-설계-규칙) +3. [검증 위치 규칙](#3-검증-위치-규칙) +4. [Entity vs Domain Service 로직 배치](#4-entity-vs-domain-service-로직-배치) +5. [체크리스트](#체크리스트) + +--- + +## 1. Entity 작성 규칙 + +### 기본 구조 + +```java +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Order { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private Long memberId; + + @Embedded + private Money totalPrice; + + @Enumerated(EnumType.STRING) + private OrderStatus status; + + @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) + private List orderLines = new ArrayList<>(); + + // === 생성 === // + + public static Order create(Long memberId, int price, List lines) { + Order order = new Order(); + order.memberId = memberId; + order.totalPrice = Money.of(price); + order.status = OrderStatus.CREATED; + order.orderLines = lines.stream() + .map(OrderLine::create) + .toList(); + return order; + } + + // === 도메인 로직 === // + + public void cancel() { + validateCancellable(); + this.status = OrderStatus.CANCELLED; + } + + // === 검증 === // + + private void validateCancellable() { + if (this.status != OrderStatus.CREATED) { + throw new CoreException(OrderErrorCode.ALREADY_CANCELLED); + } + } +} +``` + +### 생성 패턴: 정적 팩토리 메서드 + +모든 Entity는 **정적 팩토리 메서드**로 생성한다. 생성자를 직접 노출하지 않는다. + +```java +// ✅ 정적 팩토리 메서드 +public static Order create(Long memberId, int price) { + Order order = new Order(); + order.memberId = memberId; + order.totalPrice = Money.of(price); + order.status = OrderStatus.CREATED; + return order; +} + +// ❌ 생성자 직접 노출 +public Order(Long memberId, int price) { ... } + +// ❌ @Builder +@Builder +public Order(Long memberId, int price) { ... } +``` + +왜 정적 팩토리인가: +- 생성 의도를 메서드 이름으로 표현할 수 있다 (`create`, `register`, `createFromImport`) +- 생성 시점에 VO 변환, 초기값 설정, 검증을 Entity가 통제한다 +- 불변식(invariant)을 생성 시점부터 보장한다 + +### 접근 제어 + +| 규칙 | 설정 | +|------|------| +| 기본 생성자 | `@NoArgsConstructor(access = PROTECTED)` — JPA 프록시 전용 | +| Setter | **사용 금지** — 도메인 메서드로 상태 변경 | +| Getter | `@Getter` 허용 — 읽기는 자유 | +| 필드 접근 | `private` — 직접 할당은 Entity 내부에서만 | + +```java +// ❌ Setter 금지 +order.setStatus(OrderStatus.CANCELLED); + +// ✅ 도메인 메서드 +order.cancel(); +``` + +### Entity 내부 구조 순서 + +```java +@Entity +public class Order { + // 1. 필드 (id, 일반 필드, VO, 연관관계) + // 2. 정적 팩토리 메서드 (create, register ...) + // 3. 도메인 로직 메서드 (cancel, changeStatus ...) + // 4. private 검증 메서드 (validateXxx ...) +} +``` + +--- + +## 2. VO 설계 규칙 + +### VO 생성 기준 + +**단순 검증만 필요한 필드는 VO로 만들지 않는다.** 형식 규칙, 도메인 행위, 복합 규칙이 있을 때만 VO를 만든다. + +| 검증 유형 | VO 생성 | 처리 위치 | +|----------|---------|----------| +| null 검증 | ❌ | `@NotNull`, Entity 메서드 | +| 길이 검증 | ❌ | `@Size`, Entity 메서드 | +| 범위 검증 (0 이상 등) | ❌ | `@Positive`, Entity 메서드 | +| **형식 규칙** (이메일 정규식, 비밀번호 정책) | ✅ | VO 내부 | +| **도메인 행위** (계산, 변환, 비교) | ✅ | VO 내부 | +| **복합 규칙** (암호화, 포맷팅) | ✅ | VO 내부 | + +```java +// ❌ VO 안 만듦 — 단순 검증뿐 +String name; // 길이 제한만 → @Size로 충분 +int quantity; // 0 이상만 → @Positive로 충분 +Long memberId; // 단순 식별자 +LocalDateTime createdAt; // 단순 타임스탬프 + +// ✅ VO 만듦 — 형식 규칙 또는 행위 존재 +Email email; // 정규식 형식 검증 +Money price; // add(), subtract() 계산 행위 +Password password; // 암호화 로직 + 비밀번호 정책 검증 +PhoneNumber phone; // 형식 검증 + 포맷팅 +``` + +### VO 구현 방식 + +| 조건 | 구현 방식 | 예시 | +|------|----------|------| +| Entity 필드로 DB에 저장됨 | `@Embeddable` 클래스 | Money, Password, Email | +| DB 저장과 무관 | `record` | DateRange, PriceRange | + +#### @Embeddable VO (DB 저장) + +```java +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@EqualsAndHashCode +public class Money { + + @Column(nullable = false) + private int amount; + + private Money(int amount) { + validate(amount); + this.amount = amount; + } + + public static Money of(int amount) { + return new Money(amount); + } + + public Money add(Money other) { + return Money.of(this.amount + other.amount); + } + + private void validate(int amount) { + if (amount < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "금액은 0 이상이어야 합니다."); + } + } +} +``` + +필수 사항: +- `@NoArgsConstructor(PROTECTED)` — JPA 프록시용 +- `@EqualsAndHashCode` — 값 동등성 +- 생성자 `private` + 정적 팩토리 `of()` — 생성 통제 +- 생성 시 자기 검증 — 유효하지 않은 VO는 존재할 수 없다 + +#### record VO (비저장) + +```java +public record DateRange(LocalDate start, LocalDate end) { + + public DateRange { + if (start.isAfter(end)) { + throw new CoreException(ErrorType.BAD_REQUEST, + "시작일이 종료일보다 늦을 수 없습니다."); + } + } + + public boolean contains(LocalDate date) { + return !date.isBefore(start) && !date.isAfter(end); + } +} +``` + +record는 불변, equals/hashCode/toString 자동 생성. compact constructor에서 자기 검증. + +### VO 공통 원칙 + +**① 생성 시 자기 검증**: VO는 생성되는 순간 유효성이 보장된다. + +**② 불변**: 상태 변경이 필요하면 새 VO를 반환한다. + +```java +// ✅ 새 VO 반환 +public Money add(Money other) { + return Money.of(this.amount + other.amount); +} + +// ❌ 내부 상태 변경 +public void add(Money other) { + this.amount += other.amount; +} +``` + +**③ 값 동등성**: 내부 값이 같으면 같은 객체. `@Embeddable`은 `@EqualsAndHashCode` 명시, `record`는 자동. + +### VO 전달 방식: Entity 내부에서 생성 + +VO는 **무조건 Entity(Aggregate Root) 내부에서 생성**한다. 바깥에서 원시값을 받아서 Entity가 VO로 변환한다. + +```java +// ✅ Entity 내부에서 VO 생성 — 원시값을 받는다 +public static Order create(Long memberId, String email, int price) { + Order order = new Order(); + order.memberId = memberId; + order.email = Email.of(email); // 내부에서 VO 생성 + order.price = Money.of(price); // 내부에서 VO 생성 + order.status = OrderStatus.CREATED; + return order; +} + +// ❌ 바깥에서 VO를 만들어서 전달 +public static Order create(Long memberId, Email email, Money price) { ... } +``` + +왜 Entity 내부에서 생성하는가: +- Entity가 자기 VO의 생성을 완전히 통제한다 +- 바깥 계층이 도메인 VO 클래스를 알 필요 없다 (결합도 최소) +- 불변성 보장이 Entity 경계 안에서 완결된다 +- 호출 지점은 Domain Service 1~2곳뿐이므로 파라미터가 많아도 실질적 부담이 없다 + +--- + +## 3. 검증 위치 규칙 + +세 수준으로 나눈다. + +| 검증 수준 | 위치 | 기준 | +|----------|------|------| +| **단일 값 형식/규칙** | VO 내부 | 그 값 하나만으로 판단 가능 | +| **Entity 내부 크로스필드** | Entity 메서드 | 같은 Entity의 여러 필드 간 관계 | +| **외부 의존 크로스필드** | Domain Service | Repository 조회나 타 도메인 데이터 필요 | + +### 판단 플로우 + +``` +이 검증이 단일 값의 형식/규칙인가? + ├── YES → VO 내부 + └── NO → 같은 Entity의 여러 필드 간 관계인가? + ├── YES → Entity 메서드 + └── NO → Domain Service +``` + +### 예시 + +```java +// 1. 단일 값 → VO 내부 +@Embeddable +public class Email { + private Email(String value) { + if (!value.matches("^[\\w.-]+@[\\w.-]+\\.[a-zA-Z]{2,}$")) { + throw new CoreException(ErrorType.BAD_REQUEST, "이메일 형식이 올바르지 않습니다."); + } + this.value = value; + } +} + +// 2. Entity 크로스필드 → Entity 메서드 +@Entity +public class Promotion { + private LocalDate startDate; + private LocalDate endDate; + + public static Promotion create(LocalDate start, LocalDate end) { + validateDateRange(start, end); + // ... + } + + private static void validateDateRange(LocalDate start, LocalDate end) { + if (start.isAfter(end)) { + throw new CoreException(PromotionErrorCode.INVALID_DATE_RANGE); + } + } +} + +// 3. 외부 의존 → Domain Service +public class OrderService { + public Order create(OrderMemberData member, List products) { + validateOrderLimit(member); // 회원 등급별 주문 한도 — 타 도메인 데이터 필요 + // ... + } +} +``` + +--- + +## 4. Entity vs Domain Service 로직 배치 + +핵심 기준: **"자기 상태(필드)만으로 완결되는가?"** + +### Entity에 둔다 + +- 자기 상태 변경: `order.cancel()` +- 자기 상태 검증: `order.validateCancellable()` +- 자기 상태로 계산: `order.calculateTotalPrice()` +- 생성 로직: `Order.create(...)` + +### Domain Service에 둔다 + +- Repository 조회 필요: `orderService.findOrThrow(id)` +- 타 도메인 데이터 필요: `orderService.create(memberData, productData)` +- 여러 Entity 조율: `orderService.transferOwnership(from, to)` +- 외부 시스템 연동: `orderService.requestPayment(order)` + +### 판단 플로우 + +``` +이 로직이 자기 필드만으로 완결되는가? + ├── YES → Entity에 둔다 + └── NO → 뭐가 더 필요한가? + ├── Repository 조회 → Domain Service + ├── 타 도메인 정보 → Domain Service + ├── 여러 Entity 조율 → Domain Service + └── 외부 시스템 → Domain Service +``` + +### 분리 신호 + +Entity에 먼저 넣고, 아래 신호가 보이면 Domain Service로 추출한다. + +| 신호 | 액션 | +|------|------| +| Entity 메서드가 20개 이상 | 관련 로직 묶어서 Domain Service로 추출 | +| Entity 테스트에 mock이 필요해짐 | 외부 의존이 있다는 뜻 → Domain Service로 | +| 같은 검증 로직이 여러 Entity에 중복 | Domain Service 또는 공통 VO로 추출 | + +--- + +## 체크리스트 + +**Entity** +- [ ] 정적 팩토리 메서드로 생성하는가? +- [ ] `@NoArgsConstructor(access = PROTECTED)`가 있는가? +- [ ] Setter 없이 도메인 메서드로 상태를 변경하는가? +- [ ] 자기 필드만으로 완결되는 로직만 Entity에 있는가? +- [ ] VO를 Entity 내부에서 원시값으로부터 생성하는가? + +**VO** +- [ ] 단순 검증(null, 길이, 범위)만 있는 필드를 VO로 만들지 않았는가? +- [ ] DB 저장 VO는 `@Embeddable` + `@EqualsAndHashCode`인가? +- [ ] 비저장 VO는 `record`인가? +- [ ] 생성 시 자기 검증이 포함되어 있는가? +- [ ] 상태 변경 시 새 VO를 반환하는가? (불변) + +**검증 위치** +- [ ] 단일 값 검증이 VO 내부에 있는가? +- [ ] 크로스필드 검증이 Entity 메서드에 있는가? +- [ ] 외부 의존 검증이 Domain Service에 있는가? + +**로직 배치** +- [ ] Repository/타 도메인 필요한 로직이 Domain Service에 있는가? +- [ ] Entity에 외부 의존이 침투하지 않았는가? diff --git a/.claude/skills/project-convention/references/infrastructure/infrastructure-convention.md b/.claude/skills/project-convention/references/infrastructure/infrastructure-convention.md new file mode 100644 index 000000000..7ad5fc3ca --- /dev/null +++ b/.claude/skills/project-convention/references/infrastructure/infrastructure-convention.md @@ -0,0 +1,482 @@ +# Infrastructure 계층 컨벤션 + +## 목차 + +1. [Repository 패턴](#1-repository-패턴) +2. [QueryDSL 규칙](#2-querydsl-규칙) +3. [BaseEntity](#3-baseentity) +4. [DB 제약조건 규칙](#4-db-제약조건-규칙) +5. [멀티 모듈 구조](#5-멀티-모듈-구조) +6. [체크리스트](#체크리스트) + +--- + +## 1. Repository 패턴 + +### 3-클래스 패턴 + +Repository는 **domain 인터페이스 + infrastructure 구현체 + JpaRepository** 3개로 구성한다. + +``` +domain/ +└── order/ + └── OrderRepository.java ← 순수 인터페이스 (Spring 의존 없음) + +infrastructure/ +└── order/ + ├── OrderJpaRepository.java ← Spring Data JPA 인터페이스 + └── OrderRepositoryImpl.java ← 어댑터: OrderRepository 구현, JpaRepository에 위임 +``` + +왜 3-클래스인가: +- **domain이 Spring을 모른다** — `OrderRepository`는 순수 Java 인터페이스. JPA/Spring Data 의존이 없어서 domain 계층의 순수성이 보장된다 +- **테스트가 쉽다** — domain 단위 테스트에서 `OrderRepository`의 Fake를 만들면 DB 없이 테스트 가능 +- **구현 교체가 자유롭다** — JPA에서 다른 저장소로 바꿔도 domain은 변경 불필요 + +### domain Repository 인터페이스 + +Spring 의존 없는 **순수 Java 인터페이스**로 작성한다. domain이 필요로 하는 메서드만 선언한다. + +```java +// domain/order/OrderRepository.java +public interface OrderRepository { + + Order save(Order order); + + Optional findById(Long id); + + List findByUserId(Long userId); +} +``` + +**금지:** +- `JpaRepository` 상속 +- Spring 어노테이션 (`@Repository`, `@Query` 등) +- `Pageable`, `Page` 등 Spring Data 타입 + +### JpaRepository 인터페이스 + +Spring Data JPA의 자동 구현을 활용하는 인터페이스. infrastructure에 배치한다. + +```java +// infrastructure/order/OrderJpaRepository.java +public interface OrderJpaRepository extends JpaRepository { + + List findByUserId(Long userId); + + Optional findByIdAndDeletedAtIsNull(Long id); +} +``` + +### RepositoryImpl — 어댑터 + +domain Repository를 구현하고, JpaRepository에 위임한다. `@Repository`를 사용한다. + +```java +// infrastructure/order/OrderRepositoryImpl.java +@Repository +@RequiredArgsConstructor +public class OrderRepositoryImpl implements OrderRepository { + + private final OrderJpaRepository orderJpaRepository; + + @Override + public Order save(Order order) { + return orderJpaRepository.save(order); + } + + @Override + public Optional findById(Long id) { + return orderJpaRepository.findByIdAndDeletedAtIsNull(id); + } + + @Override + public List findByUserId(Long userId) { + return orderJpaRepository.findByUserId(userId); + } +} +``` + +`@Repository`를 사용하는 이유: +- **의미론적 명확성** — 이 클래스가 데이터 접근 계층임을 명시한다 +- **영속성 예외 변환** — JPA 벤더별 예외를 Spring `DataAccessException`으로 자동 변환한다 +- **Spring 스테레오타입 관례** — `@Controller`/`@Service`/`@Repository`는 계층별 표준 어노테이션이다 + +### Soft Delete 조회 처리 + +soft delete된 엔티티 필터링은 **RepositoryImpl에서 처리**한다. domain Repository 인터페이스의 `findById`를 호출하면 내부적으로 `deletedAt IS NULL` 조건이 적용된다. + +```java +// domain 인터페이스 — soft delete를 모른다 +Optional findById(Long id); + +// RepositoryImpl — soft delete 필터링을 여기서 처리 +@Override +public Optional findById(Long id) { + return orderJpaRepository.findByIdAndDeletedAtIsNull(id); +} +``` + +domain이 "삭제된 데이터"를 알 필요 없다. infrastructure가 저장소 세부사항으로서 처리한다. + +### 네이밍 규칙 + +| 클래스 | 네이밍 | 어노테이션 | 위치 | +|--------|--------|-----------|------| +| domain 인터페이스 | `{Domain}Repository` | 없음 | `domain/{domain}/` | +| JPA 인터페이스 | `{Domain}JpaRepository` | 없음 (자동) | `infrastructure/{domain}/` | +| 어댑터 구현체 | `{Domain}RepositoryImpl` | `@Repository` | `infrastructure/{domain}/` | + +--- + +## 2. QueryDSL 규칙 + +### RepositoryImpl에 직접 작성 + +QueryDSL 쿼리는 **RepositoryImpl에 직접 작성**한다. RepositoryImpl이 이미 어댑터 역할을 하고 있으므로, 단순 CRUD(JpaRepository 위임)와 복잡 쿼리(QueryDSL)를 한 곳에서 관리한다. + +```java +@Repository +@RequiredArgsConstructor +public class ProductRepositoryImpl implements ProductRepository { + + private final ProductJpaRepository productJpaRepository; + private final JPAQueryFactory queryFactory; + + // === 단순 CRUD: JpaRepository에 위임 === // + + @Override + public Product save(Product product) { + return productJpaRepository.save(product); + } + + @Override + public Optional findById(Long id) { + return productJpaRepository.findByIdAndDeletedAtIsNull(id); + } + + // === 복잡 쿼리: QueryDSL 직접 작성 === // + + @Override + public Page search(ProductSearchCondition condition, Pageable pageable) { + QProduct product = QProduct.product; + QBrand brand = QBrand.brand; + + BooleanBuilder builder = new BooleanBuilder(); + builder.and(product.deletedAt.isNull()); + builder.and(brand.deletedAt.isNull()); + + if (condition.brandId() != null) { + builder.and(product.brand.id.eq(condition.brandId())); + } + + List content = queryFactory + .selectFrom(product) + .join(product.brand, brand) + .where(builder) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .orderBy(buildOrderSpecifier(condition.sort(), product)) + .fetch(); + + Long total = queryFactory + .select(product.count()) + .from(product) + .join(product.brand, brand) + .where(builder) + .fetchOne(); + + return new PageImpl<>(content, pageable, total != null ? total : 0L); + } +} +``` + +### 분리 시점 + +RepositoryImpl의 QueryDSL 메서드가 **5개 이상**이 되거나 쿼리 복잡도가 높아지면, 별도 클래스로 분리한다. + +``` +// 초기 — RepositoryImpl에 직접 +infrastructure/ +└── product/ + ├── ProductJpaRepository.java + └── ProductRepositoryImpl.java ← JPA 위임 + QueryDSL 모두 + +// 쿼리가 많아지면 — 분리 +infrastructure/ +└── product/ + ├── ProductJpaRepository.java + ├── ProductQueryRepository.java ← QueryDSL 전용 (NEW) + └── ProductRepositoryImpl.java ← JPA 위임 + QueryRepository에 위임 +``` + +```java +// 분리 후 QueryRepository +@Repository +@RequiredArgsConstructor +public class ProductQueryRepository { + + private final JPAQueryFactory queryFactory; + + public Page search(ProductSearchCondition condition, Pageable pageable) { + // QueryDSL 쿼리 ... + } +} + +// 분리 후 RepositoryImpl +@Repository +@RequiredArgsConstructor +public class ProductRepositoryImpl implements ProductRepository { + + private final ProductJpaRepository productJpaRepository; + private final ProductQueryRepository productQueryRepository; + + @Override + public Page search(ProductSearchCondition condition, Pageable pageable) { + return productQueryRepository.search(condition, pageable); + } +} +``` + +### 동적 정렬 + +정렬 조건은 **QueryDSL `OrderSpecifier`로 변환**한다. Controller에서 받은 `sort` 파라미터를 기반으로 한다. + +```java +private OrderSpecifier buildOrderSpecifier(String sort, QProduct product) { + if (sort == null) return product.createdAt.desc(); + + return switch (sort) { + case "price_asc" -> product.price.asc(); + case "likes_desc" -> product.likeCount.desc(); + default -> product.createdAt.desc(); // latest + }; +} +``` + +### 검색 조건 DTO + +QueryDSL 검색 조건은 domain 패키지에 **record**로 정의한다. 쿼리 파라미터를 담는 용도이므로 domain DTO(`~Condition`)로 둔다. + +```java +// domain/product/dto/ProductSearchCondition.java +public record ProductSearchCondition( + Long brandId, + String sort +) {} +``` + +--- + +## 3. BaseEntity + +### 구조 + +모든 Entity는 `BaseEntity`를 상속한다. `modules/jpa` 모듈에 위치하여 전 앱에서 재사용한다. + +```java +// modules/jpa — com.loopers.domain.BaseEntity +@MappedSuperclass +@Getter +public abstract class BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private final Long id = 0L; + + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private ZonedDateTime updatedAt; + + @Column(name = "deleted_at") + private ZonedDateTime deletedAt; + + protected void guard() {} // 검증 훅 (PrePersist/PreUpdate) + + @PrePersist + private void prePersist() { ... } // createdAt, updatedAt 자동 설정 + + @PreUpdate + private void preUpdate() { ... } // updatedAt 자동 갱신 + + public void delete() { ... } // 멱등 soft delete + public void restore() { ... } // 멱등 복원 +} +``` + +### 제공하는 것 + +| 기능 | 메서드/필드 | 동작 | +|------|-----------|------| +| PK 자동 생성 | `id` (IDENTITY) | DB에서 자동 할당 | +| 생성일 자동 기록 | `createdAt` | `@PrePersist`에서 설정, 이후 변경 불가 | +| 수정일 자동 갱신 | `updatedAt` | `@PrePersist`/`@PreUpdate`에서 갱신 | +| Soft Delete | `deletedAt` + `delete()` | 멱등, null이면 삭제 안 됨 | +| 복원 | `restore()` | 멱등, `deletedAt`을 null로 | +| 검증 훅 | `guard()` | 하위 Entity가 override하여 PrePersist/PreUpdate 시 검증 | + +### Entity에서의 사용 + +```java +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Brand extends BaseEntity { + + @Column(nullable = false, unique = true) + private String name; + + public static Brand create(String name) { + Brand brand = new Brand(); + brand.name = name; + return brand; + } + + public void update(String name) { + this.name = name; + } + + // guard()를 override하여 저장 시점 검증 추가 가능 + @Override + protected void guard() { + if (name == null || name.isBlank()) { + throw new CoreException(BrandErrorCode.NAME_REQUIRED); + } + } +} +``` + +### 주의사항 + +- Entity에서 `id`, `createdAt`, `updatedAt`, `deletedAt`을 **직접 설정하지 않는다** — BaseEntity가 관리 +- `delete()`는 BaseEntity의 메서드를 그대로 사용한다 — 도메인별 삭제 로직은 Service에서 조율 +- 정적 팩토리 메서드에서 `id`를 파라미터로 받지 않는다 — DB가 할당 + +--- + +## 4. DB 제약조건 규칙 + +### FK 제약 미사용 + +테이블 간 외래키 제약조건을 **사용하지 않는다**. 무결성은 애플리케이션 레벨에서 보장한다. + +FK를 쓰지 않는 이유: +- 잠금 전파로 인한 데드락 위험 +- 삭제 순서 강제로 운영 복잡도 증가 +- 테이블 간 결합으로 독립 배포/마이그레이션 어려움 + +### 참조 방식 + +| 관계 | 참조 방식 | 예시 | +|------|----------|------| +| **같은 도메인** (Brand → Product) | 객체참조 + FK 없음 | `@ManyToOne` + `NO_CONSTRAINT` | +| **다른 도메인** 간 | ID 참조 | `private Long userId` | + +```java +// 같은 도메인: 객체참조 (Brand → Product는 같은 상품 도메인) +@ManyToOne(fetch = FetchType.LAZY) +@JoinColumn( + name = "brand_id", + foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT) +) +private Brand brand; + +// 다른 도메인: ID 참조 (Order → User는 다른 도메인) +@Column(name = "user_id", nullable = false) +private Long userId; +``` + +### @OneToMany 미사용 + +`@OneToMany`를 사용하지 않는다. 하위 엔티티는 ID로 참조하고, 조회는 별도 Repository로 한다. + +```java +// ❌ @OneToMany 사용 +@OneToMany(mappedBy = "order") +private List orderItems; + +// ✅ ID 참조 + 별도 조회 +// Order에는 orderItems 필드 없음 +// OrderItem에 orderId 필드 +@Column(name = "order_id", nullable = false) +private Long orderId; + +// 조회는 Service/Repository에서 +List items = orderItemRepository.findByOrderId(orderId); +``` + +### 유니크 제약 사용 + +테이블 **내부** 유니크 제약은 사용한다. 동시성(더블클릭 등) 상황에서 중복을 방지한다. + +```java +// 단일 컬럼 유니크 +@Column(nullable = false, unique = true) +private String name; + +// 복합 유니크 +@Table(uniqueConstraints = { + @UniqueConstraint(columnNames = {"user_id", "product_id"}) +}) +public class Like extends BaseEntity { ... } +``` + +--- + +## 5. 멀티 모듈 구조 + +### 현재 구조 + +``` +loop-pack-be-l2-vol3-java/ +├── apps/ +│ ├── commerce-api/ ← 메인 API 앱 +│ ├── commerce-streamer/ ← Kafka 컨슈머 +│ └── commerce-batch/ ← 배치 +└── modules/ + ├── jpa/ ← BaseEntity, QueryDSL/JPA/DataSource Config + ├── kafka/ + └── redis/ +``` + +### 모듈 간 역할 분담 + +| 위치 | 포함하는 것 | +|------|-----------| +| `modules/jpa` | `BaseEntity`, `QueryDslConfig`, `JpaConfig`, `DataSourceConfig` | +| `apps/commerce-api` | 도메인 코드, Controller, Facade, Service, Entity, Repository, Infrastructure | + +### 패키지 배치 원칙 + +- `modules/jpa`의 BaseEntity는 `com.loopers.domain` 패키지에 위치 — 앱의 Entity가 자연스럽게 상속 +- Config 클래스는 `com.loopers.config.jpa` 패키지에 위치 — 앱의 `support/config`와 분리 +- **도메인 로직은 modules에 넣지 않는다** — modules는 기술 인프라만 제공 + +--- + +## 체크리스트 + +**Repository 패턴** +- [ ] domain Repository가 순수 Java 인터페이스인가? (Spring 의존 없음) +- [ ] JpaRepository가 infrastructure에 있는가? +- [ ] RepositoryImpl에 `@Repository`가 붙어 있는가? +- [ ] RepositoryImpl이 domain Repository를 implements하는가? +- [ ] soft delete 필터링이 RepositoryImpl에서 처리되는가? + +**QueryDSL** +- [ ] QueryDSL 쿼리가 RepositoryImpl에 작성되어 있는가? (또는 분리 시 QueryRepository) +- [ ] JPAQueryFactory를 생성자 주입으로 받는가? +- [ ] 동적 정렬이 OrderSpecifier로 처리되는가? + +**BaseEntity** +- [ ] 모든 Entity가 BaseEntity를 상속하는가? +- [ ] Entity에서 id, createdAt, updatedAt, deletedAt을 직접 설정하지 않는가? +- [ ] 저장 시점 검증이 필요하면 guard()를 override하는가? + +**DB 제약조건** +- [ ] FK 제약조건을 사용하지 않는가? (NO_CONSTRAINT) +- [ ] 같은 도메인은 객체참조, 다른 도메인은 ID 참조인가? +- [ ] @OneToMany를 사용하지 않는가? +- [ ] 유니크 제약이 필요한 곳에 적용되어 있는가? diff --git a/.claude/skills/project-convention/references/interfaces/api-convention.md b/.claude/skills/project-convention/references/interfaces/api-convention.md new file mode 100644 index 000000000..82aac5f8d --- /dev/null +++ b/.claude/skills/project-convention/references/interfaces/api-convention.md @@ -0,0 +1,448 @@ +# API 설계 컨벤션 + +## 목차 + +1. [URL 구조](#1-url-구조) +2. [HTTP 메서드 규칙](#2-http-메서드-규칙) +3. [HTTP 상태 코드](#3-http-상태-코드) +4. [엔드포인트 설계 패턴](#4-엔드포인트-설계-패턴) +5. [쿼리 파라미터 규칙](#5-쿼리-파라미터-규칙) +6. [Controller 분리 규칙](#6-controller-분리-규칙) +7. [요청/응답 본문 규칙](#7-요청응답-본문-규칙) +8. [체크리스트](#체크리스트) + +--- + +## 1. URL 구조 + +### API Prefix — 액터별 이중 prefix + +| 대상 | Prefix | 예시 | +|------|--------|------| +| 대고객 (Guest/User) | `/api/v1` | `/api/v1/products` | +| 어드민 (Admin) | `/api-admin/v1` | `/api-admin/v1/products` | + +같은 도메인이라도 액터별로 prefix가 다르다. 고객용과 Admin용은 인증 방식, 요청/응답 DTO, 비즈니스 정책이 모두 다르기 때문이다. + +### 버전 전략 — URL 경로 기반 + +버전은 **URL 경로**에 포함한다. Header 기반이나 쿼리 파라미터 방식보다 직관적이고, 디버깅/문서화가 쉽다. + +``` +/api/v1/products ✅ URL 경로 +/api/products?version=1 ❌ 쿼리 파라미터 +Accept: application/v1 ❌ Header 기반 +``` + +### 리소스 네이밍 — 복수형, 소문자, 케밥케이스 + +리소스명은 **복수형**을 사용한다. REST에서 리소스는 "컬렉션"을 나타내며, 단건 접근은 `/{id}`로 구분한다. + +``` +/api/v1/products ✅ 복수형 +/api/v1/products/{productId} ✅ 컬렉션 → 단건 +/api/v1/product ❌ 단수형 +/api/v1/Products ❌ 대문자 +``` + +다중 단어 리소스는 **케밥케이스(kebab-case)**를 사용한다. + +``` +/api/v1/order-items ✅ 케밥케이스 +/api/v1/orderItems ❌ camelCase +/api/v1/order_items ❌ snake_case +``` + +### 경로 변수 네이밍 + +경로 변수는 **camelCase**로 작성하고, 어떤 리소스의 ID인지 명확히 표현한다. + +``` +/api/v1/products/{productId} ✅ 리소스명 + Id +/api/v1/products/{id} ❌ 모호한 id +``` + +--- + +## 2. HTTP 메서드 규칙 + +### 메서드별 용도 + +| Method | 용도 | 멱등성 | 요청 Body | +|--------|------|--------|----------| +| **GET** | 리소스 조회 (목록/단건) | ✅ | 없음 | +| **POST** | 리소스 생성, 비CRUD 행위 | ❌ | 있음 | +| **PUT** | 리소스 전체 수정 | ✅ | 있음 | +| **DELETE** | 리소스 삭제 | ✅ | 없음 | + +### PUT만 사용, PATCH 미사용 + +수정 API는 **PUT**으로 통일한다. 클라이언트가 수정 가능한 필드를 전부 보내는 "전체 교체" 방식이다. + +```java +// PUT /api/v1/products/{productId} +// → 클라이언트가 모든 수정 가능 필드를 전송 +public record UpdateRequest( + @NotBlank String name, + @Positive int price, + @PositiveOrZero int stock + ) {} +``` + +PATCH를 사용하지 않는 이유: +- 현재 도메인의 수정 대상 필드가 적어 부분 수정의 실익이 없다 +- 전체 교체 방식이 구현/검증이 단순하다 +- null과 "값을 지우겠다"의 구분이 불필요하다 + +향후 필드가 많아져서 부분 수정이 자연스러운 경우가 생기면, 해당 API에 한해 PATCH를 도입할 수 있다. + +### GET에 Body를 넣지 않는다 + +GET 요청의 필터/정렬/페이지네이션은 **쿼리 파라미터**로 전달한다. GET Body는 일부 인프라에서 무시될 수 있다. + +``` +GET /api/v1/products?brandId=1&sort=latest&page=0&size=20 ✅ +GET /api/v1/products body: { "brandId": 1 } ❌ +``` + +--- + +## 3. HTTP 상태 코드 + +### 성공 응답 + +| 상황 | 상태 코드 | 응답 Body | +|------|----------|----------| +| 조회 성공 | **200 OK** | `ApiResponse.success(data)` | +| 생성 성공 | **201 Created** | `ApiResponse.success(data)` | +| 수정 성공 | **200 OK** | `ApiResponse.success(data)` 또는 `ApiResponse.success()` | +| 삭제 성공 | **200 OK** | `ApiResponse.success()` | + +생성(POST)만 **201**로 구분한다. 삭제에 204(No Content)를 쓰지 않는 이유는 `ApiResponse` 래퍼를 일관되게 유지하기 위함이다 — 204는 body가 비어야 하므로 `ApiResponse` 포맷과 충돌한다. + +```java +// Controller 예시 +@PostMapping +public ResponseEntity> create(...) { + ProductInfo info = productFacade.create(request.toCommand()); + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.success(ProductDetailResponse.from(info))); +} + +@DeleteMapping("/{productId}") +public ResponseEntity> delete(...) { + productFacade.delete(productId); + return ResponseEntity.ok(ApiResponse.success()); +} +``` + +### 에러 응답 + +에러 응답의 HTTP 상태 코드는 **ErrorCode.getStatus()가 결정**한다. 에러를 200으로 보내지 않는다. + +| 상황 | 상태 코드 | 결정 주체 | +|------|----------|----------| +| Validation 실패 | **400** | ControllerAdvice 고정 | +| 비즈니스 에러 | **ErrorCode.getStatus()** | 도메인 ErrorCode enum | +| 서버 에러 | **500** | ControllerAdvice 최후 방어 | + +ErrorCode에 정의된 status가 그대로 HTTP 상태 코드가 된다: + +``` +OrderErrorCode.STOCK_INSUFFICIENT → HttpStatus.BAD_REQUEST → 400 +ErrorType.NOT_FOUND → HttpStatus.NOT_FOUND → 404 +BrandErrorCode.DUPLICATE_NAME → HttpStatus.CONFLICT → 409 +``` + +### 자주 쓰는 에러 상태 코드 + +| 상태 코드 | 의미 | 사용 시점 | +|----------|------|----------| +| 400 | Bad Request | Validation 실패, 잘못된 요청 | +| 401 | Unauthorized | 인증 실패 (로그인 필요) | +| 403 | Forbidden | 권한 없음 (본인 리소스 아님) | +| 404 | Not Found | 리소스 없음, soft delete된 리소스 | +| 409 | Conflict | 중복 (브랜드명 중복 등) | +| 500 | Internal Server Error | 예상치 못한 서버 에러 | + +--- + +## 4. 엔드포인트 설계 패턴 + +### 패턴 ①: 표준 CRUD + +대부분의 리소스는 이 패턴을 따른다. + +``` +GET /api/v1/{resources} ← 목록 조회 +GET /api/v1/{resources}/{id} ← 단건 조회 +POST /api/v1/{resources} ← 생성 +PUT /api/v1/{resources}/{id} ← 수정 +DELETE /api/v1/{resources}/{id} ← 삭제 +``` + +``` +// 예시: 브랜드 Admin CRUD +GET /api-admin/v1/brands +GET /api-admin/v1/brands/{brandId} +POST /api-admin/v1/brands +PUT /api-admin/v1/brands/{brandId} +DELETE /api-admin/v1/brands/{brandId} +``` + +### 패턴 ②: 중첩 리소스 (Nested Resource) + +리소스가 상위 리소스에 **종속**될 때 사용한다. "이 상품에 대한 좋아요"처럼 소속 관계가 명확한 경우다. + +``` +POST /api/v1/{parent}/{parentId}/{child} +DELETE /api/v1/{parent}/{parentId}/{child} +``` + +``` +// 예시: 상품의 좋아요 +POST /api/v1/products/{productId}/likes ← 좋아요 등록 +DELETE /api/v1/products/{productId}/likes ← 좋아요 취소 +``` + +중첩 리소스 사용 기준: +- 하위 리소스가 상위 리소스 없이는 의미가 없을 때 +- URL만 보고 "무엇에 대한 행위인지" 파악 가능해야 할 때 +- 깊이는 **2단계까지만** 허용한다 (`/a/{aId}/b` ✅, `/a/{aId}/b/{bId}/c` ❌) + +### 패턴 ③: 소유자 기준 조회 + +"내 리소스 목록"을 조회할 때, 소유자를 URL에 표현한다. + +``` +GET /api/v1/{owner}/{ownerId}/{resources} +``` + +``` +// 예시: 내가 좋아요한 상품 목록 +GET /api/v1/users/{userId}/likes +``` + +### 비CRUD 행위 표현 + +CRUD로 매핑이 어려운 행위는 **리소스 하위에 동사를 붙인다**. 단, 가능하면 리소스 중심으로 먼저 설계하고, 정말 안 될 때만 사용한다. + +``` +// 리소스로 표현 가능하면 리소스 방식 우선 +POST /api/v1/orders ✅ 주문 "생성"으로 표현 + +// 리소스로 표현이 어려운 행위 +POST /api/v1/products/{productId}/restock ← 재입고 (향후 예시) +``` + +--- + +## 5. 쿼리 파라미터 규칙 + +### 페이지네이션 — Offset 기반 기본 + +| 파라미터 | 설명 | 기본값 | +|----------|------|--------| +| `page` | 페이지 번호 (0부터 시작) | `0` | +| `size` | 페이지당 항목 수 | `20` | + +``` +GET /api-admin/v1/brands?page=0&size=20 +GET /api/v1/products?page=1&size=10 +``` + +Spring Data의 `Pageable`과 자연스럽게 연동된다. Cursor 기반 페이지네이션은 무한 스크롤 등 필요한 API에 한해 별도 도입한다. + +### 필터링 — 쿼리 파라미터로 전달 + +필터 조건은 **필드명을 그대로** 쿼리 파라미터명으로 사용한다. + +``` +GET /api/v1/products?brandId=1 +GET /api-admin/v1/products?brandId=1 +``` + +### 정렬 — sort 파라미터 + +정렬 기준은 `sort` 파라미터로 전달한다. 값은 **snake_case**로 표현한다. + +``` +GET /api/v1/products?sort=latest ← 최신순 (기본값) +GET /api/v1/products?sort=price_asc ← 가격 낮은순 +GET /api/v1/products?sort=likes_desc ← 좋아요 많은순 +``` + +### 날짜 범위 필터 + +기간 필터는 `startAt`, `endAt` 파라미터를 사용한다. + +``` +GET /api/v1/orders?startAt=2025-01-01&endAt=2025-01-31 +``` + +### 파라미터 네이밍 규칙 + +쿼리 파라미터명은 **camelCase**를 사용한다. JSON 필드명과 일관성을 유지한다. + +``` +?brandId=1&startAt=2025-01-01 ✅ camelCase +?brand_id=1&start_at=2025-01-01 ❌ snake_case +?brand-id=1 ❌ kebab-case +``` + +--- + +## 6. Controller 분리 규칙 + +### 고객 / Admin Controller 분리 + +같은 도메인이라도 **고객용과 Admin용 Controller를 분리**한다. 인증 방식, 요청/응답 DTO, prefix가 모두 다르기 때문이다. + +``` +interfaces/ +└── product/ + ├── ProductController.java ← /api/v1/products (고객) + ├── AdminProductController.java ← /api-admin/v1/products (Admin) + └── dto/ + ├── ProductDto.java ← 고객용 Request/Response + └── AdminProductDto.java ← Admin용 Request/Response +``` + +### Controller 네이밍 + +| 대상 | 네이밍 | RequestMapping | +|------|--------|----------------| +| 고객 | `{Domain}Controller` | `@RequestMapping("/api/v1/{resources}")` | +| Admin | `Admin{Domain}Controller` | `@RequestMapping("/api-admin/v1/{resources}")` | + +```java +@RestController +@RequestMapping("/api/v1/products") +public class ProductController { + private final ProductFacade productFacade; + + @GetMapping("/{productId}") + public ApiResponse getProduct(...) { ... } +} + +@RestController +@RequestMapping("/api-admin/v1/products") +public class AdminProductController { + private final ProductFacade productFacade; + + @GetMapping("/{productId}") + public ApiResponse getProduct(...) { ... } +} +``` + +### Facade 공유 규칙 + +고객 Controller와 Admin Controller는 **같은 Facade를 공유할 수 있다**. 단, Admin 전용 로직이 커지면 별도 Facade로 분리한다. + +``` +// 초기 — Facade 공유 +ProductController → ProductFacade +AdminProductController → ProductFacade + +// Admin 로직이 커지면 — Facade 분리 +ProductController → ProductFacade +AdminProductController → AdminProductFacade +``` + +분리 시점: Admin 전용 메서드가 Facade의 절반 이상을 차지하거나, Admin만의 복잡한 유스케이스가 생길 때. + +### 도메인 간 API가 겹칠 때 + +좋아요 목록 조회(`GET /api/v1/users/{userId}/likes`)처럼 URL의 루트 리소스와 실제 도메인이 다른 경우: + +``` +// 좋아요 도메인이 담당한다 — URL의 "likes"가 핵심 리소스 +interfaces/ +└── like/ + └── LikeController.java ← /api/v1/users/{userId}/likes + ← /api/v1/products/{productId}/likes +``` + +Controller를 어디에 둘지는 **핵심 리소스(행위의 주체)**가 기준이다. 좋아요 등록/취소/조회 모두 Like 도메인의 행위이므로 Like의 interfaces에 둔다. + +--- + +## 7. 요청/응답 본문 규칙 + +### 생성 요청 → 생성된 리소스 반환 + +POST로 리소스를 생성하면, **생성된 리소스 정보를 응답에 포함**한다. 클라이언트가 별도 조회 없이 바로 사용할 수 있다. + +```json +// POST /api-admin/v1/brands → 201 Created +{ + "meta": { "result": "SUCCESS", "errorCode": null, "message": null }, + "data": { "id": 1, "name": "ACNE STUDIOS" } +} +``` + +### 수정 요청 → 수정된 리소스 반환 (선택) + +PUT 수정 후 변경된 리소스를 반환한다. 반환할 필요가 없으면 `ApiResponse.success()`만 반환해도 된다. + +### 삭제 요청 → 빈 data + +DELETE 성공 시 `data: null`로 반환한다. + +```json +// DELETE /api-admin/v1/brands/{brandId} → 200 OK +{ + "meta": { "result": "SUCCESS", "errorCode": null, "message": null }, + "data": null +} +``` + +### 목록 응답 구조 + +목록 조회 시 페이지네이션 메타 정보를 포함한다. + +```json +{ + "meta": { "result": "SUCCESS", "errorCode": null, "message": null }, + "data": { + "content": [ ... ], + "page": 0, + "size": 20, + "totalElements": 58, + "totalPages": 3 + } +} +``` + +--- + +## 체크리스트 + +**URL 구조** +- [ ] 고객 API는 `/api/v1/`, Admin API는 `/api-admin/v1/` prefix인가? +- [ ] 리소스명이 복수형 소문자인가? +- [ ] 경로 변수가 `{리소스명Id}` 형태의 camelCase인가? +- [ ] 중첩 리소스가 2단계를 초과하지 않는가? + +**HTTP 메서드/상태 코드** +- [ ] GET 조회, POST 생성, PUT 수정, DELETE 삭제를 지키는가? +- [ ] GET 요청에 Body가 없는가? +- [ ] 생성 성공은 201, 나머지 성공은 200인가? +- [ ] 에러 응답이 200이 아닌 ErrorCode.getStatus() 기준인가? + +**쿼리 파라미터** +- [ ] 필터/정렬/페이지네이션이 쿼리 파라미터로 전달되는가? +- [ ] 파라미터명이 camelCase인가? +- [ ] 페이지네이션이 `page` + `size` 형태인가? + +**Controller 분리** +- [ ] 고객/Admin Controller가 분리되어 있는가? +- [ ] Admin Controller 네이밍이 `Admin{Domain}Controller`인가? +- [ ] 고객/Admin DTO가 분리되어 있는가? +- [ ] Controller가 핵심 리소스의 도메인 패키지에 배치되어 있는가? + +**요청/응답** +- [ ] 생성 응답에 생성된 리소스 정보가 포함되는가? +- [ ] 삭제 응답이 `ApiResponse.success()`인가? +- [ ] 목록 응답에 페이지네이션 메타 정보가 있는가? +- [ ] 모든 응답이 `ApiResponse` 래퍼로 감싸져 있는가? diff --git a/.claude/skills/project-convention/references/interfaces/swagger-convention.md b/.claude/skills/project-convention/references/interfaces/swagger-convention.md new file mode 100644 index 000000000..d7b021395 --- /dev/null +++ b/.claude/skills/project-convention/references/interfaces/swagger-convention.md @@ -0,0 +1,391 @@ +# API 문서화 (Swagger) 컨벤션 + +## 목차 + +1. [ApiSpec 인터페이스 패턴](#1-apispec-인터페이스-패턴) +2. [패키지 배치와 네이밍](#2-패키지-배치와-네이밍) +3. [어노테이션 규칙](#3-어노테이션-규칙) +4. [Controller 연결](#4-controller-연결) +5. [DTO와 Schema 규칙](#5-dto와-schema-규칙) +6. [에러 응답 문서화](#6-에러-응답-문서화) +7. [체크리스트](#체크리스트) + +--- + +## 1. ApiSpec 인터페이스 패턴 + +### Swagger 어노테이션을 별도 인터페이스로 분리한다 + +Controller에 Swagger 어노테이션을 직접 달지 않는다. **ApiSpec 인터페이스**에 문서화 어노테이션을 몰아넣고, Controller가 이를 구현한다. + +``` +interfaces/ +└── user/ + ├── UserV1ApiSpec.java ← Swagger 어노테이션 (인터페이스) + ├── UserController.java ← implements UserV1ApiSpec + └── dto/ + └── UserV1Dto.java +``` + +왜 분리하는가: +- **Controller가 깨끗하다** — 비즈니스 흐름(요청 → Facade → 응답)만 보인다. Swagger 어노테이션 10줄이 메서드마다 붙으면 가독성이 급격히 떨어진다 +- **문서와 구현이 독립적으로 변경된다** — 문서 설명을 바꿔도 Controller diff가 생기지 않는다 +- **리뷰가 분리된다** — API 스펙 리뷰와 구현 리뷰를 따로 할 수 있다 + +### ApiSpec 구조 + +```java +@Tag(name = "User V1 API", description = "사용자 API 입니다.") +public interface UserV1ApiSpec { + + @Operation( + summary = "회원가입", + description = "새로운 사용자를 등록합니다." + ) + ApiResponse signup( + @RequestBody(description = "회원가입 요청 정보") + UserV1Dto.SignupRequest request + ); + + @Operation( + summary = "내 정보 조회", + description = "인증된 사용자의 정보를 조회합니다. 헤더에 X-Loopers-LoginId와 X-Loopers-LoginPw를 포함해야 합니다." + ) + ApiResponse getMyInfo( + @Parameter(description = "로그인 ID", required = true) + String loginId, + @Parameter(description = "비밀번호", required = true) + String password + ); +} +``` + +핵심 원칙: +- **반환 타입**과 **파라미터 타입**은 Controller와 동일하게 맞춘다 +- **메서드명**도 Controller와 동일하게 맞춘다 +- ApiSpec에는 **Swagger 어노테이션만** 둔다. Spring MVC 어노테이션(`@GetMapping`, `@PathVariable` 등)은 Controller에만 둔다 + +--- + +## 2. 패키지 배치와 네이밍 + +### 파일 위치 + +ApiSpec 인터페이스는 **Controller와 같은 패키지**에 둔다. + +``` +interfaces/ +├── user/ +│ ├── UserV1ApiSpec.java +│ ├── UserController.java +│ └── dto/ +│ └── UserV1Dto.java +│ +├── product/ +│ ├── ProductV1ApiSpec.java +│ ├── AdminProductV1ApiSpec.java +│ ├── ProductController.java +│ ├── AdminProductController.java +│ └── dto/ +│ ├── ProductV1Dto.java +│ └── AdminProductV1Dto.java +│ +└── like/ + ├── LikeV1ApiSpec.java + ├── LikeController.java + └── dto/ + └── LikeV1Dto.java +``` + +### 네이밍 규칙 + +| 대상 | 네이밍 | 예시 | +|------|--------|------| +| 고객 ApiSpec | `{Domain}V1ApiSpec` | `ProductV1ApiSpec` | +| Admin ApiSpec | `Admin{Domain}V1ApiSpec` | `AdminProductV1ApiSpec` | + +`V1`을 포함하는 이유: +- API 버전이 URL에 `/api/v1`으로 명시되어 있다 +- 향후 V2 API가 추가될 때 `ProductV2ApiSpec`으로 자연스럽게 확장된다 +- `@Tag`의 name에도 버전이 들어간다 (`"Product V1 API"`) + +### DTO 네이밍과의 연관 + +ApiSpec의 DTO 이름도 **V1**을 포함한다. 같은 도메인이라도 API 버전별로 요청/응답이 달라질 수 있기 때문이다. + +``` +dto/ +├── UserV1Dto.java ← V1 API용 Request/Response +└── AdminUserV1Dto.java ← Admin V1 API용 +``` + +--- + +## 3. 어노테이션 규칙 + +### 필수 어노테이션 + +| 어노테이션 | 위치 | 용도 | +|-----------|------|------| +| `@Tag` | 인터페이스 레벨 | API 그룹 이름과 설명 | +| `@Operation` | 메서드 레벨 | API 요약과 상세 설명 | +| `@Parameter` | 파라미터 레벨 | 경로 변수, 헤더, 쿼리 파라미터 설명 | +| `@RequestBody` | 파라미터 레벨 | 요청 본문 설명 | + +### @Tag — 인터페이스 레벨 + +하나의 ApiSpec 인터페이스에 하나의 `@Tag`를 붙인다. + +```java +@Tag(name = "Product V1 API", description = "상품 API 입니다.") +public interface ProductV1ApiSpec { ... } + +@Tag(name = "Admin Product V1 API", description = "상품 관리 API 입니다.") +public interface AdminProductV1ApiSpec { ... } +``` + +Tag name 규칙: +- 형식: `"{Domain} V1 API"` / `"Admin {Domain} V1 API"` +- description: 한글, 간결하게 + +### @Operation — 메서드 레벨 + +모든 API 메서드에 `@Operation`을 붙인다. + +```java +@Operation( + summary = "상품 목록 조회", + description = "브랜드, 정렬 조건으로 상품 목록을 조회합니다. 페이지네이션을 지원합니다." +) +``` + +| 속성 | 규칙 | 예시 | +|------|------|------| +| `summary` | 한 줄, 동사로 시작 | `"상품 목록 조회"`, `"주문 생성"` | +| `description` | 상세 설명. 인증 요구사항, 특이사항 포함 | `"헤더에 X-Loopers-LoginId를 포함해야 합니다."` | + +### @Parameter — 경로 변수, 헤더, 쿼리 파라미터 + +```java +@Operation(summary = "상품 단건 조회") +ApiResponse getProduct( + @Parameter(description = "상품 ID", required = true, example = "1") + Long productId +); +``` + +```java +@Operation(summary = "상품 목록 조회") +ApiResponse> getProducts( + @Parameter(description = "브랜드 ID (필터)") + Long brandId, + @Parameter(description = "정렬 기준", example = "latest") + String sort, + @Parameter(description = "페이지 번호", example = "0") + int page, + @Parameter(description = "페이지 크기", example = "20") + int size +); +``` + +| 속성 | 사용 시점 | +|------|----------| +| `description` | **항상** 작성 | +| `required` | 필수 파라미터일 때 `true` | +| `example` | ID, 페이지 번호 등 구체적 값이 도움될 때 | +| `hidden` | Swagger UI에서 숨길 파라미터 (내부용 헤더 등) | + +### @RequestBody — 요청 본문 + +```java +@Operation(summary = "상품 등록") +ApiResponse create( + @RequestBody(description = "상품 등록 요청 정보") + AdminProductV1Dto.CreateRequest request +); +``` + +`io.swagger.v3.oas.annotations.parameters.RequestBody`를 사용한다 (Spring의 `@RequestBody`와 다른 패키지). + +--- + +## 4. Controller 연결 + +### Controller가 ApiSpec을 implements한다 + +```java +@RestController +@RequestMapping("/api/v1/users") +@RequiredArgsConstructor +public class UserController implements UserV1ApiSpec { + + private final UserFacade userFacade; + + @Override + @PostMapping("/signup") + public ApiResponse signup( + @org.springframework.web.bind.annotation.RequestBody @Valid + UserV1Dto.SignupRequest request + ) { + UserInfo info = userFacade.signup(request.toCommand()); + return ApiResponse.success(UserV1Dto.SignupResponse.from(info)); + } + + @Override + @GetMapping("/me") + public ApiResponse getMyInfo( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password + ) { + UserInfo info = userFacade.getMyInfo(loginId, password); + return ApiResponse.success(UserV1Dto.MyInfoResponse.from(info)); + } +} +``` + +핵심 포인트: +- **Spring MVC 어노테이션**(`@GetMapping`, `@PathVariable`, `@RequestHeader`, `@Valid`)은 **Controller에만** 둔다 +- **Swagger 어노테이션**(`@Operation`, `@Parameter`, `@Tag`)은 **ApiSpec에만** 둔다 +- `@RequestBody`는 주의: Swagger의 `io.swagger.v3.oas.annotations.parameters.RequestBody`는 ApiSpec에, Spring의 `org.springframework.web.bind.annotation.RequestBody`는 Controller에 각각 사용 +- `@Override`를 명시하여 ApiSpec과의 연결을 코드에서 확인한다 + +### Admin Controller도 동일 패턴 + +```java +@RestController +@RequestMapping("/api-admin/v1/products") +@RequiredArgsConstructor +public class AdminProductController implements AdminProductV1ApiSpec { + + private final ProductFacade productFacade; + + @Override + @PostMapping + public ResponseEntity> create( + @org.springframework.web.bind.annotation.RequestBody @Valid + AdminProductV1Dto.CreateRequest request + ) { + ProductInfo info = productFacade.create(request.toCommand()); + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.success(AdminProductV1Dto.DetailResponse.from(info))); + } +} +``` + +--- + +## 5. DTO와 Schema 규칙 + +### record DTO는 자동으로 Schema가 생성된다 + +SpringDoc은 record의 필드를 자동으로 Swagger Schema에 반영한다. 대부분의 경우 `@Schema`를 별도로 붙이지 않아도 된다. + +```java +// 이것만으로도 Swagger UI에 필드가 표시된다 +public record CreateRequest( + @NotBlank String name, + @Positive int price, + @PositiveOrZero int stock +) {} +``` + +### @Schema가 필요한 경우 + +필드명만으로는 의미가 불명확하거나, 예시 값이 필요한 경우에만 `@Schema`를 추가한다. + +```java +public record CreateRequest( + @Schema(description = "상품명", example = "오버사이즈 코트") + @NotBlank String name, + + @Schema(description = "판매가 (원)", example = "129000") + @Positive int price, + + @Schema(description = "재고 수량", example = "50") + @PositiveOrZero int stock, + + @Schema(description = "브랜드 ID", example = "1") + @NotNull Long brandId +) {} +``` + +`@Schema` 추가 기준: +- **필드명이 모호한 경우** — `status`, `type` 등 여러 의미를 가질 수 있을 때 +- **단위가 중요한 경우** — 가격(원), 무게(g) 등 +- **enum이나 특정 형식이 있는 경우** — 날짜 포맷, 정렬 값 등 +- **example이 이해를 돕는 경우** — API 테스트 시 Swagger UI에서 바로 사용 가능 + +### Response에도 동일 기준 적용 + +```java +public record DetailResponse( + Long id, + String name, + int price, + @Schema(description = "좋아요 수") + int likeCount, + @Schema(description = "생성일시", example = "2025-01-15T10:30:00+09:00") + ZonedDateTime createdAt +) { + public static DetailResponse from(ProductInfo info) { ... } +} +``` + +--- + +## 6. 에러 응답 문서화 + +### 공통 에러는 ControllerAdvice 레벨에서 문서화 + +개별 ApiSpec 메서드마다 에러 응답을 반복하지 않는다. 공통 에러(400, 401, 500 등)는 SpringDoc의 글로벌 설정이나 ControllerAdvice에서 한 번만 정의한다. + +### 도메인별 특수 에러만 ApiSpec에 명시 + +해당 API에서만 발생하는 특수한 에러가 있다면 `@ApiResponse`로 명시할 수 있다. + +```java +@Operation( + summary = "상품 좋아요", + description = "상품에 좋아요를 등록합니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "409", + description = "이미 좋아요한 상품" + ) + } +) +ApiResponse like( + @Parameter(description = "상품 ID", required = true) + Long productId, + @Parameter(description = "사용자 ID", required = true) + String loginId +); +``` + +단, 모든 에러를 나열하지 않는다. 프론트엔드 개발자가 "이 API에서 이런 에러가 나올 수 있구나"를 알아야 하는 경우에만 추가한다. + +--- + +## 체크리스트 + +**ApiSpec 인터페이스** +- [ ] 모든 Controller에 대응하는 ApiSpec 인터페이스가 있는가? +- [ ] ApiSpec에 `@Tag`가 붙어 있는가? +- [ ] 모든 API 메서드에 `@Operation(summary, description)`이 있는가? +- [ ] 파라미터에 `@Parameter(description)`이 있는가? +- [ ] 요청 본문에 Swagger `@RequestBody(description)`이 있는가? + +**Controller 연결** +- [ ] Controller가 ApiSpec을 `implements`하는가? +- [ ] Controller 메서드에 `@Override`가 명시되어 있는가? +- [ ] Spring MVC 어노테이션은 Controller에만, Swagger 어노테이션은 ApiSpec에만 있는가? +- [ ] Swagger `@RequestBody`와 Spring `@RequestBody`가 혼동되지 않는가? + +**네이밍/배치** +- [ ] ApiSpec 네이밍이 `{Domain}V1ApiSpec` / `Admin{Domain}V1ApiSpec`인가? +- [ ] ApiSpec이 Controller와 같은 패키지에 있는가? +- [ ] DTO 네이밍이 `{Domain}V1Dto` / `Admin{Domain}V1Dto`인가? + +**DTO Schema** +- [ ] 모호한 필드에 `@Schema(description)`이 추가되어 있는가? +- [ ] `@Schema`를 불필요하게 모든 필드에 붙이지 않았는가? diff --git a/.claude/skills/requirements-analysis/SKILL.md b/.claude/skills/requirements-analysis/SKILL.md new file mode 100644 index 000000000..ae4b67192 --- /dev/null +++ b/.claude/skills/requirements-analysis/SKILL.md @@ -0,0 +1,77 @@ +--- +name: requirements-analysis +description: + 제공된 요구사항을 분석하고, 개발자와의 질문/대답을 통해 애매한 요구사항을 명확히 하여 정리합니다. + 모든 정리가 끝나면, 시퀀스 다이어그램, 클래스 다이어그램, ERD 등을 Mermaid 문법으로 작성한다. + 요구사항이 제공되었을 때, 코드를 작성하기 전 이를 명확히 하는 데에 사용합니다. +--- +요구사항을 분석할 때 반드시 다음 흐름을 따른다. +### 1️⃣ 요구사항을 그대로 믿지 말고, 문제 상황으로 다시 설명한다. +- 요구사항 문장을 정리하는 데서 끝내지 않는다. +- "무엇을 만들까?"가 아니라 "지금 어떤 문제가 있고, 그걸 왜 해결하려는가?" 로 재해석한다. +- 다음 관점을 분리해서 정리한다: + - 사용자 관점 + - 비즈니스 관점 + - 시스템 관점 +> 예시 +> "주문 실패 시 결제를 취소한다" → "결제 성공/실패와 주문 상태가 어긋나지 않도록 일관성을 유지하려는 문제" + +### 2️⃣ 애매한 요구사항을 숨기지 말고 드러낸다 +- 추측하거나 알아서 결정하지 않는다. +- 요구사항에서 결정되지 않은 부분을 명시적으로 나열한다. + **다음 유형의 질문을 반드시 포함한다:** +- 정책 질문: 기준 시점, 성공/실패 조건, 예외 처리 규칙 +- 경계 질문: 어디까지가 한 책임인가, 어디서 분리되는가 +- 확장 질문: 나중에 바뀔 가능성이 있는가 + +### 3️⃣ 요구사항 명확화를 위한 질문을 개발자 답변이 쉬운 형태로 제시한다 +- 질문은 우선순위를 가진다 (중요한 것부터). +- 선택지가 있는 경우, 옵션 + 영향도를 함께 제시한다. +> 형식 예시: +- 선택지 A: 하나의 트랜잭션으로 처리 → 구현 단순, 확장성 낮음 +- 선택지 B: 단계별 분리 → 구조 복잡, 확장/보상 처리 유리 + +### 4️⃣ 합의된 내용을 바탕으로 개념 모델부터 잡는다 +- 바로 코드나 기술 얘기로 들어가지 않는다. +- 먼저 다음을 정의한다: + - 액터 (사용자, 외부 시스템) + - 핵심 도메인 + - 보조/외부 시스템 +- 이 단계는 “구현”이 아니라 설계 사고 정렬이 목적이다. + +### 5️⃣ 다이어그램은 항상 이유 → 다이어그램 → 해석 순서로 제시한다 +**다이어그램을 그리기 전에 반드시 설명한다** +- 왜 이 다이어그램이 필요한지 +- 이 다이어그램으로 무엇을 검증하려는지 + +**다이어그램은 Mermaid 문법으로 작성한다** +사용 기준: +- **시퀀스 다이어그램** + - 책임 분리 + - 호출 순서 + - 트랜잭션 경계 확인 +- **클래스 다이어그램** + - 도메인 책임 + - 의존 방향 + - 응집도 확인 +- **ERD** + - 영속성 구조 + - 관계의 주인 + - 정규화 여부 + +### 6️⃣ 다이어그램을 던지고 끝내지 말고 읽는 법을 짚어준다 +- "이 구조에서 특히 봐야 할 포인트"를 2~3줄로 설명한다. +- 설계 의도가 드러나도록 해석을 붙인다. + +### 7️⃣ 설계의 잠재 리스크를 반드시 언급한다 +- 현재 설계가 가질 수 있는 위험을 숨기지 않는다. + - 트랜잭션 비대화 + - 도메인 간 결합도 증가 + - 정책 변경 시 영향 범위 확대 +- 해결책은 정답처럼 말하지 않고 선택지로 제시한다. + +### 톤 & 스타일 가이드 +- 강의처럼 설명하지 말고 설계 리뷰 톤을 유지한다 +- 정답이라고 제시하기보다, 다른 선택지가 있다면 이를 제공하도록 한다. +- 코드보다 의도, 책임, 경계를 더 중요하게 다룬다 +- 구현 전에 생각해야 할 것을 끌어내는 데 집중한다 diff --git a/.gitignore b/.gitignore index 69f7b39d6..0bb7e2949 100644 --- a/.gitignore +++ b/.gitignore @@ -40,7 +40,8 @@ out/ .kotlin ### Claude Code ### -.claude/ +claude/* +!.claude/skills/ ### Documentation ### docs/* From e32892eac640b11d02a2a261ea8d8b4ea781df5a Mon Sep 17 00:00:00 2001 From: plan11plan Date: Wed, 18 Feb 2026 14:18:11 +0900 Subject: [PATCH 010/108] =?UTF-8?q?refactor:=20=EC=98=88=EC=99=B8=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EA=B5=AC=EC=A1=B0=EB=A5=BC=20ErrorCode=20=EC=9D=B8?= =?UTF-8?q?=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EA=B8=B0=EB=B0=98?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interfaces/api/ApiControllerAdvice.java | 43 ++++++++++++------- .../loopers/interfaces/api/ApiResponse.java | 9 ++++ .../loopers/support/error/CoreException.java | 12 +++--- .../com/loopers/support/error/ErrorCode.java | 9 ++++ .../com/loopers/support/error/ErrorType.java | 2 +- 5 files changed, 52 insertions(+), 23 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/support/error/ErrorCode.java 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 2ec0dbbd7..5d87ed9b7 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 @@ -4,11 +4,16 @@ 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.ErrorCode; import com.loopers.support.error.ErrorType; +import java.util.Arrays; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; -import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; @@ -17,18 +22,30 @@ import org.springframework.web.server.ServerWebInputException; import org.springframework.web.servlet.resource.NoResourceFoundException; -import java.util.Arrays; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import java.util.stream.Collectors; - @RestControllerAdvice @Slf4j public class ApiControllerAdvice { @ExceptionHandler public ResponseEntity> handle(CoreException e) { log.warn("CoreException : {}", e.getCustomMessage() != null ? e.getCustomMessage() : e.getMessage(), e); - return failureResponse(e.getErrorType(), e.getCustomMessage()); + return failureResponse(e.getErrorCode(), e.getCustomMessage()); + } + + @ExceptionHandler + public ResponseEntity> handleBadRequest(MethodArgumentNotValidException e) { + List fieldErrors = e.getBindingResult().getFieldErrors().stream() + .map(fe -> new ApiResponse.FieldError( + fe.getField(), + fe.getRejectedValue(), + fe.getDefaultMessage() + )) + .toList(); + return ResponseEntity.status(ErrorType.BAD_REQUEST.getStatus()) + .body(ApiResponse.failValidation( + ErrorType.BAD_REQUEST.getCode(), + ErrorType.BAD_REQUEST.getMessage(), + fieldErrors + )); } @ExceptionHandler @@ -48,12 +65,6 @@ public ResponseEntity> handleBadRequest(MissingServletRequestPara return failureResponse(ErrorType.BAD_REQUEST, message); } - @ExceptionHandler - public ResponseEntity> handleBadRequest(MethodArgumentNotValidException e) { - FieldError fieldError = e.getBindingResult().getFieldError(); - String message = fieldError != null ? fieldError.getDefaultMessage() : "잘못된 요청입니다."; - return failureResponse(ErrorType.BAD_REQUEST, message); - } @ExceptionHandler public ResponseEntity> handleBadRequest(HttpMessageNotReadableException e) { @@ -128,8 +139,8 @@ private String extractMissingParameter(String message) { return matcher.find() ? matcher.group(1) : ""; } - private ResponseEntity> failureResponse(ErrorType errorType, String errorMessage) { - return ResponseEntity.status(errorType.getStatus()) - .body(ApiResponse.fail(errorType.getCode(), errorMessage != null ? errorMessage : errorType.getMessage())); + private ResponseEntity> failureResponse(ErrorCode errorCode, String errorMessage) { + return ResponseEntity.status(errorCode.getStatus()) + .body(ApiResponse.fail(errorCode.getCode(), errorMessage != null ? errorMessage : errorCode.getMessage())); } } 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..57dedcaa5 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 @@ -1,5 +1,7 @@ package com.loopers.interfaces.api; +import java.util.List; + public record ApiResponse(Metadata meta, T data) { public record Metadata(Result result, String errorCode, String message) { public enum Result { @@ -15,6 +17,8 @@ public static Metadata fail(String errorCode, String errorMessage) { } } + public record FieldError(String field, Object value, String reason) {} + public static ApiResponse success() { return new ApiResponse<>(Metadata.success(), null); } @@ -29,4 +33,9 @@ public static ApiResponse fail(String errorCode, String errorMessage) { null ); } + + public static ApiResponse> failValidation( + String errorCode, String errorMessage, List fieldErrors) { + return new ApiResponse<>(Metadata.fail(errorCode, errorMessage), fieldErrors); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/CoreException.java b/apps/commerce-api/src/main/java/com/loopers/support/error/CoreException.java index 0cc190b6b..be5b9f708 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/CoreException.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/CoreException.java @@ -4,16 +4,16 @@ @Getter public class CoreException extends RuntimeException { - private final ErrorType errorType; + private final ErrorCode errorCode; private final String customMessage; - public CoreException(ErrorType errorType) { - this(errorType, null); + public CoreException(ErrorCode errorCode) { + this(errorCode, null); } - public CoreException(ErrorType errorType, String customMessage) { - super(customMessage != null ? customMessage : errorType.getMessage()); - this.errorType = errorType; + public CoreException(ErrorCode errorCode, String customMessage) { + super(customMessage != null ? customMessage : errorCode.getMessage()); + this.errorCode = errorCode; this.customMessage = customMessage; } } diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorCode.java b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorCode.java new file mode 100644 index 000000000..90f67b43c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorCode.java @@ -0,0 +1,9 @@ +package com.loopers.support.error; + +import org.springframework.http.HttpStatus; + +public interface ErrorCode { + HttpStatus getStatus(); + String getCode(); + String getMessage(); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java index 8d493491a..4600f7435 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java @@ -6,7 +6,7 @@ @Getter @RequiredArgsConstructor -public enum ErrorType { +public enum ErrorType implements ErrorCode { /** 범용 에러 */ INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), "일시적인 오류가 발생했습니다."), BAD_REQUEST(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "잘못된 요청입니다."), From 53b69d69d56e5ccff47f9f7619dbbdf58b9724c3 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Wed, 18 Feb 2026 14:18:15 +0900 Subject: [PATCH 011/108] =?UTF-8?q?test:=20=EC=98=88=EC=99=B8=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=85=98=EC=97=90=20=EB=94=B0=EB=A5=B8=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/AuthenticationServiceTest.java | 10 +++--- .../domain/UserServiceIntegrationTest.java | 4 +-- .../interfaces/api/UserV1ApiE2ETest.java | 36 +++++++++---------- 3 files changed, 26 insertions(+), 24 deletions(-) diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/AuthenticationServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/AuthenticationServiceTest.java index 56a1c298b..906e2246a 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/AuthenticationServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/AuthenticationServiceTest.java @@ -41,10 +41,12 @@ void setUp() { validLoginId = "testuser123"; validPassword = "Test1234!@#"; encodedPassword = "$2a$10$encodedPasswordHash"; + when(passwordEncoder.encode(validPassword)).thenReturn(encodedPassword); testUser = new UserModel( new LoginId(validLoginId), - EncryptedPassword.fromEncoded(encodedPassword), + validPassword, + passwordEncoder, new Name("홍길동"), new BirthDate(LocalDate.of(1990, 1, 15)), new Email("test@example.com") @@ -79,7 +81,7 @@ void authenticate_should_throw_exception_when_user_not_found() { // act & assert assertThatThrownBy(() -> authenticationService.authenticate(validLoginId, validPassword)) .isInstanceOf(CoreException.class) - .hasFieldOrPropertyWithValue("errorType", ErrorType.UNAUTHORIZED) + .hasFieldOrPropertyWithValue("errorCode", ErrorType.UNAUTHORIZED) .hasMessageContaining("로그인 ID 또는 비밀번호가 일치하지 않습니다."); } @@ -94,7 +96,7 @@ void authenticate_should_throw_exception_when_password_is_incorrect() { // act & assert assertThatThrownBy(() -> authenticationService.authenticate(validLoginId, wrongPassword)) .isInstanceOf(CoreException.class) - .hasFieldOrPropertyWithValue("errorType", ErrorType.UNAUTHORIZED) + .hasFieldOrPropertyWithValue("errorCode", ErrorType.UNAUTHORIZED) .hasMessageContaining("로그인 ID 또는 비밀번호가 일치하지 않습니다."); } @@ -108,7 +110,7 @@ void authenticate_should_throw_exception_when_password_is_null() { // act & assert assertThatThrownBy(() -> authenticationService.authenticate(validLoginId, null)) .isInstanceOf(CoreException.class) - .hasFieldOrPropertyWithValue("errorType", ErrorType.UNAUTHORIZED); + .hasFieldOrPropertyWithValue("errorCode", ErrorType.UNAUTHORIZED); } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceIntegrationTest.java index 88d43564c..d0dd8923a 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceIntegrationTest.java @@ -138,7 +138,7 @@ void getMyInfo_whenInvalidLoginId() { // act & assert assertThatThrownBy(() -> userService.getMyInfo(invalidLoginId)) .isInstanceOf(CoreException.class) - .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND) + .hasFieldOrPropertyWithValue("errorCode", ErrorType.NOT_FOUND) .hasMessageContaining("사용자를 찾을 수 없습니다."); } } @@ -216,7 +216,7 @@ void changePassword_whenUserNotFound() { // act & assert assertThatThrownBy(() -> userService.changePassword(new ChangePasswordCommand(invalidLoginId, rawPassword, "NewPass123!@"))) .isInstanceOf(CoreException.class) - .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND) + .hasFieldOrPropertyWithValue("errorCode", ErrorType.NOT_FOUND) .hasMessageContaining("사용자를 찾을 수 없습니다."); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java index c81b0f5b3..b4f7b5bbd 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java @@ -91,8 +91,8 @@ void throwsBadRequest_whenLoginIdIsTooShort() { ); // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, new HttpEntity<>(request), responseType); // assert @@ -116,8 +116,8 @@ void throwsBadRequest_whenLoginIdIsTooLong() { ); // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, new HttpEntity<>(request), responseType); // assert @@ -141,8 +141,8 @@ void throwsBadRequest_whenLoginIdContainsSpecialCharacters() { ); // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, new HttpEntity<>(request), responseType); // assert @@ -166,8 +166,8 @@ void throwsBadRequest_whenPasswordIsTooShort() { ); // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, new HttpEntity<>(request), responseType); // assert @@ -191,8 +191,8 @@ void throwsBadRequest_whenPasswordIsTooLong() { ); // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, new HttpEntity<>(request), responseType); // assert @@ -241,8 +241,8 @@ void throwsBadRequest_whenEmailFormatIsInvalid() { ); // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, new HttpEntity<>(request), responseType); // assert @@ -300,8 +300,8 @@ void throwsBadRequest_whenNameIsTooShort() { ); // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, new HttpEntity<>(request), responseType); // assert @@ -325,8 +325,8 @@ void throwsBadRequest_whenNameIsTooLong() { ); // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, new HttpEntity<>(request), responseType); // assert @@ -665,8 +665,8 @@ void changePassword_whenInvalidPasswordFormat() { ); // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_CHANGE_PASSWORD, HttpMethod.PATCH, new HttpEntity<>(request, headers), responseType); // assert From 339a8a0d0e8b1a376308d569d007e5a032f503b9 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Wed, 18 Feb 2026 14:18:19 +0900 Subject: [PATCH 012/108] =?UTF-8?q?chore:=20Gradle=20Java=20Home=EC=9D=84?= =?UTF-8?q?=20JDK=2021=EB=A1=9C=20=EA=B3=A0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gradle.properties | 1 + 1 file changed, 1 insertion(+) diff --git a/gradle.properties b/gradle.properties index 142d7120f..a1af0cfb3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,3 +16,4 @@ mockitoVersion=5.14.0 instancioJUnitVersion=5.0.2 slackAppenderVersion=1.6.1 kotlin.daemon.jvmargs=-Xmx1g -XX:MaxMetaspaceSize=512m +org.gradle.java.home=/Users/jins/Library/Java/JavaVirtualMachines/temurin-21.0.7/Contents/Home From 25fca33cfdc83c5ee9460aaec09a133451f84309 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Wed, 18 Feb 2026 14:50:52 +0900 Subject: [PATCH 013/108] =?UTF-8?q?refactor:=20User=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=ED=8C=A8=ED=82=A4=EC=A7=80=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=A5=BC=20=EC=84=9C=EB=B8=8C=ED=8C=A8=ED=82=A4=EC=A7=80?= =?UTF-8?q?=EB=A1=9C=20=EC=9E=AC=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/domain/ChangePasswordCommand.java | 8 --- .../com/loopers/domain/SignupCommand.java | 10 ---- .../java/com/loopers/domain/UserInfo.java | 19 ------- .../java/com/loopers/domain/UserService.java | 49 ------------------- .../{ => user}/AuthenticationService.java | 2 +- .../loopers/domain/{ => user}/BirthDate.java | 2 +- .../com/loopers/domain/{ => user}/Email.java | 2 +- .../domain/{ => user}/EncryptedPassword.java | 4 +- .../loopers/domain/{ => user}/LoginId.java | 2 +- .../com/loopers/domain/{ => user}/Name.java | 2 +- .../domain/{ => user}/PasswordEncoder.java | 2 +- .../loopers/domain/{ => user}/UserModel.java | 46 ++++++++--------- .../domain/{ => user}/UserRepository.java | 2 +- .../com/loopers/domain/user/UserService.java | 47 ++++++++++++++++++ .../{ => user}/BCryptPasswordEncoderImpl.java | 4 +- .../{ => user}/UserJpaRepository.java | 6 +-- .../{ => user}/UserRepositoryImpl.java | 8 +-- 17 files changed, 88 insertions(+), 127 deletions(-) delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/ChangePasswordCommand.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/SignupCommand.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/UserInfo.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/UserService.java rename apps/commerce-api/src/main/java/com/loopers/domain/{ => user}/AuthenticationService.java (96%) rename apps/commerce-api/src/main/java/com/loopers/domain/{ => user}/BirthDate.java (97%) rename apps/commerce-api/src/main/java/com/loopers/domain/{ => user}/Email.java (96%) rename apps/commerce-api/src/main/java/com/loopers/domain/{ => user}/EncryptedPassword.java (97%) rename apps/commerce-api/src/main/java/com/loopers/domain/{ => user}/LoginId.java (97%) rename apps/commerce-api/src/main/java/com/loopers/domain/{ => user}/Name.java (96%) rename apps/commerce-api/src/main/java/com/loopers/domain/{ => user}/PasswordEncoder.java (81%) rename apps/commerce-api/src/main/java/com/loopers/domain/{ => user}/UserModel.java (71%) rename apps/commerce-api/src/main/java/com/loopers/domain/{ => user}/UserRepository.java (82%) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java rename apps/commerce-api/src/main/java/com/loopers/infrastructure/{ => user}/BCryptPasswordEncoderImpl.java (87%) rename apps/commerce-api/src/main/java/com/loopers/infrastructure/{ => user}/UserJpaRepository.java (64%) rename apps/commerce-api/src/main/java/com/loopers/infrastructure/{ => user}/UserRepositoryImpl.java (75%) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/ChangePasswordCommand.java b/apps/commerce-api/src/main/java/com/loopers/domain/ChangePasswordCommand.java deleted file mode 100644 index 6fae67700..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/ChangePasswordCommand.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.loopers.domain; - -public record ChangePasswordCommand( - LoginId loginId, - String rawCurrentPassword, - String rawNewPassword -) { -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/SignupCommand.java b/apps/commerce-api/src/main/java/com/loopers/domain/SignupCommand.java deleted file mode 100644 index 3a90908e7..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/SignupCommand.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.loopers.domain; - -public record SignupCommand( - String loginId, - String rawPassword, - String name, - String birthDate, - String email -) { -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/UserInfo.java b/apps/commerce-api/src/main/java/com/loopers/domain/UserInfo.java deleted file mode 100644 index 45ec00a05..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/UserInfo.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.loopers.domain; - -public record UserInfo( - Long id, - String loginId, - String name, - String birthDate, - String email -) { - public static UserInfo from(UserModel model) { - return new UserInfo( - model.getId(), - model.getLoginId().getValue(), - model.getName().getValue(), - model.getBirthDate().toDateString(), - model.getEmail().getMail() - ); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/UserService.java deleted file mode 100644 index 02c9dd182..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/UserService.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.loopers.domain; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.transaction.Transactional; -import java.time.LocalDate; -import java.time.format.DateTimeFormatter; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class UserService { - private static final DateTimeFormatter BIRTH_DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); - - private final UserRepository userRepository; - private final PasswordEncoder passwordEncoder; - - @Transactional - public UserInfo signup(SignupCommand command) { - LoginId loginId = new LoginId(command.loginId()); - Name name = new Name(command.name()); - BirthDate birthDate = new BirthDate(LocalDate.parse(command.birthDate(), BIRTH_DATE_FORMATTER)); - Email email = new Email(command.email()); - - if(userRepository.find(loginId).isPresent()) { - throw new CoreException(ErrorType.BAD_REQUEST,"이미 존재하는 아이디입니다."); - } - - UserModel userModel = new UserModel(loginId, command.rawPassword(), passwordEncoder, name, birthDate, email); - - return UserInfo.from(userRepository.save(userModel)); - } - - public UserInfo getMyInfo(LoginId loginId) { - UserModel user = userRepository.find(loginId) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.")); - return UserInfo.from(user); - } - - @Transactional - public void changePassword(ChangePasswordCommand command) { - UserModel user = userRepository.find(command.loginId()) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.")); - - user.changePassword(command.rawCurrentPassword(), command.rawNewPassword(), passwordEncoder); - userRepository.save(user); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/AuthenticationService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/AuthenticationService.java similarity index 96% rename from apps/commerce-api/src/main/java/com/loopers/domain/AuthenticationService.java rename to apps/commerce-api/src/main/java/com/loopers/domain/user/AuthenticationService.java index e972352c4..36d3edca6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/AuthenticationService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/AuthenticationService.java @@ -1,4 +1,4 @@ -package com.loopers.domain; +package com.loopers.domain.user; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/BirthDate.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDate.java similarity index 97% rename from apps/commerce-api/src/main/java/com/loopers/domain/BirthDate.java rename to apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDate.java index 8dcb741c0..fdd28d205 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/BirthDate.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDate.java @@ -1,4 +1,4 @@ -package com.loopers.domain; +package com.loopers.domain.user; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/Email.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/Email.java similarity index 96% rename from apps/commerce-api/src/main/java/com/loopers/domain/Email.java rename to apps/commerce-api/src/main/java/com/loopers/domain/user/Email.java index 767b156b5..039c84d68 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/Email.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/Email.java @@ -1,4 +1,4 @@ -package com.loopers.domain; +package com.loopers.domain.user; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/EncryptedPassword.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/EncryptedPassword.java similarity index 97% rename from apps/commerce-api/src/main/java/com/loopers/domain/EncryptedPassword.java rename to apps/commerce-api/src/main/java/com/loopers/domain/user/EncryptedPassword.java index c991ab9ef..47ad53c57 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/EncryptedPassword.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/EncryptedPassword.java @@ -1,4 +1,4 @@ -package com.loopers.domain; +package com.loopers.domain.user; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; @@ -43,4 +43,4 @@ private static void validateFormat(String value) { } } -} \ No newline at end of file +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/LoginId.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/LoginId.java similarity index 97% rename from apps/commerce-api/src/main/java/com/loopers/domain/LoginId.java rename to apps/commerce-api/src/main/java/com/loopers/domain/user/LoginId.java index 98e718edf..3d7d96559 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/LoginId.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/LoginId.java @@ -1,4 +1,4 @@ -package com.loopers.domain; +package com.loopers.domain.user; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/Name.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/Name.java similarity index 96% rename from apps/commerce-api/src/main/java/com/loopers/domain/Name.java rename to apps/commerce-api/src/main/java/com/loopers/domain/user/Name.java index ac36ce2d7..09fee1310 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/Name.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/Name.java @@ -1,4 +1,4 @@ -package com.loopers.domain; +package com.loopers.domain.user; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/PasswordEncoder.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordEncoder.java similarity index 81% rename from apps/commerce-api/src/main/java/com/loopers/domain/PasswordEncoder.java rename to apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordEncoder.java index 249ec2a76..bb23c0b39 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/PasswordEncoder.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordEncoder.java @@ -1,4 +1,4 @@ -package com.loopers.domain; +package com.loopers.domain.user; public interface PasswordEncoder { String encode(String rawPassword); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/UserModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java similarity index 71% rename from apps/commerce-api/src/main/java/com/loopers/domain/UserModel.java rename to apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java index 587173175..17ac5c1b7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/UserModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java @@ -1,5 +1,6 @@ -package com.loopers.domain; +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.AttributeOverride; @@ -7,6 +8,7 @@ import jakarta.persistence.Embedded; import jakarta.persistence.Entity; import jakarta.persistence.Table; +import java.time.LocalDate; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -37,31 +39,21 @@ public class UserModel extends BaseEntity { @AttributeOverride(name = "mail", column = @Column(name = "email")) private Email email; - public UserModel(LoginId loginId, String rawPassword, PasswordEncoder encoder, Name name, BirthDate birthDate, Email email) { - validateNotNull(loginId, "로그인 ID"); - validateNotNull(name, "이름"); - validateNotNull(birthDate, "생년월일"); - validateNotNull(email, "이메일"); - validateBirthDateNotInPassword(rawPassword, birthDate); + // === 생성 === // - this.loginId = loginId; - this.password = EncryptedPassword.of(rawPassword, encoder); - this.name = name; - this.birthDate = birthDate; - this.email = email; + public static UserModel create(String loginId, String rawPassword, PasswordEncoder encoder, + String name, LocalDate birthDate, String email) { + UserModel model = new UserModel(); + model.loginId = new LoginId(loginId); + model.name = new Name(name); + model.birthDate = new BirthDate(birthDate); + model.email = new Email(email); + model.validateBirthDateNotInPassword(rawPassword, model.birthDate); + model.password = EncryptedPassword.of(rawPassword, encoder); + return model; } - private void validateNotNull(Object value, String fieldName) { - if (value == null) { - throw new CoreException(ErrorType.BAD_REQUEST, fieldName + "은(는) 필수 입력값입니다."); - } - } - - private void validateBirthDateNotInPassword(String rawPassword, BirthDate birthDate) { - if (rawPassword != null && rawPassword.contains(birthDate.toDateString())) { - throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 비밀번호 내에 포함될 수 없습니다."); - } - } + // === 도메인 로직 === // public void changePassword(String rawCurrentPassword, String rawNewPassword, PasswordEncoder encoder) { if (!this.password.matches(rawCurrentPassword, encoder)) { @@ -73,4 +65,12 @@ public void changePassword(String rawCurrentPassword, String rawNewPassword, Pas validateBirthDateNotInPassword(rawNewPassword, this.birthDate); this.password = EncryptedPassword.of(rawNewPassword, encoder); } + + // === 검증 === // + + private void validateBirthDateNotInPassword(String rawPassword, BirthDate birthDate) { + if (rawPassword != null && rawPassword.contains(birthDate.toDateString())) { + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 비밀번호 내에 포함될 수 없습니다."); + } + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/UserRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java similarity index 82% rename from apps/commerce-api/src/main/java/com/loopers/domain/UserRepository.java rename to apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java index 64706be30..048572c10 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/UserRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java @@ -1,4 +1,4 @@ -package com.loopers.domain; +package com.loopers.domain.user; import java.util.Optional; 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..e77b783eb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -0,0 +1,47 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class UserService { + private static final DateTimeFormatter BIRTH_DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional + public UserModel signup(String loginId, String rawPassword, String name, String birthDate, String email) { + if (userRepository.find(new LoginId(loginId)).isPresent()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이미 존재하는 아이디입니다."); + } + + UserModel userModel = UserModel.create( + loginId, rawPassword, passwordEncoder, name, + LocalDate.parse(birthDate, BIRTH_DATE_FORMATTER), email + ); + + return userRepository.save(userModel); + } + + @Transactional(readOnly = true) + public UserModel getByLoginId(String loginId) { + return userRepository.find(new LoginId(loginId)) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.")); + } + + @Transactional + public void changePassword(String loginId, String rawCurrentPassword, String rawNewPassword) { + UserModel user = userRepository.find(new LoginId(loginId)) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.")); + + user.changePassword(rawCurrentPassword, rawNewPassword, passwordEncoder); + userRepository.save(user); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/BCryptPasswordEncoderImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/BCryptPasswordEncoderImpl.java similarity index 87% rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/BCryptPasswordEncoderImpl.java rename to apps/commerce-api/src/main/java/com/loopers/infrastructure/user/BCryptPasswordEncoderImpl.java index b9e0b850c..9eabf0cbf 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/BCryptPasswordEncoderImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/BCryptPasswordEncoderImpl.java @@ -1,9 +1,9 @@ -package com.loopers.infrastructure; +package com.loopers.infrastructure.user; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Component; -import com.loopers.domain.PasswordEncoder; +import com.loopers.domain.user.PasswordEncoder; @Component public class BCryptPasswordEncoderImpl implements PasswordEncoder { diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/UserJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java similarity index 64% rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/UserJpaRepository.java rename to apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java index adf3d6a3c..c8c354687 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/UserJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java @@ -1,7 +1,7 @@ -package com.loopers.infrastructure; +package com.loopers.infrastructure.user; -import com.loopers.domain.LoginId; -import com.loopers.domain.UserModel; +import com.loopers.domain.user.LoginId; +import com.loopers.domain.user.UserModel; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java similarity index 75% rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/UserRepositoryImpl.java rename to apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java index afecf020b..7c1c88e68 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/UserRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java @@ -1,8 +1,8 @@ -package com.loopers.infrastructure; +package com.loopers.infrastructure.user; -import com.loopers.domain.LoginId; -import com.loopers.domain.UserModel; -import com.loopers.domain.UserRepository; +import com.loopers.domain.user.LoginId; +import com.loopers.domain.user.UserModel; +import com.loopers.domain.user.UserRepository; import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; From 92a039506a35643c9547457c0f67d2d2b2c65bd6 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Wed, 18 Feb 2026 14:50:56 +0900 Subject: [PATCH 014/108] =?UTF-8?q?feat:=20Application=20=EA=B3=84?= =?UTF-8?q?=EC=B8=B5=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20VO=20=EC=BB=A8?= =?UTF-8?q?=EB=B2=A4=EC=85=98=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/application/user/UserFacade.java | 41 +++++++++++++++++++ .../application/user/dto/UserCommand.java | 19 +++++++++ .../application/user/dto/UserInfo.java | 21 ++++++++++ 3 files changed, 81 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/user/dto/UserCommand.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/user/dto/UserInfo.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java new file mode 100644 index 000000000..413b9ba8a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java @@ -0,0 +1,41 @@ +package com.loopers.application.user; + +import com.loopers.application.user.dto.UserCommand; +import com.loopers.application.user.dto.UserInfo; +import com.loopers.domain.user.AuthenticationService; +import com.loopers.domain.user.UserModel; +import com.loopers.domain.user.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class UserFacade { + + private final UserService userService; + private final AuthenticationService authenticationService; + + @Transactional + public UserInfo signup(UserCommand.Signup command) { + UserModel userModel = userService.signup( + command.loginId(), command.rawPassword(), command.name(), command.birthDate(), command.email() + ); + return UserInfo.from(userModel); + } + + @Transactional(readOnly = true) + public UserInfo getMyInfo(String loginId, String password) { + UserModel authenticatedUser = authenticationService.authenticate(loginId, password); + UserModel user = userService.getByLoginId(authenticatedUser.getLoginId().getValue()); + return UserInfo.from(user); + } + + @Transactional + public void changePassword(String loginId, String currentPassword, UserCommand.ChangePassword command) { + UserModel authenticatedUser = authenticationService.authenticate(loginId, currentPassword); + userService.changePassword( + authenticatedUser.getLoginId().getValue(), command.rawCurrentPassword(), command.rawNewPassword() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/dto/UserCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/user/dto/UserCommand.java new file mode 100644 index 000000000..f4f6a0302 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/dto/UserCommand.java @@ -0,0 +1,19 @@ +package com.loopers.application.user.dto; + +public class UserCommand { + + public record Signup( + String loginId, + String rawPassword, + String name, + String birthDate, + String email + ) { + } + + public record ChangePassword( + String rawCurrentPassword, + String rawNewPassword + ) { + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/dto/UserInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/user/dto/UserInfo.java new file mode 100644 index 000000000..c53d9d60e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/dto/UserInfo.java @@ -0,0 +1,21 @@ +package com.loopers.application.user.dto; + +import com.loopers.domain.user.UserModel; + +public record UserInfo( + Long id, + String loginId, + String name, + String birthDate, + String email +) { + public static UserInfo from(UserModel model) { + return new UserInfo( + model.getId(), + model.getLoginId().getValue(), + model.getName().getValue(), + model.getBirthDate().toDateString(), + model.getEmail().getMail() + ); + } +} From 7aaf5cb14950b63b7ad32c5c7a61fa59b036f164 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Wed, 18 Feb 2026 14:51:01 +0900 Subject: [PATCH 015/108] =?UTF-8?q?refactor:=20Interfaces=20=EA=B3=84?= =?UTF-8?q?=EC=B8=B5=20=ED=8C=A8=ED=82=A4=EC=A7=80=20=EC=9E=AC=EA=B5=AC?= =?UTF-8?q?=EC=84=B1=20=EB=B0=8F=20Facade=20=EC=9D=98=EC=A1=B4=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{api => }/user/UserV1ApiSpec.java | 3 +- .../{api => }/user/UserV1Controller.java | 28 ++++++------------- .../{api/user => user/dto}/UserV1Dto.java | 11 ++++++-- 3 files changed, 19 insertions(+), 23 deletions(-) rename apps/commerce-api/src/main/java/com/loopers/interfaces/{api => }/user/UserV1ApiSpec.java (95%) rename apps/commerce-api/src/main/java/com/loopers/interfaces/{api => }/user/UserV1Controller.java (55%) rename apps/commerce-api/src/main/java/com/loopers/interfaces/{api/user => user/dto}/UserV1Dto.java (88%) diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/user/UserV1ApiSpec.java similarity index 95% rename from apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java rename to apps/commerce-api/src/main/java/com/loopers/interfaces/user/UserV1ApiSpec.java index b3d0743cf..b49961eab 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/user/UserV1ApiSpec.java @@ -1,6 +1,7 @@ -package com.loopers.interfaces.api.user; +package com.loopers.interfaces.user; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.user.dto.UserV1Dto; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.parameters.RequestBody; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/user/UserV1Controller.java similarity index 55% rename from apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java rename to apps/commerce-api/src/main/java/com/loopers/interfaces/user/UserV1Controller.java index b4eb4be8d..d9f3351e7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/user/UserV1Controller.java @@ -1,12 +1,9 @@ -package com.loopers.interfaces.api.user; +package com.loopers.interfaces.user; -import com.loopers.domain.AuthenticationService; -import com.loopers.domain.ChangePasswordCommand; -import com.loopers.domain.SignupCommand; -import com.loopers.domain.UserInfo; -import com.loopers.domain.UserModel; -import com.loopers.domain.UserService; +import com.loopers.application.user.UserFacade; +import com.loopers.application.user.dto.UserInfo; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.user.dto.UserV1Dto; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; @@ -19,18 +16,14 @@ public class UserV1Controller implements UserV1ApiSpec { private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; - private final UserService userService; - private final AuthenticationService authenticationService; + private final UserFacade userFacade; @PostMapping("/signup") @Override public ApiResponse signup( @Valid @RequestBody UserV1Dto.SignupRequest request ) { - SignupCommand command = new SignupCommand( - request.loginId(), request.password(), request.name(), request.birthDate(), request.email() - ); - UserInfo userInfo = userService.signup(command); + UserInfo userInfo = userFacade.signup(request.toCommand()); return ApiResponse.success(UserV1Dto.SignupResponse.from(userInfo)); } @@ -41,8 +34,7 @@ public ApiResponse getMyInfo( @RequestHeader(HEADER_LOGIN_ID) String loginId, @RequestHeader(HEADER_LOGIN_PW) String password ) { - UserModel authenticatedUser = authenticationService.authenticate(loginId, password); - UserInfo userInfo = userService.getMyInfo(authenticatedUser.getLoginId()); + UserInfo userInfo = userFacade.getMyInfo(loginId, password); return ApiResponse.success(UserV1Dto.MyInfoResponse.from(userInfo)); } @@ -54,11 +46,7 @@ public ApiResponse changePassword( @RequestHeader(HEADER_LOGIN_PW) String currentPasswordValue, @Valid @RequestBody UserV1Dto.ChangePasswordRequest request ) { - UserModel authenticatedUser = authenticationService.authenticate(loginId, currentPasswordValue); - ChangePasswordCommand command = new ChangePasswordCommand( - authenticatedUser.getLoginId(), request.currentPassword(), request.newPassword() - ); - userService.changePassword(command); + userFacade.changePassword(loginId, currentPasswordValue, request.toCommand()); return ApiResponse.success(UserV1Dto.ChangePasswordResponse.success()); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/user/dto/UserV1Dto.java similarity index 88% rename from apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java rename to apps/commerce-api/src/main/java/com/loopers/interfaces/user/dto/UserV1Dto.java index 675197763..a28f17661 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/user/dto/UserV1Dto.java @@ -1,6 +1,7 @@ -package com.loopers.interfaces.api.user; +package com.loopers.interfaces.user.dto; -import com.loopers.domain.UserInfo; +import com.loopers.application.user.dto.UserCommand; +import com.loopers.application.user.dto.UserInfo; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; @@ -33,6 +34,9 @@ public record SignupRequest( @Email(message = "이메일 형식이 올바르지 않습니다.") String email ) { + public UserCommand.Signup toCommand() { + return new UserCommand.Signup(loginId, password, name, birthDate, email); + } } public record SignupResponse( @@ -80,6 +84,9 @@ public record ChangePasswordRequest( ) String newPassword ) { + public UserCommand.ChangePassword toCommand() { + return new UserCommand.ChangePassword(currentPassword, newPassword); + } } public record ChangePasswordResponse( From a78fd8b8507e7f1f59926bd435002b4e37efb290 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Wed, 18 Feb 2026 14:51:10 +0900 Subject: [PATCH 016/108] =?UTF-8?q?test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=ED=8C=A8=ED=82=A4=EC=A7=80=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99=20=EB=B0=8F=20VO=20=EC=BB=A8=EB=B2=A4=EC=85=98=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/domain/FakePasswordEncoder.java | 24 ------ .../{ => user}/AuthenticationServiceTest.java | 12 +-- .../domain/{ => user}/BirthDateTest.java | 2 +- .../loopers/domain/{ => user}/EmailTest.java | 2 +- .../{ => user}/EncryptedPasswordTest.java | 2 +- .../domain/user/FakePasswordEncoder.java | 14 ++++ .../domain/{ => user}/FakeUserRepository.java | 9 +-- .../domain/{ => user}/LoginIdTest.java | 2 +- .../loopers/domain/{ => user}/NameTest.java | 2 +- .../domain/{ => user}/UserModelTest.java | 48 ++++++------ .../{ => user}/UserServiceFakeTest.java | 30 +++----- .../UserServiceIntegrationTest.java | 74 ++++++++----------- .../{ => user}/UserServiceMockTest.java | 27 +++---- .../{api => user}/UserV1ApiE2ETest.java | 7 +- .../{api => user}/UserV1ApiScenarioTest.java | 5 +- 15 files changed, 109 insertions(+), 151 deletions(-) delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/FakePasswordEncoder.java rename apps/commerce-api/src/test/java/com/loopers/domain/{ => user}/AuthenticationServiceTest.java (93%) rename apps/commerce-api/src/test/java/com/loopers/domain/{ => user}/BirthDateTest.java (98%) rename apps/commerce-api/src/test/java/com/loopers/domain/{ => user}/EmailTest.java (98%) rename apps/commerce-api/src/test/java/com/loopers/domain/{ => user}/EncryptedPasswordTest.java (98%) create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/FakePasswordEncoder.java rename apps/commerce-api/src/test/java/com/loopers/domain/{ => user}/FakeUserRepository.java (60%) rename apps/commerce-api/src/test/java/com/loopers/domain/{ => user}/LoginIdTest.java (98%) rename apps/commerce-api/src/test/java/com/loopers/domain/{ => user}/NameTest.java (98%) rename apps/commerce-api/src/test/java/com/loopers/domain/{ => user}/UserModelTest.java (68%) rename apps/commerce-api/src/test/java/com/loopers/domain/{ => user}/UserServiceFakeTest.java (73%) rename apps/commerce-api/src/test/java/com/loopers/domain/{ => user}/UserServiceIntegrationTest.java (73%) rename apps/commerce-api/src/test/java/com/loopers/domain/{ => user}/UserServiceMockTest.java (84%) rename apps/commerce-api/src/test/java/com/loopers/interfaces/{api => user}/UserV1ApiE2ETest.java (99%) rename apps/commerce-api/src/test/java/com/loopers/interfaces/{api => user}/UserV1ApiScenarioTest.java (98%) diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/FakePasswordEncoder.java b/apps/commerce-api/src/test/java/com/loopers/domain/FakePasswordEncoder.java deleted file mode 100644 index 29da58ab0..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/FakePasswordEncoder.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.loopers.domain; - -// ============================================================ -// STEP 3: Fake 구현체 -// -// Mock과 달리, 단순한 로직을 직접 구현한 테스트용 객체. -// when-then 지시 없이 스스로 동작한다. -// -// 핵심: PasswordEncoder 인터페이스가 domain에 있기 때문에 -// 이 Fake도 infrastructure import 없이 작성 가능하다. -// ============================================================ - -public class FakePasswordEncoder implements PasswordEncoder { - - @Override - public String encode(String rawPassword) { - return "ENCODED_" + rawPassword; - } - - @Override - public boolean matches(String rawPassword, String encodedPassword) { - return encodedPassword.equals("ENCODED_" + rawPassword); - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/AuthenticationServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/AuthenticationServiceTest.java similarity index 93% rename from apps/commerce-api/src/test/java/com/loopers/domain/AuthenticationServiceTest.java rename to apps/commerce-api/src/test/java/com/loopers/domain/user/AuthenticationServiceTest.java index 906e2246a..805cf6212 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/AuthenticationServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/AuthenticationServiceTest.java @@ -1,4 +1,4 @@ -package com.loopers.domain; +package com.loopers.domain.user; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -43,13 +43,9 @@ void setUp() { encodedPassword = "$2a$10$encodedPasswordHash"; when(passwordEncoder.encode(validPassword)).thenReturn(encodedPassword); - testUser = new UserModel( - new LoginId(validLoginId), - validPassword, - passwordEncoder, - new Name("홍길동"), - new BirthDate(LocalDate.of(1990, 1, 15)), - new Email("test@example.com") + testUser = UserModel.create( + validLoginId, validPassword, passwordEncoder, + "홍길동", LocalDate.of(1990, 1, 15), "test@example.com" ); } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/BirthDateTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/BirthDateTest.java similarity index 98% rename from apps/commerce-api/src/test/java/com/loopers/domain/BirthDateTest.java rename to apps/commerce-api/src/test/java/com/loopers/domain/user/BirthDateTest.java index cb3c0304e..676aa81dc 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/BirthDateTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/BirthDateTest.java @@ -1,4 +1,4 @@ -package com.loopers.domain; +package com.loopers.domain.user; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/EmailTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/EmailTest.java similarity index 98% rename from apps/commerce-api/src/test/java/com/loopers/domain/EmailTest.java rename to apps/commerce-api/src/test/java/com/loopers/domain/user/EmailTest.java index 245c1f36a..6849e4af2 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/EmailTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/EmailTest.java @@ -1,4 +1,4 @@ -package com.loopers.domain; +package com.loopers.domain.user; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/EncryptedPasswordTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/EncryptedPasswordTest.java similarity index 98% rename from apps/commerce-api/src/test/java/com/loopers/domain/EncryptedPasswordTest.java rename to apps/commerce-api/src/test/java/com/loopers/domain/user/EncryptedPasswordTest.java index 77973e045..80e40af3e 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/EncryptedPasswordTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/EncryptedPasswordTest.java @@ -1,4 +1,4 @@ -package com.loopers.domain; +package com.loopers.domain.user; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/FakePasswordEncoder.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/FakePasswordEncoder.java new file mode 100644 index 000000000..b0deac092 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/FakePasswordEncoder.java @@ -0,0 +1,14 @@ +package com.loopers.domain.user; + +public class FakePasswordEncoder implements PasswordEncoder { + + @Override + public String encode(String rawPassword) { + return "ENCODED_" + rawPassword; + } + + @Override + public boolean matches(String rawPassword, String encodedPassword) { + return encodedPassword.equals("ENCODED_" + rawPassword); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/FakeUserRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/FakeUserRepository.java similarity index 60% rename from apps/commerce-api/src/test/java/com/loopers/domain/FakeUserRepository.java rename to apps/commerce-api/src/test/java/com/loopers/domain/user/FakeUserRepository.java index 9df4b2bd2..77c916638 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/FakeUserRepository.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/FakeUserRepository.java @@ -1,11 +1,4 @@ -package com.loopers.domain; - -// ============================================================ -// STEP 3: Fake 구현체 -// -// UserRepository 인터페이스는 원래부터 domain에 있었으므로 -// 이 Fake는 처음부터 infrastructure import 없이 작성 가능했다. -// ============================================================ +package com.loopers.domain.user; import java.util.HashMap; import java.util.Map; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/LoginIdTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/LoginIdTest.java similarity index 98% rename from apps/commerce-api/src/test/java/com/loopers/domain/LoginIdTest.java rename to apps/commerce-api/src/test/java/com/loopers/domain/user/LoginIdTest.java index 36e3c8989..b1e26f444 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/LoginIdTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/LoginIdTest.java @@ -1,4 +1,4 @@ -package com.loopers.domain; +package com.loopers.domain.user; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/NameTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/NameTest.java similarity index 98% rename from apps/commerce-api/src/test/java/com/loopers/domain/NameTest.java rename to apps/commerce-api/src/test/java/com/loopers/domain/user/NameTest.java index 85b684f6c..2444f2ce8 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/NameTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/NameTest.java @@ -1,4 +1,4 @@ -package com.loopers.domain; +package com.loopers.domain.user; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/UserModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java similarity index 68% rename from apps/commerce-api/src/test/java/com/loopers/domain/UserModelTest.java rename to apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java index 70f0273e0..7f5a159a4 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/UserModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java @@ -1,4 +1,4 @@ -package com.loopers.domain; +package com.loopers.domain.user; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -13,21 +13,21 @@ class UserModelTest { - private LoginId validLoginId; + private String validLoginId; private String validRawPassword; - private Name validName; - private BirthDate validBirthDate; - private Email validEmail; + private String validName; + private LocalDate validBirthDate; + private String validEmail; private FakePasswordEncoder encoder; @BeforeEach void setUp() { encoder = new FakePasswordEncoder(); - validLoginId = new LoginId("testuser123"); + validLoginId = "testuser123"; validRawPassword = "Test1234!@#"; - validName = new Name("홍길동"); - validBirthDate = new BirthDate(LocalDate.of(1990, 1, 15)); - validEmail = new Email("test@example.com"); + validName = "홍길동"; + validBirthDate = LocalDate.of(1990, 1, 15); + validEmail = "test@example.com"; } @DisplayName("유저 모델을 생성할 때, ") @@ -38,57 +38,57 @@ class Create { @Test void createUserModel_whenAllDataProvided() { // act - UserModel user = new UserModel(validLoginId, validRawPassword, encoder, validName, validBirthDate, validEmail); + UserModel user = UserModel.create(validLoginId, validRawPassword, encoder, validName, validBirthDate, validEmail); // assert assertAll( - () -> assertThat(user.getLoginId()).isEqualTo(validLoginId), + () -> assertThat(user.getLoginId().getValue()).isEqualTo(validLoginId), () -> assertThat(user.getPassword().getValue()).isEqualTo("ENCODED_Test1234!@#"), - () -> assertThat(user.getName()).isEqualTo(validName), - () -> assertThat(user.getBirthDate()).isEqualTo(validBirthDate), - () -> assertThat(user.getEmail()).isEqualTo(validEmail) + () -> assertThat(user.getName().getValue()).isEqualTo(validName), + () -> assertThat(user.getBirthDate().getDate()).isEqualTo(validBirthDate), + () -> assertThat(user.getEmail().getMail()).isEqualTo(validEmail) ); } @DisplayName("로그인 ID가 누락되면 예외가 발생한다.") @Test void createUserModel_whenLoginIdIsNull() { - assertThatThrownBy(() -> new UserModel(null, validRawPassword, encoder, validName, validBirthDate, validEmail)) + assertThatThrownBy(() -> UserModel.create(null, validRawPassword, encoder, validName, validBirthDate, validEmail)) .isInstanceOf(CoreException.class); } @DisplayName("비밀번호가 누락되면 예외가 발생한다.") @Test void createUserModel_whenPasswordIsNull() { - assertThatThrownBy(() -> new UserModel(validLoginId, null, encoder, validName, validBirthDate, validEmail)) + assertThatThrownBy(() -> UserModel.create(validLoginId, null, encoder, validName, validBirthDate, validEmail)) .isInstanceOf(CoreException.class); } @DisplayName("이름이 누락되면 예외가 발생한다.") @Test void createUserModel_whenNameIsNull() { - assertThatThrownBy(() -> new UserModel(validLoginId, validRawPassword, encoder, null, validBirthDate, validEmail)) + assertThatThrownBy(() -> UserModel.create(validLoginId, validRawPassword, encoder, null, validBirthDate, validEmail)) .isInstanceOf(CoreException.class); } @DisplayName("생년월일이 누락되면 예외가 발생한다.") @Test void createUserModel_whenBirthDateIsNull() { - assertThatThrownBy(() -> new UserModel(validLoginId, validRawPassword, encoder, validName, null, validEmail)) + assertThatThrownBy(() -> UserModel.create(validLoginId, validRawPassword, encoder, validName, null, validEmail)) .isInstanceOf(CoreException.class); } @DisplayName("이메일이 누락되면 예외가 발생한다.") @Test void createUserModel_whenEmailIsNull() { - assertThatThrownBy(() -> new UserModel(validLoginId, validRawPassword, encoder, validName, validBirthDate, null)) + assertThatThrownBy(() -> UserModel.create(validLoginId, validRawPassword, encoder, validName, validBirthDate, null)) .isInstanceOf(CoreException.class); } @DisplayName("비밀번호에 생년월일이 포함되면 예외가 발생한다.") @Test void createUserModel_whenPasswordContainsBirthDate() { - assertThatThrownBy(() -> new UserModel(validLoginId, "Pw19900115!", encoder, validName, validBirthDate, validEmail)) + assertThatThrownBy(() -> UserModel.create(validLoginId, "Pw19900115!", encoder, validName, validBirthDate, validEmail)) .isInstanceOf(CoreException.class) .hasMessageContaining("생년월일은 비밀번호 내에 포함될 수 없습니다."); } @@ -102,7 +102,7 @@ class ChangePassword { @Test void changePassword_success() { // arrange - UserModel user = new UserModel(validLoginId, validRawPassword, encoder, validName, validBirthDate, validEmail); + UserModel user = UserModel.create(validLoginId, validRawPassword, encoder, validName, validBirthDate, validEmail); // act user.changePassword("Test1234!@#", "NewPass123!@", encoder); @@ -115,7 +115,7 @@ void changePassword_success() { @Test void changePassword_whenCurrentPasswordNotMatch() { // arrange - UserModel user = new UserModel(validLoginId, validRawPassword, encoder, validName, validBirthDate, validEmail); + UserModel user = UserModel.create(validLoginId, validRawPassword, encoder, validName, validBirthDate, validEmail); // act & assert assertThatThrownBy(() -> user.changePassword("Wrong123!@#", "NewPass123!@", encoder)) @@ -127,7 +127,7 @@ void changePassword_whenCurrentPasswordNotMatch() { @Test void changePassword_whenNewPasswordSameAsCurrent() { // arrange - UserModel user = new UserModel(validLoginId, validRawPassword, encoder, validName, validBirthDate, validEmail); + UserModel user = UserModel.create(validLoginId, validRawPassword, encoder, validName, validBirthDate, validEmail); // act & assert assertThatThrownBy(() -> user.changePassword("Test1234!@#", "Test1234!@#", encoder)) @@ -139,7 +139,7 @@ void changePassword_whenNewPasswordSameAsCurrent() { @Test void changePassword_whenNewPasswordContainsBirthDate() { // arrange - UserModel user = new UserModel(validLoginId, validRawPassword, encoder, validName, validBirthDate, validEmail); + UserModel user = UserModel.create(validLoginId, validRawPassword, encoder, validName, validBirthDate, validEmail); // act & assert assertThatThrownBy(() -> user.changePassword("Test1234!@#", "Pw19900115!", encoder)) diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceFakeTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceFakeTest.java similarity index 73% rename from apps/commerce-api/src/test/java/com/loopers/domain/UserServiceFakeTest.java rename to apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceFakeTest.java index f1e933ad5..7488f1a54 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceFakeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceFakeTest.java @@ -1,4 +1,4 @@ -package com.loopers.domain; +package com.loopers.domain.user; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -35,10 +35,6 @@ void setUp() { email = "test@example.com"; } - private SignupCommand signupCommand() { - return new SignupCommand(loginId, rawPassword, name, birthDate, email); - } - @DisplayName("회원가입") @Nested class Signup { @@ -47,11 +43,11 @@ class Signup { @DisplayName("성공 — when-then 0줄, 암호화 결과를 직접 검증") void signup_성공() { // act - UserInfo result = userService.signup(signupCommand()); + UserModel result = userService.signup(loginId, rawPassword, name, birthDate, email); // assert assertThat(result).isNotNull(); - assertThat(result.loginId()).isEqualTo(loginId); + assertThat(result.getLoginId().getValue()).isEqualTo(loginId); // 암호화 검증은 repository를 통해 직접 확인 UserModel saved = userRepository.find(new LoginId(loginId)).orElseThrow(); @@ -62,12 +58,11 @@ class Signup { @DisplayName("중복 아이디면 예외") void signup_중복아이디_예외() { // arrange - userService.signup(signupCommand()); + userService.signup(loginId, rawPassword, name, birthDate, email); // act & assert - SignupCommand duplicateCommand = new SignupCommand(loginId, "Other123!@#", name, birthDate, email); assertThatThrownBy(() -> - userService.signup(duplicateCommand) + userService.signup(loginId, "Other123!@#", name, birthDate, email) ).isInstanceOf(CoreException.class) .hasMessageContaining("이미 존재하는 아이디입니다."); } @@ -81,14 +76,13 @@ class ChangePassword { @DisplayName("성공 — when-then 0줄, 변경된 비밀번호를 직접 검증") void changePassword_성공() { // arrange - userService.signup(signupCommand()); + userService.signup(loginId, rawPassword, name, birthDate, email); // act - LoginId loginIdVo = new LoginId(loginId); - userService.changePassword(new ChangePasswordCommand(loginIdVo, rawPassword, "NewPass123!@")); + userService.changePassword(loginId, rawPassword, "NewPass123!@"); // assert - UserModel updated = userRepository.find(loginIdVo).orElseThrow(); + UserModel updated = userRepository.find(new LoginId(loginId)).orElseThrow(); assertThat(updated.getPassword().getValue()).isEqualTo("ENCODED_NewPass123!@"); } @@ -96,11 +90,11 @@ class ChangePassword { @DisplayName("현재 비밀번호 불일치면 예외") void changePassword_현재비밀번호_불일치() { // arrange - userService.signup(signupCommand()); + userService.signup(loginId, rawPassword, name, birthDate, email); // act & assert assertThatThrownBy(() -> - userService.changePassword(new ChangePasswordCommand(new LoginId(loginId), "Wrong123!@#", "NewPass123!@")) + userService.changePassword(loginId, "Wrong123!@#", "NewPass123!@") ).isInstanceOf(CoreException.class) .hasMessageContaining("현재 비밀번호가 일치하지 않습니다."); } @@ -109,11 +103,11 @@ class ChangePassword { @DisplayName("새 비밀번호가 현재와 같으면 예외") void changePassword_새비밀번호가_현재와_같으면_예외() { // arrange - userService.signup(signupCommand()); + userService.signup(loginId, rawPassword, name, birthDate, email); // act & assert assertThatThrownBy(() -> - userService.changePassword(new ChangePasswordCommand(new LoginId(loginId), rawPassword, rawPassword)) + userService.changePassword(loginId, rawPassword, rawPassword) ).isInstanceOf(CoreException.class) .hasMessageContaining("현재 사용 중인 비밀번호는 사용할 수 없습니다."); } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java similarity index 73% rename from apps/commerce-api/src/test/java/com/loopers/domain/UserServiceIntegrationTest.java rename to apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java index d0dd8923a..460520a9c 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java @@ -1,10 +1,10 @@ -package com.loopers.domain; +package com.loopers.domain.user; 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 com.loopers.infrastructure.UserJpaRepository; +import com.loopers.infrastructure.user.UserJpaRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import com.loopers.utils.DatabaseCleanUp; @@ -18,6 +18,7 @@ @SpringBootTest public class UserServiceIntegrationTest { + @Autowired private UserService userService; @@ -50,8 +51,8 @@ void tearDown() { databaseCleanUp.truncateAllTables(); } - private SignupCommand signupCommand() { - return new SignupCommand(loginId, rawPassword, name, birthDate, email); + private UserModel doSignup() { + return userService.signup(loginId, rawPassword, name, birthDate, email); } @DisplayName("유저가 회원가입할 때") @@ -61,15 +62,15 @@ class SingUp{ @Test void signup_whenAllInfoProvided() { // act - UserInfo result = userService.signup(signupCommand()); + UserModel result = doSignup(); // assert assertAll( () -> assertThat(result).isNotNull(), - () -> assertThat(result.loginId()).isEqualTo(loginId), - () -> assertThat(result.name()).isEqualTo(name), - () -> assertThat(result.birthDate()).isEqualTo(birthDate), - () -> assertThat(result.email()).isEqualTo(email) + () -> assertThat(result.getLoginId().getValue()).isEqualTo(loginId), + () -> assertThat(result.getName().getValue()).isEqualTo(name), + () -> assertThat(result.getBirthDate().toDateString()).isEqualTo(birthDate), + () -> assertThat(result.getEmail().getMail()).isEqualTo(email) ); } @@ -77,10 +78,10 @@ void signup_whenAllInfoProvided() { @Test void signup_should_encrypt_password() { // act - UserInfo result = userService.signup(signupCommand()); + UserModel result = doSignup(); // assert - UserModel savedUser = userJpaRepository.findById(result.id()).orElseThrow(); + UserModel savedUser = userJpaRepository.findById(result.getId()).orElseThrow(); String savedPassword = savedUser.getPassword().getValue(); assertAll( () -> assertThat(savedPassword).isNotEqualTo(rawPassword), @@ -93,10 +94,10 @@ void signup_should_encrypt_password() { @Test void signup_should_save_encrypted_password_to_database() { // act - UserInfo result = userService.signup(signupCommand()); + UserModel result = doSignup(); // assert - UserModel savedUser = userJpaRepository.findById(result.id()).orElseThrow(); + UserModel savedUser = userJpaRepository.findById(result.getId()).orElseThrow(); String savedPassword = savedUser.getPassword().getValue(); assertAll( () -> assertThat(savedPassword).isNotEqualTo(rawPassword), @@ -113,30 +114,26 @@ class GetMyInfo { @Test void getMyInfo_whenValidLoginId() { // arrange - userService.signup(signupCommand()); + doSignup(); // act - LoginId loginIdVo = new LoginId(loginId); - UserInfo result = userService.getMyInfo(loginIdVo); + UserModel result = userService.getByLoginId(loginId); // assert assertAll( () -> assertThat(result).isNotNull(), - () -> assertThat(result.loginId()).isEqualTo(loginId), - () -> assertThat(result.name()).isEqualTo(name), - () -> assertThat(result.birthDate()).isEqualTo(birthDate), - () -> assertThat(result.email()).isEqualTo(email) + () -> assertThat(result.getLoginId().getValue()).isEqualTo(loginId), + () -> assertThat(result.getName().getValue()).isEqualTo(name), + () -> assertThat(result.getBirthDate().toDateString()).isEqualTo(birthDate), + () -> assertThat(result.getEmail().getMail()).isEqualTo(email) ); } @DisplayName("존재하지 않는 로그인 ID로 조회하면 예외가 발생한다") @Test void getMyInfo_whenInvalidLoginId() { - // arrange - LoginId invalidLoginId = new LoginId("invalid123"); - // act & assert - assertThatThrownBy(() -> userService.getMyInfo(invalidLoginId)) + assertThatThrownBy(() -> userService.getByLoginId("invalid123")) .isInstanceOf(CoreException.class) .hasFieldOrPropertyWithValue("errorCode", ErrorType.NOT_FOUND) .hasMessageContaining("사용자를 찾을 수 없습니다."); @@ -150,16 +147,15 @@ class ChangePassword { @Test void changePassword_whenValidPasswords() { // arrange - userService.signup(signupCommand()); + doSignup(); String newRawPassword = "NewPass123!@"; // act - LoginId loginIdVo = new LoginId(loginId); - userService.changePassword(new ChangePasswordCommand(loginIdVo, rawPassword, newRawPassword)); + userService.changePassword(loginId, rawPassword, newRawPassword); // assert - UserInfo updatedUser = userService.getMyInfo(loginIdVo); - UserModel savedUser = userJpaRepository.findById(updatedUser.id()).orElseThrow(); + UserModel updatedUser = userService.getByLoginId(loginId); + UserModel savedUser = userJpaRepository.findById(updatedUser.getId()).orElseThrow(); String savedPassword = savedUser.getPassword().getValue(); assertAll( () -> assertThat(savedPassword).isNotEqualTo(rawPassword), @@ -172,11 +168,10 @@ void changePassword_whenValidPasswords() { @Test void changePassword_whenCurrentPasswordNotMatch() { // arrange - userService.signup(signupCommand()); + doSignup(); // act & assert - LoginId loginIdVo = new LoginId(loginId); - assertThatThrownBy(() -> userService.changePassword(new ChangePasswordCommand(loginIdVo, "Wrong123!@#", "NewPass123!@"))) + assertThatThrownBy(() -> userService.changePassword(loginId, "Wrong123!@#", "NewPass123!@")) .isInstanceOf(CoreException.class) .hasMessageContaining("현재 비밀번호가 일치하지 않습니다."); } @@ -185,11 +180,10 @@ void changePassword_whenCurrentPasswordNotMatch() { @Test void changePassword_whenNewPasswordSameAsCurrent() { // arrange - userService.signup(signupCommand()); + doSignup(); // act & assert - LoginId loginIdVo = new LoginId(loginId); - assertThatThrownBy(() -> userService.changePassword(new ChangePasswordCommand(loginIdVo, rawPassword, rawPassword))) + assertThatThrownBy(() -> userService.changePassword(loginId, rawPassword, rawPassword)) .isInstanceOf(CoreException.class) .hasMessageContaining("현재 사용 중인 비밀번호는 사용할 수 없습니다."); } @@ -198,11 +192,10 @@ void changePassword_whenNewPasswordSameAsCurrent() { @Test void changePassword_whenNewPasswordContainsBirthDate() { // arrange - userService.signup(signupCommand()); + doSignup(); // act & assert - LoginId loginIdVo = new LoginId(loginId); - assertThatThrownBy(() -> userService.changePassword(new ChangePasswordCommand(loginIdVo, rawPassword, "Pw19900115!"))) + assertThatThrownBy(() -> userService.changePassword(loginId, rawPassword, "Pw19900115!")) .isInstanceOf(CoreException.class) .hasMessageContaining("생년월일은 비밀번호 내에 포함될 수 없습니다."); } @@ -210,11 +203,8 @@ void changePassword_whenNewPasswordContainsBirthDate() { @DisplayName("존재하지 않는 사용자의 비밀번호 변경 시 예외가 발생한다") @Test void changePassword_whenUserNotFound() { - // arrange - LoginId invalidLoginId = new LoginId("invalid123"); - // act & assert - assertThatThrownBy(() -> userService.changePassword(new ChangePasswordCommand(invalidLoginId, rawPassword, "NewPass123!@"))) + assertThatThrownBy(() -> userService.changePassword("invalid123", rawPassword, "NewPass123!@")) .isInstanceOf(CoreException.class) .hasFieldOrPropertyWithValue("errorCode", ErrorType.NOT_FOUND) .hasMessageContaining("사용자를 찾을 수 없습니다."); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceMockTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceMockTest.java similarity index 84% rename from apps/commerce-api/src/test/java/com/loopers/domain/UserServiceMockTest.java rename to apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceMockTest.java index da47f1a35..d1ac2db49 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/UserServiceMockTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceMockTest.java @@ -1,4 +1,4 @@ -package com.loopers.domain; +package com.loopers.domain.user; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -7,6 +7,7 @@ import static org.mockito.Mockito.when; import com.loopers.support.error.CoreException; +import java.time.LocalDate; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -45,10 +46,6 @@ void setUp() { email = "test@example.com"; } - private SignupCommand signupCommand() { - return new SignupCommand(loginId, rawPassword, name, birthDate, email); - } - @DisplayName("회원가입") @Nested class Signup { @@ -62,11 +59,11 @@ class Signup { when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); // act - UserInfo result = userService.signup(signupCommand()); + UserModel result = userService.signup(loginId, rawPassword, name, birthDate, email); // assert assertThat(result).isNotNull(); - assertThat(result.loginId()).isEqualTo(loginId); + assertThat(result.getLoginId().getValue()).isEqualTo(loginId); } @Test @@ -78,7 +75,7 @@ class Signup { // act & assert assertThatThrownBy(() -> - userService.signup(signupCommand()) + userService.signup(loginId, rawPassword, name, birthDate, email) ).isInstanceOf(CoreException.class) .hasMessageContaining("이미 존재하는 아이디입니다."); } @@ -106,7 +103,7 @@ class ChangePassword { .thenAnswer(inv -> inv.getArgument(0)); // act - userService.changePassword(new ChangePasswordCommand(new LoginId(loginId), "Test1234!@#", "NewPass123!@")); + userService.changePassword(loginId, "Test1234!@#", "NewPass123!@"); // assert verify(userRepository).save(any()); @@ -125,7 +122,7 @@ class ChangePassword { // act & assert assertThatThrownBy(() -> - userService.changePassword(new ChangePasswordCommand(new LoginId(loginId), "Wrong123!@#", "NewPass123!@")) + userService.changePassword(loginId, "Wrong123!@#", "NewPass123!@") ).isInstanceOf(CoreException.class) .hasMessageContaining("현재 비밀번호가 일치하지 않습니다."); } @@ -138,13 +135,9 @@ private UserModel createTestUser(String encodedPassword) { @Override public String encode(String rawPassword) { return encodedPassword; } @Override public boolean matches(String rawPassword, String encoded) { return false; } }; - return new UserModel( - new LoginId(loginId), - rawPassword, - fixedEncoder, - new Name(name), - new BirthDate(java.time.LocalDate.of(1990, 1, 15)), - new Email(email) + return UserModel.create( + loginId, rawPassword, fixedEncoder, + name, LocalDate.of(1990, 1, 15), email ); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/user/UserV1ApiE2ETest.java similarity index 99% rename from apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java rename to apps/commerce-api/src/test/java/com/loopers/interfaces/user/UserV1ApiE2ETest.java index b4f7b5bbd..71c71138d 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/user/UserV1ApiE2ETest.java @@ -1,11 +1,12 @@ -package com.loopers.interfaces.api; +package com.loopers.interfaces.user; 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 com.loopers.infrastructure.UserJpaRepository; -import com.loopers.interfaces.api.user.UserV1Dto; +import com.loopers.infrastructure.user.UserJpaRepository; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.user.dto.UserV1Dto; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiScenarioTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/user/UserV1ApiScenarioTest.java similarity index 98% rename from apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiScenarioTest.java rename to apps/commerce-api/src/test/java/com/loopers/interfaces/user/UserV1ApiScenarioTest.java index e15c7e65d..e376f9a6e 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiScenarioTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/user/UserV1ApiScenarioTest.java @@ -1,10 +1,11 @@ -package com.loopers.interfaces.api; +package com.loopers.interfaces.user; 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 com.loopers.interfaces.api.user.UserV1Dto; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.user.dto.UserV1Dto; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; From f7cc53256ecbd88ffdd68d6753c839b9630aea28 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Wed, 18 Feb 2026 14:51:14 +0900 Subject: [PATCH 017/108] =?UTF-8?q?docs:=20DTO=20=EC=BB=A8=EB=B2=A4?= =?UTF-8?q?=EC=85=98=EC=97=90=20VO=20=EC=A0=84=EB=8B=AC=20=EA=B7=9C?= =?UTF-8?q?=EC=B9=99=20=EB=AA=85=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../references/common/dto-convention.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.claude/skills/project-convention/references/common/dto-convention.md b/.claude/skills/project-convention/references/common/dto-convention.md index 19899c59b..231ef49e5 100644 --- a/.claude/skills/project-convention/references/common/dto-convention.md +++ b/.claude/skills/project-convention/references/common/dto-convention.md @@ -109,12 +109,16 @@ public record StockDeductionInfo(int remainingStock, boolean success) {} | 파라미터 수 | 전달 방식 | 예시 | |------------|----------|------| -| **1~3개** | 원시 타입 / VO 직접 전달 | `orderService.create(memberId, address, shopId)` | +| **1~3개** | 원시 타입 직접 전달 | `orderService.create(memberId, address, shopId)` | | **4개 이상** | DTO(`~Data`) 사용 | `orderService.create(orderProductData)` | +> **주의 — VO 전달과 VO 생성은 다르다.** +> Entity 필드용 VO를 호출자(Facade 등)가 **새로 생성하여 전달하는 것은 금지**한다. VO는 Entity 내부에서 원시값으로부터 생성한다 (→ entity-vo-convention 참조). +> 여기서 "직접 전달"이란, Entity getter 등에서 이미 존재하는 값을 꺼내 넘기는 경우를 말한다. + ```java -// ✅ 파라미터 3개 이하 → 원시 타입/VO -public Order create(Long memberId, Address address, Long shopId) { ... } +// ✅ 파라미터 3개 이하 → 원시 타입 +public Order create(Long memberId, String address, Long shopId) { ... } // ✅ 파라미터 4개 이상 → Data DTO public Order create(OrderProductData productData) { ... } From 79e75a021e3a7b20a8db66e5589df171546ad1e4 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Wed, 18 Feb 2026 22:29:36 +0900 Subject: [PATCH 018/108] =?UTF-8?q?feat:=20Filter=20+=20ArgumentResolver?= =?UTF-8?q?=20=EA=B8=B0=EB=B0=98=20=EC=9D=B8=EC=A6=9D=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../com/loopers/interfaces/auth/Auth.java | 11 +++ .../loopers/interfaces/auth/AuthFilter.java | 79 +++++++++++++++++++ .../com/loopers/interfaces/auth/AuthUser.java | 4 + .../auth/AuthUserArgumentResolver.java | 38 +++++++++ .../loopers/support/config/WebMvcConfig.java | 20 +++++ 5 files changed, 152 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/auth/Auth.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthFilter.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthUser.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthUserArgumentResolver.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/support/config/WebMvcConfig.java diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/Auth.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/Auth.java new file mode 100644 index 000000000..d2e85de22 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/Auth.java @@ -0,0 +1,11 @@ +package com.loopers.interfaces.auth; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface Auth { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthFilter.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthFilter.java new file mode 100644 index 000000000..7c50fffad --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthFilter.java @@ -0,0 +1,79 @@ +package com.loopers.interfaces.auth; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.user.AuthenticationService; +import com.loopers.domain.user.UserModel; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Component +@RequiredArgsConstructor +public class AuthFilter extends OncePerRequestFilter { + + private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; + private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; + private static final Set AUTH_REQUIRED_URLS = Set.of( + "/api/v1/users/me", + "/api/v1/users/password" + ); + + private final AuthenticationService authenticationService; + private final ObjectMapper objectMapper; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + if (!requiresAuth(request.getRequestURI())) { + filterChain.doFilter(request, response); + return; + } + + String loginId = request.getHeader(HEADER_LOGIN_ID); + String password = request.getHeader(HEADER_LOGIN_PW); + + if (loginId == null || loginId.isBlank() || password == null || password.isBlank()) { + writeUnauthorizedResponse(response, ErrorType.UNAUTHORIZED.getMessage()); + return; + } + + try { + UserModel authenticatedUser = authenticationService.authenticate(loginId, password); + AuthUser authUser = new AuthUser( + authenticatedUser.getId(), + authenticatedUser.getLoginId().getValue(), + authenticatedUser.getName().getValue() + ); + request.setAttribute("authUser", authUser); + filterChain.doFilter(request, response); + } catch (CoreException e) { + writeUnauthorizedResponse(response, + e.getCustomMessage() != null ? e.getCustomMessage() : e.getErrorCode().getMessage()); + } + } + + private boolean requiresAuth(String uri) { + return AUTH_REQUIRED_URLS.contains(uri); + } + + private void writeUnauthorizedResponse(HttpServletResponse response, String message) throws IOException { + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + + ApiResponse apiResponse = ApiResponse.fail(ErrorType.UNAUTHORIZED.getCode(), message); + response.getWriter().write(objectMapper.writeValueAsString(apiResponse)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthUser.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthUser.java new file mode 100644 index 000000000..6626ff68c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthUser.java @@ -0,0 +1,4 @@ +package com.loopers.interfaces.auth; + +public record AuthUser(Long id, String loginId, String name) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthUserArgumentResolver.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthUserArgumentResolver.java new file mode 100644 index 000000000..4f165c50e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthUserArgumentResolver.java @@ -0,0 +1,38 @@ +package com.loopers.interfaces.auth; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +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; + +@Component +@RequiredArgsConstructor +public class AuthUserArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(Auth.class) + && AuthUser.class.isAssignableFrom(parameter.getParameterType()); + } + + @Override + public Object resolveArgument(MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory) { + HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); + AuthUser authUser = (AuthUser) request.getAttribute("authUser"); + + if (authUser == null) { + throw new CoreException(ErrorType.UNAUTHORIZED); + } + + return authUser; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/config/WebMvcConfig.java b/apps/commerce-api/src/main/java/com/loopers/support/config/WebMvcConfig.java new file mode 100644 index 000000000..424ebbe90 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/config/WebMvcConfig.java @@ -0,0 +1,20 @@ +package com.loopers.support.config; + +import com.loopers.interfaces.auth.AuthUserArgumentResolver; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +@RequiredArgsConstructor +public class WebMvcConfig implements WebMvcConfigurer { + + private final AuthUserArgumentResolver authUserArgumentResolver; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(authUserArgumentResolver); + } +} From 697346dfe6cac506528acca1f105fc0ab608e283 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Wed, 18 Feb 2026 22:29:40 +0900 Subject: [PATCH 019/108] =?UTF-8?q?refactor:=20Controller/Facade=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=9D=B8=EC=A6=9D=20=EA=B4=80=EC=8B=AC=EC=82=AC=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../loopers/application/user/UserFacade.java | 14 ++++---------- .../loopers/interfaces/user/UserV1ApiSpec.java | 13 +++++-------- .../interfaces/user/UserV1Controller.java | 17 ++++++----------- 3 files changed, 15 insertions(+), 29 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java index 413b9ba8a..b2cbfea1f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java @@ -2,7 +2,6 @@ import com.loopers.application.user.dto.UserCommand; import com.loopers.application.user.dto.UserInfo; -import com.loopers.domain.user.AuthenticationService; import com.loopers.domain.user.UserModel; import com.loopers.domain.user.UserService; import lombok.RequiredArgsConstructor; @@ -14,7 +13,6 @@ public class UserFacade { private final UserService userService; - private final AuthenticationService authenticationService; @Transactional public UserInfo signup(UserCommand.Signup command) { @@ -25,17 +23,13 @@ public UserInfo signup(UserCommand.Signup command) { } @Transactional(readOnly = true) - public UserInfo getMyInfo(String loginId, String password) { - UserModel authenticatedUser = authenticationService.authenticate(loginId, password); - UserModel user = userService.getByLoginId(authenticatedUser.getLoginId().getValue()); + public UserInfo getMyInfo(String loginId) { + UserModel user = userService.getByLoginId(loginId); return UserInfo.from(user); } @Transactional - public void changePassword(String loginId, String currentPassword, UserCommand.ChangePassword command) { - UserModel authenticatedUser = authenticationService.authenticate(loginId, currentPassword); - userService.changePassword( - authenticatedUser.getLoginId().getValue(), command.rawCurrentPassword(), command.rawNewPassword() - ); + public void changePassword(String loginId, UserCommand.ChangePassword command) { + userService.changePassword(loginId, command.rawCurrentPassword(), command.rawNewPassword()); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/user/UserV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/user/UserV1ApiSpec.java index b49961eab..5b907af56 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/user/UserV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/user/UserV1ApiSpec.java @@ -1,6 +1,7 @@ package com.loopers.interfaces.user; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.auth.AuthUser; import com.loopers.interfaces.user.dto.UserV1Dto; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -24,10 +25,8 @@ ApiResponse signup( description = "인증된 사용자의 정보를 조회합니다. 헤더에 X-Loopers-LoginId와 X-Loopers-LoginPw를 포함해야 합니다." ) ApiResponse getMyInfo( - @Parameter(description = "로그인 ID", required = true) - String loginId, - @Parameter(description = "비밀번호", required = true) - String password + @Parameter(hidden = true) + AuthUser authUser ); @Operation( @@ -35,10 +34,8 @@ ApiResponse getMyInfo( description = "인증된 사용자의 비밀번호를 변경합니다. 헤더에 X-Loopers-LoginId와 X-Loopers-LoginPw를 포함해야 합니다." ) ApiResponse changePassword( - @Parameter(description = "로그인 ID", required = true) - String loginId, - @Parameter(description = "현재 비밀번호", required = true) - String currentPassword, + @Parameter(hidden = true) + AuthUser authUser, @RequestBody(description = "비밀번호 변경 요청 정보") UserV1Dto.ChangePasswordRequest request ); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/user/UserV1Controller.java index d9f3351e7..0b8bdba2c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/user/UserV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/user/UserV1Controller.java @@ -3,6 +3,8 @@ import com.loopers.application.user.UserFacade; import com.loopers.application.user.dto.UserInfo; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.auth.Auth; +import com.loopers.interfaces.auth.AuthUser; import com.loopers.interfaces.user.dto.UserV1Dto; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -13,9 +15,6 @@ @RequestMapping("/api/v1/users") public class UserV1Controller implements UserV1ApiSpec { - private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; - private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; - private final UserFacade userFacade; @PostMapping("/signup") @@ -30,11 +29,8 @@ public ApiResponse signup( @GetMapping("/me") @Override - public ApiResponse getMyInfo( - @RequestHeader(HEADER_LOGIN_ID) String loginId, - @RequestHeader(HEADER_LOGIN_PW) String password - ) { - UserInfo userInfo = userFacade.getMyInfo(loginId, password); + public ApiResponse getMyInfo(@Auth AuthUser authUser) { + UserInfo userInfo = userFacade.getMyInfo(authUser.loginId()); return ApiResponse.success(UserV1Dto.MyInfoResponse.from(userInfo)); } @@ -42,11 +38,10 @@ public ApiResponse getMyInfo( @PatchMapping("/password") @Override public ApiResponse changePassword( - @RequestHeader(HEADER_LOGIN_ID) String loginId, - @RequestHeader(HEADER_LOGIN_PW) String currentPasswordValue, + @Auth AuthUser authUser, @Valid @RequestBody UserV1Dto.ChangePasswordRequest request ) { - userFacade.changePassword(loginId, currentPasswordValue, request.toCommand()); + userFacade.changePassword(authUser.loginId(), request.toCommand()); return ApiResponse.success(UserV1Dto.ChangePasswordResponse.success()); } From 0264b0d0a8295b259ed9e23c27b38cd95188eb6a Mon Sep 17 00:00:00 2001 From: plan11plan Date: Wed, 18 Feb 2026 22:29:45 +0900 Subject: [PATCH 020/108] =?UTF-8?q?test:=20AuthFilter,=20AuthUserArgumentR?= =?UTF-8?q?esolver=20=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../interfaces/auth/AuthFilterTest.java | 130 ++++++++++++++++++ .../auth/AuthUserArgumentResolverTest.java | 111 +++++++++++++++ 2 files changed, 241 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/auth/AuthFilterTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/auth/AuthUserArgumentResolverTest.java diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/auth/AuthFilterTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/auth/AuthFilterTest.java new file mode 100644 index 000000000..383428ff3 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/auth/AuthFilterTest.java @@ -0,0 +1,130 @@ +package com.loopers.interfaces.auth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.user.AuthenticationService; +import com.loopers.domain.user.UserModel; +import com.loopers.domain.user.LoginId; +import com.loopers.domain.user.Name; +import com.loopers.domain.user.PasswordEncoder; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.servlet.FilterChain; +import java.time.LocalDate; +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.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +@DisplayName("AuthFilter 단위 테스트") +@ExtendWith(MockitoExtension.class) +class AuthFilterTest { + + @Mock + private AuthenticationService authenticationService; + + @Mock + private FilterChain filterChain; + + private AuthFilter authFilter; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @BeforeEach + void setUp() { + authFilter = new AuthFilter(authenticationService, objectMapper); + } + + @DisplayName("인증 필요 URL에 유효한 헤더가 있으면, AuthUser attribute를 설정하고 filterChain을 진행한다") + @Test + void setsAuthUserAttribute_whenValidHeadersOnAuthRequiredUrl() throws Exception { + // arrange + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/users/me"); + request.addHeader("X-Loopers-LoginId", "testuser1"); + request.addHeader("X-Loopers-LoginPw", "Test1234!"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + PasswordEncoder encoder = mock(PasswordEncoder.class); + when(encoder.encode("Test1234!")).thenReturn("encoded"); + UserModel userModel = UserModel.create( + "testuser1", "Test1234!", encoder, "홍길동", LocalDate.of(1990, 1, 15), "test@example.com" + ); + when(authenticationService.authenticate("testuser1", "Test1234!")).thenReturn(userModel); + + // act + authFilter.doFilterInternal(request, response, filterChain); + + // assert + AuthUser authUser = (AuthUser) request.getAttribute("authUser"); + assertThat(authUser).isNotNull(); + assertThat(authUser.loginId()).isEqualTo("testuser1"); + assertThat(authUser.name()).isEqualTo("홍길동"); + verify(filterChain).doFilter(request, response); + } + + @DisplayName("인증 필요 URL에 헤더가 누락되면, 401 JSON 응답을 직접 반환한다") + @Test + void returns401_whenHeadersMissingOnAuthRequiredUrl() throws Exception { + // arrange + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/users/me"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + // act + authFilter.doFilterInternal(request, response, filterChain); + + // assert + assertThat(response.getStatus()).isEqualTo(HttpStatus.UNAUTHORIZED.value()); + assertThat(response.getContentType()).startsWith("application/json"); + assertThat(response.getContentAsString()).contains("FAIL"); + verify(filterChain, never()).doFilter(request, response); + } + + @DisplayName("인증 필요 URL에 인증 실패하면, 401 JSON 응답을 직접 반환한다") + @Test + void returns401_whenAuthenticationFailsOnAuthRequiredUrl() throws Exception { + // arrange + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/users/me"); + request.addHeader("X-Loopers-LoginId", "testuser1"); + request.addHeader("X-Loopers-LoginPw", "Wrong1234!"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + when(authenticationService.authenticate("testuser1", "Wrong1234!")) + .thenThrow(new CoreException(ErrorType.UNAUTHORIZED, "로그인 ID 또는 비밀번호가 일치하지 않습니다.")); + + // act + authFilter.doFilterInternal(request, response, filterChain); + + // assert + assertThat(response.getStatus()).isEqualTo(HttpStatus.UNAUTHORIZED.value()); + assertThat(response.getContentType()).startsWith("application/json"); + assertThat(response.getContentAsString()).contains("로그인 ID 또는 비밀번호가 일치하지 않습니다."); + verify(filterChain, never()).doFilter(request, response); + } + + @DisplayName("인증 불필요 URL이면, 헤더 없이도 filterChain을 진행한다") + @Test + void proceedsFilterChain_whenUrlDoesNotRequireAuth() throws Exception { + // arrange + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/users/signup"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + // act + authFilter.doFilterInternal(request, response, filterChain); + + // assert + assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value()); + verify(filterChain).doFilter(request, response); + verify(authenticationService, never()).authenticate(anyString(), anyString()); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/auth/AuthUserArgumentResolverTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/auth/AuthUserArgumentResolverTest.java new file mode 100644 index 000000000..9c059b46d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/auth/AuthUserArgumentResolverTest.java @@ -0,0 +1,111 @@ +package com.loopers.interfaces.auth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +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.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.MethodParameter; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.context.request.ServletWebRequest; + +@DisplayName("AuthUserArgumentResolver 단위 테스트") +@ExtendWith(MockitoExtension.class) +class AuthUserArgumentResolverTest { + + private AuthUserArgumentResolver resolver; + + @BeforeEach + void setUp() { + resolver = new AuthUserArgumentResolver(); + } + + @DisplayName("supportsParameter 메서드는") + @Nested + class SupportsParameter { + + @DisplayName("@Auth 어노테이션과 AuthUser 타입이면 true를 반환한다") + @Test + void returnsTrue_whenAuthAnnotationAndAuthUserType() throws Exception { + // arrange + MethodParameter parameter = new MethodParameter( + TestController.class.getMethod("testMethod", AuthUser.class), 0 + ); + + // act & assert + assertThat(resolver.supportsParameter(parameter)).isTrue(); + } + + @DisplayName("@Auth 어노테이션이 없으면 false를 반환한다") + @Test + void returnsFalse_whenNoAuthAnnotation() throws Exception { + // arrange + MethodParameter parameter = new MethodParameter( + TestController.class.getMethod("noAnnotationMethod", AuthUser.class), 0 + ); + + // act & assert + assertThat(resolver.supportsParameter(parameter)).isFalse(); + } + } + + @DisplayName("resolveArgument 메서드는") + @Nested + class ResolveArgument { + + @DisplayName("request attribute에 AuthUser가 있으면 반환한다") + @Test + void returnsAuthUser_whenAttributeExists() throws Exception { + // arrange + MockHttpServletRequest request = new MockHttpServletRequest(); + AuthUser expectedAuthUser = new AuthUser(1L, "testuser1", "홍길동"); + request.setAttribute("authUser", expectedAuthUser); + + NativeWebRequest webRequest = new ServletWebRequest(request); + MethodParameter parameter = new MethodParameter( + TestController.class.getMethod("testMethod", AuthUser.class), 0 + ); + + // act + Object result = resolver.resolveArgument(parameter, null, webRequest, null); + + // assert + assertThat(result).isInstanceOf(AuthUser.class); + AuthUser authUser = (AuthUser) result; + assertThat(authUser.id()).isEqualTo(1L); + assertThat(authUser.loginId()).isEqualTo("testuser1"); + assertThat(authUser.name()).isEqualTo("홍길동"); + } + + @DisplayName("request attribute에 AuthUser가 없으면 CoreException(UNAUTHORIZED)을 던진다") + @Test + void throwsUnauthorized_whenAttributeNotExists() throws Exception { + // arrange + MockHttpServletRequest request = new MockHttpServletRequest(); + NativeWebRequest webRequest = new ServletWebRequest(request); + MethodParameter parameter = new MethodParameter( + TestController.class.getMethod("testMethod", AuthUser.class), 0 + ); + + // act & assert + assertThatThrownBy(() -> resolver.resolveArgument(parameter, null, webRequest, null)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorType.UNAUTHORIZED); + } + } + + // 테스트용 컨트롤러 + static class TestController { + public void testMethod(@Auth AuthUser authUser) {} + public void noAnnotationMethod(AuthUser authUser) {} + } +} From 1395ed8715dc87991953afac44cc15a6028864a5 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Wed, 18 Feb 2026 22:51:07 +0900 Subject: [PATCH 021/108] =?UTF-8?q?refactor:=20@Auth/AuthUser=EB=A5=BC=20@?= =?UTF-8?q?Login/LoginUser=EB=A1=9C=20=EB=84=A4=EC=9D=B4=EB=B0=8D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../loopers/interfaces/auth/AuthFilter.java | 4 +- .../com/loopers/interfaces/auth/AuthUser.java | 4 -- .../interfaces/auth/{Auth.java => Login.java} | 2 +- .../loopers/interfaces/auth/LoginUser.java | 4 ++ ...er.java => LoginUserArgumentResolver.java} | 12 ++--- .../interfaces/user/UserV1ApiSpec.java | 6 +-- .../interfaces/user/UserV1Controller.java | 12 ++--- .../loopers/support/config/WebMvcConfig.java | 6 +-- .../interfaces/auth/AuthFilterTest.java | 12 ++--- ...ava => LoginUserArgumentResolverTest.java} | 50 +++++++++---------- 10 files changed, 55 insertions(+), 57 deletions(-) delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthUser.java rename apps/commerce-api/src/main/java/com/loopers/interfaces/auth/{Auth.java => Login.java} (91%) create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginUser.java rename apps/commerce-api/src/main/java/com/loopers/interfaces/auth/{AuthUserArgumentResolver.java => LoginUserArgumentResolver.java} (76%) rename apps/commerce-api/src/test/java/com/loopers/interfaces/auth/{AuthUserArgumentResolverTest.java => LoginUserArgumentResolverTest.java} (61%) diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthFilter.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthFilter.java index 7c50fffad..573b95f1c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthFilter.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthFilter.java @@ -51,12 +51,12 @@ protected void doFilterInternal(HttpServletRequest request, try { UserModel authenticatedUser = authenticationService.authenticate(loginId, password); - AuthUser authUser = new AuthUser( + LoginUser loginUser = new LoginUser( authenticatedUser.getId(), authenticatedUser.getLoginId().getValue(), authenticatedUser.getName().getValue() ); - request.setAttribute("authUser", authUser); + request.setAttribute("loginUser", loginUser); filterChain.doFilter(request, response); } catch (CoreException e) { writeUnauthorizedResponse(response, diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthUser.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthUser.java deleted file mode 100644 index 6626ff68c..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthUser.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.loopers.interfaces.auth; - -public record AuthUser(Long id, String loginId, String name) { -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/Auth.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/Login.java similarity index 91% rename from apps/commerce-api/src/main/java/com/loopers/interfaces/auth/Auth.java rename to apps/commerce-api/src/main/java/com/loopers/interfaces/auth/Login.java index d2e85de22..59f671daf 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/Auth.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/Login.java @@ -7,5 +7,5 @@ @Target(ElementType.PARAMETER) @Retention(RetentionPolicy.RUNTIME) -public @interface Auth { +public @interface Login { } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginUser.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginUser.java new file mode 100644 index 000000000..02daa62ed --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginUser.java @@ -0,0 +1,4 @@ +package com.loopers.interfaces.auth; + +public record LoginUser(Long id, String loginId, String name) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthUserArgumentResolver.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginUserArgumentResolver.java similarity index 76% rename from apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthUserArgumentResolver.java rename to apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginUserArgumentResolver.java index 4f165c50e..e3fd3afef 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthUserArgumentResolver.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginUserArgumentResolver.java @@ -13,12 +13,12 @@ @Component @RequiredArgsConstructor -public class AuthUserArgumentResolver implements HandlerMethodArgumentResolver { +public class LoginUserArgumentResolver implements HandlerMethodArgumentResolver { @Override public boolean supportsParameter(MethodParameter parameter) { - return parameter.hasParameterAnnotation(Auth.class) - && AuthUser.class.isAssignableFrom(parameter.getParameterType()); + return parameter.hasParameterAnnotation(Login.class) + && LoginUser.class.isAssignableFrom(parameter.getParameterType()); } @Override @@ -27,12 +27,12 @@ public Object resolveArgument(MethodParameter parameter, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); - AuthUser authUser = (AuthUser) request.getAttribute("authUser"); + LoginUser loginUser = (LoginUser) request.getAttribute("loginUser"); - if (authUser == null) { + if (loginUser == null) { throw new CoreException(ErrorType.UNAUTHORIZED); } - return authUser; + return loginUser; } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/user/UserV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/user/UserV1ApiSpec.java index 5b907af56..9b1ff4b1e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/user/UserV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/user/UserV1ApiSpec.java @@ -1,7 +1,7 @@ package com.loopers.interfaces.user; import com.loopers.interfaces.api.ApiResponse; -import com.loopers.interfaces.auth.AuthUser; +import com.loopers.interfaces.auth.LoginUser; import com.loopers.interfaces.user.dto.UserV1Dto; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -26,7 +26,7 @@ ApiResponse signup( ) ApiResponse getMyInfo( @Parameter(hidden = true) - AuthUser authUser + LoginUser loginUser ); @Operation( @@ -35,7 +35,7 @@ ApiResponse getMyInfo( ) ApiResponse changePassword( @Parameter(hidden = true) - AuthUser authUser, + LoginUser loginUser, @RequestBody(description = "비밀번호 변경 요청 정보") UserV1Dto.ChangePasswordRequest request ); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/user/UserV1Controller.java index 0b8bdba2c..b5b96386a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/user/UserV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/user/UserV1Controller.java @@ -3,8 +3,8 @@ import com.loopers.application.user.UserFacade; import com.loopers.application.user.dto.UserInfo; import com.loopers.interfaces.api.ApiResponse; -import com.loopers.interfaces.auth.Auth; -import com.loopers.interfaces.auth.AuthUser; +import com.loopers.interfaces.auth.Login; +import com.loopers.interfaces.auth.LoginUser; import com.loopers.interfaces.user.dto.UserV1Dto; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -29,8 +29,8 @@ public ApiResponse signup( @GetMapping("/me") @Override - public ApiResponse getMyInfo(@Auth AuthUser authUser) { - UserInfo userInfo = userFacade.getMyInfo(authUser.loginId()); + public ApiResponse getMyInfo(@Login LoginUser loginUser) { + UserInfo userInfo = userFacade.getMyInfo(loginUser.loginId()); return ApiResponse.success(UserV1Dto.MyInfoResponse.from(userInfo)); } @@ -38,10 +38,10 @@ public ApiResponse getMyInfo(@Auth AuthUser authUser) @PatchMapping("/password") @Override public ApiResponse changePassword( - @Auth AuthUser authUser, + @Login LoginUser loginUser, @Valid @RequestBody UserV1Dto.ChangePasswordRequest request ) { - userFacade.changePassword(authUser.loginId(), request.toCommand()); + userFacade.changePassword(loginUser.loginId(), request.toCommand()); return ApiResponse.success(UserV1Dto.ChangePasswordResponse.success()); } diff --git a/apps/commerce-api/src/main/java/com/loopers/support/config/WebMvcConfig.java b/apps/commerce-api/src/main/java/com/loopers/support/config/WebMvcConfig.java index 424ebbe90..a53eb62f0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/config/WebMvcConfig.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/config/WebMvcConfig.java @@ -1,6 +1,6 @@ package com.loopers.support.config; -import com.loopers.interfaces.auth.AuthUserArgumentResolver; +import com.loopers.interfaces.auth.LoginUserArgumentResolver; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; @@ -11,10 +11,10 @@ @RequiredArgsConstructor public class WebMvcConfig implements WebMvcConfigurer { - private final AuthUserArgumentResolver authUserArgumentResolver; + private final LoginUserArgumentResolver loginUserArgumentResolver; @Override public void addArgumentResolvers(List resolvers) { - resolvers.add(authUserArgumentResolver); + resolvers.add(loginUserArgumentResolver); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/auth/AuthFilterTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/auth/AuthFilterTest.java index 383428ff3..2cde63666 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/auth/AuthFilterTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/auth/AuthFilterTest.java @@ -46,9 +46,9 @@ void setUp() { authFilter = new AuthFilter(authenticationService, objectMapper); } - @DisplayName("인증 필요 URL에 유효한 헤더가 있으면, AuthUser attribute를 설정하고 filterChain을 진행한다") + @DisplayName("인증 필요 URL에 유효한 헤더가 있으면, LoginUser attribute를 설정하고 filterChain을 진행한다") @Test - void setsAuthUserAttribute_whenValidHeadersOnAuthRequiredUrl() throws Exception { + void setsLoginUserAttribute_whenValidHeadersOnAuthRequiredUrl() throws Exception { // arrange MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/users/me"); request.addHeader("X-Loopers-LoginId", "testuser1"); @@ -66,10 +66,10 @@ void setsAuthUserAttribute_whenValidHeadersOnAuthRequiredUrl() throws Exception authFilter.doFilterInternal(request, response, filterChain); // assert - AuthUser authUser = (AuthUser) request.getAttribute("authUser"); - assertThat(authUser).isNotNull(); - assertThat(authUser.loginId()).isEqualTo("testuser1"); - assertThat(authUser.name()).isEqualTo("홍길동"); + LoginUser loginUser = (LoginUser) request.getAttribute("loginUser"); + assertThat(loginUser).isNotNull(); + assertThat(loginUser.loginId()).isEqualTo("testuser1"); + assertThat(loginUser.name()).isEqualTo("홍길동"); verify(filterChain).doFilter(request, response); } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/auth/AuthUserArgumentResolverTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/auth/LoginUserArgumentResolverTest.java similarity index 61% rename from apps/commerce-api/src/test/java/com/loopers/interfaces/auth/AuthUserArgumentResolverTest.java rename to apps/commerce-api/src/test/java/com/loopers/interfaces/auth/LoginUserArgumentResolverTest.java index 9c059b46d..1a66914a8 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/auth/AuthUserArgumentResolverTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/auth/LoginUserArgumentResolverTest.java @@ -2,7 +2,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.when; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; @@ -11,46 +10,45 @@ import org.junit.jupiter.api.Nested; 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.core.MethodParameter; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.web.context.request.NativeWebRequest; import org.springframework.web.context.request.ServletWebRequest; -@DisplayName("AuthUserArgumentResolver 단위 테스트") +@DisplayName("LoginUserArgumentResolver 단위 테스트") @ExtendWith(MockitoExtension.class) -class AuthUserArgumentResolverTest { +class LoginUserArgumentResolverTest { - private AuthUserArgumentResolver resolver; + private LoginUserArgumentResolver resolver; @BeforeEach void setUp() { - resolver = new AuthUserArgumentResolver(); + resolver = new LoginUserArgumentResolver(); } @DisplayName("supportsParameter 메서드는") @Nested class SupportsParameter { - @DisplayName("@Auth 어노테이션과 AuthUser 타입이면 true를 반환한다") + @DisplayName("@Login 어노테이션과 LoginUser 타입이면 true를 반환한다") @Test - void returnsTrue_whenAuthAnnotationAndAuthUserType() throws Exception { + void returnsTrue_whenLoginAnnotationAndLoginUserType() throws Exception { // arrange MethodParameter parameter = new MethodParameter( - TestController.class.getMethod("testMethod", AuthUser.class), 0 + TestController.class.getMethod("testMethod", LoginUser.class), 0 ); // act & assert assertThat(resolver.supportsParameter(parameter)).isTrue(); } - @DisplayName("@Auth 어노테이션이 없으면 false를 반환한다") + @DisplayName("@Login 어노테이션이 없으면 false를 반환한다") @Test - void returnsFalse_whenNoAuthAnnotation() throws Exception { + void returnsFalse_whenNoLoginAnnotation() throws Exception { // arrange MethodParameter parameter = new MethodParameter( - TestController.class.getMethod("noAnnotationMethod", AuthUser.class), 0 + TestController.class.getMethod("noAnnotationMethod", LoginUser.class), 0 ); // act & assert @@ -62,38 +60,38 @@ void returnsFalse_whenNoAuthAnnotation() throws Exception { @Nested class ResolveArgument { - @DisplayName("request attribute에 AuthUser가 있으면 반환한다") + @DisplayName("request attribute에 LoginUser가 있으면 반환한다") @Test - void returnsAuthUser_whenAttributeExists() throws Exception { + void returnsLoginUser_whenAttributeExists() throws Exception { // arrange MockHttpServletRequest request = new MockHttpServletRequest(); - AuthUser expectedAuthUser = new AuthUser(1L, "testuser1", "홍길동"); - request.setAttribute("authUser", expectedAuthUser); + LoginUser expectedLoginUser = new LoginUser(1L, "testuser1", "홍길동"); + request.setAttribute("loginUser", expectedLoginUser); NativeWebRequest webRequest = new ServletWebRequest(request); MethodParameter parameter = new MethodParameter( - TestController.class.getMethod("testMethod", AuthUser.class), 0 + TestController.class.getMethod("testMethod", LoginUser.class), 0 ); // act Object result = resolver.resolveArgument(parameter, null, webRequest, null); // assert - assertThat(result).isInstanceOf(AuthUser.class); - AuthUser authUser = (AuthUser) result; - assertThat(authUser.id()).isEqualTo(1L); - assertThat(authUser.loginId()).isEqualTo("testuser1"); - assertThat(authUser.name()).isEqualTo("홍길동"); + assertThat(result).isInstanceOf(LoginUser.class); + LoginUser loginUser = (LoginUser) result; + assertThat(loginUser.id()).isEqualTo(1L); + assertThat(loginUser.loginId()).isEqualTo("testuser1"); + assertThat(loginUser.name()).isEqualTo("홍길동"); } - @DisplayName("request attribute에 AuthUser가 없으면 CoreException(UNAUTHORIZED)을 던진다") + @DisplayName("request attribute에 LoginUser가 없으면 CoreException(UNAUTHORIZED)을 던진다") @Test void throwsUnauthorized_whenAttributeNotExists() throws Exception { // arrange MockHttpServletRequest request = new MockHttpServletRequest(); NativeWebRequest webRequest = new ServletWebRequest(request); MethodParameter parameter = new MethodParameter( - TestController.class.getMethod("testMethod", AuthUser.class), 0 + TestController.class.getMethod("testMethod", LoginUser.class), 0 ); // act & assert @@ -105,7 +103,7 @@ void throwsUnauthorized_whenAttributeNotExists() throws Exception { // 테스트용 컨트롤러 static class TestController { - public void testMethod(@Auth AuthUser authUser) {} - public void noAnnotationMethod(AuthUser authUser) {} + public void testMethod(@Login LoginUser loginUser) {} + public void noAnnotationMethod(LoginUser loginUser) {} } } From 09a95227d2bb385fd7587df261ff907f884734d6 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Thu, 19 Feb 2026 15:27:20 +0900 Subject: [PATCH 022/108] =?UTF-8?q?docs:=20=EC=98=88=EC=99=B8=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EC=BB=A8=EB=B2=A4=EC=85=98=20Skill=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../references/common/exception-convention.md | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/.claude/skills/project-convention/references/common/exception-convention.md b/.claude/skills/project-convention/references/common/exception-convention.md index 346e36856..cdaf3f113 100644 --- a/.claude/skills/project-convention/references/common/exception-convention.md +++ b/.claude/skills/project-convention/references/common/exception-convention.md @@ -222,11 +222,38 @@ public record ApiResponse(Metadata meta, T data) { - `HttpMessageNotReadableException`은 `InvalidFormatException`, `MismatchedInputException`, `JsonMappingException`까지 세분화 - `Throwable` 최후 방어 핸들러 필수 - 예외는 발생 지점에서 그대로 전파, Application에서 잡아서 변환하지 않는다 +- **`@ExceptionHandler`는 응답 생성 책임만 가진다** — DB 저장 등 부가 작업을 핸들러 내부에서 수행하면, 부가 작업 예외 시 핸들러가 삼켜져(swallowed) 디버깅이 극히 어려워진다. 부가 작업이 필요하면 `ApplicationEventPublisher` + `@Async @EventListener`로 분리한다. + +### ⚠️ `@ExceptionHandler`의 처리 경계 + +`@ExceptionHandler`는 `DispatcherServlet` **내부에서만** 동작한다. Filter에서 발생한 예외는 잡을 수 없다. + +``` +WAS → Filter → DispatcherServlet → Interceptor → Controller + ↑ ↑ + @ExceptionHandler 못 잡음 @ExceptionHandler 잡음 +``` + +Filter 예외 처리 시 `HttpServletResponse`에 직접 JSON 응답을 작성해야 하며, 이때 `ApiResponse` 형식을 맞추기 위해 `ObjectMapper`를 사용한다. + +```java +// Filter 내 예외 처리 패턴 +catch (AuthenticationException e) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json;charset=UTF-8"); + + ApiResponse errorResponse = ApiResponse.fail( + ErrorType.UNAUTHORIZED.getCode(), e.getMessage()); + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); +} +``` --- ## 5. 예외 흐름 +### DispatcherServlet 내부 (일반 흐름) + ``` Domain에서 throw new CoreException(OrderErrorCode.STOCK_INSUFFICIENT) → Application 통과 (잡지 않음) @@ -242,6 +269,15 @@ Domain에서 throw new CoreException(OrderErrorCode.STOCK_INSUFFICIENT) → ApiResponse.failValidation(code, message, fieldErrors) 반환 ``` +### Filter 예외 (DispatcherServlet 외부) + +``` +Filter에서 인증 실패 등 예외 발생 + → @RestControllerAdvice 도달 불가 (DispatcherServlet 바깥) + → Filter 내부에서 HttpServletResponse에 직접 JSON 작성 + → ApiResponse.fail() 형식으로 응답 (ObjectMapper 사용) +``` + --- ## 6. 패키지 배치 @@ -286,6 +322,10 @@ interfaces/ **예외 흐름** - [ ] Domain 예외를 Application에서 잡아서 변환하고 있지 않은가? (그대로 전파) - [ ] ControllerAdvice에 `Throwable` 최후 방어 핸들러가 있는가? +- [ ] Filter 예외 처리 시 `ApiResponse` 형식으로 직접 JSON을 작성하고 있는가? + +**ControllerAdvice 설계** +- [ ] `@ExceptionHandler`가 응답 생성 외 부가 작업(DB 저장 등)을 직접 수행하고 있지 않은가? **API 응답** - [ ] 성공 응답이 `ApiResponse.success()` 또는 `ApiResponse.success(data)`를 사용하는가? From d7ad2c1db044943d7bcbfd8c4984f415729d873f Mon Sep 17 00:00:00 2001 From: plan11plan Date: Thu, 19 Feb 2026 15:27:35 +0900 Subject: [PATCH 023/108] =?UTF-8?q?docs:=20=EB=94=A5=EB=8B=A4=EC=9D=B4?= =?UTF-8?q?=EB=B8=8C=20=EC=9D=B8=ED=84=B0=EB=B7=B0=20Skill=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Deep Dive Interview Learning Skill.md | 343 ++++++++++++++++++ 1 file changed, 343 insertions(+) create mode 100644 .claude/skills/deep-dive/Deep Dive Interview Learning Skill.md diff --git a/.claude/skills/deep-dive/Deep Dive Interview Learning Skill.md b/.claude/skills/deep-dive/Deep Dive Interview Learning Skill.md new file mode 100644 index 000000000..a9fd0e214 --- /dev/null +++ b/.claude/skills/deep-dive/Deep Dive Interview Learning Skill.md @@ -0,0 +1,343 @@ +# Deep Dive Interview Learning Skill + +## 목적 + +경력 기술 면접 스타일의 **DFS(Depth-First Search) 깊이 파기 학습**을 체계적으로 진행한다. +교과서적 정의 암기가 아닌, **장애 시나리오 기반 문제 해결력**과 **Low-Level 내부 동작 원리 추론 능력**을 기른다. + +--- + +## 핵심 원칙 + +1. **시나리오 먼저, 개념은 나중에**: "X란 무엇인가?"가 아니라 "X를 쓸 때 이런 장애가 발생했다. 왜?"로 시작한다. +2. **DFS 꼬리질문**: 답변의 끝이 아니라 다음 질문의 시작이다. 코드 레벨까지 도달할 때까지 파고든다. +3. **포기 금지 훈련**: 모르는 영역에 도달해도 아는 지식을 조합해 논리적 추론을 시도한다. +4. **실전 압박 시뮬레이션**: 면접관처럼 엄격하게, 동료처럼 힌트를 주며 진행한다. + +--- + +## 세션 진행 구조 + +### Phase 1: 주제 선정 및 시나리오 설계 + +사용자가 학습할 기술 주제를 제시하면, 다음 기준으로 **장애 시나리오**를 설계한다: + +- 사용자의 프로젝트 컨텍스트(Spring Boot, Java 21, 이커머스 플랫폼 등)에 맞춘 현실적 시나리오 +- 단순 개념 질문이 아닌, **"운영 중 이런 문제가 발생했습니다"** 형태 +- 원인 추론을 위해 **최소 5 Depth 이상** 파고들 수 있는 주제 + +**시나리오 유형:** +| 유형 | 예시 | +|------|------| +| 장애 발생 | "배포 후 간헐적 502 발생, 원인은?" | +| 성능 저하 | "특정 API 응답이 갑자기 10배 느려졌다" | +| 데이터 정합성 | "같은 상품에 동시 주문 시 재고가 음수가 됐다" | +| 설계 트레이드오프 | "이 구조에서 트래픽이 10배 늘면 어디가 먼저 터지나?" | +| 동작 원리 추론 | "이 코드가 멀티스레드 환경에서 왜 안전한지/위험한지" | + +### Phase 2: DFS 질문 트리 진행 + +``` +[시나리오 제시] (Depth 0) + └─ "왜 이런 일이 발생했을까요?" (Depth 1) + └─ "그 내부 동작은 구체적으로 어떻게 되나요?" (Depth 2) + └─ "그럼 코드 레벨에서 어떤 클래스/메서드가 관여하나요?" (Depth 3) + └─ "그 클래스의 내부 구현은 어떻게 되어 있나요?" (Depth 4) + └─ "이 구현 방식의 한계점이나 edge case는?" (Depth 5) + └─ "그럼 이걸 어떻게 해결/개선하시겠어요?" (Depth 6) +``` + +**각 Depth에서의 기대 수준:** + +| Depth | 수준 | 기대하는 답변 | +|-------|------|--------------| +| 0-1 | 표면 | 현상 인식, 가능한 원인 나열 | +| 2-3 | 프레임워크 | Spring/Nginx 등 프레임워크 레벨의 동작 원리 | +| 4-5 | 코드 레벨 | 실제 클래스명, 메서드명, 내부 자료구조 | +| 6+ | 설계/개선 | 한계 인식, 대안 제시, 트레이드오프 분석 | + +### Phase 2.5: 분기 전환 판단 + +DFS 진행 중 새로운 분기가 출발 시나리오에서 **너무 멀어지면** 별도 시나리오로 분리한다. + +**분리 기준:** +- 출발 시나리오의 답변으로는 자연스럽게 도달하지 않는 주제 +- "이 질문이 왜 나왔지?"라고 독자가 느낄 수 있는 주제 전환 +- 완전히 다른 장애 상황을 전제로 해야 이해되는 내용 + +**분리 방법:** +1. 현재 분기를 마무리한다 +2. "좋아, 여기서 새로운 시나리오로 넘어갈게" 라고 전환을 명시한다 +3. 새 시나리오를 제시하고 DFS를 다시 시작한다 + +**분기 간 연결고리:** +각 분기 시작 시 이전 분기와의 연결을 한 줄로 명시한다: +``` +> 💬 분기 A에서 "DispatcherServlet 안에서는 ExceptionResolver가 잡는다"는 걸 알았다. +> 그러면 ExceptionResolver 내부에서는 어떤 기준으로 핸들러를 선택할까? +``` + +이렇게 하면 꼬리물기가 **진짜 꼬리물기**로 읽힌다. + +### Phase 3: 학습자 응답 평가 및 피드백 + +사용자의 각 답변에 대해 다음을 수행한다: + +**답변이 정확한 경우:** +- 간결하게 확인 후 즉시 다음 Depth 질문으로 전진 +- "맞습니다" 한 줄이면 충분, 장황한 칭찬 금지 + +**답변이 부분적으로 맞는 경우:** +- 맞는 부분 인정 + 빠뜨린 핵심 포인트 힌트 제공 +- "거기까지는 맞는데, 한 가지 더 생각해보세요. [힌트]" + +**답변을 모르는 경우:** +- 바로 답을 알려주지 않는다 +- 아는 지식에서 추론할 수 있는 힌트를 단계적으로 제공 +- 3회 힌트 후에도 진전이 없으면 핵심 개념을 설명하고 다음으로 진행 + +**답변이 틀린 경우:** +- 왜 틀렸는지 명확히 짚어준다 +- 올바른 멘탈 모델을 제시하고, 틀린 이유를 이해했는지 확인 질문 + +### Phase 4: 세션 마무리 및 정리 + +하나의 시나리오가 끝나면 다음을 제공한다: + +1. **DFS 경로 요약**: 이번 세션에서 탐색한 전체 질문-답변 트리를 시각화 +2. **도달 Depth 평가**: 각 분기에서 어디까지 스스로 도달했는지 +3. **취약 지점 식별**: 막혔던 Depth와 그 원인 (개념 부족? 코드 레벨 미숙? 추론 연결 실패?) +4. **복습 키워드**: 추가 학습이 필요한 클래스/개념/문서 목록 +5. **면접 답변 모범 스크립트**: 이 주제가 실제 면접에서 나왔을 때의 이상적인 답변 흐름 + +### Phase 5: 블로그 글 + 이력서 한 줄 생성 + +사용자가 블로그 글 작성을 요청하면, 세션 내용을 기반으로 다음을 생성한다. + +**블로그 글 구조:** + +```markdown +# Deep Dive 학습 세션: [주제명] + +> 학습 방식: LINE 면접 스타일 DFS 깊이 파기 +> 시리즈: [시리즈명] ①②③... +> 기반 자료: [학습 자료명] + +## 📍 DFS 탐색 경로 +[전체 질문 트리 시각화] + +## 🔁 꼬리물기 Q&A 흐름 +### 장애 시나리오 +[시나리오 설명] + +[분기별 Q&A — 연결고리 포함] + +## 📋 핵심 정리표 +## 🎤 면접에서 이렇게 답하자 +## 📝 이력서 한 줄 (해당 시 — 이력서 적합성 판단 포함) +## 📚 추가 학습 키워드 +``` + +**블로그 품질 기준:** + +1. **"흔한 오해" 리프레이밍**: 사용자의 틀린 답변은 개인 기록이 아니라 **"많은 개발자가 착각하는 포인트"**로 변환한다 + ``` + // ❌ 개인 기록 (블로그에 부적합) + 내 답변: 메서드 순서대로 ❌ + + // ✅ 독자에게 가치 있는 콘텐츠 + ❌ 흔한 오해: "메서드 선언 순서대로 우선순위가 정해진다" + ✅ 실제: 예외 상속 계층에서 더 구체적인 자식 타입이 우선 + ``` + +2. **분기 연결고리**: 각 분기 시작에 `> 💬 앞 분기에서 ...를 알았다. 그러면 ...?` 추가 +3. **"잘 모르겠어" 답변**: 그냥 삭제하고 바로 힌트 → 정답 흐름으로 + +**시나리오 분리 판단:** + +하나의 시나리오에서 분기가 3개 이상 벌어지면, 출발 시나리오와의 관련성을 검토하고 **별도 시나리오(별도 블로그 글)**로 분리한다. + +| 관련성 | 행동 | +|--------|------| +| 출발 시나리오의 직접적 답변/파생 | 같은 글에 포함 | +| 배운 개념에서 자연스럽게 넘어감 | 같은 글, 별도 분기 | +| 완전히 다른 장애 전제 필요 | **별도 글로 분리** | + +**이력서 적합성 판단:** + +모든 시나리오가 이력서에 들어갈 수 있는 건 아니다. 다음 기준으로 판단한다: + +| 기준 | 이력서 가능 | 면접 대비 전용 | +|------|-----------|--------------| +| 실제 프로젝트에서 자연스럽게 겪는 문제 | ✅ | | +| 내부 동작 원리 깊이 파기 | | ✅ | +| "일부러 잘못 만들었다가 고친 것"처럼 보이는 경험 | | ✅ | +| 설계 판단 + 트레이드오프 분석 | ✅ | | + +이력서 적합한 시나리오에는 `## 📝 이력서 한 줄` 섹션을 추가하고, 면접 전용 시나리오에는 추가하지 않는다. + +--- + +## 질문 설계 가이드라인 + +### 금지 패턴 (LINE 면접에서 나오지 않는 유형) +- "~의 정의를 말해보세요" (백과사전식) +- "~의 장단점을 비교해보세요" (교과서식) +- "~를 사용해본 경험이 있나요?" (경험 확인식) + +### 권장 패턴 (LINE 면접 스타일) +- "운영 중인 서비스에서 [구체적 증상]이 발생했습니다. 원인을 추론해보세요." +- "[기술 A]를 사용 중인데, [특수 조건]에서 [예상치 못한 동작]이 발생합니다. 왜 그럴까요?" +- "이 코드를 보세요. [동시성/성능/장애] 관점에서 어떤 문제가 있을까요?" +- "현재 아키텍처에서 트래픽이 N배 증가하면 가장 먼저 어디서 문제가 생길까요?" + +### 꼬리질문 트리거 키워드 + +사용자의 답변에서 다음 키워드가 나오면 반드시 더 깊이 파고든다: + +| 사용자 답변 키워드 | 꼬리질문 방향 | +|-------------------|--------------| +| "~가 관리합니다" | "구체적으로 어떤 자료구조로? 어떤 시점에?" | +| "ThreadLocal을 사용해서" | "ThreadLocal의 내부 구현은? 스레드 풀에서의 주의점은?" | +| "트랜잭션이 보장됩니다" | "어떤 격리 수준에서? MVCC? 락?" | +| "프록시가 처리합니다" | "어떤 종류의 프록시? JDK Dynamic? CGLIB? 차이는?" | +| "커넥션 풀에서 가져옵니다" | "풀이 고갈되면? 대기 전략은? 타임아웃은?" | +| "캐시로 해결합니다" | "캐시 무효화 전략은? 정합성은? stampede?" | +| "비동기로 처리합니다" | "스레드 모델은? 에러 처리는? 순서 보장은?" | +| "로드밸런서가 분산합니다" | "헬스체크 주기는? 장애 서버 감지 시간은?" | + +--- + +## 주제별 DFS 템플릿 + +### Spring Transaction 계열 + +``` +시나리오: @Transactional 메서드 내에서 특정 DAO 호출만 별도 트랜잭션으로 실행되는 버그 +├─ D1: 왜 같은 트랜잭션이 아닌가? +│ ├─ D2: Spring의 트랜잭션 경계는 어떻게 결정되는가? +│ │ ├─ D3: AOP 프록시 → TransactionInterceptor 동작 +│ │ │ ├─ D4: PlatformTransactionManager → DataSourceTransactionManager +│ │ │ │ ├─ D5: TransactionSynchronizationManager (ThreadLocal 기반) +│ │ │ │ │ └─ D6: DataSourceUtils.getConnection() 내부 동작 +│ │ │ │ └─ D5: 전파 속성(REQUIRED, REQUIRES_NEW 등)의 실제 커넥션 관리 +│ │ └─ D3: Self-invocation 문제 (프록시 우회) +│ └─ D2: @Async와 결합 시 스레드 분리 문제 +└─ D1: 이런 버그를 사전에 방지하려면? +``` + +### 무중단 배포 / Nginx 계열 + +``` +시나리오: Blue-Green 배포 중 간헐적 502 Bad Gateway 발생 +├─ D1: 502의 의미와 발생 조건 +│ ├─ D2: Nginx → upstream 커넥션 관리 +│ │ ├─ D3: Keep-Alive 커넥션의 재사용과 race condition +│ │ │ ├─ D4: HTTP/1.1 Half-Closed Connection 동작 +│ │ │ │ └─ D5: TCP FIN/RST 시퀀스와 타이밍 +│ │ │ └─ D4: proxy_next_upstream 설정의 역할 +│ │ └─ D3: upstream health check 메커니즘 +│ └─ D2: Graceful Shutdown 과정 +│ ├─ D3: Nginx Master/Worker Process 시그널 처리 +│ │ └─ D4: SIGQUIT vs SIGTERM 차이와 worker_shutdown_timeout +│ └─ D3: Spring Boot Graceful Shutdown (server.shutdown=graceful) +│ └─ D4: 진행 중 요청 완료 대기 메커니즘 +└─ D1: 완벽한 무중단 배포를 위한 전체 전략 +``` + +### 동시성 / 데이터 정합성 계열 + +``` +시나리오: 동시에 같은 상품 주문 시 재고가 음수로 떨어짐 +├─ D1: 왜 재고 검증 로직이 동시성 환경에서 실패하는가? +│ ├─ D2: Check-then-Act 패턴의 race condition +│ │ ├─ D3: DB 격리 수준별 동작 차이 +│ │ │ ├─ D4: MySQL InnoDB의 MVCC 구현 +│ │ │ │ └─ D5: Undo Log, Read View, 스냅샷 격리 +│ │ │ └─ D4: SELECT ... FOR UPDATE vs Optimistic Lock +│ │ └─ D3: 애플리케이션 레벨 락 vs DB 레벨 락 +│ └─ D2: 분산 환경에서의 동시성 제어 +│ ├─ D3: Redis 분산 락 (Redisson) +│ │ ├─ D4: RedLock 알고리즘과 한계 +│ │ └─ D4: Lock 획득 실패 시 재시도 전략 +│ └─ D3: Kafka를 활용한 순서 보장 처리 +└─ D1: 각 해결책의 트레이드오프 비교 +``` + +--- + +## 응답 톤 및 스타일 + +### 면접관 모드 (질문 시) +- 간결하고 명확한 시나리오 제시 +- 불필요한 힌트 없이 사용자가 스스로 추론하게 유도 +- "좋습니다. 그럼 한 단계 더 들어가볼게요." 스타일 + +### 멘토 모드 (피드백 시) +- 틀린 부분은 명확히, 맞는 부분은 간결히 +- 코드 레벨 설명 시 실제 Spring/JDK 소스 코드 기반으로 +- 핵심 클래스명, 메서드명을 정확히 제시 + +### 절대 하지 않는 것 +- 질문 전에 답을 미리 설명하는 것 +- "이건 어려운 질문인데요~" 같은 불필요한 전치사 +- 사용자가 답변을 시도하기 전에 힌트를 주는 것 +- 한 번에 여러 질문을 동시에 던지는 것 (반드시 1개씩) + +--- + +## 세션 시작 프로토콜 + +사용자가 학습 세션을 시작하면: + +1. **주제 확인**: "어떤 기술/영역을 깊이 파볼까요?" +2. **현재 이해도 파악**: "이 주제에 대해 현재 어느 정도 알고 계신가요?" (Depth 0-1 수준인지, 2-3까지는 아는지) +3. **시나리오 제시**: 사용자의 수준보다 1-2 Depth 위의 시나리오를 설계하여 제시 +4. **DFS 시작**: 첫 질문 투하 + +--- + +## 세션 종료 프로토콜 + +시나리오 탐색이 완료되면: + +```markdown +## 📊 세션 리포트 + +### DFS 탐색 경로 +[시각화된 질문 트리 + 각 Depth에서의 답변 정확도] + +### 도달 Depth +- 분기 A: Depth 4 (코드 레벨) ✅ +- 분기 B: Depth 2 (프레임워크 레벨) ⚠️ → 추가 학습 필요 + +### 취약 지점 +- [구체적 개념/클래스] 부분에서 막힘 +- 원인: [개념 미숙 / 코드 레벨 미확인 / 연결 추론 실패] + +### 추가 학습 키워드 +- [클래스명, 공식문서 링크, 관련 소스코드 위치] + +### 면접 답변 모범 스크립트 +[이 주제가 실제 면접에서 나왔을 때의 이상적인 답변 흐름 - 1분 버전] +``` + +사용자가 블로그 글 작성을 요청하면 **Phase 5**로 진행한다. + +--- + +## 사용 예시 + +**사용자**: "Spring Transaction 주제로 깊이 파기 학습하고 싶어" + +**Claude**: +"좋습니다. Spring Transaction에 대해 현재 어느 정도까지 알고 계신가요? + +(1) @Transactional 사용법 정도 +(2) 전파 속성, 격리 수준까지 +(3) 내부 AOP 프록시 동작까지 +(4) TransactionSynchronizationManager 코드 레벨까지 + +현재 수준을 알려주시면 적절한 시나리오를 준비할게요." + +→ 사용자가 (2)라고 답하면, Depth 3-4를 타겟으로 하는 장애 시나리오를 설계하여 제시. From 743c8708c13c7558c0994f5fd38354e7743bfeaa Mon Sep 17 00:00:00 2001 From: plan11plan Date: Fri, 20 Feb 2026 23:08:28 +0900 Subject: [PATCH 024/108] =?UTF-8?q?feat:=20Brand=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EC=97=94=ED=8B=B0=ED=8B=B0,=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4,=20=EB=A6=AC=ED=8F=AC=EC=A7=80=ED=86=A0=EB=A6=AC=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../loopers/domain/brand/BrandErrorCode.java | 17 +++++++ .../com/loopers/domain/brand/BrandModel.java | 51 +++++++++++++++++++ .../loopers/domain/brand/BrandRepository.java | 11 ++++ .../loopers/domain/brand/BrandService.java | 47 +++++++++++++++++ .../brand/BrandJpaRepository.java | 11 ++++ .../brand/BrandRepositoryImpl.java | 28 ++++++++++ 6 files changed, 165 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandErrorCode.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandModel.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandErrorCode.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandErrorCode.java new file mode 100644 index 000000000..3214badac --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandErrorCode.java @@ -0,0 +1,17 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum BrandErrorCode implements ErrorCode { + DUPLICATE_NAME(HttpStatus.CONFLICT, "BRAND_001", "이미 존재하는 브랜드명입니다."), + NOT_FOUND(HttpStatus.NOT_FOUND, "BRAND_002", "브랜드를 찾을 수 없습니다."); + + private final HttpStatus status; + private final String code; + private final String message; +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandModel.java new file mode 100644 index 000000000..5358b32db --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandModel.java @@ -0,0 +1,51 @@ +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; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "brands") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class BrandModel extends BaseEntity { + + @Column(name = "name", nullable = false, unique = true) + private String name; + + // === 생성 === // + + private BrandModel(String name) { + this.name = name; + } + + public static BrandModel create(String name) { + validateName(name); + BrandModel model = new BrandModel(name); + return model; + } + + // === 도메인 로직 === // + + public void update(String name) { + validateName(name); + this.name = name; + } + + // === 검증 === // + + private static void validateName(String name) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드명은 필수값입니다."); + } + if (name.length() > 99) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드명은 99자 이하여야 합니다."); + } + } +} 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..afd22aed2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java @@ -0,0 +1,11 @@ +package com.loopers.domain.brand; + +import java.util.Optional; + +public interface BrandRepository { + BrandModel save(BrandModel brandModel); + + Optional findById(Long id); + + Optional findByName(String name); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java new file mode 100644 index 000000000..226308b5f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java @@ -0,0 +1,47 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class BrandService { + private final BrandRepository brandRepository; + + @Transactional + public void register(String name) { + if (brandRepository.findByName(name).isPresent()) { + throw new CoreException(BrandErrorCode.DUPLICATE_NAME); + } + + BrandModel brandModel = BrandModel.create(name); + brandRepository.save(brandModel); + } + + @Transactional(readOnly = true) + public BrandModel getById(Long id) { + return brandRepository.findById(id) + .orElseThrow(() -> new CoreException(BrandErrorCode.NOT_FOUND)); + } + + @Transactional + public void update(Long id, String name) { + BrandModel brandModel = getById(id); + + brandRepository.findByName(name) + .filter(existing -> !existing.getId().equals(brandModel.getId())) + .ifPresent(existing -> { + throw new CoreException(BrandErrorCode.DUPLICATE_NAME); + }); + + brandModel.update(name); + } + + @Transactional + public void delete(Long id) { + BrandModel brandModel = getById(id); + brandModel.delete(); + } +} 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..2d226f149 --- /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.BrandModel; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface BrandJpaRepository extends JpaRepository { + Optional findByIdAndDeletedAtIsNull(Long id); + + Optional findByNameAndDeletedAtIsNull(String name); +} 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..ba752a9a8 --- /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.BrandModel; +import com.loopers.domain.brand.BrandRepository; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class BrandRepositoryImpl implements BrandRepository { + private final BrandJpaRepository brandJpaRepository; + + @Override + public BrandModel save(BrandModel brandModel) { + return brandJpaRepository.save(brandModel); + } + + @Override + public Optional findById(Long id) { + return brandJpaRepository.findByIdAndDeletedAtIsNull(id); + } + + @Override + public Optional findByName(String name) { + return brandJpaRepository.findByNameAndDeletedAtIsNull(name); + } +} From 569112bdf030343bcb4490e670f37fabd71098b1 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Fri, 20 Feb 2026 23:08:32 +0900 Subject: [PATCH 025/108] =?UTF-8?q?test:=20Brand=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../loopers/domain/brand/BrandModelTest.java | 126 +++++++++++++++ .../domain/brand/BrandServiceTest.java | 145 ++++++++++++++++++ .../domain/brand/FakeBrandRepository.java | 41 +++++ 3 files changed, 312 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandModelTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/brand/FakeBrandRepository.java diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandModelTest.java new file mode 100644 index 000000000..99ddb906e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandModelTest.java @@ -0,0 +1,126 @@ +package com.loopers.domain.brand; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class BrandModelTest { + + @DisplayName("브랜드를 생성할 때, ") + @Nested + class Create { + + @DisplayName("브랜드명이 주어지면, 정상적으로 생성된다.") + @Test + void createBrandModel_whenNameProvided() { + // act + BrandModel brand = BrandModel.create("Nike"); + + // assert + assertThat(brand.getName()).isEqualTo("Nike"); + } + + @DisplayName("브랜드명이 null이면 예외가 발생한다.") + @Test + void createBrandModel_whenNameIsNull() { + assertThatThrownBy(() -> BrandModel.create(null)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("브랜드명은 필수값입니다."); + } + + @DisplayName("브랜드명이 빈 문자열이면 예외가 발생한다.") + @Test + void createBrandModel_whenNameIsBlank() { + assertThatThrownBy(() -> BrandModel.create(" ")) + .isInstanceOf(CoreException.class) + .hasMessageContaining("브랜드명은 필수값입니다."); + } + + @DisplayName("브랜드명이 100자 이상이면 예외가 발생한다.") + @Test + void createBrandModel_whenNameTooLong() { + String longName = "a".repeat(100); + + assertThatThrownBy(() -> BrandModel.create(longName)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("브랜드명은 99자 이하여야 합니다."); + } + } + + @DisplayName("브랜드명을 변경할 때, ") + @Nested + class Update { + + @DisplayName("유효한 브랜드명이 주어지면, 정상적으로 변경된다.") + @Test + void updateBrandModel_whenNameProvided() { + // arrange + BrandModel brand = BrandModel.create("Nike"); + + // act + brand.update("Adidas"); + + // assert + assertThat(brand.getName()).isEqualTo("Adidas"); + } + + @DisplayName("브랜드명이 null이면 예외가 발생한다.") + @Test + void updateBrandModel_whenNameIsNull() { + // arrange + BrandModel brand = BrandModel.create("Nike"); + + // act & assert + assertThatThrownBy(() -> brand.update(null)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("브랜드명은 필수값입니다."); + } + + @DisplayName("브랜드명이 빈 문자열이면 예외가 발생한다.") + @Test + void updateBrandModel_whenNameIsBlank() { + // arrange + BrandModel brand = BrandModel.create("Nike"); + + // act & assert + assertThatThrownBy(() -> brand.update(" ")) + .isInstanceOf(CoreException.class) + .hasMessageContaining("브랜드명은 필수값입니다."); + } + + @DisplayName("브랜드명이 100자 이상이면 예외가 발생한다.") + @Test + void updateBrandModel_whenNameTooLong() { + // arrange + BrandModel brand = BrandModel.create("Nike"); + String longName = "a".repeat(100); + + // act & assert + assertThatThrownBy(() -> brand.update(longName)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("브랜드명은 99자 이하여야 합니다."); + } + } + + @DisplayName("브랜드를 삭제할 때, ") + @Nested + class Delete { + + @DisplayName("delete() 호출 시 deletedAt이 설정된다.") + @Test + void delete_whenCalled() { + // arrange + BrandModel brand = BrandModel.create("Nike"); + + // act + brand.delete(); + + // assert + assertThat(brand.getDeletedAt()).isNotNull(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java new file mode 100644 index 000000000..71823a7d5 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java @@ -0,0 +1,145 @@ +package com.loopers.domain.brand; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class BrandServiceTest { + + private BrandService brandService; + private FakeBrandRepository brandRepository; + + @BeforeEach + void setUp() { + brandRepository = new FakeBrandRepository(); + brandService = new BrandService(brandRepository); + } + + @DisplayName("브랜드를 등록할 때, ") + @Nested + class Register { + + @DisplayName("유효한 브랜드명이 주어지면, 정상적으로 등록된다.") + @Test + void register_whenValidName() { + // act + brandService.register("Nike"); + + // assert + assertThat(brandRepository.findByName("Nike")).isPresent(); + } + + @DisplayName("이미 존재하는 브랜드명이면 CONFLICT 예외가 발생한다.") + @Test + void register_whenDuplicateName() { + // arrange + brandService.register("Nike"); + + // act & assert + assertThatThrownBy(() -> brandService.register("Nike")) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorCode()).isEqualTo(BrandErrorCode.DUPLICATE_NAME)); + } + } + + @DisplayName("브랜드를 조회할 때, ") + @Nested + class GetById { + + @DisplayName("존재하는 브랜드 ID가 주어지면, 정상적으로 조회된다.") + @Test + void getById_whenExists() { + // arrange + brandService.register("Nike"); + Long savedId = brandRepository.findByName("Nike").orElseThrow().getId(); + + // act + BrandModel found = brandService.getById(savedId); + + // assert + assertThat(found.getName()).isEqualTo("Nike"); + } + + @DisplayName("존재하지 않는 브랜드 ID이면 NOT_FOUND 예외가 발생한다.") + @Test + void getById_whenNotExists() { + assertThatThrownBy(() -> brandService.getById(999L)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorCode()).isEqualTo(BrandErrorCode.NOT_FOUND)); + } + + @DisplayName("삭제된 브랜드 ID이면 NOT_FOUND 예외가 발생한다.") + @Test + void getById_whenDeleted() { + // arrange + brandService.register("Nike"); + Long savedId = brandRepository.findByName("Nike").orElseThrow().getId(); + brandService.delete(savedId); + + // act & assert + assertThatThrownBy(() -> brandService.getById(savedId)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorCode()).isEqualTo(BrandErrorCode.NOT_FOUND)); + } + } + + @DisplayName("브랜드를 수정할 때, ") + @Nested + class Update { + + @DisplayName("유효한 브랜드명이 주어지면, 정상적으로 수정된다.") + @Test + void update_whenValidName() { + // arrange + brandService.register("Nike"); + Long savedId = brandRepository.findByName("Nike").orElseThrow().getId(); + + // act + brandService.update(savedId, "Adidas"); + + // assert + BrandModel updated = brandService.getById(savedId); + assertThat(updated.getName()).isEqualTo("Adidas"); + } + + @DisplayName("다른 브랜드가 사용 중인 이름이면 CONFLICT 예외가 발생한다.") + @Test + void update_whenDuplicateName() { + // arrange + brandService.register("Nike"); + brandService.register("Adidas"); + Long adidasId = brandRepository.findByName("Adidas").orElseThrow().getId(); + + // act & assert + assertThatThrownBy(() -> brandService.update(adidasId, "Nike")) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorCode()).isEqualTo(BrandErrorCode.DUPLICATE_NAME)); + } + } + + @DisplayName("브랜드를 삭제할 때, ") + @Nested + class Delete { + + @DisplayName("존재하는 브랜드 ID가 주어지면, 정상적으로 삭제된다.") + @Test + void delete_whenExists() { + // arrange + brandService.register("Nike"); + Long savedId = brandRepository.findByName("Nike").orElseThrow().getId(); + + // act + brandService.delete(savedId); + + // assert + assertThatThrownBy(() -> brandService.getById(savedId)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorCode()).isEqualTo(BrandErrorCode.NOT_FOUND)); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/FakeBrandRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/FakeBrandRepository.java new file mode 100644 index 000000000..4eae9dd4a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/FakeBrandRepository.java @@ -0,0 +1,41 @@ +package com.loopers.domain.brand; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; + +public class FakeBrandRepository implements BrandRepository { + + private final Map store = new HashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(1); + + @Override + public BrandModel save(BrandModel brandModel) { + if (brandModel.getId() == 0L) { + try { + var idField = brandModel.getClass().getSuperclass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(brandModel, idGenerator.getAndIncrement()); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException(e); + } + } + store.put(brandModel.getId(), brandModel); + return brandModel; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)) + .filter(brand -> brand.getDeletedAt() == null); + } + + @Override + public Optional findByName(String name) { + return store.values().stream() + .filter(brand -> brand.getDeletedAt() == null) + .filter(brand -> brand.getName().equals(name)) + .findFirst(); + } +} From 88b5a7cbafd9667790bf955046a036b827ad85d6 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Fri, 20 Feb 2026 23:08:37 +0900 Subject: [PATCH 026/108] =?UTF-8?q?docs:=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=EC=BB=A8=EB=B2=A4=EC=85=98=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20(Entity=20=EC=83=9D=EC=84=B1=EC=9E=90,=20S?= =?UTF-8?q?ervice=20=EB=B0=98=ED=99=98=20=ED=83=80=EC=9E=85,=20Soft=20Dele?= =?UTF-8?q?te=20=EA=B7=9C=EC=B9=99)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../application/service-layer-convention.md | 11 ++++- .../references/domain/entity-vo-convention.md | 40 +++++++++---------- .../infrastructure-convention.md | 18 +++++++-- 3 files changed, 44 insertions(+), 25 deletions(-) diff --git a/.claude/skills/project-convention/references/application/service-layer-convention.md b/.claude/skills/project-convention/references/application/service-layer-convention.md index e713464e4..e53d87f2a 100644 --- a/.claude/skills/project-convention/references/application/service-layer-convention.md +++ b/.claude/skills/project-convention/references/application/service-layer-convention.md @@ -116,6 +116,13 @@ public class OrderFacade { - DTO 변환 (Info → Response 등) → Facade 또는 Interface 계층 - Entity 자기 필드만으로 완결되는 로직 → Entity 메서드 +### CUD 메서드는 void를 반환한다 + +Domain Service의 생성/수정/삭제(CUD) 메서드는 **void**를 반환한다. 상위 계층(Facade)에서 필요하면 별도 조회 메서드를 호출한다. + +- YAGNI: 반환값이 필요할 때 추가하면 된다 +- 명령과 조회를 분리하여 메서드의 의도가 명확해진다 + ### 예시 ```java @@ -125,12 +132,12 @@ public class OrderService { private final OrderRepository orderRepository; @Transactional - public Order create(OrderMemberData member, List products) { + public void create(OrderMemberData member, List products) { List lines = products.stream() .map(p -> OrderLine.create(p.productId(), p.name(), p.price())) .toList(); Order order = Order.create(member.memberId(), lines); - return orderRepository.save(order); + orderRepository.save(order); } @Transactional diff --git a/.claude/skills/project-convention/references/domain/entity-vo-convention.md b/.claude/skills/project-convention/references/domain/entity-vo-convention.md index 091cc1fac..eaf620e80 100644 --- a/.claude/skills/project-convention/references/domain/entity-vo-convention.md +++ b/.claude/skills/project-convention/references/domain/entity-vo-convention.md @@ -32,20 +32,16 @@ public class Order { @Enumerated(EnumType.STRING) private OrderStatus status; - @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) - private List orderLines = new ArrayList<>(); - // === 생성 === // - public static Order create(Long memberId, int price, List lines) { - Order order = new Order(); - order.memberId = memberId; - order.totalPrice = Money.of(price); - order.status = OrderStatus.CREATED; - order.orderLines = lines.stream() - .map(OrderLine::create) - .toList(); - return order; + private Order(Long memberId, Money totalPrice, OrderStatus status) { + this.memberId = memberId; + this.totalPrice = totalPrice; + this.status = status; + } + + public static Order create(Long memberId, int price) { + return new Order(memberId, Money.of(price), OrderStatus.CREATED); } // === 도메인 로직 === // @@ -65,18 +61,21 @@ public class Order { } ``` -### 생성 패턴: 정적 팩토리 메서드 +### 생성 패턴: 정적 팩토리 메서드 + private 생성자 모든 Entity는 **정적 팩토리 메서드**로 생성한다. 생성자를 직접 노출하지 않는다. +내부 생성은 **private 생성자**를 사용하여 필드를 초기화한다. ```java -// ✅ 정적 팩토리 메서드 +// ✅ private 생성자 + 정적 팩토리 메서드 (권장) +private Order(Long memberId, Money totalPrice, OrderStatus status) { + this.memberId = memberId; + this.totalPrice = totalPrice; + this.status = status; +} + public static Order create(Long memberId, int price) { - Order order = new Order(); - order.memberId = memberId; - order.totalPrice = Money.of(price); - order.status = OrderStatus.CREATED; - return order; + return new Order(memberId, Money.of(price), OrderStatus.CREATED); } // ❌ 생성자 직접 노출 @@ -87,10 +86,11 @@ public Order(Long memberId, int price) { ... } public Order(Long memberId, int price) { ... } ``` -왜 정적 팩토리인가: +왜 정적 팩토리 + private 생성자인가: - 생성 의도를 메서드 이름으로 표현할 수 있다 (`create`, `register`, `createFromImport`) - 생성 시점에 VO 변환, 초기값 설정, 검증을 Entity가 통제한다 - 불변식(invariant)을 생성 시점부터 보장한다 +- private 생성자로 필드 초기화가 한 곳에서 완결되어 의도가 명확하다 ### 접근 제어 diff --git a/.claude/skills/project-convention/references/infrastructure/infrastructure-convention.md b/.claude/skills/project-convention/references/infrastructure/infrastructure-convention.md index 7ad5fc3ca..5359734b3 100644 --- a/.claude/skills/project-convention/references/infrastructure/infrastructure-convention.md +++ b/.claude/skills/project-convention/references/infrastructure/infrastructure-convention.md @@ -104,20 +104,32 @@ public class OrderRepositoryImpl implements OrderRepository { ### Soft Delete 조회 처리 -soft delete된 엔티티 필터링은 **RepositoryImpl에서 처리**한다. domain Repository 인터페이스의 `findById`를 호출하면 내부적으로 `deletedAt IS NULL` 조건이 적용된다. +soft delete된 엔티티 필터링은 **RepositoryImpl에서만 처리**한다. Domain Service에서 `isDeleted()` 등을 이중 체크하지 않는다. + +**핵심 규칙: RepositoryImpl의 모든 조회 메서드는 `deletedAt IS NULL`을 기본 적용한다.** ```java // domain 인터페이스 — soft delete를 모른다 Optional findById(Long id); +Optional findByName(String name); -// RepositoryImpl — soft delete 필터링을 여기서 처리 +// RepositoryImpl — 모든 조회에서 soft delete 필터링 @Override public Optional findById(Long id) { return orderJpaRepository.findByIdAndDeletedAtIsNull(id); } + +@Override +public Optional findByName(String name) { + return orderJpaRepository.findByNameAndDeletedAtIsNull(name); +} ``` -domain이 "삭제된 데이터"를 알 필요 없다. infrastructure가 저장소 세부사항으로서 처리한다. +왜 RepositoryImpl에서만 처리하는가: +- **단일 책임**: soft delete는 저장소 세부사항이므로 infrastructure가 담당한다 +- **domain 순수성**: domain이 "삭제된 데이터" 개념을 알 필요 없다 +- **일관성**: 모든 조회 메서드에 동일한 규칙이 적용되므로 누락 위험이 없다 +- **Service 단순화**: Domain Service에서 `isDeleted()` 체크가 불필요하다 ### 네이밍 규칙 From c9846efb23cfdc723ae23312ee6e8daf582e0732 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Fri, 20 Feb 2026 23:36:18 +0900 Subject: [PATCH 027/108] =?UTF-8?q?feat:=20Brand=20Application=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=20=EA=B5=AC=ED=98=84=20(Facade=20+=20DTO)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/brand/BrandFacade.java | 37 +++++++++++++++++++ .../application/brand/dto/BrandCommand.java | 10 +++++ .../application/brand/dto/BrandInfo.java | 9 +++++ 3 files changed, 56 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/brand/dto/BrandCommand.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/brand/dto/BrandInfo.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java new file mode 100644 index 000000000..eeb1945a4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -0,0 +1,37 @@ +package com.loopers.application.brand; + +import com.loopers.application.brand.dto.BrandCommand; +import com.loopers.application.brand.dto.BrandInfo; +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class BrandFacade { + + private final BrandService brandService; + + @Transactional + public void register(BrandCommand.Register command) { + brandService.register(command.name()); + } + + @Transactional(readOnly = true) + public BrandInfo getById(Long id) { + BrandModel brandModel = brandService.getById(id); + return BrandInfo.from(brandModel); + } + + @Transactional + public void update(Long id, BrandCommand.Update command) { + brandService.update(id, command.name()); + } + + @Transactional + public void delete(Long id) { + brandService.delete(id); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/dto/BrandCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/dto/BrandCommand.java new file mode 100644 index 000000000..646065f08 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/dto/BrandCommand.java @@ -0,0 +1,10 @@ +package com.loopers.application.brand.dto; + +public class BrandCommand { + + public record Register(String name) { + } + + public record Update(String name) { + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/dto/BrandInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/dto/BrandInfo.java new file mode 100644 index 000000000..cf75a314d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/dto/BrandInfo.java @@ -0,0 +1,9 @@ +package com.loopers.application.brand.dto; + +import com.loopers.domain.brand.BrandModel; + +public record BrandInfo(Long id, String name) { + public static BrandInfo from(BrandModel model) { + return new BrandInfo(model.getId(), model.getName()); + } +} From 6eb7ffb38ef1acdaf506bd921eaf51742d52bad4 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Fri, 20 Feb 2026 23:36:21 +0900 Subject: [PATCH 028/108] =?UTF-8?q?test:=20BrandFacade=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/brand/BrandFacadeTest.java | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java diff --git a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java new file mode 100644 index 000000000..bddaa4dc1 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java @@ -0,0 +1,99 @@ +package com.loopers.application.brand; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.loopers.application.brand.dto.BrandCommand; +import com.loopers.application.brand.dto.BrandInfo; +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandService; +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; + +@DisplayName("BrandFacade 단위 테스트") +@ExtendWith(MockitoExtension.class) +class BrandFacadeTest { + + @Mock + private BrandService brandService; + + @InjectMocks + private BrandFacade brandFacade; + + @DisplayName("브랜드 등록") + @Nested + class Register { + + @Test + @DisplayName("Command의 name을 BrandService.register에 전달한다") + void register_호출_검증() { + // arrange + BrandCommand.Register command = new BrandCommand.Register("나이키"); + + // act + brandFacade.register(command); + + // assert + verify(brandService).register("나이키"); + } + } + + @DisplayName("브랜드 조회") + @Nested + class GetById { + + @Test + @DisplayName("BrandService.getById 결과를 BrandInfo로 변환하여 반환한다") + void getById_변환_검증() { + // arrange + BrandModel brandModel = BrandModel.create("나이키"); + when(brandService.getById(1L)).thenReturn(brandModel); + + // act + BrandInfo result = brandFacade.getById(1L); + + // assert + assertThat(result.name()).isEqualTo("나이키"); + verify(brandService).getById(1L); + } + } + + @DisplayName("브랜드 수정") + @Nested + class Update { + + @Test + @DisplayName("id와 Command의 name을 BrandService.update에 전달한다") + void update_호출_검증() { + // arrange + BrandCommand.Update command = new BrandCommand.Update("아디다스"); + + // act + brandFacade.update(1L, command); + + // assert + verify(brandService).update(1L, "아디다스"); + } + } + + @DisplayName("브랜드 삭제") + @Nested + class Delete { + + @Test + @DisplayName("id를 BrandService.delete에 전달한다") + void delete_호출_검증() { + // arrange & act + brandFacade.delete(1L); + + // assert + verify(brandService).delete(1L); + } + } +} From c30081c611cc67fb904da276123838659df6244a Mon Sep 17 00:00:00 2001 From: plan11plan Date: Sat, 21 Feb 2026 00:44:52 +0900 Subject: [PATCH 029/108] =?UTF-8?q?feat:=20Brand=20Admin=20LDAP=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=ED=95=84=ED=84=B0=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interfaces/auth/AdminAuthFilter.java | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminAuthFilter.java diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminAuthFilter.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminAuthFilter.java new file mode 100644 index 000000000..7c6125963 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminAuthFilter.java @@ -0,0 +1,60 @@ +package com.loopers.interfaces.auth; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.error.ErrorType; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Component +@RequiredArgsConstructor +@Order(1) +public class AdminAuthFilter extends OncePerRequestFilter { + + private static final String HEADER_LDAP = "X-Loopers-Ldap"; + private static final String LDAP_VALUE = "loopers.admin"; + private static final String ADMIN_PATH_PREFIX = "/api-admin/"; + + private final ObjectMapper objectMapper; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + if (!requiresAuth(request.getRequestURI())) { + filterChain.doFilter(request, response); + return; + } + + String ldapHeader = request.getHeader(HEADER_LDAP); + + if (ldapHeader == null || !LDAP_VALUE.equals(ldapHeader)) { + writeUnauthorizedResponse(response, ErrorType.UNAUTHORIZED.getMessage()); + return; + } + + filterChain.doFilter(request, response); + } + + private boolean requiresAuth(String uri) { + return uri.startsWith(ADMIN_PATH_PREFIX); + } + + private void writeUnauthorizedResponse(HttpServletResponse response, String message) throws IOException { + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + + ApiResponse apiResponse = ApiResponse.fail(ErrorType.UNAUTHORIZED.getCode(), message); + response.getWriter().write(objectMapper.writeValueAsString(apiResponse)); + } +} From 7d051fc4b94de8767e375d405c84507cf455f259 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Sat, 21 Feb 2026 00:44:58 +0900 Subject: [PATCH 030/108] =?UTF-8?q?feat:=20Brand=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8/=EC=9D=B8=ED=94=84=EB=9D=BC=EC=97=90=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=EB=84=A4=EC=9D=B4=EC=85=98=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/application/brand/BrandFacade.java | 7 +++++++ .../java/com/loopers/application/brand/dto/BrandInfo.java | 5 +++-- .../java/com/loopers/domain/brand/BrandRepository.java | 4 ++++ .../main/java/com/loopers/domain/brand/BrandService.java | 7 +++++++ .../loopers/infrastructure/brand/BrandJpaRepository.java | 4 ++++ .../loopers/infrastructure/brand/BrandRepositoryImpl.java | 7 +++++++ 6 files changed, 32 insertions(+), 2 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java index eeb1945a4..526b2fa7a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -5,6 +5,8 @@ import com.loopers.domain.brand.BrandModel; import com.loopers.domain.brand.BrandService; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -34,4 +36,9 @@ public void update(Long id, BrandCommand.Update command) { public void delete(Long id) { brandService.delete(id); } + + @Transactional(readOnly = true) + public Page getAll(Pageable pageable) { + return brandService.getAll(pageable).map(BrandInfo::from); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/dto/BrandInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/dto/BrandInfo.java index cf75a314d..d2833a1d6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/dto/BrandInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/dto/BrandInfo.java @@ -1,9 +1,10 @@ package com.loopers.application.brand.dto; import com.loopers.domain.brand.BrandModel; +import java.time.ZonedDateTime; -public record BrandInfo(Long id, String name) { +public record BrandInfo(Long id, String name, ZonedDateTime createdAt, ZonedDateTime updatedAt, ZonedDateTime deletedAt) { public static BrandInfo from(BrandModel model) { - return new BrandInfo(model.getId(), model.getName()); + return new BrandInfo(model.getId(), model.getName(), model.getCreatedAt(), model.getUpdatedAt(), model.getDeletedAt()); } } 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 afd22aed2..dc45afec1 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,6 +1,8 @@ package com.loopers.domain.brand; import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; public interface BrandRepository { BrandModel save(BrandModel brandModel); @@ -8,4 +10,6 @@ public interface BrandRepository { Optional findById(Long id); Optional findByName(String name); + + Page findAll(Pageable pageable); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java index 226308b5f..f52b9626b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java @@ -2,6 +2,8 @@ import com.loopers.support.error.CoreException; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -44,4 +46,9 @@ public void delete(Long id) { BrandModel brandModel = getById(id); brandModel.delete(); } + + @Transactional(readOnly = true) + public Page getAll(Pageable pageable) { + return brandRepository.findAll(pageable); + } } 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 index 2d226f149..8a89e1b07 100644 --- 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 @@ -2,10 +2,14 @@ import com.loopers.domain.brand.BrandModel; import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; public interface BrandJpaRepository extends JpaRepository { Optional findByIdAndDeletedAtIsNull(Long id); Optional findByNameAndDeletedAtIsNull(String name); + + Page findAllByDeletedAtIsNull(Pageable pageable); } 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 ba752a9a8..6f20c295e 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 @@ -4,6 +4,8 @@ import com.loopers.domain.brand.BrandRepository; import java.util.Optional; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; @Repository @@ -25,4 +27,9 @@ public Optional findById(Long id) { public Optional findByName(String name) { return brandJpaRepository.findByNameAndDeletedAtIsNull(name); } + + @Override + public Page findAll(Pageable pageable) { + return brandJpaRepository.findAllByDeletedAtIsNull(pageable); + } } From 09c6310e65efacff59ad02ba758fec351b226032 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Sat, 21 Feb 2026 00:45:06 +0900 Subject: [PATCH 031/108] =?UTF-8?q?feat:=20Brand=20Admin=20API=20=EC=BB=A8?= =?UTF-8?q?=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20?= =?UTF-8?q?Public=20API=20GET=20=EC=A0=84=EC=9A=A9=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interfaces/brand/AdminBrandV1ApiSpec.java | 61 +++++++++++++++ .../brand/AdminBrandV1Controller.java | 75 +++++++++++++++++++ .../interfaces/brand/BrandV1ApiSpec.java | 20 +++++ .../interfaces/brand/BrandV1Controller.java | 25 +++++++ .../interfaces/brand/dto/AdminBrandV1Dto.java | 63 ++++++++++++++++ .../interfaces/brand/dto/BrandV1Dto.java | 15 ++++ 6 files changed, 259 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/brand/AdminBrandV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/brand/AdminBrandV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/brand/BrandV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/brand/BrandV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/brand/dto/AdminBrandV1Dto.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/brand/dto/BrandV1Dto.java diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/AdminBrandV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/AdminBrandV1ApiSpec.java new file mode 100644 index 000000000..02ecbf457 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/AdminBrandV1ApiSpec.java @@ -0,0 +1,61 @@ +package com.loopers.interfaces.brand; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.brand.dto.AdminBrandV1Dto; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Admin Brand V1 API", description = "브랜드 관리자 API 입니다.") +public interface AdminBrandV1ApiSpec { + + @Operation( + summary = "브랜드 등록", + description = "새로운 브랜드를 등록합니다." + ) + ApiResponse register( + @RequestBody(description = "브랜드 등록 요청 정보") + AdminBrandV1Dto.RegisterRequest request + ); + + @Operation( + summary = "브랜드 목록 조회", + description = "브랜드 목록을 페이지네이션으로 조회합니다." + ) + ApiResponse list( + @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") + int page, + @Parameter(description = "페이지 크기", example = "20") + int size + ); + + @Operation( + summary = "브랜드 상세 조회", + description = "특정 브랜드의 상세 정보를 조회합니다." + ) + ApiResponse getById( + @Parameter(description = "브랜드 ID", required = true, example = "1") + Long brandId + ); + + @Operation( + summary = "브랜드 수정", + description = "브랜드 정보를 수정합니다." + ) + ApiResponse update( + @Parameter(description = "브랜드 ID", required = true, example = "1") + Long brandId, + @RequestBody(description = "브랜드 수정 요청 정보") + AdminBrandV1Dto.UpdateRequest request + ); + + @Operation( + summary = "브랜드 삭제", + description = "브랜드를 삭제합니다." + ) + ApiResponse delete( + @Parameter(description = "브랜드 ID", required = true, example = "1") + Long brandId + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/AdminBrandV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/AdminBrandV1Controller.java new file mode 100644 index 000000000..f166a0910 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/AdminBrandV1Controller.java @@ -0,0 +1,75 @@ +package com.loopers.interfaces.brand; + +import com.loopers.application.brand.BrandFacade; +import com.loopers.application.brand.dto.BrandInfo; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.brand.dto.AdminBrandV1Dto; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api-admin/v1/brands") +public class AdminBrandV1Controller implements AdminBrandV1ApiSpec { + + private final BrandFacade brandFacade; + + @PostMapping + @Override + public ApiResponse register( + @Valid @RequestBody AdminBrandV1Dto.RegisterRequest request + ) { + brandFacade.register(request.toCommand()); + return ApiResponse.success(); + } + + @GetMapping + @Override + public ApiResponse list( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + Page brandInfoPage = brandFacade.getAll(PageRequest.of(page, size)); + AdminBrandV1Dto.ListResponse listResponse = new AdminBrandV1Dto.ListResponse( + brandInfoPage.getNumber(), + brandInfoPage.getSize(), + brandInfoPage.getTotalElements(), + brandInfoPage.getTotalPages(), + brandInfoPage.getContent().stream() + .map(AdminBrandV1Dto.ListResponse.ListItem::from) + .toList() + ); + return ApiResponse.success(listResponse); + } + + @GetMapping("/{brandId}") + @Override + public ApiResponse getById( + @PathVariable Long brandId + ) { + BrandInfo brandInfo = brandFacade.getById(brandId); + return ApiResponse.success(AdminBrandV1Dto.DetailResponse.from(brandInfo)); + } + + @PutMapping("/{brandId}") + @Override + public ApiResponse update( + @PathVariable Long brandId, + @Valid @RequestBody AdminBrandV1Dto.UpdateRequest request + ) { + brandFacade.update(brandId, request.toCommand()); + return ApiResponse.success(); + } + + @DeleteMapping("/{brandId}") + @Override + public ApiResponse delete( + @PathVariable Long brandId + ) { + brandFacade.delete(brandId); + return ApiResponse.success(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/BrandV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/BrandV1ApiSpec.java new file mode 100644 index 000000000..1dc0361c8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/BrandV1ApiSpec.java @@ -0,0 +1,20 @@ +package com.loopers.interfaces.brand; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.brand.dto.BrandV1Dto; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Brand V1 API", description = "브랜드 API 입니다.") +public interface BrandV1ApiSpec { + + @Operation( + summary = "브랜드 상세 조회", + description = "특정 브랜드의 정보를 조회합니다." + ) + ApiResponse getById( + @Parameter(description = "브랜드 ID", required = true, example = "1") + Long brandId + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/BrandV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/BrandV1Controller.java new file mode 100644 index 000000000..5242c852f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/BrandV1Controller.java @@ -0,0 +1,25 @@ +package com.loopers.interfaces.brand; + +import com.loopers.application.brand.BrandFacade; +import com.loopers.application.brand.dto.BrandInfo; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.brand.dto.BrandV1Dto; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/brands") +public class BrandV1Controller implements BrandV1ApiSpec { + + private final BrandFacade brandFacade; + + @GetMapping("/{brandId}") + @Override + public ApiResponse getById( + @PathVariable Long brandId + ) { + BrandInfo brandInfo = brandFacade.getById(brandId); + return ApiResponse.success(BrandV1Dto.DetailResponse.from(brandInfo)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/dto/AdminBrandV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/dto/AdminBrandV1Dto.java new file mode 100644 index 000000000..b6da78b2c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/dto/AdminBrandV1Dto.java @@ -0,0 +1,63 @@ +package com.loopers.interfaces.brand.dto; + +import com.loopers.application.brand.dto.BrandCommand; +import com.loopers.application.brand.dto.BrandInfo; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import java.time.ZonedDateTime; +import java.util.List; + +public class AdminBrandV1Dto { + + public record RegisterRequest( + @NotBlank(message = "브랜드명은 필수 입력값입니다.") + @Size(max = 99, message = "브랜드명은 99자 이하여야 합니다.") + String name + ) { + public BrandCommand.Register toCommand() { + return new BrandCommand.Register(name); + } + } + + public record UpdateRequest( + @NotBlank(message = "브랜드명은 필수 입력값입니다.") + @Size(max = 99, message = "브랜드명은 99자 이하여야 합니다.") + String name + ) { + public BrandCommand.Update toCommand() { + return new BrandCommand.Update(name); + } + } + + public record DetailResponse( + Long id, + String name, + ZonedDateTime createdAt, + ZonedDateTime updatedAt, + ZonedDateTime deletedAt + ) { + public static DetailResponse from(BrandInfo info) { + return new DetailResponse(info.id(), info.name(), info.createdAt(), info.updatedAt(), info.deletedAt()); + } + } + + public record ListResponse( + int page, + int size, + long totalElements, + int totalPages, + List items + ) { + public record ListItem( + Long id, + String name, + ZonedDateTime createdAt, + ZonedDateTime updatedAt, + ZonedDateTime deletedAt + ) { + public static ListItem from(BrandInfo info) { + return new ListItem(info.id(), info.name(), info.createdAt(), info.updatedAt(), info.deletedAt()); + } + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/dto/BrandV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/dto/BrandV1Dto.java new file mode 100644 index 000000000..bfbc3aa7b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/dto/BrandV1Dto.java @@ -0,0 +1,15 @@ +package com.loopers.interfaces.brand.dto; + +import com.loopers.application.brand.dto.BrandInfo; + +public class BrandV1Dto { + + public record DetailResponse( + Long id, + String name + ) { + public static DetailResponse from(BrandInfo info) { + return new DetailResponse(info.id(), info.name()); + } + } +} From 9b49ad94be5056e9b73fff71bd4556967b5bc464 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Sat, 21 Feb 2026 00:45:11 +0900 Subject: [PATCH 032/108] =?UTF-8?q?test:=20Brand=20Admin=20API=20E2E=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B8=B0=EC=A1=B4=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/brand/BrandServiceTest.java | 52 +++ .../domain/brand/FakeBrandRepository.java | 21 + .../brand/AdminBrandV1ApiE2ETest.java | 416 ++++++++++++++++++ .../interfaces/brand/BrandV1ApiE2ETest.java | 89 ++++ .../brand/BrandV1ApiScenarioTest.java | 133 ++++++ 5 files changed, 711 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/brand/AdminBrandV1ApiE2ETest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/brand/BrandV1ApiE2ETest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/brand/BrandV1ApiScenarioTest.java diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java index 71823a7d5..e3cc64a04 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java @@ -8,6 +8,8 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; class BrandServiceTest { @@ -142,4 +144,54 @@ void delete_whenExists() { .satisfies(e -> assertThat(((CoreException) e).getErrorCode()).isEqualTo(BrandErrorCode.NOT_FOUND)); } } + + @DisplayName("브랜드 목록을 조회할 때, ") + @Nested + class GetAll { + + @DisplayName("등록된 브랜드가 있으면, 페이지네이션된 목록을 반환한다.") + @Test + void getAll_whenBrandsExist() { + // arrange + brandService.register("Nike"); + brandService.register("Adidas"); + brandService.register("Puma"); + + // act + Page result = brandService.getAll(PageRequest.of(0, 2)); + + // assert + assertThat(result.getContent()).hasSize(2); + assertThat(result.getTotalElements()).isEqualTo(3); + assertThat(result.getTotalPages()).isEqualTo(2); + } + + @DisplayName("등록된 브랜드가 없으면, 빈 목록을 반환한다.") + @Test + void getAll_whenNoBrandsExist() { + // act + Page result = brandService.getAll(PageRequest.of(0, 20)); + + // assert + assertThat(result.getContent()).isEmpty(); + assertThat(result.getTotalElements()).isEqualTo(0); + } + + @DisplayName("삭제된 브랜드는 목록에 포함되지 않는다.") + @Test + void getAll_excludesDeletedBrands() { + // arrange + brandService.register("Nike"); + brandService.register("Adidas"); + Long nikeId = brandRepository.findByName("Nike").orElseThrow().getId(); + brandService.delete(nikeId); + + // act + Page result = brandService.getAll(PageRequest.of(0, 20)); + + // assert + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).getName()).isEqualTo("Adidas"); + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/FakeBrandRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/FakeBrandRepository.java index 4eae9dd4a..715386df7 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/brand/FakeBrandRepository.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/FakeBrandRepository.java @@ -1,9 +1,14 @@ package com.loopers.domain.brand; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.concurrent.atomic.AtomicLong; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; public class FakeBrandRepository implements BrandRepository { @@ -38,4 +43,20 @@ public Optional findByName(String name) { .filter(brand -> brand.getName().equals(name)) .findFirst(); } + + @Override + public Page findAll(Pageable pageable) { + List activeModels = store.values().stream() + .filter(brand -> brand.getDeletedAt() == null) + .toList(); + + int start = (int) pageable.getOffset(); + int end = Math.min(start + pageable.getPageSize(), activeModels.size()); + + List pageContent = start >= activeModels.size() + ? new ArrayList<>() + : activeModels.subList(start, end); + + return new PageImpl<>(pageContent, pageable, activeModels.size()); + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/brand/AdminBrandV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/brand/AdminBrandV1ApiE2ETest.java new file mode 100644 index 000000000..363836e1c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/brand/AdminBrandV1ApiE2ETest.java @@ -0,0 +1,416 @@ +package com.loopers.interfaces.brand; + +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 com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.brand.dto.AdminBrandV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class AdminBrandV1ApiE2ETest { + + private static final String ENDPOINT_BRANDS = "/api-admin/v1/brands"; + + private final TestRestTemplate testRestTemplate; + private final BrandJpaRepository brandJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public AdminBrandV1ApiE2ETest( + TestRestTemplate testRestTemplate, + BrandJpaRepository brandJpaRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.brandJpaRepository = brandJpaRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "loopers.admin"); + return headers; + } + + @DisplayName("LDAP 인증") + @Nested + class Authentication { + + @DisplayName("LDAP 헤더 없이 요청하면, 401 UNAUTHORIZED 응답을 받는다.") + @Test + void returnsUnauthorized_whenNoLdapHeader() { + // arrange + AdminBrandV1Dto.RegisterRequest request = new AdminBrandV1Dto.RegisterRequest("나이키"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_BRANDS, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED), + () -> assertThat(brandJpaRepository.count()).isEqualTo(0) + ); + } + + @DisplayName("잘못된 LDAP 헤더 값으로 요청하면, 401 UNAUTHORIZED 응답을 받는다.") + @Test + void returnsUnauthorized_whenInvalidLdapHeader() { + // arrange + AdminBrandV1Dto.RegisterRequest request = new AdminBrandV1Dto.RegisterRequest("나이키"); + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "wrong.value"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_BRANDS, HttpMethod.POST, new HttpEntity<>(request, headers), responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED), + () -> assertThat(brandJpaRepository.count()).isEqualTo(0) + ); + } + + @DisplayName("GET 요청도 LDAP 헤더 없이는 401 UNAUTHORIZED 응답을 받는다.") + @Test + void returnsUnauthorized_whenGetWithoutLdapHeader() { + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_BRANDS, HttpMethod.GET, null, responseType); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + @DisplayName("POST /api-admin/v1/brands") + @Nested + class Register { + + @DisplayName("유효한 브랜드명을 주면, 브랜드 등록에 성공한다.") + @Test + void returnsSuccess_whenValidBrandNameIsProvided() { + // arrange + AdminBrandV1Dto.RegisterRequest request = new AdminBrandV1Dto.RegisterRequest("나이키"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_BRANDS, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(brandJpaRepository.count()).isEqualTo(1) + ); + } + + @DisplayName("브랜드명이 빈값이면, 400 BAD_REQUEST 응답을 받는다.") + @Test + void throwsBadRequest_whenBrandNameIsBlank() { + // arrange + AdminBrandV1Dto.RegisterRequest request = new AdminBrandV1Dto.RegisterRequest(""); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_BRANDS, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST), + () -> assertThat(brandJpaRepository.count()).isEqualTo(0) + ); + } + + @DisplayName("브랜드명이 99자를 초과하면, 400 BAD_REQUEST 응답을 받는다.") + @Test + void throwsBadRequest_whenBrandNameIsTooLong() { + // arrange + AdminBrandV1Dto.RegisterRequest request = new AdminBrandV1Dto.RegisterRequest("a".repeat(100)); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_BRANDS, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST), + () -> assertThat(brandJpaRepository.count()).isEqualTo(0) + ); + } + + @DisplayName("이미 존재하는 브랜드명이면, 409 CONFLICT 응답을 받는다.") + @Test + void throwsConflict_whenBrandNameAlreadyExists() { + // arrange + AdminBrandV1Dto.RegisterRequest firstRequest = new AdminBrandV1Dto.RegisterRequest("나이키"); + AdminBrandV1Dto.RegisterRequest secondRequest = new AdminBrandV1Dto.RegisterRequest("나이키"); + + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + testRestTemplate.exchange(ENDPOINT_BRANDS, HttpMethod.POST, new HttpEntity<>(firstRequest, adminHeaders()), responseType); + + // act + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_BRANDS, HttpMethod.POST, new HttpEntity<>(secondRequest, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT), + () -> assertThat(brandJpaRepository.count()).isEqualTo(1) + ); + } + } + + @DisplayName("GET /api-admin/v1/brands") + @Nested + class List { + + @DisplayName("브랜드 목록을 조회하면, 페이지네이션된 목록을 반환한다.") + @Test + void returnsPaginatedList_whenBrandsExist() { + // arrange + ParameterizedTypeReference> registerResponseType = new ParameterizedTypeReference<>() {}; + testRestTemplate.exchange(ENDPOINT_BRANDS, HttpMethod.POST, new HttpEntity<>(new AdminBrandV1Dto.RegisterRequest("나이키"), adminHeaders()), registerResponseType); + testRestTemplate.exchange(ENDPOINT_BRANDS, HttpMethod.POST, new HttpEntity<>(new AdminBrandV1Dto.RegisterRequest("아디다스"), adminHeaders()), registerResponseType); + testRestTemplate.exchange(ENDPOINT_BRANDS, HttpMethod.POST, new HttpEntity<>(new AdminBrandV1Dto.RegisterRequest("푸마"), adminHeaders()), registerResponseType); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_BRANDS + "?page=0&size=2", HttpMethod.GET, new HttpEntity<>(null, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(3), + () -> assertThat(response.getBody().data().totalPages()).isEqualTo(2), + () -> assertThat(response.getBody().data().items()).hasSize(2) + ); + } + + @DisplayName("브랜드가 없으면, 빈 목록을 반환한다.") + @Test + void returnsEmptyList_whenNoBrandsExist() { + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_BRANDS + "?page=0&size=20", HttpMethod.GET, new HttpEntity<>(null, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(0), + () -> assertThat(response.getBody().data().items()).isEmpty() + ); + } + } + + @DisplayName("GET /api-admin/v1/brands/{brandId}") + @Nested + class GetById { + + @DisplayName("존재하는 브랜드를 조회하면, 상세 정보를 반환한다.") + @Test + void returnsBrandDetail_whenBrandExists() { + // arrange + ParameterizedTypeReference> registerResponseType = new ParameterizedTypeReference<>() {}; + testRestTemplate.exchange(ENDPOINT_BRANDS, HttpMethod.POST, new HttpEntity<>(new AdminBrandV1Dto.RegisterRequest("나이키"), adminHeaders()), registerResponseType); + + Long brandId = brandJpaRepository.findByNameAndDeletedAtIsNull("나이키").get().getId(); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_BRANDS + "/" + brandId, HttpMethod.GET, new HttpEntity<>(null, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().id()).isEqualTo(brandId), + () -> assertThat(response.getBody().data().name()).isEqualTo("나이키"), + () -> assertThat(response.getBody().data().createdAt()).isNotNull(), + () -> assertThat(response.getBody().data().updatedAt()).isNotNull(), + () -> assertThat(response.getBody().data().deletedAt()).isNull() + ); + } + + @DisplayName("존재하지 않는 브랜드를 조회하면, 404 NOT_FOUND 응답을 받는다.") + @Test + void throwsNotFound_whenBrandDoesNotExist() { + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_BRANDS + "/999", HttpMethod.GET, new HttpEntity<>(null, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) + ); + } + } + + @DisplayName("PUT /api-admin/v1/brands/{brandId}") + @Nested + class Update { + + @DisplayName("유효한 수정 요청이면, 브랜드가 수정된다.") + @Test + void returnsSuccess_whenValidUpdateRequest() { + // arrange + ParameterizedTypeReference> registerResponseType = new ParameterizedTypeReference<>() {}; + testRestTemplate.exchange(ENDPOINT_BRANDS, HttpMethod.POST, new HttpEntity<>(new AdminBrandV1Dto.RegisterRequest("나이키"), adminHeaders()), registerResponseType); + + Long brandId = brandJpaRepository.findByNameAndDeletedAtIsNull("나이키").get().getId(); + AdminBrandV1Dto.UpdateRequest updateRequest = new AdminBrandV1Dto.UpdateRequest("아디다스"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_BRANDS + "/" + brandId, HttpMethod.PUT, new HttpEntity<>(updateRequest, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(brandJpaRepository.findByNameAndDeletedAtIsNull("아디다스")).isPresent() + ); + } + + @DisplayName("존재하지 않는 브랜드를 수정하면, 404 NOT_FOUND 응답을 받는다.") + @Test + void throwsNotFound_whenBrandDoesNotExist() { + // arrange + AdminBrandV1Dto.UpdateRequest updateRequest = new AdminBrandV1Dto.UpdateRequest("아디다스"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_BRANDS + "/999", HttpMethod.PUT, new HttpEntity<>(updateRequest, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) + ); + } + + @DisplayName("이미 존재하는 브랜드명으로 수정하면, 409 CONFLICT 응답을 받는다.") + @Test + void throwsConflict_whenBrandNameAlreadyExists() { + // arrange + ParameterizedTypeReference> registerResponseType = new ParameterizedTypeReference<>() {}; + testRestTemplate.exchange(ENDPOINT_BRANDS, HttpMethod.POST, new HttpEntity<>(new AdminBrandV1Dto.RegisterRequest("나이키"), adminHeaders()), registerResponseType); + testRestTemplate.exchange(ENDPOINT_BRANDS, HttpMethod.POST, new HttpEntity<>(new AdminBrandV1Dto.RegisterRequest("아디다스"), adminHeaders()), registerResponseType); + + Long nikeId = brandJpaRepository.findByNameAndDeletedAtIsNull("나이키").get().getId(); + AdminBrandV1Dto.UpdateRequest updateRequest = new AdminBrandV1Dto.UpdateRequest("아디다스"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_BRANDS + "/" + nikeId, HttpMethod.PUT, new HttpEntity<>(updateRequest, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT) + ); + } + + @DisplayName("브랜드명이 빈값이면, 400 BAD_REQUEST 응답을 받는다.") + @Test + void throwsBadRequest_whenBrandNameIsBlank() { + // arrange + ParameterizedTypeReference> registerResponseType = new ParameterizedTypeReference<>() {}; + testRestTemplate.exchange(ENDPOINT_BRANDS, HttpMethod.POST, new HttpEntity<>(new AdminBrandV1Dto.RegisterRequest("나이키"), adminHeaders()), registerResponseType); + + Long brandId = brandJpaRepository.findByNameAndDeletedAtIsNull("나이키").get().getId(); + AdminBrandV1Dto.UpdateRequest updateRequest = new AdminBrandV1Dto.UpdateRequest(""); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_BRANDS + "/" + brandId, HttpMethod.PUT, new HttpEntity<>(updateRequest, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST) + ); + } + } + + @DisplayName("DELETE /api-admin/v1/brands/{brandId}") + @Nested + class Delete { + + @DisplayName("존재하는 브랜드를 삭제하면, 성공한다.") + @Test + void returnsSuccess_whenBrandExists() { + // arrange + ParameterizedTypeReference> registerResponseType = new ParameterizedTypeReference<>() {}; + testRestTemplate.exchange(ENDPOINT_BRANDS, HttpMethod.POST, new HttpEntity<>(new AdminBrandV1Dto.RegisterRequest("나이키"), adminHeaders()), registerResponseType); + + Long brandId = brandJpaRepository.findByNameAndDeletedAtIsNull("나이키").get().getId(); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_BRANDS + "/" + brandId, HttpMethod.DELETE, new HttpEntity<>(null, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(brandJpaRepository.findByNameAndDeletedAtIsNull("나이키")).isEmpty() + ); + } + + @DisplayName("존재하지 않는 브랜드를 삭제하면, 404 NOT_FOUND 응답을 받는다.") + @Test + void throwsNotFound_whenBrandDoesNotExist() { + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_BRANDS + "/999", HttpMethod.DELETE, new HttpEntity<>(null, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/brand/BrandV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/brand/BrandV1ApiE2ETest.java new file mode 100644 index 000000000..eb7410c5f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/brand/BrandV1ApiE2ETest.java @@ -0,0 +1,89 @@ +package com.loopers.interfaces.brand; + +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 com.loopers.domain.brand.BrandModel; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.brand.dto.BrandV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class BrandV1ApiE2ETest { + + private static final String ENDPOINT_BRANDS = "/api/v1/brands"; + + private final TestRestTemplate testRestTemplate; + private final BrandJpaRepository brandJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public BrandV1ApiE2ETest( + TestRestTemplate testRestTemplate, + BrandJpaRepository brandJpaRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.brandJpaRepository = brandJpaRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("GET /api/v1/brands/{brandId}") + @Nested + class GetById { + + @DisplayName("존재하는 브랜드를 조회하면, 브랜드 정보를 반환한다.") + @Test + void returnsBrandInfo_whenBrandExists() { + // arrange + brandJpaRepository.save(BrandModel.create("나이키")); + Long brandId = brandJpaRepository.findByNameAndDeletedAtIsNull("나이키").get().getId(); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_BRANDS + "/" + brandId, HttpMethod.GET, null, responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().id()).isEqualTo(brandId), + () -> assertThat(response.getBody().data().name()).isEqualTo("나이키") + ); + } + + @DisplayName("존재하지 않는 브랜드를 조회하면, 404 NOT_FOUND 응답을 받는다.") + @Test + void throwsNotFound_whenBrandDoesNotExist() { + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_BRANDS + "/999", HttpMethod.GET, null, responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/brand/BrandV1ApiScenarioTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/brand/BrandV1ApiScenarioTest.java new file mode 100644 index 000000000..bbbdab78f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/brand/BrandV1ApiScenarioTest.java @@ -0,0 +1,133 @@ +package com.loopers.interfaces.brand; + +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 com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.brand.dto.AdminBrandV1Dto; +import com.loopers.interfaces.brand.dto.BrandV1Dto; +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.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DisplayName("Brand V1 API 시나리오 테스트") +class BrandV1ApiScenarioTest { + + private static final String ENDPOINT_BRANDS = "/api/v1/brands"; + private static final String ADMIN_ENDPOINT_BRANDS = "/api-admin/v1/brands"; + + private final TestRestTemplate testRestTemplate; + private final BrandJpaRepository brandJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public BrandV1ApiScenarioTest( + TestRestTemplate testRestTemplate, + BrandJpaRepository brandJpaRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.brandJpaRepository = brandJpaRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "loopers.admin"); + return headers; + } + + @DisplayName("브랜드 전체 플로우: 등록(Admin) -> 조회(Public) -> 수정(Admin) -> 조회(Public) -> 삭제(Admin) -> 조회(Public, 404)") + @Test + void fullBrandLifecycleScenario() { + // ===== 1단계: 브랜드 등록 (Admin API) ===== + AdminBrandV1Dto.RegisterRequest registerRequest = new AdminBrandV1Dto.RegisterRequest("나이키"); + + ParameterizedTypeReference> objectResponseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> registerResponse = + testRestTemplate.exchange(ADMIN_ENDPOINT_BRANDS, HttpMethod.POST, new HttpEntity<>(registerRequest, adminHeaders()), objectResponseType); + + assertAll( + "브랜드 등록 성공 검증", + () -> assertTrue(registerResponse.getStatusCode().is2xxSuccessful()), + () -> assertThat(registerResponse.getStatusCode()).isEqualTo(HttpStatus.OK) + ); + + Long brandId = brandJpaRepository.findByNameAndDeletedAtIsNull("나이키").get().getId(); + + // ===== 2단계: 브랜드 조회 (Public API) ===== + ParameterizedTypeReference> detailResponseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> getResponse = + testRestTemplate.exchange(ENDPOINT_BRANDS + "/" + brandId, HttpMethod.GET, null, detailResponseType); + + assertAll( + "브랜드 조회 성공 검증", + () -> assertTrue(getResponse.getStatusCode().is2xxSuccessful()), + () -> assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(getResponse.getBody()).isNotNull(), + () -> assertThat(getResponse.getBody().data().id()).isEqualTo(brandId), + () -> assertThat(getResponse.getBody().data().name()).isEqualTo("나이키") + ); + + // ===== 3단계: 브랜드 수정 (Admin API) ===== + AdminBrandV1Dto.UpdateRequest updateRequest = new AdminBrandV1Dto.UpdateRequest("아디다스"); + + ResponseEntity> updateResponse = + testRestTemplate.exchange(ADMIN_ENDPOINT_BRANDS + "/" + brandId, HttpMethod.PUT, new HttpEntity<>(updateRequest, adminHeaders()), objectResponseType); + + assertAll( + "브랜드 수정 성공 검증", + () -> assertTrue(updateResponse.getStatusCode().is2xxSuccessful()), + () -> assertThat(updateResponse.getStatusCode()).isEqualTo(HttpStatus.OK) + ); + + // ===== 4단계: 수정 후 조회 (Public API) ===== + ResponseEntity> getAfterUpdateResponse = + testRestTemplate.exchange(ENDPOINT_BRANDS + "/" + brandId, HttpMethod.GET, null, detailResponseType); + + assertAll( + "수정 후 조회 검증", + () -> assertTrue(getAfterUpdateResponse.getStatusCode().is2xxSuccessful()), + () -> assertThat(getAfterUpdateResponse.getBody()).isNotNull(), + () -> assertThat(getAfterUpdateResponse.getBody().data().name()).isEqualTo("아디다스") + ); + + // ===== 5단계: 브랜드 삭제 (Admin API) ===== + ResponseEntity> deleteResponse = + testRestTemplate.exchange(ADMIN_ENDPOINT_BRANDS + "/" + brandId, HttpMethod.DELETE, new HttpEntity<>(null, adminHeaders()), objectResponseType); + + assertAll( + "브랜드 삭제 성공 검증", + () -> assertTrue(deleteResponse.getStatusCode().is2xxSuccessful()), + () -> assertThat(deleteResponse.getStatusCode()).isEqualTo(HttpStatus.OK) + ); + + // ===== 6단계: 삭제 후 조회 (Public API, 404) ===== + ResponseEntity> getAfterDeleteResponse = + testRestTemplate.exchange(ENDPOINT_BRANDS + "/" + brandId, HttpMethod.GET, null, objectResponseType); + + assertAll( + "삭제 후 조회 실패 검증", + () -> assertTrue(getAfterDeleteResponse.getStatusCode().is4xxClientError()), + () -> assertThat(getAfterDeleteResponse.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) + ); + } +} From 828091d7a31d35cc8e08330db467a7c920484fa8 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Sat, 21 Feb 2026 00:45:18 +0900 Subject: [PATCH 033/108] =?UTF-8?q?chore:=20Brand=20API=20.http=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=ED=8C=8C=EC=9D=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- http/commerce-api/admin-brand-v1.http | 76 +++++++++++++++++++++++++++ http/commerce-api/brand-v1.http | 5 ++ 2 files changed, 81 insertions(+) create mode 100644 http/commerce-api/admin-brand-v1.http create mode 100644 http/commerce-api/brand-v1.http diff --git a/http/commerce-api/admin-brand-v1.http b/http/commerce-api/admin-brand-v1.http new file mode 100644 index 000000000..39bc78995 --- /dev/null +++ b/http/commerce-api/admin-brand-v1.http @@ -0,0 +1,76 @@ +### 브랜드 등록 - 성공 케이스 +POST {{commerce-api}}/api-admin/v1/brands +Content-Type: application/json +X-Loopers-Ldap: loopers.admin + +{ + "name": "나이키" +} + +### 브랜드 등록 - 브랜드명 빈값 (실패) +POST {{commerce-api}}/api-admin/v1/brands +Content-Type: application/json +X-Loopers-Ldap: loopers.admin + +{ + "name": "" +} + +### 브랜드 등록 - LDAP 헤더 누락 (실패 - 401) +POST {{commerce-api}}/api-admin/v1/brands +Content-Type: application/json + +{ + "name": "나이키" +} + +### 브랜드 등록 - 잘못된 LDAP 헤더 (실패 - 401) +POST {{commerce-api}}/api-admin/v1/brands +Content-Type: application/json +X-Loopers-Ldap: wrong.value + +{ + "name": "나이키" +} + +### 브랜드 목록 조회 - 기본 페이지 +GET {{commerce-api}}/api-admin/v1/brands?page=0&size=20 +X-Loopers-Ldap: loopers.admin + +### 브랜드 목록 조회 - 페이지 크기 지정 +GET {{commerce-api}}/api-admin/v1/brands?page=0&size=2 +X-Loopers-Ldap: loopers.admin + +### 브랜드 상세 조회 +GET {{commerce-api}}/api-admin/v1/brands/1 +X-Loopers-Ldap: loopers.admin + +### 브랜드 상세 조회 - 존재하지 않는 브랜드 (실패) +GET {{commerce-api}}/api-admin/v1/brands/999 +X-Loopers-Ldap: loopers.admin + +### 브랜드 수정 - 성공 케이스 +PUT {{commerce-api}}/api-admin/v1/brands/1 +Content-Type: application/json +X-Loopers-Ldap: loopers.admin + +{ + "name": "아디다스" +} + +### 브랜드 수정 - 브랜드명 빈값 (실패) +PUT {{commerce-api}}/api-admin/v1/brands/1 +Content-Type: application/json +X-Loopers-Ldap: loopers.admin + +{ + "name": "" +} + +### 브랜드 삭제 - 성공 케이스 +DELETE {{commerce-api}}/api-admin/v1/brands/1 +X-Loopers-Ldap: loopers.admin + +### 브랜드 삭제 - 존재하지 않는 브랜드 (실패) +DELETE {{commerce-api}}/api-admin/v1/brands/999 +X-Loopers-Ldap: loopers.admin diff --git a/http/commerce-api/brand-v1.http b/http/commerce-api/brand-v1.http new file mode 100644 index 000000000..3d6b70e97 --- /dev/null +++ b/http/commerce-api/brand-v1.http @@ -0,0 +1,5 @@ +### 브랜드 상세 조회 - 성공 케이스 +GET {{commerce-api}}/api/v1/brands/1 + +### 브랜드 상세 조회 - 존재하지 않는 브랜드 (실패) +GET {{commerce-api}}/api/v1/brands/999 From 6785a7fa6f145551a2e32a5a71128ff8a13b98b0 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Sat, 21 Feb 2026 16:57:35 +0900 Subject: [PATCH 034/108] =?UTF-8?q?feat:=20=EC=83=81=ED=92=88=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EC=97=94=ED=8B=B0=ED=8B=B0,=20VO,=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4,=20=EB=A6=AC=ED=8F=AC=EC=A7=80?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../application/product/ProductFacade.java | 63 +++++++++++ .../product/dto/ProductCommand.java | 8 ++ .../application/product/dto/ProductInfo.java | 32 ++++++ .../com/loopers/domain/product/Money.java | 30 +++++ .../domain/product/ProductErrorCode.java | 16 +++ .../loopers/domain/product/ProductModel.java | 104 ++++++++++++++++++ .../domain/product/ProductRepository.java | 22 ++++ .../domain/product/ProductService.java | 66 +++++++++++ .../com/loopers/domain/product/Stock.java | 41 +++++++ .../product/ProductJpaRepository.java | 22 ++++ .../product/ProductRepositoryImpl.java | 51 +++++++++ 11 files changed, 455 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/dto/ProductCommand.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/dto/ProductInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductErrorCode.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/Stock.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 diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java new file mode 100644 index 000000000..3f77a6080 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -0,0 +1,63 @@ +package com.loopers.application.product; + +import com.loopers.application.product.dto.ProductCommand; +import com.loopers.application.product.dto.ProductInfo; +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ProductFacade { + + private final ProductService productService; + private final BrandService brandService; + + @Transactional + public void register(ProductCommand.Register command) { + BrandModel brand = brandService.getById(command.brandId()); + productService.register(brand, command.name(), command.price(), command.stock()); + } + + @Transactional(readOnly = true) + public ProductInfo getById(Long id) { + ProductModel productModel = productService.getById(id); + return ProductInfo.from(productModel); + } + + @Transactional + public void update(Long id, ProductCommand.Update command) { + productService.update(id, command.name(), command.price(), command.stock()); + } + + @Transactional + public void delete(Long id) { + productService.delete(id); + } + + @Transactional(readOnly = true) + public Page getAll(Pageable pageable) { + return productService.getAll(pageable).map(ProductInfo::from); + } + + @Transactional(readOnly = true) + public Page getAllByBrandId(Long brandId, Pageable pageable) { + return productService.getAllByBrandId(brandId, pageable).map(ProductInfo::from); + } + + @Transactional(readOnly = true) + public Page getAllActive(Pageable pageable) { + return productService.getAllActive(pageable).map(ProductInfo::from); + } + + @Transactional(readOnly = true) + public Page getAllActiveByBrandId(Long brandId, Pageable pageable) { + return productService.getAllActiveByBrandId(brandId, pageable).map(ProductInfo::from); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/dto/ProductCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/product/dto/ProductCommand.java new file mode 100644 index 000000000..84575bfcd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/dto/ProductCommand.java @@ -0,0 +1,8 @@ +package com.loopers.application.product.dto; + +public class ProductCommand { + + public record Register(Long brandId, String name, int price, int stock) {} + + public record Update(String name, int price, int stock) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/dto/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/dto/ProductInfo.java new file mode 100644 index 000000000..74adb8dc1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/dto/ProductInfo.java @@ -0,0 +1,32 @@ +package com.loopers.application.product.dto; + +import com.loopers.domain.product.ProductModel; +import java.time.ZonedDateTime; + +public record ProductInfo( + Long id, + Long brandId, + String brandName, + String name, + int price, + int stock, + int likeCount, + ZonedDateTime createdAt, + ZonedDateTime updatedAt, + ZonedDateTime deletedAt +) { + public static ProductInfo from(ProductModel model) { + return new ProductInfo( + model.getId(), + model.getBrand().getId(), + model.getBrand().getName(), + model.getName(), + model.getPrice().getValue(), + model.getStock().getValue(), + model.getLikeCount(), + model.getCreatedAt(), + model.getUpdatedAt(), + model.getDeletedAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java new file mode 100644 index 000000000..c4be43537 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java @@ -0,0 +1,30 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Embeddable; +import lombok.EqualsAndHashCode; + +@Embeddable +@EqualsAndHashCode +public class Money { + + private int value; + + protected Money() {} + + public Money(int value) { + validate(value); + this.value = value; + } + + private void validate(int value) { + if (value < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격은 0 이상이어야 합니다."); + } + } + + public int getValue() { + return value; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductErrorCode.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductErrorCode.java new file mode 100644 index 000000000..16135bad1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductErrorCode.java @@ -0,0 +1,16 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum ProductErrorCode implements ErrorCode { + NOT_FOUND(HttpStatus.NOT_FOUND, "PRODUCT_001", "상품을 찾을 수 없습니다."); + + private final HttpStatus status; + private final String code; + private final String message; +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java new file mode 100644 index 000000000..a8d7e5153 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java @@ -0,0 +1,104 @@ +package com.loopers.domain.product; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.brand.BrandModel; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.ColumnDefault; + +@Getter +@Entity +@Table(name = "products") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProductModel extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "brand_id", nullable = false, foreignKey = @ForeignKey(value = jakarta.persistence.ConstraintMode.NO_CONSTRAINT)) + private BrandModel brand; + + @Column(name = "name", nullable = false) + private String name; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "price", nullable = false)) + private Money price; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "stock", nullable = false)) + private Stock stock; + + @Column(name = "like_count", nullable = false) + @ColumnDefault("0") + private int likeCount = 0; + + // === 생성 === // + + private ProductModel(BrandModel brand, String name, Money price, Stock stock) { + this.brand = brand; + this.name = name; + this.price = price; + this.stock = stock; + } + + public static ProductModel create(BrandModel brand, String name, int price, int stock) { + validateBrand(brand); + validateName(name); + return new ProductModel(brand, name, new Money(price), new Stock(stock)); + } + + // === 도메인 로직 === // + + public void update(String name, int price, int stock) { + validateName(name); + this.name = name; + this.price = new Money(price); + this.stock = new Stock(stock); + } + + public void decreaseStock(int quantity) { + this.stock.deduct(quantity); + } + + public boolean isSoldOut() { + return this.stock.getValue() == 0; + } + + public void addLikeCount() { + this.likeCount++; + } + + public void subtractLikeCount() { + if (this.likeCount > 0) { + this.likeCount--; + } + } + + // === 검증 === // + + private static void validateBrand(BrandModel brand) { + if (brand == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드는 필수값입니다."); + } + } + + private static void validateName(String name) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품명은 필수값입니다."); + } + if (name.length() > 99) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품명은 99자 이하여야 합니다."); + } + } +} 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..17a31b578 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -0,0 +1,22 @@ +package com.loopers.domain.product; + +import java.util.List; +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface ProductRepository { + ProductModel save(ProductModel productModel); + + Optional findById(Long id); + + Page findAll(Pageable pageable); + + Page findAllByBrandId(Long brandId, Pageable pageable); + + List findAllByBrandId(Long brandId); + + Page findAllActive(Pageable pageable); + + Page findAllActiveByBrandId(Long brandId, Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java new file mode 100644 index 000000000..4851c06fb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -0,0 +1,66 @@ +package com.loopers.domain.product; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.support.error.CoreException; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ProductService { + private final ProductRepository productRepository; + + @Transactional + public void register(BrandModel brand, String name, int price, int stock) { + ProductModel productModel = ProductModel.create(brand, name, price, stock); + productRepository.save(productModel); + } + + @Transactional(readOnly = true) + public ProductModel getById(Long id) { + return productRepository.findById(id) + .orElseThrow(() -> new CoreException(ProductErrorCode.NOT_FOUND)); + } + + @Transactional + public void update(Long id, String name, int price, int stock) { + ProductModel productModel = getById(id); + productModel.update(name, price, stock); + } + + @Transactional + public void delete(Long id) { + ProductModel productModel = getById(id); + productModel.delete(); + } + + @Transactional(readOnly = true) + public Page getAll(Pageable pageable) { + return productRepository.findAll(pageable); + } + + @Transactional(readOnly = true) + public Page getAllByBrandId(Long brandId, Pageable pageable) { + return productRepository.findAllByBrandId(brandId, pageable); + } + + @Transactional + public void softDeleteByBrandId(Long brandId) { + List products = productRepository.findAllByBrandId(brandId); + products.forEach(ProductModel::delete); + } + + @Transactional(readOnly = true) + public Page getAllActive(Pageable pageable) { + return productRepository.findAllActive(pageable); + } + + @Transactional(readOnly = true) + public Page getAllActiveByBrandId(Long brandId, Pageable pageable) { + return productRepository.findAllActiveByBrandId(brandId, pageable); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Stock.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Stock.java new file mode 100644 index 000000000..3f521f2ab --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Stock.java @@ -0,0 +1,41 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Embeddable; +import lombok.EqualsAndHashCode; + +@Embeddable +@EqualsAndHashCode +public class Stock { + + private int value; + + protected Stock() {} + + public Stock(int value) { + validate(value); + this.value = value; + } + + private void validate(int value) { + if (value < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고는 0 이상이어야 합니다."); + } + } + + public void deduct(int quantity) { + if (!hasEnough(quantity)) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다."); + } + this.value -= quantity; + } + + public boolean hasEnough(int quantity) { + return this.value >= quantity; + } + + public int getValue() { + return value; + } +} 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..9ce20d3fa --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -0,0 +1,22 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ProductModel; +import java.util.List; +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProductJpaRepository extends JpaRepository { + Optional findByIdAndDeletedAtIsNull(Long id); + + Page findAllByDeletedAtIsNull(Pageable pageable); + + Page findAllByBrandIdAndDeletedAtIsNull(Long brandId, Pageable pageable); + + List findAllByBrandIdAndDeletedAtIsNull(Long brandId); + + Page findAllByDeletedAtIsNullAndBrandDeletedAtIsNull(Pageable pageable); + + Page findAllByBrandIdAndDeletedAtIsNullAndBrandDeletedAtIsNull(Long brandId, Pageable pageable); +} 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..a4434071e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -0,0 +1,51 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductRepository; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class ProductRepositoryImpl implements ProductRepository { + private final ProductJpaRepository productJpaRepository; + + @Override + public ProductModel save(ProductModel productModel) { + return productJpaRepository.save(productModel); + } + + @Override + public Optional findById(Long id) { + return productJpaRepository.findByIdAndDeletedAtIsNull(id); + } + + @Override + public Page findAll(Pageable pageable) { + return productJpaRepository.findAllByDeletedAtIsNull(pageable); + } + + @Override + public Page findAllByBrandId(Long brandId, Pageable pageable) { + return productJpaRepository.findAllByBrandIdAndDeletedAtIsNull(brandId, pageable); + } + + @Override + public List findAllByBrandId(Long brandId) { + return productJpaRepository.findAllByBrandIdAndDeletedAtIsNull(brandId); + } + + @Override + public Page findAllActive(Pageable pageable) { + return productJpaRepository.findAllByDeletedAtIsNullAndBrandDeletedAtIsNull(pageable); + } + + @Override + public Page findAllActiveByBrandId(Long brandId, Pageable pageable) { + return productJpaRepository.findAllByBrandIdAndDeletedAtIsNullAndBrandDeletedAtIsNull(brandId, pageable); + } +} From aeba061323b8153ca59e1e5a8595989915639d77 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Sat, 21 Feb 2026 16:57:39 +0900 Subject: [PATCH 035/108] =?UTF-8?q?feat:=20=EC=83=81=ED=92=88=20Admin=20CR?= =?UTF-8?q?UD=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../product/AdminProductV1ApiSpec.java | 63 ++++++++++++ .../product/AdminProductV1Controller.java | 79 +++++++++++++++ .../product/dto/AdminProductV1Dto.java | 97 +++++++++++++++++++ 3 files changed, 239 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/product/AdminProductV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/product/AdminProductV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/product/dto/AdminProductV1Dto.java diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/product/AdminProductV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/AdminProductV1ApiSpec.java new file mode 100644 index 000000000..d194528e7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/AdminProductV1ApiSpec.java @@ -0,0 +1,63 @@ +package com.loopers.interfaces.product; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.product.dto.AdminProductV1Dto; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Admin Product V1 API", description = "상품 관리자 API 입니다.") +public interface AdminProductV1ApiSpec { + + @Operation( + summary = "상품 등록", + description = "새로운 상품을 등록합니다." + ) + ApiResponse register( + @RequestBody(description = "상품 등록 요청 정보") + AdminProductV1Dto.RegisterRequest request + ); + + @Operation( + summary = "상품 목록 조회", + description = "상품 목록을 페이지네이션으로 조회합니다." + ) + ApiResponse list( + @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") + int page, + @Parameter(description = "페이지 크기", example = "20") + int size, + @Parameter(description = "브랜드 ID (선택)", example = "1") + Long brandId + ); + + @Operation( + summary = "상품 상세 조회", + description = "특정 상품의 상세 정보를 조회합니다." + ) + ApiResponse getById( + @Parameter(description = "상품 ID", required = true, example = "1") + Long productId + ); + + @Operation( + summary = "상품 수정", + description = "상품 정보를 수정합니다." + ) + ApiResponse update( + @Parameter(description = "상품 ID", required = true, example = "1") + Long productId, + @RequestBody(description = "상품 수정 요청 정보") + AdminProductV1Dto.UpdateRequest request + ); + + @Operation( + summary = "상품 삭제", + description = "상품을 삭제합니다." + ) + ApiResponse delete( + @Parameter(description = "상품 ID", required = true, example = "1") + Long productId + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/product/AdminProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/AdminProductV1Controller.java new file mode 100644 index 000000000..ee0f4ce3e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/AdminProductV1Controller.java @@ -0,0 +1,79 @@ +package com.loopers.interfaces.product; + +import com.loopers.application.product.ProductFacade; +import com.loopers.application.product.dto.ProductInfo; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.product.dto.AdminProductV1Dto; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api-admin/v1/products") +public class AdminProductV1Controller implements AdminProductV1ApiSpec { + + private final ProductFacade productFacade; + + @PostMapping + @Override + public ApiResponse register( + @Valid @RequestBody AdminProductV1Dto.RegisterRequest request + ) { + productFacade.register(request.toCommand()); + return ApiResponse.success(); + } + + @GetMapping + @Override + public ApiResponse list( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(required = false) Long brandId + ) { + Page productInfoPage = brandId != null + ? productFacade.getAllByBrandId(brandId, PageRequest.of(page, size)) + : productFacade.getAll(PageRequest.of(page, size)); + + AdminProductV1Dto.ListResponse listResponse = new AdminProductV1Dto.ListResponse( + productInfoPage.getNumber(), + productInfoPage.getSize(), + productInfoPage.getTotalElements(), + productInfoPage.getTotalPages(), + productInfoPage.getContent().stream() + .map(AdminProductV1Dto.ListResponse.ListItem::from) + .toList() + ); + return ApiResponse.success(listResponse); + } + + @GetMapping("/{productId}") + @Override + public ApiResponse getById( + @PathVariable Long productId + ) { + ProductInfo productInfo = productFacade.getById(productId); + return ApiResponse.success(AdminProductV1Dto.DetailResponse.from(productInfo)); + } + + @PutMapping("/{productId}") + @Override + public ApiResponse update( + @PathVariable Long productId, + @Valid @RequestBody AdminProductV1Dto.UpdateRequest request + ) { + productFacade.update(productId, request.toCommand()); + return ApiResponse.success(); + } + + @DeleteMapping("/{productId}") + @Override + public ApiResponse delete( + @PathVariable Long productId + ) { + productFacade.delete(productId); + return ApiResponse.success(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/product/dto/AdminProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/dto/AdminProductV1Dto.java new file mode 100644 index 000000000..0565bcace --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/dto/AdminProductV1Dto.java @@ -0,0 +1,97 @@ +package com.loopers.interfaces.product.dto; + +import com.loopers.application.product.dto.ProductCommand; +import com.loopers.application.product.dto.ProductInfo; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.time.ZonedDateTime; +import java.util.List; + +public class AdminProductV1Dto { + + public record RegisterRequest( + @NotNull(message = "브랜드 ID는 필수 입력값입니다.") + Long brandId, + @NotBlank(message = "상품명은 필수 입력값입니다.") + @Size(max = 99, message = "상품명은 99자 이하여야 합니다.") + String name, + @NotNull(message = "가격은 필수 입력값입니다.") + @Min(value = 0, message = "가격은 0 이상이어야 합니다.") + Integer price, + @NotNull(message = "재고는 필수 입력값입니다.") + @Min(value = 0, message = "재고는 0 이상이어야 합니다.") + Integer stock + ) { + public ProductCommand.Register toCommand() { + return new ProductCommand.Register(brandId, name, price, stock); + } + } + + public record UpdateRequest( + @NotBlank(message = "상품명은 필수 입력값입니다.") + @Size(max = 99, message = "상품명은 99자 이하여야 합니다.") + String name, + @NotNull(message = "가격은 필수 입력값입니다.") + @Min(value = 0, message = "가격은 0 이상이어야 합니다.") + Integer price, + @NotNull(message = "재고는 필수 입력값입니다.") + @Min(value = 0, message = "재고는 0 이상이어야 합니다.") + Integer stock + ) { + public ProductCommand.Update toCommand() { + return new ProductCommand.Update(name, price, stock); + } + } + + public record DetailResponse( + Long id, + Long brandId, + String brandName, + String name, + int price, + int stock, + int likeCount, + ZonedDateTime createdAt, + ZonedDateTime updatedAt, + ZonedDateTime deletedAt + ) { + public static DetailResponse from(ProductInfo info) { + return new DetailResponse( + info.id(), info.brandId(), info.brandName(), + info.name(), info.price(), info.stock(), info.likeCount(), + info.createdAt(), info.updatedAt(), info.deletedAt() + ); + } + } + + public record ListResponse( + int page, + int size, + long totalElements, + int totalPages, + List items + ) { + public record ListItem( + Long id, + Long brandId, + String brandName, + String name, + int price, + int stock, + int likeCount, + ZonedDateTime createdAt, + ZonedDateTime updatedAt, + ZonedDateTime deletedAt + ) { + public static ListItem from(ProductInfo info) { + return new ListItem( + info.id(), info.brandId(), info.brandName(), + info.name(), info.price(), info.stock(), info.likeCount(), + info.createdAt(), info.updatedAt(), info.deletedAt() + ); + } + } + } +} From 8baa37c9fe47e599ef9b7104f78d3a72db9ec1d9 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Sat, 21 Feb 2026 16:57:43 +0900 Subject: [PATCH 036/108] =?UTF-8?q?feat:=20=EC=83=81=ED=92=88=20Public=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../interfaces/product/ProductV1ApiSpec.java | 35 +++++++++++ .../product/ProductV1Controller.java | 61 +++++++++++++++++++ .../interfaces/product/dto/ProductV1Dto.java | 48 +++++++++++++++ 3 files changed, 144 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/product/dto/ProductV1Dto.java diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1ApiSpec.java new file mode 100644 index 000000000..4dafcf4ab --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1ApiSpec.java @@ -0,0 +1,35 @@ +package com.loopers.interfaces.product; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.product.dto.ProductV1Dto; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Product V1 API", description = "상품 API 입니다.") +public interface ProductV1ApiSpec { + + @Operation( + summary = "상품 목록 조회", + description = "상품 목록을 조회합니다. 브랜드 필터링, 정렬, 페이지네이션을 지원합니다." + ) + ApiResponse list( + @Parameter(description = "브랜드 ID (선택)", example = "1") + Long brandId, + @Parameter(description = "정렬 기준: latest / price_asc / likes_desc", example = "latest") + String sort, + @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") + int page, + @Parameter(description = "페이지 크기", example = "20") + int size + ); + + @Operation( + summary = "상품 상세 조회", + description = "특정 상품의 정보를 조회합니다." + ) + ApiResponse getById( + @Parameter(description = "상품 ID", required = true, example = "1") + Long productId + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1Controller.java new file mode 100644 index 000000000..65f0e0096 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1Controller.java @@ -0,0 +1,61 @@ +package com.loopers.interfaces.product; + +import com.loopers.application.product.ProductFacade; +import com.loopers.application.product.dto.ProductInfo; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.product.dto.ProductV1Dto; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/products") +public class ProductV1Controller implements ProductV1ApiSpec { + + private final ProductFacade productFacade; + + @GetMapping + @Override + public ApiResponse list( + @RequestParam(required = false) Long brandId, + @RequestParam(defaultValue = "latest") String sort, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + PageRequest pageRequest = PageRequest.of(page, size, toSort(sort)); + Page productInfoPage = brandId != null + ? productFacade.getAllActiveByBrandId(brandId, pageRequest) + : productFacade.getAllActive(pageRequest); + + ProductV1Dto.ListResponse listResponse = new ProductV1Dto.ListResponse( + productInfoPage.getNumber(), + productInfoPage.getSize(), + productInfoPage.getTotalElements(), + productInfoPage.getTotalPages(), + productInfoPage.getContent().stream() + .map(ProductV1Dto.ListResponse.ListItem::from) + .toList() + ); + return ApiResponse.success(listResponse); + } + + @GetMapping("/{productId}") + @Override + public ApiResponse getById( + @PathVariable Long productId + ) { + ProductInfo productInfo = productFacade.getById(productId); + return ApiResponse.success(ProductV1Dto.DetailResponse.from(productInfo)); + } + + private Sort toSort(String sort) { + return switch (sort) { + case "price_asc" -> Sort.by(Sort.Direction.ASC, "price.value"); + case "likes_desc" -> Sort.by(Sort.Direction.DESC, "likeCount"); + default -> Sort.by(Sort.Direction.DESC, "createdAt"); + }; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/product/dto/ProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/dto/ProductV1Dto.java new file mode 100644 index 000000000..ce051f934 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/dto/ProductV1Dto.java @@ -0,0 +1,48 @@ +package com.loopers.interfaces.product.dto; + +import com.loopers.application.product.dto.ProductInfo; +import java.util.List; + +public class ProductV1Dto { + + public record DetailResponse( + Long id, + Long brandId, + String brandName, + String name, + int price, + int stock, + int likeCount + ) { + public static DetailResponse from(ProductInfo info) { + return new DetailResponse( + info.id(), info.brandId(), info.brandName(), + info.name(), info.price(), info.stock(), info.likeCount() + ); + } + } + + public record ListResponse( + int page, + int size, + long totalElements, + int totalPages, + List items + ) { + public record ListItem( + Long id, + Long brandId, + String brandName, + String name, + int price, + int likeCount + ) { + public static ListItem from(ProductInfo info) { + return new ListItem( + info.id(), info.brandId(), info.brandName(), + info.name(), info.price(), info.likeCount() + ); + } + } + } +} From a0dc1bc2ff3fd5df751dec298734ed890f448070 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Sat, 21 Feb 2026 16:57:47 +0900 Subject: [PATCH 037/108] =?UTF-8?q?feat:=20=EB=B8=8C=EB=9E=9C=EB=93=9C=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EC=8B=9C=20=EC=83=81=ED=92=88=20=EC=97=B0?= =?UTF-8?q?=EC=87=84=20soft=20delete=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../java/com/loopers/application/brand/BrandFacade.java | 3 +++ .../com/loopers/application/brand/BrandFacadeTest.java | 7 ++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java index 526b2fa7a..51e15458d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -4,6 +4,7 @@ import com.loopers.application.brand.dto.BrandInfo; import com.loopers.domain.brand.BrandModel; import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.ProductService; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -15,6 +16,7 @@ public class BrandFacade { private final BrandService brandService; + private final ProductService productService; @Transactional public void register(BrandCommand.Register command) { @@ -35,6 +37,7 @@ public void update(Long id, BrandCommand.Update command) { @Transactional public void delete(Long id) { brandService.delete(id); + productService.softDeleteByBrandId(id); } @Transactional(readOnly = true) diff --git a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java index bddaa4dc1..768dcd6ac 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java @@ -8,6 +8,7 @@ import com.loopers.application.brand.dto.BrandInfo; import com.loopers.domain.brand.BrandModel; import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.ProductService; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -23,6 +24,9 @@ class BrandFacadeTest { @Mock private BrandService brandService; + @Mock + private ProductService productService; + @InjectMocks private BrandFacade brandFacade; @@ -87,13 +91,14 @@ class Update { class Delete { @Test - @DisplayName("id를 BrandService.delete에 전달한다") + @DisplayName("id를 BrandService.delete에 전달하고 해당 브랜드의 상품을 일괄 삭제한다") void delete_호출_검증() { // arrange & act brandFacade.delete(1L); // assert verify(brandService).delete(1L); + verify(productService).softDeleteByBrandId(1L); } } } From a0863247b66f3bc4dc02de74b1722b4e85311c5e Mon Sep 17 00:00:00 2001 From: plan11plan Date: Sat, 21 Feb 2026 16:57:53 +0900 Subject: [PATCH 038/108] =?UTF-8?q?test:=20=EC=83=81=ED=92=88=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=B0=8F=20E2E=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../domain/product/FakeProductRepository.java | 114 +++++ .../com/loopers/domain/product/MoneyTest.java | 45 ++ .../domain/product/ProductModelTest.java | 237 +++++++++ .../domain/product/ProductServiceTest.java | 227 +++++++++ .../com/loopers/domain/product/StockTest.java | 113 +++++ .../product/AdminProductV1ApiE2ETest.java | 452 ++++++++++++++++++ .../product/ProductV1ApiE2ETest.java | 206 ++++++++ http/commerce-api/admin-product-v1.http | 96 ++++ http/commerce-api/product-v1.http | 20 + 9 files changed, 1510 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/FakeProductRepository.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/MoneyTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.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/product/StockTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/product/AdminProductV1ApiE2ETest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/product/ProductV1ApiE2ETest.java create mode 100644 http/commerce-api/admin-product-v1.http create mode 100644 http/commerce-api/product-v1.http diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/FakeProductRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/FakeProductRepository.java new file mode 100644 index 000000000..28ff91773 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/FakeProductRepository.java @@ -0,0 +1,114 @@ +package com.loopers.domain.product; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +public class FakeProductRepository implements ProductRepository { + + private final Map store = new HashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(1); + + @Override + public ProductModel save(ProductModel productModel) { + if (productModel.getId() == 0L) { + try { + var idField = productModel.getClass().getSuperclass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(productModel, idGenerator.getAndIncrement()); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException(e); + } + } + store.put(productModel.getId(), productModel); + return productModel; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)) + .filter(product -> product.getDeletedAt() == null); + } + + @Override + public Page findAll(Pageable pageable) { + List activeModels = store.values().stream() + .filter(product -> product.getDeletedAt() == null) + .toList(); + + int start = (int) pageable.getOffset(); + int end = Math.min(start + pageable.getPageSize(), activeModels.size()); + + List pageContent = start >= activeModels.size() + ? new ArrayList<>() + : activeModels.subList(start, end); + + return new PageImpl<>(pageContent, pageable, activeModels.size()); + } + + @Override + public Page findAllByBrandId(Long brandId, Pageable pageable) { + List filtered = store.values().stream() + .filter(product -> product.getDeletedAt() == null) + .filter(product -> product.getBrand().getId().equals(brandId)) + .toList(); + + int start = (int) pageable.getOffset(); + int end = Math.min(start + pageable.getPageSize(), filtered.size()); + + List pageContent = start >= filtered.size() + ? new ArrayList<>() + : filtered.subList(start, end); + + return new PageImpl<>(pageContent, pageable, filtered.size()); + } + + @Override + public List findAllByBrandId(Long brandId) { + return store.values().stream() + .filter(product -> product.getDeletedAt() == null) + .filter(product -> product.getBrand().getId().equals(brandId)) + .toList(); + } + + @Override + public Page findAllActive(Pageable pageable) { + List activeModels = store.values().stream() + .filter(product -> product.getDeletedAt() == null) + .filter(product -> product.getBrand().getDeletedAt() == null) + .toList(); + + int start = (int) pageable.getOffset(); + int end = Math.min(start + pageable.getPageSize(), activeModels.size()); + + List pageContent = start >= activeModels.size() + ? new ArrayList<>() + : activeModels.subList(start, end); + + return new PageImpl<>(pageContent, pageable, activeModels.size()); + } + + @Override + public Page findAllActiveByBrandId(Long brandId, Pageable pageable) { + List filtered = store.values().stream() + .filter(product -> product.getDeletedAt() == null) + .filter(product -> product.getBrand().getDeletedAt() == null) + .filter(product -> product.getBrand().getId().equals(brandId)) + .toList(); + + int start = (int) pageable.getOffset(); + int end = Math.min(start + pageable.getPageSize(), filtered.size()); + + List pageContent = start >= filtered.size() + ? new ArrayList<>() + : filtered.subList(start, end); + + return new PageImpl<>(pageContent, pageable, filtered.size()); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/MoneyTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/MoneyTest.java new file mode 100644 index 000000000..3116a9b2e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/MoneyTest.java @@ -0,0 +1,45 @@ +package com.loopers.domain.product; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class MoneyTest { + + @DisplayName("Money를 생성할 때, ") + @Nested + class Create { + + @DisplayName("0 이상의 값이면 정상적으로 생성된다.") + @Test + void create_whenValidValue() { + // act + Money money = new Money(10000); + + // assert + assertThat(money.getValue()).isEqualTo(10000); + } + + @DisplayName("0이면 정상적으로 생성된다.") + @Test + void create_whenZero() { + // act + Money money = new Money(0); + + // assert + assertThat(money.getValue()).isEqualTo(0); + } + + @DisplayName("음수이면 예외가 발생한다.") + @Test + void create_whenNegative() { + assertThatThrownBy(() -> new Money(-1)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("가격은 0 이상이어야 합니다."); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java new file mode 100644 index 000000000..89731450a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java @@ -0,0 +1,237 @@ +package com.loopers.domain.product; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class ProductModelTest { + + private BrandModel createBrand() { + return BrandModel.create("Nike"); + } + + @DisplayName("상품을 생성할 때, ") + @Nested + class Create { + + @DisplayName("유효한 값이 주어지면, 정상적으로 생성된다.") + @Test + void create_whenValidValues() { + // act + ProductModel product = ProductModel.create(createBrand(), "에어맥스", 150000, 100); + + // assert + assertThat(product.getName()).isEqualTo("에어맥스"); + assertThat(product.getPrice().getValue()).isEqualTo(150000); + assertThat(product.getStock().getValue()).isEqualTo(100); + assertThat(product.getLikeCount()).isEqualTo(0); + assertThat(product.getBrand().getName()).isEqualTo("Nike"); + } + + @DisplayName("브랜드가 null이면 예외가 발생한다.") + @Test + void create_whenBrandIsNull() { + assertThatThrownBy(() -> ProductModel.create(null, "에어맥스", 150000, 100)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("브랜드는 필수값입니다."); + } + + @DisplayName("상품명이 null이면 예외가 발생한다.") + @Test + void create_whenNameIsNull() { + assertThatThrownBy(() -> ProductModel.create(createBrand(), null, 150000, 100)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("상품명은 필수값입니다."); + } + + @DisplayName("상품명이 빈 문자열이면 예외가 발생한다.") + @Test + void create_whenNameIsBlank() { + assertThatThrownBy(() -> ProductModel.create(createBrand(), " ", 150000, 100)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("상품명은 필수값입니다."); + } + + @DisplayName("상품명이 100자 이상이면 예외가 발생한다.") + @Test + void create_whenNameTooLong() { + String longName = "a".repeat(100); + + assertThatThrownBy(() -> ProductModel.create(createBrand(), longName, 150000, 100)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("상품명은 99자 이하여야 합니다."); + } + + @DisplayName("가격이 음수이면 예외가 발생한다.") + @Test + void create_whenPriceIsNegative() { + assertThatThrownBy(() -> ProductModel.create(createBrand(), "에어맥스", -1, 100)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("가격은 0 이상이어야 합니다."); + } + + @DisplayName("재고가 음수이면 예외가 발생한다.") + @Test + void create_whenStockIsNegative() { + assertThatThrownBy(() -> ProductModel.create(createBrand(), "에어맥스", 150000, -1)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("재고는 0 이상이어야 합니다."); + } + } + + @DisplayName("상품을 수정할 때, ") + @Nested + class Update { + + @DisplayName("유효한 값이 주어지면, 정상적으로 수정된다.") + @Test + void update_whenValidValues() { + // arrange + ProductModel product = ProductModel.create(createBrand(), "에어맥스", 150000, 100); + + // act + product.update("에어포스", 120000, 50); + + // assert + assertThat(product.getName()).isEqualTo("에어포스"); + assertThat(product.getPrice().getValue()).isEqualTo(120000); + assertThat(product.getStock().getValue()).isEqualTo(50); + } + + @DisplayName("상품명이 null이면 예외가 발생한다.") + @Test + void update_whenNameIsNull() { + // arrange + ProductModel product = ProductModel.create(createBrand(), "에어맥스", 150000, 100); + + // act & assert + assertThatThrownBy(() -> product.update(null, 120000, 50)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("상품명은 필수값입니다."); + } + } + + @DisplayName("재고를 차감할 때, ") + @Nested + class DecreaseStock { + + @DisplayName("충분한 재고가 있으면 정상적으로 차감된다.") + @Test + void decreaseStock_whenEnough() { + // arrange + ProductModel product = ProductModel.create(createBrand(), "에어맥스", 150000, 10); + + // act + product.decreaseStock(3); + + // assert + assertThat(product.getStock().getValue()).isEqualTo(7); + } + + @DisplayName("재고가 부족하면 예외가 발생한다.") + @Test + void decreaseStock_whenInsufficient() { + // arrange + ProductModel product = ProductModel.create(createBrand(), "에어맥스", 150000, 5); + + // act & assert + assertThatThrownBy(() -> product.decreaseStock(6)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("재고가 부족합니다."); + } + } + + @DisplayName("품절 여부를 확인할 때, ") + @Nested + class IsSoldOut { + + @DisplayName("재고가 0이면 품절이다.") + @Test + void isSoldOut_whenStockIsZero() { + // arrange + ProductModel product = ProductModel.create(createBrand(), "에어맥스", 150000, 0); + + // act & assert + assertThat(product.isSoldOut()).isTrue(); + } + + @DisplayName("재고가 있으면 품절이 아니다.") + @Test + void isSoldOut_whenStockExists() { + // arrange + ProductModel product = ProductModel.create(createBrand(), "에어맥스", 150000, 1); + + // act & assert + assertThat(product.isSoldOut()).isFalse(); + } + } + + @DisplayName("좋아요 수를 변경할 때, ") + @Nested + class LikeCount { + + @DisplayName("addLikeCount() 호출 시 1 증가한다.") + @Test + void addLikeCount() { + // arrange + ProductModel product = ProductModel.create(createBrand(), "에어맥스", 150000, 100); + + // act + product.addLikeCount(); + + // assert + assertThat(product.getLikeCount()).isEqualTo(1); + } + + @DisplayName("subtractLikeCount() 호출 시 1 감소한다.") + @Test + void subtractLikeCount() { + // arrange + ProductModel product = ProductModel.create(createBrand(), "에어맥스", 150000, 100); + product.addLikeCount(); + product.addLikeCount(); + + // act + product.subtractLikeCount(); + + // assert + assertThat(product.getLikeCount()).isEqualTo(1); + } + + @DisplayName("좋아요 수가 0일 때 subtractLikeCount() 호출 시 0을 유지한다.") + @Test + void subtractLikeCount_whenZero() { + // arrange + ProductModel product = ProductModel.create(createBrand(), "에어맥스", 150000, 100); + + // act + product.subtractLikeCount(); + + // assert + assertThat(product.getLikeCount()).isEqualTo(0); + } + } + + @DisplayName("상품을 삭제할 때, ") + @Nested + class Delete { + + @DisplayName("delete() 호출 시 deletedAt이 설정된다.") + @Test + void delete_whenCalled() { + // arrange + ProductModel product = ProductModel.create(createBrand(), "에어맥스", 150000, 100); + + // act + product.delete(); + + // assert + assertThat(product.getDeletedAt()).isNotNull(); + } + } +} 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..94fd187a6 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java @@ -0,0 +1,227 @@ +package com.loopers.domain.product; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; + +class ProductServiceTest { + + private ProductService productService; + private FakeProductRepository productRepository; + private BrandModel brand; + + @BeforeEach + void setUp() { + productRepository = new FakeProductRepository(); + productService = new ProductService(productRepository); + brand = createBrandWithId("Nike", 1L); + } + + private BrandModel createBrandWithId(String name, Long id) { + BrandModel brandModel = BrandModel.create(name); + try { + var idField = brandModel.getClass().getSuperclass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(brandModel, id); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException(e); + } + return brandModel; + } + + @DisplayName("상품을 등록할 때, ") + @Nested + class Register { + + @DisplayName("유효한 값이 주어지면, 정상적으로 등록된다.") + @Test + void register_whenValidValues() { + // act + productService.register(brand, "에어맥스", 150000, 100); + + // assert + Page all = productRepository.findAll(PageRequest.of(0, 20)); + assertThat(all.getTotalElements()).isEqualTo(1); + assertThat(all.getContent().get(0).getName()).isEqualTo("에어맥스"); + } + } + + @DisplayName("상품을 조회할 때, ") + @Nested + class GetById { + + @DisplayName("존재하는 상품 ID가 주어지면, 정상적으로 조회된다.") + @Test + void getById_whenExists() { + // arrange + productService.register(brand, "에어맥스", 150000, 100); + Long savedId = productRepository.findAll(PageRequest.of(0, 20)).getContent().get(0).getId(); + + // act + ProductModel found = productService.getById(savedId); + + // assert + assertThat(found.getName()).isEqualTo("에어맥스"); + } + + @DisplayName("존재하지 않는 상품 ID이면 NOT_FOUND 예외가 발생한다.") + @Test + void getById_whenNotExists() { + assertThatThrownBy(() -> productService.getById(999L)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorCode()).isEqualTo(ProductErrorCode.NOT_FOUND)); + } + + @DisplayName("삭제된 상품 ID이면 NOT_FOUND 예외가 발생한다.") + @Test + void getById_whenDeleted() { + // arrange + productService.register(brand, "에어맥스", 150000, 100); + Long savedId = productRepository.findAll(PageRequest.of(0, 20)).getContent().get(0).getId(); + productService.delete(savedId); + + // act & assert + assertThatThrownBy(() -> productService.getById(savedId)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorCode()).isEqualTo(ProductErrorCode.NOT_FOUND)); + } + } + + @DisplayName("상품을 수정할 때, ") + @Nested + class Update { + + @DisplayName("유효한 값이 주어지면, 정상적으로 수정된다.") + @Test + void update_whenValidValues() { + // arrange + productService.register(brand, "에어맥스", 150000, 100); + Long savedId = productRepository.findAll(PageRequest.of(0, 20)).getContent().get(0).getId(); + + // act + productService.update(savedId, "에어포스", 120000, 50); + + // assert + ProductModel updated = productService.getById(savedId); + assertThat(updated.getName()).isEqualTo("에어포스"); + assertThat(updated.getPrice().getValue()).isEqualTo(120000); + assertThat(updated.getStock().getValue()).isEqualTo(50); + } + + @DisplayName("존재하지 않는 상품이면 NOT_FOUND 예외가 발생한다.") + @Test + void update_whenNotExists() { + assertThatThrownBy(() -> productService.update(999L, "에어포스", 120000, 50)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorCode()).isEqualTo(ProductErrorCode.NOT_FOUND)); + } + } + + @DisplayName("상품을 삭제할 때, ") + @Nested + class Delete { + + @DisplayName("존재하는 상품 ID가 주어지면, 정상적으로 삭제된다.") + @Test + void delete_whenExists() { + // arrange + productService.register(brand, "에어맥스", 150000, 100); + Long savedId = productRepository.findAll(PageRequest.of(0, 20)).getContent().get(0).getId(); + + // act + productService.delete(savedId); + + // assert + assertThatThrownBy(() -> productService.getById(savedId)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorCode()).isEqualTo(ProductErrorCode.NOT_FOUND)); + } + } + + @DisplayName("상품 목록을 조회할 때, ") + @Nested + class GetAll { + + @DisplayName("등록된 상품이 있으면, 페이지네이션된 목록을 반환한다.") + @Test + void getAll_whenProductsExist() { + // arrange + productService.register(brand, "에어맥스", 150000, 100); + productService.register(brand, "에어포스", 120000, 50); + productService.register(brand, "조던1", 200000, 30); + + // act + Page result = productService.getAll(PageRequest.of(0, 2)); + + // assert + assertThat(result.getContent()).hasSize(2); + assertThat(result.getTotalElements()).isEqualTo(3); + assertThat(result.getTotalPages()).isEqualTo(2); + } + + @DisplayName("삭제된 상품은 목록에 포함되지 않는다.") + @Test + void getAll_excludesDeletedProducts() { + // arrange + productService.register(brand, "에어맥스", 150000, 100); + productService.register(brand, "에어포스", 120000, 50); + Long firstId = productRepository.findAll(PageRequest.of(0, 20)).getContent().get(0).getId(); + productService.delete(firstId); + + // act + Page result = productService.getAll(PageRequest.of(0, 20)); + + // assert + assertThat(result.getTotalElements()).isEqualTo(1); + } + } + + @DisplayName("브랜드별 상품 목록을 조회할 때, ") + @Nested + class GetAllByBrandId { + + @DisplayName("해당 브랜드의 상품만 반환한다.") + @Test + void getAllByBrandId_whenExists() { + // arrange + BrandModel adidasBrand = createBrandWithId("Adidas", 2L); + productService.register(brand, "에어맥스", 150000, 100); + productService.register(adidasBrand, "울트라부스트", 180000, 80); + + // act + Page result = productService.getAllByBrandId(1L, PageRequest.of(0, 20)); + + // assert + assertThat(result.getTotalElements()).isEqualTo(1); + assertThat(result.getContent().get(0).getName()).isEqualTo("에어맥스"); + } + } + + @DisplayName("브랜드 삭제 시 상품을 일괄 soft delete할 때, ") + @Nested + class SoftDeleteByBrandId { + + @DisplayName("해당 브랜드의 모든 상품이 soft delete된다.") + @Test + void softDeleteByBrandId_whenProductsExist() { + // arrange + productService.register(brand, "에어맥스", 150000, 100); + productService.register(brand, "에어포스", 120000, 50); + + // act + productService.softDeleteByBrandId(brand.getId()); + + // assert + Page result = productService.getAll(PageRequest.of(0, 20)); + assertThat(result.getTotalElements()).isEqualTo(0); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/StockTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/StockTest.java new file mode 100644 index 000000000..a33890f84 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/StockTest.java @@ -0,0 +1,113 @@ +package com.loopers.domain.product; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class StockTest { + + @DisplayName("Stock을 생성할 때, ") + @Nested + class Create { + + @DisplayName("0 이상의 값이면 정상적으로 생성된다.") + @Test + void create_whenValidValue() { + // act + Stock stock = new Stock(100); + + // assert + assertThat(stock.getValue()).isEqualTo(100); + } + + @DisplayName("0이면 정상적으로 생성된다.") + @Test + void create_whenZero() { + // act + Stock stock = new Stock(0); + + // assert + assertThat(stock.getValue()).isEqualTo(0); + } + + @DisplayName("음수이면 예외가 발생한다.") + @Test + void create_whenNegative() { + assertThatThrownBy(() -> new Stock(-1)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("재고는 0 이상이어야 합니다."); + } + } + + @DisplayName("재고를 차감할 때, ") + @Nested + class Deduct { + + @DisplayName("충분한 재고가 있으면 정상적으로 차감된다.") + @Test + void deduct_whenEnoughStock() { + // arrange + Stock stock = new Stock(10); + + // act + stock.deduct(3); + + // assert + assertThat(stock.getValue()).isEqualTo(7); + } + + @DisplayName("재고가 부족하면 예외가 발생한다.") + @Test + void deduct_whenInsufficientStock() { + // arrange + Stock stock = new Stock(5); + + // act & assert + assertThatThrownBy(() -> stock.deduct(6)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("재고가 부족합니다."); + } + + @DisplayName("재고와 동일한 수량을 차감하면 0이 된다.") + @Test + void deduct_whenExactStock() { + // arrange + Stock stock = new Stock(5); + + // act + stock.deduct(5); + + // assert + assertThat(stock.getValue()).isEqualTo(0); + } + } + + @DisplayName("재고 충분 여부를 확인할 때, ") + @Nested + class HasEnough { + + @DisplayName("충분하면 true를 반환한다.") + @Test + void hasEnough_whenEnough() { + // arrange + Stock stock = new Stock(10); + + // act & assert + assertThat(stock.hasEnough(10)).isTrue(); + } + + @DisplayName("부족하면 false를 반환한다.") + @Test + void hasEnough_whenNotEnough() { + // arrange + Stock stock = new Stock(5); + + // act & assert + assertThat(stock.hasEnough(6)).isFalse(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/product/AdminProductV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/product/AdminProductV1ApiE2ETest.java new file mode 100644 index 000000000..5adcc251d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/product/AdminProductV1ApiE2ETest.java @@ -0,0 +1,452 @@ +package com.loopers.interfaces.product; + +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 com.loopers.domain.brand.BrandModel; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.product.dto.AdminProductV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class AdminProductV1ApiE2ETest { + + private static final String ENDPOINT_PRODUCTS = "/api-admin/v1/products"; + + private final TestRestTemplate testRestTemplate; + private final BrandJpaRepository brandJpaRepository; + private final ProductJpaRepository productJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + + private Long brandId; + + @Autowired + public AdminProductV1ApiE2ETest( + TestRestTemplate testRestTemplate, + BrandJpaRepository brandJpaRepository, + ProductJpaRepository productJpaRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.brandJpaRepository = brandJpaRepository; + this.productJpaRepository = productJpaRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @BeforeEach + void setUp() { + BrandModel brand = brandJpaRepository.save(BrandModel.create("나이키")); + brandId = brandJpaRepository.findByNameAndDeletedAtIsNull("나이키").get().getId(); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "loopers.admin"); + return headers; + } + + private void registerProduct(String name, int price, int stock) { + AdminProductV1Dto.RegisterRequest request = new AdminProductV1Dto.RegisterRequest(brandId, name, price, stock); + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + testRestTemplate.exchange(ENDPOINT_PRODUCTS, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), responseType); + } + + @DisplayName("LDAP 인증") + @Nested + class Authentication { + + @DisplayName("LDAP 헤더 없이 요청하면, 401 UNAUTHORIZED 응답을 받는다.") + @Test + void returnsUnauthorized_whenNoLdapHeader() { + // arrange + AdminProductV1Dto.RegisterRequest request = new AdminProductV1Dto.RegisterRequest(brandId, "에어맥스", 150000, 100); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PRODUCTS, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED), + () -> assertThat(productJpaRepository.count()).isEqualTo(0) + ); + } + + @DisplayName("잘못된 LDAP 헤더 값으로 요청하면, 401 UNAUTHORIZED 응답을 받는다.") + @Test + void returnsUnauthorized_whenInvalidLdapHeader() { + // arrange + AdminProductV1Dto.RegisterRequest request = new AdminProductV1Dto.RegisterRequest(brandId, "에어맥스", 150000, 100); + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "wrong.value"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PRODUCTS, HttpMethod.POST, new HttpEntity<>(request, headers), responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED), + () -> assertThat(productJpaRepository.count()).isEqualTo(0) + ); + } + } + + @DisplayName("POST /api-admin/v1/products") + @Nested + class Register { + + @DisplayName("유효한 상품 정보를 주면, 상품 등록에 성공한다.") + @Test + void returnsSuccess_whenValidProductInfoIsProvided() { + // arrange + AdminProductV1Dto.RegisterRequest request = new AdminProductV1Dto.RegisterRequest(brandId, "에어맥스", 150000, 100); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PRODUCTS, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(productJpaRepository.count()).isEqualTo(1) + ); + } + + @DisplayName("상품명이 빈값이면, 400 BAD_REQUEST 응답을 받는다.") + @Test + void throwsBadRequest_whenProductNameIsBlank() { + // arrange + AdminProductV1Dto.RegisterRequest request = new AdminProductV1Dto.RegisterRequest(brandId, "", 150000, 100); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PRODUCTS, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST), + () -> assertThat(productJpaRepository.count()).isEqualTo(0) + ); + } + + @DisplayName("가격이 음수이면, 400 BAD_REQUEST 응답을 받는다.") + @Test + void throwsBadRequest_whenPriceIsNegative() { + // arrange + AdminProductV1Dto.RegisterRequest request = new AdminProductV1Dto.RegisterRequest(brandId, "에어맥스", -1, 100); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PRODUCTS, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST), + () -> assertThat(productJpaRepository.count()).isEqualTo(0) + ); + } + + @DisplayName("브랜드 ID가 null이면, 400 BAD_REQUEST 응답을 받는다.") + @Test + void throwsBadRequest_whenBrandIdIsNull() { + // arrange + AdminProductV1Dto.RegisterRequest request = new AdminProductV1Dto.RegisterRequest(null, "에어맥스", 150000, 100); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PRODUCTS, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST), + () -> assertThat(productJpaRepository.count()).isEqualTo(0) + ); + } + + @DisplayName("존재하지 않는 브랜드 ID면, 404 NOT_FOUND 응답을 받는다.") + @Test + void throwsNotFound_whenBrandDoesNotExist() { + // arrange + AdminProductV1Dto.RegisterRequest request = new AdminProductV1Dto.RegisterRequest(999L, "에어맥스", 150000, 100); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PRODUCTS, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND), + () -> assertThat(productJpaRepository.count()).isEqualTo(0) + ); + } + } + + @DisplayName("GET /api-admin/v1/products") + @Nested + class List { + + @DisplayName("상품 목록을 조회하면, 페이지네이션된 목록을 반환한다.") + @Test + void returnsPaginatedList_whenProductsExist() { + // arrange + registerProduct("에어맥스", 150000, 100); + registerProduct("에어포스", 120000, 50); + registerProduct("조던1", 200000, 30); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PRODUCTS + "?page=0&size=2", HttpMethod.GET, new HttpEntity<>(null, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(3), + () -> assertThat(response.getBody().data().totalPages()).isEqualTo(2), + () -> assertThat(response.getBody().data().items()).hasSize(2) + ); + } + + @DisplayName("브랜드 ID로 필터링하면, 해당 브랜드의 상품만 반환한다.") + @Test + void returnsFilteredList_whenBrandIdIsProvided() { + // arrange + BrandModel adidas = brandJpaRepository.save(BrandModel.create("아디다스")); + Long adidasId = brandJpaRepository.findByNameAndDeletedAtIsNull("아디다스").get().getId(); + + registerProduct("에어맥스", 150000, 100); + + AdminProductV1Dto.RegisterRequest adidasProduct = new AdminProductV1Dto.RegisterRequest(adidasId, "울트라부스트", 180000, 80); + ParameterizedTypeReference> registerType = new ParameterizedTypeReference<>() {}; + testRestTemplate.exchange(ENDPOINT_PRODUCTS, HttpMethod.POST, new HttpEntity<>(adidasProduct, adminHeaders()), registerType); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PRODUCTS + "?brandId=" + brandId, HttpMethod.GET, new HttpEntity<>(null, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(1), + () -> assertThat(response.getBody().data().items().get(0).name()).isEqualTo("에어맥스") + ); + } + + @DisplayName("상품이 없으면, 빈 목록을 반환한다.") + @Test + void returnsEmptyList_whenNoProductsExist() { + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PRODUCTS + "?page=0&size=20", HttpMethod.GET, new HttpEntity<>(null, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(0), + () -> assertThat(response.getBody().data().items()).isEmpty() + ); + } + } + + @DisplayName("GET /api-admin/v1/products/{productId}") + @Nested + class GetById { + + @DisplayName("존재하는 상품을 조회하면, 상세 정보를 반환한다.") + @Test + void returnsProductDetail_whenProductExists() { + // arrange + registerProduct("에어맥스", 150000, 100); + Long productId = productJpaRepository.findAllByDeletedAtIsNull(org.springframework.data.domain.PageRequest.of(0, 1)) + .getContent().get(0).getId(); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PRODUCTS + "/" + productId, HttpMethod.GET, new HttpEntity<>(null, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().id()).isEqualTo(productId), + () -> assertThat(response.getBody().data().brandId()).isEqualTo(brandId), + () -> assertThat(response.getBody().data().brandName()).isEqualTo("나이키"), + () -> assertThat(response.getBody().data().name()).isEqualTo("에어맥스"), + () -> assertThat(response.getBody().data().price()).isEqualTo(150000), + () -> assertThat(response.getBody().data().stock()).isEqualTo(100), + () -> assertThat(response.getBody().data().createdAt()).isNotNull(), + () -> assertThat(response.getBody().data().updatedAt()).isNotNull(), + () -> assertThat(response.getBody().data().deletedAt()).isNull() + ); + } + + @DisplayName("존재하지 않는 상품을 조회하면, 404 NOT_FOUND 응답을 받는다.") + @Test + void throwsNotFound_whenProductDoesNotExist() { + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PRODUCTS + "/999", HttpMethod.GET, new HttpEntity<>(null, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) + ); + } + } + + @DisplayName("PUT /api-admin/v1/products/{productId}") + @Nested + class Update { + + @DisplayName("유효한 수정 요청이면, 상품이 수정된다.") + @Test + void returnsSuccess_whenValidUpdateRequest() { + // arrange + registerProduct("에어맥스", 150000, 100); + Long productId = productJpaRepository.findAllByDeletedAtIsNull(org.springframework.data.domain.PageRequest.of(0, 1)) + .getContent().get(0).getId(); + AdminProductV1Dto.UpdateRequest updateRequest = new AdminProductV1Dto.UpdateRequest("에어포스", 120000, 50); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PRODUCTS + "/" + productId, HttpMethod.PUT, new HttpEntity<>(updateRequest, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK) + ); + + // verify updated + ParameterizedTypeReference> detailType = new ParameterizedTypeReference<>() {}; + ResponseEntity> detailResponse = + testRestTemplate.exchange(ENDPOINT_PRODUCTS + "/" + productId, HttpMethod.GET, new HttpEntity<>(null, adminHeaders()), detailType); + + assertAll( + () -> assertThat(detailResponse.getBody().data().name()).isEqualTo("에어포스"), + () -> assertThat(detailResponse.getBody().data().price()).isEqualTo(120000), + () -> assertThat(detailResponse.getBody().data().stock()).isEqualTo(50) + ); + } + + @DisplayName("존재하지 않는 상품을 수정하면, 404 NOT_FOUND 응답을 받는다.") + @Test + void throwsNotFound_whenProductDoesNotExist() { + // arrange + AdminProductV1Dto.UpdateRequest updateRequest = new AdminProductV1Dto.UpdateRequest("에어포스", 120000, 50); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PRODUCTS + "/999", HttpMethod.PUT, new HttpEntity<>(updateRequest, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) + ); + } + + @DisplayName("상품명이 빈값이면, 400 BAD_REQUEST 응답을 받는다.") + @Test + void throwsBadRequest_whenProductNameIsBlank() { + // arrange + registerProduct("에어맥스", 150000, 100); + Long productId = productJpaRepository.findAllByDeletedAtIsNull(org.springframework.data.domain.PageRequest.of(0, 1)) + .getContent().get(0).getId(); + AdminProductV1Dto.UpdateRequest updateRequest = new AdminProductV1Dto.UpdateRequest("", 120000, 50); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PRODUCTS + "/" + productId, HttpMethod.PUT, new HttpEntity<>(updateRequest, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST) + ); + } + } + + @DisplayName("DELETE /api-admin/v1/products/{productId}") + @Nested + class Delete { + + @DisplayName("존재하는 상품을 삭제하면, 성공한다.") + @Test + void returnsSuccess_whenProductExists() { + // arrange + registerProduct("에어맥스", 150000, 100); + Long productId = productJpaRepository.findAllByDeletedAtIsNull(org.springframework.data.domain.PageRequest.of(0, 1)) + .getContent().get(0).getId(); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PRODUCTS + "/" + productId, HttpMethod.DELETE, new HttpEntity<>(null, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(productJpaRepository.findByIdAndDeletedAtIsNull(productId)).isEmpty() + ); + } + + @DisplayName("존재하지 않는 상품을 삭제하면, 404 NOT_FOUND 응답을 받는다.") + @Test + void throwsNotFound_whenProductDoesNotExist() { + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PRODUCTS + "/999", HttpMethod.DELETE, new HttpEntity<>(null, adminHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/product/ProductV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/product/ProductV1ApiE2ETest.java new file mode 100644 index 000000000..ddf7dabd1 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/product/ProductV1ApiE2ETest.java @@ -0,0 +1,206 @@ +package com.loopers.interfaces.product; + +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 com.loopers.domain.brand.BrandModel; +import com.loopers.domain.product.ProductModel; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.product.dto.ProductV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class ProductV1ApiE2ETest { + + private static final String ENDPOINT_PRODUCTS = "/api/v1/products"; + + private final TestRestTemplate testRestTemplate; + private final BrandJpaRepository brandJpaRepository; + private final ProductJpaRepository productJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + + private BrandModel savedBrand; + + @Autowired + public ProductV1ApiE2ETest( + TestRestTemplate testRestTemplate, + BrandJpaRepository brandJpaRepository, + ProductJpaRepository productJpaRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.brandJpaRepository = brandJpaRepository; + this.productJpaRepository = productJpaRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @BeforeEach + void setUp() { + brandJpaRepository.save(BrandModel.create("나이키")); + savedBrand = brandJpaRepository.findByNameAndDeletedAtIsNull("나이키").get(); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private ProductModel saveProduct(String name, int price, int stock) { + ProductModel product = ProductModel.create(savedBrand, name, price, stock); + return productJpaRepository.save(product); + } + + @DisplayName("GET /api/v1/products") + @Nested + class List { + + @DisplayName("상품 목록을 조회하면, 페이지네이션된 목록을 반환한다.") + @Test + void returnsPaginatedList_whenProductsExist() { + // arrange + saveProduct("에어맥스", 150000, 100); + saveProduct("에어포스", 120000, 50); + saveProduct("조던1", 200000, 30); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PRODUCTS + "?page=0&size=2", HttpMethod.GET, null, responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(3), + () -> assertThat(response.getBody().data().totalPages()).isEqualTo(2), + () -> assertThat(response.getBody().data().items()).hasSize(2) + ); + } + + @DisplayName("브랜드 ID로 필터링하면, 해당 브랜드의 상품만 반환한다.") + @Test + void returnsFilteredList_whenBrandIdIsProvided() { + // arrange + brandJpaRepository.save(BrandModel.create("아디다스")); + BrandModel adidas = brandJpaRepository.findByNameAndDeletedAtIsNull("아디다스").get(); + + saveProduct("에어맥스", 150000, 100); + productJpaRepository.save(ProductModel.create(adidas, "울트라부스트", 180000, 80)); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PRODUCTS + "?brandId=" + savedBrand.getId(), HttpMethod.GET, null, responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(1), + () -> assertThat(response.getBody().data().items().get(0).name()).isEqualTo("에어맥스") + ); + } + + @DisplayName("삭제된 상품은 목록에 포함되지 않는다.") + @Test + void excludesDeletedProducts() { + // arrange + saveProduct("에어맥스", 150000, 100); + ProductModel toDelete = saveProduct("에어포스", 120000, 50); + Long deleteId = productJpaRepository.findAllByDeletedAtIsNull(org.springframework.data.domain.PageRequest.of(0, 20)) + .getContent().stream() + .filter(p -> p.getName().equals("에어포스")) + .findFirst().get().getId(); + ProductModel found = productJpaRepository.findByIdAndDeletedAtIsNull(deleteId).get(); + found.delete(); + productJpaRepository.save(found); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PRODUCTS, HttpMethod.GET, null, responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(1), + () -> assertThat(response.getBody().data().items().get(0).name()).isEqualTo("에어맥스") + ); + } + + @DisplayName("상품이 없으면, 빈 목록을 반환한다.") + @Test + void returnsEmptyList_whenNoProductsExist() { + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PRODUCTS + "?page=0&size=20", HttpMethod.GET, null, responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(0), + () -> assertThat(response.getBody().data().items()).isEmpty() + ); + } + } + + @DisplayName("GET /api/v1/products/{productId}") + @Nested + class GetById { + + @DisplayName("존재하는 상품을 조회하면, 상세 정보를 반환한다.") + @Test + void returnsProductDetail_whenProductExists() { + // arrange + saveProduct("에어맥스", 150000, 100); + Long productId = productJpaRepository.findAllByDeletedAtIsNull(org.springframework.data.domain.PageRequest.of(0, 1)) + .getContent().get(0).getId(); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PRODUCTS + "/" + productId, HttpMethod.GET, null, responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().id()).isEqualTo(productId), + () -> assertThat(response.getBody().data().brandId()).isEqualTo(savedBrand.getId()), + () -> assertThat(response.getBody().data().brandName()).isEqualTo("나이키"), + () -> assertThat(response.getBody().data().name()).isEqualTo("에어맥스"), + () -> assertThat(response.getBody().data().price()).isEqualTo(150000), + () -> assertThat(response.getBody().data().stock()).isEqualTo(100), + () -> assertThat(response.getBody().data().likeCount()).isEqualTo(0) + ); + } + + @DisplayName("존재하지 않는 상품을 조회하면, 404 NOT_FOUND 응답을 받는다.") + @Test + void throwsNotFound_whenProductDoesNotExist() { + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PRODUCTS + "/999", HttpMethod.GET, null, responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) + ); + } + } +} diff --git a/http/commerce-api/admin-product-v1.http b/http/commerce-api/admin-product-v1.http new file mode 100644 index 000000000..761fb5c87 --- /dev/null +++ b/http/commerce-api/admin-product-v1.http @@ -0,0 +1,96 @@ +### 상품 등록 - 성공 케이스 +POST {{commerce-api}}/api-admin/v1/products +Content-Type: application/json +X-Loopers-Ldap: loopers.admin + +{ + "brandId": 1, + "name": "에어맥스", + "price": 150000, + "stock": 100 +} + +### 상품 등록 - 상품명 빈값 (실패) +POST {{commerce-api}}/api-admin/v1/products +Content-Type: application/json +X-Loopers-Ldap: loopers.admin + +{ + "brandId": 1, + "name": "", + "price": 150000, + "stock": 100 +} + +### 상품 등록 - LDAP 헤더 누락 (실패 - 401) +POST {{commerce-api}}/api-admin/v1/products +Content-Type: application/json + +{ + "brandId": 1, + "name": "에어맥스", + "price": 150000, + "stock": 100 +} + +### 상품 등록 - 잘못된 LDAP 헤더 (실패 - 401) +POST {{commerce-api}}/api-admin/v1/products +Content-Type: application/json +X-Loopers-Ldap: wrong.value + +{ + "brandId": 1, + "name": "에어맥스", + "price": 150000, + "stock": 100 +} + +### 상품 목록 조회 - 기본 페이지 +GET {{commerce-api}}/api-admin/v1/products?page=0&size=20 +X-Loopers-Ldap: loopers.admin + +### 상품 목록 조회 - 페이지 크기 지정 +GET {{commerce-api}}/api-admin/v1/products?page=0&size=2 +X-Loopers-Ldap: loopers.admin + +### 상품 목록 조회 - 브랜드 필터링 +GET {{commerce-api}}/api-admin/v1/products?brandId=1&page=0&size=20 +X-Loopers-Ldap: loopers.admin + +### 상품 상세 조회 +GET {{commerce-api}}/api-admin/v1/products/1 +X-Loopers-Ldap: loopers.admin + +### 상품 상세 조회 - 존재하지 않는 상품 (실패) +GET {{commerce-api}}/api-admin/v1/products/999 +X-Loopers-Ldap: loopers.admin + +### 상품 수정 - 성공 케이스 +PUT {{commerce-api}}/api-admin/v1/products/1 +Content-Type: application/json +X-Loopers-Ldap: loopers.admin + +{ + "name": "에어포스", + "price": 120000, + "stock": 50 +} + +### 상품 수정 - 상품명 빈값 (실패) +PUT {{commerce-api}}/api-admin/v1/products/1 +Content-Type: application/json +X-Loopers-Ldap: loopers.admin + +{ + "name": "", + "price": 120000, + "stock": 50 +} + +### 상품 삭제 - 성공 케이스 +DELETE {{commerce-api}}/api-admin/v1/products/1 +X-Loopers-Ldap: loopers.admin + +### 상품 삭제 - 존재하지 않는 상품 (실패) +DELETE {{commerce-api}}/api-admin/v1/products/999 +X-Loopers-Ldap: loopers.admin diff --git a/http/commerce-api/product-v1.http b/http/commerce-api/product-v1.http new file mode 100644 index 000000000..66e182c6e --- /dev/null +++ b/http/commerce-api/product-v1.http @@ -0,0 +1,20 @@ +### 상품 목록 조회 - 기본 +GET {{commerce-api}}/api/v1/products?page=0&size=20 + +### 상품 목록 조회 - 브랜드 필터링 +GET {{commerce-api}}/api/v1/products?brandId=1&page=0&size=20 + +### 상품 목록 조회 - 가격 낮은순 정렬 +GET {{commerce-api}}/api/v1/products?sort=price_asc&page=0&size=20 + +### 상품 목록 조회 - 좋아요 많은순 정렬 +GET {{commerce-api}}/api/v1/products?sort=likes_desc&page=0&size=20 + +### 상품 목록 조회 - 페이지 크기 지정 +GET {{commerce-api}}/api/v1/products?page=0&size=2 + +### 상품 상세 조회 +GET {{commerce-api}}/api/v1/products/1 + +### 상품 상세 조회 - 존재하지 않는 상품 (실패) +GET {{commerce-api}}/api/v1/products/999 From 2f79488da57300ee40658b14f5115da2d55ee9c1 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Sat, 21 Feb 2026 17:15:13 +0900 Subject: [PATCH 039/108] =?UTF-8?q?docs:=20=EB=A6=AC=EB=93=9C=EB=AF=B8=20?= =?UTF-8?q?=EC=B4=88=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 77 ------------------------------------------------------- 1 file changed, 77 deletions(-) diff --git a/README.md b/README.md index 49ee650a3..e69de29bb 100644 --- a/README.md +++ b/README.md @@ -1,77 +0,0 @@ -# Round-1 -### 시작 전 목표 -``` -- 나의 의도를 테스트 코드로 작성한다. -- TDD 방식으로 AI와 함께 기능 구현해본다. -- TDD로 요구사항을 먼저 정리하는 장점을 느껴본다. -- 작게 쪼개고 점진적으로 설계하는 과정을 느껴본다. -- 리팩토링이 가능하다는것을 느껴본다. -``` - -### 시작 후 목표 -- 요구사항을 AI 실행 프롬프트로 구조화하는 방법 알아보기 -``` -내 현재 학습 초점은 '설계는 내가, 구현은 AI가'라는 명확한 역할 분리다. -TDD 방식으로 AI에게 코딩을 위임하면서도 설계 결정권은 내가 가져가는 방식이다. -여기서 질문이 생겼는데, 기능 요구사항을 받았을 때, 어떤 변환 과정을 거쳐 AI가 정확히 구현할 수 있는 프롬프트 형태로 만들어지는가?' - -이 변환 과정을 알아보는 것을 목표로 했다. -``` - -### 내가 한 시도 - -시도 1: AI가 설계하고, AI가 결정 -- 방식: 기능 요구사항을 그대로 던져주고 TDD(Red-Green-Refactor)로 개발하라고 했다. -- 문제점: 이 과정에서 내 설계도, 내 의도도 없다는 걸 자각했다. AI가 알아서 다 했을 뿐이었다. - -시도 2: AI가 설계하고, 내가 결정 -- 방식: AI가 설계 보고서를 작성하면 내가 읽고 결정하는 위치에 서기로 했다. -- 문제점: - - AI가 너무 복잡한 설계를 제시했다. - - 그 문서를 읽는 데 시간이 많이 들었고, 전부 읽을 수도 없었다. - - 내 의도가 담긴 설계라고 전혀 느껴지지 않았다. - -시도 3: 내가 설계하고, AI의 피드백을 받기 -- 방식: 기능 요구사항으로부터 객체와 메시지 정의, 검증 및 예외 케이스를 내가 직접 문서로 정리했다. -- 좋았던 점: - - 오직 내 설계 틀 안에서 AI가 고려해줘서, 유용한 피드백을 받았다. - - 레이어별 특징, 해야 할 것, 하지 말아야 할 것을 먼저 정의해야 한다는 걸 배웠다. - - 내가 놓친 엣지 케이스나 잠재적 문제를 해상도 높게 알려주고, 판단을 요구해줬다. -- 문제점: - - 회원가입 기능을 도메인부터 API까지 전부 수도코드로 문서화하고 있었는데, 이러다 보니 생각이 들었다. - - "수도코드로 문서화할 바에, 그냥 내가 코드로 먼저 작성하고 이 스타일대로 나머지 기능을 구현하라고 하는 게 더 내 의도가 담긴 코드 아닌가?" - - 요구사항 규칙뿐만 아니라 개발 규칙들을 명확히 하는 것이 부족했다. - - 내 의도를 설명하면서 개발 스타일을 알려줄 때, 지금처럼 문서로 전달하는 게 맞는지, 아니면 내가 기능 구현 하나를 예시로 만들어주고 이 스타일을 참고해서 개발하라고 해야 할지 고민이 들었다. - -시도 4: 내가 설계하고 시범 구현을 작성하고, AI에게 이 방식대로 하라고 명령 -- 방식: 회원가입 기능을 TDD(Red-Green-Refactor)로 도메인에서 API까지 직접 구현하고, 내 코드 스타일대로 다른 기능 개발을 맡겼다. -- 진행 과정: - - AI는 A부터 Z까지 전부 만들어냈다. - - 나는 왜 그렇게 설계했고 만들었는지 물어보는 식으로 AI의 설계 사고를 배우고 있었다. - - 이 내용들은 내가 설계를 진행했어도 AI에게 물어볼 내용들이었다. - - 내 의도가 담긴 코드라기보다는, 나는 그 의도를 이해해 나가고, 동의하지 않으면 내 설계를 담아내는 식으로 진행했다. -- 문제점: - - 한 번 구현하라고 할 때마다 오래 걸렸다. 처음부터 API까지 관련 모든 코드를 20분 동안 작업하고 있었다. - - 전부 한 큐에 완성시키기 때문에 양이 방대했다. - - 나는 추가된 main 코드 확인과 테스트 통과 여부만 확인하고 있었다. - - 이게 맞는 방식인지는 아직 잘 모르겠다. - - - -### 과제 후 느낀점 -- AI 협업에 대한 미해결 고민 - - 아직 어떻게 AI를 파트너로 협업하는 것인지 깨닫지 못했다. - - 이번 과제는 사실 처음부터 완벽한 설계를 만들고 AI에게 개발하라고 하는 게 아니라, 기능 요구사항으로부터 개발자가 직접 TDD를 구현하면서 이 과정에서 궁금하거나, 막히거나, 노가다 과정을 AI에게 맡기는 것을 기대한 과제였던 걸까? -- 클로드 코드 사용 경험 - - 이번에 클로드 코드를 사용해서 개발해보는 건 처음인데, 한 번의 명령으로 A부터 Z까지 기능 구현, 문서화, 테스트 코드 전부 구현해줘서 놀라웠다. - - 더 이상 구현은 중요하지 않다는 것을 이번에 체감했다. - - 대신 개발이 되는 환경을 잘 이해하는 것, 계층 책임이나 객체 책임 및 검증 스코프를 잘 정의하는 것, 이런 것들이 더 중요한 것 같은 느낌을 받았다. - - 클로드 스킬에 관심이 생겼다. - - - ---- - -## 📋 기능 요구 사항 - -기능 요구 사항 정리 [ToDoList.md](./ToDoList.md) From ab93916f31bddc434bace517af979213590c9090 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Sat, 21 Feb 2026 21:12:17 +0900 Subject: [PATCH 040/108] =?UTF-8?q?docs:=20=EB=8F=84=EB=A9=94=EC=9D=B8?= =?UTF-8?q?=EB=B3=84=20=EC=84=A4=EA=B3=84=20=EB=AC=B8=EC=84=9C=20=EC=9E=AC?= =?UTF-8?q?=EA=B5=AC=EC=84=B1=20=EB=B0=8F=20CLAUDE.md=20=EC=84=A4=EA=B3=84?= =?UTF-8?q?=20=EC=B0=B8=EC=A1=B0=20=EA=B7=9C=EC=B9=99=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 레거시 설계 문서(01~04)를 도메인별 DESIGN.md로 재구성하고, 공통 컨벤션/개요 문서를 추가하고, CLAUDE.md에 설계 문서 참조 규칙을 반영한다. Co-Authored-By: Claude Opus 4.6 --- .claude/CLAUDE.md | 73 ++-- docs/design/01-requirements.md | 563 ---------------------------- docs/design/02-sequence-diagrams.md | 116 ------ docs/design/03-class-diagram.md | 170 --------- docs/design/04-erd.md | 158 -------- docs/design/_shared/CONVENTIONS.md | 72 ++++ docs/design/_shared/OVERVIEW.md | 232 ++++++++++++ docs/design/brand/DESIGN.md | 170 +++++++++ docs/design/cart/DESIGN.md | 212 +++++++++++ docs/design/like/DESIGN.md | 160 ++++++++ docs/design/order/DESIGN.md | 248 ++++++++++++ docs/design/product/DESIGN.md | 203 ++++++++++ 12 files changed, 1334 insertions(+), 1043 deletions(-) delete mode 100644 docs/design/01-requirements.md delete mode 100644 docs/design/02-sequence-diagrams.md delete mode 100644 docs/design/03-class-diagram.md delete mode 100644 docs/design/04-erd.md create mode 100644 docs/design/_shared/CONVENTIONS.md create mode 100644 docs/design/_shared/OVERVIEW.md create mode 100644 docs/design/brand/DESIGN.md create mode 100644 docs/design/cart/DESIGN.md create mode 100644 docs/design/like/DESIGN.md create mode 100644 docs/design/order/DESIGN.md create mode 100644 docs/design/product/DESIGN.md diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 90d05f5b3..24b95e0e5 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -1,6 +1,6 @@ ## 프로젝트 개요 -이 프로젝트는 Spring Boot 기반의 멀티모듈 Java 프로젝트입니다. TDD(Test-Driven Development) 방식으로 개발하며, 테스트 가능한 구조를 목표로 합니다. +Spring Boot 기반 멀티모듈 Java 프로젝트. TDD 방식으로 개발하며, 테스트 가능한 구조를 목표로 한다. --- @@ -35,63 +35,64 @@ - **Jacoco**: 코드 커버리지 --- - ## 모듈 구조 - -### Apps (실행 가능한 애플리케이션) ``` apps/ -├── commerce-api # REST API 서버 -├── commerce-streamer # Kafka 스트리밍 처리 -└── commerce-batch # 배치 작업 -``` +├── commerce-api # REST API 서버 +├── commerce-streamer # Kafka 스트리밍 처리 +└── commerce-batch # 배치 작업 -### Modules (도메인 및 인프라 모듈) -``` modules/ -├── jpa # BaseEntity, QueryDSL/JPA/DataSource Config -├── redis # Redis 설정 및 Repository -└── kafka # Kafka 설정 및 Producer/Consumer -``` +├── jpa # BaseEntity, QueryDSL/JPA/DataSource Config +├── redis # Redis 설정 및 Repository +└── kafka # Kafka 설정 및 Producer/Consumer -### Supports (공통 지원 모듈) -``` supports/ -├── jackson # JSON 직렬화 설정 -├── logging # 로깅 설정 -└── monitoring # 모니터링 설정 +├── jackson # JSON 직렬화 설정 +├── logging # 로깅 설정 +└── monitoring # 모니터링 설정 ``` - --- ## 아키텍처 -- 계층 우선 패키지: `interfaces → application → domain ← infrastructure` +계층 우선 패키지: `interfaces → application → domain ← infrastructure` + +### 컨벤션 + - 코딩 컨벤션: `.claude/skills/project-convention/` 참조 (코드 작성 시 해당 스킬의 references/ 하위 문서를 반드시 Read 도구로 읽을 것) - 커밋 규칙: `.claude/skills/commit-convention/` 참조 +### 설계 문서 + +기능 개발 시 해당 도메인의 설계 문서를 **먼저 읽고** 시작한다. + +| 문서 | 경로 | 용도 | +|------|------|------| +| 공통 원칙 | `docs/spec/shared/CONVENTIONS.md` | 참조 방식, Soft Delete, 용어집 등 | +| 전체 구조 | `docs/spec/shared/OVERVIEW.md` | 전체 ERD + 클래스 다이어그램 | +| 도메인 스펙 | `docs/spec/{domain}/DESIGN.md` | 요구사항 + 유즈케이스 + 시퀀스 + ERD + 클래스 | + +도메인: `brand`, `product`, `like`, `cart`, `order` + +**참조 규칙:** +1. 해당 도메인의 `DESIGN.md`를 읽는다 +2. 다른 도메인과 연동이 필요하면 그 도메인의 `DESIGN.md`도 읽는다 +3. 전체 관계 확인이 필요하면 `OVERVIEW.md`를 읽는다 + --- ## 프로젝트 실행 -### 개발 환경 실행 ```bash +# 개발 환경 ./gradlew :apps:commerce-api:bootRun -``` -### 테스트 실행 -```bash -# 전체 테스트 -./gradlew test - -# 특정 모듈 테스트 -./gradlew :apps:commerce-api:test +# 테스트 +./gradlew test # 전체 +./gradlew :apps:commerce-api:test # 특정 모듈 +./gradlew test jacocoTestReport # 커버리지 -# 커버리지 리포트 생성 -./gradlew test jacocoTestReport -``` - -### Docker Compose 실행 -```bash +# 인프라 docker compose up -d ``` diff --git a/docs/design/01-requirements.md b/docs/design/01-requirements.md deleted file mode 100644 index 6c9a18aa2..000000000 --- a/docs/design/01-requirements.md +++ /dev/null @@ -1,563 +0,0 @@ -# 1. 목적 및 범위 - -### 프로젝트 목적 -SSENSE와 같은 하이패션 이커머스 플랫폼을 설게한다. - -고객이 브랜드별 상품을 탐색하고, 마음에 드는 상품에 좋아요를 표시하고, 여러 상품을 한 번에 주문할 수 있는 이커머스 서비스를 구축한다. 어드민은 브랜드와 상품을 관리하고, 주문 현황을 파악한다. - - -### 범위 - -본 문서가 다루는 범위는 다음 네 가지 도메인으로 **한정**한다. - -- **브랜드** — 상품을 묶는 그룹 단위. 어드민이 관리하고, 고객이 조회한다. -- **상품** — 고객이 탐색하고 주문하는 대상. 등록시 브랜드가 반드시 존재해야한다. -- **좋아요** — 고객이 상품에 대한 관심을 표현하는 행위. 실제 구매를 하진 않지만 구매 후보를 표현하는 행위다. -- **장바구니** — 고객이 관심 있는 상품을 임시로 모아두는 공간. 주문 전 단계에서 상품을 선택·관리하고 주문으로 이어지기 쉽다. -- **주문** — 고객이 상품을 구매하는 행위. 주문 시점의 상품 정보가 보존된다. - -### 액터 - -- **고객(User)** — 상품을 탐색하고, 좋아요를 누르고, 주문한다. 로그인이 필요한 행위와 필요 없는 행위가 구분된다. -- **어드민(Admin)** — 브랜드와 상품을 등록·수정·삭제하고, 전체 주문 현황을 조회한다. - -### 범위 고정 - -- 유저(Users) 회원가입, 내 정보 조회, 비밀번호 변경 기능은 **이미 구현 완료**되어 본 문서에서 다루지 않는다. -- 인증/인가의 구체적 구현 방식은 기존 코드베이스의 패턴을 따르며, 본 문서에서는 **인증이 필요한지 여부만** 명시한다. -- 엔티티 필드는 **행동 기반으로 점진적으로 설계**한다. 각 시나리오가 요구하는 행동에 필요한 필드만 추가한다. - -### 범위 제외 사항 - -| 제외 항목 | 사유 | -|---|---| -| 유저(Users) 기능 | 회원가입, 내 정보 조회, 비밀번호 변경은 이미 구현 완료 | -| 결제(Payment) | 향후 별도 단계에서 추가 개발 예정 | -| 쿠폰(Coupon) | 향후 별도 단계에서 추가 개발 예정 | -| 주문 상태 전이 | 결제 기능과 함께 추가. 현재는 주문 생성(ORDERED)만 다룬다 | -| 주문 취소 | 현재 범위에서 주문 취소 기능은 제공하지 않는다 | - - -# 2. 공통 정의 - -### 권한 정의 -| 액터 | 설명 | 식별 방식 | -|------|------|----------| -| 비회원 (Guest) | 로그인하지 않은 사용자 | 헤더 없음 | -| 회원 (User) | 로그인한 사용자 | `X-Loopers-LoginId`, `X-Loopers-LoginPw` | -| 관리자 (Admin) | 사내 관리자 | `X-Loopers-Ldap: loopers.admin` | - -### API Prefix 규칙 -- 대고객 API: `/api/v1` -- 어드민 API: `/api-admin/v1` - -### 도메인 용어집 (Ubiquitous Language) - -| 한글 | 영문 | 설명 | -|------|------|------| -| 회원 | User | 서비스에 가입한 사용자. 1주차에 구현 완료 (본 설계 범위 제외) | -| 브랜드 | Brand | 상품을 판매하는 브랜드. Admin이 등록/관리 | -| 상품 | Product | 브랜드에 속한 판매 상품. 재고(stock) 포함 | -| 재고 | stock | 상품의 현재 판매 가능 수량. Product의 필드로 관리 | -| 품절 | Sold Out | 상품 재고(stock)가 0인 상태 | -| 좋아요 | Like | 회원이 상품에 대해 표현하는 선호. 회원당 상품당 1개. 별도 테이블로 관리 | -| 장바구니 | Cart | 회원이 구매 전 상품을 담아두는 보관함 | -| 장바구니 항목 | CartItem | 장바구니에 담긴 개별 상품과 수량 | -| 주문 | Order | 회원이 상품을 구매하기 위한 요청 | -| 주문 항목 | OrderItem | 주문에 포함된 개별 상품의 스냅샷 (주문 시점 가격/이름 등) | -| 스냅샷 | Snapshot | 주문 시점의 상품 정보를 복사하여 저장하는 것 | -| Soft Delete | - | deleted_at 컬럼으로 논리 삭제. 물리적으로는 데이터 유지 | -| Admin | Admin | LDAP 인증 기반 사내 관리자 | - -### 도메인 참조 원칙 - -- **DB FK 제약 미사용** — 테이블 간 외래키 제약조건을 사용하지 않는다. 무결성은 애플리케이션 레벨에서 보장. - - FK의 문제: 잠금 전파(데드락 위험), 삭제 순서 강제, 테이블 간 결합 -- **DB 유니크 제약 사용** — 테이블 내부 제약은 사용한다 (FK와 성격이 다름). 동시성(더블클릭 등) 시 중복 방지. -- **참조 방식** - - 같은 도메인 (Brand → Product): 객체참조 + FK 없음 (`@ManyToOne` + `ConstraintMode.NO_CONSTRAINT`) - - 다른 도메인 간: ID 참조 (`private Long userId` 등) -- **Aggregate** — 각 도메인은 독립 Aggregate Root. `@OneToMany` 사용하지 않음. Aggregate 규칙은 Service에서 `@Transactional`로 관리. - ---- - -# 3. 기능 요구 사항 - -## 3.1 브랜드 & 상품 - -> **비회원으로서**, 브랜드 정보를 조회하고 상품 목록을 둘러보고 상세 정보를 확인할 수 있다. -> -> **관리자로서**, 브랜드와 상품을 등록/수정/삭제하여 서비스에 입점할 브랜드와 판매 상품을 관리할 수 있다. - -### 예외 및 정책 - -- **삭제 전략: Soft Delete** — `deleted_at` 컬럼으로 논리 삭제. 복구 가능성을 열어두고, 연관 데이터(장바구니/좋아요)는 조회 시 필터링으로 처리하여 트랜잭션 범위를 줄인다. -- **브랜드 삭제 연쇄 처리** — 브랜드 soft delete 시 해당 브랜드의 상품도 전체 soft delete. 장바구니/좋아요는 즉시 삭제하지 않고 조회 시점에 필터링. - ``` - 브랜드 soft delete - └→ 해당 브랜드의 상품 전체 soft delete - └→ 장바구니 항목: 조회 시 필터링 - └→ 좋아요: 조회 시 필터링 - ``` -- **브랜드명 중복 불가** — 동일한 브랜드명이 이미 존재하면 등록/수정 실패 (409 Conflict) -- **상품 재고: Product 필드로 관리** — 별도 Stock 도메인 분리 없이 Product 엔티티의 stock 필드로 관리. 등록/수정 시 재고 설정, 주문 시 차감. -- **고객 vs Admin 응답 차이** — 고객에게는 기본 정보만, Admin에게는 등록일/수정일/삭제 여부 등 관리 정보 추가 제공 -- **soft delete된 브랜드/상품** — 고객 조회 불가 (404 반환) -- **Brand → Product 참조** — 객체참조 + FK 없음. 코드에서 `product.getBrand().getName()` 접근 가능하되, DB에 FK 제약조건은 생성하지 않음. -- **각각 독립 Aggregate Root** — Brand와 Product는 별도 Aggregate Root. 브랜드 삭제 → 상품 soft delete는 Facade에서 조율. -- **Product.likeCount 캐시 필드** — 찜 수 조회 성능을 위해 Product에 likeCount 캐싱. 찜/취소 시 원자적 증감. - -### API - -**브랜드** - -| 기능 | 액터 | Method | URI | 인증 | -|------|------|--------|-----|------| -| 브랜드 정보 조회 | 비회원/회원 | GET | `/api/v1/brands/{brandId}` | X | -| 브랜드 목록 조회 | Admin | GET | `/api-admin/v1/brands?page=0&size=20` | LDAP | -| 브랜드 상세 조회 | Admin | GET | `/api-admin/v1/brands/{brandId}` | LDAP | -| 브랜드 등록 | Admin | POST | `/api-admin/v1/brands` | LDAP | -| 브랜드 정보 수정 | Admin | PUT | `/api-admin/v1/brands/{brandId}` | LDAP | -| 브랜드 삭제 | Admin | DELETE | `/api-admin/v1/brands/{brandId}` | LDAP | - -**상품** - -| 기능 | 액터 | Method | URI | 인증 | -|------|------|--------|-----|------| -| 상품 목록 조회 | 비회원/회원 | GET | `/api/v1/products` | X | -| 상품 정보 조회 | 비회원/회원 | GET | `/api/v1/products/{productId}` | X | -| 상품 목록 조회 | Admin | GET | `/api-admin/v1/products?page=0&size=20&brandId={brandId}` | LDAP | -| 상품 상세 조회 | Admin | GET | `/api-admin/v1/products/{productId}` | LDAP | -| 상품 등록 | Admin | POST | `/api-admin/v1/products` | LDAP | -| 상품 정보 수정 | Admin | PUT | `/api-admin/v1/products/{productId}` | LDAP | -| 상품 삭제 | Admin | DELETE | `/api-admin/v1/products/{productId}` | LDAP | - -**상품 목록 조회 쿼리 파라미터** - -| 파라미터 | 설명 | 기본값 | -|----------|------|--------| -| `brandId` | 특정 브랜드 상품 필터링 | - (선택) | -| `sort` | 정렬 기준: `latest` / `price_asc` / `likes_desc` | `latest` | -| `page` | 페이지 번호 | 0 | -| `size` | 페이지당 상품 수 | 20 | - -> `sort`는 `latest` 필수, `price_asc` / `likes_desc`는 선택 구현. -> `likes_desc` 정렬 시 좋아요 수는 Product.likeCount 필드로 정렬. - -### 유즈케이스 - -**UC-B01: 브랜드 정보 조회 (비회원)** - -``` -[기능 흐름] -1. 비회원이 brandId로 브랜드 정보를 요청한다 -2. 해당 브랜드가 존재하는지 확인한다 -3. 브랜드 기본 정보를 반환한다 - -[예외] -- brandId에 해당하는 브랜드가 없으면 404 반환 -- soft delete된 브랜드는 조회 불가 (404 반환) -``` - -**UC-B02: 브랜드 등록 (Admin)** - -``` -[기능 흐름] -1. Admin이 브랜드 정보(이름 등)를 입력한다 -2. 동일한 브랜드명이 이미 존재하는지 확인한다 -3. 브랜드를 저장한다 -4. 생성된 브랜드 정보를 반환한다 - -[예외] -- 이미 존재하는 브랜드명이면 등록 실패 (409 Conflict) - -[조건] -- 브랜드명은 필수값이며 중복 불가 -``` - -**UC-B03: 브랜드 정보 수정 (Admin)** - -``` -[기능 흐름] -1. Admin이 brandId와 수정할 정보를 요청한다 -2. 해당 브랜드가 존재하는지 확인한다 -3. 브랜드 정보를 업데이트한다 - -[예외] -- brandId에 해당하는 브랜드가 없거나 삭제된 경우 404 반환 -- 수정하려는 브랜드명이 다른 브랜드와 중복되면 409 Conflict -``` - -**UC-B04: 브랜드 삭제 (Admin)** - -``` -[기능 흐름] -1. Admin이 brandId로 삭제를 요청한다 -2. 해당 브랜드가 존재하는지 확인한다 -3. 해당 브랜드를 soft delete 한다 -4. 해당 브랜드의 모든 상품도 soft delete 한다 - -[예외] -- brandId에 해당하는 브랜드가 없으면 404 반환 -- 이미 삭제된 브랜드이면 404 반환 -``` - -**UC-P01: 상품 목록 조회 (비회원)** - -``` -[기능 흐름] -1. 비회원이 상품 목록을 요청한다 (선택: brandId, sort, page, size) -2. soft delete된 상품/브랜드를 제외한다 -3. 정렬 조건에 맞게 정렬한다 -4. 페이지네이션하여 상품 목록을 반환한다 -5. 각 상품의 좋아요 수를 Product.likeCount로 함께 반환한다 - -[대안 흐름] -- brandId가 없으면 전체 상품 조회 -- sort가 없으면 latest(최신순) 기본 적용 -``` - -**UC-P02: 상품 정보 조회 (비회원)** - -``` -[기능 흐름] -1. 비회원이 productId로 상품 정보를 요청한다 -2. 해당 상품이 존재하는지 확인한다 -3. 상품 정보와 함께 좋아요 수(Product.likeCount)를 반환한다 - -[예외] -- productId에 해당하는 상품이 없거나 삭제된 경우 404 반환 -``` - -**UC-P03: 상품 등록 (Admin)** - -``` -[기능 흐름] -1. Admin이 상품 정보를 입력한다 (brandId, 상품명, 가격, 재고 등) -2. brandId에 해당하는 브랜드가 존재하는지 확인한다 -3. 상품을 저장한다 -4. 생성된 상품 정보를 반환한다 - -[예외] -- brandId에 해당하는 브랜드가 없거나 삭제된 경우 등록 실패 - -[조건] -- 상품의 브랜드는 반드시 이미 등록된(삭제되지 않은) 브랜드여야 함 -- 재고(stock)는 상품 등록 시 초기값 설정 (0 이상) -``` - -**UC-P04: 상품 정보 수정 (Admin)** - -``` -[기능 흐름] -1. Admin이 productId와 수정할 정보를 요청한다 -2. 해당 상품이 존재하는지 확인한다 -3. 상품 정보를 업데이트한다 - -[예외] -- productId에 해당하는 상품이 없거나 삭제된 경우 404 반환 - -[조건] -- 상품의 브랜드(brandId)는 수정할 수 없음 -- 재고(stock) 수정 가능 -``` - -**UC-P05: 상품 삭제 (Admin)** - -``` -[기능 흐름] -1. Admin이 productId로 삭제를 요청한다 -2. 해당 상품이 존재하는지 확인한다 -3. 해당 상품을 soft delete 한다 - -[예외] -- productId에 해당하는 상품이 없거나 이미 삭제된 경우 404 반환 -``` - ---- - -## 3.2 좋아요 - -> **회원으로서**, 마음에 드는 상품에 좋아요를 눌러 선호를 표현하고, 나중에 다시 찾아볼 수 있다. -> 이미 좋아요한 상품은 취소할 수 있다. - -### 예외 및 정책 - -- **좋아요 수: Product.likeCount 캐시** — Like 엔티티가 원본 데이터, Product.likeCount는 조회 성능을 위한 파생값(derived data). 찜/취소 시 원자적 증감. 모든 상품 조회 API에서 서브쿼리 없이 사용. -- **API 방식: 엔드포인트 분리 + 내부 토글** — POST/DELETE 엔드포인트는 미션 스펙대로 분리하되, 내부적으로 같은 Facade 메서드(toggleLike)를 호출. 409/404 없음. -- **회원당 상품당 1개** — userId + productId DB 유니크 제약. 동시성(더블클릭) 시에도 중복 방지. -- **삭제된 상품/브랜드의 좋아요** — 목록 조회 시 필터링으로 제외. -- **상품 검증 항상 수행** — 등록/취소 모두 ProductService로 상품 존재 + 삭제 여부 확인. 삭제된 상품에 대한 좋아요 조작 방지. -- **참조 방식** — 모두 ID 참조 (userId, productId). -- **User 탈퇴 시** — Like 삭제 + Product.likeCount 감소. -- **Product 삭제 시** — Like 삭제. - -### API - -| 기능 | 액터 | Method | URI | 인증 | -|------|------|--------|-----|------| -| 상품 좋아요 등록 | 회원 | POST | `/api/v1/products/{productId}/likes` | O | -| 상품 좋아요 취소 | 회원 | DELETE | `/api/v1/products/{productId}/likes` | O | -| 내가 좋아요한 상품 목록 조회 | 회원 | GET | `/api/v1/users/{userId}/likes` | O | - -### 유즈케이스 - -**UC-L01: 상품 좋아요 토글 (등록/취소)** - -``` -[기능 흐름] -1. 회원이 productId로 좋아요를 요청한다 (POST 또는 DELETE) -2. 해당 상품이 존재하는지 확인한다 (삭제된 상품 불가) -3. 좋아요 존재 여부를 확인한다 -4-a. 좋아요가 없으면: 좋아요를 저장한다 (등록) -4-b. 좋아요가 있으면: 좋아요를 삭제한다 (취소) - -[예외] -- productId에 해당하는 상품이 없거나 삭제된 경우 404 반환 - -[조건] -- 로그인한 회원만 가능 -- 회원당 상품당 1개만 저장 (유니크 제약) -- POST/DELETE 모두 같은 Facade 메서드(toggleLike)를 호출 -- 이미 좋아요한 상품에 POST → 좋아요 취소 (409 없음) -- 좋아요하지 않은 상품에 DELETE → 좋아요 등록 (404 없음) -``` - -**UC-L02: 내가 좋아요한 상품 목록 조회** - -``` -[기능 흐름] -1. 회원이 자신의 좋아요 목록을 요청한다 -2. likes 테이블에서 해당 회원의 좋아요 목록을 조회한다 -3. 상품/브랜드가 삭제되지 않은 항목만 필터링한다 -4. 상품 정보와 함께 반환한다 - -[조건] -- 로그인한 회원만 가능 -- soft delete된 상품/브랜드는 목록에서 제외 (조회 시 필터링) -- 본인의 좋아요 목록만 조회 가능 (타 유저 접근 불가) -``` - ---- - -## 3.3 주문 - -> **회원으로서**, 여러 상품을 한 번에 주문할 수 있다. -> 주문 시 상품 재고가 확인되고 차감된다. -> 주문 후에도 당시 상품 정보(가격, 이름 등)를 확인할 수 있다. -> -> **관리자로서**, 전체 주문 내역을 조회할 수 있다. - -### 예외 및 정책 - -- **재고 확인 + 차감 원자적 처리** — 재고 확인과 차감은 하나의 트랜잭션 안에서 원자적으로 수행. 일괄 처리 방식(IN 쿼리). -- **스냅샷 저장** — 주문 시점의 상품 정보(상품명, 가격, 브랜드명)를 OrderItem에 복사. 이후 상품이 변경/삭제되어도 주문 내역은 보존. -- **재고 부족 시 주문 전체 실패** — 하나의 상품이라도 재고 부족이면 주문 전체가 롤백. 부분 성공 없음. -- **items 비어있으면 실패** — 주문 항목이 없는 요청은 거부. -- **동시성 이슈** — 추후 비관적 락 또는 낙관적 락으로 해결 예정. -- **가격 변동 검증** — 주문 시점에 클라이언트가 보낸 expectedPrice와 Product의 현재 가격을 비교. 불일치 시 주문 실패 ("가격이 변경되었습니다"). 하이패션 고가 상품의 가격 분쟁 방지. -- **두 가지 주문 경로** — 바로구매(상품 페이지에서 직접)와 장바구니 주문. Order 도메인은 출처를 모르고, Facade가 경로를 조율. 주문 로직은 단일. -- **스냅샷 구조** — OrderItem에 @Embedded ProductSnapshot (productName, brandName, imageUrl 등). productId는 별도 유지 (재구매, 통계용, FK 아님). -- **Order ↔ OrderItem** — ID 참조 (orderId). @OneToMany 미사용. 같은 Aggregate이지만 프로젝트 전체 ID 참조 패턴과 일관성 유지. -- **User 탈퇴 시** — 주문 데이터 DB 유지 (비즈니스 기록). UserSnapshot 불필요 (탈퇴한 유저는 조회 주체가 사라짐). - -### API - -| 기능 | 액터 | Method | URI | 인증 | -|------|------|--------|-----|------| -| 주문 요청 | 회원 | POST | `/api/v1/orders` | O | -| 주문 목록 조회 | 회원 | GET | `/api/v1/orders?startAt={date}&endAt={date}` | O | -| 주문 상세 조회 | 회원 | GET | `/api/v1/orders/{orderId}` | O | -| 주문 목록 조회 | Admin | GET | `/api-admin/v1/orders?page=0&size=20` | LDAP | -| 주문 상세 조회 | Admin | GET | `/api-admin/v1/orders/{orderId}` | LDAP | - -**주문 요청 본문 예시** - -```json -{ - "items": [ - { "productId": 1, "quantity": 2, "expectedPrice": 50000 }, - { "productId": 3, "quantity": 1, "expectedPrice": 120000 } - ] -} -``` - -### 유즈케이스 - -**UC-O01: 주문 요청** - -``` -[기능 흐름] -1. 회원이 상품 목록(productId, quantity, expectedPrice)으로 주문을 요청한다 -2. 각 상품이 존재하는지 확인한다 (삭제된 상품 불가) -3. 각 상품의 expectedPrice와 현재 가격을 비교한다 (불일치 시 실패) -4. 각 상품의 재고가 충분한지 확인한다 -5. 재고를 차감한다 (원자적 처리) -6. 주문 시점의 상품 정보를 스냅샷으로 저장한다 (ProductSnapshot: 상품명, 브랜드명, 이미지 등) -7. 주문을 생성한다 - -[예외] -- 상품이 존재하지 않거나 삭제된 경우 주문 실패 -- expectedPrice와 현재 가격이 불일치하면 주문 실패 -- 재고가 부족한 상품이 하나라도 있으면 주문 전체 실패 -- items가 비어있으면 주문 실패 - -[조건] -- 로그인한 회원만 가능 -- 바로구매/장바구니 주문 모두 같은 API 사용 (Order 도메인은 출처를 모름) -- 재고 확인과 차감은 원자적으로 처리되어야 함 -- 동시성 이슈는 추후 해결 (비관적 락 또는 낙관적 락) -``` - -**UC-O02: 주문 목록 조회 (회원)** - -``` -[기능 흐름] -1. 회원이 기간(startAt, endAt)을 지정하여 주문 목록을 요청한다 -2. 해당 기간 내 본인의 주문 목록을 반환한다 - -[조건] -- 본인의 주문만 조회 가능 -- startAt, endAt은 필수값 (기간 지정 필수) -``` - -**UC-O03: 주문 상세 조회 (회원)** - -``` -[기능 흐름] -1. 회원이 orderId로 주문 상세를 요청한다 -2. 해당 주문이 존재하는지 확인한다 -3. 본인의 주문인지 확인한다 -4. 주문 정보와 스냅샷된 상품 정보를 반환한다 - -[예외] -- orderId에 해당하는 주문이 없으면 404 반환 -- 본인의 주문이 아니면 접근 불가 - -[조건] -- 본인의 주문만 조회 가능 -- 상품 정보는 스냅샷 기준 (현재 상품 상태와 무관) -``` - ---- - -## 3.4 장바구니 - -> **회원으로서**, 구매하고 싶은 상품을 장바구니에 담아두고 나중에 한 번에 확인할 수 있다. -> 담은 상품의 수량을 변경하거나 제거할 수 있다. -> 장바구니에 품절 상품이 있으면 품절 상태로, 삭제된 상품은 판매 종료 상태로 보여준다. - -### 예외 및 정책 - -- **Cart 엔티티 미사용** — DB에 Cart 테이블 없음. CartItem만 DB 엔티티. Cart는 코드에서 일급 컬렉션(First-Class Collection)으로 표현하여 "전체 가격 계산", "선택 항목 추출" 등 장바구니 단위 행위를 응집. -- **가격: 현재 가격 기준** — CartItem에 가격을 저장하지 않음 (가격의 원천은 항상 Product). 조회 시 항상 현재 상품 가격 사용. 하이패션 시즌 세일 시 장바구니에 담아둔 상품의 세일 가격이 자동 반영. -- **재고: 담기 시 미확인** — 장바구니에 담을 때 재고는 확인하지 않음. 주문 시점에만 확인. 장바구니는 "보관함" 성격. -- **주문과 독립** — Cart 도메인과 Order 도메인은 서로를 모른다. Facade가 경로를 조율. - ``` - [장바구니 → 주문 흐름] - 장바구니 → CartItem 조회 → OrderItemCommand 변환 → OrderService 호출 → CartItem 삭제 - - [바로구매 흐름] - 상품 페이지 → OrderItemCommand 직접 생성 → OrderService 호출 - ``` -- **품절 상품** — 장바구니에서 자동 제거하지 않음. 품절 표시하고 유저가 직접 제거. 하이패션에서 신중하게 골라 담은 상품이 자동으로 사라지면 UX 저하. -- **삭제된 상품(SoftDelete)** — 판매 종료 표시 + 주문 불가. SoftDelete이므로 상품 데이터가 남아있어 표시 가능. -- **CartItem 유니크 제약** — userId + productId DB 유니크 제약. 동시성(더블클릭) 시에도 중복 CartItem 방지. -- **참조 방식** — ID 참조 (userId, productId). 스냅샷 불필요 (항상 Product에서 현재 정보 조회). -- **User 탈퇴 시** — CartItem 삭제. -- **제약 조건** - - | 제약 | 값 | 근거 | - |------|-----|------| - | 상품당 최대 수량 | 99개 | 비정상 요청 방어 | - | 장바구니 최대 종류 | 100종 | 합리적 상한 + 페이지네이션 적용 | - | 최소 수량 | 1개 | 수량 0은 불가. 제거는 DELETE API로 명확히 분리 | - | quantity | 필수값 | 기본값 없음. 클라이언트가 명시적으로 전달 | - -### API - -| 기능 | 액터 | Method | URI | 인증 | -|------|------|--------|-----|------| -| 장바구니에 상품 담기 | 회원 | POST | `/api/v1/carts` | O | -| 장바구니 목록 조회 | 회원 | GET | `/api/v1/carts?page=0&size=20` | O | -| 장바구니 수량 변경 | 회원 | PUT | `/api/v1/carts/{cartItemId}` | O | -| 장바구니 항목 제거 | 회원 | DELETE | `/api/v1/carts/{cartItemId}` | O | - -### 유즈케이스 - -**UC-C01: 장바구니에 상품 담기** - -``` -[기능 흐름] -1. 회원이 productId와 quantity(필수)로 담기를 요청한다 -2. 해당 상품이 존재하는지 확인한다 (삭제된 상품 불가) -3. 장바구니에 같은 상품이 이미 있는지 확인한다 -4-a. 없으면: 새 CartItem을 저장한다 -4-b. 있으면: 기존 수량에 요청 수량을 합산한다 - -[예외] -- productId에 해당하는 상품이 없거나 삭제된 경우 실패 -- 합산 후 수량이 99를 초과하면 실패 -- 장바구니에 이미 100종류가 담겨있으면 신규 상품 담기 실패 - -[조건] -- quantity는 필수값 (기본값 없음), 1 이상 -- 담을 때 재고는 확인하지 않음 (주문 시점에 확인) -- 가격은 저장하지 않음 (조회 시 현재 가격 사용) -- 로그인한 회원만 가능 -``` - -**UC-C02: 장바구니 목록 조회** - -``` -[기능 흐름] -1. 회원이 장바구니 목록을 요청한다 (page, size) -2. 해당 회원의 장바구니 항목을 조회한다 -3. 각 항목의 상품/브랜드 상태를 확인한다 -4. 품절(stock=0) 상품은 품절 상태를 표시한다 -5. 삭제된(SoftDelete) 상품은 판매 종료 상태를 표시한다 -6. 상품의 현재 정보(이름, 가격, 재고 상태)와 함께 반환한다 - -[조건] -- 가격은 항상 현재 상품 가격 기준 (CartItem에 가격 저장 안 함, 가격의 원천은 Product) -- 페이지네이션 적용 (장바구니 최대 100종류) -- 본인의 장바구니만 조회 가능 -- 로그인한 회원만 가능 -``` - -**UC-C03: 장바구니 수량 변경** - -``` -[기능 흐름] -1. 회원이 cartItemId와 변경할 quantity를 요청한다 -2. 해당 장바구니 항목이 존재하는지 확인한다 -3. 본인의 장바구니 항목인지 확인한다 -4. 수량을 업데이트한다 - -[예외] -- cartItemId에 해당하는 항목이 없으면 404 반환 -- 수량이 1 미만이면 실패 (최소 1) -- 수량이 99 초과이면 실패 (최대 99) - -[조건] -- 수량 0으로 변경 불가 → 제거는 DELETE API로만 가능 -- 본인의 장바구니 항목만 수정 가능 -- 로그인한 회원만 가능 -``` - -**UC-C04: 장바구니 항목 제거** - -``` -[기능 흐름] -1. 회원이 cartItemId로 제거를 요청한다 -2. 해당 장바구니 항목이 존재하는지 확인한다 -3. 본인의 장바구니 항목인지 확인한다 -4. 해당 항목을 삭제한다 - -[예외] -- cartItemId에 해당하는 항목이 없으면 404 반환 - -[조건] -- 본인의 장바구니 항목만 제거 가능 -- 로그인한 회원만 가능 -``` diff --git a/docs/design/02-sequence-diagrams.md b/docs/design/02-sequence-diagrams.md deleted file mode 100644 index 5ee661f03..000000000 --- a/docs/design/02-sequence-diagrams.md +++ /dev/null @@ -1,116 +0,0 @@ - - -## 주문 요청 - -### 왜 이 다이어그램이 필요한가 - -주문은 이 서비스에서 가장 복잡한 흐름입니다. -**Product 도메인 (상품 검증 + 재고 차감) + Order 도메인 (주문 생성 + 스냅샷)**을 조율해야 하므로 Facade가 필요합니다. -이 다이어그램으로 **트랜잭션 경계**와 **도메인 간 협력 구조**를 검증합니다. - -### 다이어그램 - -```mermaid -sequenceDiagram - participant OC as OrderController - participant OF as OrderFacade - participant PS as ProductService - participant OS as OrderService - participant OR as OrderRepository - - Note left of OC: POST /api/v1/orders - OC->>OF: 주문 요청 - OF->>PS: 상품 조회 및 검증 - PS-->>OF: 상품 - - Note over PS: 상품 예외 처리
(없음, 삭제됨, 재고 부족) - - OF->>PS: 재고 차감 - PS-->>OF: 완료 - - OF->>OS: 주문 생성 (스냅샷 포함) - OS->>OR: 주문 저장 - OR-->>OS: 주문 - OS-->>OF: 주문 - OF-->>OC: 주문 생성 완료 -``` - - ---- - -## 좋아요 등록/취소 - -### 왜 이 다이어그램이 필요한가 - -좋아요는 **Product 검증 + Like 등록/취소**를 조율해야 하므로 Facade가 필요합니다. -POST/DELETE 엔드포인트는 분리하되, **내부적으로 같은 toggleLike 메서드**를 호출합니다. -이 다이어그램으로 **상품 검증 후 Facade의 토글 분기**를 검증합니다. - -### 다이어그램 - -```mermaid -sequenceDiagram - autonumber - - participant LC as LikeController - participant LF as LikeFacade - participant PS as ProductService - participant LS as LikeService - - Note left of LC: POST /products/{id}/likes
DELETE /products/{id}/likes - - LC->>LF: 좋아요 토글 요청 - - Note over LF: @Transactional - LF->>PS: 상품 검증 - activate PS - PS-->>PS: 상품 예외처리 - PS-->>LF: 검증 완료 - deactivate PS - - LF->>LS: 좋아요 존재 확인 - - alt 좋아요가 존재하지 않을 경우 - LF->>LS: save() - else 이미 좋아요한 경우 - LF->>LS: delete() - end -``` - ---- - -## 브랜드 삭제 (연쇄 처리) - -### 왜 이 다이어그램이 필요한가 - -브랜드 삭제는 **Brand 삭제 + Product 연쇄 삭제**를 조율해야 하므로 Facade가 필요합니다. -단일 엔티티 삭제가 아니라, **브랜드 → 해당 브랜드의 상품 전체**를 연쇄적으로 soft delete 해야 합니다. -이 다이어그램으로 **연쇄 삭제의 범위와 순서**를 검증합니다. - -### 다이어그램 - -```mermaid -sequenceDiagram - participant BC as BrandAdminController - participant BF as BrandFacade - participant BS as BrandService - participant PS as ProductService - - Note left of BC: DELETE /api-admin/v1/brands/{id} - BC->>BF: 브랜드 삭제 요청 - BF->>BS: 브랜드 조회 및 검증 - BS-->>BF: 검증 완료 - - Note over BS: 브랜드 예외 처리
(없음, 이미 삭제됨) - - BF->>BS: 브랜드 soft delete - BS-->>BF: 완료 - - BF->>PS: 해당 브랜드 상품 전체 삭제 - PS-->>BF: 완료 - - Note over BF: 장바구니/좋아요는
조회 시 필터링 - - BF-->>BC: 삭제 완료 -``` - diff --git a/docs/design/03-class-diagram.md b/docs/design/03-class-diagram.md deleted file mode 100644 index e43b9385f..000000000 --- a/docs/design/03-class-diagram.md +++ /dev/null @@ -1,170 +0,0 @@ -# 클래스 다이어그램 - -> 도메인 엔티티 중심의 클래스 다이어그램. -> 엔티티의 필드/메서드 설계와 도메인 간 결합 구조를 검증하는 것이 목적입니다. - ---- - -## 다이어그램 - -```mermaid -classDiagram - class User { - LoginId loginId - Password password - UserName name - LocalDate birthDate - Email email - +changePassword(Password) void - } - - class Brand { - String name - +update(String) void - +softDelete() void - +isDeleted() boolean - } - - class Product { - Brand brand - String name - Money price - Stock stock - int likeCount - +update(String, Money, Stock) void - +decreaseStock(int) void - +isSoldOut() boolean - +addLikeCount() void - +subtractLikeCount() void - +softDelete() void - +isDeleted() boolean - } - - class ProductLike { - Long userId - Long productId - } - - class CartItem { - Long userId - Long productId - Quantity quantity - +addQuantity(int) void - +updateQuantity(int) void - } - - class Cart { - <<일급 컬렉션>> - List~CartItem~ items - +getTotalPrice() int - +getItemCount() int - +selectItems(List~Long~) List~CartItem~ - } - - class Order { - Long userId - Money totalPrice - OrderStatus status - } - - class OrderItem { - Long orderId - Long productId - Money orderPrice - Quantity quantity - ProductSnapshot snapshot - } - - class ProductSnapshot { - <> - String productName - String brandName - String imageUrl - } - - class OrderStatus { - <> - ORDERED - } - - Product "*" --> "1" Brand : 객체참조 (FK 없음) - ProductLike "*" --> "1" User : userId - ProductLike "*" --> "1" Product : productId - CartItem "*" --> "1" User : userId - CartItem "*" --> "1" Product : productId - Cart o-- CartItem : 일급 컬렉션 - Order "*" --> "1" User : userId - OrderItem "*" --> "1" Order : orderId - OrderItem *-- ProductSnapshot : @Embedded - Order --> OrderStatus -``` - ---- - -## Value Object 규칙 - -| VO | 검증/행위 | 비즈니스 규칙 | -|---|---|---| -| LoginId | validate() | 영문 + 숫자만 허용 | -| Password | validate(birthDate) | 8~16자, 영문대소문자+숫자+특수문자, 생년월일 포함 불가 | -| Password | matches(rawPassword) | BCrypt로 암호화된 값과 원문 비교 | -| UserName | validate() | 이름 포맷 검증 | -| UserName | mask() | 마지막 글자를 `*`로 마스킹 | -| Email | validate() | 이메일 포맷 검증 | -| Money | validate() | 0 이상이어야 함 | -| Stock | validate() | 0 이상이어야 함 | -| Stock | deduct(quantity) | 재고 부족 시 CoreException(BAD_REQUEST) | -| Stock | hasEnough(quantity) | 재고가 요청 수량 이상인지 확인 | -| Quantity | validate() | 1 이상 99 이하 | -| Quantity | add(amount) | 수량 합산, 결과가 99를 초과하면 예외 | - ---- - -## 엔티티별 비즈니스 규칙 - -| 엔티티 | 메서드 | 비즈니스 규칙 | -|---|---|---| -| User | changePassword(Password) | 새 Password VO로 교체 | -| Brand | update(String) | 브랜드명 변경 | -| Brand | softDelete() / isDeleted() | deleted_at 설정. "삭제"의 정의가 바뀌어도 한 곳만 수정 | -| Product | decreaseStock(int) | 재고 부족 시 CoreException(BAD_REQUEST). Stock VO에 위임 | -| Product | isSoldOut() | stock이 0인지 확인. "품절"의 정의를 캡슐화 | -| Product | addLikeCount() / subtractLikeCount() | 찜 등록/취소 시 likeCount 원자적 증감 | -| Product | softDelete() / isDeleted() | deleted_at 설정. Brand와 동일 패턴 | -| CartItem | addQuantity(int) | 이미 담긴 상품 → 수량 합산. 99 초과 시 예외 | -| CartItem | updateQuantity(int) | 수량 변경. 1~99 범위 검증 | -| Cart | getTotalPrice() | 일급 컬렉션. 장바구니 전체 가격 계산 (Product 현재 가격 기준) | -| Cart | selectItems(List) | 선택한 항목만 추출 (장바구니에서 부분 주문 시) | -| OrderItem | createSnapshot(Product, int) | 정적 팩토리. 주문 시점 Product 정보를 ProductSnapshot으로 복사 | - ---- - -## 관계 정리 - -| 관계 | 카디널리티 | 참조 방식 | 설명 | -|---|---|---|---| -| Brand → Product | 1 : N | 객체참조 + FK 없음 | `product.getBrand().getName()` 접근. DB에 FK 제약조건 없음 | -| User → ProductLike | 1 : N | ID 참조 (userId) | 유니크 제약: userId + productId | -| Product → ProductLike | 1 : N | ID 참조 (productId) | ProductLike = 교차 테이블 | -| User → CartItem | 1 : N | ID 참조 (userId) | 유니크 제약: userId + productId | -| Product → CartItem | 1 : N | ID 참조 (productId) | CartItem에 가격 저장 안 함 | -| User → Order | 1 : N | ID 참조 (userId) | UserSnapshot 불필요 | -| Order → OrderItem | 1 : N | ID 참조 (orderId) | @OneToMany 미사용. 같은 Aggregate이지만 ID 참조 | -| OrderItem → ProductSnapshot | 1 : 1 | @Embedded | 주문 시점 상품 정보 스냅샷 | - ---- - -## 설계 결정 - -- **Rich Domain Model**: 비즈니스 로직은 엔티티와 VO 메서드에 포함한다. Facade는 오케스트레이션만 담당한다. -- **FK 미사용**: 모든 테이블 간 FK 제약조건을 사용하지 않는다. 참조 무결성은 애플리케이션 레벨에서 검증한다. - - Brand → Product만 객체참조 (`@ManyToOne` + `ConstraintMode.NO_CONSTRAINT`). 나머지는 ID 참조. -- **각 도메인 독립 Aggregate Root**: Brand, Product, ProductLike, CartItem, Order 각각 독립. `@OneToMany` 사용하지 않음. -- **Cart 엔티티 없음**: CartItem만 DB 엔티티. Cart는 일급 컬렉션으로 코드에서만 표현. User : Cart = 1:1이라 Cart의 고유 식별자(cartId)가 불필요. -- **likeCount 비정규화**: Product에 likeCount 필드로 캐싱. Like 엔티티가 원본 데이터이고 likeCount는 조회 성능을 위한 파생값(derived data). 찜/취소 시 원자적 증감. -- **N:M 관계**: ProductLike, CartItem 교차 테이블로 해소한다. -- **유니크 제약**: ProductLike(`userId + productId`), CartItem(`userId + productId`)에 DB 유니크 제약. 동시성(더블클릭) 시에도 중복 방지. -- **ProductLike, CartItem 물리 삭제**: 이력이 필요 없는 토글/임시 데이터이므로 Soft Delete 대신 물리 삭제. UNIQUE 제약조건과의 충돌을 방지한다. -- **@Embedded ProductSnapshot**: OrderItem에 스냅샷을 `@Embedded`로 분리. 스냅샷 필드가 추가되어도 ProductSnapshot만 수정하면 된다. -- **OrderItem.productId 유지**: FK 아님. 재구매, 통계 분석을 위한 데이터 연결용. 스냅샷(조회)과 역할이 다르다. -- **Order ↔ OrderItem ID 참조**: 같은 Aggregate이지만 `@OneToMany` + Cascade 대신 ID 참조 + Service에서 `@Transactional` 관리. 프로젝트 전체 패턴과 일관성 유지. N+1 위험 없음. diff --git a/docs/design/04-erd.md b/docs/design/04-erd.md deleted file mode 100644 index ad56a34bf..000000000 --- a/docs/design/04-erd.md +++ /dev/null @@ -1,158 +0,0 @@ -# ERD - -> FK 제약조건은 사용하지 않는다. 관계선은 논리적 참조 관계를 나타내며, 실제 DB에서는 ID 컬럼으로만 참조한다. - ---- - -## 다이어그램 - -```mermaid -erDiagram - users { - bigint id PK - varchar login_id UK - varchar password - varchar name - date birth_date - varchar email - timestamp created_at - timestamp updated_at - timestamp deleted_at - } - - brands { - bigint id PK - varchar name UK - timestamp created_at - timestamp updated_at - timestamp deleted_at - } - - products { - bigint id PK - bigint brand_id - varchar name - int price - int stock - int like_count - timestamp created_at - timestamp updated_at - timestamp deleted_at - } - - likes { - bigint id PK - bigint user_id - bigint product_id - timestamp created_at - } - - cart_items { - bigint id PK - bigint user_id - bigint product_id - int quantity - timestamp created_at - timestamp updated_at - } - - orders { - bigint id PK - bigint user_id - int total_price - varchar status - timestamp created_at - timestamp updated_at - timestamp deleted_at - } - - order_items { - bigint id PK - bigint order_id - bigint product_id - varchar product_name - varchar brand_name - varchar image_url - int order_price - int quantity - timestamp created_at - timestamp updated_at - timestamp deleted_at - } - - brands ||--o{ products : "" - users ||--o{ likes : "" - products ||--o{ likes : "" - users ||--o{ cart_items : "" - products ||--o{ cart_items : "" - users ||--o{ orders : "" - orders ||--|{ order_items : "" -``` - ---- - -## 제약조건 - -| 테이블 | 제약조건 | 설명 | -|---|---|---| -| users | UNIQUE(login_id) | 로그인 ID 중복 방지 | -| brands | UNIQUE(name) | 브랜드명 중복 방지 (409 Conflict) | -| likes | UNIQUE(user_id, product_id) | 1인 1좋아요 보장. 동시성(더블클릭) 방지 | -| cart_items | UNIQUE(user_id, product_id) | 동일 상품 중복 담기 방지 (수량 합산으로 처리) | - ---- - -## 인덱스 권장 - -| 테이블 | 인덱스 컬럼 | 용도 | -|---|---|---| -| products | brand_id | 브랜드별 상품 필터링, 브랜드 삭제 시 연쇄 soft delete | -| likes | user_id | 유저의 좋아요 목록 조회 | -| cart_items | user_id | 유저의 장바구니 조회 | -| orders | (user_id, created_at) | 유저의 주문 목록 조회 (날짜 범위 필터링) | -| order_items | order_id | 주문의 상세 항목 조회 | - ---- - -## 설계 원칙 - -- **FK 제약조건 미사용** — ID 컬럼으로 논리적 참조만. 참조 무결성은 애플리케이션 레벨에서 검증한다. FK의 문제(잠금 전파, 데드락 위험, 삭제 순서 강제)를 회피한다. -- **Brand → Product만 객체참조** — JPA에서 `@ManyToOne` + `ConstraintMode.NO_CONSTRAINT`로 객체 참조. DB에 FK 제약조건은 생성하지 않는다. 나머지 관계는 모두 ID 참조. -- **Soft Delete** — brands, products, orders, order_items에 deleted_at 컬럼으로 논리 삭제. 물리적으로 데이터를 제거하지 않는다. -- **Soft Delete 예외** — likes, cart_items는 이력이 필요 없는 토글/임시 데이터이므로 물리 삭제(Hard Delete). UNIQUE 제약조건과의 충돌을 방지한다. -- **공통 컬럼** — BaseEntity 공통 컬럼(id, created_at, updated_at, deleted_at) 포함. likes는 created_at만 사용. -- **Enum 저장** — OrderStatus 등 Enum은 VARCHAR로 저장한다. -- **스냅샷 컬럼** — order_items의 product_name, brand_name, image_url, order_price는 주문 시점의 스냅샷. JPA `@Embedded ProductSnapshot`으로 관리. -- **like_count 비정규화** — products에 like_count 필드로 캐싱. 찜/취소 시 원자적 증감. likes 테이블이 원본 데이터. - ---- - -## 동시성 제어 - -| 대상 | 방식 | 이유 | -|---|---|---| -| products.stock | 비관적 락 (추후 확정) | 주문 시 재고 차감. 동시 주문에도 재고가 음수가 되어서는 안 된다 | -| products.like_count | 원자적 UPDATE (`SET like_count = like_count + 1`) | 좋아요 등록/취소 시 카운터 증감. 경합이 심하지 않으므로 비관적 락은 과도함 | -| likes | DB UNIQUE 제약 | 더블클릭 시 중복 INSERT 방지. 비관적/분산 락은 과도함 | -| cart_items | DB UNIQUE 제약 | 더블클릭 시 중복 INSERT 방지. 동일 원리 | - ---- - -## 참조 무결성 검증 (애플리케이션 레벨) - -FK 제약조건이 없으므로 다음을 애플리케이션에서 검증해야 한다: - -- **상품 등록 시** — brand_id가 유효한(삭제되지 않은) 브랜드인지 확인 -- **좋아요 토글 시** — product_id가 유효한(삭제되지 않은) 상품인지 확인 -- **장바구니 담기 시** — product_id가 유효한(삭제되지 않은) 상품인지 확인 (재고는 확인하지 않음) -- **주문 생성 시** — 모든 product_id가 유효하고, expectedPrice와 현재 가격이 일치하며, 재고가 충분한지 확인 - ---- - -## order_items.product_id 포함 이유 - -order_items는 스냅샷 데이터(product_name, brand_name, image_url, order_price)를 저장하지만, product_id도 함께 보관한다. 스냅샷은 **조회 편의용**이고, product_id는 **데이터 연결용**으로 역할이 다르다. - -- 재구매 기능: "이 상품을 다시 구매" 시 원본 상품으로 이동 -- 통계 분석: "어떤 상품이 얼마나 팔렸나" 집계 시 product_id 기준으로 GROUP BY -- FK가 아님: 상품이 삭제되어도 주문 내역은 스냅샷으로 보존 diff --git a/docs/design/_shared/CONVENTIONS.md b/docs/design/_shared/CONVENTIONS.md new file mode 100644 index 000000000..cfbdff18e --- /dev/null +++ b/docs/design/_shared/CONVENTIONS.md @@ -0,0 +1,72 @@ +# 공통 설계 원칙 + +## 프로젝트 개요 + +SSENSE와 같은 하이패션 이커머스 플랫폼. Spring Boot 3.4.4, Java 21. + +### 액터 + +| 액터 | 설명 | 식별 방식 | +|------|------|----------| +| 비회원 (Guest) | 로그인하지 않은 사용자 | 헤더 없음 | +| 회원 (User) | 로그인한 사용자 | `X-Loopers-LoginId`, `X-Loopers-LoginPw` | +| 관리자 (Admin) | 사내 관리자 | `X-Loopers-Ldap: loopers.admin` | + +### API Prefix + +- 대고객 API: `/api/v1` +- 어드민 API: `/api-admin/v1` + +--- + +## 도메인 참조 원칙 + +- **DB FK 제약 미사용** — 테이블 간 외래키 제약조건을 사용하지 않는다. 무결성은 애플리케이션 레벨에서 보장. + - FK의 문제: 잠금 전파(데드락 위험), 삭제 순서 강제, 테이블 간 결합 +- **DB 유니크 제약 사용** — 테이블 내부 제약은 사용한다. 동시성(더블클릭 등) 시 중복 방지. +- **참조 방식** + - 같은 도메인 (Brand → Product): 객체참조 + FK 없음 (`@ManyToOne` + `ConstraintMode.NO_CONSTRAINT`) + - 다른 도메인 간: ID 참조 (`private Long userId` 등) +- **Aggregate** — 각 도메인은 독립 Aggregate Root. `@OneToMany` 사용하지 않음. Aggregate 규칙은 Service에서 `@Transactional`로 관리. + +--- + +## Soft Delete 전략 + +- **Soft Delete 대상**: brands, products, orders, order_items → `deleted_at` 컬럼으로 논리 삭제 +- **Hard Delete 대상**: likes, cart_items → 이력이 필요 없는 토글/임시 데이터. UNIQUE 제약조건과의 충돌 방지. + +--- + +## 공통 엔티티 구조 + +- **BaseEntity**: 공통 컬럼 (id, created_at, updated_at, deleted_at) +- **Enum 저장**: VARCHAR로 저장 +- **Rich Domain Model**: 비즈니스 로직은 엔티티와 VO 메서드에 포함. Facade는 오케스트레이션만 담당. + +--- + +## 도메인 용어집 + +| 한글 | 영문 | 설명 | +|------|------|------| +| 회원 | User | 서비스에 가입한 사용자. 구현 완료 (범위 제외) | +| 브랜드 | Brand | 상품을 판매하는 브랜드. Admin이 등록/관리 | +| 상품 | Product | 브랜드에 속한 판매 상품. 재고(stock) 포함 | +| 좋아요 | Like | 회원이 상품에 대해 표현하는 선호. 회원당 상품당 1개 | +| 장바구니 항목 | CartItem | 장바구니에 담긴 개별 상품과 수량 | +| 주문 | Order | 회원이 상품을 구매하기 위한 요청 | +| 주문 항목 | OrderItem | 주문에 포함된 개별 상품의 스냅샷 | +| 스냅샷 | Snapshot | 주문 시점의 상품 정보를 복사하여 저장하는 것 | + +--- + +## 범위 제외 사항 + +| 제외 항목 | 사유 | +|---|---| +| 유저(Users) 기능 | 회원가입, 내 정보 조회, 비밀번호 변경은 이미 구현 완료 | +| 결제(Payment) | 향후 별도 단계에서 추가 개발 예정 | +| 쿠폰(Coupon) | 향후 별도 단계에서 추가 개발 예정 | +| 주문 상태 전이 | 결제 기능과 함께 추가. 현재는 주문 생성(ORDERED)만 | +| 주문 취소 | 현재 범위에서 제공하지 않음 | diff --git a/docs/design/_shared/OVERVIEW.md b/docs/design/_shared/OVERVIEW.md new file mode 100644 index 000000000..e94a2d2e7 --- /dev/null +++ b/docs/design/_shared/OVERVIEW.md @@ -0,0 +1,232 @@ +# 전체 설계 조감도 + +> 전체 구조 파악용. 각 도메인의 상세 스펙은 `{domain}/DESIGN.md` 참조. + +--- + +## 전체 ERD + +> FK 제약조건은 사용하지 않는다. 관계선은 논리적 참조 관계를 나타내며, 실제 DB에서는 ID 컬럼으로만 참조한다. + +```mermaid +erDiagram + users { + bigint id PK + varchar login_id UK + varchar password + varchar name + date birth_date + varchar email + timestamp created_at + timestamp updated_at + timestamp deleted_at + } + + brands { + bigint id PK + varchar name UK + timestamp created_at + timestamp updated_at + timestamp deleted_at + } + + products { + bigint id PK + bigint brand_id + varchar name + int price + int stock + int like_count + timestamp created_at + timestamp updated_at + timestamp deleted_at + } + + likes { + bigint id PK + bigint user_id + bigint product_id + timestamp created_at + } + + cart_items { + bigint id PK + bigint user_id + bigint product_id + int quantity + timestamp created_at + timestamp updated_at + } + + orders { + bigint id PK + bigint user_id + int total_price + varchar status + timestamp created_at + timestamp updated_at + timestamp deleted_at + } + + order_items { + bigint id PK + bigint order_id + bigint product_id + varchar product_name + varchar brand_name + varchar image_url + int order_price + int quantity + timestamp created_at + timestamp updated_at + timestamp deleted_at + } + + brands ||--o{ products : "" + users ||--o{ likes : "" + products ||--o{ likes : "" + users ||--o{ cart_items : "" + products ||--o{ cart_items : "" + users ||--o{ orders : "" + orders ||--|{ order_items : "" +``` + +--- + +## 전체 클래스 다이어그램 + +```mermaid +classDiagram + class User { + LoginId loginId + Password password + UserName name + LocalDate birthDate + Email email + } + + class Brand { + String name + +update(String) void + +softDelete() void + } + + class Product { + Brand brand + String name + Money price + Stock stock + int likeCount + +decreaseStock(int) void + +isSoldOut() boolean + +addLikeCount() void + +subtractLikeCount() void + +softDelete() void + } + + class ProductLike { + Long userId + Long productId + } + + class CartItem { + Long userId + Long productId + Quantity quantity + +addQuantity(int) void + +updateQuantity(int) void + } + + class Cart { + <<일급 컬렉션>> + List~CartItem~ items + +getTotalPrice() int + +selectItems(List~Long~) List~CartItem~ + } + + class Order { + Long userId + Money totalPrice + OrderStatus status + } + + class OrderItem { + Long orderId + Long productId + Money orderPrice + Quantity quantity + ProductSnapshot snapshot + } + + class ProductSnapshot { + <> + String productName + String brandName + String imageUrl + } + + class OrderStatus { + <> + ORDERED + } + + Product "*" --> "1" Brand : 객체참조 (FK 없음) + ProductLike "*" --> "1" User : userId + ProductLike "*" --> "1" Product : productId + CartItem "*" --> "1" User : userId + CartItem "*" --> "1" Product : productId + Cart o-- CartItem : 일급 컬렉션 + Order "*" --> "1" User : userId + OrderItem "*" --> "1" Order : orderId + OrderItem *-- ProductSnapshot : @Embedded + Order --> OrderStatus +``` + +--- + +## 도메인 간 관계 요약 + +| 관계 | 카디널리티 | 참조 방식 | 비고 | +|---|---|---|---| +| Brand → Product | 1 : N | 객체참조 + FK 없음 | `@ManyToOne` + `ConstraintMode.NO_CONSTRAINT` | +| User → ProductLike | 1 : N | ID 참조 (userId) | UNIQUE(userId, productId) | +| Product → ProductLike | 1 : N | ID 참조 (productId) | 교차 테이블 | +| User → CartItem | 1 : N | ID 참조 (userId) | UNIQUE(userId, productId) | +| Product → CartItem | 1 : N | ID 참조 (productId) | 가격 저장 안 함 | +| User → Order | 1 : N | ID 참조 (userId) | UserSnapshot 불필요 | +| Order → OrderItem | 1 : N | ID 참조 (orderId) | @OneToMany 미사용 | +| OrderItem → ProductSnapshot | 1 : 1 | @Embedded | 주문 시점 스냅샷 | + +--- + +## 제약조건 전체 + +| 테이블 | 제약조건 | 설명 | +|---|---|---| +| users | UNIQUE(login_id) | 로그인 ID 중복 방지 | +| brands | UNIQUE(name) | 브랜드명 중복 방지 (409 Conflict) | +| likes | UNIQUE(user_id, product_id) | 1인 1좋아요 보장 | +| cart_items | UNIQUE(user_id, product_id) | 동일 상품 중복 담기 방지 | + +--- + +## 인덱스 전체 + +| 테이블 | 인덱스 컬럼 | 용도 | +|---|---|---| +| products | brand_id | 브랜드별 상품 필터링 | +| likes | user_id | 유저의 좋아요 목록 조회 | +| cart_items | user_id | 유저의 장바구니 조회 | +| orders | (user_id, created_at) | 유저의 주문 목록 조회 | +| order_items | order_id | 주문의 상세 항목 조회 | + +--- + +## 동시성 제어 전체 + +| 대상 | 방식 | 이유 | +|---|---|---| +| products.stock | 비관적 락 (추후 확정) | 재고 음수 방지 | +| products.like_count | 원자적 UPDATE | 경합 낮음 | +| likes | DB UNIQUE 제약 | 더블클릭 중복 방지 | +| cart_items | DB UNIQUE 제약 | 더블클릭 중복 방지 | diff --git a/docs/design/brand/DESIGN.md b/docs/design/brand/DESIGN.md new file mode 100644 index 000000000..550537863 --- /dev/null +++ b/docs/design/brand/DESIGN.md @@ -0,0 +1,170 @@ +# Brand 도메인 설계 + +> 공통 설계 원칙은 `_shared/CONVENTIONS.md` 참조 + +--- + +## 요구사항 + +> **비회원으로서**, 브랜드 정보를 조회할 수 있다. +> **관리자로서**, 브랜드를 등록/수정/삭제하여 입점 브랜드를 관리할 수 있다. + +### 예외 및 정책 + +- **Soft Delete** — `deleted_at` 컬럼으로 논리 삭제. 복구 가능성을 열어둔다. +- **브랜드 삭제 연쇄 처리** — 브랜드 soft delete 시 해당 브랜드의 상품도 전체 soft delete. 장바구니/좋아요는 즉시 삭제하지 않고 조회 시점에 필터링. + ``` + 브랜드 soft delete + └→ 해당 브랜드의 상품 전체 soft delete + └→ 장바구니 항목: 조회 시 필터링 + └→ 좋아요: 조회 시 필터링 + ``` +- **브랜드명 중복 불가** — 동일한 브랜드명이 이미 존재하면 등록/수정 실패 (409 Conflict) +- **고객 vs Admin 응답 차이** — 고객에게는 기본 정보만, Admin에게는 등록일/수정일/삭제 여부 등 관리 정보 추가 제공 +- **soft delete된 브랜드** — 고객 조회 불가 (404 반환) +- **Brand → Product 참조** — 객체참조 + FK 없음. `@ManyToOne` + `ConstraintMode.NO_CONSTRAINT` +- **독립 Aggregate Root** — Brand와 Product는 별도 Aggregate Root. 브랜드 삭제 → 상품 soft delete는 Facade에서 조율. + +### API + +| 기능 | 액터 | Method | URI | 인증 | +|------|------|--------|-----|------| +| 브랜드 정보 조회 | 비회원/회원 | GET | `/api/v1/brands/{brandId}` | X | +| 브랜드 목록 조회 | Admin | GET | `/api-admin/v1/brands?page=0&size=20` | LDAP | +| 브랜드 상세 조회 | Admin | GET | `/api-admin/v1/brands/{brandId}` | LDAP | +| 브랜드 등록 | Admin | POST | `/api-admin/v1/brands` | LDAP | +| 브랜드 정보 수정 | Admin | PUT | `/api-admin/v1/brands/{brandId}` | LDAP | +| 브랜드 삭제 | Admin | DELETE | `/api-admin/v1/brands/{brandId}` | LDAP | + +--- + +## 유즈케이스 + +**UC-B01: 브랜드 정보 조회 (비회원)** + +``` +[기능 흐름] +1. 비회원이 brandId로 브랜드 정보를 요청한다 +2. 해당 브랜드가 존재하는지 확인한다 +3. 브랜드 기본 정보를 반환한다 + +[예외] +- brandId에 해당하는 브랜드가 없으면 404 반환 +- soft delete된 브랜드는 조회 불가 (404 반환) +``` + +**UC-B02: 브랜드 등록 (Admin)** + +``` +[기능 흐름] +1. Admin이 브랜드 정보(이름 등)를 입력한다 +2. 동일한 브랜드명이 이미 존재하는지 확인한다 +3. 브랜드를 저장한다 +4. 생성된 브랜드 정보를 반환한다 + +[예외] +- 이미 존재하는 브랜드명이면 등록 실패 (409 Conflict) + +[조건] +- 브랜드명은 필수값이며 중복 불가 +``` + +**UC-B03: 브랜드 정보 수정 (Admin)** + +``` +[기능 흐름] +1. Admin이 brandId와 수정할 정보를 요청한다 +2. 해당 브랜드가 존재하는지 확인한다 +3. 브랜드 정보를 업데이트한다 + +[예외] +- brandId에 해당하는 브랜드가 없거나 삭제된 경우 404 반환 +- 수정하려는 브랜드명이 다른 브랜드와 중복되면 409 Conflict +``` + +**UC-B04: 브랜드 삭제 (Admin)** + +``` +[기능 흐름] +1. Admin이 brandId로 삭제를 요청한다 +2. 해당 브랜드가 존재하는지 확인한다 +3. 해당 브랜드를 soft delete 한다 +4. 해당 브랜드의 모든 상품도 soft delete 한다 + +[예외] +- brandId에 해당하는 브랜드가 없으면 404 반환 +- 이미 삭제된 브랜드이면 404 반환 +``` + +--- + +## 시퀀스 다이어그램: 브랜드 삭제 (연쇄 처리) + +> 브랜드 삭제는 **Brand 삭제 + Product 연쇄 삭제**를 조율해야 하므로 Facade가 필요하다. + +```mermaid +sequenceDiagram + participant BC as BrandAdminController + participant BF as BrandFacade + participant BS as BrandService + participant PS as ProductService + + Note left of BC: DELETE /api-admin/v1/brands/{id} + BC->>BF: 브랜드 삭제 요청 + BF->>BS: 브랜드 조회 및 검증 + BS-->>BF: 검증 완료 + + Note over BS: 브랜드 예외 처리
(없음, 이미 삭제됨) + + BF->>BS: 브랜드 soft delete + BS-->>BF: 완료 + + BF->>PS: 해당 브랜드 상품 전체 삭제 + PS-->>BF: 완료 + + Note over BF: 장바구니/좋아요는
조회 시 필터링 + + BF-->>BC: 삭제 완료 +``` + +--- + +## 클래스 설계 + +```mermaid +classDiagram + class Brand { + String name + +update(String) void + +softDelete() void + +isDeleted() boolean + } +``` + +### 비즈니스 규칙 + +| 메서드 | 비즈니스 규칙 | +|---|---| +| update(String) | 브랜드명 변경 | +| softDelete() / isDeleted() | deleted_at 설정. "삭제"의 정의가 바뀌어도 한 곳만 수정 | + +--- + +## ERD + +```mermaid +erDiagram + brands { + bigint id PK + varchar name UK + timestamp created_at + timestamp updated_at + timestamp deleted_at + } +``` + +### 제약조건 + +| 제약조건 | 설명 | +|---|---| +| UNIQUE(name) | 브랜드명 중복 방지 (409 Conflict) | diff --git a/docs/design/cart/DESIGN.md b/docs/design/cart/DESIGN.md new file mode 100644 index 000000000..03ca9fa79 --- /dev/null +++ b/docs/design/cart/DESIGN.md @@ -0,0 +1,212 @@ +# Cart 도메인 설계 + +> 공통 설계 원칙은 `_shared/CONVENTIONS.md` 참조 + +--- + +## 요구사항 + +> **회원으로서**, 구매하고 싶은 상품을 장바구니에 담아두고 나중에 한 번에 확인할 수 있다. +> 담은 상품의 수량을 변경하거나 제거할 수 있다. +> 장바구니에 품절 상품이 있으면 품절 상태로, 삭제된 상품은 판매 종료 상태로 보여준다. + +### 예외 및 정책 + +- **Cart 엔티티 미사용** — DB에 Cart 테이블 없음. CartItem만 DB 엔티티. Cart는 코드에서 일급 컬렉션(First-Class Collection)으로 표현하여 "전체 가격 계산", "선택 항목 추출" 등 장바구니 단위 행위를 응집. +- **가격: 현재 가격 기준** — CartItem에 가격을 저장하지 않음 (가격의 원천은 항상 Product). 조회 시 항상 현재 상품 가격 사용. 하이패션 시즌 세일 시 자동 반영. +- **재고: 담기 시 미확인** — 장바구니에 담을 때 재고는 확인하지 않음. 주문 시점에만 확인. 장바구니는 "보관함" 성격. +- **주문과 독립** — Cart 도메인과 Order 도메인은 서로를 모른다. Facade가 경로를 조율. + ``` + [장바구니 → 주문 흐름] + 장바구니 → CartItem 조회 → OrderItemCommand 변환 → OrderService 호출 → CartItem 삭제 + + [바로구매 흐름] + 상품 페이지 → OrderItemCommand 직접 생성 → OrderService 호출 + ``` +- **품절 상품** — 자동 제거하지 않음. 품절 표시하고 유저가 직접 제거. 하이패션에서 신중하게 골라 담은 상품이 자동으로 사라지면 UX 저하. +- **삭제된 상품(SoftDelete)** — 판매 종료 표시 + 주문 불가. +- **CartItem 유니크 제약** — userId + productId DB 유니크 제약. 동시성(더블클릭) 시에도 중복 방지. +- **참조 방식** — ID 참조 (userId, productId). 스냅샷 불필요. +- **물리 삭제(Hard Delete)** — 임시 데이터. UNIQUE 제약과 충돌 방지. +- **제약 조건** + + | 제약 | 값 | 근거 | + |------|-----|------| + | 상품당 최대 수량 | 99개 | 비정상 요청 방어 | + | 장바구니 최대 종류 | 100종 | 합리적 상한 + 페이지네이션 적용 | + | 최소 수량 | 1개 | 수량 0은 불가. 제거는 DELETE API로 명확히 분리 | + | quantity | 필수값 | 기본값 없음. 클라이언트가 명시적으로 전달 | + +### API + +| 기능 | 액터 | Method | URI | 인증 | +|------|------|--------|-----|------| +| 장바구니에 상품 담기 | 회원 | POST | `/api/v1/carts` | O | +| 장바구니 목록 조회 | 회원 | GET | `/api/v1/carts?page=0&size=20` | O | +| 장바구니 수량 변경 | 회원 | PUT | `/api/v1/carts/{cartItemId}` | O | +| 장바구니 항목 제거 | 회원 | DELETE | `/api/v1/carts/{cartItemId}` | O | + +--- + +## 유즈케이스 + +**UC-C01: 장바구니에 상품 담기** + +``` +[기능 흐름] +1. 회원이 productId와 quantity(필수)로 담기를 요청한다 +2. 해당 상품이 존재하는지 확인한다 (삭제된 상품 불가) +3. 장바구니에 같은 상품이 이미 있는지 확인한다 +4-a. 없으면: 새 CartItem을 저장한다 +4-b. 있으면: 기존 수량에 요청 수량을 합산한다 + +[예외] +- productId에 해당하는 상품이 없거나 삭제된 경우 실패 +- 합산 후 수량이 99를 초과하면 실패 +- 장바구니에 이미 100종류가 담겨있으면 신규 상품 담기 실패 + +[조건] +- quantity는 필수값 (기본값 없음), 1 이상 +- 담을 때 재고는 확인하지 않음 (주문 시점에 확인) +- 가격은 저장하지 않음 (조회 시 현재 가격 사용) +- 로그인한 회원만 가능 +``` + +**UC-C02: 장바구니 목록 조회** + +``` +[기능 흐름] +1. 회원이 장바구니 목록을 요청한다 (page, size) +2. 해당 회원의 장바구니 항목을 조회한다 +3. 각 항목의 상품/브랜드 상태를 확인한다 +4. 품절(stock=0) 상품은 품절 상태를 표시한다 +5. 삭제된(SoftDelete) 상품은 판매 종료 상태를 표시한다 +6. 상품의 현재 정보(이름, 가격, 재고 상태)와 함께 반환한다 + +[조건] +- 가격은 항상 현재 상품 가격 기준 +- 페이지네이션 적용 (장바구니 최대 100종류) +- 본인의 장바구니만 조회 가능 +- 로그인한 회원만 가능 +``` + +**UC-C03: 장바구니 수량 변경** + +``` +[기능 흐름] +1. 회원이 cartItemId와 변경할 quantity를 요청한다 +2. 해당 장바구니 항목이 존재하는지 확인한다 +3. 본인의 장바구니 항목인지 확인한다 +4. 수량을 업데이트한다 + +[예외] +- cartItemId에 해당하는 항목이 없으면 404 반환 +- 수량이 1 미만이면 실패 (최소 1) +- 수량이 99 초과이면 실패 (최대 99) + +[조건] +- 수량 0으로 변경 불가 → 제거는 DELETE API로만 가능 +- 본인의 장바구니 항목만 수정 가능 +- 로그인한 회원만 가능 +``` + +**UC-C04: 장바구니 항목 제거** + +``` +[기능 흐름] +1. 회원이 cartItemId로 제거를 요청한다 +2. 해당 장바구니 항목이 존재하는지 확인한다 +3. 본인의 장바구니 항목인지 확인한다 +4. 해당 항목을 삭제한다 + +[예외] +- cartItemId에 해당하는 항목이 없으면 404 반환 + +[조건] +- 본인의 장바구니 항목만 제거 가능 +- 로그인한 회원만 가능 +``` + +--- + +## 클래스 설계 + +```mermaid +classDiagram + class CartItem { + Long userId + Long productId + Quantity quantity + +addQuantity(int) void + +updateQuantity(int) void + } + + class Cart { + <<일급 컬렉션>> + List~CartItem~ items + +getTotalPrice() int + +getItemCount() int + +selectItems(List~Long~) List~CartItem~ + } + + CartItem "*" --> "1" User : userId + CartItem "*" --> "1" Product : productId + Cart o-- CartItem : 일급 컬렉션 +``` + +### Value Object + +| VO | 검증/행위 | 비즈니스 규칙 | +|---|---|---| +| Quantity | validate() | 1 이상 99 이하 | +| Quantity | add(amount) | 수량 합산, 결과가 99를 초과하면 예외 | + +### 비즈니스 규칙 + +| 엔티티 | 메서드 | 비즈니스 규칙 | +|---|---|---| +| CartItem | addQuantity(int) | 이미 담긴 상품 → 수량 합산. 99 초과 시 예외 | +| CartItem | updateQuantity(int) | 수량 변경. 1~99 범위 검증 | +| Cart | getTotalPrice() | 일급 컬렉션. 장바구니 전체 가격 계산 (Product 현재 가격 기준) | +| Cart | selectItems(List) | 선택한 항목만 추출 (장바구니에서 부분 주문 시) | + +--- + +## ERD + +```mermaid +erDiagram + cart_items { + bigint id PK + bigint user_id + bigint product_id + int quantity + timestamp created_at + timestamp updated_at + } + + users ||--o{ cart_items : "" + products ||--o{ cart_items : "" +``` + +### 제약조건 + +| 제약조건 | 설명 | +|---|---| +| UNIQUE(user_id, product_id) | 동일 상품 중복 담기 방지 (수량 합산으로 처리) | + +### 인덱스 + +| 인덱스 컬럼 | 용도 | +|---|---| +| cart_items.user_id | 유저의 장바구니 조회 | + +### 동시성 제어 + +| 대상 | 방식 | 이유 | +|---|---|---| +| cart_items | DB UNIQUE 제약 | 더블클릭 시 중복 INSERT 방지 | + +### 참조 무결성 검증 (애플리케이션 레벨) + +- 장바구니 담기 시 — product_id가 유효한(삭제되지 않은) 상품인지 확인 (재고는 확인하지 않음) diff --git a/docs/design/like/DESIGN.md b/docs/design/like/DESIGN.md new file mode 100644 index 000000000..2a86579e6 --- /dev/null +++ b/docs/design/like/DESIGN.md @@ -0,0 +1,160 @@ +# Like 도메인 설계 + +> 공통 설계 원칙은 `_shared/CONVENTIONS.md` 참조 + +--- + +## 요구사항 + +> **회원으로서**, 마음에 드는 상품에 좋아요를 눌러 선호를 표현하고, 나중에 다시 찾아볼 수 있다. +> 이미 좋아요한 상품은 취소할 수 있다. + +### 예외 및 정책 + +- **좋아요 수: Product.likeCount 캐시** — Like 엔티티가 원본 데이터, Product.likeCount는 조회 성능을 위한 파생값(derived data). 찜/취소 시 원자적 증감. +- **API 방식: 엔드포인트 분리 + 내부 토글** — POST/DELETE 엔드포인트는 분리하되, 내부적으로 같은 Facade 메서드(toggleLike)를 호출. 409/404 없음. +- **회원당 상품당 1개** — userId + productId DB 유니크 제약. 동시성(더블클릭) 시에도 중복 방지. +- **삭제된 상품/브랜드의 좋아요** — 목록 조회 시 필터링으로 제외. +- **상품 검증 항상 수행** — 등록/취소 모두 ProductService로 상품 존재 + 삭제 여부 확인. +- **참조 방식** — 모두 ID 참조 (userId, productId). +- **물리 삭제(Hard Delete)** — 이력 불필요. UNIQUE 제약과 충돌 방지. + +### API + +| 기능 | 액터 | Method | URI | 인증 | +|------|------|--------|-----|------| +| 상품 좋아요 등록 | 회원 | POST | `/api/v1/products/{productId}/likes` | O | +| 상품 좋아요 취소 | 회원 | DELETE | `/api/v1/products/{productId}/likes` | O | +| 내가 좋아요한 상품 목록 조회 | 회원 | GET | `/api/v1/users/{userId}/likes` | O | + +--- + +## 유즈케이스 + +**UC-L01: 상품 좋아요 토글 (등록/취소)** + +``` +[기능 흐름] +1. 회원이 productId로 좋아요를 요청한다 (POST 또는 DELETE) +2. 해당 상품이 존재하는지 확인한다 (삭제된 상품 불가) +3. 좋아요 존재 여부를 확인한다 +4-a. 좋아요가 없으면: 좋아요를 저장한다 (등록) +4-b. 좋아요가 있으면: 좋아요를 삭제한다 (취소) + +[예외] +- productId에 해당하는 상품이 없거나 삭제된 경우 404 반환 + +[조건] +- 로그인한 회원만 가능 +- 회원당 상품당 1개만 저장 (유니크 제약) +- POST/DELETE 모두 같은 Facade 메서드(toggleLike)를 호출 +- 이미 좋아요한 상품에 POST → 좋아요 취소 (409 없음) +- 좋아요하지 않은 상품에 DELETE → 좋아요 등록 (404 없음) +``` + +**UC-L02: 내가 좋아요한 상품 목록 조회** + +``` +[기능 흐름] +1. 회원이 자신의 좋아요 목록을 요청한다 +2. likes 테이블에서 해당 회원의 좋아요 목록을 조회한다 +3. 상품/브랜드가 삭제되지 않은 항목만 필터링한다 +4. 상품 정보와 함께 반환한다 + +[조건] +- 로그인한 회원만 가능 +- soft delete된 상품/브랜드는 목록에서 제외 (조회 시 필터링) +- 본인의 좋아요 목록만 조회 가능 (타 유저 접근 불가) +``` + +--- + +## 시퀀스 다이어그램: 좋아요 등록/취소 + +> 좋아요는 **Product 검증 + Like 등록/취소**를 조율해야 하므로 Facade가 필요하다. + +```mermaid +sequenceDiagram + autonumber + + participant LC as LikeController + participant LF as LikeFacade + participant PS as ProductService + participant LS as LikeService + + Note left of LC: POST /products/{id}/likes
DELETE /products/{id}/likes + + LC->>LF: 좋아요 토글 요청 + + Note over LF: @Transactional + LF->>PS: 상품 검증 + activate PS + PS-->>PS: 상품 예외처리 + PS-->>LF: 검증 완료 + deactivate PS + + LF->>LS: 좋아요 존재 확인 + + alt 좋아요가 존재하지 않을 경우 + LF->>LS: save() + else 이미 좋아요한 경우 + LF->>LS: delete() + end +``` + +--- + +## 클래스 설계 + +```mermaid +classDiagram + class ProductLike { + Long userId + Long productId + } + + ProductLike "*" --> "1" User : userId + ProductLike "*" --> "1" Product : productId +``` + +> ProductLike는 created_at만 사용 (BaseEntity의 updated_at, deleted_at 불필요). + +--- + +## ERD + +```mermaid +erDiagram + likes { + bigint id PK + bigint user_id + bigint product_id + timestamp created_at + } + + users ||--o{ likes : "" + products ||--o{ likes : "" +``` + +### 제약조건 + +| 제약조건 | 설명 | +|---|---| +| UNIQUE(user_id, product_id) | 1인 1좋아요 보장. 동시성(더블클릭) 방지 | + +### 인덱스 + +| 인덱스 컬럼 | 용도 | +|---|---| +| likes.user_id | 유저의 좋아요 목록 조회 | + +### 동시성 제어 + +| 대상 | 방식 | 이유 | +|---|---|---| +| likes | DB UNIQUE 제약 | 더블클릭 시 중복 INSERT 방지. 비관적/분산 락은 과도함 | +| products.like_count | 원자적 UPDATE (`SET like_count = like_count + 1`) | 경합이 심하지 않으므로 비관적 락은 과도함 | + +### 참조 무결성 검증 (애플리케이션 레벨) + +- 좋아요 토글 시 — product_id가 유효한(삭제되지 않은) 상품인지 확인 diff --git a/docs/design/order/DESIGN.md b/docs/design/order/DESIGN.md new file mode 100644 index 000000000..351dd87da --- /dev/null +++ b/docs/design/order/DESIGN.md @@ -0,0 +1,248 @@ +# Order 도메인 설계 + +> 공통 설계 원칙은 `_shared/CONVENTIONS.md` 참조 + +--- + +## 요구사항 + +> **회원으로서**, 여러 상품을 한 번에 주문할 수 있다. +> 주문 시 상품 재고가 확인되고 차감된다. +> 주문 후에도 당시 상품 정보(가격, 이름 등)를 확인할 수 있다. +> +> **관리자로서**, 전체 주문 내역을 조회할 수 있다. + +### 예외 및 정책 + +- **재고 확인 + 차감 원자적 처리** — 재고 확인과 차감은 하나의 트랜잭션 안에서 원자적으로 수행. 일괄 처리 방식(IN 쿼리). +- **스냅샷 저장** — 주문 시점의 상품 정보(상품명, 가격, 브랜드명)를 OrderItem에 복사. 이후 상품이 변경/삭제되어도 주문 내역은 보존. +- **재고 부족 시 주문 전체 실패** — 하나의 상품이라도 재고 부족이면 주문 전체가 롤백. 부분 성공 없음. +- **items 비어있으면 실패** — 주문 항목이 없는 요청은 거부. +- **동시성 이슈** — 추후 비관적 락 또는 낙관적 락으로 해결 예정. +- **가격 변동 검증** — 주문 시점에 클라이언트가 보낸 expectedPrice와 Product의 현재 가격을 비교. 불일치 시 주문 실패 ("가격이 변경되었습니다"). 하이패션 고가 상품의 가격 분쟁 방지. +- **두 가지 주문 경로** — 바로구매(상품 페이지에서 직접)와 장바구니 주문. Order 도메인은 출처를 모르고, Facade가 경로를 조율. 주문 로직은 단일. +- **스냅샷 구조** — OrderItem에 @Embedded ProductSnapshot (productName, brandName, imageUrl 등). productId는 별도 유지 (재구매, 통계용, FK 아님). +- **Order ↔ OrderItem** — ID 참조 (orderId). @OneToMany 미사용. 같은 Aggregate이지만 프로젝트 전체 ID 참조 패턴과 일관성 유지. + +### API + +| 기능 | 액터 | Method | URI | 인증 | +|------|------|--------|-----|------| +| 주문 요청 | 회원 | POST | `/api/v1/orders` | O | +| 주문 목록 조회 | 회원 | GET | `/api/v1/orders?startAt={date}&endAt={date}` | O | +| 주문 상세 조회 | 회원 | GET | `/api/v1/orders/{orderId}` | O | +| 주문 목록 조회 | Admin | GET | `/api-admin/v1/orders?page=0&size=20` | LDAP | +| 주문 상세 조회 | Admin | GET | `/api-admin/v1/orders/{orderId}` | LDAP | + +### 주문 요청 본문 예시 + +```json +{ + "items": [ + { "productId": 1, "quantity": 2, "expectedPrice": 50000 }, + { "productId": 3, "quantity": 1, "expectedPrice": 120000 } + ] +} +``` + +--- + +## 유즈케이스 + +**UC-O01: 주문 요청** + +``` +[기능 흐름] +1. 회원이 상품 목록(productId, quantity, expectedPrice)으로 주문을 요청한다 +2. 각 상품이 존재하는지 확인한다 (삭제된 상품 불가) +3. 각 상품의 expectedPrice와 현재 가격을 비교한다 (불일치 시 실패) +4. 각 상품의 재고가 충분한지 확인한다 +5. 재고를 차감한다 (원자적 처리) +6. 주문 시점의 상품 정보를 스냅샷으로 저장한다 (ProductSnapshot: 상품명, 브랜드명, 이미지 등) +7. 주문을 생성한다 + +[예외] +- 상품이 존재하지 않거나 삭제된 경우 주문 실패 +- expectedPrice와 현재 가격이 불일치하면 주문 실패 +- 재고가 부족한 상품이 하나라도 있으면 주문 전체 실패 +- items가 비어있으면 주문 실패 + +[조건] +- 로그인한 회원만 가능 +- 바로구매/장바구니 주문 모두 같은 API 사용 (Order 도메인은 출처를 모름) +- 재고 확인과 차감은 원자적으로 처리되어야 함 +- 동시성 이슈는 추후 해결 (비관적 락 또는 낙관적 락) +``` + +**UC-O02: 주문 목록 조회 (회원)** + +``` +[기능 흐름] +1. 회원이 기간(startAt, endAt)을 지정하여 주문 목록을 요청한다 +2. 해당 기간 내 본인의 주문 목록을 반환한다 + +[조건] +- 본인의 주문만 조회 가능 +- startAt, endAt은 필수값 (기간 지정 필수) +``` + +**UC-O03: 주문 상세 조회 (회원)** + +``` +[기능 흐름] +1. 회원이 orderId로 주문 상세를 요청한다 +2. 해당 주문이 존재하는지 확인한다 +3. 본인의 주문인지 확인한다 +4. 주문 정보와 스냅샷된 상품 정보를 반환한다 + +[예외] +- orderId에 해당하는 주문이 없으면 404 반환 +- 본인의 주문이 아니면 접근 불가 + +[조건] +- 본인의 주문만 조회 가능 +- 상품 정보는 스냅샷 기준 (현재 상품 상태와 무관) +``` + +--- + +## 시퀀스 다이어그램: 주문 요청 + +> 주문은 **Product 도메인 (상품 검증 + 재고 차감) + Order 도메인 (주문 생성 + 스냅샷)**을 조율해야 하므로 Facade가 필요하다. + +```mermaid +sequenceDiagram + participant OC as OrderController + participant OF as OrderFacade + participant PS as ProductService + participant OS as OrderService + participant OR as OrderRepository + + Note left of OC: POST /api/v1/orders + OC->>OF: 주문 요청 + OF->>PS: 상품 조회 및 검증 + PS-->>OF: 상품 + + Note over PS: 상품 예외 처리
(없음, 삭제됨, 재고 부족) + + OF->>PS: 재고 차감 + PS-->>OF: 완료 + + OF->>OS: 주문 생성 (스냅샷 포함) + OS->>OR: 주문 저장 + OR-->>OS: 주문 + OS-->>OF: 주문 + OF-->>OC: 주문 생성 완료 +``` + +--- + +## 클래스 설계 + +```mermaid +classDiagram + class Order { + Long userId + Money totalPrice + OrderStatus status + } + + class OrderItem { + Long orderId + Long productId + Money orderPrice + Quantity quantity + ProductSnapshot snapshot + } + + class ProductSnapshot { + <> + String productName + String brandName + String imageUrl + } + + class OrderStatus { + <> + ORDERED + } + + Order "*" --> "1" User : userId + OrderItem "*" --> "1" Order : orderId + OrderItem *-- ProductSnapshot : @Embedded + Order --> OrderStatus +``` + +### 비즈니스 규칙 + +| 엔티티 | 메서드 | 비즈니스 규칙 | +|---|---|---| +| OrderItem | createSnapshot(Product, int) | 정적 팩토리. 주문 시점 Product 정보를 ProductSnapshot으로 복사 | + +### 관계 정리 + +| 관계 | 참조 방식 | 설명 | +|---|---|---| +| User → Order | ID 참조 (userId) | UserSnapshot 불필요 | +| Order → OrderItem | ID 참조 (orderId) | @OneToMany 미사용. 같은 Aggregate이지만 ID 참조 | +| OrderItem → ProductSnapshot | @Embedded | 주문 시점 상품 정보 스냅샷 | +| OrderItem.productId | ID 유지 (FK 아님) | 재구매, 통계 분석을 위한 데이터 연결용 | + +--- + +## ERD + +```mermaid +erDiagram + orders { + bigint id PK + bigint user_id + int total_price + varchar status + timestamp created_at + timestamp updated_at + timestamp deleted_at + } + + order_items { + bigint id PK + bigint order_id + bigint product_id + varchar product_name + varchar brand_name + varchar image_url + int order_price + int quantity + timestamp created_at + timestamp updated_at + timestamp deleted_at + } + + users ||--o{ orders : "" + orders ||--|{ order_items : "" +``` + +### 인덱스 + +| 인덱스 컬럼 | 용도 | +|---|---| +| orders (user_id, created_at) | 유저의 주문 목록 조회 (날짜 범위 필터링) | +| order_items.order_id | 주문의 상세 항목 조회 | + +### 동시성 제어 + +| 대상 | 방식 | 이유 | +|---|---|---| +| products.stock | 비관적 락 (추후 확정) | 주문 시 재고 차감. 동시 주문에도 재고가 음수가 되어서는 안 된다 | + +### 참조 무결성 검증 (애플리케이션 레벨) + +- 주문 생성 시 — 모든 product_id가 유효하고, expectedPrice와 현재 가격이 일치하며, 재고가 충분한지 확인 + +### order_items.product_id 포함 이유 + +스냅샷은 **조회 편의용**이고, product_id는 **데이터 연결용**으로 역할이 다르다. + +- 재구매 기능: "이 상품을 다시 구매" 시 원본 상품으로 이동 +- 통계 분석: "어떤 상품이 얼마나 팔렸나" 집계 시 product_id 기준으로 GROUP BY +- FK가 아님: 상품이 삭제되어도 주문 내역은 스냅샷으로 보존 diff --git a/docs/design/product/DESIGN.md b/docs/design/product/DESIGN.md new file mode 100644 index 000000000..0680c8ae6 --- /dev/null +++ b/docs/design/product/DESIGN.md @@ -0,0 +1,203 @@ +# Product 도메인 설계 + +> 공통 설계 원칙은 `_shared/CONVENTIONS.md` 참조 + +--- + +## 요구사항 + +> **비회원으로서**, 상품 목록을 둘러보고 상세 정보를 확인할 수 있다. +> **관리자로서**, 상품을 등록/수정/삭제하여 판매 상품을 관리할 수 있다. + +### 예외 및 정책 + +- **Soft Delete** — `deleted_at` 컬럼으로 논리 삭제 +- **재고: Product 필드로 관리** — 별도 Stock 도메인 분리 없이 Product 엔티티의 stock 필드로 관리. 등록/수정 시 재고 설정, 주문 시 차감. +- **고객 vs Admin 응답 차이** — 고객에게는 기본 정보만, Admin에게는 관리 정보 추가 제공 +- **soft delete된 상품** — 고객 조회 불가 (404 반환) +- **Brand → Product 참조** — 객체참조 + FK 없음. `@ManyToOne` + `ConstraintMode.NO_CONSTRAINT`. `product.getBrand().getName()` 접근 가능. +- **Product.likeCount 캐시 필드** — 찜 수 조회 성능을 위해 Product에 likeCount 캐싱. 찜/취소 시 원자적 증감. +- **독립 Aggregate Root** — Brand와 Product는 별도 Aggregate Root. + +### API + +| 기능 | 액터 | Method | URI | 인증 | +|------|------|--------|-----|------| +| 상품 목록 조회 | 비회원/회원 | GET | `/api/v1/products` | X | +| 상품 정보 조회 | 비회원/회원 | GET | `/api/v1/products/{productId}` | X | +| 상품 목록 조회 | Admin | GET | `/api-admin/v1/products?page=0&size=20&brandId={brandId}` | LDAP | +| 상품 상세 조회 | Admin | GET | `/api-admin/v1/products/{productId}` | LDAP | +| 상품 등록 | Admin | POST | `/api-admin/v1/products` | LDAP | +| 상품 정보 수정 | Admin | PUT | `/api-admin/v1/products/{productId}` | LDAP | +| 상품 삭제 | Admin | DELETE | `/api-admin/v1/products/{productId}` | LDAP | + +### 상품 목록 조회 쿼리 파라미터 + +| 파라미터 | 설명 | 기본값 | +|----------|------|--------| +| `brandId` | 특정 브랜드 상품 필터링 | - (선택) | +| `sort` | 정렬 기준: `latest` / `price_asc` / `likes_desc` | `latest` | +| `page` | 페이지 번호 | 0 | +| `size` | 페이지당 상품 수 | 20 | + +> `likes_desc` 정렬 시 좋아요 수는 Product.likeCount 필드로 정렬. + +--- + +## 유즈케이스 + +**UC-P01: 상품 목록 조회 (비회원)** + +``` +[기능 흐름] +1. 비회원이 상품 목록을 요청한다 (선택: brandId, sort, page, size) +2. soft delete된 상품/브랜드를 제외한다 +3. 정렬 조건에 맞게 정렬한다 +4. 페이지네이션하여 상품 목록을 반환한다 +5. 각 상품의 좋아요 수를 Product.likeCount로 함께 반환한다 + +[대안 흐름] +- brandId가 없으면 전체 상품 조회 +- sort가 없으면 latest(최신순) 기본 적용 +``` + +**UC-P02: 상품 정보 조회 (비회원)** + +``` +[기능 흐름] +1. 비회원이 productId로 상품 정보를 요청한다 +2. 해당 상품이 존재하는지 확인한다 +3. 상품 정보와 함께 좋아요 수(Product.likeCount)를 반환한다 + +[예외] +- productId에 해당하는 상품이 없거나 삭제된 경우 404 반환 +``` + +**UC-P03: 상품 등록 (Admin)** + +``` +[기능 흐름] +1. Admin이 상품 정보를 입력한다 (brandId, 상품명, 가격, 재고 등) +2. brandId에 해당하는 브랜드가 존재하는지 확인한다 +3. 상품을 저장한다 +4. 생성된 상품 정보를 반환한다 + +[예외] +- brandId에 해당하는 브랜드가 없거나 삭제된 경우 등록 실패 + +[조건] +- 상품의 브랜드는 반드시 이미 등록된(삭제되지 않은) 브랜드여야 함 +- 재고(stock)는 상품 등록 시 초기값 설정 (0 이상) +``` + +**UC-P04: 상품 정보 수정 (Admin)** + +``` +[기능 흐름] +1. Admin이 productId와 수정할 정보를 요청한다 +2. 해당 상품이 존재하는지 확인한다 +3. 상품 정보를 업데이트한다 + +[예외] +- productId에 해당하는 상품이 없거나 삭제된 경우 404 반환 + +[조건] +- 상품의 브랜드(brandId)는 수정할 수 없음 +- 재고(stock) 수정 가능 +``` + +**UC-P05: 상품 삭제 (Admin)** + +``` +[기능 흐름] +1. Admin이 productId로 삭제를 요청한다 +2. 해당 상품이 존재하는지 확인한다 +3. 해당 상품을 soft delete 한다 + +[예외] +- productId에 해당하는 상품이 없거나 이미 삭제된 경우 404 반환 +``` + +--- + +## 클래스 설계 + +```mermaid +classDiagram + class Product { + Brand brand + String name + Money price + Stock stock + int likeCount + +update(String, Money, Stock) void + +decreaseStock(int) void + +isSoldOut() boolean + +addLikeCount() void + +subtractLikeCount() void + +softDelete() void + +isDeleted() boolean + } + + Product "*" --> "1" Brand : 객체참조 (FK 없음) +``` + +### Value Object + +| VO | 검증/행위 | 비즈니스 규칙 | +|---|---|---| +| Money | validate() | 0 이상이어야 함 | +| Stock | validate() | 0 이상이어야 함 | +| Stock | deduct(quantity) | 재고 부족 시 CoreException(BAD_REQUEST) | +| Stock | hasEnough(quantity) | 재고가 요청 수량 이상인지 확인 | + +### 비즈니스 규칙 + +| 메서드 | 비즈니스 규칙 | +|---|---| +| decreaseStock(int) | 재고 부족 시 CoreException(BAD_REQUEST). Stock VO에 위임 | +| isSoldOut() | stock이 0인지 확인. "품절"의 정의를 캡슐화 | +| addLikeCount() / subtractLikeCount() | 찜 등록/취소 시 likeCount 원자적 증감 | +| softDelete() / isDeleted() | deleted_at 설정 | + +--- + +## ERD + +```mermaid +erDiagram + brands { + bigint id PK + varchar name UK + timestamp created_at + timestamp updated_at + timestamp deleted_at + } + + products { + bigint id PK + bigint brand_id + varchar name + int price + int stock + int like_count + timestamp created_at + timestamp updated_at + timestamp deleted_at + } + + brands ||--o{ products : "" +``` + +### 인덱스 + +| 인덱스 컬럼 | 용도 | +|---|---| +| products.brand_id | 브랜드별 상품 필터링, 브랜드 삭제 시 연쇄 soft delete | + +### 동시성 제어 + +| 대상 | 방식 | 이유 | +|---|---|---| +| products.stock | 비관적 락 (추후 확정) | 주문 시 재고 차감. 동시 주문에도 재고가 음수가 되어서는 안 된다 | +| products.like_count | 원자적 UPDATE (`SET like_count = like_count + 1`) | 경합이 심하지 않으므로 비관적 락은 과도함 | From 14f85321b20494500d9efb1babb9bb8e775d1f2c Mon Sep 17 00:00:00 2001 From: plan11plan Date: Sat, 21 Feb 2026 23:46:10 +0900 Subject: [PATCH 041/108] =?UTF-8?q?docs:=20TDD=20=EC=8A=A4=ED=82=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/CLAUDE.md | 84 +++++++++++---------- .claude/skills/tdd/SKILL.md | 147 ++++++++++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+), 38 deletions(-) create mode 100644 .claude/skills/tdd/SKILL.md diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 24b95e0e5..26070e47a 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -4,38 +4,17 @@ Spring Boot 기반 멀티모듈 Java 프로젝트. TDD 방식으로 개발하며 --- -## 기술 스택 및 버전 - -### Core -- **Java**: 21 -- **Spring Boot**: 3.4.4 -- **Spring Cloud**: 2024.0.1 -- **Gradle**: Kotlin DSL - -### Framework & Libraries -- **Spring Web**: REST API 개발 -- **Spring Data JPA**: 데이터 접근 계층 -- **Spring Data Redis**: 캐싱 및 세션 관리 -- **QueryDSL**: 타입 세이프한 쿼리 작성 -- **Kafka**: 메시지 브로커 (commerce-streamer) -- **Spring Batch**: 배치 처리 (commerce-batch) - -### Utilities -- **Lombok**: 보일러플레이트 코드 감소 -- **Jackson**: JSON 직렬화/역직렬화 -- **SpringDoc OpenAPI**: API 문서화 (Swagger) - -### Testing -- **JUnit 5 + AssertJ + Mockito**: 테스트 프레임워크 -- **Testcontainers**: 통합 테스트 (MySQL, Redis) - -### Monitoring & Logging -- **Spring Actuator**: 애플리케이션 모니터링 -- **Slack Appender**: 로그 알림 -- **Jacoco**: 코드 커버리지 +## 기술 스택 + +- **Java 21**, **Spring Boot 3.4.4**, **Gradle Kotlin DSL** +- Spring Web, Spring Data JPA, Spring Data Redis, QueryDSL, Kafka, Spring Batch +- Lombok, Jackson, SpringDoc OpenAPI +- JUnit 5 + AssertJ + Mockito, Testcontainers (MySQL, Redis) --- + ## 모듈 구조 + ``` apps/ ├── commerce-api # REST API 서버 @@ -52,6 +31,7 @@ supports/ ├── logging # 로깅 설정 └── monitoring # 모니터링 설정 ``` + --- ## 아키텍처 @@ -82,17 +62,45 @@ supports/ --- +## TDD 개발 모드 + +"TDD로 개발" 트리거 시 `.claude/skills/tdd/SKILL.md`를 읽고 시작한다. 아래 규칙은 **Round 진행 중 매 턴 적용**. + +### 핵심 규칙 + +- Red 1개 → Green → Refactor → 다음 Red. **한 번에 여러 테스트 작성 금지** +- Red를 반드시 실행하여 **실패를 확인**한 후 Green 진행 +- Green은 **통과할 최소 코드만**. 다음 시나리오까지 미리 구현 금지 +- 기능 수직 슬라이스: 기능 하나를 Domain → Application 관통 후 다음 기능 +- 매 Round 후 진행 문서(`docs/tdd/{domain}/{feature}.md`) 갱신 + +### 계층별 전략 + +| 계층 | 테스트 더블 | TDD 방식 | +|------|-----------|---------| +| Domain Entity/VO | 더블 불필요 | TFD | +| Domain Service | **Fake 우선** | TFD | +| Application Facade | Mockito mock() | TFD | +| Controller / Repository | - | TLD (별도 진행) | + +### 테스트 실행 + +- Round 중: `./gradlew :apps:commerce-api:test --tests "{패키지}.{클래스}"` +- 전체 완료 후: `./gradlew :apps:commerce-api:test` + +### 코드 작성 + +- 실제 동작하는 코드만. 불필요한 Mock 데이터 금지 +- null-safety (Optional 활용), `println` 금지 +- 기존 코드 패턴 분석 후 일관성 유지 + +--- + ## 프로젝트 실행 ```bash -# 개발 환경 -./gradlew :apps:commerce-api:bootRun - -# 테스트 -./gradlew test # 전체 -./gradlew :apps:commerce-api:test # 특정 모듈 +./gradlew :apps:commerce-api:bootRun # 개발 환경 +./gradlew :apps:commerce-api:test # 특정 모듈 테스트 ./gradlew test jacocoTestReport # 커버리지 - -# 인프라 -docker compose up -d +docker compose up -d # 인프라 ``` diff --git a/.claude/skills/tdd/SKILL.md b/.claude/skills/tdd/SKILL.md new file mode 100644 index 000000000..281f2ef9a --- /dev/null +++ b/.claude/skills/tdd/SKILL.md @@ -0,0 +1,147 @@ +--- +name: tdd +description: | + TDD 방식으로 기능을 개발할 때 사용. DESIGN.md에서 테스트 케이스를 도출하고, + Red-Green-Refactor 루프를 기능 단위 수직 슬라이스(Domain → Application 관통)로 실행한다. + "TDD로 개발", "테스트 먼저", "TDD 모드", "Red-Green-Refactor" 시 트리거. +--- + +# TDD Skill + +DESIGN.md 기반 Red-Green-Refactor 실행 스킬. 핵심 규칙과 계층별 전략은 CLAUDE.md에 있으므로, 이 파일은 **시작 시 한 번만** 읽는다. + +--- + +## 사전 조건 + +스킬 실행 전 반드시 Read: + +1. `docs/spec/{domain}/DESIGN.md` — 유즈케이스, 시퀀스, 클래스, ERD +2. `.claude/skills/project-convention/references/common/test-convention.md` — 테스트 구조, 네이밍, 더블 전략 + +--- + +## Step 1: 테스트 목록표 + 진행 문서 생성 + +DESIGN.md에서 테스트 케이스를 도출하여 정리한다. + +``` +┌──────────────────────────────────────────────────────┐ +│ Feature: {기능명} │ +├───────────┬──────────────────┬────────────────────────┤ +│ 계층 │ 테스트 대상 │ 테스트 케이스 │ +├───────────┼──────────────────┼────────────────────────┤ +│ Domain │ {Entity/VO} │ {케이스} │ +│ Domain │ {Service} │ {케이스} │ +│ App │ {Facade} │ {케이스} │ +└───────────┴──────────────────┴────────────────────────┘ +``` + +**반드시 사용자에게 보여주고 확인을 받은 후 진행한다.** + +확인 후 진행 문서를 `docs/tdd/{domain}/{feature}.md`에 생성한다. (템플릿은 하단 참조) + +## Step 2: Round 루프 실행 + +CLAUDE.md의 TDD 핵심 규칙에 따라 Red → Green → Refactor를 반복한다. 매 Round 후 진행 문서의 해당 Round 상태를 갱신한다. + +- 🔴 Red 후: `🔴 Red: ✅ 실패 확인 — {요약}` +- 🟢 Green 후: `🟢 Green: ✅ 통과 — {요약}` +- 🔵 Refactor 후: `🔵 Refactor: ✅ {요약}` 또는 `skip` + +## Step 3: 전체 테스트 + +`./gradlew :apps:commerce-api:test` 실행. 진행 문서에 결과 기록. + +## Step 4: 완료 보고 + +상태를 `✅ 완료`로 변경, 산출물 경로 기록, 커밋 제안. + +``` +✅ TDD 완료 +Feature: {기능명} +- 테스트: {N}개 작성, 전체 통과 +- 구현: {파일 목록 요약} +커밋을 진행할까요? +``` + +--- + +## Fake 작성 규칙 + +Domain Service 테스트에서 Repository 대체 시 Fake 우선 사용. 기존 `FakeBrandRepository` 참고. + +- `HashMap` + `AtomicLong`으로 저장소 구현 +- `save()`: id 없으면 자동 생성, store에 저장 +- `findById()`: store에서 조회, soft delete 필터링 +- 테스트 소스(`src/test/java`)에 배치 + +### Fake vs Mockito 판단 + +``` +외부 의존 없음 → 더블 불필요 (Entity, VO) +외부 의존 있음 + Domain 계층 → Fake +외부 의존 있음 + Application 계층 → Mockito mock() +``` + +--- + +## 프로덕션 코드 작성 시 참조 + +| 대상 | 경로 | +|------|------| +| Entity, VO | `.claude/skills/project-convention/references/domain/entity-vo-convention.md` | +| Service | `.claude/skills/project-convention/references/application/service-layer-convention.md` | +| 테스트 | `.claude/skills/project-convention/references/common/test-convention.md` | + +--- + +## 템플릿: 진행 문서 + +```markdown +# TDD: {Feature 한글명} + +| 항목 | 내용 | +|------|------| +| 도메인 | {domain} | +| 상태 | 🟡 진행 중 | +| DESIGN.md | docs/spec/{domain}/DESIGN.md | + +--- + +## 테스트 목록표 + +{Step 1에서 도출한 테스트 목록표} + +--- + +## Round 진행 현황 + +### Round 1: {테스트 케이스명} +- 🔴 Red: ⏳ 대기 +- 🟢 Green: ⏳ 대기 +- 🔵 Refactor: ⏳ 대기 + +(모든 항목을 Round로 나열) + +--- + +## 전체 테스트 결과 + +(Step 3 완료 후 기록) + +--- + +## 산출물 + +(Step 4 완료 후 기록) + +### 테스트 파일 +- `src/test/java/.../{TestClass}.java` (new) + +### 프로덕션 파일 +- `src/main/java/.../{Class}.java` (new) + +### Fake +- `src/test/java/.../Fake{Repository}.java` (new) +``` From a5e59ea2f13e660a6fb8946d2b5e0e1abbf40a61 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Sun, 22 Feb 2026 22:09:35 +0900 Subject: [PATCH 042/108] =?UTF-8?q?chore:=20ArchUnit=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EB=B0=8F=20Command=20Hook=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .claude/hooks/convention-check.sh | 253 ++++++++++++++++++++++++++++++ .claude/settings.json | 16 ++ build.gradle.kts | 2 + 3 files changed, 271 insertions(+) create mode 100755 .claude/hooks/convention-check.sh create mode 100644 .claude/settings.json diff --git a/.claude/hooks/convention-check.sh b/.claude/hooks/convention-check.sh new file mode 100755 index 000000000..7e20879ac --- /dev/null +++ b/.claude/hooks/convention-check.sh @@ -0,0 +1,253 @@ +#!/bin/bash +# ============================================================================ +# convention-check.sh — Command Hook (PostToolUse) +# 프로젝트 컨벤션 패턴 위반을 자동 감지하는 셸 스크립트 +# +# 실행 위치: .claude/hooks/convention-check.sh +# 트리거: PostToolUse (Write|Edit) — Java 파일 수정 시 자동 실행 +# exit 0 = 통과, exit 1 = 경고(계속), exit 2 = 차단(Claude에게 피드백) +# ============================================================================ + +set -euo pipefail + +# ── stdin에서 Hook JSON 데이터 읽기 ── + +INPUT=$(cat) +FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.command // ""' 2>/dev/null) + +# Java 파일이 아니면 스킵 +if ! echo "$FILE_PATH" | grep -q '\.java$'; then + exit 0 +fi + +# 프로젝트 루트 결정 +PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}" +SRC="$PROJECT_DIR/apps/commerce-api/src/main/java/com/loopers" + +# src 디렉토리가 없으면 스킵 (프로젝트 외부 파일) +if [ ! -d "$SRC" ]; then + exit 0 +fi + +ERRORS="" +WARNINGS="" + +# ============================================================================ +# 규칙 1: 계층 의존 방향 위반 +# 출처: package-convention.md § 5. 의존 방향 규칙 +# ============================================================================ + +# 1-1. domain → infrastructure 의존 금지 +if grep -rn "import com\.loopers\.infrastructure" "$SRC/domain/" 2>/dev/null | grep -v "^Binary" | head -5; then + ERRORS+="[위반] domain → infrastructure 의존 금지\n" + ERRORS+=" → domain 계층은 순수 Java로만 구성. infrastructure를 import할 수 없음\n" + ERRORS+=" → 참고: package-convention.md § 5. 의존 방향 규칙\n\n" +fi + +# 1-2. domain → application 의존 금지 +if grep -rn "import com\.loopers\.application" "$SRC/domain/" 2>/dev/null | grep -v "^Binary" | head -5; then + ERRORS+="[위반] domain → application 의존 금지\n" + ERRORS+=" → domain은 application 계층을 알 수 없음\n" + ERRORS+=" → 참고: package-convention.md § 5. 의존 방향 규칙\n\n" +fi + +# 1-3. domain → interfaces 의존 금지 +if grep -rn "import com\.loopers\.interfaces" "$SRC/domain/" 2>/dev/null | grep -v "^Binary" | head -5; then + ERRORS+="[위반] domain → interfaces 의존 금지\n" + ERRORS+=" → domain은 interfaces 계층을 알 수 없음\n" + ERRORS+=" → 참고: package-convention.md § 5. 의존 방향 규칙\n\n" +fi + +# 1-4. interfaces → infrastructure 직접 의존 금지 +if grep -rn "import com\.loopers\.infrastructure" "$SRC/interfaces/" 2>/dev/null | grep -v "^Binary" | head -5; then + ERRORS+="[위반] interfaces → infrastructure 직접 의존 금지\n" + ERRORS+=" → Controller는 Facade를 통해 접근. Repository 직접 접근 불가\n" + ERRORS+=" → 참고: package-convention.md § 5. 의존 방향 규칙\n\n" +fi + +# 1-5. application → interfaces 역방향 의존 금지 +if grep -rn "import com\.loopers\.interfaces" "$SRC/application/" 2>/dev/null | grep -v "^Binary" | head -5; then + ERRORS+="[위반] application → interfaces 역방향 의존 금지\n" + ERRORS+=" → Facade가 Controller/Request/Response DTO를 알면 안 됨\n" + ERRORS+=" → 참고: package-convention.md § 5. 의존 방향 규칙\n\n" +fi + +# ============================================================================ +# 규칙 2: domain 계층 Spring 의존 금지 +# 출처: package-convention.md § 5, infrastructure-convention.md § 1 +# ============================================================================ + +# 2-1. domain에 @Repository 금지 +if grep -rn "^import org\.springframework\.stereotype\.Repository;" "$SRC/domain/" 2>/dev/null | head -3; then + ERRORS+="[위반] domain 패키지에 Spring @Repository 금지\n" + ERRORS+=" → @Repository는 infrastructure의 RepositoryImpl에만 사용\n" + ERRORS+=" → 참고: infrastructure-convention.md § 1. Repository 패턴\n\n" +fi + +# 2-2. domain에 @Service 허용 (컨벤션) +# 컨벤션: domain Service는 @Service 어노테이션 사용 가능. +# @Component, @Repository(Spring)는 여전히 금지. @Service만 허용. + +# 2-3. domain에 @Transactional 허용 (컨벤션) +# 컨벤션: domain Service는 @Transactional 사용 가능. +# Entity, VO, Repository 인터페이스에서는 사용하지 않는다. + +# 2-4. domain에 Spring Data Page/Pageable 허용 (컨벤션) +# 컨벤션: domain Repository 인터페이스에서 Page, Pageable 사용 가능. +# JpaRepository 상속은 금지 (infrastructure에서만 상속). + +# ============================================================================ +# 규칙 3: @OneToMany 사용 금지 +# 출처: infrastructure-convention.md § 4. DB 제약조건 규칙 +# ============================================================================ + +if grep -rn "@OneToMany" "$SRC/" 2>/dev/null | grep -v "^Binary" | head -3; then + ERRORS+="[위반] @OneToMany 사용 금지\n" + ERRORS+=" → ID 참조 + 별도 Repository 조회로 대체\n" + ERRORS+=" → 참고: infrastructure-convention.md § 4. DB 제약조건 규칙\n\n" +fi + +# ============================================================================ +# 규칙 4: Entity에 public setter 금지 +# 출처: entity-vo-convention.md § 1. Entity 작성 규칙 +# ============================================================================ + +if grep -rn "public void set[A-Z]" "$SRC/domain/" 2>/dev/null | grep -v "^Binary" | head -3; then + ERRORS+="[위반] Entity에 public setter 금지\n" + ERRORS+=" → 도메인 메서드(cancel(), update() 등)로 상태를 변경할 것\n" + ERRORS+=" → 참고: entity-vo-convention.md § 1. Entity 작성 규칙\n\n" +fi + +# ============================================================================ +# 규칙 5: Facade → Facade 호출 금지 +# 출처: service-layer-convention.md § 5. 계층 간 호출 규칙 +# ============================================================================ + +# application 패키지의 Facade 파일에서 다른 Facade를 import하는지 검사 +for FACADE_FILE in $(find "$SRC/application/" -name "*Facade.java" 2>/dev/null); do + FACADE_NAME=$(basename "$FACADE_FILE" .java) + # 자기 자신이 아닌 다른 Facade를 import하는지 확인 + OTHER_FACADE_IMPORTS=$(grep -n "import.*Facade;" "$FACADE_FILE" 2>/dev/null | grep -v "$FACADE_NAME" || true) + if [ -n "$OTHER_FACADE_IMPORTS" ]; then + ERRORS+="[위반] Facade → Facade 호출 금지: $FACADE_NAME\n" + ERRORS+=" → $OTHER_FACADE_IMPORTS\n" + ERRORS+=" → Facade는 타 도메인의 Domain Service를 직접 호출해야 함\n" + ERRORS+=" → 참고: service-layer-convention.md § 5. 계층 간 호출 규칙\n\n" + fi +done + +# ============================================================================ +# 규칙 6: @ManyToOne 시 NO_CONSTRAINT 필수 +# 출처: infrastructure-convention.md § 4. DB 제약조건 규칙 +# ============================================================================ + +# @ManyToOne이 있는 파일에서 NO_CONSTRAINT가 없는 경우 경고 +for ENTITY_FILE in $(grep -rl "@ManyToOne" "$SRC/" 2>/dev/null); do + if ! grep -q "NO_CONSTRAINT\|ConstraintMode" "$ENTITY_FILE" 2>/dev/null; then + WARNINGS+="[경고] @ManyToOne에 NO_CONSTRAINT 누락: $(basename $ENTITY_FILE)\n" + WARNINGS+=" → @JoinColumn(foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) 필수\n" + WARNINGS+=" → 참고: infrastructure-convention.md § 4. DB 제약조건 규칙\n\n" + fi +done + +# ============================================================================ +# 규칙 7: @Entity에 @NoArgsConstructor(access = PROTECTED) 필수 +# 출처: entity-vo-convention.md § 1. Entity 작성 규칙 +# ============================================================================ + +for ENTITY_FILE in $(grep -rl "^@Entity" "$SRC/domain/" 2>/dev/null); do + if ! grep -q "NoArgsConstructor" "$ENTITY_FILE" 2>/dev/null; then + WARNINGS+="[경고] @Entity에 @NoArgsConstructor(access = PROTECTED) 누락: $(basename $ENTITY_FILE)\n" + WARNINGS+=" → JPA 프록시를 위해 protected 기본 생성자 필수\n" + WARNINGS+=" → 참고: entity-vo-convention.md § 1. Entity 작성 규칙\n\n" + elif ! grep -q "PROTECTED\|AccessLevel.PROTECTED" "$ENTITY_FILE" 2>/dev/null; then + WARNINGS+="[경고] @NoArgsConstructor의 access가 PROTECTED가 아님: $(basename $ENTITY_FILE)\n" + WARNINGS+=" → @NoArgsConstructor(access = AccessLevel.PROTECTED) 필수\n" + WARNINGS+=" → 참고: entity-vo-convention.md § 1. Entity 작성 규칙\n\n" + fi +done + +# ============================================================================ +# 규칙 8: @Embeddable VO에 @EqualsAndHashCode 필수 +# 출처: entity-vo-convention.md § 2. VO 설계 규칙 +# ============================================================================ + +for VO_FILE in $(grep -rl "@Embeddable" "$SRC/domain/" 2>/dev/null); do + if ! grep -q "EqualsAndHashCode" "$VO_FILE" 2>/dev/null; then + WARNINGS+="[경고] @Embeddable VO에 @EqualsAndHashCode 누락: $(basename $VO_FILE)\n" + WARNINGS+=" → VO는 값 동등성이 필수. @EqualsAndHashCode를 추가할 것\n" + WARNINGS+=" → 참고: entity-vo-convention.md § 2. VO 설계 규칙\n\n" + fi +done + +# ============================================================================ +# 규칙 9: Controller → Facade 직접 호출 확인 (Domain Service 직접 호출 금지) +# 출처: service-layer-convention.md § 5. 계층 간 호출 규칙 +# ============================================================================ + +for CTRL_FILE in $(find "$SRC/interfaces/" -name "*Controller.java" 2>/dev/null); do + SERVICE_IMPORT=$(grep -n "import com\.loopers\.domain.*Service;" "$CTRL_FILE" 2>/dev/null || true) + if [ -n "$SERVICE_IMPORT" ]; then + WARNINGS+="[경고] Controller → Domain Service 직접 호출 의심: $(basename $CTRL_FILE)\n" + WARNINGS+=" → $SERVICE_IMPORT\n" + WARNINGS+=" → Controller는 Facade만 호출해야 함. Domain Service 직접 접근 금지\n" + WARNINGS+=" → 참고: service-layer-convention.md § 5. 계층 간 호출 규칙\n\n" + fi +done + +# ============================================================================ +# 규칙 10: Domain Service에서 타 도메인 Repository 직접 접근 금지 +# 출처: service-layer-convention.md § 5. 계층 간 호출 규칙 +# ============================================================================ + +for SERVICE_FILE in $(find "$SRC/domain/" -name "*Service.java" 2>/dev/null); do + SERVICE_DOMAIN=$(echo "$SERVICE_FILE" | grep -oP 'domain/\K[^/]+') + # 타 도메인 Repository import 검사 + OTHER_REPO=$(grep -n "import com\.loopers\.domain\." "$SERVICE_FILE" 2>/dev/null \ + | grep "Repository;" \ + | grep -v "domain\.$SERVICE_DOMAIN\." || true) + if [ -n "$OTHER_REPO" ]; then + ERRORS+="[위반] Domain Service → 타 도메인 Repository 직접 접근 금지: $(basename $SERVICE_FILE)\n" + ERRORS+=" → $OTHER_REPO\n" + ERRORS+=" → 타 도메인 데이터는 Facade에서 조율해야 함\n" + ERRORS+=" → 참고: service-layer-convention.md § 5. 계층 간 호출 규칙\n\n" + fi +done + +# ============================================================================ +# 규칙 11: @Builder 사용 금지 (Entity) +# 출처: entity-vo-convention.md § 1. 생성 패턴 +# ============================================================================ + +for ENTITY_FILE in $(grep -rl "^@Entity" "$SRC/domain/" 2>/dev/null); do + if grep -q "@Builder" "$ENTITY_FILE" 2>/dev/null; then + ERRORS+="[위반] Entity에 @Builder 사용 금지: $(basename $ENTITY_FILE)\n" + ERRORS+=" → 정적 팩토리 메서드(create, register 등) + private 생성자를 사용할 것\n" + ERRORS+=" → 참고: entity-vo-convention.md § 1. Entity 작성 규칙\n\n" + fi +done + +# ============================================================================ +# 결과 출력 +# ============================================================================ + +if [ -n "$ERRORS" ]; then + echo -e "🚫 컨벤션 위반 감지 (차단):\n" >&2 + echo -e "$ERRORS" >&2 + if [ -n "$WARNINGS" ]; then + echo -e "⚠️ 추가 경고:\n" >&2 + echo -e "$WARNINGS" >&2 + fi + echo "📖 전체 컨벤션: .claude/skills/project-convention/SKILL.md 참조" >&2 + exit 2 # 차단 — Claude에게 피드백 전달하여 자동 수정 유도 +fi + +if [ -n "$WARNINGS" ]; then + echo -e "⚠️ 컨벤션 경고 (계속 진행 가능):\n" >&2 + echo -e "$WARNINGS" >&2 + echo "📖 전체 컨벤션: .claude/skills/project-convention/SKILL.md 참조" >&2 + exit 1 # 경고만 — 사용자에게 표시하고 계속 진행 +fi + +# 모든 검사 통과 +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..17c9aed40 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Write|Edit", + "hooks": [ + { + "type": "command", + "command": "bash $CLAUDE_PROJECT_DIR/.claude/hooks/convention-check.sh", + "timeout": 30 + } + ] + } + ] + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 9c8490b8a..fa298b2b9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -63,6 +63,8 @@ subprojects { testImplementation("com.ninja-squad:springmockk:${project.properties["springMockkVersion"]}") testImplementation("org.mockito:mockito-core:${project.properties["mockitoVersion"]}") testImplementation("org.instancio:instancio-junit:${project.properties["instancioJUnitVersion"]}") + // ArchUnit + testImplementation("com.tngtech.archunit:archunit-junit5:1.3.0") // Testcontainers testImplementation("org.springframework.boot:spring-boot-testcontainers") testImplementation("org.testcontainers:testcontainers") From 289ca8868f636d3cc149265bd0c46f1dd20a19af Mon Sep 17 00:00:00 2001 From: plan11plan Date: Sun, 22 Feb 2026 22:09:39 +0900 Subject: [PATCH 043/108] =?UTF-8?q?test:=20ArchUnit=20=EC=95=84=ED=82=A4?= =?UTF-8?q?=ED=85=8D=EC=B2=98=20=EC=BB=A8=EB=B2=A4=EC=85=98=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../architecture/DomainPurityTest.java | 67 +++++++++++ .../architecture/LayeredArchitectureTest.java | 90 +++++++++++++++ .../architecture/NamingConventionTest.java | 107 ++++++++++++++++++ .../architecture/ServiceLayerTest.java | 97 ++++++++++++++++ 4 files changed, 361 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/architecture/DomainPurityTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/architecture/LayeredArchitectureTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/architecture/NamingConventionTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/architecture/ServiceLayerTest.java diff --git a/apps/commerce-api/src/test/java/com/loopers/architecture/DomainPurityTest.java b/apps/commerce-api/src/test/java/com/loopers/architecture/DomainPurityTest.java new file mode 100644 index 000000000..2317dc645 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/architecture/DomainPurityTest.java @@ -0,0 +1,67 @@ +package com.loopers.architecture; + +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +/** + * domain 계층 순수성 ArchUnit 테스트. + * + * domain 패키지에 Spring Framework 의존이 침투하지 않는 것을 보장한다. + * Repository 인터페이스는 순수 Java, Entity에 Spring 어노테이션 금지. + * + * 출처: package-convention.md § 5, infrastructure-convention.md § 1 + * + * 복사 위치: apps/commerce-api/src/test/java/com/loopers/architecture/DomainPurityTest.java + */ +@AnalyzeClasses( + packages = "com.loopers", + importOptions = ImportOption.DoNotIncludeTests.class +) +class DomainPurityTest { + + // ======================================================================== + // domain에 Spring 스테레오타입 어노테이션 금지 + // 출처: package-convention.md § 5 — "domain은 순수 Java로만 구성한다" + // ======================================================================== + + @ArchTest + static final ArchRule domain에_Spring_Repository_어노테이션_금지 = + noClasses() + .that().resideInAPackage("..domain..") + .should().beAnnotatedWith( + org.springframework.stereotype.Repository.class) + .because("@Repository는 infrastructure의 RepositoryImpl에만 사용한다. " + + "domain Repository는 순수 Java 인터페이스여야 한다. " + + "(infrastructure-convention.md § 1)"); + + // 컨벤션: domain Service는 @Service 어노테이션 사용 가능. + // DI 등록을 위해 domain Service 클래스에 @Service를 허용한다. + // @Component, @Repository(Spring)는 여전히 금지 (위 규칙으로 검증). + + @ArchTest + static final ArchRule domain에_Spring_Component_어노테이션_금지 = + noClasses() + .that().resideInAPackage("..domain..") + .should().beAnnotatedWith( + org.springframework.stereotype.Component.class) + .because("domain 계층에 Spring @Component 금지. " + + "domain은 순수 Java로만 구성한다. " + + "(package-convention.md § 5)"); + + // ======================================================================== + // domain Service 허용 사항 (컨벤션) + // + // service-layer-convention.md § 3~4에 따라 domain Service는 다음을 사용할 수 있다: + // - @Service (DI 등록) + // - @Transactional (메서드 레벨, Facade와 REQUIRED 전파) + // - Page / Pageable (Spring Data 페이지네이션) + // + // 금지 대상은 Entity, VO, Repository 인터페이스이며, + // 해당 클래스에 대한 @Repository, @Component 금지는 위 규칙으로 검증한다. + // JpaRepository 상속은 infrastructure에서만 허용 (NamingConventionTest에서 검증). + // ======================================================================== +} diff --git a/apps/commerce-api/src/test/java/com/loopers/architecture/LayeredArchitectureTest.java b/apps/commerce-api/src/test/java/com/loopers/architecture/LayeredArchitectureTest.java new file mode 100644 index 000000000..cf7c803de --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/architecture/LayeredArchitectureTest.java @@ -0,0 +1,90 @@ +package com.loopers.architecture; + +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; +import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices; + +/** + * 계층 간 의존 방향 ArchUnit 테스트. + * + * 출처: package-convention.md § 5. 의존 방향 규칙 + * 규칙: interfaces → application → domain ← infrastructure + * + * 복사 위치: apps/commerce-api/src/test/java/com/loopers/architecture/LayeredArchitectureTest.java + */ +@AnalyzeClasses( + packages = "com.loopers", + importOptions = ImportOption.DoNotIncludeTests.class +) +class LayeredArchitectureTest { + + // ======================================================================== + // 계층 의존 방향: domain은 어떤 계층도 알지 못한다 + // 출처: package-convention.md § 5 + // ======================================================================== + + @ArchTest + static final ArchRule domain은_infrastructure를_의존하지_않는다 = + noClasses() + .that().resideInAPackage("..domain..") + .should().dependOnClassesThat() + .resideInAPackage("..infrastructure..") + .because("domain 계층은 순수 Java로만 구성한다. " + + "infrastructure 의존은 DIP 위반이다. " + + "(package-convention.md § 5)"); + + @ArchTest + static final ArchRule domain은_application을_의존하지_않는다 = + noClasses() + .that().resideInAPackage("..domain..") + .should().dependOnClassesThat() + .resideInAPackage("..application..") + .because("domain → application 역방향 의존 금지. " + + "(package-convention.md § 5)"); + + @ArchTest + static final ArchRule domain은_interfaces를_의존하지_않는다 = + noClasses() + .that().resideInAPackage("..domain..") + .should().dependOnClassesThat() + .resideInAPackage("..interfaces..") + .because("domain → interfaces 역방향 의존 금지. " + + "(package-convention.md § 5)"); + + @ArchTest + static final ArchRule interfaces는_infrastructure를_직접_의존하지_않는다 = + noClasses() + .that().resideInAPackage("..interfaces..") + .should().dependOnClassesThat() + .resideInAPackage("..infrastructure..") + .because("Controller는 Facade를 통해 접근한다. " + + "Repository 직접 접근 금지. " + + "(package-convention.md § 5)"); + + @ArchTest + static final ArchRule application은_interfaces를_의존하지_않는다 = + noClasses() + .that().resideInAPackage("..application..") + .should().dependOnClassesThat() + .resideInAPackage("..interfaces..") + .because("Facade가 Controller/Request/Response DTO를 알면 안 된다. " + + "(package-convention.md § 5)"); + + // ======================================================================== + // 순환 의존 금지: 도메인 간 순환 참조 방지 + // 출처: package-convention.md § 5. 의존 방향 규칙 + // ======================================================================== + + @ArchTest + static final ArchRule 도메인_간_순환_의존이_없다 = + slices() + .matching("..domain.(*)..") + .should().beFreeOfCycles() + .because("도메인 간 순환 의존은 결합도를 높인다. " + + "타 도메인 접근은 Facade에서 조율한다. " + + "(package-convention.md § 5)"); +} diff --git a/apps/commerce-api/src/test/java/com/loopers/architecture/NamingConventionTest.java b/apps/commerce-api/src/test/java/com/loopers/architecture/NamingConventionTest.java new file mode 100644 index 000000000..e72f2fb6c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/architecture/NamingConventionTest.java @@ -0,0 +1,107 @@ +package com.loopers.architecture; + +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; + +/** + * 네이밍 및 배치 규칙 ArchUnit 테스트. + * + * 클래스 이름과 패키지 배치가 프로젝트 컨벤션을 따르는지 검증한다. + * + * 출처: package-convention.md § 4, infrastructure-convention.md § 1 + * + * 복사 위치: apps/commerce-api/src/test/java/com/loopers/architecture/NamingConventionTest.java + */ +@AnalyzeClasses( + packages = "com.loopers", + importOptions = ImportOption.DoNotIncludeTests.class +) +class NamingConventionTest { + + // ======================================================================== + // Controller 배치: interfaces 패키지에만 존재 + // 출처: package-convention.md § 4 + // ======================================================================== + + @ArchTest + static final ArchRule Controller는_interfaces_패키지에_있다 = + classes() + .that().haveSimpleNameEndingWith("Controller") + .should().resideInAPackage("..interfaces..") + .because("Controller는 interfaces/{domain}/ 패키지에 배치한다. " + + "(package-convention.md § 4)"); + + // ======================================================================== + // Facade 배치: application 패키지에만 존재 + // 출처: package-convention.md § 4 + // ======================================================================== + + @ArchTest + static final ArchRule Facade는_application_패키지에_있다 = + classes() + .that().haveSimpleNameEndingWith("Facade") + .should().resideInAPackage("..application..") + .because("Facade는 application/{domain}/ 패키지에 배치한다. " + + "(package-convention.md § 4)"); + + // ======================================================================== + // RepositoryImpl 배치: infrastructure 패키지에만 존재 + // 출처: infrastructure-convention.md § 1 — Repository 3-클래스 패턴 + // ======================================================================== + + @ArchTest + static final ArchRule RepositoryImpl은_infrastructure_패키지에_있다 = + classes() + .that().haveSimpleNameEndingWith("RepositoryImpl") + .should().resideInAPackage("..infrastructure..") + .because("RepositoryImpl은 infrastructure/{domain}/ 패키지에 배치한다. " + + "(infrastructure-convention.md § 1. Repository 3-클래스 패턴)"); + + // ======================================================================== + // JpaRepository 배치: infrastructure 패키지에만 존재 + // 출처: infrastructure-convention.md § 1 — Repository 3-클래스 패턴 + // ======================================================================== + + @ArchTest + static final ArchRule JpaRepository는_infrastructure_패키지에_있다 = + classes() + .that().haveSimpleNameEndingWith("JpaRepository") + .should().resideInAPackage("..infrastructure..") + .because("JpaRepository 인터페이스는 infrastructure/{domain}/ 패키지에 배치한다. " + + "(infrastructure-convention.md § 1. Repository 3-클래스 패턴)"); + + // ======================================================================== + // @Repository 어노테이션은 RepositoryImpl에만 + // 출처: infrastructure-convention.md § 1 + // ======================================================================== + + @ArchTest + static final ArchRule Repository_어노테이션은_Impl_클래스에만 = + classes() + .that().areAnnotatedWith( + org.springframework.stereotype.Repository.class) + .should().haveSimpleNameEndingWith("RepositoryImpl") + .orShould().haveSimpleNameEndingWith("QueryRepository") + .because("@Repository는 RepositoryImpl 또는 QueryRepository에만 붙인다. " + + "domain Repository 인터페이스는 순수 Java로 유지한다. " + + "(infrastructure-convention.md § 1)"); + + // ======================================================================== + // ErrorCode는 domain 패키지에 배치 + // 출처: exception-convention.md § 6 + // ======================================================================== + + @ArchTest + static final ArchRule ErrorCode는_domain_또는_support에_있다 = + classes() + .that().haveSimpleNameEndingWith("ErrorCode") + .should().resideInAPackage("..domain..") + .orShould().resideInAPackage("..support..") + .because("도메인 ErrorCode는 domain/{domain}/, " + + "공통 ErrorType은 support/error/ 에 배치한다. " + + "(exception-convention.md § 6)"); +} diff --git a/apps/commerce-api/src/test/java/com/loopers/architecture/ServiceLayerTest.java b/apps/commerce-api/src/test/java/com/loopers/architecture/ServiceLayerTest.java new file mode 100644 index 000000000..e0f02624e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/architecture/ServiceLayerTest.java @@ -0,0 +1,97 @@ +package com.loopers.architecture; + +import com.tngtech.archunit.base.DescribedPredicate; +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +/** + * 서비스 계층 호출 규칙 ArchUnit 테스트. + * + * Facade ↔ Service 간 호출 규칙을 바이트코드 수준에서 검증한다. + * + * 출처: service-layer-convention.md § 5. 계층 간 호출 규칙 + */ +@AnalyzeClasses( + packages = "com.loopers", + importOptions = ImportOption.DoNotIncludeTests.class +) +class ServiceLayerTest { + + // ======================================================================== + // Facade → Facade 호출 금지 + // 출처: service-layer-convention.md § 5 + // "Facade → 타 Facade ❌ 순환 의존, 트랜잭션 경계 혼란" + // ======================================================================== + + @ArchTest + static final ArchRule Facade는_다른_Facade를_의존하지_않는다 = + noClasses() + .that().haveSimpleNameEndingWith("Facade") + .and().resideInAPackage("..application..") + .should().dependOnClassesThat() + .haveSimpleNameEndingWith("Facade") + .because("Facade → Facade 호출 금지. " + + "순환 의존과 트랜잭션 경계 혼란을 방지한다. " + + "타 도메인 접근은 타 도메인의 Domain Service를 직접 호출한다. " + + "(service-layer-convention.md § 5)"); + + // ======================================================================== + // Controller → Domain Service 직접 호출 금지 + // 출처: service-layer-convention.md § 5 + // "Controller → Domain Service 직접 ❌ Facade 우회" + // ======================================================================== + + @ArchTest + static final ArchRule Controller는_domain_Service를_직접_의존하지_않는다 = + noClasses() + .that().resideInAPackage("..interfaces..") + .and().haveSimpleNameEndingWith("Controller") + .should().dependOnClassesThat( + DescribedPredicate.describe( + "domain Service classes", + (JavaClass clazz) -> clazz.getPackageName().contains(".domain.") + && clazz.getSimpleName().endsWith("Service") + )) + .because("Controller는 Facade만 호출한다. " + + "Domain Service 직접 접근은 Facade 우회이다. " + + "Filter 등 인프라 클래스는 예외. " + + "(service-layer-convention.md § 5)"); + + // ======================================================================== + // Domain Service → Facade 역방향 호출 금지 + // 출처: service-layer-convention.md § 5 + // "Domain Service → Facade ❌ 하위 → 상위 역방향" + // ======================================================================== + + @ArchTest + static final ArchRule Domain_Service는_Facade를_의존하지_않는다 = + noClasses() + .that().resideInAPackage("..domain..") + .should().dependOnClassesThat() + .haveSimpleNameEndingWith("Facade") + .because("Domain Service → Facade 역방향 호출 금지. " + + "하위 계층은 상위 계층을 알 수 없다. " + + "(service-layer-convention.md § 5)"); + + // ======================================================================== + // Facade는 Repository를 직접 접근하지 않는다 + // 출처: service-layer-convention.md § 2 + // "Facade에 넣지 않는 것: Repository 직접 호출 → Domain Service" + // ======================================================================== + + @ArchTest + static final ArchRule Facade는_Repository를_직접_의존하지_않는다 = + noClasses() + .that().haveSimpleNameEndingWith("Facade") + .and().resideInAPackage("..application..") + .should().dependOnClassesThat() + .haveSimpleNameEndingWith("Repository") + .because("Facade는 Repository를 직접 호출하지 않는다. " + + "데이터 접근은 Domain Service를 통해 한다. " + + "(service-layer-convention.md § 2)"); +} From 46afd3a0a91a4a36ae6c9a18315e725dc1c4987e Mon Sep 17 00:00:00 2001 From: plan11plan Date: Sun, 22 Feb 2026 22:09:43 +0900 Subject: [PATCH 044/108] =?UTF-8?q?docs:=20=EC=BB=A8=EB=B2=A4=EC=85=98=20?= =?UTF-8?q?=EC=8A=A4=ED=82=AC=EC=97=90=20domain=20Service=20=ED=97=88?= =?UTF-8?q?=EC=9A=A9=20=EC=82=AC=ED=95=AD=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .claude/skills/project-convention/SKILL.md | 6 ++++++ .../references/common/package-convention.md | 5 +++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.claude/skills/project-convention/SKILL.md b/.claude/skills/project-convention/SKILL.md index f5b74d1f8..068c9fc5b 100644 --- a/.claude/skills/project-convention/SKILL.md +++ b/.claude/skills/project-convention/SKILL.md @@ -104,6 +104,12 @@ ApiResponse.failValidation(code, message, fieldErrors) // Validation 에 ### Domain 계층 +**Domain Service 허용 사항** + +- `@Service`, `@Transactional` 사용 가능 (Facade와 REQUIRED 전파) +- `Page`, `Pageable` (Spring Data 페이지네이션) 사용 가능 +- 금지: `@Component`, `@Repository`(Spring), `JpaRepository` 상속 + **Entity** - 생성: **정적 팩토리 메서드** (`Order.create(...)`) diff --git a/.claude/skills/project-convention/references/common/package-convention.md b/.claude/skills/project-convention/references/common/package-convention.md index ffb5b56e5..d5371ea45 100644 --- a/.claude/skills/project-convention/references/common/package-convention.md +++ b/.claude/skills/project-convention/references/common/package-convention.md @@ -225,7 +225,7 @@ interfaces → application → domain ← infrastructure - **interfaces**는 application을 알 수 있다. domain을 직접 참조하지 않는다. - **application**은 domain을 알 수 있다. interfaces를 알면 안 된다. -- **domain**은 아무 계층도 알지 못한다. 순수 Java로만 구성한다. +- **domain**은 아무 계층도 알지 못한다. 단, domain Service는 `@Service`, `@Transactional`, `Page`/`Pageable` 사용을 허용한다 (service-layer-convention.md § 3~4). - **infrastructure**는 domain을 알 수 있다 (Repository 인터페이스 구현). ### 도메인 간 의존 @@ -272,7 +272,8 @@ public class OrderService { **의존 방향** - [ ] interfaces → application → domain ← infrastructure 방향을 지키는가? -- [ ] domain 패키지에 Spring 의존성(`@Service`, `@Transactional` 등)이 없는가? +- [ ] domain Entity/VO/Repository 인터페이스에 Spring 어노테이션(`@Component`, `@Repository`)이 없는가? +- [ ] domain Service의 `@Service`, `@Transactional`, `Page`/`Pageable` 사용은 컨벤션 허용 (service-layer-convention.md § 3~4) - [ ] 도메인 간 Entity 직접 참조가 없는가? **네이밍** From d55dabdaaececaf25c0649e3dd35932fb94c2912 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Mon, 23 Feb 2026 17:45:06 +0900 Subject: [PATCH 045/108] =?UTF-8?q?docs:=20TDD=20=EC=8A=A4=ED=82=AC?= =?UTF-8?q?=EC=97=90=20Solo/Pair=20=EB=AA=A8=EB=93=9C=20=EC=84=A0=ED=83=9D?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TDD 진행 시 Solo(Claude 단독 수행) / Pair(협력 개발) 모드를 선택할 수 있도록 스킬 문서를 확장한다. Co-Authored-By: Claude Opus 4.6 --- .claude/skills/tdd/SKILL.md | 45 +++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/.claude/skills/tdd/SKILL.md b/.claude/skills/tdd/SKILL.md index 281f2ef9a..8c26f5bc4 100644 --- a/.claude/skills/tdd/SKILL.md +++ b/.claude/skills/tdd/SKILL.md @@ -12,6 +12,51 @@ DESIGN.md 기반 Red-Green-Refactor 실행 스킬. 핵심 규칙과 계층별 --- +## 진행 모드 선택 + +TDD 시작 시 사용자에게 진행 모드를 묻는다. + +| 모드 | 설명 | +|------|------| +| **Solo** | Claude가 Red → Green → Refactor를 모두 수행. 결과를 진행 문서에 기록하며 연속 진행 | +| **Pair** | Claude가 Red(실패하는 테스트) 작성 → 사용자가 Green(통과 코드) 작성 → 함께 리뷰 + Refactor | + +모드를 선택하지 않으면 **Pair를 기본값**으로 제안한다. + +### Solo 모드 + +- Claude가 Red → Green → Refactor를 연속으로 수행 +- 매 Round 후 진행 문서 갱신 +- 전체 완료 후 사용자에게 결과 보고 + +### Pair 모드 (협력 개발) + +각 Round를 다음 순서로 진행한다: + +``` +1. 🔴 Red: Claude가 실패하는 테스트를 작성하고, 테스트를 실행하여 실패를 확인한다 + → 사용자에게 "Red 확인. Green을 작성해주세요" 안내 + → 사용자가 직접 Green 코드를 작성하거나, Claude에게 Green 작성을 요청할 수 있다 + +2. 🟢 Green: 사용자(또는 사용자 요청 시 Claude)가 최소 코드로 테스트를 통과시킨다 + → 테스트 실행하여 통과 확인 + → 함께 Green 코드를 리뷰한다 + +3. 🔵 Refactor: 함께 리팩터링 필요 여부를 논의한다 + → 리팩터링이 필요하면 수행 후 테스트 재실행 + → 필요 없으면 skip + +4. 다음 Round로 이동 +``` + +**Pair 모드 핵심 규칙:** +- Claude는 Red만 작성하고 **멈춘다** — 사용자 턴을 기다린다 +- 사용자가 "Green 해줘" / "통과시켜줘" 등 요청하면 Claude가 Green을 작성한다 +- Refactor는 항상 사용자와 함께 논의 후 진행한다 +- Round 사이에 사용자가 질문하거나 방향을 바꿀 수 있다 + +--- + ## 사전 조건 스킬 실행 전 반드시 Read: From 8e1111cac88e770c784fe422be4698132cbf1e75 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Mon, 23 Feb 2026 17:45:21 +0900 Subject: [PATCH 046/108] =?UTF-8?q?feat:=20ProductLikeModel=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=EB=B0=8F=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProductLikeModel Entity 구현 (BaseEntity 미상속, hard delete) - 정적 팩토리 메서드 create() + userId/productId null 검증 - ProductLikeRepository 인터페이스 정의 - FakeProductLikeRepository 구현 (hard delete용) - TDD 진행 문서 생성 Co-Authored-By: Claude Opus 4.6 --- .../domain/like/ProductLikeErrorCode.java | 16 ++++ .../loopers/domain/like/ProductLikeModel.java | 55 +++++++++++++ .../domain/like/ProductLikeRepository.java | 11 +++ .../domain/like/ProductLikeService.java | 10 +++ .../like/FakeProductLikeRepository.java | 39 ++++++++++ .../domain/like/ProductLikeModelTest.java | 49 ++++++++++++ .../domain/like/ProductLikeServiceTest.java | 40 ++++++++++ docs/tdd/like/list.md | 54 +++++++++++++ docs/tdd/like/toggle.md | 78 +++++++++++++++++++ 9 files changed, 352 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeErrorCode.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeModel.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeService.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/like/FakeProductLikeRepository.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeModelTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeServiceTest.java create mode 100644 docs/tdd/like/list.md create mode 100644 docs/tdd/like/toggle.md diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeErrorCode.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeErrorCode.java new file mode 100644 index 000000000..efc5f0733 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeErrorCode.java @@ -0,0 +1,16 @@ +package com.loopers.domain.like; + +import com.loopers.support.error.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum ProductLikeErrorCode implements ErrorCode { + DUPLICATE_NAME(HttpStatus.CONFLICT, "PRODUCT_LIKE_001", "이미 좋아요가 됐습니다."); + + private final HttpStatus status; + private final String code; + private final String message; +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeModel.java new file mode 100644 index 000000000..bd2f1acba --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeModel.java @@ -0,0 +1,55 @@ +package com.loopers.domain.like; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.ZonedDateTime; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + + +@Getter +@Entity +@Table(name = "likes") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProductLikeModel { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; + + private ProductLikeModel(Long userId, Long productId) { + this.userId = userId; + this.productId = productId; + this.createdAt = ZonedDateTime.now(); + } + + public static ProductLikeModel create(Long userId, Long productId) { + validate(userId,productId); + return new ProductLikeModel(userId,productId); + } + + private static void validate(Long userId, Long productId) { + if(userId == null){ + throw new CoreException(ErrorType.BAD_REQUEST, "유저 ID는 필수값입니다."); + } + if(productId== null){ + throw new CoreException(ErrorType.BAD_REQUEST, "상품 ID는 필수값입니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeRepository.java new file mode 100644 index 000000000..c4be0d75d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeRepository.java @@ -0,0 +1,11 @@ +package com.loopers.domain.like; + +import java.util.Optional; + +public interface ProductLikeRepository { + ProductLikeModel save(ProductLikeModel productLike); + + Optional findByUserIdAndProductId(Long userId, Long productId); + + void delete(ProductLikeModel productLike); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeService.java new file mode 100644 index 000000000..4d234590b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeService.java @@ -0,0 +1,10 @@ +package com.loopers.domain.like; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class ProductLikeService { + private final ProductLikeRepository productLikeRepository; +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/FakeProductLikeRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/FakeProductLikeRepository.java new file mode 100644 index 000000000..6b217a886 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/FakeProductLikeRepository.java @@ -0,0 +1,39 @@ +package com.loopers.domain.like; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; + +public class FakeProductLikeRepository implements ProductLikeRepository { + + private final Map store = new HashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(1); + + @Override + public ProductLikeModel save(ProductLikeModel productLike) { + if (productLike.getId() == null) { + try { + var idField = productLike.getClass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(productLike, idGenerator.getAndIncrement()); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException(e); + } + } + store.put(productLike.getId(), productLike); + return productLike; + } + + @Override + public Optional findByUserIdAndProductId(Long userId, Long productId) { + return store.values().stream() + .filter(like -> like.getUserId().equals(userId) && like.getProductId().equals(productId)) + .findFirst(); + } + + @Override + public void delete(ProductLikeModel productLike) { + store.remove(productLike.getId()); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeModelTest.java new file mode 100644 index 000000000..5dfe0132f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeModelTest.java @@ -0,0 +1,49 @@ +package com.loopers.domain.like; + +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 com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class ProductLikeModelTest { + + @DisplayName("좋아요를 생성할 때, ") + @Nested + class Create { + + @DisplayName("유효한 userId와 productId가 주어지면, 정상적으로 생성된다.") + @Test + void create_whenValidValues() { + // arrange + Long userId = 1L; + Long productId = 2L; + + // act + ProductLikeModel like = ProductLikeModel.create(userId, productId); + + // assert + assertAll( + () -> assertThat(like.getUserId()).isEqualTo(userId), + () -> assertThat(like.getProductId()).isEqualTo(productId) + ); + } + + @DisplayName("userId가 null이면 예외가 발생한다.") + @Test + void create_whenUserIdIsNull() { + assertThatThrownBy(() -> ProductLikeModel.create(null, 2L)) + .isInstanceOf(CoreException.class); + } + + @DisplayName("productId가 null이면 예외가 발생한다.") + @Test + void create_whenProductIdIsNull() { + assertThatThrownBy(() -> ProductLikeModel.create(1L, null)) + .isInstanceOf(CoreException.class); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeServiceTest.java new file mode 100644 index 000000000..38fd64950 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeServiceTest.java @@ -0,0 +1,40 @@ +//package com.loopers.domain.like; +// +//import static org.assertj.core.api.Assertions.assertThat; +// +//import org.junit.jupiter.api.BeforeEach; +//import org.junit.jupiter.api.DisplayName; +//import org.junit.jupiter.api.Nested; +//import org.junit.jupiter.api.Test; +// +//class ProductLikeServiceTest { +// +// private ProductLikeService productLikeService; +// private FakeProductLikeRepository productLikeRepository; +// +// @BeforeEach +// void setUp() { +// productLikeRepository = new FakeProductLikeRepository(); +// productLikeService = new ProductLikeService(productLikeRepository); +// } +// +// @DisplayName("좋아요를 토글할 때, ") +// @Nested +// class ToggleLike { +// +// @DisplayName("좋아요가 없으면, 좋아요를 등록하고 true를 반환한다.") +// @Test +// void toggleLike_whenNotExists() { +// // arrange +// Long userId = 1L; +// Long productId = 2L; +// +// // act +// boolean result = productLikeService.toggleLike(userId, productId); +// +// // assert +// assertThat(result).isTrue(); +// assertThat(productLikeRepository.findByUserIdAndProductId(userId, productId)).isPresent(); +// } +// } +//} diff --git a/docs/tdd/like/list.md b/docs/tdd/like/list.md new file mode 100644 index 000000000..157de08b0 --- /dev/null +++ b/docs/tdd/like/list.md @@ -0,0 +1,54 @@ +# TDD: UC-L02 좋아요 목록 조회 + +| 항목 | 내용 | +|------|------| +| 도메인 | like | +| 상태 | 🟡 진행 중 | +| DESIGN.md | docs/design/like/DESIGN.md | + +--- + +## 테스트 목록표 + +| Round | 계층 | 테스트 대상 | 테스트 케이스 | +|-------|------|------------|-------------| +| R9 | Domain | ProductLikeServiceTest | 사용자 ID로 좋아요 목록을 조회하면, 해당 사용자의 좋아요 목록이 반환된다 | +| R10 | Domain | ProductLikeServiceTest | 좋아요가 없는 사용자 ID로 조회하면, 빈 목록이 반환된다 | +| R11 | Application | LikeFacadeTest | 사용자의 좋아요 목록을 조회하면, LikeInfo 목록을 반환한다 | +| R12 | Application | LikeFacadeTest | 삭제된 상품의 좋아요는 목록에서 제외된다 | + +--- + +## Round 진행 현황 + +### Round 9: 사용자 ID로 좋아요 목록을 조회하면, 해당 사용자의 좋아요 목록이 반환된다 +- 🔴 Red: ⏳ 대기 +- 🟢 Green: ⏳ 대기 +- 🔵 Refactor: ⏳ 대기 + +### Round 10: 좋아요가 없는 사용자 ID로 조회하면, 빈 목록이 반환된다 +- 🔴 Red: ⏳ 대기 +- 🟢 Green: ⏳ 대기 +- 🔵 Refactor: ⏳ 대기 + +### Round 11: 사용자의 좋아요 목록을 조회하면, LikeInfo 목록을 반환한다 +- 🔴 Red: ⏳ 대기 +- 🟢 Green: ⏳ 대기 +- 🔵 Refactor: ⏳ 대기 + +### Round 12: 삭제된 상품의 좋아요는 목록에서 제외된다 +- 🔴 Red: ⏳ 대기 +- 🟢 Green: ⏳ 대기 +- 🔵 Refactor: ⏳ 대기 + +--- + +## 전체 테스트 결과 + +(완료 후 기록) + +--- + +## 산출물 + +(완료 후 기록) diff --git a/docs/tdd/like/toggle.md b/docs/tdd/like/toggle.md new file mode 100644 index 000000000..e092c81d7 --- /dev/null +++ b/docs/tdd/like/toggle.md @@ -0,0 +1,78 @@ +# TDD: UC-L01 좋아요 토글 (등록/취소) + +| 항목 | 내용 | +|------|------| +| 도메인 | like | +| 상태 | 🟡 진행 중 | +| DESIGN.md | docs/design/like/DESIGN.md | + +--- + +## 테스트 목록표 + +| Round | 계층 | 테스트 대상 | 테스트 케이스 | +|-------|------|------------|-------------| +| R1 | Domain | ProductLikeModelTest | 유효한 userId와 productId가 주어지면, 정상적으로 생성된다 | +| R2 | Domain | ProductLikeModelTest | userId가 null이면 예외가 발생한다 | +| R3 | Domain | ProductLikeModelTest | productId가 null이면 예외가 발생한다 | +| R4 | Domain | ProductLikeServiceTest | 좋아요가 없으면, 좋아요를 등록하고 true를 반환한다 | +| R5 | Domain | ProductLikeServiceTest | 좋아요가 이미 존재하면, 좋아요를 삭제하고 false를 반환한다 | +| R6 | Application | LikeFacadeTest | 상품을 검증하고 ProductLikeService.toggleLike를 호출한다 | +| R7 | Application | LikeFacadeTest | 좋아요가 등록되면 product.addLikeCount()를 호출한다 | +| R8 | Application | LikeFacadeTest | 좋아요가 취소되면 product.subtractLikeCount()를 호출한다 | + +--- + +## Round 진행 현황 + +### Round 1: 유효한 userId와 productId가 주어지면, 정상적으로 생성된다 +- 🔴 Red: ✅ 실패 확인 — ProductLikeModel 클래스 미존재로 컴파일 실패 +- 🟢 Green: ✅ 통과 — ProductLikeModel Entity 생성 (standalone, BaseEntity 미상속) +- 🔵 Refactor: skip + +### Round 2: userId가 null이면 예외가 발생한다 +- 🔴 Red: ✅ 실패 확인 — 검증 없이 생성되어 예외 미발생 +- 🟢 Green: ✅ 통과 — validateUserId() 추가 +- 🔵 Refactor: skip + +### Round 3: productId가 null이면 예외가 발생한다 +- 🔴 Red: ✅ 실패 확인 — productId 검증 미존재 +- 🟢 Green: ✅ 통과 — validateProductId() 추가 +- 🔵 Refactor: skip + +### Round 4: 좋아요가 없으면, 좋아요를 등록하고 true를 반환한다 +- 🔴 Red: ✅ 실패 확인 — toggleLike() 메서드 미존재, 컴파일 실패 +- 🟢 Green: ⏳ 대기 +- 🔵 Refactor: ⏳ 대기 + +### Round 5: 좋아요가 이미 존재하면, 좋아요를 삭제하고 false를 반환한다 +- 🔴 Red: ⏳ 대기 +- 🟢 Green: ⏳ 대기 +- 🔵 Refactor: ⏳ 대기 + +### Round 6: 상품을 검증하고 ProductLikeService.toggleLike를 호출한다 +- 🔴 Red: ⏳ 대기 +- 🟢 Green: ⏳ 대기 +- 🔵 Refactor: ⏳ 대기 + +### Round 7: 좋아요가 등록되면 product.addLikeCount()를 호출한다 +- 🔴 Red: ⏳ 대기 +- 🟢 Green: ⏳ 대기 +- 🔵 Refactor: ⏳ 대기 + +### Round 8: 좋아요가 취소되면 product.subtractLikeCount()를 호출한다 +- 🔴 Red: ⏳ 대기 +- 🟢 Green: ⏳ 대기 +- 🔵 Refactor: ⏳ 대기 + +--- + +## 전체 테스트 결과 + +(완료 후 기록) + +--- + +## 산출물 + +(완료 후 기록) From 4f128b65eaea0a376648744d0904404bc16b7119 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Mon, 23 Feb 2026 20:49:51 +0900 Subject: [PATCH 047/108] =?UTF-8?q?feat:=20ProductLikeService=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94=20=ED=86=A0=EA=B8=80=20=EB=B0=8F=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - toggleLike(): 좋아요 존재 여부에 따라 등록/삭제 후 boolean 반환 - getLikesByUserId(): 사용자별 좋아요 목록 조회 - ProductLikeRepository에 findAllByUserId 추가 - ProductLikeErrorCode 오타 수정 (DUPLICATE_NAME → DUPLICATE_LIKE) - Infrastructure 계층 JpaRepository, RepositoryImpl 구현 Co-Authored-By: Claude Opus 4.6 --- .../domain/like/ProductLikeErrorCode.java | 2 +- .../domain/like/ProductLikeRepository.java | 3 ++ .../domain/like/ProductLikeService.java | 22 +++++++++++++ .../like/ProductLikeJpaRepository.java | 12 +++++++ .../like/ProductLikeRepositoryImpl.java | 33 +++++++++++++++++++ 5 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeRepositoryImpl.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeErrorCode.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeErrorCode.java index efc5f0733..ada1575d2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeErrorCode.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeErrorCode.java @@ -8,7 +8,7 @@ @Getter @RequiredArgsConstructor public enum ProductLikeErrorCode implements ErrorCode { - DUPLICATE_NAME(HttpStatus.CONFLICT, "PRODUCT_LIKE_001", "이미 좋아요가 됐습니다."); + DUPLICATE_LIKE(HttpStatus.CONFLICT, "PRODUCT_LIKE_001", "이미 좋아요가 됐습니다."); private final HttpStatus status; private final String code; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeRepository.java index c4be0d75d..454e92a23 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeRepository.java @@ -1,5 +1,6 @@ package com.loopers.domain.like; +import java.util.List; import java.util.Optional; public interface ProductLikeRepository { @@ -8,4 +9,6 @@ public interface ProductLikeRepository { Optional findByUserIdAndProductId(Long userId, Long productId); void delete(ProductLikeModel productLike); + + List findAllByUserId(Long userId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeService.java index 4d234590b..82e0cfb5f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeService.java @@ -1,10 +1,32 @@ package com.loopers.domain.like; +import java.util.List; +import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor public class ProductLikeService { private final ProductLikeRepository productLikeRepository; + + @Transactional + public boolean toggleLike(Long userId, Long productId) { + Optional existingLike = + productLikeRepository.findByUserIdAndProductId(userId, productId); + + if (existingLike.isPresent()) { + productLikeRepository.delete(existingLike.get()); + return false; + } + + productLikeRepository.save(ProductLikeModel.create(userId, productId)); + return true; + } + + @Transactional(readOnly = true) + public List getLikesByUserId(Long userId) { + return productLikeRepository.findAllByUserId(userId); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeJpaRepository.java new file mode 100644 index 000000000..9a675c8ae --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeJpaRepository.java @@ -0,0 +1,12 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.ProductLikeModel; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProductLikeJpaRepository extends JpaRepository { + Optional findByUserIdAndProductId(Long userId, Long productId); + + List findAllByUserId(Long userId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeRepositoryImpl.java new file mode 100644 index 000000000..4b970aa54 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeRepositoryImpl.java @@ -0,0 +1,33 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.ProductLikeModel; +import com.loopers.domain.like.ProductLikeRepository; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class ProductLikeRepositoryImpl implements ProductLikeRepository { + private final ProductLikeJpaRepository productLikeJpaRepository; + @Override + public ProductLikeModel save(ProductLikeModel productLike) { + return productLikeJpaRepository.save(productLike); + } + + @Override + public Optional findByUserIdAndProductId(Long userId, Long productId) { + return productLikeJpaRepository.findByUserIdAndProductId(userId,productId); + } + + @Override + public void delete(ProductLikeModel productLike) { + productLikeJpaRepository.delete(productLike); + } + + @Override + public List findAllByUserId(Long userId) { + return productLikeJpaRepository.findAllByUserId(userId); + } +} From 4adf99e35a2a101bb1b03e6f95245daef7b770ca Mon Sep 17 00:00:00 2001 From: plan11plan Date: Mon, 23 Feb 2026 20:49:58 +0900 Subject: [PATCH 048/108] =?UTF-8?q?test:=20ProductLikeService=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94=20=ED=86=A0=EA=B8=80=20=EB=B0=8F=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 좋아요 등록 시 true 반환, 취소 시 false 반환 검증 - 사용자별 좋아요 목록 조회 및 빈 목록 반환 검증 - FakeProductLikeRepository에 findAllByUserId 추가 - TDD 진행 문서 갱신 Co-Authored-By: Claude Opus 4.6 --- .../like/FakeProductLikeRepository.java | 8 ++ .../domain/like/ProductLikeServiceTest.java | 129 ++++++++++++------ docs/tdd/like/list.md | 63 +++------ docs/tdd/like/toggle.md | 99 ++++++-------- 4 files changed, 155 insertions(+), 144 deletions(-) diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/FakeProductLikeRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/FakeProductLikeRepository.java index 6b217a886..e3f5ded59 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/like/FakeProductLikeRepository.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/FakeProductLikeRepository.java @@ -1,6 +1,7 @@ package com.loopers.domain.like; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.concurrent.atomic.AtomicLong; @@ -36,4 +37,11 @@ public Optional findByUserIdAndProductId(Long userId, Long pro public void delete(ProductLikeModel productLike) { store.remove(productLike.getId()); } + + @Override + public List findAllByUserId(Long userId) { + return store.values().stream() + .filter(like -> like.getUserId().equals(userId)) + .toList(); + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeServiceTest.java index 38fd64950..839b48a10 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeServiceTest.java @@ -1,40 +1,89 @@ -//package com.loopers.domain.like; -// -//import static org.assertj.core.api.Assertions.assertThat; -// -//import org.junit.jupiter.api.BeforeEach; -//import org.junit.jupiter.api.DisplayName; -//import org.junit.jupiter.api.Nested; -//import org.junit.jupiter.api.Test; -// -//class ProductLikeServiceTest { -// -// private ProductLikeService productLikeService; -// private FakeProductLikeRepository productLikeRepository; -// -// @BeforeEach -// void setUp() { -// productLikeRepository = new FakeProductLikeRepository(); -// productLikeService = new ProductLikeService(productLikeRepository); -// } -// -// @DisplayName("좋아요를 토글할 때, ") -// @Nested -// class ToggleLike { -// -// @DisplayName("좋아요가 없으면, 좋아요를 등록하고 true를 반환한다.") -// @Test -// void toggleLike_whenNotExists() { -// // arrange -// Long userId = 1L; -// Long productId = 2L; -// -// // act -// boolean result = productLikeService.toggleLike(userId, productId); -// -// // assert -// assertThat(result).isTrue(); -// assertThat(productLikeRepository.findByUserIdAndProductId(userId, productId)).isPresent(); -// } -// } -//} +package com.loopers.domain.like; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class ProductLikeServiceTest { + + private ProductLikeService productLikeService; + private FakeProductLikeRepository productLikeRepository; + + @BeforeEach + void setUp() { + productLikeRepository = new FakeProductLikeRepository(); + productLikeService = new ProductLikeService(productLikeRepository); + } + + @DisplayName("좋아요를 토글할 때, ") + @Nested + class ToggleLike { + + @DisplayName("좋아요가 없으면, 좋아요를 등록하고 true를 반환한다.") + @Test + void toggleLike_whenNotExists() { + // arrange + Long userId = 1L; + Long productId = 2L; + + // act + boolean result = productLikeService.toggleLike(userId, productId); + + // assert + assertThat(result).isTrue(); + assertThat(productLikeRepository.findByUserIdAndProductId(userId, productId)).isPresent(); + } + + @DisplayName("좋아요가 이미 존재하면, 좋아요를 삭제하고 false를 반환한다.") + @Test + void toggleLike_whenAlreadyExists() { + // arrange + Long userId = 1L; + Long productId = 2L; + productLikeRepository.save(ProductLikeModel.create(userId, productId)); + + // act + boolean result = productLikeService.toggleLike(userId, productId); + + // assert + assertThat(result).isFalse(); + assertThat(productLikeRepository.findByUserIdAndProductId(userId, productId)).isEmpty(); + } + } + + @DisplayName("좋아요 목록을 조회할 때, ") + @Nested + class GetLikesByUserId { + + @DisplayName("사용자 ID로 조회하면, 해당 사용자의 좋아요 목록이 반환된다.") + @Test + void getLikesByUserId_whenLikesExist() { + // arrange + Long userId = 1L; + productLikeRepository.save(ProductLikeModel.create(userId, 10L)); + productLikeRepository.save(ProductLikeModel.create(userId, 20L)); + productLikeRepository.save(ProductLikeModel.create(2L, 30L)); // 다른 사용자 + + // act + List result = productLikeService.getLikesByUserId(userId); + + // assert + assertThat(result).hasSize(2); + assertThat(result).allMatch(like -> like.getUserId().equals(userId)); + } + + @DisplayName("좋아요가 없는 사용자 ID로 조회하면, 빈 목록이 반환된다.") + @Test + void getLikesByUserId_whenNoLikes() { + // act + List result = productLikeService.getLikesByUserId(999L); + + // assert + assertThat(result).isEmpty(); + } + } +} diff --git a/docs/tdd/like/list.md b/docs/tdd/like/list.md index 157de08b0..411e953ea 100644 --- a/docs/tdd/like/list.md +++ b/docs/tdd/like/list.md @@ -1,54 +1,31 @@ -# TDD: UC-L02 좋아요 목록 조회 +# UC-L02: 좋아요 목록 조회 -| 항목 | 내용 | -|------|------| -| 도메인 | like | -| 상태 | 🟡 진행 중 | -| DESIGN.md | docs/design/like/DESIGN.md | +> DESIGN.md: `docs/design/like/DESIGN.md` --- -## 테스트 목록표 +## Domain — Service -| Round | 계층 | 테스트 대상 | 테스트 케이스 | -|-------|------|------------|-------------| -| R9 | Domain | ProductLikeServiceTest | 사용자 ID로 좋아요 목록을 조회하면, 해당 사용자의 좋아요 목록이 반환된다 | -| R10 | Domain | ProductLikeServiceTest | 좋아요가 없는 사용자 ID로 조회하면, 빈 목록이 반환된다 | -| R11 | Application | LikeFacadeTest | 사용자의 좋아요 목록을 조회하면, LikeInfo 목록을 반환한다 | -| R12 | Application | LikeFacadeTest | 삭제된 상품의 좋아요는 목록에서 제외된다 | +### 사용자별 좋아요 목록 조회 +- [x] 🔴 Red: 사용자 ID로 좋아요 목록을 조회하면, 해당 사용자의 좋아요 목록이 반환된다 +- [ ] 🟢 Green +- [ ] 🔵 Refactor ---- - -## Round 진행 현황 - -### Round 9: 사용자 ID로 좋아요 목록을 조회하면, 해당 사용자의 좋아요 목록이 반환된다 -- 🔴 Red: ⏳ 대기 -- 🟢 Green: ⏳ 대기 -- 🔵 Refactor: ⏳ 대기 - -### Round 10: 좋아요가 없는 사용자 ID로 조회하면, 빈 목록이 반환된다 -- 🔴 Red: ⏳ 대기 -- 🟢 Green: ⏳ 대기 -- 🔵 Refactor: ⏳ 대기 - -### Round 11: 사용자의 좋아요 목록을 조회하면, LikeInfo 목록을 반환한다 -- 🔴 Red: ⏳ 대기 -- 🟢 Green: ⏳ 대기 -- 🔵 Refactor: ⏳ 대기 - -### Round 12: 삭제된 상품의 좋아요는 목록에서 제외된다 -- 🔴 Red: ⏳ 대기 -- 🟢 Green: ⏳ 대기 -- 🔵 Refactor: ⏳ 대기 +### 좋아요 없는 사용자 조회 +- [x] 🔴 Red: 좋아요가 없는 사용자 ID로 조회하면, 빈 목록이 반환된다 +- [ ] 🟢 Green +- [ ] 🔵 Refactor --- -## 전체 테스트 결과 - -(완료 후 기록) - ---- +## Application — Facade -## 산출물 +### 좋아요 목록을 LikeInfo로 반환 +- [ ] 🔴 Red: 사용자의 좋아요 목록을 조회하면, LikeInfo 목록을 반환한다 +- [ ] 🟢 Green +- [ ] 🔵 Refactor -(완료 후 기록) +### 삭제된 상품 필터링 +- [ ] 🔴 Red: 삭제된 상품의 좋아요는 목록에서 제외된다 +- [ ] 🟢 Green +- [ ] 🔵 Refactor diff --git a/docs/tdd/like/toggle.md b/docs/tdd/like/toggle.md index e092c81d7..40f244927 100644 --- a/docs/tdd/like/toggle.md +++ b/docs/tdd/like/toggle.md @@ -1,78 +1,55 @@ -# TDD: UC-L01 좋아요 토글 (등록/취소) +# UC-L01: 좋아요 토글 (등록/취소) -| 항목 | 내용 | -|------|------| -| 도메인 | like | -| 상태 | 🟡 진행 중 | -| DESIGN.md | docs/design/like/DESIGN.md | +> DESIGN.md: `docs/design/like/DESIGN.md` --- -## 테스트 목록표 +## Domain — Entity -| Round | 계층 | 테스트 대상 | 테스트 케이스 | -|-------|------|------------|-------------| -| R1 | Domain | ProductLikeModelTest | 유효한 userId와 productId가 주어지면, 정상적으로 생성된다 | -| R2 | Domain | ProductLikeModelTest | userId가 null이면 예외가 발생한다 | -| R3 | Domain | ProductLikeModelTest | productId가 null이면 예외가 발생한다 | -| R4 | Domain | ProductLikeServiceTest | 좋아요가 없으면, 좋아요를 등록하고 true를 반환한다 | -| R5 | Domain | ProductLikeServiceTest | 좋아요가 이미 존재하면, 좋아요를 삭제하고 false를 반환한다 | -| R6 | Application | LikeFacadeTest | 상품을 검증하고 ProductLikeService.toggleLike를 호출한다 | -| R7 | Application | LikeFacadeTest | 좋아요가 등록되면 product.addLikeCount()를 호출한다 | -| R8 | Application | LikeFacadeTest | 좋아요가 취소되면 product.subtractLikeCount()를 호출한다 | +### ProductLikeModel 생성 +- [x] 🔴 Red: 유효한 userId와 productId가 주어지면, 정상적으로 생성된다 +- [x] 🟢 Green +- [x] 🔵 Refactor: skip ---- - -## Round 진행 현황 - -### Round 1: 유효한 userId와 productId가 주어지면, 정상적으로 생성된다 -- 🔴 Red: ✅ 실패 확인 — ProductLikeModel 클래스 미존재로 컴파일 실패 -- 🟢 Green: ✅ 통과 — ProductLikeModel Entity 생성 (standalone, BaseEntity 미상속) -- 🔵 Refactor: skip - -### Round 2: userId가 null이면 예외가 발생한다 -- 🔴 Red: ✅ 실패 확인 — 검증 없이 생성되어 예외 미발생 -- 🟢 Green: ✅ 통과 — validateUserId() 추가 -- 🔵 Refactor: skip +### ProductLikeModel userId 검증 +- [x] 🔴 Red: userId가 null이면 예외가 발생한다 +- [x] 🟢 Green +- [x] 🔵 Refactor: validate() 하나로 통합 -### Round 3: productId가 null이면 예외가 발생한다 -- 🔴 Red: ✅ 실패 확인 — productId 검증 미존재 -- 🟢 Green: ✅ 통과 — validateProductId() 추가 -- 🔵 Refactor: skip +### ProductLikeModel productId 검증 +- [x] 🔴 Red: productId가 null이면 예외가 발생한다 +- [x] 🟢 Green +- [x] 🔵 Refactor: skip -### Round 4: 좋아요가 없으면, 좋아요를 등록하고 true를 반환한다 -- 🔴 Red: ✅ 실패 확인 — toggleLike() 메서드 미존재, 컴파일 실패 -- 🟢 Green: ⏳ 대기 -- 🔵 Refactor: ⏳ 대기 - -### Round 5: 좋아요가 이미 존재하면, 좋아요를 삭제하고 false를 반환한다 -- 🔴 Red: ⏳ 대기 -- 🟢 Green: ⏳ 대기 -- 🔵 Refactor: ⏳ 대기 +--- -### Round 6: 상품을 검증하고 ProductLikeService.toggleLike를 호출한다 -- 🔴 Red: ⏳ 대기 -- 🟢 Green: ⏳ 대기 -- 🔵 Refactor: ⏳ 대기 +## Domain — Service -### Round 7: 좋아요가 등록되면 product.addLikeCount()를 호출한다 -- 🔴 Red: ⏳ 대기 -- 🟢 Green: ⏳ 대기 -- 🔵 Refactor: ⏳ 대기 +### 좋아요 등록 (toggle → true) +- [x] 🔴 Red: 좋아요가 없으면, 좋아요를 등록하고 true를 반환한다 +- [ ] 🟢 Green +- [ ] 🔵 Refactor -### Round 8: 좋아요가 취소되면 product.subtractLikeCount()를 호출한다 -- 🔴 Red: ⏳ 대기 -- 🟢 Green: ⏳ 대기 -- 🔵 Refactor: ⏳ 대기 +### 좋아요 취소 (toggle → false) +- [x] 🔴 Red: 좋아요가 이미 존재하면, 좋아요를 삭제하고 false를 반환한다 +- [ ] 🟢 Green +- [ ] 🔵 Refactor --- -## 전체 테스트 결과 +## Application — Facade -(완료 후 기록) - ---- +### 상품 검증 + toggleLike 호출 +- [ ] 🔴 Red: 상품을 검증하고 ProductLikeService.toggleLike를 호출한다 +- [ ] 🟢 Green +- [ ] 🔵 Refactor -## 산출물 +### 좋아요 등록 시 likeCount 증가 +- [ ] 🔴 Red: 좋아요가 등록되면 product.addLikeCount()를 호출한다 +- [ ] 🟢 Green +- [ ] 🔵 Refactor -(완료 후 기록) +### 좋아요 취소 시 likeCount 감소 +- [ ] 🔴 Red: 좋아요가 취소되면 product.subtractLikeCount()를 호출한다 +- [ ] 🟢 Green +- [ ] 🔵 Refactor From 96b281ad94924efd22c97c7aa982228c5a17f088 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Mon, 23 Feb 2026 23:22:43 +0900 Subject: [PATCH 049/108] =?UTF-8?q?docs:=20DTO=20=EB=84=A4=EC=9D=B4?= =?UTF-8?q?=EB=B0=8D=20=EC=BB=A8=EB=B2=A4=EC=85=98=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?(Application:=20Criteria/Result,=20Domain:=20Command)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .claude/skills/project-convention/SKILL.md | 8 +- .../application/service-layer-convention.md | 31 +++---- .../references/common/dto-convention.md | 85 ++++++++++--------- .../references/common/package-convention.md | 24 +++--- .../references/common/test-convention.md | 2 +- .../references/domain/entity-vo-convention.md | 2 +- 6 files changed, 79 insertions(+), 73 deletions(-) diff --git a/.claude/skills/project-convention/SKILL.md b/.claude/skills/project-convention/SKILL.md index 068c9fc5b..a3bd1206c 100644 --- a/.claude/skills/project-convention/SKILL.md +++ b/.claude/skills/project-convention/SKILL.md @@ -24,7 +24,7 @@ com.loopers/ ├── interfaces/ ← Controller, ApiSpec, Request/Response DTO │ ├── api/ ← 공통 (ApiResponse, ControllerAdvice) │ └── {domain}/ ← 도메인별 Controller, DTO -├── application/ ← Facade, Command/Query/Info/Result DTO +├── application/ ← Facade, Criteria/Result DTO │ └── {domain}/ ├── domain/ ← Entity, VO, Service, Repository(I/F), ErrorCode │ └── {domain}/ @@ -54,8 +54,8 @@ ApiResponse.failValidation(code, message, fieldErrors) // Validation 에 | 계층 | 요청 | 응답 | |------|------|------| | **Interface** | `~Request` | `~Response` | -| **Application** | `~Command` / `~Query` | `~Info` (단일) / `~Result` (조합) | -| **Domain** | `~Data` | **Entity** 또는 `~Info` | +| **Application** | `~Criteria` | `~Result` | +| **Domain** | `~Command` | **Entity** 또는 `~Info` | **테스트** @@ -165,7 +165,7 @@ ApiResponse.failValidation(code, message, fieldErrors) // Validation 에 - ErrorType → ErrorCode 전환, CoreException/ApiControllerAdvice/ApiResponse 수정 **DTO** → `references/common/dto-convention.md` -- DTO 신규 생성, 계층 간 전달 객체, 변환 메서드(toCommand, from), record Inner Class +- DTO 신규 생성, 계층 간 전달 객체, 변환 메서드(toCriteria, toCommand, from), record Inner Class **테스트** → `references/common/test-convention.md` - 테스트 클래스 생성, 네이밍, 단위/통합/E2E 구분, Fake vs Mockito, DB 정리 diff --git a/.claude/skills/project-convention/references/application/service-layer-convention.md b/.claude/skills/project-convention/references/application/service-layer-convention.md index e53d87f2a..aec1ee413 100644 --- a/.claude/skills/project-convention/references/application/service-layer-convention.md +++ b/.claude/skills/project-convention/references/application/service-layer-convention.md @@ -39,13 +39,14 @@ Application 계층에는 **Facade만 둔다**. 별도 ApplicationService 개념 - **유스케이스 조율**: 여러 Domain Service를 호출하여 하나의 비즈니스 흐름을 완성한다 - **DTO 변환**: Interface ↔ Application DTO 변환의 중간 지점 - **도메인 간 데이터 조합**: 여러 도메인의 Info를 Result로 묶어 반환한다 +- **타 도메인 Result → Command 변환**: 타 도메인 결과를 자기 도메인 Command로 변환한다 - **트랜잭션 경계**: 여러 Domain Service 호출의 원자성을 보장한다 ### Facade에 넣는 것 - 여러 Domain Service 조합 흐름 -- Entity → Info 변환, 여러 Info → Result 조합 -- 타 도메인 Entity → Data DTO 변환 후 자기 도메인 Service에 전달 +- Entity → Result 변환, 여러 Info → Result 조합 +- 타 도메인 Result/Info → Command DTO 변환 후 자기 도메인 Service에 전달 ### Facade에 넣지 않는 것 @@ -64,27 +65,27 @@ public class OrderFacade { private final MemberService memberService; @Transactional - public OrderInfo createOrder(OrderCommand.Create command) { + public OrderResult createOrder(OrderCriteria.Create criteria) { // 1. 타 도메인에서 필요한 데이터 조회 - MemberInfo member = memberService.getMember(command.memberId()); - List products = productService.getProducts(command.productIds()); + MemberInfo member = memberService.getMember(criteria.memberId()); + List products = productService.getProducts(criteria.productIds()); - // 2. 타 도메인 Info → 자기 도메인 Data 변환 - OrderMemberData memberData = OrderMemberData.from(member); - List productData = products.stream() - .map(OrderProductData::from) + // 2. 타 도메인 Info → 자기 도메인 Command 변환 + OrderMemberCommand memberCommand = OrderMemberCommand.from(member); + List productCommands = products.stream() + .map(OrderProductCommand::from) .toList(); // 3. 자기 도메인 Service에 위임 - Order order = orderService.create(memberData, productData); + Order order = orderService.create(memberCommand, productCommands); - // 4. Entity → Info 변환 후 반환 - return OrderInfo.from(order); + // 4. Entity → Result 변환 후 반환 + return OrderResult.from(order); } @Transactional(readOnly = true) - public OrderDetailResult getOrderDetail(OrderQuery.Detail query) { - OrderInfo order = orderService.getOrderInfo(query.orderId()); + public OrderDetailResult getOrderDetail(OrderCriteria.Detail criteria) { + OrderInfo order = orderService.getOrderInfo(criteria.orderId()); ProductInfo product = productService.getProduct(order.productId()); MemberInfo member = memberService.getMember(order.memberId()); @@ -132,7 +133,7 @@ public class OrderService { private final OrderRepository orderRepository; @Transactional - public void create(OrderMemberData member, List products) { + public void create(OrderMemberCommand member, List products) { List lines = products.stream() .map(p -> OrderLine.create(p.productId(), p.name(), p.price())) .toList(); diff --git a/.claude/skills/project-convention/references/common/dto-convention.md b/.claude/skills/project-convention/references/common/dto-convention.md index 231ef49e5..23e60c334 100644 --- a/.claude/skills/project-convention/references/common/dto-convention.md +++ b/.claude/skills/project-convention/references/common/dto-convention.md @@ -15,11 +15,13 @@ | 계층 | 요청 (입력) | 응답 (출력) | 비고 | |------|------------|------------|------| | **Interface** | `~Request` | `~Response` | API 스펙 종속, `@Valid` 부착 | -| **Application** | `~Command` / `~Query` | `~Info` (단일) / `~Result` (조합) | 유스케이스 단위 입출력 | -| **Domain Service** | `~Data` | **Entity** 또는 `~Info` | 외부 도메인 정보 명세. 필요할 때만 생성 | +| **Application** | `~Criteria` | `~Result` | 유스케이스 단위 입출력. Criteria는 Domain의 Command를 참조/조합 가능 | +| **Domain Service** | `~Command` | **Entity** 또는 `~Info` | 자기 도메인 비즈니스 입력 (타 도메인 정보 명세 포함) | - Domain Service는 **Entity를 직접 반환하는 게 기본**. `Info`는 Entity 하나로 표현이 안 될 때만 생성한다. -- Application `~Info`는 유스케이스 결과(단일 도메인), `~Result`는 여러 도메인 Info를 조합할 때 사용한다. +- Application `~Criteria`는 유스케이스 입력을 표현하며, 내부에서 Domain Service의 `~Command`를 참조하거나 조합할 수 있다. +- Application `~Result`는 유스케이스 결과를 표현한다 (단일/조합 구분 없이 통합). +- Domain Service `~Command`는 자기 도메인 비즈니스 명령과 타 도메인 정보 명세를 모두 포함한다. - Domain 계층 자체(Entity, VO)는 DTO를 사용하지 않는다. --- @@ -37,14 +39,24 @@ public class ProductDto { @NotBlank String name, @Positive int price ) { - public ProductCommand.Create toCommand() { - return new ProductCommand.Create(name, price); + public ProductCriteria.Create toCreateCriteria() { + return new ProductCriteria.Create(name, price); } } public record DetailResponse(Long id, String name, int price) { - public static DetailResponse from(ProductInfo info) { - return new DetailResponse(info.id(), info.name(), info.price()); + public static DetailResponse from(ProductResult result) { + return new DetailResponse(result.id(), result.name(), result.price()); + } + } +} +``` + +```java +public class ProductCriteria { + public record Create(String name, int price) { + public ProductCommand.Create toCommand() { + return new ProductCommand.Create(name, price); } } } @@ -58,9 +70,9 @@ public class ProductCommand { ``` ```java -public record ProductInfo(Long id, String name, int price) { - public static ProductInfo from(Product entity) { - return new ProductInfo(entity.getId(), entity.getName(), entity.getPrice()); +public record ProductResult(Long id, String name, int price) { + public static ProductResult from(Product entity) { + return new ProductResult(entity.getId(), entity.getName(), entity.getPrice()); } } ``` @@ -71,27 +83,28 @@ public record ProductInfo(Long id, String name, int price) { | 변환 | 메서드 위치 | 형태 | 예시 | |------|-----------|------|------| -| Request → Command | Request | `toCommand()` | `request.toCommand()` | -| Entity → Info | Info | `static from()` | `ProductInfo.from(entity)` | -| Info → Response | Response | `static from()` | `DetailResponse.from(info)` | -| Entity → Data (타 도메인) | Data | `static from()` | `OrderProductData.from(product)` | +| Request → Criteria | Request | `toCriteria()` | `request.toCreateCriteria()` | +| Criteria → Command | Criteria | `toCommand()` | `criteria.toCommand()` | +| Entity → Result | Result | `static from()` | `ProductResult.from(entity)` | +| Result → Response | Response | `static from()` | `DetailResponse.from(result)` | +| Entity/Info → Command (타 도메인) | Command | `static from()` | `OrderProductCommand.from(product)` | > **금지**: 하위 계층이 상위 계층을 아는 것. Domain이 Application DTO를, Application이 Interface DTO를 알면 안 된다. -### 3. Domain Service의 Data / 반환 +### 3. Domain Service의 Command / 반환 ```java // 주문 도메인이 상품 도메인에 요구하는 정보 명세 -public record OrderProductData(Long productId, String name, Money price, Long shopId) { - public static OrderProductData from(Product product) { - return new OrderProductData( - product.getId(), product.getName(), product.getPrice(), product.getShopId() +public record OrderProductCommand(Long productId, String name, Money price, Long shopId) { + public static OrderProductCommand from(ProductResult product) { + return new OrderProductCommand( + product.id(), product.name(), product.price(), product.shopId() ); } } // 기본: Entity 직접 반환 -public Order create(OrderMemberData member, List products) { +public Order create(OrderMemberCommand member, List products) { return Order.create(member.memberId(), products); } @@ -110,7 +123,7 @@ public record StockDeductionInfo(int remainingStock, boolean success) {} | 파라미터 수 | 전달 방식 | 예시 | |------------|----------|------| | **1~3개** | 원시 타입 직접 전달 | `orderService.create(memberId, address, shopId)` | -| **4개 이상** | DTO(`~Data`) 사용 | `orderService.create(orderProductData)` | +| **4개 이상** | DTO(`~Command`) 사용 | `orderService.create(orderProductCommand)` | > **주의 — VO 전달과 VO 생성은 다르다.** > Entity 필드용 VO를 호출자(Facade 등)가 **새로 생성하여 전달하는 것은 금지**한다. VO는 Entity 내부에서 원시값으로부터 생성한다 (→ entity-vo-convention 참조). @@ -120,8 +133,8 @@ public record StockDeductionInfo(int remainingStock, boolean success) {} // ✅ 파라미터 3개 이하 → 원시 타입 public Order create(Long memberId, String address, Long shopId) { ... } -// ✅ 파라미터 4개 이상 → Data DTO -public Order create(OrderProductData productData) { ... } +// ✅ 파라미터 4개 이상 → Command DTO +public Order create(OrderProductCommand productCommand) { ... } ``` ### 2. 절대 금지: Domain Service에 타 도메인 객체 노출 @@ -134,13 +147,13 @@ public class OrderService { public Order create(Member member, List products) { ... } } -// ✅ Data로 변환하여 전달 - 도메인 간 결합 제거 +// ✅ Command로 변환하여 전달 - 도메인 간 결합 제거 public class OrderService { - public Order create(OrderMemberData member, List products) { ... } + public Order create(OrderMemberCommand member, List products) { ... } } ``` -> Application 계층(Facade)이 타 도메인 Entity → Data 변환을 책임진다. +> Application 계층(Facade)이 타 도메인 Entity/Result → Command 변환을 책임진다. ### 3. 타 도메인 출력 조합: Application에서 `~Result` @@ -158,8 +171,8 @@ public record OrderDetailResult( @Service public class OrderFacade { - public OrderDetailResult getDetail(OrderDetailQuery query) { - OrderInfo order = orderService.getOrder(query.orderId()); + public OrderDetailResult getDetail(OrderCriteria.Detail criteria) { + OrderInfo order = orderService.getOrder(criteria.orderId()); ProductInfo product = productService.getProduct(order.productId()); MemberInfo member = memberService.getMember(order.memberId()); @@ -168,11 +181,6 @@ public class OrderFacade { } ``` -| 상황 | Application 응답 | 사용 시점 | -|------|-----------------|----------| -| 단일 도메인 반환 | `~Info` | 대부분의 경우 | -| 여러 도메인 조합 | `~Result` | 다중 도메인 Info를 합칠 때 | - ### Domain Service 응답 기준 | 상황 | Domain Service 응답 | 사용 시점 | @@ -187,11 +195,10 @@ public class OrderFacade { ``` Client → ProductCreateRequest (Interface 입력) - → ProductCreateCommand (Application 입력) - → OrderProductData (Domain 입력 - 필요 시) + → ProductCriteria.Create (Application 입력 — Command 참조 가능) + → ProductCommand.Create (Domain 입력) ← Entity 또는 ~Info (Domain 출력) - ← ProductInfo (Application 출력 - 단일) - ← OrderDetailResult (Application 출력 - 조합) + ← ProductResult (Application 출력) ← ProductCreateResponse (Interface 출력) ``` @@ -203,9 +210,9 @@ Client - [ ] 관련 DTO끼리 Inner Class로 그룹핑했는가? - [ ] 변환 메서드가 "아는 쪽"에 있는가? (의존 방향 위반 없는가?) - [ ] Interface DTO에만 `@Valid`, `@JsonProperty` 등이 붙어 있는가? -- [ ] Application DTO(Command/Info)에 API 스펙 관련 어노테이션이 없는가? +- [ ] Application DTO(Criteria/Result)에 API 스펙 관련 어노테이션이 없는가? - [ ] Domain Service가 Application DTO를 참조하지 않는가? - [ ] Domain Service의 Info는 Entity로 충분하지 않을 때만 만들었는가? -- [ ] Domain Service 파라미터 1~3개는 원시 타입/VO, 4개+는 Data DTO인가? +- [ ] Domain Service 파라미터 1~3개는 원시 타입/VO, 4개+는 Command DTO인가? - [ ] Domain Service 메서드 시그니처에 타 도메인 Entity가 노출되지 않는가? - [ ] 여러 도메인 Info 조합 시 Application에서 `~Result`로 합치는가? diff --git a/.claude/skills/project-convention/references/common/package-convention.md b/.claude/skills/project-convention/references/common/package-convention.md index d5371ea45..2c99a16fc 100644 --- a/.claude/skills/project-convention/references/common/package-convention.md +++ b/.claude/skills/project-convention/references/common/package-convention.md @@ -26,7 +26,7 @@ com.loopers/ │ └── like/ ← 좋아요 Controller, Request/Response DTO │ ├── application/ ← Application 계층 -│ ├── order/ ← 주문 Facade, Command/Query/Info/Result DTO +│ ├── order/ ← 주문 Facade, Criteria/Result DTO │ ├── product/ │ └── like/ │ @@ -78,9 +78,8 @@ application/ └── order/ ├── OrderFacade.java └── dto/ - ├── OrderCommand.java ← Inner Class: Create, Update ... - ├── OrderQuery.java ← Inner Class: Detail, Search ... - ├── OrderInfo.java ← 단일 도메인 응답 + ├── OrderCriteria.java ← Inner Class: Create, Detail ... + ├── OrderResult.java ← 유스케이스 결과 └── OrderDetailResult.java ← 다중 도메인 조합 응답 (필요 시) ``` @@ -96,8 +95,8 @@ domain/ ├── OrderRepository.java ← Repository 인터페이스 ├── OrderErrorCode.java ← 도메인 에러코드 └── dto/ ← 도메인 DTO (필요 시) - ├── OrderProductData.java ← 타 도메인 정보 명세 - └── OrderMemberData.java + ├── OrderProductCommand.java ← 타 도메인 정보 명세 + └── OrderMemberCommand.java ``` ### infrastructure/ — 풀 구조 @@ -124,7 +123,7 @@ application/ └── wishlist/ ├── WishlistFacade.java └── dto/ - └── WishlistInfo.java + └── WishlistResult.java domain/ └── wishlist/ @@ -191,10 +190,9 @@ support에 넣으면 안 되는 것: | 클래스 | 네이밍 | 예시 | |--------|--------|------| | Facade | `{Domain}Facade` | `OrderFacade` | -| Command DTO | `{Domain}Command.{Action}` | `OrderCommand.Create` | -| Query DTO | `{Domain}Query.{Action}` | `OrderQuery.Search` | -| Info DTO | `{Domain}Info` | `OrderInfo` | -| Result DTO | `{Domain}{Detail}Result` | `OrderDetailResult` | +| Criteria DTO | `{Domain}Criteria.{Action}` | `OrderCriteria.Create` | +| Result DTO | `{Domain}Result` | `OrderResult` | +| 조합 Result DTO | `{Domain}{Detail}Result` | `OrderDetailResult` | ### domain/{domain}/ @@ -205,7 +203,7 @@ support에 넣으면 안 되는 것: | Repository (인터페이스) | `{Domain}Repository` | `OrderRepository` | | ErrorCode | `{Domain}ErrorCode` | `OrderErrorCode` | | VO / enum | 의미에 맞게 | `OrderStatus`, `Money` | -| Data DTO | `{Target}Data` | `OrderProductData` | +| Command DTO | `{Target}Command` | `OrderProductCommand` | ### infrastructure/{domain}/ @@ -232,7 +230,7 @@ interfaces → application → domain ← infrastructure - 도메인 간 **Entity 직접 참조 금지** - Application 계층(Facade)에서 타 도메인의 Domain Service를 호출하여 조합한다 -- 필요 시 Domain의 Data DTO로 정보를 전달한다 +- 필요 시 Domain의 Command DTO로 정보를 전달한다 ```java // ✅ Application에서 타 도메인 Service 호출 diff --git a/.claude/skills/project-convention/references/common/test-convention.md b/.claude/skills/project-convention/references/common/test-convention.md index 660a233e4..b0c2dd9f9 100644 --- a/.claude/skills/project-convention/references/common/test-convention.md +++ b/.claude/skills/project-convention/references/common/test-convention.md @@ -292,7 +292,7 @@ class OrderFacadeTest { when(orderService.create(any())).thenReturn(order); // act - OrderDetailResult result = orderFacade.createOrder(command); + OrderDetailResult result = orderFacade.createOrder(criteria); // assert assertThat(result).isNotNull(); diff --git a/.claude/skills/project-convention/references/domain/entity-vo-convention.md b/.claude/skills/project-convention/references/domain/entity-vo-convention.md index eaf620e80..5f1633d1c 100644 --- a/.claude/skills/project-convention/references/domain/entity-vo-convention.md +++ b/.claude/skills/project-convention/references/domain/entity-vo-convention.md @@ -319,7 +319,7 @@ public class Promotion { // 3. 외부 의존 → Domain Service public class OrderService { - public Order create(OrderMemberData member, List products) { + public Order create(OrderMemberCommand member, List products) { validateOrderLimit(member); // 회원 등급별 주문 한도 — 타 도메인 데이터 필요 // ... } From 4a271acfb90718fedf24aea0c469f1e448b0ac95 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Mon, 23 Feb 2026 23:50:12 +0900 Subject: [PATCH 050/108] =?UTF-8?q?refactor:=20Facade/Service=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=EB=AA=85=EC=9D=84=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EC=9D=98=EB=AF=B8=EA=B0=80=20=EB=93=9C=EB=9F=AC?= =?UTF-8?q?=EB=82=98=EB=8F=84=EB=A1=9D=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../application/brand/BrandFacade.java | 10 +- .../loopers/application/like/LikeFacade.java | 42 ++++++ .../application/like/dto/LikeCommand.java | 12 ++ .../application/like/dto/LikeInfo.java | 17 +++ .../application/product/ProductFacade.java | 16 +-- .../domain/like/ProductLikeService.java | 24 ++-- .../brand/AdminBrandV1Controller.java | 10 +- .../interfaces/brand/BrandV1Controller.java | 2 +- .../product/AdminProductV1Controller.java | 12 +- .../product/ProductV1Controller.java | 6 +- .../application/brand/BrandFacadeTest.java | 8 +- .../application/like/LikeFacadeTest.java | 127 ++++++++++++++++++ .../domain/like/ProductLikeServiceTest.java | 59 +++++--- 13 files changed, 283 insertions(+), 62 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/dto/LikeCommand.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/dto/LikeInfo.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java index 51e15458d..4e50f562c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -19,29 +19,29 @@ public class BrandFacade { private final ProductService productService; @Transactional - public void register(BrandCommand.Register command) { + public void registerBrand(BrandCommand.Register command) { brandService.register(command.name()); } @Transactional(readOnly = true) - public BrandInfo getById(Long id) { + public BrandInfo getBrand(Long id) { BrandModel brandModel = brandService.getById(id); return BrandInfo.from(brandModel); } @Transactional - public void update(Long id, BrandCommand.Update command) { + public void updateBrand(Long id, BrandCommand.Update command) { brandService.update(id, command.name()); } @Transactional - public void delete(Long id) { + public void deleteBrand(Long id) { brandService.delete(id); productService.softDeleteByBrandId(id); } @Transactional(readOnly = true) - public Page getAll(Pageable pageable) { + public Page getBrands(Pageable pageable) { return brandService.getAll(pageable).map(BrandInfo::from); } } 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..7d3147d19 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -0,0 +1,42 @@ +package com.loopers.application.like; + +import com.loopers.application.like.dto.LikeCommand.Toggle; +import com.loopers.application.like.dto.LikeInfo; +import com.loopers.domain.like.ProductLikeService; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductService; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class LikeFacade { + private final ProductLikeService productLikeService; + private final ProductService productService; + + @Transactional + public void toggleLike(Toggle command) { + ProductModel product = productService.getById(command.productId()); + + switch (command.type()) { + case LIKE -> { + productLikeService.like(command.userId(), command.productId()); + product.addLikeCount(); + } + case UNLIKE -> { + productLikeService.unlike(command.userId(), command.productId()); + product.subtractLikeCount(); + } + } + } + + @Transactional(readOnly = true) + public List getMyLikedProducts(Long userId) { + return productLikeService.getLikesByUserId(userId).stream() + .filter(like -> productService.existsById(like.getProductId())) + .map(LikeInfo::from) + .toList(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/dto/LikeCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/like/dto/LikeCommand.java new file mode 100644 index 000000000..b182d20e7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/dto/LikeCommand.java @@ -0,0 +1,12 @@ +package com.loopers.application.like.dto; + +public class LikeCommand { + + public enum ApplyLikeRequestType { + LIKE, + UNLIKE + } + public record Toggle(ApplyLikeRequestType type, Long userId, Long productId) { + } + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/dto/LikeInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/like/dto/LikeInfo.java new file mode 100644 index 000000000..7eb365690 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/dto/LikeInfo.java @@ -0,0 +1,17 @@ +package com.loopers.application.like.dto; + +import com.loopers.domain.like.ProductLikeModel; +import java.time.ZonedDateTime; +import java.util.List; + +public record LikeInfo(Long id, Long userId, Long productId, ZonedDateTime createdAt) { + public static LikeInfo from(ProductLikeModel model) { + return new LikeInfo(model.getId(), model.getUserId(), model.getProductId(), model.getCreatedAt()); + } + public static List from(List models) { + return models.stream() + .map(model -> + new LikeInfo(model.getId(), model.getUserId(), model.getProductId(), model.getCreatedAt())) + .toList(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index 3f77a6080..7a8b1ec45 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -20,44 +20,44 @@ public class ProductFacade { private final BrandService brandService; @Transactional - public void register(ProductCommand.Register command) { + public void registerProduct(ProductCommand.Register command) { BrandModel brand = brandService.getById(command.brandId()); productService.register(brand, command.name(), command.price(), command.stock()); } @Transactional(readOnly = true) - public ProductInfo getById(Long id) { + public ProductInfo getProduct(Long id) { ProductModel productModel = productService.getById(id); return ProductInfo.from(productModel); } @Transactional - public void update(Long id, ProductCommand.Update command) { + public void updateProduct(Long id, ProductCommand.Update command) { productService.update(id, command.name(), command.price(), command.stock()); } @Transactional - public void delete(Long id) { + public void deleteProduct(Long id) { productService.delete(id); } @Transactional(readOnly = true) - public Page getAll(Pageable pageable) { + public Page getProducts(Pageable pageable) { return productService.getAll(pageable).map(ProductInfo::from); } @Transactional(readOnly = true) - public Page getAllByBrandId(Long brandId, Pageable pageable) { + public Page getProductsByBrandId(Long brandId, Pageable pageable) { return productService.getAllByBrandId(brandId, pageable).map(ProductInfo::from); } @Transactional(readOnly = true) - public Page getAllActive(Pageable pageable) { + public Page getActiveProducts(Pageable pageable) { return productService.getAllActive(pageable).map(ProductInfo::from); } @Transactional(readOnly = true) - public Page getAllActiveByBrandId(Long brandId, Pageable pageable) { + public Page getActiveProductsByBrandId(Long brandId, Pageable pageable) { return productService.getAllActiveByBrandId(brandId, pageable).map(ProductInfo::from); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeService.java index 82e0cfb5f..5519d5120 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeService.java @@ -1,7 +1,8 @@ package com.loopers.domain.like; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import java.util.List; -import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -12,17 +13,20 @@ public class ProductLikeService { private final ProductLikeRepository productLikeRepository; @Transactional - public boolean toggleLike(Long userId, Long productId) { - Optional existingLike = - productLikeRepository.findByUserIdAndProductId(userId, productId); + public void like(Long userId, Long productId) { + productLikeRepository.save(ProductLikeModel.create(userId, productId)); + } - if (existingLike.isPresent()) { - productLikeRepository.delete(existingLike.get()); - return false; - } + @Transactional + public void unlike(Long userId, Long productId) { + ProductLikeModel like = productLikeRepository.findByUserIdAndProductId(userId, productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND_DATA)); + productLikeRepository.delete(like); + } - productLikeRepository.save(ProductLikeModel.create(userId, productId)); - return true; + @Transactional(readOnly = true) + public boolean existsByUserIdAndProductId(Long userId, Long productId) { + return productLikeRepository.findByUserIdAndProductId(userId, productId).isPresent(); } @Transactional(readOnly = true) diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/AdminBrandV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/AdminBrandV1Controller.java index f166a0910..8668a99de 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/AdminBrandV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/AdminBrandV1Controller.java @@ -22,7 +22,7 @@ public class AdminBrandV1Controller implements AdminBrandV1ApiSpec { public ApiResponse register( @Valid @RequestBody AdminBrandV1Dto.RegisterRequest request ) { - brandFacade.register(request.toCommand()); + brandFacade.registerBrand(request.toCommand()); return ApiResponse.success(); } @@ -32,7 +32,7 @@ public ApiResponse list( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size ) { - Page brandInfoPage = brandFacade.getAll(PageRequest.of(page, size)); + Page brandInfoPage = brandFacade.getBrands(PageRequest.of(page, size)); AdminBrandV1Dto.ListResponse listResponse = new AdminBrandV1Dto.ListResponse( brandInfoPage.getNumber(), brandInfoPage.getSize(), @@ -50,7 +50,7 @@ public ApiResponse list( public ApiResponse getById( @PathVariable Long brandId ) { - BrandInfo brandInfo = brandFacade.getById(brandId); + BrandInfo brandInfo = brandFacade.getBrand(brandId); return ApiResponse.success(AdminBrandV1Dto.DetailResponse.from(brandInfo)); } @@ -60,7 +60,7 @@ public ApiResponse update( @PathVariable Long brandId, @Valid @RequestBody AdminBrandV1Dto.UpdateRequest request ) { - brandFacade.update(brandId, request.toCommand()); + brandFacade.updateBrand(brandId, request.toCommand()); return ApiResponse.success(); } @@ -69,7 +69,7 @@ public ApiResponse update( public ApiResponse delete( @PathVariable Long brandId ) { - brandFacade.delete(brandId); + brandFacade.deleteBrand(brandId); return ApiResponse.success(); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/BrandV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/BrandV1Controller.java index 5242c852f..5aefdc719 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/BrandV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/BrandV1Controller.java @@ -19,7 +19,7 @@ public class BrandV1Controller implements BrandV1ApiSpec { public ApiResponse getById( @PathVariable Long brandId ) { - BrandInfo brandInfo = brandFacade.getById(brandId); + BrandInfo brandInfo = brandFacade.getBrand(brandId); return ApiResponse.success(BrandV1Dto.DetailResponse.from(brandInfo)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/product/AdminProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/AdminProductV1Controller.java index ee0f4ce3e..e991bb6f4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/product/AdminProductV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/AdminProductV1Controller.java @@ -22,7 +22,7 @@ public class AdminProductV1Controller implements AdminProductV1ApiSpec { public ApiResponse register( @Valid @RequestBody AdminProductV1Dto.RegisterRequest request ) { - productFacade.register(request.toCommand()); + productFacade.registerProduct(request.toCommand()); return ApiResponse.success(); } @@ -34,8 +34,8 @@ public ApiResponse list( @RequestParam(required = false) Long brandId ) { Page productInfoPage = brandId != null - ? productFacade.getAllByBrandId(brandId, PageRequest.of(page, size)) - : productFacade.getAll(PageRequest.of(page, size)); + ? productFacade.getProductsByBrandId(brandId, PageRequest.of(page, size)) + : productFacade.getProducts(PageRequest.of(page, size)); AdminProductV1Dto.ListResponse listResponse = new AdminProductV1Dto.ListResponse( productInfoPage.getNumber(), @@ -54,7 +54,7 @@ public ApiResponse list( public ApiResponse getById( @PathVariable Long productId ) { - ProductInfo productInfo = productFacade.getById(productId); + ProductInfo productInfo = productFacade.getProduct(productId); return ApiResponse.success(AdminProductV1Dto.DetailResponse.from(productInfo)); } @@ -64,7 +64,7 @@ public ApiResponse update( @PathVariable Long productId, @Valid @RequestBody AdminProductV1Dto.UpdateRequest request ) { - productFacade.update(productId, request.toCommand()); + productFacade.updateProduct(productId, request.toCommand()); return ApiResponse.success(); } @@ -73,7 +73,7 @@ public ApiResponse update( public ApiResponse delete( @PathVariable Long productId ) { - productFacade.delete(productId); + productFacade.deleteProduct(productId); return ApiResponse.success(); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1Controller.java index 65f0e0096..3e03d14d5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1Controller.java @@ -27,8 +27,8 @@ public ApiResponse list( ) { PageRequest pageRequest = PageRequest.of(page, size, toSort(sort)); Page productInfoPage = brandId != null - ? productFacade.getAllActiveByBrandId(brandId, pageRequest) - : productFacade.getAllActive(pageRequest); + ? productFacade.getActiveProductsByBrandId(brandId, pageRequest) + : productFacade.getActiveProducts(pageRequest); ProductV1Dto.ListResponse listResponse = new ProductV1Dto.ListResponse( productInfoPage.getNumber(), @@ -47,7 +47,7 @@ public ApiResponse list( public ApiResponse getById( @PathVariable Long productId ) { - ProductInfo productInfo = productFacade.getById(productId); + ProductInfo productInfo = productFacade.getProduct(productId); return ApiResponse.success(ProductV1Dto.DetailResponse.from(productInfo)); } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java index 768dcd6ac..d8fed473e 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java @@ -41,7 +41,7 @@ class Register { BrandCommand.Register command = new BrandCommand.Register("나이키"); // act - brandFacade.register(command); + brandFacade.registerBrand(command); // assert verify(brandService).register("나이키"); @@ -60,7 +60,7 @@ class GetById { when(brandService.getById(1L)).thenReturn(brandModel); // act - BrandInfo result = brandFacade.getById(1L); + BrandInfo result = brandFacade.getBrand(1L); // assert assertThat(result.name()).isEqualTo("나이키"); @@ -79,7 +79,7 @@ class Update { BrandCommand.Update command = new BrandCommand.Update("아디다스"); // act - brandFacade.update(1L, command); + brandFacade.updateBrand(1L, command); // assert verify(brandService).update(1L, "아디다스"); @@ -94,7 +94,7 @@ class Delete { @DisplayName("id를 BrandService.delete에 전달하고 해당 브랜드의 상품을 일괄 삭제한다") void delete_호출_검증() { // arrange & act - brandFacade.delete(1L); + brandFacade.deleteBrand(1L); // assert verify(brandService).delete(1L); 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..7dc3f566b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java @@ -0,0 +1,127 @@ +package com.loopers.application.like; + +import static com.loopers.application.like.dto.LikeCommand.ApplyLikeRequestType.LIKE; +import static com.loopers.application.like.dto.LikeCommand.ApplyLikeRequestType.UNLIKE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.loopers.application.like.dto.LikeCommand; +import com.loopers.application.like.dto.LikeInfo; +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.like.ProductLikeModel; +import com.loopers.domain.like.ProductLikeService; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductService; +import java.util.List; +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; + +@DisplayName("LikeFacade 단위 테스트") +@ExtendWith(MockitoExtension.class) +class LikeFacadeTest { + + @Mock + private ProductLikeService productLikeService; + + @Mock + private ProductService productService; + + @InjectMocks + private LikeFacade likeFacade; + + @DisplayName("좋아요를 토글할 때, ") + @Nested + class ToggleLike { + + @DisplayName("LIKE 요청이면, 상품을 검증하고 like를 호출하고 likeCount를 증가시킨다.") + @Test + void toggleLike_whenLike_addsLikeAndIncreasesCount() { + // arrange + LikeCommand.Toggle command = new LikeCommand.Toggle(LIKE, 1L, 2L); + ProductModel product = ProductModel.create( + BrandModel.create("Nike"), "에어맥스", 150000, 100 + ); + when(productService.getById(2L)).thenReturn(product); + + // act + likeFacade.toggleLike(command); + + // assert + verify(productService).getById(2L); + verify(productLikeService).like(1L, 2L); + assertThat(product.getLikeCount()).isEqualTo(1); + } + + @DisplayName("UNLIKE 요청이면, 상품을 검증하고 unlike를 호출하고 likeCount를 감소시킨다.") + @Test + void toggleLike_whenUnlike_removesLikeAndDecreasesCount() { + // arrange + LikeCommand.Toggle command = new LikeCommand.Toggle(UNLIKE, 1L, 2L); + ProductModel product = ProductModel.create( + BrandModel.create("Nike"), "에어맥스", 150000, 100 + ); + product.addLikeCount(); // likeCount = 1 + when(productService.getById(2L)).thenReturn(product); + + // act + likeFacade.toggleLike(command); + + // assert + verify(productService).getById(2L); + verify(productLikeService).unlike(1L, 2L); + assertThat(product.getLikeCount()).isEqualTo(0); + } + } + + @DisplayName("좋아요 목록을 조회할 때, ") + @Nested + class GetLikesByUserId { + + @DisplayName("사용자의 좋아요 목록을 조회하면, LikeInfo 목록을 반환한다.") + @Test + void getLikesByUserId_returnsLikeInfoList() { + // arrange + Long userId = 1L; + List likes = List.of( + ProductLikeModel.create(userId, 10L), + ProductLikeModel.create(userId, 20L) + ); + when(productLikeService.getLikesByUserId(userId)).thenReturn(likes); + when(productService.existsById(10L)).thenReturn(true); + when(productService.existsById(20L)).thenReturn(true); + + // act + List result = likeFacade.getMyLikedProducts(userId); + + // assert + assertThat(result).hasSize(2); + } + + @DisplayName("삭제된 상품의 좋아요는 목록에서 제외된다.") + @Test + void getLikesByUserId_excludesDeletedProducts() { + // arrange + Long userId = 1L; + List likes = List.of( + ProductLikeModel.create(userId, 10L), + ProductLikeModel.create(userId, 20L) + ); + when(productLikeService.getLikesByUserId(userId)).thenReturn(likes); + when(productService.existsById(10L)).thenReturn(true); + when(productService.existsById(20L)).thenReturn(false); + + // act + List result = likeFacade.getMyLikedProducts(userId); + + // assert + assertThat(result).hasSize(1); + assertThat(result.get(0).productId()).isEqualTo(10L); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeServiceTest.java index 839b48a10..53b5d27a4 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeServiceTest.java @@ -19,39 +19,58 @@ void setUp() { productLikeService = new ProductLikeService(productLikeRepository); } - @DisplayName("좋아요를 토글할 때, ") + @DisplayName("좋아요를 등록할 때, ") @Nested - class ToggleLike { + class Like { - @DisplayName("좋아요가 없으면, 좋아요를 등록하고 true를 반환한다.") + @DisplayName("유효한 userId와 productId가 주어지면, 좋아요가 저장된다.") @Test - void toggleLike_whenNotExists() { - // arrange - Long userId = 1L; - Long productId = 2L; - + void like_whenValidValues() { // act - boolean result = productLikeService.toggleLike(userId, productId); + productLikeService.like(1L, 2L); // assert - assertThat(result).isTrue(); - assertThat(productLikeRepository.findByUserIdAndProductId(userId, productId)).isPresent(); + assertThat(productLikeRepository.findByUserIdAndProductId(1L, 2L)).isPresent(); } + } - @DisplayName("좋아요가 이미 존재하면, 좋아요를 삭제하고 false를 반환한다.") + @DisplayName("좋아요를 취소할 때, ") + @Nested + class Unlike { + + @DisplayName("존재하는 좋아요가 주어지면, 좋아요가 삭제된다.") @Test - void toggleLike_whenAlreadyExists() { + void unlike_whenExists() { // arrange - Long userId = 1L; - Long productId = 2L; - productLikeRepository.save(ProductLikeModel.create(userId, productId)); + productLikeRepository.save(ProductLikeModel.create(1L, 2L)); // act - boolean result = productLikeService.toggleLike(userId, productId); + productLikeService.unlike(1L, 2L); // assert - assertThat(result).isFalse(); - assertThat(productLikeRepository.findByUserIdAndProductId(userId, productId)).isEmpty(); + assertThat(productLikeRepository.findByUserIdAndProductId(1L, 2L)).isEmpty(); + } + } + + @DisplayName("좋아요 존재 여부를 확인할 때, ") + @Nested + class ExistsByUserIdAndProductId { + + @DisplayName("좋아요가 존재하면, true를 반환한다.") + @Test + void exists_whenLikeExists() { + // arrange + productLikeRepository.save(ProductLikeModel.create(1L, 2L)); + + // act & assert + assertThat(productLikeService.existsByUserIdAndProductId(1L, 2L)).isTrue(); + } + + @DisplayName("좋아요가 없으면, false를 반환한다.") + @Test + void exists_whenLikeNotExists() { + // act & assert + assertThat(productLikeService.existsByUserIdAndProductId(1L, 2L)).isFalse(); } } @@ -66,7 +85,7 @@ void getLikesByUserId_whenLikesExist() { Long userId = 1L; productLikeRepository.save(ProductLikeModel.create(userId, 10L)); productLikeRepository.save(ProductLikeModel.create(userId, 20L)); - productLikeRepository.save(ProductLikeModel.create(2L, 30L)); // 다른 사용자 + productLikeRepository.save(ProductLikeModel.create(2L, 30L)); // act List result = productLikeService.getLikesByUserId(userId); From cefde3c5174f78e6b1c21068de4fa279c581033f Mon Sep 17 00:00:00 2001 From: plan11plan Date: Tue, 24 Feb 2026 00:27:51 +0900 Subject: [PATCH 051/108] =?UTF-8?q?fix:=20UserRepository=20soft=20delete?= =?UTF-8?q?=20=ED=95=84=ED=84=B0=EB=A7=81=20=EB=88=84=EB=9D=BD=20=EB=B2=84?= =?UTF-8?q?=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../com/loopers/infrastructure/user/UserJpaRepository.java | 1 + .../com/loopers/infrastructure/user/UserRepositoryImpl.java | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) 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 c8c354687..af502b12f 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 @@ -7,4 +7,5 @@ public interface UserJpaRepository extends JpaRepository { Optional findByLoginId(LoginId loginId); + Optional findByLoginIdAndDeletedAtIsNull(LoginId loginId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java index 7c1c88e68..dd5d53d86 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 @@ -5,10 +5,10 @@ import com.loopers.domain.user.UserRepository; import java.util.Optional; import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; +import org.springframework.stereotype.Repository; @RequiredArgsConstructor -@Component +@Repository public class UserRepositoryImpl implements UserRepository { private final UserJpaRepository userJpaRepository; @@ -19,6 +19,6 @@ public UserModel save(UserModel userModel) { @Override public Optional find(LoginId loginId) { - return userJpaRepository.findByLoginId(loginId); + return userJpaRepository.findByLoginIdAndDeletedAtIsNull(loginId); } } From 9185b91dbc50c707b758dc633b868940c4ceba11 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Tue, 24 Feb 2026 00:27:56 +0900 Subject: [PATCH 052/108] =?UTF-8?q?feat:=20=EC=83=81=ED=92=88=20=EC=A1=B4?= =?UTF-8?q?=EC=9E=AC=20=ED=99=95=EC=9D=B8=20=EB=A9=94=EC=84=9C=EB=93=9C=20?= =?UTF-8?q?=EB=B0=8F=20NOT=5FFOUND=5FDATA=20=EC=97=90=EB=9F=AC=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../main/java/com/loopers/domain/product/ProductService.java | 5 +++++ .../src/main/java/com/loopers/support/error/ErrorType.java | 1 + 2 files changed, 6 insertions(+) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java index 4851c06fb..11a6bbb42 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -54,6 +54,11 @@ public void softDeleteByBrandId(Long brandId) { products.forEach(ProductModel::delete); } + @Transactional(readOnly = true) + public boolean existsById(Long id) { + return productRepository.findById(id).isPresent(); + } + @Transactional(readOnly = true) public Page getAllActive(Pageable pageable) { return productRepository.findAllActive(pageable); diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java index 4600f7435..0f9f3657b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java @@ -12,6 +12,7 @@ public enum ErrorType implements ErrorCode { BAD_REQUEST(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "잘못된 요청입니다."), UNAUTHORIZED(HttpStatus.UNAUTHORIZED, HttpStatus.UNAUTHORIZED.getReasonPhrase(), "인증에 실패했습니다."), NOT_FOUND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "존재하지 않는 요청입니다."), + NOT_FOUND_DATA(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "해당 데이터를 찾을 수 없습니다."), CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 리소스입니다."); private final HttpStatus status; From 0ab85d79b3c850ee7a88eda86eb25cf3671b1fae Mon Sep 17 00:00:00 2001 From: plan11plan Date: Tue, 24 Feb 2026 01:00:03 +0900 Subject: [PATCH 053/108] =?UTF-8?q?refactor:=20DTO=20=EC=BB=A8=EB=B2=A4?= =?UTF-8?q?=EC=85=98=20=EB=84=A4=EC=9D=B4=EB=B0=8D=20=EC=A0=81=EC=9A=A9=20?= =?UTF-8?q?(Command=E2=86=92domain,=20Info=E2=86=92Result,=20Criteria=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../application/brand/BrandFacade.java | 20 +++++------ .../application/brand/dto/BrandCriteria.java | 18 ++++++++++ .../application/brand/dto/BrandInfo.java | 10 ------ .../application/brand/dto/BrandResult.java | 10 ++++++ .../loopers/application/like/LikeFacade.java | 18 +++++----- .../application/like/dto/LikeCriteria.java | 12 +++++++ .../application/like/dto/LikeInfo.java | 17 ---------- .../application/like/dto/LikeResult.java | 17 ++++++++++ .../application/product/ProductFacade.java | 34 +++++++++---------- .../product/dto/ProductCriteria.java | 18 ++++++++++ .../{ProductInfo.java => ProductResult.java} | 6 ++-- .../loopers/application/user/UserFacade.java | 18 +++++----- .../application/user/dto/UserCriteria.java | 18 ++++++++++ .../dto/{UserInfo.java => UserResult.java} | 6 ++-- .../brand/dto/BrandCommand.java | 2 +- .../like/dto/LikeCommand.java | 2 +- .../product/dto/ProductCommand.java | 2 +- .../user/dto/UserCommand.java | 2 +- .../brand/AdminBrandV1Controller.java | 10 +++--- .../interfaces/brand/BrandV1Controller.java | 4 +-- .../interfaces/brand/dto/AdminBrandV1Dto.java | 16 ++++----- .../interfaces/brand/dto/BrandV1Dto.java | 4 +-- .../product/AdminProductV1Controller.java | 10 +++--- .../product/ProductV1Controller.java | 6 ++-- .../product/dto/AdminProductV1Dto.java | 16 ++++----- .../interfaces/product/dto/ProductV1Dto.java | 6 ++-- .../interfaces/user/UserV1Controller.java | 8 ++--- .../interfaces/user/dto/UserV1Dto.java | 16 ++++----- 28 files changed, 196 insertions(+), 130 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/brand/dto/BrandCriteria.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/brand/dto/BrandInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/brand/dto/BrandResult.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/dto/LikeCriteria.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/dto/LikeInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/dto/LikeResult.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/dto/ProductCriteria.java rename apps/commerce-api/src/main/java/com/loopers/application/product/dto/{ProductInfo.java => ProductResult.java} (85%) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/user/dto/UserCriteria.java rename apps/commerce-api/src/main/java/com/loopers/application/user/dto/{UserInfo.java => UserResult.java} (78%) rename apps/commerce-api/src/main/java/com/loopers/{application => domain}/brand/dto/BrandCommand.java (74%) rename apps/commerce-api/src/main/java/com/loopers/{application => domain}/like/dto/LikeCommand.java (82%) rename apps/commerce-api/src/main/java/com/loopers/{application => domain}/product/dto/ProductCommand.java (79%) rename apps/commerce-api/src/main/java/com/loopers/{application => domain}/user/dto/UserCommand.java (87%) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java index 4e50f562c..08f2aa808 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -1,7 +1,7 @@ package com.loopers.application.brand; -import com.loopers.application.brand.dto.BrandCommand; -import com.loopers.application.brand.dto.BrandInfo; +import com.loopers.application.brand.dto.BrandCriteria; +import com.loopers.application.brand.dto.BrandResult; import com.loopers.domain.brand.BrandModel; import com.loopers.domain.brand.BrandService; import com.loopers.domain.product.ProductService; @@ -19,19 +19,19 @@ public class BrandFacade { private final ProductService productService; @Transactional - public void registerBrand(BrandCommand.Register command) { - brandService.register(command.name()); + public void registerBrand(BrandCriteria.Register criteria) { + brandService.register(criteria.name()); } @Transactional(readOnly = true) - public BrandInfo getBrand(Long id) { + public BrandResult getBrand(Long id) { BrandModel brandModel = brandService.getById(id); - return BrandInfo.from(brandModel); + return BrandResult.from(brandModel); } @Transactional - public void updateBrand(Long id, BrandCommand.Update command) { - brandService.update(id, command.name()); + public void updateBrand(Long id, BrandCriteria.Update criteria) { + brandService.update(id, criteria.name()); } @Transactional @@ -41,7 +41,7 @@ public void deleteBrand(Long id) { } @Transactional(readOnly = true) - public Page getBrands(Pageable pageable) { - return brandService.getAll(pageable).map(BrandInfo::from); + public Page getBrands(Pageable pageable) { + return brandService.getAll(pageable).map(BrandResult::from); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/dto/BrandCriteria.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/dto/BrandCriteria.java new file mode 100644 index 000000000..f71745650 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/dto/BrandCriteria.java @@ -0,0 +1,18 @@ +package com.loopers.application.brand.dto; + +import com.loopers.domain.brand.dto.BrandCommand; + +public class BrandCriteria { + + public record Register(String name) { + public BrandCommand.Register toCommand() { + return new BrandCommand.Register(name); + } + } + + public record Update(String name) { + public BrandCommand.Update toCommand() { + return new BrandCommand.Update(name); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/dto/BrandInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/dto/BrandInfo.java deleted file mode 100644 index d2833a1d6..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/dto/BrandInfo.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.loopers.application.brand.dto; - -import com.loopers.domain.brand.BrandModel; -import java.time.ZonedDateTime; - -public record BrandInfo(Long id, String name, ZonedDateTime createdAt, ZonedDateTime updatedAt, ZonedDateTime deletedAt) { - public static BrandInfo from(BrandModel model) { - return new BrandInfo(model.getId(), model.getName(), model.getCreatedAt(), model.getUpdatedAt(), model.getDeletedAt()); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/dto/BrandResult.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/dto/BrandResult.java new file mode 100644 index 000000000..fc6540cef --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/dto/BrandResult.java @@ -0,0 +1,10 @@ +package com.loopers.application.brand.dto; + +import com.loopers.domain.brand.BrandModel; +import java.time.ZonedDateTime; + +public record BrandResult(Long id, String name, ZonedDateTime createdAt, ZonedDateTime updatedAt, ZonedDateTime deletedAt) { + public static BrandResult from(BrandModel model) { + return new BrandResult(model.getId(), model.getName(), model.getCreatedAt(), model.getUpdatedAt(), model.getDeletedAt()); + } +} 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 7d3147d19..41515dcff 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,7 +1,7 @@ package com.loopers.application.like; -import com.loopers.application.like.dto.LikeCommand.Toggle; -import com.loopers.application.like.dto.LikeInfo; +import com.loopers.application.like.dto.LikeCriteria; +import com.loopers.application.like.dto.LikeResult; import com.loopers.domain.like.ProductLikeService; import com.loopers.domain.product.ProductModel; import com.loopers.domain.product.ProductService; @@ -17,26 +17,26 @@ public class LikeFacade { private final ProductService productService; @Transactional - public void toggleLike(Toggle command) { - ProductModel product = productService.getById(command.productId()); + public void toggleLike(LikeCriteria.Toggle criteria) { + ProductModel product = productService.getById(criteria.productId()); - switch (command.type()) { + switch (criteria.type()) { case LIKE -> { - productLikeService.like(command.userId(), command.productId()); + productLikeService.like(criteria.userId(), criteria.productId()); product.addLikeCount(); } case UNLIKE -> { - productLikeService.unlike(command.userId(), command.productId()); + productLikeService.unlike(criteria.userId(), criteria.productId()); product.subtractLikeCount(); } } } @Transactional(readOnly = true) - public List getMyLikedProducts(Long userId) { + public List getMyLikedProducts(Long userId) { return productLikeService.getLikesByUserId(userId).stream() .filter(like -> productService.existsById(like.getProductId())) - .map(LikeInfo::from) + .map(LikeResult::from) .toList(); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/dto/LikeCriteria.java b/apps/commerce-api/src/main/java/com/loopers/application/like/dto/LikeCriteria.java new file mode 100644 index 000000000..6edfb4b3a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/dto/LikeCriteria.java @@ -0,0 +1,12 @@ +package com.loopers.application.like.dto; + +import com.loopers.domain.like.dto.LikeCommand; + +public class LikeCriteria { + + public record Toggle(LikeCommand.ApplyLikeRequestType type, Long userId, Long productId) { + public LikeCommand.Toggle toCommand() { + return new LikeCommand.Toggle(type, userId, productId); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/dto/LikeInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/like/dto/LikeInfo.java deleted file mode 100644 index 7eb365690..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/dto/LikeInfo.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.loopers.application.like.dto; - -import com.loopers.domain.like.ProductLikeModel; -import java.time.ZonedDateTime; -import java.util.List; - -public record LikeInfo(Long id, Long userId, Long productId, ZonedDateTime createdAt) { - public static LikeInfo from(ProductLikeModel model) { - return new LikeInfo(model.getId(), model.getUserId(), model.getProductId(), model.getCreatedAt()); - } - public static List from(List models) { - return models.stream() - .map(model -> - new LikeInfo(model.getId(), model.getUserId(), model.getProductId(), model.getCreatedAt())) - .toList(); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/dto/LikeResult.java b/apps/commerce-api/src/main/java/com/loopers/application/like/dto/LikeResult.java new file mode 100644 index 000000000..985e8c644 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/dto/LikeResult.java @@ -0,0 +1,17 @@ +package com.loopers.application.like.dto; + +import com.loopers.domain.like.ProductLikeModel; +import java.time.ZonedDateTime; +import java.util.List; + +public record LikeResult(Long id, Long userId, Long productId, ZonedDateTime createdAt) { + public static LikeResult from(ProductLikeModel model) { + return new LikeResult(model.getId(), model.getUserId(), model.getProductId(), model.getCreatedAt()); + } + public static List from(List models) { + return models.stream() + .map(model -> + new LikeResult(model.getId(), model.getUserId(), model.getProductId(), model.getCreatedAt())) + .toList(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index 7a8b1ec45..1bee1b84e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -1,7 +1,7 @@ package com.loopers.application.product; -import com.loopers.application.product.dto.ProductCommand; -import com.loopers.application.product.dto.ProductInfo; +import com.loopers.application.product.dto.ProductCriteria; +import com.loopers.application.product.dto.ProductResult; import com.loopers.domain.brand.BrandModel; import com.loopers.domain.brand.BrandService; import com.loopers.domain.product.ProductModel; @@ -20,20 +20,20 @@ public class ProductFacade { private final BrandService brandService; @Transactional - public void registerProduct(ProductCommand.Register command) { - BrandModel brand = brandService.getById(command.brandId()); - productService.register(brand, command.name(), command.price(), command.stock()); + public void registerProduct(ProductCriteria.Register criteria) { + BrandModel brand = brandService.getById(criteria.brandId()); + productService.register(brand, criteria.name(), criteria.price(), criteria.stock()); } @Transactional(readOnly = true) - public ProductInfo getProduct(Long id) { + public ProductResult getProduct(Long id) { ProductModel productModel = productService.getById(id); - return ProductInfo.from(productModel); + return ProductResult.from(productModel); } @Transactional - public void updateProduct(Long id, ProductCommand.Update command) { - productService.update(id, command.name(), command.price(), command.stock()); + public void updateProduct(Long id, ProductCriteria.Update criteria) { + productService.update(id, criteria.name(), criteria.price(), criteria.stock()); } @Transactional @@ -42,22 +42,22 @@ public void deleteProduct(Long id) { } @Transactional(readOnly = true) - public Page getProducts(Pageable pageable) { - return productService.getAll(pageable).map(ProductInfo::from); + public Page getProducts(Pageable pageable) { + return productService.getAll(pageable).map(ProductResult::from); } @Transactional(readOnly = true) - public Page getProductsByBrandId(Long brandId, Pageable pageable) { - return productService.getAllByBrandId(brandId, pageable).map(ProductInfo::from); + public Page getProductsByBrandId(Long brandId, Pageable pageable) { + return productService.getAllByBrandId(brandId, pageable).map(ProductResult::from); } @Transactional(readOnly = true) - public Page getActiveProducts(Pageable pageable) { - return productService.getAllActive(pageable).map(ProductInfo::from); + public Page getActiveProducts(Pageable pageable) { + return productService.getAllActive(pageable).map(ProductResult::from); } @Transactional(readOnly = true) - public Page getActiveProductsByBrandId(Long brandId, Pageable pageable) { - return productService.getAllActiveByBrandId(brandId, pageable).map(ProductInfo::from); + public Page getActiveProductsByBrandId(Long brandId, Pageable pageable) { + return productService.getAllActiveByBrandId(brandId, pageable).map(ProductResult::from); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/dto/ProductCriteria.java b/apps/commerce-api/src/main/java/com/loopers/application/product/dto/ProductCriteria.java new file mode 100644 index 000000000..d3e25d176 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/dto/ProductCriteria.java @@ -0,0 +1,18 @@ +package com.loopers.application.product.dto; + +import com.loopers.domain.product.dto.ProductCommand; + +public class ProductCriteria { + + public record Register(Long brandId, String name, int price, int stock) { + public ProductCommand.Register toCommand() { + return new ProductCommand.Register(brandId, name, price, stock); + } + } + + public record Update(String name, int price, int stock) { + public ProductCommand.Update toCommand() { + return new ProductCommand.Update(name, price, stock); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/dto/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/dto/ProductResult.java similarity index 85% rename from apps/commerce-api/src/main/java/com/loopers/application/product/dto/ProductInfo.java rename to apps/commerce-api/src/main/java/com/loopers/application/product/dto/ProductResult.java index 74adb8dc1..5aef724d1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/dto/ProductInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/dto/ProductResult.java @@ -3,7 +3,7 @@ import com.loopers.domain.product.ProductModel; import java.time.ZonedDateTime; -public record ProductInfo( +public record ProductResult( Long id, Long brandId, String brandName, @@ -15,8 +15,8 @@ public record ProductInfo( ZonedDateTime updatedAt, ZonedDateTime deletedAt ) { - public static ProductInfo from(ProductModel model) { - return new ProductInfo( + public static ProductResult from(ProductModel model) { + return new ProductResult( model.getId(), model.getBrand().getId(), model.getBrand().getName(), diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java index b2cbfea1f..01f8990bd 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java @@ -1,7 +1,7 @@ package com.loopers.application.user; -import com.loopers.application.user.dto.UserCommand; -import com.loopers.application.user.dto.UserInfo; +import com.loopers.application.user.dto.UserCriteria; +import com.loopers.application.user.dto.UserResult; import com.loopers.domain.user.UserModel; import com.loopers.domain.user.UserService; import lombok.RequiredArgsConstructor; @@ -15,21 +15,21 @@ public class UserFacade { private final UserService userService; @Transactional - public UserInfo signup(UserCommand.Signup command) { + public UserResult signup(UserCriteria.Signup criteria) { UserModel userModel = userService.signup( - command.loginId(), command.rawPassword(), command.name(), command.birthDate(), command.email() + criteria.loginId(), criteria.rawPassword(), criteria.name(), criteria.birthDate(), criteria.email() ); - return UserInfo.from(userModel); + return UserResult.from(userModel); } @Transactional(readOnly = true) - public UserInfo getMyInfo(String loginId) { + public UserResult getMyInfo(String loginId) { UserModel user = userService.getByLoginId(loginId); - return UserInfo.from(user); + return UserResult.from(user); } @Transactional - public void changePassword(String loginId, UserCommand.ChangePassword command) { - userService.changePassword(loginId, command.rawCurrentPassword(), command.rawNewPassword()); + public void changePassword(String loginId, UserCriteria.ChangePassword criteria) { + userService.changePassword(loginId, criteria.rawCurrentPassword(), criteria.rawNewPassword()); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/dto/UserCriteria.java b/apps/commerce-api/src/main/java/com/loopers/application/user/dto/UserCriteria.java new file mode 100644 index 000000000..f72973c20 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/dto/UserCriteria.java @@ -0,0 +1,18 @@ +package com.loopers.application.user.dto; + +import com.loopers.domain.user.dto.UserCommand; + +public class UserCriteria { + + public record Signup(String loginId, String rawPassword, String name, String birthDate, String email) { + public UserCommand.Signup toCommand() { + return new UserCommand.Signup(loginId, rawPassword, name, birthDate, email); + } + } + + public record ChangePassword(String rawCurrentPassword, String rawNewPassword) { + public UserCommand.ChangePassword toCommand() { + return new UserCommand.ChangePassword(rawCurrentPassword, rawNewPassword); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/dto/UserInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/user/dto/UserResult.java similarity index 78% rename from apps/commerce-api/src/main/java/com/loopers/application/user/dto/UserInfo.java rename to apps/commerce-api/src/main/java/com/loopers/application/user/dto/UserResult.java index c53d9d60e..b95f2fdf5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/dto/UserInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/dto/UserResult.java @@ -2,15 +2,15 @@ import com.loopers.domain.user.UserModel; -public record UserInfo( +public record UserResult( Long id, String loginId, String name, String birthDate, String email ) { - public static UserInfo from(UserModel model) { - return new UserInfo( + public static UserResult from(UserModel model) { + return new UserResult( model.getId(), model.getLoginId().getValue(), model.getName().getValue(), diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/dto/BrandCommand.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/dto/BrandCommand.java similarity index 74% rename from apps/commerce-api/src/main/java/com/loopers/application/brand/dto/BrandCommand.java rename to apps/commerce-api/src/main/java/com/loopers/domain/brand/dto/BrandCommand.java index 646065f08..b10a348d2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/dto/BrandCommand.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/dto/BrandCommand.java @@ -1,4 +1,4 @@ -package com.loopers.application.brand.dto; +package com.loopers.domain.brand.dto; public class BrandCommand { diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/dto/LikeCommand.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/dto/LikeCommand.java similarity index 82% rename from apps/commerce-api/src/main/java/com/loopers/application/like/dto/LikeCommand.java rename to apps/commerce-api/src/main/java/com/loopers/domain/like/dto/LikeCommand.java index b182d20e7..ceedb565d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/dto/LikeCommand.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/dto/LikeCommand.java @@ -1,4 +1,4 @@ -package com.loopers.application.like.dto; +package com.loopers.domain.like.dto; public class LikeCommand { diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/dto/ProductCommand.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/dto/ProductCommand.java similarity index 79% rename from apps/commerce-api/src/main/java/com/loopers/application/product/dto/ProductCommand.java rename to apps/commerce-api/src/main/java/com/loopers/domain/product/dto/ProductCommand.java index 84575bfcd..aba72737e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/dto/ProductCommand.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/dto/ProductCommand.java @@ -1,4 +1,4 @@ -package com.loopers.application.product.dto; +package com.loopers.domain.product.dto; public class ProductCommand { diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/dto/UserCommand.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/dto/UserCommand.java similarity index 87% rename from apps/commerce-api/src/main/java/com/loopers/application/user/dto/UserCommand.java rename to apps/commerce-api/src/main/java/com/loopers/domain/user/dto/UserCommand.java index f4f6a0302..17f49e842 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/dto/UserCommand.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/dto/UserCommand.java @@ -1,4 +1,4 @@ -package com.loopers.application.user.dto; +package com.loopers.domain.user.dto; public class UserCommand { diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/AdminBrandV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/AdminBrandV1Controller.java index 8668a99de..bb040f0f2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/AdminBrandV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/AdminBrandV1Controller.java @@ -1,7 +1,7 @@ package com.loopers.interfaces.brand; import com.loopers.application.brand.BrandFacade; -import com.loopers.application.brand.dto.BrandInfo; +import com.loopers.application.brand.dto.BrandResult; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.brand.dto.AdminBrandV1Dto; import jakarta.validation.Valid; @@ -22,7 +22,7 @@ public class AdminBrandV1Controller implements AdminBrandV1ApiSpec { public ApiResponse register( @Valid @RequestBody AdminBrandV1Dto.RegisterRequest request ) { - brandFacade.registerBrand(request.toCommand()); + brandFacade.registerBrand(request.toCriteria()); return ApiResponse.success(); } @@ -32,7 +32,7 @@ public ApiResponse list( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size ) { - Page brandInfoPage = brandFacade.getBrands(PageRequest.of(page, size)); + Page brandInfoPage = brandFacade.getBrands(PageRequest.of(page, size)); AdminBrandV1Dto.ListResponse listResponse = new AdminBrandV1Dto.ListResponse( brandInfoPage.getNumber(), brandInfoPage.getSize(), @@ -50,7 +50,7 @@ public ApiResponse list( public ApiResponse getById( @PathVariable Long brandId ) { - BrandInfo brandInfo = brandFacade.getBrand(brandId); + BrandResult brandInfo = brandFacade.getBrand(brandId); return ApiResponse.success(AdminBrandV1Dto.DetailResponse.from(brandInfo)); } @@ -60,7 +60,7 @@ public ApiResponse update( @PathVariable Long brandId, @Valid @RequestBody AdminBrandV1Dto.UpdateRequest request ) { - brandFacade.updateBrand(brandId, request.toCommand()); + brandFacade.updateBrand(brandId, request.toCriteria()); return ApiResponse.success(); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/BrandV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/BrandV1Controller.java index 5aefdc719..76a8d051c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/BrandV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/BrandV1Controller.java @@ -1,7 +1,7 @@ package com.loopers.interfaces.brand; import com.loopers.application.brand.BrandFacade; -import com.loopers.application.brand.dto.BrandInfo; +import com.loopers.application.brand.dto.BrandResult; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.brand.dto.BrandV1Dto; import lombok.RequiredArgsConstructor; @@ -19,7 +19,7 @@ public class BrandV1Controller implements BrandV1ApiSpec { public ApiResponse getById( @PathVariable Long brandId ) { - BrandInfo brandInfo = brandFacade.getBrand(brandId); + BrandResult brandInfo = brandFacade.getBrand(brandId); return ApiResponse.success(BrandV1Dto.DetailResponse.from(brandInfo)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/dto/AdminBrandV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/dto/AdminBrandV1Dto.java index b6da78b2c..b400827b1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/dto/AdminBrandV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/dto/AdminBrandV1Dto.java @@ -1,7 +1,7 @@ package com.loopers.interfaces.brand.dto; -import com.loopers.application.brand.dto.BrandCommand; -import com.loopers.application.brand.dto.BrandInfo; +import com.loopers.application.brand.dto.BrandCriteria; +import com.loopers.application.brand.dto.BrandResult; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; import java.time.ZonedDateTime; @@ -14,8 +14,8 @@ public record RegisterRequest( @Size(max = 99, message = "브랜드명은 99자 이하여야 합니다.") String name ) { - public BrandCommand.Register toCommand() { - return new BrandCommand.Register(name); + public BrandCriteria.Register toCriteria() { + return new BrandCriteria.Register(name); } } @@ -24,8 +24,8 @@ public record UpdateRequest( @Size(max = 99, message = "브랜드명은 99자 이하여야 합니다.") String name ) { - public BrandCommand.Update toCommand() { - return new BrandCommand.Update(name); + public BrandCriteria.Update toCriteria() { + return new BrandCriteria.Update(name); } } @@ -36,7 +36,7 @@ public record DetailResponse( ZonedDateTime updatedAt, ZonedDateTime deletedAt ) { - public static DetailResponse from(BrandInfo info) { + public static DetailResponse from(BrandResult info) { return new DetailResponse(info.id(), info.name(), info.createdAt(), info.updatedAt(), info.deletedAt()); } } @@ -55,7 +55,7 @@ public record ListItem( ZonedDateTime updatedAt, ZonedDateTime deletedAt ) { - public static ListItem from(BrandInfo info) { + public static ListItem from(BrandResult info) { return new ListItem(info.id(), info.name(), info.createdAt(), info.updatedAt(), info.deletedAt()); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/dto/BrandV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/dto/BrandV1Dto.java index bfbc3aa7b..70947943f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/dto/BrandV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/dto/BrandV1Dto.java @@ -1,6 +1,6 @@ package com.loopers.interfaces.brand.dto; -import com.loopers.application.brand.dto.BrandInfo; +import com.loopers.application.brand.dto.BrandResult; public class BrandV1Dto { @@ -8,7 +8,7 @@ public record DetailResponse( Long id, String name ) { - public static DetailResponse from(BrandInfo info) { + public static DetailResponse from(BrandResult info) { return new DetailResponse(info.id(), info.name()); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/product/AdminProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/AdminProductV1Controller.java index e991bb6f4..fcb332be1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/product/AdminProductV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/AdminProductV1Controller.java @@ -1,7 +1,7 @@ package com.loopers.interfaces.product; import com.loopers.application.product.ProductFacade; -import com.loopers.application.product.dto.ProductInfo; +import com.loopers.application.product.dto.ProductResult; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.product.dto.AdminProductV1Dto; import jakarta.validation.Valid; @@ -22,7 +22,7 @@ public class AdminProductV1Controller implements AdminProductV1ApiSpec { public ApiResponse register( @Valid @RequestBody AdminProductV1Dto.RegisterRequest request ) { - productFacade.registerProduct(request.toCommand()); + productFacade.registerProduct(request.toCriteria()); return ApiResponse.success(); } @@ -33,7 +33,7 @@ public ApiResponse list( @RequestParam(defaultValue = "20") int size, @RequestParam(required = false) Long brandId ) { - Page productInfoPage = brandId != null + Page productInfoPage = brandId != null ? productFacade.getProductsByBrandId(brandId, PageRequest.of(page, size)) : productFacade.getProducts(PageRequest.of(page, size)); @@ -54,7 +54,7 @@ public ApiResponse list( public ApiResponse getById( @PathVariable Long productId ) { - ProductInfo productInfo = productFacade.getProduct(productId); + ProductResult productInfo = productFacade.getProduct(productId); return ApiResponse.success(AdminProductV1Dto.DetailResponse.from(productInfo)); } @@ -64,7 +64,7 @@ public ApiResponse update( @PathVariable Long productId, @Valid @RequestBody AdminProductV1Dto.UpdateRequest request ) { - productFacade.updateProduct(productId, request.toCommand()); + productFacade.updateProduct(productId, request.toCriteria()); return ApiResponse.success(); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1Controller.java index 3e03d14d5..acd018b61 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1Controller.java @@ -1,7 +1,7 @@ package com.loopers.interfaces.product; import com.loopers.application.product.ProductFacade; -import com.loopers.application.product.dto.ProductInfo; +import com.loopers.application.product.dto.ProductResult; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.product.dto.ProductV1Dto; import lombok.RequiredArgsConstructor; @@ -26,7 +26,7 @@ public ApiResponse list( @RequestParam(defaultValue = "20") int size ) { PageRequest pageRequest = PageRequest.of(page, size, toSort(sort)); - Page productInfoPage = brandId != null + Page productInfoPage = brandId != null ? productFacade.getActiveProductsByBrandId(brandId, pageRequest) : productFacade.getActiveProducts(pageRequest); @@ -47,7 +47,7 @@ public ApiResponse list( public ApiResponse getById( @PathVariable Long productId ) { - ProductInfo productInfo = productFacade.getProduct(productId); + ProductResult productInfo = productFacade.getProduct(productId); return ApiResponse.success(ProductV1Dto.DetailResponse.from(productInfo)); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/product/dto/AdminProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/dto/AdminProductV1Dto.java index 0565bcace..443b14d6b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/product/dto/AdminProductV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/dto/AdminProductV1Dto.java @@ -1,7 +1,7 @@ package com.loopers.interfaces.product.dto; -import com.loopers.application.product.dto.ProductCommand; -import com.loopers.application.product.dto.ProductInfo; +import com.loopers.application.product.dto.ProductCriteria; +import com.loopers.application.product.dto.ProductResult; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; @@ -24,8 +24,8 @@ public record RegisterRequest( @Min(value = 0, message = "재고는 0 이상이어야 합니다.") Integer stock ) { - public ProductCommand.Register toCommand() { - return new ProductCommand.Register(brandId, name, price, stock); + public ProductCriteria.Register toCriteria() { + return new ProductCriteria.Register(brandId, name, price, stock); } } @@ -40,8 +40,8 @@ public record UpdateRequest( @Min(value = 0, message = "재고는 0 이상이어야 합니다.") Integer stock ) { - public ProductCommand.Update toCommand() { - return new ProductCommand.Update(name, price, stock); + public ProductCriteria.Update toCriteria() { + return new ProductCriteria.Update(name, price, stock); } } @@ -57,7 +57,7 @@ public record DetailResponse( ZonedDateTime updatedAt, ZonedDateTime deletedAt ) { - public static DetailResponse from(ProductInfo info) { + public static DetailResponse from(ProductResult info) { return new DetailResponse( info.id(), info.brandId(), info.brandName(), info.name(), info.price(), info.stock(), info.likeCount(), @@ -85,7 +85,7 @@ public record ListItem( ZonedDateTime updatedAt, ZonedDateTime deletedAt ) { - public static ListItem from(ProductInfo info) { + public static ListItem from(ProductResult info) { return new ListItem( info.id(), info.brandId(), info.brandName(), info.name(), info.price(), info.stock(), info.likeCount(), diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/product/dto/ProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/dto/ProductV1Dto.java index ce051f934..2f7a067e3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/product/dto/ProductV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/dto/ProductV1Dto.java @@ -1,6 +1,6 @@ package com.loopers.interfaces.product.dto; -import com.loopers.application.product.dto.ProductInfo; +import com.loopers.application.product.dto.ProductResult; import java.util.List; public class ProductV1Dto { @@ -14,7 +14,7 @@ public record DetailResponse( int stock, int likeCount ) { - public static DetailResponse from(ProductInfo info) { + public static DetailResponse from(ProductResult info) { return new DetailResponse( info.id(), info.brandId(), info.brandName(), info.name(), info.price(), info.stock(), info.likeCount() @@ -37,7 +37,7 @@ public record ListItem( int price, int likeCount ) { - public static ListItem from(ProductInfo info) { + public static ListItem from(ProductResult info) { return new ListItem( info.id(), info.brandId(), info.brandName(), info.name(), info.price(), info.likeCount() diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/user/UserV1Controller.java index b5b96386a..b84bbe192 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/user/UserV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/user/UserV1Controller.java @@ -1,7 +1,7 @@ package com.loopers.interfaces.user; import com.loopers.application.user.UserFacade; -import com.loopers.application.user.dto.UserInfo; +import com.loopers.application.user.dto.UserResult; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.auth.Login; import com.loopers.interfaces.auth.LoginUser; @@ -22,7 +22,7 @@ public class UserV1Controller implements UserV1ApiSpec { public ApiResponse signup( @Valid @RequestBody UserV1Dto.SignupRequest request ) { - UserInfo userInfo = userFacade.signup(request.toCommand()); + UserResult userInfo = userFacade.signup(request.toCriteria()); return ApiResponse.success(UserV1Dto.SignupResponse.from(userInfo)); } @@ -30,7 +30,7 @@ public ApiResponse signup( @GetMapping("/me") @Override public ApiResponse getMyInfo(@Login LoginUser loginUser) { - UserInfo userInfo = userFacade.getMyInfo(loginUser.loginId()); + UserResult userInfo = userFacade.getMyInfo(loginUser.loginId()); return ApiResponse.success(UserV1Dto.MyInfoResponse.from(userInfo)); } @@ -41,7 +41,7 @@ public ApiResponse changePassword( @Login LoginUser loginUser, @Valid @RequestBody UserV1Dto.ChangePasswordRequest request ) { - userFacade.changePassword(loginUser.loginId(), request.toCommand()); + userFacade.changePassword(loginUser.loginId(), request.toCriteria()); return ApiResponse.success(UserV1Dto.ChangePasswordResponse.success()); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/user/dto/UserV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/user/dto/UserV1Dto.java index a28f17661..38c3b5850 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/user/dto/UserV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/user/dto/UserV1Dto.java @@ -1,7 +1,7 @@ package com.loopers.interfaces.user.dto; -import com.loopers.application.user.dto.UserCommand; -import com.loopers.application.user.dto.UserInfo; +import com.loopers.application.user.dto.UserCriteria; +import com.loopers.application.user.dto.UserResult; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; @@ -34,8 +34,8 @@ public record SignupRequest( @Email(message = "이메일 형식이 올바르지 않습니다.") String email ) { - public UserCommand.Signup toCommand() { - return new UserCommand.Signup(loginId, password, name, birthDate, email); + public UserCriteria.Signup toCriteria() { + return new UserCriteria.Signup(loginId, password, name, birthDate, email); } } @@ -46,7 +46,7 @@ public record SignupResponse( String birthDate, String email ) { - public static SignupResponse from(UserInfo info) { + public static SignupResponse from(UserResult info) { return new SignupResponse( info.id(), info.loginId(), @@ -63,7 +63,7 @@ public record MyInfoResponse( String birthDate, String email ) { - public static MyInfoResponse from(UserInfo info) { + public static MyInfoResponse from(UserResult info) { return new MyInfoResponse( info.loginId(), maskName(info.name()), @@ -84,8 +84,8 @@ public record ChangePasswordRequest( ) String newPassword ) { - public UserCommand.ChangePassword toCommand() { - return new UserCommand.ChangePassword(currentPassword, newPassword); + public UserCriteria.ChangePassword toCriteria() { + return new UserCriteria.ChangePassword(currentPassword, newPassword); } } From 23f20a1bfd61ff39164a7d4c4952d1e5a72461c5 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Tue, 24 Feb 2026 01:01:29 +0900 Subject: [PATCH 054/108] =?UTF-8?q?test:=20Facade=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20DTO=20=EC=BB=A8=EB=B2=A4=EC=85=98=20=EB=84=A4?= =?UTF-8?q?=EC=9D=B4=EB=B0=8D=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/brand/BrandFacadeTest.java | 16 ++++++------- .../application/like/LikeFacadeTest.java | 24 +++++++++---------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java index d8fed473e..be17b5d52 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java @@ -4,8 +4,8 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import com.loopers.application.brand.dto.BrandCommand; -import com.loopers.application.brand.dto.BrandInfo; +import com.loopers.application.brand.dto.BrandCriteria; +import com.loopers.application.brand.dto.BrandResult; import com.loopers.domain.brand.BrandModel; import com.loopers.domain.brand.BrandService; import com.loopers.domain.product.ProductService; @@ -38,10 +38,10 @@ class Register { @DisplayName("Command의 name을 BrandService.register에 전달한다") void register_호출_검증() { // arrange - BrandCommand.Register command = new BrandCommand.Register("나이키"); + BrandCriteria.Register criteria = new BrandCriteria.Register("나이키"); // act - brandFacade.registerBrand(command); + brandFacade.registerBrand(criteria); // assert verify(brandService).register("나이키"); @@ -53,14 +53,14 @@ class Register { class GetById { @Test - @DisplayName("BrandService.getById 결과를 BrandInfo로 변환하여 반환한다") + @DisplayName("BrandService.getById 결과를 BrandResult로 변환하여 반환한다") void getById_변환_검증() { // arrange BrandModel brandModel = BrandModel.create("나이키"); when(brandService.getById(1L)).thenReturn(brandModel); // act - BrandInfo result = brandFacade.getBrand(1L); + BrandResult result = brandFacade.getBrand(1L); // assert assertThat(result.name()).isEqualTo("나이키"); @@ -76,10 +76,10 @@ class Update { @DisplayName("id와 Command의 name을 BrandService.update에 전달한다") void update_호출_검증() { // arrange - BrandCommand.Update command = new BrandCommand.Update("아디다스"); + BrandCriteria.Update criteria = new BrandCriteria.Update("아디다스"); // act - brandFacade.updateBrand(1L, command); + brandFacade.updateBrand(1L, criteria); // assert verify(brandService).update(1L, "아디다스"); 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 7dc3f566b..d646ca32f 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,13 +1,13 @@ package com.loopers.application.like; -import static com.loopers.application.like.dto.LikeCommand.ApplyLikeRequestType.LIKE; -import static com.loopers.application.like.dto.LikeCommand.ApplyLikeRequestType.UNLIKE; +import static com.loopers.domain.like.dto.LikeCommand.ApplyLikeRequestType.LIKE; +import static com.loopers.domain.like.dto.LikeCommand.ApplyLikeRequestType.UNLIKE; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import com.loopers.application.like.dto.LikeCommand; -import com.loopers.application.like.dto.LikeInfo; +import com.loopers.application.like.dto.LikeCriteria; +import com.loopers.application.like.dto.LikeResult; import com.loopers.domain.brand.BrandModel; import com.loopers.domain.like.ProductLikeModel; import com.loopers.domain.like.ProductLikeService; @@ -43,14 +43,14 @@ class ToggleLike { @Test void toggleLike_whenLike_addsLikeAndIncreasesCount() { // arrange - LikeCommand.Toggle command = new LikeCommand.Toggle(LIKE, 1L, 2L); + LikeCriteria.Toggle criteria = new LikeCriteria.Toggle(LIKE, 1L, 2L); ProductModel product = ProductModel.create( BrandModel.create("Nike"), "에어맥스", 150000, 100 ); when(productService.getById(2L)).thenReturn(product); // act - likeFacade.toggleLike(command); + likeFacade.toggleLike(criteria); // assert verify(productService).getById(2L); @@ -62,7 +62,7 @@ void toggleLike_whenLike_addsLikeAndIncreasesCount() { @Test void toggleLike_whenUnlike_removesLikeAndDecreasesCount() { // arrange - LikeCommand.Toggle command = new LikeCommand.Toggle(UNLIKE, 1L, 2L); + LikeCriteria.Toggle criteria = new LikeCriteria.Toggle(UNLIKE, 1L, 2L); ProductModel product = ProductModel.create( BrandModel.create("Nike"), "에어맥스", 150000, 100 ); @@ -70,7 +70,7 @@ void toggleLike_whenUnlike_removesLikeAndDecreasesCount() { when(productService.getById(2L)).thenReturn(product); // act - likeFacade.toggleLike(command); + likeFacade.toggleLike(criteria); // assert verify(productService).getById(2L); @@ -83,9 +83,9 @@ void toggleLike_whenUnlike_removesLikeAndDecreasesCount() { @Nested class GetLikesByUserId { - @DisplayName("사용자의 좋아요 목록을 조회하면, LikeInfo 목록을 반환한다.") + @DisplayName("사용자의 좋아요 목록을 조회하면, LikeResult 목록을 반환한다.") @Test - void getLikesByUserId_returnsLikeInfoList() { + void getLikesByUserId_returnsLikeResultList() { // arrange Long userId = 1L; List likes = List.of( @@ -97,7 +97,7 @@ void getLikesByUserId_returnsLikeInfoList() { when(productService.existsById(20L)).thenReturn(true); // act - List result = likeFacade.getMyLikedProducts(userId); + List result = likeFacade.getMyLikedProducts(userId); // assert assertThat(result).hasSize(2); @@ -117,7 +117,7 @@ void getLikesByUserId_excludesDeletedProducts() { when(productService.existsById(20L)).thenReturn(false); // act - List result = likeFacade.getMyLikedProducts(userId); + List result = likeFacade.getMyLikedProducts(userId); // assert assertThat(result).hasSize(1); From 2fd28f53354086c5ca33cacc4b6cf993cdf22143 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Tue, 24 Feb 2026 01:31:42 +0900 Subject: [PATCH 055/108] =?UTF-8?q?refactor:=20=EC=83=81=ED=92=88=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EB=84=A4?= =?UTF-8?q?=EC=9D=B4=EB=B0=8D=20=EA=B0=9C=EC=84=A0=20(softDelete/Active=20?= =?UTF-8?q?=E2=86=92=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=9D=98=EB=8F=84=20?= =?UTF-8?q?=ED=91=9C=ED=98=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../com/loopers/application/brand/BrandFacade.java | 2 +- .../com/loopers/application/product/ProductFacade.java | 8 ++++---- .../com/loopers/domain/product/ProductRepository.java | 4 ++-- .../com/loopers/domain/product/ProductService.java | 10 +++++----- .../infrastructure/product/ProductRepositoryImpl.java | 4 ++-- .../interfaces/product/ProductV1Controller.java | 4 ++-- .../com/loopers/application/brand/BrandFacadeTest.java | 2 +- .../loopers/domain/product/FakeProductRepository.java | 4 ++-- .../com/loopers/domain/product/ProductServiceTest.java | 10 +++++----- 9 files changed, 24 insertions(+), 24 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java index 08f2aa808..7ba37e7f2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -37,7 +37,7 @@ public void updateBrand(Long id, BrandCriteria.Update criteria) { @Transactional public void deleteBrand(Long id) { brandService.delete(id); - productService.softDeleteByBrandId(id); + productService.deleteAllByBrandId(id); } @Transactional(readOnly = true) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index 1bee1b84e..19fab2c36 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -52,12 +52,12 @@ public Page getProductsByBrandId(Long brandId, Pageable pageable) } @Transactional(readOnly = true) - public Page getActiveProducts(Pageable pageable) { - return productService.getAllActive(pageable).map(ProductResult::from); + public Page getProductsWithActiveBrand(Pageable pageable) { + return productService.getAllWithActiveBrand(pageable).map(ProductResult::from); } @Transactional(readOnly = true) - public Page getActiveProductsByBrandId(Long brandId, Pageable pageable) { - return productService.getAllActiveByBrandId(brandId, pageable).map(ProductResult::from); + public Page getProductsWithActiveBrandByBrandId(Long brandId, Pageable pageable) { + return productService.getAllWithActiveBrandByBrandId(brandId, pageable).map(ProductResult::from); } } 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 17a31b578..2ae9941f3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -16,7 +16,7 @@ public interface ProductRepository { List findAllByBrandId(Long brandId); - Page findAllActive(Pageable pageable); + Page findAllWithActiveBrand(Pageable pageable); - Page findAllActiveByBrandId(Long brandId, Pageable pageable); + Page findAllWithActiveBrandByBrandId(Long brandId, Pageable pageable); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java index 11a6bbb42..2d95faec5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -49,7 +49,7 @@ public Page getAllByBrandId(Long brandId, Pageable pageable) { } @Transactional - public void softDeleteByBrandId(Long brandId) { + public void deleteAllByBrandId(Long brandId) { List products = productRepository.findAllByBrandId(brandId); products.forEach(ProductModel::delete); } @@ -60,12 +60,12 @@ public boolean existsById(Long id) { } @Transactional(readOnly = true) - public Page getAllActive(Pageable pageable) { - return productRepository.findAllActive(pageable); + public Page getAllWithActiveBrand(Pageable pageable) { + return productRepository.findAllWithActiveBrand(pageable); } @Transactional(readOnly = true) - public Page getAllActiveByBrandId(Long brandId, Pageable pageable) { - return productRepository.findAllActiveByBrandId(brandId, pageable); + public Page getAllWithActiveBrandByBrandId(Long brandId, Pageable pageable) { + return productRepository.findAllWithActiveBrandByBrandId(brandId, pageable); } } 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 a4434071e..c1cd791cd 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 @@ -40,12 +40,12 @@ public List findAllByBrandId(Long brandId) { } @Override - public Page findAllActive(Pageable pageable) { + public Page findAllWithActiveBrand(Pageable pageable) { return productJpaRepository.findAllByDeletedAtIsNullAndBrandDeletedAtIsNull(pageable); } @Override - public Page findAllActiveByBrandId(Long brandId, Pageable pageable) { + public Page findAllWithActiveBrandByBrandId(Long brandId, Pageable pageable) { return productJpaRepository.findAllByBrandIdAndDeletedAtIsNullAndBrandDeletedAtIsNull(brandId, pageable); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1Controller.java index acd018b61..77c2c3c34 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1Controller.java @@ -27,8 +27,8 @@ public ApiResponse list( ) { PageRequest pageRequest = PageRequest.of(page, size, toSort(sort)); Page productInfoPage = brandId != null - ? productFacade.getActiveProductsByBrandId(brandId, pageRequest) - : productFacade.getActiveProducts(pageRequest); + ? productFacade.getProductsWithActiveBrandByBrandId(brandId, pageRequest) + : productFacade.getProductsWithActiveBrand(pageRequest); ProductV1Dto.ListResponse listResponse = new ProductV1Dto.ListResponse( productInfoPage.getNumber(), diff --git a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java index be17b5d52..c4accb45a 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java @@ -98,7 +98,7 @@ class Delete { // assert verify(brandService).delete(1L); - verify(productService).softDeleteByBrandId(1L); + verify(productService).deleteAllByBrandId(1L); } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/FakeProductRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/FakeProductRepository.java index 28ff91773..fa5914173 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/FakeProductRepository.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/FakeProductRepository.java @@ -78,7 +78,7 @@ public List findAllByBrandId(Long brandId) { } @Override - public Page findAllActive(Pageable pageable) { + public Page findAllWithActiveBrand(Pageable pageable) { List activeModels = store.values().stream() .filter(product -> product.getDeletedAt() == null) .filter(product -> product.getBrand().getDeletedAt() == null) @@ -95,7 +95,7 @@ public Page findAllActive(Pageable pageable) { } @Override - public Page findAllActiveByBrandId(Long brandId, Pageable pageable) { + public Page findAllWithActiveBrandByBrandId(Long brandId, Pageable pageable) { List filtered = store.values().stream() .filter(product -> product.getDeletedAt() == null) .filter(product -> product.getBrand().getDeletedAt() == null) 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 index 94fd187a6..9a497ba09 100644 --- 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 @@ -205,19 +205,19 @@ void getAllByBrandId_whenExists() { } } - @DisplayName("브랜드 삭제 시 상품을 일괄 soft delete할 때, ") + @DisplayName("브랜드별 상품을 일괄 삭제할 때, ") @Nested - class SoftDeleteByBrandId { + class DeleteAllByBrandId { - @DisplayName("해당 브랜드의 모든 상품이 soft delete된다.") + @DisplayName("해당 브랜드의 모든 상품이 삭제된다.") @Test - void softDeleteByBrandId_whenProductsExist() { + void deleteAllByBrandId_whenProductsExist() { // arrange productService.register(brand, "에어맥스", 150000, 100); productService.register(brand, "에어포스", 120000, 50); // act - productService.softDeleteByBrandId(brand.getId()); + productService.deleteAllByBrandId(brand.getId()); // assert Page result = productService.getAll(PageRequest.of(0, 20)); From 4299018829adab193e48e2d5744022dd4710111e Mon Sep 17 00:00:00 2001 From: plan11plan Date: Tue, 24 Feb 2026 02:01:22 +0900 Subject: [PATCH 056/108] =?UTF-8?q?refactor:=20=EC=A2=8B=EC=95=84=EC=9A=94?= =?UTF-8?q?=20toggleLike=EB=A5=BC=20like/unlike=EB=A1=9C=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=ED=95=98=EA=B3=A0=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20enum=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../loopers/application/like/LikeFacade.java | 23 ++++---- .../application/like/dto/LikeCriteria.java | 12 ----- .../loopers/domain/like/dto/LikeCommand.java | 12 ----- .../interfaces/like/LikeV1ApiSpec.java | 38 ++++++++++++++ .../interfaces/like/LikeV1Controller.java | 52 +++++++++++++++++++ .../interfaces/like/dto/LikeV1Dto.java | 30 +++++++++++ 6 files changed, 130 insertions(+), 37 deletions(-) delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/dto/LikeCriteria.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/dto/LikeCommand.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/like/LikeV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/like/LikeV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/like/dto/LikeV1Dto.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java index 41515dcff..933e840c6 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,6 +1,5 @@ package com.loopers.application.like; -import com.loopers.application.like.dto.LikeCriteria; import com.loopers.application.like.dto.LikeResult; import com.loopers.domain.like.ProductLikeService; import com.loopers.domain.product.ProductModel; @@ -17,19 +16,17 @@ public class LikeFacade { private final ProductService productService; @Transactional - public void toggleLike(LikeCriteria.Toggle criteria) { - ProductModel product = productService.getById(criteria.productId()); + public void like(Long userId, Long productId) { + ProductModel product = productService.getById(productId); + productLikeService.like(userId, productId); + product.addLikeCount(); + } - switch (criteria.type()) { - case LIKE -> { - productLikeService.like(criteria.userId(), criteria.productId()); - product.addLikeCount(); - } - case UNLIKE -> { - productLikeService.unlike(criteria.userId(), criteria.productId()); - product.subtractLikeCount(); - } - } + @Transactional + public void unlike(Long userId, Long productId) { + ProductModel product = productService.getById(productId); + productLikeService.unlike(userId, productId); + product.subtractLikeCount(); } @Transactional(readOnly = true) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/dto/LikeCriteria.java b/apps/commerce-api/src/main/java/com/loopers/application/like/dto/LikeCriteria.java deleted file mode 100644 index 6edfb4b3a..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/dto/LikeCriteria.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.loopers.application.like.dto; - -import com.loopers.domain.like.dto.LikeCommand; - -public class LikeCriteria { - - public record Toggle(LikeCommand.ApplyLikeRequestType type, Long userId, Long productId) { - public LikeCommand.Toggle toCommand() { - return new LikeCommand.Toggle(type, userId, productId); - } - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/dto/LikeCommand.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/dto/LikeCommand.java deleted file mode 100644 index ceedb565d..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/dto/LikeCommand.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.loopers.domain.like.dto; - -public class LikeCommand { - - public enum ApplyLikeRequestType { - LIKE, - UNLIKE - } - public record Toggle(ApplyLikeRequestType type, Long userId, Long productId) { - } - -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/like/LikeV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/like/LikeV1ApiSpec.java new file mode 100644 index 000000000..fee10a9ba --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/like/LikeV1ApiSpec.java @@ -0,0 +1,38 @@ +package com.loopers.interfaces.like; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.auth.LoginUser; +import com.loopers.interfaces.like.dto.LikeV1Dto; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Like V1 API", description = "좋아요 API 입니다.") +public interface LikeV1ApiSpec { + + @Operation( + summary = "상품 좋아요 등록", + description = "상품에 좋아요를 등록합니다." + ) + ApiResponse like( + @Parameter(hidden = true) LoginUser loginUser, + @Parameter(description = "상품 ID", required = true, example = "1") Long productId + ); + + @Operation( + summary = "상품 좋아요 취소", + description = "상품의 좋아요를 취소합니다." + ) + ApiResponse unlike( + @Parameter(hidden = true) LoginUser loginUser, + @Parameter(description = "상품 ID", required = true, example = "1") Long productId + ); + + @Operation( + summary = "내가 좋아요한 상품 목록 조회", + description = "로그인한 사용자가 좋아요한 상품 목록을 조회합니다." + ) + ApiResponse getMyLikes( + @Parameter(hidden = true) LoginUser loginUser + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/like/LikeV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/like/LikeV1Controller.java new file mode 100644 index 000000000..df751bd49 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/like/LikeV1Controller.java @@ -0,0 +1,52 @@ +package com.loopers.interfaces.like; + +import com.loopers.application.like.LikeFacade; +import com.loopers.application.like.dto.LikeResult; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.auth.Login; +import com.loopers.interfaces.auth.LoginUser; +import com.loopers.interfaces.like.dto.LikeV1Dto; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +public class LikeV1Controller implements LikeV1ApiSpec { + + private final LikeFacade likeFacade; + + @PostMapping("/api/v1/products/{productId}/likes") + @Override + public ApiResponse like( + @Login LoginUser loginUser, + @PathVariable Long productId + ) { + likeFacade.like(loginUser.id(), productId); + return ApiResponse.success(); + } + + @DeleteMapping("/api/v1/products/{productId}/likes") + @Override + public ApiResponse unlike( + @Login LoginUser loginUser, + @PathVariable Long productId + ) { + likeFacade.unlike(loginUser.id(), productId); + return ApiResponse.success(); + } + + @GetMapping("/api/v1/users/me/likes") + @Override + public ApiResponse getMyLikes( + @Login LoginUser loginUser + ) { + List results = likeFacade.getMyLikedProducts(loginUser.id()); + LikeV1Dto.ListResponse listResponse = new LikeV1Dto.ListResponse( + results.stream() + .map(LikeV1Dto.ListResponse.ListItem::from) + .toList() + ); + return ApiResponse.success(listResponse); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/like/dto/LikeV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/like/dto/LikeV1Dto.java new file mode 100644 index 000000000..642b02dc0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/like/dto/LikeV1Dto.java @@ -0,0 +1,30 @@ +package com.loopers.interfaces.like.dto; + +import com.loopers.application.like.dto.LikeResult; +import java.time.ZonedDateTime; +import java.util.List; + +public class LikeV1Dto { + + public record LikeResponse( + Long productId, + ZonedDateTime createdAt + ) { + public static LikeResponse from(LikeResult result) { + return new LikeResponse(result.productId(), result.createdAt()); + } + } + + public record ListResponse( + List items + ) { + public record ListItem( + Long productId, + ZonedDateTime createdAt + ) { + public static ListItem from(LikeResult result) { + return new ListItem(result.productId(), result.createdAt()); + } + } + } +} From 87d31fe21d5b59d98592e0e9560dc9430491b821 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Tue, 24 Feb 2026 02:01:27 +0900 Subject: [PATCH 057/108] =?UTF-8?q?test:=20=EC=A2=8B=EC=95=84=EC=9A=94=20l?= =?UTF-8?q?ike/unlike=20=EB=B6=84=EB=A6=AC=EC=97=90=20=EB=94=B0=EB=A5=B8?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../application/like/LikeFacadeTest.java | 26 ++-- .../interfaces/like/LikeV1ControllerTest.java | 136 ++++++++++++++++++ 2 files changed, 149 insertions(+), 13 deletions(-) create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/like/LikeV1ControllerTest.java 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 d646ca32f..2cf26406b 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,12 +1,9 @@ package com.loopers.application.like; -import static com.loopers.domain.like.dto.LikeCommand.ApplyLikeRequestType.LIKE; -import static com.loopers.domain.like.dto.LikeCommand.ApplyLikeRequestType.UNLIKE; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import com.loopers.application.like.dto.LikeCriteria; import com.loopers.application.like.dto.LikeResult; import com.loopers.domain.brand.BrandModel; import com.loopers.domain.like.ProductLikeModel; @@ -35,34 +32,37 @@ class LikeFacadeTest { @InjectMocks private LikeFacade likeFacade; - @DisplayName("좋아요를 토글할 때, ") + @DisplayName("좋아요를 등록할 때, ") @Nested - class ToggleLike { + class Like { - @DisplayName("LIKE 요청이면, 상품을 검증하고 like를 호출하고 likeCount를 증가시킨다.") + @DisplayName("상품을 검증하고 like를 호출하고 likeCount를 증가시킨다.") @Test - void toggleLike_whenLike_addsLikeAndIncreasesCount() { + void like_addsLikeAndIncreasesCount() { // arrange - LikeCriteria.Toggle criteria = new LikeCriteria.Toggle(LIKE, 1L, 2L); ProductModel product = ProductModel.create( BrandModel.create("Nike"), "에어맥스", 150000, 100 ); when(productService.getById(2L)).thenReturn(product); // act - likeFacade.toggleLike(criteria); + likeFacade.like(1L, 2L); // assert verify(productService).getById(2L); verify(productLikeService).like(1L, 2L); assertThat(product.getLikeCount()).isEqualTo(1); } + } + + @DisplayName("좋아요를 취소할 때, ") + @Nested + class Unlike { - @DisplayName("UNLIKE 요청이면, 상품을 검증하고 unlike를 호출하고 likeCount를 감소시킨다.") + @DisplayName("상품을 검증하고 unlike를 호출하고 likeCount를 감소시킨다.") @Test - void toggleLike_whenUnlike_removesLikeAndDecreasesCount() { + void unlike_removesLikeAndDecreasesCount() { // arrange - LikeCriteria.Toggle criteria = new LikeCriteria.Toggle(UNLIKE, 1L, 2L); ProductModel product = ProductModel.create( BrandModel.create("Nike"), "에어맥스", 150000, 100 ); @@ -70,7 +70,7 @@ void toggleLike_whenUnlike_removesLikeAndDecreasesCount() { when(productService.getById(2L)).thenReturn(product); // act - likeFacade.toggleLike(criteria); + likeFacade.unlike(1L, 2L); // assert verify(productService).getById(2L); diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/like/LikeV1ControllerTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/like/LikeV1ControllerTest.java new file mode 100644 index 000000000..c9ef3b45b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/like/LikeV1ControllerTest.java @@ -0,0 +1,136 @@ +package com.loopers.interfaces.like; + +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.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.loopers.application.like.LikeFacade; +import com.loopers.application.like.dto.LikeResult; +import com.loopers.domain.product.ProductErrorCode; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.auth.LoginUser; +import com.loopers.interfaces.like.dto.LikeV1Dto; +import com.loopers.support.error.CoreException; +import java.time.ZonedDateTime; +import java.util.List; +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; + +@DisplayName("LikeV1Controller 단위 테스트") +@ExtendWith(MockitoExtension.class) +class LikeV1ControllerTest { + + @Mock + private LikeFacade likeFacade; + + @InjectMocks + private LikeV1Controller likeV1Controller; + + private final LoginUser loginUser = new LoginUser(1L, "testuser", "테스터"); + + @DisplayName("POST /api/v1/products/{productId}/likes") + @Nested + class Like { + + @DisplayName("좋아요 등록 요청이면, likeFacade.like를 호출하고 성공 응답을 반환한다.") + @Test + void like_callsFacadeLike() { + // arrange + Long productId = 10L; + doNothing().when(likeFacade).like(1L, 10L); + + // act + ApiResponse response = likeV1Controller.like(loginUser, productId); + + // assert + verify(likeFacade).like(1L, 10L); + assertThat(response.meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS); + } + + @DisplayName("존재하지 않는 상품이면, 예외가 전파된다.") + @Test + void like_whenProductNotFound_throwsException() { + // arrange + Long productId = 999L; + doThrow(new CoreException(ProductErrorCode.NOT_FOUND)) + .when(likeFacade).like(1L, 999L); + + // act & assert + assertThatThrownBy( + () -> likeV1Controller.like(loginUser, productId) + ).isInstanceOf(CoreException.class); + } + } + + @DisplayName("DELETE /api/v1/products/{productId}/likes") + @Nested + class Unlike { + + @DisplayName("좋아요 취소 요청이면, likeFacade.unlike를 호출하고 성공 응답을 반환한다.") + @Test + void unlike_callsFacadeUnlike() { + // arrange + Long productId = 10L; + doNothing().when(likeFacade).unlike(1L, 10L); + + // act + ApiResponse response = likeV1Controller.unlike(loginUser, productId); + + // assert + verify(likeFacade).unlike(1L, 10L); + assertThat(response.meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS); + } + } + + @DisplayName("GET /api/v1/users/me/likes") + @Nested + class GetMyLikes { + + @DisplayName("좋아요 목록을 조회하면, LikeV1Dto.ListResponse를 반환한다.") + @Test + void getMyLikes_returnsListResponse() { + // arrange + List results = List.of( + new LikeResult(1L, 1L, 10L, ZonedDateTime.now()), + new LikeResult(2L, 1L, 20L, ZonedDateTime.now()) + ); + when(likeFacade.getMyLikedProducts(1L)).thenReturn(results); + + // act + ApiResponse response = likeV1Controller.getMyLikes(loginUser); + + // assert + assertAll( + () -> assertThat(response.meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS), + () -> assertThat(response.data().items()).hasSize(2), + () -> assertThat(response.data().items().get(0).productId()).isEqualTo(10L), + () -> assertThat(response.data().items().get(1).productId()).isEqualTo(20L) + ); + } + + @DisplayName("좋아요가 없으면, 빈 목록을 반환한다.") + @Test + void getMyLikes_returnsEmptyList_whenNoLikes() { + // arrange + when(likeFacade.getMyLikedProducts(1L)).thenReturn(List.of()); + + // act + ApiResponse response = likeV1Controller.getMyLikes(loginUser); + + // assert + assertAll( + () -> assertThat(response.meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS), + () -> assertThat(response.data().items()).isEmpty() + ); + } + } +} From 47b30b50dad402d66f0bd1ef6ece6eda20845887 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Tue, 24 Feb 2026 02:01:31 +0900 Subject: [PATCH 058/108] =?UTF-8?q?docs:=20=EC=A2=8B=EC=95=84=EC=9A=94=20?= =?UTF-8?q?=EC=84=A4=EA=B3=84=20=EB=AC=B8=EC=84=9C=20like/unlike=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- docs/design/like/DESIGN.md | 67 +++++++++++++++++++++++--------------- 1 file changed, 41 insertions(+), 26 deletions(-) diff --git a/docs/design/like/DESIGN.md b/docs/design/like/DESIGN.md index 2a86579e6..01f070890 100644 --- a/docs/design/like/DESIGN.md +++ b/docs/design/like/DESIGN.md @@ -12,7 +12,7 @@ ### 예외 및 정책 - **좋아요 수: Product.likeCount 캐시** — Like 엔티티가 원본 데이터, Product.likeCount는 조회 성능을 위한 파생값(derived data). 찜/취소 시 원자적 증감. -- **API 방식: 엔드포인트 분리 + 내부 토글** — POST/DELETE 엔드포인트는 분리하되, 내부적으로 같은 Facade 메서드(toggleLike)를 호출. 409/404 없음. +- **API 방식: 엔드포인트 분리** — POST/DELETE 엔드포인트를 분리하고, Facade도 like/unlike 메서드를 각각 제공. 409/404 없음. - **회원당 상품당 1개** — userId + productId DB 유니크 제약. 동시성(더블클릭) 시에도 중복 방지. - **삭제된 상품/브랜드의 좋아요** — 목록 조회 시 필터링으로 제외. - **상품 검증 항상 수행** — 등록/취소 모두 ProductService로 상품 존재 + 삭제 여부 확인. @@ -31,15 +31,20 @@ ## 유즈케이스 -**UC-L01: 상품 좋아요 토글 (등록/취소)** +**UC-L01: 상품 좋아요 등록/취소** ``` -[기능 흐름] -1. 회원이 productId로 좋아요를 요청한다 (POST 또는 DELETE) +[기능 흐름 - 등록 (POST)] +1. 회원이 productId로 좋아요 등록을 요청한다 +2. 해당 상품이 존재하는지 확인한다 (삭제된 상품 불가) +3. 좋아요를 저장한다 +4. 상품의 likeCount를 증가시킨다 + +[기능 흐름 - 취소 (DELETE)] +1. 회원이 productId로 좋아요 취소를 요청한다 2. 해당 상품이 존재하는지 확인한다 (삭제된 상품 불가) -3. 좋아요 존재 여부를 확인한다 -4-a. 좋아요가 없으면: 좋아요를 저장한다 (등록) -4-b. 좋아요가 있으면: 좋아요를 삭제한다 (취소) +3. 좋아요를 삭제한다 +4. 상품의 likeCount를 감소시킨다 [예외] - productId에 해당하는 상품이 없거나 삭제된 경우 404 반환 @@ -47,9 +52,6 @@ [조건] - 로그인한 회원만 가능 - 회원당 상품당 1개만 저장 (유니크 제약) -- POST/DELETE 모두 같은 Facade 메서드(toggleLike)를 호출 -- 이미 좋아요한 상품에 POST → 좋아요 취소 (409 없음) -- 좋아요하지 않은 상품에 DELETE → 좋아요 등록 (404 없음) ``` **UC-L02: 내가 좋아요한 상품 목록 조회** @@ -71,7 +73,7 @@ ## 시퀀스 다이어그램: 좋아요 등록/취소 -> 좋아요는 **Product 검증 + Like 등록/취소**를 조율해야 하므로 Facade가 필요하다. +> 좋아요는 **Product 검증 + Like 등록/취소 + likeCount 갱신**을 조율해야 하므로 Facade가 필요하다. ```mermaid sequenceDiagram @@ -82,24 +84,37 @@ sequenceDiagram participant PS as ProductService participant LS as LikeService - Note left of LC: POST /products/{id}/likes
DELETE /products/{id}/likes + Note left of LC: POST /products/{id}/likes - LC->>LF: 좋아요 토글 요청 + LC->>LF: like(userId, productId) Note over LF: @Transactional - LF->>PS: 상품 검증 - activate PS - PS-->>PS: 상품 예외처리 - PS-->>LF: 검증 완료 - deactivate PS - - LF->>LS: 좋아요 존재 확인 - - alt 좋아요가 존재하지 않을 경우 - LF->>LS: save() - else 이미 좋아요한 경우 - LF->>LS: delete() - end + LF->>PS: getById(productId) + PS-->>LF: ProductModel + + LF->>LS: like(userId, productId) + LF->>LF: product.addLikeCount() +``` + +```mermaid +sequenceDiagram + autonumber + + participant LC as LikeController + participant LF as LikeFacade + participant PS as ProductService + participant LS as LikeService + + Note left of LC: DELETE /products/{id}/likes + + LC->>LF: unlike(userId, productId) + + Note over LF: @Transactional + LF->>PS: getById(productId) + PS-->>LF: ProductModel + + LF->>LS: unlike(userId, productId) + LF->>LF: product.subtractLikeCount() ``` --- From e05aa0e058fd886524159e6ff4f8bfff3310221f Mon Sep 17 00:00:00 2001 From: plan11plan Date: Tue, 24 Feb 2026 13:16:50 +0900 Subject: [PATCH 059/108] =?UTF-8?q?refactor:=20=EC=A2=8B=EC=95=84=EC=9A=94?= =?UTF-8?q?=20=EC=A0=95=EC=B1=85=EC=9D=84=20likeCount=20=EC=BA=90=EC=8B=9C?= =?UTF-8?q?=EC=97=90=EC=84=9C=20COUNT(*)=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EC=BF=BC=EB=A6=AC=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../com/loopers/application/like/LikeFacade.java | 6 +----- .../application/product/dto/ProductResult.java | 2 -- .../loopers/domain/like/ProductLikeModel.java | 5 ++++- .../domain/like/ProductLikeRepository.java | 2 ++ .../loopers/domain/like/ProductLikeService.java | 10 +++++++++- .../com/loopers/domain/product/ProductModel.java | 16 ---------------- .../loopers/domain/product/ProductService.java | 7 +++++++ .../like/ProductLikeJpaRepository.java | 2 ++ .../like/ProductLikeRepositoryImpl.java | 5 +++++ .../interfaces/product/ProductV1ApiSpec.java | 2 +- .../interfaces/product/ProductV1Controller.java | 1 - .../product/dto/AdminProductV1Dto.java | 6 ++---- .../interfaces/product/dto/ProductV1Dto.java | 10 ++++------ 13 files changed, 37 insertions(+), 37 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java index 933e840c6..72a7cb513 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 @@ -2,7 +2,6 @@ import com.loopers.application.like.dto.LikeResult; import com.loopers.domain.like.ProductLikeService; -import com.loopers.domain.product.ProductModel; import com.loopers.domain.product.ProductService; import java.util.List; import lombok.RequiredArgsConstructor; @@ -17,16 +16,13 @@ public class LikeFacade { @Transactional public void like(Long userId, Long productId) { - ProductModel product = productService.getById(productId); + productService.validateExists(productId); productLikeService.like(userId, productId); - product.addLikeCount(); } @Transactional public void unlike(Long userId, Long productId) { - ProductModel product = productService.getById(productId); productLikeService.unlike(userId, productId); - product.subtractLikeCount(); } @Transactional(readOnly = true) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/dto/ProductResult.java b/apps/commerce-api/src/main/java/com/loopers/application/product/dto/ProductResult.java index 5aef724d1..1b1fa13bf 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/dto/ProductResult.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/dto/ProductResult.java @@ -10,7 +10,6 @@ public record ProductResult( String name, int price, int stock, - int likeCount, ZonedDateTime createdAt, ZonedDateTime updatedAt, ZonedDateTime deletedAt @@ -23,7 +22,6 @@ public static ProductResult from(ProductModel model) { model.getName(), model.getPrice().getValue(), model.getStock().getValue(), - model.getLikeCount(), model.getCreatedAt(), model.getUpdatedAt(), model.getDeletedAt() diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeModel.java index bd2f1acba..46faa91d2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeModel.java @@ -8,6 +8,7 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import java.time.ZonedDateTime; import lombok.AccessLevel; import lombok.Getter; @@ -16,7 +17,9 @@ @Getter @Entity -@Table(name = "likes") +@Table(name = "likes", uniqueConstraints = { + @UniqueConstraint(name = "uk_likes_user_product", columnNames = {"user_id", "product_id"}) +}) @NoArgsConstructor(access = AccessLevel.PROTECTED) public class ProductLikeModel { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeRepository.java index 454e92a23..6539f9bc7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeRepository.java @@ -11,4 +11,6 @@ public interface ProductLikeRepository { void delete(ProductLikeModel productLike); List findAllByUserId(Long userId); + + long countByProductId(Long productId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeService.java index 5519d5120..0d5e714b8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeService.java @@ -14,13 +14,16 @@ public class ProductLikeService { @Transactional public void like(Long userId, Long productId) { + if (productLikeRepository.findByUserIdAndProductId(userId, productId).isPresent()) { + throw new CoreException(ErrorType.CONFLICT, "이미 좋아요한 상품입니다"); + } productLikeRepository.save(ProductLikeModel.create(userId, productId)); } @Transactional public void unlike(Long userId, Long productId) { ProductLikeModel like = productLikeRepository.findByUserIdAndProductId(userId, productId) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND_DATA)); + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "좋아요 기록이 없습니다")); productLikeRepository.delete(like); } @@ -33,4 +36,9 @@ public boolean existsByUserIdAndProductId(Long userId, Long productId) { public List getLikesByUserId(Long userId) { return productLikeRepository.findAllByUserId(userId); } + + @Transactional(readOnly = true) + public long countLikes(Long productId) { + return productLikeRepository.countByProductId(productId); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java index a8d7e5153..82e5a6c3e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java @@ -16,8 +16,6 @@ import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; -import org.hibernate.annotations.ColumnDefault; - @Getter @Entity @Table(name = "products") @@ -39,10 +37,6 @@ public class ProductModel extends BaseEntity { @AttributeOverride(name = "value", column = @Column(name = "stock", nullable = false)) private Stock stock; - @Column(name = "like_count", nullable = false) - @ColumnDefault("0") - private int likeCount = 0; - // === 생성 === // private ProductModel(BrandModel brand, String name, Money price, Stock stock) { @@ -75,16 +69,6 @@ public boolean isSoldOut() { return this.stock.getValue() == 0; } - public void addLikeCount() { - this.likeCount++; - } - - public void subtractLikeCount() { - if (this.likeCount > 0) { - this.likeCount--; - } - } - // === 검증 === // private static void validateBrand(BrandModel brand) { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java index 2d95faec5..04daad773 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -59,6 +59,13 @@ public boolean existsById(Long id) { return productRepository.findById(id).isPresent(); } + @Transactional(readOnly = true) + public void validateExists(Long id) { + if (!existsById(id)) { + throw new CoreException(ProductErrorCode.NOT_FOUND); + } + } + @Transactional(readOnly = true) public Page getAllWithActiveBrand(Pageable pageable) { return productRepository.findAllWithActiveBrand(pageable); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeJpaRepository.java index 9a675c8ae..13a10b83d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeJpaRepository.java @@ -9,4 +9,6 @@ public interface ProductLikeJpaRepository extends JpaRepository findByUserIdAndProductId(Long userId, Long productId); List findAllByUserId(Long userId); + + long countByProductId(Long productId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeRepositoryImpl.java index 4b970aa54..1f34189b1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeRepositoryImpl.java @@ -30,4 +30,9 @@ public void delete(ProductLikeModel productLike) { public List findAllByUserId(Long userId) { return productLikeJpaRepository.findAllByUserId(userId); } + + @Override + public long countByProductId(Long productId) { + return productLikeJpaRepository.countByProductId(productId); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1ApiSpec.java index 4dafcf4ab..314732809 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1ApiSpec.java @@ -16,7 +16,7 @@ public interface ProductV1ApiSpec { ApiResponse list( @Parameter(description = "브랜드 ID (선택)", example = "1") Long brandId, - @Parameter(description = "정렬 기준: latest / price_asc / likes_desc", example = "latest") + @Parameter(description = "정렬 기준: latest / price_asc", example = "latest") String sort, @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") int page, diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1Controller.java index 77c2c3c34..d8c72ad08 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1Controller.java @@ -54,7 +54,6 @@ public ApiResponse getById( private Sort toSort(String sort) { return switch (sort) { case "price_asc" -> Sort.by(Sort.Direction.ASC, "price.value"); - case "likes_desc" -> Sort.by(Sort.Direction.DESC, "likeCount"); default -> Sort.by(Sort.Direction.DESC, "createdAt"); }; } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/product/dto/AdminProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/dto/AdminProductV1Dto.java index 443b14d6b..71d0445de 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/product/dto/AdminProductV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/dto/AdminProductV1Dto.java @@ -52,7 +52,6 @@ public record DetailResponse( String name, int price, int stock, - int likeCount, ZonedDateTime createdAt, ZonedDateTime updatedAt, ZonedDateTime deletedAt @@ -60,7 +59,7 @@ public record DetailResponse( public static DetailResponse from(ProductResult info) { return new DetailResponse( info.id(), info.brandId(), info.brandName(), - info.name(), info.price(), info.stock(), info.likeCount(), + info.name(), info.price(), info.stock(), info.createdAt(), info.updatedAt(), info.deletedAt() ); } @@ -80,7 +79,6 @@ public record ListItem( String name, int price, int stock, - int likeCount, ZonedDateTime createdAt, ZonedDateTime updatedAt, ZonedDateTime deletedAt @@ -88,7 +86,7 @@ public record ListItem( public static ListItem from(ProductResult info) { return new ListItem( info.id(), info.brandId(), info.brandName(), - info.name(), info.price(), info.stock(), info.likeCount(), + info.name(), info.price(), info.stock(), info.createdAt(), info.updatedAt(), info.deletedAt() ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/product/dto/ProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/dto/ProductV1Dto.java index 2f7a067e3..75d0d45d4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/product/dto/ProductV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/dto/ProductV1Dto.java @@ -11,13 +11,12 @@ public record DetailResponse( String brandName, String name, int price, - int stock, - int likeCount + int stock ) { public static DetailResponse from(ProductResult info) { return new DetailResponse( info.id(), info.brandId(), info.brandName(), - info.name(), info.price(), info.stock(), info.likeCount() + info.name(), info.price(), info.stock() ); } } @@ -34,13 +33,12 @@ public record ListItem( Long brandId, String brandName, String name, - int price, - int likeCount + int price ) { public static ListItem from(ProductResult info) { return new ListItem( info.id(), info.brandId(), info.brandName(), - info.name(), info.price(), info.likeCount() + info.name(), info.price() ); } } From c3fe3c65dae6e962234a6b53b06bf3732c1720fa Mon Sep 17 00:00:00 2001 From: plan11plan Date: Tue, 24 Feb 2026 13:16:55 +0900 Subject: [PATCH 060/108] =?UTF-8?q?test:=20=EC=A2=8B=EC=95=84=EC=9A=94=20?= =?UTF-8?q?=EC=A0=95=EC=B1=85=20=EC=A0=84=ED=99=98=EC=97=90=20=EB=94=B0?= =?UTF-8?q?=EB=A5=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../application/like/LikeFacadeTest.java | 28 ++--------- .../like/FakeProductLikeRepository.java | 7 +++ .../domain/like/ProductLikeServiceTest.java | 42 +++++++++++++++++ .../domain/product/ProductModelTest.java | 47 ------------------- .../product/ProductV1ApiE2ETest.java | 3 +- 5 files changed, 55 insertions(+), 72 deletions(-) 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 2cf26406b..cf46b51b9 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 @@ -5,10 +5,8 @@ import static org.mockito.Mockito.when; import com.loopers.application.like.dto.LikeResult; -import com.loopers.domain.brand.BrandModel; import com.loopers.domain.like.ProductLikeModel; import com.loopers.domain.like.ProductLikeService; -import com.loopers.domain.product.ProductModel; import com.loopers.domain.product.ProductService; import java.util.List; import org.junit.jupiter.api.DisplayName; @@ -36,22 +34,15 @@ class LikeFacadeTest { @Nested class Like { - @DisplayName("상품을 검증하고 like를 호출하고 likeCount를 증가시킨다.") + @DisplayName("상품 존재를 검증하고 like를 호출한다.") @Test - void like_addsLikeAndIncreasesCount() { - // arrange - ProductModel product = ProductModel.create( - BrandModel.create("Nike"), "에어맥스", 150000, 100 - ); - when(productService.getById(2L)).thenReturn(product); - + void like_validatesProductAndCallsLike() { // act likeFacade.like(1L, 2L); // assert - verify(productService).getById(2L); + verify(productService).validateExists(2L); verify(productLikeService).like(1L, 2L); - assertThat(product.getLikeCount()).isEqualTo(1); } } @@ -59,23 +50,14 @@ void like_addsLikeAndIncreasesCount() { @Nested class Unlike { - @DisplayName("상품을 검증하고 unlike를 호출하고 likeCount를 감소시킨다.") + @DisplayName("unlike를 호출한다.") @Test - void unlike_removesLikeAndDecreasesCount() { - // arrange - ProductModel product = ProductModel.create( - BrandModel.create("Nike"), "에어맥스", 150000, 100 - ); - product.addLikeCount(); // likeCount = 1 - when(productService.getById(2L)).thenReturn(product); - + void unlike_callsUnlike() { // act likeFacade.unlike(1L, 2L); // assert - verify(productService).getById(2L); verify(productLikeService).unlike(1L, 2L); - assertThat(product.getLikeCount()).isEqualTo(0); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/FakeProductLikeRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/FakeProductLikeRepository.java index e3f5ded59..73431ca5c 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/like/FakeProductLikeRepository.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/FakeProductLikeRepository.java @@ -44,4 +44,11 @@ public List findAllByUserId(Long userId) { .filter(like -> like.getUserId().equals(userId)) .toList(); } + + @Override + public long countByProductId(Long productId) { + return store.values().stream() + .filter(like -> like.getProductId().equals(productId)) + .count(); + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeServiceTest.java index 53b5d27a4..b62299141 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeServiceTest.java @@ -1,7 +1,9 @@ package com.loopers.domain.like; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import com.loopers.support.error.CoreException; import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -32,6 +34,18 @@ void like_whenValidValues() { // assert assertThat(productLikeRepository.findByUserIdAndProductId(1L, 2L)).isPresent(); } + + @DisplayName("이미 좋아요한 상품이면 CONFLICT 예외가 발생한다.") + @Test + void like_whenAlreadyLiked_throwsConflict() { + // arrange + productLikeService.like(1L, 2L); + + // act & assert + assertThatThrownBy(() -> productLikeService.like(1L, 2L)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("이미 좋아요한 상품입니다"); + } } @DisplayName("좋아요를 취소할 때, ") @@ -50,6 +64,15 @@ void unlike_whenExists() { // assert assertThat(productLikeRepository.findByUserIdAndProductId(1L, 2L)).isEmpty(); } + + @DisplayName("좋아요 기록이 없으면 NOT_FOUND 예외가 발생한다.") + @Test + void unlike_whenNotExists_throwsNotFound() { + // act & assert + assertThatThrownBy(() -> productLikeService.unlike(1L, 2L)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("좋아요 기록이 없습니다"); + } } @DisplayName("좋아요 존재 여부를 확인할 때, ") @@ -105,4 +128,23 @@ void getLikesByUserId_whenNoLikes() { assertThat(result).isEmpty(); } } + + @DisplayName("좋아요 수를 조회할 때, ") + @Nested + class CountLikes { + + @DisplayName("상품의 좋아요 수를 반환한다.") + @Test + void countLikes_returnsCount() { + // arrange + productLikeRepository.save(ProductLikeModel.create(1L, 10L)); + productLikeRepository.save(ProductLikeModel.create(2L, 10L)); + productLikeRepository.save(ProductLikeModel.create(3L, 20L)); + + // act & assert + assertThat(productLikeService.countLikes(10L)).isEqualTo(2); + assertThat(productLikeService.countLikes(20L)).isEqualTo(1); + assertThat(productLikeService.countLikes(99L)).isEqualTo(0); + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java index 89731450a..9e08b011b 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java @@ -29,7 +29,6 @@ void create_whenValidValues() { assertThat(product.getName()).isEqualTo("에어맥스"); assertThat(product.getPrice().getValue()).isEqualTo(150000); assertThat(product.getStock().getValue()).isEqualTo(100); - assertThat(product.getLikeCount()).isEqualTo(0); assertThat(product.getBrand().getName()).isEqualTo("Nike"); } @@ -171,52 +170,6 @@ void isSoldOut_whenStockExists() { } } - @DisplayName("좋아요 수를 변경할 때, ") - @Nested - class LikeCount { - - @DisplayName("addLikeCount() 호출 시 1 증가한다.") - @Test - void addLikeCount() { - // arrange - ProductModel product = ProductModel.create(createBrand(), "에어맥스", 150000, 100); - - // act - product.addLikeCount(); - - // assert - assertThat(product.getLikeCount()).isEqualTo(1); - } - - @DisplayName("subtractLikeCount() 호출 시 1 감소한다.") - @Test - void subtractLikeCount() { - // arrange - ProductModel product = ProductModel.create(createBrand(), "에어맥스", 150000, 100); - product.addLikeCount(); - product.addLikeCount(); - - // act - product.subtractLikeCount(); - - // assert - assertThat(product.getLikeCount()).isEqualTo(1); - } - - @DisplayName("좋아요 수가 0일 때 subtractLikeCount() 호출 시 0을 유지한다.") - @Test - void subtractLikeCount_whenZero() { - // arrange - ProductModel product = ProductModel.create(createBrand(), "에어맥스", 150000, 100); - - // act - product.subtractLikeCount(); - - // assert - assertThat(product.getLikeCount()).isEqualTo(0); - } - } - @DisplayName("상품을 삭제할 때, ") @Nested class Delete { diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/product/ProductV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/product/ProductV1ApiE2ETest.java index ddf7dabd1..5cf96f036 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/product/ProductV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/product/ProductV1ApiE2ETest.java @@ -183,8 +183,7 @@ void returnsProductDetail_whenProductExists() { () -> assertThat(response.getBody().data().brandName()).isEqualTo("나이키"), () -> assertThat(response.getBody().data().name()).isEqualTo("에어맥스"), () -> assertThat(response.getBody().data().price()).isEqualTo(150000), - () -> assertThat(response.getBody().data().stock()).isEqualTo(100), - () -> assertThat(response.getBody().data().likeCount()).isEqualTo(0) + () -> assertThat(response.getBody().data().stock()).isEqualTo(100) ); } From e36bc89ed7c8d2534b09507b3c604dbbc7456b10 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Tue, 24 Feb 2026 13:17:01 +0900 Subject: [PATCH 061/108] =?UTF-8?q?docs:=20=EC=A2=8B=EC=95=84=EC=9A=94=20?= =?UTF-8?q?=EC=84=A4=EA=B3=84=20=EB=AC=B8=EC=84=9C=20=EC=A0=95=EC=B1=85=20?= =?UTF-8?q?=EC=A0=84=ED=99=98=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- docs/design/like/DESIGN.md | 38 ++++++++++++++++---------------------- 1 file changed, 16 insertions(+), 22 deletions(-) diff --git a/docs/design/like/DESIGN.md b/docs/design/like/DESIGN.md index 01f070890..34d4fe0db 100644 --- a/docs/design/like/DESIGN.md +++ b/docs/design/like/DESIGN.md @@ -11,11 +11,12 @@ ### 예외 및 정책 -- **좋아요 수: Product.likeCount 캐시** — Like 엔티티가 원본 데이터, Product.likeCount는 조회 성능을 위한 파생값(derived data). 찜/취소 시 원자적 증감. -- **API 방식: 엔드포인트 분리** — POST/DELETE 엔드포인트를 분리하고, Facade도 like/unlike 메서드를 각각 제공. 409/404 없음. +- **좋아요 수: COUNT(*) 실시간 쿼리** — likes 테이블에서 COUNT(*)로 조회. Product 엔티티에 캐시 필드를 두지 않는다. +- **API 방식: 엔드포인트 분리** — POST/DELETE 엔드포인트를 분리하고, Facade도 like/unlike 메서드를 각각 제공. +- **중복 방어: 이중 방어** — 애플리케이션 레벨 중복 체크(1차) + DB UNIQUE 제약(2차). 중복 시 CONFLICT 예외. - **회원당 상품당 1개** — userId + productId DB 유니크 제약. 동시성(더블클릭) 시에도 중복 방지. - **삭제된 상품/브랜드의 좋아요** — 목록 조회 시 필터링으로 제외. -- **상품 검증 항상 수행** — 등록/취소 모두 ProductService로 상품 존재 + 삭제 여부 확인. +- **상품 검증** — 등록 시에만 ProductService로 상품 존재 확인. 취소 시에는 Like 도메인 내에서 처리. - **참조 방식** — 모두 ID 참조 (userId, productId). - **물리 삭제(Hard Delete)** — 이력 불필요. UNIQUE 제약과 충돌 방지. @@ -37,21 +38,21 @@ [기능 흐름 - 등록 (POST)] 1. 회원이 productId로 좋아요 등록을 요청한다 2. 해당 상품이 존재하는지 확인한다 (삭제된 상품 불가) -3. 좋아요를 저장한다 -4. 상품의 likeCount를 증가시킨다 +3. 중복 좋아요인지 확인한다 +4. 좋아요를 저장한다 [기능 흐름 - 취소 (DELETE)] 1. 회원이 productId로 좋아요 취소를 요청한다 -2. 해당 상품이 존재하는지 확인한다 (삭제된 상품 불가) +2. 좋아요 기록을 조회한다 3. 좋아요를 삭제한다 -4. 상품의 likeCount를 감소시킨다 [예외] -- productId에 해당하는 상품이 없거나 삭제된 경우 404 반환 +- 등록 시: 상품이 없거나 삭제된 경우 404, 이미 좋아요한 경우 409 +- 취소 시: 좋아요 기록이 없는 경우 404 [조건] - 로그인한 회원만 가능 -- 회원당 상품당 1개만 저장 (유니크 제약) +- 회원당 상품당 1개만 저장 (애플리케이션 체크 + DB 유니크 제약) ``` **UC-L02: 내가 좋아요한 상품 목록 조회** @@ -73,7 +74,7 @@ ## 시퀀스 다이어그램: 좋아요 등록/취소 -> 좋아요는 **Product 검증 + Like 등록/취소 + likeCount 갱신**을 조율해야 하므로 Facade가 필요하다. +> 좋아요 등록은 **Product 존재 확인 + Like 등록**을 조율해야 하므로 Facade가 필요하다. ```mermaid sequenceDiagram @@ -89,11 +90,9 @@ sequenceDiagram LC->>LF: like(userId, productId) Note over LF: @Transactional - LF->>PS: getById(productId) - PS-->>LF: ProductModel - + LF->>PS: validateExists(productId) LF->>LS: like(userId, productId) - LF->>LF: product.addLikeCount() + LS-->>LS: 중복 체크 후 저장 ``` ```mermaid @@ -102,7 +101,6 @@ sequenceDiagram participant LC as LikeController participant LF as LikeFacade - participant PS as ProductService participant LS as LikeService Note left of LC: DELETE /products/{id}/likes @@ -110,11 +108,8 @@ sequenceDiagram LC->>LF: unlike(userId, productId) Note over LF: @Transactional - LF->>PS: getById(productId) - PS-->>LF: ProductModel - LF->>LS: unlike(userId, productId) - LF->>LF: product.subtractLikeCount() + LS-->>LS: 조회 후 삭제 ``` --- @@ -167,9 +162,8 @@ erDiagram | 대상 | 방식 | 이유 | |---|---|---| -| likes | DB UNIQUE 제약 | 더블클릭 시 중복 INSERT 방지. 비관적/분산 락은 과도함 | -| products.like_count | 원자적 UPDATE (`SET like_count = like_count + 1`) | 경합이 심하지 않으므로 비관적 락은 과도함 | +| likes | 애플리케이션 중복 체크(1차) + DB UNIQUE 제약(2차) | 이중 방어. 더블클릭 시에도 중복 INSERT 방지 | ### 참조 무결성 검증 (애플리케이션 레벨) -- 좋아요 토글 시 — product_id가 유효한(삭제되지 않은) 상품인지 확인 +- 좋아요 등록 시 — product_id가 유효한(삭제되지 않은) 상품인지 확인 From e91a0c4afccb6bf4ffdd8a88c97119afd6b96788 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Tue, 24 Feb 2026 13:17:07 +0900 Subject: [PATCH 062/108] =?UTF-8?q?docs:=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=EC=BB=A8=EB=B2=A4=EC=85=98=20=EC=8A=A4=ED=82=AC=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=EC=97=90=EC=84=9C=20likeCount=20=EC=98=88?= =?UTF-8?q?=EC=8B=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../references/infrastructure/infrastructure-convention.md | 1 - .../references/interfaces/swagger-convention.md | 2 -- 2 files changed, 3 deletions(-) diff --git a/.claude/skills/project-convention/references/infrastructure/infrastructure-convention.md b/.claude/skills/project-convention/references/infrastructure/infrastructure-convention.md index 5359734b3..3fac734f3 100644 --- a/.claude/skills/project-convention/references/infrastructure/infrastructure-convention.md +++ b/.claude/skills/project-convention/references/infrastructure/infrastructure-convention.md @@ -260,7 +260,6 @@ private OrderSpecifier buildOrderSpecifier(String sort, QProduct product) { return switch (sort) { case "price_asc" -> product.price.asc(); - case "likes_desc" -> product.likeCount.desc(); default -> product.createdAt.desc(); // latest }; } diff --git a/.claude/skills/project-convention/references/interfaces/swagger-convention.md b/.claude/skills/project-convention/references/interfaces/swagger-convention.md index d7b021395..7c47dd38a 100644 --- a/.claude/skills/project-convention/references/interfaces/swagger-convention.md +++ b/.claude/skills/project-convention/references/interfaces/swagger-convention.md @@ -322,8 +322,6 @@ public record DetailResponse( Long id, String name, int price, - @Schema(description = "좋아요 수") - int likeCount, @Schema(description = "생성일시", example = "2025-01-15T10:30:00+09:00") ZonedDateTime createdAt ) { From 0f9d66b0edb5cb4423c988d0964560b7fc76f0a2 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Tue, 24 Feb 2026 13:27:16 +0900 Subject: [PATCH 063/108] =?UTF-8?q?test:=20=EC=A2=8B=EC=95=84=EC=9A=94=20A?= =?UTF-8?q?PI=20E2E=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=B0=8F=20HTTP=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=ED=8C=8C=EC=9D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 좋아요 등록/취소/목록 조회에 대한 E2E 테스트와 IntelliJ HTTP 요청 파일을 추가하고, AuthFilter에 좋아요 엔드포인트 인증 경로를 등록한다. Co-Authored-By: Claude Opus 4.6 --- .../loopers/interfaces/auth/AuthFilter.java | 7 +- .../interfaces/like/LikeV1ApiE2ETest.java | 366 ++++++++++++++++++ docs/tdd/like/list.md | 31 -- docs/tdd/like/toggle.md | 55 --- http/commerce-api/like-v1.http | 117 ++++++ 5 files changed, 488 insertions(+), 88 deletions(-) create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/like/LikeV1ApiE2ETest.java delete mode 100644 docs/tdd/like/list.md delete mode 100644 docs/tdd/like/toggle.md create mode 100644 http/commerce-api/like-v1.http diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthFilter.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthFilter.java index 573b95f1c..e1351d590 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthFilter.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthFilter.java @@ -26,8 +26,10 @@ public class AuthFilter extends OncePerRequestFilter { private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; private static final Set AUTH_REQUIRED_URLS = Set.of( "/api/v1/users/me", - "/api/v1/users/password" + "/api/v1/users/password", + "/api/v1/users/me/likes" ); + private static final String AUTH_REQUIRED_SUFFIX = "/likes"; private final AuthenticationService authenticationService; private final ObjectMapper objectMapper; @@ -65,7 +67,8 @@ protected void doFilterInternal(HttpServletRequest request, } private boolean requiresAuth(String uri) { - return AUTH_REQUIRED_URLS.contains(uri); + return AUTH_REQUIRED_URLS.contains(uri) + || (uri.startsWith("/api/v1/products/") && uri.endsWith(AUTH_REQUIRED_SUFFIX)); } private void writeUnauthorizedResponse(HttpServletResponse response, String message) throws IOException { diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/like/LikeV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/like/LikeV1ApiE2ETest.java new file mode 100644 index 000000000..5c07ec3ba --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/like/LikeV1ApiE2ETest.java @@ -0,0 +1,366 @@ +package com.loopers.interfaces.like; + +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 com.loopers.domain.brand.BrandModel; +import com.loopers.domain.product.ProductModel; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.like.ProductLikeJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.like.dto.LikeV1Dto; +import com.loopers.interfaces.user.dto.UserV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class LikeV1ApiE2ETest { + + private static final String ENDPOINT_SIGNUP = "/api/v1/users/signup"; + private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; + private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; + + private final TestRestTemplate testRestTemplate; + private final BrandJpaRepository brandJpaRepository; + private final ProductJpaRepository productJpaRepository; + private final ProductLikeJpaRepository productLikeJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + + private Long productId; + + @Autowired + public LikeV1ApiE2ETest( + TestRestTemplate testRestTemplate, + BrandJpaRepository brandJpaRepository, + ProductJpaRepository productJpaRepository, + ProductLikeJpaRepository productLikeJpaRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.brandJpaRepository = brandJpaRepository; + this.productJpaRepository = productJpaRepository; + this.productLikeJpaRepository = productLikeJpaRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @BeforeEach + void setUp() { + // 사용자 등록 + UserV1Dto.SignupRequest signupRequest = new UserV1Dto.SignupRequest( + "testuser1", + "Test1234!", + "홍길동", + "19900101", + "test@example.com" + ); + ParameterizedTypeReference> signupType = new ParameterizedTypeReference<>() {}; + testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, new HttpEntity<>(signupRequest), signupType); + + // 브랜드 및 상품 등록 + BrandModel brand = brandJpaRepository.save(BrandModel.create("나이키")); + ProductModel product = productJpaRepository.save(ProductModel.create(brand, "에어맥스", 150000, 100)); + productId = product.getId(); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private HttpHeaders authHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LOGIN_ID, "testuser1"); + headers.set(HEADER_LOGIN_PW, "Test1234!"); + return headers; + } + + private String likeEndpoint(Long productId) { + return "/api/v1/products/" + productId + "/likes"; + } + + private void likeProduct(Long productId) { + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + testRestTemplate.exchange(likeEndpoint(productId), HttpMethod.POST, new HttpEntity<>(null, authHeaders()), responseType); + } + + @DisplayName("POST /api/v1/products/{productId}/likes") + @Nested + class Like { + + @DisplayName("유효한 인증 헤더로 좋아요를 등록하면, 성공 응답을 반환한다.") + @Test + void returnsSuccess_whenValidRequest() { + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(likeEndpoint(productId), HttpMethod.POST, new HttpEntity<>(null, authHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(productLikeJpaRepository.count()).isEqualTo(1) + ); + } + + @DisplayName("이미 좋아요한 상품에 다시 좋아요하면, 409 CONFLICT 응답을 받는다.") + @Test + void throwsConflict_whenAlreadyLiked() { + // arrange + likeProduct(productId); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(likeEndpoint(productId), HttpMethod.POST, new HttpEntity<>(null, authHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT), + () -> assertThat(productLikeJpaRepository.count()).isEqualTo(1) + ); + } + + @DisplayName("존재하지 않는 상품에 좋아요하면, 404 NOT_FOUND 응답을 받는다.") + @Test + void throwsNotFound_whenProductDoesNotExist() { + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(likeEndpoint(999L), HttpMethod.POST, new HttpEntity<>(null, authHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND), + () -> assertThat(productLikeJpaRepository.count()).isEqualTo(0) + ); + } + + @DisplayName("인증 헤더가 없으면, 401 UNAUTHORIZED 응답을 받는다.") + @Test + void throwsUnauthorized_whenNoAuthHeaders() { + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(likeEndpoint(productId), HttpMethod.POST, null, responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED), + () -> assertThat(productLikeJpaRepository.count()).isEqualTo(0) + ); + } + + @DisplayName("잘못된 비밀번호로 요청하면, 401 UNAUTHORIZED 응답을 받는다.") + @Test + void throwsUnauthorized_whenPasswordIsIncorrect() { + // arrange + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LOGIN_ID, "testuser1"); + headers.set(HEADER_LOGIN_PW, "Wrong1234!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(likeEndpoint(productId), HttpMethod.POST, new HttpEntity<>(null, headers), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED), + () -> assertThat(productLikeJpaRepository.count()).isEqualTo(0) + ); + } + } + + @DisplayName("DELETE /api/v1/products/{productId}/likes") + @Nested + class Unlike { + + @DisplayName("좋아요한 상품의 좋아요를 취소하면, 성공 응답을 반환한다.") + @Test + void returnsSuccess_whenLikeExists() { + // arrange + likeProduct(productId); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(likeEndpoint(productId), HttpMethod.DELETE, new HttpEntity<>(null, authHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(productLikeJpaRepository.count()).isEqualTo(0) + ); + } + + @DisplayName("좋아요 기록이 없는 상품의 좋아요를 취소하면, 404 NOT_FOUND 응답을 받는다.") + @Test + void throwsNotFound_whenLikeDoesNotExist() { + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(likeEndpoint(productId), HttpMethod.DELETE, new HttpEntity<>(null, authHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) + ); + } + + @DisplayName("인증 헤더가 없으면, 401 UNAUTHORIZED 응답을 받는다.") + @Test + void throwsUnauthorized_whenNoAuthHeaders() { + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(likeEndpoint(productId), HttpMethod.DELETE, null, responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED) + ); + } + + @DisplayName("잘못된 비밀번호로 요청하면, 401 UNAUTHORIZED 응답을 받는다.") + @Test + void throwsUnauthorized_whenPasswordIsIncorrect() { + // arrange + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LOGIN_ID, "testuser1"); + headers.set(HEADER_LOGIN_PW, "Wrong1234!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(likeEndpoint(productId), HttpMethod.DELETE, new HttpEntity<>(null, headers), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED) + ); + } + } + + @DisplayName("GET /api/v1/users/me/likes") + @Nested + class GetMyLikes { + + private static final String ENDPOINT_MY_LIKES = "/api/v1/users/me/likes"; + + @DisplayName("좋아요한 상품이 있으면, 좋아요 목록을 반환한다.") + @Test + void returnsLikeList_whenLikesExist() { + // arrange + likeProduct(productId); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_MY_LIKES, HttpMethod.GET, new HttpEntity<>(null, authHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().items()).hasSize(1), + () -> assertThat(response.getBody().data().items().get(0).productId()).isEqualTo(productId) + ); + } + + @DisplayName("좋아요한 상품이 없으면, 빈 목록을 반환한다.") + @Test + void returnsEmptyList_whenNoLikesExist() { + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_MY_LIKES, HttpMethod.GET, new HttpEntity<>(null, authHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().items()).isEmpty() + ); + } + + @DisplayName("좋아요 후 취소하면, 목록에서 사라진다.") + @Test + void returnsEmptyList_afterUnlike() { + // arrange + likeProduct(productId); + + ParameterizedTypeReference> unlikeType = new ParameterizedTypeReference<>() {}; + testRestTemplate.exchange(likeEndpoint(productId), HttpMethod.DELETE, new HttpEntity<>(null, authHeaders()), unlikeType); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_MY_LIKES, HttpMethod.GET, new HttpEntity<>(null, authHeaders()), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().items()).isEmpty() + ); + } + + @DisplayName("인증 헤더가 없으면, 401 UNAUTHORIZED 응답을 받는다.") + @Test + void throwsUnauthorized_whenNoAuthHeaders() { + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_MY_LIKES, HttpMethod.GET, null, responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED) + ); + } + + @DisplayName("잘못된 비밀번호로 요청하면, 401 UNAUTHORIZED 응답을 받는다.") + @Test + void throwsUnauthorized_whenPasswordIsIncorrect() { + // arrange + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LOGIN_ID, "testuser1"); + headers.set(HEADER_LOGIN_PW, "Wrong1234!"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_MY_LIKES, HttpMethod.GET, new HttpEntity<>(null, headers), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED) + ); + } + } +} diff --git a/docs/tdd/like/list.md b/docs/tdd/like/list.md deleted file mode 100644 index 411e953ea..000000000 --- a/docs/tdd/like/list.md +++ /dev/null @@ -1,31 +0,0 @@ -# UC-L02: 좋아요 목록 조회 - -> DESIGN.md: `docs/design/like/DESIGN.md` - ---- - -## Domain — Service - -### 사용자별 좋아요 목록 조회 -- [x] 🔴 Red: 사용자 ID로 좋아요 목록을 조회하면, 해당 사용자의 좋아요 목록이 반환된다 -- [ ] 🟢 Green -- [ ] 🔵 Refactor - -### 좋아요 없는 사용자 조회 -- [x] 🔴 Red: 좋아요가 없는 사용자 ID로 조회하면, 빈 목록이 반환된다 -- [ ] 🟢 Green -- [ ] 🔵 Refactor - ---- - -## Application — Facade - -### 좋아요 목록을 LikeInfo로 반환 -- [ ] 🔴 Red: 사용자의 좋아요 목록을 조회하면, LikeInfo 목록을 반환한다 -- [ ] 🟢 Green -- [ ] 🔵 Refactor - -### 삭제된 상품 필터링 -- [ ] 🔴 Red: 삭제된 상품의 좋아요는 목록에서 제외된다 -- [ ] 🟢 Green -- [ ] 🔵 Refactor diff --git a/docs/tdd/like/toggle.md b/docs/tdd/like/toggle.md deleted file mode 100644 index 40f244927..000000000 --- a/docs/tdd/like/toggle.md +++ /dev/null @@ -1,55 +0,0 @@ -# UC-L01: 좋아요 토글 (등록/취소) - -> DESIGN.md: `docs/design/like/DESIGN.md` - ---- - -## Domain — Entity - -### ProductLikeModel 생성 -- [x] 🔴 Red: 유효한 userId와 productId가 주어지면, 정상적으로 생성된다 -- [x] 🟢 Green -- [x] 🔵 Refactor: skip - -### ProductLikeModel userId 검증 -- [x] 🔴 Red: userId가 null이면 예외가 발생한다 -- [x] 🟢 Green -- [x] 🔵 Refactor: validate() 하나로 통합 - -### ProductLikeModel productId 검증 -- [x] 🔴 Red: productId가 null이면 예외가 발생한다 -- [x] 🟢 Green -- [x] 🔵 Refactor: skip - ---- - -## Domain — Service - -### 좋아요 등록 (toggle → true) -- [x] 🔴 Red: 좋아요가 없으면, 좋아요를 등록하고 true를 반환한다 -- [ ] 🟢 Green -- [ ] 🔵 Refactor - -### 좋아요 취소 (toggle → false) -- [x] 🔴 Red: 좋아요가 이미 존재하면, 좋아요를 삭제하고 false를 반환한다 -- [ ] 🟢 Green -- [ ] 🔵 Refactor - ---- - -## Application — Facade - -### 상품 검증 + toggleLike 호출 -- [ ] 🔴 Red: 상품을 검증하고 ProductLikeService.toggleLike를 호출한다 -- [ ] 🟢 Green -- [ ] 🔵 Refactor - -### 좋아요 등록 시 likeCount 증가 -- [ ] 🔴 Red: 좋아요가 등록되면 product.addLikeCount()를 호출한다 -- [ ] 🟢 Green -- [ ] 🔵 Refactor - -### 좋아요 취소 시 likeCount 감소 -- [ ] 🔴 Red: 좋아요가 취소되면 product.subtractLikeCount()를 호출한다 -- [ ] 🟢 Green -- [ ] 🔵 Refactor diff --git a/http/commerce-api/like-v1.http b/http/commerce-api/like-v1.http new file mode 100644 index 000000000..c89d000c4 --- /dev/null +++ b/http/commerce-api/like-v1.http @@ -0,0 +1,117 @@ +### 좋아요 등록 - 성공 케이스 (사전: 회원가입 + 상품 등록 필요) +POST {{commerce-api}}/api/v1/products/1/likes +X-Loopers-LoginId: testuser1 +X-Loopers-LoginPw: Test1234! + +### 좋아요 등록 - 이미 좋아요한 상품 (실패, 409 CONFLICT) +POST {{commerce-api}}/api/v1/products/1/likes +X-Loopers-LoginId: testuser1 +X-Loopers-LoginPw: Test1234! + +### 좋아요 등록 - 존재하지 않는 상품 (실패, 404 NOT_FOUND) +POST {{commerce-api}}/api/v1/products/999/likes +X-Loopers-LoginId: testuser1 +X-Loopers-LoginPw: Test1234! + +### 좋아요 등록 - 인증 헤더 누락 (실패, 401 UNAUTHORIZED) +POST {{commerce-api}}/api/v1/products/1/likes + +### 좋아요 등록 - 잘못된 비밀번호 (실패, 401 UNAUTHORIZED) +POST {{commerce-api}}/api/v1/products/1/likes +X-Loopers-LoginId: testuser1 +X-Loopers-LoginPw: Wrong1234! + +############################################### +# 좋아요 취소 API +############################################### + +### 좋아요 취소 - 성공 케이스 +DELETE {{commerce-api}}/api/v1/products/1/likes +X-Loopers-LoginId: testuser1 +X-Loopers-LoginPw: Test1234! + +### 좋아요 취소 - 좋아요 기록이 없는 상품 (실패, 404 NOT_FOUND) +DELETE {{commerce-api}}/api/v1/products/1/likes +X-Loopers-LoginId: testuser1 +X-Loopers-LoginPw: Test1234! + +### 좋아요 취소 - 인증 헤더 누락 (실패, 401 UNAUTHORIZED) +DELETE {{commerce-api}}/api/v1/products/1/likes + +### 좋아요 취소 - 잘못된 비밀번호 (실패, 401 UNAUTHORIZED) +DELETE {{commerce-api}}/api/v1/products/1/likes +X-Loopers-LoginId: testuser1 +X-Loopers-LoginPw: Wrong1234! + +############################################### +# 내 좋아요 목록 조회 API +############################################### + +### 내 좋아요 목록 조회 - 성공 케이스 +GET {{commerce-api}}/api/v1/users/me/likes +X-Loopers-LoginId: testuser1 +X-Loopers-LoginPw: Test1234! + +### 내 좋아요 목록 조회 - 좋아요 없을 때 빈 목록 +GET {{commerce-api}}/api/v1/users/me/likes +X-Loopers-LoginId: testuser1 +X-Loopers-LoginPw: Test1234! + +### 내 좋아요 목록 조회 - 인증 헤더 누락 (실패, 401 UNAUTHORIZED) +GET {{commerce-api}}/api/v1/users/me/likes + +### 내 좋아요 목록 조회 - 잘못된 비밀번호 (실패, 401 UNAUTHORIZED) +GET {{commerce-api}}/api/v1/users/me/likes +X-Loopers-LoginId: testuser1 +X-Loopers-LoginPw: Wrong1234! + +############################################### +# 시나리오 테스트 (전체 플로우) +############################################### + +### [STEP 1] 회원가입 +POST {{commerce-api}}/api/v1/users/signup +Content-Type: application/json + +{ + "loginId": "likeuser1", + "password": "Test1234!", + "name": "좋아요테스터", + "birthDate": "19950505", + "email": "like@example.com" +} + +### [STEP 2] 좋아요 등록 (상품 1) +POST {{commerce-api}}/api/v1/products/1/likes +X-Loopers-LoginId: likeuser1 +X-Loopers-LoginPw: Test1234! + +### [STEP 3] 좋아요 등록 (상품 2) +POST {{commerce-api}}/api/v1/products/2/likes +X-Loopers-LoginId: likeuser1 +X-Loopers-LoginPw: Test1234! + +### [STEP 4] 내 좋아요 목록 조회 (2개) +GET {{commerce-api}}/api/v1/users/me/likes +X-Loopers-LoginId: likeuser1 +X-Loopers-LoginPw: Test1234! + +### [STEP 5] 좋아요 취소 (상품 1) +DELETE {{commerce-api}}/api/v1/products/1/likes +X-Loopers-LoginId: likeuser1 +X-Loopers-LoginPw: Test1234! + +### [STEP 6] 내 좋아요 목록 조회 (1개) +GET {{commerce-api}}/api/v1/users/me/likes +X-Loopers-LoginId: likeuser1 +X-Loopers-LoginPw: Test1234! + +### [STEP 7] 중복 좋아요 등록 시도 (실패, 409 CONFLICT) +POST {{commerce-api}}/api/v1/products/2/likes +X-Loopers-LoginId: likeuser1 +X-Loopers-LoginPw: Test1234! + +### [STEP 8] 이미 취소한 좋아요 다시 취소 시도 (실패, 404 NOT_FOUND) +DELETE {{commerce-api}}/api/v1/products/1/likes +X-Loopers-LoginId: likeuser1 +X-Loopers-LoginPw: Test1234! From f660be25328147dfe650de00560bb26ba44f25f3 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Tue, 24 Feb 2026 14:10:56 +0900 Subject: [PATCH 064/108] =?UTF-8?q?docs:=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=EC=BB=A8=EB=B2=A4=EC=85=98=20=EC=8A=A4=ED=82=AC?= =?UTF-8?q?=EC=97=90=20=EC=9D=B8=EB=9D=BC=EC=9D=B8=20=EB=B3=80=EC=88=98=20?= =?UTF-8?q?=EC=BB=A8=EB=B2=A4=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .claude/skills/project-convention/SKILL.md | 15 ++ .../common/inline-variable-convention.md | 229 ++++++++++++++++++ 2 files changed, 244 insertions(+) create mode 100644 .claude/skills/project-convention/references/common/inline-variable-convention.md diff --git a/.claude/skills/project-convention/SKILL.md b/.claude/skills/project-convention/SKILL.md index a3bd1206c..9fb996836 100644 --- a/.claude/skills/project-convention/SKILL.md +++ b/.claude/skills/project-convention/SKILL.md @@ -64,6 +64,18 @@ ApiResponse.failValidation(code, message, fieldErrors) // Validation 에 - 테스트 더블: Domain → **Fake**, Application → **Mockito**, E2E → **실제 Bean** - DB 격리: `@AfterEach` + `DatabaseCleanUp.truncateAllTables()` +**인라인 변수 & 코드 스타일** + +- 일회용 변수(1회 참조)는 **인라인** — 객체 생성과 사용을 한 표현식으로 응집 +- 변수 유지 조건: **2회 이상 참조**, **의미 경계**, **3단계 이상 중첩** +- 줄바꿈: **Chop-down** (첫 인자부터 줄바꿈 + 8칸 continuation indent) +- 닫는 괄호 `))` 는 마지막 인자 뒤에 붙인다 (별도 줄 X) +- **괄호 정렬(Align to parenthesis) 사용 금지** + +``` +판단: 변수 1회 참조? → 인라인 / 중첩 3단계+? → 의미 경계에서 변수 추출 +``` + --- ### Interface 계층 @@ -170,6 +182,9 @@ ApiResponse.failValidation(code, message, fieldErrors) // Validation 에 **테스트** → `references/common/test-convention.md` - 테스트 클래스 생성, 네이밍, 단위/통합/E2E 구분, Fake vs Mockito, DB 정리 +**인라인 변수 & 코드 스타일** → `references/common/inline-variable-convention.md` +- 일회용 변수 인라인 판단, 변수 유지 조건, Chop-down 줄바꿈, 닫는 괄호 위치, 레이어별 예시 + ### Interface 계층 **API 설계** → `references/interfaces/api-convention.md` diff --git a/.claude/skills/project-convention/references/common/inline-variable-convention.md b/.claude/skills/project-convention/references/common/inline-variable-convention.md new file mode 100644 index 000000000..88778fd33 --- /dev/null +++ b/.claude/skills/project-convention/references/common/inline-variable-convention.md @@ -0,0 +1,229 @@ +# 인라인 변수 컨벤션 (Inline Variable Convention) + +> 일회용 지역변수를 제거하고, 객체 생성과 사용을 한 흐름으로 응집시킨다. + +## 목차 + +1. [적용 범위](#적용-범위) +2. [핵심 원칙](#핵심-원칙) +3. [줄바꿈 & 들여쓰기 스타일](#줄바꿈--들여쓰기-스타일) +4. [레이어별 적용 예시](#레이어별-적용-예시) +5. [판단 플로우차트](#판단-플로우차트) +6. [체크리스트](#체크리스트) + +--- + +## 적용 범위 + +모든 레이어 (Controller, Facade, Service, Domain) + +--- + +## 핵심 원칙 + +### 1. 일회용 지역변수는 인라인한다 + +변수가 **생성 직후 단 한 번만 참조**되면 인라인한다. + +```java +// ❌ Bad — 일회용 변수가 코드만 늘린다 +OrderCriteria.ListByDate criteria = new OrderCriteria.ListByDate(startAt, endAt); +List results = orderFacade.getMyOrders(loginUser.id(), criteria); +OrderResponse.ListResponse listResponse = new OrderResponse.ListResponse( + results.stream().map(OrderResponse.OrderSummary::from).toList()); +return ApiResponse.success(listResponse); + +// ✅ Good — 흐름이 한눈에 읽힌다 +List results = + orderFacade.getMyOrders( + loginUser.id(), + new OrderCriteria.ListByDate(startAt, endAt)); + +return ApiResponse.success( + new OrderResponse.ListResponse( + results.stream().map(OrderResponse.OrderSummary::from).toList())); +``` + +### 2. 객체 생성과 사용을 분리하지 않는다 + +객체를 만들어 변수에 담고 → 다른 곳에 넘기는 2단계 패턴은 응집도를 떨어뜨린다. + +```java +// ❌ Bad — 생성과 사용이 분리되어 응집도 저하 +OrderItem orderItem = new OrderItem(product, quantity); +order.add(orderItem); + +// ✅ Good — 생성과 사용이 한 표현식 +order.add(new OrderItem(product, quantity)); +``` + +```java +// ❌ Bad +Address address = new Address(city, street, zipCode); +member.changeAddress(address); + +// ✅ Good +member.changeAddress(new Address(city, street, zipCode)); +``` + +### 3. 변수를 유지하는 경우 + +다음 조건 중 하나라도 해당하면 변수로 추출한다. + +| 조건 | 이유 | 예시 | +|------|------|------| +| **2회 이상 참조** | 중복 호출 방지 | `results`를 응답 변환 + 로깅에 사용 | +| **의미 경계가 달라지는 지점** | 가독성, 디버깅 용이 | Facade/Service 호출 결과 | +| **인라인 시 한 줄이 3단계 이상 중첩** | 가독성 한계 | 아래 예시 참고 | + +```java +// 인라인 시 중첩이 너무 깊어지는 경우 → 변수 추출 +// ❌ 과도한 인라인 +return ApiResponse.success( + new OrderResponse.DetailResponse( + OrderResponse.OrderDetail.from( + orderFacade.getOrderDetail( + loginUser.id(), + new OrderCriteria.Detail(orderId))))); + +// ✅ 의미 경계에서 끊는다 +OrderResult.OrderDetail result = + orderFacade.getOrderDetail( + loginUser.id(), + new OrderCriteria.Detail(orderId)); + +return ApiResponse.success( + new OrderResponse.DetailResponse( + OrderResponse.OrderDetail.from(result))); +``` + +--- + +## 줄바꿈 & 들여쓰기 스타일 + +### Chop-down 스타일 (권장) + +메서드 인자가 한 줄에 안 들어가면, **첫 번째 인자부터 줄바꿈** + **8칸(continuation indent)** 적용한다. + +```java +// 한 줄에 들어가면 그대로 +order.add(new OrderItem(product, quantity)); + +// 안 들어가면 chop-down +List results = + orderFacade.getMyOrders( + loginUser.id(), + new OrderCriteria.ListByDate(startAt, endAt)); +``` + +**왜 이 스타일인가?** + +| 스타일 | 문제점 | +|--------|--------| +| 괄호 정렬 (Align to parenthesis) | 메서드명 길이에 따라 들여쓰기가 변동, diff가 지저분함 | +| **Chop-down (권장)** | **일관된 indent, 리네임해도 diff 깔끔** | + +### IntelliJ 설정 + +``` +Settings > Editor > Code Style > Java > Wrapping and Braces +├── Method call arguments: Chop down if long +├── Continuation indent: 8 +└── Align when multiline: OFF (체크 해제) +``` + +### 닫는 괄호 위치 + +연쇄된 닫는 괄호 `))` 는 마지막 인자 뒤에 붙인다 (별도 줄 X). + +```java +// ✅ 닫는 괄호는 마지막 인자에 붙인다 +return ApiResponse.success( + new OrderResponse.ListResponse( + results.stream().map(OrderResponse.OrderSummary::from).toList())); + +// ❌ 닫는 괄호를 별도 줄에 내리지 않는다 +return ApiResponse.success( + new OrderResponse.ListResponse( + results.stream().map(OrderResponse.OrderSummary::from).toList() + ) +); +``` + +--- + +## 레이어별 적용 예시 + +### Controller + +```java +@GetMapping +@Override +public ApiResponse list( + @Login LoginUser loginUser, + @RequestParam ZonedDateTime startAt, + @RequestParam ZonedDateTime endAt +) { + List results = + orderFacade.getMyOrders( + loginUser.id(), + new OrderCriteria.ListByDate(startAt, endAt)); + + return ApiResponse.success( + new OrderResponse.ListResponse( + results.stream().map(OrderResponse.OrderSummary::from).toList())); +} +``` + +### Service / Facade + +```java +public OrderResult.OrderDetail getOrderDetail(Long userId, OrderCriteria.Detail criteria) { + Order order = orderReader.read(criteria.orderId()); + order.validateOwner(userId); + + return OrderResult.OrderDetail.from(order); +} +``` + +### Domain + +```java +// ❌ Bad +public void addItem(Product product, int quantity) { + OrderItem orderItem = new OrderItem(product, quantity, product.getPrice()); + this.orderItems.add(orderItem); + this.totalAmount = calculateTotal(); +} + +// ✅ Good +public void addItem(Product product, int quantity) { + this.orderItems.add(new OrderItem(product, quantity, product.getPrice())); + this.totalAmount = calculateTotal(); +} +``` + +--- + +## 판단 플로우차트 + +``` +변수 선언을 만났다 + └─ 이 변수가 2회 이상 참조되는가? + ├─ YES → 변수 유지 + └─ NO → 인라인 시 중첩이 3단계 이상인가? + ├─ YES → 의미 경계에서 변수 추출 + └─ NO → 인라인한다 +``` + +--- + +## 체크리스트 + +- [ ] 일회용 지역변수(생성 직후 1회만 참조)를 인라인했는가? +- [ ] 객체 생성과 사용이 한 표현식으로 응집되어 있는가? +- [ ] 2회 이상 참조되는 변수는 변수로 유지했는가? +- [ ] 인라인 시 3단계 이상 중첩이 발생하면 의미 경계에서 변수를 추출했는가? +- [ ] 줄바꿈이 Chop-down 스타일(8칸 continuation indent)을 따르는가? +- [ ] 닫는 괄호 `))` 가 마지막 인자 뒤에 붙어 있는가? (별도 줄 X) +- [ ] 괄호 정렬(Align to parenthesis)을 사용하지 않았는가? From 1353ab0264cc3cbcb3247adbf790ddf3153ad3d2 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Tue, 24 Feb 2026 14:28:12 +0900 Subject: [PATCH 065/108] =?UTF-8?q?refactor:=20=EC=9D=B8=EB=9D=BC=EC=9D=B8?= =?UTF-8?q?=20=EB=B3=80=EC=88=98=20=EC=BB=A8=EB=B2=A4=EC=85=98=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=20(Domain,=20Controller,=20DTO=20=EA=B3=84=EC=B8=B5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 일회용 변수 인라인, 생성-사용 분리 제거, 닫는 괄호 위치 수정, 4칸→8칸 continuation indent 적용 Co-Authored-By: Claude Opus 4.6 --- .../application/like/dto/LikeResult.java | 2 +- .../product/dto/ProductResult.java | 19 +++++++++--------- .../application/user/dto/UserResult.java | 11 +++++----- .../com/loopers/domain/brand/BrandModel.java | 3 +-- .../loopers/domain/brand/BrandService.java | 3 +-- .../domain/product/ProductService.java | 3 +-- .../domain/user/AuthenticationService.java | 4 +--- .../brand/AdminBrandV1Controller.java | 19 +++++++++--------- .../interfaces/like/LikeV1Controller.java | 12 +++++------ .../product/AdminProductV1Controller.java | 19 +++++++++--------- .../product/ProductV1Controller.java | 19 +++++++++--------- .../product/dto/AdminProductV1Dto.java | 14 ++++++------- .../interfaces/product/dto/ProductV1Dto.java | 10 ++++------ .../interfaces/user/dto/UserV1Dto.java | 20 +++++++++---------- 14 files changed, 71 insertions(+), 87 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/dto/LikeResult.java b/apps/commerce-api/src/main/java/com/loopers/application/like/dto/LikeResult.java index 985e8c644..89d5eb5eb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/dto/LikeResult.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/dto/LikeResult.java @@ -12,6 +12,6 @@ public static List from(List models) { return models.stream() .map(model -> new LikeResult(model.getId(), model.getUserId(), model.getProductId(), model.getCreatedAt())) - .toList(); + .toList(); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/dto/ProductResult.java b/apps/commerce-api/src/main/java/com/loopers/application/product/dto/ProductResult.java index 1b1fa13bf..d822227df 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/dto/ProductResult.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/dto/ProductResult.java @@ -16,15 +16,14 @@ public record ProductResult( ) { public static ProductResult from(ProductModel model) { return new ProductResult( - model.getId(), - model.getBrand().getId(), - model.getBrand().getName(), - model.getName(), - model.getPrice().getValue(), - model.getStock().getValue(), - model.getCreatedAt(), - model.getUpdatedAt(), - model.getDeletedAt() - ); + model.getId(), + model.getBrand().getId(), + model.getBrand().getName(), + model.getName(), + model.getPrice().getValue(), + model.getStock().getValue(), + model.getCreatedAt(), + model.getUpdatedAt(), + model.getDeletedAt()); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/dto/UserResult.java b/apps/commerce-api/src/main/java/com/loopers/application/user/dto/UserResult.java index b95f2fdf5..6d5cb10fb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/dto/UserResult.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/dto/UserResult.java @@ -11,11 +11,10 @@ public record UserResult( ) { public static UserResult from(UserModel model) { return new UserResult( - model.getId(), - model.getLoginId().getValue(), - model.getName().getValue(), - model.getBirthDate().toDateString(), - model.getEmail().getMail() - ); + model.getId(), + model.getLoginId().getValue(), + model.getName().getValue(), + model.getBirthDate().toDateString(), + model.getEmail().getMail()); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandModel.java index 5358b32db..2cc44b0d6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandModel.java @@ -27,8 +27,7 @@ private BrandModel(String name) { public static BrandModel create(String name) { validateName(name); - BrandModel model = new BrandModel(name); - return model; + return new BrandModel(name); } // === 도메인 로직 === // diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java index f52b9626b..12b512cac 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java @@ -18,8 +18,7 @@ public void register(String name) { throw new CoreException(BrandErrorCode.DUPLICATE_NAME); } - BrandModel brandModel = BrandModel.create(name); - brandRepository.save(brandModel); + brandRepository.save(BrandModel.create(name)); } @Transactional(readOnly = true) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java index 04daad773..f278ae7ce 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -16,8 +16,7 @@ public class ProductService { @Transactional public void register(BrandModel brand, String name, int price, int stock) { - ProductModel productModel = ProductModel.create(brand, name, price, stock); - productRepository.save(productModel); + productRepository.save(ProductModel.create(brand, name, price, stock)); } @Transactional(readOnly = true) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/AuthenticationService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/AuthenticationService.java index 36d3edca6..624699b7c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/AuthenticationService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/AuthenticationService.java @@ -13,9 +13,7 @@ public class AuthenticationService { private final PasswordEncoder passwordEncoder; public UserModel authenticate(String loginIdValue, String rawPassword) { - LoginId loginId = new LoginId(loginIdValue); - - UserModel user = userRepository.find(loginId) + UserModel user = userRepository.find(new LoginId(loginIdValue)) .orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED, "로그인 ID 또는 비밀번호가 일치하지 않습니다.")); if (!user.getPassword().matches(rawPassword, passwordEncoder)) { diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/AdminBrandV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/AdminBrandV1Controller.java index bb040f0f2..e3bd22b77 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/AdminBrandV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/brand/AdminBrandV1Controller.java @@ -33,16 +33,15 @@ public ApiResponse list( @RequestParam(defaultValue = "20") int size ) { Page brandInfoPage = brandFacade.getBrands(PageRequest.of(page, size)); - AdminBrandV1Dto.ListResponse listResponse = new AdminBrandV1Dto.ListResponse( - brandInfoPage.getNumber(), - brandInfoPage.getSize(), - brandInfoPage.getTotalElements(), - brandInfoPage.getTotalPages(), - brandInfoPage.getContent().stream() - .map(AdminBrandV1Dto.ListResponse.ListItem::from) - .toList() - ); - return ApiResponse.success(listResponse); + return ApiResponse.success( + new AdminBrandV1Dto.ListResponse( + brandInfoPage.getNumber(), + brandInfoPage.getSize(), + brandInfoPage.getTotalElements(), + brandInfoPage.getTotalPages(), + brandInfoPage.getContent().stream() + .map(AdminBrandV1Dto.ListResponse.ListItem::from) + .toList())); } @GetMapping("/{brandId}") diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/like/LikeV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/like/LikeV1Controller.java index df751bd49..1a6485786 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/like/LikeV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/like/LikeV1Controller.java @@ -42,11 +42,11 @@ public ApiResponse getMyLikes( @Login LoginUser loginUser ) { List results = likeFacade.getMyLikedProducts(loginUser.id()); - LikeV1Dto.ListResponse listResponse = new LikeV1Dto.ListResponse( - results.stream() - .map(LikeV1Dto.ListResponse.ListItem::from) - .toList() - ); - return ApiResponse.success(listResponse); + + return ApiResponse.success( + new LikeV1Dto.ListResponse( + results.stream() + .map(LikeV1Dto.ListResponse.ListItem::from) + .toList())); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/product/AdminProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/AdminProductV1Controller.java index fcb332be1..d028994b1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/product/AdminProductV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/AdminProductV1Controller.java @@ -37,16 +37,15 @@ public ApiResponse list( ? productFacade.getProductsByBrandId(brandId, PageRequest.of(page, size)) : productFacade.getProducts(PageRequest.of(page, size)); - AdminProductV1Dto.ListResponse listResponse = new AdminProductV1Dto.ListResponse( - productInfoPage.getNumber(), - productInfoPage.getSize(), - productInfoPage.getTotalElements(), - productInfoPage.getTotalPages(), - productInfoPage.getContent().stream() - .map(AdminProductV1Dto.ListResponse.ListItem::from) - .toList() - ); - return ApiResponse.success(listResponse); + return ApiResponse.success( + new AdminProductV1Dto.ListResponse( + productInfoPage.getNumber(), + productInfoPage.getSize(), + productInfoPage.getTotalElements(), + productInfoPage.getTotalPages(), + productInfoPage.getContent().stream() + .map(AdminProductV1Dto.ListResponse.ListItem::from) + .toList())); } @GetMapping("/{productId}") diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1Controller.java index d8c72ad08..f010c4dc1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1Controller.java @@ -30,16 +30,15 @@ public ApiResponse list( ? productFacade.getProductsWithActiveBrandByBrandId(brandId, pageRequest) : productFacade.getProductsWithActiveBrand(pageRequest); - ProductV1Dto.ListResponse listResponse = new ProductV1Dto.ListResponse( - productInfoPage.getNumber(), - productInfoPage.getSize(), - productInfoPage.getTotalElements(), - productInfoPage.getTotalPages(), - productInfoPage.getContent().stream() - .map(ProductV1Dto.ListResponse.ListItem::from) - .toList() - ); - return ApiResponse.success(listResponse); + return ApiResponse.success( + new ProductV1Dto.ListResponse( + productInfoPage.getNumber(), + productInfoPage.getSize(), + productInfoPage.getTotalElements(), + productInfoPage.getTotalPages(), + productInfoPage.getContent().stream() + .map(ProductV1Dto.ListResponse.ListItem::from) + .toList())); } @GetMapping("/{productId}") diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/product/dto/AdminProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/dto/AdminProductV1Dto.java index 71d0445de..c904c71c5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/product/dto/AdminProductV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/dto/AdminProductV1Dto.java @@ -58,10 +58,9 @@ public record DetailResponse( ) { public static DetailResponse from(ProductResult info) { return new DetailResponse( - info.id(), info.brandId(), info.brandName(), - info.name(), info.price(), info.stock(), - info.createdAt(), info.updatedAt(), info.deletedAt() - ); + info.id(), info.brandId(), info.brandName(), + info.name(), info.price(), info.stock(), + info.createdAt(), info.updatedAt(), info.deletedAt()); } } @@ -85,10 +84,9 @@ public record ListItem( ) { public static ListItem from(ProductResult info) { return new ListItem( - info.id(), info.brandId(), info.brandName(), - info.name(), info.price(), info.stock(), - info.createdAt(), info.updatedAt(), info.deletedAt() - ); + info.id(), info.brandId(), info.brandName(), + info.name(), info.price(), info.stock(), + info.createdAt(), info.updatedAt(), info.deletedAt()); } } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/product/dto/ProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/dto/ProductV1Dto.java index 75d0d45d4..7ea335198 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/product/dto/ProductV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/dto/ProductV1Dto.java @@ -15,9 +15,8 @@ public record DetailResponse( ) { public static DetailResponse from(ProductResult info) { return new DetailResponse( - info.id(), info.brandId(), info.brandName(), - info.name(), info.price(), info.stock() - ); + info.id(), info.brandId(), info.brandName(), + info.name(), info.price(), info.stock()); } } @@ -37,9 +36,8 @@ public record ListItem( ) { public static ListItem from(ProductResult info) { return new ListItem( - info.id(), info.brandId(), info.brandName(), - info.name(), info.price() - ); + info.id(), info.brandId(), info.brandName(), + info.name(), info.price()); } } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/user/dto/UserV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/user/dto/UserV1Dto.java index 38c3b5850..5e36e9cae 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/user/dto/UserV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/user/dto/UserV1Dto.java @@ -48,12 +48,11 @@ public record SignupResponse( ) { public static SignupResponse from(UserResult info) { return new SignupResponse( - info.id(), - info.loginId(), - maskName(info.name()), - info.birthDate(), - info.email() - ); + info.id(), + info.loginId(), + maskName(info.name()), + info.birthDate(), + info.email()); } } @@ -65,11 +64,10 @@ public record MyInfoResponse( ) { public static MyInfoResponse from(UserResult info) { return new MyInfoResponse( - info.loginId(), - maskName(info.name()), - info.birthDate(), - info.email() - ); + info.loginId(), + maskName(info.name()), + info.birthDate(), + info.email()); } } From 51bb6da01d76bf3ca81fde7722ea51c17da1a5ef Mon Sep 17 00:00:00 2001 From: plan11plan Date: Tue, 24 Feb 2026 14:40:08 +0900 Subject: [PATCH 066/108] =?UTF-8?q?refactor:=20Facade=20private=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=EB=A5=BC=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EA=B3=84=EC=B8=B5=EC=9C=BC=EB=A1=9C=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../java/com/loopers/domain/product/ProductErrorCode.java | 3 ++- .../main/java/com/loopers/domain/product/ProductModel.java | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductErrorCode.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductErrorCode.java index 16135bad1..d3b42141d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductErrorCode.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductErrorCode.java @@ -8,7 +8,8 @@ @Getter @RequiredArgsConstructor public enum ProductErrorCode implements ErrorCode { - NOT_FOUND(HttpStatus.NOT_FOUND, "PRODUCT_001", "상품을 찾을 수 없습니다."); + NOT_FOUND(HttpStatus.NOT_FOUND, "PRODUCT_001", "상품을 찾을 수 없습니다."), + PRICE_MISMATCH(HttpStatus.BAD_REQUEST, "PRODUCT_002", "상품 가격이 변경되었습니다."); private final HttpStatus status; private final String code; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java index 82e5a6c3e..d8357de08 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java @@ -61,6 +61,12 @@ public void update(String name, int price, int stock) { this.stock = new Stock(stock); } + public void validatePrice(int expectedPrice) { + if (expectedPrice != this.price.getValue()) { + throw new CoreException(ProductErrorCode.PRICE_MISMATCH); + } + } + public void decreaseStock(int quantity) { this.stock.deduct(quantity); } From f09b4632c96ee99b090cdf55f3392b054facfc2c Mon Sep 17 00:00:00 2001 From: plan11plan Date: Tue, 24 Feb 2026 14:40:12 +0900 Subject: [PATCH 067/108] =?UTF-8?q?docs:=20Facade=20private=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EA=B8=88=EC=A7=80=20=EC=BB=A8=EB=B2=A4?= =?UTF-8?q?=EC=85=98=20=EB=B0=8F=20=ED=9B=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .claude/hooks/convention-check.sh | 16 ++++++++++++++++ .../application/service-layer-convention.md | 2 ++ 2 files changed, 18 insertions(+) diff --git a/.claude/hooks/convention-check.sh b/.claude/hooks/convention-check.sh index 7e20879ac..47c2dddcf 100755 --- a/.claude/hooks/convention-check.sh +++ b/.claude/hooks/convention-check.sh @@ -227,6 +227,22 @@ for ENTITY_FILE in $(grep -rl "^@Entity" "$SRC/domain/" 2>/dev/null); do fi done +# ============================================================================ +# 규칙 12: Facade에 private 메서드 금지 +# 출처: service-layer-convention.md § 2. Facade에 넣지 않는 것 +# ============================================================================ + +for FACADE_FILE in $(find "$SRC/application/" -name "*Facade.java" 2>/dev/null); do + PRIVATE_METHODS=$(grep -n "private .*(.*)" "$FACADE_FILE" 2>/dev/null \ + | grep -v "private final\|private static final" || true) + if [ -n "$PRIVATE_METHODS" ]; then + ERRORS+="[위반] Facade에 private 메서드 금지: $(basename $FACADE_FILE)\n" + ERRORS+=" → $PRIVATE_METHODS\n" + ERRORS+=" → private 메서드가 필요하면 Domain Service 또는 Entity로 이동할 것\n" + ERRORS+=" → 참고: service-layer-convention.md § 2. Facade에 넣지 않는 것\n\n" + fi +done + # ============================================================================ # 결과 출력 # ============================================================================ diff --git a/.claude/skills/project-convention/references/application/service-layer-convention.md b/.claude/skills/project-convention/references/application/service-layer-convention.md index aec1ee413..4024db166 100644 --- a/.claude/skills/project-convention/references/application/service-layer-convention.md +++ b/.claude/skills/project-convention/references/application/service-layer-convention.md @@ -53,6 +53,7 @@ Application 계층에는 **Facade만 둔다**. 별도 ApplicationService 개념 - 비즈니스 규칙, 검증 로직 → Domain Service 또는 Entity - Repository 직접 호출 → Domain Service - Entity 상태 변경 로직 → Entity 메서드 +- **private 메서드** → Facade에 private 메서드를 생성하지 않는다. private 메서드가 필요하다면 해당 로직은 Domain Service 또는 Entity에 속해야 한다는 신호이다 ### 예시 @@ -291,6 +292,7 @@ OrderQueryFacade // 조회, 검색, 목록 **Facade** - [ ] Facade에 비즈니스 규칙/검증 로직이 없는가? (Domain Service나 Entity에 있어야 함) +- [ ] Facade에 private 메서드가 없는가? (있다면 Domain Service 또는 Entity로 이동) - [ ] Facade에서 Repository를 직접 호출하지 않는가? - [ ] Facade가 타 Facade를 호출하지 않는가? - [ ] 타 도메인 접근 시 타 도메인의 Domain Service를 직접 호출하는가? From 6c27af083bd3da6393af56cf619c98a296164e57 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Tue, 24 Feb 2026 15:00:49 +0900 Subject: [PATCH 068/108] =?UTF-8?q?refactor:=20OrderFacade=20=EC=A3=BC?= =?UTF-8?q?=EB=AC=B8=20=EC=83=9D=EC=84=B1=20=EC=8B=9C=20=EC=83=81=ED=92=88?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=EB=A5=BC=20IN=EC=A0=88=20=EC=9D=BC?= =?UTF-8?q?=EA=B4=84=20=EC=A1=B0=ED=9A=8C=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../domain/product/ProductRepository.java | 2 + .../domain/product/ProductService.java | 9 + .../product/ProductJpaRepository.java | 2 + .../product/ProductRepositoryImpl.java | 5 + .../application/order/OrderFacadeTest.java | 269 ++++++++++++++++++ .../domain/product/FakeProductRepository.java | 8 + 6 files changed, 295 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java 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 2ae9941f3..e05a25c66 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -16,6 +16,8 @@ public interface ProductRepository { List findAllByBrandId(Long brandId); + List findAllByIdIn(List ids); + Page findAllWithActiveBrand(Pageable pageable); Page findAllWithActiveBrandByBrandId(Long brandId, Pageable pageable); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java index f278ae7ce..dde3711b9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -25,6 +25,15 @@ public ProductModel getById(Long id) { .orElseThrow(() -> new CoreException(ProductErrorCode.NOT_FOUND)); } + @Transactional(readOnly = true) + public List getAllByIds(List ids) { + List products = productRepository.findAllByIdIn(ids); + if (products.size() != ids.size()) { + throw new CoreException(ProductErrorCode.NOT_FOUND); + } + return products; + } + @Transactional public void update(Long id, String name, int price, int stock) { ProductModel productModel = getById(id); 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 9ce20d3fa..820dd69b5 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 @@ -16,6 +16,8 @@ public interface ProductJpaRepository extends JpaRepository List findAllByBrandIdAndDeletedAtIsNull(Long brandId); + List findAllByIdInAndDeletedAtIsNull(List ids); + Page findAllByDeletedAtIsNullAndBrandDeletedAtIsNull(Pageable pageable); Page findAllByBrandIdAndDeletedAtIsNullAndBrandDeletedAtIsNull(Long brandId, Pageable pageable); 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 c1cd791cd..4f61bdfd2 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 @@ -39,6 +39,11 @@ public List findAllByBrandId(Long brandId) { return productJpaRepository.findAllByBrandIdAndDeletedAtIsNull(brandId); } + @Override + public List findAllByIdIn(List ids) { + return productJpaRepository.findAllByIdInAndDeletedAtIsNull(ids); + } + @Override public Page findAllWithActiveBrand(Pageable pageable) { return productJpaRepository.findAllByDeletedAtIsNullAndBrandDeletedAtIsNull(pageable); diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java new file mode 100644 index 000000000..8d2146d22 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java @@ -0,0 +1,269 @@ +package com.loopers.application.order; + +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.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.loopers.application.order.dto.OrderCriteria; +import com.loopers.application.order.dto.OrderResult; +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.order.OrderErrorCode; +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.order.OrderService; +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.order.dto.OrderCommand; +import com.loopers.domain.product.ProductErrorCode; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductService; +import com.loopers.support.error.CoreException; +import java.time.ZonedDateTime; +import java.util.List; +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.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +@DisplayName("OrderFacade 단위 테스트") +@ExtendWith(MockitoExtension.class) +class OrderFacadeTest { + + @Mock + private ProductService productService; + + @Mock + private OrderService orderService; + + @InjectMocks + private OrderFacade orderFacade; + + private ProductModel createProductWithId(BrandModel brand, String name, int price, int stock, Long id) { + ProductModel product = ProductModel.create(brand, name, price, stock); + try { + var idField = product.getClass().getSuperclass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(product, id); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException(e); + } + return product; + } + + @DisplayName("주문 생성 (UC-O01)") + @Nested + class CreateOrder { + + @DisplayName("상품 일괄 조회 → 가격 검증 → 재고 차감 → 주문 생성 순서를 수행한다") + @Test + void createOrder_success() { + // arrange + BrandModel brand = BrandModel.create("브랜드A"); + ProductModel product = createProductWithId(brand, "상품A", 25000, 100, 10L); + + when(productService.getAllByIds(List.of(10L))).thenReturn(List.of(product)); + + OrderModel order = OrderModel.create(1L, 25000); + when(orderService.createOrder(any(OrderCommand.Create.class))).thenReturn(order); + + OrderCriteria.Create criteria = new OrderCriteria.Create(List.of( + new OrderCriteria.Create.CreateItem(10L, 1, 25000) + )); + + // act + OrderResult.OrderSummary result = orderFacade.createOrder(1L, criteria); + + // assert + assertAll( + () -> verify(productService).getAllByIds(List.of(10L)), + () -> verify(orderService).createOrder(any(OrderCommand.Create.class)), + () -> assertThat(result.totalPrice()).isEqualTo(25000) + ); + } + + @DisplayName("주문 항목이 비어있으면 예외가 발생한다") + @Test + void createOrder_withEmptyItems_throwsException() { + // arrange + OrderCriteria.Create criteria = new OrderCriteria.Create(List.of()); + + when(productService.getAllByIds(List.of())).thenReturn(List.of()); + when(orderService.createOrder(any(OrderCommand.Create.class))) + .thenThrow(new CoreException(OrderErrorCode.EMPTY_ORDER_ITEMS)); + + // act & assert + assertThatThrownBy(() -> orderFacade.createOrder(1L, criteria)) + .isInstanceOf(CoreException.class); + } + + @DisplayName("상품이 존재하지 않으면 예외가 발생한다") + @Test + void createOrder_productNotFound_throwsException() { + // arrange + when(productService.getAllByIds(List.of(999L))) + .thenThrow(new CoreException(ProductErrorCode.NOT_FOUND)); + + OrderCriteria.Create criteria = new OrderCriteria.Create(List.of( + new OrderCriteria.Create.CreateItem(999L, 1, 25000) + )); + + // act & assert + assertThatThrownBy(() -> orderFacade.createOrder(1L, criteria)) + .isInstanceOf(CoreException.class); + } + + @DisplayName("expectedPrice와 현재 가격 불일치 시 예외가 발생한다") + @Test + void createOrder_priceMismatch_throwsException() { + // arrange + BrandModel brand = BrandModel.create("브랜드A"); + ProductModel product = createProductWithId(brand, "상품A", 25000, 100, 10L); + + when(productService.getAllByIds(List.of(10L))).thenReturn(List.of(product)); + + OrderCriteria.Create criteria = new OrderCriteria.Create(List.of( + new OrderCriteria.Create.CreateItem(10L, 1, 30000) + )); + + // act & assert + assertThatThrownBy(() -> orderFacade.createOrder(1L, criteria)) + .isInstanceOf(CoreException.class); + } + + @DisplayName("재고 부족 시 예외가 발생한다") + @Test + void createOrder_insufficientStock_throwsException() { + // arrange + BrandModel brand = BrandModel.create("브랜드A"); + ProductModel product = createProductWithId(brand, "상품A", 25000, 1, 10L); + + when(productService.getAllByIds(List.of(10L))).thenReturn(List.of(product)); + + OrderCriteria.Create criteria = new OrderCriteria.Create(List.of( + new OrderCriteria.Create.CreateItem(10L, 100, 25000) + )); + + // act & assert + assertThatThrownBy(() -> orderFacade.createOrder(1L, criteria)) + .isInstanceOf(CoreException.class); + } + } + + @DisplayName("회원 주문 목록 조회 (UC-O02)") + @Nested + class GetMyOrders { + + @DisplayName("기간 내 본인 주문 목록을 반환한다") + @Test + void getMyOrders_returnsOrders() { + // arrange + OrderModel order = OrderModel.create(1L, 50000); + ZonedDateTime startAt = ZonedDateTime.now().minusDays(7); + ZonedDateTime endAt = ZonedDateTime.now(); + + when(orderService.getOrdersByUserIdAndPeriod(1L, startAt, endAt)) + .thenReturn(List.of(order)); + + OrderCriteria.ListByDate criteria = new OrderCriteria.ListByDate(startAt, endAt); + + // act + List results = orderFacade.getMyOrders(1L, criteria); + + // assert + assertAll( + () -> assertThat(results).hasSize(1), + () -> assertThat(results.get(0).totalPrice()).isEqualTo(50000) + ); + } + } + + @DisplayName("회원 주문 상세 조회 (UC-O03)") + @Nested + class GetMyOrderDetail { + + @DisplayName("주문 상세 + 주문 항목을 반환한다") + @Test + void getMyOrderDetail_returnsDetail() { + // arrange + OrderModel order = OrderModel.create(1L, 50000); + OrderItemModel item = OrderItemModel.create(1L, 10L, 25000, 2, "상품A", "브랜드A"); + + when(orderService.getById(1L)).thenReturn(order); + when(orderService.getOrderItemsByOrderId(1L)).thenReturn(List.of(item)); + + // act + OrderResult.OrderDetail result = orderFacade.getMyOrderDetail(1L, 1L); + + // assert + assertAll( + () -> assertThat(result.totalPrice()).isEqualTo(50000), + () -> assertThat(result.items()).hasSize(1), + () -> assertThat(result.items().get(0).productName()).isEqualTo("상품A") + ); + } + + @DisplayName("본인 주문이 아니면 예외가 발생한다") + @Test + void getMyOrderDetail_notOwner_throwsException() { + // arrange + OrderModel order = OrderModel.create(2L, 50000); + when(orderService.getById(1L)).thenReturn(order); + + // act & assert + assertThatThrownBy(() -> orderFacade.getMyOrderDetail(1L, 1L)) + .isInstanceOf(CoreException.class); + } + } + + @DisplayName("관리자 주문 조회") + @Nested + class AdminOrders { + + @DisplayName("전체 주문 페이지네이션을 반환한다") + @Test + void getAllOrders_returnsPage() { + // arrange + OrderModel order = OrderModel.create(1L, 50000); + Page page = new PageImpl<>(List.of(order), PageRequest.of(0, 10), 1); + + when(orderService.getAllOrders(any())).thenReturn(page); + + // act + Page result = orderFacade.getAllOrders(PageRequest.of(0, 10)); + + // assert + assertAll( + () -> assertThat(result.getTotalElements()).isEqualTo(1), + () -> assertThat(result.getContent().get(0).totalPrice()).isEqualTo(50000) + ); + } + + @DisplayName("주문 상세를 반환한다 (소유권 검증 없음)") + @Test + void getOrderDetail_returnsDetail_withoutOwnerCheck() { + // arrange + OrderModel order = OrderModel.create(2L, 50000); + OrderItemModel item = OrderItemModel.create(1L, 10L, 25000, 2, "상품A", "브랜드A"); + + when(orderService.getById(1L)).thenReturn(order); + when(orderService.getOrderItemsByOrderId(1L)).thenReturn(List.of(item)); + + // act + OrderResult.OrderDetail result = orderFacade.getOrderDetail(1L); + + // assert + assertAll( + () -> assertThat(result.userId()).isEqualTo(2L), + () -> assertThat(result.items()).hasSize(1) + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/FakeProductRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/FakeProductRepository.java index fa5914173..e0fbcd0f6 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/FakeProductRepository.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/FakeProductRepository.java @@ -77,6 +77,14 @@ public List findAllByBrandId(Long brandId) { .toList(); } + @Override + public List findAllByIdIn(List ids) { + return store.values().stream() + .filter(product -> product.getDeletedAt() == null) + .filter(product -> ids.contains(product.getId())) + .toList(); + } + @Override public Page findAllWithActiveBrand(Pageable pageable) { List activeModels = store.values().stream() From ac963085c4f9d1f4e7e9c85a32a318440eb356ef Mon Sep 17 00:00:00 2001 From: plan11plan Date: Tue, 24 Feb 2026 15:00:53 +0900 Subject: [PATCH 069/108] =?UTF-8?q?docs:=20=EC=A3=BC=EB=AC=B8=20=EC=84=A4?= =?UTF-8?q?=EA=B3=84=20=EB=AC=B8=EC=84=9C=20=EC=9C=A0=EC=A6=88=EC=BC=80?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=ED=9D=90=EB=A6=84=EC=97=90=20IN=EC=A0=88?= =?UTF-8?q?=20=EC=9D=BC=EA=B4=84=20=EC=A1=B0=ED=9A=8C=20=EB=AA=85=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- docs/design/order/DESIGN.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/design/order/DESIGN.md b/docs/design/order/DESIGN.md index 351dd87da..648172515 100644 --- a/docs/design/order/DESIGN.md +++ b/docs/design/order/DESIGN.md @@ -54,10 +54,10 @@ ``` [기능 흐름] 1. 회원이 상품 목록(productId, quantity, expectedPrice)으로 주문을 요청한다 -2. 각 상품이 존재하는지 확인한다 (삭제된 상품 불가) -3. 각 상품의 expectedPrice와 현재 가격을 비교한다 (불일치 시 실패) -4. 각 상품의 재고가 충분한지 확인한다 -5. 재고를 차감한다 (원자적 처리) +2. 상품 ID 목록을 추출하여 IN절로 일괄 조회한다 (개별 루프 조회 금지) +3. 조회된 상품 수와 요청 상품 수가 일치하는지 확인한다 (삭제된 상품 불가) +4. 각 상품의 expectedPrice와 현재 가격을 비교한다 (불일치 시 실패) +5. 각 상품의 재고가 충분한지 확인하고 차감한다 (원자적 처리) 6. 주문 시점의 상품 정보를 스냅샷으로 저장한다 (ProductSnapshot: 상품명, 브랜드명, 이미지 등) 7. 주문을 생성한다 From e250d8dc72d92df62d0e460544ff601373ca557c Mon Sep 17 00:00:00 2001 From: plan11plan Date: Tue, 24 Feb 2026 15:00:58 +0900 Subject: [PATCH 070/108] =?UTF-8?q?docs:=20Facade=20=EC=9D=BC=EA=B4=84=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=9B=90=EC=B9=99=20=EC=BB=A8=EB=B2=A4?= =?UTF-8?q?=EC=85=98=20=EB=B0=8F=20=EA=B5=AC=ED=98=84=20=ED=9B=84=20DESIGN?= =?UTF-8?q?.md=20=EB=8C=80=EC=A1=B0=20=EA=B7=9C=EC=B9=99=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .claude/CLAUDE.md | 1 + .../application/service-layer-convention.md | 32 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 26070e47a..23c7f3228 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -59,6 +59,7 @@ supports/ 1. 해당 도메인의 `DESIGN.md`를 읽는다 2. 다른 도메인과 연동이 필요하면 그 도메인의 `DESIGN.md`도 읽는다 3. 전체 관계 확인이 필요하면 `OVERVIEW.md`를 읽는다 +4. 구현 완료 후 `DESIGN.md`의 **'예외 및 정책'** 섹션과 **유즈케이스 기능 흐름**을 재확인하여, 각 항목이 코드에 반영되었는지 대조한다 --- diff --git a/.claude/skills/project-convention/references/application/service-layer-convention.md b/.claude/skills/project-convention/references/application/service-layer-convention.md index 4024db166..5bda63ec6 100644 --- a/.claude/skills/project-convention/references/application/service-layer-convention.md +++ b/.claude/skills/project-convention/references/application/service-layer-convention.md @@ -55,6 +55,37 @@ Application 계층에는 **Facade만 둔다**. 별도 ApplicationService 개념 - Entity 상태 변경 로직 → Entity 메서드 - **private 메서드** → Facade에 private 메서드를 생성하지 않는다. private 메서드가 필요하다면 해당 로직은 Domain Service 또는 Entity에 속해야 한다는 신호이다 +### 일괄 조회 원칙 (N+1 방지) + +Facade에서 여러 엔티티를 다룰 때 **반드시 IN절 일괄 조회**를 사용한다. 루프 안에서 개별 조회(`getById`)를 반복 호출하지 않는다. + +```java +// ❌ 금지: 루프 내 개별 조회 (N+1 쿼리) +criteria.items().stream() + .map(item -> { + ProductModel product = productService.getById(item.productId()); + ... + }); + +// ✅ 필수: ID 목록 추출 → IN절 일괄 조회 → Map으로 매핑 +List productIds = criteria.items().stream() + .map(CreateItem::productId) + .toList(); + +Map productMap = productService.getAllByIds(productIds).stream() + .collect(Collectors.toMap(ProductModel::getId, Function.identity())); + +criteria.items().stream() + .map(item -> { + ProductModel product = productMap.get(item.productId()); + ... + }); +``` + +이 패턴은 Facade의 **흐름 조율** 역할에 부합한다: +- 필요한 데이터를 한 번에 확보한 후 흐름을 진행한다 +- Domain Service에 `getAllByIds(List)` 메서드를 제공하고, 조회 결과 개수 검증은 Service가 담당한다 + ### 예시 ```java @@ -296,6 +327,7 @@ OrderQueryFacade // 조회, 검색, 목록 - [ ] Facade에서 Repository를 직접 호출하지 않는가? - [ ] Facade가 타 Facade를 호출하지 않는가? - [ ] 타 도메인 접근 시 타 도메인의 Domain Service를 직접 호출하는가? +- [ ] 여러 엔티티를 다룰 때 IN절 일괄 조회를 사용하는가? (루프 내 개별 조회 금지) **Domain Service** - [ ] 자기 도메인의 Repository만 접근하는가? From e83f1caf85d5d8be2998a9624337e946694ee9b0 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Tue, 24 Feb 2026 15:12:47 +0900 Subject: [PATCH 071/108] =?UTF-8?q?refactor:=20OrderFacade=20=EC=86=8C?= =?UTF-8?q?=EC=9C=A0=EA=B6=8C=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=EC=9D=84=20OrderService=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../application/order/OrderFacade.java | 85 +++++++++++++++++++ .../loopers/domain/order/OrderService.java | 78 +++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java new file mode 100644 index 000000000..287a01820 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -0,0 +1,85 @@ +package com.loopers.application.order; + +import com.loopers.application.order.dto.OrderCriteria; +import com.loopers.application.order.dto.OrderResult; +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.order.OrderService; +import com.loopers.domain.order.dto.OrderCommand; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductService; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class OrderFacade { + + private final ProductService productService; + private final OrderService orderService; + + @Transactional + public OrderResult.OrderSummary createOrder(Long userId, OrderCriteria.Create criteria) { + List productIds = criteria.items().stream() + .map(OrderCriteria.Create.CreateItem::productId) + .toList(); + + Map productMap = productService.getAllByIds(productIds).stream() + .collect(Collectors.toMap(ProductModel::getId, Function.identity())); + + List commandItems = criteria.items().stream() + .map(item -> { + ProductModel product = productMap.get(item.productId()); + product.validatePrice(item.expectedPrice()); + product.decreaseStock(item.quantity()); + return new OrderCommand.Create.CreateItem( + item.productId(), + product.getPrice().getValue(), + item.quantity(), + product.getName(), + product.getBrand().getName() + ); + }) + .toList(); + + OrderCommand.Create command = new OrderCommand.Create(userId, commandItems); + OrderModel order = orderService.createOrder(command); + return OrderResult.OrderSummary.from(order); + } + + @Transactional(readOnly = true) + public List getMyOrders(Long userId, OrderCriteria.ListByDate criteria) { + List orders = orderService.getOrdersByUserIdAndPeriod(userId, criteria.startAt(), criteria.endAt()); + return orders.stream() + .map(OrderResult.OrderSummary::from) + .toList(); + } + + @Transactional(readOnly = true) + public OrderResult.OrderDetail getMyOrderDetail(Long userId, Long orderId) { + OrderModel order = orderService.getByIdAndUserId(orderId, userId); + List items = orderService.getOrderItemsByOrderId(orderId); + return OrderResult.OrderDetail.from(order, items); + } + + @Transactional(readOnly = true) + public Page getAllOrders(Pageable pageable) { + return orderService.getAllOrders(pageable) + .map(OrderResult.OrderSummary::from); + } + + @Transactional(readOnly = true) + public OrderResult.OrderDetail getOrderDetail(Long orderId) { + OrderModel order = orderService.getById(orderId); + List items = orderService.getOrderItemsByOrderId(orderId); + return OrderResult.OrderDetail.from(order, items); + } + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java new file mode 100644 index 000000000..146c03874 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -0,0 +1,78 @@ +package com.loopers.domain.order; + +import com.loopers.domain.order.dto.OrderCommand; +import com.loopers.support.error.CoreException; +import java.time.ZonedDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class OrderService { + + private final OrderRepository orderRepository; + private final OrderItemRepository orderItemRepository; + + @Transactional + public OrderModel createOrder(OrderCommand.Create command) { + validateItems(command.items()); + + int totalPrice = command.items().stream() + .mapToInt(item -> item.orderPrice() * item.quantity()) + .sum(); + + OrderModel savedOrder = orderRepository.save( + OrderModel.create(command.userId(), totalPrice)); + + for (OrderCommand.Create.CreateItem item : command.items()) { + orderItemRepository.save( + OrderItemModel.create( + savedOrder.getId(), + item.productId(), + item.orderPrice(), + item.quantity(), + item.productName(), + item.brandName())); + } + + return savedOrder; + } + + @Transactional(readOnly = true) + public OrderModel getById(Long id) { + return orderRepository.findById(id) + .orElseThrow(() -> new CoreException(OrderErrorCode.NOT_FOUND)); + } + + @Transactional(readOnly = true) + public OrderModel getByIdAndUserId(Long id, Long userId) { + OrderModel order = getById(id); + order.validateOwner(userId); + return order; + } + + @Transactional(readOnly = true) + public List getOrdersByUserIdAndPeriod(Long userId, ZonedDateTime startAt, ZonedDateTime endAt) { + return orderRepository.findAllByUserIdAndCreatedAtBetween(userId, startAt, endAt); + } + + @Transactional(readOnly = true) + public List getOrderItemsByOrderId(Long orderId) { + return orderItemRepository.findAllByOrderId(orderId); + } + + @Transactional(readOnly = true) + public Page getAllOrders(Pageable pageable) { + return orderRepository.findAll(pageable); + } + + private void validateItems(List items) { + if (items == null || items.isEmpty()) { + throw new CoreException(OrderErrorCode.EMPTY_ORDER_ITEMS); + } + } +} From 3f0610725793faf99de1afea4f0a9beb4d6df435 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Tue, 24 Feb 2026 15:12:52 +0900 Subject: [PATCH 072/108] =?UTF-8?q?test:=20=EC=A3=BC=EB=AC=B8=20=EC=86=8C?= =?UTF-8?q?=EC=9C=A0=EA=B6=8C=20=EA=B2=80=EC=A6=9D=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../application/order/OrderFacadeTest.java | 6 +- .../domain/order/OrderServiceTest.java | 219 ++++++++++++++++++ 2 files changed, 222 insertions(+), 3 deletions(-) create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java index 8d2146d22..d678fe8ff 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java @@ -196,7 +196,7 @@ void getMyOrderDetail_returnsDetail() { OrderModel order = OrderModel.create(1L, 50000); OrderItemModel item = OrderItemModel.create(1L, 10L, 25000, 2, "상품A", "브랜드A"); - when(orderService.getById(1L)).thenReturn(order); + when(orderService.getByIdAndUserId(1L, 1L)).thenReturn(order); when(orderService.getOrderItemsByOrderId(1L)).thenReturn(List.of(item)); // act @@ -214,8 +214,8 @@ void getMyOrderDetail_returnsDetail() { @Test void getMyOrderDetail_notOwner_throwsException() { // arrange - OrderModel order = OrderModel.create(2L, 50000); - when(orderService.getById(1L)).thenReturn(order); + when(orderService.getByIdAndUserId(1L, 1L)) + .thenThrow(new CoreException(OrderErrorCode.FORBIDDEN)); // act & assert assertThatThrownBy(() -> orderFacade.getMyOrderDetail(1L, 1L)) 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..12b0ed36c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java @@ -0,0 +1,219 @@ +package com.loopers.domain.order; + +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 com.loopers.domain.order.dto.OrderCommand; +import com.loopers.support.error.CoreException; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; + +@DisplayName("OrderService 단위 테스트") +class OrderServiceTest { + + private OrderService orderService; + private FakeOrderRepository fakeOrderRepository; + private FakeOrderItemRepository fakeOrderItemRepository; + + @BeforeEach + void setUp() { + fakeOrderRepository = new FakeOrderRepository(); + fakeOrderItemRepository = new FakeOrderItemRepository(); + orderService = new OrderService(fakeOrderRepository, fakeOrderItemRepository); + } + + @DisplayName("주문 생성") + @Nested + class CreateOrder { + + @DisplayName("주문 생성 후 저장된다") + @Test + void createOrder_savesOrder() { + // arrange + OrderCommand.Create command = new OrderCommand.Create(1L, List.of( + new OrderCommand.Create.CreateItem(10L, 25000, 2, "상품A", "브랜드A") + )); + + // act + OrderModel order = orderService.createOrder(command); + + // assert + assertAll( + () -> assertThat(order.getId()).isNotEqualTo(0L), + () -> assertThat(order.getUserId()).isEqualTo(1L), + () -> assertThat(order.getTotalPrice().getValue()).isEqualTo(50000), + () -> assertThat(order.getStatus()).isEqualTo(OrderStatus.ORDERED) + ); + } + + @DisplayName("주문 항목이 저장된다") + @Test + void createOrder_savesOrderItems() { + // arrange + OrderCommand.Create command = new OrderCommand.Create(1L, List.of( + new OrderCommand.Create.CreateItem(10L, 25000, 2, "상품A", "브랜드A"), + new OrderCommand.Create.CreateItem(20L, 30000, 1, "상품B", "브랜드B") + )); + + // act + OrderModel order = orderService.createOrder(command); + + // assert + List items = orderService.getOrderItemsByOrderId(order.getId()); + assertAll( + () -> assertThat(items).hasSize(2), + () -> assertThat(items.get(0).getProductId()).isEqualTo(10L), + () -> assertThat(items.get(1).getProductId()).isEqualTo(20L) + ); + } + + @DisplayName("빈 항목이면 예외가 발생한다") + @Test + void createOrder_withEmptyItems_throwsException() { + // arrange + OrderCommand.Create command = new OrderCommand.Create(1L, List.of()); + + // act & assert + assertThatThrownBy(() -> orderService.createOrder(command)) + .isInstanceOf(CoreException.class); + } + } + + @DisplayName("주문 조회") + @Nested + class GetOrder { + + @DisplayName("ID로 주문을 조회한다") + @Test + void getById_returnsOrder() { + // arrange + OrderCommand.Create command = new OrderCommand.Create(1L, List.of( + new OrderCommand.Create.CreateItem(10L, 25000, 1, "상품A", "브랜드A") + )); + OrderModel savedOrder = orderService.createOrder(command); + + // act + OrderModel foundOrder = orderService.getById(savedOrder.getId()); + + // assert + assertThat(foundOrder.getId()).isEqualTo(savedOrder.getId()); + } + + @DisplayName("존재하지 않는 주문 조회 시 예외가 발생한다") + @Test + void getById_notFound_throwsException() { + // act & assert + assertThatThrownBy(() -> orderService.getById(999L)) + .isInstanceOf(CoreException.class); + } + + @DisplayName("ID + userId로 본인 주문을 조회한다") + @Test + void getByIdAndUserId_returnsOrder() { + // arrange + OrderCommand.Create command = new OrderCommand.Create(1L, List.of( + new OrderCommand.Create.CreateItem(10L, 25000, 1, "상품A", "브랜드A") + )); + OrderModel savedOrder = orderService.createOrder(command); + + // act + OrderModel foundOrder = orderService.getByIdAndUserId(savedOrder.getId(), 1L); + + // assert + assertThat(foundOrder.getId()).isEqualTo(savedOrder.getId()); + } + + @DisplayName("본인 주문이 아니면 예외가 발생한다") + @Test + void getByIdAndUserId_notOwner_throwsException() { + // arrange + OrderCommand.Create command = new OrderCommand.Create(1L, List.of( + new OrderCommand.Create.CreateItem(10L, 25000, 1, "상품A", "브랜드A") + )); + OrderModel savedOrder = orderService.createOrder(command); + + // act & assert + assertThatThrownBy(() -> orderService.getByIdAndUserId(savedOrder.getId(), 999L)) + .isInstanceOf(CoreException.class); + } + } + + @DisplayName("유저ID + 기간으로 주문 목록 조회") + @Nested + class GetOrdersByUserIdAndPeriod { + + @DisplayName("해당 유저의 주문 목록을 반환한다") + @Test + void getOrdersByUserIdAndPeriod_returnsOrders() { + // arrange + OrderCommand.Create command = new OrderCommand.Create(1L, List.of( + new OrderCommand.Create.CreateItem(10L, 25000, 1, "상품A", "브랜드A") + )); + orderService.createOrder(command); + + // act + List orders = orderService.getOrdersByUserIdAndPeriod( + 1L, + java.time.ZonedDateTime.now().minusDays(1), + java.time.ZonedDateTime.now().plusDays(1) + ); + + // assert + assertThat(orders).hasSize(1); + } + } + + @DisplayName("주문ID로 주문 항목 목록 조회") + @Nested + class GetOrderItems { + + @DisplayName("해당 주문의 항목 목록을 반환한다") + @Test + void getOrderItemsByOrderId_returnsItems() { + // arrange + OrderCommand.Create command = new OrderCommand.Create(1L, List.of( + new OrderCommand.Create.CreateItem(10L, 25000, 2, "상품A", "브랜드A") + )); + OrderModel order = orderService.createOrder(command); + + // act + List items = orderService.getOrderItemsByOrderId(order.getId()); + + // assert + assertThat(items).hasSize(1); + assertThat(items.get(0).getProductSnapshot().getProductName()).isEqualTo("상품A"); + } + } + + @DisplayName("전체 주문 페이지네이션 조회") + @Nested + class GetAllOrders { + + @DisplayName("전체 주문을 페이지네이션으로 반환한다") + @Test + void getAllOrders_returnsPage() { + // arrange + orderService.createOrder(new OrderCommand.Create(1L, List.of( + new OrderCommand.Create.CreateItem(10L, 25000, 1, "상품A", "브랜드A") + ))); + orderService.createOrder(new OrderCommand.Create(2L, List.of( + new OrderCommand.Create.CreateItem(20L, 30000, 1, "상품B", "브랜드B") + ))); + + // act + Page page = orderService.getAllOrders(PageRequest.of(0, 10)); + + // assert + assertAll( + () -> assertThat(page.getTotalElements()).isEqualTo(2), + () -> assertThat(page.getContent()).hasSize(2) + ); + } + } +} From 03029deed0bbe7681d7aed6cda8abdda7bb7b09d Mon Sep 17 00:00:00 2001 From: plan11plan Date: Tue, 24 Feb 2026 15:50:59 +0900 Subject: [PATCH 073/108] =?UTF-8?q?feat:=20=EC=A3=BC=EB=AC=B8=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/order/OrderFacade.java | 49 ++++++------- .../application/order/dto/OrderCriteria.java | 14 ++++ .../application/order/dto/OrderResult.java | 62 ++++++++++++++++ .../java/com/loopers/domain/order/Money.java | 30 ++++++++ .../loopers/domain/order/OrderErrorCode.java | 18 +++++ .../loopers/domain/order/OrderItemModel.java | 71 ++++++++++++++++++ .../domain/order/OrderItemRepository.java | 10 +++ .../com/loopers/domain/order/OrderModel.java | 56 ++++++++++++++ .../loopers/domain/order/OrderRepository.java | 18 +++++ .../loopers/domain/order/OrderService.java | 6 +- .../com/loopers/domain/order/OrderStatus.java | 5 ++ .../loopers/domain/order/ProductSnapshot.java | 37 ++++++++++ .../com/loopers/domain/order/Quantity.java | 30 ++++++++ .../domain/order/dto/OrderCommand.java | 17 +++++ .../order/OrderItemJpaRepository.java | 10 +++ .../order/OrderItemRepositoryImpl.java | 24 ++++++ .../order/OrderJpaRepository.java | 15 ++++ .../order/OrderRepositoryImpl.java | 38 ++++++++++ .../interfaces/order/AdminOrderV1ApiSpec.java | 28 +++++++ .../order/AdminOrderV1Controller.java | 50 +++++++++++++ .../interfaces/order/OrderV1ApiSpec.java | 43 +++++++++++ .../interfaces/order/OrderV1Controller.java | 67 +++++++++++++++++ .../interfaces/order/dto/OrderRequest.java | 46 ++++++++++++ .../interfaces/order/dto/OrderResponse.java | 73 +++++++++++++++++++ 24 files changed, 789 insertions(+), 28 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/dto/OrderCriteria.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/dto/OrderResult.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/Money.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderErrorCode.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/ProductSnapshot.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/Quantity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/dto/OrderCommand.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/order/AdminOrderV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/order/AdminOrderV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/order/OrderV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/order/OrderV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/order/dto/OrderRequest.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/order/dto/OrderResponse.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java index 287a01820..7b1056278 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -28,38 +28,37 @@ public class OrderFacade { @Transactional public OrderResult.OrderSummary createOrder(Long userId, OrderCriteria.Create criteria) { List productIds = criteria.items().stream() - .map(OrderCriteria.Create.CreateItem::productId) - .toList(); + .map(OrderCriteria.Create.CreateItem::productId) + .toList(); - Map productMap = productService.getAllByIds(productIds).stream() - .collect(Collectors.toMap(ProductModel::getId, Function.identity())); + Map productMap = + productService.getAllByIds(productIds).stream() + .collect(Collectors.toMap(ProductModel::getId, Function.identity())); List commandItems = criteria.items().stream() - .map(item -> { - ProductModel product = productMap.get(item.productId()); - product.validatePrice(item.expectedPrice()); - product.decreaseStock(item.quantity()); - return new OrderCommand.Create.CreateItem( - item.productId(), - product.getPrice().getValue(), - item.quantity(), - product.getName(), - product.getBrand().getName() - ); - }) - .toList(); + .map(item -> { + ProductModel product = productMap.get(item.productId()); + product.validatePrice(item.expectedPrice()); + product.decreaseStock(item.quantity()); + return new OrderCommand.Create.CreateItem( + item.productId(), + product.getPrice().getValue(), + item.quantity(), + product.getName(), + product.getBrand().getName()); + }) + .toList(); - OrderCommand.Create command = new OrderCommand.Create(userId, commandItems); - OrderModel order = orderService.createOrder(command); - return OrderResult.OrderSummary.from(order); + return OrderResult.OrderSummary.from( + orderService.createOrder( + new OrderCommand.Create(userId, commandItems))); } @Transactional(readOnly = true) public List getMyOrders(Long userId, OrderCriteria.ListByDate criteria) { - List orders = orderService.getOrdersByUserIdAndPeriod(userId, criteria.startAt(), criteria.endAt()); - return orders.stream() - .map(OrderResult.OrderSummary::from) - .toList(); + return orderService.getOrdersByUserIdAndPeriod(userId, criteria.startAt(), criteria.endAt()).stream() + .map(OrderResult.OrderSummary::from) + .toList(); } @Transactional(readOnly = true) @@ -72,7 +71,7 @@ public OrderResult.OrderDetail getMyOrderDetail(Long userId, Long orderId) { @Transactional(readOnly = true) public Page getAllOrders(Pageable pageable) { return orderService.getAllOrders(pageable) - .map(OrderResult.OrderSummary::from); + .map(OrderResult.OrderSummary::from); } @Transactional(readOnly = true) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/dto/OrderCriteria.java b/apps/commerce-api/src/main/java/com/loopers/application/order/dto/OrderCriteria.java new file mode 100644 index 000000000..a0e3caeee --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/dto/OrderCriteria.java @@ -0,0 +1,14 @@ +package com.loopers.application.order.dto; + +import java.time.ZonedDateTime; +import java.util.List; + +public class OrderCriteria { + + public record Create(List items) { + + public record CreateItem(Long productId, int quantity, int expectedPrice) {} + } + + public record ListByDate(ZonedDateTime startAt, ZonedDateTime endAt) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/dto/OrderResult.java b/apps/commerce-api/src/main/java/com/loopers/application/order/dto/OrderResult.java new file mode 100644 index 000000000..e5d1ff64d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/dto/OrderResult.java @@ -0,0 +1,62 @@ +package com.loopers.application.order.dto; + +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderModel; +import java.time.ZonedDateTime; +import java.util.List; + +public class OrderResult { + + public record OrderSummary( + Long orderId, + int totalPrice, + String status, + ZonedDateTime createdAt + ) { + public static OrderSummary from(OrderModel model) { + return new OrderSummary( + model.getId(), + model.getTotalPrice().getValue(), + model.getStatus().name(), + model.getCreatedAt()); + } + } + + public record OrderDetail( + Long orderId, + Long userId, + int totalPrice, + String status, + ZonedDateTime createdAt, + List items + ) { + public static OrderDetail from(OrderModel model, List items) { + return new OrderDetail( + model.getId(), + model.getUserId(), + model.getTotalPrice().getValue(), + model.getStatus().name(), + model.getCreatedAt(), + items.stream().map(OrderItemDetail::from).toList()); + } + } + + public record OrderItemDetail( + Long orderItemId, + Long productId, + String productName, + String brandName, + int orderPrice, + int quantity + ) { + public static OrderItemDetail from(OrderItemModel model) { + return new OrderItemDetail( + model.getId(), + model.getProductId(), + model.getProductSnapshot().getProductName(), + model.getProductSnapshot().getBrandName(), + model.getOrderPrice().getValue(), + model.getQuantity().getValue()); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Money.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Money.java new file mode 100644 index 000000000..0d48278dd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Money.java @@ -0,0 +1,30 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Embeddable; +import lombok.EqualsAndHashCode; + +@Embeddable +@EqualsAndHashCode +public class Money { + + private int value; + + protected Money() {} + + public Money(int value) { + validate(value); + this.value = value; + } + + private void validate(int value) { + if (value < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격은 0 이상이어야 합니다."); + } + } + + public int getValue() { + return value; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderErrorCode.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderErrorCode.java new file mode 100644 index 000000000..9b6afd379 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderErrorCode.java @@ -0,0 +1,18 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum OrderErrorCode implements ErrorCode { + NOT_FOUND(HttpStatus.NOT_FOUND, "ORDER_001", "주문을 찾을 수 없습니다."), + EMPTY_ORDER_ITEMS(HttpStatus.BAD_REQUEST, "ORDER_002", "주문 항목이 비어있습니다."), + FORBIDDEN(HttpStatus.FORBIDDEN, "ORDER_003", "본인의 주문만 조회할 수 있습니다."); + + private final HttpStatus status; + private final String code; + private final String message; +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java new file mode 100644 index 000000000..c7c8482c9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java @@ -0,0 +1,71 @@ +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.AttributeOverride; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "order_items") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class OrderItemModel extends BaseEntity { + + @Column(name = "order_id", nullable = false) + private Long orderId; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "order_price", nullable = false)) + private Money orderPrice; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "quantity", nullable = false)) + private Quantity quantity; + + @Embedded + private ProductSnapshot productSnapshot; + + private OrderItemModel(Long orderId, Long productId, Money orderPrice, Quantity quantity, ProductSnapshot productSnapshot) { + this.orderId = orderId; + this.productId = productId; + this.orderPrice = orderPrice; + this.quantity = quantity; + this.productSnapshot = productSnapshot; + } + + public static OrderItemModel create( + Long orderId, Long productId, int orderPrice, int quantity, + String productName, String brandName) { + validateOrderId(orderId); + validateProductId(productId); + return new OrderItemModel( + orderId, + productId, + new Money(orderPrice), + new Quantity(quantity), + new ProductSnapshot(productName, brandName) + ); + } + + private static void validateOrderId(Long orderId) { + if (orderId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 ID는 필수값입니다."); + } + } + + private static void validateProductId(Long productId) { + if (productId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 ID는 필수값입니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java new file mode 100644 index 000000000..2f0a86e27 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java @@ -0,0 +1,10 @@ +package com.loopers.domain.order; + +import java.util.List; + +public interface OrderItemRepository { + + OrderItemModel save(OrderItemModel orderItemModel); + + List findAllByOrderId(Long orderId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java new file mode 100644 index 000000000..b9efe8f3d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java @@ -0,0 +1,56 @@ +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.AttributeOverride; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "orders") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class OrderModel extends BaseEntity { + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "total_price", nullable = false)) + private Money totalPrice; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private OrderStatus status; + + private OrderModel(Long userId, Money totalPrice, OrderStatus status) { + this.userId = userId; + this.totalPrice = totalPrice; + this.status = status; + } + + public static OrderModel create(Long userId, int totalPrice) { + validateUserId(userId); + return new OrderModel(userId, new Money(totalPrice), OrderStatus.ORDERED); + } + + public void validateOwner(Long userId) { + if (!userId.equals(this.userId)) { + throw new CoreException(OrderErrorCode.FORBIDDEN); + } + } + + private static void validateUserId(Long userId) { + if (userId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "사용자 ID는 필수값입니다."); + } + } +} 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..1d99813eb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java @@ -0,0 +1,18 @@ +package com.loopers.domain.order; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface OrderRepository { + + OrderModel save(OrderModel orderModel); + + Optional findById(Long id); + + List findAllByUserIdAndCreatedAtBetween(Long userId, ZonedDateTime startAt, ZonedDateTime endAt); + + Page findAll(Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java index 146c03874..e506517ba 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -22,8 +22,8 @@ public OrderModel createOrder(OrderCommand.Create command) { validateItems(command.items()); int totalPrice = command.items().stream() - .mapToInt(item -> item.orderPrice() * item.quantity()) - .sum(); + .mapToInt(item -> item.orderPrice() * item.quantity()) + .sum(); OrderModel savedOrder = orderRepository.save( OrderModel.create(command.userId(), totalPrice)); @@ -45,7 +45,7 @@ public OrderModel createOrder(OrderCommand.Create command) { @Transactional(readOnly = true) public OrderModel getById(Long id) { return orderRepository.findById(id) - .orElseThrow(() -> new CoreException(OrderErrorCode.NOT_FOUND)); + .orElseThrow(() -> new CoreException(OrderErrorCode.NOT_FOUND)); } @Transactional(readOnly = true) 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..6dab40e1f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java @@ -0,0 +1,5 @@ +package com.loopers.domain.order; + +public enum OrderStatus { + ORDERED +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/ProductSnapshot.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/ProductSnapshot.java new file mode 100644 index 000000000..d0372a076 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/ProductSnapshot.java @@ -0,0 +1,37 @@ +package com.loopers.domain.order; + +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; + +@Getter +@Embeddable +@EqualsAndHashCode +public class ProductSnapshot { + + @Column(name = "product_name", nullable = false) + private String productName; + + @Column(name = "brand_name", nullable = false) + private String brandName; + + protected ProductSnapshot() {} + + public ProductSnapshot(String productName, String brandName) { + validate(productName, brandName); + this.productName = productName; + this.brandName = brandName; + } + + private void validate(String productName, String brandName) { + if (productName == null || productName.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품명은 필수값입니다."); + } + if (brandName == null || brandName.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드명은 필수값입니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Quantity.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Quantity.java new file mode 100644 index 000000000..01c1ecb3f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Quantity.java @@ -0,0 +1,30 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Embeddable; +import lombok.EqualsAndHashCode; + +@Embeddable +@EqualsAndHashCode +public class Quantity { + + private int value; + + protected Quantity() {} + + public Quantity(int value) { + validate(value); + this.value = value; + } + + private void validate(int value) { + if (value < 1) { + throw new CoreException(ErrorType.BAD_REQUEST, "수량은 1 이상이어야 합니다."); + } + } + + public int getValue() { + return value; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/dto/OrderCommand.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/dto/OrderCommand.java new file mode 100644 index 000000000..9c60e3dd2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/dto/OrderCommand.java @@ -0,0 +1,17 @@ +package com.loopers.domain.order.dto; + +import java.util.List; + +public class OrderCommand { + + public record Create(Long userId, List items) { + + public record CreateItem( + Long productId, + int orderPrice, + int quantity, + String productName, + String brandName + ) {} + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java new file mode 100644 index 000000000..fe7e3d54a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java @@ -0,0 +1,10 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderItemModel; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface OrderItemJpaRepository extends JpaRepository { + + List findAllByOrderId(Long orderId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java new file mode 100644 index 000000000..a5c364dff --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java @@ -0,0 +1,24 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderItemRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class OrderItemRepositoryImpl implements OrderItemRepository { + + private final OrderItemJpaRepository orderItemJpaRepository; + + @Override + public OrderItemModel save(OrderItemModel orderItemModel) { + return orderItemJpaRepository.save(orderItemModel); + } + + @Override + public List findAllByOrderId(Long orderId) { + return orderItemJpaRepository.findAllByOrderId(orderId); + } +} 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..ce6d266bc --- /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.OrderModel; +import java.time.ZonedDateTime; +import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface OrderJpaRepository extends JpaRepository { + + List findAllByUserIdAndCreatedAtBetween(Long userId, ZonedDateTime startAt, ZonedDateTime endAt); + + Page findAll(Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java new file mode 100644 index 000000000..13e4707cd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -0,0 +1,38 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.order.OrderRepository; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class OrderRepositoryImpl implements OrderRepository { + + private final OrderJpaRepository orderJpaRepository; + + @Override + public OrderModel save(OrderModel orderModel) { + return orderJpaRepository.save(orderModel); + } + + @Override + public Optional findById(Long id) { + return orderJpaRepository.findById(id); + } + + @Override + public List findAllByUserIdAndCreatedAtBetween(Long userId, ZonedDateTime startAt, ZonedDateTime endAt) { + return orderJpaRepository.findAllByUserIdAndCreatedAtBetween(userId, startAt, endAt); + } + + @Override + public Page findAll(Pageable pageable) { + return orderJpaRepository.findAll(pageable); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/order/AdminOrderV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/order/AdminOrderV1ApiSpec.java new file mode 100644 index 000000000..364626144 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/order/AdminOrderV1ApiSpec.java @@ -0,0 +1,28 @@ +package com.loopers.interfaces.order; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.order.dto.OrderResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Admin Order V1 API", description = "주문 관리자 API 입니다.") +public interface AdminOrderV1ApiSpec { + + @Operation( + summary = "주문 목록 조회", + description = "전체 주문을 페이지네이션으로 조회합니다." + ) + ApiResponse list( + @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") int page, + @Parameter(description = "페이지 크기", example = "20") int size + ); + + @Operation( + summary = "주문 상세 조회", + description = "주문 상세를 조회합니다." + ) + ApiResponse getById( + @Parameter(description = "주문 ID", required = true) Long orderId + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/order/AdminOrderV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/order/AdminOrderV1Controller.java new file mode 100644 index 000000000..2e158df5f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/order/AdminOrderV1Controller.java @@ -0,0 +1,50 @@ +package com.loopers.interfaces.order; + +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.dto.OrderResult; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.order.dto.OrderResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +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; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api-admin/v1/orders") +public class AdminOrderV1Controller implements AdminOrderV1ApiSpec { + + private final OrderFacade orderFacade; + + @GetMapping + @Override + public ApiResponse list( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + Page orderPage = orderFacade.getAllOrders(PageRequest.of(page, size)); + return ApiResponse.success( + new OrderResponse.PageResponse( + orderPage.getNumber(), + orderPage.getSize(), + orderPage.getTotalElements(), + orderPage.getTotalPages(), + orderPage.getContent().stream() + .map(OrderResponse.OrderSummary::from) + .toList())); + } + + @GetMapping("/{orderId}") + @Override + public ApiResponse getById( + @PathVariable Long orderId + ) { + return ApiResponse.success( + OrderResponse.OrderDetail.from( + orderFacade.getOrderDetail(orderId))); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/order/OrderV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/order/OrderV1ApiSpec.java new file mode 100644 index 000000000..ed7b3a5b9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/order/OrderV1ApiSpec.java @@ -0,0 +1,43 @@ +package com.loopers.interfaces.order; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.auth.LoginUser; +import com.loopers.interfaces.order.dto.OrderRequest; +import com.loopers.interfaces.order.dto.OrderResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.time.ZonedDateTime; + +@Tag(name = "Order V1 API", description = "주문 API 입니다.") +public interface OrderV1ApiSpec { + + @Operation( + summary = "주문 생성", + description = "새로운 주문을 생성합니다." + ) + ApiResponse create( + @Parameter(hidden = true) LoginUser loginUser, + @RequestBody(description = "주문 생성 요청 정보") OrderRequest.Create request + ); + + @Operation( + summary = "내 주문 목록 조회", + description = "기간 내 본인의 주문 목록을 조회합니다." + ) + ApiResponse list( + @Parameter(hidden = true) LoginUser loginUser, + @Parameter(description = "시작일시") ZonedDateTime startAt, + @Parameter(description = "종료일시") ZonedDateTime endAt + ); + + @Operation( + summary = "내 주문 상세 조회", + description = "본인의 주문 상세를 조회합니다." + ) + ApiResponse getById( + @Parameter(hidden = true) LoginUser loginUser, + @Parameter(description = "주문 ID", required = true) Long orderId + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/order/OrderV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/order/OrderV1Controller.java new file mode 100644 index 000000000..efdfcfd6c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/order/OrderV1Controller.java @@ -0,0 +1,67 @@ +package com.loopers.interfaces.order; + +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.dto.OrderResult; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.auth.Login; +import com.loopers.interfaces.auth.LoginUser; +import com.loopers.interfaces.order.dto.OrderRequest; +import com.loopers.interfaces.order.dto.OrderResponse; +import jakarta.validation.Valid; +import java.time.ZonedDateTime; +import java.util.List; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/orders") +public class OrderV1Controller implements OrderV1ApiSpec { + + private final OrderFacade orderFacade; + + @PostMapping + @Override + public ApiResponse create( + @Login LoginUser loginUser, + @Valid @RequestBody OrderRequest.Create request + ) { + return ApiResponse.success( + OrderResponse.OrderSummary.from( + orderFacade.createOrder(loginUser.id(), request.toCriteria()))); + } + + @GetMapping + @Override + public ApiResponse list( + @Login LoginUser loginUser, + @RequestParam ZonedDateTime startAt, + @RequestParam ZonedDateTime endAt + ) { + List results = + orderFacade.getMyOrders( + loginUser.id(), + new OrderRequest.ListRequest(startAt, endAt).toCriteria()); + + return ApiResponse.success( + new OrderResponse.ListResponse( + results.stream().map(OrderResponse.OrderSummary::from).toList())); + } + + @GetMapping("/{orderId}") + @Override + public ApiResponse getById( + @Login LoginUser loginUser, + @PathVariable Long orderId + ) { + return ApiResponse.success( + OrderResponse.OrderDetail.from( + orderFacade.getMyOrderDetail(loginUser.id(), orderId))); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/order/dto/OrderRequest.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/order/dto/OrderRequest.java new file mode 100644 index 000000000..29a0c8625 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/order/dto/OrderRequest.java @@ -0,0 +1,46 @@ +package com.loopers.interfaces.order.dto; + +import com.loopers.application.order.dto.OrderCriteria; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import java.time.ZonedDateTime; +import java.util.List; + +public class OrderRequest { + + public record ListRequest( + ZonedDateTime startAt, + ZonedDateTime endAt + ) { + public OrderCriteria.ListByDate toCriteria() { + return new OrderCriteria.ListByDate(startAt, endAt); + } + } + + public record Create( + @NotEmpty(message = "주문 항목은 필수 입력값입니다.") + @Valid + List items + ) { + public OrderCriteria.Create toCriteria() { + return new OrderCriteria.Create( + items.stream() + .map(item -> new OrderCriteria.Create.CreateItem( + item.productId(), item.quantity(), item.expectedPrice())) + .toList()); + } + } + + public record OrderItemRequest( + @NotNull(message = "상품 ID는 필수 입력값입니다.") + Long productId, + @NotNull(message = "수량은 필수 입력값입니다.") + @Min(value = 1, message = "수량은 1 이상이어야 합니다.") + Integer quantity, + @NotNull(message = "예상 가격은 필수 입력값입니다.") + @Min(value = 0, message = "예상 가격은 0 이상이어야 합니다.") + Integer expectedPrice + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/order/dto/OrderResponse.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/order/dto/OrderResponse.java new file mode 100644 index 000000000..c3e7901f8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/order/dto/OrderResponse.java @@ -0,0 +1,73 @@ +package com.loopers.interfaces.order.dto; + +import com.loopers.application.order.dto.OrderResult; +import java.time.ZonedDateTime; +import java.util.List; + +public class OrderResponse { + + public record OrderSummary( + Long orderId, + int totalPrice, + String status, + ZonedDateTime createdAt + ) { + public static OrderSummary from(OrderResult.OrderSummary result) { + return new OrderSummary( + result.orderId(), + result.totalPrice(), + result.status(), + result.createdAt()); + } + } + + public record OrderDetail( + Long orderId, + Long userId, + int totalPrice, + String status, + ZonedDateTime createdAt, + List items + ) { + public static OrderDetail from(OrderResult.OrderDetail result) { + return new OrderDetail( + result.orderId(), + result.userId(), + result.totalPrice(), + result.status(), + result.createdAt(), + result.items().stream().map(OrderItemDetail::from).toList()); + } + } + + public record OrderItemDetail( + Long orderItemId, + Long productId, + String productName, + String brandName, + int orderPrice, + int quantity + ) { + public static OrderItemDetail from(OrderResult.OrderItemDetail result) { + return new OrderItemDetail( + result.orderItemId(), + result.productId(), + result.productName(), + result.brandName(), + result.orderPrice(), + result.quantity()); + } + } + + public record ListResponse( + List items + ) {} + + public record PageResponse( + int page, + int size, + long totalElements, + int totalPages, + List items + ) {} +} From c306b9c5fb85c52e3f1fbd96de631549e1804b07 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Tue, 24 Feb 2026 15:51:05 +0900 Subject: [PATCH 074/108] =?UTF-8?q?test:=20=EC=A3=BC=EB=AC=B8=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/order/FakeOrderItemRepository.java | 34 +++++ .../domain/order/FakeOrderRepository.java | 64 ++++++++++ .../domain/order/OrderItemModelTest.java | 52 ++++++++ .../loopers/domain/order/OrderModelTest.java | 49 ++++++++ .../domain/order/ProductSnapshotTest.java | 48 +++++++ .../loopers/domain/order/QuantityTest.java | 44 +++++++ .../order/AdminOrderV1ControllerTest.java | 89 +++++++++++++ .../order/OrderV1ControllerTest.java | 119 ++++++++++++++++++ 8 files changed, 499 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/FakeOrderItemRepository.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/FakeOrderRepository.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/ProductSnapshotTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/QuantityTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/order/AdminOrderV1ControllerTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/order/OrderV1ControllerTest.java diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/FakeOrderItemRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/FakeOrderItemRepository.java new file mode 100644 index 000000000..974527b82 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/FakeOrderItemRepository.java @@ -0,0 +1,34 @@ +package com.loopers.domain.order; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; + +public class FakeOrderItemRepository implements OrderItemRepository { + + private final Map store = new HashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(1); + + @Override + public OrderItemModel save(OrderItemModel orderItemModel) { + if (orderItemModel.getId() == 0L) { + try { + var idField = orderItemModel.getClass().getSuperclass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(orderItemModel, idGenerator.getAndIncrement()); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException(e); + } + } + store.put(orderItemModel.getId(), orderItemModel); + return orderItemModel; + } + + @Override + public List findAllByOrderId(Long orderId) { + return store.values().stream() + .filter(item -> item.getOrderId().equals(orderId)) + .toList(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/FakeOrderRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/FakeOrderRepository.java new file mode 100644 index 000000000..4d544216e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/FakeOrderRepository.java @@ -0,0 +1,64 @@ +package com.loopers.domain.order; + +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +public class FakeOrderRepository implements OrderRepository { + + private final Map store = new HashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(1); + + @Override + public OrderModel save(OrderModel orderModel) { + if (orderModel.getId() == 0L) { + try { + var idField = orderModel.getClass().getSuperclass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(orderModel, idGenerator.getAndIncrement()); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException(e); + } + } + store.put(orderModel.getId(), orderModel); + return orderModel; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)); + } + + @Override + public List findAllByUserIdAndCreatedAtBetween(Long userId, ZonedDateTime startAt, ZonedDateTime endAt) { + return store.values().stream() + .filter(order -> order.getUserId().equals(userId)) + .filter(order -> { + ZonedDateTime createdAt = order.getCreatedAt(); + if (createdAt == null) return true; + return !createdAt.isBefore(startAt) && !createdAt.isAfter(endAt); + }) + .toList(); + } + + @Override + public Page findAll(Pageable pageable) { + List all = new ArrayList<>(store.values()); + + int start = (int) pageable.getOffset(); + int end = Math.min(start + pageable.getPageSize(), all.size()); + + List pageContent = start >= all.size() + ? new ArrayList<>() + : all.subList(start, end); + + return new PageImpl<>(pageContent, pageable, all.size()); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java new file mode 100644 index 000000000..aff727a4f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java @@ -0,0 +1,52 @@ +package com.loopers.domain.order; + +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 com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("OrderItemModel 단위 테스트") +class OrderItemModelTest { + + @DisplayName("생성") + @Nested + class Create { + + @DisplayName("유효한 값이면 생성에 성공한다 (스냅샷 포함)") + @Test + void create_withValidValues() { + // act + OrderItemModel orderItem = OrderItemModel.create(1L, 10L, 25000, 2, "상품A", "브랜드A"); + + // assert + assertAll( + () -> assertThat(orderItem.getOrderId()).isEqualTo(1L), + () -> assertThat(orderItem.getProductId()).isEqualTo(10L), + () -> assertThat(orderItem.getOrderPrice().getValue()).isEqualTo(25000), + () -> assertThat(orderItem.getQuantity().getValue()).isEqualTo(2), + () -> assertThat(orderItem.getProductSnapshot().getProductName()).isEqualTo("상품A"), + () -> assertThat(orderItem.getProductSnapshot().getBrandName()).isEqualTo("브랜드A") + ); + } + + @DisplayName("orderId가 null이면 예외가 발생한다") + @Test + void create_withNullOrderId_throwsException() { + // act & assert + assertThatThrownBy(() -> OrderItemModel.create(null, 10L, 25000, 2, "상품A", "브랜드A")) + .isInstanceOf(CoreException.class); + } + + @DisplayName("productId가 null이면 예외가 발생한다") + @Test + void create_withNullProductId_throwsException() { + // act & assert + assertThatThrownBy(() -> OrderItemModel.create(1L, null, 25000, 2, "상품A", "브랜드A")) + .isInstanceOf(CoreException.class); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java new file mode 100644 index 000000000..e0370be0e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java @@ -0,0 +1,49 @@ +package com.loopers.domain.order; + +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 com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("OrderModel 단위 테스트") +class OrderModelTest { + + @DisplayName("생성") + @Nested + class Create { + + @DisplayName("유효한 값이면 ORDERED 상태로 생성된다") + @Test + void create_withValidValues() { + // act + OrderModel order = OrderModel.create(1L, 50000); + + // assert + assertAll( + () -> assertThat(order.getUserId()).isEqualTo(1L), + () -> assertThat(order.getTotalPrice().getValue()).isEqualTo(50000), + () -> assertThat(order.getStatus()).isEqualTo(OrderStatus.ORDERED) + ); + } + + @DisplayName("userId가 null이면 예외가 발생한다") + @Test + void create_withNullUserId_throwsException() { + // act & assert + assertThatThrownBy(() -> OrderModel.create(null, 50000)) + .isInstanceOf(CoreException.class); + } + + @DisplayName("totalPrice가 음수이면 예외가 발생한다") + @Test + void create_withNegativeTotalPrice_throwsException() { + // act & assert + assertThatThrownBy(() -> OrderModel.create(1L, -1)) + .isInstanceOf(CoreException.class); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/ProductSnapshotTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/ProductSnapshotTest.java new file mode 100644 index 000000000..d36bf6771 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/ProductSnapshotTest.java @@ -0,0 +1,48 @@ +package com.loopers.domain.order; + +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 com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("ProductSnapshot VO 단위 테스트") +class ProductSnapshotTest { + + @DisplayName("생성") + @Nested + class Create { + + @DisplayName("유효한 값이면 생성에 성공한다") + @Test + void create_withValidValues() { + // act + ProductSnapshot snapshot = new ProductSnapshot("상품A", "브랜드A"); + + // assert + assertAll( + () -> assertThat(snapshot.getProductName()).isEqualTo("상품A"), + () -> assertThat(snapshot.getBrandName()).isEqualTo("브랜드A") + ); + } + + @DisplayName("상품명이 null이면 예외가 발생한다") + @Test + void create_withNullProductName_throwsException() { + // act & assert + assertThatThrownBy(() -> new ProductSnapshot(null, "브랜드A")) + .isInstanceOf(CoreException.class); + } + + @DisplayName("브랜드명이 null이면 예외가 발생한다") + @Test + void create_withNullBrandName_throwsException() { + // act & assert + assertThatThrownBy(() -> new ProductSnapshot("상품A", null)) + .isInstanceOf(CoreException.class); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/QuantityTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/QuantityTest.java new file mode 100644 index 000000000..865ce65f9 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/QuantityTest.java @@ -0,0 +1,44 @@ +package com.loopers.domain.order; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("Quantity VO 단위 테스트") +class QuantityTest { + + @DisplayName("생성") + @Nested + class Create { + + @DisplayName("1 이상이면 생성에 성공한다") + @Test + void create_withValidValue() { + // act + Quantity quantity = new Quantity(1); + + // assert + assertThat(quantity.getValue()).isEqualTo(1); + } + + @DisplayName("0이면 예외가 발생한다") + @Test + void create_withZero_throwsException() { + // act & assert + assertThatThrownBy(() -> new Quantity(0)) + .isInstanceOf(CoreException.class); + } + + @DisplayName("음수이면 예외가 발생한다") + @Test + void create_withNegative_throwsException() { + // act & assert + assertThatThrownBy(() -> new Quantity(-1)) + .isInstanceOf(CoreException.class); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/order/AdminOrderV1ControllerTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/order/AdminOrderV1ControllerTest.java new file mode 100644 index 000000000..f03665a4c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/order/AdminOrderV1ControllerTest.java @@ -0,0 +1,89 @@ +package com.loopers.interfaces.order; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.dto.OrderResult; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.order.dto.OrderResponse; +import java.time.ZonedDateTime; +import java.util.List; +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.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +@DisplayName("AdminOrderV1Controller 단위 테스트") +@ExtendWith(MockitoExtension.class) +class AdminOrderV1ControllerTest { + + @Mock + private OrderFacade orderFacade; + + @InjectMocks + private AdminOrderV1Controller adminOrderV1Controller; + + @DisplayName("GET /api-admin/v1/orders") + @Nested + class ListOrders { + + @DisplayName("전체 주문 목록을 200 응답으로 반환한다") + @Test + void list_returnsPageResponse() { + // arrange + OrderResult.OrderSummary summary = new OrderResult.OrderSummary( + 1L, 50000, "ORDERED", ZonedDateTime.now() + ); + Page page = new PageImpl<>( + List.of(summary), PageRequest.of(0, 20), 1 + ); + when(orderFacade.getAllOrders(any())).thenReturn(page); + + // act + ApiResponse response = adminOrderV1Controller.list(0, 20); + + // assert + assertAll( + () -> assertThat(response.meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS), + () -> assertThat(response.data().totalElements()).isEqualTo(1), + () -> assertThat(response.data().items()).hasSize(1) + ); + } + } + + @DisplayName("GET /api-admin/v1/orders/{orderId}") + @Nested + class GetOrderDetail { + + @DisplayName("주문 상세를 200 응답으로 반환한다") + @Test + void getById_returnsOrderDetail() { + // arrange + OrderResult.OrderDetail result = new OrderResult.OrderDetail( + 1L, 2L, 50000, "ORDERED", ZonedDateTime.now(), + List.of(new OrderResult.OrderItemDetail(1L, 10L, "상품A", "브랜드A", 25000, 2)) + ); + when(orderFacade.getOrderDetail(1L)).thenReturn(result); + + // act + ApiResponse response = adminOrderV1Controller.getById(1L); + + // assert + assertAll( + () -> assertThat(response.meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS), + () -> assertThat(response.data().orderId()).isEqualTo(1L), + () -> assertThat(response.data().userId()).isEqualTo(2L), + () -> assertThat(response.data().items()).hasSize(1) + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/order/OrderV1ControllerTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/order/OrderV1ControllerTest.java new file mode 100644 index 000000000..3cf7bfabf --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/order/OrderV1ControllerTest.java @@ -0,0 +1,119 @@ +package com.loopers.interfaces.order; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.dto.OrderCriteria; +import com.loopers.application.order.dto.OrderResult; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.auth.LoginUser; +import com.loopers.interfaces.order.dto.OrderRequest; +import com.loopers.interfaces.order.dto.OrderResponse; +import java.time.ZonedDateTime; +import java.util.List; +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; + +@DisplayName("OrderV1Controller 단위 테스트") +@ExtendWith(MockitoExtension.class) +class OrderV1ControllerTest { + + @Mock + private OrderFacade orderFacade; + + @InjectMocks + private OrderV1Controller orderV1Controller; + + private final LoginUser loginUser = new LoginUser(1L, "testuser", "테스터"); + + @DisplayName("POST /api/v1/orders") + @Nested + class CreateOrder { + + @DisplayName("주문 생성 요청이면, 201 응답을 반환한다") + @Test + void create_returnsCreatedResponse() { + // arrange + OrderRequest.Create request = new OrderRequest.Create(List.of( + new OrderRequest.OrderItemRequest(10L, 2, 25000) + )); + + OrderResult.OrderSummary result = new OrderResult.OrderSummary( + 1L, 50000, "ORDERED", ZonedDateTime.now() + ); + when(orderFacade.createOrder(eq(1L), any(OrderCriteria.Create.class))).thenReturn(result); + + // act + ApiResponse response = orderV1Controller.create(loginUser, request); + + // assert + assertAll( + () -> assertThat(response.meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS), + () -> assertThat(response.data().orderId()).isEqualTo(1L), + () -> assertThat(response.data().totalPrice()).isEqualTo(50000) + ); + } + } + + @DisplayName("GET /api/v1/orders") + @Nested + class ListOrders { + + @DisplayName("기간 내 주문 목록을 200 응답으로 반환한다") + @Test + void list_returnsOrderList() { + // arrange + ZonedDateTime startAt = ZonedDateTime.now().minusDays(7); + ZonedDateTime endAt = ZonedDateTime.now(); + + List results = List.of( + new OrderResult.OrderSummary(1L, 50000, "ORDERED", ZonedDateTime.now()) + ); + when(orderFacade.getMyOrders(eq(1L), any(OrderCriteria.ListByDate.class))).thenReturn(results); + + // act + ApiResponse response = orderV1Controller.list(loginUser, startAt, endAt); + + // assert + assertAll( + () -> assertThat(response.meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS), + () -> assertThat(response.data().items()).hasSize(1) + ); + } + } + + @DisplayName("GET /api/v1/orders/{orderId}") + @Nested + class GetOrderDetail { + + @DisplayName("주문 상세를 200 응답으로 반환한다") + @Test + void getById_returnsOrderDetail() { + // arrange + OrderResult.OrderDetail result = new OrderResult.OrderDetail( + 1L, 1L, 50000, "ORDERED", ZonedDateTime.now(), + List.of(new OrderResult.OrderItemDetail(1L, 10L, "상품A", "브랜드A", 25000, 2)) + ); + when(orderFacade.getMyOrderDetail(1L, 1L)).thenReturn(result); + + // act + ApiResponse response = orderV1Controller.getById(loginUser, 1L); + + // assert + assertAll( + () -> assertThat(response.meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS), + () -> assertThat(response.data().orderId()).isEqualTo(1L), + () -> assertThat(response.data().items()).hasSize(1) + ); + } + } +} From 78870169887bcc7713d03ce78e5100b39e0ac22a Mon Sep 17 00:00:00 2001 From: plan11plan Date: Tue, 24 Feb 2026 15:51:10 +0900 Subject: [PATCH 075/108] =?UTF-8?q?docs:=20=EC=BB=A8=EB=B2=A4=EC=85=98=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EA=B0=9C=EC=84=A0=20(200=20=ED=86=B5?= =?UTF-8?q?=EC=9D=BC,=20V1=20=EB=84=A4=EC=9D=B4=EB=B0=8D,=20Page=20?= =?UTF-8?q?=ED=97=88=EC=9A=A9,=20CUD=20=EB=B0=98=ED=99=98=20=EA=B7=9C?= =?UTF-8?q?=EC=B9=99)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/service-layer-convention.md | 29 +++++++++++--- .../infrastructure-convention.md | 6 ++- .../references/interfaces/api-convention.md | 39 ++++++++++--------- .../interfaces/swagger-convention.md | 14 +++---- 4 files changed, 55 insertions(+), 33 deletions(-) diff --git a/.claude/skills/project-convention/references/application/service-layer-convention.md b/.claude/skills/project-convention/references/application/service-layer-convention.md index 5bda63ec6..3db5968fc 100644 --- a/.claude/skills/project-convention/references/application/service-layer-convention.md +++ b/.claude/skills/project-convention/references/application/service-layer-convention.md @@ -149,12 +149,29 @@ public class OrderFacade { - DTO 변환 (Info → Response 등) → Facade 또는 Interface 계층 - Entity 자기 필드만으로 완결되는 로직 → Entity 메서드 -### CUD 메서드는 void를 반환한다 +### CUD 메서드의 반환 규칙 -Domain Service의 생성/수정/삭제(CUD) 메서드는 **void**를 반환한다. 상위 계층(Facade)에서 필요하면 별도 조회 메서드를 호출한다. +Domain Service의 생성/수정/삭제(CUD) 메서드는 **기본적으로 void**를 반환한다. 명령과 조회를 분리하여 메서드의 의도를 명확하게 한다. -- YAGNI: 반환값이 필요할 때 추가하면 된다 -- 명령과 조회를 분리하여 메서드의 의도가 명확해진다 +**Entity 반환이 허용되는 경우:** +- Facade에서 생성된 Entity의 **ID나 상태를 즉시 사용**해야 할 때 (예: 생성 후 하위 엔티티에 ID 전달, 응답 반환) +- **별도 조회가 비효율적**일 때 (예: save() 직후 동일 Entity를 다시 findById()하는 것은 불필요) + +```java +// ✅ Entity 반환 — 생성 후 ID/상태를 Facade에서 즉시 사용 +@Transactional +public Order create(OrderMemberCommand member, List products) { + Order order = Order.create(member.memberId(), products); + return orderRepository.save(order); // 생성된 ID를 Facade에서 활용 +} + +// ✅ void — 상태 변경 후 반환할 필요 없음 +@Transactional +public void cancel(Long orderId) { + Order order = getOrder(orderId); + order.cancel(); +} +``` ### 예시 @@ -165,12 +182,12 @@ public class OrderService { private final OrderRepository orderRepository; @Transactional - public void create(OrderMemberCommand member, List products) { + public Order create(OrderMemberCommand member, List products) { List lines = products.stream() .map(p -> OrderLine.create(p.productId(), p.name(), p.price())) .toList(); Order order = Order.create(member.memberId(), lines); - orderRepository.save(order); + return orderRepository.save(order); } @Transactional diff --git a/.claude/skills/project-convention/references/infrastructure/infrastructure-convention.md b/.claude/skills/project-convention/references/infrastructure/infrastructure-convention.md index 3fac734f3..0e9a1ff15 100644 --- a/.claude/skills/project-convention/references/infrastructure/infrastructure-convention.md +++ b/.claude/skills/project-convention/references/infrastructure/infrastructure-convention.md @@ -52,7 +52,11 @@ public interface OrderRepository { **금지:** - `JpaRepository` 상속 - Spring 어노테이션 (`@Repository`, `@Query` 등) -- `Pageable`, `Page` 등 Spring Data 타입 + +**예외 허용 — `Page`, `Pageable`:** +- `Page`와 `Pageable`은 단순 데이터 구조(페이지네이션 메타정보)에 가까워 도메인 오염이 적다 +- 별도 래퍼를 만들면 RepositoryImpl에서 매번 변환 보일러플레이트가 발생하므로, 실용성을 우선한다 +- domain Repository 인터페이스에서 `Page`, `Pageable`을 직접 사용할 수 있다 ### JpaRepository 인터페이스 diff --git a/.claude/skills/project-convention/references/interfaces/api-convention.md b/.claude/skills/project-convention/references/interfaces/api-convention.md index 82aac5f8d..17739e16c 100644 --- a/.claude/skills/project-convention/references/interfaces/api-convention.md +++ b/.claude/skills/project-convention/references/interfaces/api-convention.md @@ -114,25 +114,24 @@ GET /api/v1/products body: { "brandId": 1 } ❌ | 상황 | 상태 코드 | 응답 Body | |------|----------|----------| | 조회 성공 | **200 OK** | `ApiResponse.success(data)` | -| 생성 성공 | **201 Created** | `ApiResponse.success(data)` | +| 생성 성공 | **200 OK** | `ApiResponse.success(data)` | | 수정 성공 | **200 OK** | `ApiResponse.success(data)` 또는 `ApiResponse.success()` | | 삭제 성공 | **200 OK** | `ApiResponse.success()` | -생성(POST)만 **201**로 구분한다. 삭제에 204(No Content)를 쓰지 않는 이유는 `ApiResponse` 래퍼를 일관되게 유지하기 위함이다 — 204는 body가 비어야 하므로 `ApiResponse` 포맷과 충돌한다. +모든 성공 응답은 **200 OK**로 통일한다. `ApiResponse` 래퍼가 `meta.result = SUCCESS/FAIL`로 성공/실패를 명확히 구분하므로, HTTP 상태 코드를 세분화할 실익이 없다. 상태 코드를 개발자가 매번 기억하고 관리해야 하는 부담도 제거된다. 삭제에 204(No Content)를 쓰지 않는 이유는 `ApiResponse` 래퍼를 일관되게 유지하기 위함이다 — 204는 body가 비어야 하므로 `ApiResponse` 포맷과 충돌한다. ```java // Controller 예시 @PostMapping -public ResponseEntity> create(...) { +public ApiResponse create(...) { ProductInfo info = productFacade.create(request.toCommand()); - return ResponseEntity.status(HttpStatus.CREATED) - .body(ApiResponse.success(ProductDetailResponse.from(info))); + return ApiResponse.success(ProductDetailResponse.from(info)); } @DeleteMapping("/{productId}") -public ResponseEntity> delete(...) { +public ApiResponse delete(...) { productFacade.delete(productId); - return ResponseEntity.ok(ApiResponse.success()); + return ApiResponse.success(); } ``` @@ -301,8 +300,8 @@ GET /api/v1/orders?startAt=2025-01-01&endAt=2025-01-31 ``` interfaces/ └── product/ - ├── ProductController.java ← /api/v1/products (고객) - ├── AdminProductController.java ← /api-admin/v1/products (Admin) + ├── ProductV1Controller.java ← /api/v1/products (고객) + ├── AdminProductV1Controller.java ← /api-admin/v1/products (Admin) └── dto/ ├── ProductDto.java ← 고객용 Request/Response └── AdminProductDto.java ← Admin용 Request/Response @@ -312,13 +311,15 @@ interfaces/ | 대상 | 네이밍 | RequestMapping | |------|--------|----------------| -| 고객 | `{Domain}Controller` | `@RequestMapping("/api/v1/{resources}")` | -| Admin | `Admin{Domain}Controller` | `@RequestMapping("/api-admin/v1/{resources}")` | +| 고객 | `{Domain}V1Controller` | `@RequestMapping("/api/v1/{resources}")` | +| Admin | `Admin{Domain}V1Controller` | `@RequestMapping("/api-admin/v1/{resources}")` | + +Controller 이름에 **V1**을 포함한다. URL에 `/api/v1`이 명시되어 있으므로, V2 API 추가 시 `{Domain}V2Controller`로 자연스럽게 확장된다. ApiSpec 인터페이스(`{Domain}V1ApiSpec`)와 네이밍이 일관된다. ```java @RestController @RequestMapping("/api/v1/products") -public class ProductController { +public class ProductV1Controller { private final ProductFacade productFacade; @GetMapping("/{productId}") @@ -327,7 +328,7 @@ public class ProductController { @RestController @RequestMapping("/api-admin/v1/products") -public class AdminProductController { +public class AdminProductV1Controller { private final ProductFacade productFacade; @GetMapping("/{productId}") @@ -341,12 +342,12 @@ public class AdminProductController { ``` // 초기 — Facade 공유 -ProductController → ProductFacade -AdminProductController → ProductFacade +ProductV1Controller → ProductFacade +AdminProductV1Controller → ProductFacade // Admin 로직이 커지면 — Facade 분리 -ProductController → ProductFacade -AdminProductController → AdminProductFacade +ProductV1Controller → ProductFacade +AdminProductV1Controller → AdminProductFacade ``` 분리 시점: Admin 전용 메서드가 Facade의 절반 이상을 차지하거나, Admin만의 복잡한 유스케이스가 생길 때. @@ -427,7 +428,7 @@ DELETE 성공 시 `data: null`로 반환한다. **HTTP 메서드/상태 코드** - [ ] GET 조회, POST 생성, PUT 수정, DELETE 삭제를 지키는가? - [ ] GET 요청에 Body가 없는가? -- [ ] 생성 성공은 201, 나머지 성공은 200인가? +- [ ] 모든 성공 응답이 200인가? (`@ResponseStatus`, `ResponseEntity` 불필요) - [ ] 에러 응답이 200이 아닌 ErrorCode.getStatus() 기준인가? **쿼리 파라미터** @@ -437,7 +438,7 @@ DELETE 성공 시 `data: null`로 반환한다. **Controller 분리** - [ ] 고객/Admin Controller가 분리되어 있는가? -- [ ] Admin Controller 네이밍이 `Admin{Domain}Controller`인가? +- [ ] Controller 네이밍이 `{Domain}V1Controller` / `Admin{Domain}V1Controller`인가? - [ ] 고객/Admin DTO가 분리되어 있는가? - [ ] Controller가 핵심 리소스의 도메인 패키지에 배치되어 있는가? diff --git a/.claude/skills/project-convention/references/interfaces/swagger-convention.md b/.claude/skills/project-convention/references/interfaces/swagger-convention.md index 7c47dd38a..4f50d0f81 100644 --- a/.claude/skills/project-convention/references/interfaces/swagger-convention.md +++ b/.claude/skills/project-convention/references/interfaces/swagger-convention.md @@ -22,7 +22,7 @@ Controller에 Swagger 어노테이션을 직접 달지 않는다. **ApiSpec 인 interfaces/ └── user/ ├── UserV1ApiSpec.java ← Swagger 어노테이션 (인터페이스) - ├── UserController.java ← implements UserV1ApiSpec + ├── UserV1Controller.java ← implements UserV1ApiSpec └── dto/ └── UserV1Dto.java ``` @@ -77,22 +77,22 @@ ApiSpec 인터페이스는 **Controller와 같은 패키지**에 둔다. interfaces/ ├── user/ │ ├── UserV1ApiSpec.java -│ ├── UserController.java +│ ├── UserV1Controller.java │ └── dto/ │ └── UserV1Dto.java │ ├── product/ │ ├── ProductV1ApiSpec.java │ ├── AdminProductV1ApiSpec.java -│ ├── ProductController.java -│ ├── AdminProductController.java +│ ├── ProductV1Controller.java +│ ├── AdminProductV1Controller.java │ └── dto/ │ ├── ProductV1Dto.java │ └── AdminProductV1Dto.java │ └── like/ ├── LikeV1ApiSpec.java - ├── LikeController.java + ├── LikeV1Controller.java └── dto/ └── LikeV1Dto.java ``` @@ -217,7 +217,7 @@ ApiResponse create( @RestController @RequestMapping("/api/v1/users") @RequiredArgsConstructor -public class UserController implements UserV1ApiSpec { +public class UserV1Controller implements UserV1ApiSpec { private final UserFacade userFacade; @@ -255,7 +255,7 @@ public class UserController implements UserV1ApiSpec { @RestController @RequestMapping("/api-admin/v1/products") @RequiredArgsConstructor -public class AdminProductController implements AdminProductV1ApiSpec { +public class AdminProductV1Controller implements AdminProductV1ApiSpec { private final ProductFacade productFacade; From ca876f436937226e0e656b6969668ee19bbff3d9 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Tue, 24 Feb 2026 20:38:48 +0900 Subject: [PATCH 076/108] =?UTF-8?q?refactor:=20=EC=A3=BC=EB=AC=B8=20VO=20?= =?UTF-8?q?=EC=A0=95=EC=A0=81=20=ED=8C=A9=ED=86=A0=EB=A6=AC=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=ED=8C=A8=ED=84=B4=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/domain/order/Money.java | 17 ++++++++++------- .../loopers/domain/order/OrderItemModel.java | 11 +++++------ .../com/loopers/domain/order/OrderModel.java | 2 +- .../loopers/domain/order/ProductSnapshot.java | 11 ++++++++--- .../java/com/loopers/domain/order/Quantity.java | 17 ++++++++++------- 5 files changed, 34 insertions(+), 24 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Money.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Money.java index 0d48278dd..35dd00d0e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/Money.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Money.java @@ -3,28 +3,31 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import jakarta.persistence.Embeddable; +import lombok.AccessLevel; import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +@Getter @Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) @EqualsAndHashCode public class Money { private int value; - protected Money() {} - - public Money(int value) { + private Money(int value) { validate(value); this.value = value; } + public static Money of(int value) { + return new Money(value); + } + private void validate(int value) { if (value < 0) { throw new CoreException(ErrorType.BAD_REQUEST, "가격은 0 이상이어야 합니다."); } } - - public int getValue() { - return value; - } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java index c7c8482c9..9bbd789b3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java @@ -49,12 +49,11 @@ public static OrderItemModel create( validateOrderId(orderId); validateProductId(productId); return new OrderItemModel( - orderId, - productId, - new Money(orderPrice), - new Quantity(quantity), - new ProductSnapshot(productName, brandName) - ); + orderId, + productId, + Money.of(orderPrice), + Quantity.of(quantity), + ProductSnapshot.of(productName, brandName)); } private static void validateOrderId(Long orderId) { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java index b9efe8f3d..6010d7853 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java @@ -39,7 +39,7 @@ private OrderModel(Long userId, Money totalPrice, OrderStatus status) { public static OrderModel create(Long userId, int totalPrice) { validateUserId(userId); - return new OrderModel(userId, new Money(totalPrice), OrderStatus.ORDERED); + return new OrderModel(userId, Money.of(totalPrice), OrderStatus.ORDERED); } public void validateOwner(Long userId) { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/ProductSnapshot.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/ProductSnapshot.java index d0372a076..88b03907f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/ProductSnapshot.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/ProductSnapshot.java @@ -4,11 +4,14 @@ import com.loopers.support.error.ErrorType; import jakarta.persistence.Column; import jakarta.persistence.Embeddable; +import lombok.AccessLevel; import lombok.EqualsAndHashCode; import lombok.Getter; +import lombok.NoArgsConstructor; @Getter @Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) @EqualsAndHashCode public class ProductSnapshot { @@ -18,14 +21,16 @@ public class ProductSnapshot { @Column(name = "brand_name", nullable = false) private String brandName; - protected ProductSnapshot() {} - - public ProductSnapshot(String productName, String brandName) { + private ProductSnapshot(String productName, String brandName) { validate(productName, brandName); this.productName = productName; this.brandName = brandName; } + public static ProductSnapshot of(String productName, String brandName) { + return new ProductSnapshot(productName, brandName); + } + private void validate(String productName, String brandName) { if (productName == null || productName.isBlank()) { throw new CoreException(ErrorType.BAD_REQUEST, "상품명은 필수값입니다."); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Quantity.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Quantity.java index 01c1ecb3f..78c090efb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/Quantity.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Quantity.java @@ -3,28 +3,31 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import jakarta.persistence.Embeddable; +import lombok.AccessLevel; import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +@Getter @Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) @EqualsAndHashCode public class Quantity { private int value; - protected Quantity() {} - - public Quantity(int value) { + private Quantity(int value) { validate(value); this.value = value; } + public static Quantity of(int value) { + return new Quantity(value); + } + private void validate(int value) { if (value < 1) { throw new CoreException(ErrorType.BAD_REQUEST, "수량은 1 이상이어야 합니다."); } } - - public int getValue() { - return value; - } } From a7109ec64ae08fc0a2aef6cf46505d610612266f Mon Sep 17 00:00:00 2001 From: plan11plan Date: Tue, 24 Feb 2026 20:38:53 +0900 Subject: [PATCH 077/108] =?UTF-8?q?test:=20=EC=A3=BC=EB=AC=B8=20VO=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=A0=95=EC=A0=81=20=ED=8C=A9?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=EB=A9=94=EC=84=9C=EB=93=9C=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/domain/order/ProductSnapshotTest.java | 6 +++--- .../test/java/com/loopers/domain/order/QuantityTest.java | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/ProductSnapshotTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/ProductSnapshotTest.java index d36bf6771..094ba7d82 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/ProductSnapshotTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/ProductSnapshotTest.java @@ -20,7 +20,7 @@ class Create { @Test void create_withValidValues() { // act - ProductSnapshot snapshot = new ProductSnapshot("상품A", "브랜드A"); + ProductSnapshot snapshot = ProductSnapshot.of("상품A", "브랜드A"); // assert assertAll( @@ -33,7 +33,7 @@ void create_withValidValues() { @Test void create_withNullProductName_throwsException() { // act & assert - assertThatThrownBy(() -> new ProductSnapshot(null, "브랜드A")) + assertThatThrownBy(() -> ProductSnapshot.of(null, "브랜드A")) .isInstanceOf(CoreException.class); } @@ -41,7 +41,7 @@ void create_withNullProductName_throwsException() { @Test void create_withNullBrandName_throwsException() { // act & assert - assertThatThrownBy(() -> new ProductSnapshot("상품A", null)) + assertThatThrownBy(() -> ProductSnapshot.of("상품A", null)) .isInstanceOf(CoreException.class); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/QuantityTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/QuantityTest.java index 865ce65f9..5d5af8cc9 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/QuantityTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/QuantityTest.java @@ -19,7 +19,7 @@ class Create { @Test void create_withValidValue() { // act - Quantity quantity = new Quantity(1); + Quantity quantity = Quantity.of(1); // assert assertThat(quantity.getValue()).isEqualTo(1); @@ -29,7 +29,7 @@ void create_withValidValue() { @Test void create_withZero_throwsException() { // act & assert - assertThatThrownBy(() -> new Quantity(0)) + assertThatThrownBy(() -> Quantity.of(0)) .isInstanceOf(CoreException.class); } @@ -37,7 +37,7 @@ void create_withZero_throwsException() { @Test void create_withNegative_throwsException() { // act & assert - assertThatThrownBy(() -> new Quantity(-1)) + assertThatThrownBy(() -> Quantity.of(-1)) .isInstanceOf(CoreException.class); } } From 323f8c1992c0736f7ec5046c96171e33aaeb04ef Mon Sep 17 00:00:00 2001 From: plan11plan Date: Tue, 24 Feb 2026 20:38:56 +0900 Subject: [PATCH 078/108] =?UTF-8?q?chore:=20=EC=BB=A8=EB=B2=A4=EC=85=98=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EC=97=90=EC=9D=B4=EC=A0=84=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/agents/convention-review/AGENT.md | 127 ++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 .claude/agents/convention-review/AGENT.md diff --git a/.claude/agents/convention-review/AGENT.md b/.claude/agents/convention-review/AGENT.md new file mode 100644 index 000000000..d888fd1d9 --- /dev/null +++ b/.claude/agents/convention-review/AGENT.md @@ -0,0 +1,127 @@ +# Convention Review Agent + +작업 완료 후 **서브 에이전트(Sonnet 4.6)**를 띄워 컨벤션 위반을 검출한다. + +## 왜 서브 에이전트인가 + +메인 에이전트는 컨텍스트가 커질수록 스킬/컨벤션 문서의 attention이 약해져 위반을 놓친다. +서브 에이전트는 **매번 새 컨텍스트**로 시작하므로 컨벤션 문서에 대한 attention이 100%에 가깝다. + +--- + +## 실행 절차 + +### Step 0: 사용자에게 알림 + +서브 에이전트를 실행하기 전에 **반드시** 사용자에게 알린다: + +``` +🔍 컨벤션 리뷰 에이전트(Sonnet)를 실행합니다. (~47초 소요) + 대상: {변경 파일 수}개 Java 파일 + 검증: {적용할 컨벤션 문서 목록} +``` + +### Step 1: 변경 파일 파악 + +```bash +git diff --name-only HEAD +``` + +커밋 전이면 `git diff --name-only`와 `git diff --cached --name-only`로 변경 파일을 파악한다. +`.java` 파일만 필터링한다. + +### Step 2: 변경 파일의 계층 분류 + +변경 파일을 계층별로 분류하고, 각 계층에 해당하는 컨벤션 문서를 결정한다. + +| 파일 경로 패턴 | 계층 | 필수 컨벤션 문서 | +|--------------|------|----------------| +| `interfaces/` | Interface | `inline-variable-convention.md` | +| `application/` | Application | `inline-variable-convention.md`, `service-layer-convention.md` | +| `domain/` | Domain | `inline-variable-convention.md`, `entity-vo-convention.md` | +| `infrastructure/` | Infrastructure | `infrastructure-convention.md` | +| `*Dto*.java`, `*Request*.java`, `*Response*.java`, `*Result*.java`, `*Criteria*.java` | DTO | `inline-variable-convention.md`, `dto-convention.md` | + +`inline-variable-convention.md`는 **모든 계층**에서 필수로 포함한다. + +### Step 3: 서브 에이전트 실행 + +Task 도구로 서브 에이전트를 생성한다. + +``` +Task( + subagent_type: "general-purpose", + model: "sonnet", + prompt: <아래 프롬프트 템플릿> +) +``` + +### Step 4: 결과 보고 + +서브 에이전트의 결과를 사용자에게 보여준다. + +- 위반 있음 → 파일:라인 — 위반 내용 — 수정 제안 형식으로 보고 +- 위반 없음 → "컨벤션 위반 없음" 보고 +- 오탐 가능성이 있는 항목은 별도 표시 + +--- + +## 서브 에이전트 프롬프트 템플릿 + +``` +당신은 Java Spring 프로젝트의 코드 컨벤션 리뷰어입니다. + +## 절차 + +1. 아래 컨벤션 문서를 Read로 읽으세요: + {계층별로 필요한 컨벤션 문서 절대경로 목록} + +2. 아래 코드 파일을 Read로 읽으세요: + {변경된 .java 파일 절대경로 목록} + +3. 컨벤션 문서의 규칙을 기준으로 코드를 검토하세요. + +## 검토 항목 + +### 인라인 변수 (inline-variable-convention.md) +- 1회 참조 변수가 인라인되지 않고 남아있는가 +- 2회 이상 참조 변수를 불필요하게 인라인하지 않았는가 +- 메서드 호출 인자의 줄바꿈이 8-space continuation indent를 따르는가 +- 닫는 괄호가 마지막 인자에 붙어 있는가 (별도 줄 X) +- Align-to-parenthesis를 사용하지 않았는가 + +### 계층별 규칙 (해당 계층의 컨벤션 문서) +- 컨벤션 문서에 명시된 규칙 위반이 있는가 + +## 검토 제외 (오탐 방지) + +- **메서드 선언부 파라미터**의 들여쓰기는 검토하지 마세요. + 8-space continuation indent는 **메서드 호출 인자**, **변수 할당의 우변**, + **return 문의 값** 등에 적용됩니다. + 메서드 선언부 `public void foo(` 뒤의 파라미터 들여쓰기는 이 규칙의 대상이 아닙니다. +- **테스트 코드**는 프로덕션 코드와 다른 스타일을 허용합니다. + 테스트의 가독성을 위한 변수 추출은 위반으로 보지 마세요. + +## 보고 형식 + +위반이 있으면: +``` +[위반] 파일명:라인번호 — 위반 규칙 — 설명 + 현재: (현재 코드) + 수정: (수정 제안) +``` + +위반이 없으면: +``` +컨벤션 위반 없음 +``` +``` + +--- + +## 주의사항 + +- 리뷰 모델은 **Sonnet 4.6** 사용 (비용 ~$0.05/회, 속도 ~47초) +- 컨벤션 문서는 **서브 에이전트가 직접 Read** — 메인 에이전트 컨텍스트에서 복사하지 않음 +- 변경 파일이 많으면 계층별로 서브 에이전트를 **병렬** 실행 가능 +- 오탐이 발견되면 이 문서의 "검토 제외" 섹션에 추가할 것 From c9e93a734a6c9a692040649b664f6622e63e9ded Mon Sep 17 00:00:00 2001 From: plan11plan Date: Tue, 24 Feb 2026 20:39:00 +0900 Subject: [PATCH 079/108] =?UTF-8?q?docs:=20CLAUDE.md=EC=97=90=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=ED=95=B5=EC=8B=AC=20?= =?UTF-8?q?=EA=B7=9C=EC=B9=99=20=EB=B0=8F=20=EB=A6=AC=EB=B7=B0=20=EC=97=90?= =?UTF-8?q?=EC=9D=B4=EC=A0=84=ED=8A=B8=20=EC=B0=B8=EC=A1=B0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/CLAUDE.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 23c7f3228..d9c2cd333 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -43,6 +43,14 @@ supports/ - 코딩 컨벤션: `.claude/skills/project-convention/` 참조 (코드 작성 시 해당 스킬의 references/ 하위 문서를 반드시 Read 도구로 읽을 것) - 커밋 규칙: `.claude/skills/commit-convention/` 참조 +### 코드 스타일 핵심 (매 작업 시 준수) + +- 1회용 변수는 인라인 (2회 이상 참조 시에만 변수 추출) +- 메서드 체이닝 줄바꿈: 8-space continuation indent +- 닫는 괄호: 마지막 인자에 붙임 (별도 줄 금지) +- 컨텍스트가 길어질수록 컨벤션 누락 가능성 증가 → 코드 작성 전 관련 컨벤션 문서를 반드시 다시 Read할 것 +- **기능 완료 후 커밋 전**: `.claude/agents/convention-review/AGENT.md`를 읽고 서브 에이전트(Sonnet) 리뷰를 실행할 것 + ### 설계 문서 기능 개발 시 해당 도메인의 설계 문서를 **먼저 읽고** 시작한다. From c024e5b5949ce41677213007b56f529a45bf9f4b Mon Sep 17 00:00:00 2001 From: plan11plan Date: Wed, 25 Feb 2026 17:09:59 +0900 Subject: [PATCH 080/108] =?UTF-8?q?refactor:=20=EC=A0=84=EC=B2=B4=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20VO=20=EC=A0=9C=EA=B1=B0=20?= =?UTF-8?q?=EB=B0=8F=20Entity=20=EC=9B=90=EC=8B=9C=EA=B0=92=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../application/order/OrderFacade.java | 50 ++++---- .../application/order/dto/OrderResult.java | 12 +- .../product/dto/ProductResult.java | 4 +- .../application/user/dto/UserResult.java | 8 +- .../java/com/loopers/domain/order/Money.java | 33 ------ .../loopers/domain/order/OrderItemModel.java | 65 +++++++---- .../com/loopers/domain/order/OrderModel.java | 18 +-- .../loopers/domain/order/ProductSnapshot.java | 42 ------- .../com/loopers/domain/order/Quantity.java | 33 ------ .../com/loopers/domain/product/Money.java | 30 ----- .../loopers/domain/product/ProductModel.java | 51 ++++++--- .../com/loopers/domain/product/Stock.java | 41 ------- .../domain/user/AuthenticationService.java | 6 +- .../com/loopers/domain/user/BirthDate.java | 41 ------- .../java/com/loopers/domain/user/Email.java | 34 ------ .../domain/user/EncryptedPassword.java | 46 -------- .../java/com/loopers/domain/user/LoginId.java | 41 ------- .../java/com/loopers/domain/user/Name.java | 34 ------ .../com/loopers/domain/user/UserModel.java | 108 ++++++++++++------ .../loopers/domain/user/UserRepository.java | 2 +- .../com/loopers/domain/user/UserService.java | 49 ++++++-- .../user/UserJpaRepository.java | 7 +- .../user/UserRepositoryImpl.java | 3 +- .../loopers/interfaces/auth/AuthFilter.java | 10 +- 24 files changed, 243 insertions(+), 525 deletions(-) delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/Money.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/ProductSnapshot.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/Quantity.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/Stock.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDate.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/Email.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/EncryptedPassword.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/LoginId.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/Name.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java index 7b1056278..4c14627f1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -2,8 +2,6 @@ import com.loopers.application.order.dto.OrderCriteria; import com.loopers.application.order.dto.OrderResult; -import com.loopers.domain.order.OrderItemModel; -import com.loopers.domain.order.OrderModel; import com.loopers.domain.order.OrderService; import com.loopers.domain.order.dto.OrderCommand; import com.loopers.domain.product.ProductModel; @@ -27,31 +25,27 @@ public class OrderFacade { @Transactional public OrderResult.OrderSummary createOrder(Long userId, OrderCriteria.Create criteria) { - List productIds = criteria.items().stream() - .map(OrderCriteria.Create.CreateItem::productId) - .toList(); - Map productMap = - productService.getAllByIds(productIds).stream() + productService.getAllByIds(criteria.items().stream() + .map(OrderCriteria.Create.CreateItem::productId) + .toList()).stream() .collect(Collectors.toMap(ProductModel::getId, Function.identity())); - List commandItems = criteria.items().stream() - .map(item -> { - ProductModel product = productMap.get(item.productId()); - product.validatePrice(item.expectedPrice()); - product.decreaseStock(item.quantity()); - return new OrderCommand.Create.CreateItem( - item.productId(), - product.getPrice().getValue(), - item.quantity(), - product.getName(), - product.getBrand().getName()); - }) - .toList(); - return OrderResult.OrderSummary.from( orderService.createOrder( - new OrderCommand.Create(userId, commandItems))); + new OrderCommand.Create(userId, criteria.items().stream() + .map(item -> { + ProductModel product = productMap.get(item.productId()); + product.validateExpectedPrice(item.expectedPrice()); + product.decreaseStock(item.quantity()); + return new OrderCommand.Create.CreateItem( + item.productId(), + product.getPrice(), + item.quantity(), + product.getName(), + product.getBrand().getName()); + }) + .toList()))); } @Transactional(readOnly = true) @@ -63,9 +57,9 @@ public List getMyOrders(Long userId, OrderCriteria.Lis @Transactional(readOnly = true) public OrderResult.OrderDetail getMyOrderDetail(Long userId, Long orderId) { - OrderModel order = orderService.getByIdAndUserId(orderId, userId); - List items = orderService.getOrderItemsByOrderId(orderId); - return OrderResult.OrderDetail.from(order, items); + return OrderResult.OrderDetail.from( + orderService.getByIdAndUserId(orderId, userId), + orderService.getOrderItemsByOrderId(orderId)); } @Transactional(readOnly = true) @@ -76,9 +70,9 @@ public Page getAllOrders(Pageable pageable) { @Transactional(readOnly = true) public OrderResult.OrderDetail getOrderDetail(Long orderId) { - OrderModel order = orderService.getById(orderId); - List items = orderService.getOrderItemsByOrderId(orderId); - return OrderResult.OrderDetail.from(order, items); + return OrderResult.OrderDetail.from( + orderService.getById(orderId), + orderService.getOrderItemsByOrderId(orderId)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/dto/OrderResult.java b/apps/commerce-api/src/main/java/com/loopers/application/order/dto/OrderResult.java index e5d1ff64d..69f487e98 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/dto/OrderResult.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/dto/OrderResult.java @@ -16,7 +16,7 @@ public record OrderSummary( public static OrderSummary from(OrderModel model) { return new OrderSummary( model.getId(), - model.getTotalPrice().getValue(), + model.getTotalPrice(), model.getStatus().name(), model.getCreatedAt()); } @@ -34,7 +34,7 @@ public static OrderDetail from(OrderModel model, List items) { return new OrderDetail( model.getId(), model.getUserId(), - model.getTotalPrice().getValue(), + model.getTotalPrice(), model.getStatus().name(), model.getCreatedAt(), items.stream().map(OrderItemDetail::from).toList()); @@ -53,10 +53,10 @@ public static OrderItemDetail from(OrderItemModel model) { return new OrderItemDetail( model.getId(), model.getProductId(), - model.getProductSnapshot().getProductName(), - model.getProductSnapshot().getBrandName(), - model.getOrderPrice().getValue(), - model.getQuantity().getValue()); + model.getProductName(), + model.getBrandName(), + model.getOrderPrice(), + model.getQuantity()); } } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/dto/ProductResult.java b/apps/commerce-api/src/main/java/com/loopers/application/product/dto/ProductResult.java index d822227df..10e709f35 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/dto/ProductResult.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/dto/ProductResult.java @@ -20,8 +20,8 @@ public static ProductResult from(ProductModel model) { model.getBrand().getId(), model.getBrand().getName(), model.getName(), - model.getPrice().getValue(), - model.getStock().getValue(), + model.getPrice(), + model.getStock(), model.getCreatedAt(), model.getUpdatedAt(), model.getDeletedAt()); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/dto/UserResult.java b/apps/commerce-api/src/main/java/com/loopers/application/user/dto/UserResult.java index 6d5cb10fb..6c530ddc1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/dto/UserResult.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/dto/UserResult.java @@ -12,9 +12,9 @@ public record UserResult( public static UserResult from(UserModel model) { return new UserResult( model.getId(), - model.getLoginId().getValue(), - model.getName().getValue(), - model.getBirthDate().toDateString(), - model.getEmail().getMail()); + model.getLoginId(), + model.getName(), + model.getBirthDateString(), + model.getEmail()); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Money.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Money.java deleted file mode 100644 index 35dd00d0e..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/Money.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.loopers.domain.order; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.Embeddable; -import lombok.AccessLevel; -import lombok.EqualsAndHashCode; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@Embeddable -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@EqualsAndHashCode -public class Money { - - private int value; - - private Money(int value) { - validate(value); - this.value = value; - } - - public static Money of(int value) { - return new Money(value); - } - - private void validate(int value) { - if (value < 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "가격은 0 이상이어야 합니다."); - } - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java index 9bbd789b3..8322cad0c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java @@ -3,9 +3,7 @@ import com.loopers.domain.BaseEntity; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; -import jakarta.persistence.AttributeOverride; import jakarta.persistence.Column; -import jakarta.persistence.Embedded; import jakarta.persistence.Entity; import jakarta.persistence.Table; import lombok.AccessLevel; @@ -24,36 +22,37 @@ public class OrderItemModel extends BaseEntity { @Column(name = "product_id", nullable = false) private Long productId; - @Embedded - @AttributeOverride(name = "value", column = @Column(name = "order_price", nullable = false)) - private Money orderPrice; + @Column(name = "order_price", nullable = false) + private int orderPrice; - @Embedded - @AttributeOverride(name = "value", column = @Column(name = "quantity", nullable = false)) - private Quantity quantity; + @Column(name = "quantity", nullable = false) + private int quantity; - @Embedded - private ProductSnapshot productSnapshot; + @Column(name = "product_name", nullable = false) + private String productName; - private OrderItemModel(Long orderId, Long productId, Money orderPrice, Quantity quantity, ProductSnapshot productSnapshot) { + @Column(name = "brand_name", nullable = false) + private String brandName; + + private OrderItemModel(Long orderId, Long productId, int orderPrice, int quantity, + String productName, String brandName) { this.orderId = orderId; this.productId = productId; this.orderPrice = orderPrice; this.quantity = quantity; - this.productSnapshot = productSnapshot; + this.productName = productName; + this.brandName = brandName; } - public static OrderItemModel create( - Long orderId, Long productId, int orderPrice, int quantity, - String productName, String brandName) { + public static OrderItemModel create(Long orderId, Long productId, int orderPrice, int quantity, + String productName, String brandName) { validateOrderId(orderId); validateProductId(productId); - return new OrderItemModel( - orderId, - productId, - Money.of(orderPrice), - Quantity.of(quantity), - ProductSnapshot.of(productName, brandName)); + validateOrderPrice(orderPrice); + validateQuantity(quantity); + validateProductName(productName); + validateBrandName(brandName); + return new OrderItemModel(orderId, productId, orderPrice, quantity, productName, brandName); } private static void validateOrderId(Long orderId) { @@ -67,4 +66,28 @@ private static void validateProductId(Long productId) { throw new CoreException(ErrorType.BAD_REQUEST, "상품 ID는 필수값입니다."); } } + + private static void validateOrderPrice(int orderPrice) { + if (orderPrice < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격은 0 이상이어야 합니다."); + } + } + + private static void validateQuantity(int quantity) { + if (quantity < 1) { + throw new CoreException(ErrorType.BAD_REQUEST, "수량은 1 이상이어야 합니다."); + } + } + + private static void validateProductName(String productName) { + if (productName == null || productName.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품명은 필수값입니다."); + } + } + + private static void validateBrandName(String brandName) { + if (brandName == null || brandName.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드명은 필수값입니다."); + } + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java index 6010d7853..a56636848 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java @@ -3,9 +3,7 @@ import com.loopers.domain.BaseEntity; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; -import jakarta.persistence.AttributeOverride; import jakarta.persistence.Column; -import jakarta.persistence.Embedded; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; @@ -23,15 +21,14 @@ public class OrderModel extends BaseEntity { @Column(name = "user_id", nullable = false) private Long userId; - @Embedded - @AttributeOverride(name = "value", column = @Column(name = "total_price", nullable = false)) - private Money totalPrice; + @Column(name = "total_price", nullable = false) + private int totalPrice; @Enumerated(EnumType.STRING) @Column(name = "status", nullable = false) private OrderStatus status; - private OrderModel(Long userId, Money totalPrice, OrderStatus status) { + private OrderModel(Long userId, int totalPrice, OrderStatus status) { this.userId = userId; this.totalPrice = totalPrice; this.status = status; @@ -39,7 +36,8 @@ private OrderModel(Long userId, Money totalPrice, OrderStatus status) { public static OrderModel create(Long userId, int totalPrice) { validateUserId(userId); - return new OrderModel(userId, Money.of(totalPrice), OrderStatus.ORDERED); + validateTotalPrice(totalPrice); + return new OrderModel(userId, totalPrice, OrderStatus.ORDERED); } public void validateOwner(Long userId) { @@ -53,4 +51,10 @@ private static void validateUserId(Long userId) { throw new CoreException(ErrorType.BAD_REQUEST, "사용자 ID는 필수값입니다."); } } + + private static void validateTotalPrice(int totalPrice) { + if (totalPrice < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격은 0 이상이어야 합니다."); + } + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/ProductSnapshot.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/ProductSnapshot.java deleted file mode 100644 index 88b03907f..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/ProductSnapshot.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.loopers.domain.order; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.Column; -import jakarta.persistence.Embeddable; -import lombok.AccessLevel; -import lombok.EqualsAndHashCode; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@Embeddable -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@EqualsAndHashCode -public class ProductSnapshot { - - @Column(name = "product_name", nullable = false) - private String productName; - - @Column(name = "brand_name", nullable = false) - private String brandName; - - private ProductSnapshot(String productName, String brandName) { - validate(productName, brandName); - this.productName = productName; - this.brandName = brandName; - } - - public static ProductSnapshot of(String productName, String brandName) { - return new ProductSnapshot(productName, brandName); - } - - private void validate(String productName, String brandName) { - if (productName == null || productName.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "상품명은 필수값입니다."); - } - if (brandName == null || brandName.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "브랜드명은 필수값입니다."); - } - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Quantity.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Quantity.java deleted file mode 100644 index 78c090efb..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/Quantity.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.loopers.domain.order; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.Embeddable; -import lombok.AccessLevel; -import lombok.EqualsAndHashCode; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@Embeddable -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@EqualsAndHashCode -public class Quantity { - - private int value; - - private Quantity(int value) { - validate(value); - this.value = value; - } - - public static Quantity of(int value) { - return new Quantity(value); - } - - private void validate(int value) { - if (value < 1) { - throw new CoreException(ErrorType.BAD_REQUEST, "수량은 1 이상이어야 합니다."); - } - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java deleted file mode 100644 index c4be43537..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.loopers.domain.product; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.Embeddable; -import lombok.EqualsAndHashCode; - -@Embeddable -@EqualsAndHashCode -public class Money { - - private int value; - - protected Money() {} - - public Money(int value) { - validate(value); - this.value = value; - } - - private void validate(int value) { - if (value < 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "가격은 0 이상이어야 합니다."); - } - } - - public int getValue() { - return value; - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java index d8357de08..ddcfd59fb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java @@ -4,9 +4,7 @@ import com.loopers.domain.brand.BrandModel; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; -import jakarta.persistence.AttributeOverride; import jakarta.persistence.Column; -import jakarta.persistence.Embedded; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.ForeignKey; @@ -16,6 +14,7 @@ import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; + @Getter @Entity @Table(name = "products") @@ -29,17 +28,15 @@ public class ProductModel extends BaseEntity { @Column(name = "name", nullable = false) private String name; - @Embedded - @AttributeOverride(name = "value", column = @Column(name = "price", nullable = false)) - private Money price; + @Column(name = "price", nullable = false) + private int price; - @Embedded - @AttributeOverride(name = "value", column = @Column(name = "stock", nullable = false)) - private Stock stock; + @Column(name = "stock", nullable = false) + private int stock; // === 생성 === // - private ProductModel(BrandModel brand, String name, Money price, Stock stock) { + private ProductModel(BrandModel brand, String name, int price, int stock) { this.brand = brand; this.name = name; this.price = price; @@ -49,30 +46,40 @@ private ProductModel(BrandModel brand, String name, Money price, Stock stock) { public static ProductModel create(BrandModel brand, String name, int price, int stock) { validateBrand(brand); validateName(name); - return new ProductModel(brand, name, new Money(price), new Stock(stock)); + validatePriceRange(price); + validateStockRange(stock); + return new ProductModel(brand, name, price, stock); } // === 도메인 로직 === // public void update(String name, int price, int stock) { validateName(name); + validatePriceRange(price); + validateStockRange(stock); this.name = name; - this.price = new Money(price); - this.stock = new Stock(stock); + this.price = price; + this.stock = stock; } - public void validatePrice(int expectedPrice) { - if (expectedPrice != this.price.getValue()) { + public void validateExpectedPrice(int expectedPrice) { + if (expectedPrice != this.price) { throw new CoreException(ProductErrorCode.PRICE_MISMATCH); } } public void decreaseStock(int quantity) { - this.stock.deduct(quantity); + if (quantity < 1) { + throw new CoreException(ErrorType.BAD_REQUEST, "차감 수량은 1 이상이어야 합니다."); + } + if (this.stock < quantity) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다."); + } + this.stock -= quantity; } public boolean isSoldOut() { - return this.stock.getValue() == 0; + return this.stock == 0; } // === 검증 === // @@ -91,4 +98,16 @@ private static void validateName(String name) { throw new CoreException(ErrorType.BAD_REQUEST, "상품명은 99자 이하여야 합니다."); } } + + private static void validatePriceRange(int price) { + if (price < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격은 0 이상이어야 합니다."); + } + } + + private static void validateStockRange(int stock) { + if (stock < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고는 0 이상이어야 합니다."); + } + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Stock.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Stock.java deleted file mode 100644 index 3f521f2ab..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Stock.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.loopers.domain.product; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.Embeddable; -import lombok.EqualsAndHashCode; - -@Embeddable -@EqualsAndHashCode -public class Stock { - - private int value; - - protected Stock() {} - - public Stock(int value) { - validate(value); - this.value = value; - } - - private void validate(int value) { - if (value < 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "재고는 0 이상이어야 합니다."); - } - } - - public void deduct(int quantity) { - if (!hasEnough(quantity)) { - throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다."); - } - this.value -= quantity; - } - - public boolean hasEnough(int quantity) { - return this.value >= quantity; - } - - public int getValue() { - return value; - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/AuthenticationService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/AuthenticationService.java index 624699b7c..fe6496215 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/AuthenticationService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/AuthenticationService.java @@ -13,10 +13,10 @@ public class AuthenticationService { private final PasswordEncoder passwordEncoder; public UserModel authenticate(String loginIdValue, String rawPassword) { - UserModel user = userRepository.find(new LoginId(loginIdValue)) - .orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED, "로그인 ID 또는 비밀번호가 일치하지 않습니다.")); + UserModel user = userRepository.findByLoginId(loginIdValue) + .orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED, "로그인 ID 또는 비밀번호가 일치하지 않습니다.")); - if (!user.getPassword().matches(rawPassword, passwordEncoder)) { + if (!passwordEncoder.matches(rawPassword, user.getPassword())) { throw new CoreException(ErrorType.UNAUTHORIZED, "로그인 ID 또는 비밀번호가 일치하지 않습니다."); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDate.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDate.java deleted file mode 100644 index fdd28d205..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDate.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.loopers.domain.user; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.Embeddable; -import java.time.LocalDate; -import java.time.format.DateTimeFormatter; -import lombok.EqualsAndHashCode; - -@Embeddable -@EqualsAndHashCode -public class BirthDate { - - private static final DateTimeFormatter DATE_STRING_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); - - private LocalDate birthDate; - - protected BirthDate() {} - - public BirthDate(LocalDate birthDate) { - validate(birthDate); - this.birthDate = birthDate; - } - - private void validate(LocalDate birthDate) { - if (birthDate == null) { - throw new CoreException(ErrorType.BAD_REQUEST,"생년월일은 필수 입력값입니다."); - } - if (birthDate.isAfter(LocalDate.now())) { - throw new CoreException(ErrorType.BAD_REQUEST,"생년월일은 과거 날짜여야 합니다."); - } - } - - public String toDateString() { - return birthDate.format(DATE_STRING_FORMATTER); - } - - public LocalDate getDate() { - return birthDate; - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/Email.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/Email.java deleted file mode 100644 index 039c84d68..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/Email.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.loopers.domain.user; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.Embeddable; -import java.util.regex.Pattern; -import lombok.EqualsAndHashCode; -import lombok.Getter; - -@Embeddable -@Getter -@EqualsAndHashCode -public class Email { - private static final Pattern EMAIL_PATTERN = Pattern.compile("^[A-Za-z0-9+_.-]+@([A-Za-z0-9-]+\\.)+[A-Za-z]{2,}$"); - - private String mail; - - protected Email() {} - - public Email(String mail) { - validateEmail(mail); - this.mail = mail; - } - - private void validateEmail(String mail) { - if (mail == null || mail.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "이메일은 비어있을 수 없습니다."); - } - - if (!EMAIL_PATTERN.matcher(mail).matches()) { - throw new CoreException(ErrorType.BAD_REQUEST, "이메일 형식이 올바르지 않습니다."); - } - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/EncryptedPassword.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/EncryptedPassword.java deleted file mode 100644 index 47ad53c57..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/EncryptedPassword.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 jakarta.persistence.Embeddable; -import java.util.regex.Pattern; -import lombok.EqualsAndHashCode; - -@Embeddable -@EqualsAndHashCode -public class EncryptedPassword { - private static final Pattern PASSWORD_PATTERN = - Pattern.compile("^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[~!@#$%^&*()_+=-])[A-Za-z\\d~!@#$%^&*()_+=-]{8,16}$"); - - private String value; - - protected EncryptedPassword() {} - - private EncryptedPassword(String encryptedValue) { - this.value = encryptedValue; - } - - public static EncryptedPassword of(String rawPassword, PasswordEncoder encoder) { - validateFormat(rawPassword); - return new EncryptedPassword(encoder.encode(rawPassword)); - } - - public static EncryptedPassword fromEncoded(String encodedValue) { - return new EncryptedPassword(encodedValue); - } - - public boolean matches(String rawPassword, PasswordEncoder encoder) { - return encoder.matches(rawPassword, this.value); - } - - public String getValue() { - return value; - } - - private static void validateFormat(String value) { - if (value == null || !PASSWORD_PATTERN.matcher(value).matches()) { - throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 8~16자의 영문 대소문자, 숫자, 특수문자 조합이어야 합니다."); - } - } - -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/LoginId.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/LoginId.java deleted file mode 100644 index 3d7d96559..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/LoginId.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.loopers.domain.user; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.Embeddable; -import java.util.regex.Pattern; -import lombok.EqualsAndHashCode; - -@Embeddable -@EqualsAndHashCode -public class LoginId { - private static final int MIN_LENGTH = 4; - private static final int MAX_LENGTH = 12; - - private static final Pattern ALPHANUMERIC_PATTERN = Pattern.compile("^[a-zA-Z0-9]*$"); - - private String value; - - protected LoginId() {} - - public LoginId(String value) { - validate(value); - this.value = value; - } - - private void validate(String value) { - if (value == null || value.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 비어있을 수 없습니다."); - } - if (value.length() < MIN_LENGTH || value.length() > MAX_LENGTH) { - throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 4자에서 12자 사이여야 합니다."); - } - if (!ALPHANUMERIC_PATTERN.matcher(value).matches()) { - throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 영문과 숫자만 허용됩니다."); - } - } - - public String getValue() { - return value; - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/Name.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/Name.java deleted file mode 100644 index 09fee1310..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/Name.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.loopers.domain.user; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.Embeddable; -import lombok.EqualsAndHashCode; - -@Embeddable -@EqualsAndHashCode -public class Name { - private final static int MIN_LENGTH = 2; - private final static int MAX_LENGTH = 10; - private String name; - - protected Name() {} - - public Name(String name) { - validate(name); - this.name = name; - } - - private void validate(String name) { - if (name == null || name.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "이름은 비어있을 수 없습니다."); - } - if(name.length() < MIN_LENGTH || name.length() > MAX_LENGTH){ - throw new CoreException(ErrorType.BAD_REQUEST, "유효하지 않은 이름 길이입니다."); - } - } - - public String getValue() { - return name; - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java index 17ac5c1b7..ef283310e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java @@ -3,12 +3,12 @@ import com.loopers.domain.BaseEntity; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; -import jakarta.persistence.AttributeOverride; import jakarta.persistence.Column; -import jakarta.persistence.Embedded; import jakarta.persistence.Entity; import jakarta.persistence.Table; import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.regex.Pattern; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -19,58 +19,92 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class UserModel extends BaseEntity { - @Embedded - @AttributeOverride(name = "value", column = @Column(name = "login_id")) - private LoginId loginId; + @Column(name = "login_id") + private String loginId; - @Embedded - @AttributeOverride(name = "value", column = @Column(name = "password")) - private EncryptedPassword password; + @Column(name = "password") + private String password; - @Embedded - @AttributeOverride(name = "name", column = @Column(name = "name")) - private Name name; + @Column(name = "name") + private String name; - @Embedded - @AttributeOverride(name = "birthDate", column = @Column(name = "birth_date")) - private BirthDate birthDate; + @Column(name = "birth_date") + private LocalDate birthDate; - @Embedded - @AttributeOverride(name = "mail", column = @Column(name = "email")) - private Email email; + @Column(name = "email") + private String email; // === 생성 === // - public static UserModel create(String loginId, String rawPassword, PasswordEncoder encoder, + private UserModel(String loginId, String password, String name, LocalDate birthDate, String email) { + this.loginId = loginId; + this.password = password; + this.name = name; + this.birthDate = birthDate; + this.email = email; + } + + public static UserModel create(String loginId, String encryptedPassword, String name, LocalDate birthDate, String email) { - UserModel model = new UserModel(); - model.loginId = new LoginId(loginId); - model.name = new Name(name); - model.birthDate = new BirthDate(birthDate); - model.email = new Email(email); - model.validateBirthDateNotInPassword(rawPassword, model.birthDate); - model.password = EncryptedPassword.of(rawPassword, encoder); - return model; + validateLoginId(loginId); + validateName(name); + validateBirthDate(birthDate); + validateEmail(email); + return new UserModel(loginId, encryptedPassword, name, birthDate, email); } // === 도메인 로직 === // - public void changePassword(String rawCurrentPassword, String rawNewPassword, PasswordEncoder encoder) { - if (!this.password.matches(rawCurrentPassword, encoder)) { - throw new CoreException(ErrorType.BAD_REQUEST, "현재 비밀번호가 일치하지 않습니다."); + public void changePassword(String newEncryptedPassword) { + this.password = newEncryptedPassword; + } + + public String getBirthDateString() { + return birthDate.format(DateTimeFormatter.ofPattern("yyyyMMdd")); + } + + // === 검증 === // + + private static final Pattern LOGIN_ID_PATTERN = Pattern.compile("^[a-zA-Z0-9]*$"); + private static final Pattern EMAIL_PATTERN = + Pattern.compile("^[A-Za-z0-9+_.-]+@([A-Za-z0-9-]+\\.)+[A-Za-z]{2,}$"); + + private static void validateLoginId(String loginId) { + if (loginId == null || loginId.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 비어있을 수 없습니다."); + } + if (loginId.length() < 4 || loginId.length() > 12) { + throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 4자에서 12자 사이여야 합니다."); } - if (this.password.matches(rawNewPassword, encoder)) { - throw new CoreException(ErrorType.BAD_REQUEST, "현재 사용 중인 비밀번호는 사용할 수 없습니다."); + if (!LOGIN_ID_PATTERN.matcher(loginId).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 영문과 숫자만 허용됩니다."); } - validateBirthDateNotInPassword(rawNewPassword, this.birthDate); - this.password = EncryptedPassword.of(rawNewPassword, encoder); } - // === 검증 === // + private static void validateName(String name) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이름은 비어있을 수 없습니다."); + } + if (name.length() < 2 || name.length() > 10) { + throw new CoreException(ErrorType.BAD_REQUEST, "유효하지 않은 이름 길이입니다."); + } + } - private void validateBirthDateNotInPassword(String rawPassword, BirthDate birthDate) { - if (rawPassword != null && rawPassword.contains(birthDate.toDateString())) { - throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 비밀번호 내에 포함될 수 없습니다."); + private static void validateBirthDate(LocalDate birthDate) { + if (birthDate == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 필수 입력값입니다."); + } + if (birthDate.isAfter(LocalDate.now())) { + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 과거 날짜여야 합니다."); + } + } + + private static 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, "이메일 형식이 올바르지 않습니다."); } } } 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 048572c10..4a3680695 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 @@ -5,5 +5,5 @@ public interface UserRepository { UserModel save(UserModel userModel); - Optional find(LoginId loginId); + Optional findByLoginId(String loginId); } 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 e77b783eb..b51813fd8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -2,46 +2,71 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; -import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; import java.time.format.DateTimeFormatter; +import java.util.regex.Pattern; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor public class UserService { private static final DateTimeFormatter BIRTH_DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + private static final Pattern PASSWORD_PATTERN = + Pattern.compile("^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[~!@#$%^&*()_+=-])[A-Za-z\\d~!@#$%^&*()_+=-]{8,16}$"); private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; @Transactional public UserModel signup(String loginId, String rawPassword, String name, String birthDate, String email) { - if (userRepository.find(new LoginId(loginId)).isPresent()) { + if (userRepository.findByLoginId(loginId).isPresent()) { throw new CoreException(ErrorType.BAD_REQUEST, "이미 존재하는 아이디입니다."); } - UserModel userModel = UserModel.create( - loginId, rawPassword, passwordEncoder, name, - LocalDate.parse(birthDate, BIRTH_DATE_FORMATTER), email - ); + LocalDate parsedBirthDate = LocalDate.parse(birthDate, BIRTH_DATE_FORMATTER); + validatePasswordFormat(rawPassword); + validateBirthDateNotInPassword(rawPassword, parsedBirthDate); - return userRepository.save(userModel); + return userRepository.save( + UserModel.create(loginId, passwordEncoder.encode(rawPassword), name, parsedBirthDate, email)); } @Transactional(readOnly = true) public UserModel getByLoginId(String loginId) { - return userRepository.find(new LoginId(loginId)) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.")); + return userRepository.findByLoginId(loginId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.")); } @Transactional public void changePassword(String loginId, String rawCurrentPassword, String rawNewPassword) { - UserModel user = userRepository.find(new LoginId(loginId)) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.")); + UserModel user = userRepository.findByLoginId(loginId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.")); + + if (!passwordEncoder.matches(rawCurrentPassword, user.getPassword())) { + throw new CoreException(ErrorType.BAD_REQUEST, "현재 비밀번호가 일치하지 않습니다."); + } + if (passwordEncoder.matches(rawNewPassword, user.getPassword())) { + throw new CoreException(ErrorType.BAD_REQUEST, "현재 사용 중인 비밀번호는 사용할 수 없습니다."); + } + + validatePasswordFormat(rawNewPassword); + validateBirthDateNotInPassword(rawNewPassword, user.getBirthDate()); - user.changePassword(rawCurrentPassword, rawNewPassword, passwordEncoder); + user.changePassword(passwordEncoder.encode(rawNewPassword)); userRepository.save(user); } + + private void validatePasswordFormat(String rawPassword) { + if (rawPassword == null || !PASSWORD_PATTERN.matcher(rawPassword).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 8~16자의 영문 대소문자, 숫자, 특수문자 조합이어야 합니다."); + } + } + + private void validateBirthDateNotInPassword(String rawPassword, LocalDate birthDate) { + if (rawPassword.contains(birthDate.format(BIRTH_DATE_FORMATTER))) { + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 비밀번호 내에 포함될 수 없습니다."); + } + } } 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 af502b12f..bc88675df 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,11 +1,10 @@ package com.loopers.infrastructure.user; -import com.loopers.domain.user.LoginId; import com.loopers.domain.user.UserModel; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; -public interface UserJpaRepository extends JpaRepository { - Optional findByLoginId(LoginId loginId); - Optional findByLoginIdAndDeletedAtIsNull(LoginId loginId); +public interface UserJpaRepository extends JpaRepository { + Optional findByLoginId(String loginId); + Optional findByLoginIdAndDeletedAtIsNull(String loginId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java index dd5d53d86..7f46babe5 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 @@ -1,6 +1,5 @@ package com.loopers.infrastructure.user; -import com.loopers.domain.user.LoginId; import com.loopers.domain.user.UserModel; import com.loopers.domain.user.UserRepository; import java.util.Optional; @@ -18,7 +17,7 @@ public UserModel save(UserModel userModel) { } @Override - public Optional find(LoginId loginId) { + public Optional findByLoginId(String loginId) { return userJpaRepository.findByLoginIdAndDeletedAtIsNull(loginId); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthFilter.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthFilter.java index e1351d590..bc2caf152 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthFilter.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthFilter.java @@ -52,13 +52,9 @@ protected void doFilterInternal(HttpServletRequest request, } try { - UserModel authenticatedUser = authenticationService.authenticate(loginId, password); - LoginUser loginUser = new LoginUser( - authenticatedUser.getId(), - authenticatedUser.getLoginId().getValue(), - authenticatedUser.getName().getValue() - ); - request.setAttribute("loginUser", loginUser); + UserModel user = authenticationService.authenticate(loginId, password); + request.setAttribute("loginUser", + new LoginUser(user.getId(), user.getLoginId(), user.getName())); filterChain.doFilter(request, response); } catch (CoreException e) { writeUnauthorizedResponse(response, From 173f466a3e98f2cde5721869305bd1970a8bc1bd Mon Sep 17 00:00:00 2001 From: plan11plan Date: Wed, 25 Feb 2026 17:10:16 +0900 Subject: [PATCH 081/108] =?UTF-8?q?test:=20VO=20=EC=A0=9C=EA=B1=B0?= =?UTF-8?q?=EC=97=90=20=EB=94=B0=EB=A5=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=EB=90=9C=20VO=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../domain/order/OrderItemModelTest.java | 8 +- .../loopers/domain/order/OrderModelTest.java | 2 +- .../domain/order/OrderServiceTest.java | 4 +- .../domain/order/ProductSnapshotTest.java | 48 -------- .../loopers/domain/order/QuantityTest.java | 44 ------- .../com/loopers/domain/product/MoneyTest.java | 45 ------- .../domain/product/ProductModelTest.java | 10 +- .../domain/product/ProductServiceTest.java | 4 +- .../com/loopers/domain/product/StockTest.java | 113 ------------------ .../user/AuthenticationServiceTest.java | 17 ++- .../loopers/domain/user/BirthDateTest.java | 69 ----------- .../com/loopers/domain/user/EmailTest.java | 66 ---------- .../domain/user/EncryptedPasswordTest.java | 58 --------- .../domain/user/FakeUserRepository.java | 6 +- .../com/loopers/domain/user/LoginIdTest.java | 42 ------- .../com/loopers/domain/user/NameTest.java | 64 ---------- .../loopers/domain/user/UserModelTest.java | 85 +++---------- .../domain/user/UserServiceFakeTest.java | 41 ++++++- .../user/UserServiceIntegrationTest.java | 22 ++-- .../domain/user/UserServiceMockTest.java | 20 ++-- .../interfaces/auth/AuthFilterTest.java | 8 +- 21 files changed, 96 insertions(+), 680 deletions(-) delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/ProductSnapshotTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/QuantityTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/MoneyTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/StockTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/BirthDateTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/EmailTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/EncryptedPasswordTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/LoginIdTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/NameTest.java diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java index aff727a4f..a579600b7 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java @@ -26,10 +26,10 @@ void create_withValidValues() { assertAll( () -> assertThat(orderItem.getOrderId()).isEqualTo(1L), () -> assertThat(orderItem.getProductId()).isEqualTo(10L), - () -> assertThat(orderItem.getOrderPrice().getValue()).isEqualTo(25000), - () -> assertThat(orderItem.getQuantity().getValue()).isEqualTo(2), - () -> assertThat(orderItem.getProductSnapshot().getProductName()).isEqualTo("상품A"), - () -> assertThat(orderItem.getProductSnapshot().getBrandName()).isEqualTo("브랜드A") + () -> assertThat(orderItem.getOrderPrice()).isEqualTo(25000), + () -> assertThat(orderItem.getQuantity()).isEqualTo(2), + () -> assertThat(orderItem.getProductName()).isEqualTo("상품A"), + () -> assertThat(orderItem.getBrandName()).isEqualTo("브랜드A") ); } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java index e0370be0e..92eb48b36 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java @@ -25,7 +25,7 @@ void create_withValidValues() { // assert assertAll( () -> assertThat(order.getUserId()).isEqualTo(1L), - () -> assertThat(order.getTotalPrice().getValue()).isEqualTo(50000), + () -> assertThat(order.getTotalPrice()).isEqualTo(50000), () -> assertThat(order.getStatus()).isEqualTo(OrderStatus.ORDERED) ); } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java index 12b0ed36c..938ab4bf0 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java @@ -47,7 +47,7 @@ void createOrder_savesOrder() { assertAll( () -> assertThat(order.getId()).isNotEqualTo(0L), () -> assertThat(order.getUserId()).isEqualTo(1L), - () -> assertThat(order.getTotalPrice().getValue()).isEqualTo(50000), + () -> assertThat(order.getTotalPrice()).isEqualTo(50000), () -> assertThat(order.getStatus()).isEqualTo(OrderStatus.ORDERED) ); } @@ -187,7 +187,7 @@ void getOrderItemsByOrderId_returnsItems() { // assert assertThat(items).hasSize(1); - assertThat(items.get(0).getProductSnapshot().getProductName()).isEqualTo("상품A"); + assertThat(items.get(0).getProductName()).isEqualTo("상품A"); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/ProductSnapshotTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/ProductSnapshotTest.java deleted file mode 100644 index 094ba7d82..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/ProductSnapshotTest.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.loopers.domain.order; - -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 com.loopers.support.error.CoreException; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -@DisplayName("ProductSnapshot VO 단위 테스트") -class ProductSnapshotTest { - - @DisplayName("생성") - @Nested - class Create { - - @DisplayName("유효한 값이면 생성에 성공한다") - @Test - void create_withValidValues() { - // act - ProductSnapshot snapshot = ProductSnapshot.of("상품A", "브랜드A"); - - // assert - assertAll( - () -> assertThat(snapshot.getProductName()).isEqualTo("상품A"), - () -> assertThat(snapshot.getBrandName()).isEqualTo("브랜드A") - ); - } - - @DisplayName("상품명이 null이면 예외가 발생한다") - @Test - void create_withNullProductName_throwsException() { - // act & assert - assertThatThrownBy(() -> ProductSnapshot.of(null, "브랜드A")) - .isInstanceOf(CoreException.class); - } - - @DisplayName("브랜드명이 null이면 예외가 발생한다") - @Test - void create_withNullBrandName_throwsException() { - // act & assert - assertThatThrownBy(() -> ProductSnapshot.of("상품A", null)) - .isInstanceOf(CoreException.class); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/QuantityTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/QuantityTest.java deleted file mode 100644 index 5d5af8cc9..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/QuantityTest.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.loopers.domain.order; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import com.loopers.support.error.CoreException; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -@DisplayName("Quantity VO 단위 테스트") -class QuantityTest { - - @DisplayName("생성") - @Nested - class Create { - - @DisplayName("1 이상이면 생성에 성공한다") - @Test - void create_withValidValue() { - // act - Quantity quantity = Quantity.of(1); - - // assert - assertThat(quantity.getValue()).isEqualTo(1); - } - - @DisplayName("0이면 예외가 발생한다") - @Test - void create_withZero_throwsException() { - // act & assert - assertThatThrownBy(() -> Quantity.of(0)) - .isInstanceOf(CoreException.class); - } - - @DisplayName("음수이면 예외가 발생한다") - @Test - void create_withNegative_throwsException() { - // act & assert - assertThatThrownBy(() -> Quantity.of(-1)) - .isInstanceOf(CoreException.class); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/MoneyTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/MoneyTest.java deleted file mode 100644 index 3116a9b2e..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/MoneyTest.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.loopers.domain.product; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import com.loopers.support.error.CoreException; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -class MoneyTest { - - @DisplayName("Money를 생성할 때, ") - @Nested - class Create { - - @DisplayName("0 이상의 값이면 정상적으로 생성된다.") - @Test - void create_whenValidValue() { - // act - Money money = new Money(10000); - - // assert - assertThat(money.getValue()).isEqualTo(10000); - } - - @DisplayName("0이면 정상적으로 생성된다.") - @Test - void create_whenZero() { - // act - Money money = new Money(0); - - // assert - assertThat(money.getValue()).isEqualTo(0); - } - - @DisplayName("음수이면 예외가 발생한다.") - @Test - void create_whenNegative() { - assertThatThrownBy(() -> new Money(-1)) - .isInstanceOf(CoreException.class) - .hasMessageContaining("가격은 0 이상이어야 합니다."); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java index 9e08b011b..dca2dca5d 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java @@ -27,8 +27,8 @@ void create_whenValidValues() { // assert assertThat(product.getName()).isEqualTo("에어맥스"); - assertThat(product.getPrice().getValue()).isEqualTo(150000); - assertThat(product.getStock().getValue()).isEqualTo(100); + assertThat(product.getPrice()).isEqualTo(150000); + assertThat(product.getStock()).isEqualTo(100); assertThat(product.getBrand().getName()).isEqualTo("Nike"); } @@ -98,8 +98,8 @@ void update_whenValidValues() { // assert assertThat(product.getName()).isEqualTo("에어포스"); - assertThat(product.getPrice().getValue()).isEqualTo(120000); - assertThat(product.getStock().getValue()).isEqualTo(50); + assertThat(product.getPrice()).isEqualTo(120000); + assertThat(product.getStock()).isEqualTo(50); } @DisplayName("상품명이 null이면 예외가 발생한다.") @@ -129,7 +129,7 @@ void decreaseStock_whenEnough() { product.decreaseStock(3); // assert - assertThat(product.getStock().getValue()).isEqualTo(7); + assertThat(product.getStock()).isEqualTo(7); } @DisplayName("재고가 부족하면 예외가 발생한다.") 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 index 9a497ba09..f4eef2ef1 100644 --- 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 @@ -112,8 +112,8 @@ void update_whenValidValues() { // assert ProductModel updated = productService.getById(savedId); assertThat(updated.getName()).isEqualTo("에어포스"); - assertThat(updated.getPrice().getValue()).isEqualTo(120000); - assertThat(updated.getStock().getValue()).isEqualTo(50); + assertThat(updated.getPrice()).isEqualTo(120000); + assertThat(updated.getStock()).isEqualTo(50); } @DisplayName("존재하지 않는 상품이면 NOT_FOUND 예외가 발생한다.") diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/StockTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/StockTest.java deleted file mode 100644 index a33890f84..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/StockTest.java +++ /dev/null @@ -1,113 +0,0 @@ -package com.loopers.domain.product; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import com.loopers.support.error.CoreException; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -class StockTest { - - @DisplayName("Stock을 생성할 때, ") - @Nested - class Create { - - @DisplayName("0 이상의 값이면 정상적으로 생성된다.") - @Test - void create_whenValidValue() { - // act - Stock stock = new Stock(100); - - // assert - assertThat(stock.getValue()).isEqualTo(100); - } - - @DisplayName("0이면 정상적으로 생성된다.") - @Test - void create_whenZero() { - // act - Stock stock = new Stock(0); - - // assert - assertThat(stock.getValue()).isEqualTo(0); - } - - @DisplayName("음수이면 예외가 발생한다.") - @Test - void create_whenNegative() { - assertThatThrownBy(() -> new Stock(-1)) - .isInstanceOf(CoreException.class) - .hasMessageContaining("재고는 0 이상이어야 합니다."); - } - } - - @DisplayName("재고를 차감할 때, ") - @Nested - class Deduct { - - @DisplayName("충분한 재고가 있으면 정상적으로 차감된다.") - @Test - void deduct_whenEnoughStock() { - // arrange - Stock stock = new Stock(10); - - // act - stock.deduct(3); - - // assert - assertThat(stock.getValue()).isEqualTo(7); - } - - @DisplayName("재고가 부족하면 예외가 발생한다.") - @Test - void deduct_whenInsufficientStock() { - // arrange - Stock stock = new Stock(5); - - // act & assert - assertThatThrownBy(() -> stock.deduct(6)) - .isInstanceOf(CoreException.class) - .hasMessageContaining("재고가 부족합니다."); - } - - @DisplayName("재고와 동일한 수량을 차감하면 0이 된다.") - @Test - void deduct_whenExactStock() { - // arrange - Stock stock = new Stock(5); - - // act - stock.deduct(5); - - // assert - assertThat(stock.getValue()).isEqualTo(0); - } - } - - @DisplayName("재고 충분 여부를 확인할 때, ") - @Nested - class HasEnough { - - @DisplayName("충분하면 true를 반환한다.") - @Test - void hasEnough_whenEnough() { - // arrange - Stock stock = new Stock(10); - - // act & assert - assertThat(stock.hasEnough(10)).isTrue(); - } - - @DisplayName("부족하면 false를 반환한다.") - @Test - void hasEnough_whenNotEnough() { - // arrange - Stock stock = new Stock(5); - - // act & assert - assertThat(stock.hasEnough(6)).isFalse(); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/AuthenticationServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/AuthenticationServiceTest.java index 805cf6212..124a0ee27 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/AuthenticationServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/AuthenticationServiceTest.java @@ -2,7 +2,7 @@ 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.when; import com.loopers.support.error.CoreException; @@ -41,11 +41,10 @@ void setUp() { validLoginId = "testuser123"; validPassword = "Test1234!@#"; encodedPassword = "$2a$10$encodedPasswordHash"; - when(passwordEncoder.encode(validPassword)).thenReturn(encodedPassword); testUser = UserModel.create( - validLoginId, validPassword, passwordEncoder, - "홍길동", LocalDate.of(1990, 1, 15), "test@example.com" + validLoginId, encodedPassword, + "홍길동", LocalDate.of(1990, 1, 15), "test@example.com" ); } @@ -57,7 +56,7 @@ class Authenticate { @DisplayName("올바른 로그인 ID와 비밀번호로 인증하면 사용자 정보를 반환한다") void authenticate_should_return_user_when_credentials_are_correct() { // arrange - when(userRepository.find(any(LoginId.class))).thenReturn(Optional.of(testUser)); + when(userRepository.findByLoginId(anyString())).thenReturn(Optional.of(testUser)); when(passwordEncoder.matches(validPassword, encodedPassword)).thenReturn(true); // act @@ -65,14 +64,14 @@ void authenticate_should_return_user_when_credentials_are_correct() { // assert assertThat(result).isNotNull(); - assertThat(result.getLoginId().getValue()).isEqualTo(validLoginId); + assertThat(result.getLoginId()).isEqualTo(validLoginId); } @Test @DisplayName("존재하지 않는 로그인 ID로 인증하면 UNAUTHORIZED 예외를 던진다") void authenticate_should_throw_exception_when_user_not_found() { // arrange - when(userRepository.find(any(LoginId.class))).thenReturn(Optional.empty()); + when(userRepository.findByLoginId(anyString())).thenReturn(Optional.empty()); // act & assert assertThatThrownBy(() -> authenticationService.authenticate(validLoginId, validPassword)) @@ -85,7 +84,7 @@ void authenticate_should_throw_exception_when_user_not_found() { @DisplayName("잘못된 비밀번호로 인증하면 UNAUTHORIZED 예외를 던진다") void authenticate_should_throw_exception_when_password_is_incorrect() { // arrange - when(userRepository.find(any(LoginId.class))).thenReturn(Optional.of(testUser)); + when(userRepository.findByLoginId(anyString())).thenReturn(Optional.of(testUser)); String wrongPassword = "Wrong1234!@#"; when(passwordEncoder.matches(wrongPassword, encodedPassword)).thenReturn(false); @@ -100,7 +99,7 @@ void authenticate_should_throw_exception_when_password_is_incorrect() { @DisplayName("비밀번호가 null이면 UNAUTHORIZED 예외를 던진다") void authenticate_should_throw_exception_when_password_is_null() { // arrange - when(userRepository.find(any(LoginId.class))).thenReturn(Optional.of(testUser)); + when(userRepository.findByLoginId(anyString())).thenReturn(Optional.of(testUser)); when(passwordEncoder.matches(null, encodedPassword)).thenReturn(false); // act & assert diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/BirthDateTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/BirthDateTest.java deleted file mode 100644 index 676aa81dc..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/BirthDateTest.java +++ /dev/null @@ -1,69 +0,0 @@ -package com.loopers.domain.user; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import com.loopers.support.error.CoreException; -import java.time.LocalDate; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -class BirthDateTest { - - @DisplayName("생년월일 객체를 생성할 때, ") - @Nested - class Create { - - @DisplayName("과거 혹은 현재의 날짜가 주어지면, 정상적으로 생성된다.") - @Test - void createBirthDate_whenValidDateProvided() { - // arrange - LocalDate validDate = LocalDate.of(1995, 5, 20); - - // act - BirthDate birthDate = new BirthDate(validDate); - - // assert - assertThat(birthDate.getDate()).isEqualTo(validDate); - } - - @DisplayName("날짜가 null이면 예외가 발생한다.") - @Test - void createBirthDate_whenDateIsNull() { - assertThatThrownBy(() -> new BirthDate(null)) - .isInstanceOf(CoreException.class) - .hasMessageContaining("필수 입력값입니다."); - } - - @DisplayName("날짜가 미래의 날짜이면 예외가 발생한다.") - @Test - void createBirthDate_whenDateIsInFuture() { - // arrange - LocalDate futureDate = LocalDate.now().plusDays(1); - - // act & assert - assertThatThrownBy(() -> new BirthDate(futureDate)) - .isInstanceOf(CoreException.class) - .hasMessageContaining("과거 날짜여야 합니다."); - } - } - - @DisplayName("날짜를 문자열로 변환할 때, ") - @Nested - class Conversion { - - @DisplayName("yyyyMMdd 형식의 문자열을 반환한다.") - @Test - void toDateString_returnsFormattedString() { - // arrange - BirthDate birthDate = new BirthDate(LocalDate.of(1988, 12, 5)); - - // act - String result = birthDate.toDateString(); - - // assert - assertThat(result).isEqualTo("19881205"); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/EmailTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/EmailTest.java deleted file mode 100644 index 6849e4af2..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/EmailTest.java +++ /dev/null @@ -1,66 +0,0 @@ -package com.loopers.domain.user; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import com.loopers.support.error.CoreException; -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; - -class EmailTest { - - @DisplayName("이메일 객체를 생성할 때, ") - @Nested - class Create { - - @DisplayName("올바른 이메일 형식이 주어지면, 정상적으로 생성된다.") - @ParameterizedTest - @ValueSource(strings = { - "test@example.com", - "user.name+tag@domain.co.kr", - "12345@loopers.io", - "email@sub.domain.com" - }) - void createEmail_whenValidFormat(String validEmail) { - // act - Email email = new Email(validEmail); - - // assert - assertThat(email.getMail()).isEqualTo(validEmail); - } - - @DisplayName("이메일이 null이거나 비어있으면 예외가 발생한다.") - @ParameterizedTest - @ValueSource(strings = {"", " ", " "}) - void createEmail_whenNullOrBlank(String blankEmail) { - // null 케이스 별도 테스트 - assertThatThrownBy(() -> new Email(null)) - .isInstanceOf(CoreException.class) - .hasMessageContaining("이메일은 비어있을 수 없습니다."); - - // 공백 케이스 - assertThatThrownBy(() -> new Email(blankEmail)) - .isInstanceOf(CoreException.class) - .hasMessageContaining("이메일은 비어있을 수 없습니다."); - } - - @DisplayName("형식이 올바르지 않으면 예외가 발생한다.") - @ParameterizedTest - @ValueSource(strings = { - "plainaddress", // @ 없음 - "#@%^%#$@#$@#.com", // 특수문자 남발 - "@domain.com", // 로컬 파트 없음 - "Joe Smith ", // 이름 포함 - "email.domain.com", // @ 없음 - "email@domain@domain.com", // @ 중복 - "email@domain..com" // 도메인 마침표 중복 - }) - void createEmail_whenInvalidFormat(String invalidEmail) { - assertThatThrownBy(() -> new Email(invalidEmail)) - .isInstanceOf(CoreException.class) - .hasMessageContaining("이메일 형식이 올바르지 않습니다."); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/EncryptedPasswordTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/EncryptedPasswordTest.java deleted file mode 100644 index 80e40af3e..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/EncryptedPasswordTest.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.loopers.domain.user; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; - -import com.loopers.support.error.CoreException; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -class EncryptedPasswordTest { - - private PasswordEncoder noOpEncoder; - - @BeforeEach - void setUp() { - noOpEncoder = new PasswordEncoder() { - @Override - public String encode(String rawPassword) { return rawPassword; } - @Override - public boolean matches(String rawPassword, String encodedPassword) { return rawPassword.equals(encodedPassword); } - }; - } - - @DisplayName("암호화된 비밀번호 객체를 생성할 때, ") - @Nested - class Create { - - @DisplayName("8~16자의 영문 대소문자, 숫자, 특수문자가 모두 포함되면 정상 생성된다.") - @Test - void createPassword_whenValidFormat() { - assertDoesNotThrow(() -> EncryptedPassword.of("Valid123!@#", noOpEncoder)); - } - - @DisplayName("규칙에 어긋나는 형식이면 예외가 발생한다.") - @Test - void createPassword_whenInvalidFormat() { - assertThatThrownBy(() -> EncryptedPassword.of("invalid", noOpEncoder)) - .isInstanceOf(CoreException.class); - } - } - - @DisplayName("비밀번호 비즈니스 규칙을 검증할 때, ") - @Nested - class Validation { - - @DisplayName("matches()로 원시 비밀번호와 암호화된 비밀번호를 비교할 수 있다.") - @Test - void matches_shouldCompareRawWithEncoded() { - EncryptedPassword password = EncryptedPassword.of("Valid123!@#", noOpEncoder); - - assertThat(password.matches("Valid123!@#", noOpEncoder)).isTrue(); - assertThat(password.matches("Wrong123!@#", noOpEncoder)).isFalse(); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/FakeUserRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/FakeUserRepository.java index 77c916638..bccd9d44f 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/FakeUserRepository.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/FakeUserRepository.java @@ -10,12 +10,12 @@ public class FakeUserRepository implements UserRepository { @Override public UserModel save(UserModel userModel) { - store.put(userModel.getLoginId().getValue(), userModel); + store.put(userModel.getLoginId(), userModel); return userModel; } @Override - public Optional find(LoginId loginId) { - return Optional.ofNullable(store.get(loginId.getValue())); + public Optional findByLoginId(String loginId) { + return Optional.ofNullable(store.get(loginId)); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/LoginIdTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/LoginIdTest.java deleted file mode 100644 index b1e26f444..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/LoginIdTest.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.loopers.domain.user; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import com.loopers.support.error.CoreException; -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; - -class LoginIdTest { - - @DisplayName("로그인 ID 객체를 생성할 때, ") - @Nested - class Create { - - @DisplayName("4~12자의 영문/숫자가 주어지면, 정상적으로 생성된다.") - @ParameterizedTest - @ValueSource(strings = {"user123", "loop", "loopers2026"}) - void createLoginId_whenValidValue(String validValue) { - LoginId loginId = new LoginId(validValue); - assertThat(loginId.getValue()).isEqualTo(validValue); - } - - @DisplayName("4자 미만이거나 12자를 초과하면 예외가 발생한다.") - @ParameterizedTest - @ValueSource(strings = {"abc", "longloginid123"}) - void createLoginId_whenLengthIsInvalid(String invalidLengthValue) { - assertThatThrownBy(() -> new LoginId(invalidLengthValue)) - .isInstanceOf(CoreException.class); - } - - @DisplayName("영문/숫자가 아닌 문자가 포함되면 예외가 발생한다.") - @ParameterizedTest - @ValueSource(strings = {"user!", "로그인id", "user 12"}) - void createLoginId_whenContainsInvalidChars(String invalidCharValue) { - assertThatThrownBy(() -> new LoginId(invalidCharValue)) - .isInstanceOf(CoreException.class); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/NameTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/NameTest.java deleted file mode 100644 index 2444f2ce8..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/NameTest.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.loopers.domain.user; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import com.loopers.support.error.CoreException; -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.ValueSource; - -class NameTest { - - @DisplayName("이름 객체를 생성할 때, ") - @Nested - class Create { - - @DisplayName("2자 이상 10자 이하의 이름이 주어지면, 정상적으로 생성된다.") - @ParameterizedTest - @ValueSource(strings = {"홍길", "홍길동", "가나다라마바사아자차"}) - void createName_whenValidNameProvided(String validNameValue) { - // act - Name name = new Name(validNameValue); - - // assert - assertThat(name).isNotNull(); - assertThat(name.getValue()).isEqualTo(validNameValue); - } - - @DisplayName("이름이 null이면 예외가 발생한다.") - @Test - void createName_whenNameIsNull() { - assertThatThrownBy(() -> new Name(null)) - .isInstanceOf(CoreException.class); - } - - @DisplayName("이름이 빈 문자열이거나 공백이면 예외가 발생한다.") - @ParameterizedTest - @ValueSource(strings = {"", " ", " "}) - void createName_whenNameIsBlank(String blankName) { - assertThatThrownBy(() -> new Name(blankName)) - .isInstanceOf(CoreException.class); - } - - @DisplayName("이름이 2자 미만이면 예외가 발생한다.") - @Test - void createName_whenNameIsTooShort() { - String shortName = "가"; - - assertThatThrownBy(() -> new Name(shortName)) - .isInstanceOf(CoreException.class); - } - - @DisplayName("이름이 10자를 초과하면 예외가 발생한다.") - @Test - void createName_whenNameIsTooLong() { - String longName = "가나다라마바사아자차카"; // 11자 - - assertThatThrownBy(() -> new Name(longName)) - .isInstanceOf(CoreException.class); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java index 7f5a159a4..63f19f4b5 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java @@ -14,17 +14,15 @@ class UserModelTest { private String validLoginId; - private String validRawPassword; + private String validEncryptedPassword; private String validName; private LocalDate validBirthDate; private String validEmail; - private FakePasswordEncoder encoder; @BeforeEach void setUp() { - encoder = new FakePasswordEncoder(); validLoginId = "testuser123"; - validRawPassword = "Test1234!@#"; + validEncryptedPassword = "ENCODED_Test1234!@#"; validName = "홍길동"; validBirthDate = LocalDate.of(1990, 1, 15); validEmail = "test@example.com"; @@ -38,113 +36,62 @@ class Create { @Test void createUserModel_whenAllDataProvided() { // act - UserModel user = UserModel.create(validLoginId, validRawPassword, encoder, validName, validBirthDate, validEmail); + UserModel user = UserModel.create(validLoginId, validEncryptedPassword, validName, validBirthDate, validEmail); // assert assertAll( - () -> assertThat(user.getLoginId().getValue()).isEqualTo(validLoginId), - () -> assertThat(user.getPassword().getValue()).isEqualTo("ENCODED_Test1234!@#"), - () -> assertThat(user.getName().getValue()).isEqualTo(validName), - () -> assertThat(user.getBirthDate().getDate()).isEqualTo(validBirthDate), - () -> assertThat(user.getEmail().getMail()).isEqualTo(validEmail) + () -> assertThat(user.getLoginId()).isEqualTo(validLoginId), + () -> assertThat(user.getPassword()).isEqualTo(validEncryptedPassword), + () -> assertThat(user.getName()).isEqualTo(validName), + () -> assertThat(user.getBirthDate()).isEqualTo(validBirthDate), + () -> assertThat(user.getEmail()).isEqualTo(validEmail) ); } @DisplayName("로그인 ID가 누락되면 예외가 발생한다.") @Test void createUserModel_whenLoginIdIsNull() { - assertThatThrownBy(() -> UserModel.create(null, validRawPassword, encoder, validName, validBirthDate, validEmail)) - .isInstanceOf(CoreException.class); - } - - @DisplayName("비밀번호가 누락되면 예외가 발생한다.") - @Test - void createUserModel_whenPasswordIsNull() { - assertThatThrownBy(() -> UserModel.create(validLoginId, null, encoder, validName, validBirthDate, validEmail)) + assertThatThrownBy(() -> UserModel.create(null, validEncryptedPassword, validName, validBirthDate, validEmail)) .isInstanceOf(CoreException.class); } @DisplayName("이름이 누락되면 예외가 발생한다.") @Test void createUserModel_whenNameIsNull() { - assertThatThrownBy(() -> UserModel.create(validLoginId, validRawPassword, encoder, null, validBirthDate, validEmail)) + assertThatThrownBy(() -> UserModel.create(validLoginId, validEncryptedPassword, null, validBirthDate, validEmail)) .isInstanceOf(CoreException.class); } @DisplayName("생년월일이 누락되면 예외가 발생한다.") @Test void createUserModel_whenBirthDateIsNull() { - assertThatThrownBy(() -> UserModel.create(validLoginId, validRawPassword, encoder, validName, null, validEmail)) + assertThatThrownBy(() -> UserModel.create(validLoginId, validEncryptedPassword, validName, null, validEmail)) .isInstanceOf(CoreException.class); } @DisplayName("이메일이 누락되면 예외가 발생한다.") @Test void createUserModel_whenEmailIsNull() { - assertThatThrownBy(() -> UserModel.create(validLoginId, validRawPassword, encoder, validName, validBirthDate, null)) + assertThatThrownBy(() -> UserModel.create(validLoginId, validEncryptedPassword, validName, validBirthDate, null)) .isInstanceOf(CoreException.class); } - - @DisplayName("비밀번호에 생년월일이 포함되면 예외가 발생한다.") - @Test - void createUserModel_whenPasswordContainsBirthDate() { - assertThatThrownBy(() -> UserModel.create(validLoginId, "Pw19900115!", encoder, validName, validBirthDate, validEmail)) - .isInstanceOf(CoreException.class) - .hasMessageContaining("생년월일은 비밀번호 내에 포함될 수 없습니다."); - } } @DisplayName("비밀번호를 변경할 때, ") @Nested class ChangePassword { - @DisplayName("올바른 현재 비밀번호와 유효한 새 비밀번호가 주어지면 비밀번호가 변경된다.") + @DisplayName("새 암호화된 비밀번호가 주어지면 비밀번호가 변경된다.") @Test void changePassword_success() { // arrange - UserModel user = UserModel.create(validLoginId, validRawPassword, encoder, validName, validBirthDate, validEmail); + UserModel user = UserModel.create(validLoginId, validEncryptedPassword, validName, validBirthDate, validEmail); // act - user.changePassword("Test1234!@#", "NewPass123!@", encoder); + user.changePassword("ENCODED_NewPass123!@"); // assert - assertThat(user.getPassword().getValue()).isEqualTo("ENCODED_NewPass123!@"); - } - - @DisplayName("현재 비밀번호가 일치하지 않으면 예외가 발생한다.") - @Test - void changePassword_whenCurrentPasswordNotMatch() { - // arrange - UserModel user = UserModel.create(validLoginId, validRawPassword, encoder, validName, validBirthDate, validEmail); - - // act & assert - assertThatThrownBy(() -> user.changePassword("Wrong123!@#", "NewPass123!@", encoder)) - .isInstanceOf(CoreException.class) - .hasMessageContaining("현재 비밀번호가 일치하지 않습니다."); - } - - @DisplayName("새 비밀번호가 현재 비밀번호와 동일하면 예외가 발생한다.") - @Test - void changePassword_whenNewPasswordSameAsCurrent() { - // arrange - UserModel user = UserModel.create(validLoginId, validRawPassword, encoder, validName, validBirthDate, validEmail); - - // act & assert - assertThatThrownBy(() -> user.changePassword("Test1234!@#", "Test1234!@#", encoder)) - .isInstanceOf(CoreException.class) - .hasMessageContaining("현재 사용 중인 비밀번호는 사용할 수 없습니다."); - } - - @DisplayName("새 비밀번호에 생년월일이 포함되면 예외가 발생한다.") - @Test - void changePassword_whenNewPasswordContainsBirthDate() { - // arrange - UserModel user = UserModel.create(validLoginId, validRawPassword, encoder, validName, validBirthDate, validEmail); - - // act & assert - assertThatThrownBy(() -> user.changePassword("Test1234!@#", "Pw19900115!", encoder)) - .isInstanceOf(CoreException.class) - .hasMessageContaining("생년월일은 비밀번호 내에 포함될 수 없습니다."); + assertThat(user.getPassword()).isEqualTo("ENCODED_NewPass123!@"); } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceFakeTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceFakeTest.java index 7488f1a54..cd03a5a91 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceFakeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceFakeTest.java @@ -47,11 +47,11 @@ class Signup { // assert assertThat(result).isNotNull(); - assertThat(result.getLoginId().getValue()).isEqualTo(loginId); + assertThat(result.getLoginId()).isEqualTo(loginId); // 암호화 검증은 repository를 통해 직접 확인 - UserModel saved = userRepository.find(new LoginId(loginId)).orElseThrow(); - assertThat(saved.getPassword().getValue()).isEqualTo("ENCODED_Test1234!@#"); + UserModel saved = userRepository.findByLoginId(loginId).orElseThrow(); + assertThat(saved.getPassword()).isEqualTo("ENCODED_Test1234!@#"); } @Test @@ -66,6 +66,24 @@ class Signup { ).isInstanceOf(CoreException.class) .hasMessageContaining("이미 존재하는 아이디입니다."); } + + @Test + @DisplayName("비밀번호 형식이 올바르지 않으면 예외가 발생한다.") + void signup_비밀번호_형식_오류() { + assertThatThrownBy(() -> + userService.signup(loginId, "short", name, birthDate, email) + ).isInstanceOf(CoreException.class) + .hasMessageContaining("비밀번호는 8~16자의 영문 대소문자, 숫자, 특수문자 조합이어야 합니다."); + } + + @Test + @DisplayName("비밀번호에 생년월일이 포함되면 예외가 발생한다.") + void signup_비밀번호에_생년월일_포함() { + assertThatThrownBy(() -> + userService.signup(loginId, "Pw19900115!", name, birthDate, email) + ).isInstanceOf(CoreException.class) + .hasMessageContaining("생년월일은 비밀번호 내에 포함될 수 없습니다."); + } } @DisplayName("비밀번호 변경") @@ -82,8 +100,8 @@ class ChangePassword { userService.changePassword(loginId, rawPassword, "NewPass123!@"); // assert - UserModel updated = userRepository.find(new LoginId(loginId)).orElseThrow(); - assertThat(updated.getPassword().getValue()).isEqualTo("ENCODED_NewPass123!@"); + UserModel updated = userRepository.findByLoginId(loginId).orElseThrow(); + assertThat(updated.getPassword()).isEqualTo("ENCODED_NewPass123!@"); } @Test @@ -111,5 +129,18 @@ class ChangePassword { ).isInstanceOf(CoreException.class) .hasMessageContaining("현재 사용 중인 비밀번호는 사용할 수 없습니다."); } + + @Test + @DisplayName("새 비밀번호에 생년월일이 포함되면 예외가 발생한다.") + void changePassword_새비밀번호에_생년월일_포함() { + // arrange + userService.signup(loginId, rawPassword, name, birthDate, email); + + // act & assert + assertThatThrownBy(() -> + userService.changePassword(loginId, rawPassword, "Pw19900115!") + ).isInstanceOf(CoreException.class) + .hasMessageContaining("생년월일은 비밀번호 내에 포함될 수 없습니다."); + } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java index 460520a9c..ba0c1f208 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java @@ -67,10 +67,10 @@ void signup_whenAllInfoProvided() { // assert assertAll( () -> assertThat(result).isNotNull(), - () -> assertThat(result.getLoginId().getValue()).isEqualTo(loginId), - () -> assertThat(result.getName().getValue()).isEqualTo(name), - () -> assertThat(result.getBirthDate().toDateString()).isEqualTo(birthDate), - () -> assertThat(result.getEmail().getMail()).isEqualTo(email) + () -> assertThat(result.getLoginId()).isEqualTo(loginId), + () -> assertThat(result.getName()).isEqualTo(name), + () -> assertThat(result.getBirthDateString()).isEqualTo(birthDate), + () -> assertThat(result.getEmail()).isEqualTo(email) ); } @@ -82,7 +82,7 @@ void signup_should_encrypt_password() { // assert UserModel savedUser = userJpaRepository.findById(result.getId()).orElseThrow(); - String savedPassword = savedUser.getPassword().getValue(); + String savedPassword = savedUser.getPassword(); assertAll( () -> assertThat(savedPassword).isNotEqualTo(rawPassword), () -> assertThat(savedPassword).startsWith("$2a$"), @@ -98,7 +98,7 @@ void signup_should_save_encrypted_password_to_database() { // assert UserModel savedUser = userJpaRepository.findById(result.getId()).orElseThrow(); - String savedPassword = savedUser.getPassword().getValue(); + String savedPassword = savedUser.getPassword(); assertAll( () -> assertThat(savedPassword).isNotEqualTo(rawPassword), () -> assertThat(savedPassword).startsWith("$2a$"), @@ -122,10 +122,10 @@ void getMyInfo_whenValidLoginId() { // assert assertAll( () -> assertThat(result).isNotNull(), - () -> assertThat(result.getLoginId().getValue()).isEqualTo(loginId), - () -> assertThat(result.getName().getValue()).isEqualTo(name), - () -> assertThat(result.getBirthDate().toDateString()).isEqualTo(birthDate), - () -> assertThat(result.getEmail().getMail()).isEqualTo(email) + () -> assertThat(result.getLoginId()).isEqualTo(loginId), + () -> assertThat(result.getName()).isEqualTo(name), + () -> assertThat(result.getBirthDateString()).isEqualTo(birthDate), + () -> assertThat(result.getEmail()).isEqualTo(email) ); } @@ -156,7 +156,7 @@ void changePassword_whenValidPasswords() { // assert UserModel updatedUser = userService.getByLoginId(loginId); UserModel savedUser = userJpaRepository.findById(updatedUser.getId()).orElseThrow(); - String savedPassword = savedUser.getPassword().getValue(); + String savedPassword = savedUser.getPassword(); assertAll( () -> assertThat(savedPassword).isNotEqualTo(rawPassword), () -> assertThat(savedPassword).isNotEqualTo(newRawPassword), diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceMockTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceMockTest.java index d1ac2db49..c49d51a0c 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceMockTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceMockTest.java @@ -3,6 +3,7 @@ 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.verify; import static org.mockito.Mockito.when; @@ -54,7 +55,7 @@ class Signup { @DisplayName("성공") void signup_성공() { // arrange - when(userRepository.find(any(LoginId.class))).thenReturn(Optional.empty()); + when(userRepository.findByLoginId(anyString())).thenReturn(Optional.empty()); when(passwordEncoder.encode("Test1234!@#")).thenReturn("$2a$10$encodedHash"); when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); @@ -63,7 +64,7 @@ class Signup { // assert assertThat(result).isNotNull(); - assertThat(result.getLoginId().getValue()).isEqualTo(loginId); + assertThat(result.getLoginId()).isEqualTo(loginId); } @Test @@ -71,7 +72,7 @@ class Signup { void signup_중복아이디_예외() { // arrange UserModel existingUser = createTestUser("$2a$10$existingHash"); - when(userRepository.find(any(LoginId.class))).thenReturn(Optional.of(existingUser)); + when(userRepository.findByLoginId(anyString())).thenReturn(Optional.of(existingUser)); // act & assert assertThatThrownBy(() -> @@ -91,7 +92,7 @@ class ChangePassword { // arrange UserModel existingUser = createTestUser("$2a$10$encodedOldHash"); - when(userRepository.find(any(LoginId.class))) + when(userRepository.findByLoginId(anyString())) .thenReturn(Optional.of(existingUser)); when(passwordEncoder.matches("Test1234!@#", "$2a$10$encodedOldHash")) .thenReturn(true); @@ -115,7 +116,7 @@ class ChangePassword { // arrange UserModel existingUser = createTestUser("$2a$10$encodedOldHash"); - when(userRepository.find(any(LoginId.class))) + when(userRepository.findByLoginId(anyString())) .thenReturn(Optional.of(existingUser)); when(passwordEncoder.matches("Wrong123!@#", "$2a$10$encodedOldHash")) .thenReturn(false); @@ -131,13 +132,6 @@ class ChangePassword { // --- 헬퍼 --- private UserModel createTestUser(String encodedPassword) { - PasswordEncoder fixedEncoder = new PasswordEncoder() { - @Override public String encode(String rawPassword) { return encodedPassword; } - @Override public boolean matches(String rawPassword, String encoded) { return false; } - }; - return UserModel.create( - loginId, rawPassword, fixedEncoder, - name, LocalDate.of(1990, 1, 15), email - ); + return UserModel.create(loginId, encodedPassword, name, LocalDate.of(1990, 1, 15), email); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/auth/AuthFilterTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/auth/AuthFilterTest.java index 2cde63666..461afcca0 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/auth/AuthFilterTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/auth/AuthFilterTest.java @@ -2,7 +2,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -10,9 +9,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.loopers.domain.user.AuthenticationService; import com.loopers.domain.user.UserModel; -import com.loopers.domain.user.LoginId; -import com.loopers.domain.user.Name; -import com.loopers.domain.user.PasswordEncoder; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import jakarta.servlet.FilterChain; @@ -55,10 +51,8 @@ void setsLoginUserAttribute_whenValidHeadersOnAuthRequiredUrl() throws Exception request.addHeader("X-Loopers-LoginPw", "Test1234!"); MockHttpServletResponse response = new MockHttpServletResponse(); - PasswordEncoder encoder = mock(PasswordEncoder.class); - when(encoder.encode("Test1234!")).thenReturn("encoded"); UserModel userModel = UserModel.create( - "testuser1", "Test1234!", encoder, "홍길동", LocalDate.of(1990, 1, 15), "test@example.com" + "testuser1", "encoded", "홍길동", LocalDate.of(1990, 1, 15), "test@example.com" ); when(authenticationService.authenticate("testuser1", "Test1234!")).thenReturn(userModel); From 4c4c977d9b628909b24c6b9e779df2f27ae37f4f Mon Sep 17 00:00:00 2001 From: plan11plan Date: Wed, 25 Feb 2026 17:10:22 +0900 Subject: [PATCH 082/108] =?UTF-8?q?docs:=20VO=20=EC=A0=9C=EA=B1=B0?= =?UTF-8?q?=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EC=84=A4=EA=B3=84=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- docs/design/_shared/OVERVIEW.md | 29 +++++++------------ docs/design/cart/DESIGN.md | 13 ++------- docs/design/order/DESIGN.md | 19 ++++-------- docs/design/product/DESIGN.md | 20 +++++-------- ...54\240\225_\352\270\260\353\241\235_v3.md" | 27 +++++++---------- 5 files changed, 38 insertions(+), 70 deletions(-) diff --git a/docs/design/_shared/OVERVIEW.md b/docs/design/_shared/OVERVIEW.md index e94a2d2e7..ef437e154 100644 --- a/docs/design/_shared/OVERVIEW.md +++ b/docs/design/_shared/OVERVIEW.md @@ -98,11 +98,11 @@ erDiagram ```mermaid classDiagram class User { - LoginId loginId - Password password - UserName name + String loginId + String password + String name LocalDate birthDate - Email email + String email } class Brand { @@ -114,8 +114,8 @@ classDiagram class Product { Brand brand String name - Money price - Stock stock + int price + int stock int likeCount +decreaseStock(int) void +isSoldOut() boolean @@ -132,7 +132,7 @@ classDiagram class CartItem { Long userId Long productId - Quantity quantity + int quantity +addQuantity(int) void +updateQuantity(int) void } @@ -146,23 +146,17 @@ classDiagram class Order { Long userId - Money totalPrice + int totalPrice OrderStatus status } class OrderItem { Long orderId Long productId - Money orderPrice - Quantity quantity - ProductSnapshot snapshot - } - - class ProductSnapshot { - <> + int orderPrice + int quantity String productName String brandName - String imageUrl } class OrderStatus { @@ -178,7 +172,6 @@ classDiagram Cart o-- CartItem : 일급 컬렉션 Order "*" --> "1" User : userId OrderItem "*" --> "1" Order : orderId - OrderItem *-- ProductSnapshot : @Embedded Order --> OrderStatus ``` @@ -195,7 +188,7 @@ classDiagram | Product → CartItem | 1 : N | ID 참조 (productId) | 가격 저장 안 함 | | User → Order | 1 : N | ID 참조 (userId) | UserSnapshot 불필요 | | Order → OrderItem | 1 : N | ID 참조 (orderId) | @OneToMany 미사용 | -| OrderItem → ProductSnapshot | 1 : 1 | @Embedded | 주문 시점 스냅샷 | +| OrderItem | - | 직접 필드 (productName, brandName) | 주문 시점 스냅샷 | --- diff --git a/docs/design/cart/DESIGN.md b/docs/design/cart/DESIGN.md index 03ca9fa79..ebab12f4e 100644 --- a/docs/design/cart/DESIGN.md +++ b/docs/design/cart/DESIGN.md @@ -136,7 +136,7 @@ classDiagram class CartItem { Long userId Long productId - Quantity quantity + int quantity +addQuantity(int) void +updateQuantity(int) void } @@ -154,19 +154,12 @@ classDiagram Cart o-- CartItem : 일급 컬렉션 ``` -### Value Object - -| VO | 검증/행위 | 비즈니스 규칙 | -|---|---|---| -| Quantity | validate() | 1 이상 99 이하 | -| Quantity | add(amount) | 수량 합산, 결과가 99를 초과하면 예외 | - ### 비즈니스 규칙 | 엔티티 | 메서드 | 비즈니스 규칙 | |---|---|---| -| CartItem | addQuantity(int) | 이미 담긴 상품 → 수량 합산. 99 초과 시 예외 | -| CartItem | updateQuantity(int) | 수량 변경. 1~99 범위 검증 | +| CartItem | addQuantity(int) | 이미 담긴 상품 → 수량 합산. 99 초과 시 예외. Entity 내부에서 범위 검증 | +| CartItem | updateQuantity(int) | 수량 변경. 1~99 범위를 Entity 내부에서 검증 | | Cart | getTotalPrice() | 일급 컬렉션. 장바구니 전체 가격 계산 (Product 현재 가격 기준) | | Cart | selectItems(List) | 선택한 항목만 추출 (장바구니에서 부분 주문 시) | diff --git a/docs/design/order/DESIGN.md b/docs/design/order/DESIGN.md index 648172515..161c1393f 100644 --- a/docs/design/order/DESIGN.md +++ b/docs/design/order/DESIGN.md @@ -21,7 +21,7 @@ - **동시성 이슈** — 추후 비관적 락 또는 낙관적 락으로 해결 예정. - **가격 변동 검증** — 주문 시점에 클라이언트가 보낸 expectedPrice와 Product의 현재 가격을 비교. 불일치 시 주문 실패 ("가격이 변경되었습니다"). 하이패션 고가 상품의 가격 분쟁 방지. - **두 가지 주문 경로** — 바로구매(상품 페이지에서 직접)와 장바구니 주문. Order 도메인은 출처를 모르고, Facade가 경로를 조율. 주문 로직은 단일. -- **스냅샷 구조** — OrderItem에 @Embedded ProductSnapshot (productName, brandName, imageUrl 등). productId는 별도 유지 (재구매, 통계용, FK 아님). +- **스냅샷 구조** — OrderItem에 직접 필드로 저장 (productName, brandName). productId는 별도 유지 (재구매, 통계용, FK 아님). VO(@Embeddable)를 사용하지 않고 Entity 필드로 직접 관리. - **Order ↔ OrderItem** — ID 참조 (orderId). @OneToMany 미사용. 같은 Aggregate이지만 프로젝트 전체 ID 참조 패턴과 일관성 유지. ### API @@ -143,23 +143,17 @@ sequenceDiagram classDiagram class Order { Long userId - Money totalPrice + int totalPrice OrderStatus status } class OrderItem { Long orderId Long productId - Money orderPrice - Quantity quantity - ProductSnapshot snapshot - } - - class ProductSnapshot { - <> + int orderPrice + int quantity String productName String brandName - String imageUrl } class OrderStatus { @@ -169,7 +163,6 @@ classDiagram Order "*" --> "1" User : userId OrderItem "*" --> "1" Order : orderId - OrderItem *-- ProductSnapshot : @Embedded Order --> OrderStatus ``` @@ -177,7 +170,7 @@ classDiagram | 엔티티 | 메서드 | 비즈니스 규칙 | |---|---|---| -| OrderItem | createSnapshot(Product, int) | 정적 팩토리. 주문 시점 Product 정보를 ProductSnapshot으로 복사 | +| OrderItem | create(orderId, productId, orderPrice, quantity, productName, brandName) | 정적 팩토리. 주문 시점 상품 정보를 직접 필드로 저장 | ### 관계 정리 @@ -185,7 +178,7 @@ classDiagram |---|---|---| | User → Order | ID 참조 (userId) | UserSnapshot 불필요 | | Order → OrderItem | ID 참조 (orderId) | @OneToMany 미사용. 같은 Aggregate이지만 ID 참조 | -| OrderItem → ProductSnapshot | @Embedded | 주문 시점 상품 정보 스냅샷 | +| OrderItem 스냅샷 필드 | 직접 필드 (productName, brandName) | 주문 시점 상품 정보를 Entity 필드로 직접 저장 | | OrderItem.productId | ID 유지 (FK 아님) | 재구매, 통계 분석을 위한 데이터 연결용 | --- diff --git a/docs/design/product/DESIGN.md b/docs/design/product/DESIGN.md index 0680c8ae6..7e46c0c4b 100644 --- a/docs/design/product/DESIGN.md +++ b/docs/design/product/DESIGN.md @@ -127,11 +127,12 @@ classDiagram class Product { Brand brand String name - Money price - Stock stock + int price + int stock int likeCount - +update(String, Money, Stock) void + +update(String, int, int) void +decreaseStock(int) void + +validateExpectedPrice(int) void +isSoldOut() boolean +addLikeCount() void +subtractLikeCount() void @@ -142,20 +143,13 @@ classDiagram Product "*" --> "1" Brand : 객체참조 (FK 없음) ``` -### Value Object - -| VO | 검증/행위 | 비즈니스 규칙 | -|---|---|---| -| Money | validate() | 0 이상이어야 함 | -| Stock | validate() | 0 이상이어야 함 | -| Stock | deduct(quantity) | 재고 부족 시 CoreException(BAD_REQUEST) | -| Stock | hasEnough(quantity) | 재고가 요청 수량 이상인지 확인 | - ### 비즈니스 규칙 | 메서드 | 비즈니스 규칙 | |---|---| -| decreaseStock(int) | 재고 부족 시 CoreException(BAD_REQUEST). Stock VO에 위임 | +| create(brand, name, price, stock) | 가격 0 이상, 재고 0 이상 검증 (Entity 내부 validatePriceRange, validateStockRange) | +| decreaseStock(int) | 재고 부족 시 CoreException(BAD_REQUEST). Entity 도메인 메서드에서 직접 처리 | +| validateExpectedPrice(int) | 주문 시 기대 가격과 현재 가격 비교. 불일치 시 예외 | | isSoldOut() | stock이 0인지 확인. "품절"의 정의를 캡슐화 | | addLikeCount() / subtractLikeCount() | 찜 등록/취소 시 likeCount 원자적 증감 | | softDelete() / isDeleted() | deleted_at 설정 | diff --git "a/docs/design/\353\217\204\353\251\224\354\235\270_\352\264\200\352\263\204_\354\204\244\352\263\204_\354\235\230\354\202\254\352\262\260\354\240\225_\352\270\260\353\241\235_v3.md" "b/docs/design/\353\217\204\353\251\224\354\235\270_\352\264\200\352\263\204_\354\204\244\352\263\204_\354\235\230\354\202\254\352\262\260\354\240\225_\352\270\260\353\241\235_v3.md" index be248ac3e..3f1118bec 100644 --- "a/docs/design/\353\217\204\353\251\224\354\235\270_\352\264\200\352\263\204_\354\204\244\352\263\204_\354\235\230\354\202\254\352\262\260\354\240\225_\352\270\260\353\241\235_v3.md" +++ "b/docs/design/\353\217\204\353\251\224\354\235\270_\352\264\200\352\263\204_\354\204\244\352\263\204_\354\235\230\354\202\254\352\262\260\354\240\225_\352\270\260\353\241\235_v3.md" @@ -500,30 +500,25 @@ graph LR | | 접근 1: 직접 넣기 | 접근 2: @Embedded | 접근 3: 별도 테이블 | |---|---|---|---| | DB | OrderItem 테이블 하나 | OrderItem 테이블 하나 | OrderItem + Snapshot 2개 | -| 코드 | 필드 뒤섞임 | **역할별 분리** | 완전 분리 | -| 스냅샷 필드 추가 시 | OrderItem이 비대해짐 | **ProductSnapshot만 수정** | JOIN 필요 | +| 코드 | 필드 뒤섞임 | 역할별 분리 | 완전 분리 | +| 스냅샷 필드 추가 시 | OrderItem이 비대해짐 | ProductSnapshot만 수정 | JOIN 필요 | +| JPA 마찰 | 없음 | @AttributeOverride, null 전파 | JOIN 관리 | -- **내 상황에서의 판단**: 하이패션 상품은 스냅샷할 정보가 많을 수 있다(상품명, 브랜드명, 이미지, 사이즈, 컬러, 소재 등). 접근 1이면 OrderItem 필드가 비대해지고, 접근 3은 항상 같이 생성/조회되는 데이터를 굳이 테이블로 나누는 과도한 분리다. +- **내 상황에서의 판단**: 현재 스냅샷 필드가 2개(productName, brandName)로 소수이므로 접근 1(직접 필드)이 가장 단순하다. 접근 2(@Embedded)는 `@Embeddable`의 JPA 마찰(같은 타입 2개 시 `@AttributeOverride`, 내부 필드 전부 null 시 객체 자체 null)이 있고, 프로젝트 전체에서 VO를 만들지 않는 원칙과도 일관성이 맞지 않는다. 접근 3은 항상 같이 생성/조회되는 데이터를 굳이 테이블로 나누는 과도한 분리다. 스냅샷 필드가 많아지면 그때 @Embedded 또는 별도 테이블을 재검토한다. -### 결정: @Embedded로 분리 +### 결정: Entity 직접 필드로 저장 + +> **리팩토링 기록**: 초기에는 `@Embedded ProductSnapshot`으로 설계했으나, VO 전면 제거 리팩토링에서 Entity 직접 필드 방식으로 변경. `@Embeddable`의 JPA 마찰(같은 타입 2개 시 `@AttributeOverride` 보일러플레이트, 내부 필드 전부 null 시 객체 자체 null 등)을 고려하여, 스냅샷 필드가 소수일 때는 직접 필드가 더 단순하다고 판단. ```java @Entity public class OrderItem { private Long orderId; private Long productId; // 데이터 연결용 (재구매, 통계) - private int quantity; private int orderPrice; - - @Embedded - private ProductSnapshot snapshot; // 조회 편의용 -} - -@Embeddable -public class ProductSnapshot { - private String productName; - private String brandName; - private String imageUrl; + private int quantity; + private String productName; // 주문 시점 스냅샷 + private String brandName; // 주문 시점 스냅샷 } ``` @@ -531,7 +526,7 @@ public class ProductSnapshot { > - **영역을 나누는 기준은 "함께 변경되는가"** — 생명주기 종속이나 구조 유사성이 아니라, 변경 이유가 같은가 > - **FK는 편의가 아니라 제약** — 데드락, 결합도, 삭제 순서 문제를 감수할 수 있을 때만 -> - **같은 데이터라도 역할이 다르면 분리** — productId(연결), orderPrice(기록), ProductSnapshot(조회) +> - **같은 데이터라도 역할이 다르면 분리** — productId(연결), orderPrice(기록), productName/brandName(스냅샷 조회) --- From 2f96572fad363355eba179d227780d8cb1b187ae Mon Sep 17 00:00:00 2001 From: plan11plan Date: Wed, 25 Feb 2026 17:10:29 +0900 Subject: [PATCH 083/108] =?UTF-8?q?docs:=20VO=20=EC=A0=9C=EA=B1=B0?= =?UTF-8?q?=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EC=BB=A8=EB=B2=A4=EC=85=98=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .claude/hooks/convention-check.sh | 13 - .claude/skills/project-convention/SKILL.md | 8 +- .../references/common/dto-convention.md | 11 +- .../references/common/package-convention.md | 8 +- .../references/common/test-convention.md | 54 ++--- .../references/domain/entity-vo-convention.md | 223 +++++++----------- 6 files changed, 119 insertions(+), 198 deletions(-) diff --git a/.claude/hooks/convention-check.sh b/.claude/hooks/convention-check.sh index 47c2dddcf..8b1e59182 100755 --- a/.claude/hooks/convention-check.sh +++ b/.claude/hooks/convention-check.sh @@ -167,19 +167,6 @@ for ENTITY_FILE in $(grep -rl "^@Entity" "$SRC/domain/" 2>/dev/null); do fi done -# ============================================================================ -# 규칙 8: @Embeddable VO에 @EqualsAndHashCode 필수 -# 출처: entity-vo-convention.md § 2. VO 설계 규칙 -# ============================================================================ - -for VO_FILE in $(grep -rl "@Embeddable" "$SRC/domain/" 2>/dev/null); do - if ! grep -q "EqualsAndHashCode" "$VO_FILE" 2>/dev/null; then - WARNINGS+="[경고] @Embeddable VO에 @EqualsAndHashCode 누락: $(basename $VO_FILE)\n" - WARNINGS+=" → VO는 값 동등성이 필수. @EqualsAndHashCode를 추가할 것\n" - WARNINGS+=" → 참고: entity-vo-convention.md § 2. VO 설계 규칙\n\n" - fi -done - # ============================================================================ # 규칙 9: Controller → Facade 직접 호출 확인 (Domain Service 직접 호출 금지) # 출처: service-layer-convention.md § 5. 계층 간 호출 규칙 diff --git a/.claude/skills/project-convention/SKILL.md b/.claude/skills/project-convention/SKILL.md index 9fb996836..a8f0f8a9f 100644 --- a/.claude/skills/project-convention/SKILL.md +++ b/.claude/skills/project-convention/SKILL.md @@ -131,10 +131,10 @@ ApiResponse.failValidation(code, message, fieldErrors) // Validation 에 **Value Object** -- 생성 기준: 단순 검증만이면 안 만듦. 형식 규칙/행위/복합 규칙이 있을 때만 -- 구현: DB 저장 → `@Embeddable`, 비저장 → `record` -- 전달: **Entity 내부에서 원시값으로부터 생성** (바깥에서 VO 전달 금지) -- 검증: 단일값 → VO, 크로스필드 → Entity, 외부의존 → Domain Service +- **기본 원칙: VO를 만들지 않는다** — 검증/행위는 Entity 도메인 메서드 또는 Domain Service에서 처리 +- 필드는 원시값(`int`, `String`, `LocalDate` 등)으로 선언 +- 예외적 VO 생성 조건: 도메인 행위 2개 이상 + 여러 도메인 중복 + `record`로 구현 (`@Embeddable` 지양) +- 검증: 자기 필드 → Entity `private static validateXxx`, 외부 의존 → Domain Service --- diff --git a/.claude/skills/project-convention/references/common/dto-convention.md b/.claude/skills/project-convention/references/common/dto-convention.md index 23e60c334..62391eb1c 100644 --- a/.claude/skills/project-convention/references/common/dto-convention.md +++ b/.claude/skills/project-convention/references/common/dto-convention.md @@ -22,7 +22,7 @@ - Application `~Criteria`는 유스케이스 입력을 표현하며, 내부에서 Domain Service의 `~Command`를 참조하거나 조합할 수 있다. - Application `~Result`는 유스케이스 결과를 표현한다 (단일/조합 구분 없이 통합). - Domain Service `~Command`는 자기 도메인 비즈니스 명령과 타 도메인 정보 명세를 모두 포함한다. -- Domain 계층 자체(Entity, VO)는 DTO를 사용하지 않는다. +- Domain 계층 자체(Entity)는 DTO를 사용하지 않는다. --- @@ -95,7 +95,7 @@ public record ProductResult(Long id, String name, int price) { ```java // 주문 도메인이 상품 도메인에 요구하는 정보 명세 -public record OrderProductCommand(Long productId, String name, Money price, Long shopId) { +public record OrderProductCommand(Long productId, String name, int price, Long shopId) { public static OrderProductCommand from(ProductResult product) { return new OrderProductCommand( product.id(), product.name(), product.price(), product.shopId() @@ -125,9 +125,8 @@ public record StockDeductionInfo(int remainingStock, boolean success) {} | **1~3개** | 원시 타입 직접 전달 | `orderService.create(memberId, address, shopId)` | | **4개 이상** | DTO(`~Command`) 사용 | `orderService.create(orderProductCommand)` | -> **주의 — VO 전달과 VO 생성은 다르다.** -> Entity 필드용 VO를 호출자(Facade 등)가 **새로 생성하여 전달하는 것은 금지**한다. VO는 Entity 내부에서 원시값으로부터 생성한다 (→ entity-vo-convention 참조). -> 여기서 "직접 전달"이란, Entity getter 등에서 이미 존재하는 값을 꺼내 넘기는 경우를 말한다. +> **주의 — Entity 필드는 원시값으로 전달한다.** +> Entity 필드에 대응하는 값은 원시 타입(`int`, `String`, `LocalDate` 등)으로 직접 전달한다 (→ entity-vo-convention 참조). ```java // ✅ 파라미터 3개 이하 → 원시 타입 @@ -213,6 +212,6 @@ Client - [ ] Application DTO(Criteria/Result)에 API 스펙 관련 어노테이션이 없는가? - [ ] Domain Service가 Application DTO를 참조하지 않는가? - [ ] Domain Service의 Info는 Entity로 충분하지 않을 때만 만들었는가? -- [ ] Domain Service 파라미터 1~3개는 원시 타입/VO, 4개+는 Command DTO인가? +- [ ] Domain Service 파라미터 1~3개는 원시 타입, 4개+는 Command DTO인가? - [ ] Domain Service 메서드 시그니처에 타 도메인 Entity가 노출되지 않는가? - [ ] 여러 도메인 Info 조합 시 Application에서 `~Result`로 합치는가? diff --git a/.claude/skills/project-convention/references/common/package-convention.md b/.claude/skills/project-convention/references/common/package-convention.md index 2c99a16fc..47cc0170d 100644 --- a/.claude/skills/project-convention/references/common/package-convention.md +++ b/.claude/skills/project-convention/references/common/package-convention.md @@ -31,7 +31,7 @@ com.loopers/ │ └── like/ │ ├── domain/ ← Domain 계층 -│ ├── order/ ← 주문 Entity, VO, Service, Repository(I/F), ErrorCode +│ ├── order/ ← 주문 Entity, Service, Repository(I/F), ErrorCode │ ├── product/ │ └── like/ │ @@ -90,7 +90,7 @@ domain/ └── order/ ├── Order.java ← Entity (Aggregate Root) ├── OrderLine.java ← Entity (하위) - ├── OrderStatus.java ← enum / VO + ├── OrderStatus.java ← enum ├── OrderService.java ← Domain Service ├── OrderRepository.java ← Repository 인터페이스 ├── OrderErrorCode.java ← 도메인 에러코드 @@ -202,7 +202,7 @@ support에 넣으면 안 되는 것: | Domain Service | `{Domain}Service` | `OrderService` | | Repository (인터페이스) | `{Domain}Repository` | `OrderRepository` | | ErrorCode | `{Domain}ErrorCode` | `OrderErrorCode` | -| VO / enum | 의미에 맞게 | `OrderStatus`, `Money` | +| enum | 의미에 맞게 | `OrderStatus` | | Command DTO | `{Target}Command` | `OrderProductCommand` | ### infrastructure/{domain}/ @@ -270,7 +270,7 @@ public class OrderService { **의존 방향** - [ ] interfaces → application → domain ← infrastructure 방향을 지키는가? -- [ ] domain Entity/VO/Repository 인터페이스에 Spring 어노테이션(`@Component`, `@Repository`)이 없는가? +- [ ] domain Entity/Repository 인터페이스에 Spring 어노테이션(`@Component`, `@Repository`)이 없는가? - [ ] domain Service의 `@Service`, `@Transactional`, `Page`/`Pageable` 사용은 컨벤션 허용 (service-layer-convention.md § 3~4) - [ ] 도메인 간 Entity 직접 참조가 없는가? diff --git a/.claude/skills/project-convention/references/common/test-convention.md b/.claude/skills/project-convention/references/common/test-convention.md index b0c2dd9f9..02bbc8997 100644 --- a/.claude/skills/project-convention/references/common/test-convention.md +++ b/.claude/skills/project-convention/references/common/test-convention.md @@ -32,18 +32,18 @@ | 항목 | 내용 | |------|------| -| 대상 | Entity, VO, Domain Service | +| 대상 | Entity, Domain Service | | 환경 | **Spring 없이 순수 JVM** | | 테스트 더블 | **Fake 우선**, 필요 시 Mockito | | 속도 | 빠름 (ms 단위) | | 비중 | 가장 많이 작성 | ```java -class NameTest { +class UserModelTest { @Test - void createName_whenValidNameProvided() { - Name name = new Name("홍길동"); - assertThat(name.getValue()).isEqualTo("홍길동"); + void create_whenAllDataProvided() { + UserModel user = UserModel.create("testuser", "encPw", "홍길동", LocalDate.of(2000, 1, 1), "test@email.com"); + assertThat(user.getName()).isEqualTo("홍길동"); } } ``` @@ -101,35 +101,31 @@ class UserV1ApiE2ETest { ```java class UserModelTest { - // 공통 픽스처는 @BeforeEach에서 초기화 - @BeforeEach - void setUp() { - encoder = new FakePasswordEncoder(); - validLoginId = new LoginId("testuser123"); - // ... - } - @DisplayName("유저 모델을 생성할 때, ") @Nested class Create { @DisplayName("모든 필드가 주어지면, 정상적으로 생성된다.") @Test - void createUserModel_whenAllDataProvided() { + void create_whenAllDataProvided() { // act - UserModel user = new UserModel(...); + UserModel user = UserModel.create( + "testuser123", "encryptedPw", "홍길동", + LocalDate.of(2000, 1, 1), "test@email.com"); // assert assertAll( - () -> assertThat(user.getLoginId()).isEqualTo(validLoginId), - () -> assertThat(user.getName()).isEqualTo(validName) + () -> assertThat(user.getLoginId()).isEqualTo("testuser123"), + () -> assertThat(user.getName()).isEqualTo("홍길동") ); } @DisplayName("로그인 ID가 누락되면 예외가 발생한다.") @Test - void createUserModel_whenLoginIdIsNull() { - assertThatThrownBy(() -> new UserModel(null, ...)) + void create_whenLoginIdIsNull() { + assertThatThrownBy(() -> UserModel.create( + null, "encryptedPw", "홍길동", + LocalDate.of(2000, 1, 1), "test@email.com")) .isInstanceOf(CoreException.class); } } @@ -165,8 +161,10 @@ void createOrder_whenAllDataProvided() { ```java @Test -void createName_whenNameIsNull() { - assertThatThrownBy(() -> new Name(null)) +void create_whenNameIsNull() { + assertThatThrownBy(() -> UserModel.create( + "testuser", "encPw", null, + LocalDate.of(2000, 1, 1), "test@email.com")) .isInstanceOf(CoreException.class); } ``` @@ -191,9 +189,11 @@ assertAll( @DisplayName("2자 이상 10자 이하의 이름이 주어지면, 정상적으로 생성된다.") @ParameterizedTest @ValueSource(strings = {"홍길", "홍길동", "가나다라마바사아자차"}) -void createName_whenValidNameProvided(String validNameValue) { - Name name = new Name(validNameValue); - assertThat(name.getValue()).isEqualTo(validNameValue); +void create_whenValidNameProvided(String validName) { + UserModel user = UserModel.create( + "testuser", "encPw", validName, + LocalDate.of(2000, 1, 1), "test@email.com"); + assertThat(user.getName()).isEqualTo(validName); } ``` @@ -205,7 +205,7 @@ void createName_whenValidNameProvided(String validNameValue) { | 테스트 유형 | 클래스명 패턴 | 예시 | |-----------|-----------|------| -| 단위 (Entity/VO) | `{클래스명}Test` | `NameTest`, `OrderTest` | +| 단위 (Entity) | `{클래스명}Test` | `UserModelTest`, `OrderModelTest` | | 단위 (Domain Service) | `{클래스명}Test` | `OrderServiceTest` | | 통합 | `{클래스명}IntegrationTest` | `UserServiceIntegrationTest` | | E2E | `{API명}E2ETest` | `UserV1ApiE2ETest` | @@ -247,7 +247,7 @@ void changePassword_success() { ... } | 테스트 대상 | 더블 전략 | 이유 | |-----------|---------|------| -| **Entity, VO** | 더블 불필요 (순수 로직) | 외부 의존 없음 | +| **Entity** | 더블 불필요 (순수 로직) | 외부 의존 없음 | | **Domain Service** | **Fake 우선** | 실제 동작과 유사, 상태 검증 가능 | | **Application Facade** | **Mockito mock()** | 여러 Service 조합, Fake 비용 큼 | | **통합 / E2E** | **실제 Bean** | 연동 검증이 목적 | @@ -305,7 +305,7 @@ class OrderFacadeTest { ``` 테스트 대상이 외부 의존이 있는가? - ├── NO → 더블 불필요 (Entity, VO) + ├── NO → 더블 불필요 (Entity) └── YES → 의존이 인터페이스로 분리되어 있는가? ├── YES → Fake를 만들 가치가 있는가? │ ├── 상태 연동이 중요 → Fake diff --git a/.claude/skills/project-convention/references/domain/entity-vo-convention.md b/.claude/skills/project-convention/references/domain/entity-vo-convention.md index 5f1633d1c..5368f091f 100644 --- a/.claude/skills/project-convention/references/domain/entity-vo-convention.md +++ b/.claude/skills/project-convention/references/domain/entity-vo-convention.md @@ -125,82 +125,63 @@ public class Order { ## 2. VO 설계 규칙 -### VO 생성 기준 +### 기본 원칙: VO를 만들지 않는다 -**단순 검증만 필요한 필드는 VO로 만들지 않는다.** 형식 규칙, 도메인 행위, 복합 규칙이 있을 때만 VO를 만든다. +**모든 검증과 행위는 Entity 도메인 메서드 또는 Domain Service에서 처리한다.** VO를 만들지 않는 이유: -| 검증 유형 | VO 생성 | 처리 위치 | -|----------|---------|----------| -| null 검증 | ❌ | `@NotNull`, Entity 메서드 | -| 길이 검증 | ❌ | `@Size`, Entity 메서드 | -| 범위 검증 (0 이상 등) | ❌ | `@Positive`, Entity 메서드 | -| **형식 규칙** (이메일 정규식, 비밀번호 정책) | ✅ | VO 내부 | -| **도메인 행위** (계산, 변환, 비교) | ✅ | VO 내부 | -| **복합 규칙** (암호화, 포맷팅) | ✅ | VO 내부 | - -```java -// ❌ VO 안 만듦 — 단순 검증뿐 -String name; // 길이 제한만 → @Size로 충분 -int quantity; // 0 이상만 → @Positive로 충분 -Long memberId; // 단순 식별자 -LocalDateTime createdAt; // 단순 타임스탬프 - -// ✅ VO 만듦 — 형식 규칙 또는 행위 존재 -Email email; // 정규식 형식 검증 -Money price; // add(), subtract() 계산 행위 -Password password; // 암호화 로직 + 비밀번호 정책 검증 -PhoneNumber phone; // 형식 검증 + 포맷팅 -``` - -### VO 구현 방식 - -| 조건 | 구현 방식 | 예시 | -|------|----------|------| -| Entity 필드로 DB에 저장됨 | `@Embeddable` 클래스 | Money, Password, Email | -| DB 저장과 무관 | `record` | DateRange, PriceRange | +1. **설계**: 검증만 있는 VO는 Entity 메서드로 충분하고, VO 관리 부담이 캡슐화 이득보다 크다 +2. **실무**: 필드마다 "이 필드는 VO인가?" 확인하는 인지 비용이 개발 속도를 떨어뜨린다 +3. **기술(JPA)**: `@Embeddable`은 같은 타입 2개 사용 시 `@AttributeOverride` 보일러플레이트, 내부 필드 전부 null이면 객체 자체 null 등 기술적 마찰이 있다 -#### @Embeddable VO (DB 저장) +| 검증/행위 유형 | VO 생성 | 처리 위치 | +|----------|---------|----------| +| null/길이/범위 검증 | ❌ | Entity 도메인 메서드 (`private static validateXxx`) | +| 형식 규칙 (이메일, 비밀번호 정책) | ❌ | Entity 도메인 메서드 또는 Domain Service | +| 외부 인프라 의존 (암호화 등) | ❌ | Domain Service | +| 도메인 행위 (계산, 변환) | ❌ | Entity 도메인 메서드 | ```java -@Embeddable -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@Getter -@EqualsAndHashCode -public class Money { +// Entity에서 직접 검증 + 행위 처리 +@Entity +public class ProductModel extends BaseEntity { - @Column(nullable = false) - private int amount; + @Column(name = "price", nullable = false) + private int price; - private Money(int amount) { - validate(amount); - this.amount = amount; - } + @Column(name = "stock", nullable = false) + private int stock; - public static Money of(int amount) { - return new Money(amount); + public static ProductModel create(BrandModel brand, String name, int price, int stock) { + validatePriceRange(price); + validateStockRange(stock); + return new ProductModel(brand, name, price, stock); } - public Money add(Money other) { - return Money.of(this.amount + other.amount); + public void decreaseStock(int quantity) { + if (this.stock < quantity) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다."); + } + this.stock -= quantity; } - private void validate(int amount) { - if (amount < 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "금액은 0 이상이어야 합니다."); + private static void validatePriceRange(int price) { + if (price < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격은 0 이상이어야 합니다."); } } } ``` -필수 사항: -- `@NoArgsConstructor(PROTECTED)` — JPA 프록시용 -- `@EqualsAndHashCode` — 값 동등성 -- 생성자 `private` + 정적 팩토리 `of()` — 생성 통제 -- 생성 시 자기 검증 — 유효하지 않은 VO는 존재할 수 없다 +### 예외적으로 VO를 만드는 조건 + +아래 **세 가지를 모두** 충족할 때만 record VO를 만든다: -#### record VO (비저장) +1. 도메인 행위(계산, 변환, 비교)가 **2개 이상** 존재 +2. **여러 도메인**에서 동일 행위가 중복 +3. DB 저장과 무관 (**record**로 구현, `@Embeddable` 지양) ```java +// ✅ 예외적 VO — 행위 2개 이상 + 다도메인 중복 + 비저장 public record DateRange(LocalDate start, LocalDate end) { public DateRange { @@ -218,110 +199,66 @@ public record DateRange(LocalDate start, LocalDate end) { record는 불변, equals/hashCode/toString 자동 생성. compact constructor에서 자기 검증. -### VO 공통 원칙 - -**① 생성 시 자기 검증**: VO는 생성되는 순간 유효성이 보장된다. - -**② 불변**: 상태 변경이 필요하면 새 VO를 반환한다. - -```java -// ✅ 새 VO 반환 -public Money add(Money other) { - return Money.of(this.amount + other.amount); -} - -// ❌ 내부 상태 변경 -public void add(Money other) { - this.amount += other.amount; -} -``` +### 공유 VO 배치 규칙 -**③ 값 동등성**: 내부 값이 같으면 같은 객체. `@Embeddable`은 `@EqualsAndHashCode` 명시, `record`는 자동. +예외적으로 VO를 만들 경우, 아키텍처에 따라 배치한다: -### VO 전달 방식: Entity 내부에서 생성 +| 아키텍처 | 배치 위치 | +|----------|----------| +| 레이어 우선 (현재) | `domain.common` 패키지 | +| 도메인 우선 | `common.domain` 패키지 | +| 멀티모듈 | `common-domain` 모듈 | -VO는 **무조건 Entity(Aggregate Root) 내부에서 생성**한다. 바깥에서 원시값을 받아서 Entity가 VO로 변환한다. - -```java -// ✅ Entity 내부에서 VO 생성 — 원시값을 받는다 -public static Order create(Long memberId, String email, int price) { - Order order = new Order(); - order.memberId = memberId; - order.email = Email.of(email); // 내부에서 VO 생성 - order.price = Money.of(price); // 내부에서 VO 생성 - order.status = OrderStatus.CREATED; - return order; -} - -// ❌ 바깥에서 VO를 만들어서 전달 -public static Order create(Long memberId, Email email, Money price) { ... } -``` - -왜 Entity 내부에서 생성하는가: -- Entity가 자기 VO의 생성을 완전히 통제한다 -- 바깥 계층이 도메인 VO 클래스를 알 필요 없다 (결합도 최소) -- 불변성 보장이 Entity 경계 안에서 완결된다 -- 호출 지점은 Domain Service 1~2곳뿐이므로 파라미터가 많아도 실질적 부담이 없다 +**주의**: common이 비대해지지 않게 진입 기준을 엄격히 적용한다. --- ## 3. 검증 위치 규칙 -세 수준으로 나눈다. +두 수준으로 나눈다. | 검증 수준 | 위치 | 기준 | |----------|------|------| -| **단일 값 형식/규칙** | VO 내부 | 그 값 하나만으로 판단 가능 | -| **Entity 내부 크로스필드** | Entity 메서드 | 같은 Entity의 여러 필드 간 관계 | -| **외부 의존 크로스필드** | Domain Service | Repository 조회나 타 도메인 데이터 필요 | +| **단일 값 / 크로스필드 검증** | Entity `private static validateXxx` 메서드 | 자기 필드만으로 판단 가능 | +| **외부 의존 검증** | Domain Service | Repository 조회, 타 도메인 데이터, 인프라(암호화 등) 필요 | ### 판단 플로우 ``` -이 검증이 단일 값의 형식/규칙인가? - ├── YES → VO 내부 - └── NO → 같은 Entity의 여러 필드 간 관계인가? - ├── YES → Entity 메서드 - └── NO → Domain Service +이 검증이 자기 필드만으로 완결되는가? + ├── YES → Entity의 private static validateXxx 메서드 + └── NO → 뭐가 더 필요한가? + ├── Repository 조회 → Domain Service + ├── 타 도메인 정보 → Domain Service + └── 외부 인프라 (암호화 등) → Domain Service ``` ### 예시 ```java -// 1. 단일 값 → VO 내부 -@Embeddable -public class Email { - private Email(String value) { - if (!value.matches("^[\\w.-]+@[\\w.-]+\\.[a-zA-Z]{2,}$")) { - throw new CoreException(ErrorType.BAD_REQUEST, "이메일 형식이 올바르지 않습니다."); - } - this.value = value; - } -} - -// 2. Entity 크로스필드 → Entity 메서드 +// 1. 단일 값 검증 → Entity 내부 @Entity -public class Promotion { - private LocalDate startDate; - private LocalDate endDate; - - public static Promotion create(LocalDate start, LocalDate end) { - validateDateRange(start, end); - // ... - } +public class UserModel { + private static final Pattern EMAIL_PATTERN = Pattern.compile("^[A-Za-z0-9+_.-]+@([A-Za-z0-9-]+\\.)+[A-Za-z]{2,}$"); - private static void validateDateRange(LocalDate start, LocalDate end) { - if (start.isAfter(end)) { - throw new CoreException(PromotionErrorCode.INVALID_DATE_RANGE); + private static 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, "이메일 형식이 올바르지 않습니다."); } } } -// 3. 외부 의존 → Domain Service -public class OrderService { - public Order create(OrderMemberCommand member, List products) { - validateOrderLimit(member); // 회원 등급별 주문 한도 — 타 도메인 데이터 필요 - // ... +// 2. 외부 의존 검증 → Domain Service +public class UserService { + private static final Pattern PASSWORD_PATTERN = Pattern.compile("..."); + + public UserModel signup(String loginId, String rawPassword, ...) { + validatePasswordFormat(rawPassword); // 형식 검증도 Service에서 (암호화 전 raw 값 필요) + validateBirthDateNotInPassword(rawPassword, birthDate); // 크로스필드 + 인프라 의존 + UserModel.create(loginId, passwordEncoder.encode(rawPassword), ...); // 암호화된 값 전달 } } ``` @@ -377,19 +314,17 @@ Entity에 먼저 넣고, 아래 신호가 보이면 Domain Service로 추출한 - [ ] `@NoArgsConstructor(access = PROTECTED)`가 있는가? - [ ] Setter 없이 도메인 메서드로 상태를 변경하는가? - [ ] 자기 필드만으로 완결되는 로직만 Entity에 있는가? -- [ ] VO를 Entity 내부에서 원시값으로부터 생성하는가? +- [ ] 필드는 원시값(`int`, `String`, `LocalDate` 등)으로 선언하는가? +- [ ] 각 도메인 메서드에서 필요한 검증을 수행하는가? -**VO** -- [ ] 단순 검증(null, 길이, 범위)만 있는 필드를 VO로 만들지 않았는가? -- [ ] DB 저장 VO는 `@Embeddable` + `@EqualsAndHashCode`인가? -- [ ] 비저장 VO는 `record`인가? -- [ ] 생성 시 자기 검증이 포함되어 있는가? -- [ ] 상태 변경 시 새 VO를 반환하는가? (불변) +**VO (예외적 생성 시)** +- [ ] 도메인 행위가 2개 이상 + 여러 도메인에서 중복되는 경우에만 생성했는가? +- [ ] `record`로 구현했는가? (`@Embeddable` 지양) +- [ ] `domain.common` (또는 아키텍처별 공유 영역)에 배치했는가? **검증 위치** -- [ ] 단일 값 검증이 VO 내부에 있는가? -- [ ] 크로스필드 검증이 Entity 메서드에 있는가? -- [ ] 외부 의존 검증이 Domain Service에 있는가? +- [ ] 자기 필드 검증이 Entity `private static validateXxx` 메서드에 있는가? +- [ ] 외부 의존(인프라, 타 도메인) 검증이 Domain Service에 있는가? **로직 배치** - [ ] Repository/타 도메인 필요한 로직이 Domain Service에 있는가? From 0011f21104b6fd38bcd949fd3a45776ca6e9a074 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Wed, 25 Feb 2026 17:10:33 +0900 Subject: [PATCH 084/108] =?UTF-8?q?chore:=20.gitignore=EC=97=90=20.intervi?= =?UTF-8?q?ew-state=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 0bb7e2949..28570c022 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,7 @@ out/ ### Claude Code ### claude/* !.claude/skills/ +.interview-state/ ### Documentation ### docs/* From b012b3e463f79735db5ad1377f0bdefa18540fec Mon Sep 17 00:00:00 2001 From: plan11plan Date: Wed, 25 Feb 2026 17:10:40 +0900 Subject: [PATCH 085/108] =?UTF-8?q?chore:=20=EB=A9=B4=EC=A0=91=20=ED=8C=8C?= =?UTF-8?q?=ED=8A=B8=EB=84=88=20=EC=97=90=EC=9D=B4=EC=A0=84=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .claude/agents/interview-partner/SKILL.md | 281 ++++++++++++++++++ .../interview-partner/agents/code-scanner.md | 137 +++++++++ .../agents/notion-publisher.md | 94 ++++++ .../agents/refactor-executor.md | 93 ++++++ .../referencs/notion-schema.md | 109 +++++++ .../referencs/summary-format.md | 122 ++++++++ .../scripts/session-state.md | 109 +++++++ 7 files changed, 945 insertions(+) create mode 100644 .claude/agents/interview-partner/SKILL.md create mode 100644 .claude/agents/interview-partner/agents/code-scanner.md create mode 100644 .claude/agents/interview-partner/agents/notion-publisher.md create mode 100644 .claude/agents/interview-partner/agents/refactor-executor.md create mode 100644 .claude/agents/interview-partner/referencs/notion-schema.md create mode 100644 .claude/agents/interview-partner/referencs/summary-format.md create mode 100644 .claude/agents/interview-partner/scripts/session-state.md diff --git a/.claude/agents/interview-partner/SKILL.md b/.claude/agents/interview-partner/SKILL.md new file mode 100644 index 000000000..24c535ef9 --- /dev/null +++ b/.claude/agents/interview-partner/SKILL.md @@ -0,0 +1,281 @@ +--- +name: toss-interview-partner +description: > + 토스 면접관 스타일의 대화형 아키텍처/설계 검증 파트너. + 사용자가 자신의 프로젝트 설계 결정(파사드 패턴, 패키지 분리, VO 설계, + 레이어 구조, 기술 선택 등)에 대해 이야기하면 면접관처럼 꼬리질문을 던지고, + 사용자의 답변에서 빈틈을 찾아 더 깊이 파고들며, 대화가 충분히 진행되면 + 면접 준비 자료로 정리하고 노션에 저장한다. + "면접 준비", "설계 리뷰", "왜 이렇게 했지", "깊이파기", "꼬리질문", + "아키텍처 검증", "파사드", "패키지 분리", "VO", "레이어", "도메인 설계", + "리팩토링 대화", "면접 시뮬레이션", "설계 결정", "면접 시작" 등의 + 키워드에 트리거한다. 단순 코드 리뷰나 한 번에 분석해주는 것이 아니라, + 반드시 사용자와 대화를 주고받으며 진행해야 한다. +--- + +# Interview Partner + +## 핵심 철학 + +이 스킬의 목표는 **사용자가 스스로 생각하게 만드는 것**이다. +답을 알려주는 것이 아니라, 질문으로 사용자의 사고를 끌어낸다. +면접의 본질은 "정답을 아느냐"가 아니라 "왜 그렇게 생각하느냐"이다. + +--- + +## 워크플로우 (Claude Code 환경) + +``` +[사용자: "면접 시작" / "파사드 깊이파기 해줘" 등] + │ + ├─ ① code-scanner 서브에이전트 (자동, 첫 세션 1회) + │ └─ 프로젝트 코드 스캔 → .interview-state/design-map.md 생성 + │ + ├─ ② 🎤 면접 모드 (메인 에이전트) + │ └─ design-map 참조하며 프로젝트 맥락 기반 꼬리질문 + │ └─ 주기적으로 .interview-state/session.md 체크포인트 저장 + │ + ├─ ③ 📋 정리 모드 ("정리해줘" 트리거) + │ └─ session.md 기반 면접 노트 생성 + │ └─ references/summary-format.md 참조 + │ + ├─ ④ notion-publisher 서브에이전트 ("노션에 저장해줘" 트리거) + │ └─ 면접 노트를 노션 페이지로 생성 + │ └─ references/notion-schema.md 참조 + │ + ├─ ⑤ refactor-executor 서브에이전트 ("코드 개선해줘" 트리거) + │ └─ 면접에서 도출된 개선 포인트 기반 리팩토링 + │ + └─ 🔄 "다른 주제 볼까?" → ② 로 복귀 +``` + +### 자동 실행 조건 + +- **code-scanner**: 면접 세션 시작 시 `.interview-state/design-map.md`가 없으면 자동 실행. + 이미 있으면 스킵. 사용자가 "다시 스캔해줘"라고 하면 재실행. +- **notion-publisher**: 사용자가 명시적으로 요청할 때만 ("노션에 저장", "노션에 올려줘") +- **refactor-executor**: 사용자가 명시적으로 요청할 때만 ("코드 개선해줘", "리팩토링해줘") + +--- + +## 프로젝트 컨텍스트 + +이 스킬이 분석하는 프로젝트: +- **프레임워크**: Spring Boot 3.4.4, Java 21 +- **아키텍처**: Interface → Application → Domain ← Infrastructure (레이어드) +- **도메인 분리**: 패키지 기반 도메인 분리 (7개 도메인, 33개 기능) +- **기술 스택**: JPA, QueryDSL, Kafka, Redis, Virtual Threads +- **프로젝트 구조**: apps/ (애플리케이션), modules/ (도메인 모듈), supports/ (공통) + +code-scanner의 design-map이 생성되면 이 정보가 구체화된다. +design-map을 참조하여 **실제 클래스명, 패키지명, 의존관계**를 질문에 반영한다. + +### 필수 참조 문서 + +면접 모드에서 질문의 깊이를 높이기 위해 아래 문서들을 참조한다. + +**설계 문서** (`docs/design/`): +- `_shared/OVERVIEW.md` — 전체 ERD + 클래스 다이어그램 +- `_shared/CONVENTIONS.md` — 참조 방식, Soft Delete, 용어집 +- `{domain}/DESIGN.md` — 각 도메인별 요구사항, 유즈케이스, 시퀀스, ERD, 클래스 +- `도메인_관계_설계_의사결정_기록_v3.md` — 도메인 간 관계 설계 의사결정 + +**프로젝트 컨벤션** (`.claude/skills/project-convention/references/`): +- `common/package-convention.md` — 패키지 구조 +- `common/dto-convention.md` — DTO 설계 +- `common/exception-convention.md` — 예외 처리 +- `domain/entity-vo-convention.md` — Entity/VO 설계 +- `application/service-layer-convention.md` — 서비스 레이어 +- `infrastructure/infrastructure-convention.md` — 인프라 레이어 +- `interfaces/api-convention.md` — API 설계 + +**활용 방식**: 사용자가 특정 주제(예: VO, 파사드, 패키지 분리)를 꺼내면 +해당 주제의 설계 문서와 컨벤션을 Read하여 **설계 의도 vs 실제 코드** +관점에서 더 날카로운 꼬리질문을 던진다. + +--- + +## 모드 상세 + +### 🎤 면접 모드 (기본) + +사용자가 주제를 꺼내면 자동 진입. "정리해줘" 전까지 유지. + +#### 첫 질문 전략 + +바로 꼬리질문으로 시작하지 않는다. +먼저 사용자의 현재 이해도를 파악하는 열린 질문을 한다. + +``` +사용자: "파사드 패턴 써서 레이어 구조 잡았는데 리뷰 좀" + +나쁜 시작: "파사드의 단점이 뭔지 아세요?" (퀴즈 느낌) +좋은 시작: "파사드를 도입하게 된 계기가 뭐였어? + 어떤 문제를 해결하려고 뒀어?" +``` + +#### 4가지 설계 렌즈 (모든 질문의 뿌리) + +아키텍처/설계/객체지향의 모든 질문은 결국 이 4가지로 수렴한다. +**어떤 주제든 이 4렌즈 중 가장 약한 지점을 파고든다.** + +``` +🔲 경계 — "어디까지 선 넘어도 되는지" + 이 객체/레이어/모듈이 알아도 되는 범위는 어디까지인가? + - "이 클래스가 저걸 알고 있어도 돼?" + - "이 레이어에서 저 레이어를 직접 참조하고 있는데, 괜찮아?" + - "이 도메인이 저 도메인의 내부 구현을 알고 있잖아. 의도한 거야?" + - "이 의존 방향이 바뀌면 어디까지 깨져?" + +📦 책임 — "어디까지 하면 머리가 안 터지는지" + 이 객체/클래스/메서드가 지금 너무 많은 걸 하고 있지 않은가? + - "이 클래스가 하는 일을 한 문장으로 말할 수 있어?" + - "이 메서드에서 하는 일을 나열해봐. 그게 다 여기 있어야 해?" + - "이 파사드가 비대해지면 어떻게 할 거야?" + - "이 책임을 다른 데로 옮기면 뭐가 달라져?" + +🎭 역할 — "쟤가 해도 될 것 같은데?" + 이 일을 꼭 이 객체가 해야 하는가? 더 적합한 곳이 있지 않은가? + - "이 로직이 여기 있는 이유가 뭐야? 저기서 해도 되지 않아?" + - "서비스가 이걸 하고 있는데, 이거 도메인 객체가 할 일 아니야?" + - "파사드가 이걸 하는 게 맞아? 서비스 없이 컨트롤러에서 바로 하면 안 돼?" + - "이 검증을 여기서 하는 이유는? 더 앞단/뒷단에서 하면?" + +🤝 협력 — "같이 해야만 하는 것" + 이 객체들이 함께 움직여야 하는 이유가 있는가? 결합도는 적절한가? + - "이 두 도메인이 항상 같이 움직이는데, 왜 분리했어?" + - "반대로 이 두 개가 따로 변할 수 있는 상황은 없어?" + - "이 협력 관계에서 한쪽이 죽으면 다른 쪽은 어떻게 돼?" + - "이벤트로 느슨하게 연결하면 안 되는 이유가 있어?" +``` + +**사용법**: 사용자의 답변을 듣고, 4렌즈 중 어떤 관점이 약한지 판단한 후 +해당 렌즈의 질문 패턴으로 파고든다. **한 턴에는 하나의 렌즈에 집중**. + +#### 답변 상태별 대응 + +``` +[답변이 추상적] → "구체적으로 어떤 상황에서?" / "코드로 보여줄 수 있어?" +[답변이 단정적] → "반대로 ~한 경우에는?" / "그 전제가 깨지는 상황은?" +[답변이 교과서적] → "너네 프로젝트에서는 구체적으로?" / "실제로 불편한 점은?" +[트레이드오프 숨어있을 때] → "~는 포기하는 거잖아. 괜찮아?" +[맞지만 더 갈 수 있을 때] → "한 단계 더 들어가서..." / "코드 레벨에서 설명할 수 있어?" +[틀렸을 때] → 바로 지적하지 않고 반례로 유도: "~한 상황에서도 그렇게 동작할까?" +``` + +#### 깊이(DFS) vs 너비(BFS) 조절 + +한 주제에서 **5레벨 이상** 깊이 파지 않는다. +3~4레벨에서 명확히 답변하면 **옆으로 이동**. + +**전환 시그널**: +- 명확한 답변 → "좋아, 그 부분은 확실하네." +- 새로운 주제 → 자연스럽게 전환 +- 맴돌 때 → "이 부분은 여기까지 하고, 다른 걸 볼까?" + +#### design-map 활용 질문 + +code-scanner가 생성한 design-map을 참조하여 +**실제 클래스명/패키지명으로 질문**한다. + +``` +design-map에 "OrderFacade → ProductService, PaymentService 의존" 정보가 있으면: + +나쁜 질문: "파사드에서 서비스를 어떻게 조합해?" (일반론) +좋은 질문: "OrderFacade에서 ProductService랑 PaymentService를 둘 다 호출하고 있는데, + 이 두 서비스의 트랜잭션이 하나로 묶여야 해? + 아니면 따로 돌아도 괜찮아?" +``` + +#### 장애 시나리오 유도 + +대화 흐름 속에서 자연스럽게 끌어낸다. + +``` +사용자: "주문 생성 시 재고를 먼저 차감하고 결제를 진행해요" +→ "재고 차감은 됐는데 결제가 실패하면 어떻게 돼?" +→ 장애 발생 시 사용자에게 어떻게 보여? → 현재 코드에서 잡고 있어? → 한계는? +``` + +#### 코드 리뷰 연계 + +대화 중 코드 수준의 논의가 필요하면 **직접 코드를 읽는다** (Claude Code니까). +설계 관점의 질문을 한다 (문법/스타일 리뷰가 아님). +리팩토링이 필요해 보여도 **바로 답을 말하지 않고** 질문으로 유도. + +정리 모드에서 코드 개선을 요청하면, 그때 refactor-executor 호출. + +#### 리액션 패턴 + +- 좋은 답변: "오, 그 관점 좋다." / "맞아, 핵심을 잘 짚었네." +- 아쉬운 답변: "음, 근데 ~는 어떻게 되는 거지?" +- 모르겠다: "괜찮아, 힌트를 줄게..." +- 틀린 답변: "그렇게 생각할 수도 있는데, ~한 경우를 한번 생각해봐." + +#### 질문 개수 제한 + +**한 턴에 질문은 최대 2개.** 핵심 1개 + 파생 1개. + +#### 세션 상태 저장 + +5~6턴마다 `.interview-state/session.md`에 체크포인트를 저장한다. +포맷은 `scripts/session-state.md` 참조. 이렇게 해야 긴 대화에서 +컨텍스트가 밀려도 어디까지 진행했는지 복구할 수 있다. + +--- + +### 📋 정리 모드 + +"정리해줘", "요약해줘", "면접 자료로 만들어줘" 등을 말하면 진입. + +1. `.interview-state/session.md`에서 전체 대화 진행 상태를 읽는다 +2. `references/summary-format.md`를 참조하여 면접 노트를 생성한다 +3. `.interview-state/notes/[주제]-[날짜].md`에 저장한다 +4. 사용자에게 보여주고 피드백을 받는다 +5. "노션에 저장해줘"를 요청하면 → notion-publisher 서브에이전트 호출 +6. "코드 개선해줘"를 요청하면 → refactor-executor 서브에이전트 호출 +7. "다른 주제 볼까?"로 면접 모드 복귀 가능 + +--- + +## 주제별 진입점 × 렌즈 매핑 + +> 시작점일 뿐. 사용자 답변에 따라 질문이 달라져야 한다. +> 리스트를 순서대로 읽는 것은 **절대 금지**. + +### 레이어드 아키텍처 / 파사드 → 📦책임, 🎭역할 +- "파사드를 도입하게 된 계기가 뭐였어?" +- 깊이 파기: 책임 겹침 → 역할 재배치 → 경계 재정의 + +### 패키지 분리 / 도메인 경계 → 🔲경계, 🤝협력 +- "패키지를 분리하는 기준이 뭐야?" +- 깊이 파기: 분리 근거 → 같이 움직이는데 왜 갈랐는지 → 의존 방향 + +### VO / 값 객체 → 📦책임, 🔲경계 +- "이 VO를 만들게 된 이유가 뭐야?" +- 깊이 파기: 책임 캡슐화 → VO가 알아도 되는 범위 → 매핑 비용 + +### 기술 선택 (Kafka, Redis, QueryDSL) → 🎭역할, 🤝협력 +- "이 기능에 [기술]을 도입한 이유가 뭐야?" +- 깊이 파기: 역할 → 대체 가능성 → 장애 시 협력 관계 + +### 트랜잭션 / 동시성 → 🔲경계, 🤝협력 +- "이 작업의 트랜잭션 범위를 어디까지 잡았어?" +- 깊이 파기: 경계 → 부분 실패 시 협력 → 보상 전략 + +### 조회 성능 / 쿼리 → 📦책임, 🎭역할 +- "이 화면에서 쿼리가 몇 방 나가?" +- 깊이 파기: 조회 책임 → CQRS 필요성 → 캐시 역할 + +--- + +## 금지 사항 + +1. **답을 먼저 말하지 마라**: 사용자가 생각할 기회를 뺏지 않는다 +2. **교과서를 읽지 마라**: "파사드 패턴이란..." 같은 정의 나열 금지 +3. **한꺼번에 폭격하지 마라**: 질문은 한 턴에 최대 2개 +4. **사용자를 무시하지 마라**: 답변을 듣고 그에 기반한 후속 질문을 해야 한다 +5. **일반론으로 도망가지 마라**: design-map을 활용해 실제 코드 맥락에서 질문 +6. **포기를 허용하지 마라**: "모르겠다"면 힌트를 주고 다시 시도하게 한다 +7. **비꼬지 마라**: 틀려도 "그렇게 생각할 수 있지, 근데..." 톤을 유지한다 +8. **정리 모드 전까지 요약하지 마라**: 면접 모드에서는 계속 질문만 한다 diff --git a/.claude/agents/interview-partner/agents/code-scanner.md b/.claude/agents/interview-partner/agents/code-scanner.md new file mode 100644 index 000000000..07adf2730 --- /dev/null +++ b/.claude/agents/interview-partner/agents/code-scanner.md @@ -0,0 +1,137 @@ +# Code Scanner Agent + +프로젝트 코드를 스캔하여 설계 맵(design-map)을 생성한다. +면접 모드에서 프로젝트 맥락 기반 질문을 할 수 있도록 사전 분석을 수행한다. + +## 필수 참조 문서 + +코드 스캔 전에 아래 문서들을 **반드시 먼저 Read**하여 프로젝트의 설계 의도와 컨벤션을 이해한다. + +### 설계 문서 (`docs/design/`) +- `docs/design/_shared/OVERVIEW.md` — 전체 ERD + 클래스 다이어그램 +- `docs/design/_shared/CONVENTIONS.md` — 참조 방식, Soft Delete, 용어집 +- `docs/design/brand/DESIGN.md` — 브랜드 도메인 요구사항 + 유즈케이스 + ERD +- `docs/design/product/DESIGN.md` — 상품 도메인 +- `docs/design/like/DESIGN.md` — 좋아요 도메인 +- `docs/design/cart/DESIGN.md` — 장바구니 도메인 +- `docs/design/order/DESIGN.md` — 주문 도메인 +- `docs/design/도메인_관계_설계_의사결정_기록_v3.md` — 도메인 간 관계 설계 의사결정 기록 + +### 프로젝트 컨벤션 (`.claude/skills/project-convention/`) +- `references/common/package-convention.md` — 패키지 구조 규칙 +- `references/common/dto-convention.md` — DTO 설계 규칙 +- `references/common/exception-convention.md` — 예외 처리 전략 +- `references/domain/entity-vo-convention.md` — Entity/VO 설계 규칙 +- `references/application/service-layer-convention.md` — 서비스 레이어 규칙 +- `references/infrastructure/infrastructure-convention.md` — 인프라 레이어 규칙 +- `references/interfaces/api-convention.md` — API 설계 규칙 + +이 문서들의 내용은 design-map의 **"설계 포인트"** 추출에 활용한다. +코드가 컨벤션과 설계 문서에 기술된 의도대로 구현되었는지도 사실 기반으로 기록한다. + +## 입력 + +- 프로젝트 루트 경로 (기본: 현재 작업 디렉토리) + +## 출력 + +- `.interview-state/design-map.md` + +## 프로세스 + +### Step 1: 프로젝트 구조 파악 + +1. 프로젝트 루트에서 디렉토리 트리를 생성한다 (3레벨 깊이) +2. `apps/`, `modules/`, `supports/` 구조를 파악한다 +3. `build.gradle` 또는 `pom.xml`에서 의존성과 모듈 관계를 확인한다 + +### Step 2: 레이어별 클래스 분류 + +각 도메인 모듈에서 아래 레이어별 클래스를 식별한다: + +``` +Interface 레이어: + - *Controller.java → API 엔드포인트 목록 + - *Request.java, *Response.java → DTO 목록 + +Application 레이어: + - *Facade.java → 파사드 목록 + 의존하는 서비스 목록 + - *UseCase.java → 유스케이스 목록 (있다면) + +Domain 레이어: + - *Service.java → 도메인 서비스 목록 + 의존 관계 + - 엔티티 클래스 → 필드, 연관관계 (@OneToMany 등) + - *VO.java, @Embeddable → 값 객체 목록 + - *Repository.java (인터페이스) → 리포지토리 목록 + +Infrastructure 레이어: + - *RepositoryImpl.java → 구현체 (QueryDSL 사용 여부) + - *Producer.java, *Consumer.java → Kafka 사용처 + - Redis 관련 클래스 → 캐시/락 사용처 +``` + +### Step 3: 도메인 간 참조 관계 분석 + +1. 각 도메인 패키지의 import문을 분석한다 +2. 도메인 A → 도메인 B 참조 방향을 파악한다 +3. ID 참조 vs 객체 참조를 구분한다 +4. 순환 참조가 있는지 확인한다 + +### Step 4: 설계 포인트 추출 + +코드에서 면접 질문 소재가 될 수 있는 포인트를 추출한다: + +- 파사드가 여러 서비스를 조합하는 지점 +- @Transactional 범위와 전파 속성 +- 도메인 간 경계를 넘는 호출 +- VO로 감싼 원시값 목록 +- 복잡한 쿼리 (QueryDSL 사용처) +- 이벤트 발행/구독 지점 +- 예외 처리 전략 + +### Step 5: design-map.md 생성 + +아래 포맷으로 `.interview-state/design-map.md`에 저장한다: + +```markdown +# Design Map + +생성일: [날짜] +프로젝트 루트: [경로] + +## 모듈 구조 +[apps/, modules/, supports/ 트리] + +## 도메인 목록 +| 도메인 | 패키지 경로 | 주요 엔티티 | 파사드 | 서비스 | +|--------|-----------|------------|--------|--------| +| ... | ... | ... | ... | ... | + +## 레이어별 클래스 맵 + +### [도메인명] +- Interface: [컨트롤러, DTO] +- Application: [파사드] → 의존: [서비스 목록] +- Domain: [서비스, 엔티티, VO] +- Infrastructure: [구현체, 외부 연동] + +## 도메인 간 참조 관계 +[도메인A] → [도메인B]: [참조 방식 (ID/객체)] / [어떤 클래스에서] +... + +## 설계 포인트 (면접 소재) +1. [포인트]: [위치] - [간단 설명] +2. ... + +## 기술 스택 사용 맵 +- Kafka: [Producer/Consumer 위치] +- Redis: [사용처와 용도] +- QueryDSL: [사용하는 Repository] +``` + +## 주의사항 + +- 코드의 좋고 나쁨을 판단하지 않는다 (그건 면접 모드에서 할 일) +- 가능한 한 객관적 사실만 기록한다 +- 너무 세부적인 코드까지 기록하지 않는다 (클래스/메서드 수준까지만) +- 분석 중 발견한 패턴이나 특이사항은 "설계 포인트"에 기록한다 diff --git a/.claude/agents/interview-partner/agents/notion-publisher.md b/.claude/agents/interview-partner/agents/notion-publisher.md new file mode 100644 index 000000000..6fd996a6d --- /dev/null +++ b/.claude/agents/interview-partner/agents/notion-publisher.md @@ -0,0 +1,94 @@ +# Notion Publisher Agent + +면접 노트를 노션 MCP를 통해 저장/업데이트한다. + +## 입력 + +- 면접 노트 마크다운 (`.interview-state/notes/[주제]-[날짜].md`) +- 노션 DB 구조 (`references/notion-schema.md` 참조) + +## 출력 + +- 노션 페이지 생성 또는 업데이트 +- 사용자에게 노션 페이지 URL 반환 + +## 프로세스 + +### Step 1: 노션 데이터베이스 확인 + +1. `references/notion-schema.md`에 정의된 DB 이름으로 검색한다 +2. DB가 없으면 새로 생성한다 (스키마는 notion-schema.md 참조) +3. DB가 있으면 기존 페이지 중 같은 주제가 있는지 확인한다 + +### Step 2: 면접 노트 파싱 + +면접 노트에서 아래 속성을 추출한다: + +``` +- 제목: "내가 [이걸] [이렇게] 한 근거" 패턴 +- 주제 태그: 파사드, 패키지분리, VO, 트랜잭션 등 +- 렌즈 태그: 경계, 책임, 역할, 협력 중 해당하는 것 +- 상태: 강점 / 빈틈있음 / 추가학습필요 +- 날짜: 면접 세션 날짜 +``` + +### Step 3: 노션 페이지 생성 또는 업데이트 + +**새 페이지 생성 시:** + +노션 MCP의 create-pages를 사용한다: + +``` +parent: {data_source_id: "[DB의 data_source_id]"} +properties: + 제목: "내가 [주제]를 [결정]한 근거" + 주제: [추출된 태그] + 렌즈: [경계/책임/역할/협력] + 상태: [강점/빈틈있음/추가학습필요] + date:날짜:start: [YYYY-MM-DD] +content: [면접 노트 마크다운 내용] +``` + +**기존 페이지 업데이트 시 (같은 주제를 다시 파고든 경우):** + +노션 MCP의 update-page를 사용한다: +- 기존 내용 아래에 "---" 구분선 + 새 세션 내용을 추가 +- 상태 속성을 최신으로 업데이트 +- 빈틈이 해소됐으면 "강점"으로 변경 + +### Step 4: 결과 보고 + +- 생성/업데이트된 페이지 URL을 사용자에게 알려준다 +- "노션에서 확인해봐" 메시지와 함께 마무리 + +## 페이지 내용 구조 + +노션 페이지 본문은 아래 구조를 따른다: + +```markdown +## 설계 결정과 근거 +[면접 노트의 해당 섹션] + +## 4렌즈 점검 결과 +[경계/책임/역할/협력 각각] + +## 예상 꼬리질문 대응 +[Q&A 형식] + +## 빈틈 (추가 학습) +[체크리스트] + +## 내 강점 +[자신감 가져도 되는 부분] + +--- +### 세션 이력 +- [날짜] 첫 세션 +- [날짜] 추가 세션 (빈틈 X 해소) +``` + +## 주의사항 + +- 노션 MCP 연결이 안 되어 있으면 사용자에게 연결 방법을 안내한다 +- DB 생성 시 사용자에게 확인을 받는다 ("면접 준비 DB를 노션에 만들까?") +- 페이지 내용이 너무 길면 핵심만 노션에 올리고 전체본은 로컬 참조 diff --git a/.claude/agents/interview-partner/agents/refactor-executor.md b/.claude/agents/interview-partner/agents/refactor-executor.md new file mode 100644 index 000000000..bd2d5b55d --- /dev/null +++ b/.claude/agents/interview-partner/agents/refactor-executor.md @@ -0,0 +1,93 @@ +# Refactor Executor Agent + +면접 대화에서 도출된 개선 포인트를 실제 코드에 적용한다. + +## 입력 + +- 면접 노트의 "빈틈" 또는 "코드 개선 사항" 섹션 +- 프로젝트 루트 경로 +- `.interview-state/design-map.md` (코드 위치 참조) + +## 출력 + +- Before/After 코드 변경 +- 테스트 코드 (해당 시) +- `.interview-state/refactors/[주제]-[날짜].md` 변경 기록 + +## 프로세스 + +### Step 1: 개선 포인트 확인 + +면접 노트에서 코드 변경이 필요한 포인트를 추출한다: +- 빈틈 중 코드 수준의 개선이 가능한 것 +- 대화에서 합의된 설계 변경 사항 +- 장애 시나리오 방어 코드 추가 + +사용자에게 변경 범위를 확인받는다: +"이 부분들을 변경할 건데, 진행할까?" + +### Step 2: 기존 코드 백업 + +변경 대상 파일을 `.interview-state/refactors/backup/`에 복사한다. +롤백이 필요할 때를 대비. + +### Step 3: 리팩토링 실행 + +변경을 적용한다. 변경 시 아래 원칙을 따른다: + +- 프로젝트의 기존 컨벤션을 유지한다 +- 레이어드 아키텍처 규칙을 따른다 (Interface → Application → Domain ← Infrastructure) +- 변경 이유를 주석으로 남기지 않는다 (커밋 메시지로 대체) +- 한 번에 하나의 관심사만 변경한다 + +### Step 4: 검증 + +1. 컴파일 확인: `./gradlew compileJava` (또는 해당 빌드 명령) +2. 기존 테스트 실행: `./gradlew test` +3. 새 테스트 필요 시 작성: + - 장애 시나리오 방어 테스트 + - 동시성 테스트 (해당 시) + - 경계값 테스트 + +### Step 5: 변경 기록 생성 + +`.interview-state/refactors/[주제]-[날짜].md`에 기록: + +```markdown +# 리팩토링 기록: [주제] + +날짜: [YYYY-MM-DD] +면접 세션: [관련 면접 노트 경로] + +## 변경 사항 + +### [변경 1: 한 줄 설명] +- 파일: [경로] +- 렌즈: [경계/책임/역할/협력] +- Before: [핵심 코드] +- After: [핵심 코드] +- Why: [면접에서 도출된 이유] + +## 테스트 +- [추가된 테스트 목록] +- 기존 테스트 통과 여부: ✅ / ❌ + +## 롤백 +백업 위치: .interview-state/refactors/backup/ +``` + +### Step 6: 결과 보고 + +사용자에게 변경 요약을 보여주고: +- 변경된 파일 목록 +- Before/After 핵심 코드 +- 테스트 결과 +- "노션에도 반영할까?" 제안 + +## 주의사항 + +- 사용자 확인 없이 코드를 변경하지 않는다 +- 컴파일이 깨지면 즉시 롤백한다 +- 기존 테스트가 실패하면 원인을 분석하고 사용자에게 보고한다 +- 대규모 리팩토링은 단계별로 나눠서 진행한다 +- git이 있으면 변경 전 브랜치를 생성하는 것을 권장한다 diff --git a/.claude/agents/interview-partner/referencs/notion-schema.md b/.claude/agents/interview-partner/referencs/notion-schema.md new file mode 100644 index 000000000..2b150133d --- /dev/null +++ b/.claude/agents/interview-partner/referencs/notion-schema.md @@ -0,0 +1,109 @@ +# 노션 데이터베이스 스키마: 면접 준비 노트 + +## 데이터베이스 이름 + +`면접 깊이파기 노트` (또는 사용자가 지정한 이름) + +## 스키마 정의 (SQL DDL) + +```sql +CREATE TABLE ( + "제목" TITLE, + "주제" MULTI_SELECT( + '파사드':blue, + '패키지분리':green, + 'VO':purple, + '트랜잭션':red, + '동시성':red, + 'Kafka':orange, + 'Redis':orange, + 'QueryDSL':orange, + '레이어드아키텍처':blue, + '도메인설계':green, + '조회성능':yellow, + '예외처리':red + ), + "렌즈" MULTI_SELECT( + '🔲 경계':gray, + '📦 책임':blue, + '🎭 역할':purple, + '🤝 협력':green + ), + "상태" SELECT( + '강점':green, + '빈틈있음':yellow, + '추가학습필요':red, + '해소완료':blue + ), + "날짜" DATE, + "도메인" MULTI_SELECT( + '주문':default, + '상품':default, + '브랜드':default, + '회원':default, + '결제':default, + '배송':default, + '좋아요':default + ), + "블로그작성" CHECKBOX, + "코드개선" CHECKBOX +) +``` + +## 제목 규칙 + +**"내가 [이걸] [이렇게] 한 근거"** 패턴을 따른다. + +예시: +- "내가 파사드를 서비스 위에 둔 근거" +- "내가 브랜드와 프로덕트 패키지를 분리한 근거" +- "내가 좋아요에 Redis 대신 DB 카운터를 택한 근거" +- "내가 주문 트랜잭션 범위를 파사드에서 잡은 근거" + +## 페이지 본문 구조 + +```markdown +## 설계 결정과 근거 +- **[결정]**: [근거] → [면접에서 이렇게 말하면 됨] + +## 4렌즈 점검 결과 + +### 🔲 경계 +- 잘 지킨 부분: ... +- 흔들린 부분: ... + +### 📦 책임 +- 잘 지킨 부분: ... +- 흔들린 부분: ... + +### 🎭 역할 +- 잘 지킨 부분: ... +- 흔들린 부분: ... + +### 🤝 협력 +- 잘 지킨 부분: ... +- 흔들린 부분: ... + +## 예상 꼬리질문 대응 + +### Q: [질문] +→ 내 답변 포인트: ... +→ 추가로 물어볼 수 있는 것: ... + +## 빈틈 (추가 학습) +- [ ] [주제]: [학습 방향] + +## 내 강점 +- [강점]: 근거가 탄탄한 부분 + +--- +### 세션 이력 +- [YYYY-MM-DD] 첫 세션 +``` + +## 운영 규칙 + +- **같은 주제 반복 시**: 기존 페이지에 새 세션 내용을 추가 (누적) +- **빈틈 해소 시**: 상태를 "해소완료"로 변경, 해소 과정 기록 +- **블로그 작성 시**: "블로그작성" 체크 + 블로그 URL 본문에 추가 +- **코드 개선 시**: "코드개선" 체크 + Before/After 본문에 추가 diff --git a/.claude/agents/interview-partner/referencs/summary-format.md b/.claude/agents/interview-partner/referencs/summary-format.md new file mode 100644 index 000000000..444f75137 --- /dev/null +++ b/.claude/agents/interview-partner/referencs/summary-format.md @@ -0,0 +1,122 @@ +# 정리 모드: 면접 준비 노트 포맷 + +사용자가 "정리해줘"를 요청하면 이 포맷으로 대화 내용을 정리한다. + +--- + +## 포맷 A: 면접 대비 노트 (기본) + +```markdown +# [주제] 면접 준비 노트 + +## 내 설계 결정과 근거 +> 대화에서 확인된 내 핵심 결정들 + +- **[결정]**: [내가 말한 근거] → [면접에서 이렇게 말하면 됨] + +## 4렌즈 점검 결과 + +### 🔲 경계 (어디까지 알아도 되는가) +- 잘 지킨 부분: ... +- 흔들린 부분: ... + +### 📦 책임 (어디까지 하면 머리가 안 터지는가) +- 잘 지킨 부분: ... +- 흔들린 부분: ... + +### 🎭 역할 (쟤가 해도 될 것 같은데?) +- 잘 지킨 부분: ... +- 흔들린 부분: ... + +### 🤝 협력 (같이 해야만 하는 것) +- 잘 지킨 부분: ... +- 흔들린 부분: ... + +## 예상 꼬리질문 대응 +> 실제 대화에서 나온 질문 흐름 기반 + +### Q: [질문] +→ 내 답변 포인트: ... +→ 추가로 물어볼 수 있는 것: ... +→ 주의: [대화에서 빈틈이 드러난 부분] + +## 빈틈 (추가 학습) +> "모르겠다" 또는 답변이 약했던 부분 + +- [ ] [주제]: [왜 중요한지] / [학습 방향] + +## 내 강점 +> 명확하게 잘 답변한 부분 — 자신감 가져도 됨 + +- [강점]: 이 부분은 근거가 탄탄하다 +``` + +## 포맷 B: 블로그 포스트용 + +사용자가 "블로그로도 만들어줘"를 요청하면 추가로 이 포맷 적용. + +```markdown +# [제목: Tradeoff 중심] + +## 문제 상황 +- 우리 서비스의 맥락 +- 해결해야 할 핵심 문제 + +## 선택지 비교 +- 대안 A vs 대안 B (대화에서 나온 Tradeoff) +- 비교 기준과 판단 근거 + +## 우리의 선택과 이유 +- 왜 이 방식을 택했는가 +- 무엇을 의도적으로 포기했는가 + +## 장애 시나리오와 대응 +- 대화에서 나온 장애 상황 +- 방어 전략 + +## 회고 +- 이 결정에 대한 현재 평가 +``` + +## 포맷 C: 코드 리팩토링 정리 + +대화에서 코드 개선이 도출된 경우 추가. + +```markdown +## 코드 개선 사항 + +### [개선 포인트] +**Before**: (기존 코드 핵심부) +**After**: (개선 코드) +**Why**: (대화에서 도출된 이유) +**장애 방어**: (이 변경이 막아주는 시나리오) +``` + +--- + +## 정리 원칙 + +1. 대화에서 **실제로 나온 내용만** 정리 (억지로 채우지 않음) +2. 사용자의 **원래 표현을 최대한 살림** (면접에서 자기 말로 답변하도록) +3. 빈틈은 비난이 아니라 **학습 기회**로 프레이밍 +4. 정리 후 "더 파고 싶은 주제 있어?"로 대화 재개 유도 + +## 제목 패턴 (블로그용) + +**핵심 공식: "내가 [이걸] [이렇게] 한 근거"** + +설계 결정의 주체(나)와 의도(왜)가 제목에서 바로 드러나야 한다. +면접관이 제목만 보고 "이거 물어봐야겠다"고 생각하게 만드는 제목이 좋다. + +좋은 제목: +- "내가 파사드를 서비스 위에 둔 근거" +- "내가 브랜드와 프로덕트 패키지를 분리한 근거" +- "내가 좋아요에 Redis 대신 DB 카운터를 택한 근거" +- "내가 VO로 원시값을 감싼 근거" +- "내가 주문 트랜잭션 범위를 파사드에서 잡은 근거" +- "내가 도메인 간 참조를 ID로만 제한한 근거" + +피할 제목: +- "파사드 패턴 정리" (주체 없음, 학습 노트) +- "Spring 레이어드 아키텍처란" (교과서) +- "Redis vs DB 비교" (결정이 안 보임) diff --git a/.claude/agents/interview-partner/scripts/session-state.md b/.claude/agents/interview-partner/scripts/session-state.md new file mode 100644 index 000000000..af9f861ba --- /dev/null +++ b/.claude/agents/interview-partner/scripts/session-state.md @@ -0,0 +1,109 @@ +# 세션 상태 추적 포맷 + +면접 대화가 길어질 때 컨텍스트를 유지하기 위한 체크포인트 파일. +`.interview-state/session.md`에 저장한다. + +## 저장 타이밍 + +- 면접 세션 시작 시 (초기화) +- 5~6턴마다 자동 업데이트 +- 주제 전환 시 +- 정리 모드 진입 시 + +## 포맷 + +```markdown +# Interview Session State + +세션 시작: [YYYY-MM-DD HH:MM] +마지막 업데이트: [YYYY-MM-DD HH:MM] +현재 모드: 🎤 면접 / 📋 정리 + +## 현재 진행 상황 + +현재 주제: [예: 파사드와 서비스의 책임 경계] +현재 렌즈: [예: 📦 책임] +현재 깊이: [예: Level 3] +대화 턴 수: [예: 12] + +## 커버한 주제 + +### ✅ [주제 1: 파사드 도입 근거] +- 렌즈: 📦 책임, 🎭 역할 +- 깊이: Level 4까지 진행 +- 결과: 근거 명확 (강점) +- 핵심 답변: "컨트롤러의 오케스트레이션 책임을 분리하기 위해..." + +### ✅ [주제 2: 브랜드-프로덕트 패키지 분리] +- 렌즈: 🔲 경계, 🤝 협력 +- 깊이: Level 3까지 진행 +- 결과: 빈틈 발견 (브랜드 없는 프로덕트 케이스 미고려) +- 핵심 답변: "변경 단위가 다르기 때문에..." +- 빈틈: "항상 같이 조회되는데 분리한 비용에 대한 근거 약함" + +### 🔄 [주제 3: 현재 진행 중] +- ... + +## 빈틈 목록 (누적) + +1. [빈틈]: [어떤 질문에서 드러났는지] / [추가 학습 방향] +2. ... + +## 강점 목록 (누적) + +1. [강점]: [어떤 답변이 좋았는지] +2. ... + +## 다음 질문 후보 + +주제 전환이 필요할 때 참조: +- [ ] [아직 안 다룬 주제 1] +- [ ] [아직 안 다룬 주제 2] +``` + +## 디렉토리 구조 + +``` +.interview-state/ +├── design-map.md ← code-scanner 산출물 +├── session.md ← 현재 세션 상태 (이 파일) +├── notes/ +│ ├── 파사드-2025-02-25.md ← 정리된 면접 노트 +│ └── 패키지분리-2025-02-26.md +└── refactors/ + ├── backup/ ← 리팩토링 전 백업 + └── 파사드-2025-02-25.md ← 리팩토링 기록 +``` + +## 초기화 (세션 시작 시) + +```markdown +# Interview Session State + +세션 시작: [now] +마지막 업데이트: [now] +현재 모드: 🎤 면접 + +## 현재 진행 상황 + +현재 주제: [사용자가 꺼낸 첫 주제] +현재 렌즈: [아직 미정] +현재 깊이: Level 1 +대화 턴 수: 1 + +## 커버한 주제 + +(없음) + +## 빈틈 목록 (누적) + +(없음) + +## 강점 목록 (누적) + +(없음) + +## 다음 질문 후보 + +[design-map.md의 설계 포인트에서 추출] +``` From 3520e57533e0f42b7a68fbb2f3960834ff1a29bb Mon Sep 17 00:00:00 2001 From: plan11plan Date: Wed, 25 Feb 2026 22:27:40 +0900 Subject: [PATCH 086/108] =?UTF-8?q?refactor:=20Product=20=E2=86=92=20Brand?= =?UTF-8?q?=20=EC=B0=B8=EC=A1=B0=EB=A5=BC=20=EA=B0=9D=EC=B2=B4=EC=B0=B8?= =?UTF-8?q?=EC=A1=B0=EC=97=90=EC=84=9C=20ID=20=EC=B0=B8=EC=A1=B0=EB=A1=9C?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../application/order/OrderFacade.java | 23 ++++++--- .../application/product/ProductFacade.java | 47 +++++++++++++++---- .../product/dto/ProductResult.java | 6 +-- .../loopers/domain/brand/BrandRepository.java | 3 ++ .../loopers/domain/brand/BrandService.java | 11 +++++ .../loopers/domain/product/ProductModel.java | 26 ++++------ .../domain/product/ProductRepository.java | 4 -- .../domain/product/ProductService.java | 14 +----- .../brand/BrandJpaRepository.java | 3 ++ .../brand/BrandRepositoryImpl.java | 6 +++ .../product/ProductJpaRepository.java | 4 -- .../product/ProductRepositoryImpl.java | 10 ---- 12 files changed, 94 insertions(+), 63 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java index 4c14627f1..ccb191a1b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -2,6 +2,8 @@ import com.loopers.application.order.dto.OrderCriteria; import com.loopers.application.order.dto.OrderResult; +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandService; import com.loopers.domain.order.OrderService; import com.loopers.domain.order.dto.OrderCommand; import com.loopers.domain.product.ProductModel; @@ -21,15 +23,24 @@ public class OrderFacade { private final ProductService productService; + private final BrandService brandService; private final OrderService orderService; @Transactional public OrderResult.OrderSummary createOrder(Long userId, OrderCriteria.Create criteria) { - Map productMap = - productService.getAllByIds(criteria.items().stream() - .map(OrderCriteria.Create.CreateItem::productId) - .toList()).stream() - .collect(Collectors.toMap(ProductModel::getId, Function.identity())); + List products = productService.getAllByIds(criteria.items().stream() + .map(OrderCriteria.Create.CreateItem::productId) + .toList()); + + Map productMap = products.stream() + .collect(Collectors.toMap(ProductModel::getId, Function.identity())); + + List brandIds = products.stream() + .map(ProductModel::getBrandId) + .distinct() + .toList(); + Map brandNameMap = brandService.getAllByIds(brandIds).stream() + .collect(Collectors.toMap(BrandModel::getId, BrandModel::getName)); return OrderResult.OrderSummary.from( orderService.createOrder( @@ -43,7 +54,7 @@ public OrderResult.OrderSummary createOrder(Long userId, OrderCriteria.Create cr product.getPrice(), item.quantity(), product.getName(), - product.getBrand().getName()); + brandNameMap.get(product.getBrandId())); }) .toList()))); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index 19fab2c36..b189f23d4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -6,6 +6,9 @@ import com.loopers.domain.brand.BrandService; import com.loopers.domain.product.ProductModel; import com.loopers.domain.product.ProductService; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -21,14 +24,15 @@ public class ProductFacade { @Transactional public void registerProduct(ProductCriteria.Register criteria) { - BrandModel brand = brandService.getById(criteria.brandId()); - productService.register(brand, criteria.name(), criteria.price(), criteria.stock()); + brandService.validateExists(criteria.brandId()); + productService.register(criteria.brandId(), criteria.name(), criteria.price(), criteria.stock()); } @Transactional(readOnly = true) public ProductResult getProduct(Long id) { - ProductModel productModel = productService.getById(id); - return ProductResult.from(productModel); + ProductModel product = productService.getById(id); + BrandModel brand = brandService.getById(product.getBrandId()); + return ProductResult.of(product, brand.getName()); } @Transactional @@ -43,21 +47,48 @@ public void deleteProduct(Long id) { @Transactional(readOnly = true) public Page getProducts(Pageable pageable) { - return productService.getAll(pageable).map(ProductResult::from); + Page products = productService.getAll(pageable); + Map brandNameMap = getBrandNameMap(products.getContent()); + return products.map(product -> ProductResult.of(product, brandNameMap.get(product.getBrandId()))); } @Transactional(readOnly = true) public Page getProductsByBrandId(Long brandId, Pageable pageable) { - return productService.getAllByBrandId(brandId, pageable).map(ProductResult::from); + BrandModel brand = brandService.getById(brandId); + return productService.getAllByBrandId(brandId, pageable) + .map(product -> ProductResult.of(product, brand.getName())); } @Transactional(readOnly = true) public Page getProductsWithActiveBrand(Pageable pageable) { - return productService.getAllWithActiveBrand(pageable).map(ProductResult::from); + Page products = productService.getAll(pageable); + Map brandNameMap = getActiveBrandNameMap(products.getContent()); + return products.map(product -> ProductResult.of(product, brandNameMap.get(product.getBrandId()))); } @Transactional(readOnly = true) public Page getProductsWithActiveBrandByBrandId(Long brandId, Pageable pageable) { - return productService.getAllWithActiveBrandByBrandId(brandId, pageable).map(ProductResult::from); + BrandModel brand = brandService.getById(brandId); + return productService.getAllByBrandId(brandId, pageable) + .map(product -> ProductResult.of(product, brand.getName())); + } + + private Map getBrandNameMap(List products) { + List brandIds = products.stream() + .map(ProductModel::getBrandId) + .distinct() + .toList(); + return brandService.getAllByIds(brandIds).stream() + .collect(Collectors.toMap(BrandModel::getId, BrandModel::getName)); + } + + private Map getActiveBrandNameMap(List products) { + List brandIds = products.stream() + .map(ProductModel::getBrandId) + .distinct() + .toList(); + return brandService.getAllByIds(brandIds).stream() + .filter(brand -> brand.getDeletedAt() == null) + .collect(Collectors.toMap(BrandModel::getId, BrandModel::getName)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/dto/ProductResult.java b/apps/commerce-api/src/main/java/com/loopers/application/product/dto/ProductResult.java index 10e709f35..068bab4bf 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/dto/ProductResult.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/dto/ProductResult.java @@ -14,11 +14,11 @@ public record ProductResult( ZonedDateTime updatedAt, ZonedDateTime deletedAt ) { - public static ProductResult from(ProductModel model) { + public static ProductResult of(ProductModel model, String brandName) { return new ProductResult( model.getId(), - model.getBrand().getId(), - model.getBrand().getName(), + model.getBrandId(), + brandName, model.getName(), model.getPrice(), model.getStock(), 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 dc45afec1..a0bf210af 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; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -12,4 +13,6 @@ public interface BrandRepository { Optional findByName(String name); Page findAll(Pageable pageable); + + List findAllByIdIn(List ids); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java index 12b512cac..593c166dc 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java @@ -1,6 +1,7 @@ package com.loopers.domain.brand; import com.loopers.support.error.CoreException; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -50,4 +51,14 @@ public void delete(Long id) { public Page getAll(Pageable pageable) { return brandRepository.findAll(pageable); } + + @Transactional(readOnly = true) + public List getAllByIds(List ids) { + return brandRepository.findAllByIdIn(ids); + } + + @Transactional(readOnly = true) + public void validateExists(Long id) { + getById(id); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java index ddcfd59fb..ef7323aad 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java @@ -1,15 +1,10 @@ package com.loopers.domain.product; import com.loopers.domain.BaseEntity; -import com.loopers.domain.brand.BrandModel; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import jakarta.persistence.Column; import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.ForeignKey; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.Getter; @@ -21,9 +16,8 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class ProductModel extends BaseEntity { - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "brand_id", nullable = false, foreignKey = @ForeignKey(value = jakarta.persistence.ConstraintMode.NO_CONSTRAINT)) - private BrandModel brand; + @Column(name = "brand_id", nullable = false) + private Long brandId; @Column(name = "name", nullable = false) private String name; @@ -36,19 +30,19 @@ public class ProductModel extends BaseEntity { // === 생성 === // - private ProductModel(BrandModel brand, String name, int price, int stock) { - this.brand = brand; + private ProductModel(Long brandId, String name, int price, int stock) { + this.brandId = brandId; this.name = name; this.price = price; this.stock = stock; } - public static ProductModel create(BrandModel brand, String name, int price, int stock) { - validateBrand(brand); + public static ProductModel create(Long brandId, String name, int price, int stock) { + validateBrandId(brandId); validateName(name); validatePriceRange(price); validateStockRange(stock); - return new ProductModel(brand, name, price, stock); + return new ProductModel(brandId, name, price, stock); } // === 도메인 로직 === // @@ -84,9 +78,9 @@ public boolean isSoldOut() { // === 검증 === // - private static void validateBrand(BrandModel brand) { - if (brand == null) { - throw new CoreException(ErrorType.BAD_REQUEST, "브랜드는 필수값입니다."); + private static void validateBrandId(Long brandId) { + if (brandId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 ID는 필수값입니다."); } } 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 e05a25c66..4a3296af9 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 @@ -17,8 +17,4 @@ public interface ProductRepository { List findAllByBrandId(Long brandId); List findAllByIdIn(List ids); - - Page findAllWithActiveBrand(Pageable pageable); - - Page findAllWithActiveBrandByBrandId(Long brandId, Pageable pageable); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java index dde3711b9..afa25dfe7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -1,6 +1,5 @@ package com.loopers.domain.product; -import com.loopers.domain.brand.BrandModel; import com.loopers.support.error.CoreException; import java.util.List; import lombok.RequiredArgsConstructor; @@ -15,8 +14,8 @@ public class ProductService { private final ProductRepository productRepository; @Transactional - public void register(BrandModel brand, String name, int price, int stock) { - productRepository.save(ProductModel.create(brand, name, price, stock)); + public void register(Long brandId, String name, int price, int stock) { + productRepository.save(ProductModel.create(brandId, name, price, stock)); } @Transactional(readOnly = true) @@ -74,13 +73,4 @@ public void validateExists(Long id) { } } - @Transactional(readOnly = true) - public Page getAllWithActiveBrand(Pageable pageable) { - return productRepository.findAllWithActiveBrand(pageable); - } - - @Transactional(readOnly = true) - public Page getAllWithActiveBrandByBrandId(Long brandId, Pageable pageable) { - return productRepository.findAllWithActiveBrandByBrandId(brandId, pageable); - } } 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 index 8a89e1b07..bd54955e6 100644 --- 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 @@ -1,6 +1,7 @@ package com.loopers.infrastructure.brand; import com.loopers.domain.brand.BrandModel; +import java.util.List; import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -12,4 +13,6 @@ public interface BrandJpaRepository extends JpaRepository { Optional findByNameAndDeletedAtIsNull(String name); Page findAllByDeletedAtIsNull(Pageable pageable); + + List findAllByIdInAndDeletedAtIsNull(List ids); } 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 6f20c295e..b86a8abd2 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 @@ -2,6 +2,7 @@ import com.loopers.domain.brand.BrandModel; import com.loopers.domain.brand.BrandRepository; +import java.util.List; import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -32,4 +33,9 @@ public Optional findByName(String name) { public Page findAll(Pageable pageable) { return brandJpaRepository.findAllByDeletedAtIsNull(pageable); } + + @Override + public List findAllByIdIn(List ids) { + return brandJpaRepository.findAllByIdInAndDeletedAtIsNull(ids); + } } 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 820dd69b5..5aef70885 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 @@ -17,8 +17,4 @@ public interface ProductJpaRepository extends JpaRepository List findAllByBrandIdAndDeletedAtIsNull(Long brandId); List findAllByIdInAndDeletedAtIsNull(List ids); - - Page findAllByDeletedAtIsNullAndBrandDeletedAtIsNull(Pageable pageable); - - Page findAllByBrandIdAndDeletedAtIsNullAndBrandDeletedAtIsNull(Long brandId, Pageable pageable); } 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 4f61bdfd2..c5adb8178 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,14 +43,4 @@ public List findAllByBrandId(Long brandId) { public List findAllByIdIn(List ids) { return productJpaRepository.findAllByIdInAndDeletedAtIsNull(ids); } - - @Override - public Page findAllWithActiveBrand(Pageable pageable) { - return productJpaRepository.findAllByDeletedAtIsNullAndBrandDeletedAtIsNull(pageable); - } - - @Override - public Page findAllWithActiveBrandByBrandId(Long brandId, Pageable pageable) { - return productJpaRepository.findAllByBrandIdAndDeletedAtIsNullAndBrandDeletedAtIsNull(brandId, pageable); - } } From d6c753bbf9c96dbf7fc6406e3cbb23c622da2123 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Wed, 25 Feb 2026 22:46:32 +0900 Subject: [PATCH 087/108] =?UTF-8?q?test:=20Product=20=E2=86=92=20Brand=20I?= =?UTF-8?q?D=20=EC=B0=B8=EC=A1=B0=20=EB=B3=80=EA=B2=BD=EC=97=90=20?= =?UTF-8?q?=EB=94=B0=EB=A5=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../application/order/OrderFacadeTest.java | 39 +++++++++++--- .../domain/brand/FakeBrandRepository.java | 8 +++ .../domain/product/FakeProductRepository.java | 38 +------------- .../domain/product/ProductModelTest.java | 39 +++++++------- .../domain/product/ProductServiceTest.java | 51 +++++++------------ .../interfaces/like/LikeV1ApiE2ETest.java | 2 +- .../product/ProductV1ApiE2ETest.java | 4 +- 7 files changed, 81 insertions(+), 100 deletions(-) diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java index d678fe8ff..9d1fc7661 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java @@ -10,6 +10,7 @@ import com.loopers.application.order.dto.OrderCriteria; import com.loopers.application.order.dto.OrderResult; import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandService; import com.loopers.domain.order.OrderErrorCode; import com.loopers.domain.order.OrderItemModel; import com.loopers.domain.order.OrderModel; @@ -40,14 +41,17 @@ class OrderFacadeTest { @Mock private ProductService productService; + @Mock + private BrandService brandService; + @Mock private OrderService orderService; @InjectMocks private OrderFacade orderFacade; - private ProductModel createProductWithId(BrandModel brand, String name, int price, int stock, Long id) { - ProductModel product = ProductModel.create(brand, name, price, stock); + private ProductModel createProductWithId(Long brandId, String name, int price, int stock, Long id) { + ProductModel product = ProductModel.create(brandId, name, price, stock); try { var idField = product.getClass().getSuperclass().getDeclaredField("id"); idField.setAccessible(true); @@ -58,6 +62,18 @@ private ProductModel createProductWithId(BrandModel brand, String name, int pric return product; } + private BrandModel createBrandWithId(String name, Long id) { + BrandModel brand = BrandModel.create(name); + try { + var idField = brand.getClass().getSuperclass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(brand, id); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException(e); + } + return brand; + } + @DisplayName("주문 생성 (UC-O01)") @Nested class CreateOrder { @@ -66,10 +82,12 @@ class CreateOrder { @Test void createOrder_success() { // arrange - BrandModel brand = BrandModel.create("브랜드A"); - ProductModel product = createProductWithId(brand, "상품A", 25000, 100, 10L); + Long brandId = 1L; + ProductModel product = createProductWithId(brandId, "상품A", 25000, 100, 10L); + BrandModel brand = createBrandWithId("브랜드A", brandId); when(productService.getAllByIds(List.of(10L))).thenReturn(List.of(product)); + when(brandService.getAllByIds(List.of(brandId))).thenReturn(List.of(brand)); OrderModel order = OrderModel.create(1L, 25000); when(orderService.createOrder(any(OrderCommand.Create.class))).thenReturn(order); @@ -84,6 +102,7 @@ void createOrder_success() { // assert assertAll( () -> verify(productService).getAllByIds(List.of(10L)), + () -> verify(brandService).getAllByIds(List.of(brandId)), () -> verify(orderService).createOrder(any(OrderCommand.Create.class)), () -> assertThat(result.totalPrice()).isEqualTo(25000) ); @@ -124,10 +143,12 @@ void createOrder_productNotFound_throwsException() { @Test void createOrder_priceMismatch_throwsException() { // arrange - BrandModel brand = BrandModel.create("브랜드A"); - ProductModel product = createProductWithId(brand, "상품A", 25000, 100, 10L); + Long brandId = 1L; + ProductModel product = createProductWithId(brandId, "상품A", 25000, 100, 10L); + BrandModel brand = createBrandWithId("브랜드A", brandId); when(productService.getAllByIds(List.of(10L))).thenReturn(List.of(product)); + when(brandService.getAllByIds(List.of(brandId))).thenReturn(List.of(brand)); OrderCriteria.Create criteria = new OrderCriteria.Create(List.of( new OrderCriteria.Create.CreateItem(10L, 1, 30000) @@ -142,10 +163,12 @@ void createOrder_priceMismatch_throwsException() { @Test void createOrder_insufficientStock_throwsException() { // arrange - BrandModel brand = BrandModel.create("브랜드A"); - ProductModel product = createProductWithId(brand, "상품A", 25000, 1, 10L); + Long brandId = 1L; + ProductModel product = createProductWithId(brandId, "상품A", 25000, 1, 10L); + BrandModel brand = createBrandWithId("브랜드A", brandId); when(productService.getAllByIds(List.of(10L))).thenReturn(List.of(product)); + when(brandService.getAllByIds(List.of(brandId))).thenReturn(List.of(brand)); OrderCriteria.Create criteria = new OrderCriteria.Create(List.of( new OrderCriteria.Create.CreateItem(10L, 100, 25000) diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/FakeBrandRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/FakeBrandRepository.java index 715386df7..6cf68cd6a 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/brand/FakeBrandRepository.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/FakeBrandRepository.java @@ -59,4 +59,12 @@ public Page findAll(Pageable pageable) { return new PageImpl<>(pageContent, pageable, activeModels.size()); } + + @Override + public List findAllByIdIn(List ids) { + return store.values().stream() + .filter(brand -> brand.getDeletedAt() == null) + .filter(brand -> ids.contains(brand.getId())) + .toList(); + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/FakeProductRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/FakeProductRepository.java index e0fbcd0f6..fc12edee0 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/FakeProductRepository.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/FakeProductRepository.java @@ -56,7 +56,7 @@ public Page findAll(Pageable pageable) { public Page findAllByBrandId(Long brandId, Pageable pageable) { List filtered = store.values().stream() .filter(product -> product.getDeletedAt() == null) - .filter(product -> product.getBrand().getId().equals(brandId)) + .filter(product -> product.getBrandId().equals(brandId)) .toList(); int start = (int) pageable.getOffset(); @@ -73,7 +73,7 @@ public Page findAllByBrandId(Long brandId, Pageable pageable) { public List findAllByBrandId(Long brandId) { return store.values().stream() .filter(product -> product.getDeletedAt() == null) - .filter(product -> product.getBrand().getId().equals(brandId)) + .filter(product -> product.getBrandId().equals(brandId)) .toList(); } @@ -85,38 +85,4 @@ public List findAllByIdIn(List ids) { .toList(); } - @Override - public Page findAllWithActiveBrand(Pageable pageable) { - List activeModels = store.values().stream() - .filter(product -> product.getDeletedAt() == null) - .filter(product -> product.getBrand().getDeletedAt() == null) - .toList(); - - int start = (int) pageable.getOffset(); - int end = Math.min(start + pageable.getPageSize(), activeModels.size()); - - List pageContent = start >= activeModels.size() - ? new ArrayList<>() - : activeModels.subList(start, end); - - return new PageImpl<>(pageContent, pageable, activeModels.size()); - } - - @Override - public Page findAllWithActiveBrandByBrandId(Long brandId, Pageable pageable) { - List filtered = store.values().stream() - .filter(product -> product.getDeletedAt() == null) - .filter(product -> product.getBrand().getDeletedAt() == null) - .filter(product -> product.getBrand().getId().equals(brandId)) - .toList(); - - int start = (int) pageable.getOffset(); - int end = Math.min(start + pageable.getPageSize(), filtered.size()); - - List pageContent = start >= filtered.size() - ? new ArrayList<>() - : filtered.subList(start, end); - - return new PageImpl<>(pageContent, pageable, filtered.size()); - } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java index dca2dca5d..0171b3b97 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java @@ -3,7 +3,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import com.loopers.domain.brand.BrandModel; import com.loopers.support.error.CoreException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -11,9 +10,7 @@ class ProductModelTest { - private BrandModel createBrand() { - return BrandModel.create("Nike"); - } + private static final Long BRAND_ID = 1L; @DisplayName("상품을 생성할 때, ") @Nested @@ -23,27 +20,27 @@ class Create { @Test void create_whenValidValues() { // act - ProductModel product = ProductModel.create(createBrand(), "에어맥스", 150000, 100); + ProductModel product = ProductModel.create(BRAND_ID, "에어맥스", 150000, 100); // assert + assertThat(product.getBrandId()).isEqualTo(BRAND_ID); assertThat(product.getName()).isEqualTo("에어맥스"); assertThat(product.getPrice()).isEqualTo(150000); assertThat(product.getStock()).isEqualTo(100); - assertThat(product.getBrand().getName()).isEqualTo("Nike"); } - @DisplayName("브랜드가 null이면 예외가 발생한다.") + @DisplayName("브랜드 ID가 null이면 예외가 발생한다.") @Test - void create_whenBrandIsNull() { + void create_whenBrandIdIsNull() { assertThatThrownBy(() -> ProductModel.create(null, "에어맥스", 150000, 100)) .isInstanceOf(CoreException.class) - .hasMessageContaining("브랜드는 필수값입니다."); + .hasMessageContaining("브랜드 ID는 필수값입니다."); } @DisplayName("상품명이 null이면 예외가 발생한다.") @Test void create_whenNameIsNull() { - assertThatThrownBy(() -> ProductModel.create(createBrand(), null, 150000, 100)) + assertThatThrownBy(() -> ProductModel.create(BRAND_ID, null, 150000, 100)) .isInstanceOf(CoreException.class) .hasMessageContaining("상품명은 필수값입니다."); } @@ -51,7 +48,7 @@ void create_whenNameIsNull() { @DisplayName("상품명이 빈 문자열이면 예외가 발생한다.") @Test void create_whenNameIsBlank() { - assertThatThrownBy(() -> ProductModel.create(createBrand(), " ", 150000, 100)) + assertThatThrownBy(() -> ProductModel.create(BRAND_ID, " ", 150000, 100)) .isInstanceOf(CoreException.class) .hasMessageContaining("상품명은 필수값입니다."); } @@ -61,7 +58,7 @@ void create_whenNameIsBlank() { void create_whenNameTooLong() { String longName = "a".repeat(100); - assertThatThrownBy(() -> ProductModel.create(createBrand(), longName, 150000, 100)) + assertThatThrownBy(() -> ProductModel.create(BRAND_ID, longName, 150000, 100)) .isInstanceOf(CoreException.class) .hasMessageContaining("상품명은 99자 이하여야 합니다."); } @@ -69,7 +66,7 @@ void create_whenNameTooLong() { @DisplayName("가격이 음수이면 예외가 발생한다.") @Test void create_whenPriceIsNegative() { - assertThatThrownBy(() -> ProductModel.create(createBrand(), "에어맥스", -1, 100)) + assertThatThrownBy(() -> ProductModel.create(BRAND_ID, "에어맥스", -1, 100)) .isInstanceOf(CoreException.class) .hasMessageContaining("가격은 0 이상이어야 합니다."); } @@ -77,7 +74,7 @@ void create_whenPriceIsNegative() { @DisplayName("재고가 음수이면 예외가 발생한다.") @Test void create_whenStockIsNegative() { - assertThatThrownBy(() -> ProductModel.create(createBrand(), "에어맥스", 150000, -1)) + assertThatThrownBy(() -> ProductModel.create(BRAND_ID, "에어맥스", 150000, -1)) .isInstanceOf(CoreException.class) .hasMessageContaining("재고는 0 이상이어야 합니다."); } @@ -91,7 +88,7 @@ class Update { @Test void update_whenValidValues() { // arrange - ProductModel product = ProductModel.create(createBrand(), "에어맥스", 150000, 100); + ProductModel product = ProductModel.create(BRAND_ID, "에어맥스", 150000, 100); // act product.update("에어포스", 120000, 50); @@ -106,7 +103,7 @@ void update_whenValidValues() { @Test void update_whenNameIsNull() { // arrange - ProductModel product = ProductModel.create(createBrand(), "에어맥스", 150000, 100); + ProductModel product = ProductModel.create(BRAND_ID, "에어맥스", 150000, 100); // act & assert assertThatThrownBy(() -> product.update(null, 120000, 50)) @@ -123,7 +120,7 @@ class DecreaseStock { @Test void decreaseStock_whenEnough() { // arrange - ProductModel product = ProductModel.create(createBrand(), "에어맥스", 150000, 10); + ProductModel product = ProductModel.create(BRAND_ID, "에어맥스", 150000, 10); // act product.decreaseStock(3); @@ -136,7 +133,7 @@ void decreaseStock_whenEnough() { @Test void decreaseStock_whenInsufficient() { // arrange - ProductModel product = ProductModel.create(createBrand(), "에어맥스", 150000, 5); + ProductModel product = ProductModel.create(BRAND_ID, "에어맥스", 150000, 5); // act & assert assertThatThrownBy(() -> product.decreaseStock(6)) @@ -153,7 +150,7 @@ class IsSoldOut { @Test void isSoldOut_whenStockIsZero() { // arrange - ProductModel product = ProductModel.create(createBrand(), "에어맥스", 150000, 0); + ProductModel product = ProductModel.create(BRAND_ID, "에어맥스", 150000, 0); // act & assert assertThat(product.isSoldOut()).isTrue(); @@ -163,7 +160,7 @@ void isSoldOut_whenStockIsZero() { @Test void isSoldOut_whenStockExists() { // arrange - ProductModel product = ProductModel.create(createBrand(), "에어맥스", 150000, 1); + ProductModel product = ProductModel.create(BRAND_ID, "에어맥스", 150000, 1); // act & assert assertThat(product.isSoldOut()).isFalse(); @@ -178,7 +175,7 @@ class Delete { @Test void delete_whenCalled() { // arrange - ProductModel product = ProductModel.create(createBrand(), "에어맥스", 150000, 100); + ProductModel product = ProductModel.create(BRAND_ID, "에어맥스", 150000, 100); // act product.delete(); 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 index f4eef2ef1..c83bf8852 100644 --- 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 @@ -3,7 +3,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import com.loopers.domain.brand.BrandModel; import com.loopers.support.error.CoreException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -16,25 +15,14 @@ class ProductServiceTest { private ProductService productService; private FakeProductRepository productRepository; - private BrandModel brand; + + private static final Long BRAND_ID = 1L; + private static final Long BRAND_ID_2 = 2L; @BeforeEach void setUp() { productRepository = new FakeProductRepository(); productService = new ProductService(productRepository); - brand = createBrandWithId("Nike", 1L); - } - - private BrandModel createBrandWithId(String name, Long id) { - BrandModel brandModel = BrandModel.create(name); - try { - var idField = brandModel.getClass().getSuperclass().getDeclaredField("id"); - idField.setAccessible(true); - idField.set(brandModel, id); - } catch (NoSuchFieldException | IllegalAccessException e) { - throw new RuntimeException(e); - } - return brandModel; } @DisplayName("상품을 등록할 때, ") @@ -45,7 +33,7 @@ class Register { @Test void register_whenValidValues() { // act - productService.register(brand, "에어맥스", 150000, 100); + productService.register(BRAND_ID, "에어맥스", 150000, 100); // assert Page all = productRepository.findAll(PageRequest.of(0, 20)); @@ -62,7 +50,7 @@ class GetById { @Test void getById_whenExists() { // arrange - productService.register(brand, "에어맥스", 150000, 100); + productService.register(BRAND_ID, "에어맥스", 150000, 100); Long savedId = productRepository.findAll(PageRequest.of(0, 20)).getContent().get(0).getId(); // act @@ -84,7 +72,7 @@ void getById_whenNotExists() { @Test void getById_whenDeleted() { // arrange - productService.register(brand, "에어맥스", 150000, 100); + productService.register(BRAND_ID, "에어맥스", 150000, 100); Long savedId = productRepository.findAll(PageRequest.of(0, 20)).getContent().get(0).getId(); productService.delete(savedId); @@ -103,7 +91,7 @@ class Update { @Test void update_whenValidValues() { // arrange - productService.register(brand, "에어맥스", 150000, 100); + productService.register(BRAND_ID, "에어맥스", 150000, 100); Long savedId = productRepository.findAll(PageRequest.of(0, 20)).getContent().get(0).getId(); // act @@ -133,7 +121,7 @@ class Delete { @Test void delete_whenExists() { // arrange - productService.register(brand, "에어맥스", 150000, 100); + productService.register(BRAND_ID, "에어맥스", 150000, 100); Long savedId = productRepository.findAll(PageRequest.of(0, 20)).getContent().get(0).getId(); // act @@ -154,9 +142,9 @@ class GetAll { @Test void getAll_whenProductsExist() { // arrange - productService.register(brand, "에어맥스", 150000, 100); - productService.register(brand, "에어포스", 120000, 50); - productService.register(brand, "조던1", 200000, 30); + productService.register(BRAND_ID, "에어맥스", 150000, 100); + productService.register(BRAND_ID, "에어포스", 120000, 50); + productService.register(BRAND_ID, "조던1", 200000, 30); // act Page result = productService.getAll(PageRequest.of(0, 2)); @@ -171,8 +159,8 @@ void getAll_whenProductsExist() { @Test void getAll_excludesDeletedProducts() { // arrange - productService.register(brand, "에어맥스", 150000, 100); - productService.register(brand, "에어포스", 120000, 50); + productService.register(BRAND_ID, "에어맥스", 150000, 100); + productService.register(BRAND_ID, "에어포스", 120000, 50); Long firstId = productRepository.findAll(PageRequest.of(0, 20)).getContent().get(0).getId(); productService.delete(firstId); @@ -192,12 +180,11 @@ class GetAllByBrandId { @Test void getAllByBrandId_whenExists() { // arrange - BrandModel adidasBrand = createBrandWithId("Adidas", 2L); - productService.register(brand, "에어맥스", 150000, 100); - productService.register(adidasBrand, "울트라부스트", 180000, 80); + productService.register(BRAND_ID, "에어맥스", 150000, 100); + productService.register(BRAND_ID_2, "울트라부스트", 180000, 80); // act - Page result = productService.getAllByBrandId(1L, PageRequest.of(0, 20)); + Page result = productService.getAllByBrandId(BRAND_ID, PageRequest.of(0, 20)); // assert assertThat(result.getTotalElements()).isEqualTo(1); @@ -213,11 +200,11 @@ class DeleteAllByBrandId { @Test void deleteAllByBrandId_whenProductsExist() { // arrange - productService.register(brand, "에어맥스", 150000, 100); - productService.register(brand, "에어포스", 120000, 50); + productService.register(BRAND_ID, "에어맥스", 150000, 100); + productService.register(BRAND_ID, "에어포스", 120000, 50); // act - productService.deleteAllByBrandId(brand.getId()); + productService.deleteAllByBrandId(BRAND_ID); // assert Page result = productService.getAll(PageRequest.of(0, 20)); diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/like/LikeV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/like/LikeV1ApiE2ETest.java index 5c07ec3ba..0c941c88f 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/like/LikeV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/like/LikeV1ApiE2ETest.java @@ -73,7 +73,7 @@ void setUp() { // 브랜드 및 상품 등록 BrandModel brand = brandJpaRepository.save(BrandModel.create("나이키")); - ProductModel product = productJpaRepository.save(ProductModel.create(brand, "에어맥스", 150000, 100)); + ProductModel product = productJpaRepository.save(ProductModel.create(brand.getId(), "에어맥스", 150000, 100)); productId = product.getId(); } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/product/ProductV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/product/ProductV1ApiE2ETest.java index 5cf96f036..d3642f066 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/product/ProductV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/product/ProductV1ApiE2ETest.java @@ -61,7 +61,7 @@ void tearDown() { } private ProductModel saveProduct(String name, int price, int stock) { - ProductModel product = ProductModel.create(savedBrand, name, price, stock); + ProductModel product = ProductModel.create(savedBrand.getId(), name, price, stock); return productJpaRepository.save(product); } @@ -99,7 +99,7 @@ void returnsFilteredList_whenBrandIdIsProvided() { BrandModel adidas = brandJpaRepository.findByNameAndDeletedAtIsNull("아디다스").get(); saveProduct("에어맥스", 150000, 100); - productJpaRepository.save(ProductModel.create(adidas, "울트라부스트", 180000, 80)); + productJpaRepository.save(ProductModel.create(adidas.getId(), "울트라부스트", 180000, 80)); // act ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; From baea6194435f2cd82a056eea8c4aebaaf977c954 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Wed, 25 Feb 2026 22:53:13 +0900 Subject: [PATCH 088/108] =?UTF-8?q?refactor:=20Facade=20private=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=EB=A5=BC=20BrandService=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99=20=EB=B0=8F=20Chop-down=20=EC=8A=A4=ED=83=80?= =?UTF-8?q?=EC=9D=BC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../application/order/OrderFacade.java | 40 +++++++++---------- .../application/product/ProductFacade.java | 33 +++++---------- .../loopers/domain/brand/BrandService.java | 15 +++++++ .../application/order/OrderFacadeTest.java | 26 +++--------- 4 files changed, 51 insertions(+), 63 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java index ccb191a1b..6be528ec4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -2,7 +2,6 @@ import com.loopers.application.order.dto.OrderCriteria; import com.loopers.application.order.dto.OrderResult; -import com.loopers.domain.brand.BrandModel; import com.loopers.domain.brand.BrandService; import com.loopers.domain.order.OrderService; import com.loopers.domain.order.dto.OrderCommand; @@ -35,28 +34,29 @@ public OrderResult.OrderSummary createOrder(Long userId, OrderCriteria.Create cr Map productMap = products.stream() .collect(Collectors.toMap(ProductModel::getId, Function.identity())); - List brandIds = products.stream() - .map(ProductModel::getBrandId) - .distinct() - .toList(); - Map brandNameMap = brandService.getAllByIds(brandIds).stream() - .collect(Collectors.toMap(BrandModel::getId, BrandModel::getName)); + Map brandNameMap = brandService.getNameMapByIds( + products.stream() + .map(ProductModel::getBrandId) + .distinct() + .toList()); return OrderResult.OrderSummary.from( orderService.createOrder( - new OrderCommand.Create(userId, criteria.items().stream() - .map(item -> { - ProductModel product = productMap.get(item.productId()); - product.validateExpectedPrice(item.expectedPrice()); - product.decreaseStock(item.quantity()); - return new OrderCommand.Create.CreateItem( - item.productId(), - product.getPrice(), - item.quantity(), - product.getName(), - brandNameMap.get(product.getBrandId())); - }) - .toList()))); + new OrderCommand.Create( + userId, + criteria.items().stream() + .map(item -> { + ProductModel product = productMap.get(item.productId()); + product.validateExpectedPrice(item.expectedPrice()); + product.decreaseStock(item.quantity()); + return new OrderCommand.Create.CreateItem( + item.productId(), + product.getPrice(), + item.quantity(), + product.getName(), + brandNameMap.get(product.getBrandId())); + }) + .toList()))); } @Transactional(readOnly = true) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index b189f23d4..4440ab286 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -6,9 +6,7 @@ import com.loopers.domain.brand.BrandService; import com.loopers.domain.product.ProductModel; import com.loopers.domain.product.ProductService; -import java.util.List; import java.util.Map; -import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -48,7 +46,11 @@ public void deleteProduct(Long id) { @Transactional(readOnly = true) public Page getProducts(Pageable pageable) { Page products = productService.getAll(pageable); - Map brandNameMap = getBrandNameMap(products.getContent()); + Map brandNameMap = brandService.getNameMapByIds( + products.getContent().stream() + .map(ProductModel::getBrandId) + .distinct() + .toList()); return products.map(product -> ProductResult.of(product, brandNameMap.get(product.getBrandId()))); } @@ -62,7 +64,11 @@ public Page getProductsByBrandId(Long brandId, Pageable pageable) @Transactional(readOnly = true) public Page getProductsWithActiveBrand(Pageable pageable) { Page products = productService.getAll(pageable); - Map brandNameMap = getActiveBrandNameMap(products.getContent()); + Map brandNameMap = brandService.getActiveNameMapByIds( + products.getContent().stream() + .map(ProductModel::getBrandId) + .distinct() + .toList()); return products.map(product -> ProductResult.of(product, brandNameMap.get(product.getBrandId()))); } @@ -72,23 +78,4 @@ public Page getProductsWithActiveBrandByBrandId(Long brandId, Pag return productService.getAllByBrandId(brandId, pageable) .map(product -> ProductResult.of(product, brand.getName())); } - - private Map getBrandNameMap(List products) { - List brandIds = products.stream() - .map(ProductModel::getBrandId) - .distinct() - .toList(); - return brandService.getAllByIds(brandIds).stream() - .collect(Collectors.toMap(BrandModel::getId, BrandModel::getName)); - } - - private Map getActiveBrandNameMap(List products) { - List brandIds = products.stream() - .map(ProductModel::getBrandId) - .distinct() - .toList(); - return brandService.getAllByIds(brandIds).stream() - .filter(brand -> brand.getDeletedAt() == null) - .collect(Collectors.toMap(BrandModel::getId, BrandModel::getName)); - } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java index 593c166dc..bf66bb6d1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java @@ -2,6 +2,8 @@ import com.loopers.support.error.CoreException; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -57,6 +59,19 @@ public List getAllByIds(List ids) { return brandRepository.findAllByIdIn(ids); } + @Transactional(readOnly = true) + public Map getNameMapByIds(List ids) { + return brandRepository.findAllByIdIn(ids).stream() + .collect(Collectors.toMap(BrandModel::getId, BrandModel::getName)); + } + + @Transactional(readOnly = true) + public Map getActiveNameMapByIds(List ids) { + return brandRepository.findAllByIdIn(ids).stream() + .filter(brand -> brand.getDeletedAt() == null) + .collect(Collectors.toMap(BrandModel::getId, BrandModel::getName)); + } + @Transactional(readOnly = true) public void validateExists(Long id) { getById(id); diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java index 9d1fc7661..7f5f46dbe 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java @@ -9,7 +9,6 @@ import com.loopers.application.order.dto.OrderCriteria; import com.loopers.application.order.dto.OrderResult; -import com.loopers.domain.brand.BrandModel; import com.loopers.domain.brand.BrandService; import com.loopers.domain.order.OrderErrorCode; import com.loopers.domain.order.OrderItemModel; @@ -23,6 +22,7 @@ import com.loopers.support.error.CoreException; import java.time.ZonedDateTime; import java.util.List; +import java.util.Map; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -62,18 +62,6 @@ private ProductModel createProductWithId(Long brandId, String name, int price, i return product; } - private BrandModel createBrandWithId(String name, Long id) { - BrandModel brand = BrandModel.create(name); - try { - var idField = brand.getClass().getSuperclass().getDeclaredField("id"); - idField.setAccessible(true); - idField.set(brand, id); - } catch (NoSuchFieldException | IllegalAccessException e) { - throw new RuntimeException(e); - } - return brand; - } - @DisplayName("주문 생성 (UC-O01)") @Nested class CreateOrder { @@ -84,10 +72,9 @@ void createOrder_success() { // arrange Long brandId = 1L; ProductModel product = createProductWithId(brandId, "상품A", 25000, 100, 10L); - BrandModel brand = createBrandWithId("브랜드A", brandId); when(productService.getAllByIds(List.of(10L))).thenReturn(List.of(product)); - when(brandService.getAllByIds(List.of(brandId))).thenReturn(List.of(brand)); + when(brandService.getNameMapByIds(List.of(brandId))).thenReturn(Map.of(brandId, "브랜드A")); OrderModel order = OrderModel.create(1L, 25000); when(orderService.createOrder(any(OrderCommand.Create.class))).thenReturn(order); @@ -102,7 +89,7 @@ void createOrder_success() { // assert assertAll( () -> verify(productService).getAllByIds(List.of(10L)), - () -> verify(brandService).getAllByIds(List.of(brandId)), + () -> verify(brandService).getNameMapByIds(List.of(brandId)), () -> verify(orderService).createOrder(any(OrderCommand.Create.class)), () -> assertThat(result.totalPrice()).isEqualTo(25000) ); @@ -115,6 +102,7 @@ void createOrder_withEmptyItems_throwsException() { OrderCriteria.Create criteria = new OrderCriteria.Create(List.of()); when(productService.getAllByIds(List.of())).thenReturn(List.of()); + when(brandService.getNameMapByIds(List.of())).thenReturn(Map.of()); when(orderService.createOrder(any(OrderCommand.Create.class))) .thenThrow(new CoreException(OrderErrorCode.EMPTY_ORDER_ITEMS)); @@ -145,10 +133,9 @@ void createOrder_priceMismatch_throwsException() { // arrange Long brandId = 1L; ProductModel product = createProductWithId(brandId, "상품A", 25000, 100, 10L); - BrandModel brand = createBrandWithId("브랜드A", brandId); when(productService.getAllByIds(List.of(10L))).thenReturn(List.of(product)); - when(brandService.getAllByIds(List.of(brandId))).thenReturn(List.of(brand)); + when(brandService.getNameMapByIds(List.of(brandId))).thenReturn(Map.of(brandId, "브랜드A")); OrderCriteria.Create criteria = new OrderCriteria.Create(List.of( new OrderCriteria.Create.CreateItem(10L, 1, 30000) @@ -165,10 +152,9 @@ void createOrder_insufficientStock_throwsException() { // arrange Long brandId = 1L; ProductModel product = createProductWithId(brandId, "상품A", 25000, 1, 10L); - BrandModel brand = createBrandWithId("브랜드A", brandId); when(productService.getAllByIds(List.of(10L))).thenReturn(List.of(product)); - when(brandService.getAllByIds(List.of(brandId))).thenReturn(List.of(brand)); + when(brandService.getNameMapByIds(List.of(brandId))).thenReturn(Map.of(brandId, "브랜드A")); OrderCriteria.Create criteria = new OrderCriteria.Create(List.of( new OrderCriteria.Create.CreateItem(10L, 100, 25000) From 87d258fb64f33eb49d0053264c3542a41d68f114 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Wed, 25 Feb 2026 22:53:37 +0900 Subject: [PATCH 089/108] =?UTF-8?q?docs:=20Product=20=E2=86=92=20Brand=20I?= =?UTF-8?q?D=20=EC=B0=B8=EC=A1=B0=20=EB=B3=80=EA=B2=BD=EC=97=90=20?= =?UTF-8?q?=EB=94=B0=EB=A5=B8=20=EC=84=A4=EA=B3=84=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- docs/design/_shared/CONVENTIONS.md | 25 +++++++++++++++++++++++-- docs/design/_shared/OVERVIEW.md | 6 +++--- docs/design/brand/DESIGN.md | 2 +- docs/design/product/DESIGN.md | 8 ++++---- 4 files changed, 31 insertions(+), 10 deletions(-) diff --git a/docs/design/_shared/CONVENTIONS.md b/docs/design/_shared/CONVENTIONS.md index cfbdff18e..14040de8a 100644 --- a/docs/design/_shared/CONVENTIONS.md +++ b/docs/design/_shared/CONVENTIONS.md @@ -25,10 +25,31 @@ SSENSE와 같은 하이패션 이커머스 플랫폼. Spring Boot 3.4.4, Java 21 - FK의 문제: 잠금 전파(데드락 위험), 삭제 순서 강제, 테이블 간 결합 - **DB 유니크 제약 사용** — 테이블 내부 제약은 사용한다. 동시성(더블클릭 등) 시 중복 방지. - **참조 방식** - - 같은 도메인 (Brand → Product): 객체참조 + FK 없음 (`@ManyToOne` + `ConstraintMode.NO_CONSTRAINT`) - - 다른 도메인 간: ID 참조 (`private Long userId` 등) + - 도메인 간: ID 참조 (`private Long brandId`, `private Long userId` 등). 도메인 패키지 간 격벽 유지 + - 같은 도메인 내부 (Order ↔ OrderItem): 양방향 매핑 허용 기준에 따라 결정 - **Aggregate** — 각 도메인은 독립 Aggregate Root. `@OneToMany` 사용하지 않음. Aggregate 규칙은 Service에서 `@Transactional`로 관리. +### 도메인 간 의존 규칙 + +도메인 패키지 간 의존은 기본적으로 **단방향**만 허용한다. 허용된 방향은 아래 표에 명시한다. + +| From → To | 방향 | 허용 레이어 | 사유 | +|-----------|------|------------|------| +| Product → Brand | 단방향 | Domain (ID 참조), Application (Service 호출) | 상품은 브랜드에 생명주기 종속 | +| Brand → Product | 역방향 금지 | Application Facade에서만 조율 | BrandFacade가 ProductService를 호출하여 연쇄 삭제 처리 | +| Order → Product | 단방향 | Application (Service 호출, 스냅샷 조회) | 주문 시 상품 정보 스냅샷. Domain에서는 ID 참조만 | + +**원칙:** +- Domain 레이어에서 다른 도메인을 참조할 때는 ID 참조만 허용 (객체참조 금지) +- 역방향이 필요한 조율(브랜드 삭제 시 상품 연쇄 삭제 등)은 Application 레이어(Facade)에서 처리 +- 순환 의존이 발생하면 이벤트 기반 분리를 검토 + +**양방향 매핑 허용 기준:** + +같은 도메인 내에서 트랜잭션 일관성·생명주기 종속·독립 변경 가능성을 따졌을 때 양방향이 더 자연스러운 경우 `@OneToMany` 양방향 매핑을 허용한다. 예: Order ↔ OrderItem처럼 루트 엔티티를 기준으로 비즈니스가 동작하고, 항상 루트를 통해 하위 엔티티에 접근하는 구조. + +JPA 양방향 매핑에 따르는 추가 UPDATE 쿼리 등 성능 오버헤드는 객체 그래프 탐색의 편리함과 트레이드오프로 감안한다. 단, 쿼리 최적화(fetch join, batch size 등)는 별도로 고려한다. + --- ## Soft Delete 전략 diff --git a/docs/design/_shared/OVERVIEW.md b/docs/design/_shared/OVERVIEW.md index ef437e154..1cbe21948 100644 --- a/docs/design/_shared/OVERVIEW.md +++ b/docs/design/_shared/OVERVIEW.md @@ -112,7 +112,7 @@ classDiagram } class Product { - Brand brand + Long brandId String name int price int stock @@ -164,7 +164,7 @@ classDiagram ORDERED } - Product "*" --> "1" Brand : 객체참조 (FK 없음) + Product "*" --> "1" Brand : ID 참조 (brandId) ProductLike "*" --> "1" User : userId ProductLike "*" --> "1" Product : productId CartItem "*" --> "1" User : userId @@ -181,7 +181,7 @@ classDiagram | 관계 | 카디널리티 | 참조 방식 | 비고 | |---|---|---|---| -| Brand → Product | 1 : N | 객체참조 + FK 없음 | `@ManyToOne` + `ConstraintMode.NO_CONSTRAINT` | +| Brand → Product | 1 : N | ID 참조 (brandId) | 도메인 간 패키지 격벽 유지 | | User → ProductLike | 1 : N | ID 참조 (userId) | UNIQUE(userId, productId) | | Product → ProductLike | 1 : N | ID 참조 (productId) | 교차 테이블 | | User → CartItem | 1 : N | ID 참조 (userId) | UNIQUE(userId, productId) | diff --git a/docs/design/brand/DESIGN.md b/docs/design/brand/DESIGN.md index 550537863..0235847f3 100644 --- a/docs/design/brand/DESIGN.md +++ b/docs/design/brand/DESIGN.md @@ -22,7 +22,7 @@ - **브랜드명 중복 불가** — 동일한 브랜드명이 이미 존재하면 등록/수정 실패 (409 Conflict) - **고객 vs Admin 응답 차이** — 고객에게는 기본 정보만, Admin에게는 등록일/수정일/삭제 여부 등 관리 정보 추가 제공 - **soft delete된 브랜드** — 고객 조회 불가 (404 반환) -- **Brand → Product 참조** — 객체참조 + FK 없음. `@ManyToOne` + `ConstraintMode.NO_CONSTRAINT` +- **Brand → Product 참조** — ID 참조 (`Long brandId`). 도메인 간 패키지 격벽 유지를 위해 객체참조 대신 ID 참조 - **독립 Aggregate Root** — Brand와 Product는 별도 Aggregate Root. 브랜드 삭제 → 상품 soft delete는 Facade에서 조율. ### API diff --git a/docs/design/product/DESIGN.md b/docs/design/product/DESIGN.md index 7e46c0c4b..e4e18b30c 100644 --- a/docs/design/product/DESIGN.md +++ b/docs/design/product/DESIGN.md @@ -15,7 +15,7 @@ - **재고: Product 필드로 관리** — 별도 Stock 도메인 분리 없이 Product 엔티티의 stock 필드로 관리. 등록/수정 시 재고 설정, 주문 시 차감. - **고객 vs Admin 응답 차이** — 고객에게는 기본 정보만, Admin에게는 관리 정보 추가 제공 - **soft delete된 상품** — 고객 조회 불가 (404 반환) -- **Brand → Product 참조** — 객체참조 + FK 없음. `@ManyToOne` + `ConstraintMode.NO_CONSTRAINT`. `product.getBrand().getName()` 접근 가능. +- **Brand → Product 참조** — ID 참조 (`Long brandId`). 도메인 간 패키지 격벽을 위해 객체참조 대신 ID 참조. Brand 정보가 필요할 때는 Application 계층(Facade)에서 BrandService를 통해 조합. - **Product.likeCount 캐시 필드** — 찜 수 조회 성능을 위해 Product에 likeCount 캐싱. 찜/취소 시 원자적 증감. - **독립 Aggregate Root** — Brand와 Product는 별도 Aggregate Root. @@ -125,7 +125,7 @@ ```mermaid classDiagram class Product { - Brand brand + Long brandId String name int price int stock @@ -140,14 +140,14 @@ classDiagram +isDeleted() boolean } - Product "*" --> "1" Brand : 객체참조 (FK 없음) + Product "*" --> "1" Brand : ID 참조 (brandId) ``` ### 비즈니스 규칙 | 메서드 | 비즈니스 규칙 | |---|---| -| create(brand, name, price, stock) | 가격 0 이상, 재고 0 이상 검증 (Entity 내부 validatePriceRange, validateStockRange) | +| create(brandId, name, price, stock) | 가격 0 이상, 재고 0 이상 검증 (Entity 내부 validatePriceRange, validateStockRange) | | decreaseStock(int) | 재고 부족 시 CoreException(BAD_REQUEST). Entity 도메인 메서드에서 직접 처리 | | validateExpectedPrice(int) | 주문 시 기대 가격과 현재 가격 비교. 불일치 시 예외 | | isSoldOut() | stock이 0인지 확인. "품절"의 정의를 캡슐화 | From cb651fa704e43a04ffcde23e94294fef48f6c931 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Wed, 25 Feb 2026 22:56:17 +0900 Subject: [PATCH 090/108] =?UTF-8?q?docs:=20=EB=85=B8=EC=85=98=20MCP=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index d5b2d26af..c0c2e8fa9 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -48,7 +48,10 @@ "WebFetch(domain:medium.com)", "Bash(source:*)", "Bash(sdk use java:*)", - "Bash(JAVA_HOME=/Users/jins/Library/Java/JavaVirtualMachines/corretto-21.0.4/Contents/Home ./gradlew:*)" + "Bash(JAVA_HOME=/Users/jins/Library/Java/JavaVirtualMachines/corretto-21.0.4/Contents/Home ./gradlew:*)", + "mcp__claude_ai_Notion__notion-search", + "mcp__claude_ai_Notion__notion-fetch", + "mcp__claude_ai_Notion__notion-create-pages" ], "deny": [], "ask": [] From 7517c00cf2fe00393c7a620466cf69040ff6f8d3 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Fri, 27 Feb 2026 16:38:56 +0900 Subject: [PATCH 091/108] =?UTF-8?q?feat:=20=EC=83=81=ED=92=88=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94=20=EC=88=98=20=EC=A1=B0=ED=9A=8C=20=EB=B0=8F?= =?UTF-8?q?=20likes=5Fdesc=20=EC=A0=95=EB=A0=AC=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../application/product/ProductFacade.java | 76 ++++++++++++++++--- .../product/dto/ProductResult.java | 6 ++ .../domain/like/ProductLikeRepository.java | 3 + .../domain/like/ProductLikeService.java | 6 ++ .../domain/product/ProductRepository.java | 2 + .../domain/product/ProductService.java | 9 +++ .../like/ProductLikeJpaRepository.java | 5 ++ .../like/ProductLikeRepositoryImpl.java | 11 ++- .../product/ProductJpaRepository.java | 2 + .../product/ProductRepositoryImpl.java | 5 ++ .../interfaces/product/ProductV1ApiSpec.java | 2 +- .../product/ProductV1Controller.java | 25 +++--- .../interfaces/product/dto/ProductV1Dto.java | 23 ++++-- .../domain/product/FakeProductRepository.java | 6 ++ 14 files changed, 153 insertions(+), 28 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index 4440ab286..1780e6f5d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -2,13 +2,17 @@ import com.loopers.application.product.dto.ProductCriteria; import com.loopers.application.product.dto.ProductResult; -import com.loopers.domain.brand.BrandModel; import com.loopers.domain.brand.BrandService; +import com.loopers.domain.like.ProductLikeService; import com.loopers.domain.product.ProductModel; import com.loopers.domain.product.ProductService; +import java.util.Comparator; +import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -19,6 +23,7 @@ public class ProductFacade { private final ProductService productService; private final BrandService brandService; + private final ProductLikeService productLikeService; @Transactional public void registerProduct(ProductCriteria.Register criteria) { @@ -29,8 +34,10 @@ public void registerProduct(ProductCriteria.Register criteria) { @Transactional(readOnly = true) public ProductResult getProduct(Long id) { ProductModel product = productService.getById(id); - BrandModel brand = brandService.getById(product.getBrandId()); - return ProductResult.of(product, brand.getName()); + return ProductResult.of( + product, + brandService.getById(product.getBrandId()).getName(), + productLikeService.countLikes(id)); } @Transactional @@ -56,9 +63,9 @@ public Page getProducts(Pageable pageable) { @Transactional(readOnly = true) public Page getProductsByBrandId(Long brandId, Pageable pageable) { - BrandModel brand = brandService.getById(brandId); + String brandName = brandService.getById(brandId).getName(); return productService.getAllByBrandId(brandId, pageable) - .map(product -> ProductResult.of(product, brand.getName())); + .map(product -> ProductResult.of(product, brandName)); } @Transactional(readOnly = true) @@ -69,13 +76,64 @@ public Page getProductsWithActiveBrand(Pageable pageable) { .map(ProductModel::getBrandId) .distinct() .toList()); - return products.map(product -> ProductResult.of(product, brandNameMap.get(product.getBrandId()))); + Map likeCountMap = productLikeService.countLikesByProductIds( + products.getContent().stream() + .map(ProductModel::getId) + .toList()); + List results = products.getContent().stream() + .filter(product -> brandNameMap.containsKey(product.getBrandId())) + .map(product -> ProductResult.of( + product, + brandNameMap.get(product.getBrandId()), + likeCountMap.getOrDefault(product.getId(), 0L))) + .toList(); + return new PageImpl<>(results, products.getPageable(), results.size()); } @Transactional(readOnly = true) public Page getProductsWithActiveBrandByBrandId(Long brandId, Pageable pageable) { - BrandModel brand = brandService.getById(brandId); - return productService.getAllByBrandId(brandId, pageable) - .map(product -> ProductResult.of(product, brand.getName())); + String brandName = brandService.getById(brandId).getName(); + Page products = productService.getAllByBrandId(brandId, pageable); + Map likeCountMap = productLikeService.countLikesByProductIds( + products.getContent().stream() + .map(ProductModel::getId) + .toList()); + List results = products.getContent().stream() + .map(product -> ProductResult.of( + product, + brandName, + likeCountMap.getOrDefault(product.getId(), 0L))) + .toList(); + return new PageImpl<>(results, products.getPageable(), products.getTotalElements()); + } + + @Transactional(readOnly = true) + public Page getProductsWithActiveBrandSortedByLikes(Long brandId, int page, int size) { + List products = brandId != null + ? productService.getAllByBrandId(brandId) + : productService.getAll(); + Map brandNameMap = brandService.getActiveNameMapByIds( + products.stream() + .map(ProductModel::getBrandId) + .distinct() + .toList()); + Map likeCountMap = productLikeService.countLikesByProductIds( + products.stream() + .map(ProductModel::getId) + .toList()); + List sorted = products.stream() + .filter(product -> brandNameMap.containsKey(product.getBrandId())) + .map(product -> ProductResult.of( + product, + brandNameMap.get(product.getBrandId()), + likeCountMap.getOrDefault(product.getId(), 0L))) + .sorted(Comparator.comparingLong(ProductResult::likeCount).reversed()) + .toList(); + int start = page * size; + int end = Math.min(start + size, sorted.size()); + return new PageImpl<>( + start >= sorted.size() ? List.of() : sorted.subList(start, end), + PageRequest.of(page, size), + sorted.size()); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/dto/ProductResult.java b/apps/commerce-api/src/main/java/com/loopers/application/product/dto/ProductResult.java index 068bab4bf..95a8c5cb9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/dto/ProductResult.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/dto/ProductResult.java @@ -10,11 +10,16 @@ public record ProductResult( String name, int price, int stock, + long likeCount, ZonedDateTime createdAt, ZonedDateTime updatedAt, ZonedDateTime deletedAt ) { public static ProductResult of(ProductModel model, String brandName) { + return of(model, brandName, 0L); + } + + public static ProductResult of(ProductModel model, String brandName, long likeCount) { return new ProductResult( model.getId(), model.getBrandId(), @@ -22,6 +27,7 @@ public static ProductResult of(ProductModel model, String brandName) { model.getName(), model.getPrice(), model.getStock(), + likeCount, model.getCreatedAt(), model.getUpdatedAt(), model.getDeletedAt()); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeRepository.java index 6539f9bc7..5fa108d6e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeRepository.java @@ -1,6 +1,7 @@ package com.loopers.domain.like; import java.util.List; +import java.util.Map; import java.util.Optional; public interface ProductLikeRepository { @@ -13,4 +14,6 @@ public interface ProductLikeRepository { List findAllByUserId(Long userId); long countByProductId(Long productId); + + Map countByProductIds(List productIds); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeService.java index 0d5e714b8..bae92e689 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/ProductLikeService.java @@ -3,6 +3,7 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import java.util.List; +import java.util.Map; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -41,4 +42,9 @@ public List getLikesByUserId(Long userId) { public long countLikes(Long productId) { return productLikeRepository.countByProductId(productId); } + + @Transactional(readOnly = true) + public Map countLikesByProductIds(List productIds) { + return productLikeRepository.countByProductIds(productIds); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java index 4a3296af9..fafe25406 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 @@ -17,4 +17,6 @@ public interface ProductRepository { List findAllByBrandId(Long brandId); List findAllByIdIn(List ids); + + List findAll(); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java index afa25dfe7..ba24258ee 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -50,6 +50,11 @@ public Page getAll(Pageable pageable) { return productRepository.findAll(pageable); } + @Transactional(readOnly = true) + public List getAll() { + return productRepository.findAll(); + } + @Transactional(readOnly = true) public Page getAllByBrandId(Long brandId, Pageable pageable) { return productRepository.findAllByBrandId(brandId, pageable); @@ -73,4 +78,8 @@ public void validateExists(Long id) { } } + @Transactional(readOnly = true) + public List getAllByBrandId(Long brandId) { + return productRepository.findAllByBrandId(brandId); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeJpaRepository.java index 13a10b83d..5d189dd85 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeJpaRepository.java @@ -4,6 +4,8 @@ import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface ProductLikeJpaRepository extends JpaRepository { Optional findByUserIdAndProductId(Long userId, Long productId); @@ -11,4 +13,7 @@ public interface ProductLikeJpaRepository extends JpaRepository findAllByUserId(Long userId); long countByProductId(Long productId); + + @Query("SELECT l.productId, COUNT(l) FROM ProductLikeModel l WHERE l.productId IN :productIds GROUP BY l.productId") + List countByProductIdIn(@Param("productIds") List productIds); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeRepositoryImpl.java index 1f34189b1..494a64a6b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/ProductLikeRepositoryImpl.java @@ -3,6 +3,7 @@ import com.loopers.domain.like.ProductLikeModel; import com.loopers.domain.like.ProductLikeRepository; import java.util.List; +import java.util.Map; import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; @@ -18,7 +19,7 @@ public ProductLikeModel save(ProductLikeModel productLike) { @Override public Optional findByUserIdAndProductId(Long userId, Long productId) { - return productLikeJpaRepository.findByUserIdAndProductId(userId,productId); + return productLikeJpaRepository.findByUserIdAndProductId(userId, productId); } @Override @@ -35,4 +36,12 @@ public List findAllByUserId(Long userId) { public long countByProductId(Long productId) { return productLikeJpaRepository.countByProductId(productId); } + + @Override + public Map countByProductIds(List productIds) { + return productLikeJpaRepository.countByProductIdIn(productIds).stream() + .collect(java.util.stream.Collectors.toMap( + row -> (Long) row[0], + row -> (Long) row[1])); + } } 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 5aef70885..40a228055 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 @@ -12,6 +12,8 @@ public interface ProductJpaRepository extends JpaRepository Page findAllByDeletedAtIsNull(Pageable pageable); + List findAllByDeletedAtIsNull(); + Page findAllByBrandIdAndDeletedAtIsNull(Long brandId, Pageable pageable); List findAllByBrandIdAndDeletedAtIsNull(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 index c5adb8178..8d84aad98 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 @@ -29,6 +29,11 @@ public Page findAll(Pageable pageable) { return productJpaRepository.findAllByDeletedAtIsNull(pageable); } + @Override + public List findAll() { + return productJpaRepository.findAllByDeletedAtIsNull(); + } + @Override public Page findAllByBrandId(Long brandId, Pageable pageable) { return productJpaRepository.findAllByBrandIdAndDeletedAtIsNull(brandId, pageable); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1ApiSpec.java index 314732809..4dafcf4ab 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1ApiSpec.java @@ -16,7 +16,7 @@ public interface ProductV1ApiSpec { ApiResponse list( @Parameter(description = "브랜드 ID (선택)", example = "1") Long brandId, - @Parameter(description = "정렬 기준: latest / price_asc", example = "latest") + @Parameter(description = "정렬 기준: latest / price_asc / likes_desc", example = "latest") String sort, @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") int page, diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1Controller.java index f010c4dc1..3c89a43d0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/ProductV1Controller.java @@ -25,18 +25,21 @@ public ApiResponse list( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size ) { - PageRequest pageRequest = PageRequest.of(page, size, toSort(sort)); - Page productInfoPage = brandId != null - ? productFacade.getProductsWithActiveBrandByBrandId(brandId, pageRequest) - : productFacade.getProductsWithActiveBrand(pageRequest); + Page productPage = "likes_desc".equals(sort) + ? productFacade.getProductsWithActiveBrandSortedByLikes(brandId, page, size) + : brandId != null + ? productFacade.getProductsWithActiveBrandByBrandId( + brandId, PageRequest.of(page, size, toSort(sort))) + : productFacade.getProductsWithActiveBrand( + PageRequest.of(page, size, toSort(sort))); return ApiResponse.success( new ProductV1Dto.ListResponse( - productInfoPage.getNumber(), - productInfoPage.getSize(), - productInfoPage.getTotalElements(), - productInfoPage.getTotalPages(), - productInfoPage.getContent().stream() + productPage.getNumber(), + productPage.getSize(), + productPage.getTotalElements(), + productPage.getTotalPages(), + productPage.getContent().stream() .map(ProductV1Dto.ListResponse.ListItem::from) .toList())); } @@ -46,8 +49,8 @@ public ApiResponse list( public ApiResponse getById( @PathVariable Long productId ) { - ProductResult productInfo = productFacade.getProduct(productId); - return ApiResponse.success(ProductV1Dto.DetailResponse.from(productInfo)); + ProductResult result = productFacade.getProduct(productId); + return ApiResponse.success(ProductV1Dto.DetailResponse.from(result)); } private Sort toSort(String sort) { diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/product/dto/ProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/dto/ProductV1Dto.java index 7ea335198..3fa370eac 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/product/dto/ProductV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/product/dto/ProductV1Dto.java @@ -11,12 +11,18 @@ public record DetailResponse( String brandName, String name, int price, - int stock + int stock, + long likeCount ) { public static DetailResponse from(ProductResult info) { return new DetailResponse( - info.id(), info.brandId(), info.brandName(), - info.name(), info.price(), info.stock()); + info.id(), + info.brandId(), + info.brandName(), + info.name(), + info.price(), + info.stock(), + info.likeCount()); } } @@ -32,12 +38,17 @@ public record ListItem( Long brandId, String brandName, String name, - int price + int price, + long likeCount ) { public static ListItem from(ProductResult info) { return new ListItem( - info.id(), info.brandId(), info.brandName(), - info.name(), info.price()); + info.id(), + info.brandId(), + info.brandName(), + info.name(), + info.price(), + info.likeCount()); } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/FakeProductRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/FakeProductRepository.java index fc12edee0..6cd10bb8e 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/FakeProductRepository.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/FakeProductRepository.java @@ -85,4 +85,10 @@ public List findAllByIdIn(List ids) { .toList(); } + @Override + public List findAll() { + return store.values().stream() + .filter(product -> product.getDeletedAt() == null) + .toList(); + } } From 957ed557811dc76a6397f36d7ec2faff46d1ed8b Mon Sep 17 00:00:00 2001 From: plan11plan Date: Fri, 27 Feb 2026 16:39:10 +0900 Subject: [PATCH 092/108] =?UTF-8?q?test:=20=EC=83=81=ED=92=88=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94=20=EC=88=98=20=EC=A1=B0=ED=9A=8C=20=EB=B0=8F?= =?UTF-8?q?=20likes=5Fdesc=20=EC=A0=95=EB=A0=AC=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../product/ProductFacadeTest.java | 120 ++++++++++++++++++ .../like/FakeProductLikeRepository.java | 12 ++ .../domain/like/ProductLikeServiceTest.java | 23 ++++ .../domain/product/FakeProductRepository.java | 14 +- .../product/ProductV1ApiE2ETest.java | 71 ++++++++++- 5 files changed, 228 insertions(+), 12 deletions(-) create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java new file mode 100644 index 000000000..1f1665cb7 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java @@ -0,0 +1,120 @@ +package com.loopers.application.product; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.when; + +import com.loopers.application.product.dto.ProductResult; +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.like.ProductLikeService; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductService; +import java.util.List; +import java.util.Map; +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.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +@DisplayName("ProductFacade 단위 테스트") +@ExtendWith(MockitoExtension.class) +class ProductFacadeTest { + + @Mock + private ProductService productService; + + @Mock + private BrandService brandService; + + @Mock + private ProductLikeService productLikeService; + + @InjectMocks + private ProductFacade productFacade; + + @DisplayName("상품 상세를 조회할 때, ") + @Nested + class GetProduct { + + @DisplayName("좋아요 수가 포함된 ProductResult를 반환한다.") + @Test + void getProduct_returnsResultWithLikeCount() { + // arrange + ProductModel product = ProductModel.create(1L, "에어맥스", 150000, 100); + when(productService.getById(1L)).thenReturn(product); + when(brandService.getById(1L)).thenReturn(BrandModel.create("나이키")); + when(productLikeService.countLikes(1L)).thenReturn(5L); + + // act + ProductResult result = productFacade.getProduct(1L); + + // assert + assertThat(result.likeCount()).isEqualTo(5L); + } + } + + @DisplayName("상품 목록을 조회할 때, ") + @Nested + class GetProductsWithActiveBrand { + + @DisplayName("각 상품에 좋아요 수가 포함된다.") + @Test + void getProductsWithActiveBrand_returnsResultsWithLikeCount() { + // arrange + ProductModel product1 = ProductModel.create(1L, "에어맥스", 150000, 100); + ProductModel product2 = ProductModel.create(1L, "에어포스", 120000, 50); + PageRequest pageable = PageRequest.of(0, 20); + + when(productService.getAll(pageable)) + .thenReturn(new PageImpl<>(List.of(product1, product2), pageable, 2)); + when(brandService.getActiveNameMapByIds(List.of(1L))) + .thenReturn(Map.of(1L, "나이키")); + when(productLikeService.countLikesByProductIds(List.of(0L, 0L))) + .thenReturn(Map.of(0L, 3L)); + + // act + Page result = productFacade.getProductsWithActiveBrand(pageable); + + // assert + assertThat(result.getContent()).hasSize(2); + assertThat(result.getContent().get(0).likeCount()).isEqualTo(3L); + } + } + + @DisplayName("좋아요 수 정렬로 상품 목록을 조회할 때, ") + @Nested + class GetProductsWithActiveBrandSortedByLikes { + + @DisplayName("좋아요 수 내림차순으로 정렬되고 페이지네이션된다.") + @Test + void getProductsSortedByLikes_returnsSortedAndPaginated() { + // arrange + ProductModel product1 = ProductModel.create(1L, "에어맥스", 150000, 100); + ProductModel product2 = ProductModel.create(1L, "에어포스", 120000, 50); + ProductModel product3 = ProductModel.create(1L, "조던1", 200000, 30); + + when(productService.getAll()) + .thenReturn(List.of(product1, product2, product3)); + when(brandService.getActiveNameMapByIds(List.of(1L))) + .thenReturn(Map.of(1L, "나이키")); + when(productLikeService.countLikesByProductIds(anyList())) + .thenReturn(Map.of(0L, 1L)); + + // act + Page result = + productFacade.getProductsWithActiveBrandSortedByLikes(null, 0, 2); + + // assert + assertThat(result.getContent()).hasSize(2); + assertThat(result.getTotalElements()).isEqualTo(3); + assertThat(result.getTotalPages()).isEqualTo(2); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/FakeProductLikeRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/FakeProductLikeRepository.java index 73431ca5c..2defe2cdf 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/like/FakeProductLikeRepository.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/FakeProductLikeRepository.java @@ -5,6 +5,7 @@ import java.util.Map; import java.util.Optional; import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; public class FakeProductLikeRepository implements ProductLikeRepository { @@ -51,4 +52,15 @@ public long countByProductId(Long productId) { .filter(like -> like.getProductId().equals(productId)) .count(); } + + @Override + public Map countByProductIds(List productIds) { + Map countMap = store.values().stream() + .filter(like -> productIds.contains(like.getProductId())) + .collect(Collectors.groupingBy( + ProductLikeModel::getProductId, + Collectors.counting())); + productIds.forEach(id -> countMap.putIfAbsent(id, 0L)); + return countMap; + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeServiceTest.java index b62299141..e616f35ae 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/ProductLikeServiceTest.java @@ -5,6 +5,7 @@ import com.loopers.support.error.CoreException; import java.util.List; +import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -147,4 +148,26 @@ void countLikes_returnsCount() { assertThat(productLikeService.countLikes(99L)).isEqualTo(0); } } + + @DisplayName("상품별 좋아요 수를 일괄 조회할 때, ") + @Nested + class CountLikesByProductIds { + + @DisplayName("여러 상품 ID로 조회하면, 상품별 좋아요 수 Map을 반환한다.") + @Test + void countLikesByProductIds_returnsCountMap() { + // arrange + productLikeRepository.save(ProductLikeModel.create(1L, 10L)); + productLikeRepository.save(ProductLikeModel.create(2L, 10L)); + productLikeRepository.save(ProductLikeModel.create(3L, 20L)); + + // act + Map result = productLikeService.countLikesByProductIds(List.of(10L, 20L, 30L)); + + // assert + assertThat(result).containsEntry(10L, 2L); + assertThat(result).containsEntry(20L, 1L); + assertThat(result).containsEntry(30L, 0L); + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/FakeProductRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/FakeProductRepository.java index 6cd10bb8e..747ee9b47 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/FakeProductRepository.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/FakeProductRepository.java @@ -52,6 +52,13 @@ public Page findAll(Pageable pageable) { return new PageImpl<>(pageContent, pageable, activeModels.size()); } + @Override + public List findAll() { + return store.values().stream() + .filter(product -> product.getDeletedAt() == null) + .toList(); + } + @Override public Page findAllByBrandId(Long brandId, Pageable pageable) { List filtered = store.values().stream() @@ -84,11 +91,4 @@ public List findAllByIdIn(List ids) { .filter(product -> ids.contains(product.getId())) .toList(); } - - @Override - public List findAll() { - return store.values().stream() - .filter(product -> product.getDeletedAt() == null) - .toList(); - } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/product/ProductV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/product/ProductV1ApiE2ETest.java index d3642f066..57f54096e 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/product/ProductV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/product/ProductV1ApiE2ETest.java @@ -5,8 +5,10 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.like.ProductLikeModel; import com.loopers.domain.product.ProductModel; import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.like.ProductLikeJpaRepository; import com.loopers.infrastructure.product.ProductJpaRepository; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.product.dto.ProductV1Dto; @@ -32,6 +34,7 @@ class ProductV1ApiE2ETest { private final TestRestTemplate testRestTemplate; private final BrandJpaRepository brandJpaRepository; private final ProductJpaRepository productJpaRepository; + private final ProductLikeJpaRepository productLikeJpaRepository; private final DatabaseCleanUp databaseCleanUp; private BrandModel savedBrand; @@ -41,11 +44,13 @@ public ProductV1ApiE2ETest( TestRestTemplate testRestTemplate, BrandJpaRepository brandJpaRepository, ProductJpaRepository productJpaRepository, + ProductLikeJpaRepository productLikeJpaRepository, DatabaseCleanUp databaseCleanUp ) { this.testRestTemplate = testRestTemplate; this.brandJpaRepository = brandJpaRepository; this.productJpaRepository = productJpaRepository; + this.productLikeJpaRepository = productLikeJpaRepository; this.databaseCleanUp = databaseCleanUp; } @@ -156,19 +161,74 @@ void returnsEmptyList_whenNoProductsExist() { () -> assertThat(response.getBody().data().items()).isEmpty() ); } + + @DisplayName("목록 조회 시 각 상품에 좋아요 수가 포함된다.") + @Test + void returnsList_withLikeCount() { + // arrange + ProductModel product1 = saveProduct("에어맥스", 150000, 100); + saveProduct("에어포스", 120000, 50); + productLikeJpaRepository.save(ProductLikeModel.create(1L, product1.getId())); + productLikeJpaRepository.save(ProductLikeModel.create(2L, product1.getId())); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_PRODUCTS, HttpMethod.GET, null, responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().items()).anyMatch(item -> item.likeCount() == 2L), + () -> assertThat(response.getBody().data().items()).anyMatch(item -> item.likeCount() == 0L) + ); + } + + @DisplayName("likes_desc 정렬로 조회하면, 좋아요 수 내림차순으로 정렬된다.") + @Test + void returnsSortedByLikesDesc_whenSortIsLikesDesc() { + // arrange + ProductModel product1 = saveProduct("에어맥스", 150000, 100); + ProductModel product2 = saveProduct("에어포스", 120000, 50); + ProductModel product3 = saveProduct("조던1", 200000, 30); + productLikeJpaRepository.save(ProductLikeModel.create(1L, product2.getId())); + productLikeJpaRepository.save(ProductLikeModel.create(2L, product2.getId())); + productLikeJpaRepository.save(ProductLikeModel.create(3L, product2.getId())); + productLikeJpaRepository.save(ProductLikeModel.create(1L, product3.getId())); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange( + ENDPOINT_PRODUCTS + "?sort=likes_desc&page=0&size=2", + HttpMethod.GET, null, responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(3), + () -> assertThat(response.getBody().data().totalPages()).isEqualTo(2), + () -> assertThat(response.getBody().data().items()).hasSize(2), + () -> assertThat(response.getBody().data().items().get(0).name()).isEqualTo("에어포스"), + () -> assertThat(response.getBody().data().items().get(0).likeCount()).isEqualTo(3L), + () -> assertThat(response.getBody().data().items().get(1).name()).isEqualTo("조던1"), + () -> assertThat(response.getBody().data().items().get(1).likeCount()).isEqualTo(1L) + ); + } } @DisplayName("GET /api/v1/products/{productId}") @Nested class GetById { - @DisplayName("존재하는 상품을 조회하면, 상세 정보를 반환한다.") + @DisplayName("존재하는 상품을 조회하면, 좋아요 수가 포함된 상세 정보를 반환한다.") @Test void returnsProductDetail_whenProductExists() { // arrange - saveProduct("에어맥스", 150000, 100); - Long productId = productJpaRepository.findAllByDeletedAtIsNull(org.springframework.data.domain.PageRequest.of(0, 1)) - .getContent().get(0).getId(); + ProductModel product = saveProduct("에어맥스", 150000, 100); + Long productId = product.getId(); + productLikeJpaRepository.save(ProductLikeModel.create(1L, productId)); + productLikeJpaRepository.save(ProductLikeModel.create(2L, productId)); // act ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; @@ -183,7 +243,8 @@ void returnsProductDetail_whenProductExists() { () -> assertThat(response.getBody().data().brandName()).isEqualTo("나이키"), () -> assertThat(response.getBody().data().name()).isEqualTo("에어맥스"), () -> assertThat(response.getBody().data().price()).isEqualTo(150000), - () -> assertThat(response.getBody().data().stock()).isEqualTo(100) + () -> assertThat(response.getBody().data().stock()).isEqualTo(100), + () -> assertThat(response.getBody().data().likeCount()).isEqualTo(2L) ); } From 11802f2a91ecd1a6e97fbe6968dba064deb9ba65 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Fri, 27 Feb 2026 16:52:20 +0900 Subject: [PATCH 093/108] =?UTF-8?q?fix:=20=EC=83=81=ED=92=88=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20totalElements?= =?UTF-8?q?=EA=B0=80=20=ED=95=84=ED=84=B0=EB=A7=81=EB=90=9C=20=ED=81=AC?= =?UTF-8?q?=EA=B8=B0=EB=A1=9C=20=EB=B0=98=ED=99=98=EB=90=98=EB=8A=94=20?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../java/com/loopers/application/product/ProductFacade.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index 1780e6f5d..349a8aefb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -87,7 +87,7 @@ public Page getProductsWithActiveBrand(Pageable pageable) { brandNameMap.get(product.getBrandId()), likeCountMap.getOrDefault(product.getId(), 0L))) .toList(); - return new PageImpl<>(results, products.getPageable(), results.size()); + return new PageImpl<>(results, products.getPageable(), products.getTotalElements()); } @Transactional(readOnly = true) From 7fca91754bfcce6fb35059f27e490d83b329ac90 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Fri, 27 Feb 2026 23:35:08 +0900 Subject: [PATCH 094/108] =?UTF-8?q?refactor:=20Order-OrderItem=20=EC=96=91?= =?UTF-8?q?=EB=B0=A9=ED=96=A5=20=EC=97=B0=EA=B4=80=EA=B4=80=EA=B3=84=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../application/order/OrderFacade.java | 42 +++++++++---------- .../application/order/dto/OrderResult.java | 4 +- .../loopers/domain/order/OrderItemModel.java | 22 +++++----- .../com/loopers/domain/order/OrderModel.java | 35 +++++++++++++--- .../loopers/domain/order/OrderService.java | 31 +------------- .../domain/order/dto/OrderCommand.java | 17 -------- 6 files changed, 63 insertions(+), 88 deletions(-) delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/dto/OrderCommand.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java index 6be528ec4..d7e28f4ca 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -3,8 +3,9 @@ import com.loopers.application.order.dto.OrderCriteria; import com.loopers.application.order.dto.OrderResult; import com.loopers.domain.brand.BrandService; +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderModel; import com.loopers.domain.order.OrderService; -import com.loopers.domain.order.dto.OrderCommand; import com.loopers.domain.product.ProductModel; import com.loopers.domain.product.ProductService; import java.util.List; @@ -40,23 +41,22 @@ public OrderResult.OrderSummary createOrder(Long userId, OrderCriteria.Create cr .distinct() .toList()); + List items = criteria.items().stream() + .map(item -> { + ProductModel product = productMap.get(item.productId()); + product.validateExpectedPrice(item.expectedPrice()); + product.decreaseStock(item.quantity()); + return OrderItemModel.create( + item.productId(), + product.getPrice(), + item.quantity(), + product.getName(), + brandNameMap.get(product.getBrandId())); + }) + .toList(); + return OrderResult.OrderSummary.from( - orderService.createOrder( - new OrderCommand.Create( - userId, - criteria.items().stream() - .map(item -> { - ProductModel product = productMap.get(item.productId()); - product.validateExpectedPrice(item.expectedPrice()); - product.decreaseStock(item.quantity()); - return new OrderCommand.Create.CreateItem( - item.productId(), - product.getPrice(), - item.quantity(), - product.getName(), - brandNameMap.get(product.getBrandId())); - }) - .toList()))); + orderService.createOrder(userId, items)); } @Transactional(readOnly = true) @@ -69,8 +69,7 @@ public List getMyOrders(Long userId, OrderCriteria.Lis @Transactional(readOnly = true) public OrderResult.OrderDetail getMyOrderDetail(Long userId, Long orderId) { return OrderResult.OrderDetail.from( - orderService.getByIdAndUserId(orderId, userId), - orderService.getOrderItemsByOrderId(orderId)); + orderService.getByIdAndUserId(orderId, userId)); } @Transactional(readOnly = true) @@ -81,9 +80,6 @@ public Page getAllOrders(Pageable pageable) { @Transactional(readOnly = true) public OrderResult.OrderDetail getOrderDetail(Long orderId) { - return OrderResult.OrderDetail.from( - orderService.getById(orderId), - orderService.getOrderItemsByOrderId(orderId)); + return OrderResult.OrderDetail.from(orderService.getById(orderId)); } - } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/dto/OrderResult.java b/apps/commerce-api/src/main/java/com/loopers/application/order/dto/OrderResult.java index 69f487e98..77a33a07d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/dto/OrderResult.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/dto/OrderResult.java @@ -30,14 +30,14 @@ public record OrderDetail( ZonedDateTime createdAt, List items ) { - public static OrderDetail from(OrderModel model, List items) { + public static OrderDetail from(OrderModel model) { return new OrderDetail( model.getId(), model.getUserId(), model.getTotalPrice(), model.getStatus().name(), model.getCreatedAt(), - items.stream().map(OrderItemDetail::from).toList()); + model.getItems().stream().map(OrderItemDetail::from).toList()); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java index 8322cad0c..12cffd873 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java @@ -5,6 +5,9 @@ import com.loopers.support.error.ErrorType; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.Getter; @@ -16,8 +19,9 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class OrderItemModel extends BaseEntity { - @Column(name = "order_id", nullable = false) - private Long orderId; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "order_id", nullable = false) + private OrderModel order; @Column(name = "product_id", nullable = false) private Long productId; @@ -34,9 +38,8 @@ public class OrderItemModel extends BaseEntity { @Column(name = "brand_name", nullable = false) private String brandName; - private OrderItemModel(Long orderId, Long productId, int orderPrice, int quantity, + private OrderItemModel(Long productId, int orderPrice, int quantity, String productName, String brandName) { - this.orderId = orderId; this.productId = productId; this.orderPrice = orderPrice; this.quantity = quantity; @@ -44,21 +47,18 @@ private OrderItemModel(Long orderId, Long productId, int orderPrice, int quantit this.brandName = brandName; } - public static OrderItemModel create(Long orderId, Long productId, int orderPrice, int quantity, + public static OrderItemModel create(Long productId, int orderPrice, int quantity, String productName, String brandName) { - validateOrderId(orderId); validateProductId(productId); validateOrderPrice(orderPrice); validateQuantity(quantity); validateProductName(productName); validateBrandName(brandName); - return new OrderItemModel(orderId, productId, orderPrice, quantity, productName, brandName); + return new OrderItemModel(productId, orderPrice, quantity, productName, brandName); } - private static void validateOrderId(Long orderId) { - if (orderId == null) { - throw new CoreException(ErrorType.BAD_REQUEST, "주문 ID는 필수값입니다."); - } + void assignOrder(OrderModel order) { + this.order = order; } private static void validateProductId(Long productId) { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java index a56636848..9f5d1e5a9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java @@ -3,14 +3,19 @@ import com.loopers.domain.BaseEntity; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; +import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; +import jakarta.persistence.OneToMany; import jakarta.persistence.Table; +import java.util.ArrayList; +import java.util.List; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; +import org.hibernate.annotations.BatchSize; @Getter @Entity @@ -28,16 +33,34 @@ public class OrderModel extends BaseEntity { @Column(name = "status", nullable = false) private OrderStatus status; + @BatchSize(size = 100) + @OneToMany(mappedBy = "order", cascade = CascadeType.PERSIST) + private List items = new ArrayList<>(); + private OrderModel(Long userId, int totalPrice, OrderStatus status) { this.userId = userId; this.totalPrice = totalPrice; this.status = status; } - public static OrderModel create(Long userId, int totalPrice) { + public static OrderModel create(Long userId, List items) { validateUserId(userId); - validateTotalPrice(totalPrice); - return new OrderModel(userId, totalPrice, OrderStatus.ORDERED); + validateItems(items); + OrderModel order = new OrderModel(userId, 0, OrderStatus.ORDERED); + items.forEach(order::addItem); + order.totalPrice = order.calculateTotalPrice(); + return order; + } + + public void addItem(OrderItemModel item) { + items.add(item); + item.assignOrder(this); + } + + public int calculateTotalPrice() { + return items.stream() + .mapToInt(item -> item.getOrderPrice() * item.getQuantity()) + .sum(); } public void validateOwner(Long userId) { @@ -52,9 +75,9 @@ private static void validateUserId(Long userId) { } } - private static void validateTotalPrice(int totalPrice) { - if (totalPrice < 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "가격은 0 이상이어야 합니다."); + private static void validateItems(List items) { + if (items == null || items.isEmpty()) { + throw new CoreException(OrderErrorCode.EMPTY_ORDER_ITEMS); } } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java index e506517ba..a068a0706 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -1,6 +1,5 @@ package com.loopers.domain.order; -import com.loopers.domain.order.dto.OrderCommand; import com.loopers.support.error.CoreException; import java.time.ZonedDateTime; import java.util.List; @@ -18,28 +17,8 @@ public class OrderService { private final OrderItemRepository orderItemRepository; @Transactional - public OrderModel createOrder(OrderCommand.Create command) { - validateItems(command.items()); - - int totalPrice = command.items().stream() - .mapToInt(item -> item.orderPrice() * item.quantity()) - .sum(); - - OrderModel savedOrder = orderRepository.save( - OrderModel.create(command.userId(), totalPrice)); - - for (OrderCommand.Create.CreateItem item : command.items()) { - orderItemRepository.save( - OrderItemModel.create( - savedOrder.getId(), - item.productId(), - item.orderPrice(), - item.quantity(), - item.productName(), - item.brandName())); - } - - return savedOrder; + public OrderModel createOrder(Long userId, List items) { + return orderRepository.save(OrderModel.create(userId, items)); } @Transactional(readOnly = true) @@ -69,10 +48,4 @@ public List getOrderItemsByOrderId(Long orderId) { public Page getAllOrders(Pageable pageable) { return orderRepository.findAll(pageable); } - - private void validateItems(List items) { - if (items == null || items.isEmpty()) { - throw new CoreException(OrderErrorCode.EMPTY_ORDER_ITEMS); - } - } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/dto/OrderCommand.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/dto/OrderCommand.java deleted file mode 100644 index 9c60e3dd2..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/dto/OrderCommand.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.loopers.domain.order.dto; - -import java.util.List; - -public class OrderCommand { - - public record Create(Long userId, List items) { - - public record CreateItem( - Long productId, - int orderPrice, - int quantity, - String productName, - String brandName - ) {} - } -} From 4030a61728d16bac3d66ee0d844de6807ea9b23f Mon Sep 17 00:00:00 2001 From: plan11plan Date: Fri, 27 Feb 2026 23:35:18 +0900 Subject: [PATCH 095/108] =?UTF-8?q?test:=20Order-OrderItem=20=EC=96=91?= =?UTF-8?q?=EB=B0=A9=ED=96=A5=20=EC=97=B0=EA=B4=80=EA=B4=80=EA=B3=84=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../application/order/OrderFacadeTest.java | 105 ++++++-------- .../domain/order/FakeOrderItemRepository.java | 5 +- .../domain/order/OrderItemModelTest.java | 35 ++--- .../loopers/domain/order/OrderModelTest.java | 52 +++++-- .../domain/order/OrderServiceTest.java | 130 +++++------------- 5 files changed, 124 insertions(+), 203 deletions(-) diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java index 7f5f46dbe..eea3a6589 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java @@ -4,6 +4,8 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertAll; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -15,7 +17,6 @@ import com.loopers.domain.order.OrderModel; import com.loopers.domain.order.OrderService; import com.loopers.domain.order.OrderStatus; -import com.loopers.domain.order.dto.OrderCommand; import com.loopers.domain.product.ProductErrorCode; import com.loopers.domain.product.ProductModel; import com.loopers.domain.product.ProductService; @@ -62,7 +63,7 @@ private ProductModel createProductWithId(Long brandId, String name, int price, i return product; } - @DisplayName("주문 생성 (UC-O01)") + @DisplayName("주문을 생성할 때 (UC-O01), ") @Nested class CreateOrder { @@ -76,39 +77,22 @@ void createOrder_success() { when(productService.getAllByIds(List.of(10L))).thenReturn(List.of(product)); when(brandService.getNameMapByIds(List.of(brandId))).thenReturn(Map.of(brandId, "브랜드A")); - OrderModel order = OrderModel.create(1L, 25000); - when(orderService.createOrder(any(OrderCommand.Create.class))).thenReturn(order); + OrderModel order = OrderModel.create(1L, List.of( + OrderItemModel.create(10L, 25000, 1, "상품A", "브랜드A"))); + when(orderService.createOrder(anyLong(), anyList())).thenReturn(order); OrderCriteria.Create criteria = new OrderCriteria.Create(List.of( - new OrderCriteria.Create.CreateItem(10L, 1, 25000) - )); + new OrderCriteria.Create.CreateItem(10L, 1, 25000))); // act OrderResult.OrderSummary result = orderFacade.createOrder(1L, criteria); // assert assertAll( - () -> verify(productService).getAllByIds(List.of(10L)), - () -> verify(brandService).getNameMapByIds(List.of(brandId)), - () -> verify(orderService).createOrder(any(OrderCommand.Create.class)), - () -> assertThat(result.totalPrice()).isEqualTo(25000) - ); - } - - @DisplayName("주문 항목이 비어있으면 예외가 발생한다") - @Test - void createOrder_withEmptyItems_throwsException() { - // arrange - OrderCriteria.Create criteria = new OrderCriteria.Create(List.of()); - - when(productService.getAllByIds(List.of())).thenReturn(List.of()); - when(brandService.getNameMapByIds(List.of())).thenReturn(Map.of()); - when(orderService.createOrder(any(OrderCommand.Create.class))) - .thenThrow(new CoreException(OrderErrorCode.EMPTY_ORDER_ITEMS)); - - // act & assert - assertThatThrownBy(() -> orderFacade.createOrder(1L, criteria)) - .isInstanceOf(CoreException.class); + () -> verify(productService).getAllByIds(List.of(10L)), + () -> verify(brandService).getNameMapByIds(List.of(brandId)), + () -> verify(orderService).createOrder(anyLong(), anyList()), + () -> assertThat(result.totalPrice()).isEqualTo(25000)); } @DisplayName("상품이 존재하지 않으면 예외가 발생한다") @@ -116,15 +100,14 @@ void createOrder_withEmptyItems_throwsException() { void createOrder_productNotFound_throwsException() { // arrange when(productService.getAllByIds(List.of(999L))) - .thenThrow(new CoreException(ProductErrorCode.NOT_FOUND)); + .thenThrow(new CoreException(ProductErrorCode.NOT_FOUND)); OrderCriteria.Create criteria = new OrderCriteria.Create(List.of( - new OrderCriteria.Create.CreateItem(999L, 1, 25000) - )); + new OrderCriteria.Create.CreateItem(999L, 1, 25000))); // act & assert assertThatThrownBy(() -> orderFacade.createOrder(1L, criteria)) - .isInstanceOf(CoreException.class); + .isInstanceOf(CoreException.class); } @DisplayName("expectedPrice와 현재 가격 불일치 시 예외가 발생한다") @@ -138,12 +121,11 @@ void createOrder_priceMismatch_throwsException() { when(brandService.getNameMapByIds(List.of(brandId))).thenReturn(Map.of(brandId, "브랜드A")); OrderCriteria.Create criteria = new OrderCriteria.Create(List.of( - new OrderCriteria.Create.CreateItem(10L, 1, 30000) - )); + new OrderCriteria.Create.CreateItem(10L, 1, 30000))); // act & assert assertThatThrownBy(() -> orderFacade.createOrder(1L, criteria)) - .isInstanceOf(CoreException.class); + .isInstanceOf(CoreException.class); } @DisplayName("재고 부족 시 예외가 발생한다") @@ -157,16 +139,15 @@ void createOrder_insufficientStock_throwsException() { when(brandService.getNameMapByIds(List.of(brandId))).thenReturn(Map.of(brandId, "브랜드A")); OrderCriteria.Create criteria = new OrderCriteria.Create(List.of( - new OrderCriteria.Create.CreateItem(10L, 100, 25000) - )); + new OrderCriteria.Create.CreateItem(10L, 100, 25000))); // act & assert assertThatThrownBy(() -> orderFacade.createOrder(1L, criteria)) - .isInstanceOf(CoreException.class); + .isInstanceOf(CoreException.class); } } - @DisplayName("회원 주문 목록 조회 (UC-O02)") + @DisplayName("회원 주문 목록을 조회할 때 (UC-O02), ") @Nested class GetMyOrders { @@ -174,12 +155,13 @@ class GetMyOrders { @Test void getMyOrders_returnsOrders() { // arrange - OrderModel order = OrderModel.create(1L, 50000); + OrderModel order = OrderModel.create(1L, List.of( + OrderItemModel.create(10L, 50000, 1, "상품A", "브랜드A"))); ZonedDateTime startAt = ZonedDateTime.now().minusDays(7); ZonedDateTime endAt = ZonedDateTime.now(); when(orderService.getOrdersByUserIdAndPeriod(1L, startAt, endAt)) - .thenReturn(List.of(order)); + .thenReturn(List.of(order)); OrderCriteria.ListByDate criteria = new OrderCriteria.ListByDate(startAt, endAt); @@ -188,13 +170,12 @@ void getMyOrders_returnsOrders() { // assert assertAll( - () -> assertThat(results).hasSize(1), - () -> assertThat(results.get(0).totalPrice()).isEqualTo(50000) - ); + () -> assertThat(results).hasSize(1), + () -> assertThat(results.get(0).totalPrice()).isEqualTo(50000)); } } - @DisplayName("회원 주문 상세 조회 (UC-O03)") + @DisplayName("회원 주문 상세를 조회할 때 (UC-O03), ") @Nested class GetMyOrderDetail { @@ -202,21 +183,19 @@ class GetMyOrderDetail { @Test void getMyOrderDetail_returnsDetail() { // arrange - OrderModel order = OrderModel.create(1L, 50000); - OrderItemModel item = OrderItemModel.create(1L, 10L, 25000, 2, "상품A", "브랜드A"); + OrderModel order = OrderModel.create(1L, List.of( + OrderItemModel.create(10L, 25000, 2, "상품A", "브랜드A"))); when(orderService.getByIdAndUserId(1L, 1L)).thenReturn(order); - when(orderService.getOrderItemsByOrderId(1L)).thenReturn(List.of(item)); // act OrderResult.OrderDetail result = orderFacade.getMyOrderDetail(1L, 1L); // assert assertAll( - () -> assertThat(result.totalPrice()).isEqualTo(50000), - () -> assertThat(result.items()).hasSize(1), - () -> assertThat(result.items().get(0).productName()).isEqualTo("상품A") - ); + () -> assertThat(result.totalPrice()).isEqualTo(50000), + () -> assertThat(result.items()).hasSize(1), + () -> assertThat(result.items().get(0).productName()).isEqualTo("상품A")); } @DisplayName("본인 주문이 아니면 예외가 발생한다") @@ -224,15 +203,15 @@ void getMyOrderDetail_returnsDetail() { void getMyOrderDetail_notOwner_throwsException() { // arrange when(orderService.getByIdAndUserId(1L, 1L)) - .thenThrow(new CoreException(OrderErrorCode.FORBIDDEN)); + .thenThrow(new CoreException(OrderErrorCode.FORBIDDEN)); // act & assert assertThatThrownBy(() -> orderFacade.getMyOrderDetail(1L, 1L)) - .isInstanceOf(CoreException.class); + .isInstanceOf(CoreException.class); } } - @DisplayName("관리자 주문 조회") + @DisplayName("관리자 주문 조회할 때, ") @Nested class AdminOrders { @@ -240,7 +219,8 @@ class AdminOrders { @Test void getAllOrders_returnsPage() { // arrange - OrderModel order = OrderModel.create(1L, 50000); + OrderModel order = OrderModel.create(1L, List.of( + OrderItemModel.create(10L, 50000, 1, "상품A", "브랜드A"))); Page page = new PageImpl<>(List.of(order), PageRequest.of(0, 10), 1); when(orderService.getAllOrders(any())).thenReturn(page); @@ -250,29 +230,26 @@ void getAllOrders_returnsPage() { // assert assertAll( - () -> assertThat(result.getTotalElements()).isEqualTo(1), - () -> assertThat(result.getContent().get(0).totalPrice()).isEqualTo(50000) - ); + () -> assertThat(result.getTotalElements()).isEqualTo(1), + () -> assertThat(result.getContent().get(0).totalPrice()).isEqualTo(50000)); } @DisplayName("주문 상세를 반환한다 (소유권 검증 없음)") @Test void getOrderDetail_returnsDetail_withoutOwnerCheck() { // arrange - OrderModel order = OrderModel.create(2L, 50000); - OrderItemModel item = OrderItemModel.create(1L, 10L, 25000, 2, "상품A", "브랜드A"); + OrderModel order = OrderModel.create(2L, List.of( + OrderItemModel.create(10L, 25000, 2, "상품A", "브랜드A"))); when(orderService.getById(1L)).thenReturn(order); - when(orderService.getOrderItemsByOrderId(1L)).thenReturn(List.of(item)); // act OrderResult.OrderDetail result = orderFacade.getOrderDetail(1L); // assert assertAll( - () -> assertThat(result.userId()).isEqualTo(2L), - () -> assertThat(result.items()).hasSize(1) - ); + () -> assertThat(result.userId()).isEqualTo(2L), + () -> assertThat(result.items()).hasSize(1)); } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/FakeOrderItemRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/FakeOrderItemRepository.java index 974527b82..a1ca79748 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/FakeOrderItemRepository.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/FakeOrderItemRepository.java @@ -28,7 +28,8 @@ public OrderItemModel save(OrderItemModel orderItemModel) { @Override public List findAllByOrderId(Long orderId) { return store.values().stream() - .filter(item -> item.getOrderId().equals(orderId)) - .toList(); + .filter(item -> item.getOrder() != null + && item.getOrder().getId() == orderId) + .toList(); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java index a579600b7..7c5b324a3 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java @@ -12,41 +12,24 @@ @DisplayName("OrderItemModel 단위 테스트") class OrderItemModelTest { - @DisplayName("생성") + @DisplayName("생성할 때, ") @Nested class Create { - @DisplayName("유효한 값이면 생성에 성공한다 (스냅샷 포함)") + @DisplayName("유효한 값이면 생성에 성공한다 (orderId 없이, 스냅샷 포함)") @Test void create_withValidValues() { // act - OrderItemModel orderItem = OrderItemModel.create(1L, 10L, 25000, 2, "상품A", "브랜드A"); + OrderItemModel orderItem = OrderItemModel.create( + 10L, 25000, 2, "상품A", "브랜드A"); // assert assertAll( - () -> assertThat(orderItem.getOrderId()).isEqualTo(1L), - () -> assertThat(orderItem.getProductId()).isEqualTo(10L), - () -> assertThat(orderItem.getOrderPrice()).isEqualTo(25000), - () -> assertThat(orderItem.getQuantity()).isEqualTo(2), - () -> assertThat(orderItem.getProductName()).isEqualTo("상품A"), - () -> assertThat(orderItem.getBrandName()).isEqualTo("브랜드A") - ); - } - - @DisplayName("orderId가 null이면 예외가 발생한다") - @Test - void create_withNullOrderId_throwsException() { - // act & assert - assertThatThrownBy(() -> OrderItemModel.create(null, 10L, 25000, 2, "상품A", "브랜드A")) - .isInstanceOf(CoreException.class); - } - - @DisplayName("productId가 null이면 예외가 발생한다") - @Test - void create_withNullProductId_throwsException() { - // act & assert - assertThatThrownBy(() -> OrderItemModel.create(1L, null, 25000, 2, "상품A", "브랜드A")) - .isInstanceOf(CoreException.class); + () -> assertThat(orderItem.getProductId()).isEqualTo(10L), + () -> assertThat(orderItem.getOrderPrice()).isEqualTo(25000), + () -> assertThat(orderItem.getQuantity()).isEqualTo(2), + () -> assertThat(orderItem.getProductName()).isEqualTo("상품A"), + () -> assertThat(orderItem.getBrandName()).isEqualTo("브랜드A")); } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java index 92eb48b36..6aad0e18f 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java @@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertAll; import com.loopers.support.error.CoreException; +import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -12,38 +13,63 @@ @DisplayName("OrderModel 단위 테스트") class OrderModelTest { - @DisplayName("생성") + @DisplayName("생성할 때, ") @Nested class Create { - @DisplayName("유효한 값이면 ORDERED 상태로 생성된다") + @DisplayName("유효한 값이면 ORDERED 상태로 생성되고 totalPrice가 items로부터 계산된다") @Test void create_withValidValues() { + // arrange + List items = List.of( + OrderItemModel.create(10L, 25000, 2, "상품A", "브랜드A")); + // act - OrderModel order = OrderModel.create(1L, 50000); + OrderModel order = OrderModel.create(1L, items); // assert assertAll( - () -> assertThat(order.getUserId()).isEqualTo(1L), - () -> assertThat(order.getTotalPrice()).isEqualTo(50000), - () -> assertThat(order.getStatus()).isEqualTo(OrderStatus.ORDERED) - ); + () -> assertThat(order.getUserId()).isEqualTo(1L), + () -> assertThat(order.getTotalPrice()).isEqualTo(50000), + () -> assertThat(order.getStatus()).isEqualTo(OrderStatus.ORDERED), + () -> assertThat(order.getItems()).hasSize(1), + () -> assertThat(order.getItems().get(0).getOrder()).isSameAs(order)); + } + + @DisplayName("items가 비어있으면 예외가 발생한다") + @Test + void create_withEmptyItems_throwsException() { + assertThatThrownBy(() -> OrderModel.create(1L, List.of())) + .isInstanceOf(CoreException.class); } @DisplayName("userId가 null이면 예외가 발생한다") @Test void create_withNullUserId_throwsException() { + // arrange + List items = List.of( + OrderItemModel.create(10L, 25000, 1, "상품A", "브랜드A")); + // act & assert - assertThatThrownBy(() -> OrderModel.create(null, 50000)) - .isInstanceOf(CoreException.class); + assertThatThrownBy(() -> OrderModel.create(null, items)) + .isInstanceOf(CoreException.class); } + } + + @DisplayName("소유자 검증할 때, ") + @Nested + class ValidateOwner { - @DisplayName("totalPrice가 음수이면 예외가 발생한다") + @DisplayName("본인 주문이 아니면 예외가 발생한다") @Test - void create_withNegativeTotalPrice_throwsException() { + void validateOwner_notOwner_throwsException() { + // arrange + OrderModel order = OrderModel.create(1L, List.of( + OrderItemModel.create(10L, 25000, 1, "상품A", "브랜드A"))); + // act & assert - assertThatThrownBy(() -> OrderModel.create(1L, -1)) - .isInstanceOf(CoreException.class); + assertThatThrownBy(() -> order.validateOwner(999L)) + .isInstanceOf(CoreException.class); } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java index 938ab4bf0..d9dc763be 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java @@ -4,7 +4,6 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertAll; -import com.loopers.domain.order.dto.OrderCommand; import com.loopers.support.error.CoreException; import java.util.List; import org.junit.jupiter.api.BeforeEach; @@ -28,64 +27,39 @@ void setUp() { orderService = new OrderService(fakeOrderRepository, fakeOrderItemRepository); } - @DisplayName("주문 생성") + private List createSampleItems() { + return List.of( + OrderItemModel.create(10L, 25000, 2, "상품A", "브랜드A")); + } + + @DisplayName("주문을 생성할 때, ") @Nested class CreateOrder { - @DisplayName("주문 생성 후 저장된다") + @DisplayName("주문이 저장되고 totalPrice가 계산된다") @Test void createOrder_savesOrder() { - // arrange - OrderCommand.Create command = new OrderCommand.Create(1L, List.of( - new OrderCommand.Create.CreateItem(10L, 25000, 2, "상품A", "브랜드A") - )); - - // act - OrderModel order = orderService.createOrder(command); - - // assert - assertAll( - () -> assertThat(order.getId()).isNotEqualTo(0L), - () -> assertThat(order.getUserId()).isEqualTo(1L), - () -> assertThat(order.getTotalPrice()).isEqualTo(50000), - () -> assertThat(order.getStatus()).isEqualTo(OrderStatus.ORDERED) - ); - } - - @DisplayName("주문 항목이 저장된다") - @Test - void createOrder_savesOrderItems() { - // arrange - OrderCommand.Create command = new OrderCommand.Create(1L, List.of( - new OrderCommand.Create.CreateItem(10L, 25000, 2, "상품A", "브랜드A"), - new OrderCommand.Create.CreateItem(20L, 30000, 1, "상품B", "브랜드B") - )); - // act - OrderModel order = orderService.createOrder(command); + OrderModel order = orderService.createOrder(1L, createSampleItems()); // assert - List items = orderService.getOrderItemsByOrderId(order.getId()); assertAll( - () -> assertThat(items).hasSize(2), - () -> assertThat(items.get(0).getProductId()).isEqualTo(10L), - () -> assertThat(items.get(1).getProductId()).isEqualTo(20L) - ); + () -> assertThat(order.getId()).isNotEqualTo(0L), + () -> assertThat(order.getUserId()).isEqualTo(1L), + () -> assertThat(order.getTotalPrice()).isEqualTo(50000), + () -> assertThat(order.getStatus()).isEqualTo(OrderStatus.ORDERED), + () -> assertThat(order.getItems()).hasSize(1)); } @DisplayName("빈 항목이면 예외가 발생한다") @Test void createOrder_withEmptyItems_throwsException() { - // arrange - OrderCommand.Create command = new OrderCommand.Create(1L, List.of()); - - // act & assert - assertThatThrownBy(() -> orderService.createOrder(command)) - .isInstanceOf(CoreException.class); + assertThatThrownBy(() -> orderService.createOrder(1L, List.of())) + .isInstanceOf(CoreException.class); } } - @DisplayName("주문 조회") + @DisplayName("주문을 조회할 때, ") @Nested class GetOrder { @@ -93,10 +67,7 @@ class GetOrder { @Test void getById_returnsOrder() { // arrange - OrderCommand.Create command = new OrderCommand.Create(1L, List.of( - new OrderCommand.Create.CreateItem(10L, 25000, 1, "상품A", "브랜드A") - )); - OrderModel savedOrder = orderService.createOrder(command); + OrderModel savedOrder = orderService.createOrder(1L, createSampleItems()); // act OrderModel foundOrder = orderService.getById(savedOrder.getId()); @@ -108,19 +79,15 @@ void getById_returnsOrder() { @DisplayName("존재하지 않는 주문 조회 시 예외가 발생한다") @Test void getById_notFound_throwsException() { - // act & assert assertThatThrownBy(() -> orderService.getById(999L)) - .isInstanceOf(CoreException.class); + .isInstanceOf(CoreException.class); } @DisplayName("ID + userId로 본인 주문을 조회한다") @Test void getByIdAndUserId_returnsOrder() { // arrange - OrderCommand.Create command = new OrderCommand.Create(1L, List.of( - new OrderCommand.Create.CreateItem(10L, 25000, 1, "상품A", "브랜드A") - )); - OrderModel savedOrder = orderService.createOrder(command); + OrderModel savedOrder = orderService.createOrder(1L, createSampleItems()); // act OrderModel foundOrder = orderService.getByIdAndUserId(savedOrder.getId(), 1L); @@ -133,18 +100,15 @@ void getByIdAndUserId_returnsOrder() { @Test void getByIdAndUserId_notOwner_throwsException() { // arrange - OrderCommand.Create command = new OrderCommand.Create(1L, List.of( - new OrderCommand.Create.CreateItem(10L, 25000, 1, "상품A", "브랜드A") - )); - OrderModel savedOrder = orderService.createOrder(command); + OrderModel savedOrder = orderService.createOrder(1L, createSampleItems()); // act & assert assertThatThrownBy(() -> orderService.getByIdAndUserId(savedOrder.getId(), 999L)) - .isInstanceOf(CoreException.class); + .isInstanceOf(CoreException.class); } } - @DisplayName("유저ID + 기간으로 주문 목록 조회") + @DisplayName("유저ID + 기간으로 주문 목록을 조회할 때, ") @Nested class GetOrdersByUserIdAndPeriod { @@ -152,46 +116,20 @@ class GetOrdersByUserIdAndPeriod { @Test void getOrdersByUserIdAndPeriod_returnsOrders() { // arrange - OrderCommand.Create command = new OrderCommand.Create(1L, List.of( - new OrderCommand.Create.CreateItem(10L, 25000, 1, "상품A", "브랜드A") - )); - orderService.createOrder(command); + orderService.createOrder(1L, createSampleItems()); // act List orders = orderService.getOrdersByUserIdAndPeriod( - 1L, - java.time.ZonedDateTime.now().minusDays(1), - java.time.ZonedDateTime.now().plusDays(1) - ); + 1L, + java.time.ZonedDateTime.now().minusDays(1), + java.time.ZonedDateTime.now().plusDays(1)); // assert assertThat(orders).hasSize(1); } } - @DisplayName("주문ID로 주문 항목 목록 조회") - @Nested - class GetOrderItems { - - @DisplayName("해당 주문의 항목 목록을 반환한다") - @Test - void getOrderItemsByOrderId_returnsItems() { - // arrange - OrderCommand.Create command = new OrderCommand.Create(1L, List.of( - new OrderCommand.Create.CreateItem(10L, 25000, 2, "상품A", "브랜드A") - )); - OrderModel order = orderService.createOrder(command); - - // act - List items = orderService.getOrderItemsByOrderId(order.getId()); - - // assert - assertThat(items).hasSize(1); - assertThat(items.get(0).getProductName()).isEqualTo("상품A"); - } - } - - @DisplayName("전체 주문 페이지네이션 조회") + @DisplayName("전체 주문을 페이지네이션으로 조회할 때, ") @Nested class GetAllOrders { @@ -199,21 +137,17 @@ class GetAllOrders { @Test void getAllOrders_returnsPage() { // arrange - orderService.createOrder(new OrderCommand.Create(1L, List.of( - new OrderCommand.Create.CreateItem(10L, 25000, 1, "상품A", "브랜드A") - ))); - orderService.createOrder(new OrderCommand.Create(2L, List.of( - new OrderCommand.Create.CreateItem(20L, 30000, 1, "상품B", "브랜드B") - ))); + orderService.createOrder(1L, createSampleItems()); + orderService.createOrder(2L, List.of( + OrderItemModel.create(20L, 30000, 1, "상품B", "브랜드B"))); // act Page page = orderService.getAllOrders(PageRequest.of(0, 10)); // assert assertAll( - () -> assertThat(page.getTotalElements()).isEqualTo(2), - () -> assertThat(page.getContent()).hasSize(2) - ); + () -> assertThat(page.getTotalElements()).isEqualTo(2), + () -> assertThat(page.getContent()).hasSize(2)); } } } From c7bf7867cdbc67bdd86eb32e0c82392dcc2c1f2f Mon Sep 17 00:00:00 2001 From: plan11plan Date: Fri, 27 Feb 2026 23:35:29 +0900 Subject: [PATCH 096/108] =?UTF-8?q?docs:=20Order-OrderItem=20=EC=96=91?= =?UTF-8?q?=EB=B0=A9=ED=96=A5=20=EC=97=B0=EA=B4=80=EA=B4=80=EA=B3=84=20?= =?UTF-8?q?=EC=84=A4=EA=B3=84=20=EB=AC=B8=EC=84=9C=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- docs/design/_shared/OVERVIEW.md | 12 +++++++++--- docs/design/order/DESIGN.md | 28 ++++++++++++++++++++++------ 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/docs/design/_shared/OVERVIEW.md b/docs/design/_shared/OVERVIEW.md index 1cbe21948..9fc0a5092 100644 --- a/docs/design/_shared/OVERVIEW.md +++ b/docs/design/_shared/OVERVIEW.md @@ -146,17 +146,23 @@ classDiagram class Order { Long userId + List~OrderItem~ items int totalPrice OrderStatus status + +create(Long userId, List~OrderItem~ items) Order + +addItem(OrderItem item) void + +calculateTotalPrice() int } class OrderItem { - Long orderId + Order order Long productId int orderPrice int quantity String productName String brandName + +create(Long productId, int orderPrice, int quantity, String productName, String brandName) OrderItem + +assignOrder(Order order) void } class OrderStatus { @@ -171,7 +177,7 @@ classDiagram CartItem "*" --> "1" Product : productId Cart o-- CartItem : 일급 컬렉션 Order "*" --> "1" User : userId - OrderItem "*" --> "1" Order : orderId + Order "1" *-- "*" OrderItem : @OneToMany (양방향) Order --> OrderStatus ``` @@ -187,7 +193,7 @@ classDiagram | User → CartItem | 1 : N | ID 참조 (userId) | UNIQUE(userId, productId) | | Product → CartItem | 1 : N | ID 참조 (productId) | 가격 저장 안 함 | | User → Order | 1 : N | ID 참조 (userId) | UserSnapshot 불필요 | -| Order → OrderItem | 1 : N | ID 참조 (orderId) | @OneToMany 미사용 | +| Order ↔ OrderItem | 1 : N | 양방향 `@OneToMany` / `@ManyToOne` | 같은 Aggregate. cascade=PERSIST, @BatchSize(100) | | OrderItem | - | 직접 필드 (productName, brandName) | 주문 시점 스냅샷 | --- diff --git a/docs/design/order/DESIGN.md b/docs/design/order/DESIGN.md index 161c1393f..8baf08921 100644 --- a/docs/design/order/DESIGN.md +++ b/docs/design/order/DESIGN.md @@ -21,8 +21,13 @@ - **동시성 이슈** — 추후 비관적 락 또는 낙관적 락으로 해결 예정. - **가격 변동 검증** — 주문 시점에 클라이언트가 보낸 expectedPrice와 Product의 현재 가격을 비교. 불일치 시 주문 실패 ("가격이 변경되었습니다"). 하이패션 고가 상품의 가격 분쟁 방지. - **두 가지 주문 경로** — 바로구매(상품 페이지에서 직접)와 장바구니 주문. Order 도메인은 출처를 모르고, Facade가 경로를 조율. 주문 로직은 단일. -- **스냅샷 구조** — OrderItem에 직접 필드로 저장 (productName, brandName). productId는 별도 유지 (재구매, 통계용, FK 아님). VO(@Embeddable)를 사용하지 않고 Entity 필드로 직접 관리. -- **Order ↔ OrderItem** — ID 참조 (orderId). @OneToMany 미사용. 같은 Aggregate이지만 프로젝트 전체 ID 참조 패턴과 일관성 유지. +- **스냅샷 구조** — OrderItem에 직접 필드로 저장 (productName, brandName). 필드 3~4개 수준에서 별도 테이블 분리는 불필요. productId는 별도 유지 (재구매, 통계용, FK 아님). VO(@Embeddable)를 사용하지 않고 Entity 필드로 직접 관리. +- **Order ↔ OrderItem** — 양방향 `@OneToMany` / `@ManyToOne` 매핑. 같은 Aggregate 내부이므로 Aggregate Root(Order)가 OrderItem의 생명주기를 직접 관리한다. + - `cascade = CascadeType.PERSIST` — Order 저장 시 OrderItem 함께 저장. REMOVE는 Soft Delete와 충돌하므로 미사용. + - `orphanRemoval = false` — 주문 항목 제거 요구사항 없음 + Soft Delete 정책과 충돌 방지. + - `@BatchSize(size = 100)` — LAZY 기본, 목록 조회 시 N+1 방지. + - **totalPrice** — Order.create() 시점에 items로부터 직접 계산. 도메인 계산 로직은 도메인이 소유한다. + - **OrderItemRepository 유지** — Aggregate Root 통한 접근은 생성/변경에 적용. 조회(통계, 배치, 검색)는 OrderItemRepository 직접 사용 허용. ### API @@ -143,17 +148,24 @@ sequenceDiagram classDiagram class Order { Long userId + List~OrderItem~ items int totalPrice OrderStatus status + +create(Long userId, List~OrderItem~ items) Order + +addItem(OrderItem item) void + +calculateTotalPrice() int + +validateOwner(Long userId) void } class OrderItem { - Long orderId + Order order Long productId int orderPrice int quantity String productName String brandName + +create(Long productId, int orderPrice, int quantity, String productName, String brandName) OrderItem + +assignOrder(Order order) void } class OrderStatus { @@ -162,7 +174,7 @@ classDiagram } Order "*" --> "1" User : userId - OrderItem "*" --> "1" Order : orderId + Order "1" *-- "*" OrderItem : @OneToMany (양방향) Order --> OrderStatus ``` @@ -170,14 +182,18 @@ classDiagram | 엔티티 | 메서드 | 비즈니스 규칙 | |---|---|---| -| OrderItem | create(orderId, productId, orderPrice, quantity, productName, brandName) | 정적 팩토리. 주문 시점 상품 정보를 직접 필드로 저장 | +| Order | create(userId, items) | 정적 팩토리. items를 받아 totalPrice를 직접 계산하고, 양방향 연관관계를 세팅 | +| Order | addItem(item) | 편의 메서드. items 컬렉션에 추가 + item.assignOrder(this)로 양방향 동기화 | +| Order | calculateTotalPrice() | items의 orderPrice × quantity 합산 | +| OrderItem | create(productId, orderPrice, quantity, productName, brandName) | 정적 팩토리. 주문 시점 상품 정보를 직접 필드로 저장. orderId 불필요 (연관관계로 관리) | +| OrderItem | assignOrder(order) | Order 참조 세팅. Order.addItem()에서 호출 | ### 관계 정리 | 관계 | 참조 방식 | 설명 | |---|---|---| | User → Order | ID 참조 (userId) | UserSnapshot 불필요 | -| Order → OrderItem | ID 참조 (orderId) | @OneToMany 미사용. 같은 Aggregate이지만 ID 참조 | +| Order ↔ OrderItem | 양방향 `@OneToMany` / `@ManyToOne` | 같은 Aggregate. cascade=PERSIST, orphanRemoval=false, @BatchSize(100) | | OrderItem 스냅샷 필드 | 직접 필드 (productName, brandName) | 주문 시점 상품 정보를 Entity 필드로 직접 저장 | | OrderItem.productId | ID 유지 (FK 아님) | 재구매, 통계 분석을 위한 데이터 연결용 | From ef44547d705b5505a44092719d38cb4c5d4dacf7 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Fri, 27 Feb 2026 23:58:46 +0900 Subject: [PATCH 097/108] =?UTF-8?q?refactor:=20OrderFacade=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EB=A1=9C=EC=A7=81=EC=9D=84=20ProductServi?= =?UTF-8?q?ce=EB=A1=9C=20=EC=BA=A1=EC=8A=90=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../application/order/OrderFacade.java | 40 ++++++++----------- .../domain/product/ProductService.java | 26 ++++++++++++ .../domain/product/ProductSnapshot.java | 4 ++ .../domain/product/StockDeductionCommand.java | 4 ++ 4 files changed, 50 insertions(+), 24 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSnapshot.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/StockDeductionCommand.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java index d7e28f4ca..d16a25e8c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -4,14 +4,12 @@ import com.loopers.application.order.dto.OrderResult; import com.loopers.domain.brand.BrandService; import com.loopers.domain.order.OrderItemModel; -import com.loopers.domain.order.OrderModel; import com.loopers.domain.order.OrderService; -import com.loopers.domain.product.ProductModel; import com.loopers.domain.product.ProductService; +import com.loopers.domain.product.ProductSnapshot; +import com.loopers.domain.product.StockDeductionCommand; import java.util.List; import java.util.Map; -import java.util.function.Function; -import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -28,31 +26,25 @@ public class OrderFacade { @Transactional public OrderResult.OrderSummary createOrder(Long userId, OrderCriteria.Create criteria) { - List products = productService.getAllByIds(criteria.items().stream() - .map(OrderCriteria.Create.CreateItem::productId) - .toList()); - - Map productMap = products.stream() - .collect(Collectors.toMap(ProductModel::getId, Function.identity())); + List snapshots = productService.validateAndDeductStock( + criteria.items().stream() + .map(item -> new StockDeductionCommand( + item.productId(), item.quantity(), item.expectedPrice())) + .toList()); Map brandNameMap = brandService.getNameMapByIds( - products.stream() - .map(ProductModel::getBrandId) + snapshots.stream() + .map(ProductSnapshot::brandId) .distinct() .toList()); - List items = criteria.items().stream() - .map(item -> { - ProductModel product = productMap.get(item.productId()); - product.validateExpectedPrice(item.expectedPrice()); - product.decreaseStock(item.quantity()); - return OrderItemModel.create( - item.productId(), - product.getPrice(), - item.quantity(), - product.getName(), - brandNameMap.get(product.getBrandId())); - }) + List items = snapshots.stream() + .map(snapshot -> OrderItemModel.create( + snapshot.productId(), + snapshot.price(), + snapshot.quantity(), + snapshot.name(), + brandNameMap.get(snapshot.brandId()))) .toList(); return OrderResult.OrderSummary.from( diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java index ba24258ee..f644f09a1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -2,6 +2,9 @@ import com.loopers.support.error.CoreException; import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -82,4 +85,27 @@ public void validateExists(Long id) { public List getAllByBrandId(Long brandId) { return productRepository.findAllByBrandId(brandId); } + + @Transactional + public List validateAndDeductStock(List commands) { + List products = getAllByIds( + commands.stream().map(StockDeductionCommand::productId).toList()); + + Map productMap = products.stream() + .collect(Collectors.toMap(ProductModel::getId, Function.identity())); + + return commands.stream() + .map(command -> { + ProductModel product = productMap.get(command.productId()); + product.validateExpectedPrice(command.expectedPrice()); + product.decreaseStock(command.quantity()); + return new ProductSnapshot( + command.productId(), + product.getName(), + product.getPrice(), + command.quantity(), + product.getBrandId()); + }) + .toList(); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSnapshot.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSnapshot.java new file mode 100644 index 000000000..c9f94a288 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSnapshot.java @@ -0,0 +1,4 @@ +package com.loopers.domain.product; + +public record ProductSnapshot(Long productId, String name, int price, int quantity, Long brandId) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/StockDeductionCommand.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/StockDeductionCommand.java new file mode 100644 index 000000000..e02d4f28d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/StockDeductionCommand.java @@ -0,0 +1,4 @@ +package com.loopers.domain.product; + +public record StockDeductionCommand(Long productId, int quantity, int expectedPrice) { +} From 00c03afe454129dd7ad9319840a444eaac95eef7 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Fri, 27 Feb 2026 23:59:06 +0900 Subject: [PATCH 098/108] =?UTF-8?q?test:=20ProductService=20=EC=9E=AC?= =?UTF-8?q?=EA=B3=A0=20=EA=B2=80=EC=A6=9D/=EC=B0=A8=EA=B0=90=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20OrderFaca?= =?UTF-8?q?de=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../application/order/OrderFacadeTest.java | 40 +++-------- .../domain/product/ProductServiceTest.java | 72 +++++++++++++++++++ 2 files changed, 82 insertions(+), 30 deletions(-) diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java index eea3a6589..f97d60dfd 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java @@ -16,10 +16,9 @@ import com.loopers.domain.order.OrderItemModel; import com.loopers.domain.order.OrderModel; import com.loopers.domain.order.OrderService; -import com.loopers.domain.order.OrderStatus; import com.loopers.domain.product.ProductErrorCode; -import com.loopers.domain.product.ProductModel; import com.loopers.domain.product.ProductService; +import com.loopers.domain.product.ProductSnapshot; import com.loopers.support.error.CoreException; import java.time.ZonedDateTime; import java.util.List; @@ -51,30 +50,17 @@ class OrderFacadeTest { @InjectMocks private OrderFacade orderFacade; - private ProductModel createProductWithId(Long brandId, String name, int price, int stock, Long id) { - ProductModel product = ProductModel.create(brandId, name, price, stock); - try { - var idField = product.getClass().getSuperclass().getDeclaredField("id"); - idField.setAccessible(true); - idField.set(product, id); - } catch (NoSuchFieldException | IllegalAccessException e) { - throw new RuntimeException(e); - } - return product; - } - @DisplayName("주문을 생성할 때 (UC-O01), ") @Nested class CreateOrder { - @DisplayName("상품 일괄 조회 → 가격 검증 → 재고 차감 → 주문 생성 순서를 수행한다") + @DisplayName("검증+차감 → 브랜드 조회 → 주문 생성 순서를 수행한다") @Test void createOrder_success() { // arrange Long brandId = 1L; - ProductModel product = createProductWithId(brandId, "상품A", 25000, 100, 10L); - - when(productService.getAllByIds(List.of(10L))).thenReturn(List.of(product)); + when(productService.validateAndDeductStock(anyList())).thenReturn(List.of( + new ProductSnapshot(10L, "상품A", 25000, 1, brandId))); when(brandService.getNameMapByIds(List.of(brandId))).thenReturn(Map.of(brandId, "브랜드A")); OrderModel order = OrderModel.create(1L, List.of( @@ -89,7 +75,7 @@ void createOrder_success() { // assert assertAll( - () -> verify(productService).getAllByIds(List.of(10L)), + () -> verify(productService).validateAndDeductStock(anyList()), () -> verify(brandService).getNameMapByIds(List.of(brandId)), () -> verify(orderService).createOrder(anyLong(), anyList()), () -> assertThat(result.totalPrice()).isEqualTo(25000)); @@ -99,7 +85,7 @@ void createOrder_success() { @Test void createOrder_productNotFound_throwsException() { // arrange - when(productService.getAllByIds(List.of(999L))) + when(productService.validateAndDeductStock(anyList())) .thenThrow(new CoreException(ProductErrorCode.NOT_FOUND)); OrderCriteria.Create criteria = new OrderCriteria.Create(List.of( @@ -114,11 +100,8 @@ void createOrder_productNotFound_throwsException() { @Test void createOrder_priceMismatch_throwsException() { // arrange - Long brandId = 1L; - ProductModel product = createProductWithId(brandId, "상품A", 25000, 100, 10L); - - when(productService.getAllByIds(List.of(10L))).thenReturn(List.of(product)); - when(brandService.getNameMapByIds(List.of(brandId))).thenReturn(Map.of(brandId, "브랜드A")); + when(productService.validateAndDeductStock(anyList())) + .thenThrow(new CoreException(ProductErrorCode.PRICE_MISMATCH)); OrderCriteria.Create criteria = new OrderCriteria.Create(List.of( new OrderCriteria.Create.CreateItem(10L, 1, 30000))); @@ -132,11 +115,8 @@ void createOrder_priceMismatch_throwsException() { @Test void createOrder_insufficientStock_throwsException() { // arrange - Long brandId = 1L; - ProductModel product = createProductWithId(brandId, "상품A", 25000, 1, 10L); - - when(productService.getAllByIds(List.of(10L))).thenReturn(List.of(product)); - when(brandService.getNameMapByIds(List.of(brandId))).thenReturn(Map.of(brandId, "브랜드A")); + when(productService.validateAndDeductStock(anyList())) + .thenThrow(new CoreException(ProductErrorCode.NOT_FOUND)); OrderCriteria.Create criteria = new OrderCriteria.Create(List.of( new OrderCriteria.Create.CreateItem(10L, 100, 25000))); 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 index c83bf8852..6443608b3 100644 --- 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 @@ -2,8 +2,10 @@ 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 com.loopers.support.error.CoreException; +import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -211,4 +213,74 @@ void deleteAllByBrandId_whenProductsExist() { assertThat(result.getTotalElements()).isEqualTo(0); } } + + @DisplayName("상품 검증 및 재고 차감을 할 때, ") + @Nested + class ValidateAndDeductStock { + + @DisplayName("유효한 커맨드가 주어지면, 스냅샷을 반환하고 재고가 차감된다.") + @Test + void validateAndDeductStock_success() { + // arrange + productService.register(BRAND_ID, "에어맥스", 150000, 100); + Long productId = productRepository.findAll(PageRequest.of(0, 20)) + .getContent().get(0).getId(); + + List commands = List.of( + new StockDeductionCommand(productId, 2, 150000)); + + // act + List snapshots = productService.validateAndDeductStock(commands); + + // assert + assertAll( + () -> assertThat(snapshots).hasSize(1), + () -> assertThat(snapshots.get(0).productId()).isEqualTo(productId), + () -> assertThat(snapshots.get(0).name()).isEqualTo("에어맥스"), + () -> assertThat(snapshots.get(0).price()).isEqualTo(150000), + () -> assertThat(snapshots.get(0).quantity()).isEqualTo(2), + () -> assertThat(snapshots.get(0).brandId()).isEqualTo(BRAND_ID), + () -> assertThat(productService.getById(productId).getStock()).isEqualTo(98)); + } + + @DisplayName("존재하지 않는 상품 ID가 포함되면, NOT_FOUND 예외가 발생한다.") + @Test + void validateAndDeductStock_whenProductNotFound() { + assertThatThrownBy(() -> productService.validateAndDeductStock(List.of( + new StockDeductionCommand(999L, 1, 50000)))) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorCode()) + .isEqualTo(ProductErrorCode.NOT_FOUND)); + } + + @DisplayName("expectedPrice와 현재 가격이 불일치하면, PRICE_MISMATCH 예외가 발생한다.") + @Test + void validateAndDeductStock_whenPriceMismatch() { + // arrange + productService.register(BRAND_ID, "에어맥스", 150000, 100); + Long productId = productRepository.findAll(PageRequest.of(0, 20)) + .getContent().get(0).getId(); + + // act & assert + assertThatThrownBy(() -> productService.validateAndDeductStock(List.of( + new StockDeductionCommand(productId, 1, 200000)))) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorCode()) + .isEqualTo(ProductErrorCode.PRICE_MISMATCH)); + } + + @DisplayName("재고가 부족하면, 예외가 발생한다.") + @Test + void validateAndDeductStock_whenInsufficientStock() { + // arrange + productService.register(BRAND_ID, "에어맥스", 150000, 5); + Long productId = productRepository.findAll(PageRequest.of(0, 20)) + .getContent().get(0).getId(); + + // act & assert + assertThatThrownBy(() -> productService.validateAndDeductStock(List.of( + new StockDeductionCommand(productId, 10, 150000)))) + .isInstanceOf(CoreException.class); + } + } } From 6b707fc1124b1db439536858c0df822ea2ff610e Mon Sep 17 00:00:00 2001 From: plan11plan Date: Sat, 28 Feb 2026 00:58:13 +0900 Subject: [PATCH 099/108] =?UTF-8?q?docs:=20=EC=A3=BC=EB=AC=B8=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=ED=85=9C=20=EC=B7=A8=EC=86=8C=20=EC=84=A4=EA=B3=84=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- docs/design/_shared/CONVENTIONS.md | 3 +- docs/design/_shared/OVERVIEW.md | 30 ++++++- docs/design/order/DESIGN.md | 134 ++++++++++++++++++++++++++--- 3 files changed, 151 insertions(+), 16 deletions(-) diff --git a/docs/design/_shared/CONVENTIONS.md b/docs/design/_shared/CONVENTIONS.md index 14040de8a..c4a6cf6c6 100644 --- a/docs/design/_shared/CONVENTIONS.md +++ b/docs/design/_shared/CONVENTIONS.md @@ -89,5 +89,4 @@ JPA 양방향 매핑에 따르는 추가 UPDATE 쿼리 등 성능 오버헤드 | 유저(Users) 기능 | 회원가입, 내 정보 조회, 비밀번호 변경은 이미 구현 완료 | | 결제(Payment) | 향후 별도 단계에서 추가 개발 예정 | | 쿠폰(Coupon) | 향후 별도 단계에서 추가 개발 예정 | -| 주문 상태 전이 | 결제 기능과 함께 추가. 현재는 주문 생성(ORDERED)만 | -| 주문 취소 | 현재 범위에서 제공하지 않음 | +| 주문 상태 전이 (결제 연동) | 결제 기능과 함께 추가. 현재는 ORDERED / CANCELLED만 | diff --git a/docs/design/_shared/OVERVIEW.md b/docs/design/_shared/OVERVIEW.md index 9fc0a5092..d7482ea64 100644 --- a/docs/design/_shared/OVERVIEW.md +++ b/docs/design/_shared/OVERVIEW.md @@ -62,6 +62,7 @@ erDiagram bigint id PK bigint user_id int total_price + int original_total_price varchar status timestamp created_at timestamp updated_at @@ -77,6 +78,7 @@ erDiagram varchar image_url int order_price int quantity + varchar status timestamp created_at timestamp updated_at timestamp deleted_at @@ -148,26 +150,44 @@ classDiagram Long userId List~OrderItem~ items int totalPrice + int originalTotalPrice OrderStatus status +create(Long userId, List~OrderItem~ items) Order +addItem(OrderItem item) void - +calculateTotalPrice() int + +cancelItem(Long orderItemId) OrderItem + +recalculateTotalPrice() void + +validateOwner(Long userId) void } class OrderItem { Order order Long productId + ProductSnapshot productSnapshot int orderPrice int quantity + OrderItemStatus status + +create(Long productId, int orderPrice, int quantity, ProductSnapshot snapshot) OrderItem + +cancel() void + +assignOrder(Order order) void + } + + class ProductSnapshot { + <> String productName String brandName - +create(Long productId, int orderPrice, int quantity, String productName, String brandName) OrderItem - +assignOrder(Order order) void + String imageUrl } class OrderStatus { <> ORDERED + CANCELLED + } + + class OrderItemStatus { + <> + ORDERED + CANCELLED } Product "*" --> "1" Brand : ID 참조 (brandId) @@ -179,6 +199,8 @@ classDiagram Order "*" --> "1" User : userId Order "1" *-- "*" OrderItem : @OneToMany (양방향) Order --> OrderStatus + OrderItem *-- ProductSnapshot : @Embedded + OrderItem --> OrderItemStatus ``` --- @@ -194,7 +216,7 @@ classDiagram | Product → CartItem | 1 : N | ID 참조 (productId) | 가격 저장 안 함 | | User → Order | 1 : N | ID 참조 (userId) | UserSnapshot 불필요 | | Order ↔ OrderItem | 1 : N | 양방향 `@OneToMany` / `@ManyToOne` | 같은 Aggregate. cascade=PERSIST, @BatchSize(100) | -| OrderItem | - | 직접 필드 (productName, brandName) | 주문 시점 스냅샷 | +| OrderItem → ProductSnapshot | - | `@Embedded` (`@Embeddable` VO) | 주문 시점 스냅샷을 개념 단위로 그룹핑 | --- diff --git a/docs/design/order/DESIGN.md b/docs/design/order/DESIGN.md index 8baf08921..9f4103438 100644 --- a/docs/design/order/DESIGN.md +++ b/docs/design/order/DESIGN.md @@ -10,7 +10,11 @@ > 주문 시 상품 재고가 확인되고 차감된다. > 주문 후에도 당시 상품 정보(가격, 이름 등)를 확인할 수 있다. > +> **회원으로서**, 주문한 아이템을 개별적으로 취소할 수 있다. +> 취소 시 해당 상품의 재고가 복구된다. +> > **관리자로서**, 전체 주문 내역을 조회할 수 있다. +> **관리자로서**, 주문 아이템을 취소할 수 있다. ### 예외 및 정책 @@ -21,13 +25,19 @@ - **동시성 이슈** — 추후 비관적 락 또는 낙관적 락으로 해결 예정. - **가격 변동 검증** — 주문 시점에 클라이언트가 보낸 expectedPrice와 Product의 현재 가격을 비교. 불일치 시 주문 실패 ("가격이 변경되었습니다"). 하이패션 고가 상품의 가격 분쟁 방지. - **두 가지 주문 경로** — 바로구매(상품 페이지에서 직접)와 장바구니 주문. Order 도메인은 출처를 모르고, Facade가 경로를 조율. 주문 로직은 단일. -- **스냅샷 구조** — OrderItem에 직접 필드로 저장 (productName, brandName). 필드 3~4개 수준에서 별도 테이블 분리는 불필요. productId는 별도 유지 (재구매, 통계용, FK 아님). VO(@Embeddable)를 사용하지 않고 Entity 필드로 직접 관리. +- **스냅샷 구조** — `ProductSnapshot` `@Embeddable` VO로 그룹핑 (productName, brandName, imageUrl). 도메인 성장에 따라 스냅샷 필드가 늘어날 수 있으므로 개념 단위로 묶는다. productId는 스냅샷 외부에 별도 유지 (재구매, 통계용, FK 아님). - **Order ↔ OrderItem** — 양방향 `@OneToMany` / `@ManyToOne` 매핑. 같은 Aggregate 내부이므로 Aggregate Root(Order)가 OrderItem의 생명주기를 직접 관리한다. - `cascade = CascadeType.PERSIST` — Order 저장 시 OrderItem 함께 저장. REMOVE는 Soft Delete와 충돌하므로 미사용. - `orphanRemoval = false` — 주문 항목 제거 요구사항 없음 + Soft Delete 정책과 충돌 방지. - `@BatchSize(size = 100)` — LAZY 기본, 목록 조회 시 N+1 방지. - - **totalPrice** — Order.create() 시점에 items로부터 직접 계산. 도메인 계산 로직은 도메인이 소유한다. + - **totalPrice / originalTotalPrice** — Order.create() 시점에 items로부터 직접 계산. originalTotalPrice는 생성 시점의 금액을 보존(불변). totalPrice는 아이템 취소 시 남은 ORDERED 아이템 기준으로 재계산. - **OrderItemRepository 유지** — Aggregate Root 통한 접근은 생성/변경에 적용. 조회(통계, 배치, 검색)는 OrderItemRepository 직접 사용 허용. +- **주문 아이템 취소** — 아이템 단위로 개별 취소. 한 번에 하나씩만 취소 가능. + - **취소 가능 조건** — Order가 ORDERED 상태이고, 해당 OrderItem이 ORDERED 상태일 때만 취소 가능. + - **OrderItemStatus** — ORDERED, CANCELLED. 아이템별 개별 상태 관리. + - **전체 아이템 취소 시** — 모든 아이템이 CANCELLED이면 Order도 자동으로 CANCELLED 전이. + - **재고 복구** — 취소된 아이템의 quantity만큼 Product 재고 복구. Facade에서 Order↔Product 조율. + - **취소 액터** — 회원 본인(소유자 검증) + Admin(소유자 검증 없음). ### API @@ -38,6 +48,8 @@ | 주문 상세 조회 | 회원 | GET | `/api/v1/orders/{orderId}` | O | | 주문 목록 조회 | Admin | GET | `/api-admin/v1/orders?page=0&size=20` | LDAP | | 주문 상세 조회 | Admin | GET | `/api-admin/v1/orders/{orderId}` | LDAP | +| 아이템 취소 | 회원 | PATCH | `/api/v1/orders/{orderId}/items/{orderItemId}/cancel` | O | +| 아이템 취소 | Admin | PATCH | `/api-admin/v1/orders/{orderId}/items/{orderItemId}/cancel` | LDAP | ### 주문 요청 본문 예시 @@ -109,9 +121,59 @@ - 상품 정보는 스냅샷 기준 (현재 상품 상태와 무관) ``` +**UC-O04: 주문 아이템 취소 (회원)** + +``` +[기능 흐름] +1. 회원이 orderId, orderItemId로 아이템 취소를 요청한다 +2. 주문이 존재하는지 확인한다 +3. 본인의 주문인지 확인한다 +4. 주문이 ORDERED 상태인지 확인한다 +5. 해당 아이템이 ORDERED 상태인지 확인한다 +6. 아이템을 CANCELLED로 변경한다 +7. totalPrice를 남은 ORDERED 아이템 기준으로 재계산한다 +8. 모든 아이템이 CANCELLED이면 Order도 CANCELLED로 변경한다 +9. 해당 상품의 재고를 복구한다 (quantity만큼) + +[예외] +- 주문이 존재하지 않으면 404 +- 본인의 주문이 아니면 접근 불가 +- 주문이 이미 CANCELLED이면 취소 불가 +- 아이템이 이미 CANCELLED이면 취소 불가 + +[조건] +- 로그인한 회원만 가능 +- 한 번에 아이템 하나만 취소 +``` + +**UC-O05: 주문 아이템 취소 (Admin)** + +``` +[기능 흐름] +1. Admin이 orderId, orderItemId로 아이템 취소를 요청한다 +2. 주문이 존재하는지 확인한다 +3. 주문이 ORDERED 상태인지 확인한다 +4. 해당 아이템이 ORDERED 상태인지 확인한다 +5. 아이템을 CANCELLED로 변경한다 +6. totalPrice를 남은 ORDERED 아이템 기준으로 재계산한다 +7. 모든 아이템이 CANCELLED이면 Order도 CANCELLED로 변경한다 +8. 해당 상품의 재고를 복구한다 (quantity만큼) + +[예외] +- 주문이 존재하지 않으면 404 +- 주문이 이미 CANCELLED이면 취소 불가 +- 아이템이 이미 CANCELLED이면 취소 불가 + +[조건] +- LDAP 인증 필요 +- 소유자 검증 없음 +``` + --- -## 시퀀스 다이어그램: 주문 요청 +## 시퀀스 다이어그램 + +### 주문 요청 > 주문은 **Product 도메인 (상품 검증 + 재고 차감) + Order 도메인 (주문 생성 + 스냅샷)**을 조율해야 하므로 Facade가 필요하다. @@ -140,6 +202,34 @@ sequenceDiagram OF-->>OC: 주문 생성 완료 ``` +### 주문 아이템 취소 + +> 취소는 **Order 도메인 (상태 전이 + totalPrice 재계산) + Product 도메인 (재고 복구)**을 조율해야 하므로 Facade가 필요하다. + +```mermaid +sequenceDiagram + participant OC as OrderController + participant OF as OrderFacade + participant OS as OrderService + participant PS as ProductService + + Note left of OC: PATCH /api/v1/orders/{orderId}
/items/{orderItemId}/cancel + OC->>OF: 아이템 취소 요청 (orderId, orderItemId, userId) + OF->>OS: 주문 조회 + 소유자 검증 + OS-->>OF: Order (with items) + + Note over OS: 주문 상태 검증 (ORDERED인지)
아이템 상태 검증 (ORDERED인지) + + OF->>OS: 아이템 취소 (cancelItem) + Note over OS: item.cancel()
totalPrice 재계산
전체 CANCELLED 시 Order도 CANCELLED + OS-->>OF: 취소된 OrderItem 정보 (productId, quantity) + + OF->>PS: 재고 복구 (productId, quantity) + PS-->>OF: 완료 + + OF-->>OC: 취소 완료 +``` + --- ## 클래스 설계 @@ -150,42 +240,64 @@ classDiagram Long userId List~OrderItem~ items int totalPrice + int originalTotalPrice OrderStatus status +create(Long userId, List~OrderItem~ items) Order +addItem(OrderItem item) void - +calculateTotalPrice() int + +cancelItem(Long orderItemId) OrderItem + +recalculateTotalPrice() void +validateOwner(Long userId) void } class OrderItem { Order order Long productId + ProductSnapshot productSnapshot int orderPrice int quantity + OrderItemStatus status + +create(Long productId, int orderPrice, int quantity, ProductSnapshot snapshot) OrderItem + +cancel() void + +assignOrder(Order order) void + } + + class ProductSnapshot { + <> String productName String brandName - +create(Long productId, int orderPrice, int quantity, String productName, String brandName) OrderItem - +assignOrder(Order order) void + String imageUrl } class OrderStatus { <> ORDERED + CANCELLED + } + + class OrderItemStatus { + <> + ORDERED + CANCELLED } Order "*" --> "1" User : userId Order "1" *-- "*" OrderItem : @OneToMany (양방향) Order --> OrderStatus + OrderItem *-- ProductSnapshot : @Embedded + OrderItem --> OrderItemStatus ``` ### 비즈니스 규칙 | 엔티티 | 메서드 | 비즈니스 규칙 | |---|---|---| -| Order | create(userId, items) | 정적 팩토리. items를 받아 totalPrice를 직접 계산하고, 양방향 연관관계를 세팅 | +| Order | create(userId, items) | 정적 팩토리. items를 받아 totalPrice와 originalTotalPrice를 직접 계산하고, 양방향 연관관계를 세팅 | | Order | addItem(item) | 편의 메서드. items 컬렉션에 추가 + item.assignOrder(this)로 양방향 동기화 | -| Order | calculateTotalPrice() | items의 orderPrice × quantity 합산 | -| OrderItem | create(productId, orderPrice, quantity, productName, brandName) | 정적 팩토리. 주문 시점 상품 정보를 직접 필드로 저장. orderId 불필요 (연관관계로 관리) | +| Order | cancelItem(orderItemId) | 아이템을 찾아 cancel() 호출. totalPrice 재계산. 전체 CANCELLED 시 Order도 CANCELLED. 취소된 OrderItem 반환 (재고 복구용) | +| Order | recalculateTotalPrice() | ORDERED 상태인 items의 orderPrice × quantity 합산으로 totalPrice 갱신 | +| Order | validateOwner(userId) | 본인 주문인지 검증 | +| OrderItem | create(productId, orderPrice, quantity, snapshot) | 정적 팩토리. 주문 시점 상품 정보를 ProductSnapshot으로 저장. status는 ORDERED | +| OrderItem | cancel() | status를 CANCELLED로 변경. 이미 CANCELLED이면 예외 | | OrderItem | assignOrder(order) | Order 참조 세팅. Order.addItem()에서 호출 | ### 관계 정리 @@ -194,7 +306,7 @@ classDiagram |---|---|---| | User → Order | ID 참조 (userId) | UserSnapshot 불필요 | | Order ↔ OrderItem | 양방향 `@OneToMany` / `@ManyToOne` | 같은 Aggregate. cascade=PERSIST, orphanRemoval=false, @BatchSize(100) | -| OrderItem 스냅샷 필드 | 직접 필드 (productName, brandName) | 주문 시점 상품 정보를 Entity 필드로 직접 저장 | +| OrderItem → ProductSnapshot | `@Embedded` (`@Embeddable` VO) | 주문 시점 상품 정보를 개념 단위로 그룹핑. 테이블은 order_items에 그대로 저장 | | OrderItem.productId | ID 유지 (FK 아님) | 재구매, 통계 분석을 위한 데이터 연결용 | --- @@ -207,6 +319,7 @@ erDiagram bigint id PK bigint user_id int total_price + int original_total_price varchar status timestamp created_at timestamp updated_at @@ -222,6 +335,7 @@ erDiagram varchar image_url int order_price int quantity + varchar status timestamp created_at timestamp updated_at timestamp deleted_at From 20503c709eae382d9e0cd217914bd053b16117d1 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Sat, 28 Feb 2026 00:58:17 +0900 Subject: [PATCH 100/108] =?UTF-8?q?docs:=20@Embeddable=20=EA=B0=9C?= =?UTF-8?q?=EB=85=90=20=EA=B7=B8=EB=A3=B9=ED=95=91=20=EC=BB=A8=EB=B2=A4?= =?UTF-8?q?=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../references/domain/entity-vo-convention.md | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/.claude/skills/project-convention/references/domain/entity-vo-convention.md b/.claude/skills/project-convention/references/domain/entity-vo-convention.md index 5368f091f..8b4421b40 100644 --- a/.claude/skills/project-convention/references/domain/entity-vo-convention.md +++ b/.claude/skills/project-convention/references/domain/entity-vo-convention.md @@ -211,6 +211,47 @@ record는 불변, equals/hashCode/toString 자동 생성. compact constructor에 **주의**: common이 비대해지지 않게 진입 기준을 엄격히 적용한다. +### @Embeddable 개념 그룹핑 + +Entity 내에서 관련 필드가 하나의 개념 단위를 이룰 때 `@Embeddable`로 그룹핑한다. 행위 중심의 record VO와는 목적이 다르다. + +**사용 기준 — 아래 세 가지를 모두 충족할 때:** + +1. **3개 이상**의 필드가 하나의 개념을 표현 (예: 스냅샷, 주소, 좌표) +2. 해당 필드들이 항상 **함께 생성**되고 **함께 조회**됨 +3. 도메인 성장에 따라 필드가 **늘어날 가능성**이 있음 + +**규칙:** + +- 행위 없이 **데이터 그룹핑만** 담당 (행위가 필요하면 Entity 메서드에서 처리) +- 같은 Entity에 같은 타입 2개 사용 금지 (`@AttributeOverride` 보일러플레이트 방지) +- 클래스명은 `{개념}Snapshot`, `{개념}Info` 등 역할이 드러나는 이름 사용 + +```java +// ✅ @Embeddable 그룹핑 — 스냅샷 필드 3개 이상, 함께 생성/조회, 확장 가능성 +@Embeddable +public class ProductSnapshot { + + @Column(name = "product_name", nullable = false) + private String productName; + + @Column(name = "brand_name", nullable = false) + private String brandName; + + @Column(name = "image_url") + private String imageUrl; + + protected ProductSnapshot() { + } + + public ProductSnapshot(String productName, String brandName, String imageUrl) { + this.productName = productName; + this.brandName = brandName; + this.imageUrl = imageUrl; + } +} +``` + --- ## 3. 검증 위치 규칙 From 3c2cfb783f9567f7e363e99ab9912040a597e969 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Sat, 28 Feb 2026 00:58:24 +0900 Subject: [PATCH 101/108] =?UTF-8?q?feat:=20=EC=A3=BC=EB=AC=B8=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=ED=85=9C=20=EC=B7=A8=EC=86=8C=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../application/order/OrderFacade.java | 13 +++++++ .../loopers/domain/order/OrderErrorCode.java | 5 ++- .../loopers/domain/order/OrderItemModel.java | 14 ++++++++ .../loopers/domain/order/OrderItemStatus.java | 6 ++++ .../com/loopers/domain/order/OrderModel.java | 36 ++++++++++++++++++- .../loopers/domain/order/OrderService.java | 6 ++++ .../com/loopers/domain/order/OrderStatus.java | 3 +- .../loopers/domain/product/ProductModel.java | 7 ++++ .../domain/product/ProductService.java | 5 +++ 9 files changed, 92 insertions(+), 3 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemStatus.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java index d16a25e8c..1c6f57736 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -74,4 +74,17 @@ public Page getAllOrders(Pageable pageable) { public OrderResult.OrderDetail getOrderDetail(Long orderId) { return OrderResult.OrderDetail.from(orderService.getById(orderId)); } + + @Transactional + public void cancelMyOrderItem(Long userId, Long orderId, Long orderItemId) { + orderService.getByIdAndUserId(orderId, userId); + OrderItemModel cancelledItem = orderService.cancelItem(orderId, orderItemId); + productService.increaseStock(cancelledItem.getProductId(), cancelledItem.getQuantity()); + } + + @Transactional + public void cancelOrderItem(Long orderId, Long orderItemId) { + OrderItemModel cancelledItem = orderService.cancelItem(orderId, orderItemId); + productService.increaseStock(cancelledItem.getProductId(), cancelledItem.getQuantity()); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderErrorCode.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderErrorCode.java index 9b6afd379..76106371b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderErrorCode.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderErrorCode.java @@ -10,7 +10,10 @@ public enum OrderErrorCode implements ErrorCode { NOT_FOUND(HttpStatus.NOT_FOUND, "ORDER_001", "주문을 찾을 수 없습니다."), EMPTY_ORDER_ITEMS(HttpStatus.BAD_REQUEST, "ORDER_002", "주문 항목이 비어있습니다."), - FORBIDDEN(HttpStatus.FORBIDDEN, "ORDER_003", "본인의 주문만 조회할 수 있습니다."); + FORBIDDEN(HttpStatus.FORBIDDEN, "ORDER_003", "본인의 주문만 조회할 수 있습니다."), + ALREADY_CANCELLED_ITEM(HttpStatus.BAD_REQUEST, "ORDER_004", "이미 취소된 주문 항목입니다."), + ALREADY_CANCELLED_ORDER(HttpStatus.BAD_REQUEST, "ORDER_005", "이미 취소된 주문입니다."), + ORDER_ITEM_NOT_FOUND(HttpStatus.NOT_FOUND, "ORDER_006", "주문 항목을 찾을 수 없습니다."); private final HttpStatus status; private final String code; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java index 12cffd873..b7e6b92a3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java @@ -5,6 +5,8 @@ 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.FetchType; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; @@ -38,6 +40,10 @@ public class OrderItemModel extends BaseEntity { @Column(name = "brand_name", nullable = false) private String brandName; + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private OrderItemStatus status; + private OrderItemModel(Long productId, int orderPrice, int quantity, String productName, String brandName) { this.productId = productId; @@ -45,6 +51,7 @@ private OrderItemModel(Long productId, int orderPrice, int quantity, this.quantity = quantity; this.productName = productName; this.brandName = brandName; + this.status = OrderItemStatus.ORDERED; } public static OrderItemModel create(Long productId, int orderPrice, int quantity, @@ -57,6 +64,13 @@ public static OrderItemModel create(Long productId, int orderPrice, int quantity return new OrderItemModel(productId, orderPrice, quantity, productName, brandName); } + public void cancel() { + if (this.status == OrderItemStatus.CANCELLED) { + throw new CoreException(OrderErrorCode.ALREADY_CANCELLED_ITEM); + } + this.status = OrderItemStatus.CANCELLED; + } + void assignOrder(OrderModel order) { this.order = order; } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemStatus.java new file mode 100644 index 000000000..01e96eb76 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemStatus.java @@ -0,0 +1,6 @@ +package com.loopers.domain.order; + +public enum OrderItemStatus { + ORDERED, + CANCELLED +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java index 9f5d1e5a9..09197f6b9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java @@ -29,6 +29,9 @@ public class OrderModel extends BaseEntity { @Column(name = "total_price", nullable = false) private int totalPrice; + @Column(name = "original_total_price", nullable = false) + private int originalTotalPrice; + @Enumerated(EnumType.STRING) @Column(name = "status", nullable = false) private OrderStatus status; @@ -40,6 +43,7 @@ public class OrderModel extends BaseEntity { private OrderModel(Long userId, int totalPrice, OrderStatus status) { this.userId = userId; this.totalPrice = totalPrice; + this.originalTotalPrice = totalPrice; this.status = status; } @@ -48,7 +52,9 @@ public static OrderModel create(Long userId, List items) { validateItems(items); OrderModel order = new OrderModel(userId, 0, OrderStatus.ORDERED); items.forEach(order::addItem); - order.totalPrice = order.calculateTotalPrice(); + int calculatedPrice = order.calculateTotalPrice(); + order.totalPrice = calculatedPrice; + order.originalTotalPrice = calculatedPrice; return order; } @@ -57,12 +63,40 @@ public void addItem(OrderItemModel item) { item.assignOrder(this); } + public OrderItemModel cancelItem(Long orderItemId) { + if (this.status == OrderStatus.CANCELLED) { + throw new CoreException(OrderErrorCode.ALREADY_CANCELLED_ORDER); + } + OrderItemModel item = items.stream() + .filter(i -> i.getId().equals(orderItemId)) + .findFirst() + .orElseThrow(() -> new CoreException(OrderErrorCode.ORDER_ITEM_NOT_FOUND)); + item.cancel(); + recalculateTotalPrice(); + if (isAllItemsCancelled()) { + this.status = OrderStatus.CANCELLED; + } + return item; + } + + private boolean isAllItemsCancelled() { + return items.stream() + .allMatch(item -> item.getStatus() == OrderItemStatus.CANCELLED); + } + public int calculateTotalPrice() { return items.stream() .mapToInt(item -> item.getOrderPrice() * item.getQuantity()) .sum(); } + public void recalculateTotalPrice() { + this.totalPrice = items.stream() + .filter(item -> item.getStatus() == OrderItemStatus.ORDERED) + .mapToInt(item -> item.getOrderPrice() * item.getQuantity()) + .sum(); + } + public void validateOwner(Long userId) { if (!userId.equals(this.userId)) { throw new CoreException(OrderErrorCode.FORBIDDEN); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java index a068a0706..6cd53bfbb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -39,6 +39,12 @@ public List getOrdersByUserIdAndPeriod(Long userId, ZonedDateTime st return orderRepository.findAllByUserIdAndCreatedAtBetween(userId, startAt, endAt); } + @Transactional + public OrderItemModel cancelItem(Long orderId, Long orderItemId) { + OrderModel order = getById(orderId); + return order.cancelItem(orderItemId); + } + @Transactional(readOnly = true) public List getOrderItemsByOrderId(Long orderId) { return orderItemRepository.findAllByOrderId(orderId); 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 index 6dab40e1f..b2d11834f 100644 --- 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 @@ -1,5 +1,6 @@ package com.loopers.domain.order; public enum OrderStatus { - ORDERED + ORDERED, + CANCELLED } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java index ef7323aad..1baeec6db 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java @@ -72,6 +72,13 @@ public void decreaseStock(int quantity) { this.stock -= quantity; } + public void increaseStock(int quantity) { + if (quantity < 1) { + throw new CoreException(ErrorType.BAD_REQUEST, "복구 수량은 1 이상이어야 합니다."); + } + this.stock += quantity; + } + public boolean isSoldOut() { return this.stock == 0; } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java index f644f09a1..ded3430f6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -86,6 +86,11 @@ public List getAllByBrandId(Long brandId) { return productRepository.findAllByBrandId(brandId); } + @Transactional + public void increaseStock(Long productId, int quantity) { + getById(productId).increaseStock(quantity); + } + @Transactional public List validateAndDeductStock(List commands) { List products = getAllByIds( From 69e96f5a4f0f0b6ba7781647c7d8a73e465d4406 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Sat, 28 Feb 2026 00:58:35 +0900 Subject: [PATCH 102/108] =?UTF-8?q?test:=20=EC=A3=BC=EB=AC=B8=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=ED=85=9C=20=EC=B7=A8=EC=86=8C=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../application/order/OrderFacadeTest.java | 38 ++++++++ .../domain/order/OrderItemModelTest.java | 32 +++++++ .../loopers/domain/order/OrderModelTest.java | 86 +++++++++++++++++++ .../domain/order/OrderServiceTest.java | 22 +++++ .../domain/product/ProductModelTest.java | 18 ++++ 5 files changed, 196 insertions(+) diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java index f97d60dfd..da958f3cb 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java @@ -232,4 +232,42 @@ void getOrderDetail_returnsDetail_withoutOwnerCheck() { () -> assertThat(result.items()).hasSize(1)); } } + + @DisplayName("회원 아이템 취소할 때 (UC-O04), ") + @Nested + class CancelMyOrderItem { + + @DisplayName("소유자 검증 + 아이템 취소 + 재고 복구가 수행된다") + @Test + void cancelMyOrderItem_success() { + // arrange + OrderItemModel cancelledItem = OrderItemModel.create( + 10L, 25000, 2, "상품A", "브랜드A"); + + when(orderService.getByIdAndUserId(1L, 1L)).thenReturn( + OrderModel.create(1L, List.of(cancelledItem))); + when(orderService.cancelItem(1L, 100L)).thenReturn(cancelledItem); + + // act + orderFacade.cancelMyOrderItem(1L, 1L, 100L); + + // assert + assertAll( + () -> verify(orderService).getByIdAndUserId(1L, 1L), + () -> verify(orderService).cancelItem(1L, 100L), + () -> verify(productService).increaseStock(10L, 2)); + } + + @DisplayName("본인 주문이 아니면 예외가 발생한다") + @Test + void cancelMyOrderItem_notOwner_throwsException() { + // arrange + when(orderService.getByIdAndUserId(1L, 999L)) + .thenThrow(new CoreException(OrderErrorCode.FORBIDDEN)); + + // act & assert + assertThatThrownBy(() -> orderFacade.cancelMyOrderItem(999L, 1L, 100L)) + .isInstanceOf(CoreException.class); + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java index 7c5b324a3..012f063ad 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java @@ -32,4 +32,36 @@ void create_withValidValues() { () -> assertThat(orderItem.getBrandName()).isEqualTo("브랜드A")); } } + + @DisplayName("취소할 때, ") + @Nested + class Cancel { + + @DisplayName("ORDERED 상태의 아이템이 CANCELLED로 변경된다") + @Test + void cancel_success() { + // arrange + OrderItemModel orderItem = OrderItemModel.create( + 10L, 25000, 2, "상품A", "브랜드A"); + + // act + orderItem.cancel(); + + // assert + assertThat(orderItem.getStatus()).isEqualTo(OrderItemStatus.CANCELLED); + } + + @DisplayName("이미 CANCELLED인 아이템을 취소하면 예외가 발생한다") + @Test + void cancel_alreadyCancelled_throwsException() { + // arrange + OrderItemModel orderItem = OrderItemModel.create( + 10L, 25000, 2, "상품A", "브랜드A"); + orderItem.cancel(); + + // act & assert + assertThatThrownBy(() -> orderItem.cancel()) + .isInstanceOf(CoreException.class); + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java index 6aad0e18f..dd70baefb 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java @@ -6,6 +6,7 @@ import com.loopers.support.error.CoreException; import java.util.List; +import java.util.concurrent.atomic.AtomicLong; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -13,6 +14,25 @@ @DisplayName("OrderModel 단위 테스트") class OrderModelTest { + private static final AtomicLong ID_GENERATOR = new AtomicLong(1); + + private static void setId(Object entity, long id) { + try { + var idField = entity.getClass().getSuperclass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(entity, id); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException(e); + } + } + + private static OrderItemModel createItemWithId(Long productId, int orderPrice, int quantity, + String productName, String brandName) { + OrderItemModel item = OrderItemModel.create(productId, orderPrice, quantity, productName, brandName); + setId(item, ID_GENERATOR.getAndIncrement()); + return item; + } + @DisplayName("생성할 때, ") @Nested class Create { @@ -72,4 +92,70 @@ void validateOwner_notOwner_throwsException() { .isInstanceOf(CoreException.class); } } + + @DisplayName("아이템을 취소할 때, ") + @Nested + class CancelItem { + + @DisplayName("취소된 아이템을 제외하고 totalPrice가 재계산된다") + @Test + void cancelItem_recalculatesTotalPrice() { + // arrange + OrderItemModel item1 = createItemWithId(10L, 25000, 2, "상품A", "브랜드A"); + OrderItemModel item2 = createItemWithId(20L, 30000, 1, "상품B", "브랜드B"); + OrderModel order = OrderModel.create(1L, List.of(item1, item2)); + + // act + order.cancelItem(item1.getId()); + + // assert + assertAll( + () -> assertThat(order.getTotalPrice()).isEqualTo(30000), + () -> assertThat(order.getOriginalTotalPrice()).isEqualTo(80000), + () -> assertThat(item1.getStatus()).isEqualTo(OrderItemStatus.CANCELLED)); + } + + @DisplayName("모든 아이템이 취소되면 주문 상태가 CANCELLED로 변경된다") + @Test + void cancelItem_allItemsCancelled_orderCancelled() { + // arrange + OrderItemModel item1 = createItemWithId(10L, 25000, 2, "상품A", "브랜드A"); + OrderItemModel item2 = createItemWithId(20L, 30000, 1, "상품B", "브랜드B"); + OrderModel order = OrderModel.create(1L, List.of(item1, item2)); + + // act + order.cancelItem(item1.getId()); + order.cancelItem(item2.getId()); + + // assert + assertAll( + () -> assertThat(order.getStatus()).isEqualTo(OrderStatus.CANCELLED), + () -> assertThat(order.getTotalPrice()).isEqualTo(0)); + } + + @DisplayName("존재하지 않는 아이템 ID로 취소하면 예외가 발생한다") + @Test + void cancelItem_itemNotFound_throwsException() { + // arrange + OrderItemModel item = createItemWithId(10L, 25000, 1, "상품A", "브랜드A"); + OrderModel order = OrderModel.create(1L, List.of(item)); + + // act & assert + assertThatThrownBy(() -> order.cancelItem(999L)) + .isInstanceOf(CoreException.class); + } + + @DisplayName("이미 CANCELLED인 주문에서 취소하면 예외가 발생한다") + @Test + void cancelItem_orderAlreadyCancelled_throwsException() { + // arrange + OrderItemModel item = createItemWithId(10L, 25000, 1, "상품A", "브랜드A"); + OrderModel order = OrderModel.create(1L, List.of(item)); + order.cancelItem(item.getId()); + + // act & assert + assertThatThrownBy(() -> order.cancelItem(item.getId())) + .isInstanceOf(CoreException.class); + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java index d9dc763be..03de91809 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java @@ -150,4 +150,26 @@ void getAllOrders_returnsPage() { () -> assertThat(page.getContent()).hasSize(2)); } } + + @DisplayName("아이템을 취소할 때, ") + @Nested + class CancelItem { + + @DisplayName("주문을 조회하고 아이템을 취소한다") + @Test + void cancelItem_success() { + // arrange + OrderModel order = orderService.createOrder(1L, createSampleItems()); + Long orderItemId = order.getItems().get(0).getId(); + + // act + OrderItemModel cancelledItem = orderService.cancelItem(order.getId(), orderItemId); + + // assert + assertAll( + () -> assertThat(cancelledItem.getStatus()).isEqualTo(OrderItemStatus.CANCELLED), + () -> assertThat(order.getTotalPrice()).isEqualTo(0), + () -> assertThat(order.getStatus()).isEqualTo(OrderStatus.CANCELLED)); + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java index 0171b3b97..7178c3993 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java @@ -142,6 +142,24 @@ void decreaseStock_whenInsufficient() { } } + @DisplayName("재고를 복구할 때, ") + @Nested + class IncreaseStock { + + @DisplayName("수량만큼 재고가 증가한다") + @Test + void increaseStock_success() { + // arrange + ProductModel product = ProductModel.create(BRAND_ID, "에어맥스", 150000, 5); + + // act + product.increaseStock(3); + + // assert + assertThat(product.getStock()).isEqualTo(8); + } + } + @DisplayName("품절 여부를 확인할 때, ") @Nested class IsSoldOut { From 466db2bbf53cdd299182b8882d565c9202b3b5d2 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Sat, 28 Feb 2026 01:32:49 +0900 Subject: [PATCH 103/108] =?UTF-8?q?feat:=20=EC=A3=BC=EB=AC=B8=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=ED=85=9C=20=EC=B7=A8=EC=86=8C=20API=20=EC=97=94?= =?UTF-8?q?=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../java/com/loopers/interfaces/auth/AuthFilter.java | 4 +++- .../interfaces/order/AdminOrderV1ApiSpec.java | 9 +++++++++ .../interfaces/order/AdminOrderV1Controller.java | 11 +++++++++++ .../com/loopers/interfaces/order/OrderV1ApiSpec.java | 10 ++++++++++ .../loopers/interfaces/order/OrderV1Controller.java | 12 ++++++++++++ 5 files changed, 45 insertions(+), 1 deletion(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthFilter.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthFilter.java index bc2caf152..89cc3c060 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthFilter.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AuthFilter.java @@ -30,6 +30,7 @@ public class AuthFilter extends OncePerRequestFilter { "/api/v1/users/me/likes" ); private static final String AUTH_REQUIRED_SUFFIX = "/likes"; + private static final String AUTH_REQUIRED_PREFIX_ORDERS = "/api/v1/orders"; private final AuthenticationService authenticationService; private final ObjectMapper objectMapper; @@ -64,7 +65,8 @@ protected void doFilterInternal(HttpServletRequest request, private boolean requiresAuth(String uri) { return AUTH_REQUIRED_URLS.contains(uri) - || (uri.startsWith("/api/v1/products/") && uri.endsWith(AUTH_REQUIRED_SUFFIX)); + || (uri.startsWith("/api/v1/products/") && uri.endsWith(AUTH_REQUIRED_SUFFIX)) + || uri.startsWith(AUTH_REQUIRED_PREFIX_ORDERS); } private void writeUnauthorizedResponse(HttpServletResponse response, String message) throws IOException { diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/order/AdminOrderV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/order/AdminOrderV1ApiSpec.java index 364626144..be1724db7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/order/AdminOrderV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/order/AdminOrderV1ApiSpec.java @@ -25,4 +25,13 @@ ApiResponse list( ApiResponse getById( @Parameter(description = "주문 ID", required = true) Long orderId ); + + @Operation( + summary = "주문 아이템 취소", + description = "주문 아이템을 취소합니다. 취소 시 재고가 복구됩니다." + ) + ApiResponse cancelItem( + @Parameter(description = "주문 ID", required = true) Long orderId, + @Parameter(description = "주문 아이템 ID", required = true) Long orderItemId + ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/order/AdminOrderV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/order/AdminOrderV1Controller.java index 2e158df5f..6f251d32b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/order/AdminOrderV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/order/AdminOrderV1Controller.java @@ -8,6 +8,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -47,4 +48,14 @@ public ApiResponse getById( OrderResponse.OrderDetail.from( orderFacade.getOrderDetail(orderId))); } + + @PatchMapping("/{orderId}/items/{orderItemId}/cancel") + @Override + public ApiResponse cancelItem( + @PathVariable Long orderId, + @PathVariable Long orderItemId + ) { + orderFacade.cancelOrderItem(orderId, orderItemId); + return ApiResponse.success(); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/order/OrderV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/order/OrderV1ApiSpec.java index ed7b3a5b9..41f4f4947 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/order/OrderV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/order/OrderV1ApiSpec.java @@ -40,4 +40,14 @@ ApiResponse getById( @Parameter(hidden = true) LoginUser loginUser, @Parameter(description = "주문 ID", required = true) Long orderId ); + + @Operation( + summary = "주문 아이템 취소", + description = "본인 주문의 아이템을 개별 취소합니다. 취소 시 재고가 복구됩니다." + ) + ApiResponse cancelItem( + @Parameter(hidden = true) LoginUser loginUser, + @Parameter(description = "주문 ID", required = true) Long orderId, + @Parameter(description = "주문 아이템 ID", required = true) Long orderItemId + ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/order/OrderV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/order/OrderV1Controller.java index efdfcfd6c..4fe6403e0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/order/OrderV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/order/OrderV1Controller.java @@ -13,6 +13,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -64,4 +65,15 @@ public ApiResponse getById( OrderResponse.OrderDetail.from( orderFacade.getMyOrderDetail(loginUser.id(), orderId))); } + + @PatchMapping("/{orderId}/items/{orderItemId}/cancel") + @Override + public ApiResponse cancelItem( + @Login LoginUser loginUser, + @PathVariable Long orderId, + @PathVariable Long orderItemId + ) { + orderFacade.cancelMyOrderItem(loginUser.id(), orderId, orderItemId); + return ApiResponse.success(); + } } From 89df73b3bbebdf178aff6dcb6a75ec9c17598192 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Sat, 28 Feb 2026 01:32:56 +0900 Subject: [PATCH 104/108] =?UTF-8?q?test:=20=EC=A3=BC=EB=AC=B8=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=ED=85=9C=20=EC=B7=A8=EC=86=8C=20API=20E2E=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../order/AdminOrderV1ApiE2ETest.java | 198 ++++++++++++++ .../interfaces/order/OrderV1ApiE2ETest.java | 247 ++++++++++++++++++ 2 files changed, 445 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/order/AdminOrderV1ApiE2ETest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/order/OrderV1ApiE2ETest.java diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/order/AdminOrderV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/order/AdminOrderV1ApiE2ETest.java new file mode 100644 index 000000000..e6328b57a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/order/AdminOrderV1ApiE2ETest.java @@ -0,0 +1,198 @@ +package com.loopers.interfaces.order; + +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 com.loopers.domain.brand.BrandModel; +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.product.ProductModel; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.user.dto.UserV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import jakarta.persistence.EntityManager; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.support.TransactionTemplate; + +@DisplayName("Admin Order V1 API E2E 테스트") +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class AdminOrderV1ApiE2ETest { + + private static final String HEADER_LDAP = "X-Loopers-Ldap"; + + private final TestRestTemplate testRestTemplate; + private final BrandJpaRepository brandJpaRepository; + private final ProductJpaRepository productJpaRepository; + private final EntityManager entityManager; + private final TransactionTemplate transactionTemplate; + private final DatabaseCleanUp databaseCleanUp; + + private Long productId; + private Long orderId; + private Long orderItemId; + + @Autowired + public AdminOrderV1ApiE2ETest( + TestRestTemplate testRestTemplate, + BrandJpaRepository brandJpaRepository, + ProductJpaRepository productJpaRepository, + EntityManager entityManager, + TransactionTemplate transactionTemplate, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.brandJpaRepository = brandJpaRepository; + this.productJpaRepository = productJpaRepository; + this.entityManager = entityManager; + this.transactionTemplate = transactionTemplate; + this.databaseCleanUp = databaseCleanUp; + } + + @BeforeEach + void setUp() { + // 사용자 등록 + UserV1Dto.SignupRequest signupRequest = new UserV1Dto.SignupRequest( + "testuser1", "Test1234!", "홍길동", "19900101", "test@example.com"); + ResponseEntity> signupResponse = + testRestTemplate.exchange( + "/api/v1/users/signup", HttpMethod.POST, + new HttpEntity<>(signupRequest), + new ParameterizedTypeReference<>() {}); + Long userId = signupResponse.getBody().data().id(); + + // 브랜드 + 상품 + BrandModel brand = brandJpaRepository.save(BrandModel.create("ACNE STUDIOS")); + ProductModel product = productJpaRepository.save( + ProductModel.create(brand.getId(), "오버사이즈 코트", 50000, 100)); + productId = product.getId(); + + // 주문 직접 생성 (재고 차감 시뮬레이션) + product.decreaseStock(2); + productJpaRepository.save(product); + + Long finalUserId = userId; + transactionTemplate.executeWithoutResult(status -> { + OrderModel order = OrderModel.create(finalUserId, List.of( + OrderItemModel.create(productId, 50000, 2, "오버사이즈 코트", "ACNE STUDIOS"))); + entityManager.persist(order); + entityManager.flush(); + orderId = order.getId(); + orderItemId = order.getItems().get(0).getId(); + }); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LDAP, "loopers.admin"); + return headers; + } + + private String cancelEndpoint(Long orderId, Long orderItemId) { + return "/api-admin/v1/orders/" + orderId + "/items/" + orderItemId + "/cancel"; + } + + @DisplayName("PATCH /api-admin/v1/orders/{orderId}/items/{orderItemId}/cancel") + @Nested + class CancelItem { + + @DisplayName("관리자가 주문 아이템을 취소하면, 성공 응답을 반환한다.") + @Test + void returnsSuccess_whenValidRequest() { + // act + ResponseEntity> response = + testRestTemplate.exchange( + cancelEndpoint(orderId, orderItemId), HttpMethod.PATCH, + new HttpEntity<>(null, adminHeaders()), + new ParameterizedTypeReference<>() {}); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK)); + } + + @DisplayName("취소 후 재고가 복구된다.") + @Test + void restoresStock_afterCancel() { + // act + testRestTemplate.exchange( + cancelEndpoint(orderId, orderItemId), HttpMethod.PATCH, + new HttpEntity<>(null, adminHeaders()), + new ParameterizedTypeReference>() {}); + + // assert + ProductModel product = productJpaRepository.findById(productId).orElseThrow(); + assertThat(product.getStock()).isEqualTo(100); + } + + @DisplayName("타 사용자의 주문도 취소할 수 있다.") + @Test + void canCancelOtherUsersOrder() { + // act + ResponseEntity> response = + testRestTemplate.exchange( + cancelEndpoint(orderId, orderItemId), HttpMethod.PATCH, + new HttpEntity<>(null, adminHeaders()), + new ParameterizedTypeReference<>() {}); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @DisplayName("존재하지 않는 주문 ID로 취소하면, 404 응답을 반환한다.") + @Test + void throwsNotFound_whenOrderNotFound() { + // act + ResponseEntity> response = + testRestTemplate.exchange( + cancelEndpoint(999L, 999L), HttpMethod.PATCH, + new HttpEntity<>(null, adminHeaders()), + new ParameterizedTypeReference<>() {}); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @DisplayName("이미 취소된 아이템을 다시 취소하면, 400 응답을 반환한다.") + @Test + void throwsBadRequest_whenAlreadyCancelled() { + // arrange + testRestTemplate.exchange( + cancelEndpoint(orderId, orderItemId), HttpMethod.PATCH, + new HttpEntity<>(null, adminHeaders()), + new ParameterizedTypeReference>() {}); + + // act + ResponseEntity> response = + testRestTemplate.exchange( + cancelEndpoint(orderId, orderItemId), HttpMethod.PATCH, + new HttpEntity<>(null, adminHeaders()), + new ParameterizedTypeReference<>() {}); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/order/OrderV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/order/OrderV1ApiE2ETest.java new file mode 100644 index 000000000..a21af8e35 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/order/OrderV1ApiE2ETest.java @@ -0,0 +1,247 @@ +package com.loopers.interfaces.order; + +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 com.loopers.domain.brand.BrandModel; +import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.product.ProductModel; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.order.dto.OrderResponse; +import com.loopers.interfaces.user.dto.UserV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import jakarta.persistence.EntityManager; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.support.TransactionTemplate; + +@DisplayName("Order V1 API E2E 테스트") +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class OrderV1ApiE2ETest { + + private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; + private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; + + private final TestRestTemplate testRestTemplate; + private final BrandJpaRepository brandJpaRepository; + private final ProductJpaRepository productJpaRepository; + private final EntityManager entityManager; + private final TransactionTemplate transactionTemplate; + private final DatabaseCleanUp databaseCleanUp; + + private Long userId; + private Long productId; + private Long orderId; + private Long orderItemId; + + @Autowired + public OrderV1ApiE2ETest( + TestRestTemplate testRestTemplate, + BrandJpaRepository brandJpaRepository, + ProductJpaRepository productJpaRepository, + EntityManager entityManager, + TransactionTemplate transactionTemplate, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.brandJpaRepository = brandJpaRepository; + this.productJpaRepository = productJpaRepository; + this.entityManager = entityManager; + this.transactionTemplate = transactionTemplate; + this.databaseCleanUp = databaseCleanUp; + } + + @BeforeEach + void setUp() { + // 사용자 등록 + UserV1Dto.SignupRequest signupRequest = new UserV1Dto.SignupRequest( + "testuser1", "Test1234!", "홍길동", "19900101", "test@example.com"); + ResponseEntity> signupResponse = + testRestTemplate.exchange( + "/api/v1/users/signup", HttpMethod.POST, + new HttpEntity<>(signupRequest), + new ParameterizedTypeReference<>() {}); + userId = signupResponse.getBody().data().id(); + + // 브랜드 + 상품 + BrandModel brand = brandJpaRepository.save(BrandModel.create("ACNE STUDIOS")); + ProductModel product = productJpaRepository.save( + ProductModel.create(brand.getId(), "오버사이즈 코트", 50000, 100)); + productId = product.getId(); + + // 주문 직접 생성 (재고 차감 시뮬레이션) + product.decreaseStock(2); + productJpaRepository.save(product); + + Long finalUserId = userId; + transactionTemplate.executeWithoutResult(status -> { + OrderModel order = OrderModel.create(finalUserId, List.of( + OrderItemModel.create(productId, 50000, 2, "오버사이즈 코트", "ACNE STUDIOS"))); + entityManager.persist(order); + entityManager.flush(); + orderId = order.getId(); + orderItemId = order.getItems().get(0).getId(); + }); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private HttpHeaders authHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LOGIN_ID, "testuser1"); + headers.set(HEADER_LOGIN_PW, "Test1234!"); + return headers; + } + + private String cancelEndpoint(Long orderId, Long orderItemId) { + return "/api/v1/orders/" + orderId + "/items/" + orderItemId + "/cancel"; + } + + @DisplayName("PATCH /api/v1/orders/{orderId}/items/{orderItemId}/cancel") + @Nested + class CancelItem { + + @DisplayName("본인 주문의 아이템을 취소하면, 성공 응답을 반환한다.") + @Test + void returnsSuccess_whenValidRequest() { + // act + ResponseEntity> response = + testRestTemplate.exchange( + cancelEndpoint(orderId, orderItemId), HttpMethod.PATCH, + new HttpEntity<>(null, authHeaders()), + new ParameterizedTypeReference<>() {}); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK)); + } + + @DisplayName("취소 후 재고가 복구된다.") + @Test + void restoresStock_afterCancel() { + // act + testRestTemplate.exchange( + cancelEndpoint(orderId, orderItemId), HttpMethod.PATCH, + new HttpEntity<>(null, authHeaders()), + new ParameterizedTypeReference>() {}); + + // assert + ProductModel product = productJpaRepository.findById(productId).orElseThrow(); + assertThat(product.getStock()).isEqualTo(100); + } + + @DisplayName("취소 후 주문 상세에서 totalPrice가 재계산된다.") + @Test + void recalculatesTotalPrice_afterCancel() { + // act + testRestTemplate.exchange( + cancelEndpoint(orderId, orderItemId), HttpMethod.PATCH, + new HttpEntity<>(null, authHeaders()), + new ParameterizedTypeReference>() {}); + + // assert + ResponseEntity> detailResponse = + testRestTemplate.exchange( + "/api/v1/orders/" + orderId, HttpMethod.GET, + new HttpEntity<>(null, authHeaders()), + new ParameterizedTypeReference<>() {}); + + assertThat(detailResponse.getBody().data().totalPrice()).isEqualTo(0); + } + + @DisplayName("이미 취소된 아이템을 다시 취소하면, 400 응답을 반환한다.") + @Test + void throwsBadRequest_whenAlreadyCancelled() { + // arrange + testRestTemplate.exchange( + cancelEndpoint(orderId, orderItemId), HttpMethod.PATCH, + new HttpEntity<>(null, authHeaders()), + new ParameterizedTypeReference>() {}); + + // act + ResponseEntity> response = + testRestTemplate.exchange( + cancelEndpoint(orderId, orderItemId), HttpMethod.PATCH, + new HttpEntity<>(null, authHeaders()), + new ParameterizedTypeReference<>() {}); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("존재하지 않는 주문 ID로 취소하면, 404 응답을 반환한다.") + @Test + void throwsNotFound_whenOrderNotFound() { + // act + ResponseEntity> response = + testRestTemplate.exchange( + cancelEndpoint(999L, 999L), HttpMethod.PATCH, + new HttpEntity<>(null, authHeaders()), + new ParameterizedTypeReference<>() {}); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @DisplayName("타인의 주문을 취소하면, 403 응답을 반환한다.") + @Test + void throwsForbidden_whenNotOwner() { + // arrange + UserV1Dto.SignupRequest otherUser = new UserV1Dto.SignupRequest( + "otheruser1", "Test1234!", "김철수", "19950101", "other@example.com"); + testRestTemplate.exchange( + "/api/v1/users/signup", HttpMethod.POST, + new HttpEntity<>(otherUser), + new ParameterizedTypeReference>() {}); + + HttpHeaders otherHeaders = new HttpHeaders(); + otherHeaders.set(HEADER_LOGIN_ID, "otheruser1"); + otherHeaders.set(HEADER_LOGIN_PW, "Test1234!"); + + // act + ResponseEntity> response = + testRestTemplate.exchange( + cancelEndpoint(orderId, orderItemId), HttpMethod.PATCH, + new HttpEntity<>(null, otherHeaders), + new ParameterizedTypeReference<>() {}); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @DisplayName("인증 헤더가 없으면, 401 응답을 반환한다.") + @Test + void throwsUnauthorized_whenNoAuthHeaders() { + // act + ResponseEntity> response = + testRestTemplate.exchange( + cancelEndpoint(1L, 1L), HttpMethod.PATCH, + null, + new ParameterizedTypeReference<>() {}); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } +} From 8a4016e13a0b079a37d8429b3d1977a9e4181fcd Mon Sep 17 00:00:00 2001 From: plan11plan Date: Sat, 28 Feb 2026 01:50:12 +0900 Subject: [PATCH 105/108] =?UTF-8?q?fix:=20Order-OrderItem=20cascade?= =?UTF-8?q?=EC=97=90=20MERGE=20=EC=B6=94=EA=B0=80=ED=95=98=EC=97=AC=20JpaR?= =?UTF-8?q?epository.save()=20=EC=8B=9C=20=EC=9E=90=EC=8B=9D=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=EC=A0=84=ED=8C=8C=20=EB=B3=B4=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../com/loopers/domain/order/OrderModel.java | 2 +- .../order/AdminOrderV1ApiE2ETest.java | 26 +++++++------------ .../interfaces/order/OrderV1ApiE2ETest.java | 26 +++++++------------ docs/design/order/DESIGN.md | 4 +-- 4 files changed, 21 insertions(+), 37 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java index 09197f6b9..18296f4e5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java @@ -37,7 +37,7 @@ public class OrderModel extends BaseEntity { private OrderStatus status; @BatchSize(size = 100) - @OneToMany(mappedBy = "order", cascade = CascadeType.PERSIST) + @OneToMany(mappedBy = "order", cascade = {CascadeType.PERSIST, CascadeType.MERGE}) private List items = new ArrayList<>(); private OrderModel(Long userId, int totalPrice, OrderStatus status) { diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/order/AdminOrderV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/order/AdminOrderV1ApiE2ETest.java index e6328b57a..9bc2ee982 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/order/AdminOrderV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/order/AdminOrderV1ApiE2ETest.java @@ -9,11 +9,11 @@ import com.loopers.domain.order.OrderModel; import com.loopers.domain.product.ProductModel; import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.order.OrderJpaRepository; import com.loopers.infrastructure.product.ProductJpaRepository; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.user.dto.UserV1Dto; import com.loopers.utils.DatabaseCleanUp; -import jakarta.persistence.EntityManager; import java.util.List; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -29,7 +29,6 @@ import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.transaction.support.TransactionTemplate; @DisplayName("Admin Order V1 API E2E 테스트") @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @@ -40,8 +39,7 @@ class AdminOrderV1ApiE2ETest { private final TestRestTemplate testRestTemplate; private final BrandJpaRepository brandJpaRepository; private final ProductJpaRepository productJpaRepository; - private final EntityManager entityManager; - private final TransactionTemplate transactionTemplate; + private final OrderJpaRepository orderJpaRepository; private final DatabaseCleanUp databaseCleanUp; private Long productId; @@ -53,15 +51,13 @@ public AdminOrderV1ApiE2ETest( TestRestTemplate testRestTemplate, BrandJpaRepository brandJpaRepository, ProductJpaRepository productJpaRepository, - EntityManager entityManager, - TransactionTemplate transactionTemplate, + OrderJpaRepository orderJpaRepository, DatabaseCleanUp databaseCleanUp ) { this.testRestTemplate = testRestTemplate; this.brandJpaRepository = brandJpaRepository; this.productJpaRepository = productJpaRepository; - this.entityManager = entityManager; - this.transactionTemplate = transactionTemplate; + this.orderJpaRepository = orderJpaRepository; this.databaseCleanUp = databaseCleanUp; } @@ -87,15 +83,11 @@ void setUp() { product.decreaseStock(2); productJpaRepository.save(product); - Long finalUserId = userId; - transactionTemplate.executeWithoutResult(status -> { - OrderModel order = OrderModel.create(finalUserId, List.of( - OrderItemModel.create(productId, 50000, 2, "오버사이즈 코트", "ACNE STUDIOS"))); - entityManager.persist(order); - entityManager.flush(); - orderId = order.getId(); - orderItemId = order.getItems().get(0).getId(); - }); + OrderModel order = orderJpaRepository.save( + OrderModel.create(userId, List.of( + OrderItemModel.create(productId, 50000, 2, "오버사이즈 코트", "ACNE STUDIOS")))); + orderId = order.getId(); + orderItemId = order.getItems().get(0).getId(); } @AfterEach diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/order/OrderV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/order/OrderV1ApiE2ETest.java index a21af8e35..df8ba82cf 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/order/OrderV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/order/OrderV1ApiE2ETest.java @@ -9,12 +9,12 @@ import com.loopers.domain.order.OrderModel; import com.loopers.domain.product.ProductModel; import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.order.OrderJpaRepository; import com.loopers.infrastructure.product.ProductJpaRepository; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.order.dto.OrderResponse; import com.loopers.interfaces.user.dto.UserV1Dto; import com.loopers.utils.DatabaseCleanUp; -import jakarta.persistence.EntityManager; import java.util.List; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -30,7 +30,6 @@ import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.transaction.support.TransactionTemplate; @DisplayName("Order V1 API E2E 테스트") @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @@ -42,8 +41,7 @@ class OrderV1ApiE2ETest { private final TestRestTemplate testRestTemplate; private final BrandJpaRepository brandJpaRepository; private final ProductJpaRepository productJpaRepository; - private final EntityManager entityManager; - private final TransactionTemplate transactionTemplate; + private final OrderJpaRepository orderJpaRepository; private final DatabaseCleanUp databaseCleanUp; private Long userId; @@ -56,15 +54,13 @@ public OrderV1ApiE2ETest( TestRestTemplate testRestTemplate, BrandJpaRepository brandJpaRepository, ProductJpaRepository productJpaRepository, - EntityManager entityManager, - TransactionTemplate transactionTemplate, + OrderJpaRepository orderJpaRepository, DatabaseCleanUp databaseCleanUp ) { this.testRestTemplate = testRestTemplate; this.brandJpaRepository = brandJpaRepository; this.productJpaRepository = productJpaRepository; - this.entityManager = entityManager; - this.transactionTemplate = transactionTemplate; + this.orderJpaRepository = orderJpaRepository; this.databaseCleanUp = databaseCleanUp; } @@ -90,15 +86,11 @@ void setUp() { product.decreaseStock(2); productJpaRepository.save(product); - Long finalUserId = userId; - transactionTemplate.executeWithoutResult(status -> { - OrderModel order = OrderModel.create(finalUserId, List.of( - OrderItemModel.create(productId, 50000, 2, "오버사이즈 코트", "ACNE STUDIOS"))); - entityManager.persist(order); - entityManager.flush(); - orderId = order.getId(); - orderItemId = order.getItems().get(0).getId(); - }); + OrderModel order = orderJpaRepository.save( + OrderModel.create(userId, List.of( + OrderItemModel.create(productId, 50000, 2, "오버사이즈 코트", "ACNE STUDIOS")))); + orderId = order.getId(); + orderItemId = order.getItems().get(0).getId(); } @AfterEach diff --git a/docs/design/order/DESIGN.md b/docs/design/order/DESIGN.md index 9f4103438..45e025fa1 100644 --- a/docs/design/order/DESIGN.md +++ b/docs/design/order/DESIGN.md @@ -27,7 +27,7 @@ - **두 가지 주문 경로** — 바로구매(상품 페이지에서 직접)와 장바구니 주문. Order 도메인은 출처를 모르고, Facade가 경로를 조율. 주문 로직은 단일. - **스냅샷 구조** — `ProductSnapshot` `@Embeddable` VO로 그룹핑 (productName, brandName, imageUrl). 도메인 성장에 따라 스냅샷 필드가 늘어날 수 있으므로 개념 단위로 묶는다. productId는 스냅샷 외부에 별도 유지 (재구매, 통계용, FK 아님). - **Order ↔ OrderItem** — 양방향 `@OneToMany` / `@ManyToOne` 매핑. 같은 Aggregate 내부이므로 Aggregate Root(Order)가 OrderItem의 생명주기를 직접 관리한다. - - `cascade = CascadeType.PERSIST` — Order 저장 시 OrderItem 함께 저장. REMOVE는 Soft Delete와 충돌하므로 미사용. + - `cascade = {CascadeType.PERSIST, CascadeType.MERGE}` — Order 저장/병합 시 OrderItem 함께 처리. REMOVE는 Soft Delete와 충돌하므로 미사용. - `orphanRemoval = false` — 주문 항목 제거 요구사항 없음 + Soft Delete 정책과 충돌 방지. - `@BatchSize(size = 100)` — LAZY 기본, 목록 조회 시 N+1 방지. - **totalPrice / originalTotalPrice** — Order.create() 시점에 items로부터 직접 계산. originalTotalPrice는 생성 시점의 금액을 보존(불변). totalPrice는 아이템 취소 시 남은 ORDERED 아이템 기준으로 재계산. @@ -305,7 +305,7 @@ classDiagram | 관계 | 참조 방식 | 설명 | |---|---|---| | User → Order | ID 참조 (userId) | UserSnapshot 불필요 | -| Order ↔ OrderItem | 양방향 `@OneToMany` / `@ManyToOne` | 같은 Aggregate. cascade=PERSIST, orphanRemoval=false, @BatchSize(100) | +| Order ↔ OrderItem | 양방향 `@OneToMany` / `@ManyToOne` | 같은 Aggregate. cascade={PERSIST,MERGE}, orphanRemoval=false, @BatchSize(100) | | OrderItem → ProductSnapshot | `@Embedded` (`@Embeddable` VO) | 주문 시점 상품 정보를 개념 단위로 그룹핑. 테이블은 order_items에 그대로 저장 | | OrderItem.productId | ID 유지 (FK 아님) | 재구매, 통계 분석을 위한 데이터 연결용 | From 919d750ac2129f5fd96c44ad3ee32c2be258de6d Mon Sep 17 00:00:00 2001 From: plan11plan Date: Sat, 28 Feb 2026 02:36:39 +0900 Subject: [PATCH 106/108] =?UTF-8?q?refactor:=20ProductSnapshot=20=EB=84=A4?= =?UTF-8?q?=EC=9D=B4=EB=B0=8D=20=EB=B0=8F=20@Embeddable=20=EC=BB=A8?= =?UTF-8?q?=EB=B2=A4=EC=85=98=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../application/order/OrderFacade.java | 26 +++++++++---------- .../loopers/domain/order/OrderItemModel.java | 24 ++++++++++------- .../loopers/domain/order/ProductSnapshot.java | 24 +++++++++++++++++ .../domain/product/ProductService.java | 18 ++++++------- .../domain/product/ProductSnapshot.java | 4 --- .../domain/product/StockDeductionCommand.java | 4 --- .../domain/product/dto/ProductCommand.java | 2 ++ .../domain/product/dto/ProductInfo.java | 6 +++++ 8 files changed, 69 insertions(+), 39 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/ProductSnapshot.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSnapshot.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/StockDeductionCommand.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/dto/ProductInfo.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java index 1c6f57736..86df97e83 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -6,8 +6,8 @@ import com.loopers.domain.order.OrderItemModel; import com.loopers.domain.order.OrderService; import com.loopers.domain.product.ProductService; -import com.loopers.domain.product.ProductSnapshot; -import com.loopers.domain.product.StockDeductionCommand; +import com.loopers.domain.product.dto.ProductCommand; +import com.loopers.domain.product.dto.ProductInfo; import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; @@ -26,25 +26,25 @@ public class OrderFacade { @Transactional public OrderResult.OrderSummary createOrder(Long userId, OrderCriteria.Create criteria) { - List snapshots = productService.validateAndDeductStock( + List deductionInfos = productService.validateAndDeductStock( criteria.items().stream() - .map(item -> new StockDeductionCommand( + .map(item -> new ProductCommand.StockDeduction( item.productId(), item.quantity(), item.expectedPrice())) .toList()); Map brandNameMap = brandService.getNameMapByIds( - snapshots.stream() - .map(ProductSnapshot::brandId) + deductionInfos.stream() + .map(ProductInfo.StockDeduction::brandId) .distinct() .toList()); - List items = snapshots.stream() - .map(snapshot -> OrderItemModel.create( - snapshot.productId(), - snapshot.price(), - snapshot.quantity(), - snapshot.name(), - brandNameMap.get(snapshot.brandId()))) + List items = deductionInfos.stream() + .map(info -> OrderItemModel.create( + info.productId(), + info.price(), + info.quantity(), + info.name(), + brandNameMap.get(info.brandId()))) .toList(); return OrderResult.OrderSummary.from( diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java index b7e6b92a3..ab9cf0f08 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.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.EnumType; import jakarta.persistence.Enumerated; @@ -34,23 +35,19 @@ public class OrderItemModel extends BaseEntity { @Column(name = "quantity", nullable = false) private int quantity; - @Column(name = "product_name", nullable = false) - private String productName; - - @Column(name = "brand_name", nullable = false) - private String brandName; + @Embedded + private ProductSnapshot productSnapshot; @Enumerated(EnumType.STRING) @Column(name = "status", nullable = false) private OrderItemStatus status; private OrderItemModel(Long productId, int orderPrice, int quantity, - String productName, String brandName) { + ProductSnapshot productSnapshot) { this.productId = productId; this.orderPrice = orderPrice; this.quantity = quantity; - this.productName = productName; - this.brandName = brandName; + this.productSnapshot = productSnapshot; this.status = OrderItemStatus.ORDERED; } @@ -61,7 +58,16 @@ public static OrderItemModel create(Long productId, int orderPrice, int quantity validateQuantity(quantity); validateProductName(productName); validateBrandName(brandName); - return new OrderItemModel(productId, orderPrice, quantity, productName, brandName); + return new OrderItemModel(productId, orderPrice, quantity, + new ProductSnapshot(productName, brandName)); + } + + public String getProductName() { + return productSnapshot.getProductName(); + } + + public String getBrandName() { + return productSnapshot.getBrandName(); } public void cancel() { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/ProductSnapshot.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/ProductSnapshot.java new file mode 100644 index 000000000..afde63614 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/ProductSnapshot.java @@ -0,0 +1,24 @@ +package com.loopers.domain.order; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProductSnapshot { + + @Column(name = "product_name", nullable = false) + private String productName; + + @Column(name = "brand_name", nullable = false) + private String brandName; + + public ProductSnapshot(String productName, String brandName) { + this.productName = productName; + this.brandName = brandName; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java index ded3430f6..caf676bd9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -1,5 +1,7 @@ package com.loopers.domain.product; +import com.loopers.domain.product.dto.ProductCommand; +import com.loopers.domain.product.dto.ProductInfo; import com.loopers.support.error.CoreException; import java.util.List; import java.util.Map; @@ -38,14 +40,12 @@ public List getAllByIds(List ids) { @Transactional public void update(Long id, String name, int price, int stock) { - ProductModel productModel = getById(id); - productModel.update(name, price, stock); + getById(id).update(name, price, stock); } @Transactional public void delete(Long id) { - ProductModel productModel = getById(id); - productModel.delete(); + getById(id).delete(); } @Transactional(readOnly = true) @@ -65,8 +65,7 @@ public Page getAllByBrandId(Long brandId, Pageable pageable) { @Transactional public void deleteAllByBrandId(Long brandId) { - List products = productRepository.findAllByBrandId(brandId); - products.forEach(ProductModel::delete); + productRepository.findAllByBrandId(brandId).forEach(ProductModel::delete); } @Transactional(readOnly = true) @@ -92,9 +91,10 @@ public void increaseStock(Long productId, int quantity) { } @Transactional - public List validateAndDeductStock(List commands) { + public List validateAndDeductStock( + List commands) { List products = getAllByIds( - commands.stream().map(StockDeductionCommand::productId).toList()); + commands.stream().map(ProductCommand.StockDeduction::productId).toList()); Map productMap = products.stream() .collect(Collectors.toMap(ProductModel::getId, Function.identity())); @@ -104,7 +104,7 @@ public List validateAndDeductStock(List ProductModel product = productMap.get(command.productId()); product.validateExpectedPrice(command.expectedPrice()); product.decreaseStock(command.quantity()); - return new ProductSnapshot( + return new ProductInfo.StockDeduction( command.productId(), product.getName(), product.getPrice(), diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSnapshot.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSnapshot.java deleted file mode 100644 index c9f94a288..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSnapshot.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.loopers.domain.product; - -public record ProductSnapshot(Long productId, String name, int price, int quantity, Long brandId) { -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/StockDeductionCommand.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/StockDeductionCommand.java deleted file mode 100644 index e02d4f28d..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/StockDeductionCommand.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.loopers.domain.product; - -public record StockDeductionCommand(Long productId, int quantity, int expectedPrice) { -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/dto/ProductCommand.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/dto/ProductCommand.java index aba72737e..63a46ec8d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/dto/ProductCommand.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/dto/ProductCommand.java @@ -5,4 +5,6 @@ public class ProductCommand { public record Register(Long brandId, String name, int price, int stock) {} public record Update(String name, int price, int stock) {} + + public record StockDeduction(Long productId, int quantity, int expectedPrice) {} } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/dto/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/dto/ProductInfo.java new file mode 100644 index 000000000..2231ca18c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/dto/ProductInfo.java @@ -0,0 +1,6 @@ +package com.loopers.domain.product.dto; + +public class ProductInfo { + + public record StockDeduction(Long productId, String name, int price, int quantity, Long brandId) {} +} From 16002f6bea1407417c65bc5b58c49f4917efc383 Mon Sep 17 00:00:00 2001 From: plan11plan Date: Sat, 28 Feb 2026 02:36:50 +0900 Subject: [PATCH 107/108] =?UTF-8?q?test:=20ProductSnapshot/StockDeductionI?= =?UTF-8?q?nfo=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=EC=97=90=20=EB=94=B0?= =?UTF-8?q?=EB=A5=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../application/order/OrderFacadeTest.java | 16 ++++++++-------- .../loopers/domain/order/OrderItemModelTest.java | 6 +++--- .../com/loopers/domain/order/OrderModelTest.java | 9 +++++---- .../loopers/domain/order/OrderServiceTest.java | 4 ++-- .../domain/product/ProductServiceTest.java | 14 ++++++++------ .../interfaces/order/AdminOrderV1ApiE2ETest.java | 2 +- .../interfaces/order/OrderV1ApiE2ETest.java | 2 +- 7 files changed, 28 insertions(+), 25 deletions(-) diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java index da958f3cb..3969302e4 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java @@ -18,7 +18,7 @@ import com.loopers.domain.order.OrderService; import com.loopers.domain.product.ProductErrorCode; import com.loopers.domain.product.ProductService; -import com.loopers.domain.product.ProductSnapshot; +import com.loopers.domain.product.dto.ProductInfo; import com.loopers.support.error.CoreException; import java.time.ZonedDateTime; import java.util.List; @@ -60,11 +60,11 @@ void createOrder_success() { // arrange Long brandId = 1L; when(productService.validateAndDeductStock(anyList())).thenReturn(List.of( - new ProductSnapshot(10L, "상품A", 25000, 1, brandId))); + new ProductInfo.StockDeduction(10L, "상품A", 25000, 1, brandId))); when(brandService.getNameMapByIds(List.of(brandId))).thenReturn(Map.of(brandId, "브랜드A")); OrderModel order = OrderModel.create(1L, List.of( - OrderItemModel.create(10L, 25000, 1, "상품A", "브랜드A"))); + OrderItemModel.create(10L, 25000, 1, "상품A", ("브랜드A")))); when(orderService.createOrder(anyLong(), anyList())).thenReturn(order); OrderCriteria.Create criteria = new OrderCriteria.Create(List.of( @@ -136,7 +136,7 @@ class GetMyOrders { void getMyOrders_returnsOrders() { // arrange OrderModel order = OrderModel.create(1L, List.of( - OrderItemModel.create(10L, 50000, 1, "상품A", "브랜드A"))); + OrderItemModel.create(10L, 50000, 1, "상품A", ("브랜드A")))); ZonedDateTime startAt = ZonedDateTime.now().minusDays(7); ZonedDateTime endAt = ZonedDateTime.now(); @@ -164,7 +164,7 @@ class GetMyOrderDetail { void getMyOrderDetail_returnsDetail() { // arrange OrderModel order = OrderModel.create(1L, List.of( - OrderItemModel.create(10L, 25000, 2, "상품A", "브랜드A"))); + OrderItemModel.create(10L, 25000, 2, "상품A", ("브랜드A")))); when(orderService.getByIdAndUserId(1L, 1L)).thenReturn(order); @@ -200,7 +200,7 @@ class AdminOrders { void getAllOrders_returnsPage() { // arrange OrderModel order = OrderModel.create(1L, List.of( - OrderItemModel.create(10L, 50000, 1, "상품A", "브랜드A"))); + OrderItemModel.create(10L, 50000, 1, "상품A", ("브랜드A")))); Page page = new PageImpl<>(List.of(order), PageRequest.of(0, 10), 1); when(orderService.getAllOrders(any())).thenReturn(page); @@ -219,7 +219,7 @@ void getAllOrders_returnsPage() { void getOrderDetail_returnsDetail_withoutOwnerCheck() { // arrange OrderModel order = OrderModel.create(2L, List.of( - OrderItemModel.create(10L, 25000, 2, "상품A", "브랜드A"))); + OrderItemModel.create(10L, 25000, 2, "상품A", ("브랜드A")))); when(orderService.getById(1L)).thenReturn(order); @@ -242,7 +242,7 @@ class CancelMyOrderItem { void cancelMyOrderItem_success() { // arrange OrderItemModel cancelledItem = OrderItemModel.create( - 10L, 25000, 2, "상품A", "브랜드A"); + 10L, 25000, 2, "상품A", ("브랜드A")); when(orderService.getByIdAndUserId(1L, 1L)).thenReturn( OrderModel.create(1L, List.of(cancelledItem))); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java index 012f063ad..ee426e3a8 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java @@ -21,7 +21,7 @@ class Create { void create_withValidValues() { // act OrderItemModel orderItem = OrderItemModel.create( - 10L, 25000, 2, "상품A", "브랜드A"); + 10L, 25000, 2, "상품A", ("브랜드A")); // assert assertAll( @@ -42,7 +42,7 @@ class Cancel { void cancel_success() { // arrange OrderItemModel orderItem = OrderItemModel.create( - 10L, 25000, 2, "상품A", "브랜드A"); + 10L, 25000, 2, "상품A", ("브랜드A")); // act orderItem.cancel(); @@ -56,7 +56,7 @@ void cancel_success() { void cancel_alreadyCancelled_throwsException() { // arrange OrderItemModel orderItem = OrderItemModel.create( - 10L, 25000, 2, "상품A", "브랜드A"); + 10L, 25000, 2, "상품A", ("브랜드A")); orderItem.cancel(); // act & assert diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java index dd70baefb..e2679354b 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java @@ -28,7 +28,8 @@ private static void setId(Object entity, long id) { private static OrderItemModel createItemWithId(Long productId, int orderPrice, int quantity, String productName, String brandName) { - OrderItemModel item = OrderItemModel.create(productId, orderPrice, quantity, productName, brandName); + OrderItemModel item = OrderItemModel.create( + productId, orderPrice, quantity, productName, brandName); setId(item, ID_GENERATOR.getAndIncrement()); return item; } @@ -42,7 +43,7 @@ class Create { void create_withValidValues() { // arrange List items = List.of( - OrderItemModel.create(10L, 25000, 2, "상품A", "브랜드A")); + OrderItemModel.create(10L, 25000, 2, "상품A", ("브랜드A"))); // act OrderModel order = OrderModel.create(1L, items); @@ -68,7 +69,7 @@ void create_withEmptyItems_throwsException() { void create_withNullUserId_throwsException() { // arrange List items = List.of( - OrderItemModel.create(10L, 25000, 1, "상품A", "브랜드A")); + OrderItemModel.create(10L, 25000, 1, "상품A", ("브랜드A"))); // act & assert assertThatThrownBy(() -> OrderModel.create(null, items)) @@ -85,7 +86,7 @@ class ValidateOwner { void validateOwner_notOwner_throwsException() { // arrange OrderModel order = OrderModel.create(1L, List.of( - OrderItemModel.create(10L, 25000, 1, "상품A", "브랜드A"))); + OrderItemModel.create(10L, 25000, 1, "상품A", ("브랜드A")))); // act & assert assertThatThrownBy(() -> order.validateOwner(999L)) diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java index 03de91809..a451d0f12 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java @@ -29,7 +29,7 @@ void setUp() { private List createSampleItems() { return List.of( - OrderItemModel.create(10L, 25000, 2, "상품A", "브랜드A")); + OrderItemModel.create(10L, 25000, 2, "상품A", ("브랜드A"))); } @DisplayName("주문을 생성할 때, ") @@ -139,7 +139,7 @@ void getAllOrders_returnsPage() { // arrange orderService.createOrder(1L, createSampleItems()); orderService.createOrder(2L, List.of( - OrderItemModel.create(20L, 30000, 1, "상품B", "브랜드B"))); + OrderItemModel.create(20L, 30000, 1, "상품B", ("브랜드B")))); // act Page page = orderService.getAllOrders(PageRequest.of(0, 10)); 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 index 6443608b3..2bbac2183 100644 --- 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 @@ -4,6 +4,8 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertAll; +import com.loopers.domain.product.dto.ProductCommand; +import com.loopers.domain.product.dto.ProductInfo; import com.loopers.support.error.CoreException; import java.util.List; import org.junit.jupiter.api.BeforeEach; @@ -226,11 +228,11 @@ void validateAndDeductStock_success() { Long productId = productRepository.findAll(PageRequest.of(0, 20)) .getContent().get(0).getId(); - List commands = List.of( - new StockDeductionCommand(productId, 2, 150000)); + List commands = List.of( + new ProductCommand.StockDeduction(productId, 2, 150000)); // act - List snapshots = productService.validateAndDeductStock(commands); + List snapshots = productService.validateAndDeductStock(commands); // assert assertAll( @@ -247,7 +249,7 @@ void validateAndDeductStock_success() { @Test void validateAndDeductStock_whenProductNotFound() { assertThatThrownBy(() -> productService.validateAndDeductStock(List.of( - new StockDeductionCommand(999L, 1, 50000)))) + new ProductCommand.StockDeduction(999L, 1, 50000)))) .isInstanceOf(CoreException.class) .satisfies(e -> assertThat(((CoreException) e).getErrorCode()) .isEqualTo(ProductErrorCode.NOT_FOUND)); @@ -263,7 +265,7 @@ void validateAndDeductStock_whenPriceMismatch() { // act & assert assertThatThrownBy(() -> productService.validateAndDeductStock(List.of( - new StockDeductionCommand(productId, 1, 200000)))) + new ProductCommand.StockDeduction(productId, 1, 200000)))) .isInstanceOf(CoreException.class) .satisfies(e -> assertThat(((CoreException) e).getErrorCode()) .isEqualTo(ProductErrorCode.PRICE_MISMATCH)); @@ -279,7 +281,7 @@ void validateAndDeductStock_whenInsufficientStock() { // act & assert assertThatThrownBy(() -> productService.validateAndDeductStock(List.of( - new StockDeductionCommand(productId, 10, 150000)))) + new ProductCommand.StockDeduction(productId, 10, 150000)))) .isInstanceOf(CoreException.class); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/order/AdminOrderV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/order/AdminOrderV1ApiE2ETest.java index 9bc2ee982..61a376b04 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/order/AdminOrderV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/order/AdminOrderV1ApiE2ETest.java @@ -85,7 +85,7 @@ void setUp() { OrderModel order = orderJpaRepository.save( OrderModel.create(userId, List.of( - OrderItemModel.create(productId, 50000, 2, "오버사이즈 코트", "ACNE STUDIOS")))); + OrderItemModel.create(productId, 50000, 2, "오버사이즈 코트", ("ACNE STUDIOS"))))); orderId = order.getId(); orderItemId = order.getItems().get(0).getId(); } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/order/OrderV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/order/OrderV1ApiE2ETest.java index df8ba82cf..f1cd0e510 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/order/OrderV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/order/OrderV1ApiE2ETest.java @@ -88,7 +88,7 @@ void setUp() { OrderModel order = orderJpaRepository.save( OrderModel.create(userId, List.of( - OrderItemModel.create(productId, 50000, 2, "오버사이즈 코트", "ACNE STUDIOS")))); + OrderItemModel.create(productId, 50000, 2, "오버사이즈 코트", ("ACNE STUDIOS"))))); orderId = order.getId(); orderItemId = order.getItems().get(0).getId(); } From ce71fa507bb7410e9dcc944e60de647d9a09aded Mon Sep 17 00:00:00 2001 From: plan11plan Date: Sat, 28 Feb 2026 02:36:58 +0900 Subject: [PATCH 108/108] =?UTF-8?q?docs:=20DTO=20=EC=BB=A8=EB=B2=A4?= =?UTF-8?q?=EC=85=98=20=EB=AC=B8=EC=84=9C=EC=97=90=20Domain=20Info/Command?= =?UTF-8?q?=20=EB=B0=B0=EC=B9=98=20=EA=B7=9C=EC=B9=99=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .claude/agents/convention-review/AGENT.md | 2 +- .../references/common/dto-convention.md | 20 +++++++++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/.claude/agents/convention-review/AGENT.md b/.claude/agents/convention-review/AGENT.md index d888fd1d9..c17e02e77 100644 --- a/.claude/agents/convention-review/AGENT.md +++ b/.claude/agents/convention-review/AGENT.md @@ -40,7 +40,7 @@ git diff --name-only HEAD | `application/` | Application | `inline-variable-convention.md`, `service-layer-convention.md` | | `domain/` | Domain | `inline-variable-convention.md`, `entity-vo-convention.md` | | `infrastructure/` | Infrastructure | `infrastructure-convention.md` | -| `*Dto*.java`, `*Request*.java`, `*Response*.java`, `*Result*.java`, `*Criteria*.java` | DTO | `inline-variable-convention.md`, `dto-convention.md` | +| `*Dto*.java`, `*Request*.java`, `*Response*.java`, `*Result*.java`, `*Criteria*.java`, `*Command*.java`, `*Info*.java` | DTO | `inline-variable-convention.md`, `dto-convention.md` | `inline-variable-convention.md`는 **모든 계층**에서 필수로 포함한다. diff --git a/.claude/skills/project-convention/references/common/dto-convention.md b/.claude/skills/project-convention/references/common/dto-convention.md index 62391eb1c..6f1a73cb6 100644 --- a/.claude/skills/project-convention/references/common/dto-convention.md +++ b/.claude/skills/project-convention/references/common/dto-convention.md @@ -66,6 +66,13 @@ public class ProductCriteria { public class ProductCommand { public record Create(String name, int price) {} public record Update(Long id, String name, int price) {} + public record StockDeduction(Long productId, int quantity, int expectedPrice) {} +} +``` + +```java +public class ProductInfo { + public record StockDeduction(Long productId, String name, int price, int quantity, Long brandId) {} } ``` @@ -77,6 +84,9 @@ public record ProductResult(Long id, String name, int price) { } ``` +> **Domain DTO 배치**: `~Command`와 `~Info`는 모두 `domain/{도메인}/dto/` 패키지에 배치한다. +> `{Domain}Command`, `{Domain}Info` 그룹 클래스 아래 Inner Class(record)로 그룹핑한다. + ### 2. 변환 메서드 위치: "아는 쪽"에 둔다 의존 방향(상위 → 하위)을 지키며, **변환 대상을 아는 쪽**에 메서드를 배치한다. @@ -108,8 +118,11 @@ public Order create(OrderMemberCommand member, List product return Order.create(member.memberId(), products); } -// Entity로 표현 불가능할 때만 Info 생성 -public record StockDeductionInfo(int remainingStock, boolean success) {} +// Entity로 표현 불가능할 때만 Info 생성 — {Domain}Info의 Inner Class로 작성 +// 위치: domain/{도메인}/dto/{Domain}Info.java +public class ProductInfo { + public record StockDeduction(Long productId, String name, int price, int quantity, Long brandId) {} +} ``` --- @@ -215,3 +228,6 @@ Client - [ ] Domain Service 파라미터 1~3개는 원시 타입, 4개+는 Command DTO인가? - [ ] Domain Service 메서드 시그니처에 타 도메인 Entity가 노출되지 않는가? - [ ] 여러 도메인 Info 조합 시 Application에서 `~Result`로 합치는가? +- [ ] Domain DTO(`~Command`, `~Info`)가 `domain/{도메인}/dto/` 패키지에 있는가? +- [ ] Domain `~Command`는 `{Domain}Command`의 Inner Class로 그룹핑되었는가? +- [ ] Domain `~Info`는 `{Domain}Info`의 Inner Class로 그룹핑되었는가?