From 439b5a33d0997f9e29545642df4fc4246a8c8f0d Mon Sep 17 00:00:00 2001 From: APapeIsName Date: Sun, 22 Feb 2026 13:35:58 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20Member=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 - 회원가입 기능 (검증 + 암호화 + 저장) - 내 정보 조회 기능 (인증 + 마스킹) - 비밀번호 변경 기능 (인증 + 검증 + 변경) - 컨트롤러 구현 (REST API 엔드포인트) - 단위 테스트, 통합 테스트, E2E 테스트 작성 - 예외 처리 (ErrorType + CoreException) --- .gitignore | 3 + .../application/service/MemberService.java | 78 +++ .../service/dto/MemberRegisterRequest.java | 15 + .../service/dto/MyMemberInfoResponse.java | 11 + .../service/dto/PasswordUpdateRequest.java | 6 + .../loopers/controller/MemberController.java | 52 ++ .../member/MemberRepository.java | 13 + .../interfaces/api/ApiControllerAdvice.java | 8 + .../MemberServiceIntegrationTest.java | 211 +++++++ .../application/MemberServiceTest.java | 144 +++++ .../com/loopers/controller/MemberE2ETest.java | 81 +++ docs/design/member-class-diagram.md | 238 ++++++++ modules/jpa/build.gradle.kts | 7 + .../com/loopers/domain/member/Member.java | 74 +++ .../domain/member/MemberExceptionMessage.java | 119 ++++ .../domain/member/policy/MemberPolicy.java | 93 ++++ .../com/loopers/utils/PasswordEncryptor.java | 38 ++ .../domain/member/MemberTest.java | 520 ++++++++++++++++++ 18 files changed, 1711 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/service/MemberService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/service/dto/MemberRegisterRequest.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/service/dto/MyMemberInfoResponse.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/service/dto/PasswordUpdateRequest.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/controller/MemberController.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepository.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/MemberServiceIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/MemberServiceTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/controller/MemberE2ETest.java create mode 100644 docs/design/member-class-diagram.md create mode 100644 modules/jpa/src/main/java/com/loopers/domain/member/Member.java create mode 100644 modules/jpa/src/main/java/com/loopers/domain/member/MemberExceptionMessage.java create mode 100644 modules/jpa/src/main/java/com/loopers/domain/member/policy/MemberPolicy.java create mode 100644 modules/jpa/src/main/java/com/loopers/utils/PasswordEncryptor.java create mode 100644 modules/jpa/src/testFixtures/java/com/loopers/testcontainers/domain/member/MemberTest.java diff --git a/.gitignore b/.gitignore index 5a979af6f..df3d44c1a 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ out/ ### Kotlin ### .kotlin + +/.claude +CLAUDE.md \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/application/service/MemberService.java b/apps/commerce-api/src/main/java/com/loopers/application/service/MemberService.java new file mode 100644 index 000000000..78423e492 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/service/MemberService.java @@ -0,0 +1,78 @@ +package com.loopers.application.service; + +import com.loopers.application.service.dto.MemberRegisterRequest; +import com.loopers.application.service.dto.MyMemberInfoResponse; +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberExceptionMessage; +import com.loopers.infrastructure.member.MemberRepository; +import com.loopers.support.error.CoreException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class MemberService { + + private final MemberRepository memberRepository; + + @Transactional + public void register(MemberRegisterRequest request) { + boolean isLoginIdAlreadyExists = memberRepository.existsByLoginId(request.loginId()); + + if (isLoginIdAlreadyExists) { + throw new IllegalArgumentException(MemberExceptionMessage.LoginId.DUPLICATE_ID_EXISTS.message()); + } + + memberRepository.save(Member.register(request.loginId(), request.password(), request.name(), request.birthdate(), request.email())); + } + + @Transactional(readOnly = true) + public MyMemberInfoResponse getMyInfo(String userId, String password) { + // 1. 회원 조회 (없으면 예외 발생 - MemberExceptionMessage.Common.NOT_FOUND 사용) + Member member = memberRepository.findByLoginId(userId) + .orElseThrow(() -> new IllegalArgumentException(MemberExceptionMessage.ExistsMember.CANNOT_LOGIN.message())); + + // 2. 비밀번호 일치 여부 확인 (도메인 모델의 isSamePassword 활용) + if (!member.isSamePassword(password)) { + // 비밀번호 불일치 시 예외 발생 (인증 관련 메시지 사용) + throw new IllegalArgumentException(MemberExceptionMessage.ExistsMember.CANNOT_LOGIN.message()); + } + + // 3. DTO 변환 및 이름 마스킹 처리 + return new MyMemberInfoResponse( + member.getLoginId(), + maskName(member.getName()), // 마스킹 로직 적용 + member.getBirthDate(), + member.getEmail() + ); + } + + @Transactional + public void updatePassword(String userId, String currentPassword, String newPassword) { + // 1. 회원 조회 + Member member = memberRepository.findByLoginId(userId) + .orElseThrow(() -> new IllegalArgumentException(MemberExceptionMessage.ExistsMember.CANNOT_LOGIN.message())); + + // 2. 본인 확인 (기존 비밀번호 일치 여부) + if (!member.isSamePassword(currentPassword)) { + throw new IllegalArgumentException(MemberExceptionMessage.Password.PASSWORD_INCORRECT.message()); // 적절한 메시지로 변경 가능 + } + + // 4. 도메인 정책 검증 및 수정 (생년월일 포함 여부 등은 도메인 내 로직에서 처리) + member.updatePassword(newPassword); + } + + /** + * 이름의 마지막 글자를 *로 마스킹 처리 + */ + private String maskName(String name) { + if (name == null || name.isEmpty()) { + return ""; + } + if (name.length() == 1) { + return "*"; + } + return name.substring(0, name.length() - 1) + "*"; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/service/dto/MemberRegisterRequest.java b/apps/commerce-api/src/main/java/com/loopers/application/service/dto/MemberRegisterRequest.java new file mode 100644 index 000000000..3b2f9cd1a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/service/dto/MemberRegisterRequest.java @@ -0,0 +1,15 @@ +package com.loopers.application.service.dto; + +import lombok.Builder; + +import java.time.LocalDate; + +@Builder +public record MemberRegisterRequest ( + String loginId, + String password, + String name, + LocalDate birthdate, + String email +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/service/dto/MyMemberInfoResponse.java b/apps/commerce-api/src/main/java/com/loopers/application/service/dto/MyMemberInfoResponse.java new file mode 100644 index 000000000..77b8c4872 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/service/dto/MyMemberInfoResponse.java @@ -0,0 +1,11 @@ +package com.loopers.application.service.dto; + +import java.time.LocalDate; + +public record MyMemberInfoResponse( + String loginId, + String name, + LocalDate birthdate, + String email +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/service/dto/PasswordUpdateRequest.java b/apps/commerce-api/src/main/java/com/loopers/application/service/dto/PasswordUpdateRequest.java new file mode 100644 index 000000000..7ca9a26cc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/service/dto/PasswordUpdateRequest.java @@ -0,0 +1,6 @@ +package com.loopers.application.service.dto; + +public record PasswordUpdateRequest( + String newPassword +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/controller/MemberController.java b/apps/commerce-api/src/main/java/com/loopers/controller/MemberController.java new file mode 100644 index 000000000..8cd406465 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/controller/MemberController.java @@ -0,0 +1,52 @@ +package com.loopers.controller; + +import com.loopers.application.service.MemberService; +import com.loopers.application.service.dto.MemberRegisterRequest; +import com.loopers.application.service.dto.MyMemberInfoResponse; +import com.loopers.application.service.dto.PasswordUpdateRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/members") +@RequiredArgsConstructor +public class MemberController { + + private final MemberService memberService; + + /** + * 회원가입 + */ + @PostMapping("/register") + @ResponseStatus(HttpStatus.CREATED) + public void register(@RequestBody MemberRegisterRequest request) { + memberService.register(request); + } + + /** + * 내 정보 조회 + * 헤더 인증 (ID, PW) 기반 + */ + @GetMapping("/me") + public MyMemberInfoResponse getMyInfo( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password + ) { + return memberService.getMyInfo(loginId, password); + } + + /** + * 비밀번호 수정 + * 헤더 인증 (ID, 기존 PW) + 바디 (새 PW) + */ + @PatchMapping("/password") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void updatePassword( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String currentPassword, + @RequestBody PasswordUpdateRequest request + ) { + memberService.updatePassword(loginId, currentPassword, request.newPassword()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepository.java new file mode 100644 index 000000000..236e016c6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepository.java @@ -0,0 +1,13 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.member.Member; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface MemberRepository extends JpaRepository { + + boolean existsByLoginId(String inputId); + + Optional findByLoginId(String loginId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java index 20b2809c8..5852b88a4 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 @@ -113,6 +113,14 @@ public ResponseEntity> handle(Throwable e) { return failureResponse(ErrorType.INTERNAL_ERROR, null); } + // TODO: 예외처리 변경하는 게 좋을 것 같음 + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleIllegalArgumentException(IllegalArgumentException e) { + log.warn("IllegalArgumentException : {}", e.getMessage()); + return ResponseEntity.status(401) + .body(ApiResponse.fail("UNAUTHORIZED", e.getMessage())); + } + private String extractMissingParameter(String message) { Pattern pattern = Pattern.compile("'(.+?)'"); Matcher matcher = pattern.matcher(message); diff --git a/apps/commerce-api/src/test/java/com/loopers/application/MemberServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/MemberServiceIntegrationTest.java new file mode 100644 index 000000000..d6210ee28 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/MemberServiceIntegrationTest.java @@ -0,0 +1,211 @@ +package com.loopers.application; + +import com.loopers.application.service.MemberService; +import com.loopers.application.service.dto.MemberRegisterRequest; +import com.loopers.application.service.dto.MyMemberInfoResponse; +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberExceptionMessage; +import com.loopers.infrastructure.member.MemberRepository; +import com.loopers.utils.PasswordEncryptor; +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.transaction.annotation.Transactional; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest +@Transactional +public class MemberServiceIntegrationTest { + + @Autowired + private MemberService memberService; + + @Autowired + private MemberRepository memberRepository; + + @Test + public void 회원가입_성공() throws Exception { + // given + String inputId = "integrationId123"; + MemberRegisterRequest request = MemberRegisterRequest.builder() + .loginId(inputId) + .password("Pass!1234") + .name("공명선") + .birthdate(LocalDate.of(2001, 2, 9)) + .email("test@loopers.com") + .build(); + + // when + memberService.register(request); + + // then + assertThat(memberRepository.existsByLoginId(inputId)).isTrue(); + } + + @Test + void 회원가입_시_중복_아이디_사용_불가() { + // given + String duplicateId = "existingId"; + memberRepository.save(Member.builder() + .loginId(duplicateId) + .password("encodedPassword") + .name("기존유저") + .birthDate(LocalDate.of(1990, 1, 1)) + .email("old@test.com") + .build()); + + MemberRegisterRequest request = MemberRegisterRequest.builder() + .loginId(duplicateId) + .password("NewPass!123") + .name("신규유저") + .birthdate(LocalDate.of(2000, 1, 1)) + .email("new@test.com") + .build(); + + // when & then + assertThatThrownBy(() -> memberService.register(request)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("조회 성공: 올바른 ID와 비밀번호를 입력하면 마스킹된 정보를 반환한다") + void getMyInfo_Success() { + // given + String loginId = "tester123"; + String password = "password123!"; + memberRepository.save(Member.builder() + .loginId(loginId) + .password(PasswordEncryptor.encode(password)) // 실제로는 암호화된 값이 들어갈 지점 + .name("공명선") + .birthDate(LocalDate.of(2001, 2, 9)) + .email("test@loopers.com") + .build()); + + // when + MyMemberInfoResponse response = memberService.getMyInfo(loginId, password); + + // then + assertThat(response.loginId()).isEqualTo(loginId); + assertThat(response.name()).isEqualTo("공명*"); // 마스킹 검증 + assertThat(response.email()).isEqualTo("test@loopers.com"); + } + + @Test + @DisplayName("조회 실패: 아이디는 맞지만 비밀번호가 틀리면 예외를 던진다") + void getMyInfo_Fail_InvalidPassword() { + // given + String loginId = "tester123"; + memberRepository.save(Member.builder() + .loginId(loginId) + .password("correctPassword") + .name("공명선") + .birthDate(LocalDate.of(2001, 2, 9)) + .email("test@loopers.com") + .build()); + + // when & then + assertThatThrownBy(() -> memberService.getMyInfo(loginId, "wrongPassword")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(MemberExceptionMessage.ExistsMember.CANNOT_LOGIN.message()); + } + + @Test + @DisplayName("조회 실패: 존재하지 않는 아이디로 조회하면 예외를 던진다") + void getMyInfo_Fail_NotFoundId() { + // given + String unknownId = "nobody"; + String anyPassword = "anyPassword"; + + // when & then + assertThatThrownBy(() -> memberService.getMyInfo(unknownId, anyPassword)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(MemberExceptionMessage.ExistsMember.CANNOT_LOGIN.message()); + } + + /** + * 비밀번호 수정 통합 테스트 + */ + @Test + @DisplayName("비밀번호 수정 성공: 기존 비밀번호가 일치하고 새 비밀번호가 정책에 맞으면 수정된다") + void updatePassword_Success() { + // given + String loginId = "tester123"; + String currentPw = "oldPass123!"; + String newPw = "newPass5678@"; + + memberRepository.save(Member.builder() + .loginId(loginId) + .password(PasswordEncryptor.encode(currentPw)) + .name("공명선") + .birthDate(LocalDate.of(2001, 2, 9)) + .email("test@loopers.com") + .build()); + + // when + memberService.updatePassword(loginId, currentPw, newPw); + + // then + Member updatedMember = memberRepository.findByLoginId(loginId).orElseThrow(); + assertThat(updatedMember.isSamePassword(newPw)).isTrue(); + } + + @Test + @DisplayName("비밀번호 수정 실패: 현재 비밀번호와 새 비밀번호가 동일하면 예외가 발생한다") + void updatePassword_Fail_SamePassword() { + // given + String loginId = "tester123"; + String currentPw = "oldPass123!"; + + memberRepository.save(Member.builder() + .loginId(loginId) + .password(PasswordEncryptor.encode(currentPw)) + .birthDate(LocalDate.of(2001, 2, 9)) + .build()); + + // when & then + assertThatThrownBy(() -> memberService.updatePassword(loginId, currentPw, currentPw)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(MemberExceptionMessage.Password.PASSWORD_CANNOT_BE_SAME_AS_CURRENT.message()); + } + + @Test + @DisplayName("비밀번호 수정 실패: 새 비밀번호에 생년월일이 포함되면 예외가 발생한다") + void updatePassword_Fail_ContainsBirthDate() { + // given + String loginId = "tester123"; + String currentPw = "oldPass123!"; + String newPw = "pass20010209!"; // 생년월일 포함 + + memberRepository.save(Member.builder() + .loginId(loginId) + .password(PasswordEncryptor.encode(currentPw)) + .birthDate(LocalDate.of(2001, 2, 9)) + .build()); + + // when & then + assertThatThrownBy(() -> memberService.updatePassword(loginId, currentPw, newPw)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(MemberExceptionMessage.Password.PASSWORD_CONTAINS_BIRTHDATE.message()); + } + + @Test + @DisplayName("비밀번호 수정 실패: 현재 비밀번호가 틀리면 예외가 발생한다") + void updatePassword_Fail_IncorrectCurrentPassword() { + // given + String loginId = "tester123"; + memberRepository.save(Member.builder() + .loginId(loginId) + .password(PasswordEncryptor.encode("correct123!")) + .build()); + + // when & then + assertThatThrownBy(() -> memberService.updatePassword(loginId, "wrong123!", "newPass123!")) + .isInstanceOf(IllegalArgumentException.class); + } + +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/MemberServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/MemberServiceTest.java new file mode 100644 index 000000000..3963636b1 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/MemberServiceTest.java @@ -0,0 +1,144 @@ +package com.loopers.application; + +import com.loopers.application.service.MemberService; +import com.loopers.application.service.dto.MemberRegisterRequest; +import com.loopers.application.service.dto.MyMemberInfoResponse; +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberExceptionMessage; +import com.loopers.infrastructure.member.MemberRepository; +import com.loopers.utils.PasswordEncryptor; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +public class MemberServiceTest { + + @InjectMocks + private MemberService memberService; + + @Mock + private MemberRepository memberRepository; + + /** + * 회원가입 + */ + + @Test + public void 회원가입_시_아이디_중복_불가() throws Exception { + //given + String inputId = "apape123"; + MemberRegisterRequest request = MemberRegisterRequest.builder() + .loginId(inputId) + .password("password123!") + .name("공명선") + .birthdate(LocalDate.of(2001, 2, 9)) + .email("gms72901217@gmail.com").build(); + when(memberRepository.existsByLoginId(inputId)).thenReturn(true); + + //when + + //then + assertThatThrownBy(() -> memberService.register(request)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(MemberExceptionMessage.LoginId.DUPLICATE_ID_EXISTS.message()); + } + + @Test + public void 회원가입_성공() throws Exception { + //given + ArgumentCaptor memberCaptor = ArgumentCaptor.forClass(Member.class); + String inputId = "newId123"; + MemberRegisterRequest request = new MemberRegisterRequest( + inputId,"password123!","공명선",LocalDate.of(2001, 2, 9),"gms72901217@gmail.com"); + when(memberRepository.existsByLoginId(inputId)).thenReturn(false); + + //when + memberService.register(request); + + //then + verify(memberRepository).save(memberCaptor.capture()); + assertThat(memberCaptor.getValue().getLoginId()).isEqualTo(request.loginId()); + } + + /** + * 요청 공통 + */ + + @Test + public void 존재하지_않는_회원_조회_시_예외_발생() throws Exception { + //given + String dummyId = "unknownId"; + String dummyPwd = "password123!"; + given(memberRepository.findByLoginId(dummyId)).willReturn(Optional.empty()); + + //when + + //then + assertThatThrownBy(() -> memberService.getMyInfo(dummyId, dummyPwd)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(MemberExceptionMessage.ExistsMember.CANNOT_LOGIN.message()); + } + + /** + * 내 정보 조회 + */ + + @Test + public void 내_정보_조회_시_이름_마스킹() throws Exception { + // given + String loginId = "apape123"; + String password = "password123!"; + + Member member = Member.builder() + .loginId(loginId) + .password(PasswordEncryptor.encode(password)) + .name("공명선") + .birthDate(LocalDate.of(2001, 2, 9)) + .email("gms72901217@gmail.com") + .build(); + + given(memberRepository.findByLoginId(loginId)).willReturn(Optional.of(member)); + + // when + MyMemberInfoResponse response = memberService.getMyInfo(loginId, password); + + // then + assertThat(response.loginId()).isEqualTo(loginId); + + } + + @Test + @DisplayName("현재 비밀번호가 틀리면 수정을 진행하지 않고 예외를 던진다") + void updatePassword_Fail_InvalidCurrentPassword() { + // given + String loginId = "tester"; + Member member = Member.builder() + .loginId(loginId) + .password("correctPasswo") + .build(); + + given(memberRepository.findByLoginId(loginId)).willReturn(Optional.of(member)); + + // when & then + assertThatThrownBy(() -> memberService.updatePassword(loginId, "wrongPassword", "newPass123!")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(MemberExceptionMessage.Password.PASSWORD_INCORRECT.message()); + + // 비밀번호 수정 메서드가 호출되지 않았는지 간접적으로 확인 가능 (혹은 상태 검증) + } + +} diff --git a/apps/commerce-api/src/test/java/com/loopers/controller/MemberE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/controller/MemberE2ETest.java new file mode 100644 index 000000000..ad501412c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/controller/MemberE2ETest.java @@ -0,0 +1,81 @@ +package com.loopers.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.service.dto.MemberRegisterRequest; +import com.loopers.application.service.dto.PasswordUpdateRequest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +public class MemberE2ETest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Test + @DisplayName("전체 시나리오: 회원가입 - 내 정보 조회 - 비밀번호 변경") + void member_full_lifecycle_scenario() throws Exception { + // [1] 회원가입 (Register) + String loginId = "loopers123"; + String initialPw = "Initial!1234"; + MemberRegisterRequest registerRequest = new MemberRegisterRequest( + loginId, initialPw, "공명선", LocalDate.of(2001, 2, 9), "test@loopers.com" + ); + + mockMvc.perform(post("/api/members/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(registerRequest))) + .andExpect(status().isCreated()); + + // [2] 내 정보 조회 (Get Info) - 마스킹 확인 + mockMvc.perform(get("/api/members/me") + .header("X-Loopers-LoginId", loginId) + .header("X-Loopers-LoginPw", initialPw)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.loginId").value(loginId)) + .andExpect(jsonPath("$.name").value("공명*")) // 마지막 글자 마스킹 + .andExpect(jsonPath("$.email").value("test@loopers.com")); + + // [3] 비밀번호 수정 (Update Password) + String newPw = "Updated!5678"; + PasswordUpdateRequest updateRequest = new PasswordUpdateRequest(newPw); + + mockMvc.perform(patch("/api/members/password") + .header("X-Loopers-LoginId", loginId) + .header("X-Loopers-LoginPw", initialPw) // 수정 요청도 기존 헤더 인증 필요 + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest))) + .andExpect(status().isNoContent()); + + // [4] 변경된 비밀번호로 내 정보 재조회 (Re-verify) + // 새 비밀번호로 헤더를 보내야만 성공해야 함 + mockMvc.perform(get("/api/members/me") + .header("X-Loopers-LoginId", loginId) + .header("X-Loopers-LoginPw", newPw)) // New Password + .andExpect(status().isOk()) + .andExpect(jsonPath("$.loginId").value(loginId)); + + // [5] 기존 비밀번호로 조회 시도 (Should Fail) + mockMvc.perform(get("/api/members/me") + .header("X-Loopers-LoginId", loginId) + .header("X-Loopers-LoginPw", initialPw)) // Old Password + .andExpect(status().isUnauthorized()); // 또는 400 에러 (설정한 예외 처리에 따름) + } + +} diff --git a/docs/design/member-class-diagram.md b/docs/design/member-class-diagram.md new file mode 100644 index 000000000..a2696cf76 --- /dev/null +++ b/docs/design/member-class-diagram.md @@ -0,0 +1,238 @@ +# 회원(Member) 도메인 클래스 다이어그램 + +> 작성일: 2026-02-01 +> 상태: **Planner Mode - 승인 대기** + +## 1. 요구사항 정리 + +### 1.1 회원가입 요구사항 +| 항목 | 설명 | 검증 규칙 | +|------|------|-----------| +| loginId | 로그인 ID | 중복 불가, 포맷 검증 (추후 정의) | +| password | 비밀번호 | 암호화 저장, 아래 규칙 적용 | +| name | 이름 | 포맷 검증 (추후 정의) | +| email | 이메일 | 포맷 검증 (추후 정의) | +| birthDate | 생년월일 | 비밀번호 검증에 사용 | + +### 1.2 비밀번호 규칙 +1. **길이**: 8~16자 +2. **허용 문자**: 영문 대소문자, 숫자, 특수문자만 가능 +3. **금지 조건**: 생년월일(YYYYMMDD, YYMMDD, MMDD 등)이 비밀번호에 포함될 수 없음 + +--- + +## 2. 클래스 다이어그램 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ <> │ +│ BaseEntity │ +├─────────────────────────────────────────────────────────────┤ +│ - id: Long │ +│ - createdAt: ZonedDateTime │ +│ - updatedAt: ZonedDateTime │ +│ - deletedAt: ZonedDateTime │ +├─────────────────────────────────────────────────────────────┤ +│ + delete(): void │ +│ + restore(): void │ +│ # guard(): void │ +└─────────────────────────────────────────────────────────────┘ + △ + │ extends + │ +┌─────────────────────────────────────────────────────────────┐ +│ <> │ +│ Member │ +├─────────────────────────────────────────────────────────────┤ +│ - loginId: String // 로그인 ID (Unique) │ +│ - password: String // 암호화된 비밀번호 │ +│ - name: String // 이름 │ +│ - email: String // 이메일 │ +│ - birthDate: LocalDate // 생년월일 │ +├─────────────────────────────────────────────────────────────┤ +│ + Member(loginId, rawPassword, name, email, birthDate, │ +│ passwordEncoder): Member │ +│ + updatePassword(rawPassword, passwordEncoder): void │ +│ + matchesPassword(rawPassword, passwordEncoder): boolean │ +│ # guard(): void │ +└─────────────────────────────────────────────────────────────┘ + │ + │ uses + ▽ +┌─────────────────────────────────────────────────────────────┐ +│ <> │ +│ PasswordValidator │ +├─────────────────────────────────────────────────────────────┤ +│ + validate(rawPassword, birthDate): void │ +│ - validateLength(password): void │ +│ - validateCharacters(password): void │ +│ - validateNotContainsBirthDate(password, birthDate): void │ +└─────────────────────────────────────────────────────────────┘ + + +┌─────────────────────────────────────────────────────────────┐ +│ <> │ +│ PasswordEncoder │ +├─────────────────────────────────────────────────────────────┤ +│ + encode(rawPassword): String │ +│ + matches(rawPassword, encodedPassword): boolean │ +└─────────────────────────────────────────────────────────────┘ + △ + │ implements + │ +┌─────────────────────────────────────────────────────────────┐ +│ <> │ +│ BCryptPasswordEncoder (Spring) │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## 3. 계층별 클래스 구조 + +``` +com.loopers +├── interfaces/ +│ └── api/ +│ └── member/ +│ ├── MemberV1Controller.java // REST API +│ ├── MemberV1ApiSpec.java // OpenAPI 스펙 +│ └── MemberV1Dto.java // 요청/응답 DTO +│ +├── application/ +│ └── member/ +│ ├── MemberFacade.java // 비즈니스 조율 +│ └── MemberInfo.java // 응답 정보 (Record) +│ +├── domain/ +│ └── member/ +│ ├── Member.java // 엔티티 +│ ├── MemberService.java // 도메인 서비스 +│ ├── MemberRepository.java // 도메인 인터페이스 +│ └── PasswordValidator.java // 비밀번호 검증 +│ +└── infrastructure/ + └── member/ + ├── MemberJpaRepository.java // Spring Data JPA + └── MemberRepositoryImpl.java // 도메인 구현체 +``` + +--- + +## 4. 주요 클래스 상세 + +### 4.1 Member (엔티티) + +```java +@Entity +@Table(name = "member") +public class Member extends BaseEntity { + + @Column(name = "login_id", nullable = false, unique = true) + private String loginId; + + @Column(name = "password", nullable = false) + private String password; // 암호화된 값 + + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "email", nullable = false) + private String email; + + @Column(name = "birth_date", nullable = false) + private LocalDate birthDate; + + // JPA용 기본 생성자 + protected Member() {} + + // 생성자에서 비밀번호 검증 + 암호화 + public Member(String loginId, String rawPassword, String name, + String email, LocalDate birthDate, + PasswordEncoder passwordEncoder) { + PasswordValidator.validate(rawPassword, birthDate); + this.loginId = loginId; + this.password = passwordEncoder.encode(rawPassword); + this.name = name; + this.email = email; + this.birthDate = birthDate; + guard(); + } +} +``` + +### 4.2 PasswordValidator (검증기) + +```java +public final class PasswordValidator { + + private static final int MIN_LENGTH = 8; + private static final int MAX_LENGTH = 16; + private static final Pattern VALID_PATTERN = + Pattern.compile("^[a-zA-Z0-9!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?]+$"); + + public static void validate(String rawPassword, LocalDate birthDate) { + validateLength(rawPassword); + validateCharacters(rawPassword); + validateNotContainsBirthDate(rawPassword, birthDate); + } + + // 8~16자 검증 + private static void validateLength(String password) { ... } + + // 영문 대소문자, 숫자, 특수문자만 허용 + private static void validateCharacters(String password) { ... } + + // 생년월일 포함 여부 검증 (YYYYMMDD, YYMMDD, MMDD 등) + private static void validateNotContainsBirthDate(String password, LocalDate birthDate) { ... } +} +``` + +--- + +## 5. 데이터베이스 스키마 + +```sql +CREATE TABLE member ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + login_id VARCHAR(50) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL, -- BCrypt 해시 + name VARCHAR(100) NOT NULL, + email VARCHAR(255) NOT NULL, + birth_date DATE NOT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + deleted_at DATETIME(6) NULL, + + INDEX idx_member_login_id (login_id), + INDEX idx_member_email (email) +); +``` + +--- + +## 6. 검토 필요 사항 + +### 확인 요청 +1. **loginId 포맷**: 어떤 형식을 허용할지 (영문+숫자, 길이 제한 등) +2. **email 포맷**: 표준 이메일 검증만 할지, 특정 도메인 제한이 있는지 +3. **name 포맷**: 한글/영문 허용 범위, 길이 제한 +4. **생년월일 검증 범위**: `YYYYMMDD`, `YYMMDD`, `MMDD` 외 추가 패턴이 있는지 + +### 추후 확장 고려 +- [ ] 로그인 기능 (JWT/Session) +- [ ] 비밀번호 변경 +- [ ] 이메일 인증 +- [ ] 소셜 로그인 연동 + +--- + +## 7. 승인 요청 + +위 설계에 대해 검토 부탁드립니다. + +- [ ] 클래스 구조 승인 +- [ ] DB 스키마 승인 +- [ ] 검증 규칙 추가 정보 제공 + +**승인 후 TDD Red Phase로 진입합니다.** diff --git a/modules/jpa/build.gradle.kts b/modules/jpa/build.gradle.kts index e62a6a7ed..941d8c272 100644 --- a/modules/jpa/build.gradle.kts +++ b/modules/jpa/build.gradle.kts @@ -18,4 +18,11 @@ dependencies { testFixturesImplementation("org.springframework.boot:spring-boot-starter-data-jpa") testFixturesImplementation("org.testcontainers:mysql") + + testFixturesImplementation("org.junit.jupiter:junit-jupiter-api") + testFixturesImplementation("org.assertj:assertj-core") + + testFixturesImplementation("org.junit.jupiter:junit-jupiter-params") + + testFixturesRuntimeOnly("org.junit.jupiter:junit-jupiter-engine") } diff --git a/modules/jpa/src/main/java/com/loopers/domain/member/Member.java b/modules/jpa/src/main/java/com/loopers/domain/member/Member.java new file mode 100644 index 000000000..4b4155e84 --- /dev/null +++ b/modules/jpa/src/main/java/com/loopers/domain/member/Member.java @@ -0,0 +1,74 @@ +package com.loopers.domain.member; + +import com.loopers.domain.member.policy.*; +import com.loopers.utils.PasswordEncryptor; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Entity +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Table(name = "member") +public class Member { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String loginId; + + private String password; + + private String name; + + private LocalDate birthDate; + + private String email; + + public static Member register( + String loginId, + String password, + String name, + LocalDate birthDate, + String email + ) { + MemberPolicy.LoginId.validate(loginId); + MemberPolicy.BirthDate.validate(birthDate); + MemberPolicy.Password.validate(password, birthDate); + MemberPolicy.Name.validate(name); + MemberPolicy.Email.validate(email); + + return Member.builder() + .loginId(loginId) + .password(encodedPassword(password)) + .name(name) + .birthDate(birthDate) + .email(email) + .build(); + } + + public boolean isSamePassword(String inputPassword) { + return PasswordEncryptor.matches(inputPassword, this.password); + } + + public void updatePassword(String newPassword) { + if (isSamePassword(newPassword)) { + throw new IllegalArgumentException(MemberExceptionMessage.Password.PASSWORD_CANNOT_BE_SAME_AS_CURRENT.message()); + } + MemberPolicy.Password.validate(newPassword, birthDate); + + this.password = encodedPassword(newPassword); + } + + private static String encodedPassword(String password) { + return PasswordEncryptor.encode(password); + } + +} diff --git a/modules/jpa/src/main/java/com/loopers/domain/member/MemberExceptionMessage.java b/modules/jpa/src/main/java/com/loopers/domain/member/MemberExceptionMessage.java new file mode 100644 index 000000000..8adce180d --- /dev/null +++ b/modules/jpa/src/main/java/com/loopers/domain/member/MemberExceptionMessage.java @@ -0,0 +1,119 @@ +package com.loopers.domain.member; + +import lombok.AllArgsConstructor; + +public class MemberExceptionMessage { + + public interface ExceptionMessage { + String message(); + } + + /** + * 1_000 ~ 1_099: 로그인 ID 관련 오류 + */ + @AllArgsConstructor + public enum LoginId implements ExceptionMessage { + INVALID_ID_FORMAT("아이디는 영문이 포함되어야 하며, 한글이나 특수문자는 사용할 수 없습니다.", 1_001), + INVALID_ID_NUMERIC_ONLY("아이디를 숫자만으로 구성할 수 없습니다. 영문을 포함해주세요.", 1_002), + DUPLICATE_ID_EXISTS("이미 사용 중인 아이디입니다.", 1_003), + INVALID_ID_LENGTH("아이디는 6글자 이상, 20글자 이하여야 합니다.", 1_004) + ; + + private final String message; + private final Integer code; + + public String message() { + return message; + } + } + + /** + * 1_100 ~ 1_199: 비밀번호 관련 오류 + */ + + @AllArgsConstructor + public enum Password implements ExceptionMessage { + // 1. 기본 형식 검증 (Basic Format) + // 1-1. 길이 제한 (8~16자) + INVALID_PASSWORD_LENGTH("비밀번호는 8자 이상 16자 이하여야 합니다.", 1_101), + + // 메시지 수정: '모두 포함' -> '영문, 숫자, 특수문자만 사용 가능' + INVALID_PASSWORD_COMPOSITION("비밀번호는 영문, 숫자, 특수문자만 사용할 수 있으며 한글 등은 포함할 수 없습니다.", 1_102), + + // 2. 생년월일 포함 금지 규칙 (Zero-Birthdate Policy) + // 2-1, 2-2, 2-3 통합 검증 + PASSWORD_CONTAINS_BIRTHDATE("비밀번호에 생년월일 정보(YYYYMMDD 또는 YYMMDD)를 포함할 수 없습니다.", 1_103), + + // 3. 수정 시 정책 (Update Policy) + // 3-1. 재사용 금지 + PASSWORD_CANNOT_BE_SAME_AS_CURRENT("현재 사용 중인 비밀번호와 동일한 비밀번호로 변경할 수 없습니다.", 1_104), + + PASSWORD_NOT_ENCODED("비밀번호가 암호화되지 않았습니다.", 1_105), + + PASSWORD_INCORRECT("비밀번호가 일치하지 않습니다.", 1_106) + + ; + + private final String message; + private final Integer code; + + public String message() { + return message; + } + } + + /** + * 1_200 ~ 1_219: 이름(Name) 관련 오류 + */ + @AllArgsConstructor + public enum Name implements ExceptionMessage { + TOO_SHORT("이름은 최소 2자 이상이어야 합니다.", 1_201), + TOO_LONG("이름은 최대 40자까지 가능합니다.", 1_202), + CONTAINS_INVALID_CHAR("이름에 숫자나 특수문자를 포함할 수 없습니다. 한글과 영문만 가능합니다.", 1_203); + + private final String message; + private final Integer code; + public String message() { return message; } + } + + /** + * 1_220 ~ 1_239: 이메일(Email) 관련 오류 + */ + @AllArgsConstructor + public enum Email implements ExceptionMessage { + INVALID_FORMAT("유효하지 않은 이메일 형식입니다.", 1_221), + TOO_LONG("이메일은 255자를 초과할 수 없습니다.", 1_222); + + private final String message; + private final Integer code; + public String message() { return message; } + } + + /** + * 1_240 ~ 1_259: 생년월일(BirthDate) 관련 오류 + */ + @AllArgsConstructor + public enum BirthDate implements ExceptionMessage { + CANNOT_BE_FUTURE("생년월일은 미래 날짜일 수 없습니다.", 1_241); + + private final String message; + private final Integer code; + public String message() { return message; } + } + + /** + * 1_300 ~ 1_399: 회원(Member) 존재 여부 관련 오류 + */ + @AllArgsConstructor + public enum ExistsMember implements ExceptionMessage { + CANNOT_LOGIN("아이디나 비밀번호가 잘못됐습니다.", 1_301); + + private final String message; + private final Integer code; + + public String message() { + return message; + } + } + +} diff --git a/modules/jpa/src/main/java/com/loopers/domain/member/policy/MemberPolicy.java b/modules/jpa/src/main/java/com/loopers/domain/member/policy/MemberPolicy.java new file mode 100644 index 000000000..3bfcca8f1 --- /dev/null +++ b/modules/jpa/src/main/java/com/loopers/domain/member/policy/MemberPolicy.java @@ -0,0 +1,93 @@ +package com.loopers.domain.member.policy; + +import com.loopers.domain.member.MemberExceptionMessage; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +public class MemberPolicy { + + public static class LoginId { + public static void validate(String loginId) { + // 1-4. 길이 제한 (6~20자) + if (loginId == null || loginId.length() < 6 || loginId.length() > 20) { + throw new IllegalArgumentException(MemberExceptionMessage.LoginId.INVALID_ID_LENGTH.message()); + } + // 1-2. 숫자만 존재할 수 없음 + if (loginId.matches("^[0-9]*$")) { + throw new IllegalArgumentException(MemberExceptionMessage.LoginId.INVALID_ID_NUMERIC_ONLY.message()); + } + // 1-1. 영문/숫자만 허용 및 영문 필수 포함 (한글, 특수문자 불가) + if (!loginId.matches("^[a-zA-Z0-9]*$") || !loginId.matches(".*[a-zA-Z].*")) { + throw new IllegalArgumentException(MemberExceptionMessage.LoginId.INVALID_ID_FORMAT.message()); + } + } + } + + public static class Password { + public static void validate(String password, LocalDate birthDate) { + // 1-1. 길이 제한 (8~16자) + if (password == null || password.length() < 8 || password.length() > 16) { + throw new IllegalArgumentException(MemberExceptionMessage.Password.INVALID_PASSWORD_LENGTH.message()); + } + + // 1-2. 조합 규칙 수정: "한글 등 허용되지 않은 문자"가 포함되었는지만 체크 + // 영문, 숫자, 특수문자(@$!%*?&)만 허용하는 정규식으로 변경 (필수 포함 조건 삭제) + String allowedCharsRegex = "^[A-Za-z\\d@$!%*?&]*$"; + if (!password.matches(allowedCharsRegex)) { + throw new IllegalArgumentException(MemberExceptionMessage.Password.INVALID_PASSWORD_COMPOSITION.message()); + } + + // 2. 생년월일 포함 금지 규칙 (Zero-Birthdate Policy) + // 이 로직에 도달하기 전에 위 정규식에서 튕기지 않도록 테스트 데이터가 수정되거나 정규식이 유연해야 합니다. + String yyyyMMdd = birthDate.format(DateTimeFormatter.ofPattern("yyyyMMdd")); + String yyMMdd = yyyyMMdd.substring(2); + if (password.contains(yyyyMMdd) || password.contains(yyMMdd)) { + throw new IllegalArgumentException(MemberExceptionMessage.Password.PASSWORD_CONTAINS_BIRTHDATE.message()); + } + } + } + + public static class Name { + public static void validate(String name) { + // 이름 길이 체크 + if (name == null || name.length() < 2) { + throw new IllegalArgumentException(MemberExceptionMessage.Name.TOO_SHORT.message()); + } + if (name.length() > 40) { + throw new IllegalArgumentException(MemberExceptionMessage.Name.TOO_LONG.message()); + } + // 한글/영문만 허용 (숫자, 특수문자 불가) + if (!name.matches("^[a-zA-Z가-힣\\s]*$")) { + throw new IllegalArgumentException(MemberExceptionMessage.Name.CONTAINS_INVALID_CHAR.message()); + } + } + } + + public static class Email { + public static void validate(String email) { + if (email == null) throw new IllegalArgumentException("이메일은 필수입니다."); + + // 길이 체크 + if (email.length() > 255) { + throw new IllegalArgumentException(MemberExceptionMessage.Email.TOO_LONG.message()); + } + // RFC 5321 기반 기본 형식 체크 + if (!email.matches("^[A-Za-z0-9+_.-]+@(.+)$")) { + throw new IllegalArgumentException(MemberExceptionMessage.Email.INVALID_FORMAT.message()); + } + } + } + + public static class BirthDate { + public static void validate(LocalDate birthDate) { + if (birthDate == null) throw new IllegalArgumentException("생년월일은 필수입니다."); + + // 미래 날짜 불가 + if (birthDate.isAfter(LocalDate.now())) { + throw new IllegalArgumentException(MemberExceptionMessage.BirthDate.CANNOT_BE_FUTURE.message()); + } + } + } + +} diff --git a/modules/jpa/src/main/java/com/loopers/utils/PasswordEncryptor.java b/modules/jpa/src/main/java/com/loopers/utils/PasswordEncryptor.java new file mode 100644 index 000000000..a3503503b --- /dev/null +++ b/modules/jpa/src/main/java/com/loopers/utils/PasswordEncryptor.java @@ -0,0 +1,38 @@ +package com.loopers.utils; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; + +public class PasswordEncryptor { + + private static final String ALGORITHM = "SHA-256"; + + /** + * 비밀번호를 SHA-256으로 해싱함. + * (주의: 이 예제는 순수 해싱만 수행합니다. 실무에선 Salt를 추가해야 안전합니다.) + */ + public static String encode(String rawPassword) { + try { + MessageDigest digest = MessageDigest.getInstance(ALGORITHM); + byte[] encodedHash = digest.digest(rawPassword.getBytes(StandardCharsets.UTF_8)); + + // 바이트 배열을 읽기 쉬운 문자열(Base64)로 변환 + return Base64.getEncoder().encodeToString(encodedHash); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("암호화 알고리즘을 찾을 수 없습니다.", e); + } + } + + /** + * 일치 여부 확인 + */ + public static boolean matches(String rawPassword, String encodedPassword) { + if (rawPassword == null || encodedPassword == null) return false; + + // 입력받은 원문을 똑같이 해싱해서 결과가 같은지 비교 + String newHash = encode(rawPassword); + return newHash.equals(encodedPassword); + } +} diff --git a/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/domain/member/MemberTest.java b/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/domain/member/MemberTest.java new file mode 100644 index 000000000..06c44d3dd --- /dev/null +++ b/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/domain/member/MemberTest.java @@ -0,0 +1,520 @@ +package com.loopers.testcontainers.domain.member; + +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberExceptionMessage; +import com.loopers.utils.PasswordEncryptor; +import org.assertj.core.api.AbstractThrowableAssert; +import org.assertj.core.api.LocalDateAssert; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +/** + * Member 도메인 엔티티 유효성 검증 테스트 + * + * 검증 대상: + * - 로그인 ID: 영문/숫자만 허용, 6~20글자 + * - 비밀번호: 8~16자, 영문 대소문자 + 숫자 + 특수문자 조합 + * - 이름: 한글/영어 허용, 2~40글자 + * - 이메일: RFC 5321 표준 준수 + */ +@DisplayName("Member 도메인 유효성 검증 테스트") +class MemberTest { + + // 테스트용 유효한 기본값 + private static final String VALID_LOGIN_ID = "hello1234"; + private static final String VALID_PASSWORD = "Password1!"; + private static final String VALID_NAME = "홍길동"; + private static final LocalDate VALID_BIRTH_DATE = LocalDate.of(2001, 2, 9); + private static final String VALID_EMAIL = "test@example.com"; + + @Test + public void 회원가입_성공() throws Exception { + //given + + //when + + //then + assertDoesNotThrow(() -> + Member.register(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTH_DATE, VALID_EMAIL) + ); + + } + + @DisplayName("회원가입 시에 ID 검증") + @Nested + class LoginIdValidation { + + // 1. 아이디는 영문과 숫자가 합쳐진 경우 허용하며, 중복 가입할 수 없으며, 6~20글자여야 함. + // 1-1. 아이디는 영문이 들어가야 함. + // 1-1-1. 아이디는 영문이 아닌 한글이 들어갈 수 없음. + // 1-1-2. 아이디는 영문이 아닌 특수 문자가 들어갈 수 없음. + // 1-2. 아이디는 숫자만 존재할 수 없음. + // 1-3. 아이디는 중복 가입할 수 없음. -> + // 1-3-1. 아이디가 중복인 경우 예외 메시지를 띄워야 함. + // 1-4. 아이디의 길이는 6~20글자여야 함. + // 1-4-1. 아이디의 길이는 6글자 미만일 수 없음. + // 1-4-2. 아이디의 길이는 20글자 초과일 수 없음. + + // 1-1-1 + @Test + public void 아이디는_영문이_아닌_한글이_들어갈_수_없음() throws Exception { + //given + String wrongId = "한글입slek"; + + //when + + //then + throwIfWrongIdInput(wrongId) + .hasMessage(MemberExceptionMessage.LoginId.INVALID_ID_FORMAT.message()); + + } + + // 1-1-2 + @Test + public void 아이디는_영문이_아닌_특수문자가_들어갈_수_없음() throws Exception + { + //given + String wrongId = "@apgl!#"; + + //when + + //then + throwIfWrongIdInput(wrongId) + .hasMessage(MemberExceptionMessage.LoginId.INVALID_ID_FORMAT.message()); + } + + // 1-2 + @Test + public void 아이디는_숫자만_존재할_수_없음() throws Exception { + //given + String wrongId = "12345678"; + + //when + + //then + throwIfWrongIdInput(wrongId) + .hasMessage(MemberExceptionMessage.LoginId.INVALID_ID_NUMERIC_ONLY.message()); + } + + // 1-3 -> 레포지토리 가져오니까 서비스의 통합 테스트 + @Test + public void 아이디는_중복_가입할_수_없음() throws Exception { + //given + + //when + + //then + + } + + // 1-4-1 + @Test + public void 아이디의_길이_6자_미만_불가() throws Exception { + //given + String wrongId = "ap245"; + + //when + + //then + throwIfWrongIdInput(wrongId) + .hasMessage(MemberExceptionMessage.LoginId.INVALID_ID_LENGTH.message()); + } + + // 1-4-2 + @Test + public void 아이디의_길이_20자_초과_불가() throws Exception { + //given + String wrongId = "apapeisname1234ppap56"; // 21글자 + + //when + + //then + throwIfWrongIdInput(wrongId) + .hasMessage(MemberExceptionMessage.LoginId.INVALID_ID_LENGTH.message()); + } + + private AbstractThrowableAssert throwIfWrongIdInput(String wrongId) { + return assertThatThrownBy(() -> Member.register(wrongId, VALID_PASSWORD, VALID_NAME, VALID_BIRTH_DATE, VALID_EMAIL)) + .isInstanceOf(IllegalArgumentException.class); + } + } + + /** + * [비밀번호 보안 정책 - Zero-Birthdate Policy 기반] + * * 1. 기본 형식 검증 (Basic Format) + * - 1-1. 길이는 8자 이상 16자 이하여야 함. + * - 1-1-1. 길이는 8자 미만일 수 없음 + * - 1-1-2. 길이는 16자 초과일 수 없음 + * - 1-2. 영문 대문자, 영문 소문자, 숫자, 특수문자가 최소 1개 이상 포함된 조합이어야 함. + * * 2. 생년월일 포함 금지 규칙 (Zero-Birthdate Policy) + * - 2-1. 사용자의 생년월일(YYYYMMDD)이 비밀번호 문자열 내에 포함될 수 없음. (ex: "pass19950520!") + * - 2-2. 사용자의 생년월일(YYMMDD)이 비밀번호 문자열 내에 포함될 수 없음. (ex: "950520pass#") + * * 3. 수정 시 정책 (Update Policy) + * - 3-1. 현재 사용 중인 비밀번호(암호화 전 원문 기준)와 동일한 비밀번호로 변경 불가. -> Service, 통합 + * * 4. 보안 전제 조건 + * - 4-1. DB 저장 전 반드시 단방향 해시 암호화 과정을 거쳐야 함. + */ + + @DisplayName("비밀번호 형식 검증") + @Nested + class PasswordFormatValidation { + + // 1-1-1 + @Test + public void 비밀번호_길이는_8자_미만일_수_없음() throws Exception { + //given + String wrongPassword = "pap1234"; // 7글자 + + //when + + //then + throwIfWrongPasswordInput(wrongPassword) + .hasMessage(MemberExceptionMessage.Password.INVALID_PASSWORD_LENGTH.message()); + } + + // 1-1-2 + @Test + public void 비밀번호_길이는_16자_초과일_수_없음() throws Exception { + //given + String wrongPassword = "qwer1234tyui5678a"; // 17글자 + + //when + + //then + throwIfWrongPasswordInput(wrongPassword) + .hasMessage(MemberExceptionMessage.Password.INVALID_PASSWORD_LENGTH.message()); + + } + + // 1-2 + @Test + public void 비밀번호는_영문_숫자_특수문자만_사용할_수_있음() throws Exception { + //given + String wrongPassword = "한글password123"; + + //when + + //then + throwIfWrongPasswordInput(wrongPassword) + .hasMessage(MemberExceptionMessage.Password.INVALID_PASSWORD_COMPOSITION.message()); + + } + + // 2-1 + @Test + public void 사용자_생년월일_YYYYMMDD가_비밀번호_포함_불가() throws Exception { + //given + LocalDate userBirthDate = LocalDate.of(2001, 2, 9); + String wrongPassword = "pwd20010209!"; + + //when + + //then + throwIfPasswordContainsBirthDate(wrongPassword, userBirthDate) + .hasMessage(MemberExceptionMessage.Password.PASSWORD_CONTAINS_BIRTHDATE.message()); + } + + // 2-2 + @Test + public void 사용자_생년월일_YYMMDD가_비밀번호_포함_불가() throws Exception { + //given + LocalDate userBirthDate = LocalDate.of(2001, 2, 9); + String wrongPassword = "pass010209!"; + + //when + + //then + throwIfPasswordContainsBirthDate(wrongPassword, userBirthDate) + .hasMessage(MemberExceptionMessage.Password.PASSWORD_CONTAINS_BIRTHDATE.message()); + } + + // 4 + @Test + public void 비밀번호는_암호화해_저장() throws Exception { + //given + + //when + String encodedPassword = PasswordEncryptor.encode(VALID_PASSWORD); + + //then + assertThat(PasswordEncryptor.matches(VALID_PASSWORD, encodedPassword)).isTrue(); + } + + private AbstractThrowableAssert throwIfWrongPasswordInput(String wrongPassword) { + return assertThatThrownBy(() -> Member.register(VALID_LOGIN_ID, wrongPassword, VALID_NAME, VALID_BIRTH_DATE, VALID_EMAIL)) + .isInstanceOf(IllegalArgumentException.class); + } + + private AbstractThrowableAssert throwIfPasswordContainsBirthDate(String wrongPassword, LocalDate birthDate) { + return assertThatThrownBy(() -> Member.register(VALID_LOGIN_ID, wrongPassword, VALID_NAME, birthDate, VALID_EMAIL)) + .isInstanceOf(IllegalArgumentException.class); + } + + } + + @DisplayName("이름(Name) 유효성 검증") + @Nested + class NameValidation { + + @Test + void 이름은_2자_미만일_수_없음() { + String shortName = "홍"; + throwIfWrongNameInput(shortName) + .hasMessage(MemberExceptionMessage.Name.TOO_SHORT.message()); + } + + @Test + void 이름은_40자를_초과할_수_없음() { + String longName = "가".repeat(41); + throwIfWrongNameInput(longName) + .hasMessage(MemberExceptionMessage.Name.TOO_LONG.message()); + } + + @Test + void 이름에_숫자가_포함될_수_없음() { + String nameWithDigit = "홍길동1"; + throwIfWrongNameInput(nameWithDigit) + .hasMessage(MemberExceptionMessage.Name.CONTAINS_INVALID_CHAR.message()); + } + + @Test + void 이름에_특수문자가_포함될_수_없음() { + String nameWithSpecial = "John@"; + throwIfWrongNameInput(nameWithSpecial) + .hasMessage(MemberExceptionMessage.Name.CONTAINS_INVALID_CHAR.message()); + } + + private AbstractThrowableAssert throwIfWrongNameInput(String name) { + return assertThatThrownBy(() -> Member.register(VALID_LOGIN_ID, VALID_PASSWORD, name, VALID_BIRTH_DATE, VALID_EMAIL)) + .isInstanceOf(IllegalArgumentException.class); + } + } + + @DisplayName("이메일(Email) 유효성 검증") + @Nested + class EmailValidation { + + @Test + void 이메일_기본_형식을_준수해야_함() { + String wrongEmail = "test#example.com"; // @ 없음 + throwIfWrongEmailInput(wrongEmail) + .hasMessage(MemberExceptionMessage.Email.INVALID_FORMAT.message()); + } + + @Test + @DisplayName("이메일은 255자를 초과할 수 없음") + void emailTooLong() { + String longEmail = "a".repeat(250) + "@test.com"; + throwIfWrongEmailInput(longEmail) + .hasMessage(MemberExceptionMessage.Email.TOO_LONG.message()); + } + + private AbstractThrowableAssert throwIfWrongEmailInput(String email) { + return assertThatThrownBy(() -> Member.register(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTH_DATE, email)) + .isInstanceOf(IllegalArgumentException.class); + } + } + + @DisplayName("생년월일(BirthDate) 유효성 검증") + @Nested + class BirthDateValidation { + + @Test + @DisplayName("미래_날짜는_생년월일_등록_불가") + void birthDateCannotBeFuture() { + LocalDate futureDate = LocalDate.now().plusDays(1); + throwIfWrongBirthDateInput(futureDate) + .hasMessage(MemberExceptionMessage.BirthDate.CANNOT_BE_FUTURE.message()); + } + + private AbstractThrowableAssert throwIfWrongBirthDateInput(LocalDate birthDate) { + return assertThatThrownBy(() -> Member.register(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, birthDate, VALID_EMAIL)) + .isInstanceOf(IllegalArgumentException.class); + } + } + + @DisplayName("회원가입 통합 성공 검증") + @Nested + class RegistrationSuccess { + + @Test + @DisplayName("모든 조건이 유효하면 회원가입에 성공한다") + void successWhenAllFieldsValid() { + assertThatCode(() -> Member.register(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTH_DATE, VALID_EMAIL)) + .doesNotThrowAnyException(); + } + } + + @DisplayName("요청 시 비밀번호 동일한지 검증") + @Nested + class SamePasswordValidation { + + @Test + @DisplayName("입력받은 비밀번호가 저장된 비밀번호와 정확히 일치하면 true를 반환한다") + void isSamePassword_Success() { + // given + String savedPassword = "password123!"; + Member member = Member.builder() + .password(PasswordEncryptor.encode(savedPassword)) + .build(); + + // when + boolean result = member.isSamePassword("password123!"); + + // then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("입력받은 비밀번호가 저장된 비밀번호와 다르면 false를 반환한다") + void isSamePassword_Fail() { + // given + String savedPassword = "password123!"; + Member member = Member.builder() + .password(savedPassword) + .build(); + + // when + boolean result = member.isSamePassword("wrongPassword"); + + // then + assertThat(result).isFalse(); + } + } + + @DisplayName("비밀번호 수정 정책 검증") + @Nested + class UpdatePasswordPolicy { + + @Test + @DisplayName("새 비밀번호가 현재 비밀번호와 같으면 예외가 발생한다") + void updatePassword_Fail_SameAsCurrent() { + // given + Member member = Member.builder() + .password(PasswordEncryptor.encode("oldPassword123!")) + .build(); + + // when & then + assertThatThrownBy(() -> member.updatePassword("oldPassword123!")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(MemberExceptionMessage.Password.PASSWORD_CANNOT_BE_SAME_AS_CURRENT.message()); + } + + @Test + @DisplayName("새 비밀번호에 생년월일이 포함되면 예외가 발생한다") + void updatePassword_Fail_ContainsBirthDate() { + // given + Member member = Member.builder() + .password("oldPass123!") + .birthDate(LocalDate.of(2001, 2, 9)) + .build(); + + // when & then + assertThatThrownBy(() -> member.updatePassword("pass20010209!")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(MemberExceptionMessage.Password.PASSWORD_CONTAINS_BIRTHDATE.message()); + } + + @DisplayName("비밀번호 수정 시 형식 검증") + @Nested + class PasswordFormatValidation { + + // 1-1-1 + @Test + public void 비밀번호_길이는_8자_미만일_수_없음() throws Exception { + //given + String wrongPassword = "pap1234"; // 7글자 + + //when + + //then + throwIfWrongPasswordInput(wrongPassword) + .hasMessage(MemberExceptionMessage.Password.INVALID_PASSWORD_LENGTH.message()); + } + + // 1-1-2 + @Test + public void 비밀번호_길이는_16자_초과일_수_없음() throws Exception { + //given + String wrongPassword = "qwer1234tyui5678a"; // 17글자 + + //when + + //then + throwIfWrongPasswordInput(wrongPassword) + .hasMessage(MemberExceptionMessage.Password.INVALID_PASSWORD_LENGTH.message()); + + } + + // 1-2 + @Test + public void 비밀번호는_영문_숫자_특수문자만_사용할_수_있음() throws Exception { + //given + String wrongPassword = "한글password123"; + + //when + + //then + throwIfWrongPasswordInput(wrongPassword) + .hasMessage(MemberExceptionMessage.Password.INVALID_PASSWORD_COMPOSITION.message()); + + } + + // 2-1 + @Test + public void 사용자_생년월일_YYYYMMDD가_비밀번호_포함_불가() throws Exception { + //given + LocalDate userBirthDate = LocalDate.of(2001, 2, 9); + String wrongPassword = "pwd20010209!"; + + //when + + //then + throwIfWrongPasswordInput(wrongPassword) + .hasMessage(MemberExceptionMessage.Password.PASSWORD_CONTAINS_BIRTHDATE.message()); + } + + // 2-2 + @Test + public void 사용자_생년월일_YYMMDD가_비밀번호_포함_불가() throws Exception { + //given + LocalDate userBirthDate = LocalDate.of(2001, 2, 9); + String wrongPassword = "pass010209!"; + + //when + + //then + throwIfWrongPasswordInput(wrongPassword) + .hasMessage(MemberExceptionMessage.Password.PASSWORD_CONTAINS_BIRTHDATE.message()); + } + + // 4 + @Test + public void 비밀번호는_암호화해_저장() throws Exception { + //given + + //when + String encodedPassword = PasswordEncryptor.encode(VALID_PASSWORD); + + //then + assertThat(PasswordEncryptor.matches(VALID_PASSWORD, encodedPassword)).isTrue(); + } + + private AbstractThrowableAssert throwIfWrongPasswordInput(String wrongPassword) { + Member member = Member.builder() + .password("oldPass123!") + .birthDate(LocalDate.of(2001, 2, 9)) + .build(); + return assertThatThrownBy(() -> member.updatePassword(wrongPassword)) + .isInstanceOf(IllegalArgumentException.class); + } + + } + } +} From 88c70a46ec2d1234bfe8f9d9a7a32cf2a2288f7e Mon Sep 17 00:00:00 2001 From: APapeIsName Date: Sun, 22 Feb 2026 13:36:05 +0900 Subject: [PATCH 2/8] =?UTF-8?q?docs:=20=EC=9A=94=EA=B5=AC=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20=EB=B0=8F=20=EC=84=A4=EA=B3=84=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 01. 요구사항 문서 - 02. 시퀀스 다이어그램 - 03. 클래스 다이어그램 - 04. ERD - 구조 리팩토링 계획서 - 아키텍처 논의 기록 --- docs/analysis/class-diagram-analysis.md | 312 ++++++++++ docs/analysis/erd-analysis.md | 267 ++++++++ .../prompt-design-process-analysis.md | 45 ++ .../requirements-gathering-analysis.md | 233 +++++++ docs/analysis/sequence-diagram-analysis.md | 252 ++++++++ docs/design/01-requirements.md | 478 ++++++++++++++ docs/design/02-sequence-diagrams.md | 588 ++++++++++++++++++ docs/design/03-class-diagram.md | 340 ++++++++++ docs/design/04-erd.md | 229 +++++++ docs/design/base/class-diagram-erd.md | 418 +++++++++++++ docs/design/base/domain-definition-v2.md | 228 +++++++ docs/design/base/member-class-diagram.md | 238 +++++++ docs/design/base/requirements-input.md | 88 +++ docs/design/base/sequence-diagrams.md | 270 ++++++++ docs/design/base/ubiquitous-language.md | 112 ++++ docs/design/base/user-story.md | 400 ++++++++++++ docs/planning/phase1-structure-refactoring.md | 378 +++++++++++ docs/planning/refactoring-plan.md | 226 +++++++ docs/temp/architecture-discussion-log.md | 322 ++++++++++ docs/temp/refactoring-plan.md | 118 ++++ docs/thought/architecture-discussion-log.md | 205 ++++++ 21 files changed, 5747 insertions(+) create mode 100644 docs/analysis/class-diagram-analysis.md create mode 100644 docs/analysis/erd-analysis.md create mode 100644 docs/analysis/prompt-design-process-analysis.md create mode 100644 docs/analysis/requirements-gathering-analysis.md create mode 100644 docs/analysis/sequence-diagram-analysis.md create mode 100644 docs/design/01-requirements.md create mode 100644 docs/design/02-sequence-diagrams.md create mode 100644 docs/design/03-class-diagram.md create mode 100644 docs/design/04-erd.md create mode 100644 docs/design/base/class-diagram-erd.md create mode 100644 docs/design/base/domain-definition-v2.md create mode 100644 docs/design/base/member-class-diagram.md create mode 100644 docs/design/base/requirements-input.md create mode 100644 docs/design/base/sequence-diagrams.md create mode 100644 docs/design/base/ubiquitous-language.md create mode 100644 docs/design/base/user-story.md create mode 100644 docs/planning/phase1-structure-refactoring.md create mode 100644 docs/planning/refactoring-plan.md create mode 100644 docs/temp/architecture-discussion-log.md create mode 100644 docs/temp/refactoring-plan.md create mode 100644 docs/thought/architecture-discussion-log.md diff --git a/docs/analysis/class-diagram-analysis.md b/docs/analysis/class-diagram-analysis.md new file mode 100644 index 000000000..e4b0af55a --- /dev/null +++ b/docs/analysis/class-diagram-analysis.md @@ -0,0 +1,312 @@ +# 클래스 다이어그램 프로세스 분석 + +> 03-class-diagram.md 작성 과정에서 드러난 설계 철학, 의사결정 방식, 보완이 필요한 영역을 분석한다. +> 향후 클래스 다이어그램 작성 시 재사용할 수 있는 규칙(Rule)을 도출하기 위한 문서이다. + +--- + +## 1. 클래스 다이어그램 정리 단계 (실제 진행 순서) + +### 1단계: 원칙 선언 → 계획 보정 + +- AI가 기능 정의서 + 시퀀스 다이어그램 + base 문서를 기반으로 계획 수립 +- **첫 계획에 ERD를 포함** → 개발자가 즉시 거부하고 4가지 원칙을 선언 + +``` +예시 채팅: +개발자: "ERD 는 따로 할 거야. 클래스 다이어그램만 진행해줘. + 1. 엔티티/VO 분리 기준: ID 존재 여부, 생명 주기 + 2. 연관 관계: 단방향 기본, 양방향 최소화 + 3. 비즈니스 책임은 도메인 객체에 포함시키기 + 4. 설계 후, '한 객체에 책임이 몰리지 않았는가?' 점검 + 이 4가지가 중요해." +``` + +> **패턴**: 산출물 작성 전에 **원칙을 먼저 선언**한다. AI에게 자유도를 주지 않고, 판단 기준을 명시적으로 제공한다. 시퀀스 다이어그램 때의 "목적 정의"와 동일한 패턴이지만, 클래스 다이어그램에서는 더 구체적인 원칙(4가지)으로 발전했다. + +--- + +### 2단계: 확장 방향 제시 → 현재와 미래의 경계 설정 + +- 두 번째 계획도 거부 — 확장성 고려가 빠져 있었음 +- 개발자가 **메인 목표**와 **확장 방향**을 명시 + +``` +예시 채팅: +개발자: "감성 이커머스 — 좋아요 누르고, 쿠폰 쓰고, 주문 및 결제하는 커머스 플랫폼. + 내가 좋아하는 브랜드의 상품들을 한 번에 담아 주문하고, + 유저 행동은 랭킹과 추천으로 연결된다. 요게 메인 목표야." + +개발자: "그리고 나중에 갔을 때 좋아요라는 개념이 바운디드 컨텍스트로 만들어질 거 같아. + 브랜드와 상품에 좋아요가 추가될 거고, 선호 라는 개념으로 확장되면서 + 이는 추천과 랭킹으로 이어질 것 같은 느낌이 있거든." +``` + +> **패턴**: "현재 구현 범위"와 "장기 목표"를 모두 제시한 뒤, "현재 구조가 장기 목표를 막지 않는가?"를 검증 기준으로 삼는다. YAGNI(현재 불필요한 건 안 만든다)와 확장성(막히지는 않게 한다)의 균형을 잡는다. + +--- + +### 3단계: 안티패턴 대조 → 구조 검증 + +- 문서 초안이 완성된 후, 개발자가 3가지 안티패턴을 제시하고 현재 설계와 대조 + +``` +예시 채팅: +개발자: "- 모든 필드를 객체로 표현하려다 지나친 복잡도 + - 도메인 책임 없이 Service에 모든 로직 집중 + - VO를 테이블처럼 다루려는 시도 (ex. Price를 별도 DB로 설계) + 이건 안 좋은 방법이야. 그리고 클래스 구조가 도메인 설계를 잘 표현하고 있는가? + 이를 만족해야 해. 어때보여? 지금 구조" +``` + +> **패턴**: 요구사항·시퀀스 때와 동일하게, **구체적 안티패턴 → 대조 검증** 방식을 사용한다. "잘 됐어?"가 아닌 "이 기준에 맞아?"로 묻는다. 3번 연속 동일 패턴. + +--- + +### 4단계: 다이어그램의 본질 질문 → 범위 재정의 + +- AI의 안티패턴 분석에서 오류 발견 후, 개발자가 다이어그램의 범위를 재정의 + +``` +예시 채팅: +AI: "다이어그램이 도메인 모델과 애플리케이션 계층을 섞고 있습니다..." + +개발자: "1번이 맞는 말 같기는 한데, 다이어그램 내부에 Service 같은 게 들어가 있나?" + +(AI가 파일 재확인 후, Service는 없고 Payment/Coupon 확장 지점만 있음을 인정) + +개발자: "2번은 당연히 빼야지 이번에는. 그냥 확장 가능성만 따로 적어둬." + +개발자: "서비스나 이런 도메인 특히 클래스들과 관련없다면 다이어그램에는 존재하지 않도록. + 다만 책임 분산에는 넣어둬." +``` + +> **패턴**: "이 다이어그램에 뭐가 있어야 하고, 뭐가 없어야 하는가?"를 명확히 한다. 다이어그램 = 도메인 클래스만. Service/Facade/확장 미구현 = 본문 텍스트에서 다룬다. + +--- + +### 5단계: 행위 부재 진단 → VO 도출 + +- 시퀀스 ↔ 클래스 교차 점검에서 "도메인 객체 간 행위 관계가 없다"는 사실을 확인 +- 개발자가 핵심 문제를 지적하고, VO를 통한 책임 위임을 제안 + +``` +예시 채팅: +개발자: "확실히 이러니까 좀 보이네. 대부분의 로직들이 서비스에 있어. + 클래스들은 아무런 행위를 하고 있지 않아. + 객체 지향적으로 설계해보자. 일단 VO 를 정하자. + VO들은 그 자체로 룰이 생길 수 있는 경우, 정책이 있다거나 하는 경우야. + 현재 있는 엔티티들 중의 속성들 중 어떤 것이 VO가 될 수 있을까?" +``` + +> **패턴**: 문제 진단("서비스에 로직이 몰려 있다") → 해결 방향("VO로 책임 위임") → 구체적 기준("자체 규칙이 있는가?") 순서로 사고한다. 추상적 원칙을 먼저 세우고, 그 원칙으로 후보를 선별한다. + +--- + +### 6단계: VO 선별 → 위임 패턴 적용 + +- AI가 후보를 분석하고, 개발자가 Stock, Price, Quantity를 선택 +- 위임 패턴 적용 + 기술 인프라 클래스 제거 + +``` +예시 채팅: +개발자: "Stock, Price, Quantity를 VO 로 두자. + 그리고 decreaseStock 같은 메소드의 책임을 위임해. + 이와 같은 방식으로 다시 그려주고, + BaseTimeEntity 와 BaseEntity 를 여기서는 일단 빼줘. + 굳이 당장 필요없는 것 같아 비즈니스 설계를 보는 데에 있어서. + 클래스 다이어그램이 모든 엔티티들 사이 관계만 보는 다이어그램은 아니지 않나?" +``` + +> **패턴**: 클래스 다이어그램의 역할을 재정의한다. "엔티티 간 관계도"가 아닌 "비즈니스 규칙이 어떤 객체에 어떻게 분배되어 있는가를 보여주는 다이어그램"이다. 기술 인프라(BaseEntity)는 비즈니스 설계를 보는 데 방해가 되므로 제거한다. + +--- + +### 7단계: 시퀀스 다이어그램 동기화 + +- 클래스 다이어그램 변경에 따라, 시퀀스 다이어그램에서 Service가 하던 검증이 엔티티로 이동한 부분을 반영 + +``` +예시 채팅: +개발자: "그리고 여기서 바뀐 내용에서 서비스에서 객체로 책임이 위임된 경우가 있을 건데, + 해당 부분도 시퀀스 다이어그램에 적용시켜줘. + 대신 시퀀스 다이어그램에서는 VO 까지는 안 적어도 돼." +``` + +> **패턴**: 산출물 간 일관성을 유지한다. 클래스 다이어그램에서 책임이 이동하면, 시퀀스 다이어그램도 반드시 동기화한다. 단, 시퀀스는 VO 수준까지 내려가지 않는다(표현 수준의 경계). + +--- + +### 8단계: 교차 검증 → 빈틈 발견 → 즉시 해결 + +- 최종적으로 시퀀스 ↔ 클래스 양방향 전수 검증 +- 2건의 불일치 발견 후 즉시 해결 방안 결정 + +``` +예시 채팅: +개발자: "둘 다 검증할 거야. + 둘 다 객체지향적 설계를 잘 따르나? + 엔티티와 VO 로 나누어 책임을 위임받고 있나? + 객체는 자신의 행위를 수행하고 있나? + 서비스에서 이 역할을 빼앗아가고 있진 않나? + 그리고 이전에 제시했던 조건들도 붙여서 최종 검증해줘." + +(AI가 2건 발견: Stock 사전 확인 메서드 부재, Brand.delete() Note 불일치) + +개발자: "발견 1은 a 선택지를 고르고, 이를 Quantity 가 가져갈 수 있는지도 체크해줘. + 발견 2는 시퀀스 다이어그램에 내용 추가해줘." +``` + +> **패턴**: 검증은 단방향이 아닌 **양방향 교차 검증**이다. 클래스 → 시퀀스, 시퀀스 → 클래스 양쪽을 모두 확인한다. 발견된 문제는 미루지 않고 즉시 해결하며, VO 책임 배분(Stock vs Quantity)까지 검증한다. + +--- + +## 2. 의사결정 시 주목하는 포인트 + +### 2-1. 원칙 선행, 산출물 후행 +- 시퀀스: "목적을 먼저 정의" +- 클래스: "4가지 원칙을 먼저 선언" +- **패턴 진화**: 산출물이 복잡해질수록, 원칙이 더 구체적이고 명시적으로 발전한다 + +### 2-2. "이 다이어그램은 무엇을 보여주는가?" +- 클래스 다이어그램 ≠ 엔티티 관계도 +- 클래스 다이어그램 = 비즈니스 규칙의 분배도 +- 이 정의에 맞지 않는 것(BaseEntity, Payment 미구현, Service)은 다이어그램에서 제외 +- **판단 기준**: "이 요소가 없으면 비즈니스 규칙 분배를 이해할 수 없는가?" + +### 2-3. VO 선별 기준: "자체 규칙이 있는가?" +- 규칙이 있으면 VO (Stock: >= 0 + decrease, Price: > 0, Quantity: > 0) +- 규칙이 없으면 원시 타입 (name, description, brandId) +- 과도한 래핑도, 규칙 없는 원시 타입도 거부하는 **중간 지점**을 선택 + +### 2-4. 위임의 방향 검증 +- "이 책임을 누가 가져가는가?"를 VO 수준까지 추적 +- Stock.isEnough vs Quantity.canBeSatisfiedBy → 같은 불변식은 같은 객체에, 의존 방향 유지 +- **판단 기준**: (1) 같은 규칙은 한 곳에 (2) 의존 방향 역전 금지 + +### 2-5. 현재와 미래의 균형 +- YAGNI: 현재 불필요한 건 구현하지 않는다 (Payment, Coupon, 선호 BC) +- 확장성: 현재 구조가 미래를 막지 않아야 한다 (ID 참조, enum 확장, VO 추가 가능) +- **경계선**: "구현하지 않되, 막히지도 않게" + +--- + +## 3. AI가 질문으로 채워준 영역 (개발자가 놓친 부분) + +### 3-1. Brand UNIQUE + soft-delete 충돌 +**문제**: Brand name에 UNIQUE 제약이 있는데, soft-delete하면 이름이 점유된 채로 남음 + +``` +AI: "삭제된 브랜드 이름을 새 브랜드가 사용할 수 있어야 하나요?" + → 3가지 선택지 제시: + (a) 삭제 시 이름 변경 (name_deleted_timestamp) + (b) UNIQUE 제거 + (c) 앱 레벨 검증만 + +개발자: "삭제 시 이름 변경" +``` + +> **교훈**: 기능(UNIQUE) + 정책(soft-delete)의 조합이 충돌을 만든다. 기능과 정책을 동시에 정의할 때, 조합의 부작용을 반드시 점검해야 한다. + +### 3-2. VO 후보 분석 +**문제**: "VO를 정하자"까지는 결정했지만, 어떤 속성이 VO가 될 수 있는지 구체적으로 식별하지 않았음 + +``` +AI: [전체 속성 분석 테이블 제시] + "stock — 강력 후보: >= 0 불변식 + decrease 행위 + price — 후보: > 0 불변식, 행위는 현재 없음 + quantity — 후보: > 0 불변식 + name, description, ID — 아님: 규칙 없음" + +개발자: "Stock, Price, Quantity를 VO로 두자." +``` + +> **교훈**: "VO를 정한다"는 방향 결정 후, 전체 속성을 "규칙 있는가?"라는 기준으로 선별하는 과정이 필요하다. AI가 후보를 제시하고 개발자가 선택하는 패턴. + +### 3-3. 재고 사전 확인 메서드 부재 +**문제**: 시퀀스 다이어그램에 `alt 모든 상품 재고 충분`이 있는데, 이 판단을 수행하는 메서드가 클래스 다이어그램에 없었음 + +``` +AI: "시퀀스 3-1에서 '모든 상품 재고 충분' 조건의 판단 주체가 불명확합니다. + Stock에 decrease(Quantity)만 있고, isEnough(Quantity) 같은 확인 메서드가 없습니다." + +개발자: "발견 1은 a 선택지를 고르고, Quantity가 가져갈 수 있는지도 체크해줘." +``` + +> **교훈**: 시퀀스 다이어그램의 `alt` 조건은 반드시 클래스 다이어그램의 메서드와 대응해야 한다. "누가 이 판단을 하는가?"가 양쪽에서 일치해야 한다. + +### 3-4. Brand.delete() 설명 불일치 +**문제**: 클래스 다이어그램에서는 delete()가 "name 변경 + deletedAt 세팅"인데, 시퀀스 다이어그램에서는 "deletedAt 세팅"만 기술 + +``` +AI: "시퀀스 4-5에서 Brand.delete()의 Note에 이름 변경이 누락되어 있습니다." +``` + +> **교훈**: 산출물 간 동일한 메서드의 설명이 일치해야 한다. 한 곳을 수정하면 다른 곳도 동기화한다. + +--- + +## 4. 발견된 반복 실수 패턴 + +### 4-1. AI가 다이어그램 내용을 오독 +- AI가 "Service가 다이어그램에 있다"고 주장 → 개발자가 "진짜 있나?" → 확인 결과 없었음 +- **규칙**: AI의 분석을 맹신하지 않는다. "정말 그런가?" 한 번 더 확인한다. AI가 파일을 재확인한 후 답변해야 한다. + +### 4-2. 기술 인프라가 비즈니스 설계를 가림 +- BaseEntity, BaseTimeEntity가 다이어그램에 포함되어 비즈니스 구조가 보이지 않음 +- Payment, Coupon `<<미구현>>`이 다이어그램에 포함되어 현재 범위와 혼동 +- **규칙**: 클래스 다이어그램에는 현재 구현 범위의 도메인 클래스만 포함한다. 기술 인프라와 미구현 확장 지점은 텍스트 섹션으로 분리한다. + +### 4-3. 첫 계획에 범위 초과 항목 포함 +- 첫 계획에 ERD를 포함 → "따로 할 거야"로 즉시 거부 +- 요구사항 때 "과잉 추가", 시퀀스 때 "임의 축소"와 근본 원인 동일: **AI의 임의 판단** +- **규칙**: 산출물의 범위는 개발자가 지정한다. AI는 지정된 범위만 작업한다. + +### 4-4. VO 없는 빈약한 도메인 모델 +- 초기 클래스 다이어그램에서 엔티티의 속성이 전부 원시 타입이고, 행위가 Service에 집중 +- 시퀀스 ↔ 클래스 교차 검증으로 비로소 발견 +- **규칙**: 클래스 다이어그램 작성 후, "이 엔티티에서 자체 규칙이 있는 속성은 없는가?"를 반드시 점검한다. + +--- + +## 5. 클래스 다이어그램 작성 시 확인해야 할 체크리스트 + +### A. 작성 전 확인 +- [ ] 원칙을 명시적으로 선언했는가? (엔티티/VO 분리, 연관 방향, 책임 배치, 집중 점검) +- [ ] 현재 구현 범위와 장기 목표를 모두 파악했는가? +- [ ] 시퀀스 다이어그램에서 도출된 엔티티 행위 목록을 확보했는가? + +### B. 다이어그램 범위 +- [ ] 현재 구현 범위의 도메인 클래스만 포함했는가? +- [ ] 기술 인프라 클래스(BaseEntity 등)를 다이어그램에서 분리했는가? +- [ ] 미구현 확장 지점을 다이어그램이 아닌 텍스트 섹션으로 관리하는가? +- [ ] Service, Facade, Repository는 다이어그램에 포함하지 않았는가? (책임 분산 섹션에서 다룸) + +### C. 엔티티/VO 분리 +- [ ] 모든 엔티티가 고유 ID + 독립 생명주기를 가지는가? +- [ ] 모든 VO가 자체 규칙(불변식)을 캡슐화하는가? +- [ ] "자체 규칙이 있는 속성"이 원시 타입으로 남아 있지 않은가? (VO 후보 점검) +- [ ] 규칙이 없는 속성이 VO로 과도하게 래핑되지 않았는가? + +### D. 연관 관계 +- [ ] 모든 연관이 단방향인가? +- [ ] BC 간 참조가 ID(Long)만 사용하는가? +- [ ] VO 간 의존 방향에 순환이 없는가? + +### E. 책임 분배 +- [ ] 비즈니스 규칙이 도메인 객체(엔티티/VO)에 있는가? +- [ ] 엔티티가 VO에 적절히 위임하는가? (위임 표 작성) +- [ ] Service가 도메인 로직을 수행하지 않는가? (조율만 수행) +- [ ] 한 객체에 책임이 몰려 있지 않은가? + +### F. 교차 검증 (시퀀스 ↔ 클래스) +- [ ] 클래스 다이어그램의 모든 메서드가 시퀀스에서 사용되는가? +- [ ] 시퀀스에서 엔티티가 하는 일이 클래스에 정의되어 있는가? +- [ ] 시퀀스의 alt 조건이 클래스의 메서드와 대응하는가? +- [ ] 두 문서 간 동일 메서드의 설명이 일치하는가? + +### G. 안티패턴 점검 +- [ ] 모든 필드를 객체로 표현하려다 지나친 복잡도를 만들지 않았는가? +- [ ] 도메인 책임 없이 Service에 모든 로직이 집중되지 않았는가? +- [ ] VO를 테이블처럼 다루려 하지 않았는가? +- [ ] 클래스 구조가 도메인 설계를 잘 표현하고 있는가? diff --git a/docs/analysis/erd-analysis.md b/docs/analysis/erd-analysis.md new file mode 100644 index 000000000..97590d930 --- /dev/null +++ b/docs/analysis/erd-analysis.md @@ -0,0 +1,267 @@ +# ERD 프로세스 분석 + +> 04-erd.md 작성 과정에서 드러난 ERD 설계 방식, 의사결정 패턴, 보완이 필요한 영역을 분석한다. +> 향후 ERD 작성 시 재사용할 수 있는 규칙(Rule)을 도출하기 위한 문서이다. + +--- + +## 1. ERD 정리 단계 (실제 진행 순서) + +### 1단계: 외부 조언 수용 → 핵심 제약 선언 + +- ERD 계획을 수립하는 과정에서, 개발자가 **외부 조언**을 즉시 설계 제약으로 반영 +- 계획 수립 중에 추가 제약이 들어옴 + +``` +예시 채팅: +개발자: "그리고 FK 는 걸지마. 오늘 들었던 조언이었는데 FK는 빼는 게 낫대." +``` + +> **패턴**: 설계 제약은 프로젝트 내부에서만 나오지 않는다. **외부 조언이나 학습 결과를 즉시 설계에 반영**한다. "오늘 들었던"이라는 표현에서, 조언을 받은 즉시 적용하는 의사결정 속도가 드러난다. 이전 산출물에서 "결정을 미루지 않는다"는 패턴의 연장선이다. + +--- + +### 2단계: 참조 범위 한정 → base 배제 + +- 클래스 다이어그램까지는 base 문서를 "확장 방향 참고"로 사용했으나, ERD부터는 명시적으로 배제 + +``` +예시 채팅: +개발자: "기존 파일들 기반으로(design/01~03) ERD 다이어그램 만들자. + base는 이제 더 이상 필요없으니 참고하지 말고." +``` + +> **패턴**: 산출물이 구체화될수록 **입력 범위를 좁힌다**. 요구사항(넓은 범위) → 시퀀스(기능 단위) → 클래스(도메인 단위) → ERD(구현 단위). ERD는 "현재 확정된 설계의 DB 매핑"이므로, 장기 방향(base)은 입력에서 제외한다. + +--- + +### 3단계: 계획 수립 → 테이블 수준 상세화 + +- AI가 클래스 다이어그램 + 기존 코드(BaseEntity, Member)를 읽고, 테이블 수준의 상세 계획 작성 +- 개발자가 계획을 검토 후 승인 + +``` +예시 채팅: +AI: [6개 테이블 상세 설계: member, brand, product, product_like, orders, order_line_snapshot] + [VO → 컬럼 매핑 규칙, 상속 → 컬럼 매핑, 논리 참조 목록, 설계 결정 7개] + +개발자: (승인) +``` + +> **패턴**: ERD는 이전 산출물(클래스 다이어그램)에서 **기계적으로 도출 가능한 부분이 많다**. 따라서 계획 단계에서 이미 테이블 컬럼까지 상세화할 수 있다. 이전 산출물(요구사항, 시퀀스, 클래스)에서 여러 번의 보정이 필요했던 것과 대조적이다. 이는 앞선 설계가 충분히 확정되었다는 증거이기도 하다. + +--- + +### 4단계: 작성 → 즉시 자체 교차 검증 + +- 문서 작성 완료 후, AI가 자발적으로 클래스 다이어그램 ↔ ERD 교차 검증을 수행 + +``` +예시 채팅: +AI: [13개 항목 교차 검증 테이블] + "Brand (name, description) → brand(name VARCHAR, description TEXT) ✅" + "Stock VO → product.stock INT ✅" + "soft-delete (Brand, Product) → deleted_at 컬럼 존재 ✅" + ... + "모든 항목이 일치합니다." +``` + +> **패턴**: 이전 산출물에서 개발자가 반복적으로 "교차 검증해줘"를 요청했기 때문에, AI가 검증을 선행 수행한 케이스. **반복된 패턴이 습관화**된 사례다. + +--- + +### 5단계: 안티패턴 대조 검증 + +- 개발자가 ERD 특화 안티패턴 3가지를 제시하고, 현재 설계와 대조 요청 + +``` +예시 채팅: +개발자: "ERD 설계 시 데이터 정합성을 고려하여 구성하였는가? 이를 지켰는지, + - 비즈니스 흐름이 반영되지 않은 정규화만 추구 + - 중복 데이터 제거만 집중해 조회 JOIN이 과도해짐 + - 상태 컬럼 없음 → 코드에서 하드코딩으로 해결함 + 이를 무시하지 않았는지 판단해줘." +``` + +> **패턴**: 요구사항(안티패턴 3개) → 시퀀스(안티패턴 제시) → 클래스(안티패턴 3개) → ERD(안티패턴 3개). **4번째 연속 동일 패턴**이다. 산출물마다 해당 산출물 특화 안티패턴을 제시하고, 구체적 대조 검증을 요청한다. "잘 됐어?"가 아닌 "이 기준에 맞아?"로 묻는다. + +--- + +### 6단계: 전 문서 전수 대조 → 최종 검증 + +- 안티패턴 점검 후, 개발자가 01~03 + base 전체를 대상으로 최종 검증 요청 + +``` +예시 채팅: +개발자: "최종적으로 design/base 에 있는 내용과 01-requirements, 02-sequence-diagrams, + 03-class-diagram 를 확인해서 내가 생각하는 방향과 ERD가 제대로 나왔는지 확인해줘. + 대신 01-requirements, 02-sequence-diagrams, 03-class-diagram 3개가 현재 요구사항이니 + 이 부분을 기반으로 확인해야 해. 마지막이야. 검증해줘." +``` + +> **패턴**: "base는 참조하지 마"(3단계)와 "base와 비교해서 방향 확인해"(6단계)는 모순이 아니다. **작성 시에는 현재 범위만, 검증 시에는 장기 방향도 함께 확인**한다. 작성과 검증의 입력 범위가 다르다. +> +> 또한 "마지막이야. 검증해줘"라는 표현에서, **최종 검증은 별도의 단계로 의식적으로 수행**하는 패턴이 보인다. + +--- + +## 2. 의사결정 시 주목하는 포인트 + +### 2-1. 외부 입력의 즉시 반영 + +- "오늘 들었던 조언"을 설계 제약으로 즉시 적용 +- 결정 이유를 명시: "FK는 빼는 게 낫대" — 출처와 근거가 함께 전달 +- **패턴**: 외부 학습(강의, 리뷰, 조언)의 결과를 설계에 반영할 때, "무엇을"과 "왜"를 함께 기록한다 + +### 2-2. 산출물의 입력 범위 축소 + +- 요구사항: base + 원본 명세 + 도메인 정의서 (가장 넓음) +- 시퀀스: 기능 정의서 기반 +- 클래스: 기능 정의서 + 시퀀스 + base(확장 방향 참고) +- ERD: 01~03만 (base 배제) +- **패턴**: 산출물이 구현에 가까워질수록 입력 범위가 좁아진다. 추상(넓음) → 구체(좁음) + +### 2-3. 작성 범위 ≠ 검증 범위 + +- ERD **작성**: 01~03만 (현재 확정 범위) +- ERD **검증**: 01~03 + base (장기 방향 포함) +- **패턴**: 작성은 현재에 집중하고, 검증은 미래까지 포함한다. "만들 때는 YAGNI, 확인할 때는 방향성" + +### 2-4. 도메인 개념 → DB 개념 매핑의 원칙 + +- VO는 컬럼이다 (테이블이 아니다) +- 도메인 VO지만 JPA 매핑을 위해 id가 필요할 수 있다 (OrderLineSnapshot) +- 도메인 개념과 DB 구현의 차이를 명시적으로 기록한다 +- **패턴**: "도메인에서는 X이지만 DB에서는 Y" — 이 차이를 설계 결정으로 문서화한다 + +### 2-5. 안티패턴 기반 검증의 일관성 + +| 산출물 | 안티패턴 수 | 검증 방식 | +|--------|-----------|----------| +| 요구사항 | 3개 | 구체적 안티패턴 → 대조 | +| 시퀀스 | 안티패턴 제시 | 구체적 안티패턴 → 대조 | +| 클래스 | 3개 | 구체적 안티패턴 → 대조 | +| ERD | 3개 | 구체적 안티패턴 → 대조 | + +> 4개 산출물 모두 동일한 검증 패턴. "이게 좋은가?"가 아닌 **"이 나쁜 패턴에 해당하지 않는가?"**로 검증한다. + +--- + +## 3. AI가 채워준 영역 (개발자가 놓친 부분) + +### 3-1. SQL 예약어 충돌 + +**문제**: 클래스 다이어그램의 `Order` 엔티티를 그대로 테이블명으로 사용하면 SQL 예약어와 충돌 + +``` +AI: 테이블명을 `orders`로 설정 (ORDER는 SQL 예약어) + 설계 결정 #3으로 기록: "백틱 의존보다 명확한 이름 사용" +``` + +> **교훈**: 도메인 이름 → DB 테이블명 변환 시, SQL 예약어 목록을 반드시 확인해야 한다. + +### 3-2. VO의 JPA 매핑 갭 + +**문제**: OrderLineSnapshot은 도메인에서 VO(독립 식별 불필요)이지만, JPA 1:N 매핑에 PK가 필요 + +``` +AI: "도메인 VO이지만 JPA @OneToMany 매핑에 PK 필요" + → id 컬럼 포함 + 설계 결정 #4로 기록 + 대안: "@ElementCollection (컬렉션 전체 삭제/재삽입 성능 이슈)" +``` + +> **교훈**: 도메인 설계와 ORM 구현 사이에는 갭이 존재한다. "도메인에서는 X이지만 구현에서는 Y"를 명시적으로 기록해야 한다. + +### 3-3. 불변 VO의 timestamp 불필요 + +**문제**: OrderLineSnapshot에 timestamp(created_at, updated_at)를 포함할지 결정이 필요 + +``` +AI: "불변 VO. Order의 created_at이 생성 시점을 대변" + → timestamp 제외 + 설계 결정 #5로 기록 +``` + +> **교훈**: 모든 테이블에 기계적으로 timestamp를 넣는 것이 아니라, **테이블의 성격(불변/가변)에 따라 판단**해야 한다. + +### 3-4. UNIQUE 제약의 동시성 보장 + +**문제**: product_like의 중복 방지를 앱 레벨만으로 할지, DB 레벨도 필요한지 + +``` +AI: "중복 좋아요 방지를 DB 레벨에서 보장. + 앱 레벨 검증만으로는 동시성 이슈 가능" + → UNIQUE(member_id, product_id) + 설계 결정 #6으로 기록 +``` + +> **교훈**: 비즈니스 규칙 중 "중복 불가" 규칙은 앱 레벨 검증만으로는 경쟁 조건에 취약하다. DB UNIQUE 제약으로 최종 방어선을 만들어야 한다. + +--- + +## 4. 발견된 반복 실수 패턴 + +### 4-1. ERD에서 특별히 새로운 실수 없음 + +이번 ERD 작성에서는 이전 산출물들에서 발견된 실수 패턴(AI 과잉 추가, 임의 판단, 기술 용어 혼입 등)이 **재발하지 않았다**. 이유: + +1. **입력이 이미 정제됨**: 01~03 문서가 충분히 확정된 상태에서 ERD를 작성 +2. **기계적 매핑 비중이 높음**: 클래스 다이어그램 → ERD는 도메인 판단보다 매핑 규칙이 중심 +3. **누적된 규칙이 작동**: 이전 3개 산출물에서 도출된 규칙들(AI 과잉 추가 금지, 범위 한정 등)이 적용됨 + +### 4-2. 주의해야 할 잠재적 패턴 + +이번에는 발생하지 않았으나, ERD 작성 시 자주 발생할 수 있는 패턴: + +- **인덱스 전략 누락**: 현재 ERD에 인덱스가 명시되지 않았다. base 문서에는 8개의 인덱스가 정의되어 있었으나, 현재 범위에서는 제외. 구현 시 별도로 다뤄야 한다. +- **컬럼 길이/정밀도 미정의**: VARCHAR의 길이, DATETIME의 정밀도 등이 명시되지 않았다. 구현 시 결정해야 한다. +- **VO 매핑 시 AttributeConverter 누락**: Stock, Price, Quantity를 INT 컬럼으로 매핑할 때, JPA AttributeConverter 또는 @Embeddable 전략이 필요하나 ERD 수준에서는 다루지 않았다. + +> **규칙**: 이들은 "ERD 수준의 결정"이 아닌 "구현 수준의 결정"이다. ERD에서는 논리적 구조만 다루고, 물리적 세부사항(인덱스, 길이, 컨버터)은 구현 단계에서 결정한다. + +--- + +## 5. ERD 작성 시 확인해야 할 체크리스트 + +### A. 작성 전 확인 + +- [ ] 클래스 다이어그램이 확정되었는가? (ERD의 입력) +- [ ] 상속 전략(MappedSuperclass / Single Table 등)이 결정되었는가? +- [ ] 삭제 정책(soft-delete / hard-delete / 삭제 없음)이 엔티티별로 확정되었는가? +- [ ] FK 사용 여부가 결정되었는가? + +### B. 도메인 → DB 매핑 + +- [ ] 모든 엔티티가 테이블로 매핑되었는가? +- [ ] VO는 별도 테이블이 아닌 소유 엔티티의 **컬럼**으로 매핑되었는가? +- [ ] Composition 관계(1:N VO)가 별도 테이블 + 참조 컬럼으로 매핑되었는가? +- [ ] Composition 테이블에 JPA PK(id)가 포함되었는가? +- [ ] enum이 컬럼(VARCHAR 등)으로 매핑되었는가? +- [ ] SQL 예약어와 충돌하는 테이블/컬럼명이 없는가? + +### C. 삭제 정책 반영 + +- [ ] soft-delete 엔티티에 deleted_at 컬럼이 있는가? +- [ ] hard-delete 엔티티에 deleted_at이 **없는가**? +- [ ] 삭제 없는 엔티티에 deleted_at이 **없는가**? +- [ ] 불변 VO 테이블에 불필요한 timestamp가 없는가? + +### D. 참조 무결성 + +- [ ] FK 유무 결정이 명시되어 있는가? +- [ ] FK가 없다면, 앱 레벨 검증 방식이 문서화되었는가? +- [ ] UNIQUE 제약이 필요한 곳(중복 불가 비즈니스 규칙)에 설정되었는가? +- [ ] 동시성 이슈가 있는 규칙이 DB 레벨 제약으로 보호되는가? + +### E. 안티패턴 점검 + +- [ ] 정규화만 추구하여 비즈니스 흐름이 무시되지 않았는가? (스냅샷 비정규화 등) +- [ ] 중복 제거만 집중하여 조회 JOIN이 과도하지 않은가? +- [ ] 모든 비즈니스 상태가 DB 컬럼으로 표현되는가? (하드코딩 없음) +- [ ] VO를 테이블로 만들지 않았는가? + +### F. 교차 검증 + +- [ ] 클래스 다이어그램의 모든 엔티티/VO가 ERD에 매핑되었는가? +- [ ] 클래스 다이어그램의 모든 연관 관계가 ERD에 반영되었는가? +- [ ] 시퀀스 다이어그램의 Repository 호출 패턴이 ERD 구조로 지원되는가? +- [ ] 요구사항의 모든 기능이 ERD 구조로 구현 가능한가? +- [ ] base 방향성을 ERD가 차단하지 않는가? (작성 범위 밖이지만 검증 시 확인) diff --git a/docs/analysis/prompt-design-process-analysis.md b/docs/analysis/prompt-design-process-analysis.md new file mode 100644 index 000000000..25a0a4058 --- /dev/null +++ b/docs/analysis/prompt-design-process-analysis.md @@ -0,0 +1,45 @@ +# 설계 프로세스 분석 프롬프트 + +> 각 설계 산출물(요구사항 정의서, 시퀀스 다이어그램, 클래스 다이어그램 등)을 함께 작성한 뒤, +> 그 과정을 분석하여 재사용 가능한 규칙을 도출하기 위한 프롬프트이다. +> 아래 프롬프트를 산출물명에 맞게 수정하여 사용한다. + +--- + +## 프롬프트 원형 + +``` +{산출물명}을 같이 그리면서 내가 말했던 내용들을 바탕으로 분석하는 md를 작성해줘. + +구체적으로: +1. 내가 {산출물명} 을 정리하면서 말했던 내용들을 바탕으로 + 내가 어떤 식으로 {산출물명} 을 정리하는지에 대해 세세히 분석해줘. +2. 그 예시 채팅을 아래 적어줘. +3. 이를 단계나 순서로 나누고, 내가 어떤 점에 주목하는지에 대한 패턴도 분석해줘. +4. 나한테 부족했던 점들 중 너가 재질문 하면서 채워줬던 부분들도 포함해줘. +5. 나중에 {산출물명} 정리 시 사용할 수 있는 rule을 만들어줘. +``` + +## 최초 사용 예시 (요구사항 정의서) + +> "시퀀스 다이어그램을 조금 고쳐야 할 거 같아. 그전에 일단 내가 요구사항 정리하면서 말했던 내용들을 바탕으로 내가 어떤 식으로 요구사항을 정리하는지에 대해 세세히 분석하고, 그 예시 채팅을 아래 적어줘. 그리고 이를 단계나 순서로 나누고, 내가 어떤 점에 주목하는지에 대한 패턴도 분석해줘. 또한, 나한테 부족했던 점들 중 너가 재질문 하면서 채워줬던 부분들 등등 나중에 요구사항 정리 시 사용할 수 있는 rule을 만들기 위한 md 문서를 만들어줘." + +## 분석 문서 구조 (공통) + +``` +1. {산출물명} 정리 단계 (실제 진행 순서) + - 각 단계마다: 설명 + 예시 채팅 + 패턴 요약 +2. 의사결정 시 주목하는 포인트 +3. AI가 재질문으로 채워준 영역 (개발자가 놓친 부분) +4. 발견된 반복 실수 패턴 +5. {산출물명} 정리 시 확인해야 할 체크리스트 +``` + +## 적용 이력 + +| # | 산출물 | 분석 문서 | +|---|--------|----------| +| 1 | 요구사항 정의서 (01-requirements.md) | requirements-gathering-analysis.md | +| 2 | 시퀀스 다이어그램 (02-sequence-diagrams.md) | sequence-diagram-analysis.md | +| 3 | 클래스 다이어그램 (03-class-diagram.md) | class-diagram-analysis.md | +| 4 | ERD (04-erd.md) | erd-analysis.md | diff --git a/docs/analysis/requirements-gathering-analysis.md b/docs/analysis/requirements-gathering-analysis.md new file mode 100644 index 000000000..36ca1b8ee --- /dev/null +++ b/docs/analysis/requirements-gathering-analysis.md @@ -0,0 +1,233 @@ +# 요구사항 정리 프로세스 분석 + +> 01-requirements.md 작성 과정에서 드러난 요구사항 정리 패턴, 의사결정 방식, 보완이 필요한 영역을 분석한다. +> 향후 요구사항 정리 시 재사용할 수 있는 규칙(Rule)을 도출하기 위한 문서이다. + +--- + +## 1. 요구사항 정리 단계 (실제 진행 순서) + +### 1단계: 초안 생성 → 즉시 톤 보정 +- AI가 기존 설계 문서(도메인 정의서, 유저 스토리 등)를 기반으로 초안을 작성 +- **개발자가 가장 먼저 한 일**: 대상 독자(audience) 확인 + +``` +예시 채팅: +개발자: "너무 개발적이라는 거야. 이 정리된 문서는 기획자와 디자이너도 알아들을 수 있어야 해." +``` + +> **패턴**: 내용보다 톤/독자를 먼저 잡는다. 내용이 아무리 정확해도 읽는 사람이 이해 못하면 의미 없다. + +--- + +### 2단계: 빈 곳 식별 → 빠른 의사결정 +- AI에게 "설계 문서에서 채워지지 않은 부분이 뭐냐"고 질문 +- AI가 9개 미결 항목을 제시하면, **한 번에 전부 답변** + +``` +예시 채팅: +개발자: "여기서 너가 생각했을 때 애매했던 내용이 뭐였어? design 안에 있던 모든 문서들을 확인해보면서 + 그 부분이 안 채워진 게 뭐였어?" + +AI: [9가지 미결사항 제시] + +개발자: "1번은 폐점 브랜드인 걸 보여줘야 해. 2번은 주문 내역에 남겨야 해. 3번은 취소 가능하게 해야 해. + 4번은 별도 라인으로 처리해줘. 5번은 반드시 넣어야 돼. 6번은 중복 불가. 7번은 전체로 하자. + 8번은 [정렬 테이블]. 9번은 당연히 에러야." +``` + +> **패턴**: 결정을 미루지 않는다. "나중에 결정"이 아닌, 지금 당장 가능한 답을 준다. +> 단, 확실하지 않은 건 "미정"이나 "지금 중요하지 않음"으로 분류한다 (예: 주문 수량 상한). + +--- + +### 3단계: 자기 검증 요청 +- 문서가 어느 정도 완성되면, AI에게 셀프 리뷰를 요청 + +``` +예시 채팅: +개발자: "그러면 지금 요구사항 문서가 내가 제시해줬던 요구사항들을 잘 풀어내고 있다고 생각해? + 추상화가 너무 돼 있거나, 구현 시 필요한 용어들이 포함돼 있거나, + 특정 상황에 대한 예외 상황들이 명시되지 않았거나 하진 않아?" +``` + +> **패턴**: 직접 검토하기 전에 AI를 먼저 검증 도구로 활용한다. +> 리뷰 관점을 구체적으로 제시한다: (1) 추상화 수준, (2) 기술 용어 혼입, (3) 예외 상황 누락. + +--- + +### 4단계: 설계 방향 전환 (대대적 수정) +- 리뷰 과정에서 근본적인 설계 변경을 결단 + +``` +예시 채팅: +개발자: "오케이 일단 하나 수정할게. 좀 대대적인 수정인데, 폐점이라는 기능은 없애고, + soft-delete의 삭제로 바꿀게. 내가 너무 요구사항까지 바꾸려고 했던 것 같아서." +``` + +> **패턴**: 복잡도가 올라가면 기능을 빼는 쪽으로 결정한다. "더 넣기"보다 "덜어내기"를 선호한다. +> 자기 판단의 오류도 인정한다 ("내가 너무 요구사항까지 바꾸려고 했던 것 같아서"). + +--- + +### 5단계: 안티패턴 체크 +- 문서가 다 만들어진 후, 알려진 안티패턴과 비교 + +``` +예시 채팅: +개발자: "전체적으로 + - 기능 중심만 있고 예외/조건이 없음 + - 유스케이스 흐름을 너무 추상적으로 작성 + - 실제 흐름과 명세가 점차 이격이 발생 + 이 부분은 요구사항의 안 좋은 형태야. 이 부분과 비교해서 우리 요구사항이 잘 적혀 있는지 확인해줘." +``` + +> **패턴**: 일반론이 아닌, 구체적인 안티패턴 목록을 제시하고 대조 검증을 요청한다. + +--- + +### 6단계: 원본 명세 대조 +- 최종 단계에서 원본 API 명세와 1:1 대조 + +``` +예시 채팅: +개발자: [원본 API 명세 전체를 제공하며] + "이 부분들이 전부 요구사항에 녹여들어있는지 확인해줘." + +AI: [3가지 불일치 발견 - 좋아요 토글/분리, 브랜드 연쇄 삭제, 추가된 기능] + +개발자: "1번은 원본 명세에 맞게 수정하자. 2번도 연쇄로 바꾸자. 3번은 전부 없애자." +``` + +> **패턴**: 요구사항이 원본에서 벗어나지 않았는지 최종 확인한다. 벗어난 부분은 원본에 맞춘다. +> 원본에 없는 기능은 과감히 제거한다. + +--- + +## 2. 의사결정 시 주목하는 포인트 + +### 2-1. 독자 중심 +- 문서의 첫 번째 기준: "기획자와 디자이너가 알아들을 수 있는가?" +- HTTP 상태코드, DB 용어, 트랜잭션 등 기술 용어 배제 +- 사용자 관점의 에러 메시지로 대체 ("404" → "상품을 찾을 수 없습니다") + +### 2-2. 단순화 우선 +- 복잡한 기능보다 단순한 구조를 선호 +- 폐점 + 삭제 → 삭제(soft-delete)만으로 통합 +- 주문 취소 기능 → 원본에 없으니 제거 +- REQUESTED 상태 → 저장하지 않음 + +### 2-3. 일관성 강박 +- 문서 간 불일치를 허용하지 않음 +- 요구사항 ↔ 원본 API 명세 1:1 대조 +- 같은 개념에 다른 용어를 쓰는 것을 허용하지 않음 + +### 2-4. 예외 상황 우선 +- "정상 흐름"보다 "이런 경우에는"에 더 관심 +- "이미 삭제된 걸 또 삭제하면?", "브랜드가 삭제된 상품에 좋아요 하면?" 등 +- 예외 상황이 명시되지 않은 문서는 불완전하다고 판단 + +### 2-5. 결정의 이유 기록 +- 단순히 "A로 하자"가 아닌, 왜 A인지를 함께 결정 +- 예: 스냅샷 저장 → "거절된 내용도 사용자 입장에서는 필요하기 때문" +- 예: soft-delete → "내가 너무 요구사항까지 바꾸려고 했던 것 같아서" + +--- + +## 3. AI가 재질문으로 채워준 영역 (개발자가 놓친 부분) + +### 3-1. 흐름 간 모순 +**문제**: 좋아요 목록에서 폐점 브랜드 상품이 보이는데, 그걸 누르면 상세 조회 시 "not found" +``` +AI: "좋아요 목록에서는 폐점 상품이 보이는데, 그걸 탭하면 상세에서 404가 뜨는 UX 모순이 있습니다." +``` +> **교훈**: 기능 단위로 정의하면 기능 사이의 연결점에서 모순이 생긴다. 기능 간 흐름을 따라가며 검증해야 한다. + +### 3-2. 등록에는 있고 수정에는 없는 검증 규칙 +**문제**: 상품 등록(5-3)에는 가격/재고 검증이 있는데, 상품 수정(5-4)에는 없었음 +``` +AI: "5-3에는 가격/재고 검증이 있는데, 5-4에는 빠져 있습니다." +``` +> **교훈**: 등록과 수정의 검증 규칙은 대칭이어야 한다. 하나를 정의하면 나머지도 확인해야 한다. + +### 3-3. 삭제의 연쇄 효과 범위 +**문제**: 브랜드를 삭제하면 상품은? 상품을 삭제하면 좋아요는? 주문 기록은? +``` +AI: "삭제된 브랜드의 상품에 좋아요/주문이 가능한가요?" +개발자: "불가능해." +``` +> **교훈**: 삭제 기능을 정의할 때, 관련된 모든 도메인에 대한 영향도를 체크리스트로 확인해야 한다. + +### 3-4. soft-delete 시 재등록 메커니즘 +**문제**: 좋아요를 취소(soft-delete)한 후 다시 등록하면? 새 레코드? 복원? +``` +AI: "좋아요 재등록 시 새로운 레코드를 만드나요, 기존 soft-delete 기록을 복원하나요?" +개발자: "기존 기록을 복원하는 쪽이야." +``` +> **교훈**: soft-delete를 선택하면, "다시 만들기" 시나리오가 반드시 따라온다. 삭제 방식을 정하면 재생성 방식도 함께 정해야 한다. + +### 3-5. 조회 조건의 필수/선택 구분 +**문제**: 주문 목록의 날짜 필터가 필수인지 선택인지 명시되지 않았음 +``` +AI: "날짜 필터링은 필수인가요, 선택인가요?" +개발자: "반드시 넣어야 돼." +``` +> **교훈**: 모든 필터/파라미터에 대해 필수/선택을 명시해야 한다. 암묵적으로 남겨두면 구현 시 혼란이 생긴다. + +### 3-6. 거절/실패 케이스의 상세 수준 +**문제**: 거절된 주문의 상세 조회 시 어떤 정보가 보이는지 정의되지 않았음 +``` +AI: "거절된 주문 상세에는 거절 사유? 스냅샷? 상태만?" +개발자: "거절됨 상태만 보이게 일단은." +→ (이후 추가 결정) "스냅샷을 저장하도록 하자. 거절된 내용도 사용자 입장에서는 필요하기 때문." +``` +> **교훈**: 실패 케이스도 정상 케이스만큼 상세하게 정의해야 한다. "에러"로 퉁치면 안 된다. + +--- + +## 4. 발견된 반복 실수 패턴 + +### 4-1. AI의 과잉 추가 +- AI가 원본 명세에 없는 기능을 "자연스럽다"는 이유로 추가 + - 사용자 브랜드 목록 (원본에 없음) + - 주문 취소 (원본에 없음) + - 브랜드 복원 (원본에 없음) +- **규칙**: 원본 명세에 없는 기능은 절대 추가하지 않는다. 필요하면 별도 제안으로 분리한다. + +### 4-2. AI의 임의 판단 +- AI가 시퀀스 다이어그램에서 "단순 CRUD"라고 판단하여 임의로 제외 +``` +개발자: "혹시 단순 CRUD를 빼라고 했었나 내가?" +``` +- **규칙**: 제외/추가 판단은 개발자가 한다. AI는 전부 포함한 후 개발자가 빼도록 한다. + +### 4-3. 기술 용어 반복 혼입 +- "비관적 락", "SELECT FOR UPDATE", "트랜잭션", "FK" 등이 요구사항 문서에 반복 등장 +- 한 번 지적해도 다시 나타남 +- **규칙**: 요구사항 문서에 기술 용어가 포함되면 작성 실패로 간주한다. + +--- + +## 5. 요구사항 정리 시 확인해야 할 체크리스트 + +### A. 문서 작성 전 +- [ ] 대상 독자가 누구인지 먼저 정한다 (기획자? 개발자? 디자이너?) +- [ ] 원본 명세(API 스펙, 기획서 등)를 확보한다 +- [ ] 기존 설계 문서를 전부 읽고, 빈 곳을 식별한다 + +### B. 빈 곳 채우기 +- [ ] 모든 "삭제" 기능에 대해: 연쇄 범위, 되돌림 가능 여부, 재생성 시 동작 +- [ ] 모든 "조회" 기능에 대해: 필터 필수/선택, 정렬 기본값, 페이지 크기 +- [ ] 모든 "등록" 기능에 대해: 필수/선택 필드, 검증 규칙, 중복 허용 여부 +- [ ] 모든 "수정" 기능에 대해: 전체/부분, 변경 불가 필드, 검증 규칙 (등록과 대칭) +- [ ] 실패 케이스: 어떤 정보를 보여주는지, 기록으로 남기는지 + +### C. 검증 +- [ ] 기능 간 흐름 추적: A 화면 → B 화면 이동 시 모순 없는지 +- [ ] 원본 명세와 1:1 대조: 빠진 것, 추가된 것, 변경된 것 +- [ ] 안티패턴 체크: 예외 없는 기능, 추상적 흐름, 명세 이격 +- [ ] 기술 용어 오염 체크: 독자가 이해 못할 단어가 없는지 + +### D. 마무리 +- [ ] 열린 결정사항을 명시적으로 기록한다 (결정 보류 ≠ 누락) +- [ ] 결정된 사항에는 이유를 함께 기록한다 diff --git a/docs/analysis/sequence-diagram-analysis.md b/docs/analysis/sequence-diagram-analysis.md new file mode 100644 index 000000000..5939a2ecf --- /dev/null +++ b/docs/analysis/sequence-diagram-analysis.md @@ -0,0 +1,252 @@ +# 시퀀스 다이어그램 프로세스 분석 + +> 02-sequence-diagrams.md 작성 과정에서 드러난 설계 철학, 의사결정 방식, 보완이 필요한 영역을 분석한다. +> 향후 시퀀스 다이어그램 작성 시 재사용할 수 있는 규칙(Rule)을 도출하기 위한 문서이다. + +--- + +## 1. 시퀀스 다이어그램 정리 단계 (실제 진행 순서) + +### 1단계: 초안 생성 → 범위 보정 + +- AI가 기능 정의서(01-requirements.md)와 기존 base 문서들을 참고하여 초안 작성 +- **AI가 "단순 CRUD는 가치가 없다"고 판단하여 6개만 작성** → 개발자가 즉시 지적 + +``` +예시 채팅: +개발자: "혹시 단순 CRUD를 빼라고 했었나 내가?" +AI: "... 제 판단으로 뺐습니다." +개발자: "응 모든 API들은 일단 넣어줘." +``` + +> **패턴**: 포함/제외 판단은 개발자가 한다. AI는 전부 포함한 후 개발자가 빼도록 한다. 이 패턴은 요구사항 정리 때와 동일하다. + +--- + +### 2단계: 시퀀스 다이어그램의 목적 정의 + +- 21개 전체 다이어그램이 완성된 후, 개발자가 시퀀스 다이어그램의 **근본 목적**을 설명 + +``` +예시 채팅: +개발자: "시퀀스 다이어그램은 객체지향 설계를 위해 객체들이 어떤 일을 맡아야 하는가? 를 정하기 위한 + 작업이라고 생각해. 시퀀스 다이어그램에서 객체와 메시지가 있는데, 이 메시지들은 결국 객체의 + 책임이라고 생각하거든. 객체가 수행해야 하는 책임은 무엇인가 에 대해 생각하고, 각 객체가 + 이를 잘 수행하고 있는가 를 생각할 수 있어야 해." +``` + +> **패턴**: 산출물의 형태보다 목적을 먼저 정의한다. "어떻게 그릴까"가 아닌 "왜 그리는가"를 먼저 합의한다. + +--- + +### 3단계: 표현 수준 결정 (개발적 vs 구현적) + +- 목적 정의 후, 어디까지 보여주고 어디부터 빼야 하는지를 결정 + +``` +예시 채팅: +개발자: "구현적인 내용이 들어가면 안 될 거 같아. DB 명령어가 뭐고 이런 건 필요없어. + 다만, 조금은 개발적이어도 괜찮아. 예를 들어, 객체명이나 메소드명이 들어간다던지 하는 것들. + 물론, API 엔드포인트명 도 포함이야." +``` + +> **패턴**: "금지 선"과 "허용 선"을 명시적으로 긋는다. +> - **금지**: SQL, DB 명령어, 트랜잭션 키워드, 물리적 저장 방식 +> - **허용**: 객체명, 메서드명, API 엔드포인트, 한글 설명 + +--- + +### 4단계: 애매한 설계 요소 질의 → 즉시 결정 + +- AI가 4가지 설계 의문을 제시하고, 개발자가 즉시 결정 + +``` +예시 채팅: +AI: "1. Repository도 참여자로 포함할지? + 2. Facade가 필요한지? + 3. 예외/alt 블록은 유지할지? + 4. 메시지 포맷은?" + +개발자: "1. repository 도 필요해. 서비스도 repository 가 없다면 어디서 정보를 가져올지를 못 정할 거 같아. + 2. Facade 는 서비스 여러개가 서로 순환 관계를 이룰 때만 사용하면 될 것 같아. + 우선 facade 가 정말 필요한지 확인해줘. + 3. 응 그건 유지해줘. + 4. 한글 설명 + 메서드명 이렇게." +``` + +> **패턴**: 결정을 미루지 않고, 이유와 함께 즉시 답변한다. 확실하지 않은 건 "확인해줘"로 검증을 요청한다. + +--- + +### 5단계: Facade 필요성 검증 + +- AI가 서비스 간 의존 관계를 분석하고, 순환이 있는 곳만 Facade를 적용 +- 개발자가 순환 해소 방안(Controller로 올리기 vs Facade)을 평가 + +``` +예시 채팅: +AI: "BrandService ↔ ProductService 순환을 Controller에서 조율하면 Facade 없이 해결 가능합니다." + +개발자: "음 컨트롤러를 헤친다면 별로 인 것 같아. 일단 Facade 넣어봐. + Brand - Product 사이 브랜드 삭제 시랑 상품 등록 시에는." +``` + +> **패턴**: 이론적으로 맞더라도 실용적 판단을 우선한다. "Controller가 지저분해지면 차라리 Facade"라는 실용 기준. 패턴의 교과서적 정당성보다 코드의 깔끔함을 중시한다. + +--- + +### 6단계: 안티패턴 제시 → 자기 진단 요청 + +- 개발자가 시퀀스 다이어그램의 안티패턴 3가지를 제시하고 현재 상태를 점검 + +``` +예시 채팅: +개발자: "- 너무 많은 세부 흐름을 다 넣어서 시퀀스가 복잡함 + - 도메인 객체 간 메시지 없이 Service만 호출 + - 시퀀스와 실제 구현이 따로 놀아 유지보수 불가능 + 이 부분은 안 좋은 예시야. 그리고 시퀀스 다이어그램에서 책임 객체가 드러나는가?" +``` + +> **패턴**: 요구사항 정리 때와 동일하게, 구체적인 안티패턴을 제시하고 대조 검증을 요청한다. "잘 됐어?"가 아닌 "이 기준에 맞아?"라고 묻는다. + +--- + +### 7단계: 핵심 문제 진단 → 전면 재작성 지시 + +- AI가 자기 진단에서 문제 #2, #3에 해당한다고 인정 +- 개발자가 최종 판단을 내리고 재작성 지시 + +``` +예시 채팅: +개발자: "그렇네 너무 alt 가 많고 엔티티가 하는 일은 없어. 이건 시퀀스 다이어그램으로써 + 큰 가치가 없어. 객체지향적이지 못하니까. 수정해서 다시 작성해줘." +``` + +> **패턴**: 부분 수정이 아닌 전면 재작성을 결정한다. 근본 구조가 잘못되면 덧대기보다 다시 짓는 것을 선택한다. + +--- + +## 2. 의사결정 시 주목하는 포인트 + +### 2-1. 목적 중심 사고 +- 다이어그램의 목적: 객체의 책임을 정하는 것 +- 형식보다 "이 다이어그램을 보고 누가 무슨 일을 하는지 알 수 있는가?"를 기준으로 평가 +- 예쁜 다이어그램보다 책임이 드러나는 다이어그램을 선호 + +### 2-2. 구현과 설계의 경계 +- SQL, 트랜잭션, FK → 구현. 시퀀스에 불필요 +- 객체명, 메서드명, Repository → 설계. 시퀀스에 필요 +- **경계선**: "이 정보가 없으면 객체의 책임을 정할 수 없는가?" → 없으면 빼고, 있어야 하면 넣는다 + +### 2-3. 실용적 패턴 선택 +- Facade: 이론적으로 필요하지 않아도, 코드가 지저분해지면 도입 +- Controller에 로직 올리기: "컨트롤러를 헤치면 별로" → 거부 +- **판단 기준**: 코드를 읽는 사람이 자연스럽게 이해할 수 있는가 + +### 2-4. 엔티티에 행위 부여 +- Service가 모든 걸 하는 건 "객체지향적이지 못함" +- Brand가 `delete()`를 직접 수행, Product가 `decreaseStock()`을 직접 수행 +- **판단 기준**: "이 로직이 누구의 책임인가?" → 데이터를 가진 객체가 직접 해야 한다 + +### 2-5. 단순함이 가치 +- alt 블록이 많으면 → 복잡해져서 가치가 떨어진다 +- 예외 처리는 Note나 guard 메서드로 간결하게 +- 진짜 비즈니스 분기(수락/거절)만 alt로 표현 + +--- + +## 3. AI가 질문으로 채워준 영역 (개발자가 놓친 부분) + +### 3-1. Facade 적용 범위 구체화 +**문제**: "순환일 때 Facade를 쓴다"까지는 결정했지만, 어떤 케이스가 순환인지 구체적으로 식별하지 않았음 + +``` +AI: [서비스 의존 관계 분석 결과] + "OrderService → ProductService (단방향) + LikeService → ProductService (단방향) + BrandService ↔ ProductService (순환: 브랜드 삭제 연쇄 + 상품 등록 시 브랜드 검증)" +``` + +> **교훈**: 패턴(Facade)을 적용하겠다는 결정 후, 적용 대상을 구체적으로 열거해야 한다. "필요할 때"가 아닌 "이 두 곳에서"로 확정해야 한다. + +### 3-2. Controller vs Facade 트레이드오프 제시 +**문제**: 개발자가 Facade를 바로 넣으라고 했지만, AI가 Controller에서 해결하는 대안을 먼저 제시 + +``` +AI: "BrandService ↔ ProductService 순환을 Controller에서 Brand 조회 후 ProductService에 전달하면 + Facade 없이도 해결됩니다." + +개발자: "음 컨트롤러를 헤친다면 별로 인 것 같아. 일단 Facade 넣어봐." +``` + +> **교훈**: 대안을 제시하고 비교한 뒤 결정하는 것이 더 나은 의사결정으로 이어진다. 다만 개발자가 빠르게 판단할 수 있도록 간결하게 제시해야 한다. + +### 3-3. 엔티티 행위 구체화 +**문제**: "엔티티가 행위를 가져야 한다"는 방향은 정했지만, 어떤 행위를 누가 가질지 구체화되지 않았음 + +``` +AI: [엔티티별 행위 도출] + "Brand: update(), guardNotDeleted(), delete() + Product: update(), guardNotDeleted(), delete(), decreaseStock() + Order: isOwnedBy() + ProductLike: restore(), delete() → (hard-delete 전환 후 제거)" +``` + +> **교훈**: 객체지향 원칙에 합의한 뒤, 각 엔티티의 구체적 행위 목록을 확정하는 과정이 필요하다. + +--- + +## 4. 발견된 반복 실수 패턴 + +### 4-1. AI의 범위 임의 축소 +- "단순 CRUD"라고 판단하여 6개 API만 작성 (21개 중 15개 누락) +- 요구사항 정리 때의 "과잉 추가"와 반대 방향이지만, 근본 원인은 동일: **AI가 임의 판단** +- **규칙**: 전부 포함한 후 개발자가 뺀다. 빼는 것도, 넣는 것도 AI가 판단하지 않는다. + +### 4-2. 구현 디테일 반복 혼입 +- 1차: SQL 쿼리, Database 참여자, FOR UPDATE 구문 포함 +- 2차: Repository로 교체했으나, Note에 SQL 힌트("JOIN brand b ON...") 잔존 +- **규칙**: 시퀀스 다이어그램에 SQL, 트랜잭션 키워드, DB 명령어가 포함되면 작성 실패로 간주한다. + +### 4-3. Service만 있는 절차적 흐름 +- 여러 차례 수정에도 엔티티가 참여자로 등장하지 않음 +- 엔티티 행위를 Note에 숨김 ("Product 엔티티가 stock 차감 수행"을 메모로만 표시) +- **규칙**: Note에 엔티티 행위가 서술되어 있으면, 그 엔티티를 참여자로 승격시켜야 한다. + +### 4-4. alt 블록 남용 +- 모든 예외 상황(404, 403, 400 등)을 alt로 표현 +- 다이어그램이 예외 처리 명세서가 되어 핵심 흐름이 묻힘 +- **규칙**: alt는 진짜 비즈니스 분기(수락/거절 등)에만 사용한다. 예외는 guard 메서드나 Note로 처리한다. + +--- + +## 5. 시퀀스 다이어그램 작성 시 확인해야 할 체크리스트 + +### A. 작성 전 확인 +- [ ] 이 다이어그램의 목적을 정의했는가? (객체의 책임 도출) +- [ ] 대상 API 전체 목록을 확인했는가? (임의 누락 방지) +- [ ] 표현 수준의 경계를 정했는가? (금지: SQL, DB / 허용: 객체명, 메서드명) + +### B. 참여자 점검 +- [ ] 도메인 엔티티가 참여자로 포함되어 있는가? +- [ ] 엔티티가 직접 메시지를 받는 행위가 있는가? (update, delete, guard 등) +- [ ] Repository가 포함되어 있는가? +- [ ] Facade는 순환 의존이 증명된 곳에만 사용했는가? +- [ ] Note에 엔티티 행위가 서술되어 있지 않은가? (있으면 → 참여자로 승격) + +### C. 메시지 점검 +- [ ] 모든 메시지가 "한글 설명 + 메서드명(파라미터)" 형식인가? +- [ ] 메시지가 해당 객체의 책임을 나타내는가? +- [ ] 구현 디테일(SQL, 트랜잭션)이 포함되지 않았는가? + +### D. 구조 점검 +- [ ] alt 블록이 진짜 비즈니스 분기에만 사용되었는가? +- [ ] 예외 처리가 guard 메서드나 Note로 간결하게 표현되었는가? +- [ ] 시퀀스와 실제 구현 구조가 대응하는가? (Service → Entity 메서드 호출 등) +- [ ] "이 다이어그램을 보고 각 객체가 무슨 일을 하는지 알 수 있는가?" + +### E. 완성 후 검증 +- [ ] 안티패턴 체크: Service만 호출하는 절차적 흐름이 아닌가? +- [ ] 안티패턴 체크: 너무 많은 세부 흐름으로 복잡하지 않은가? +- [ ] 안티패턴 체크: 시퀀스와 실제 구현이 따로 놀지 않는가? +- [ ] 기능 정의서(01-requirements.md)의 모든 API가 빠짐없이 포함되었는가? diff --git a/docs/design/01-requirements.md b/docs/design/01-requirements.md new file mode 100644 index 000000000..f7bd7dc03 --- /dev/null +++ b/docs/design/01-requirements.md @@ -0,0 +1,478 @@ +# 기능 정의서 — 감성 이커머스 + +> 작성일: 2026-02-11 +> 이 문서는 기획, 디자인, 개발이 같은 그림을 보기 위한 기능 정의서입니다. +> "사용자가 뭘 하면, 어떤 일이 일어나는가"를 중심으로 기술합니다. + +--- + +## 등장인물 + +| 누구 | 설명 | 할 수 있는 일 | +|------|------|--------------| +| **사용자** | 앱에 들어온 모든 사람 (비회원 포함) | 상품 둘러보기, 브랜드 조회 | +| **회원** | 회원가입을 마친 사용자 | 좋아요 등록/취소, 주문 | +| **관리자** | 서비스를 운영하는 내부 직원 | 브랜드 관리, 상품 관리, 주문 확인 | + +--- + +## 1. 상품 둘러보기 + +> 누구나 할 수 있다. 로그인 필요 없음. + +### 1-1. 상품 목록 보기 + +**[유저 스토리]** +- 사용자는 등록된 상품 목록을 볼 수 있다. +- 특정 브랜드의 상품만 골라 볼 수 있다. +- 최신순, 가격순, 좋아요순으로 정렬할 수 있다. + +**[기능 흐름]** +1. 사용자가 상품 목록 화면에 진입한다. +2. 기본으로 최신 등록순으로 상품이 노출된다. +3. 원하면 브랜드를 선택하여 해당 브랜드 상품만 필터링한다. +4. 원하면 정렬 기준을 변경한다. + - **최신순** (기본값) + - 가격 낮은순 + - 좋아요 많은순 +5. 한 페이지에 20개씩 노출되며, 페이지를 넘길 수 있다. + +**[이런 경우에는]** +- 상품이 하나도 없으면 → 빈 목록 화면 ("등록된 상품이 없습니다" 등) +- 삭제된 상품은 → 목록에 나타나지 않는다 + +--- + +### 1-2. 상품 상세 보기 + +**[유저 스토리]** +- 사용자는 상품을 눌러 상세 정보를 확인할 수 있다. +- 상품 이름, 설명, 가격, 남은 재고, 소속 브랜드 정보가 보인다. + +**[기능 흐름]** +1. 사용자가 목록에서 상품을 선택한다. +2. 상품 상세 화면에 진입한다. +3. 상품 정보(이름, 설명, 가격, 재고)와 브랜드 정보(이름, 설명)가 함께 표시된다. + +**[이런 경우에는]** +- 없는 상품이거나 삭제된 상품을 조회하면 → "상품을 찾을 수 없습니다" + +--- + +### 1-3. 브랜드 상세 보기 + +**[유저 스토리]** +- 사용자는 특정 브랜드의 이름과 설명을 확인할 수 있다. + +**[기능 흐름]** +1. 사용자가 브랜드를 선택한다. +2. 브랜드 상세 정보(이름, 설명)가 표시된다. + +**[이런 경우에는]** +- 없는 브랜드이거나 삭제된 브랜드면 → "브랜드를 찾을 수 없습니다" + +--- + +## 2. 좋아요 + +> 회원만 할 수 있다. 로그인 필요. + +### 2-1. 좋아요 등록 + +**[유저 스토리]** +- 회원은 마음에 드는 상품에 좋아요를 등록할 수 있다. + +**[기능 흐름]** +1. 회원이 상품에서 좋아요 등록을 요청한다. +2. 해당 상품이 존재하고 삭제되지 않았는지 확인한다. +3. 해당 회원이 이 상품에 이미 좋아요를 눌렀는지 확인한다. +4. 좋아요가 등록된다. + +**[이런 경우에는]** +- 로그인하지 않은 사용자 → 로그인 필요 안내 +- 없거나 삭제된 상품 → "상품을 찾을 수 없습니다" +- 이미 좋아요를 누른 상태 → "이미 좋아요한 상품입니다" +- 소속 브랜드가 삭제된 상품 → "현재 이용할 수 없는 상품입니다" + +--- + +### 2-2. 좋아요 취소 + +**[유저 스토리]** +- 회원은 좋아요를 누른 상품의 좋아요를 취소할 수 있다. + +**[기능 흐름]** +1. 회원이 상품에서 좋아요 취소를 요청한다. +2. 해당 상품에 좋아요를 누른 상태인지 확인한다. +3. 좋아요가 취소된다. + +**[이런 경우에는]** +- 로그인하지 않은 사용자 → 로그인 필요 안내 +- 없거나 삭제된 상품 → "상품을 찾을 수 없습니다" +- 좋아요를 누르지 않은 상태에서 취소 시도 → "좋아요하지 않은 상품입니다" +- 소속 브랜드가 삭제된 상품이라도 → 취소는 정상적으로 가능 + +--- + +### 2-3. 내 좋아요 목록 보기 + +**[유저 스토리]** +- 회원은 자신이 좋아요한 상품을 모아 볼 수 있다. + +**[기능 흐름]** +1. 회원이 내 좋아요 목록 화면에 진입한다. +2. 좋아요한 상품이 목록으로 노출된다. +3. 페이지 단위로 표시된다. + +**[이런 경우에는]** +- 로그인하지 않은 사용자 → 로그인 필요 안내 +- 좋아요한 상품이 없으면 → 빈 목록 화면 ("좋아요한 상품이 없습니다" 등) +- 좋아요를 눌렀는데 이후 상품이 삭제된 경우 → 해당 항목은 목록에서 제외 + +--- + +## 3. 주문 + +> 회원만 할 수 있다. 로그인 필요. + +### 3-1. 주문하기 + +**[유저 스토리]** +- 회원은 여러 상품을 수량과 함께 지정하여 한 번에 주문할 수 있다. +- 모든 상품의 재고가 충분하면 주문이 **수락**되고, 해당 수량만큼 재고가 줄어든다. +- 하나라도 재고가 부족하면 주문 전체가 **거절**되고, 이미 줄어든 재고도 원래대로 돌아간다. +- 주문 시점의 상품 정보(이름, 설명, 가격, 브랜드명, 수량)가 주문 기록으로 남는다. + +**[기능 흐름]** +1. 회원이 주문할 상품과 수량을 선택한다. + - 예: A상품 2개, B상품 1개 +2. 주문 요청을 보낸다. +3. 각 상품의 재고가 충분한지 확인한다. +4. 주문 당시의 상품 정보를 기록(스냅샷)으로 저장한다. +5. **재고가 모두 충분하면:** + - 재고를 수량만큼 차감한다. + - 주문 상태: **수락(ACCEPTED)** + - 회원에게 주문 완료 화면을 보여준다. +6. **하나라도 재고가 부족하면:** + - 아무것도 차감하지 않는다. (전부 아니면 전무) + - 주문 상태: **거절(REJECTED)** + - 어떤 상품이 부족한지 알려준다 (상품명, 요청 수량, 남은 재고). + +**[이런 경우에는]** +- 로그인하지 않은 사용자 → 로그인 필요 안내 +- 주문할 상품을 하나도 선택하지 않음 → "주문할 상품을 선택해주세요" +- 수량을 0개 이하로 입력 → "수량은 1개 이상이어야 합니다" +- 없거나 삭제된 상품이 포함됨 → "상품을 찾을 수 없습니다" +- 소속 브랜드가 삭제된 상품이 포함됨 → "현재 주문할 수 없는 상품이 포함되어 있습니다" +- 같은 상품이 중복으로 들어오면 → "동일한 상품이 중복으로 포함되어 있습니다" +- 거절된 주문도 주문 내역에 기록으로 남는다 (회원이 "왜 거절됐는지" 확인할 수 있도록) + +**[주문 기록(스냅샷)이란?]** +> 주문을 넣는 순간의 상품 정보를 사진처럼 찍어두는 것이다. +> 나중에 상품 이름이 바뀌거나 가격이 올라도, 주문 기록에는 "그때 그 가격, 그때 그 이름"이 그대로 남는다. +> 상품이나 브랜드가 삭제되어도 주문 기록은 절대 사라지지 않는다. + +--- + +### 3-2. 내 주문 내역 보기 + +**[유저 스토리]** +- 회원은 자신의 주문 내역을 기간을 지정하여 조회할 수 있다. +- 수락된 주문, 거절된 주문 모두 내역에 남아 있다. +- 다른 사람의 주문은 볼 수 없다. + +**[기능 흐름]** +1. 회원이 주문 내역 화면에 진입한다. +2. **시작일과 종료일을 반드시 선택**한다. +3. 해당 기간 내의 본인 주문이 목록으로 표시된다. +4. 각 주문에는 상태(수락/거절)가 함께 보인다. +5. 페이지 단위로 표시된다. + +**[이런 경우에는]** +- 로그인하지 않은 사용자 → 로그인 필요 안내 +- 날짜를 선택하지 않으면 → "조회 기간을 선택해주세요" +- 시작일이 종료일보다 뒤면 → "시작일은 종료일보다 앞이어야 합니다" +- 해당 기간에 주문이 없으면 → 빈 목록 화면 + +--- + +### 3-3. 주문 상세 보기 + +**[유저 스토리]** +- 회원은 특정 주문을 눌러 상세 내역을 확인할 수 있다. +- 주문 상태, 주문한 상품 목록, 각 상품의 당시 가격과 수량이 보인다. + +**[기능 흐름]** +1. 회원이 주문 내역에서 특정 주문을 선택한다. +2. 주문 정보(상태, 주문일시)와 상품 기록(이름, 가격, 수량, 브랜드명)이 표시된다. + +**[이런 경우에는]** +- 없는 주문을 조회하면 → "주문을 찾을 수 없습니다" +- 다른 사람의 주문을 조회하면 → "접근 권한이 없습니다" + +--- + +## 4. 관리자 — 브랜드 관리 + +> 관리자 인증을 통과해야 사용할 수 있다. + +### 4-1. 브랜드 목록 보기 + +**[유저 스토리]** +- 관리자는 전체 브랜드를 한눈에 볼 수 있다. +- 삭제된 브랜드도 모두 보인다. + +**[기능 흐름]** +1. 관리자가 브랜드 관리 화면에 진입한다. +2. 모든 브랜드가 목록으로 표시된다 (삭제된 브랜드 포함). +3. 각 브랜드의 상태(활성 / 삭제됨)가 표시된다. +4. 페이지 단위로 표시된다. + +--- + +### 4-2. 브랜드 상세 보기 + +**[유저 스토리]** +- 관리자는 브랜드의 상세 정보와 삭제 여부를 확인할 수 있다. + +**[기능 흐름]** +1. 관리자가 브랜드를 선택한다. +2. 이름, 설명, 상태(활성 / 삭제됨)가 표시된다. + +**[이런 경우에는]** +- 없는 브랜드 → "브랜드를 찾을 수 없습니다" + +--- + +### 4-3. 브랜드 등록 + +**[유저 스토리]** +- 관리자는 새로운 브랜드를 등록할 수 있다. +- 같은 이름의 브랜드는 등록할 수 없다. + +**[기능 흐름]** +1. 관리자가 이름, 설명을 입력한다. +2. 브랜드가 등록된다. + +**[이런 경우에는]** +- 이름을 입력하지 않으면 → "브랜드명을 입력해주세요" +- 설명은 입력하지 않아도 된다 (선택) +- 이미 같은 이름의 브랜드가 있으면 → "이미 존재하는 브랜드명입니다" + +--- + +### 4-4. 브랜드 수정 + +**[유저 스토리]** +- 관리자는 브랜드의 이름과 설명을 수정할 수 있다. +- 수정 시 이름과 설명을 모두 보내야 한다 (전체 덮어쓰기 방식). + +**[기능 흐름]** +1. 관리자가 수정할 브랜드를 선택한다. +2. 이름과 설명을 변경하고 저장한다. + +**[이런 경우에는]** +- 없는 브랜드 → "브랜드를 찾을 수 없습니다" +- 변경한 이름이 다른 브랜드와 중복 → "이미 존재하는 브랜드명입니다" + +--- + +### 4-5. 브랜드 삭제 + +**[유저 스토리]** +- 관리자는 브랜드를 삭제할 수 있다. +- 삭제하면 해당 브랜드의 소속 상품도 함께 삭제된다. +- 기존 주문 기록(스냅샷)은 영향 없다. + +**[기능 흐름]** +1. 관리자가 삭제할 브랜드를 선택한다. +2. 삭제를 확인한다. +3. 브랜드가 삭제 상태로 전환된다. +4. 해당 브랜드의 소속 상품도 함께 삭제 상태로 전환된다. + +**[이런 경우에는]** +- 없는 브랜드 → "브랜드를 찾을 수 없습니다" +- 이미 삭제된 브랜드 → "이미 삭제된 브랜드입니다" + +--- + +## 5. 관리자 — 상품 관리 + +> 관리자 인증을 통과해야 사용할 수 있다. + +### 5-1. 상품 목록 보기 + +**[유저 스토리]** +- 관리자는 전체 상품을 한눈에 볼 수 있다. +- 삭제된 상품도 모두 보인다. +- 브랜드별로 필터링할 수 있다. + +**[기능 흐름]** +1. 관리자가 상품 관리 화면에 진입한다. +2. 모든 상품이 목록으로 표시된다 (삭제된 상품 포함). +3. 원하면 브랜드를 선택하여 필터링한다. +4. 페이지 단위로 표시된다. + +--- + +### 5-2. 상품 상세 보기 + +**[유저 스토리]** +- 관리자는 상품의 상세 정보를 확인할 수 있다. + +**[기능 흐름]** +1. 관리자가 상품을 선택한다. +2. 상품 정보(이름, 설명, 가격, 재고)와 소속 브랜드 정보가 표시된다. + +**[이런 경우에는]** +- 없는 상품 → "상품을 찾을 수 없습니다" + +--- + +### 5-3. 상품 등록 + +**[유저 스토리]** +- 관리자는 새로운 상품을 등록할 수 있다. +- 상품은 반드시 하나의 브랜드에 소속되어야 한다. +- 한번 정해진 브랜드는 나중에 바꿀 수 없다. +- 삭제된 브랜드에는 상품을 등록할 수 없다. + +**[기능 흐름]** +1. 관리자가 상품 정보를 입력한다: 이름, 설명, 가격, 재고, 소속 브랜드 +2. 해당 브랜드가 존재하고 활성 상태인지 확인한다. +3. 상품이 등록된다. + +**[이런 경우에는]** +- 브랜드를 선택하지 않거나 없는 브랜드이면 → "브랜드를 찾을 수 없습니다" +- 삭제된 브랜드를 선택하면 → "삭제된 브랜드에는 상품을 등록할 수 없습니다" +- 이름을 입력하지 않으면 → "상품명을 입력해주세요" +- 설명은 입력하지 않아도 된다 (선택) +- 가격이 0원 이하이면 → "가격은 1원 이상이어야 합니다" +- 재고가 0 미만이면 → "재고는 0 이상이어야 합니다" + +--- + +### 5-4. 상품 수정 + +**[유저 스토리]** +- 관리자는 상품의 이름, 설명, 가격, 재고를 수정할 수 있다. +- 소속 브랜드는 변경할 수 없다. +- 수정 시 모든 항목을 보내야 한다 (전체 덮어쓰기 방식). + +**[기능 흐름]** +1. 관리자가 수정할 상품을 선택한다. +2. 이름, 설명, 가격, 재고를 변경하고 저장한다. + +**[이런 경우에는]** +- 없는 상품 → "상품을 찾을 수 없습니다" +- 브랜드를 바꾸려고 하면 → "소속 브랜드는 변경할 수 없습니다" +- 가격이 0원 이하이면 → "가격은 1원 이상이어야 합니다" +- 재고가 0 미만이면 → "재고는 0 이상이어야 합니다" + +--- + +### 5-5. 상품 삭제 + +**[유저 스토리]** +- 관리자는 상품을 삭제할 수 있다. +- 사용자 화면에서 삭제된 상품은 더 이상 보이지 않는다. +- 기존 주문 기록(스냅샷)은 영향 없다. + +**[기능 흐름]** +1. 관리자가 삭제할 상품을 선택한다. +2. 삭제를 확인한다. +3. 상품이 삭제 상태로 전환된다. + +**[이런 경우에는]** +- 없는 상품 → "상품을 찾을 수 없습니다" +- 이미 삭제된 상품 → "이미 삭제된 상품입니다" + +--- + +## 6. 관리자 — 주문 확인 + +> 관리자 인증을 통과해야 사용할 수 있다. +> 관리자는 주문을 **확인만** 할 수 있다. 주문을 만들거나 취소할 수 없다. + +### 6-1. 주문 목록 보기 + +**[유저 스토리]** +- 관리자는 모든 회원의 주문 목록을 확인할 수 있다. + +**[기능 흐름]** +1. 관리자가 주문 관리 화면에 진입한다. +2. 전체 주문이 목록으로 표시된다. +3. 페이지 단위로 표시된다. + +--- + +### 6-2. 주문 상세 보기 + +**[유저 스토리]** +- 관리자는 특정 주문의 상세 내역을 확인할 수 있다. + +**[기능 흐름]** +1. 관리자가 주문을 선택한다. +2. 주문 정보(상태, 주문일시, 주문 회원)와 상품 기록(이름, 가격, 수량, 브랜드명)이 표시된다. + +**[이런 경우에는]** +- 없는 주문 → "주문을 찾을 수 없습니다" + +--- + +## 7. 인증 + +### 7-1. 회원 인증 + +회원 전용 기능(좋아요, 주문)을 사용하려면 로그인이 필요하다. +로그인하지 않고 회원 전용 기능에 접근하면 → "로그인이 필요합니다" + +### 7-2. 관리자 인증 + +관리자 전용 기능(브랜드 관리, 상품 관리, 주문 확인)을 사용하려면 관리자 인증이 필요하다. +인증에 실패하면 → "관리자 인증이 필요합니다" + +--- + +## 부록 A: 주문의 생명주기 + +주문은 두 가지 상태를 가진다. + +``` +주문 요청 ──(재고 충분)──→ 수락 + │ + └──(재고 부족)──→ 거절 +``` + +| 상태 | 의미 | +|------|------| +| **수락 (ACCEPTED)** | 재고 확인 완료, 재고 차감됨, 주문 성공 | +| **거절 (REJECTED)** | 재고 부족으로 주문 실패, 차감 없음 | + +- 수락과 거절 모두 최종 상태이다. 한번 결정되면 변경할 수 없다. +- 거절된 주문도 내역에 남아서, 회원이 "왜 주문이 안 됐는지" 확인할 수 있다. + +--- + +## 부록 B: 삭제 정책 + +브랜드와 상품의 삭제는 **되돌릴 수 있는 삭제**(soft-delete)이다. +삭제된 데이터는 사용자 화면에서 보이지 않지만, 관리자 화면에서는 확인할 수 있다. +좋아요는 **물리 삭제**(hard-delete)이다. 취소 시 레코드가 완전히 제거된다. + +| 대상 | 삭제 방식 | 삭제하면 | 연쇄 효과 | +|------|----------|---------|----------| +| **브랜드** | soft-delete | 사용자 화면에서 브랜드가 사라짐 | 소속 상품도 함께 삭제됨 | +| **상품** | soft-delete | 사용자 화면에서 상품이 사라짐. 좋아요 목록에서도 제외됨 | - | +| **좋아요** | hard-delete | 좋아요가 완전히 제거됨 | - | + +- 주문 기록(스냅샷)은 어떤 삭제에도 영향받지 않는다. + +--- + +## 부록 C: 결정 이력 + +| # | 항목 | 결정 | 이유 | +|---|------|------|------| +| 1 | 거절된 주문에 상품 기록(스냅샷)을 저장할지 | 저장한다 | 거절된 내용도 사용자 입장에서 확인이 필요하기 때문 | +| 2 | 좋아요 삭제 방식 | hard-delete | 좋아요 처리가 soft-delete 시 많은 자원을 소모하므로, 물리 삭제로 변경 | diff --git a/docs/design/02-sequence-diagrams.md b/docs/design/02-sequence-diagrams.md new file mode 100644 index 000000000..66ba80f4c --- /dev/null +++ b/docs/design/02-sequence-diagrams.md @@ -0,0 +1,588 @@ +# 시퀀스 다이어그램 + +> 작성일: 2026-02-11 +> 기능 정의서(01-requirements.md) 기반 +> 각 객체가 어떤 책임을 맡는가, 객체 간 메시지(책임 위임)는 어떻게 흐르는가를 중심으로 기술한다. +> 도메인 엔티티(Brand, Product, Order 등)도 책임 객체로서 다이어그램에 참여한다. + +### 객체 의존 관계 + +``` +OrderService → ProductService (주문 시 재고 확인/차감) +LikeService → ProductService (좋아요 시 상품/브랜드 유효성 확인) +BrandService ↔ ProductService (순환: 브랜드 삭제 연쇄 / 상품 등록 시 브랜드 검증) + → Facade로 해소: AdminBrandFacade, AdminProductFacade +``` + +--- + +## 1. 상품 둘러보기 + +### 1-1. 상품 목록 보기 + +```mermaid +sequenceDiagram + actor U as 사용자 + participant C as ProductController + participant PS as ProductService + participant PR as ProductRepository + + U->>C: GET /api/v1/products?page&size&sort&brandId + C->>PS: 상품 목록 조회 getProducts(page, size, sort, brandId) + PS->>PR: 활성 상품 페이징 조회 findAllActive(page, size, sort, brandId) + Note over PR: 삭제된 상품·브랜드 제외
brandId 필터 (선택)
정렬: latest / price_asc / likes_desc + PR-->>PS: Page + PS-->>C: Page + C-->>U: 200 OK +``` + +#### 읽는 포인트 +- **ProductRepository**: 삭제 필터링(상품 + 브랜드), 정렬, 페이징을 한 번의 조회로 처리하는 책임. + +--- + +### 1-2. 상품 상세 보기 + +```mermaid +sequenceDiagram + actor U as 사용자 + participant C as ProductController + participant PS as ProductService + participant PR as ProductRepository + + U->>C: GET /api/v1/products/{productId} + C->>PS: 상품 상세 조회 getProductDetail(productId) + PS->>PR: 활성 상품 단건 조회 findActiveById(productId) + Note over PR: 삭제된 상품·브랜드 제외
브랜드 정보 함께 조회 + PR-->>PS: Product + Brand + PS-->>C: ProductDetailInfo + C-->>U: 200 OK +``` + +#### 읽는 포인트 +- **ProductRepository**: 상품과 브랜드를 한 번에 조회하며, 어느 쪽이든 삭제되었으면 조회 불가. + +--- + +### 1-3. 브랜드 상세 보기 + +```mermaid +sequenceDiagram + actor U as 사용자 + participant C as BrandController + participant BS as BrandService + participant BR as BrandRepository + + U->>C: GET /api/v1/brands/{brandId} + C->>BS: 브랜드 상세 조회 getBrand(brandId) + BS->>BR: 활성 브랜드 단건 조회 findActiveById(brandId) + BR-->>BS: Brand + BS-->>C: BrandInfo + C-->>U: 200 OK +``` + +--- + +## 2. 좋아요 + +### 2-1. 좋아요 등록 + +```mermaid +sequenceDiagram + actor M as 회원 + participant C as LikeController + participant LS as LikeService + participant PS as ProductService + participant LR as LikeRepository + + M->>C: POST /api/v1/products/{productId}/likes + C->>LS: 좋아요 등록 registerLike(memberId, productId) + + LS->>PS: 상품 유효성 확인 getActiveProduct(productId) + Note over PS: 상품 존재·삭제 여부, 브랜드 삭제 여부 확인 + PS-->>LS: Product + + LS->>LR: 중복 확인 existsByMemberIdAndProductId(memberId, productId) + LR-->>LS: boolean + + LS->>LR: 좋아요 저장 save(newLike) + + LS-->>C: 등록 완료 + C-->>M: 201 Created +``` + +#### 읽는 포인트 +- **LikeService**: 등록 흐름 조율. 상품 유효성은 ProductService에 위임하여, 상품/브랜드 상태를 직접 알 필요가 없다. +- **LikeRepository**: 중복 확인과 저장의 책임. hard-delete 방식이므로 취소 이력 없이 단순하게 존재 여부만 확인한다. +- 이미 좋아요가 있으면 LikeService가 예외를 발생시킨다. + +--- + +### 2-2. 좋아요 취소 + +```mermaid +sequenceDiagram + actor M as 회원 + participant C as LikeController + participant LS as LikeService + participant LR as LikeRepository + + M->>C: DELETE /api/v1/products/{productId}/likes + C->>LS: 좋아요 취소 cancelLike(memberId, productId) + + LS->>LR: 좋아요 조회 findByMemberIdAndProductId(memberId, productId) + LR-->>LS: ProductLike + + LS->>LR: 좋아요 삭제 delete(productLike) + Note over LR: 물리 삭제 (hard-delete) + + LS-->>C: 취소 완료 + C-->>M: 200 OK +``` + +#### 읽는 포인트 +- **LikeRepository**: 물리 삭제(hard-delete)의 책임. 레코드가 완전히 제거되므로 재등록 시 새 레코드가 생성된다. +- **LikeService**: 상품/브랜드 존재 여부를 확인하지 않는다. 요구사항에 따라 브랜드가 삭제되어도 취소는 가능하다. + +--- + +### 2-3. 내 좋아요 목록 보기 + +```mermaid +sequenceDiagram + actor M as 회원 + participant C as LikeController + participant LS as LikeService + participant LR as LikeRepository + + M->>C: GET /api/v1/likes?page&size + C->>LS: 내 좋아요 목록 조회 getMyLikes(memberId, page, size) + LS->>LR: 좋아요 목록 조회 findLikesByMemberId(memberId, page, size) + Note over LR: 상품 활성 + 브랜드 활성 조건 필터
삭제된 상품·브랜드의 좋아요는 제외 + LR-->>LS: Page + LS-->>C: Page + C-->>M: 200 OK +``` + +#### 읽는 포인트 +- **LikeRepository**: 상품·브랜드 활성 필터링의 책임. 삭제된 상품/브랜드에 대한 좋아요는 목록에서 제외된다. + +--- + +## 3. 주문 + +### 3-1. 주문하기 + +```mermaid +sequenceDiagram + actor M as 회원 + participant C as OrderController + participant OS as OrderService + participant PS as ProductService + participant P as Product + participant OR as OrderRepository + + M->>C: POST /api/v1/orders [{productId, quantity}, ...] + C->>OS: 주문 생성 createOrder(memberId, items) + + OS->>OS: 중복 상품 검증, productId 오름차순 정렬 + + loop 각 상품 (오름차순) + OS->>PS: 주문용 상품 조회 (락) getProductForOrder(productId) + Note over PS: 삭제된 상품·브랜드 확인, 비관적 락 획득 + PS-->>OS: Product + end + + OS->>OS: 스냅샷 생성 (모든 상품의 현재 정보 캡처) + + loop 각 상품 재고 확인 + OS->>P: 재고 충분 확인 hasEnoughStock(quantity) + end + + alt 모든 상품 재고 충분 + loop 각 상품 + OS->>P: 재고 차감 decreaseStock(quantity) + end + OS->>OR: 수락 주문 저장 save(order: ACCEPTED, snapshots) + else 하나라도 재고 부족 + OS->>OR: 거절 주문 저장 save(order: REJECTED, snapshots) + end + + OS-->>C: 주문 결과 + C-->>M: 응답 +``` + +#### 읽는 포인트 +- **OrderService**: 주문 흐름 전체를 조율하는 책임. 중복 검증, 정렬(데드락 방지), 스냅샷 생성, 수락/거절 판단까지 관장한다. ProductService와 단방향 의존. +- **Product 엔티티**: `hasEnoughStock(quantity)` — 재고 충분 여부를 Product이 스스로 판단한다. `decreaseStock(quantity)` — 재고 차감도 Product이 스스로 수행한다. 외부에서 stock 값을 직접 조작하지 않는다. +- **ProductService**: 비관적 락으로 상품을 조회하고 유효성(삭제 여부, 브랜드 상태)을 확인하는 책임. +- **OrderRepository**: 수락/거절 모두 스냅샷과 함께 저장하는 책임. +- **"확인 먼저, 차감 나중"**: 모든 상품을 먼저 확보한 후, 재고 충분 여부를 판단하고, 충분할 때만 차감한다. + +--- + +### 3-2. 내 주문 내역 보기 + +```mermaid +sequenceDiagram + actor M as 회원 + participant C as OrderController + participant OS as OrderService + participant OR as OrderRepository + + M->>C: GET /api/v1/orders?startDate&endDate&page&size + C->>OS: 내 주문 목록 조회 getMyOrders(memberId, startDate, endDate, page, size) + Note over OS: 날짜 유효성 검증 (필수, 시작일 ≤ 종료일) + OS->>OR: 회원의 기간별 주문 조회 findByMemberIdAndPeriod(memberId, startDate, endDate, page, size) + OR-->>OS: Page + OS-->>C: Page + C-->>M: 200 OK +``` + +#### 읽는 포인트 +- **OrderService**: 날짜 유효성 검증의 책임. 날짜는 필수이며, 시작일이 종료일보다 뒤면 예외. +- **OrderRepository**: 회원 ID + 기간 필터의 책임. 본인 주문만 조회된다. + +--- + +### 3-3. 주문 상세 보기 + +```mermaid +sequenceDiagram + actor M as 회원 + participant C as OrderController + participant OS as OrderService + participant OR as OrderRepository + participant O as Order + + M->>C: GET /api/v1/orders/{orderId} + C->>OS: 주문 상세 조회 getOrderDetail(memberId, orderId) + OS->>OR: 주문 + 스냅샷 조회 findWithSnapshotsById(orderId) + OR-->>OS: Order + List + + OS->>O: 본인 확인 isOwnedBy(memberId) + Note over O: 본인 주문이 아니면 예외 + + OS-->>C: OrderDetailInfo + C-->>M: 200 OK +``` + +#### 읽는 포인트 +- **Order 엔티티**: `isOwnedBy(memberId)` — 본인 확인은 Order 객체 스스로가 판단한다. Service가 memberId를 비교하는 것이 아니다. +- **OrderRepository**: 주문과 스냅샷을 함께 로딩하는 책임. + +--- + +## 4. 관리자 — 브랜드 관리 + +### 4-1. 브랜드 목록 보기 + +```mermaid +sequenceDiagram + actor A as 관리자 + participant C as AdminBrandController + participant BS as BrandService + participant BR as BrandRepository + + A->>C: GET /api/v1/admin/brands?page&size + C->>BS: 전체 브랜드 목록 조회 getAllBrands(page, size) + BS->>BR: 전체 브랜드 페이징 조회 findAll(page, size) + Note over BR: 삭제된 브랜드 포함 + BR-->>BS: Page + BS-->>C: Page + C-->>A: 200 OK +``` + +--- + +### 4-2. 브랜드 상세 보기 + +```mermaid +sequenceDiagram + actor A as 관리자 + participant C as AdminBrandController + participant BS as BrandService + participant BR as BrandRepository + + A->>C: GET /api/v1/admin/brands/{brandId} + C->>BS: 브랜드 상세 조회 getBrand(brandId) + BS->>BR: 브랜드 단건 조회 findById(brandId) + Note over BR: 삭제된 브랜드도 조회 가능 + BR-->>BS: Brand + BS-->>C: BrandDetailInfo + C-->>A: 200 OK +``` + +--- + +### 4-3. 브랜드 등록 + +```mermaid +sequenceDiagram + actor A as 관리자 + participant C as AdminBrandController + participant BS as BrandService + participant BR as BrandRepository + + A->>C: POST /api/v1/admin/brands {name, description} + C->>BS: 브랜드 등록 createBrand(name, description) + BS->>BR: 이름 중복 확인 existsByName(name) + BR-->>BS: boolean + BS->>BR: 브랜드 저장 save(brand) + BR-->>BS: Brand + BS-->>C: BrandInfo + C-->>A: 201 Created +``` + +#### 읽는 포인트 +- **BrandService**: 입력값 검증(이름 필수)과 이름 중복 검증의 책임. 설명은 선택. +- **BrandRepository**: 이름 중복 여부 확인과 저장의 책임. + +--- + +### 4-4. 브랜드 수정 + +```mermaid +sequenceDiagram + actor A as 관리자 + participant C as AdminBrandController + participant BS as BrandService + participant BR as BrandRepository + participant B as Brand + + A->>C: PUT /api/v1/admin/brands/{brandId} {name, description} + C->>BS: 브랜드 수정 updateBrand(brandId, name, description) + BS->>BR: 브랜드 조회 findById(brandId) + BR-->>BS: Brand + BS->>BR: 이름 중복 확인 (자기 제외) existsByNameAndIdNot(name, brandId) + BR-->>BS: boolean + BS->>B: 정보 변경 update(name, description) + BS-->>C: BrandInfo + C-->>A: 200 OK +``` + +#### 읽는 포인트 +- **Brand 엔티티**: `update(name, description)` — 정보 변경은 Brand 객체 스스로가 수행한다. 전체 덮어쓰기 방식. +- **BrandRepository**: 이름 중복 검증 시 자기 자신을 제외하는 책임. + +--- + +### 4-5. 브랜드 삭제 (연쇄 soft-delete) + +> BrandService ↔ ProductService 순환 의존을 Facade로 해소한다. + +```mermaid +sequenceDiagram + actor A as 관리자 + participant C as AdminBrandController + participant F as AdminBrandFacade + participant BS as BrandService + participant PS as ProductService + participant B as Brand + + A->>C: DELETE /api/v1/admin/brands/{brandId} + C->>F: 브랜드 삭제 deleteBrand(brandId) + + F->>BS: 브랜드 조회 getBrand(brandId) + BS-->>F: Brand + + F->>B: 삭제 여부 확인 guardNotDeleted() + Note over B: 이미 삭제된 상태면 예외 + + F->>PS: 소속 상품 연쇄 삭제 softDeleteByBrandId(brandId) + F->>B: 삭제 delete() + Note over B: name 변경 + deletedAt 세팅
(UNIQUE 제약 해소) + + F-->>C: 삭제 완료 + C-->>A: 204 No Content +``` + +#### 읽는 포인트 +- **AdminBrandFacade**: BrandService ↔ ProductService 순환을 해소하는 조율자. 삭제 순서(상품 먼저 → 브랜드 나중)를 결정하는 책임. +- **Brand 엔티티**: `guardNotDeleted()` — 삭제 가능 상태인지 스스로 검증한다. `delete()` — deletedAt 세팅도 스스로 수행한다. +- **ProductService**: 브랜드 ID로 소속 상품을 일괄 soft-delete하는 책임. 좋아요는 건드리지 않는다. + +--- + +## 5. 관리자 — 상품 관리 + +### 5-1. 상품 목록 보기 + +```mermaid +sequenceDiagram + actor A as 관리자 + participant C as AdminProductController + participant PS as ProductService + participant PR as ProductRepository + + A->>C: GET /api/v1/admin/products?page&size&brandId + C->>PS: 전체 상품 목록 조회 getAllProducts(page, size, brandId) + PS->>PR: 전체 상품 페이징 조회 findAll(page, size, brandId) + Note over PR: 삭제된 상품 포함, brandId 필터 (선택) + PR-->>PS: Page + PS-->>C: Page + C-->>A: 200 OK +``` + +--- + +### 5-2. 상품 상세 보기 + +```mermaid +sequenceDiagram + actor A as 관리자 + participant C as AdminProductController + participant PS as ProductService + participant PR as ProductRepository + + A->>C: GET /api/v1/admin/products/{productId} + C->>PS: 상품 상세 조회 getProduct(productId) + PS->>PR: 상품 단건 조회 findById(productId) + Note over PR: 삭제된 상품도 조회 가능, 브랜드 정보 함께 조회 + PR-->>PS: Product + Brand + PS-->>C: ProductDetailInfo + C-->>A: 200 OK +``` + +--- + +### 5-3. 상품 등록 + +> BrandService ↔ ProductService 순환 의존을 Facade로 해소한다. + +```mermaid +sequenceDiagram + actor A as 관리자 + participant C as AdminProductController + participant F as AdminProductFacade + participant BS as BrandService + participant B as Brand + participant PS as ProductService + participant P as Product + participant PR as ProductRepository + + A->>C: POST /api/v1/admin/products {name, description, price, stock, brandId} + C->>F: 상품 등록 createProduct(name, description, price, stock, brandId) + + F->>BS: 브랜드 조회 getBrand(brandId) + BS-->>F: Brand + + F->>B: 삭제 여부 확인 guardNotDeleted() + Note over B: 삭제된 브랜드면 예외 + + F->>PS: 상품 생성 createProduct(name, description, price, stock, brandId) + PS->>P: 생성 new Product(name, description, price, stock, brandId) + Note over P: 가격 > 0, 재고 >= 0 검증 + PS->>PR: 상품 저장 save(product) + PR-->>PS: Product + PS-->>F: Product + + F-->>C: ProductInfo + C-->>A: 201 Created +``` + +#### 읽는 포인트 +- **AdminProductFacade**: BrandService ↔ ProductService 순환을 해소하는 조율자. 브랜드 검증 → 상품 생성 순서를 결정하는 책임. +- **Brand 엔티티**: `guardNotDeleted()` — 삭제된 브랜드에 상품을 등록할 수 없다는 불변식을 Brand 스스로가 지킨다. +- **Product 엔티티**: 생성 시 입력값 검증(가격 > 0, 재고 >= 0)을 스스로 수행한다. +- **ProductService**: 상품 생성 조율과 저장의 책임. 입력값 검증은 Product에 위임. BrandService를 모른다. + +--- + +### 5-4. 상품 수정 + +```mermaid +sequenceDiagram + actor A as 관리자 + participant C as AdminProductController + participant PS as ProductService + participant PR as ProductRepository + participant P as Product + + A->>C: PUT /api/v1/admin/products/{productId} {name, description, price, stock} + C->>PS: 상품 수정 updateProduct(productId, name, description, price, stock) + PS->>PR: 상품 조회 findById(productId) + PR-->>PS: Product + + PS->>P: 정보 변경 update(name, description, price, stock) + Note over P: 가격 > 0, 재고 >= 0 검증
브랜드는 변경 불가 + + PS-->>C: ProductInfo + C-->>A: 200 OK +``` + +#### 읽는 포인트 +- **Product 엔티티**: `update(name, description, price, stock)` — 정보 변경과 입력값 검증을 Product 스스로가 수행한다. 브랜드 변경은 아예 파라미터로 받지 않는다. + +--- + +### 5-5. 상품 삭제 + +```mermaid +sequenceDiagram + actor A as 관리자 + participant C as AdminProductController + participant PS as ProductService + participant PR as ProductRepository + participant P as Product + + A->>C: DELETE /api/v1/admin/products/{productId} + C->>PS: 상품 삭제 deleteProduct(productId) + PS->>PR: 상품 조회 findById(productId) + PR-->>PS: Product + + PS->>P: 삭제 여부 확인 guardNotDeleted() + Note over P: 이미 삭제된 상태면 예외 + PS->>P: 삭제 delete() + Note over P: deletedAt 세팅 + + PS-->>C: 삭제 완료 + C-->>A: 204 No Content +``` + +#### 읽는 포인트 +- **Product 엔티티**: `guardNotDeleted()` — 삭제 가능 상태인지 스스로 검증한다. `delete()` — deletedAt 세팅도 스스로 수행한다. +- 단독 soft-delete. 좋아요는 건드리지 않으며, 목록 조회 시 자연스럽게 제외된다. + +--- + +## 6. 관리자 — 주문 확인 + +### 6-1. 주문 목록 보기 + +```mermaid +sequenceDiagram + actor A as 관리자 + participant C as AdminOrderController + participant OS as OrderService + participant OR as OrderRepository + + A->>C: GET /api/v1/admin/orders?page&size + C->>OS: 전체 주문 목록 조회 getAllOrders(page, size) + OS->>OR: 전체 주문 페이징 조회 findAll(page, size) + OR-->>OS: Page + OS-->>C: Page + C-->>A: 200 OK +``` + +--- + +### 6-2. 주문 상세 보기 + +```mermaid +sequenceDiagram + actor A as 관리자 + participant C as AdminOrderController + participant OS as OrderService + participant OR as OrderRepository + + A->>C: GET /api/v1/admin/orders/{orderId} + C->>OS: 주문 상세 조회 getOrderDetail(orderId) + OS->>OR: 주문 + 스냅샷 조회 findWithSnapshotsById(orderId) + OR-->>OS: Order + List + OS-->>C: OrderDetailInfo + C-->>A: 200 OK +``` + +#### 읽는 포인트 +- 회원 주문 상세(3-3)와 달리 `Order.isOwnedBy()` 호출이 없다. 관리자는 모든 주문을 조회할 수 있다. diff --git a/docs/design/03-class-diagram.md b/docs/design/03-class-diagram.md new file mode 100644 index 000000000..1a1a5c400 --- /dev/null +++ b/docs/design/03-class-diagram.md @@ -0,0 +1,340 @@ +# 클래스 다이어그램 + +> 작성일: 2026-02-12 +> 기능 정의서(01-requirements.md) + 시퀀스 다이어그램(02-sequence-diagrams.md) 기반 +> 4가지 원칙: (1) 엔티티/VO 분리 — ID·생명주기·자체 규칙 (2) 단방향 연관 기본 (3) 비즈니스 책임은 도메인 객체에 (위임 패턴) (4) 책임 집중 점검 + +--- + +## 1. 클래스 다이어그램 + +```mermaid +classDiagram + direction TB + + %% ── Value Objects ── + + class Stock { + <> + -int value + +decrease(Quantity) Stock + +isEnough(Quantity) boolean + } + + class Price { + <> + -int value + } + + class Quantity { + <> + -int value + } + + note for Stock "불변 객체\nisEnough → 충분 여부 boolean\ndecrease → 새 Stock 반환 (부족 시 예외)" + note for Price "불변 객체\n생성 시 value > 0 검증" + note for Quantity "불변 객체\n생성 시 value > 0 검증" + + %% ── 브랜드 ── + + class Brand { + -String name + -String description + +update(name, description) void + +guardNotDeleted() void + +delete() void + } + + note for Brand "delete():\nname 변경 + soft-delete\n(UNIQUE 제약 해소)" + + %% ── 상품 ── + + class Product { + -String name + -String description + -Price price + -Stock stock + -Long brandId + +update(name, description, Price, Stock) void + +hasEnoughStock(Quantity) boolean + +decreaseStock(Quantity) void + +guardNotDeleted() void + } + + class ProductLike { + -Long memberId + -Long productId + } + + %% ── 주문 ── + + class Order { + -Long memberId + -OrderStatus status + -ZonedDateTime orderedAt + -List~OrderLineSnapshot~ lines + +isOwnedBy(memberId) boolean + } + + class OrderLineSnapshot { + <> + -Long productId + -String productName + -String productDescription + -Price price + -Quantity quantity + -String brandName + } + + class OrderStatus { + <> + ACCEPTED + REJECTED + } + + %% ── VO 포함 (Composition) ── + Product *-- Stock : stock + Product *-- Price : price + OrderLineSnapshot *-- Price : 주문 시점 가격 + OrderLineSnapshot *-- Quantity : quantity + + %% ── VO 간 행위 의존 ── + Stock ..> Quantity : isEnough / decrease + + %% ── 연관 (단방향, ID 참조) ── + Product ..> Brand : brandId (Long) + ProductLike ..> Product : productId (Long) + ProductLike ..> Member : memberId (Long) + Order ..> Member : memberId (Long) + Order *-- OrderLineSnapshot : 1..N + Order --> OrderStatus : status +``` + +--- + +## 2. 읽는 포인트 + +### 원칙 1: 엔티티/VO 분리 — ID 존재 여부, 생명주기, 자체 규칙 + +**엔티티 (Entity)**: 고유한 식별자(ID)를 가지며, 독립적인 생명주기를 가진다. + +- **Brand**: 고유 ID. 생성 → 수정 → 삭제의 독립 생명주기. +- **Product**: 고유 ID. 생성 → 수정 → 삭제의 독립 생명주기. 브랜드 삭제 시 연쇄 삭제되지만, 이는 비즈니스 규칙이지 생명주기 종속이 아니다. +- **ProductLike**: `memberId + productId`로 고유 식별. 등록 → 삭제의 독립 생명주기. +- **Order**: 고유 ID. 생성 시 즉시 최종 상태(ACCEPTED/REJECTED)로 결정. + +**Value Object (VO)**: 고유 식별자가 불필요하며, 자체 규칙(불변식)을 캡슐화하는 불변 객체다. + +- **Stock**: 재고의 본질적 규칙("음수가 될 수 없다")을 스스로 지킨다. `decrease(Quantity)` 시 부족하면 예외, 충분하면 새 Stock을 반환한다. +- **Price**: 가격의 규칙("0보다 커야 한다")을 생성 시 검증한다. 불변. +- **Quantity**: 수량의 규칙("0보다 커야 한다")을 생성 시 검증한다. Stock.decrease의 인자로 사용된다. +- **OrderLineSnapshot**: Order 없이 존재할 수 없다. 주문 시점의 Price와 Quantity를 포함하며, 한번 생성되면 불변이다. + +### 원칙 2: 단방향 연관, 양방향 최소화 + +모든 연관이 **단방향**이다. 양방향 참조는 하나도 없다. + +- `Product → Brand`: Product가 `brandId(Long)`로 브랜드를 참조한다. Brand는 자기에게 소속된 Product를 모른다. +- `ProductLike → Product`: ProductLike가 `productId(Long)`로 상품을 참조한다. Product는 자기에게 달린 좋아요를 모른다. +- `Order → Member`: Order가 `memberId(Long)`로 회원을 참조한다. Member는 자기의 주문을 모른다. + +**BC 간 참조는 ID(Long)만 사용한다.** 객체 참조가 아닌 ID 참조이므로 BC 간 직접 의존이 없다. + +### 원칙 3: 비즈니스 책임은 도메인 객체에 — 위임 패턴 + +엔티티는 VO에게 규칙 검증을 **위임**한다. Service가 아닌 도메인 객체가 비즈니스 규칙을 수행한다. + +**VO가 지키는 규칙 (자기 자신의 불변식)** + +| VO | 규칙 | 검증 시점 | +|----|------|----------| +| Stock | value >= 0 | 생성 시 | +| Stock | value >= quantity.value | isEnough(), decrease() 시 | +| Price | value > 0 | 생성 시 | +| Quantity | value > 0 | 생성 시 | + +**엔티티가 VO에 위임하는 행위** + +| 엔티티 | 메서드 | 위임 대상 | 위임 내용 | +|--------|--------|----------|----------| +| Product | `hasEnoughStock(Quantity)` | Stock.isEnough(Quantity) | 재고 충분 여부 판단을 Stock에 위임 | +| Product | `decreaseStock(Quantity)` | Stock.decrease(Quantity) | 재고 차감 규칙은 Stock이 수행. Product는 결과를 받아 자기 상태를 교체 | +| Product | `update(name, desc, Price, Stock)` | Price, Stock 생성자 | 가격·재고 검증은 VO 생성 시 이미 완료. Product는 교체만 수행 | + +**엔티티가 직접 수행하는 행위 (위임 불필요)** + +| 엔티티 | 메서드 | 왜 이 객체의 책임인가 | +|--------|--------|---------------------| +| Brand | `update(name, desc)` | 자기 데이터를 자기가 변경한다 | +| Brand | `guardNotDeleted()` | 자기 상태(삭제 여부)를 자기가 검증한다 | +| Brand | `delete()` | 삭제 + 이름 변경을 한 번에 수행한다 (UNIQUE 해소) | +| Product | `guardNotDeleted()` | 삭제 여부를 자기가 검증한다 | +| Order | `isOwnedBy(memberId)` | 본인 주문 확인을 자기가 판단한다 | + +**ProductLike에 메서드가 없는 이유**: 좋아요는 "회원과 상품 사이의 관계 기록"이라는 본질에 충실한 단순 엔티티다. 등록은 `new ProductLike(memberId, productId)`, 삭제는 물리 삭제(hard-delete). + +### 원칙 4: 한 객체에 책임이 몰리지 않았는가? + +> 섹션 4(책임 분산 점검)에서 상세 분석. + +--- + +## 3. 엔티티 vs VO 분류표 + +| 클래스 | 분류 | 기준: ID | 기준: 생명주기 | 기준: 자체 규칙 | 삭제 방식 | +|--------|------|---------|--------------|---------------|----------| +| Brand | Entity | 고유 ID | 독립 (생성→수정→삭제) | - | soft-delete | +| Product | Entity | 고유 ID | 독립, 브랜드 연쇄 삭제 가능 | - | soft-delete | +| ProductLike | Entity | member+product 식별 | 독립 (등록→삭제) | - | hard-delete | +| Order | Entity | 고유 ID | 독립 (생성→최종 상태) | - | 삭제 없음 | +| Stock | **VO** | 불필요 | Product에 종속 | value >= 0, decrease 시 비음수 검증 | Product와 동일 | +| Price | **VO** | 불필요 | Product 또는 Snapshot에 종속 | value > 0 | 소유자와 동일 | +| Quantity | **VO** | 불필요 | Snapshot에 종속 | value > 0 | 소유자와 동일 | +| OrderLineSnapshot | **VO** | 불필요 | Order에 종속, 불변 | Price·Quantity에 위임 | Order와 동일 | +| OrderStatus | enum | - | - | - | - | + +### VO 선별 기준: "자체 규칙이 있는가?" + +``` +자체 규칙 있음 → VO +├── Stock: 음수 불가 + 차감 행위 +├── Price: 양수만 가능 +├── Quantity: 양수만 가능 +└── OrderLineSnapshot: 주문에 종속 + 불변 + Price·Quantity 포함 + +자체 규칙 없음 → 원시 타입 유지 +├── name (String): 단순 필수값 +├── description (String): 선택값 +└── brandId, memberId, productId (Long): 식별 참조 +``` + +--- + +## 4. 책임 분산 점검 + +### 도메인 객체별 (엔티티 + VO) + +| 객체 | 책임 수 | 책임 목록 | 판단 | +|------|--------|----------|------| +| Brand | 3 | update, guardNotDeleted, delete | **적절**. 자기 데이터에 대한 변경/검증/삭제 | +| Product | 4 | update, hasEnoughStock(위임), decreaseStock(위임), guardNotDeleted | **적절**. 재고 규칙을 Stock에 위임하여 책임 감소 | +| Stock | 2 | isEnough(Quantity), decrease(Quantity) | **적절**. 재고의 핵심 규칙만 보유. 같은 불변식(value >= quantity)의 조회/변경 | +| Price | 0+1 | 생성자 검증 | **적절**. 가격 규칙만 보유 | +| Quantity | 0+1 | 생성자 검증 | **적절**. 수량 규칙만 보유 | +| Order | 1 | isOwnedBy | **적절**. 현재 최소 | +| ProductLike | 0 | - | **적절**. 단순 관계 레코드 | +| OrderLineSnapshot | 0 | - | **적절**. 불변 VO. Price, Quantity를 포함하여 스냅샷 | + +### Service별 + +| Service | 주요 책임 | 판단 | +|---------|----------|------| +| BrandService | 브랜드 CRUD, 이름 중복 검증 | **적절** | +| ProductService | 상품 CRUD, 브랜드별 연쇄 삭제, 주문용 락 조회 | **적절** | +| LikeService | 좋아요 등록/취소, 목록 조회 + 상품 유효성 확인 위임 | **적절** | +| OrderService | 주문 생성 조율 (중복 검증, 정렬, 상품 확보, 스냅샷 생성, 수락/거절 판단) | **모니터링 필요**. 확장 시 분리 고려 | + +### Facade별 + +| Facade | 존재 이유 | 판단 | +|--------|----------|------| +| AdminBrandFacade | BrandService ↔ ProductService 순환 해소 (브랜드 삭제 연쇄) | **적절** | +| AdminProductFacade | BrandService ↔ ProductService 순환 해소 (상품 등록 브랜드 검증) | **적절** | + +--- + +## 5. 확장 지점 + +> 메인 목표: **좋아요 누르고, 쿠폰 쓰고, 주문 및 결제하는 커머스 플랫폼. 유저 행동은 랭킹과 추천으로 연결.** +> 현재는 구현하지 않지만, 현재 클래스 구조가 확장을 막지 않아야 한다. + +### 5-1. 결제 BC (Payment Context) + +주문에 결제를 연동한다. + +``` +현재: 주문 요청 → 재고 확인 → ACCEPTED / REJECTED +확장: 주문 요청 → 재고 확인 → ACCEPTED → PAYMENT_PENDING → PAID +``` + +- **현재 구조의 대응**: OrderStatus는 enum이므로 값 추가만으로 확장 가능. Payment BC는 `orderId(Long)`로 주문을 참조한다 (ID 참조 패턴 유지). +- **막히지 않는 이유**: Order가 Payment를 모른다. Payment가 Order를 ID로 참조하는 단방향. 새 BC를 추가해도 기존 코드를 수정할 필요 없다. + +### 5-2. 쿠폰 BC (Coupon Context) + +주문 시 쿠폰을 적용한다. + +- **현재 구조의 대응**: Order에 `couponId(Long)` 필드를 추가하고, Coupon BC는 별도로 분리한다. Order는 쿠폰의 존재만 알고, 할인 계산은 Coupon BC에 위임한다. +- **막히지 않는 이유**: ID 참조 패턴. BC 간 직접 의존 없음. + +### 5-3. 좋아요 → 선호 BC (Preference Context) + +좋아요 대상이 상품에서 브랜드로 확장되고, "선호"라는 상위 개념으로 통합되어 랭킹/추천으로 연결된다. + +``` +현재: ProductLike (상품 좋아요만) +확장: Preference BC + ├── ProductLike (상품 좋아요) + ├── BrandLike (브랜드 좋아요) + └── → 랭킹/추천 시스템 연동 +``` + +- **현재 구조의 대응**: ProductLike가 독립 엔티티이며 상품 BC에 소속. 나중에 BrandLike를 추가하고, 이들을 "선호 BC"로 묶으면 된다. +- **막히지 않는 이유**: ProductLike는 `memberId + productId` ID 참조만 사용하므로, 동일 패턴으로 `BrandLike(memberId + brandId)`를 만들 수 있다. 랭킹/추천은 이 데이터를 이벤트 기반으로 소비하면 된다. + +### 5-4. 주문 취소 + +수락된 주문을 회원이 취소한다. + +- **현재 구조의 대응**: OrderStatus에 `CANCELLED` 추가, Order에 `cancel()` 메서드 추가, Stock에 `increase(Quantity)` 추가. +- **막히지 않는 이유**: enum 값 추가 + 도메인 객체 메서드 추가만으로 구현 가능. 기존 구조를 변경할 필요 없다. + +### 5-5. 서비스 분리 (MSA) + +모놀리스에서 마이크로서비스로 전환한다. + +- **현재 구조의 대응**: 모든 BC 간 참조가 `Long` ID. Aggregate Root 경계 명확. 브랜드 삭제 연쇄를 도메인 이벤트로 전환하면 된다 (Facade → Event Publisher). +- **막히지 않는 이유**: 객체 참조가 아닌 ID 참조이므로, BC를 별도 서비스로 분리해도 참조 방식 변경 불필요. + +--- + +## 6. 설계 결정 기록 + +| # | 결정 | 이유 | 대안 | +|---|------|------|------| +| 1 | BaseTimeEntity 신규 도입 | ProductLike(hard-delete)와 Order(never deleted)는 deletedAt 불필요. 상속으로 삭제 정책을 코드에 명시 | BaseEntity 그대로 상속 (불필요한 컬럼, 의도 불명확) | +| 2 | Brand/Product는 BaseEntity 상속 | soft-delete 필요. deletedAt 활용 | 별도 closedAt 관리 (폐점 개념 제거됨, 불필요) | +| 3 | Brand.delete(): 이름 변경 + soft-delete | DB UNIQUE 제약 유지하면서 삭제된 브랜드 이름 재사용 가능 | 앱 레벨 검증만 (UNIQUE 없음), UNIQUE 제거 (데이터 정합성 약화) | +| 4 | OrderStatus: ACCEPTED, REJECTED만 | 현재 요구사항에 중간 상태/취소 없음. enum이므로 확장 용이 | CANCELLED 포함 (현재 불필요, YAGNI) | +| 5 | 모든 BC 간 참조를 ID(Long)만 사용 | BC 간 직접 의존 제거. MSA 전환 시 변경 최소화 | 객체 참조 (편리하나 BC 경계 위반) | +| 6 | OrderLineSnapshot은 VO | Order 없이 존재 불가, 불변, 독립 식별 불필요 | Entity로 분류 (불필요한 생명주기 관리) | +| 7 | ProductLike에 메서드 없음 | 단순 관계 레코드. hard-delete이므로 엔티티 행위 불필요 | toggle() 등 추가 (과도한 추상화) | +| 8 | 양방향 연관 0개 | 단방향만으로 모든 요구사항 충족. 양방향은 순환 의존과 복잡성 유발 | Product ↔ Brand 양방향 (편의성 vs 복잡성 트레이드오프) | +| 9 | 좋아요를 상품 BC에 배치 (현재) | 현재는 상품 좋아요만 존재. 확장 시 선호 BC로 분리 | 처음부터 선호 BC 분리 (YAGNI, 과도한 설계) | +| 10 | Stock, Price, Quantity를 VO로 분리 | 자체 규칙(불변식)이 있는 속성만 VO로 캡슐화. "규칙 없으면 원시 타입" 기준 | 원시 타입 유지 (규칙이 엔티티나 Service에 흩어짐) | +| 11 | Product.decreaseStock → Stock.decrease 위임 | 재고 규칙은 재고의 책임. Product는 조율만 수행 | Product가 직접 검증 (책임 혼재) | +| 12 | BaseEntity/BaseTimeEntity를 다이어그램에서 제외 | 비즈니스 설계에 기술 인프라 클래스가 불필요. 코드 구현 시 적용 | 포함 (기술적 완전성은 높지만 비즈니스 가독성 저하) | +| 13 | Stock.isEnough(Quantity) + Product.hasEnoughStock(Quantity) 추가 | 주문 시 "확인 먼저, 차감 나중" 흐름에서 재고 확인 판단 주체를 명확화. Quantity가 아닌 Stock이 보유 (같은 불변식, 의존 방향 유지) | Quantity.canBeSatisfiedBy(Stock) (VO 간 순환 의존 발생) | + +--- + +## 7. 안티패턴 점검 + +### 점검 항목 + +| # | 안티패턴 | 현재 설계 | 판단 | +|---|---------|----------|------| +| 1 | 모든 필드를 객체로 표현하려다 지나친 복잡도 | Stock, Price, Quantity만 VO — 자체 규칙이 있는 것만 선별. name, description, ID 등 규칙 없는 필드는 원시 타입 유지 | **통과**. "규칙이 있는 것만 VO"라는 기준으로 과도한 래핑 방지 | +| 2 | 도메인 책임 없이 Service에 모든 로직 집중 | 재고 규칙(Stock.decrease), 가격 검증(Price 생성), 수량 검증(Quantity 생성)이 VO에 캡슐화. 엔티티는 VO에 위임 | **통과**. 비즈니스 규칙이 도메인 객체에 분배됨 | +| 3 | VO를 테이블처럼 다루려는 시도 | Stock, Price, Quantity는 별도 테이블 없이 엔티티 컬럼으로 매핑. OrderLineSnapshot도 Order 내부에 Composition | **통과**. VO는 코드 구조이지 DB 구조가 아님 | + +### 클래스 구조가 도메인 설계를 잘 표현하고 있는가? + +| 설계 요소 | 도메인 의미 표현 방식 | +|----------|---------------------| +| VO 포함 (Product ◆── Stock, Price) | "재고와 가격은 상품의 속성이면서, 자기만의 규칙을 가진다"를 구조로 표현 | +| VO 간 의존 (Stock ──▷ Quantity) | "재고를 차감하려면 수량이 필요하다"는 도메인 관계를 표현 | +| 위임 패턴 (decreaseStock → Stock.decrease) | "규칙은 규칙을 아는 객체가 수행한다"는 객체지향 원칙을 표현 | +| 연관 방향 (전부 단방향 ID 참조) | BC 경계가 다이어그램에서 바로 보임 | +| Composition (Order ◆── OrderLineSnapshot) | "스냅샷은 주문의 일부"라는 생명주기 종속을 시각적으로 표현 | +| 메서드 없는 엔티티 (ProductLike) | "관계 기록"이라는 본질에 충실 — 억지 행위 없음 | diff --git a/docs/design/04-erd.md b/docs/design/04-erd.md new file mode 100644 index 000000000..408c8609b --- /dev/null +++ b/docs/design/04-erd.md @@ -0,0 +1,229 @@ +# ERD (Entity-Relationship Diagram) + +> 작성일: 2026-02-12 +> 기능 정의서(01), 시퀀스 다이어그램(02), 클래스 다이어그램(03) 기반 +> FK 제약조건 없음 — 모든 참조 무결성은 애플리케이션 레벨에서 관리 + +--- + +## 1. ERD 다이어그램 + +```mermaid +erDiagram + member { + BIGINT id PK "AUTO_INCREMENT" + VARCHAR login_id "NOT NULL" + VARCHAR password "NOT NULL" + VARCHAR name "NOT NULL" + DATE birth_date "NOT NULL" + VARCHAR email "NOT NULL" + } + + brand { + BIGINT id PK "AUTO_INCREMENT" + VARCHAR name "NOT NULL, UNIQUE" + TEXT description "nullable" + DATETIME created_at "NOT NULL" + DATETIME updated_at "NOT NULL" + DATETIME deleted_at "nullable" + } + + product { + BIGINT id PK "AUTO_INCREMENT" + VARCHAR name "NOT NULL" + TEXT description "nullable" + INT price "NOT NULL" + INT stock "NOT NULL" + BIGINT brand_id "NOT NULL" + DATETIME created_at "NOT NULL" + DATETIME updated_at "NOT NULL" + DATETIME deleted_at "nullable" + } + + product_like { + BIGINT id PK "AUTO_INCREMENT" + BIGINT member_id "NOT NULL" + BIGINT product_id "NOT NULL" + DATETIME created_at "NOT NULL" + DATETIME updated_at "NOT NULL" + } + + orders { + BIGINT id PK "AUTO_INCREMENT" + BIGINT member_id "NOT NULL" + VARCHAR status "NOT NULL" + DATETIME ordered_at "NOT NULL" + DATETIME created_at "NOT NULL" + DATETIME updated_at "NOT NULL" + } + + order_line_snapshot { + BIGINT id PK "AUTO_INCREMENT" + BIGINT order_id "NOT NULL" + BIGINT product_id "NOT NULL" + VARCHAR product_name "NOT NULL" + TEXT product_description "nullable" + INT price "NOT NULL" + INT quantity "NOT NULL" + VARCHAR brand_name "NOT NULL" + } + + brand ||--o{ product : "brand_id" + member ||--o{ product_like : "member_id" + product ||--o{ product_like : "product_id" + member ||--o{ orders : "member_id" + orders ||--o{ order_line_snapshot : "order_id" +``` + +> **관계선 = 논리 참조**. DB에 FK 제약조건은 존재하지 않는다. 참조 무결성은 애플리케이션 레벨에서 보장한다. + +--- + +## 2. 읽는 포인트 + +### FK 없음 — 앱 레벨 참조 무결성 + +모든 테이블 간 참조는 `BIGINT` 컬럼(brand_id, member_id 등)으로만 연결된다. DB에 FOREIGN KEY 제약조건을 걸지 않는다. + +- **이유**: BC(Bounded Context) 간 결합도를 최소화한다. MSA 전환 시 테이블이 별도 DB로 분리되어도 구조 변경이 불필요하다. +- **대신**: 참조 대상이 존재하는지, 삭제되지 않았는지는 Service/Facade 레벨에서 검증한다 (시퀀스 다이어그램 참고). + +### VO → 컬럼 매핑 + +클래스 다이어그램의 VO(Value Object)는 별도 테이블이 아닌 **엔티티 테이블의 컬럼**으로 매핑된다. + +| VO | 매핑 컬럼 | DB 타입 | 규칙 (앱 레벨) | +|----|----------|---------|--------------| +| Stock | product.stock | INT | >= 0 (음수 불가) | +| Price | product.price, order_line_snapshot.price | INT | > 0 (양수만) | +| Quantity | order_line_snapshot.quantity | INT | > 0 (양수만) | + +> VO는 코드 구조이지 DB 구조가 아니다 (클래스 다이어그램 안티패턴 #3). +> DB에는 INT 컬럼으로 저장되고, 앱에서 VO 객체로 감싸서 규칙을 검증한다. + +### 상속 전략 (MappedSuperclass) + +JPA 상속은 `@MappedSuperclass`를 사용한다. 상속 클래스별로 별도 테이블이 생기지 않고, 자식 테이블에 컬럼이 포함된다. + +| 상속 클래스 | 포함 컬럼 | 상속하는 테이블 | +|------------|----------|---------------| +| BaseEntity | id, created_at, updated_at, **deleted_at** | brand, product | +| BaseTimeEntity (신규) | id, created_at, updated_at | product_like, orders | + +### 삭제 정책별 테이블 구분 + +| 삭제 정책 | 테이블 | deleted_at 유무 | 상속 | +|----------|--------|----------------|------| +| soft-delete | brand, product | 있음 | BaseEntity | +| hard-delete | product_like | 없음 | BaseTimeEntity | +| 삭제 없음 | orders, order_line_snapshot | 없음 | BaseTimeEntity / 없음 | + +--- + +## 3. 테이블 상세 정의 + +### member (기존) + +독립 엔티티. BaseEntity를 상속하지 않는다. + +| 컬럼 | 타입 | 제약 | 비고 | +|-------|------|------|------| +| id | BIGINT | PK, AUTO_INCREMENT | | +| login_id | VARCHAR | NOT NULL | | +| password | VARCHAR | NOT NULL | 암호화 저장 | +| name | VARCHAR | NOT NULL | 2-40자, 한글/영문 | +| birth_date | DATE | NOT NULL | 미래 날짜 불가 | +| email | VARCHAR | NOT NULL | RFC 5321, 최대 255자 | + +### brand + +BaseEntity 상속 (soft-delete). + +| 컬럼 | 타입 | 제약 | 비고 | +|-------|------|------|------| +| id | BIGINT | PK, AUTO_INCREMENT | | +| name | VARCHAR | NOT NULL, UNIQUE | delete 시 이름 변경으로 UNIQUE 해소 | +| description | TEXT | nullable | 선택 입력 | +| created_at | DATETIME | NOT NULL | BaseEntity | +| updated_at | DATETIME | NOT NULL | BaseEntity | +| deleted_at | DATETIME | nullable | soft-delete 마커 | + +**UNIQUE 해소 전략**: Brand.delete() 시 name을 변경하여(예: `_DELETED_{timestamp}` 접미) UNIQUE 제약을 해소한다. 삭제된 브랜드 이름을 새 브랜드가 재사용할 수 있다. + +### product + +BaseEntity 상속 (soft-delete). + +| 컬럼 | 타입 | 제약 | 비고 | +|-------|------|------|------| +| id | BIGINT | PK, AUTO_INCREMENT | | +| name | VARCHAR | NOT NULL | | +| description | TEXT | nullable | 선택 입력 | +| price | INT | NOT NULL | VO: Price → 앱 레벨 > 0 검증 | +| stock | INT | NOT NULL | VO: Stock → 앱 레벨 >= 0 검증 | +| brand_id | BIGINT | NOT NULL | → brand.id (FK 없음) | +| created_at | DATETIME | NOT NULL | BaseEntity | +| updated_at | DATETIME | NOT NULL | BaseEntity | +| deleted_at | DATETIME | nullable | soft-delete 마커 | + +### product_like + +BaseTimeEntity 상속 (hard-delete). + +| 컬럼 | 타입 | 제약 | 비고 | +|-------|------|------|------| +| id | BIGINT | PK, AUTO_INCREMENT | | +| member_id | BIGINT | NOT NULL | → member.id (FK 없음) | +| product_id | BIGINT | NOT NULL | → product.id (FK 없음) | +| created_at | DATETIME | NOT NULL | BaseTimeEntity | +| updated_at | DATETIME | NOT NULL | BaseTimeEntity | + +- **UNIQUE(member_id, product_id)**: 같은 회원이 같은 상품에 중복 좋아요를 할 수 없다. + +### orders + +BaseTimeEntity 상속 (삭제 없음). 테이블명은 `orders` (ORDER는 SQL 예약어). + +| 컬럼 | 타입 | 제약 | 비고 | +|-------|------|------|------| +| id | BIGINT | PK, AUTO_INCREMENT | | +| member_id | BIGINT | NOT NULL | → member.id (FK 없음) | +| status | VARCHAR | NOT NULL | ACCEPTED / REJECTED | +| ordered_at | DATETIME | NOT NULL | 주문 시점 | +| created_at | DATETIME | NOT NULL | BaseTimeEntity | +| updated_at | DATETIME | NOT NULL | BaseTimeEntity | + +- **status**: 주문 생성 시 즉시 최종 상태(ACCEPTED/REJECTED)로 결정된다. 중간 상태 없음. + +### order_line_snapshot + +Order에 종속되는 VO. Composition 1:N. + +| 컬럼 | 타입 | 제약 | 비고 | +|-------|------|------|------| +| id | BIGINT | PK, AUTO_INCREMENT | JPA 매핑용 | +| order_id | BIGINT | NOT NULL | → orders.id (FK 없음) | +| product_id | BIGINT | NOT NULL | 스냅샷 시점 상품 ID | +| product_name | VARCHAR | NOT NULL | 스냅샷 | +| product_description | TEXT | nullable | 스냅샷 | +| price | INT | NOT NULL | VO: Price (주문 시점 가격) | +| quantity | INT | NOT NULL | VO: Quantity (주문 수량) | +| brand_name | VARCHAR | NOT NULL | 스냅샷 시점 브랜드명 | + +- **timestamp 없음**: 불변 VO. 생성 시점은 소속 Order의 created_at/ordered_at이 대변한다. +- **id 컬럼 존재 이유**: 도메인에서는 VO(독립 식별 불필요)이지만, JPA 1:N 매핑에 PK가 필요하다. +- **상품/브랜드 삭제 무관**: 스냅샷이므로 원본이 삭제되어도 기록은 유지된다. + +--- + +## 4. 설계 결정 기록 + +| # | 결정 | 이유 | 대안 | +|---|------|------|------| +| 1 | FK 제약조건 없음 | 앱 레벨에서 참조 무결성 관리. BC 간 결합도 최소화. MSA 전환 대비 | FK 설정 (DB 정합성 보장이 강하나, BC 간 결합 증가) | +| 2 | VO는 컬럼으로 매핑 | VO는 코드 구조이지 DB 구조가 아니다. 별도 테이블은 안티패턴 | VO별 테이블 (과도한 JOIN, 도메인 의미 왜곡) | +| 3 | order → orders 테이블명 | ORDER는 SQL 예약어. 백틱 의존보다 명확한 이름 사용 | 백틱으로 감싸기 (DB 종류 변경 시 호환성 문제) | +| 4 | OrderLineSnapshot에 id 컬럼 포함 | 도메인 VO이지만 JPA @OneToMany 매핑에 PK 필요 | @ElementCollection (컬렉션 전체 삭제/재삽입 성능 이슈) | +| 5 | OrderLineSnapshot에 timestamp 없음 | 불변 VO. Order의 created_at이 생성 시점을 대변 | timestamp 포함 (불필요한 중복 정보) | +| 6 | product_like에 UNIQUE(member_id, product_id) | 중복 좋아요 방지를 DB 레벨에서 보장. 앱 레벨 검증만으로는 동시성 이슈 가능 | 앱 레벨만 (경쟁 조건에 취약) | +| 7 | brand.name에 UNIQUE 제약 | 이름 중복 불가 요구사항. delete 시 이름 변경으로 UNIQUE 해소 (클래스 다이어그램 결정 #3) | UNIQUE 없이 앱 검증만 (동시성에 취약) | diff --git a/docs/design/base/class-diagram-erd.md b/docs/design/base/class-diagram-erd.md new file mode 100644 index 000000000..f18c7e7e0 --- /dev/null +++ b/docs/design/base/class-diagram-erd.md @@ -0,0 +1,418 @@ +# 클래스 다이어그램 & ERD + +> 작성일: 2026-02-10 +> 도메인 정의서(v2) + 요구사항 분석 기반 + +--- + +## 1. 클래스 다이어그램 + +### 왜 필요한가 +각 도메인의 책임 배분, Aggregate 경계, 엔티티/VO 구분, BC 간 참조 방식을 한눈에 확인한다. +도메인 로직이 Service에 집중되지 않고 엔티티 자체에 비즈니스 규칙이 있는지 검증한다. + +### 다이어그램 + +```mermaid +classDiagram + direction TB + + class BaseEntity { + <> + #Long id + #ZonedDateTime createdAt + #ZonedDateTime updatedAt + #ZonedDateTime deletedAt + +delete() void + +restore() void + +guard() void + } + + class BaseTimeEntity { + <> + #Long id + #ZonedDateTime createdAt + #ZonedDateTime updatedAt + +guard() void + } + + note for BaseTimeEntity "물리 삭제 도메인용\n(Brand, Product, ProductLike, Order)\ndeletedAt 없음" + note for BaseEntity "소프트 삭제 도메인용\n(Member 등 기존 엔티티)" + + class Brand { + -String name + -String description + -ZonedDateTime closedAt + +close() void + +reopen() void + +isClosed() boolean + +update(name, description) void + #guard() void + } + + class Product { + -String name + -String description + -int price + -int stock + -Long brandId + +decreaseStock(quantity) void + +restoreStock(quantity) void + +update(name, description, price, stock) void + #guard() void + } + + class ProductLike { + -Long memberId + -Long productId + } + + class Order { + -Long memberId + -OrderStatus status + -ZonedDateTime orderedAt + -List~OrderLineSnapshot~ lines + +cancel() void + +isOwnedBy(memberId) boolean + +isCancellable() boolean + } + + class OrderLineSnapshot { + <> + -Long productId + -String productName + -String productDescription + -int price + -int quantity + -String brandName + } + + class OrderStatus { + <> + REQUESTED + ACCEPTED + REJECTED + CANCELLED + } + + BaseTimeEntity <|-- Brand + BaseTimeEntity <|-- Product + BaseTimeEntity <|-- ProductLike + BaseTimeEntity <|-- Order + + Product ..> Brand : brandId 참조 (ID only) + ProductLike ..> Product : productId 참조 + ProductLike ..> Member : memberId 참조 + Order ..> Member : memberId 참조 + Order *-- OrderLineSnapshot : 1..N 포함 + Order --> OrderStatus : status +``` + +### 읽는 포인트 + +**1. 도메인 로직이 엔티티에 있다** +- `Brand.close()`, `Brand.reopen()` — 폐점/재입점은 브랜드 스스로가 결정하는 행위 +- `Product.decreaseStock()`, `Product.restoreStock()` — 재고 변경은 상품의 책임. 재고 비음수 불변식(`stock >= 0`)은 여기서 검증 +- `Order.cancel()` — 상태 전이 규칙(`ACCEPTED → CANCELLED`만 허용)은 주문이 판단 +- `Order.isOwnedBy()` — 본인 주문 여부 확인도 주문 엔티티의 책임 + +**2. BC 간 참조는 ID만 사용한다** +- `Product.brandId`는 `Long` 타입. `Brand` 객체 참조가 아님 +- `ProductLike.memberId`, `Order.memberId`도 동일 +- 이렇게 하면 BC 간 직접 의존이 없어, 나중에 서비스 분리 시 변경이 최소화됨 + +**3. OrderLineSnapshot은 Value Object이다** +- 식별자(`id`)가 없다 (DB에서는 기술적으로 PK를 부여하지만, 도메인 관점에서 독립 식별 불필요) +- 한번 생성되면 변경 불가 (불변) +- Order와 동일한 생명주기를 가짐 (Order 없이 존재 불가) + +**4. ProductLike는 독립 엔티티이다** +- 값 객체가 아닌 이유: `memberId + productId`라는 고유 식별이 필요하고, 등록/삭제(토글)라는 상태 변경이 있음 +- 다만 도메인 로직이 거의 없는 단순 엔티티. 이는 좋아요의 본질이 "관계의 기록"이기 때문 + +--- + +## 2. 계층별 패키지 구조 (예상) + +기존 코드베이스 패턴(ExampleV1Controller, ExampleFacade, ExampleService, ExampleRepository)을 따른다. + +``` +com.loopers +├── interfaces/api/ +│ ├── brand/ +│ │ ├── BrandV1ApiSpec.java +│ │ ├── BrandV1Controller.java +│ │ └── BrandV1Dto.java +│ ├── product/ +│ │ ├── ProductV1ApiSpec.java +│ │ ├── ProductV1Controller.java +│ │ └── ProductV1Dto.java +│ ├── like/ +│ │ ├── LikeV1ApiSpec.java +│ │ ├── LikeV1Controller.java +│ │ └── LikeV1Dto.java +│ ├── order/ +│ │ ├── OrderV1ApiSpec.java +│ │ ├── OrderV1Controller.java +│ │ └── OrderV1Dto.java +│ └── admin/ +│ ├── AdminBrandV1Controller.java +│ ├── AdminProductV1Controller.java +│ └── AdminOrderV1Controller.java +│ +├── application/ +│ ├── brand/ +│ │ ├── BrandFacade.java +│ │ └── BrandInfo.java +│ ├── product/ +│ │ ├── ProductFacade.java +│ │ └── ProductInfo.java +│ ├── like/ +│ │ └── LikeFacade.java +│ ├── order/ +│ │ ├── OrderFacade.java +│ │ └── OrderInfo.java +│ └── admin/ +│ ├── AdminBrandFacade.java +│ ├── AdminProductFacade.java +│ └── AdminOrderFacade.java +│ +├── domain/ +│ ├── brand/ +│ │ ├── Brand.java +│ │ ├── BrandService.java +│ │ └── BrandRepository.java +│ ├── product/ +│ │ ├── Product.java +│ │ ├── ProductService.java +│ │ └── ProductRepository.java +│ ├── like/ +│ │ ├── ProductLike.java +│ │ ├── LikeService.java +│ │ └── LikeRepository.java +│ └── order/ +│ ├── Order.java +│ ├── OrderLineSnapshot.java +│ ├── OrderStatus.java +│ ├── OrderService.java +│ └── OrderRepository.java +│ +└── infrastructure/ + ├── brand/ + │ ├── BrandJpaRepository.java + │ └── BrandRepositoryImpl.java + ├── product/ + │ ├── ProductJpaRepository.java + │ └── ProductRepositoryImpl.java + ├── like/ + │ ├── LikeJpaRepository.java + │ └── LikeRepositoryImpl.java + └── order/ + ├── OrderJpaRepository.java + └── OrderRepositoryImpl.java +``` + +--- + +## 3. ERD + +### 왜 필요한가 +영속성 구조, FK 방향, 인덱스 전략, 정규화 수준을 확인한다. +특히 스냅샷의 product_id가 FK가 아닌 이유, 테이블명 예약어 회피 등을 명시한다. + +### 다이어그램 + +```mermaid +erDiagram + MEMBER { + bigint id PK + varchar login_id UK "로그인 ID" + varchar password "암호화된 비밀번호" + varchar name "이름" + date birth_date "생년월일" + varchar email "이메일" + } + + BRAND { + bigint id PK + varchar name "브랜드명" + varchar description "브랜드 설명" + datetime closed_at "NULL=영업중, 값=폐점일시" + datetime created_at "생성일시" + datetime updated_at "수정일시" + } + + PRODUCT { + bigint id PK + bigint brand_id FK "소속 브랜드 (생성 후 불변)" + varchar name "상품명" + varchar description "상품 설명" + int price "가격 (원)" + int stock "재고 수량" + datetime created_at "생성일시" + datetime updated_at "수정일시" + } + + PRODUCT_LIKE { + bigint id PK + bigint member_id FK "회원 ID" + bigint product_id FK "상품 ID" + datetime created_at "좋아요 등록일시" + } + + ORDERS { + bigint id PK + bigint member_id FK "주문한 회원" + varchar status "REQUESTED/ACCEPTED/REJECTED/CANCELLED" + datetime ordered_at "주문일시" + datetime created_at "생성일시" + datetime updated_at "수정일시" + } + + ORDER_LINE_SNAPSHOT { + bigint id PK + bigint order_id FK "소속 주문" + bigint product_id "원본 상품 ID (FK 아님)" + varchar product_name "스냅샷: 상품명" + varchar product_description "스냅샷: 상품 설명" + int price "스냅샷: 주문 당시 가격" + int quantity "주문 수량" + varchar brand_name "스냅샷: 브랜드명" + datetime created_at "생성일시" + } + + BRAND ||--o{ PRODUCT : "1:N 소속" + PRODUCT ||--o{ PRODUCT_LIKE : "1:N 좋아요" + MEMBER ||--o{ PRODUCT_LIKE : "1:N 좋아요" + MEMBER ||--o{ ORDERS : "1:N 주문" + ORDERS ||--|{ ORDER_LINE_SNAPSHOT : "1:N 주문라인" +``` + +### 읽는 포인트 + +**1. ORDER_LINE_SNAPSHOT.product_id는 FK가 아니다** +- 상품이 물리 삭제되어도 스냅샷은 보존되어야 한다 +- FK를 걸면 상품 삭제 시 CASCADE DELETE로 스냅샷이 사라지거나, RESTRICT로 삭제가 막힌다 +- 따라서 비즈니스 참조(조회용)로만 사용하고, DB 레벨 참조 무결성은 적용하지 않는다 + +**2. PRODUCT_LIKE에 복합 유니크 제약이 필요하다** +```sql +UNIQUE INDEX uk_like_member_product (member_id, product_id) +``` +- 한 회원이 같은 상품에 좋아요를 중복 생성하면 안 된다 +- 토글 시 이 제약으로 데이터 정합성을 DB 레벨에서 보장한다 + +**3. ORDERS 테이블명** +- `ORDER`는 SQL 예약어(`ORDER BY`)이므로 `ORDERS`로 명명한다 + +**4. BRAND.closed_at의 이중 역할** +- NULL: 영업 중 +- NOT NULL: 폐점 상태 + 폐점 시각 기록 +- 상품 조회 시 `WHERE brand.closed_at IS NULL`이 핵심 필터 조건 + +**5. BaseTimeEntity로 물리 삭제 도메인을 분리한다** +- 기존 `BaseEntity`(deletedAt 포함)는 소프트 삭제가 필요한 엔티티(Member 등)에서 사용한다 +- 신규 `BaseTimeEntity`(deletedAt 없음)는 물리 삭제 도메인(Brand, Product, ProductLike, Order)에서 사용한다 +- 주문(ORDERS)은 삭제하지 않고 상태(CANCELLED)로 관리한다 +- 이 분리로 도메인의 삭제 정책이 코드에 명확히 드러난다 + +--- + +## 4. 인덱스 전략 + +| 테이블 | 인덱스 | 용도 | +|--------|--------|------| +| `product` | `idx_product_brand_id (brand_id)` | 브랜드별 상품 필터링 | +| `product_like` | `uk_like_member_product (member_id, product_id)` UNIQUE | 중복 좋아요 방지 + 토글 조회 | +| `product_like` | `idx_like_member_id (member_id)` | 내 좋아요 목록 조회 | +| `product_like` | `idx_like_product_id (product_id)` | 상품 삭제 시 연쇄 삭제 | +| `orders` | `idx_orders_member_id (member_id)` | 내 주문 목록 조회 | +| `orders` | `idx_orders_ordered_at (ordered_at)` | 날짜 기간 필터링 | +| `orders` | `idx_orders_member_ordered (member_id, ordered_at)` | 회원별 기간 필터 복합 | +| `order_line_snapshot` | `idx_snapshot_order_id (order_id)` | 주문 상세 조회 시 스냅샷 로딩 | + +--- + +## 5. DDL (예상) + +```sql +CREATE TABLE brand ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + description VARCHAR(500) NOT NULL, + closed_at DATETIME(6) NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL +); + +CREATE TABLE product ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + brand_id BIGINT NOT NULL, + name VARCHAR(200) NOT NULL, + description VARCHAR(1000) NOT NULL, + price INT NOT NULL, + stock INT NOT NULL DEFAULT 0, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + + INDEX idx_product_brand_id (brand_id), + CONSTRAINT fk_product_brand FOREIGN KEY (brand_id) REFERENCES brand(id) +); + +CREATE TABLE product_like ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + member_id BIGINT NOT NULL, + product_id BIGINT NOT NULL, + created_at DATETIME(6) NOT NULL, + + UNIQUE INDEX uk_like_member_product (member_id, product_id), + INDEX idx_like_member_id (member_id), + INDEX idx_like_product_id (product_id), + CONSTRAINT fk_like_member FOREIGN KEY (member_id) REFERENCES member(id), + CONSTRAINT fk_like_product FOREIGN KEY (product_id) REFERENCES product(id) +); + +CREATE TABLE orders ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + member_id BIGINT NOT NULL, + status VARCHAR(20) NOT NULL, + ordered_at DATETIME(6) NOT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + + INDEX idx_orders_member_id (member_id), + INDEX idx_orders_member_ordered (member_id, ordered_at), + CONSTRAINT fk_orders_member FOREIGN KEY (member_id) REFERENCES member(id) +); + +CREATE TABLE order_line_snapshot ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + order_id BIGINT NOT NULL, + product_id BIGINT NOT NULL, + product_name VARCHAR(200) NOT NULL, + product_description VARCHAR(1000) NOT NULL, + price INT NOT NULL, + quantity INT NOT NULL, + brand_name VARCHAR(100) NOT NULL, + created_at DATETIME(6) NOT NULL, + + INDEX idx_snapshot_order_id (order_id), + CONSTRAINT fk_snapshot_order FOREIGN KEY (order_id) REFERENCES orders(id) +); +``` + +--- + +## 6. 설계 결정 기록 + +| 결정 | 이유 | 대안 | +|------|------|------| +| 스냅샷 product_id에 FK 미적용 | 상품 삭제 후에도 스냅샷 보존 필요 | FK + ON DELETE SET NULL (NULL 허용 시) | +| ORDERS 테이블명 | ORDER는 SQL 예약어 | order_info, purchase 등 | +| **BaseTimeEntity 신규 도입** | 물리 삭제 도메인(Brand, Product, ProductLike, Order)은 deletedAt이 불필요. 기존 BaseEntity(deletedAt 포함)는 소프트 삭제 도메인(Member 등)에서 계속 사용 | BaseEntity를 그대로 상속하고 deletedAt 미사용 (불필요한 컬럼 낭비) | +| 비관적 락 (FOR UPDATE) | 재고 정합성 확보. 감성 이커머스 규모에 적합 | 낙관적 락, 원자적 UPDATE | +| 개별 UPDATE 루프 | 실패 상품 식별 가능. 비관적 락과 자연스러운 조합 | 벌크 UPDATE | +| **productId 오름차순 정렬 후 락 획득** | 데드락 방지. 모든 TX가 동일 순서로 락을 잡아 교착 상태 원천 차단 | 정렬 없이 요청 순서대로 (데드락 위험) | +| **폐점 브랜드 검증 규칙 위치: Facade** | 현재 모놀리스에서 Facade가 BC 간 조율자 역할. 상품은 brandId(Long)만 보유하여 브랜드 상태를 스스로 알 수 없음 | 도메인 서비스(`OrderDomainService.validateOrderable(product, brand)`)로 이동 — 도메인 모델링 순수성을 높이지만 복잡도 증가 | +| **주문 취소 시 삭제된 상품의 재고 복원 건너뛰기** | 상품이 물리 삭제되면 복원할 대상이 없음. 주문 상태 변경(CANCELLED)은 정상 수행 | 취소 자체를 거부 (사용자 경험 저하) | +| **모놀리스에서 BC 간 단일 TX** | BC 경계는 논리적 자치권(언어, 책임). TX 경계는 데이터 정합성(물리적). 같은 DB를 쓰는 모놀리스에서 두 BC가 하나의 TX에 참여하는 것은 정합성과 단순성을 동시에 확보 | 도메인 이벤트 + 보상 트랜잭션 (서비스 분리 시 전환) | diff --git a/docs/design/base/domain-definition-v2.md b/docs/design/base/domain-definition-v2.md new file mode 100644 index 000000000..de0ecdaf4 --- /dev/null +++ b/docs/design/base/domain-definition-v2.md @@ -0,0 +1,228 @@ +# 감성 이커머스 도메인 정의서 (v2) + +> 작성일: 2026-02-10 +> 상태: **확정** + +## 1. 프로젝트 개요 + +**감성 이커머스** — 좋아요 누르고, 쿠폰 쓰고, 주문 및 결제하는 커머스 플랫폼. +내가 좋아하는 브랜드의 상품들을 한 번에 담아 주문하고, 유저 행동은 랭킹과 추천으로 연결된다. + +**목표**: 회원이 구매하고 싶은 상품과 그 브랜드를 제시할 수 있어야 한다. + +**전제 조건**: +- 회원은 회원가입한 사용자를 일컫는다. +- 사용자는 회원과 비회원을 포함한 애플리케이션 사용자이다. +- 사용자는 모든 행동의 중심이 되는 인물이다. + +--- + +## 2. 액터 정의 + +| 액터 | 정의 | 인증 방식 | +|------|------|----------| +| **사용자** | 회원과 비회원을 포함한 서비스 이용자. 상품 탐색이 주 행위 | 인증 없음 | +| **회원** | 서비스와 신뢰의 계약을 맺은 사용자. 좋아요·주문 등 편의 기능 사용 가능 | 헤더 기반 인증 (`X-Loopers-LoginId/Pw`) | +| **관리자** | 서비스의 운영 권한을 가진 내부 사용자. 브랜드·상품·주문을 관리 | 헤더 기반 간이 LDAP 인증 | + +--- + +## 3. 도메인 본질 정의 + +### 3.1 회원 (Member) — 구현 완료 +서비스와 사용자 사이 맺은 신뢰의 계약. +회원은 서비스에게 정보를, 서비스는 회원에게 편의 기능(좋아요, 주문)을 제공하며 상호 이익이 되는 관계를 형성한다. + +### 3.2 브랜드 (Brand) +상품을 만들고 신뢰를 부여하는 주체. +사용자에게는 탐색의 분류 기준이 되고, 관리자에게는 입점/폐점/삭제의 관리 대상이 된다. +브랜드는 **입점(활성)**과 **폐점(비활성)**이라는 영업 상태를 가지며, 이는 소속 상품의 노출 여부를 결정한다. + +### 3.3 상품 (Product) +판매를 위해 진열된 재화. +이름·가격·재고라는 고유 속성을 가지며, 재고는 주문 가능 여부를 결정하는 핵심 비즈니스 규칙과 직결된다. +상품은 반드시 하나의 브랜드에 소속되며, 이 소속은 생성 시 확정되어 이후 변경할 수 없다. + +### 3.4 좋아요 (Like) +회원이 특정 상품에 대해 표현한 관심의 기록. +회원과 상품 사이의 관계를 나타내는 연관 엔티티이며, 등록과 취소라는 행위를 가진다. + +### 3.5 주문 (Order) +회원이 상품 구매를 위해 서비스에 제출하는 요청서. +요청 시점의 상품 정보를 스냅샷으로 보존하며, 요청·수락·거절·취소라는 생명주기를 가진다. + +### 3.6 주문 상품 스냅샷 (Order Product Snapshot) +주문 시점에 캡처된 상품의 불변 사본. +상품명, 가격, 브랜드명, 수량 등이 기록되며, 원본 상품이나 브랜드의 변경·삭제와 무관하게 영구 보존된다. + +--- + +## 4. 서브도메인 분류 + +| 서브도메인 | 분류 | 근거 | 우선순위 | +|-----------|------|------|---------| +| **상품** | Core | 탐색과 구매의 핵심 대상, 재고가 주문 가능 여부를 결정 | 1순위 | +| **주문** | Core | 구매 행위 자체, 결제 확장 지점 | 1순위 | +| **브랜드** | Supporting → Core 전환 가능 | 어드민 CRUD + 입점/폐점 생명주기 보유 | 2순위 | +| **좋아요** | Supporting | 회원 편의, 향후 랭킹/추천 연동 가능 | 3순위 | +| **회원** | Generic (구현 완료) | 인증/인가 기반 제공 | - | + +--- + +## 5. 바운디드 컨텍스트 (BC) 구조 + +``` +BC: 브랜드 (Brand Context) +└── 브랜드 (Aggregate Root) — 이름, 설명, closedAt + └── 관리자: CRUD + 폐점/재입점 / 사용자: 조회 전용 + +BC: 상품 (Product Context) +├── 상품 (Aggregate Root) — 이름, 가격, 재고, 브랜드ID(참조) +└── 좋아요 (Entity) — 회원ID, 상품ID, 등록일시 + └── 회원-상품 간 연관 엔티티 + +BC: 주문 (Order Context) +├── 주문 (Aggregate Root) — 회원ID, 상태, 주문일시 +└── 주문 상품 스냅샷 (Value Object) — 상품명, 가격, 브랜드명, 수량 + └── 주문 시점 불변 사본 + +[확장 지점] BC: 결제 (Payment Context) — 미구현 +└── 주문ID 참조, 결제 상태 관리 +``` + +### BC 간 관계 + +``` +[관리자] ──(LDAP 간이 인증)──▶ [브랜드 BC] ──(브랜드ID)──▶ [상품 BC] ◀──(상품ID)── [주문 BC] + (CRUD, 폐점/재입점) (CRUD, 좋아요) (생성, 조회) + │ + 삭제 시 연쇄: 상품 물리삭제 → 좋아요 물리삭제 + 폐점 시: 상품 자동 비노출 (데이터 보존) + │ + [주문 스냅샷은 항상 보존] +``` + +--- + +## 6. 브랜드 생명주기 — 폐점과 삭제 + +### 두 가지 행위의 경계 + +| | 폐점 (Close) | 삭제 (Delete) | +|---|---|---| +| **의도** | 브랜드가 더 이상 판매하지 않는다 | 브랜드를 완전히 제거한다 | +| **비유** | 가게 셔터를 내림 | 등기부에서 말소 | +| **브랜드** | `closedAt` 세팅, 데이터 보존 | 물리 삭제 | +| **상품** | 데이터 보존, 조회에서 자동 제외 | 물리 삭제 (연쇄) | +| **좋아요** | 데이터 보존 | 물리 삭제 (연쇄) | +| **주문 스냅샷** | 영향 없음 | 영향 없음 | +| **복원** | 재입점 가능 (`closedAt = null`) | 불가 | + +### 상품 비노출 규칙 + +폐점된 브랜드의 상품은 별도 플래그 없이 **브랜드의 `closedAt`**으로 판단한다. +- 사용자 상품 목록/상세 → 폐점 브랜드 상품 제외 +- 브랜드별 필터 → 폐점 브랜드 자체가 필터 대상에서 제외 +- 관리자 조회 → 폐점 브랜드·상품 모두 조회 가능 (운영 목적) + +--- + +## 7. 주문 상태 모델 + +``` +REQUESTED ──(재고 확인 성공)──▶ ACCEPTED ──(회원 요청)──▶ CANCELLED + │ (재고 복원) + └──(재고 부족)──▶ REJECTED +``` + +| 상태 | 의미 | 전이 조건 | +|------|------|----------| +| `REQUESTED` | 주문 요청됨 | 회원의 주문 요청 | +| `ACCEPTED` | 주문 수락, 재고 차감 완료 | 재고 >= 주문 수량, 수량만큼 차감 | +| `REJECTED` | 주문 거절 | 재고 부족 | +| `CANCELLED` | 주문 취소, 재고 복원 | 수락 이후 회원이 취소 요청 | + +--- + +## 8. 도메인 불변식 (Invariants) + +### 브랜드 BC +| 규칙 | 설명 | +|------|------| +| 폐점 브랜드 상품 등록 금지 | `closedAt != null`인 브랜드에는 새 상품을 등록할 수 없다 | +| 폐점 브랜드 상품 주문 불가 | 폐점 브랜드의 상품에는 주문을 요청할 수 없다 | +| 삭제 시 연쇄 물리 삭제 | 브랜드 삭제 시 소속 상품 + 해당 좋아요가 물리 삭제된다 | +| 재입점 시 자동 복원 | `closedAt` 해제 시 보존된 상품·좋아요가 자동 복원된다 | + +### 상품 BC +| 규칙 | 설명 | +|------|------| +| 브랜드 불변 | 상품의 브랜드는 생성 시 확정, 이후 변경 불가 | +| 브랜드 존재 검증 | 상품 등록 시 참조하는 브랜드는 반드시 존재해야 한다 | +| 재고 비음수 | 재고는 0 미만이 될 수 없다 | + +### 주문 BC +| 규칙 | 설명 | +|------|------| +| 스냅샷 불변 | 주문 스냅샷은 생성 이후 변경할 수 없다 | +| 스냅샷 영구 보존 | 원본 상품/브랜드의 삭제와 무관하게 보존된다 | +| 취소 시 재고 복원 | ACCEPTED → CANCELLED 전이 시 차감한 수량만큼 재고를 복원한다 | +| 취소 시 삭제된 상품 건너뛰기 | 원본 상품이 이미 물리 삭제된 경우, 해당 상품의 재고 복원은 건너뛰고 주문 상태만 CANCELLED로 변경한다 | + +--- + +## 9. 요구사항-도메인 매핑 + +### 사용자/회원 요구사항 +| # | 요구사항 | 담당 BC | 핵심 개념 | +|---|---------|---------|----------| +| 1 | 상품 목록 조회 | 상품 | Product (브랜드 closedAt 필터) | +| 2 | 상품 상세 조회 | 상품 | Product | +| 3 | 상품의 브랜드 정보 조회 | 상품 + 브랜드 | Product → Brand 참조 | +| 4 | 특정 브랜드 상품만 조회 | 상품 | Brand ID 필터링 | +| 5 | 좋아요 등록/취소 | 상품 | Like (Entity) | +| 6 | 좋아요 상품 목록 조회 | 상품 | Like → Product | +| 7 | 주문 요청 | 주문 + 상품 | Order 생성 + 재고 확인 | +| 8 | 재고 0 → 거절 | 주문 + 상품 | Order(REJECTED) | +| 9 | 재고 > 0 → 수락 + 차감 | 주문 + 상품 | Order(ACCEPTED) + 수량만큼 차감 | +| 10 | 스냅샷 저장 | 주문 | OrderProductSnapshot(VO) | +| 11 | 날짜 필터 주문 목록 조회 | 주문 | Order 날짜 범위 조회 | +| 12 | 주문 상세 조회 | 주문 | Order + Snapshot | +| 13 | 결제 확장성 | 주문 상태 | 상태 열거형 확장 + Payment BC 예약 | + +### 관리자 요구사항 +| # | 요구사항 | 담당 BC | 핵심 개념 | +|---|---------|---------|----------| +| A1 | 브랜드 목록 조회 (페이징) | 브랜드 | Brand | +| A2 | 브랜드 상세 조회 | 브랜드 | Brand | +| A3 | 브랜드 등록 | 브랜드 | Brand 생성 | +| A4 | 브랜드 수정 | 브랜드 | Brand 업데이트 | +| A5 | 브랜드 삭제 (상품 연쇄 삭제) | 브랜드 + 상품 | 물리 삭제 + 연쇄 | +| A6 | 상품 목록 조회 (페이징, 브랜드 필터) | 상품 | Product | +| A7 | 상품 상세 조회 | 상품 | Product | +| A8 | 상품 등록 (브랜드 존재 검증) | 상품 + 브랜드 | Product 생성 | +| A9 | 상품 수정 (브랜드 변경 불가) | 상품 | Product 업데이트 | +| A10 | 상품 삭제 | 상품 | Product 물리 삭제 | +| A11 | 주문 목록 조회 (페이징) | 주문 | Order (읽기 전용) | +| A12 | 주문 상세 조회 | 주문 | Order + Snapshot (읽기 전용) | +| Auth | 모든 관리자 API LDAP 인증 | 공통 | 헤더 기반 간이 인증 | + +--- + +## 10. 리스크 및 열린 결정사항 + +### 리스크 — 확정 +| 리스크 | 결정 | 상태 | +|--------|------|------| +| 주문 시 재고 차감 동시성 | 비관적 락(SELECT FOR UPDATE) + productId 오름차순 정렬로 데드락 방지 | ✅ 확정 | +| 재고 차감 방식 | 개별 UPDATE 루프. 실패 상품을 구체적으로 식별하여 에러 응답에 포함 | ✅ 확정 | +| 대량 연쇄 삭제 시 트랜잭션 부하 | 단일 TX 유지. 브랜드 삭제는 빈번하지 않은 관리자 작업 | ✅ 확정 | +| BC 간 트랜잭션 경계 | 모놀리스에서 상품 BC + 주문 BC 단일 TX 참여. 서비스 분리 시 이벤트 기반으로 전환 | ✅ 확정 | +| 주문 취소 시 상품 삭제됨 | 재고 복원 건너뛰기. 주문 상태만 CANCELLED로 변경 | ✅ 확정 | +| 폐점 브랜드 검증 규칙 위치 | 현재 Facade에서 검증. 향후 도메인 서비스로 이동 고려 | ✅ 확정 (현재) | +| 좋아요 데이터 증가 | 인덱스 전략 수립 (복합 유니크, member_id, product_id 인덱스) | ✅ 확정 | +| 브랜드 삭제 후 동일 이름 재등록 | Hard Delete이므로 UNIQUE 충돌 없음 | ✅ 해결됨 | + +### 열린 결정사항 +- [ ] 폐점 브랜드의 좋아요 목록 노출 정책 (숨김 vs "폐점된 상품" 표시) +- [ ] REJECTED 주문에 스냅샷을 저장할 것인지 여부 (저장: 거절 이력 확인 가능 / 미저장: 스냅샷 = 계약 증거라는 본질 유지) diff --git a/docs/design/base/member-class-diagram.md b/docs/design/base/member-class-diagram.md new file mode 100644 index 000000000..a2696cf76 --- /dev/null +++ b/docs/design/base/member-class-diagram.md @@ -0,0 +1,238 @@ +# 회원(Member) 도메인 클래스 다이어그램 + +> 작성일: 2026-02-01 +> 상태: **Planner Mode - 승인 대기** + +## 1. 요구사항 정리 + +### 1.1 회원가입 요구사항 +| 항목 | 설명 | 검증 규칙 | +|------|------|-----------| +| loginId | 로그인 ID | 중복 불가, 포맷 검증 (추후 정의) | +| password | 비밀번호 | 암호화 저장, 아래 규칙 적용 | +| name | 이름 | 포맷 검증 (추후 정의) | +| email | 이메일 | 포맷 검증 (추후 정의) | +| birthDate | 생년월일 | 비밀번호 검증에 사용 | + +### 1.2 비밀번호 규칙 +1. **길이**: 8~16자 +2. **허용 문자**: 영문 대소문자, 숫자, 특수문자만 가능 +3. **금지 조건**: 생년월일(YYYYMMDD, YYMMDD, MMDD 등)이 비밀번호에 포함될 수 없음 + +--- + +## 2. 클래스 다이어그램 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ <> │ +│ BaseEntity │ +├─────────────────────────────────────────────────────────────┤ +│ - id: Long │ +│ - createdAt: ZonedDateTime │ +│ - updatedAt: ZonedDateTime │ +│ - deletedAt: ZonedDateTime │ +├─────────────────────────────────────────────────────────────┤ +│ + delete(): void │ +│ + restore(): void │ +│ # guard(): void │ +└─────────────────────────────────────────────────────────────┘ + △ + │ extends + │ +┌─────────────────────────────────────────────────────────────┐ +│ <> │ +│ Member │ +├─────────────────────────────────────────────────────────────┤ +│ - loginId: String // 로그인 ID (Unique) │ +│ - password: String // 암호화된 비밀번호 │ +│ - name: String // 이름 │ +│ - email: String // 이메일 │ +│ - birthDate: LocalDate // 생년월일 │ +├─────────────────────────────────────────────────────────────┤ +│ + Member(loginId, rawPassword, name, email, birthDate, │ +│ passwordEncoder): Member │ +│ + updatePassword(rawPassword, passwordEncoder): void │ +│ + matchesPassword(rawPassword, passwordEncoder): boolean │ +│ # guard(): void │ +└─────────────────────────────────────────────────────────────┘ + │ + │ uses + ▽ +┌─────────────────────────────────────────────────────────────┐ +│ <> │ +│ PasswordValidator │ +├─────────────────────────────────────────────────────────────┤ +│ + validate(rawPassword, birthDate): void │ +│ - validateLength(password): void │ +│ - validateCharacters(password): void │ +│ - validateNotContainsBirthDate(password, birthDate): void │ +└─────────────────────────────────────────────────────────────┘ + + +┌─────────────────────────────────────────────────────────────┐ +│ <> │ +│ PasswordEncoder │ +├─────────────────────────────────────────────────────────────┤ +│ + encode(rawPassword): String │ +│ + matches(rawPassword, encodedPassword): boolean │ +└─────────────────────────────────────────────────────────────┘ + △ + │ implements + │ +┌─────────────────────────────────────────────────────────────┐ +│ <> │ +│ BCryptPasswordEncoder (Spring) │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## 3. 계층별 클래스 구조 + +``` +com.loopers +├── interfaces/ +│ └── api/ +│ └── member/ +│ ├── MemberV1Controller.java // REST API +│ ├── MemberV1ApiSpec.java // OpenAPI 스펙 +│ └── MemberV1Dto.java // 요청/응답 DTO +│ +├── application/ +│ └── member/ +│ ├── MemberFacade.java // 비즈니스 조율 +│ └── MemberInfo.java // 응답 정보 (Record) +│ +├── domain/ +│ └── member/ +│ ├── Member.java // 엔티티 +│ ├── MemberService.java // 도메인 서비스 +│ ├── MemberRepository.java // 도메인 인터페이스 +│ └── PasswordValidator.java // 비밀번호 검증 +│ +└── infrastructure/ + └── member/ + ├── MemberJpaRepository.java // Spring Data JPA + └── MemberRepositoryImpl.java // 도메인 구현체 +``` + +--- + +## 4. 주요 클래스 상세 + +### 4.1 Member (엔티티) + +```java +@Entity +@Table(name = "member") +public class Member extends BaseEntity { + + @Column(name = "login_id", nullable = false, unique = true) + private String loginId; + + @Column(name = "password", nullable = false) + private String password; // 암호화된 값 + + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "email", nullable = false) + private String email; + + @Column(name = "birth_date", nullable = false) + private LocalDate birthDate; + + // JPA용 기본 생성자 + protected Member() {} + + // 생성자에서 비밀번호 검증 + 암호화 + public Member(String loginId, String rawPassword, String name, + String email, LocalDate birthDate, + PasswordEncoder passwordEncoder) { + PasswordValidator.validate(rawPassword, birthDate); + this.loginId = loginId; + this.password = passwordEncoder.encode(rawPassword); + this.name = name; + this.email = email; + this.birthDate = birthDate; + guard(); + } +} +``` + +### 4.2 PasswordValidator (검증기) + +```java +public final class PasswordValidator { + + private static final int MIN_LENGTH = 8; + private static final int MAX_LENGTH = 16; + private static final Pattern VALID_PATTERN = + Pattern.compile("^[a-zA-Z0-9!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?]+$"); + + public static void validate(String rawPassword, LocalDate birthDate) { + validateLength(rawPassword); + validateCharacters(rawPassword); + validateNotContainsBirthDate(rawPassword, birthDate); + } + + // 8~16자 검증 + private static void validateLength(String password) { ... } + + // 영문 대소문자, 숫자, 특수문자만 허용 + private static void validateCharacters(String password) { ... } + + // 생년월일 포함 여부 검증 (YYYYMMDD, YYMMDD, MMDD 등) + private static void validateNotContainsBirthDate(String password, LocalDate birthDate) { ... } +} +``` + +--- + +## 5. 데이터베이스 스키마 + +```sql +CREATE TABLE member ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + login_id VARCHAR(50) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL, -- BCrypt 해시 + name VARCHAR(100) NOT NULL, + email VARCHAR(255) NOT NULL, + birth_date DATE NOT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + deleted_at DATETIME(6) NULL, + + INDEX idx_member_login_id (login_id), + INDEX idx_member_email (email) +); +``` + +--- + +## 6. 검토 필요 사항 + +### 확인 요청 +1. **loginId 포맷**: 어떤 형식을 허용할지 (영문+숫자, 길이 제한 등) +2. **email 포맷**: 표준 이메일 검증만 할지, 특정 도메인 제한이 있는지 +3. **name 포맷**: 한글/영문 허용 범위, 길이 제한 +4. **생년월일 검증 범위**: `YYYYMMDD`, `YYMMDD`, `MMDD` 외 추가 패턴이 있는지 + +### 추후 확장 고려 +- [ ] 로그인 기능 (JWT/Session) +- [ ] 비밀번호 변경 +- [ ] 이메일 인증 +- [ ] 소셜 로그인 연동 + +--- + +## 7. 승인 요청 + +위 설계에 대해 검토 부탁드립니다. + +- [ ] 클래스 구조 승인 +- [ ] DB 스키마 승인 +- [ ] 검증 규칙 추가 정보 제공 + +**승인 후 TDD Red Phase로 진입합니다.** diff --git a/docs/design/base/requirements-input.md b/docs/design/base/requirements-input.md new file mode 100644 index 000000000..656931567 --- /dev/null +++ b/docs/design/base/requirements-input.md @@ -0,0 +1,88 @@ +# 요구사항 분석 입력 문서 + +> 도메인 정의서(v2) 기반 — `/requirements-analysis` 스킬 입력용 + +## 프로젝트 컨텍스트 + +- 감성 이커머스 플랫폼 +- 기존 구현: 회원 도메인 (회원가입, 인증, 비밀번호 변경) +- 이번 범위: 브랜드, 상품, 좋아요, 주문 + +## 액터 + +| 액터 | 인증 | 역할 | +|------|------|------| +| 사용자 | 없음 | 상품/브랜드 조회 | +| 회원 | 헤더 인증 (`X-Loopers-LoginId/Pw`) | 좋아요, 주문 | +| 관리자 | 헤더 기반 간이 LDAP | 브랜드·상품 CRUD, 주문 조회 | + +## 확정된 도메인 설계 + +### BC 구조 +- **브랜드 BC**: 브랜드(Aggregate Root) — 이름, 설명, closedAt +- **상품 BC**: 상품(Aggregate Root) — 이름, 가격, 재고, 브랜드ID / 좋아요(Entity) — 회원ID, 상품ID +- **주문 BC**: 주문(Aggregate Root) — 회원ID, 상태 / 스냅샷(Value Object) — 상품명, 가격, 브랜드명, 수량 + +### 브랜드 생명주기 +- **폐점(Close)**: closedAt 세팅 → 상품 자동 비노출 (데이터 보존) → 재입점 가능 +- **삭제(Delete)**: 물리 삭제 → 상품·좋아요 연쇄 물리 삭제 → 복원 불가 + +### 주문 상태: REQUESTED → ACCEPTED / REJECTED / CANCELLED +- 주문 수량 N개 가능 +- 취소 시 재고 복원 + +### 핵심 불변식 +- 상품의 브랜드는 생성 후 변경 불가 +- 상품 등록 시 브랜드 존재 필수 +- 폐점 브랜드에 상품 등록/주문 불가 +- 주문 스냅샷은 원본 삭제와 무관하게 보존 + +## 사용자/회원 요구사항 + +1. 사용자는 상품 목록을 조회할 수 있어야 한다. +2. 사용자는 상품의 상세 정보를 조회할 수 있어야 한다. +3. 사용자는 상품의 브랜드 정보를 조회할 수 있어야 한다. +4. 사용자는 특정 브랜드의 상품 정보만 조회할 수 있어야 한다. +5. 회원은 상품에 좋아요를 등록하고, 취소할 수 있다. +6. 회원은 개인이 좋아요를 누른 상품 목록만을 따로 조회할 수 있다. +7. 회원은 상품에 대하여 주문을 요청할 수 있다. (수량 N개) +8. 주문 요청 시 재고가 부족하면 주문이 거절된다 (REJECTED). +9. 주문 요청 시 재고가 충분하면 주문이 수락되며 수량만큼 재고가 차감된다 (ACCEPTED). +10. 주문 시 당시 상품 정보가 스냅샷으로 저장된다. +11. 회원은 날짜 기간을 필터링하여 자신의 주문 목록을 조회할 수 있다. +12. 회원은 주문한 내용의 상세 정보를 확인할 수 있다. +13. 수락된 주문은 회원이 취소할 수 있으며, 취소 시 재고가 복원된다 (CANCELLED). + +## 관리자 요구사항 + +### 브랜드 관리 +A1. 관리자는 등록된 브랜드 목록을 조회할 수 있어야 한다. (페이징) +A2. 관리자는 특정 브랜드의 상세 정보를 조회할 수 있어야 한다. +A3. 관리자는 새로운 브랜드를 등록할 수 있어야 한다. +A4. 관리자는 기존 브랜드의 정보를 수정할 수 있어야 한다. +A5. 관리자는 브랜드를 삭제할 수 있어야 한다. (상품·좋아요 연쇄 물리 삭제) +A6. 관리자는 브랜드를 폐점할 수 있어야 한다. (closedAt 세팅, 재입점 가능) + +### 상품 관리 +A7. 관리자는 등록된 상품 목록을 조회할 수 있어야 한다. (페이징, 브랜드 필터) +A8. 관리자는 특정 상품의 상세 정보를 조회할 수 있어야 한다. +A9. 관리자는 새로운 상품을 등록할 수 있어야 한다. (브랜드 존재 검증 필수) +A10. 관리자는 상품 정보를 수정할 수 있어야 한다. (브랜드 변경 불가) +A11. 관리자는 등록된 상품을 삭제할 수 있어야 한다. (물리 삭제) + +### 주문 관리 +A12. 관리자는 전체 주문 목록을 조회할 수 있어야 한다. (페이징) +A13. 관리자는 특정 주문의 상세 내역을 조회할 수 있어야 한다. + +### 인증 +Auth. 모든 관리자 API는 헤더 기반 간이 LDAP 인증을 거쳐야 한다. + +## 결제 확장 지점 (미구현) + +- 결제는 별도 BC(Payment Context)로 분리 예정 +- 주문 상태 모델에 결제 관련 상태 추가 가능 (PAYMENT_PENDING, PAID 등) +- 현재는 주문 상태를 REQUESTED/ACCEPTED/REJECTED/CANCELLED로만 운영 + +## 열린 결정사항 + +- 폐점 브랜드의 좋아요 목록 노출 정책 (숨김 vs "폐점된 상품" 표시) diff --git a/docs/design/base/sequence-diagrams.md b/docs/design/base/sequence-diagrams.md new file mode 100644 index 000000000..16a33f5c8 --- /dev/null +++ b/docs/design/base/sequence-diagrams.md @@ -0,0 +1,270 @@ +# 시퀀스 다이어그램 + +> 작성일: 2026-02-10 +> 도메인 정의서(v2) + 요구사항 분석 기반 +> 각 다이어그램은 "왜 필요한가 → 다이어그램 → 읽는 포인트" 순서로 기술한다. + +--- + +## 1. 주문 생성 흐름 + +### 왜 필요한가 +주문은 상품 BC(재고 확인/차감)와 주문 BC(주문 생성/스냅샷)가 만나는 지점이다. +트랜잭션 경계, 비관적 락의 적용 지점, 실패 시 롤백 범위를 검증한다. + +### 다이어그램 + +```mermaid +sequenceDiagram + actor M as 회원 + participant C as OrderController + participant F as OrderFacade + participant PS as ProductService + participant OS as OrderService + participant DB as Database + + M->>C: POST /api/v1/orders
[{productId, quantity}, ...] + C->>F: createOrder(memberId, orderItems) + + F->>F: @Transactional 시작 + F->>F: 주문 상품을 productId 오름차순 정렬 (데드락 방지) + + loop 정렬된 각 주문 상품에 대해 + F->>PS: getProductForOrder(productId) + PS->>DB: SELECT * FROM product WHERE id = ? FOR UPDATE + DB-->>PS: Product (행 락 획득) + PS-->>F: Product + Brand 정보 + + alt 브랜드 폐점 (brand.closedAt != null) + F-->>C: 400 Bad Request (폐점 브랜드) + Note over F: 트랜잭션 롤백 + end + + F->>PS: decreaseStock(product, quantity) + + alt 재고 부족 (stock < quantity) + PS-->>F: IllegalArgumentException + Note over F: 트랜잭션 롤백 + F-->>C: 400 Bad Request (REJECTED) + end + + PS->>DB: UPDATE product SET stock = stock - ? WHERE id = ? + PS-->>F: 차감 완료 + end + + F->>OS: createOrder(memberId, snapshots, ACCEPTED) + OS->>DB: INSERT INTO orders (ACCEPTED) + OS->>DB: INSERT INTO order_line_snapshot (N건) + OS-->>F: Order + + F->>F: 트랜잭션 커밋 + F-->>C: Order 정보 + C-->>M: 201 Created +``` + +### 읽는 포인트 +- **Facade가 트랜잭션을 소유한다.** ProductService와 OrderService는 각자의 책임만 수행하고, 조율은 Facade에서 한다. 모놀리스에서 두 BC(상품, 주문)가 하나의 트랜잭션에 참여하는 것은 정합성과 단순성을 동시에 확보하는 올바른 선택이다. BC 경계는 논리적 자치권이며, TX 경계와 반드시 일치할 필요는 없다. +- **비관적 락(FOR UPDATE)**은 ProductService가 DB에서 상품을 조회하는 시점에 획득된다. 다른 트랜잭션은 이 행이 해제될 때까지 대기한다. +- **productId 오름차순 정렬 후 락 획득**: 데드락을 방지한다. TX1이 A→B, TX2가 B→A 순서로 락을 잡으면 교착 상태가 발생하므로, 모든 트랜잭션이 동일한 순서(ID 오름차순)로 락을 획득해야 한다. +- **실패 시 전체 롤백**: 3개 상품 중 3번째에서 재고 부족이면, 1~2번째의 재고 차감도 롤백된다. 에러 응답에는 **어떤 상품이 부족한지** 구체적 정보(productId, productName, requestedQuantity, availableStock)를 포함한다. +- **REQUESTED 상태는 DB에 저장되지 않는다.** 요청 수신~재고 확인 사이의 논리적 상태이며, 저장 시점에는 ACCEPTED 또는 REJECTED가 확정된다. + +--- + +## 2. 주문 취소 흐름 + +### 왜 필요한가 +취소 시 재고 복원이 수반된다. 주문 BC에서 상품 BC로의 역방향 호출이 발생하며, +상태 전이의 유효성 검증과 재고 복원의 정확성을 확인한다. + +### 다이어그램 + +```mermaid +sequenceDiagram + actor M as 회원 + participant C as OrderController + participant F as OrderFacade + participant OS as OrderService + participant PS as ProductService + participant DB as Database + + M->>C: PATCH /api/v1/orders/{orderId}/cancel + C->>F: cancelOrder(memberId, orderId) + + F->>F: @Transactional 시작 + + F->>OS: getOrder(orderId) + OS->>DB: SELECT * FROM orders WHERE id = ? + OS-->>F: Order (with snapshots) + + alt 본인 주문이 아님 + F-->>C: 403 Forbidden + end + + alt 상태가 ACCEPTED가 아님 + F-->>C: 400 Bad Request (취소 불가 상태) + end + + F->>OS: cancel(order) + OS->>DB: UPDATE orders SET status = 'CANCELLED' WHERE id = ? + + loop 각 스냅샷 라인에 대해 (productId 오름차순) + F->>PS: restoreStock(productId, quantity) + PS->>DB: SELECT * FROM product WHERE id = ? FOR UPDATE + + alt 상품이 이미 삭제됨 (조회 결과 없음) + Note over PS: 재고 복원 건너뛰기 (skip) + else 상품 존재 + PS->>DB: UPDATE product SET stock = stock + ? WHERE id = ? + end + end + + F->>F: 트랜잭션 커밋 + F-->>C: 취소 완료 + C-->>M: 200 OK +``` + +### 읽는 포인트 +- **상태 검증이 먼저, 재고 복원이 나중이다.** 상태가 유효하지 않으면 DB 수정 없이 즉시 반환한다. +- **스냅샷에 기록된 수량으로 복원한다.** 원본 상품의 현재 상태가 아닌, 주문 당시 차감한 수량을 기준으로 한다. +- **상품이 이미 삭제된 경우 재고 복원을 건너뛴다.** 상품이 물리 삭제되었다면 복원할 대상이 없으므로, 해당 라인은 skip하고 주문 상태만 CANCELLED로 변경한다. +- **재고 복원 시에도 productId 오름차순 정렬**을 적용하여 데드락을 방지한다. + +--- + +## 3. 브랜드 삭제 흐름 (연쇄) + +### 왜 필요한가 +3개 테이블에 걸친 연쇄 물리 삭제의 순서와 트랜잭션 범위를 명확히 한다. +FK 제약 위반 없이 삭제하려면 의존 역순으로 처리해야 한다. + +### 다이어그램 + +```mermaid +sequenceDiagram + actor A as 관리자 + participant C as AdminBrandController + participant F as AdminBrandFacade + participant BS as BrandService + participant PS as ProductService + participant LS as LikeService + participant DB as Database + + A->>C: DELETE /api/v1/admin/brands/{id}
[X-Loopers-Ldap] + C->>F: deleteBrand(brandId) + + F->>F: @Transactional 시작 + + F->>BS: getBrand(brandId) + BS->>DB: SELECT * FROM brand WHERE id = ? + BS-->>F: Brand + + alt 브랜드 없음 + F-->>C: 404 Not Found + end + + F->>PS: findProductIdsByBrandId(brandId) + PS->>DB: SELECT id FROM product WHERE brand_id = ? + PS-->>F: List productIds + + F->>LS: deleteLikesByProductIds(productIds) + LS->>DB: DELETE FROM product_like WHERE product_id IN (...) + + F->>PS: deleteProductsByBrandId(brandId) + PS->>DB: DELETE FROM product WHERE brand_id = ? + + F->>BS: deleteBrand(brandId) + BS->>DB: DELETE FROM brand WHERE id = ? + + F->>F: 트랜잭션 커밋 + F-->>C: 삭제 완료 + C-->>A: 204 No Content +``` + +### 읽는 포인트 +- **삭제 순서: 좋아요 → 상품 → 브랜드.** FK 의존의 역순이다. 순서를 바꾸면 참조 무결성 위반이 발생한다. +- **productIds를 먼저 조회한 후** 좋아요 삭제에 사용한다. 상품 삭제 이후에는 brand_id로 상품을 찾을 수 없다. +- **단일 트랜잭션**: 중간에 실패하면 전체가 롤백되어 부분 삭제 상태가 발생하지 않는다. + +--- + +## 4. 좋아요 토글 흐름 + +### 왜 필요한가 +토글 방식의 내부 분기 로직과 응답 구조를 확인한다. +폐점 브랜드 상품에 대한 좋아요 차단 조건도 포함된다. + +### 다이어그램 + +```mermaid +sequenceDiagram + actor M as 회원 + participant C as LikeController + participant LS as LikeService + participant PS as ProductService + participant DB as Database + + M->>C: POST /api/v1/products/{productId}/likes
[X-Loopers-LoginId, X-Loopers-LoginPw] + C->>LS: toggleLike(memberId, productId) + + LS->>PS: getProduct(productId) + PS->>DB: SELECT p.*, b.closed_at FROM product p JOIN brand b ... + PS-->>LS: Product + Brand + + alt 상품 없음 + LS-->>C: 404 Not Found + end + + alt 브랜드 폐점 + LS-->>C: 400 Bad Request + end + + LS->>DB: SELECT * FROM product_like
WHERE member_id = ? AND product_id = ? + + alt 좋아요 존재 + LS->>DB: DELETE FROM product_like WHERE id = ? + LS-->>C: { liked: false } + else 좋아요 미존재 + LS->>DB: INSERT INTO product_like (member_id, product_id, created_at) + LS-->>C: { liked: true } + end + + C-->>M: 200 OK { liked: true/false } +``` + +### 읽는 포인트 +- **상품 존재 + 브랜드 폐점 여부를 먼저 확인한다.** 좋아요 처리 전에 비즈니스 전제 조건을 검증한다. +- **토글 로직**: 존재하면 삭제(물리), 없으면 생성. 별도의 등록/취소 API가 없다. +- **응답에 현재 상태를 포함**하여 클라이언트가 UI를 동기화할 수 있다. + +--- + +## 5. 상품 조회 흐름 (사용자) + +### 왜 필요한가 +사용자 상품 조회에서 폐점 브랜드 필터링이 어떻게 적용되는지 확인한다. +두 BC(상품 + 브랜드)의 데이터를 조합하는 조회 전략을 검증한다. + +### 다이어그램 + +```mermaid +sequenceDiagram + actor U as 사용자 + participant C as ProductController + participant PS as ProductService + participant DB as Database + + U->>C: GET /api/v1/products?page=0&size=20&brandId=1 + C->>PS: getProducts(page, size, brandId) + + PS->>DB: SELECT p.* FROM product p
JOIN brand b ON p.brand_id = b.id
WHERE b.closed_at IS NULL
[AND p.brand_id = :brandId]
LIMIT :size OFFSET :page*size + DB-->>PS: Page + + PS-->>C: Page + C-->>U: 200 OK { data: [...], meta: { page, size, totalElements } } +``` + +### 읽는 포인트 +- **JOIN을 통해 브랜드 폐점 여부를 필터링한다.** 상품 테이블에 별도 플래그가 없으므로, 반드시 brand 테이블과 조인해야 한다. +- **brandId 파라미터는 선택적이다.** 없으면 전체 상품(폐점 제외), 있으면 해당 브랜드 상품만 반환한다. +- **관리자 조회와의 차이**: 관리자는 `WHERE b.closed_at IS NULL` 조건 없이 전체를 조회한다. diff --git a/docs/design/base/ubiquitous-language.md b/docs/design/base/ubiquitous-language.md new file mode 100644 index 000000000..833db3689 --- /dev/null +++ b/docs/design/base/ubiquitous-language.md @@ -0,0 +1,112 @@ +# 유비쿼터스 언어 사전 (Ubiquitous Language Dictionary) + +> 작성일: 2026-02-10 +> 이 문서는 기획, 설계, 코드에서 동일한 용어를 사용하기 위한 약속입니다. +> 코드의 클래스명, 변수명, enum 값은 이 사전의 영문 표현을 따릅니다. + +--- + +## 1. 액터 + +| 한글 | 영문 (코드) | 정의 | 비고 | +|------|------------|------|------| +| 사용자 | User | 회원과 비회원을 포함한 서비스 이용자 | 인증 불필요한 행위의 주체 | +| 회원 | Member | 회원가입을 완료한 사용자 | `member` 테이블, `Member` 엔티티 | +| 관리자 | Admin | 서비스 운영 권한을 가진 내부 사용자 | LDAP 간이 인증 | + +--- + +## 2. 브랜드 컨텍스트 + +| 한글 | 영문 (코드) | 정의 | DB 컬럼/테이블 | +|------|------------|------|---------------| +| 브랜드 | Brand | 상품을 만들고 신뢰를 부여하는 주체 | `brand` 테이블 | +| 브랜드명 | name | 브랜드의 고유 이름 | `brand.name` | +| 브랜드 설명 | description | 브랜드에 대한 소개 | `brand.description` | +| 폐점 | close | 브랜드가 더 이상 상품을 판매하지 않는 상태로 전환 | `brand.closed_at` 세팅 | +| 재입점 | reopen | 폐점된 브랜드가 다시 영업을 시작하는 상태로 전환 | `brand.closed_at` = NULL | +| 폐점일시 | closedAt | 브랜드가 폐점된 시각. NULL이면 영업 중 | `brand.closed_at` | +| 브랜드 삭제 | delete (Brand) | 브랜드를 완전히 제거 (물리 삭제). 상품·좋아요 연쇄 삭제 | `DELETE FROM brand` | + +--- + +## 3. 상품 컨텍스트 + +| 한글 | 영문 (코드) | 정의 | DB 컬럼/테이블 | +|------|------------|------|---------------| +| 상품 | Product | 판매를 위해 진열된 재화 | `product` 테이블 | +| 상품명 | name | 상품의 이름 | `product.name` | +| 상품 설명 | description | 상품에 대한 소개 | `product.description` | +| 가격 | price | 상품의 판매 가격 (정수, 원 단위) | `product.price` | +| 재고 | stock | 현재 판매 가능한 수량 | `product.stock` | +| 재고 차감 | decreaseStock | 주문 수락 시 수량만큼 재고를 줄이는 행위 | `Product.decreaseStock(quantity)` | +| 재고 복원 | restoreStock | 주문 취소 시 수량만큼 재고를 되돌리는 행위 | `Product.restoreStock(quantity)` | +| 브랜드 소속 | brandId | 상품이 소속된 브랜드의 식별자. 생성 후 변경 불가 | `product.brand_id` FK | +| 상품 삭제 | delete (Product) | 상품을 완전히 제거 (물리 삭제). 좋아요 연쇄 삭제 | `DELETE FROM product` | + +--- + +## 4. 좋아요 컨텍스트 + +| 한글 | 영문 (코드) | 정의 | DB 컬럼/테이블 | +|------|------------|------|---------------| +| 좋아요 | ProductLike | 회원이 특정 상품에 표현한 관심의 기록 | `product_like` 테이블 | +| 좋아요 토글 | toggleLike | 좋아요가 없으면 등록, 있으면 취소하는 행위 | `POST /api/v1/products/{id}/likes` | +| 좋아요 상태 | liked | 현재 좋아요 여부 (true/false) | API 응답 필드 | + +**주의**: "좋아요"는 기획서에서는 한글, 코드에서는 `ProductLike`로 통일. +`Like`는 SQL 예약어이므로 단독 사용을 피한다. + +--- + +## 5. 주문 컨텍스트 + +| 한글 | 영문 (코드) | 정의 | DB 컬럼/테이블 | +|------|------------|------|---------------| +| 주문 | Order | 회원이 상품 구매를 위해 서비스에 제출하는 요청서 | `orders` 테이블 | +| 주문 상태 | OrderStatus | 주문의 현재 생명주기 단계 | `orders.status` | +| 주문 요청 | REQUESTED | 주문이 접수되어 재고 확인 중인 상태 | enum 값 | +| 주문 수락 | ACCEPTED | 재고 확인 완료, 재고 차감됨 | enum 값 | +| 주문 거절 | REJECTED | 재고 부족으로 주문이 거절됨 | enum 값 | +| 주문 취소 | CANCELLED | 회원이 수락된 주문을 취소함, 재고 복원됨 | enum 값 | +| 주문일시 | orderedAt | 주문이 생성된 시각 | `orders.ordered_at` | +| 주문 라인 스냅샷 | OrderLineSnapshot | 주문 시점에 캡처된 상품의 불변 사본 | `order_line_snapshot` 테이블 | +| 주문 수량 | quantity | 특정 상품을 몇 개 주문했는지 | `order_line_snapshot.quantity` | + +**주의**: 테이블명은 `orders` (ORDER는 SQL 예약어). + +--- + +## 6. 주문 상태 전이 규칙 + +``` +상태 전이가 허용되는 경로만 아래에 기재한다. +이 외의 전이는 모두 비즈니스 규칙 위반이다. + +REQUESTED → ACCEPTED (재고 충분) +REQUESTED → REJECTED (재고 부족) +ACCEPTED → CANCELLED (회원 취소 요청) +``` + +--- + +## 7. 인증 헤더 + +| 용도 | 헤더명 | 값 | 사용 주체 | +|------|--------|---|----------| +| 회원 인증 (ID) | `X-Loopers-LoginId` | 회원 로그인 ID | 회원 | +| 회원 인증 (PW) | `X-Loopers-LoginPw` | 회원 비밀번호 | 회원 | +| 관리자 인증 | `X-Loopers-Ldap` | 관리자 식별 값 | 관리자 | + +--- + +## 8. 용어 혼동 방지 + +| 혼동하기 쉬운 표현 | 올바른 용어 | 이유 | +|-------------------|-----------|------| +| 상품 비활성화 | 브랜드 폐점 | 상품 자체에 비활성 플래그가 없음. 브랜드의 closedAt으로 판단 | +| 소프트 삭제 | 해당 없음 | 이 프로젝트에서 브랜드/상품/좋아요는 물리 삭제만 사용 | +| 장바구니 | 해당 없음 | 현재 범위에 장바구니 없음. 주문 시 직접 상품 목록 전달 | +| 결제 | 해당 없음 (미구현) | Payment BC로 확장 예정. 현재 주문 상태에 결제 관련 값 없음 | +| 삭제 (브랜드) | 물리 삭제 + 연쇄 | "삭제"는 영구 제거를 의미. 복원 불가. 폐점과 구분 필수 | +| 삭제 (상품) | 물리 삭제 + 좋아요 연쇄 | 상품 단독 삭제 시에도 좋아요 함께 제거 | diff --git a/docs/design/base/user-story.md b/docs/design/base/user-story.md new file mode 100644 index 000000000..7e98c772b --- /dev/null +++ b/docs/design/base/user-story.md @@ -0,0 +1,400 @@ +# 유저 스토리 & 유스케이스 + +> 작성일: 2026-02-10 +> 도메인 정의서(v2) 기반 + +--- + +## 1. 액터 정의 + +| 액터 | 정의 | 인증 방식 | 주요 행위 | +|------|------|----------|----------| +| **사용자 (User)** | 회원/비회원 포함 서비스 이용자 | 없음 | 상품 탐색, 브랜드 조회 | +| **회원 (Member)** | 회원가입을 완료한 사용자 | `X-Loopers-LoginId` + `X-Loopers-LoginPw` | 좋아요, 주문, 주문 취소 | +| **관리자 (Admin)** | 서비스 운영 권한을 가진 내부 사용자 | `X-Loopers-Ldap` | 브랜드/상품 CRUD, 주문 모니터링 | + +--- + +## 2. 유저 스토리 + +### 2.1 상품 탐색 (사용자) + +#### US-01: 상품 목록 조회 +- **As a** 사용자 +- **I want to** 등록된 상품 목록을 페이지 단위로 조회한다 +- **So that** 구매하고 싶은 상품을 찾을 수 있다 + +**인수 조건:** +- 폐점된 브랜드(`closed_at IS NOT NULL`)의 상품은 목록에 노출되지 않는다 +- 페이징이 적용된다 (page, size 파라미터) +- 브랜드 ID로 필터링할 수 있다 (선택) + +**예외:** +- 등록된 상품이 없으면 빈 목록을 반환한다 + +--- + +#### US-02: 상품 상세 조회 +- **As a** 사용자 +- **I want to** 특정 상품의 상세 정보를 조회한다 +- **So that** 상품의 이름, 설명, 가격, 재고, 브랜드 정보를 확인할 수 있다 + +**인수 조건:** +- 상품 정보에 소속 브랜드의 이름과 설명이 포함된다 +- 폐점 브랜드의 상품은 조회할 수 없다 + +**예외:** +- 존재하지 않는 상품 ID → 404 Not Found +- 폐점 브랜드의 상품 → 404 Not Found (사용자에게는 "없는 상품"과 동일하게 처리) + +--- + +#### US-03: 브랜드 목록 조회 +- **As a** 사용자 +- **I want to** 영업 중인 브랜드 목록을 조회한다 +- **So that** 관심 있는 브랜드를 찾고, 해당 브랜드의 상품을 탐색할 수 있다 + +**인수 조건:** +- 폐점 브랜드(`closed_at IS NOT NULL`)는 목록에 노출되지 않는다 + +**예외:** +- 영업 중인 브랜드가 없으면 빈 목록을 반환한다 + +--- + +#### US-04: 브랜드 상세 조회 +- **As a** 사용자 +- **I want to** 특정 브랜드의 상세 정보를 조회한다 +- **So that** 브랜드의 이름과 설명을 확인할 수 있다 + +**예외:** +- 존재하지 않는 브랜드 ID → 404 Not Found +- 폐점 브랜드 → 404 Not Found + +--- + +#### US-05: 브랜드별 상품 목록 조회 +- **As a** 사용자 +- **I want to** 특정 브랜드에 소속된 상품 목록만 조회한다 +- **So that** 좋아하는 브랜드의 상품만 모아 볼 수 있다 + +**인수 조건:** +- 해당 브랜드가 영업 중이어야 조회 가능하다 +- 페이징이 적용된다 + +**예외:** +- 폐점 브랜드 → 404 Not Found +- 해당 브랜드에 상품이 없으면 빈 목록을 반환한다 + +--- + +### 2.2 좋아요 (회원) + +#### US-06: 좋아요 토글 +- **As a** 회원 +- **I want to** 상품에 좋아요를 등록하거나 취소한다 (토글) +- **So that** 관심 있는 상품을 표시하고, 나중에 다시 찾을 수 있다 + +**인수 조건:** +- 좋아요가 없는 상태에서 요청하면 등록, 있는 상태에서 요청하면 취소 +- 응답에 현재 좋아요 상태(`liked: true/false`)를 포함한다 +- 폐점 브랜드의 상품에는 좋아요를 등록할 수 없다 + +**예외:** +- 비회원 요청 → 401 Unauthorized +- 존재하지 않는 상품 → 404 Not Found +- 폐점 브랜드 상품 → 400 Bad Request + +--- + +#### US-07: 내 좋아요 목록 조회 +- **As a** 회원 +- **I want to** 내가 좋아요한 상품 목록을 조회한다 +- **So that** 관심 상품을 한눈에 보고 구매를 결정할 수 있다 + +**인수 조건:** +- 삭제된 상품의 좋아요는 목록에서 제외한다 (LEFT JOIN 필터링) +- 폐점 브랜드 상품의 좋아요 노출 정책: 제외 (폐점 브랜드 상품은 비노출) +- 페이징이 적용된다 + +**예외:** +- 비회원 요청 → 401 Unauthorized +- 좋아요한 상품이 없으면 빈 목록을 반환한다 + +--- + +### 2.3 주문 (회원) + +#### US-08: 주문 요청 +- **As a** 회원 +- **I want to** 여러 상품을 수량과 함께 지정하여 주문을 요청한다 +- **So that** 원하는 상품을 구매할 수 있다 + +**인수 조건:** +- 주문 요청: `[{productId, quantity}, ...]` 형태의 상품 목록을 전달한다 +- 각 상품에 대해 비관적 락(`SELECT FOR UPDATE`)으로 재고를 확인한다 +- 모든 상품의 재고가 충분하면: + - 각 상품의 재고를 수량만큼 차감한다 + - 주문 시점의 상품 정보(이름, 설명, 가격, 브랜드명, 수량)를 스냅샷으로 저장한다 + - 주문 상태를 ACCEPTED로 저장한다 +- 하나라도 재고가 부족하면: + - 전체 트랜잭션을 롤백한다 (이미 차감한 재고도 원복) + - 주문 상태를 REJECTED로 저장한다 +- 전체가 하나의 트랜잭션이다 (all-or-nothing) +- **데드락 방지**: 재고 차감 시 productId 오름차순으로 정렬하여 비관적 락을 획득한다 + +**예외:** +- 비회원 요청 → 401 Unauthorized +- 존재하지 않는 상품 ID 포함 → 404 Not Found +- 폐점 브랜드 상품 포함 → 400 Bad Request (폐점 브랜드 주문 불가) +- 수량이 0 이하 → 400 Bad Request +- 주문 상품 목록이 비어있음 → 400 Bad Request +- 재고 부족 → 400 Bad Request (**어떤 상품이 부족한지 구체적으로 응답**에 포함: productId, productName, requestedQuantity, availableStock) + +**열린 결정사항:** +- REJECTED 주문에 스냅샷을 저장할 것인지 여부 (저장: 사용자가 거절 이력 확인 가능 / 미저장: 스냅샷 = 계약 성립의 증거라는 본질 유지) + +--- + +#### US-09: 내 주문 목록 조회 +- **As a** 회원 +- **I want to** 내 주문 목록을 날짜 기간으로 필터링하여 조회한다 +- **So that** 특정 기간의 주문 내역을 확인할 수 있다 + +**인수 조건:** +- 시작일(startDate), 종료일(endDate) 파라미터로 필터링한다 +- 주문일시(`ordered_at`) 기준으로 필터링한다 +- 본인의 주문만 조회된다 +- 페이징이 적용된다 + +**예외:** +- 비회원 요청 → 401 Unauthorized +- 시작일이 종료일보다 뒤 → 400 Bad Request +- 해당 기간에 주문이 없으면 빈 목록 반환 + +--- + +#### US-10: 주문 상세 조회 +- **As a** 회원 +- **I want to** 특정 주문의 상세 내역을 확인한다 +- **So that** 주문한 상품, 수량, 당시 가격, 주문 상태를 확인할 수 있다 + +**인수 조건:** +- 주문 정보 + 스냅샷 목록이 함께 조회된다 +- 본인의 주문만 조회 가능하다 + +**예외:** +- 비회원 요청 → 401 Unauthorized +- 존재하지 않는 주문 ID → 404 Not Found +- 타인의 주문 → 403 Forbidden + +--- + +#### US-11: 주문 취소 +- **As a** 회원 +- **I want to** 수락된 주문을 취소한다 +- **So that** 구매를 철회하고 재고가 복원된다 + +**인수 조건:** +- ACCEPTED 상태의 주문만 취소할 수 있다 +- 취소 시 스냅샷에 기록된 수량만큼 각 상품의 재고를 복원한다 +- **원본 상품이 이미 삭제된 경우, 해당 상품의 재고 복원은 건너뛴다** (주문 상태는 정상적으로 CANCELLED로 변경) +- 주문 상태가 CANCELLED로 변경된다 + +**예외:** +- 비회원 요청 → 401 Unauthorized +- REJECTED/CANCELLED 상태 주문 취소 시도 → 400 Bad Request +- 타인의 주문 취소 시도 → 403 Forbidden + +--- + +### 2.4 관리자 — 브랜드 관리 + +#### US-A01: 브랜드 목록 조회 +- **As a** 관리자 +- **I want to** 전체 브랜드 목록을 조회한다 (폐점 포함) +- **So that** 등록된 브랜드 현황을 파악할 수 있다 + +**인수 조건:** +- 폐점 브랜드도 포함하여 조회된다 (사용자 조회와 다름) +- 페이징이 적용된다 + +--- + +#### US-A02: 브랜드 상세 조회 +- **As a** 관리자 +- **I want to** 특정 브랜드의 상세 정보를 조회한다 + +**인수 조건:** +- 폐점 상태(`closedAt`) 정보가 포함된다 + +--- + +#### US-A03: 브랜드 등록 +- **As a** 관리자 +- **I want to** 새로운 브랜드를 등록한다 + +**인수 조건:** +- 이름, 설명을 입력하여 등록한다 +- 등록 시 `closedAt`은 NULL (영업 중 상태) + +**예외:** +- 이름이 비어있음 → 400 Bad Request + +--- + +#### US-A04: 브랜드 수정 +- **As a** 관리자 +- **I want to** 브랜드의 이름, 설명을 수정한다 + +**예외:** +- 존재하지 않는 브랜드 → 404 Not Found + +--- + +#### US-A05: 브랜드 삭제 (연쇄) +- **As a** 관리자 +- **I want to** 브랜드를 삭제한다 +- **So that** 잘못 등록된 브랜드를 완전히 제거할 수 있다 + +**인수 조건:** +- 브랜드 물리 삭제 +- 소속 상품 물리 삭제 (연쇄) +- 해당 상품의 좋아요 물리 삭제 (연쇄) +- 삭제 순서: 좋아요 → 상품 → 브랜드 (FK 역순) +- 기존 주문의 스냅샷은 영향 없음 + +**예외:** +- 존재하지 않는 브랜드 → 404 Not Found + +--- + +#### US-A06: 브랜드 폐점 +- **As a** 관리자 +- **I want to** 브랜드를 폐점 처리한다 +- **So that** 해당 브랜드의 상품이 사용자에게 노출되지 않는다 + +**인수 조건:** +- `closedAt`에 현재 시각을 세팅한다 +- 소속 상품 데이터는 보존되며, 사용자 조회에서 자동 비노출된다 +- 좋아요 데이터도 보존된다 + +**예외:** +- 이미 폐점 상태 → 400 Bad Request (멱등성 vs 에러 — 정책 결정 필요) + +--- + +#### US-A07: 브랜드 재입점 +- **As a** 관리자 +- **I want to** 폐점된 브랜드를 재입점 처리한다 +- **So that** 해당 브랜드의 상품이 다시 사용자에게 노출된다 + +**인수 조건:** +- `closedAt`을 NULL로 되돌린다 +- 보존된 상품과 좋아요가 자동 복원된다 + +**예외:** +- 이미 영업 중 → 400 Bad Request + +--- + +### 2.5 관리자 — 상품 관리 + +#### US-A08: 상품 목록 조회 +- **As a** 관리자 +- **I want to** 전체 상품 목록을 조회한다 +- **So that** 등록된 상품 현황을 파악할 수 있다 + +**인수 조건:** +- 폐점 브랜드 상품도 포함하여 조회된다 +- 브랜드 ID로 필터링 가능 (선택) +- 페이징이 적용된다 + +--- + +#### US-A09: 상품 상세 조회 +- **As a** 관리자 +- **I want to** 특정 상품의 상세 정보를 조회한다 + +--- + +#### US-A10: 상품 등록 +- **As a** 관리자 +- **I want to** 새로운 상품을 등록한다 + +**인수 조건:** +- 이름, 설명, 가격, 재고, 브랜드ID를 입력한다 +- 브랜드가 존재해야 한다 (존재 검증) +- 폐점 브랜드에는 상품을 등록할 수 없다 + +**예외:** +- 존재하지 않는 브랜드 ID → 404 Not Found +- 폐점 브랜드 → 400 Bad Request +- 가격이 0 이하 → 400 Bad Request +- 재고가 0 미만 → 400 Bad Request + +--- + +#### US-A11: 상품 수정 +- **As a** 관리자 +- **I want to** 상품의 이름, 설명, 가격, 재고를 수정한다 + +**인수 조건:** +- **브랜드는 변경할 수 없다** (불변) + +**예외:** +- 존재하지 않는 상품 → 404 Not Found +- 브랜드 변경 시도 → 400 Bad Request + +--- + +#### US-A12: 상품 삭제 +- **As a** 관리자 +- **I want to** 상품을 삭제한다 + +**인수 조건:** +- 상품 물리 삭제 +- 해당 상품의 좋아요도 물리 삭제 (연쇄) +- 기존 주문 스냅샷은 영향 없음 + +**예외:** +- 존재하지 않는 상품 → 404 Not Found + +--- + +### 2.6 관리자 — 주문 모니터링 + +#### US-A13: 주문 목록 조회 +- **As a** 관리자 +- **I want to** 전체 주문 목록을 조회한다 (읽기 전용) + +**인수 조건:** +- 모든 회원의 주문이 조회된다 +- 페이징이 적용된다 + +--- + +#### US-A14: 주문 상세 조회 +- **As a** 관리자 +- **I want to** 특정 주문의 상세 내역을 조회한다 (읽기 전용) + +--- + +## 3. 주문 상태 전이 모델 + +``` +REQUESTED ──(재고 충분)──→ ACCEPTED ──(회원 취소 요청)──→ CANCELLED + │ (재고 복원) + └──(재고 부족)──→ REJECTED +``` + +| 전이 | 조건 | 부수 효과 | +|------|------|----------| +| REQUESTED → ACCEPTED | 모든 상품의 재고 >= 주문 수량 | 수량만큼 재고 차감, 스냅샷 저장 | +| REQUESTED → REJECTED | 하나 이상의 상품 재고 부족 | 전체 롤백 (차감 없음) | +| ACCEPTED → CANCELLED | 회원의 취소 요청 | 스냅샷 기준 수량만큼 재고 복원 | + +**불가능한 전이:** +- REJECTED → 어떤 상태로든 전이 불가 (최종 상태) +- CANCELLED → 어떤 상태로든 전이 불가 (최종 상태) +- ACCEPTED → REJECTED (논리적으로 불가) diff --git a/docs/planning/phase1-structure-refactoring.md b/docs/planning/phase1-structure-refactoring.md new file mode 100644 index 000000000..e2eac46db --- /dev/null +++ b/docs/planning/phase1-structure-refactoring.md @@ -0,0 +1,378 @@ +# Phase 1: 구조 변경 설계서 + +> 작성일: 2026-02-21 (최종 수정: 2026-02-21) +> 상태: 구현 진행 중 + +--- + +## 1. 목표 + +현재 `apps/modules/supports` 3계층 구조를 레이어드 아키텍처 + DIP 원칙에 맞게 +`domain/application/presentation/modules/supports` 5계층 구조로 재배치한다. + +**원칙: 파일 이동과 의존성 변경만 수행. 비즈니스 로직 변경 없음.** + +--- + +## 2. 레이어 아키텍처 + +### 2-1. 레이어 다이어그램 + +``` +┌───────────────────────────────────────────────────────────┐ +│ presentation/ Presentation Layer (bootJar)│ +│ Controller, DTO, API Spec, app-specific Adapter │ +│ Spring Boot main, application.yml │ +│ → application, modules, supports 에 의존 │ +├───────────────────────────────────────────────────────────┤ +│ application/ Application Layer │ +│ Service, Facade, 비즈니스 유스케이스 조합 │ +│ → domain 에만 의존 │ +├──────────────────┬────────────────────────────────────────┤ +│ modules/ │ domain/ Domain Layer │ +│ Infrastructure │ Entity, VO, Repository Port │ +│ jpa, redis, │ 순수 비즈니스 규칙 │ +│ kafka │ → 아무것도 의존하지 않음 │ +│ → domain 에 │ │ +│ 의존 │ │ +├──────────────────┴────────────────────────────────────────┤ +│ supports/ Cross-cutting │ +│ jackson, logging, monitoring │ +│ → 독립 │ +└───────────────────────────────────────────────────────────┘ +``` + +### 2-2. 의존 방향 + +``` +presentation/commerce-api (bootJar) + ├─→ application/commerce-api (서비스 로직) + ├─→ domain (엔티티, 포트) + ├─→ modules/jpa, redis (인프라 어댑터) + └─→ supports/* (횡단 관심사) + +application/commerce-api (java-library) + └─→ domain (엔티티, 포트만 참조) + +modules/jpa (java-library) + └─→ domain (엔티티 참조, 포트 구현) + +domain (java-library) + └─→ (없음) +``` + +### 2-3. 각 레이어의 책임 + +| 레이어 | 책임 | 포함 내용 | 의존 | +|--------|------|----------|------| +| **domain** | 순수 비즈니스 규칙 | Entity, VO, Repository Port, Policy, 도메인 예외 | 없음 (jakarta.persistence-api만) | +| **application** | 유스케이스 조합 | Service, Facade, 서비스 DTO | domain | +| **presentation** | 외부 인터페이스 | Controller, API DTO, Spring Boot main, app-specific Adapter | application, domain, modules, supports | +| **modules** | 인프라 어댑터 | DB/Redis/Kafka 설정, 공유 Repository 구현체 | domain | +| **supports** | 횡단 관심사 | Jackson, Logging, Monitoring 설정 | 없음 | + +--- + +## 3. 결과 모듈 구조 + +### 3-1. Before (현재) + +``` +Root +├── apps/ +│ ├── commerce-api/ ← 모든 것이 혼재 (Controller, Service, Entity 참조) +│ ├── commerce-batch/ +│ └── commerce-streamer/ +├── modules/ +│ ├── jpa/ ← 도메인 코드가 여기에 혼재 +│ ├── redis/ +│ └── kafka/ ← 패키지 오타 (confg) +└── supports/ +``` + +### 3-2. After (목표) + +``` +Root +├── domain/ ← 신규: Domain Layer +│ ├── build.gradle.kts +│ └── src/ +│ ├── main/java/com/loopers/ +│ │ ├── domain/ +│ │ │ ├── BaseEntity.java +│ │ │ └── member/ +│ │ │ ├── Member.java +│ │ │ ├── MemberExceptionMessage.java +│ │ │ ├── MemberRepository.java (Port) +│ │ │ └── policy/MemberPolicy.java +│ │ └── utils/PasswordEncryptor.java +│ └── test/java/com/loopers/domain/member/ +│ └── MemberTest.java +│ +├── application/ ← 신규: Application Layer +│ └── commerce-api/ +│ ├── build.gradle.kts +│ └── src/ +│ ├── main/java/com/loopers/ +│ │ ├── application/ +│ │ │ ├── service/ +│ │ │ │ ├── MemberService.java +│ │ │ │ └── dto/ (Request/Response DTOs) +│ │ │ └── example/ +│ │ │ ├── ExampleFacade.java +│ │ │ └── ExampleInfo.java +│ │ ├── domain/example/ (app-specific 도메인) +│ │ │ ├── ExampleModel.java +│ │ │ ├── ExampleRepository.java +│ │ │ └── ExampleService.java +│ │ └── support/error/ +│ │ ├── CoreException.java +│ │ └── ErrorType.java +│ └── test/java/com/loopers/ (단위 테스트) +│ ├── application/MemberServiceTest.java +│ ├── domain/example/ExampleModelTest.java +│ └── support/error/CoreExceptionTest.java +│ +├── presentation/ ← 신규: Presentation Layer +│ ├── commerce-api/ +│ │ ├── build.gradle.kts +│ │ └── src/ +│ │ ├── main/java/com/loopers/ +│ │ │ ├── CommerceApiApplication.java +│ │ │ ├── interfaces/api/ +│ │ │ │ ├── ApiResponse.java +│ │ │ │ ├── ApiControllerAdvice.java +│ │ │ │ ├── member/MemberController.java +│ │ │ │ └── example/ (Controller, ApiSpec, Dto) +│ │ │ └── infrastructure/example/ +│ │ │ ├── ExampleJpaRepository.java +│ │ │ └── ExampleRepositoryImpl.java +│ │ ├── main/resources/application.yml +│ │ └── test/java/com/loopers/ (통합/E2E 테스트) +│ │ ├── CommerceApiContextTest.java +│ │ ├── application/MemberServiceIntegrationTest.java +│ │ ├── controller/MemberE2ETest.java +│ │ └── interfaces/api/ExampleV1ApiE2ETest.java +│ ├── commerce-batch/ (기존 apps/commerce-batch 이동) +│ └── commerce-streamer/ (기존 apps/commerce-streamer 이동) +│ +├── modules/ ← Infrastructure Layer +│ ├── jpa/ +│ │ └── src/main/java/com/loopers/ +│ │ ├── config/jpa/ (DataSourceConfig, JpaConfig, QueryDslConfig) +│ │ └── infrastructure/member/ ← 신규: 공유 Adapter +│ │ ├── MemberJpaRepository.java +│ │ └── MemberRepositoryImpl.java +│ ├── redis/ +│ └── kafka/ +│ └── src/main/.../config/kafka/ ← confg → config 수정 +│ +└── supports/ (변경 없음) +``` + +--- + +## 4. Gradle 설정 변경 + +### 4-1. settings.gradle.kts + +```kotlin +include( + ":domain", + ":application:commerce-api", + ":presentation:commerce-api", + ":presentation:commerce-batch", + ":presentation:commerce-streamer", + ":modules:jpa", + ":modules:redis", + ":modules:kafka", + ":supports:jackson", + ":supports:logging", + ":supports:monitoring", +) +``` + +### 4-2. root build.gradle.kts 변경 사항 + +| 항목 | Before | After | +|------|--------|-------| +| bootJar 필터 | `it.parent?.name.equals("apps")` | `it.parent?.name.equals("presentation")` | +| 컨테이너 비활성화 | `project("apps")` | `project("application")` + `project("presentation")` | + +### 4-3. 신규 모듈 build.gradle.kts + +**domain/build.gradle.kts:** +- Plugins: `java-library`, `java-test-fixtures` +- Dependencies: `api("jakarta.persistence:jakarta.persistence-api")` + +**application/commerce-api/build.gradle.kts:** +- Plugins: `java-library` +- Dependencies: `api(project(":domain"))` + +**presentation/commerce-api/build.gradle.kts:** +- Dependencies: domain, application:commerce-api, modules:jpa, modules:redis, supports:*, web, actuator, springdoc +- TestFixtures: domain, modules:jpa, modules:redis + +**modules/jpa/build.gradle.kts 수정:** +- `api(project(":domain"))` 추가 + +--- + +## 5. 파일 이동 매핑 + +### 5-1. modules/jpa → domain (도메인 코드) + +| 파일 | 패키지 변경 | +|------|-----------| +| BaseEntity.java | 없음 | +| Member.java | 없음 | +| MemberExceptionMessage.java | 없음 | +| MemberPolicy.java | 없음 | +| PasswordEncryptor.java | 없음 | + +신규 생성: `domain/.../member/MemberRepository.java` (Port 인터페이스) +이동: `MemberTest.java` (testFixtures → domain/src/test/, 패키지 변경) + +### 5-2. apps/commerce-api → application/commerce-api (비즈니스 로직) + +| 파일 | 패키지 변경 | +|------|-----------| +| MemberService.java | 없음 (import만: `infrastructure.member.MemberRepository` → `domain.member.MemberRepository`) | +| MemberRegisterRequest.java | 없음 | +| MyMemberInfoResponse.java | 없음 | +| PasswordUpdateRequest.java | 없음 | +| ExampleFacade.java, ExampleInfo.java | 없음 | +| ExampleModel.java, ExampleRepository.java, ExampleService.java | 없음 | +| CoreException.java, ErrorType.java | 없음 | + +### 5-3. apps/commerce-api → presentation/commerce-api (인터페이스 + 부트) + +| 파일 | 패키지 변경 | +|------|-----------| +| CommerceApiApplication.java | 없음 | +| ApiResponse.java, ApiControllerAdvice.java | 없음 | +| ExampleV1Controller.java, ExampleV1ApiSpec.java, ExampleV1Dto.java | 없음 | +| MemberController.java | `com.loopers.controller` → `com.loopers.interfaces.api.member` | +| ExampleJpaRepository.java, ExampleRepositoryImpl.java | 없음 | +| application.yml | 없음 | + +### 5-4. apps/commerce-api → modules/jpa (공유 Adapter) + +신규 생성: +- `modules/jpa/.../infrastructure/member/MemberJpaRepository.java` +- `modules/jpa/.../infrastructure/member/MemberRepositoryImpl.java` + +삭제: +- `apps/commerce-api/.../infrastructure/member/MemberRepository.java` + +### 5-5. 테스트 파일 분리 + +**단위 테스트 → application/commerce-api/src/test/:** +- MemberServiceTest.java (Mockito) +- ExampleModelTest.java +- CoreExceptionTest.java + +**통합/E2E 테스트 → presentation/commerce-api/src/test/:** +- CommerceApiContextTest.java +- MemberServiceIntegrationTest.java +- MemberE2ETest.java +- ExampleServiceIntegrationTest.java +- ExampleV1ApiE2ETest.java + +### 5-6. batch/streamer + +`apps/commerce-batch/` → `presentation/commerce-batch/` (내용 변경 없음) +`apps/commerce-streamer/` → `presentation/commerce-streamer/` (내용 변경 없음) + +--- + +## 6. 기타 수정 + +### 6-1. BaseEntity.id final 제거 +```java +// Before: private final Long id = 0L; +// After: private Long id; +``` + +### 6-2. Kafka 패키지 오타 수정 +`com.loopers.confg.kafka` → `com.loopers.config.kafka` + +--- + +## 7. 영향도 분석 + +### 7-1. @EntityScan / @EnableJpaRepositories + +| 설정 | 영향 | 이유 | +|------|------|------| +| `@EntityScan({"com.loopers"})` | 영향 없음 | domain, application 모듈 엔티티 모두 `com.loopers.*` 패키지 | +| `@EnableJpaRepositories({"com.loopers.infrastructure"})` | 영향 없음 | modules/jpa, presentation의 JPA Repo 모두 `com.loopers.infrastructure.*` | + +### 7-2. @SpringBootApplication 컴포넌트 스캔 + +presentation/commerce-api의 `@SpringBootApplication`이 `com.loopers` 패키지를 스캔. +application/의 `@Service`, modules/의 `@Configuration` 등 모두 자동 감지됨. + +### 7-3. 전이 의존성 + +`modules/jpa`가 `api(project(":domain"))`을 선언 → modules:jpa 의존하는 모듈이 domain을 자동으로 받음. +`application/commerce-api`가 `api(project(":domain"))` 선언 → presentation이 domain을 자동으로 받음. +그래도 presentation에서 `implementation(project(":domain"))` 명시하여 의도를 명확히 함. + +--- + +## 8. 작업 순서 + +1. **Gradle 설정 변경** (settings, root build, 신규 build 파일들) +2. **domain 모듈 생성** + 코드 이동 (modules/jpa → domain) +3. **MemberRepository Adapter** 생성 (modules/jpa) +4. **application/commerce-api** 생성 + 비즈니스 코드 이동 +5. **presentation/commerce-api** 생성 + 인터페이스/부트 코드 이동 +6. **batch/streamer** 이동 (apps → presentation) +7. **Kafka 오타 수정** +8. **apps/ 디렉토리 삭제** +9. **검증**: `./gradlew clean build` + +--- + +## 9. 완료 기준 + +- [ ] `./gradlew clean build` 전체 통과 +- [ ] `./gradlew :domain:test` — MemberTest 통과 +- [ ] `./gradlew :application:commerce-api:test` — 단위 테스트 통과 +- [ ] `./gradlew :presentation:commerce-api:test` — 통합/E2E 테스트 통과 +- [ ] `./gradlew :presentation:commerce-api:bootRun` — 서버 정상 기동 +- [ ] `apps/` 디렉토리 완전 제거 +- [ ] `modules/jpa`에 비즈니스 로직 없음 (설정 + Adapter만) + +--- + +## 10. 작업 제외 사항 (이번 범위 밖) + +- Brand, Product, ProductLike, Order 등 신규 도메인 구현 +- VO(@Embeddable) 도입 — 신규 도메인에서 적용 +- MemberPolicy 규칙 내재화 — Phase 2 +- 도메인 예외 체계 구축 (DomainException → domain 레이어) — Phase 2 +- 예외 처리 체계 재설계 — Phase 2 +- Service 구조 변경 (ApplicationService + DomainService 분리) — Phase 2 +- `@Builder`, `@AllArgsConstructor` 제거 → 정적 팩토리 메서드 전환 — Phase 2 +- DTO 네이밍 통일 (`RegisterMemberRequest` 스타일) — Phase 2 +- Service 책임 분리 (마스킹 로직 이동) — Phase 2 +- supports 모듈 의존성 중복 정리 — 별도 작업 + +--- + +## 11. Phase 2 예고: 코드 스타일 적용 사항 + +> Phase 1 구현 전 논의에서 결정된 코드 스타일. Phase 1(구조 변경)에서는 적용하지 않고, +> Phase 2(모델링 및 설계 변경)에서 일괄 적용. + +| 항목 | Before | After | +|------|--------|-------| +| Entity 생성 | `@Builder` + `@AllArgsConstructor` | 정적 팩토리 메서드만 | +| `@Transactional` | Service 메서드에 개별 적용 | ApplicationService 클래스 레벨에만 | +| Service 구조 | Service가 Repository 직접 사용 | ApplicationService → DomainService → Repository | +| 예외 처리 위치 | application 레이어 (CoreException) | domain 레이어에 도메인 예외 정의, modules/jpa에서 던짐 | +| DTO 네이밍 | `MemberRegisterRequest` | `RegisterMemberRequest` (행동 먼저) | +| 메서드 내부 주석 | 있음 | 없음 (메서드명으로 의도 표현) | +| Javadoc | 없음 | Controller 메서드에만 | diff --git a/docs/planning/refactoring-plan.md b/docs/planning/refactoring-plan.md new file mode 100644 index 000000000..a0a6b5e54 --- /dev/null +++ b/docs/planning/refactoring-plan.md @@ -0,0 +1,226 @@ +# Member 리팩토링 작업 계획서 + +> 작성일: 2026-02-21 +> 범위: 기존 구현된 Member 기능의 구조 변경 및 리팩토링 +> 신규 기능(Brand, Product, Order 등)은 이 작업 이후 별도 진행 + +--- + +## 1. 작업 배경 + +### 1-1. 현재 상태 +- Member 관련 기능(회원가입, 로그인, 비밀번호 변경)만 구현되어 있음 +- 도메인 코드가 `modules/jpa`(인프라 설정 모듈)에 위치 +- 예외 처리 체계가 일관되지 않음 +- 도메인 단위 테스트(MemberTest)가 없음 +- 서비스 테스트에서 mock/capture로 간접 검증 + +### 1-2. 아키텍처 분석에서 도출된 문제점 + +| # | 문제 | 위치 | 심각도 | +|---|------|------|--------| +| 1 | domain 모듈 부재 — 도메인 코드가 인프라 모듈에 혼재 | `modules/jpa` | Critical | +| 2 | IllegalArgumentException → 전부 401 UNAUTHORIZED 반환 | `ApiControllerAdvice` | Critical | +| 3 | BaseTimeEntity 미구현 (soft-delete 불필요한 엔티티용) | `modules/jpa` | Critical | +| 4 | MemberPolicy 중앙 집중 — 응집도 떨어짐 | `modules/jpa` | High | +| 5 | 패키지 구조 불일치 (`controller/` vs `interfaces/api/`) | `apps/commerce-api` | High | +| 6 | Service에 표현 로직 혼재 (이름 마스킹) | `MemberService` | Medium | +| 7 | BaseEntity.id `final` 선언 | `BaseEntity` | Medium | +| 8 | Kafka 패키지 오타 (`confg` → `config`) | `modules/kafka` | Low | + +--- + +## 2. 작업 순서 + +### Phase 1: 구조 변경 + +> 목표: 멀티모듈 의존 방향을 DIP 원칙에 맞게 재배치 + +#### 1-1. `domain/` 모듈 신설 (루트 레벨) + +**Before:** +``` +Root +├── apps/ +├── modules/ +│ ├── jpa/ ← 여기에 Member 엔티티, Policy, PasswordEncryptor 혼재 +│ ├── redis/ +│ └── kafka/ +└── supports/ +``` + +**After:** +``` +Root +├── domain/ ← 신규: 순수 도메인 (최하위 계층, 의존 없음) +├── apps/ +├── modules/ +│ ├── jpa/ ← 인프라 설정 + Repository 구현체만 +│ ├── redis/ +│ └── kafka/ +└── supports/ +``` + +**구체적 작업:** +- `settings.gradle.kts`에 `domain` 모듈 추가 +- `domain/build.gradle.kts` 생성 (`java-library` + `java-test-fixtures`) +- 의존성: `jakarta.persistence-api`, `lombok` (최소한만) + +#### 1-2. 기존 도메인 코드 이동 + +| 파일 | From | To | +|------|------|----| +| `Member.java` | `modules/jpa/.../domain/member/` | `domain/.../member/` | +| `MemberPolicy.java` | `modules/jpa/.../domain/member/policy/` | (리팩토링 후 제거) | +| `MemberExceptionMessage.java` | `modules/jpa/.../domain/member/` | `domain/.../member/` | +| `PasswordEncryptor.java` | `modules/jpa/.../utils/` | `domain/.../member/` | +| `BaseEntity.java` | `modules/jpa/.../domain/` | `domain/.../common/` | +| `MemberRepository` 인터페이스 | `apps/.../infrastructure/member/` | `domain/.../member/` | +| `MemberRepository` 구현체 | - | `modules/jpa/` (신규, Spring Data JPA) | + +#### 1-3. 의존 방향 설정 + +``` +apps/commerce-api + ├─→ domain (비즈니스 규칙) + ├─→ modules/jpa (Repository 구현체) + ├─→ modules/redis + └─→ supports/* + +modules/jpa + └─→ domain (엔티티 참조, Repository 인터페이스 구현) + +domain + └─→ (없음) ← JPA API만 compileOnly 또는 api 수준 의존 +``` + +#### 1-4. 패키지 구조 통일 +- `controller/MemberController.java` → `interfaces/api/member/MemberController.java`로 이동 +- 기존 `interfaces/api/` 컨벤션에 맞춤 + +#### 1-5. 기타 구조 수정 +- `modules/kafka`: `confg` → `config` 패키지명 수정 +- `BaseEntity.id`: `final` 제거 + +--- + +### Phase 2: 모델링 및 설계 변경 + +> 목표: 도메인 객체가 자기 규칙을 가지도록 재설계 (테스트 가능한 구조) + +#### 2-1. BaseEntity / BaseTimeEntity 분리 + +```java +// 공통 시간 추적 (soft-delete 불필요한 엔티티용) +@MappedSuperclass +public abstract class BaseTimeEntity { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + private ZonedDateTime createdAt; + private ZonedDateTime updatedAt; +} + +// soft-delete 필요한 엔티티용 +@MappedSuperclass +public abstract class BaseEntity extends BaseTimeEntity { + private ZonedDateTime deletedAt; + public void delete() { ... } + public void restore() { ... } +} +``` + +#### 2-2. MemberPolicy → 도메인 객체에 규칙 내재 + +**Before (중앙 집중 Policy):** +```java +MemberPolicy.Name.validate(name); +MemberPolicy.Email.validate(email); +MemberPolicy.Password.validate(password, birthDate); +``` + +**After (각 객체가 자기 규칙 보유):** +```java +// Member.register() 내부에서 직접 검증 +// 또는 VO를 도입하여 생성 시 검증 +// → Phase 2에서 구체적 설계 결정 +``` + +검증 로직을 Member 엔티티 내부로 이동하되, 검증 규칙의 상수/메시지는 Member 내부 또는 동일 패키지에 위치시킴. +VO 도입 여부는 각 필드의 자체 규칙 유무에 따라 판단: +- 자체 규칙이 있는 필드(Stock, Price 등) → VO(`@Embeddable`) +- 단순 검증만 필요한 필드(name, email 등) → 엔티티 내부 검증 + +#### 2-3. 예외 처리 체계 재설계 + +**Before:** +- 도메인: `IllegalArgumentException` 사용 +- ControllerAdvice: 모든 `IllegalArgumentException` → 401 UNAUTHORIZED + +**After:** +- 도메인 검증 실패 → `CoreException(ErrorType.BAD_REQUEST, "메시지")` +- 인증 실패 → 별도 `AuthenticationException` 또는 `CoreException(ErrorType.UNAUTHORIZED, "메시지")` +- ControllerAdvice: `CoreException` 기반으로 통일 + +ErrorType에 `UNAUTHORIZED` 추가: +```java +UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "Unauthorized", "인증에 실패했습니다.") +``` + +#### 2-4. Service 책임 분리 + +- `MemberService.maskName()` → 표현 계층(Controller 또는 Response DTO)으로 이동 +- Service는 도메인 로직 조합과 트랜잭션 관리에만 집중 + +--- + +### Phase 3: 테스트 코드 수정 + +> 목표: 도메인 단위 테스트 확보, 기존 테스트 정리 + +#### 3-1. 도메인 단위 테스트 작성 (Red → Green) + +``` +domain/src/test/java/com/loopers/member/ +├── MemberTest.java ← 회원 생성, 비밀번호 변경 등 도메인 규칙 검증 +└── MemberExceptionMessageTest.java ← (필요 시) +``` + +테스트 예시: +- 정상 회원 생성 +- 비밀번호에 생년월일 포함 시 거부 +- 동일 비밀번호로 변경 시 거부 +- 이름/이메일 형식 검증 + +#### 3-2. 기존 Service 테스트 수정 + +- `MemberServiceTest`: mock capture 방식 → 도메인 규칙은 MemberTest로 이동 +- `MemberServiceIntegrationTest`: Repository 연동 검증에 집중 +- `MemberE2ETest`: API 스펙(요청/응답 형식, HTTP 상태 코드) 검증에 집중 + +#### 3-3. 테스트 계층 명확화 + +| 계층 | 대상 | 위치 | 의존 | +|------|------|------|------| +| 단위 테스트 | 도메인 객체 | `domain/src/test/` | 없음 (순수 자바) | +| 통합 테스트 | Service + Repository | `apps/src/test/` | Testcontainers | +| E2E 테스트 | API 전체 흐름 | `apps/src/test/` | Spring Context + Testcontainers | + +--- + +## 3. 완료 기준 + +- [ ] `domain/` 모듈이 루트 레벨에 존재하며, 다른 모듈에 의존하지 않음 +- [ ] `modules/jpa`에 비즈니스 로직이 없음 (설정 + Repository 구현체만) +- [ ] 모든 예외가 `CoreException` 기반으로 통일 +- [ ] `MemberTest` 도메인 단위 테스트가 존재하며 통과 +- [ ] 기존 모든 테스트(`./gradlew test`)가 통과 +- [ ] 패키지 구조가 `interfaces/api/` 컨벤션에 맞춤 + +--- + +## 4. 작업 제외 사항 (이번 범위 밖) + +- Brand, Product, ProductLike, Order 등 신규 도메인 구현 +- VO(@Embeddable) 도입은 신규 도메인에서 적용 (Member는 기존 구조 유지 또는 최소 변경) +- Facade 패턴 도입 (신규 도메인 간 의존 해소 시 적용) +- supports 모듈 의존성 중복 정리 (별도 작업) diff --git a/docs/temp/architecture-discussion-log.md b/docs/temp/architecture-discussion-log.md new file mode 100644 index 000000000..4f205fc33 --- /dev/null +++ b/docs/temp/architecture-discussion-log.md @@ -0,0 +1,322 @@ +# 아키텍처 논의 기록 + +> 작성일: 2026-02-21 +> 참여: 개발자, AI (Claude Code) +> 맥락: TDD + DDD 기반 커머스 프로젝트 리팩토링 전 논의 + +--- + +## 1. 아키텍처 분석에서 발견된 문제 + +### 분석 요약 + +현재 프로젝트는 문서(요구사항, 클래스 다이어그램, ERD)는 잘 정의되어 있지만, +실제 구현은 Member CRUD만 존재하며, 구현된 코드에도 구조적 문제가 있음. + +### 핵심 문제 3가지 + +1. **domain 모듈 부재**: 도메인 코드(Member, Policy, PasswordEncryptor)가 인프라 설정 모듈(`modules/jpa`)에 위치 +2. **예외 처리 붕괴**: 모든 `IllegalArgumentException`이 401 UNAUTHORIZED로 반환 +3. **도메인 테스트 부재**: `MemberTest`(도메인 단위 테스트)가 testFixtures에 있어 실행되지 않고, Service mock 테스트만 존재 + +### 추가 발견 사항 + +| # | 문제 | 위치 | 심각도 | +|---|------|------|--------| +| 4 | MemberPolicy 중앙 집중 — 응집도 떨어짐 | `modules/jpa` | High | +| 5 | 패키지 구조 불일치 (`controller/` vs `interfaces/api/`) | `apps/commerce-api` | High | +| 6 | Application/Presentation 레이어 미분리 | `apps/commerce-api` | High | +| 7 | Service에 표현 로직 혼재 (이름 마스킹) | `MemberService` | Medium | +| 8 | BaseEntity.id `final` 선언 | `BaseEntity` | Medium | +| 9 | Kafka 패키지 오타 (`confg` → `config`) | `modules/kafka` | Low | +| 10 | supports 모듈 의존성 중복 | `logging`/`monitoring` | Low | + +--- + +## 2. 멘토 피드백 반영 + +### 피드백 1: MemberPolicy 중앙화의 문제 + +> "MemberPolicy에 들어갈 로직 자체가 Name이랑 코드위치상 거리가 멀어짐에 따라 +> 찾아가야하는 문제가 생깁니다. 더불어, MemberPolicy 하나에서 관리되고 있으므로, +> 변경사항이 발생하면 변경된 곳의 위치를 찾고 수정함에 있어서 불편함을 초래할 수 있습니다." + +**논의 결과:** +- 별도 Policy 클래스 대신, 도메인 객체가 자기 규칙을 가지는 방향으로 전환 +- VO가 자체 규칙을 가지면 해당 VO만 보면 되므로 응집도 향상 +- 다만 VO 도입은 신규 도메인(Stock, Price, Quantity)에 우선 적용하고, + 기존 Member는 Phase 2에서 규칙 내재화 + +### 피드백 2: 도메인 테스트 부재 + +> "MemberTest가 없습니다. 즉 도메인 테스트 코드가 없습니다." +> "spy를 사용하는 것이 적절한지 확인해볼 필요가 있습니다." + +**논의 결과:** +- 도메인 단위 테스트를 최우선으로 작성 +- Service 테스트에서 mock capture로 도메인 규칙을 검증하는 방식 지양 +- 도메인 규칙은 도메인 테스트에서, Service는 조합/트랜잭션 검증에 집중 + +### 피드백 3: 도메인 코드 위치 + +> "jpa가 modules에 들어가져 있는데요. 여기서 jpa는 reusable configuration이기 때문에 +> 적절하지 않습니다. 도메인 코드는 app 쪽에 있어야 합니다." + +**논의 결과:** +- 별도 `domain/` 모듈을 루트 레벨에 신설 +- `modules/jpa`는 인프라 설정 + Repository Adapter만 담당 +- DIP: domain이 Repository 인터페이스(Port)를 정의하고, modules/jpa가 구현(Adapter) + +--- + +## 3. 주요 설계 결정 + +### 3-1. 레이어 분리: 모듈 수준 (최종 결정) + +**논의 과정:** +- 처음에는 domain/ 모듈 분리만 고려 +- Application Layer와 Presentation Layer도 모듈 수준으로 분리하자는 논의 발생 +- 패키지 수준 정리 vs 모듈 수준 분리를 비교 + +**결정: 모듈 수준 분리 (안 A: 레이어 명시적 분리)** + +``` +Root +├── domain/ ← Domain Layer (java-library) +├── application/ ← Application Layer (java-library) +│ └── commerce-api/ +├── presentation/ ← Presentation Layer (bootJar) +│ └── commerce-api/ +├── modules/ ← Infrastructure Layer (java-library) +└── supports/ ← Cross-cutting +``` + +이유: +- 컴파일 시점에 의존 방향을 강제할 수 있음 +- 레이어 간 책임이 명확히 분리됨 +- 디렉토리 이름 = 레이어 이름으로 직관적 + +**비교했던 대안:** +| 안 | 설명 | 기각 이유 | +|---|------|----------| +| 패키지 수준 정리 | apps 내부에서 패키지로만 구분 | 컴파일러가 의존 방향 강제 불가 | +| apps 내부 분리 | apps/commerce-api-core + apps/commerce-api | apps가 두 가지 의미를 가짐 | +| apps를 application으로 | apps 이름 유지 + presentation 추가 | "apps"가 "application layer" 의미로 혼란 | + +### 3-2. domain 모듈 위치: 루트 레벨 + +**결정: `domain/` (루트 레벨)** + +이유: `modules/`는 jpa, redis, kafka 등 "외부 시스템 연결 어댑터"의 성격. +도메인은 이와 본질적으로 다르므로 분리하는 것이 의미적으로 명확함. + +### 3-3. JPA 어노테이션: 실용적 방향 + +**결정: domain에서 `@Entity`, `@Embeddable` 등 JPA 어노테이션 허용** + +이유: +- 현재 프로젝트가 이미 BaseEntity에서 @MappedSuperclass 등 사용 중 +- 순수주의 적용 시 매핑 레이어 추가로 복잡도 증가 +- `jakarta.persistence-api`는 인터페이스 수준 의존 + +### 3-4. Repository Adapter 위치: modules/jpa + +**결정: MemberJpaRepository + MemberRepositoryImpl을 `modules/jpa`에 배치** + +논의: +- 처음 제안은 apps(현 presentation) 내부에 두는 것 +- 개발자가 modules/jpa에 두기를 선택 +- 이유: 모든 앱(batch, streamer)이 같은 Repository 구현을 공유 + +결과: +- Port(MemberRepository 인터페이스) → `domain/` 모듈 +- Adapter(MemberJpaRepository + MemberRepositoryImpl) → `modules/jpa` +- app-specific adapter(ExampleJpaRepository 등) → `presentation/commerce-api` + +### 3-5. bootJar 위치: presentation + +**결정: `presentation/`이 Spring Boot 실행 애플리케이션을 담당** + +이유: +- 가장 바깥 레이어가 실행 진입점 +- 레이어 구조와 일관됨 +- `./gradlew :presentation:commerce-api:bootRun`으로 실행 + +### 3-6. VO 전략: @Embeddable 사용 + +| 대상 | JPA 매핑 | 자체 규칙 | +|------|---------|----------| +| Stock | `@Embeddable` → product.stock INT | value >= 0, isEnough(), decrease() | +| Price | `@Embeddable` → product.price INT | value > 0 | +| Quantity | `@Embeddable` → order_line_snapshot.quantity INT | value > 0 | + +VO 도입 기준: 자체 규칙(invariant)이 있는 필드만 VO로 승격. +(Phase 2 이후 신규 도메인 구현 시 적용) + +### 3-7. Presentation 레이어 네이밍: "presentation" + +검토한 후보: `interfaces`, `presentation`, `api` +결정: `presentation` — 레이어드 아키텍처 용어에 부합, 직관적. + +--- + +## 4. "테스트 가능한 코드"에 대한 고민 + +### 핵심 질문 +> "Test 가능한 코드란 무엇인가?" + +### 도출된 3가지 기준 + +#### 기준 1: 비즈니스 규칙이 객체 안에 있는가? +- Service에 if문으로 규칙이 있으면 → mock 테스트 필요 (테스트 어려움) +- 도메인 객체가 자기 규칙을 가지면 → new로 생성해서 바로 검증 (테스트 쉬움) +- **예시:** `new Stock(10).decrease(new Quantity(3))` → Mock 없이 순수 자바로 검증 가능 + +#### 기준 2: 외부 의존이 주입 가능한가? +- 객체가 직접 외부를 호출하면 → 테스트 시 그 외부를 통째로 구성해야 함 +- 인터페이스로 받아서 사용하면 → Fake 구현으로 대체 가능 +- **예시:** `ProductRepository` 인터페이스를 domain에 정의 → 테스트 시 `FakeProductRepository` 주입 + +#### 기준 3: 부수효과(side effect)가 분리되어 있는가? +- 하나의 메서드에서 검증 + 저장 + 이벤트 발행 → 전부 필요해서 테스트 무거움 +- 순수 로직(도메인)과 부수효과(Service)가 분리 → 각각 적절한 수준으로 테스트 + +### 테스트 피라미드 적용 + +``` + / E2E \ ← 적고 느림 (Spring Context + DB) + / 통합 \ ← 적당 (Service + Repository) + / 단위 \ ← 많고 빠름 (순수 도메인 객체) +``` + +도메인에 규칙이 내재되어 있으면 → 피라미드 하단(단위 테스트)이 두꺼워짐 +Service에 규칙이 있으면 → 피라미드가 뒤집혀서 통합/E2E에 의존 + +### 현재 코드의 문제 + +```java +// 현재: Service에서 mock capture로 간접 검증 +verify(memberRepository).save(memberCaptor.capture()); +assertThat(memberCaptor.getValue().getLoginId()).isEqualTo(request.loginId()); +// → 구현 세부사항에 결합, Member 도메인 규칙 자체를 검증하지 않음 + +// 목표: 도메인 객체를 직접 테스트 +Member member = Member.register("testId", "Password1!", "홍길동", + LocalDate.of(1990, 1, 1), "test@test.com"); +assertThat(member.isSamePassword("Password1!")).isTrue(); +// → Mock 없음, DB 없음, Spring 없음. 규칙만 검증. +``` + +--- + +## 5. 리팩토링 진행 순서 + +``` +Phase 1: 구조 변경 + → domain, application, presentation 모듈 분리 + → 코드 이동, 의존 방향 설정, 패키지 통일, 기타 수정 + +Phase 2: 모델링 및 설계 변경 + → BaseTimeEntity 분리 + → MemberPolicy 제거 (규칙 내재화) + → 예외 체계 통일 (CoreException 기반) + → Service 책임 분리 (마스킹 로직 이동) + +Phase 3: 테스트 코드 수정 + → 도메인 단위 테스트 보강 + → 기존 Service 테스트 정리 + → 테스트 계층 명확화 +``` + +각 Phase 완료 후 `./gradlew test` 통과를 확인하며 점진적으로 진행. + +--- + +## 6. 코드 스타일 논의 (Phase 1 구현 전) + +> 날짜: 2026-02-21 +> 맥락: Phase 1 구현 직전, 코드 스타일 통일을 위한 논의 + +### 6-1. @Builder 사용 금지 + +**결정: `@Builder`, `@AllArgsConstructor` 사용하지 않음** + +이유: +- `@Builder`는 필드 추가/삭제 시 기존 호출부에서 컴파일 에러가 발생하지 않음 +- 런타임에 가서야 문제를 발견하게 됨 +- 정적 팩토리 메서드(`Member.register(...)`)는 파라미터 변경 시 즉시 컴파일 에러 + +대안: +- 생성: 정적 팩토리 메서드만 사용 +- `@NoArgsConstructor(access = AccessLevel.PROTECTED)` 유지 (JPA용) + +### 6-2. @Transactional 정책 + +**결정: ApplicationService 클래스 레벨에만 적용** + +- 기본: `@Transactional` (클래스 레벨) +- 조회 메서드: `@Transactional(readOnly = true)` 오버라이드 +- DomainService에는 절대 `@Transactional` 사용하지 않음 + +### 6-3. Service 구조: ApplicationService + DomainService + +**결정:** +- **ApplicationService**: 유스케이스 조합, 트랜잭션 경계 담당 +- **DomainService**: 바운디드 컨텍스트/애그리거트 내 도메인 로직 조합 담당 + +ApplicationService는 Repository를 직접 사용하지 않고 DomainService를 통해 접근. + +### 6-4. Repository 예외 처리 위치 + +**논의 과정:** +1. 처음: DomainService에서 Repository + 예외 처리를 담당하는 안 검토 +2. 문제 제기: Repository + 예외 처리 역할은 DomainService(도메인 로직 조합)와 성격이 다름 +3. 별도 이름 부여 검토 (MemberStore, MemberReader 등) +4. 개발자 제안: "예외 처리를 modules/jpa로 넘기고, 도메인은 불러오기만" +5. 문제 발견: modules/jpa → application 의존 방향 위반 (CoreException이 application에 있으므로) +6. 해결: **도메인 예외를 domain 레이어에 정의** + +**최종 결정: 도메인 예외를 domain 레이어에 정의** + +``` +domain/ + └── member/ + ├── Member.java + ├── MemberRepository.java (Port) + └── exception/ + └── MemberNotFoundException.java + └── support/error/ + └── DomainException.java ← 도메인 예외 베이스 +``` + +의존 방향: +``` +presentation → application → domain ← modules/jpa + ↑ + 모두 domain 예외 사용 가능 +``` + +각 레이어 역할: +- **domain**: 예외 정의 (DomainException, MemberNotFoundException 등) +- **modules/jpa**: RepositoryImpl에서 도메인 예외를 던짐 (domain에 의존하므로 가능) +- **application**: 그대로 전파하거나 비즈니스 판단 후 다른 예외로 변환 +- **presentation**: ApiControllerAdvice에서 도메인 예외를 HTTP 응답으로 매핑 + +### 6-5. DTO 네이밍 + +**결정: 행동을 먼저 작성** +- `RegisterMemberRequest` (O) +- `MemberRegisterRequest` (X) + +### 6-6. 주석 정책 + +**결정:** +- 메서드 내부 주석 없음 — 메서드명 자체로 로직이 드러나야 함 +- Javadoc은 Controller 메서드에만 +- Service의 public 메서드가 많아질 때 public에만 Javadoc 추가 + +### 6-7. @ResponseStatus + +**결정: 컨벤션 확립 예정** +- 현재 MemberController에서 `@ResponseStatus(HttpStatus.CREATED)`, `@ResponseStatus(HttpStatus.NO_CONTENT)` 사용 중 +- 구현하면서 정리할 예정 diff --git a/docs/temp/refactoring-plan.md b/docs/temp/refactoring-plan.md new file mode 100644 index 000000000..4f1cfba61 --- /dev/null +++ b/docs/temp/refactoring-plan.md @@ -0,0 +1,118 @@ +# Member 리팩토링 작업 계획서 + +> 작성일: 2026-02-21 (최종 수정: 2026-02-21) +> 범위: 기존 구현된 Member 기능의 구조 변경 및 리팩토링 +> 신규 기능(Brand, Product, Order 등)은 이 작업 이후 별도 진행 + +--- + +## 1. 작업 배경 + +### 1-1. 현재 상태 +- Member 관련 기능(회원가입, 로그인, 비밀번호 변경)만 구현되어 있음 +- 도메인 코드가 `modules/jpa`(인프라 설정 모듈)에 위치 +- Application/Presentation 레이어가 모듈 수준으로 분리되어 있지 않음 +- 예외 처리 체계가 일관되지 않음 +- 도메인 단위 테스트(MemberTest)가 testFixtures에 있어 실행되지 않음 + +### 1-2. 아키텍처 분석에서 도출된 문제점 + +| # | 문제 | 심각도 | +|---|------|--------| +| 1 | domain 모듈 부재 — 도메인 코드가 인프라 모듈에 혼재 | Critical | +| 2 | IllegalArgumentException → 전부 401 UNAUTHORIZED 반환 | Critical | +| 3 | BaseTimeEntity 미구현 (soft-delete 불필요한 엔티티용) | Critical | +| 4 | Application/Presentation 레이어 미분리 | High | +| 5 | MemberPolicy 중앙 집중 — 응집도 떨어짐 | High | +| 6 | 패키지 구조 불일치 (`controller/` vs `interfaces/api/`) | High | +| 7 | Service에 표현 로직 혼재 (이름 마스킹) | Medium | +| 8 | BaseEntity.id `final` 선언 | Medium | +| 9 | Kafka 패키지 오타 (`confg` → `config`) | Low | + +--- + +## 2. 목표 아키텍처 + +### 레이어드 아키텍처 + DIP + +``` +presentation (bootJar) → application (java-library) → domain (java-library) + → modules (java-library) → domain + → supports (독립) +``` + +### 모듈 구조 + +``` +Root +├── domain/ ← Domain Layer (순수 비즈니스 규칙) +├── application/ ← Application Layer (유스케이스 조합) +│ └── commerce-api/ +├── presentation/ ← Presentation Layer (컨트롤러 + Spring Boot) +│ ├── commerce-api/ ← bootJar +│ ├── commerce-batch/ ← bootJar +│ └── commerce-streamer/ ← bootJar +├── modules/ ← Infrastructure Layer (인프라 어댑터) +│ ├── jpa/ +│ ├── redis/ +│ └── kafka/ +└── supports/ ← Cross-cutting (횡단 관심사) +``` + +--- + +## 3. Phase 별 작업 계획 + +### Phase 1: 구조 변경 +> 상세: `docs/planning/phase1-structure-refactoring.md` + +- domain/ 모듈 신설 (루트 레벨) +- application/commerce-api 모듈 신설 +- presentation/commerce-api 모듈 신설 (bootJar) +- 도메인 코드 이동 (modules/jpa → domain) +- 비즈니스 코드 이동 (apps → application) +- 인터페이스 코드 이동 (apps → presentation) +- MemberRepository DIP 분리 (Port + Adapter) +- MemberController 패키지 통일 +- MemberTest 이동 (testFixtures → domain/src/test/) +- BaseEntity.id final 제거 +- Kafka 패키지 오타 수정 +- batch/streamer 이동 (apps → presentation) +- apps/ 디렉토리 삭제 + +### Phase 2: 모델링 및 설계 변경 + +- BaseTimeEntity 신설 (soft-delete 불필요한 엔티티용) +- MemberPolicy 제거 → 도메인 객체에 규칙 내재화 +- 도메인 예외 체계 구축 (DomainException을 domain 레이어에 정의) +- 예외 처리 체계 재설계 (도메인 예외 기반 통일, presentation에서 HTTP 매핑) +- Service 구조 변경 (ApplicationService + DomainService 분리) +- Service 책임 분리 (마스킹 로직을 Presentation 레이어로 이동) +- `@Builder`, `@AllArgsConstructor` 제거 → 정적 팩토리 메서드로 전환 +- DTO 네이밍 통일 (행동 먼저: `RegisterMemberRequest`) + +### Phase 3: 테스트 코드 수정 + +- 도메인 단위 테스트 보강 (domain/src/test/) +- 기존 Service 테스트 정리 (mock capture 방식 개선) +- 테스트 계층 명확화 (단위/통합/E2E 분리) + +--- + +## 4. 완료 기준 + +- [ ] `./gradlew clean build` 전체 통과 +- [ ] 5계층 모듈 구조 (domain, application, presentation, modules, supports) +- [ ] 모든 예외가 CoreException 기반으로 통일 (Phase 2) +- [ ] MemberTest 도메인 단위 테스트가 domain 모듈에서 실행되며 통과 +- [ ] 기존 모든 테스트 통과 +- [ ] 패키지 구조가 `interfaces/api/` 컨벤션에 맞춤 + +--- + +## 5. 작업 제외 사항 (이번 범위 밖) + +- Brand, Product, ProductLike, Order 등 신규 도메인 구현 +- VO(@Embeddable) 도입은 신규 도메인에서 적용 +- Facade 패턴 도입 (신규 도메인 간 의존 해소 시 적용) +- supports 모듈 의존성 중복 정리 (별도 작업) diff --git a/docs/thought/architecture-discussion-log.md b/docs/thought/architecture-discussion-log.md new file mode 100644 index 000000000..3177a3bcf --- /dev/null +++ b/docs/thought/architecture-discussion-log.md @@ -0,0 +1,205 @@ +# 아키텍처 논의 기록 + +> 작성일: 2026-02-21 +> 참여: 개발자, AI (Claude Code) +> 맥락: TDD + DDD 기반 커머스 프로젝트 리팩토링 전 논의 + +--- + +## 1. 아키텍처 분석에서 발견된 문제 + +### 분석 요약 + +현재 프로젝트는 문서(요구사항, 클래스 다이어그램, ERD)는 잘 정의되어 있지만, +실제 구현은 Member CRUD만 존재하며, 구현된 코드에도 구조적 문제가 있음. + +### 핵심 문제 3가지 + +1. **domain 모듈 부재**: 도메인 코드(Member, Policy, PasswordEncryptor)가 인프라 설정 모듈(`modules/jpa`)에 위치 +2. **예외 처리 붕괴**: 모든 `IllegalArgumentException`이 401 UNAUTHORIZED로 반환 +3. **도메인 테스트 부재**: `MemberTest`(도메인 단위 테스트)가 없고, Service mock 테스트만 존재 + +--- + +## 2. 멘토 피드백 반영 + +### 피드백 1: MemberPolicy 중앙화의 문제 + +> "MemberPolicy에 들어갈 로직 자체가 Name 이랑 코드위치상 거리가 멀어짐에 따라 +> 찾아가야하는 문제가 생깁니다. 더불어, MemberPolicy 하나에서 관리되고 있으므로, +> 변경사항이 발생하면 변경된 곳의 위치를 찾고 수정함에 있어서 불편함을 초래할 수 있습니다." + +**논의 결과:** +- 별도 Policy 클래스 대신, 도메인 객체가 자기 규칙을 가지는 방향으로 전환 +- VO가 자체 규칙을 가지면 해당 VO만 보면 되므로 응집도 향상 +- 다만 VO 도입은 신규 도메인(Stock, Price, Quantity)에 우선 적용하고, + 기존 Member는 엔티티 내부 검증으로 최소 변경 + +### 피드백 2: 도메인 테스트 부재 + +> "MemberTest가 없습니다. 즉 도메인 테스트 코드가 없습니다." +> "spy를 사용하는 것이 적절한지 확인해볼 필요가 있습니다." + +**논의 결과:** +- 도메인 단위 테스트를 최우선으로 작성 +- Service 테스트에서 mock capture로 도메인 규칙을 검증하는 방식 지양 +- 도메인 규칙은 도메인 테스트에서, Service는 조합/트랜잭션 검증에 집중 + +### 피드백 3: 도메인 코드 위치 + +> "jpa 가 modules에 들어가져 있는데요. 여기서 jpa 는 reusable configuration이기 때문에 +> 적절하지 않습니다. 도메인 코드는 app 쪽에 있어야 합니다." + +**논의 결과:** +- 별도 `domain/` 모듈을 루트 레벨에 신설 +- `modules/jpa`는 인프라 설정 + Repository 구현체만 담당 +- DIP: domain이 Repository 인터페이스를 정의하고, modules/jpa가 구현 + +--- + +## 3. 주요 설계 결정 + +### 3-1. domain 모듈 위치: 루트 레벨 vs modules 하위 + +| 선택지 | 장점 | 단점 | +|--------|------|------| +| `modules/domain/` | modules 안에서 관리 일원화 | modules의 성격(인프라 설정)과 안 맞음 | +| **`domain/` (루트)** | 직관적, 계층 구분 명확 | 기존 3분류(apps/modules/supports) 깨짐 | + +**결정: 루트 레벨 `domain/`** + +이유: `modules/`는 jpa, redis, kafka 등 "외부 시스템 연결 어댑터"의 성격. +도메인은 이와 본질적으로 다르므로 분리하는 것이 의미적으로 명확함. + +### 3-2. JPA 어노테이션: 실용적 vs 순수주의 + +| 선택지 | domain에 JPA 어노테이션 | 장점 | 단점 | +|--------|------------------------|------|------| +| **실용적 (선택)** | `@Entity`, `@Embeddable` 허용 | 코드 간결, 매핑 클래스 불필요 | domain이 `jakarta.persistence` API에 의존 | +| 순수주의 | 순수 POJO만 | 완전한 프레임워크 독립 | JPA 매핑 레이어 별도 필요, 코드량 증가 | + +**결정: 실용적 방향** + +이유: +- 현재 프로젝트가 이미 `BaseEntity`에서 `@MappedSuperclass`, `@PrePersist` 등 사용 중 +- 순수주의 적용 시 매핑 레이어 추가로 복잡도 증가, 현 단계에서 불필요 +- `jakarta.persistence-api`는 인터페이스 수준이므로 Hibernate 구현에 직접 의존하지 않음 + +### 3-3. VO 전략: @Embeddable 사용 + +| 대상 | JPA 매핑 | 자체 규칙 | +|------|---------|----------| +| Stock | `@Embeddable` → product.stock INT | value >= 0, isEnough(), decrease() | +| Price | `@Embeddable` → product.price INT | value > 0 | +| Quantity | `@Embeddable` → order_line_snapshot.quantity INT | value > 0 | + +**VO 도입 기준:** 자체 규칙(invariant)이 있는 필드만 VO로 승격. +규칙 없이 단순 저장만 하는 필드는 primitive 유지. + +### 3-4. 정책 검증 패턴: Policy 분리 vs 객체 내재 + +**결정: 객체에 내재 (co-location)** + +``` +Before: Member → MemberPolicy.Name.validate(name) // 찾아가야 함 +After: Member.register() 내부에서 직접 검증 // 한 곳에서 확인 + 또는 VO가 생성자에서 검증 // 해당 VO만 보면 됨 +``` + +--- + +## 4. "테스트 가능한 코드"에 대한 고민 + +### 핵심 질문 +> "Test 가능한 코드란 무엇인가?" + +### 도출된 3가지 기준 + +#### 기준 1: 비즈니스 규칙이 객체 안에 있는가? +- Service에 if문으로 규칙이 있으면 → mock 테스트 필요 (테스트 어려움) +- 도메인 객체가 자기 규칙을 가지면 → new로 생성해서 바로 검증 (테스트 쉬움) +- **예시:** `new Stock(10).decrease(new Quantity(3))` → Mock 없이 순수 자바로 검증 가능 + +#### 기준 2: 외부 의존이 주입 가능한가? +- 객체가 직접 외부를 호출하면 → 테스트 시 그 외부를 통째로 구성해야 함 +- 인터페이스로 받아서 사용하면 → Fake 구현으로 대체 가능 +- **예시:** `ProductRepository` 인터페이스를 domain에 정의 → 테스트 시 `FakeProductRepository` 주입 + +#### 기준 3: 부수효과(side effect)가 분리되어 있는가? +- 하나의 메서드에서 검증 + 저장 + 이벤트 발행 → 전부 필요해서 테스트 무거움 +- 순수 로직(도메인)과 부수효과(Service)가 분리 → 각각 적절한 수준으로 테스트 + +### 테스트 피라미드 적용 + +``` + / E2E \ ← 적고 느림 (Spring Context + DB) + / 통합 \ ← 적당 (Service + Repository) + / 단위 \ ← 많고 빠름 (순수 도메인 객체) +``` + +도메인에 규칙이 내재되어 있으면 → 피라미드 하단(단위 테스트)이 두꺼워짐 +Service에 규칙이 있으면 → 피라미드가 뒤집혀서 통합/E2E에 의존 + +### 현재 코드의 문제 + +```java +// 현재: Service에서 mock capture로 간접 검증 +verify(memberRepository).save(memberCaptor.capture()); +assertThat(memberCaptor.getValue().getLoginId()).isEqualTo(request.loginId()); +// → 구현 세부사항에 결합, Member 도메인 규칙 자체를 검증하지 않음 + +// 목표: 도메인 객체를 직접 테스트 +Member member = Member.register("testId", "Password1!", "홍길동", + LocalDate.of(1990, 1, 1), "test@test.com"); +assertThat(member.isSamePassword("Password1!")).isTrue(); +// → Mock 없음, DB 없음, Spring 없음. 규칙만 검증. +``` + +--- + +## 5. 의존 방향 정리 (DIP 적용) + +``` +┌─────────────────────────────────────────────┐ +│ apps/commerce-api (최상위 — 조합 + 설정) │ +│ - Controller, Service(Facade) │ +│ - 의존: domain, modules/jpa, supports/* │ +└───────────┬─────────────────────┬────────────┘ + │ │ + ▼ ▼ +┌───────────────────┐ ┌─────────────────────┐ +│ modules/jpa │ │ supports/* │ +│ (인프라 어댑터) │ │ (횡단 관심사) │ +│ - Repository 구현 │ │ - Jackson, Logging │ +│ - DataSource 설정 │ │ - Monitoring │ +│ - 의존: domain │ │ - 의존: 없음 │ +└────────┬──────────┘ └─────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────┐ +│ domain/ (최하위 — 순수 비즈니스 규칙) │ +│ - Entity, VO, Repository 인터페이스 │ +│ - 도메인 정책, 예외 │ +│ - 의존: 없음 (jakarta.persistence API만) │ +└─────────────────────────────────────────────┘ +``` + +**핵심 원칙:** 화살표는 항상 아래(domain)를 향한다. +domain은 상위 계층의 존재를 모른다. + +--- + +## 6. 리팩토링 진행 순서 + +``` +Phase 1: 구조 변경 + → domain 모듈 생성, 코드 이동, 의존 방향 설정, 패키지 통일 + +Phase 2: 모델링 및 설계 변경 + → BaseTimeEntity 분리, MemberPolicy 제거(규칙 내재화), 예외 체계 통일 + +Phase 3: 테스트 코드 수정 + → 도메인 단위 테스트 작성, 기존 Service 테스트 정리, 테스트 계층 명확화 +``` + +각 Phase 완료 후 `./gradlew test` 통과를 확인하며 점진적으로 진행. From 640a567bb0cf1d37925dd40a80303bd0a7fa0259 Mon Sep 17 00:00:00 2001 From: APapeIsName Date: Thu, 26 Feb 2026 22:40:38 +0900 Subject: [PATCH 3/8] =?UTF-8?q?refactor:=20=EC=84=A4=EA=B3=84=20=EB=B0=8F?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=20=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 --- .gitignore | 3 + application/commerce-service/build.gradle.kts | 13 + .../application/example/ExampleFacade.java | 17 + .../application/example/ExampleInfo.java | 13 + .../application/service/MemberService.java | 69 ++ .../application/service/dto/MemberInfo.java | 15 + .../service/dto/RegisterMemberCommand.java | 12 + .../service/dto/UpdatePasswordCommand.java | 6 + .../loopers/domain/example/ExampleModel.java | 44 + .../domain/example/ExampleRepository.java | 7 + .../domain/example/ExampleService.java | 20 + .../application/MemberServiceTest.java | 126 +++ .../domain/example/ExampleModelTest.java | 64 ++ build.gradle.kts | 16 +- docs/analysis/class-diagram-analysis.md | 2 + docs/analysis/erd-analysis.md | 2 + .../prompt-design-process-analysis.md | 2 + .../requirements-gathering-analysis.md | 2 + docs/analysis/sequence-diagram-analysis.md | 2 + docs/design/02-sequence-diagrams.md | 85 +- docs/design/03-class-diagram.md | 58 +- docs/design/04-erd.md | 105 ++- docs/design/05-domain-model.md | 226 +++++ docs/design/06-architecture.md | 800 ++++++++++++++++++ docs/design/base/class-diagram-erd.md | 2 + docs/design/base/domain-definition-v2.md | 2 + docs/design/base/member-class-diagram.md | 2 + docs/design/base/requirements-input.md | 2 + docs/design/base/sequence-diagrams.md | 2 + docs/design/base/ubiquitous-language.md | 2 + docs/design/base/user-story.md | 2 + docs/planning/brand-plan.md | 311 +++++++ docs/planning/phase1-structure-refactoring.md | 59 +- docs/planning/product-plan.md | 576 +++++++++++++ docs/planning/refactoring-plan.md | 272 ++---- docs/temp/architecture-discussion-log.md | 4 +- docs/temp/refactoring-plan.md | 118 --- docs/thought/architecture-direction-v2.md | 408 +++++++++ docs/thought/architecture-discussion-log.md | 13 + docs/thought/phase1-implementation-log.md | 331 ++++++++ docs/thought/phase1-step7-troubleshooting.md | 660 +++++++++++++++ docs/thought/phase2-discussion-log.md | 228 +++++ docs/thought/phase2-implementation-log.md | 415 +++++++++ docs/thought/phase3-implementation-log.md | 243 ++++++ docs/thought/volume3-discussion-log.md | 691 +++++++++++++++ domain/build.gradle.kts | 9 + .../java/com/loopers/domain/BaseEntity.java | 36 + .../com/loopers/domain/BaseTimeEntity.java | 48 ++ .../com/loopers/domain/member/Member.java | 86 ++ .../domain/member/MemberExceptionMessage.java | 0 .../domain/member/MemberRepository.java | 12 + .../domain/member/PasswordEncryptor.java | 8 + .../domain/member/policy/MemberPolicy.java | 0 .../com/loopers/domain/member/vo/Email.java | 54 ++ .../com/loopers/domain/member/vo/LoginId.java | 54 ++ .../loopers/domain/member/vo/MemberId.java | 38 + .../loopers/domain/member/vo/MemberName.java | 54 ++ .../loopers/domain/member/vo/Password.java | 82 ++ .../com/loopers/domain/member/MemberTest.java | 87 ++ .../loopers/domain/member/vo/EmailTest.java | 45 + .../loopers/domain/member/vo/LoginIdTest.java | 81 ++ .../domain/member/vo/MemberIdTest.java | 52 ++ .../domain/member/vo/MemberNameTest.java | 69 ++ .../domain/member/vo/PasswordTest.java | 88 ++ .../support/error/CoreExceptionTest.java | 34 + .../domain/member/FakePasswordEncryptor.java | 15 + .../loopers/domain/member/MemberFixture.java | 43 + .../jpa/build.gradle.kts | 2 + .../loopers/config/jpa/DataSourceConfig.java | 0 .../com/loopers/config/jpa/JpaConfig.java | 0 .../loopers/config/jpa/QueryDslConfig.java | 0 .../member/MemberJpaRepository.java | 13 + .../member/MemberRepositoryImpl.java | 30 + .../jpa/src/main/resources/jpa.yml | 0 .../MySqlTestContainersConfig.java | 0 .../com/loopers/utils/DatabaseCleanUp.java | 0 .../kafka/build.gradle.kts | 0 .../loopers/config}/kafka/KafkaConfig.java | 2 +- .../kafka/src/main/resources/kafka.yml | 0 .../redis/build.gradle.kts | 0 .../com/loopers/config/redis/RedisConfig.java | 0 .../loopers/config/redis/RedisNodeInfo.java | 0 .../loopers/config/redis/RedisProperties.java | 0 .../redis/src/main/resources/redis.yml | 0 .../RedisTestContainersConfig.java | 0 .../java/com/loopers/utils/RedisCleanUp.java | 0 infrastructure/security/build.gradle.kts | 8 + .../security/BCryptPasswordEncryptor.java | 21 + .../java/com/loopers/domain/BaseEntity.java | 73 -- .../com/loopers/domain/member/Member.java | 74 -- .../com/loopers/utils/PasswordEncryptor.java | 38 - .../domain/member/MemberTest.java | 520 ------------ presentation/commerce-api/build.gradle.kts | 28 + .../com/loopers/CommerceApiApplication.java | 22 + .../example/ExampleJpaRepository.java | 6 + .../example/ExampleRepositoryImpl.java | 19 + .../interfaces/api/ApiControllerAdvice.java | 138 +++ .../loopers/interfaces/api/ApiResponse.java | 32 + .../api/example/ExampleV1ApiSpec.java | 19 + .../api/example/ExampleV1Controller.java | 28 + .../interfaces/api/example/ExampleV1Dto.java | 15 + .../api/member/MemberController.java | 41 + .../api/member/dto/MemberApiResponse.java | 32 + .../src/main/resources/application.yml | 58 ++ .../com/loopers/CommerceApiContextTest.java | 14 + .../MemberServiceIntegrationTest.java | 184 ++++ .../com/loopers/controller/MemberE2ETest.java | 123 +++ .../ExampleServiceIntegrationTest.java | 72 ++ .../interfaces/api/ExampleV1ApiE2ETest.java | 114 +++ presentation/commerce-batch/build.gradle.kts | 23 + .../com/loopers/CommerceBatchApplication.java | 24 + .../loopers/batch/job/demo/DemoJobConfig.java | 48 ++ .../batch/job/demo/step/DemoTasklet.java | 32 + .../loopers/batch/listener/ChunkListener.java | 21 + .../loopers/batch/listener/JobListener.java | 53 ++ .../batch/listener/StepMonitorListener.java | 44 + .../src/main/resources/application.yml | 54 ++ .../loopers/CommerceBatchApplicationTest.java | 10 + .../com/loopers/job/demo/DemoJobE2ETest.java | 76 ++ .../commerce-streamer/build.gradle.kts | 25 + .../loopers/CommerceStreamerApplication.java | 24 + .../consumer/DemoKafkaConsumer.java | 24 + .../src/main/resources/application.yml | 58 ++ settings.gradle.kts | 16 +- supports/error/build.gradle.kts | 5 + .../loopers/support/error/CoreException.java | 19 + .../com/loopers/support/error/ErrorType.java | 17 + 127 files changed, 8211 insertions(+), 1135 deletions(-) create mode 100644 application/commerce-service/build.gradle.kts create mode 100644 application/commerce-service/src/main/java/com/loopers/application/example/ExampleFacade.java create mode 100644 application/commerce-service/src/main/java/com/loopers/application/example/ExampleInfo.java create mode 100644 application/commerce-service/src/main/java/com/loopers/application/service/MemberService.java create mode 100644 application/commerce-service/src/main/java/com/loopers/application/service/dto/MemberInfo.java create mode 100644 application/commerce-service/src/main/java/com/loopers/application/service/dto/RegisterMemberCommand.java create mode 100644 application/commerce-service/src/main/java/com/loopers/application/service/dto/UpdatePasswordCommand.java create mode 100644 application/commerce-service/src/main/java/com/loopers/domain/example/ExampleModel.java create mode 100644 application/commerce-service/src/main/java/com/loopers/domain/example/ExampleRepository.java create mode 100644 application/commerce-service/src/main/java/com/loopers/domain/example/ExampleService.java create mode 100644 application/commerce-service/src/test/java/com/loopers/application/MemberServiceTest.java create mode 100644 application/commerce-service/src/test/java/com/loopers/domain/example/ExampleModelTest.java create mode 100644 docs/design/05-domain-model.md create mode 100644 docs/design/06-architecture.md create mode 100644 docs/planning/brand-plan.md create mode 100644 docs/planning/product-plan.md delete mode 100644 docs/temp/refactoring-plan.md create mode 100644 docs/thought/architecture-direction-v2.md create mode 100644 docs/thought/phase1-implementation-log.md create mode 100644 docs/thought/phase1-step7-troubleshooting.md create mode 100644 docs/thought/phase2-discussion-log.md create mode 100644 docs/thought/phase2-implementation-log.md create mode 100644 docs/thought/phase3-implementation-log.md create mode 100644 docs/thought/volume3-discussion-log.md create mode 100644 domain/build.gradle.kts create mode 100644 domain/src/main/java/com/loopers/domain/BaseEntity.java create mode 100644 domain/src/main/java/com/loopers/domain/BaseTimeEntity.java create mode 100644 domain/src/main/java/com/loopers/domain/member/Member.java rename {modules/jpa => domain}/src/main/java/com/loopers/domain/member/MemberExceptionMessage.java (100%) create mode 100644 domain/src/main/java/com/loopers/domain/member/MemberRepository.java create mode 100644 domain/src/main/java/com/loopers/domain/member/PasswordEncryptor.java rename {modules/jpa => domain}/src/main/java/com/loopers/domain/member/policy/MemberPolicy.java (100%) create mode 100644 domain/src/main/java/com/loopers/domain/member/vo/Email.java create mode 100644 domain/src/main/java/com/loopers/domain/member/vo/LoginId.java create mode 100644 domain/src/main/java/com/loopers/domain/member/vo/MemberId.java create mode 100644 domain/src/main/java/com/loopers/domain/member/vo/MemberName.java create mode 100644 domain/src/main/java/com/loopers/domain/member/vo/Password.java create mode 100644 domain/src/test/java/com/loopers/domain/member/MemberTest.java create mode 100644 domain/src/test/java/com/loopers/domain/member/vo/EmailTest.java create mode 100644 domain/src/test/java/com/loopers/domain/member/vo/LoginIdTest.java create mode 100644 domain/src/test/java/com/loopers/domain/member/vo/MemberIdTest.java create mode 100644 domain/src/test/java/com/loopers/domain/member/vo/MemberNameTest.java create mode 100644 domain/src/test/java/com/loopers/domain/member/vo/PasswordTest.java create mode 100644 domain/src/test/java/com/loopers/support/error/CoreExceptionTest.java create mode 100644 domain/src/testFixtures/java/com/loopers/domain/member/FakePasswordEncryptor.java create mode 100644 domain/src/testFixtures/java/com/loopers/domain/member/MemberFixture.java rename {modules => infrastructure}/jpa/build.gradle.kts (95%) rename {modules => infrastructure}/jpa/src/main/java/com/loopers/config/jpa/DataSourceConfig.java (100%) rename {modules => infrastructure}/jpa/src/main/java/com/loopers/config/jpa/JpaConfig.java (100%) rename {modules => infrastructure}/jpa/src/main/java/com/loopers/config/jpa/QueryDslConfig.java (100%) create mode 100644 infrastructure/jpa/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java create mode 100644 infrastructure/jpa/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java rename {modules => infrastructure}/jpa/src/main/resources/jpa.yml (100%) rename {modules => infrastructure}/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java (100%) rename {modules => infrastructure}/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java (100%) rename {modules => infrastructure}/kafka/build.gradle.kts (100%) rename {modules/kafka/src/main/java/com/loopers/confg => infrastructure/kafka/src/main/java/com/loopers/config}/kafka/KafkaConfig.java (99%) rename {modules => infrastructure}/kafka/src/main/resources/kafka.yml (100%) rename {modules => infrastructure}/redis/build.gradle.kts (100%) rename {modules => infrastructure}/redis/src/main/java/com/loopers/config/redis/RedisConfig.java (100%) rename {modules => infrastructure}/redis/src/main/java/com/loopers/config/redis/RedisNodeInfo.java (100%) rename {modules => infrastructure}/redis/src/main/java/com/loopers/config/redis/RedisProperties.java (100%) rename {modules => infrastructure}/redis/src/main/resources/redis.yml (100%) rename {modules => infrastructure}/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java (100%) rename {modules => infrastructure}/redis/src/testFixtures/java/com/loopers/utils/RedisCleanUp.java (100%) create mode 100644 infrastructure/security/build.gradle.kts create mode 100644 infrastructure/security/src/main/java/com/loopers/infrastructure/security/BCryptPasswordEncryptor.java delete mode 100644 modules/jpa/src/main/java/com/loopers/domain/BaseEntity.java delete mode 100644 modules/jpa/src/main/java/com/loopers/domain/member/Member.java delete mode 100644 modules/jpa/src/main/java/com/loopers/utils/PasswordEncryptor.java delete mode 100644 modules/jpa/src/testFixtures/java/com/loopers/testcontainers/domain/member/MemberTest.java create mode 100644 presentation/commerce-api/build.gradle.kts create mode 100644 presentation/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java create mode 100644 presentation/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java create mode 100644 presentation/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java create mode 100644 presentation/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java create mode 100644 presentation/commerce-api/src/main/java/com/loopers/interfaces/api/ApiResponse.java create mode 100644 presentation/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java create mode 100644 presentation/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java create mode 100644 presentation/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java create mode 100644 presentation/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberController.java create mode 100644 presentation/commerce-api/src/main/java/com/loopers/interfaces/api/member/dto/MemberApiResponse.java create mode 100644 presentation/commerce-api/src/main/resources/application.yml create mode 100644 presentation/commerce-api/src/test/java/com/loopers/CommerceApiContextTest.java create mode 100644 presentation/commerce-api/src/test/java/com/loopers/application/MemberServiceIntegrationTest.java create mode 100644 presentation/commerce-api/src/test/java/com/loopers/controller/MemberE2ETest.java create mode 100644 presentation/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java create mode 100644 presentation/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java create mode 100644 presentation/commerce-batch/build.gradle.kts create mode 100644 presentation/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java create mode 100644 presentation/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java create mode 100644 presentation/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java create mode 100644 presentation/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java create mode 100644 presentation/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java create mode 100644 presentation/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java create mode 100644 presentation/commerce-batch/src/main/resources/application.yml create mode 100644 presentation/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java create mode 100644 presentation/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java create mode 100644 presentation/commerce-streamer/build.gradle.kts create mode 100644 presentation/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java create mode 100644 presentation/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DemoKafkaConsumer.java create mode 100644 presentation/commerce-streamer/src/main/resources/application.yml create mode 100644 supports/error/build.gradle.kts create mode 100644 supports/error/src/main/java/com/loopers/support/error/CoreException.java create mode 100644 supports/error/src/main/java/com/loopers/support/error/ErrorType.java diff --git a/.gitignore b/.gitignore index df3d44c1a..ae37b1d34 100644 --- a/.gitignore +++ b/.gitignore @@ -39,5 +39,8 @@ out/ ### Kotlin ### .kotlin +### OS ### +.DS_Store + /.claude CLAUDE.md \ No newline at end of file diff --git a/application/commerce-service/build.gradle.kts b/application/commerce-service/build.gradle.kts new file mode 100644 index 000000000..bd8f34a31 --- /dev/null +++ b/application/commerce-service/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + `java-library` +} + +dependencies { + api(project(":domain")) + + // @Service, @Transactional + implementation("org.springframework:spring-tx") + implementation("org.springframework:spring-context") + + testImplementation(testFixtures(project(":domain"))) +} diff --git a/application/commerce-service/src/main/java/com/loopers/application/example/ExampleFacade.java b/application/commerce-service/src/main/java/com/loopers/application/example/ExampleFacade.java new file mode 100644 index 000000000..552a9ad62 --- /dev/null +++ b/application/commerce-service/src/main/java/com/loopers/application/example/ExampleFacade.java @@ -0,0 +1,17 @@ +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/application/commerce-service/src/main/java/com/loopers/application/example/ExampleInfo.java b/application/commerce-service/src/main/java/com/loopers/application/example/ExampleInfo.java new file mode 100644 index 000000000..877aba96c --- /dev/null +++ b/application/commerce-service/src/main/java/com/loopers/application/example/ExampleInfo.java @@ -0,0 +1,13 @@ +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/application/commerce-service/src/main/java/com/loopers/application/service/MemberService.java b/application/commerce-service/src/main/java/com/loopers/application/service/MemberService.java new file mode 100644 index 000000000..7393ad344 --- /dev/null +++ b/application/commerce-service/src/main/java/com/loopers/application/service/MemberService.java @@ -0,0 +1,69 @@ +package com.loopers.application.service; + +import com.loopers.application.service.dto.RegisterMemberCommand; +import com.loopers.application.service.dto.MemberInfo; +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberExceptionMessage; +import com.loopers.domain.member.MemberRepository; +import com.loopers.domain.member.PasswordEncryptor; +import com.loopers.domain.member.vo.*; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class MemberService { + + private final MemberRepository memberRepository; + private final PasswordEncryptor passwordEncryptor; + + @Transactional + public void register(RegisterMemberCommand request) { + boolean isLoginIdAlreadyExists = memberRepository.existsByLoginId(request.loginId()); + + if (isLoginIdAlreadyExists) { + throw new CoreException(ErrorType.CONFLICT, MemberExceptionMessage.LoginId.DUPLICATE_ID_EXISTS.message()); + } + + Member member = Member.register( + LoginId.of(request.loginId()), + Password.of(request.password(), request.birthdate(), passwordEncryptor), + MemberName.of(request.name()), + request.birthdate(), + Email.of(request.email()) + ); + memberRepository.save(member); + } + + @Transactional(readOnly = true) + public MemberInfo getMyInfo(String userId, String password) { + Member member = memberRepository.findByLoginId(userId) + .orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED, MemberExceptionMessage.ExistsMember.CANNOT_LOGIN.message())); + + if (!member.getPassword().matches(password, passwordEncryptor)) { + throw new CoreException(ErrorType.UNAUTHORIZED, MemberExceptionMessage.ExistsMember.CANNOT_LOGIN.message()); + } + + return new MemberInfo( + member.getLoginId(), + member.getName(), + member.getBirthDate(), + member.getEmail() + ); + } + + @Transactional + public void updatePassword(String userId, String currentPassword, String newPassword) { + Member member = memberRepository.findByLoginId(userId) + .orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED, MemberExceptionMessage.ExistsMember.CANNOT_LOGIN.message())); + + if (!member.getPassword().matches(currentPassword, passwordEncryptor)) { + throw new CoreException(ErrorType.UNAUTHORIZED, MemberExceptionMessage.Password.PASSWORD_INCORRECT.message()); + } + + member.updatePassword(newPassword, passwordEncryptor); + } +} diff --git a/application/commerce-service/src/main/java/com/loopers/application/service/dto/MemberInfo.java b/application/commerce-service/src/main/java/com/loopers/application/service/dto/MemberInfo.java new file mode 100644 index 000000000..a7cda40c8 --- /dev/null +++ b/application/commerce-service/src/main/java/com/loopers/application/service/dto/MemberInfo.java @@ -0,0 +1,15 @@ +package com.loopers.application.service.dto; + +import com.loopers.domain.member.vo.Email; +import com.loopers.domain.member.vo.LoginId; +import com.loopers.domain.member.vo.MemberName; + +import java.time.LocalDate; + +public record MemberInfo( + LoginId loginId, + MemberName name, + LocalDate birthdate, + Email email +) { +} diff --git a/application/commerce-service/src/main/java/com/loopers/application/service/dto/RegisterMemberCommand.java b/application/commerce-service/src/main/java/com/loopers/application/service/dto/RegisterMemberCommand.java new file mode 100644 index 000000000..6cf98b7b5 --- /dev/null +++ b/application/commerce-service/src/main/java/com/loopers/application/service/dto/RegisterMemberCommand.java @@ -0,0 +1,12 @@ +package com.loopers.application.service.dto; + +import java.time.LocalDate; + +public record RegisterMemberCommand( + String loginId, + String password, + String name, + LocalDate birthdate, + String email +) { +} diff --git a/application/commerce-service/src/main/java/com/loopers/application/service/dto/UpdatePasswordCommand.java b/application/commerce-service/src/main/java/com/loopers/application/service/dto/UpdatePasswordCommand.java new file mode 100644 index 000000000..6a57fabac --- /dev/null +++ b/application/commerce-service/src/main/java/com/loopers/application/service/dto/UpdatePasswordCommand.java @@ -0,0 +1,6 @@ +package com.loopers.application.service.dto; + +public record UpdatePasswordCommand( + String newPassword +) { +} diff --git a/application/commerce-service/src/main/java/com/loopers/domain/example/ExampleModel.java b/application/commerce-service/src/main/java/com/loopers/domain/example/ExampleModel.java new file mode 100644 index 000000000..c588c4a8a --- /dev/null +++ b/application/commerce-service/src/main/java/com/loopers/domain/example/ExampleModel.java @@ -0,0 +1,44 @@ +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/application/commerce-service/src/main/java/com/loopers/domain/example/ExampleRepository.java b/application/commerce-service/src/main/java/com/loopers/domain/example/ExampleRepository.java new file mode 100644 index 000000000..3625e5662 --- /dev/null +++ b/application/commerce-service/src/main/java/com/loopers/domain/example/ExampleRepository.java @@ -0,0 +1,7 @@ +package com.loopers.domain.example; + +import java.util.Optional; + +public interface ExampleRepository { + Optional find(Long id); +} diff --git a/application/commerce-service/src/main/java/com/loopers/domain/example/ExampleService.java b/application/commerce-service/src/main/java/com/loopers/domain/example/ExampleService.java new file mode 100644 index 000000000..c0e8431e8 --- /dev/null +++ b/application/commerce-service/src/main/java/com/loopers/domain/example/ExampleService.java @@ -0,0 +1,20 @@ +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/application/commerce-service/src/test/java/com/loopers/application/MemberServiceTest.java b/application/commerce-service/src/test/java/com/loopers/application/MemberServiceTest.java new file mode 100644 index 000000000..aa795e743 --- /dev/null +++ b/application/commerce-service/src/test/java/com/loopers/application/MemberServiceTest.java @@ -0,0 +1,126 @@ +package com.loopers.application; + +import com.loopers.application.service.MemberService; +import com.loopers.application.service.dto.RegisterMemberCommand; +import com.loopers.application.service.dto.MemberInfo; +import com.loopers.domain.member.*; +import com.loopers.domain.member.vo.LoginId; +import com.loopers.domain.member.vo.MemberName; +import com.loopers.domain.member.vo.Password; +import com.loopers.domain.member.PasswordEncryptor; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class MemberServiceTest { + + @InjectMocks + private MemberService memberService; + + @Mock + private MemberRepository memberRepository; + + @Spy + private PasswordEncryptor passwordEncryptor = new FakePasswordEncryptor(); + + @Test + void 회원가입_시_아이디_중복_불가() { + // given + String inputId = "apape123"; + RegisterMemberCommand request = new RegisterMemberCommand( + inputId, "password123!", "공명선", LocalDate.of(2001, 2, 9), "gms72901217@gmail.com"); + when(memberRepository.existsByLoginId(inputId)).thenReturn(true); + + // when + + // then + assertThatThrownBy(() -> memberService.register(request)) + .hasMessage(MemberExceptionMessage.LoginId.DUPLICATE_ID_EXISTS.message()); + } + + @Test + void 회원가입_성공_시_저장된다() { + // given + String inputId = "newId123"; + RegisterMemberCommand request = new RegisterMemberCommand( + inputId, "password123!", "공명선", LocalDate.of(2001, 2, 9), "gms72901217@gmail.com"); + when(memberRepository.existsByLoginId(inputId)).thenReturn(false); + + // when + memberService.register(request); + + // then + verify(memberRepository).save(any(Member.class)); + } + + @Test + void 존재하지_않는_회원_조회_시_예외_발생() { + // given + String dummyId = "unknownId"; + String dummyPwd = "password123!"; + given(memberRepository.findByLoginId(dummyId)).willReturn(Optional.empty()); + + // when + + // then + assertThatThrownBy(() -> memberService.getMyInfo(dummyId, dummyPwd)) + .hasMessage(MemberExceptionMessage.ExistsMember.CANNOT_LOGIN.message()); + } + + @Test + void 내_정보_조회_성공_loginId_반환() { + // given + String loginId = "apape123"; + String password = MemberFixture.DEFAULT_RAW_PASSWORD; + Member member = MemberFixture.create(LoginId.of(loginId)); + given(memberRepository.findByLoginId(loginId)).willReturn(Optional.of(member)); + + // when + MemberInfo response = memberService.getMyInfo(loginId, password); + + // then + assertThat(response.loginId()).isEqualTo(LoginId.of(loginId)); + } + + @Test + void 내_정보_조회_성공_name_반환() { + // given + String loginId = "apape123"; + String password = MemberFixture.DEFAULT_RAW_PASSWORD; + Member member = MemberFixture.create(LoginId.of(loginId)); + given(memberRepository.findByLoginId(loginId)).willReturn(Optional.of(member)); + + // when + MemberInfo response = memberService.getMyInfo(loginId, password); + + // then + assertThat(response.name()).isEqualTo(MemberName.of("홍길동")); + } + + @Test + void 현재_비밀번호가_틀리면_수정_불가() { + // given + String loginId = "tester12"; + String correctPassword = "correctPw1!"; + Member member = MemberFixture.create( + LoginId.of(loginId), + Password.of(correctPassword, MemberFixture.DEFAULT_BIRTH_DATE, new FakePasswordEncryptor()) + ); + given(memberRepository.findByLoginId(loginId)).willReturn(Optional.of(member)); + + // when & then + assertThatThrownBy(() -> memberService.updatePassword(loginId, "wrongPassword1!", "newPass123!")) + .hasMessage(MemberExceptionMessage.Password.PASSWORD_INCORRECT.message()); + } +} diff --git a/application/commerce-service/src/test/java/com/loopers/domain/example/ExampleModelTest.java b/application/commerce-service/src/test/java/com/loopers/domain/example/ExampleModelTest.java new file mode 100644 index 000000000..94e9e7c8d --- /dev/null +++ b/application/commerce-service/src/test/java/com/loopers/domain/example/ExampleModelTest.java @@ -0,0 +1,64 @@ +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.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/build.gradle.kts b/build.gradle.kts index 9c8490b8a..e24a0bd86 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,4 @@ import org.gradle.api.Project.DEFAULT_VERSION -import org.springframework.boot.gradle.tasks.bundling.BootJar /** --- configuration functions --- */ fun getGitHash(): String { @@ -35,12 +34,12 @@ allprojects { subprojects { apply(plugin = "java") - apply(plugin = "org.springframework.boot") apply(plugin = "io.spring.dependency-management") apply(plugin = "jacoco") dependencyManagement { imports { + mavenBom("org.springframework.boot:spring-boot-dependencies:${project.properties["springBootVersion"]}") mavenBom("org.springframework.cloud:spring-cloud-dependencies:${project.properties["springCloudDependenciesVersion"]}") } } @@ -69,14 +68,6 @@ subprojects { testImplementation("org.testcontainers:junit-jupiter") } - tasks.withType(Jar::class) { enabled = true } - tasks.withType(BootJar::class) { enabled = false } - - configure(allprojects.filter { it.parent?.name.equals("apps") }) { - tasks.withType(Jar::class) { enabled = false } - tasks.withType(BootJar::class) { enabled = true } - } - tasks.test { maxParallelForks = 1 useJUnitPlatform() @@ -106,6 +97,7 @@ subprojects { } // module-container 는 task 를 실행하지 않도록 한다. -project("apps") { tasks.configureEach { enabled = false } } -project("modules") { tasks.configureEach { enabled = false } } +project("application") { tasks.configureEach { enabled = false } } +project("presentation") { tasks.configureEach { enabled = false } } +project("infrastructure") { tasks.configureEach { enabled = false } } project("supports") { tasks.configureEach { enabled = false } } diff --git a/docs/analysis/class-diagram-analysis.md b/docs/analysis/class-diagram-analysis.md index e4b0af55a..4ac4cbbb1 100644 --- a/docs/analysis/class-diagram-analysis.md +++ b/docs/analysis/class-diagram-analysis.md @@ -1,5 +1,7 @@ # 클래스 다이어그램 프로세스 분석 +> **ARCHIVE** — 이 문서는 히스토리 참고용입니다. 현재 설계 기준 문서(SoT)는 `docs/design/01~04-*.md`입니다. + > 03-class-diagram.md 작성 과정에서 드러난 설계 철학, 의사결정 방식, 보완이 필요한 영역을 분석한다. > 향후 클래스 다이어그램 작성 시 재사용할 수 있는 규칙(Rule)을 도출하기 위한 문서이다. diff --git a/docs/analysis/erd-analysis.md b/docs/analysis/erd-analysis.md index 97590d930..1649a5ad9 100644 --- a/docs/analysis/erd-analysis.md +++ b/docs/analysis/erd-analysis.md @@ -1,5 +1,7 @@ # ERD 프로세스 분석 +> **ARCHIVE** — 이 문서는 히스토리 참고용입니다. 현재 설계 기준 문서(SoT)는 `docs/design/01~04-*.md`입니다. + > 04-erd.md 작성 과정에서 드러난 ERD 설계 방식, 의사결정 패턴, 보완이 필요한 영역을 분석한다. > 향후 ERD 작성 시 재사용할 수 있는 규칙(Rule)을 도출하기 위한 문서이다. diff --git a/docs/analysis/prompt-design-process-analysis.md b/docs/analysis/prompt-design-process-analysis.md index 25a0a4058..252cc4f27 100644 --- a/docs/analysis/prompt-design-process-analysis.md +++ b/docs/analysis/prompt-design-process-analysis.md @@ -1,5 +1,7 @@ # 설계 프로세스 분석 프롬프트 +> **ARCHIVE** — 이 문서는 히스토리 참고용입니다. 현재 설계 기준 문서(SoT)는 `docs/design/01~04-*.md`입니다. + > 각 설계 산출물(요구사항 정의서, 시퀀스 다이어그램, 클래스 다이어그램 등)을 함께 작성한 뒤, > 그 과정을 분석하여 재사용 가능한 규칙을 도출하기 위한 프롬프트이다. > 아래 프롬프트를 산출물명에 맞게 수정하여 사용한다. diff --git a/docs/analysis/requirements-gathering-analysis.md b/docs/analysis/requirements-gathering-analysis.md index 36ca1b8ee..b54941597 100644 --- a/docs/analysis/requirements-gathering-analysis.md +++ b/docs/analysis/requirements-gathering-analysis.md @@ -1,5 +1,7 @@ # 요구사항 정리 프로세스 분석 +> **ARCHIVE** — 이 문서는 히스토리 참고용입니다. 현재 설계 기준 문서(SoT)는 `docs/design/01~04-*.md`입니다. + > 01-requirements.md 작성 과정에서 드러난 요구사항 정리 패턴, 의사결정 방식, 보완이 필요한 영역을 분석한다. > 향후 요구사항 정리 시 재사용할 수 있는 규칙(Rule)을 도출하기 위한 문서이다. diff --git a/docs/analysis/sequence-diagram-analysis.md b/docs/analysis/sequence-diagram-analysis.md index 5939a2ecf..afbee364f 100644 --- a/docs/analysis/sequence-diagram-analysis.md +++ b/docs/analysis/sequence-diagram-analysis.md @@ -1,5 +1,7 @@ # 시퀀스 다이어그램 프로세스 분석 +> **ARCHIVE** — 이 문서는 히스토리 참고용입니다. 현재 설계 기준 문서(SoT)는 `docs/design/01~04-*.md`입니다. + > 02-sequence-diagrams.md 작성 과정에서 드러난 설계 철학, 의사결정 방식, 보완이 필요한 영역을 분석한다. > 향후 시퀀스 다이어그램 작성 시 재사용할 수 있는 규칙(Rule)을 도출하기 위한 문서이다. diff --git a/docs/design/02-sequence-diagrams.md b/docs/design/02-sequence-diagrams.md index 66ba80f4c..4bc82d513 100644 --- a/docs/design/02-sequence-diagrams.md +++ b/docs/design/02-sequence-diagrams.md @@ -10,8 +10,8 @@ ``` OrderService → ProductService (주문 시 재고 확인/차감) LikeService → ProductService (좋아요 시 상품/브랜드 유효성 확인) -BrandService ↔ ProductService (순환: 브랜드 삭제 연쇄 / 상품 등록 시 브랜드 검증) - → Facade로 해소: AdminBrandFacade, AdminProductFacade +BrandService ↔ ProductService (같은 BC 내 cross-aggregate 규칙) + → CatalogDomainService로 해소 (Domain 레이어, Brand↔Product는 같은 Catalog BC) ``` --- @@ -102,10 +102,10 @@ sequenceDiagram Note over PS: 상품 존재·삭제 여부, 브랜드 삭제 여부 확인 PS-->>LS: Product - LS->>LR: 중복 확인 existsByMemberIdAndProductId(memberId, productId) + LS->>LR: 중복 확인 existsByMemberIdAndSubjectTypeAndSubjectId(memberId, PRODUCT, productId) LR-->>LS: boolean - LS->>LR: 좋아요 저장 save(newLike) + LS->>LR: 좋아요 저장 save(Like.of(memberId, PRODUCT, productId)) LS-->>C: 등록 완료 C-->>M: 201 Created @@ -113,7 +113,7 @@ sequenceDiagram #### 읽는 포인트 - **LikeService**: 등록 흐름 조율. 상품 유효성은 ProductService에 위임하여, 상품/브랜드 상태를 직접 알 필요가 없다. -- **LikeRepository**: 중복 확인과 저장의 책임. hard-delete 방식이므로 취소 이력 없이 단순하게 존재 여부만 확인한다. +- **LikeRepository**: 중복 확인과 저장의 책임. hard-delete 방식이므로 취소 이력 없이 단순하게 존재 여부만 확인한다. Like는 `subjectType(PRODUCT) + subjectId`로 대상을 식별한다. - 이미 좋아요가 있으면 LikeService가 예외를 발생시킨다. --- @@ -130,10 +130,10 @@ sequenceDiagram M->>C: DELETE /api/v1/products/{productId}/likes C->>LS: 좋아요 취소 cancelLike(memberId, productId) - LS->>LR: 좋아요 조회 findByMemberIdAndProductId(memberId, productId) - LR-->>LS: ProductLike + LS->>LR: 좋아요 조회 findByMemberIdAndSubjectTypeAndSubjectId(memberId, PRODUCT, productId) + LR-->>LS: Like - LS->>LR: 좋아요 삭제 delete(productLike) + LS->>LR: 좋아요 삭제 delete(like) Note over LR: 물리 삭제 (hard-delete) LS-->>C: 취소 완료 @@ -157,8 +157,8 @@ sequenceDiagram M->>C: GET /api/v1/likes?page&size C->>LS: 내 좋아요 목록 조회 getMyLikes(memberId, page, size) - LS->>LR: 좋아요 목록 조회 findLikesByMemberId(memberId, page, size) - Note over LR: 상품 활성 + 브랜드 활성 조건 필터
삭제된 상품·브랜드의 좋아요는 제외 + LS->>LR: 좋아요 목록 조회 findProductLikesByMemberId(memberId, page, size) + Note over LR: subjectType=PRODUCT 필터
상품 활성 + 브랜드 활성 조건 필터
삭제된 상품·브랜드의 좋아요는 제외 LR-->>LS: Page LS-->>C: Page C-->>M: 200 OK @@ -369,38 +369,37 @@ sequenceDiagram ### 4-5. 브랜드 삭제 (연쇄 soft-delete) -> BrandService ↔ ProductService 순환 의존을 Facade로 해소한다. +> Brand와 Product는 같은 Catalog BC. cross-aggregate 규칙은 CatalogDomainService(Domain 레이어)에서 처리한다. ```mermaid sequenceDiagram actor A as 관리자 participant C as AdminBrandController - participant F as AdminBrandFacade - participant BS as BrandService - participant PS as ProductService + participant BS as AdminBrandService + participant CDS as CatalogDomainService + participant BR as BrandRepository + participant PR as ProductRepository participant B as Brand A->>C: DELETE /api/v1/admin/brands/{brandId} - C->>F: 브랜드 삭제 deleteBrand(brandId) - - F->>BS: 브랜드 조회 getBrand(brandId) - BS-->>F: Brand + C->>BS: 브랜드 삭제 deleteBrand(brandId) - F->>B: 삭제 여부 확인 guardNotDeleted() - Note over B: 이미 삭제된 상태면 예외 + BS->>CDS: 브랜드 삭제 deleteBrand(brandId) + CDS->>BR: 브랜드 조회 findById(brandId) + BR-->>CDS: Brand - F->>PS: 소속 상품 연쇄 삭제 softDeleteByBrandId(brandId) - F->>B: 삭제 delete() - Note over B: name 변경 + deletedAt 세팅
(UNIQUE 제약 해소) + CDS->>PR: 소속 상품 연쇄 삭제 softDeleteByBrandId(brandId) + CDS->>B: 삭제 delete() + Note over B: guardNotDeleted() + name 변경
+ deletedAt 세팅 (UNIQUE 해소) - F-->>C: 삭제 완료 + BS-->>C: 삭제 완료 C-->>A: 204 No Content ``` #### 읽는 포인트 -- **AdminBrandFacade**: BrandService ↔ ProductService 순환을 해소하는 조율자. 삭제 순서(상품 먼저 → 브랜드 나중)를 결정하는 책임. -- **Brand 엔티티**: `guardNotDeleted()` — 삭제 가능 상태인지 스스로 검증한다. `delete()` — deletedAt 세팅도 스스로 수행한다. -- **ProductService**: 브랜드 ID로 소속 상품을 일괄 soft-delete하는 책임. 좋아요는 건드리지 않는다. +- **CatalogDomainService**: 같은 BC(Catalog) 내 cross-aggregate 규칙 처리. 삭제 순서(상품 먼저 → 브랜드 나중)는 도메인 규칙. +- **AdminBrandService**: 트랜잭션 경계 소유. CatalogDomainService를 호출하는 Application 조정자. +- **Brand 엔티티**: `delete()` 내부에서 `guardNotDeleted()` + name 변경 + deletedAt 세팅을 스스로 수행한다. --- @@ -448,44 +447,44 @@ sequenceDiagram ### 5-3. 상품 등록 -> BrandService ↔ ProductService 순환 의존을 Facade로 해소한다. +> Brand와 Product는 같은 Catalog BC. 상품 등록 시 브랜드 검증은 CatalogDomainService(Domain 레이어)에서 처리한다. ```mermaid sequenceDiagram actor A as 관리자 participant C as AdminProductController - participant F as AdminProductFacade - participant BS as BrandService + participant PS as AdminProductService + participant CDS as CatalogDomainService + participant BR as BrandRepository participant B as Brand - participant PS as ProductService participant P as Product participant PR as ProductRepository A->>C: POST /api/v1/admin/products {name, description, price, stock, brandId} - C->>F: 상품 등록 createProduct(name, description, price, stock, brandId) + C->>PS: 상품 등록 createProduct(name, description, price, stock, brandId) - F->>BS: 브랜드 조회 getBrand(brandId) - BS-->>F: Brand + PS->>CDS: 상품 생성 createProduct(brandId, name, price, stock, description) + CDS->>BR: 브랜드 조회 findById(brandId) + BR-->>CDS: Brand - F->>B: 삭제 여부 확인 guardNotDeleted() + CDS->>B: 삭제 여부 확인 guardNotDeleted() Note over B: 삭제된 브랜드면 예외 - F->>PS: 상품 생성 createProduct(name, description, price, stock, brandId) - PS->>P: 생성 new Product(name, description, price, stock, brandId) + CDS->>P: 생성 Product.create(brandId, name, price, stock, description) Note over P: 가격 > 0, 재고 >= 0 검증 - PS->>PR: 상품 저장 save(product) - PR-->>PS: Product - PS-->>F: Product + CDS->>PR: 상품 저장 save(product) + PR-->>CDS: Product + CDS-->>PS: Product - F-->>C: ProductInfo + PS-->>C: ProductInfo C-->>A: 201 Created ``` #### 읽는 포인트 -- **AdminProductFacade**: BrandService ↔ ProductService 순환을 해소하는 조율자. 브랜드 검증 → 상품 생성 순서를 결정하는 책임. +- **CatalogDomainService**: 같은 BC(Catalog) 내 cross-aggregate 규칙 처리. 브랜드 검증 → 상품 생성 순서는 도메인 규칙. +- **AdminProductService**: 트랜잭션 경계 소유. CatalogDomainService를 호출하는 Application 조정자. - **Brand 엔티티**: `guardNotDeleted()` — 삭제된 브랜드에 상품을 등록할 수 없다는 불변식을 Brand 스스로가 지킨다. - **Product 엔티티**: 생성 시 입력값 검증(가격 > 0, 재고 >= 0)을 스스로 수행한다. -- **ProductService**: 상품 생성 조율과 저장의 책임. 입력값 검증은 Product에 위임. BrandService를 모른다. --- diff --git a/docs/design/03-class-diagram.md b/docs/design/03-class-diagram.md index 1a1a5c400..b3f5b6078 100644 --- a/docs/design/03-class-diagram.md +++ b/docs/design/03-class-diagram.md @@ -61,11 +61,19 @@ classDiagram +guardNotDeleted() void } - class ProductLike { + class Like { -Long memberId - -Long productId + -LikeSubjectType subjectType + -Long subjectId } + class LikeSubjectType { + <> + PRODUCT + } + + note for Like "단순 관계 레코드 (hard-delete)\nsubjectType + subjectId로 대상 식별\n상속 없이 enum으로 확장" + %% ── 주문 ── class Order { @@ -103,8 +111,9 @@ classDiagram %% ── 연관 (단방향, ID 참조) ── Product ..> Brand : brandId (Long) - ProductLike ..> Product : productId (Long) - ProductLike ..> Member : memberId (Long) + Like ..> Member : memberId (Long) + Like ..> Product : subjectId (Long, subjectType=PRODUCT) + Like --> LikeSubjectType : subjectType Order ..> Member : memberId (Long) Order *-- OrderLineSnapshot : 1..N Order --> OrderStatus : status @@ -120,7 +129,7 @@ classDiagram - **Brand**: 고유 ID. 생성 → 수정 → 삭제의 독립 생명주기. - **Product**: 고유 ID. 생성 → 수정 → 삭제의 독립 생명주기. 브랜드 삭제 시 연쇄 삭제되지만, 이는 비즈니스 규칙이지 생명주기 종속이 아니다. -- **ProductLike**: `memberId + productId`로 고유 식별. 등록 → 삭제의 독립 생명주기. +- **Like**: `memberId + subjectType + subjectId`로 고유 식별. 등록 → 삭제의 독립 생명주기. `subjectType`(enum)으로 좋아요 대상 종류를, `subjectId`로 대상 ID를 지정한다. - **Order**: 고유 ID. 생성 시 즉시 최종 상태(ACCEPTED/REJECTED)로 결정. **Value Object (VO)**: 고유 식별자가 불필요하며, 자체 규칙(불변식)을 캡슐화하는 불변 객체다. @@ -135,7 +144,7 @@ classDiagram 모든 연관이 **단방향**이다. 양방향 참조는 하나도 없다. - `Product → Brand`: Product가 `brandId(Long)`로 브랜드를 참조한다. Brand는 자기에게 소속된 Product를 모른다. -- `ProductLike → Product`: ProductLike가 `productId(Long)`로 상품을 참조한다. Product는 자기에게 달린 좋아요를 모른다. +- `Like → Product/Brand`: Like가 `subjectType(enum) + subjectId(Long)`로 대상을 참조한다. Product/Brand는 자기에게 달린 좋아요를 모른다. - `Order → Member`: Order가 `memberId(Long)`로 회원을 참조한다. Member는 자기의 주문을 모른다. **BC 간 참조는 ID(Long)만 사용한다.** 객체 참조가 아닌 ID 참조이므로 BC 간 직접 의존이 없다. @@ -171,7 +180,7 @@ classDiagram | Product | `guardNotDeleted()` | 삭제 여부를 자기가 검증한다 | | Order | `isOwnedBy(memberId)` | 본인 주문 확인을 자기가 판단한다 | -**ProductLike에 메서드가 없는 이유**: 좋아요는 "회원과 상품 사이의 관계 기록"이라는 본질에 충실한 단순 엔티티다. 등록은 `new ProductLike(memberId, productId)`, 삭제는 물리 삭제(hard-delete). +**Like에 메서드가 없는 이유**: 좋아요는 "회원과 대상 사이의 관계 기록"이라는 본질에 충실한 단순 엔티티다. 등록은 `Like.of(memberId, subjectType, subjectId)`, 삭제는 물리 삭제(hard-delete). `subjectType` enum으로 대상 종류(PRODUCT, 향후 BRAND 등)를 구분하며, 상속 없이 확장 가능하다. ### 원칙 4: 한 객체에 책임이 몰리지 않았는가? @@ -185,7 +194,8 @@ classDiagram |--------|------|---------|--------------|---------------|----------| | Brand | Entity | 고유 ID | 독립 (생성→수정→삭제) | - | soft-delete | | Product | Entity | 고유 ID | 독립, 브랜드 연쇄 삭제 가능 | - | soft-delete | -| ProductLike | Entity | member+product 식별 | 독립 (등록→삭제) | - | hard-delete | +| Like | Entity | member+subjectType+subjectId 식별 | 독립 (등록→삭제) | - | hard-delete | +| LikeSubjectType | enum | - | - | - | - | | Order | Entity | 고유 ID | 독립 (생성→최종 상태) | - | 삭제 없음 | | Stock | **VO** | 불필요 | Product에 종속 | value >= 0, decrease 시 비음수 검증 | Product와 동일 | | Price | **VO** | 불필요 | Product 또는 Snapshot에 종속 | value > 0 | 소유자와 동일 | @@ -222,7 +232,7 @@ classDiagram | Price | 0+1 | 생성자 검증 | **적절**. 가격 규칙만 보유 | | Quantity | 0+1 | 생성자 검증 | **적절**. 수량 규칙만 보유 | | Order | 1 | isOwnedBy | **적절**. 현재 최소 | -| ProductLike | 0 | - | **적절**. 단순 관계 레코드 | +| Like | 0 | - | **적절**. 단순 관계 레코드. subjectType enum으로 대상 구분 | | OrderLineSnapshot | 0 | - | **적절**. 불변 VO. Price, Quantity를 포함하여 스냅샷 | ### Service별 @@ -234,12 +244,11 @@ classDiagram | LikeService | 좋아요 등록/취소, 목록 조회 + 상품 유효성 확인 위임 | **적절** | | OrderService | 주문 생성 조율 (중복 검증, 정렬, 상품 확보, 스냅샷 생성, 수락/거절 판단) | **모니터링 필요**. 확장 시 분리 고려 | -### Facade별 +### Domain Service별 -| Facade | 존재 이유 | 판단 | -|--------|----------|------| -| AdminBrandFacade | BrandService ↔ ProductService 순환 해소 (브랜드 삭제 연쇄) | **적절** | -| AdminProductFacade | BrandService ↔ ProductService 순환 해소 (상품 등록 브랜드 검증) | **적절** | +| Domain Service | 존재 이유 | 판단 | +|----------------|----------|------| +| CatalogDomainService | 같은 BC(Catalog) 내 Brand↔Product cross-aggregate 규칙 처리 (브랜드 삭제 연쇄, 상품 등록 브랜드 검증) | **적절**. 같은 BC 내 도메인 규칙이므로 Domain Service가 적합 | --- @@ -269,18 +278,19 @@ classDiagram ### 5-3. 좋아요 → 선호 BC (Preference Context) -좋아요 대상이 상품에서 브랜드로 확장되고, "선호"라는 상위 개념으로 통합되어 랭킹/추천으로 연결된다. +좋아요 대상이 상품에서 브랜드, 판매자 등으로 확장되고, "선호"라는 상위 개념으로 통합되어 랭킹/추천으로 연결된다. ``` -현재: ProductLike (상품 좋아요만) +현재: Like (subjectType=PRODUCT) 확장: Preference BC - ├── ProductLike (상품 좋아요) - ├── BrandLike (브랜드 좋아요) + ├── Like (subjectType=PRODUCT) + ├── Like (subjectType=BRAND) + ├── Like (subjectType=SELLER, ...) └── → 랭킹/추천 시스템 연동 ``` -- **현재 구조의 대응**: ProductLike가 독립 엔티티이며 상품 BC에 소속. 나중에 BrandLike를 추가하고, 이들을 "선호 BC"로 묶으면 된다. -- **막히지 않는 이유**: ProductLike는 `memberId + productId` ID 참조만 사용하므로, 동일 패턴으로 `BrandLike(memberId + brandId)`를 만들 수 있다. 랭킹/추천은 이 데이터를 이벤트 기반으로 소비하면 된다. +- **현재 구조의 대응**: Like가 `subjectType(enum) + subjectId(Long)`로 대상을 일반화. enum 값 추가만으로 새 대상 타입 확장. +- **막히지 않는 이유**: 스키마 변경 없이 `LikeSubjectType`에 `BRAND`를 추가하면 끝. 타입 메타데이터가 필요해지면 enum형 코드 테이블(`like_subject_type`)로 전환 가능. ### 5-4. 주문 취소 @@ -302,15 +312,15 @@ classDiagram | # | 결정 | 이유 | 대안 | |---|------|------|------| -| 1 | BaseTimeEntity 신규 도입 | ProductLike(hard-delete)와 Order(never deleted)는 deletedAt 불필요. 상속으로 삭제 정책을 코드에 명시 | BaseEntity 그대로 상속 (불필요한 컬럼, 의도 불명확) | +| 1 | BaseTimeEntity 신규 도입 | Like(hard-delete)와 Order(never deleted)는 deletedAt 불필요. 상속으로 삭제 정책을 코드에 명시 | BaseEntity 그대로 상속 (불필요한 컬럼, 의도 불명확) | | 2 | Brand/Product는 BaseEntity 상속 | soft-delete 필요. deletedAt 활용 | 별도 closedAt 관리 (폐점 개념 제거됨, 불필요) | | 3 | Brand.delete(): 이름 변경 + soft-delete | DB UNIQUE 제약 유지하면서 삭제된 브랜드 이름 재사용 가능 | 앱 레벨 검증만 (UNIQUE 없음), UNIQUE 제거 (데이터 정합성 약화) | | 4 | OrderStatus: ACCEPTED, REJECTED만 | 현재 요구사항에 중간 상태/취소 없음. enum이므로 확장 용이 | CANCELLED 포함 (현재 불필요, YAGNI) | | 5 | 모든 BC 간 참조를 ID(Long)만 사용 | BC 간 직접 의존 제거. MSA 전환 시 변경 최소화 | 객체 참조 (편리하나 BC 경계 위반) | | 6 | OrderLineSnapshot은 VO | Order 없이 존재 불가, 불변, 독립 식별 불필요 | Entity로 분류 (불필요한 생명주기 관리) | -| 7 | ProductLike에 메서드 없음 | 단순 관계 레코드. hard-delete이므로 엔티티 행위 불필요 | toggle() 등 추가 (과도한 추상화) | +| 7 | Like에 메서드 없음 | 단순 관계 레코드. hard-delete이므로 엔티티 행위 불필요 | toggle() 등 추가 (과도한 추상화) | | 8 | 양방향 연관 0개 | 단방향만으로 모든 요구사항 충족. 양방향은 순환 의존과 복잡성 유발 | Product ↔ Brand 양방향 (편의성 vs 복잡성 트레이드오프) | -| 9 | 좋아요를 상품 BC에 배치 (현재) | 현재는 상품 좋아요만 존재. 확장 시 선호 BC로 분리 | 처음부터 선호 BC 분리 (YAGNI, 과도한 설계) | +| 9 | Like를 subjectType+subjectId로 일반화 | 상속(JOINED/SINGLE_TABLE) 대신 enum+ID 패턴 채택. UNIQUE 제약 자연스러움, 스키마 변경 없이 타입 확장, 무FK 철학 일관 | JPA 상속 (JOINED: UNIQUE 불가+JOIN 비용, SINGLE_TABLE: nullable 컬럼), ProductLike/BrandLike 클래스 분리 (타입 추가마다 엔티티+테이블 필요) | | 10 | Stock, Price, Quantity를 VO로 분리 | 자체 규칙(불변식)이 있는 속성만 VO로 캡슐화. "규칙 없으면 원시 타입" 기준 | 원시 타입 유지 (규칙이 엔티티나 Service에 흩어짐) | | 11 | Product.decreaseStock → Stock.decrease 위임 | 재고 규칙은 재고의 책임. Product는 조율만 수행 | Product가 직접 검증 (책임 혼재) | | 12 | BaseEntity/BaseTimeEntity를 다이어그램에서 제외 | 비즈니스 설계에 기술 인프라 클래스가 불필요. 코드 구현 시 적용 | 포함 (기술적 완전성은 높지만 비즈니스 가독성 저하) | @@ -337,4 +347,4 @@ classDiagram | 위임 패턴 (decreaseStock → Stock.decrease) | "규칙은 규칙을 아는 객체가 수행한다"는 객체지향 원칙을 표현 | | 연관 방향 (전부 단방향 ID 참조) | BC 경계가 다이어그램에서 바로 보임 | | Composition (Order ◆── OrderLineSnapshot) | "스냅샷은 주문의 일부"라는 생명주기 종속을 시각적으로 표현 | -| 메서드 없는 엔티티 (ProductLike) | "관계 기록"이라는 본질에 충실 — 억지 행위 없음 | +| 메서드 없는 엔티티 (Like) | "관계 기록"이라는 본질에 충실 — 억지 행위 없음. subjectType enum으로 대상 종류 구분 | diff --git a/docs/design/04-erd.md b/docs/design/04-erd.md index 408c8609b..f7e79dbac 100644 --- a/docs/design/04-erd.md +++ b/docs/design/04-erd.md @@ -40,10 +40,11 @@ erDiagram DATETIME deleted_at "nullable" } - product_like { + likes { BIGINT id PK "AUTO_INCREMENT" BIGINT member_id "NOT NULL" - BIGINT product_id "NOT NULL" + VARCHAR subject_type "NOT NULL" + BIGINT subject_id "NOT NULL" DATETIME created_at "NOT NULL" DATETIME updated_at "NOT NULL" } @@ -69,8 +70,7 @@ erDiagram } brand ||--o{ product : "brand_id" - member ||--o{ product_like : "member_id" - product ||--o{ product_like : "product_id" + member ||--o{ likes : "member_id" member ||--o{ orders : "member_id" orders ||--o{ order_line_snapshot : "order_id" ``` @@ -108,14 +108,14 @@ JPA 상속은 `@MappedSuperclass`를 사용한다. 상속 클래스별로 별도 | 상속 클래스 | 포함 컬럼 | 상속하는 테이블 | |------------|----------|---------------| | BaseEntity | id, created_at, updated_at, **deleted_at** | brand, product | -| BaseTimeEntity (신규) | id, created_at, updated_at | product_like, orders | +| BaseTimeEntity (신규) | id, created_at, updated_at | likes, orders | ### 삭제 정책별 테이블 구분 | 삭제 정책 | 테이블 | deleted_at 유무 | 상속 | |----------|--------|----------------|------| | soft-delete | brand, product | 있음 | BaseEntity | -| hard-delete | product_like | 없음 | BaseTimeEntity | +| hard-delete | likes | 없음 | BaseTimeEntity | | 삭제 없음 | orders, order_line_snapshot | 없음 | BaseTimeEntity / 없음 | --- @@ -166,19 +166,22 @@ BaseEntity 상속 (soft-delete). | updated_at | DATETIME | NOT NULL | BaseEntity | | deleted_at | DATETIME | nullable | soft-delete 마커 | -### product_like +### likes -BaseTimeEntity 상속 (hard-delete). +BaseTimeEntity 상속 (hard-delete). 테이블명은 `likes` (LIKE는 SQL 예약어). | 컬럼 | 타입 | 제약 | 비고 | |-------|------|------|------| | id | BIGINT | PK, AUTO_INCREMENT | | | member_id | BIGINT | NOT NULL | → member.id (FK 없음) | -| product_id | BIGINT | NOT NULL | → product.id (FK 없음) | +| subject_type | VARCHAR | NOT NULL | LikeSubjectType enum (EnumType.STRING) | +| subject_id | BIGINT | NOT NULL | 대상 ID (subject_type에 따라 해석) | | created_at | DATETIME | NOT NULL | BaseTimeEntity | | updated_at | DATETIME | NOT NULL | BaseTimeEntity | -- **UNIQUE(member_id, product_id)**: 같은 회원이 같은 상품에 중복 좋아요를 할 수 없다. +- **UNIQUE(member_id, subject_type, subject_id)**: 같은 회원이 같은 대상에 중복 좋아요를 할 수 없다. +- **subject_type**: 앱 enum(`LikeSubjectType`)을 문자열로 저장. 현재 `PRODUCT`만 존재. 확장 시 enum 값 추가. 규모 확장 시 enum형 코드 테이블로 전환 가능. +- **subject_id**: subject_type에 따라 `product.id`, `brand.id` 등을 가리킨다. FK 없이 앱 레벨에서 해석. ### orders @@ -225,5 +228,85 @@ Order에 종속되는 VO. Composition 1:N. | 3 | order → orders 테이블명 | ORDER는 SQL 예약어. 백틱 의존보다 명확한 이름 사용 | 백틱으로 감싸기 (DB 종류 변경 시 호환성 문제) | | 4 | OrderLineSnapshot에 id 컬럼 포함 | 도메인 VO이지만 JPA @OneToMany 매핑에 PK 필요 | @ElementCollection (컬렉션 전체 삭제/재삽입 성능 이슈) | | 5 | OrderLineSnapshot에 timestamp 없음 | 불변 VO. Order의 created_at이 생성 시점을 대변 | timestamp 포함 (불필요한 중복 정보) | -| 6 | product_like에 UNIQUE(member_id, product_id) | 중복 좋아요 방지를 DB 레벨에서 보장. 앱 레벨 검증만으로는 동시성 이슈 가능 | 앱 레벨만 (경쟁 조건에 취약) | +| 6 | likes에 UNIQUE(member_id, subject_type, subject_id) | 중복 좋아요 방지를 DB 레벨에서 보장. subjectType+subjectId 일반화로 단일 테이블에서 모든 좋아요 타입의 중복 차단 | 앱 레벨만 (경쟁 조건에 취약), 타입별 테이블 분리 (UNIQUE는 쉬우나 스키마 변경 필요) | | 7 | brand.name에 UNIQUE 제약 | 이름 중복 불가 요구사항. delete 시 이름 변경으로 UNIQUE 해소 (클래스 다이어그램 결정 #3) | UNIQUE 없이 앱 검증만 (동시성에 취약) | +| 8 | like → likes 테이블명 | LIKE는 SQL 예약어. orders(#3)와 동일한 이유로 복수형 사용 | 백틱으로 감싸기 (DB 종류 변경 시 호환성 문제) | + +--- + +## 5. 무FK 운영 규약 + +> FK 제약조건 없이 참조 무결성을 보장하기 위한 앱 레벨 규칙. +> 각 참조 컬럼에 대해 **누가, 언제, 어떻게** 무결성을 검증하는지 명시한다. + +### 참조 무결성 검증 매트릭스 + +| 참조 컬럼 | 참조 대상 | 검증 시점 | 검증 주체 | 검증 방법 | +|-----------|----------|----------|----------|----------| +| product.brand_id | brand.id | 상품 등록 | AdminProductFacade | `BrandService.getBrand()` + `Brand.guardNotDeleted()` | +| likes.member_id | member.id | 좋아요 등록 | 인증 컨텍스트 | 인증된 memberId만 사용 (암묵적 검증) | +| likes.subject_id | product.id | 좋아요 등록 | LikeService | `ProductService.getActiveProduct()` (상품+브랜드 활성 확인) | +| orders.member_id | member.id | 주문 생성 | 인증 컨텍스트 | 인증된 memberId만 사용 (암묵적 검증) | +| order_line_snapshot.order_id | orders.id | 주문 생성 | OrderService | Order와 함께 생성 (Composition, 독립 생성 불가) | +| order_line_snapshot.product_id | product.id | 주문 생성 | OrderService | `ProductService.getProductForOrder()` (비관적 락 + 활성 확인) | + +### 삭제 시 참조 보호 규칙 + +| 삭제 대상 | 영향 받는 테이블 | 처리 방식 | 처리 주체 | +|-----------|----------------|----------|----------| +| brand (soft-delete) | product | 연쇄 soft-delete | AdminBrandFacade → `ProductService.softDeleteByBrandId()` | +| brand (soft-delete) | likes | 처리 없음 | 목록 조회 시 LikeRepository가 자연 필터링 | +| product (soft-delete) | likes | 처리 없음 | 목록 조회 시 LikeRepository가 자연 필터링 | +| product (soft-delete) | order_line_snapshot | 영향 없음 | 스냅샷이므로 원본 상태와 무관 | + +### 고아 레코드 방지 원칙 + +1. **쓰기 시점 검증**: 참조 대상의 존재·활성 여부는 **레코드 생성 시점**에 앱 레벨에서 반드시 검증한다. +2. **읽기 시점 필터링**: 참조 대상이 이후 삭제되더라도, 조회 쿼리에서 활성 필터링으로 자연스럽게 제외한다. +3. **스냅샷 불변성**: order_line_snapshot은 생성 후 변경되지 않으므로, 원본 삭제와 무관하게 기록이 유지된다. +4. **취소는 무검증**: 좋아요 취소 시 상품/브랜드 상태를 확인하지 않는다 (요구사항: 삭제된 브랜드의 좋아요도 취소 가능). + +--- + +## 6. 인덱스 전략 + +> 시퀀스 다이어그램의 쿼리 패턴에서 도출한 인덱스 정의. +> PK 인덱스는 생략한다. + +### member + +| 인덱스 | 컬럼 | 타입 | 사용 쿼리 | +|--------|------|------|----------| +| uk_member_login_id | login_id | UNIQUE | `findByLoginId`, `existsByLoginId` | + +### brand + +| 인덱스 | 컬럼 | 타입 | 사용 쿼리 | +|--------|------|------|----------| +| uk_brand_name | name | UNIQUE | `existsByName`, `existsByNameAndIdNot` | + +### product + +| 인덱스 | 컬럼 | 타입 | 사용 쿼리 | +|--------|------|------|----------| +| idx_product_brand_id | brand_id | INDEX | `findAllByBrandId`, `softDeleteByBrandId` | + +### likes + +| 인덱스 | 컬럼 | 타입 | 사용 쿼리 | +|--------|------|------|----------| +| uk_likes_member_subject | (member_id, subject_type, subject_id) | UNIQUE | `existsByMemberIdAndSubjectTypeAndSubjectId`, `findByMemberIdAndSubjectTypeAndSubjectId` | + +> `findProductLikesByMemberId(memberId, page, size)`는 `uk_likes_member_subject`의 선두 컬럼 `(member_id, subject_type)`으로 커버된다. + +### orders + +| 인덱스 | 컬럼 | 타입 | 사용 쿼리 | +|--------|------|------|----------| +| idx_orders_member_ordered | (member_id, ordered_at) | INDEX | `findByMemberIdAndPeriod` | + +### order_line_snapshot + +| 인덱스 | 컬럼 | 타입 | 사용 쿼리 | +|--------|------|------|----------| +| idx_ols_order_id | order_id | INDEX | `findWithSnapshotsById` (Order와 함께 로딩) | diff --git a/docs/design/05-domain-model.md b/docs/design/05-domain-model.md new file mode 100644 index 000000000..52ceeccb8 --- /dev/null +++ b/docs/design/05-domain-model.md @@ -0,0 +1,226 @@ +# 도메인 모델 정의서 + +> 작성일: 2026-02-22 +> 상태: Draft (SoT 후보) +> 기반 문서: `docs/design/01-requirements.md`, `docs/design/02-sequence-diagrams.md`, `docs/design/03-class-diagram.md`, `docs/design/04-erd.md` + +--- + +## 1. 목적 + +이 문서는 현재 프로젝트의 도메인 모델 경계와 레이어 책임을 명확히 정의한다. +특히 다음 질문에 답한다. + +- 어떤 바운디드 컨텍스트(BC)가 현재 존재하는가? +- Like는 현재 어디에 속하며, 언제 독립 BC로 전환하는가? +- Application Service와 Domain Service의 책임 경계는 무엇인가? +- Repository Port, 트랜잭션, 외부 의존의 허용 범위는 어디까지인가? + +--- + +## 2. 현재 BC 경계 + +현재 기준 BC는 다음 4개다. + +1. `Member Context` +- 책임: 회원 가입, 인증 기반 식별, 비밀번호 변경, 내 정보 조회 +- Aggregate: `Member` + +2. `Catalog Context` +- 책임: 브랜드/상품 관리 및 조회 +- Aggregate: `Brand`, `Product` + +3. `Like Context` +- 책임: 회원의 선호(좋아요) 관계 기록 관리 +- Aggregate: `Like` +- 모델: `Like(memberId, subjectType, subjectId)` + +4. `Order Context` +- 책임: 주문 생성/조회, 주문 스냅샷 보존, 수락/거절 판단 +- Aggregate: `Order` (+ `OrderLineSnapshot` VO) +- 참고: 재고 차감은 Catalog Context(Product)의 책임이며, Order Context는 ProductService를 통해 요청만 한다. + +참고: +- BC 간 참조는 객체 참조가 아닌 ID(Long) 참조만 사용한다. +- FK 제약조건을 사용하지 않는다. 참조 무결성은 애플리케이션 레벨에서 검증한다. + - 이유: 운영 유연성 확보 + MSA 전환 시 FK로 인한 확장성 제한 제거 + - 같은 BC 내부(예: product.brand_id → brand.id)에도 동일하게 적용한다. + +--- + +## 3. Like BC의 현재 위치와 확장 방향 + +### 3-1. 현재 모델 + +- 엔티티: `Like` +- 식별: `memberId + subjectType + subjectId` +- 저장: `likes` 테이블 단일 구조 +- 제약: `UNIQUE(member_id, subject_type, subject_id)` + +### 3-2. 현재 `subjectType` + +- 현재 값: `PRODUCT` +- 확장 예정 값: `BRAND`, `SELLER` 등 + +### 3-3. Preference BC 전환 기준 + +다음 조건 중 1개 이상 만족 시 `Like Context`를 `Preference Context`로 승격 검토한다. + +1. `subjectType`이 2종 이상으로 확장되고 타입별 정책 분기가 발생할 때 +2. 좋아요 외 선호 행위(북마크, 팔로우, 숨김 등)가 추가될 때 +3. 랭킹/추천 파이프라인과 독립 배포 경계가 필요할 때 + +--- + +## 4. 레이어 책임 규칙 + +### 4-1. Domain Layer — 논리적 영역 + +- 역할: **논리적 영역의 비즈니스 규칙** 처리. 불변식, 상태 전이, 도메인 모델 캡슐화 +- 기준: "이 규칙은 기술 구현과 무관하게 항상 성립하는가?" → Yes라면 Domain +- 허용: JPA 매핑 어노테이션 (`@Entity`, `@Embeddable`, `@MappedSuperclass`, `@Table`, `@Column` 등) +- 금지: HTTP/Kafka/Redis 등 인프라 세부 구현 직접 의존 + +### 4-2. Application Layer — 물리적 영역 + +- 역할: **물리적 영역의 비즈니스 로직** 처리. 유스케이스 오케스트레이션(조정자) +- 기준: 도메인 레이어에서 전부 해결하지 못하는 경우 Application으로 올라온다 +- Application으로 올라오는 조건: + 1. **BC 경계를 넘는 조율**: 서로 다른 BC의 도메인 객체/서비스를 조합해야 할 때 (각 BC의 논리적 규칙은 해당 Domain이 처리하고, Application은 이를 오케스트레이션) + 2. **외부 인프라 의존**: 변경점이 많은 프레임워크/서비스/모듈(HTTP, Kafka, Redis 등)을 써야 할 때 + 3. **물리적 기술 관심사**: 트랜잭션 경계 관리, 데드락 방지 정렬, 락 획득 순서 등 +- 책임: + - 트랜잭션 경계 소유 (@Transactional) + - Cross-BC 오케스트레이션 + - 외부 Port 호출 조합 + +### 4-3. Domain Service + +- 역할: 같은 BC 내에서 단일 엔티티에 귀속되지 않는 도메인 규칙 처리 +- 허용: + - 도메인 객체 사용 + - Repository Port 호출 (`find`, `save`) — DIP된 포트이므로 도메인 레이어에서 사용 가능 +- 금지: + - 트랜잭션 애노테이션 소유 + - 외부 시스템 직접 호출(HTTP/Kafka/Redis) +- 호출 규칙: + - Domain Service는 **반드시 Application Service를 통해 호출**된다 + - Controller → Domain Service 직접 호출 금지 (트랜잭션 없이 save가 실행되는 것을 방지) + +### 4-4. Facade + +- 역할: **Application Service 간 순환 참조 해소** +- 위치: Application 레이어 +- 도입 기준: Application Service A가 Application Service B를 필요로 하고, B도 A를 필요로 할 때 +- 주의: 같은 BC 내 cross-aggregate 규칙은 Facade가 아닌 Domain Service로 해결한다 + - 예: Brand 삭제 → Product 연쇄 삭제는 같은 BC(Catalog)이므로 `CatalogDomainService`가 처리 + +--- + +## 5. Service 분류 기준 + +### 핵심 기준: 논리적인가, 물리적인가? + +| 질문 | 위치 | +|------|------| +| 이 규칙은 기술 구현과 무관하게 항상 성립하는가? (논리적) | Domain (Entity/VO/Domain Service) | +| 이 로직은 기술적 조율이 필요한가? (물리적) | Application Service | + +### 세부 판별 순서 + +1. 이 로직이 단일 엔티티의 상태 전이/불변식 판단인가? + - Yes → Entity/VO + +2. 이 로직이 같은 BC 내부 규칙이지만 단일 엔티티에 넣기 어려운가? (cross-aggregate 규칙) + - Yes → Domain Service + +3. 이 로직이 BC 경계를 넘는 조율인가? + - Yes → Application Service (각 BC의 논리적 규칙은 해당 Domain이 처리, Application은 오케스트레이션) + +4. 이 로직이 물리적 기술 관심사인가? (트랜잭션, 데드락 방지, 락 순서 등) + - Yes → Application Service + +5. Application Service 간 순환 참조가 발생하는가? + - Yes → Facade로 해소 + +### 예시: 주문 생성 + +| 로직 | 분류 | 이유 | +|------|------|------| +| 중복 상품 검증 | Domain (Order) | "같은 상품 중복 주문 불가" = 논리적 비즈니스 규칙 | +| productId 정렬 | Application | 데드락 방지 = 물리적/기술적 관심사 | +| 재고 충분 여부 확인 | Domain (Stock.isEnough) | Stock의 불변식 = 논리적 | +| 수락/거절 판단 | Domain (Order 또는 OrderDomainService) | "전부 아니면 전무" = 논리적 비즈니스 규칙 | +| 위 흐름의 오케스트레이션 | Application (OrderService) | Product 조회 + 락 획득 + 트랜잭션 = 물리적 | + +### 예시: 좋아요 등록 (Cross-BC) + +| 로직 | 분류 | 이유 | +|------|------|------| +| "좋아요 대상은 유효해야 한다" | Domain (Like BC 규칙) | 논리적 비즈니스 규칙 | +| 상품 활성 여부 확인 | Domain (Catalog BC — Product/Brand) | 논리적. 각 BC가 자기 규칙을 처리 | +| 위 흐름의 오케스트레이션 | Application (LikeService) | Cross-BC 조율 = 물리적 | + +--- + +## 6. 규칙 반복 시 승격 기준 + +동일 규칙이 아래 기준을 만족하면 도메인 레이어로 승격한다. + +1. 두 개 이상의 Application Service에서 동일 규칙이 반복된다. +2. 규칙 변경 시 두 곳 이상 수정이 필요하다. +3. 규칙 테스트가 Service 테스트에서만 간접 검증되고 있다. + +승격 원칙: +- 불변식이면 Entity/VO로 이동 +- 단일 엔티티에 담기 어렵다면 Domain Service로 이동 + +--- + +## 7. Aggregate 규칙 + +### 7-1. Aggregate Root 원칙 + +- Aggregate 내부 객체는 **Root를 통해서만** 외부에 노출된다. +- Repository는 **Aggregate Root에 대해서만** 존재한다. +- Aggregate 간 참조는 **ID(Long)만** 사용한다. + +### 7-2. 현재 Aggregate 구조 + +| BC | Aggregate Root | 내부 객체 (VO) | Repository | +|-----|---------------|---------------|-----------| +| Member | Member | LoginId, Password, MemberName, Email | MemberRepository | +| Catalog | Brand | (없음) | BrandRepository | +| Catalog | Product | Price, Stock | ProductRepository | +| Like | Like | (없음) | LikeRepository | +| Order | Order | OrderLineSnapshot | OrderRepository | + +### 7-3. Catalog BC: Brand와 Product가 독립 Aggregate인 이유 + +- **독립적 생명주기**: Product 없이 Brand만 존재 가능 +- **규모 차이**: 하나의 Brand에 수천 개 Product가 소속 가능. Brand Aggregate에 Product를 포함하면 메모리/성능 문제 +- **독립 변경**: Product 가격/재고 수정 시 Brand를 잠글 필요 없음 + +Brand ↔ Product cross-aggregate 규칙(삭제 연쇄, 생성 시 브랜드 검증)은 **CatalogDomainService**에서 처리한다. + +### 7-4. 트랜잭션 경계 + +- **기본 원칙**: 하나의 트랜잭션에서 하나의 Aggregate만 변경한다. +- **같은 BC 내 예외**: 같은 BC 안에서 cross-aggregate 변경이 필요한 경우, Domain Service가 같은 트랜잭션에서 처리할 수 있다. + - 예: Brand 삭제 → Product 연쇄 삭제 (CatalogDomainService, 같은 트랜잭션) +- **다른 BC 간**: 현재는 Application Service가 같은 트랜잭션에서 조율한다. 규모 확장 시 이벤트 기반(eventual consistency)으로 전환을 검토한다. + +--- + +## 8. 향후 문서 반영 포인트 + +다음 문서와의 정합성을 함께 유지한다. + +1. `docs/design/03-class-diagram.md` +- Like/Preference 확장 문단과 본 문서의 BC 정의를 동일하게 유지 + +2. `docs/design/04-erd.md` +- `likes(subject_type, subject_id)` 구조와 본 문서의 Like BC 정의를 동일하게 유지 + +3. `docs/design/base/domain-definition-v2.md` (ARCHIVE) +- 히스토리 참고만 허용, 현재 설계 판단의 직접 근거로 사용하지 않음 diff --git a/docs/design/06-architecture.md b/docs/design/06-architecture.md new file mode 100644 index 000000000..646b8d014 --- /dev/null +++ b/docs/design/06-architecture.md @@ -0,0 +1,800 @@ +# 아키텍처 설계서 + +> 작성일: 2026-02-22 +> +> 기반 문서: `01-requirements.md` ~ `05-domain-model.md` + +--- + +## 1. 이 문서의 목적 + +이 문서는 다음 세 가지 질문에 답한다. + +1. **WHY** — 왜 이 레이어 구조인가? 각 레이어는 어떤 문제를 해결하기 위해 존재하는가? +2. **WHERE** — 새 기능이 추가될 때 코드는 어디에 놓는가? +3. **BOUNDARY** — 현재 구조가 어떤 확장을 지원하고, 어떤 제약이 있는가? + +기존 설계 문서(01~05)는 요구사항, 시퀀스, 클래스, ERD, 도메인 모델을 각각 다룬다. 이 문서는 그것들 위에서 **"왜 이 구조인가"를 종합**하는 조감도 역할이다. + +--- + +## 2. 아키텍처 전체 조감도 + +### 2-1. 레이어 구성도 + +```mermaid +graph TB + subgraph Presentation["Presentation Layer"] + direction LR + P_CTRL["Controller"] + P_DTO["API DTO"] + P_RES["ApiResponse\nApiControllerAdvice"] + end + + subgraph Application["Application Layer"] + direction LR + A_SVC["Service"] + A_FACADE["Facade"] + A_DTO["Command / Query DTO"] + end + + subgraph Domain["Domain Layer"] + direction LR + D_ENT["Entity"] + D_VO["VO"] + D_PORT["Repository\n(interface)"] + D_DS["Domain Service"] + D_ERR["ErrorType\nCoreException"] + end + + subgraph Infrastructure["Infrastructure Layer"] + direction LR + I_IMPL["RepositoryImpl"] + I_JPA["JpaRepository"] + end + + Presentation -->|depends on| Application + Application -->|depends on| Domain + Infrastructure -->|"implements\n(Repository)"| Domain +``` + +### 2-2. 읽는 포인트 + +- **의존 방향은 항상 Domain을 향한다.** Domain은 아무것도 의존하지 않는다. +- **Infrastructure는 Domain의 Repository를 구현**한다. Domain이 정의한 Repository(interface)를 Infrastructure가 RepositoryImpl로 구현한다. +- **Presentation이 3개**(HTTP, Batch, Kafka)다. 같은 Application을 서로 다른 프로토콜로 노출한다. ErrorType에 HttpStatus를 넣지 않은 이유가 여기에 있다. + +### 2-3. 물리적 디렉토리 구조 + +``` +Root +├── domain/ [Domain Layer] +├── application/ +│ └── commerce-service/ [Application Layer] +├── presentation/ +│ ├── commerce-api/ [Presentation — REST API] +│ ├── commerce-batch/ [Presentation — Spring Batch] +│ └── commerce-streamer/ [Presentation — Kafka Consumer] +├── modules/ +│ ├── jpa/ [Infrastructure — DB] +│ ├── redis/ [Infrastructure — Cache] +│ └── kafka/ [Infrastructure — Messaging] +└── supports/ + ├── jackson/ [Cross-cutting — Serialization] + ├── logging/ [Cross-cutting — Logging] + └── monitoring/ [Cross-cutting — Metrics] +``` + +--- + +## 3. 레이어 상세 설계 + +### 3-1. Domain Layer (논리적 영역) + +#### 존재 이유 + +**"기술 구현과 무관하게 항상 성립하는 비즈니스 규칙"**을 격리한다. + +Spring Boot가 Django로 바뀌어도, MySQL이 MongoDB로 바뀌어도, 이 레이어의 코드는 변하지 않아야 한다. "재고는 음수가 될 수 없다", "같은 회원이 같은 상품에 중복 좋아요 불가" — 이런 규칙은 기술 스택과 무관하다. + +#### 허용 / 금지 + +| 허용 | 금지 | +|------|------| +| JPA 매핑 어노테이션 (`@Entity`, `@Embeddable`, `@MappedSuperclass`, `@Table`, `@Column`) | `@Transactional`, `@Service` | +| `@Component` (Domain Service용) | `HttpStatus`, Spring Web | +| Repository (interface 정의) | Kafka, Redis, 외부 HTTP 클라이언트 | + +> **왜 JPA 어노테이션과 `@Component`를 허용하는가?** +> `jakarta.persistence-api`는 인터페이스 수준의 표준 스펙이다. `spring-context`의 `@Component`도 Domain Service를 빈으로 등록하기 위한 최소한의 의존이다. 순수성을 고집하면 `@Bean` Config 클래스가 필요해져 Domain Service 추가마다 관리 포인트가 늘어난다. 이 프로젝트에서 Spring을 벗어날 가능성은 현실적으로 없으므로, 실용성을 우선한다. + +#### 클래스 목록 + +**공통 기반** + +| 클래스 | 종류 | 책임 | +|--------|------|------| +| `BaseTimeEntity` | @MappedSuperclass | `id`, `createdAt`, `updatedAt` 자동 관리. soft-delete가 불필요한 엔티티용 | +| `BaseEntity` | @MappedSuperclass | `BaseTimeEntity` 상속 + `deletedAt`, `delete()`, `restore()`. soft-delete가 필요한 엔티티용 | +| `ErrorType` | enum | 비즈니스 실패 분류. `code`와 `message`만 보유. **HttpStatus를 모른다** | +| `CoreException` | RuntimeException | `ErrorType` + 선택적 `customMessage` | + +> **왜 BaseTimeEntity / BaseEntity를 분리하는가?** +> 삭제 정책을 **상속 구조로 명시**하기 위해서다. Like(hard-delete)와 Order(삭제 없음)에 `deletedAt` 컬럼은 불필요하다. 어떤 엔티티가 `BaseEntity`를 상속하면 "이 엔티티는 soft-delete를 사용한다"는 설계 의도가 코드 레벨에서 드러난다. + +```java +BaseTimeEntity (id, createdAt, updatedAt) + ├── BaseEntity + deletedAt + │ ├── Brand (soft-delete) + │ └── Product (soft-delete) + │ + └── BaseTimeEntity만 + ├── Member (탈퇴 미구현, 향후 결정) + ├── Like (hard-delete) + └── Order (삭제 없음, 불변) +``` + +**BC별 클래스** + +| BC | 클래스 | 종류 | 책임 | +|----|--------|------|------| +| Member | `Member` | Entity | 회원 등록, 비밀번호 검증/변경. VO에 규칙 위임 | +| Member | `LoginId`, `Password`, `MemberName`, `Email` | VO (@Embeddable) | 각 필드의 자체 규칙 캡슐화 (길이, 형식, 암호화) | +| Member | `MemberRepository` | interface | 회원 조회/저장 계약 | +| Member | `MemberExceptionMessage` | enum | 예외 메시지 상수 | +| Catalog | `Brand` | Entity | 브랜드 CRUD, `delete()` override (guardNotDeleted + name suffix로 UNIQUE 해소) | +| Catalog | `Product` | Entity | 상품 CRUD, VO 위임 (`hasEnoughStock`, `decreaseStock`) | +| Catalog | `Price` | VO (@Embeddable) | 가격 > 0 자체 검증 | +| Catalog | `Stock` | VO (@Embeddable) | 재고 >= 0 자체 검증, `isEnough(Quantity)`, `decrease(Quantity)` | +| Catalog | `CatalogDomainService` | Domain Service | 같은 BC 내 cross-aggregate 규칙 (Brand 삭제 → Product 연쇄, 상품 등록 시 Brand 검증) | +| Catalog | `BrandRepository`, `ProductRepository` | interface | Catalog 조회/저장 계약 | +| Like | `Like` | Entity | 관계 레코드 (hard-delete). `subjectType(enum) + subjectId(Long)` | +| Like | `LikeRepository` | interface | 좋아요 조회/저장 계약 | +| Order | `Order` | Entity | 주문 상태 관리, `isOwnedBy(memberId)` | +| Order | `OrderLineSnapshot` | VO | 주문 시점 불변 스냅샷 (Price, Quantity 포함) | +| Order | `Quantity` | VO (@Embeddable) | 수량 > 0 자체 검증 | +| Order | `OrderRepository` | interface | 주문 조회/저장 계약 | + +#### 패키지 구조 + +Domain 레이어는 **BC 단위로 패키지를 구성**한다. Brand와 Product는 같은 Catalog BC이므로 `catalog/` 하위에 배치하여, 디렉토리만 봐도 BC 경계가 드러나게 한다. + +> 상위 레이어(Application, Presentation, Infrastructure)는 BC 기준 패키지를 적용하지 않는다. 상위 레이어는 Admin/User 분리, Cross-BC 조합 등 도메인 경계와 다른 기준으로 클래스가 구성되므로, 별도의 패키지 전략을 적용한다. + +``` +com.loopers +├── domain/ +│ ├── BaseTimeEntity.java +│ ├── BaseEntity.java +│ ├── member/ +│ │ ├── Member.java +│ │ ├── MemberRepository.java +│ │ ├── MemberExceptionMessage.java +│ │ └── vo/ +│ │ ├── LoginId.java +│ │ ├── Password.java +│ │ ├── MemberName.java +│ │ └── Email.java +│ ├── catalog/ +│ │ ├── CatalogDomainService.java +│ │ ├── brand/ +│ │ │ ├── Brand.java +│ │ │ ├── BrandRepository.java +│ │ │ └── BrandExceptionMessage.java +│ │ └── product/ +│ │ ├── Product.java +│ │ ├── ProductRepository.java +│ │ ├── ProductExceptionMessage.java +│ │ └── vo/ +│ │ ├── Price.java +│ │ └── Stock.java +│ ├── like/ +│ │ ├── Like.java +│ │ ├── LikeRepository.java +│ │ └── LikeSubjectType.java +│ └── order/ +│ ├── Order.java +│ ├── OrderRepository.java +│ ├── OrderLineSnapshot.java +│ ├── OrderStatus.java +│ └── vo/ +│ └── Quantity.java +└── support/ + └── error/ + ├── ErrorType.java + └── CoreException.java +``` + +--- + +### 3-2. Application Layer (물리적 영역) + +#### 존재 이유 + +**Domain만으로 해결할 수 없는 "물리적/기술적 관심사"**를 처리한다. + +Domain이 "논리적으로 항상 성립하는 규칙"이라면, Application은 "그 규칙을 실행하기 위해 필요한 기술적 조율"이다. + +#### Application으로 올라오는 3가지 조건 + +1. **BC 경계를 넘는 조율**: 서로 다른 BC의 도메인 객체/서비스를 조합해야 할 때 + - 예: LikeService가 ProductService를 호출하여 상품 유효성 확인 +2. **외부 인프라 의존**: 변경점이 많은 프레임워크/서비스를 써야 할 때 + - 예: HTTP 클라이언트, Kafka Producer, Redis Cache +3. **물리적 기술 관심사**: 트랜잭션 경계, 데드락 방지 정렬, 락 획득 순서 등 + - 예: OrderService에서 productId 오름차순 정렬 후 비관적 락 획득 + +#### 허용 / 금지 + +| 허용 | 금지 | +|------|------| +| `@Service`, `@Transactional` | `@Controller`, `@RequestMapping` | +| Domain 객체 사용, Repository 호출 | HttpStatus, HttpServletRequest | +| Domain Service 호출 | Presentation DTO 직접 사용 | + +> **왜 spring-web을 포함하지 않는가?** +> Application 모듈의 `build.gradle.kts`에는 `spring-tx`와 `spring-context`만 있다. `spring-web`이 없으므로 Application은 HTTP의 존재를 모른다. 이것이 같은 Application을 HTTP(commerce-api), Batch(commerce-batch), Kafka(commerce-streamer) 세 가지 Presentation에서 재사용할 수 있는 근본 이유다. + +```kotlin +// application/commerce-service/build.gradle.kts +dependencies { + api(project(":domain")) + implementation("org.springframework:spring-tx") // @Transactional + implementation("org.springframework:spring-context") // @Service, DI + // spring-web 없음 +} +``` + +#### 클래스 목록 + +| 클래스 | BC | 책임 | +|--------|-----|------| +| `MemberService` | Member | 회원 등록, 조회, 비밀번호 변경 오케스트레이션 | +| `BrandService` | Catalog | 활성 브랜드 목록 조회 (User) | +| `AdminBrandService` | Catalog | 브랜드 CRUD. 삭제 시 CatalogDomainService 호출 | +| `AdminProductService` | Catalog | 상품 등록 시 CatalogDomainService 호출 | +| `ProductService` | Catalog | 상품 조회, 수정, 삭제, 주문용 락 조회 | +| `LikeService` | Like | 좋아요 등록/취소/조회. Cross-BC 유효성 확인 (ProductService 호출) | +| `OrderService` | Order | 주문 생성 오케스트레이션 (정렬, 락, 스냅샷, 수락/거절) | + +**DTO 분류** + +| 종류 | 역할 | 네이밍 규칙 | 예시 | +|---------|------|-----------|------| +| Command | 외부 → Application 요청 (상태 변경) | `{Action}{Domain}Command` | `CreateBrandCommand`, `RegisterMemberCommand` | +| Info | Application → 외부 응답 (조회 결과) | `{Domain}Info` | `BrandInfo`, `MemberInfo` | + +> DTO는 Java `record`로 구현한다. 불변이며, HTTP 관심사(status code, header)를 알지 않는다. + +#### Domain Service 호출 규칙 + +``` +Controller → Application Service → Domain Service → Repository + (@Transactional) (트랜잭션 안에서 동작) +``` + +**Controller → Domain Service 직접 호출을 금지하는 이유**: Domain Service가 `Repository.save()`를 호출하는데, 트랜잭션 없이 save가 실행되면 DB 일관성이 깨진다. Application Service가 `@Transactional` 경계를 소유하므로, Domain Service는 이 경계 안에서만 동작해야 한다. + +#### Facade 규칙 + +Facade는 **Application Service 간 순환 참조를 해소**하기 위해서만 도입한다. 같은 BC 내 cross-aggregate 규칙은 Facade가 아닌 Domain Service로 해결한다. + +| 구분 | Facade | Domain Service | +|------|--------|----------------| +| 위치 | Application 레이어 | Domain 레이어 | +| 역할 | Application Service 간 순환 참조 해소 | 같은 BC 내 cross-aggregate 규칙 | +| 예시 | (현재 해당 없음) | CatalogDomainService (Brand↔Product) | +| 의존 | 여러 Application Service 주입 | 도메인 객체 + Repository | + +#### 패키지 구조 + +``` +com.loopers.application +├── service/ +│ ├── MemberService.java +│ ├── BrandService.java +│ ├── AdminBrandService.java +│ ├── AdminProductService.java +│ ├── ProductService.java +│ ├── LikeService.java +│ ├── OrderService.java +│ └── dto/ +│ ├── CreateBrandCommand.java +│ ├── UpdateBrandCommand.java +│ ├── BrandInfo.java +│ ├── RegisterMemberCommand.java +│ ├── MemberInfo.java +│ └── ... +└── facade/ + └── (현재 비어 있음 — 순환 참조 발생 시 도입) +``` + +--- + +### 3-3. Presentation Layer + +#### 존재 이유 + +**"어떤 프로토콜로 외부와 소통하는가"**를 격리한다. + +같은 비즈니스 로직(Application)을 HTTP, Batch, Kafka 세 가지 인터페이스로 노출할 수 있다. 이것이 ErrorType에서 HttpStatus를 분리한 근본 이유다. + +```mermaid +graph LR + subgraph Presentation + API["commerce-api\n(HTTP)"] + BATCH["commerce-batch\n(Batch)"] + STREAMER["commerce-streamer\n(Kafka)"] + end + + subgraph ErrorHandling["에러 해석 — 각 Presentation이 자기 프로토콜로 해석"] + API_ERR["ErrorType → HttpStatus\n(ApiControllerAdvice)"] + BATCH_ERR["ErrorType → ExitCode"] + STREAM_ERR["ErrorType → DLQ / Retry"] + end + + SVC["Application Layer\ncommerce-service"] + + API --> SVC + BATCH --> SVC + STREAMER --> SVC + API --- API_ERR + BATCH --- BATCH_ERR + STREAMER --- STREAM_ERR +``` + +> **ErrorType은 "무슨 종류의 실패인가"만 표현한다.** "어떻게 응답할 것인가"는 Presentation이 결정한다. commerce-api는 HttpStatus로, commerce-batch는 ExitCode로, commerce-streamer는 DLQ/Retry로 해석한다. 이것이 Domain에 HttpStatus를 넣지 않은 이유다. + +#### ErrorType → HttpStatus 매핑 (commerce-api) + +```java +// ApiControllerAdvice.java — Presentation 레이어에서 해석 +private HttpStatus toHttpStatus(ErrorType errorType) { + return switch (errorType) { + case BAD_REQUEST -> HttpStatus.BAD_REQUEST; // 400 + case NOT_FOUND -> HttpStatus.NOT_FOUND; // 404 + case CONFLICT -> HttpStatus.CONFLICT; // 409 + case UNAUTHORIZED -> HttpStatus.UNAUTHORIZED; // 401 + case INTERNAL_ERROR -> HttpStatus.INTERNAL_SERVER_ERROR; // 500 + }; +} +``` + +#### 클래스 목록 (commerce-api 기준) + +| 클래스 | 책임 | +|--------|------| +| `ApiResponse` | 통합 응답 래퍼. `Metadata(result, errorCode, message) + data` | +| `ApiControllerAdvice` | `CoreException` → `ErrorType` → `HttpStatus` 매핑. 프레임워크 예외도 일관된 응답으로 변환 | +| `MemberController` | 회원 HTTP 엔드포인트. 헤더 인증 | +| `BrandController` | 사용자용 브랜드 조회 | +| `AdminBrandController` | 관리자용 브랜드 CRUD | +| `ProductController` | 사용자용 상품 조회 | +| `AdminProductController` | 관리자용 상품 CRUD | +| `LikeController` | 좋아요 등록/취소/조회 | +| `OrderController` | 회원 주문 생성/조회 | +| `AdminOrderController` | 관리자 주문 조회 | +| `Presentation DTO` | API 전용 Request/Response. Application DTO와 분리 | + +#### Presentation DTO vs Application DTO + +| 레이어 | DTO 위치 | 역할 | 예시 | +|--------|---------|------|------| +| Presentation | `interfaces/api/{도메인}/dto/` | HTTP 계약 (Request Body, Response Body) | `CreateBrandApiRequest`, `BrandApiResponse` | +| Application | `application/service/dto/` | Use Case 계약 (프로토콜 무관) | `CreateBrandCommand`, `BrandInfo` | + +> **왜 분리하는가?** Presentation DTO는 API 클라이언트와의 계약이고, Application DTO는 Use Case의 계약이다. 분리하면 API 스펙 변경이 Domain/Application에 영향을 주지 않고, 같은 Application을 다른 Presentation(Batch, Kafka)에서도 재사용할 수 있다. + +#### 패키지 구조 (commerce-api) + +``` +com.loopers.interfaces.api +├── ApiResponse.java +├── ApiControllerAdvice.java +├── member/ +│ ├── MemberController.java +│ └── dto/ ... +├── brand/ +│ ├── BrandController.java +│ ├── AdminBrandController.java +│ └── dto/ ... +├── product/ ... +├── like/ ... +└── order/ ... +``` + +--- + +### 3-4. Infrastructure Layer (modules/) + +#### 존재 이유 + +**Repository 인터페이스의 구현체**가 위치한다. Domain이 정의한 Repository(interface)를 JPA/Redis/Kafka로 구현한다. + +#### 클래스 목록 (modules/jpa 기준) + +| 클래스 | 종류 | 책임 | +|--------|------|------| +| `MemberJpaRepository` | Spring Data JPA | JPA 쿼리 정의 (`findByLoginId_Value`) | +| `MemberRepositoryImpl` | 구현체 | `MemberRepository` 인터페이스 구현, JpaRepository에 위임 | +| `BrandJpaRepository` | Spring Data JPA | Brand JPA 쿼리 | +| `BrandRepositoryImpl` | 구현체 | `BrandRepository` 인터페이스 구현 | +| `JpaConfig` | Configuration | `@EntityScan`, `@EnableJpaRepositories` | +| `QueryDslConfig` | Configuration | `JPAQueryFactory` 빈 등록 | +| `DataSourceConfig` | Configuration | DataSource 설정 (HikariCP) | + +#### Repository 추상체 — 구현체 + +``` +Domain (추상체 정의) Infrastructure (구현체) +┌─────────────────┐ ┌─────────────────────────┐ +│ MemberRepository │◄───────│ MemberRepositoryImpl │ +│ (interface) │ │ (@Repository) │ +└─────────────────┘ │ └── MemberJpaRepository│ + │ (Spring Data JPA) │ + └─────────────────────────┘ +``` + +> **@Embedded 필드 쿼리 규칙**: VO가 `@Embeddable`일 때 Spring Data JPA는 `findByLoginId_Value` 형태(언더스코어로 내부 필드 접근)를 사용한다. + +#### 패키지 구조 + +``` +com.loopers +├── config/ +│ └── jpa/ +│ ├── JpaConfig.java +│ ├── QueryDslConfig.java +│ └── DataSourceConfig.java +└── infrastructure/ + ├── member/ + │ ├── MemberJpaRepository.java + │ └── MemberRepositoryImpl.java + ├── brand/ ... + ├── product/ ... + └── ... +``` + +--- + +### 3-5. Cross-cutting Concerns (supports/) + +#### 존재 이유 + +모든 Presentation 모듈이 공유하는 인프라 설정이다. **레이어가 아닌 add-on** 성격이며, 비즈니스 로직을 모른다. + +| 모듈 | 책임 | +|------|------| +| `supports/jackson` | ObjectMapper 설정 (JSR-310, NON_NULL, FAIL_ON_UNKNOWN_PROPERTIES 비활성화) | +| `supports/logging` | Logback 설정 + Slack Appender (에러 알림) | +| `supports/monitoring` | Prometheus + Micrometer 메트릭 노출 | + +--- + +## 4. 의존 방향과 DIP + +### 4-1. 의존 방향 + +```mermaid +graph TB + P["Presentation\n(commerce-api / batch / streamer)"] + A["Application\n(commerce-service)"] + D["Domain"] + I["Infrastructure\n(modules/jpa, redis, kafka)"] + S["Supports\n(jackson, logging, monitoring)"] + + P -->|depends on| A + P -->|depends on| I + P -->|depends on| S + A -->|depends on| D + I -->|depends on| D + D -->|depends on| NOTHING["없음\n(jakarta.persistence-api만)"] +``` + +**핵심**: 모든 화살표가 Domain을 향한다. Domain은 프레임워크에 의존하지 않는다. + +### 4-2. Repository 추상체와 구현체 + +```mermaid +graph LR + subgraph Domain["Domain Layer"] + REPO["MemberRepository\n(interface)"] + end + subgraph Application["Application Layer"] + SVC["MemberService\n(@Transactional)"] + end + subgraph Infrastructure["Infrastructure Layer"] + IMPL["MemberRepositoryImpl\n(implements)"] + JPA["MemberJpaRepository\n(Spring Data JPA)"] + end + + SVC -->|"uses"| REPO + IMPL -->|"implements"| REPO + IMPL -->|"delegates to"| JPA +``` + +**왜 Repository 인터페이스를 Domain에 두는가?** `MemberService`는 `MemberRepository` **인터페이스에만** 의존한다. JPA 구현체를 모르므로, DB가 바뀌어도 Application/Domain은 변하지 않는다. 테스트에서 `FakeMemberRepository`를 주입하면 DB 없이 순수 단위 테스트가 가능하다. + +### 4-3. 실제 Gradle 의존성 + +```kotlin +// domain/build.gradle.kts — 프레임워크 의존 최소화 +dependencies { + api("jakarta.persistence:jakarta.persistence-api") +} + +// application/commerce-service/build.gradle.kts — Domain만 의존, HTTP 모름 +dependencies { + api(project(":domain")) + implementation("org.springframework:spring-tx") + implementation("org.springframework:spring-context") +} + +// modules/jpa/build.gradle.kts — Domain의 Repository를 구현 +dependencies { + api(project(":domain")) + api("org.springframework.boot:spring-boot-starter-data-jpa") +} +``` + +--- + +## 5. 바운디드 컨텍스트 매핑 + +### 5-1. BC 경계와 Aggregate + +```mermaid +graph TB + subgraph MemberBC["Member Context"] + M["Member\n(Aggregate Root)"] + end + + subgraph CatalogBC["Catalog Context"] + B["Brand\n(Aggregate Root)"] + P["Product\n(Aggregate Root)"] + CDS["CatalogDomainService\n(cross-aggregate 규칙)"] + CDS ---|"조율"| B + CDS ---|"조율"| P + end + + subgraph LikeBC["Like Context"] + L["Like\n(Aggregate Root)"] + end + + subgraph OrderBC["Order Context"] + O["Order\n(Aggregate Root)"] + OLS["OrderLineSnapshot\n(VO, Composition)"] + O ---|"포함"| OLS + end + + P -..->|"brandId (Long)"| B + L -..->|"memberId (Long)"| M + L -..->|"subjectId (Long)"| P + O -..->|"memberId (Long)"| M + OLS -..->|"productId (Long)"| P +``` + +**점선(`..>`) = ID(Long) 참조**. 객체 참조가 아니다. + +### 5-2. 무FK 정책 + +BC 간 참조뿐 아니라 **같은 BC 내(Product → Brand)에서도 FK를 사용하지 않는다.** + +| 이유 | 설명 | +|------|------| +| 도메인 규칙 명시적 제어 | 삭제 연쇄를 DB CASCADE 대신 CatalogDomainService로 제어. 삭제 순서(상품 먼저 → 브랜드 나중)와 부가 로직을 코드에 명시적으로 표현 | +| 운영 유연성 | 데이터 마이그레이션, 벌크 작업 시 FK가 제약이 됨 | + +참조 무결성은 **애플리케이션 레벨에서 보장**한다 (상세: `04-erd.md` 5절). + +### 5-3. Brand↔Product가 독립 Aggregate인 이유 + +Brand와 Product는 같은 Catalog BC에 속하지만 **독립 Aggregate**이다. + +| 기준 | 판단 | +|------|------| +| 독립적 생명주기 | Product 없이 Brand만 존재 가능 | +| 규모 차이 | Brand 1개에 Product 수천 개 가능. Brand Aggregate에 포함하면 메모리/성능 문제 | +| 독립 변경 | Product 가격/재고 수정 시 Brand를 잠글 필요 없음 | + +Cross-aggregate 규칙(삭제 연쇄, 생성 시 브랜드 검증)은 **CatalogDomainService**에서 처리한다. + +--- + +## 6. 요청 흐름 추적 + +### 6-1. 단순 흐름 — 브랜드 등록 + +레이어 경계가 participant로 드러나는 시퀀스 다이어그램. + +```mermaid +sequenceDiagram + actor A as 관리자 + participant CTRL as AdminBrandController
(Presentation) + participant SVC as AdminBrandService
(Application) + participant BRAND as Brand
(Domain Entity) + participant PORT as BrandRepository
(Domain — interface) + participant IMPL as BrandRepositoryImpl
(Infrastructure — 구현체) + + A->>CTRL: POST /api/admin/brands {name} + Note over CTRL: Presentation DTO → Application Command 변환 + + CTRL->>SVC: create(CreateBrandCommand) + Note over SVC: @Transactional 시작 + + SVC->>PORT: existsByName(name) + PORT->>IMPL: (실제 JPA 호출) + IMPL-->>SVC: boolean + + alt 중복이면 + SVC-->>CTRL: CoreException(CONFLICT) + CTRL-->>A: 409 Conflict (ApiControllerAdvice가 ErrorType 해석) + end + + SVC->>BRAND: Brand.create(name) + Note over BRAND: 이름 검증 (빈 값, 길이) + + SVC->>PORT: save(brand) + PORT->>IMPL: (구현체 위임) + IMPL-->>SVC: Brand + + Note over SVC: @Transactional 종료 + + SVC-->>CTRL: BrandInfo + Note over CTRL: Application DTO → Presentation DTO 변환 + CTRL-->>A: 201 Created + ApiResponse +``` + +#### 읽는 포인트 + +- **DTO 변환이 두 번** 일어난다: Presentation → Application (요청), Application → Presentation (응답) +- **이름 검증(논리적)**은 Brand Entity가, **중복 검증(물리적 — DB 조회 필요)**은 Application Service가 수행한다 +- **에러 해석**은 ApiControllerAdvice(Presentation)가 담당한다: `ErrorType.CONFLICT → HttpStatus.CONFLICT(409)` + +### 6-2. 복합 흐름 — 주문 생성 (Cross-BC) + +논리적(Domain)과 물리적(Application)의 경계가 드러나는 흐름. + +```mermaid +sequenceDiagram + actor M as 회원 + participant CTRL as OrderController
(Presentation) + participant OS as OrderService
(Application) + participant PS as ProductService
(Application) + participant P as Product
(Domain Entity) + participant STOCK as Stock
(Domain VO) + participant PORT as OrderRepository
(Domain — interface) + + M->>CTRL: POST /api/orders [{productId, quantity}, ...] + CTRL->>OS: createOrder(memberId, items) + Note over OS: @Transactional 시작 + + OS->>OS: productId 오름차순 정렬 + Note right of OS: 물리적 — 데드락 방지 + + loop 각 상품 (Application 오케스트레이션) + OS->>PS: getProductForOrder(productId) + Note over PS: 비관적 락 + 유효성 확인 (물리적) + PS-->>OS: Product + end + + loop 재고 확인 (Domain 규칙) + OS->>P: hasEnoughStock(quantity) + P->>STOCK: isEnough(quantity) + Note over STOCK: 논리적 — stock >= quantity + end + + alt 모든 재고 충분 + loop 재고 차감 (Domain 규칙) + OS->>P: decreaseStock(quantity) + P->>STOCK: decrease(quantity) + Note over STOCK: 새 Stock 반환 (불변 VO) + end + OS->>PORT: save(ACCEPTED + snapshots) + else 재고 부족 + OS->>PORT: save(REJECTED + snapshots) + end + + Note over OS: @Transactional 종료 + OS-->>CTRL: OrderResult + CTRL-->>M: Response (ApiResponse) +``` + +#### 읽는 포인트 + +이 흐름에서 **논리적(Domain)과 물리적(Application)의 경계**가 드러난다. + +| 로직 | 레이어 | 이유 | +|------|--------|------| +| productId 오름차순 정렬 | Application (물리적) | 데드락 방지 = 기술 관심사 | +| 비관적 락 획득 | Application (물리적) | 동시성 제어 = 기술 관심사 | +| `Stock.isEnough(quantity)` | Domain (논리적) | "재고 >= 수량" = 기술 무관한 규칙 | +| `Stock.decrease(quantity)` | Domain (논리적) | 재고 차감 = 기술 무관한 규칙 | +| 수락/거절 판단 | Domain (논리적) | "전부 충분하면 수락" = 비즈니스 규칙 | +| 위 흐름의 조율 | Application (물리적) | 트랜잭션 + Cross-BC 오케스트레이션 | + +--- + +## 7. 새 기능은 어디에 놓는가 — 의사결정 프레임워크 + +### 7-1. 판별 플로우 + +```mermaid +flowchart TD + START["새 로직 추가"] --> Q1{"이 규칙은 기술 구현과\n무관하게 항상 성립하는가?"} + + Q1 -->|"Yes — 논리적"| Q2{"단일 엔티티의\n상태/불변식인가?"} + Q1 -->|"No — 물리적"| Q5{"BC 경계를 넘는\n조율인가?"} + + Q2 -->|"Yes"| A1["Entity 또는 VO"] + Q2 -->|"No"| Q3{"같은 BC 내\ncross-aggregate 규칙인가?"} + + Q3 -->|"Yes"| A2["Domain Service"] + Q3 -->|"No"| Q5 + + Q5 -->|"Yes"| A3["Application Service"] + Q5 -->|"No"| Q6{"물리적 기술 관심사인가?\n(트랜잭션, 락, 데드락 방지)"} + + Q6 -->|"Yes"| A3 + Q6 -->|"No"| Q7{"Application Service 간\n순환 참조인가?"} + + Q7 -->|"Yes"| A4["Facade"] + Q7 -->|"No"| A3 + + A1 --> DOMAIN["Domain Layer"] + A2 --> DOMAIN + A3 --> APP["Application Layer"] + A4 --> APP + + style DOMAIN fill:#e8f5e9 + style APP fill:#e3f2fd +``` + +### 7-2. 구체적 예시 + +| 시나리오 | 결정 | 이유 | +|---------|------|------| +| "재고는 음수가 될 수 없다" | `Stock` VO (Domain) | 기술 무관한 불변식 | +| "가격은 0보다 커야 한다" | `Price` VO (Domain) | 기술 무관한 불변식 | +| "이미 삭제된 브랜드는 다시 삭제 불가" | `Brand.guardNotDeleted()` (Domain) | 엔티티 자기 상태 검증 | +| "Brand 삭제 시 Product 연쇄 삭제" | `CatalogDomainService` (Domain) | 같은 BC 내 cross-aggregate 규칙 | +| "productId 오름차순 정렬 (데드락 방지)" | `OrderService` (Application) | 물리적 기술 관심사 | +| "좋아요 등록 시 상품 유효성 확인" | `LikeService` (Application) | Cross-BC 조율 (Like → Catalog) | +| "ErrorType → HttpStatus 매핑" | `ApiControllerAdvice` (Presentation) | 프로토콜 해석 | +| "MemberRepository 구현" | `MemberRepositoryImpl` (Infrastructure) | Repository 구현체 | + +### 7-3. 핵심 원칙 + +1. **판단 주체는 규칙을 아는 객체**다. Service가 `if (stock >= quantity)`를 직접 검사하지 않는다. `Stock.isEnough(quantity)`를 호출한다. +2. **같은 BC 내 cross-aggregate 규칙은 Domain Service**다. Facade가 아니다. +3. **Application Service는 조율자**다. 규칙 자체를 구현하지 않고, Domain 객체에 위임한다. +4. **Facade는 순환 참조 해소 전용**이다. "BC 간 조율"이 Facade의 역할이 아니다. + +--- + +## 8. 설계 결정 기록 (ADR) + +아키텍처 레벨 결정만 정리한다. 클래스 레벨 결정은 `03-class-diagram.md` 6절 참조. + +| # | 결정 | 맥락 | 선택 이유 | 대안 | +|---|------|------|----------|------| +| 1 | Domain에 JPA 어노테이션 허용 | 순수 POJO vs 실용적 매핑 | `jakarta.persistence-api`는 인터페이스 수준 스펙. 매핑 레이어 추가 비용 > 이득 | 순수 POJO + 별도 매핑 레이어 | +| 2 | ErrorType에 HttpStatus 미포함 | Presentation이 3개 (HTTP, Batch, Kafka) | Batch/Kafka에서 HttpStatus는 무의미. 각 Presentation이 자기 프로토콜로 해석해야 한다 | ErrorType에 HttpStatus 포함 | +| 3 | Repository 인터페이스를 Domain에 배치 | 의존 역전 | Domain이 추상체를 소유하고 Infrastructure가 구현하면 의존 방향이 안쪽을 향한다. 테스트 시 Fake 주입 가능 | Repository를 Infrastructure에 배치 | +| 4 | Application에 spring-web 미포함 | Application의 프로토콜 독립성 | 트랜잭션(`@Transactional`)과 DI(`@Service`)만 필요. HTTP는 Presentation의 책임 | spring-web 포함 | +| 5 | BC 간/내 모두 FK 없음 | 도메인 규칙 명시적 제어 | 삭제 연쇄를 DB CASCADE 대신 CatalogDomainService로 제어. 규칙이 코드에 표현된다 | FK 사용 | +| 6 | BaseTimeEntity / BaseEntity 분리 | 삭제 정책을 상속으로 표현 | Like(hard-delete), Order(삭제 없음)에 `deletedAt`은 불필요. 상속이 의도를 코드로 드러낸다 | BaseEntity 하나만 | +| 7 | Domain Service 필요할 때만 도입 | YAGNI | 현재 CatalogDomainService만 실제 필요. Member BC에 DomainService는 불필요 | 모든 BC에 미리 생성 | +| 8 | presentation에서만 Spring Boot 플러그인 | Library 모듈에 bootJar 불필요 | domain, application, modules는 `java-library`. bootJar는 실행 모듈(presentation)만 | 전체 모듈에 적용 | + +--- + +## 9. 기반 문서 참조 + +| 문서 | 내용 | 본 문서와의 관계 | +|------|------|----------------| +| `01-requirements.md` | 기능 정의서 | 아키텍처가 지원해야 하는 유스케이스의 원천 | +| `02-sequence-diagrams.md` | 시퀀스 다이어그램 | 6절(요청 흐름)의 세부 참조. 각 API 흐름의 객체 간 메시지 | +| `03-class-diagram.md` | 클래스 다이어그램 | 3절(레이어 상세)의 클래스 설계 원천. 엔티티/VO 분류, 책임 분산 점검 | +| `04-erd.md` | ERD | VO → 컬럼 매핑, 무FK 운영 규약 세부 | +| `05-domain-model.md` | 도메인 모델 정의서 | BC 경계, 레이어 책임 규칙, Service 분류 기준의 원천 (SoT) | diff --git a/docs/design/base/class-diagram-erd.md b/docs/design/base/class-diagram-erd.md index f18c7e7e0..04a7ac1fd 100644 --- a/docs/design/base/class-diagram-erd.md +++ b/docs/design/base/class-diagram-erd.md @@ -1,5 +1,7 @@ # 클래스 다이어그램 & ERD +> **ARCHIVE** — 이 문서는 히스토리 참고용입니다. 현재 설계 기준 문서(SoT)는 `docs/design/01~04-*.md`입니다. + > 작성일: 2026-02-10 > 도메인 정의서(v2) + 요구사항 분석 기반 diff --git a/docs/design/base/domain-definition-v2.md b/docs/design/base/domain-definition-v2.md index de0ecdaf4..2b6d29b24 100644 --- a/docs/design/base/domain-definition-v2.md +++ b/docs/design/base/domain-definition-v2.md @@ -1,5 +1,7 @@ # 감성 이커머스 도메인 정의서 (v2) +> **ARCHIVE** — 이 문서는 히스토리 참고용입니다. 현재 설계 기준 문서(SoT)는 `docs/design/01~04-*.md`입니다. + > 작성일: 2026-02-10 > 상태: **확정** diff --git a/docs/design/base/member-class-diagram.md b/docs/design/base/member-class-diagram.md index a2696cf76..59d806398 100644 --- a/docs/design/base/member-class-diagram.md +++ b/docs/design/base/member-class-diagram.md @@ -1,5 +1,7 @@ # 회원(Member) 도메인 클래스 다이어그램 +> **ARCHIVE** — 이 문서는 히스토리 참고용입니다. 현재 설계 기준 문서(SoT)는 `docs/design/01~04-*.md`입니다. + > 작성일: 2026-02-01 > 상태: **Planner Mode - 승인 대기** diff --git a/docs/design/base/requirements-input.md b/docs/design/base/requirements-input.md index 656931567..1b548e6f6 100644 --- a/docs/design/base/requirements-input.md +++ b/docs/design/base/requirements-input.md @@ -1,5 +1,7 @@ # 요구사항 분석 입력 문서 +> **ARCHIVE** — 이 문서는 히스토리 참고용입니다. 현재 설계 기준 문서(SoT)는 `docs/design/01~04-*.md`입니다. + > 도메인 정의서(v2) 기반 — `/requirements-analysis` 스킬 입력용 ## 프로젝트 컨텍스트 diff --git a/docs/design/base/sequence-diagrams.md b/docs/design/base/sequence-diagrams.md index 16a33f5c8..41590e143 100644 --- a/docs/design/base/sequence-diagrams.md +++ b/docs/design/base/sequence-diagrams.md @@ -1,5 +1,7 @@ # 시퀀스 다이어그램 +> **ARCHIVE** — 이 문서는 히스토리 참고용입니다. 현재 설계 기준 문서(SoT)는 `docs/design/01~04-*.md`입니다. + > 작성일: 2026-02-10 > 도메인 정의서(v2) + 요구사항 분석 기반 > 각 다이어그램은 "왜 필요한가 → 다이어그램 → 읽는 포인트" 순서로 기술한다. diff --git a/docs/design/base/ubiquitous-language.md b/docs/design/base/ubiquitous-language.md index 833db3689..6aca72609 100644 --- a/docs/design/base/ubiquitous-language.md +++ b/docs/design/base/ubiquitous-language.md @@ -1,5 +1,7 @@ # 유비쿼터스 언어 사전 (Ubiquitous Language Dictionary) +> **ARCHIVE** — 이 문서는 히스토리 참고용입니다. 현재 설계 기준 문서(SoT)는 `docs/design/01~04-*.md`입니다. + > 작성일: 2026-02-10 > 이 문서는 기획, 설계, 코드에서 동일한 용어를 사용하기 위한 약속입니다. > 코드의 클래스명, 변수명, enum 값은 이 사전의 영문 표현을 따릅니다. diff --git a/docs/design/base/user-story.md b/docs/design/base/user-story.md index 7e98c772b..6159282d5 100644 --- a/docs/design/base/user-story.md +++ b/docs/design/base/user-story.md @@ -1,5 +1,7 @@ # 유저 스토리 & 유스케이스 +> **ARCHIVE** — 이 문서는 히스토리 참고용입니다. 현재 설계 기준 문서(SoT)는 `docs/design/01~04-*.md`입니다. + > 작성일: 2026-02-10 > 도메인 정의서(v2) 기반 diff --git a/docs/planning/brand-plan.md b/docs/planning/brand-plan.md new file mode 100644 index 000000000..5958afe35 --- /dev/null +++ b/docs/planning/brand-plan.md @@ -0,0 +1,311 @@ +# Brand 도메인 TDD 구현 계획서 + +> 작성일: 2026-02-22 +> 선행 조건: Phase 1~3 (Member 리팩토링) 완료 +> 후행 작업: Product 도메인 구현 (brand-plan 완료 후) + +--- + +## 1. 요구사항 요약 + +### 1-1. API 목록 + +| # | 역할 | Method | URI | 설명 | +|---|------|--------|-----|------| +| 1 | User | GET | `/api/brands` | 활성 브랜드 목록 조회 | +| 2 | Admin | POST | `/api/admin/brands` | 브랜드 생성 | +| 3 | Admin | GET | `/api/admin/brands/{id}` | 브랜드 단건 조회 (삭제 포함) | +| 4 | Admin | PUT | `/api/admin/brands/{id}` | 브랜드 수정 | +| 5 | Admin | DELETE | `/api/admin/brands/{id}` | 브랜드 삭제 (soft-delete) | +| 6 | Admin | GET | `/api/admin/brands` | 브랜드 전체 목록 조회 (삭제 포함) | + +### 1-2. 도메인 규칙 + +- Brand는 `name` (브랜드명)을 가진다. +- Brand name은 **UNIQUE** 제약이 있다 (활성 상태 기준). +- Brand는 soft-delete를 지원한다 (`BaseEntity` 상속, `deletedAt`). +- 삭제된 Brand는 User API에 노출되지 않는다. +- 삭제 시 name을 변경하여 UNIQUE 제약을 해소한다 (예: `"나이키"` → `"나이키_deleted_1708XXX"`). +- 이미 삭제된 Brand를 다시 삭제하면 예외를 던진다 (`guardNotDeleted`). + +### 1-3. 에러 시나리오 매트릭스 + +| # | 시나리오 | ErrorType | ExceptionMessage | +|---|---------|-----------|-----------------| +| 1 | 브랜드명 빈 값 또는 길이 초과 | BAD_REQUEST | `BrandExceptionMessage.INVALID_NAME` | +| 2 | 브랜드명 중복 (생성/수정 시) | CONFLICT | `BrandExceptionMessage.DUPLICATE_NAME` | +| 3 | 존재하지 않는 브랜드 조회/수정/삭제 | NOT_FOUND | `BrandExceptionMessage.NOT_FOUND` | +| 4 | 이미 삭제된 브랜드 삭제 시도 | BAD_REQUEST | `BrandExceptionMessage.ALREADY_DELETED` | +| 5 | 이미 삭제된 브랜드 수정 시도 | BAD_REQUEST | `BrandExceptionMessage.ALREADY_DELETED` | + +--- + +## 2. 설계 결정 + +### 2-1. Entity 상속: `BaseEntity` + +Brand는 soft-delete가 필요하므로 `BaseEntity`를 상속한다. + +``` +BaseTimeEntity (id, createdAt, updatedAt) + └── BaseEntity (deletedAt, delete(), restore()) + └── Brand (name) +``` + +### 2-2. `Brand.delete()` override + +`BaseEntity.delete()`를 override하여 두 가지 추가 동작을 수행한다: + +1. **guardNotDeleted**: 이미 삭제된 상태이면 예외 +2. **name 변경**: UNIQUE 제약 해소를 위해 `"브랜드명_deleted_{timestamp}"` 형태로 변경 + +```java +@Override +public void delete() { + guardNotDeleted(); + this.name = this.name + "_deleted_" + System.currentTimeMillis(); + super.delete(); +} + +private void guardNotDeleted() { + if (getDeletedAt() != null) { + throw new CoreException(ErrorType.BAD_REQUEST, + BrandExceptionMessage.ALREADY_DELETED.message()); + } +} +``` + +### 2-3. VO 불필요 + +Brand는 `name` 하나의 필드만 가지며, 검증 규칙이 단순하여 별도 VO 없이 Entity 내에서 직접 검증한다. + +### 2-4. DTO 분리 구조 + +``` +Presentation Layer (commerce-api) +├── interfaces/api/brand/dto/ +│ ├── CreateBrandApiRequest.java → CreateBrandCommand 변환 +│ ├── UpdateBrandApiRequest.java → UpdateBrandCommand 변환 +│ └── BrandApiResponse.java ← BrandInfo 변환 +│ +Application Layer (commerce-service) +├── application/service/dto/ +│ ├── CreateBrandCommand.java (record) +│ ├── UpdateBrandCommand.java (record) +│ └── BrandInfo.java (record, from(Brand)) +``` + +### 2-5. CatalogDomainService (Product 구현 후) + +Brand 삭제 시 소속 Product도 연쇄 soft-delete해야 한다. Brand와 Product는 같은 BC(Catalog)의 독립 Aggregate이므로, cross-aggregate 규칙은 **CatalogDomainService**(Domain 레이어)에서 처리한다. + +- **현재**: `AdminBrandService.delete()` — Brand만 삭제 (Product 미구현) +- **Product 구현 후**: `AdminBrandService.delete()` → `CatalogDomainService.deleteBrand()` 호출 + +```java +// domain/src/main/java/com/loopers/domain/catalog/CatalogDomainService.java +// Product 구현 후 추가 +@RequiredArgsConstructor +public class CatalogDomainService { + private final BrandRepository brandRepository; + private final ProductRepository productRepository; + + public void deleteBrand(Long brandId) { + Brand brand = brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + BrandExceptionMessage.NOT_FOUND.message())); + productRepository.softDeleteByBrandId(brandId); + brand.delete(); + } +} +``` + +> Facade와의 차이: Facade는 Application Service 간 순환 참조 해소 용도. Brand↔Product는 같은 BC이므로 Domain Service가 적합하다. + +--- + +## 3. TDD 구현 순서 + +### Step 1: ExceptionMessage 정의 + +> 파일: `domain/src/main/java/com/loopers/domain/brand/BrandExceptionMessage.java` + +- `INVALID_NAME` — 브랜드명 빈 값 또는 길이 초과 +- `DUPLICATE_NAME` — 브랜드명 중복 +- `NOT_FOUND` — 브랜드 없음 +- `ALREADY_DELETED` — 이미 삭제된 브랜드 + +### Step 2: Entity (Red → Green) + +> 파일: `domain/src/main/java/com/loopers/domain/brand/Brand.java` +> 테스트: `domain/src/test/java/com/loopers/domain/brand/BrandTest.java` + +테스트 케이스: +- 브랜드 생성 성공 +- 브랜드명 빈 값이면 예외 +- 브랜드명 길이 초과 시 예외 +- 브랜드명 수정 성공 +- 삭제 시 deletedAt 설정 +- 삭제 시 name 변경 (`_deleted_` suffix) +- 이미 삭제된 브랜드 삭제 시 예외 +- 이미 삭제된 브랜드 수정 시 예외 + +### Step 3: Fixture + +> 파일: `domain/src/testFixtures/java/com/loopers/domain/brand/BrandFixture.java` + +```java +public class BrandFixture { + public static final String DEFAULT_NAME = "나이키"; + + public static Brand create() { ... } + public static Brand create(String name) { ... } +} +``` + +### Step 4: Repository Port + +> 파일: `domain/src/main/java/com/loopers/domain/brand/BrandRepository.java` + +```java +public interface BrandRepository { + Brand save(Brand brand); + Optional findById(Long id); + boolean existsByName(String name); + List findAllActive(); + List findAll(); +} +``` + +### Step 5: Service + DTOs (Red → Green) + +> 파일: `application/commerce-service/src/main/java/com/loopers/application/service/AdminBrandService.java` +> 파일: `application/commerce-service/src/main/java/com/loopers/application/service/BrandService.java` +> 테스트: `application/commerce-service/src/test/java/com/loopers/application/service/AdminBrandServiceTest.java` +> 테스트: `application/commerce-service/src/test/java/com/loopers/application/service/BrandServiceTest.java` + +**BrandService** (User): +- `getActiveBrands()` — 활성 브랜드 목록 반환 + +**AdminBrandService** (Admin): +- `create(CreateBrandCommand)` — 중복 검사 + 생성 +- `getById(Long)` — 단건 조회 +- `getAll()` — 전체 목록 조회 (삭제 포함) +- `update(Long, UpdateBrandCommand)` — 중복 검사 + 수정 +- `delete(Long)` — soft-delete + +테스트 케이스 (AdminBrandServiceTest): +- 생성 시 브랜드명 중복이면 CONFLICT 예외 +- 생성 성공 시 save 호출 확인 +- 조회 시 존재하지 않으면 NOT_FOUND 예외 +- 수정 시 존재하지 않으면 NOT_FOUND 예외 +- 수정 시 다른 브랜드와 이름 중복이면 CONFLICT 예외 +- 삭제 시 존재하지 않으면 NOT_FOUND 예외 + +### Step 6: Repository Adapter + +> 파일: `modules/jpa/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java` +> 파일: `modules/jpa/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java` + +```java +public interface BrandJpaRepository extends JpaRepository { + boolean existsByName(String name); + List findAllByDeletedAtIsNull(); +} +``` + +### Step 7: Controller + +> 파일: `presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java` +> 파일: `presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/AdminBrandController.java` + +**BrandController**: +- `GET /api/brands` → `BrandService.getActiveBrands()` + +**AdminBrandController**: +- `POST /api/admin/brands` → `AdminBrandService.create()` +- `GET /api/admin/brands/{id}` → `AdminBrandService.getById()` +- `GET /api/admin/brands` → `AdminBrandService.getAll()` +- `PUT /api/admin/brands/{id}` → `AdminBrandService.update()` +- `DELETE /api/admin/brands/{id}` → `AdminBrandService.delete()` (Product 구현 후: `CatalogDomainService.deleteBrand()` 경유) + +### Step 8: 통합 / E2E 테스트 + +> 파일: `presentation/commerce-api/src/test/java/com/loopers/controller/BrandE2ETest.java` + +테스트 시나리오: +- 브랜드 생성 → 201 Created +- 브랜드명 중복 생성 → 409 Conflict +- 활성 브랜드 목록 조회 → 200 OK (삭제된 브랜드 미포함) +- 브랜드 수정 → 200 OK +- 브랜드 삭제 → 204 No Content +- 삭제된 브랜드 재삭제 → 400 Bad Request +- Admin 전체 목록 조회 → 삭제 포함 + +--- + +## 4. 파일 생성 목록 + +### Domain Layer (`domain/`) + +| 경로 | 설명 | +|------|------| +| `domain/src/main/java/com/loopers/domain/brand/Brand.java` | Entity | +| `domain/src/main/java/com/loopers/domain/brand/BrandRepository.java` | Repository Port | +| `domain/src/main/java/com/loopers/domain/brand/BrandExceptionMessage.java` | 예외 메시지 | +| `domain/src/test/java/com/loopers/domain/brand/BrandTest.java` | 도메인 단위 테스트 | +| `domain/src/testFixtures/java/com/loopers/domain/brand/BrandFixture.java` | Fixture | + +### Application Layer (`application/commerce-service/`) + +| 경로 | 설명 | +|------|------| +| `application/commerce-service/src/main/java/com/loopers/application/service/BrandService.java` | User Service | +| `application/commerce-service/src/main/java/com/loopers/application/service/AdminBrandService.java` | Admin Service | +| `application/commerce-service/src/main/java/com/loopers/application/service/dto/CreateBrandCommand.java` | 생성 DTO | +| `application/commerce-service/src/main/java/com/loopers/application/service/dto/UpdateBrandCommand.java` | 수정 DTO | +| `application/commerce-service/src/main/java/com/loopers/application/service/dto/BrandInfo.java` | 응답 DTO | +| `application/commerce-service/src/test/java/com/loopers/application/service/BrandServiceTest.java` | User Service 테스트 | +| `application/commerce-service/src/test/java/com/loopers/application/service/AdminBrandServiceTest.java` | Admin Service 테스트 | + +### Presentation Layer (`presentation/commerce-api/`) + +| 경로 | 설명 | +|------|------| +| `presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java` | User Controller | +| `presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/AdminBrandController.java` | Admin Controller | +| `presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/CreateBrandApiRequest.java` | Presentation 생성 DTO | +| `presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/UpdateBrandApiRequest.java` | Presentation 수정 DTO | +| `presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandApiResponse.java` | Presentation 응답 DTO | +| `presentation/commerce-api/src/test/java/com/loopers/controller/BrandE2ETest.java` | E2E 테스트 | + +### Infrastructure Layer (`modules/jpa/`) + +| 경로 | 설명 | +|------|------| +| `modules/jpa/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java` | Spring Data JPA | +| `modules/jpa/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java` | Repository Adapter | + +--- + +## 5. DB 스키마 + +```sql +CREATE TABLE brand ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL UNIQUE, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + deleted_at DATETIME(6) NULL +); +``` + +--- + +## 6. 참조 + +- 기존 패턴: `domain/src/main/java/com/loopers/domain/member/Member.java` +- BaseEntity: `domain/src/main/java/com/loopers/domain/BaseEntity.java` +- ErrorType: `domain/src/main/java/com/loopers/support/error/ErrorType.java` +- 도메인 모델 정의서: `docs/design/05-domain-model.md` +- Member 리팩토링 기록: `docs/planning/refactoring-plan.md` diff --git a/docs/planning/phase1-structure-refactoring.md b/docs/planning/phase1-structure-refactoring.md index e2eac46db..40605bc4b 100644 --- a/docs/planning/phase1-structure-refactoring.md +++ b/docs/planning/phase1-structure-refactoring.md @@ -1,7 +1,7 @@ # Phase 1: 구조 변경 설계서 > 작성일: 2026-02-21 (최종 수정: 2026-02-21) -> 상태: 구현 진행 중 +> 상태: 완료 --- @@ -46,12 +46,12 @@ ``` presentation/commerce-api (bootJar) - ├─→ application/commerce-api (서비스 로직) - ├─→ domain (엔티티, 포트) - ├─→ modules/jpa, redis (인프라 어댑터) - └─→ supports/* (횡단 관심사) + ├─→ application/commerce-service (서비스 로직) + ├─→ domain (엔티티, 포트) + ├─→ modules/jpa, redis (인프라 어댑터) + └─→ supports/* (횡단 관심사) -application/commerce-api (java-library) +application/commerce-service (java-library) └─→ domain (엔티티, 포트만 참조) modules/jpa (java-library) @@ -110,7 +110,7 @@ Root │ └── MemberTest.java │ ├── application/ ← 신규: Application Layer -│ └── commerce-api/ +│ └── commerce-service/ │ ├── build.gradle.kts │ └── src/ │ ├── main/java/com/loopers/ @@ -179,7 +179,7 @@ Root ```kotlin include( ":domain", - ":application:commerce-api", + ":application:commerce-service", ":presentation:commerce-api", ":presentation:commerce-batch", ":presentation:commerce-streamer", @@ -196,7 +196,9 @@ include( | 항목 | Before | After | |------|--------|-------| -| bootJar 필터 | `it.parent?.name.equals("apps")` | `it.parent?.name.equals("presentation")` | +| Spring Boot 플러그인 | 전체 서브프로젝트에 적용 | presentation 모듈에서만 적용 | +| BOM 버전 관리 | Spring Boot 플러그인이 자동 제공 | `spring-boot-dependencies` BOM 명시 import | +| bootJar/jar 태스크 설정 | root에서 기본 비활성화/활성화 | 불필요 (Spring Boot 플러그인 없는 모듈에는 bootJar 자체가 없음) | | 컨테이너 비활성화 | `project("apps")` | `project("application")` + `project("presentation")` | ### 4-3. 신규 모듈 build.gradle.kts @@ -205,14 +207,18 @@ include( - Plugins: `java-library`, `java-test-fixtures` - Dependencies: `api("jakarta.persistence:jakarta.persistence-api")` -**application/commerce-api/build.gradle.kts:** +**application/commerce-service/build.gradle.kts:** - Plugins: `java-library` -- Dependencies: `api(project(":domain"))` +- Dependencies: `api(project(":domain"))`, `implementation("org.springframework:spring-web")`, `implementation("org.springframework:spring-tx")` **presentation/commerce-api/build.gradle.kts:** -- Dependencies: domain, application:commerce-api, modules:jpa, modules:redis, supports:*, web, actuator, springdoc +- Plugin: `apply(plugin = "org.springframework.boot")` (bootJar 활성화) +- Dependencies: domain, application:commerce-service, modules:jpa, modules:redis, supports:*, web, actuator, springdoc - TestFixtures: domain, modules:jpa, modules:redis +**presentation/commerce-batch/build.gradle.kts, commerce-streamer/build.gradle.kts:** +- Plugin: `apply(plugin = "org.springframework.boot")` (bootJar 활성화) + **modules/jpa/build.gradle.kts 수정:** - `api(project(":domain"))` 추가 @@ -233,7 +239,7 @@ include( 신규 생성: `domain/.../member/MemberRepository.java` (Port 인터페이스) 이동: `MemberTest.java` (testFixtures → domain/src/test/, 패키지 변경) -### 5-2. apps/commerce-api → application/commerce-api (비즈니스 로직) +### 5-2. apps/commerce-api → application/commerce-service (비즈니스 로직) | 파일 | 패키지 변경 | |------|-----------| @@ -267,7 +273,7 @@ include( ### 5-5. 테스트 파일 분리 -**단위 테스트 → application/commerce-api/src/test/:** +**단위 테스트 → application/commerce-service/src/test/:** - MemberServiceTest.java (Mockito) - ExampleModelTest.java - CoreExceptionTest.java @@ -316,7 +322,14 @@ application/의 `@Service`, modules/의 `@Configuration` 등 모두 자동 감 ### 7-3. 전이 의존성 `modules/jpa`가 `api(project(":domain"))`을 선언 → modules:jpa 의존하는 모듈이 domain을 자동으로 받음. -`application/commerce-api`가 `api(project(":domain"))` 선언 → presentation이 domain을 자동으로 받음. +`application/commerce-service`가 `api(project(":domain"))` 선언 → presentation이 domain을 자동으로 받음. + +### 7-4. Spring Boot 플러그인 적용 범위 + +| 모듈 그룹 | Spring Boot 플러그인 | bootJar 태스크 | BOM 버전 관리 | +|-----------|---------------------|---------------|-------------| +| domain, application, modules, supports | 미적용 | 없음 | `spring-boot-dependencies` BOM 명시 import | +| presentation/* | 적용 | 있음 (기본 활성화) | 플러그인 자동 제공 + BOM import | 그래도 presentation에서 `implementation(project(":domain"))` 명시하여 의도를 명확히 함. --- @@ -326,7 +339,7 @@ application/의 `@Service`, modules/의 `@Configuration` 등 모두 자동 감 1. **Gradle 설정 변경** (settings, root build, 신규 build 파일들) 2. **domain 모듈 생성** + 코드 이동 (modules/jpa → domain) 3. **MemberRepository Adapter** 생성 (modules/jpa) -4. **application/commerce-api** 생성 + 비즈니스 코드 이동 +4. **application/commerce-service** 생성 + 비즈니스 코드 이동 5. **presentation/commerce-api** 생성 + 인터페이스/부트 코드 이동 6. **batch/streamer** 이동 (apps → presentation) 7. **Kafka 오타 수정** @@ -337,13 +350,13 @@ application/의 `@Service`, modules/의 `@Configuration` 등 모두 자동 감 ## 9. 완료 기준 -- [ ] `./gradlew clean build` 전체 통과 -- [ ] `./gradlew :domain:test` — MemberTest 통과 -- [ ] `./gradlew :application:commerce-api:test` — 단위 테스트 통과 -- [ ] `./gradlew :presentation:commerce-api:test` — 통합/E2E 테스트 통과 -- [ ] `./gradlew :presentation:commerce-api:bootRun` — 서버 정상 기동 -- [ ] `apps/` 디렉토리 완전 제거 -- [ ] `modules/jpa`에 비즈니스 로직 없음 (설정 + Adapter만) +- [x] `./gradlew clean build -x test` 전체 통과 +- [x] `./gradlew :domain:test` — MemberTest 통과 +- [x] `./gradlew :application:commerce-service:test` — 단위 테스트 통과 +- [ ] `./gradlew :presentation:commerce-api:test` — Docker 환경 필요 (코드 이상 없음) +- [ ] `./gradlew :presentation:commerce-api:bootRun` — Docker 환경 필요 +- [x] `apps/` 디렉토리 완전 제거 +- [x] `modules/jpa`에 비즈니스 로직 없음 (설정 + Adapter만) --- diff --git a/docs/planning/product-plan.md b/docs/planning/product-plan.md new file mode 100644 index 000000000..ef7d2b6f3 --- /dev/null +++ b/docs/planning/product-plan.md @@ -0,0 +1,576 @@ +# Product 도메인 TDD 구현 계획서 + +> 작성일: 2026-02-22 +> 선행 조건: Brand 도메인 구현 완료 +> 후행 작업: Like 도메인, Order 도메인 (별도 계획서) + +--- + +## 1. 요구사항 요약 + +### 1-1. API 목록 + +| # | 역할 | Method | URI | 설명 | +|---|------|--------|-----|------| +| 1 | User | GET | `/api/products` | 활성 상품 목록 조회 (정렬, 페이징) | +| 2 | User | GET | `/api/products/{id}` | 활성 상품 단건 조회 (상품+브랜드 모두 활성) | +| 3 | Admin | POST | `/api/admin/products` | 상품 생성 | +| 4 | Admin | GET | `/api/admin/products/{id}` | 상품 단건 조회 (삭제 포함) | +| 5 | Admin | PUT | `/api/admin/products/{id}` | 상품 수정 | +| 6 | Admin | DELETE | `/api/admin/products/{id}` | 상품 삭제 (soft-delete) | +| 7 | Admin | GET | `/api/admin/products` | 상품 전체 목록 조회 (삭제 포함) | + +### 1-2. 도메인 규칙 + +- Product는 하나의 Brand에 소속된다 (`brandId` FK). +- Product는 `name`, `price`, `stock`, `description` 필드를 가진다. +- Product는 soft-delete를 지원한다 (`BaseEntity` 상속). +- 삭제된 Product는 User API에 노출되지 않는다. +- 소속 Brand가 삭제된 Product도 User API에 노출되지 않는다. +- Brand 삭제 시 소속 Product를 벌크 soft-delete한다. +- 상품 생성 시 소속 Brand가 활성 상태여야 한다. + +### 1-3. 정렬 옵션 (User 목록 조회) + +| 정렬 키 | 정렬 방식 | 설명 | +|---------|----------|------| +| `latest` | `created_at DESC` | 최신 등록순 (기본값) | +| `price_asc` | `price ASC` | 가격 낮은순 | +| `likes_desc` | `like_count DESC` | 좋아요 많은순 (LEFT JOIN + COUNT) | + +### 1-4. 에러 시나리오 매트릭스 + +| # | 시나리오 | ErrorType | ExceptionMessage | +|---|---------|-----------|-----------------| +| 1 | 상품명 빈 값 또는 길이 초과 | BAD_REQUEST | `ProductExceptionMessage.INVALID_NAME` | +| 2 | 가격이 0 이하 | BAD_REQUEST | `ProductExceptionMessage.INVALID_PRICE` | +| 3 | 재고가 음수 | BAD_REQUEST | `ProductExceptionMessage.INVALID_STOCK` | +| 4 | 수량이 0 이하 | BAD_REQUEST | `ProductExceptionMessage.INVALID_QUANTITY` | +| 5 | 존재하지 않는 상품 조회/수정/삭제 | NOT_FOUND | `ProductExceptionMessage.NOT_FOUND` | +| 6 | 이미 삭제된 상품 삭제 시도 | BAD_REQUEST | `ProductExceptionMessage.ALREADY_DELETED` | +| 7 | 이미 삭제된 상품 수정 시도 | BAD_REQUEST | `ProductExceptionMessage.ALREADY_DELETED` | +| 8 | 생성 시 소속 Brand가 없음 | NOT_FOUND | `BrandExceptionMessage.NOT_FOUND` | +| 9 | 생성 시 소속 Brand가 삭제 상태 | BAD_REQUEST | `BrandExceptionMessage.ALREADY_DELETED` | +| 10 | User 단건 조회 시 상품 또는 브랜드가 삭제 상태 | NOT_FOUND | `ProductExceptionMessage.NOT_FOUND` | +| 11 | 재고 부족 (decrease 시) | BAD_REQUEST | `ProductExceptionMessage.INSUFFICIENT_STOCK` | + +--- + +## 2. 설계 결정 + +### 2-1. Entity 상속: `BaseEntity` + +``` +BaseTimeEntity (id, createdAt, updatedAt) + └── BaseEntity (deletedAt, delete(), restore()) + └── Product (brandId, name, price, stock, description) +``` + +### 2-2. Value Objects (3개) + +#### Price + +> 파일: `domain/src/main/java/com/loopers/domain/product/vo/Price.java` + +```java +@Embeddable +public class Price { + @Column(name = "price") + private int value; + + public static Price of(int value) { + if (value <= 0) throw ...; + return new Price(value); + } +} +``` + +- 검증: `value > 0` +- 타입: `int` (원 단위, 소수점 불필요) + +#### Stock + +> 파일: `domain/src/main/java/com/loopers/domain/product/vo/Stock.java` + +```java +@Embeddable +public class Stock { + @Column(name = "stock") + private int value; + + public static Stock of(int value) { + if (value < 0) throw ...; + return new Stock(value); + } + + public boolean isEnough(int quantity) { + return this.value >= quantity; + } + + public Stock decrease(int quantity) { + if (!isEnough(quantity)) throw ...; + return new Stock(this.value - quantity); + } +} +``` + +- 검증: `value >= 0` +- 도메인 메서드: `isEnough(quantity)`, `decrease(quantity)` +- `decrease()`는 새 Stock 인스턴스를 반환하는 불변 패턴 + +#### Quantity + +> 파일: `domain/src/main/java/com/loopers/domain/product/vo/Quantity.java` + +```java +@Embeddable +public class Quantity { + private int value; + + public static Quantity of(int value) { + if (value <= 0) throw ...; + return new Quantity(value); + } +} +``` + +- 검증: `value > 0` +- 용도: Order 구현 시 주문 수량으로 활용 (현재는 Stock.decrease의 파라미터로만 사용) +- 현 단계에서는 정의만 해두고, Order 구현 시 본격 활용 + +### 2-3. Product Entity + +```java +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "product") +public class Product extends BaseEntity { + + @Column(name = "brand_id", nullable = false) + private Long brandId; + + @Column(name = "name", nullable = false) + private String name; + + @Embedded + private Price price; + + @Embedded + private Stock stock; + + @Column(name = "description") + private String description; + + public static Product create(Long brandId, String name, int price, int stock, String description) { + validateName(name); + return new Product(brandId, name, Price.of(price), Stock.of(stock), description); + } + + public void update(String name, int price, int stock, String description) { + guardNotDeleted(); + validateName(name); + this.name = name; + this.price = Price.of(price); + this.stock = Stock.of(stock); + this.description = description; + } + + @Override + public void delete() { + guardNotDeleted(); + super.delete(); + } + + public void decreaseStock(int quantity) { + this.stock = stock.decrease(quantity); + } +} +``` + +### 2-4. Brand와의 관계: `brandId` (FK only) + +- Product는 `brandId`만 가진다 (JPA `@ManyToOne` 연관관계 사용하지 않음). +- Brand 유효성 검증은 CatalogDomainService(Domain 레이어)에서 수행한다. +- 이유: Brand와 Product는 같은 BC(Catalog)의 독립 Aggregate. cross-aggregate 규칙은 Domain Service가 담당한다. + +### 2-5. DTO 분리 구조 + +``` +Presentation Layer (commerce-api) +├── interfaces/api/product/dto/ +│ ├── CreateProductApiRequest.java → CreateProductCommand 변환 +│ ├── UpdateProductApiRequest.java → UpdateProductCommand 변환 +│ ├── ProductApiResponse.java ← ProductInfo 변환 +│ └── ProductListApiResponse.java ← ProductSummary 변환 +│ +Application Layer (commerce-service) +├── application/service/dto/ +│ ├── CreateProductCommand.java (record) +│ ├── UpdateProductCommand.java (record) +│ ├── ProductInfo.java (record, from(Product)) +│ └── ProductSummary.java (record, 목록용 간략 정보) +``` + +### 2-6. QueryDSL 활용 + +#### findActiveById: 상품+브랜드 활성 조건 + +```java +// 상품이 활성(deletedAt IS NULL)이고 +// 소속 브랜드도 활성(deletedAt IS NULL)인 경우에만 반환 +public Optional findActiveById(Long id) { + return Optional.ofNullable( + queryFactory.selectFrom(product) + .where( + product.id.eq(id), + product.deletedAt.isNull(), + JPAExpressions.selectOne() + .from(brand) + .where( + brand.id.eq(product.brandId), + brand.deletedAt.isNull() + ).exists() + ) + .fetchOne() + ); +} +``` + +#### 정렬 3종 + 페이징 + +```java +public Page findAllActive(Pageable pageable, String sort) { + // sort: "latest", "price_asc", "likes_desc" + // likes_desc: LEFT JOIN like + COUNT + GROUP BY +} +``` + +#### softDeleteByBrandId: 벌크 UPDATE + +```java +public long softDeleteByBrandId(Long brandId) { + return queryFactory.update(product) + .set(product.deletedAt, ZonedDateTime.now()) + .where( + product.brandId.eq(brandId), + product.deletedAt.isNull() + ) + .execute(); +} +``` + +### 2-7. CatalogDomainService (cross-aggregate 규칙) + +Brand와 Product는 같은 BC(Catalog)의 독립 Aggregate. cross-aggregate 규칙은 **CatalogDomainService**(Domain 레이어)에서 처리한다. + +> 파일: `domain/src/main/java/com/loopers/domain/catalog/CatalogDomainService.java` + +```java +@RequiredArgsConstructor +public class CatalogDomainService { + private final BrandRepository brandRepository; + private final ProductRepository productRepository; + + // 상품 생성 시 브랜드 검증 + public Product createProduct(Long brandId, String name, int price, int stock, String description) { + Brand brand = brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + BrandExceptionMessage.NOT_FOUND.message())); + brand.guardNotDeleted(); + Product product = Product.create(brandId, name, price, stock, description); + return productRepository.save(product); + } + + // 브랜드 삭제 + 소속 상품 연쇄 삭제 + public void deleteBrand(Long brandId) { + Brand brand = brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + BrandExceptionMessage.NOT_FOUND.message())); + productRepository.softDeleteByBrandId(brandId); + brand.delete(); + } +} +``` + +호출 규칙: Application Service → CatalogDomainService. Controller 직접 호출 금지 (트랜잭션 보장). + +### 2-8. 향후 Domain Service 도입 후보 + +| 후보 | 도입 시점 | 사유 | +|------|----------|------| +| `OrderDomainService` | Order 구현 시 | 재고 판단 + 주문 승인/거절 — 여러 Product 종합 판단 | + +현재 `Stock.decrease()`와 `Product.decreaseStock()`으로 단일 상품 재고 차감은 Entity 책임으로 충분하다. + +--- + +## 3. TDD 구현 순서 + +### Step 1: ExceptionMessage 정의 + +> 파일: `domain/src/main/java/com/loopers/domain/product/ProductExceptionMessage.java` + +- `INVALID_NAME` — 상품명 빈 값 또는 길이 초과 +- `INVALID_PRICE` — 가격 0 이하 +- `INVALID_STOCK` — 재고 음수 +- `INVALID_QUANTITY` — 수량 0 이하 +- `NOT_FOUND` — 상품 없음 +- `ALREADY_DELETED` — 이미 삭제된 상품 +- `INSUFFICIENT_STOCK` — 재고 부족 + +### Step 2: Price VO (Red → Green) + +> 파일: `domain/src/main/java/com/loopers/domain/product/vo/Price.java` +> 테스트: `domain/src/test/java/com/loopers/domain/product/vo/PriceTest.java` + +테스트 케이스: +- 양수 가격 생성 성공 +- 0 이하 가격이면 예외 +- equals/hashCode 동등성 + +### Step 3: Stock VO (Red → Green) + +> 파일: `domain/src/main/java/com/loopers/domain/product/vo/Stock.java` +> 테스트: `domain/src/test/java/com/loopers/domain/product/vo/StockTest.java` + +테스트 케이스: +- 0 이상 재고 생성 성공 +- 음수 재고이면 예외 +- `isEnough` — 충분하면 true +- `isEnough` — 부족하면 false +- `decrease` — 정상 차감 시 새 Stock 반환 +- `decrease` — 재고 부족 시 예외 +- equals/hashCode 동등성 + +### Step 4: Quantity VO (Red → Green) + +> 파일: `domain/src/main/java/com/loopers/domain/product/vo/Quantity.java` +> 테스트: `domain/src/test/java/com/loopers/domain/product/vo/QuantityTest.java` + +테스트 케이스: +- 양수 수량 생성 성공 +- 0 이하 수량이면 예외 +- equals/hashCode 동등성 + +### Step 5: Entity (Red → Green) + +> 파일: `domain/src/main/java/com/loopers/domain/product/Product.java` +> 테스트: `domain/src/test/java/com/loopers/domain/product/ProductTest.java` + +테스트 케이스: +- 상품 생성 성공 +- 상품명 빈 값이면 예외 +- 상품명 길이 초과 시 예외 +- 상품 수정 성공 +- 삭제 시 deletedAt 설정 +- 이미 삭제된 상품 삭제 시 예외 +- 이미 삭제된 상품 수정 시 예외 +- `decreaseStock` 성공 +- `decreaseStock` 재고 부족 시 예외 + +### Step 6: Fixture + +> 파일: `domain/src/testFixtures/java/com/loopers/domain/product/ProductFixture.java` + +```java +public class ProductFixture { + public static final Long DEFAULT_BRAND_ID = 1L; + public static final String DEFAULT_NAME = "에어맥스 90"; + public static final int DEFAULT_PRICE = 139000; + public static final int DEFAULT_STOCK = 100; + public static final String DEFAULT_DESCRIPTION = "나이키 에어맥스 90"; + + public static Product create() { ... } + public static Product create(Long brandId) { ... } + public static Product create(Long brandId, String name, int price, int stock) { ... } +} +``` + +### Step 7: Repository Port + +> 파일: `domain/src/main/java/com/loopers/domain/product/ProductRepository.java` + +```java +public interface ProductRepository { + Product save(Product product); + Optional findById(Long id); + Optional findActiveById(Long id); // 상품+브랜드 활성 + Page findAllActive(Pageable pageable, String sort); + List findAll(); + long softDeleteByBrandId(Long brandId); +} +``` + +### Step 8: Service + DTOs (Red → Green) + +> 파일: `application/commerce-service/src/main/java/com/loopers/application/service/ProductService.java` +> 파일: `application/commerce-service/src/main/java/com/loopers/application/service/AdminProductService.java` +> 테스트: `application/commerce-service/src/test/java/com/loopers/application/service/ProductServiceTest.java` +> 테스트: `application/commerce-service/src/test/java/com/loopers/application/service/AdminProductServiceTest.java` + +**ProductService** (User): +- `getActiveProducts(Pageable, String sort)` — 활성 상품 목록 (정렬, 페이징) +- `getActiveProduct(Long id)` — 활성 상품 단건 조회 + +**AdminProductService** (Admin): +- `create(CreateProductCommand)` — 생성 +- `getById(Long)` — 단건 조회 (삭제 포함) +- `getAll()` — 전체 목록 조회 +- `update(Long, UpdateProductCommand)` — 수정 +- `delete(Long)` — soft-delete +- `softDeleteByBrandId(Long)` — 벌크 삭제 (CatalogDomainService에서 호출) + +### Step 9: Repository Adapter (QueryDSL) + +> 파일: `modules/jpa/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java` +> 파일: `modules/jpa/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java` + +QueryDSL 구현: +- `findActiveById` — 서브쿼리로 Brand 활성 조건 확인 +- `findAllActive` — 정렬 3종 + 페이징 (likes_desc는 LEFT JOIN) +- `softDeleteByBrandId` — 벌크 UPDATE + +### Step 10: Controller + +> 파일: `presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java` +> 파일: `presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductController.java` + +**ProductController**: +- `GET /api/products` → `ProductService.getActiveProducts()` +- `GET /api/products/{id}` → `ProductService.getActiveProduct()` + +**AdminProductController**: +- `POST /api/admin/products` → `AdminProductService.create()` (CatalogDomainService.createProduct() 경유) +- `GET /api/admin/products/{id}` → `AdminProductService.getById()` +- `GET /api/admin/products` → `AdminProductService.getAll()` +- `PUT /api/admin/products/{id}` → `AdminProductService.update()` +- `DELETE /api/admin/products/{id}` → `AdminProductService.delete()` + +### Step 11: CatalogDomainService (cross-aggregate 규칙) + +> 파일: `domain/src/main/java/com/loopers/domain/catalog/CatalogDomainService.java` +> 테스트: `domain/src/test/java/com/loopers/domain/catalog/CatalogDomainServiceTest.java` + +테스트 케이스 (상품 생성 시 브랜드 검증): +- 브랜드가 존재하고 활성 상태면 상품 생성 성공 +- 브랜드가 없으면 NOT_FOUND 예외 +- 브랜드가 삭제 상태면 BAD_REQUEST 예외 + +테스트 케이스 (브랜드 삭제 연쇄): +- 브랜드 삭제 시 소속 상품도 soft-delete +- 소속 상품이 없어도 브랜드 삭제 정상 수행 + +> **주의**: Brand 계획서의 AdminBrandController DELETE 엔드포인트가 CatalogDomainService.deleteBrand()을 경유하도록 교체 + +### Step 13: Cascade 통합 테스트 + +> 파일: `presentation/commerce-api/src/test/java/com/loopers/controller/BrandProductCascadeE2ETest.java` + +테스트 시나리오: +- 브랜드 생성 → 상품 생성 → 브랜드 삭제 → 상품도 삭제 확인 +- 브랜드 삭제 후 User 상품 목록에서 소속 상품 미노출 +- 브랜드 삭제 후 User 상품 단건 조회 시 NOT_FOUND + +### Step 14: E2E 테스트 + +> 파일: `presentation/commerce-api/src/test/java/com/loopers/controller/ProductE2ETest.java` + +테스트 시나리오: +- 상품 생성 → 201 Created +- 삭제된 브랜드로 상품 생성 → 400 Bad Request +- 활성 상품 목록 조회 (latest) → 200 OK +- 활성 상품 목록 조회 (price_asc) → 가격 오름차순 확인 +- 활성 상품 단건 조회 → 200 OK +- 삭제된 상품 User 조회 → 404 Not Found +- 상품 수정 → 200 OK +- 상품 삭제 → 204 No Content +- 삭제된 상품 재삭제 → 400 Bad Request + +--- + +## 4. 파일 생성 목록 + +### Domain Layer (`domain/`) + +| 경로 | 설명 | +|------|------| +| `domain/src/main/java/com/loopers/domain/product/Product.java` | Entity | +| `domain/src/main/java/com/loopers/domain/product/ProductRepository.java` | Repository Port | +| `domain/src/main/java/com/loopers/domain/product/ProductExceptionMessage.java` | 예외 메시지 | +| `domain/src/main/java/com/loopers/domain/product/vo/Price.java` | 가격 VO | +| `domain/src/main/java/com/loopers/domain/product/vo/Stock.java` | 재고 VO | +| `domain/src/main/java/com/loopers/domain/product/vo/Quantity.java` | 수량 VO | +| `domain/src/test/java/com/loopers/domain/product/ProductTest.java` | Entity 테스트 | +| `domain/src/test/java/com/loopers/domain/product/vo/PriceTest.java` | Price VO 테스트 | +| `domain/src/test/java/com/loopers/domain/product/vo/StockTest.java` | Stock VO 테스트 | +| `domain/src/test/java/com/loopers/domain/product/vo/QuantityTest.java` | Quantity VO 테스트 | +| `domain/src/testFixtures/java/com/loopers/domain/product/ProductFixture.java` | Fixture | +| `domain/src/main/java/com/loopers/domain/catalog/CatalogDomainService.java` | Catalog BC Domain Service | +| `domain/src/test/java/com/loopers/domain/catalog/CatalogDomainServiceTest.java` | Domain Service 테스트 | + +### Application Layer (`application/commerce-service/`) + +| 경로 | 설명 | +|------|------| +| `application/commerce-service/src/main/java/com/loopers/application/service/ProductService.java` | User Service | +| `application/commerce-service/src/main/java/com/loopers/application/service/AdminProductService.java` | Admin Service | +| `application/commerce-service/src/main/java/com/loopers/application/service/dto/CreateProductCommand.java` | 생성 DTO | +| `application/commerce-service/src/main/java/com/loopers/application/service/dto/UpdateProductCommand.java` | 수정 DTO | +| `application/commerce-service/src/main/java/com/loopers/application/service/dto/ProductInfo.java` | 상세 응답 DTO | +| `application/commerce-service/src/main/java/com/loopers/application/service/dto/ProductSummary.java` | 목록 응답 DTO | +| `application/commerce-service/src/test/java/com/loopers/application/service/ProductServiceTest.java` | User Service 테스트 | +| `application/commerce-service/src/test/java/com/loopers/application/service/AdminProductServiceTest.java` | Admin Service 테스트 | + +### Presentation Layer (`presentation/commerce-api/`) + +| 경로 | 설명 | +|------|------| +| `presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java` | User Controller | +| `presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductController.java` | Admin Controller | +| `presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/CreateProductApiRequest.java` | Presentation 생성 DTO | +| `presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/UpdateProductApiRequest.java` | Presentation 수정 DTO | +| `presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductApiResponse.java` | Presentation 상세 응답 DTO | +| `presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductListApiResponse.java` | Presentation 목록 응답 DTO | +| `presentation/commerce-api/src/test/java/com/loopers/controller/ProductE2ETest.java` | E2E 테스트 | +| `presentation/commerce-api/src/test/java/com/loopers/controller/BrandProductCascadeE2ETest.java` | Cascade 통합 테스트 | + +### Infrastructure Layer (`modules/jpa/`) + +| 경로 | 설명 | +|------|------| +| `modules/jpa/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java` | Spring Data JPA | +| `modules/jpa/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java` | Repository Adapter (QueryDSL) | + +--- + +## 5. DB 스키마 + +```sql +CREATE TABLE product ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + brand_id BIGINT NOT NULL, + name VARCHAR(200) NOT NULL, + price INT NOT NULL, + stock INT NOT NULL DEFAULT 0, + description TEXT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + deleted_at DATETIME(6) NULL, + -- FK 제약조건 없음 (운영 유연성 + MSA 전환 대비, 앱 레벨 검증) +); + +CREATE INDEX idx_product_brand_id ON product(brand_id); +CREATE INDEX idx_product_deleted_at ON product(deleted_at); +``` + +--- + +## 6. 참조 + +- 도메인 모델 정의서: `docs/design/05-domain-model.md` +- Brand 계획서: `docs/planning/brand-plan.md` +- 기존 패턴: `domain/src/main/java/com/loopers/domain/member/Member.java` +- VO 패턴: `domain/src/main/java/com/loopers/domain/member/vo/LoginId.java` +- BaseEntity: `domain/src/main/java/com/loopers/domain/BaseEntity.java` +- Member 리팩토링 기록: `docs/planning/refactoring-plan.md` diff --git a/docs/planning/refactoring-plan.md b/docs/planning/refactoring-plan.md index a0a6b5e54..f1b87b7eb 100644 --- a/docs/planning/refactoring-plan.md +++ b/docs/planning/refactoring-plan.md @@ -1,8 +1,20 @@ # Member 리팩토링 작업 계획서 -> 작성일: 2026-02-21 +> 작성일: 2026-02-21 (최종 수정: 2026-02-22) > 범위: 기존 구현된 Member 기능의 구조 변경 및 리팩토링 > 신규 기능(Brand, Product, Order 등)은 이 작업 이후 별도 진행 +> +> **Phase 1 완료.** 실제 구현 구조는 이 초기 계획과 차이 있음: +> - `apps/` → `presentation/` + `application/commerce-service/` 으로 분리 +> - 상세 설계서: `docs/planning/phase1-structure-refactoring.md` +> - 구현 로그: `docs/thought/phase1-implementation-log.md` +> +> **Phase 2 완료.** 초기 계획과 실제 구현의 차이: +> - MemberPolicy → VO 4개로 전환 (초기 계획: 엔티티 내부 검증) +> - ErrorType: HttpStatus 제거, pure enum으로 domain 이동 +> - DomainService: 현재 불필요하여 보류 +> - 구현 로그: `docs/thought/phase2-implementation-log.md` +> - 논의 기록: `docs/thought/phase2-discussion-log.md` --- @@ -17,210 +29,88 @@ ### 1-2. 아키텍처 분석에서 도출된 문제점 -| # | 문제 | 위치 | 심각도 | -|---|------|------|--------| -| 1 | domain 모듈 부재 — 도메인 코드가 인프라 모듈에 혼재 | `modules/jpa` | Critical | -| 2 | IllegalArgumentException → 전부 401 UNAUTHORIZED 반환 | `ApiControllerAdvice` | Critical | -| 3 | BaseTimeEntity 미구현 (soft-delete 불필요한 엔티티용) | `modules/jpa` | Critical | -| 4 | MemberPolicy 중앙 집중 — 응집도 떨어짐 | `modules/jpa` | High | -| 5 | 패키지 구조 불일치 (`controller/` vs `interfaces/api/`) | `apps/commerce-api` | High | -| 6 | Service에 표현 로직 혼재 (이름 마스킹) | `MemberService` | Medium | -| 7 | BaseEntity.id `final` 선언 | `BaseEntity` | Medium | -| 8 | Kafka 패키지 오타 (`confg` → `config`) | `modules/kafka` | Low | +| # | 문제 | 위치 | 심각도 | 해결 Phase | +|---|------|------|--------|-----------| +| 1 | domain 모듈 부재 — 도메인 코드가 인프라 모듈에 혼재 | `modules/jpa` | Critical | Phase 1 | +| 2 | IllegalArgumentException → 전부 401 UNAUTHORIZED 반환 | `ApiControllerAdvice` | Critical | Phase 2 | +| 3 | BaseTimeEntity 미구현 (soft-delete 불필요한 엔티티용) | `modules/jpa` | Critical | Phase 2 | +| 4 | MemberPolicy 중앙 집중 — 응집도 떨어짐 | `modules/jpa` | High | Phase 2 | +| 5 | 패키지 구조 불일치 (`controller/` vs `interfaces/api/`) | `apps/commerce-api` | High | Phase 1 | +| 6 | Service에 표현 로직 혼재 (이름 마스킹) | `MemberService` | Medium | Phase 2 | +| 7 | BaseEntity.id `final` 선언 | `BaseEntity` | Medium | Phase 1 | +| 8 | Kafka 패키지 오타 (`confg` → `config`) | `modules/kafka` | Low | Phase 1 | --- ## 2. 작업 순서 -### Phase 1: 구조 변경 - -> 목표: 멀티모듈 의존 방향을 DIP 원칙에 맞게 재배치 - -#### 1-1. `domain/` 모듈 신설 (루트 레벨) - -**Before:** -``` -Root -├── apps/ -├── modules/ -│ ├── jpa/ ← 여기에 Member 엔티티, Policy, PasswordEncryptor 혼재 -│ ├── redis/ -│ └── kafka/ -└── supports/ -``` - -**After:** -``` -Root -├── domain/ ← 신규: 순수 도메인 (최하위 계층, 의존 없음) -├── apps/ -├── modules/ -│ ├── jpa/ ← 인프라 설정 + Repository 구현체만 -│ ├── redis/ -│ └── kafka/ -└── supports/ -``` - -**구체적 작업:** -- `settings.gradle.kts`에 `domain` 모듈 추가 -- `domain/build.gradle.kts` 생성 (`java-library` + `java-test-fixtures`) -- 의존성: `jakarta.persistence-api`, `lombok` (최소한만) - -#### 1-2. 기존 도메인 코드 이동 - -| 파일 | From | To | -|------|------|----| -| `Member.java` | `modules/jpa/.../domain/member/` | `domain/.../member/` | -| `MemberPolicy.java` | `modules/jpa/.../domain/member/policy/` | (리팩토링 후 제거) | -| `MemberExceptionMessage.java` | `modules/jpa/.../domain/member/` | `domain/.../member/` | -| `PasswordEncryptor.java` | `modules/jpa/.../utils/` | `domain/.../member/` | -| `BaseEntity.java` | `modules/jpa/.../domain/` | `domain/.../common/` | -| `MemberRepository` 인터페이스 | `apps/.../infrastructure/member/` | `domain/.../member/` | -| `MemberRepository` 구현체 | - | `modules/jpa/` (신규, Spring Data JPA) | - -#### 1-3. 의존 방향 설정 - -``` -apps/commerce-api - ├─→ domain (비즈니스 규칙) - ├─→ modules/jpa (Repository 구현체) - ├─→ modules/redis - └─→ supports/* - -modules/jpa - └─→ domain (엔티티 참조, Repository 인터페이스 구현) - -domain - └─→ (없음) ← JPA API만 compileOnly 또는 api 수준 의존 -``` - -#### 1-4. 패키지 구조 통일 -- `controller/MemberController.java` → `interfaces/api/member/MemberController.java`로 이동 -- 기존 `interfaces/api/` 컨벤션에 맞춤 - -#### 1-5. 기타 구조 수정 -- `modules/kafka`: `confg` → `config` 패키지명 수정 -- `BaseEntity.id`: `final` 제거 - ---- - -### Phase 2: 모델링 및 설계 변경 - -> 목표: 도메인 객체가 자기 규칙을 가지도록 재설계 (테스트 가능한 구조) - -#### 2-1. BaseEntity / BaseTimeEntity 분리 - -```java -// 공통 시간 추적 (soft-delete 불필요한 엔티티용) -@MappedSuperclass -public abstract class BaseTimeEntity { - @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - private ZonedDateTime createdAt; - private ZonedDateTime updatedAt; -} - -// soft-delete 필요한 엔티티용 -@MappedSuperclass -public abstract class BaseEntity extends BaseTimeEntity { - private ZonedDateTime deletedAt; - public void delete() { ... } - public void restore() { ... } -} -``` - -#### 2-2. MemberPolicy → 도메인 객체에 규칙 내재 - -**Before (중앙 집중 Policy):** -```java -MemberPolicy.Name.validate(name); -MemberPolicy.Email.validate(email); -MemberPolicy.Password.validate(password, birthDate); -``` - -**After (각 객체가 자기 규칙 보유):** -```java -// Member.register() 내부에서 직접 검증 -// 또는 VO를 도입하여 생성 시 검증 -// → Phase 2에서 구체적 설계 결정 -``` - -검증 로직을 Member 엔티티 내부로 이동하되, 검증 규칙의 상수/메시지는 Member 내부 또는 동일 패키지에 위치시킴. -VO 도입 여부는 각 필드의 자체 규칙 유무에 따라 판단: -- 자체 규칙이 있는 필드(Stock, Price 등) → VO(`@Embeddable`) -- 단순 검증만 필요한 필드(name, email 등) → 엔티티 내부 검증 - -#### 2-3. 예외 처리 체계 재설계 - -**Before:** -- 도메인: `IllegalArgumentException` 사용 -- ControllerAdvice: 모든 `IllegalArgumentException` → 401 UNAUTHORIZED - -**After:** -- 도메인 검증 실패 → `CoreException(ErrorType.BAD_REQUEST, "메시지")` -- 인증 실패 → 별도 `AuthenticationException` 또는 `CoreException(ErrorType.UNAUTHORIZED, "메시지")` -- ControllerAdvice: `CoreException` 기반으로 통일 - -ErrorType에 `UNAUTHORIZED` 추가: -```java -UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "Unauthorized", "인증에 실패했습니다.") -``` - -#### 2-4. Service 책임 분리 - -- `MemberService.maskName()` → 표현 계층(Controller 또는 Response DTO)으로 이동 -- Service는 도메인 로직 조합과 트랜잭션 관리에만 집중 - ---- - -### Phase 3: 테스트 코드 수정 - -> 목표: 도메인 단위 테스트 확보, 기존 테스트 정리 - -#### 3-1. 도메인 단위 테스트 작성 (Red → Green) - -``` -domain/src/test/java/com/loopers/member/ -├── MemberTest.java ← 회원 생성, 비밀번호 변경 등 도메인 규칙 검증 -└── MemberExceptionMessageTest.java ← (필요 시) -``` - -테스트 예시: -- 정상 회원 생성 -- 비밀번호에 생년월일 포함 시 거부 -- 동일 비밀번호로 변경 시 거부 -- 이름/이메일 형식 검증 - -#### 3-2. 기존 Service 테스트 수정 - -- `MemberServiceTest`: mock capture 방식 → 도메인 규칙은 MemberTest로 이동 -- `MemberServiceIntegrationTest`: Repository 연동 검증에 집중 -- `MemberE2ETest`: API 스펙(요청/응답 형식, HTTP 상태 코드) 검증에 집중 - -#### 3-3. 테스트 계층 명확화 - -| 계층 | 대상 | 위치 | 의존 | -|------|------|------|------| -| 단위 테스트 | 도메인 객체 | `domain/src/test/` | 없음 (순수 자바) | -| 통합 테스트 | Service + Repository | `apps/src/test/` | Testcontainers | -| E2E 테스트 | API 전체 흐름 | `apps/src/test/` | Spring Context + Testcontainers | +### Phase 1: 구조 변경 ✅ + +> 상세: `docs/planning/phase1-structure-refactoring.md`, `docs/thought/phase1-implementation-log.md` + +- [x] domain/ 모듈 신설 (루트 레벨) +- [x] application/commerce-service 모듈 신설 +- [x] presentation/commerce-api 모듈 신설 (bootJar) +- [x] 도메인 코드 이동 (modules/jpa → domain) +- [x] 비즈니스 코드 이동 (apps → application) +- [x] 인터페이스 코드 이동 (apps → presentation) +- [x] MemberRepository DIP 분리 (Port + Adapter) +- [x] MemberController 패키지 통일 +- [x] BaseEntity.id final 제거 +- [x] Kafka 패키지 오타 수정 (**재검증**: confg 잔존 확인 → 문서 정리 시 삭제 완료) +- [x] batch/streamer 이동 (apps → presentation) +- [x] apps/ 디렉토리 삭제 +- [x] 네이밍 개선 (commerce-api-core → commerce-service) +- [x] Spring Boot 플러그인 적용 범위 축소 + +### Phase 2: 모델링 및 설계 변경 ✅ + +> 상세: `docs/thought/phase2-implementation-log.md`, `docs/thought/phase2-discussion-log.md` + +- [x] ErrorType → pure enum (HttpStatus 제거), domain 레이어로 이동 +- [x] CoreException → domain 레이어로 이동 +- [x] UNAUTHORIZED ErrorType 추가 +- [x] BaseTimeEntity 신설 (id + createdAt + updatedAt) +- [x] BaseEntity → BaseTimeEntity 상속 + deletedAt +- [x] MemberPolicy → VO 4개 전환 (LoginId, Password, MemberName, Email) +- [x] Member: @Builder/@AllArgsConstructor 제거 → 정적 팩토리 +- [x] Member: BaseTimeEntity 상속 +- [x] IllegalArgumentException → CoreException 전면 교체 +- [x] ApiControllerAdvice: ErrorType → HttpStatus switch 매핑, IllegalArgumentException 핸들러 제거 +- [x] 마스킹 로직: MemberService → GetMemberInfoResponse.withMaskedName() +- [x] DTO 네이밍 통일 (RegisterMemberRequest, GetMemberInfoResponse, UpdatePasswordRequest) +- [x] MemberFixture testFixtures 생성 +- [ ] DomainService 분리 → **보류** (현재 불필요, 신규 도메인 추가 시 도입) + +### Phase 3: 테스트 코드 수정 ✅ + +> **Phase 3 완료.** 구현 로그: `docs/thought/phase3-implementation-log.md` + +- [x] 도메인 단위 테스트 보강 (domain/src/test/) +- [x] 기존 Service 테스트 정리 (mock capture 방식 개선) +- [x] 테스트 계층 명확화 (단위/통합/E2E 분리) +- [x] MemberTest: .isInstanceOf 제거, 빈/중복 테스트 삭제 +- [x] MemberServiceTest: mock capture 제거, 단언문 분리 +- [x] MemberServiceIntegrationTest: 단언문 분리, 마스킹 버그 수정 +- [x] MemberE2ETest: 시나리오 분리 (5개 독립 테스트) +- [x] CLAUDE.md: 테스트 단위 원칙 (1 테스트 = 1 단언문) 추가 +- [x] Squash 잔류 파일 정리 --- ## 3. 완료 기준 -- [ ] `domain/` 모듈이 루트 레벨에 존재하며, 다른 모듈에 의존하지 않음 -- [ ] `modules/jpa`에 비즈니스 로직이 없음 (설정 + Repository 구현체만) -- [ ] 모든 예외가 `CoreException` 기반으로 통일 -- [ ] `MemberTest` 도메인 단위 테스트가 존재하며 통과 -- [ ] 기존 모든 테스트(`./gradlew test`)가 통과 -- [ ] 패키지 구조가 `interfaces/api/` 컨벤션에 맞춤 +- [x] `domain/` 모듈이 루트 레벨에 존재하며, 다른 모듈에 의존하지 않음 +- [x] `modules/jpa`에 비즈니스 로직이 없음 (설정 + Repository 구현체만) (**재검증**: 도메인 코드 잔존 확인 → 문서 정리 시 삭제 완료) +- [x] 모든 예외가 `CoreException` 기반으로 통일 +- [x] `MemberTest` 도메인 단위 테스트가 존재하며 통과 +- [ ] 기존 모든 테스트(`./gradlew test`)가 통과 — Docker 환경 필요 +- [x] 패키지 구조가 `interfaces/api/` 컨벤션에 맞춤 --- ## 4. 작업 제외 사항 (이번 범위 밖) -- Brand, Product, ProductLike, Order 등 신규 도메인 구현 -- VO(@Embeddable) 도입은 신규 도메인에서 적용 (Member는 기존 구조 유지 또는 최소 변경) +- Brand, Product, Like, Order 등 신규 도메인 구현 - Facade 패턴 도입 (신규 도메인 간 의존 해소 시 적용) - supports 모듈 의존성 중복 정리 (별도 작업) diff --git a/docs/temp/architecture-discussion-log.md b/docs/temp/architecture-discussion-log.md index 4f205fc33..2d380ddc0 100644 --- a/docs/temp/architecture-discussion-log.md +++ b/docs/temp/architecture-discussion-log.md @@ -1,5 +1,7 @@ # 아키텍처 논의 기록 +> **ARCHIVE** — 이 문서는 히스토리 참고용입니다. 현재 설계 기준 문서(SoT)는 `docs/design/01~04-*.md`입니다. + > 작성일: 2026-02-21 > 참여: 개발자, AI (Claude Code) > 맥락: TDD + DDD 기반 커머스 프로젝트 리팩토링 전 논의 @@ -84,7 +86,7 @@ Root ├── domain/ ← Domain Layer (java-library) ├── application/ ← Application Layer (java-library) -│ └── commerce-api/ +│ └── commerce-service/ ├── presentation/ ← Presentation Layer (bootJar) │ └── commerce-api/ ├── modules/ ← Infrastructure Layer (java-library) diff --git a/docs/temp/refactoring-plan.md b/docs/temp/refactoring-plan.md deleted file mode 100644 index 4f1cfba61..000000000 --- a/docs/temp/refactoring-plan.md +++ /dev/null @@ -1,118 +0,0 @@ -# Member 리팩토링 작업 계획서 - -> 작성일: 2026-02-21 (최종 수정: 2026-02-21) -> 범위: 기존 구현된 Member 기능의 구조 변경 및 리팩토링 -> 신규 기능(Brand, Product, Order 등)은 이 작업 이후 별도 진행 - ---- - -## 1. 작업 배경 - -### 1-1. 현재 상태 -- Member 관련 기능(회원가입, 로그인, 비밀번호 변경)만 구현되어 있음 -- 도메인 코드가 `modules/jpa`(인프라 설정 모듈)에 위치 -- Application/Presentation 레이어가 모듈 수준으로 분리되어 있지 않음 -- 예외 처리 체계가 일관되지 않음 -- 도메인 단위 테스트(MemberTest)가 testFixtures에 있어 실행되지 않음 - -### 1-2. 아키텍처 분석에서 도출된 문제점 - -| # | 문제 | 심각도 | -|---|------|--------| -| 1 | domain 모듈 부재 — 도메인 코드가 인프라 모듈에 혼재 | Critical | -| 2 | IllegalArgumentException → 전부 401 UNAUTHORIZED 반환 | Critical | -| 3 | BaseTimeEntity 미구현 (soft-delete 불필요한 엔티티용) | Critical | -| 4 | Application/Presentation 레이어 미분리 | High | -| 5 | MemberPolicy 중앙 집중 — 응집도 떨어짐 | High | -| 6 | 패키지 구조 불일치 (`controller/` vs `interfaces/api/`) | High | -| 7 | Service에 표현 로직 혼재 (이름 마스킹) | Medium | -| 8 | BaseEntity.id `final` 선언 | Medium | -| 9 | Kafka 패키지 오타 (`confg` → `config`) | Low | - ---- - -## 2. 목표 아키텍처 - -### 레이어드 아키텍처 + DIP - -``` -presentation (bootJar) → application (java-library) → domain (java-library) - → modules (java-library) → domain - → supports (독립) -``` - -### 모듈 구조 - -``` -Root -├── domain/ ← Domain Layer (순수 비즈니스 규칙) -├── application/ ← Application Layer (유스케이스 조합) -│ └── commerce-api/ -├── presentation/ ← Presentation Layer (컨트롤러 + Spring Boot) -│ ├── commerce-api/ ← bootJar -│ ├── commerce-batch/ ← bootJar -│ └── commerce-streamer/ ← bootJar -├── modules/ ← Infrastructure Layer (인프라 어댑터) -│ ├── jpa/ -│ ├── redis/ -│ └── kafka/ -└── supports/ ← Cross-cutting (횡단 관심사) -``` - ---- - -## 3. Phase 별 작업 계획 - -### Phase 1: 구조 변경 -> 상세: `docs/planning/phase1-structure-refactoring.md` - -- domain/ 모듈 신설 (루트 레벨) -- application/commerce-api 모듈 신설 -- presentation/commerce-api 모듈 신설 (bootJar) -- 도메인 코드 이동 (modules/jpa → domain) -- 비즈니스 코드 이동 (apps → application) -- 인터페이스 코드 이동 (apps → presentation) -- MemberRepository DIP 분리 (Port + Adapter) -- MemberController 패키지 통일 -- MemberTest 이동 (testFixtures → domain/src/test/) -- BaseEntity.id final 제거 -- Kafka 패키지 오타 수정 -- batch/streamer 이동 (apps → presentation) -- apps/ 디렉토리 삭제 - -### Phase 2: 모델링 및 설계 변경 - -- BaseTimeEntity 신설 (soft-delete 불필요한 엔티티용) -- MemberPolicy 제거 → 도메인 객체에 규칙 내재화 -- 도메인 예외 체계 구축 (DomainException을 domain 레이어에 정의) -- 예외 처리 체계 재설계 (도메인 예외 기반 통일, presentation에서 HTTP 매핑) -- Service 구조 변경 (ApplicationService + DomainService 분리) -- Service 책임 분리 (마스킹 로직을 Presentation 레이어로 이동) -- `@Builder`, `@AllArgsConstructor` 제거 → 정적 팩토리 메서드로 전환 -- DTO 네이밍 통일 (행동 먼저: `RegisterMemberRequest`) - -### Phase 3: 테스트 코드 수정 - -- 도메인 단위 테스트 보강 (domain/src/test/) -- 기존 Service 테스트 정리 (mock capture 방식 개선) -- 테스트 계층 명확화 (단위/통합/E2E 분리) - ---- - -## 4. 완료 기준 - -- [ ] `./gradlew clean build` 전체 통과 -- [ ] 5계층 모듈 구조 (domain, application, presentation, modules, supports) -- [ ] 모든 예외가 CoreException 기반으로 통일 (Phase 2) -- [ ] MemberTest 도메인 단위 테스트가 domain 모듈에서 실행되며 통과 -- [ ] 기존 모든 테스트 통과 -- [ ] 패키지 구조가 `interfaces/api/` 컨벤션에 맞춤 - ---- - -## 5. 작업 제외 사항 (이번 범위 밖) - -- Brand, Product, ProductLike, Order 등 신규 도메인 구현 -- VO(@Embeddable) 도입은 신규 도메인에서 적용 -- Facade 패턴 도입 (신규 도메인 간 의존 해소 시 적용) -- supports 모듈 의존성 중복 정리 (별도 작업) diff --git a/docs/thought/architecture-direction-v2.md b/docs/thought/architecture-direction-v2.md new file mode 100644 index 000000000..a0c00c513 --- /dev/null +++ b/docs/thought/architecture-direction-v2.md @@ -0,0 +1,408 @@ +# 설계 방향 v2 — 260223 멘토링 이후 + +> 작성일: 2026-02-24 +> +> 기반: 260223 Kev님 멘토링 + 기존 06-architecture.md +> +> 상태: **확정** — 모든 아키텍처 결정 완료 + +--- + +## 변경 배경 + +기존 `06-architecture.md`에서는 JPA Entity로 Domain을 구현하는 것이 **단점이 더 작다**고 판단했다. + +하지만 260223 멘토링을 듣고 나서 든 생각: + +> DIP를 과제로 내주셨다는 것 == DIP는 요구사항이고, 기획의 의도가 담긴 것이 아닐까? +> 우리가 아직은 모르는 Repository나 Entity의 변경사항이 이후 주차에서 생기기 때문에 DIP를 구현하는 것이 아닐까? + +→ **결론: Domain 레이어를 순수하게 유지하자.** + +--- + +## 1. Domain Layer — 기능적인 요구사항의 레이어 + +기술 구현과 상관 없는 **기능적인 요구사항(공책 게임으로 구현할 수 있는 요구사항)**을 구현하는 레이어. + +### 1-1. Entity, VO — 순수 도메인 객체 + +**기존 결정 (Deprecated):** +- JPA `@Entity`, `@Embeddable`을 사용 +- 순수 도메인 엔티티보다 JPA 엔티티로 구현했을 때 단점이 더 작다고 판단 + +| 순수한 도메인 엔티티의 단점 | JPA 엔티티의 단점 | +|---|---| +| JPA만의 편의성을 잃어버림: 영속성 컨텍스트 사용 불가 | 비즈니스 로직 가독성 저하: 엔티티 기능 구현 시 JPA 고려 필요 | +| 코드 복잡도 상승: 관리해야 하는 레이어 및 코드 증가 | 테스트 코드 작성 시 영속성 컨텍스트 고려 필요 | +| | 프레임워크 변경 시 엔티티 코드도 수정 | + +당시에는 JPA 엔티티의 단점이 더 작다고 판단했고, 필요하면 AI로 빠르게 변경할 수 있다고 봤다. + +**현재 결정: 순수 도메인 엔티티로 변경** + +DIP를 요구사항으로 받아들여, Domain 레이어를 순수하게 유지한다. + +### 1-1-1. ID VO화 + +모든 엔티티의 ID를 `Long`이 아닌 **전용 VO**로 감싼다. + +```java +// Before +private Long id; + +// After +public class MemberId { + private final Long value; +} +``` + +| VO | 대상 | 효과 | +|----|------|------| +| `MemberId` | Member | Cross-BC 참조 시 타입으로 구분 가능 | +| `BrandId` | Brand | `ProductId`와 혼동 방지 | +| `ProductId` | Product | 주문, 좋아요 등에서 명확한 참조 | +| `OrderId` | Order | | +| `LikeId` | Like | | + +**타입 안전성 확보:** +```java +// Before — Long끼리 섞여도 컴파일 에러 없음 +public void doSomething(Long memberId, Long productId) { ... } +doSomething(productId, memberId); // 컴파일 OK, 런타임 버그 + +// After — 타입이 다르면 컴파일 에러 +public void doSomething(MemberId memberId, ProductId productId) { ... } +doSomething(productId, memberId); // 컴파일 에러 +``` + +JPA Entity에서는 `Long`으로 저장하고, `toModel()`/`fromModel()`에서 VO로 변환. + +### 1-1-2. VO 팩토리 메서드 — `of()` 통일 + +모든 VO는 `of()` 하나로 생성한다. DB에서 복원할 때도 `of()`를 사용하여 검증을 통과시킨다. +`fromValue()` 같은 검증 스킵 메서드를 별도로 만들지 않는다. + +- 문자열 검증 비용은 무시할 수준 +- 생성 경로가 하나이므로 혼동 없음 +- DB 데이터 오염 시 검증이 잡아줌 +- 검증 규칙 변경 시 기존 데이터 문제는 데이터 마이그레이션으로 해결 (VO 책임 아님) + +```java +// toModel() 에서도 of() 사용 +public Member toModel() { + return new Member( + new MemberId(getId()), + LoginId.of(loginId), + Password.fromEncrypted(password), // Password만 예외 + MemberName.of(name), + birthDate, + Email.of(email), + getCreatedAt(), + getUpdatedAt() + ); +} +``` + +**Password만 예외:** `of()`는 평문 → 암호화, `fromEncrypted()`는 이미 암호화된 값 복원. +이건 검증 스킵이 아니라 **생성 의미 자체가 다른 것**이다. + +**신규 엔티티의 ID가 없는 상태 처리:** + +DB auto-increment를 사용하므로 `save()` 전에는 ID가 없다. +`save()` 반환값에서 ID가 채워진 Domain Entity를 받는 방식으로 처리한다. + +```java +Member member = Member.register(...); // ID 없음 (null) +Member saved = memberRepository.save(member); // ID가 채워진 새 객체 반환 +saved.getId(); // MemberId(1L) +``` + +RepositoryImpl의 `save()`가 JPA Entity를 저장한 뒤, auto-generated ID를 포함하여 `toModel()`로 변환해 반환한다. + +### 1-2. Domain Service — 함수의 객체화 + +나만의 정의: + +> Domain Service는 **단일 엔티티에서 구현 불가능**하지만, **하나의 BC 내에서 책임을 맡는 기능**을 구현. + +근거: +- Service의 의미는 Input과 Output을 가지며 상태를 가지지 않는다 +- → **`Service == 함수의 객체화`** 라고 정의 +- Domain Service도 하나의 객체라는 판단 하에 SRP를 적용 +- → 단일 엔티티에서 구현 불가능 + 하나의 BC 내 책임 = Domain Service + +**Bean 등록: `@Component` 사용 안 함. Application 레이어에서 `@Configuration` + `@Bean` 수동 등록.** + +Domain을 순수하게 유지하는 방향이므로, Spring 어노테이션(`@Component` 포함)을 Domain에서 전부 제거한다. +Application 레이어의 `@Configuration` 클래스에서 `@Bean`으로 등록한다. + +이 패턴은 DDD 레퍼런스 프로젝트들(DDDSample, ddd-by-examples/library, Baeldung Hexagonal)에서 표준으로 쓰이는 방식이다. + +```java +// application/commerce-service 내 @Configuration +@Configuration +public class DomainServiceConfig { + @Bean + public CatalogDomainService catalogDomainService( + BrandRepository brandRepo, ProductRepository productRepo) { + return new CatalogDomainService(brandRepo, productRepo); + } +} +``` + +- 컴파일 시점: Application은 Repository **인터페이스**(Domain)만 알면 됨 +- 런타임: Presentation(Composition Root)에서 Infrastructure의 구현체 Bean이 주입됨 +- Application에 이미 `spring-context` 의존이 있으므로 `@Configuration` 사용 가능 + +### 1-3. Repository + +도메인 레이어는 알 수 없는 저장소랑 상호작용(저장, 조회, 수정, 삭제)하는 용도. + +공책 게임으로 치면 연필로 쓰고, 지우개로 지우고 하며 정보를 작성하는 것이라고 판단. +→ Domain 레이어에서 호출하는 것을 정당하다고 생각함. +그것 또한 도메인 레이어가 담당하는 것이 아닐까? + +### 1-4. CoreException / ErrorType — supports 레이어로 이동 + +- AOP를 통해 예외 처리를 간단하게 처리하기 위해 커스텀 예외 클래스를 만듦 +- 기존처럼 레이어를 나누면 커스텀 예외 클래스를 모든 레이어마다 만들고 매핑해야 한다는 것을 깨달음 +- 예외 클래스에 대해서만 예외적으로 공통적인 클래스를 사용하는 것으로 우회함 + +**결정: supports 레이어로 이동. Domain이 supports/error에 의존하는 것을 예외적으로 허용.** + +| 항목 | 내용 | +|------|------| +| 신규 모듈 | `supports/error` | +| 포함 클래스 | `ErrorType`, `CoreException` | +| 의존 방향 | `domain → supports/error` (예외적 허용) | +| 모듈 성격 | 순수 Java (Spring 의존 없음) | + +기존 supports 모듈들(jackson, logging, monitoring)은 모두 Presentation add-on 성격이지만, +`supports/error`는 **모든 레이어가 공유하는 예외 기반**이라는 점에서 성격이 다름. +이 차이를 인지한 상태에서 예외적으로 허용한다. + +### 1-5. PasswordEncryptor — DIP로 Infrastructure로 이동 + +현재 상태: `domain/utils/PasswordEncryptor.java` (SHA-256, static 유틸리티) + +**결정: 인터페이스는 Domain, 구현체(BCrypt)는 Infrastructure. DIP 적용.** + +Domain에 `PasswordEncryptor` 인터페이스를 두고, Infrastructure에서 BCrypt로 구현한다. + +``` +Domain Infrastructure +┌──────────────────────┐ ┌──────────────────────────┐ +│ PasswordEncryptor │◄──────────│ BCryptPasswordEncryptor │ +│ (interface) │implements │ (spring-security-crypto) │ +│ encode(raw) │ └──────────────────────────┘ +│ matches(raw, enc) │ +└──────────────────────┘ +``` + +**Password VO 변경 — encryptor를 파라미터로 받는 방식:** + +```java +// Before (static 호출) +public static Password of(String rawPassword, LocalDate birthDate) { + return new Password(PasswordEncryptor.encode(rawPassword)); +} + +// After (인터페이스 주입) +public static Password of(String rawPassword, LocalDate birthDate, PasswordEncryptor encryptor) { + validateFormat(rawPassword); + validateBirthDateNotContained(rawPassword, birthDate); + return new Password(encryptor.encode(rawPassword)); +} + +public boolean matches(String rawPassword, PasswordEncryptor encryptor) { + return encryptor.matches(rawPassword, this.value); +} +``` + +Application Service(MemberService)가 PasswordEncryptor를 주입받아 Member에 전달: +```java +// MemberService +private final PasswordEncryptor passwordEncryptor; + +public void register(...) { + Member member = Member.register(loginId, rawPassword, name, birthDate, email, passwordEncryptor); +} +``` + +### 1-6. Aggregate vs BC + +> 애그리거트는 데이터 일관성을 지키는 하나의 단위임. +> 그래서 특정 트랜잭션 안에서 그 애그리거트 단위의 것들은 항상 같이 작동해야 함. +> 그러므로 BC가 현재의 선택임. + +--- + +## 2. Application Layer — 비기능적 요구사항 + BC 조합의 레이어 + +**비기능적인 요구사항(트랜잭션 등)**을 구현하거나, 도메인 레이어에서 해결하지 못하는 **여러 BC들을 조합하여 기능**을 구현하는 레이어. + +### 2-1. Application Service + +여러 도메인 계층의 BC나 외부 기술 등을 응용하여 비즈니스 로직으로 조합시켜 만드는, **인풋과 아웃풋이 분명한 함수 같은 객체**. + +### 2-2. Facade + +Service들을 가져와서 조합해야 하는데 Service 사이의 **순환 참조가 발생할 때**, 이를 막기 위해 만들어진 패턴. + +--- + +## 3. Presentation Layer (Interfaces Layer) — 값을 표현하는 레이어 + +클래스를 매핑하여 **사용자나 다른 서버 등 인터페이스로 값을 표현**하는 레이어. + +### 3-1. API DTO (컨트롤러 기준) + +Application의 Command / Query DTO로부터 API 요청값 및 응답값을 매핑해 레이어 사이 또는 다른 개체(클라이언트, 서버 등)와 통신함. + +### 3-2. ApiControllerAdvice + +Domain 레이어의 ErrorType을 HttpStatus로 매핑. + +--- + +## 4. Infrastructure Layer — DIP를 위한 레이어 + +> 물리적 모듈명: `infrastructure/` (구 `modules/`에서 리네임 완료) + +Domain Layer에서 DB와 연결하여 도메인 객체를 가져다 쓸 수 있도록 하기 위해 **기존의 의존 방향을 뒤집어, Domain Layer의 변경을 최소화**하기 위해 만들어진 레이어. + +### 4-1. RepositoryImpl + +Domain 레이어의 Repository를 구현해 DIP를 만족하도록 구현하는 구현체. 실제 기능은 JpaRepository에 위임. + +### 4-2. JpaRepository + +Spring Data JPA로 만들어둔 DB에서 값을 가져다 쓸 수 있는 Repository 객체. + +### 4-3. JPA Entity — Domain Entity와 분리 + +Domain Entity가 순수해지므로, **JPA Entity는 Infrastructure에 별도로 존재**한다. + +RepositoryImpl이 Domain Entity ↔ JPA Entity 간 변환을 담당한다. + +``` +Domain Infrastructure (infrastructure/jpa) +┌─────────────┐ ┌──────────────────┐ ┌─────────────────────┐ +│ Member │◄─toModel─│ MemberJpaEntity │─────│ MemberJpaRepository │ +│ (순수 POJO) │ │ (@Entity) │ │ (Spring Data JPA) │ +└─────────────┘─fromModel→└──────────────────┘ └─────────────────────┘ + │ + ┌────────┴────────┐ + │ MemberRepoImpl │ + │ (implements │ + │ MemberRepo) │ + └─────────────────┘ +``` + +**RepositoryImpl의 역할 변경:** + +```java +// Before — Domain Entity를 직접 JPA에 전달 +public Member save(Member member) { + return memberJpaRepository.save(member); +} + +// After — Domain Entity ↔ JPA Entity 변환 +public Member save(Member member) { + MemberJpaEntity jpaEntity = MemberJpaEntity.fromModel(member); + MemberJpaEntity saved = memberJpaRepository.save(jpaEntity); + return saved.toModel(); +} +``` + +**변환 메서드는 JPA Entity가 소유:** + +```java +// MemberJpaEntity — Infrastructure +@Entity @Table(name = "member") +public class MemberJpaEntity { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @Column(name = "login_id") private String loginId; + @Column(name = "password") private String password; + @Column(name = "name") private String name; + private LocalDate birthDate; + @Column(name = "email") private String email; + private ZonedDateTime createdAt; + private ZonedDateTime updatedAt; + + // Infrastructure → Domain + public Member toModel() { ... } + + // Domain → Infrastructure + public static MemberJpaEntity fromModel(Member member) { ... } +} +``` + +**trade-off 인지:** + +| 얻는 것 | 잃는 것 | +|---------|---------| +| Domain이 JPA를 모름 — 테스트에서 영속성 컨텍스트 고려 불필요 | 매핑 코드 증가 (toModel/fromModel) | +| 프레임워크 변경 시 Domain 코드 무변경 | JPA dirty checking 직접 사용 불가 — save 명시 호출 필요 | +| Entity 설계가 비즈니스 로직에만 집중 | 객체 복사 비용 (현재 규모에서는 무시 가능) | + +### 4-4. BaseTimeJpaEntity / BaseJpaEntity — Infrastructure로 이동 + +기존 Domain의 `BaseTimeEntity`, `BaseEntity`는 `@MappedSuperclass`, `@PrePersist`, `@PreUpdate` 등 JPA 어노테이션을 사용한다. +Domain을 순수하게 유지하므로, **JPA용 Base 클래스는 Infrastructure(infrastructure/jpa)로 이동**한다. + +``` +Domain (순수) Infrastructure (infrastructure/jpa) +┌─────────────────────┐ ┌─────────────────────────────┐ +│ (Base 클래스 없음 │ │ BaseTimeJpaEntity │ +│ 또는 순수 Base) │ │ @MappedSuperclass │ +│ │ │ @PrePersist, @PreUpdate │ +│ Member │ │ │ +│ id, createdAt, │ │ BaseJpaEntity │ +│ updatedAt │ │ extends BaseTimeJpaEntity │ +└─────────────────────┘ │ + deletedAt │ + └─────────────────────────────┘ +``` + +Domain Entity는 `id`, `createdAt`, `updatedAt` 등을 직접 필드로 가지되, JPA 어노테이션 없이 순수하게 유지한다. +JPA Entity(`MemberJpaEntity` 등)가 `BaseTimeJpaEntity`를 상속하고, `toModel()`에서 Domain Entity로 변환할 때 이 필드들을 옮겨준다. + +### 4-5. BCryptPasswordEncryptor — infrastructure/security + +> 물리적 모듈: `infrastructure/security` + +Domain의 `PasswordEncryptor` 인터페이스를 BCrypt로 구현. + +`spring-security-crypto`의 `BCryptPasswordEncoder`에 위임. + +암호화는 DB와 무관한 기술 관심사이므로 `infrastructure/jpa`가 아닌 별도 모듈로 분리한다. + +--- + +## 5. 변경 영향 분석 — 06-architecture.md 대비 + +### 5-1. 결정 완료 + +| # | 항목 | 기존 | 변경 | +|---|------|------|------| +| 1 | Domain Entity | JPA 어노테이션 허용 | **순수 POJO. JPA Entity는 Infrastructure에 분리** | +| 2 | Domain Service Bean 등록 | `@Component` 허용 | **`@Component` 제거. `@Bean` 수동 등록** | +| 3 | Spring 어노테이션 | `@Component` Domain 허용 | **Domain에서 Spring 어노테이션 전부 제거** | +| 4 | CoreException / ErrorType | Domain Layer | **supports/error 모듈로 이동. Domain의 의존을 예외적으로 허용** | +| 5 | PasswordEncryptor | `domain/utils/` (SHA-256, static) | **Domain에 interface, Infrastructure에 BCrypt 구현체. DIP 적용** | +| 6 | Aggregate vs BC | 미결정 | **BC 단위. Aggregate는 데이터 일관성 단위로 이해하되, 현재는 BC로 운영** | +| 7 | ID | `Long id` (원시값) | **VO화. `MemberId`, `BrandId`, `ProductId` 등으로 타입 안전성 확보** | +| 8 | ID null 처리 | — | **save() 반환값에서 ID가 채워진 객체를 받는 방식 (C방식)** | +| 9 | Domain Service Bean 등록 | 미정 | **Application 레이어에서 `@Configuration` + `@Bean`. DDDSample, ddd-by-examples/library 등 DDD 레퍼런스의 표준 패턴** | +| 10 | BaseTimeEntity / BaseEntity | Domain Layer (JPA 어노테이션) | **Infrastructure(infrastructure/jpa)로 이동. BaseTimeJpaEntity / BaseJpaEntity** | +| 11 | 모듈 리네임 | `modules/` | **`infrastructure/`로 리네임 완료** | +| 12 | supports/error | 미생성 | **모듈 생성 완료. 순수 Java, Spring 의존 없음** | + +| 13 | BCryptPasswordEncryptor 모듈 위치 | 미정 | **`infrastructure/security` 모듈 생성 완료. `spring-security-crypto` 의존** | +| 14 | VO 팩토리 메서드 | — | **`of()` 하나로 통일. DB 복원 시에도 검증. Password만 `fromEncrypted()` 예외 (생성 의미가 다름)** | + +### 5-2. 미결정 + +없음 — 모든 아키텍처 결정 완료. diff --git a/docs/thought/architecture-discussion-log.md b/docs/thought/architecture-discussion-log.md index 3177a3bcf..f85f1606d 100644 --- a/docs/thought/architecture-discussion-log.md +++ b/docs/thought/architecture-discussion-log.md @@ -203,3 +203,16 @@ Phase 3: 테스트 코드 수정 ``` 각 Phase 완료 후 `./gradlew test` 통과를 확인하며 점진적으로 진행. + +--- + +## 7. Phase 2 논의 사항 (2026-02-22 추가) + +Phase 2에서는 예외 체계 설계, VO 전략, DomainService 보류 등 중요한 설계 논의가 진행됨. +상세 기록: `docs/thought/phase2-discussion-log.md` + +주요 결정 사항: +- **ErrorType**: HttpStatus 제거 → pure enum, domain 레이어에 위치. 각 presentation이 자기 프로토콜에 맞게 해석. +- **VO 도입 기준**: "검증이 자주 변하거나 정책적으로 자주 변하는 속성" → VO. Password도 VO로 전환. +- **DomainService**: 현재 Member만으로는 불필요. 신규 도메인 간 로직 발생 시 도입. +- **실용주의 일관성**: JPA 허용(표준 스펙, 분리 비용 높음) vs HttpStatus 불허(Spring 고유, 분리 비용 낮음) — 같은 기준, 다른 결론. diff --git a/docs/thought/phase1-implementation-log.md b/docs/thought/phase1-implementation-log.md new file mode 100644 index 000000000..ab03f3fa3 --- /dev/null +++ b/docs/thought/phase1-implementation-log.md @@ -0,0 +1,331 @@ +# Phase 1: 구조 변경 - 구현 실시간 로그 + +> 작성일: 2026-02-21 +> 상태: 완료 +> 관련 문서: [설계서](../planning/phase1-structure-refactoring.md), [Step 7 트러블슈팅](./phase1-step7-troubleshooting.md) + +--- + +## Step 1: Gradle 설정 변경 + +### 작업 내용 + +**1-1. `settings.gradle.kts` 수정** + +모듈 include 목록을 기존 `apps/*` 기반에서 `domain/application/presentation` 기반으로 변경. + +```kotlin +// Before +include( + ":apps:commerce-api", + ":apps:commerce-batch", + ":apps:commerce-streamer", + ":modules:jpa", + ":modules:redis", + ":modules:kafka", + ":supports:jackson", + ":supports:logging", + ":supports:monitoring", +) + +// After +include( + ":domain", + ":application:commerce-api", + ":presentation:commerce-api", + ":presentation:commerce-batch", + ":presentation:commerce-streamer", + ":modules:jpa", + ":modules:redis", + ":modules:kafka", + ":supports:jackson", + ":supports:logging", + ":supports:monitoring", +) +``` + +**1-2. `build.gradle.kts` (root) 수정** + +- bootJar 활성화 필터: `"apps"` → `"presentation"` +- 컨테이너 프로젝트 비활성화: `project("apps")` → `project("application")` + `project("presentation")` + +**1-3. 신규 build.gradle.kts 파일 생성** + +| 파일 | 주요 설정 | +|------|----------| +| `domain/build.gradle.kts` | `java-library`, `java-test-fixtures`, `api("jakarta.persistence:jakarta.persistence-api")` | +| `application/commerce-api/build.gradle.kts` | `java-library`, `api(project(":domain"))` | +| `presentation/commerce-api/build.gradle.kts` | domain, application, modules, supports 의존 + web, actuator, springdoc | +| `presentation/commerce-batch/build.gradle.kts` | 기존 apps/commerce-batch와 동일 | +| `presentation/commerce-streamer/build.gradle.kts` | 기존 apps/commerce-streamer와 동일 | + +**1-4. `modules/jpa/build.gradle.kts` 수정** + +`api(project(":domain"))` 의존성 추가. + +### 검수 결과 + +개발자 확인 완료. 다음 스텝 진행. + +--- + +## Step 2: domain 모듈 생성 + 코드 이동 (modules/jpa → domain) + +### 작업 내용 + +**2-1. 디렉토리 구조 생성** + +``` +domain/src/main/java/com/loopers/domain/member/policy/ +domain/src/main/java/com/loopers/utils/ +domain/src/test/java/com/loopers/domain/member/ +``` + +**2-2. 파일 이동 (modules/jpa → domain)** + +| 파일 | 패키지 변경 | 비고 | +|------|-----------|------| +| `BaseEntity.java` | 없음 | `id` 필드: `private final Long id = 0L` → `private Long id` | +| `Member.java` | 없음 | | +| `MemberExceptionMessage.java` | 없음 | | +| `MemberPolicy.java` | 없음 | | +| `PasswordEncryptor.java` | 없음 | | + +**2-3. 신규 생성: MemberRepository Port 인터페이스** + +```java +package com.loopers.domain.member; + +public interface MemberRepository { + Member save(Member member); + Optional findByLoginId(String loginId); + boolean existsByLoginId(String loginId); +} +``` + +**2-4. MemberTest 이동** + +- From: `modules/jpa/src/testFixtures/.../testcontainers/domain/member/MemberTest.java` +- To: `domain/src/test/java/com/loopers/domain/member/MemberTest.java` +- 패키지 변경: `com.loopers.testcontainers.domain.member` → `com.loopers.domain.member` + +**2-5. modules/jpa 원본 삭제** + +`modules/jpa/src/main/.../domain/`, `.../utils/` 디렉토리 및 파일 삭제. + +### 검수 결과 + +개발자 확인 완료. 다음 스텝 진행. + +--- + +## Step 3: MemberRepository Adapter 생성 (modules/jpa) + +### 작업 내용 + +`modules/jpa/src/main/java/com/loopers/infrastructure/member/` 디렉토리에 두 파일 생성. + +**3-1. MemberJpaRepository.java** (Spring Data JPA 인터페이스) + +```java +package com.loopers.infrastructure.member; + +public interface MemberJpaRepository extends JpaRepository { + boolean existsByLoginId(String loginId); + Optional findByLoginId(String loginId); +} +``` + +**3-2. MemberRepositoryImpl.java** (Port 구현체) + +```java +@Repository +@RequiredArgsConstructor +public class MemberRepositoryImpl implements MemberRepository { + private final MemberJpaRepository memberJpaRepository; + + @Override + public Member save(Member member) { return memberJpaRepository.save(member); } + @Override + public Optional findByLoginId(String loginId) { return memberJpaRepository.findByLoginId(loginId); } + @Override + public boolean existsByLoginId(String loginId) { return memberJpaRepository.existsByLoginId(loginId); } +} +``` + +### 검수 결과 + +개발자 확인 완료. 다음 스텝 진행. + +--- + +## Step 4: application/commerce-api 생성 + 비즈니스 코드 이동 + +### 작업 내용 + +**4-1. 소스 코드 이동 (apps/commerce-api → application/commerce-api)** + +| 파일 | import 변경 | +|------|------------| +| `MemberService.java` | `infrastructure.member.MemberRepository` → `domain.member.MemberRepository` | +| `MemberRegisterRequest.java` | 없음 | +| `MyMemberInfoResponse.java` | 없음 | +| `PasswordUpdateRequest.java` | 없음 | +| `ExampleFacade.java` | 없음 | +| `ExampleInfo.java` | 없음 | +| `ExampleModel.java` | 없음 | +| `ExampleRepository.java` | 없음 | +| `ExampleService.java` | 없음 | +| `CoreException.java` | 없음 | +| `ErrorType.java` | 없음 | + +**4-2. 테스트 코드 이동 (단위 테스트만)** + +| 파일 | import 변경 | +|------|------------| +| `MemberServiceTest.java` | `infrastructure.member.MemberRepository` → `domain.member.MemberRepository` | +| `ExampleModelTest.java` | 없음 | +| `CoreExceptionTest.java` | 없음 | + +### 검수 결과 + +개발자 확인 완료. 다음 스텝 진행. + +--- + +## Step 5: presentation/commerce-api 생성 + 인터페이스/부트 코드 이동 + +### 작업 내용 + +**5-1. 소스 코드 이동 (apps/commerce-api → presentation/commerce-api)** + +| 파일 | 변경 사항 | +|------|----------| +| `CommerceApiApplication.java` | 없음 | +| `ApiResponse.java` | 없음 | +| `ApiControllerAdvice.java` | 없음 | +| `ExampleV1Controller.java` | 없음 | +| `ExampleV1ApiSpec.java` | 없음 | +| `ExampleV1Dto.java` | 없음 | +| `MemberController.java` | 패키지: `com.loopers.controller` → `com.loopers.interfaces.api.member` | +| `ExampleJpaRepository.java` | 없음 | +| `ExampleRepositoryImpl.java` | 없음 | +| `application.yml` | 없음 | + +**5-2. 테스트 코드 이동 (통합/E2E 테스트)** + +| 파일 | import 변경 | +|------|------------| +| `CommerceApiContextTest.java` | 없음 | +| `MemberServiceIntegrationTest.java` | `infrastructure.member.MemberRepository` → `domain.member.MemberRepository` | +| `MemberE2ETest.java` | 없음 | +| `ExampleServiceIntegrationTest.java` | 없음 | +| `ExampleV1ApiE2ETest.java` | 없음 | + +**5-3. 기존 apps/commerce-api infrastructure/member/ 삭제** + +apps에 남아 있던 `MemberRepository.java` (구 JPA 인터페이스) 삭제. + +### 검수 결과 + +개발자 확인 완료. 다음 스텝 진행. + +--- + +## Step 6: batch/streamer 이동 + Kafka 오타 수정 + +### 작업 내용 + +**6-1. 파일 복사** + +- `apps/commerce-batch/src/*` → `presentation/commerce-batch/src/*` (내용 변경 없음) +- `apps/commerce-streamer/src/*` → `presentation/commerce-streamer/src/*` (내용 변경 없음) + +**6-2. Kafka 패키지 오타 수정** + +- 디렉토리: `modules/kafka/.../confg/kafka/` → `config/kafka/` +- `KafkaConfig.java` 패키지: `com.loopers.confg.kafka` → `com.loopers.config.kafka` +- `DemoKafkaConsumer.java` import: `confg.kafka.KafkaConfig` → `config.kafka.KafkaConfig` + +### 검수 결과 + +개발자 확인 완료. 다음 스텝 진행. + +--- + +## Step 7: apps/ 디렉토리 삭제 + 빌드 검증 + +> 상세 트러블슈팅 문서: [phase1-step7-troubleshooting.md](./phase1-step7-troubleshooting.md) + +### 요약 + +`apps/` 삭제 후 첫 빌드에서 **Gradle Circular Dependency** 발생. +원인은 `:application:commerce-api`와 `:presentation:commerce-api`의 **프로젝트 이름 충돌** (`commerce-api`). + +8가지 접근법을 시도한 끝에 **디렉토리 이름 변경**(`commerce-api` → `commerce-service`)으로 해결. +추가로 컴파일 오류(`HttpStatus`, `@Transactional`) 및 테스트 오류(`ExampleModelTest`) 수정. + +### 최종 결과 + +| 검증 항목 | 결과 | +|----------|------| +| `./gradlew clean build -x test` | **BUILD SUCCESSFUL** (53 tasks) | +| `./gradlew :domain:test` | **PASS** | +| `./gradlew :application:commerce-service:test` | **PASS** (10 tests) | +| `./gradlew :presentation:commerce-api:test` | 16 실패 (Docker/Testcontainers 미실행 — 코드 문제 아님) | +| `apps/` 디렉토리 | **삭제 완료** | + +--- + +## Phase 1 완료 상태 + +- [x] `./gradlew clean build -x test` 전체 통과 +- [x] `./gradlew :domain:test` — MemberTest 통과 +- [x] `./gradlew :application:commerce-service:test` — 단위 테스트 통과 +- [ ] `./gradlew :presentation:commerce-api:test` — Docker 환경 필요 (코드 이상 없음) +- [ ] `./gradlew :presentation:commerce-api:bootRun` — Docker 환경 필요 +- [x] `apps/` 디렉토리 완전 제거 +- [x] `modules/jpa`에 비즈니스 로직 없음 (설정 + Adapter만) +- [x] library 모듈에 bootJar 태스크 없음 (Spring Boot 플러그인 미적용) + +--- + +## Step 8: 네이밍 개선 + Gradle 설정 정비 (Phase 1 후속) + +### 배경 + +Phase 1 트러블슈팅에서 발견된 두 가지 구조적 문제 해결. + +### 작업 내용 + +**8-1. 모듈 이름 변경** + +`application/commerce-api-core` → `application/commerce-service` + +- `-core`는 프레임워크 코어 모듈에 쓰이는 관례로 부적절 +- `commerce-service`가 application 레이어의 서비스 로직 역할을 명확히 표현 +- `settings.gradle.kts`, `presentation/commerce-api/build.gradle.kts` 참조 업데이트 + +**8-2. Spring Boot 플러그인 적용 범위 축소** + +``` +Before: 모든 서브프로젝트에 org.springframework.boot 적용 → bootJar 비활성화 +After: presentation 모듈에서만 org.springframework.boot 적용 + library 모듈은 spring-boot-dependencies BOM 명시 import로 버전 관리 +``` + +변경 파일: +- `build.gradle.kts` (root): `apply(plugin = "org.springframework.boot")` 제거, BOM import 추가 +- `presentation/commerce-api/build.gradle.kts`: `apply(plugin = "org.springframework.boot")` 추가 +- `presentation/commerce-batch/build.gradle.kts`: 동일 +- `presentation/commerce-streamer/build.gradle.kts`: 동일 + +### 검증 결과 + +| 검증 항목 | 결과 | +|----------|------| +| `./gradlew clean build -x test` | **BUILD SUCCESSFUL** (47 tasks) | +| `./gradlew :domain:test :application:commerce-service:test` | **PASS** | +| `:domain` bootJar 태스크 | **없음** | +| `:application:commerce-service` bootJar 태스크 | **없음** | +| `:presentation:commerce-api` bootJar 태스크 | **있음** | diff --git a/docs/thought/phase1-step7-troubleshooting.md b/docs/thought/phase1-step7-troubleshooting.md new file mode 100644 index 000000000..a75ab5cef --- /dev/null +++ b/docs/thought/phase1-step7-troubleshooting.md @@ -0,0 +1,660 @@ +# Phase 1 Step 7: 빌드 검증 트러블슈팅 상세 기록 + +> 작성일: 2026-02-21 +> 관련 문서: [구현 로그](./phase1-implementation-log.md) + +--- + +## 목차 + +1. [최초 오류 발생](#1-최초-오류-발생) +2. [시도 1: configure 블록 위치 변경](#2-시도-1-configure-블록-위치-변경) +3. [시도 2: tasks.withType → tasks.named 변경](#3-시도-2-taskswithtype--tasksnamed-변경) +4. [시도 3: 각 모듈에 직접 설정](#4-시도-3-각-모듈에-직접-설정) +5. [시도 4: jar 비활성화 제거](#5-시도-4-jar-비활성화-제거) +6. [진단: 다른 모듈 테스트](#6-진단-다른-모듈-테스트) +7. [시도 5: application:commerce-api 의존성 제거 테스트](#7-시도-5-applicationcommerce-api-의존성-제거-테스트) +8. [중간 수정: application 모듈 컴파일 오류 해결](#8-중간-수정-application-모듈-컴파일-오류-해결) +9. [시도 6: 디렉토리 이름 변경 (성공)](#9-시도-6-디렉토리-이름-변경-성공) +10. [시도 7: settings 레벨 이름 오버라이드 (실패)](#10-시도-7-settings-레벨-이름-오버라이드-실패) +11. [시도 8: Spring Boot 플러그인 조건부 적용 (실패)](#11-시도-8-spring-boot-플러그인-조건부-적용-실패) +12. [최종 해결: 디렉토리 이름 변경 확정](#12-최종-해결-디렉토리-이름-변경-확정) +13. [후속 오류: ExampleModelTest 실패](#13-후속-오류-examplemodeltest-실패) +14. [최종 검증](#14-최종-검증) +15. [근본 원인 분석](#15-근본-원인-분석) + +--- + +## 1. 최초 오류 발생 + +### 실행 명령 + +```bash +rm -rf apps/ +./gradlew clean build +``` + +### 오류 메시지 + +``` +FAILURE: Build failed with an exception. + +* What went wrong: +Circular dependency between the following tasks: +:presentation:commerce-api:classes +\--- :presentation:commerce-api:compileJava + \--- :presentation:commerce-api:jar + +--- :presentation:commerce-api:classes (*) + \--- :presentation:commerce-api:compileJava (*) +``` + +### 분석 + +`compileJava` → `jar` → `classes` → `compileJava` 로 순환하는 태스크 의존성. +정상적인 Gradle 빌드에서는 발생하지 않는 구조. Spring Boot 플러그인의 bootJar 태스크 와이어링과 관련된 문제로 추정. + +--- + +## 2. 시도 1: configure 블록 위치 변경 + +### 가설 + +`subprojects {}` 블록 안에 있는 `configure(allprojects.filter { ... })` 블록이 적용 순서 문제를 일으키고 있다. + +### 변경 코드 + +```kotlin +// build.gradle.kts (root) + +// Before: subprojects 블록 안에 configure 존재 +subprojects { + // ... 공통 설정 ... + + configure(allprojects.filter { it.parent?.name.equals("presentation") }) { + tasks.withType(Jar::class) { enabled = false } + tasks.withType(BootJar::class) { enabled = true } + } +} + +// After: configure 블록을 subprojects 밖으로 분리 +subprojects { + // ... 공통 설정 ... +} + +configure(subprojects.filter { it.parent?.name == "presentation" }) { + tasks.withType(Jar::class) { enabled = false } + tasks.withType(BootJar::class) { enabled = true } +} +``` + +### 결과: 실패 + +``` +Circular dependency between the following tasks: +:presentation:commerce-api:classes +\--- :presentation:commerce-api:compileJava + \--- :presentation:commerce-api:jar + +--- :presentation:commerce-api:classes (*) + \--- :presentation:commerce-api:compileJava (*) +``` + +동일한 순환 의존성 오류. 블록 위치는 원인이 아님. + +--- + +## 3. 시도 2: tasks.withType → tasks.named 변경 + +### 가설 + +`tasks.withType(Jar::class)`는 `BootJar`도 포함한다 (BootJar extends Jar). +이로 인해 BootJar까지 비활성화되면서 태스크 해석 순서가 꼬인다. +`tasks.named`로 정확한 태스크만 지정하면 해결될 수 있다. + +### 변경 코드 + +```kotlin +// build.gradle.kts (root) + +// Before +configure(subprojects.filter { it.parent?.name == "presentation" }) { + tasks.withType(Jar::class) { enabled = false } + tasks.withType(BootJar::class) { enabled = true } +} + +// After +configure(subprojects.filter { it.parent?.name == "presentation" }) { + tasks.named("jar") { enabled = false } + tasks.named("bootJar") { enabled = true } +} +``` + +### 결과: 실패 + +동일한 순환 의존성 오류. `withType` vs `named`는 원인이 아님. + +--- + +## 4. 시도 3: 각 모듈에 직접 설정 + +### 가설 + +root의 configure 블록이 presentation 모듈에 제대로 적용되지 않는다. +각 presentation 모듈의 `build.gradle.kts`에 직접 bootJar/jar 설정을 넣으면 해결된다. + +### 변경 코드 + +```kotlin +// build.gradle.kts (root) — configure 블록 완전 제거 + +// presentation/commerce-api/build.gradle.kts +import org.springframework.boot.gradle.tasks.bundling.BootJar + +tasks.named("jar") { enabled = false } +tasks.named("bootJar") { enabled = true } + +dependencies { + // ... 기존 의존성 ... +} +``` + +`commerce-batch`, `commerce-streamer`에도 동일하게 적용. + +### 결과: 실패 + +동일한 순환 의존성 오류. 설정 위치(root vs 개별 모듈)는 원인이 아님. + +--- + +## 5. 시도 4: jar 비활성화 제거 + +### 가설 + +`jar` 태스크를 비활성화하는 것 자체가 순환을 만든다. +`bootJar`만 활성화하고 `jar` 비활성화는 제거하면 된다. + +### 변경 코드 + +```kotlin +// presentation/commerce-api/build.gradle.kts + +// Before +tasks.named("jar") { enabled = false } +tasks.named("bootJar") { enabled = true } + +// After — jar 비활성화 라인 제거 +tasks.named("bootJar") { enabled = true } +``` + +### 결과: 실패 + +```bash +./gradlew :presentation:commerce-api:compileJava +``` + +동일한 순환 의존성 오류. `compileJava`에서조차 발생하므로 jar/bootJar 설정과 무관한 근본적 문제. + +--- + +## 6. 진단: 다른 모듈 테스트 + +### 실행 명령 + +```bash +./gradlew :presentation:commerce-batch:compileJava +``` + +### 결과: BUILD SUCCESSFUL + +`commerce-batch`는 정상 컴파일. 문제는 **`:presentation:commerce-api`에만 국한**. + +### 핵심 차이점 + +| 모듈 | `application:commerce-api` 의존 | 결과 | +|------|-------------------------------|------| +| `presentation:commerce-api` | **있음** | 순환 의존성 | +| `presentation:commerce-batch` | 없음 | 정상 | +| `presentation:commerce-streamer` | 없음 | 정상 | + +→ `:application:commerce-api` 의존성이 순환의 트리거. + +--- + +## 7. 시도 5: application:commerce-api 의존성 제거 테스트 + +### 가설 + +`:application:commerce-api`와 `:presentation:commerce-api`가 동일한 Gradle 프로젝트 이름 `commerce-api`를 공유하여 충돌한다. + +### 변경 코드 + +```kotlin +// presentation/commerce-api/build.gradle.kts + +dependencies { + implementation(project(":domain")) + // implementation(project(":application:commerce-api")) // ← 주석 처리 + implementation(project(":modules:jpa")) + // ... 나머지 유지 ... +} +``` + +### 결과: 순환 의존성 해소 (컴파일 오류 발생) + +```bash +./gradlew :presentation:commerce-api:compileJava +``` + +``` +ExampleJpaRepository.java:3: error: package com.loopers.domain.example does not exist +ExampleRepositoryImpl.java:3: error: package com.loopers.domain.example does not exist +``` + +순환 의존성은 사라짐. 컴파일 오류는 application 모듈의 코드를 참조할 수 없어서 발생. + +**확정: 프로젝트 이름 충돌이 근본 원인.** + +의존성을 다시 복원. + +--- + +## 8. 중간 수정: application 모듈 컴파일 오류 해결 + +순환 의존성과 별도로, `application:commerce-api` 자체의 컴파일도 확인. + +### 실행 명령 + +```bash +./gradlew :application:commerce-api:compileJava +``` + +### 오류 메시지 + +``` +ErrorType.java:5: error: package org.springframework.http does not exist +import org.springframework.http.HttpStatus; + +ErrorType.java:16: error: cannot find symbol + private final HttpStatus status; + +ExampleService.java:7: error: package org.springframework.transaction.annotation does not exist +import org.springframework.transaction.annotation.Transactional; + +MemberService.java:11: error: package org.springframework.transaction.annotation does not exist +import org.springframework.transaction.annotation.Transactional; +``` + +### 원인 + +`application/commerce-api/build.gradle.kts`에 `api(project(":domain"))`만 선언. +`ErrorType`이 사용하는 `HttpStatus`는 `spring-web`에, `@Transactional`은 `spring-tx`에 존재. +application 레이어는 `spring-boot-starter-web`을 의존하지 않으므로 개별 의존성 필요. + +### 해결 코드 + +```kotlin +// application/commerce-api/build.gradle.kts + +// Before +plugins { + `java-library` +} +dependencies { + api(project(":domain")) +} + +// After +plugins { + `java-library` +} +dependencies { + api(project(":domain")) + implementation("org.springframework:spring-web") + implementation("org.springframework:spring-tx") +} +``` + +### 결과: BUILD SUCCESSFUL + +```bash +./gradlew :application:commerce-api:compileJava +# BUILD SUCCESSFUL in 1s +``` + +--- + +## 9. 시도 6: 디렉토리 이름 변경 (성공) + +### 가설 + +Gradle은 디렉토리 이름에서 프로젝트 이름을 유도한다. +`application/commerce-api`(이름: `commerce-api`)와 `presentation/commerce-api`(이름: `commerce-api`)가 충돌. +디렉토리를 `commerce-api-core`로 변경하면 프로젝트 이름이 유일해진다. + +### 변경 내용 + +```bash +mv application/commerce-api application/commerce-api-core +``` + +```kotlin +// settings.gradle.kts +// Before: ":application:commerce-api" +// After: ":application:commerce-api-core" + +// presentation/commerce-api/build.gradle.kts +// Before: implementation(project(":application:commerce-api")) +// After: implementation(project(":application:commerce-api-core")) +``` + +### 결과: BUILD SUCCESSFUL + +```bash +./gradlew :presentation:commerce-api:compileJava +# BUILD SUCCESSFUL in 1s — 10 actionable tasks: 2 executed, 8 up-to-date +``` + +**순환 의존성 완전 해소.** + +> 그러나 이 시점에서 "더 깔끔한 방법"을 찾기 위해 이 변경을 되돌리고 다른 접근을 시도. + +```bash +mv application/commerce-api-core application/commerce-api # 되돌림 +``` + +--- + +## 10. 시도 7: settings 레벨 이름 오버라이드 (실패) + +### 가설 + +디렉토리를 바꾸지 않고 `settings.gradle.kts`에서 프로젝트 이름만 오버라이드할 수 있다. + +### 변경 코드 + +```kotlin +// settings.gradle.kts + +include( + ":domain", + ":application:commerce-api", + // ... +) + +// 프로젝트 이름 오버라이드 +project(":application:commerce-api").name = "commerce-api-core" +``` + +### 결과: 실패 + +``` +* What went wrong: +Project with path ':application:commerce-api' could not be found +in project ':presentation:commerce-api'. +``` + +### 원인 + +`project(":application:commerce-api")`로 이름을 변경하면 Gradle 내부 경로 자체가 바뀐다. +`build.gradle.kts`의 `project(":application:commerce-api")` 참조가 더 이상 유효하지 않음. +참조를 `project(":application:commerce-api-core")`로 바꿔야 하는데, 그러면 디렉토리 이름 변경과 동일한 효과. + +--- + +## 11. 시도 8: Spring Boot 플러그인 조건부 적용 (실패) + +### 가설 + +Spring Boot 플러그인이 bootJar 태스크를 와이어링할 때 같은 이름의 프로젝트를 혼동한다. +library 모듈(domain, application)에서 Spring Boot 플러그인을 제거하면 해결된다. + +### 변경 코드 + +```kotlin +// build.gradle.kts (root) + +subprojects { + apply(plugin = "java") + apply(plugin = "io.spring.dependency-management") + apply(plugin = "jacoco") + + // domain과 application 모듈에는 Spring Boot 플러그인 미적용 + if (project.path != ":domain" && project.parent?.name != "application") { + apply(plugin = "org.springframework.boot") + } + // ... +} +``` + +### 결과: 실패 — 순환 의존성 동일 + +``` +Circular dependency between the following tasks: +:presentation:commerce-api:classes +\--- :presentation:commerce-api:compileJava + \--- :presentation:commerce-api:jar + +--- :presentation:commerce-api:classes (*) + \--- :presentation:commerce-api:compileJava (*) +``` + +Spring Boot 플러그인 적용 여부와 무관하게 Gradle의 프로젝트 이름 해석 레벨에서 충돌 발생. + +### 부수 효과 + +이 상태에서 디렉토리 이름 변경(시도 6)을 다시 적용해도, +Spring Boot 플러그인이 domain/application에 없어서 **BOM 버전 해석 실패**: + +``` +Execution failed for task ':domain:compileJava'. +> Could not resolve all files for configuration ':domain:compileClasspath'. + > Could not find jakarta.persistence:jakarta.persistence-api:. + Required by: project :domain + +> Could not find org.springframework.boot:spring-boot-starter:. + Required by: project :domain + +> Could not find com.fasterxml.jackson.datatype:jackson-datatype-jsr310:. + Required by: project :domain + +> Could not find org.projectlombok:lombok:. + Required by: project :domain +``` + +`io.spring.dependency-management`만으로는 Spring Boot BOM이 자동 적용되지 않음. +`org.springframework.boot` 플러그인이 있어야 BOM을 통한 버전 관리가 동작. + +--- + +## 12. 최종 해결: 디렉토리 이름 변경 확정 + +### 최종 변경 사항 + +**1단계: Spring Boot 플러그인 전체 복원** + +```kotlin +// build.gradle.kts (root) + +subprojects { + apply(plugin = "java") + apply(plugin = "org.springframework.boot") // 전체 서브프로젝트에 적용 + apply(plugin = "io.spring.dependency-management") + apply(plugin = "jacoco") + // ... + + // 기본: jar 활성화, bootJar 비활성화 (library 모듈용) + tasks.withType(Jar::class) { enabled = true } + tasks.withType(BootJar::class) { enabled = false } +} +``` + +**2단계: 디렉토리 이름 변경** + +```bash +mv application/commerce-api application/commerce-api-core +``` + +**3단계: Gradle 참조 업데이트** + +```kotlin +// settings.gradle.kts +include( + ":domain", + ":application:commerce-api-core", // ← 변경 + ":presentation:commerce-api", + // ... +) + +// presentation/commerce-api/build.gradle.kts +dependencies { + implementation(project(":application:commerce-api-core")) // ← 변경 + // ... +} +``` + +**4단계: 각 presentation 모듈에서 bootJar 활성화** + +```kotlin +// presentation/commerce-api/build.gradle.kts +import org.springframework.boot.gradle.tasks.bundling.BootJar +tasks.named("bootJar") { enabled = true } + +// presentation/commerce-batch/build.gradle.kts (동일) +// presentation/commerce-streamer/build.gradle.kts (동일) +``` + +### 결과: BUILD SUCCESSFUL + +```bash +./gradlew clean build -x test +# BUILD SUCCESSFUL in 3s (53 actionable tasks: 51 executed, 2 up-to-date) +``` + +--- + +## 13. 후속 오류: ExampleModelTest 실패 + +### 실행 명령 + +```bash +./gradlew :application:commerce-api-core:test +``` + +### 오류 메시지 + +``` +ExampleModelTest > Create > 제목과 설명이 모두 주어지면, 정상적으로 생성된다. FAILED + org.opentest4j.MultipleFailuresError at ExampleModelTest.java:28 + Caused by: java.lang.AssertionError at ExampleModelTest.java:29 + +10 tests completed, 1 failed +``` + +### 원인 + +Step 2에서 `BaseEntity.id`를 `private final Long id = 0L` → `private Long id`로 변경. +영속화 전 `id`가 `0L`이 아닌 `null`이 되었음. +테스트에 `assertThat(exampleModel.getId()).isNotNull()` assertion이 있어 실패. + +### 해결 코드 + +```java +// application/commerce-api-core/src/test/.../ExampleModelTest.java + +// Before +assertAll( + () -> assertThat(exampleModel.getId()).isNotNull(), + () -> assertThat(exampleModel.getName()).isEqualTo(name), + () -> assertThat(exampleModel.getDescription()).isEqualTo(description) +); + +// After — getId() assertion 제거 +assertAll( + () -> assertThat(exampleModel.getName()).isEqualTo(name), + () -> assertThat(exampleModel.getDescription()).isEqualTo(description) +); +``` + +### 결과: 테스트 통과 + +```bash +./gradlew :domain:test :application:commerce-api-core:test +# BUILD SUCCESSFUL — 전체 단위 테스트 통과 +``` + +--- + +## 14. 최종 검증 + +| 명령 | 결과 | +|------|------| +| `./gradlew clean build -x test` | BUILD SUCCESSFUL (53 tasks) | +| `./gradlew :domain:test` | PASS | +| `./gradlew :application:commerce-api-core:test` | PASS (10 tests) | +| `./gradlew :presentation:commerce-api:test` | 16 FAIL — Docker/Testcontainers 미실행 (코드 문제 아님) | +| `ls apps/` | `No such file or directory` (삭제 완료) | + +--- + +## 15. 근본 원인 분석 + +### 원인 + +Gradle은 프로젝트 이름을 **디렉토리 이름**에서 유도한다. + +``` +:application:commerce-api → 프로젝트 이름: "commerce-api" +:presentation:commerce-api → 프로젝트 이름: "commerce-api" +``` + +동일한 이름의 프로젝트가 2개 존재하면, Spring Boot 플러그인이 bootJar 태스크 의존성 그래프를 +와이어링할 때 **다른 프로젝트의 태스크를 자기 프로젝트의 태스크로 혼동**하여 순환 발생: + +``` +:presentation:commerce-api:compileJava + → :presentation:commerce-api:jar (여기서 :application:commerce-api:jar과 혼동) + → :presentation:commerce-api:classes + → :presentation:commerce-api:compileJava ← 순환! +``` + +### 시도한 접근법 총 정리 + +| # | 접근법 | 변경 위치 | 결과 | 실패 이유 | +|---|--------|----------|------|----------| +| 1 | configure 블록 위치 이동 | root build.gradle.kts | 실패 | 이름 충돌은 블록 위치와 무관 | +| 2 | withType → named | root build.gradle.kts | 실패 | 태스크 선택 방식과 무관 | +| 3 | 각 모듈에 직접 설정 | presentation/*/build.gradle.kts | 실패 | 설정 위치와 무관 | +| 4 | jar 비활성화 제거 | presentation/commerce-api/build.gradle.kts | 실패 | jar/bootJar 설정과 무관 | +| 5 | application 의존성 제거 | presentation/commerce-api/build.gradle.kts | **순환 해소** | 이름 충돌 트리거 제거 확인 | +| 6 | **디렉토리 이름 변경** | 파일시스템 + settings + build | **성공** | 프로젝트 이름 유일화 | +| 7 | settings 이름 오버라이드 | settings.gradle.kts | 실패 | 경로 참조 깨짐 | +| 8 | Spring Boot 플러그인 조건부 적용 | root build.gradle.kts | 실패 | BOM 버전 해석 실패 | + +### 교훈 + +1. **Gradle 멀티모듈에서 서로 다른 부모 아래에 같은 이름의 서브모듈을 두면 안 된다.** +2. `settings.gradle.kts`의 `project(...).name` 오버라이드는 경로 기반 참조를 깨뜨린다. +3. `io.spring.dependency-management`만으로는 Spring Boot BOM 버전이 해석되지 않는다. 단, `spring-boot-dependencies` BOM을 명시적으로 import하면 Spring Boot 플러그인 없이도 버전 관리가 가능하다. +4. 순환 의존성 오류 메시지는 실제 원인(이름 충돌)을 직접 알려주지 않는다. 다른 모듈과의 비교 테스트가 핵심 진단법이었다. + +--- + +## 16. 후속 조치: 네이밍 개선 + Spring Boot 플러그인 정비 + +> 이 문서의 트러블슈팅 결과를 바탕으로 Phase 1 후속 작업으로 진행. + +### 16-1. `commerce-api-core` → `commerce-service` 이름 변경 + +`-core`는 프레임워크 코어 모듈 관례. `commerce-service`가 application 레이어 역할을 더 명확히 표현. + +### 16-2. Spring Boot 플러그인 적용 범위 축소 + +교훈 3번의 발견(BOM 명시 import)을 활용하여 근본 구조 개선: + +``` +Before: 모든 서브프로젝트에 org.springframework.boot 적용 → bootJar 기본 비활성화 +After: presentation 모듈에서만 적용 + library 모듈은 BOM 명시 import +``` + +이로써: +- library 모듈에 불필요한 bootJar 태스크가 생기지 않음 +- 프로젝트 이름 충돌 시에도 순환 의존성 위험이 원천 차단됨 +- 각 모듈의 역할이 Gradle 설정에서도 명확히 드러남 diff --git a/docs/thought/phase2-discussion-log.md b/docs/thought/phase2-discussion-log.md new file mode 100644 index 000000000..f68d3a967 --- /dev/null +++ b/docs/thought/phase2-discussion-log.md @@ -0,0 +1,228 @@ +# Phase 2: 모델링 및 설계 변경 - 논의 기록 + +> 작성일: 2026-02-22 +> 참여: 개발자, AI (Claude Code) +> 맥락: Phase 1(구조 변경) 완료 후, 도메인 모델링 및 예외 체계 설계 논의 + +--- + +## 1. 예외 체계 설계 + +### 1-1. 문제 정의 + +Phase 1 완료 후 남은 핵심 문제: +- 도메인 레이어에서 `IllegalArgumentException` 사용 → `ApiControllerAdvice`에서 전부 401로 반환 +- `ErrorType`이 `HttpStatus`를 직접 보유 → application 레이어가 `spring-web`에 의존 +- presentation이 3개(api, batch, streamer)인데, 각각의 에러 모델이 다름 + +### 1-2. HttpStatus를 application에 두는 것의 적절성 + +**개발자 질문:** "application layer는 HTTP에 의존해선 안 되는 걸까?" + +**분석:** +- application이 HTTP를 아는 것 자체는 실용적으로 문제없다는 의견 +- 다만 이 프로젝트는 presentation이 3개(HTTP API, Batch, Kafka Streamer) +- HTTP status는 HTTP 전용 개념이므로, application에 두면 Batch/Kafka에서 불필요한 의존 발생 + +**결론:** HTTP status를 application에 두는 것이 "잘못된" 것은 아니지만, 이 프로젝트에서는 분리가 더 적합. + +### 1-3. DomainException 설계 방향 — 4가지 선택지 검토 + +| 방식 | 설명 | 장점 | 단점 | +|------|------|------|------| +| A. DomainException에 HTTP/Kafka 로직 직접 보유 | 예외가 모든 프로토콜의 매핑 정보를 가짐 | 간편, 한곳에서 파악 | 프로토콜 추가 시 domain 변경 필요. domain이 spring-web에 의존 | +| B. DomainException 추상화 + 위에서 매핑 | 순수 분류만 하고 presentation이 해석 | 깨끗한 분리 | 매핑 코드 중복, 수정 포인트 분산 | +| C. ErrorCode + HttpStatus (가장 보편적) | ErrorCode enum에 HttpStatus 포함 | 대부분의 실무 프로젝트가 사용 | API만 있을 때 최적, 다중 presentation에서 부적합 | +| D. ErrorCode + ErrorGroup | HttpStatus 없이 의미 분류만 | 순수성과 실용성 균형 | 간접 레이어 하나 추가 | + +### 1-4. sealed class 제안과 검토 + +**개발자 제안:** sealed interface/class를 사용하면 어떨까? + +**분석:** +- Java 21 pattern matching과 조합하면 컴파일 타임에 exhaustive check 가능 +- 새 예외 타입 추가 시 모든 presentation의 switch 문에서 **컴파일 에러** 발생 → 강제로 핸들링 전략 정의 +- 개발자의 `@Builder` 거부 이유(컴파일 타임 안전성)와 동일한 맥락 + +```java +// 가능한 구조 +public sealed class DomainException extends RuntimeException + permits DomainException.BadRequest, DomainException.NotFound, ... + +// presentation에서 +switch (e) { + case DomainException.BadRequest b -> HttpStatus.BAD_REQUEST; + case DomainException.NotFound n -> HttpStatus.NOT_FOUND; + // 새 타입 추가 시 → 컴파일 에러 +} +``` + +### 1-5. Kafka 에러 모델 학습 + +**개발자 질문:** "Kafka는 어떤 식의 오류 상태가 존재해?" + +HTTP와 Kafka의 에러 모델은 근본적으로 다름: + +| 상황 | HTTP | Kafka | +|------|------|-------| +| 정상 처리 | 200 OK | ACK (offset commit) | +| 일시적 오류 | 503 → 재시도 | Retry + Backoff | +| 비즈니스 검증 실패 | 400 | DLQ (재시도 무의미) | +| 인증/권한 오류 | 401/403 | DLQ + Alert | +| 역직렬화 실패 | 422 | Skip or DLQ | +| 재시도 소진 | - | DLQ | + +**핵심 차이:** HTTP는 "요청자에게 응답 코드를 돌려주는" 모델, Kafka는 "내가 처리할 수 있느냐/없느냐"만 판단. + +이 분석이 최종 설계 결정에 결정적 영향을 미침 → "domain에 HttpStatus를 넣지 않아야 한다"는 확신. + +### 1-6. 최종 결정: ErrorType pure enum + +**개발자의 최종 판단:** + +> "enum 으로만 쓰고 이걸 해석하는 건 자유로 남겨두게 어때?" + +HTTP status 코드의 분류(400, 401, 404, 409, 500)는 사실 비즈니스 의미에서 파생된 것: +- 400 = 규칙 위반, 401 = 인증 실패, 404 = 존재하지 않음, 409 = 중복/충돌, 500 = 시스템 오류 + +이 의미론적 분류를 ErrorType enum으로 표현하고, 각 presentation이 자기 프로토콜에 맞게 해석: + +```java +// domain 레이어 +public enum ErrorType { + BAD_REQUEST, NOT_FOUND, CONFLICT, UNAUTHORIZED, INTERNAL_ERROR +} + +// presentation/commerce-api +ErrorType.BAD_REQUEST → HttpStatus.BAD_REQUEST +ErrorType.UNAUTHORIZED → HttpStatus.UNAUTHORIZED + +// presentation/commerce-streamer +ErrorType.BAD_REQUEST → DLQ (재시도 무의미) +ErrorType.NOT_FOUND → Retry → DLQ +ErrorType.CONFLICT → ACK (멱등) +``` + +sealed class 대신 enum을 선택한 이유: +- 현재 에러 분류가 5개 수준으로 충분 +- enum이 더 간결하고 기존 구조와 변경량 최소 +- sealed class는 에러 타입별로 다른 데이터를 가져야 할 때(ex: Retry 횟수) 도입 검토 + +--- + +## 2. VO 전환 전략 + +### 2-1. JPA 허용 결정과의 연장선 + +Phase 1에서 "domain에 JPA 어노테이션 허용"을 결정한 바 있음. +`@Embeddable` VO도 같은 맥락으로 자연스럽게 사용 가능. + +### 2-2. VO 대상 선정 + +**개발자 판단:** "결국에는 검증이 자주 변하거나, 정책적으로 자주 변하는 속성이 존재하면 바뀔 거 같은데" + +이 기준으로 Password도 VO에 포함: + +| 필드 | VO 여부 | 근거 | +|------|---------|------| +| loginId | `LoginId` VO | 형식/길이 규칙, 정책 변경 가능 | +| password | `Password` VO | 길이/형식/생년월일 규칙 + 암호화 + 비교. **가장 정책 변경이 잦은 필드** | +| name | `MemberName` VO | 형식/길이 규칙 | +| email | `Email` VO | RFC 형식/길이 규칙 | +| birthDate | `LocalDate` 유지 | "미래 불가"만 검증. LocalDate 자체가 value type | + +### 2-3. Password VO의 특수성 + +Password는 다른 VO와 다른 점: +- **저장 값이 raw와 다름**: raw → 암호화 → 저장 +- **생성 시 외부 컨텍스트 필요**: `birthDate`가 검증에 사용됨 +- **비교 로직 소유**: `matches(rawPassword)` + +이 모든 것을 Password VO가 소유하게 함으로써: +- Member에서 `PasswordEncryptor` 직접 호출 제거 +- `isSamePassword()` 위임 메서드가 `password.matches()` 호출로 변경 +- 비밀번호 정책 변경 시 **Password VO 하나만 수정** + +--- + +## 3. DomainService 보류 결정 + +### 3-1. 분석 + +현재 MemberService의 메서드별 책임: + +| 메서드 | 로직 | 분류 | +|--------|------|------| +| `register()` | 중복 체크 → 도메인 생성 → 저장 | Application (유스케이스 조합) | +| `getMyInfo()` | 조회 → 인증 → DTO 변환 | Application | +| `updatePassword()` | 조회 → 인증 → 도메인 위임 | Application | + +검증은 VO가, 비밀번호 변경은 엔티티가, 유스케이스 조합은 ApplicationService가 담당. +**DomainService가 담당할 로직이 현재 없음.** + +### 3-2. 결정 + +개발자 동의 하에 보류. DomainService는 다음 상황에서 도입: +- 여러 도메인 간 규칙이 필요할 때 (ex: "재고가 충분한지 확인 후 주문 생성") +- 하나의 엔티티에 담기 어려운 도메인 로직이 생길 때 + +빈 껍데기를 미리 만들지 않는다는 원칙 (CLAUDE.md: "오버엔지니어링 금지"). + +--- + +## 4. 마스킹 로직의 위치 + +### 4-1. 문제 + +`MemberService.maskName()`이 Service에 위치 — 표현 관심사가 비즈니스 레이어에 혼재. + +### 4-2. 논의 없이 합의 + +이전 Phase 2 계획에서 이미 "마스킹은 Presentation으로 이동"으로 합의되어 있었음. + +### 4-3. 구현 방식 선택 + +마스킹 로직을 어디에 둘지: +- Controller 내부 메서드 → Controller가 비대해짐 +- 별도 Masking 유틸 → 과도한 추상화 +- **Response DTO의 `withMaskedName()` 메서드** → DTO가 자기 표현을 소유 + +`withMaskedName()`으로 결정. record의 불변성을 유지하면서 마스킹된 새 인스턴스를 반환. + +--- + +## 5. 설계 원칙 정리 + +Phase 2 논의를 통해 확인/정립된 설계 원칙: + +### 5-1. 실용주의 기준의 일관성 + +| 판단 대상 | 결정 | 근거 | +|-----------|------|------| +| JPA `@Entity` in domain | 허용 | 표준 스펙, 분리 비용 높음 | +| `HttpStatus` in domain | 불허 | Spring 고유, 분리 비용 낮음 (switch 하나) | +| `@Embeddable` VO | 허용 | JPA 허용의 연장선 | + +같은 "실용주의" 기준이지만 대상의 특성에 따라 결론이 다름. + +### 5-2. VO 도입 기준 + +> "검증이 자주 변하거나, 정책적으로 자주 변하는 속성이 존재하면" + +- 자체 규칙(invariant)이 있는 필드 → VO +- 단순 저장만 하는 필드 → primitive/표준 타입 유지 +- 불변 보장 + 검증 내재화가 핵심 가치 + +### 5-3. 예외 설계 원칙 + +> "enum으로만 쓰고 해석하는 건 자유로 남겨두게" + +- domain은 **"무슨 종류의 실패인가"**만 표현 +- **"어떻게 응답할 것인가"**는 presentation이 결정 +- 프로토콜(HTTP, Kafka, Batch)마다 같은 ErrorType을 다르게 해석 + +### 5-4. 오버엔지니어링 방지 + +- 현재 필요하지 않은 DomainService는 만들지 않음 +- sealed class는 현재 enum으로 충분하므로 도입하지 않음 +- "신규 도메인 추가 시" 같은 미래 시점에 재검토 diff --git a/docs/thought/phase2-implementation-log.md b/docs/thought/phase2-implementation-log.md new file mode 100644 index 000000000..6831331c1 --- /dev/null +++ b/docs/thought/phase2-implementation-log.md @@ -0,0 +1,415 @@ +# Phase 2: 모델링 및 설계 변경 - 구현 실시간 로그 + +> 작성일: 2026-02-22 +> 상태: 완료 +> 관련 문서: [설계 논의](./phase2-discussion-log.md), [리팩토링 계획서](../planning/refactoring-plan.md) + +--- + +## Step 1: ErrorType(pure enum) + CoreException → domain 이동 + +### 배경 + +기존 `ErrorType`은 `HttpStatus`를 직접 보유하고 있어 application 레이어가 `spring-web`에 의존. +Phase 2 논의에서 ErrorType을 순수 enum으로 변경하고, 각 presentation 레이어가 자기 프로토콜에 맞게 해석하기로 결정. + +### 작업 내용 + +**1-1. ErrorType 변경 (HttpStatus 제거, UNAUTHORIZED 추가)** + +```java +// Before (application 레이어) +@Getter +@RequiredArgsConstructor +public enum ErrorType { + INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, ...), + BAD_REQUEST(HttpStatus.BAD_REQUEST, ...), + NOT_FOUND(HttpStatus.NOT_FOUND, ...), + CONFLICT(HttpStatus.CONFLICT, ...); + private final HttpStatus status; + private final String code; + private final String message; +} + +// After (domain 레이어) +@Getter +@RequiredArgsConstructor +public enum ErrorType { + INTERNAL_ERROR("Internal Server Error", "일시적인 오류가 발생했습니다."), + BAD_REQUEST("Bad Request", "잘못된 요청입니다."), + NOT_FOUND("Not Found", "존재하지 않는 요청입니다."), + CONFLICT("Conflict", "이미 존재하는 리소스입니다."), + UNAUTHORIZED("Unauthorized", "인증에 실패했습니다."); + private final String code; + private final String message; +} +``` + +**1-2. CoreException → domain 이동** + +`application/commerce-service/.../support/error/` → `domain/.../support/error/` + +내용 변경 없음. 패키지 동일(`com.loopers.support.error`). + +**1-3. ApiControllerAdvice 재설계** + +- `IllegalArgumentException` 핸들러 **삭제** (모든 도메인/서비스 예외가 CoreException으로 통일됨) +- `toHttpStatus()` switch 매핑 메서드 추가 + +```java +private HttpStatus toHttpStatus(ErrorType errorType) { + return switch (errorType) { + case BAD_REQUEST -> HttpStatus.BAD_REQUEST; + case NOT_FOUND -> HttpStatus.NOT_FOUND; + case CONFLICT -> HttpStatus.CONFLICT; + case UNAUTHORIZED -> HttpStatus.UNAUTHORIZED; + case INTERNAL_ERROR -> HttpStatus.INTERNAL_SERVER_ERROR; + }; +} +``` + +**1-4. application/commerce-service build.gradle.kts 변경** + +```kotlin +// Before +implementation("org.springframework:spring-web") // ErrorType의 HttpStatus 때문에 필요했음 +implementation("org.springframework:spring-tx") + +// After +implementation("org.springframework:spring-tx") +implementation("org.springframework:spring-context") // @Service 어노테이션 +``` + +**1-5. CoreExceptionTest → domain 이동** + +`application/commerce-service/src/test/` → `domain/src/test/` + +### 검증 결과 + +| 검증 항목 | 결과 | +|----------|------| +| `./gradlew clean build -x test` | **BUILD SUCCESSFUL** (47 tasks) | +| `./gradlew :domain:test` | **PASS** | +| `./gradlew :application:commerce-service:test` | **PASS** | + +--- + +## Step 2: BaseTimeEntity 신설 + +### 배경 + +기존 `BaseEntity`가 id + createdAt + updatedAt + deletedAt 을 모두 가지고 있어, +soft-delete가 불필요한 엔티티에도 deletedAt 컬럼이 강제됨. + +### 작업 내용 + +**2-1. BaseTimeEntity 신설** + +```java +@MappedSuperclass +@Getter +public abstract class BaseTimeEntity { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + private ZonedDateTime createdAt; + private ZonedDateTime updatedAt; + + protected void guard() {} + + @PrePersist → createdAt, updatedAt 설정 + @PreUpdate → updatedAt 갱신 +} +``` + +**2-2. BaseEntity 변경** + +```java +// Before: 독립 클래스 (id, createdAt, updatedAt, deletedAt 모두 보유) +// After: BaseTimeEntity 상속, deletedAt + delete()/restore()만 추가 +@MappedSuperclass +@Getter +public abstract class BaseEntity extends BaseTimeEntity { + private ZonedDateTime deletedAt; + public void delete() { ... } + public void restore() { ... } +} +``` + +### 검증 결과 + +| 검증 항목 | 결과 | +|----------|------| +| `./gradlew clean build -x test` | **BUILD SUCCESSFUL** | + +--- + +## Step 3: MemberPolicy → VO 전환 + @Builder 제거 + +### 배경 + +MemberPolicy가 중앙 집중 방식으로 모든 검증 규칙을 보유하고 있어 응집도가 낮음. +검증 규칙을 각 VO에 내재화하여 "해당 VO만 보면 규칙을 알 수 있는" 구조로 전환. + +### 작업 내용 + +**3-1. VO 4개 생성** + +| VO | 패키지 | 검증 규칙 | +|----|--------|----------| +| `LoginId` | `domain/.../member/vo/` | 6~20자, 영문+숫자, 영문 필수, 숫자만 불가 | +| `Password` | `domain/.../member/vo/` | 8~16자, 허용 문자, 생년월일 미포함, 암호화 | +| `MemberName` | `domain/.../member/vo/` | 2~40자, 한글/영문만 | +| `Email` | `domain/.../member/vo/` | RFC 5321, 255자 이하 | + +각 VO 공통 구조: + +```java +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class LoginId { + @Column(name = "login_id") + private String value; + + private LoginId(String value) { this.value = value; } + + public static LoginId of(String value) { + validate(value); + return new LoginId(value); + } + + private static void validate(String value) { + // CoreException(ErrorType.BAD_REQUEST, ...) 사용 + } + + // equals(), hashCode() 구현 +} +``` + +**Password VO 특이사항:** + +```java +public class Password { + private String value; // 암호화된 값 저장 + + public static Password of(String rawPassword, LocalDate birthDate) { + validateFormat(rawPassword); + validateBirthDateNotContained(rawPassword, birthDate); + return new Password(PasswordEncryptor.encode(rawPassword)); + } + + public boolean matches(String rawPassword) { ... } + + public void validateChangeable(String newRawPassword, LocalDate birthDate) { + // 동일 비밀번호 체크 + 형식 검증 + 생년월일 검증 + } +} +``` + +Password VO가 검증 + 암호화 + 비교를 모두 소유. Member에서 `PasswordEncryptor` 직접 호출이 사라짐. + +**3-2. Member 엔티티 리팩토링** + +```java +// Before +@Entity @Getter @NoArgsConstructor @AllArgsConstructor @Builder +public class Member { + @Id @GeneratedValue private Long id; + private String loginId; + private String password; + ... + public static Member register(...) { + MemberPolicy.LoginId.validate(loginId); + ... + return Member.builder().loginId(loginId).password(encodedPassword(password))...build(); + } +} + +// After +@Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Member extends BaseTimeEntity { + @Embedded private LoginId loginId; + @Embedded private Password password; + @Embedded private MemberName name; + private LocalDate birthDate; + @Embedded private Email email; + + private Member(LoginId loginId, Password password, MemberName name, LocalDate birthDate, Email email) { + validateBirthDate(birthDate); + this.loginId = loginId; + ... + } + + public static Member register(String loginId, String rawPassword, String name, LocalDate birthDate, String email) { + return new Member(LoginId.of(loginId), Password.of(rawPassword, birthDate), MemberName.of(name), birthDate, Email.of(email)); + } + + // getValue() 위임 메서드: getLoginIdValue(), getNameValue(), getEmailValue() +} +``` + +변경 포인트: +- `@Builder`, `@AllArgsConstructor` 삭제 → private 생성자 + `register()` 정적 팩토리 +- `@Embedded` VO 사용 +- `BaseTimeEntity` 상속 (id, createdAt, updatedAt 자동 관리) +- `isSamePassword()` → `password.matches()` 위임 +- `updatePassword()` → `password.validateChangeable()` + 새 Password 생성 + +**3-3. MemberPolicy 삭제** + +검증 로직이 VO로 분산되었으므로 `MemberPolicy.java` 및 `policy/` 패키지 삭제. + +**3-4. JPA 쿼리 메서드 변경** + +`@Embedded` 사용 시 Spring Data JPA 쿼리 메서드가 변경됨: + +```java +// Before +boolean existsByLoginId(String loginId); +Optional findByLoginId(String loginId); + +// After +boolean existsByLoginId_Value(String loginId); +Optional findByLoginId_Value(String loginId); +``` + +`MemberRepositoryImpl`에서 변환하므로 Port 인터페이스(`MemberRepository`)는 변경 없음. + +**3-5. MemberFixture 생성 (testFixtures)** + +`@Builder` 제거로 테스트에서 Member 생성이 `register()` 경유 필수. +`domain/src/testFixtures/`에 `MemberFixture` 생성: + +```java +public class MemberFixture { + public static Member create() { ... } + public static Member create(String loginId) { ... } + public static Member create(String loginId, String password) { ... } +} +``` + +**3-6. 테스트 수정** + +- `IllegalArgumentException` → `CoreException` 검증으로 변경 +- `Member.builder()` → `Member.register()` 또는 `MemberFixture.create()` 로 변경 +- DTO `@Builder` 제거 → `new` 생성자 사용 + +### 검증 결과 + +| 검증 항목 | 결과 | +|----------|------| +| `./gradlew clean build -x test` | **BUILD SUCCESSFUL** | +| `./gradlew :domain:test` | **PASS** | +| `./gradlew :application:commerce-service:test` | **PASS** | + +--- + +## Step 4: 마스킹 로직 Presentation 이동 + +### 배경 + +이름 마스킹은 표현(presentation) 관심사. Service가 이를 담당하면 Service 책임이 비대해지고, +다른 presentation(batch, streamer)에서 다른 형태의 마스킹이 필요할 때 대응 불가. + +### 작업 내용 + +**4-1. MemberService에서 maskName() 제거** + +Service는 raw name을 반환. 마스킹 없음. + +**4-2. GetMemberInfoResponse에 withMaskedName() 추가** + +```java +public record GetMemberInfoResponse(String loginId, String name, LocalDate birthdate, String email) { + public GetMemberInfoResponse withMaskedName() { + return new GetMemberInfoResponse(loginId, maskName(name), birthdate, email); + } + + private static String maskName(String name) { + if (name == null || name.isEmpty()) return ""; + if (name.length() == 1) return "*"; + return name.substring(0, name.length() - 1) + "*"; + } +} +``` + +**4-3. MemberController에서 마스킹 호출** + +```java +@GetMapping("/me") +public GetMemberInfoResponse getMyInfo(...) { + GetMemberInfoResponse response = memberService.getMyInfo(loginId, password); + return response.withMaskedName(); +} +``` + +### 검증 결과 + +| 검증 항목 | 결과 | +|----------|------| +| `./gradlew clean build -x test` | **BUILD SUCCESSFUL** | +| `./gradlew :domain:test :application:commerce-service:test` | **PASS** | + +--- + +## Step 5: DTO 네이밍 통일 + +### 배경 + +기존 DTO가 "대상 먼저" 패턴(`MemberRegisterRequest`)이었으나, +코드 스타일 컨벤션에서 "행동 먼저" 패턴으로 결정. + +### 작업 내용 + +| Before | After | +|--------|-------| +| `MemberRegisterRequest` | `RegisterMemberRequest` | +| `MyMemberInfoResponse` | `GetMemberInfoResponse` | +| `PasswordUpdateRequest` | `UpdatePasswordRequest` | + +변경 파일: +- DTO 파일 3개 (신규 생성 + 기존 삭제) +- `MemberService.java` (import + 참조) +- `MemberController.java` (import + 참조) +- `MemberServiceTest.java` (import + 참조) +- `MemberServiceIntegrationTest.java` (import + 참조) +- `MemberE2ETest.java` (import + 참조) + +### 검증 결과 + +| 검증 항목 | 결과 | +|----------|------| +| `./gradlew clean build -x test` | **BUILD SUCCESSFUL** | +| `./gradlew :domain:test :application:commerce-service:test` | **PASS** | + +--- + +## Step 보류: DomainService 분리 + +### 판단 + +현재 Member 도메인에는 DomainService가 필요한 복잡한 도메인 간 로직이 없음. +- 검증 → VO가 담당 +- 비밀번호 변경 → Member 엔티티 메서드 +- 유스케이스 조합 → ApplicationService(MemberService) + +**결론:** 신규 도메인(Brand, Product, Order) 추가 시 도메인 간 로직이 생기면 그때 도입. +빈 껍데기 서비스를 미리 만드는 것은 오버엔지니어링. + +--- + +## Phase 2 완료 상태 + +- [x] `./gradlew clean build -x test` 전체 통과 +- [x] `./gradlew :domain:test` — MemberTest, CoreExceptionTest 통과 +- [x] `./gradlew :application:commerce-service:test` — MemberServiceTest 통과 +- [ ] `./gradlew :presentation:commerce-api:test` — Docker 환경 필요 (코드 컴파일 통과) +- [x] ErrorType: pure enum (HttpStatus 없음), domain 레이어에 위치 +- [x] CoreException: domain 레이어에 위치 +- [x] BaseTimeEntity 신설, BaseEntity가 상속 +- [x] Member: VO 4개 사용, @Builder 제거, BaseTimeEntity 상속 +- [x] MemberPolicy 삭제 +- [x] IllegalArgumentException 전부 CoreException으로 대체 +- [x] 마스킹 로직: Presentation 레이어로 이동 +- [x] DTO: 행동 먼저(action-first) 네이밍 +- [ ] DomainService: 현재 불필요, 신규 도메인 추가 시 도입 diff --git a/docs/thought/phase3-implementation-log.md b/docs/thought/phase3-implementation-log.md new file mode 100644 index 000000000..d4f377c2d --- /dev/null +++ b/docs/thought/phase3-implementation-log.md @@ -0,0 +1,243 @@ +# Phase 3: 테스트 코드 수정 - 구현 실시간 로그 + +> 작성일: 2026-02-22 +> 상태: 완료 +> 관련 문서: [Phase 2 구현 로그](./phase2-implementation-log.md), [리팩토링 계획서](../planning/refactoring-plan.md) +> 핵심 원칙: "1 테스트 = 1 단언문" + +--- + +## Step 1: MemberTest (domain 단위 테스트) 정리 + +### 배경 + +Phase 2에서 모든 도메인 예외를 `CoreException`으로 통일했으므로, +테스트 코드의 `.isInstanceOf(CoreException.class)` 검증은 아키텍처가 이미 보장하는 부분. +불필요한 단언문을 제거하고, 빈 테스트 및 중복 테스트를 삭제하여 테스트 구조를 정리. + +### 작업 내용 + +**1-1. `.isInstanceOf(CoreException.class)` 전체 제거** + +```java +// Before +assertThatThrownBy(() -> Member.register(...)) + .isInstanceOf(CoreException.class) + .hasMessage("..."); + +// After +assertThatThrownBy(() -> Member.register(...)) + .hasMessage("..."); +``` + +helper 메서드(`throwIfWrongIdInput` 등)에서도 `.isInstanceOf()` 제거, `assertThatThrownBy`만 반환하도록 통일. + +**1-2. 빈 테스트 삭제** + +- `아이디는_중복_가입할_수_없음` — 본문이 비어 있고, 중복 체크는 Repository 의존이므로 Service 테스트 영역 + +**1-3. 중복 테스트 삭제** + +| 삭제 대상 | 이유 | +|----------|------| +| `RegistrationSuccess.successWhenAllFieldsValid` | `회원가입_성공` 테스트와 동일 | +| `비밀번호는_암호화해_저장` (2곳) | `SamePasswordValidation.isSamePassword_Success`가 이미 검증 | +| `UpdatePasswordPolicy.PasswordFormatValidation` 내부 전체 | 회원가입 섹션과 동일한 형식 검증 중복 | + +**1-4. 최종 테스트 구조** + +``` +MemberTest (20개 테스트) +├── 회원가입_성공 +├── @Nested LoginIdValidation (5개) +├── @Nested PasswordFormatValidation (5개) +├── @Nested NameValidation (4개) +├── @Nested EmailValidation (2개) +├── @Nested BirthDateValidation (1개) +├── @Nested SamePasswordValidation (2개) +└── @Nested UpdatePasswordPolicy (2개: 동일비밀번호, 생년월일포함) +``` + +### 검증 결과 + +| 검증 항목 | 결과 | +|----------|------| +| `./gradlew :domain:test` | **PASS** | + +--- + +## Step 2: MemberServiceTest (mock 단위 테스트) 정리 + +### 배경 + +Service 테스트의 역할은 "유스케이스 조합이 올바르게 이루어지는가"를 검증하는 것. +Member 필드 값 검증은 MemberTest(domain 단위 테스트)가 담당하므로, Service 테스트에서 `ArgumentCaptor`로 필드를 꺼내 검증하는 것은 책임 영역 초과. + +### 작업 내용 + +**2-1. `회원가입_성공` 단순화** + +```java +// Before +ArgumentCaptor captor = ArgumentCaptor.forClass(Member.class); +verify(memberRepository).save(captor.capture()); +assertThat(captor.getValue().getLoginIdValue()).isEqualTo("testuser1"); + +// After +verify(memberRepository).save(any(Member.class)); +``` + +Service는 "save가 호출되었는가" (조합 검증)만 확인. + +**2-2. `내_정보_조회_성공` → 2개 테스트 분리** + +1 테스트 = 1 단언문 원칙 적용: + +- `내_정보_조회_성공_loginId_반환` +- `내_정보_조회_성공_name_반환` (`MemberFixture.DEFAULT_NAME` 참조) + +**2-3. `.isInstanceOf(CoreException.class)` 전체 제거** + +Step 1과 동일한 이유로 제거. + +### 검증 결과 + +| 검증 항목 | 결과 | +|----------|------| +| `./gradlew :application:commerce-service:test` | **PASS** | + +--- + +## Step 2.5: Squash 잔류 파일 정리 + +### 배경 + +Phase 2 squash 과정에서 삭제 대상이었던 파일 6개가 잔류하여 컴파일 에러 발생. + +### 작업 내용 + +삭제된 파일: + +| 파일 | 잔류 이유 | +|------|----------| +| `application/.../support/error/ErrorType.java` | 구 버전 (HttpStatus 포함), domain으로 이동됨 | +| `application/.../support/error/CoreException.java` | domain으로 이동됨 | +| `application/.../support/error/CoreExceptionTest.java` | domain으로 이동됨 | +| `application/.../dto/MemberRegisterRequest.java` | `RegisterMemberRequest`로 교체됨 | +| `application/.../dto/MyMemberInfoResponse.java` | `GetMemberInfoResponse`로 교체됨 | +| `application/.../dto/PasswordUpdateRequest.java` | `UpdatePasswordRequest`로 교체됨 | + +### 검증 결과 + +| 검증 항목 | 결과 | +|----------|------| +| `./gradlew clean build -x test` | **BUILD SUCCESSFUL** | + +--- + +## Step 3: MemberServiceIntegrationTest (통합 테스트) 정리 + +### 배경 + +통합 테스트에서도 1 테스트 = 1 단언문 원칙을 적용. +또한 Phase 2에서 마스킹 로직을 Controller로 이동했으므로, Service 직접 호출 시 원본 이름이 반환되어야 하는데 기대값이 마스킹된 값으로 남아있는 버그 발견. + +### 작업 내용 + +**3-1. `getMyInfo_Success` → 3개 테스트 분리** + +- `getMyInfo_성공_loginId_반환` +- `getMyInfo_성공_이름_반환` +- `getMyInfo_성공_이메일_반환` + +**3-2. 마스킹 기대값 버그 수정** + +```java +// Before (버그) +assertThat(response.name()).isEqualTo("공명*"); + +// After (수정) +assertThat(response.name()).isEqualTo("공명선"); +``` + +Phase 2에서 마스킹을 Controller(`GetMemberInfoResponse.withMaskedName()`)로 이동했으므로, +Service를 직접 호출하는 통합 테스트에서는 원본 이름이 반환되어야 함. + +**3-3. `.isInstanceOf(CoreException.class)` 전체 제거** + +Step 1과 동일한 이유로 제거. + +### 검증 결과 + +| 검증 항목 | 결과 | +|----------|------| +| `./gradlew clean build -x test` | **BUILD SUCCESSFUL** | + +--- + +## Step 4: MemberE2ETest (E2E 테스트) 시나리오 분리 + +### 배경 + +기존 E2E 테스트는 단일 메서드(`member_full_lifecycle_scenario`)에 전체 생명주기를 넣어두어, +어느 단계에서 실패했는지 파악하기 어렵고, 1 테스트 = 1 단언문 원칙에도 위배. + +### 작업 내용 + +**4-1. 단일 시나리오 → 5개 독립 테스트 분리** + +| # | 테스트명 | 검증 내용 | +|---|---------|----------| +| 1 | `회원가입_성공` | `status().isCreated()` | +| 2 | `내_정보_조회_마스킹된_이름_반환` | `jsonPath("$.name").value("공명*")` | +| 3 | `비밀번호_변경_성공` | `status().isNoContent()` | +| 4 | `변경된_비밀번호로_조회_성공` | `status().isOk()` | +| 5 | `기존_비밀번호로_조회_실패` | `status().isUnauthorized()` | + +**4-2. 공통 로직 헬퍼 메서드 추출** + +```java +private void registerMember() { ... } +private void changePassword() { ... } +private ResultActions createRegisterRequest() { ... } +``` + +**4-3. 상수 추출** + +```java +private static final String LOGIN_ID = "..."; +private static final String INITIAL_PW = "..."; +private static final String NEW_PW = "..."; +``` + +### 검증 결과 + +| 검증 항목 | 결과 | +|----------|------| +| `./gradlew clean build -x test` | **BUILD SUCCESSFUL** | +| `./gradlew :domain:test :application:commerce-service:test` | **PASS** | + +--- + +## Phase 3 완료 상태 + +### 테스트 수 변화 + +| 테스트 클래스 | Before | After | 변화 | +|--------------|--------|-------|------| +| MemberTest | 21개 | 20개 | -1 (빈/중복 삭제) | +| MemberServiceTest | 5개 | 6개 | +1 (단언문 분리) | +| MemberServiceIntegrationTest | 9개 | 11개 | +2 (단언문 분리) | +| MemberE2ETest | 1개 | 5개 | +4 (시나리오 분리) | + +### 체크리스트 + +- [x] MemberTest: `.isInstanceOf` 제거, 빈/중복 테스트 삭제, 구조 정리 (21 -> 20개) +- [x] MemberServiceTest: mock capture 제거, 단언문 분리 (5 -> 6개) +- [x] MemberServiceIntegrationTest: 단언문 분리, 마스킹 버그 수정 (9 -> 11개) +- [x] MemberE2ETest: 시나리오 분리 (1 -> 5개) +- [x] Squash 잔류 파일 6개 정리 +- [x] CLAUDE.md: 테스트 단위 원칙 추가 +- [x] `./gradlew clean build -x test` — BUILD SUCCESSFUL +- [x] `./gradlew :domain:test :application:commerce-service:test` — PASS +- [ ] `./gradlew :presentation:commerce-api:test` — Docker 환경 필요 diff --git a/docs/thought/volume3-discussion-log.md b/docs/thought/volume3-discussion-log.md new file mode 100644 index 000000000..2e9013bfe --- /dev/null +++ b/docs/thought/volume3-discussion-log.md @@ -0,0 +1,691 @@ +# Volume 3 전체 논의 기록 + +> 작성일: 2026-02-23 +> 참여: 개발자, AI (Claude Code) +> 범위: Volume 3 프로젝트 전 세션의 미문서화 논의 종합 +> 기존 기록: [architecture-discussion-log.md](./architecture-discussion-log.md), [phase2-discussion-log.md](./phase2-discussion-log.md) + +--- + +## 목차 + +1. [초기 프로젝트 분석 및 방향 설정](#1-초기-프로젝트-분석-및-방향-설정) (2026-02-03) +2. [테스트 설계 철학](#2-테스트-설계-철학) (2026-02-03) +3. [도메인 정의 작업](#3-도메인-정의-작업) (2026-02-10) +4. [Brand 도메인 분석 — Soft Delete vs Hard Delete](#4-brand-도메인-분석--soft-delete-vs-hard-delete) (2026-02-12) +5. [Brand & Product BC 설계](#5-brand--product-bc-설계) (2026-02-22) +6. [Facade에서 CatalogDomainService로의 전환](#6-facade에서-catalogdomainservice로의-전환) (2026-02-22) +7. [아키텍처 설계서 작성 논의](#7-아키텍처-설계서-작성-논의) (2026-02-22~23) +8. [핵심 설계 결정 요약](#8-핵심-설계-결정-요약) +9. [Member DIP 리팩토링](#9-member-dip-리팩토링) (2026-02-24) +10. [DTO 네이밍 체계 확정](#10-dto-네이밍-체계-확정) (2026-02-24) + +--- + +## 1. 초기 프로젝트 분석 및 방향 설정 + +> 세션일: 2026-02-03 + +### 1-1. 프로젝트 첫 분석 + +기존 템플릿 프로젝트의 구조를 분석했을 때, 개발자는 다음 평가를 내림: + +> "좋은 구조인 거 같아. OOP, DDD, Clean Architecture 고려가 잘 되어 있다." + +다만 실제 구현은 Member CRUD만 존재하며, 문서(요구사항, 클래스 다이어그램, ERD)는 정의되어 있으나 코드와 문서 사이에 간극이 있었음. + +### 1-2. AI 역할 경계 설정 + +개발자가 초기에 중요한 작업 방식을 확립: + +**테스트는 직접 작성한다:** +> "테스트는 내가 직접 쓸 거야. AI에게는 테스트 설계 의도 설명을 요청한다." + +**AI는 방향성 제안만:** +- 주요 의사결정의 최종 승인은 개발자가 수행 +- AI는 선택지와 trade-off를 분석하여 제안 +- 코드 작성 시 개발자의 의도를 확인한 후 진행 + +이 원칙은 이후 CLAUDE.md의 "증강 코딩" 워크플로우로 정형화됨. + +### 1-3. 리팩토링 우선 순서 결정 + +구현보다 리팩토링을 먼저 진행하기로 결정: + +``` +1단계: 리팩토링 (기존 기능) +2단계: 모델링 및 설계 변경 +3단계: 테스트 코드 수정 +4단계: 신규 기능 구현 (Brand, Product, Like, Order) +``` + +--- + +## 2. 테스트 설계 철학 + +> 세션일: 2026-02-03 + +### 2-1. 단언문(Assertion) 원칙 + +**개발자 질문:** "단언문의 특성을 정의해 줘." + +논의 끝에 확립된 원칙: + +> **하나의 테스트 메서드에는 단언문이 1개만 존재해야 한다.** + +**이유:** +- 단언문이 여러 개이면 첫 번째 실패 시 나머지는 실행되지 않아 실패 정보가 손실 +- 테스트 이름이 "무엇을 검증하는가"를 정확히 표현할 수 없게 됨 + +**적용:** 단언문이 2개 이상 필요한 테스트는 별도의 테스트 메서드로 분리. + +### 2-2. VO 테스트 전략 + +**개발자 질문:** "VO를 별도로 테스트할까, Entity와 통합해서 테스트할까?" + +**결정:** Entity 통합 테스트 + +**근거:** +- VO는 Entity의 내부 구성 요소 +- Entity의 행위를 통해 VO의 규칙이 자연스럽게 검증됨 +- 예: `Member.register()`를 테스트하면 LoginId, Password 등 VO의 검증도 함께 검증 + +### 2-3. Builder 패턴 사용 거부 + +**개발자 판단:** + +> "빌더 쓰니까 나중 가서 오류가 많이 생기더라고. 컴파일 오류를 감지하기 어려운." + +Builder 패턴의 문제: +- 필수 파라미터 누락을 컴파일 타임에 잡지 못함 +- 리팩토링 시 새 필드를 추가해도 기존 Builder 호출이 컴파일됨 → 런타임 버그 + +**결정:** 정적 팩토리 메서드 사용. 필드가 추가되면 컴파일 에러 발생 → 안전. + +### 2-4. testFixtures 도입 + +**개발자 질문:** "test/와 testFixtures/의 차이가 뭐야?" + +| 디렉토리 | 역할 | 가시성 | +|----------|------|--------| +| `test/` | 일반 테스트 코드 | 해당 모듈 내부만 | +| `testFixtures/` | 재사용 가능한 테스트 객체 (Fixture) | 의존하는 다른 모듈에서도 사용 가능 | + +**적용:** +- `MemberFixture`: `domain/src/testFixtures/`에 배치 +- application 모듈 테스트에서 `testFixtures(project(":domain"))` 의존으로 사용 + +--- + +## 3. 도메인 정의 작업 + +> 세션일: 2026-02-10 + +### 3-1. 도메인 정의의 목적 + +코드 작성 전, 요구사항에 등장하는 도메인 단어의 근본적 의미를 파악하는 작업을 수행. + +**개발자의 관점:** +> "도메인 정의는 근본적으로 변하지 않는 단어의 뜻을 파악하고, 이를 바탕으로 요구사항과 기능의 방향성을 분석하는 데 사용한다." + +### 3-2. 도메인 정의 결과 + +주요 도메인: + +- **회원(Member)**: 사용자와 서비스 사이의 신뢰 계약. 회원인 사용자에게 편의 기능 제공. +- **상품(Product)**: 판매하는 재화. 브랜드에 소속되며, 이름/가격/재고를 가짐. +- **브랜드(Brand)**: 상품을 묶는 단위. 관리자가 생성/관리. +- **좋아요(Like)**: 회원의 선호 표현. 상품 외 다른 대상으로 확장 가능. +- **주문(Order)**: 회원이 상품을 구매하는 행위. 주문 시점의 상품 정보를 스냅샷으로 보존. + +### 3-3. 바운디드 컨텍스트 초안 + +``` +BC: Member → 회원 가입, 인증, 정보 조회 +BC: Catalog → 브랜드/상품 관리 및 조회 +BC: Like → 선호(좋아요) 관계 기록 +BC: Order → 주문 생성/조회, 스냅샷 보존 +``` + +이 구조는 이후 `05-domain-model.md`로 정형화됨. + +--- + +## 4. Brand 도메인 분석 — Soft Delete vs Hard Delete + +> 세션일: 2026-02-12 + +### 4-1. 삭제 전략 논의 + +Brand 도메인에서 삭제를 어떻게 처리할 것인가에 대한 심층 논의. + +**선택지:** + +| 방식 | 설명 | 장점 | 단점 | +|------|------|------|------| +| Hard Delete | 레코드 물리 삭제 | 간단, 데이터 깔끔 | 복구 불가, 참조 무결성 문제 | +| Soft Delete | `deletedAt` 컬럼으로 논리 삭제 | 복구 가능, 이력 보존 | 쿼리 시 필터 필요 | +| Hybrid | 상태값(closedAt)으로 비활성화 | 도메인 의미 반영 | 복잡도 증가 | + +### 4-2. 입점/폐점 개념 + +개발자가 Brand의 삭제를 "폐점"이라는 도메인 언어로 표현: + +> "입점 폐점 느낌으로, Brand를 폐점하면 기록은 남기되 비활성화" + +### 4-3. 최종 결정 + +Soft Delete 채택 (`BaseEntity.deletedAt` 활용): + +- Brand 삭제 시 `deletedAt` 설정 + `name` 변경 (UNIQUE 해소) +- 삭제된 Brand는 User API에 노출되지 않음 +- 이미 삭제된 Brand 재삭제 시 예외 (`guardNotDeleted`) + +이 결정은 `brand-plan.md` 2-2절에 반영됨. + +--- + +## 5. Brand & Product BC 설계 + +> 세션일: 2026-02-22 (Phase 3 완료 후) + +### 5-1. Brand와 Product를 같은 BC로 결정 + +**개발자 질문:** "Brand와 Product를 하나의 BC로 할까, 분리할까?" + +**결정: 같은 BC (Catalog)** + +**근거:** +- Brand와 Product는 도메인적으로 밀접하게 연관 +- Brand 삭제 시 소속 Product 연쇄 삭제가 필요 → 같은 BC에서 처리하는 것이 자연스러움 +- 단, 독립 Aggregate로 분리 (Brand Aggregate, Product Aggregate) + +**독립 Aggregate인 이유:** +- 독립적 생명주기: Product 없이 Brand만 존재 가능 +- 규모 차이: 하나의 Brand에 수천 개 Product 가능 → 같은 Aggregate에 넣으면 메모리/성능 문제 +- 독립 변경: Product 가격/재고 수정 시 Brand를 잠글 필요 없음 + +### 5-2. Like를 독립 BC로 분리한 이유 + +초기에는 ProductLike로 Product BC에 포함하려 했으나, 독립 BC로 결정. + +**개발자 판단:** + +> "Like는 추천 시스템에서도 사용할 수 있고, 상품 외 다른 대상(브랜드, 셀러)으로 확장 가능하니까 분리하자." + +**분리 근거:** +- `subjectType` 확장 가능성 (`PRODUCT`, `BRAND`, `SELLER` 등) +- 추천/랭킹 파이프라인과의 독립 배포 경계 필요 가능성 +- 다른 팀 책임 가능성 + +**모델:** `Like(memberId, subjectType, subjectId)` — 범용적 좋아요 모델 + +### 5-3. BC 간 참조 규칙 + +**결정:** 모든 BC 간 참조는 객체 참조가 아닌 **ID(Long) 참조만** 사용. + +| 관계 | 참조 방식 | +|------|----------| +| Product → Brand | `product.brandId` (Long) | +| Like → Product | `like.subjectId` (Long) | +| Order → Product | `orderLineSnapshot.productId` (Long) | +| Order → Member | `order.memberId` (Long) | + +**FK 제약조건 없음:** +- 같은 BC 내부(product.brand_id → brand.id)에도 FK 없음 +- 삭제 연쇄를 DB CASCADE가 아닌 `CatalogDomainService`로 명시적 제어 +- 규칙이 코드에 표현되어 추적 가능 + +--- + +## 6. Facade에서 CatalogDomainService로의 전환 + +> 세션일: 2026-02-22 + +### 6-1. 문제 발견 + +초기 설계에서 Brand 삭제 시 Product 연쇄 삭제를 `AdminBrandFacade` (Application 레이어)로 처리하려 했음. + +**기존 설계:** +``` +AdminBrandController → AdminBrandFacade → AdminBrandService + AdminProductService +``` + +### 6-2. 왜 Facade가 부적합한가 + +**개발자와의 논의에서 도출된 결론:** + +| 구분 | Facade | Domain Service | +|------|--------|----------------| +| 위치 | Application 레이어 | Domain 레이어 | +| 역할 | Application Service 간 순환 참조 해소 | 같은 BC 내 cross-aggregate 도메인 규칙 | +| 해당 상황 | Brand↔Product 순환 참조? → 아님 | Brand↔Product 같은 BC의 삭제 연쇄 규칙? → 맞음 | + +Brand 삭제 → Product 연쇄 삭제는: +- **같은 BC**(Catalog)의 규칙 +- **도메인 규칙** ("브랜드가 폐점하면 소속 상품도 비활성화") +- 기술적 조율이 아닌 **비즈니스 규칙** + +따라서 Facade가 아닌 **CatalogDomainService**가 적합. + +### 6-3. 전환 결과 + +**변경 후:** +``` +AdminBrandController → AdminBrandService → CatalogDomainService + ├── BrandRepository + └── ProductRepository +``` + +**CatalogDomainService의 책임:** +- Brand 삭제 시 소속 Product 연쇄 soft-delete +- Product 생성 시 Brand 활성 여부 검증 + +**반영 문서:** +- `02-sequence-diagrams.md` 5-3절: AdminProductFacade → CatalogDomainService +- `03-class-diagram.md` 4절: Facade 테이블 → Domain Service 테이블 +- `brand-plan.md` 2-5절: CatalogDomainService 설계 +- `05-domain-model.md` 4-3절: Domain Service vs Facade 구분 + +--- + +## 7. 아키텍처 설계서 작성 논의 + +> 세션일: 2026-02-22~23 + +### 7-1. 아키텍처 설계서의 목적 + +**개발자 요청:** + +> "리뷰를 받기 위해 아키텍처 설계도를 그려줘. 내 리뷰가 아니라 시니어 분의 리뷰." +> +> "의도를 좀 담았으면 좋겠어. 내가 왜 이런 구조들을 선택하고 이런 클래스들을 만들었는지." + +**목표:** 시니어가 이 문서 하나로 전체 아키텍처의 의도를 파악할 수 있어야 한다. + +결과물: `docs/design/06-architecture.md` + +### 7-2. Presentation → Application & modules/jpa 의존 이유 + +**개발자 질문:** "아키텍처에서 presentation → application, modules/jpa 둘 다 의존하는 이유가 뭐야?" + +**답변 요약 — Composition Root 패턴:** + +``` +presentation/commerce-api/ +├── CommerceApiApplication.java ← @SpringBootApplication +├── 여기서 모든 Bean이 조립됨 +│ ├── AdminBrandService (application) +│ ├── BrandRepositoryImpl (modules/jpa) +│ └── BrandJpaRepository (modules/jpa) +``` + +- Presentation은 Spring Boot 애플리케이션의 진입점 +- 모든 Bean을 조립하는 **Composition Root** 역할 +- application의 Service Bean과 modules/jpa의 Repository Bean을 모두 알아야 조립 가능 + +### 7-3. Catalog BC 기반 패키징 + +**개발자 질문:** "catalog랑 brand랑 product를 3개로 나눈 이유는 뭐야? catalog 안에 brand랑 product가 들어가야 맞지 않나?" + +**결정:** Domain 레이어에만 BC 기반 패키징 적용 + +``` +domain/src/main/java/com/loopers/domain/ +├── catalog/ +│ ├── brand/ +│ │ ├── Brand.java +│ │ ├── BrandRepository.java +│ │ └── BrandExceptionMessage.java +│ ├── product/ +│ │ ├── Product.java +│ │ ├── ProductRepository.java +│ │ └── ProductExceptionMessage.java +│ └── CatalogDomainService.java +├── member/ +├── like/ +└── order/ +``` + +**상위 레이어는 별도 고민 필요:** + +> "도메인 레이어만 일단 적용해줘. 알다시피 위에서는 여러 응용이 나오게 되고, 그러면 당연히 어디에 두는가를 또 고민해야 하잖아?" + +Application, Presentation, Infrastructure 레이어의 패키징은 추후 결정. + +### 7-4. 다이어그램 간결화 + +**개발자 요청:** + +> "정말 간단한 다이어그램 하나만 그려줘. 클래스도 필요없고... 가볍게만 그려줘." + +초기 버전은 Gradle 모듈까지 포함한 상세 다이어그램이었으나, 개발자의 피드백에 따라 **개념만 표현하는 최소 다이어그램**으로 축소: + +``` +Presentation: Controller, API DTO + ↓ +Application: Service, Facade, Command / Query DTO + ↓ +Domain: BC, Entity, VO, Repository (interface), Domain Service, ErrorType + ↑ +Infrastructure: Repository 구현체, JPA Config +``` + +### 7-5. DTO 네이밍 변경: Info → Query + +**개발자 결정:** + +> "Info는 Query로 바꿀게." + +| Before | After | 예시 | +|--------|-------|------| +| `BrandInfo` | `BrandQuery` | `BrandQuery.from(Brand brand)` | +| `MemberInfo` | `MemberQuery` | `MemberQuery.from(Member member)` | + +**네이밍 컨벤션:** +- 요청 DTO: **Command** (`CreateBrandCommand`, `UpdateBrandCommand`) +- 응답 DTO: **Query** (`BrandQuery`) + +### 7-6. Port-Adapter → Repository 추상체/구현체 + +**개발자 교정:** + +> "Port-Adapter 패턴도 결국 헥사고날에서 쓰는 거고 나는 레이어드 아키텍처니까 그냥 Repository 추상체와 구현체로 해줘." + +| Before (Hexagonal 용어) | After (Layered 용어) | +|------------------------|---------------------| +| Port | Repository (interface) | +| Adapter | Repository 구현체 (RepositoryImpl) | +| Port-Adapter 패턴 | Repository 추상체/구현체 | + +**핵심:** 이 프로젝트는 **레이어드 아키텍처**를 사용한다. 헥사고날 용어를 혼용하지 않는다. + +### 7-7. Domain Service의 Bean 등록 문제 + +**개발자 질문:** "Domain에 @Service 어노테이션이 못 붙으면 Domain Service는 어떻게 구현해?" + +Domain 레이어 규칙: +- `@Service` (spring-context) → Domain에서 사용 가능하나, `@Service`는 Application Service 전용 +- `@Component` → 허용 + +**선택지:** + +| 방식 | 설명 | 장점 | 단점 | +|------|------|------|------| +| A. @Bean 수동 등록 | Application에서 `@Configuration` + `@Bean`으로 생성 | Domain에 Spring 의존 없음 | 등록 보일러플레이트 | +| **B. spring-context 추가** | Domain에 `@Component` 사용 | 간결, JPA 허용과 일관성 | spring-context 의존 추가 | + +**개발자 결정:** B (spring-context 추가) + +> "B가 나을 거 같긴 한데" + +**근거:** JPA 어노테이션 허용과 같은 실용주의 기준. `spring-context`는 DI 프레임워크 인터페이스 수준이므로 허용. + +**Domain 레이어 어노테이션 규칙:** + +| 어노테이션 | 허용 여부 | 이유 | +|-----------|----------|------| +| `@Entity`, `@Embeddable` | 허용 | JPA 표준 스펙 (인터페이스 수준) | +| `@Component` | 허용 | DI 마커 (Domain Service용) | +| `@Service` | 금지 | Application Service 전용으로 의미 구분 | +| `@Transactional` | 금지 | 트랜잭션 경계는 Application 책임 | +| `@RestController` | 금지 | HTTP는 Presentation 책임 | + +### 7-8. VO 불변성과 값 변경 + +**개발자 질문:** "VO는 구현할 때 예를 들어, +1 되는 값이 있으면, 불변해야 하니까 안에서 ++ 로 구현을 안 하고 객체로 +1된 값을 반환하게 되나?" + +**답변:** 맞다. VO는 **새 객체를 반환**한다. + +```java +// Stock VO — 불변 패턴 +public Stock decrease(Quantity quantity) { + if (!isEnough(quantity)) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고 부족"); + } + return new Stock(this.value - quantity.getValue()); // 새 객체 반환 +} + +// Entity에서 VO 교체 +public void decreaseStock(Quantity quantity) { + this.stock = this.stock.decrease(quantity); // 참조 교체 +} +``` + +**핵심:** VO 내부 상태를 변경하지 않고, 새 VO 인스턴스를 생성하여 반환. Entity가 VO 참조를 교체. + +### 7-9. Aggregate 트랜잭션 경계 + +**개발자 질문:** "애그리거트는 하나의 트랜잭션 경계를 가져야 하잖아. 그러면 여러 애그리거트나 BC들이 합쳐진 경우에는? 그때도 트랜잭션을 각자 나눠서 가져?" + +**3가지 경우 정리:** + +| 상황 | 트랜잭션 전략 | 예시 | +|------|-------------|------| +| 같은 BC, cross-aggregate | **같은 트랜잭션** | Brand 삭제 → Product 연쇄 (CatalogDomainService) | +| 다른 BC (모놀리스) | **같은 트랜잭션** (실용적) | 주문 생성 → 재고 차감 (OrderService에서 조율) | +| 다른 BC (규모 확장 시) | 이벤트 기반 (eventual consistency) | 현재 해당 없음 | + +**모놀리스에서의 실용적 판단:** +- 이론적으로는 BC마다 트랜잭션 분리가 이상적 +- 하지만 모놀리스에서 같은 DB를 사용하면 같은 트랜잭션이 실용적 +- Application Service가 `@Transactional`을 소유하고 여러 Domain Service/Repository를 조율 + +### 7-10. MSA 언급 제거 + +**개발자 교정:** + +> "근데 내가 MSA로 옮긴다는 얘기는 안 했는데. 그 부분도 일단 없애줄래?" + +아키텍처 설계서에서 "MSA 전환 대비"라는 문구가 FK 없음 정책의 이유로 포함되어 있었음. 개발자는 MSA 전환을 언급한 적 없으므로 제거. + +**수정:** +- 무FK 정책 사유: ~~"MSA 전환 대비 + 도메인 규칙 명시적 제어"~~ → "도메인 규칙 명시적 제어" +- ADR 테이블: 동일하게 MSA 문구 제거 + +**교훈:** AI가 일반적으로 정당한 이유라도, 개발자가 언급하지 않은 의도를 가정하여 문서에 포함하지 않는다. + +--- + +## 8. 핵심 설계 결정 요약 + +### 8-1. 실용주의 일관성 기준 + +이 프로젝트의 모든 설계 결정은 **동일한 실용주의 기준**으로 판단됨: + +> "분리의 이득이 비용보다 큰가?" + +| 대상 | 결정 | 근거 | +|------|------|------| +| JPA `@Entity` in Domain | 허용 | 표준 스펙. 매핑 레이어 추가 비용 > 이득 | +| `spring-context` in Domain | 허용 | DI 마커. @Bean 등록 보일러플레이트 > 이득 | +| `HttpStatus` in Domain | 불허 | Spring 고유. 분리 비용 낮음 (switch 하나) | +| `@Service` in Domain | 불허 | Application과 의미 구분 필요 | + +### 8-2. 용어 결정 이력 + +| 시점 | Before | After | 이유 | +|------|--------|-------|------| +| Phase 2 | `MemberPolicy` 분리 | Entity/VO 내재화 | 응집도 향상, 코드 위치 근접성 | +| Phase 2 | `DomainException` hierarchy | `ErrorType` pure enum | 간결, 해석 자유도 | +| 설계 단계 | `AdminBrandFacade` | `CatalogDomainService` | 같은 BC = Domain Service | +| 설계 단계 | `ProductLike` (Catalog BC) | `Like` (독립 BC) | 확장성, 책임 분리 | +| 문서 단계 | `BrandInfo` (DTO) | `BrandQuery` (DTO) | 의미론적 명확성 | +| 문서 단계 | Port / Adapter | Repository 추상체 / 구현체 | 레이어드 아키텍처 용어 통일 | + +### 8-3. 아직 미결정 사항 + +| 항목 | 상태 | 결정 시점 | +|------|------|----------| +| Application/Presentation/Infrastructure 레이어의 BC 기반 패키징 | 보류 | Brand/Product 구현 시 | +| sealed class 도입 (ErrorType 확장) | 보류 | 에러 타입별 다른 데이터 필요 시 | +| Event Sourcing / Kafka 비동기 처리 | 보류 | 규모 확장 시 | +| Cache 전략 | 보류 | Brand/Product 구현 시 | +| Preference BC 전환 (Like → Preference) | 보류 | subjectType 2종 이상 + 타입별 정책 분기 시 | + +--- + +## 9. Member DIP 리팩토링 + +> 세션일: 2026-02-24 + +### 9-1. PasswordEncryptor DIP + +**핵심 질문:** "비밀번호 암호화는 정말 기능적 요구사항일까?" + +**분석 결과:** +- 비밀번호 **검증**(형식, 길이, 생년월일 포함 여부) = **기능적 요구사항** → Domain +- 비밀번호 **암호화**(SHA-256, BCrypt) = **비기능적 요구사항**(보안/인프라) → Infrastructure + +**그런데 왜 Domain에 PasswordEncryptor 인터페이스가 필요한가?** + +> "새 비밀번호가 현재 비밀번호와 동일하면 안 된다" + +이 규칙은 **비즈니스 규칙**이다. 그런데 이 규칙을 검증하려면 암호화된 현재 비밀번호와 raw 입력을 비교해야 한다. 즉, 비즈니스 규칙이 암호화 비교를 **요구**한다. + +**결론:** PasswordEncryptor 인터페이스를 Domain에 두는 것은 DIP로 정당화된다. + +``` +Domain Layer: PasswordEncryptor (interface) ← Password VO가 사용 +Infrastructure: BCryptPasswordEncryptor (구현체) +Application: 조정자 — PasswordEncryptor를 보유하고 Domain에 전달 +``` + +### 9-2. MemberPasswordService 생성과 삭제 + +**시도:** PasswordEncryptor를 Domain Service가 보유하게 하면 Application이 깔끔해지지 않을까? + +**검증 — DomainService 3가지 도입 기준:** + +| 기준 | 해당 여부 | 이유 | +|------|----------|------| +| ① 단일 엔티티에 속하지 않는 도메인 규칙 | 해당 없음 | 비밀번호 규칙은 Password VO 하나에 속함 | +| ② 여러 엔티티 간 불변식 검증 | 해당 없음 | Member 하나만 관여 | +| ③ Application에 도메인 로직 누출 | 해당 없음 | Application은 `member.updatePassword()`를 호출할 뿐, 판단하지 않음 | + +**결론:** 3가지 모두 불충족 → MemberPasswordService 삭제. Application이 PasswordEncryptor를 조정자로서 보유하는 것으로 충분. + +### 9-3. changeTo 패턴 + +비밀번호 변경 시 "검증 → 생성"을 하나의 메서드로 통합. + +```java +// Password VO +public Password changeTo(String newRawPassword, LocalDate birthDate, PasswordEncryptor encryptor) { + validateChangeable(newRawPassword, birthDate, encryptor); // 동일 비밀번호, 형식, 생년월일 + return Password.of(newRawPassword, birthDate, encryptor); +} + +// Member Entity +public void updatePassword(String newRawPassword, PasswordEncryptor encryptor) { + this.password = password.changeTo(newRawPassword, birthDate, encryptor); +} +``` + +**이점:** 변경 규칙이 Password VO에 완전히 캡슐화. Member는 `changeTo`만 호출. + +### 9-4. VO 직접 노출 + +**기존:** `member.getLoginIdValue()` (convenience getter, String 반환) +**변경:** `member.getLoginId()` (VO 직접 반환) + +**이유:** +- VO가 값을 캡슐화하고 있으므로 한 번 더 래핑할 이유 없음 +- Application DTO(`MemberInfo`)도 VO를 직접 보유 +- String 변환은 Presentation이 `info.loginId().getValue()`로 수행 + +### 9-5. Presentation DTO 분리 + +마스킹은 표현 관심사이므로 Presentation 레이어로 이동. + +``` +Application: MemberInfo(LoginId, MemberName, LocalDate, Email) ← VO 보유 +Presentation: MemberApiResponse.from(MemberInfo) ← String 변환 + 마스킹 +``` + +### 9-6. MemberId VO + +**선택지:** + +| 방식 | 설명 | +|------|------| +| A. Domain 래퍼 | `MemberId(Long value)` — JPA 구조 변경 없음 | +| B. JPA 통합 | `@EmbeddedId` 사용 — BaseTimeEntity 변경 필요 | + +**결정:** A (Domain 래퍼). JPA의 `Long id`는 그대로 두고, `MemberId.of(getId())`로 감싸서 타입 안전성 확보. + +**Member equals/hashCode:** +- `equals`: id 기반 (id가 null이면 같은 참조만 동등) +- `hashCode`: `getClass().hashCode()` — Hibernate 권장 패턴 (영속화 전후 안정) + +### 9-7. 테스트 컨벤션 확립 + +| 규칙 | 설명 | +|------|------| +| `@DisplayName` 금지 | 한글 메서드명이 테스트 의도를 충분히 표현 | +| 한글 메서드명 | `회원가입_성공()`, `비밀번호_수정_실패_현재_비밀번호와_동일()` | +| 3A 주석만 유지 | `// given`, `// when`, `// then` (또는 `// when & then`) | +| 메서드 내 일반 주석 금지 | 코드가 의도를 표현해야 함 | + +--- + +## 10. DTO 네이밍 체계 확정 + +> 세션일: 2026-02-24 + +### 10-1. Query 네이밍 검토 + +7-5절에서 "Info → Query"로 변경을 결정했으나, 실제 적용 과정에서 문제 발견: + +- `BrandQuery`는 "브랜드를 조회하는 쿼리 객체"(검색 조건)로 읽힐 수 있음 +- 실제 의미는 "조회 결과 응답"인데, 이름이 의도를 정확히 전달하지 못함 +- `QueryDto` 접미도 검토했으나 `dto/` 패키지에 이미 위치하므로 중복 + +**결론:** Query 네이밍 기각. Info로 회귀. + +### 10-2. 확정된 전 레이어 DTO 네이밍 체계 + +**Application Layer:** + +| 방향 | 패턴 | 예시 | +|------|------|------| +| Inbound (상태 변경) | `{Action}{Domain}Command` | `RegisterMemberCommand`, `CreateBrandCommand` | +| Outbound (조회 결과) | `{Domain}Info` | `MemberInfo`, `BrandInfo` | + +**Presentation Layer:** + +| 방향 | 패턴 | 예시 | +|------|------|------| +| Inbound (Request Body) | `{Action}{Domain}ApiRequest` | `RegisterMemberApiRequest`, `CreateBrandApiRequest` | +| Outbound (Response Body) | `{Domain}ApiResponse` | `MemberApiResponse`, `BrandApiResponse` | + +**변환 흐름:** +``` +HTTP Request → {Action}{Domain}ApiRequest.toCommand() → {Action}{Domain}Command +{Domain}Info → {Domain}ApiResponse.from({Domain}Info) → HTTP Response +``` + +**적용 결과:** + +| Before | After | +|--------|-------| +| `RegisterMemberRequest` | `RegisterMemberCommand` | +| `UpdatePasswordRequest` | `UpdatePasswordCommand` | +| `GetMemberInfoResponse` | `MemberInfo` | +| `GetMemberInfoApiResponse` | `MemberApiResponse` | + +--- + +## 참조 문서 + +| 문서 | 내용 | +|------|------| +| [architecture-discussion-log.md](./architecture-discussion-log.md) | Phase 1 아키텍처 분석, 멘토 피드백, 주요 설계 결정 | +| [phase2-discussion-log.md](./phase2-discussion-log.md) | 예외 체계 설계, VO 전략, DomainService 보류 | +| [phase1-implementation-log.md](./phase1-implementation-log.md) | Phase 1 구현 진행 기록 | +| [phase2-implementation-log.md](./phase2-implementation-log.md) | Phase 2 구현 진행 기록 | +| [phase3-implementation-log.md](./phase3-implementation-log.md) | Phase 3 구현 진행 기록 | +| `docs/design/06-architecture.md` | 아키텍처 설계서 (시니어 리뷰용) | +| `docs/design/05-domain-model.md` | 도메인 모델 정의서 | +| `docs/planning/brand-plan.md` | Brand TDD 구현 계획 | +| `docs/planning/product-plan.md` | Product TDD 구현 계획 | diff --git a/domain/build.gradle.kts b/domain/build.gradle.kts new file mode 100644 index 000000000..caff74485 --- /dev/null +++ b/domain/build.gradle.kts @@ -0,0 +1,9 @@ +plugins { + `java-library` + `java-test-fixtures` +} + +dependencies { + api("jakarta.persistence:jakarta.persistence-api") + api(project(":supports:error")) +} diff --git a/domain/src/main/java/com/loopers/domain/BaseEntity.java b/domain/src/main/java/com/loopers/domain/BaseEntity.java new file mode 100644 index 000000000..9824d8333 --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/BaseEntity.java @@ -0,0 +1,36 @@ +package com.loopers.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import java.time.ZonedDateTime; + +/** + * soft-delete가 필요한 엔티티용. + * BaseTimeEntity의 생성/수정 시간 관리를 상속받고, 삭제 시간을 추가한다. + */ +@MappedSuperclass +@Getter +public abstract class BaseEntity extends BaseTimeEntity { + + @Column(name = "deleted_at") + private ZonedDateTime deletedAt; + + /** + * delete 연산은 멱등하게 동작할 수 있도록 한다. (삭제된 엔티티를 다시 삭제해도 동일한 결과가 나오도록) + */ + public void delete() { + if (this.deletedAt == null) { + this.deletedAt = ZonedDateTime.now(); + } + } + + /** + * restore 연산은 멱등하게 동작할 수 있도록 한다. (삭제되지 않은 엔티티를 복원해도 동일한 결과가 나오도록) + */ + public void restore() { + if (this.deletedAt != null) { + this.deletedAt = null; + } + } +} diff --git a/domain/src/main/java/com/loopers/domain/BaseTimeEntity.java b/domain/src/main/java/com/loopers/domain/BaseTimeEntity.java new file mode 100644 index 000000000..b498665cf --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/BaseTimeEntity.java @@ -0,0 +1,48 @@ +package com.loopers.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.MappedSuperclass; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import lombok.Getter; +import java.time.ZonedDateTime; + +/** + * 생성/수정 시간을 자동으로 관리한다. + * soft-delete가 불필요한 엔티티용. + */ +@MappedSuperclass +@Getter +public abstract class BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private ZonedDateTime updatedAt; + + protected void guard() {} + + @PrePersist + private void prePersist() { + guard(); + + ZonedDateTime now = ZonedDateTime.now(); + this.createdAt = now; + this.updatedAt = now; + } + + @PreUpdate + private void preUpdate() { + guard(); + + this.updatedAt = ZonedDateTime.now(); + } +} diff --git a/domain/src/main/java/com/loopers/domain/member/Member.java b/domain/src/main/java/com/loopers/domain/member/Member.java new file mode 100644 index 000000000..883733e84 --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/member/Member.java @@ -0,0 +1,86 @@ +package com.loopers.domain.member; + +import com.loopers.domain.BaseTimeEntity; +import com.loopers.domain.member.vo.Email; +import com.loopers.domain.member.vo.LoginId; +import com.loopers.domain.member.vo.MemberId; +import com.loopers.domain.member.vo.MemberName; +import com.loopers.domain.member.vo.Password; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.util.Objects; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "member") +public class Member extends BaseTimeEntity { + + @Embedded + private LoginId loginId; + + @Embedded + private Password password; + + @Embedded + private MemberName name; + + private LocalDate birthDate; + + @Embedded + private Email email; + + private Member(LoginId loginId, Password password, MemberName name, LocalDate birthDate, Email email) { + validateBirthDate(birthDate); + this.loginId = loginId; + this.password = password; + this.name = name; + this.birthDate = birthDate; + this.email = email; + } + + public static Member register( + LoginId loginId, + Password password, + MemberName name, + LocalDate birthDate, + Email email + ) { + return new Member(loginId, password, name, birthDate, email); + } + + public MemberId getMemberId() { + return getId() != null ? MemberId.of(getId()) : null; + } + + public void updatePassword(String newRawPassword, PasswordEncryptor encryptor) { + this.password = password.changeTo(newRawPassword, birthDate, encryptor); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Member member)) return false; + return getId() != null && Objects.equals(getId(), member.getId()); + } + + @Override + public int hashCode() { + return getClass().hashCode(); + } + + 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, MemberExceptionMessage.BirthDate.CANNOT_BE_FUTURE.message()); + } + } +} diff --git a/modules/jpa/src/main/java/com/loopers/domain/member/MemberExceptionMessage.java b/domain/src/main/java/com/loopers/domain/member/MemberExceptionMessage.java similarity index 100% rename from modules/jpa/src/main/java/com/loopers/domain/member/MemberExceptionMessage.java rename to domain/src/main/java/com/loopers/domain/member/MemberExceptionMessage.java diff --git a/domain/src/main/java/com/loopers/domain/member/MemberRepository.java b/domain/src/main/java/com/loopers/domain/member/MemberRepository.java new file mode 100644 index 000000000..f06cecd27 --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/member/MemberRepository.java @@ -0,0 +1,12 @@ +package com.loopers.domain.member; + +import java.util.Optional; + +public interface MemberRepository { + + Member save(Member member); + + Optional findByLoginId(String loginId); + + boolean existsByLoginId(String loginId); +} diff --git a/domain/src/main/java/com/loopers/domain/member/PasswordEncryptor.java b/domain/src/main/java/com/loopers/domain/member/PasswordEncryptor.java new file mode 100644 index 000000000..fb61bebc5 --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/member/PasswordEncryptor.java @@ -0,0 +1,8 @@ +package com.loopers.domain.member; + +public interface PasswordEncryptor { + + String encode(String rawPassword); + + boolean matches(String rawPassword, String encodedPassword); +} diff --git a/modules/jpa/src/main/java/com/loopers/domain/member/policy/MemberPolicy.java b/domain/src/main/java/com/loopers/domain/member/policy/MemberPolicy.java similarity index 100% rename from modules/jpa/src/main/java/com/loopers/domain/member/policy/MemberPolicy.java rename to domain/src/main/java/com/loopers/domain/member/policy/MemberPolicy.java diff --git a/domain/src/main/java/com/loopers/domain/member/vo/Email.java b/domain/src/main/java/com/loopers/domain/member/vo/Email.java new file mode 100644 index 000000000..f304aabc5 --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/member/vo/Email.java @@ -0,0 +1,54 @@ +package com.loopers.domain.member.vo; + +import com.loopers.domain.member.MemberExceptionMessage; +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.Getter; +import lombok.NoArgsConstructor; + +import java.util.Objects; + +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Email { + + @Column(name = "email") + private String value; + + private Email(String value) { + this.value = value; + } + + public static Email of(String value) { + validate(value); + return new Email(value); + } + + private static void validate(String value) { + if (value == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "이메일은 필수입니다."); + } + if (value.length() > 255) { + throw new CoreException(ErrorType.BAD_REQUEST, MemberExceptionMessage.Email.TOO_LONG.message()); + } + if (!value.matches("^[A-Za-z0-9+_.-]+@(.+)$")) { + throw new CoreException(ErrorType.BAD_REQUEST, MemberExceptionMessage.Email.INVALID_FORMAT.message()); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Email email)) return false; + return Objects.equals(value, email.value); + } + + @Override + public int hashCode() { + return Objects.hashCode(value); + } +} diff --git a/domain/src/main/java/com/loopers/domain/member/vo/LoginId.java b/domain/src/main/java/com/loopers/domain/member/vo/LoginId.java new file mode 100644 index 000000000..93dee0baf --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/member/vo/LoginId.java @@ -0,0 +1,54 @@ +package com.loopers.domain.member.vo; + +import com.loopers.domain.member.MemberExceptionMessage; +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.Getter; +import lombok.NoArgsConstructor; + +import java.util.Objects; + +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class LoginId { + + @Column(name = "login_id") + private String value; + + private LoginId(String value) { + this.value = value; + } + + public static LoginId of(String value) { + validate(value); + return new LoginId(value); + } + + private static void validate(String value) { + if (value == null || value.length() < 6 || value.length() > 20) { + throw new CoreException(ErrorType.BAD_REQUEST, MemberExceptionMessage.LoginId.INVALID_ID_LENGTH.message()); + } + if (value.matches("^[0-9]*$")) { + throw new CoreException(ErrorType.BAD_REQUEST, MemberExceptionMessage.LoginId.INVALID_ID_NUMERIC_ONLY.message()); + } + if (!value.matches("^[a-zA-Z0-9]*$") || !value.matches(".*[a-zA-Z].*")) { + throw new CoreException(ErrorType.BAD_REQUEST, MemberExceptionMessage.LoginId.INVALID_ID_FORMAT.message()); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof LoginId loginId)) return false; + return Objects.equals(value, loginId.value); + } + + @Override + public int hashCode() { + return Objects.hashCode(value); + } +} diff --git a/domain/src/main/java/com/loopers/domain/member/vo/MemberId.java b/domain/src/main/java/com/loopers/domain/member/vo/MemberId.java new file mode 100644 index 000000000..bfea4c5e9 --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/member/vo/MemberId.java @@ -0,0 +1,38 @@ +package com.loopers.domain.member.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.util.Objects; + +public class MemberId { + + private final Long value; + + private MemberId(Long value) { + this.value = value; + } + + public static MemberId of(Long value) { + if (value == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "MemberId는 null일 수 없습니다."); + } + return new MemberId(value); + } + + public Long getValue() { + return value; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof MemberId memberId)) return false; + return Objects.equals(value, memberId.value); + } + + @Override + public int hashCode() { + return Objects.hashCode(value); + } +} diff --git a/domain/src/main/java/com/loopers/domain/member/vo/MemberName.java b/domain/src/main/java/com/loopers/domain/member/vo/MemberName.java new file mode 100644 index 000000000..8d1f13b04 --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/member/vo/MemberName.java @@ -0,0 +1,54 @@ +package com.loopers.domain.member.vo; + +import com.loopers.domain.member.MemberExceptionMessage; +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.Getter; +import lombok.NoArgsConstructor; + +import java.util.Objects; + +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MemberName { + + @Column(name = "name") + private String value; + + private MemberName(String value) { + this.value = value; + } + + public static MemberName of(String value) { + validate(value); + return new MemberName(value); + } + + private static void validate(String value) { + if (value == null || value.length() < 2) { + throw new CoreException(ErrorType.BAD_REQUEST, MemberExceptionMessage.Name.TOO_SHORT.message()); + } + if (value.length() > 40) { + throw new CoreException(ErrorType.BAD_REQUEST, MemberExceptionMessage.Name.TOO_LONG.message()); + } + if (!value.matches("^[a-zA-Z가-힣\\s]*$")) { + throw new CoreException(ErrorType.BAD_REQUEST, MemberExceptionMessage.Name.CONTAINS_INVALID_CHAR.message()); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof MemberName that)) return false; + return Objects.equals(value, that.value); + } + + @Override + public int hashCode() { + return Objects.hashCode(value); + } +} diff --git a/domain/src/main/java/com/loopers/domain/member/vo/Password.java b/domain/src/main/java/com/loopers/domain/member/vo/Password.java new file mode 100644 index 000000000..b1ad05c57 --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/member/vo/Password.java @@ -0,0 +1,82 @@ +package com.loopers.domain.member.vo; + +import com.loopers.domain.member.MemberExceptionMessage; +import com.loopers.domain.member.PasswordEncryptor; +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.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.Objects; + +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Password { + + private static final String ALLOWED_CHARS_REGEX = "^[A-Za-z\\d@$!%*?&]*$"; + + @Column(name = "password") + private String value; + + private Password(String encryptedValue) { + this.value = encryptedValue; + } + + public static Password of(String rawPassword, LocalDate birthDate, PasswordEncryptor encryptor) { + validateFormat(rawPassword); + validateBirthDateNotContained(rawPassword, birthDate); + return new Password(encryptor.encode(rawPassword)); + } + + public boolean matches(String rawPassword, PasswordEncryptor encryptor) { + return encryptor.matches(rawPassword, this.value); + } + + public Password changeTo(String newRawPassword, LocalDate birthDate, PasswordEncryptor encryptor) { + validateChangeable(newRawPassword, birthDate, encryptor); + return Password.of(newRawPassword, birthDate, encryptor); + } + + private void validateChangeable(String newRawPassword, LocalDate birthDate, PasswordEncryptor encryptor) { + if (matches(newRawPassword, encryptor)) { + throw new CoreException(ErrorType.BAD_REQUEST, MemberExceptionMessage.Password.PASSWORD_CANNOT_BE_SAME_AS_CURRENT.message()); + } + validateFormat(newRawPassword); + validateBirthDateNotContained(newRawPassword, birthDate); + } + + private static void validateFormat(String rawPassword) { + if (rawPassword == null || rawPassword.length() < 8 || rawPassword.length() > 16) { + throw new CoreException(ErrorType.BAD_REQUEST, MemberExceptionMessage.Password.INVALID_PASSWORD_LENGTH.message()); + } + if (!rawPassword.matches(ALLOWED_CHARS_REGEX)) { + throw new CoreException(ErrorType.BAD_REQUEST, MemberExceptionMessage.Password.INVALID_PASSWORD_COMPOSITION.message()); + } + } + + private static void validateBirthDateNotContained(String rawPassword, LocalDate birthDate) { + String yyyyMMdd = birthDate.format(DateTimeFormatter.ofPattern("yyyyMMdd")); + String yyMMdd = yyyyMMdd.substring(2); + if (rawPassword.contains(yyyyMMdd) || rawPassword.contains(yyMMdd)) { + throw new CoreException(ErrorType.BAD_REQUEST, MemberExceptionMessage.Password.PASSWORD_CONTAINS_BIRTHDATE.message()); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Password password)) return false; + return Objects.equals(value, password.value); + } + + @Override + public int hashCode() { + return Objects.hashCode(value); + } +} diff --git a/domain/src/test/java/com/loopers/domain/member/MemberTest.java b/domain/src/test/java/com/loopers/domain/member/MemberTest.java new file mode 100644 index 000000000..52bffb024 --- /dev/null +++ b/domain/src/test/java/com/loopers/domain/member/MemberTest.java @@ -0,0 +1,87 @@ +package com.loopers.domain.member; + +import com.loopers.domain.member.vo.*; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +class MemberTest { + + private final PasswordEncryptor encryptor = new FakePasswordEncryptor(); + + private static final LocalDate VALID_BIRTH_DATE = LocalDate.of(2001, 2, 9); + + private LoginId validLoginId() { + return LoginId.of("hello1234"); + } + + private Password validPassword() { + return Password.of("Password1!", VALID_BIRTH_DATE, encryptor); + } + + private MemberName validName() { + return MemberName.of("홍길동"); + } + + private Email validEmail() { + return Email.of("test@example.com"); + } + + @Test + void 회원가입_성공() { + // given + + // when + + // then + assertDoesNotThrow(() -> + Member.register(validLoginId(), validPassword(), validName(), VALID_BIRTH_DATE, validEmail()) + ); + } + + @Nested + class 생년월일_유효성_검증 { + + @Test + void 미래_날짜는_생년월일_등록_불가() { + // given + LocalDate futureDate = LocalDate.now().plusDays(1); + + // when + + // then + assertThatThrownBy(() -> Member.register(validLoginId(), validPassword(), validName(), futureDate, validEmail())) + .hasMessage(MemberExceptionMessage.BirthDate.CANNOT_BE_FUTURE.message()); + } + } + + @Nested + class 비밀번호_수정_정책_검증 { + + @Test + void 새_비밀번호가_현재_비밀번호와_같으면_예외() { + // given + String currentPassword = "oldPassword1!"; + Password password = Password.of(currentPassword, VALID_BIRTH_DATE, encryptor); + Member member = Member.register(validLoginId(), password, validName(), VALID_BIRTH_DATE, validEmail()); + + // when & then + assertThatThrownBy(() -> member.updatePassword(currentPassword, encryptor)) + .hasMessage(MemberExceptionMessage.Password.PASSWORD_CANNOT_BE_SAME_AS_CURRENT.message()); + } + + @Test + void 새_비밀번호에_생년월일이_포함되면_예외() { + // given + Member member = Member.register(validLoginId(), validPassword(), validName(), VALID_BIRTH_DATE, validEmail()); + + // when & then + assertThatThrownBy(() -> member.updatePassword("pass20010209!", encryptor)) + .hasMessage(MemberExceptionMessage.Password.PASSWORD_CONTAINS_BIRTHDATE.message()); + } + } +} diff --git a/domain/src/test/java/com/loopers/domain/member/vo/EmailTest.java b/domain/src/test/java/com/loopers/domain/member/vo/EmailTest.java new file mode 100644 index 000000000..920a0fac5 --- /dev/null +++ b/domain/src/test/java/com/loopers/domain/member/vo/EmailTest.java @@ -0,0 +1,45 @@ +package com.loopers.domain.member.vo; + +import com.loopers.domain.member.MemberExceptionMessage; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +class EmailTest { + + @Test + void 유효한_이메일로_생성_성공() { + // given + String validEmail = "test@example.com"; + + // when + + // then + assertDoesNotThrow(() -> Email.of(validEmail)); + } + + @Test + void 이메일_기본_형식을_준수해야_함() { + // given + String wrongEmail = "test#example.com"; + + // when + + // then + assertThatThrownBy(() -> Email.of(wrongEmail)) + .hasMessage(MemberExceptionMessage.Email.INVALID_FORMAT.message()); + } + + @Test + void 이메일은_255자를_초과할_수_없음() { + // given + String longEmail = "a".repeat(250) + "@test.com"; + + // when + + // then + assertThatThrownBy(() -> Email.of(longEmail)) + .hasMessage(MemberExceptionMessage.Email.TOO_LONG.message()); + } +} diff --git a/domain/src/test/java/com/loopers/domain/member/vo/LoginIdTest.java b/domain/src/test/java/com/loopers/domain/member/vo/LoginIdTest.java new file mode 100644 index 000000000..1b4d22afa --- /dev/null +++ b/domain/src/test/java/com/loopers/domain/member/vo/LoginIdTest.java @@ -0,0 +1,81 @@ +package com.loopers.domain.member.vo; + +import com.loopers.domain.member.MemberExceptionMessage; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +class LoginIdTest { + + @Test + void 유효한_아이디로_생성_성공() { + // given + String validId = "hello1234"; + + // when + + // then + assertDoesNotThrow(() -> LoginId.of(validId)); + } + + @Test + void 아이디는_영문이_아닌_한글이_들어갈_수_없음() { + // given + String wrongId = "한글입slek"; + + // when + + // then + assertThatThrownBy(() -> LoginId.of(wrongId)) + .hasMessage(MemberExceptionMessage.LoginId.INVALID_ID_FORMAT.message()); + } + + @Test + void 아이디는_영문이_아닌_특수문자가_들어갈_수_없음() { + // given + String wrongId = "@apgl!#"; + + // when + + // then + assertThatThrownBy(() -> LoginId.of(wrongId)) + .hasMessage(MemberExceptionMessage.LoginId.INVALID_ID_FORMAT.message()); + } + + @Test + void 아이디는_숫자만_존재할_수_없음() { + // given + String wrongId = "12345678"; + + // when + + // then + assertThatThrownBy(() -> LoginId.of(wrongId)) + .hasMessage(MemberExceptionMessage.LoginId.INVALID_ID_NUMERIC_ONLY.message()); + } + + @Test + void 아이디의_길이_6자_미만_불가() { + // given + String wrongId = "ap245"; + + // when + + // then + assertThatThrownBy(() -> LoginId.of(wrongId)) + .hasMessage(MemberExceptionMessage.LoginId.INVALID_ID_LENGTH.message()); + } + + @Test + void 아이디의_길이_20자_초과_불가() { + // given + String wrongId = "apapeisname1234ppap56"; // 21글자 + + // when + + // then + assertThatThrownBy(() -> LoginId.of(wrongId)) + .hasMessage(MemberExceptionMessage.LoginId.INVALID_ID_LENGTH.message()); + } +} diff --git a/domain/src/test/java/com/loopers/domain/member/vo/MemberIdTest.java b/domain/src/test/java/com/loopers/domain/member/vo/MemberIdTest.java new file mode 100644 index 000000000..d6a5ed120 --- /dev/null +++ b/domain/src/test/java/com/loopers/domain/member/vo/MemberIdTest.java @@ -0,0 +1,52 @@ +package com.loopers.domain.member.vo; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class MemberIdTest { + + @Test + void Long_값으로_생성_성공() { + // given + Long value = 1L; + + // when + MemberId memberId = MemberId.of(value); + + // then + assertThat(memberId.getValue()).isEqualTo(1L); + } + + @Test + void null_값으로_생성_시_예외() { + // given + Long value = null; + + // when & then + assertThatThrownBy(() -> MemberId.of(value)) + .isInstanceOf(CoreException.class); + } + + @Test + void 같은_값이면_동등하다() { + // given + MemberId id1 = MemberId.of(1L); + MemberId id2 = MemberId.of(1L); + + // when & then + assertThat(id1).isEqualTo(id2); + } + + @Test + void 다른_값이면_동등하지_않다() { + // given + MemberId id1 = MemberId.of(1L); + MemberId id2 = MemberId.of(2L); + + // when & then + assertThat(id1).isNotEqualTo(id2); + } +} diff --git a/domain/src/test/java/com/loopers/domain/member/vo/MemberNameTest.java b/domain/src/test/java/com/loopers/domain/member/vo/MemberNameTest.java new file mode 100644 index 000000000..9ee3b1d0c --- /dev/null +++ b/domain/src/test/java/com/loopers/domain/member/vo/MemberNameTest.java @@ -0,0 +1,69 @@ +package com.loopers.domain.member.vo; + +import com.loopers.domain.member.MemberExceptionMessage; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +class MemberNameTest { + + @Test + void 유효한_이름으로_생성_성공() { + // given + String validName = "홍길동"; + + // when + + // then + assertDoesNotThrow(() -> MemberName.of(validName)); + } + + @Test + void 이름은_2자_미만일_수_없음() { + // given + String shortName = "홍"; + + // when + + // then + assertThatThrownBy(() -> MemberName.of(shortName)) + .hasMessage(MemberExceptionMessage.Name.TOO_SHORT.message()); + } + + @Test + void 이름은_40자를_초과할_수_없음() { + // given + String longName = "가".repeat(41); + + // when + + // then + assertThatThrownBy(() -> MemberName.of(longName)) + .hasMessage(MemberExceptionMessage.Name.TOO_LONG.message()); + } + + @Test + void 이름에_숫자가_포함될_수_없음() { + // given + String nameWithDigit = "홍길동1"; + + // when + + // then + assertThatThrownBy(() -> MemberName.of(nameWithDigit)) + .hasMessage(MemberExceptionMessage.Name.CONTAINS_INVALID_CHAR.message()); + } + + @Test + void 이름에_특수문자가_포함될_수_없음() { + // given + String nameWithSpecial = "John@"; + + // when + + // then + assertThatThrownBy(() -> MemberName.of(nameWithSpecial)) + .hasMessage(MemberExceptionMessage.Name.CONTAINS_INVALID_CHAR.message()); + } +} diff --git a/domain/src/test/java/com/loopers/domain/member/vo/PasswordTest.java b/domain/src/test/java/com/loopers/domain/member/vo/PasswordTest.java new file mode 100644 index 000000000..4e6780e51 --- /dev/null +++ b/domain/src/test/java/com/loopers/domain/member/vo/PasswordTest.java @@ -0,0 +1,88 @@ +package com.loopers.domain.member.vo; + +import com.loopers.domain.member.FakePasswordEncryptor; +import com.loopers.domain.member.MemberExceptionMessage; +import com.loopers.domain.member.PasswordEncryptor; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +class PasswordTest { + + private final PasswordEncryptor encryptor = new FakePasswordEncryptor(); + private final LocalDate birthDate = LocalDate.of(2001, 2, 9); + + @Test + void 유효한_비밀번호로_생성_성공() { + // given + String validPassword = "Password1!"; + + // when + + // then + assertDoesNotThrow(() -> Password.of(validPassword, birthDate, encryptor)); + } + + @Test + void 비밀번호_길이는_8자_미만일_수_없음() { + // given + String shortPassword = "pap1234"; // 7글자 + + // when + + // then + assertThatThrownBy(() -> Password.of(shortPassword, birthDate, encryptor)) + .hasMessage(MemberExceptionMessage.Password.INVALID_PASSWORD_LENGTH.message()); + } + + @Test + void 비밀번호_길이는_16자_초과일_수_없음() { + // given + String longPassword = "qwer1234tyui5678a"; // 17글자 + + // when + + // then + assertThatThrownBy(() -> Password.of(longPassword, birthDate, encryptor)) + .hasMessage(MemberExceptionMessage.Password.INVALID_PASSWORD_LENGTH.message()); + } + + @Test + void 비밀번호는_영문_숫자_특수문자만_사용할_수_있음() { + // given + String wrongPassword = "한글password123"; + + // when + + // then + assertThatThrownBy(() -> Password.of(wrongPassword, birthDate, encryptor)) + .hasMessage(MemberExceptionMessage.Password.INVALID_PASSWORD_COMPOSITION.message()); + } + + @Test + void 사용자_생년월일_YYYYMMDD가_비밀번호_포함_불가() { + // given + String wrongPassword = "pwd20010209!"; + + // when + + // then + assertThatThrownBy(() -> Password.of(wrongPassword, birthDate, encryptor)) + .hasMessage(MemberExceptionMessage.Password.PASSWORD_CONTAINS_BIRTHDATE.message()); + } + + @Test + void 사용자_생년월일_YYMMDD가_비밀번호_포함_불가() { + // given + String wrongPassword = "pass010209!"; + + // when + + // then + assertThatThrownBy(() -> Password.of(wrongPassword, birthDate, encryptor)) + .hasMessage(MemberExceptionMessage.Password.PASSWORD_CONTAINS_BIRTHDATE.message()); + } +} diff --git a/domain/src/test/java/com/loopers/support/error/CoreExceptionTest.java b/domain/src/test/java/com/loopers/support/error/CoreExceptionTest.java new file mode 100644 index 000000000..44db8c5e6 --- /dev/null +++ b/domain/src/test/java/com/loopers/support/error/CoreExceptionTest.java @@ -0,0 +1,34 @@ +package com.loopers.support.error; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class CoreExceptionTest { + @DisplayName("ErrorType 기반의 예외 생성 시, 별도의 메시지가 주어지지 않으면 ErrorType의 메시지를 사용한다.") + @Test + void messageShouldBeErrorTypeMessage_whenCustomMessageIsNull() { + // arrange + ErrorType[] errorTypes = ErrorType.values(); + + // act & assert + for (ErrorType errorType : errorTypes) { + CoreException exception = new CoreException(errorType); + assertThat(exception.getMessage()).isEqualTo(errorType.getMessage()); + } + } + + @DisplayName("ErrorType 기반의 예외 생성 시, 별도의 메시지가 주어지면 해당 메시지를 사용한다.") + @Test + void messageShouldBeCustomMessage_whenCustomMessageIsNotNull() { + // arrange + String customMessage = "custom message"; + + // act + CoreException exception = new CoreException(ErrorType.INTERNAL_ERROR, customMessage); + + // assert + assertThat(exception.getMessage()).isEqualTo(customMessage); + } +} diff --git a/domain/src/testFixtures/java/com/loopers/domain/member/FakePasswordEncryptor.java b/domain/src/testFixtures/java/com/loopers/domain/member/FakePasswordEncryptor.java new file mode 100644 index 000000000..0a5930388 --- /dev/null +++ b/domain/src/testFixtures/java/com/loopers/domain/member/FakePasswordEncryptor.java @@ -0,0 +1,15 @@ +package com.loopers.domain.member; + + +public class FakePasswordEncryptor implements PasswordEncryptor { + + @Override + public String encode(String rawPassword) { + return rawPassword; + } + + @Override + public boolean matches(String rawPassword, String encodedPassword) { + return rawPassword.equals(encodedPassword); + } +} diff --git a/domain/src/testFixtures/java/com/loopers/domain/member/MemberFixture.java b/domain/src/testFixtures/java/com/loopers/domain/member/MemberFixture.java new file mode 100644 index 000000000..bde0a18ae --- /dev/null +++ b/domain/src/testFixtures/java/com/loopers/domain/member/MemberFixture.java @@ -0,0 +1,43 @@ +package com.loopers.domain.member; + +import com.loopers.domain.member.vo.*; + +import java.time.LocalDate; + +public class MemberFixture { + + private static final PasswordEncryptor ENCRYPTOR = new FakePasswordEncryptor(); + + public static final String DEFAULT_RAW_PASSWORD = "Password1!"; + public static final LocalDate DEFAULT_BIRTH_DATE = LocalDate.of(2001, 2, 9); + + public static Member create() { + return Member.register( + LoginId.of("hello1234"), + Password.of(DEFAULT_RAW_PASSWORD, DEFAULT_BIRTH_DATE, ENCRYPTOR), + MemberName.of("홍길동"), + DEFAULT_BIRTH_DATE, + Email.of("test@example.com") + ); + } + + public static Member create(LoginId loginId) { + return Member.register( + loginId, + Password.of(DEFAULT_RAW_PASSWORD, DEFAULT_BIRTH_DATE, ENCRYPTOR), + MemberName.of("홍길동"), + DEFAULT_BIRTH_DATE, + Email.of("test@example.com") + ); + } + + public static Member create(LoginId loginId, Password password) { + return Member.register( + loginId, + password, + MemberName.of("홍길동"), + DEFAULT_BIRTH_DATE, + Email.of("test@example.com") + ); + } +} diff --git a/modules/jpa/build.gradle.kts b/infrastructure/jpa/build.gradle.kts similarity index 95% rename from modules/jpa/build.gradle.kts rename to infrastructure/jpa/build.gradle.kts index 941d8c272..e823af645 100644 --- a/modules/jpa/build.gradle.kts +++ b/infrastructure/jpa/build.gradle.kts @@ -4,6 +4,8 @@ plugins { } dependencies { + // domain + api(project(":domain")) // jpa api("org.springframework.boot:spring-boot-starter-data-jpa") // querydsl diff --git a/modules/jpa/src/main/java/com/loopers/config/jpa/DataSourceConfig.java b/infrastructure/jpa/src/main/java/com/loopers/config/jpa/DataSourceConfig.java similarity index 100% rename from modules/jpa/src/main/java/com/loopers/config/jpa/DataSourceConfig.java rename to infrastructure/jpa/src/main/java/com/loopers/config/jpa/DataSourceConfig.java diff --git a/modules/jpa/src/main/java/com/loopers/config/jpa/JpaConfig.java b/infrastructure/jpa/src/main/java/com/loopers/config/jpa/JpaConfig.java similarity index 100% rename from modules/jpa/src/main/java/com/loopers/config/jpa/JpaConfig.java rename to infrastructure/jpa/src/main/java/com/loopers/config/jpa/JpaConfig.java diff --git a/modules/jpa/src/main/java/com/loopers/config/jpa/QueryDslConfig.java b/infrastructure/jpa/src/main/java/com/loopers/config/jpa/QueryDslConfig.java similarity index 100% rename from modules/jpa/src/main/java/com/loopers/config/jpa/QueryDslConfig.java rename to infrastructure/jpa/src/main/java/com/loopers/config/jpa/QueryDslConfig.java diff --git a/infrastructure/jpa/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java new file mode 100644 index 000000000..95fe7e2a1 --- /dev/null +++ b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java @@ -0,0 +1,13 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.member.Member; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface MemberJpaRepository extends JpaRepository { + + boolean existsByLoginId_Value(String loginId); + + Optional findByLoginId_Value(String loginId); +} diff --git a/infrastructure/jpa/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java new file mode 100644 index 000000000..906ee7654 --- /dev/null +++ b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java @@ -0,0 +1,30 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class MemberRepositoryImpl implements MemberRepository { + + private final MemberJpaRepository memberJpaRepository; + + @Override + public Member save(Member member) { + return memberJpaRepository.save(member); + } + + @Override + public Optional findByLoginId(String loginId) { + return memberJpaRepository.findByLoginId_Value(loginId); + } + + @Override + public boolean existsByLoginId(String loginId) { + return memberJpaRepository.existsByLoginId_Value(loginId); + } +} diff --git a/modules/jpa/src/main/resources/jpa.yml b/infrastructure/jpa/src/main/resources/jpa.yml similarity index 100% rename from modules/jpa/src/main/resources/jpa.yml rename to infrastructure/jpa/src/main/resources/jpa.yml diff --git a/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java b/infrastructure/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java similarity index 100% rename from modules/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java rename to infrastructure/jpa/src/testFixtures/java/com/loopers/testcontainers/MySqlTestContainersConfig.java diff --git a/modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java b/infrastructure/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java similarity index 100% rename from modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java rename to infrastructure/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java diff --git a/modules/kafka/build.gradle.kts b/infrastructure/kafka/build.gradle.kts similarity index 100% rename from modules/kafka/build.gradle.kts rename to infrastructure/kafka/build.gradle.kts diff --git a/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java b/infrastructure/kafka/src/main/java/com/loopers/config/kafka/KafkaConfig.java similarity index 99% rename from modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java rename to infrastructure/kafka/src/main/java/com/loopers/config/kafka/KafkaConfig.java index a73842775..ce5b10871 100644 --- a/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java +++ b/infrastructure/kafka/src/main/java/com/loopers/config/kafka/KafkaConfig.java @@ -1,4 +1,4 @@ -package com.loopers.confg.kafka; +package com.loopers.config.kafka; import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.kafka.clients.consumer.ConsumerConfig; diff --git a/modules/kafka/src/main/resources/kafka.yml b/infrastructure/kafka/src/main/resources/kafka.yml similarity index 100% rename from modules/kafka/src/main/resources/kafka.yml rename to infrastructure/kafka/src/main/resources/kafka.yml diff --git a/modules/redis/build.gradle.kts b/infrastructure/redis/build.gradle.kts similarity index 100% rename from modules/redis/build.gradle.kts rename to infrastructure/redis/build.gradle.kts diff --git a/modules/redis/src/main/java/com/loopers/config/redis/RedisConfig.java b/infrastructure/redis/src/main/java/com/loopers/config/redis/RedisConfig.java similarity index 100% rename from modules/redis/src/main/java/com/loopers/config/redis/RedisConfig.java rename to infrastructure/redis/src/main/java/com/loopers/config/redis/RedisConfig.java diff --git a/modules/redis/src/main/java/com/loopers/config/redis/RedisNodeInfo.java b/infrastructure/redis/src/main/java/com/loopers/config/redis/RedisNodeInfo.java similarity index 100% rename from modules/redis/src/main/java/com/loopers/config/redis/RedisNodeInfo.java rename to infrastructure/redis/src/main/java/com/loopers/config/redis/RedisNodeInfo.java diff --git a/modules/redis/src/main/java/com/loopers/config/redis/RedisProperties.java b/infrastructure/redis/src/main/java/com/loopers/config/redis/RedisProperties.java similarity index 100% rename from modules/redis/src/main/java/com/loopers/config/redis/RedisProperties.java rename to infrastructure/redis/src/main/java/com/loopers/config/redis/RedisProperties.java diff --git a/modules/redis/src/main/resources/redis.yml b/infrastructure/redis/src/main/resources/redis.yml similarity index 100% rename from modules/redis/src/main/resources/redis.yml rename to infrastructure/redis/src/main/resources/redis.yml diff --git a/modules/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java b/infrastructure/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java similarity index 100% rename from modules/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java rename to infrastructure/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java diff --git a/modules/redis/src/testFixtures/java/com/loopers/utils/RedisCleanUp.java b/infrastructure/redis/src/testFixtures/java/com/loopers/utils/RedisCleanUp.java similarity index 100% rename from modules/redis/src/testFixtures/java/com/loopers/utils/RedisCleanUp.java rename to infrastructure/redis/src/testFixtures/java/com/loopers/utils/RedisCleanUp.java diff --git a/infrastructure/security/build.gradle.kts b/infrastructure/security/build.gradle.kts new file mode 100644 index 000000000..bdc106f69 --- /dev/null +++ b/infrastructure/security/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + `java-library` +} + +dependencies { + api(project(":domain")) + implementation("org.springframework.security:spring-security-crypto") +} diff --git a/infrastructure/security/src/main/java/com/loopers/infrastructure/security/BCryptPasswordEncryptor.java b/infrastructure/security/src/main/java/com/loopers/infrastructure/security/BCryptPasswordEncryptor.java new file mode 100644 index 000000000..77d390d26 --- /dev/null +++ b/infrastructure/security/src/main/java/com/loopers/infrastructure/security/BCryptPasswordEncryptor.java @@ -0,0 +1,21 @@ +package com.loopers.infrastructure.security; + +import com.loopers.domain.member.PasswordEncryptor; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Component; + +@Component +public class BCryptPasswordEncryptor implements PasswordEncryptor { + + private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); + + @Override + public String encode(String rawPassword) { + return encoder.encode(rawPassword); + } + + @Override + public boolean matches(String rawPassword, String encodedPassword) { + return encoder.matches(rawPassword, encodedPassword); + } +} diff --git a/modules/jpa/src/main/java/com/loopers/domain/BaseEntity.java b/modules/jpa/src/main/java/com/loopers/domain/BaseEntity.java deleted file mode 100644 index d15a9c764..000000000 --- a/modules/jpa/src/main/java/com/loopers/domain/BaseEntity.java +++ /dev/null @@ -1,73 +0,0 @@ -package com.loopers.domain; - -import jakarta.persistence.Column; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.MappedSuperclass; -import jakarta.persistence.PrePersist; -import jakarta.persistence.PreUpdate; -import lombok.Getter; -import java.time.ZonedDateTime; - -/** - * 생성/수정/삭제 정보를 자동으로 관리해준다. - * 재사용성을 위해 이 외의 컬럼이나 동작은 추가하지 않는다. - */ -@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; - - /** - * 엔티티의 유효성을 검증한다. - * 이 메소드는 PrePersist 및 PreUpdate 시점에 호출된다. - */ - protected void guard() {} - - @PrePersist - private void prePersist() { - guard(); - - ZonedDateTime now = ZonedDateTime.now(); - this.createdAt = now; - this.updatedAt = now; - } - - @PreUpdate - private void preUpdate() { - guard(); - - this.updatedAt = ZonedDateTime.now(); - } - - /** - * delete 연산은 멱등하게 동작할 수 있도록 한다. (삭제된 엔티티를 다시 삭제해도 동일한 결과가 나오도록) - */ - public void delete() { - if (this.deletedAt == null) { - this.deletedAt = ZonedDateTime.now(); - } - } - - /** - * restore 연산은 멱등하게 동작할 수 있도록 한다. (삭제되지 않은 엔티티를 복원해도 동일한 결과가 나오도록) - */ - public void restore() { - if (this.deletedAt != null) { - this.deletedAt = null; - } - } -} diff --git a/modules/jpa/src/main/java/com/loopers/domain/member/Member.java b/modules/jpa/src/main/java/com/loopers/domain/member/Member.java deleted file mode 100644 index 4b4155e84..000000000 --- a/modules/jpa/src/main/java/com/loopers/domain/member/Member.java +++ /dev/null @@ -1,74 +0,0 @@ -package com.loopers.domain.member; - -import com.loopers.domain.member.policy.*; -import com.loopers.utils.PasswordEncryptor; -import jakarta.persistence.*; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.time.LocalDate; - -@Entity -@Getter -@NoArgsConstructor -@AllArgsConstructor -@Builder -@Table(name = "member") -public class Member { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - private String loginId; - - private String password; - - private String name; - - private LocalDate birthDate; - - private String email; - - public static Member register( - String loginId, - String password, - String name, - LocalDate birthDate, - String email - ) { - MemberPolicy.LoginId.validate(loginId); - MemberPolicy.BirthDate.validate(birthDate); - MemberPolicy.Password.validate(password, birthDate); - MemberPolicy.Name.validate(name); - MemberPolicy.Email.validate(email); - - return Member.builder() - .loginId(loginId) - .password(encodedPassword(password)) - .name(name) - .birthDate(birthDate) - .email(email) - .build(); - } - - public boolean isSamePassword(String inputPassword) { - return PasswordEncryptor.matches(inputPassword, this.password); - } - - public void updatePassword(String newPassword) { - if (isSamePassword(newPassword)) { - throw new IllegalArgumentException(MemberExceptionMessage.Password.PASSWORD_CANNOT_BE_SAME_AS_CURRENT.message()); - } - MemberPolicy.Password.validate(newPassword, birthDate); - - this.password = encodedPassword(newPassword); - } - - private static String encodedPassword(String password) { - return PasswordEncryptor.encode(password); - } - -} diff --git a/modules/jpa/src/main/java/com/loopers/utils/PasswordEncryptor.java b/modules/jpa/src/main/java/com/loopers/utils/PasswordEncryptor.java deleted file mode 100644 index a3503503b..000000000 --- a/modules/jpa/src/main/java/com/loopers/utils/PasswordEncryptor.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.loopers.utils; - -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.Base64; - -public class PasswordEncryptor { - - private static final String ALGORITHM = "SHA-256"; - - /** - * 비밀번호를 SHA-256으로 해싱함. - * (주의: 이 예제는 순수 해싱만 수행합니다. 실무에선 Salt를 추가해야 안전합니다.) - */ - public static String encode(String rawPassword) { - try { - MessageDigest digest = MessageDigest.getInstance(ALGORITHM); - byte[] encodedHash = digest.digest(rawPassword.getBytes(StandardCharsets.UTF_8)); - - // 바이트 배열을 읽기 쉬운 문자열(Base64)로 변환 - return Base64.getEncoder().encodeToString(encodedHash); - } catch (NoSuchAlgorithmException e) { - throw new RuntimeException("암호화 알고리즘을 찾을 수 없습니다.", e); - } - } - - /** - * 일치 여부 확인 - */ - public static boolean matches(String rawPassword, String encodedPassword) { - if (rawPassword == null || encodedPassword == null) return false; - - // 입력받은 원문을 똑같이 해싱해서 결과가 같은지 비교 - String newHash = encode(rawPassword); - return newHash.equals(encodedPassword); - } -} diff --git a/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/domain/member/MemberTest.java b/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/domain/member/MemberTest.java deleted file mode 100644 index 06c44d3dd..000000000 --- a/modules/jpa/src/testFixtures/java/com/loopers/testcontainers/domain/member/MemberTest.java +++ /dev/null @@ -1,520 +0,0 @@ -package com.loopers.testcontainers.domain.member; - -import com.loopers.domain.member.Member; -import com.loopers.domain.member.MemberExceptionMessage; -import com.loopers.utils.PasswordEncryptor; -import org.assertj.core.api.AbstractThrowableAssert; -import org.assertj.core.api.LocalDateAssert; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -import java.time.LocalDate; - -import static org.assertj.core.api.Assertions.*; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; - -/** - * Member 도메인 엔티티 유효성 검증 테스트 - * - * 검증 대상: - * - 로그인 ID: 영문/숫자만 허용, 6~20글자 - * - 비밀번호: 8~16자, 영문 대소문자 + 숫자 + 특수문자 조합 - * - 이름: 한글/영어 허용, 2~40글자 - * - 이메일: RFC 5321 표준 준수 - */ -@DisplayName("Member 도메인 유효성 검증 테스트") -class MemberTest { - - // 테스트용 유효한 기본값 - private static final String VALID_LOGIN_ID = "hello1234"; - private static final String VALID_PASSWORD = "Password1!"; - private static final String VALID_NAME = "홍길동"; - private static final LocalDate VALID_BIRTH_DATE = LocalDate.of(2001, 2, 9); - private static final String VALID_EMAIL = "test@example.com"; - - @Test - public void 회원가입_성공() throws Exception { - //given - - //when - - //then - assertDoesNotThrow(() -> - Member.register(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTH_DATE, VALID_EMAIL) - ); - - } - - @DisplayName("회원가입 시에 ID 검증") - @Nested - class LoginIdValidation { - - // 1. 아이디는 영문과 숫자가 합쳐진 경우 허용하며, 중복 가입할 수 없으며, 6~20글자여야 함. - // 1-1. 아이디는 영문이 들어가야 함. - // 1-1-1. 아이디는 영문이 아닌 한글이 들어갈 수 없음. - // 1-1-2. 아이디는 영문이 아닌 특수 문자가 들어갈 수 없음. - // 1-2. 아이디는 숫자만 존재할 수 없음. - // 1-3. 아이디는 중복 가입할 수 없음. -> - // 1-3-1. 아이디가 중복인 경우 예외 메시지를 띄워야 함. - // 1-4. 아이디의 길이는 6~20글자여야 함. - // 1-4-1. 아이디의 길이는 6글자 미만일 수 없음. - // 1-4-2. 아이디의 길이는 20글자 초과일 수 없음. - - // 1-1-1 - @Test - public void 아이디는_영문이_아닌_한글이_들어갈_수_없음() throws Exception { - //given - String wrongId = "한글입slek"; - - //when - - //then - throwIfWrongIdInput(wrongId) - .hasMessage(MemberExceptionMessage.LoginId.INVALID_ID_FORMAT.message()); - - } - - // 1-1-2 - @Test - public void 아이디는_영문이_아닌_특수문자가_들어갈_수_없음() throws Exception - { - //given - String wrongId = "@apgl!#"; - - //when - - //then - throwIfWrongIdInput(wrongId) - .hasMessage(MemberExceptionMessage.LoginId.INVALID_ID_FORMAT.message()); - } - - // 1-2 - @Test - public void 아이디는_숫자만_존재할_수_없음() throws Exception { - //given - String wrongId = "12345678"; - - //when - - //then - throwIfWrongIdInput(wrongId) - .hasMessage(MemberExceptionMessage.LoginId.INVALID_ID_NUMERIC_ONLY.message()); - } - - // 1-3 -> 레포지토리 가져오니까 서비스의 통합 테스트 - @Test - public void 아이디는_중복_가입할_수_없음() throws Exception { - //given - - //when - - //then - - } - - // 1-4-1 - @Test - public void 아이디의_길이_6자_미만_불가() throws Exception { - //given - String wrongId = "ap245"; - - //when - - //then - throwIfWrongIdInput(wrongId) - .hasMessage(MemberExceptionMessage.LoginId.INVALID_ID_LENGTH.message()); - } - - // 1-4-2 - @Test - public void 아이디의_길이_20자_초과_불가() throws Exception { - //given - String wrongId = "apapeisname1234ppap56"; // 21글자 - - //when - - //then - throwIfWrongIdInput(wrongId) - .hasMessage(MemberExceptionMessage.LoginId.INVALID_ID_LENGTH.message()); - } - - private AbstractThrowableAssert throwIfWrongIdInput(String wrongId) { - return assertThatThrownBy(() -> Member.register(wrongId, VALID_PASSWORD, VALID_NAME, VALID_BIRTH_DATE, VALID_EMAIL)) - .isInstanceOf(IllegalArgumentException.class); - } - } - - /** - * [비밀번호 보안 정책 - Zero-Birthdate Policy 기반] - * * 1. 기본 형식 검증 (Basic Format) - * - 1-1. 길이는 8자 이상 16자 이하여야 함. - * - 1-1-1. 길이는 8자 미만일 수 없음 - * - 1-1-2. 길이는 16자 초과일 수 없음 - * - 1-2. 영문 대문자, 영문 소문자, 숫자, 특수문자가 최소 1개 이상 포함된 조합이어야 함. - * * 2. 생년월일 포함 금지 규칙 (Zero-Birthdate Policy) - * - 2-1. 사용자의 생년월일(YYYYMMDD)이 비밀번호 문자열 내에 포함될 수 없음. (ex: "pass19950520!") - * - 2-2. 사용자의 생년월일(YYMMDD)이 비밀번호 문자열 내에 포함될 수 없음. (ex: "950520pass#") - * * 3. 수정 시 정책 (Update Policy) - * - 3-1. 현재 사용 중인 비밀번호(암호화 전 원문 기준)와 동일한 비밀번호로 변경 불가. -> Service, 통합 - * * 4. 보안 전제 조건 - * - 4-1. DB 저장 전 반드시 단방향 해시 암호화 과정을 거쳐야 함. - */ - - @DisplayName("비밀번호 형식 검증") - @Nested - class PasswordFormatValidation { - - // 1-1-1 - @Test - public void 비밀번호_길이는_8자_미만일_수_없음() throws Exception { - //given - String wrongPassword = "pap1234"; // 7글자 - - //when - - //then - throwIfWrongPasswordInput(wrongPassword) - .hasMessage(MemberExceptionMessage.Password.INVALID_PASSWORD_LENGTH.message()); - } - - // 1-1-2 - @Test - public void 비밀번호_길이는_16자_초과일_수_없음() throws Exception { - //given - String wrongPassword = "qwer1234tyui5678a"; // 17글자 - - //when - - //then - throwIfWrongPasswordInput(wrongPassword) - .hasMessage(MemberExceptionMessage.Password.INVALID_PASSWORD_LENGTH.message()); - - } - - // 1-2 - @Test - public void 비밀번호는_영문_숫자_특수문자만_사용할_수_있음() throws Exception { - //given - String wrongPassword = "한글password123"; - - //when - - //then - throwIfWrongPasswordInput(wrongPassword) - .hasMessage(MemberExceptionMessage.Password.INVALID_PASSWORD_COMPOSITION.message()); - - } - - // 2-1 - @Test - public void 사용자_생년월일_YYYYMMDD가_비밀번호_포함_불가() throws Exception { - //given - LocalDate userBirthDate = LocalDate.of(2001, 2, 9); - String wrongPassword = "pwd20010209!"; - - //when - - //then - throwIfPasswordContainsBirthDate(wrongPassword, userBirthDate) - .hasMessage(MemberExceptionMessage.Password.PASSWORD_CONTAINS_BIRTHDATE.message()); - } - - // 2-2 - @Test - public void 사용자_생년월일_YYMMDD가_비밀번호_포함_불가() throws Exception { - //given - LocalDate userBirthDate = LocalDate.of(2001, 2, 9); - String wrongPassword = "pass010209!"; - - //when - - //then - throwIfPasswordContainsBirthDate(wrongPassword, userBirthDate) - .hasMessage(MemberExceptionMessage.Password.PASSWORD_CONTAINS_BIRTHDATE.message()); - } - - // 4 - @Test - public void 비밀번호는_암호화해_저장() throws Exception { - //given - - //when - String encodedPassword = PasswordEncryptor.encode(VALID_PASSWORD); - - //then - assertThat(PasswordEncryptor.matches(VALID_PASSWORD, encodedPassword)).isTrue(); - } - - private AbstractThrowableAssert throwIfWrongPasswordInput(String wrongPassword) { - return assertThatThrownBy(() -> Member.register(VALID_LOGIN_ID, wrongPassword, VALID_NAME, VALID_BIRTH_DATE, VALID_EMAIL)) - .isInstanceOf(IllegalArgumentException.class); - } - - private AbstractThrowableAssert throwIfPasswordContainsBirthDate(String wrongPassword, LocalDate birthDate) { - return assertThatThrownBy(() -> Member.register(VALID_LOGIN_ID, wrongPassword, VALID_NAME, birthDate, VALID_EMAIL)) - .isInstanceOf(IllegalArgumentException.class); - } - - } - - @DisplayName("이름(Name) 유효성 검증") - @Nested - class NameValidation { - - @Test - void 이름은_2자_미만일_수_없음() { - String shortName = "홍"; - throwIfWrongNameInput(shortName) - .hasMessage(MemberExceptionMessage.Name.TOO_SHORT.message()); - } - - @Test - void 이름은_40자를_초과할_수_없음() { - String longName = "가".repeat(41); - throwIfWrongNameInput(longName) - .hasMessage(MemberExceptionMessage.Name.TOO_LONG.message()); - } - - @Test - void 이름에_숫자가_포함될_수_없음() { - String nameWithDigit = "홍길동1"; - throwIfWrongNameInput(nameWithDigit) - .hasMessage(MemberExceptionMessage.Name.CONTAINS_INVALID_CHAR.message()); - } - - @Test - void 이름에_특수문자가_포함될_수_없음() { - String nameWithSpecial = "John@"; - throwIfWrongNameInput(nameWithSpecial) - .hasMessage(MemberExceptionMessage.Name.CONTAINS_INVALID_CHAR.message()); - } - - private AbstractThrowableAssert throwIfWrongNameInput(String name) { - return assertThatThrownBy(() -> Member.register(VALID_LOGIN_ID, VALID_PASSWORD, name, VALID_BIRTH_DATE, VALID_EMAIL)) - .isInstanceOf(IllegalArgumentException.class); - } - } - - @DisplayName("이메일(Email) 유효성 검증") - @Nested - class EmailValidation { - - @Test - void 이메일_기본_형식을_준수해야_함() { - String wrongEmail = "test#example.com"; // @ 없음 - throwIfWrongEmailInput(wrongEmail) - .hasMessage(MemberExceptionMessage.Email.INVALID_FORMAT.message()); - } - - @Test - @DisplayName("이메일은 255자를 초과할 수 없음") - void emailTooLong() { - String longEmail = "a".repeat(250) + "@test.com"; - throwIfWrongEmailInput(longEmail) - .hasMessage(MemberExceptionMessage.Email.TOO_LONG.message()); - } - - private AbstractThrowableAssert throwIfWrongEmailInput(String email) { - return assertThatThrownBy(() -> Member.register(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTH_DATE, email)) - .isInstanceOf(IllegalArgumentException.class); - } - } - - @DisplayName("생년월일(BirthDate) 유효성 검증") - @Nested - class BirthDateValidation { - - @Test - @DisplayName("미래_날짜는_생년월일_등록_불가") - void birthDateCannotBeFuture() { - LocalDate futureDate = LocalDate.now().plusDays(1); - throwIfWrongBirthDateInput(futureDate) - .hasMessage(MemberExceptionMessage.BirthDate.CANNOT_BE_FUTURE.message()); - } - - private AbstractThrowableAssert throwIfWrongBirthDateInput(LocalDate birthDate) { - return assertThatThrownBy(() -> Member.register(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, birthDate, VALID_EMAIL)) - .isInstanceOf(IllegalArgumentException.class); - } - } - - @DisplayName("회원가입 통합 성공 검증") - @Nested - class RegistrationSuccess { - - @Test - @DisplayName("모든 조건이 유효하면 회원가입에 성공한다") - void successWhenAllFieldsValid() { - assertThatCode(() -> Member.register(VALID_LOGIN_ID, VALID_PASSWORD, VALID_NAME, VALID_BIRTH_DATE, VALID_EMAIL)) - .doesNotThrowAnyException(); - } - } - - @DisplayName("요청 시 비밀번호 동일한지 검증") - @Nested - class SamePasswordValidation { - - @Test - @DisplayName("입력받은 비밀번호가 저장된 비밀번호와 정확히 일치하면 true를 반환한다") - void isSamePassword_Success() { - // given - String savedPassword = "password123!"; - Member member = Member.builder() - .password(PasswordEncryptor.encode(savedPassword)) - .build(); - - // when - boolean result = member.isSamePassword("password123!"); - - // then - assertThat(result).isTrue(); - } - - @Test - @DisplayName("입력받은 비밀번호가 저장된 비밀번호와 다르면 false를 반환한다") - void isSamePassword_Fail() { - // given - String savedPassword = "password123!"; - Member member = Member.builder() - .password(savedPassword) - .build(); - - // when - boolean result = member.isSamePassword("wrongPassword"); - - // then - assertThat(result).isFalse(); - } - } - - @DisplayName("비밀번호 수정 정책 검증") - @Nested - class UpdatePasswordPolicy { - - @Test - @DisplayName("새 비밀번호가 현재 비밀번호와 같으면 예외가 발생한다") - void updatePassword_Fail_SameAsCurrent() { - // given - Member member = Member.builder() - .password(PasswordEncryptor.encode("oldPassword123!")) - .build(); - - // when & then - assertThatThrownBy(() -> member.updatePassword("oldPassword123!")) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage(MemberExceptionMessage.Password.PASSWORD_CANNOT_BE_SAME_AS_CURRENT.message()); - } - - @Test - @DisplayName("새 비밀번호에 생년월일이 포함되면 예외가 발생한다") - void updatePassword_Fail_ContainsBirthDate() { - // given - Member member = Member.builder() - .password("oldPass123!") - .birthDate(LocalDate.of(2001, 2, 9)) - .build(); - - // when & then - assertThatThrownBy(() -> member.updatePassword("pass20010209!")) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage(MemberExceptionMessage.Password.PASSWORD_CONTAINS_BIRTHDATE.message()); - } - - @DisplayName("비밀번호 수정 시 형식 검증") - @Nested - class PasswordFormatValidation { - - // 1-1-1 - @Test - public void 비밀번호_길이는_8자_미만일_수_없음() throws Exception { - //given - String wrongPassword = "pap1234"; // 7글자 - - //when - - //then - throwIfWrongPasswordInput(wrongPassword) - .hasMessage(MemberExceptionMessage.Password.INVALID_PASSWORD_LENGTH.message()); - } - - // 1-1-2 - @Test - public void 비밀번호_길이는_16자_초과일_수_없음() throws Exception { - //given - String wrongPassword = "qwer1234tyui5678a"; // 17글자 - - //when - - //then - throwIfWrongPasswordInput(wrongPassword) - .hasMessage(MemberExceptionMessage.Password.INVALID_PASSWORD_LENGTH.message()); - - } - - // 1-2 - @Test - public void 비밀번호는_영문_숫자_특수문자만_사용할_수_있음() throws Exception { - //given - String wrongPassword = "한글password123"; - - //when - - //then - throwIfWrongPasswordInput(wrongPassword) - .hasMessage(MemberExceptionMessage.Password.INVALID_PASSWORD_COMPOSITION.message()); - - } - - // 2-1 - @Test - public void 사용자_생년월일_YYYYMMDD가_비밀번호_포함_불가() throws Exception { - //given - LocalDate userBirthDate = LocalDate.of(2001, 2, 9); - String wrongPassword = "pwd20010209!"; - - //when - - //then - throwIfWrongPasswordInput(wrongPassword) - .hasMessage(MemberExceptionMessage.Password.PASSWORD_CONTAINS_BIRTHDATE.message()); - } - - // 2-2 - @Test - public void 사용자_생년월일_YYMMDD가_비밀번호_포함_불가() throws Exception { - //given - LocalDate userBirthDate = LocalDate.of(2001, 2, 9); - String wrongPassword = "pass010209!"; - - //when - - //then - throwIfWrongPasswordInput(wrongPassword) - .hasMessage(MemberExceptionMessage.Password.PASSWORD_CONTAINS_BIRTHDATE.message()); - } - - // 4 - @Test - public void 비밀번호는_암호화해_저장() throws Exception { - //given - - //when - String encodedPassword = PasswordEncryptor.encode(VALID_PASSWORD); - - //then - assertThat(PasswordEncryptor.matches(VALID_PASSWORD, encodedPassword)).isTrue(); - } - - private AbstractThrowableAssert throwIfWrongPasswordInput(String wrongPassword) { - Member member = Member.builder() - .password("oldPass123!") - .birthDate(LocalDate.of(2001, 2, 9)) - .build(); - return assertThatThrownBy(() -> member.updatePassword(wrongPassword)) - .isInstanceOf(IllegalArgumentException.class); - } - - } - } -} diff --git a/presentation/commerce-api/build.gradle.kts b/presentation/commerce-api/build.gradle.kts new file mode 100644 index 000000000..f8e09ddd9 --- /dev/null +++ b/presentation/commerce-api/build.gradle.kts @@ -0,0 +1,28 @@ +apply(plugin = "org.springframework.boot") + +dependencies { + // layers + implementation(project(":domain")) + implementation(project(":application:commerce-service")) + implementation(project(":infrastructure:jpa")) + implementation(project(":infrastructure:redis")) + implementation(project(":infrastructure:security")) + implementation(project(":supports:jackson")) + implementation(project(":supports:logging")) + implementation(project(":supports:monitoring")) + + // web + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${project.properties["springDocOpenApiVersion"]}") + + // querydsl + annotationProcessor("com.querydsl:querydsl-apt::jakarta") + annotationProcessor("jakarta.persistence:jakarta.persistence-api") + annotationProcessor("jakarta.annotation:jakarta.annotation-api") + + // test-fixtures + testImplementation(testFixtures(project(":domain"))) + testImplementation(testFixtures(project(":infrastructure:jpa"))) + testImplementation(testFixtures(project(":infrastructure:redis"))) +} diff --git a/presentation/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java b/presentation/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java new file mode 100644 index 000000000..9027b51bf --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java @@ -0,0 +1,22 @@ +package com.loopers; + +import jakarta.annotation.PostConstruct; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import java.util.TimeZone; + +@ConfigurationPropertiesScan +@SpringBootApplication +public class CommerceApiApplication { + + @PostConstruct + public void started() { + // set timezone + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); + } + + public static void main(String[] args) { + SpringApplication.run(CommerceApiApplication.class, args); + } +} diff --git a/presentation/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java b/presentation/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java new file mode 100644 index 000000000..ce6d3ead0 --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java @@ -0,0 +1,6 @@ +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/presentation/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java b/presentation/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java new file mode 100644 index 000000000..37f2272f0 --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java @@ -0,0 +1,19 @@ +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/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java new file mode 100644 index 000000000..bc6e93eaa --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java @@ -0,0 +1,138 @@ +package com.loopers.interfaces.api; + +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.exc.InvalidFormatException; +import com.fasterxml.jackson.databind.exc.MismatchedInputException; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.server.ServerWebInputException; +import org.springframework.web.servlet.resource.NoResourceFoundException; + +import 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()); + } + + @ExceptionHandler + public ResponseEntity> handleBadRequest(MethodArgumentTypeMismatchException e) { + String name = e.getName(); + String type = e.getRequiredType() != null ? e.getRequiredType().getSimpleName() : "unknown"; + String value = e.getValue() != null ? e.getValue().toString() : "null"; + String message = String.format("요청 파라미터 '%s' (타입: %s)의 값 '%s'이(가) 잘못되었습니다.", name, type, value); + return failureResponse(ErrorType.BAD_REQUEST, message); + } + + @ExceptionHandler + public ResponseEntity> handleBadRequest(MissingServletRequestParameterException e) { + String name = e.getParameterName(); + String type = e.getParameterType(); + String message = String.format("필수 요청 파라미터 '%s' (타입: %s)가 누락되었습니다.", name, type); + return failureResponse(ErrorType.BAD_REQUEST, message); + } + + @ExceptionHandler + public ResponseEntity> handleBadRequest(HttpMessageNotReadableException e) { + String errorMessage; + Throwable rootCause = e.getRootCause(); + + if (rootCause instanceof InvalidFormatException invalidFormat) { + String fieldName = invalidFormat.getPath().stream() + .map(ref -> ref.getFieldName() != null ? ref.getFieldName() : "?") + .collect(Collectors.joining(".")); + + String valueIndicationMessage = ""; + if (invalidFormat.getTargetType().isEnum()) { + Class enumClass = invalidFormat.getTargetType(); + String enumValues = Arrays.stream(enumClass.getEnumConstants()) + .map(Object::toString) + .collect(Collectors.joining(", ")); + valueIndicationMessage = "사용 가능한 값 : [" + enumValues + "]"; + } + + String expectedType = invalidFormat.getTargetType().getSimpleName(); + Object value = invalidFormat.getValue(); + + errorMessage = String.format("필드 '%s'의 값 '%s'이(가) 예상 타입(%s)과 일치하지 않습니다. %s", + fieldName, value, expectedType, valueIndicationMessage); + + } else if (rootCause instanceof MismatchedInputException mismatchedInput) { + String fieldPath = mismatchedInput.getPath().stream() + .map(ref -> ref.getFieldName() != null ? ref.getFieldName() : "?") + .collect(Collectors.joining(".")); + errorMessage = String.format("필수 필드 '%s'이(가) 누락되었습니다.", fieldPath); + + } else if (rootCause instanceof JsonMappingException jsonMapping) { + String fieldPath = jsonMapping.getPath().stream() + .map(ref -> ref.getFieldName() != null ? ref.getFieldName() : "?") + .collect(Collectors.joining(".")); + errorMessage = String.format("필드 '%s'에서 JSON 매핑 오류가 발생했습니다: %s", + fieldPath, jsonMapping.getOriginalMessage()); + + } else { + errorMessage = "요청 본문을 처리하는 중 오류가 발생했습니다. JSON 메세지 규격을 확인해주세요."; + } + + return failureResponse(ErrorType.BAD_REQUEST, errorMessage); + } + + @ExceptionHandler + public ResponseEntity> handleBadRequest(ServerWebInputException e) { + String missingParams = extractMissingParameter(e.getReason() != null ? e.getReason() : ""); + if (!missingParams.isEmpty()) { + String message = String.format("필수 요청 값 '%s'가 누락되었습니다.", missingParams); + return failureResponse(ErrorType.BAD_REQUEST, message); + } else { + return failureResponse(ErrorType.BAD_REQUEST, null); + } + } + + @ExceptionHandler + public ResponseEntity> handleNotFound(NoResourceFoundException e) { + return failureResponse(ErrorType.NOT_FOUND, null); + } + + @ExceptionHandler + public ResponseEntity> handle(Throwable e) { + log.error("Exception : {}", e.getMessage(), e); + return failureResponse(ErrorType.INTERNAL_ERROR, null); + } + + private String extractMissingParameter(String message) { + Pattern pattern = Pattern.compile("'(.+?)'"); + Matcher matcher = pattern.matcher(message); + return matcher.find() ? matcher.group(1) : ""; + } + + private ResponseEntity> failureResponse(ErrorType errorType, String errorMessage) { + HttpStatus httpStatus = toHttpStatus(errorType); + return ResponseEntity.status(httpStatus) + .body(ApiResponse.fail(errorType.getCode(), errorMessage != null ? errorMessage : errorType.getMessage())); + } + + private HttpStatus toHttpStatus(ErrorType errorType) { + return switch (errorType) { + case BAD_REQUEST -> HttpStatus.BAD_REQUEST; + case NOT_FOUND -> HttpStatus.NOT_FOUND; + case CONFLICT -> HttpStatus.CONFLICT; + case UNAUTHORIZED -> HttpStatus.UNAUTHORIZED; + case INTERNAL_ERROR -> HttpStatus.INTERNAL_SERVER_ERROR; + }; + } +} diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/ApiResponse.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/ApiResponse.java new file mode 100644 index 000000000..33b77b529 --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/ApiResponse.java @@ -0,0 +1,32 @@ +package com.loopers.interfaces.api; + +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 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 + ); + } +} diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java new file mode 100644 index 000000000..219e3101e --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java @@ -0,0 +1,19 @@ +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/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java new file mode 100644 index 000000000..917376016 --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java @@ -0,0 +1,28 @@ +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/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java new file mode 100644 index 000000000..4ecf0eea5 --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java @@ -0,0 +1,15 @@ +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/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberController.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberController.java new file mode 100644 index 000000000..6527b6958 --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberController.java @@ -0,0 +1,41 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.application.service.MemberService; +import com.loopers.application.service.dto.RegisterMemberCommand; +import com.loopers.application.service.dto.UpdatePasswordCommand; +import com.loopers.interfaces.api.member.dto.MemberApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/members") +@RequiredArgsConstructor +public class MemberController { + + private final MemberService memberService; + + @PostMapping("/register") + @ResponseStatus(HttpStatus.CREATED) + public void register(@RequestBody RegisterMemberCommand request) { + memberService.register(request); + } + + @GetMapping("/me") + public MemberApiResponse getMyInfo( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password + ) { + return MemberApiResponse.from(memberService.getMyInfo(loginId, password)); + } + + @PatchMapping("/password") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void updatePassword( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String currentPassword, + @RequestBody UpdatePasswordCommand request + ) { + memberService.updatePassword(loginId, currentPassword, request.newPassword()); + } +} diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/member/dto/MemberApiResponse.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/member/dto/MemberApiResponse.java new file mode 100644 index 000000000..7b52ec2c7 --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/member/dto/MemberApiResponse.java @@ -0,0 +1,32 @@ +package com.loopers.interfaces.api.member.dto; + +import com.loopers.application.service.dto.MemberInfo; + +import java.time.LocalDate; + +public record MemberApiResponse( + String loginId, + String name, + LocalDate birthdate, + String email +) { + + public static MemberApiResponse from(MemberInfo info) { + return new MemberApiResponse( + info.loginId().getValue(), + maskName(info.name().getValue()), + info.birthdate(), + info.email().getValue() + ); + } + + private static String maskName(String name) { + if (name == null || name.isEmpty()) { + return ""; + } + if (name.length() == 1) { + return "*"; + } + return name.substring(0, name.length() - 1) + "*"; + } +} diff --git a/presentation/commerce-api/src/main/resources/application.yml b/presentation/commerce-api/src/main/resources/application.yml new file mode 100644 index 000000000..484c070d0 --- /dev/null +++ b/presentation/commerce-api/src/main/resources/application.yml @@ -0,0 +1,58 @@ +server: + shutdown: graceful + tomcat: + threads: + max: 200 # 최대 워커 스레드 수 (default : 200) + min-spare: 10 # 최소 유지 스레드 수 (default : 10) + connection-timeout: 1m # 연결 타임아웃 (ms) (default : 60000ms = 1m) + max-connections: 8192 # 최대 동시 연결 수 (default : 8192) + accept-count: 100 # 대기 큐 크기 (default : 100) + keep-alive-timeout: 60s # 60s + max-http-request-header-size: 8KB + +spring: + main: + web-application-type: servlet + application: + name: commerce-api + profiles: + active: local + config: + import: + - jpa.yml + - redis.yml + - logging.yml + - monitoring.yml + +springdoc: + use-fqn: true + swagger-ui: + path: /swagger-ui.html + +--- +spring: + config: + activate: + on-profile: local, test + +--- +spring: + config: + activate: + on-profile: dev + +--- +spring: + config: + activate: + on-profile: qa + +--- +spring: + config: + activate: + on-profile: prd + +springdoc: + api-docs: + enabled: false \ No newline at end of file diff --git a/presentation/commerce-api/src/test/java/com/loopers/CommerceApiContextTest.java b/presentation/commerce-api/src/test/java/com/loopers/CommerceApiContextTest.java new file mode 100644 index 000000000..a485bafe8 --- /dev/null +++ b/presentation/commerce-api/src/test/java/com/loopers/CommerceApiContextTest.java @@ -0,0 +1,14 @@ +package com.loopers; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class CommerceApiContextTest { + + @Test + void contextLoads() { + // 이 테스트는 Spring Boot 애플리케이션 컨텍스트가 로드되는지 확인합니다. + // 모든 빈이 올바르게 로드되었는지 확인하는 데 사용됩니다. + } +} diff --git a/presentation/commerce-api/src/test/java/com/loopers/application/MemberServiceIntegrationTest.java b/presentation/commerce-api/src/test/java/com/loopers/application/MemberServiceIntegrationTest.java new file mode 100644 index 000000000..ffdf45b81 --- /dev/null +++ b/presentation/commerce-api/src/test/java/com/loopers/application/MemberServiceIntegrationTest.java @@ -0,0 +1,184 @@ +package com.loopers.application; + +import com.loopers.application.service.MemberService; +import com.loopers.application.service.dto.RegisterMemberCommand; +import com.loopers.application.service.dto.MemberInfo; +import com.loopers.domain.member.MemberExceptionMessage; +import com.loopers.domain.member.MemberRepository; +import com.loopers.domain.member.PasswordEncryptor; +import com.loopers.domain.member.vo.Email; +import com.loopers.domain.member.vo.LoginId; +import com.loopers.domain.member.vo.MemberName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest +@Transactional +class MemberServiceIntegrationTest { + + @Autowired + private MemberService memberService; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private PasswordEncryptor passwordEncryptor; + + private static final LocalDate BIRTH_DATE = LocalDate.of(2001, 2, 9); + + private void 회원을_등록한다(String loginId, String password) { + memberService.register(new RegisterMemberCommand( + loginId, password, "공명선", BIRTH_DATE, "test@loopers.com")); + } + + @Test + void 회원가입_성공() { + // given + String inputId = "integrationId123"; + RegisterMemberCommand request = new RegisterMemberCommand( + inputId, "Pass!1234", "공명선", BIRTH_DATE, "test@loopers.com"); + + // when + memberService.register(request); + + // then + assertThat(memberRepository.existsByLoginId(inputId)).isTrue(); + } + + @Test + void 회원가입_시_중복_아이디_사용_불가() { + // given + String duplicateId = "existingId"; + 회원을_등록한다(duplicateId, "encodedPw1!"); + + RegisterMemberCommand request = new RegisterMemberCommand( + duplicateId, "NewPass!123", "신규유저", LocalDate.of(2000, 1, 1), "new@test.com"); + + // when & then + assertThatThrownBy(() -> memberService.register(request)) + .hasMessage(MemberExceptionMessage.LoginId.DUPLICATE_ID_EXISTS.message()); + } + + @Test + void 내_정보_조회_성공_loginId_반환() { + // given + String loginId = "tester123"; + String password = "password123!"; + 회원을_등록한다(loginId, password); + + // when + MemberInfo response = memberService.getMyInfo(loginId, password); + + // then + assertThat(response.loginId()).isEqualTo(LoginId.of(loginId)); + } + + @Test + void 내_정보_조회_성공_이름_반환() { + // given + String loginId = "tester123"; + String password = "password123!"; + 회원을_등록한다(loginId, password); + + // when + MemberInfo response = memberService.getMyInfo(loginId, password); + + // then + assertThat(response.name()).isEqualTo(MemberName.of("공명선")); + } + + @Test + void 내_정보_조회_성공_이메일_반환() { + // given + String loginId = "tester123"; + String password = "password123!"; + 회원을_등록한다(loginId, password); + + // when + MemberInfo response = memberService.getMyInfo(loginId, password); + + // then + assertThat(response.email()).isEqualTo(Email.of("test@loopers.com")); + } + + @Test + void 내_정보_조회_실패_비밀번호_불일치() { + // given + String loginId = "tester123"; + 회원을_등록한다(loginId, "password123!"); + + // when & then + assertThatThrownBy(() -> memberService.getMyInfo(loginId, "wrongPassword1!")) + .hasMessage(MemberExceptionMessage.ExistsMember.CANNOT_LOGIN.message()); + } + + @Test + void 내_정보_조회_실패_존재하지_않는_아이디() { + // given + String unknownId = "nobody12"; + + // when & then + assertThatThrownBy(() -> memberService.getMyInfo(unknownId, "anyPassword1!")) + .hasMessage(MemberExceptionMessage.ExistsMember.CANNOT_LOGIN.message()); + } + + @Test + void 비밀번호_수정_성공() { + // given + String loginId = "tester123"; + String currentPw = "oldPass123!"; + String newPw = "newPass5678@"; + 회원을_등록한다(loginId, currentPw); + + // when + memberService.updatePassword(loginId, currentPw, newPw); + + // then + assertThat(memberRepository.findByLoginId(loginId).orElseThrow() + .getPassword().matches(newPw, passwordEncryptor) + ).isTrue(); + } + + @Test + void 비밀번호_수정_실패_현재_비밀번호와_동일() { + // given + String loginId = "tester123"; + String currentPw = "oldPass123!"; + 회원을_등록한다(loginId, currentPw); + + // when & then + assertThatThrownBy(() -> memberService.updatePassword(loginId, currentPw, currentPw)) + .hasMessage(MemberExceptionMessage.Password.PASSWORD_CANNOT_BE_SAME_AS_CURRENT.message()); + } + + @Test + void 비밀번호_수정_실패_생년월일_포함() { + // given + String loginId = "tester123"; + String currentPw = "oldPass123!"; + 회원을_등록한다(loginId, currentPw); + + // when & then + assertThatThrownBy(() -> memberService.updatePassword(loginId, currentPw, "pass20010209!")) + .hasMessage(MemberExceptionMessage.Password.PASSWORD_CONTAINS_BIRTHDATE.message()); + } + + @Test + void 비밀번호_수정_실패_현재_비밀번호_불일치() { + // given + String loginId = "tester123"; + 회원을_등록한다(loginId, "correct123!"); + + // when & then + assertThatThrownBy(() -> memberService.updatePassword(loginId, "wrong123!!", "newPass123!")) + .hasMessage(MemberExceptionMessage.Password.PASSWORD_INCORRECT.message()); + } +} diff --git a/presentation/commerce-api/src/test/java/com/loopers/controller/MemberE2ETest.java b/presentation/commerce-api/src/test/java/com/loopers/controller/MemberE2ETest.java new file mode 100644 index 000000000..a6553f54a --- /dev/null +++ b/presentation/commerce-api/src/test/java/com/loopers/controller/MemberE2ETest.java @@ -0,0 +1,123 @@ +package com.loopers.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.service.dto.RegisterMemberCommand; +import com.loopers.application.service.dto.UpdatePasswordCommand; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +public class MemberE2ETest { + + private static final String LOGIN_ID = "loopers123"; + private static final String INITIAL_PW = "Initial!1234"; + private static final String NEW_PW = "Updated!5678"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Test + @DisplayName("회원가입 성공 시 201 Created를 반환한다") + void 회원가입_성공() throws Exception { + // given + RegisterMemberCommand request = createRegisterRequest(); + + // when & then + mockMvc.perform(post("/api/members/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()); + } + + @Test + @DisplayName("내 정보 조회 시 마스킹된 이름이 반환된다") + void 내_정보_조회_마스킹된_이름_반환() throws Exception { + // given + registerMember(); + + // when & then + mockMvc.perform(get("/api/members/me") + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", INITIAL_PW)) + .andExpect(jsonPath("$.name").value("공명*")); + } + + @Test + @DisplayName("비밀번호 변경 성공 시 204 No Content를 반환한다") + void 비밀번호_변경_성공() throws Exception { + // given + registerMember(); + + // when & then + mockMvc.perform(patch("/api/members/password") + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", INITIAL_PW) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new UpdatePasswordCommand(NEW_PW)))) + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("변경된 비밀번호로 내 정보 조회가 성공한다") + void 변경된_비밀번호로_조회_성공() throws Exception { + // given + registerMember(); + changePassword(); + + // when & then + mockMvc.perform(get("/api/members/me") + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", NEW_PW)) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("비밀번호 변경 후 기존 비밀번호로 조회하면 401 Unauthorized를 반환한다") + void 기존_비밀번호로_조회_실패() throws Exception { + // given + registerMember(); + changePassword(); + + // when & then + mockMvc.perform(get("/api/members/me") + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", INITIAL_PW)) + .andExpect(status().isUnauthorized()); + } + + private RegisterMemberCommand createRegisterRequest() { + return new RegisterMemberCommand( + LOGIN_ID, INITIAL_PW, "공명선", LocalDate.of(2001, 2, 9), "test@loopers.com" + ); + } + + private void registerMember() throws Exception { + mockMvc.perform(post("/api/members/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createRegisterRequest()))); + } + + private void changePassword() throws Exception { + mockMvc.perform(patch("/api/members/password") + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", INITIAL_PW) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new UpdatePasswordCommand(NEW_PW)))); + } +} diff --git a/presentation/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java b/presentation/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java new file mode 100644 index 000000000..bbd5fdbe1 --- /dev/null +++ b/presentation/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java @@ -0,0 +1,72 @@ +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/presentation/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java b/presentation/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java new file mode 100644 index 000000000..1bb3dba65 --- /dev/null +++ b/presentation/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java @@ -0,0 +1,114 @@ +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) + ); + } + } +} diff --git a/presentation/commerce-batch/build.gradle.kts b/presentation/commerce-batch/build.gradle.kts new file mode 100644 index 000000000..852a06dd3 --- /dev/null +++ b/presentation/commerce-batch/build.gradle.kts @@ -0,0 +1,23 @@ +apply(plugin = "org.springframework.boot") + +dependencies { + // add-ons + implementation(project(":infrastructure:jpa")) + implementation(project(":infrastructure:redis")) + implementation(project(":supports:jackson")) + implementation(project(":supports:logging")) + implementation(project(":supports:monitoring")) + + // batch + implementation("org.springframework.boot:spring-boot-starter-batch") + testImplementation("org.springframework.batch:spring-batch-test") + + // querydsl + annotationProcessor("com.querydsl:querydsl-apt::jakarta") + annotationProcessor("jakarta.persistence:jakarta.persistence-api") + annotationProcessor("jakarta.annotation:jakarta.annotation-api") + + // test-fixtures + testImplementation(testFixtures(project(":infrastructure:jpa"))) + testImplementation(testFixtures(project(":infrastructure:redis"))) +} diff --git a/presentation/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java b/presentation/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java new file mode 100644 index 000000000..e5005c373 --- /dev/null +++ b/presentation/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java @@ -0,0 +1,24 @@ +package com.loopers; + +import jakarta.annotation.PostConstruct; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; + +import java.util.TimeZone; + +@ConfigurationPropertiesScan +@SpringBootApplication +public class CommerceBatchApplication { + + @PostConstruct + public void started() { + // set timezone + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); + } + + public static void main(String[] args) { + int exitCode = SpringApplication.exit(SpringApplication.run(CommerceBatchApplication.class, args)); + System.exit(exitCode); + } +} diff --git a/presentation/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java b/presentation/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java new file mode 100644 index 000000000..7c486483f --- /dev/null +++ b/presentation/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java @@ -0,0 +1,48 @@ +package com.loopers.batch.job.demo; + +import com.loopers.batch.job.demo.step.DemoTasklet; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.JobScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.support.transaction.ResourcelessTransactionManager; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = DemoJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Configuration +public class DemoJobConfig { + public static final String JOB_NAME = "demoJob"; + private static final String STEP_DEMO_SIMPLE_TASK_NAME = "demoSimpleTask"; + + private final JobRepository jobRepository; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final DemoTasklet demoTasklet; + + @Bean(JOB_NAME) + public Job demoJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(categorySyncStep()) + .listener(jobListener) + .build(); + } + + @JobScope + @Bean(STEP_DEMO_SIMPLE_TASK_NAME) + public Step categorySyncStep() { + return new StepBuilder(STEP_DEMO_SIMPLE_TASK_NAME, jobRepository) + .tasklet(demoTasklet, new ResourcelessTransactionManager()) + .listener(stepMonitorListener) + .build(); + } +} diff --git a/presentation/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java b/presentation/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java new file mode 100644 index 000000000..800fe5a03 --- /dev/null +++ b/presentation/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java @@ -0,0 +1,32 @@ +package com.loopers.batch.job.demo.step; + +import com.loopers.batch.job.demo.DemoJobConfig; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +@StepScope +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = DemoJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Component +public class DemoTasklet implements Tasklet { + @Value("#{jobParameters['requestDate']}") + private String requestDate; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception { + if (requestDate == null) { + throw new RuntimeException("requestDate is null"); + } + System.out.println("Demo Tasklet 실행 (실행 일자 : " + requestDate + ")"); + Thread.sleep(1000); + System.out.println("Demo Tasklet 작업 완료"); + return RepeatStatus.FINISHED; + } +} diff --git a/presentation/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java b/presentation/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java new file mode 100644 index 000000000..10b09b8fc --- /dev/null +++ b/presentation/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java @@ -0,0 +1,21 @@ +package com.loopers.batch.listener; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.annotation.AfterChunk; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.stereotype.Component; + +@Slf4j +@RequiredArgsConstructor +@Component +public class ChunkListener { + + @AfterChunk + void afterChunk(ChunkContext chunkContext) { + log.info( + "청크 종료: readCount: ${chunkContext.stepContext.stepExecution.readCount}, " + + "writeCount: ${chunkContext.stepContext.stepExecution.writeCount}" + ); + } +} diff --git a/presentation/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java b/presentation/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java new file mode 100644 index 000000000..cb5c8bebd --- /dev/null +++ b/presentation/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java @@ -0,0 +1,53 @@ +package com.loopers.batch.listener; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.annotation.AfterJob; +import org.springframework.batch.core.annotation.BeforeJob; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; + +@Slf4j +@RequiredArgsConstructor +@Component +public class JobListener { + + @BeforeJob + void beforeJob(JobExecution jobExecution) { + log.info("Job '${jobExecution.jobInstance.jobName}' 시작"); + jobExecution.getExecutionContext().putLong("startTime", System.currentTimeMillis()); + } + + @AfterJob + void afterJob(JobExecution jobExecution) { + var startTime = jobExecution.getExecutionContext().getLong("startTime"); + var endTime = System.currentTimeMillis(); + + var startDateTime = Instant.ofEpochMilli(startTime) + .atZone(ZoneId.systemDefault()) + .toLocalDateTime(); + var endDateTime = Instant.ofEpochMilli(endTime) + .atZone(ZoneId.systemDefault()) + .toLocalDateTime(); + + var totalTime = endTime - startTime; + var duration = Duration.ofMillis(totalTime); + var hours = duration.toHours(); + var minutes = duration.toMinutes() % 60; + var seconds = duration.getSeconds() % 60; + + var message = String.format( + """ + *Start Time:* %s + *End Time:* %s + *Total Time:* %d시간 %d분 %d초 + """, startDateTime, endDateTime, hours, minutes, seconds + ).trim(); + + log.info(message); + } +} diff --git a/presentation/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java b/presentation/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java new file mode 100644 index 000000000..4f22f40b0 --- /dev/null +++ b/presentation/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java @@ -0,0 +1,44 @@ +package com.loopers.batch.listener; + +import jakarta.annotation.Nonnull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.core.StepExecutionListener; +import org.springframework.stereotype.Component; +import java.util.Objects; +import java.util.stream.Collectors; + +@Slf4j +@RequiredArgsConstructor +@Component +public class StepMonitorListener implements StepExecutionListener { + + @Override + public void beforeStep(@Nonnull StepExecution stepExecution) { + log.info("Step '{}' 시작", stepExecution.getStepName()); + } + + @Override + public ExitStatus afterStep(@Nonnull StepExecution stepExecution) { + if (!stepExecution.getFailureExceptions().isEmpty()) { + var jobName = stepExecution.getJobExecution().getJobInstance().getJobName(); + var exceptions = stepExecution.getFailureExceptions().stream() + .map(Throwable::getMessage) + .filter(Objects::nonNull) + .collect(Collectors.joining("\n")); + log.info( + """ + [에러 발생] + jobName: {} + exceptions: + {} + """.trim(), jobName, exceptions + ); + // error 발생 시 slack 등 다른 채널로 모니터 전송 + return ExitStatus.FAILED; + } + return ExitStatus.COMPLETED; + } +} diff --git a/presentation/commerce-batch/src/main/resources/application.yml b/presentation/commerce-batch/src/main/resources/application.yml new file mode 100644 index 000000000..9aa0d760a --- /dev/null +++ b/presentation/commerce-batch/src/main/resources/application.yml @@ -0,0 +1,54 @@ +spring: + main: + web-application-type: none + application: + name: commerce-batch + profiles: + active: local + config: + import: + - jpa.yml + - redis.yml + - logging.yml + - monitoring.yml + batch: + job: + name: ${job.name:NONE} + jdbc: + initialize-schema: never + +management: + health: + defaults: + enabled: false + +--- +spring: + config: + activate: + on-profile: local, test + batch: + jdbc: + initialize-schema: always + +--- +spring: + config: + activate: + on-profile: dev + +--- +spring: + config: + activate: + on-profile: qa + +--- +spring: + config: + activate: + on-profile: prd + +springdoc: + api-docs: + enabled: false \ No newline at end of file diff --git a/presentation/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java b/presentation/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java new file mode 100644 index 000000000..c5e3bc7a3 --- /dev/null +++ b/presentation/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java @@ -0,0 +1,10 @@ +package com.loopers; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +public class CommerceBatchApplicationTest { + @Test + void contextLoads() {} +} diff --git a/presentation/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java b/presentation/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java new file mode 100644 index 000000000..dafe59a18 --- /dev/null +++ b/presentation/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java @@ -0,0 +1,76 @@ +package com.loopers.job.demo; + +import com.loopers.batch.job.demo.DemoJobConfig; +import lombok.RequiredArgsConstructor; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +import java.time.LocalDate; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +@SpringBatchTest +@TestPropertySource(properties = "spring.batch.job.name=" + DemoJobConfig.JOB_NAME) +class DemoJobE2ETest { + + // IDE 정적 분석 상 [SpringBatchTest] 의 주입보다 [SpringBootTest] 의 주입이 우선되어, 해당 컴포넌트는 없으므로 오류처럼 보일 수 있음. + // [SpringBatchTest] 자체가 Scope 기반으로 주입하기 때문에 정상 동작함. + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + @Qualifier(DemoJobConfig.JOB_NAME) + private Job job; + + @BeforeEach + void beforeEach() { + + } + + @DisplayName("jobParameter 중 requestDate 인자가 주어지지 않았을 때, demoJob 배치는 실패한다.") + @Test + void shouldNotSaveCategories_whenApiError() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + // act + var jobExecution = jobLauncherTestUtils.launchJob(); + + // assert + assertAll( + () -> assertThat(jobExecution).isNotNull(), + () -> assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.FAILED.getExitCode()) + ); + } + + @DisplayName("demoJob 배치가 정상적으로 실행된다.") + @Test + void success() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + // act + var jobParameters = new JobParametersBuilder() + .addLocalDate("requestDate", LocalDate.now()) + .toJobParameters(); + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // assert + assertAll( + () -> assertThat(jobExecution).isNotNull(), + () -> assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()) + ); + } +} diff --git a/presentation/commerce-streamer/build.gradle.kts b/presentation/commerce-streamer/build.gradle.kts new file mode 100644 index 000000000..eaa862431 --- /dev/null +++ b/presentation/commerce-streamer/build.gradle.kts @@ -0,0 +1,25 @@ +apply(plugin = "org.springframework.boot") + +dependencies { + // add-ons + implementation(project(":infrastructure:jpa")) + implementation(project(":infrastructure:redis")) + implementation(project(":infrastructure:kafka")) + implementation(project(":supports:jackson")) + implementation(project(":supports:logging")) + implementation(project(":supports:monitoring")) + + // web + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-actuator") + + // querydsl + annotationProcessor("com.querydsl:querydsl-apt::jakarta") + annotationProcessor("jakarta.persistence:jakarta.persistence-api") + annotationProcessor("jakarta.annotation:jakarta.annotation-api") + + // test-fixtures + testImplementation(testFixtures(project(":infrastructure:jpa"))) + testImplementation(testFixtures(project(":infrastructure:redis"))) + testImplementation(testFixtures(project(":infrastructure:kafka"))) +} diff --git a/presentation/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java b/presentation/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java new file mode 100644 index 000000000..ea4b4d15a --- /dev/null +++ b/presentation/commerce-streamer/src/main/java/com/loopers/CommerceStreamerApplication.java @@ -0,0 +1,24 @@ +package com.loopers; + +import jakarta.annotation.PostConstruct; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; + +import java.util.TimeZone; + +@ConfigurationPropertiesScan +@SpringBootApplication +public class CommerceStreamerApplication { + @PostConstruct + public void started() { + // set timezone + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); + } + + public static void main(String[] args) { + SpringApplication.run(CommerceStreamerApplication.class, args); + } +} + + diff --git a/presentation/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DemoKafkaConsumer.java b/presentation/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DemoKafkaConsumer.java new file mode 100644 index 000000000..df5122d5a --- /dev/null +++ b/presentation/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DemoKafkaConsumer.java @@ -0,0 +1,24 @@ +package com.loopers.interfaces.consumer; + +import com.loopers.config.kafka.KafkaConfig; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class DemoKafkaConsumer { + @KafkaListener( + topics = {"${demo-kafka.test.topic-name}"}, + containerFactory = KafkaConfig.BATCH_LISTENER + ) + public void demoListener( + List> messages, + Acknowledgment acknowledgment + ){ + System.out.println(messages); + acknowledgment.acknowledge(); + } +} diff --git a/presentation/commerce-streamer/src/main/resources/application.yml b/presentation/commerce-streamer/src/main/resources/application.yml new file mode 100644 index 000000000..0651bc2bd --- /dev/null +++ b/presentation/commerce-streamer/src/main/resources/application.yml @@ -0,0 +1,58 @@ +server: + shutdown: graceful + tomcat: + threads: + max: 200 # 최대 워커 스레드 수 (default : 200) + min-spare: 10 # 최소 유지 스레드 수 (default : 10) + connection-timeout: 1m # 연결 타임아웃 (ms) (default : 60000ms = 1m) + max-connections: 8192 # 최대 동시 연결 수 (default : 8192) + accept-count: 100 # 대기 큐 크기 (default : 100) + keep-alive-timeout: 60s # 60s + max-http-request-header-size: 8KB + +spring: + main: + web-application-type: servlet + application: + name: commerce-api + profiles: + active: local + config: + import: + - jpa.yml + - redis.yml + - kafka.yml + - logging.yml + - monitoring.yml + +demo-kafka: + test: + topic-name: demo.internal.topic-v1 + +--- +spring: + config: + activate: + on-profile: local, test + +--- +spring: + config: + activate: + on-profile: dev + +--- +spring: + config: + activate: + on-profile: qa + +--- +spring: + config: + activate: + on-profile: prd + +springdoc: + api-docs: + enabled: false \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index a2c303835..6e52eb3f6 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,15 +1,19 @@ rootProject.name = "loopers-java-spring-template" include( - ":apps:commerce-api", - ":apps:commerce-streamer", - ":apps:commerce-batch", - ":modules:jpa", - ":modules:redis", - ":modules:kafka", + ":domain", + ":application:commerce-service", + ":presentation:commerce-api", + ":presentation:commerce-batch", + ":presentation:commerce-streamer", + ":infrastructure:jpa", + ":infrastructure:redis", + ":infrastructure:kafka", + ":infrastructure:security", ":supports:jackson", ":supports:logging", ":supports:monitoring", + ":supports:error", ) // configurations diff --git a/supports/error/build.gradle.kts b/supports/error/build.gradle.kts new file mode 100644 index 000000000..d461d4b44 --- /dev/null +++ b/supports/error/build.gradle.kts @@ -0,0 +1,5 @@ +plugins { + `java-library` +} + +// 순수 Java 모듈 — Spring 의존 없음 diff --git a/supports/error/src/main/java/com/loopers/support/error/CoreException.java b/supports/error/src/main/java/com/loopers/support/error/CoreException.java new file mode 100644 index 000000000..0cc190b6b --- /dev/null +++ b/supports/error/src/main/java/com/loopers/support/error/CoreException.java @@ -0,0 +1,19 @@ +package com.loopers.support.error; + +import lombok.Getter; + +@Getter +public class CoreException extends RuntimeException { + private final ErrorType errorType; + private final String customMessage; + + public CoreException(ErrorType errorType) { + this(errorType, null); + } + + public CoreException(ErrorType errorType, String customMessage) { + super(customMessage != null ? customMessage : errorType.getMessage()); + this.errorType = errorType; + this.customMessage = customMessage; + } +} diff --git a/supports/error/src/main/java/com/loopers/support/error/ErrorType.java b/supports/error/src/main/java/com/loopers/support/error/ErrorType.java new file mode 100644 index 000000000..8791c5b01 --- /dev/null +++ b/supports/error/src/main/java/com/loopers/support/error/ErrorType.java @@ -0,0 +1,17 @@ +package com.loopers.support.error; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ErrorType { + INTERNAL_ERROR("Internal Server Error", "일시적인 오류가 발생했습니다."), + BAD_REQUEST("Bad Request", "잘못된 요청입니다."), + NOT_FOUND("Not Found", "존재하지 않는 요청입니다."), + CONFLICT("Conflict", "이미 존재하는 리소스입니다."), + UNAUTHORIZED("Unauthorized", "인증에 실패했습니다."); + + private final String code; + private final String message; +} From e87dd403565fed6e2e8165e11406e1134c18d3e1 Mon Sep 17 00:00:00 2001 From: APapeIsName Date: Thu, 26 Feb 2026 22:40:39 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20Brand/Product=20=EA=B8=B0=EB=8A=A5?= =?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 --- .../application/service/BrandService.java | 76 +++++++ .../application/service/MemberService.java | 9 +- .../application/service/ProductService.java | 127 +++++++++++ .../service/dto/BrandCreateCommand.java | 6 + .../application/service/dto/BrandInfo.java | 12 + .../service/dto/BrandUpdateCommand.java | 6 + .../application/service/dto/MemberInfo.java | 1 + ...ommand.java => MemberRegisterCommand.java} | 2 +- ...ommand.java => PasswordUpdateCommand.java} | 2 +- .../service/dto/ProductCreateCommand.java | 10 + .../application/service/dto/ProductInfo.java | 12 + .../service/dto/ProductUpdateCommand.java | 9 + .../loopers/application/BrandServiceTest.java | 171 ++++++++++++++ .../application/MemberServiceTest.java | 6 +- .../application/ProductServiceTest.java | 210 ++++++++++++++++++ docs/design/02-sequence-diagrams.md | 60 +++-- docs/design/03-class-diagram.md | 37 +-- docs/design/04-erd.md | 57 +++-- docs/design/05-domain-model.md | 10 +- docs/design/06-architecture.md | 52 +++-- docs/planning/brand-plan.md | 36 +-- docs/planning/product-plan.md | 69 +++--- docs/thought/architecture-direction-v2.md | 4 +- docs/thought/volume3-discussion-log.md | 35 ++- domain/build.gradle.kts | 7 + .../java/com/loopers/domain/BaseEntity.java | 8 +- .../domain/catalog/BrandDeleteService.java | 27 +++ .../loopers/domain/catalog/brand/Brand.java | 56 +++++ .../catalog/brand/BrandExceptionMessage.java | 21 ++ .../domain/catalog/brand/BrandRepository.java | 19 ++ .../domain/catalog/product/Product.java | 92 ++++++++ .../product/ProductExceptionMessage.java | 57 +++++ .../catalog/product/ProductRepository.java | 17 ++ .../catalog/product/ProductSortType.java | 7 + .../domain/catalog/product/vo/Money.java | 45 ++++ .../domain/catalog/product/vo/Quantity.java | 35 +++ .../domain/catalog/product/vo/Stock.java | 61 +++++ .../com/loopers/domain/catalog/vo/Name.java | 48 ++++ .../com/loopers/domain/member/Member.java | 17 +- .../catalog/BrandDeleteServiceTest.java | 72 ++++++ .../domain/catalog/brand/BrandTest.java | 160 +++++++++++++ .../domain/catalog/product/ProductTest.java | 115 ++++++++++ .../domain/catalog/product/vo/MoneyTest.java | 56 +++++ .../catalog/product/vo/QuantityTest.java | 33 +++ .../domain/catalog/product/vo/StockTest.java | 100 +++++++++ .../domain/member/vo/MemberIdTest.java | 12 - .../domain/catalog/brand/BrandFixture.java | 14 ++ .../catalog/product/ProductFixture.java | 18 ++ .../brand/BrandJpaRepository.java | 15 ++ .../brand/BrandRepositoryImpl.java | 46 ++++ .../product/ProductJpaRepository.java | 7 + .../product/ProductRepositoryImpl.java | 68 ++++++ .../api/brand/AdminBrandController.java | 48 ++++ .../interfaces/api/brand/BrandController.java | 25 +++ .../api/brand/dto/BrandApiResponse.java | 12 + .../api/brand/dto/BrandCreateApiRequest.java | 11 + .../api/brand/dto/BrandUpdateApiRequest.java | 11 + .../api/member/MemberController.java | 8 +- .../api/product/AdminProductController.java | 48 ++++ .../api/product/ProductController.java | 31 +++ .../api/product/dto/ProductApiResponse.java | 25 +++ .../product/dto/ProductCreateApiRequest.java | 15 ++ .../product/dto/ProductUpdateApiRequest.java | 14 ++ .../MemberServiceIntegrationTest.java | 10 +- .../com/loopers/controller/BrandE2ETest.java | 132 +++++++++++ .../com/loopers/controller/MemberE2ETest.java | 14 +- .../loopers/controller/ProductE2ETest.java | 131 +++++++++++ 67 files changed, 2563 insertions(+), 224 deletions(-) create mode 100644 application/commerce-service/src/main/java/com/loopers/application/service/BrandService.java create mode 100644 application/commerce-service/src/main/java/com/loopers/application/service/ProductService.java create mode 100644 application/commerce-service/src/main/java/com/loopers/application/service/dto/BrandCreateCommand.java create mode 100644 application/commerce-service/src/main/java/com/loopers/application/service/dto/BrandInfo.java create mode 100644 application/commerce-service/src/main/java/com/loopers/application/service/dto/BrandUpdateCommand.java rename application/commerce-service/src/main/java/com/loopers/application/service/dto/{RegisterMemberCommand.java => MemberRegisterCommand.java} (84%) rename application/commerce-service/src/main/java/com/loopers/application/service/dto/{UpdatePasswordCommand.java => PasswordUpdateCommand.java} (68%) create mode 100644 application/commerce-service/src/main/java/com/loopers/application/service/dto/ProductCreateCommand.java create mode 100644 application/commerce-service/src/main/java/com/loopers/application/service/dto/ProductInfo.java create mode 100644 application/commerce-service/src/main/java/com/loopers/application/service/dto/ProductUpdateCommand.java create mode 100644 application/commerce-service/src/test/java/com/loopers/application/BrandServiceTest.java create mode 100644 application/commerce-service/src/test/java/com/loopers/application/ProductServiceTest.java create mode 100644 domain/src/main/java/com/loopers/domain/catalog/BrandDeleteService.java create mode 100644 domain/src/main/java/com/loopers/domain/catalog/brand/Brand.java create mode 100644 domain/src/main/java/com/loopers/domain/catalog/brand/BrandExceptionMessage.java create mode 100644 domain/src/main/java/com/loopers/domain/catalog/brand/BrandRepository.java create mode 100644 domain/src/main/java/com/loopers/domain/catalog/product/Product.java create mode 100644 domain/src/main/java/com/loopers/domain/catalog/product/ProductExceptionMessage.java create mode 100644 domain/src/main/java/com/loopers/domain/catalog/product/ProductRepository.java create mode 100644 domain/src/main/java/com/loopers/domain/catalog/product/ProductSortType.java create mode 100644 domain/src/main/java/com/loopers/domain/catalog/product/vo/Money.java create mode 100644 domain/src/main/java/com/loopers/domain/catalog/product/vo/Quantity.java create mode 100644 domain/src/main/java/com/loopers/domain/catalog/product/vo/Stock.java create mode 100644 domain/src/main/java/com/loopers/domain/catalog/vo/Name.java create mode 100644 domain/src/test/java/com/loopers/domain/catalog/BrandDeleteServiceTest.java create mode 100644 domain/src/test/java/com/loopers/domain/catalog/brand/BrandTest.java create mode 100644 domain/src/test/java/com/loopers/domain/catalog/product/ProductTest.java create mode 100644 domain/src/test/java/com/loopers/domain/catalog/product/vo/MoneyTest.java create mode 100644 domain/src/test/java/com/loopers/domain/catalog/product/vo/QuantityTest.java create mode 100644 domain/src/test/java/com/loopers/domain/catalog/product/vo/StockTest.java create mode 100644 domain/src/testFixtures/java/com/loopers/domain/catalog/brand/BrandFixture.java create mode 100644 domain/src/testFixtures/java/com/loopers/domain/catalog/product/ProductFixture.java create mode 100644 infrastructure/jpa/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java create mode 100644 infrastructure/jpa/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java create mode 100644 infrastructure/jpa/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java create mode 100644 infrastructure/jpa/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java create mode 100644 presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/AdminBrandController.java create mode 100644 presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java create mode 100644 presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandApiResponse.java create mode 100644 presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandCreateApiRequest.java create mode 100644 presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandUpdateApiRequest.java create mode 100644 presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductController.java create mode 100644 presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java create mode 100644 presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductApiResponse.java create mode 100644 presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductCreateApiRequest.java create mode 100644 presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductUpdateApiRequest.java create mode 100644 presentation/commerce-api/src/test/java/com/loopers/controller/BrandE2ETest.java create mode 100644 presentation/commerce-api/src/test/java/com/loopers/controller/ProductE2ETest.java diff --git a/application/commerce-service/src/main/java/com/loopers/application/service/BrandService.java b/application/commerce-service/src/main/java/com/loopers/application/service/BrandService.java new file mode 100644 index 000000000..02ac993d9 --- /dev/null +++ b/application/commerce-service/src/main/java/com/loopers/application/service/BrandService.java @@ -0,0 +1,76 @@ +package com.loopers.application.service; + +import com.loopers.application.service.dto.BrandCreateCommand; +import com.loopers.application.service.dto.BrandInfo; +import com.loopers.application.service.dto.BrandUpdateCommand; +import com.loopers.domain.catalog.BrandDeleteService; +import com.loopers.domain.catalog.brand.Brand; +import com.loopers.domain.catalog.brand.BrandExceptionMessage; +import com.loopers.domain.catalog.brand.BrandRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class BrandService { + + private final BrandRepository brandRepository; + private final BrandDeleteService brandDeleteService; + + @Transactional + public void create(BrandCreateCommand command) { + if (brandRepository.existsByName(command.name())) { + throw new CoreException(ErrorType.CONFLICT, + BrandExceptionMessage.Brand.DUPLICATE_NAME.message()); + } + + Brand brand = Brand.register(command.name()); + brandRepository.save(brand); + } + + @Transactional(readOnly = true) + public BrandInfo getById(Long id) { + Brand brand = brandRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + BrandExceptionMessage.Brand.NOT_FOUND.message())); + return BrandInfo.from(brand); + } + + @Transactional(readOnly = true) + public List getAll() { + return brandRepository.findAll().stream() + .map(BrandInfo::from) + .toList(); + } + + @Transactional(readOnly = true) + public List getActiveBrands() { + return brandRepository.findAllByDeletedAtIsNull().stream() + .map(BrandInfo::from) + .toList(); + } + + @Transactional + public void update(Long id, BrandUpdateCommand command) { + Brand brand = brandRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + BrandExceptionMessage.Brand.NOT_FOUND.message())); + + if (brandRepository.existsByName(command.name())) { + throw new CoreException(ErrorType.CONFLICT, + BrandExceptionMessage.Brand.DUPLICATE_NAME.message()); + } + + brand.updateName(command.name()); + } + + @Transactional + public void delete(Long id) { + brandDeleteService.delete(id); + } +} diff --git a/application/commerce-service/src/main/java/com/loopers/application/service/MemberService.java b/application/commerce-service/src/main/java/com/loopers/application/service/MemberService.java index 7393ad344..4bba14675 100644 --- a/application/commerce-service/src/main/java/com/loopers/application/service/MemberService.java +++ b/application/commerce-service/src/main/java/com/loopers/application/service/MemberService.java @@ -1,6 +1,6 @@ package com.loopers.application.service; -import com.loopers.application.service.dto.RegisterMemberCommand; +import com.loopers.application.service.dto.MemberRegisterCommand; import com.loopers.application.service.dto.MemberInfo; import com.loopers.domain.member.Member; import com.loopers.domain.member.MemberExceptionMessage; @@ -21,7 +21,7 @@ public class MemberService { private final PasswordEncryptor passwordEncryptor; @Transactional - public void register(RegisterMemberCommand request) { + public void register(MemberRegisterCommand request) { boolean isLoginIdAlreadyExists = memberRepository.existsByLoginId(request.loginId()); if (isLoginIdAlreadyExists) { @@ -43,11 +43,12 @@ public MemberInfo getMyInfo(String userId, String password) { Member member = memberRepository.findByLoginId(userId) .orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED, MemberExceptionMessage.ExistsMember.CANNOT_LOGIN.message())); - if (!member.getPassword().matches(password, passwordEncryptor)) { + if (!member.matchesPassword(password, passwordEncryptor)) { throw new CoreException(ErrorType.UNAUTHORIZED, MemberExceptionMessage.ExistsMember.CANNOT_LOGIN.message()); } return new MemberInfo( + member.getId(), member.getLoginId(), member.getName(), member.getBirthDate(), @@ -60,7 +61,7 @@ public void updatePassword(String userId, String currentPassword, String newPass Member member = memberRepository.findByLoginId(userId) .orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED, MemberExceptionMessage.ExistsMember.CANNOT_LOGIN.message())); - if (!member.getPassword().matches(currentPassword, passwordEncryptor)) { + if (!member.matchesPassword(currentPassword, passwordEncryptor)) { throw new CoreException(ErrorType.UNAUTHORIZED, MemberExceptionMessage.Password.PASSWORD_INCORRECT.message()); } diff --git a/application/commerce-service/src/main/java/com/loopers/application/service/ProductService.java b/application/commerce-service/src/main/java/com/loopers/application/service/ProductService.java new file mode 100644 index 000000000..17cddaf8f --- /dev/null +++ b/application/commerce-service/src/main/java/com/loopers/application/service/ProductService.java @@ -0,0 +1,127 @@ +package com.loopers.application.service; + +import com.loopers.application.service.dto.ProductCreateCommand; +import com.loopers.application.service.dto.ProductInfo; +import com.loopers.application.service.dto.ProductUpdateCommand; +import com.loopers.domain.catalog.brand.Brand; +import com.loopers.domain.catalog.brand.BrandExceptionMessage; +import com.loopers.domain.catalog.brand.BrandRepository; +import com.loopers.domain.catalog.product.Product; +import com.loopers.domain.catalog.product.ProductExceptionMessage; +import com.loopers.domain.catalog.product.ProductRepository; +import com.loopers.domain.catalog.product.ProductSortType; +import com.loopers.domain.catalog.product.vo.Money; +import com.loopers.domain.catalog.product.vo.Stock; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class ProductService { + + private final ProductRepository productRepository; + private final BrandRepository brandRepository; + + @Transactional + public void create(ProductCreateCommand command) { + Brand brand = brandRepository.findById(command.brandId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + BrandExceptionMessage.Brand.NOT_FOUND.message())); + + if (brand.isDeleted()) { + throw new CoreException(ErrorType.BAD_REQUEST, + BrandExceptionMessage.Brand.ALREADY_DELETED.message()); + } + + Product product = Product.register( + command.name(), + command.description(), + Money.of(command.price()), + Stock.of(command.stock()), + command.brandId() + ); + productRepository.save(product); + } + + @Transactional(readOnly = true) + public ProductInfo getById(Long id) { + Product product = productRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + ProductExceptionMessage.Product.NOT_FOUND.message())); + + Brand brand = brandRepository.findById(product.getBrandId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + BrandExceptionMessage.Brand.NOT_FOUND.message())); + + return toProductInfo(product, brand); + } + + @Transactional(readOnly = true) + public List getAll() { + List products = productRepository.findAll(); + return toProductInfos(products); + } + + @Transactional(readOnly = true) + public List getActiveProducts(ProductSortType sortType) { + List products = productRepository.findAllActive(sortType); + return toProductInfos(products); + } + + @Transactional + public void update(Long id, ProductUpdateCommand command) { + Product product = productRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + ProductExceptionMessage.Product.NOT_FOUND.message())); + + product.update( + command.name(), + command.description(), + Money.of(command.price()), + Stock.of(command.stock()) + ); + } + + @Transactional + public void delete(Long id) { + Product product = productRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + ProductExceptionMessage.Product.NOT_FOUND.message())); + + product.delete(); + } + + private List toProductInfos(List products) { + List brandIds = products.stream() + .map(Product::getBrandId) + .distinct() + .toList(); + + Map brandMap = brandRepository.findAllByIdIn(brandIds).stream() + .collect(Collectors.toMap(Brand::getId, Function.identity())); + + return products.stream() + .map(product -> toProductInfo(product, brandMap.get(product.getBrandId()))) + .toList(); + } + + private ProductInfo toProductInfo(Product product, Brand brand) { + return new ProductInfo( + product.getId(), + product.getName().getValue(), + product.getDescription(), + product.getPrice().getValue(), + product.getStock().getValue(), + product.getLikesCount(), + brand != null ? brand.getName().getValue() : null + ); + } +} diff --git a/application/commerce-service/src/main/java/com/loopers/application/service/dto/BrandCreateCommand.java b/application/commerce-service/src/main/java/com/loopers/application/service/dto/BrandCreateCommand.java new file mode 100644 index 000000000..7cd1af049 --- /dev/null +++ b/application/commerce-service/src/main/java/com/loopers/application/service/dto/BrandCreateCommand.java @@ -0,0 +1,6 @@ +package com.loopers.application.service.dto; + +public record BrandCreateCommand( + String name +) { +} diff --git a/application/commerce-service/src/main/java/com/loopers/application/service/dto/BrandInfo.java b/application/commerce-service/src/main/java/com/loopers/application/service/dto/BrandInfo.java new file mode 100644 index 000000000..a028ee6a9 --- /dev/null +++ b/application/commerce-service/src/main/java/com/loopers/application/service/dto/BrandInfo.java @@ -0,0 +1,12 @@ +package com.loopers.application.service.dto; + +import com.loopers.domain.catalog.brand.Brand; + +public record BrandInfo( + Long id, + String name +) { + public static BrandInfo from(Brand brand) { + return new BrandInfo(brand.getId(), brand.getName().getValue()); + } +} diff --git a/application/commerce-service/src/main/java/com/loopers/application/service/dto/BrandUpdateCommand.java b/application/commerce-service/src/main/java/com/loopers/application/service/dto/BrandUpdateCommand.java new file mode 100644 index 000000000..e7a6b8546 --- /dev/null +++ b/application/commerce-service/src/main/java/com/loopers/application/service/dto/BrandUpdateCommand.java @@ -0,0 +1,6 @@ +package com.loopers.application.service.dto; + +public record BrandUpdateCommand( + String name +) { +} diff --git a/application/commerce-service/src/main/java/com/loopers/application/service/dto/MemberInfo.java b/application/commerce-service/src/main/java/com/loopers/application/service/dto/MemberInfo.java index a7cda40c8..b3a8cf2fd 100644 --- a/application/commerce-service/src/main/java/com/loopers/application/service/dto/MemberInfo.java +++ b/application/commerce-service/src/main/java/com/loopers/application/service/dto/MemberInfo.java @@ -7,6 +7,7 @@ import java.time.LocalDate; public record MemberInfo( + Long memberId, LoginId loginId, MemberName name, LocalDate birthdate, diff --git a/application/commerce-service/src/main/java/com/loopers/application/service/dto/RegisterMemberCommand.java b/application/commerce-service/src/main/java/com/loopers/application/service/dto/MemberRegisterCommand.java similarity index 84% rename from application/commerce-service/src/main/java/com/loopers/application/service/dto/RegisterMemberCommand.java rename to application/commerce-service/src/main/java/com/loopers/application/service/dto/MemberRegisterCommand.java index 6cf98b7b5..b3c27bc48 100644 --- a/application/commerce-service/src/main/java/com/loopers/application/service/dto/RegisterMemberCommand.java +++ b/application/commerce-service/src/main/java/com/loopers/application/service/dto/MemberRegisterCommand.java @@ -2,7 +2,7 @@ import java.time.LocalDate; -public record RegisterMemberCommand( +public record MemberRegisterCommand( String loginId, String password, String name, diff --git a/application/commerce-service/src/main/java/com/loopers/application/service/dto/UpdatePasswordCommand.java b/application/commerce-service/src/main/java/com/loopers/application/service/dto/PasswordUpdateCommand.java similarity index 68% rename from application/commerce-service/src/main/java/com/loopers/application/service/dto/UpdatePasswordCommand.java rename to application/commerce-service/src/main/java/com/loopers/application/service/dto/PasswordUpdateCommand.java index 6a57fabac..a6e4fa71a 100644 --- a/application/commerce-service/src/main/java/com/loopers/application/service/dto/UpdatePasswordCommand.java +++ b/application/commerce-service/src/main/java/com/loopers/application/service/dto/PasswordUpdateCommand.java @@ -1,6 +1,6 @@ package com.loopers.application.service.dto; -public record UpdatePasswordCommand( +public record PasswordUpdateCommand( String newPassword ) { } diff --git a/application/commerce-service/src/main/java/com/loopers/application/service/dto/ProductCreateCommand.java b/application/commerce-service/src/main/java/com/loopers/application/service/dto/ProductCreateCommand.java new file mode 100644 index 000000000..5c285811f --- /dev/null +++ b/application/commerce-service/src/main/java/com/loopers/application/service/dto/ProductCreateCommand.java @@ -0,0 +1,10 @@ +package com.loopers.application.service.dto; + +public record ProductCreateCommand( + String name, + String description, + long price, + long stock, + Long brandId +) { +} diff --git a/application/commerce-service/src/main/java/com/loopers/application/service/dto/ProductInfo.java b/application/commerce-service/src/main/java/com/loopers/application/service/dto/ProductInfo.java new file mode 100644 index 000000000..20d2d3adb --- /dev/null +++ b/application/commerce-service/src/main/java/com/loopers/application/service/dto/ProductInfo.java @@ -0,0 +1,12 @@ +package com.loopers.application.service.dto; + +public record ProductInfo( + Long id, + String name, + String description, + long price, + long stock, + long likesCount, + String brandName +) { +} diff --git a/application/commerce-service/src/main/java/com/loopers/application/service/dto/ProductUpdateCommand.java b/application/commerce-service/src/main/java/com/loopers/application/service/dto/ProductUpdateCommand.java new file mode 100644 index 000000000..add00cdd2 --- /dev/null +++ b/application/commerce-service/src/main/java/com/loopers/application/service/dto/ProductUpdateCommand.java @@ -0,0 +1,9 @@ +package com.loopers.application.service.dto; + +public record ProductUpdateCommand( + String name, + String description, + long price, + long stock +) { +} diff --git a/application/commerce-service/src/test/java/com/loopers/application/BrandServiceTest.java b/application/commerce-service/src/test/java/com/loopers/application/BrandServiceTest.java new file mode 100644 index 000000000..3765c10dd --- /dev/null +++ b/application/commerce-service/src/test/java/com/loopers/application/BrandServiceTest.java @@ -0,0 +1,171 @@ +package com.loopers.application; + +import com.loopers.application.service.BrandService; +import com.loopers.application.service.dto.BrandCreateCommand; +import com.loopers.application.service.dto.BrandInfo; +import com.loopers.application.service.dto.BrandUpdateCommand; +import com.loopers.domain.catalog.BrandDeleteService; +import com.loopers.domain.catalog.brand.Brand; +import com.loopers.domain.catalog.brand.BrandExceptionMessage; +import com.loopers.domain.catalog.brand.BrandRepository; +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class BrandServiceTest { + + @InjectMocks + private BrandService brandService; + + @Mock + private BrandRepository brandRepository; + + @Mock + private BrandDeleteService brandDeleteService; + + @Test + void 브랜드_생성_성공_시_저장된다() { + // given + BrandCreateCommand command = new BrandCreateCommand("나이키"); + given(brandRepository.existsByName("나이키")).willReturn(false); + + // when + brandService.create(command); + + // then + verify(brandRepository).save(any(Brand.class)); + } + + @Test + void 브랜드_생성_시_중복_이름이면_예외() { + // given + BrandCreateCommand command = new BrandCreateCommand("나이키"); + given(brandRepository.existsByName("나이키")).willReturn(true); + + // when & then + assertThatThrownBy(() -> brandService.create(command)) + .isInstanceOf(CoreException.class) + .hasMessage(BrandExceptionMessage.Brand.DUPLICATE_NAME.message()); + } + + @Test + void 브랜드_단건_조회_성공() { + // given + Long brandId = 1L; + Brand brand = Brand.register("나이키"); + given(brandRepository.findById(brandId)).willReturn(Optional.of(brand)); + + // when + BrandInfo result = brandService.getById(brandId); + + // then + assertThat(result.name()).isEqualTo("나이키"); + } + + @Test + void 브랜드_단건_조회_시_존재하지_않으면_예외() { + // given + Long brandId = 999L; + given(brandRepository.findById(brandId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> brandService.getById(brandId)) + .isInstanceOf(CoreException.class) + .hasMessage(BrandExceptionMessage.Brand.NOT_FOUND.message()); + } + + @Test + void 브랜드_전체_목록_조회() { + // given + List brands = List.of(Brand.register("나이키"), Brand.register("아디다스")); + given(brandRepository.findAll()).willReturn(brands); + + // when + List result = brandService.getAll(); + + // then + assertThat(result).hasSize(2); + } + + @Test + void 활성_브랜드_목록_조회() { + // given + List brands = List.of(Brand.register("나이키"), Brand.register("아디다스")); + given(brandRepository.findAllByDeletedAtIsNull()).willReturn(brands); + + // when + List result = brandService.getActiveBrands(); + + // then + assertThat(result).hasSize(2); + } + + @Test + void 브랜드_수정_시_존재하지_않으면_예외() { + // given + Long brandId = 999L; + BrandUpdateCommand command = new BrandUpdateCommand("아디다스"); + given(brandRepository.findById(brandId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> brandService.update(brandId, command)) + .isInstanceOf(CoreException.class) + .hasMessage(BrandExceptionMessage.Brand.NOT_FOUND.message()); + } + + @Test + void 브랜드_수정_시_다른_브랜드와_이름_중복이면_예외() { + // given + Long brandId = 1L; + Brand brand = Brand.register("나이키"); + BrandUpdateCommand command = new BrandUpdateCommand("아디다스"); + given(brandRepository.findById(brandId)).willReturn(Optional.of(brand)); + given(brandRepository.existsByName("아디다스")).willReturn(true); + + // when & then + assertThatThrownBy(() -> brandService.update(brandId, command)) + .isInstanceOf(CoreException.class) + .hasMessage(BrandExceptionMessage.Brand.DUPLICATE_NAME.message()); + } + + @Test + void 브랜드_수정_성공() { + // given + Long brandId = 1L; + Brand brand = Brand.register("나이키"); + BrandUpdateCommand command = new BrandUpdateCommand("아디다스"); + given(brandRepository.findById(brandId)).willReturn(Optional.of(brand)); + given(brandRepository.existsByName("아디다스")).willReturn(false); + + // when + brandService.update(brandId, command); + + // then + assertThat(brand.hasName("아디다스")).isTrue(); + } + + @Test + void 브랜드_삭제_시_BrandDeleteService에_위임() { + // given + Long brandId = 1L; + + // when + brandService.delete(brandId); + + // then + verify(brandDeleteService).delete(brandId); + } +} diff --git a/application/commerce-service/src/test/java/com/loopers/application/MemberServiceTest.java b/application/commerce-service/src/test/java/com/loopers/application/MemberServiceTest.java index aa795e743..a6d586423 100644 --- a/application/commerce-service/src/test/java/com/loopers/application/MemberServiceTest.java +++ b/application/commerce-service/src/test/java/com/loopers/application/MemberServiceTest.java @@ -1,7 +1,7 @@ package com.loopers.application; import com.loopers.application.service.MemberService; -import com.loopers.application.service.dto.RegisterMemberCommand; +import com.loopers.application.service.dto.MemberRegisterCommand; import com.loopers.application.service.dto.MemberInfo; import com.loopers.domain.member.*; import com.loopers.domain.member.vo.LoginId; @@ -38,7 +38,7 @@ class MemberServiceTest { void 회원가입_시_아이디_중복_불가() { // given String inputId = "apape123"; - RegisterMemberCommand request = new RegisterMemberCommand( + MemberRegisterCommand request = new MemberRegisterCommand( inputId, "password123!", "공명선", LocalDate.of(2001, 2, 9), "gms72901217@gmail.com"); when(memberRepository.existsByLoginId(inputId)).thenReturn(true); @@ -53,7 +53,7 @@ class MemberServiceTest { void 회원가입_성공_시_저장된다() { // given String inputId = "newId123"; - RegisterMemberCommand request = new RegisterMemberCommand( + MemberRegisterCommand request = new MemberRegisterCommand( inputId, "password123!", "공명선", LocalDate.of(2001, 2, 9), "gms72901217@gmail.com"); when(memberRepository.existsByLoginId(inputId)).thenReturn(false); diff --git a/application/commerce-service/src/test/java/com/loopers/application/ProductServiceTest.java b/application/commerce-service/src/test/java/com/loopers/application/ProductServiceTest.java new file mode 100644 index 000000000..f814f247f --- /dev/null +++ b/application/commerce-service/src/test/java/com/loopers/application/ProductServiceTest.java @@ -0,0 +1,210 @@ +package com.loopers.application; + +import com.loopers.application.service.ProductService; +import com.loopers.application.service.dto.ProductCreateCommand; +import com.loopers.application.service.dto.ProductInfo; +import com.loopers.application.service.dto.ProductUpdateCommand; +import com.loopers.domain.catalog.brand.Brand; +import com.loopers.domain.catalog.brand.BrandExceptionMessage; +import com.loopers.domain.catalog.brand.BrandRepository; +import com.loopers.domain.catalog.product.Product; +import com.loopers.domain.catalog.product.ProductExceptionMessage; +import com.loopers.domain.catalog.product.ProductRepository; +import com.loopers.domain.catalog.product.ProductSortType; +import com.loopers.domain.catalog.product.vo.Money; +import com.loopers.domain.catalog.product.vo.Stock; +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class ProductServiceTest { + + @InjectMocks + private ProductService productService; + + @Mock + private ProductRepository productRepository; + + @Mock + private BrandRepository brandRepository; + + // 상품을 생성한다 + + @Test + void 상품_생성_성공_시_저장된다() { + // given + ProductCreateCommand command = new ProductCreateCommand("에어맥스", "설명", 100000, 50, 1L); + Brand brand = Brand.register("나이키"); + given(brandRepository.findById(1L)).willReturn(Optional.of(brand)); + + // when + productService.create(command); + + // then + verify(productRepository).save(any(Product.class)); + } + + @Test + void 상품_생성_시_브랜드가_없으면_예외() { + // given + ProductCreateCommand command = new ProductCreateCommand("에어맥스", "설명", 100000, 50, 999L); + given(brandRepository.findById(999L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> productService.create(command)) + .isInstanceOf(CoreException.class) + .hasMessage(BrandExceptionMessage.Brand.NOT_FOUND.message()); + } + + @Test + void 상품_생성_시_삭제된_브랜드면_예외() { + // given + ProductCreateCommand command = new ProductCreateCommand("에어맥스", "설명", 100000, 50, 1L); + Brand brand = Brand.register("나이키"); + brand.delete(); + given(brandRepository.findById(1L)).willReturn(Optional.of(brand)); + + // when & then + assertThatThrownBy(() -> productService.create(command)) + .isInstanceOf(CoreException.class) + .hasMessage(BrandExceptionMessage.Brand.ALREADY_DELETED.message()); + } + + // 상품을 상세 조회한다 + + @Test + void 상품_상세_조회_성공_브랜드명_포함() { + // given + Long productId = 1L; + Product product = Product.register("에어맥스", "설명", Money.of(100000), Stock.of(50), 10L); + Brand brand = Brand.register("나이키"); + given(productRepository.findById(productId)).willReturn(Optional.of(product)); + given(brandRepository.findById(10L)).willReturn(Optional.of(brand)); + + // when + ProductInfo result = productService.getById(productId); + + // then + assertThat(result.brandName()).isEqualTo("나이키"); + } + + @Test + void 상품_조회_시_존재하지_않으면_예외() { + // given + Long productId = 999L; + given(productRepository.findById(productId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> productService.getById(productId)) + .isInstanceOf(CoreException.class) + .hasMessage(ProductExceptionMessage.Product.NOT_FOUND.message()); + } + + // 전체 상품을 조회한다 + + @Test + void 전체_목록_조회() { + // given + List products = List.of( + Product.register("에어맥스", "설명", Money.of(100000), Stock.of(50), 1L) + ); + given(productRepository.findAll()).willReturn(products); + given(brandRepository.findAllByIdIn(List.of(1L))).willReturn(List.of()); + + // when + List result = productService.getAll(); + + // then + assertThat(result).hasSize(1); + } + + // 활성 상품을 정렬 조건으로 조회한다 + + @Test + void 활성_상품_목록_조회() { + // given + List products = List.of( + Product.register("에어맥스", "설명", Money.of(100000), Stock.of(50), 1L), + Product.register("슈퍼스타", "설명", Money.of(80000), Stock.of(30), 2L) + ); + given(productRepository.findAllActive(ProductSortType.LATEST)).willReturn(products); + given(brandRepository.findAllByIdIn(List.of(1L, 2L))).willReturn(List.of()); + + // when + List result = productService.getActiveProducts(ProductSortType.LATEST); + + // then + assertThat(result).hasSize(2); + } + + // 상품을 수정한다 + + @Test + void 상품_수정_성공() { + // given + Long productId = 1L; + Product product = Product.register("에어맥스", "설명", Money.of(100000), Stock.of(50), 1L); + ProductUpdateCommand command = new ProductUpdateCommand("에어맥스2", "새설명", 120000, 60); + given(productRepository.findById(productId)).willReturn(Optional.of(product)); + + // when + productService.update(productId, command); + + // then + assertThat(product.hasName("에어맥스2")).isTrue(); + } + + @Test + void 상품_수정_시_존재하지_않으면_예외() { + // given + Long productId = 999L; + ProductUpdateCommand command = new ProductUpdateCommand("에어맥스2", "새설명", 120000, 60); + given(productRepository.findById(productId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> productService.update(productId, command)) + .isInstanceOf(CoreException.class) + .hasMessage(ProductExceptionMessage.Product.NOT_FOUND.message()); + } + + // 상품을 삭제한다 + + @Test + void 상품_삭제_성공() { + // given + Long productId = 1L; + Product product = Product.register("에어맥스", "설명", Money.of(100000), Stock.of(50), 1L); + given(productRepository.findById(productId)).willReturn(Optional.of(product)); + + // when + productService.delete(productId); + + // then + assertThat(product.isDeleted()).isTrue(); + } + + @Test + void 상품_삭제_시_존재하지_않으면_예외() { + // given + Long productId = 999L; + given(productRepository.findById(productId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> productService.delete(productId)) + .isInstanceOf(CoreException.class) + .hasMessage(ProductExceptionMessage.Product.NOT_FOUND.message()); + } +} diff --git a/docs/design/02-sequence-diagrams.md b/docs/design/02-sequence-diagrams.md index 4bc82d513..1ab162d2c 100644 --- a/docs/design/02-sequence-diagrams.md +++ b/docs/design/02-sequence-diagrams.md @@ -11,7 +11,7 @@ OrderService → ProductService (주문 시 재고 확인/차감) LikeService → ProductService (좋아요 시 상품/브랜드 유효성 확인) BrandService ↔ ProductService (같은 BC 내 cross-aggregate 규칙) - → CatalogDomainService로 해소 (Domain 레이어, Brand↔Product는 같은 Catalog BC) + → BrandDeleteService로 해소 (Domain 레이어, Brand↔Product는 같은 Catalog BC) ``` --- @@ -203,9 +203,9 @@ sequenceDiagram loop 각 상품 OS->>P: 재고 차감 decreaseStock(quantity) end - OS->>OR: 수락 주문 저장 save(order: ACCEPTED, snapshots) + OS->>OR: 수락 주문 저장 save(order: ACCEPTED, lines + snapshots) else 하나라도 재고 부족 - OS->>OR: 거절 주문 저장 save(order: REJECTED, snapshots) + OS->>OR: 거절 주문 저장 save(order: REJECTED, lines + snapshots) end OS-->>C: 주문 결과 @@ -257,8 +257,8 @@ sequenceDiagram M->>C: GET /api/v1/orders/{orderId} C->>OS: 주문 상세 조회 getOrderDetail(memberId, orderId) - OS->>OR: 주문 + 스냅샷 조회 findWithSnapshotsById(orderId) - OR-->>OS: Order + List + OS->>OR: 주문 + 주문항목 + 스냅샷 조회 findWithLinesById(orderId) + OR-->>OS: Order + OrderLines + Snapshots OS->>O: 본인 확인 isOwnedBy(memberId) Note over O: 본인 주문이 아니면 예외 @@ -269,7 +269,7 @@ sequenceDiagram #### 읽는 포인트 - **Order 엔티티**: `isOwnedBy(memberId)` — 본인 확인은 Order 객체 스스로가 판단한다. Service가 memberId를 비교하는 것이 아니다. -- **OrderRepository**: 주문과 스냅샷을 함께 로딩하는 책임. +- **OrderRepository**: 주문, 주문항목, 스냅샷을 함께 로딩하는 책임. --- @@ -369,27 +369,27 @@ sequenceDiagram ### 4-5. 브랜드 삭제 (연쇄 soft-delete) -> Brand와 Product는 같은 Catalog BC. cross-aggregate 규칙은 CatalogDomainService(Domain 레이어)에서 처리한다. +> Brand와 Product는 같은 Catalog BC. Brand 삭제 시 소속 Product 연쇄 삭제는 BrandDeleteService(Domain 레이어)에서 처리한다. ```mermaid sequenceDiagram actor A as 관리자 participant C as AdminBrandController participant BS as AdminBrandService - participant CDS as CatalogDomainService + participant BDS as BrandDeleteService participant BR as BrandRepository participant PR as ProductRepository participant B as Brand A->>C: DELETE /api/v1/admin/brands/{brandId} - C->>BS: 브랜드 삭제 deleteBrand(brandId) + C->>BS: 브랜드 삭제 delete(brandId) - BS->>CDS: 브랜드 삭제 deleteBrand(brandId) - CDS->>BR: 브랜드 조회 findById(brandId) - BR-->>CDS: Brand + BS->>BDS: 브랜드 삭제 delete(brandId) + BDS->>BR: 브랜드 조회 findById(brandId) + BR-->>BDS: Brand - CDS->>PR: 소속 상품 연쇄 삭제 softDeleteByBrandId(brandId) - CDS->>B: 삭제 delete() + BDS->>PR: 소속 상품 연쇄 삭제 softDeleteByBrandId(brandId) + BDS->>B: 삭제 delete() Note over B: guardNotDeleted() + name 변경
+ deletedAt 세팅 (UNIQUE 해소) BS-->>C: 삭제 완료 @@ -397,8 +397,8 @@ sequenceDiagram ``` #### 읽는 포인트 -- **CatalogDomainService**: 같은 BC(Catalog) 내 cross-aggregate 규칙 처리. 삭제 순서(상품 먼저 → 브랜드 나중)는 도메인 규칙. -- **AdminBrandService**: 트랜잭션 경계 소유. CatalogDomainService를 호출하는 Application 조정자. +- **BrandDeleteService**: 같은 BC(Catalog) 내 cross-aggregate 규칙 처리. 삭제 순서(상품 먼저 → 브랜드 나중)는 도메인 규칙. +- **AdminBrandService**: 트랜잭션 경계 소유. BrandDeleteService를 호출하는 Application 조정자. - **Brand 엔티티**: `delete()` 내부에서 `guardNotDeleted()` + name 변경 + deletedAt 세팅을 스스로 수행한다. --- @@ -447,43 +447,39 @@ sequenceDiagram ### 5-3. 상품 등록 -> Brand와 Product는 같은 Catalog BC. 상품 등록 시 브랜드 검증은 CatalogDomainService(Domain 레이어)에서 처리한다. +> 상품 등록 시 Brand 활성 여부 확인은 AdminProductService(Application 레이어)에서 오케스트레이션한다. ```mermaid sequenceDiagram actor A as 관리자 participant C as AdminProductController participant PS as AdminProductService - participant CDS as CatalogDomainService participant BR as BrandRepository participant B as Brand participant P as Product participant PR as ProductRepository A->>C: POST /api/v1/admin/products {name, description, price, stock, brandId} - C->>PS: 상품 등록 createProduct(name, description, price, stock, brandId) + C->>PS: 상품 등록 create(BrandCreateCommand) - PS->>CDS: 상품 생성 createProduct(brandId, name, price, stock, description) - CDS->>BR: 브랜드 조회 findById(brandId) - BR-->>CDS: Brand + PS->>BR: 브랜드 조회 findById(brandId) + BR-->>PS: Brand - CDS->>B: 삭제 여부 확인 guardNotDeleted() + PS->>B: 삭제 여부 확인 Note over B: 삭제된 브랜드면 예외 - CDS->>P: 생성 Product.create(brandId, name, price, stock, description) + PS->>P: 생성 Product.register(name, description, price, stock, brandId) Note over P: 가격 > 0, 재고 >= 0 검증 - CDS->>PR: 상품 저장 save(product) - PR-->>CDS: Product - CDS-->>PS: Product + PS->>PR: 상품 저장 save(product) + PR-->>PS: Product PS-->>C: ProductInfo C-->>A: 201 Created ``` #### 읽는 포인트 -- **CatalogDomainService**: 같은 BC(Catalog) 내 cross-aggregate 규칙 처리. 브랜드 검증 → 상품 생성 순서는 도메인 규칙. -- **AdminProductService**: 트랜잭션 경계 소유. CatalogDomainService를 호출하는 Application 조정자. -- **Brand 엔티티**: `guardNotDeleted()` — 삭제된 브랜드에 상품을 등록할 수 없다는 불변식을 Brand 스스로가 지킨다. +- **AdminProductService**: 트랜잭션 경계 소유. Brand 조회 → 활성 확인 → Product 생성의 오케스트레이션. +- **Brand 엔티티**: 삭제 여부는 Brand 자신의 상태. Application Service가 조회 후 확인한다. - **Product 엔티티**: 생성 시 입력값 검증(가격 > 0, 재고 >= 0)을 스스로 수행한다. --- @@ -577,8 +573,8 @@ sequenceDiagram A->>C: GET /api/v1/admin/orders/{orderId} C->>OS: 주문 상세 조회 getOrderDetail(orderId) - OS->>OR: 주문 + 스냅샷 조회 findWithSnapshotsById(orderId) - OR-->>OS: Order + List + OS->>OR: 주문 + 주문항목 + 스냅샷 조회 findWithLinesById(orderId) + OR-->>OS: Order + OrderLines + Snapshots OS-->>C: OrderDetailInfo C-->>A: 200 OK ``` diff --git a/docs/design/03-class-diagram.md b/docs/design/03-class-diagram.md index b3f5b6078..c1ed1e50b 100644 --- a/docs/design/03-class-diagram.md +++ b/docs/design/03-class-diagram.md @@ -80,17 +80,21 @@ classDiagram -Long memberId -OrderStatus status -ZonedDateTime orderedAt - -List~OrderLineSnapshot~ lines + -List~OrderLine~ lines +isOwnedBy(memberId) boolean } - class OrderLineSnapshot { - <> + class OrderLine { -Long productId + -Quantity quantity + -OrderLineSnapshot snapshot + } + + class OrderLineSnapshot { + <> -String productName -String productDescription -Price price - -Quantity quantity -String brandName } @@ -103,8 +107,9 @@ classDiagram %% ── VO 포함 (Composition) ── Product *-- Stock : stock Product *-- Price : price + OrderLine *-- Quantity : quantity + OrderLine --> OrderLineSnapshot : snapshot (1:1) OrderLineSnapshot *-- Price : 주문 시점 가격 - OrderLineSnapshot *-- Quantity : quantity %% ── VO 간 행위 의존 ── Stock ..> Quantity : isEnough / decrease @@ -115,7 +120,8 @@ classDiagram Like ..> Product : subjectId (Long, subjectType=PRODUCT) Like --> LikeSubjectType : subjectType Order ..> Member : memberId (Long) - Order *-- OrderLineSnapshot : 1..N + Order *-- OrderLine : 1..N + OrderLine ..> Product : productId (Long) Order --> OrderStatus : status ``` @@ -131,13 +137,14 @@ classDiagram - **Product**: 고유 ID. 생성 → 수정 → 삭제의 독립 생명주기. 브랜드 삭제 시 연쇄 삭제되지만, 이는 비즈니스 규칙이지 생명주기 종속이 아니다. - **Like**: `memberId + subjectType + subjectId`로 고유 식별. 등록 → 삭제의 독립 생명주기. `subjectType`(enum)으로 좋아요 대상 종류를, `subjectId`로 대상 ID를 지정한다. - **Order**: 고유 ID. 생성 시 즉시 최종 상태(ACCEPTED/REJECTED)로 결정. +- **OrderLine**: Order에 종속되는 주문 항목. 상품 ID와 수량을 보유하며, 스냅샷을 1:1로 소유한다. 나중에 쿠폰/부분취소 등 라인별 기능의 확장 지점. **Value Object (VO)**: 고유 식별자가 불필요하며, 자체 규칙(불변식)을 캡슐화하는 불변 객체다. - **Stock**: 재고의 본질적 규칙("음수가 될 수 없다")을 스스로 지킨다. `decrease(Quantity)` 시 부족하면 예외, 충분하면 새 Stock을 반환한다. - **Price**: 가격의 규칙("0보다 커야 한다")을 생성 시 검증한다. 불변. - **Quantity**: 수량의 규칙("0보다 커야 한다")을 생성 시 검증한다. Stock.decrease의 인자로 사용된다. -- **OrderLineSnapshot**: Order 없이 존재할 수 없다. 주문 시점의 Price와 Quantity를 포함하며, 한번 생성되면 불변이다. +- **OrderLineSnapshot**: 도메인 관점에서는 VO(불변, 독립 식별 불필요)이지만, 정규화를 위해 @Entity로 별도 테이블에 매핑한다. OrderLine에 1:1로 종속되며, 주문 시점의 상품 정보(이름, 가격, 브랜드명)를 보존한다. ### 원칙 2: 단방향 연관, 양방향 최소화 @@ -197,10 +204,11 @@ classDiagram | Like | Entity | member+subjectType+subjectId 식별 | 독립 (등록→삭제) | - | hard-delete | | LikeSubjectType | enum | - | - | - | - | | Order | Entity | 고유 ID | 독립 (생성→최종 상태) | - | 삭제 없음 | +| OrderLine | Entity | 고유 ID | Order에 종속 | - | Order와 동일 | +| OrderLineSnapshot | **VO** (JPA @Entity) | JPA 매핑용 | OrderLine에 종속, 불변 | Price 포함 | OrderLine과 동일 | | Stock | **VO** | 불필요 | Product에 종속 | value >= 0, decrease 시 비음수 검증 | Product와 동일 | | Price | **VO** | 불필요 | Product 또는 Snapshot에 종속 | value > 0 | 소유자와 동일 | -| Quantity | **VO** | 불필요 | Snapshot에 종속 | value > 0 | 소유자와 동일 | -| OrderLineSnapshot | **VO** | 불필요 | Order에 종속, 불변 | Price·Quantity에 위임 | Order와 동일 | +| Quantity | **VO** | 불필요 | OrderLine에 종속 | value > 0 | 소유자와 동일 | | OrderStatus | enum | - | - | - | - | ### VO 선별 기준: "자체 규칙이 있는가?" @@ -210,7 +218,7 @@ classDiagram ├── Stock: 음수 불가 + 차감 행위 ├── Price: 양수만 가능 ├── Quantity: 양수만 가능 -└── OrderLineSnapshot: 주문에 종속 + 불변 + Price·Quantity 포함 +└── OrderLineSnapshot: OrderLine에 종속 + 불변 + Price 포함 (JPA @Entity로 별도 테이블) 자체 규칙 없음 → 원시 타입 유지 ├── name (String): 단순 필수값 @@ -232,8 +240,9 @@ classDiagram | Price | 0+1 | 생성자 검증 | **적절**. 가격 규칙만 보유 | | Quantity | 0+1 | 생성자 검증 | **적절**. 수량 규칙만 보유 | | Order | 1 | isOwnedBy | **적절**. 현재 최소 | +| OrderLine | 0 | - | **적절**. 주문 항목. Quantity와 Snapshot 보유 | | Like | 0 | - | **적절**. 단순 관계 레코드. subjectType enum으로 대상 구분 | -| OrderLineSnapshot | 0 | - | **적절**. 불변 VO. Price, Quantity를 포함하여 스냅샷 | +| OrderLineSnapshot | 0 | - | **적절**. 불변 스냅샷. Price 포함 | ### Service별 @@ -248,7 +257,7 @@ classDiagram | Domain Service | 존재 이유 | 판단 | |----------------|----------|------| -| CatalogDomainService | 같은 BC(Catalog) 내 Brand↔Product cross-aggregate 규칙 처리 (브랜드 삭제 연쇄, 상품 등록 브랜드 검증) | **적절**. 같은 BC 내 도메인 규칙이므로 Domain Service가 적합 | +| BrandDeleteService | Brand 삭제 시 소속 Product 연쇄 soft-delete | **적절**. 같은 BC 내 cross-aggregate 도메인 규칙이므로 Domain Service가 적합 | --- @@ -317,7 +326,7 @@ classDiagram | 3 | Brand.delete(): 이름 변경 + soft-delete | DB UNIQUE 제약 유지하면서 삭제된 브랜드 이름 재사용 가능 | 앱 레벨 검증만 (UNIQUE 없음), UNIQUE 제거 (데이터 정합성 약화) | | 4 | OrderStatus: ACCEPTED, REJECTED만 | 현재 요구사항에 중간 상태/취소 없음. enum이므로 확장 용이 | CANCELLED 포함 (현재 불필요, YAGNI) | | 5 | 모든 BC 간 참조를 ID(Long)만 사용 | BC 간 직접 의존 제거. MSA 전환 시 변경 최소화 | 객체 참조 (편리하나 BC 경계 위반) | -| 6 | OrderLineSnapshot은 VO | Order 없이 존재 불가, 불변, 독립 식별 불필요 | Entity로 분류 (불필요한 생명주기 관리) | +| 6 | OrderLine(Entity) + OrderLineSnapshot(VO, @Entity) 분리 | OrderLine은 주문 항목으로 라인별 확장 지점(쿠폰, 부분취소). OrderLineSnapshot은 불변 스냅샷으로 정규화를 위해 별도 테이블 | OrderLineSnapshot 하나로 합치기 (확장 어려움), @Embeddable (정규화 위반) | | 7 | Like에 메서드 없음 | 단순 관계 레코드. hard-delete이므로 엔티티 행위 불필요 | toggle() 등 추가 (과도한 추상화) | | 8 | 양방향 연관 0개 | 단방향만으로 모든 요구사항 충족. 양방향은 순환 의존과 복잡성 유발 | Product ↔ Brand 양방향 (편의성 vs 복잡성 트레이드오프) | | 9 | Like를 subjectType+subjectId로 일반화 | 상속(JOINED/SINGLE_TABLE) 대신 enum+ID 패턴 채택. UNIQUE 제약 자연스러움, 스키마 변경 없이 타입 확장, 무FK 철학 일관 | JPA 상속 (JOINED: UNIQUE 불가+JOIN 비용, SINGLE_TABLE: nullable 컬럼), ProductLike/BrandLike 클래스 분리 (타입 추가마다 엔티티+테이블 필요) | @@ -346,5 +355,5 @@ classDiagram | VO 간 의존 (Stock ──▷ Quantity) | "재고를 차감하려면 수량이 필요하다"는 도메인 관계를 표현 | | 위임 패턴 (decreaseStock → Stock.decrease) | "규칙은 규칙을 아는 객체가 수행한다"는 객체지향 원칙을 표현 | | 연관 방향 (전부 단방향 ID 참조) | BC 경계가 다이어그램에서 바로 보임 | -| Composition (Order ◆── OrderLineSnapshot) | "스냅샷은 주문의 일부"라는 생명주기 종속을 시각적으로 표현 | +| Composition (Order ◆── OrderLine → OrderLineSnapshot) | "주문 항목과 스냅샷은 주문의 일부"라는 생명주기 종속을 시각적으로 표현 | | 메서드 없는 엔티티 (Like) | "관계 기록"이라는 본질에 충실 — 억지 행위 없음. subjectType enum으로 대상 종류 구분 | diff --git a/docs/design/04-erd.md b/docs/design/04-erd.md index f7e79dbac..656d969ac 100644 --- a/docs/design/04-erd.md +++ b/docs/design/04-erd.md @@ -58,21 +58,27 @@ erDiagram DATETIME updated_at "NOT NULL" } - order_line_snapshot { + order_line { BIGINT id PK "AUTO_INCREMENT" BIGINT order_id "NOT NULL" BIGINT product_id "NOT NULL" + INT quantity "NOT NULL" + } + + order_line_snapshot { + BIGINT id PK "AUTO_INCREMENT" + BIGINT order_line_id "NOT NULL" VARCHAR product_name "NOT NULL" TEXT product_description "nullable" INT price "NOT NULL" - INT quantity "NOT NULL" VARCHAR brand_name "NOT NULL" } brand ||--o{ product : "brand_id" member ||--o{ likes : "member_id" member ||--o{ orders : "member_id" - orders ||--o{ order_line_snapshot : "order_id" + orders ||--o{ order_line : "order_id" + order_line ||--|| order_line_snapshot : "order_line_id" ``` > **관계선 = 논리 참조**. DB에 FK 제약조건은 존재하지 않는다. 참조 무결성은 애플리케이션 레벨에서 보장한다. @@ -96,7 +102,7 @@ erDiagram |----|----------|---------|--------------| | Stock | product.stock | INT | >= 0 (음수 불가) | | Price | product.price, order_line_snapshot.price | INT | > 0 (양수만) | -| Quantity | order_line_snapshot.quantity | INT | > 0 (양수만) | +| Quantity | order_line.quantity | INT | > 0 (양수만) | > VO는 코드 구조이지 DB 구조가 아니다 (클래스 다이어그램 안티패턴 #3). > DB에는 INT 컬럼으로 저장되고, 앱에서 VO 객체로 감싸서 규칙을 검증한다. @@ -116,7 +122,7 @@ JPA 상속은 `@MappedSuperclass`를 사용한다. 상속 클래스별로 별도 |----------|--------|----------------|------| | soft-delete | brand, product | 있음 | BaseEntity | | hard-delete | likes | 없음 | BaseTimeEntity | -| 삭제 없음 | orders, order_line_snapshot | 없음 | BaseTimeEntity / 없음 | +| 삭제 없음 | orders, order_line, order_line_snapshot | 없음 | BaseTimeEntity / 없음 | --- @@ -198,23 +204,33 @@ BaseTimeEntity 상속 (삭제 없음). 테이블명은 `orders` (ORDER는 SQL - **status**: 주문 생성 시 즉시 최종 상태(ACCEPTED/REJECTED)로 결정된다. 중간 상태 없음. -### order_line_snapshot +### order_line -Order에 종속되는 VO. Composition 1:N. +Order에 종속되는 주문 항목. Composition 1:N. | 컬럼 | 타입 | 제약 | 비고 | |-------|------|------|------| -| id | BIGINT | PK, AUTO_INCREMENT | JPA 매핑용 | +| id | BIGINT | PK, AUTO_INCREMENT | | | order_id | BIGINT | NOT NULL | → orders.id (FK 없음) | -| product_id | BIGINT | NOT NULL | 스냅샷 시점 상품 ID | +| product_id | BIGINT | NOT NULL | 주문 시점 상품 ID | +| quantity | INT | NOT NULL | VO: Quantity (주문 수량) | + +- **확장 지점**: 향후 쿠폰 적용, 부분 취소 등 라인별 기능 확장 시 이 테이블에 컬럼/관계 추가. + +### order_line_snapshot + +OrderLine에 1:1로 종속되는 불변 스냅샷. 도메인 VO이지만 정규화를 위해 @Entity로 별도 테이블. + +| 컬럼 | 타입 | 제약 | 비고 | +|-------|------|------|------| +| id | BIGINT | PK, AUTO_INCREMENT | | +| order_line_id | BIGINT | NOT NULL | → order_line.id (FK 없음) | | product_name | VARCHAR | NOT NULL | 스냅샷 | | product_description | TEXT | nullable | 스냅샷 | | price | INT | NOT NULL | VO: Price (주문 시점 가격) | -| quantity | INT | NOT NULL | VO: Quantity (주문 수량) | | brand_name | VARCHAR | NOT NULL | 스냅샷 시점 브랜드명 | -- **timestamp 없음**: 불변 VO. 생성 시점은 소속 Order의 created_at/ordered_at이 대변한다. -- **id 컬럼 존재 이유**: 도메인에서는 VO(독립 식별 불필요)이지만, JPA 1:N 매핑에 PK가 필요하다. +- **timestamp 없음**: 불변. 생성 시점은 소속 Order의 created_at/ordered_at이 대변한다. - **상품/브랜드 삭제 무관**: 스냅샷이므로 원본이 삭제되어도 기록은 유지된다. --- @@ -226,7 +242,7 @@ Order에 종속되는 VO. Composition 1:N. | 1 | FK 제약조건 없음 | 앱 레벨에서 참조 무결성 관리. BC 간 결합도 최소화. MSA 전환 대비 | FK 설정 (DB 정합성 보장이 강하나, BC 간 결합 증가) | | 2 | VO는 컬럼으로 매핑 | VO는 코드 구조이지 DB 구조가 아니다. 별도 테이블은 안티패턴 | VO별 테이블 (과도한 JOIN, 도메인 의미 왜곡) | | 3 | order → orders 테이블명 | ORDER는 SQL 예약어. 백틱 의존보다 명확한 이름 사용 | 백틱으로 감싸기 (DB 종류 변경 시 호환성 문제) | -| 4 | OrderLineSnapshot에 id 컬럼 포함 | 도메인 VO이지만 JPA @OneToMany 매핑에 PK 필요 | @ElementCollection (컬렉션 전체 삭제/재삽입 성능 이슈) | +| 4 | OrderLine + OrderLineSnapshot 분리 | OrderLine은 주문 항목(Entity), OrderLineSnapshot은 불변 스냅샷(VO, @Entity). 정규화 유지 + 라인별 확장 지점 확보 | 하나로 합치기 (확장 어려움), @Embeddable (정규화 위반) | | 5 | OrderLineSnapshot에 timestamp 없음 | 불변 VO. Order의 created_at이 생성 시점을 대변 | timestamp 포함 (불필요한 중복 정보) | | 6 | likes에 UNIQUE(member_id, subject_type, subject_id) | 중복 좋아요 방지를 DB 레벨에서 보장. subjectType+subjectId 일반화로 단일 테이블에서 모든 좋아요 타입의 중복 차단 | 앱 레벨만 (경쟁 조건에 취약), 타입별 테이블 분리 (UNIQUE는 쉬우나 스키마 변경 필요) | | 7 | brand.name에 UNIQUE 제약 | 이름 중복 불가 요구사항. delete 시 이름 변경으로 UNIQUE 해소 (클래스 다이어그램 결정 #3) | UNIQUE 없이 앱 검증만 (동시성에 취약) | @@ -247,8 +263,9 @@ Order에 종속되는 VO. Composition 1:N. | likes.member_id | member.id | 좋아요 등록 | 인증 컨텍스트 | 인증된 memberId만 사용 (암묵적 검증) | | likes.subject_id | product.id | 좋아요 등록 | LikeService | `ProductService.getActiveProduct()` (상품+브랜드 활성 확인) | | orders.member_id | member.id | 주문 생성 | 인증 컨텍스트 | 인증된 memberId만 사용 (암묵적 검증) | -| order_line_snapshot.order_id | orders.id | 주문 생성 | OrderService | Order와 함께 생성 (Composition, 독립 생성 불가) | -| order_line_snapshot.product_id | product.id | 주문 생성 | OrderService | `ProductService.getProductForOrder()` (비관적 락 + 활성 확인) | +| order_line.order_id | orders.id | 주문 생성 | OrderService | Order와 함께 생성 (Composition, 독립 생성 불가) | +| order_line.product_id | product.id | 주문 생성 | OrderService | `ProductService.getProductForOrder()` (비관적 락 + 활성 확인) | +| order_line_snapshot.order_line_id | order_line.id | 주문 생성 | OrderService | OrderLine과 함께 생성 (1:1 종속, 독립 생성 불가) | ### 삭제 시 참조 보호 규칙 @@ -257,7 +274,7 @@ Order에 종속되는 VO. Composition 1:N. | brand (soft-delete) | product | 연쇄 soft-delete | AdminBrandFacade → `ProductService.softDeleteByBrandId()` | | brand (soft-delete) | likes | 처리 없음 | 목록 조회 시 LikeRepository가 자연 필터링 | | product (soft-delete) | likes | 처리 없음 | 목록 조회 시 LikeRepository가 자연 필터링 | -| product (soft-delete) | order_line_snapshot | 영향 없음 | 스냅샷이므로 원본 상태와 무관 | +| product (soft-delete) | order_line, order_line_snapshot | 영향 없음 | 스냅샷이므로 원본 상태와 무관 | ### 고아 레코드 방지 원칙 @@ -305,8 +322,14 @@ Order에 종속되는 VO. Composition 1:N. |--------|------|------|----------| | idx_orders_member_ordered | (member_id, ordered_at) | INDEX | `findByMemberIdAndPeriod` | +### order_line + +| 인덱스 | 컬럼 | 타입 | 사용 쿼리 | +|--------|------|------|----------| +| idx_ol_order_id | order_id | INDEX | Order와 함께 로딩 | + ### order_line_snapshot | 인덱스 | 컬럼 | 타입 | 사용 쿼리 | |--------|------|------|----------| -| idx_ols_order_id | order_id | INDEX | `findWithSnapshotsById` (Order와 함께 로딩) | +| idx_ols_order_line_id | order_line_id | INDEX | OrderLine과 함께 로딩 (1:1) | diff --git a/docs/design/05-domain-model.md b/docs/design/05-domain-model.md index 52ceeccb8..4f26dd0a5 100644 --- a/docs/design/05-domain-model.md +++ b/docs/design/05-domain-model.md @@ -37,7 +37,7 @@ 4. `Order Context` - 책임: 주문 생성/조회, 주문 스냅샷 보존, 수락/거절 판단 -- Aggregate: `Order` (+ `OrderLineSnapshot` VO) +- Aggregate: `Order` (+ `OrderLine` Entity, `OrderLineSnapshot` VO) - 참고: 재고 차감은 Catalog Context(Product)의 책임이며, Order Context는 ProductService를 통해 요청만 한다. 참고: @@ -113,7 +113,7 @@ - 위치: Application 레이어 - 도입 기준: Application Service A가 Application Service B를 필요로 하고, B도 A를 필요로 할 때 - 주의: 같은 BC 내 cross-aggregate 규칙은 Facade가 아닌 Domain Service로 해결한다 - - 예: Brand 삭제 → Product 연쇄 삭제는 같은 BC(Catalog)이므로 `CatalogDomainService`가 처리 + - 예: Brand 삭제 → Product 연쇄 삭제는 같은 BC(Catalog)이므로 `BrandDeleteService`가 처리 --- @@ -193,7 +193,7 @@ | Catalog | Brand | (없음) | BrandRepository | | Catalog | Product | Price, Stock | ProductRepository | | Like | Like | (없음) | LikeRepository | -| Order | Order | OrderLineSnapshot | OrderRepository | +| Order | Order | OrderLine, OrderLineSnapshot | OrderRepository | ### 7-3. Catalog BC: Brand와 Product가 독립 Aggregate인 이유 @@ -201,13 +201,13 @@ - **규모 차이**: 하나의 Brand에 수천 개 Product가 소속 가능. Brand Aggregate에 Product를 포함하면 메모리/성능 문제 - **독립 변경**: Product 가격/재고 수정 시 Brand를 잠글 필요 없음 -Brand ↔ Product cross-aggregate 규칙(삭제 연쇄, 생성 시 브랜드 검증)은 **CatalogDomainService**에서 처리한다. +Brand 삭제 시 소속 Product 연쇄 삭제는 **BrandDeleteService**에서 처리한다. 상품 등록 시 Brand 활성 검증은 Application Service에서 오케스트레이션한다. ### 7-4. 트랜잭션 경계 - **기본 원칙**: 하나의 트랜잭션에서 하나의 Aggregate만 변경한다. - **같은 BC 내 예외**: 같은 BC 안에서 cross-aggregate 변경이 필요한 경우, Domain Service가 같은 트랜잭션에서 처리할 수 있다. - - 예: Brand 삭제 → Product 연쇄 삭제 (CatalogDomainService, 같은 트랜잭션) + - 예: Brand 삭제 → Product 연쇄 삭제 (BrandDeleteService, 같은 트랜잭션) - **다른 BC 간**: 현재는 Application Service가 같은 트랜잭션에서 조율한다. 규모 확장 시 이벤트 기반(eventual consistency)으로 전환을 검토한다. --- diff --git a/docs/design/06-architecture.md b/docs/design/06-architecture.md index 646b8d014..ba6d06447 100644 --- a/docs/design/06-architecture.md +++ b/docs/design/06-architecture.md @@ -146,12 +146,13 @@ BaseTimeEntity (id, createdAt, updatedAt) | Catalog | `Product` | Entity | 상품 CRUD, VO 위임 (`hasEnoughStock`, `decreaseStock`) | | Catalog | `Price` | VO (@Embeddable) | 가격 > 0 자체 검증 | | Catalog | `Stock` | VO (@Embeddable) | 재고 >= 0 자체 검증, `isEnough(Quantity)`, `decrease(Quantity)` | -| Catalog | `CatalogDomainService` | Domain Service | 같은 BC 내 cross-aggregate 규칙 (Brand 삭제 → Product 연쇄, 상품 등록 시 Brand 검증) | +| Catalog | `BrandDeleteService` | Domain Service | Brand 삭제 시 소속 Product 연쇄 soft-delete | | Catalog | `BrandRepository`, `ProductRepository` | interface | Catalog 조회/저장 계약 | | Like | `Like` | Entity | 관계 레코드 (hard-delete). `subjectType(enum) + subjectId(Long)` | | Like | `LikeRepository` | interface | 좋아요 조회/저장 계약 | | Order | `Order` | Entity | 주문 상태 관리, `isOwnedBy(memberId)` | -| Order | `OrderLineSnapshot` | VO | 주문 시점 불변 스냅샷 (Price, Quantity 포함) | +| Order | `OrderLine` | Entity | 주문 항목. productId, Quantity 보유. 라인별 확장 지점 | +| Order | `OrderLineSnapshot` | VO (@Entity) | 주문 시점 불변 스냅샷 (Price 포함). 정규화를 위해 별도 테이블 | | Order | `Quantity` | VO (@Embeddable) | 수량 > 0 자체 검증 | | Order | `OrderRepository` | interface | 주문 조회/저장 계약 | @@ -176,7 +177,7 @@ com.loopers │ │ ├── MemberName.java │ │ └── Email.java │ ├── catalog/ -│ │ ├── CatalogDomainService.java +│ │ ├── BrandDeleteService.java │ │ ├── brand/ │ │ │ ├── Brand.java │ │ │ ├── BrandRepository.java @@ -195,6 +196,7 @@ com.loopers │ └── order/ │ ├── Order.java │ ├── OrderRepository.java +│ ├── OrderLine.java │ ├── OrderLineSnapshot.java │ ├── OrderStatus.java │ └── vo/ @@ -251,8 +253,8 @@ dependencies { |--------|-----|------| | `MemberService` | Member | 회원 등록, 조회, 비밀번호 변경 오케스트레이션 | | `BrandService` | Catalog | 활성 브랜드 목록 조회 (User) | -| `AdminBrandService` | Catalog | 브랜드 CRUD. 삭제 시 CatalogDomainService 호출 | -| `AdminProductService` | Catalog | 상품 등록 시 CatalogDomainService 호출 | +| `AdminBrandService` | Catalog | 브랜드 CRUD. 삭제 시 BrandDeleteService 호출 | +| `AdminProductService` | Catalog | 상품 CRUD. 등록 시 Brand 활성 여부 확인 | | `ProductService` | Catalog | 상품 조회, 수정, 삭제, 주문용 락 조회 | | `LikeService` | Like | 좋아요 등록/취소/조회. Cross-BC 유효성 확인 (ProductService 호출) | | `OrderService` | Order | 주문 생성 오케스트레이션 (정렬, 락, 스냅샷, 수락/거절) | @@ -261,7 +263,7 @@ dependencies { | 종류 | 역할 | 네이밍 규칙 | 예시 | |---------|------|-----------|------| -| Command | 외부 → Application 요청 (상태 변경) | `{Action}{Domain}Command` | `CreateBrandCommand`, `RegisterMemberCommand` | +| Command | 외부 → Application 요청 (상태 변경) | `{Domain}{Action}Command` | `BrandCreateCommand`, `MemberRegisterCommand` | | Info | Application → 외부 응답 (조회 결과) | `{Domain}Info` | `BrandInfo`, `MemberInfo` | > DTO는 Java `record`로 구현한다. 불변이며, HTTP 관심사(status code, header)를 알지 않는다. @@ -283,7 +285,7 @@ Facade는 **Application Service 간 순환 참조를 해소**하기 위해서만 |------|--------|----------------| | 위치 | Application 레이어 | Domain 레이어 | | 역할 | Application Service 간 순환 참조 해소 | 같은 BC 내 cross-aggregate 규칙 | -| 예시 | (현재 해당 없음) | CatalogDomainService (Brand↔Product) | +| 예시 | (현재 해당 없음) | BrandDeleteService (Brand 삭제 → Product 연쇄) | | 의존 | 여러 Application Service 주입 | 도메인 객체 + Repository | #### 패키지 구조 @@ -299,10 +301,10 @@ com.loopers.application │ ├── LikeService.java │ ├── OrderService.java │ └── dto/ -│ ├── CreateBrandCommand.java -│ ├── UpdateBrandCommand.java +│ ├── BrandCreateCommand.java +│ ├── BrandUpdateCommand.java │ ├── BrandInfo.java -│ ├── RegisterMemberCommand.java +│ ├── MemberRegisterCommand.java │ ├── MemberInfo.java │ └── ... └── facade/ @@ -380,8 +382,8 @@ private HttpStatus toHttpStatus(ErrorType errorType) { | 레이어 | DTO 위치 | 역할 | 예시 | |--------|---------|------|------| -| Presentation | `interfaces/api/{도메인}/dto/` | HTTP 계약 (Request Body, Response Body) | `CreateBrandApiRequest`, `BrandApiResponse` | -| Application | `application/service/dto/` | Use Case 계약 (프로토콜 무관) | `CreateBrandCommand`, `BrandInfo` | +| Presentation | `interfaces/api/{도메인}/dto/` | HTTP 계약 (Request Body, Response Body) | `BrandCreateApiRequest`, `BrandApiResponse` | +| Application | `application/service/dto/` | Use Case 계약 (프로토콜 무관) | `BrandCreateCommand`, `BrandInfo` | > **왜 분리하는가?** Presentation DTO는 API 클라이언트와의 계약이고, Application DTO는 Use Case의 계약이다. 분리하면 API 스펙 변경이 Domain/Application에 영향을 주지 않고, 같은 Application을 다른 Presentation(Batch, Kafka)에서도 재사용할 수 있다. @@ -552,7 +554,7 @@ graph TB subgraph CatalogBC["Catalog Context"] B["Brand\n(Aggregate Root)"] P["Product\n(Aggregate Root)"] - CDS["CatalogDomainService\n(cross-aggregate 규칙)"] + CDS["BrandDeleteService\n(Brand 삭제 + Product 연쇄)"] CDS ---|"조율"| B CDS ---|"조율"| P end @@ -563,15 +565,17 @@ graph TB subgraph OrderBC["Order Context"] O["Order\n(Aggregate Root)"] - OLS["OrderLineSnapshot\n(VO, Composition)"] - O ---|"포함"| OLS + OL["OrderLine\n(Entity)"] + OLS["OrderLineSnapshot\n(VO, @Entity)"] + O ---|"포함"| OL + OL ---|"1:1"| OLS end P -..->|"brandId (Long)"| B L -..->|"memberId (Long)"| M L -..->|"subjectId (Long)"| P O -..->|"memberId (Long)"| M - OLS -..->|"productId (Long)"| P + OL -..->|"productId (Long)"| P ``` **점선(`..>`) = ID(Long) 참조**. 객체 참조가 아니다. @@ -582,7 +586,7 @@ BC 간 참조뿐 아니라 **같은 BC 내(Product → Brand)에서도 FK를 사 | 이유 | 설명 | |------|------| -| 도메인 규칙 명시적 제어 | 삭제 연쇄를 DB CASCADE 대신 CatalogDomainService로 제어. 삭제 순서(상품 먼저 → 브랜드 나중)와 부가 로직을 코드에 명시적으로 표현 | +| 도메인 규칙 명시적 제어 | 삭제 연쇄를 DB CASCADE 대신 BrandDeleteService로 제어. 삭제 순서(상품 먼저 → 브랜드 나중)와 부가 로직을 코드에 명시적으로 표현 | | 운영 유연성 | 데이터 마이그레이션, 벌크 작업 시 FK가 제약이 됨 | 참조 무결성은 **애플리케이션 레벨에서 보장**한다 (상세: `04-erd.md` 5절). @@ -597,7 +601,7 @@ Brand와 Product는 같은 Catalog BC에 속하지만 **독립 Aggregate**이다 | 규모 차이 | Brand 1개에 Product 수천 개 가능. Brand Aggregate에 포함하면 메모리/성능 문제 | | 독립 변경 | Product 가격/재고 수정 시 Brand를 잠글 필요 없음 | -Cross-aggregate 규칙(삭제 연쇄, 생성 시 브랜드 검증)은 **CatalogDomainService**에서 처리한다. +Cross-aggregate 규칙(삭제 연쇄)은 **BrandDeleteService**에서 처리한다. 상품 등록 시 Brand 활성 검증은 Application Service에서 오케스트레이션한다. --- @@ -619,7 +623,7 @@ sequenceDiagram A->>CTRL: POST /api/admin/brands {name} Note over CTRL: Presentation DTO → Application Command 변환 - CTRL->>SVC: create(CreateBrandCommand) + CTRL->>SVC: create(BrandCreateCommand) Note over SVC: @Transactional 시작 SVC->>PORT: existsByName(name) @@ -690,9 +694,9 @@ sequenceDiagram P->>STOCK: decrease(quantity) Note over STOCK: 새 Stock 반환 (불변 VO) end - OS->>PORT: save(ACCEPTED + snapshots) + OS->>PORT: save(ACCEPTED + lines + snapshots) else 재고 부족 - OS->>PORT: save(REJECTED + snapshots) + OS->>PORT: save(REJECTED + lines + snapshots) end Note over OS: @Transactional 종료 @@ -757,7 +761,7 @@ flowchart TD | "재고는 음수가 될 수 없다" | `Stock` VO (Domain) | 기술 무관한 불변식 | | "가격은 0보다 커야 한다" | `Price` VO (Domain) | 기술 무관한 불변식 | | "이미 삭제된 브랜드는 다시 삭제 불가" | `Brand.guardNotDeleted()` (Domain) | 엔티티 자기 상태 검증 | -| "Brand 삭제 시 Product 연쇄 삭제" | `CatalogDomainService` (Domain) | 같은 BC 내 cross-aggregate 규칙 | +| "Brand 삭제 시 Product 연쇄 삭제" | `BrandDeleteService` (Domain) | 같은 BC 내 cross-aggregate 규칙 | | "productId 오름차순 정렬 (데드락 방지)" | `OrderService` (Application) | 물리적 기술 관심사 | | "좋아요 등록 시 상품 유효성 확인" | `LikeService` (Application) | Cross-BC 조율 (Like → Catalog) | | "ErrorType → HttpStatus 매핑" | `ApiControllerAdvice` (Presentation) | 프로토콜 해석 | @@ -782,9 +786,9 @@ flowchart TD | 2 | ErrorType에 HttpStatus 미포함 | Presentation이 3개 (HTTP, Batch, Kafka) | Batch/Kafka에서 HttpStatus는 무의미. 각 Presentation이 자기 프로토콜로 해석해야 한다 | ErrorType에 HttpStatus 포함 | | 3 | Repository 인터페이스를 Domain에 배치 | 의존 역전 | Domain이 추상체를 소유하고 Infrastructure가 구현하면 의존 방향이 안쪽을 향한다. 테스트 시 Fake 주입 가능 | Repository를 Infrastructure에 배치 | | 4 | Application에 spring-web 미포함 | Application의 프로토콜 독립성 | 트랜잭션(`@Transactional`)과 DI(`@Service`)만 필요. HTTP는 Presentation의 책임 | spring-web 포함 | -| 5 | BC 간/내 모두 FK 없음 | 도메인 규칙 명시적 제어 | 삭제 연쇄를 DB CASCADE 대신 CatalogDomainService로 제어. 규칙이 코드에 표현된다 | FK 사용 | +| 5 | BC 간/내 모두 FK 없음 | 도메인 규칙 명시적 제어 | 삭제 연쇄를 DB CASCADE 대신 BrandDeleteService로 제어. 규칙이 코드에 표현된다 | FK 사용 | | 6 | BaseTimeEntity / BaseEntity 분리 | 삭제 정책을 상속으로 표현 | Like(hard-delete), Order(삭제 없음)에 `deletedAt`은 불필요. 상속이 의도를 코드로 드러낸다 | BaseEntity 하나만 | -| 7 | Domain Service 필요할 때만 도입 | YAGNI | 현재 CatalogDomainService만 실제 필요. Member BC에 DomainService는 불필요 | 모든 BC에 미리 생성 | +| 7 | Domain Service 필요할 때만 도입 | YAGNI | 현재 BrandDeleteService만 실제 필요. Member BC에 DomainService는 불필요 | 모든 BC에 미리 생성 | | 8 | presentation에서만 Spring Boot 플러그인 | Library 모듈에 bootJar 불필요 | domain, application, modules는 `java-library`. bootJar는 실행 모듈(presentation)만 | 전체 모듈에 적용 | --- diff --git a/docs/planning/brand-plan.md b/docs/planning/brand-plan.md index 5958afe35..3516e743c 100644 --- a/docs/planning/brand-plan.md +++ b/docs/planning/brand-plan.md @@ -84,33 +84,33 @@ Brand는 `name` 하나의 필드만 가지며, 검증 규칙이 단순하여 별 ``` Presentation Layer (commerce-api) ├── interfaces/api/brand/dto/ -│ ├── CreateBrandApiRequest.java → CreateBrandCommand 변환 -│ ├── UpdateBrandApiRequest.java → UpdateBrandCommand 변환 +│ ├── BrandCreateApiRequest.java → BrandCreateCommand 변환 +│ ├── BrandUpdateApiRequest.java → BrandUpdateCommand 변환 │ └── BrandApiResponse.java ← BrandInfo 변환 │ Application Layer (commerce-service) ├── application/service/dto/ -│ ├── CreateBrandCommand.java (record) -│ ├── UpdateBrandCommand.java (record) +│ ├── BrandCreateCommand.java (record) +│ ├── BrandUpdateCommand.java (record) │ └── BrandInfo.java (record, from(Brand)) ``` -### 2-5. CatalogDomainService (Product 구현 후) +### 2-5. BrandDeleteService (Product 구현 후) -Brand 삭제 시 소속 Product도 연쇄 soft-delete해야 한다. Brand와 Product는 같은 BC(Catalog)의 독립 Aggregate이므로, cross-aggregate 규칙은 **CatalogDomainService**(Domain 레이어)에서 처리한다. +Brand 삭제 시 소속 Product도 연쇄 soft-delete해야 한다. Brand와 Product는 같은 BC(Catalog)의 독립 Aggregate이므로, cross-aggregate 규칙은 **BrandDeleteService**(Domain 레이어)에서 처리한다. - **현재**: `AdminBrandService.delete()` — Brand만 삭제 (Product 미구현) -- **Product 구현 후**: `AdminBrandService.delete()` → `CatalogDomainService.deleteBrand()` 호출 +- **Product 구현 후**: `AdminBrandService.delete()` → `BrandDeleteService.delete()` 호출 ```java -// domain/src/main/java/com/loopers/domain/catalog/CatalogDomainService.java +// domain/src/main/java/com/loopers/domain/catalog/BrandDeleteService.java // Product 구현 후 추가 @RequiredArgsConstructor -public class CatalogDomainService { +public class BrandDeleteService { private final BrandRepository brandRepository; private final ProductRepository productRepository; - public void deleteBrand(Long brandId) { + public void delete(Long brandId) { Brand brand = brandRepository.findById(brandId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, BrandExceptionMessage.NOT_FOUND.message())); @@ -120,7 +120,7 @@ public class CatalogDomainService { } ``` -> Facade와의 차이: Facade는 Application Service 간 순환 참조 해소 용도. Brand↔Product는 같은 BC이므로 Domain Service가 적합하다. +> Facade와의 차이: Facade는 Application Service 간 순환 참조 해소 용도. Brand 삭제 → Product 연쇄는 같은 BC의 cross-aggregate 도메인 규칙이므로 Domain Service가 적합하다. --- @@ -188,10 +188,10 @@ public interface BrandRepository { - `getActiveBrands()` — 활성 브랜드 목록 반환 **AdminBrandService** (Admin): -- `create(CreateBrandCommand)` — 중복 검사 + 생성 +- `create(BrandCreateCommand)` — 중복 검사 + 생성 - `getById(Long)` — 단건 조회 - `getAll()` — 전체 목록 조회 (삭제 포함) -- `update(Long, UpdateBrandCommand)` — 중복 검사 + 수정 +- `update(Long, BrandUpdateCommand)` — 중복 검사 + 수정 - `delete(Long)` — soft-delete 테스트 케이스 (AdminBrandServiceTest): @@ -227,7 +227,7 @@ public interface BrandJpaRepository extends JpaRepository { - `GET /api/admin/brands/{id}` → `AdminBrandService.getById()` - `GET /api/admin/brands` → `AdminBrandService.getAll()` - `PUT /api/admin/brands/{id}` → `AdminBrandService.update()` -- `DELETE /api/admin/brands/{id}` → `AdminBrandService.delete()` (Product 구현 후: `CatalogDomainService.deleteBrand()` 경유) +- `DELETE /api/admin/brands/{id}` → `AdminBrandService.delete()` (Product 구현 후: `BrandDeleteService.delete()` 경유) ### Step 8: 통합 / E2E 테스트 @@ -262,8 +262,8 @@ public interface BrandJpaRepository extends JpaRepository { |------|------| | `application/commerce-service/src/main/java/com/loopers/application/service/BrandService.java` | User Service | | `application/commerce-service/src/main/java/com/loopers/application/service/AdminBrandService.java` | Admin Service | -| `application/commerce-service/src/main/java/com/loopers/application/service/dto/CreateBrandCommand.java` | 생성 DTO | -| `application/commerce-service/src/main/java/com/loopers/application/service/dto/UpdateBrandCommand.java` | 수정 DTO | +| `application/commerce-service/src/main/java/com/loopers/application/service/dto/BrandCreateCommand.java` | 생성 DTO | +| `application/commerce-service/src/main/java/com/loopers/application/service/dto/BrandUpdateCommand.java` | 수정 DTO | | `application/commerce-service/src/main/java/com/loopers/application/service/dto/BrandInfo.java` | 응답 DTO | | `application/commerce-service/src/test/java/com/loopers/application/service/BrandServiceTest.java` | User Service 테스트 | | `application/commerce-service/src/test/java/com/loopers/application/service/AdminBrandServiceTest.java` | Admin Service 테스트 | @@ -274,8 +274,8 @@ public interface BrandJpaRepository extends JpaRepository { |------|------| | `presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java` | User Controller | | `presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/AdminBrandController.java` | Admin Controller | -| `presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/CreateBrandApiRequest.java` | Presentation 생성 DTO | -| `presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/UpdateBrandApiRequest.java` | Presentation 수정 DTO | +| `presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandCreateApiRequest.java` | Presentation 생성 DTO | +| `presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandUpdateApiRequest.java` | Presentation 수정 DTO | | `presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandApiResponse.java` | Presentation 응답 DTO | | `presentation/commerce-api/src/test/java/com/loopers/controller/BrandE2ETest.java` | E2E 테스트 | diff --git a/docs/planning/product-plan.md b/docs/planning/product-plan.md index ef7d2b6f3..3ff5886ce 100644 --- a/docs/planning/product-plan.md +++ b/docs/planning/product-plan.md @@ -191,23 +191,23 @@ public class Product extends BaseEntity { ### 2-4. Brand와의 관계: `brandId` (FK only) - Product는 `brandId`만 가진다 (JPA `@ManyToOne` 연관관계 사용하지 않음). -- Brand 유효성 검증은 CatalogDomainService(Domain 레이어)에서 수행한다. -- 이유: Brand와 Product는 같은 BC(Catalog)의 독립 Aggregate. cross-aggregate 규칙은 Domain Service가 담당한다. +- Brand 활성 여부 확인은 AdminProductService(Application 레이어)에서 오케스트레이션한다. +- 이유: Brand 활성 검증은 상품 등록의 사전 조건이지 독립적 도메인 규칙이 아니다. ### 2-5. DTO 분리 구조 ``` Presentation Layer (commerce-api) ├── interfaces/api/product/dto/ -│ ├── CreateProductApiRequest.java → CreateProductCommand 변환 -│ ├── UpdateProductApiRequest.java → UpdateProductCommand 변환 +│ ├── ProductCreateApiRequest.java → ProductCreateCommand 변환 +│ ├── ProductUpdateApiRequest.java → ProductUpdateCommand 변환 │ ├── ProductApiResponse.java ← ProductInfo 변환 │ └── ProductListApiResponse.java ← ProductSummary 변환 │ Application Layer (commerce-service) ├── application/service/dto/ -│ ├── CreateProductCommand.java (record) -│ ├── UpdateProductCommand.java (record) +│ ├── ProductCreateCommand.java (record) +│ ├── ProductUpdateCommand.java (record) │ ├── ProductInfo.java (record, from(Product)) │ └── ProductSummary.java (record, 목록용 간략 정보) ``` @@ -260,30 +260,19 @@ public long softDeleteByBrandId(Long brandId) { } ``` -### 2-7. CatalogDomainService (cross-aggregate 규칙) +### 2-7. BrandDeleteService (cross-aggregate 규칙) -Brand와 Product는 같은 BC(Catalog)의 독립 Aggregate. cross-aggregate 규칙은 **CatalogDomainService**(Domain 레이어)에서 처리한다. +Brand 삭제 시 소속 Product 연쇄 soft-delete는 **BrandDeleteService**(Domain 레이어)에서 처리한다. 상품 등록 시 Brand 활성 검증은 AdminProductService(Application)에서 오케스트레이션한다. -> 파일: `domain/src/main/java/com/loopers/domain/catalog/CatalogDomainService.java` +> 파일: `domain/src/main/java/com/loopers/domain/catalog/BrandDeleteService.java` ```java @RequiredArgsConstructor -public class CatalogDomainService { +public class BrandDeleteService { private final BrandRepository brandRepository; private final ProductRepository productRepository; - // 상품 생성 시 브랜드 검증 - public Product createProduct(Long brandId, String name, int price, int stock, String description) { - Brand brand = brandRepository.findById(brandId) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, - BrandExceptionMessage.NOT_FOUND.message())); - brand.guardNotDeleted(); - Product product = Product.create(brandId, name, price, stock, description); - return productRepository.save(product); - } - - // 브랜드 삭제 + 소속 상품 연쇄 삭제 - public void deleteBrand(Long brandId) { + public void delete(Long brandId) { Brand brand = brandRepository.findById(brandId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, BrandExceptionMessage.NOT_FOUND.message())); @@ -293,7 +282,7 @@ public class CatalogDomainService { } ``` -호출 규칙: Application Service → CatalogDomainService. Controller 직접 호출 금지 (트랜잭션 보장). +호출 규칙: Application Service → BrandDeleteService. Controller 직접 호출 금지 (트랜잭션 보장). ### 2-8. 향후 Domain Service 도입 후보 @@ -414,12 +403,12 @@ public interface ProductRepository { - `getActiveProduct(Long id)` — 활성 상품 단건 조회 **AdminProductService** (Admin): -- `create(CreateProductCommand)` — 생성 +- `create(ProductCreateCommand)` — 생성 - `getById(Long)` — 단건 조회 (삭제 포함) - `getAll()` — 전체 목록 조회 -- `update(Long, UpdateProductCommand)` — 수정 +- `update(Long, ProductUpdateCommand)` — 수정 - `delete(Long)` — soft-delete -- `softDeleteByBrandId(Long)` — 벌크 삭제 (CatalogDomainService에서 호출) +- `softDeleteByBrandId(Long)` — 벌크 삭제 (BrandDeleteService에서 호출) ### Step 9: Repository Adapter (QueryDSL) @@ -441,27 +430,23 @@ QueryDSL 구현: - `GET /api/products/{id}` → `ProductService.getActiveProduct()` **AdminProductController**: -- `POST /api/admin/products` → `AdminProductService.create()` (CatalogDomainService.createProduct() 경유) +- `POST /api/admin/products` → `AdminProductService.create()` (Brand 활성 확인 후 생성) - `GET /api/admin/products/{id}` → `AdminProductService.getById()` - `GET /api/admin/products` → `AdminProductService.getAll()` - `PUT /api/admin/products/{id}` → `AdminProductService.update()` - `DELETE /api/admin/products/{id}` → `AdminProductService.delete()` -### Step 11: CatalogDomainService (cross-aggregate 규칙) - -> 파일: `domain/src/main/java/com/loopers/domain/catalog/CatalogDomainService.java` -> 테스트: `domain/src/test/java/com/loopers/domain/catalog/CatalogDomainServiceTest.java` +### Step 11: BrandDeleteService (cross-aggregate 규칙) -테스트 케이스 (상품 생성 시 브랜드 검증): -- 브랜드가 존재하고 활성 상태면 상품 생성 성공 -- 브랜드가 없으면 NOT_FOUND 예외 -- 브랜드가 삭제 상태면 BAD_REQUEST 예외 +> 파일: `domain/src/main/java/com/loopers/domain/catalog/BrandDeleteService.java` +> 테스트: `domain/src/test/java/com/loopers/domain/catalog/BrandDeleteServiceTest.java` 테스트 케이스 (브랜드 삭제 연쇄): - 브랜드 삭제 시 소속 상품도 soft-delete - 소속 상품이 없어도 브랜드 삭제 정상 수행 +- 존재하지 않는 브랜드 삭제 시 NOT_FOUND 예외 -> **주의**: Brand 계획서의 AdminBrandController DELETE 엔드포인트가 CatalogDomainService.deleteBrand()을 경유하도록 교체 +> **주의**: Brand 계획서의 AdminBrandController DELETE 엔드포인트가 BrandDeleteService.delete()를 경유하도록 교체 ### Step 13: Cascade 통합 테스트 @@ -506,8 +491,8 @@ QueryDSL 구현: | `domain/src/test/java/com/loopers/domain/product/vo/StockTest.java` | Stock VO 테스트 | | `domain/src/test/java/com/loopers/domain/product/vo/QuantityTest.java` | Quantity VO 테스트 | | `domain/src/testFixtures/java/com/loopers/domain/product/ProductFixture.java` | Fixture | -| `domain/src/main/java/com/loopers/domain/catalog/CatalogDomainService.java` | Catalog BC Domain Service | -| `domain/src/test/java/com/loopers/domain/catalog/CatalogDomainServiceTest.java` | Domain Service 테스트 | +| `domain/src/main/java/com/loopers/domain/catalog/BrandDeleteService.java` | Brand 삭제 Domain Service | +| `domain/src/test/java/com/loopers/domain/catalog/BrandDeleteServiceTest.java` | Domain Service 테스트 | ### Application Layer (`application/commerce-service/`) @@ -515,8 +500,8 @@ QueryDSL 구현: |------|------| | `application/commerce-service/src/main/java/com/loopers/application/service/ProductService.java` | User Service | | `application/commerce-service/src/main/java/com/loopers/application/service/AdminProductService.java` | Admin Service | -| `application/commerce-service/src/main/java/com/loopers/application/service/dto/CreateProductCommand.java` | 생성 DTO | -| `application/commerce-service/src/main/java/com/loopers/application/service/dto/UpdateProductCommand.java` | 수정 DTO | +| `application/commerce-service/src/main/java/com/loopers/application/service/dto/ProductCreateCommand.java` | 생성 DTO | +| `application/commerce-service/src/main/java/com/loopers/application/service/dto/ProductUpdateCommand.java` | 수정 DTO | | `application/commerce-service/src/main/java/com/loopers/application/service/dto/ProductInfo.java` | 상세 응답 DTO | | `application/commerce-service/src/main/java/com/loopers/application/service/dto/ProductSummary.java` | 목록 응답 DTO | | `application/commerce-service/src/test/java/com/loopers/application/service/ProductServiceTest.java` | User Service 테스트 | @@ -528,8 +513,8 @@ QueryDSL 구현: |------|------| | `presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java` | User Controller | | `presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductController.java` | Admin Controller | -| `presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/CreateProductApiRequest.java` | Presentation 생성 DTO | -| `presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/UpdateProductApiRequest.java` | Presentation 수정 DTO | +| `presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductCreateApiRequest.java` | Presentation 생성 DTO | +| `presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductUpdateApiRequest.java` | Presentation 수정 DTO | | `presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductApiResponse.java` | Presentation 상세 응답 DTO | | `presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductListApiResponse.java` | Presentation 목록 응답 DTO | | `presentation/commerce-api/src/test/java/com/loopers/controller/ProductE2ETest.java` | E2E 테스트 | diff --git a/docs/thought/architecture-direction-v2.md b/docs/thought/architecture-direction-v2.md index a0c00c513..d64752ea0 100644 --- a/docs/thought/architecture-direction-v2.md +++ b/docs/thought/architecture-direction-v2.md @@ -144,9 +144,9 @@ Application 레이어의 `@Configuration` 클래스에서 `@Bean`으로 등록 @Configuration public class DomainServiceConfig { @Bean - public CatalogDomainService catalogDomainService( + public BrandDeleteService brandDeleteService( BrandRepository brandRepo, ProductRepository productRepo) { - return new CatalogDomainService(brandRepo, productRepo); + return new BrandDeleteService(brandRepo, productRepo); } } ``` diff --git a/docs/thought/volume3-discussion-log.md b/docs/thought/volume3-discussion-log.md index 2e9013bfe..88d458439 100644 --- a/docs/thought/volume3-discussion-log.md +++ b/docs/thought/volume3-discussion-log.md @@ -14,7 +14,7 @@ 3. [도메인 정의 작업](#3-도메인-정의-작업) (2026-02-10) 4. [Brand 도메인 분석 — Soft Delete vs Hard Delete](#4-brand-도메인-분석--soft-delete-vs-hard-delete) (2026-02-12) 5. [Brand & Product BC 설계](#5-brand--product-bc-설계) (2026-02-22) -6. [Facade에서 CatalogDomainService로의 전환](#6-facade에서-catalogdomainservice로의-전환) (2026-02-22) +6. [Facade에서 BrandDeleteService로의 전환](#6-facade에서-branddeleteservice로의-전환) (2026-02-22) 7. [아키텍처 설계서 작성 논의](#7-아키텍처-설계서-작성-논의) (2026-02-22~23) 8. [핵심 설계 결정 요약](#8-핵심-설계-결정-요약) 9. [Member DIP 리팩토링](#9-member-dip-리팩토링) (2026-02-24) @@ -233,12 +233,12 @@ Soft Delete 채택 (`BaseEntity.deletedAt` 활용): **FK 제약조건 없음:** - 같은 BC 내부(product.brand_id → brand.id)에도 FK 없음 -- 삭제 연쇄를 DB CASCADE가 아닌 `CatalogDomainService`로 명시적 제어 +- 삭제 연쇄를 DB CASCADE가 아닌 `BrandDeleteService`로 명시적 제어 - 규칙이 코드에 표현되어 추적 가능 --- -## 6. Facade에서 CatalogDomainService로의 전환 +## 6. Facade에서 BrandDeleteService로의 전환 > 세션일: 2026-02-22 @@ -266,25 +266,24 @@ Brand 삭제 → Product 연쇄 삭제는: - **도메인 규칙** ("브랜드가 폐점하면 소속 상품도 비활성화") - 기술적 조율이 아닌 **비즈니스 규칙** -따라서 Facade가 아닌 **CatalogDomainService**가 적합. +따라서 Facade가 아닌 **BrandDeleteService**가 적합. ### 6-3. 전환 결과 **변경 후:** ``` -AdminBrandController → AdminBrandService → CatalogDomainService +AdminBrandController → AdminBrandService → BrandDeleteService ├── BrandRepository └── ProductRepository ``` -**CatalogDomainService의 책임:** +**BrandDeleteService의 책임:** - Brand 삭제 시 소속 Product 연쇄 soft-delete -- Product 생성 시 Brand 활성 여부 검증 **반영 문서:** -- `02-sequence-diagrams.md` 5-3절: AdminProductFacade → CatalogDomainService +- `02-sequence-diagrams.md` 5-3절: AdminProductFacade → BrandDeleteService - `03-class-diagram.md` 4절: Facade 테이블 → Domain Service 테이블 -- `brand-plan.md` 2-5절: CatalogDomainService 설계 +- `brand-plan.md` 2-5절: BrandDeleteService 설계 - `05-domain-model.md` 4-3절: Domain Service vs Facade 구분 --- @@ -341,7 +340,7 @@ domain/src/main/java/com/loopers/domain/ │ │ ├── Product.java │ │ ├── ProductRepository.java │ │ └── ProductExceptionMessage.java -│ └── CatalogDomainService.java +│ └── BrandDeleteService.java ├── member/ ├── like/ └── order/ @@ -383,7 +382,7 @@ Infrastructure: Repository 구현체, JPA Config | `MemberInfo` | `MemberQuery` | `MemberQuery.from(Member member)` | **네이밍 컨벤션:** -- 요청 DTO: **Command** (`CreateBrandCommand`, `UpdateBrandCommand`) +- 요청 DTO: **Command** (`BrandCreateCommand`, `BrandUpdateCommand`) - 응답 DTO: **Query** (`BrandQuery`) ### 7-6. Port-Adapter → Repository 추상체/구현체 @@ -462,7 +461,7 @@ public void decreaseStock(Quantity quantity) { | 상황 | 트랜잭션 전략 | 예시 | |------|-------------|------| -| 같은 BC, cross-aggregate | **같은 트랜잭션** | Brand 삭제 → Product 연쇄 (CatalogDomainService) | +| 같은 BC, cross-aggregate | **같은 트랜잭션** | Brand 삭제 → Product 연쇄 (BrandDeleteService) | | 다른 BC (모놀리스) | **같은 트랜잭션** (실용적) | 주문 생성 → 재고 차감 (OrderService에서 조율) | | 다른 BC (규모 확장 시) | 이벤트 기반 (eventual consistency) | 현재 해당 없음 | @@ -508,7 +507,7 @@ public void decreaseStock(Quantity quantity) { |------|--------|-------|------| | Phase 2 | `MemberPolicy` 분리 | Entity/VO 내재화 | 응집도 향상, 코드 위치 근접성 | | Phase 2 | `DomainException` hierarchy | `ErrorType` pure enum | 간결, 해석 자유도 | -| 설계 단계 | `AdminBrandFacade` | `CatalogDomainService` | 같은 BC = Domain Service | +| 설계 단계 | `AdminBrandFacade` | `BrandDeleteService` | 같은 BC = Domain Service | | 설계 단계 | `ProductLike` (Catalog BC) | `Like` (독립 BC) | 확장성, 책임 분리 | | 문서 단계 | `BrandInfo` (DTO) | `BrandQuery` (DTO) | 의미론적 명확성 | | 문서 단계 | Port / Adapter | Repository 추상체 / 구현체 | 레이어드 아키텍처 용어 통일 | @@ -649,19 +648,19 @@ Presentation: MemberApiResponse.from(MemberInfo) ← String 변환 | 방향 | 패턴 | 예시 | |------|------|------| -| Inbound (상태 변경) | `{Action}{Domain}Command` | `RegisterMemberCommand`, `CreateBrandCommand` | +| Inbound (상태 변경) | `{Domain}{Action}Command` | `MemberRegisterCommand`, `BrandCreateCommand` | | Outbound (조회 결과) | `{Domain}Info` | `MemberInfo`, `BrandInfo` | **Presentation Layer:** | 방향 | 패턴 | 예시 | |------|------|------| -| Inbound (Request Body) | `{Action}{Domain}ApiRequest` | `RegisterMemberApiRequest`, `CreateBrandApiRequest` | +| Inbound (Request Body) | `{Domain}{Action}ApiRequest` | `MemberRegisterApiRequest`, `BrandCreateApiRequest` | | Outbound (Response Body) | `{Domain}ApiResponse` | `MemberApiResponse`, `BrandApiResponse` | **변환 흐름:** ``` -HTTP Request → {Action}{Domain}ApiRequest.toCommand() → {Action}{Domain}Command +HTTP Request → {Domain}{Action}ApiRequest.toCommand() → {Domain}{Action}Command {Domain}Info → {Domain}ApiResponse.from({Domain}Info) → HTTP Response ``` @@ -669,8 +668,8 @@ HTTP Request → {Action}{Domain}ApiRequest.toCommand() → {Action}{Domain}Comm | Before | After | |--------|-------| -| `RegisterMemberRequest` | `RegisterMemberCommand` | -| `UpdatePasswordRequest` | `UpdatePasswordCommand` | +| `RegisterMemberRequest` | `MemberRegisterCommand` | +| `UpdatePasswordRequest` | `PasswordUpdateCommand` | | `GetMemberInfoResponse` | `MemberInfo` | | `GetMemberInfoApiResponse` | `MemberApiResponse` | diff --git a/domain/build.gradle.kts b/domain/build.gradle.kts index caff74485..eff9066d0 100644 --- a/domain/build.gradle.kts +++ b/domain/build.gradle.kts @@ -6,4 +6,11 @@ plugins { dependencies { api("jakarta.persistence:jakarta.persistence-api") api(project(":supports:error")) + implementation("org.springframework:spring-context") + + // querydsl Q클래스 생성 (엔티티가 domain에 위치하므로 여기서 apt 실행) + api("com.querydsl:querydsl-jpa::jakarta") + annotationProcessor("com.querydsl:querydsl-apt::jakarta") + annotationProcessor("jakarta.persistence:jakarta.persistence-api") + annotationProcessor("jakarta.annotation:jakarta.annotation-api") } diff --git a/domain/src/main/java/com/loopers/domain/BaseEntity.java b/domain/src/main/java/com/loopers/domain/BaseEntity.java index 9824d8333..299c832d7 100644 --- a/domain/src/main/java/com/loopers/domain/BaseEntity.java +++ b/domain/src/main/java/com/loopers/domain/BaseEntity.java @@ -19,8 +19,12 @@ public abstract class BaseEntity extends BaseTimeEntity { /** * delete 연산은 멱등하게 동작할 수 있도록 한다. (삭제된 엔티티를 다시 삭제해도 동일한 결과가 나오도록) */ + public boolean isDeleted() { + return this.deletedAt != null; + } + public void delete() { - if (this.deletedAt == null) { + if (!isDeleted()) { this.deletedAt = ZonedDateTime.now(); } } @@ -29,7 +33,7 @@ public void delete() { * restore 연산은 멱등하게 동작할 수 있도록 한다. (삭제되지 않은 엔티티를 복원해도 동일한 결과가 나오도록) */ public void restore() { - if (this.deletedAt != null) { + if (isDeleted()) { this.deletedAt = null; } } diff --git a/domain/src/main/java/com/loopers/domain/catalog/BrandDeleteService.java b/domain/src/main/java/com/loopers/domain/catalog/BrandDeleteService.java new file mode 100644 index 000000000..4355a1b3c --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/catalog/BrandDeleteService.java @@ -0,0 +1,27 @@ +package com.loopers.domain.catalog; + +import com.loopers.domain.catalog.brand.Brand; +import com.loopers.domain.catalog.brand.BrandExceptionMessage; +import com.loopers.domain.catalog.brand.BrandRepository; +import com.loopers.domain.catalog.product.ProductRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class BrandDeleteService { + + private final BrandRepository brandRepository; + private final ProductRepository productRepository; + + public void delete(Long brandId) { + Brand brand = brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + BrandExceptionMessage.Brand.NOT_FOUND.message())); + + productRepository.softDeleteByBrandId(brandId); + brand.delete(); + } +} diff --git a/domain/src/main/java/com/loopers/domain/catalog/brand/Brand.java b/domain/src/main/java/com/loopers/domain/catalog/brand/Brand.java new file mode 100644 index 000000000..4079bac05 --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/catalog/brand/Brand.java @@ -0,0 +1,56 @@ +package com.loopers.domain.catalog.brand; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.catalog.vo.Name; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "brand") +public class Brand extends BaseEntity { + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "name", nullable = false, length = 100, unique = true)) + private Name name; + + private Brand(Name name) { + this.name = name; + } + + public static Brand register(String name) { + return new Brand(Name.of(name)); + } + + public boolean hasName(String name) { + return this.name.getValue().equals(name); + } + + public boolean hasNameStartingWith(String prefix) { + return this.name.startsWith(prefix); + } + + public void updateName(String name) { + guardNotDeleted(); + this.name = Name.of(name); + } + + @Override + public void delete() { + guardNotDeleted(); + this.name = Name.of(this.name.getValue() + "_deleted_" + System.currentTimeMillis()); + super.delete(); + } + + private void guardNotDeleted() { + if (isDeleted()) { + throw new CoreException(ErrorType.BAD_REQUEST, + BrandExceptionMessage.Brand.ALREADY_DELETED.message()); + } + } +} diff --git a/domain/src/main/java/com/loopers/domain/catalog/brand/BrandExceptionMessage.java b/domain/src/main/java/com/loopers/domain/catalog/brand/BrandExceptionMessage.java new file mode 100644 index 000000000..deb2140fd --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/catalog/brand/BrandExceptionMessage.java @@ -0,0 +1,21 @@ +package com.loopers.domain.catalog.brand; + +import lombok.AllArgsConstructor; + +public class BrandExceptionMessage { + + @AllArgsConstructor + public enum Brand { + INVALID_NAME("브랜드명은 1자 이상 100자 이하여야 합니다.", 2_001), + DUPLICATE_NAME("이미 존재하는 브랜드명입니다.", 2_002), + NOT_FOUND("존재하지 않는 브랜드입니다.", 2_003), + ALREADY_DELETED("이미 삭제된 브랜드입니다.", 2_004); + + private final String message; + private final Integer code; + + public String message() { + return message; + } + } +} diff --git a/domain/src/main/java/com/loopers/domain/catalog/brand/BrandRepository.java b/domain/src/main/java/com/loopers/domain/catalog/brand/BrandRepository.java new file mode 100644 index 000000000..2eca1a6a2 --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/catalog/brand/BrandRepository.java @@ -0,0 +1,19 @@ +package com.loopers.domain.catalog.brand; + +import java.util.List; +import java.util.Optional; + +public interface BrandRepository { + + Brand save(Brand brand); + + Optional findById(Long id); + + boolean existsByName(String name); + + List findAllByDeletedAtIsNull(); + + List findAll(); + + List findAllByIdIn(List ids); +} diff --git a/domain/src/main/java/com/loopers/domain/catalog/product/Product.java b/domain/src/main/java/com/loopers/domain/catalog/product/Product.java new file mode 100644 index 000000000..60f662638 --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/catalog/product/Product.java @@ -0,0 +1,92 @@ +package com.loopers.domain.catalog.product; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.catalog.product.vo.Money; +import com.loopers.domain.catalog.product.vo.Quantity; +import com.loopers.domain.catalog.product.vo.Stock; +import com.loopers.domain.catalog.vo.Name; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "product") +public class Product extends BaseEntity { + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "name", nullable = false, length = 100)) + private Name name; + + @Column(name = "description") + private String description; + + @Embedded + private Money price; + + @Embedded + private Stock stock; + + @Column(name = "brand_id", nullable = false) + private Long brandId; + + @Column(name = "likes_count", nullable = false) + private long likesCount; + + private Product(Name name, String description, Money price, Stock stock, Long brandId) { + this.name = name; + this.description = description; + this.price = price; + this.stock = stock; + this.brandId = brandId; + this.likesCount = 0L; + } + + public static Product register(String name, String description, Money price, Stock stock, Long brandId) { + return new Product(Name.of(name), description, price, stock, brandId); + } + + public boolean hasName(String name) { + return this.name.getValue().equals(name); + } + + public boolean belongsToBrand(Long brandId) { + return this.brandId.equals(brandId); + } + + public boolean hasDescription() { + return this.description != null; + } + + public boolean hasStock(long value) { + return this.stock.isEqualTo(value); + } + + public void update(String name, String description, Money price, Stock stock) { + guardNotDeleted(); + this.name = Name.of(name); + this.description = description; + this.price = price; + this.stock = stock; + } + + public void decreaseStock(Quantity quantity) { + guardNotDeleted(); + this.stock = this.stock.decrease(quantity); + } + + public boolean hasEnoughStock(Quantity quantity) { + return this.stock.isEnough(quantity); + } + + private void guardNotDeleted() { + if (isDeleted()) { + throw new CoreException(ErrorType.BAD_REQUEST, + ProductExceptionMessage.Product.ALREADY_DELETED.message()); + } + } +} diff --git a/domain/src/main/java/com/loopers/domain/catalog/product/ProductExceptionMessage.java b/domain/src/main/java/com/loopers/domain/catalog/product/ProductExceptionMessage.java new file mode 100644 index 000000000..d899680af --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/catalog/product/ProductExceptionMessage.java @@ -0,0 +1,57 @@ +package com.loopers.domain.catalog.product; + +import lombok.AllArgsConstructor; + +public class ProductExceptionMessage { + + @AllArgsConstructor + public enum Product { + INVALID_NAME("상품명은 1자 이상 100자 이하여야 합니다.", 3_001), + NOT_FOUND("존재하지 않는 상품입니다.", 3_002), + ALREADY_DELETED("이미 삭제된 상품입니다.", 3_003); + + private final String message; + private final Integer code; + + public String message() { + return message; + } + } + + @AllArgsConstructor + public enum Price { + INVALID_PRICE("가격은 0보다 커야 합니다.", 3_101); + + private final String message; + private final Integer code; + + public String message() { + return message; + } + } + + @AllArgsConstructor + public enum Stock { + INVALID_STOCK("재고는 0 이상이어야 합니다.", 3_201), + INSUFFICIENT_STOCK("재고가 부족합니다.", 3_202); + + private final String message; + private final Integer code; + + public String message() { + return message; + } + } + + @AllArgsConstructor + public enum Quantity { + INVALID_QUANTITY("수량은 0보다 커야 합니다.", 3_301); + + private final String message; + private final Integer code; + + public String message() { + return message; + } + } +} diff --git a/domain/src/main/java/com/loopers/domain/catalog/product/ProductRepository.java b/domain/src/main/java/com/loopers/domain/catalog/product/ProductRepository.java new file mode 100644 index 000000000..7a38dbec5 --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/catalog/product/ProductRepository.java @@ -0,0 +1,17 @@ +package com.loopers.domain.catalog.product; + +import java.util.List; +import java.util.Optional; + +public interface ProductRepository { + + Product save(Product product); + + Optional findById(Long id); + + List findAllActive(ProductSortType sortType); + + List findAll(); + + void softDeleteByBrandId(Long brandId); +} diff --git a/domain/src/main/java/com/loopers/domain/catalog/product/ProductSortType.java b/domain/src/main/java/com/loopers/domain/catalog/product/ProductSortType.java new file mode 100644 index 000000000..1ddc926b8 --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/catalog/product/ProductSortType.java @@ -0,0 +1,7 @@ +package com.loopers.domain.catalog.product; + +public enum ProductSortType { + LATEST, + PRICE_ASC, + LIKES_DESC +} diff --git a/domain/src/main/java/com/loopers/domain/catalog/product/vo/Money.java b/domain/src/main/java/com/loopers/domain/catalog/product/vo/Money.java new file mode 100644 index 000000000..bc590b744 --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/catalog/product/vo/Money.java @@ -0,0 +1,45 @@ +package com.loopers.domain.catalog.product.vo; + +import com.loopers.domain.catalog.product.ProductExceptionMessage; +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.Getter; +import lombok.NoArgsConstructor; + +import java.util.Objects; + +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Money { + + @Column(name = "price", nullable = false) + private long value; + + private Money(long value) { + this.value = value; + } + + public static Money of(long value) { + if (value <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, + ProductExceptionMessage.Price.INVALID_PRICE.message()); + } + return new Money(value); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Money money)) return false; + return value == money.value; + } + + @Override + public int hashCode() { + return Objects.hashCode(value); + } +} diff --git a/domain/src/main/java/com/loopers/domain/catalog/product/vo/Quantity.java b/domain/src/main/java/com/loopers/domain/catalog/product/vo/Quantity.java new file mode 100644 index 000000000..cfadba56e --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/catalog/product/vo/Quantity.java @@ -0,0 +1,35 @@ +package com.loopers.domain.catalog.product.vo; + +import com.loopers.domain.catalog.product.ProductExceptionMessage; +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.Getter; +import lombok.NoArgsConstructor; + +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Quantity { + + @Column(name = "quantity", nullable = false) + private long value; + + private Quantity(long value) { + this.value = value; + } + + public boolean isEqualTo(long value) { + return this.value == value; + } + + public static Quantity of(long value) { + if (value <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, + ProductExceptionMessage.Quantity.INVALID_QUANTITY.message()); + } + return new Quantity(value); + } +} diff --git a/domain/src/main/java/com/loopers/domain/catalog/product/vo/Stock.java b/domain/src/main/java/com/loopers/domain/catalog/product/vo/Stock.java new file mode 100644 index 000000000..8d4e11cab --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/catalog/product/vo/Stock.java @@ -0,0 +1,61 @@ +package com.loopers.domain.catalog.product.vo; + +import com.loopers.domain.catalog.product.ProductExceptionMessage; +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.Getter; +import lombok.NoArgsConstructor; + +import java.util.Objects; + +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Stock { + + @Column(name = "stock", nullable = false) + private long value; + + private Stock(long value) { + this.value = value; + } + + public static Stock of(long value) { + if (value < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, + ProductExceptionMessage.Stock.INVALID_STOCK.message()); + } + return new Stock(value); + } + + public boolean isEqualTo(long value) { + return this.value == value; + } + + public Stock decrease(Quantity quantity) { + if (!isEnough(quantity)) { + throw new CoreException(ErrorType.BAD_REQUEST, + ProductExceptionMessage.Stock.INSUFFICIENT_STOCK.message()); + } + return new Stock(this.value - quantity.getValue()); + } + + public boolean isEnough(Quantity quantity) { + return this.value >= quantity.getValue(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Stock stock)) return false; + return value == stock.value; + } + + @Override + public int hashCode() { + return Objects.hashCode(value); + } +} diff --git a/domain/src/main/java/com/loopers/domain/catalog/vo/Name.java b/domain/src/main/java/com/loopers/domain/catalog/vo/Name.java new file mode 100644 index 000000000..560b89e8c --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/catalog/vo/Name.java @@ -0,0 +1,48 @@ +package com.loopers.domain.catalog.vo; + +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.Getter; +import lombok.NoArgsConstructor; + +import java.util.Objects; + +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Name { + + @Column(name = "name", nullable = false, length = 100) + private String value; + + private Name(String value) { + this.value = value; + } + + public static Name of(String value) { + if (value == null || value.isBlank() || value.length() > 100) { + throw new CoreException(ErrorType.BAD_REQUEST, + "이름은 1자 이상 100자 이하여야 합니다."); + } + return new Name(value); + } + + public boolean startsWith(String prefix) { + return this.value.startsWith(prefix); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Name name)) return false; + return Objects.equals(value, name.value); + } + + @Override + public int hashCode() { + return Objects.hashCode(value); + } +} diff --git a/domain/src/main/java/com/loopers/domain/member/Member.java b/domain/src/main/java/com/loopers/domain/member/Member.java index 883733e84..9b08f36c9 100644 --- a/domain/src/main/java/com/loopers/domain/member/Member.java +++ b/domain/src/main/java/com/loopers/domain/member/Member.java @@ -14,7 +14,6 @@ import lombok.NoArgsConstructor; import java.time.LocalDate; -import java.util.Objects; @Entity @Getter @@ -59,20 +58,12 @@ public MemberId getMemberId() { return getId() != null ? MemberId.of(getId()) : null; } - public void updatePassword(String newRawPassword, PasswordEncryptor encryptor) { - this.password = password.changeTo(newRawPassword, birthDate, encryptor); - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (!(o instanceof Member member)) return false; - return getId() != null && Objects.equals(getId(), member.getId()); + public boolean matchesPassword(String rawPassword, PasswordEncryptor encryptor) { + return this.password.matches(rawPassword, encryptor); } - @Override - public int hashCode() { - return getClass().hashCode(); + public void updatePassword(String newRawPassword, PasswordEncryptor encryptor) { + this.password = password.changeTo(newRawPassword, birthDate, encryptor); } private static void validateBirthDate(LocalDate birthDate) { diff --git a/domain/src/test/java/com/loopers/domain/catalog/BrandDeleteServiceTest.java b/domain/src/test/java/com/loopers/domain/catalog/BrandDeleteServiceTest.java new file mode 100644 index 000000000..a9beb3a2d --- /dev/null +++ b/domain/src/test/java/com/loopers/domain/catalog/BrandDeleteServiceTest.java @@ -0,0 +1,72 @@ +package com.loopers.domain.catalog; + +import com.loopers.domain.catalog.brand.Brand; +import com.loopers.domain.catalog.brand.BrandExceptionMessage; +import com.loopers.domain.catalog.brand.BrandRepository; +import com.loopers.domain.catalog.product.ProductRepository; +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class BrandDeleteServiceTest { + + @InjectMocks + private BrandDeleteService brandDeleteService; + + @Mock + private BrandRepository brandRepository; + + @Mock + private ProductRepository productRepository; + + @Test + void 브랜드_삭제_시_소속_상품_연쇄_삭제() { + // given + Long brandId = 1L; + Brand brand = Brand.register("나이키"); + given(brandRepository.findById(brandId)).willReturn(Optional.of(brand)); + + // when + brandDeleteService.delete(brandId); + + // then + verify(productRepository).softDeleteByBrandId(brandId); + } + + @Test + void 브랜드_삭제_시_브랜드_deletedAt_설정() { + // given + Long brandId = 1L; + Brand brand = Brand.register("나이키"); + given(brandRepository.findById(brandId)).willReturn(Optional.of(brand)); + + // when + brandDeleteService.delete(brandId); + + // then + assertThat(brand.isDeleted()).isTrue(); + } + + @Test + void 존재하지_않는_브랜드_삭제_시_예외() { + // given + Long brandId = 999L; + given(brandRepository.findById(brandId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> brandDeleteService.delete(brandId)) + .isInstanceOf(CoreException.class) + .hasMessage(BrandExceptionMessage.Brand.NOT_FOUND.message()); + } +} diff --git a/domain/src/test/java/com/loopers/domain/catalog/brand/BrandTest.java b/domain/src/test/java/com/loopers/domain/catalog/brand/BrandTest.java new file mode 100644 index 000000000..2527264b7 --- /dev/null +++ b/domain/src/test/java/com/loopers/domain/catalog/brand/BrandTest.java @@ -0,0 +1,160 @@ +package com.loopers.domain.catalog.brand; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class BrandTest { + + @Test + void 브랜드_등록_성공() { + // given + String name = "나이키"; + + // when + Brand brand = Brand.register(name); + + // then + assertThat(brand.hasName(name)).isTrue(); + } + + @Test + void 이름_1자_등록_성공() { + // when + Brand brand = Brand.register("A"); + + // then + assertThat(brand.hasName("A")).isTrue(); + } + + @Test + void 이름_100자_경계값_등록_성공() { + // given + String name = "a".repeat(100); + + // when + Brand brand = Brand.register(name); + + // then + assertThat(brand.hasName(name)).isTrue(); + } + + @Test + void 빈_이름으로_등록_시_예외() { + // when & then + assertThatThrownBy(() -> Brand.register("")) + .isInstanceOf(CoreException.class) + .hasMessage("이름은 1자 이상 100자 이하여야 합니다."); + } + + @Test + void null_이름으로_등록_시_예외() { + // when & then + assertThatThrownBy(() -> Brand.register(null)) + .isInstanceOf(CoreException.class) + .hasMessage("이름은 1자 이상 100자 이하여야 합니다."); + } + + @Test + void 공백만_있는_이름_등록_시_예외() { + // when & then + assertThatThrownBy(() -> Brand.register(" ")) + .isInstanceOf(CoreException.class) + .hasMessage("이름은 1자 이상 100자 이하여야 합니다."); + } + + @Test + void 이름_길이_초과_시_예외() { + // given + String longName = "a".repeat(101); + + // when & then + assertThatThrownBy(() -> Brand.register(longName)) + .isInstanceOf(CoreException.class) + .hasMessage("이름은 1자 이상 100자 이하여야 합니다."); + } + + @Test + void 이름_수정_성공() { + // given + Brand brand = Brand.register("나이키"); + + // when + brand.updateName("아디다스"); + + // then + assertThat(brand.hasName("아디다스")).isTrue(); + } + + @Test + void 수정_시_빈_이름_예외() { + // given + Brand brand = Brand.register("나이키"); + + // when & then + assertThatThrownBy(() -> brand.updateName("")) + .isInstanceOf(CoreException.class) + .hasMessage("이름은 1자 이상 100자 이하여야 합니다."); + } + + @Test + void 수정_시_이름_길이_초과_예외() { + // given + Brand brand = Brand.register("나이키"); + + // when & then + assertThatThrownBy(() -> brand.updateName("a".repeat(101))) + .isInstanceOf(CoreException.class) + .hasMessage("이름은 1자 이상 100자 이하여야 합니다."); + } + + @Test + void 삭제_시_deletedAt_설정() { + // given + Brand brand = Brand.register("나이키"); + + // when + brand.delete(); + + // then + assertThat(brand.isDeleted()).isTrue(); + } + + @Test + void 삭제_시_이름에_deleted_접미사_추가() { + // given + Brand brand = Brand.register("나이키"); + + // when + brand.delete(); + + // then + assertThat(brand.hasNameStartingWith("나이키_deleted_")).isTrue(); + } + + @Test + void 이미_삭제된_브랜드_재삭제_시_예외() { + // given + Brand brand = Brand.register("나이키"); + brand.delete(); + + // when & then + assertThatThrownBy(brand::delete) + .isInstanceOf(CoreException.class) + .hasMessage(BrandExceptionMessage.Brand.ALREADY_DELETED.message()); + } + + @Test + void 이미_삭제된_브랜드_수정_시_예외() { + // given + Brand brand = Brand.register("나이키"); + brand.delete(); + + // when & then + assertThatThrownBy(() -> brand.updateName("아디다스")) + .isInstanceOf(CoreException.class) + .hasMessage(BrandExceptionMessage.Brand.ALREADY_DELETED.message()); + } +} diff --git a/domain/src/test/java/com/loopers/domain/catalog/product/ProductTest.java b/domain/src/test/java/com/loopers/domain/catalog/product/ProductTest.java new file mode 100644 index 000000000..1309b5cb1 --- /dev/null +++ b/domain/src/test/java/com/loopers/domain/catalog/product/ProductTest.java @@ -0,0 +1,115 @@ +package com.loopers.domain.catalog.product; + +import com.loopers.domain.catalog.product.vo.Money; +import com.loopers.domain.catalog.product.vo.Quantity; +import com.loopers.domain.catalog.product.vo.Stock; +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ProductTest { + + @Test + void 상품_등록_성공() { + // when + Product product = Product.register("티셔츠", "기본 티셔츠", Money.of(10000L), Stock.of(100L), 1L); + + // then + assertThat(product.hasName("티셔츠")).isTrue(); + } + + @Test + void 상품_등록_시_brandId_설정() { + // when + Product product = Product.register("티셔츠", "기본 티셔츠", Money.of(10000L), Stock.of(100L), 1L); + + // then + assertThat(product.belongsToBrand(1L)).isTrue(); + } + + @Test + void 상품_등록_시_description_null_허용() { + // when + Product product = Product.register("티셔츠", null, Money.of(10000L), Stock.of(100L), 1L); + + // then + assertThat(product.hasDescription()).isFalse(); + } + + @Test + void 빈_이름_등록_시_예외() { + // when & then + assertThatThrownBy(() -> Product.register("", "설명", Money.of(10000L), Stock.of(100L), 1L)) + .isInstanceOf(CoreException.class) + .hasMessage("이름은 1자 이상 100자 이하여야 합니다."); + } + + @Test + void 이름_길이_초과_시_예외() { + // when & then + assertThatThrownBy(() -> Product.register("a".repeat(101), "설명", Money.of(10000L), Stock.of(100L), 1L)) + .isInstanceOf(CoreException.class) + .hasMessage("이름은 1자 이상 100자 이하여야 합니다."); + } + + @Test + void 상품_수정_성공() { + // given + Product product = Product.register("티셔츠", "설명", Money.of(10000L), Stock.of(100L), 1L); + + // when + product.update("맨투맨", "새 설명", Money.of(20000L), Stock.of(50L)); + + // then + assertThat(product.hasName("맨투맨")).isTrue(); + } + + @Test + void 재고_차감_성공() { + // given + Product product = Product.register("티셔츠", "설명", Money.of(10000L), Stock.of(100L), 1L); + + // when + product.decreaseStock(Quantity.of(30L)); + + // then + assertThat(product.hasStock(70L)).isTrue(); + } + + @Test + void 재고_부족_시_차감_예외() { + // given + Product product = Product.register("티셔츠", "설명", Money.of(10000L), Stock.of(10L), 1L); + + // when & then + assertThatThrownBy(() -> product.decreaseStock(Quantity.of(11L))) + .isInstanceOf(CoreException.class) + .hasMessage(ProductExceptionMessage.Stock.INSUFFICIENT_STOCK.message()); + } + + @Test + void 삭제된_상품_수정_시_예외() { + // given + Product product = Product.register("티셔츠", "설명", Money.of(10000L), Stock.of(100L), 1L); + product.delete(); + + // when & then + assertThatThrownBy(() -> product.update("맨투맨", "새 설명", Money.of(20000L), Stock.of(50L))) + .isInstanceOf(CoreException.class) + .hasMessage(ProductExceptionMessage.Product.ALREADY_DELETED.message()); + } + + @Test + void 삭제된_상품_재고_차감_시_예외() { + // given + Product product = Product.register("티셔츠", "설명", Money.of(10000L), Stock.of(100L), 1L); + product.delete(); + + // when & then + assertThatThrownBy(() -> product.decreaseStock(Quantity.of(1L))) + .isInstanceOf(CoreException.class) + .hasMessage(ProductExceptionMessage.Product.ALREADY_DELETED.message()); + } +} diff --git a/domain/src/test/java/com/loopers/domain/catalog/product/vo/MoneyTest.java b/domain/src/test/java/com/loopers/domain/catalog/product/vo/MoneyTest.java new file mode 100644 index 000000000..5af2e5ac5 --- /dev/null +++ b/domain/src/test/java/com/loopers/domain/catalog/product/vo/MoneyTest.java @@ -0,0 +1,56 @@ +package com.loopers.domain.catalog.product.vo; + +import com.loopers.domain.catalog.product.ProductExceptionMessage; +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class MoneyTest { + + @Test + void 가격_양수_생성_성공() { + // when + Money money = Money.of(10000L); + + // then + assertThat(money.getValue()).isEqualTo(10000L); + } + + @Test + void 가격_0_생성_시_예외() { + // when & then + assertThatThrownBy(() -> Money.of(0L)) + .isInstanceOf(CoreException.class) + .hasMessage(ProductExceptionMessage.Price.INVALID_PRICE.message()); + } + + @Test + void 가격_음수_생성_시_예외() { + // when & then + assertThatThrownBy(() -> Money.of(-1L)) + .isInstanceOf(CoreException.class) + .hasMessage(ProductExceptionMessage.Price.INVALID_PRICE.message()); + } + + @Test + void 같은_값이면_동등하다() { + // given + Money money1 = Money.of(10000L); + Money money2 = Money.of(10000L); + + // then + assertThat(money1).isEqualTo(money2); + } + + @Test + void 다른_값이면_동등하지_않다() { + // given + Money money1 = Money.of(10000L); + Money money2 = Money.of(20000L); + + // then + assertThat(money1).isNotEqualTo(money2); + } +} diff --git a/domain/src/test/java/com/loopers/domain/catalog/product/vo/QuantityTest.java b/domain/src/test/java/com/loopers/domain/catalog/product/vo/QuantityTest.java new file mode 100644 index 000000000..0b78017be --- /dev/null +++ b/domain/src/test/java/com/loopers/domain/catalog/product/vo/QuantityTest.java @@ -0,0 +1,33 @@ +package com.loopers.domain.catalog.product.vo; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class QuantityTest { + + @Test + void 수량_양수_생성_성공() { + // when + Quantity quantity = Quantity.of(10L); + + // then + assertThat(quantity.getValue()).isEqualTo(10L); + } + + @Test + void 수량_0_생성_시_예외() { + // when & then + assertThatThrownBy(() -> Quantity.of(0L)) + .isInstanceOf(CoreException.class); + } + + @Test + void 수량_음수_생성_시_예외() { + // when & then + assertThatThrownBy(() -> Quantity.of(-1L)) + .isInstanceOf(CoreException.class); + } +} diff --git a/domain/src/test/java/com/loopers/domain/catalog/product/vo/StockTest.java b/domain/src/test/java/com/loopers/domain/catalog/product/vo/StockTest.java new file mode 100644 index 000000000..8b25f5465 --- /dev/null +++ b/domain/src/test/java/com/loopers/domain/catalog/product/vo/StockTest.java @@ -0,0 +1,100 @@ +package com.loopers.domain.catalog.product.vo; + +import com.loopers.domain.catalog.product.ProductExceptionMessage; +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class StockTest { + + @Test + void 재고_0이상_생성_성공() { + // when + Stock stock = Stock.of(100L); + + // then + assertThat(stock.getValue()).isEqualTo(100L); + } + + @Test + void 재고_0_생성_성공() { + // when + Stock stock = Stock.of(0L); + + // then + assertThat(stock.getValue()).isEqualTo(0L); + } + + @Test + void 재고_음수_생성_시_예외() { + // when & then + assertThatThrownBy(() -> Stock.of(-1L)) + .isInstanceOf(CoreException.class) + .hasMessage(ProductExceptionMessage.Stock.INVALID_STOCK.message()); + } + + @Test + void 재고_차감_성공_새_객체_반환() { + // given + Stock stock = Stock.of(100L); + + // when + Stock decreased = stock.decrease(Quantity.of(30L)); + + // then + assertThat(decreased.getValue()).isEqualTo(70L); + } + + @Test + void 재고_차감_시_원본_불변() { + // given + Stock stock = Stock.of(100L); + + // when + stock.decrease(Quantity.of(30L)); + + // then + assertThat(stock.getValue()).isEqualTo(100L); + } + + @Test + void 재고_부족_시_차감_예외() { + // given + Stock stock = Stock.of(10L); + + // when & then + assertThatThrownBy(() -> stock.decrease(Quantity.of(11L))) + .isInstanceOf(CoreException.class) + .hasMessage(ProductExceptionMessage.Stock.INSUFFICIENT_STOCK.message()); + } + + @Test + void 재고_충분_여부_확인_true() { + // given + Stock stock = Stock.of(100L); + + // then + assertThat(stock.isEnough(Quantity.of(100L))).isTrue(); + } + + @Test + void 재고_충분_여부_확인_false() { + // given + Stock stock = Stock.of(10L); + + // then + assertThat(stock.isEnough(Quantity.of(11L))).isFalse(); + } + + @Test + void 같은_값이면_동등하다() { + // given + Stock stock1 = Stock.of(100L); + Stock stock2 = Stock.of(100L); + + // then + assertThat(stock1).isEqualTo(stock2); + } +} diff --git a/domain/src/test/java/com/loopers/domain/member/vo/MemberIdTest.java b/domain/src/test/java/com/loopers/domain/member/vo/MemberIdTest.java index d6a5ed120..ea0e8d04b 100644 --- a/domain/src/test/java/com/loopers/domain/member/vo/MemberIdTest.java +++ b/domain/src/test/java/com/loopers/domain/member/vo/MemberIdTest.java @@ -8,18 +8,6 @@ class MemberIdTest { - @Test - void Long_값으로_생성_성공() { - // given - Long value = 1L; - - // when - MemberId memberId = MemberId.of(value); - - // then - assertThat(memberId.getValue()).isEqualTo(1L); - } - @Test void null_값으로_생성_시_예외() { // given diff --git a/domain/src/testFixtures/java/com/loopers/domain/catalog/brand/BrandFixture.java b/domain/src/testFixtures/java/com/loopers/domain/catalog/brand/BrandFixture.java new file mode 100644 index 000000000..88f5f6e8b --- /dev/null +++ b/domain/src/testFixtures/java/com/loopers/domain/catalog/brand/BrandFixture.java @@ -0,0 +1,14 @@ +package com.loopers.domain.catalog.brand; + +public class BrandFixture { + + public static final String DEFAULT_NAME = "나이키"; + + public static Brand create() { + return Brand.register(DEFAULT_NAME); + } + + public static Brand create(String name) { + return Brand.register(name); + } +} diff --git a/domain/src/testFixtures/java/com/loopers/domain/catalog/product/ProductFixture.java b/domain/src/testFixtures/java/com/loopers/domain/catalog/product/ProductFixture.java new file mode 100644 index 000000000..1fa9c7992 --- /dev/null +++ b/domain/src/testFixtures/java/com/loopers/domain/catalog/product/ProductFixture.java @@ -0,0 +1,18 @@ +package com.loopers.domain.catalog.product; + +import com.loopers.domain.catalog.product.vo.Money; +import com.loopers.domain.catalog.product.vo.Stock; + +public class ProductFixture { + + public static final String DEFAULT_NAME = "기본 티셔츠"; + public static final Long DEFAULT_BRAND_ID = 1L; + + public static Product create() { + return Product.register(DEFAULT_NAME, "상품 설명", Money.of(10000L), Stock.of(100L), DEFAULT_BRAND_ID); + } + + public static Product create(String name, Long brandId) { + return Product.register(name, "상품 설명", Money.of(10000L), Stock.of(100L), brandId); + } +} diff --git a/infrastructure/jpa/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java new file mode 100644 index 000000000..a0758f054 --- /dev/null +++ b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java @@ -0,0 +1,15 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.catalog.brand.Brand; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface BrandJpaRepository extends JpaRepository { + + boolean existsByName_Value(String name); + + List findAllByDeletedAtIsNull(); + + List findAllByIdIn(List ids); +} diff --git a/infrastructure/jpa/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java new file mode 100644 index 000000000..58a728748 --- /dev/null +++ b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java @@ -0,0 +1,46 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.catalog.brand.Brand; +import com.loopers.domain.catalog.brand.BrandRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class BrandRepositoryImpl implements BrandRepository { + + private final BrandJpaRepository brandJpaRepository; + + @Override + public Brand save(Brand brand) { + return brandJpaRepository.save(brand); + } + + @Override + public Optional findById(Long id) { + return brandJpaRepository.findById(id); + } + + @Override + public boolean existsByName(String name) { + return brandJpaRepository.existsByName_Value(name); + } + + @Override + public List findAllByDeletedAtIsNull() { + return brandJpaRepository.findAllByDeletedAtIsNull(); + } + + @Override + public List findAll() { + return brandJpaRepository.findAll(); + } + + @Override + public List findAllByIdIn(List ids) { + return brandJpaRepository.findAllByIdIn(ids); + } +} diff --git a/infrastructure/jpa/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java new file mode 100644 index 000000000..a13a5870e --- /dev/null +++ b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -0,0 +1,7 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.catalog.product.Product; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProductJpaRepository extends JpaRepository { +} diff --git a/infrastructure/jpa/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java new file mode 100644 index 000000000..04bb8ca00 --- /dev/null +++ b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -0,0 +1,68 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.catalog.product.Product; +import com.loopers.domain.catalog.product.ProductRepository; +import com.loopers.domain.catalog.product.ProductSortType; +import com.loopers.domain.catalog.product.QProduct; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class ProductRepositoryImpl implements ProductRepository { + + private final ProductJpaRepository productJpaRepository; + private final JPAQueryFactory queryFactory; + + private static final QProduct product = QProduct.product; + + @Override + public Product save(Product product) { + return productJpaRepository.save(product); + } + + @Override + public Optional findById(Long id) { + return productJpaRepository.findById(id); + } + + @Override + public List findAllActive(ProductSortType sortType) { + return queryFactory + .selectFrom(product) + .where(product.deletedAt.isNull()) + .orderBy(toOrderSpecifier(sortType)) + .fetch(); + } + + @Override + public List findAll() { + return productJpaRepository.findAll(); + } + + @Override + public void softDeleteByBrandId(Long brandId) { + queryFactory + .update(product) + .set(product.deletedAt, ZonedDateTime.now()) + .where( + product.brandId.eq(brandId), + product.deletedAt.isNull() + ) + .execute(); + } + + private OrderSpecifier toOrderSpecifier(ProductSortType sortType) { + return switch (sortType) { + case LATEST -> product.createdAt.desc(); + case PRICE_ASC -> product.price.value.asc(); + case LIKES_DESC -> product.likesCount.desc(); + }; + } +} diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/AdminBrandController.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/AdminBrandController.java new file mode 100644 index 000000000..66c77512d --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/AdminBrandController.java @@ -0,0 +1,48 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.service.BrandService; +import com.loopers.interfaces.api.brand.dto.BrandApiResponse; +import com.loopers.interfaces.api.brand.dto.BrandCreateApiRequest; +import com.loopers.interfaces.api.brand.dto.BrandUpdateApiRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/admin/brands") +@RequiredArgsConstructor +public class AdminBrandController { + + private final BrandService brandService; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public void create(@RequestBody BrandCreateApiRequest request) { + brandService.create(request.toCommand()); + } + + @GetMapping("/{id}") + public BrandApiResponse getById(@PathVariable Long id) { + return BrandApiResponse.from(brandService.getById(id)); + } + + @GetMapping + public List getAll() { + return brandService.getAll().stream() + .map(BrandApiResponse::from) + .toList(); + } + + @PutMapping("/{id}") + public void update(@PathVariable Long id, @RequestBody BrandUpdateApiRequest request) { + brandService.update(id, request.toCommand()); + } + + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void delete(@PathVariable Long id) { + brandService.delete(id); + } +} diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java new file mode 100644 index 000000000..d3aedeed0 --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java @@ -0,0 +1,25 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.service.BrandService; +import com.loopers.interfaces.api.brand.dto.BrandApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/brands") +@RequiredArgsConstructor +public class BrandController { + + private final BrandService brandService; + + @GetMapping + public List getActiveBrands() { + return brandService.getActiveBrands().stream() + .map(BrandApiResponse::from) + .toList(); + } +} diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandApiResponse.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandApiResponse.java new file mode 100644 index 000000000..16ccbba50 --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandApiResponse.java @@ -0,0 +1,12 @@ +package com.loopers.interfaces.api.brand.dto; + +import com.loopers.application.service.dto.BrandInfo; + +public record BrandApiResponse( + Long id, + String name +) { + public static BrandApiResponse from(BrandInfo info) { + return new BrandApiResponse(info.id(), info.name()); + } +} diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandCreateApiRequest.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandCreateApiRequest.java new file mode 100644 index 000000000..06c67ab8b --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandCreateApiRequest.java @@ -0,0 +1,11 @@ +package com.loopers.interfaces.api.brand.dto; + +import com.loopers.application.service.dto.BrandCreateCommand; + +public record BrandCreateApiRequest( + String name +) { + public BrandCreateCommand toCommand() { + return new BrandCreateCommand(name); + } +} diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandUpdateApiRequest.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandUpdateApiRequest.java new file mode 100644 index 000000000..523544198 --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandUpdateApiRequest.java @@ -0,0 +1,11 @@ +package com.loopers.interfaces.api.brand.dto; + +import com.loopers.application.service.dto.BrandUpdateCommand; + +public record BrandUpdateApiRequest( + String name +) { + public BrandUpdateCommand toCommand() { + return new BrandUpdateCommand(name); + } +} diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberController.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberController.java index 6527b6958..14e5e9f61 100644 --- a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberController.java +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberController.java @@ -1,8 +1,8 @@ package com.loopers.interfaces.api.member; import com.loopers.application.service.MemberService; -import com.loopers.application.service.dto.RegisterMemberCommand; -import com.loopers.application.service.dto.UpdatePasswordCommand; +import com.loopers.application.service.dto.MemberRegisterCommand; +import com.loopers.application.service.dto.PasswordUpdateCommand; import com.loopers.interfaces.api.member.dto.MemberApiResponse; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; @@ -17,7 +17,7 @@ public class MemberController { @PostMapping("/register") @ResponseStatus(HttpStatus.CREATED) - public void register(@RequestBody RegisterMemberCommand request) { + public void register(@RequestBody MemberRegisterCommand request) { memberService.register(request); } @@ -34,7 +34,7 @@ public MemberApiResponse getMyInfo( public void updatePassword( @RequestHeader("X-Loopers-LoginId") String loginId, @RequestHeader("X-Loopers-LoginPw") String currentPassword, - @RequestBody UpdatePasswordCommand request + @RequestBody PasswordUpdateCommand request ) { memberService.updatePassword(loginId, currentPassword, request.newPassword()); } diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductController.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductController.java new file mode 100644 index 000000000..e6c6859b2 --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductController.java @@ -0,0 +1,48 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.service.ProductService; +import com.loopers.interfaces.api.product.dto.ProductApiResponse; +import com.loopers.interfaces.api.product.dto.ProductCreateApiRequest; +import com.loopers.interfaces.api.product.dto.ProductUpdateApiRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/admin/products") +@RequiredArgsConstructor +public class AdminProductController { + + private final ProductService productService; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public void create(@RequestBody ProductCreateApiRequest request) { + productService.create(request.toCommand()); + } + + @GetMapping("/{id}") + public ProductApiResponse getById(@PathVariable Long id) { + return ProductApiResponse.from(productService.getById(id)); + } + + @GetMapping + public List getAll() { + return productService.getAll().stream() + .map(ProductApiResponse::from) + .toList(); + } + + @PutMapping("/{id}") + public void update(@PathVariable Long id, @RequestBody ProductUpdateApiRequest request) { + productService.update(id, request.toCommand()); + } + + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void delete(@PathVariable Long id) { + productService.delete(id); + } +} diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java new file mode 100644 index 000000000..3cd53aebd --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java @@ -0,0 +1,31 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.service.ProductService; +import com.loopers.domain.catalog.product.ProductSortType; +import com.loopers.interfaces.api.product.dto.ProductApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/products") +@RequiredArgsConstructor +public class ProductController { + + private final ProductService productService; + + @GetMapping + public List getActiveProducts( + @RequestParam(defaultValue = "LATEST") ProductSortType sort + ) { + return productService.getActiveProducts(sort).stream() + .map(ProductApiResponse::from) + .toList(); + } + + @GetMapping("/{id}") + public ProductApiResponse getById(@PathVariable Long id) { + return ProductApiResponse.from(productService.getById(id)); + } +} diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductApiResponse.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductApiResponse.java new file mode 100644 index 000000000..0cda61ac5 --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductApiResponse.java @@ -0,0 +1,25 @@ +package com.loopers.interfaces.api.product.dto; + +import com.loopers.application.service.dto.ProductInfo; + +public record ProductApiResponse( + Long id, + String name, + String description, + long price, + long stock, + long likesCount, + String brandName +) { + public static ProductApiResponse from(ProductInfo info) { + return new ProductApiResponse( + info.id(), + info.name(), + info.description(), + info.price(), + info.stock(), + info.likesCount(), + info.brandName() + ); + } +} diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductCreateApiRequest.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductCreateApiRequest.java new file mode 100644 index 000000000..543427785 --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductCreateApiRequest.java @@ -0,0 +1,15 @@ +package com.loopers.interfaces.api.product.dto; + +import com.loopers.application.service.dto.ProductCreateCommand; + +public record ProductCreateApiRequest( + String name, + String description, + long price, + long stock, + Long brandId +) { + public ProductCreateCommand toCommand() { + return new ProductCreateCommand(name, description, price, stock, brandId); + } +} diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductUpdateApiRequest.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductUpdateApiRequest.java new file mode 100644 index 000000000..e866253a5 --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductUpdateApiRequest.java @@ -0,0 +1,14 @@ +package com.loopers.interfaces.api.product.dto; + +import com.loopers.application.service.dto.ProductUpdateCommand; + +public record ProductUpdateApiRequest( + String name, + String description, + long price, + long stock +) { + public ProductUpdateCommand toCommand() { + return new ProductUpdateCommand(name, description, price, stock); + } +} diff --git a/presentation/commerce-api/src/test/java/com/loopers/application/MemberServiceIntegrationTest.java b/presentation/commerce-api/src/test/java/com/loopers/application/MemberServiceIntegrationTest.java index ffdf45b81..b4830fbf9 100644 --- a/presentation/commerce-api/src/test/java/com/loopers/application/MemberServiceIntegrationTest.java +++ b/presentation/commerce-api/src/test/java/com/loopers/application/MemberServiceIntegrationTest.java @@ -1,7 +1,7 @@ package com.loopers.application; import com.loopers.application.service.MemberService; -import com.loopers.application.service.dto.RegisterMemberCommand; +import com.loopers.application.service.dto.MemberRegisterCommand; import com.loopers.application.service.dto.MemberInfo; import com.loopers.domain.member.MemberExceptionMessage; import com.loopers.domain.member.MemberRepository; @@ -35,7 +35,7 @@ class MemberServiceIntegrationTest { private static final LocalDate BIRTH_DATE = LocalDate.of(2001, 2, 9); private void 회원을_등록한다(String loginId, String password) { - memberService.register(new RegisterMemberCommand( + memberService.register(new MemberRegisterCommand( loginId, password, "공명선", BIRTH_DATE, "test@loopers.com")); } @@ -43,7 +43,7 @@ class MemberServiceIntegrationTest { void 회원가입_성공() { // given String inputId = "integrationId123"; - RegisterMemberCommand request = new RegisterMemberCommand( + MemberRegisterCommand request = new MemberRegisterCommand( inputId, "Pass!1234", "공명선", BIRTH_DATE, "test@loopers.com"); // when @@ -59,7 +59,7 @@ class MemberServiceIntegrationTest { String duplicateId = "existingId"; 회원을_등록한다(duplicateId, "encodedPw1!"); - RegisterMemberCommand request = new RegisterMemberCommand( + MemberRegisterCommand request = new MemberRegisterCommand( duplicateId, "NewPass!123", "신규유저", LocalDate.of(2000, 1, 1), "new@test.com"); // when & then @@ -143,7 +143,7 @@ class MemberServiceIntegrationTest { // then assertThat(memberRepository.findByLoginId(loginId).orElseThrow() - .getPassword().matches(newPw, passwordEncryptor) + .matchesPassword(newPw, passwordEncryptor) ).isTrue(); } diff --git a/presentation/commerce-api/src/test/java/com/loopers/controller/BrandE2ETest.java b/presentation/commerce-api/src/test/java/com/loopers/controller/BrandE2ETest.java new file mode 100644 index 000000000..c61c6a346 --- /dev/null +++ b/presentation/commerce-api/src/test/java/com/loopers/controller/BrandE2ETest.java @@ -0,0 +1,132 @@ +package com.loopers.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.interfaces.api.brand.dto.BrandCreateApiRequest; +import com.loopers.interfaces.api.brand.dto.BrandUpdateApiRequest; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class BrandE2ETest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Test + void 브랜드_생성_성공_201() throws Exception { + // when & then + mockMvc.perform(post("/api/admin/brands") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new BrandCreateApiRequest("나이키")))) + .andExpect(status().isCreated()); + } + + @Test + void 브랜드_중복_생성_409() throws Exception { + // given + 브랜드를_생성한다("나이키"); + + // when & then + mockMvc.perform(post("/api/admin/brands") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new BrandCreateApiRequest("나이키")))) + .andExpect(status().isConflict()); + } + + @Test + void 브랜드_단건_조회_200() throws Exception { + // given + Long brandId = 브랜드를_생성하고_ID를_반환한다("나이키"); + + // when & then + mockMvc.perform(get("/api/admin/brands/{id}", brandId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("나이키")); + } + + @Test + void 활성_브랜드_목록_조회_200() throws Exception { + // given + 브랜드를_생성한다("나이키"); + 브랜드를_생성한다("아디다스"); + + // when & then + mockMvc.perform(get("/api/brands")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(2)); + } + + @Test + void Admin_전체_브랜드_목록_조회_삭제_포함() throws Exception { + // given + Long brandId = 브랜드를_생성하고_ID를_반환한다("나이키"); + 브랜드를_생성한다("아디다스"); + mockMvc.perform(delete("/api/admin/brands/{id}", brandId)); + + // when & then + mockMvc.perform(get("/api/admin/brands")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(2)); + } + + @Test + void 브랜드_수정_200() throws Exception { + // given + Long brandId = 브랜드를_생성하고_ID를_반환한다("나이키"); + + // when & then + mockMvc.perform(put("/api/admin/brands/{id}", brandId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new BrandUpdateApiRequest("아디다스")))) + .andExpect(status().isOk()); + } + + @Test + void 브랜드_삭제_204() throws Exception { + // given + Long brandId = 브랜드를_생성하고_ID를_반환한다("나이키"); + + // when & then + mockMvc.perform(delete("/api/admin/brands/{id}", brandId)) + .andExpect(status().isNoContent()); + } + + @Test + void 삭제된_브랜드는_활성_목록에_미포함() throws Exception { + // given + Long brandId = 브랜드를_생성하고_ID를_반환한다("나이키"); + 브랜드를_생성한다("아디다스"); + mockMvc.perform(delete("/api/admin/brands/{id}", brandId)); + + // when & then + mockMvc.perform(get("/api/brands")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)); + } + + private void 브랜드를_생성한다(String name) throws Exception { + mockMvc.perform(post("/api/admin/brands") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new BrandCreateApiRequest(name)))); + } + + private Long 브랜드를_생성하고_ID를_반환한다(String name) throws Exception { + 브랜드를_생성한다(name); + String response = mockMvc.perform(get("/api/admin/brands")) + .andReturn().getResponse().getContentAsString(); + return objectMapper.readTree(response).get(0).get("id").asLong(); + } +} diff --git a/presentation/commerce-api/src/test/java/com/loopers/controller/MemberE2ETest.java b/presentation/commerce-api/src/test/java/com/loopers/controller/MemberE2ETest.java index a6553f54a..557fa6f8a 100644 --- a/presentation/commerce-api/src/test/java/com/loopers/controller/MemberE2ETest.java +++ b/presentation/commerce-api/src/test/java/com/loopers/controller/MemberE2ETest.java @@ -1,8 +1,8 @@ package com.loopers.controller; import com.fasterxml.jackson.databind.ObjectMapper; -import com.loopers.application.service.dto.RegisterMemberCommand; -import com.loopers.application.service.dto.UpdatePasswordCommand; +import com.loopers.application.service.dto.MemberRegisterCommand; +import com.loopers.application.service.dto.PasswordUpdateCommand; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -36,7 +36,7 @@ public class MemberE2ETest { @DisplayName("회원가입 성공 시 201 Created를 반환한다") void 회원가입_성공() throws Exception { // given - RegisterMemberCommand request = createRegisterRequest(); + MemberRegisterCommand request = createRegisterRequest(); // when & then mockMvc.perform(post("/api/members/register") @@ -69,7 +69,7 @@ public class MemberE2ETest { .header("X-Loopers-LoginId", LOGIN_ID) .header("X-Loopers-LoginPw", INITIAL_PW) .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(new UpdatePasswordCommand(NEW_PW)))) + .content(objectMapper.writeValueAsString(new PasswordUpdateCommand(NEW_PW)))) .andExpect(status().isNoContent()); } @@ -101,8 +101,8 @@ public class MemberE2ETest { .andExpect(status().isUnauthorized()); } - private RegisterMemberCommand createRegisterRequest() { - return new RegisterMemberCommand( + private MemberRegisterCommand createRegisterRequest() { + return new MemberRegisterCommand( LOGIN_ID, INITIAL_PW, "공명선", LocalDate.of(2001, 2, 9), "test@loopers.com" ); } @@ -118,6 +118,6 @@ private void changePassword() throws Exception { .header("X-Loopers-LoginId", LOGIN_ID) .header("X-Loopers-LoginPw", INITIAL_PW) .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(new UpdatePasswordCommand(NEW_PW)))); + .content(objectMapper.writeValueAsString(new PasswordUpdateCommand(NEW_PW)))); } } diff --git a/presentation/commerce-api/src/test/java/com/loopers/controller/ProductE2ETest.java b/presentation/commerce-api/src/test/java/com/loopers/controller/ProductE2ETest.java new file mode 100644 index 000000000..39861a9aa --- /dev/null +++ b/presentation/commerce-api/src/test/java/com/loopers/controller/ProductE2ETest.java @@ -0,0 +1,131 @@ +package com.loopers.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.interfaces.api.brand.dto.BrandCreateApiRequest; +import com.loopers.interfaces.api.product.dto.ProductCreateApiRequest; +import com.loopers.interfaces.api.product.dto.ProductUpdateApiRequest; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class ProductE2ETest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Test + void 상품_생성_성공_201() throws Exception { + // given + Long brandId = 브랜드를_생성하고_ID를_반환한다("나이키"); + + // when & then + mockMvc.perform(post("/api/admin/products") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new ProductCreateApiRequest("에어맥스", "설명", 100000, 50, brandId)))) + .andExpect(status().isCreated()); + } + + @Test + void 상품_상세_조회_브랜드명_포함() throws Exception { + // given + Long brandId = 브랜드를_생성하고_ID를_반환한다("나이키"); + Long productId = 상품을_생성하고_ID를_반환한다("에어맥스", 100000, 50, brandId); + + // when & then + mockMvc.perform(get("/api/products/{id}", productId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.brandName").value("나이키")) + .andExpect(jsonPath("$.likesCount").value(0)); + } + + @Test + void 활성_상품_목록_조회_정렬() throws Exception { + // given + Long brandId = 브랜드를_생성하고_ID를_반환한다("나이키"); + 상품을_생성한다("에어맥스", 100000, 50, brandId); + 상품을_생성한다("조던", 200000, 30, brandId); + + // when & then + mockMvc.perform(get("/api/products").param("sort", "PRICE_ASC")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(2)) + .andExpect(jsonPath("$[0].name").value("에어맥스")); + } + + @Test + void 상품_수정_200() throws Exception { + // given + Long brandId = 브랜드를_생성하고_ID를_반환한다("나이키"); + Long productId = 상품을_생성하고_ID를_반환한다("에어맥스", 100000, 50, brandId); + + // when & then + mockMvc.perform(put("/api/admin/products/{id}", productId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new ProductUpdateApiRequest("에어맥스2", "새설명", 120000, 60)))) + .andExpect(status().isOk()); + } + + @Test + void 상품_삭제_204() throws Exception { + // given + Long brandId = 브랜드를_생성하고_ID를_반환한다("나이키"); + Long productId = 상품을_생성하고_ID를_반환한다("에어맥스", 100000, 50, brandId); + + // when & then + mockMvc.perform(delete("/api/admin/products/{id}", productId)) + .andExpect(status().isNoContent()); + } + + @Test + void 삭제된_상품은_활성_목록에_미포함() throws Exception { + // given + Long brandId = 브랜드를_생성하고_ID를_반환한다("나이키"); + Long productId = 상품을_생성하고_ID를_반환한다("에어맥스", 100000, 50, brandId); + 상품을_생성한다("조던", 200000, 30, brandId); + mockMvc.perform(delete("/api/admin/products/{id}", productId)); + + // when & then + mockMvc.perform(get("/api/products")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)); + } + + private Long 브랜드를_생성하고_ID를_반환한다(String name) throws Exception { + mockMvc.perform(post("/api/admin/brands") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new BrandCreateApiRequest(name)))); + + String response = mockMvc.perform(get("/api/admin/brands")) + .andReturn().getResponse().getContentAsString(); + return objectMapper.readTree(response).get(0).get("id").asLong(); + } + + private void 상품을_생성한다(String name, long price, long stock, Long brandId) throws Exception { + mockMvc.perform(post("/api/admin/products") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new ProductCreateApiRequest(name, "설명", price, stock, brandId)))); + } + + private Long 상품을_생성하고_ID를_반환한다(String name, long price, long stock, Long brandId) throws Exception { + 상품을_생성한다(name, price, stock, brandId); + String response = mockMvc.perform(get("/api/admin/products")) + .andReturn().getResponse().getContentAsString(); + return objectMapper.readTree(response).get(0).get("id").asLong(); + } +} From 7929f0ef8f2a299518f48012564d004ab84c817b Mon Sep 17 00:00:00 2001 From: APapeIsName Date: Thu, 26 Feb 2026 22:40:39 +0900 Subject: [PATCH 5/8] =?UTF-8?q?feat:=20Order=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 --- .../application/service/OrderService.java | 177 +++++++++++ .../service/dto/OrderCreateCommand.java | 9 + .../application/service/dto/OrderInfo.java | 18 ++ .../service/dto/OrderLineInfo.java | 12 + .../service/dto/OrderLineRequest.java | 7 + .../loopers/application/OrderServiceTest.java | 290 ++++++++++++++++++ docs/design/03-class-diagram.md | 34 +- docs/design/05-domain-model.md | 6 +- docs/design/06-architecture.md | 10 +- .../catalog/product/ProductRepository.java | 2 + .../java/com/loopers/domain/order/Order.java | 68 ++++ .../domain/order/OrderExceptionMessage.java | 21 ++ .../com/loopers/domain/order/OrderLine.java | 63 ++++ .../domain/order/OrderLineRepository.java | 12 + .../domain/order/OrderLineSnapshot.java | 47 +++ .../order/OrderLineSnapshotRepository.java | 12 + .../loopers/domain/order/OrderRepository.java | 15 + .../com/loopers/domain/order/OrderStatus.java | 6 + .../domain/order/OrderLineSnapshotTest.java | 44 +++ .../loopers/domain/order/OrderLineTest.java | 36 +++ .../com/loopers/domain/order/OrderTest.java | 111 +++++++ .../order/OrderJpaRepository.java | 11 + .../order/OrderLineJpaRepository.java | 11 + .../order/OrderLineRepositoryImpl.java | 30 ++ .../order/OrderLineSnapshotJpaRepository.java | 11 + .../OrderLineSnapshotRepositoryImpl.java | 30 ++ .../order/OrderRepositoryImpl.java | 36 +++ .../product/ProductJpaRepository.java | 4 + .../product/ProductRepositoryImpl.java | 5 + .../interfaces/api/ApiControllerAdvice.java | 1 + .../api/order/AdminOrderController.java | 28 ++ .../interfaces/api/order/OrderController.java | 53 ++++ .../api/order/dto/OrderApiResponse.java | 26 ++ .../api/order/dto/OrderCreateApiRequest.java | 19 ++ .../api/order/dto/OrderLineApiResponse.java | 25 ++ .../api/order/dto/OrderLineItemRequest.java | 7 + .../com/loopers/controller/OrderE2ETest.java | 200 ++++++++++++ .../com/loopers/support/error/ErrorType.java | 3 +- 38 files changed, 1480 insertions(+), 20 deletions(-) create mode 100644 application/commerce-service/src/main/java/com/loopers/application/service/OrderService.java create mode 100644 application/commerce-service/src/main/java/com/loopers/application/service/dto/OrderCreateCommand.java create mode 100644 application/commerce-service/src/main/java/com/loopers/application/service/dto/OrderInfo.java create mode 100644 application/commerce-service/src/main/java/com/loopers/application/service/dto/OrderLineInfo.java create mode 100644 application/commerce-service/src/main/java/com/loopers/application/service/dto/OrderLineRequest.java create mode 100644 application/commerce-service/src/test/java/com/loopers/application/OrderServiceTest.java create mode 100644 domain/src/main/java/com/loopers/domain/order/Order.java create mode 100644 domain/src/main/java/com/loopers/domain/order/OrderExceptionMessage.java create mode 100644 domain/src/main/java/com/loopers/domain/order/OrderLine.java create mode 100644 domain/src/main/java/com/loopers/domain/order/OrderLineRepository.java create mode 100644 domain/src/main/java/com/loopers/domain/order/OrderLineSnapshot.java create mode 100644 domain/src/main/java/com/loopers/domain/order/OrderLineSnapshotRepository.java create mode 100644 domain/src/main/java/com/loopers/domain/order/OrderRepository.java create mode 100644 domain/src/main/java/com/loopers/domain/order/OrderStatus.java create mode 100644 domain/src/test/java/com/loopers/domain/order/OrderLineSnapshotTest.java create mode 100644 domain/src/test/java/com/loopers/domain/order/OrderLineTest.java create mode 100644 domain/src/test/java/com/loopers/domain/order/OrderTest.java create mode 100644 infrastructure/jpa/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java create mode 100644 infrastructure/jpa/src/main/java/com/loopers/infrastructure/order/OrderLineJpaRepository.java create mode 100644 infrastructure/jpa/src/main/java/com/loopers/infrastructure/order/OrderLineRepositoryImpl.java create mode 100644 infrastructure/jpa/src/main/java/com/loopers/infrastructure/order/OrderLineSnapshotJpaRepository.java create mode 100644 infrastructure/jpa/src/main/java/com/loopers/infrastructure/order/OrderLineSnapshotRepositoryImpl.java create mode 100644 infrastructure/jpa/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java create mode 100644 presentation/commerce-api/src/main/java/com/loopers/interfaces/api/order/AdminOrderController.java create mode 100644 presentation/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java create mode 100644 presentation/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/OrderApiResponse.java create mode 100644 presentation/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/OrderCreateApiRequest.java create mode 100644 presentation/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/OrderLineApiResponse.java create mode 100644 presentation/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/OrderLineItemRequest.java create mode 100644 presentation/commerce-api/src/test/java/com/loopers/controller/OrderE2ETest.java diff --git a/application/commerce-service/src/main/java/com/loopers/application/service/OrderService.java b/application/commerce-service/src/main/java/com/loopers/application/service/OrderService.java new file mode 100644 index 000000000..3a5d079c5 --- /dev/null +++ b/application/commerce-service/src/main/java/com/loopers/application/service/OrderService.java @@ -0,0 +1,177 @@ +package com.loopers.application.service; + +import com.loopers.application.service.dto.OrderCreateCommand; +import com.loopers.application.service.dto.OrderInfo; +import com.loopers.application.service.dto.OrderLineInfo; +import com.loopers.application.service.dto.OrderLineRequest; +import com.loopers.domain.catalog.brand.Brand; +import com.loopers.domain.catalog.brand.BrandRepository; +import com.loopers.domain.catalog.product.Product; +import com.loopers.domain.catalog.product.ProductExceptionMessage; +import com.loopers.domain.catalog.product.ProductRepository; +import com.loopers.domain.catalog.product.vo.Quantity; +import com.loopers.domain.order.*; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class OrderService { + + private final OrderRepository orderRepository; + private final OrderLineRepository orderLineRepository; + private final OrderLineSnapshotRepository orderLineSnapshotRepository; + private final ProductRepository productRepository; + private final BrandRepository brandRepository; + + @Transactional + public OrderInfo create(OrderCreateCommand command) { + List requests = command.orderLines(); + List productIds = requests.stream() + .map(OrderLineRequest::productId) + .distinct() + .sorted() + .toList(); + + Map productMap = findActiveProducts(productIds); + + List brandIds = productMap.values().stream() + .map(Product::getBrandId).distinct().toList(); + Map brandMap = brandRepository.findAllByIdIn(brandIds).stream() + .collect(Collectors.toMap(Brand::getId, Function.identity())); + + boolean allEnough = requests.stream() + .allMatch(req -> productMap.get(req.productId()) + .hasEnoughStock(Quantity.of(req.quantity()))); + OrderStatus status = allEnough ? OrderStatus.ACCEPTED : OrderStatus.REJECTED; + + if (allEnough) { + requests.forEach(req -> + productMap.get(req.productId()).decreaseStock(Quantity.of(req.quantity()))); + } + + List orderLines = requests.stream() + .map(req -> { + Product product = productMap.get(req.productId()); + Brand brand = brandMap.get(product.getBrandId()); + return OrderLine.of( + req.productId(), Quantity.of(req.quantity()), + product.getName().getValue(), product.getDescription(), + product.getPrice().getValue(), + brand != null ? brand.getName().getValue() : null + ); + }) + .toList(); + + Order order = Order.place(command.memberId(), orderLines, status); + Order savedOrder = orderRepository.save(order); + List savedLines = orderLineRepository.saveAll( + savedOrder.assignOrderLines(orderLines)); + + List snapshots = savedLines.stream() + .map(line -> line.assignSnapshot().getSnapshot()) + .toList(); + orderLineSnapshotRepository.saveAll(snapshots); + + return toOrderInfo(savedOrder, savedLines, snapshots); + } + + @Transactional(readOnly = true) + public OrderInfo getById(Long orderId, Long memberId) { + Order order = orderRepository.findById(orderId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + OrderExceptionMessage.Order.NOT_FOUND.message())); + + if (!order.isOwnedBy(memberId)) { + throw new CoreException(ErrorType.FORBIDDEN, + OrderExceptionMessage.Order.NOT_OWNER.message()); + } + + return toOrderInfo(order); + } + + @Transactional(readOnly = true) + public List getByMemberId(Long memberId) { + return orderRepository.findByMemberId(memberId).stream() + .map(this::toOrderInfo) + .toList(); + } + + @Transactional(readOnly = true) + public List getAll() { + return orderRepository.findAll().stream() + .map(this::toOrderInfo) + .toList(); + } + + @Transactional(readOnly = true) + public OrderInfo getByIdForAdmin(Long orderId) { + Order order = orderRepository.findById(orderId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + OrderExceptionMessage.Order.NOT_FOUND.message())); + + return toOrderInfo(order); + } + + private Map findActiveProducts(List productIds) { + List products = productRepository.findAllByIdIn(productIds); + + if (products.size() != productIds.size()) { + throw new CoreException(ErrorType.NOT_FOUND, + ProductExceptionMessage.Product.NOT_FOUND.message()); + } + + products.forEach(product -> { + if (product.isDeleted()) { + throw new CoreException(ErrorType.BAD_REQUEST, + ProductExceptionMessage.Product.ALREADY_DELETED.message()); + } + }); + + return products.stream() + .collect(Collectors.toMap(Product::getId, Function.identity())); + } + + private OrderInfo toOrderInfo(Order order) { + List lines = orderLineRepository.findByOrderId(order.getId()); + List lineIds = lines.stream().map(OrderLine::getId).toList(); + List snapshots = orderLineSnapshotRepository.findByOrderLineIdIn(lineIds); + return toOrderInfo(order, lines, snapshots); + } + + private OrderInfo toOrderInfo(Order order, List lines, List snapshots) { + Map snapshotMap = snapshots.stream() + .collect(Collectors.toMap(OrderLineSnapshot::getOrderLineId, Function.identity())); + + List lineInfos = lines.stream() + .map(line -> { + OrderLineSnapshot snapshot = snapshotMap.get(line.getId()); + return new OrderLineInfo( + line.getId(), + line.getProductId(), + line.getQuantity().getValue(), + snapshot != null ? snapshot.getProductName() : null, + snapshot != null ? snapshot.getProductDescription() : null, + snapshot != null ? snapshot.getPrice() : 0, + snapshot != null ? snapshot.getBrandName() : null + ); + }) + .toList(); + + return new OrderInfo( + order.getId(), + order.getMemberId(), + order.getStatus(), + order.getCreatedAt(), + lineInfos + ); + } +} diff --git a/application/commerce-service/src/main/java/com/loopers/application/service/dto/OrderCreateCommand.java b/application/commerce-service/src/main/java/com/loopers/application/service/dto/OrderCreateCommand.java new file mode 100644 index 000000000..68a299c64 --- /dev/null +++ b/application/commerce-service/src/main/java/com/loopers/application/service/dto/OrderCreateCommand.java @@ -0,0 +1,9 @@ +package com.loopers.application.service.dto; + +import java.util.List; + +public record OrderCreateCommand( + Long memberId, + List orderLines +) { +} diff --git a/application/commerce-service/src/main/java/com/loopers/application/service/dto/OrderInfo.java b/application/commerce-service/src/main/java/com/loopers/application/service/dto/OrderInfo.java new file mode 100644 index 000000000..3317c1db4 --- /dev/null +++ b/application/commerce-service/src/main/java/com/loopers/application/service/dto/OrderInfo.java @@ -0,0 +1,18 @@ +package com.loopers.application.service.dto; + +import com.loopers.domain.order.OrderStatus; + +import java.time.ZonedDateTime; +import java.util.List; + +public record OrderInfo( + Long orderId, + Long memberId, + OrderStatus status, + ZonedDateTime createdAt, + List orderLines +) { + public boolean isAccepted() { + return this.status == OrderStatus.ACCEPTED; + } +} diff --git a/application/commerce-service/src/main/java/com/loopers/application/service/dto/OrderLineInfo.java b/application/commerce-service/src/main/java/com/loopers/application/service/dto/OrderLineInfo.java new file mode 100644 index 000000000..d1e1d7505 --- /dev/null +++ b/application/commerce-service/src/main/java/com/loopers/application/service/dto/OrderLineInfo.java @@ -0,0 +1,12 @@ +package com.loopers.application.service.dto; + +public record OrderLineInfo( + Long orderLineId, + Long productId, + long quantity, + String productName, + String productDescription, + long price, + String brandName +) { +} diff --git a/application/commerce-service/src/main/java/com/loopers/application/service/dto/OrderLineRequest.java b/application/commerce-service/src/main/java/com/loopers/application/service/dto/OrderLineRequest.java new file mode 100644 index 000000000..a53b78205 --- /dev/null +++ b/application/commerce-service/src/main/java/com/loopers/application/service/dto/OrderLineRequest.java @@ -0,0 +1,7 @@ +package com.loopers.application.service.dto; + +public record OrderLineRequest( + Long productId, + long quantity +) { +} diff --git a/application/commerce-service/src/test/java/com/loopers/application/OrderServiceTest.java b/application/commerce-service/src/test/java/com/loopers/application/OrderServiceTest.java new file mode 100644 index 000000000..e7437362d --- /dev/null +++ b/application/commerce-service/src/test/java/com/loopers/application/OrderServiceTest.java @@ -0,0 +1,290 @@ +package com.loopers.application; + +import com.loopers.application.service.OrderService; +import com.loopers.application.service.dto.OrderCreateCommand; +import com.loopers.application.service.dto.OrderInfo; +import com.loopers.application.service.dto.OrderLineRequest; +import com.loopers.domain.catalog.brand.Brand; +import com.loopers.domain.catalog.brand.BrandRepository; +import com.loopers.domain.catalog.product.Product; +import com.loopers.domain.catalog.product.ProductExceptionMessage; +import com.loopers.domain.catalog.product.ProductRepository; +import com.loopers.domain.catalog.product.vo.Money; +import com.loopers.domain.catalog.product.vo.Quantity; +import com.loopers.domain.catalog.product.vo.Stock; +import com.loopers.domain.order.*; +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class OrderServiceTest { + + @InjectMocks + private OrderService orderService; + + @Mock + private OrderRepository orderRepository; + + @Mock + private OrderLineRepository orderLineRepository; + + @Mock + private OrderLineSnapshotRepository orderLineSnapshotRepository; + + @Mock + private ProductRepository productRepository; + + @Mock + private BrandRepository brandRepository; + + // 주문을 생성한다 + + @Test + void 주문_생성_성공_재고_충분하면_수락() { + // given + givenProductAndBrand(1L, "에어맥스", 50, 1L, "나이키"); + givenOrderSave(); + OrderCreateCommand command = new OrderCreateCommand(10L, List.of( + new OrderLineRequest(1L, 2) + )); + + // when + OrderInfo result = orderService.create(command); + + // then + assertThat(result.isAccepted()).isTrue(); + } + + @Test + void 주문_생성_성공_재고_부족하면_거절() { + // given + givenProductAndBrand(1L, "에어맥스", 1, 1L, "나이키"); + givenOrderSave(); + OrderCreateCommand command = new OrderCreateCommand(10L, List.of( + new OrderLineRequest(1L, 5) + )); + + // when + OrderInfo result = orderService.create(command); + + // then + assertThat(result.isAccepted()).isFalse(); + } + + @Test + void 주문_거절_시_재고_차감하지_않는다() { + // given + Product product = createProduct(1L, "에어맥스", 1, 1L); + Brand brand = createBrand(1L, "나이키"); + given(productRepository.findAllByIdIn(List.of(1L))).willReturn(List.of(product)); + given(brandRepository.findAllByIdIn(List.of(1L))).willReturn(List.of(brand)); + givenOrderSave(); + OrderCreateCommand command = new OrderCreateCommand(10L, List.of( + new OrderLineRequest(1L, 5) + )); + + // when + orderService.create(command); + + // then + assertThat(product.hasEnoughStock(Quantity.of(1))).isTrue(); + } + + @Test + void 주문_수락_시_재고_차감된다() { + // given + Product product = createProduct(1L, "에어맥스", 50, 1L); + Brand brand = createBrand(1L, "나이키"); + given(productRepository.findAllByIdIn(List.of(1L))).willReturn(List.of(product)); + given(brandRepository.findAllByIdIn(List.of(1L))).willReturn(List.of(brand)); + givenOrderSave(); + OrderCreateCommand command = new OrderCreateCommand(10L, List.of( + new OrderLineRequest(1L, 2) + )); + + // when + orderService.create(command); + + // then + assertThat(product.hasEnoughStock(Quantity.of(49))).isFalse(); + } + + @Test + void 삭제된_상품_포함_시_예외() { + // given + Product product = createProduct(1L, "에어맥스", 50, 1L); + product.delete(); + given(productRepository.findAllByIdIn(List.of(1L))).willReturn(List.of(product)); + OrderCreateCommand command = new OrderCreateCommand(10L, List.of( + new OrderLineRequest(1L, 2) + )); + + // when & then + assertThatThrownBy(() -> orderService.create(command)) + .isInstanceOf(CoreException.class) + .hasMessage(ProductExceptionMessage.Product.ALREADY_DELETED.message()); + } + + @Test + void 존재하지_않는_상품_포함_시_예외() { + // given + given(productRepository.findAllByIdIn(List.of(999L))).willReturn(List.of()); + OrderCreateCommand command = new OrderCreateCommand(10L, List.of( + new OrderLineRequest(999L, 2) + )); + + // when & then + assertThatThrownBy(() -> orderService.create(command)) + .isInstanceOf(CoreException.class) + .hasMessage(ProductExceptionMessage.Product.NOT_FOUND.message()); + } + + @Test + void 중복_상품_주문_시_예외() { + // given + givenProductAndBrand(1L, "에어맥스", 50, 1L, "나이키"); + OrderCreateCommand command = new OrderCreateCommand(10L, List.of( + new OrderLineRequest(1L, 2), + new OrderLineRequest(1L, 3) + )); + + // when & then + assertThatThrownBy(() -> orderService.create(command)) + .isInstanceOf(CoreException.class) + .hasMessage(OrderExceptionMessage.Order.DUPLICATE_PRODUCT.message()); + } + + @Test + void 빈_주문_시_예외() { + // given + OrderCreateCommand command = new OrderCreateCommand(10L, List.of()); + + // when & then + assertThatThrownBy(() -> orderService.create(command)) + .isInstanceOf(CoreException.class) + .hasMessage(OrderExceptionMessage.Order.EMPTY_ORDER_LINES.message()); + } + + // 내 주문 내역을 조회한다 + + @Test + void 내_주문_내역_조회() { + // given + Long memberId = 10L; + Order order = Order.place(memberId, List.of( + OrderLine.of(1L, Quantity.of(2), "에어맥스", "설명", 100000, "나이키") + ), OrderStatus.ACCEPTED); + given(orderRepository.findByMemberId(memberId)).willReturn(List.of(order)); + given(orderLineRepository.findByOrderId(any())).willReturn(List.of()); + given(orderLineSnapshotRepository.findByOrderLineIdIn(any())).willReturn(List.of()); + + // when + List result = orderService.getByMemberId(memberId); + + // then + assertThat(result).hasSize(1); + } + + // 주문 상세를 조회한다 + + @Test + void 주문_상세_조회_본인() { + // given + Long orderId = 1L; + Long memberId = 10L; + Order order = Order.place(memberId, List.of( + OrderLine.of(1L, Quantity.of(2), "에어맥스", "설명", 100000, "나이키") + ), OrderStatus.ACCEPTED); + given(orderRepository.findById(orderId)).willReturn(Optional.of(order)); + given(orderLineRepository.findByOrderId(any())).willReturn(List.of()); + given(orderLineSnapshotRepository.findByOrderLineIdIn(any())).willReturn(List.of()); + + // when + OrderInfo result = orderService.getById(orderId, memberId); + + // then + assertThat(result.isAccepted()).isTrue(); + } + + @Test + void 주문_상세_조회_타인_예외() { + // given + Long orderId = 1L; + Order order = Order.place(10L, List.of( + OrderLine.of(1L, Quantity.of(2), "에어맥스", "설명", 100000, "나이키") + ), OrderStatus.ACCEPTED); + given(orderRepository.findById(orderId)).willReturn(Optional.of(order)); + + // when & then + assertThatThrownBy(() -> orderService.getById(orderId, 99L)) + .isInstanceOf(CoreException.class) + .hasMessage(OrderExceptionMessage.Order.NOT_OWNER.message()); + } + + @Test + void 존재하지_않는_주문_조회_시_예외() { + // given + given(orderRepository.findById(999L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> orderService.getById(999L, 10L)) + .isInstanceOf(CoreException.class) + .hasMessage(OrderExceptionMessage.Order.NOT_FOUND.message()); + } + + // 전체 주문을 조회한다 (관리자) + + @Test + void 전체_주문_목록_조회() { + // given + Order order = Order.place(10L, List.of( + OrderLine.of(1L, Quantity.of(2), "에어맥스", "설명", 100000, "나이키") + ), OrderStatus.ACCEPTED); + given(orderRepository.findAll()).willReturn(List.of(order)); + given(orderLineRepository.findByOrderId(any())).willReturn(List.of()); + given(orderLineSnapshotRepository.findByOrderLineIdIn(any())).willReturn(List.of()); + + // when + List result = orderService.getAll(); + + // then + assertThat(result).hasSize(1); + } + + private Product createProduct(Long id, String name, long stock, Long brandId) { + Product product = Product.register(name, "설명", Money.of(100000), Stock.of(stock), brandId); + ReflectionTestUtils.setField(product, "id", id); + return product; + } + + private Brand createBrand(Long id, String name) { + Brand brand = Brand.register(name); + ReflectionTestUtils.setField(brand, "id", id); + return brand; + } + + private void givenProductAndBrand(Long productId, String productName, long stock, Long brandId, String brandName) { + Product product = createProduct(productId, productName, stock, brandId); + Brand brand = createBrand(brandId, brandName); + given(productRepository.findAllByIdIn(List.of(productId))).willReturn(List.of(product)); + given(brandRepository.findAllByIdIn(List.of(brandId))).willReturn(List.of(brand)); + } + + private void givenOrderSave() { + given(orderRepository.save(any(Order.class))).willAnswer(invocation -> invocation.getArgument(0)); + given(orderLineRepository.saveAll(any())).willAnswer(invocation -> invocation.getArgument(0)); + } +} diff --git a/docs/design/03-class-diagram.md b/docs/design/03-class-diagram.md index c1ed1e50b..477d98352 100644 --- a/docs/design/03-class-diagram.md +++ b/docs/design/03-class-diagram.md @@ -79,23 +79,28 @@ classDiagram class Order { -Long memberId -OrderStatus status - -ZonedDateTime orderedAt - -List~OrderLine~ lines + +place(memberId, orderLines, status)$ Order +isOwnedBy(memberId) boolean + +assignOrderLines(orderLines) List~OrderLine~ } class OrderLine { + -Long orderId -Long productId -Quantity quantity - -OrderLineSnapshot snapshot + +of(productId, quantity, name, desc, price, brand)$ OrderLine + +assignToOrder(orderId) OrderLine + +assignSnapshot() OrderLine } class OrderLineSnapshot { <> + -Long orderLineId -String productName -String productDescription - -Price price + -long price -String brandName + +assignToOrderLine(orderLineId) void } class OrderStatus { @@ -120,8 +125,9 @@ classDiagram Like ..> Product : subjectId (Long, subjectType=PRODUCT) Like --> LikeSubjectType : subjectType Order ..> Member : memberId (Long) - Order *-- OrderLine : 1..N + OrderLine ..> Order : orderId (Long) OrderLine ..> Product : productId (Long) + OrderLineSnapshot ..> OrderLine : orderLineId (Long) Order --> OrderStatus : status ``` @@ -136,15 +142,15 @@ classDiagram - **Brand**: 고유 ID. 생성 → 수정 → 삭제의 독립 생명주기. - **Product**: 고유 ID. 생성 → 수정 → 삭제의 독립 생명주기. 브랜드 삭제 시 연쇄 삭제되지만, 이는 비즈니스 규칙이지 생명주기 종속이 아니다. - **Like**: `memberId + subjectType + subjectId`로 고유 식별. 등록 → 삭제의 독립 생명주기. `subjectType`(enum)으로 좋아요 대상 종류를, `subjectId`로 대상 ID를 지정한다. -- **Order**: 고유 ID. 생성 시 즉시 최종 상태(ACCEPTED/REJECTED)로 결정. -- **OrderLine**: Order에 종속되는 주문 항목. 상품 ID와 수량을 보유하며, 스냅샷을 1:1로 소유한다. 나중에 쿠폰/부분취소 등 라인별 기능의 확장 지점. +- **Order**: 고유 ID. Aggregate Root. `place()` 시 orderLines를 받아 불변식(빈 주문, 중복 상품)을 검증하지만, 필드로 보유하지 않는다. 생성 시 즉시 최종 상태(ACCEPTED/REJECTED)로 결정. `assignOrderLines()`로 하위 엔티티의 소속을 관리한다. +- **OrderLine**: 주문 항목. `orderId(Long)`로 소속 주문을 식별한다. `of()` 팩토리에서 OrderLineSnapshot을 내부 생성한다. 나중에 쿠폰/부분취소 등 라인별 기능의 확장 지점. **Value Object (VO)**: 고유 식별자가 불필요하며, 자체 규칙(불변식)을 캡슐화하는 불변 객체다. - **Stock**: 재고의 본질적 규칙("음수가 될 수 없다")을 스스로 지킨다. `decrease(Quantity)` 시 부족하면 예외, 충분하면 새 Stock을 반환한다. - **Price**: 가격의 규칙("0보다 커야 한다")을 생성 시 검증한다. 불변. - **Quantity**: 수량의 규칙("0보다 커야 한다")을 생성 시 검증한다. Stock.decrease의 인자로 사용된다. -- **OrderLineSnapshot**: 도메인 관점에서는 VO(불변, 독립 식별 불필요)이지만, 정규화를 위해 @Entity로 별도 테이블에 매핑한다. OrderLine에 1:1로 종속되며, 주문 시점의 상품 정보(이름, 가격, 브랜드명)를 보존한다. +- **OrderLineSnapshot**: 도메인 관점에서는 VO(불변, 독립 식별 불필요)이지만, 정규화를 위해 @Entity로 별도 테이블에 매핑한다. `orderLineId(Long)`로 소속 주문항목을 식별한다. 주문 시점의 상품 정보(이름, 가격, 브랜드명)를 보존한다. ### 원칙 2: 단방향 연관, 양방향 최소화 @@ -239,8 +245,8 @@ classDiagram | Stock | 2 | isEnough(Quantity), decrease(Quantity) | **적절**. 재고의 핵심 규칙만 보유. 같은 불변식(value >= quantity)의 조회/변경 | | Price | 0+1 | 생성자 검증 | **적절**. 가격 규칙만 보유 | | Quantity | 0+1 | 생성자 검증 | **적절**. 수량 규칙만 보유 | -| Order | 1 | isOwnedBy | **적절**. 현재 최소 | -| OrderLine | 0 | - | **적절**. 주문 항목. Quantity와 Snapshot 보유 | +| Order | 3 | place(검증), isOwnedBy, assignOrderLines | **적절**. Aggregate Root로서 불변식 검증 + 하위 소속 관리 | +| OrderLine | 3 | of(스냅샷 내부 생성), assignToOrder, assignSnapshot | **적절**. 주문 항목 생성 + 소속 관리. 연산의 닫힘(self 반환) | | Like | 0 | - | **적절**. 단순 관계 레코드. subjectType enum으로 대상 구분 | | OrderLineSnapshot | 0 | - | **적절**. 불변 스냅샷. Price 포함 | @@ -334,6 +340,10 @@ classDiagram | 11 | Product.decreaseStock → Stock.decrease 위임 | 재고 규칙은 재고의 책임. Product는 조율만 수행 | Product가 직접 검증 (책임 혼재) | | 12 | BaseEntity/BaseTimeEntity를 다이어그램에서 제외 | 비즈니스 설계에 기술 인프라 클래스가 불필요. 코드 구현 시 적용 | 포함 (기술적 완전성은 높지만 비즈니스 가독성 저하) | | 13 | Stock.isEnough(Quantity) + Product.hasEnoughStock(Quantity) 추가 | 주문 시 "확인 먼저, 차감 나중" 흐름에서 재고 확인 판단 주체를 명확화. Quantity가 아닌 Stock이 보유 (같은 불변식, 의존 방향 유지) | Quantity.canBeSatisfiedBy(Stock) (VO 간 순환 의존 발생) | +| 14 | JPA 관계 매핑(@OneToMany, @ManyToOne, @OneToOne) 금지 | 모든 엔티티 간 참조를 ID(Long)로만. BC 간뿐 아니라 같은 Aggregate 내에서도 동일 적용. 일관된 무FK 철학 | @OneToMany + cascade (편리하나 결합도 증가, JPA 의존 심화) | +| 15 | Aggregate Root가 하위 불변식 직접 검증 | Order.place()가 orderLines를 받아 빈 주문/중복 상품 검증. DomainService에 위임하지 않음. Aggregate Root = 불변식 게이트키퍼 | OrderDomainService에서 검증 (Root의 책임 약화) | +| 16 | 연산의 닫힘 패턴 | assign류 메서드가 self를 반환하여 map/체이닝 가능. forEach(void) 대신 map(self 반환) 선호 | void 반환 + forEach (체이닝 불가, 함수형 스타일 불일치) | +| 17 | Order.place() — 도메인 행위를 표현하는 팩토리 네이밍 | "주문하다" = place. Brand.register()와 동일한 원칙. create() 같은 기술적 이름 금지 | Order.create() (행위 의도 불명확) | --- @@ -355,5 +365,7 @@ classDiagram | VO 간 의존 (Stock ──▷ Quantity) | "재고를 차감하려면 수량이 필요하다"는 도메인 관계를 표현 | | 위임 패턴 (decreaseStock → Stock.decrease) | "규칙은 규칙을 아는 객체가 수행한다"는 객체지향 원칙을 표현 | | 연관 방향 (전부 단방향 ID 참조) | BC 경계가 다이어그램에서 바로 보임 | -| Composition (Order ◆── OrderLine → OrderLineSnapshot) | "주문 항목과 스냅샷은 주문의 일부"라는 생명주기 종속을 시각적으로 표현 | +| ID 참조 (OrderLine → orderId, OrderLineSnapshot → orderLineId) | "주문 항목과 스냅샷은 주문에 종속되지만 ID로만 참조"라는 무FK 원칙 일관성 | +| Aggregate Root 불변식 (Order.place → 검증) | "Aggregate Root가 하위 엔티티의 불변식을 직접 검증"하는 DDD 원칙 | +| 연산의 닫힘 (assignToOrder → OrderLine) | assign류 메서드가 self를 반환하여 map/체이닝을 가능하게 하는 함수형 패턴 | | 메서드 없는 엔티티 (Like) | "관계 기록"이라는 본질에 충실 — 억지 행위 없음. subjectType enum으로 대상 종류 구분 | diff --git a/docs/design/05-domain-model.md b/docs/design/05-domain-model.md index 4f26dd0a5..09b19a6c7 100644 --- a/docs/design/05-domain-model.md +++ b/docs/design/05-domain-model.md @@ -147,10 +147,10 @@ | 로직 | 분류 | 이유 | |------|------|------| -| 중복 상품 검증 | Domain (Order) | "같은 상품 중복 주문 불가" = 논리적 비즈니스 규칙 | +| 중복 상품 검증 | Domain (Order.place) | "같은 상품 중복 주문 불가" = Aggregate Root가 직접 검증하는 논리적 비즈니스 규칙 | | productId 정렬 | Application | 데드락 방지 = 물리적/기술적 관심사 | | 재고 충분 여부 확인 | Domain (Stock.isEnough) | Stock의 불변식 = 논리적 | -| 수락/거절 판단 | Domain (Order 또는 OrderDomainService) | "전부 아니면 전무" = 논리적 비즈니스 규칙 | +| 수락/거절 판단 | Domain (Order.place) | "전부 아니면 전무" = Aggregate Root가 상태를 결정하는 논리적 비즈니스 규칙 | | 위 흐름의 오케스트레이션 | Application (OrderService) | Product 조회 + 락 획득 + 트랜잭션 = 물리적 | ### 예시: 좋아요 등록 (Cross-BC) @@ -193,7 +193,7 @@ | Catalog | Brand | (없음) | BrandRepository | | Catalog | Product | Price, Stock | ProductRepository | | Like | Like | (없음) | LikeRepository | -| Order | Order | OrderLine, OrderLineSnapshot | OrderRepository | +| Order | Order | (OrderLine, OrderLineSnapshot은 ID 참조) | OrderRepository | ### 7-3. Catalog BC: Brand와 Product가 독립 Aggregate인 이유 diff --git a/docs/design/06-architecture.md b/docs/design/06-architecture.md index ba6d06447..2fb375aa3 100644 --- a/docs/design/06-architecture.md +++ b/docs/design/06-architecture.md @@ -150,9 +150,9 @@ BaseTimeEntity (id, createdAt, updatedAt) | Catalog | `BrandRepository`, `ProductRepository` | interface | Catalog 조회/저장 계약 | | Like | `Like` | Entity | 관계 레코드 (hard-delete). `subjectType(enum) + subjectId(Long)` | | Like | `LikeRepository` | interface | 좋아요 조회/저장 계약 | -| Order | `Order` | Entity | 주문 상태 관리, `isOwnedBy(memberId)` | -| Order | `OrderLine` | Entity | 주문 항목. productId, Quantity 보유. 라인별 확장 지점 | -| Order | `OrderLineSnapshot` | VO (@Entity) | 주문 시점 불변 스냅샷 (Price 포함). 정규화를 위해 별도 테이블 | +| Order | `Order` | Entity | Aggregate Root. `place()`로 불변식 검증(빈 주문, 중복 상품), `assignOrderLines()`로 하위 소속 관리, `isOwnedBy(memberId)` | +| Order | `OrderLine` | Entity | 주문 항목. `orderId(Long)`로 Order 참조. `of()`에서 스냅샷 내부 생성. `assignToOrder()` / `assignSnapshot()`은 self 반환(연산의 닫힘) | +| Order | `OrderLineSnapshot` | VO (@Entity) | 주문 시점 불변 스냅샷. `orderLineId(Long)`로 OrderLine 참조. 정규화를 위해 별도 테이블 | | Order | `Quantity` | VO (@Embeddable) | 수량 > 0 자체 검증 | | Order | `OrderRepository` | interface | 주문 조회/저장 계약 | @@ -567,8 +567,8 @@ graph TB O["Order\n(Aggregate Root)"] OL["OrderLine\n(Entity)"] OLS["OrderLineSnapshot\n(VO, @Entity)"] - O ---|"포함"| OL - OL ---|"1:1"| OLS + OL -.->|"orderId (Long)"| O + OLS -.->|"orderLineId (Long)"| OL end P -..->|"brandId (Long)"| B diff --git a/domain/src/main/java/com/loopers/domain/catalog/product/ProductRepository.java b/domain/src/main/java/com/loopers/domain/catalog/product/ProductRepository.java index 7a38dbec5..e7973a47c 100644 --- a/domain/src/main/java/com/loopers/domain/catalog/product/ProductRepository.java +++ b/domain/src/main/java/com/loopers/domain/catalog/product/ProductRepository.java @@ -13,5 +13,7 @@ public interface ProductRepository { List findAll(); + List findAllByIdIn(List ids); + void softDeleteByBrandId(Long brandId); } diff --git a/domain/src/main/java/com/loopers/domain/order/Order.java b/domain/src/main/java/com/loopers/domain/order/Order.java new file mode 100644 index 000000000..a7cde5469 --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/order/Order.java @@ -0,0 +1,68 @@ +package com.loopers.domain.order; + +import com.loopers.domain.BaseTimeEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "orders") +public class Order extends BaseTimeEntity { + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private OrderStatus status; + + private Order(Long memberId, OrderStatus status) { + this.memberId = memberId; + this.status = status; + } + + public static Order place(Long memberId, List orderLines, OrderStatus status) { + validateNotEmpty(orderLines); + validateNoDuplicateProducts(orderLines); + return new Order(memberId, status); + } + + public boolean isAccepted() { + return this.status == OrderStatus.ACCEPTED; + } + + public boolean isOwnedBy(Long memberId) { + return this.memberId.equals(memberId); + } + + public List assignOrderLines(List orderLines) { + return orderLines.stream() + .map(line -> line.assignToOrder(this.getId())) + .toList(); + } + + private static void validateNotEmpty(List orderLines) { + if (orderLines == null || orderLines.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, + OrderExceptionMessage.Order.EMPTY_ORDER_LINES.message()); + } + } + + private static void validateNoDuplicateProducts(List orderLines) { + long distinctCount = orderLines.stream() + .map(OrderLine::getProductId) + .distinct() + .count(); + if (distinctCount != orderLines.size()) { + throw new CoreException(ErrorType.BAD_REQUEST, + OrderExceptionMessage.Order.DUPLICATE_PRODUCT.message()); + } + } +} diff --git a/domain/src/main/java/com/loopers/domain/order/OrderExceptionMessage.java b/domain/src/main/java/com/loopers/domain/order/OrderExceptionMessage.java new file mode 100644 index 000000000..01a5bd502 --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/order/OrderExceptionMessage.java @@ -0,0 +1,21 @@ +package com.loopers.domain.order; + +import lombok.AllArgsConstructor; + +public class OrderExceptionMessage { + + @AllArgsConstructor + public enum Order { + NOT_FOUND("존재하지 않는 주문입니다.", 4_001), + NOT_OWNER("본인의 주문이 아닙니다.", 4_002), + EMPTY_ORDER_LINES("주문할 상품을 선택해주세요.", 4_003), + DUPLICATE_PRODUCT("동일한 상품이 중복으로 포함되어 있습니다.", 4_004); + + private final String message; + private final Integer code; + + public String message() { + return message; + } + } +} diff --git a/domain/src/main/java/com/loopers/domain/order/OrderLine.java b/domain/src/main/java/com/loopers/domain/order/OrderLine.java new file mode 100644 index 000000000..b7ed1bf25 --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/order/OrderLine.java @@ -0,0 +1,63 @@ +package com.loopers.domain.order; + +import com.loopers.domain.catalog.product.vo.Quantity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "order_line") +public class OrderLine { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "order_id", nullable = false) + private Long orderId; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Embedded + private Quantity quantity; + + @Transient + private OrderLineSnapshot snapshot; + + private OrderLine(Long productId, Quantity quantity, OrderLineSnapshot snapshot) { + this.productId = productId; + this.quantity = quantity; + this.snapshot = snapshot; + } + + public static OrderLine of(Long productId, Quantity quantity, String productName, String productDescription, long price, String brandName) { + OrderLineSnapshot snapshot = OrderLineSnapshot.of(productName, productDescription, price, brandName); + return new OrderLine(productId, quantity, snapshot); + } + + public boolean belongsToProduct(Long productId) { + return this.productId.equals(productId); + } + + public boolean hasQuantity(long value) { + return this.quantity.isEqualTo(value); + } + + public boolean hasSnapshot() { + return this.snapshot != null; + } + + public OrderLine assignToOrder(Long orderId) { + this.orderId = orderId; + return this; + } + + public OrderLine assignSnapshot() { + this.snapshot.assignToOrderLine(this.id); + return this; + } +} diff --git a/domain/src/main/java/com/loopers/domain/order/OrderLineRepository.java b/domain/src/main/java/com/loopers/domain/order/OrderLineRepository.java new file mode 100644 index 000000000..151681cfe --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/order/OrderLineRepository.java @@ -0,0 +1,12 @@ +package com.loopers.domain.order; + +import java.util.List; + +public interface OrderLineRepository { + + OrderLine save(OrderLine orderLine); + + List saveAll(List orderLines); + + List findByOrderId(Long orderId); +} diff --git a/domain/src/main/java/com/loopers/domain/order/OrderLineSnapshot.java b/domain/src/main/java/com/loopers/domain/order/OrderLineSnapshot.java new file mode 100644 index 000000000..e77cc897e --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/order/OrderLineSnapshot.java @@ -0,0 +1,47 @@ +package com.loopers.domain.order; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "order_line_snapshot") +public class OrderLineSnapshot { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "product_name", nullable = false) + private String productName; + + @Column(name = "product_description") + private String productDescription; + + @Column(name = "price", nullable = false) + private long price; + + @Column(name = "brand_name", nullable = false) + private String brandName; + + @Column(name = "order_line_id", nullable = false) + private Long orderLineId; + + private OrderLineSnapshot(String productName, String productDescription, long price, String brandName) { + this.productName = productName; + this.productDescription = productDescription; + this.price = price; + this.brandName = brandName; + } + + public static OrderLineSnapshot of(String productName, String productDescription, long price, String brandName) { + return new OrderLineSnapshot(productName, productDescription, price, brandName); + } + + public void assignToOrderLine(Long orderLineId) { + this.orderLineId = orderLineId; + } +} diff --git a/domain/src/main/java/com/loopers/domain/order/OrderLineSnapshotRepository.java b/domain/src/main/java/com/loopers/domain/order/OrderLineSnapshotRepository.java new file mode 100644 index 000000000..0420f3107 --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/order/OrderLineSnapshotRepository.java @@ -0,0 +1,12 @@ +package com.loopers.domain.order; + +import java.util.List; + +public interface OrderLineSnapshotRepository { + + OrderLineSnapshot save(OrderLineSnapshot snapshot); + + List saveAll(List snapshots); + + List findByOrderLineIdIn(List orderLineIds); +} diff --git a/domain/src/main/java/com/loopers/domain/order/OrderRepository.java b/domain/src/main/java/com/loopers/domain/order/OrderRepository.java new file mode 100644 index 000000000..69350e0fe --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/order/OrderRepository.java @@ -0,0 +1,15 @@ +package com.loopers.domain.order; + +import java.util.List; +import java.util.Optional; + +public interface OrderRepository { + + Order save(Order order); + + Optional findById(Long id); + + List findByMemberId(Long memberId); + + List findAll(); +} diff --git a/domain/src/main/java/com/loopers/domain/order/OrderStatus.java b/domain/src/main/java/com/loopers/domain/order/OrderStatus.java new file mode 100644 index 000000000..a7888268d --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/order/OrderStatus.java @@ -0,0 +1,6 @@ +package com.loopers.domain.order; + +public enum OrderStatus { + ACCEPTED, + REJECTED +} diff --git a/domain/src/test/java/com/loopers/domain/order/OrderLineSnapshotTest.java b/domain/src/test/java/com/loopers/domain/order/OrderLineSnapshotTest.java new file mode 100644 index 000000000..4fb74ea75 --- /dev/null +++ b/domain/src/test/java/com/loopers/domain/order/OrderLineSnapshotTest.java @@ -0,0 +1,44 @@ +package com.loopers.domain.order; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class OrderLineSnapshotTest { + + @Test + void 스냅샷_생성_성공() { + // when + OrderLineSnapshot snapshot = OrderLineSnapshot.of("에어맥스", "설명", 100000L, "나이키"); + + // then + assertThat(snapshot.getProductName()).isEqualTo("에어맥스"); + } + + @Test + void 스냅샷_생성_시_가격_보존() { + // when + OrderLineSnapshot snapshot = OrderLineSnapshot.of("에어맥스", "설명", 100000L, "나이키"); + + // then + assertThat(snapshot.getPrice()).isEqualTo(100000L); + } + + @Test + void 스냅샷_생성_시_브랜드명_보존() { + // when + OrderLineSnapshot snapshot = OrderLineSnapshot.of("에어맥스", "설명", 100000L, "나이키"); + + // then + assertThat(snapshot.getBrandName()).isEqualTo("나이키"); + } + + @Test + void 스냅샷_description_null_허용() { + // when + OrderLineSnapshot snapshot = OrderLineSnapshot.of("에어맥스", null, 100000L, "나이키"); + + // then + assertThat(snapshot.getProductDescription()).isNull(); + } +} diff --git a/domain/src/test/java/com/loopers/domain/order/OrderLineTest.java b/domain/src/test/java/com/loopers/domain/order/OrderLineTest.java new file mode 100644 index 000000000..7552363db --- /dev/null +++ b/domain/src/test/java/com/loopers/domain/order/OrderLineTest.java @@ -0,0 +1,36 @@ +package com.loopers.domain.order; + +import com.loopers.domain.catalog.product.vo.Quantity; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class OrderLineTest { + + @Test + void 주문항목_생성_성공_상품ID() { + // when + OrderLine line = OrderLine.of(1L, Quantity.of(2L), "에어맥스", "설명", 100000L, "나이키"); + + // then + assertThat(line.belongsToProduct(1L)).isTrue(); + } + + @Test + void 주문항목_생성_성공_수량() { + // when + OrderLine line = OrderLine.of(1L, Quantity.of(3L), "에어맥스", "설명", 100000L, "나이키"); + + // then + assertThat(line.hasQuantity(3L)).isTrue(); + } + + @Test + void 주문항목_생성_시_스냅샷_자동_생성() { + // when + OrderLine line = OrderLine.of(1L, Quantity.of(2L), "에어맥스", "설명", 100000L, "나이키"); + + // then + assertThat(line.hasSnapshot()).isTrue(); + } +} diff --git a/domain/src/test/java/com/loopers/domain/order/OrderTest.java b/domain/src/test/java/com/loopers/domain/order/OrderTest.java new file mode 100644 index 000000000..cbfcaeacb --- /dev/null +++ b/domain/src/test/java/com/loopers/domain/order/OrderTest.java @@ -0,0 +1,111 @@ +package com.loopers.domain.order; + +import com.loopers.domain.catalog.product.vo.Quantity; +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class OrderTest { + + @Test + void 수락_주문() { + // given + List lines = List.of( + OrderLine.of(1L, Quantity.of(2L), "에어맥스", "설명", 100000L, "나이키") + ); + + // when + Order order = Order.place(10L, lines, OrderStatus.ACCEPTED); + + // then + assertThat(order.isAccepted()).isTrue(); + } + + @Test + void 거절_주문() { + // given + List lines = List.of( + OrderLine.of(1L, Quantity.of(2L), "에어맥스", "설명", 100000L, "나이키") + ); + + // when + Order order = Order.place(10L, lines, OrderStatus.REJECTED); + + // then + assertThat(order.isAccepted()).isFalse(); + } + + @Test + void 단일_상품_주문_성공() { + // given + List lines = List.of( + OrderLine.of(1L, Quantity.of(2L), "에어맥스", "설명", 100000L, "나이키") + ); + + // when & then + assertThat(Order.place(10L, lines, OrderStatus.ACCEPTED).isAccepted()).isTrue(); + } + + @Test + void 다중_상품_주문_성공() { + // given + List lines = List.of( + OrderLine.of(1L, Quantity.of(2L), "에어맥스", "설명", 100000L, "나이키"), + OrderLine.of(2L, Quantity.of(1L), "조던", "설명2", 200000L, "나이키"), + OrderLine.of(3L, Quantity.of(3L), "뉴발란스 993", "설명3", 150000L, "뉴발란스") + ); + + // when & then + assertThat(Order.place(10L, lines, OrderStatus.ACCEPTED).isAccepted()).isTrue(); + } + + @Test + void 본인_확인_성공() { + // given + List lines = List.of( + OrderLine.of(1L, Quantity.of(2L), "에어맥스", "설명", 100000L, "나이키") + ); + Order order = Order.place(10L, lines, OrderStatus.ACCEPTED); + + // when & then + assertThat(order.isOwnedBy(10L)).isTrue(); + } + + @Test + void 본인_아니면_false() { + // given + List lines = List.of( + OrderLine.of(1L, Quantity.of(2L), "에어맥스", "설명", 100000L, "나이키") + ); + Order order = Order.place(10L, lines, OrderStatus.ACCEPTED); + + // when & then + assertThat(order.isOwnedBy(99L)).isFalse(); + } + + @Test + void 빈_주문_예외() { + // when & then + assertThatThrownBy(() -> Order.place(10L, List.of(), OrderStatus.ACCEPTED)) + .isInstanceOf(CoreException.class) + .hasMessage(OrderExceptionMessage.Order.EMPTY_ORDER_LINES.message()); + } + + @Test + void 중복_상품_예외() { + // given + List lines = List.of( + OrderLine.of(1L, Quantity.of(2L), "에어맥스", "설명", 100000L, "나이키"), + OrderLine.of(1L, Quantity.of(1L), "에어맥스", "설명", 100000L, "나이키") + ); + + // when & then + assertThatThrownBy(() -> Order.place(10L, lines, OrderStatus.ACCEPTED)) + .isInstanceOf(CoreException.class) + .hasMessage(OrderExceptionMessage.Order.DUPLICATE_PRODUCT.message()); + } +} diff --git a/infrastructure/jpa/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java new file mode 100644 index 000000000..4d6104e80 --- /dev/null +++ b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.Order; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface OrderJpaRepository extends JpaRepository { + + List findByMemberId(Long memberId); +} diff --git a/infrastructure/jpa/src/main/java/com/loopers/infrastructure/order/OrderLineJpaRepository.java b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/order/OrderLineJpaRepository.java new file mode 100644 index 000000000..9216b5e23 --- /dev/null +++ b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/order/OrderLineJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderLine; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface OrderLineJpaRepository extends JpaRepository { + + List findByOrderId(Long orderId); +} diff --git a/infrastructure/jpa/src/main/java/com/loopers/infrastructure/order/OrderLineRepositoryImpl.java b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/order/OrderLineRepositoryImpl.java new file mode 100644 index 000000000..e943138a0 --- /dev/null +++ b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/order/OrderLineRepositoryImpl.java @@ -0,0 +1,30 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderLine; +import com.loopers.domain.order.OrderLineRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class OrderLineRepositoryImpl implements OrderLineRepository { + + private final OrderLineJpaRepository orderLineJpaRepository; + + @Override + public OrderLine save(OrderLine orderLine) { + return orderLineJpaRepository.save(orderLine); + } + + @Override + public List saveAll(List orderLines) { + return orderLineJpaRepository.saveAll(orderLines); + } + + @Override + public List findByOrderId(Long orderId) { + return orderLineJpaRepository.findByOrderId(orderId); + } +} diff --git a/infrastructure/jpa/src/main/java/com/loopers/infrastructure/order/OrderLineSnapshotJpaRepository.java b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/order/OrderLineSnapshotJpaRepository.java new file mode 100644 index 000000000..6287b22cf --- /dev/null +++ b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/order/OrderLineSnapshotJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderLineSnapshot; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface OrderLineSnapshotJpaRepository extends JpaRepository { + + List findByOrderLineIdIn(List orderLineIds); +} diff --git a/infrastructure/jpa/src/main/java/com/loopers/infrastructure/order/OrderLineSnapshotRepositoryImpl.java b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/order/OrderLineSnapshotRepositoryImpl.java new file mode 100644 index 000000000..f90f8d739 --- /dev/null +++ b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/order/OrderLineSnapshotRepositoryImpl.java @@ -0,0 +1,30 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderLineSnapshot; +import com.loopers.domain.order.OrderLineSnapshotRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class OrderLineSnapshotRepositoryImpl implements OrderLineSnapshotRepository { + + private final OrderLineSnapshotJpaRepository orderLineSnapshotJpaRepository; + + @Override + public OrderLineSnapshot save(OrderLineSnapshot snapshot) { + return orderLineSnapshotJpaRepository.save(snapshot); + } + + @Override + public List saveAll(List snapshots) { + return orderLineSnapshotJpaRepository.saveAll(snapshots); + } + + @Override + public List findByOrderLineIdIn(List orderLineIds) { + return orderLineSnapshotJpaRepository.findByOrderLineIdIn(orderLineIds); + } +} diff --git a/infrastructure/jpa/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java new file mode 100644 index 000000000..a974d7134 --- /dev/null +++ b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -0,0 +1,36 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@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.findById(id); + } + + @Override + public List findByMemberId(Long memberId) { + return orderJpaRepository.findByMemberId(memberId); + } + + @Override + public List findAll() { + return orderJpaRepository.findAll(); + } +} diff --git a/infrastructure/jpa/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java index a13a5870e..5420afd57 100644 --- a/infrastructure/jpa/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java +++ b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -3,5 +3,9 @@ import com.loopers.domain.catalog.product.Product; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; + public interface ProductJpaRepository extends JpaRepository { + + List findAllByIdIn(List ids); } diff --git a/infrastructure/jpa/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java index 04bb8ca00..0e362cc09 100644 --- a/infrastructure/jpa/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -46,6 +46,11 @@ public List findAll() { return productJpaRepository.findAll(); } + @Override + public List findAllByIdIn(List ids) { + return productJpaRepository.findAllByIdIn(ids); + } + @Override public void softDeleteByBrandId(Long brandId) { queryFactory diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java index bc6e93eaa..a926b7d24 100644 --- a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java @@ -132,6 +132,7 @@ private HttpStatus toHttpStatus(ErrorType errorType) { case NOT_FOUND -> HttpStatus.NOT_FOUND; case CONFLICT -> HttpStatus.CONFLICT; case UNAUTHORIZED -> HttpStatus.UNAUTHORIZED; + case FORBIDDEN -> HttpStatus.FORBIDDEN; case INTERNAL_ERROR -> HttpStatus.INTERNAL_SERVER_ERROR; }; } diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/order/AdminOrderController.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/order/AdminOrderController.java new file mode 100644 index 000000000..c1641a6e7 --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/order/AdminOrderController.java @@ -0,0 +1,28 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.service.OrderService; +import com.loopers.interfaces.api.order.dto.OrderApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/admin/orders") +@RequiredArgsConstructor +public class AdminOrderController { + + private final OrderService orderService; + + @GetMapping + public List getAll() { + return orderService.getAll().stream() + .map(OrderApiResponse::from) + .toList(); + } + + @GetMapping("/{id}") + public OrderApiResponse getById(@PathVariable Long id) { + return OrderApiResponse.from(orderService.getByIdForAdmin(id)); + } +} diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java new file mode 100644 index 000000000..ad2866d3f --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java @@ -0,0 +1,53 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.service.MemberService; +import com.loopers.application.service.OrderService; +import com.loopers.application.service.dto.MemberInfo; +import com.loopers.interfaces.api.order.dto.OrderApiResponse; +import com.loopers.interfaces.api.order.dto.OrderCreateApiRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/orders") +@RequiredArgsConstructor +public class OrderController { + + private final OrderService orderService; + private final MemberService memberService; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public OrderApiResponse create( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password, + @RequestBody OrderCreateApiRequest request + ) { + MemberInfo member = memberService.getMyInfo(loginId, password); + return OrderApiResponse.from(orderService.create(request.toCommand(member.memberId()))); + } + + @GetMapping + public List getMyOrders( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password + ) { + MemberInfo member = memberService.getMyInfo(loginId, password); + return orderService.getByMemberId(member.memberId()).stream() + .map(OrderApiResponse::from) + .toList(); + } + + @GetMapping("/{id}") + public OrderApiResponse getById( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password, + @PathVariable Long id + ) { + MemberInfo member = memberService.getMyInfo(loginId, password); + return OrderApiResponse.from(orderService.getById(id, member.memberId())); + } +} diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/OrderApiResponse.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/OrderApiResponse.java new file mode 100644 index 000000000..ca6058595 --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/OrderApiResponse.java @@ -0,0 +1,26 @@ +package com.loopers.interfaces.api.order.dto; + +import com.loopers.application.service.dto.OrderInfo; + +import java.time.ZonedDateTime; +import java.util.List; + +public record OrderApiResponse( + Long id, + Long memberId, + String status, + ZonedDateTime createdAt, + List orderLines +) { + public static OrderApiResponse from(OrderInfo info) { + return new OrderApiResponse( + info.orderId(), + info.memberId(), + info.status().name(), + info.createdAt(), + info.orderLines().stream() + .map(OrderLineApiResponse::from) + .toList() + ); + } +} diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/OrderCreateApiRequest.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/OrderCreateApiRequest.java new file mode 100644 index 000000000..f34c6c4e8 --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/OrderCreateApiRequest.java @@ -0,0 +1,19 @@ +package com.loopers.interfaces.api.order.dto; + +import com.loopers.application.service.dto.OrderCreateCommand; +import com.loopers.application.service.dto.OrderLineRequest; + +import java.util.List; + +public record OrderCreateApiRequest( + List orderLines +) { + public OrderCreateCommand toCommand(Long memberId) { + return new OrderCreateCommand( + memberId, + orderLines.stream() + .map(item -> new OrderLineRequest(item.productId(), item.quantity())) + .toList() + ); + } +} diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/OrderLineApiResponse.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/OrderLineApiResponse.java new file mode 100644 index 000000000..6c56be6a5 --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/OrderLineApiResponse.java @@ -0,0 +1,25 @@ +package com.loopers.interfaces.api.order.dto; + +import com.loopers.application.service.dto.OrderLineInfo; + +public record OrderLineApiResponse( + Long id, + Long productId, + long quantity, + String productName, + String productDescription, + long price, + String brandName +) { + public static OrderLineApiResponse from(OrderLineInfo info) { + return new OrderLineApiResponse( + info.orderLineId(), + info.productId(), + info.quantity(), + info.productName(), + info.productDescription(), + info.price(), + info.brandName() + ); + } +} diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/OrderLineItemRequest.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/OrderLineItemRequest.java new file mode 100644 index 000000000..c2190d439 --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/OrderLineItemRequest.java @@ -0,0 +1,7 @@ +package com.loopers.interfaces.api.order.dto; + +public record OrderLineItemRequest( + Long productId, + long quantity +) { +} diff --git a/presentation/commerce-api/src/test/java/com/loopers/controller/OrderE2ETest.java b/presentation/commerce-api/src/test/java/com/loopers/controller/OrderE2ETest.java new file mode 100644 index 000000000..6464ccc26 --- /dev/null +++ b/presentation/commerce-api/src/test/java/com/loopers/controller/OrderE2ETest.java @@ -0,0 +1,200 @@ +package com.loopers.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.service.dto.MemberRegisterCommand; +import com.loopers.interfaces.api.brand.dto.BrandCreateApiRequest; +import com.loopers.interfaces.api.order.dto.OrderCreateApiRequest; +import com.loopers.interfaces.api.order.dto.OrderLineItemRequest; +import com.loopers.interfaces.api.product.dto.ProductCreateApiRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class OrderE2ETest { + + private static final String LOGIN_ID = "ordertest123"; + private static final String PASSWORD = "Order!1234"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + private Long brandId; + private Long productId1; + private Long productId2; + + @BeforeEach + void setUp() throws Exception { + 회원을_등록한다(); + brandId = 브랜드를_생성하고_ID를_반환한다("나이키"); + productId1 = 상품을_생성하고_ID를_반환한다("에어맥스", 100000, 50, brandId); + productId2 = 상품을_생성하고_ID를_반환한다("조던", 200000, 30, brandId); + } + + @Test + void 주문_생성_수락_201() throws Exception { + // when & then + mockMvc.perform(post("/api/orders") + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new OrderCreateApiRequest(List.of( + new OrderLineItemRequest(productId1, 2), + new OrderLineItemRequest(productId2, 1) + ))))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.status").value("ACCEPTED")); + } + + @Test + void 주문_생성_거절_재고_부족_201() throws Exception { + // when & then + mockMvc.perform(post("/api/orders") + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new OrderCreateApiRequest(List.of( + new OrderLineItemRequest(productId1, 999) + ))))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.status").value("REJECTED")); + } + + @Test + void 주문_생성_시_스냅샷_포함() throws Exception { + // when & then + mockMvc.perform(post("/api/orders") + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new OrderCreateApiRequest(List.of( + new OrderLineItemRequest(productId1, 1) + ))))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.orderLines[0].productName").value("에어맥스")) + .andExpect(jsonPath("$.orderLines[0].brandName").value("나이키")); + } + + @Test + void 내_주문_내역_조회_200() throws Exception { + // given + 주문을_생성한다(List.of(new OrderLineItemRequest(productId1, 1))); + + // when & then + mockMvc.perform(get("/api/orders") + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)); + } + + @Test + void 주문_상세_조회_200() throws Exception { + // given + Long orderId = 주문을_생성하고_ID를_반환한다(List.of(new OrderLineItemRequest(productId1, 2))); + + // when & then + mockMvc.perform(get("/api/orders/{id}", orderId) + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.orderLines.length()").value(1)); + } + + @Test + void 관리자_전체_목록_200() throws Exception { + // given + 주문을_생성한다(List.of(new OrderLineItemRequest(productId1, 1))); + 주문을_생성한다(List.of(new OrderLineItemRequest(productId2, 1))); + + // when & then + mockMvc.perform(get("/api/admin/orders")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(2)); + } + + @Test + void 관리자_상세_조회_200() throws Exception { + // given + Long orderId = 주문을_생성하고_ID를_반환한다(List.of(new OrderLineItemRequest(productId1, 1))); + + // when & then + mockMvc.perform(get("/api/admin/orders/{id}", orderId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(orderId)); + } + + private void 회원을_등록한다() throws Exception { + mockMvc.perform(post("/api/members/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new MemberRegisterCommand(LOGIN_ID, PASSWORD, "테스터", LocalDate.of(2000, 1, 1), "order@test.com")))); + } + + private Long 브랜드를_생성하고_ID를_반환한다(String name) throws Exception { + mockMvc.perform(post("/api/admin/brands") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new BrandCreateApiRequest(name)))); + + String response = mockMvc.perform(get("/api/admin/brands")) + .andReturn().getResponse().getContentAsString(); + return objectMapper.readTree(response).get(0).get("id").asLong(); + } + + private Long 상품을_생성하고_ID를_반환한다(String name, long price, long stock, Long brandId) throws Exception { + mockMvc.perform(post("/api/admin/products") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new ProductCreateApiRequest(name, "설명", price, stock, brandId)))); + + String response = mockMvc.perform(get("/api/admin/products")) + .andReturn().getResponse().getContentAsString(); + + var products = objectMapper.readTree(response); + for (var product : products) { + if (product.get("name").asText().equals(name)) { + return product.get("id").asLong(); + } + } + return products.get(0).get("id").asLong(); + } + + private void 주문을_생성한다(List items) throws Exception { + mockMvc.perform(post("/api/orders") + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new OrderCreateApiRequest(items)))); + } + + private Long 주문을_생성하고_ID를_반환한다(List items) throws Exception { + String response = mockMvc.perform(post("/api/orders") + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new OrderCreateApiRequest(items)))) + .andReturn().getResponse().getContentAsString(); + return objectMapper.readTree(response).get("id").asLong(); + } +} diff --git a/supports/error/src/main/java/com/loopers/support/error/ErrorType.java b/supports/error/src/main/java/com/loopers/support/error/ErrorType.java index 8791c5b01..774cfedbf 100644 --- a/supports/error/src/main/java/com/loopers/support/error/ErrorType.java +++ b/supports/error/src/main/java/com/loopers/support/error/ErrorType.java @@ -10,7 +10,8 @@ public enum ErrorType { BAD_REQUEST("Bad Request", "잘못된 요청입니다."), NOT_FOUND("Not Found", "존재하지 않는 요청입니다."), CONFLICT("Conflict", "이미 존재하는 리소스입니다."), - UNAUTHORIZED("Unauthorized", "인증에 실패했습니다."); + UNAUTHORIZED("Unauthorized", "인증에 실패했습니다."), + FORBIDDEN("Forbidden", "접근 권한이 없습니다."); private final String code; private final String message; From 9528fa68c98a83f7cf3cbc7924f78efa764f24ba Mon Sep 17 00:00:00 2001 From: APapeIsName Date: Thu, 26 Feb 2026 22:40:39 +0900 Subject: [PATCH 6/8] =?UTF-8?q?feat:=20Like=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 --- .DS_Store | Bin 6148 -> 0 bytes .../application/service/LikeService.java | 91 ++++++++ .../service/dto/LikeRegisterCommand.java | 4 + .../loopers/application/LikeServiceTest.java | 192 +++++++++++++++ docs/design/03-class-diagram.md | 13 +- docs/design/05-domain-model.md | 5 +- .../domain/catalog/ActiveProductService.java | 37 +++ .../domain/catalog/product/Product.java | 12 + .../product/ProductExceptionMessage.java | 3 +- .../java/com/loopers/domain/like/Like.java | 45 ++++ .../domain/like/LikeExceptionMessage.java | 19 ++ .../loopers/domain/like/LikeRepository.java | 16 ++ .../loopers/domain/like/LikeSubjectType.java | 5 + .../catalog/ActiveProductServiceTest.java | 88 +++++++ .../domain/catalog/product/ProductTest.java | 37 +++ .../com/loopers/domain/like/LikeTest.java | 44 ++++ .../com/loopers/domain/like/LikeFixture.java | 15 ++ .../like/LikeJpaRepository.java | 17 ++ .../like/LikeRepositoryImpl.java | 42 ++++ .../interfaces/api/like/LikeController.java | 58 +++++ .../com/loopers/controller/LikeE2ETest.java | 220 ++++++++++++++++++ 21 files changed, 955 insertions(+), 8 deletions(-) delete mode 100644 .DS_Store create mode 100644 application/commerce-service/src/main/java/com/loopers/application/service/LikeService.java create mode 100644 application/commerce-service/src/main/java/com/loopers/application/service/dto/LikeRegisterCommand.java create mode 100644 application/commerce-service/src/test/java/com/loopers/application/LikeServiceTest.java create mode 100644 domain/src/main/java/com/loopers/domain/catalog/ActiveProductService.java create mode 100644 domain/src/main/java/com/loopers/domain/like/Like.java create mode 100644 domain/src/main/java/com/loopers/domain/like/LikeExceptionMessage.java create mode 100644 domain/src/main/java/com/loopers/domain/like/LikeRepository.java create mode 100644 domain/src/main/java/com/loopers/domain/like/LikeSubjectType.java create mode 100644 domain/src/test/java/com/loopers/domain/catalog/ActiveProductServiceTest.java create mode 100644 domain/src/test/java/com/loopers/domain/like/LikeTest.java create mode 100644 domain/src/testFixtures/java/com/loopers/domain/like/LikeFixture.java create mode 100644 infrastructure/jpa/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java create mode 100644 infrastructure/jpa/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java create mode 100644 presentation/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeController.java create mode 100644 presentation/commerce-api/src/test/java/com/loopers/controller/LikeE2ETest.java diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 83b7e14dcdfc001bff9d4e3d62c14125a204ab72..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKT}}cq5dMlAMTrLEi;o)*K;jKnga;D!K`x-MM2X-Me&UCE(-uRtjP(_ZDLKodZnO)%MGx4~py+M3n;JNrZ~IG7XzjwPX* zDxeDdZ3X1Jo8T5Rz8015Z<}+icWbtYCI(nxj0tkIaECeLa*jqEJz%*O|5}OPrgwoM zeMgwbYnhWZ-ynbN3*q$=@H(t;nUPt+dbkQ%jfvIDgjtwjC(7^?uV=Z1-i_Fs)eoq| zGL90K<{)61;GS%jm{t79CGy+B1$sC~7gy+`8+!|viuaVY=G+(AhiaX|;W^;2i)-dR zLkE|{7N*GK{17%_#1R(EE4;^eWZjGJ?7U?eW~no7Wj|wu-IGl`-vXZ89Q? zz2Q)MRX`O`1wIv!--nbh($ diff --git a/application/commerce-service/src/main/java/com/loopers/application/service/LikeService.java b/application/commerce-service/src/main/java/com/loopers/application/service/LikeService.java new file mode 100644 index 000000000..d36a17261 --- /dev/null +++ b/application/commerce-service/src/main/java/com/loopers/application/service/LikeService.java @@ -0,0 +1,91 @@ +package com.loopers.application.service; + +import com.loopers.application.service.dto.LikeRegisterCommand; +import com.loopers.application.service.dto.ProductInfo; +import com.loopers.domain.catalog.ActiveProductService; +import com.loopers.domain.catalog.brand.Brand; +import com.loopers.domain.catalog.brand.BrandRepository; +import com.loopers.domain.catalog.product.Product; +import com.loopers.domain.catalog.product.ProductRepository; +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeExceptionMessage; +import com.loopers.domain.like.LikeRepository; +import com.loopers.domain.like.LikeSubjectType; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class LikeService { + + private final LikeRepository likeRepository; + private final ActiveProductService activeProductService; + private final ProductRepository productRepository; + private final BrandRepository brandRepository; + + // 좋아요를 등록한다 + + @Transactional + public void like(LikeRegisterCommand command) { + Product product = activeProductService.get(command.productId()); + + if (likeRepository.existsByMemberIdAndSubjectTypeAndSubjectId( + command.memberId(), LikeSubjectType.PRODUCT, command.productId())) { + throw new CoreException(ErrorType.BAD_REQUEST, + LikeExceptionMessage.Like.ALREADY_LIKED.message()); + } + + likeRepository.save(Like.mark(command.memberId(), LikeSubjectType.PRODUCT, command.productId())); + product.increaseLikesCount(); + } + + // 좋아요를 취소한다 + + @Transactional + public void unlike(Long memberId, Long productId) { + Like like = likeRepository.findByMemberIdAndSubjectTypeAndSubjectId( + memberId, LikeSubjectType.PRODUCT, productId) + .orElseThrow(() -> new CoreException(ErrorType.BAD_REQUEST, + LikeExceptionMessage.Like.NOT_LIKED.message())); + + likeRepository.delete(like); + + productRepository.findById(productId) + .ifPresent(Product::decreaseLikesCount); + } + + // 내 좋아요 목록을 조회한다 + + @Transactional(readOnly = true) + public List getMyLikes(Long memberId) { + List likes = likeRepository.findByMemberIdAndSubjectType(memberId, LikeSubjectType.PRODUCT); + + List productIds = likes.stream() + .map(Like::getSubjectId) + .toList(); + + List activeProducts = productRepository.findAllByIdIn(productIds).stream() + .filter(product -> !product.isDeleted()) + .toList(); + + List brandIds = activeProducts.stream() + .map(Product::getBrandId) + .distinct() + .toList(); + + Map brandMap = brandRepository.findAllByIdIn(brandIds).stream() + .collect(Collectors.toMap(Brand::getId, Function.identity())); + + return activeProducts.stream() + .map(product -> ProductInfo.from(product, brandMap.get(product.getBrandId()))) + .toList(); + } +} diff --git a/application/commerce-service/src/main/java/com/loopers/application/service/dto/LikeRegisterCommand.java b/application/commerce-service/src/main/java/com/loopers/application/service/dto/LikeRegisterCommand.java new file mode 100644 index 000000000..50eb074e2 --- /dev/null +++ b/application/commerce-service/src/main/java/com/loopers/application/service/dto/LikeRegisterCommand.java @@ -0,0 +1,4 @@ +package com.loopers.application.service.dto; + +public record LikeRegisterCommand(Long memberId, Long productId) { +} diff --git a/application/commerce-service/src/test/java/com/loopers/application/LikeServiceTest.java b/application/commerce-service/src/test/java/com/loopers/application/LikeServiceTest.java new file mode 100644 index 000000000..00055299b --- /dev/null +++ b/application/commerce-service/src/test/java/com/loopers/application/LikeServiceTest.java @@ -0,0 +1,192 @@ +package com.loopers.application; + +import com.loopers.application.service.LikeService; +import com.loopers.application.service.dto.LikeRegisterCommand; +import com.loopers.application.service.dto.ProductInfo; +import com.loopers.domain.catalog.ActiveProductService; +import com.loopers.domain.catalog.brand.Brand; +import com.loopers.domain.catalog.brand.BrandRepository; +import com.loopers.domain.catalog.product.Product; +import com.loopers.domain.catalog.product.ProductRepository; +import com.loopers.domain.catalog.product.vo.Money; +import com.loopers.domain.catalog.product.vo.Stock; +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeExceptionMessage; +import com.loopers.domain.like.LikeRepository; +import com.loopers.domain.like.LikeSubjectType; +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class LikeServiceTest { + + @InjectMocks + private LikeService likeService; + + @Mock + private LikeRepository likeRepository; + + @Mock + private ActiveProductService activeProductService; + + @Mock + private ProductRepository productRepository; + + @Mock + private BrandRepository brandRepository; + + // 좋아요를 등록한다 + + @Test + void 좋아요_등록_성공() { + // given + LikeRegisterCommand command = new LikeRegisterCommand(1L, 100L); + Product product = Product.register("에어맥스", "설명", Money.of(100000), Stock.of(50), 10L); + given(activeProductService.get(100L)).willReturn(product); + given(likeRepository.existsByMemberIdAndSubjectTypeAndSubjectId(1L, LikeSubjectType.PRODUCT, 100L)) + .willReturn(false); + + // when + likeService.like(command); + + // then + verify(likeRepository).save(any(Like.class)); + } + + @Test + void 좋아요_등록_시_likesCount_증가() { + // given + LikeRegisterCommand command = new LikeRegisterCommand(1L, 100L); + Product product = Product.register("에어맥스", "설명", Money.of(100000), Stock.of(50), 10L); + given(activeProductService.get(100L)).willReturn(product); + given(likeRepository.existsByMemberIdAndSubjectTypeAndSubjectId(1L, LikeSubjectType.PRODUCT, 100L)) + .willReturn(false); + + // when + likeService.like(command); + + // then + assertThat(product.hasLikesCount(1L)).isTrue(); + } + + @Test + void 이미_좋아요한_상품이면_예외() { + // given + LikeRegisterCommand command = new LikeRegisterCommand(1L, 100L); + Product product = Product.register("에어맥스", "설명", Money.of(100000), Stock.of(50), 10L); + given(activeProductService.get(100L)).willReturn(product); + given(likeRepository.existsByMemberIdAndSubjectTypeAndSubjectId(1L, LikeSubjectType.PRODUCT, 100L)) + .willReturn(true); + + // when & then + assertThatThrownBy(() -> likeService.like(command)) + .isInstanceOf(CoreException.class) + .hasMessage(LikeExceptionMessage.Like.ALREADY_LIKED.message()); + } + + // 좋아요를 취소한다 + + @Test + void 좋아요_취소_성공() { + // given + Like like = Like.mark(1L, LikeSubjectType.PRODUCT, 100L); + Product product = Product.register("에어맥스", "설명", Money.of(100000), Stock.of(50), 10L); + product.increaseLikesCount(); + given(likeRepository.findByMemberIdAndSubjectTypeAndSubjectId(1L, LikeSubjectType.PRODUCT, 100L)) + .willReturn(Optional.of(like)); + given(productRepository.findById(100L)).willReturn(Optional.of(product)); + + // when + likeService.unlike(1L, 100L); + + // then + verify(likeRepository).delete(like); + } + + @Test + void 좋아요_취소_시_likesCount_감소() { + // given + Like like = Like.mark(1L, LikeSubjectType.PRODUCT, 100L); + Product product = Product.register("에어맥스", "설명", Money.of(100000), Stock.of(50), 10L); + product.increaseLikesCount(); + given(likeRepository.findByMemberIdAndSubjectTypeAndSubjectId(1L, LikeSubjectType.PRODUCT, 100L)) + .willReturn(Optional.of(like)); + given(productRepository.findById(100L)).willReturn(Optional.of(product)); + + // when + likeService.unlike(1L, 100L); + + // then + assertThat(product.hasLikesCount(0L)).isTrue(); + } + + @Test + void 좋아요하지_않은_상품_취소_시_예외() { + // given + given(likeRepository.findByMemberIdAndSubjectTypeAndSubjectId(1L, LikeSubjectType.PRODUCT, 100L)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> likeService.unlike(1L, 100L)) + .isInstanceOf(CoreException.class) + .hasMessage(LikeExceptionMessage.Like.NOT_LIKED.message()); + } + + // 내 좋아요 목록을 조회한다 + + @Test + void 내_좋아요_목록_조회_성공() { + // given + List likes = List.of( + Like.mark(1L, LikeSubjectType.PRODUCT, 100L), + Like.mark(1L, LikeSubjectType.PRODUCT, 200L) + ); + Product product1 = Product.register("에어맥스", "설명1", Money.of(100000), Stock.of(50), 10L); + Product product2 = Product.register("조던", "설명2", Money.of(200000), Stock.of(30), 10L); + Brand brand = Brand.register("나이키"); + given(likeRepository.findByMemberIdAndSubjectType(1L, LikeSubjectType.PRODUCT)).willReturn(likes); + given(productRepository.findAllByIdIn(List.of(100L, 200L))).willReturn(List.of(product1, product2)); + given(brandRepository.findAllByIdIn(List.of(10L))).willReturn(List.of(brand)); + + // when + List result = likeService.getMyLikes(1L); + + // then + assertThat(result).hasSize(2); + } + + @Test + void 삭제된_상품은_목록에서_제외() { + // given + List likes = List.of( + Like.mark(1L, LikeSubjectType.PRODUCT, 100L), + Like.mark(1L, LikeSubjectType.PRODUCT, 200L) + ); + Product product1 = Product.register("에어맥스", "설명1", Money.of(100000), Stock.of(50), 10L); + Product product2 = Product.register("조던", "설명2", Money.of(200000), Stock.of(30), 10L); + product2.delete(); + Brand brand = Brand.register("나이키"); + given(likeRepository.findByMemberIdAndSubjectType(1L, LikeSubjectType.PRODUCT)).willReturn(likes); + given(productRepository.findAllByIdIn(List.of(100L, 200L))).willReturn(List.of(product1, product2)); + given(brandRepository.findAllByIdIn(List.of(10L))).willReturn(List.of(brand)); + + // when + List result = likeService.getMyLikes(1L); + + // then + assertThat(result).hasSize(1); + } +} diff --git a/docs/design/03-class-diagram.md b/docs/design/03-class-diagram.md index 477d98352..5c0c7bb3d 100644 --- a/docs/design/03-class-diagram.md +++ b/docs/design/03-class-diagram.md @@ -65,6 +65,9 @@ classDiagram -Long memberId -LikeSubjectType subjectType -Long subjectId + +mark(memberId, subjectType, subjectId)$ Like + +isOwnedBy(memberId) boolean + +isForSubject(subjectType, subjectId) boolean } class LikeSubjectType { @@ -72,7 +75,7 @@ classDiagram PRODUCT } - note for Like "단순 관계 레코드 (hard-delete)\nsubjectType + subjectId로 대상 식별\n상속 없이 enum으로 확장" + note for Like "호감 표현 (hard-delete)\n사용자의 특정 대상에 대한 관심/호감 표현\n서비스가 얻는 선호도 데이터\nsubjectType + subjectId로 대상 식별\n상속 없이 enum으로 확장" %% ── 주문 ── @@ -193,7 +196,7 @@ classDiagram | Product | `guardNotDeleted()` | 삭제 여부를 자기가 검증한다 | | Order | `isOwnedBy(memberId)` | 본인 주문 확인을 자기가 판단한다 | -**Like에 메서드가 없는 이유**: 좋아요는 "회원과 대상 사이의 관계 기록"이라는 본질에 충실한 단순 엔티티다. 등록은 `Like.of(memberId, subjectType, subjectId)`, 삭제는 물리 삭제(hard-delete). `subjectType` enum으로 대상 종류(PRODUCT, 향후 BRAND 등)를 구분하며, 상속 없이 확장 가능하다. +**Like의 도메인 정의**: 좋아요는 사용자의 특정 대상에 대한 관심/호감 표현이다. 서비스가 사용자와의 계약을 통해 얻는 선호도 데이터로서의 가치를 가진다. 생성은 `Like.mark(memberId, subjectType, subjectId)`, 철회는 물리 삭제(hard-delete). 불변이며 수정은 없다. `isOwnedBy(memberId)` — 누구의 호감인지, `isForSubject(subjectType, subjectId)` — 어떤 대상에 대한 호감인지를 자기가 답한다. `subjectType` enum으로 대상 종류(PRODUCT, 향후 BRAND 등)를 구분하며, 상속 없이 확장 가능하다. ### 원칙 4: 한 객체에 책임이 몰리지 않았는가? @@ -247,7 +250,7 @@ classDiagram | Quantity | 0+1 | 생성자 검증 | **적절**. 수량 규칙만 보유 | | Order | 3 | place(검증), isOwnedBy, assignOrderLines | **적절**. Aggregate Root로서 불변식 검증 + 하위 소속 관리 | | OrderLine | 3 | of(스냅샷 내부 생성), assignToOrder, assignSnapshot | **적절**. 주문 항목 생성 + 소속 관리. 연산의 닫힘(self 반환) | -| Like | 0 | - | **적절**. 단순 관계 레코드. subjectType enum으로 대상 구분 | +| Like | 2 | isOwnedBy, isForSubject | **적절**. 호감 표현. 자기 정체성(누구의, 어떤 대상에 대한)에 답하는 행위만 보유 | | OrderLineSnapshot | 0 | - | **적절**. 불변 스냅샷. Price 포함 | ### Service별 @@ -333,7 +336,7 @@ classDiagram | 4 | OrderStatus: ACCEPTED, REJECTED만 | 현재 요구사항에 중간 상태/취소 없음. enum이므로 확장 용이 | CANCELLED 포함 (현재 불필요, YAGNI) | | 5 | 모든 BC 간 참조를 ID(Long)만 사용 | BC 간 직접 의존 제거. MSA 전환 시 변경 최소화 | 객체 참조 (편리하나 BC 경계 위반) | | 6 | OrderLine(Entity) + OrderLineSnapshot(VO, @Entity) 분리 | OrderLine은 주문 항목으로 라인별 확장 지점(쿠폰, 부분취소). OrderLineSnapshot은 불변 스냅샷으로 정규화를 위해 별도 테이블 | OrderLineSnapshot 하나로 합치기 (확장 어려움), @Embeddable (정규화 위반) | -| 7 | Like에 메서드 없음 | 단순 관계 레코드. hard-delete이므로 엔티티 행위 불필요 | toggle() 등 추가 (과도한 추상화) | +| 7 | Like에 정체성 행위 메서드 추가 | "호감 표현"이라는 도메인 정의에 따라 `isOwnedBy`, `isForSubject`로 자기 정체성에 답함. 단순 관계 레코드가 아닌 선호도 데이터로서의 의미 부여 | 메서드 없음 (도메인 의미 손실), toggle() (과도한 추상화) | | 8 | 양방향 연관 0개 | 단방향만으로 모든 요구사항 충족. 양방향은 순환 의존과 복잡성 유발 | Product ↔ Brand 양방향 (편의성 vs 복잡성 트레이드오프) | | 9 | Like를 subjectType+subjectId로 일반화 | 상속(JOINED/SINGLE_TABLE) 대신 enum+ID 패턴 채택. UNIQUE 제약 자연스러움, 스키마 변경 없이 타입 확장, 무FK 철학 일관 | JPA 상속 (JOINED: UNIQUE 불가+JOIN 비용, SINGLE_TABLE: nullable 컬럼), ProductLike/BrandLike 클래스 분리 (타입 추가마다 엔티티+테이블 필요) | | 10 | Stock, Price, Quantity를 VO로 분리 | 자체 규칙(불변식)이 있는 속성만 VO로 캡슐화. "규칙 없으면 원시 타입" 기준 | 원시 타입 유지 (규칙이 엔티티나 Service에 흩어짐) | @@ -368,4 +371,4 @@ classDiagram | ID 참조 (OrderLine → orderId, OrderLineSnapshot → orderLineId) | "주문 항목과 스냅샷은 주문에 종속되지만 ID로만 참조"라는 무FK 원칙 일관성 | | Aggregate Root 불변식 (Order.place → 검증) | "Aggregate Root가 하위 엔티티의 불변식을 직접 검증"하는 DDD 원칙 | | 연산의 닫힘 (assignToOrder → OrderLine) | assign류 메서드가 self를 반환하여 map/체이닝을 가능하게 하는 함수형 패턴 | -| 메서드 없는 엔티티 (Like) | "관계 기록"이라는 본질에 충실 — 억지 행위 없음. subjectType enum으로 대상 종류 구분 | +| 호감 표현 엔티티 (Like) | "선호도 데이터"라는 본질에 충실 — 자기 정체성(누구의, 어떤 대상)에 답하는 행위를 보유. subjectType enum으로 대상 종류 구분 | diff --git a/docs/design/05-domain-model.md b/docs/design/05-domain-model.md index 09b19a6c7..85f9d6fa9 100644 --- a/docs/design/05-domain-model.md +++ b/docs/design/05-domain-model.md @@ -31,9 +31,10 @@ - Aggregate: `Brand`, `Product` 3. `Like Context` -- 책임: 회원의 선호(좋아요) 관계 기록 관리 +- 책임: 사용자의 특정 대상에 대한 관심/호감 표현 관리. 서비스가 사용자와의 계약을 통해 얻는 선호도 데이터 - Aggregate: `Like` -- 모델: `Like(memberId, subjectType, subjectId)` +- 모델: `Like(memberId, subjectType, subjectId)` + `mark()`, `isOwnedBy()`, `isForSubject()` +- 비정규화 관계: Product.likesCount(인기도)는 Catalog BC가 소유. Like BC의 개별 레코드가 원본이며, likesCount는 BC 경계를 사유로 한 정당한 비정규화 4. `Order Context` - 책임: 주문 생성/조회, 주문 스냅샷 보존, 수락/거절 판단 diff --git a/domain/src/main/java/com/loopers/domain/catalog/ActiveProductService.java b/domain/src/main/java/com/loopers/domain/catalog/ActiveProductService.java new file mode 100644 index 000000000..e9e6c0779 --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/catalog/ActiveProductService.java @@ -0,0 +1,37 @@ +package com.loopers.domain.catalog; + +import com.loopers.domain.catalog.brand.Brand; +import com.loopers.domain.catalog.brand.BrandRepository; +import com.loopers.domain.catalog.product.Product; +import com.loopers.domain.catalog.product.ProductExceptionMessage; +import com.loopers.domain.catalog.product.ProductRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ActiveProductService { + + private final ProductRepository productRepository; + private final BrandRepository brandRepository; + + public Product get(Long productId) { + Product product = productRepository.findById(productId) + .filter(p -> !p.isDeleted()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + ProductExceptionMessage.Product.NOT_FOUND.message())); + + Brand brand = brandRepository.findById(product.getBrandId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, + ProductExceptionMessage.Product.NOT_FOUND.message())); + + if (brand.isDeleted()) { + throw new CoreException(ErrorType.BAD_REQUEST, + ProductExceptionMessage.Product.UNAVAILABLE.message()); + } + + return product; + } +} diff --git a/domain/src/main/java/com/loopers/domain/catalog/product/Product.java b/domain/src/main/java/com/loopers/domain/catalog/product/Product.java index 60f662638..d1aacfd25 100644 --- a/domain/src/main/java/com/loopers/domain/catalog/product/Product.java +++ b/domain/src/main/java/com/loopers/domain/catalog/product/Product.java @@ -83,6 +83,18 @@ public boolean hasEnoughStock(Quantity quantity) { return this.stock.isEnough(quantity); } + public void increaseLikesCount() { + this.likesCount++; + } + + public void decreaseLikesCount() { + this.likesCount = Math.max(0, this.likesCount - 1); + } + + public boolean hasLikesCount(long value) { + return this.likesCount == value; + } + private void guardNotDeleted() { if (isDeleted()) { throw new CoreException(ErrorType.BAD_REQUEST, diff --git a/domain/src/main/java/com/loopers/domain/catalog/product/ProductExceptionMessage.java b/domain/src/main/java/com/loopers/domain/catalog/product/ProductExceptionMessage.java index d899680af..86b053665 100644 --- a/domain/src/main/java/com/loopers/domain/catalog/product/ProductExceptionMessage.java +++ b/domain/src/main/java/com/loopers/domain/catalog/product/ProductExceptionMessage.java @@ -8,7 +8,8 @@ public class ProductExceptionMessage { public enum Product { INVALID_NAME("상품명은 1자 이상 100자 이하여야 합니다.", 3_001), NOT_FOUND("존재하지 않는 상품입니다.", 3_002), - ALREADY_DELETED("이미 삭제된 상품입니다.", 3_003); + ALREADY_DELETED("이미 삭제된 상품입니다.", 3_003), + UNAVAILABLE("현재 이용할 수 없는 상품입니다.", 3_004); private final String message; private final Integer code; diff --git a/domain/src/main/java/com/loopers/domain/like/Like.java b/domain/src/main/java/com/loopers/domain/like/Like.java new file mode 100644 index 000000000..941e52173 --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/like/Like.java @@ -0,0 +1,45 @@ +package com.loopers.domain.like; + +import com.loopers.domain.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "likes", uniqueConstraints = { + @UniqueConstraint(name = "uk_likes_member_subject", + columnNames = {"member_id", "subject_type", "subject_id"}) +}) +public class Like extends BaseTimeEntity { + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Enumerated(EnumType.STRING) + @Column(name = "subject_type", nullable = false) + private LikeSubjectType subjectType; + + @Column(name = "subject_id", nullable = false) + private Long subjectId; + + private Like(Long memberId, LikeSubjectType subjectType, Long subjectId) { + this.memberId = memberId; + this.subjectType = subjectType; + this.subjectId = subjectId; + } + + public static Like mark(Long memberId, LikeSubjectType subjectType, Long subjectId) { + return new Like(memberId, subjectType, subjectId); + } + + public boolean isOwnedBy(Long memberId) { + return this.memberId.equals(memberId); + } + + public boolean isForSubject(LikeSubjectType subjectType, Long subjectId) { + return this.subjectType == subjectType && this.subjectId.equals(subjectId); + } +} diff --git a/domain/src/main/java/com/loopers/domain/like/LikeExceptionMessage.java b/domain/src/main/java/com/loopers/domain/like/LikeExceptionMessage.java new file mode 100644 index 000000000..801e91a6a --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/like/LikeExceptionMessage.java @@ -0,0 +1,19 @@ +package com.loopers.domain.like; + +import lombok.AllArgsConstructor; + +public class LikeExceptionMessage { + + @AllArgsConstructor + public enum Like { + ALREADY_LIKED("이미 좋아요한 상품입니다.", 5_001), + NOT_LIKED("좋아요하지 않은 상품입니다.", 5_002); + + private final String message; + private final Integer code; + + public String message() { + return message; + } + } +} diff --git a/domain/src/main/java/com/loopers/domain/like/LikeRepository.java b/domain/src/main/java/com/loopers/domain/like/LikeRepository.java new file mode 100644 index 000000000..60436fee8 --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/like/LikeRepository.java @@ -0,0 +1,16 @@ +package com.loopers.domain.like; + +import java.util.Optional; + +public interface LikeRepository { + + Like save(Like like); + + void delete(Like like); + + boolean existsByMemberIdAndSubjectTypeAndSubjectId(Long memberId, LikeSubjectType subjectType, Long subjectId); + + Optional findByMemberIdAndSubjectTypeAndSubjectId(Long memberId, LikeSubjectType subjectType, Long subjectId); + + java.util.List findByMemberIdAndSubjectType(Long memberId, LikeSubjectType subjectType); +} diff --git a/domain/src/main/java/com/loopers/domain/like/LikeSubjectType.java b/domain/src/main/java/com/loopers/domain/like/LikeSubjectType.java new file mode 100644 index 000000000..e95fc654a --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/like/LikeSubjectType.java @@ -0,0 +1,5 @@ +package com.loopers.domain.like; + +public enum LikeSubjectType { + PRODUCT +} diff --git a/domain/src/test/java/com/loopers/domain/catalog/ActiveProductServiceTest.java b/domain/src/test/java/com/loopers/domain/catalog/ActiveProductServiceTest.java new file mode 100644 index 000000000..6e5c56234 --- /dev/null +++ b/domain/src/test/java/com/loopers/domain/catalog/ActiveProductServiceTest.java @@ -0,0 +1,88 @@ +package com.loopers.domain.catalog; + +import com.loopers.domain.catalog.brand.Brand; +import com.loopers.domain.catalog.brand.BrandRepository; +import com.loopers.domain.catalog.product.Product; +import com.loopers.domain.catalog.product.ProductExceptionMessage; +import com.loopers.domain.catalog.product.ProductRepository; +import com.loopers.domain.catalog.product.vo.Money; +import com.loopers.domain.catalog.product.vo.Stock; +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class ActiveProductServiceTest { + + @InjectMocks + private ActiveProductService activeProductService; + + @Mock + private ProductRepository productRepository; + + @Mock + private BrandRepository brandRepository; + + @Test + void 활성_상품_조회_성공() { + // given + Product product = Product.register("에어맥스", "설명", Money.of(100000), Stock.of(50), 10L); + Brand brand = Brand.register("나이키"); + given(productRepository.findById(1L)).willReturn(Optional.of(product)); + given(brandRepository.findById(10L)).willReturn(Optional.of(brand)); + + // when + Product result = activeProductService.get(1L); + + // then + assertThat(result.hasName("에어맥스")).isTrue(); + } + + @Test + void 상품_없으면_예외() { + // given + given(productRepository.findById(999L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> activeProductService.get(999L)) + .isInstanceOf(CoreException.class) + .hasMessage(ProductExceptionMessage.Product.NOT_FOUND.message()); + } + + @Test + void 삭제된_상품이면_예외() { + // given + Product product = Product.register("에어맥스", "설명", Money.of(100000), Stock.of(50), 10L); + product.delete(); + given(productRepository.findById(1L)).willReturn(Optional.of(product)); + + // when & then + assertThatThrownBy(() -> activeProductService.get(1L)) + .isInstanceOf(CoreException.class) + .hasMessage(ProductExceptionMessage.Product.NOT_FOUND.message()); + } + + @Test + void 브랜드_삭제된_상품이면_예외() { + // given + Product product = Product.register("에어맥스", "설명", Money.of(100000), Stock.of(50), 10L); + Brand brand = Brand.register("나이키"); + brand.delete(); + given(productRepository.findById(1L)).willReturn(Optional.of(product)); + given(brandRepository.findById(10L)).willReturn(Optional.of(brand)); + + // when & then + assertThatThrownBy(() -> activeProductService.get(1L)) + .isInstanceOf(CoreException.class) + .hasMessage(ProductExceptionMessage.Product.UNAVAILABLE.message()); + } +} diff --git a/domain/src/test/java/com/loopers/domain/catalog/product/ProductTest.java b/domain/src/test/java/com/loopers/domain/catalog/product/ProductTest.java index 1309b5cb1..70fc4b9bd 100644 --- a/domain/src/test/java/com/loopers/domain/catalog/product/ProductTest.java +++ b/domain/src/test/java/com/loopers/domain/catalog/product/ProductTest.java @@ -101,6 +101,43 @@ class ProductTest { .hasMessage(ProductExceptionMessage.Product.ALREADY_DELETED.message()); } + @Test + void 좋아요수_증가_성공() { + // given + Product product = Product.register("티셔츠", "설명", Money.of(10000L), Stock.of(100L), 1L); + + // when + product.increaseLikesCount(); + + // then + assertThat(product.hasLikesCount(1L)).isTrue(); + } + + @Test + void 좋아요수_감소_성공() { + // given + Product product = Product.register("티셔츠", "설명", Money.of(10000L), Stock.of(100L), 1L); + product.increaseLikesCount(); + + // when + product.decreaseLikesCount(); + + // then + assertThat(product.hasLikesCount(0L)).isTrue(); + } + + @Test + void 좋아요수_0_미만_불가() { + // given + Product product = Product.register("티셔츠", "설명", Money.of(10000L), Stock.of(100L), 1L); + + // when + product.decreaseLikesCount(); + + // then + assertThat(product.hasLikesCount(0L)).isTrue(); + } + @Test void 삭제된_상품_재고_차감_시_예외() { // given diff --git a/domain/src/test/java/com/loopers/domain/like/LikeTest.java b/domain/src/test/java/com/loopers/domain/like/LikeTest.java new file mode 100644 index 000000000..0e0c7b700 --- /dev/null +++ b/domain/src/test/java/com/loopers/domain/like/LikeTest.java @@ -0,0 +1,44 @@ +package com.loopers.domain.like; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class LikeTest { + + @Test + void 본인_확인_성공() { + // given + Like like = Like.mark(1L, LikeSubjectType.PRODUCT, 100L); + + // when & then + assertThat(like.isOwnedBy(1L)).isTrue(); + } + + @Test + void 본인_아니면_false() { + // given + Like like = Like.mark(1L, LikeSubjectType.PRODUCT, 100L); + + // when & then + assertThat(like.isOwnedBy(99L)).isFalse(); + } + + @Test + void 대상_확인_성공() { + // given + Like like = Like.mark(1L, LikeSubjectType.PRODUCT, 100L); + + // when & then + assertThat(like.isForSubject(LikeSubjectType.PRODUCT, 100L)).isTrue(); + } + + @Test + void 대상_아니면_false() { + // given + Like like = Like.mark(1L, LikeSubjectType.PRODUCT, 100L); + + // when & then + assertThat(like.isForSubject(LikeSubjectType.PRODUCT, 999L)).isFalse(); + } +} diff --git a/domain/src/testFixtures/java/com/loopers/domain/like/LikeFixture.java b/domain/src/testFixtures/java/com/loopers/domain/like/LikeFixture.java new file mode 100644 index 000000000..06b4ab745 --- /dev/null +++ b/domain/src/testFixtures/java/com/loopers/domain/like/LikeFixture.java @@ -0,0 +1,15 @@ +package com.loopers.domain.like; + +public class LikeFixture { + + public static final Long DEFAULT_MEMBER_ID = 1L; + public static final Long DEFAULT_SUBJECT_ID = 100L; + + public static Like create() { + return Like.mark(DEFAULT_MEMBER_ID, LikeSubjectType.PRODUCT, DEFAULT_SUBJECT_ID); + } + + public static Like create(Long memberId, Long subjectId) { + return Like.mark(memberId, LikeSubjectType.PRODUCT, subjectId); + } +} diff --git a/infrastructure/jpa/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java new file mode 100644 index 000000000..d51a4f56f --- /dev/null +++ b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java @@ -0,0 +1,17 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeSubjectType; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface LikeJpaRepository extends JpaRepository { + + boolean existsByMemberIdAndSubjectTypeAndSubjectId(Long memberId, LikeSubjectType subjectType, Long subjectId); + + Optional findByMemberIdAndSubjectTypeAndSubjectId(Long memberId, LikeSubjectType subjectType, Long subjectId); + + List findByMemberIdAndSubjectType(Long memberId, LikeSubjectType subjectType); +} diff --git a/infrastructure/jpa/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java new file mode 100644 index 000000000..535564c7a --- /dev/null +++ b/infrastructure/jpa/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java @@ -0,0 +1,42 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeRepository; +import com.loopers.domain.like.LikeSubjectType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class LikeRepositoryImpl implements LikeRepository { + + private final LikeJpaRepository likeJpaRepository; + + @Override + public Like save(Like like) { + return likeJpaRepository.save(like); + } + + @Override + public void delete(Like like) { + likeJpaRepository.delete(like); + } + + @Override + public boolean existsByMemberIdAndSubjectTypeAndSubjectId(Long memberId, LikeSubjectType subjectType, Long subjectId) { + return likeJpaRepository.existsByMemberIdAndSubjectTypeAndSubjectId(memberId, subjectType, subjectId); + } + + @Override + public Optional findByMemberIdAndSubjectTypeAndSubjectId(Long memberId, LikeSubjectType subjectType, Long subjectId) { + return likeJpaRepository.findByMemberIdAndSubjectTypeAndSubjectId(memberId, subjectType, subjectId); + } + + @Override + public List findByMemberIdAndSubjectType(Long memberId, LikeSubjectType subjectType) { + return likeJpaRepository.findByMemberIdAndSubjectType(memberId, subjectType); + } +} diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeController.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeController.java new file mode 100644 index 000000000..6313e2144 --- /dev/null +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeController.java @@ -0,0 +1,58 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.service.LikeService; +import com.loopers.application.service.MemberService; +import com.loopers.application.service.dto.LikeRegisterCommand; +import com.loopers.application.service.dto.MemberInfo; +import com.loopers.interfaces.api.product.dto.ProductApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 좋아요 API + */ +@RestController +@RequiredArgsConstructor +public class LikeController { + + private final LikeService likeService; + private final MemberService memberService; + + /** 좋아요 등록 */ + @PostMapping("/api/products/{productId}/likes") + @ResponseStatus(HttpStatus.CREATED) + public void like( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password, + @PathVariable Long productId + ) { + MemberInfo member = memberService.getMyInfo(loginId, password); + likeService.like(new LikeRegisterCommand(member.memberId(), productId)); + } + + /** 좋아요 취소 */ + @DeleteMapping("/api/products/{productId}/likes") + public void unlike( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password, + @PathVariable Long productId + ) { + MemberInfo member = memberService.getMyInfo(loginId, password); + likeService.unlike(member.memberId(), productId); + } + + /** 내 좋아요 목록 조회 */ + @GetMapping("/api/likes") + public List getMyLikes( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String password + ) { + MemberInfo member = memberService.getMyInfo(loginId, password); + return likeService.getMyLikes(member.memberId()).stream() + .map(ProductApiResponse::from) + .toList(); + } +} diff --git a/presentation/commerce-api/src/test/java/com/loopers/controller/LikeE2ETest.java b/presentation/commerce-api/src/test/java/com/loopers/controller/LikeE2ETest.java new file mode 100644 index 000000000..752d6a898 --- /dev/null +++ b/presentation/commerce-api/src/test/java/com/loopers/controller/LikeE2ETest.java @@ -0,0 +1,220 @@ +package com.loopers.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.service.dto.MemberRegisterCommand; +import com.loopers.interfaces.api.brand.dto.BrandCreateApiRequest; +import com.loopers.interfaces.api.product.dto.ProductCreateApiRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class LikeE2ETest { + + private static final String LOGIN_ID = "liketest123"; + private static final String PASSWORD = "Like!1234"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + private Long brandId; + private Long productId; + + @BeforeEach + void setUp() throws Exception { + 회원을_등록한다(); + brandId = 브랜드를_생성하고_ID를_반환한다("나이키"); + productId = 상품을_생성하고_ID를_반환한다("에어맥스", 100000, 50, brandId); + } + + @Test + void 좋아요_등록_201() throws Exception { + // when & then + mockMvc.perform(post("/api/products/{productId}/likes", productId) + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD)) + .andExpect(status().isCreated()); + } + + @Test + void 좋아요_등록_시_likesCount_증가() throws Exception { + // given + 좋아요를_등록한다(productId); + + // when & then + mockMvc.perform(get("/api/products/{productId}", productId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.likesCount").value(1)); + } + + @Test + void 좋아요_중복_등록_시_400() throws Exception { + // given + 좋아요를_등록한다(productId); + + // when & then + mockMvc.perform(post("/api/products/{productId}/likes", productId) + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD)) + .andExpect(status().isBadRequest()); + } + + @Test + void 삭제된_상품에_좋아요_시_404() throws Exception { + // given + 상품을_삭제한다(productId); + + // when & then + mockMvc.perform(post("/api/products/{productId}/likes", productId) + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD)) + .andExpect(status().isNotFound()); + } + + @Test + void 브랜드_삭제된_상품에_좋아요_시_400() throws Exception { + // given + 브랜드를_삭제한다(brandId); + + // when & then + mockMvc.perform(post("/api/products/{productId}/likes", productId) + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD)) + .andExpect(status().isBadRequest()); + } + + @Test + void 좋아요_취소_200() throws Exception { + // given + 좋아요를_등록한다(productId); + + // when & then + mockMvc.perform(delete("/api/products/{productId}/likes", productId) + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD)) + .andExpect(status().isOk()); + } + + @Test + void 좋아요_취소_시_likesCount_감소() throws Exception { + // given + 좋아요를_등록한다(productId); + 좋아요를_취소한다(productId); + + // when & then + mockMvc.perform(get("/api/products/{productId}", productId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.likesCount").value(0)); + } + + @Test + void 좋아요하지_않은_상품_취소_시_400() throws Exception { + // when & then + mockMvc.perform(delete("/api/products/{productId}/likes", productId) + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD)) + .andExpect(status().isBadRequest()); + } + + @Test + void 내_좋아요_목록_조회_200() throws Exception { + // given + Long productId2 = 상품을_생성하고_ID를_반환한다("조던", 200000, 30, brandId); + 좋아요를_등록한다(productId); + 좋아요를_등록한다(productId2); + + // when & then + mockMvc.perform(get("/api/likes") + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(2)); + } + + @Test + void 삭제된_상품은_좋아요_목록에서_제외() throws Exception { + // given + Long productId2 = 상품을_생성하고_ID를_반환한다("조던", 200000, 30, brandId); + 좋아요를_등록한다(productId); + 좋아요를_등록한다(productId2); + 상품을_삭제한다(productId2); + + // when & then + mockMvc.perform(get("/api/likes") + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)); + } + + private void 회원을_등록한다() throws Exception { + mockMvc.perform(post("/api/members/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new MemberRegisterCommand(LOGIN_ID, PASSWORD, "테스터", LocalDate.of(2000, 1, 1), "like@test.com")))); + } + + private Long 브랜드를_생성하고_ID를_반환한다(String name) throws Exception { + mockMvc.perform(post("/api/admin/brands") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new BrandCreateApiRequest(name)))); + + String response = mockMvc.perform(get("/api/admin/brands")) + .andReturn().getResponse().getContentAsString(); + return objectMapper.readTree(response).get(0).get("id").asLong(); + } + + private Long 상품을_생성하고_ID를_반환한다(String name, long price, long stock, Long brandId) throws Exception { + mockMvc.perform(post("/api/admin/products") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new ProductCreateApiRequest(name, "설명", price, stock, brandId)))); + + String response = mockMvc.perform(get("/api/admin/products")) + .andReturn().getResponse().getContentAsString(); + + var products = objectMapper.readTree(response); + for (var product : products) { + if (product.get("name").asText().equals(name)) { + return product.get("id").asLong(); + } + } + return products.get(0).get("id").asLong(); + } + + private void 좋아요를_등록한다(Long productId) throws Exception { + mockMvc.perform(post("/api/products/{productId}/likes", productId) + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD)); + } + + private void 좋아요를_취소한다(Long productId) throws Exception { + mockMvc.perform(delete("/api/products/{productId}/likes", productId) + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD)); + } + + private void 상품을_삭제한다(Long productId) throws Exception { + mockMvc.perform(delete("/api/admin/products/{id}", productId)); + } + + private void 브랜드를_삭제한다(Long brandId) throws Exception { + mockMvc.perform(delete("/api/admin/brands/{id}", brandId)); + } +} From be6bd787186badab264c7776bc777b57386c0277 Mon Sep 17 00:00:00 2001 From: APapeIsName Date: Thu, 26 Feb 2026 22:40:39 +0900 Subject: [PATCH 7/8] =?UTF-8?q?refactor:=20ProductInfo=20=EC=A0=95?= =?UTF-8?q?=EC=A0=81=20=ED=8C=A9=ED=86=A0=EB=A6=AC=20=EC=B6=94=EC=B6=9C=20?= =?UTF-8?q?=EB=B0=8F=20BaseEntity=20=EA=B3=84=EC=B8=B5=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/service/ProductService.java | 16 +------- .../application/service/dto/ProductInfo.java | 15 ++++++++ .../loopers/domain/example/ExampleModel.java | 4 +- .../loopers/domain/example/ExampleModel.java | 4 +- .../java/com/loopers/domain/BaseEntity.java | 38 ++++--------------- .../com/loopers/domain/BaseTimeEntity.java | 13 +------ .../loopers/domain/SoftDeletableEntity.java | 36 ++++++++++++++++++ .../loopers/domain/catalog/brand/Brand.java | 4 +- .../domain/catalog/product/Product.java | 4 +- .../api/brand/AdminBrandController.java | 8 ++++ .../interfaces/api/brand/BrandController.java | 4 ++ .../api/member/MemberController.java | 6 +++ .../api/order/AdminOrderController.java | 5 +++ .../interfaces/api/order/OrderController.java | 6 +++ .../api/product/AdminProductController.java | 8 ++++ .../api/product/ProductController.java | 5 +++ 16 files changed, 111 insertions(+), 65 deletions(-) create mode 100644 domain/src/main/java/com/loopers/domain/SoftDeletableEntity.java diff --git a/application/commerce-service/src/main/java/com/loopers/application/service/ProductService.java b/application/commerce-service/src/main/java/com/loopers/application/service/ProductService.java index 17cddaf8f..8b2daf658 100644 --- a/application/commerce-service/src/main/java/com/loopers/application/service/ProductService.java +++ b/application/commerce-service/src/main/java/com/loopers/application/service/ProductService.java @@ -61,7 +61,7 @@ public ProductInfo getById(Long id) { .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, BrandExceptionMessage.Brand.NOT_FOUND.message())); - return toProductInfo(product, brand); + return ProductInfo.from(product, brand); } @Transactional(readOnly = true) @@ -109,19 +109,7 @@ private List toProductInfos(List products) { .collect(Collectors.toMap(Brand::getId, Function.identity())); return products.stream() - .map(product -> toProductInfo(product, brandMap.get(product.getBrandId()))) + .map(product -> ProductInfo.from(product, brandMap.get(product.getBrandId()))) .toList(); } - - private ProductInfo toProductInfo(Product product, Brand brand) { - return new ProductInfo( - product.getId(), - product.getName().getValue(), - product.getDescription(), - product.getPrice().getValue(), - product.getStock().getValue(), - product.getLikesCount(), - brand != null ? brand.getName().getValue() : null - ); - } } diff --git a/application/commerce-service/src/main/java/com/loopers/application/service/dto/ProductInfo.java b/application/commerce-service/src/main/java/com/loopers/application/service/dto/ProductInfo.java index 20d2d3adb..6465c3f3a 100644 --- a/application/commerce-service/src/main/java/com/loopers/application/service/dto/ProductInfo.java +++ b/application/commerce-service/src/main/java/com/loopers/application/service/dto/ProductInfo.java @@ -1,5 +1,8 @@ package com.loopers.application.service.dto; +import com.loopers.domain.catalog.brand.Brand; +import com.loopers.domain.catalog.product.Product; + public record ProductInfo( Long id, String name, @@ -9,4 +12,16 @@ public record ProductInfo( long likesCount, String brandName ) { + + public static ProductInfo from(Product product, Brand brand) { + return new ProductInfo( + product.getId(), + product.getName().getValue(), + product.getDescription(), + product.getPrice().getValue(), + product.getStock().getValue(), + product.getLikesCount(), + brand != null ? brand.getName().getValue() : null + ); + } } diff --git a/application/commerce-service/src/main/java/com/loopers/domain/example/ExampleModel.java b/application/commerce-service/src/main/java/com/loopers/domain/example/ExampleModel.java index c588c4a8a..f4442b476 100644 --- a/application/commerce-service/src/main/java/com/loopers/domain/example/ExampleModel.java +++ b/application/commerce-service/src/main/java/com/loopers/domain/example/ExampleModel.java @@ -1,6 +1,6 @@ package com.loopers.domain.example; -import com.loopers.domain.BaseEntity; +import com.loopers.domain.SoftDeletableEntity; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import jakarta.persistence.Entity; @@ -8,7 +8,7 @@ @Entity @Table(name = "example") -public class ExampleModel extends BaseEntity { +public class ExampleModel extends SoftDeletableEntity { private String name; private String description; 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 index c588c4a8a..f4442b476 100644 --- 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 @@ -1,6 +1,6 @@ package com.loopers.domain.example; -import com.loopers.domain.BaseEntity; +import com.loopers.domain.SoftDeletableEntity; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import jakarta.persistence.Entity; @@ -8,7 +8,7 @@ @Entity @Table(name = "example") -public class ExampleModel extends BaseEntity { +public class ExampleModel extends SoftDeletableEntity { private String name; private String description; diff --git a/domain/src/main/java/com/loopers/domain/BaseEntity.java b/domain/src/main/java/com/loopers/domain/BaseEntity.java index 299c832d7..506fa14af 100644 --- a/domain/src/main/java/com/loopers/domain/BaseEntity.java +++ b/domain/src/main/java/com/loopers/domain/BaseEntity.java @@ -1,40 +1,16 @@ package com.loopers.domain; -import jakarta.persistence.Column; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; import jakarta.persistence.MappedSuperclass; import lombok.Getter; -import java.time.ZonedDateTime; -/** - * soft-delete가 필요한 엔티티용. - * BaseTimeEntity의 생성/수정 시간 관리를 상속받고, 삭제 시간을 추가한다. - */ @MappedSuperclass @Getter -public abstract class BaseEntity extends BaseTimeEntity { +public abstract class BaseEntity { - @Column(name = "deleted_at") - private ZonedDateTime deletedAt; - - /** - * delete 연산은 멱등하게 동작할 수 있도록 한다. (삭제된 엔티티를 다시 삭제해도 동일한 결과가 나오도록) - */ - public boolean isDeleted() { - return this.deletedAt != null; - } - - public void delete() { - if (!isDeleted()) { - this.deletedAt = ZonedDateTime.now(); - } - } - - /** - * restore 연산은 멱등하게 동작할 수 있도록 한다. (삭제되지 않은 엔티티를 복원해도 동일한 결과가 나오도록) - */ - public void restore() { - if (isDeleted()) { - this.deletedAt = null; - } - } + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; } diff --git a/domain/src/main/java/com/loopers/domain/BaseTimeEntity.java b/domain/src/main/java/com/loopers/domain/BaseTimeEntity.java index b498665cf..9c3c81114 100644 --- a/domain/src/main/java/com/loopers/domain/BaseTimeEntity.java +++ b/domain/src/main/java/com/loopers/domain/BaseTimeEntity.java @@ -1,26 +1,15 @@ package com.loopers.domain; import jakarta.persistence.Column; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; import jakarta.persistence.MappedSuperclass; import jakarta.persistence.PrePersist; import jakarta.persistence.PreUpdate; import lombok.Getter; import java.time.ZonedDateTime; -/** - * 생성/수정 시간을 자동으로 관리한다. - * soft-delete가 불필요한 엔티티용. - */ @MappedSuperclass @Getter -public abstract class BaseTimeEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; +public abstract class BaseTimeEntity extends BaseEntity { @Column(name = "created_at", nullable = false, updatable = false) private ZonedDateTime createdAt; diff --git a/domain/src/main/java/com/loopers/domain/SoftDeletableEntity.java b/domain/src/main/java/com/loopers/domain/SoftDeletableEntity.java new file mode 100644 index 000000000..8c2ac5bc5 --- /dev/null +++ b/domain/src/main/java/com/loopers/domain/SoftDeletableEntity.java @@ -0,0 +1,36 @@ +package com.loopers.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import java.time.ZonedDateTime; + +@MappedSuperclass +@Getter +public abstract class SoftDeletableEntity extends BaseTimeEntity { + + @Column(name = "deleted_at") + private ZonedDateTime deletedAt; + + /** + * delete 연산은 멱등하게 동작할 수 있도록 한다. (삭제된 엔티티를 다시 삭제해도 동일한 결과가 나오도록) + */ + public boolean isDeleted() { + return this.deletedAt != null; + } + + public void delete() { + if (!isDeleted()) { + this.deletedAt = ZonedDateTime.now(); + } + } + + /** + * restore 연산은 멱등하게 동작할 수 있도록 한다. (삭제되지 않은 엔티티를 복원해도 동일한 결과가 나오도록) + */ + public void restore() { + if (isDeleted()) { + this.deletedAt = null; + } + } +} diff --git a/domain/src/main/java/com/loopers/domain/catalog/brand/Brand.java b/domain/src/main/java/com/loopers/domain/catalog/brand/Brand.java index 4079bac05..07aaac223 100644 --- a/domain/src/main/java/com/loopers/domain/catalog/brand/Brand.java +++ b/domain/src/main/java/com/loopers/domain/catalog/brand/Brand.java @@ -1,6 +1,6 @@ package com.loopers.domain.catalog.brand; -import com.loopers.domain.BaseEntity; +import com.loopers.domain.SoftDeletableEntity; import com.loopers.domain.catalog.vo.Name; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; @@ -13,7 +13,7 @@ @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @Table(name = "brand") -public class Brand extends BaseEntity { +public class Brand extends SoftDeletableEntity { @Embedded @AttributeOverride(name = "value", column = @Column(name = "name", nullable = false, length = 100, unique = true)) diff --git a/domain/src/main/java/com/loopers/domain/catalog/product/Product.java b/domain/src/main/java/com/loopers/domain/catalog/product/Product.java index d1aacfd25..f5c6d8cfc 100644 --- a/domain/src/main/java/com/loopers/domain/catalog/product/Product.java +++ b/domain/src/main/java/com/loopers/domain/catalog/product/Product.java @@ -1,6 +1,6 @@ package com.loopers.domain.catalog.product; -import com.loopers.domain.BaseEntity; +import com.loopers.domain.SoftDeletableEntity; import com.loopers.domain.catalog.product.vo.Money; import com.loopers.domain.catalog.product.vo.Quantity; import com.loopers.domain.catalog.product.vo.Stock; @@ -16,7 +16,7 @@ @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @Table(name = "product") -public class Product extends BaseEntity { +public class Product extends SoftDeletableEntity { @Embedded @AttributeOverride(name = "value", column = @Column(name = "name", nullable = false, length = 100)) diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/AdminBrandController.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/AdminBrandController.java index 66c77512d..f55526ea1 100644 --- a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/AdminBrandController.java +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/AdminBrandController.java @@ -10,6 +10,9 @@ import java.util.List; +/** + * 브랜드 관리 API (관리자) + */ @RestController @RequestMapping("/api/admin/brands") @RequiredArgsConstructor @@ -17,17 +20,20 @@ public class AdminBrandController { private final BrandService brandService; + /** 브랜드 생성 */ @PostMapping @ResponseStatus(HttpStatus.CREATED) public void create(@RequestBody BrandCreateApiRequest request) { brandService.create(request.toCommand()); } + /** 브랜드 단건 조회 */ @GetMapping("/{id}") public BrandApiResponse getById(@PathVariable Long id) { return BrandApiResponse.from(brandService.getById(id)); } + /** 브랜드 전체 조회 */ @GetMapping public List getAll() { return brandService.getAll().stream() @@ -35,11 +41,13 @@ public List getAll() { .toList(); } + /** 브랜드 수정 */ @PutMapping("/{id}") public void update(@PathVariable Long id, @RequestBody BrandUpdateApiRequest request) { brandService.update(id, request.toCommand()); } + /** 브랜드 삭제 */ @DeleteMapping("/{id}") @ResponseStatus(HttpStatus.NO_CONTENT) public void delete(@PathVariable Long id) { diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java index d3aedeed0..541d5addf 100644 --- a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java @@ -9,6 +9,9 @@ import java.util.List; +/** + * 브랜드 API (사용자) + */ @RestController @RequestMapping("/api/brands") @RequiredArgsConstructor @@ -16,6 +19,7 @@ public class BrandController { private final BrandService brandService; + /** 활성 브랜드 목록 조회 */ @GetMapping public List getActiveBrands() { return brandService.getActiveBrands().stream() diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberController.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberController.java index 14e5e9f61..b4fe0b60b 100644 --- a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberController.java +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberController.java @@ -8,6 +8,9 @@ import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.*; +/** + * 회원 API + */ @RestController @RequestMapping("/api/members") @RequiredArgsConstructor @@ -15,12 +18,14 @@ public class MemberController { private final MemberService memberService; + /** 회원 가입 */ @PostMapping("/register") @ResponseStatus(HttpStatus.CREATED) public void register(@RequestBody MemberRegisterCommand request) { memberService.register(request); } + /** 내 정보 조회 */ @GetMapping("/me") public MemberApiResponse getMyInfo( @RequestHeader("X-Loopers-LoginId") String loginId, @@ -29,6 +34,7 @@ public MemberApiResponse getMyInfo( return MemberApiResponse.from(memberService.getMyInfo(loginId, password)); } + /** 비밀번호 변경 */ @PatchMapping("/password") @ResponseStatus(HttpStatus.NO_CONTENT) public void updatePassword( diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/order/AdminOrderController.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/order/AdminOrderController.java index c1641a6e7..b8f3ffc64 100644 --- a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/order/AdminOrderController.java +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/order/AdminOrderController.java @@ -7,6 +7,9 @@ import java.util.List; +/** + * 주문 관리 API (관리자) + */ @RestController @RequestMapping("/api/admin/orders") @RequiredArgsConstructor @@ -14,6 +17,7 @@ public class AdminOrderController { private final OrderService orderService; + /** 주문 전체 조회 */ @GetMapping public List getAll() { return orderService.getAll().stream() @@ -21,6 +25,7 @@ public List getAll() { .toList(); } + /** 주문 단건 조회 */ @GetMapping("/{id}") public OrderApiResponse getById(@PathVariable Long id) { return OrderApiResponse.from(orderService.getByIdForAdmin(id)); diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java index ad2866d3f..2e471ca51 100644 --- a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java @@ -11,6 +11,9 @@ import java.util.List; +/** + * 주문 API + */ @RestController @RequestMapping("/api/orders") @RequiredArgsConstructor @@ -19,6 +22,7 @@ public class OrderController { private final OrderService orderService; private final MemberService memberService; + /** 주문 생성 */ @PostMapping @ResponseStatus(HttpStatus.CREATED) public OrderApiResponse create( @@ -30,6 +34,7 @@ public OrderApiResponse create( return OrderApiResponse.from(orderService.create(request.toCommand(member.memberId()))); } + /** 내 주문 목록 조회 */ @GetMapping public List getMyOrders( @RequestHeader("X-Loopers-LoginId") String loginId, @@ -41,6 +46,7 @@ public List getMyOrders( .toList(); } + /** 주문 단건 조회 */ @GetMapping("/{id}") public OrderApiResponse getById( @RequestHeader("X-Loopers-LoginId") String loginId, diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductController.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductController.java index e6c6859b2..da8c3fb15 100644 --- a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductController.java +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductController.java @@ -10,6 +10,9 @@ import java.util.List; +/** + * 상품 관리 API (관리자) + */ @RestController @RequestMapping("/api/admin/products") @RequiredArgsConstructor @@ -17,17 +20,20 @@ public class AdminProductController { private final ProductService productService; + /** 상품 생성 */ @PostMapping @ResponseStatus(HttpStatus.CREATED) public void create(@RequestBody ProductCreateApiRequest request) { productService.create(request.toCommand()); } + /** 상품 단건 조회 */ @GetMapping("/{id}") public ProductApiResponse getById(@PathVariable Long id) { return ProductApiResponse.from(productService.getById(id)); } + /** 상품 전체 조회 */ @GetMapping public List getAll() { return productService.getAll().stream() @@ -35,11 +41,13 @@ public List getAll() { .toList(); } + /** 상품 수정 */ @PutMapping("/{id}") public void update(@PathVariable Long id, @RequestBody ProductUpdateApiRequest request) { productService.update(id, request.toCommand()); } + /** 상품 삭제 */ @DeleteMapping("/{id}") @ResponseStatus(HttpStatus.NO_CONTENT) public void delete(@PathVariable Long id) { diff --git a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java index 3cd53aebd..b9728439a 100644 --- a/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java +++ b/presentation/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java @@ -8,6 +8,9 @@ import java.util.List; +/** + * 상품 API (사용자) + */ @RestController @RequestMapping("/api/products") @RequiredArgsConstructor @@ -15,6 +18,7 @@ public class ProductController { private final ProductService productService; + /** 활성 상품 목록 조회 */ @GetMapping public List getActiveProducts( @RequestParam(defaultValue = "LATEST") ProductSortType sort @@ -24,6 +28,7 @@ public List getActiveProducts( .toList(); } + /** 상품 단건 조회 */ @GetMapping("/{id}") public ProductApiResponse getById(@PathVariable Long id) { return ProductApiResponse.from(productService.getById(id)); From 646a8dd22294fda5da3fd6c10681ec45a486860f Mon Sep 17 00:00:00 2001 From: APapeIsName Date: Thu, 26 Feb 2026 23:09:46 +0900 Subject: [PATCH 8/8] =?UTF-8?q?refactor:=20OrderService.create=20=EB=A9=94?= =?UTF-8?q?=EC=86=8C=EB=93=9C=20=EC=95=95=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/service/OrderService.java | 99 +++++++++++-------- .../com/loopers/domain/order/OrderStatus.java | 6 +- 2 files changed, 64 insertions(+), 41 deletions(-) diff --git a/application/commerce-service/src/main/java/com/loopers/application/service/OrderService.java b/application/commerce-service/src/main/java/com/loopers/application/service/OrderService.java index 3a5d079c5..21bdf0c7e 100644 --- a/application/commerce-service/src/main/java/com/loopers/application/service/OrderService.java +++ b/application/commerce-service/src/main/java/com/loopers/application/service/OrderService.java @@ -35,51 +35,20 @@ public class OrderService { @Transactional public OrderInfo create(OrderCreateCommand command) { List requests = command.orderLines(); - List productIds = requests.stream() - .map(OrderLineRequest::productId) - .distinct() - .sorted() - .toList(); + List productIds = extractProductIds(requests); Map productMap = findActiveProducts(productIds); + Map brandMap = findBrandMap(productMap); - List brandIds = productMap.values().stream() - .map(Product::getBrandId).distinct().toList(); - Map brandMap = brandRepository.findAllByIdIn(brandIds).stream() - .collect(Collectors.toMap(Brand::getId, Function.identity())); - - boolean allEnough = requests.stream() - .allMatch(req -> productMap.get(req.productId()) - .hasEnoughStock(Quantity.of(req.quantity()))); - OrderStatus status = allEnough ? OrderStatus.ACCEPTED : OrderStatus.REJECTED; - - if (allEnough) { - requests.forEach(req -> - productMap.get(req.productId()).decreaseStock(Quantity.of(req.quantity()))); + OrderStatus status = determineStatus(requests, productMap); + if (status == OrderStatus.ACCEPTED) { + decreaseStock(requests, productMap); } - List orderLines = requests.stream() - .map(req -> { - Product product = productMap.get(req.productId()); - Brand brand = brandMap.get(product.getBrandId()); - return OrderLine.of( - req.productId(), Quantity.of(req.quantity()), - product.getName().getValue(), product.getDescription(), - product.getPrice().getValue(), - brand != null ? brand.getName().getValue() : null - ); - }) - .toList(); - - Order order = Order.place(command.memberId(), orderLines, status); - Order savedOrder = orderRepository.save(order); - List savedLines = orderLineRepository.saveAll( - savedOrder.assignOrderLines(orderLines)); - - List snapshots = savedLines.stream() - .map(line -> line.assignSnapshot().getSnapshot()) - .toList(); - orderLineSnapshotRepository.saveAll(snapshots); + List orderLines = createOrderLines(requests, productMap, brandMap); + Order savedOrder = orderRepository.save(Order.place(command.memberId(), orderLines, status)); + List savedLines = orderLineRepository.saveAll(savedOrder.assignOrderLines(orderLines)); + List snapshots = saveSnapshots(savedLines); return toOrderInfo(savedOrder, savedLines, snapshots); } @@ -121,6 +90,56 @@ public OrderInfo getByIdForAdmin(Long orderId) { return toOrderInfo(order); } + private List extractProductIds(List requests) { + return requests.stream() + .map(OrderLineRequest::productId) + .distinct() + .sorted() + .toList(); + } + + private Map findBrandMap(Map productMap) { + List brandIds = productMap.values().stream() + .map(Product::getBrandId).distinct().toList(); + return brandRepository.findAllByIdIn(brandIds).stream() + .collect(Collectors.toMap(Brand::getId, Function.identity())); + } + + private OrderStatus determineStatus(List requests, Map productMap) { + boolean allEnough = requests.stream() + .allMatch(req -> productMap.get(req.productId()) + .hasEnoughStock(Quantity.of(req.quantity()))); + return OrderStatus.determine(allEnough); + } + + private void decreaseStock(List requests, Map productMap) { + requests.forEach(req -> + productMap.get(req.productId()).decreaseStock(Quantity.of(req.quantity()))); + } + + private List createOrderLines(List requests, Map productMap, Map brandMap) { + return requests.stream() + .map(req -> { + Product product = productMap.get(req.productId()); + Brand brand = brandMap.get(product.getBrandId()); + return OrderLine.of( + req.productId(), Quantity.of(req.quantity()), + product.getName().getValue(), product.getDescription(), + product.getPrice().getValue(), + brand != null ? brand.getName().getValue() : null + ); + }) + .toList(); + } + + private List saveSnapshots(List savedLines) { + List snapshots = savedLines.stream() + .map(line -> line.assignSnapshot().getSnapshot()) + .toList(); + orderLineSnapshotRepository.saveAll(snapshots); + return snapshots; + } + private Map findActiveProducts(List productIds) { List products = productRepository.findAllByIdIn(productIds); diff --git a/domain/src/main/java/com/loopers/domain/order/OrderStatus.java b/domain/src/main/java/com/loopers/domain/order/OrderStatus.java index a7888268d..35376d082 100644 --- a/domain/src/main/java/com/loopers/domain/order/OrderStatus.java +++ b/domain/src/main/java/com/loopers/domain/order/OrderStatus.java @@ -2,5 +2,9 @@ public enum OrderStatus { ACCEPTED, - REJECTED + REJECTED; + + public static OrderStatus determine(boolean allStockAvailable) { + return allStockAvailable ? ACCEPTED : REJECTED; + } }