From 04a8e8f702cb810b2d994784d165d9a7a9ec1f12 Mon Sep 17 00:00:00 2001 From: najang Date: Sun, 8 Feb 2026 16:30:16 +0900 Subject: [PATCH 1/9] =?UTF-8?q?refactor:=20RESTful=20=EC=BB=A8=EB=B2=A4?= =?UTF-8?q?=EC=85=98=EC=97=90=20=EB=A7=9E=EA=B2=8C=20API=20URL=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../loopers/interfaces/api/user/UserV1Controller.java | 10 +++++----- .../com/loopers/support/auth/AuthenticationConfig.java | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java index 8d86dfa7..eb3a777f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java @@ -18,12 +18,12 @@ @RequiredArgsConstructor @RestController -@RequestMapping("/user") +@RequestMapping("/api/v1/users") public class UserV1Controller implements UserV1ApiSpec { private final UserFacade userFacade; - @PostMapping("/signup") + @PostMapping @ResponseStatus(HttpStatus.CREATED) @Override public ApiResponse signup( @@ -39,7 +39,7 @@ public ApiResponse signup( return ApiResponse.success(UserV1Dto.SignupResponse.from(info)); } - @GetMapping + @GetMapping("/me") @Override public ApiResponse getMyInfo( @LoginUser UserModel user @@ -48,7 +48,7 @@ public ApiResponse getMyInfo( return ApiResponse.success(UserV1Dto.UserInfoResponse.from(info)); } - @PatchMapping("/changePassword") + @PatchMapping("/password") @ResponseStatus(HttpStatus.NO_CONTENT) @Override public void changePassword( @@ -57,4 +57,4 @@ public void changePassword( ) { userFacade.changePassword(user, request.currentPassword(), request.newPassword()); } -} \ No newline at end of file +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthenticationConfig.java b/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthenticationConfig.java index b7a5e9b2..e2881c65 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthenticationConfig.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/auth/AuthenticationConfig.java @@ -18,8 +18,8 @@ public class AuthenticationConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authInterceptor) - .addPathPatterns("/user") - .addPathPatterns("/user/changePassword"); + .addPathPatterns("/api/v1/users/me") + .addPathPatterns("/api/v1/users/password"); } @Override From 6ce92b040f8da25427a3682e01b427b38158d994 Mon Sep 17 00:00:00 2001 From: najang Date: Sun, 8 Feb 2026 16:30:27 +0900 Subject: [PATCH 2/9] =?UTF-8?q?refactor:=20UserInfo=20null=20=EC=B2=B4?= =?UTF-8?q?=ED=81=AC,=20PasswordEncoder=20=EB=AC=B8=EC=84=9C=ED=99=94=20?= =?UTF-8?q?=EB=B0=8F=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../com/loopers/application/user/UserInfo.java | 6 +++++- .../com/loopers/domain/user/PasswordEncoder.java | 15 ++++++++++++++- .../java/com/loopers/domain/user/UserModel.java | 4 ++-- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java index f3165c52..920efcf7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java @@ -12,6 +12,10 @@ public record UserInfo( String email ) { public static UserInfo from(UserModel model) { + if(model == null) { + throw new IllegalArgumentException("UserModel은 null일 수 없습니다."); + } + return new UserInfo( model.getLoginId(), model.getName(), @@ -20,4 +24,4 @@ public static UserInfo from(UserModel model) { model.getEmail() ); } -} \ No newline at end of file +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordEncoder.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordEncoder.java index 6de65c6c..8dde8385 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordEncoder.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/PasswordEncoder.java @@ -1,7 +1,20 @@ package com.loopers.domain.user; public interface PasswordEncoder { + /** + * 평문 비밀번호를 인코딩한다. + * @param rawPassword 평문 비밀번호 (null 불가) + * @return 인코딩된 비밀번호 + * @throws IllegalArgumentException rawPassword가 null인 경우 + */ String encode(String rawPassword); + /** + * 평문 비밀번호와 인코딩된 비밀번호가 일치하는지 검증한다. + * @param rawPassword 평문 비밀번호 (null 불가) + * @param encodedPassword 인코딩된 비밀번호 (null 불가) + * @return 일치 여부 + * @throws IllegalArgumentException 파라미터가 null인 경우 + */ boolean matches(String rawPassword, String encodedPassword); -} \ No newline at end of file +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java index 5b2dd4fe..b806cf34 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java @@ -111,8 +111,8 @@ private void validateEmail(String email) { if (email == null || email.isBlank()) { throw new CoreException(ErrorType.BAD_REQUEST, "이메일은 비어있을 수 없습니다."); } - if (!email.matches("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$")) { + if (!email.matches("^[^\\s@]+@[^\\s@]+\\.[^\\s@]{2,}$")) { throw new CoreException(ErrorType.BAD_REQUEST, "유효하지 않은 이메일 형식입니다."); } } -} \ No newline at end of file +} From d99247193cb5f25c56c74aabd93dc0f0edc7752b Mon Sep 17 00:00:00 2001 From: najang Date: Sun, 8 Feb 2026 16:30:35 +0900 Subject: [PATCH 3/9] =?UTF-8?q?refactor:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EB=B9=88=20=EA=B0=92=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=B1=85=EC=9E=84=EC=9D=84=20=EB=8F=84=EB=A9=94=EC=9D=B8?= =?UTF-8?q?=EC=97=90=EC=84=9C=20Web=20=EB=A0=88=EC=9D=B4=EC=96=B4=EB=A1=9C?= =?UTF-8?q?=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../domain/user/policy/PasswordPolicy.java | 5 +-- .../user/policy/PasswordPolicyTest.java | 45 ------------------- 2 files changed, 1 insertion(+), 49 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/policy/PasswordPolicy.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/policy/PasswordPolicy.java index ff175a61..fd57102c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/policy/PasswordPolicy.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/policy/PasswordPolicy.java @@ -35,9 +35,6 @@ private void validateDifferentFromCurrent(String currentRawPassword, String newR if (currentRawPassword == null || currentRawPassword.isBlank()) { throw new CoreException(ErrorType.BAD_REQUEST, "현재 비밀번호는 비어있을 수 없습니다."); } - if (newRawPassword == null || newRawPassword.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "새 비밀번호는 비어있을 수 없습니다."); - } if (currentRawPassword.equals(newRawPassword)) { throw new CoreException(ErrorType.BAD_REQUEST, "새 비밀번호는 현재 비밀번호와 달라야 합니다."); } @@ -70,4 +67,4 @@ private void validateNotContainsBirthDate(String password, LocalDate birthDate) throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호에 생년월일을 포함할 수 없습니다."); } } -} \ No newline at end of file +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/policy/PasswordPolicyTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/policy/PasswordPolicyTest.java index 7565a2fc..572c0570 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/policy/PasswordPolicyTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/policy/PasswordPolicyTest.java @@ -71,19 +71,6 @@ void throwsBadRequest_whenPasswordIsTooLong() { assertThat(exception.getMessage()).contains("8~16자"); } - @DisplayName("비밀번호가 null이면, BAD_REQUEST 예외가 발생한다.") - @Test - void throwsBadRequest_whenPasswordIsNull() { - // act - CoreException exception = assertThrows(CoreException.class, () -> - passwordPolicy.validateForSignup(null, BIRTH_DATE) - ); - - // assert - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - assertThat(exception.getMessage()).contains("8~16자"); - } - @DisplayName("비밀번호에 생년월일이 포함되면, BAD_REQUEST 예외가 발생한다.") @Test void throwsBadRequest_whenPasswordContainsBirthDate() { @@ -149,38 +136,6 @@ void throwsBadRequest_whenNewPasswordIsSameAsCurrent() { assertThat(exception.getMessage()).contains("현재 비밀번호와 달라야"); } - @DisplayName("현재 비밀번호가 null이면, BAD_REQUEST 예외가 발생한다.") - @Test - void throwsBadRequest_whenCurrentPasswordIsNull() { - // arrange - String newPassword = "NewPass456!"; - - // act - CoreException exception = assertThrows(CoreException.class, () -> - passwordPolicy.validateForChange(null, newPassword, BIRTH_DATE) - ); - - // assert - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - assertThat(exception.getMessage()).contains("현재 비밀번호는 비어있을 수 없습니다"); - } - - @DisplayName("현재 비밀번호가 빈 문자열이면, BAD_REQUEST 예외가 발생한다.") - @Test - void throwsBadRequest_whenCurrentPasswordIsBlank() { - // arrange - String newPassword = "NewPass456!"; - - // act - CoreException exception = assertThrows(CoreException.class, () -> - passwordPolicy.validateForChange(" ", newPassword, BIRTH_DATE) - ); - - // assert - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - assertThat(exception.getMessage()).contains("현재 비밀번호는 비어있을 수 없습니다"); - } - @DisplayName("새 비밀번호에 생년월일이 포함되면, BAD_REQUEST 예외가 발생한다.") @Test void throwsBadRequest_whenNewPasswordContainsBirthDate() { From b2ec8ca5ec0a058af3313d85ef53f2fb2d2bad6d Mon Sep 17 00:00:00 2001 From: najang Date: Sun, 8 Feb 2026 16:30:48 +0900 Subject: [PATCH 4/9] =?UTF-8?q?test:=20E2E=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20URL=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20=EB=B9=84=EB=B0=80?= =?UTF-8?q?=EB=B2=88=ED=98=B8=20=EA=B4=80=EB=A0=A8=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../loopers/domain/user/UserModelTest.java | 13 +- .../interfaces/api/UserV1ApiE2ETest.java | 125 +++++++++++++++--- 2 files changed, 119 insertions(+), 19 deletions(-) diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java index e55f8e67..a5633e7a 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java @@ -153,6 +153,17 @@ void masksLastCharacter_whenNameHasTwoCharacters() { @DisplayName("비밀번호 변경 시,") @Nested class ChangePassword { + @DisplayName("새로운 비밀번호가 비어있으면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNewPasswordIsBlank() { + // arrange + UserModel userModel = new UserModel(LOGIN_ID, "oldEncoded", NAME, BIRTH_DATE, EMAIL); + + // act & assert + assertThatThrownBy(() -> userModel.changePassword("")) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } @DisplayName("새로운 암호화된 비밀번호로 변경된다.") @Test @@ -168,4 +179,4 @@ void changesPassword_whenNewEncodedPasswordIsProvided() { assertThat(userModel.getPassword()).isEqualTo(newEncodedPassword); } } -} \ No newline at end of file +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java index 27587cb1..f59a1c56 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java @@ -67,7 +67,7 @@ private HttpHeaders createAuthHeaders(String loginId, String password) { return headers; } - @DisplayName("POST /user/signup") + @DisplayName("POST /api/v1/users") @Nested class Signup { @@ -81,7 +81,7 @@ void returns201_whenValidSignupRequest() { // act ResponseEntity> response = testRestTemplate.exchange( - "/user/signup", + "/api/v1/users", HttpMethod.POST, new HttpEntity<>(request), new ParameterizedTypeReference<>() { @@ -111,7 +111,7 @@ void returns409_whenLoginIdAlreadyExists() { // act ResponseEntity> response = testRestTemplate.exchange( - "/user/signup", + "/api/v1/users", HttpMethod.POST, new HttpEntity<>(request), new ParameterizedTypeReference<>() { @@ -132,7 +132,7 @@ void returns400_whenPasswordContainsBirthDate() { // act ResponseEntity> response = testRestTemplate.exchange( - "/user/signup", + "/api/v1/users", HttpMethod.POST, new HttpEntity<>(request), new ParameterizedTypeReference<>() { @@ -153,7 +153,7 @@ void returns400_whenPasswordIsTooShort() { // act ResponseEntity> response = testRestTemplate.exchange( - "/user/signup", + "/api/v1/users", HttpMethod.POST, new HttpEntity<>(request), new ParameterizedTypeReference<>() { @@ -174,7 +174,28 @@ void returns400_whenLoginIdContainsSpecialCharacters() { // act ResponseEntity> response = testRestTemplate.exchange( - "/user/signup", + "/api/v1/users", + HttpMethod.POST, + new HttpEntity<>(request), + new ParameterizedTypeReference<>() { + } + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("비밀번호가 빈 값이면, 400 Bad Request를 반환한다.") + @Test + void returns400_whenPasswordIsBlank() { + // arrange + UserV1Dto.SignupRequest request = new UserV1Dto.SignupRequest( + LOGIN_ID, "", NAME, BIRTH_DATE, EMAIL + ); + + // act + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/users", HttpMethod.POST, new HttpEntity<>(request), new ParameterizedTypeReference<>() { @@ -195,7 +216,7 @@ void returns400_whenEmailFormatIsInvalid() { // act ResponseEntity> response = testRestTemplate.exchange( - "/user/signup", + "/api/v1/users", HttpMethod.POST, new HttpEntity<>(request), new ParameterizedTypeReference<>() { @@ -207,7 +228,7 @@ void returns400_whenEmailFormatIsInvalid() { } } - @DisplayName("GET /user") + @DisplayName("GET /api/v1/users/me") @Nested class GetMyInfo { @@ -220,7 +241,7 @@ void returns200WithMaskedName_whenAuthIsValid() { // act ResponseEntity> response = testRestTemplate.exchange( - "/user", + "/api/v1/users/me", HttpMethod.GET, new HttpEntity<>(headers), new ParameterizedTypeReference<>() { @@ -243,7 +264,7 @@ void returns200WithMaskedName_whenAuthIsValid() { void returns401_whenAuthHeaderIsMissing() { // arrange & act ResponseEntity> response = testRestTemplate.exchange( - "/user", + "/api/v1/users/me", HttpMethod.GET, new HttpEntity<>(null), new ParameterizedTypeReference<>() { @@ -263,7 +284,7 @@ void returns401_whenPasswordIsWrong() { // act ResponseEntity> response = testRestTemplate.exchange( - "/user", + "/api/v1/users/me", HttpMethod.GET, new HttpEntity<>(headers), new ParameterizedTypeReference<>() { @@ -275,7 +296,7 @@ void returns401_whenPasswordIsWrong() { } } - @DisplayName("PATCH /user/changePassword") + @DisplayName("PATCH /api/v1/users/password") @Nested class ChangePassword { @@ -293,7 +314,7 @@ void returns204_whenRequestIsValid() { // act ResponseEntity response = testRestTemplate.exchange( - "/user/changePassword", + "/api/v1/users/password", HttpMethod.PATCH, new HttpEntity<>(request, headers), Void.class @@ -301,6 +322,28 @@ void returns204_whenRequestIsValid() { // assert assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + + // act + ResponseEntity after = testRestTemplate.exchange( + "/api/v1/users/me", + HttpMethod.GET, + new HttpEntity<>(createAuthHeaders(LOGIN_ID, NEW_PASSWORD)), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(after.getStatusCode()).isEqualTo(HttpStatus.OK); + + // act + ResponseEntity oldAuth = testRestTemplate.exchange( + "/api/v1/users/me", + HttpMethod.GET, + new HttpEntity<>(createAuthHeaders(LOGIN_ID, RAW_PASSWORD)), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(oldAuth.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); } @DisplayName("인증 헤더가 없으면, 401 Unauthorized를 반환한다.") @@ -313,7 +356,7 @@ void returns401_whenAuthHeaderIsMissing() { // act ResponseEntity> response = testRestTemplate.exchange( - "/user/changePassword", + "/api/v1/users/password", HttpMethod.PATCH, new HttpEntity<>(request), new ParameterizedTypeReference<>() { @@ -336,7 +379,7 @@ void returns401_whenCurrentPasswordIsWrong() { // act ResponseEntity> response = testRestTemplate.exchange( - "/user/changePassword", + "/api/v1/users/password", HttpMethod.PATCH, new HttpEntity<>(request, headers), new ParameterizedTypeReference<>() { @@ -359,7 +402,53 @@ void returns400_whenNewPasswordIsSameAsCurrent() { // act ResponseEntity> response = testRestTemplate.exchange( - "/user/changePassword", + "/api/v1/users/password", + HttpMethod.PATCH, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() { + } + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("현재 비밀번호가 빈 값이면, 400 Bad Request를 반환한다.") + @Test + void returns400_whenCurrentPasswordIsBlank() { + // arrange + createUser(LOGIN_ID, RAW_PASSWORD, NAME, BIRTH_DATE, EMAIL); + HttpHeaders headers = createAuthHeaders(LOGIN_ID, RAW_PASSWORD); + UserV1Dto.ChangePasswordRequest request = new UserV1Dto.ChangePasswordRequest( + "", NEW_PASSWORD + ); + + // act + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/users/password", + HttpMethod.PATCH, + new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() { + } + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("새 비밀번호가 빈 값이면, 400 Bad Request를 반환한다.") + @Test + void returns400_whenNewPasswordIsBlank() { + // arrange + createUser(LOGIN_ID, RAW_PASSWORD, NAME, BIRTH_DATE, EMAIL); + HttpHeaders headers = createAuthHeaders(LOGIN_ID, RAW_PASSWORD); + UserV1Dto.ChangePasswordRequest request = new UserV1Dto.ChangePasswordRequest( + RAW_PASSWORD, "" + ); + + // act + ResponseEntity> response = testRestTemplate.exchange( + "/api/v1/users/password", HttpMethod.PATCH, new HttpEntity<>(request, headers), new ParameterizedTypeReference<>() { @@ -382,7 +471,7 @@ void returns400_whenNewPasswordContainsBirthDate() { // act ResponseEntity> response = testRestTemplate.exchange( - "/user/changePassword", + "/api/v1/users/password", HttpMethod.PATCH, new HttpEntity<>(request, headers), new ParameterizedTypeReference<>() { @@ -393,4 +482,4 @@ void returns400_whenNewPasswordContainsBirthDate() { assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); } } -} \ No newline at end of file +} From 71391c22c204420f779d526c882ac37749e0172e Mon Sep 17 00:00:00 2001 From: najang Date: Thu, 12 Feb 2026 21:59:35 +0900 Subject: [PATCH 5/9] =?UTF-8?q?docs:=20=EC=9A=94=EA=B5=AC=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20=EB=AA=85=EC=84=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/design/01-requirements.md | 231 +++++++++++++++++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 docs/design/01-requirements.md diff --git a/docs/design/01-requirements.md b/docs/design/01-requirements.md new file mode 100644 index 00000000..19963f09 --- /dev/null +++ b/docs/design/01-requirements.md @@ -0,0 +1,231 @@ +# 유저 시나리오 기반 요구사항 명세 + +## 브랜드(Brand) + +[유저 스토리] + +- 사용자는 브랜드 정보를 조회할 수 있다. + +[기능 흐름] + +1. 비로그인 사용자도 가능 +2. 브랜드 상세 조회 요청(GET `/api/v1/brands/{brandId}`) +3. 브랜드 존재 여부 확인 +4. 브랜드 정보 응답 + +--- + +## 상품(Product) + +[유저 스토리] + +- 사용자는 상품 목록을 조회할 수 있다. +- 사용자는 특정 브랜드의 상품만 필터링해서 볼 수 있다. +- 사용자는 상품을 정렬해서 볼 수 있다(필수: latest / 선택: price_asc, likes_desc). +- 사용자는 상품 상세 정보를 조회할 수 있다. + +[기능 흐름] + +#### 1) 상품 목록 조회(GET `/api/v1/products`) + +1. 비로그인 사용자도 가능 +2. 조회 파라미터 적용(`brandId`, `sort`, `page`, `size`) +3. 정렬 기준 적용(필수: `latest`, 선택: `price_asc`, `likes_desc`) +4. 페이징 적용(기본 page=0, size=20) +5. 상품 목록 응답 + +#### 2) 상품 상세 조회(GET `/api/v1/products/{productId}`) + +1. 비로그인 사용자도 가능 +2. 상품 존재 여부 확인 +3. 상품 정보 응답 + +--- + +## 좋아요(Like) + +[유저 스토리] + +- 사용자는 상품을 찜(좋아요)할 수 있다. +- 이미 찜한 상품을 다시 누르면 찜이 취소된다. +- 사용자는 내가 좋아요한 상품 목록을 조회할 수 있다. + +[기능 흐름] + +#### 1) 좋아요 토글(POST `/api/v1/products/{productId}/likes`, DELETE `/api/v1/products/{productId}/likes`) + +1. 로그인 사용자만 가능 +2. 좋아요 요청 시 상품 존재 여부 확인 +3. 좋아요 존재 여부 판단(사용자-상품) +4. 없으면 저장, 있으면 삭제 +5. 좋아요 수 반영(+1 / -1) + +#### 2) 내가 좋아요한 상품 목록 조회(GET `/api/v1/users/{userId}/likes`) + +1. 로그인 사용자만 가능 +2. 요청 userId와 인증 사용자 일치 여부 확인(타인 조회 방지) +3. 해당 유저의 좋아요 상품 목록 조회 +4. 목록 응답 + +--- + +## 장바구니(Cart) + +[유저 스토리] + +- 사용자는 상품을 장바구니에 담을 수 있다. +- 사용자는 장바구니 목록을 조회할 수 있다. +- 사용자는 장바구니 상품 수량을 변경할 수 있다. +- 사용자는 장바구니에서 상품을 삭제할 수 있다. + +[기능 흐름] + +#### 1) 장바구니 담기(POST `/api/v1/cart/items`) + +1. 로그인 사용자만 가능 +2. 상품 존재 여부 확인 +3. 장바구니에 동일 상품 존재 여부 판단 +4. 없으면 저장, 있으면 수량 누적(또는 정책에 따라 덮어쓰기) +5. 장바구니 반영 결과 응답 + +#### 2) 장바구니 조회(GET `/api/v1/cart`) + +1. 로그인 사용자만 가능 +2. 내 장바구니 아이템 목록 조회 +3. 상품 요약 정보 조합(상품명/가격/브랜드 등) +4. 장바구니 목록 응답 + +#### 3) 장바구니 수량 변경(PUT `/api/v1/cart/items/{productId}`) + +1. 로그인 사용자만 가능 +2. 대상 장바구니 아이템 존재 여부 확인(내 장바구니인지) +3. 변경 수량 검증(>0 등) +4. 수량 업데이트 +5. 결과 응답 + +#### 4) 장바구니 삭제(DELETE `/api/v1/cart/items/{productId}`) + +1. 로그인 사용자만 가능 +2. 대상 장바구니 아이템 존재 여부 확인(내 장바구니인지) +3. 아이템 삭제 +4. 결과 응답 + +--- + +## 주문(Order) + +[유저 스토리] + +- 사용자는 여러 상품을 한 번에 주문할 수 있다. +- 사용자는 기간 조건으로 주문 목록을 조회할 수 있다. +- 사용자는 단일 주문 상세를 조회할 수 있다. +- 주문 시 재고 확인 및 차감이 보장되어야 한다. +- 주문 정보에는 당시 상품 정보가 스냅샷으로 저장되어야 한다. +- 사용자는 ORDERED 상태의 주문을 취소할 수 있다. + +[기능 흐름] + +#### 1) 주문 요청(POST `/api/v1/orders`) + +1. 로그인 사용자만 가능 +2. 주문 아이템 목록 검증(productId, quantity) +3. 각 상품 존재 여부 및 판매 가능 여부 확인 +4. 재고 확인 및 차감 보장(동시성 고려) +5. 주문 생성(주문/주문아이템) +6. 주문아이템에 상품 정보 스냅샷 저장 +7. 주문 결과 응답 + +#### 2) 주문 목록 조회(GET `/api/v1/orders?startAt=&endAt=`) + +1. 로그인 사용자만 가능 +2. 기간 파라미터 검증(startAt, endAt) +3. 내 주문 목록 조회(기간 조건) +4. 목록 응답 + +#### 3) 주문 상세 조회(GET `/api/v1/orders/{orderId}`) + +1. 로그인 사용자만 가능 +2. 주문 존재 여부 확인 +3. 내 주문인지 권한 검증 +4. 주문 상세(스냅샷 포함) 응답 + +#### 4) 주문 취소(PATCH `/api/v1/orders/{orderId}/cancel`) + +1. 로그인 사용자만 가능 +2. 주문 존재 여부 확인 +3. 내 주문인지 권한 검증 +4. 주문 상태가 ORDERED인지 확인 (ORDERED가 아니면 취소 불가) +5. 주문 상태를 CANCELLED로 변경 +6. 각 주문 아이템의 재고 복원 +7. 취소 결과 응답 + +--- + +## 브랜드 & 상품 ADMIN + +[어드민 스토리] + +- 어드민은 브랜드를 등록/조회/수정/삭제할 수 있다. +- 어드민은 상품을 등록/조회/수정/삭제할 수 있다. +- 브랜드 삭제 시 해당 브랜드의 상품도 함께 삭제되어야 한다. +- 상품 등록 시 브랜드는 이미 등록된 브랜드여야 한다. +- 상품 수정 시 상품의 브랜드는 수정할 수 없다. + +[기능 흐름] + +#### 1) 브랜드 관리(`/api-admin/v1/brands...`) + +1. LDAP 인증된 어드민만 가능 +2. 브랜드 목록 조회(페이징) +3. 브랜드 상세 조회 +4. 브랜드 등록 +5. 브랜드 수정 +6. 브랜드 삭제 시 연관 상품도 함께 삭제 + +#### 2) 상품 관리(`/api-admin/v1/products...`) + +1. LDAP 인증된 어드민만 가능 +2. 상품 목록 조회(페이징, brandId 필터 가능) +3. 상품 상세 조회 +4. 상품 등록 시 브랜드 존재 여부 확인 +5. 상품 수정 시 브랜드 변경 불가 +6. 상품 삭제 + +--- + +## 주문 ADMIN + +[어드민 스토리] + +- 어드민은 주문 목록을 조회할 수 있다. +- 어드민은 단일 주문 상세를 조회할 수 있다. +- 어드민은 주문 상태를 변경할 수 있다. + +[기능 흐름] + +1. LDAP 인증된 어드민만 가능 +2. 주문 목록 조회(페이징) (GET `/api-admin/v1/orders?startAt=&endAt=`) +3. 주문 상세 조회(주문/아이템/스냅샷 포함) (GET `/api-admin/v1/orders/{orderId}`) +4. 주문 상태 변경 (PATCH `/api-admin/v1/orders/{orderId}/status`) + - 상태 전이 규칙 검증 (ORDERED→SHIPPING, ORDERED→CANCELLED, SHIPPING→DELIVERED) + - CANCELLED 전이 시 재고 복원 + +--- + +## 설계 결정 사항 +항목 | 결정 | +|----|------| +| 판매 가능 여부 | 상태값(SELLING/STOP/SOLD_OUT) + 재고 복합 판단 | +| 좋아요 수 관리 | Product에 likeCount 비정규화 | +| 장바구니 동일 상품 | 수량 누적 | +| 삭제 전략 | Soft delete(deleteAt) | +| 주문-장바구니 관계 | 주문 시 장바구니 아이템 자동 삭제 | +| 주문 기간 파라미터 | startAt, endAt 둘 다 필수 | +| 스냅샷 범위 | 기본(상품명/가격/브랜드명) | +| isLiked 응답 | 로그인 시에만 포함 | +| 동시성 제어 | 비관적 락(Pessimistic Lock + SELECT FOR UPDATE) | +| 상품 옵션 | 단일 상품만 (옵션 없음) | +| 주문 상태 | ORDERED → SHIPPING → DELIVERED + CANCELLED | +| 어드민 인증 | X-Loopers-Ldap 헤더에 loopers.admin, User에 role 필드 추가, role=ADMIN 인증 | +| 어드민 주문 관리 | 상태 변경 가능 (PATCH) | +| 사용자 주문 취소 | ORDERED 상태일 때 취소 가능 (재고 복원) | From 7b109fa29c6bf2f526a7dba0d436cf2fa8adc4be Mon Sep 17 00:00:00 2001 From: najang Date: Thu, 12 Feb 2026 21:59:45 +0900 Subject: [PATCH 6/9] =?UTF-8?q?docs:=20=EC=8B=9C=ED=80=80=EC=8A=A4=20?= =?UTF-8?q?=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/design/02-sequence-diagrams.md | 824 ++++++++++++++++++++++++++++ 1 file changed, 824 insertions(+) create mode 100644 docs/design/02-sequence-diagrams.md diff --git a/docs/design/02-sequence-diagrams.md b/docs/design/02-sequence-diagrams.md new file mode 100644 index 00000000..7f78af4d --- /dev/null +++ b/docs/design/02-sequence-diagrams.md @@ -0,0 +1,824 @@ +# 시퀀스 다이어그램 + +## 사용자 API + +--- + +### 브랜드 상세 조회 + +**목적**: 단순 조회 흐름에서 삭제된 엔티티의 처리 방식과, 단일 서비스 호출 시 UseCase 없이 API → Domain 직접 호출 구조를 확인한다. + +```mermaid +sequenceDiagram + actor 사용자 + participant API + participant 브랜드 + participant DB + + 사용자->>API: 브랜드 상세 조회 요청 + API->>브랜드: 브랜드 조회 + 브랜드->>DB: 활성 브랜드 조회 + Note over DB: 삭제된 브랜드 제외 + + alt 브랜드 존재 + DB-->>브랜드: 브랜드 정보 + 브랜드-->>API: 브랜드 정보 + API-->>사용자: 브랜드 정보 응답 + else 브랜드 없음 (삭제 포함) + DB-->>브랜드: 없음 + 브랜드-->>API: NOT_FOUND + API-->>사용자: 404 Not Found + end +``` + +**핵심 포인트**: +- 단일 서비스 호출이므로 UseCase(Facade) 없이 API → 브랜드 직접 호출 +- 삭제된 브랜드는 사용자 API에서 NOT_FOUND 처리 + +--- + +### 상품 목록 조회 + +**목적**: 로그인/비로그인에 따른 좋아요 정보 포함 여부 분기와, UseCase가 상품+좋아요 두 도메인을 조율하는 흐름을 확인한다. + +```mermaid +sequenceDiagram + actor 사용자 + participant API + participant UseCase + participant 상품 + participant 좋아요 + participant DB + + 사용자->>API: 상품 목록 조회 요청 + Note over API: 인증 헤더 존재 시 사용자 식별 (선택) + + API->>UseCase: 상품 목록 조회 + UseCase->>상품: 상품 목록 조회 + 상품->>DB: 활성 상품 목록 조회 + Note over DB: 브랜드 필터, 정렬, 페이징 적용
정렬: latest / price_asc / likes_desc + DB-->>상품: 상품 목록 + 상품-->>UseCase: 상품 목록 + + alt 로그인 사용자 + UseCase->>좋아요: 좋아요 여부 확인 + 좋아요->>DB: 사용자-상품 좋아요 관계 조회 + DB-->>좋아요: 좋아요 상품 ID 목록 + 좋아요-->>UseCase: 좋아요 상태 + Note over UseCase: 각 상품에 isLiked 설정 + else 비로그인 + Note over UseCase: isLiked 미포함, likeCount는 포함 + end + + UseCase-->>API: 상품 목록 + API-->>사용자: 상품 목록 응답 +``` + +**핵심 포인트**: +- likeCount는 상품 컬럼(비정규화)이므로 별도 쿼리 없이 항상 포함 +- isLiked는 로그인 사용자에게만 제공 — UseCase가 상품+좋아요 두 도메인을 조율하는 이유 + +--- + +### 상품 상세 조회 + +**목적**: 상품+브랜드+좋아요 3개 도메인 조합 흐름과, 로그인 여부에 따른 선택적 좋아요 조회를 확인한다. + +```mermaid +sequenceDiagram + actor 사용자 + participant API + participant UseCase + participant 브랜드 + participant 좋아요 + participant DB + + 사용자->>API: 상품 상세 조회 요청 + Note over API: 인증 헤더 존재 시 사용자 식별 (선택) + Note over UseCase: 전제: 상품 조회/유효성(존재·활성) 확인은 '상품 조회' 시퀀스에서 수행됨 + + alt 상품 조회 실패(존재하지 않음/비활성) + API-->>사용자: 404 Not Found + else 상품 조회 성공 + API->>UseCase: 상품 상세 조회(상품 식별자/상품 기본정보 전달) + + UseCase->>브랜드: 브랜드 정보 조회 + 브랜드->>DB: 브랜드 조회 + DB-->>브랜드: 브랜드 정보 + 브랜드-->>UseCase: 브랜드 정보 + + alt 로그인 사용자 + UseCase->>좋아요: 좋아요 여부 조회 + 좋아요->>DB: 사용자-상품 좋아요 관계 확인 + DB-->>좋아요: 좋아요 여부 + 좋아요-->>UseCase: 좋아요 상태 + else 비로그인 사용자 + Note over UseCase: 좋아요 정보 제외(or 기본값 false) + end + + UseCase-->>API: 상품 상세(브랜드 + 좋아요 포함) + API-->>사용자: 200 OK + end +``` + +**핵심 포인트**: +- UseCase가 3개 서비스를 조율 — 브랜드 조회는 항상, 좋아요 확인은 로그인 시에만 +- 상품이 없으면 브랜드·좋아요 조회 자체를 하지 않음 (불필요한 호출 방지) + +--- + +### 좋아요 등록 + +**목적**: 멱등성 보장과, 좋아요 도메인이 상품 도메인의 like_count를 직접 갱신하는 cross-domain 의존 흐름을 확인한다. + +```mermaid +sequenceDiagram + actor 사용자 + participant API + participant UseCase + participant 좋아요 + participant 상품 + + 사용자->>API: 좋아요 요청 + Note over API: 인증 확인 + + alt 인증 실패 + API-->>사용자: 401 Unauthorized + else 인증 통과 + API->>UseCase: 좋아요 처리 요청 + + alt 상품이 유효하지 않음(삭제/비활성 등) + UseCase-->>API: NOT_FOUND + API-->>사용자: 404 Not Found + else 상품 유효 + UseCase->>좋아요: 좋아요 관계 저장(멱등) + + alt 신규 좋아요 생성 + UseCase->>상품: 좋아요 수 반영(+1) + else 이미 좋아요 상태 + Note over UseCase: 멱등 처리(추가 동작 없음) + end + + UseCase-->>API: liked=true + API-->>사용자: 200 OK + end + end +``` + +**핵심 포인트**: +- 이미 좋아요 상태면 추가 동작 없음 (멱등) +- 좋아요 도메인이 상품 도메인의 like_count를 직접 갱신 — cross-domain 의존이 존재하는 지점 + +--- + +### 좋아요 취소 + +**목적**: 등록의 역연산으로서 대칭적 흐름 확인과, 상품 존재 확인이 등록과 동일하게 적용되는지 확인한다. + +```mermaid +sequenceDiagram + actor 사용자 + participant API + participant UseCase + participant 좋아요 + participant 상품 + + 사용자->>API: 좋아요 취소 요청 (DELETE) + Note over API: 인증 확인 + + alt 인증 실패 + API-->>사용자: 401 Unauthorized + else 인증 통과 + API->>UseCase: 좋아요 취소 처리 요청 + + alt 상품이 유효하지 않음(삭제/비활성 등) + UseCase-->>API: NOT_FOUND + API-->>사용자: 404 Not Found + else 상품 유효 + UseCase->>좋아요: 좋아요 관계 삭제(멱등) + + alt 기존 좋아요 존재 → 삭제 + UseCase->>상품: 좋아요 수 반영(-1) + else 이미 좋아요 없음 → 추가 동작 없음 + Note over UseCase: 멱등 처리(추가 동작 없음) + end + + UseCase-->>API: liked=false + API-->>사용자: 200 OK + end + end +``` + +**핵심 포인트**: +- 등록과 대칭적 구조 — 상품 존재 확인 → 좋아요 상태 확인 → 취소/멱등 +- 삭제된 상품이면 404 반환 (좋아요 관계는 DB에 남아 있지만 취소 불가) + +--- + +### 내가 좋아요한 상품 목록 조회 + +**목적**: 타인 조회 차단과, 삭제된 상품을 목록에서 제외하는 필터링 정책을 확인한다. + +```mermaid +sequenceDiagram + actor 사용자 + participant API + participant UseCase + participant 좋아요 + participant 상품 + participant DB + + 사용자->>API: 좋아요 목록 조회 요청 + Note over API: 인증 확인 + + API->>UseCase: 좋아요 목록 조회 + + alt 요청 userId ≠ 인증 사용자 + UseCase-->>API: UNAUTHORIZED + API-->>사용자: 401 Unauthorized + else 본인 요청 + UseCase->>좋아요: 좋아요 목록 조회 + 좋아요->>DB: 사용자의 좋아요 관계 조회 + DB-->>좋아요: 좋아요 목록 + 좋아요-->>UseCase: 좋아요 목록 + + UseCase->>상품: 상품 정보 조회 + 상품->>DB: 활성 상품 조회 + Note over DB: 삭제된 상품 제외 + DB-->>상품: 상품 목록 + 상품-->>UseCase: 상품 정보 + + UseCase-->>API: 좋아요 상품 목록 + API-->>사용자: 좋아요 상품 목록 응답 + end +``` + +**핵심 포인트**: +- 본인만 조회 가능 (타인 userId 요청 시 401) +- 삭제된 상품은 목록에서 제외 — 좋아요 관계는 DB에 남아 있지만 노출하지 않음 + +--- + +### 장바구니 담기 + +**목적**: 동일 상품 중복 담기 시 수량 누적 정책과, UseCase의 상품 확인 + 장바구니 추가 조율 흐름을 확인한다. + +```mermaid +sequenceDiagram + actor 사용자 + participant API + participant UseCase + participant 상품 + participant 장바구니 + participant DB + + 사용자->>API: 장바구니 담기 요청 + Note over API: 인증 확인 + + API->>UseCase: 장바구니 담기 + UseCase->>상품: 상품 존재 확인 + 상품->>DB: 활성 상품 조회 + + alt 상품 없음 + DB-->>상품: 없음 + 상품-->>API: NOT_FOUND + API-->>사용자: 404 Not Found + else 상품 존재 + DB-->>상품: 상품 정보 + 상품-->>UseCase: 확인 완료 + + UseCase->>장바구니: 장바구니 아이템 추가 + 장바구니->>DB: 동일 상품 장바구니 아이템 조회 + + alt 동일 상품 존재 + DB-->>장바구니: 기존 아이템 + Note over 장바구니: 수량 누적 (기존 + 요청) + 장바구니->>DB: 수량 변경 반영 + else 신규 + DB-->>장바구니: 없음 + 장바구니->>DB: 장바구니 아이템 저장 + end + + 장바구니-->>UseCase: 장바구니 아이템 + UseCase-->>API: 장바구니 반영 결과 + API-->>사용자: 200 OK + end +``` + +**핵심 포인트**: +- 동일 상품이 이미 있으면 수량 합산 (기존 수량 + 요청 수량) +- 상품 존재 확인은 UseCase가 별도 서비스(상품)를 호출 — 장바구니 도메인은 상품에 의존하지 않음 + +--- + +### 장바구니 조회 + +**목적**: 삭제된 상품을 포함한 조회와 판매 종료 표시 처리, 3개 도메인(장바구니+상품+브랜드) 조합 흐름을 확인한다. + +```mermaid +sequenceDiagram + actor 사용자 + participant API + participant UseCase + participant 장바구니 + participant 상품 + participant 브랜드 + participant DB + + 사용자->>API: 장바구니 조회 요청 + Note over API: 인증 확인 + + API->>UseCase: 장바구니 조회 + + UseCase->>장바구니: 장바구니 아이템 목록 조회 + 장바구니->>DB: 활성 장바구니 아이템 조회 + DB-->>장바구니: 장바구니 아이템 목록 + 장바구니-->>UseCase: 장바구니 아이템 목록 + + UseCase->>상품: 상품 정보 조회 + 상품->>DB: 상품 조회 (삭제 포함) + Note over DB: 삭제된 상품도 포함 (판매 종료 표시용) + DB-->>상품: 상품 목록 + 상품-->>UseCase: 상품 정보 + + UseCase->>브랜드: 브랜드 정보 조회 + 브랜드->>DB: 브랜드 조회 + DB-->>브랜드: 브랜드 목록 + 브랜드-->>UseCase: 브랜드 정보 + + Note over UseCase: 상품 + 브랜드 + 수량 조합
삭제된 상품은 판매 종료 플래그 설정 + + UseCase-->>API: 장바구니 목록 + API-->>사용자: 장바구니 목록 응답 +``` + +**핵심 포인트**: +- 좋아요 목록과 달리, 삭제된 상품도 포함하여 "판매 종료" 플래그로 표시 +- UseCase가 장바구니+상품+브랜드 3개 도메인을 조합 + +--- + +### 장바구니 수량 변경 + +**목적**: 단일 도메인 흐름과 수량 검증(1 이상)을 확인한다. + +```mermaid +sequenceDiagram + actor 사용자 + participant API + participant 장바구니 + participant DB + + 사용자->>API: 장바구니 수량 변경 요청 + Note over API: 인증 확인 + + API->>장바구니: 수량 변경 + 장바구니->>DB: 사용자의 장바구니 아이템 조회 + + alt 장바구니 아이템 없음 + DB-->>장바구니: 없음 + 장바구니-->>API: NOT_FOUND + API-->>사용자: 404 Not Found + else 존재 + DB-->>장바구니: 장바구니 아이템 + Note over 장바구니: 수량 검증 (1 이상) + 장바구니->>DB: 수량 변경 반영 + 장바구니-->>API: 변경 결과 + API-->>사용자: 200 OK + end +``` + +**핵심 포인트**: +- 단일 서비스 호출이므로 UseCase 불필요 +- 수량 1 미만 요청은 도메인에서 검증 후 거부 + +--- + +### 장바구니 삭제 + +**목적**: soft delete 처리 방식을 확인한다. + +```mermaid +sequenceDiagram + actor 사용자 + participant API + participant 장바구니 + participant DB + + 사용자->>API: 장바구니 아이템 삭제 요청 + Note over API: 인증 확인 + + API->>장바구니: 아이템 삭제 + 장바구니->>DB: 사용자의 장바구니 아이템 조회 + + alt 장바구니 아이템 없음 + DB-->>장바구니: 없음 + 장바구니-->>API: NOT_FOUND + API-->>사용자: 404 Not Found + else 존재 + DB-->>장바구니: 장바구니 아이템 + 장바구니->>DB: 장바구니 아이템 삭제 반영 + Note over DB: soft delete (deleted_at 설정) + 장바구니-->>API: 삭제 완료 + API-->>사용자: 200 OK + end +``` + +**핵심 포인트**: +- 물리 삭제가 아닌 soft delete (deleted_at 설정) +- 단일 서비스이므로 UseCase 불필요 + +--- + +### 주문 생성 + +**목적**: 재고 차감+주문 생성의 트랜잭션 경계, 비관적 락을 통한 동시성 제어, 스냅샷 저장, 그리고 장바구니 정리의 별도 트랜잭션 분리를 검증한다. + +```mermaid +sequenceDiagram + actor 사용자 + participant API + participant UseCase + participant 주문 + participant 상품 + participant 장바구니 + participant DB + + 사용자->>API: 주문 요청 + Note over API: 인증 확인 + + API->>UseCase: 주문 생성 요청 + + rect rgb(230, 245, 255) + Note over UseCase,DB: 트랜잭션: 재고 차감 + 주문 생성 + + UseCase->>주문: 주문 생성 + Note over 주문: productId 오름차순 정렬 (데드락 방지) + + loop 각 주문 아이템 (정렬된 순서) + 주문->>상품: 상품 조회 (비관적 락) + 상품->>DB: 락 획득 후 상품 조회 + DB-->>상품: 상품 정보 + 상품-->>주문: 상품 정보 + + Note over 주문: 판매 가능 검증
(SELLING 상태 + 재고 충분) + + 주문->>상품: 재고 차감 + 상품->>DB: 재고 차감 반영 + end + + 주문->>DB: 주문 저장 (상태: ORDERED) + 주문->>DB: 주문 아이템 스냅샷 저장 + Note over DB: 스냅샷: 상품명, 주문가격, 브랜드명 + DB-->>주문: 주문 생성 완료 + 주문-->>UseCase: 주문 정보 + end + + rect rgb(255, 250, 230) + Note over UseCase,DB: 별도 트랜잭션: 장바구니 정리 + + UseCase->>장바구니: 주문 상품 장바구니 삭제 + 장바구니->>DB: 장바구니 아이템 삭제 반영 + Note over UseCase: 장바구니 삭제 실패해도 주문 유효 + end + + UseCase-->>API: 주문 정보 + API-->>사용자: 201 Created +``` + +**핵심 포인트**: +- 재고 차감 + 주문 생성은 하나의 트랜잭션. productId 오름차순 비관적 락으로 데드락 방지 +- 장바구니 삭제는 별도 트랜잭션 — 실패해도 주문은 유효 (최종 일관성) +- 주문 도메인이 상품 도메인을 직접 호출하여 재고 차감 (cross-domain 의존) + +--- + +### 주문 목록 조회 + +**목적**: 기간 조건 기반 조회와 본인 주문만 조회되는 흐름을 확인한다. + +```mermaid +sequenceDiagram + actor 사용자 + participant API + participant 주문 + participant DB + + 사용자->>API: 주문 목록 조회 요청 + Note over API: 인증 확인, 기간 파라미터 검증 (startAt, endAt) + + API->>주문: 주문 목록 조회 + 주문->>DB: 사용자의 기간별 주문 조회 + Note over DB: orderedAt 기준 내림차순 + DB-->>주문: 주문 목록 + 주문-->>API: 주문 목록 + API-->>사용자: 주문 목록 응답 +``` + +**핵심 포인트**: +- 단일 서비스이므로 UseCase 불필요 +- 본인 주문만 조회 (userId 필터) + +--- + +### 주문 상세 조회 + +**목적**: 본인 주문 권한 검증 흐름과 스냅샷 포함 응답을 확인한다. + +```mermaid +sequenceDiagram + actor 사용자 + participant API + participant 주문 + participant DB + + 사용자->>API: 주문 상세 조회 요청 + Note over API: 인증 확인 + + API->>주문: 주문 상세 조회 + 주문->>DB: 주문 + 주문 아이템 조회 + + alt 주문 없음 + DB-->>주문: 없음 + 주문-->>API: NOT_FOUND + API-->>사용자: 404 Not Found + else 주문 존재 + DB-->>주문: 주문 정보 (아이템 포함) + + alt 본인 주문이 아님 + 주문-->>API: UNAUTHORIZED + API-->>사용자: 401 Unauthorized + else 본인 주문 + 주문-->>API: 주문 상세 (스냅샷 포함) + API-->>사용자: 주문 상세 응답 + end + end +``` + +**핵심 포인트**: +- 주문 존재 확인 → 본인 확인 순서로 검증 +- 주문 아이템의 스냅샷(상품명, 가격, 브랜드명)이 응답에 포함 + +--- + +### 주문 취소 + +**목적**: 상태 검증(ORDERED만 취소 가능), 권한 검증, 재고 복원의 전체 흐름을 확인한다. + +```mermaid +sequenceDiagram + actor 사용자 + participant API + participant UseCase + participant 주문 + participant 상품 + participant DB + + 사용자->>API: 주문 취소 요청 + Note over API: 인증 확인 + + API->>UseCase: 주문 취소 요청 + + UseCase->>주문: 주문 조회 + 주문->>DB: 주문 + 주문 아이템 조회 + DB-->>주문: 주문 정보 + 주문-->>UseCase: 주문 정보 + + alt 주문 없음 + UseCase-->>API: NOT_FOUND + API-->>사용자: 404 Not Found + else 본인 주문이 아님 + UseCase-->>API: UNAUTHORIZED + API-->>사용자: 401 Unauthorized + else 주문 상태 ≠ ORDERED + Note over 주문: ORDERED 상태에서만 취소 가능 + UseCase-->>API: BAD_REQUEST + API-->>사용자: 400 Bad Request + else 취소 가능 + UseCase->>주문: 주문 취소 + 주문->>DB: 주문 상태 변경 (CANCELLED) + + loop 각 주문 아이템 + UseCase->>상품: 재고 복원 + 상품->>DB: 재고 복원 반영 + end + + UseCase-->>API: 취소 완료 + API-->>사용자: 200 OK + end +``` + +**핵심 포인트**: +- ORDERED 상태에서만 취소 가능 — SHIPPING 이후는 취소 불가 +- 주문(상태 변경) + 상품(재고 복원) cross-domain 호출이므로 UseCase가 조율 +- 주문 생성의 역연산 구조 — 생성과 동일하게 UseCase를 통해 처리 + +--- + +## 어드민 API + +--- + +### 어드민 브랜드 관리 + +**목적**: 브랜드 CRUD 중 삭제 시 연관 상품의 연쇄 soft delete 흐름과, 삭제에서만 UseCase가 필요한 구조를 확인한다. + +```mermaid +sequenceDiagram + actor 어드민 + participant API + participant UseCase + participant 브랜드 + participant 상품 + participant DB + + Note over 어드민,DB: 모든 요청에 LDAP 인증 필수 (role=ADMIN) + + rect rgb(240, 255, 240) + Note over 어드민,DB: 브랜드 목록 조회 + 어드민->>API: 브랜드 목록 조회 요청 + API->>브랜드: 브랜드 목록 조회 + 브랜드->>DB: 활성 브랜드 목록 조회 (페이징) + DB-->>브랜드: 브랜드 목록 + 브랜드-->>API: 브랜드 목록 + API-->>어드민: 200 OK + end + + rect rgb(230, 245, 255) + Note over 어드민,DB: 브랜드 등록 + 어드민->>API: 브랜드 등록 요청 + API->>브랜드: 브랜드 등록 + 브랜드->>DB: 브랜드 저장 + DB-->>브랜드: 브랜드 정보 + 브랜드-->>API: 브랜드 정보 + API-->>어드민: 201 Created + end + + rect rgb(255, 255, 230) + Note over 어드민,DB: 브랜드 수정 + 어드민->>API: 브랜드 수정 요청 + API->>브랜드: 브랜드 수정 + 브랜드->>DB: 활성 브랜드 조회 + 브랜드->>DB: 브랜드 정보 변경 반영 + 브랜드-->>API: 브랜드 정보 + API-->>어드민: 200 OK + end + + rect rgb(255, 240, 240) + Note over 어드민,DB: 브랜드 삭제 (연쇄 soft delete) + 어드민->>API: 브랜드 삭제 요청 + API->>UseCase: 브랜드 삭제 + + UseCase->>브랜드: 브랜드 삭제 + 브랜드->>DB: 브랜드 soft delete 반영 + + UseCase->>상품: 브랜드 연관 상품 삭제 + 상품->>DB: 연관 상품 soft delete 반영 + Note over DB: 장바구니/좋아요 관계는 유지 + + UseCase-->>API: 삭제 완료 + API-->>어드민: 200 OK + end +``` + +**핵심 포인트**: +- 목록/등록/수정은 단일 서비스로 UseCase 불필요. 삭제만 UseCase가 브랜드+상품을 조율 +- 브랜드 삭제 시 연관 상품도 soft delete — 장바구니/좋아요 관계는 유지 + +--- + +### 어드민 상품 관리 + +**목적**: 상품 등록 시 브랜드 검증, 수정 시 브랜드 변경 불가 제약, 삭제 후 장바구니/좋아요 유지를 확인한다. + +```mermaid +sequenceDiagram + actor 어드민 + participant API + participant UseCase + participant 브랜드 + participant 상품 + participant DB + + Note over 어드민,DB: 모든 요청에 LDAP 인증 필수 + + rect rgb(230, 245, 255) + Note over 어드민,DB: 상품 등록 + 어드민->>API: 상품 등록 요청 + API->>UseCase: 상품 등록 + + UseCase->>브랜드: 브랜드 존재 확인 + 브랜드->>DB: 활성 브랜드 조회 + + alt 브랜드 없음 + DB-->>브랜드: 없음 + 브랜드-->>API: NOT_FOUND + API-->>어드민: 404 Not Found + else 브랜드 존재 + DB-->>브랜드: 브랜드 정보 + UseCase->>상품: 상품 등록 + 상품->>DB: 상품 저장 + DB-->>상품: 상품 정보 + 상품-->>API: 상품 정보 + API-->>어드민: 201 Created + end + end + + rect rgb(255, 255, 230) + Note over 어드민,DB: 상품 수정 (브랜드 변경 불가) + 어드민->>API: 상품 수정 요청 + Note over API: brandId 필드 없음 + API->>상품: 상품 수정 + 상품->>DB: 상품 조회 + 상품->>DB: 상품 정보 변경 반영 + 상품-->>API: 상품 정보 + API-->>어드민: 200 OK + end + + rect rgb(255, 240, 240) + Note over 어드민,DB: 상품 삭제 + 어드민->>API: 상품 삭제 요청 + API->>상품: 상품 삭제 + 상품->>DB: 상품 soft delete 반영 + Note over DB: 장바구니/좋아요 관계는 유지 + 상품-->>API: 삭제 완료 + API-->>어드민: 200 OK + end +``` + +**핵심 포인트**: +- 등록 시에만 UseCase 필요 (브랜드 존재 확인). 수정/삭제는 단일 서비스 +- 수정 시 brandId 필드 자체가 없어 브랜드 변경 불가를 구조적으로 강제 + +--- + +### 어드민 주문 관리 + +**목적**: 어드민 주문 조회(본인 검증 없음)와 상태 전이 규칙(SHIPPING→CANCELLED 제거됨)을 확인한다. + +```mermaid +sequenceDiagram + actor 어드민 + participant API + participant 주문 + participant 상품 + participant DB + + Note over 어드민,DB: 모든 요청에 LDAP 인증 필수 + + rect rgb(240, 255, 240) + Note over 어드민,DB: 주문 목록 조회 + 어드민->>API: 주문 목록 조회 요청 + API->>주문: 전체 주문 목록 조회 + 주문->>DB: 기간별 전체 주문 조회 (페이징) + Note over DB: 전체 사용자 대상 (userId 필터 없음) + DB-->>주문: 주문 목록 + 주문-->>API: 주문 목록 + API-->>어드민: 200 OK + end + + rect rgb(230, 245, 255) + Note over 어드민,DB: 주문 상세 조회 + 어드민->>API: 주문 상세 조회 요청 + API->>주문: 주문 상세 조회 + 주문->>DB: 주문 + 주문 아이템 조회 + Note over 주문: 어드민은 본인 검증 없음 + DB-->>주문: 주문 정보 + 주문-->>API: 주문 상세 (스냅샷 포함) + API-->>어드민: 200 OK + end + + rect rgb(255, 255, 230) + Note over 어드민,DB: 주문 상태 변경 + 어드민->>API: 주문 상태 변경 요청 + API->>주문: 주문 상태 변경 + 주문->>DB: 주문 + 주문 아이템 조회 + DB-->>주문: 주문 정보 + + Note over 주문: 상태 전이 규칙 검증
ORDERED → SHIPPING ✅
ORDERED → CANCELLED ✅
SHIPPING → DELIVERED ✅
DELIVERED → 변경 불가 ❌
CANCELLED → 변경 불가 ❌ + + alt 잘못된 상태 전이 + 주문-->>API: BAD_REQUEST + API-->>어드민: 400 Bad Request + else CANCELLED로 전이 + 주문->>DB: 주문 상태 변경 (CANCELLED) + + loop 각 주문 아이템 + 주문->>상품: 재고 복원 + 상품->>DB: 재고 복원 반영 + end + + 주문-->>API: 변경 완료 + API-->>어드민: 200 OK + else 일반 상태 전이 (SHIPPING / DELIVERED) + 주문->>DB: 주문 상태 변경 + 주문-->>API: 변경 완료 + API-->>어드민: 200 OK + end + end +``` + +**핵심 포인트**: +- SHIPPING → CANCELLED 전이 제거됨 — ORDERED에서만 취소 가능 +- 어드민은 전체 사용자 주문 조회 가능, 본인 검증 없음 +- 주문 조회/상태 변경은 단일 서비스이므로 UseCase 불필요 \ No newline at end of file From 1e0a0257e142aec800c5e39a652446441f3dfa64 Mon Sep 17 00:00:00 2001 From: najang Date: Thu, 12 Feb 2026 21:59:51 +0900 Subject: [PATCH 7/9] =?UTF-8?q?docs:=20=ED=81=B4=EB=9E=98=EC=8A=A4=20?= =?UTF-8?q?=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/design/03-class-diagram.md | 175 ++++++++++++++++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 docs/design/03-class-diagram.md diff --git a/docs/design/03-class-diagram.md b/docs/design/03-class-diagram.md new file mode 100644 index 00000000..d38d4fe1 --- /dev/null +++ b/docs/design/03-class-diagram.md @@ -0,0 +1,175 @@ +# 도메인 객체 클래스 다이어그램 + +```mermaid +classDiagram + %% ─── BaseEntity (공통 상위 클래스) ─── + %% modules/jpa BaseEntity 기반 + %% id, createdAt, updatedAt, deletedAt 을 제공하며 + %% delete()/restore() 멱등 연산 포함 + + class BaseEntity { + <> + #Long id + #ZonedDateTime createdAt + #ZonedDateTime updatedAt + #ZonedDateTime deletedAt + +delete() + +restore() + } + + %% ─── Domain Entities / Enums ─── + + class User { + -String loginId + -String password + -String name + -LocalDate birthDate + -String email + -UserRole role + } + + class UserRole { + <> + USER + ADMIN + } + + class Brand { + -String name + -String description + +브랜드정보변경(name, description) + } + + class Product { + -Long brandId + -String name + -String description + -int price + -int stock + -SellingStatus sellingStatus + -int likeCount + +재고차감(quantity) + +재고복원(quantity) + +좋아요수증가() + +좋아요수감소() + +주문가능여부() boolean + } + + class SellingStatus { + <> + SELLING + STOP + SOLD_OUT + } + + %% Like는 복합 키(userId + productId)를 사용하므로 BaseEntity 상속 불가 + %% @IdClass 또는 @EmbeddedId 방식으로 별도 구현 + class Like { + -Long userId "복합 키 구성 요소" + -Long productId "복합 키 구성 요소" + -ZonedDateTime createdAt + -ZonedDateTime updatedAt + } + + class CartItem { + -Long userId + -Long productId + -int quantity + +수량추가(quantity) + +수량변경(quantity) + } + + class Order { + -Long userId + -OrderStatus status + -int totalPrice + -ZonedDateTime orderedAt + -List~OrderItem~ items + +주문취소() + +상태변경(newStatus) + +총가격계산() int + } + + class OrderStatus { + <> + ORDERED + SHIPPING + DELIVERED + CANCELLED + } + + class OrderItem { + -Long orderId + -Long productId "참조용 식별자" + -int quantity + -int orderPrice + %% Snapshot (주문 시점 고정 정보) + -String productName + -String brandName + } + + %% ─── Inheritance (BaseEntity) ─── + BaseEntity <|-- User + BaseEntity <|-- Brand + BaseEntity <|-- Product + BaseEntity <|-- CartItem + BaseEntity <|-- Order + BaseEntity <|-- OrderItem + + %% ─── Enum Relationships ─── + User --> UserRole + Product --> SellingStatus + Order --> OrderStatus + + %% ─── Aggregate Boundary ─── + %% Order가 Aggregate Root, OrderItem은 Order를 통해서만 생명주기 관리 + Order "1" *-- "*" OrderItem : 구성(애그리게잇) + + %% ─── Reference by ID (loose coupling) ─── + Product ..> Brand : brandId로 참조 + Like ..> User : userId 참조 + Like ..> Product : productId 참조 + CartItem ..> User : userId 참조 + CartItem ..> Product : productId 참조 + Order ..> User : userId 참조 + + %% ─── Cross-domain 서비스 의존 (UseCase 레벨) ─── + %% 좋아요 UseCase → Product.좋아요수증가/감소() 호출 + %% 주문 UseCase → Product.재고차감/복원() 호출 (비관적 락, productId 오름차순) + %% 브랜드 삭제 UseCase → Product.삭제() 연쇄 호출 + %% 주문 생성 UseCase → CartItem 삭제 (별도 트랜잭션, 실패해도 주문 유효) +``` +--- + +## 설계 포인트 + +### 1. BaseEntity 상속 구조 +- `BaseEntity`가 `id`, `createdAt`, `updatedAt`, `deletedAt`, `delete()`, `restore()`를 제공 +- Brand, Product, CartItem, Order, OrderItem, User는 BaseEntity를 상속 +- **예외**: `Like`는 복합 키(`userId` + `productId`)를 사용하므로 BaseEntity 상속 불가 → `@IdClass` 또는 `@EmbeddedId` 별도 구현 + +### 2. OrderStatus 상태 전이 규칙 +- `ORDERED → SHIPPING` / `ORDERED → CANCELLED` / `SHIPPING → DELIVERED`만 허용 +- `DELIVERED`, `CANCELLED`에서는 변경 불가 +- `상태변경(newStatus)` 메서드 내에서 전이 가능 여부 검증 + +### 3. Product.주문가능여부() 판단 기준 +- `sellingStatus == SELLING` **AND** `stock > 0` 복합 조건 +- 두 조건 중 하나라도 불충족 시 주문 불가 + +### 5. Cross-domain 의존성 (UseCase 레벨) +| UseCase | 호출 대상 | 비고 | +|---------|-----------|------| +| 좋아요 등록/취소 | Product.좋아요수증가/감소() | likeCount 비정규화 갱신 | +| 주문 생성 | Product.재고차감() | 비관적 락, productId 오름차순 데드락 방지 | +| 주문 취소 | Product.재고복원() | 주문 생성의 역연산 | +| 브랜드 삭제 | Product.삭제() | 연쇄 soft delete | +| 주문 생성 | CartItem 삭제 | 별도 트랜잭션 (실패해도 주문 유효) | + +### 6. Aggregate Boundary +- **Order Aggregate**: Order(Root) + OrderItem → OrderItem은 독립 Repository 없이 Order를 통해서만 접근 +- 나머지 엔티티(User, Brand, Product, CartItem, Like)는 각각 독립 Aggregate Root + +### 7. Order.totalPrice 계산 +- `totalPrice = Σ(OrderItem.quantity × OrderItem.orderPrice)` +- 주문 생성 시 계산하여 저장 (조회 성능 최적화) \ No newline at end of file From e968d71954bc2859c2b66819bf3f04edc16a60bc Mon Sep 17 00:00:00 2001 From: najang Date: Thu, 12 Feb 2026 21:59:57 +0900 Subject: [PATCH 8/9] =?UTF-8?q?docs:=20ERD=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/design/04-erd.md | 121 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 docs/design/04-erd.md diff --git a/docs/design/04-erd.md b/docs/design/04-erd.md new file mode 100644 index 00000000..554bd59c --- /dev/null +++ b/docs/design/04-erd.md @@ -0,0 +1,121 @@ +# ERD + +```mermaid +erDiagram + USER { + bigint id PK + varchar login_id UK + varchar password + varchar name + date birth_date + varchar email + varchar role "USER / ADMIN" + datetime created_at + datetime updated_at + datetime deleted_at + } + + BRAND { + bigint id PK + varchar name + varchar description + datetime created_at + datetime updated_at + datetime deleted_at + } + + PRODUCT { + bigint id PK + bigint brand_id FK + varchar name + varchar description + int price + int stock "동시성 제어 대상 (비관적 락)" + varchar selling_status "SELLING / STOP / SOLD_OUT" + int like_count "비정규화" + datetime created_at + datetime updated_at + datetime deleted_at + } + + LIKES { + bigint user_id PK,FK + bigint product_id PK,FK + datetime created_at + datetime updated_at + } + + CART_ITEM { + bigint id PK + bigint user_id FK "UK (user_id, product_id)" + bigint product_id FK "UK (user_id, product_id)" + int quantity + datetime created_at + datetime updated_at + datetime deleted_at + } + + ORDERS { + bigint id PK + bigint user_id FK + varchar status "ORDERED / SHIPPING / DELIVERED / CANCELLED" + int total_price + datetime ordered_at "주문 시점 기록" + datetime created_at + datetime updated_at + datetime deleted_at + } + + ORDER_ITEM { + bigint id PK + bigint order_id FK + bigint product_id FK + int quantity + int order_price "스냅샷: 주문 시점 가격" + varchar product_name "스냅샷: 주문 시점 상품명" + varchar brand_name "스냅샷: 주문 시점 브랜드명" + datetime created_at + datetime updated_at + datetime deleted_at + } + + BRAND ||--o{ PRODUCT : "브랜드의 상품" + PRODUCT ||--o{ LIKES : "상품에 대한 좋아요" + USER ||--o{ LIKES : "좋아요" + USER ||--o{ CART_ITEM : "장바구니" + PRODUCT ||--o{ CART_ITEM : "장바구니에 담긴 상품" + USER ||--o{ ORDERS : "주문" + ORDERS ||--o{ ORDER_ITEM : "주문 항목" + PRODUCT ||--o{ ORDER_ITEM : "주문된 상품" +``` + +--- + +## 설계 포인트 + +### 1. Soft Delete 전략 +- `deleted_at`이 있는 테이블: USER, BRAND, PRODUCT, CART_ITEM, ORDERS, ORDER_ITEM +- `deleted_at`이 **없는** 테이블: LIKES (물리 삭제 — 좋아요 취소 시 행 자체 삭제) +- 조회 시 `WHERE deleted_at IS NULL` 조건 필수 (사용자 API 기준) + +### 2. 복합 키 vs 대리 키 +- **LIKES**: 복합 PK (`user_id` + `product_id`) — 1인 1상품 1좋아요를 DB 레벨에서 보장 +- **CART_ITEM**: 대리 키 (`id` PK) + 복합 유니크 (`user_id`, `product_id`) — 수량 변경이 빈번하여 대리 키 사용 +- **ORDER_ITEM**: 대리 키 (`id` PK) — 동일 상품을 다른 주문에서 반복 주문 가능 + +### 3. 비정규화 필드 +- `PRODUCT.like_count`: 좋아요 수 캐싱 (COUNT 쿼리 회피, 갱신 시 동시성 고려 필요) +- `ORDER_ITEM.product_name`, `ORDER_ITEM.brand_name`, `ORDER_ITEM.order_price`: 주문 시점 스냅샷 (원본 변경에 영향받지 않음) + +### 4. 인덱스 후보 +| 테이블 | 컬럼 | 사유 | +|--------|-------|------| +| PRODUCT | `brand_id` | 브랜드별 상품 필터링 | +| PRODUCT | `selling_status` | 판매 상태별 조회 | +| CART_ITEM | `(user_id, product_id)` | 유니크 제약 + 중복 담기 방지 | +| ORDERS | `(user_id, ordered_at)` | 사용자별 기간 주문 조회 | +| ORDER_ITEM | `order_id` | 주문별 아이템 조회 | + +### 5. 동시성 제어 대상 +- `PRODUCT.stock`: 주문 시 비관적 락 (`SELECT ... FOR UPDATE`, productId 오름차순) +- `PRODUCT.like_count`: 좋아요 등록/취소 시 갱신 (동시성 제어 방식 결정 필요) \ No newline at end of file From 5f37e7c07101bc39ad02d0e0519bbbc83b1c1c8c Mon Sep 17 00:00:00 2001 From: najang Date: Thu, 12 Feb 2026 23:19:01 +0900 Subject: [PATCH 9/9] =?UTF-8?q?refactor:=20User=20role=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EC=A0=9C=EA=B1=B0,=20=EC=96=B4=EB=93=9C=EB=AF=BC?= =?UTF-8?q?=20=EC=9D=B8=EC=A6=9D=20=EB=B0=A9=EC=8B=9D=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/design/01-requirements.md | 2 +- docs/design/02-sequence-diagrams.md | 2 +- docs/design/03-class-diagram.md | 8 -------- docs/design/04-erd.md | 1 - 4 files changed, 2 insertions(+), 11 deletions(-) diff --git a/docs/design/01-requirements.md b/docs/design/01-requirements.md index 19963f09..5e834e62 100644 --- a/docs/design/01-requirements.md +++ b/docs/design/01-requirements.md @@ -226,6 +226,6 @@ | 동시성 제어 | 비관적 락(Pessimistic Lock + SELECT FOR UPDATE) | | 상품 옵션 | 단일 상품만 (옵션 없음) | | 주문 상태 | ORDERED → SHIPPING → DELIVERED + CANCELLED | -| 어드민 인증 | X-Loopers-Ldap 헤더에 loopers.admin, User에 role 필드 추가, role=ADMIN 인증 | +| 어드민 인증 | X-Loopers-Ldap 헤더의 loopers.admin 값으로 어드민 식별 | | 어드민 주문 관리 | 상태 변경 가능 (PATCH) | | 사용자 주문 취소 | ORDERED 상태일 때 취소 가능 (재고 복원) | diff --git a/docs/design/02-sequence-diagrams.md b/docs/design/02-sequence-diagrams.md index 7f78af4d..4cfa6ee6 100644 --- a/docs/design/02-sequence-diagrams.md +++ b/docs/design/02-sequence-diagrams.md @@ -632,7 +632,7 @@ sequenceDiagram participant 상품 participant DB - Note over 어드민,DB: 모든 요청에 LDAP 인증 필수 (role=ADMIN) + Note over 어드민,DB: 모든 요청에 LDAP 인증 필수 (X-Loopers-Ldap: loopers.admin) rect rgb(240, 255, 240) Note over 어드민,DB: 브랜드 목록 조회 diff --git a/docs/design/03-class-diagram.md b/docs/design/03-class-diagram.md index d38d4fe1..f60a53d5 100644 --- a/docs/design/03-class-diagram.md +++ b/docs/design/03-class-diagram.md @@ -25,13 +25,6 @@ classDiagram -String name -LocalDate birthDate -String email - -UserRole role - } - - class UserRole { - <> - USER - ADMIN } class Brand { @@ -117,7 +110,6 @@ classDiagram BaseEntity <|-- OrderItem %% ─── Enum Relationships ─── - User --> UserRole Product --> SellingStatus Order --> OrderStatus diff --git a/docs/design/04-erd.md b/docs/design/04-erd.md index 554bd59c..924fd738 100644 --- a/docs/design/04-erd.md +++ b/docs/design/04-erd.md @@ -9,7 +9,6 @@ erDiagram varchar name date birth_date varchar email - varchar role "USER / ADMIN" datetime created_at datetime updated_at datetime deleted_at