From 19b38bc4d6ec5b7dbf5fd3cd8feec1496ed067d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=84=EC=9D=B8=EA=B5=AD?= Date: Fri, 6 Feb 2026 18:15:12 +0900 Subject: [PATCH 1/8] =?UTF-8?q?test:=20=ED=86=B5=ED=95=A9=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EB=B0=8F=20E2E=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GlobalExceptionHandler 추가로 예외를 HTTP 응답으로 변환 - UserApiIntegrationTest: MockMvc 기반 통합 테스트 12개 - 회원가입 API (4개): 성공, 중복 ID, 필수 필드 누락, 잘못된 이메일 - 내 정보 조회 API (4개): 성공, 잘못된 비밀번호, 사용자 없음, 마스킹 - 비밀번호 변경 API (4개): 성공, 현재 비밀번호 불일치, 동일 비밀번호, 인증 실패 - UserApiE2ETest: TestRestTemplate 기반 E2E 테스트 7개 - 회원가입 시나리오 (2개) - 인증 시나리오 (2개) - 비밀번호 변경 시나리오 (2개) - 전체 사용자 플로우 (1개) 총 83개 테스트 통과 Co-Authored-By: Claude Opus 4.5 --- .../support/error/GlobalExceptionHandler.java | 69 ++++ .../interfaces/api/UserApiE2ETest.java | 297 ++++++++++++++++++ .../api/UserApiIntegrationTest.java | 275 ++++++++++++++++ 3 files changed, 641 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/support/error/GlobalExceptionHandler.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserApiE2ETest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserApiIntegrationTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/GlobalExceptionHandler.java b/apps/commerce-api/src/main/java/com/loopers/support/error/GlobalExceptionHandler.java new file mode 100644 index 00000000..105a897c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/GlobalExceptionHandler.java @@ -0,0 +1,69 @@ +package com.loopers.support.error; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingRequestHeaderException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.Map; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(CoreException.class) + public ResponseEntity> handleCoreException(CoreException e) { + return ResponseEntity + .status(e.getErrorType().getStatus()) + .body(Map.of( + "code", e.getErrorType().getCode(), + "message", e.getMessage() + )); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleIllegalArgumentException(IllegalArgumentException e) { + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(Map.of( + "code", "BAD_REQUEST", + "message", e.getMessage() + )); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidationException(MethodArgumentNotValidException e) { + String message = e.getBindingResult().getFieldErrors().stream() + .findFirst() + .map(error -> error.getField() + ": " + error.getDefaultMessage()) + .orElse("유효성 검사 실패"); + + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(Map.of( + "code", "VALIDATION_ERROR", + "message", message + )); + } + + @ExceptionHandler(MissingRequestHeaderException.class) + public ResponseEntity> handleMissingHeaderException(MissingRequestHeaderException e) { + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(Map.of( + "code", "MISSING_HEADER", + "message", "필수 헤더가 누락되었습니다: " + e.getHeaderName() + )); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleException(Exception e) { + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of( + "code", "INTERNAL_ERROR", + "message", "일시적인 오류가 발생했습니다." + )); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserApiE2ETest.java new file mode 100644 index 00000000..4a08ee37 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserApiE2ETest.java @@ -0,0 +1,297 @@ +package com.loopers.interfaces.api; + +import com.loopers.interfaces.api.dto.PasswordUpdateRequest; +import com.loopers.interfaces.api.dto.UserInfoResponse; +import com.loopers.interfaces.api.dto.UserRegisterRequest; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.context.annotation.Import; +import org.springframework.http.*; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Import(MySqlTestContainersConfig.class) +class UserApiE2ETest { + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private static final String BASE_URL = "/api/v1/users"; + private static final LocalDate TEST_BIRTHDAY = LocalDate.of(1990, 5, 15); + + @BeforeEach + void setUp() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + @DisplayName("E2E: 회원가입 시나리오") + class RegisterE2E { + + @Test + @DisplayName("회원가입 → 내 정보 조회 성공") + void register_then_getMyInfo() { + // given + String loginId = "e2euser1"; + String password = "Password1!"; + var registerRequest = createRegisterRequest(loginId, password, "홍길동"); + + // when - 회원가입 + ResponseEntity registerResponse = restTemplate.postForEntity( + BASE_URL + "/register", + registerRequest, + Void.class + ); + + // then - 회원가입 성공 + assertThat(registerResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + + // when - 내 정보 조회 + HttpHeaders headers = createAuthHeaders(loginId, password); + ResponseEntity getInfoResponse = restTemplate.exchange( + BASE_URL + "/me", + HttpMethod.GET, + new HttpEntity<>(headers), + UserInfoResponse.class + ); + + // then - 조회 성공 + assertThat(getInfoResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(getInfoResponse.getBody()).isNotNull(); + assertThat(getInfoResponse.getBody().loginId()).isEqualTo(loginId); + assertThat(getInfoResponse.getBody().name()).isEqualTo("홍길*"); + assertThat(getInfoResponse.getBody().birthday()).isEqualTo("19900515"); + } + + @Test + @DisplayName("중복 ID 가입 시도 실패") + void register_duplicateId_fail() { + // given + String loginId = "e2euser1"; + var request = createRegisterRequest(loginId, "Password1!", "홍길동"); + + // 첫 번째 가입 + restTemplate.postForEntity(BASE_URL + "/register", request, Void.class); + + // when - 동일 ID로 재가입 + ResponseEntity response = restTemplate.postForEntity( + BASE_URL + "/register", + request, + Void.class + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @Nested + @DisplayName("E2E: 인증 시나리오") + class AuthenticationE2E { + + @Test + @DisplayName("잘못된 비밀번호로 인증 실패") + void authentication_wrongPassword_fail() { + // given + String loginId = "e2euser1"; + registerUser(loginId, "Password1!", "홍길동"); + + // when - 잘못된 비밀번호로 조회 + HttpHeaders headers = createAuthHeaders(loginId, "WrongPassword1!"); + ResponseEntity response = restTemplate.exchange( + BASE_URL + "/me", + HttpMethod.GET, + new HttpEntity<>(headers), + String.class + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + @DisplayName("존재하지 않는 사용자 인증 실패") + void authentication_userNotFound_fail() { + // when + HttpHeaders headers = createAuthHeaders("notexist", "Password1!"); + ResponseEntity response = restTemplate.exchange( + BASE_URL + "/me", + HttpMethod.GET, + new HttpEntity<>(headers), + String.class + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @Nested + @DisplayName("E2E: 비밀번호 변경 시나리오") + class PasswordChangeE2E { + + @Test + @DisplayName("비밀번호 변경 → 새 비밀번호로 로그인 성공") + void changePassword_then_loginWithNewPassword() { + // given + String loginId = "e2euser1"; + String oldPassword = "Password1!"; + String newPassword = "NewPassword1!"; + registerUser(loginId, oldPassword, "홍길동"); + + // when - 비밀번호 변경 + HttpHeaders headers = createAuthHeaders(loginId, oldPassword); + headers.setContentType(MediaType.APPLICATION_JSON); + var updateRequest = new PasswordUpdateRequest(oldPassword, newPassword); + + ResponseEntity updateResponse = restTemplate.exchange( + BASE_URL + "/me/password", + HttpMethod.PUT, + new HttpEntity<>(updateRequest, headers), + Void.class + ); + + // then - 변경 성공 + assertThat(updateResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + + // when - 새 비밀번호로 조회 + HttpHeaders newHeaders = createAuthHeaders(loginId, newPassword); + ResponseEntity getInfoResponse = restTemplate.exchange( + BASE_URL + "/me", + HttpMethod.GET, + new HttpEntity<>(newHeaders), + UserInfoResponse.class + ); + + // then - 새 비밀번호로 조회 성공 + assertThat(getInfoResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + + // when - 이전 비밀번호로 조회 시도 + HttpHeaders oldHeaders = createAuthHeaders(loginId, oldPassword); + ResponseEntity oldPasswordResponse = restTemplate.exchange( + BASE_URL + "/me", + HttpMethod.GET, + new HttpEntity<>(oldHeaders), + String.class + ); + + // then - 이전 비밀번호로는 실패 + assertThat(oldPasswordResponse.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + @DisplayName("동일한 비밀번호로 변경 시 실패") + void changePassword_samePassword_fail() { + // given + String loginId = "e2euser1"; + String password = "Password1!"; + registerUser(loginId, password, "홍길동"); + + // when + HttpHeaders headers = createAuthHeaders(loginId, password); + headers.setContentType(MediaType.APPLICATION_JSON); + var updateRequest = new PasswordUpdateRequest(password, password); + + ResponseEntity response = restTemplate.exchange( + BASE_URL + "/me/password", + HttpMethod.PUT, + new HttpEntity<>(updateRequest, headers), + String.class + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @Nested + @DisplayName("E2E: 전체 사용자 플로우") + class FullUserFlowE2E { + + @Test + @DisplayName("회원가입 → 조회 → 비밀번호 변경 → 새 비밀번호로 조회") + void fullUserFlow() { + // Step 1: 회원가입 + String loginId = "flowuser1"; + String password = "Password1!"; + var registerRequest = createRegisterRequest(loginId, password, "김철수"); + + ResponseEntity registerResponse = restTemplate.postForEntity( + BASE_URL + "/register", + registerRequest, + Void.class + ); + assertThat(registerResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + + // Step 2: 내 정보 조회 + HttpHeaders headers = createAuthHeaders(loginId, password); + ResponseEntity infoResponse = restTemplate.exchange( + BASE_URL + "/me", + HttpMethod.GET, + new HttpEntity<>(headers), + UserInfoResponse.class + ); + assertThat(infoResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(infoResponse.getBody().name()).isEqualTo("김철*"); + + // Step 3: 비밀번호 변경 + String newPassword = "NewPassword1!"; + headers.setContentType(MediaType.APPLICATION_JSON); + var updateRequest = new PasswordUpdateRequest(password, newPassword); + + ResponseEntity updateResponse = restTemplate.exchange( + BASE_URL + "/me/password", + HttpMethod.PUT, + new HttpEntity<>(updateRequest, headers), + Void.class + ); + assertThat(updateResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + + // Step 4: 새 비밀번호로 조회 + HttpHeaders newHeaders = createAuthHeaders(loginId, newPassword); + ResponseEntity finalResponse = restTemplate.exchange( + BASE_URL + "/me", + HttpMethod.GET, + new HttpEntity<>(newHeaders), + UserInfoResponse.class + ); + assertThat(finalResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(finalResponse.getBody().loginId()).isEqualTo(loginId); + } + } + + private UserRegisterRequest createRegisterRequest(String loginId, String password, String name) { + return new UserRegisterRequest( + loginId, + password, + name, + TEST_BIRTHDAY, + "test@example.com" + ); + } + + private HttpHeaders createAuthHeaders(String loginId, String password) { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", loginId); + headers.set("X-Loopers-LoginPw", password); + return headers; + } + + private void registerUser(String loginId, String password, String name) { + var request = createRegisterRequest(loginId, password, name); + restTemplate.postForEntity(BASE_URL + "/register", request, Void.class); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserApiIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserApiIntegrationTest.java new file mode 100644 index 00000000..c14faa6b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserApiIntegrationTest.java @@ -0,0 +1,275 @@ +package com.loopers.interfaces.api; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.interfaces.api.dto.PasswordUpdateRequest; +import com.loopers.interfaces.api.dto.UserRegisterRequest; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDate; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@Import(MySqlTestContainersConfig.class) +class UserApiIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private static final String BASE_URL = "/api/v1/users"; + private static final LocalDate TEST_BIRTHDAY = LocalDate.of(1990, 5, 15); + + @BeforeEach + void setUp() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + @DisplayName("회원가입 API") + class RegisterApi { + + @Test + @DisplayName("회원가입 성공") + void register_success() throws Exception { + var request = new UserRegisterRequest( + "testuser1", + "Password1!", + "홍길동", + TEST_BIRTHDAY, + "test@example.com" + ); + + mockMvc.perform(post(BASE_URL + "/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("중복 ID로 회원가입 시 실패") + void register_fail_duplicateId() throws Exception { + var request = new UserRegisterRequest( + "testuser1", + "Password1!", + "홍길동", + TEST_BIRTHDAY, + "test@example.com" + ); + + // 첫 번째 가입 + mockMvc.perform(post(BASE_URL + "/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + + // 동일 ID로 재가입 시도 + mockMvc.perform(post(BASE_URL + "/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("필수 필드 누락 시 실패") + void register_fail_missingFields() throws Exception { + var request = new UserRegisterRequest( + "", + "Password1!", + "홍길동", + TEST_BIRTHDAY, + "test@example.com" + ); + + mockMvc.perform(post(BASE_URL + "/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("잘못된 이메일 형식으로 가입 시 실패") + void register_fail_invalidEmail() throws Exception { + var request = new UserRegisterRequest( + "testuser1", + "Password1!", + "홍길동", + TEST_BIRTHDAY, + "invalid-email" + ); + + mockMvc.perform(post(BASE_URL + "/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + } + + @Nested + @DisplayName("내 정보 조회 API") + class GetMyInfoApi { + + @Test + @DisplayName("내 정보 조회 성공") + void getMyInfo_success() throws Exception { + String loginId = "testuser1"; + String password = "Password1!"; + registerUser(loginId, password, "홍길동"); + + mockMvc.perform(get(BASE_URL + "/me") + .header("X-Loopers-LoginId", loginId) + .header("X-Loopers-LoginPw", password)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.loginId").value(loginId)) + .andExpect(jsonPath("$.name").value("홍길*")) + .andExpect(jsonPath("$.birthday").value("19900515")) + .andExpect(jsonPath("$.email").value("test@example.com")); + } + + @Test + @DisplayName("잘못된 비밀번호로 조회 시 실패") + void getMyInfo_fail_wrongPassword() throws Exception { + String loginId = "testuser1"; + registerUser(loginId, "Password1!", "홍길동"); + + mockMvc.perform(get(BASE_URL + "/me") + .header("X-Loopers-LoginId", loginId) + .header("X-Loopers-LoginPw", "WrongPassword1!")) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("존재하지 않는 사용자 조회 시 실패") + void getMyInfo_fail_userNotFound() throws Exception { + mockMvc.perform(get(BASE_URL + "/me") + .header("X-Loopers-LoginId", "notexist") + .header("X-Loopers-LoginPw", "Password1!")) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("2자 이름 마스킹 확인") + void getMyInfo_maskedName_2chars() throws Exception { + String loginId = "testuser1"; + String password = "Password1!"; + registerUser(loginId, password, "홍길"); + + mockMvc.perform(get(BASE_URL + "/me") + .header("X-Loopers-LoginId", loginId) + .header("X-Loopers-LoginPw", password)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("홍*")); + } + } + + @Nested + @DisplayName("비밀번호 변경 API") + class UpdatePasswordApi { + + @Test + @DisplayName("비밀번호 변경 성공") + void updatePassword_success() throws Exception { + String loginId = "testuser1"; + String currentPassword = "Password1!"; + String newPassword = "NewPassword1!"; + registerUser(loginId, currentPassword, "홍길동"); + + var request = new PasswordUpdateRequest(currentPassword, newPassword); + + mockMvc.perform(put(BASE_URL + "/me/password") + .header("X-Loopers-LoginId", loginId) + .header("X-Loopers-LoginPw", currentPassword) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + + // 변경된 비밀번호로 조회 확인 + mockMvc.perform(get(BASE_URL + "/me") + .header("X-Loopers-LoginId", loginId) + .header("X-Loopers-LoginPw", newPassword)) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("현재 비밀번호 불일치 시 실패") + void updatePassword_fail_wrongCurrentPassword() throws Exception { + String loginId = "testuser1"; + registerUser(loginId, "Password1!", "홍길동"); + + var request = new PasswordUpdateRequest("WrongPassword1!", "NewPassword1!"); + + mockMvc.perform(put(BASE_URL + "/me/password") + .header("X-Loopers-LoginId", loginId) + .header("X-Loopers-LoginPw", "Password1!") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("동일한 비밀번호로 변경 시 실패") + void updatePassword_fail_samePassword() throws Exception { + String loginId = "testuser1"; + String password = "Password1!"; + registerUser(loginId, password, "홍길동"); + + var request = new PasswordUpdateRequest(password, password); + + mockMvc.perform(put(BASE_URL + "/me/password") + .header("X-Loopers-LoginId", loginId) + .header("X-Loopers-LoginPw", password) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("인증 실패 시 비밀번호 변경 불가") + void updatePassword_fail_authenticationFailed() throws Exception { + String loginId = "testuser1"; + registerUser(loginId, "Password1!", "홍길동"); + + var request = new PasswordUpdateRequest("Password1!", "NewPassword1!"); + + mockMvc.perform(put(BASE_URL + "/me/password") + .header("X-Loopers-LoginId", loginId) + .header("X-Loopers-LoginPw", "WrongPassword1!") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + } + + private void registerUser(String loginId, String password, String name) throws Exception { + var request = new UserRegisterRequest( + loginId, + password, + name, + TEST_BIRTHDAY, + "test@example.com" + ); + + mockMvc.perform(post(BASE_URL + "/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + } +} From 85ec4ab1cda898e2936e683e43d91bc65c32a9e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=84=EC=9D=B8=EA=B5=AD?= Date: Thu, 12 Feb 2026 18:19:09 +0900 Subject: [PATCH 2/8] =?UTF-8?q?refactor:=20UserRegisterService=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=A0=88=EC=9D=B4=EC=96=B4=20=EA=B0=84=20=EC=B1=85?= =?UTF-8?q?=EC=9E=84=20=EB=B6=84=EB=A6=AC=20=EC=95=88=EB=90=98=EC=96=B4=20?= =?UTF-8?q?=EC=9E=88=EB=8A=94=EA=B2=83=20-->=20=EC=9D=B8=ED=94=84=EB=9D=BC?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=B0=9C=EC=83=9D=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=EB=8A=94=20=EC=9D=B8=ED=94=84=EB=9D=BC=20?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=96=B4=EA=B0=80=20=EC=B1=85=EC=9E=84?= =?UTF-8?q?=EC=A7=80=EA=B3=A0,=20=EB=8F=84=EB=A9=94=EC=9D=B8/=EC=95=A0?= =?UTF-8?q?=ED=94=8C=EB=A6=AC=EC=BC=80=EC=9D=B4=EC=85=98=EC=97=90=EB=8A=94?= =?UTF-8?q?=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=96=B8=EC=96=B4=EB=A1=9C=20?= =?UTF-8?q?=EB=90=9C=20=EC=98=88=EC=99=B8=EB=A7=8C=20=EC=A0=84=ED=8C=8C?= =?UTF-8?q?=ED=95=98=EB=9D=BC=20=EB=9D=BC=EB=8A=94=20=EC=95=84=ED=82=A4?= =?UTF-8?q?=ED=85=8D=EC=B2=98=EC=9D=98=20=EC=9D=98=EC=A1=B4=EC=84=B1=20?= =?UTF-8?q?=EA=B7=9C=EC=B9=99=EC=9D=84=20=EC=A7=80=ED=82=A4=EB=A0=A4?= =?UTF-8?q?=EA=B3=A0=20=EB=B3=80=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/UserRegisterService.java | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/service/UserRegisterService.java b/apps/commerce-api/src/main/java/com/loopers/application/service/UserRegisterService.java index 5766561c..4c0f10eb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/service/UserRegisterService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/service/UserRegisterService.java @@ -32,19 +32,19 @@ public void register(String loginId, String name, String rawPassword, LocalDate Password password = Password.of(rawPassword, birthday); String encodedPassword = passwordEncoder.encrypt(password.getValue()); - try { - User user = User.register( - userId, - userName, - encodedPassword, - birth, - userEmail, - WrongPasswordCount.init(), - LocalDateTime.now() - ); - userRepository.save(user); - } catch (DataIntegrityViolationException ex) { - throw new IllegalArgumentException("이미 사용중인 ID 입니다.", ex); + if (userRepository.existsById(userId)) { + throw new IllegalArgumentException("이미 사용중인 ID 입니다."); } + + User user = User.register( + userId, + userName, + encodedPassword, + birth, + userEmail, + WrongPasswordCount.init(), + LocalDateTime.now() + ); + userRepository.save(user); } } From 8954c03297717631ce466a2c04ee0386836cef7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=84=EC=9D=B8=EA=B5=AD?= Date: Thu, 12 Feb 2026 18:37:03 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat=20:=20docs:=20=EC=84=A4=EA=B3=84=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EC=8B=A4=EC=A0=9C=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EC=9E=91=EC=84=B1=20=EB=B0=8F=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 01-requirements: 엔드포인트, ID/PW 규칙, 에러 응답을 실제 코드에 맞게 수정 - 02-sequence-diagrams: JWT 기반 → 헤더 기반 인증으로 시퀀스 다이어그램 재작성 - 03-class-diagram: User 도메인 클래스 다이어그램 작성 및 향후 도메인(Brand/Product/Like/Coupon/Order) 설계 추가 - 04-erd: 현재 users 테이블 ERD 작성 및 향후 확장 ERD(10개 테이블) 설계 추가 --- .docs/design/01-requirements.md | 178 +++++ .docs/design/02-sequence-diagrams.md | 321 ++++++++ .docs/design/03-class-diagram.md | 1067 ++++++++++++++++++++++++++ .docs/design/04-erd.md | 228 ++++++ 4 files changed, 1794 insertions(+) create mode 100644 .docs/design/01-requirements.md create mode 100644 .docs/design/02-sequence-diagrams.md create mode 100644 .docs/design/03-class-diagram.md create mode 100644 .docs/design/04-erd.md diff --git a/.docs/design/01-requirements.md b/.docs/design/01-requirements.md new file mode 100644 index 00000000..87db08e8 --- /dev/null +++ b/.docs/design/01-requirements.md @@ -0,0 +1,178 @@ +# 프로젝트: 감성 이커머스 (Emotional E-commerce) +## 기능 정의 및 요구사항 명세서 (Requirement Specification) + +- **버전**: v1.0 +- **작성일**: 2026-02-12 +- **문서 개요**: 본 문서는 유저 시나리오를 기반으로 서비스의 핵심 기능, API 인터페이스, 데이터 제약사항 및 예외 처리 기준을 정의한다. + +--- + +## 1. 개요 및 시나리오 (Overview) + +### 1.1 서비스 목표 +내가 좋아하는 브랜드의 상품을 탐색하고 좋아요를 누르며, 쿠폰을 통해 합리적으로 구매하는 감성 커머스 플랫폼. 유저의 행동 데이터는 추후 랭킹과 추천 시스템의 기반이 된다. + +### 1.2 유저 시나리오 (User Journey) +1. **진입**: 유저는 회원가입을 하고 로그인을 통해 본인을 식별한다. +2. **탐색**: 브랜드와 상품을 둘러보고, 마음에 드는 상품에 '좋아요'를 누른다. +3. **혜택**: 주문 전 사용 가능한 쿠폰을 발급받는다. +4. **구매**: 장바구니 혹은 바로 구매를 통해 상품을 주문하고 결제한다. +5. **관리**: 내 주문 내역을 확인하고, 배송 상태를 조회하거나 영수증을 발급받는다. + +--- + +## 2. 공통 아키텍처 및 규칙 (General Rules) + +### 2.1 API Endpoint 규칙 +* **User (대고객)**: `/api/v1` +* **Admin (관리자)**: `/api-admin/v1` + +### 2.2 인증 및 식별 (Authentication) +표준 인증 방식 대신, 아래 커스텀 헤더를 통해 요청자를 식별한다. + +| 구분 | Header Key | 설명 | 비고 | +| :--- | :--- | :--- | :--- | +| **User** | `X-Loopers-LoginId` | 로그인 ID | | +| **User** | `X-Loopers-LoginPw` | 비밀번호 | 평문 전송 (Test Scope) | +| **Admin** | `X-Loopers-Ldap` | LDAP 식별자 | 값: `loopers.admin` | + +### 2.3 보안 및 접근 제어 +* 유저는 **타 유저의 정보**에 절대 접근할 수 없다. +* 관리자 API는 일반 유저가 호출할 수 없다. + +--- + +## 3. 상세 기능 명세 (Detailed Specifications) + +### 3.1 사용자 (User) + +| Role | Method | Endpoint | 기능 | 상세 로직 및 제약사항 | +| :--- | :---: | :--- | :--- | :--- | +| Guest | `POST` | `/users/register` | **회원가입** | ID 중복 체크 필수 | +| User | `GET` | `/users/me` | **내 정보 조회** | 이름 마스킹 처리 | +| User | `PUT` | `/users/me/password` | **비밀번호 변경** | 기존 비밀번호 확인 로직 포함 | + +#### 상세 요구사항 +* **회원가입 입력값**: ID, PW, 이름, 생년월일, 이메일 +* **ID 규칙**: 영문 소문자, 숫자만 허용 (4~10자). +* **비밀번호 규칙**: + * 8~16자, 영문 대소문자/숫자/특수문자 허용. + * 생년월일은 비밀번호에 포함될 수 없음. +* **정보 조회 마스킹**: 이름의 마지막 글자를 `*`로 처리 (예: `홍길동` -> `홍길*`). +* **비밀번호 변경**: `{기존 PW, 새 PW}` 입력. 새 PW는 기존 PW와 달라야 함. + +--- + +### 3.2 상품 & 브랜드 (Product & Brand) + +| Role | Method | Endpoint | 기능 | 상세 로직 및 제약사항 | +| :--- | :---: | :--- | :--- | :--- | +| Any | `GET` | `/brands/{brandId}` | **브랜드 조회** | 브랜드 정보 반환 | +| Any | `GET` | `/products` | **상품 목록** | 필터, 정렬, 페이징 | +| Any | `GET` | `/products/{productId}` | **상품 상세** | | + +#### 상세 요구사항 +* **목록 조회 쿼리 파라미터**: + * `brandId`: 특정 브랜드 필터링 + * `sort`: `latest`(기본), `price_asc`(가격낮은순), `likes_desc`(좋아요순) + * `page`: 0부터 시작 (Default 0), `size`: 20 (Default 20) + +--- + +### 3.3 좋아요 (Like) + +| Role | Method | Endpoint | 기능 | 상세 로직 및 제약사항 | +| :--- | :---: | :--- | :--- | :--- | +| User | `POST` | `/products/{id}/likes` | **좋아요 등록** | Idempotency 보장 | +| User | `DELETE` | `/products/{id}/likes` | **좋아요 취소** | | +| User | `GET` | `/users/me/likes` | **좋아요 목록** | 필터링 지원 | + +#### 상세 요구사항 +* **제약**: 유저당 1개의 상품에 1번만 좋아요 가능. +* **목록 필터**: `sale_yn` (세일중), `status` (판매중/품절제외). +* **목록 정렬**: 날짜순, 가격순, 할인율순, 브랜드명순. +* **표시 정보**: 품절 여부(Dimmed), 세일 뱃지 등 UI 상태값 포함. + +--- + +### 3.4 쿠폰 (Coupon) + +| Role | Method | Endpoint | 기능 | 상세 로직 및 제약사항 | +| :--- | :---: | :--- | :--- | :--- | +| User | `POST` | `/coupons/{id}/issue` | **쿠폰 발급** | 선착순/중복 방지 | +| User | `GET` | `/users/me/coupons` | **내 쿠폰 조회** | 사용 가능 여부 포함 | + +#### 상세 요구사항 +* **발급 제약**: 유저당 1회 제한. 전체 발행량 소진 시 발급 불가(`Sold Out`). +* **조회 정보**: 쿠폰명, 할인금액(율), 최소주문금액, 만료일, 상태(사용가능/사용완료/만료). + +--- + +### 3.5 주문 (Order) + +| Role | Method | Endpoint | 기능 | 상세 로직 및 제약사항 | +| :--- | :---: | :--- | :--- | :--- | +| User | `POST` | `/orders` | **주문 요청** | 트랜잭션 처리 필수 | +| User | `GET` | `/orders/me` | **내 주문 목록** | 기간 조회 | +| User | `GET` | `/orders/{id}` | **주문 상세** | 영수증 데이터 포함 | + +#### 상세 요구사항 +1. **주문 요청**: + * **프로세스**: 재고 확인 -> 재고 차감 -> 쿠폰 적용 -> 결제 금액 검증 -> 주문 생성. + * **스냅샷(Snapshot)**: 주문 시점의 상품명, 가격을 별도 저장 (상품 정보 변경 영향 없음). + * **입력값**: 배송지(이름/주소/요청사항), 결제수단, 도착희망일. +2. **주문 상태 및 액션**: + * `결제완료/상품준비중`: 주문 취소 가능, 배송지 변경 가능. + * `배송중/배송완료`: 주문 취소 불가(반품 절차), 배송지 변경 불가. +3. **영수증 (Receipt)**: + * **카드영수증**: 상점정보, 결제일시, 금액. + * **거래명세서**: 공급자/공급받는자(유저 입력 가능) 정보, 품목, 세액, 비고 포함. + +--- + +### 3.6 관리자 기능 (Admin) + +| Role | Method | Endpoint | 기능 | 상세 로직 및 제약사항 | +| :--- | :---: | :--- | :--- | :--- | +| Admin | `GET` | `/api-admin/v1/brands` | 브랜드 목록 | | +| Admin | `POST` | `/api-admin/v1/brands` | 브랜드 등록 | | +| Admin | `PUT` | `/api-admin/v1/brands/{id}` | 브랜드 수정 | | +| Admin | `DELETE`| `/api-admin/v1/brands/{id}` | **브랜드 삭제** | **[Cascade]** 하위 상품 일괄 삭제 | +| Admin | `POST` | `/api-admin/v1/products` | **상품 등록** | 등록된 브랜드 ID만 허용 | +| Admin | `PUT` | `/api-admin/v1/products/{id}`| **상품 수정** | **[Immutable]** 브랜드 변경 불가 | +| Admin | `DELETE`| `/api-admin/v1/products/{id}`| 상품 삭제 | Soft Delete 권장 | +| Admin | `GET` | `/api-admin/v1/orders` | 주문 목록 | 전체 유저 주문 조회 | + +--- + +## 4. 예외 처리 및 에러 표준 + +### 4.1 에러 응답 포맷 (JSON) +모든 API는 실패 시 아래 포맷을 준수한다. +```json +{ + "code": "BAD_REQUEST", + "message": "사용자에게 노출될 상세 에러 메시지" +} +``` +### 4.2 HTTP 상태 코드 +- 200 OK: 요청 성공. + +- 400 Bad Request: 필수 파라미터 누락, 유효성 검사 실패 (PW 규칙 위반 등), 인증 실패 (ID/PW 불일치), 필수 헤더 누락. + +- 404 Not Found: 존재하지 않는 리소스 (상품, 브랜드, 주문 등). + +- 409 Conflict: 비즈니스 로직 충돌 (이미 좋아요 누름, 재고 부족, 이미 발급된 쿠폰). + +- 500 Internal Server Error: 서버 내부 오류. + +### 4.3 주요 에러 코드 정의 + +| 코드 | HTTP 상태 | 설명 | +| :--- | :---: | :--- | +| `BAD_REQUEST` | 400 | 유효성 검사 실패, 인증 실패, ID 중복 등 | +| `VALIDATION_ERROR` | 400 | DTO `@Valid` 어노테이션 검증 실패 | +| `MISSING_HEADER` | 400 | 필수 헤더 누락 (`X-Loopers-LoginId` 등) | +| `Not Found` | 404 | 존재하지 않는 리소스 | +| `Conflict` | 409 | 비즈니스 로직 충돌 (리소스 중복 등) | +| `INTERNAL_ERROR` | 500 | 서버 내부 오류 | \ No newline at end of file diff --git a/.docs/design/02-sequence-diagrams.md b/.docs/design/02-sequence-diagrams.md new file mode 100644 index 00000000..4ff8d386 --- /dev/null +++ b/.docs/design/02-sequence-diagrams.md @@ -0,0 +1,321 @@ +# 5. 시스템 시퀀스 다이어그램 (System Sequence Diagrams) + +모든 핵심 기능(회원가입, 인증, 조회, 어드민 등록)에 대해 **객체의 역할과 책임(Responsibility)**이 명확히 드러나도록 시퀀스 다이어그램을 작성했습니다. + +단순한 `Service` 하나가 모든 일을 다 하는 것이 아니라, **인증(Authentication), 값 객체 검증(Value Object), 암호화(Encoder), 조회(Query)** 등의 책임이 분리된 구조입니다. + +| Flow | 핵심 책임 | +|------|-----------| +| User Flow | 회원가입, 헤더 기반 인증, 정보 조회, 비밀번호 변경 | +| Read Flow | 데이터 조회와 DTO 변환 | +| Write Flow (Admin) | 권한 체크와 데이터 무결성(참조 관계) | +| Order Flow | 재고/결제/스냅샷의 트랜잭션 | +| Coupon Flow | 동시성 제어(선착순) | + +--- + +## 5-1. 회원 기능 (User Flow) + +**핵심 책임 객체:** + +| 객체 | 책임 | +|------|------| +| `UserController` | HTTP 요청 수신 및 UseCase 위임 | +| `UserRegisterService` | 회원가입 오케스트레이션 (값 객체 검증, 중복 확인, 암호화, 저장) | +| `AuthenticationService` | 헤더 기반 인증 (사용자 조회, 비밀번호 매칭) | +| `UserQueryService` | 사용자 정보 조회 및 이름 마스킹 | +| `PasswordUpdateService` | 비밀번호 변경 (기존 검증, 신규 검증, 암호화, 저장) | +| `PasswordEncoder` | 비밀번호 암호화 및 매칭 (SHA-256) | +| `UserRepository` | 중복 ID 체크 및 사용자 영속화 | + +### Scenario 1 — 회원가입 (Register) + +```mermaid +sequenceDiagram + autonumber + actor User as 👤 User + participant API as 🌐 UserController + participant Service as 📦 UserRegisterService + participant VO as 🔒 Value Objects + participant Encoder as 🛡️ PasswordEncoder + participant DB as 💾 UserRepository + + User->>API: POST /api/v1/users/register (loginId, password, name, birthday, email) + API->>Service: register(loginId, name, rawPassword, birthday, email) + + rect rgb(240, 248, 255) + Note right of Service: [책임 1] 값 객체 검증 + Service->>VO: UserId.of(loginId), UserName.of(name), Birthday.of(birthday), Email.of(email), Password.of(rawPassword, birthday) + alt 검증 실패 (형식 불일치, 생년월일 포함 등) + VO-->>Service: throw IllegalArgumentException + Service-->>API: 예외 전파 + API-->>User: 400 Bad Request + else 검증 통과 + VO-->>Service: Value Objects + end + end + + rect rgb(255, 240, 245) + Note right of Service: [책임 2] 중복 확인 + Service->>DB: existsById(userId) + alt ID 중복 + DB-->>Service: true + Service-->>API: throw IllegalArgumentException("이미 사용중인 ID 입니다.") + API-->>User: 400 Bad Request + else ID 사용 가능 + DB-->>Service: false + end + end + + rect rgb(255, 250, 205) + Note right of Service: [책임 3] 암호화 + Service->>Encoder: encrypt(rawPassword) + Encoder-->>Service: salt:hashedPassword + end + + rect rgb(240, 255, 240) + Note right of Service: [책임 4] 도메인 객체 생성 및 저장 + Service->>Service: User.register(userId, userName, encodedPassword, birth, email, wrongPasswordCount, now) + Service->>DB: save(User) + DB-->>Service: User + end + + Service-->>API: void + API-->>User: 200 OK +``` + +### Scenario 2 — 내 정보 조회 (Get My Info) + +```mermaid +sequenceDiagram + autonumber + actor User as 👤 User + participant API as 🌐 UserController + participant Auth as 🔐 AuthenticationService + participant Query as 🔍 UserQueryService + participant Encoder as 🛡️ PasswordEncoder + participant DB as 💾 UserRepository + + User->>API: GET /api/v1/users/me (Header: X-Loopers-LoginId, X-Loopers-LoginPw) + + alt 필수 헤더 누락 + API-->>User: 400 Bad Request ("필수 헤더가 누락되었습니다") + end + + rect rgb(255, 230, 230) + Note right of API: [책임 1] 헤더 기반 인증 + API->>Auth: authenticate(userId, rawPassword) + Auth->>DB: findById(userId) + alt 유저 없음 + DB-->>Auth: Optional.empty() + Auth-->>API: throw IllegalArgumentException("사용자를 찾을 수 없습니다.") + API-->>User: 400 Bad Request + else 유저 존재 + DB-->>Auth: User + end + Auth->>Encoder: matches(rawPassword, encodedPassword) + alt 비밀번호 불일치 + Encoder-->>Auth: false + Auth-->>API: throw IllegalArgumentException("비밀번호가 일치하지 않습니다.") + API-->>User: 400 Bad Request + else 비밀번호 일치 + Encoder-->>Auth: true + end + end + + rect rgb(240, 248, 255) + Note right of API: [책임 2] 사용자 정보 조회 + API->>Query: getUserInfo(userId) + Query->>DB: findById(userId) + DB-->>Query: User + Note right of Query: 이름 마스킹: "홍길동" → "홍길*" + Query-->>API: UserInfoResponse(loginId, maskedName, birthday, email) + end + + API->>API: UserInfoResponse.from(userInfo) — birthday → "yyyyMMdd" 포맷 + API-->>User: 200 OK (JSON) +``` + +### Scenario 3 — 비밀번호 변경 (Update Password) + +```mermaid +sequenceDiagram + autonumber + actor User as 👤 User + participant API as 🌐 UserController + participant Auth as 🔐 AuthenticationService + participant Service as 🔑 PasswordUpdateService + participant VO as 🔒 Value Objects + participant Encoder as 🛡️ PasswordEncoder + participant DB as 💾 UserRepository + + User->>API: PUT /api/v1/users/me/password (Header: X-Loopers-LoginId, X-Loopers-LoginPw, Body: currentPassword, newPassword) + + rect rgb(255, 230, 230) + Note right of API: [책임 1] 헤더 기반 인증 + API->>Auth: authenticate(userId, rawPassword) + Auth->>DB: findById(userId) + Auth->>Encoder: matches(rawPassword, encodedPassword) + alt 인증 실패 + Auth-->>API: throw IllegalArgumentException + API-->>User: 400 Bad Request + end + end + + API->>Service: updatePassword(userId, currentPassword, newPassword) + + rect rgb(240, 248, 255) + Note right of Service: [책임 2] 사용자 조회 및 비밀번호 값 객체 검증 + Service->>DB: findById(userId) + DB-->>Service: User + Service->>VO: Password.of(currentRawPassword, birthday), Password.of(newRawPassword, birthday) + alt 비밀번호 형식 오류 또는 생년월일 포함 + VO-->>Service: throw IllegalArgumentException + Service-->>API: 예외 전파 + API-->>User: 400 Bad Request + end + end + + rect rgb(255, 240, 245) + Note right of Service: [책임 3] 비밀번호 검증 + Service->>Encoder: matches(currentPassword, encodedPassword) + alt 현재 비밀번호 불일치 + Encoder-->>Service: false + Service-->>API: throw IllegalArgumentException("현재 비밀번호가 일치하지 않습니다.") + API-->>User: 400 Bad Request + end + + Service->>Encoder: matches(newPassword, encodedPassword) + alt 새 비밀번호가 기존과 동일 + Encoder-->>Service: true + Service-->>API: throw IllegalArgumentException("현재 비밀번호는 사용할 수 없습니다.") + API-->>User: 400 Bad Request + end + end + + rect rgb(240, 255, 240) + Note right of Service: [책임 4] 암호화 및 저장 + Service->>Encoder: encrypt(newPassword) + Encoder-->>Service: salt:hashedPassword + Service->>Service: user.changePassword(encodedNewPassword) + Service->>DB: save(updatedUser) + DB-->>Service: User + end + + Service-->>API: void + API-->>User: 200 OK +``` + +--- + +## 5-2. 브랜드 및 상품 조회 (Public Read Flow) + +**핵심 책임 객체:** + +| 객체 | 책임 | +|------|------| +| `QueryHandler` | 복잡한 검색/필터링 쿼리 처리 (QueryDSL 등) | +| `DtoMapper` | 엔티티 → API 응답 객체 변환 (민감 정보 제외, 포맷팅) | + +```mermaid +sequenceDiagram + autonumber + actor User as 👤 User + participant API as 🌐 ProductController + participant Service as 🛍️ ProductService + participant Query as 🔍 QueryHandler + participant Mapper as 🎨 DtoMapper + participant DB as 💾 Repository + + Note over User, API: 인증 불필요 (Public API) + + User->>API: GET /products?brandId=1&sort=latest&page=0 + API->>Service: getProductList(filterCondition) + + rect rgb(240, 248, 255) + Note right of Service: [책임 1] 데이터 조회 + Service->>Query: search(brandId, sort, page) + Query->>DB: Dynamic Select Query + DB-->>Query: List + Query-->>Service: List + end + + rect rgb(255, 240, 245) + Note right of Service: [책임 2] 응답 데이터 가공 + Service->>Mapper: toSummaryDtoList(entities) + Note right of Mapper: 품절 여부 계산, 이미지 URL 매핑 + Mapper-->>Service: List + end + + Service-->>API: PageResponse + API-->>User: 200 OK (JSON) +``` + +--- + +## 5-3. 브랜드 및 상품 등록 (Admin Write Flow) + +**핵심 책임 객체:** + +| 객체 | 책임 | +|------|------| +| `AdminGuard` | 관리자 권한 및 헤더 검증 (AOP/Interceptor) | +| `ImageUploader` | 이미지 파일 외부 저장소 업로드 (S3 등) | +| `CatalogService` | 브랜드 유효성 검증 및 상품 등록 오케스트레이션 | + +```mermaid +sequenceDiagram + autonumber + actor Admin as 👨‍💼 Admin + participant API as 🌐 AdminController + participant Guard as 👮 AdminGuard + participant Service as 📦 CatalogService + participant Uploader as ☁️ ImageUploader + participant DB as 💾 Repository + + Note over Admin, API: Header: X-Loopers-Ldap + + Admin->>API: POST /admin/products (Info, Images, BrandId) + + rect rgb(255, 230, 230) + Note right of API: [책임 1] 관리자 권한 검증 + API->>Guard: checkAdminHeader(request) + alt 권한 없음 + Guard-->>API: throw UnauthorizedException + API-->>Admin: 403 Forbidden + else 권한 확인됨 + Guard-->>API: AdminInfo + end + end + + API->>Service: registerProduct(dto, files) + + rect rgb(240, 248, 255) + Note right of Service: [책임 2] 비즈니스 유효성 검사 + Service->>DB: existsBrand(brandId) + alt 브랜드 없음 + DB-->>Service: false + Service-->>API: throw InvalidBrandException + API-->>Admin: 400 Bad Request + else 브랜드 존재 + DB-->>Service: true + end + end + + rect rgb(255, 250, 205) + Note right of Service: [책임 3] 리소스(이미지) 처리 + Service->>Uploader: uploadImages(files) + Uploader-->>Service: List + end + + rect rgb(240, 255, 240) + Note right of Service: [책임 4] 데이터 영속화 + Service->>DB: save(ProductEntity + ImageEntities) + DB-->>Service: Product ID + end + + Service-->>API: ProductResponse + API-->>Admin: 201 Created +``` + +--- \ No newline at end of file diff --git a/.docs/design/03-class-diagram.md b/.docs/design/03-class-diagram.md new file mode 100644 index 00000000..22471ecc --- /dev/null +++ b/.docs/design/03-class-diagram.md @@ -0,0 +1,1067 @@ +# 6. 도메인 객체 설계 (Class Diagram) + +클린 아키텍처(Clean Architecture) 기반으로 **도메인 계층이 어떤 외부 기술에도 의존하지 않도록** 설계했습니다. + +각 계층의 의존 방향은 항상 **바깥 → 안쪽**이며, 도메인 계층은 순수 Java 객체로만 구성됩니다. + +``` +Interfaces → Application → Domain ← Infrastructure +``` + +--- + +## 6-1. 전체 아키텍처 계층 구조 + +```mermaid +classDiagram + direction TB + + namespace Interfaces { + class UserController + class UserRegisterRequest + class UserInfoResponse + class PasswordUpdateRequest + } + + namespace Application { + class RegisterUseCase + class AuthenticationUseCase + class UserQueryUseCase + class PasswordUpdateUseCase + class UserRegisterService + class AuthenticationService + class UserQueryService + class PasswordUpdateService + } + + namespace Domain { + class User + class UserId + class UserName + class Password + class Email + class Birthday + class WrongPasswordCount + class PasswordEncoder + class UserRepository + } + + namespace Infrastructure { + class UserRepositoryImpl + class UserJpaRepository + class UserJpaEntity + class Sha256PasswordEncoder + } + + UserController --> RegisterUseCase + UserController --> AuthenticationUseCase + UserController --> UserQueryUseCase + UserController --> PasswordUpdateUseCase + + UserRegisterService ..|> RegisterUseCase + AuthenticationService ..|> AuthenticationUseCase + UserQueryService ..|> UserQueryUseCase + PasswordUpdateService ..|> PasswordUpdateUseCase + + UserRegisterService --> UserRepository + UserRegisterService --> PasswordEncoder + AuthenticationService --> UserRepository + AuthenticationService --> PasswordEncoder + UserQueryService --> UserRepository + PasswordUpdateService --> UserRepository + PasswordUpdateService --> PasswordEncoder + + UserRepositoryImpl ..|> UserRepository + Sha256PasswordEncoder ..|> PasswordEncoder + UserRepositoryImpl --> UserJpaRepository +``` + +--- + +## 6-2. Domain 계층 — Aggregate Root & Value Objects + +도메인 모델은 **불변(Immutable) 객체** 기반으로 설계했습니다. + +`User`는 Aggregate Root이며, 모든 필드는 값 객체(Value Object)로 감싸서 **자기 검증(Self-Validating)** 책임을 가집니다. + +```mermaid +classDiagram + class User { + -Long id + -UserId userId + -UserName userName + -String encodedPassword + -Birthday birth + -Email email + -WrongPasswordCount wrongPasswordCount + -LocalDateTime createdAt + +register(userId, userName, encodedPassword, birth, email, wrongPasswordCount, createdAt)$ User + +reconstitute(id, userId, userName, encodedPassword, birth, email, wrongPasswordCount, createdAt)$ User + +matchesPassword(Password, PasswordMatchChecker) boolean + +changePassword(String newEncodedPassword) User + } + + class PasswordMatchChecker { + <> + +matches(Password, String encodingPassword) boolean + } + + class UserId { + -String value + -Pattern PATTERN = "^[a-z0-9]{4,10}$" + +of(String value)$ UserId + } + + class UserName { + -String value + -Pattern PATTERN = "^[a-zA-Z0-9가-힣]{2,20}$" + +of(String value)$ UserName + } + + class Password { + -String value + +of(String rawPassword, LocalDate birthday)$ Password + +containsBirthday(String, LocalDate)$ boolean + } + + class Email { + -String value + -Pattern PATTERN = "^[a-zA-Z0-9]+@..." + +of(String value)$ Email + } + + class Birthday { + -LocalDate value + +of(LocalDate value)$ Birthday + } + + class WrongPasswordCount { + -int value + +init()$ WrongPasswordCount + +of(int value)$ WrongPasswordCount + +increment() WrongPasswordCount + +reset() WrongPasswordCount + +isLocked() boolean + } + + User *-- UserId + User *-- UserName + User *-- Birthday + User *-- Email + User *-- WrongPasswordCount + User ..> Password : matchesPassword()에서 사용 + User +-- PasswordMatchChecker +``` + +### 값 객체 검증 규칙 + +| Value Object | 검증 규칙 | 예외 메시지 | +|---|---|---| +| `UserId` | 4~10자, 영문 소문자+숫자만 | `로그인 ID는 4~10자의 영문 소문자, 숫자만 가능합니다.` | +| `UserName` | 2~20자, 한글/영문/숫자 | `이름은 2~20자의 한글 또는 영문만 가능합니다.` | +| `Password` | 8~16자, 영문+숫자+특수문자, 생년월일 포함 불가 | `비밀번호는 8~16자리 영문 대소문자, 숫자, 특수문자만 가능합니다.` | +| `Email` | 이메일 형식 정규식 검증 | `올바른 이메일 형식이 아닙니다` | +| `Birthday` | not null, 미래 날짜 불가, 1900년 이후 | `생년월일은 미래 날짜일 수 없습니다.` | +| `WrongPasswordCount` | 음수 불가, 5회 이상이면 잠금 | `비밀번호 오류 횟수는 음수일 수 없습니다.` | + +### 설계 결정 + +- **`User.register()`**: id = null로 생성 (영속화 전 신규 객체) +- **`User.reconstitute()`**: DB에서 복원할 때 사용 (id 포함) +- **`User.changePassword()`**: 새로운 User 인스턴스 반환 (불변성 유지) +- **`PasswordMatchChecker`**: 함수형 인터페이스로 도메인이 암호화 구현에 의존하지 않도록 설계 + +--- + +## 6-3. Domain 계층 — Repository & Domain Service 인터페이스 + +도메인 계층은 인터페이스만 정의하고, 구현은 Infrastructure 계층에 위임합니다 (의존성 역전 원칙). + +```mermaid +classDiagram + class UserRepository { + <> + +save(User) User + +findById(UserId) Optional~User~ + +existsById(UserId) boolean + } + + class PasswordEncoder { + <> + +encrypt(String rawPassword) String + +matches(String rawPassword, String encodedPassword) boolean + } + + UserRepository ..> User + UserRepository ..> UserId +``` + +--- + +## 6-4. Application 계층 — UseCase 인터페이스 & Service 구현 + +UseCase 인터페이스는 Controller가 의존하는 **입력 포트(Input Port)** 역할을 합니다. + +각 Service는 **하나의 UseCase만 구현**하여 단일 책임 원칙(SRP)을 따릅니다. + +```mermaid +classDiagram + class RegisterUseCase { + <> + +register(String loginId, String name, String rawPassword, LocalDate birthday, String email) void + } + + class AuthenticationUseCase { + <> + +authenticate(UserId userId, String rawPassword) void + } + + class UserQueryUseCase { + <> + +getUserInfo(UserId userId) UserInfoResponse + } + + class UserInfoResponse { + <> + +String loginId + +String maskedName + +LocalDate birthday + +String email + } + + class PasswordUpdateUseCase { + <> + +updatePassword(UserId userId, String currentRawPassword, String newRawPassword) void + } + + class UserRegisterService { + -UserRepository userRepository + -PasswordEncoder passwordEncoder + +register(loginId, name, rawPassword, birthday, email) void + } + + class AuthenticationService { + -UserRepository userRepository + -PasswordEncoder passwordEncoder + +authenticate(UserId, String rawPassword) void + } + + class UserQueryService { + -UserRepository userRepository + +getUserInfo(UserId) UserInfoResponse + -maskName(String name) String + } + + class PasswordUpdateService { + -UserRepository userRepository + -PasswordEncoder passwordEncoder + +updatePassword(UserId, String currentRawPassword, String newRawPassword) void + } + + UserRegisterService ..|> RegisterUseCase + AuthenticationService ..|> AuthenticationUseCase + UserQueryService ..|> UserQueryUseCase + PasswordUpdateService ..|> PasswordUpdateUseCase + UserQueryUseCase +-- UserInfoResponse +``` + +### Service별 책임 + +| Service | UseCase | 트랜잭션 | 핵심 로직 | +|---|---|---|---| +| `UserRegisterService` | `RegisterUseCase` | `@Transactional` | 값 객체 검증 → 중복 확인 → 암호화 → 저장 | +| `AuthenticationService` | `AuthenticationUseCase` | `@Transactional(readOnly)` | 사용자 조회 → 비밀번호 매칭 | +| `UserQueryService` | `UserQueryUseCase` | `@Transactional(readOnly)` | 사용자 조회 → 이름 마스킹 | +| `PasswordUpdateService` | `PasswordUpdateUseCase` | `@Transactional` | 기존 PW 검증 → 신규 PW 검증 → 암호화 → 저장 | + +--- + +## 6-5. Interfaces 계층 — Controller & DTO + +Controller는 UseCase 인터페이스에만 의존하며, 도메인 객체를 직접 노출하지 않습니다. + +```mermaid +classDiagram + class UserController { + -RegisterUseCase registerUseCase + -UserQueryUseCase userQueryUseCase + -PasswordUpdateUseCase passwordUpdateUseCase + -AuthenticationUseCase authenticationUseCase + +register(UserRegisterRequest) ResponseEntity~Void~ + +getMyInfo(String loginId, String loginPw) ResponseEntity~UserInfoResponse~ + +updatePassword(String loginId, String loginPw, PasswordUpdateRequest) ResponseEntity~Void~ + } + + class UserRegisterRequest { + <> + +String loginId + +String password + +String name + +LocalDate birthday + +String email + } + + class UserInfoResponse { + <> + +String loginId + +String name + +String birthday + +String email + +from(UserQueryUseCase.UserInfoResponse)$ UserInfoResponse + } + + class PasswordUpdateRequest { + <> + +String currentPassword + +String newPassword + } + + UserController ..> UserRegisterRequest + UserController ..> UserInfoResponse + UserController ..> PasswordUpdateRequest +``` + +### API 엔드포인트 매핑 + +| HTTP Method | Path | DTO | 인증 | +|---|---|---|---| +| `POST` | `/api/v1/users/register` | `UserRegisterRequest` (Body) | 불필요 | +| `GET` | `/api/v1/users/me` | `UserInfoResponse` (응답) | `X-Loopers-LoginId`, `X-Loopers-LoginPw` | +| `PUT` | `/api/v1/users/me/password` | `PasswordUpdateRequest` (Body) | `X-Loopers-LoginId`, `X-Loopers-LoginPw` | + +--- + +## 6-6. Infrastructure 계층 — 기술 구현 + +도메인 인터페이스의 실제 구현을 담당합니다. 도메인 계층은 이 계층의 존재를 모릅니다. + +```mermaid +classDiagram + class UserRepositoryImpl { + -UserJpaRepository userJpaRepository + +save(User) User + +findById(UserId) Optional~User~ + +existsById(UserId) boolean + -toEntity(User) UserJpaEntity + -toDomain(UserJpaEntity) User + } + + class UserJpaRepository { + <> + +findByUserId(String userId) Optional~UserJpaEntity~ + +existsByUserId(String userId) boolean + } + + class UserJpaEntity { + -Long id + -String userId + -String encodedPassword + -String username + -LocalDate birthday + -String email + -LocalDateTime createdAt + } + + class Sha256PasswordEncoder { + +encrypt(String rawPassword) String + +matches(String rawPassword, String encodedPassword) boolean + -generateSalt() String + -sha256(String input) String + } + + UserRepositoryImpl ..|> UserRepository : implements + UserRepositoryImpl --> UserJpaRepository + UserRepositoryImpl ..> UserJpaEntity + Sha256PasswordEncoder ..|> PasswordEncoder : implements + UserJpaRepository ..> UserJpaEntity +``` + +### 변환 흐름 (Domain ↔ Infrastructure) + +``` +저장: User → toEntity() → UserJpaEntity → JPA save → UserJpaEntity → toDomain() → User +조회: UserJpaRepository.findByUserId() → UserJpaEntity → toDomain() → User +``` + +### 암호화 형식 (SHA-256 + Salt) + +``` +encrypt("password123") +→ salt = Base64(SecureRandom 16bytes) +→ hash = Base64(SHA-256("password123" + salt)) +→ 저장 형식: "salt:hash" + +matches("password123", "salt:hash") +→ split(":") → salt, storedHash +→ inputHash = SHA-256("password123" + salt) +→ storedHash.equals(inputHash) +``` + +--- + +## 6-7. Support 계층 — 에러 처리 + +```mermaid +classDiagram + class GlobalExceptionHandler { + +handleCoreException(CoreException) ResponseEntity + +handleIllegalArgumentException(IllegalArgumentException) ResponseEntity + +handleValidationException(MethodArgumentNotValidException) ResponseEntity + +handleMissingHeaderException(MissingRequestHeaderException) ResponseEntity + +handleException(Exception) ResponseEntity + } + + class CoreException { + -ErrorType errorType + -String customMessage + } + + class ErrorType { + <> + INTERNAL_ERROR(500) + BAD_REQUEST(400) + NOT_FOUND(404) + CONFLICT(409) + -HttpStatus status + -String code + -String message + } + + GlobalExceptionHandler ..> CoreException + CoreException --> ErrorType +``` + +### 예외 처리 매핑 + +| 예외 | HTTP 상태 | 코드 | 발생 위치 | +|---|---|---|---| +| `IllegalArgumentException` | 400 | `BAD_REQUEST` | Value Object 검증, Service 비즈니스 검증 | +| `MethodArgumentNotValidException` | 400 | `VALIDATION_ERROR` | DTO `@Valid` 어노테이션 검증 | +| `MissingRequestHeaderException` | 400 | `MISSING_HEADER` | 필수 헤더 누락 (`X-Loopers-LoginId` 등) | +| `CoreException` | ErrorType에 따름 | ErrorType.code | 명시적 도메인 예외 | +| `Exception` | 500 | `INTERNAL_ERROR` | 예상치 못한 서버 오류 | + +--- + +## 6-8. 의존성 방향 요약 + +``` +┌─────────────────────────────────────────────────────┐ +│ Interfaces (Controller, DTO) │ +│ └─ 의존 → UseCase 인터페이스 (Application 계층) │ +├─────────────────────────────────────────────────────┤ +│ Application (UseCase, Service) │ +│ └─ 의존 → Domain 인터페이스 (Repository, Encoder) │ +├─────────────────────────────────────────────────────┤ +│ Domain (User, Value Objects, Interface) │ +│ └─ 외부 의존 없음 (순수 Java) │ +├─────────────────────────────────────────────────────┤ +│ Infrastructure (JPA, SHA-256) │ +│ └─ 의존 → Domain 인터페이스를 구현 │ +└─────────────────────────────────────────────────────┘ +``` + +- Domain 계층은 **Spring, JPA, 외부 라이브러리에 의존하지 않음** (Lombok 제외) +- Application 계층은 **Domain 인터페이스에만 의존** (구현체를 모름) +- Infrastructure 계층은 **Domain 인터페이스를 구현**하며 의존 방향을 역전 (DIP) + +--- + +# 향후 확장 도메인 설계 (미래 목표) + +> 아래 내용은 `01-requirements.md`에 정의된 기능 요구사항을 기반으로 설계한 **미래 구현 목표**입니다. +> 현재 구현된 User 도메인과 동일한 클린 아키텍처 패턴을 따릅니다. + +--- + +## 6-9. 전체 도메인 관계도 + +```mermaid +classDiagram + direction TB + + class User { + <> + } + class Brand { + <> + } + class Product { + <> + } + class Like { + <> + } + class Coupon { + <> + } + class UserCoupon { + <> + } + class Order { + <> + } + class OrderItem { + <> + } + class OrderSnapshot { + <> + } + + User "1" --> "*" Like : 좋아요 + User "1" --> "*" UserCoupon : 쿠폰 보유 + User "1" --> "*" Order : 주문 + + Brand "1" --> "*" Product : 보유 상품 + + Product "1" --> "*" Like : 좋아요 대상 + Product "1" --> "*" OrderItem : 주문 항목 + + Coupon "1" --> "*" UserCoupon : 발급 + + Order "1" *-- "*" OrderItem : 주문 상세 + Order "1" *-- "1" OrderSnapshot : 주문 시점 스냅샷 +``` + +--- + +## 6-10. Brand 도메인 + +### Domain 계층 + +```mermaid +classDiagram + class Brand { + <> + -Long id + -BrandName name + -String description + -LocalDateTime createdAt + +register(name, description)$ Brand + +reconstitute(id, name, description, createdAt)$ Brand + +update(BrandName name, String description) Brand + } + + class BrandName { + <> + -String value + +of(String value)$ BrandName + } + + Brand *-- BrandName +``` + +### Application 계층 + +```mermaid +classDiagram + class BrandQueryUseCase { + <> + +getBrand(Long brandId) BrandResponse + } + + class AdminBrandUseCase { + <> + +listBrands() List~BrandResponse~ + +registerBrand(String name, String description) void + +updateBrand(Long id, String name, String description) void + +deleteBrand(Long id) void + } + + class BrandRepository { + <> + +save(Brand) Brand + +findById(Long id) Optional~Brand~ + +findAll() List~Brand~ + +deleteById(Long id) void + } +``` + +### API 엔드포인트 + +| Role | HTTP Method | Path | UseCase | +|---|---|---|---| +| Any | `GET` | `/api/v1/brands/{brandId}` | `BrandQueryUseCase` | +| Admin | `GET` | `/api-admin/v1/brands` | `AdminBrandUseCase` | +| Admin | `POST` | `/api-admin/v1/brands` | `AdminBrandUseCase` | +| Admin | `PUT` | `/api-admin/v1/brands/{id}` | `AdminBrandUseCase` | +| Admin | `DELETE` | `/api-admin/v1/brands/{id}` | `AdminBrandUseCase` — 하위 상품 Cascade 삭제 | + +--- + +## 6-11. Product 도메인 + +### Domain 계층 + +```mermaid +classDiagram + class Product { + <> + -Long id + -Long brandId + -ProductName name + -Money price + -StockQuantity stockQuantity + -String description + -List~ProductImage~ images + -int likeCount + -LocalDateTime createdAt + +register(brandId, name, price, stockQuantity, description, images)$ Product + +reconstitute(...)$ Product + +update(ProductName, Money, StockQuantity, String) Product + +decreaseStock(int quantity) Product + +isOutOfStock() boolean + } + + class ProductName { + <> + -String value + +of(String value)$ ProductName + } + + class Money { + <> + -int value + +of(int value)$ Money + +isDiscounted(Money originalPrice) boolean + +discountRate(Money originalPrice) int + } + + class StockQuantity { + <> + -int value + +of(int value)$ StockQuantity + +decrease(int quantity) StockQuantity + +isZero() boolean + } + + class ProductImage { + <> + -String imageUrl + -int sortOrder + } + + Product *-- ProductName + Product *-- Money + Product *-- StockQuantity + Product *-- "0..*" ProductImage +``` + +### Application 계층 + +```mermaid +classDiagram + class ProductListUseCase { + <> + +getProductList(Long brandId, String sort, int page, int size) PageResponse + } + + class ProductDetailUseCase { + <> + +getProductDetail(Long productId) ProductDetailResponse + } + + class AdminProductUseCase { + <> + +registerProduct(Long brandId, String name, int price, int stock, String desc, List~file~ images) void + +updateProduct(Long id, String name, int price, int stock, String desc) void + +deleteProduct(Long id) void + } + + class ProductRepository { + <> + +save(Product) Product + +findById(Long id) Optional~Product~ + +findByBrandId(Long brandId, String sort, int page, int size) Page~Product~ + +deleteById(Long id) void + +deleteByBrandId(Long brandId) void + } +``` + +### 설계 포인트 + +- **`brandId` 변경 불가**: 상품 수정 시 브랜드 변경 불가 (Immutable 제약) +- **Soft Delete 권장**: 삭제 시 `deletedAt` 설정 (`BaseEntity` 상속) +- **브랜드 삭제 Cascade**: `AdminBrandUseCase.deleteBrand()` 호출 시 `ProductRepository.deleteByBrandId()` 연계 + +### API 엔드포인트 + +| Role | HTTP Method | Path | UseCase | +|---|---|---|---| +| Any | `GET` | `/api/v1/products?brandId=&sort=&page=&size=` | `ProductListUseCase` | +| Any | `GET` | `/api/v1/products/{productId}` | `ProductDetailUseCase` | +| Admin | `POST` | `/api-admin/v1/products` | `AdminProductUseCase` | +| Admin | `PUT` | `/api-admin/v1/products/{id}` | `AdminProductUseCase` | +| Admin | `DELETE` | `/api-admin/v1/products/{id}` | `AdminProductUseCase` | + +### 정렬 옵션 + +| `sort` 값 | 설명 | +|---|---| +| `latest` (기본) | 최신 등록순 | +| `price_asc` | 가격 낮은순 | +| `likes_desc` | 좋아요 많은순 | + +--- + +## 6-12. Like 도메인 + +### Domain 계층 + +```mermaid +classDiagram + class Like { + <> + -Long id + -UserId userId + -Long productId + -LocalDateTime createdAt + +create(userId, productId)$ Like + +reconstitute(id, userId, productId, createdAt)$ Like + } + + class LikeRepository { + <> + +save(Like) Like + +delete(UserId userId, Long productId) void + +findByUserId(UserId userId, LikeFilter filter, String sort, int page, int size) Page~Like~ + +existsByUserIdAndProductId(UserId userId, Long productId) boolean + } + + class LikeFilter { + <> + -Boolean saleYn + -String status + } + + Like *-- UserId + Like ..> LikeFilter : 목록 조회 시 필터 +``` + +### Application 계층 + +```mermaid +classDiagram + class LikeUseCase { + <> + +addLike(UserId userId, Long productId) void + +removeLike(UserId userId, Long productId) void + } + + class LikeQueryUseCase { + <> + +getMyLikes(UserId userId, Boolean saleYn, String status, String sort, int page, int size) PageResponse + } +``` + +### 설계 포인트 + +- **멱등성(Idempotency)**: 이미 좋아요한 상품에 다시 좋아요 → 예외 없이 무시 또는 409 Conflict +- **유저당 1상품 1좋아요**: `UNIQUE(user_id, product_id)` 제약 +- **좋아요 수 동기화**: `Like` 생성/삭제 시 `Product.likeCount` 증감 + +### API 엔드포인트 + +| Role | HTTP Method | Path | UseCase | +|---|---|---|---| +| User | `POST` | `/api/v1/products/{id}/likes` | `LikeUseCase` | +| User | `DELETE` | `/api/v1/products/{id}/likes` | `LikeUseCase` | +| User | `GET` | `/api/v1/users/me/likes` | `LikeQueryUseCase` | + +--- + +## 6-13. Coupon 도메인 + +### Domain 계층 + +```mermaid +classDiagram + class Coupon { + <> + -Long id + -CouponName name + -Money discountAmount + -Money minimumOrderAmount + -int totalQuantity + -int issuedQuantity + -LocalDateTime startAt + -LocalDateTime endAt + -LocalDateTime createdAt + +register(name, discountAmount, minimumOrderAmount, totalQuantity, startAt, endAt)$ Coupon + +reconstitute(...)$ Coupon + +issue() Coupon + +isSoldOut() boolean + +isExpired() boolean + +isAvailable() boolean + } + + class CouponName { + <> + -String value + +of(String value)$ CouponName + } + + class UserCoupon { + <> + -Long id + -UserId userId + -Long couponId + -boolean isUsed + -LocalDateTime issuedAt + -LocalDateTime usedAt + +issue(userId, couponId)$ UserCoupon + +use() UserCoupon + +isAvailable(LocalDateTime now) boolean + } + + class CouponRepository { + <> + +save(Coupon) Coupon + +findById(Long id) Optional~Coupon~ + } + + class UserCouponRepository { + <> + +save(UserCoupon) UserCoupon + +findByUserId(UserId userId) List~UserCoupon~ + +existsByUserIdAndCouponId(UserId userId, Long couponId) boolean + } + + Coupon *-- CouponName + Coupon *-- Money : discountAmount + Coupon *-- Money : minimumOrderAmount + UserCoupon --> Coupon : couponId + UserCoupon *-- UserId +``` + +### Application 계층 + +```mermaid +classDiagram + class CouponIssueUseCase { + <> + +issueCoupon(UserId userId, Long couponId) void + } + + class CouponQueryUseCase { + <> + +getMyCoupons(UserId userId) List~CouponResponse~ + } + + class CouponIssueService { + -CouponRepository couponRepository + -UserCouponRepository userCouponRepository + +issueCoupon(UserId userId, Long couponId) void + } + + CouponIssueService ..|> CouponIssueUseCase +``` + +### 설계 포인트 + +- **선착순 동시성 제어**: `Coupon.issue()` 시 `issuedQuantity` 증가 — 비관적 락 또는 Redis 활용 +- **유저당 1회 발급**: `existsByUserIdAndCouponId()` 체크 → 중복 시 409 Conflict +- **쿠폰 상태**: 사용가능 / 사용완료 / 만료 — `UserCoupon.isAvailable()` 판정 +- **Sold Out**: `totalQuantity <= issuedQuantity` → `COUPON_SOLD_OUT` + +### API 엔드포인트 + +| Role | HTTP Method | Path | UseCase | +|---|---|---|---| +| User | `POST` | `/api/v1/coupons/{id}/issue` | `CouponIssueUseCase` | +| User | `GET` | `/api/v1/users/me/coupons` | `CouponQueryUseCase` | + +--- + +## 6-14. Order 도메인 + +### Domain 계층 + +```mermaid +classDiagram + class Order { + <> + -Long id + -UserId userId + -List~OrderItem~ items + -OrderSnapshot snapshot + -ShippingInfo shippingInfo + -PaymentMethod paymentMethod + -Money totalAmount + -Money discountAmount + -Money paymentAmount + -OrderStatus status + -Long couponId + -LocalDate desiredDeliveryDate + -LocalDateTime createdAt + +create(userId, items, shippingInfo, paymentMethod, couponId, desiredDeliveryDate)$ Order + +reconstitute(...)$ Order + +cancel() Order + +updateShippingInfo(ShippingInfo) Order + +isCancellable() boolean + +isShippingChangeable() boolean + } + + class OrderItem { + <> + -Long id + -Long productId + -int quantity + -Money price + } + + class OrderSnapshot { + <> + -String snapshotData + +capture(List~OrderItem~, List~Product~)$ OrderSnapshot + } + + class ShippingInfo { + <> + -String receiverName + -String address + -String request + } + + class PaymentMethod { + <> + -String type + -String detail + } + + class OrderStatus { + <> + PAYMENT_COMPLETED + PREPARING + SHIPPING + DELIVERED + CANCELLED + +isCancellable() boolean + +isShippingChangeable() boolean + } + + Order *-- "1..*" OrderItem + Order *-- "1" OrderSnapshot + Order *-- "1" ShippingInfo + Order *-- "1" PaymentMethod + Order --> OrderStatus + Order *-- UserId + Order *-- Money : totalAmount + Order *-- Money : discountAmount + Order *-- Money : paymentAmount + OrderItem *-- Money : price +``` + +### Application 계층 + +```mermaid +classDiagram + class OrderCreateUseCase { + <> + +createOrder(UserId, List~OrderItemRequest~, ShippingInfo, PaymentMethod, Long couponId, LocalDate desiredDate) void + } + + class OrderQueryUseCase { + <> + +getMyOrders(UserId userId, LocalDate from, LocalDate to) List~OrderResponse~ + +getOrderDetail(UserId userId, Long orderId) OrderDetailResponse + } + + class AdminOrderQueryUseCase { + <> + +getAllOrders() List~OrderResponse~ + } + + class OrderCreateService { + -OrderRepository orderRepository + -ProductRepository productRepository + -CouponRepository couponRepository + -UserCouponRepository userCouponRepository + +createOrder(...) void + } + + OrderCreateService ..|> OrderCreateUseCase +``` + +### 주문 생성 프로세스 + +``` +1. 재고 확인 → Product.isOutOfStock() 체크 +2. 재고 차감 → Product.decreaseStock(quantity) +3. 쿠폰 적용 → UserCoupon.use(), 할인 금액 계산 +4. 결제 금액 검증 → totalAmount - discountAmount = paymentAmount +5. 스냅샷 생성 → OrderSnapshot.capture() — 주문 시점 상품 정보 보존 +6. 주문 생성 → Order.create() + OrderItems +``` + +### 주문 상태별 가능 액션 + +| 상태 | 주문 취소 | 배송지 변경 | +|---|---|---| +| `PAYMENT_COMPLETED` (결제완료) | 가능 | 가능 | +| `PREPARING` (상품준비중) | 가능 | 가능 | +| `SHIPPING` (배송중) | 불가 (반품 절차) | 불가 | +| `DELIVERED` (배송완료) | 불가 (반품 절차) | 불가 | + +### 영수증 (Receipt) + +| 유형 | 포함 정보 | +|---|---| +| 카드영수증 | 상점정보, 결제일시, 금액 | +| 거래명세서 | 공급자/공급받는자 정보, 품목, 세액, 비고 | + +### API 엔드포인트 + +| Role | HTTP Method | Path | UseCase | +|---|---|---|---| +| User | `POST` | `/api/v1/orders` | `OrderCreateUseCase` | +| User | `GET` | `/api/v1/orders/me` | `OrderQueryUseCase` | +| User | `GET` | `/api/v1/orders/{id}` | `OrderQueryUseCase` | +| Admin | `GET` | `/api-admin/v1/orders` | `AdminOrderQueryUseCase` | + +--- + +## 6-15. Admin 인증 설계 + +관리자 API는 `X-Loopers-Ldap` 헤더를 통해 권한을 검증합니다. + +```mermaid +classDiagram + class AdminGuard { + <> + +validateAdmin(String ldapHeader) void + } + + class AdminGuardImpl { + -String ADMIN_LDAP_VALUE = "loopers.admin" + +validateAdmin(String ldapHeader) void + } + + class AdminBrandController { + -AdminGuard adminGuard + -AdminBrandUseCase adminBrandUseCase + } + + class AdminProductController { + -AdminGuard adminGuard + -AdminProductUseCase adminProductUseCase + } + + class AdminOrderController { + -AdminGuard adminGuard + -AdminOrderQueryUseCase adminOrderQueryUseCase + } + + AdminGuardImpl ..|> AdminGuard + AdminBrandController --> AdminGuard + AdminProductController --> AdminGuard + AdminOrderController --> AdminGuard +``` + +### 접근 제어 규칙 + +| 규칙 | 설명 | +|---|---| +| Admin 인증 | `X-Loopers-Ldap: loopers.admin` 헤더 필수 | +| User 접근 차단 | 일반 유저가 `/api-admin/**` 호출 시 403 Forbidden | +| 타 유저 접근 차단 | 유저는 자신의 정보만 조회 가능 (주문, 좋아요, 쿠폰 등) | diff --git a/.docs/design/04-erd.md b/.docs/design/04-erd.md new file mode 100644 index 00000000..99cc0194 --- /dev/null +++ b/.docs/design/04-erd.md @@ -0,0 +1,228 @@ +# 7. 전체 테이블 구조 및 관계 정리 (ERD) + +현재 구현된 테이블 구조와 향후 확장될 테이블 관계를 정리합니다. + +- **DB**: MySQL +- **DDL 전략**: local/test 환경은 `ddl-auto: create`, 운영 환경은 `ddl-auto: none` (수동 관리) +- **타임존**: UTC 기준 저장 (`hibernate.jdbc.time_zone: UTC`) + +--- + +## 7-1. 현재 구현된 ERD + +```mermaid +erDiagram + USERS { + BIGINT id PK "AUTO_INCREMENT" + VARCHAR user_id UK "NOT NULL, 로그인 ID (20)" + VARCHAR encoded_password "NOT NULL, salt:hash 형식" + VARCHAR username "NOT NULL, 사용자 이름 (20)" + DATE birthday "NOT NULL, 생년월일" + VARCHAR email "NOT NULL, 이메일" + DATETIME created_at "NOT NULL, 생성일시 (수정 불가)" + } +``` + +--- + +## 7-2. 테이블 상세 명세 + +### `users` 테이블 + +> JPA Entity: `UserJpaEntity` (`com.loopers.infrastructure.entity`) + +| 컬럼명 | 타입 | 제약조건 | 설명 | +|---|---|---|---| +| `id` | `BIGINT` | `PK`, `AUTO_INCREMENT` | 내부 식별자 | +| `user_id` | `VARCHAR(20)` | `NOT NULL`, `UNIQUE` | 로그인 ID (영문 소문자+숫자 4~10자) | +| `encoded_password` | `VARCHAR(255)` | `NOT NULL` | 암호화된 비밀번호 (`salt:hash` 형식) | +| `username` | `VARCHAR(20)` | `NOT NULL` | 사용자 이름 (한글/영문/숫자 2~20자) | +| `birthday` | `DATE` | `NOT NULL` | 생년월일 (1900-01-01 이후, 미래 불가) | +| `email` | `VARCHAR(255)` | `NOT NULL` | 이메일 주소 | +| `created_at` | `DATETIME` | `NOT NULL`, `updatable = false` | 가입일시 (수정 불가) | + +### DDL (예상) + +```sql +CREATE TABLE users ( + id BIGINT NOT NULL AUTO_INCREMENT, + user_id VARCHAR(20) NOT NULL, + encoded_password VARCHAR(255) NOT NULL, + username VARCHAR(20) NOT NULL, + birthday DATE NOT NULL, + email VARCHAR(255) NOT NULL, + created_at DATETIME NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_users_user_id (user_id) +); +``` + +### 인덱스 + +| 인덱스 | 타입 | 컬럼 | 용도 | +|---|---|---|---| +| `PRIMARY` | PK | `id` | 내부 식별자 | +| `uk_users_user_id` | UNIQUE | `user_id` | 로그인 ID 중복 방지 및 조회 | + +--- + +## 7-3. 도메인 모델 ↔ 테이블 매핑 + +`UserJpaEntity`는 `BaseEntity`를 상속하지 않고 독자적으로 컬럼을 정의합니다. + +``` +Domain (User) DB (users) +───────────── ────────── +Long id → id (BIGINT, PK) +UserId.value → user_id (VARCHAR) +String encodedPassword → encoded_password (VARCHAR) +UserName.value → username (VARCHAR) +Birthday.value → birthday (DATE) +Email.value → email (VARCHAR) +LocalDateTime createdAt → created_at (DATETIME) +───────────────────────────────────────────── +WrongPasswordCount → (DB에 미저장, 도메인 전용) +``` + +### 주의 사항 + +- **`WrongPasswordCount`**: 현재 DB에 컬럼이 없으며, 복원 시 항상 `WrongPasswordCount.init()` (0)으로 초기화됩니다. +- **`updated_at` / `deleted_at`**: `BaseEntity`에 정의되어 있지만 `UserJpaEntity`는 상속하지 않아 해당 컬럼이 없습니다. + +--- + +## 7-4. BaseEntity 공통 컬럼 (향후 테이블 확장 시 적용) + +`modules/jpa`에 정의된 `BaseEntity`는 향후 새로운 엔티티가 상속받아 사용할 공통 컬럼입니다. + +```mermaid +erDiagram + BASE_ENTITY { + BIGINT id PK "AUTO_INCREMENT" + DATETIME created_at "NOT NULL, 자동 설정" + DATETIME updated_at "NOT NULL, 자동 갱신" + DATETIME deleted_at "NULL, Soft Delete" + } +``` + +| 컬럼 | 동작 | 설명 | +|---|---|---| +| `created_at` | `@PrePersist` 시 자동 설정 | 최초 생성일시 (수정 불가) | +| `updated_at` | `@PrePersist`, `@PreUpdate` 시 자동 갱신 | 마지막 수정일시 | +| `deleted_at` | `delete()` 호출 시 설정, `restore()` 시 null | Soft Delete 지원 | + +--- + +## 7-5. 향후 확장 ERD (미래 목표) + +시퀀스 다이어그램 5-2, 5-3에서 설계한 브랜드/상품/주문/쿠폰 기능 구현 시 예상되는 테이블 구조입니다. + +```mermaid +erDiagram + USERS ||--o{ ORDERS : "주문" + USERS ||--o{ USER_COUPONS : "보유 쿠폰" + USERS ||--o{ LIKES : "좋아요" + + BRANDS ||--o{ PRODUCTS : "보유 상품" + + PRODUCTS ||--o{ PRODUCT_IMAGES : "상품 이미지" + PRODUCTS ||--o{ ORDER_ITEMS : "주문 항목" + PRODUCTS ||--o{ LIKES : "좋아요 대상" + + ORDERS ||--o{ ORDER_ITEMS : "주문 상세" + ORDERS ||--o| ORDER_SNAPSHOTS : "주문 스냅샷" + + COUPONS ||--o{ USER_COUPONS : "발급 이력" + + USERS { + BIGINT id PK + VARCHAR user_id UK + VARCHAR encoded_password + VARCHAR username + DATE birthday + VARCHAR email + DATETIME created_at + } + + BRANDS { + BIGINT id PK + VARCHAR name + VARCHAR description + DATETIME created_at + DATETIME updated_at + DATETIME deleted_at + } + + PRODUCTS { + BIGINT id PK + BIGINT brand_id FK + VARCHAR name + INT price + INT stock_quantity + VARCHAR description + DATETIME created_at + DATETIME updated_at + DATETIME deleted_at + } + + PRODUCT_IMAGES { + BIGINT id PK + BIGINT product_id FK + VARCHAR image_url + INT sort_order + DATETIME created_at + } + + LIKES { + BIGINT id PK + BIGINT user_id FK "UNIQUE(user_id, product_id)" + BIGINT product_id FK + DATETIME created_at + } + + ORDERS { + BIGINT id PK + BIGINT user_id FK + INT total_amount + VARCHAR status + DATETIME created_at + DATETIME updated_at + } + + ORDER_ITEMS { + BIGINT id PK + BIGINT order_id FK + BIGINT product_id FK + INT quantity + INT price + } + + ORDER_SNAPSHOTS { + BIGINT id PK + BIGINT order_id FK + TEXT snapshot_data + DATETIME created_at + } + + COUPONS { + BIGINT id PK + VARCHAR name + INT discount_amount + INT total_quantity + INT issued_quantity + DATETIME start_at + DATETIME end_at + DATETIME created_at + } + + USER_COUPONS { + BIGINT id PK + BIGINT user_id FK + BIGINT coupon_id FK + BOOLEAN is_used + DATETIME issued_at + DATETIME used_at + } +``` + +> 위 ERD는 미래 구현 목표이며, 실제 구현 시 도메인 설계에 따라 변경될 수 있습니다. From 74f9289a336d60053a7c2191ec0123be99326306 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=84=EC=9D=B8=EA=B5=AD?= Date: Thu, 12 Feb 2026 19:04:07 +0900 Subject: [PATCH 4/8] =?UTF-8?q?refactor=20:=20=EC=8B=9C=ED=80=80=EC=8A=A4?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20erd=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .docs/design/02-sequence-diagrams.md | 389 ++++++++++++++++++++++++++- .docs/design/04-erd.md | 14 +- 2 files changed, 396 insertions(+), 7 deletions(-) diff --git a/.docs/design/02-sequence-diagrams.md b/.docs/design/02-sequence-diagrams.md index 4ff8d386..76683be4 100644 --- a/.docs/design/02-sequence-diagrams.md +++ b/.docs/design/02-sequence-diagrams.md @@ -9,8 +9,9 @@ | User Flow | 회원가입, 헤더 기반 인증, 정보 조회, 비밀번호 변경 | | Read Flow | 데이터 조회와 DTO 변환 | | Write Flow (Admin) | 권한 체크와 데이터 무결성(참조 관계) | +| Like Flow | 멱등성 보장과 좋아요 수 동기화 | +| Coupon Flow | 동시성 제어(선착순), 유저당 1회 발급 | | Order Flow | 재고/결제/스냅샷의 트랜잭션 | -| Coupon Flow | 동시성 제어(선착순) | --- @@ -229,7 +230,7 @@ sequenceDiagram Note over User, API: 인증 불필요 (Public API) - User->>API: GET /products?brandId=1&sort=latest&page=0 + User->>API: GET /api/v1/products?brandId=1&sort=latest&page=0 API->>Service: getProductList(filterCondition) rect rgb(240, 248, 255) @@ -275,7 +276,7 @@ sequenceDiagram Note over Admin, API: Header: X-Loopers-Ldap - Admin->>API: POST /admin/products (Info, Images, BrandId) + Admin->>API: POST /api-admin/v1/products (Info, Images, BrandId) rect rgb(255, 230, 230) Note right of API: [책임 1] 관리자 권한 검증 @@ -318,4 +319,384 @@ sequenceDiagram API-->>Admin: 201 Created ``` ---- \ No newline at end of file +--- + +## 5-4. 좋아요 기능 (Like Flow) + +**핵심 책임 객체:** + +| 객체 | 책임 | +|------|------| +| `LikeController` | HTTP 요청 수신 및 UseCase 위임 | +| `AuthenticationService` | 헤더 기반 인증 (사용자 조회, 비밀번호 매칭) | +| `LikeService` | 좋아요 등록/취소 오케스트레이션 (멱등성 보장) | +| `ProductRepository` | 상품 존재 여부 확인 | +| `LikeRepository` | 좋아요 데이터 영속화 및 중복 확인 | + +### Scenario 1 — 좋아요 등록 (Add Like) + +```mermaid +sequenceDiagram + autonumber + actor User as 👤 User + participant API as 🌐 LikeController + participant Auth as 🔐 AuthenticationService + participant Service as ❤️ LikeService + participant ProductDB as 💾 ProductRepository + participant LikeDB as 💾 LikeRepository + + User->>API: POST /api/v1/products/{productId}/likes (Header: X-Loopers-LoginId, X-Loopers-LoginPw) + + rect rgb(255, 230, 230) + Note right of API: [책임 1] 헤더 기반 인증 + API->>Auth: authenticate(userId, rawPassword) + alt 인증 실패 + Auth-->>API: throw IllegalArgumentException + API-->>User: 400 Bad Request + end + end + + API->>Service: addLike(userId, productId) + + rect rgb(240, 248, 255) + Note right of Service: [책임 2] 상품 존재 확인 + Service->>ProductDB: findById(productId) + alt 상품 없음 + ProductDB-->>Service: Optional.empty() + Service-->>API: throw IllegalArgumentException("상품을 찾을 수 없습니다.") + API-->>User: 404 Not Found + else 상품 존재 + ProductDB-->>Service: Product + end + end + + rect rgb(255, 240, 245) + Note right of Service: [책임 3] 멱등성 보장 (중복 확인) + Service->>LikeDB: existsByUserIdAndProductId(userId, productId) + alt 이미 좋아요 누름 + LikeDB-->>Service: true + Service-->>API: 정상 응답 (멱등성 — 에러 아님) + API-->>User: 200 OK + else 좋아요 없음 + LikeDB-->>Service: false + end + end + + rect rgb(240, 255, 240) + Note right of Service: [책임 4] 좋아요 저장 + Service->>Service: Like.create(userId, productId) + Service->>LikeDB: save(Like) + LikeDB-->>Service: Like + end + + Service-->>API: void + API-->>User: 200 OK +``` + +### Scenario 2 — 좋아요 취소 (Cancel Like) + +```mermaid +sequenceDiagram + autonumber + actor User as 👤 User + participant API as 🌐 LikeController + participant Auth as 🔐 AuthenticationService + participant Service as ❤️ LikeService + participant LikeDB as 💾 LikeRepository + + User->>API: DELETE /api/v1/products/{productId}/likes (Header: X-Loopers-LoginId, X-Loopers-LoginPw) + + rect rgb(255, 230, 230) + Note right of API: [책임 1] 헤더 기반 인증 + API->>Auth: authenticate(userId, rawPassword) + alt 인증 실패 + Auth-->>API: throw IllegalArgumentException + API-->>User: 400 Bad Request + end + end + + API->>Service: cancelLike(userId, productId) + + rect rgb(240, 248, 255) + Note right of Service: [책임 2] 좋아요 존재 확인 + Service->>LikeDB: findByUserIdAndProductId(userId, productId) + alt 좋아요 없음 + LikeDB-->>Service: Optional.empty() + Service-->>API: 정상 응답 (멱등성 — 에러 아님) + API-->>User: 200 OK + else 좋아요 존재 + LikeDB-->>Service: Like + end + end + + rect rgb(255, 240, 245) + Note right of Service: [책임 3] 좋아요 삭제 + Service->>LikeDB: delete(Like) + end + + Service-->>API: void + API-->>User: 200 OK +``` + +--- + +## 5-5. 쿠폰 기능 (Coupon Flow) + +**핵심 책임 객체:** + +| 객체 | 책임 | +|------|------| +| `CouponController` | HTTP 요청 수신 및 UseCase 위임 | +| `AuthenticationService` | 헤더 기반 인증 (사용자 조회, 비밀번호 매칭) | +| `CouponIssueService` | 쿠폰 발급 오케스트레이션 (동시성 제어, 중복 방지) | +| `CouponRepository` | 쿠폰 조회 및 수량 관리 (비관적 락) | +| `UserCouponRepository` | 유저-쿠폰 발급 이력 관리 | + +### Scenario 1 — 선착순 쿠폰 발급 (Issue Coupon) + +```mermaid +sequenceDiagram + autonumber + actor User as 👤 User + participant API as 🌐 CouponController + participant Auth as 🔐 AuthenticationService + participant Service as 🎟️ CouponIssueService + participant CouponDB as 💾 CouponRepository + participant UserCouponDB as 💾 UserCouponRepository + + User->>API: POST /api/v1/coupons/{couponId}/issue (Header: X-Loopers-LoginId, X-Loopers-LoginPw) + + rect rgb(255, 230, 230) + Note right of API: [책임 1] 헤더 기반 인증 + API->>Auth: authenticate(userId, rawPassword) + alt 인증 실패 + Auth-->>API: throw IllegalArgumentException + API-->>User: 400 Bad Request + end + end + + API->>Service: issueCoupon(userId, couponId) + + rect rgb(240, 248, 255) + Note right of Service: [책임 2] 쿠폰 조회 (비관적 락) + Service->>CouponDB: findByIdForUpdate(couponId) + Note right of CouponDB: SELECT ... FOR UPDATE (동시성 제어) + alt 쿠폰 없음 + CouponDB-->>Service: Optional.empty() + Service-->>API: throw IllegalArgumentException("쿠폰을 찾을 수 없습니다.") + API-->>User: 404 Not Found + else 쿠폰 존재 + CouponDB-->>Service: Coupon + end + end + + rect rgb(255, 240, 245) + Note right of Service: [책임 3] 발급 가능 여부 확인 + Service->>Service: coupon.issuable() — 수량 잔여 확인 + alt 수량 소진 (Sold Out) + Service-->>API: throw IllegalStateException("쿠폰이 모두 소진되었습니다.") + API-->>User: 409 Conflict + end + + Service->>UserCouponDB: existsByUserIdAndCouponId(userId, couponId) + alt 이미 발급받음 + UserCouponDB-->>Service: true + Service-->>API: throw IllegalStateException("이미 발급받은 쿠폰입니다.") + API-->>User: 409 Conflict + else 미발급 + UserCouponDB-->>Service: false + end + end + + rect rgb(240, 255, 240) + Note right of Service: [책임 4] 쿠폰 발급 처리 + Service->>Service: coupon.issue() — issuedQuantity++ + Service->>CouponDB: save(coupon) + Service->>Service: UserCoupon.create(userId, couponId) + Service->>UserCouponDB: save(UserCoupon) + UserCouponDB-->>Service: UserCoupon + end + + Service-->>API: void + API-->>User: 200 OK +``` + +--- + +## 5-6. 주문 기능 (Order Flow) + +**핵심 책임 객체:** + +| 객체 | 책임 | +|------|------| +| `OrderController` | HTTP 요청 수신 및 UseCase 위임 | +| `AuthenticationService` | 헤더 기반 인증 (사용자 조회, 비밀번호 매칭) | +| `OrderCreateService` | 주문 생성 오케스트레이션 (재고 확인, 쿠폰 적용, 스냅샷) | +| `OrderCancelService` | 주문 취소 처리 (상태 검증, 재고/쿠폰 복원) | +| `ProductRepository` | 재고 확인 및 차감 | +| `CouponRepository` | 쿠폰 적용 및 복원 | +| `OrderRepository` | 주문 데이터 영속화 | + +### Scenario 1 — 주문 생성 (Create Order) + +```mermaid +sequenceDiagram + autonumber + actor User as 👤 User + participant API as 🌐 OrderController + participant Auth as 🔐 AuthenticationService + participant Service as 🛒 OrderCreateService + participant ProductDB as 💾 ProductRepository + participant CouponDB as 💾 CouponRepository + participant OrderDB as 💾 OrderRepository + + User->>API: POST /api/v1/orders (Header: X-Loopers-LoginId, X-Loopers-LoginPw, Body: items, couponId, deliveryInfo, paymentMethod) + + rect rgb(255, 230, 230) + Note right of API: [책임 1] 헤더 기반 인증 + API->>Auth: authenticate(userId, rawPassword) + alt 인증 실패 + Auth-->>API: throw IllegalArgumentException + API-->>User: 400 Bad Request + end + end + + API->>Service: createOrder(userId, orderRequest) + + rect rgb(240, 248, 255) + Note right of Service: [책임 2] 재고 확인 및 차감 + loop 각 주문 항목 + Service->>ProductDB: findByIdForUpdate(productId) + Note right of ProductDB: SELECT ... FOR UPDATE (동시성 제어) + alt 상품 없음 + ProductDB-->>Service: Optional.empty() + Service-->>API: throw IllegalArgumentException("상품을 찾을 수 없습니다.") + API-->>User: 404 Not Found + else 상품 존재 + ProductDB-->>Service: Product + end + Service->>Service: product.decreaseStock(quantity) + alt 재고 부족 + Service-->>API: throw IllegalStateException("재고가 부족합니다.") + API-->>User: 409 Conflict + end + Service->>ProductDB: save(product) + end + end + + rect rgb(255, 250, 205) + Note right of Service: [책임 3] 쿠폰 적용 (선택) + alt 쿠폰 사용 요청 + Service->>CouponDB: findUserCoupon(userId, couponId) + alt 쿠폰 없음 또는 사용 불가 + CouponDB-->>Service: 검증 실패 + Service-->>API: throw IllegalArgumentException("사용할 수 없는 쿠폰입니다.") + API-->>User: 400 Bad Request + else 쿠폰 사용 가능 + CouponDB-->>Service: UserCoupon + Service->>Service: userCoupon.use() — 사용 처리 + Service->>CouponDB: save(userCoupon) + end + end + end + + rect rgb(255, 240, 245) + Note right of Service: [책임 4] 결제 금액 검증 + Service->>Service: calculateTotalAmount(items, discount) + Service->>Service: verifyPaymentAmount(calculated, requested) + alt 금액 불일치 + Service-->>API: throw IllegalArgumentException("결제 금액이 일치하지 않습니다.") + API-->>User: 400 Bad Request + end + end + + rect rgb(240, 255, 240) + Note right of Service: [책임 5] 주문 생성 및 스냅샷 저장 + Service->>Service: Order.create(userId, items, totalAmount, deliveryInfo) + Service->>Service: OrderSnapshot.capture(order, products) — 주문 시점 상품 정보 보존 + Service->>OrderDB: save(Order + OrderItems + OrderSnapshot) + OrderDB-->>Service: Order + end + + Service-->>API: OrderResponse + API-->>User: 200 OK (JSON) +``` + +### Scenario 2 — 주문 취소 (Cancel Order) + +```mermaid +sequenceDiagram + autonumber + actor User as 👤 User + participant API as 🌐 OrderController + participant Auth as 🔐 AuthenticationService + participant Service as 🛒 OrderCancelService + participant OrderDB as 💾 OrderRepository + participant ProductDB as 💾 ProductRepository + participant CouponDB as 💾 CouponRepository + + User->>API: POST /api/v1/orders/{orderId}/cancel (Header: X-Loopers-LoginId, X-Loopers-LoginPw) + + rect rgb(255, 230, 230) + Note right of API: [책임 1] 헤더 기반 인증 + API->>Auth: authenticate(userId, rawPassword) + alt 인증 실패 + Auth-->>API: throw IllegalArgumentException + API-->>User: 400 Bad Request + end + end + + API->>Service: cancelOrder(userId, orderId) + + rect rgb(240, 248, 255) + Note right of Service: [책임 2] 주문 조회 및 권한/상태 확인 + Service->>OrderDB: findById(orderId) + alt 주문 없음 + OrderDB-->>Service: Optional.empty() + Service-->>API: throw IllegalArgumentException("주문을 찾을 수 없습니다.") + API-->>User: 404 Not Found + else 주문 존재 + OrderDB-->>Service: Order + end + Service->>Service: order.validateOwner(userId) — 본인 주문 확인 + alt 본인 주문 아님 + Service-->>API: throw IllegalArgumentException("본인의 주문만 취소할 수 있습니다.") + API-->>User: 400 Bad Request + end + Service->>Service: order.isCancellable() — 상태 확인 (결제완료/상품준비중) + alt 취소 불가 상태 (배송중/배송완료) + Service-->>API: throw IllegalStateException("배송중/배송완료 상태에서는 취소할 수 없습니다.") + API-->>User: 409 Conflict + end + end + + rect rgb(255, 240, 245) + Note right of Service: [책임 3] 재고 복원 + loop 각 주문 항목 + Service->>ProductDB: findById(productId) + ProductDB-->>Service: Product + Service->>Service: product.increaseStock(quantity) + Service->>ProductDB: save(product) + end + end + + rect rgb(255, 250, 205) + Note right of Service: [책임 4] 쿠폰 복원 (사용한 경우) + alt 쿠폰 사용 주문 + Service->>CouponDB: findUserCoupon(userId, couponId) + CouponDB-->>Service: UserCoupon + Service->>Service: userCoupon.restore() — 사용 취소 + Service->>CouponDB: save(userCoupon) + end + end + + rect rgb(240, 255, 240) + Note right of Service: [책임 5] 주문 상태 변경 + Service->>Service: order.cancel() — 상태를 '취소'로 변경 + Service->>OrderDB: save(order) + OrderDB-->>Service: Order + end + + Service-->>API: void + API-->>User: 200 OK +``` \ No newline at end of file diff --git a/.docs/design/04-erd.md b/.docs/design/04-erd.md index 99cc0194..234b28f1 100644 --- a/.docs/design/04-erd.md +++ b/.docs/design/04-erd.md @@ -115,7 +115,7 @@ erDiagram ## 7-5. 향후 확장 ERD (미래 목표) -시퀀스 다이어그램 5-2, 5-3에서 설계한 브랜드/상품/주문/쿠폰 기능 구현 시 예상되는 테이블 구조입니다. +시퀀스 다이어그램 5-2 ~ 5-6에서 설계한 브랜드/상품/좋아요/쿠폰/주문 기능 구현 시 예상되는 테이블 구조입니다. ```mermaid erDiagram @@ -183,8 +183,16 @@ erDiagram ORDERS { BIGINT id PK BIGINT user_id FK - INT total_amount - VARCHAR status + BIGINT coupon_id FK "NULL, 사용한 쿠폰" + VARCHAR receiver_name "NOT NULL, 수령인" + VARCHAR address "NOT NULL, 배송지" + VARCHAR request "NULL, 배송 요청사항" + VARCHAR payment_method "NOT NULL, 결제수단" + INT total_amount "NOT NULL, 총 주문금액" + INT discount_amount "NOT NULL, 할인금액 (DEFAULT 0)" + INT payment_amount "NOT NULL, 최종 결제금액" + VARCHAR status "NOT NULL, 주문상태" + DATE desired_delivery_date "NULL, 도착희망일" DATETIME created_at DATETIME updated_at } From 59d0a6b36a74e6499764cc1991802f1761c1eb8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=84=EC=9D=B8=EA=B5=AD?= Date: Thu, 12 Feb 2026 22:42:09 +0900 Subject: [PATCH 5/8] =?UTF-8?q?=E2=8F=BA=20refactor:=204=EA=B0=9C=20User?= =?UTF-8?q?=20=EC=84=9C=EB=B9=84=EC=8A=A4=EB=A5=BC=20UserService=EB=A1=9C?= =?UTF-8?q?=20=ED=86=B5=ED=95=A9=20=EB=B0=8F=20=EC=84=A4=EA=B3=84=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EC=A0=95=EB=A6=AC=20docs=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .docs/design/02-sequence-diagrams.md | 156 +-- .docs/design/03-class-diagram.md | 997 ++++-------------- .../service/AuthenticationService.java | 32 - .../service/PasswordUpdateService.java | 50 - .../application/service/UserQueryService.java | 42 - .../service/UserRegisterService.java | 50 - .../application/service/UserService.java | 107 ++ .../service/AuthenticationServiceTest.java | 100 -- .../service/PasswordUpdateServiceTest.java | 123 --- .../service/UserQueryServiceTest.java | 105 -- .../service/UserRegisterServiceTest.java | 71 -- .../application/service/UserServiceTest.java | 307 ++++++ 12 files changed, 634 insertions(+), 1506 deletions(-) delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/service/AuthenticationService.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/service/PasswordUpdateService.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/service/UserQueryService.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/service/UserRegisterService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/service/UserService.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/application/service/AuthenticationServiceTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/application/service/PasswordUpdateServiceTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/application/service/UserQueryServiceTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/application/service/UserRegisterServiceTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/service/UserServiceTest.java diff --git a/.docs/design/02-sequence-diagrams.md b/.docs/design/02-sequence-diagrams.md index 76683be4..3f95fbfa 100644 --- a/.docs/design/02-sequence-diagrams.md +++ b/.docs/design/02-sequence-diagrams.md @@ -10,7 +10,6 @@ | Read Flow | 데이터 조회와 DTO 변환 | | Write Flow (Admin) | 권한 체크와 데이터 무결성(참조 관계) | | Like Flow | 멱등성 보장과 좋아요 수 동기화 | -| Coupon Flow | 동시성 제어(선착순), 유저당 1회 발급 | | Order Flow | 재고/결제/스냅샷의 트랜잭션 | --- @@ -22,10 +21,7 @@ | 객체 | 책임 | |------|------| | `UserController` | HTTP 요청 수신 및 UseCase 위임 | -| `UserRegisterService` | 회원가입 오케스트레이션 (값 객체 검증, 중복 확인, 암호화, 저장) | -| `AuthenticationService` | 헤더 기반 인증 (사용자 조회, 비밀번호 매칭) | -| `UserQueryService` | 사용자 정보 조회 및 이름 마스킹 | -| `PasswordUpdateService` | 비밀번호 변경 (기존 검증, 신규 검증, 암호화, 저장) | +| `UserService` | 회원가입, 인증, 정보 조회, 비밀번호 변경 통합 서비스 | | `PasswordEncoder` | 비밀번호 암호화 및 매칭 (SHA-256) | | `UserRepository` | 중복 ID 체크 및 사용자 영속화 | @@ -36,7 +32,7 @@ sequenceDiagram autonumber actor User as 👤 User participant API as 🌐 UserController - participant Service as 📦 UserRegisterService + participant Service as 📦 UserService participant VO as 🔒 Value Objects participant Encoder as 🛡️ PasswordEncoder participant DB as 💾 UserRepository @@ -92,8 +88,8 @@ sequenceDiagram autonumber actor User as 👤 User participant API as 🌐 UserController - participant Auth as 🔐 AuthenticationService - participant Query as 🔍 UserQueryService + participant Auth as 🔐 UserService + participant Query as 🔍 UserService participant Encoder as 🛡️ PasswordEncoder participant DB as 💾 UserRepository @@ -144,8 +140,8 @@ sequenceDiagram autonumber actor User as 👤 User participant API as 🌐 UserController - participant Auth as 🔐 AuthenticationService - participant Service as 🔑 PasswordUpdateService + participant Auth as 🔐 UserService + participant Service as 🔑 UserService participant VO as 🔒 Value Objects participant Encoder as 🛡️ PasswordEncoder participant DB as 💾 UserRepository @@ -328,7 +324,7 @@ sequenceDiagram | 객체 | 책임 | |------|------| | `LikeController` | HTTP 요청 수신 및 UseCase 위임 | -| `AuthenticationService` | 헤더 기반 인증 (사용자 조회, 비밀번호 매칭) | +| `UserService` | 헤더 기반 인증 (사용자 조회, 비밀번호 매칭) | | `LikeService` | 좋아요 등록/취소 오케스트레이션 (멱등성 보장) | | `ProductRepository` | 상품 존재 여부 확인 | | `LikeRepository` | 좋아요 데이터 영속화 및 중복 확인 | @@ -340,7 +336,7 @@ sequenceDiagram autonumber actor User as 👤 User participant API as 🌐 LikeController - participant Auth as 🔐 AuthenticationService + participant Auth as 🔐 UserService participant Service as ❤️ LikeService participant ProductDB as 💾 ProductRepository participant LikeDB as 💾 LikeRepository @@ -400,7 +396,7 @@ sequenceDiagram autonumber actor User as 👤 User participant API as 🌐 LikeController - participant Auth as 🔐 AuthenticationService + participant Auth as 🔐 UserService participant Service as ❤️ LikeService participant LikeDB as 💾 LikeRepository @@ -440,101 +436,17 @@ sequenceDiagram --- -## 5-5. 쿠폰 기능 (Coupon Flow) - -**핵심 책임 객체:** - -| 객체 | 책임 | -|------|------| -| `CouponController` | HTTP 요청 수신 및 UseCase 위임 | -| `AuthenticationService` | 헤더 기반 인증 (사용자 조회, 비밀번호 매칭) | -| `CouponIssueService` | 쿠폰 발급 오케스트레이션 (동시성 제어, 중복 방지) | -| `CouponRepository` | 쿠폰 조회 및 수량 관리 (비관적 락) | -| `UserCouponRepository` | 유저-쿠폰 발급 이력 관리 | - -### Scenario 1 — 선착순 쿠폰 발급 (Issue Coupon) - -```mermaid -sequenceDiagram - autonumber - actor User as 👤 User - participant API as 🌐 CouponController - participant Auth as 🔐 AuthenticationService - participant Service as 🎟️ CouponIssueService - participant CouponDB as 💾 CouponRepository - participant UserCouponDB as 💾 UserCouponRepository - - User->>API: POST /api/v1/coupons/{couponId}/issue (Header: X-Loopers-LoginId, X-Loopers-LoginPw) - - rect rgb(255, 230, 230) - Note right of API: [책임 1] 헤더 기반 인증 - API->>Auth: authenticate(userId, rawPassword) - alt 인증 실패 - Auth-->>API: throw IllegalArgumentException - API-->>User: 400 Bad Request - end - end - - API->>Service: issueCoupon(userId, couponId) - - rect rgb(240, 248, 255) - Note right of Service: [책임 2] 쿠폰 조회 (비관적 락) - Service->>CouponDB: findByIdForUpdate(couponId) - Note right of CouponDB: SELECT ... FOR UPDATE (동시성 제어) - alt 쿠폰 없음 - CouponDB-->>Service: Optional.empty() - Service-->>API: throw IllegalArgumentException("쿠폰을 찾을 수 없습니다.") - API-->>User: 404 Not Found - else 쿠폰 존재 - CouponDB-->>Service: Coupon - end - end - - rect rgb(255, 240, 245) - Note right of Service: [책임 3] 발급 가능 여부 확인 - Service->>Service: coupon.issuable() — 수량 잔여 확인 - alt 수량 소진 (Sold Out) - Service-->>API: throw IllegalStateException("쿠폰이 모두 소진되었습니다.") - API-->>User: 409 Conflict - end - - Service->>UserCouponDB: existsByUserIdAndCouponId(userId, couponId) - alt 이미 발급받음 - UserCouponDB-->>Service: true - Service-->>API: throw IllegalStateException("이미 발급받은 쿠폰입니다.") - API-->>User: 409 Conflict - else 미발급 - UserCouponDB-->>Service: false - end - end - - rect rgb(240, 255, 240) - Note right of Service: [책임 4] 쿠폰 발급 처리 - Service->>Service: coupon.issue() — issuedQuantity++ - Service->>CouponDB: save(coupon) - Service->>Service: UserCoupon.create(userId, couponId) - Service->>UserCouponDB: save(UserCoupon) - UserCouponDB-->>Service: UserCoupon - end - - Service-->>API: void - API-->>User: 200 OK -``` - ---- - -## 5-6. 주문 기능 (Order Flow) +## 5-5. 주문 기능 (Order Flow) **핵심 책임 객체:** | 객체 | 책임 | |------|------| | `OrderController` | HTTP 요청 수신 및 UseCase 위임 | -| `AuthenticationService` | 헤더 기반 인증 (사용자 조회, 비밀번호 매칭) | -| `OrderCreateService` | 주문 생성 오케스트레이션 (재고 확인, 쿠폰 적용, 스냅샷) | -| `OrderCancelService` | 주문 취소 처리 (상태 검증, 재고/쿠폰 복원) | +| `UserService` | 헤더 기반 인증 (사용자 조회, 비밀번호 매칭) | +| `OrderCreateService` | 주문 생성 오케스트레이션 (재고 확인, 스냅샷) | +| `OrderCancelService` | 주문 취소 처리 (상태 검증, 재고 복원) | | `ProductRepository` | 재고 확인 및 차감 | -| `CouponRepository` | 쿠폰 적용 및 복원 | | `OrderRepository` | 주문 데이터 영속화 | ### Scenario 1 — 주문 생성 (Create Order) @@ -544,13 +456,12 @@ sequenceDiagram autonumber actor User as 👤 User participant API as 🌐 OrderController - participant Auth as 🔐 AuthenticationService + participant Auth as 🔐 UserService participant Service as 🛒 OrderCreateService participant ProductDB as 💾 ProductRepository - participant CouponDB as 💾 CouponRepository participant OrderDB as 💾 OrderRepository - User->>API: POST /api/v1/orders (Header: X-Loopers-LoginId, X-Loopers-LoginPw, Body: items, couponId, deliveryInfo, paymentMethod) + User->>API: POST /api/v1/orders (Header: X-Loopers-LoginId, X-Loopers-LoginPw, Body: items, deliveryInfo, paymentMethod) rect rgb(255, 230, 230) Note right of API: [책임 1] 헤더 기반 인증 @@ -584,25 +495,9 @@ sequenceDiagram end end - rect rgb(255, 250, 205) - Note right of Service: [책임 3] 쿠폰 적용 (선택) - alt 쿠폰 사용 요청 - Service->>CouponDB: findUserCoupon(userId, couponId) - alt 쿠폰 없음 또는 사용 불가 - CouponDB-->>Service: 검증 실패 - Service-->>API: throw IllegalArgumentException("사용할 수 없는 쿠폰입니다.") - API-->>User: 400 Bad Request - else 쿠폰 사용 가능 - CouponDB-->>Service: UserCoupon - Service->>Service: userCoupon.use() — 사용 처리 - Service->>CouponDB: save(userCoupon) - end - end - end - rect rgb(255, 240, 245) - Note right of Service: [책임 4] 결제 금액 검증 - Service->>Service: calculateTotalAmount(items, discount) + Note right of Service: [책임 3] 결제 금액 검증 + Service->>Service: calculateTotalAmount(items) Service->>Service: verifyPaymentAmount(calculated, requested) alt 금액 불일치 Service-->>API: throw IllegalArgumentException("결제 금액이 일치하지 않습니다.") @@ -611,7 +506,7 @@ sequenceDiagram end rect rgb(240, 255, 240) - Note right of Service: [책임 5] 주문 생성 및 스냅샷 저장 + Note right of Service: [책임 4] 주문 생성 및 스냅샷 저장 Service->>Service: Order.create(userId, items, totalAmount, deliveryInfo) Service->>Service: OrderSnapshot.capture(order, products) — 주문 시점 상품 정보 보존 Service->>OrderDB: save(Order + OrderItems + OrderSnapshot) @@ -629,11 +524,10 @@ sequenceDiagram autonumber actor User as 👤 User participant API as 🌐 OrderController - participant Auth as 🔐 AuthenticationService + participant Auth as 🔐 UserService participant Service as 🛒 OrderCancelService participant OrderDB as 💾 OrderRepository participant ProductDB as 💾 ProductRepository - participant CouponDB as 💾 CouponRepository User->>API: POST /api/v1/orders/{orderId}/cancel (Header: X-Loopers-LoginId, X-Loopers-LoginPw) @@ -680,18 +574,8 @@ sequenceDiagram end end - rect rgb(255, 250, 205) - Note right of Service: [책임 4] 쿠폰 복원 (사용한 경우) - alt 쿠폰 사용 주문 - Service->>CouponDB: findUserCoupon(userId, couponId) - CouponDB-->>Service: UserCoupon - Service->>Service: userCoupon.restore() — 사용 취소 - Service->>CouponDB: save(userCoupon) - end - end - rect rgb(240, 255, 240) - Note right of Service: [책임 5] 주문 상태 변경 + Note right of Service: [책임 4] 주문 상태 변경 Service->>Service: order.cancel() — 상태를 '취소'로 변경 Service->>OrderDB: save(order) OrderDB-->>Service: Order diff --git a/.docs/design/03-class-diagram.md b/.docs/design/03-class-diagram.md index 22471ecc..37274de3 100644 --- a/.docs/design/03-class-diagram.md +++ b/.docs/design/03-class-diagram.md @@ -1,8 +1,6 @@ # 6. 도메인 객체 설계 (Class Diagram) -클린 아키텍처(Clean Architecture) 기반으로 **도메인 계층이 어떤 외부 기술에도 의존하지 않도록** 설계했습니다. - -각 계층의 의존 방향은 항상 **바깥 → 안쪽**이며, 도메인 계층은 순수 Java 객체로만 구성됩니다. +클린 아키텍처 기반으로 **도메인 계층이 어떤 외부 기술에도 의존하지 않도록** 설계했습니다. ``` Interfaces → Application → Domain ← Infrastructure @@ -10,348 +8,201 @@ Interfaces → Application → Domain ← Infrastructure --- -## 6-1. 전체 아키텍처 계층 구조 +## 6-1. User 도메인 (현재 구현) + +### 전체 구조 ```mermaid classDiagram direction TB - namespace Interfaces { - class UserController - class UserRegisterRequest - class UserInfoResponse - class PasswordUpdateRequest + %% === Interfaces === + class UserController { + -RegisterUseCase registerUseCase + -AuthenticationUseCase authenticationUseCase + -UserQueryUseCase userQueryUseCase + -PasswordUpdateUseCase passwordUpdateUseCase + +register(UserRegisterRequest) ResponseEntity + +getMyInfo(loginId, loginPw) ResponseEntity + +updatePassword(loginId, loginPw, PasswordUpdateRequest) ResponseEntity } - namespace Application { - class RegisterUseCase - class AuthenticationUseCase - class UserQueryUseCase - class PasswordUpdateUseCase - class UserRegisterService - class AuthenticationService - class UserQueryService - class PasswordUpdateService + %% === Application === + class RegisterUseCase { + <> + +register(loginId, name, rawPassword, birthday, email) + } + class AuthenticationUseCase { + <> + +authenticate(userId, rawPassword) + } + class UserQueryUseCase { + <> + +getUserInfo(userId) UserInfoResponse + } + class PasswordUpdateUseCase { + <> + +updatePassword(userId, currentRawPassword, newRawPassword) + } + class UserService { + -UserRepository userRepository + -PasswordEncoder passwordEncoder + +register() + +authenticate() + +getUserInfo() + +updatePassword() + -findUser(UserId) User + -maskName(String) String } - namespace Domain { - class User - class UserId - class UserName - class Password - class Email - class Birthday - class WrongPasswordCount - class PasswordEncoder - class UserRepository + %% === Domain === + class User { + <> + -Long id + -UserId userId + -UserName userName + -String encodedPassword + -Birthday birth + -Email email + -WrongPasswordCount wrongPasswordCount + -LocalDateTime createdAt + +register(...)$ User + +reconstitute(...)$ User + +changePassword(String) User + } + class UserRepository { + <> + +save(User) User + +findById(UserId) Optional~User~ + } + class PasswordEncoder { + <> + +encrypt(String) String + +matches(String, String) boolean } - namespace Infrastructure { - class UserRepositoryImpl - class UserJpaRepository - class UserJpaEntity - class Sha256PasswordEncoder + %% === Infrastructure === + class UserRepositoryImpl { + +save(User) User + +findById(UserId) Optional~User~ + } + class Sha256PasswordEncoder { + +encrypt(String) String + +matches(String, String) boolean } + %% --- 관계 --- UserController --> RegisterUseCase UserController --> AuthenticationUseCase UserController --> UserQueryUseCase UserController --> PasswordUpdateUseCase - UserRegisterService ..|> RegisterUseCase - AuthenticationService ..|> AuthenticationUseCase - UserQueryService ..|> UserQueryUseCase - PasswordUpdateService ..|> PasswordUpdateUseCase - - UserRegisterService --> UserRepository - UserRegisterService --> PasswordEncoder - AuthenticationService --> UserRepository - AuthenticationService --> PasswordEncoder - UserQueryService --> UserRepository - PasswordUpdateService --> UserRepository - PasswordUpdateService --> PasswordEncoder + UserService ..|> RegisterUseCase + UserService ..|> AuthenticationUseCase + UserService ..|> UserQueryUseCase + UserService ..|> PasswordUpdateUseCase + UserService --> UserRepository + UserService --> PasswordEncoder UserRepositoryImpl ..|> UserRepository Sha256PasswordEncoder ..|> PasswordEncoder - UserRepositoryImpl --> UserJpaRepository ``` ---- +### UserService 메서드별 책임 -## 6-2. Domain 계층 — Aggregate Root & Value Objects +| 메서드 | UseCase | 트랜잭션 | 핵심 로직 | +|---|---|---|---| +| `register()` | `RegisterUseCase` | `@Transactional` | 값 객체 검증 → 암호화 → 저장 (중복 시 예외) | +| `authenticate()` | `AuthenticationUseCase` | `readOnly` | 사용자 조회 → 비밀번호 매칭 | +| `getUserInfo()` | `UserQueryUseCase` | `readOnly` | 사용자 조회 → 이름 마스킹 | +| `updatePassword()` | `PasswordUpdateUseCase` | `@Transactional` | 기존 PW 검증 → 신규 PW 검증 → 암호화 → 저장 | -도메인 모델은 **불변(Immutable) 객체** 기반으로 설계했습니다. +### API 엔드포인트 -`User`는 Aggregate Root이며, 모든 필드는 값 객체(Value Object)로 감싸서 **자기 검증(Self-Validating)** 책임을 가집니다. +| Method | Path | 인증 | +|---|---|---| +| `POST` | `/api/v1/users/register` | 불필요 | +| `GET` | `/api/v1/users/me` | `X-Loopers-LoginId`, `X-Loopers-LoginPw` | +| `PUT` | `/api/v1/users/me/password` | `X-Loopers-LoginId`, `X-Loopers-LoginPw` | + +--- + +## 6-2. Value Objects & 검증 규칙 ```mermaid classDiagram - class User { - -Long id - -UserId userId - -UserName userName - -String encodedPassword - -Birthday birth - -Email email - -WrongPasswordCount wrongPasswordCount - -LocalDateTime createdAt - +register(userId, userName, encodedPassword, birth, email, wrongPasswordCount, createdAt)$ User - +reconstitute(id, userId, userName, encodedPassword, birth, email, wrongPasswordCount, createdAt)$ User - +matchesPassword(Password, PasswordMatchChecker) boolean - +changePassword(String newEncodedPassword) User - } - - class PasswordMatchChecker { - <> - +matches(Password, String encodingPassword) boolean - } + User *-- UserId + User *-- UserName + User *-- Birthday + User *-- Email + User *-- WrongPasswordCount + User ..> Password : register/updatePassword에서 사용 class UserId { -String value - -Pattern PATTERN = "^[a-z0-9]{4,10}$" - +of(String value)$ UserId + +of(String)$ UserId } - class UserName { -String value - -Pattern PATTERN = "^[a-zA-Z0-9가-힣]{2,20}$" - +of(String value)$ UserName + +of(String)$ UserName } - class Password { -String value - +of(String rawPassword, LocalDate birthday)$ Password - +containsBirthday(String, LocalDate)$ boolean + +of(String, LocalDate)$ Password } - class Email { -String value - -Pattern PATTERN = "^[a-zA-Z0-9]+@..." - +of(String value)$ Email + +of(String)$ Email } - class Birthday { -LocalDate value - +of(LocalDate value)$ Birthday + +of(LocalDate)$ Birthday } - class WrongPasswordCount { -int value +init()$ WrongPasswordCount - +of(int value)$ WrongPasswordCount +increment() WrongPasswordCount - +reset() WrongPasswordCount +isLocked() boolean } - - User *-- UserId - User *-- UserName - User *-- Birthday - User *-- Email - User *-- WrongPasswordCount - User ..> Password : matchesPassword()에서 사용 - User +-- PasswordMatchChecker ``` -### 값 객체 검증 규칙 - | Value Object | 검증 규칙 | 예외 메시지 | |---|---|---| | `UserId` | 4~10자, 영문 소문자+숫자만 | `로그인 ID는 4~10자의 영문 소문자, 숫자만 가능합니다.` | | `UserName` | 2~20자, 한글/영문/숫자 | `이름은 2~20자의 한글 또는 영문만 가능합니다.` | | `Password` | 8~16자, 영문+숫자+특수문자, 생년월일 포함 불가 | `비밀번호는 8~16자리 영문 대소문자, 숫자, 특수문자만 가능합니다.` | -| `Email` | 이메일 형식 정규식 검증 | `올바른 이메일 형식이 아닙니다` | -| `Birthday` | not null, 미래 날짜 불가, 1900년 이후 | `생년월일은 미래 날짜일 수 없습니다.` | -| `WrongPasswordCount` | 음수 불가, 5회 이상이면 잠금 | `비밀번호 오류 횟수는 음수일 수 없습니다.` | +| `Email` | 이메일 형식 정규식 | `올바른 이메일 형식이 아닙니다` | +| `Birthday` | not null, 미래 불가, 1900년 이후 | `생년월일은 미래 날짜일 수 없습니다.` | +| `WrongPasswordCount` | 음수 불가, 5회 이상 잠금 | `비밀번호 오류 횟수는 음수일 수 없습니다.` | ### 설계 결정 - **`User.register()`**: id = null로 생성 (영속화 전 신규 객체) - **`User.reconstitute()`**: DB에서 복원할 때 사용 (id 포함) - **`User.changePassword()`**: 새로운 User 인스턴스 반환 (불변성 유지) -- **`PasswordMatchChecker`**: 함수형 인터페이스로 도메인이 암호화 구현에 의존하지 않도록 설계 --- -## 6-3. Domain 계층 — Repository & Domain Service 인터페이스 - -도메인 계층은 인터페이스만 정의하고, 구현은 Infrastructure 계층에 위임합니다 (의존성 역전 원칙). +## 6-3. Infrastructure 계층 ```mermaid classDiagram - class UserRepository { - <> - +save(User) User - +findById(UserId) Optional~User~ - +existsById(UserId) boolean - } - - class PasswordEncoder { - <> - +encrypt(String rawPassword) String - +matches(String rawPassword, String encodedPassword) boolean - } - - UserRepository ..> User - UserRepository ..> UserId -``` - ---- - -## 6-4. Application 계층 — UseCase 인터페이스 & Service 구현 - -UseCase 인터페이스는 Controller가 의존하는 **입력 포트(Input Port)** 역할을 합니다. - -각 Service는 **하나의 UseCase만 구현**하여 단일 책임 원칙(SRP)을 따릅니다. - -```mermaid -classDiagram - class RegisterUseCase { - <> - +register(String loginId, String name, String rawPassword, LocalDate birthday, String email) void - } - - class AuthenticationUseCase { - <> - +authenticate(UserId userId, String rawPassword) void - } - - class UserQueryUseCase { - <> - +getUserInfo(UserId userId) UserInfoResponse - } - - class UserInfoResponse { - <> - +String loginId - +String maskedName - +LocalDate birthday - +String email - } - - class PasswordUpdateUseCase { - <> - +updatePassword(UserId userId, String currentRawPassword, String newRawPassword) void - } - - class UserRegisterService { - -UserRepository userRepository - -PasswordEncoder passwordEncoder - +register(loginId, name, rawPassword, birthday, email) void - } - - class AuthenticationService { - -UserRepository userRepository - -PasswordEncoder passwordEncoder - +authenticate(UserId, String rawPassword) void - } - - class UserQueryService { - -UserRepository userRepository - +getUserInfo(UserId) UserInfoResponse - -maskName(String name) String - } - - class PasswordUpdateService { - -UserRepository userRepository - -PasswordEncoder passwordEncoder - +updatePassword(UserId, String currentRawPassword, String newRawPassword) void - } - - UserRegisterService ..|> RegisterUseCase - AuthenticationService ..|> AuthenticationUseCase - UserQueryService ..|> UserQueryUseCase - PasswordUpdateService ..|> PasswordUpdateUseCase - UserQueryUseCase +-- UserInfoResponse -``` - -### Service별 책임 - -| Service | UseCase | 트랜잭션 | 핵심 로직 | -|---|---|---|---| -| `UserRegisterService` | `RegisterUseCase` | `@Transactional` | 값 객체 검증 → 중복 확인 → 암호화 → 저장 | -| `AuthenticationService` | `AuthenticationUseCase` | `@Transactional(readOnly)` | 사용자 조회 → 비밀번호 매칭 | -| `UserQueryService` | `UserQueryUseCase` | `@Transactional(readOnly)` | 사용자 조회 → 이름 마스킹 | -| `PasswordUpdateService` | `PasswordUpdateUseCase` | `@Transactional` | 기존 PW 검증 → 신규 PW 검증 → 암호화 → 저장 | - ---- - -## 6-5. Interfaces 계층 — Controller & DTO - -Controller는 UseCase 인터페이스에만 의존하며, 도메인 객체를 직접 노출하지 않습니다. - -```mermaid -classDiagram - class UserController { - -RegisterUseCase registerUseCase - -UserQueryUseCase userQueryUseCase - -PasswordUpdateUseCase passwordUpdateUseCase - -AuthenticationUseCase authenticationUseCase - +register(UserRegisterRequest) ResponseEntity~Void~ - +getMyInfo(String loginId, String loginPw) ResponseEntity~UserInfoResponse~ - +updatePassword(String loginId, String loginPw, PasswordUpdateRequest) ResponseEntity~Void~ - } - - class UserRegisterRequest { - <> - +String loginId - +String password - +String name - +LocalDate birthday - +String email - } - - class UserInfoResponse { - <> - +String loginId - +String name - +String birthday - +String email - +from(UserQueryUseCase.UserInfoResponse)$ UserInfoResponse - } - - class PasswordUpdateRequest { - <> - +String currentPassword - +String newPassword - } - - UserController ..> UserRegisterRequest - UserController ..> UserInfoResponse - UserController ..> PasswordUpdateRequest -``` - -### API 엔드포인트 매핑 - -| HTTP Method | Path | DTO | 인증 | -|---|---|---|---| -| `POST` | `/api/v1/users/register` | `UserRegisterRequest` (Body) | 불필요 | -| `GET` | `/api/v1/users/me` | `UserInfoResponse` (응답) | `X-Loopers-LoginId`, `X-Loopers-LoginPw` | -| `PUT` | `/api/v1/users/me/password` | `PasswordUpdateRequest` (Body) | `X-Loopers-LoginId`, `X-Loopers-LoginPw` | - ---- - -## 6-6. Infrastructure 계층 — 기술 구현 - -도메인 인터페이스의 실제 구현을 담당합니다. 도메인 계층은 이 계층의 존재를 모릅니다. + UserRepositoryImpl ..|> UserRepository + UserRepositoryImpl --> UserJpaRepository + UserRepositoryImpl ..> UserJpaEntity + Sha256PasswordEncoder ..|> PasswordEncoder -```mermaid -classDiagram class UserRepositoryImpl { - -UserJpaRepository userJpaRepository +save(User) User +findById(UserId) Optional~User~ - +existsById(UserId) boolean -toEntity(User) UserJpaEntity -toDomain(UserJpaEntity) User } - class UserJpaRepository { <> - +findByUserId(String userId) Optional~UserJpaEntity~ - +existsByUserId(String userId) boolean + +findByUserId(String) Optional~UserJpaEntity~ + +existsByUserId(String) boolean } - class UserJpaEntity { -Long id -String userId @@ -361,96 +212,38 @@ classDiagram -String email -LocalDateTime createdAt } - class Sha256PasswordEncoder { - +encrypt(String rawPassword) String - +matches(String rawPassword, String encodedPassword) boolean - -generateSalt() String - -sha256(String input) String + +encrypt(String) String + +matches(String, String) boolean } - - UserRepositoryImpl ..|> UserRepository : implements - UserRepositoryImpl --> UserJpaRepository - UserRepositoryImpl ..> UserJpaEntity - Sha256PasswordEncoder ..|> PasswordEncoder : implements - UserJpaRepository ..> UserJpaEntity ``` -### 변환 흐름 (Domain ↔ Infrastructure) +**변환 흐름**: `User` → `toEntity()` → `UserJpaEntity` → JPA save → `toDomain()` → `User` -``` -저장: User → toEntity() → UserJpaEntity → JPA save → UserJpaEntity → toDomain() → User -조회: UserJpaRepository.findByUserId() → UserJpaEntity → toDomain() → User -``` - -### 암호화 형식 (SHA-256 + Salt) - -``` -encrypt("password123") -→ salt = Base64(SecureRandom 16bytes) -→ hash = Base64(SHA-256("password123" + salt)) -→ 저장 형식: "salt:hash" - -matches("password123", "salt:hash") -→ split(":") → salt, storedHash -→ inputHash = SHA-256("password123" + salt) -→ storedHash.equals(inputHash) -``` +**암호화 형식**: `salt:hash` (SHA-256 + Base64 Salt) --- -## 6-7. Support 계층 — 에러 처리 - -```mermaid -classDiagram - class GlobalExceptionHandler { - +handleCoreException(CoreException) ResponseEntity - +handleIllegalArgumentException(IllegalArgumentException) ResponseEntity - +handleValidationException(MethodArgumentNotValidException) ResponseEntity - +handleMissingHeaderException(MissingRequestHeaderException) ResponseEntity - +handleException(Exception) ResponseEntity - } - - class CoreException { - -ErrorType errorType - -String customMessage - } - - class ErrorType { - <> - INTERNAL_ERROR(500) - BAD_REQUEST(400) - NOT_FOUND(404) - CONFLICT(409) - -HttpStatus status - -String code - -String message - } - - GlobalExceptionHandler ..> CoreException - CoreException --> ErrorType -``` +## 6-4. 에러 처리 -### 예외 처리 매핑 - -| 예외 | HTTP 상태 | 코드 | 발생 위치 | -|---|---|---|---| -| `IllegalArgumentException` | 400 | `BAD_REQUEST` | Value Object 검증, Service 비즈니스 검증 | -| `MethodArgumentNotValidException` | 400 | `VALIDATION_ERROR` | DTO `@Valid` 어노테이션 검증 | -| `MissingRequestHeaderException` | 400 | `MISSING_HEADER` | 필수 헤더 누락 (`X-Loopers-LoginId` 등) | -| `CoreException` | ErrorType에 따름 | ErrorType.code | 명시적 도메인 예외 | -| `Exception` | 500 | `INTERNAL_ERROR` | 예상치 못한 서버 오류 | +| 예외 | HTTP 상태 | 발생 위치 | +|---|---|---| +| `IllegalArgumentException` | 400 | Value Object 검증, Service 비즈니스 검증 | +| `MethodArgumentNotValidException` | 400 | DTO `@Valid` 검증 | +| `MissingRequestHeaderException` | 400 | 필수 헤더 누락 | +| `CoreException` | ErrorType에 따름 | 명시적 도메인 예외 | +| `Exception` | 500 | 예상치 못한 서버 오류 | --- -## 6-8. 의존성 방향 요약 +## 6-5. 의존성 방향 요약 ``` ┌─────────────────────────────────────────────────────┐ │ Interfaces (Controller, DTO) │ │ └─ 의존 → UseCase 인터페이스 (Application 계층) │ ├─────────────────────────────────────────────────────┤ -│ Application (UseCase, Service) │ +│ Application (UseCase, UserService) │ │ └─ 의존 → Domain 인터페이스 (Repository, Encoder) │ ├─────────────────────────────────────────────────────┤ │ Domain (User, Value Objects, Interface) │ @@ -461,76 +254,43 @@ classDiagram └─────────────────────────────────────────────────────┘ ``` -- Domain 계층은 **Spring, JPA, 외부 라이브러리에 의존하지 않음** (Lombok 제외) -- Application 계층은 **Domain 인터페이스에만 의존** (구현체를 모름) -- Infrastructure 계층은 **Domain 인터페이스를 구현**하며 의존 방향을 역전 (DIP) - --- # 향후 확장 도메인 설계 (미래 목표) -> 아래 내용은 `01-requirements.md`에 정의된 기능 요구사항을 기반으로 설계한 **미래 구현 목표**입니다. -> 현재 구현된 User 도메인과 동일한 클린 아키텍처 패턴을 따릅니다. - ---- +> `01-requirements.md`에 정의된 기능 요구사항 기반의 **미래 구현 목표**입니다. -## 6-9. 전체 도메인 관계도 +## 6-6. 전체 도메인 관계도 ```mermaid classDiagram direction TB - class User { - <> - } - class Brand { - <> - } - class Product { - <> - } - class Like { - <> - } - class Coupon { - <> - } - class UserCoupon { - <> - } - class Order { - <> - } - class OrderItem { - <> - } - class OrderSnapshot { - <> - } + class User { <> } + class Brand { <> } + class Product { <> } + class Like { <> } + class Order { <> } + class OrderItem { <> } + class OrderSnapshot { <> } User "1" --> "*" Like : 좋아요 - User "1" --> "*" UserCoupon : 쿠폰 보유 User "1" --> "*" Order : 주문 - Brand "1" --> "*" Product : 보유 상품 - Product "1" --> "*" Like : 좋아요 대상 Product "1" --> "*" OrderItem : 주문 항목 - - Coupon "1" --> "*" UserCoupon : 발급 - Order "1" *-- "*" OrderItem : 주문 상세 Order "1" *-- "1" OrderSnapshot : 주문 시점 스냅샷 ``` --- -## 6-10. Brand 도메인 - -### Domain 계층 +## 6-7. Brand 도메인 ```mermaid classDiagram + Brand *-- BrandName + class Brand { <> -Long id @@ -538,63 +298,35 @@ classDiagram -String description -LocalDateTime createdAt +register(name, description)$ Brand - +reconstitute(id, name, description, createdAt)$ Brand - +update(BrandName name, String description) Brand + +reconstitute(...)$ Brand + +update(BrandName, String) Brand } - class BrandName { <> -String value - +of(String value)$ BrandName + +of(String)$ BrandName } - - Brand *-- BrandName ``` -### Application 계층 - -```mermaid -classDiagram - class BrandQueryUseCase { - <> - +getBrand(Long brandId) BrandResponse - } - - class AdminBrandUseCase { - <> - +listBrands() List~BrandResponse~ - +registerBrand(String name, String description) void - +updateBrand(Long id, String name, String description) void - +deleteBrand(Long id) void - } - - class BrandRepository { - <> - +save(Brand) Brand - +findById(Long id) Optional~Brand~ - +findAll() List~Brand~ - +deleteById(Long id) void - } -``` - -### API 엔드포인트 - -| Role | HTTP Method | Path | UseCase | +| Role | Method | Path | UseCase | |---|---|---|---| | Any | `GET` | `/api/v1/brands/{brandId}` | `BrandQueryUseCase` | | Admin | `GET` | `/api-admin/v1/brands` | `AdminBrandUseCase` | | Admin | `POST` | `/api-admin/v1/brands` | `AdminBrandUseCase` | | Admin | `PUT` | `/api-admin/v1/brands/{id}` | `AdminBrandUseCase` | -| Admin | `DELETE` | `/api-admin/v1/brands/{id}` | `AdminBrandUseCase` — 하위 상품 Cascade 삭제 | +| Admin | `DELETE` | `/api-admin/v1/brands/{id}` | `AdminBrandUseCase` (하위 상품 Cascade) | --- -## 6-11. Product 도메인 - -### Domain 계층 +## 6-8. Product 도메인 ```mermaid classDiagram + Product *-- ProductName + Product *-- Money + Product *-- StockQuantity + Product *-- "0..*" ProductImage + class Product { <> -Long id @@ -605,108 +337,36 @@ classDiagram -String description -List~ProductImage~ images -int likeCount - -LocalDateTime createdAt - +register(brandId, name, price, stockQuantity, description, images)$ Product - +reconstitute(...)$ Product - +update(ProductName, Money, StockQuantity, String) Product - +decreaseStock(int quantity) Product + +register(...)$ Product + +decreaseStock(int) Product +isOutOfStock() boolean } - - class ProductName { - <> - -String value - +of(String value)$ ProductName - } - class Money { <> -int value - +of(int value)$ Money - +isDiscounted(Money originalPrice) boolean - +discountRate(Money originalPrice) int + +of(int)$ Money } - class StockQuantity { <> -int value - +of(int value)$ StockQuantity - +decrease(int quantity) StockQuantity + +decrease(int) StockQuantity +isZero() boolean } - - class ProductImage { - <> - -String imageUrl - -int sortOrder - } - - Product *-- ProductName - Product *-- Money - Product *-- StockQuantity - Product *-- "0..*" ProductImage -``` - -### Application 계층 - -```mermaid -classDiagram - class ProductListUseCase { - <> - +getProductList(Long brandId, String sort, int page, int size) PageResponse - } - - class ProductDetailUseCase { - <> - +getProductDetail(Long productId) ProductDetailResponse - } - - class AdminProductUseCase { - <> - +registerProduct(Long brandId, String name, int price, int stock, String desc, List~file~ images) void - +updateProduct(Long id, String name, int price, int stock, String desc) void - +deleteProduct(Long id) void - } - - class ProductRepository { - <> - +save(Product) Product - +findById(Long id) Optional~Product~ - +findByBrandId(Long brandId, String sort, int page, int size) Page~Product~ - +deleteById(Long id) void - +deleteByBrandId(Long brandId) void - } ``` -### 설계 포인트 - -- **`brandId` 변경 불가**: 상품 수정 시 브랜드 변경 불가 (Immutable 제약) -- **Soft Delete 권장**: 삭제 시 `deletedAt` 설정 (`BaseEntity` 상속) -- **브랜드 삭제 Cascade**: `AdminBrandUseCase.deleteBrand()` 호출 시 `ProductRepository.deleteByBrandId()` 연계 - -### API 엔드포인트 - -| Role | HTTP Method | Path | UseCase | -|---|---|---|---| -| Any | `GET` | `/api/v1/products?brandId=&sort=&page=&size=` | `ProductListUseCase` | -| Any | `GET` | `/api/v1/products/{productId}` | `ProductDetailUseCase` | -| Admin | `POST` | `/api-admin/v1/products` | `AdminProductUseCase` | -| Admin | `PUT` | `/api-admin/v1/products/{id}` | `AdminProductUseCase` | -| Admin | `DELETE` | `/api-admin/v1/products/{id}` | `AdminProductUseCase` | - -### 정렬 옵션 +| Role | Method | Path | +|---|---|---| +| Any | `GET` | `/api/v1/products?brandId=&sort=&page=&size=` | +| Any | `GET` | `/api/v1/products/{productId}` | +| Admin | `POST` | `/api-admin/v1/products` | +| Admin | `PUT` | `/api-admin/v1/products/{id}` | +| Admin | `DELETE` | `/api-admin/v1/products/{id}` | -| `sort` 값 | 설명 | -|---|---| -| `latest` (기본) | 최신 등록순 | -| `price_asc` | 가격 낮은순 | -| `likes_desc` | 좋아요 많은순 | +정렬: `latest` (기본) | `price_asc` | `likes_desc` --- -## 6-12. Like 도메인 - -### Domain 계층 +## 6-9. Like 도메인 ```mermaid classDiagram @@ -717,218 +377,55 @@ classDiagram -Long productId -LocalDateTime createdAt +create(userId, productId)$ Like - +reconstitute(id, userId, productId, createdAt)$ Like } - class LikeRepository { <> +save(Like) Like - +delete(UserId userId, Long productId) void - +findByUserId(UserId userId, LikeFilter filter, String sort, int page, int size) Page~Like~ - +existsByUserIdAndProductId(UserId userId, Long productId) boolean - } - - class LikeFilter { - <> - -Boolean saleYn - -String status - } - - Like *-- UserId - Like ..> LikeFilter : 목록 조회 시 필터 -``` - -### Application 계층 - -```mermaid -classDiagram - class LikeUseCase { - <> - +addLike(UserId userId, Long productId) void - +removeLike(UserId userId, Long productId) void - } - - class LikeQueryUseCase { - <> - +getMyLikes(UserId userId, Boolean saleYn, String status, String sort, int page, int size) PageResponse + +delete(UserId, Long) void + +existsByUserIdAndProductId(UserId, Long) boolean } ``` -### 설계 포인트 - -- **멱등성(Idempotency)**: 이미 좋아요한 상품에 다시 좋아요 → 예외 없이 무시 또는 409 Conflict +- **멱등성**: 이미 좋아요한 상품에 다시 좋아요 → 예외 없이 무시 - **유저당 1상품 1좋아요**: `UNIQUE(user_id, product_id)` 제약 - **좋아요 수 동기화**: `Like` 생성/삭제 시 `Product.likeCount` 증감 -### API 엔드포인트 - -| Role | HTTP Method | Path | UseCase | -|---|---|---|---| -| User | `POST` | `/api/v1/products/{id}/likes` | `LikeUseCase` | -| User | `DELETE` | `/api/v1/products/{id}/likes` | `LikeUseCase` | -| User | `GET` | `/api/v1/users/me/likes` | `LikeQueryUseCase` | +| Role | Method | Path | +|---|---|---| +| User | `POST` | `/api/v1/products/{id}/likes` | +| User | `DELETE` | `/api/v1/products/{id}/likes` | +| User | `GET` | `/api/v1/users/me/likes` | --- -## 6-13. Coupon 도메인 - -### Domain 계층 +## 6-10. Order 도메인 ```mermaid classDiagram - class Coupon { - <> - -Long id - -CouponName name - -Money discountAmount - -Money minimumOrderAmount - -int totalQuantity - -int issuedQuantity - -LocalDateTime startAt - -LocalDateTime endAt - -LocalDateTime createdAt - +register(name, discountAmount, minimumOrderAmount, totalQuantity, startAt, endAt)$ Coupon - +reconstitute(...)$ Coupon - +issue() Coupon - +isSoldOut() boolean - +isExpired() boolean - +isAvailable() boolean - } - - class CouponName { - <> - -String value - +of(String value)$ CouponName - } - - class UserCoupon { - <> - -Long id - -UserId userId - -Long couponId - -boolean isUsed - -LocalDateTime issuedAt - -LocalDateTime usedAt - +issue(userId, couponId)$ UserCoupon - +use() UserCoupon - +isAvailable(LocalDateTime now) boolean - } - - class CouponRepository { - <> - +save(Coupon) Coupon - +findById(Long id) Optional~Coupon~ - } - - class UserCouponRepository { - <> - +save(UserCoupon) UserCoupon - +findByUserId(UserId userId) List~UserCoupon~ - +existsByUserIdAndCouponId(UserId userId, Long couponId) boolean - } - - Coupon *-- CouponName - Coupon *-- Money : discountAmount - Coupon *-- Money : minimumOrderAmount - UserCoupon --> Coupon : couponId - UserCoupon *-- UserId -``` - -### Application 계층 - -```mermaid -classDiagram - class CouponIssueUseCase { - <> - +issueCoupon(UserId userId, Long couponId) void - } - - class CouponQueryUseCase { - <> - +getMyCoupons(UserId userId) List~CouponResponse~ - } - - class CouponIssueService { - -CouponRepository couponRepository - -UserCouponRepository userCouponRepository - +issueCoupon(UserId userId, Long couponId) void - } - - CouponIssueService ..|> CouponIssueUseCase -``` - -### 설계 포인트 - -- **선착순 동시성 제어**: `Coupon.issue()` 시 `issuedQuantity` 증가 — 비관적 락 또는 Redis 활용 -- **유저당 1회 발급**: `existsByUserIdAndCouponId()` 체크 → 중복 시 409 Conflict -- **쿠폰 상태**: 사용가능 / 사용완료 / 만료 — `UserCoupon.isAvailable()` 판정 -- **Sold Out**: `totalQuantity <= issuedQuantity` → `COUPON_SOLD_OUT` - -### API 엔드포인트 - -| Role | HTTP Method | Path | UseCase | -|---|---|---|---| -| User | `POST` | `/api/v1/coupons/{id}/issue` | `CouponIssueUseCase` | -| User | `GET` | `/api/v1/users/me/coupons` | `CouponQueryUseCase` | - ---- - -## 6-14. Order 도메인 - -### Domain 계층 + Order *-- "1..*" OrderItem + Order *-- "1" OrderSnapshot + Order *-- "1" ShippingInfo + Order *-- "1" PaymentMethod + Order --> OrderStatus -```mermaid -classDiagram class Order { <> -Long id -UserId userId - -List~OrderItem~ items - -OrderSnapshot snapshot - -ShippingInfo shippingInfo - -PaymentMethod paymentMethod -Money totalAmount -Money discountAmount -Money paymentAmount -OrderStatus status - -Long couponId - -LocalDate desiredDeliveryDate - -LocalDateTime createdAt - +create(userId, items, shippingInfo, paymentMethod, couponId, desiredDeliveryDate)$ Order - +reconstitute(...)$ Order + +create(...)$ Order +cancel() Order - +updateShippingInfo(ShippingInfo) Order +isCancellable() boolean - +isShippingChangeable() boolean } - class OrderItem { <> - -Long id -Long productId -int quantity -Money price } - - class OrderSnapshot { - <> - -String snapshotData - +capture(List~OrderItem~, List~Product~)$ OrderSnapshot - } - - class ShippingInfo { - <> - -String receiverName - -String address - -String request - } - - class PaymentMethod { - <> - -String type - -String detail - } - class OrderStatus { <> PAYMENT_COMPLETED @@ -936,132 +433,38 @@ classDiagram SHIPPING DELIVERED CANCELLED - +isCancellable() boolean - +isShippingChangeable() boolean } - - Order *-- "1..*" OrderItem - Order *-- "1" OrderSnapshot - Order *-- "1" ShippingInfo - Order *-- "1" PaymentMethod - Order --> OrderStatus - Order *-- UserId - Order *-- Money : totalAmount - Order *-- Money : discountAmount - Order *-- Money : paymentAmount - OrderItem *-- Money : price -``` - -### Application 계층 - -```mermaid -classDiagram - class OrderCreateUseCase { - <> - +createOrder(UserId, List~OrderItemRequest~, ShippingInfo, PaymentMethod, Long couponId, LocalDate desiredDate) void - } - - class OrderQueryUseCase { - <> - +getMyOrders(UserId userId, LocalDate from, LocalDate to) List~OrderResponse~ - +getOrderDetail(UserId userId, Long orderId) OrderDetailResponse - } - - class AdminOrderQueryUseCase { - <> - +getAllOrders() List~OrderResponse~ - } - - class OrderCreateService { - -OrderRepository orderRepository - -ProductRepository productRepository - -CouponRepository couponRepository - -UserCouponRepository userCouponRepository - +createOrder(...) void + class OrderSnapshot { + <> + -String snapshotData + +capture(...)$ OrderSnapshot } - - OrderCreateService ..|> OrderCreateUseCase ``` -### 주문 생성 프로세스 - -``` -1. 재고 확인 → Product.isOutOfStock() 체크 -2. 재고 차감 → Product.decreaseStock(quantity) -3. 쿠폰 적용 → UserCoupon.use(), 할인 금액 계산 -4. 결제 금액 검증 → totalAmount - discountAmount = paymentAmount -5. 스냅샷 생성 → OrderSnapshot.capture() — 주문 시점 상품 정보 보존 -6. 주문 생성 → Order.create() + OrderItems -``` - -### 주문 상태별 가능 액션 +**주문 생성 프로세스**: 재고 확인 → 재고 차감 → 결제 금액 검증 → 스냅샷 생성 → 주문 생성 | 상태 | 주문 취소 | 배송지 변경 | |---|---|---| -| `PAYMENT_COMPLETED` (결제완료) | 가능 | 가능 | -| `PREPARING` (상품준비중) | 가능 | 가능 | -| `SHIPPING` (배송중) | 불가 (반품 절차) | 불가 | -| `DELIVERED` (배송완료) | 불가 (반품 절차) | 불가 | - -### 영수증 (Receipt) - -| 유형 | 포함 정보 | -|---|---| -| 카드영수증 | 상점정보, 결제일시, 금액 | -| 거래명세서 | 공급자/공급받는자 정보, 품목, 세액, 비고 | +| `PAYMENT_COMPLETED` | 가능 | 가능 | +| `PREPARING` | 가능 | 가능 | +| `SHIPPING` | 불가 | 불가 | +| `DELIVERED` | 불가 | 불가 | -### API 엔드포인트 - -| Role | HTTP Method | Path | UseCase | -|---|---|---|---| -| User | `POST` | `/api/v1/orders` | `OrderCreateUseCase` | -| User | `GET` | `/api/v1/orders/me` | `OrderQueryUseCase` | -| User | `GET` | `/api/v1/orders/{id}` | `OrderQueryUseCase` | -| Admin | `GET` | `/api-admin/v1/orders` | `AdminOrderQueryUseCase` | +| Role | Method | Path | +|---|---|---| +| User | `POST` | `/api/v1/orders` | +| User | `GET` | `/api/v1/orders/me` | +| User | `GET` | `/api/v1/orders/{id}` | +| Admin | `GET` | `/api-admin/v1/orders` | --- -## 6-15. Admin 인증 설계 - -관리자 API는 `X-Loopers-Ldap` 헤더를 통해 권한을 검증합니다. - -```mermaid -classDiagram - class AdminGuard { - <> - +validateAdmin(String ldapHeader) void - } - - class AdminGuardImpl { - -String ADMIN_LDAP_VALUE = "loopers.admin" - +validateAdmin(String ldapHeader) void - } - - class AdminBrandController { - -AdminGuard adminGuard - -AdminBrandUseCase adminBrandUseCase - } - - class AdminProductController { - -AdminGuard adminGuard - -AdminProductUseCase adminProductUseCase - } - - class AdminOrderController { - -AdminGuard adminGuard - -AdminOrderQueryUseCase adminOrderQueryUseCase - } - - AdminGuardImpl ..|> AdminGuard - AdminBrandController --> AdminGuard - AdminProductController --> AdminGuard - AdminOrderController --> AdminGuard -``` +## 6-11. Admin 인증 -### 접근 제어 규칙 +관리자 API는 `X-Loopers-Ldap` 헤더로 권한 검증합니다. | 규칙 | 설명 | |---|---| | Admin 인증 | `X-Loopers-Ldap: loopers.admin` 헤더 필수 | -| User 접근 차단 | 일반 유저가 `/api-admin/**` 호출 시 403 Forbidden | -| 타 유저 접근 차단 | 유저는 자신의 정보만 조회 가능 (주문, 좋아요, 쿠폰 등) | +| User 접근 차단 | `/api-admin/**` 호출 시 403 Forbidden | +| 타 유저 접근 차단 | 유저는 자신의 정보만 조회 가능 | diff --git a/apps/commerce-api/src/main/java/com/loopers/application/service/AuthenticationService.java b/apps/commerce-api/src/main/java/com/loopers/application/service/AuthenticationService.java deleted file mode 100644 index 9ea21ed0..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/service/AuthenticationService.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.loopers.application.service; - -import com.loopers.application.AuthenticationUseCase; -import com.loopers.domain.model.User; -import com.loopers.domain.model.UserId; -import com.loopers.domain.repository.UserRepository; -import com.loopers.domain.service.PasswordEncoder; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@Transactional(readOnly = true) -public class AuthenticationService implements AuthenticationUseCase { - - private final UserRepository userRepository; - private final PasswordEncoder passwordEncoder; - - public AuthenticationService(UserRepository userRepository, PasswordEncoder passwordEncoder) { - this.userRepository = userRepository; - this.passwordEncoder = passwordEncoder; - } - - @Override - public void authenticate(UserId userId, String rawPassword) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); - - if (!passwordEncoder.matches(rawPassword, user.getEncodedPassword())) { - throw new IllegalArgumentException("비밀번호가 일치하지 않습니다."); - } - } -} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/application/service/PasswordUpdateService.java b/apps/commerce-api/src/main/java/com/loopers/application/service/PasswordUpdateService.java deleted file mode 100644 index 1de70c1e..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/service/PasswordUpdateService.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.loopers.application.service; - -import com.loopers.application.PasswordUpdateUseCase; -import com.loopers.domain.model.Password; -import com.loopers.domain.model.User; -import com.loopers.domain.model.UserId; -import com.loopers.domain.repository.UserRepository; -import com.loopers.domain.service.PasswordEncoder; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDate; - -@Service -public class PasswordUpdateService implements PasswordUpdateUseCase { - - private final UserRepository userRepository; - private final PasswordEncoder passwordEncoder; - - public PasswordUpdateService(UserRepository userRepository, PasswordEncoder passwordEncoder) { - this.userRepository = userRepository; - this.passwordEncoder = passwordEncoder; - } - - @Override - @Transactional - public void updatePassword(UserId userId, String currentRawPassword, String newRawPassword) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); - - LocalDate birthday = user.getBirth().getValue(); - Password currentPassword = Password.of(currentRawPassword, birthday); - Password newPassword = Password.of(newRawPassword, birthday); - - // 기존 비밀번호 확인 - if (!passwordEncoder.matches(currentPassword.getValue(), user.getEncodedPassword())) { - throw new IllegalArgumentException("현재 비밀번호가 일치하지 않습니다."); - } - - // 새 비밀번호가 현재 비밀번호와 동일한지 확인 - if (passwordEncoder.matches(newPassword.getValue(), user.getEncodedPassword())) { - throw new IllegalArgumentException("현재 비밀번호는 사용할 수 없습니다."); - } - - // 비밀번호 암호화 후 저장 - String encodedNewPassword = passwordEncoder.encrypt(newPassword.getValue()); - User updatedUser = user.changePassword(encodedNewPassword); - userRepository.save(updatedUser); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/service/UserQueryService.java b/apps/commerce-api/src/main/java/com/loopers/application/service/UserQueryService.java deleted file mode 100644 index 0dc883ca..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/service/UserQueryService.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.loopers.application.service; - -import com.loopers.application.UserQueryUseCase; -import com.loopers.domain.model.User; -import com.loopers.domain.model.UserId; -import com.loopers.domain.repository.UserRepository; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@Transactional(readOnly = true) -public class UserQueryService implements UserQueryUseCase { - - private final UserRepository userRepository; - - public UserQueryService(UserRepository userRepository) { - this.userRepository = userRepository; - } - - @Override - public UserInfoResponse getUserInfo(UserId userId) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); - - return new UserInfoResponse( - user.getUserId().getValue(), - maskName(user.getUserName().getValue()), - user.getBirth().getValue(), - user.getEmail().getValue() - ); - } - - private String maskName(String name) { - if (name == null || name.isEmpty()) { - return name; - } - 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/UserRegisterService.java b/apps/commerce-api/src/main/java/com/loopers/application/service/UserRegisterService.java deleted file mode 100644 index 4c0f10eb..00000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/service/UserRegisterService.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.loopers.application.service; - -import com.loopers.application.RegisterUseCase; -import com.loopers.domain.model.*; -import com.loopers.domain.repository.UserRepository; -import com.loopers.domain.service.PasswordEncoder; -import org.springframework.dao.DataIntegrityViolationException; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDate; -import java.time.LocalDateTime; - -@Service -public class UserRegisterService implements RegisterUseCase { - - private final UserRepository userRepository; - private final PasswordEncoder passwordEncoder; - - public UserRegisterService(UserRepository userRepository, PasswordEncoder passwordEncoder) { - this.userRepository = userRepository; - this.passwordEncoder = passwordEncoder; - } - - @Override - @Transactional - public void register(String loginId, String name, String rawPassword, LocalDate birthday, String email) { - UserId userId = UserId.of(loginId); - UserName userName = UserName.of(name); - Birthday birth = Birthday.of(birthday); - Email userEmail = Email.of(email); - Password password = Password.of(rawPassword, birthday); - String encodedPassword = passwordEncoder.encrypt(password.getValue()); - - if (userRepository.existsById(userId)) { - throw new IllegalArgumentException("이미 사용중인 ID 입니다."); - } - - User user = User.register( - userId, - userName, - encodedPassword, - birth, - userEmail, - WrongPasswordCount.init(), - LocalDateTime.now() - ); - userRepository.save(user); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/service/UserService.java b/apps/commerce-api/src/main/java/com/loopers/application/service/UserService.java new file mode 100644 index 00000000..46c72079 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/service/UserService.java @@ -0,0 +1,107 @@ +package com.loopers.application.service; + +import com.loopers.application.AuthenticationUseCase; +import com.loopers.application.PasswordUpdateUseCase; +import com.loopers.application.RegisterUseCase; +import com.loopers.application.UserQueryUseCase; +import com.loopers.domain.model.*; +import com.loopers.domain.repository.UserRepository; +import com.loopers.domain.service.PasswordEncoder; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Service +@Transactional(readOnly = true) +public class UserService implements RegisterUseCase, AuthenticationUseCase, PasswordUpdateUseCase, UserQueryUseCase { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) { + this.userRepository = userRepository; + this.passwordEncoder = passwordEncoder; + } + + @Override + @Transactional + public void register(String loginId, String name, String rawPassword, LocalDate birthday, String email) { + UserId userId = UserId.of(loginId); + UserName userName = UserName.of(name); + Birthday birth = Birthday.of(birthday); + Email userEmail = Email.of(email); + Password password = Password.of(rawPassword, birthday); + String encodedPassword = passwordEncoder.encrypt(password.getValue()); + + try { + User user = User.register( + userId, userName, encodedPassword, birth, + userEmail, WrongPasswordCount.init(), LocalDateTime.now() + ); + userRepository.save(user); + } catch (DataIntegrityViolationException ex) { + throw new IllegalArgumentException("이미 사용중인 ID 입니다.", ex); + } + } + + @Override + public void authenticate(UserId userId, String rawPassword) { + User user = findUser(userId); + + if (!passwordEncoder.matches(rawPassword, user.getEncodedPassword())) { + throw new IllegalArgumentException("비밀번호가 일치하지 않습니다."); + } + } + + @Override + @Transactional + public void updatePassword(UserId userId, String currentRawPassword, String newRawPassword) { + User user = findUser(userId); + + LocalDate birthday = user.getBirth().getValue(); + Password currentPassword = Password.of(currentRawPassword, birthday); + Password newPassword = Password.of(newRawPassword, birthday); + + if (!passwordEncoder.matches(currentPassword.getValue(), user.getEncodedPassword())) { + throw new IllegalArgumentException("현재 비밀번호가 일치하지 않습니다."); + } + + if (passwordEncoder.matches(newPassword.getValue(), user.getEncodedPassword())) { + throw new IllegalArgumentException("현재 비밀번호는 사용할 수 없습니다."); + } + + String encodedNewPassword = passwordEncoder.encrypt(newPassword.getValue()); + User updatedUser = user.changePassword(encodedNewPassword); + userRepository.save(updatedUser); + } + + @Override + public UserInfoResponse getUserInfo(UserId userId) { + User user = findUser(userId); + + return new UserInfoResponse( + user.getUserId().getValue(), + maskName(user.getUserName().getValue()), + user.getBirth().getValue(), + user.getEmail().getValue() + ); + } + + private User findUser(UserId userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); + } + + private String maskName(String name) { + if (name == null || name.isEmpty()) { + return name; + } + if (name.length() == 1) { + return "*"; + } + return name.substring(0, name.length() - 1) + "*"; + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/service/AuthenticationServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/service/AuthenticationServiceTest.java deleted file mode 100644 index d0b5e776..00000000 --- a/apps/commerce-api/src/test/java/com/loopers/application/service/AuthenticationServiceTest.java +++ /dev/null @@ -1,100 +0,0 @@ -package com.loopers.application.service; - -import com.loopers.domain.model.*; -import com.loopers.domain.repository.UserRepository; -import com.loopers.domain.service.PasswordEncoder; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.*; - -class AuthenticationServiceTest { - - private UserRepository userRepository; - private PasswordEncoder passwordEncoder; - private AuthenticationService service; - - @BeforeEach - void setUp() { - userRepository = mock(UserRepository.class); - passwordEncoder = mock(PasswordEncoder.class); - service = new AuthenticationService(userRepository, passwordEncoder); - } - - @Test - @DisplayName("인증 성공") - void authenticate_success() { - // given - UserId userId = UserId.of("test1234"); - String rawPassword = "Password1!"; - String encodedPassword = "encoded_password"; - - User user = User.reconstitute( - 1L, - userId, - UserName.of("홍길동"), - encodedPassword, - Birthday.of(LocalDate.of(1990, 5, 15)), - Email.of("test@example.com"), - WrongPasswordCount.init(), - LocalDateTime.now() - ); - - when(userRepository.findById(userId)).thenReturn(Optional.of(user)); - when(passwordEncoder.matches(rawPassword, encodedPassword)).thenReturn(true); - - // when & then - 예외가 발생하지 않으면 성공 - service.authenticate(userId, rawPassword); - - verify(userRepository).findById(userId); - verify(passwordEncoder).matches(rawPassword, encodedPassword); - } - - @Test - @DisplayName("존재하지 않는 사용자 인증 실패") - void authenticate_fail_userNotFound() { - // given - UserId userId = UserId.of("notexist"); - - when(userRepository.findById(userId)).thenReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> service.authenticate(userId, "password")) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("사용자를 찾을 수 없습니다"); - } - - @Test - @DisplayName("비밀번호 불일치 인증 실패") - void authenticate_fail_passwordMismatch() { - // given - UserId userId = UserId.of("test1234"); - String wrongPassword = "WrongPassword1!"; - String encodedPassword = "encoded_password"; - - User user = User.reconstitute( - 1L, - userId, - UserName.of("홍길동"), - encodedPassword, - Birthday.of(LocalDate.of(1990, 5, 15)), - Email.of("test@example.com"), - WrongPasswordCount.init(), - LocalDateTime.now() - ); - - when(userRepository.findById(userId)).thenReturn(Optional.of(user)); - when(passwordEncoder.matches(wrongPassword, encodedPassword)).thenReturn(false); - - // when & then - assertThatThrownBy(() -> service.authenticate(userId, wrongPassword)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("비밀번호가 일치하지 않습니다"); - } -} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/application/service/PasswordUpdateServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/service/PasswordUpdateServiceTest.java deleted file mode 100644 index 33a30c4a..00000000 --- a/apps/commerce-api/src/test/java/com/loopers/application/service/PasswordUpdateServiceTest.java +++ /dev/null @@ -1,123 +0,0 @@ -package com.loopers.application.service; - -import com.loopers.domain.model.*; -import com.loopers.domain.repository.UserRepository; -import com.loopers.domain.service.PasswordEncoder; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThatNoException; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -class PasswordUpdateServiceTest { - - private UserRepository userRepository; - private PasswordEncoder passwordEncoder; - private PasswordUpdateService service; - - private static final LocalDate BIRTHDAY = LocalDate.of(1990, 5, 15); - - @BeforeEach - void setUp() { - userRepository = mock(UserRepository.class); - passwordEncoder = mock(PasswordEncoder.class); - service = new PasswordUpdateService(userRepository, passwordEncoder); - } - - @Test - @DisplayName("비밀번호 변경 성공") - void updatePassword_success() { - // given - UserId userId = UserId.of("test1234"); - User user = createUser(userId, "encoded_current"); - String currentRawPassword = "Current1!"; - String newRawPassword = "NewPass1!"; - - when(userRepository.findById(userId)).thenReturn(Optional.of(user)); - when(passwordEncoder.matches(currentRawPassword, "encoded_current")).thenReturn(true); - when(passwordEncoder.matches(newRawPassword, "encoded_current")).thenReturn(false); - when(passwordEncoder.encrypt(newRawPassword)).thenReturn("encoded_new"); - - // when & then - assertThatNoException() - .isThrownBy(() -> service.updatePassword(userId, currentRawPassword, newRawPassword)); - - verify(userRepository).save(any(User.class)); - } - - @Test - @DisplayName("현재 비밀번호 불일치시 예외") - void updatePassword_fail_wrong_current() { - // given - UserId userId = UserId.of("test1234"); - User user = createUser(userId, "encoded_current"); - String wrongRawPassword = "WrongPw1!"; - String newRawPassword = "NewPass1!"; - - when(userRepository.findById(userId)).thenReturn(Optional.of(user)); - when(passwordEncoder.matches(wrongRawPassword, "encoded_current")).thenReturn(false); - - // when & then - assertThatThrownBy(() -> service.updatePassword(userId, wrongRawPassword, newRawPassword)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("현재 비밀번호가 일치하지 않습니다"); - - verify(userRepository, never()).save(any(User.class)); - } - - @Test - @DisplayName("새 비밀번호가 현재와 동일하면 예외") - void updatePassword_fail_same_password() { - // given - UserId userId = UserId.of("test1234"); - User user = createUser(userId, "encoded_current"); - String currentRawPassword = "Current1!"; - String sameRawPassword = "Current1!"; - - when(userRepository.findById(userId)).thenReturn(Optional.of(user)); - when(passwordEncoder.matches(currentRawPassword, "encoded_current")).thenReturn(true); - - // when & then - assertThatThrownBy(() -> service.updatePassword(userId, currentRawPassword, sameRawPassword)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("현재 비밀번호는 사용할 수 없습니다"); - - verify(userRepository, never()).save(any(User.class)); - } - - @Test - @DisplayName("존재하지 않는 사용자면 예외") - void updatePassword_fail_user_not_found() { - // given - UserId userId = UserId.of("notexist"); - String currentRawPassword = "Current1!"; - String newRawPassword = "NewPass1!"; - - when(userRepository.findById(userId)).thenReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> service.updatePassword(userId, currentRawPassword, newRawPassword)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("사용자를 찾을 수 없습니다"); - } - - private User createUser(UserId userId, String encodedPassword) { - return User.reconstitute( - 1L, - userId, - UserName.of("홍길동"), - encodedPassword, - Birthday.of(BIRTHDAY), - Email.of("test@example.com"), - WrongPasswordCount.init(), - LocalDateTime.now() - ); - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/service/UserQueryServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/service/UserQueryServiceTest.java deleted file mode 100644 index 230dba2a..00000000 --- a/apps/commerce-api/src/test/java/com/loopers/application/service/UserQueryServiceTest.java +++ /dev/null @@ -1,105 +0,0 @@ -package com.loopers.application.service; - -import com.loopers.domain.model.*; -import com.loopers.domain.repository.UserRepository; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -class UserQueryServiceTest { - - private UserRepository userRepository; - private UserQueryService service; - - @BeforeEach - void setUp() { - userRepository = mock(UserRepository.class); - service = new UserQueryService(userRepository); - } - - @Test - @DisplayName("내 정보 조회 성공") - void getUserInfo_success() { - // given - UserId userId = UserId.of("test1234"); - LocalDate birthday = LocalDate.of(1990, 5, 15); - User user = User.reconstitute( - 1L, - userId, - UserName.of("홍길동"), - "encoded_password", - Birthday.of(birthday), - Email.of("test@example.com"), - WrongPasswordCount.init(), - LocalDateTime.now() - ); - - when(userRepository.findById(userId)).thenReturn(Optional.of(user)); - - // when - var result = service.getUserInfo(userId); - - // then - assertThat(result.loginId()).isEqualTo("test1234"); - assertThat(result.maskedName()).isEqualTo("홍길*"); - assertThat(result.birthday()).isEqualTo(birthday); - assertThat(result.email()).isEqualTo("test@example.com"); - } - - @Test - @DisplayName("이름 마스킹 - 2자") - void getUserInfo_maskedName_2chars() { - // given - UserId userId = UserId.of("test1234"); - User user = User.reconstitute( - 1L, - userId, - UserName.of("홍길"), - "encoded_password", - Birthday.of(LocalDate.of(1990, 5, 15)), - Email.of("test@example.com"), - WrongPasswordCount.init(), - LocalDateTime.now() - ); - - when(userRepository.findById(userId)).thenReturn(Optional.of(user)); - - // when - var result = service.getUserInfo(userId); - - // then - assertThat(result.maskedName()).isEqualTo("홍*"); - } - - @Test - @DisplayName("존재하지 않는 사용자 조회시 예외") - void getUserInfo_fail_not_found() { - // given - UserId userId = UserId.of("notexist"); - - when(userRepository.findById(userId)).thenReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> service.getUserInfo(userId)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("사용자를 찾을 수 없습니다"); - } - - @Test - @DisplayName("이름은 최소 2자 이상이어야 한다") - void userName_fail_lessThan2chars() { - // UserName은 2~20자만 허용하므로 1자는 생성 불가 - assertThatThrownBy(() -> UserName.of("홍")) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("2~20자"); - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/service/UserRegisterServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/service/UserRegisterServiceTest.java deleted file mode 100644 index 86721148..00000000 --- a/apps/commerce-api/src/test/java/com/loopers/application/service/UserRegisterServiceTest.java +++ /dev/null @@ -1,71 +0,0 @@ -package com.loopers.application.service; - -import com.loopers.domain.model.User; -import com.loopers.domain.repository.UserRepository; -import com.loopers.domain.service.PasswordEncoder; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.dao.DataIntegrityViolationException; - -import java.time.LocalDate; - -import static org.assertj.core.api.Assertions.assertThatNoException; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.*; - -class UserRegisterServiceTest { - - private UserRepository userRepository; - private PasswordEncoder passwordEncoder; - private UserRegisterService service; - - @BeforeEach - void setUp() { - userRepository = mock(UserRepository.class); - passwordEncoder = mock(PasswordEncoder.class); - service = new UserRegisterService(userRepository, passwordEncoder); - } - - @Test - @DisplayName("회원가입 성공") - void register_success() { - // given - String loginId = "test1234"; - String name = "홍길동"; - String rawPassword = "Password1!"; - LocalDate birthday = LocalDate.of(1990, 5, 15); - String email = "test@example.com"; - - when(passwordEncoder.encrypt(anyString())).thenReturn("encoded_password"); - - // when & then - assertThatNoException() - .isThrownBy(() -> service.register(loginId, name, rawPassword, birthday, email)); - - verify(passwordEncoder).encrypt(rawPassword); - verify(userRepository).save(any(User.class)); - } - - @Test - @DisplayName("중복된 ID로 가입시 예외") - void register_fail_duplicated_id() { - // given - String duplicatedId = "test1234"; - String name = "홍길동"; - String rawPassword = "Password1!"; - LocalDate birthday = LocalDate.of(1990, 5, 15); - String email = "test@example.com"; - - when(passwordEncoder.encrypt(anyString())).thenReturn("encoded_password"); - doThrow(new DataIntegrityViolationException("Duplicate entry")) - .when(userRepository).save(any(User.class)); - - // when & then - assertThatThrownBy(() -> service.register(duplicatedId, name, rawPassword, birthday, email)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("이미 사용중인 ID"); - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/service/UserServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/service/UserServiceTest.java new file mode 100644 index 00000000..92ca8c06 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/service/UserServiceTest.java @@ -0,0 +1,307 @@ +package com.loopers.application.service; + +import com.loopers.domain.model.*; +import com.loopers.domain.repository.UserRepository; +import com.loopers.domain.service.PasswordEncoder; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.dao.DataIntegrityViolationException; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +class UserServiceTest { + + private UserRepository userRepository; + private PasswordEncoder passwordEncoder; + private UserService service; + + private static final LocalDate BIRTHDAY = LocalDate.of(1990, 5, 15); + + @BeforeEach + void setUp() { + userRepository = mock(UserRepository.class); + passwordEncoder = mock(PasswordEncoder.class); + service = new UserService(userRepository, passwordEncoder); + } + + @Nested + @DisplayName("회원가입") + class Register { + + @Test + @DisplayName("회원가입 성공") + void register_success() { + // given + String loginId = "test1234"; + String name = "홍길동"; + String rawPassword = "Password1!"; + String email = "test@example.com"; + + when(passwordEncoder.encrypt(anyString())).thenReturn("encoded_password"); + + // when & then + assertThatNoException() + .isThrownBy(() -> service.register(loginId, name, rawPassword, BIRTHDAY, email)); + + verify(passwordEncoder).encrypt(rawPassword); + verify(userRepository).save(any(User.class)); + } + + @Test + @DisplayName("중복된 ID로 가입시 예외") + void register_fail_duplicated_id() { + // given + String duplicatedId = "test1234"; + String name = "홍길동"; + String rawPassword = "Password1!"; + String email = "test@example.com"; + + when(passwordEncoder.encrypt(anyString())).thenReturn("encoded_password"); + doThrow(new DataIntegrityViolationException("Duplicate entry")) + .when(userRepository).save(any(User.class)); + + // when & then + assertThatThrownBy(() -> service.register(duplicatedId, name, rawPassword, BIRTHDAY, email)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("이미 사용중인 ID"); + } + } + + @Nested + @DisplayName("인증") + class Authentication { + + @Test + @DisplayName("인증 성공") + void authenticate_success() { + // given + UserId userId = UserId.of("test1234"); + String rawPassword = "Password1!"; + String encodedPassword = "encoded_password"; + User user = createUser(userId, encodedPassword); + + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(passwordEncoder.matches(rawPassword, encodedPassword)).thenReturn(true); + + // when & then + service.authenticate(userId, rawPassword); + + verify(userRepository).findById(userId); + verify(passwordEncoder).matches(rawPassword, encodedPassword); + } + + @Test + @DisplayName("존재하지 않는 사용자 인증 실패") + void authenticate_fail_userNotFound() { + // given + UserId userId = UserId.of("notexist"); + + when(userRepository.findById(userId)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> service.authenticate(userId, "password")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("사용자를 찾을 수 없습니다"); + } + + @Test + @DisplayName("비밀번호 불일치 인증 실패") + void authenticate_fail_passwordMismatch() { + // given + UserId userId = UserId.of("test1234"); + String wrongPassword = "WrongPassword1!"; + String encodedPassword = "encoded_password"; + User user = createUser(userId, encodedPassword); + + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(passwordEncoder.matches(wrongPassword, encodedPassword)).thenReturn(false); + + // when & then + assertThatThrownBy(() -> service.authenticate(userId, wrongPassword)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("비밀번호가 일치하지 않습니다"); + } + } + + @Nested + @DisplayName("비밀번호 변경") + class PasswordUpdate { + + @Test + @DisplayName("비밀번호 변경 성공") + void updatePassword_success() { + // given + UserId userId = UserId.of("test1234"); + User user = createUser(userId, "encoded_current"); + String currentRawPassword = "Current1!"; + String newRawPassword = "NewPass1!"; + + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(passwordEncoder.matches(currentRawPassword, "encoded_current")).thenReturn(true); + when(passwordEncoder.matches(newRawPassword, "encoded_current")).thenReturn(false); + when(passwordEncoder.encrypt(newRawPassword)).thenReturn("encoded_new"); + + // when & then + assertThatNoException() + .isThrownBy(() -> service.updatePassword(userId, currentRawPassword, newRawPassword)); + + verify(userRepository).save(any(User.class)); + } + + @Test + @DisplayName("현재 비밀번호 불일치시 예외") + void updatePassword_fail_wrong_current() { + // given + UserId userId = UserId.of("test1234"); + User user = createUser(userId, "encoded_current"); + String wrongRawPassword = "WrongPw1!"; + String newRawPassword = "NewPass1!"; + + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(passwordEncoder.matches(wrongRawPassword, "encoded_current")).thenReturn(false); + + // when & then + assertThatThrownBy(() -> service.updatePassword(userId, wrongRawPassword, newRawPassword)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("현재 비밀번호가 일치하지 않습니다"); + + verify(userRepository, never()).save(any(User.class)); + } + + @Test + @DisplayName("새 비밀번호가 현재와 동일하면 예외") + void updatePassword_fail_same_password() { + // given + UserId userId = UserId.of("test1234"); + User user = createUser(userId, "encoded_current"); + String currentRawPassword = "Current1!"; + String sameRawPassword = "Current1!"; + + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(passwordEncoder.matches(currentRawPassword, "encoded_current")).thenReturn(true); + + // when & then + assertThatThrownBy(() -> service.updatePassword(userId, currentRawPassword, sameRawPassword)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("현재 비밀번호는 사용할 수 없습니다"); + + verify(userRepository, never()).save(any(User.class)); + } + + @Test + @DisplayName("존재하지 않는 사용자면 예외") + void updatePassword_fail_user_not_found() { + // given + UserId userId = UserId.of("notexist"); + + when(userRepository.findById(userId)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> service.updatePassword(userId, "Current1!", "NewPass1!")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("사용자를 찾을 수 없습니다"); + } + } + + @Nested + @DisplayName("내 정보 조회") + class UserQuery { + + @Test + @DisplayName("내 정보 조회 성공") + void getUserInfo_success() { + // given + UserId userId = UserId.of("test1234"); + User user = User.reconstitute( + 1L, + userId, + UserName.of("홍길동"), + "encoded_password", + Birthday.of(BIRTHDAY), + Email.of("test@example.com"), + WrongPasswordCount.init(), + LocalDateTime.now() + ); + + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + + // when + var result = service.getUserInfo(userId); + + // then + assertThat(result.loginId()).isEqualTo("test1234"); + assertThat(result.maskedName()).isEqualTo("홍길*"); + assertThat(result.birthday()).isEqualTo(BIRTHDAY); + assertThat(result.email()).isEqualTo("test@example.com"); + } + + @Test + @DisplayName("이름 마스킹 - 2자") + void getUserInfo_maskedName_2chars() { + // given + UserId userId = UserId.of("test1234"); + User user = User.reconstitute( + 1L, + userId, + UserName.of("홍길"), + "encoded_password", + Birthday.of(BIRTHDAY), + Email.of("test@example.com"), + WrongPasswordCount.init(), + LocalDateTime.now() + ); + + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + + // when + var result = service.getUserInfo(userId); + + // then + assertThat(result.maskedName()).isEqualTo("홍*"); + } + + @Test + @DisplayName("존재하지 않는 사용자 조회시 예외") + void getUserInfo_fail_not_found() { + // given + UserId userId = UserId.of("notexist"); + + when(userRepository.findById(userId)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> service.getUserInfo(userId)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("사용자를 찾을 수 없습니다"); + } + + @Test + @DisplayName("이름은 최소 2자 이상이어야 한다") + void userName_fail_lessThan2chars() { + assertThatThrownBy(() -> UserName.of("홍")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("2~20자"); + } + } + + private User createUser(UserId userId, String encodedPassword) { + return User.reconstitute( + 1L, + userId, + UserName.of("홍길동"), + encodedPassword, + Birthday.of(BIRTHDAY), + Email.of("test@example.com"), + WrongPasswordCount.init(), + LocalDateTime.now() + ); + } +} From f991621756100a114c53e31d67cbaab29c0ecd0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=84=EC=9D=B8=EA=B5=AD?= Date: Thu, 12 Feb 2026 23:09:06 +0900 Subject: [PATCH 6/8] =?UTF-8?q?=E2=8F=BA=20refactor:=20class-diagram=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .docs/design/03-class-diagram.md | 758 ++++++++++++++++++++++++------- 1 file changed, 604 insertions(+), 154 deletions(-) diff --git a/.docs/design/03-class-diagram.md b/.docs/design/03-class-diagram.md index 37274de3..a9a262a6 100644 --- a/.docs/design/03-class-diagram.md +++ b/.docs/design/03-class-diagram.md @@ -6,56 +6,351 @@ Interfaces → Application → Domain ← Infrastructure ``` +> UML 표기법 참고: [UML 클래스 다이어그램](https://djcho.github.io/etc/etc-uml-classdiagram/) + +### UML 관계 범례 + +| 관계 | Mermaid 표기 | 설명 | +|---|---|---| +| 일반화(Generalization) | `--|>` 실선 + 빈 삼각형 | 상속 (extends) | +| 실체화(Realization) | `..|>` 점선 + 빈 삼각형 | 구현 (implements) | +| 의존(Dependency) | `..>` 점선 화살표 | 메서드 파라미터/로컬 변수로 참조 | +| 연관(Association) | `-->` 실선 화살표 | 필드로 참조 | +| 합성(Composition) | `*--` 채워진 다이아몬드 | 강한 소유 (생명주기 종속) | +| 집합(Aggregation) | `o--` 빈 다이아몬드 | 약한 소유 (독립 생명주기) | + +### 접근 제어자 + +| 기호 | 접근 제어자 | +|---|---| +| `+` | public | +| `-` | private | +| `#` | protected | +| `~` | package-private | + --- -## 6-1. User 도메인 (현재 구현) +## 6-1. 전체 아키텍처 클래스 다이어그램 -### 전체 구조 +> 레이어 간 의존 방향과 모든 클래스의 관계를 한눈에 보여줍니다. ```mermaid classDiagram direction TB - %% === Interfaces === - class UserController { - -RegisterUseCase registerUseCase - -AuthenticationUseCase authenticationUseCase - -UserQueryUseCase userQueryUseCase - -PasswordUpdateUseCase passwordUpdateUseCase - +register(UserRegisterRequest) ResponseEntity - +getMyInfo(loginId, loginPw) ResponseEntity - +updatePassword(loginId, loginPw, PasswordUpdateRequest) ResponseEntity - } + %% ═══════════════════════════════════════ + %% Interfaces Layer (Presentation) + %% ═══════════════════════════════════════ + + namespace Interfaces { + class UserController { + <> + -RegisterUseCase registerUseCase + -AuthenticationUseCase authenticationUseCase + -UserQueryUseCase userQueryUseCase + -PasswordUpdateUseCase passwordUpdateUseCase + +register(UserRegisterRequest) ResponseEntity~Void~ + +getMyInfo(String loginId, String loginPw) ResponseEntity~UserInfoResponse~ + +updatePassword(String loginId, String loginPw, PasswordUpdateRequest) ResponseEntity~Void~ + } + + class UserRegisterRequest { + <> + -String loginId + -String password + -String name + -LocalDate birthday + -String email + } + + class UserInfoResponse { + <> + -String loginId + -String name + -String birthday + -String email + +from(UserQueryUseCase.UserInfoResponse)$ UserInfoResponse + } + + class PasswordUpdateRequest { + <> + -String currentPassword + -String newPassword + } + + class GlobalExceptionHandler { + <> + +handleCoreException(CoreException) ResponseEntity + +handleIllegalArgumentException(IllegalArgumentException) ResponseEntity + +handleValidationException(MethodArgumentNotValidException) ResponseEntity + +handleMissingHeaderException(MissingRequestHeaderException) ResponseEntity + +handleException(Exception) ResponseEntity + } + } + + %% ═══════════════════════════════════════ + %% Application Layer (Use Cases) + %% ═══════════════════════════════════════ + + namespace Application { + class RegisterUseCase { + <> + +register(String loginId, String name, String rawPassword, LocalDate birthday, String email) void + } + + class AuthenticationUseCase { + <> + +authenticate(UserId userId, String rawPassword) void + } + + class UserQueryUseCase { + <> + +getUserInfo(UserId userId) UserInfoResponse + } + + class PasswordUpdateUseCase { + <> + +updatePassword(UserId userId, String currentRawPassword, String newRawPassword) void + } + + class UserService { + <> + -UserRepository userRepository + -PasswordEncoder passwordEncoder + +register(String, String, String, LocalDate, String) void + +authenticate(UserId, String) void + +getUserInfo(UserId) UserInfoResponse + +updatePassword(UserId, String, String) void + -findUser(UserId) User + -maskName(String) String + } + } + + %% ═══════════════════════════════════════ + %% Domain Layer (Core Business Logic) + %% ═══════════════════════════════════════ + + namespace Domain { + class User { + <> + -Long id + -UserId userId + -UserName userName + -String encodedPassword + -Birthday birth + -Email email + -WrongPasswordCount wrongPasswordCount + -LocalDateTime createdAt + +register(UserId, UserName, String, Birthday, Email, WrongPasswordCount, LocalDateTime)$ User + +reconstitute(Long, UserId, UserName, String, Birthday, Email, WrongPasswordCount, LocalDateTime)$ User + +matchesPassword(Password, PasswordMatchChecker) boolean + +changePassword(String) User + } + + class PasswordMatchChecker { + <> + <> + +matches(String rawPassword, String encodedPassword) boolean + } + + class UserId { + <> + -String value + +of(String value)$ UserId + +getValue() String + } + + class UserName { + <> + -String value + +of(String value)$ UserName + +getValue() String + } + + class Password { + <> + -String value + +of(String rawPassword, LocalDate birthday)$ Password + -containsBirthday(String, LocalDate)$ boolean + +getValue() String + } + + class Email { + <> + -String value + +of(String value)$ Email + +getValue() String + } + + class Birthday { + <> + -LocalDate value + +of(LocalDate value)$ Birthday + +getValue() LocalDate + } + + class WrongPasswordCount { + <> + -int value + +init()$ WrongPasswordCount + +of(int count)$ WrongPasswordCount + +increment() WrongPasswordCount + +reset() WrongPasswordCount + +isLocked() boolean + +getValue() int + } + + class UserRepository { + <> + +save(User user) User + +findById(UserId userId) Optional~User~ + +existsById(UserId userId) boolean + } + + class PasswordEncoder { + <> + +encrypt(String rawPassword) String + +matches(String rawPassword, String encodedPassword) boolean + } + } + + %% ═══════════════════════════════════════ + %% Infrastructure Layer (Adapters) + %% ═══════════════════════════════════════ + + namespace Infrastructure { + class UserRepositoryImpl { + <> + -UserJpaRepository userJpaRepository + +save(User user) User + +findById(UserId userId) Optional~User~ + +existsById(UserId userId) boolean + -toEntity(User user) UserJpaEntity + -toDomain(UserJpaEntity entity) User + } + + class UserJpaRepository { + <> + +findByUserId(String userId) Optional~UserJpaEntity~ + +existsByUserId(String userId) boolean + } + + class UserJpaEntity { + <> + -Long id + -String userId + -String encodedPassword + -String username + -LocalDate birthday + -String email + -LocalDateTime createdAt + } + + class Sha256PasswordEncoder { + <> + +encrypt(String rawPassword) String + +matches(String rawPassword, String encodedPassword) boolean + -generateSalt() String + -sha256(String input) String + } + + class JpaRepository_T_ID_ { + <> + <> + } + } + + %% ═══════════════════════════════════════ + %% Error Handling (Cross-cutting) + %% ═══════════════════════════════════════ + + namespace ErrorHandling { + class CoreException { + -ErrorType errorType + -String customMessage + +CoreException(ErrorType) + +CoreException(ErrorType, String) + +getErrorType() ErrorType + } + + class ErrorType { + <> + INTERNAL_ERROR + BAD_REQUEST + NOT_FOUND + CONFLICT + -HttpStatus status + -String code + -String message + } + } + + %% ═══════════════════════════════════════ + %% 관계 정의 (Relationships) + %% ═══════════════════════════════════════ + + %% --- Interfaces → Application (의존) --- + UserController ..> RegisterUseCase : «uses» + UserController ..> AuthenticationUseCase : «uses» + UserController ..> UserQueryUseCase : «uses» + UserController ..> PasswordUpdateUseCase : «uses» + UserController ..> UserRegisterRequest : «uses» + UserController ..> PasswordUpdateRequest : «uses» + UserController ..> UserInfoResponse : «creates» + + %% --- Application: 실체화 (Realization) --- + UserService ..|> RegisterUseCase : «implements» + UserService ..|> AuthenticationUseCase : «implements» + UserService ..|> UserQueryUseCase : «implements» + UserService ..|> PasswordUpdateUseCase : «implements» + + %% --- Application → Domain (연관) --- + UserService --> UserRepository : -userRepository + UserService --> PasswordEncoder : -passwordEncoder + UserService ..> User : «uses» + + %% --- Domain: 합성 (Composition) - 생명주기 종속 --- + User *-- "1" UserId : -userId + User *-- "1" UserName : -userName + User *-- "1" Birthday : -birth + User *-- "1" Email : -email + User *-- "1" WrongPasswordCount : -wrongPasswordCount + + %% --- Domain: 의존 (Dependency) - 메서드에서만 사용 --- + User ..> Password : register/updatePassword 시 검증용 + User --> PasswordMatchChecker : matchesPassword() + + %% --- Infrastructure: 실체화 (Realization) --- + UserRepositoryImpl ..|> UserRepository : «implements» + Sha256PasswordEncoder ..|> PasswordEncoder : «implements» + + %% --- Infrastructure: 일반화 (Generalization) --- + UserJpaRepository --|> JpaRepository_T_ID_ : «extends» + + %% --- Infrastructure: 연관/의존 --- + UserRepositoryImpl --> UserJpaRepository : -userJpaRepository + UserRepositoryImpl ..> UserJpaEntity : toEntity() / toDomain() + UserRepositoryImpl ..> User : 도메인 변환 + + %% --- Error Handling --- + CoreException --> ErrorType : -errorType + CoreException --|> RuntimeException : «extends» + GlobalExceptionHandler ..> CoreException : «handles» + + %% --- DTO 변환 --- + UserInfoResponse ..> UserQueryUseCase : from() 변환 +``` - %% === Application === - class RegisterUseCase { - <> - +register(loginId, name, rawPassword, birthday, email) - } - class AuthenticationUseCase { - <> - +authenticate(userId, rawPassword) - } - class UserQueryUseCase { - <> - +getUserInfo(userId) UserInfoResponse - } - class PasswordUpdateUseCase { - <> - +updatePassword(userId, currentRawPassword, newRawPassword) - } - class UserService { - -UserRepository userRepository - -PasswordEncoder passwordEncoder - +register() - +authenticate() - +getUserInfo() - +updatePassword() - -findUser(UserId) User - -maskName(String) String - } +--- + +## 6-2. Value Objects 상세 다이어그램 + +> User 애그리거트가 소유하는 값 객체들의 **합성(Composition)** 관계와 검증 규칙을 보여줍니다. + +```mermaid +classDiagram + direction LR - %% === Domain === class User { <> -Long id @@ -68,104 +363,85 @@ classDiagram -LocalDateTime createdAt +register(...)$ User +reconstitute(...)$ User - +changePassword(String) User - } - class UserRepository { - <> - +save(User) User - +findById(UserId) Optional~User~ - } - class PasswordEncoder { - <> - +encrypt(String) String - +matches(String, String) boolean + +matchesPassword(Password, PasswordMatchChecker) boolean + +changePassword(String encodedPassword) User } - %% === Infrastructure === - class UserRepositoryImpl { - +save(User) User - +findById(UserId) Optional~User~ - } - class Sha256PasswordEncoder { - +encrypt(String) String - +matches(String, String) boolean - } - - %% --- 관계 --- - UserController --> RegisterUseCase - UserController --> AuthenticationUseCase - UserController --> UserQueryUseCase - UserController --> PasswordUpdateUseCase - - UserService ..|> RegisterUseCase - UserService ..|> AuthenticationUseCase - UserService ..|> UserQueryUseCase - UserService ..|> PasswordUpdateUseCase - UserService --> UserRepository - UserService --> PasswordEncoder - - UserRepositoryImpl ..|> UserRepository - Sha256PasswordEncoder ..|> PasswordEncoder -``` - -### UserService 메서드별 책임 - -| 메서드 | UseCase | 트랜잭션 | 핵심 로직 | -|---|---|---|---| -| `register()` | `RegisterUseCase` | `@Transactional` | 값 객체 검증 → 암호화 → 저장 (중복 시 예외) | -| `authenticate()` | `AuthenticationUseCase` | `readOnly` | 사용자 조회 → 비밀번호 매칭 | -| `getUserInfo()` | `UserQueryUseCase` | `readOnly` | 사용자 조회 → 이름 마스킹 | -| `updatePassword()` | `PasswordUpdateUseCase` | `@Transactional` | 기존 PW 검증 → 신규 PW 검증 → 암호화 → 저장 | - -### API 엔드포인트 - -| Method | Path | 인증 | -|---|---|---| -| `POST` | `/api/v1/users/register` | 불필요 | -| `GET` | `/api/v1/users/me` | `X-Loopers-LoginId`, `X-Loopers-LoginPw` | -| `PUT` | `/api/v1/users/me/password` | `X-Loopers-LoginId`, `X-Loopers-LoginPw` | - ---- - -## 6-2. Value Objects & 검증 규칙 - -```mermaid -classDiagram - User *-- UserId - User *-- UserName - User *-- Birthday - User *-- Email - User *-- WrongPasswordCount - User ..> Password : register/updatePassword에서 사용 - class UserId { + <> -String value +of(String)$ UserId } + note for UserId "정규식: ^[a-z0-9]{4,10}$\n4~10자, 영문 소문자+숫자" + class UserName { + <> -String value +of(String)$ UserName } + note for UserName "정규식: ^[a-zA-Z0-9가-힣]{2,20}$\n2~20자, 한글/영문/숫자" + class Password { + <> -String value + -DateTimeFormatter FMT_YYYYMMDD$ + -DateTimeFormatter FMT_YYMMDD$ + -DateTimeFormatter FMT_MMDD$ +of(String, LocalDate)$ Password + -containsBirthday(String, LocalDate)$ boolean } + note for Password "8~16자, 영문+숫자+특수문자\n생년월일 패턴 포함 불가" + class Email { + <> -String value +of(String)$ Email } + note for Email "이메일 형식 정규식 검증" + class Birthday { + <> -LocalDate value +of(LocalDate)$ Birthday } + note for Birthday "미래 날짜 불가\n1900-01-01 이후" + class WrongPasswordCount { + <> -int value +init()$ WrongPasswordCount + +of(int)$ WrongPasswordCount +increment() WrongPasswordCount + +reset() WrongPasswordCount +isLocked() boolean } + note for WrongPasswordCount "0 이상, 5회 이상 → 잠금\n불변: increment/reset → 새 인스턴스" + + class PasswordMatchChecker { + <> + <> + +matches(String, String) boolean + } + + %% 합성 관계 (Composition) - 채워진 다이아몬드 + %% User가 소멸하면 Value Object도 소멸 + User *-- "1" UserId + User *-- "1" UserName + User *-- "1" Birthday + User *-- "1" Email + User *-- "1" WrongPasswordCount + + %% 의존 관계 (Dependency) - 점선 화살표 + %% register/updatePassword 메서드에서만 참조 + User ..> Password : 생성/변경 시 검증 + + %% 연관 관계 (Association) - 실선 화살표 + %% matchesPassword() 파라미터 + User ..> PasswordMatchChecker : matchesPassword()에서 사용 ``` +### Value Object 검증 규칙 + | Value Object | 검증 규칙 | 예외 메시지 | |---|---|---| | `UserId` | 4~10자, 영문 소문자+숫자만 | `로그인 ID는 4~10자의 영문 소문자, 숫자만 가능합니다.` | @@ -183,27 +459,70 @@ classDiagram --- -## 6-3. Infrastructure 계층 +## 6-3. Infrastructure 계층 상세 + +> 도메인 인터페이스를 **실체화(Realization)** 하는 인프라 어댑터와 JPA 엔티티 매핑을 보여줍니다. ```mermaid classDiagram - UserRepositoryImpl ..|> UserRepository - UserRepositoryImpl --> UserJpaRepository - UserRepositoryImpl ..> UserJpaEntity - Sha256PasswordEncoder ..|> PasswordEncoder + direction TB + + %% Domain Interfaces (Port) + class UserRepository { + <> + <> + +save(User) User + +findById(UserId) Optional~User~ + +existsById(UserId) boolean + } + + class PasswordEncoder { + <> + <> + +encrypt(String) String + +matches(String, String) boolean + } + %% Infrastructure Adapters class UserRepositoryImpl { + <> + <> + -UserJpaRepository userJpaRepository +save(User) User +findById(UserId) Optional~User~ + +existsById(UserId) boolean -toEntity(User) UserJpaEntity -toDomain(UserJpaEntity) User } + + class Sha256PasswordEncoder { + <> + <> + +encrypt(String) String + +matches(String, String) boolean + -generateSalt() String + -sha256(String) String + } + + %% JPA class UserJpaRepository { <> + <> +findByUserId(String) Optional~UserJpaEntity~ +existsByUserId(String) boolean } + + class JpaRepository~T_ID~ { + <> + <> + +save(T) T + +findById(ID) Optional~T~ + +existsById(ID) boolean + +deleteById(ID) void + } + class UserJpaEntity { + <> -Long id -String userId -String encodedPassword @@ -211,27 +530,98 @@ classDiagram -LocalDate birthday -String email -LocalDateTime createdAt + +UserJpaEntity(String, String, String, LocalDate, String, LocalDateTime) } - class Sha256PasswordEncoder { - +encrypt(String) String - +matches(String, String) boolean + + %% Domain Model (참조용) + class User { + <> } + + %% === 관계 === + + %% 실체화 (Realization): 점선 + 빈 삼각형 + UserRepositoryImpl ..|> UserRepository : «implements» + Sha256PasswordEncoder ..|> PasswordEncoder : «implements» + + %% 일반화 (Generalization): 실선 + 빈 삼각형 + UserJpaRepository --|> JpaRepository~T_ID~ : «extends» + + %% 연관 (Association): 필드 참조 + UserRepositoryImpl --> "1" UserJpaRepository : -userJpaRepository + + %% 의존 (Dependency): 메서드에서 변환 시 사용 + UserRepositoryImpl ..> UserJpaEntity : toEntity() / toDomain() + UserRepositoryImpl ..> User : 도메인 모델 변환 ``` **변환 흐름**: `User` → `toEntity()` → `UserJpaEntity` → JPA save → `toDomain()` → `User` -**암호화 형식**: `salt:hash` (SHA-256 + Base64 Salt) +**암호화 형식**: `salt:hash` (SHA-256 + 16byte Base64 Salt) --- -## 6-4. 에러 처리 +## 6-4. 에러 처리 다이어그램 + +```mermaid +classDiagram + direction TB + + class GlobalExceptionHandler { + <> + +handleCoreException(CoreException) ResponseEntity~Map~ + +handleIllegalArgumentException(IllegalArgumentException) ResponseEntity~Map~ + +handleValidationException(MethodArgumentNotValidException) ResponseEntity~Map~ + +handleMissingHeaderException(MissingRequestHeaderException) ResponseEntity~Map~ + +handleException(Exception) ResponseEntity~Map~ + } + + class CoreException { + -ErrorType errorType + -String customMessage + +CoreException(ErrorType) + +CoreException(ErrorType, String) + +getErrorType() ErrorType + +getCustomMessage() String + } + + class RuntimeException { + <> + } + + class ErrorType { + <> + INTERNAL_ERROR + BAD_REQUEST + NOT_FOUND + CONFLICT + -HttpStatus status + -String code + -String message + +getStatus() HttpStatus + +getCode() String + +getMessage() String + } + + %% 일반화 (Generalization) + CoreException --|> RuntimeException : «extends» + + %% 합성 (Composition) - ErrorType은 CoreException에 종속 + CoreException *-- "1" ErrorType : -errorType + + %% 의존 (Dependency) - 예외 핸들링 + GlobalExceptionHandler ..> CoreException : «catches» + GlobalExceptionHandler ..> IllegalArgumentException : «catches» +``` + +### 예외 매핑 테이블 | 예외 | HTTP 상태 | 발생 위치 | |---|---|---| +| `CoreException` | ErrorType에 따름 | 명시적 도메인 예외 | | `IllegalArgumentException` | 400 | Value Object 검증, Service 비즈니스 검증 | | `MethodArgumentNotValidException` | 400 | DTO `@Valid` 검증 | | `MissingRequestHeaderException` | 400 | 필수 헤더 누락 | -| `CoreException` | ErrorType에 따름 | 명시적 도메인 예외 | | `Exception` | 500 | 예상치 못한 서버 오류 | --- @@ -239,21 +629,35 @@ classDiagram ## 6-5. 의존성 방향 요약 ``` -┌─────────────────────────────────────────────────────┐ -│ Interfaces (Controller, DTO) │ -│ └─ 의존 → UseCase 인터페이스 (Application 계층) │ -├─────────────────────────────────────────────────────┤ -│ Application (UseCase, UserService) │ -│ └─ 의존 → Domain 인터페이스 (Repository, Encoder) │ -├─────────────────────────────────────────────────────┤ -│ Domain (User, Value Objects, Interface) │ -│ └─ 외부 의존 없음 (순수 Java) │ -├─────────────────────────────────────────────────────┤ -│ Infrastructure (JPA, SHA-256) │ -│ └─ 의존 → Domain 인터페이스를 구현 │ -└─────────────────────────────────────────────────────┘ +┌─────────────────────────────────────────────────────────────────────┐ +│ Interfaces (Controller, DTO) │ +│ └─ 의존 → UseCase «interface» (Application 계층) │ +│ 관계: 의존(Dependency) - 점선 화살표 │ +├─────────────────────────────────────────────────────────────────────┤ +│ Application (UseCase, UserService) │ +│ └─ 의존 → Domain «interface» (Repository, PasswordEncoder) │ +│ 관계: 실체화(Realization) - UseCase 구현 │ +│ 연관(Association) - Repository/Encoder 필드 참조 │ +├─────────────────────────────────────────────────────────────────────┤ +│ Domain (User, Value Objects, Interface) │ +│ └─ 외부 의존 없음 (순수 Java) │ +│ 관계: 합성(Composition) - User ↔ Value Objects │ +├─────────────────────────────────────────────────────────────────────┤ +│ Infrastructure (JPA, SHA-256) │ +│ └─ 의존 → Domain «interface»를 구현 │ +│ 관계: 실체화(Realization) - Domain Port 구현 │ +│ 일반화(Generalization) - JpaRepository 상속 │ +└─────────────────────────────────────────────────────────────────────┘ ``` +### API 엔드포인트 + +| Method | Path | 인증 | UseCase | +|---|---|---|---| +| `POST` | `/api/v1/users/register` | 불필요 | `RegisterUseCase` | +| `GET` | `/api/v1/users/me` | `X-Loopers-LoginId`, `X-Loopers-LoginPw` | `UserQueryUseCase` + `AuthenticationUseCase` | +| `PUT` | `/api/v1/users/me/password` | `X-Loopers-LoginId`, `X-Loopers-LoginPw` | `PasswordUpdateUseCase` + `AuthenticationUseCase` | + --- # 향후 확장 도메인 설계 (미래 목표) @@ -266,20 +670,39 @@ classDiagram classDiagram direction TB - class User { <> } - class Brand { <> } - class Product { <> } - class Like { <> } - class Order { <> } - class OrderItem { <> } - class OrderSnapshot { <> } + class User { + <> + } + class Brand { + <> + } + class Product { + <> + } + class Like { + <> + } + class Order { + <> + } + class OrderItem { + <> + } + class OrderSnapshot { + <> + } + %% 연관 (Association) - 다중성 포함 User "1" --> "*" Like : 좋아요 User "1" --> "*" Order : 주문 + + %% 연관 (Association) Brand "1" --> "*" Product : 보유 상품 Product "1" --> "*" Like : 좋아요 대상 Product "1" --> "*" OrderItem : 주문 항목 - Order "1" *-- "*" OrderItem : 주문 상세 + + %% 합성 (Composition) - 생명주기 종속 + Order "1" *-- "1..*" OrderItem : 주문 상세 Order "1" *-- "1" OrderSnapshot : 주문 시점 스냅샷 ``` @@ -289,8 +712,6 @@ classDiagram ```mermaid classDiagram - Brand *-- BrandName - class Brand { <> -Long id @@ -306,6 +727,8 @@ classDiagram -String value +of(String)$ BrandName } + + Brand *-- "1" BrandName : -name ``` | Role | Method | Path | UseCase | @@ -322,11 +745,6 @@ classDiagram ```mermaid classDiagram - Product *-- ProductName - Product *-- Money - Product *-- StockQuantity - Product *-- "0..*" ProductImage - class Product { <> -Long id @@ -341,6 +759,11 @@ classDiagram +decreaseStock(int) Product +isOutOfStock() boolean } + class ProductName { + <> + -String value + +of(String)$ ProductName + } class Money { <> -int value @@ -352,6 +775,16 @@ classDiagram +decrease(int) StockQuantity +isZero() boolean } + class ProductImage { + <> + -String url + -int sortOrder + } + + Product *-- "1" ProductName : -name + Product *-- "1" Money : -price + Product *-- "1" StockQuantity : -stockQuantity + Product *-- "0..*" ProductImage : -images ``` | Role | Method | Path | @@ -384,6 +817,9 @@ classDiagram +delete(UserId, Long) void +existsByUserIdAndProductId(UserId, Long) boolean } + + Like ..> UserId : -userId + LikeRepository ..> Like : «manages» ``` - **멱등성**: 이미 좋아요한 상품에 다시 좋아요 → 예외 없이 무시 @@ -402,12 +838,6 @@ classDiagram ```mermaid classDiagram - Order *-- "1..*" OrderItem - Order *-- "1" OrderSnapshot - Order *-- "1" ShippingInfo - Order *-- "1" PaymentMethod - Order --> OrderStatus - class Order { <> -Long id @@ -427,7 +857,7 @@ classDiagram -Money price } class OrderStatus { - <> + <> PAYMENT_COMPLETED PREPARING SHIPPING @@ -439,6 +869,26 @@ classDiagram -String snapshotData +capture(...)$ OrderSnapshot } + class ShippingInfo { + <> + -String address + -String receiverName + -String receiverPhone + } + class PaymentMethod { + <> + -String type + -String detail + } + + %% 합성 (Composition) - Order 소멸 시 함께 소멸 + Order *-- "1..*" OrderItem : 주문 상세 + Order *-- "1" OrderSnapshot : 주문 시점 스냅샷 + Order *-- "1" ShippingInfo : 배송 정보 + Order *-- "1" PaymentMethod : 결제 수단 + + %% 연관 (Association) - enum 참조 + Order --> OrderStatus : -status ``` **주문 생성 프로세스**: 재고 확인 → 재고 차감 → 결제 금액 검증 → 스냅샷 생성 → 주문 생성 From 7e40c48da772e83fead76394f3ea64a205211cd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=84=EC=9D=B8=EA=B5=AD?= Date: Thu, 12 Feb 2026 23:54:00 +0900 Subject: [PATCH 7/8] =?UTF-8?q?docs:=20UML=20=ED=81=B4=EB=9E=98=EC=8A=A4?= =?UTF-8?q?=20=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 6-1 전체 아키텍처 다이어그램을 Part A(Interfaces→Application), Part B(Domain←Infrastructure)로 분리하여 가독성 향상 - UML 6가지 관계(일반화, 실체화, 의존, 연관, 합성, 집합) 표기법 범례 추가 - 접근 제어자(+, -, #, ~), 다중성, 스테레오타입 표기 보강 Co-Authored-By: Claude Opus 4.6 --- .docs/design/03-class-diagram.md | 483 +++++++++++++++---------------- 1 file changed, 226 insertions(+), 257 deletions(-) diff --git a/.docs/design/03-class-diagram.md b/.docs/design/03-class-diagram.md index a9a262a6..05a10fac 100644 --- a/.docs/design/03-class-diagram.md +++ b/.docs/design/03-class-diagram.md @@ -32,313 +32,282 @@ Interfaces → Application → Domain ← Infrastructure ## 6-1. 전체 아키텍처 클래스 다이어그램 -> 레이어 간 의존 방향과 모든 클래스의 관계를 한눈에 보여줍니다. +> 다이어그램이 크므로 **상위 레이어**(Interfaces → Application)와 **하위 레이어**(Domain ← Infrastructure)로 나눠서 보여줍니다. + +### Part A. Interfaces → Application (요청 흐름) + +> Controller가 UseCase 인터페이스에 의존하고, UserService가 이를 **실체화(Realization)** 합니다. ```mermaid classDiagram - direction TB + direction LR %% ═══════════════════════════════════════ - %% Interfaces Layer (Presentation) + %% Interfaces Layer %% ═══════════════════════════════════════ - namespace Interfaces { - class UserController { - <> - -RegisterUseCase registerUseCase - -AuthenticationUseCase authenticationUseCase - -UserQueryUseCase userQueryUseCase - -PasswordUpdateUseCase passwordUpdateUseCase - +register(UserRegisterRequest) ResponseEntity~Void~ - +getMyInfo(String loginId, String loginPw) ResponseEntity~UserInfoResponse~ - +updatePassword(String loginId, String loginPw, PasswordUpdateRequest) ResponseEntity~Void~ - } - - class UserRegisterRequest { - <> - -String loginId - -String password - -String name - -LocalDate birthday - -String email - } - - class UserInfoResponse { - <> - -String loginId - -String name - -String birthday - -String email - +from(UserQueryUseCase.UserInfoResponse)$ UserInfoResponse - } - - class PasswordUpdateRequest { - <> - -String currentPassword - -String newPassword - } - - class GlobalExceptionHandler { - <> - +handleCoreException(CoreException) ResponseEntity - +handleIllegalArgumentException(IllegalArgumentException) ResponseEntity - +handleValidationException(MethodArgumentNotValidException) ResponseEntity - +handleMissingHeaderException(MissingRequestHeaderException) ResponseEntity - +handleException(Exception) ResponseEntity - } + class UserController { + <> + -RegisterUseCase registerUseCase + -AuthenticationUseCase authenticationUseCase + -UserQueryUseCase userQueryUseCase + -PasswordUpdateUseCase passwordUpdateUseCase + +register(UserRegisterRequest) ResponseEntity~Void~ + +getMyInfo(String, String) ResponseEntity~UserInfoResponse~ + +updatePassword(String, String, PasswordUpdateRequest) ResponseEntity~Void~ + } + + class UserRegisterRequest { + <> + -String loginId + -String password + -String name + -LocalDate birthday + -String email } - %% ═══════════════════════════════════════ - %% Application Layer (Use Cases) - %% ═══════════════════════════════════════ + class UserInfoResponse { + <> + -String loginId + -String name + -String birthday + -String email + +from(UserQueryUseCase.UserInfoResponse)$ UserInfoResponse + } - namespace Application { - class RegisterUseCase { - <> - +register(String loginId, String name, String rawPassword, LocalDate birthday, String email) void - } - - class AuthenticationUseCase { - <> - +authenticate(UserId userId, String rawPassword) void - } - - class UserQueryUseCase { - <> - +getUserInfo(UserId userId) UserInfoResponse - } - - class PasswordUpdateUseCase { - <> - +updatePassword(UserId userId, String currentRawPassword, String newRawPassword) void - } - - class UserService { - <> - -UserRepository userRepository - -PasswordEncoder passwordEncoder - +register(String, String, String, LocalDate, String) void - +authenticate(UserId, String) void - +getUserInfo(UserId) UserInfoResponse - +updatePassword(UserId, String, String) void - -findUser(UserId) User - -maskName(String) String - } + class PasswordUpdateRequest { + <> + -String currentPassword + -String newPassword } %% ═══════════════════════════════════════ - %% Domain Layer (Core Business Logic) + %% Application Layer %% ═══════════════════════════════════════ - namespace Domain { - class User { - <> - -Long id - -UserId userId - -UserName userName - -String encodedPassword - -Birthday birth - -Email email - -WrongPasswordCount wrongPasswordCount - -LocalDateTime createdAt - +register(UserId, UserName, String, Birthday, Email, WrongPasswordCount, LocalDateTime)$ User - +reconstitute(Long, UserId, UserName, String, Birthday, Email, WrongPasswordCount, LocalDateTime)$ User - +matchesPassword(Password, PasswordMatchChecker) boolean - +changePassword(String) User - } - - class PasswordMatchChecker { - <> - <> - +matches(String rawPassword, String encodedPassword) boolean - } - - class UserId { - <> - -String value - +of(String value)$ UserId - +getValue() String - } - - class UserName { - <> - -String value - +of(String value)$ UserName - +getValue() String - } - - class Password { - <> - -String value - +of(String rawPassword, LocalDate birthday)$ Password - -containsBirthday(String, LocalDate)$ boolean - +getValue() String - } - - class Email { - <> - -String value - +of(String value)$ Email - +getValue() String - } - - class Birthday { - <> - -LocalDate value - +of(LocalDate value)$ Birthday - +getValue() LocalDate - } - - class WrongPasswordCount { - <> - -int value - +init()$ WrongPasswordCount - +of(int count)$ WrongPasswordCount - +increment() WrongPasswordCount - +reset() WrongPasswordCount - +isLocked() boolean - +getValue() int - } - - class UserRepository { - <> - +save(User user) User - +findById(UserId userId) Optional~User~ - +existsById(UserId userId) boolean - } - - class PasswordEncoder { - <> - +encrypt(String rawPassword) String - +matches(String rawPassword, String encodedPassword) boolean - } + class RegisterUseCase { + <> + +register(String, String, String, LocalDate, String) void } - %% ═══════════════════════════════════════ - %% Infrastructure Layer (Adapters) - %% ═══════════════════════════════════════ - - namespace Infrastructure { - class UserRepositoryImpl { - <> - -UserJpaRepository userJpaRepository - +save(User user) User - +findById(UserId userId) Optional~User~ - +existsById(UserId userId) boolean - -toEntity(User user) UserJpaEntity - -toDomain(UserJpaEntity entity) User - } - - class UserJpaRepository { - <> - +findByUserId(String userId) Optional~UserJpaEntity~ - +existsByUserId(String userId) boolean - } - - class UserJpaEntity { - <> - -Long id - -String userId - -String encodedPassword - -String username - -LocalDate birthday - -String email - -LocalDateTime createdAt - } - - class Sha256PasswordEncoder { - <> - +encrypt(String rawPassword) String - +matches(String rawPassword, String encodedPassword) boolean - -generateSalt() String - -sha256(String input) String - } - - class JpaRepository_T_ID_ { - <> - <> - } + class AuthenticationUseCase { + <> + +authenticate(UserId, String) void } - %% ═══════════════════════════════════════ - %% Error Handling (Cross-cutting) - %% ═══════════════════════════════════════ + class UserQueryUseCase { + <> + +getUserInfo(UserId) UserInfoResponse + } - namespace ErrorHandling { - class CoreException { - -ErrorType errorType - -String customMessage - +CoreException(ErrorType) - +CoreException(ErrorType, String) - +getErrorType() ErrorType - } - - class ErrorType { - <> - INTERNAL_ERROR - BAD_REQUEST - NOT_FOUND - CONFLICT - -HttpStatus status - -String code - -String message - } + class PasswordUpdateUseCase { + <> + +updatePassword(UserId, String, String) void } - %% ═══════════════════════════════════════ - %% 관계 정의 (Relationships) - %% ═══════════════════════════════════════ + class UserService { + <> + -UserRepository userRepository + -PasswordEncoder passwordEncoder + +register(String, String, String, LocalDate, String) void + +authenticate(UserId, String) void + +getUserInfo(UserId) UserInfoResponse + +updatePassword(UserId, String, String) void + -findUser(UserId) User + -maskName(String) String + } - %% --- Interfaces → Application (의존) --- + %% --- 의존 (Dependency): Controller → UseCase --- UserController ..> RegisterUseCase : «uses» UserController ..> AuthenticationUseCase : «uses» UserController ..> UserQueryUseCase : «uses» UserController ..> PasswordUpdateUseCase : «uses» - UserController ..> UserRegisterRequest : «uses» - UserController ..> PasswordUpdateRequest : «uses» - UserController ..> UserInfoResponse : «creates» - %% --- Application: 실체화 (Realization) --- + %% --- 의존 (Dependency): Controller → DTO --- + UserController ..> UserRegisterRequest + UserController ..> PasswordUpdateRequest + UserController ..> UserInfoResponse + + %% --- 실체화 (Realization): Service ── UseCase --- UserService ..|> RegisterUseCase : «implements» UserService ..|> AuthenticationUseCase : «implements» UserService ..|> UserQueryUseCase : «implements» UserService ..|> PasswordUpdateUseCase : «implements» + %% --- DTO 변환 --- + UserInfoResponse ..> UserQueryUseCase : from() 변환 +``` + +### Part B. Domain ← Infrastructure (핵심 도메인 + 어댑터) + +> Domain의 포트(interface)를 Infrastructure가 **실체화(Realization)** 합니다. User 애그리거트는 Value Object를 **합성(Composition)** 합니다. + +```mermaid +classDiagram + direction TB + + %% ═══════════════════════════════════════ + %% Application (연결점) + %% ═══════════════════════════════════════ + + class UserService { + <> + -UserRepository userRepository + -PasswordEncoder passwordEncoder + } + + %% ═══════════════════════════════════════ + %% Domain Layer + %% ═══════════════════════════════════════ + + class User { + <> + -Long id + -UserId userId + -UserName userName + -String encodedPassword + -Birthday birth + -Email email + -WrongPasswordCount wrongPasswordCount + -LocalDateTime createdAt + +register(...)$ User + +reconstitute(...)$ User + +matchesPassword(Password, PasswordMatchChecker) boolean + +changePassword(String) User + } + + class PasswordMatchChecker { + <> + <> + +matches(String, String) boolean + } + + class UserId { + <> + -String value + +of(String)$ UserId + } + + class UserName { + <> + -String value + +of(String)$ UserName + } + + class Password { + <> + -String value + +of(String, LocalDate)$ Password + } + + class Email { + <> + -String value + +of(String)$ Email + } + + class Birthday { + <> + -LocalDate value + +of(LocalDate)$ Birthday + } + + class WrongPasswordCount { + <> + -int value + +init()$ WrongPasswordCount + +of(int)$ WrongPasswordCount + +increment() WrongPasswordCount + +reset() WrongPasswordCount + +isLocked() boolean + } + + class UserRepository { + <> + <> + +save(User) User + +findById(UserId) Optional~User~ + +existsById(UserId) boolean + } + + class PasswordEncoder { + <> + <> + +encrypt(String) String + +matches(String, String) boolean + } + + %% ═══════════════════════════════════════ + %% Infrastructure Layer + %% ═══════════════════════════════════════ + + class UserRepositoryImpl { + <> + -UserJpaRepository userJpaRepository + +save(User) User + +findById(UserId) Optional~User~ + +existsById(UserId) boolean + -toEntity(User) UserJpaEntity + -toDomain(UserJpaEntity) User + } + + class UserJpaRepository { + <> + <> + +findByUserId(String) Optional~UserJpaEntity~ + +existsByUserId(String) boolean + } + + class UserJpaEntity { + <> + -Long id + -String userId + -String encodedPassword + -String username + -LocalDate birthday + -String email + -LocalDateTime createdAt + } + + class JpaRepository~T_ID~ { + <> + <> + } + + class Sha256PasswordEncoder { + <> + +encrypt(String) String + +matches(String, String) boolean + -generateSalt() String + -sha256(String) String + } + %% --- Application → Domain (연관) --- UserService --> UserRepository : -userRepository UserService --> PasswordEncoder : -passwordEncoder UserService ..> User : «uses» - %% --- Domain: 합성 (Composition) - 생명주기 종속 --- + %% --- 합성 (Composition): User ◆── Value Objects --- User *-- "1" UserId : -userId User *-- "1" UserName : -userName User *-- "1" Birthday : -birth User *-- "1" Email : -email User *-- "1" WrongPasswordCount : -wrongPasswordCount - %% --- Domain: 의존 (Dependency) - 메서드에서만 사용 --- - User ..> Password : register/updatePassword 시 검증용 - User --> PasswordMatchChecker : matchesPassword() + %% --- 의존 (Dependency): 메서드에서만 사용 --- + User ..> Password : 생성/변경 시 검증 + User ..> PasswordMatchChecker : matchesPassword() - %% --- Infrastructure: 실체화 (Realization) --- + %% --- 실체화 (Realization): Infrastructure → Domain Port --- UserRepositoryImpl ..|> UserRepository : «implements» Sha256PasswordEncoder ..|> PasswordEncoder : «implements» - %% --- Infrastructure: 일반화 (Generalization) --- - UserJpaRepository --|> JpaRepository_T_ID_ : «extends» + %% --- 일반화 (Generalization): JPA 상속 --- + UserJpaRepository --|> JpaRepository~T_ID~ : «extends» - %% --- Infrastructure: 연관/의존 --- - UserRepositoryImpl --> UserJpaRepository : -userJpaRepository + %% --- 연관/의존: Infrastructure 내부 --- + UserRepositoryImpl --> "1" UserJpaRepository : -userJpaRepository UserRepositoryImpl ..> UserJpaEntity : toEntity() / toDomain() - UserRepositoryImpl ..> User : 도메인 변환 - - %% --- Error Handling --- - CoreException --> ErrorType : -errorType - CoreException --|> RuntimeException : «extends» - GlobalExceptionHandler ..> CoreException : «handles» - - %% --- DTO 변환 --- - UserInfoResponse ..> UserQueryUseCase : from() 변환 ``` --- From 5b95ddb3239cba7211c4ae1a455dc10b711cd004 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=84=EC=9D=B8=EA=B5=AD?= Date: Fri, 13 Feb 2026 00:42:56 +0900 Subject: [PATCH 8/8] =?UTF-8?q?=E2=8F=BA=20refactor:=20class-diagram=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .docs/design/03-class-diagram.md | 709 +++++++++++-------------------- .docs/design/04-erd.md | 110 +++-- 2 files changed, 334 insertions(+), 485 deletions(-) diff --git a/.docs/design/03-class-diagram.md b/.docs/design/03-class-diagram.md index 05a10fac..34c77d89 100644 --- a/.docs/design/03-class-diagram.md +++ b/.docs/design/03-class-diagram.md @@ -1,14 +1,20 @@ -# 6. 도메인 객체 설계 (Class Diagram) +# 헥사고날 아키텍처 - User 도메인 설계 + +## 아키텍처 개요 클린 아키텍처 기반으로 **도메인 계층이 어떤 외부 기술에도 의존하지 않도록** 설계했습니다. +```mermaid +graph LR + I[Interfaces] --> A[Application] --> D[Domain] + I_F[Infrastructure] -.-> D + style D fill:#fffde7,stroke:#fdd835,color:black + style A fill:#e8f5e9,stroke:#43a047,color:black + style I fill:#e3f2fd,stroke:#1e88e5,color:black + style I_F fill:#ede7f6,stroke:#5e35b1,color:black ``` -Interfaces → Application → Domain ← Infrastructure -``` - -> UML 표기법 참고: [UML 클래스 다이어그램](https://djcho.github.io/etc/etc-uml-classdiagram/) -### UML 관계 범례 +## UML 관계 범례 | 관계 | Mermaid 표기 | 설명 | |---|---|---| @@ -19,22 +25,15 @@ Interfaces → Application → Domain ← Infrastructure | 합성(Composition) | `*--` 채워진 다이아몬드 | 강한 소유 (생명주기 종속) | | 집합(Aggregation) | `o--` 빈 다이아몬드 | 약한 소유 (독립 생명주기) | -### 접근 제어자 - -| 기호 | 접근 제어자 | -|---|---| -| `+` | public | -| `-` | private | -| `#` | protected | -| `~` | package-private | - --- -## 6-1. 전체 아키텍처 클래스 다이어그램 +## 전체 아키텍처 클래스 다이어그램 > 다이어그램이 크므로 **상위 레이어**(Interfaces → Application)와 **하위 레이어**(Domain ← Infrastructure)로 나눠서 보여줍니다. -### Part A. Interfaces → Application (요청 흐름) +--- + +## Part A. Interfaces → Application (요청 흐름) > Controller가 UseCase 인터페이스에 의존하고, UserService가 이를 **실체화(Realization)** 합니다. @@ -45,18 +44,16 @@ classDiagram %% ═══════════════════════════════════════ %% Interfaces Layer %% ═══════════════════════════════════════ - class UserController { <> -RegisterUseCase registerUseCase -AuthenticationUseCase authenticationUseCase -UserQueryUseCase userQueryUseCase -PasswordUpdateUseCase passwordUpdateUseCase - +register(UserRegisterRequest) ResponseEntity~Void~ - +getMyInfo(String, String) ResponseEntity~UserInfoResponse~ - +updatePassword(String, String, PasswordUpdateRequest) ResponseEntity~Void~ + +register(UserRegisterRequest) ResponseEntity + +getMyInfo(String, String) ResponseEntity + +updatePassword(String, String, PasswordUpdateRequest) ResponseEntity } - class UserRegisterRequest { <> -String loginId @@ -65,16 +62,14 @@ classDiagram -LocalDate birthday -String email } - class UserInfoResponse { <> -String loginId -String name -String birthday -String email - +from(UserQueryUseCase.UserInfoResponse)$ UserInfoResponse + +from(UserQueryUseCase) UserInfoResponse$ } - class PasswordUpdateRequest { <> -String currentPassword @@ -84,27 +79,29 @@ classDiagram %% ═══════════════════════════════════════ %% Application Layer %% ═══════════════════════════════════════ - class RegisterUseCase { <> +register(String, String, String, LocalDate, String) void } - class AuthenticationUseCase { <> +authenticate(UserId, String) void } - class UserQueryUseCase { <> +getUserInfo(UserId) UserInfoResponse } - + class UserQueryUseCase_UserInfoResponse { + <> + -String loginId + -String maskedName + -LocalDate birthday + -String email + } class PasswordUpdateUseCase { <> +updatePassword(UserId, String, String) void } - class UserService { <> -UserRepository userRepository @@ -118,27 +115,64 @@ classDiagram } %% --- 의존 (Dependency): Controller → UseCase --- - UserController ..> RegisterUseCase : «uses» - UserController ..> AuthenticationUseCase : «uses» - UserController ..> UserQueryUseCase : «uses» - UserController ..> PasswordUpdateUseCase : «uses» + UserController ..> RegisterUseCase : uses + UserController ..> AuthenticationUseCase : uses + UserController ..> UserQueryUseCase : uses + UserController ..> PasswordUpdateUseCase : uses %% --- 의존 (Dependency): Controller → DTO --- UserController ..> UserRegisterRequest UserController ..> PasswordUpdateRequest UserController ..> UserInfoResponse - %% --- 실체화 (Realization): Service ── UseCase --- - UserService ..|> RegisterUseCase : «implements» - UserService ..|> AuthenticationUseCase : «implements» - UserService ..|> UserQueryUseCase : «implements» - UserService ..|> PasswordUpdateUseCase : «implements» + %% --- 실체화 (Realization): Service → UseCase --- + UserService ..|> RegisterUseCase : implements + UserService ..|> AuthenticationUseCase : implements + UserService ..|> UserQueryUseCase : implements + UserService ..|> PasswordUpdateUseCase : implements + + %% --- inner record --- + UserQueryUseCase *-- UserQueryUseCase_UserInfoResponse : inner record %% --- DTO 변환 --- - UserInfoResponse ..> UserQueryUseCase : from() 변환 + UserInfoResponse ..> UserQueryUseCase_UserInfoResponse : from() + + %% ═══════════════════════════════════════ + %% Styling + %% ═══════════════════════════════════════ + style UserController fill:#e3f2fd,stroke:#1e88e5,stroke-width:2px,color:#000 + style UserRegisterRequest fill:#fffde7,stroke:#fbc02d,stroke-width:1px,color:#000 + style UserInfoResponse fill:#fffde7,stroke:#fbc02d,stroke-width:1px,color:#000 + style PasswordUpdateRequest fill:#fffde7,stroke:#fbc02d,stroke-width:1px,color:#000 + + style RegisterUseCase fill:#e8f5e9,stroke:#43a047,stroke-width:2px,color:#000 + style AuthenticationUseCase fill:#e8f5e9,stroke:#43a047,stroke-width:2px,color:#000 + style UserQueryUseCase fill:#e8f5e9,stroke:#43a047,stroke-width:2px,color:#000 + style UserQueryUseCase_UserInfoResponse fill:#e8f5e9,stroke:#43a047,stroke-width:1px,color:#000 + style PasswordUpdateUseCase fill:#e8f5e9,stroke:#43a047,stroke-width:2px,color:#000 + style UserService fill:#c8e6c9,stroke:#2e7d32,stroke-width:3px,color:#000 ``` -### Part B. Domain ← Infrastructure (핵심 도메인 + 어댑터) +### 이 다이어그램에서 봐야 할 포인트 + +- Controller는 `UserService`를 직접 알지 못한다. 4개의 UseCase 인터페이스만 의존하고, 구현체는 Spring이 주입한다. 이 경계가 무너지면 Controller 변경 시 Service 내부까지 영향이 번진다. +- UseCase 4개 분리는 ISP 적용이다. `register()`만 쓰는 곳에서 `updatePassword()`를 알 필요가 없다. 단, UserService 하나가 4개를 모두 구현하므로 **인터페이스는 분리되어 있지만 구현의 응집은 유지**된다. + +### 설계 의도 + +- **UseCase 인터페이스 분리 + Service 구현체 통합**: Controller는 자신이 사용하는 UseCase만 의존하고, 구현 코드의 중복(`findUser()`, `passwordEncoder` 등)은 UserService 한 곳에서 관리한다. +- `UserQueryUseCase` 안에 `UserInfoResponse` inner record를 두어, 반환 타입이 Application 레이어에서 정의된다. Interfaces 레이어의 DTO와 분리하여 레이어 간 결합을 끊는다. + +### 잠재 리스크 + +| 리스크 | 설명 | 선택지 | +|---|---|---| +| UserService 비대화 | 현재 4개 UseCase를 하나가 구현. 도메인이 커지면(주문, 좋아요 등) 메서드가 계속 늘어날 수 있음 | **A)** 도메인별 Service 분리 (OrderService, LikeService) **B)** 현재 User 도메인 내에서만 통합 유지하고, 다른 도메인은 별도 Service | +| Controller에서 직접 인증 호출 | `getMyInfo()`와 `updatePassword()`에서 `authenticationUseCase.authenticate()`를 직접 호출. 인증 로직이 Controller에 노출됨 | **A)** 현행 유지 — 단순하고 명시적 **B)** Spring Interceptor/Filter로 인증을 분리하여 Controller는 비즈니스만 담당 | + +--- + +## Part B. Domain ← Infrastructure (핵심 도메인 + 어댑터) > Domain의 포트(interface)를 Infrastructure가 **실체화(Realization)** 합니다. User 애그리거트는 Value Object를 **합성(Composition)** 합니다. @@ -149,7 +183,6 @@ classDiagram %% ═══════════════════════════════════════ %% Application (연결점) %% ═══════════════════════════════════════ - class UserService { <> -UserRepository userRepository @@ -159,7 +192,6 @@ classDiagram %% ═══════════════════════════════════════ %% Domain Layer %% ═══════════════════════════════════════ - class User { <> -Long id @@ -170,66 +202,47 @@ classDiagram -Email email -WrongPasswordCount wrongPasswordCount -LocalDateTime createdAt - +register(...)$ User - +reconstitute(...)$ User + +register() User$ + +reconstitute() User$ +matchesPassword(Password, PasswordMatchChecker) boolean +changePassword(String) User } - class PasswordMatchChecker { <> <> +matches(String, String) boolean } - class UserId { <> -String value - +of(String)$ UserId } - class UserName { <> -String value - +of(String)$ UserName } - class Password { <> -String value - +of(String, LocalDate)$ Password } - class Email { <> -String value - +of(String)$ Email } - class Birthday { <> -LocalDate value - +of(LocalDate)$ Birthday } - class WrongPasswordCount { <> -int value - +init()$ WrongPasswordCount - +of(int)$ WrongPasswordCount - +increment() WrongPasswordCount - +reset() WrongPasswordCount - +isLocked() boolean } - class UserRepository { <> <> +save(User) User - +findById(UserId) Optional~User~ + +findById(UserId) User? +existsById(UserId) boolean } - class PasswordEncoder { <> <> @@ -240,24 +253,22 @@ classDiagram %% ═══════════════════════════════════════ %% Infrastructure Layer %% ═══════════════════════════════════════ - class UserRepositoryImpl { <> + <> -UserJpaRepository userJpaRepository +save(User) User - +findById(UserId) Optional~User~ + +findById(UserId) User? +existsById(UserId) boolean -toEntity(User) UserJpaEntity -toDomain(UserJpaEntity) User } - class UserJpaRepository { <> <> - +findByUserId(String) Optional~UserJpaEntity~ + +findByUserId(String) UserJpaEntity? +existsByUserId(String) boolean } - class UserJpaEntity { <> -Long id @@ -268,14 +279,13 @@ classDiagram -String email -LocalDateTime createdAt } - - class JpaRepository~T_ID~ { + class JpaRepositoryBase { <> <> } - class Sha256PasswordEncoder { <> + <> +encrypt(String) String +matches(String, String) boolean -generateSalt() String @@ -285,9 +295,9 @@ classDiagram %% --- Application → Domain (연관) --- UserService --> UserRepository : -userRepository UserService --> PasswordEncoder : -passwordEncoder - UserService ..> User : «uses» + UserService ..> User : uses - %% --- 합성 (Composition): User ◆── Value Objects --- + %% --- 합성 (Composition): User → Value Objects --- User *-- "1" UserId : -userId User *-- "1" UserName : -userName User *-- "1" Birthday : -birth @@ -299,20 +309,66 @@ classDiagram User ..> PasswordMatchChecker : matchesPassword() %% --- 실체화 (Realization): Infrastructure → Domain Port --- - UserRepositoryImpl ..|> UserRepository : «implements» - Sha256PasswordEncoder ..|> PasswordEncoder : «implements» + UserRepositoryImpl ..|> UserRepository : implements + Sha256PasswordEncoder ..|> PasswordEncoder : implements %% --- 일반화 (Generalization): JPA 상속 --- - UserJpaRepository --|> JpaRepository~T_ID~ : «extends» + UserJpaRepository --|> JpaRepositoryBase : extends %% --- 연관/의존: Infrastructure 내부 --- UserRepositoryImpl --> "1" UserJpaRepository : -userJpaRepository UserRepositoryImpl ..> UserJpaEntity : toEntity() / toDomain() + + %% ═══════════════════════════════════════ + %% Styling + %% ═══════════════════════════════════════ + + %% Application + style UserService fill:#c8e6c9,stroke:#2e7d32,stroke-width:3px,color:#000 + + %% Domain - Aggregate Root + style User fill:#ffecb3,stroke:#ff6f00,stroke-width:3px,color:#000 + + %% Domain - Value Objects + style UserId fill:#fff9c4,stroke:#fbc02d,stroke-width:1px,color:#000 + style UserName fill:#fff9c4,stroke:#fbc02d,stroke-width:1px,color:#000 + style Password fill:#fff9c4,stroke:#fbc02d,stroke-width:1px,color:#000 + style Email fill:#fff9c4,stroke:#fbc02d,stroke-width:1px,color:#000 + style Birthday fill:#fff9c4,stroke:#fbc02d,stroke-width:1px,color:#000 + style WrongPasswordCount fill:#fff9c4,stroke:#fbc02d,stroke-width:1px,color:#000 + + %% Domain - Ports + style UserRepository fill:#fffde7,stroke:#fdd835,stroke-width:2px,color:#000 + style PasswordEncoder fill:#fffde7,stroke:#fdd835,stroke-width:2px,color:#000 + style PasswordMatchChecker fill:#fffde7,stroke:#fdd835,stroke-width:2px,color:#000 + + %% Infrastructure - Adapters + style UserRepositoryImpl fill:#ede7f6,stroke:#5e35b1,stroke-width:2px,color:#000 + style Sha256PasswordEncoder fill:#ede7f6,stroke:#5e35b1,stroke-width:2px,color:#000 + + %% Infrastructure - JPA + style UserJpaRepository fill:#eeeeee,stroke:#9e9e9e,stroke-width:1px,color:#000 + style JpaRepositoryBase fill:#eeeeee,stroke:#9e9e9e,stroke-width:1px,color:#000 + style UserJpaEntity fill:#eeeeee,stroke:#9e9e9e,stroke-width:1px,color:#000 ``` +### 이 다이어그램에서 봐야 할 포인트 + +- 화살표 방향에 주목: `UserService → UserRepository(interface) ← UserRepositoryImpl`. Domain Port를 사이에 두고 Application과 Infrastructure가 **서로를 직접 모르는 구조**다. 이것이 의존성 역전(DIP)의 핵심이다. +- User가 6개의 Value Object를 합성(Composition)하고 있다. Value Object는 User 없이 독립 존재하지 않으므로 채워진 다이아몬드(`*--`)로 표현한다. +- `PasswordMatchChecker`는 `@FunctionalInterface`다. User 도메인이 암호화 구현을 모르면서도 비밀번호 매칭을 할 수 있게 하는 전략 패턴이다. + +### 잠재 리스크 + +| 리스크 | 설명 | 선택지 | +|---|---|---| +| 도메인 ↔ JPA 변환 비용 | `toEntity()` / `toDomain()`을 매번 호출. 엔티티가 복잡해지면 변환 로직 유지보수 부담 증가 | **A)** 현행 유지 — 도메인 순수성의 대가로 감수 **B)** MapStruct 등 매핑 라이브러리 도입 | +| WrongPasswordCount 영속 누락 | 도메인에는 존재하지만 DB에 저장하지 않아, `toDomain()` 시 항상 0으로 복원됨 | ERD 문서의 데이터 정합성 섹션 참고 | +| Value Object 검증이 앱 레벨에만 존재 | DB 레벨에는 `NOT NULL`과 `UNIQUE` 외에 검증 없음. 직접 SQL 실행 시 도메인 규칙 우회 가능 | **A)** 운영 DDL에 CHECK 제약 추가 **B)** DB는 저장소 역할에 한정하고, 앱 레벨 검증만으로 충분하다고 판단 | + --- -## 6-2. Value Objects 상세 다이어그램 +## Value Objects 상세 다이어그램 > User 애그리거트가 소유하는 값 객체들의 **합성(Composition)** 관계와 검증 규칙을 보여줍니다. @@ -330,8 +386,8 @@ classDiagram -Email email -WrongPasswordCount wrongPasswordCount -LocalDateTime createdAt - +register(...)$ User - +reconstitute(...)$ User + +register() User$ + +reconstitute() User$ +matchesPassword(Password, PasswordMatchChecker) boolean +changePassword(String encodedPassword) User } @@ -339,74 +395,68 @@ classDiagram class UserId { <> -String value - +of(String)$ UserId + +of(String) UserId$ } - note for UserId "정규식: ^[a-z0-9]{4,10}$\n4~10자, 영문 소문자+숫자" class UserName { <> -String value - +of(String)$ UserName + +of(String) UserName$ } - note for UserName "정규식: ^[a-zA-Z0-9가-힣]{2,20}$\n2~20자, 한글/영문/숫자" class Password { <> -String value - -DateTimeFormatter FMT_YYYYMMDD$ - -DateTimeFormatter FMT_YYMMDD$ - -DateTimeFormatter FMT_MMDD$ - +of(String, LocalDate)$ Password - -containsBirthday(String, LocalDate)$ boolean + +of(String, LocalDate) Password$ } - note for Password "8~16자, 영문+숫자+특수문자\n생년월일 패턴 포함 불가" class Email { <> -String value - +of(String)$ Email + +of(String) Email$ } - note for Email "이메일 형식 정규식 검증" class Birthday { <> -LocalDate value - +of(LocalDate)$ Birthday + +of(LocalDate) Birthday$ } - note for Birthday "미래 날짜 불가\n1900-01-01 이후" class WrongPasswordCount { <> -int value - +init()$ WrongPasswordCount - +of(int)$ WrongPasswordCount + +init() WrongPasswordCount$ +increment() WrongPasswordCount +reset() WrongPasswordCount - +isLocked() boolean } - note for WrongPasswordCount "0 이상, 5회 이상 → 잠금\n불변: increment/reset → 새 인스턴스" class PasswordMatchChecker { <> <> - +matches(String, String) boolean } - %% 합성 관계 (Composition) - 채워진 다이아몬드 - %% User가 소멸하면 Value Object도 소멸 + %% 합성 관계 (Composition) User *-- "1" UserId User *-- "1" UserName User *-- "1" Birthday User *-- "1" Email User *-- "1" WrongPasswordCount - %% 의존 관계 (Dependency) - 점선 화살표 - %% register/updatePassword 메서드에서만 참조 + %% 의존 관계 (Dependency) User ..> Password : 생성/변경 시 검증 - %% 연관 관계 (Association) - 실선 화살표 - %% matchesPassword() 파라미터 + %% 연관 관계 (Association) User ..> PasswordMatchChecker : matchesPassword()에서 사용 + + %% Styling + style User fill:#ffecb3,stroke:#ff6f00,stroke-width:3px,color:#000 + style UserId fill:#fff9c4,stroke:#fbc02d,stroke-width:1px,color:#000 + style UserName fill:#fff9c4,stroke:#fbc02d,stroke-width:1px,color:#000 + style Password fill:#fff9c4,stroke:#fbc02d,stroke-width:1px,color:#000 + style Email fill:#fff9c4,stroke:#fbc02d,stroke-width:1px,color:#000 + style Birthday fill:#fff9c4,stroke:#fbc02d,stroke-width:1px,color:#000 + style WrongPasswordCount fill:#fff9c4,stroke:#fbc02d,stroke-width:1px,color:#000 + style PasswordMatchChecker fill:#fffde7,stroke:#fdd835,stroke-width:2px,color:#000 ``` ### Value Object 검증 규칙 @@ -420,15 +470,9 @@ classDiagram | `Birthday` | not null, 미래 불가, 1900년 이후 | `생년월일은 미래 날짜일 수 없습니다.` | | `WrongPasswordCount` | 음수 불가, 5회 이상 잠금 | `비밀번호 오류 횟수는 음수일 수 없습니다.` | -### 설계 결정 - -- **`User.register()`**: id = null로 생성 (영속화 전 신규 객체) -- **`User.reconstitute()`**: DB에서 복원할 때 사용 (id 포함) -- **`User.changePassword()`**: 새로운 User 인스턴스 반환 (불변성 유지) - --- -## 6-3. Infrastructure 계층 상세 +## Infrastructure 계층 상세 > 도메인 인터페이스를 **실체화(Realization)** 하는 인프라 어댑터와 JPA 엔티티 매핑을 보여줍니다. @@ -436,15 +480,13 @@ classDiagram classDiagram direction TB - %% Domain Interfaces (Port) class UserRepository { <> <> +save(User) User - +findById(UserId) Optional~User~ + +findById(UserId) User? +existsById(UserId) boolean } - class PasswordEncoder { <> <> @@ -452,18 +494,16 @@ classDiagram +matches(String, String) boolean } - %% Infrastructure Adapters class UserRepositoryImpl { <> <> -UserJpaRepository userJpaRepository +save(User) User - +findById(UserId) Optional~User~ + +findById(UserId) User? +existsById(UserId) boolean -toEntity(User) UserJpaEntity -toDomain(UserJpaEntity) User } - class Sha256PasswordEncoder { <> <> @@ -473,23 +513,16 @@ classDiagram -sha256(String) String } - %% JPA class UserJpaRepository { <> <> - +findByUserId(String) Optional~UserJpaEntity~ + +findByUserId(String) UserJpaEntity? +existsByUserId(String) boolean } - - class JpaRepository~T_ID~ { + class JpaRepositoryBase { <> <> - +save(T) T - +findById(ID) Optional~T~ - +existsById(ID) boolean - +deleteById(ID) void } - class UserJpaEntity { <> -Long id @@ -499,38 +532,51 @@ classDiagram -LocalDate birthday -String email -LocalDateTime createdAt - +UserJpaEntity(String, String, String, LocalDate, String, LocalDateTime) } - %% Domain Model (참조용) class User { <> } %% === 관계 === + %% 실체화 (Realization) + UserRepositoryImpl ..|> UserRepository : implements + Sha256PasswordEncoder ..|> PasswordEncoder : implements - %% 실체화 (Realization): 점선 + 빈 삼각형 - UserRepositoryImpl ..|> UserRepository : «implements» - Sha256PasswordEncoder ..|> PasswordEncoder : «implements» - - %% 일반화 (Generalization): 실선 + 빈 삼각형 - UserJpaRepository --|> JpaRepository~T_ID~ : «extends» + %% 일반화 (Generalization) + UserJpaRepository --|> JpaRepositoryBase : extends - %% 연관 (Association): 필드 참조 + %% 연관 (Association) UserRepositoryImpl --> "1" UserJpaRepository : -userJpaRepository - %% 의존 (Dependency): 메서드에서 변환 시 사용 + %% 의존 (Dependency) UserRepositoryImpl ..> UserJpaEntity : toEntity() / toDomain() UserRepositoryImpl ..> User : 도메인 모델 변환 + + %% Styling + style UserRepository fill:#fffde7,stroke:#fdd835,stroke-width:2px,color:#000 + style PasswordEncoder fill:#fffde7,stroke:#fdd835,stroke-width:2px,color:#000 + style UserRepositoryImpl fill:#ede7f6,stroke:#5e35b1,stroke-width:2px,color:#000 + style Sha256PasswordEncoder fill:#ede7f6,stroke:#5e35b1,stroke-width:2px,color:#000 + style UserJpaRepository fill:#eeeeee,stroke:#9e9e9e,stroke-width:1px,color:#000 + style JpaRepositoryBase fill:#eeeeee,stroke:#9e9e9e,stroke-width:1px,color:#000 + style UserJpaEntity fill:#eeeeee,stroke:#9e9e9e,stroke-width:1px,color:#000 + style User fill:#ffecb3,stroke:#ff6f00,stroke-width:3px,color:#000 ``` -**변환 흐름**: `User` → `toEntity()` → `UserJpaEntity` → JPA save → `toDomain()` → `User` +### Entity Mapping + +```java +// Domain → Persistence +UserRepositoryImpl.toEntity(User) → UserJpaEntity -**암호화 형식**: `salt:hash` (SHA-256 + 16byte Base64 Salt) +// Persistence → Domain +UserRepositoryImpl.toDomain(UserJpaEntity) → User +``` --- -## 6-4. 에러 처리 다이어그램 +## 에러 처리 다이어그램 ```mermaid classDiagram @@ -538,13 +584,12 @@ classDiagram class GlobalExceptionHandler { <> - +handleCoreException(CoreException) ResponseEntity~Map~ - +handleIllegalArgumentException(IllegalArgumentException) ResponseEntity~Map~ - +handleValidationException(MethodArgumentNotValidException) ResponseEntity~Map~ - +handleMissingHeaderException(MissingRequestHeaderException) ResponseEntity~Map~ - +handleException(Exception) ResponseEntity~Map~ + +handleCoreException(CoreException) ResponseEntity + +handleIllegalArgumentException(IllegalArgumentException) ResponseEntity + +handleValidationException(MethodArgumentNotValidException) ResponseEntity + +handleMissingHeaderException(MissingRequestHeaderException) ResponseEntity + +handleException(Exception) ResponseEntity } - class CoreException { -ErrorType errorType -String customMessage @@ -553,11 +598,6 @@ classDiagram +getErrorType() ErrorType +getCustomMessage() String } - - class RuntimeException { - <> - } - class ErrorType { <> INTERNAL_ERROR @@ -567,323 +607,92 @@ classDiagram -HttpStatus status -String code -String message - +getStatus() HttpStatus - +getCode() String - +getMessage() String + } + class RuntimeException { + <> } %% 일반화 (Generalization) - CoreException --|> RuntimeException : «extends» + CoreException --|> RuntimeException : extends - %% 합성 (Composition) - ErrorType은 CoreException에 종속 + %% 합성 (Composition) CoreException *-- "1" ErrorType : -errorType %% 의존 (Dependency) - 예외 핸들링 - GlobalExceptionHandler ..> CoreException : «catches» - GlobalExceptionHandler ..> IllegalArgumentException : «catches» + GlobalExceptionHandler ..> CoreException : catches + GlobalExceptionHandler ..> IllegalArgumentException : catches + GlobalExceptionHandler ..> Exception : catches + + %% Styling + style GlobalExceptionHandler fill:#ffebee,stroke:#e53935,stroke-width:2px,color:#000 + style CoreException fill:#ffcdd2,stroke:#c62828,stroke-width:2px,color:#000 + style ErrorType fill:#ef9a9a,stroke:#b71c1c,stroke-width:1px,color:#000 + style RuntimeException fill:#eeeeee,stroke:#9e9e9e,stroke-width:1px,color:#000 ``` -### 예외 매핑 테이블 - -| 예외 | HTTP 상태 | 발생 위치 | -|---|---|---| -| `CoreException` | ErrorType에 따름 | 명시적 도메인 예외 | -| `IllegalArgumentException` | 400 | Value Object 검증, Service 비즈니스 검증 | -| `MethodArgumentNotValidException` | 400 | DTO `@Valid` 검증 | -| `MissingRequestHeaderException` | 400 | 필수 헤더 누락 | -| `Exception` | 500 | 예상치 못한 서버 오류 | - --- -## 6-5. 의존성 방향 요약 +## 전체 아키텍처 요약 -``` -┌─────────────────────────────────────────────────────────────────────┐ -│ Interfaces (Controller, DTO) │ -│ └─ 의존 → UseCase «interface» (Application 계층) │ -│ 관계: 의존(Dependency) - 점선 화살표 │ -├─────────────────────────────────────────────────────────────────────┤ -│ Application (UseCase, UserService) │ -│ └─ 의존 → Domain «interface» (Repository, PasswordEncoder) │ -│ 관계: 실체화(Realization) - UseCase 구현 │ -│ 연관(Association) - Repository/Encoder 필드 참조 │ -├─────────────────────────────────────────────────────────────────────┤ -│ Domain (User, Value Objects, Interface) │ -│ └─ 외부 의존 없음 (순수 Java) │ -│ 관계: 합성(Composition) - User ↔ Value Objects │ -├─────────────────────────────────────────────────────────────────────┤ -│ Infrastructure (JPA, SHA-256) │ -│ └─ 의존 → Domain «interface»를 구현 │ -│ 관계: 실체화(Realization) - Domain Port 구현 │ -│ 일반화(Generalization) - JpaRepository 상속 │ -└─────────────────────────────────────────────────────────────────────┘ -``` - -### API 엔드포인트 - -| Method | Path | 인증 | UseCase | -|---|---|---|---| -| `POST` | `/api/v1/users/register` | 불필요 | `RegisterUseCase` | -| `GET` | `/api/v1/users/me` | `X-Loopers-LoginId`, `X-Loopers-LoginPw` | `UserQueryUseCase` + `AuthenticationUseCase` | -| `PUT` | `/api/v1/users/me/password` | `X-Loopers-LoginId`, `X-Loopers-LoginPw` | `PasswordUpdateUseCase` + `AuthenticationUseCase` | - ---- - -# 향후 확장 도메인 설계 (미래 목표) - -> `01-requirements.md`에 정의된 기능 요구사항 기반의 **미래 구현 목표**입니다. - -## 6-6. 전체 도메인 관계도 - -```mermaid -classDiagram - direction TB +### 전체 흐름도 - class User { - <> - } - class Brand { - <> - } - class Product { - <> - } - class Like { - <> - } - class Order { - <> - } - class OrderItem { - <> - } - class OrderSnapshot { - <> - } - - %% 연관 (Association) - 다중성 포함 - User "1" --> "*" Like : 좋아요 - User "1" --> "*" Order : 주문 - - %% 연관 (Association) - Brand "1" --> "*" Product : 보유 상품 - Product "1" --> "*" Like : 좋아요 대상 - Product "1" --> "*" OrderItem : 주문 항목 - - %% 합성 (Composition) - 생명주기 종속 - Order "1" *-- "1..*" OrderItem : 주문 상세 - Order "1" *-- "1" OrderSnapshot : 주문 시점 스냅샷 ``` - ---- - -## 6-7. Brand 도메인 - -```mermaid -classDiagram - class Brand { - <> - -Long id - -BrandName name - -String description - -LocalDateTime createdAt - +register(name, description)$ Brand - +reconstitute(...)$ Brand - +update(BrandName, String) Brand - } - class BrandName { - <> - -String value - +of(String)$ BrandName - } - - Brand *-- "1" BrandName : -name +┌─────────────────────────────────────┐ +│ Interface Layer │ +│ (UserController, DTOs) │ ← REST API 엔드포인트 +├─────────────────────────────────────┤ +│ Application Layer │ +│ (UseCases, UserService) │ ← 비즈니스 로직 +├─────────────────────────────────────┤ +│ Domain Layer │ +│ (User, Value Objects, Ports) │ ← 핵심 도메인 +├─────────────────────────────────────┤ +│ Infrastructure Layer │ +│ (Adapters) │ ← 기술 구현 +├─────────────────────────────────────┤ +│ Persistence Layer │ +│ (JPA, Entity) │ ← 데이터베이스 +└─────────────────────────────────────┘ ``` -| Role | Method | Path | UseCase | -|---|---|---|---| -| Any | `GET` | `/api/v1/brands/{brandId}` | `BrandQueryUseCase` | -| Admin | `GET` | `/api-admin/v1/brands` | `AdminBrandUseCase` | -| Admin | `POST` | `/api-admin/v1/brands` | `AdminBrandUseCase` | -| Admin | `PUT` | `/api-admin/v1/brands/{id}` | `AdminBrandUseCase` | -| Admin | `DELETE` | `/api-admin/v1/brands/{id}` | `AdminBrandUseCase` (하위 상품 Cascade) | +### 요청 처리 흐름 예시 ---- +1. **HTTP Request** → `UserController.register()` +2. **Controller** → `RegisterUseCase.register()` 호출 +3. **UseCase** → `UserService.register()` 실행 +4. **Service** → `User.register()` (도메인 로직) +5. **Service** → `UserRepository.save()` 호출 (Domain Port) +6. **Repository** → `UserRepositoryImpl.save()` 실행 (Adapter) +7. **Adapter** → `UserJpaRepository.save()` 실행 (JPA) +8. **JPA** → 데이터베이스에 저장 +9. 역순으로 응답 반환 -## 6-8. Product 도메인 +### 의존성 방향 -```mermaid -classDiagram - class Product { - <> - -Long id - -Long brandId - -ProductName name - -Money price - -StockQuantity stockQuantity - -String description - -List~ProductImage~ images - -int likeCount - +register(...)$ Product - +decreaseStock(int) Product - +isOutOfStock() boolean - } - class ProductName { - <> - -String value - +of(String)$ ProductName - } - class Money { - <> - -int value - +of(int)$ Money - } - class StockQuantity { - <> - -int value - +decrease(int) StockQuantity - +isZero() boolean - } - class ProductImage { - <> - -String url - -int sortOrder - } - - Product *-- "1" ProductName : -name - Product *-- "1" Money : -price - Product *-- "1" StockQuantity : -stockQuantity - Product *-- "0..*" ProductImage : -images ``` - -| Role | Method | Path | -|---|---|---| -| Any | `GET` | `/api/v1/products?brandId=&sort=&page=&size=` | -| Any | `GET` | `/api/v1/products/{productId}` | -| Admin | `POST` | `/api-admin/v1/products` | -| Admin | `PUT` | `/api-admin/v1/products/{id}` | -| Admin | `DELETE` | `/api-admin/v1/products/{id}` | - -정렬: `latest` (기본) | `price_asc` | `likes_desc` - ---- - -## 6-9. Like 도메인 - -```mermaid -classDiagram - class Like { - <> - -Long id - -UserId userId - -Long productId - -LocalDateTime createdAt - +create(userId, productId)$ Like - } - class LikeRepository { - <> - +save(Like) Like - +delete(UserId, Long) void - +existsByUserIdAndProductId(UserId, Long) boolean - } - - Like ..> UserId : -userId - LikeRepository ..> Like : «manages» -``` - -- **멱등성**: 이미 좋아요한 상품에 다시 좋아요 → 예외 없이 무시 -- **유저당 1상품 1좋아요**: `UNIQUE(user_id, product_id)` 제약 -- **좋아요 수 동기화**: `Like` 생성/삭제 시 `Product.likeCount` 증감 - -| Role | Method | Path | -|---|---|---| -| User | `POST` | `/api/v1/products/{id}/likes` | -| User | `DELETE` | `/api/v1/products/{id}/likes` | -| User | `GET` | `/api/v1/users/me/likes` | - ---- - -## 6-10. Order 도메인 - -```mermaid -classDiagram - class Order { - <> - -Long id - -UserId userId - -Money totalAmount - -Money discountAmount - -Money paymentAmount - -OrderStatus status - +create(...)$ Order - +cancel() Order - +isCancellable() boolean - } - class OrderItem { - <> - -Long productId - -int quantity - -Money price - } - class OrderStatus { - <> - PAYMENT_COMPLETED - PREPARING - SHIPPING - DELIVERED - CANCELLED - } - class OrderSnapshot { - <> - -String snapshotData - +capture(...)$ OrderSnapshot - } - class ShippingInfo { - <> - -String address - -String receiverName - -String receiverPhone - } - class PaymentMethod { - <> - -String type - -String detail - } - - %% 합성 (Composition) - Order 소멸 시 함께 소멸 - Order *-- "1..*" OrderItem : 주문 상세 - Order *-- "1" OrderSnapshot : 주문 시점 스냅샷 - Order *-- "1" ShippingInfo : 배송 정보 - Order *-- "1" PaymentMethod : 결제 수단 - - %% 연관 (Association) - enum 참조 - Order --> OrderStatus : -status +Interface → Application → Domain ← Infrastructure ← Persistence + ↑ ↓ + └──────────────┘ + (의존성 역전) ``` -**주문 생성 프로세스**: 재고 확인 → 재고 차감 → 결제 금액 검증 → 스냅샷 생성 → 주문 생성 - -| 상태 | 주문 취소 | 배송지 변경 | -|---|---|---| -| `PAYMENT_COMPLETED` | 가능 | 가능 | -| `PREPARING` | 가능 | 가능 | -| `SHIPPING` | 불가 | 불가 | -| `DELIVERED` | 불가 | 불가 | - -| Role | Method | Path | -|---|---|---| -| User | `POST` | `/api/v1/orders` | -| User | `GET` | `/api/v1/orders/me` | -| User | `GET` | `/api/v1/orders/{id}` | -| Admin | `GET` | `/api-admin/v1/orders` | - ---- - -## 6-11. Admin 인증 - -관리자 API는 `X-Loopers-Ldap` 헤더로 권한 검증합니다. - -| 규칙 | 설명 | -|---|---| -| Admin 인증 | `X-Loopers-Ldap: loopers.admin` 헤더 필수 | -| User 접근 차단 | `/api-admin/**` 호출 시 403 Forbidden | -| 타 유저 접근 차단 | 유저는 자신의 정보만 조회 가능 | +### 핵심 원칙 + +1. ✅ **도메인 독립성**: Domain은 외부 기술에 의존하지 않음 +2. ✅ **의존성 역전**: Infrastructure가 Domain을 구현 +3. ✅ **Port & Adapter**: 인터페이스(Port)와 구현(Adapter) 분리 +4. ✅ **불변성**: Value Object는 모두 불변 +5. ✅ **응집도**: 관련된 로직은 한 곳에 모음 +6. ✅ **테스트 용이성**: 각 레이어를 독립적으로 테스트 가능 + +### 레이어별 색상 가이드 + +| 레이어 | 색상 | 설명 | +|--------|------|------| +| Interface | 🔵 파란색 | REST API, DTOs | +| Application | 🟢 초록색 | UseCases, Service | +| Domain (Aggregate) | 🟠 주황색 | User (Aggregate Root) | +| Domain (Value Object) | 🟡 노란색 | 불변 값 객체들 | +| Domain (Port) | 🟡 진한 노란색 | 인터페이스 | +| Infrastructure | 🟣 보라색 | Adapter 구현체 | +| Persistence | ⚪ 회색 | JPA, Entity | \ No newline at end of file diff --git a/.docs/design/04-erd.md b/.docs/design/04-erd.md index 234b28f1..e39cf7d0 100644 --- a/.docs/design/04-erd.md +++ b/.docs/design/04-erd.md @@ -23,6 +23,11 @@ erDiagram } ``` +### 이 ERD에서 봐야 할 포인트 + +- `users` 테이블은 현재 유일하게 구현된 테이블이며, **도메인 검증은 전부 애플리케이션 레벨**에서 처리한다. DB 제약조건은 `NOT NULL`과 `UNIQUE` 정도만 걸려 있다. +- `WrongPasswordCount`는 DB에 저장하지 않는 설계 결정이 있다 — 이 트레이드오프가 어떤 의미인지 7-3에서 다룬다. + --- ## 7-2. 테이블 상세 명세 @@ -89,6 +94,25 @@ WrongPasswordCount → (DB에 미저장, 도메인 전용) - **`WrongPasswordCount`**: 현재 DB에 컬럼이 없으며, 복원 시 항상 `WrongPasswordCount.init()` (0)으로 초기화됩니다. - **`updated_at` / `deleted_at`**: `BaseEntity`에 정의되어 있지만 `UserJpaEntity`는 상속하지 않아 해당 컬럼이 없습니다. +### 데이터 정합성 검토 + +**현재 구현 (users)** + +| 항목 | 현재 상태 | 리스크 | +|---|---|---| +| `email` UNIQUE 제약 없음 | 동일 이메일로 다중 가입 가능 | 이메일 기반 기능(비밀번호 찾기 등) 추가 시 정합성 깨짐 | +| `WrongPasswordCount` 미영속 | 서버 재시작/복원 시 항상 0으로 리셋 | 비밀번호 5회 오류 잠금 정책이 사실상 무력화됨 | +| `birthday` 검증이 앱 레벨에만 존재 | DB에는 어떤 날짜든 들어갈 수 있음 | 직접 SQL 삽입이나 마이그레이션 시 검증 우회 가능 | +| `updated_at` 컬럼 없음 | 비밀번호 변경 이력 추적 불가 | 감사(audit) 요구사항 발생 시 대응 어려움 | + +**선택지** + +- `email`에 UNIQUE 걸지 않은 건 의도적인 결정일 수 있다. 단, 향후 요구사항에 따라 `uk_users_email` 추가를 검토할 필요가 있다. +- `WrongPasswordCount`는 두 가지 방향이 있다: + - **A) DB 컬럼 추가** → 영속화하여 재시작에도 유지. 단순하지만 매번 UPDATE 발생. + - **B) Redis 캐시** → TTL 기반으로 일정 시간 후 자동 리셋. 별도 인프라 의존. +- `birthday` DB 레벨 제약은 `CHECK (birthday >= '1900-01-01' AND birthday <= CURDATE())`로 가능하지만, JPA `ddl-auto: create` 환경에서는 적용되지 않으므로 운영 DDL에서 관리해야 한다. + --- ## 7-4. BaseEntity 공통 컬럼 (향후 테이블 확장 시 적용) @@ -115,12 +139,11 @@ erDiagram ## 7-5. 향후 확장 ERD (미래 목표) -시퀀스 다이어그램 5-2 ~ 5-6에서 설계한 브랜드/상품/좋아요/쿠폰/주문 기능 구현 시 예상되는 테이블 구조입니다. +시퀀스 다이어그램 5-2 ~ 5-6에서 설계한 브랜드/상품/좋아요/주문 기능 구현 시 예상되는 테이블 구조입니다. ```mermaid erDiagram USERS ||--o{ ORDERS : "주문" - USERS ||--o{ USER_COUPONS : "보유 쿠폰" USERS ||--o{ LIKES : "좋아요" BRANDS ||--o{ PRODUCTS : "보유 상품" @@ -132,8 +155,6 @@ erDiagram ORDERS ||--o{ ORDER_ITEMS : "주문 상세" ORDERS ||--o| ORDER_SNAPSHOTS : "주문 스냅샷" - COUPONS ||--o{ USER_COUPONS : "발급 이력" - USERS { BIGINT id PK VARCHAR user_id UK @@ -146,7 +167,7 @@ erDiagram BRANDS { BIGINT id PK - VARCHAR name + VARCHAR name UK "NOT NULL" VARCHAR description DATETIME created_at DATETIME updated_at @@ -155,10 +176,11 @@ erDiagram PRODUCTS { BIGINT id PK - BIGINT brand_id FK - VARCHAR name - INT price - INT stock_quantity + BIGINT brand_id FK "NOT NULL" + VARCHAR name "NOT NULL" + INT price "NOT NULL, CHECK >= 0" + INT stock_quantity "NOT NULL, CHECK >= 0" + INT like_count "NOT NULL, DEFAULT 0" VARCHAR description DATETIME created_at DATETIME updated_at @@ -183,7 +205,6 @@ erDiagram ORDERS { BIGINT id PK BIGINT user_id FK - BIGINT coupon_id FK "NULL, 사용한 쿠폰" VARCHAR receiver_name "NOT NULL, 수령인" VARCHAR address "NOT NULL, 배송지" VARCHAR request "NULL, 배송 요청사항" @@ -199,38 +220,57 @@ erDiagram ORDER_ITEMS { BIGINT id PK - BIGINT order_id FK - BIGINT product_id FK - INT quantity - INT price + BIGINT order_id FK "NOT NULL, CASCADE DELETE" + BIGINT product_id FK "NOT NULL" + INT quantity "NOT NULL, CHECK > 0" + INT unit_price "NOT NULL, 주문 시점 단가" } ORDER_SNAPSHOTS { BIGINT id PK - BIGINT order_id FK - TEXT snapshot_data - DATETIME created_at + BIGINT order_id FK "NOT NULL, UNIQUE, CASCADE DELETE" + TEXT snapshot_data "NOT NULL, JSON" + DATETIME created_at "NOT NULL" } +``` - COUPONS { - BIGINT id PK - VARCHAR name - INT discount_amount - INT total_quantity - INT issued_quantity - DATETIME start_at - DATETIME end_at - DATETIME created_at - } +### 이 ERD에서 봐야 할 포인트 + +- `ORDERS`와 `ORDER_ITEMS`는 합성(Composition) 관계다. 주문이 삭제되면 주문 항목도 함께 사라져야 하므로 FK에 **CASCADE DELETE**를 명시했다. `ORDER_SNAPSHOTS`도 마찬가지. +- `LIKES`에 `UNIQUE(user_id, product_id)` 복합 유니크가 걸려 있다. 이것이 "유저당 1상품 1좋아요"를 DB 레벨에서 보장하는 핵심 제약이다. +- `ORDER_ITEMS.unit_price`는 주문 시점의 상품 단가를 스냅샷한다. `PRODUCTS.price`가 이후 변경되어도 주문 금액이 보존된다. `ORDER_SNAPSHOTS`와 함께 **주문 시점 불변성**을 이중으로 보장하는 구조다. +- `PRODUCTS.like_count`는 비정규화 컬럼이다. `COUNT(LIKES)`를 매번 조회하는 비용을 줄이기 위해 도입했지만, `LIKES` 테이블과의 **정합성 유지 책임**이 서비스 레이어에 생긴다. + +### 정합성을 위해 적용한 DB 제약조건 + +| 테이블 | 제약 | 보장하는 것 | +|---|---|---| +| `BRANDS.name` | `UNIQUE` | 브랜드 이름 중복 방지 | +| `PRODUCTS.price` | `CHECK >= 0` | 음수 가격 방지 | +| `PRODUCTS.stock_quantity` | `CHECK >= 0` | 재고 음수 방지 (동시성은 앱 레벨에서 추가 보장 필요) | +| `LIKES(user_id, product_id)` | `UNIQUE` | 유저당 1상품 1좋아요 | +| `ORDER_ITEMS.quantity` | `CHECK > 0` | 0개 주문 방지 | +| `ORDER_ITEMS.order_id` | `FK CASCADE DELETE` | 주문 삭제 시 항목 자동 삭제 | +| `ORDER_SNAPSHOTS.order_id` | `FK CASCADE DELETE`, `UNIQUE` | 주문당 1스냅샷, 주문 삭제 시 자동 삭제 | + +### 금액 정합성 규칙 - USER_COUPONS { - BIGINT id PK - BIGINT user_id FK - BIGINT coupon_id FK - BOOLEAN is_used - DATETIME issued_at - DATETIME used_at - } ``` +ORDERS.total_amount = SUM(ORDER_ITEMS.unit_price * ORDER_ITEMS.quantity) +ORDERS.payment_amount = ORDERS.total_amount - ORDERS.discount_amount +``` + +이 관계는 DB `CHECK` 제약으로 걸 수 없다 (cross-row 참조). 주문 생성 시 **서비스 레이어에서 계산하고 검증**해야 하며, 사후에 불일치가 발생하면 `ORDER_SNAPSHOTS`의 원본 데이터로 추적할 수 있다. + +### 데이터 정합성 리스크 (향후 구현 시 검토) + +| 항목 | 리스크 | 선택지 | +|---|---|---| +| `PRODUCTS.stock_quantity` 동시성 | `CHECK >= 0`만으로는 동시 주문 시 race condition 방지 불가 | **A)** `SELECT ... FOR UPDATE` 비관적 잠금 **B)** `UPDATE ... SET stock = stock - ? WHERE stock >= ?` 원자적 감소 | +| `PRODUCTS.like_count` ↔ `LIKES` 불일치 | 좋아요 생성/삭제 시 count 동기화가 깨질 수 있음 | **A)** 같은 트랜잭션에서 `LIKES` INSERT + `PRODUCTS.like_count` UPDATE **B)** 비동기 이벤트로 분리 (eventual consistency) | +| `PRODUCTS` Soft Delete + `ORDER_ITEMS` FK | 삭제된 상품의 주문 내역 조회 시 FK 참조 깨짐 | **A)** Soft Delete이므로 실제 삭제 안 됨 — 조회 시 `deleted_at IS NOT NULL` 필터링 **B)** `ORDER_SNAPSHOTS`에 상품 정보가 있으므로 FK 대신 스냅샷 활용 | +| `BRANDS` Soft Delete + `PRODUCTS.brand_id` FK | 브랜드 삭제 시 하위 상품 처리 정책 필요 | **A)** Cascade Soft Delete — 브랜드 삭제 시 상품도 함께 Soft Delete **B)** 브랜드에 상품이 남아있으면 삭제 차단 | +| `ORDERS.discount_amount` 근거 없음 | 쿠폰 제거 후 할인 금액의 산출 근거가 불명확 | 할인 정책이 없다면 컬럼 제거 검토. 있다면 `discount_type` 등으로 근거를 남겨야 함 | +| `ORDERS.status` VARCHAR | 문자열이라 오타/잘못된 값 입력 가능 | **A)** `ENUM` 타입 사용 — DB 레벨 보장, 마이그레이션 시 변경 어려움 **B)** VARCHAR 유지 + 앱 레벨 검증 — 유연하지만 DB 정합성 약함 | > 위 ERD는 미래 구현 목표이며, 실제 구현 시 도메인 설계에 따라 변경될 수 있습니다.